From e1fdb256734fd3dee287bf4492ee4954e7bb70b7 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 23 Aug 2019 02:48:59 +0000 Subject: [PATCH 001/981] Add grouping for GUI options --- lib/cli.py | 328 ++++++++++++--------- lib/config.py | 12 +- lib/gui/command.py | 13 +- lib/gui/options.py | 6 +- lib/gui/popup_configure.py | 13 +- plugins/train/_config.py | 73 ++--- plugins/train/model/dfl_sae_defaults.py | 41 +-- plugins/train/model/realface_defaults.py | 5 + plugins/train/model/unbalanced_defaults.py | 32 +- plugins/train/trainer/original_defaults.py | 8 + tools/cli.py | 104 ++++--- 11 files changed, 392 insertions(+), 243 deletions(-) diff --git a/lib/cli.py b/lib/cli.py index 1856d9d824..8db49b1905 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -392,6 +392,7 @@ def get_global_arguments(): "action": FileFullPaths, "filetypes": "ini", "type": str, + "group": "Global Options", "help": "Optionally overide the saved config with the path to a " "custom config file."}) global_args.append({"opts": ("-L", "--loglevel"), @@ -399,6 +400,7 @@ def get_global_arguments(): "dest": "loglevel", "default": "INFO", "choices": ("INFO", "VERBOSE", "DEBUG", "TRACE"), + "group": "Global Options", "help": "Log level. Stick with INFO or VERBOSE unless you need to " "file an error report. Be careful with TRACE as it will " "generate a lot of data"}) @@ -407,6 +409,7 @@ def get_global_arguments(): "filetypes": 'log', "type": str, "dest": "logfile", + "group": "Global Options", "help": "Path to store the logfile. Leave blank to store in the " "faceswap folder", "default": None}) @@ -436,7 +439,7 @@ def add_arguments(self): for option in options: args = option["opts"] kwargs = {key: option[key] - for key in option.keys() if key != "opts"} + for key in option.keys() if key not in ("opts", "group")} self.parser.add_argument(*args, **kwargs) def process_suppressions(self): @@ -473,6 +476,7 @@ def get_argument_list(): "filetypes": "video", "dest": "input_dir", "required": True, + "group": "Data", "help": "Input directory or video. Either a directory containing " "the image files you wish to process or path to a video " "file. NB: This should be the source video/frames NOT the " @@ -481,6 +485,7 @@ def get_argument_list(): "action": DirFullPaths, "dest": "output_dir", "required": True, + "group": "Data", "help": "Output directory. This is where the converted files will " "be saved."}) argument_list.append({"opts": ("-al", "--alignments"), @@ -488,44 +493,9 @@ def get_argument_list(): "filetypes": "alignments", "type": str, "dest": "alignments_path", + "group": "Data", "help": "Optional path to an alignments file. Leave blank if the " "alignments file is at the default location."}) - argument_list.append({"opts": ("-n", "--nfilter"), - "action": FilesFullPaths, - "filetypes": "image", - "dest": "nfilter", - "nargs": "+", - "default": None, - "help": "Optionally filter out people who you do not wish to " - "process by passing in an image of that person. Should be a " - "front portrait with a single person in the image. Multiple " - "images can be added space separated. NB: Using face filter " - "will significantly decrease extraction speed and its " - "accuracy cannot be guaranteed."}) - argument_list.append({"opts": ("-f", "--filter"), - "action": FilesFullPaths, - "filetypes": "image", - "dest": "filter", - "nargs": "+", - "default": None, - "help": "Optionally select people you wish to process by passing in " - "an image of that person. Should be a front portrait with a " - "single person in the image. Multiple images can be added " - "space separated. NB: Using face filter will significantly " - "decrease extraction speed and its accuracy cannot be " - "guaranteed."}) - argument_list.append({"opts": ("-l", "--ref_threshold"), - "action": Slider, - "min_max": (0.01, 0.99), - "rounding": 2, - "type": float, - "dest": "ref_threshold", - "default": 0.4, - "help": "For use with the optional nfilter/filter files. Threshold " - "for positive face recognition. Lower values are stricter. " - "NB: Using face filter will significantly decrease " - "extraction speed and its accuracy cannot be " - "guaranteed."}) return argument_list @@ -545,6 +515,7 @@ def get_optional_arguments(): "dest": "serializer", "default": "json", "choices": ("json", "pickle", "yaml"), + "group": "Data", "help": "Serializer for alignments file. If yaml is chosen and not " "available, then json will be used as the default " "fallback."}) @@ -567,6 +538,7 @@ def get_optional_arguments(): "type": str.lower, "choices": PluginLoader.get_available_extractors("detect"), "default": default_detector, + "group": "Plugins", "help": "R|Detector to use. Some of these have configurable settings in " "'/config/extract.ini' or 'Edit > Configure Extract Plugins':" "\nL|'cv2-dnn': A CPU only extractor, is the least reliable, but uses least " @@ -584,6 +556,7 @@ def get_optional_arguments(): "type": str.lower, "choices": PluginLoader.get_available_extractors("align"), "default": default_aligner, + "group": "Plugins", "help": "R|Aligner to use." "\nL|'cv2-dnn': A cpu only CNN based landmark detector. Faster, less " "resource intensive, but less accurate. Only use this if not using a gpu " @@ -596,6 +569,7 @@ def get_optional_arguments(): "dest": "normalization", "choices": ["none", "clahe", "hist", "mean"], "default": "none", + "group": "plugins", "help": "R|Performing normalization can help the aligner better " "align faces with difficult lighting conditions at an " "extraction speed cost. Different methods will yield " @@ -610,11 +584,51 @@ def get_optional_arguments(): "type": str, "dest": "rotate_images", "default": None, + "group": "plugins", "help": "If a face isn't found, rotate the images to try to find a " "face. Can find more faces at the cost of extraction speed. " "Pass in a single number to use increments of that size up " "to 360, or pass in a list of numbers to enumerate exactly " "what angles to check"}) + argument_list.append({"opts": ("-n", "--nfilter"), + "action": FilesFullPaths, + "filetypes": "image", + "dest": "nfilter", + "nargs": "+", + "default": None, + "group": "Face Processing", + "help": "Optionally filter out people who you do not wish to " + "process by passing in an image of that person. Should be a " + "front portrait with a single person in the image. Multiple " + "images can be added space separated. NB: Using face filter " + "will significantly decrease extraction speed and its " + "accuracy cannot be guaranteed."}) + argument_list.append({"opts": ("-f", "--filter"), + "action": FilesFullPaths, + "filetypes": "image", + "dest": "filter", + "nargs": "+", + "default": None, + "group": "Face Processing", + "help": "Optionally select people you wish to process by passing in " + "an image of that person. Should be a front portrait with a " + "single person in the image. Multiple images can be added " + "space separated. NB: Using face filter will significantly " + "decrease extraction speed and its accuracy cannot be " + "guaranteed."}) + argument_list.append({"opts": ("-l", "--ref_threshold"), + "action": Slider, + "min_max": (0.01, 0.99), + "rounding": 2, + "type": float, + "dest": "ref_threshold", + "default": 0.4, + "group": "Face Processing", + "help": "For use with the optional nfilter/filter files. Threshold " + "for positive face recognition. Lower values are stricter. " + "NB: Using face filter will significantly decrease " + "extraction speed and its accuracy cannot be " + "guaranteed."}) argument_list.append({"opts": ("-bt", "--blur-threshold"), "type": float, "action": Slider, @@ -622,6 +636,7 @@ def get_optional_arguments(): "rounding": 1, "dest": "blur_thresh", "default": 0.0, + "group": "Face Processing", "help": "Automatically discard images blurrier than the specified " "threshold. Discarded images are moved into a \"blurry\" " "sub-folder. Lower values allow more blur. Set to 0.0 to " @@ -630,6 +645,7 @@ def get_optional_arguments(): "action": "store_true", "default": False, "backend": "nvidia", + "help": "Don't run extraction in parallel. Will run detection first " "then alignment (2 passes). Useful if VRAM is at a " "premium."}) @@ -639,6 +655,7 @@ def get_optional_arguments(): "min_max": (128, 512), "default": 256, "rounding": 64, + "group": "output", "help": "The output size of extracted faces. Make sure that the " "model you intend to train supports your required size. " "This will only need to be changed for hi-res models."}) @@ -649,6 +666,7 @@ def get_optional_arguments(): "min_max": (0, 1080), "default": 0, "rounding": 20, + "group": "Face Processing", "help": "Filters out faces detected below this size. Length, in " "pixels across the diagonal of the bounding box. Set to 0 " "for off"}) @@ -659,6 +677,7 @@ def get_optional_arguments(): "min_max": (1, 100), "default": 1, "rounding": 1, + "group": "Face Processing", "help": "Extract every 'nth' frame. This option will skip frames " "when extracting faces. For example a value of 1 will " "extract faces from every frame, a value of 10 will extract " @@ -693,6 +712,7 @@ def get_optional_arguments(): "min_max": (0, 1000), "rounding": 10, "default": 0, + "group": "output", "help": "Automatically save the alignments file after a set amount " "of frames. By default the alignments file is only saved at " "the end of the extraction process. NB: If extracting in 2 " @@ -713,10 +733,20 @@ def get_optional_arguments(): """ Put the arguments in a list so that they are accessible from both argparse and gui """ argument_list = [] + argument_list.append({"opts": ("-ref", "--reference-video"), + "action": FileFullPaths, + "dest": "reference_video", + "filetypes": "video", + "type": str, + "group": "data", + "help": "Only required if converting from images to video. Provide " + "The original video that the source frames were extracted " + "from (for extracting the fps and audio)."}) argument_list.append({"opts": ("-m", "--model-dir"), "action": DirFullPaths, "dest": "model_dir", "required": True, + "group": "data", "help": "Model directory. The directory containing the trained " "model you wish to use for conversion."}) argument_list.append({ @@ -726,6 +756,7 @@ def get_optional_arguments(): "dest": "color_adjustment", "choices": PluginLoader.get_available_convert_plugins("color", True), "default": "avg-color", + "group": "plugins", "help": "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Edit > Configure " "Convert Plugins':" @@ -743,23 +774,13 @@ def get_optional_arguments(): "gradients at the mask seam by smoothing colors. Generally does not give " "very satisfactory results." "\nL|none: Don't perform color adjustment."}) - argument_list.append({ - "opts": ("-sc", "--scaling"), - "action": Radio, - "type": str.lower, - "choices": PluginLoader.get_available_convert_plugins("scaling", True), - "default": "none", - "help": "R|Performs a scaling process to attempt to get better definition on the " - "final swap. Some of these options have configurable settings in " - "'/config/convert.ini' or 'Edit > Configure Convert Plugins':" - "\nL|sharpen: Perform sharpening on the final face." - "\nL|none: Don't perform any scaling operations."}) argument_list.append({ "opts": ("-M", "--mask-type"), "action": Radio, "type": str.lower, "dest": "mask_type", "choices": get_available_masks() + ["predicted"], + "group": "plugins", "default": "predicted", "help": "R|Mask to use to replace faces. Blending of the masks can be adjusted in " "'/config/convert.ini' or 'Edit > Configure Convert Plugins':" @@ -773,11 +794,24 @@ def get_optional_arguments(): "not trained with a mask then this will fallback to " "'{}'".format(get_default_mask()) + "\nL|none: Don't use a mask."}) + argument_list.append({ + "opts": ("-sc", "--scaling"), + "action": Radio, + "type": str.lower, + "choices": PluginLoader.get_available_convert_plugins("scaling", True), + "group": "plugins", + "default": "none", + "help": "R|Performs a scaling process to attempt to get better definition on the " + "final swap. Some of these options have configurable settings in " + "'/config/convert.ini' or 'Edit > Configure Convert Plugins':" + "\nL|sharpen: Perform sharpening on the final face." + "\nL|none: Don't perform any scaling operations."}) argument_list.append({"opts": ("-w", "--writer"), "action": Radio, "type": str, "choices": PluginLoader.get_available_convert_plugins("writer", False), + "group": "plugins", "default": "opencv", "help": "R|The plugin to use to output the converted images. The " "writers are configurable in '/config/convert.ini' or 'Edit " @@ -797,35 +831,24 @@ def get_optional_arguments(): "default": 100, "min_max": (25, 400), "rounding": 1, + "group": "Frame Processing", "help": "Scale the final output frames by this amount. 100%% will " "output the frames at source dimensions. 50%% at half size " "200%% at double size"}) - argument_list.append({"opts": ("-j", "--jobs"), - "dest": "jobs", - "action": Slider, - "type": int, - "default": 0, - "min_max": (0, 40), - "rounding": 1, - "help": "The maximum number of parallel processes for performing " - "conversion. Converting images is system RAM heavy so it is " - "possible to run out of memory if you have a lot of " - "processes and not enough RAM to accomodate them all. " - "Setting this to 0 will use the maximum available. No " - "matter what you set this to, it will never attempt to use " - "more processes than are available on your system. If " - "singleprocess is enabled this setting will be ignored."}) - argument_list.append({"opts": ("-g", "--gpus"), - "type": int, - "backend": "nvidia", - "action": Slider, - "min_max": (1, 10), - "rounding": 1, - "default": 1, - "help": "Number of GPUs to use for conversion"}) + argument_list.append({"opts": ("-fr", "--frame-ranges"), + "nargs": "+", + "type": str, + "group": "Frame Processing", + "help": "Frame ranges to apply transfer to e.g. For frames 10 to 50 " + "and 90 to 100 use --frame-ranges 10-50 90-100. Frames " + "falling outside of the selected range will be discarded " + "unless '-k' (--keep-unchanged) is selected. NB: If you are " + "converting from images, then the filenames must end with " + "the frame-number!"}) argument_list.append({"opts": ("-a", "--input-aligned-dir"), "action": DirFullPaths, "dest": "input_aligned_dir", + "group": "Face Processing", "default": None, "help": "If you have not cleansed your alignments file, then you " "can filter out faces by defining a folder here that " @@ -835,23 +858,45 @@ def get_optional_arguments(): "specified folder will be converted. Leaving this blank " "will convert all faces that exist within the alignments " "file."}) - argument_list.append({"opts": ("-ref", "--reference-video"), - "action": FileFullPaths, - "dest": "reference_video", - "filetypes": "video", - "type": str, - "help": "Only required if converting from images to video. Provide " - "The original video that the source frames were extracted " - "from (for extracting the fps and audio)."}) - argument_list.append({"opts": ("-fr", "--frame-ranges"), + argument_list.append({"opts": ("-n", "--nfilter"), + "action": FilesFullPaths, + "filetypes": "image", + "dest": "nfilter", "nargs": "+", - "type": str, - "help": "Frame ranges to apply transfer to e.g. For frames 10 to 50 " - "and 90 to 100 use --frame-ranges 10-50 90-100. Frames " - "falling outside of the selected range will be discarded " - "unless '-k' (--keep-unchanged) is selected. NB: If you are " - "converting from images, then the filenames must end with " - "the frame-number!"}) + "default": None, + "group": "Face Processing", + "help": "Optionally filter out people who you do not wish to " + "process by passing in an image of that person. Should be a " + "front portrait with a single person in the image. Multiple " + "images can be added space separated. NB: Using face filter " + "will significantly decrease extraction speed and its " + "accuracy cannot be guaranteed."}) + argument_list.append({"opts": ("-f", "--filter"), + "action": FilesFullPaths, + "filetypes": "image", + "dest": "filter", + "nargs": "+", + "default": None, + "group": "Face Processing", + "help": "Optionally select people you wish to process by passing in " + "an image of that person. Should be a front portrait with a " + "single person in the image. Multiple images can be added " + "space separated. NB: Using face filter will significantly " + "decrease extraction speed and its accuracy cannot be " + "guaranteed."}) + argument_list.append({"opts": ("-l", "--ref_threshold"), + "action": Slider, + "min_max": (0.01, 0.99), + "rounding": 2, + "type": float, + "dest": "ref_threshold", + "default": 0.4, + "group": "Face Processing", + "help": "For use with the optional nfilter/filter files. Threshold " + "for positive face recognition. Lower values are stricter. " + "NB: Using face filter will significantly decrease " + "extraction speed and its accuracy cannot be " + "guaranteed."}) argument_list.append({"opts": ("-k", "--keep-unchanged"), "action": "store_true", "dest": "keep_unchanged", @@ -891,6 +936,7 @@ def get_argument_list(): "action": DirFullPaths, "dest": "input_a", "required": True, + "group": "faces", "help": "Input directory. A directory containing training images " "for face A. This is the original face, i.e. the face that " "you want to remove and replace with face B."}) @@ -900,24 +946,16 @@ def get_argument_list(): "type": str, "dest": "alignments_path_a", "default": None, + "group": "faces", "help": "Path to alignments file for training set A. Only required " "if you are using a masked model or warp-to-landmarks is " "enabled. Defaults to /alignments.json if not " "provided."}) - argument_list.append({"opts": ("-tia", "--timelapse-input-A"), - "action": DirFullPaths, - "dest": "timelapse_input_a", - "default": None, - "help": "Optional for creating a timelapse. Timelapse will save an " - "image of your selected faces into the timelapse-output " - "folder at every save iteration. This should be the " - "input folder of 'A' faces that you would like to use for " - "creating the timelapse. You must also supply a " - "--timelapse-output and a --timelapse-input-B parameter."}) argument_list.append({"opts": ("-B", "--input-B"), "action": DirFullPaths, "dest": "input_b", "required": True, + "group": "faces", "help": "Input directory. A directory containing training images " "for face B. This is the swap face, i.e. the face that " "you want to place onto the head of person A."}) @@ -927,33 +965,16 @@ def get_argument_list(): "type": str, "dest": "alignments_path_b", "default": None, + "group": "faces", "help": "Path to alignments file for training set B. Only required " "if you are using a masked model or warp-to-landmarks is " "enabled. Defaults to /alignments.json if not " "provided."}) - argument_list.append({"opts": ("-tib", "--timelapse-input-B"), - "action": DirFullPaths, - "dest": "timelapse_input_b", - "default": None, - "help": "Optional for creating a timelapse. Timelapse will save an " - "image of your selected faces into the timelapse-output " - "folder at every save iteration. This should be the " - "input folder of 'B' faces that you would like to use for " - "creating the timelapse. You must also supply a " - "--timelapse-output and a --timelapse-input-A parameter."}) - argument_list.append({"opts": ("-to", "--timelapse-output"), - "action": DirFullPaths, - "dest": "timelapse_output", - "default": None, - "help": "Optional for creating a timelapse. Timelapse will save an " - "image of your selected faces into the timelapse-output " - "folder at every save iteration. If the input folders are " - "supplied but no output folder, it will default to your " - "model folder /timelapse/"}) argument_list.append({"opts": ("-m", "--model-dir"), "action": DirFullPaths, "dest": "model_dir", "required": True, + "group": "model", "help": "Model directory. This is where the training data will be " "stored. You should always specify a new folder for new " "models. If starting a new model, select either an empty " @@ -965,6 +986,7 @@ def get_argument_list(): "type": str.lower, "choices": PluginLoader.get_available_models(), "default": PluginLoader.get_default_model(), + "group": "model", "help": "R|Select which trainer to use. Trainers can be" "configured from the edit menu or the config folder." "\nL|original: The original model created by /u/deepfakes." @@ -987,24 +1009,6 @@ def get_argument_list(): "\nL|villain: 128px in/out model from villainguy. Very " "resource hungry (11GB for batchsize 16). Good for " "details, but more susceptible to color differences."}) - argument_list.append({"opts": ("-s", "--save-interval"), - "type": int, - "action": Slider, - "min_max": (10, 1000), - "rounding": 10, - "dest": "save_interval", - "default": 100, - "help": "Sets the number of iterations between each model save."}) - argument_list.append({"opts": ("-ss", "--snapshot-interval"), - "type": int, - "action": Slider, - "min_max": (0, 100000), - "rounding": 5000, - "dest": "snapshot_interval", - "default": 25000, - "help": "Sets the number of iterations before saving a backup " - "snapshot of the model in it's current state. Set to 0 for " - "off."}) argument_list.append({"opts": ("-bs", "--batch-size"), "type": int, "action": Slider, @@ -1012,6 +1016,7 @@ def get_argument_list(): "rounding": 2, "dest": "batch_size", "default": 64, + "group": "training", "help": "Batch size. This is the number of images processed through " "the model for each iteration. Larger batches require more " "GPU RAM."}) @@ -1021,6 +1026,7 @@ def get_argument_list(): "min_max": (0, 5000000), "rounding": 20000, "default": 1000000, + "group": "training", "help": "Length of training in iterations. This is only really used " "for automation. There is no 'correct' number of iterations " "a model should be trained for. You should stop training " @@ -1033,6 +1039,7 @@ def get_argument_list(): "action": Slider, "min_max": (1, 10), "rounding": 1, + "group": "training", "default": 1, "help": "Number of GPUs to use for training"}) argument_list.append({"opts": ("-ps", "--preview-scale"), @@ -1040,9 +1047,62 @@ def get_argument_list(): "action": Slider, "dest": "preview_scale", "min_max": (25, 200), + "group": "training", "rounding": 25, "default": 50, "help": "Percentage amount to scale the preview by."}) + argument_list.append({"opts": ("-s", "--save-interval"), + "type": int, + "action": Slider, + "min_max": (10, 1000), + "rounding": 10, + "dest": "save_interval", + "group": "Saving", + "default": 100, + "help": "Sets the number of iterations between each model save."}) + argument_list.append({"opts": ("-ss", "--snapshot-interval"), + "type": int, + "action": Slider, + "min_max": (0, 100000), + "rounding": 5000, + "dest": "snapshot_interval", + "group": "Saving", + "default": 25000, + "help": "Sets the number of iterations before saving a backup " + "snapshot of the model in it's current state. Set to 0 for " + "off."}) + argument_list.append({"opts": ("-tia", "--timelapse-input-A"), + "action": DirFullPaths, + "dest": "timelapse_input_a", + "default": None, + "group": "timelapse", + "help": "Optional for creating a timelapse. Timelapse will save an " + "image of your selected faces into the timelapse-output " + "folder at every save iteration. This should be the " + "input folder of 'A' faces that you would like to use for " + "creating the timelapse. You must also supply a " + "--timelapse-output and a --timelapse-input-B parameter."}) + argument_list.append({"opts": ("-tib", "--timelapse-input-B"), + "action": DirFullPaths, + "dest": "timelapse_input_b", + "default": None, + "group": "timelapse", + "help": "Optional for creating a timelapse. Timelapse will save an " + "image of your selected faces into the timelapse-output " + "folder at every save iteration. This should be the " + "input folder of 'B' faces that you would like to use for " + "creating the timelapse. You must also supply a " + "--timelapse-output and a --timelapse-input-A parameter."}) + argument_list.append({"opts": ("-to", "--timelapse-output"), + "action": DirFullPaths, + "dest": "timelapse_output", + "default": None, + "group": "timelapse", + "help": "Optional for creating a timelapse. Timelapse will save an " + "image of your selected faces into the timelapse-output " + "folder at every save iteration. If the input folders are " + "supplied but no output folder, it will default to your " + "model folder /timelapse/"}) argument_list.append({"opts": ("-p", "--preview"), "action": "store_true", "dest": "preview", diff --git a/lib/config.py b/lib/config.py index ffbac836be..c4e41f7f77 100644 --- a/lib/config.py +++ b/lib/config.py @@ -118,7 +118,8 @@ def add_section(self, title=None, info=None): self.defaults[title]["helptext"] = info def add_item(self, section=None, title=None, datatype=str, default=None, info=None, - rounding=None, min_max=None, choices=None, gui_radio=False, fixed=True): + rounding=None, min_max=None, choices=None, gui_radio=False, fixed=True, + group=None): """ Add a default item to a config section For int or float values, rounding and min_max must be set @@ -138,11 +139,13 @@ def add_item(self, section=None, title=None, datatype=str, default=None, info=No existing models, and will overide the value saved in the state file with the updated value in config. + The 'Group' parameter allows you to assign the config item to a group in the GUI + """ logger.debug("Add item: (section: '%s', title: '%s', datatype: '%s', default: '%s', " "info: '%s', rounding: '%s', min_max: %s, choices: %s, gui_radio: %s, " - "fixed: %s)", section, title, datatype, default, info, rounding, min_max, - choices, gui_radio, fixed) + "fixed: %s, group: %s)", section, title, datatype, default, info, rounding, + min_max, choices, gui_radio, fixed, group) choices = list() if not choices else choices @@ -168,7 +171,8 @@ def add_item(self, section=None, title=None, datatype=str, default=None, info=No "min_max": min_max, "choices": choices, "gui_radio": gui_radio, - "fixed": fixed} + "fixed": fixed, + "group": group} @staticmethod def expand_helptext(helptext, choices, default, datatype, min_max, fixed): diff --git a/lib/gui/command.py b/lib/gui/command.py index 47950dc1b9..774f691ae3 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -122,6 +122,7 @@ def __init__(self, parent): self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) self.optsframe = ttk.Frame(self.canvas) + self.group_frames = dict() self.optscanvas = self.canvas.create_window((0, 0), window=self.optsframe, anchor=tk.NW) @@ -159,9 +160,19 @@ def build_frame(self): cli_opts = get_config().cli_opts for option in cli_opts.gen_command_options(self.command): + group = option["group"] + frame = self.optsframe + if group is not None: + group = group.lower() + if self.group_frames.get(group, None) is None: + group_frame = ttk.LabelFrame(self.optsframe, text=group.title()) + group_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5) + self.group_frames[group] = group_frame + frame = self.group_frames[group] + optioncontrol = OptionControl(self.command, option, - self.optsframe, + frame, self.chkbtns[1]) optioncontrol.build_full_control() diff --git a/lib/gui/options.py b/lib/gui/options.py index 8e8e71c3b7..609a05880e 100644 --- a/lib/gui/options.py +++ b/lib/gui/options.py @@ -92,12 +92,13 @@ def process_options(self, command_options): if opt.get("help", "") == SUPPRESS: logger.trace("Skipping suppressed option: %s", opt) continue - ctl, sysbrowser, filetypes, action_option = self.set_control(opt) + ctl, sysbrowser, filetypes, action_option, group = self.set_control(opt) opt["control_title"] = self.set_control_title(opt.get("opts", "")) opt["control"] = ctl opt["filesystem_browser"] = sysbrowser opt["filetypes"] = filetypes opt["action_option"] = action_option + opt["group"] = group final_options.append(opt) logger.trace("Processed: %s", opt) return final_options @@ -112,6 +113,7 @@ def set_control_title(opts): def set_control(self, option): """ Set the control and filesystem browser to use for each option """ sysbrowser = None + group = option.get("group", None) action = option.get("action", None) action_option = option.get("action_option", None) filetypes = option.get("filetypes", None) @@ -134,7 +136,7 @@ def set_control(self, option): ctl = ttk.Combobox elif option.get("action", "") == "store_true": ctl = ttk.Checkbutton - return ctl, sysbrowser, filetypes, action_option + return ctl, sysbrowser, filetypes, action_option, group @staticmethod def set_sysbrowser(action, filetypes, action_option): diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 382ea49034..ae6925b586 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -193,6 +193,7 @@ def __init__(self, parent, options, plugin_info): self.optsframe = ttk.Frame(self.canvas) self.optscanvas = self.canvas.create_window((0, 0), window=self.optsframe, anchor=tk.NW) + self.group_frames = dict() self.build_frame() logger.debug("Initialized %s", self.__class__.__name__) @@ -207,7 +208,17 @@ def build_frame(self): for key, val in self.options.items(): if key == "helptext": continue - ctl = ControlBuilder(self.optsframe, + group = val["group"] + frame = self.optsframe + if group is not None: + group = group.lower() + if self.group_frames.get(group, None) is None: + group_frame = ttk.LabelFrame(self.optsframe, text=group.title()) + group_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5) + self.group_frames[group] = group_frame + frame = self.group_frames[group] + + ctl = ControlBuilder(frame, key, val["type"], val["default"], diff --git a/plugins/train/_config.py b/plugins/train/_config.py index d2a41f93f5..c855d45dd8 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -57,13 +57,42 @@ def set_globals(self): self.add_section(title=section, info="Options that apply to all models" + ADDITIONAL_INFO) self.add_item( - section=section, title="icnr_init", datatype=bool, default=False, + section=section, title="coverage", datatype=float, default=68.75, + min_max=(62.5, 100.0), rounding=2, fixed=True, + info="How much of the extracted image to train on. A lower coverage will limit the " + "model's scope to a zoomed-in central area while higher amounts can include the " + "entire face. A trade-off exists between lower amounts given more detail " + "versus higher amounts avoiding noticeable swap transitions. Sensible values to " + "use are:" + "\n\t62.5%% spans from eyebrow to eyebrow." + "\n\t75.0%% spans from temple to temple." + "\n\t87.5%% spans from ear to ear." + "\n\t100.0%% is a mugshot.") + self.add_item( + section=section, title="mask_type", datatype=str, default="none", + choices=get_available_masks(), group="mask", + info="The mask to be used for training:" + "\n\t none: Doesn't use any mask." + "\n\t components: An improved face hull mask using a facehull of 8 facial parts" + "\n\t dfl_full: An improved face hull mask using a facehull of 3 facial parts" + "\n\t extended: Based on components mask. Extends the eyebrow points to further " + "up the forehead. May perform badly on difficult angles." + "\n\t facehull: Face cutout based on landmarks") + self.add_item( + section=section, title="mask_blur", datatype=bool, default=False, group="mask", + info="Apply gaussian blur to the mask input. This has the effect of smoothing the " + "edges of the mask, which can help with poorly calculated masks, and give less " + "of a hard edge to the predicted mask.") + self.add_item( + section=section, title="icnr_init", datatype=bool, + default=False, group="initialization", info="Use ICNR to tile the default initializer in a repeating pattern. " "This strategy is designed for pairing with sub-pixel / pixel shuffler " "to reduce the 'checkerboard effect' in image reconstruction. " "\n\t https://arxiv.org/ftp/arxiv/papers/1707/1707.02937.pdf") self.add_item( - section=section, title="conv_aware_init", datatype=bool, default=False, + section=section, title="conv_aware_init", datatype=bool, + default=False, group="initialization", info="Use Convolution Aware Initialization for convolutional layers. " "This can help eradicate the vanishing and exploding gradient problem " "as well as lead to higher accuracy, lower loss and faster convergence.\nNB:" @@ -77,26 +106,29 @@ def set_globals(self): "for this initialization technique are expensive. This will only impact starting " "a new model.") self.add_item( - section=section, title="subpixel_upscaling", datatype=bool, default=False, + section=section, title="subpixel_upscaling", datatype=bool, + default=False, group="network", info="Use subpixel upscaling rather than pixel shuffler. These techniques " "are both designed to produce better resolving upscaling than other " "methods. Each perform the same operations, but using different TF opts." "\n\t https://arxiv.org/pdf/1609.05158.pdf") self.add_item( - section=section, title="reflect_padding", datatype=bool, default=False, + section=section, title="reflect_padding", datatype=bool, + default=False, group="network", info="Use reflection padding rather than zero padding with convolutions. " "Each convolution must pad the image boundaries to maintain the proper " "sizing. More complex padding schemes can reduce artifacts at the " "border of the image." "\n\t http://www-cs.engr.ccny.cuny.edu/~wolberg/cs470/hw/hw2_pad.txt") self.add_item( - section=section, title="penalized_mask_loss", datatype=bool, default=True, + section=section, title="penalized_mask_loss", datatype=bool, + default=True, group="loss", info="Image loss function is weighted by mask presence. For areas of " "the image without the facial mask, reconstuction errors will be " "ignored while the masked face area is prioritized. May increase " "overall quality by focusing attention on the core face area.") self.add_item( - section=section, title="loss_function", datatype=str, + section=section, title="loss_function", datatype=str, group="loss", default="mae", choices=["mae", "mse", "logcosh", "smooth_l1", "l_inf_norm", "ssim", "gmsd", "pixel_gradient_diff"], @@ -125,42 +157,15 @@ def set_globals(self): "pixel spatial difference in each image and then minimize that difference " "between two images. Allows for large color shifts,but maintains the structure " "of the image.\n") - self.add_item( - section=section, title="mask_type", datatype=str, default="none", - choices=get_available_masks(), - info="The mask to be used for training:" - "\n\t none: Doesn't use any mask." - "\n\t components: An improved face hull mask using a facehull of 8 facial parts" - "\n\t dfl_full: An improved face hull mask using a facehull of 3 facial parts" - "\n\t extended: Based on components mask. Extends the eyebrow points to further " - "up the forehead. May perform badly on difficult angles." - "\n\t facehull: Face cutout based on landmarks") - self.add_item( - section=section, title="mask_blur", datatype=bool, default=False, - info="Apply gaussian blur to the mask input. This has the effect of smoothing the " - "edges of the mask, which can help with poorly calculated masks, and give less " - "of a hard edge to the predicted mask.") self.add_item( section=section, title="learning_rate", datatype=float, default=5e-5, - min_max=(1e-6, 1e-4), rounding=6, fixed=False, + min_max=(1e-6, 1e-4), rounding=6, fixed=False, group="optimizer", info="Learning rate - how fast your network will learn (how large are " "the modifications to the model weights after one batch of training). " "Values that are too large might result in model crashes and the " "inability of the model to find the best solution. " "Values that are too small might be unable to escape from dead-ends " "and find the best global minimum.") - self.add_item( - section=section, title="coverage", datatype=float, default=68.75, - min_max=(62.5, 100.0), rounding=2, fixed=True, - info="How much of the extracted image to train on. A lower coverage will limit the " - "model's scope to a zoomed-in central area while higher amounts can include the " - "entire face. A trade-off exists between lower amounts given more detail " - "versus higher amounts avoiding noticeable swap transitions. Sensible values to " - "use are:" - "\n\t62.5%% spans from eyebrow to eyebrow." - "\n\t75.0%% spans from temple to temple." - "\n\t87.5%% spans from ear to ear." - "\n\t100.0%% is a mugshot.") def load_module(self, filename, module_path, plugin_type): """ Load the defaults module and add defaults """ diff --git a/plugins/train/model/dfl_sae_defaults.py b/plugins/train/model/dfl_sae_defaults.py index 3eacc79a12..5c627c90a8 100644 --- a/plugins/train/model/dfl_sae_defaults.py +++ b/plugins/train/model/dfl_sae_defaults.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ - The default options for the faceswap Dfl_H128 Model plugin. + The default options for the faceswap Dfl_SAE Model plugin. Defaults files should be named _defaults.py Any items placed into this file will automatically get added to the relevant config .ini files @@ -45,16 +45,6 @@ _DEFAULTS = { - "architecture": { - "default": "df", - "info": "Model architecture:" - "\n\t'df': Keeps the faces more natural." - "\n\t'liae': Can help fix overly different face shapes.", - "datatype": str, - "choices": ["df", "liae"], - "gui_radio": True, - "fixed": True, - }, "input_size": { "default": 128, "info": "Resolution (in pixels) of the input image to train on.\n" @@ -65,6 +55,24 @@ "min_max": (64, 256), "fixed": True, }, + "clipnorm": { + "default": True, + "info": "Controls gradient clipping of the optimizer. Can prevent model corruption at " + "the expense of VRAM.", + "datatype": bool, + "fixed": False, + }, + "architecture": { + "default": "df", + "info": "Model architecture:" + "\n\t'df': Keeps the faces more natural." + "\n\t'liae': Can help fix overly different face shapes.", + "datatype": str, + "choices": ["df", "liae"], + "gui_radio": True, + "fixed": True, + "group": "network", + }, "autoencoder_dims": { "default": 0, "info": "Face information is stored in AutoEncoder dimensions. If there are not enough " @@ -75,6 +83,7 @@ "rounding": 32, "min_max": (0, 1024), "fixed": True, + "group": "network", }, "encoder_dims": { "default": 42, @@ -84,6 +93,7 @@ "rounding": 1, "min_max": (21, 85), "fixed": True, + "group": "network", }, "decoder_dims": { "default": 21, @@ -93,18 +103,13 @@ "rounding": 1, "min_max": (10, 85), "fixed": True, + "group": "network", }, "multiscale_decoder": { "default": False, "info": "Multiscale decoder can help to obtain better details.", "datatype": bool, "fixed": True, - }, - "clipnorm": { - "default": True, - "info": "Controls gradient clipping of the optimizer. Can prevent model corruption at " - "the expense of VRAM.", - "datatype": bool, - "fixed": False, + "group": "network", }, } diff --git a/plugins/train/model/realface_defaults.py b/plugins/train/model/realface_defaults.py index 9ff86b113a..cdf001fa1c 100755 --- a/plugins/train/model/realface_defaults.py +++ b/plugins/train/model/realface_defaults.py @@ -61,6 +61,7 @@ "choices": [], "gui_radio": False, "fixed": True, + "group": "size" }, "output_size": { "default": 128, @@ -73,6 +74,7 @@ "choices": [], "gui_radio": False, "fixed": True, + "group": "size" }, "dense_nodes": { "default": 1536, @@ -85,6 +87,7 @@ "choices": [], "gui_radio": False, "fixed": True, + "group": "network" }, "complexity_encoder": { "default": 128, @@ -95,6 +98,7 @@ "choices": [], "gui_radio": False, "fixed": True, + "group": "network" }, "complexity_decoder": { "default": 512, @@ -105,5 +109,6 @@ "choices": [], "gui_radio": False, "fixed": True, + "group": "network" }, } diff --git a/plugins/train/model/unbalanced_defaults.py b/plugins/train/model/unbalanced_defaults.py index 16f430d722..b92c5e5a95 100755 --- a/plugins/train/model/unbalanced_defaults.py +++ b/plugins/train/model/unbalanced_defaults.py @@ -48,6 +48,20 @@ _DEFAULTS = { + "input_size": { + "default": 128, + "info": "Resolution (in pixels) of the image to train on.\n" + "BE AWARE Larger resolution will dramatically increaseVRAM requirements.\n" + "Make sure your resolution is divisible by 64 (e.g. 64, 128, 256 etc.).\n" + "NB: Your faceset must be at least 1.6x larger than your required input " + "size.\n(e.g. 160 is the maximum input size for a 256x256 faceset).", + "datatype": int, + "rounding": 64, + "min_max": (64, 512), + "choices": [], + "gui_radio": False, + "fixed": True, + }, "lowmem": { "default": False, "info": "Lower memory mode. Set to 'True' if having issues with VRAM useage.\n" @@ -81,6 +95,7 @@ "choices": [], "gui_radio": False, "fixed": True, + "group": "network", }, "complexity_encoder": { "default": 128, @@ -91,6 +106,7 @@ "choices": [], "gui_radio": False, "fixed": True, + "group": "network", }, "complexity_decoder_a": { "default": 384, @@ -101,6 +117,7 @@ "choices": [], "gui_radio": False, "fixed": True, + "group": "network", }, "complexity_decoder_b": { "default": 512, @@ -111,19 +128,6 @@ "choices": [], "gui_radio": False, "fixed": True, - }, - "input_size": { - "default": 128, - "info": "Resolution (in pixels) of the image to train on.\n" - "BE AWARE Larger resolution will dramatically increaseVRAM requirements.\n" - "Make sure your resolution is divisible by 64 (e.g. 64, 128, 256 etc.).\n" - "NB: Your faceset must be at least 1.6x larger than your required input " - "size.\n(e.g. 160 is the maximum input size for a 256x256 faceset).", - "datatype": int, - "rounding": 64, - "min_max": (64, 512), - "choices": [], - "gui_radio": False, - "fixed": True, + "group": "network", }, } diff --git a/plugins/train/trainer/original_defaults.py b/plugins/train/trainer/original_defaults.py index 7aa168fc40..07a5774e44 100755 --- a/plugins/train/trainer/original_defaults.py +++ b/plugins/train/trainer/original_defaults.py @@ -60,6 +60,7 @@ "datatype": int, "rounding": 1, "min_max": (0, 25), + "group": "image augmentation", }, "rotation_range": { "default": 10, @@ -67,6 +68,7 @@ "datatype": int, "rounding": 1, "min_max": (0, 25), + "group": "image augmentation", }, "shift_range": { "default": 5, @@ -75,6 +77,7 @@ "datatype": int, "rounding": 1, "min_max": (0, 25), + "group": "image augmentation", }, "flip_chance": { "default": 50, @@ -83,6 +86,7 @@ "datatype": int, "rounding": 1, "min_max": (0, 75), + "group": "image augmentation", }, "color_lightness": { "default": 30, @@ -91,6 +95,7 @@ "datatype": int, "rounding": 1, "min_max": (0, 75), + "group": "color augmentation", }, "color_ab": { "default": 8, @@ -100,6 +105,7 @@ "datatype": int, "rounding": 1, "min_max": (0, 50), + "group": "color augmentation", }, "color_clahe_chance": { "default": 50, @@ -110,6 +116,7 @@ "rounding": 1, "min_max": (0, 75), "fixed": False, + "group": "color augmentation", }, "color_clahe_max_size": { "default": 4, @@ -121,5 +128,6 @@ "datatype": int, "rounding": 1, "min_max": (1, 8), + "group": "color augmentation", }, } diff --git a/tools/cli.py b/tools/cli.py index 2c868fd3fc..51aa56ea83 100644 --- a/tools/cli.py +++ b/tools/cli.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ +from argparse import SUPPRESS + from lib.cli import FaceSwapArgs from lib.cli import (ContextFullPaths, DirOrFileFullPaths, DirFullPaths, FileFullPaths, FilesFullPaths, SaveFileFullPaths, Radio, Slider) @@ -87,6 +89,7 @@ def get_argument_list(self): "action": FilesFullPaths, "dest": "alignments_file", "nargs": "+", + "group": "data", "required": True, "filetypes": "alignments", "help": "Full path to the alignments file to be processed. If " @@ -95,16 +98,19 @@ def get_argument_list(self): argument_list.append({"opts": ("-fc", "-faces_folder"), "action": DirFullPaths, "dest": "faces_dir", + "group": "data", "help": "Directory containing extracted faces."}) argument_list.append({"opts": ("-fr", "-frames_folder"), "action": DirOrFileFullPaths, "dest": "frames_dir", "filetypes": "video", + "group": "data", "help": "Directory containing source frames " "that faces were extracted from."}) argument_list.append({"opts": ("-fmt", "--alignment_format"), "type": str, "choices": ("json", "pickle", "yaml"), + "group": "data", "help": "The file format to save the alignment " "data in. Defaults to same as source."}) argument_list.append({ @@ -112,6 +118,7 @@ def get_argument_list(self): "action": Radio, "type": str, "choices": ("console", "file", "move"), + "group": "output", "default": "console", "help": "R|How to output discovered items ('faces' and 'frames' only):" "\nL|'console': Print the list of frames to the screen. (DEFAULT)" @@ -126,6 +133,7 @@ def get_argument_list(self): "min_max": (1, 100), "default": 1, "rounding": 1, + "group": "output", "help": "Extract every 'nth' frame. This option will skip frames " "when extracting faces. For example a value of 1 will " "extract faces from every frame, a value of 10 will extract " @@ -135,6 +143,7 @@ def get_argument_list(self): "action": Slider, "min_max": (128, 512), "default": 256, + "group": "output", "rounding": 64, "help": "The output size of extracted faces. (extract only)"}) argument_list.append({"opts": ("-ae", "--align-eyes"), @@ -164,6 +173,7 @@ def get_argument_list(self): "action": DirOrFileFullPaths, "filetypes": "video", "dest": "input_dir", + "group": "data", "required": True, "help": "Input directory or video. Either a directory containing " "the image files you wish to process or path to a video " @@ -172,12 +182,14 @@ def get_argument_list(self): "action": FileFullPaths, "filetypes": "alignments", "type": str, + "group": "data", "dest": "alignments_path", "help": "Path to the alignments file for the input, if not at the " "default location"}) argument_list.append({"opts": ("-m", "--model-dir"), "action": DirFullPaths, "dest": "model_dir", + "group": "data", "required": True, "help": "Model directory. A directory containing the trained model " "you wish to process."}) @@ -231,14 +243,16 @@ def get_argument_list(self): "dest": "input", "default": "input", "help": "Input file.", + "group": "data", "required": True, "action_option": "-a", "filetypes": "video"}) argument_list.append({"opts": ('-o', '--output'), "action": ContextFullPaths, - "dest": "output", + "group": "data", "default": "", + "dest": "output", "help": "Output file. If no output is " "specified then: if the output is " "meant to be a video then a video " @@ -257,6 +271,7 @@ def get_argument_list(self): argument_list.append({"opts": ('-r', '--reference-video'), "action": FileFullPaths, "dest": "ref_vid", + "group": "data", "default": None, "help": "Path to reference video if 'input' " "was not a video.", @@ -265,6 +280,7 @@ def get_argument_list(self): argument_list.append({"opts": ('-fps', '--fps'), "type": str, "dest": "fps", + "group": "output", "default": "-1.0", "help": "Provide video fps. Can be an integer, " "float or fraction. Negative values " @@ -276,6 +292,7 @@ def get_argument_list(self): "action": Radio, "choices": _image_extensions, "dest": "extract_ext", + "group": "output", "default": ".png", "help": "Image format that extracted images " "should be saved as. '.bmp' will offer " @@ -287,6 +304,7 @@ def get_argument_list(self): argument_list.append({"opts": ('-s', '--start'), "type": str, "dest": "start", + "group": "clip", "default": "00:00:00", "help": "Enter the start time from which an " "action is to be applied. " @@ -298,6 +316,7 @@ def get_argument_list(self): argument_list.append({"opts": ('-e', '--end'), "type": str, "dest": "end", + "group": "clip", "default": "00:00:00", "help": "Enter the end time to which an action " "is to be applied. If both an end time " @@ -309,6 +328,7 @@ def get_argument_list(self): argument_list.append({"opts": ('-d', '--duration'), "type": str, "dest": "duration", + "group": "clip", "default": "00:00:00", "help": "Enter the duration of the chosen " "action, for example if you enter " @@ -324,6 +344,7 @@ def get_argument_list(self): argument_list.append({"opts": ('-m', '--mux-audio'), "action": "store_true", "dest": "mux_audio", + "group": "output", "default": False, "help": "Mux the audio from the reference " "video into the input video. This " @@ -339,6 +360,7 @@ def get_argument_list(self): "(3, 90Clockwise&VerticalFlip)"), "type": lambda v: self.__parse_transpose(v), "dest": "transpose", + "group": "rotate", "default": None, "help": "Transpose the video. If transpose is " "set, then degrees will be ignored. For " @@ -351,12 +373,14 @@ def get_argument_list(self): "type": str, "dest": "degrees", "default": None, + "group": "rotate", "help": "Rotate the video clockwise by the " "given number of degrees."}) argument_list.append({"opts": ('-sc', '--scale'), "type": str, "dest": "scale", + "group": "output", "default": "1920x1080", "help": "Set the new resolution scale if the " "chosen action is 'rescale'."}) @@ -365,10 +389,13 @@ def get_argument_list(self): "action": "store_true", "dest": "preview", "default": False, - "help": "Uses ffplay to preview the effects of " - "actions that have a video output. " - "Currently preview does not work when " - "muxing audio."}) + # TODO Fix preview or remove + "help": SUPPRESS, + # "help": "Uses ffplay to preview the effects of " + # "actions that have a video output. " + # "Currently preview does not work when " + # "muxing audio." + }) argument_list.append({"opts": ('-q', '--quiet'), "action": "store_true", @@ -416,32 +443,17 @@ def get_argument_list(): argument_list.append({"opts": ('-i', '--input'), "action": DirFullPaths, "dest": "input_dir", - "default": "input_dir", + "group": "data", "help": "Input directory of aligned faces.", "required": True}) argument_list.append({"opts": ('-o', '--output'), "action": DirFullPaths, "dest": "output_dir", - "default": "_output_dir", + "group": "data", "help": "Output directory for sorted aligned " "faces."}) - argument_list.append({"opts": ('-fp', '--final-process'), - "action": Radio, - "type": str, - "choices": ("folders", "rename"), - "dest": 'final_process', - "default": "rename", - "help": "R|Default: rename." - "\nL|'folders': files are sorted using " - "the -s/--sort-by method, then they " - "are organized into folders using " - "the -g/--group-by grouping method." - "\nL|'rename': files are sorted using " - "the -s/--sort-by then they are " - "renamed."}) - argument_list.append({"opts": ('-k', '--keep'), "action": 'store_true', "dest": 'keep_original', @@ -459,6 +471,7 @@ def get_argument_list(): "choices": ("blur", "face", "face-cnn", "face-cnn-dissim", "face-yaw", "hist", "hist-dissim"), "dest": 'sort_method', + "group": "sort settings", "default": "hist", "help": "R|Sort by method. Choose how images are sorted. " "\nL|'blur': Sort faces by blurriness." @@ -481,24 +494,13 @@ def get_argument_list(): "dissimilarity." "\nDefault: hist"}) - argument_list.append({"opts": ('-g', '--group-by'), - "action": Radio, - "type": str, - "choices": ("blur", "face-cnn", "face-yaw", "hist"), - "dest": 'group_method', - "default": "hist", - "help": "Group by method. " - "When -fp/--final-processing by " - "folders choose the how the images are " - "grouped after sorting. " - "Default: hist"}) - argument_list.append({"opts": ('-t', '--ref_threshold'), "action": Slider, "min_max": (-1.0, 10.0), "rounding": 2, "type": float, "dest": 'min_threshold', + "group": "sort settings", "default": -1.0, "help": "Float value. " "Minimum threshold to use for grouping comparison with " @@ -512,12 +514,42 @@ def get_argument_list(): "could result in a lot of directories being created. " "Defaults: face-cnn 7.2, hist 0.3"}) + argument_list.append({"opts": ('-fp', '--final-process'), + "action": Radio, + "type": str, + "choices": ("folders", "rename"), + "dest": 'final_process', + "default": "rename", + "group": "output", + "help": "R|Default: rename." + "\nL|'folders': files are sorted using " + "the -s/--sort-by method, then they " + "are organized into folders using " + "the -g/--group-by grouping method." + "\nL|'rename': files are sorted using " + "the -s/--sort-by then they are " + "renamed."}) + + argument_list.append({"opts": ('-g', '--group-by'), + "action": Radio, + "type": str, + "choices": ("blur", "face-cnn", "face-yaw", "hist"), + "dest": 'group_method', + "group": "output", + "default": "hist", + "help": "Group by method. " + "When -fp/--final-processing by " + "folders choose the how the images are " + "grouped after sorting. " + "Default: hist"}) + argument_list.append({"opts": ('-b', '--bins'), "action": Slider, "min_max": (1, 100), "rounding": 1, "type": int, "dest": 'num_bins', + "group": "output", "default": 5, "help": "Integer value. " "Number of folders that will be used " @@ -544,12 +576,13 @@ def get_argument_list(): "type": str.upper, "choices": ("CPU", "GPU"), "default": "GPU", + "group": "sort settings", "help": "Backend to use for VGG Face inference." "Only used for sort by 'face'."}) argument_list.append({"opts": ('-l', '--log-changes'), "action": 'store_true', - "dest": 'log_changes', + "group": "output", "default": False, "help": "Logs file renaming changes if " "grouping by renaming, or it logs the " @@ -562,6 +595,7 @@ def get_argument_list(): argument_list.append({"opts": ('-lf', '--log-file'), "action": SaveFileFullPaths, "filetypes": "alignments", + "group": "output", "dest": 'log_file_path', "default": 'sort_log.json', "help": "Specify a log file to use for saving " From eb84869841584050b78baad346e96f52113e22ec Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 23 Aug 2019 17:17:43 +0100 Subject: [PATCH 002/981] GUI Move control helper utils to own module --- lib/gui/command.py | 3 +- lib/gui/control_helper.py | 333 ++++++++++++++++++++++++++++++++++++ lib/gui/display_analysis.py | 3 +- lib/gui/popup_configure.py | 103 +---------- lib/gui/utils.py | 297 ++++---------------------------- tools/preview.py | 7 +- 6 files changed, 384 insertions(+), 362 deletions(-) create mode 100644 lib/gui/control_helper.py diff --git a/lib/gui/command.py b/lib/gui/command.py index 774f691ae3..ab28da263d 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -5,8 +5,9 @@ import tkinter as tk from tkinter import ttk +from .control_helper import set_slider_rounding from .tooltip import Tooltip -from .utils import ContextMenu, FileHandler, get_images, get_config, set_slider_rounding +from .utils import ContextMenu, FileHandler, get_images, get_config logger = logging.getLogger(__name__) # pylint:disable=invalid-name diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py new file mode 100644 index 0000000000..acb42e2d98 --- /dev/null +++ b/lib/gui/control_helper.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +""" Helper functions and classes for GUI controls """ +import logging +import tkinter as tk +from tkinter import ttk + +from .tooltip import Tooltip +from .utils import ContextMenu + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +def set_slider_rounding(value, var, d_type, round_to, min_max): + """ Set the underlying variable to correct number based on slider rounding """ + if d_type == float: + var.set(round(float(value), round_to)) + else: + steps = range(min_max[0], min_max[1] + round_to, round_to) + value = min(steps, key=lambda x: abs(x - int(float(value)))) + var.set(value) + + +def adjust_wraplength(event): + """ dynamically adjust the wraplength of a label on event """ + label = event.widget + label.configure(wraplength=event.width - 1) + + +class ControlPanel(ttk.Frame): # pylint:disable=too-many-ancestors + """ A Panel for holding controls + Also keeps tally if groups passed in, so that any options with special + processing needs are processed in the correct group frame """ + + def __init__(self, parent, options, items_per_row=1, radio_columns=4, header_text=None): + logger.debug("Initializing %s: (parent: '%s', options: %s, items_per_row: %s, " + "radio_columns: %s, header_text: %s)", + self.__class__.__name__, parent, options, items_per_row, radio_columns, + header_text) + super().__init__(parent) + self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + + self.options = options + + self.header_text = header_text + self.group_frames = dict() + + self.canvas = tk.Canvas(self, bd=0, highlightthickness=0) + self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + self.optsframe = ttk.Frame(self.canvas) + self.optscanvas = self.canvas.create_window((0, 0), window=self.optsframe, anchor=tk.NW) + + self.build_panel(radio_columns) + logger.debug("Initialized %s", self.__class__.__name__) + + def build_panel(self, radio_columns): + """ Build the options frame for this command """ + logger.debug("Add Config Frame") + self.add_scrollbar() + self.canvas.bind("", self.resize_frame) + + self.add_info() + for key, val in self.options.items(): + if key == "helptext": + continue + frame = self.get_holding_frame(val["group"]) + ctl = ControlBuilder(frame, + key, + val["type"], + val["default"], + selected_value=val["value"], + choices=val["choices"], + is_radio=val["gui_radio"], + rounding=val["rounding"], + min_max=val["min_max"], + helptext=val["helptext"], + radio_columns=radio_columns) + val["selected"] = ctl.tk_var + logger.debug("Added Config Frame") + + def get_holding_frame(self, group): + """ Return either the main options frame or a group frame """ + if group is None: + return self.optsframe + group = group.lower() + if self.group_frames.get(group, None) is None: + logger.debug("Creating new group frame for: %s", group) + group_frame = ttk.LabelFrame(self.optsframe, text=group.title()) + group_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5) + self.group_frames[group] = group_frame + return self.group_frames[group] + + def add_scrollbar(self): + """ Add a scrollbar to the options frame """ + logger.debug("Add Config Scrollbar") + scrollbar = ttk.Scrollbar(self, command=self.canvas.yview) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + self.canvas.config(yscrollcommand=scrollbar.set) + self.optsframe.bind("", self.update_scrollbar) + logger.debug("Added Config Scrollbar") + + def update_scrollbar(self, event): # pylint: disable=unused-argument + """ Update the options frame scrollbar """ + self.canvas.configure(scrollregion=self.canvas.bbox("all")) + + def resize_frame(self, event): + """ Resize the options frame to fit the canvas """ + logger.debug("Resize Config Frame") + canvas_width = event.width + self.canvas.itemconfig(self.optscanvas, width=canvas_width) + logger.debug("Resized Config Frame") + + def add_info(self): + """ Plugin information """ + info_frame = ttk.Frame(self.optsframe) + info_frame.pack(fill=tk.X, expand=True) + lbl = ttk.Label(info_frame, text="About:", width=20, anchor=tk.W) + lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) + info = ttk.Label(info_frame, text=self.header_text) + info.pack(padx=5, pady=5, fill=tk.X, expand=True) + info.bind("", adjust_wraplength) + + +class ControlBuilder(): + # TODO Expand out for cli options + """ + Builds and returns a frame containing a tkinter control with label + + Currently only setup for config items + + Parameters + ---------- + parent: tkinter object + Parent tkinter object + title: str + Title of the control. Will be used for label text + dtype: datatype object + Datatype of the control + default: str + Default value for the control + selected_value: str, optional + Selected value for the control. If None, default will be used + choices: list or tuple, object + Used for combo boxes and radio control option setting + is_radio: bool, optional + Specifies to use a Radio control instead of combobox if choices are passed + rounding: int or float, optional + For slider controls. Sets the stepping + min_max: int or float, optional + For slider controls. Sets the min and max values + helptext: str, optional + Sets the tooltip text + radio_columns: int, optional + Sets the number of columns to use for grouping radio buttons + label_width: int, optional + Sets the width of the control label. Defaults to 20 + control_width: int, optional + Sets the width of the control. Default is to auto expand + """ + def __init__(self, parent, title, dtype, default, + selected_value=None, choices=None, is_radio=False, rounding=None, + min_max=None, helptext=None, radio_columns=3, label_width=20, control_width=None): + logger.debug("Initializing %s: (parent: %s, title: %s, dtype: %s, default: %s, " + "selected_value: %s, choices: %s, is_radio: %s, rounding: %s, min_max: %s, " + "helptext: %s, radio_columns: %s, label_width: %s, control_width: %s)", + self.__class__.__name__, parent, title, dtype, default, selected_value, + choices, is_radio, rounding, min_max, helptext, radio_columns, label_width, + control_width) + + self.title = title + self.default = default + + self.frame = self.control_frame(parent, helptext) + self.control = self.set_control(dtype, choices, is_radio) + self.tk_var = self.set_tk_var(dtype, selected_value) + + self.build_control(choices, + dtype, + rounding, + min_max, + radio_columns, + label_width, + control_width) + logger.debug("Initialized: %s", self.__class__.__name__) + + # Frame, control type and varable + def control_frame(self, parent, helptext): + """ Frame to hold control and it's label """ + logger.debug("Build control frame") + frame = ttk.Frame(parent) + frame.pack(side=tk.TOP, fill=tk.X) + if helptext is not None: + helptext = self.format_helptext(helptext) + Tooltip(frame, text=helptext, wraplength=720) + logger.debug("Built control frame") + return frame + + def format_helptext(self, helptext): + """ Format the help text for tooltips """ + logger.debug("Format control help: '%s'", self.title) + helptext = helptext.replace("\n\t", "\n - ").replace("%%", "%") + helptext = self.title + " - " + helptext + logger.debug("Formatted control help: (title: '%s', help: '%s'", self.title, helptext) + return helptext + + def set_control(self, dtype, choices, is_radio): + """ Set the correct control type based on the datatype or for this option """ + if choices and is_radio: + control = ttk.Radiobutton + elif choices: + control = ttk.Combobox + elif dtype == bool: + control = ttk.Checkbutton + elif dtype in (int, float): + control = ttk.Scale + else: + control = ttk.Entry + logger.debug("Setting control '%s' to %s", self.title, control) + return control + + def set_tk_var(self, dtype, selected_value): + """ Correct variable type for control """ + logger.debug("Setting tk variable: (title: '%s', dtype: %s, selected_value: %s)", + self.title, dtype, selected_value) + if dtype == bool: + var = tk.BooleanVar + elif dtype == int: + var = tk.IntVar + elif dtype == float: + var = tk.DoubleVar + else: + var = tk.StringVar + var = var(self.frame) + val = self.default if selected_value is None else selected_value + var.set(val) + logger.debug("Set tk variable: (title: '%s', type: %s, value: '%s')", + self.title, type(var), val) + return var + + # Build the full control + def build_control(self, choices, dtype, rounding, min_max, radio_columns, + label_width, control_width): + """ Build the correct control type for the option passed through """ + logger.debug("Build confog option control") + self.build_control_label(label_width) + self.build_one_control(choices, dtype, rounding, min_max, radio_columns, control_width) + logger.debug("Built option control") + + def build_control_label(self, label_width): + """ Label for control """ + logger.debug("Build control label: (title: '%s', label_width: %s)", + self.title, label_width) + title = self.title.replace("_", " ").title() + lbl = ttk.Label(self.frame, text=title, width=label_width, anchor=tk.W) + lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) + logger.debug("Built control label: '%s'", self.title) + + def build_one_control(self, choices, dtype, rounding, min_max, radio_columns, control_width): + """ Build and place the option controls """ + logger.debug("Build control: (title: '%s', control: %s, choices: %s, dtype: %s, " + "rounding: %s, min_max: %s: radio_columns: %s, control_width: %s)", + self.title, self.control, choices, dtype, rounding, min_max, radio_columns, + control_width) + if self.control == ttk.Scale: + ctl = self.slider_control(dtype, rounding, min_max) + elif self.control == ttk.Radiobutton: + ctl = self.radio_control(choices, radio_columns) + else: + ctl = self.control_to_optionsframe(choices) + self.set_control_width(ctl, control_width) + ctl.pack(padx=5, pady=5, fill=tk.X, expand=True) + logger.debug("Built control: '%s'", self.title) + + @staticmethod + def set_control_width(ctl, control_width): + """ Set the control width if required """ + if control_width is not None: + ctl.config(width=control_width) + + def radio_control(self, choices, columns): + """ Create a group of radio buttons """ + logger.debug("Adding radio group: %s", self.title) + ctl = ttk.Frame(self.frame) + frames = list() + for _ in range(columns): + frame = ttk.Frame(ctl) + frame.pack(padx=5, pady=5, fill=tk.X, expand=True, side=tk.LEFT, anchor=tk.N) + frames.append(frame) + + for idx, choice in enumerate(choices): + frame_id = idx % columns + radio = ttk.Radiobutton(frames[frame_id], + text=choice.title(), + value=choice, + variable=self.tk_var) + radio.pack(anchor=tk.W) + logger.debug("Adding radio option %s to column %s", choice, frame_id) + logger.debug("Added radio group: '%s'", self.title) + return ctl + + def slider_control(self, dtype, rounding, min_max): + """ A slider control with corresponding Entry box """ + logger.debug("Add slider control to Options Frame: (title: '%s', dtype: %s, rounding: %s, " + "min_max: %s)", self.title, dtype, rounding, min_max) + tbox = ttk.Entry(self.frame, width=8, textvariable=self.tk_var, justify=tk.RIGHT) + tbox.pack(padx=(0, 5), side=tk.RIGHT) + ctl = self.control( + self.frame, + variable=self.tk_var, + command=lambda val, var=self.tk_var, dt=dtype, rn=rounding, mm=min_max: + set_slider_rounding(val, var, dt, rn, mm)) + rc_menu = ContextMenu(tbox) + rc_menu.cm_bind() + ctl["from_"] = min_max[0] + ctl["to"] = min_max[1] + logger.debug("Added slider control to Options Frame: %s", self.title) + return ctl + + def control_to_optionsframe(self, choices): + """ Standard non-check buttons sit in the main options frame """ + logger.debug("Add control to Options Frame: (title: '%s', control: %s, choices: %s)", + self.title, self.control, choices) + if self.control == ttk.Checkbutton: + ctl = self.control(self.frame, variable=self.tk_var, text=None) + else: + ctl = self.control(self.frame, textvariable=self.tk_var) + rc_menu = ContextMenu(ctl) + rc_menu.cm_bind() + if choices: + logger.debug("Adding combo choices: %s", choices) + ctl["values"] = [choice for choice in choices] + logger.debug("Added control to Options Frame: %s", self.title) + return ctl diff --git a/lib/gui/display_analysis.py b/lib/gui/display_analysis.py index faa7a4167e..8f01e8c07e 100644 --- a/lib/gui/display_analysis.py +++ b/lib/gui/display_analysis.py @@ -7,11 +7,12 @@ import tkinter as tk from tkinter import ttk +from .control_helper import ControlBuilder from .display_graph import SessionGraph from .display_page import DisplayPage from .stats import Calculations, Session from .tooltip import Tooltip -from .utils import ControlBuilder, FileHandler, get_config, get_images, LongRunningTask +from .utils import FileHandler, get_config, get_images, LongRunningTask logger = logging.getLogger(__name__) # pylint: disable=invalid-name diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index ae6925b586..5bb6be3fdc 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -7,8 +7,9 @@ from tkinter import ttk +from .control_helper import ControlPanel from .tooltip import Tooltip -from .utils import adjust_wraplength, get_config, get_images, ControlBuilder +from .utils import get_config, get_images logger = logging.getLogger(__name__) # pylint: disable=invalid-name POPUP = dict() @@ -78,7 +79,7 @@ def build(self): logger.debug("Building plugin config popup") container = ttk.Notebook(self.page_frame) container.pack(fill=tk.BOTH, expand=True) - categories = sorted(list(key for key in self.config_dict_gui.keys())) + categories = sorted(list(self.config_dict_gui.keys())) if "global" in categories: # Move global to first item categories.insert(0, categories.pop(categories.index("global"))) for category in categories: @@ -97,16 +98,16 @@ def build_page(self, container, category): page = ttk.Notebook(container) page.pack(side=tk.TOP, fill=tk.BOTH, expand=True) for plugin in plugins: - frame = ConfigFrame(page, - self.config_dict_gui[category][plugin], - self.plugin_info[plugin]) + frame = ControlPanel(page, + self.config_dict_gui[category][plugin], + self.plugin_info[plugin]) title = plugin[plugin.rfind(".") + 1:] title = title.replace("_", " ").title() page.add(frame, text=title) else: - page = ConfigFrame(container, - self.config_dict_gui[category][plugins[0]], - self.plugin_info[plugins[0]]) + page = ControlPanel(container, + self.config_dict_gui[category][plugins[0]], + self.plugin_info[plugins[0]]) logger.debug("Built plugin config page: '%s'", category) @@ -175,89 +176,3 @@ def save_config(self): print("Saved config: '{}'".format(self.config.configfile)) self.destroy() logger.debug("Saved config") - - -class ConfigFrame(ttk.Frame): # pylint: disable=too-many-ancestors - """ Config Frame - Holds the Options for config """ - - def __init__(self, parent, options, plugin_info): - logger.debug("Initializing %s", self.__class__.__name__) - ttk.Frame.__init__(self, parent) - self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) - - self.options = options - self.plugin_info = plugin_info - - self.canvas = tk.Canvas(self, bd=0, highlightthickness=0) - self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - - self.optsframe = ttk.Frame(self.canvas) - self.optscanvas = self.canvas.create_window((0, 0), window=self.optsframe, anchor=tk.NW) - self.group_frames = dict() - - self.build_frame() - logger.debug("Initialized %s", self.__class__.__name__) - - def build_frame(self): - """ Build the options frame for this command """ - logger.debug("Add Config Frame") - self.add_scrollbar() - self.canvas.bind("", self.resize_frame) - - self.add_info() - for key, val in self.options.items(): - if key == "helptext": - continue - group = val["group"] - frame = self.optsframe - if group is not None: - group = group.lower() - if self.group_frames.get(group, None) is None: - group_frame = ttk.LabelFrame(self.optsframe, text=group.title()) - group_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5) - self.group_frames[group] = group_frame - frame = self.group_frames[group] - - ctl = ControlBuilder(frame, - key, - val["type"], - val["default"], - selected_value=val["value"], - choices=val["choices"], - is_radio=val["gui_radio"], - rounding=val["rounding"], - min_max=val["min_max"], - helptext=val["helptext"], - radio_columns=4) - val["selected"] = ctl.tk_var - logger.debug("Added Config Frame") - - def add_scrollbar(self): - """ Add a scrollbar to the options frame """ - logger.debug("Add Config Scrollbar") - scrollbar = ttk.Scrollbar(self, command=self.canvas.yview) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - self.canvas.config(yscrollcommand=scrollbar.set) - self.optsframe.bind("", self.update_scrollbar) - logger.debug("Added Config Scrollbar") - - def update_scrollbar(self, event): # pylint: disable=unused-argument - """ Update the options frame scrollbar """ - self.canvas.configure(scrollregion=self.canvas.bbox("all")) - - def resize_frame(self, event): - """ Resize the options frame to fit the canvas """ - logger.debug("Resize Config Frame") - canvas_width = event.width - self.canvas.itemconfig(self.optscanvas, width=canvas_width) - logger.debug("Resized Config Frame") - - def add_info(self): - """ Plugin information """ - info_frame = ttk.Frame(self.optsframe) - info_frame.pack(fill=tk.X, expand=True) - lbl = ttk.Label(info_frame, text="About:", width=20, anchor=tk.W) - lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) - info = ttk.Label(info_frame, text=self.plugin_info) - info.pack(padx=5, pady=5, fill=tk.X, expand=True) - info.bind("", adjust_wraplength) diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 3c470fdb65..f0142ae865 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -14,7 +14,6 @@ from PIL import Image, ImageDraw, ImageTk from lib.Serializer import JSONSerializer -from .tooltip import Tooltip logger = logging.getLogger(__name__) # pylint: disable=invalid-name _CONFIG = None @@ -51,22 +50,6 @@ def get_images(): return _IMAGES -def set_slider_rounding(value, var, d_type, round_to, min_max): - """ Set the underlying variable to correct number based on slider rounding """ - if d_type == float: - var.set(round(float(value), round_to)) - else: - steps = range(min_max[0], min_max[1] + round_to, round_to) - value = min(steps, key=lambda x: abs(x - int(float(value)))) - var.set(value) - - -def adjust_wraplength(event): - """ dynamically adjust the wraplength of a label on event """ - label = event.widget - label.configure(wraplength=event.width - 1) - - class FileHandler(): """ Raise a filedialog box and capture input """ @@ -525,44 +508,6 @@ def resize_image(self, name, framesize): self.previewtrain[name][1] = ImageTk.PhotoImage(displayimg) -class ContextMenu(tk.Menu): # pylint: disable=too-many-ancestors - """ Pop up menu """ - def __init__(self, widget): - logger.debug("Initializing %s: (widget_class: '%s')", - self.__class__.__name__, widget.winfo_class()) - super().__init__(tearoff=0) - self.widget = widget - self.standard_actions() - logger.debug("Initialized %s", self.__class__.__name__) - - def standard_actions(self): - """ Standard menu actions """ - self.add_command(label="Cut", command=lambda: self.widget.event_generate("<>")) - self.add_command(label="Copy", command=lambda: self.widget.event_generate("<>")) - self.add_command(label="Paste", command=lambda: self.widget.event_generate("<>")) - self.add_separator() - self.add_command(label="Select all", command=self.select_all) - - def cm_bind(self): - """ Bind the menu to the widget's Right Click event """ - button = "" if platform.system() == "Darwin" else "" - logger.debug("Binding '%s' to '%s'", button, self.widget.winfo_class()) - scaling_factor = get_config().scaling_factor if get_config() is not None else 1.0 - x_offset = int(34 * scaling_factor) - self.widget.bind(button, - lambda event: self.tk_popup(event.x_root + x_offset, event.y_root, 0)) - - def select_all(self): - """ Select all for Text or Entry widgets """ - logger.debug("Selecting all for '%s'", self.widget.winfo_class()) - if self.widget.winfo_class() == "Text": - self.widget.focus_force() - self.widget.tag_add("sel", "1.0", "end") - else: - self.widget.focus_force() - self.widget.select_range(0, tk.END) - - class ConsoleOut(ttk.Frame): # pylint: disable=too-many-ancestors """ The Console out section of the GUI """ @@ -853,216 +798,42 @@ def add_to_recent(self, filename, command): out.write(recent_json.encode("utf-8")) -class ControlBuilder(): - # TODO Expand out for cli options - """ - Builds and returns a frame containing a tkinter control with label - - Currently only setup for config items - - Parameters - ---------- - parent: tkinter object - Parent tkinter object - title: str - Title of the control. Will be used for label text - dtype: datatype object - Datatype of the control - default: str - Default value for the control - selected_value: str, optional - Selected value for the control. If None, default will be used - choices: list or tuple, object - Used for combo boxes and radio control option setting - is_radio: bool, optional - Specifies to use a Radio control instead of combobox if choices are passed - rounding: int or float, optional - For slider controls. Sets the stepping - min_max: int or float, optional - For slider controls. Sets the min and max values - helptext: str, optional - Sets the tooltip text - radio_columns: int, optional - Sets the number of columns to use for grouping radio buttons - label_width: int, optional - Sets the width of the control label. Defaults to 20 - control_width: int, optional - Sets the width of the control. Default is to auto expand - """ - def __init__(self, parent, title, dtype, default, - selected_value=None, choices=None, is_radio=False, rounding=None, - min_max=None, helptext=None, radio_columns=3, label_width=20, control_width=None): - logger.debug("Initializing %s: (parent: %s, title: %s, dtype: %s, default: %s, " - "selected_value: %s, choices: %s, is_radio: %s, rounding: %s, min_max: %s, " - "helptext: %s, radio_columns: %s, label_width: %s, control_width: %s)", - self.__class__.__name__, parent, title, dtype, default, selected_value, - choices, is_radio, rounding, min_max, helptext, radio_columns, label_width, - control_width) - - self.title = title - self.default = default - - self.frame = self.control_frame(parent, helptext) - self.control = self.set_control(dtype, choices, is_radio) - self.tk_var = self.set_tk_var(dtype, selected_value) - - self.build_control(choices, - dtype, - rounding, - min_max, - radio_columns, - label_width, - control_width) - logger.debug("Initialized: %s", self.__class__.__name__) - - # Frame, control type and varable - def control_frame(self, parent, helptext): - """ Frame to hold control and it's label """ - logger.debug("Build control frame") - frame = ttk.Frame(parent) - frame.pack(side=tk.TOP, fill=tk.X) - if helptext is not None: - helptext = self.format_helptext(helptext) - Tooltip(frame, text=helptext, wraplength=720) - logger.debug("Built control frame") - return frame - - def format_helptext(self, helptext): - """ Format the help text for tooltips """ - logger.debug("Format control help: '%s'", self.title) - helptext = helptext.replace("\n\t", "\n - ").replace("%%", "%") - helptext = self.title + " - " + helptext - logger.debug("Formatted control help: (title: '%s', help: '%s'", self.title, helptext) - return helptext - - def set_control(self, dtype, choices, is_radio): - """ Set the correct control type based on the datatype or for this option """ - if choices and is_radio: - control = ttk.Radiobutton - elif choices: - control = ttk.Combobox - elif dtype == bool: - control = ttk.Checkbutton - elif dtype in (int, float): - control = ttk.Scale - else: - control = ttk.Entry - logger.debug("Setting control '%s' to %s", self.title, control) - return control - - def set_tk_var(self, dtype, selected_value): - """ Correct variable type for control """ - logger.debug("Setting tk variable: (title: '%s', dtype: %s, selected_value: %s)", - self.title, dtype, selected_value) - if dtype == bool: - var = tk.BooleanVar - elif dtype == int: - var = tk.IntVar - elif dtype == float: - var = tk.DoubleVar - else: - var = tk.StringVar - var = var(self.frame) - val = self.default if selected_value is None else selected_value - var.set(val) - logger.debug("Set tk variable: (title: '%s', type: %s, value: '%s')", - self.title, type(var), val) - return var - - # Build the full control - def build_control(self, choices, dtype, rounding, min_max, radio_columns, - label_width, control_width): - """ Build the correct control type for the option passed through """ - logger.debug("Build confog option control") - self.build_control_label(label_width) - self.build_one_control(choices, dtype, rounding, min_max, radio_columns, control_width) - logger.debug("Built option control") - - def build_control_label(self, label_width): - """ Label for control """ - logger.debug("Build control label: (title: '%s', label_width: %s)", - self.title, label_width) - title = self.title.replace("_", " ").title() - lbl = ttk.Label(self.frame, text=title, width=label_width, anchor=tk.W) - lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) - logger.debug("Built control label: '%s'", self.title) - - def build_one_control(self, choices, dtype, rounding, min_max, radio_columns, control_width): - """ Build and place the option controls """ - logger.debug("Build control: (title: '%s', control: %s, choices: %s, dtype: %s, " - "rounding: %s, min_max: %s: radio_columns: %s, control_width: %s)", - self.title, self.control, choices, dtype, rounding, min_max, radio_columns, - control_width) - if self.control == ttk.Scale: - ctl = self.slider_control(dtype, rounding, min_max) - elif self.control == ttk.Radiobutton: - ctl = self.radio_control(choices, radio_columns) - else: - ctl = self.control_to_optionsframe(choices) - self.set_control_width(ctl, control_width) - ctl.pack(padx=5, pady=5, fill=tk.X, expand=True) - logger.debug("Built control: '%s'", self.title) +class ContextMenu(tk.Menu): # pylint: disable=too-many-ancestors + """ Pop up menu """ + def __init__(self, widget): + logger.debug("Initializing %s: (widget_class: '%s')", + self.__class__.__name__, widget.winfo_class()) + super().__init__(tearoff=0) + self.widget = widget + self.standard_actions() + logger.debug("Initialized %s", self.__class__.__name__) - @staticmethod - def set_control_width(ctl, control_width): - """ Set the control width if required """ - if control_width is not None: - ctl.config(width=control_width) - - def radio_control(self, choices, columns): - """ Create a group of radio buttons """ - logger.debug("Adding radio group: %s", self.title) - ctl = ttk.Frame(self.frame) - frames = list() - for _ in range(columns): - frame = ttk.Frame(ctl) - frame.pack(padx=5, pady=5, fill=tk.X, expand=True, side=tk.LEFT, anchor=tk.N) - frames.append(frame) - - for idx, choice in enumerate(choices): - frame_id = idx % columns - radio = ttk.Radiobutton(frames[frame_id], - text=choice.title(), - value=choice, - variable=self.tk_var) - radio.pack(anchor=tk.W) - logger.debug("Adding radio option %s to column %s", choice, frame_id) - logger.debug("Added radio group: '%s'", self.title) - return ctl - - def slider_control(self, dtype, rounding, min_max): - """ A slider control with corresponding Entry box """ - logger.debug("Add slider control to Options Frame: (title: '%s', dtype: %s, rounding: %s, " - "min_max: %s)", self.title, dtype, rounding, min_max) - tbox = ttk.Entry(self.frame, width=8, textvariable=self.tk_var, justify=tk.RIGHT) - tbox.pack(padx=(0, 5), side=tk.RIGHT) - ctl = self.control( - self.frame, - variable=self.tk_var, - command=lambda val, var=self.tk_var, dt=dtype, rn=rounding, mm=min_max: - set_slider_rounding(val, var, dt, rn, mm)) - rc_menu = ContextMenu(tbox) - rc_menu.cm_bind() - ctl["from_"] = min_max[0] - ctl["to"] = min_max[1] - logger.debug("Added slider control to Options Frame: %s", self.title) - return ctl - - def control_to_optionsframe(self, choices): - """ Standard non-check buttons sit in the main options frame """ - logger.debug("Add control to Options Frame: (title: '%s', control: %s, choices: %s)", - self.title, self.control, choices) - if self.control == ttk.Checkbutton: - ctl = self.control(self.frame, variable=self.tk_var, text=None) + def standard_actions(self): + """ Standard menu actions """ + self.add_command(label="Cut", command=lambda: self.widget.event_generate("<>")) + self.add_command(label="Copy", command=lambda: self.widget.event_generate("<>")) + self.add_command(label="Paste", command=lambda: self.widget.event_generate("<>")) + self.add_separator() + self.add_command(label="Select all", command=self.select_all) + + def cm_bind(self): + """ Bind the menu to the widget's Right Click event """ + button = "" if platform.system() == "Darwin" else "" + logger.debug("Binding '%s' to '%s'", button, self.widget.winfo_class()) + scaling_factor = get_config().scaling_factor if get_config() is not None else 1.0 + x_offset = int(34 * scaling_factor) + self.widget.bind(button, + lambda event: self.tk_popup(event.x_root + x_offset, event.y_root, 0)) + + def select_all(self): + """ Select all for Text or Entry widgets """ + logger.debug("Selecting all for '%s'", self.widget.winfo_class()) + if self.widget.winfo_class() == "Text": + self.widget.focus_force() + self.widget.tag_add("sel", "1.0", "end") else: - ctl = self.control(self.frame, textvariable=self.tk_var) - rc_menu = ContextMenu(ctl) - rc_menu.cm_bind() - if choices: - logger.debug("Adding combo choices: %s", choices) - ctl["values"] = [choice for choice in choices] - logger.debug("Added control to Options Frame: %s", self.title) - return ctl + self.widget.focus_force() + self.widget.select_range(0, tk.END) class LongRunningTask(Thread): diff --git a/tools/preview.py b/tools/preview.py index ef6601737d..78a925df0a 100644 --- a/tools/preview.py +++ b/tools/preview.py @@ -16,7 +16,8 @@ from lib.aligner import Extract as AlignerExtract from lib.cli import ConvertArgs -from lib.gui.utils import ControlBuilder, get_images, initialize_images +from lib.gui.control_helper import ControlBuilder +from lib.gui.utils import get_images, initialize_images from lib.gui.tooltip import Tooltip from lib.convert import Converter from lib.faces_detect import DetectedFace @@ -573,7 +574,7 @@ def get_config_dicts(self): def reset_config_saved(self, section=None): """ Reset config to saved values """ logger.debug("Resetting to saved config: %s", section) - sections = [section] if section is not None else [key for key in self.tk_vars.keys()] + sections = [section] if section is not None else list(self.tk_vars.keys()) for config_section in sections: for item, options in self.config_dicts[config_section].items(): if item == "helptext": @@ -587,7 +588,7 @@ def reset_config_saved(self, section=None): def reset_config_default(self, section=None): """ Reset config to default values """ logger.debug("Resetting to default: %s", section) - sections = [section] if section is not None else [key for key in self.tk_vars.keys()] + sections = [section] if section is not None else list(self.tk_vars.keys()) for config_section in sections: for item, options in self.config.defaults[config_section].items(): if item == "helptext": From 1ff98eb5bea0c14f1001867f553f6b39ca505d3b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 23 Aug 2019 17:35:09 +0100 Subject: [PATCH 003/981] Bugfix: Reinsert jobs and gpu for convert --- lib/cli.py | 87 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 56 insertions(+), 31 deletions(-) diff --git a/lib/cli.py b/lib/cli.py index 8db49b1905..bcb4280f2b 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -590,6 +590,29 @@ def get_optional_arguments(): "Pass in a single number to use increments of that size up " "to 360, or pass in a list of numbers to enumerate exactly " "what angles to check"}) + argument_list.append({"opts": ("-min", "--min-size"), + "type": int, + "action": Slider, + "dest": "min_size", + "min_max": (0, 1080), + "default": 0, + "rounding": 20, + "group": "Face Processing", + "help": "Filters out faces detected below this size. Length, in " + "pixels across the diagonal of the bounding box. Set to 0 " + "for off"}) + argument_list.append({"opts": ("-een", "--extract-every-n"), + "type": int, + "action": Slider, + "dest": "extract_every_n", + "min_max": (1, 100), + "default": 1, + "rounding": 1, + "group": "Face Processing", + "help": "Extract every 'nth' frame. This option will skip frames " + "when extracting faces. For example a value of 1 will " + "extract faces from every frame, a value of 10 will extract " + "faces from every 10th frame."}) argument_list.append({"opts": ("-n", "--nfilter"), "action": FilesFullPaths, "filetypes": "image", @@ -645,7 +668,6 @@ def get_optional_arguments(): "action": "store_true", "default": False, "backend": "nvidia", - "help": "Don't run extraction in parallel. Will run detection first " "then alignment (2 passes). Useful if VRAM is at a " "premium."}) @@ -659,29 +681,6 @@ def get_optional_arguments(): "help": "The output size of extracted faces. Make sure that the " "model you intend to train supports your required size. " "This will only need to be changed for hi-res models."}) - argument_list.append({"opts": ("-min", "--min-size"), - "type": int, - "action": Slider, - "dest": "min_size", - "min_max": (0, 1080), - "default": 0, - "rounding": 20, - "group": "Face Processing", - "help": "Filters out faces detected below this size. Length, in " - "pixels across the diagonal of the bounding box. Set to 0 " - "for off"}) - argument_list.append({"opts": ("-een", "--extract-every-n"), - "type": int, - "action": Slider, - "dest": "extract_every_n", - "min_max": (1, 100), - "default": 1, - "rounding": 1, - "group": "Face Processing", - "help": "Extract every 'nth' frame. This option will skip frames " - "when extracting faces. For example a value of 1 will " - "extract faces from every frame, a value of 10 will extract " - "faces from every 10th frame."}) argument_list.append({"opts": ("-s", "--skip-existing"), "action": "store_true", "dest": "skip_existing", @@ -897,6 +896,39 @@ def get_optional_arguments(): "NB: Using face filter will significantly decrease " "extraction speed and its accuracy cannot be " "guaranteed."}) + + argument_list.append({"opts": ("-j", "--jobs"), + "dest": "jobs", + "action": Slider, + "group": "settings", + "type": int, + "default": 0, + "min_max": (0, 40), + "rounding": 1, + "help": "The maximum number of parallel processes for performing " + "conversion. Converting images is system RAM heavy so it is " + "possible to run out of memory if you have a lot of " + "processes and not enough RAM to accomodate them all. " + "Setting this to 0 will use the maximum available. No " + "matter what you set this to, it will never attempt to use " + "more processes than are available on your system. If " + "singleprocess is enabled this setting will be ignored."}) + argument_list.append({"opts": ("-g", "--gpus"), + "type": int, + "backend": "nvidia", + "action": Slider, + "min_max": (1, 10), + "rounding": 1, + "group": "settings", + "default": 1, + "help": "Number of GPUs to use for conversion"}) + argument_list.append({"opts": ("-t", "--trainer"), + "type": str.lower, + "choices": PluginLoader.get_available_models(), + "group": "settings", + "help": "[LEGACY] This only needs to be selected if a legacy " + "model is being loaded or if there are multiple models in " + "the model folder"}) argument_list.append({"opts": ("-k", "--keep-unchanged"), "action": "store_true", "dest": "keep_unchanged", @@ -914,13 +946,6 @@ def get_optional_arguments(): "default": False, "help": "Disable multiprocessing. Slower but less resource " "intensive."}) - argument_list.append({"opts": ("-t", "--trainer"), - "type": str.lower, - "choices": PluginLoader.get_available_models(), - "help": "[LEGACY] This only needs to be selected if a legacy " - "model is being loaded or if there are multiple models in " - "the model folder"}) - return argument_list From 0b3d70e2e0afe5f9bf77465d33b7e76e02716465 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 23 Aug 2019 17:36:04 +0100 Subject: [PATCH 004/981] GUI Control Handler change --- lib/gui/display_command.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/gui/display_command.py b/lib/gui/display_command.py index 2f079aad6e..6c15ff9a1b 100644 --- a/lib/gui/display_command.py +++ b/lib/gui/display_command.py @@ -12,7 +12,8 @@ from .display_page import DisplayOptionalPage from .tooltip import Tooltip from .stats import Calculations -from .utils import FileHandler, get_config, get_images, set_slider_rounding +from .control_helper import set_slider_rounding +from .utils import FileHandler, get_config, get_images logger = logging.getLogger(__name__) # pylint: disable=invalid-name From dd3288672a7e8c1a57ea96fb36876617b29b3436 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 24 Aug 2019 20:40:44 +0100 Subject: [PATCH 005/981] GUI Update - Launch in fullscreen - Expand Control Helper. Clean up config popup - Standardize and overhaul GUI for CliOpts --- lib/cli.py | 162 +++++++++------- lib/gui/command.py | 351 +-------------------------------- lib/gui/control_helper.py | 383 ++++++++++++++++++++++++++++++------- lib/gui/options.py | 160 ++++++++-------- lib/gui/popup_configure.py | 9 +- scripts/gui.py | 1 + 6 files changed, 497 insertions(+), 569 deletions(-) diff --git a/lib/cli.py b/lib/cli.py index bcb4280f2b..d7f5df4316 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -601,18 +601,6 @@ def get_optional_arguments(): "help": "Filters out faces detected below this size. Length, in " "pixels across the diagonal of the bounding box. Set to 0 " "for off"}) - argument_list.append({"opts": ("-een", "--extract-every-n"), - "type": int, - "action": Slider, - "dest": "extract_every_n", - "min_max": (1, 100), - "default": 1, - "rounding": 1, - "group": "Face Processing", - "help": "Extract every 'nth' frame. This option will skip frames " - "when extracting faces. For example a value of 1 will " - "extract faces from every frame, a value of 10 will extract " - "faces from every 10th frame."}) argument_list.append({"opts": ("-n", "--nfilter"), "action": FilesFullPaths, "filetypes": "image", @@ -668,42 +656,46 @@ def get_optional_arguments(): "action": "store_true", "default": False, "backend": "nvidia", + "group": "settings", "help": "Don't run extraction in parallel. Will run detection first " "then alignment (2 passes). Useful if VRAM is at a " "premium."}) - argument_list.append({"opts": ("-sz", "--size"), - "type": int, - "action": Slider, - "min_max": (128, 512), - "default": 256, - "rounding": 64, - "group": "output", - "help": "The output size of extracted faces. Make sure that the " - "model you intend to train supports your required size. " - "This will only need to be changed for hi-res models."}) argument_list.append({"opts": ("-s", "--skip-existing"), "action": "store_true", "dest": "skip_existing", + "group": "skipping", "default": False, "help": "Skips frames that have already been extracted and exist in " "the alignments file"}) argument_list.append({"opts": ("-sf", "--skip-existing-faces"), "action": "store_true", "dest": "skip_faces", + "group": "skipping", "default": False, "help": "Skip frames that already have detected faces in the " "alignments file"}) - argument_list.append({"opts": ("-dl", "--debug-landmarks"), - "action": "store_true", - "dest": "debug_landmarks", - "default": False, - "help": "Draw landmarks on the ouput faces for debugging purposes."}) - argument_list.append({"opts": ("-ae", "--align-eyes"), - "action": "store_true", - "dest": "align_eyes", - "default": False, - "help": "Perform extra alignment to ensure left/right eyes are at " - "the same height"}) + argument_list.append({"opts": ("-een", "--extract-every-n"), + "type": int, + "action": Slider, + "dest": "extract_every_n", + "min_max": (1, 100), + "default": 1, + "rounding": 1, + "group": "output", + "help": "Extract every 'nth' frame. This option will skip frames " + "when extracting faces. For example a value of 1 will " + "extract faces from every frame, a value of 10 will extract " + "faces from every 10th frame."}) + argument_list.append({"opts": ("-sz", "--size"), + "type": int, + "action": Slider, + "min_max": (128, 512), + "default": 256, + "rounding": 64, + "group": "output", + "help": "The output size of extracted faces. Make sure that the " + "model you intend to train supports your required size. " + "This will only need to be changed for hi-res models."}) argument_list.append({"opts": ("-si", "--save-interval"), "dest": "save_interval", "type": int, @@ -719,6 +711,19 @@ def get_optional_arguments(): "saved out during the second pass. WARNING: Don't interrupt " "the script when writing the file because it might get " "corrupted. Set to 0 to turn off"}) + argument_list.append({"opts": ("-dl", "--debug-landmarks"), + "action": "store_true", + "dest": "debug_landmarks", + "group": "output", + "default": False, + "help": "Draw landmarks on the ouput faces for debugging purposes."}) + argument_list.append({"opts": ("-ae", "--align-eyes"), + "action": "store_true", + "dest": "align_eyes", + "group": "output", + "default": False, + "help": "Perform extra alignment to ensure left/right eyes are at " + "the same height"}) return argument_list @@ -932,17 +937,20 @@ def get_optional_arguments(): argument_list.append({"opts": ("-k", "--keep-unchanged"), "action": "store_true", "dest": "keep_unchanged", + "group": "Frame Processing", "default": False, "help": "When used with --frame-ranges outputs the unchanged frames " "that are not processed instead of discarding them."}) argument_list.append({"opts": ("-s", "--swap-model"), "action": "store_true", "dest": "swap_model", + "group": "settings", "default": False, "help": "Swap the model. Instead converting from of A -> B, " "converts B -> A"}) argument_list.append({"opts": ("-sp", "--singleprocess"), "action": "store_true", + "group": "settings", "default": False, "help": "Disable multiprocessing. Slower but less resource " "intensive."}) @@ -1067,15 +1075,39 @@ def get_argument_list(): "group": "training", "default": 1, "help": "Number of GPUs to use for training"}) - argument_list.append({"opts": ("-ps", "--preview-scale"), - "type": int, - "action": Slider, - "dest": "preview_scale", - "min_max": (25, 200), - "group": "training", - "rounding": 25, - "default": 50, - "help": "Percentage amount to scale the preview by."}) + argument_list.append({"opts": ("-msg", "--memory-saving-gradients"), + "action": "store_true", + "dest": "memory_saving_gradients", + "group": "VRAM Savings", + "default": False, + "backend": "nvidia", + "help": "Trades off VRAM usage against computation time. Can fit " + "larger models into memory at a cost of slower training " + "speed. 50%%-150%% batch size increase for 20%%-50%% longer " + "training time. NB: Launch time will be significantly " + "delayed. Switching sides using ping-pong training will " + "take longer."}) + argument_list.append({"opts": ("-o", "--optimizer-savings"), + "dest": "optimizer_savings", + "action": "store_true", + "default": False, + "group": "VRAM Savings", + "backend": "nvidia", + "help": "To save VRAM some optimizer gradient calculations can be " + "performed on the CPU rather than the GPU. This allows you " + "to increase batchsize at a training speed/system RAM " + "cost."}) + argument_list.append({"opts": ("-pp", "--ping-pong"), + "action": "store_true", + "dest": "pingpong", + "group": "VRAM Savings", + "default": False, + "backend": "nvidia", + "help": "Enable ping pong training. Trains one side at a time, " + "switching sides at each save iteration. Training will " + "take 2 to 4 times longer, with about a 30%%-50%% reduction " + "in VRAM useage. NB: Preview won't show until both sides " + "have been trained once."}) argument_list.append({"opts": ("-s", "--save-interval"), "type": int, "action": Slider, @@ -1128,20 +1160,32 @@ def get_argument_list(): "folder at every save iteration. If the input folders are " "supplied but no output folder, it will default to your " "model folder /timelapse/"}) + argument_list.append({"opts": ("-ps", "--preview-scale"), + "type": int, + "action": Slider, + "dest": "preview_scale", + "min_max": (25, 200), + "group": "preview", + "rounding": 25, + "default": 50, + "help": "Percentage amount to scale the preview by."}) argument_list.append({"opts": ("-p", "--preview"), "action": "store_true", "dest": "preview", + "group": "preview", "default": False, "help": "Show training preview output. in a separate window."}) argument_list.append({"opts": ("-w", "--write-image"), "action": "store_true", "dest": "write_image", + "group": "preview", "default": False, "help": "Writes the training result to a file. The image will be " "stored in the root of your FaceSwap folder."}) argument_list.append({"opts": ("-ag", "--allow-growth"), "action": "store_true", "dest": "allow_growth", + "group": "training", "default": False, "backend": "nvidia", "help": "Sets allow_growth option of Tensorflow to spare memory " @@ -1149,43 +1193,15 @@ def get_argument_list(): argument_list.append({"opts": ("-nl", "--no-logs"), "action": "store_true", "dest": "no_logs", + "group": "training", "default": False, "help": "Disables TensorBoard logging. NB: Disabling logs means " "that you will not be able to use the graph or analysis " "for this session in the GUI."}) - argument_list.append({"opts": ("-msg", "--memory-saving-gradients"), - "action": "store_true", - "dest": "memory_saving_gradients", - "default": False, - "backend": "nvidia", - "help": "Trades off VRAM usage against computation time. Can fit " - "larger models into memory at a cost of slower training " - "speed. 50%%-150%% batch size increase for 20%%-50%% longer " - "training time. NB: Launch time will be significantly " - "delayed. Switching sides using ping-pong training will " - "take longer."}) - argument_list.append({"opts": ("-o", "--optimizer-savings"), - "dest": "optimizer_savings", - "action": "store_true", - "default": False, - "backend": "nvidia", - "help": "To save VRAM some optimizer gradient calculations can be " - "performed on the CPU rather than the GPU. This allows you " - "to increase batchsize at a training speed/system RAM " - "cost."}) - argument_list.append({"opts": ("-pp", "--ping-pong"), - "action": "store_true", - "dest": "pingpong", - "default": False, - "backend": "nvidia", - "help": "Enable ping pong training. Trains one side at a time, " - "switching sides at each save iteration. Training will " - "take 2 to 4 times longer, with about a 30%%-50%% reduction " - "in VRAM useage. NB: Preview won't show until both sides " - "have been trained once."}) argument_list.append({"opts": ("-wl", "--warp-to-landmarks"), "action": "store_true", "dest": "warp_to_landmarks", + "group": "training", "default": False, "help": "Warps training faces to closely matched Landmarks from the " "opposite face-set rather than randomly warping the face. " @@ -1195,6 +1211,7 @@ def get_argument_list(): argument_list.append({"opts": ("-nf", "--no-flip"), "action": "store_true", "dest": "no_flip", + "group": "training", "default": False, "help": "To effectively learn, a random set of images are flipped " "horizontally. Sometimes it is desirable for this not to " @@ -1203,6 +1220,7 @@ def get_argument_list(): argument_list.append({"opts": ("-nac", "--no-augment-color"), "action": "store_true", "dest": "no_augment_color", + "group": "training", "default": False, "help": "Color augmentation helps make the model less susceptible " "to color differences between the A and B sets, at an " diff --git a/lib/gui/command.py b/lib/gui/command.py index ab28da263d..f4e6b08ee7 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -5,9 +5,9 @@ import tkinter as tk from tkinter import ttk -from .control_helper import set_slider_rounding +from .control_helper import set_slider_rounding, ControlPanel from .tooltip import Tooltip -from .utils import ContextMenu, FileHandler, get_images, get_config +from .utils import get_images, get_config logger = logging.getLogger(__name__) # pylint:disable=invalid-name @@ -18,8 +18,10 @@ class CommandNotebook(ttk.Notebook): # pylint:disable=too-many-ancestors def __init__(self, parent): logger.debug("Initializing %s: (parent: %s)", self.__class__.__name__, parent) scaling_factor = get_config().scaling_factor - width = int(420 * scaling_factor) - height = int(500 * scaling_factor) + width = int(470 * scaling_factor) + root_height = get_config().root.winfo_height() + height = int(round(root_height * 0.78125)) + self.actionbtns = dict() super().__init__(parent, width=width, height=height) parent.add(self) @@ -94,8 +96,8 @@ def __init__(self, parent, category, command): def build_tab(self): """ Build the tab """ logger.debug("Build Tab: '%s'", self.command) - OptionsFrame(self) - + options = get_config().cli_opts.opts[self.command] + ControlPanel(self, options, label_width=16, radio_columns=2, columns=2) self.add_frame_separator() ActionFrame(self) @@ -109,343 +111,6 @@ def add_frame_separator(self): logger.debug("Added frame seperator") -class OptionsFrame(ttk.Frame): # pylint:disable=too-many-ancestors - """ Options Frame - Holds the Options for each command """ - - def __init__(self, parent): - logger.debug("Initializing %s", self.__class__.__name__) - super().__init__(parent) - self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) - - self.command = parent.command - - self.canvas = tk.Canvas(self, bd=0, highlightthickness=0) - self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - - self.optsframe = ttk.Frame(self.canvas) - self.group_frames = dict() - self.optscanvas = self.canvas.create_window((0, 0), - window=self.optsframe, - anchor=tk.NW) - self.chkbtns = self.checkbuttons_frame() - - self.build_frame() - cli_opts = get_config().cli_opts - cli_opts.set_context_option(self.command) - logger.debug("Initialized %s", self.__class__.__name__) - - def checkbuttons_frame(self): - """ Build and format frame for holding the check buttons """ - logger.debug("Add Options CheckButtons Frame") - container = ttk.Frame(self.optsframe) - - lbl = ttk.Label(container, text="Options", width=16, anchor=tk.W) - lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) - - chkframe = ttk.Frame(container) - chkleft = ttk.Frame(chkframe, name="leftFrame") - chkright = ttk.Frame(chkframe, name="rightFrame") - - chkframe.pack(fill=tk.X, expand=True) - chkleft.pack(padx=5, pady=5, fill=tk.X, expand=True, side=tk.LEFT, anchor=tk.N) - chkright.pack(padx=5, pady=5, fill=tk.X, expand=True, side=tk.RIGHT, anchor=tk.N) - logger.debug("Added Options CheckButtons Frame") - - return container, chkframe - - def build_frame(self): - """ Build the options frame for this command """ - logger.debug("Add Options Frame") - self.add_scrollbar() - self.canvas.bind("", self.resize_frame) - - cli_opts = get_config().cli_opts - for option in cli_opts.gen_command_options(self.command): - group = option["group"] - frame = self.optsframe - if group is not None: - group = group.lower() - if self.group_frames.get(group, None) is None: - group_frame = ttk.LabelFrame(self.optsframe, text=group.title()) - group_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5) - self.group_frames[group] = group_frame - frame = self.group_frames[group] - - optioncontrol = OptionControl(self.command, - option, - frame, - self.chkbtns[1]) - optioncontrol.build_full_control() - - if self.chkbtns[1].winfo_children(): - self.chkbtns[0].pack(side=tk.BOTTOM, fill=tk.X, expand=True) - logger.debug("Added Options Frame") - - def add_scrollbar(self): - """ Add a scrollbar to the options frame """ - logger.debug("Add Options Scrollbar") - scrollbar = ttk.Scrollbar(self, command=self.canvas.yview) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - self.canvas.config(yscrollcommand=scrollbar.set) - self.optsframe.bind("", self.update_scrollbar) - logger.debug("Added Options Scrollbar") - - def update_scrollbar(self, event): # pylint:disable=unused-argument - """ Update the options frame scrollbar """ - self.canvas.configure(scrollregion=self.canvas.bbox("all")) - - def resize_frame(self, event): - """ Resize the options frame to fit the canvas """ - logger.debug("Resize Options Frame") - canvas_width = event.width - self.canvas.itemconfig(self.optscanvas, width=canvas_width) - logger.debug("Resized Options Frame") - - -class OptionControl(): - """ Build the correct control for the option parsed and place it on the - frame """ - - def __init__(self, command, option, option_frame, checkbuttons_frame): - logger.debug("Initializing %s", self.__class__.__name__) - self.command = command - self.option = option - self.option_frame = option_frame - self.chkbtns = checkbuttons_frame - logger.debug("Initialized %s", self.__class__.__name__) - - def build_full_control(self): - """ Build the correct control type for the option passed through """ - logger.debug("Build option control") - ctl = self.option["control"] - ctltitle = self.option["control_title"] - sysbrowser = self.option["filesystem_browser"] - ctlhelp = self.format_help(ctltitle) - dflt = self.option.get("default", "") - if self.option.get("nargs", None) and isinstance(dflt, (list, tuple)): - dflt = ' '.join(str(val) for val in dflt) - if ctl == ttk.Checkbutton: - dflt = self.option.get("default", False) - choices = self.option["choices"] if ctl in(ttk.Combobox, ttk.Radiobutton) else None - min_max = self.option["min_max"] if ctl == ttk.Scale else None - - ctlframe = self.build_one_control_frame() - - if ctl != ttk.Checkbutton: - self.build_one_control_label(ctlframe, ctltitle) - - ctlvars = (ctl, ctltitle, dflt, ctlhelp) - self.option["value"] = self.build_one_control(ctlframe, - ctlvars, - choices, - min_max, - sysbrowser) - logger.debug("Built option control") - - def format_help(self, ctltitle): - """ Format the help text for tooltips """ - logger.debug("Format control help: '%s'", ctltitle) - ctlhelp = self.option.get("help", "") - if ctlhelp.startswith("R|"): - ctlhelp = ctlhelp[2:].replace("\nL|", "\n - ").replace("\n", "\n\n") - else: - ctlhelp = " ".join(ctlhelp.split()) - ctlhelp = ctlhelp.replace("%%", "%") - ctlhelp = ". ".join(i.capitalize() for i in ctlhelp.split(". ")) - ctlhelp = ctltitle + " - " + ctlhelp - logger.debug("Formatted control help: (title: '%s', help: '%s'", ctltitle, ctlhelp) - return ctlhelp - - def build_one_control_frame(self): - """ Build the frame to hold the control """ - logger.debug("Build control frame") - frame = ttk.Frame(self.option_frame) - frame.pack(fill=tk.X, expand=True) - logger.debug("Built control frame") - return frame - - @staticmethod - def build_one_control_label(frame, control_title): - """ Build and place the control label """ - logger.debug("Build control label: '%s'", control_title) - lbl = ttk.Label(frame, text=control_title, width=16, anchor=tk.W) - lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) - logger.debug("Built control label: '%s'", control_title) - - def build_one_control(self, frame, controlvars, choices, min_max, sysbrowser): - """ Build and place the option controls """ - logger.debug("Build control: (controlvars: %s, choices: %s, min_max: %s, sysbrowser: %s", - controlvars, choices, min_max, sysbrowser) - control, control_title, default, helptext = controlvars - default = default if default is not None else "" - - var = tk.BooleanVar(frame) if control == ttk.Checkbutton else tk.StringVar(frame) - var.set(default) - - if sysbrowser: - self.add_browser_buttons(frame, sysbrowser, var) - - if control == ttk.Checkbutton: - self.checkbutton_to_checkframe(control, control_title, var, helptext) - elif control == ttk.Radiobutton: - self.radio_control(frame, control_title, var, choices, helptext) - elif control == ttk.Scale: - self.slider_control(control, frame, var, min_max, helptext) - else: - self.control_to_optionsframe(control, frame, var, choices, helptext) - logger.debug("Built control: '%s'", control_title) - return var - - @staticmethod - def radio_control(frame, control_title, var, choices, helptext): - """ Create a group of radio buttons """ - logger.debug("Adding radio group: %s", control_title) - radio_frame_left = ttk.Frame(frame) - radio_frame_middle = ttk.Frame(frame) - radio_frame_right = ttk.Frame(frame) - - radio_frame_left.pack(padx=5, pady=5, fill=tk.X, expand=True, side=tk.LEFT, anchor=tk.N) - radio_frame_middle.pack(padx=5, pady=5, fill=tk.X, expand=True, side=tk.LEFT, anchor=tk.N) - radio_frame_right.pack(padx=5, pady=5, fill=tk.X, expand=True, side=tk.RIGHT, anchor=tk.N) - - for idx, choice in enumerate(choices): - pos = idx + 1 - if pos % 3 == 0: - radio_frame = radio_frame_right - elif (pos + 1) % 3 == 0: - radio_frame = radio_frame_middle - else: - radio_frame = radio_frame_left - - ctl = ttk.Radiobutton(radio_frame, text=choice.title(), value=choice, variable=var) - ctl.pack(anchor=tk.W) - Tooltip(ctl, text=helptext, wraplength=920) - logger.debug("Added radio group: '%s'", control_title) - - def checkbutton_to_checkframe(self, control, control_title, var, helptext): - """ Add checkbuttons to the checkbutton frame """ - logger.debug("Add control checkframe: '%s'", control_title) - leftframe = self.chkbtns.children["leftFrame"] - rightframe = self.chkbtns.children["rightFrame"] - chkbtn_count = len({**leftframe.children, **rightframe.children}) - - frame = leftframe if chkbtn_count % 2 == 0 else rightframe - - ctl = control(frame, variable=var, text=control_title) - ctl.pack(side=tk.TOP, anchor=tk.W) - - Tooltip(ctl, text=helptext, wraplength=200) - logger.debug("Added control checkframe: '%s'", control_title) - - def slider_control(self, control, frame, tk_var, min_max, helptext): - """ A slider control with corresponding Entry box """ - logger.debug("Add slider control to Options Frame: %s", control) - d_type = self.option.get("type", float) - rnd = self.option.get("rounding", 2) if d_type == float else self.option.get("rounding", 1) - - tbox = ttk.Entry(frame, width=8, textvariable=tk_var, justify=tk.RIGHT) - tbox.pack(padx=(0, 5), side=tk.RIGHT) - ctl = control( - frame, - variable=tk_var, - command=lambda val, var=tk_var, dt=d_type, rn=rnd, mm=min_max: - set_slider_rounding(val, var, dt, rn, mm)) - ctl.pack(padx=5, pady=5, fill=tk.X, expand=True) - rc_menu = ContextMenu(ctl) - rc_menu.cm_bind() - ctl["from_"] = min_max[0] - ctl["to"] = min_max[1] - - Tooltip(ctl, text=helptext, wraplength=920) - Tooltip(tbox, text=helptext, wraplength=920) - logger.debug("Added slider control to Options Frame: %s", control) - - @staticmethod - def control_to_optionsframe(control, frame, var, choices, helptext): - """ Standard non-check buttons sit in the main options frame """ - logger.debug("Add control to Options Frame: %s", control) - ctl = control(frame, textvariable=var) - ctl.pack(padx=5, pady=5, fill=tk.X, expand=True) - rc_menu = ContextMenu(ctl) - rc_menu.cm_bind() - if control == ttk.Combobox: - logger.debug("Adding combo choices: %s", choices) - ctl["values"] = [choice for choice in choices] - Tooltip(ctl, text=helptext, wraplength=920) - logger.debug("Added control to Options Frame: %s", control) - - def add_browser_buttons(self, frame, sysbrowser, filepath): - """ Add correct file browser button for control """ - logger.debug("Adding browser buttons: (sysbrowser: '%s', filepath: '%s'", - sysbrowser, filepath) - for browser in sysbrowser: - img = get_images().icons[browser] - action = getattr(self, "ask_" + browser) - filetypes = self.option.get("filetypes", "default") - fileopn = ttk.Button(frame, - image=img, - command=lambda cmd=action: cmd(filepath, filetypes)) - fileopn.pack(padx=(0, 5), side=tk.RIGHT) - logger.debug("Added browser buttons: (action: %s, filetypes: %s", - action, filetypes) - - @staticmethod - def ask_folder(filepath, filetypes=None): - """ Pop-up to get path to a directory - :param filepath: tkinter StringVar object - that will store the path to a directory. - :param filetypes: Unused argument to allow - filetypes to be given in ask_load(). """ - dirname = FileHandler("dir", filetypes).retfile - if dirname: - logger.debug(dirname) - filepath.set(dirname) - - @staticmethod - def ask_load(filepath, filetypes): - """ Pop-up to get path to a file """ - filename = FileHandler("filename", filetypes).retfile - if filename: - logger.debug(filename) - filepath.set(filename) - - @staticmethod - def ask_load_multi(filepath, filetypes): - """ Pop-up to get path to a file """ - filenames = FileHandler("filename_multi", filetypes).retfile - if filenames: - final_names = " ".join("\"{}\"".format(fname) for fname in filenames) - logger.debug(final_names) - filepath.set(final_names) - - @staticmethod - def ask_save(filepath, filetypes=None): - """ Pop-up to get path to save a new file """ - filename = FileHandler("savefilename", filetypes).retfile - if filename: - logger.debug(filename) - filepath.set(filename) - - @staticmethod - def ask_nothing(filepath, filetypes=None): # pylint:disable=unused-argument - """ Method that does nothing, used for disabling open/save pop up """ - return - - def ask_context(self, filepath, filetypes): - """ Method to pop the correct dialog depending on context """ - logger.debug("Getting context filebrowser") - selected_action = self.option["action_option"].get() - selected_variable = self.option["dest"] - filename = FileHandler("context", - filetypes, - command=self.command, - action=selected_action, - variable=selected_variable).retfile - if filename: - logger.debug(filename) - filepath.set(filename) - - class ActionFrame(ttk.Frame): # pylint:disable=too-many-ancestors """Action Frame - Displays action controls for the command tab """ diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index acb42e2d98..cba8aa3c14 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -5,7 +5,7 @@ from tkinter import ttk from .tooltip import Tooltip -from .utils import ContextMenu +from .utils import ContextMenu, FileHandler, get_images logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -31,15 +31,19 @@ class ControlPanel(ttk.Frame): # pylint:disable=too-many-ancestors Also keeps tally if groups passed in, so that any options with special processing needs are processed in the correct group frame """ - def __init__(self, parent, options, items_per_row=1, radio_columns=4, header_text=None): - logger.debug("Initializing %s: (parent: '%s', options: %s, items_per_row: %s, " - "radio_columns: %s, header_text: %s)", - self.__class__.__name__, parent, options, items_per_row, radio_columns, - header_text) + def __init__(self, parent, options, label_width=20, columns=1, radio_columns=4, + header_text=None, blank_nones=True): + logger.debug("Initializing %s: (parent: '%s', options: %s, label_width: %s, columns: %s, " + "radio_columns: %s, header_text: %s, blank_nones: %s)", + self.__class__.__name__, parent, options, label_width, columns, radio_columns, + header_text, blank_nones) super().__init__(parent) self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) self.options = options + self.label_width = label_width + self.columns = columns + self.radio_columns = radio_columns self.header_text = header_text self.group_frames = dict() @@ -47,48 +51,96 @@ def __init__(self, parent, options, items_per_row=1, radio_columns=4, header_tex self.canvas = tk.Canvas(self, bd=0, highlightthickness=0) self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - self.optsframe = ttk.Frame(self.canvas) - self.optscanvas = self.canvas.create_window((0, 0), window=self.optsframe, anchor=tk.NW) + self.mainframe, self.optsframe = self.get_opts_frame() + self.optscanvas = self.canvas.create_window((0, 0), window=self.mainframe, anchor=tk.NW) - self.build_panel(radio_columns) + self.build_panel(radio_columns, blank_nones) logger.debug("Initialized %s", self.__class__.__name__) - def build_panel(self, radio_columns): + def get_opts_frame(self): + """ Return an autofill container for the options inside a main frame """ + mainframe = ttk.Frame(self.canvas) + if self.header_text is not None: + self.add_info(mainframe) + optsframe = ttk.Frame(mainframe) + optsframe.pack(expand=True, fill=tk.BOTH) + holder = AutoFillContainer(optsframe, self.columns) + logger.debug("Opts frames: '%s'", holder) + return mainframe, holder + + def add_info(self, frame): + """ Plugin information """ + gui_style = ttk.Style() + gui_style.configure('White.TFrame', background='#FFFFFF') + gui_style.configure('Header.TLabel', background='#FFFFFF', font=("", 9, "bold")) + gui_style.configure('Body.TLabel', background='#FFFFFF', font=("", 9)) + + info_frame = ttk.Frame(frame, style='White.TFrame', relief=tk.SOLID) + info_frame.pack(fill=tk.X, side=tk.TOP, expand=True, padx=10, pady=10) + label_frame = ttk.Frame(info_frame, style='White.TFrame') + label_frame.pack(padx=5, pady=5, fill=tk.X, expand=True) + for idx, line in enumerate(self.header_text.splitlines()): + if not line: + continue + style = "Header.TLabel" if idx == 0 else "Body.TLabel" + info = ttk.Label(label_frame, text=line, style=style, anchor=tk.W) + info.pack(fill=tk.X, padx=0, pady=0, expand=True, side=tk.TOP) + info.bind("", adjust_wraplength) + + def build_panel(self, radio_columns, blank_nones): """ Build the options frame for this command """ logger.debug("Add Config Frame") self.add_scrollbar() self.canvas.bind("", self.resize_frame) - self.add_info() for key, val in self.options.items(): if key == "helptext": continue - frame = self.get_holding_frame(val["group"]) - ctl = ControlBuilder(frame, + group = "_master" if val["group"] is None else val["group"] + group_frame = self.get_group_frame(group) + ctl = ControlBuilder(group_frame["frame"], key, val["type"], val["default"], + label_width=self.label_width, selected_value=val["value"], choices=val["choices"], is_radio=val["gui_radio"], rounding=val["rounding"], min_max=val["min_max"], helptext=val["helptext"], - radio_columns=radio_columns) + sysbrowser=val.get("sysbrowser", None), + checkbuttons_frame=group_frame["chkbtns"], + radio_columns=radio_columns, + blank_nones=blank_nones) + if group_frame["chkbtns"].items > 0: + group_frame["chkbtns"].parent.pack(side=tk.BOTTOM, fill=tk.X, anchor=tk.NW) val["selected"] = ctl.tk_var + self.options[key]["_gui_option"] = ctl + for key, val in self.options.items(): + if key == "helptext": + continue + filebrowser = val["_gui_option"].filebrowser + if filebrowser is not None: + filebrowser.set_context_action_option(self.options) logger.debug("Added Config Frame") - def get_holding_frame(self, group): - """ Return either the main options frame or a group frame """ - if group is None: - return self.optsframe + def get_group_frame(self, group): + """ Return a new group frame """ group = group.lower() if self.group_frames.get(group, None) is None: logger.debug("Creating new group frame for: %s", group) - group_frame = ttk.LabelFrame(self.optsframe, text=group.title()) - group_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5) - self.group_frames[group] = group_frame - return self.group_frames[group] + is_master = group == "_master" + opts_frame = self.optsframe.subframe + group_frame = ttk.LabelFrame(opts_frame, + text="" if is_master else group.title(), + name=group.lower()) + group_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5, anchor=tk.NW) + + self.group_frames[group] = dict(frame=group_frame, + chkbtns=self.checkbuttons_frame(group_frame)) + group_frame = self.group_frames[group] + return group_frame def add_scrollbar(self): """ Add a scrollbar to the options frame """ @@ -96,7 +148,7 @@ def add_scrollbar(self): scrollbar = ttk.Scrollbar(self, command=self.canvas.yview) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) self.canvas.config(yscrollcommand=scrollbar.set) - self.optsframe.bind("", self.update_scrollbar) + self.mainframe.bind("", self.update_scrollbar) logger.debug("Added Config Scrollbar") def update_scrollbar(self, event): # pylint: disable=unused-argument @@ -110,19 +162,66 @@ def resize_frame(self, event): self.canvas.itemconfig(self.optscanvas, width=canvas_width) logger.debug("Resized Config Frame") - def add_info(self): - """ Plugin information """ - info_frame = ttk.Frame(self.optsframe) - info_frame.pack(fill=tk.X, expand=True) - lbl = ttk.Label(info_frame, text="About:", width=20, anchor=tk.W) - lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) - info = ttk.Label(info_frame, text=self.header_text) - info.pack(padx=5, pady=5, fill=tk.X, expand=True) - info.bind("", adjust_wraplength) + def checkbuttons_frame(self, frame): + """ Build and format frame for holding the check buttons + if is_master then check buttons will be placed in a LabelFrame + otherwise in a standard frame """ + logger.debug("Add Options CheckButtons Frame") + chk_frame = ttk.Frame(frame, name="chkbuttons") + holder = AutoFillContainer(chk_frame, self.radio_columns) + logger.debug("Added Options CheckButtons Frame") + return holder + + +class AutoFillContainer(): + """ A container object that autofills columns """ + def __init__(self, parent, columns): + logger.debug("Initializing: %s: (parent: %s, columns: %s)", self.__class__.__name__, + parent, columns) + self.parent = parent + self.columns = columns + self._items = 0 + self._idx = 0 + self.subframes = self.set_subframes() + logger.debug("Initialized: %s: (items: %s)", self.__class__.__name__, self.items) + + @property + def items(self): + """ Returns the number if items held in this containter """ + return self._items + + @property + def subframe(self): + """ Returns the next subframe to be populated """ + frame = self.subframes[self._idx] + next_idx = self._idx + 1 if self._idx + 1 != self.columns else 0 + logger.debug("current_idx: %s, next_idx: %s", self._idx, next_idx) + self._idx = next_idx + return frame + + @property + def last_subframe(self): + """ Returns the last column """ + return self.subframes[self.columns - 1] + + def set_subframes(self): + """ Set a subrame for each requested column """ + subframes = [] + for idx in range(self.columns): + if self.columns != 1: + name = "{}_{}".format(self.parent.winfo_name(), idx) + subframe = ttk.Frame(self.parent, name=name) + subframe.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N, expand=True, fill=tk.X) + subframes.append(subframe) + logger.debug("Added subframe: %s", name) + else: + subframes.append(self.parent) + logger.debug("Using parent as subframe: %s", self.parent.winfo_name()) + self._items += 1 + return subframes class ControlBuilder(): - # TODO Expand out for cli options """ Builds and returns a frame containing a tkinter control with label @@ -135,7 +234,7 @@ class ControlBuilder(): title: str Title of the control. Will be used for label text dtype: datatype object - Datatype of the control + Datatype of the control. default: str Default value for the control selected_value: str, optional @@ -148,55 +247,69 @@ class ControlBuilder(): For slider controls. Sets the stepping min_max: int or float, optional For slider controls. Sets the min and max values + sysbrowser: dict, optional + Adds Filesystem browser buttons to ttk.Entry options. + Expects a dict: {sysbrowser: str, filetypes: str} helptext: str, optional Sets the tooltip text radio_columns: int, optional Sets the number of columns to use for grouping radio buttons label_width: int, optional Sets the width of the control label. Defaults to 20 + checkbuttons_frame: tk.frame, optional + If a checkbutton frame is passed in, then checkbuttons will be placed in this frame + rather than the main options frame control_width: int, optional Sets the width of the control. Default is to auto expand + blank_nones: bool, optional + Sets selected values to an empty string rather than None if this is true. Default is true """ def __init__(self, parent, title, dtype, default, selected_value=None, choices=None, is_radio=False, rounding=None, - min_max=None, helptext=None, radio_columns=3, label_width=20, control_width=None): + min_max=None, sysbrowser=None, helptext=None, radio_columns=3, label_width=20, + checkbuttons_frame=None, control_width=None, blank_nones=True): logger.debug("Initializing %s: (parent: %s, title: %s, dtype: %s, default: %s, " "selected_value: %s, choices: %s, is_radio: %s, rounding: %s, min_max: %s, " - "helptext: %s, radio_columns: %s, label_width: %s, control_width: %s)", + "sysbrowser: %s, helptext: %s, radio_columns: %s, label_width: %s, " + "checkbuttons_frame: %s, control_width: %s, blank_nones: %s)", self.__class__.__name__, parent, title, dtype, default, selected_value, - choices, is_radio, rounding, min_max, helptext, radio_columns, label_width, - control_width) + choices, is_radio, rounding, min_max, sysbrowser, helptext, radio_columns, + label_width, checkbuttons_frame, control_width, blank_nones) self.title = title self.default = default + self.helptext = self.format_helptext(helptext) + self.label_width = label_width + self.filebrowser = None - self.frame = self.control_frame(parent, helptext) + self.frame = self.control_frame(parent) + self.chkbtns = checkbuttons_frame self.control = self.set_control(dtype, choices, is_radio) - self.tk_var = self.set_tk_var(dtype, selected_value) + self.tk_var = self.set_tk_var(dtype, selected_value, blank_nones) self.build_control(choices, dtype, rounding, min_max, + sysbrowser, radio_columns, - label_width, control_width) logger.debug("Initialized: %s", self.__class__.__name__) # Frame, control type and varable - def control_frame(self, parent, helptext): + @staticmethod + def control_frame(parent): """ Frame to hold control and it's label """ logger.debug("Build control frame") frame = ttk.Frame(parent) - frame.pack(side=tk.TOP, fill=tk.X) - if helptext is not None: - helptext = self.format_helptext(helptext) - Tooltip(frame, text=helptext, wraplength=720) + frame.pack(fill=tk.X) logger.debug("Built control frame") return frame def format_helptext(self, helptext): """ Format the help text for tooltips """ + if helptext is None: + return helptext logger.debug("Format control help: '%s'", self.title) helptext = helptext.replace("\n\t", "\n - ").replace("%%", "%") helptext = self.title + " - " + helptext @@ -218,10 +331,11 @@ def set_control(self, dtype, choices, is_radio): logger.debug("Setting control '%s' to %s", self.title, control) return control - def set_tk_var(self, dtype, selected_value): + def set_tk_var(self, dtype, selected_value, blank_nones): """ Correct variable type for control """ - logger.debug("Setting tk variable: (title: '%s', dtype: %s, selected_value: %s)", - self.title, dtype, selected_value) + logger.debug("Setting tk variable: (title: '%s', dtype: %s, selected_value: %s, " + "blank_nones: %s)", + self.title, dtype, selected_value, blank_nones) if dtype == bool: var = tk.BooleanVar elif dtype == int: @@ -232,43 +346,60 @@ def set_tk_var(self, dtype, selected_value): var = tk.StringVar var = var(self.frame) val = self.default if selected_value is None else selected_value + val = "" if val is None and blank_nones else val var.set(val) logger.debug("Set tk variable: (title: '%s', type: %s, value: '%s')", self.title, type(var), val) return var # Build the full control - def build_control(self, choices, dtype, rounding, min_max, radio_columns, - label_width, control_width): + def build_control(self, choices, dtype, rounding, min_max, sysbrowser, radio_columns, + control_width): """ Build the correct control type for the option passed through """ logger.debug("Build confog option control") - self.build_control_label(label_width) - self.build_one_control(choices, dtype, rounding, min_max, radio_columns, control_width) + if self.control not in (ttk.Checkbutton, ttk.Radiobutton): + self.build_control_label() + self.build_one_control(choices, + dtype, + rounding, + min_max, + sysbrowser, + radio_columns, + control_width) logger.debug("Built option control") - def build_control_label(self, label_width): + def build_control_label(self): """ Label for control """ - logger.debug("Build control label: (title: '%s', label_width: %s)", - self.title, label_width) + logger.debug("Build control label: (title: '%s')", self.title) title = self.title.replace("_", " ").title() - lbl = ttk.Label(self.frame, text=title, width=label_width, anchor=tk.W) + lbl = ttk.Label(self.frame, text=title, width=self.label_width, anchor=tk.W) lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) + if self.helptext is not None: + Tooltip(lbl, text=self.helptext, wraplength=720) + logger.debug("Built control label: '%s'", self.title) - def build_one_control(self, choices, dtype, rounding, min_max, radio_columns, control_width): + def build_one_control(self, choices, dtype, rounding, min_max, + sysbrowser, radio_columns, control_width): """ Build and place the option controls """ logger.debug("Build control: (title: '%s', control: %s, choices: %s, dtype: %s, " - "rounding: %s, min_max: %s: radio_columns: %s, control_width: %s)", - self.title, self.control, choices, dtype, rounding, min_max, radio_columns, - control_width) + "rounding: %s, sysbrowser: %s, min_max: %s: radio_columns: %s, " + "control_width: %s)", self.title, self.control, choices, dtype, rounding, + sysbrowser, min_max, radio_columns, control_width) if self.control == ttk.Scale: ctl = self.slider_control(dtype, rounding, min_max) elif self.control == ttk.Radiobutton: ctl = self.radio_control(choices, radio_columns) + elif self.control == ttk.Checkbutton: + ctl = self.control_to_checkframe() else: - ctl = self.control_to_optionsframe(choices) + ctl = self.control_to_optionsframe(choices, sysbrowser) self.set_control_width(ctl, control_width) - ctl.pack(padx=5, pady=5, fill=tk.X, expand=True) + if self.control != ttk.Checkbutton: + ctl.pack(padx=5, pady=5, fill=tk.X, expand=True) + if self.helptext is not None: + Tooltip(ctl, text=self.helptext, wraplength=720) + logger.debug("Built control: '%s'", self.title) @staticmethod @@ -280,23 +411,17 @@ def set_control_width(ctl, control_width): def radio_control(self, choices, columns): """ Create a group of radio buttons """ logger.debug("Adding radio group: %s", self.title) - ctl = ttk.Frame(self.frame) - frames = list() - for _ in range(columns): - frame = ttk.Frame(ctl) - frame.pack(padx=5, pady=5, fill=tk.X, expand=True, side=tk.LEFT, anchor=tk.N) - frames.append(frame) - + ctl = ttk.LabelFrame(self.frame, text=self.title.replace("_", " ").title()) + radio_holder = AutoFillContainer(ctl, columns) for idx, choice in enumerate(choices): frame_id = idx % columns - radio = ttk.Radiobutton(frames[frame_id], + radio = ttk.Radiobutton(radio_holder.subframe, text=choice.title(), value=choice, variable=self.tk_var) radio.pack(anchor=tk.W) logger.debug("Adding radio option %s to column %s", choice, frame_id) - logger.debug("Added radio group: '%s'", self.title) - return ctl + return radio_holder.parent def slider_control(self, dtype, rounding, min_max): """ A slider control with corresponding Entry box """ @@ -316,13 +441,15 @@ def slider_control(self, dtype, rounding, min_max): logger.debug("Added slider control to Options Frame: %s", self.title) return ctl - def control_to_optionsframe(self, choices): + def control_to_optionsframe(self, choices, sysbrowser): """ Standard non-check buttons sit in the main options frame """ logger.debug("Add control to Options Frame: (title: '%s', control: %s, choices: %s)", self.title, self.control, choices) if self.control == ttk.Checkbutton: ctl = self.control(self.frame, variable=self.tk_var, text=None) else: + if sysbrowser is not None: + self.filebrowser = FileBrowser(self.tk_var, self.frame, sysbrowser) ctl = self.control(self.frame, textvariable=self.tk_var) rc_menu = ContextMenu(ctl) rc_menu.cm_bind() @@ -331,3 +458,111 @@ def control_to_optionsframe(self, choices): ctl["values"] = [choice for choice in choices] logger.debug("Added control to Options Frame: %s", self.title) return ctl + + def control_to_checkframe(self): + """ Add checkbuttons to the checkbutton frame """ + logger.debug("Add control checkframe: '%s'", self.title) + chkframe = self.chkbtns.subframe + ctl = self.control(chkframe, + variable=self.tk_var, + text=self.title.replace("_", " ").title(), + name=self.title.lower()) + Tooltip(ctl, text=self.helptext, wraplength=200) + ctl.pack(side=tk.TOP, anchor=tk.W) + logger.debug("Added control checkframe: '%s'", self.title) + return ctl + + +class FileBrowser(): + """ Add FileBrowser buttons to control and handle routing """ + def __init__(self, tk_var, control_frame, sysbrowser_dict): + logger.debug("Initializing: %s: (tk_var: %s, control_frame: %s, sysbrowser_dict: %s)", + self.__class__.__name__, tk_var, control_frame, sysbrowser_dict) + self.tk_var = tk_var + self.frame = control_frame + self.browser = sysbrowser_dict["browser"] + self.filetypes = sysbrowser_dict["filetypes"] + self.action_option = sysbrowser_dict.get("action_option", None) + self.command = sysbrowser_dict.get("command", None) + self.destination = sysbrowser_dict.get("destination", None) + self.add_browser_buttons() + logger.debug("Initialized: %s", self.__class__.__name__) + + def add_browser_buttons(self): + """ Add correct file browser button for control """ + logger.debug("Adding browser buttons: (sysbrowser: '%s'", self.browser) + for browser in self.browser: + img = get_images().icons[browser] + action = getattr(self, "ask_" + browser) + fileopn = ttk.Button(self.frame, + image=img, + command=lambda cmd=action: cmd(self.tk_var, self.filetypes)) + fileopn.pack(padx=(0, 5), side=tk.RIGHT) + logger.debug("Added browser buttons: (action: %s, filetypes: %s", + action, self.filetypes) + + def set_context_action_option(self, options): + """ Set the tk_var for the source action option + that dictates the context sensitive file browser. """ + if self.browser != ["context"]: + return + actions = {item["opts"][0]: item["selected"] + for item in options.values()} + logger.debug("Settiong action option for opt %s", self.action_option) + self.action_option = actions[self.action_option] + + @staticmethod + def ask_folder(filepath, filetypes=None): + """ Pop-up to get path to a directory + :param filepath: tkinter StringVar object + that will store the path to a directory. + :param filetypes: Unused argument to allow + filetypes to be given in ask_load(). """ + dirname = FileHandler("dir", filetypes).retfile + if dirname: + logger.debug(dirname) + filepath.set(dirname) + + @staticmethod + def ask_load(filepath, filetypes): + """ Pop-up to get path to a file """ + filename = FileHandler("filename", filetypes).retfile + if filename: + logger.debug(filename) + filepath.set(filename) + + @staticmethod + def ask_load_multi(filepath, filetypes): + """ Pop-up to get path to a file """ + filenames = FileHandler("filename_multi", filetypes).retfile + if filenames: + final_names = " ".join("\"{}\"".format(fname) for fname in filenames) + logger.debug(final_names) + filepath.set(final_names) + + @staticmethod + def ask_save(filepath, filetypes=None): + """ Pop-up to get path to save a new file """ + filename = FileHandler("savefilename", filetypes).retfile + if filename: + logger.debug(filename) + filepath.set(filename) + + @staticmethod + def ask_nothing(filepath, filetypes=None): # pylint:disable=unused-argument + """ Method that does nothing, used for disabling open/save pop up """ + return + + def ask_context(self, filepath, filetypes): + """ Method to pop the correct dialog depending on context """ + logger.debug("Getting context filebrowser") + selected_action = self.action_option.get() + selected_variable = self.destination + filename = FileHandler("context", + filetypes, + command=self.command, + action=selected_action, + variable=selected_variable).retfile + if filename: + logger.debug(filename) + filepath.set(filename) diff --git a/lib/gui/options.py b/lib/gui/options.py index 609a05880e..20f3ca8398 100644 --- a/lib/gui/options.py +++ b/lib/gui/options.py @@ -4,7 +4,7 @@ from argparse import SUPPRESS import logging import re -from tkinter import ttk +from collections import OrderedDict from lib import cli import tools.cli as ToolsCli @@ -72,7 +72,7 @@ def extract_options(self, cli_source, mod_classes): logger.debug("Processing: (classname: '%s')", classname) command = self.format_command_name(classname) options = self.get_cli_arguments(cli_source, classname, command) - options = self.process_options(options) + options = self.process_options(options, command) logger.debug("Processed: (classname: '%s', command: '%s', options: %s)", classname, command, options) subopts[command] = options @@ -84,24 +84,29 @@ def get_cli_arguments(cli_source, classname, command): meth = getattr(cli_source, classname)(None, command) return meth.argument_list + meth.optional_arguments + meth.global_arguments - def process_options(self, command_options): + def process_options(self, command_options, command): """ Process the options for a single command """ - final_options = list() + gui_options = OrderedDict() for opt in command_options: logger.trace("Processing: %s", opt) if opt.get("help", "") == SUPPRESS: logger.trace("Skipping suppressed option: %s", opt) continue - ctl, sysbrowser, filetypes, action_option, group = self.set_control(opt) - opt["control_title"] = self.set_control_title(opt.get("opts", "")) - opt["control"] = ctl - opt["filesystem_browser"] = sysbrowser - opt["filetypes"] = filetypes - opt["action_option"] = action_option - opt["group"] = group - final_options.append(opt) + title = self.set_control_title(opt["opts"]) + gui_options[title] = { + "type": self.get_data_type(opt), + "default": opt.get("default", None), + "value": opt.get("default", ""), + "choices": opt.get("choices", None), + "gui_radio": opt.get("action", "") == cli.Radio, + "rounding": self.get_rounding(opt), + "min_max": opt.get("min_max", None), + "sysbrowser": self.get_sysbrowser(opt, command), + "group": opt.get("group", None), + "helptext": opt["help"], + "opts": opt["opts"]} logger.trace("Processed: %s", opt) - return final_options + return gui_options @staticmethod def set_control_title(opts): @@ -110,75 +115,76 @@ def set_control_title(opts): ctltitle = ctltitle.replace("-", " ").replace("_", " ").strip().title() return ctltitle - def set_control(self, option): - """ Set the control and filesystem browser to use for each option """ - sysbrowser = None - group = option.get("group", None) - action = option.get("action", None) - action_option = option.get("action_option", None) - filetypes = option.get("filetypes", None) - ctl = ttk.Entry - if action in (cli.FullPaths, - cli.DirFullPaths, - cli.FileFullPaths, - cli.FilesFullPaths, - cli.DirOrFileFullPaths, - cli.SaveFileFullPaths, - cli.ContextFullPaths): - sysbrowser, filetypes = self.set_sysbrowser(action, - filetypes, - action_option) - elif option.get("min_max", None): - ctl = ttk.Scale - elif option.get("action", "") == cli.Radio: - ctl = ttk.Radiobutton - elif option.get("choices", "") != "": - ctl = ttk.Combobox - elif option.get("action", "") == "store_true": - ctl = ttk.Checkbutton - return ctl, sysbrowser, filetypes, action_option, group + @staticmethod + def get_data_type(opt): + """ Return a datatype for passing into control_helper.py to get the correct control """ + if opt.get("type", None) is not None and isinstance(opt["type"], type): + retval = opt["type"] + elif opt.get("action", "") in ("store_true", "store_false"): + retval = bool + else: + retval = str + return retval @staticmethod - def set_sysbrowser(action, filetypes, action_option): - """ Set the correct file system browser and filetypes - for the passed in action """ - sysbrowser = ["folder"] - filetypes = "default" if not filetypes else filetypes + def get_rounding(opt): + """ Return rounding if correct data type, else None """ + dtype = opt.get("type", None) + if dtype == float: + retval = opt.get("rounding", 2) + elif dtype == int: + retval = opt.get("rounding", 1) + else: + retval = None + return retval + + @staticmethod + def get_sysbrowser(option, command): + """ Return the system file browser and file types if required else None """ + action = option.get("action", None) + if action not in (cli.FullPaths, + cli.DirFullPaths, + cli.FileFullPaths, + cli.FilesFullPaths, + cli.DirOrFileFullPaths, + cli.SaveFileFullPaths, + cli.ContextFullPaths): + return None + + retval = dict() + action_option = option.get("action_option", None) + retval["filetypes"] = option.get("filetypes", "default") if action == cli.FileFullPaths: - sysbrowser = ["load"] + retval["browser"] = ["load"] elif action == cli.FilesFullPaths: - sysbrowser = ["load_multi"] + retval["browser"] = ["load_multi"] elif action == cli.SaveFileFullPaths: - sysbrowser = ["save"] + retval["browser"] = ["save"] elif action == cli.DirOrFileFullPaths: - sysbrowser = ["folder", "load"] + retval["browser"] = ["folder", "load"] elif action == cli.ContextFullPaths and action_option: - sysbrowser = ["context"] - logger.debug("sysbrowser: %s, filetypes: '%s'", sysbrowser, filetypes) - return sysbrowser, filetypes - - def set_context_option(self, command): - """ Set the tk_var for the source action option - that dictates the context sensitive file browser. """ - actions = {item["opts"][0]: item["value"] - for item in self.gen_command_options(command)} - for opt in self.gen_command_options(command): - if opt["filesystem_browser"] == ["context"]: - opt["action_option"] = actions[opt["action_option"]] + retval["browser"] = ["context"] + retval["command"] = command + retval["action_option"] = action_option + retval["destination"] = option.get("dest", option["opts"][1].replace("--", "")) + else: + retval["browser"] = ["folder"] + logger.debug(retval) + return retval def gen_command_options(self, command): """ Yield each option for specified command """ - for option in self.opts[command]: - yield option + for key, val in self.opts[command].items(): + yield key, val def options_to_process(self, command=None): """ Return a consistent object for processing regardless of whether processing all commands or just one command for reset and clear """ if command is None: - options = [opt for opts in self.opts.values() for opt in opts] + options = [opt for opts in self.opts.values() for opt in opts.values()] else: - options = [opt for opt in self.gen_command_options(command)] + options = [opt for opt in self.opts[command].values()] return options def reset(self, command=None): @@ -191,19 +197,19 @@ def reset(self, command=None): if (option.get("nargs", None) and isinstance(default, (list, tuple))): default = ' '.join(str(val) for val in default) - option["value"].set(default) + option["selected"].set(default) def clear(self, command=None): """ Clear the options values for all or passed commands """ logger.debug("Clearing options. (command: '%s'", command) for option in self.options_to_process(command): - if isinstance(option["value"].get(), bool): - option["value"].set(False) - elif isinstance(option["value"].get(), int): - option["value"].set(0) + if isinstance(option["selected"].get(), bool): + option["selected"].set(False) + elif isinstance(option["selected"].get(), int): + option["selected"].set(0) else: - option["value"].set("") + option["selected"].set("") def get_option_values(self, command=None): """ Return all or single command control titles @@ -214,7 +220,7 @@ def get_option_values(self, command=None): continue cmd_dict = dict() for opt in opts: - cmd_dict[opt["control_title"]] = opt["value"].get() + cmd_dict[opt["control_title"]] = opt["selected"].get() ctl_dict[cmd] = cmd_dict logger.debug("command: '%s', ctl_dict: '%s'", command, ctl_dict) return ctl_dict @@ -222,16 +228,16 @@ def get_option_values(self, command=None): def get_one_option_variable(self, command, title): """ Return a single tk_var for the specified command and control_title """ - for option in self.gen_command_options(command): - if option["control_title"] == title: - return option["value"] + for opt_title, option in self.gen_command_options(command): + if opt_title == title: + return option["selected"] return None def gen_cli_arguments(self, command): """ Return the generated cli arguments for the selected command """ - for option in self.gen_command_options(command): - optval = str(option.get("value", "").get()) + for _, option in self.gen_command_options(command): + optval = str(option.get("selected", "").get()) opt = option["opts"][0] if command in ("extract", "convert") and opt == "-o": get_images().pathoutput = optval diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 5bb6be3fdc..d11a485410 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -51,7 +51,7 @@ def set_geometry(self, root): scaling_factor = get_config().scaling_factor pos_x = root.winfo_x() + 80 pos_y = root.winfo_y() + 80 - width = int(720 * scaling_factor) + width = int(600 * scaling_factor) height = int(400 * scaling_factor) logger.debug("Pop up Geometry: %sx%s, %s+%s", width, height, pos_x, pos_y) self.geometry("{}x{}+{}+{}".format(width, height, pos_x, pos_y)) @@ -94,20 +94,23 @@ def build_page(self, container, category): """ Build a plugin config page """ logger.debug("Building plugin config page: '%s'", category) plugins = sorted(list(key for key in self.config_dict_gui[category].keys())) + panel_kwargs = dict(columns=2, radio_columns=2, blank_nones=False) if any(plugin != category for plugin in plugins): page = ttk.Notebook(container) page.pack(side=tk.TOP, fill=tk.BOTH, expand=True) for plugin in plugins: frame = ControlPanel(page, self.config_dict_gui[category][plugin], - self.plugin_info[plugin]) + header_text=self.plugin_info[plugin], + **panel_kwargs) title = plugin[plugin.rfind(".") + 1:] title = title.replace("_", " ").title() page.add(frame, text=title) else: page = ControlPanel(container, self.config_dict_gui[category][plugins[0]], - self.plugin_info[plugins[0]]) + header_text=self.plugin_info[plugins[0]], + **panel_kwargs) logger.debug("Built plugin config page: '%s'", category) diff --git a/scripts/gui.py b/scripts/gui.py index cc4421051d..c32581a260 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -116,6 +116,7 @@ def __init__(self, arguments): pathscript = os.path.realpath(os.path.dirname(cmd)) self.args = arguments self.root = FaceswapGui(pathscript) + self.root.state("zoomed") def process(self): """ Builds the GUI """ From 3291fc8a2ed2f95af0f3df78eebdda0750430781 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 24 Aug 2019 20:57:19 +0100 Subject: [PATCH 006/981] Tooltips on FileBrowser Buttons --- lib/gui/control_helper.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index cba8aa3c14..321251d3ca 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -488,6 +488,16 @@ def __init__(self, tk_var, control_frame, sysbrowser_dict): self.add_browser_buttons() logger.debug("Initialized: %s", self.__class__.__name__) + @property + def helptext(self): + """ Dict containing tooltip text for buttons """ + retval = dict(folder="Select a folder", + load="Select a file", + load_multi="Select 1 or several files", + context="Filebrowser changes depending on selected action", + save="Select a save location") + return retval + def add_browser_buttons(self): """ Add correct file browser button for control """ logger.debug("Adding browser buttons: (sysbrowser: '%s'", self.browser) @@ -498,6 +508,7 @@ def add_browser_buttons(self): image=img, command=lambda cmd=action: cmd(self.tk_var, self.filetypes)) fileopn.pack(padx=(0, 5), side=tk.RIGHT) + Tooltip(fileopn, text=self.helptext[browser], wraplength=200) logger.debug("Added browser buttons: (action: %s, filetypes: %s", action, self.filetypes) From 3e08ba4387ee1f53d637f261033704a30750a09a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 24 Aug 2019 20:03:58 +0000 Subject: [PATCH 007/981] Fix for Linux fullscreen --- scripts/gui.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/gui.py b/scripts/gui.py index c32581a260..10390e6414 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -116,7 +116,10 @@ def __init__(self, arguments): pathscript = os.path.realpath(os.path.dirname(cmd)) self.args = arguments self.root = FaceswapGui(pathscript) - self.root.state("zoomed") + try: + self.root.state("zoomed") + except tk.TclError: + self.root.attributes('-zoomed', True) def process(self): """ Builds the GUI """ From b36b52727626129330513fcde593e5c795b57882 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 25 Aug 2019 00:16:56 +0100 Subject: [PATCH 008/981] GUI Tweaks - Fix tootlp formatting - Revert to single column view - Fix borders on ungrouped items - Add individual tooltips for Radio options - Add more groups - Remove forced fullscreen --- lib/gui/command.py | 4 ++-- lib/gui/control_helper.py | 32 +++++++++++++++++++++++++++----- scripts/gui.py | 4 ---- tools/cli.py | 4 ++++ 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/lib/gui/command.py b/lib/gui/command.py index f4e6b08ee7..37cc81bcf8 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -18,7 +18,7 @@ class CommandNotebook(ttk.Notebook): # pylint:disable=too-many-ancestors def __init__(self, parent): logger.debug("Initializing %s: (parent: %s)", self.__class__.__name__, parent) scaling_factor = get_config().scaling_factor - width = int(470 * scaling_factor) + width = int(420 * scaling_factor) root_height = get_config().root.winfo_height() height = int(round(root_height * 0.78125)) @@ -97,7 +97,7 @@ def build_tab(self): """ Build the tab """ logger.debug("Build Tab: '%s'", self.command) options = get_config().cli_opts.opts[self.command] - ControlPanel(self, options, label_width=16, radio_columns=2, columns=2) + ControlPanel(self, options, label_width=16, radio_columns=3, columns=1) self.add_frame_separator() ActionFrame(self) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 321251d3ca..4ba20fc850 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """ Helper functions and classes for GUI controls """ import logging +import re + import tkinter as tk from tkinter import ttk @@ -132,9 +134,13 @@ def get_group_frame(self, group): logger.debug("Creating new group frame for: %s", group) is_master = group == "_master" opts_frame = self.optsframe.subframe - group_frame = ttk.LabelFrame(opts_frame, - text="" if is_master else group.title(), - name=group.lower()) + if is_master: + group_frame = ttk.Frame(opts_frame, name=group.lower()) + else: + group_frame = ttk.LabelFrame(opts_frame, + text="" if is_master else group.title(), + name=group.lower()) + group_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5, anchor=tk.NW) self.group_frames[group] = dict(frame=group_frame, @@ -279,6 +285,7 @@ def __init__(self, parent, title, dtype, default, self.title = title self.default = default self.helptext = self.format_helptext(helptext) + self.helpset = False self.label_width = label_width self.filebrowser = None @@ -311,7 +318,12 @@ def format_helptext(self, helptext): if helptext is None: return helptext logger.debug("Format control help: '%s'", self.title) - helptext = helptext.replace("\n\t", "\n - ").replace("%%", "%") + if helptext.startswith("R|"): + helptext = helptext[2:].replace("\nL|", "\n - ").replace("\n", "\n\n") + else: + helptext = " ".join(helptext.split()) + helptext = helptext.replace("%%", "%") + helptext = ". ".join(i.capitalize() for i in helptext.split(". ")) helptext = self.title + " - " + helptext logger.debug("Formatted control help: (title: '%s', help: '%s'", self.title, helptext) return helptext @@ -397,7 +409,7 @@ def build_one_control(self, choices, dtype, rounding, min_max, self.set_control_width(ctl, control_width) if self.control != ttk.Checkbutton: ctl.pack(padx=5, pady=5, fill=tk.X, expand=True) - if self.helptext is not None: + if self.helptext is not None and not self.helpset: Tooltip(ctl, text=self.helptext, wraplength=720) logger.debug("Built control: '%s'", self.title) @@ -411,6 +423,11 @@ def set_control_width(ctl, control_width): def radio_control(self, choices, columns): """ Create a group of radio buttons """ logger.debug("Adding radio group: %s", self.title) + helpitems = {re.sub(r'[^A-Za-z0-9\-]+', '', + line.split()[1].lower()): " ".join(line.split()[1:]) + for line in self.helptext.splitlines() + if line.startswith(" - ")} + ctl = ttk.LabelFrame(self.frame, text=self.title.replace("_", " ").title()) radio_holder = AutoFillContainer(ctl, columns) for idx, choice in enumerate(choices): @@ -419,6 +436,11 @@ def radio_control(self, choices, columns): text=choice.title(), value=choice, variable=self.tk_var) + if choice.lower() in helpitems: + self.helpset = True + helptext = helpitems[choice.lower()].capitalize() + helptext = '. '.join(item.capitalize() for item in helptext.split('. ')) + Tooltip(radio, text=helptext) radio.pack(anchor=tk.W) logger.debug("Adding radio option %s to column %s", choice, frame_id) return radio_holder.parent diff --git a/scripts/gui.py b/scripts/gui.py index 10390e6414..cc4421051d 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -116,10 +116,6 @@ def __init__(self, arguments): pathscript = os.path.realpath(os.path.dirname(cmd)) self.args = arguments self.root = FaceswapGui(pathscript) - try: - self.root.state("zoomed") - except tk.TclError: - self.root.attributes('-zoomed', True) def process(self): """ Builds the GUI """ diff --git a/tools/cli.py b/tools/cli.py index 51aa56ea83..5b7769947f 100644 --- a/tools/cli.py +++ b/tools/cli.py @@ -149,6 +149,7 @@ def get_argument_list(self): argument_list.append({"opts": ("-ae", "--align-eyes"), "action": "store_true", "dest": "align_eyes", + "group": "output", "default": False, "help": "Perform extra alignment to ensure " "left/right eyes are at the same " @@ -156,6 +157,7 @@ def get_argument_list(self): "only)"}) argument_list.append({"opts": ("-dm", "--disable-monitor"), "action": "store_true", + "group": "settings", "dest": "disable_monitor", "default": False, "help": "Enable this option if manual " @@ -400,6 +402,7 @@ def get_argument_list(self): argument_list.append({"opts": ('-q', '--quiet'), "action": "store_true", "dest": "quiet", + "group": "settings", "default": False, "help": "Reduces output verbosity so that only " "serious errors are printed. If both " @@ -409,6 +412,7 @@ def get_argument_list(self): argument_list.append({"opts": ('-v', '--verbose'), "action": "store_true", "dest": "verbose", + "group": "settings", "default": False, "help": "Increases output verbosity. If both " "quiet and verbose are set, verbose " From a90a1fe7d8c687abc8f47e0a94e5b58bbba5a0dc Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 25 Aug 2019 01:13:42 +0000 Subject: [PATCH 009/981] GUI: Color Update. Linix height fix --- lib/gui/command.py | 3 +-- lib/gui/control_helper.py | 8 ++++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/gui/command.py b/lib/gui/command.py index 37cc81bcf8..ad967c0b91 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -19,8 +19,7 @@ def __init__(self, parent): logger.debug("Initializing %s: (parent: %s)", self.__class__.__name__, parent) scaling_factor = get_config().scaling_factor width = int(420 * scaling_factor) - root_height = get_config().root.winfo_height() - height = int(round(root_height * 0.78125)) + height = int(500 * scaling_factor) self.actionbtns = dict() super().__init__(parent, width=width, height=height) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 4ba20fc850..61369aa50b 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -39,7 +39,11 @@ def __init__(self, parent, options, label_width=20, columns=1, radio_columns=4, "radio_columns: %s, header_text: %s, blank_nones: %s)", self.__class__.__name__, parent, options, label_width, columns, radio_columns, header_text, blank_nones) + gui_style = ttk.Style() + + gui_style.configure('WinGrayBG.TLabelframe.Label', foreground="#0046D5", relief=tk.SOLID) super().__init__(parent) + self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) self.options = options @@ -139,7 +143,7 @@ def get_group_frame(self, group): else: group_frame = ttk.LabelFrame(opts_frame, text="" if is_master else group.title(), - name=group.lower()) + name=group.lower(), style="WinGrayBG.TLabelframe") group_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5, anchor=tk.NW) @@ -428,7 +432,7 @@ def radio_control(self, choices, columns): for line in self.helptext.splitlines() if line.startswith(" - ")} - ctl = ttk.LabelFrame(self.frame, text=self.title.replace("_", " ").title()) + ctl = ttk.LabelFrame(self.frame, text=self.title.replace("_", " ").title(), style="WinGrayBG.TLabelframe") radio_holder = AutoFillContainer(ctl, columns) for idx, choice in enumerate(choices): frame_id = idx % columns From 105af8a02a0de21f97b27e7eb7466a93656d25db Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 25 Aug 2019 11:07:34 +0100 Subject: [PATCH 010/981] GUI Tweaks --- lib/cli.py | 46 +++++++++---------- lib/gui/command.py | 2 +- lib/gui/control_helper.py | 34 ++++++++------ .../convert/color/color_transfer_defaults.py | 2 + .../convert/color/manual_balance_defaults.py | 6 +++ plugins/convert/mask/box_blend_defaults.py | 3 ++ plugins/convert/mask/mask_blend_defaults.py | 3 ++ plugins/convert/scaling/sharpen_defaults.py | 3 ++ plugins/convert/writer/ffmpeg_defaults.py | 5 ++ plugins/convert/writer/gif_defaults.py | 4 ++ plugins/convert/writer/opencv_defaults.py | 3 ++ plugins/convert/writer/pillow_defaults.py | 6 +++ plugins/train/_config.py | 2 +- plugins/train/model/dfl_h128_defaults.py | 1 + plugins/train/model/dfl_sae_defaults.py | 2 + plugins/train/model/original_defaults.py | 1 + plugins/train/model/unbalanced_defaults.py | 3 ++ plugins/train/model/villain_defaults.py | 1 + plugins/train/trainer/original_defaults.py | 1 + tools/cli.py | 17 +++---- 20 files changed, 98 insertions(+), 47 deletions(-) diff --git a/lib/cli.py b/lib/cli.py index d7f5df4316..d2e5d06a65 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -652,28 +652,6 @@ def get_optional_arguments(): "threshold. Discarded images are moved into a \"blurry\" " "sub-folder. Lower values allow more blur. Set to 0.0 to " "turn off."}) - argument_list.append({"opts": ("-sp", "--singleprocess"), - "action": "store_true", - "default": False, - "backend": "nvidia", - "group": "settings", - "help": "Don't run extraction in parallel. Will run detection first " - "then alignment (2 passes). Useful if VRAM is at a " - "premium."}) - argument_list.append({"opts": ("-s", "--skip-existing"), - "action": "store_true", - "dest": "skip_existing", - "group": "skipping", - "default": False, - "help": "Skips frames that have already been extracted and exist in " - "the alignments file"}) - argument_list.append({"opts": ("-sf", "--skip-existing-faces"), - "action": "store_true", - "dest": "skip_faces", - "group": "skipping", - "default": False, - "help": "Skip frames that already have detected faces in the " - "alignments file"}) argument_list.append({"opts": ("-een", "--extract-every-n"), "type": int, "action": Slider, @@ -724,6 +702,28 @@ def get_optional_arguments(): "default": False, "help": "Perform extra alignment to ensure left/right eyes are at " "the same height"}) + argument_list.append({"opts": ("-sp", "--singleprocess"), + "action": "store_true", + "default": False, + "backend": "nvidia", + "group": "settings", + "help": "Don't run extraction in parallel. Will run detection first " + "then alignment (2 passes). Useful if VRAM is at a " + "premium."}) + argument_list.append({"opts": ("-s", "--skip-existing"), + "action": "store_true", + "dest": "skip_existing", + "group": "settings", + "default": False, + "help": "Skips frames that have already been extracted and exist in " + "the alignments file"}) + argument_list.append({"opts": ("-sf", "--skip-existing-faces"), + "action": "store_true", + "dest": "skip_faces", + "group": "settings", + "default": False, + "help": "Skip frames that already have detected faces in the " + "alignments file"}) return argument_list @@ -1185,7 +1185,7 @@ def get_argument_list(): argument_list.append({"opts": ("-ag", "--allow-growth"), "action": "store_true", "dest": "allow_growth", - "group": "training", + "group": "model", "default": False, "backend": "nvidia", "help": "Sets allow_growth option of Tensorflow to spare memory " diff --git a/lib/gui/command.py b/lib/gui/command.py index ad967c0b91..fa8adc3ee4 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -96,7 +96,7 @@ def build_tab(self): """ Build the tab """ logger.debug("Build Tab: '%s'", self.command) options = get_config().cli_opts.opts[self.command] - ControlPanel(self, options, label_width=16, radio_columns=3, columns=1) + ControlPanel(self, options, label_width=16, radio_columns=2, columns=2) self.add_frame_separator() ActionFrame(self) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 61369aa50b..d654dfa43b 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -40,8 +40,8 @@ def __init__(self, parent, options, label_width=20, columns=1, radio_columns=4, self.__class__.__name__, parent, options, label_width, columns, radio_columns, header_text, blank_nones) gui_style = ttk.Style() - - gui_style.configure('WinGrayBG.TLabelframe.Label', foreground="#0046D5", relief=tk.SOLID) + + gui_style.configure('BlueText.TLabelframe.Label', foreground="#0046D5", relief=tk.SOLID) super().__init__(parent) self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) @@ -143,7 +143,7 @@ def get_group_frame(self, group): else: group_frame = ttk.LabelFrame(opts_frame, text="" if is_master else group.title(), - name=group.lower(), style="WinGrayBG.TLabelframe") + name=group.lower(), style="BlueText.TLabelframe") group_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5, anchor=tk.NW) @@ -325,8 +325,7 @@ def format_helptext(self, helptext): if helptext.startswith("R|"): helptext = helptext[2:].replace("\nL|", "\n - ").replace("\n", "\n\n") else: - helptext = " ".join(helptext.split()) - helptext = helptext.replace("%%", "%") + helptext = helptext.replace("\n\t", "\n - ").replace("%%", "%") helptext = ". ".join(i.capitalize() for i in helptext.split(". ")) helptext = self.title + " - " + helptext logger.debug("Formatted control help: (title: '%s', help: '%s'", self.title, helptext) @@ -427,12 +426,17 @@ def set_control_width(ctl, control_width): def radio_control(self, choices, columns): """ Create a group of radio buttons """ logger.debug("Adding radio group: %s", self.title) + all_help = [line for line in self.helptext.splitlines()] + if any(line.startswith(" - ") for line in all_help): + intro = all_help[0] helpitems = {re.sub(r'[^A-Za-z0-9\-]+', '', line.split()[1].lower()): " ".join(line.split()[1:]) - for line in self.helptext.splitlines() + for line in all_help if line.startswith(" - ")} - ctl = ttk.LabelFrame(self.frame, text=self.title.replace("_", " ").title(), style="WinGrayBG.TLabelframe") + ctl = ttk.LabelFrame(self.frame, + text=self.title.replace("_", " ").title(), + style="BlueText.TLabelframe") radio_holder = AutoFillContainer(ctl, columns) for idx, choice in enumerate(choices): frame_id = idx % columns @@ -443,8 +447,10 @@ def radio_control(self, choices, columns): if choice.lower() in helpitems: self.helpset = True helptext = helpitems[choice.lower()].capitalize() - helptext = '. '.join(item.capitalize() for item in helptext.split('. ')) - Tooltip(radio, text=helptext) + helptext = "{}\n\n - {}".format( + intro, + '. '.join(item.capitalize() for item in helptext.split('. '))) + Tooltip(radio, text=helptext, wraplength=400) radio.pack(anchor=tk.W) logger.debug("Adding radio option %s to column %s", choice, frame_id) return radio_holder.parent @@ -517,11 +523,11 @@ def __init__(self, tk_var, control_frame, sysbrowser_dict): @property def helptext(self): """ Dict containing tooltip text for buttons """ - retval = dict(folder="Select a folder", - load="Select a file", - load_multi="Select 1 or several files", - context="Filebrowser changes depending on selected action", - save="Select a save location") + retval = dict(folder="Select a folder...", + load="Select a file...", + load_multi="Select one or more files...", + context="Select a file or folder...", + save="Select a save location...") return retval def add_browser_buttons(self): diff --git a/plugins/convert/color/color_transfer_defaults.py b/plugins/convert/color/color_transfer_defaults.py index b1b0bc4cfc..0944c60f6e 100755 --- a/plugins/convert/color/color_transfer_defaults.py +++ b/plugins/convert/color/color_transfer_defaults.py @@ -58,6 +58,7 @@ "input.\nScaling will adjust image brightness to avoid washed out portions in " "the resulting color transfer that can be caused by clipping.", "datatype": bool, + "group": "method", "rounding": None, "min_max": None, "choices": [], @@ -72,6 +73,7 @@ "scaling factor proposed in the paper. This method seems to produce more " "consistently aesthetically pleasing results.", "datatype": bool, + "group": "method", "rounding": None, "min_max": None, "choices": [], diff --git a/plugins/convert/color/manual_balance_defaults.py b/plugins/convert/color/manual_balance_defaults.py index f55ea07c01..f5347014b0 100755 --- a/plugins/convert/color/manual_balance_defaults.py +++ b/plugins/convert/color/manual_balance_defaults.py @@ -68,6 +68,7 @@ "datatype": str, "rounding": None, "min_max": None, + "group": "color balance", "choices": ["RGB", "HSV", "LAB", "YCrCb"], "gui_radio": True, "fixed": True, @@ -83,6 +84,7 @@ "rounding": 1, "min_max": (-100.0, 100.0), "choices": [], + "group": "color balance", "gui_radio": False, "fixed": True, }, @@ -98,6 +100,7 @@ "min_max": (-100.0, 100.0), "choices": [], "gui_radio": False, + "group": "color balance", "fixed": True, }, "balance_3": { @@ -112,6 +115,7 @@ "min_max": (-100.0, 100.0), "choices": [], "gui_radio": False, + "group": "color balance", "fixed": True, }, "contrast": { @@ -122,6 +126,7 @@ "min_max": (-100.0, 100.0), "choices": [], "gui_radio": False, + "group": "brightness contrast", "fixed": True, }, "brightness": { @@ -132,6 +137,7 @@ "min_max": (-100.0, 100.0), "choices": [], "gui_radio": False, + "group": "brightness contrast", "fixed": True, }, } diff --git a/plugins/convert/mask/box_blend_defaults.py b/plugins/convert/mask/box_blend_defaults.py index 3895a2d449..dbeca5f8af 100755 --- a/plugins/convert/mask/box_blend_defaults.py +++ b/plugins/convert/mask/box_blend_defaults.py @@ -69,6 +69,7 @@ "the source face.", "datatype": float, "rounding": 1, + "group": "settings", "min_max": (0.1, 25.0), "choices": [], "gui_radio": False, @@ -87,6 +88,7 @@ "min_max": (0.1, 25.0), "choices": [], "gui_radio": False, + "group": "settings", "fixed": True, }, "passes": { @@ -100,6 +102,7 @@ "min_max": (1, 8), "choices": [], "gui_radio": False, + "group": "settings", "fixed": True, }, } diff --git a/plugins/convert/mask/mask_blend_defaults.py b/plugins/convert/mask/mask_blend_defaults.py index a46e8b7360..deb2a4b687 100755 --- a/plugins/convert/mask/mask_blend_defaults.py +++ b/plugins/convert/mask/mask_blend_defaults.py @@ -70,6 +70,7 @@ "min_max": (0.1, 25.0), "choices": [], "gui_radio": False, + "group": "settings", "fixed": True, }, "passes": { @@ -83,6 +84,7 @@ "min_max": (1, 8), "choices": [], "gui_radio": False, + "group": "settings", "fixed": True, }, "erosion": { @@ -95,6 +97,7 @@ "min_max": (-100.0, 100.0), "choices": [], "gui_radio": False, + "group": "settings", "fixed": True, }, } diff --git a/plugins/convert/scaling/sharpen_defaults.py b/plugins/convert/scaling/sharpen_defaults.py index 5f020d23d7..991c0a6f9f 100755 --- a/plugins/convert/scaling/sharpen_defaults.py +++ b/plugins/convert/scaling/sharpen_defaults.py @@ -71,6 +71,7 @@ "min_max": (100, 500), "choices": [], "gui_radio": False, + "group": "settings", "fixed": True, }, "radius": { @@ -87,6 +88,7 @@ "min_max": (0.1, 5.0), "choices": [], "gui_radio": False, + "group": "settings", "fixed": True, }, "threshold": { @@ -103,6 +105,7 @@ "min_max": (1.0, 10.0), "choices": [], "gui_radio": False, + "group": "settings", "fixed": True, }, } diff --git a/plugins/convert/writer/ffmpeg_defaults.py b/plugins/convert/writer/ffmpeg_defaults.py index 4f1bb841e3..9c0da9f948 100755 --- a/plugins/convert/writer/ffmpeg_defaults.py +++ b/plugins/convert/writer/ffmpeg_defaults.py @@ -81,6 +81,7 @@ "min_max": (0, 51), "choices": [], "gui_radio": False, + "group": "quality", "fixed": True, }, "preset": { @@ -104,6 +105,7 @@ "veryslow", ], "gui_radio": True, + "group": "quality", "fixed": True, }, "tune": { @@ -131,6 +133,7 @@ "zerolatency", ], "gui_radio": False, + "group": "settings", "fixed": True, }, "profile": { @@ -142,6 +145,7 @@ "min_max": None, "choices": ["auto", "baseline", "main", "high", "high10", "high422", "high444"], "gui_radio": False, + "group": "settings", "fixed": True, }, "level": { @@ -175,6 +179,7 @@ "6.2", ], "gui_radio": False, + "group": "settings", "fixed": True, }, } diff --git a/plugins/convert/writer/gif_defaults.py b/plugins/convert/writer/gif_defaults.py index 42fb3281f1..800fff10c7 100755 --- a/plugins/convert/writer/gif_defaults.py +++ b/plugins/convert/writer/gif_defaults.py @@ -52,6 +52,7 @@ "rounding": 1, "min_max": (1, 60), "choices": [], + "group": "settings", "gui_radio": False, "fixed": True, }, @@ -62,6 +63,7 @@ "rounding": 1, "min_max": (0, 100), "choices": [], + "group": "settings", "gui_radio": False, "fixed": True, }, @@ -73,6 +75,7 @@ "rounding": None, "min_max": None, "choices": ["2", "4", "8", "16", "32", "64", "128", "256"], + "group": "settings", "gui_radio": False, "fixed": True, }, @@ -84,6 +87,7 @@ "rounding": None, "min_max": None, "choices": [], + "group": "settings", "gui_radio": False, "fixed": True, }, diff --git a/plugins/convert/writer/opencv_defaults.py b/plugins/convert/writer/opencv_defaults.py index 58fd24c666..b2f6c9d29c 100755 --- a/plugins/convert/writer/opencv_defaults.py +++ b/plugins/convert/writer/opencv_defaults.py @@ -73,6 +73,7 @@ "rounding": None, "min_max": None, "choices": [], + "group": "settings", "gui_radio": False, "fixed": True, }, @@ -84,6 +85,7 @@ "rounding": 1, "min_max": (1, 95), "choices": [], + "group": "compression", "gui_radio": False, "fixed": True, }, @@ -95,6 +97,7 @@ "rounding": 1, "min_max": (0, 9), "choices": [], + "group": "compression", "gui_radio": False, "fixed": True, }, diff --git a/plugins/convert/writer/pillow_defaults.py b/plugins/convert/writer/pillow_defaults.py index c8757718dd..4169b62126 100755 --- a/plugins/convert/writer/pillow_defaults.py +++ b/plugins/convert/writer/pillow_defaults.py @@ -74,6 +74,7 @@ "rounding": None, "min_max": None, "choices": [], + "group": "settings", "gui_radio": False, "fixed": True, }, @@ -85,6 +86,7 @@ "rounding": None, "min_max": None, "choices": [], + "group": "settings", "gui_radio": False, "fixed": True, }, @@ -95,6 +97,7 @@ "rounding": None, "min_max": None, "choices": [], + "group": "settings", "gui_radio": False, "fixed": True, }, @@ -106,6 +109,7 @@ "rounding": 1, "min_max": (1, 95), "choices": [], + "group": "compression", "gui_radio": False, "fixed": True, }, @@ -118,6 +122,7 @@ "rounding": 1, "min_max": (0, 9), "choices": [], + "group": "compression", "gui_radio": False, "fixed": True, }, @@ -140,6 +145,7 @@ "tiff_sgilog24", "tiff_raw_16", ], + "group": "compression", "gui_radio": False, "fixed": True, }, diff --git a/plugins/train/_config.py b/plugins/train/_config.py index c855d45dd8..f652565ccb 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -58,7 +58,7 @@ def set_globals(self): info="Options that apply to all models" + ADDITIONAL_INFO) self.add_item( section=section, title="coverage", datatype=float, default=68.75, - min_max=(62.5, 100.0), rounding=2, fixed=True, + min_max=(62.5, 100.0), rounding=2, fixed=True, group="face", info="How much of the extracted image to train on. A lower coverage will limit the " "model's scope to a zoomed-in central area while higher amounts can include the " "entire face. A trade-off exists between lower amounts given more detail " diff --git a/plugins/train/model/dfl_h128_defaults.py b/plugins/train/model/dfl_h128_defaults.py index edc69c7a74..77283b8103 100755 --- a/plugins/train/model/dfl_h128_defaults.py +++ b/plugins/train/model/dfl_h128_defaults.py @@ -54,6 +54,7 @@ "min_max": None, "choices": [], "gui_radio": False, + "group": "settings", "fixed": True, }, } diff --git a/plugins/train/model/dfl_sae_defaults.py b/plugins/train/model/dfl_sae_defaults.py index 5c627c90a8..34fc916257 100644 --- a/plugins/train/model/dfl_sae_defaults.py +++ b/plugins/train/model/dfl_sae_defaults.py @@ -53,6 +53,7 @@ "datatype": int, "rounding": 16, "min_max": (64, 256), + "group": "size", "fixed": True, }, "clipnorm": { @@ -61,6 +62,7 @@ "the expense of VRAM.", "datatype": bool, "fixed": False, + "group": "settings", }, "architecture": { "default": "df", diff --git a/plugins/train/model/original_defaults.py b/plugins/train/model/original_defaults.py index 79e9d3e4e3..dc709207df 100755 --- a/plugins/train/model/original_defaults.py +++ b/plugins/train/model/original_defaults.py @@ -55,5 +55,6 @@ "choices": [], "gui_radio": False, "fixed": True, + "group": "settings", }, } diff --git a/plugins/train/model/unbalanced_defaults.py b/plugins/train/model/unbalanced_defaults.py index b92c5e5a95..317aec23ff 100755 --- a/plugins/train/model/unbalanced_defaults.py +++ b/plugins/train/model/unbalanced_defaults.py @@ -60,6 +60,7 @@ "min_max": (64, 512), "choices": [], "gui_radio": False, + "group": "size", "fixed": True, }, "lowmem": { @@ -72,6 +73,7 @@ "min_max": None, "choices": [], "gui_radio": False, + "group": "settings", "fixed": True, }, "clipnorm": { @@ -83,6 +85,7 @@ "min_max": None, "choices": [], "gui_radio": False, + "group": "settings", "fixed": True, }, "nodes": { diff --git a/plugins/train/model/villain_defaults.py b/plugins/train/model/villain_defaults.py index e63423dcb5..68a4fad833 100755 --- a/plugins/train/model/villain_defaults.py +++ b/plugins/train/model/villain_defaults.py @@ -57,6 +57,7 @@ "min_max": None, "choices": [], "gui_radio": False, + "group": "settings", "fixed": True, }, } diff --git a/plugins/train/trainer/original_defaults.py b/plugins/train/trainer/original_defaults.py index 07a5774e44..2f911cde12 100755 --- a/plugins/train/trainer/original_defaults.py +++ b/plugins/train/trainer/original_defaults.py @@ -53,6 +53,7 @@ "datatype": int, "rounding": 2, "min_max": (2, 16), + "group": "evaluation" }, "zoom_amount": { "default": 5, diff --git a/tools/cli.py b/tools/cli.py index 5b7769947f..a3586f458c 100644 --- a/tools/cli.py +++ b/tools/cli.py @@ -118,7 +118,7 @@ def get_argument_list(self): "action": Radio, "type": str, "choices": ("console", "file", "move"), - "group": "output", + "group": "processing", "default": "console", "help": "R|How to output discovered items ('faces' and 'frames' only):" "\nL|'console': Print the list of frames to the screen. (DEFAULT)" @@ -133,7 +133,7 @@ def get_argument_list(self): "min_max": (1, 100), "default": 1, "rounding": 1, - "group": "output", + "group": "extract", "help": "Extract every 'nth' frame. This option will skip frames " "when extracting faces. For example a value of 1 will " "extract faces from every frame, a value of 10 will extract " @@ -143,13 +143,13 @@ def get_argument_list(self): "action": Slider, "min_max": (128, 512), "default": 256, - "group": "output", + "group": "extract", "rounding": 64, "help": "The output size of extracted faces. (extract only)"}) argument_list.append({"opts": ("-ae", "--align-eyes"), "action": "store_true", "dest": "align_eyes", - "group": "output", + "group": "extract", "default": False, "help": "Perform extra alignment to ensure " "left/right eyes are at the same " @@ -157,7 +157,7 @@ def get_argument_list(self): "only)"}) argument_list.append({"opts": ("-dm", "--disable-monitor"), "action": "store_true", - "group": "settings", + "group": "manual tool", "dest": "disable_monitor", "default": False, "help": "Enable this option if manual " @@ -462,6 +462,7 @@ def get_argument_list(): "action": 'store_true', "dest": 'keep_original', "default": False, + "group": "output", "help": "Keeps the original files in the input " "directory. Be careful when using this " "with rename grouping and no specified " @@ -580,13 +581,13 @@ def get_argument_list(): "type": str.upper, "choices": ("CPU", "GPU"), "default": "GPU", - "group": "sort settings", + "group": "settings", "help": "Backend to use for VGG Face inference." "Only used for sort by 'face'."}) argument_list.append({"opts": ('-l', '--log-changes'), "action": 'store_true', - "group": "output", + "group": "settings", "default": False, "help": "Logs file renaming changes if " "grouping by renaming, or it logs the " @@ -599,7 +600,7 @@ def get_argument_list(): argument_list.append({"opts": ('-lf', '--log-file'), "action": SaveFileFullPaths, "filetypes": "alignments", - "group": "output", + "group": "settings", "dest": 'log_file_path', "default": 'sort_log.json', "help": "Specify a log file to use for saving " From f051692d04021dbc2ad288d052a5759508f3bb06 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 25 Aug 2019 10:09:06 +0000 Subject: [PATCH 011/981] gui fix --- lib/gui/command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/gui/command.py b/lib/gui/command.py index fa8adc3ee4..ad967c0b91 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -96,7 +96,7 @@ def build_tab(self): """ Build the tab """ logger.debug("Build Tab: '%s'", self.command) options = get_config().cli_opts.opts[self.command] - ControlPanel(self, options, label_width=16, radio_columns=2, columns=2) + ControlPanel(self, options, label_width=16, radio_columns=3, columns=1) self.add_frame_separator() ActionFrame(self) From 66a31fecb2a7ba46d15d22f711a6bfd86e435419 Mon Sep 17 00:00:00 2001 From: kilroythethird <44308116+kilroythethird@users.noreply.github.com> Date: Sun, 25 Aug 2019 13:14:52 +0200 Subject: [PATCH 012/981] Optimized numpy functions in Converter (#838) --- lib/convert.py | 84 ++++++++++++++++++++++++---------------------- scripts/convert.py | 2 +- 2 files changed, 45 insertions(+), 41 deletions(-) diff --git a/lib/convert.py b/lib/convert.py index 95dda43bcd..c09703e5dd 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -12,7 +12,6 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name - class Converter(): """ Swap a source face with a target """ def __init__(self, output_dir, output_size, output_has_mask, @@ -78,31 +77,32 @@ def process(self, in_queue, out_queue, completion_queue=None): logger.debug("Starting convert process. (in_queue: %s, out_queue: %s, completion_queue: " "%s)", in_queue, out_queue, completion_queue) while True: - item = in_queue.get() - if item == "EOF": + items = in_queue.get() + if items == "EOF": logger.debug("EOF Received") logger.debug("Patch queue finished") # Signal EOF to other processes in pool logger.debug("Putting EOF back to in_queue") - in_queue.put(item) + in_queue.put(items) break - logger.trace("Patch queue got: '%s'", item["filename"]) - - try: - image = self.patch_image(item) - except Exception as err: # pylint: disable=broad-except - # Log error and output original frame - logger.error("Failed to convert image: '%s'. Reason: %s", - item["filename"], str(err)) - image = item["image"] - # UNCOMMENT THIS CODE BLOCK TO PRINT TRACEBACK ERRORS - # import sys - # import traceback - # exc_info = sys.exc_info() - # traceback.print_exception(*exc_info) - - logger.trace("Out queue put: %s", item["filename"]) - out_queue.put((item["filename"], image)) + + for item in items: + logger.trace("Patch queue got: '%s'", item["filename"]) + try: + image = self.patch_image(item) + except Exception as err: # pylint: disable=broad-except + # Log error and output original frame + logger.error("Failed to convert image: '%s'. Reason: %s", + item["filename"], str(err)) + image = item["image"] + # UNCOMMENT THIS CODE BLOCK TO PRINT TRACEBACK ERRORS + # import sys + # import traceback + # exc_info = sys.exc_info() + # traceback.print_exception(*exc_info) + + logger.trace("Out queue put: %s", item["filename"]) + out_queue.put((item["filename"], image)) logger.debug("Completed convert process") # Signal that this process has finished if completion_queue is not None: @@ -112,10 +112,15 @@ def patch_image(self, predicted): """ Patch the image """ logger.trace("Patching image: '%s'", predicted["filename"]) frame_size = (predicted["image"].shape[1], predicted["image"].shape[0]) - new_image = self.get_new_image(predicted, frame_size) - patched_face = self.post_warp_adjustments(predicted, new_image) + new_image, background = self.get_new_image(predicted, frame_size) + patched_face = self.post_warp_adjustments(background, new_image) patched_face = self.scale_image(patched_face) - patched_face = np.rint(patched_face * 255.0).astype("uint8") + patched_face *= 255.0 + patched_face = np.rint( + patched_face, + out=np.empty(patched_face.shape, dtype="uint8"), + casting='unsafe' + ) if self.writer_pre_encode is not None: patched_face = self.writer_pre_encode(patched_face) logger.trace("Patched image: '%s'", predicted["filename"]) @@ -126,10 +131,10 @@ def get_new_image(self, predicted, frame_size): logger.trace("Getting: (filename: '%s', faces: %s)", predicted["filename"], len(predicted["swapped_faces"])) - placeholder = predicted["image"] / 255.0 - placeholder = np.concatenate((placeholder, - np.zeros((frame_size[1], frame_size[0], 1))), - axis=-1).astype("float32") + placeholder = np.zeros((frame_size[1], frame_size[0], 4), dtype="float32") + background = predicted["image"] / np.array(255.0, dtype="float32") + placeholder[:, :, :3] = background + for new_face, detected_face in zip(predicted["swapped_faces"], predicted["detected_faces"]): predicted_mask = new_face[:, :, -1] if new_face.shape[2] == 4 else None @@ -140,7 +145,7 @@ def get_new_image(self, predicted, frame_size): new_face = self.pre_warp_adjustments(src_face, new_face, detected_face, predicted_mask) # Warp face with the mask - placeholder = cv2.warpAffine( # pylint: disable=no-member + cv2.warpAffine( # pylint: disable=no-member new_face, detected_face.reference_matrix, frame_size, @@ -148,11 +153,11 @@ def get_new_image(self, predicted, frame_size): flags=cv2.WARP_INVERSE_MAP | interpolator, # pylint: disable=no-member borderMode=cv2.BORDER_TRANSPARENT) # pylint: disable=no-member - placeholder = np.clip(placeholder, 0.0, 1.0) + np.clip(placeholder, 0.0, 1.0, out=placeholder) logger.trace("Got filename: '%s'. (placeholders: %s)", predicted["filename"], placeholder.shape) - return placeholder + return placeholder, background def pre_warp_adjustments(self, old_face, new_face, detected_face, predicted_mask): """ Run the pre-warp adjustments """ @@ -178,11 +183,11 @@ def get_image_mask(self, new_face, detected_face, predicted_mask): else: logger.trace("Adding mask to alpha channel") new_face = np.concatenate((new_face, mask), -1) - new_face = np.clip(new_face, 0.0, 1.0) + np.clip(new_face, 0.0, 1.0, out=new_face) logger.trace("Got mask. Image shape: %s", new_face.shape) return new_face, raw_mask - def post_warp_adjustments(self, predicted, new_image): + def post_warp_adjustments(self, background, new_image): """ Apply fixes to the image after warping """ if self.adjustments["scaling"] is not None: new_image = self.adjustments["scaling"].run(new_image) @@ -190,13 +195,11 @@ def post_warp_adjustments(self, predicted, new_image): if self.draw_transparent: frame = new_image else: - mask = np.repeat(new_image[:, :, -1][:, :, np.newaxis], 3, axis=-1) - foreground = new_image[:, :, :3] - background = (predicted["image"][:, :, :3] / 255.0) * (1.0 - mask) - + foreground, mask = np.split(new_image, (3, ), axis=-1) foreground *= mask - frame = foreground + background - + background *= (1.0 - mask) + background += foreground + frame = background np.clip(frame, 0.0, 1.0, out=frame) return frame @@ -210,4 +213,5 @@ def scale_image(self, frame): round((frame.shape[0] / 2 * self.scale) * 2)) frame = cv2.resize(frame, dims, interpolation=interp) # pylint: disable=no-member logger.trace("resized frame: %s", frame.shape) - return np.clip(frame, 0.0, 1.0) + np.clip(frame, 0.0, 1.0, out=frame) + return frame diff --git a/scripts/convert.py b/scripts/convert.py index 7456cfc105..bd3a1632cf 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -653,8 +653,8 @@ def queue_out_frames(self, batch, swapped_faces): logger.trace("Putting to queue. ('%s', detected_faces: %s, swapped_faces: %s)", item["filename"], len(item["detected_faces"]), item["swapped_faces"].shape[0]) - self.out_queue.put(item) pointer += num_faces + self.out_queue.put(batch) logger.trace("Queued out batch. Batchsize: %s", len(batch)) From c0811a8c4bfd493bf85b9b8c79bd78998b94f90a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 26 Aug 2019 00:08:37 +0100 Subject: [PATCH 013/981] GUI Bugfix: Save full config --- lib/gui/options.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/gui/options.py b/lib/gui/options.py index 20f3ca8398..75fed07410 100644 --- a/lib/gui/options.py +++ b/lib/gui/options.py @@ -219,8 +219,8 @@ def get_option_values(self, command=None): if command and command != cmd: continue cmd_dict = dict() - for opt in opts: - cmd_dict[opt["control_title"]] = opt["selected"].get() + for key, val in opts.items(): + cmd_dict[key] = val["selected"].get() ctl_dict[cmd] = cmd_dict logger.debug("command: '%s', ctl_dict: '%s'", command, ctl_dict) return ctl_dict From 95582a81af9e2a58139d04624cf8542163faf199 Mon Sep 17 00:00:00 2001 From: kilroythethird <44308116+kilroythethird@users.noreply.github.com> Date: Mon, 26 Aug 2019 13:18:26 +0200 Subject: [PATCH 014/981] Fix sort without provided output dir (#845) --- tools/sort.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/sort.py b/tools/sort.py index 41b1585487..0391164e7a 100644 --- a/tools/sort.py +++ b/tools/sort.py @@ -43,7 +43,8 @@ def process(self): # Set output dir to the same value as input dir # if the user didn't specify it. - if self.args.output_dir.lower() == "_output_dir": + if self.args.output_dir is None: + logger.verbose("No output directory provided. Using input dir as output dir.") self.args.output_dir = self.args.input_dir # Assigning default threshold values based on grouping method From d901afc10e6969f66370c6ef8d5b4480676b7441 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 26 Aug 2019 13:23:38 +0100 Subject: [PATCH 015/981] BugFix: Multi-Select files command execution --- lib/gui/options.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/gui/options.py b/lib/gui/options.py index 75fed07410..573d127f25 100644 --- a/lib/gui/options.py +++ b/lib/gui/options.py @@ -104,7 +104,8 @@ def process_options(self, command_options, command): "sysbrowser": self.get_sysbrowser(opt, command), "group": opt.get("group", None), "helptext": opt["help"], - "opts": opt["opts"]} + "opts": opt["opts"], + "nargs": opt.get("nargs", None)} logger.trace("Processed: %s", opt) return gui_options From db60f6037d10fa044b75349b0ab5e3d3f75c3047 Mon Sep 17 00:00:00 2001 From: kilroythethird Date: Tue, 27 Aug 2019 12:13:52 +0200 Subject: [PATCH 016/981] Fixed support for videos with uppercase ext when using manual align --- tools/lib_alignments/media.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/lib_alignments/media.py b/tools/lib_alignments/media.py index 96d7ad5250..05050dd94e 100644 --- a/tools/lib_alignments/media.py +++ b/tools/lib_alignments/media.py @@ -133,7 +133,7 @@ def check_input_folder(self): if (loadtype == "Frames" and os.path.isfile(self.folder) and - os.path.splitext(self.folder)[1] in _video_extensions): + os.path.splitext(self.folder)[1].lower() in _video_extensions): logger.verbose("Video exists at: '%s'", self.folder) retval = cv2.VideoCapture(self.folder) # pylint: disable=no-member # TODO ImageIO single frame seek seems slow. Look into this From e666384792d2825bdb92605d572cdabc2c895dec Mon Sep 17 00:00:00 2001 From: kilroythethird <44308116+kilroythethird@users.noreply.github.com> Date: Tue, 27 Aug 2019 15:58:24 +0200 Subject: [PATCH 017/981] Fixed convert bug in preview (#846) --- lib/convert.py | 2 ++ tools/preview.py | 11 ++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/convert.py b/lib/convert.py index c09703e5dd..3f782f2edb 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -86,6 +86,8 @@ def process(self, in_queue, out_queue, completion_queue=None): in_queue.put(items) break + if isinstance(items, dict): + items = [items] for item in items: logger.trace("Patch queue got: '%s'", item["filename"]) try: diff --git a/tools/preview.py b/tools/preview.py index 78a925df0a..570ed26b64 100644 --- a/tools/preview.py +++ b/tools/preview.py @@ -245,13 +245,14 @@ def predict(self): idx = 0 while idx < self.sample_size: logger.debug("Predicting face %s of %s", idx + 1, self.sample_size) - item = self.predictor.out_queue.get() - if item == "EOF": + items = self.predictor.out_queue.get() + if items == "EOF": logger.debug("Received EOF") break - self.predicted_images.append(item) - logger.debug("Predicted face %s of %s", idx + 1, self.sample_size) - idx += 1 + for item in items: + self.predicted_images.append(item) + logger.debug("Predicted face %s of %s", idx + 1, self.sample_size) + idx += 1 logger.debug("Predicted faces") From 90de8166b53593f38d6a702ba559a6bb8ae061d5 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 27 Aug 2019 18:50:26 +0000 Subject: [PATCH 018/981] Interim Preview Tool Fix --- tools/preview.py | 216 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 214 insertions(+), 2 deletions(-) diff --git a/tools/preview.py b/tools/preview.py index 570ed26b64..fed1bb9dbf 100644 --- a/tools/preview.py +++ b/tools/preview.py @@ -16,9 +16,9 @@ from lib.aligner import Extract as AlignerExtract from lib.cli import ConvertArgs -from lib.gui.control_helper import ControlBuilder -from lib.gui.utils import get_images, initialize_images +from lib.gui.utils import get_images, initialize_images, ContextMenu from lib.gui.tooltip import Tooltip +from lib.gui.control_helper import set_slider_rounding from lib.convert import Converter from lib.faces_detect import DetectedFace from lib.model.masks import get_available_masks @@ -965,3 +965,215 @@ def add_actions(self, parent, config_key): btnutl.pack(padx=2, side=tk.RIGHT) Tooltip(btnutl, text=text, wraplength=200) logger.debug("Added util buttons") + + +class ControlBuilder(): + # TODO Expand out for cli options + """ + Builds and returns a frame containing a tkinter control with label + + Currently only setup for config items + + Parameters + ---------- + parent: tkinter object + Parent tkinter object + title: str + Title of the control. Will be used for label text + dtype: datatype object + Datatype of the control + default: str + Default value for the control + selected_value: str, optional + Selected value for the control. If None, default will be used + choices: list or tuple, object + Used for combo boxes and radio control option setting + is_radio: bool, optional + Specifies to use a Radio control instead of combobox if choices are passed + rounding: int or float, optional + For slider controls. Sets the stepping + min_max: int or float, optional + For slider controls. Sets the min and max values + helptext: str, optional + Sets the tooltip text + radio_columns: int, optional + Sets the number of columns to use for grouping radio buttons + label_width: int, optional + Sets the width of the control label. Defaults to 20 + control_width: int, optional + Sets the width of the control. Default is to auto expand + """ + def __init__(self, parent, title, dtype, default, + selected_value=None, choices=None, is_radio=False, rounding=None, + min_max=None, helptext=None, radio_columns=3, label_width=20, control_width=None): + logger.debug("Initializing %s: (parent: %s, title: %s, dtype: %s, default: %s, " + "selected_value: %s, choices: %s, is_radio: %s, rounding: %s, min_max: %s, " + "helptext: %s, radio_columns: %s, label_width: %s, control_width: %s)", + self.__class__.__name__, parent, title, dtype, default, selected_value, + choices, is_radio, rounding, min_max, helptext, radio_columns, label_width, + control_width) + + self.title = title + self.default = default + + self.frame = self.control_frame(parent, helptext) + self.control = self.set_control(dtype, choices, is_radio) + self.tk_var = self.set_tk_var(dtype, selected_value) + + self.build_control(choices, + dtype, + rounding, + min_max, + radio_columns, + label_width, + control_width) + logger.debug("Initialized: %s", self.__class__.__name__) + + # Frame, control type and varable + def control_frame(self, parent, helptext): + """ Frame to hold control and it's label """ + logger.debug("Build control frame") + frame = ttk.Frame(parent) + frame.pack(side=tk.TOP, fill=tk.X) + if helptext is not None: + helptext = self.format_helptext(helptext) + Tooltip(frame, text=helptext, wraplength=720) + logger.debug("Built control frame") + return frame + + def format_helptext(self, helptext): + """ Format the help text for tooltips """ + logger.debug("Format control help: '%s'", self.title) + helptext = helptext.replace("\n\t", "\n - ").replace("%%", "%") + helptext = self.title + " - " + helptext + logger.debug("Formatted control help: (title: '%s', help: '%s'", self.title, helptext) + return helptext + + def set_control(self, dtype, choices, is_radio): + """ Set the correct control type based on the datatype or for this option """ + if choices and is_radio: + control = ttk.Radiobutton + elif choices: + control = ttk.Combobox + elif dtype == bool: + control = ttk.Checkbutton + elif dtype in (int, float): + control = ttk.Scale + else: + control = ttk.Entry + logger.debug("Setting control '%s' to %s", self.title, control) + return control + + def set_tk_var(self, dtype, selected_value): + """ Correct variable type for control """ + logger.debug("Setting tk variable: (title: '%s', dtype: %s, selected_value: %s)", + self.title, dtype, selected_value) + if dtype == bool: + var = tk.BooleanVar + elif dtype == int: + var = tk.IntVar + elif dtype == float: + var = tk.DoubleVar + else: + var = tk.StringVar + var = var(self.frame) + val = self.default if selected_value is None else selected_value + var.set(val) + logger.debug("Set tk variable: (title: '%s', type: %s, value: '%s')", + self.title, type(var), val) + return var + + # Build the full control + def build_control(self, choices, dtype, rounding, min_max, radio_columns, + label_width, control_width): + """ Build the correct control type for the option passed through """ + logger.debug("Build confog option control") + self.build_control_label(label_width) + self.build_one_control(choices, dtype, rounding, min_max, radio_columns, control_width) + logger.debug("Built option control") + + def build_control_label(self, label_width): + """ Label for control """ + logger.debug("Build control label: (title: '%s', label_width: %s)", + self.title, label_width) + title = self.title.replace("_", " ").title() + lbl = ttk.Label(self.frame, text=title, width=label_width, anchor=tk.W) + lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) + logger.debug("Built control label: '%s'", self.title) + + def build_one_control(self, choices, dtype, rounding, min_max, radio_columns, control_width): + """ Build and place the option controls """ + logger.debug("Build control: (title: '%s', control: %s, choices: %s, dtype: %s, " + "rounding: %s, min_max: %s: radio_columns: %s, control_width: %s)", + self.title, self.control, choices, dtype, rounding, min_max, radio_columns, + control_width) + if self.control == ttk.Scale: + ctl = self.slider_control(dtype, rounding, min_max) + elif self.control == ttk.Radiobutton: + ctl = self.radio_control(choices, radio_columns) + else: + ctl = self.control_to_optionsframe(choices) + self.set_control_width(ctl, control_width) + ctl.pack(padx=5, pady=5, fill=tk.X, expand=True) + logger.debug("Built control: '%s'", self.title) + + @staticmethod + def set_control_width(ctl, control_width): + """ Set the control width if required """ + if control_width is not None: + ctl.config(width=control_width) + + def radio_control(self, choices, columns): + """ Create a group of radio buttons """ + logger.debug("Adding radio group: %s", self.title) + ctl = ttk.Frame(self.frame) + frames = list() + for _ in range(columns): + frame = ttk.Frame(ctl) + frame.pack(padx=5, pady=5, fill=tk.X, expand=True, side=tk.LEFT, anchor=tk.N) + frames.append(frame) + + for idx, choice in enumerate(choices): + frame_id = idx % columns + radio = ttk.Radiobutton(frames[frame_id], + text=choice.title(), + value=choice, + variable=self.tk_var) + radio.pack(anchor=tk.W) + logger.debug("Adding radio option %s to column %s", choice, frame_id) + logger.debug("Added radio group: '%s'", self.title) + return ctl + + def slider_control(self, dtype, rounding, min_max): + """ A slider control with corresponding Entry box """ + logger.debug("Add slider control to Options Frame: (title: '%s', dtype: %s, rounding: %s, " + "min_max: %s)", self.title, dtype, rounding, min_max) + tbox = ttk.Entry(self.frame, width=8, textvariable=self.tk_var, justify=tk.RIGHT) + tbox.pack(padx=(0, 5), side=tk.RIGHT) + ctl = self.control( + self.frame, + variable=self.tk_var, + command=lambda val, var=self.tk_var, dt=dtype, rn=rounding, mm=min_max: + set_slider_rounding(val, var, dt, rn, mm)) + rc_menu = ContextMenu(tbox) + rc_menu.cm_bind() + ctl["from_"] = min_max[0] + ctl["to"] = min_max[1] + logger.debug("Added slider control to Options Frame: %s", self.title) + return ctl + + def control_to_optionsframe(self, choices): + """ Standard non-check buttons sit in the main options frame """ + logger.debug("Add control to Options Frame: (title: '%s', control: %s, choices: %s)", + self.title, self.control, choices) + if self.control == ttk.Checkbutton: + ctl = self.control(self.frame, variable=self.tk_var, text=None) + else: + ctl = self.control(self.frame, textvariable=self.tk_var) + rc_menu = ContextMenu(ctl) + rc_menu.cm_bind() + if choices: + logger.debug("Adding combo choices: %s", choices) + ctl["values"] = [choice for choice in choices] + logger.debug("Added control to Options Frame: %s", self.title) + return ctl From 00990c4a353c26ed565af903ff9005813efe8864 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 27 Aug 2019 23:52:04 +0100 Subject: [PATCH 019/981] Gui Updates (#847) * GUI Improvements - Add basic GUI Config options (available in settings menu) - Dynamically add/remove columns on settings panel resize - Wrap text properly on info headers - Fix helptext formatting for configs - Standardize Tooltip widths - [code] Apply some widget naming - [code] Rename radio_columns to option_columns - [code] fix column count checking - Add Starting Tab config item - Add info boxes to main pages - Global font settings - Add resources to help menu --- lib/cli.py | 24 ++++ lib/gui/_config.py | 69 ++++++++++ lib/gui/command.py | 18 +-- lib/gui/control_helper.py | 266 ++++++++++++++++++++++++++++--------- lib/gui/display.py | 2 +- lib/gui/menu.py | 105 +++++++++++---- lib/gui/options.py | 5 +- lib/gui/popup_configure.py | 2 +- lib/gui/utils.py | 16 +++ scripts/gui.py | 102 +++++++++++--- tools/cli.py | 66 ++++++--- tools/preview.py | 6 +- 12 files changed, 545 insertions(+), 136 deletions(-) create mode 100644 lib/gui/_config.py diff --git a/lib/cli.py b/lib/cli.py index d2e5d06a65..4a8686db8d 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -355,6 +355,7 @@ def __init__(self, subparser, command, description="default", subparsers=None): self.global_arguments = self.get_global_arguments() + self.info = self.get_info() self.argument_list = self.get_argument_list() self.optional_arguments = self.get_optional_arguments() self.process_suppressions() @@ -368,6 +369,12 @@ def __init__(self, subparser, command, script = ScriptExecutor(command, subparsers) self.parser.set_defaults(func=script.execute_script) + @staticmethod + def get_info(): + """ Return command information for display in the GUI. + Override for command specific info """ + return None + @staticmethod def get_argument_list(): """ Put the arguments in a list so that they are accessible from both @@ -504,6 +511,11 @@ class ExtractArgs(ExtractConvertArgs): Inherits base options from ExtractConvertArgs where arguments that are used for both extract and convert should be placed """ + @staticmethod + def get_info(): + """ Return command information """ + return "Extract faces from image or video sources" + @staticmethod def get_optional_arguments(): """ Put the arguments in a list so that they are accessible from both @@ -732,6 +744,11 @@ class ConvertArgs(ExtractConvertArgs): Inherits base options from ExtractConvertArgs where arguments that are used for both extract and convert should be placed """ + @staticmethod + def get_info(): + """ Return command information """ + return "Swap the original faces in a source video/images to your final faces" + @staticmethod def get_optional_arguments(): """ Put the arguments in a list so that they are accessible from both @@ -960,6 +977,13 @@ def get_optional_arguments(): class TrainArgs(FaceSwapArgs): """ Class to parse the command line arguments for training """ + @staticmethod + def get_info(): + """ Return command information """ + return ("Train a model on extracted original (A) and swap (B) faces\n" + "Training models can take a long time. Anything from 24hrs to " + "over a week") + @staticmethod def get_argument_list(): """ Put the arguments in a list so that they are accessible from both diff --git a/lib/gui/_config.py b/lib/gui/_config.py new file mode 100644 index 0000000000..47552c6e6a --- /dev/null +++ b/lib/gui/_config.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +""" Default configurations for models """ + +import logging +import sys +import os +from tkinter import font + +from lib.config import FaceswapConfig + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class Config(FaceswapConfig): + """ Config File for GUI """ + # pylint: disable=too-many-statements + def set_defaults(self): + """ Set the default values for config """ + logger.debug("Setting defaults") + self.set_globals() + + def set_globals(self): + """ + Set the global options for GUI + """ + logger.debug("Setting global config") + section = "global" + self.add_section(title=section, + info="Faceswap GUI Options.\nNB: Faceswap will need to be restarted for " + "any changes to take effect.") + self.add_item( + section=section, title="fullscreen", datatype=bool, default=False, group="startup", + info="Start Faceswap maximized.") + self.add_item( + section=section, title="tab", datatype=str, default="extract", group="startup", + choices=get_commands(), + info="Start Faceswap in this tab.") + self.add_item( + section=section, title="options_panel_width", datatype=int, default=30, + min_max=(10, 90), rounding=1, group="layout", + info="How wide the lefthand option panel is as a percentage of GUI width at startup.") + self.add_item( + section=section, title="console_panel_height", datatype=int, default=20, + min_max=(10, 90), rounding=1, group="layout", + info="How tall the bottom console panel is as a percentage of GUI height at startup.") + self.add_item( + section=section, title="font", datatype=str, + choices=["default"] + sorted(font.families()), default="default", group="font", + info="Global font") + self.add_item( + section=section, title="font_size", datatype=int, default=9, + min_max=(6, 12), rounding=1, group="font", + info="Global font size.") + + +def get_commands(): + """ Return commands formatted for GUI """ + root_path = os.path.abspath(os.path.dirname(sys.argv[0])) + command_path = os.path.join(root_path, "scripts") + tools_path = os.path.join(root_path, "tools") + commands = [os.path.splitext(item)[0] for item in os.listdir(command_path) + if os.path.splitext(item)[1] == ".py" + and os.path.splitext(item)[0] not in ("gui", "fsmedia") + and not os.path.splitext(item)[0].startswith("_")] + tools = [os.path.splitext(item)[0] for item in os.listdir(tools_path) + if os.path.splitext(item)[1] == ".py" + and os.path.splitext(item)[0] not in ("gui", "cli") + and not os.path.splitext(item)[0].startswith("_")] + return commands + tools diff --git a/lib/gui/command.py b/lib/gui/command.py index ad967c0b91..f237564b63 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -17,12 +17,8 @@ class CommandNotebook(ttk.Notebook): # pylint:disable=too-many-ancestors def __init__(self, parent): logger.debug("Initializing %s: (parent: %s)", self.__class__.__name__, parent) - scaling_factor = get_config().scaling_factor - width = int(420 * scaling_factor) - height = int(500 * scaling_factor) - self.actionbtns = dict() - super().__init__(parent, width=width, height=height) + super().__init__(parent) parent.add(self) self.tools_notebook = ToolsNotebook(self) @@ -57,8 +53,8 @@ def change_action_button(self, *args): logger.debug("Update Action Buttons: (args: %s", args) tk_vars = get_config().tk_vars - for cmd in self.actionbtns.keys(): - btnact = self.actionbtns[cmd] + for cmd, action in self.actionbtns.items(): + btnact = action if tk_vars["runningtask"].get(): ttl = "Terminate" hlp = "Exit the running process" @@ -83,7 +79,7 @@ class CommandTab(ttk.Frame): # pylint:disable=too-many-ancestors def __init__(self, parent, category, command): logger.debug("Initializing %s: (category: '%s', command: '%s')", self.__class__.__name__, category, command) - super().__init__(parent) + super().__init__(parent, name="tab_{}".format(command.lower())) self.category = category self.actionbtns = parent.actionbtns @@ -96,7 +92,11 @@ def build_tab(self): """ Build the tab """ logger.debug("Build Tab: '%s'", self.command) options = get_config().cli_opts.opts[self.command] - ControlPanel(self, options, label_width=16, radio_columns=3, columns=1) + info = options.get("helptext", None) + if info is not None: + del options["helptext"] + ControlPanel(self, options, + label_width=16, option_columns=3, columns=1, header_text=info) self.add_frame_separator() ActionFrame(self) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index d654dfa43b..b54590a0ce 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -5,12 +5,27 @@ import tkinter as tk from tkinter import ttk +from itertools import zip_longest from .tooltip import Tooltip -from .utils import ContextMenu, FileHandler, get_images +from .utils import ContextMenu, FileHandler, get_config, get_images logger = logging.getLogger(__name__) # pylint: disable=invalid-name +# We store Tooltips globally when they are created +# Because we need to add them back to newly cloned widgets +_TOOLTIPS = dict() + + +def get_tooltip(widget, text, wraplength=600): + """ Store the tooltip layout and widget id in _TOOLTIPS and return a tooltip """ + global _TOOLTIPS # pylint:disable=global-statement + _TOOLTIPS[str(widget)] = {"text": text, + "wraplength": wraplength} + logger.debug("Adding to tooltips dict: (widget: %s. text: '%s', wraplength: %s)", + widget, text, wraplength) + return Tooltip(widget, text=text, wraplength=wraplength) + def set_slider_rounding(value, var, d_type, round_to, min_max): """ Set the underlying variable to correct number based on slider rounding """ @@ -33,15 +48,12 @@ class ControlPanel(ttk.Frame): # pylint:disable=too-many-ancestors Also keeps tally if groups passed in, so that any options with special processing needs are processed in the correct group frame """ - def __init__(self, parent, options, label_width=20, columns=1, radio_columns=4, + def __init__(self, parent, options, label_width=20, columns=1, option_columns=4, header_text=None, blank_nones=True): logger.debug("Initializing %s: (parent: '%s', options: %s, label_width: %s, columns: %s, " - "radio_columns: %s, header_text: %s, blank_nones: %s)", - self.__class__.__name__, parent, options, label_width, columns, radio_columns, - header_text, blank_nones) - gui_style = ttk.Style() - - gui_style.configure('BlueText.TLabelframe.Label', foreground="#0046D5", relief=tk.SOLID) + "option_columns: %s, header_text: %s, blank_nones: %s)", + self.__class__.__name__, parent, options, label_width, columns, + option_columns, header_text, blank_nones) super().__init__(parent) self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) @@ -49,7 +61,7 @@ def __init__(self, parent, options, label_width=20, columns=1, radio_columns=4, self.options = options self.label_width = label_width self.columns = columns - self.radio_columns = radio_columns + self.option_columns = option_columns self.header_text = header_text self.group_frames = dict() @@ -60,7 +72,8 @@ def __init__(self, parent, options, label_width=20, columns=1, radio_columns=4, self.mainframe, self.optsframe = self.get_opts_frame() self.optscanvas = self.canvas.create_window((0, 0), window=self.mainframe, anchor=tk.NW) - self.build_panel(radio_columns, blank_nones) + self.build_panel(blank_nones) + logger.debug("Initialized %s", self.__class__.__name__) def get_opts_frame(self): @@ -68,7 +81,7 @@ def get_opts_frame(self): mainframe = ttk.Frame(self.canvas) if self.header_text is not None: self.add_info(mainframe) - optsframe = ttk.Frame(mainframe) + optsframe = ttk.Frame(mainframe, name="opts_frame") optsframe.pack(expand=True, fill=tk.BOTH) holder = AutoFillContainer(optsframe, self.columns) logger.debug("Opts frames: '%s'", holder) @@ -78,8 +91,11 @@ def add_info(self, frame): """ Plugin information """ gui_style = ttk.Style() gui_style.configure('White.TFrame', background='#FFFFFF') - gui_style.configure('Header.TLabel', background='#FFFFFF', font=("", 9, "bold")) - gui_style.configure('Body.TLabel', background='#FFFFFF', font=("", 9)) + gui_style.configure('Header.TLabel', + background='#FFFFFF', + font=get_config().default_font + ("bold", )) + gui_style.configure('Body.TLabel', + background='#FFFFFF') info_frame = ttk.Frame(frame, style='White.TFrame', relief=tk.SOLID) info_frame.pack(fill=tk.X, side=tk.TOP, expand=True, padx=10, pady=10) @@ -90,10 +106,10 @@ def add_info(self, frame): continue style = "Header.TLabel" if idx == 0 else "Body.TLabel" info = ttk.Label(label_frame, text=line, style=style, anchor=tk.W) + info.bind("", adjust_wraplength) info.pack(fill=tk.X, padx=0, pady=0, expand=True, side=tk.TOP) - info.bind("", adjust_wraplength) - def build_panel(self, radio_columns, blank_nones): + def build_panel(self, blank_nones): """ Build the options frame for this command """ logger.debug("Add Config Frame") self.add_scrollbar() @@ -117,7 +133,7 @@ def build_panel(self, radio_columns, blank_nones): helptext=val["helptext"], sysbrowser=val.get("sysbrowser", None), checkbuttons_frame=group_frame["chkbtns"], - radio_columns=radio_columns, + option_columns=self.option_columns, blank_nones=blank_nones) if group_frame["chkbtns"].items > 0: group_frame["chkbtns"].parent.pack(side=tk.BOTTOM, fill=tk.X, anchor=tk.NW) @@ -143,7 +159,7 @@ def get_group_frame(self, group): else: group_frame = ttk.LabelFrame(opts_frame, text="" if is_master else group.title(), - name=group.lower(), style="BlueText.TLabelframe") + name=group.lower()) group_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5, anchor=tk.NW) @@ -170,6 +186,7 @@ def resize_frame(self, event): logger.debug("Resize Config Frame") canvas_width = event.width self.canvas.itemconfig(self.optscanvas, width=canvas_width) + self.optsframe.rearrange_columns(canvas_width) logger.debug("Resized Config Frame") def checkbuttons_frame(self, frame): @@ -178,7 +195,7 @@ def checkbuttons_frame(self, frame): otherwise in a standard frame """ logger.debug("Add Options CheckButtons Frame") chk_frame = ttk.Frame(frame, name="chkbuttons") - holder = AutoFillContainer(chk_frame, self.radio_columns) + holder = AutoFillContainer(chk_frame, self.option_columns) logger.debug("Added Options CheckButtons Frame") return holder @@ -188,13 +205,29 @@ class AutoFillContainer(): def __init__(self, parent, columns): logger.debug("Initializing: %s: (parent: %s, columns: %s)", self.__class__.__name__, parent, columns) + self.max_columns = 4 + self.single_column_width = self.scale_column_width(288, 9) + self.max_width = self.max_columns * self.single_column_width self.parent = parent - self.columns = columns + self.columns = min(columns, self.max_columns) self._items = 0 self._idx = 0 + self._widget_config = [] # Master list of all children in order self.subframes = self.set_subframes() logger.debug("Initialized: %s: (items: %s)", self.__class__.__name__, self.items) + @staticmethod + def scale_column_width(original_size, original_fontsize): + """ Scale the column width based on selected font size """ + font_size = get_config().user_config_dict["font_size"] + if font_size == original_fontsize: + return original_size + scale = 1 + (((font_size / original_fontsize) - 1) / 2) + retval = round(original_size * scale) + logger.debug("scaled column width: (old_width: %s, scale: %s, new_width:%s)", + original_size, scale, retval) + return retval + @property def items(self): """ Returns the number if items held in this containter """ @@ -204,32 +237,141 @@ def items(self): def subframe(self): """ Returns the next subframe to be populated """ frame = self.subframes[self._idx] - next_idx = self._idx + 1 if self._idx + 1 != self.columns else 0 + next_idx = self._idx + 1 if self._idx + 1 < self.columns else 0 logger.debug("current_idx: %s, next_idx: %s", self._idx, next_idx) self._idx = next_idx + self._items += 1 return frame - @property - def last_subframe(self): - """ Returns the last column """ - return self.subframes[self.columns - 1] - def set_subframes(self): - """ Set a subrame for each requested column """ + """ Set a subrame for each possible column """ subframes = [] - for idx in range(self.columns): - if self.columns != 1: - name = "{}_{}".format(self.parent.winfo_name(), idx) - subframe = ttk.Frame(self.parent, name=name) + for idx in range(self.max_columns): + name = "af_subframe_{}".format(idx) + subframe = ttk.Frame(self.parent, name=name) + if idx < self.columns: + # Only pack visible columns subframe.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N, expand=True, fill=tk.X) - subframes.append(subframe) - logger.debug("Added subframe: %s", name) - else: - subframes.append(self.parent) - logger.debug("Using parent as subframe: %s", self.parent.winfo_name()) - self._items += 1 + subframes.append(subframe) + logger.debug("Added subframe: %s", name) return subframes + def rearrange_columns(self, width): + """ On column number change redistribute widgets """ + if not self.single_column_width < width < self.max_width: + logger.debug("width outside min/max thresholds: (min: %s, width: %s, max: %s)", + self.single_column_width, width, self.max_width) + return + range_min = self.columns * self.single_column_width + range_max = (self.columns + 1) * self.single_column_width + if range_min < width < range_max: + logger.debug("width outside next step refresh threshold: (step down: %s, width: %s," + "step up: %s)", range_min, width, range_max) + return + new_columns = width // self.single_column_width + logger.debug("Rearranging columns: (width: %s, old_columns: %s, new_columns: %s)", + width, self.columns, new_columns) + self.columns = new_columns + if not self._widget_config: + self.compile_widget_config() + self.destroy_children() + self.repack_columns() + self.pack_widget_clones(self._widget_config) + + def compile_widget_config(self): + """ Compile all children recursively in correct order if not already compiled """ + zipped = zip_longest(*(subframe.winfo_children() for subframe in self.subframes)) + children = [child for group in zipped for child in group if child is not None] + self._widget_config = [{"class": child.__class__, + "id": str(child), + "tooltip": _TOOLTIPS.get(str(child), None), + "pack_info": self.pack_config_cleaner(child), + "name": child.winfo_name(), + "config": self.config_cleaner(child), + "children": self.get_all_children_config(child, [])} + for idx, child in enumerate(children)] + logger.debug("Compiled AutoFillContainer children: %s", self._widget_config) + + def get_all_children_config(self, widget, child_list): + """ Return all children, recursively, of given widget """ + for child in widget.winfo_children(): + if child.winfo_ismapped(): + id_ = str(child) + child_list.append({"class": child.__class__, + "id": id_, + "tooltip": _TOOLTIPS.get(id_, None), + "pack_info": self.pack_config_cleaner(child), + "name": child.winfo_name(), + "config": self.config_cleaner(child), + "parent": child.winfo_parent()}) + self.get_all_children_config(child, child_list) + return child_list + + @staticmethod + def config_cleaner(widget): + """ Some options don't like to be copied, so this returns a cleaned + configuration from a widget """ + new_config = dict() + if widget.configure() is None: + return None + for key, val in widget.configure().items(): + if key == "class": + continue + if key in ("anchor", "justify") and val[3] == "": + continue + new_config[key] = widget.cget(key) + return new_config + + @staticmethod + def pack_config_cleaner(widget): + """ Some options don't like to be copied, so this returns a cleaned + configuration from a widget """ + return {key: val for key, val in widget.pack_info().items() if key != "in"} + + def unpack_originals(self): + """ The original widgets must be unpacked but not destroyed + as we need to reference them for cloning """ + for subframe in self.subframes: + for child in subframe.winfo_children(): + child.pack_forget() + + def destroy_children(self): + """ Destroy the currently existing widgets """ + for subframe in self.subframes: + for child in subframe.winfo_children(): + child.destroy() + + def repack_columns(self): + """ Repack or unpack columns based on display columns """ + for idx, subframe in enumerate(self.subframes): + if idx < self.columns and not subframe.winfo_ismapped(): + subframe.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N, expand=True, fill=tk.X) + elif idx >= self.columns and subframe.winfo_ismapped(): + subframe.pack_forget() + + def pack_widget_clones(self, widget_dicts, old_children=None, new_children=None): + """ Widgets cannot be given a new parent so we need to clone + them and then pack the new widget """ + for widget_dict in widget_dicts: + logger.debug(widget_dict["id"]) + old_children = [] if old_children is None else old_children + new_children = [] if new_children is None else new_children + if widget_dict.get("parent", None) is not None: + parent = new_children[old_children.index(widget_dict["parent"])] + else: + # Get the next subframe if this doesn't have a logged parent + parent = self.subframe + clone = widget_dict["class"](parent, name=widget_dict["name"]) + if widget_dict["config"] is not None: + clone.configure(**widget_dict["config"]) + if widget_dict["tooltip"] is not None: + Tooltip(clone, **widget_dict["tooltip"]) + clone.pack(**widget_dict["pack_info"]) + old_children.append(widget_dict["id"]) + new_children.append(clone) + if widget_dict.get("children", None) is not None: + self.pack_widget_clones(widget_dict["children"], old_children, new_children) + class ControlBuilder(): """ @@ -262,7 +404,7 @@ class ControlBuilder(): Expects a dict: {sysbrowser: str, filetypes: str} helptext: str, optional Sets the tooltip text - radio_columns: int, optional + option_columns: int, optional Sets the number of columns to use for grouping radio buttons label_width: int, optional Sets the width of the control label. Defaults to 20 @@ -276,14 +418,14 @@ class ControlBuilder(): """ def __init__(self, parent, title, dtype, default, selected_value=None, choices=None, is_radio=False, rounding=None, - min_max=None, sysbrowser=None, helptext=None, radio_columns=3, label_width=20, + min_max=None, sysbrowser=None, helptext=None, option_columns=3, label_width=20, checkbuttons_frame=None, control_width=None, blank_nones=True): logger.debug("Initializing %s: (parent: %s, title: %s, dtype: %s, default: %s, " "selected_value: %s, choices: %s, is_radio: %s, rounding: %s, min_max: %s, " - "sysbrowser: %s, helptext: %s, radio_columns: %s, label_width: %s, " + "sysbrowser: %s, helptext: %s, option_columns: %s, label_width: %s, " "checkbuttons_frame: %s, control_width: %s, blank_nones: %s)", self.__class__.__name__, parent, title, dtype, default, selected_value, - choices, is_radio, rounding, min_max, sysbrowser, helptext, radio_columns, + choices, is_radio, rounding, min_max, sysbrowser, helptext, option_columns, label_width, checkbuttons_frame, control_width, blank_nones) self.title = title @@ -303,7 +445,7 @@ def __init__(self, parent, title, dtype, default, rounding, min_max, sysbrowser, - radio_columns, + option_columns, control_width) logger.debug("Initialized: %s", self.__class__.__name__) @@ -325,7 +467,7 @@ def format_helptext(self, helptext): if helptext.startswith("R|"): helptext = helptext[2:].replace("\nL|", "\n - ").replace("\n", "\n\n") else: - helptext = helptext.replace("\n\t", "\n - ").replace("%%", "%") + helptext = helptext.replace("\n\t", "\n - ").replace("%%", "%") helptext = ". ".join(i.capitalize() for i in helptext.split(". ")) helptext = self.title + " - " + helptext logger.debug("Formatted control help: (title: '%s', help: '%s'", self.title, helptext) @@ -368,7 +510,7 @@ def set_tk_var(self, dtype, selected_value, blank_nones): return var # Build the full control - def build_control(self, choices, dtype, rounding, min_max, sysbrowser, radio_columns, + def build_control(self, choices, dtype, rounding, min_max, sysbrowser, option_columns, control_width): """ Build the correct control type for the option passed through """ logger.debug("Build confog option control") @@ -379,7 +521,7 @@ def build_control(self, choices, dtype, rounding, min_max, sysbrowser, radio_col rounding, min_max, sysbrowser, - radio_columns, + option_columns, control_width) logger.debug("Built option control") @@ -390,21 +532,20 @@ def build_control_label(self): lbl = ttk.Label(self.frame, text=title, width=self.label_width, anchor=tk.W) lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) if self.helptext is not None: - Tooltip(lbl, text=self.helptext, wraplength=720) - + get_tooltip(lbl, text=self.helptext, wraplength=600) logger.debug("Built control label: '%s'", self.title) def build_one_control(self, choices, dtype, rounding, min_max, - sysbrowser, radio_columns, control_width): + sysbrowser, option_columns, control_width): """ Build and place the option controls """ logger.debug("Build control: (title: '%s', control: %s, choices: %s, dtype: %s, " - "rounding: %s, sysbrowser: %s, min_max: %s: radio_columns: %s, " + "rounding: %s, sysbrowser: %s, min_max: %s: option_columns: %s, " "control_width: %s)", self.title, self.control, choices, dtype, rounding, - sysbrowser, min_max, radio_columns, control_width) + sysbrowser, min_max, option_columns, control_width) if self.control == ttk.Scale: ctl = self.slider_control(dtype, rounding, min_max) elif self.control == ttk.Radiobutton: - ctl = self.radio_control(choices, radio_columns) + ctl = self.radio_control(choices, option_columns) elif self.control == ttk.Checkbutton: ctl = self.control_to_checkframe() else: @@ -413,7 +554,7 @@ def build_one_control(self, choices, dtype, rounding, min_max, if self.control != ttk.Checkbutton: ctl.pack(padx=5, pady=5, fill=tk.X, expand=True) if self.helptext is not None and not self.helpset: - Tooltip(ctl, text=self.helptext, wraplength=720) + get_tooltip(ctl, text=self.helptext, wraplength=600) logger.debug("Built control: '%s'", self.title) @@ -433,10 +574,9 @@ def radio_control(self, choices, columns): line.split()[1].lower()): " ".join(line.split()[1:]) for line in all_help if line.startswith(" - ")} - ctl = ttk.LabelFrame(self.frame, text=self.title.replace("_", " ").title(), - style="BlueText.TLabelframe") + name="radio_labelframe") radio_holder = AutoFillContainer(ctl, columns) for idx, choice in enumerate(choices): frame_id = idx % columns @@ -448,9 +588,9 @@ def radio_control(self, choices, columns): self.helpset = True helptext = helpitems[choice.lower()].capitalize() helptext = "{}\n\n - {}".format( - intro, - '. '.join(item.capitalize() for item in helptext.split('. '))) - Tooltip(radio, text=helptext, wraplength=400) + '. '.join(item.capitalize() for item in helptext.split('. ')), + intro) + get_tooltip(radio, text=helptext, wraplength=600) radio.pack(anchor=tk.W) logger.debug("Adding radio option %s to column %s", choice, frame_id) return radio_holder.parent @@ -459,7 +599,11 @@ def slider_control(self, dtype, rounding, min_max): """ A slider control with corresponding Entry box """ logger.debug("Add slider control to Options Frame: (title: '%s', dtype: %s, rounding: %s, " "min_max: %s)", self.title, dtype, rounding, min_max) - tbox = ttk.Entry(self.frame, width=8, textvariable=self.tk_var, justify=tk.RIGHT) + tbox = ttk.Entry(self.frame, + width=8, + textvariable=self.tk_var, + justify=tk.RIGHT, + font=get_config().default_font) tbox.pack(padx=(0, 5), side=tk.RIGHT) ctl = self.control( self.frame, @@ -482,7 +626,9 @@ def control_to_optionsframe(self, choices, sysbrowser): else: if sysbrowser is not None: self.filebrowser = FileBrowser(self.tk_var, self.frame, sysbrowser) - ctl = self.control(self.frame, textvariable=self.tk_var) + ctl = self.control(self.frame, + textvariable=self.tk_var, + font=get_config().default_font) rc_menu = ContextMenu(ctl) rc_menu.cm_bind() if choices: @@ -499,7 +645,7 @@ def control_to_checkframe(self): variable=self.tk_var, text=self.title.replace("_", " ").title(), name=self.title.lower()) - Tooltip(ctl, text=self.helptext, wraplength=200) + get_tooltip(ctl, text=self.helptext, wraplength=600) ctl.pack(side=tk.TOP, anchor=tk.W) logger.debug("Added control checkframe: '%s'", self.title) return ctl @@ -540,7 +686,7 @@ def add_browser_buttons(self): image=img, command=lambda cmd=action: cmd(self.tk_var, self.filetypes)) fileopn.pack(padx=(0, 5), side=tk.RIGHT) - Tooltip(fileopn, text=self.helptext[browser], wraplength=200) + get_tooltip(fileopn, text=self.helptext[browser], wraplength=600) logger.debug("Added browser buttons: (action: %s, filetypes: %s", action, self.filetypes) diff --git a/lib/gui/display.py b/lib/gui/display.py index 61aac590ff..6e0f1d770f 100644 --- a/lib/gui/display.py +++ b/lib/gui/display.py @@ -20,7 +20,7 @@ class DisplayNotebook(ttk.Notebook): # pylint: disable=too-many-ancestors def __init__(self, parent): logger.debug("Initializing %s", self.__class__.__name__) - ttk.Notebook.__init__(self, parent, width=780) + super().__init__(parent) parent.add(self) tk_vars = get_config().tk_vars self.wrapper_var = tk_vars["display"] diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 6970ca2eae..a5d1e264bf 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -6,6 +6,8 @@ import os import sys import tkinter as tk +import webbrowser + from importlib import import_module from subprocess import Popen, PIPE, STDOUT @@ -17,6 +19,10 @@ from .utils import get_config from .popup_configure import popup_config +_RESOURCES = [("faceswap.dev - Guides and Forum", "https://www.faceswap.dev"), + ("Patreon - Support this project", "https://www.patreon.com/faceswap"), + ("Discord - The FaceSwap Discord server", "https://discord.gg/VasFUAy"), + ("Github - Our Source Code", "https://github.com/deepfakes/faceswap")] logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -29,29 +35,26 @@ def __init__(self, master=None): self.root = master self.file_menu = FileMenu(self) - self.edit_menu = tk.Menu(self, tearoff=0) - self.tools_menu = ToolsMenu(self) + self.settings_menu = SettingsMenu(self) + self.help_menu = HelpMenu(self) self.add_cascade(label="File", menu=self.file_menu, underline=0) - self.build_edit_menu() - self.add_cascade(label="Tools", menu=self.tools_menu, underline=0) + self.add_cascade(label="Settings", menu=self.settings_menu, underline=0) + self.add_cascade(label="Help", menu=self.help_menu, underline=0) logger.debug("Initialized %s", self.__class__.__name__) - def build_edit_menu(self): - """ Add the edit menu to the menu bar """ - logger.debug("Building Edit menu") - configs = self.scan_for_configs() - for name in sorted(list(configs.keys())): - label = "Configure {} Plugins...".format(name.title()) - config = configs[name] - self.edit_menu.add_command( - label=label, - underline=10, - command=lambda conf=(name, config), root=self.root: popup_config(conf, root)) - self.add_cascade(label="Edit", menu=self.edit_menu, underline=0) - logger.debug("Built Edit menu") - def scan_for_configs(self): +class SettingsMenu(tk.Menu): # pylint:disable=too-many-ancestors + """ Settings menu items and functions """ + def __init__(self, parent): + logger.debug("Initializing %s", self.__class__.__name__) + super().__init__(parent, tearoff=0) + self.root = parent.root + self.configs = self.scan_for_plugin_configs() + self.build() + logger.debug("Initialized %s", self.__class__.__name__) + + def scan_for_plugin_configs(self): """ Scan for config.ini file locations """ root_path = os.path.abspath(os.path.dirname(sys.argv[0])) plugins_path = os.path.join(root_path, "plugins") @@ -75,6 +78,24 @@ def load_config(plugin_type): logger.debug("Found '%s' config at '%s'", plugin_type, config.configfile) return config + def build(self): + """ Add the settings menu to the menu bar """ + logger.debug("Building settings menu") + for name in sorted(list(self.configs.keys())): + label = "Configure {} Plugins...".format(name.title()) + config = self.configs[name] + self.add_command( + label=label, + underline=10, + command=lambda conf=(name, config), root=self.root: popup_config(conf, root)) + self.add_separator() + conf = get_config().user_config + self.add_command( + label="GUI Settings...", + underline=10, + command=lambda conf=("GUI", conf), root=self.root: popup_config(conf, root)) + logger.debug("Built settings menu") + class FileMenu(tk.Menu): # pylint:disable=too-many-ancestors """ File menu items and functions """ @@ -142,32 +163,50 @@ def refresh_recent_menu(self): self.build_recent_menu() -class ToolsMenu(tk.Menu): # pylint:disable=too-many-ancestors - """ Tools menu items and functions """ +class HelpMenu(tk.Menu): # pylint:disable=too-many-ancestors + """ Help menu items and functions """ def __init__(self, parent): logger.debug("Initializing %s", self.__class__.__name__) super().__init__(parent, tearoff=0) self.root = parent.root + self.recources_menu = tk.Menu(self, tearoff=0) self.build() logger.debug("Initialized %s", self.__class__.__name__) def build(self): - """ Build the tools menu """ - logger.debug("Building Tools menu") + """ Build the help menu """ + logger.debug("Building Help menu") + self.add_command(label="Check for updates...", + underline=0, + command=lambda action="check": self.in_thread(action)) + self.add_command(label="Update Faceswap...", underline=0, command=lambda action="update": self.in_thread(action)) + self.add_separator() + self.build_recources_menu() + self.add_cascade(label="Resources", underline=0, menu=self.recources_menu) + self.add_separator() self.add_command(label="Output System Information", underline=0, command=lambda action="output_sysinfo": self.in_thread(action)) - logger.debug("Built Tools menu") + logger.debug("Built help menu") + + def build_recources_menu(self): + """ Build resources menu """ + logger.debug("Building Resources Files menu") + for resource in _RESOURCES: + self.recources_menu.add_command( + label=resource[0], + command=lambda link=resource[1]: webbrowser.open_new(link)) + logger.debug("Built resources menu") def in_thread(self, action): """ Perform selected action inside a thread """ - logger.debug("Performing tools action: %s", action) + logger.debug("Performing help action: %s", action) thread = MultiThread(getattr(self, action), thread_count=1) thread.start() - logger.debug("Performed tools action: %s", action) + logger.debug("Performed help action: %s", action) @staticmethod def clear_console(): @@ -190,6 +229,15 @@ def output_sysinfo(self): print(info) self.root.config(cursor="") + def check(self): + """ Check for updates and clone repo """ + logger.debug("Checking for updates...") + self.root.config(cursor="watch") + encoding = locale.getpreferredencoding() + logger.debug("Encoding: %s", encoding) + self.check_for_updates(encoding, check=True) + self.root.config(cursor="") + def update(self): """ Check for updates and clone repo """ logger.debug("Updating Faceswap...") @@ -205,7 +253,7 @@ def update(self): self.root.config(cursor="") @staticmethod - def check_for_updates(encoding): + def check_for_updates(encoding, check=False): """ Check whether an update is required """ # Do the check logger.info("Checking for updates...") @@ -215,8 +263,6 @@ def check_for_updates(encoding): cmd = Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT) stdout, _ = cmd.communicate() retcode = cmd.poll() - logger.debug("'%s' output: %s", gitcmd, stdout.decode(encoding)) - logger.debug("'%s' returncode: %s", gitcmd, retcode) if retcode != 0: msg = ("Git is not installed or you are not running a cloned repo. " "Unable to check for updates") @@ -230,12 +276,13 @@ def check_for_updates(encoding): msg = "Faceswap is up to date." break if line.lower().startswith("your branch is behind"): + msg = "There are updates available" update = True break if "have diverged" in line.lower(): msg = "Your branch has diverged from the remote repo. Not updating" break - if not update: + if not update or check: logger.info(msg) logger.debug("Checked for update. Update required: %s", update) return update diff --git a/lib/gui/options.py b/lib/gui/options.py index 573d127f25..fbd8983b28 100644 --- a/lib/gui/options.py +++ b/lib/gui/options.py @@ -71,8 +71,9 @@ def extract_options(self, cli_source, mod_classes): for classname in mod_classes: logger.debug("Processing: (classname: '%s')", classname) command = self.format_command_name(classname) - options = self.get_cli_arguments(cli_source, classname, command) + info, options = self.get_cli_arguments(cli_source, classname, command) options = self.process_options(options, command) + options["helptext"] = info logger.debug("Processed: (classname: '%s', command: '%s', options: %s)", classname, command, options) subopts[command] = options @@ -82,7 +83,7 @@ def extract_options(self, cli_source, mod_classes): def get_cli_arguments(cli_source, classname, command): """ Extract the options from the main and tools cli files """ meth = getattr(cli_source, classname)(None, command) - return meth.argument_list + meth.optional_arguments + meth.global_arguments + return meth.info, meth.argument_list + meth.optional_arguments + meth.global_arguments def process_options(self, command_options, command): """ Process the options for a single command """ diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index d11a485410..7f77d6e65f 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -94,7 +94,7 @@ def build_page(self, container, category): """ Build a plugin config page """ logger.debug("Building plugin config page: '%s'", category) plugins = sorted(list(key for key in self.config_dict_gui[category].keys())) - panel_kwargs = dict(columns=2, radio_columns=2, blank_nones=False) + panel_kwargs = dict(columns=2, option_columns=2, blank_nones=False) if any(plugin != category for plugin in plugins): page = ttk.Notebook(container) page.pack(side=tk.TOP, fill=tk.BOTH, expand=True) diff --git a/lib/gui/utils.py b/lib/gui/utils.py index f0142ae865..b51eefb4f9 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -15,6 +15,8 @@ from lib.Serializer import JSONSerializer +from ._config import Config as UserConfig + logger = logging.getLogger(__name__) # pylint: disable=invalid-name _CONFIG = None _IMAGES = None @@ -629,10 +631,24 @@ def __init__(self, root, cli_opts, scaling_factor, pathcache, statusbar, session self.statusbar = statusbar self.serializer = JSONSerializer self.tk_vars = self.set_tk_vars() + self.user_config = UserConfig(None) self.command_notebook = None # set in command.py self.session = session logger.debug("Initialized %s", self.__class__.__name__) + @property + def user_config_dict(self): + """ Return the dictionary from user_config """ + return self.user_config.config_dict + + @property + def default_font(self): + """ Return the selected font """ + font = self.user_config_dict["font"] + if font == "default": + font = tk.font.nametofont("TkDefaultFont").configure()["family"] + return (font, self.user_config_dict["font_size"]) + @property def command_tabs(self): """ Return dict of command tab titles with their IDs """ diff --git a/scripts/gui.py b/scripts/gui.py index cc4421051d..f2032d927c 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -22,8 +22,12 @@ def __init__(self, pathscript): super().__init__() self.initialize_globals(pathscript) + self.set_fonts() + self.set_styles() self.set_geometry() + self.wrapper = ProcessWrapper(pathscript) + self.objects = dict() get_images().delete_preview() self.protocol("WM_DELETE_WINDOW", self.close_app) @@ -39,6 +43,20 @@ def initialize_globals(self, pathscript): initialize_config(self, cliopts, scaling_factor, pathcache, statusbar, session) initialize_images() + @staticmethod + def set_fonts(): + """ Set global default font """ + tk.font.nametofont("TkFixedFont").configure(size=get_config().default_font[1]) + for font in ("TkDefaultFont", "TkHeadingFont", "TkMenuFont"): + tk.font.nametofont(font).configure(family=get_config().default_font[0], + size=get_config().default_font[1]) + + @staticmethod + def set_styles(): + """ Set global custom styles """ + gui_style = ttk.Style() + gui_style.configure('TLabelframe.Label', foreground="#0046D5", relief=tk.SOLID) + def get_scaling(self): """ Get the display DPI """ dpi = self.winfo_fpixels("1i") @@ -48,12 +66,22 @@ def get_scaling(self): def set_geometry(self): """ Set GUI geometry """ + fullscreen = get_config().user_config_dict["fullscreen"] scaling_factor = get_config().scaling_factor - self.tk.call("tk", "scaling", scaling_factor) - width = int(1200 * scaling_factor) - height = int(640 * scaling_factor) - logger.debug("Geometry: %sx%s", width, height) - self.geometry("{}x{}+80+80".format(str(width), str(height))) + + if fullscreen: + initial_dimensions = (self.winfo_screenwidth(), self.winfo_screenheight()) + else: + initial_dimensions = (round(1200 * scaling_factor), round(640 * scaling_factor)) + + if fullscreen and sys.platform == "win32": + self.state('zoomed') + elif fullscreen: + self.attributes('-zoomed', True) + else: + self.geometry("{}x{}+80+80".format(str(initial_dimensions[0]), + str(initial_dimensions[1]))) + logger.debug("Geometry: %sx%s", *initial_dimensions) def build_gui(self, debug_console): """ Build the GUI """ @@ -62,11 +90,13 @@ def build_gui(self, debug_console): self.tk.call('wm', 'iconphoto', self._w, get_images().icons["favicon"]) self.configure(menu=MainMenuBar(self)) - topcontainer, bottomcontainer = self.add_containers() + self.add_containers() - CommandNotebook(topcontainer) - DisplayNotebook(topcontainer) - ConsoleOut(bottomcontainer, debug_console) + self.objects["command"] = CommandNotebook(self.objects["containers"]["top"]) + self.objects["display"] = DisplayNotebook(self.objects["containers"]["top"]) + self.objects["console"] = ConsoleOut(self.objects["containers"]["bottom"], debug_console) + self.set_initial_focus() + self.set_layout() logger.debug("Built GUI") def add_containers(self): @@ -74,20 +104,62 @@ def add_containers(self): hold each main area of the gui """ logger.debug("Adding containers") maincontainer = tk.PanedWindow(self, - sashrelief=tk.RAISED, - orient=tk.VERTICAL) + sashrelief=tk.RIDGE, + sashwidth=4, + sashpad=8, + orient=tk.VERTICAL, + name="pw_main") maincontainer.pack(fill=tk.BOTH, expand=True) topcontainer = tk.PanedWindow(maincontainer, - sashrelief=tk.RAISED, - orient=tk.HORIZONTAL) + sashrelief=tk.RIDGE, + sashwidth=4, + sashpad=8, + orient=tk.HORIZONTAL, + name="pw_top") maincontainer.add(topcontainer) - bottomcontainer = ttk.Frame(maincontainer, height=150) + bottomcontainer = ttk.Frame(maincontainer, name="frame_bottom") maincontainer.add(bottomcontainer) + self.objects["containers"] = dict(main=maincontainer, + top=topcontainer, + bottom=bottomcontainer) logger.debug("Added containers") - return topcontainer, bottomcontainer + + @staticmethod + def set_initial_focus(): + """ Set the tab focus from settings """ + config = get_config() + tab = config.user_config_dict["tab"] + logger.debug("Setting focus for tab: %s", tab) + tabs = config.command_tabs + if tab in tabs: + config.command_notebook.select(tabs[tab]) + else: + tool_tabs = config.tools_command_tabs + if tab in tool_tabs: + config.command_notebook.select(tabs["tools"]) + config.command_notebook.tools_notebook.select(tool_tabs[tab]) + logger.debug("Focus set to: %s", tab) + + def set_layout(self): + """ Set initial layout """ + self.update_idletasks() + root = get_config().root + config = get_config().user_config_dict + r_width = root.winfo_width() + r_height = root.winfo_height() + w_ratio = config["options_panel_width"] / 100.0 + h_ratio = 1 - (config["console_panel_height"] / 100.0) + width = round(r_width * w_ratio) + height = round(r_height * h_ratio) + logger.debug("Setting Initial Layout: (root_width: %s, root_height: %s, width_ratio: %s, " + "height_ratio: %s, width: %s, height: %s", r_width, r_height, w_ratio, + h_ratio, width, height) + self.objects["containers"]["top"].sash_place(0, width, 1) + self.objects["containers"]["main"].sash_place(0, 1, height) + self.update_idletasks() def close_app(self): """ Close Python. This is here because the graph diff --git a/tools/cli.py b/tools/cli.py index a3586f458c..f780e1d6bd 100644 --- a/tools/cli.py +++ b/tools/cli.py @@ -11,6 +11,12 @@ class AlignmentsArgs(FaceSwapArgs): """ Class to parse the command line arguments for Aligments tool """ + @staticmethod + def get_info(): + """ Return command information """ + return ("Alignments tool\nThis tool allows you to perform numerous actions on or using an " + "alignments file against its corresponding faceset/frame source.") + def get_argument_list(self): frames_dir = " Must Pass in a frames folder/source video file (-fr)." faces_dir = " Must Pass in a faces folder (-fc)." @@ -168,6 +174,12 @@ def get_argument_list(self): class PreviewArgs(FaceSwapArgs): """ Class to parse the command line arguments for Preview (Convert Settings) tool """ + + @staticmethod + def get_info(): + """ Return command information """ + return "Preview tool\nAllows you to configure your convert settings with a live preview" + def get_argument_list(self): argument_list = list() @@ -208,6 +220,11 @@ def get_argument_list(self): class EffmpegArgs(FaceSwapArgs): """ Class to parse the command line arguments for EFFMPEG tool """ + @staticmethod + def get_info(): + """ Return command information """ + return "A wrapper for ffmpeg for performing image <> video converting." + @staticmethod def __parse_transpose(value): index = 0 @@ -235,10 +252,15 @@ def get_argument_list(self): "default": "extract", "help": "R|Choose which action you want ffmpeg " "ffmpeg to do." - "\nL|'slice' cuts a portion of the video " - "into a separate video file." - "\nL|'get-fps' returns the chosen video's " - "fps."}) + "\nL|'extract': turns videos into images " + "\nL|'gen-vid': turns images into videos " + "\nL|'get-fps' returns the chosen video's fps." + "\nL|'get-info' returns information about a video." + "\nL|'mux-audio' add audio from one video to another." + "\nL|'rescale' resize video." + "\nL|'rotate' rotate video." + "\nL|'slice' cuts a portion of the video into a separate " + "video file."}) argument_list.append({"opts": ('-i', '--input'), "action": ContextFullPaths, @@ -424,6 +446,11 @@ def get_argument_list(self): class RestoreArgs(FaceSwapArgs): """ Class to restore model files from backup """ + @staticmethod + def get_info(): + """ Return command information """ + return "A tool for restoring models from backup (.bk) files" + @staticmethod def get_argument_list(): """ Put the arguments in a list so that they are accessible from both argparse and gui """ @@ -440,6 +467,11 @@ def get_argument_list(): class SortArgs(FaceSwapArgs): """ Class to parse the command line arguments for sort tool """ + @staticmethod + def get_info(): + """ Return command information """ + return "Sort faces using a number of different techniques" + @staticmethod def get_argument_list(): """ Put the arguments in a list so that they are accessible from both argparse and gui """ @@ -458,18 +490,6 @@ def get_argument_list(): "help": "Output directory for sorted aligned " "faces."}) - argument_list.append({"opts": ('-k', '--keep'), - "action": 'store_true', - "dest": 'keep_original', - "default": False, - "group": "output", - "help": "Keeps the original files in the input " - "directory. Be careful when using this " - "with rename grouping and no specified " - "output directory as this would keep " - "the original and renamed files in the " - "same directory."}) - argument_list.append({"opts": ('-s', '--sort-by'), "action": Radio, "type": str, @@ -477,7 +497,7 @@ def get_argument_list(): "face-yaw", "hist", "hist-dissim"), "dest": 'sort_method', "group": "sort settings", - "default": "hist", + "default": "face", "help": "R|Sort by method. Choose how images are sorted. " "\nL|'blur': Sort faces by blurriness." "\nL|'face': Use VGG Face to sort by face similarity. This " @@ -498,7 +518,17 @@ def get_argument_list(): "\nL|'hist-dissim': Like 'hist' but sorts by " "dissimilarity." "\nDefault: hist"}) - + argument_list.append({"opts": ('-k', '--keep'), + "action": 'store_true', + "dest": 'keep_original', + "default": False, + "group": "output", + "help": "Keeps the original files in the input " + "directory. Be careful when using this " + "with rename grouping and no specified " + "output directory as this would keep " + "the original and renamed files in the " + "same directory."}) argument_list.append({"opts": ('-t', '--ref_threshold'), "action": Slider, "min_max": (-1.0, 10.0), diff --git a/tools/preview.py b/tools/preview.py index fed1bb9dbf..2d11e63426 100644 --- a/tools/preview.py +++ b/tools/preview.py @@ -115,7 +115,11 @@ def refresh(self, *args): def build_ui(self): """ Build the UI elements for displaying preview and options """ - container = tk.PanedWindow(self.root, sashrelief=tk.RAISED, orient=tk.VERTICAL) + container = tk.PanedWindow(self.root, + sashrelief=tk.RIDGE, + sashwidth=4, + sashpad=8, + orient=tk.VERTICAL) container.pack(fill=tk.BOTH, expand=True) container.preview_display = self.display self.image_canvas = ImagesCanvas(container, self.tk_vars) From 36a7f7340acce585a9ff30c994c55695ac43eecc Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 28 Aug 2019 16:17:25 +0100 Subject: [PATCH 020/981] Bugfix: Fix commands for re-arranged panels --- lib/gui/control_helper.py | 66 ++++++++++++++++++++++----------------- lib/gui/utils.py | 6 +--- 2 files changed, 38 insertions(+), 34 deletions(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index b54590a0ce..603e61d836 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -6,6 +6,7 @@ import tkinter as tk from tkinter import ttk from itertools import zip_longest +from functools import partial from .tooltip import Tooltip from .utils import ContextMenu, FileHandler, get_config, get_images @@ -16,10 +17,13 @@ # Because we need to add them back to newly cloned widgets _TOOLTIPS = dict() +# We store commands globally when they are created +# Because we need to add them back to newly cloned widgets +_COMMANDS = dict() + def get_tooltip(widget, text, wraplength=600): """ Store the tooltip layout and widget id in _TOOLTIPS and return a tooltip """ - global _TOOLTIPS # pylint:disable=global-statement _TOOLTIPS[str(widget)] = {"text": text, "wraplength": wraplength} logger.debug("Adding to tooltips dict: (widget: %s. text: '%s', wraplength: %s)", @@ -27,6 +31,13 @@ def get_tooltip(widget, text, wraplength=600): return Tooltip(widget, text=text, wraplength=wraplength) +def add_command(name, func): + """ For controls that execute commands, the command must be added to the _COMMAND list so that + it can be added back to the widget during cloning """ + logger.debug("Adding to commands: %s - %s", name, func) + _COMMANDS[name] = func + + def set_slider_rounding(value, var, d_type, round_to, min_max): """ Set the underlying variable to correct number based on slider rounding """ if d_type == float: @@ -214,7 +225,7 @@ def __init__(self, parent, columns): self._idx = 0 self._widget_config = [] # Master list of all children in order self.subframes = self.set_subframes() - logger.debug("Initialized: %s: (items: %s)", self.__class__.__name__, self.items) + logger.debug("Initialized: %s", self.__class__.__name__) @staticmethod def scale_column_width(original_size, original_fontsize): @@ -310,16 +321,19 @@ def get_all_children_config(self, widget, child_list): @staticmethod def config_cleaner(widget): """ Some options don't like to be copied, so this returns a cleaned - configuration from a widget """ + configuration from a widget + We use config() instead of configure() because some items (TScale) do + not populate configure()""" new_config = dict() - if widget.configure() is None: - return None - for key, val in widget.configure().items(): + for key in widget.config(): if key == "class": continue - if key in ("anchor", "justify") and val[3] == "": + val = widget.cget(key) + if key in ("anchor", "justify") and val == "": continue - new_config[key] = widget.cget(key) + # Return correct command from master command dict + val = _COMMANDS[val] if key == "command" and val != "" else val + new_config[key] = val return new_config @staticmethod @@ -328,13 +342,6 @@ def pack_config_cleaner(widget): configuration from a widget """ return {key: val for key, val in widget.pack_info().items() if key != "in"} - def unpack_originals(self): - """ The original widgets must be unpacked but not destroyed - as we need to reference them for cloning """ - for subframe in self.subframes: - for child in subframe.winfo_children(): - child.pack_forget() - def destroy_children(self): """ Destroy the currently existing widgets """ for subframe in self.subframes: @@ -353,7 +360,7 @@ def pack_widget_clones(self, widget_dicts, old_children=None, new_children=None) """ Widgets cannot be given a new parent so we need to clone them and then pack the new widget """ for widget_dict in widget_dicts: - logger.debug(widget_dict["id"]) + logger.debug("Cloning widget: %s", widget_dict) old_children = [] if old_children is None else old_children new_children = [] if new_children is None else new_children if widget_dict.get("parent", None) is not None: @@ -450,11 +457,10 @@ def __init__(self, parent, title, dtype, default, logger.debug("Initialized: %s", self.__class__.__name__) # Frame, control type and varable - @staticmethod - def control_frame(parent): + def control_frame(self, parent): """ Frame to hold control and it's label """ logger.debug("Build control frame") - frame = ttk.Frame(parent) + frame = ttk.Frame(parent, name="fr_{}".format(self.title.lower())) frame.pack(fill=tk.X) logger.debug("Built control frame") return frame @@ -513,7 +519,7 @@ def set_tk_var(self, dtype, selected_value, blank_nones): def build_control(self, choices, dtype, rounding, min_max, sysbrowser, option_columns, control_width): """ Build the correct control type for the option passed through """ - logger.debug("Build confog option control") + logger.debug("Build config option control") if self.control not in (ttk.Checkbutton, ttk.Radiobutton): self.build_control_label() self.build_one_control(choices, @@ -605,11 +611,13 @@ def slider_control(self, dtype, rounding, min_max): justify=tk.RIGHT, font=get_config().default_font) tbox.pack(padx=(0, 5), side=tk.RIGHT) - ctl = self.control( - self.frame, - variable=self.tk_var, - command=lambda val, var=self.tk_var, dt=dtype, rn=rounding, mm=min_max: - set_slider_rounding(val, var, dt, rn, mm)) + cmd = partial(set_slider_rounding, + var=self.tk_var, + d_type=dtype, + round_to=rounding, + min_max=min_max) + ctl = self.control(self.frame, variable=self.tk_var, command=cmd) + add_command(ctl.cget("command"), cmd) rc_menu = ContextMenu(tbox) rc_menu.cm_bind() ctl["from_"] = min_max[0] @@ -678,13 +686,13 @@ def helptext(self): def add_browser_buttons(self): """ Add correct file browser button for control """ - logger.debug("Adding browser buttons: (sysbrowser: '%s'", self.browser) + logger.debug("Adding browser buttons: (sysbrowser: %s", self.browser) for browser in self.browser: img = get_images().icons[browser] action = getattr(self, "ask_" + browser) - fileopn = ttk.Button(self.frame, - image=img, - command=lambda cmd=action: cmd(self.tk_var, self.filetypes)) + cmd = partial(action, filepath=self.tk_var, filetypes=self.filetypes) + fileopn = ttk.Button(self.frame, image=img, command=cmd) + add_command(fileopn.cget("command"), cmd) fileopn.pack(padx=(0, 5), side=tk.RIGHT) get_tooltip(fileopn, text=self.helptext[browser], wraplength=600) logger.debug("Added browser buttons: (action: %s, filetypes: %s", diff --git a/lib/gui/utils.py b/lib/gui/utils.py index b51eefb4f9..776ce0fe31 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -632,15 +632,11 @@ def __init__(self, root, cli_opts, scaling_factor, pathcache, statusbar, session self.serializer = JSONSerializer self.tk_vars = self.set_tk_vars() self.user_config = UserConfig(None) + self.user_config_dict = self.user_config.config_dict self.command_notebook = None # set in command.py self.session = session logger.debug("Initialized %s", self.__class__.__name__) - @property - def user_config_dict(self): - """ Return the dictionary from user_config """ - return self.user_config.config_dict - @property def default_font(self): """ Return the selected font """ From 3159cb6eca130e9b6e2de7762abab1c173923007 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 29 Aug 2019 00:03:51 +0100 Subject: [PATCH 021/981] GUI Fixups - GUI Bugfix: Startup options - fix min/max thresholds - GUI Bugfix: Make console read-only - GUI Bugfix: Rightclick menu for recreated widgets - Enhancement: Create ControlPanelOptions Class + migrate options --- lib/gui/command.py | 13 +- lib/gui/control_helper.py | 610 ++++++++++++++++++++++--------------- lib/gui/options.py | 94 +++--- lib/gui/popup_configure.py | 53 ++-- lib/gui/utils.py | 6 +- 5 files changed, 461 insertions(+), 315 deletions(-) diff --git a/lib/gui/command.py b/lib/gui/command.py index f237564b63..ca75b34fe8 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -92,13 +92,14 @@ def build_tab(self): """ Build the tab """ logger.debug("Build Tab: '%s'", self.command) options = get_config().cli_opts.opts[self.command] - info = options.get("helptext", None) - if info is not None: - del options["helptext"] - ControlPanel(self, options, - label_width=16, option_columns=3, columns=1, header_text=info) + cp_opts = [val["cpanel_option"] for key, val in options.items() if key != "helptext"] + ControlPanel(self, + cp_opts, + label_width=16, + option_columns=3, + columns=1, + header_text=options.get("helptext", None)) self.add_frame_separator() - ActionFrame(self) logger.debug("Built Tab: '%s'", self.command) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 603e61d836..fa6abd5670 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -13,29 +13,35 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name -# We store Tooltips globally when they are created -# Because we need to add them back to newly cloned widgets -_TOOLTIPS = dict() - -# We store commands globally when they are created -# Because we need to add them back to newly cloned widgets -_COMMANDS = dict() +# We store Tooltips, ContextMenus and Commands globally when they are created +# Because we need to add them back to newly cloned widgets (they are not easily accessible from +# original config or are prone to getting destroyed when the original widget is destroyed) +_RECREATE_OBJECTS = dict(tooltips=dict(), commands=dict(), contextmenus=dict()) def get_tooltip(widget, text, wraplength=600): """ Store the tooltip layout and widget id in _TOOLTIPS and return a tooltip """ - _TOOLTIPS[str(widget)] = {"text": text, - "wraplength": wraplength} + _RECREATE_OBJECTS["tooltips"][str(widget)] = {"text": text, + "wraplength": wraplength} logger.debug("Adding to tooltips dict: (widget: %s. text: '%s', wraplength: %s)", widget, text, wraplength) return Tooltip(widget, text=text, wraplength=wraplength) +def get_contextmenu(widget): + """ Create a context menu, store its mapping and return """ + rc_menu = ContextMenu(widget) + _RECREATE_OBJECTS["contextmenus"][str(widget)] = rc_menu + logger.debug("Adding to Context menu: (widget: %s. rc_menu: %s)", + widget, rc_menu) + return rc_menu + + def add_command(name, func): """ For controls that execute commands, the command must be added to the _COMMAND list so that it can be added back to the widget during cloning """ logger.debug("Adding to commands: %s - %s", name, func) - _COMMANDS[name] = func + _RECREATE_OBJECTS["commands"][name] = func def set_slider_rounding(value, var, d_type, round_to, min_max): @@ -54,13 +60,202 @@ def adjust_wraplength(event): label.configure(wraplength=event.width - 1) +class ControlPanelOption(): + """ + A class to hold a control panel option. A list of these is expected + to be passed to the ControlPanel object. + + Parameters + ---------- + title: str + Title of the control. Will be used for label text and control naming + dtype: datatype object + Datatype of the control. + group: str, optional + The group that this control should sit with. If provided, all controls in the same + group will be placed together. Default: None + default: str, optional + Default value for the control. If None is provided, then action will be dictated by + whether "blank_nones" is set in ControlPanel + initial_value: str, optional + Initial value for the control. If None, default will be used + choices: list or tuple, object + Used for combo boxes and radio control option setting + is_radio: bool, optional + Specifies to use a Radio control instead of combobox if choices are passed + rounding: int or float, optional + For slider controls. Sets the stepping + min_max: int or float, optional + For slider controls. Sets the min and max values + sysbrowser: dict, optional + Adds Filesystem browser buttons to ttk.Entry options. + Expects a dict: {sysbrowser: str, filetypes: str} + helptext: str, optional + Sets the tooltip text + """ + + def __init__(self, title, dtype, # pylint:disable=too-many-arguments + group=None, default=None, initial_value=None, choices=None, is_radio=False, + rounding=None, min_max=None, sysbrowser=None, helptext=None): + logger.debug("Initializing %s: (title: '%s', dtype: %s, group: %s, default: %s, " + "initial_value: %s, choices: %s, is_radio: %s, rounding: %s, min_max: %s, " + "sysbrowser: %s, helptext: '%s')", self.__class__.__name__, title, dtype, + group, default, initial_value, choices, is_radio, rounding, min_max, + sysbrowser, helptext) + + self.dtype = dtype + self.sysbrowser = sysbrowser + self._options = dict(title=title, + group=group, + default=default, + initial_value=initial_value, + choices=choices, + is_radio=is_radio, + rounding=rounding, + min_max=min_max, + helptext=helptext) + self.control = self.get_control() + self.tk_var = self.get_tk_var() + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def name(self): + """ Lowered title for naming """ + return self._options["title"].lower() + + @property + def title(self): + """ Title case title for naming with underscores removed """ + return self._options["title"].replace("_", " ").title() + + @property + def group(self): + """ Return group or _master if no group set """ + group = self._options["group"] + group = "_master" if group is None else group + return group + + @property + def default(self): + """ Return either selected value or default """ + return self._options["default"] + + @property + def value(self): + """ Return either selected value or default """ + val = self._options["initial_value"] + val = self.default if val is None else val + return val + + @property + def choices(self): + """ Return choices """ + return self._options["choices"] + + @property + def is_radio(self): + """ Return is_radio """ + return self._options["is_radio"] + + @property + def rounding(self): + """ Return rounding """ + return self._options["rounding"] + + @property + def min_max(self): + """ Return min_max """ + return self._options["min_max"] + + @property + def helptext(self): + """ Format and return help text for tooltips """ + helptext = self._options["helptext"] + if helptext is None: + return helptext + logger.debug("Format control help: '%s'", self.name) + if helptext.startswith("R|"): + helptext = helptext[2:].replace("\nL|", "\n - ").replace("\n", "\n\n") + else: + helptext = helptext.replace("\n\t", "\n - ").replace("%%", "%") + helptext = ". ".join(i.capitalize() for i in helptext.split(". ")) + helptext = self.title + " - " + helptext + logger.debug("Formatted control help: (name: '%s', help: '%s'", self.name, helptext) + return helptext + + def get(self): + """ Return the value from the tk_var """ + return self.tk_var.get() + + def set(self, value): + """ Set the tk_var to a new value """ + self.tk_var.set(value) + + def get_control(self): + """ Set the correct control type based on the datatype or for this option """ + if self.choices and self.is_radio: + control = ttk.Radiobutton + elif self.choices: + control = ttk.Combobox + elif self.dtype == bool: + control = ttk.Checkbutton + elif self.dtype in (int, float): + control = ttk.Scale + else: + control = ttk.Entry + logger.debug("Setting control '%s' to %s", self.title, control) + return control + + def get_tk_var(self): + """ Correct variable type for control """ + if self.dtype == bool: + var = tk.BooleanVar() + elif self.dtype == int: + var = tk.IntVar() + elif self.dtype == float: + var = tk.DoubleVar() + else: + var = tk.StringVar() + logger.debug("Setting tk variable: (name: '%s', dtype: %s, tk_var: %s)", + self.name, self.dtype, var) + return var + + class ControlPanel(ttk.Frame): # pylint:disable=too-many-ancestors - """ A Panel for holding controls - Also keeps tally if groups passed in, so that any options with special - processing needs are processed in the correct group frame """ + """ + A Control Panel to hold control panel options. + This class handles all of the formatting, placing and TK_Variables + in a consistent manner. + + It can also provide dynamic columns for resizing widgets - def __init__(self, parent, options, label_width=20, columns=1, option_columns=4, - header_text=None, blank_nones=True): + Parameters + ---------- + parent: tk object + Parent widget that should hold this control panel + options: list of ControlPanelOptions objects + The list of controls that are to be built into this control panel + label_width: int, optional + The width that labels for controls should be set to. + Defaults to 20 + columns: int, optional + The maximum number of columns that this control panel should be able + to accomodate. Setting to 1 means that there will only be 1 column + regardless of how wide the control panel is. Higher numbers will + dynamically fill extra columns if space permits. Defaults to 1 + option_columns: int, optional + For checkbutton and radiobutton containers, how many options should + be displayed on each row. Defaults to 4 + header_text: str, optional + If provided, will place an information box at the top of the control + panel with these contents. + blank_nones: bool, optional + How the control panel should handle Nones. If set to True then Nones + will be converted to empty strings. Default: False + """ + + def __init__(self, parent, options, # pylint:disable=too-many-arguments + label_width=20, columns=1, option_columns=4, header_text=None, blank_nones=True): logger.debug("Initializing %s: (parent: '%s', options: %s, label_width: %s, columns: %s, " "option_columns: %s, header_text: %s, blank_nones: %s)", self.__class__.__name__, parent, options, label_width, columns, @@ -70,6 +265,7 @@ def __init__(self, parent, options, label_width=20, columns=1, option_columns=4, self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) self.options = options + self.controls = [] self.label_width = label_width self.columns = columns self.option_columns = option_columns @@ -126,34 +322,20 @@ def build_panel(self, blank_nones): self.add_scrollbar() self.canvas.bind("", self.resize_frame) - for key, val in self.options.items(): - if key == "helptext": - continue - group = "_master" if val["group"] is None else val["group"] - group_frame = self.get_group_frame(group) + for option in self.options: + group_frame = self.get_group_frame(option.group) ctl = ControlBuilder(group_frame["frame"], - key, - val["type"], - val["default"], + option, label_width=self.label_width, - selected_value=val["value"], - choices=val["choices"], - is_radio=val["gui_radio"], - rounding=val["rounding"], - min_max=val["min_max"], - helptext=val["helptext"], - sysbrowser=val.get("sysbrowser", None), checkbuttons_frame=group_frame["chkbtns"], option_columns=self.option_columns, blank_nones=blank_nones) if group_frame["chkbtns"].items > 0: group_frame["chkbtns"].parent.pack(side=tk.BOTTOM, fill=tk.X, anchor=tk.NW) - val["selected"] = ctl.tk_var - self.options[key]["_gui_option"] = ctl - for key, val in self.options.items(): - if key == "helptext": - continue - filebrowser = val["_gui_option"].filebrowser + + self.controls.append(ctl) + for control in self.controls: + filebrowser = control.filebrowser if filebrowser is not None: filebrowser.set_context_action_option(self.options) logger.debug("Added Config Frame") @@ -269,17 +451,10 @@ def set_subframes(self): def rearrange_columns(self, width): """ On column number change redistribute widgets """ - if not self.single_column_width < width < self.max_width: - logger.debug("width outside min/max thresholds: (min: %s, width: %s, max: %s)", - self.single_column_width, width, self.max_width) + if not self.validate(width): return - range_min = self.columns * self.single_column_width - range_max = (self.columns + 1) * self.single_column_width - if range_min < width < range_max: - logger.debug("width outside next step refresh threshold: (step down: %s, width: %s," - "step up: %s)", range_min, width, range_max) - return - new_columns = width // self.single_column_width + + new_columns = min(self.max_columns, max(1, width // self.single_column_width)) logger.debug("Rearranging columns: (width: %s, old_columns: %s, new_columns: %s)", width, self.columns, new_columns) self.columns = new_columns @@ -289,13 +464,29 @@ def rearrange_columns(self, width): self.repack_columns() self.pack_widget_clones(self._widget_config) + def validate(self, width): + """ Validate that passed in width should trigger column re-arranging """ + if ((width < self.single_column_width and self.columns == 1) or + (width > self.max_width and self.columns == self.max_columns)): + logger.debug("width outside min/max thresholds: (min: %s, width: %s, max: %s)", + self.single_column_width, width, self.max_width) + return False + range_min = self.columns * self.single_column_width + range_max = (self.columns + 1) * self.single_column_width + if range_min < width < range_max: + logger.debug("width outside next step refresh threshold: (step down: %s, width: %s," + "step up: %s)", range_min, width, range_max) + return False + return True + def compile_widget_config(self): """ Compile all children recursively in correct order if not already compiled """ zipped = zip_longest(*(subframe.winfo_children() for subframe in self.subframes)) children = [child for group in zipped for child in group if child is not None] self._widget_config = [{"class": child.__class__, "id": str(child), - "tooltip": _TOOLTIPS.get(str(child), None), + "tooltip": _RECREATE_OBJECTS["tooltips"].get(str(child), None), + "rc_menu": _RECREATE_OBJECTS["contextmenus"].get(str(child), None), "pack_info": self.pack_config_cleaner(child), "name": child.winfo_name(), "config": self.config_cleaner(child), @@ -308,13 +499,15 @@ def get_all_children_config(self, widget, child_list): for child in widget.winfo_children(): if child.winfo_ismapped(): id_ = str(child) - child_list.append({"class": child.__class__, - "id": id_, - "tooltip": _TOOLTIPS.get(id_, None), - "pack_info": self.pack_config_cleaner(child), - "name": child.winfo_name(), - "config": self.config_cleaner(child), - "parent": child.winfo_parent()}) + child_list.append({ + "class": child.__class__, + "id": id_, + "tooltip": _RECREATE_OBJECTS["tooltips"].get(id_, None), + "rc_menu": _RECREATE_OBJECTS["contextmenus"].get(str(id_), None), + "pack_info": self.pack_config_cleaner(child), + "name": child.winfo_name(), + "config": self.config_cleaner(child), + "parent": child.winfo_parent()}) self.get_all_children_config(child, child_list) return child_list @@ -332,7 +525,7 @@ def config_cleaner(widget): if key in ("anchor", "justify") and val == "": continue # Return correct command from master command dict - val = _COMMANDS[val] if key == "command" and val != "" else val + val = _RECREATE_OBJECTS["commands"][val] if key == "command" and val != "" else val new_config[key] = val return new_config @@ -373,6 +566,11 @@ def pack_widget_clones(self, widget_dicts, old_children=None, new_children=None) clone.configure(**widget_dict["config"]) if widget_dict["tooltip"] is not None: Tooltip(clone, **widget_dict["tooltip"]) + rc_menu = widget_dict["rc_menu"] + if rc_menu is not None: + # Re-initialize for new widget and bind + rc_menu.__init__(widget=clone) + rc_menu.cm_bind() clone.pack(**widget_dict["pack_info"]) old_children.append(widget_dict["id"]) new_children.append(clone) @@ -383,197 +581,101 @@ def pack_widget_clones(self, widget_dicts, old_children=None, new_children=None) class ControlBuilder(): """ Builds and returns a frame containing a tkinter control with label - - Currently only setup for config items + This should only be called from the ControlPanel class Parameters ---------- parent: tkinter object Parent tkinter object - title: str - Title of the control. Will be used for label text - dtype: datatype object - Datatype of the control. - default: str - Default value for the control - selected_value: str, optional - Selected value for the control. If None, default will be used - choices: list or tuple, object - Used for combo boxes and radio control option setting - is_radio: bool, optional - Specifies to use a Radio control instead of combobox if choices are passed - rounding: int or float, optional - For slider controls. Sets the stepping - min_max: int or float, optional - For slider controls. Sets the min and max values - sysbrowser: dict, optional - Adds Filesystem browser buttons to ttk.Entry options. - Expects a dict: {sysbrowser: str, filetypes: str} - helptext: str, optional - Sets the tooltip text - option_columns: int, optional - Sets the number of columns to use for grouping radio buttons - label_width: int, optional - Sets the width of the control label. Defaults to 20 - checkbuttons_frame: tk.frame, optional + option: ControlPanelOption object + Holds all of the required option information + option_columns: int + Number of options to put on a single row for checkbuttons/radiobuttons + label_width: int + Sets the width of the control label + checkbuttons_frame: tk.frame If a checkbutton frame is passed in, then checkbuttons will be placed in this frame rather than the main options frame - control_width: int, optional - Sets the width of the control. Default is to auto expand - blank_nones: bool, optional - Sets selected values to an empty string rather than None if this is true. Default is true + blank_nones: bool + Sets selected values to an empty string rather than None if this is true. """ - def __init__(self, parent, title, dtype, default, - selected_value=None, choices=None, is_radio=False, rounding=None, - min_max=None, sysbrowser=None, helptext=None, option_columns=3, label_width=20, - checkbuttons_frame=None, control_width=None, blank_nones=True): - logger.debug("Initializing %s: (parent: %s, title: %s, dtype: %s, default: %s, " - "selected_value: %s, choices: %s, is_radio: %s, rounding: %s, min_max: %s, " - "sysbrowser: %s, helptext: %s, option_columns: %s, label_width: %s, " - "checkbuttons_frame: %s, control_width: %s, blank_nones: %s)", - self.__class__.__name__, parent, title, dtype, default, selected_value, - choices, is_radio, rounding, min_max, sysbrowser, helptext, option_columns, - label_width, checkbuttons_frame, control_width, blank_nones) - - self.title = title - self.default = default - self.helptext = self.format_helptext(helptext) + def __init__(self, parent, option, option_columns, label_width, # pylint: disable=too-many-arguments + checkbuttons_frame, blank_nones): + logger.debug("Initializing %s: (parent: %s, option: %s, option_columns: %s, " + "label_width: %s, checkbuttons_frame: %s, blank_nones: %s)", + self.__class__.__name__, parent, option, option_columns, label_width, + checkbuttons_frame, blank_nones) + + self.option = option + self.option_columns = option_columns self.helpset = False self.label_width = label_width self.filebrowser = None self.frame = self.control_frame(parent) self.chkbtns = checkbuttons_frame - self.control = self.set_control(dtype, choices, is_radio) - self.tk_var = self.set_tk_var(dtype, selected_value, blank_nones) - - self.build_control(choices, - dtype, - rounding, - min_max, - sysbrowser, - option_columns, - control_width) + + self.set_tk_var(blank_nones) + self.build_control() logger.debug("Initialized: %s", self.__class__.__name__) # Frame, control type and varable def control_frame(self, parent): """ Frame to hold control and it's label """ logger.debug("Build control frame") - frame = ttk.Frame(parent, name="fr_{}".format(self.title.lower())) + frame = ttk.Frame(parent, name="fr_{}".format(self.option.name)) frame.pack(fill=tk.X) logger.debug("Built control frame") return frame - def format_helptext(self, helptext): - """ Format the help text for tooltips """ - if helptext is None: - return helptext - logger.debug("Format control help: '%s'", self.title) - if helptext.startswith("R|"): - helptext = helptext[2:].replace("\nL|", "\n - ").replace("\n", "\n\n") - else: - helptext = helptext.replace("\n\t", "\n - ").replace("%%", "%") - helptext = ". ".join(i.capitalize() for i in helptext.split(". ")) - helptext = self.title + " - " + helptext - logger.debug("Formatted control help: (title: '%s', help: '%s'", self.title, helptext) - return helptext - - def set_control(self, dtype, choices, is_radio): - """ Set the correct control type based on the datatype or for this option """ - if choices and is_radio: - control = ttk.Radiobutton - elif choices: - control = ttk.Combobox - elif dtype == bool: - control = ttk.Checkbutton - elif dtype in (int, float): - control = ttk.Scale - else: - control = ttk.Entry - logger.debug("Setting control '%s' to %s", self.title, control) - return control - - def set_tk_var(self, dtype, selected_value, blank_nones): + def set_tk_var(self, blank_nones): """ Correct variable type for control """ - logger.debug("Setting tk variable: (title: '%s', dtype: %s, selected_value: %s, " - "blank_nones: %s)", - self.title, dtype, selected_value, blank_nones) - if dtype == bool: - var = tk.BooleanVar - elif dtype == int: - var = tk.IntVar - elif dtype == float: - var = tk.DoubleVar - else: - var = tk.StringVar - var = var(self.frame) - val = self.default if selected_value is None else selected_value - val = "" if val is None and blank_nones else val - var.set(val) - logger.debug("Set tk variable: (title: '%s', type: %s, value: '%s')", - self.title, type(var), val) - return var + val = "" if self.option.value is None and blank_nones else self.option.value + self.option.tk_var.set(val) + logger.debug("Set tk variable: (option: '%s', variable: %s, value: '%s')", + self.option.name, self.option.tk_var, val) # Build the full control - def build_control(self, choices, dtype, rounding, min_max, sysbrowser, option_columns, - control_width): + def build_control(self): """ Build the correct control type for the option passed through """ logger.debug("Build config option control") - if self.control not in (ttk.Checkbutton, ttk.Radiobutton): + if self.option.control not in (ttk.Checkbutton, ttk.Radiobutton): self.build_control_label() - self.build_one_control(choices, - dtype, - rounding, - min_max, - sysbrowser, - option_columns, - control_width) + self.build_one_control() logger.debug("Built option control") def build_control_label(self): """ Label for control """ - logger.debug("Build control label: (title: '%s')", self.title) - title = self.title.replace("_", " ").title() - lbl = ttk.Label(self.frame, text=title, width=self.label_width, anchor=tk.W) + logger.debug("Build control label: (option: '%s')", self.option.name) + lbl = ttk.Label(self.frame, text=self.option.title, width=self.label_width, anchor=tk.W) lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) - if self.helptext is not None: - get_tooltip(lbl, text=self.helptext, wraplength=600) - logger.debug("Built control label: '%s'", self.title) + if self.option.helptext is not None: + get_tooltip(lbl, text=self.option.helptext, wraplength=600) + logger.debug("Built control label: (widget: '%s', title: '%s'", + self.option.name, self.option.title) - def build_one_control(self, choices, dtype, rounding, min_max, - sysbrowser, option_columns, control_width): + def build_one_control(self): """ Build and place the option controls """ - logger.debug("Build control: (title: '%s', control: %s, choices: %s, dtype: %s, " - "rounding: %s, sysbrowser: %s, min_max: %s: option_columns: %s, " - "control_width: %s)", self.title, self.control, choices, dtype, rounding, - sysbrowser, min_max, option_columns, control_width) - if self.control == ttk.Scale: - ctl = self.slider_control(dtype, rounding, min_max) - elif self.control == ttk.Radiobutton: - ctl = self.radio_control(choices, option_columns) - elif self.control == ttk.Checkbutton: + logger.debug("Build control: '%s')", self.option.name) + if self.option.control == ttk.Scale: + ctl = self.slider_control() + elif self.option.control == ttk.Radiobutton: + ctl = self.radio_control() + elif self.option.control == ttk.Checkbutton: ctl = self.control_to_checkframe() else: - ctl = self.control_to_optionsframe(choices, sysbrowser) - self.set_control_width(ctl, control_width) - if self.control != ttk.Checkbutton: + ctl = self.control_to_optionsframe() + if self.option.control != ttk.Checkbutton: ctl.pack(padx=5, pady=5, fill=tk.X, expand=True) - if self.helptext is not None and not self.helpset: - get_tooltip(ctl, text=self.helptext, wraplength=600) + if self.option.helptext is not None and not self.helpset: + get_tooltip(ctl, text=self.option.helptext, wraplength=600) - logger.debug("Built control: '%s'", self.title) + logger.debug("Built control: '%s'", self.option.name) - @staticmethod - def set_control_width(ctl, control_width): - """ Set the control width if required """ - if control_width is not None: - ctl.config(width=control_width) - - def radio_control(self, choices, columns): + def radio_control(self): """ Create a group of radio buttons """ - logger.debug("Adding radio group: %s", self.title) - all_help = [line for line in self.helptext.splitlines()] + logger.debug("Adding radio group: %s", self.option.name) + all_help = [line for line in self.option.helptext.splitlines()] if any(line.startswith(" - ") for line in all_help): intro = all_help[0] helpitems = {re.sub(r'[^A-Za-z0-9\-]+', '', @@ -581,15 +683,14 @@ def radio_control(self, choices, columns): for line in all_help if line.startswith(" - ")} ctl = ttk.LabelFrame(self.frame, - text=self.title.replace("_", " ").title(), + text=self.option.title, name="radio_labelframe") - radio_holder = AutoFillContainer(ctl, columns) - for idx, choice in enumerate(choices): - frame_id = idx % columns + radio_holder = AutoFillContainer(ctl, self.option_columns) + for choice in self.option.choices: radio = ttk.Radiobutton(radio_holder.subframe, - text=choice.title(), + text=choice.replace("_", " ").title(), value=choice, - variable=self.tk_var) + variable=self.option.tk_var) if choice.lower() in helpitems: self.helpset = True helptext = helpitems[choice.lower()].capitalize() @@ -598,64 +699,67 @@ def radio_control(self, choices, columns): intro) get_tooltip(radio, text=helptext, wraplength=600) radio.pack(anchor=tk.W) - logger.debug("Adding radio option %s to column %s", choice, frame_id) + logger.debug("Added radio option %s", choice) return radio_holder.parent - def slider_control(self, dtype, rounding, min_max): + def slider_control(self): """ A slider control with corresponding Entry box """ - logger.debug("Add slider control to Options Frame: (title: '%s', dtype: %s, rounding: %s, " - "min_max: %s)", self.title, dtype, rounding, min_max) + logger.debug("Add slider control to Options Frame: (widget: '%s', dtype: %s, " + "rounding: %s, min_max: %s)", self.option.name, self.option.dtype, + self.option.rounding, self.option.min_max) tbox = ttk.Entry(self.frame, width=8, - textvariable=self.tk_var, + textvariable=self.option.tk_var, justify=tk.RIGHT, font=get_config().default_font) tbox.pack(padx=(0, 5), side=tk.RIGHT) cmd = partial(set_slider_rounding, - var=self.tk_var, - d_type=dtype, - round_to=rounding, - min_max=min_max) - ctl = self.control(self.frame, variable=self.tk_var, command=cmd) + var=self.option.tk_var, + d_type=self.option.dtype, + round_to=self.option.rounding, + min_max=self.option.min_max) + ctl = self.option.control(self.frame, variable=self.option.tk_var, command=cmd) add_command(ctl.cget("command"), cmd) - rc_menu = ContextMenu(tbox) + rc_menu = get_contextmenu(tbox) rc_menu.cm_bind() - ctl["from_"] = min_max[0] - ctl["to"] = min_max[1] - logger.debug("Added slider control to Options Frame: %s", self.title) + ctl["from_"] = self.option.min_max[0] + ctl["to"] = self.option.min_max[1] + logger.debug("Added slider control to Options Frame: %s", self.option.name) return ctl - def control_to_optionsframe(self, choices, sysbrowser): + def control_to_optionsframe(self): """ Standard non-check buttons sit in the main options frame """ - logger.debug("Add control to Options Frame: (title: '%s', control: %s, choices: %s)", - self.title, self.control, choices) - if self.control == ttk.Checkbutton: - ctl = self.control(self.frame, variable=self.tk_var, text=None) + logger.debug("Add control to Options Frame: (widget: '%s', control: %s, choices: %s)", + self.option.name, self.option.control, self.option.choices) + if self.option.control == ttk.Checkbutton: + ctl = self.option.control(self.frame, variable=self.option.tk_var, text=None) else: - if sysbrowser is not None: - self.filebrowser = FileBrowser(self.tk_var, self.frame, sysbrowser) - ctl = self.control(self.frame, - textvariable=self.tk_var, - font=get_config().default_font) - rc_menu = ContextMenu(ctl) + if self.option.sysbrowser is not None: + self.filebrowser = FileBrowser(self.option.tk_var, + self.frame, + self.option.sysbrowser) + ctl = self.option.control(self.frame, + textvariable=self.option.tk_var, + font=get_config().default_font) + rc_menu = get_contextmenu(ctl) rc_menu.cm_bind() - if choices: - logger.debug("Adding combo choices: %s", choices) - ctl["values"] = [choice for choice in choices] - logger.debug("Added control to Options Frame: %s", self.title) + if self.option.choices: + logger.debug("Adding combo choices: %s", self.option.choices) + ctl["values"] = [choice for choice in self.option.choices] + logger.debug("Added control to Options Frame: %s", self.option.name) return ctl def control_to_checkframe(self): """ Add checkbuttons to the checkbutton frame """ - logger.debug("Add control checkframe: '%s'", self.title) + logger.debug("Add control checkframe: '%s'", self.option.name) chkframe = self.chkbtns.subframe - ctl = self.control(chkframe, - variable=self.tk_var, - text=self.title.replace("_", " ").title(), - name=self.title.lower()) - get_tooltip(ctl, text=self.helptext, wraplength=600) + ctl = self.option.control(chkframe, + variable=self.option.tk_var, + text=self.option.title, + name=self.option.name) + get_tooltip(ctl, text=self.option.helptext, wraplength=600) ctl.pack(side=tk.TOP, anchor=tk.W) - logger.debug("Added control checkframe: '%s'", self.title) + logger.debug("Added control checkframe: '%s'", self.option.name) return ctl @@ -668,7 +772,7 @@ def __init__(self, tk_var, control_frame, sysbrowser_dict): self.frame = control_frame self.browser = sysbrowser_dict["browser"] self.filetypes = sysbrowser_dict["filetypes"] - self.action_option = sysbrowser_dict.get("action_option", None) + self.action_option = self.format_action_option(sysbrowser_dict.get("action_option", None)) self.command = sysbrowser_dict.get("command", None) self.destination = sysbrowser_dict.get("destination", None) self.add_browser_buttons() @@ -684,6 +788,17 @@ def helptext(self): save="Select a save location...") return retval + @staticmethod + def format_action_option(action_option): + """ Format the action option to remove any dashes at the start """ + if action_option is None: + return action_option + if action_option.startswith("--"): + return action_option[2:] + if action_option.startswith("-"): + return action_option[1:] + return action_option + def add_browser_buttons(self): """ Add correct file browser button for control """ logger.debug("Adding browser buttons: (sysbrowser: %s", self.browser) @@ -703,8 +818,7 @@ def set_context_action_option(self, options): that dictates the context sensitive file browser. """ if self.browser != ["context"]: return - actions = {item["opts"][0]: item["selected"] - for item in options.values()} + actions = {opt.name: opt.tk_var for opt in options} logger.debug("Settiong action option for opt %s", self.action_option) self.action_option = actions[self.action_option] diff --git a/lib/gui/options.py b/lib/gui/options.py index fbd8983b28..124f60932d 100644 --- a/lib/gui/options.py +++ b/lib/gui/options.py @@ -9,6 +9,7 @@ from lib import cli import tools.cli as ToolsCli from .utils import get_images +from .control_helper import ControlPanelOption logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -94,20 +95,21 @@ def process_options(self, command_options, command): logger.trace("Skipping suppressed option: %s", opt) continue title = self.set_control_title(opt["opts"]) - gui_options[title] = { - "type": self.get_data_type(opt), - "default": opt.get("default", None), - "value": opt.get("default", ""), - "choices": opt.get("choices", None), - "gui_radio": opt.get("action", "") == cli.Radio, - "rounding": self.get_rounding(opt), - "min_max": opt.get("min_max", None), - "sysbrowser": self.get_sysbrowser(opt, command), - "group": opt.get("group", None), - "helptext": opt["help"], - "opts": opt["opts"], - "nargs": opt.get("nargs", None)} - logger.trace("Processed: %s", opt) + cpanel_option = ControlPanelOption( + title, + self.get_data_type(opt), + group=opt.get("group", None), + default=opt.get("default", None), + choices=opt.get("choices", None), + is_radio=opt.get("action", "") == cli.Radio, + rounding=self.get_rounding(opt), + min_max=opt.get("min_max", None), + sysbrowser=self.get_sysbrowser(opt, command_options, command), + helptext=opt["help"]) + gui_options[title] = dict(cpanel_option=cpanel_option, + opts=opt["opts"], + nargs=opt.get("nargs", None)) + logger.trace("Processed: %s", gui_options) return gui_options @staticmethod @@ -140,8 +142,7 @@ def get_rounding(opt): retval = None return retval - @staticmethod - def get_sysbrowser(option, command): + def get_sysbrowser(self, option, options, command): """ Return the system file browser and file types if required else None """ action = option.get("action", None) if action not in (cli.FullPaths, @@ -154,7 +155,10 @@ def get_sysbrowser(option, command): return None retval = dict() - action_option = option.get("action_option", None) + action_option = None + if option.get("action_option", None) is not None: + self.expand_action_option(option, options) + action_option = option["action_option"] retval["filetypes"] = option.get("filetypes", "default") if action == cli.FileFullPaths: retval["browser"] = ["load"] @@ -174,19 +178,31 @@ def get_sysbrowser(option, command): logger.debug(retval) return retval + @staticmethod + def expand_action_option(option, options): + """ Expand the action option to the full command name """ + opts = {opt["opts"][0]: opt["opts"][-1] + for opt in options} + old_val = option["action_option"] + new_val = opts[old_val] + logger.debug("Updating action option from '%s' to '%s'", old_val, new_val) + option["action_option"] = new_val + def gen_command_options(self, command): """ Yield each option for specified command """ for key, val in self.opts[command].items(): + if not isinstance(val, dict): + continue yield key, val def options_to_process(self, command=None): - """ Return a consistent object for processing - regardless of whether processing all commands - or just one command for reset and clear """ + """ Return a consistent object for processing regardless of whether processing all commands + or just one command for reset and clear. Removes helptext from return value """ if command is None: - options = [opt for opts in self.opts.values() for opt in opts.values()] + options = [opt for opts in self.opts.values() + for opt in opts.values() if isinstance(opt, dict)] else: - options = [opt for opt in self.opts[command].values()] + options = [opt for opt in self.opts[command].values() if isinstance(opt, dict)] return options def reset(self, command=None): @@ -194,35 +210,36 @@ def reset(self, command=None): back to default value """ logger.debug("Resetting options to default. (command: '%s'", command) for option in self.options_to_process(command): - default = option.get("default", "") - default = "" if default is None else default + cp_opt = option["cpanel_option"] + default = "" if cp_opt.default is None else cp_opt.default if (option.get("nargs", None) and isinstance(default, (list, tuple))): default = ' '.join(str(val) for val in default) - option["selected"].set(default) + cp_opt.set(default) def clear(self, command=None): - """ Clear the options values for all or passed - commands """ + """ Clear the options values for all or passed commands """ logger.debug("Clearing options. (command: '%s'", command) for option in self.options_to_process(command): - if isinstance(option["selected"].get(), bool): - option["selected"].set(False) - elif isinstance(option["selected"].get(), int): - option["selected"].set(0) + cp_opt = option["cpanel_option"] + if isinstance(cp_opt.get(), bool): + cp_opt.set(False) + elif isinstance(cp_opt.get(), (int, float)): + cp_opt.set(0) else: - option["selected"].set("") + cp_opt.set("") def get_option_values(self, command=None): - """ Return all or single command control titles - with the associated tk_var value """ + """ Return all or single command control titles with the associated tk_var value """ ctl_dict = dict() for cmd, opts in self.opts.items(): if command and command != cmd: continue cmd_dict = dict() for key, val in opts.items(): - cmd_dict[key] = val["selected"].get() + if not isinstance(val, dict): + continue + cmd_dict[key] = val["cpanel_option"].get() ctl_dict[cmd] = cmd_dict logger.debug("command: '%s', ctl_dict: '%s'", command, ctl_dict) return ctl_dict @@ -232,14 +249,13 @@ def get_one_option_variable(self, command, title): command and control_title """ for opt_title, option in self.gen_command_options(command): if opt_title == title: - return option["selected"] + return option["cpanel_option"].tk_var return None def gen_cli_arguments(self, command): - """ Return the generated cli arguments for - the selected command """ + """ Return the generated cli arguments for the selected command """ for _, option in self.gen_command_options(command): - optval = str(option.get("selected", "").get()) + optval = str(option["cpanel_option"].get()) opt = option["opts"][0] if command in ("extract", "convert") and opt == "-o": get_images().pathoutput = optval diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 7f77d6e65f..9a2f8f6653 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -1,13 +1,14 @@ #!/usr/bin python3 """ Configure Plugins popup of the Faceswap GUI """ +from collections import OrderedDict from configparser import ConfigParser import logging import tkinter as tk from tkinter import ttk -from .control_helper import ControlPanel +from .control_helper import ControlPanel, ControlPanelOption from .tooltip import Tooltip from .utils import get_config, get_images @@ -41,7 +42,7 @@ def __init__(self, config, root): self.page_frame.pack(fill=tk.BOTH, expand=True) self.plugin_info = dict() - self.config_dict_gui = self.get_config() + self.config_cpanel_dict = self.get_config() self.build() self.update() logger.debug("Initialized %s", self.__class__.__name__) @@ -57,20 +58,30 @@ def set_geometry(self, root): self.geometry("{}x{}+{}+{}".format(width, height, pos_x, pos_y)) def get_config(self): - """ Format config into useful format for GUI and pull default value if a value has not - been supplied """ + """ Format config into a dict of ControlPanelOptions """ logger.debug("Formatting Config for GUI") conf = dict() for section in self.config.config.sections(): self.config.section = section category = section.split(".")[0] options = self.config.defaults[section] - conf.setdefault(category, dict())[section] = options - for key in options.keys(): + section = section.split(".")[-1] + conf.setdefault(category, dict())[section] = OrderedDict() + for key, val in options.items(): if key == "helptext": - self.plugin_info[section] = options[key] + self.plugin_info[section] = val continue - options[key]["value"] = self.config.config_dict.get(key, options[key]["default"]) + conf[category][section][key] = ControlPanelOption( + title=key, + dtype=val["type"], + group=val["group"], + default=val["default"], + initial_value=self.config.config_dict.get(key, val["default"]), + choices=val["choices"], + is_radio=val["gui_radio"], + rounding=val["rounding"], + min_max=val["min_max"], + helptext=val["helptext"]) logger.debug("Formatted Config for GUI: %s", conf) return conf @@ -79,7 +90,7 @@ def build(self): logger.debug("Building plugin config popup") container = ttk.Notebook(self.page_frame) container.pack(fill=tk.BOTH, expand=True) - categories = sorted(list(self.config_dict_gui.keys())) + categories = sorted(list(self.config_cpanel_dict.keys())) if "global" in categories: # Move global to first item categories.insert(0, categories.pop(categories.index("global"))) for category in categories: @@ -93,22 +104,24 @@ def build(self): def build_page(self, container, category): """ Build a plugin config page """ logger.debug("Building plugin config page: '%s'", category) - plugins = sorted(list(key for key in self.config_dict_gui[category].keys())) + plugins = sorted(list(key for key in self.config_cpanel_dict[category].keys())) panel_kwargs = dict(columns=2, option_columns=2, blank_nones=False) if any(plugin != category for plugin in plugins): page = ttk.Notebook(container) page.pack(side=tk.TOP, fill=tk.BOTH, expand=True) for plugin in plugins: + cp_options = [opt for opt in self.config_cpanel_dict[category][plugin].values()] frame = ControlPanel(page, - self.config_dict_gui[category][plugin], + cp_options, header_text=self.plugin_info[plugin], **panel_kwargs) title = plugin[plugin.rfind(".") + 1:] title = title.replace("_", " ").title() page.add(frame, text=title) else: + cp_options = [opt for opt in self.config_cpanel_dict[category][plugins[0]].values()] page = ControlPanel(container, - self.config_dict_gui[category][plugins[0]], + cp_options, header_text=self.plugin_info[plugins[0]], **panel_kwargs) @@ -144,22 +157,20 @@ def reset(self): logger.debug("Resetting config") for section, items in self.config.defaults.items(): logger.debug("Resetting section: '%s'", section) - lookup = [section.split(".")[0], section] if "." in section else [section, section] + lookup = [section.split(".")[0], section.split(".")[-1]] for item, def_opt in items.items(): if item == "helptext": continue default = def_opt["default"] - tk_var = self.config_dict_gui[lookup[0]][lookup[1]][item]["selected"] logger.debug("Resetting: '%s' to '%s'", item, default) - tk_var.set(default) + self.config_cpanel_dict[lookup[0]][lookup[1]][item].set(default) def save_config(self): """ Save the config file """ logger.debug("Saving config") - options = {sect: opts - for value in self.config_dict_gui.values() + options = {".".join((key, sect)) if sect != key else key: opts + for key, value in self.config_cpanel_dict.items() for sect, opts in value.items()} - new_config = ConfigParser(allow_no_value=True) for section, items in self.config.defaults.items(): logger.debug("Adding section: '%s')", section) @@ -167,13 +178,13 @@ def save_config(self): for item, def_opt in items.items(): if item == "helptext": continue - new_opt = options[section][item] - logger.debug("Adding option: (item: '%s', default: '%s' new: '%s'", + new_opt = options[section][item].get() + logger.debug("Adding option: (item: '%s', default: %s new: '%s'", item, def_opt, new_opt) helptext = def_opt["helptext"] helptext = self.config.format_help(helptext, is_section=False) new_config.set(section, helptext) - new_config.set(section, item, str(new_opt["selected"].get())) + new_config.set(section, item, str(new_opt)) self.config.config = new_config self.config.save_config() print("Saved config: '{}'".format(self.config.configfile)) diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 776ce0fe31..834a40b7a3 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -538,7 +538,7 @@ def set_console_clear_var_trace(self): def build_console(self): """ Build and place the console """ logger.debug("Build console") - self.console.config(width=100, height=6, bg="gray90", fg="black") + self.console.config(width=100, height=6, bg="gray90", fg="black", state="disabled") self.console.pack(side=tk.LEFT, anchor=tk.N, fill=tk.BOTH, expand=True) scrollbar = ttk.Scrollbar(self, command=self.console.yview) @@ -575,7 +575,9 @@ def clear(self, *args): # pylint: disable=unused-argument if not self.console_clear.get(): logger.debug("Console not set for clearing. Skipping") return + self.console.configure(state="normal") self.console.delete(1.0, tk.END) + self.console.configure(state="disabled") self.console_clear.set(False) logger.debug("Cleared console") @@ -605,8 +607,10 @@ def get_tag(self, string): def write(self, string): """ Capture stdout/stderr """ + self.console.configure(state="normal") self.console.insert(tk.END, string, self.get_tag(string)) self.console.see(tk.END) + self.console.configure(state="disabled") @staticmethod def flush(): From 1c3b9d968f3e42851adc684282a11bd71912e1f3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 29 Aug 2019 13:27:06 +0100 Subject: [PATCH 022/981] Bugfix: Clean font list selection for config --- lib/gui/_config.py | 12 ++++++++++-- lib/gui/control_helper.py | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/gui/_config.py b/lib/gui/_config.py index 47552c6e6a..cf6a8646b1 100644 --- a/lib/gui/_config.py +++ b/lib/gui/_config.py @@ -45,8 +45,8 @@ def set_globals(self): info="How tall the bottom console panel is as a percentage of GUI height at startup.") self.add_item( section=section, title="font", datatype=str, - choices=["default"] + sorted(font.families()), default="default", group="font", - info="Global font") + choices=get_clean_fonts(), + default="default", group="font", info="Global font") self.add_item( section=section, title="font_size", datatype=int, default=9, min_max=(6, 12), rounding=1, group="font", @@ -67,3 +67,11 @@ def get_commands(): and os.path.splitext(item)[0] not in ("gui", "cli") and not os.path.splitext(item)[0].startswith("_")] return commands + tools + + +def get_clean_fonts(): + """ Return the font list with any @prefixed or non-unicode characters stripped + and default prefixed """ + cleaned_fonts = sorted([fnt for fnt in font.families() + if not fnt.startswith("@") and not any([ord(c) > 127 for c in fnt])]) + return ["default"] + cleaned_fonts diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index fa6abd5670..1239651a1a 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -41,7 +41,7 @@ def add_command(name, func): """ For controls that execute commands, the command must be added to the _COMMAND list so that it can be added back to the widget during cloning """ logger.debug("Adding to commands: %s - %s", name, func) - _RECREATE_OBJECTS["commands"][name] = func + _RECREATE_OBJECTS["commands"][str(name)] = func def set_slider_rounding(value, var, d_type, round_to, min_max): From 79d7493c353d64966bcd5b4ecb756910c4bc2208 Mon Sep 17 00:00:00 2001 From: kilroythethird Date: Fri, 23 Aug 2019 18:28:07 +0200 Subject: [PATCH 023/981] Added simple travis tests --- .gitignore | 1 + .travis.yml | 91 +++++++++++++++++++++++ README.md | 2 + lib/cli.py | 8 +- lib/utils.py | 3 +- simple_tests.py | 190 ++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 .travis.yml create mode 100644 simple_tests.py diff --git a/.gitignore b/.gitignore index 852240133b..e2d5ca9254 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ !plugins/convert/* !tools !tools/lib* +!.travis.yml *.ini *.pyc __pycache__/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..d9989b25f3 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,91 @@ +# Adapted from https://github.com/kangwonlee/travis-yml-conda-posix-nt/blob/master/.travis.yml + +language: shell + +env: + global: + - CONDA_PYTHON=3.6 + - CONDA_BLD_PATH=${HOME}/conda-bld + +os: + - linux + # - windows + # - osx + + +cache: + # More time is needed for caching due to the sheer size of the conda env. + timeout: 1000 + directories: + - ${HOME}/cache + +before_cache: + # adapted from https://github.com/theochem/cgrid/blob/master/.travis.yml + - rm -rf ${MINICONDA_PATH}/conda-bld + - rm -rf ${MINICONDA_PATH}/locks + - rm -rf ${MINICONDA_PATH}/pkgs + - rm -rf ${MINICONDA_PATH}/var + - rm -rf ${MINICONDA_PATH}/envs/*/conda-bld + - rm -rf ${MINICONDA_PATH}/envs/*/locks + - rm -rf ${MINICONDA_PATH}/envs/*/pkgs + - rm -rf ${MINICONDA_PATH}/envs/*/var + # Clean out test results + - rm -rf ${HOME}/cache/tests/*/faces + - rm -rf ${HOME}/cache/tests/*/conv + - rm -rf ${HOME}/cache/tests/*/*.json + - rm -rf ${HOME}/cache/tests/vid/faces_sorted + - rm -rf ${HOME}/cache/tests/vid/model + +before_install: + # set conda path info + - | + if [[ "$TRAVIS_OS_NAME" != "windows" ]]; then + MINICONDA_PATH=${HOME}/cache/miniconda; + MINICONDA_SUB_PATH=$MINICONDA_PATH/bin; + elif [[ "$TRAVIS_OS_NAME" == "windows" ]]; then + MINICONDA_PATH=${HOME}/cache/miniconda3/; + MINICONDA_PATH_WIN=`cygpath --windows $MINICONDA_PATH`; + MINICONDA_SUB_PATH=$MINICONDA_PATH/Scripts; + fi; + # obtain miniconda installer + - | + if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then + wget https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh; + # Not used at the moment, but in case we want to also run test on osx, we can. + elif [[ "$TRAVIS_OS_NAME" == "osx" ]]; then + wget https://repo.continuum.io/miniconda/Miniconda3-latest-MacOSX-x86_64.sh -O miniconda.sh; + fi; + +install: + # install miniconda + # pip and conda will also need OpenSSL for Windows + - | + if test -e "$MINICONDA_PATH"; then + echo "Conda already installed"; + else + echo "Installing conda"; + if [[ "$TRAVIS_OS_NAME" != "windows" ]]; then + bash miniconda.sh -b -p $MINICONDA_PATH; + elif [[ "$TRAVIS_OS_NAME" == "windows" ]]; then + choco install openssl.light; + choco install miniconda3 --params="'/AddToPath:1 /D:$MINICONDA_PATH_WIN'"; + fi; + fi; + - export PATH="$MINICONDA_PATH:$MINICONDA_SUB_PATH:$PATH"; + # for conda version 4.4 or later + - source $MINICONDA_PATH/etc/profile.d/conda.sh; + - hash -r; + - conda config --set always_yes yes --set changeps1 no; + - conda update -q conda; + # Useful for debugging any issues with conda + - conda info -a + - echo "Python $CONDA_PYTHON running on $TRAVIS_OS_NAME"; + # Only create the environment if we don't have it already + - conda env list | grep faceswap || conda create -q --name faceswap python=$CONDA_PYTHON; + - conda activate faceswap; + - conda --version ; python --version ; pip --version; + - python setup.py --installer; + +script: + - python simple_tests.py; + diff --git a/README.md b/README.md index cfb23e48ff..2ef4c1a2ad 100755 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@
Jennifer Lawrence/Steve Buscemi FaceSwap using the Villain model

+[![Build Status](https://travis-ci.org/deepfakes/faceswap.svg?branch=master)](https://travis-ci.org/deepfakes/faceswap) + Make sure you check out [INSTALL.md](INSTALL.md) before getting started. - [deepfakes_faceswap](#deepfakesfaceswap) diff --git a/lib/cli.py b/lib/cli.py index 4a8686db8d..a1d7600e4d 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -114,15 +114,17 @@ def execute_script(self, arguments): is_gui = hasattr(arguments, "redirect_gui") and arguments.redirect_gui log_setup(arguments.loglevel, arguments.logfile, self.command, is_gui) logger.debug("Executing: %s. PID: %s", self.command, os.getpid()) + success = False if get_backend() == "amd": plaidml_found = self.setup_amd(arguments.loglevel) if not plaidml_found: - safe_shutdown() - exit(1) + safe_shutdown(got_error=True) + return try: script = self.import_script() process = script(arguments) process.process() + success = True except FaceswapError as err: for line in str(err).splitlines(): logger.error(line) @@ -141,7 +143,7 @@ def execute_script(self, arguments): "before reporting", crash_file) finally: - safe_shutdown() + safe_shutdown(got_error=not success) @staticmethod def setup_amd(loglevel): diff --git a/lib/utils.py b/lib/utils.py index ad87f0e4a3..49143af267 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -445,7 +445,7 @@ def camel_case_split(identifier): return [m.group(0) for m in matches] -def safe_shutdown(): +def safe_shutdown(got_error=False): """ Close queues, threads and processes in event of crash """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name logger.debug("Safely shutting down") @@ -458,6 +458,7 @@ def safe_shutdown(): while not queue_manager._log_queue.empty(): # pylint:disable=protected-access continue queue_manager.manager.shutdown() + exit(1 if got_error else 0) class FaceswapError(Exception): diff --git a/simple_tests.py b/simple_tests.py new file mode 100644 index 0000000000..9f7b2e0183 --- /dev/null +++ b/simple_tests.py @@ -0,0 +1,190 @@ +""" +Contains some simple tests. +The purpose of this tests is to detect crashes and hangs +but NOT to guarantee the corectness of the operations. +For this we want another set of testcases using pytest. + +Due to my lazy coding, DON'T USE PATHES WITH BLANKS ! +""" + +import sys +from subprocess import check_call, CalledProcessError +import urllib +from urllib.request import urlretrieve +import os +from os.path import join as pathjoin, expanduser + +fail_count = 0 +test_count = 0 +_COLORS = { + "FAIL": "\033[1;31m", + "OK": "\033[1;32m", + "STATUS": "\033[1;37m", + "BOLD": "\033[1m", + "ENDC": "\033[0m" +} + + +def print_colored(text, color="OK", bold=False): + # This might not work on windows, + # altho travis runs windows stuff in git bash, so it might ? + color = _COLORS.get(color, color) + print("%s%s%s%s" % ( + color, "" if not bold else _COLORS["BOLD"], text, _COLORS["ENDC"] + )) + + +def print_ok(text): + print_colored(text, "OK", True) + + +def print_fail(text): + print_colored(text, "FAIL", True) + + +def print_status(text): + print_colored(text, "STATUS", True) + + +def run_test(name, cmd): + global fail_count, test_count + print_status("[?] running %s" % name) + print("Cmd: %s" % " ".join(cmd)) + test_count += 1 + try: + check_call(cmd) + print_ok("[+] Test success") + return True + except CalledProcessError as e: + print_fail("[-] Test failed with %s" % e) + fail_count += 1 + return False + + +def download_file(url, filename): # TODO: retry + if os.path.isfile(filename): + print_status("[?] '%s' already cached as '%s'" % (url, filename)) + return filename + try: + print_status("[?] Downloading '%s' to '%s'" % (url, filename)) + video, _ = urlretrieve(url, filename) + return video + except urllib.error.URLError as e: + print_fail("[-] Failed downloading: %s" % e) + return None + + +def extract_args(detector, aligner, in_path, out_path, args=None): + py_exe = sys.executable + _extract_args = "%s faceswap.py extract -i %s -o %s -D %s -A %s" % ( + py_exe, in_path, out_path, detector, aligner + ) + if args: + _extract_args += " %s" % args + return _extract_args.split() + + +def train_args(model, model_path, faces, alignments, iterations=5, bs=8): + py_exe = sys.executable + args = "%s faceswap.py train -A %s -ala %s -B %s -alb %s -m %s -t %s -bs %i -it %s" % ( + py_exe, faces, alignments, faces, alignments, model_path, model, bs, iterations + ) + return args.split() + + +def convert_args(in_path, out_path, model_path, writer, args=None): + py_exe = sys.executable + conv_args = "%s faceswap.py convert -i %s -o %s -m %s -w %s" % ( + py_exe, in_path, out_path, model_path, writer + ) + if args: + conv_args += " %s" % args + return conv_args.split() # Don't use pathes with spaces ;) + + +def sort_args(in_path, out_path, sortby="face", groupby="hist", method="rename"): + py_exe = sys.executable + _sort_args = "%s tools.py sort -i %s -o %s -s %s -fp %s -g %s -k" % ( + py_exe, in_path, out_path, sortby, method, groupby + ) + return _sort_args.split() + + +if __name__ == '__main__': + vid_src = "https://faceswap.dev/data/test.mp4" + img_src = "https://archive.org/download/GPN-2003-00070/GPN-2003-00070.jpg" + base_dir = pathjoin(expanduser("~"), "cache", "tests") + + vid_base = pathjoin(base_dir, "vid") + img_base = pathjoin(base_dir, "imgs") + os.makedirs(vid_base, exist_ok=True) + os.makedirs(img_base, exist_ok=True) + py_exe = sys.executable + + vid_path = download_file(vid_src, pathjoin(vid_base, "test.mp4")) + if not vid_path: + print_fail("[-] Aborting") + exit(1) + vid_extract = run_test( + "Extraction video with cv2-dnn detector and cv2-dnn aligner.", + extract_args("Cv2-Dnn", "Cv2-Dnn", vid_path, pathjoin(vid_base, "faces")) + ) + + img_path = download_file(img_src, pathjoin(img_base, "test_img.jpg")) + if not img_path: + print_fail("[-] Aborting") + exit(1) + img_extract = run_test( + "Extraction images with cv2-dnn detector and cv2-dnn aligner.", + extract_args("Cv2-Dnn", "Cv2-Dnn", img_base, pathjoin(img_base, "faces")) + ) + + if vid_extract: + run_test( + "Sort faces.", + sort_args( + pathjoin(vid_base, "faces"), pathjoin(vid_base, "faces_sorted"), + sortby="face", method="rename" + ) + ) + + run_test( + "Rename sorted faces.", + ( + py_exe, "tools.py", "alignments", "-j", "rename", + "-a", pathjoin(vid_base, "test_alignments.json"), + "-fc", pathjoin(vid_base, "faces_sorted"), + ) + ) + + trained = run_test( + "Train lightweight model for 5 iterations.", + train_args( + "lightweight", pathjoin(vid_base, "model"), + pathjoin(vid_base, "faces"), pathjoin(vid_base, "test_alignments.json") + ) + ) + + if trained: + run_test( + "Convert video.", + convert_args( + vid_path, pathjoin(vid_base, "conv"), + pathjoin(vid_base, "model"), "ffmpeg" + ) + ) + + run_test( + "Convert images.", + convert_args( + img_base, pathjoin(img_base, "conv"), + pathjoin(vid_base, "model"), "opencv" + ) + ) + + if fail_count == 0: + print_ok("[+] Failed %i/%i tests." % (fail_count, test_count)) + exit(0) + else: + print_fail("[-] Failed %i/%i tests." % (fail_count, test_count)) + exit(1) From 967f97f4a5b5e24b4d00d1081fd36ac8096c5a5b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 29 Aug 2019 18:31:34 +0000 Subject: [PATCH 024/981] Minor extract fixups --- plugins/extract/detect/_base.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index 08ec612000..56f193be28 100755 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -21,7 +21,7 @@ import cv2 from lib.gpu_stats import GPUStats -from lib.utils import deprecation_warning, rotate_landmarks, GetModel +from lib.utils import rotate_landmarks, GetModel from plugins.extract._config import Config logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -34,7 +34,7 @@ def get_config(plugin_name, configfile=None): class Detector(): """ Detector object """ - def __init__(self, loglevel, configfile=None, + def __init__(self, loglevel, configfile=None, # pylint:disable=too-many-arguments git_model_id=None, model_filename=None, rotation=None, min_size=0): logger.debug("Initializing %s: (loglevel: %s, configfile: %s, git_model_id: %s, " "model_filename: %s, rotation: %s, min_size: %s)", @@ -169,7 +169,7 @@ def filter_small_faces(self, detected_faces): return retval # <<< DETECTION IMAGE COMPILATION METHODS >>> # - def compile_detection_image(self, input_image, + def compile_detection_image(self, input_image, # pylint:disable=too-many-arguments is_square=False, scale_up=False, to_rgb=False, to_grayscale=False, pad_to=None): """ Compile the detection image """ @@ -218,8 +218,8 @@ def scale_image(image, scale, pad_to=None): if scale != 1.0: dims = (int(width * scale), int(height * scale)) if scale < 1.0: - logger.verbose("Resizing image from %sx%s to %s. Scale=%s", - width, height, "x".join(str(i) for i in dims), scale) + logger.debug("Resizing image from %sx%s to %s. Scale=%s", + width, height, "x".join(str(i) for i in dims), scale) image = cv2.resize(image, dims, interpolation=interpln) if pad_to: image = Detector.pad_image(image, pad_to) @@ -227,15 +227,16 @@ def scale_image(image, scale, pad_to=None): @staticmethod def pad_image(image, target): + """ Pad an image to a square """ height, width = image.shape[:2] if width < target[0] or height < target[1]: pad_l = (target[0] - width) // 2 pad_r = (target[0] - width) - pad_l pad_t = (target[1] - height) // 2 pad_b = (target[1] - height) - pad_t - img = cv2.copyMakeBorder( + img = cv2.copyMakeBorder( # pylint:disable=no-member image, pad_t, pad_b, pad_l, pad_r, - cv2.BORDER_CONSTANT, (0, 0, 0) + cv2.BORDER_CONSTANT, (0, 0, 0) # pylint:disable=no-member ) return img return image From 0d8354abff4b3e9f60b55146decc6a0f889c75f4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 30 Aug 2019 13:04:47 +0100 Subject: [PATCH 025/981] Add configs to sysinfo --- lib/sysinfo.py | 63 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/lib/sysinfo.py b/lib/sysinfo.py index 30fe02ef98..54c35c87f4 100644 --- a/lib/sysinfo.py +++ b/lib/sysinfo.py @@ -1,6 +1,7 @@ #!/usr/bin python3 """ Obtain information about the running system, environment and gpu """ +import json import locale import os import platform @@ -19,7 +20,7 @@ class SysInfo(): def __init__(self): gpu_stats = GPUStats(log=False) - + self.configs = Configs().configs self.platform = platform.platform() self.system = platform.system() self.machine = platform.machine() @@ -346,6 +347,8 @@ def full_info(self): return retval retval += "\n\n============== Conda Packages ==============\n" retval += self.installed_conda + retval += "\n\n================= Configs ==================" + retval += self.configs return retval def format_ram(self): @@ -367,4 +370,62 @@ def get_sysinfo(): return retval +class Configs(): + """ Parses the config files in /config and outputs the information """ + + def __init__(self): + self.config_dir = os.path.join(os.path.abspath(os.path.dirname(sys.argv[0])), "config") + self.configs = self.get_configs() + + def get_configs(self): + """ Return the configs from the config dir """ + config_files = [os.path.join(self.config_dir, cfile) + for cfile in os.listdir(self.config_dir) + if os.path.basename(cfile) == ".faceswap" + or os.path.splitext(cfile)[1] == ".ini"] + return self.parse_configs(config_files) + + def parse_configs(self, config_files): + """ Parse the config files into the output format """ + formatted = "" + for cfile in config_files: + fname = os.path.basename(cfile) + ext = os.path.splitext(cfile)[1] + formatted += "\n--------- {} ---------\n".format(fname) + if ext == ".ini": + formatted += self.parse_ini(cfile) + elif fname == ".faceswap": + formatted += self.parse_json(cfile) + return formatted + + def parse_ini(self, config_file): + """ Parse an INI file converting it to a dict """ + formatted = "" + with open(config_file, "r") as cfile: + for line in cfile.readlines(): + line = line.strip() + if line.startswith("#") or not line: + continue + item = line.split("=") + if len(item) == 1: + formatted += "\n{}\n".format(item[0].strip()) + else: + formatted += self.format_text(item[0], item[1]) + return formatted + + def parse_json(self, config_file): + """ Parse a Json File converting it to a dict """ + formatted = "" + with open(config_file, "r") as cfile: + conf_dict = json.load(cfile) + for key in sorted(conf_dict.keys()): + formatted += self.format_text(key, conf_dict[key]) + return formatted + + @staticmethod + def format_text(key, val): + """Format the text for output """ + return "{0: <25} {1}\n".format(key.strip() + ":", val.strip()) + + sysinfo = get_sysinfo() # pylint: disable=invalid-name From e6f17cdf7be2576638d2ca9c6e8416b36101260a Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 31 Aug 2019 03:04:10 -0500 Subject: [PATCH 026/981] Delete align_eyes.py --- lib/align_eyes.py | 71 ----------------------------------------------- 1 file changed, 71 deletions(-) delete mode 100644 lib/align_eyes.py diff --git a/lib/align_eyes.py b/lib/align_eyes.py deleted file mode 100644 index dc8a1ef2d6..0000000000 --- a/lib/align_eyes.py +++ /dev/null @@ -1,71 +0,0 @@ -# Code borrowed from https://github.com/jrosebr1/imutils/blob/d5cb29d02cf178c399210d5a139a821dfb0ae136/imutils/face_utils/helpers.py -""" -The MIT License (MIT) - -Copyright (c) 2015-2016 Adrian Rosebrock, http://www.pyimagesearch.com - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -""" - -from collections import OrderedDict -import numpy as np -import cv2 - -# define a dictionary that maps the indexes of the facial -# landmarks to specific face regions -FACIAL_LANDMARKS_IDXS = OrderedDict([ - ("mouth", (48, 68)), - ("right_eyebrow", (17, 22)), - ("left_eyebrow", (22, 27)), - ("right_eye", (36, 42)), - ("left_eye", (42, 48)), - ("nose", (27, 36)), - ("jaw", (0, 17)), - ("chin", (8, 11)) -]) - -# Returns a rotation matrix that when applied to the 68 input facial landmarks -# results in landmarks with eyes aligned horizontally -def align_eyes(landmarks, size): - desiredLeftEye = (0.35, 0.35) # (y, x) value - desiredFaceWidth = desiredFaceHeight = size - - # extract the left and right eye (x, y)-coordinates - (lStart, lEnd) = FACIAL_LANDMARKS_IDXS["left_eye"] - (rStart, rEnd) = FACIAL_LANDMARKS_IDXS["right_eye"] - leftEyePts = landmarks[lStart:lEnd] - rightEyePts = landmarks[rStart:rEnd] - - # compute the center of mass for each eye - leftEyeCenter = leftEyePts.mean(axis=0).astype("int") - rightEyeCenter = rightEyePts.mean(axis=0).astype("int") - - # compute the angle between the eye centroids - dY = rightEyeCenter[0,1] - leftEyeCenter[0,1] - dX = rightEyeCenter[0,0] - leftEyeCenter[0,0] - angle = np.degrees(np.arctan2(dY, dX)) - 180 - - # compute center (x, y)-coordinates (i.e., the median point) - # between the two eyes in the input image - eyesCenter = ((leftEyeCenter[0,0] + rightEyeCenter[0,0]) // 2, (leftEyeCenter[0,1] + rightEyeCenter[0,1]) // 2) - - # grab the rotation matrix for rotating and scaling the face - M = cv2.getRotationMatrix2D(eyesCenter, angle, 1.0) - - return M From 1a18241c21b43d5a76c94e467c950dce393bab83 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 31 Aug 2019 03:04:56 -0500 Subject: [PATCH 027/981] Revert "Delete align_eyes.py" This reverts commit e6f17cdf7be2576638d2ca9c6e8416b36101260a. --- lib/align_eyes.py | 71 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 lib/align_eyes.py diff --git a/lib/align_eyes.py b/lib/align_eyes.py new file mode 100644 index 0000000000..dc8a1ef2d6 --- /dev/null +++ b/lib/align_eyes.py @@ -0,0 +1,71 @@ +# Code borrowed from https://github.com/jrosebr1/imutils/blob/d5cb29d02cf178c399210d5a139a821dfb0ae136/imutils/face_utils/helpers.py +""" +The MIT License (MIT) + +Copyright (c) 2015-2016 Adrian Rosebrock, http://www.pyimagesearch.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +from collections import OrderedDict +import numpy as np +import cv2 + +# define a dictionary that maps the indexes of the facial +# landmarks to specific face regions +FACIAL_LANDMARKS_IDXS = OrderedDict([ + ("mouth", (48, 68)), + ("right_eyebrow", (17, 22)), + ("left_eyebrow", (22, 27)), + ("right_eye", (36, 42)), + ("left_eye", (42, 48)), + ("nose", (27, 36)), + ("jaw", (0, 17)), + ("chin", (8, 11)) +]) + +# Returns a rotation matrix that when applied to the 68 input facial landmarks +# results in landmarks with eyes aligned horizontally +def align_eyes(landmarks, size): + desiredLeftEye = (0.35, 0.35) # (y, x) value + desiredFaceWidth = desiredFaceHeight = size + + # extract the left and right eye (x, y)-coordinates + (lStart, lEnd) = FACIAL_LANDMARKS_IDXS["left_eye"] + (rStart, rEnd) = FACIAL_LANDMARKS_IDXS["right_eye"] + leftEyePts = landmarks[lStart:lEnd] + rightEyePts = landmarks[rStart:rEnd] + + # compute the center of mass for each eye + leftEyeCenter = leftEyePts.mean(axis=0).astype("int") + rightEyeCenter = rightEyePts.mean(axis=0).astype("int") + + # compute the angle between the eye centroids + dY = rightEyeCenter[0,1] - leftEyeCenter[0,1] + dX = rightEyeCenter[0,0] - leftEyeCenter[0,0] + angle = np.degrees(np.arctan2(dY, dX)) - 180 + + # compute center (x, y)-coordinates (i.e., the median point) + # between the two eyes in the input image + eyesCenter = ((leftEyeCenter[0,0] + rightEyeCenter[0,0]) // 2, (leftEyeCenter[0,1] + rightEyeCenter[0,1]) // 2) + + # grab the rotation matrix for rotating and scaling the face + M = cv2.getRotationMatrix2D(eyesCenter, angle, 1.0) + + return M From 5bf54d949d7397a2a3ff3e70e4cfe7895b46c726 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 31 Aug 2019 11:00:39 +0100 Subject: [PATCH 028/981] Add configs and state file to crash report --- lib/sysinfo.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/lib/sysinfo.py b/lib/sysinfo.py index 54c35c87f4..a874d26c76 100644 --- a/lib/sysinfo.py +++ b/lib/sysinfo.py @@ -20,6 +20,7 @@ class SysInfo(): def __init__(self): gpu_stats = GPUStats(log=False) + self.state_file = State().state_file self.configs = Configs().configs self.platform = platform.platform() self.system = platform.system() @@ -347,6 +348,7 @@ def full_info(self): return retval retval += "\n\n============== Conda Packages ==============\n" retval += self.installed_conda + retval += self.state_file retval += "\n\n================= Configs ==================" retval += self.configs return retval @@ -428,4 +430,39 @@ def format_text(key, val): return "{0: <25} {1}\n".format(key.strip() + ":", val.strip()) +class State(): + """ State file for training command """ + def __init__(self): + self.model_dir = self.get_arg("-m", "--model-dir") + self.trainer = self.get_arg("-t", "--trainer") + self.state_file = self.get_state_file() + + @property + def is_training(self): + """ Return whether this has been called during training """ + return len(sys.argv) > 1 and sys.argv[1].lower() == "train" + + @staticmethod + def get_arg(*args): + """ Return the value for a given option from sys.argv. Returns None if not found """ + cmd = sys.argv + for opt in args: + if opt in cmd: + return cmd[cmd.index(opt) + 1] + return None + + def get_state_file(self): + """ Return the state file in a string """ + if not self.is_training or self.model_dir is None or self.trainer is None: + return "" + fname = os.path.join(self.model_dir, "{}_state.json".format(self.trainer)) + if not os.path.isfile(fname): + return "" + + retval = "\n\n=============== State File =================\n" + with open(fname, "r") as sfile: + retval += sfile.read() + return retval + + sysinfo = get_sysinfo() # pylint: disable=invalid-name From 5558d039f81b4fe7bdb00be7510293de38f1f8c1 Mon Sep 17 00:00:00 2001 From: kilroythethird Date: Sat, 31 Aug 2019 15:41:37 +0200 Subject: [PATCH 029/981] added df -h to travis script --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index d9989b25f3..1332036aa3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -85,6 +85,8 @@ install: - conda activate faceswap; - conda --version ; python --version ; pip --version; - python setup.py --installer; + # For debugging purposes + - df -h script: - python simple_tests.py; From feedd2aa11d4be016534d6dbaac1b2451c4c84c9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 31 Aug 2019 17:00:53 +0100 Subject: [PATCH 030/981] More robust Crash Report messaging --- lib/cli.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/cli.py b/lib/cli.py index 4a8686db8d..707152bee1 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -137,8 +137,9 @@ def execute_script(self, arguments): crash_file = crash_log() logger.exception("Got Exception on main handler:") logger.critical("An unexpected crash has occurred. Crash report written to '%s'. " - "Please verify you are running the latest version of faceswap " - "before reporting", crash_file) + "You MUST provide this file if seeking assistance. Please verify you " + "are running the latest version of faceswap before reporting", + crash_file) finally: safe_shutdown() From 10c5c7e8e32d791b1ba1f0f19e3109f3114fa327 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 1 Sep 2019 14:30:31 +0100 Subject: [PATCH 031/981] Double number of log lines in crash report --- lib/logger.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/logger.py b/lib/logger.py index ed8f41262d..6dce4660b7 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -136,7 +136,7 @@ def stream_handler(loglevel, is_gui): def crash_handler(log_format): - """ Add a handler that sores the last 50 debug lines to 'debug_buffer' + """ Add a handler that sores the last 100 debug lines to 'debug_buffer' for use in crash reports """ log_crash = logging.StreamHandler(debug_buffer) log_crash.setFormatter(log_format) @@ -186,5 +186,5 @@ def faceswap_logrecord(*args, **kwargs): # Set logger class to custom logger logging.setLoggerClass(MultiProcessingLogger) -# Stores the last 50 debug messages -debug_buffer = RollingBuffer(maxlen=50) # pylint: disable=invalid-name +# Stores the last 100 debug messages +debug_buffer = RollingBuffer(maxlen=100) # pylint: disable=invalid-name From 8c2124849e93bae36ca95ae7e39b2d3e48eb7db0 Mon Sep 17 00:00:00 2001 From: Donghyeok Tak Date: Tue, 3 Sep 2019 06:52:04 +0900 Subject: [PATCH 032/981] Fix misspellings in help messages of arguments (#856) --- lib/cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/cli.py b/lib/cli.py index 766503bca5..c6b839043a 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -785,14 +785,14 @@ def get_optional_arguments(): "configurable settings in '/config/convert.ini' or 'Edit > Configure " "Convert Plugins':" "\nL|avg-color: Adjust the mean of each color channel in the swapped " - "reconstruction to equal the mean of the masked area in the orginal image." + "reconstruction to equal the mean of the masked area in the original image." "\nL|color-transfer: Transfers the color distribution from the source to the " "target image using the mean and standard deviations of the L*a*b* " "color space." "\nL|manual-balance: Manually adjust the balance of the image in a variety of " "color spaces. Best used with the Preview tool to set correct values." "\nL|match-hist: Adjust the histogram of each color channel in the swapped " - "reconstruction to equal the histogram of the masked area in the orginal " + "reconstruction to equal the histogram of the masked area in the original " "image." "\nL|seamless-clone: Use cv2's seamless clone function to remove extreme " "gradients at the mask seam by smoothing colors. Generally does not give " @@ -933,7 +933,7 @@ def get_optional_arguments(): "help": "The maximum number of parallel processes for performing " "conversion. Converting images is system RAM heavy so it is " "possible to run out of memory if you have a lot of " - "processes and not enough RAM to accomodate them all. " + "processes and not enough RAM to accommodate them all. " "Setting this to 0 will use the maximum available. No " "matter what you set this to, it will never attempt to use " "more processes than are available on your system. If " From f0833012a175b169752248caa5938265b1e69ab1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 2 Sep 2019 22:55:36 +0100 Subject: [PATCH 033/981] De-Multiprocess Convert (#857) * Convert: Swap MP pool for Thread pool --- plugins/convert/writer/opencv.py | 1 + plugins/convert/writer/pillow.py | 1 + scripts/convert.py | 73 ++++++++++++++------------------ 3 files changed, 34 insertions(+), 41 deletions(-) diff --git a/plugins/convert/writer/opencv.py b/plugins/convert/writer/opencv.py index cabd0ab652..17cafdc591 100644 --- a/plugins/convert/writer/opencv.py +++ b/plugins/convert/writer/opencv.py @@ -47,6 +47,7 @@ def write(self, filename, image): logger.error("Failed to save image '%s'. Original Error: %s", filename, err) def pre_encode(self, image): + """ Pre_encode the image in lib/convert.py threads as it is a LOT quicker """ logger.trace("Pre-encoding image") image = cv2.imencode(self.extension, image, self.args)[1] # pylint: disable=no-member return image diff --git a/plugins/convert/writer/pillow.py b/plugins/convert/writer/pillow.py index e207183c22..ef440f2b6c 100644 --- a/plugins/convert/writer/pillow.py +++ b/plugins/convert/writer/pillow.py @@ -50,6 +50,7 @@ def write(self, filename, image): logger.error("Failed to save image '%s'. Original Error: %s", filename, err) def pre_encode(self, image): + """ Pre_encode the image in lib/convert.py threads as it is a LOT quicker """ logger.trace("Pre-encoding image") fmt = self.format_dict.get(self.config["format"], None) fmt = self.config["format"].upper() if fmt is None else fmt diff --git a/scripts/convert.py b/scripts/convert.py index bd3a1632cf..cf51e73902 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -6,6 +6,7 @@ import os import sys from threading import Event +from time import sleep from cv2 import imwrite # pylint:disable=no-name-in-module import numpy as np @@ -16,8 +17,8 @@ from lib.convert import Converter from lib.faces_detect import DetectedFace from lib.gpu_stats import GPUStats -from lib.multithreading import MultiThread, PoolProcess, total_cpus -from lib.queue_manager import queue_manager, QueueEmpty +from lib.multithreading import MultiThread, total_cpus +from lib.queue_manager import queue_manager from lib.utils import FaceswapError, get_folder, get_image_paths, hash_image_file from plugins.extract.pipeline import Extractor from plugins.plugin_loader import PluginLoader @@ -32,6 +33,7 @@ def __init__(self, arguments): self.args = arguments Utils.set_verbosity(self.args.loglevel) + self.patch_threads = None self.images = Images(self.args) self.validate() self.alignments = Alignments(self.args, False, self.images.is_video) @@ -83,9 +85,8 @@ def validate(self): if (self.args.writer == "ffmpeg" and not self.images.is_video and self.args.reference_video is None): - logger.error("Output as video selected, but using frames as input. You must provide a " - "reference video ('-ref', '--reference-video').") - exit(1) + raise FaceswapError("Output as video selected, but using frames as input. You must " + "provide a reference video ('-ref', '--reference-video').") output_dir = get_folder(self.args.output_dir) logger.info("Output Directory: %s", output_dir) @@ -93,7 +94,7 @@ def add_queues(self): """ Add the queues for convert """ logger.debug("Adding queues. Queue size: %s", self.queue_size) for qname in ("convert_in", "convert_out", "patch"): - queue_manager.add_queue(qname, self.queue_size) + queue_manager.add_queue(qname, self.queue_size, multiprocessing_queue=False) def process(self): """ Process the conversion """ @@ -121,27 +122,17 @@ def convert_images(self): logger.debug("Converting images") save_queue = queue_manager.get_queue("convert_out") patch_queue = queue_manager.get_queue("patch") - completion_queue = queue_manager.get_queue("patch_completed") - pool = PoolProcess(self.converter.process, patch_queue, save_queue, - completion_queue=completion_queue, - processes=self.pool_processes) - pool.start() - completed_count = 0 + self.patch_threads = MultiThread(self.converter.process, patch_queue, save_queue, + thread_count=self.pool_processes, name="patch") + + self.patch_threads.start() while True: self.check_thread_error() if self.disk_io.completion_event.is_set(): logger.debug("DiskIO completion event set. Joining Pool") break - try: - completed = completion_queue.get(True, 1) - except QueueEmpty: - continue - completed_count += completed - logger.debug("Total process pools completed: %s of %s", completed_count, pool.procs) - if completed_count == pool.procs: - logger.debug("All processes completed. Joining Pool") - break - pool.join() + sleep(1) + self.patch_threads.join() logger.debug("Putting EOF") save_queue.put("EOF") @@ -149,7 +140,10 @@ def convert_images(self): def check_thread_error(self): """ Check and raise thread errors """ - for thread in (self.predictor.thread, self.disk_io.load_thread, self.disk_io.save_thread): + for thread in (self.predictor.thread, + self.disk_io.load_thread, + self.disk_io.save_thread, + self.patch_threads): thread.check_and_raise_error() @@ -238,15 +232,13 @@ def get_frame_ranges(self): logger.debug("minframe: %s, maxframe: %s", minframe, maxframe) if minframe is None or maxframe is None: - logger.error("Frame Ranges specified, but could not determine frame numbering " - "from filenames") - exit(1) + raise FaceswapError("Frame Ranges specified, but could not determine frame numbering " + "from filenames") retval = list() for rng in self.args.frame_ranges: if "-" not in rng: - logger.error("Frame Ranges not specified in the correct format") - exit(1) + raise FaceswapError("Frame Ranges not specified in the correct format") start, end = rng.split("-") retval.append((max(int(start), minframe), min(int(end), maxframe))) logger.debug("frame ranges: %s", retval) @@ -289,7 +281,9 @@ def add_queue(self, task): q_name = "convert_out" else: q_name = task - setattr(self, "{}_queue".format(task), queue_manager.get_queue(q_name)) + setattr(self, + "{}_queue".format(task), + queue_manager.get_queue(q_name, multiprocessing_queue=False)) logger.debug("Added queue for task: '%s'", task) def start_thread(self, task): @@ -312,7 +306,7 @@ def load(self, *args): # pylint: disable=unused-argument if self.load_queue.shutdown.is_set(): logger.debug("Load Queue: Stop signal received. Terminating") break - if image is None or (not image.any() and image.ndim not in ((2, 3))): + if image is None or (not image.any() and image.ndim not in (2, 3)): # All black frames will return not np.any() so check dims too logger.warning("Unable to open image. Skipping: '%s'", filename) continue @@ -488,8 +482,7 @@ def load_model(self): logger.debug("Loading Model") model_dir = get_folder(self.args.model_dir, make_folder=False) if not model_dir: - logger.error("%s does not exist.", self.args.model_dir) - exit(1) + raise FaceswapError("{} does not exist.".format(self.args.model_dir)) trainer = self.get_trainer(model_dir) gpus = 1 if not hasattr(self.args, "gpus") else self.args.gpus model = PluginLoader.get_model(trainer)(model_dir, gpus, predict=True) @@ -505,9 +498,9 @@ def get_trainer(self, model_dir): statefile = [fname for fname in os.listdir(str(model_dir)) if fname.endswith("_state.json")] if len(statefile) != 1: - logger.error("There should be 1 state file in your model folder. %s were found. " - "Specify a trainer with the '-t', '--trainer' option.", len(statefile)) - exit(1) + raise FaceswapError("There should be 1 state file in your model folder. {} were " + "found. Specify a trainer with the '-t', '--trainer' " + "option.".format(len(statefile))) statefile = os.path.join(str(model_dir), statefile[0]) with open(statefile, "rb") as inp: @@ -515,9 +508,8 @@ def get_trainer(self, model_dir): trainer = state.get("name", None) if not trainer: - logger.error("Trainer name could not be read from state file. " - "Specify a trainer with the '-t', '--trainer' option.") - exit(1) + raise FaceswapError("Trainer name could not be read from state file. " + "Specify a trainer with the '-t', '--trainer' option.") logger.debug("Trainer from state file: '%s'", trainer) return trainer @@ -702,9 +694,8 @@ def get_face_hashes(self): face_hashes.append(hash_image_file(face)) logger.debug("Face Hashes: %s", (len(face_hashes))) if not face_hashes: - logger.error("Aligned directory is empty, no faces will be converted!") - exit(1) - elif len(face_hashes) <= len(self.input_images) / 3: + raise FaceswapError("Aligned directory is empty, no faces will be converted!") + if len(face_hashes) <= len(self.input_images) / 3: logger.warning("Aligned directory contains far fewer images than the input " "directory, are you sure this is the right folder?") return face_hashes From 3ba8c73f496df6a0a2991085ccf246e9505269dd Mon Sep 17 00:00:00 2001 From: kilroythethird <44308116+kilroythethird@users.noreply.github.com> Date: Wed, 4 Sep 2019 00:42:51 +0200 Subject: [PATCH 034/981] de-MultiProcess Training * Replaced multiprocessing in training_data with threading * Fix cpu affinity issue * Using multiple threads for BackgroundGenerator --- lib/cli.py | 2 + lib/multithreading.py | 295 ++++----------------------------- lib/training_data.py | 126 ++++---------- plugins/train/trainer/_base.py | 5 +- 4 files changed, 65 insertions(+), 363 deletions(-) diff --git a/lib/cli.py b/lib/cli.py index c6b839043a..c51258a109 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -48,6 +48,8 @@ def test_for_tf_version(): min_ver = 1.12 max_ver = 1.14 try: + # Ensure tensorflow doesn't pin all threads to one core when using tf-mkl + os.environ["KMP_AFFINITY"] = "disabled" import tensorflow as tf except ImportError as err: raise FaceswapError("There was an error importing Tensorflow. This is most likely " diff --git a/lib/multithreading.py b/lib/multithreading.py index ac1a445f60..abbbda5bf1 100644 --- a/lib/multithreading.py +++ b/lib/multithreading.py @@ -8,8 +8,8 @@ import queue as Queue import sys +import os import threading -import numpy as np from lib.logger import LOG_QUEUE, set_root_logger logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -21,258 +21,6 @@ def total_cpus(): return mp.cpu_count() -class ConsumerBuffer(): - """ Memory buffer for consuming """ - def __init__(self, dispatcher, index, data): - logger.trace("Initializing %s: (dispatcher: '%s', index: %s, data: %s)", - self.__class__.__name__, dispatcher, index, data) - self._data = data - self._id = index - self._dispatcher = dispatcher - logger.trace("Initialized %s", self.__class__.__name__) - - def get(self): - """ Return Data """ - return self._data - - def free(self): - """ Return Free """ - self._dispatcher.free(self._id) - - def __enter__(self): - """ On Enter """ - return self.get() - - def __exit__(self, *args): - """ On Exit """ - self.free() - - -class WorkerBuffer(): - """ Memory buffer for working """ - def __init__(self, index, data, stop_event, queue): - logger.trace("Initializing %s: (index: '%s', data: %s, stop_event: %s, queue: %s)", - self.__class__.__name__, index, data, stop_event, queue) - self._id = index - self._data = data - self._stop_event = stop_event - self._queue = queue - logger.trace("Initialized %s", self.__class__.__name__) - - def get(self): - """ Return Data """ - return self._data - - def ready(self): - """ Worker Ready """ - if self._stop_event.is_set(): - return - self._queue.put(self._id) - - def __enter__(self): - """ On Enter """ - return self.get() - - def __exit__(self, *args): - """ On Exit """ - self.ready() - - -class FixedProducerDispatcher(): - """ - Runs the given method in N subprocesses - and provides fixed size shared memory to the method. - This class is designed for endless running worker processes - filling the provided memory with data, - like preparing trainingsdata for neural network training. - - As soon as one worker finishes all worker are shutdown. - - Example: - # Producer side - def do_work(memory_gen): - for memory_wrap in memory_gen: - # alternative memory_wrap.get and memory_wrap.ready can be used - with memory_wrap as memory: - input, exp_result = prepare_batch(...) - memory[0][:] = input - memory[1][:] = exp_result - - # Consumer side - batch_size = 64 - height = width = 256 - batch_shapes = (batch_size, height, width, 3) - dispatcher = FixedProducerDispatcher(do_work, shapes=[batch_shapes, batch_shapes]) - for batch_wrapper in dispatcher: - # alternative batch_wrapper.get and batch_wrapper.free can be used - with batch_wrapper as batch: - send_batch_to_trainer(batch) - """ - CTX = mp.get_context("spawn") - EVENT = CTX.Event - - def __init__(self, method, shapes, in_queue, out_queue, - args=tuple(), kwargs={}, ctype=c_float, workers=1, buffers=None): - logger.debug("Initializing %s: (method: '%s', shapes: %s, ctype: %s, workers: %s, " - "buffers: %s)", self.__class__.__name__, method, shapes, ctype, workers, - buffers) - logger.trace("args: %s, kwargs: %s", args, kwargs) - if buffers is None: - buffers = workers * 2 - else: - assert buffers >= 2 and buffers > workers - self.name = "%s_FixedProducerDispatcher" % str(method) - self._target_func = method - self._shapes = shapes - self._stop_event = self.EVENT() - self._buffer_tokens = in_queue - for i in range(buffers): - self._buffer_tokens.put(i) - self._result_tokens = out_queue - worker_data, self.data = self._create_data(shapes, ctype, buffers) - proc_args = { - 'data': worker_data, - 'stop_event': self._stop_event, - 'target': self._target_func, - 'buffer_tokens': self._buffer_tokens, - 'result_tokens': self._result_tokens, - 'dtype': np.dtype(ctype), - 'shapes': shapes, - 'log_queue': LOG_QUEUE, - 'log_level': logger.getEffectiveLevel(), - 'args': args, - 'kwargs': kwargs - } - self._worker = tuple(self._create_worker(proc_args) for _ in range(workers)) - self._open_worker = len(self._worker) - logger.debug("Initialized %s", self.__class__.__name__) - - @staticmethod - def _np_from_shared(shared, shapes, dtype): - """ Numpy array from shared memory """ - arrs = [] - offset = 0 - np_data = np.frombuffer(shared, dtype=dtype) - for shape in shapes: - count = np.prod(shape) - arrs.append(np_data[offset:offset+count].reshape(shape)) - offset += count - return arrs - - def _create_data(self, shapes, ctype, buffers): - """ Create data """ - buffer_size = int(sum(np.prod(x) for x in shapes)) - dtype = np.dtype(ctype) - data = tuple(RawArray(ctype, buffer_size) for _ in range(buffers)) - np_data = tuple(self._np_from_shared(arr, shapes, dtype) for arr in data) - return data, np_data - - def _create_worker(self, kwargs): - """ Create Worker """ - return self.CTX.Process(target=self._runner, kwargs=kwargs) - - def free(self, index): - """ Free memory """ - if self._stop_event.is_set(): - return - if isinstance(index, ConsumerBuffer): - index = index.index - self._buffer_tokens.put(index) - - def __iter__(self): - """ Iterator """ - return self - - def __next__(self): - """ Next item """ - return self.next() - - def next(self, block=True, timeout=None): - """ - Yields ConsumerBuffer filled by the worker. - Will raise StopIteration if no more elements are available OR any worker is finished. - Will raise queue.Empty when block is False and no element is available. - - The returned data is safe until ConsumerBuffer.free() is called or the - with context is left. If you plan to hold on to it after that make a copy. - - This method is thread safe. - """ - if self._stop_event.is_set(): - raise StopIteration - i = self._result_tokens.get(block=block, timeout=timeout) - if i is None: - self._open_worker -= 1 - raise StopIteration - if self._stop_event.is_set(): - raise StopIteration - return ConsumerBuffer(self, i, self.data[i]) - - def start(self): - """ Start Workers """ - for process in self._worker: - process.start() - _launched_processes.add(self) - - def is_alive(self): - """ Check workers are alive """ - for worker in self._worker: - if worker.is_alive(): - return True - return False - - def join(self): - """ Join Workers """ - self.stop() - while self._open_worker: - if self._result_tokens.get() is None: - self._open_worker -= 1 - while True: - try: - self._buffer_tokens.get(block=False, timeout=0.01) - except Queue.Empty: - break - for worker in self._worker: - worker.join() - - def stop(self): - """ Stop Workers """ - self._stop_event.set() - for _ in range(self._open_worker): - self._buffer_tokens.put(None) - - def is_shutdown(self): - """ Check if stop event is set """ - return self._stop_event.is_set() - - @classmethod - def _runner(cls, data=None, stop_event=None, target=None, - buffer_tokens=None, result_tokens=None, dtype=None, - shapes=None, log_queue=None, log_level=None, - args=None, kwargs=None): - """ Shared Memory Object runner """ - # Fork inherits the queue handler, so skip registration with "fork" - set_root_logger(log_level, queue=log_queue) - logger.debug("FixedProducerDispatcher worker for %s started", str(target)) - np_data = [cls._np_from_shared(d, shapes, dtype) for d in data] - - def get_free_slot(): - while not stop_event.is_set(): - i = buffer_tokens.get() - if stop_event.is_set() or i is None or i == "EOF": - break - yield WorkerBuffer(i, np_data[i], stop_event, result_tokens) - - args = tuple((get_free_slot(),)) + tuple(args) - try: - target(*args, **kwargs) - except Exception as ex: - logger.exception(ex) - stop_event.set() - result_tokens.put(None) - logger.debug("FixedProducerDispatcher worker for %s shutdown", str(target)) - - class PoolProcess(): """ Pool multiple processes """ def __init__(self, method, in_queue, out_queue, *args, processes=None, **kwargs): @@ -386,6 +134,13 @@ def __init__(self, group=None, target=None, name=None, # pylint: disable=too-ma args=args, kwargs=kwargs, daemon=daemon) self.err = None + def check_and_raise_error(self): + """ Checks for errors in thread and raises them in caller """ + if not self.err: + return + logger.debug("Thread error caught: %s", self.err) + raise self.err[1].with_traceback(self.err[2]) + def run(self): try: if self._target: @@ -423,7 +178,7 @@ def has_error(self): @property def errors(self): """ Return a list of thread errors """ - return [thread.err for thread in self._threads] + return [thread.err for thread in self._threads if thread.err] def check_and_raise_error(self): """ Checks for errors in thread and raises them in caller """ @@ -462,31 +217,39 @@ def join(self): logger.debug("Joined all Threads: '%s'", self._name) -class BackgroundGenerator(threading.Thread): +class BackgroundGenerator(MultiThread): """ Run a queue in the background. From: https://stackoverflow.com/questions/7323664/ """ # See below why prefetch count is flawed - def __init__(self, generator, prefetch=1): - threading.Thread.__init__(self) - self.queue = Queue.Queue(maxsize=prefetch) + def __init__(self, generator, prefetch=1, thread_count=2, + queue=None, args=None, kwargs=None): + super().__init__(target=self._run, thread_count=thread_count) + self.queue = queue or Queue.Queue(prefetch) self.generator = generator - self.daemon = True + self._gen_args = args or tuple() + self._gen_kwargs = kwargs or dict() self.start() - def run(self): + def _run(self): """ Put until queue size is reached. Note: put blocks only if put is called while queue has already - reached max size => this makes 2 prefetched items! One in the - queue, one waiting for insertion! """ - for item in self.generator: - self.queue.put(item) - self.queue.put(None) + reached max size => this makes prefetch + thread_count prefetched items! + N in the the queue, one waiting for insertion per thread! """ + try: + for item in self.generator(*self._gen_args, **self._gen_kwargs): + self.queue.put(item) + self.queue.put(None) + except Exception: + self.queue.put(None) + raise def iterator(self): """ Iterate items out of the queue """ while True: next_item = self.queue.get() - if next_item is None: + self.check_and_raise_error() + if next_item is None or next_item == "EOF": + logger.debug("Got EOF OR NONE in BackgroundGenerator") break yield next_item diff --git a/lib/training_data.py b/lib/training_data.py index 6f4c65a7da..423f7441a7 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -6,12 +6,12 @@ from hashlib import sha1 from random import random, shuffle, choice -import cv2 import numpy as np +import cv2 from scipy.interpolate import griddata from lib.model import masks -from lib.multithreading import FixedProducerDispatcher +from lib.multithreading import BackgroundGenerator from lib.queue_manager import queue_manager from lib.umeyama import umeyama from lib.utils import cv2_read_img, FaceswapError @@ -33,8 +33,7 @@ def __init__(self, model_input_size, model_output_shapes, training_opts, config) self.training_opts = training_opts self.mask_class = self.set_mask_class() self.landmarks = self.training_opts.get("landmarks", None) - self.fixed_producer_dispatcher = None # Set by FPD when loading - self._nearest_landmarks = None + self._nearest_landmarks = {} self.processing = ImageManipulation(model_input_size, model_output_shapes, training_opts.get("coverage_ratio", 0.625), @@ -60,81 +59,9 @@ def minibatch_ab(self, images, batchsize, side, is_preview, is_timelapse) self.batchsize = batchsize is_display = is_preview or is_timelapse - queue_in, queue_out = self.make_queues(side, is_preview, is_timelapse) - training_size = self.training_opts.get("training_size", 256) - batch_shape = list(( - (batchsize, training_size, training_size, 3), # sample images - (batchsize, self.model_input_size, self.model_input_size, 3))) # Training Image - # Target images - batch_shape.extend(tuple([(batchsize, ) + shape for shape in self.model_output_shapes])) - logger.debug("Batch shapes: %s", batch_shape) - - self.fixed_producer_dispatcher = FixedProducerDispatcher( - method=self.load_batches, - shapes=batch_shape, - in_queue=queue_in, - out_queue=queue_out, - args=(images, side, is_display, do_shuffle, batchsize)) - self.fixed_producer_dispatcher.start() - logger.debug("Batching to queue: (side: '%s', is_display: %s)", side, is_display) - return self.minibatch(side, is_display, self.fixed_producer_dispatcher) - - def join_subprocess(self): - """ Join the FixedProduceerDispatcher subprocess from outside this module """ - logger.debug("Joining FixedProducerDispatcher") - if self.fixed_producer_dispatcher is None: - logger.debug("FixedProducerDispatcher not yet initialized. Exiting") - return - self.fixed_producer_dispatcher.join() - logger.debug("Joined FixedProducerDispatcher") - - @staticmethod - def make_queues(side, is_preview, is_timelapse): - """ Create the buffer token queues for Fixed Producer Dispatcher """ - q_name = "_{}".format(side) - if is_preview: - q_name = "{}{}".format("preview", q_name) - elif is_timelapse: - q_name = "{}{}".format("timelapse", q_name) - else: - q_name = "{}{}".format("train", q_name) - q_names = ["{}_{}".format(q_name, direction) for direction in ("in", "out")] - logger.debug(q_names) - queues = [queue_manager.get_queue(queue) for queue in q_names] - return queues - - def load_batches(self, mem_gen, images, side, is_display, - do_shuffle=True, batchsize=0): - """ Load the warped images and target images to queue """ - logger.debug("Loading batch: (image_count: %s, side: '%s', is_display: %s, " - "do_shuffle: %s)", len(images), side, is_display, do_shuffle) - self.validate_samples(images) - # Intialize this for each subprocess - self._nearest_landmarks = dict() - - def _img_iter(imgs): - while True: - if do_shuffle: - shuffle(imgs) - for img in imgs: - yield img - - img_iter = _img_iter(images) - epoch = 0 - for memory_wrapper in mem_gen: - memory = memory_wrapper.get() - logger.trace("Putting to batch queue: (side: '%s', is_display: %s)", - side, is_display) - for i, img_path in enumerate(img_iter): - imgs = self.process_face(img_path, side, is_display) - for j, img in enumerate(imgs): - memory[j][i][:] = img - epoch += 1 - if i == batchsize - 1: - break - memory_wrapper.ready() - logger.debug("Finished batching: (epoch: %s, side: '%s', is_display: %s)", - epoch, side, is_display) + args = (images, side, is_display, do_shuffle, batchsize) + batcher = BackgroundGenerator(self.minibatch, thread_count=2, args=args) + return batcher.iterator() def validate_samples(self, data): """ Check the total number of images against batchsize and return @@ -150,22 +77,36 @@ def validate_samples(self, data): "your batch-size.") raise FaceswapError(msg) from err - @staticmethod - def minibatch(side, is_display, load_process): + def minibatch(self, images, side, is_display, do_shuffle, batchsize): """ A generator function that yields epoch, batchsize of warped_img and batchsize of target_img from the load queue """ - logger.debug("Launching minibatch generator for queue (side: '%s', is_display: %s)", - side, is_display) - for batch_wrapper in load_process: - with batch_wrapper as batch: - logger.trace("Yielding batch: (size: %s, item shapes: %s, side: '%s', " - "is_display: %s)", - len(batch), [item.shape for item in batch], side, is_display) - yield batch - load_process.stop() - logger.debug("Finished minibatch generator for queue: (side: '%s', is_display: %s)", + logger.debug("Loading minibatch generator: (image_count: %s, side: '%s', is_display: %s, " + "do_shuffle: %s)", len(images), side, is_display, do_shuffle) + self.validate_samples(images) + + def _img_iter(imgs): + while True: + if do_shuffle: + shuffle(imgs) + for img in imgs: + yield img + + img_iter = _img_iter(images) + while True: + batch = list() + for _ in range(batchsize): + img_path = next(img_iter) + data = self.process_face(img_path, side, is_display) + batch.append(data) + batch = list(zip(*batch)) + batch = [np.array(x, dtype="float32") for x in batch] + logger.trace("Yielding batch: (size: %s, item shapes: %s, side: '%s', " + "is_display: %s)", + len(batch), [item.shape for item in batch], side, is_display) + yield batch + + logger.debug("Finished minibatch generator: (side: '%s', is_display: %s)", side, is_display) - load_process.join() def process_face(self, filename, side, is_display): """ Load an image and perform transformation and warping """ @@ -180,7 +121,6 @@ def process_face(self, filename, side, is_display): image = self.processing.color_adjust(image, self.training_opts["augment_color"], is_display) - if not is_display: image = self.processing.random_transform(image) if not self.training_opts["no_flip"]: diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 0e1332eaee..519eb60dd5 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -170,6 +170,7 @@ def train_one_step(self, viewer, timelapse_kwargs): do_snapshot = (snapshot_interval != 0 and self.model.iterations >= snapshot_interval and self.model.iterations % snapshot_interval == 0) + loss = dict() try: for side, batcher in self.batchers.items(): @@ -207,9 +208,6 @@ def train_one_step(self, viewer, timelapse_kwargs): if do_snapshot: self.model.do_snapshot() except Exception as err: - # Shutdown the FixedProducerDispatchers then continue to raise error - for batcher in self.batchers.values(): - batcher.shutdown_feed() raise err def store_history(self, side, loss): @@ -253,7 +251,6 @@ def __init__(self, side, images, model, use_mask, batch_size, config): generator = self.load_generator() self.feed = generator.minibatch_ab(images, batch_size, self.side) - self.shutdown_feed = generator.join_subprocess self.preview_feed = None self.timelapse_feed = None From da0d303c1dea4984f24f07e6d34f9fbf4057903f Mon Sep 17 00:00:00 2001 From: kilroythethird Date: Wed, 4 Sep 2019 17:21:35 +0200 Subject: [PATCH 035/981] s3fd-amd fixes --- plugins/extract/detect/s3fd_amd.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/plugins/extract/detect/s3fd_amd.py b/plugins/extract/detect/s3fd_amd.py index 9e0a840dc5..4dbcc06f94 100644 --- a/plugins/extract/detect/s3fd_amd.py +++ b/plugins/extract/detect/s3fd_amd.py @@ -14,6 +14,7 @@ from lib.multithreading import FSThread from lib.queue_manager import queue_manager import queue +from os.path import basename class Detect(Detector): @@ -62,7 +63,7 @@ def post_processing_thread(self, in_queue, again_queue): while True: job = in_queue.get() if job == "EOF": - logger.debug("Post processing got EOF") + logger.debug("S3fd-amd post processing got EOF") got_first_eof = True else: predictions, items = job @@ -77,14 +78,15 @@ def post_processing_thread(self, in_queue, again_queue): self.finalize(item) if did_rotation: open_rot_jobs -= 1 - logger.debug("Found face after rotation.") + logger.trace("Found face after rotation.") elif s3fd_opts["rotations"]: # we have remaining rotations + logger.trace("No face detected, remaining rotations: %s", s3fd_opts["rotations"]) if not did_rotation: open_rot_jobs += 1 logger.trace("Rotate face %s and try again.", item["filename"]) again_queue.put(item) else: - logger.trace("No face detected for %s.", item["filename"]) + logger.debug("No face detected for %s.", item["filename"]) open_rot_jobs -= 1 item["detected_faces"] = [] del item["_s3fd"] @@ -100,7 +102,7 @@ def prediction_thread(self, in_queue, out_queue): while True: job = in_queue.get() if job == "EOF": - logger.debug("Prediction processing got EOF") + logger.debug("S3fd-amd prediction processing got EOF") if got_first_eof: break out_queue.put(job) @@ -113,7 +115,6 @@ def prediction_thread(self, in_queue, out_queue): def detect_faces(self, *args, **kwargs): """ Detect faces in rgb image """ super().detect_faces(*args, **kwargs) - logger.debug("Launching Detect") self.rotate_queue = queue_manager.get_queue("s3fd_rotate", 8, False) prediction_queue = queue_manager.get_queue("s3fd_pred", 8, False) post_queue = queue_manager.get_queue("s3fd_post", 8, False) @@ -128,11 +129,14 @@ def detect_faces(self, *args, **kwargs): got_first_eof = False while True: + worker.check_and_raise_error() + post_worker.check_and_raise_error() got_eof, in_batch = self.get_batch() batch = list() for item in in_batch: s3fd_opts = item.setdefault("_s3fd", {}) if "scaled_img" not in s3fd_opts: + logger.trace("Resizing %s" % basename(item["filename"])) detect_image, scale, pads = self.compile_detection_image( item["image"], is_square=True, pad_to=self.target ) @@ -142,8 +146,11 @@ def detect_faces(self, *args, **kwargs): s3fd_opts["rotmatrix"] = None # the first "rotation" is always 0 img = s3fd_opts["scaled_img"] = detect_image else: + logger.trace("Rotating %s" % basename(item["filename"])) angle = s3fd_opts["rotations"][0] - img, rotmat = self.rotate_image(s3fd_opts["scaled_img"], angle) + img, rotmat = self.rotate_image_by_angle( + s3fd_opts["scaled_img"], angle, *self.target + ) s3fd_opts["rotmatrix"] = rotmat batch.append((img, item)) @@ -154,12 +161,16 @@ def detect_faces(self, *args, **kwargs): prediction_queue.put((batch_data, batch_items)) if got_eof: - logger.info("Main worker got EOF") + logger.debug("S3fd-amd main worker got EOF") prediction_queue.put("EOF") + # Required to prevent hanging when less then BS items are in the + # again queue and we won't receive new images. + self.batch_size = 1 if got_first_eof: break got_first_eof = True + logger.debug("Joining s3fd-amd worker") worker.join() post_worker.join() for qname in (): From f8e0190fa0a423b1eb2c15aee1fece447e95fed5 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 7 Sep 2019 13:06:58 -0500 Subject: [PATCH 036/981] update opencv-python --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8efc45d89d..8ab14be851 100755 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ tqdm psutil pathlib numpy==1.16.2 -opencv-python +opencv-python>=4.0 scikit-image Pillow==6.1.0 scikit-learn From 23bb80a9adc1b3e69e2224bbbf294f946079ae0f Mon Sep 17 00:00:00 2001 From: Artem Ivanov <37909402+andenixa@users.noreply.github.com> Date: Sat, 14 Sep 2019 13:58:53 +0300 Subject: [PATCH 037/981] Nnblocks added scale factor to Upscaler (#869) * Update nn_blocks.py --- lib/model/nn_blocks.py | 7 ++++--- requirements.txt | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index e7783eee8d..57db4dda4a 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -110,7 +110,7 @@ def conv(self, inp, filters, kernel_size=5, strides=2, padding="same", return var_x def upscale(self, inp, filters, kernel_size=3, padding="same", - use_instance_norm=False, res_block_follows=False, **kwargs): + use_instance_norm=False, res_block_follows=False, scale_factor=2, **kwargs): """ Upscale Layer """ logger.debug("inp: %s, filters: %s, kernel_size: %s, use_instance_norm: %s, kwargs: %s)", inp, filters, kernel_size, use_instance_norm, kwargs) @@ -137,9 +137,10 @@ def upscale(self, inp, filters, kernel_size=3, padding="same", if not res_block_follows: var_x = LeakyReLU(0.1, name="{}_leakyrelu".format(name))(var_x) if self.use_subpixel: - var_x = SubPixelUpscaling(name="{}_subpixel".format(name))(var_x) + var_x = SubPixelUpscaling(name="{}_subpixel".format(name), + scale_factor=scale_factor)(var_x) else: - var_x = PixelShuffler(name="{}_pixelshuffler".format(name))(var_x) + var_x = PixelShuffler(name="{}_pixelshuffler".format(name), size=scale_factor)(var_x) return var_x # <<< DFaker Model Blocks >>> # diff --git a/requirements.txt b/requirements.txt index 8efc45d89d..8ab14be851 100755 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ tqdm psutil pathlib numpy==1.16.2 -opencv-python +opencv-python>=4.0 scikit-image Pillow==6.1.0 scikit-learn From 88352b0268efe49b54c9bdfad4846317752991ed Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 15 Sep 2019 17:07:41 +0100 Subject: [PATCH 038/981] De-Multiprocess Extract (#871) * requirements.txt: - Pin opencv to 4.1.1 (fixes cv2-dnn error) * lib.face_detect.DetectedFace: change LandmarksXY to landmarks_xy. Add left, right, top, bottom attributes * lib.model.session: Session manager for loading models into different graphs (for Nvidia + CPU) * plugins.extract._base: New parent class for all extract plugins * plugins.extract.pipeline. Remove MultiProcessing. Dynamically limit batchsize for Nvidia cards. Remove loglevel input * S3FD + FAN plugins. Standardise to Keras version for all backends * Standardize all extract plugins to new threaded codebase * Documentation. Start implementing Numpy style docstrings for Sphinx Documentation * Remove s3fd_amd. Change convert OTF to expect DetectedFace object * faces_detect - clean up and documentation * Remove PoolProcess * Migrate manual tool to new extract workflow * Remove AMD specific extractor code from cli and plugins * Sort tool to new extract workflow * Remove multiprocessing from project * Remove multiprocessing queues from QueueManager * Remove multiprocessing support from logger * Move face_filter to new extraction pipeline * Alignments landmarksXY > landmarks_xy and legacy handling * Intercept get_backend for sphinx doc build # Add Sphinx documentation --- .gitignore | 3 + docs/conf.py | 54 + docs/full/lib.faces_detect.rst | 7 + docs/full/lib.model.rst | 17 + docs/full/lib.model.session.rst | 7 + docs/full/lib.rst | 18 + docs/full/modules.rst | 8 + docs/full/plugins.extract._base.rst | 7 + docs/full/plugins.extract.align._base.rst | 7 + docs/full/plugins.extract.align.rst | 17 + docs/full/plugins.extract.detect._base.rst | 7 + docs/full/plugins.extract.detect.rst | 17 + docs/full/plugins.extract.pipeline.rst | 7 + docs/full/plugins.extract.rst | 26 + docs/full/plugins.rst | 17 + docs/index.rst | 21 + lib/aligner.py | 4 +- lib/alignments.py | 28 + lib/cli.py | 53 +- lib/face_filter.py | 47 +- lib/faces_detect.py | 308 +++-- lib/logger.py | 46 +- lib/model/session.py | 125 ++ lib/multithreading.py | 139 +- lib/queue_manager.py | 27 +- lib/utils.py | 36 +- plugins/extract/_base.py | 436 ++++++ plugins/extract/align/_base.py | 404 +++--- plugins/extract/align/cv2_dnn.py | 134 +- plugins/extract/align/fan.py | 392 +++--- plugins/extract/align/fan_amd.py | 271 ---- .../fan_defaults.py} | 29 +- plugins/extract/detect/_base.py | 622 ++++----- plugins/extract/detect/cv2_dnn.py | 122 +- plugins/extract/detect/cv2_dnn_defaults.py | 4 +- plugins/extract/detect/manual.py | 51 +- plugins/extract/detect/mtcnn.py | 1166 +++++++---------- plugins/extract/detect/mtcnn_defaults.py | 2 + plugins/extract/detect/s3fd.py | 384 +++--- plugins/extract/detect/s3fd_amd.py | 492 ------- plugins/extract/detect/s3fd_defaults.py | 60 +- plugins/extract/pipeline.py | 491 ++++--- plugins/plugin_loader.py | 11 - requirements.txt | 2 +- scripts/convert.py | 15 +- scripts/extract.py | 12 +- scripts/fsmedia.py | 4 +- tools/lib_alignments/annotate.py | 4 +- tools/lib_alignments/jobs.py | 6 +- tools/lib_alignments/jobs_manual.py | 106 +- tools/sort.py | 53 +- 51 files changed, 3058 insertions(+), 3268 deletions(-) create mode 100644 docs/conf.py create mode 100644 docs/full/lib.faces_detect.rst create mode 100644 docs/full/lib.model.rst create mode 100644 docs/full/lib.model.session.rst create mode 100644 docs/full/lib.rst create mode 100644 docs/full/modules.rst create mode 100644 docs/full/plugins.extract._base.rst create mode 100644 docs/full/plugins.extract.align._base.rst create mode 100644 docs/full/plugins.extract.align.rst create mode 100644 docs/full/plugins.extract.detect._base.rst create mode 100644 docs/full/plugins.extract.detect.rst create mode 100644 docs/full/plugins.extract.pipeline.rst create mode 100644 docs/full/plugins.extract.rst create mode 100644 docs/full/plugins.rst create mode 100644 docs/index.rst create mode 100644 lib/model/session.py create mode 100644 plugins/extract/_base.py delete mode 100644 plugins/extract/align/fan_amd.py rename plugins/extract/{detect/s3fd_amd_defaults.py => align/fan_defaults.py} (75%) delete mode 100644 plugins/extract/detect/s3fd_amd.py diff --git a/.gitignore b/.gitignore index e2d5ca9254..8ce9f7b148 100644 --- a/.gitignore +++ b/.gitignore @@ -7,12 +7,15 @@ !*.nsi !*.png !*.py +!*.rst !*.txt !.cache !Dockerfile* !requirements* !.install/ !.install/windows +!docs +!docs/full !config/ !lib/ !lib/* diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000000..aa8b1345ae --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,54 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys +sys.path.insert(0, os.path.abspath('../')) +sys.setrecursionlimit(1500) + +# -- Project information ----------------------------------------------------- + +project = 'faceswap' +copyright = '2019, faceswap.dev' +author = 'faceswap.dev' + +# The full version, including alpha/beta/rc tags +release = '0.99' + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = ['sphinx.ext.napoleon', ] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] diff --git a/docs/full/lib.faces_detect.rst b/docs/full/lib.faces_detect.rst new file mode 100644 index 0000000000..e2469620c1 --- /dev/null +++ b/docs/full/lib.faces_detect.rst @@ -0,0 +1,7 @@ +lib.faces\_detect module +======================== + +.. automodule:: lib.faces_detect + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib.model.rst b/docs/full/lib.model.rst new file mode 100644 index 0000000000..b75d7a96ca --- /dev/null +++ b/docs/full/lib.model.rst @@ -0,0 +1,17 @@ +lib.model package +================= + +Submodules +---------- + +.. toctree:: + + lib.model.session + +Module contents +--------------- + +.. automodule:: lib.model + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib.model.session.rst b/docs/full/lib.model.session.rst new file mode 100644 index 0000000000..e80025ad18 --- /dev/null +++ b/docs/full/lib.model.session.rst @@ -0,0 +1,7 @@ +lib.model.session module +======================== + +.. automodule:: lib.model.session + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib.rst b/docs/full/lib.rst new file mode 100644 index 0000000000..a4726c5455 --- /dev/null +++ b/docs/full/lib.rst @@ -0,0 +1,18 @@ +lib package +=========== + +Subpackages +----------- + +.. toctree:: + + lib.model + lib.faces_detect + +Module contents +--------------- + +.. automodule:: lib + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/modules.rst b/docs/full/modules.rst new file mode 100644 index 0000000000..f632b60805 --- /dev/null +++ b/docs/full/modules.rst @@ -0,0 +1,8 @@ +faceswap +======== + +.. toctree:: + :maxdepth: 4 + + lib + plugins diff --git a/docs/full/plugins.extract._base.rst b/docs/full/plugins.extract._base.rst new file mode 100644 index 0000000000..242ab986ee --- /dev/null +++ b/docs/full/plugins.extract._base.rst @@ -0,0 +1,7 @@ +plugins.extract._base module +=============================== + +.. automodule:: plugins.extract._base + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/plugins.extract.align._base.rst b/docs/full/plugins.extract.align._base.rst new file mode 100644 index 0000000000..b8ce7b5976 --- /dev/null +++ b/docs/full/plugins.extract.align._base.rst @@ -0,0 +1,7 @@ +plugins.extract.align._base module +====================================== + +.. automodule:: plugins.extract.align._base + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/plugins.extract.align.rst b/docs/full/plugins.extract.align.rst new file mode 100644 index 0000000000..7ae7a06f36 --- /dev/null +++ b/docs/full/plugins.extract.align.rst @@ -0,0 +1,17 @@ +plugins.extract.align package +============================= + +Submodules +---------- + +.. toctree:: + + plugins.extract.align._base + +Module contents +--------------- + +.. automodule:: plugins.extract.align + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/plugins.extract.detect._base.rst b/docs/full/plugins.extract.detect._base.rst new file mode 100644 index 0000000000..3ee95a1762 --- /dev/null +++ b/docs/full/plugins.extract.detect._base.rst @@ -0,0 +1,7 @@ +plugins.extract.detect._base module +====================================== + +.. automodule:: plugins.extract.detect._base + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/plugins.extract.detect.rst b/docs/full/plugins.extract.detect.rst new file mode 100644 index 0000000000..27f2d9d137 --- /dev/null +++ b/docs/full/plugins.extract.detect.rst @@ -0,0 +1,17 @@ +plugins.extract.detect package +============================== + +Submodules +---------- + +.. toctree:: + + plugins.extract.detect._base + +Module contents +--------------- + +.. automodule:: plugins.extract.detect + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/plugins.extract.pipeline.rst b/docs/full/plugins.extract.pipeline.rst new file mode 100644 index 0000000000..f36acf820d --- /dev/null +++ b/docs/full/plugins.extract.pipeline.rst @@ -0,0 +1,7 @@ +plugins.extract.pipeline module +=============================== + +.. automodule:: plugins.extract.pipeline + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/plugins.extract.rst b/docs/full/plugins.extract.rst new file mode 100644 index 0000000000..384a8c56af --- /dev/null +++ b/docs/full/plugins.extract.rst @@ -0,0 +1,26 @@ +plugins.extract package +======================= + +Subpackages +----------- + +.. toctree:: + + plugins.extract.align + plugins.extract.detect + +Submodules +---------- + +.. toctree:: + + plugins.extract._base + plugins.extract.pipeline + +Module contents +--------------- + +.. automodule:: plugins.extract + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/plugins.rst b/docs/full/plugins.rst new file mode 100644 index 0000000000..c0659fecf3 --- /dev/null +++ b/docs/full/plugins.rst @@ -0,0 +1,17 @@ +plugins package +=============== + +Subpackages +----------- + +.. toctree:: + + plugins.extract + +Module contents +--------------- + +.. automodule:: plugins + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000000..af05d8532f --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,21 @@ +.. faceswap documentation master file, created by + sphinx-quickstart on Fri Sep 13 11:28:50 2019. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +faceswap.dev Developer Documentation +==================================== + +.. toctree:: + :maxdepth: 6 + :caption: Contents: + + full/modules + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/lib/aligner.py b/lib/aligner.py index 4770f908eb..40186615bb 100644 --- a/lib/aligner.py +++ b/lib/aligner.py @@ -139,7 +139,7 @@ def get_matrix_scaling(mat): def get_align_mat(face, size, should_align_eyes): """ Return the alignment Matrix """ logger.trace("size: %s, should_align_eyes: %s", size, should_align_eyes) - mat_umeyama = umeyama(np.array(face.landmarks_as_xy[17:]), True)[0:2] + mat_umeyama = umeyama(np.array(face.landmarks_xy[17:]), True)[0:2] if should_align_eyes is False: return mat_umeyama @@ -147,7 +147,7 @@ def get_align_mat(face, size, should_align_eyes): mat_umeyama = mat_umeyama * size # Convert to matrix - landmarks = np.matrix(face.landmarks_as_xy) + landmarks = np.matrix(face.landmarks_xy) # cv2 expects points to be in the form # np.array([ [[x1, y1]], [[x2, y2]], ... ]), we'll expand the dim diff --git a/lib/alignments.py b/lib/alignments.py index 0250060f37..8717947303 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -34,6 +34,7 @@ def __init__(self, folder, filename="alignments", serializer="json"): self.file = self.get_location(folder, filename) self.data = self.load() + self.update_legacy() logger.debug("Initialized %s", self.__class__.__name__) # << PROPERTIES >> # @@ -272,6 +273,11 @@ def yield_original_index_reverse(image_alignments, number_alignments): # << LEGACY FUNCTIONS >> # + def update_legacy(self): + """ Update legacy alignments """ + if self.has_legacy_landmarksxy(): + logger.info("Updating legacy alignments") + self.update_legacy_landmarksxy() # < Rotation > # # The old rotation method would rotate the image to find a face, then # store the rotated landmarks along with a rotation value to tell the @@ -361,3 +367,25 @@ def add_face_hashes(self, frame_name, hashes): abs(count_match), msg, frame_name) for idx, i_hash in hashes.items(): faces[idx]["hash"] = i_hash + + # # + # Landmarks renamed from landmarksXY to landmarks_xy for PEP compliance + def has_legacy_landmarksxy(self): + """ check for legacy landmarksXY keys """ + logger.debug("checking legacy landmarksXY") + retval = (any(key == "landmarksXY" + for alignments in self.data.values() + for alignment in alignments + for key in alignment)) + logger.debug("legacy landmarksXY: %s", retval) + return retval + + def update_legacy_landmarksxy(self): + """ Update landmarksXY to landmarks_xy and save alignments """ + update_count = 0 + for alignments in self.data.values(): + for alignment in alignments: + alignment["landmarks_xy"] = alignment.pop("landmarksXY") + update_count += 1 + logger.debug("Updated landmarks_xy: %s", update_count) + self.save() diff --git a/lib/cli.py b/lib/cli.py index c51258a109..cafe2402c8 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -519,13 +519,19 @@ class ExtractArgs(ExtractConvertArgs): @staticmethod def get_info(): """ Return command information """ - return "Extract faces from image or video sources" + return ("Extract faces from image or video sources.\n" + "Extraction plugins can be configured in the 'Settings' Menu") @staticmethod def get_optional_arguments(): """ Put the arguments in a list so that they are accessible from both argparse and gui """ - backend = get_backend() + if get_backend() == "cpu": + default_detector = default_aligner = "cv2-dnn" + else: + default_detector = "s3fd" + default_aligner = "fan" + argument_list = [] argument_list.append({"opts": ("--serializer", ), "type": str.lower, @@ -536,19 +542,6 @@ def get_optional_arguments(): "help": "Serializer for alignments file. If yaml is chosen and not " "available, then json will be used as the default " "fallback."}) - s3fd = "s3fd" - fan = "fan" - if backend == "cpu": - default_detector = default_aligner = "cv2-dnn" - else: - default_detector = s3fd - default_aligner = fan - if backend == "amd": - default_detector += "-amd" - default_aligner += "-amd" - s3fd += "-amd" - fan += "-amd" - argument_list.append({ "opts": ("-D", "--detector"), "action": Radio, @@ -558,13 +551,12 @@ def get_optional_arguments(): "group": "Plugins", "help": "R|Detector to use. Some of these have configurable settings in " "'/config/extract.ini' or 'Edit > Configure Extract Plugins':" - "\nL|'cv2-dnn': A CPU only extractor, is the least reliable, but uses least " + "\nL|cv2-dnn: A CPU only extractor, is the least reliable, but uses least " "resources and runs fast on CPU. Use this if not using a GPU and time is " "important." - "\nL|'mtcnn': Fast on GPU, slow on CPU. Uses fewer resources than other GPU " - "detectors but can often return more false positives. NB: Runs on CPU for AMD " - "cards." - "\nL|'" + s3fd + "': Fast on GPU, slow on CPU. Can detect more faces and " + "\nL|mtcnn: Fast on GPU, slow on CPU. Uses fewer resources than other GPU " + "detectors but can often return more false positives." + "\nL|s3fd: Fast on GPU, slow on CPU. Can detect more faces and " "fewer false positives than other GPU detectors, but is a lot more resource " "intensive."}) argument_list.append({ @@ -575,10 +567,10 @@ def get_optional_arguments(): "default": default_aligner, "group": "Plugins", "help": "R|Aligner to use." - "\nL|'cv2-dnn': A cpu only CNN based landmark detector. Faster, less " + "\nL|cv2-dnn: A cpu only CNN based landmark detector. Faster, less " "resource intensive, but less accurate. Only use this if not using a gpu " " and time is important." - "\nL|'" + fan + "': Face Alignment Network. Best aligner. GPU " + "\nL|fan: Face Alignment Network. Best aligner. GPU " "heavy, slow when not running on GPU"}) argument_list.append({"opts": ("-nm", "--normalization"), "action": Radio, @@ -592,11 +584,11 @@ def get_optional_arguments(): "extraction speed cost. Different methods will yield " "different results on different sets. NB: This does not " "impact the output face, just the input to the aligner." - "\nL|'none': Don't perform normalization on the face." - "\nL|'clahe': Perform Contrast Limited Adaptive Histogram " + "\nL|none: Don't perform normalization on the face." + "\nL|clahe: Perform Contrast Limited Adaptive Histogram " "Equalization on the face." - "\nL|'hist': Equalize the histograms on the RGB channels." - "\nL|'mean': Normalize the face colors to the mean."}) + "\nL|hist: Equalize the histograms on the RGB channels." + "\nL|mean: Normalize the face colors to the mean."}) argument_list.append({"opts": ("-r", "--rotate-images"), "type": str, "dest": "rotate_images", @@ -752,7 +744,8 @@ class ConvertArgs(ExtractConvertArgs): @staticmethod def get_info(): """ Return command information """ - return "Swap the original faces in a source video/images to your final faces" + return ("Swap the original faces in a source video/images to your final faces.\n" + "Conversion plugins can be configured in the 'Settings' Menu") @staticmethod def get_optional_arguments(): @@ -985,9 +978,9 @@ class TrainArgs(FaceSwapArgs): @staticmethod def get_info(): """ Return command information """ - return ("Train a model on extracted original (A) and swap (B) faces\n" - "Training models can take a long time. Anything from 24hrs to " - "over a week") + return ("Train a model on extracted original (A) and swap (B) faces.\n" + "Training models can take a long time. Anything from 24hrs to over a week\n" + "Model plugins can be configured in the 'Settings' Menu") @staticmethod def get_argument_list(): diff --git a/lib/face_filter.py b/lib/face_filter.py index cd1226fde6..31919715a2 100644 --- a/lib/face_filter.py +++ b/lib/face_filter.py @@ -3,8 +3,6 @@ import logging -from lib.faces_detect import DetectedFace -from lib.logger import get_loglevel from lib.vgg_face import VGGFace from lib.utils import cv2_read_img from plugins.extract.pipeline import Extractor @@ -21,16 +19,25 @@ class FaceFilter(): """ Face filter for extraction NB: we take only first face, so the reference file should only contain one face. """ - def __init__(self, reference_file_paths, nreference_file_paths, detector, aligner, loglevel, + def __init__(self, reference_file_paths, nreference_file_paths, detector, aligner, multiprocess=False, threshold=0.4): logger.debug("Initializing %s: (reference_file_paths: %s, nreference_file_paths: %s, " - "detector: %s, aligner: %s. loglevel: %s, multiprocess: %s, threshold: %s)", + "detector: %s, aligner: %s, multiprocess: %s, threshold: %s)", self.__class__.__name__, reference_file_paths, nreference_file_paths, - detector, aligner, loglevel, multiprocess, threshold) - self.numeric_loglevel = get_loglevel(loglevel) + detector, aligner, multiprocess, threshold) self.vgg_face = VGGFace() self.filters = self.load_images(reference_file_paths, nreference_file_paths) - self.align_faces(detector, aligner, loglevel, multiprocess) + # TODO Revert face-filter to use the selected detector and aligner. + # Currently Tensorflow does not release vram after it has been allocated + # Whilst this vram can still be used, the pipeline for the extraction process can't see + # it so thinks there is not enough vram available. + # Either the pipeline will need to be changed to be re-usable by face-filter and extraction + # Or another vram measurement technique will need to be implemented to for when TF has + # already performed allocation. For now we force CPU detectors. + + # self.align_faces(detector, aligner, multiprocess) + self.align_faces("cv2-dnn", "cv2-dnn", multiprocess) + self.get_filter_encodings() self.threshold = threshold logger.debug("Initialized %s", self.__class__.__name__) @@ -49,38 +56,25 @@ def load_images(reference_file_paths, nreference_file_paths): return retval # Extraction pipeline - def align_faces(self, detector_name, aligner_name, loglevel, multiprocess): + def align_faces(self, detector_name, aligner_name, multiprocess): """ Use the requested detectors to retrieve landmarks for filter images """ - extractor = Extractor(detector_name, aligner_name, loglevel, multiprocess=multiprocess) + extractor = Extractor(detector_name, aligner_name, multiprocess=multiprocess) self.run_extractor(extractor) del extractor self.load_aligned_face() def run_extractor(self, extractor): """ Run extractor to get faces """ - exception = False for _ in range(extractor.passes): self.queue_images(extractor) - if exception: - break extractor.launch() for faces in extractor.detected_faces(): - exception = faces.get("exception", False) - if exception: - break filename = faces["filename"] detected_faces = faces["detected_faces"] - if len(detected_faces) > 1: logger.warning("Multiple faces found in %s file: '%s'. Using first detected " "face.", self.filters[filename]["type"], filename) - detected_faces = [detected_faces[0]] - self.filters[filename]["detected_faces"] = detected_faces - - # Aligner output - if extractor.final_pass: - landmarks = faces["landmarks"] - self.filters[filename]["landmarks"] = landmarks + self.filters[filename]["detected_face"] = detected_faces[0] def queue_images(self, extractor): """ queue images for detection and alignment """ @@ -100,13 +94,8 @@ def load_aligned_face(self): """ Align the faces for vgg_face input """ for filename, face in self.filters.items(): logger.debug("Loading aligned face: '%s'", filename) - bounding_box = face["detected_faces"][0] image = face["image"] - landmarks = face["landmarks"][0] - - detected_face = DetectedFace() - detected_face.from_bounding_box_dict(bounding_box, image) - detected_face.landmarksXY = landmarks + detected_face = face["detected_face"] detected_face.load_aligned(image, size=224) face["face"] = detected_face.aligned_face del face["image"] diff --git a/lib/faces_detect.py b/lib/faces_detect.py index 651a1079fe..a4a4b58443 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -10,18 +10,49 @@ class DetectedFace(): - """ Detected face and landmark information """ - def __init__( # pylint: disable=invalid-name - self, image=None, x=None, w=None, y=None, h=None, - landmarksXY=None): - logger.trace("Initializing %s", self.__class__.__name__) + """ Detected face and landmark information + + Holds information about a detected face, it's location in a source image + and the face's 68 point landmarks. + + Methods for aligning a face are also callable from here. + + Parameters + ---------- + image: np.ndarray, optional + This is a generic image placeholder that should not be relied on to be holding a particular + image. It may hold the source frame that holds the face, a cropped face or a scaled image + depending on the method using this object. + x: int + The left most point (in pixels) of the face's bounding box as discovered in + :mod:`plugins.extract.detect` + w: int + The width (in pixels) of the face's bounding box as discovered in + :mod:`plugins.extract.detect` + y: int + The top most point (in pixels) of the face's bounding box as discovered in + :mod:`plugins.extract.detect` + h: int + The height (in pixels) of the face's bounding box as discovered in + :mod:`plugins.extract.detect` + landmarks_xy: list + The 68 point landmarks as discovered in :mod:`plugins.extract.align`. Should be a ``list`` + of 68 `(x, y)` ``tuples`` with each of the landmark co-ordinates. + """ + def __init__(self, image=None, x=None, w=None, y=None, h=None, landmarks_xy=None): + logger.trace("Initializing %s: (image: %s, x: %s, w: %s, y: %s, h:%s, landmarks_xy: %s)", + self.__class__.__name__, + image.shape if image is not None and image.any() else image, + x, w, y, h, landmarks_xy) self.image = image self.x = x self.w = w self.y = y self.h = h - self.landmarksXY = landmarksXY - self.hash = None # Hash must be set when the file is saved due to image compression + self.landmarks_xy = landmarks_xy + self.hash = None + """ str: The hash of the face. This cannot be set until the file is saved due to image + compression, but will be set if loading data from :func:`from_alignment` """ self.aligned = dict() self.feed = dict() @@ -29,83 +60,124 @@ def __init__( # pylint: disable=invalid-name logger.trace("Initialized %s", self.__class__.__name__) @property - def extract_ratio(self): - """ The ratio of padding to add for training images """ - return 0.375 + def left(self): + """int: Left point (in pixels) of face detection bounding box within the parent image """ + return self.x @property - def landmarks_as_xy(self): - """ Landmarks as XY """ - return self.landmarksXY - - def to_bounding_box_dict(self): - """ Return Bounding Box as a bounding box dixt """ - retval = dict(left=self.x, top=self.y, right=self.x + self.w, bottom=self.y + self.h) - logger.trace("Returning: %s", retval) - return retval - - def from_bounding_box_dict(self, bounding_box_dict, image=None): - """ Set Bounding Box from a bounding box dict """ - logger.trace("Creating from bounding box dict: %s", bounding_box_dict) - if not isinstance(bounding_box_dict, dict): - raise ValueError("Supplied Bounding Box is not a dictionary.") - self.x = bounding_box_dict["left"] - self.w = bounding_box_dict["right"] - bounding_box_dict["left"] - self.y = bounding_box_dict["top"] - self.h = bounding_box_dict["bottom"] - bounding_box_dict["top"] - if image is not None and image.any(): - self.image_to_face(image) - logger.trace("Created from bounding box dict: (x: %s, w: %s, y: %s. h: %s)", - self.x, self.w, self.y, self.h) + def top(self): + """int: Top point (in pixels) of face detection bounding box within the parent image """ + return self.y - def image_to_face(self, image): - """ Crop an image around bounding box to the face - and capture it's dimensions """ - logger.trace("Cropping face from image") - self.image = image[self.y: self.y + self.h, - self.x: self.x + self.w] + @property + def right(self): + """int: Right point (in pixels) of face detection bounding box within the parent image """ + return self.x + self.w + + @property + def bottom(self): + """int: Bottom point (in pixels) of face detection bounding box within the parent image """ + return self.y + self.h + + @property + def _extract_ratio(self): + """ float: The ratio of padding to add for training images """ + return 0.375 def to_alignment(self): - """ Convert a detected face to alignment dict """ + """ Return the detected face formatted for an alignments file + + returns + ------- + alignment: dict + The alignment dict will be returned with the keys ``x``, ``w``, ``y``, ``h``, + ``landmarks_xy``, ``hash``. + """ + alignment = dict() alignment["x"] = self.x alignment["w"] = self.w alignment["y"] = self.y alignment["h"] = self.h - alignment["landmarksXY"] = self.landmarksXY + alignment["landmarks_xy"] = self.landmarks_xy alignment["hash"] = self.hash logger.trace("Returning: %s", alignment) return alignment def from_alignment(self, alignment, image=None): - """ Convert a face alignment to detected face object """ + """ Set the attributes of this class from an alignments file and optionally load the face + into the ``image`` attribute. + + Parameters + ---------- + alignment: dict + A dictionary entry for a face from an alignments file containing the keys + ``x``, ``w``, ``y``, ``h``, ``landmarks_xy``. Optionally the key ``hash`` + will be provided, but not all use cases will know the face hash at this time. + image: numpy.ndarray, optional + If an image is passed in, then the ``image`` attribute will + be set to the cropped face based on the passed in bounding box co-ordinates + """ + logger.trace("Creating from alignment: (alignment: %s, has_image: %s)", alignment, bool(image is not None)) self.x = alignment["x"] self.w = alignment["w"] self.y = alignment["y"] self.h = alignment["h"] - self.landmarksXY = alignment["landmarksXY"] + self.landmarks_xy = alignment["landmarks_xy"] # Manual tool does not know the final hash so default to None self.hash = alignment.get("hash", None) if image is not None and image.any(): - self.image_to_face(image) + self._image_to_face(image) logger.trace("Created from alignment: (x: %s, w: %s, y: %s. h: %s, " "landmarks: %s)", - self.x, self.w, self.y, self.h, self.landmarksXY) + self.x, self.w, self.y, self.h, self.landmarks_xy) + + def _image_to_face(self, image): + """ set self.image to be the cropped face from detected bounding box """ + logger.trace("Cropping face from image") + self.image = image[self.top: self.bottom, + self.left: self.right] # <<< Aligned Face methods and properties >>> # def load_aligned(self, image, size=256, align_eyes=False, dtype=None): - """ No need to load aligned information for all uses of this - class, so only call this to load the information for easy - reference to aligned properties for this face """ - # Don't reload an already aligned face: + """ Align a face from a given image. + + Aligning a face is a relatively expensive task and is not required for all uses of + the :class:`~lib.faces_detect.DetectedFace` object, so call this function explicitly to + load an aligned face. + + This method plugs into :mod:`lib.aligner` to perform face alignment based on this face's + ``landmarks_xy``. If the face has already been aligned, then this function will return + having performed no action. + + Parameters + ---------- + image: numpy.ndarray + The image that contains the face to be aligned + size: int + The size of the output face in pixels + align_eyes: bool, optional + Optionally perform additional alignment to align eyes. Default: `False` + dtype: str, optional + Optionally set a ``dtype`` for the final face to be formatted in. Default: ``None`` + + Notes + ----- + This method must be executed to get access to the following `properties`: + - :func:`original_roi` + - :func:`aligned_landmarks` + - :func:`aligned_face` + - :func:`adjusted_interpolators` + """ if self.aligned: + # Don't reload an already aligned face logger.trace("Skipping alignment calculation for already aligned face") else: logger.trace("Loading aligned face: (size: %s, align_eyes: %s, dtype: %s)", size, align_eyes, dtype) - padding = int(size * self.extract_ratio) // 2 + padding = int(size * self._extract_ratio) // 2 self.aligned["size"] = size self.aligned["padding"] = padding self.aligned["align_eyes"] = align_eyes @@ -124,24 +196,39 @@ def load_aligned(self, image, size=256, align_eyes=False, dtype=None): for key, val in self.aligned.items() if key != "face"}) - def padding_from_coverage(self, size, coverage_ratio): + def _padding_from_coverage(self, size, coverage_ratio): """ Return the image padding for a face from coverage_ratio set against a pre-padded training image """ - adjusted_ratio = coverage_ratio - (1 - self.extract_ratio) + adjusted_ratio = coverage_ratio - (1 - self._extract_ratio) padding = round((size * adjusted_ratio) / 2) logger.trace(padding) return padding def load_feed_face(self, image, size=64, coverage_ratio=0.625, dtype=None): - """ Return a face in the correct dimensions for feeding into a NN - - Coverage ratio should be the ratio of the extracted image that was used for - training """ + """ Align a face in the correct dimensions for feeding into a model. + + Parameters + ---------- + image: numpy.ndarray + The image that contains the face to be aligned + size: int + The size of the face in pixels to be fed into the model + coverage_ratio: float, optional + the ratio of the extracted image that was used for training. Default: `0.625` + dtype: str, optional + Optionally set a ``dtype`` for the final face to be formatted in. Default: ``None`` + + Notes + ----- + This method must be executed to get access to the following `properties`: + - :func:`feed_face` + - :func:`feed_interpolators` + """ logger.trace("Loading feed face: (size: %s, coverage_ratio: %s, dtype: %s)", size, coverage_ratio, dtype) self.feed["size"] = size - self.feed["padding"] = self.padding_from_coverage(size, coverage_ratio) + self.feed["padding"] = self._padding_from_coverage(size, coverage_ratio) self.feed["matrix"] = get_align_mat(self, size, should_align_eyes=False) face = np.clip(AlignerExtract().transform(image, @@ -152,18 +239,35 @@ def load_feed_face(self, image, size=64, coverage_ratio=0.625, dtype=None): self.feed["face"] = face if dtype is None else face.astype(dtype) logger.trace("Loaded feed face. (face_shape: %s, matrix: %s)", - self.feed_face.shape, self.feed_matrix) + self.feed_face.shape, self._feed_matrix) def load_reference_face(self, image, size=64, coverage_ratio=0.625, dtype=None): - """ Return a face in the correct dimensions for reference to the output from a NN - - Coverage ratio should be the ratio of the extracted image that was used for - training """ + """ Align a face in the correct dimensions for reference against the output from a model. + + Parameters + ---------- + image: numpy.ndarray + The image that contains the face to be aligned + size: int + The size of the face in pixels to be fed into the model + coverage_ratio: float, optional + the ratio of the extracted image that was used for training. Default: `0.625` + dtype: str, optional + Optionally set a ``dtype`` for the final face to be formatted in. Default: ``None`` + + Notes + ----- + This method must be executed to get access to the following `properties`: + - :func:`reference_face` + - :func:`reference_landmarks` + - :func:`reference_matrix` + - :func:`reference_interpolators` + """ logger.trace("Loading reference face: (size: %s, coverage_ratio: %s, dtype: %s)", size, coverage_ratio, dtype) self.reference["size"] = size - self.reference["padding"] = self.padding_from_coverage(size, coverage_ratio) + self.reference["padding"] = self._padding_from_coverage(size, coverage_ratio) self.reference["matrix"] = get_align_mat(self, size, should_align_eyes=False) face = np.clip(AlignerExtract().transform(image, @@ -178,8 +282,10 @@ def load_reference_face(self, image, size=64, coverage_ratio=0.625, dtype=None): @property def original_roi(self): - """ Return the square aligned box location on the original - image """ + """ numpy.ndarray: The location of the extracted face box within the original frame. + Only available after :func:`load_aligned` has been called, otherwise returns ``None``""" + if not self.aligned: + return None roi = AlignerExtract().get_original_roi(self.aligned["matrix"], self.aligned["size"], self.aligned["padding"]) @@ -188,8 +294,11 @@ def original_roi(self): @property def aligned_landmarks(self): - """ Return the landmarks location transposed to extracted face """ - landmarks = AlignerExtract().transform_points(self.landmarksXY, + """ numpy.ndarray: The 68 point landmarks location transposed to the extracted face box. + Only available after :func:`load_aligned` has been called, otherwise returns ``None``""" + if not self.aligned: + return None + landmarks = AlignerExtract().transform_points(self.landmarks_xy, self.aligned["matrix"], self.aligned["size"], self.aligned["padding"]) @@ -198,12 +307,16 @@ def aligned_landmarks(self): @property def aligned_face(self): - """ Return aligned detected face """ - return self.aligned["face"] + """ numpy.ndarray: The aligned detected face. Only available after :func:`load_aligned` + has been called with an image, otherwise returns ``None`` """ + return self.aligned.get("face", None) @property - def adjusted_matrix(self): - """ Return adjusted matrix for size/padding combination """ + def _adjusted_matrix(self): + """ numpy.ndarray: Adjusted matrix for size/padding combination. Only available after + :func:`load_aligned` has been called, otherwise returns ``None``""" + if not self.aligned: + return None mat = AlignerExtract().transform_matrix(self.aligned["matrix"], self.aligned["size"], self.aligned["padding"]) @@ -212,17 +325,26 @@ def adjusted_matrix(self): @property def adjusted_interpolators(self): - """ Return the interpolator and reverse interpolator for the adjusted matrix """ - return get_matrix_scaling(self.adjusted_matrix) + """ tuple: Tuple of (`interpolator` and `reverse interpolator`) for the adjusted matrix. + Only available after :func:`load_aligned` has been called, otherwise returns ``None``""" + if not self.aligned: + return None + return get_matrix_scaling(self._adjusted_matrix) @property def feed_face(self): - """ Return face for feeding into NN """ + """ numpy.ndarray: The aligned face sized for feeding into a model. Only available after + :func:`load_feed_face` has been called with an image, otherwise returns ``None`` """ + if not self.feed: + return None return self.feed["face"] @property - def feed_matrix(self): - """ Return matrix for transforming feed face back to image """ + def _feed_matrix(self): + """ numpy.ndarray: The adjusted matrix face sized for feeding into a model. Only available + after :func:`load_feed_face` has been called with an image, otherwise returns ``None`` """ + if not self.feed: + return None mat = AlignerExtract().transform_matrix(self.feed["matrix"], self.feed["size"], self.feed["padding"]) @@ -231,18 +353,30 @@ def feed_matrix(self): @property def feed_interpolators(self): - """ Return the interpolators for an input face """ - return get_matrix_scaling(self.feed_matrix) + """ tuple: Tuple of (`interpolator` and `reverse interpolator`) for the adjusted feed + matrix. Only available after :func:`load_feed_face` has been called, otherwise returns + ``None``""" + if not self.feed: + return None + return get_matrix_scaling(self._feed_matrix) @property def reference_face(self): - """ Return source face at size of output from NN for reference """ + """ numpy.ndarray: The aligned face sized for reference against a face coming out of a + model. Only available after :func:`load_reference_face` has been called, otherwise + returns ``None``""" + if not self.reference: + return None return self.reference["face"] @property def reference_landmarks(self): - """ Return the landmarks location transposed to reference face """ - landmarks = AlignerExtract().transform_points(self.landmarksXY, + """ numpy.ndarray: The 68 point landmarks location transposed to the reference face box. + Only available after :func:`load_reference_face` has been called, otherwise returns + ``None``""" + if not self.reference: + return None + landmarks = AlignerExtract().transform_points(self.landmarks_xy, self.reference["matrix"], self.reference["size"], self.reference["padding"]) @@ -251,7 +385,11 @@ def reference_landmarks(self): @property def reference_matrix(self): - """ Return matrix for transforming output face back to image """ + """ numpy.ndarray: The adjusted matrix face sized for refence against a face coming out of + a model. Only available after :func:`load_reference_face` has been called, otherwise + returns ``None``""" + if not self.reference: + return None mat = AlignerExtract().transform_matrix(self.reference["matrix"], self.reference["size"], self.reference["padding"]) @@ -260,5 +398,9 @@ def reference_matrix(self): @property def reference_interpolators(self): - """ Return the interpolators for an output face """ + """ tuple: Tuple of (`interpolator` and `reverse interpolator`) for the reference + matrix. Only available after :func:`load_reference_face` has been called, otherwise + returns ``None``""" + if not self.reference: + return None return get_matrix_scaling(self.reference_matrix) diff --git a/lib/logger.py b/lib/logger.py index 6dce4660b7..cd01436ecf 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -2,22 +2,17 @@ """ Logging Setup """ import collections import logging -from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler +from logging.handlers import RotatingFileHandler import os import re import sys import traceback from datetime import datetime -from time import sleep from tqdm import tqdm -from lib.queue_manager import queue_manager -LOG_QUEUE = queue_manager._log_queue # pylint: disable=protected-access - - -class MultiProcessingLogger(logging.Logger): +class FaceswapLogger(logging.Logger): """ Create custom logger with custom levels """ def __init__(self, name): for new_level in (("VERBOSE", 15), ("TRACE", 5)): @@ -48,11 +43,14 @@ class FaceswapFormatter(logging.Formatter): Messages that begin with "R|" should be handled as is """ def format(self, record): - if record.msg.startswith("R|"): - record.msg = record.msg[2:] - record.strip_spaces = False - elif record.strip_spaces: - record.msg = re.sub(" +", " ", record.msg.replace("\n", "\\n").replace("\r", "\\r")) + if isinstance(record.msg, str): + if record.msg.startswith("R|"): + record.msg = record.msg[2:] + record.strip_spaces = False + elif record.strip_spaces: + record.msg = re.sub(" +", + " ", + record.msg.replace("\n", "\\n").replace("\r", "\\r")) return super().format(record) @@ -71,31 +69,27 @@ def emit(self, record): tqdm.write(msg) -def set_root_logger(loglevel=logging.INFO, queue=LOG_QUEUE): - """ Setup the root logger. - Loaded in main process and into any spawned processes - Automatically added in multithreading.py""" +def set_root_logger(loglevel=logging.INFO): + """ Setup the root logger. """ rootlogger = logging.getLogger() - q_handler = QueueHandler(queue) - rootlogger.addHandler(q_handler) rootlogger.setLevel(loglevel) + return rootlogger def log_setup(loglevel, logfile, command, is_gui=False): """ initial log set up. """ numeric_loglevel = get_loglevel(loglevel) root_loglevel = min(logging.DEBUG, numeric_loglevel) - set_root_logger(loglevel=root_loglevel) + rootlogger = set_root_logger(loglevel=root_loglevel) log_format = FaceswapFormatter("%(asctime)s %(processName)-15s %(threadName)-15s " "%(module)-15s %(funcName)-25s %(levelname)-8s %(message)s", datefmt="%m/%d/%Y %H:%M:%S") f_handler = file_handler(numeric_loglevel, logfile, log_format, command) s_handler = stream_handler(numeric_loglevel, is_gui) c_handler = crash_handler(log_format) - - q_listener = QueueListener(LOG_QUEUE, f_handler, s_handler, c_handler, - respect_handler_level=True) - q_listener.start() + rootlogger.addHandler(f_handler) + rootlogger.addHandler(s_handler) + rootlogger.addHandler(c_handler) logging.info("Log level set to: %s", loglevel.upper()) @@ -159,10 +153,6 @@ def crash_log(): path = os.getcwd() filename = os.path.join(path, datetime.now().strftime("crash_report.%Y.%m.%d.%H%M%S%f.log")) - # Wait until all log items have been processed - while not LOG_QUEUE.empty(): - sleep(1) - freeze_log = list(debug_buffer) with open(filename, "w") as outfile: outfile.writelines(freeze_log) @@ -184,7 +174,7 @@ def faceswap_logrecord(*args, **kwargs): logging.setLogRecordFactory(faceswap_logrecord) # Set logger class to custom logger -logging.setLoggerClass(MultiProcessingLogger) +logging.setLoggerClass(FaceswapLogger) # Stores the last 100 debug messages debug_buffer = RollingBuffer(maxlen=100) # pylint: disable=invalid-name diff --git a/lib/model/session.py b/lib/model/session.py new file mode 100644 index 0000000000..6fa2be7f67 --- /dev/null +++ b/lib/model/session.py @@ -0,0 +1,125 @@ +#!/usr/bin python3 +""" Settings manager for Keras Backend """ + +import logging + +import tensorflow as tf +from keras.models import load_model as k_load_model, Model + +from lib.utils import get_backend + +logger = logging.getLogger(__name__) # pylint:disable=invalid-name + + +class KSession(): + """ Handles the settings of backend sessions. + + This class acts as a wrapper for various :class:`keras.Model()` functions, ensuring that + actions performed on a model are handled consistently within the correct graph. + + Currently this only does anything for Nvidia users, making sure a unique graph and session is + provided for the given model. + + Parameters + ---------- + name: str + The name of the model that is to be loaded + model_path: str + The path to the keras model file + model_kwargs: dict + Any kwargs that need to be passed to :func:`keras.models.load_models()` + """ + def __init__(self, name, model_path, model_kwargs=None): + logger.trace("Initializing: %s (name: %s, model_path: %s, model_kwargs: %s)", + self.__class__.__name__, name, model_path, model_kwargs) + self._name = name + self._session = self._set_session() + self._model_path = model_path + self._model_kwargs = model_kwargs + self._model = None + logger.trace("Initialized: %s", self.__class__.__name__,) + + def predict(self, feed): + """ Get predictions from the model in the correct session. + + This method is a wrapper for :func:`keras.predict()` function. + + Parameters + ---------- + feed: numpy.ndarray or list + The feed to be provided to the model as input. This should be a ``numpy.ndarray`` + for single inputs or a ``list`` of ``numpy.ndarrays`` for multiple inputs. + """ + if self._session is None: + return self._model.predict(feed) + + with self._session.as_default(): # pylint: disable=not-context-manager + with self._session.graph.as_default(): + return self._model.predict(feed) + + def _set_session(self): + """ Sets the session and graph. + + If the backend is AMD then this does nothing and the global ``Keras`` ``Session`` + is used + """ + if get_backend() == "amd": + return None + + self.graph = tf.Graph() + config = tf.ConfigProto() + session = tf.Session(graph=tf.Graph(), config=config) + logger.debug("Creating tf.session: (graph: %s, session: %s, config: %s)", + session.graph, session, config) + return session + + def load_model(self): + """ Loads a model within the correct session. + + This method is a wrapper for :func:`keras.models.load_model()`. Loads a model and its + weights from :attr:`model_path`. Any additional ``kwargs`` to be passed to + :func:`keras.models.load_model()` should also be defined during initialization of the + class. + """ + logger.verbose("Initializing plugin model: %s", self._name) + if self._session is None: + self._model = k_load_model(self._model_path, **self._model_kwargs) + else: + with self._session.as_default(): # pylint: disable=not-context-manager + with self._session.graph.as_default(): + self._model = k_load_model(self._model_path, **self._model_kwargs) + + def define_model(self, function): + """ Defines a given model in the correct session. + + This method acts as a wrapper for :class:`keras.models.Model()` to ensure that the model + is defined within it's own graph. + + Parameters + ---------- + function: function + A function that defines a :class:`keras.Model` and returns it's ``inputs`` and + ``outputs``. The function that generates these results should be passed in, NOT the + results themselves, as the function needs to be executed within the correct context. + """ + if self._session is None: + self._model = Model(*function()) + else: + with self._session.as_default(): # pylint: disable=not-context-manager + with self._session.graph.as_default(): + self._model = Model(*function()) + + def load_model_weights(self): + """ Load model weights for a defined model inside the correct session. + + This method is a wrapper for :class:`keras.load_weights()`. Once a model has been defined + in :func:`define_model()` this method can be called to load its weights in the correct + graph from the :attr:`model_path` defined during initialization of this class. + """ + logger.verbose("Initializing plugin model: %s", self._name) + if self._session is None: + self._model.load_weights(self._model_path) + else: + with self._session.as_default(): # pylint: disable=not-context-manager + with self._session.graph.as_default(): + self._model.load_weights(self._model_path) diff --git a/lib/multithreading.py b/lib/multithreading.py index abbbda5bf1..62a0251840 100644 --- a/lib/multithreading.py +++ b/lib/multithreading.py @@ -2,128 +2,18 @@ """ Multithreading/processing utils for faceswap """ import logging -import multiprocessing as mp -from multiprocessing.sharedctypes import RawArray -from ctypes import c_float +from multiprocessing import cpu_count import queue as Queue import sys -import os import threading -from lib.logger import LOG_QUEUE, set_root_logger logger = logging.getLogger(__name__) # pylint: disable=invalid-name -_launched_processes = set() # pylint: disable=invalid-name def total_cpus(): """ Return total number of cpus """ - return mp.cpu_count() - - -class PoolProcess(): - """ Pool multiple processes """ - def __init__(self, method, in_queue, out_queue, *args, processes=None, **kwargs): - self._name = method.__qualname__ - logger.debug("Initializing %s: (target: '%s', processes: %s)", - self.__class__.__name__, self._name, processes) - - self.procs = self.set_procs(processes) - ctx = mp.get_context("spawn") - self.pool = ctx.Pool(processes=self.procs, - initializer=set_root_logger, - initargs=(logger.getEffectiveLevel(), LOG_QUEUE)) - self._method = method - self._kwargs = self.build_target_kwargs(in_queue, out_queue, kwargs) - self._args = args - - logger.debug("Initialized %s: '%s'", self.__class__.__name__, self._name) - - @staticmethod - def build_target_kwargs(in_queue, out_queue, kwargs): - """ Add standard kwargs to passed in kwargs list """ - kwargs["in_queue"] = in_queue - kwargs["out_queue"] = out_queue - return kwargs - - def set_procs(self, processes): - """ Set the number of processes to use """ - processes = mp.cpu_count() if processes is None else processes - running_processes = len(mp.active_children()) - avail_processes = max(mp.cpu_count() - running_processes, 1) - processes = min(avail_processes, processes) - logger.verbose("Processing '%s' in %s processes", self._name, processes) - return processes - - def start(self): - """ Run the processing pool """ - logging.debug("Pooling Processes: (target: '%s', args: %s, kwargs: %s)", - self._name, self._args, self._kwargs) - for idx in range(self.procs): - logger.debug("Adding process %s of %s to mp.Pool '%s'", - idx + 1, self.procs, self._name) - self.pool.apply_async(self._method, args=self._args, kwds=self._kwargs) - _launched_processes.add(self.pool) - logging.debug("Pooled Processes: '%s'", self._name) - - def join(self): - """ Join the process """ - logger.debug("Joining Pooled Process: '%s'", self._name) - self.pool.close() - self.pool.join() - _launched_processes.remove(self.pool) - logger.debug("Joined Pooled Process: '%s'", self._name) - - -class SpawnProcess(mp.context.SpawnProcess): - """ Process in spawnable context - Must be spawnable to share CUDA across processes """ - def __init__(self, target, in_queue, out_queue, *args, **kwargs): - name = target.__qualname__ - logger.debug("Initializing %s: (target: '%s', args: %s, kwargs: %s)", - self.__class__.__name__, name, args, kwargs) - ctx = mp.get_context("spawn") - self.event = ctx.Event() - self.error = ctx.Event() - kwargs = self.build_target_kwargs(in_queue, out_queue, kwargs) - super().__init__(target=target, name=name, args=args, kwargs=kwargs) - self.daemon = True - logger.debug("Initialized %s: '%s'", self.__class__.__name__, name) - - def build_target_kwargs(self, in_queue, out_queue, kwargs): - """ Add standard kwargs to passed in kwargs list """ - kwargs["event"] = self.event - kwargs["error"] = self.error - kwargs["log_init"] = set_root_logger - kwargs["log_queue"] = LOG_QUEUE - kwargs["log_level"] = logger.getEffectiveLevel() - kwargs["in_queue"] = in_queue - kwargs["out_queue"] = out_queue - return kwargs - - def run(self): - """ Add logger to spawned process """ - logger_init = self._kwargs["log_init"] - log_queue = self._kwargs["log_queue"] - log_level = self._kwargs["log_level"] - logger_init(log_level, log_queue) - super().run() - - def start(self): - """ Add logging to start function """ - logger.debug("Spawning Process: (name: '%s', args: %s, kwargs: %s, daemon: %s)", - self._name, self._args, self._kwargs, self.daemon) - super().start() - _launched_processes.add(self) - logger.debug("Spawned Process: (name: '%s', PID: %s)", self._name, self.pid) - - def join(self, timeout=None): - """ Add logging to join function """ - logger.debug("Joining Process: (name: '%s', PID: %s)", self._name, self.pid) - super().join(timeout=timeout) - if self in _launched_processes: - _launched_processes.remove(self) - logger.debug("Joined Process: (name: '%s', PID: %s)", self._name, self.pid) + return cpu_count() class FSThread(threading.Thread): @@ -180,6 +70,11 @@ def errors(self): """ Return a list of thread errors """ return [thread.err for thread in self._threads if thread.err] + @property + def name(self): + """ Return thread name """ + return self._name + def check_and_raise_error(self): """ Checks for errors in thread and raises them in caller """ if not self.has_error: @@ -223,6 +118,7 @@ class BackgroundGenerator(MultiThread): # See below why prefetch count is flawed def __init__(self, generator, prefetch=1, thread_count=2, queue=None, args=None, kwargs=None): + # pylint:disable=too-many-arguments super().__init__(target=self._run, thread_count=thread_count) self.queue = queue or Queue.Queue(prefetch) self.generator = generator @@ -252,22 +148,3 @@ def iterator(self): logger.debug("Got EOF OR NONE in BackgroundGenerator") break yield next_item - - -def terminate_processes(): - """ Join all active processes on unexpected shutdown - - If the process is doing long running work, make sure you - have a mechanism in place to terminate this work to avoid - long blocks - """ - - logger.debug("Processes to join: %s", [process - for process in _launched_processes - if isinstance(process, mp.pool.Pool) - or process.is_alive()]) - for process in list(_launched_processes): - if isinstance(process, mp.pool.Pool): - process.terminate() - if isinstance(process, mp.pool.Pool) or process.is_alive(): - process.join() diff --git a/lib/queue_manager.py b/lib/queue_manager.py index 9baaab2111..47b842e34c 100644 --- a/lib/queue_manager.py +++ b/lib/queue_manager.py @@ -5,8 +5,6 @@ a multiprocess on a Windows System it will break Faceswap""" import logging -import multiprocessing as mp -import sys import threading from queue import Queue, Empty as QueueEmpty # pylint: disable=unused-import; # noqa @@ -22,22 +20,11 @@ class QueueManager(): def __init__(self): logger.debug("Initializing %s", self.__class__.__name__) - # Hacky fix to stop multiprocessing spawning managers in child processes - if mp.current_process().name == "MainProcess": - # Use a Multiprocessing manager in main process - self.manager = mp.Manager() - else: - # Use a standard mp.queue in child process. NB: This will never be used - # but spawned processes will load this module, so we need to dummy in a queue - self.manager = mp - self.shutdown = self.manager.Event() + self.shutdown = threading.Event() self.queues = dict() - # Despite launching a subprocess, the scripts still want to access the same logging - # queue as the GUI, so make sure the GUI gets it's own queue - self._log_queue = self.manager.Queue() if "gui" not in sys.argv else mp.Queue() logger.debug("Initialized %s", self.__class__.__name__) - def add_queue(self, name, maxsize=0, multiprocessing_queue=True): + def add_queue(self, name, maxsize=0): """ Add a queue to the manager Adds an event "shutdown" to the queue that can be used to indicate @@ -47,10 +34,7 @@ def add_queue(self, name, maxsize=0, multiprocessing_queue=True): if name in self.queues.keys(): raise ValueError("Queue '{}' already exists.".format(name)) - if multiprocessing_queue: - queue = self.manager.Queue(maxsize=maxsize) - else: - queue = Queue(maxsize=maxsize) + queue = Queue(maxsize=maxsize) setattr(queue, "shutdown", self.shutdown) self.queues[name] = queue @@ -62,13 +46,13 @@ def del_queue(self, name): del self.queues[name] logger.debug("QueueManager deleted: '%s'", name) - def get_queue(self, name, maxsize=0, multiprocessing_queue=True): + def get_queue(self, name, maxsize=0): """ Return a queue from the manager If it doesn't exist, create it """ logger.debug("QueueManager getting: '%s'", name) queue = self.queues.get(name, None) if not queue: - self.add_queue(name, maxsize, multiprocessing_queue) + self.add_queue(name, maxsize) queue = self.queues[name] logger.debug("QueueManager got: '%s'", name) return queue @@ -109,6 +93,7 @@ def debug_queue_sizes(self, update_secs): logged to INFO so it also displays in console """ while True: + logger.info("====================================================") for name in sorted(self.queues.keys()): logger.info("%s: %s", name, self.queues[name].qsize()) sleep(update_secs) diff --git a/lib/utils.py b/lib/utils.py index 49143af267..f0340f91eb 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -49,6 +49,9 @@ def get_config_file(): def get_backend(self): """ Return the backend from config/.faceswap """ + # Intercept for sphinx docs build + if sys.argv[0].endswith("sphinx-build"): + return "nvidia" if not os.path.isfile(self.config_file): self.configure_backend() while True: @@ -349,18 +352,18 @@ def rotate_landmarks(face, rotation_matrix): # pylint:disable=c-extension-no-member """ Rotate the landmarks and bounding box for faces found in rotated images. - Pass in a DetectedFace object, Alignments dict or bounding box dict - (as defined in lib/plugins/extract/detect/_base.py) """ + Pass in a DetectedFace object or Alignments dict """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name logger.trace("Rotating landmarks: (rotation_matrix: %s, type(face): %s", rotation_matrix, type(face)) + rotated_landmarks = None # Detected Face Object if isinstance(face, DetectedFace): bounding_box = [[face.x, face.y], [face.x + face.w, face.y], [face.x + face.w, face.y + face.h], [face.x, face.y + face.h]] - landmarks = face.landmarksXY + landmarks = face.landmarks_xy # Alignments Dict elif isinstance(face, dict) and "x" in face: @@ -371,15 +374,7 @@ def rotate_landmarks(face, rotation_matrix): face.get("y", 0) + face.get("h", 0)], [face.get("x", 0), face.get("y", 0) + face.get("h", 0)]] - landmarks = face.get("landmarksXY", list()) - - # Bounding Box Dict - elif isinstance(face, dict) and "left" in face: - bounding_box = [[face["left"], face["top"]], - [face["right"], face["top"]], - [face["right"], face["bottom"]], - [face["left"], face["bottom"]]] - landmarks = list() + landmarks = face.get("landmarks_xy", list()) else: raise ValueError("Unsupported face type") @@ -415,16 +410,7 @@ def rotate_landmarks(face, rotation_matrix): face.r = 0 if len(rotated) > 1: rotated_landmarks = [tuple(point) for point in rotated[1].tolist()] - face.landmarksXY = rotated_landmarks - elif isinstance(face, dict) and "x" in face: - face["x"] = int(pt_x) - face["y"] = int(pt_y) - face["w"] = int(width) - face["h"] = int(height) - face["r"] = 0 - if len(rotated) > 1: - rotated_landmarks = [tuple(point) for point in rotated[1].tolist()] - face["landmarksXY"] = rotated_landmarks + face.landmarks_xy = rotated_landmarks else: face["left"] = int(pt_x) face["top"] = int(pt_y) @@ -450,14 +436,8 @@ def safe_shutdown(got_error=False): logger = logging.getLogger(__name__) # pylint:disable=invalid-name logger.debug("Safely shutting down") from lib.queue_manager import queue_manager - from lib.multithreading import terminate_processes queue_manager.terminate_queues() - terminate_processes() logger.debug("Cleanup complete. Shutting down queue manager and exiting") - queue_manager._log_queue.put(None) # pylint:disable=protected-access - while not queue_manager._log_queue.empty(): # pylint:disable=protected-access - continue - queue_manager.manager.shutdown() exit(1 if got_error else 0) diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py new file mode 100644 index 0000000000..8274801a9a --- /dev/null +++ b/plugins/extract/_base.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +""" Base class for Faceswap :mod:`~plugins.extract.detect` and :mod:`~plugins.extract.align` +Plugins +""" +import logging +import os +import sys + +import cv2 +import numpy as np + +from lib.multithreading import MultiThread +from lib.queue_manager import queue_manager +from lib.utils import GetModel +from ._config import Config + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + +# TODO Cpu mode +# TODO Run with warnings mode + + +def _get_config(plugin_name, configfile=None): + """ Return the config for the requested model + + Parameters + ---------- + plugin_name: str + The module name of the child plugin. + configfile: str, optional + Path to a :file:`./config/.ini` file for this plugin. Default: use system + config. + + Returns + ------- + config_dict, dict + A dictionary of configuration items from the config file + """ + return Config(plugin_name, configfile=configfile).config_dict + + +class Extractor(): + """ Extractor Plugin Object + + All ``_base`` classes for Aligners and Detectors inherit from this class. + + This class sets up a pipeline for working with ML plugins. + + Plugins are split into 3 threads, to utilize Numpy and CV2s parallel processing, as well as + allow the predict function of the model to sit in a dedicated thread. + A plugin is expected to have 3 core functions, each in their own thread: + - :func:`process_input()` - Prepare the data for feeding into a model + - :func:`predict` - Feed the data through the model + - :func:`process_output()` - Perform any data post-processing + + Parameters + ---------- + git_model_id: int + The second digit in the github tag that identifies this model. See + https://github.com/deepfakes-models/faceswap-models for more information + model_filename: str + The name of the model file to be loaded + + Other Parameters + ---------------- + configfile: str, optional + Path to a custom configuration ``ini`` file. Default: Use system configfile + + + The following attributes should be set in the plugin's :func:`__init__` method after + initializing the parent. + + Attributes + ---------- + name: str + Name of this plugin. Used for display purposes. + input_size: int + The input size to the model in pixels across one edge. The input size should always be + square. + colorformat: str + Color format for model. Must be ``'BGR'``, ``'RGB'`` or ``'GRAY'``. Defaults to ``'BGR'`` + if not explicitly set. + vram: int + Approximate VRAM used by the model at :attr:`input_size`. Used to calculate the + :attr:`batchsize`. Be conservative to avoid OOM. + vram_warnings: int + Approximate VRAM used by the model at :attr:`input_size` that will still run, but generates + warnings. Used to calculate the :attr:`batchsize`. Be conservative to avoid OOM. + vram_per_batch: int + Approximate additional VRAM used by the model for each additional batch. Used to calculate + the :attr:`batchsize`. Be conservative to avoid OOM. + + See Also + -------- + plugins.extract.detect._base : Detector parent class for extraction plugins. + plugins.extract.align._base : Aligner parent class for extraction plugins. + plugins.extract.pipeline : The extract pipeline that configures and calls all plugins + + """ + def __init__(self, git_model_id=None, model_filename=None, configfile=None): + logger.debug("Initializing %s: (git_model_id: %s, model_filename: %s, " + " configfile: %s)", self.__class__.__name__, git_model_id, + model_filename, configfile) + + self.config = _get_config(".".join(self.__module__.split(".")[-2:]), configfile=configfile) + """ dict: Config for this plugin, loaded from ``extract.ini`` configfile """ + + self.model_path = self._get_model(git_model_id, model_filename) + """ str or list: Path to the model file(s) (if required). Multiple model files should + be a list of strings """ + + # << SET THE FOLLOWING IN PLUGINS __init__ IF DIFFERENT FROM DEFAULT >> # + self.name = None + self.input_size = None + self.colorformat = "BGR" + self.vram = None + self.vram_warnings = None # Will run at this with warnings + self.vram_per_batch = None + + # << THE FOLLOWING ARE SET IN self.initialize METHOD >> # + self.queue_size = 32 + """ int: Queue size for all internal queues. Set in :func:`initialize()` """ + + self.model = None + """varies: The model for this plugin. + Set in the plugin's :func:`init_model()` method """ + + # For detectors that support batching, this should be set to the calculated batch size + # that the amount of available VRAM will support. + self.batchsize = 1 + """ int: Batchsize for feeding this model. The number of images the model should + feed through at once. """ + + self._queues = dict() + """ dict: in + out queues and internal queues for this plugin, """ + + self._threads = [] + """ list: Internal threads for this plugin """ + + # << THE FOLLOWING PROTECTED ATTRIBUTES ARE SET IN PLUGIN TYPE _base.py >>> # + self._plugin_type = None + """ str: Plugin type. ``detect`` or ``align`` + set in ``._base`` """ + + logger.debug("Initialized _base %s", self.__class__.__name__) + + # <<< OVERIDABLE METHODS >>> # + def init_model(self): + """ **Override method** + + Override this method to execute the specific model initialization method """ + raise NotImplementedError + + def process_input(self, batch): + """ **Override method** + + Override this method for specific extractor pre-processing of image + + Parameters + ---------- + batch : dict + Contains the batch that is currently being passed through the plugin process + + Notes + ----- + When preparing an input to the model a key ``feed`` must be added + to the :attr:`batch` ``dict`` which contains this input. + """ + raise NotImplementedError + + def predict(self, batch): + """ **Override method** + + Override this method for specific extractor model prediction function + + Parameters + ---------- + batch : dict + Contains the batch that is currently being passed through the plugin process + + Notes + ----- + Input for :func:`predict` should have been set in :func:`process_input` with the addition + of a ``feed`` key to the :attr:`batch` ``dict``. + + Output from the model should add the key ``prediction`` to the :attr:`batch` ``dict``. + + For Detect: + the expected output for the ``prediction`` key of the :attr:`batch` dict should be a + ``list`` of :attr:`batchsize` of detected face points. These points should be either + a ``list``, ``tuple`` or ``numpy.array`` with the first 4 items being the `left`, + `top`, `right`, `bottom` points, in that order + """ + raise NotImplementedError + + def process_output(self, batch): + """ **Override method** + + Override this method for specific extractor model post predict function + + Parameters + ---------- + batch : dict + Contains the batch that is currently being passed through the plugin process + + Notes + ----- + For Align: + The key ``landmarks`` must be returned in the :attr:`batch` ``dict`` from this method. + This should be a ``list`` or ``numpy.array`` of :attr:`batchsize` containing a + ``list``, ``tuple`` or ``numpy.array`` of `(x, y)` co-ords of the 68 point landmarks + as calculated from the :attr:`model`. + """ + raise NotImplementedError + + def _predict(self, batch): + """ **Override method** (at `` level) + + This method is overridable at the `` level (ie. + ``plugins.extract.detect._base`` or ``plugins.extract.align._base``) and should not + be overriden within plugins themselves. + + It acts as a wrapper for the plugin's ``self.predict`` method and handles any + predict processing that is consistent for all plugins within the `plugin_type` + + Parameters + ---------- + batch : dict + Contains the batch that is currently being passed through the plugin process + """ + raise NotImplementedError + + def finalize(self, batch): + """ **Override method** (at `` level) + + This method is overridable at the `` level (ie. + :mod:`plugins.extract.detect._base` or :mod:`plugins.extract.align._base`) and should not + be overriden within plugins themselves. + + Handles consistent finalization for all plugins that exist within that plugin type. Its + input is always the output from :func:`process_output()` + + Parameters + ---------- + batch : dict + Contains the batch that is currently being passed through the plugin process + + """ + + def get_batch(self, queue): + """ **Override method** (at `` level) + + This method is overridable at the `` level (ie. + :mod:`plugins.extract.detect._base` or :mod:`plugins.extract.align._base`) and should not + be overriden within plugins themselves. + + Get items from the queue in batches of :attr:`batchsize` + + Parameters + ---------- + queue : queue.Queue() + The ``queue`` that the batch will be fed from. This will be the input to the plugin. + """ + raise NotImplementedError + + # <<< THREADING METHODS >>> # + def start(self): + """ Start all threads + + Exposed for :mod:`~plugins.extract.pipeline` to start plugin's threads + """ + for thread in self._threads: + thread.start() + + def join(self): + """ Join all threads + + Exposed for :mod:`~plugins.extract.pipeline` to join plugin's threads + """ + for thread in self._threads: + thread.join() + del thread + + def check_and_raise_error(self): + """ Check all threads for errors + + Exposed for :mod:`~plugins.extract.pipeline` to check plugin's threads for errors + """ + for thread in self._threads: + err = thread.check_and_raise_error() + if err is not None: + logger.debug("thread_error_detected") + return True + return False + + # <<< PROTECTED ACCESS METHODS >>> # + # <<< INIT METHODS >>> # + def _get_model(self, git_model_id, model_filename): + """ Check if model is available, if not, download and unzip it """ + if model_filename is None: + logger.debug("No model_filename specified. Returning None") + return None + if git_model_id is None: + logger.debug("No git_model_id specified. Returning None") + return None + plugin_path = os.path.join(*self.__module__.split(".")[:-1]) + if os.path.basename(plugin_path) in ("detect", "align"): + base_path = os.path.dirname(os.path.realpath(sys.argv[0])) + cache_path = os.path.join(base_path, plugin_path, ".cache") + else: + cache_path = os.path.join(os.path.dirname(__file__), ".cache") + model = GetModel(model_filename, cache_path, git_model_id) + return model.model_path + + # <<< PLUGIN INITIALIZATION >>> # + def initialize(self, *args, **kwargs): + """ Inititalize the extractor plugin + + Should be called from :mod:`~plugins.extract.pipeline` + """ + logger.debug("initialize %s: (args: %s, kwargs: %s)", + self.__class__.__name__, args, kwargs) + p_type = "Detector" if self._plugin_type == "detect" else "Aligner" + logger.info("Initializing %s %s...", self.name, p_type) + self.queue_size = kwargs["queue_size"] + self._add_queues(kwargs["in_queue"], kwargs["out_queue"], ["predict", "post"]) + self._compile_threads() + self.init_model() + logger.info("Initialized %s %s with batchsize of %s", self.name, p_type, self.batchsize) + + def _add_queues(self, in_queue, out_queue, queues): + """ Add the queues + in_queue and out_queue should be pre-created queue manager queues + queues should be a list of queue names """ + self._queues["in"] = in_queue + self._queues["out"] = out_queue + for q_name in queues: + self._queues[q_name] = queue_manager.get_queue( + name="{}_{}".format(self._plugin_type, q_name), + maxsize=self.queue_size) + + # <<< THREAD METHODS >>> # + def _compile_threads(self): + """ Compile the threads into self._threads list """ + logger.debug("Compiling %s threads", self._plugin_type) + self._add_thread("{}_input".format(self._plugin_type), + self.process_input, + self._queues["in"], + self._queues["predict"]) + self._add_thread("{}_predict".format(self._plugin_type), + self._predict, + self._queues["predict"], + self._queues["post"]) + self._add_thread("{}_output".format(self._plugin_type), + self.process_output, + self._queues["post"], + self._queues["out"]) + logger.debug("Compiled %s threads: %s", self._plugin_type, self._threads) + + def _add_thread(self, name, function, in_queue, out_queue): + """ Add a MultiThread thread to self._threads """ + logger.debug("Adding thread: (name: %s, function: %s, in_queue: %s, out_queue: %s)", + name, function, in_queue, out_queue) + self._threads.append(MultiThread(target=self._thread_process, + name=name, + function=function, + in_queue=in_queue, + out_queue=out_queue)) + logger.debug("Added thread: %s", name) + + def _thread_process(self, function, in_queue, out_queue): + """ Perform a plugin function in a thread """ + func_name = function.__name__ + logger.debug("threading: (function: '%s')", func_name) + while True: + if func_name == "process_input": + # Process input items to batches + exhausted, batch = self.get_batch(in_queue) + if exhausted: + if batch: + # Put the final batch + batch = function(batch) + out_queue.put(batch) + break + else: + batch = self._get_item(in_queue) + if batch == "EOF": + break + batch = function(batch) + if func_name == "process_output": + # Process output items to individual items from batch + for item in self.finalize(batch): + out_queue.put(item) + else: + out_queue.put(batch) + logger.debug("Putting EOF") + out_queue.put("EOF") + + # <<< QUEUE METHODS >>> # + @staticmethod + def _get_item(queue): + """ Yield one item from a queue """ + item = queue.get() + if isinstance(item, dict): + logger.trace("item: %s, queue: %s", + {k: v.shape if isinstance(v, np.ndarray) else v + for k, v in item.items()}, + queue) + else: + logger.trace("item: %s, queue: %s", item, queue) + return item + + # <<< MISC UTILITY METHODS >>> # + def _convert_color(self, image): + """ Convert the image to the correct color format """ + logger.trace("Converting image to color format: %s", self.colorformat) + if self.colorformat == "RGB": + cvt_image = image[:, :, ::-1].copy() + elif self.colorformat == "GRAY": + cvt_image = cv2.cvtColor(image.copy(), cv2.COLOR_BGR2GRAY) # pylint:disable=no-member + else: + cvt_image = image.copy() + return cvt_image + + @staticmethod + def _dict_lists_to_list_dicts(dictionary): + """ Convert a dictionary of lists to a list of dictionaries """ + return [dict(zip(dictionary, val)) for val in zip(*dictionary.values())] + + @staticmethod + def _remove_invalid_keys(dictionary, valid_keys): + """ Remove items from dict that are no longer required """ + for key in list(dictionary.keys()): + if key not in valid_keys: + logger.trace("Removing from output: '%s'", key) + del dictionary[key] diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index 3142cf54b7..c552741335 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -1,189 +1,231 @@ #!/usr/bin/env python3 """ Base class for Face Aligner plugins - Plugins should inherit from this class - See the override methods for which methods are - required. +All Aligner Plugins should inherit from this class. +See the override methods for which methods are required. - The plugin will receive a dict containing: - {"filename": , - "image": , - "detected_faces": } +The plugin will receive a dict containing: - For each source item, the plugin must pass a dict to finalize containing: - {"filename": , - "image": , - "detected_faces": , - "landmarks": } - """ +>>> {"filename": [], +>>> "image": [], +>>> "detected_faces": [>> {"filename": [], +>>> "image": [], +>>> "landmarks": [list of 68 point face landmarks] +>>> "detected_faces": []} +""" -from io import StringIO import cv2 +import numpy as np -from lib.aligner import Extract -from lib.gpu_stats import GPUStats -from lib.utils import GetModel +from plugins.extract._base import Extractor, logger -logger = logging.getLogger(__name__) # pylint:disable=invalid-name +class Aligner(Extractor): + """ Aligner plugin _base Object -class Aligner(): - """ Landmarks Aligner Object """ - def __init__(self, loglevel, configfile=None, normalize_method=None, - git_model_id=None, model_filename=None, colorspace="BGR", input_size=256): - logger.debug("Initializing %s: (loglevel: %s, configfile: %s, normalize_method: %s, " - "git_model_id: %s, model_filename: '%s', colorspace: '%s'. input_size: %s)", - self.__class__.__name__, loglevel, configfile, normalize_method, git_model_id, - model_filename, colorspace, input_size) - self.loglevel = loglevel - self.normalize_method = normalize_method - self.colorspace = colorspace.upper() - self.input_size = input_size - self.extract = Extract() - self.init = None - self.error = None + All Aligner plugins must inherit from this class + + Parameters + ---------- + git_model_id: int + The second digit in the github tag that identifies this model. See + https://github.com/deepfakes-models/faceswap-models for more information + model_filename: str + The name of the model file to be loaded + normalize_method: {`None`, 'clahe', 'hist', 'mean'}, optional + Normalize the images fed to the aligner. Default: ``None`` - # The input and output queues for the plugin. - # See lib.queue_manager.QueueManager for getting queues - self.queues = {"in": None, "out": None} + Other Parameters + ---------------- + configfile: str, optional + Path to a custom configuration ``ini`` file. Default: Use system configfile - # Get model if required - self.model_path = self.get_model(git_model_id, model_filename) + See Also + -------- + plugins.extract.align : Aligner plugins + plugins.extract._base : Parent class for all extraction plugins + plugins.extract.detect._base : Detector parent class for extraction plugins. - # Approximate VRAM required for aligner. Used to calculate - # how many parallel processes / batches can be run. - # Be conservative to avoid OOM. - self.vram = None + """ - # Set to true if the plugin supports PlaidML - self.supports_plaidml = False + def __init__(self, git_model_id, model_filename, + configfile=None, normalize_method=None): + logger.debug("Initializing %s: (normalize_method: %s)", self.__class__.__name__, + normalize_method) + super().__init__(git_model_id, + model_filename, + configfile=configfile) + self.normalize_method = normalize_method + self._plugin_type = "align" + self._faces_per_filename = dict() # Tracking for recompiling face batches + self._rollover = [] # Items that are rolled over from the previous batch in get_batch + self._output_faces = [] logger.debug("Initialized %s", self.__class__.__name__) - # <<< OVERRIDE METHODS >>> # - # These methods must be overriden when creating a plugin - def initialize(self, *args, **kwargs): - """ Inititalize the aligner - Tasks to be run before any alignments are performed. - Override for specific detector """ - logger.debug("_base initialize %s: (PID: %s, args: %s, kwargs: %s)", - self.__class__.__name__, os.getpid(), args, kwargs) - self.init = kwargs["event"] - self.error = kwargs["error"] - self.queues["in"] = kwargs["in_queue"] - self.queues["out"] = kwargs["out_queue"] - - def align_image(self, detected_face, image): - """ Align the incoming image for feeding into aligner - Override for aligner specific processing """ - raise NotImplementedError - - def predict_landmarks(self, feed_dict): - """ Predict the 68 point landmarks - Override for aligner specific landmark prediction """ - raise NotImplementedError - - # <<< GET MODEL >>> # - @staticmethod - def get_model(git_model_id, model_filename): - """ Check if model is available, if not, download and unzip it """ - if model_filename is None: - logger.debug("No model_filename specified. Returning None") - return None - if git_model_id is None: - logger.debug("No git_model_id specified. Returning None") - return None - cache_path = os.path.join(os.path.dirname(__file__), ".cache") - model = GetModel(model_filename, cache_path, git_model_id) - return model.model_path - - # <<< ALIGNMENT WRAPPER >>> # - def run(self, *args, **kwargs): - """ Parent align process. - This should always be called as the entry point so exceptions - are passed back to parent. - Do not override """ - try: - self.align(*args, **kwargs) - except Exception: # pylint:disable=broad-except - logger.error("Caught exception in child process: %s", os.getpid()) - # Display traceback if in initialization stage - if not self.init.is_set(): - logger.exception("Traceback:") - tb_buffer = StringIO() - traceback.print_exc(file=tb_buffer) - exception = {"exception": (os.getpid(), tb_buffer)} - self.queues["out"].put(exception) - exit(1) - - def align(self, *args, **kwargs): - """ Process landmarks """ - if not self.init: - self.initialize(*args, **kwargs) - logger.debug("Launching Align: (args: %s kwargs: %s)", args, kwargs) - - for item in self.get_item(): + # << QUEUE METHODS >>> # + def get_batch(self, queue): + """ Get items for inputting into the aligner from the queue in batches + + Items are returned from the ``queue`` in batches of + :attr:`~plugins.extract._base.Extractor.batchsize` + + To ensure consistent batchsizes for aligner the items are split into separate items for + each :class:`lib.faces_detect.DetectedFace` object. + + Remember to put ``'EOF'`` to the out queue after processing + the final batch + + Outputs items in the following format. All lists are of length + :attr:`~plugins.extract._base.Extractor.batchsize`: + + >>> {'filename': [], + >>> 'image': [], + >>> 'detected_faces': [[>> # + def finalize(self, batch): + """ Finalize the output from Aligner + + This should be called as the final task of each `plugin`. + + It strips unneeded items from the :attr:`batch` ``dict`` and pairs the detected faces back + up with their original frame before yielding each frame. + + Outputs items in the format: + + >>> {'image': [], + >>> 'filename': [), + >>> 'detected_faces': []} + + Parameters + ---------- + batch : dict + The final ``dict`` from the `plugin` process. It must contain the `keys`: + ``detected_faces``, ``landmarks``, ``filename``, ``image`` + + Yields + ------ + dict + A ``dict`` for each frame containing the ``image``, ``filename`` and list of + :class:`lib.faces_detect.DetectedFace` objects. + + """ + + for face, landmarks in zip(batch["detected_faces"], batch["landmarks"]): + face.landmarks_xy = [(int(round(pt[0])), int(round(pt[1]))) for pt in landmarks] + + self._remove_invalid_keys(batch, ("detected_faces", "filename", "image")) + logger.trace("Item out: %s", {key: val + for key, val in batch.items() + if key != "image"}) + for filename, image, face in zip(batch["filename"], + batch["image"], + batch["detected_faces"]): + self._output_faces.append(face) + if len(self._output_faces) != self._faces_per_filename[filename]: + continue + retval = dict(filename=filename, image=image, detected_faces=self._output_faces) + + self._output_faces = [] + logger.trace("Yielding: (filename: '%s', image: %s, detected_faces: %s)", + retval["filename"], retval["image"].shape, len(retval["detected_faces"])) + yield retval + + # <<< PROTECTED METHODS >>> # + # <<< PREDICT WRAPPER >>> # + def _predict(self, batch): + """ Just return the aligner's predict function """ + return self.predict(batch) # <<< FACE NORMALIZATION METHODS >>> # - def normalize_face(self, feed_dict): - """ Normalize the face for feeding into model """ + def _normalize_faces(self, faces): + """ Normalizes the face for feeding into model + + The normalization method is dictated by the cli argument: + -nh (--normalization) + """ if self.normalize_method is None: - return - logger.trace("Normalizing face") - meth = getattr(self, "normalize_{}".format(self.normalize_method.lower())) - feed_dict["image"] = meth(feed_dict["image"]) - logger.trace("Normalized face") + return faces + logger.trace("Normalizing faces") + meth = getattr(self, "_normalize_{}".format(self.normalize_method.lower())) + faces = [meth(face) for face in faces] + logger.trace("Normalized faces") + return faces @staticmethod - def normalize_mean(face): + def _normalize_mean(face): """ Normalize Face to the Mean """ face = face / 255.0 for chan in range(3): @@ -193,59 +235,17 @@ def normalize_mean(face): return face * 255.0 @staticmethod - def normalize_hist(face): + def _normalize_hist(face): """ Equalize the RGB histogram channels """ for chan in range(3): face[:, :, chan] = cv2.equalizeHist(face[:, :, chan]) # pylint: disable=no-member return face @staticmethod - def normalize_clahe(face): + def _normalize_clahe(face): """ Perform Contrast Limited Adaptive Histogram Equalization """ clahe = cv2.createCLAHE(clipLimit=2.0, # pylint: disable=no-member tileGridSize=(4, 4)) for chan in range(3): face[:, :, chan] = clahe.apply(face[:, :, chan]) return face - - # <<< FINALIZE METHODS >>> # - def finalize(self, output): - """ This should be called as the final task of each plugin - aligns faces and puts to the out queue """ - if output == "EOF": - logger.trace("Item out: %s", output) - self.queues["out"].put("EOF") - return - logger.trace("Item out: %s", {key: val - for key, val in output.items() - if key != "image"}) - self.queues["out"].put((output)) - - # <<< MISC METHODS >>> # - def get_vram_free(self): - """ Return free and total VRAM on card with most VRAM free""" - stats = GPUStats() - vram = stats.get_card_most_free(supports_plaidml=self.supports_plaidml) - logger.verbose("Using device %s with %sMB free of %sMB", - vram["device"], - int(vram["free"]), - int(vram["total"])) - return int(vram["card_id"]), int(vram["free"]), int(vram["total"]) - - def get_item(self): - """ Yield one item from the queue """ - while True: - item = self.queues["in"].get() - if isinstance(item, dict): - logger.trace("Item in: %s", {key: val - for key, val in item.items() - if key != "image"}) - # Pass Detector failures straight out and quit - if item.get("exception", None): - self.queues["out"].put(item) - exit(1) - else: - logger.trace("Item in: %s", item) - yield item - if item == "EOF": - break diff --git a/plugins/extract/align/cv2_dnn.py b/plugins/extract/align/cv2_dnn.py index ecf3dcf10d..919e35ea60 100644 --- a/plugins/extract/align/cv2_dnn.py +++ b/plugins/extract/align/cv2_dnn.py @@ -35,60 +35,57 @@ class Align(Aligner): def __init__(self, **kwargs): git_model_id = 1 model_filename = "cnn-facial-landmark_v1.pb" - super().__init__(git_model_id=git_model_id, - model_filename=model_filename, - colorspace="RGB", - input_size=128, - **kwargs) + super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + + self.name = "cv2-DNN Aligner" + self.input_size = 128 + self.colorformat = "RGB" self.vram = 0 # Doesn't use GPU - self.model = None - - def initialize(self, *args, **kwargs): - """ Initialization tasks to run prior to alignments """ - try: - super().initialize(*args, **kwargs) - logger.info("Initializing cv2 DNN Aligner...") - logger.debug("cv2 DNN initialize: (args: %s kwargs: %s)", args, kwargs) - logger.verbose("Using CPU for alignment") - - self.model = cv2.dnn.readNetFromTensorflow( # pylint: disable=no-member - self.model_path) - self.model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) # pylint: disable=no-member - self.init.set() - logger.info("Initialized cv2 DNN Aligner.") - except Exception as err: - self.error.set() - raise err - - def align_image(self, detected_face, image): + self.batchsize = 1 + + def init_model(self): + """ Initialize CV2 DNN Detector Model""" + self.model = cv2.dnn.readNetFromTensorflow(self.model_path) # pylint: disable=no-member + self.model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) # pylint: disable=no-member + + def process_input(self, batch): + """ Compile the detected faces for prediction """ + faces, batch["roi"] = self.align_image(batch["detected_faces"]) + faces = self._normalize_faces(faces) + batch["feed"] = np.array(faces, dtype="float32").transpose((0, 3, 1, 2)) + return batch + + def align_image(self, detected_faces): """ Align the incoming image for prediction """ logger.trace("Aligning image around center") - - box = (detected_face["left"], - detected_face["top"], - detected_face["right"], - detected_face["bottom"]) - height = detected_face["bottom"] - detected_face["top"] - width = detected_face["right"] - detected_face["left"] - diff_height_width = height - width - offset_y = int(abs(diff_height_width / 2)) - box_moved = self.move_box(box, [0, offset_y]) - - # Make box square. - roi = self.get_square_box(box_moved) - # Pad the image if face is outside of boundaries - image = self.pad_image(roi, image) - face = image[roi[1]: roi[3], roi[0]: roi[2]] - - if face.shape[0] < self.input_size: - interpolation = cv2.INTER_CUBIC # pylint:disable=no-member - else: - interpolation = cv2.INTER_AREA # pylint:disable=no-member - - face = cv2.resize(face, # pylint:disable=no-member - dsize=(int(self.input_size), int(self.input_size)), - interpolation=interpolation) - return dict(image=face, roi=roi) + rois = [] + faces = [] + for face in detected_faces: + box = (face.left, + face.top, + face.right, + face.bottom) + diff_height_width = face.h - face.w + offset_y = int(abs(diff_height_width / 2)) + box_moved = self.move_box(box, [0, offset_y]) + + # Make box square. + roi = self.get_square_box(box_moved) + # Pad the image if face is outside of boundaries + image = self.pad_image(roi, face.image) + face = image[roi[1]: roi[3], roi[0]: roi[2]] + + if face.shape[0] < self.input_size: + interpolation = cv2.INTER_CUBIC # pylint:disable=no-member + else: + interpolation = cv2.INTER_AREA # pylint:disable=no-member + + face = cv2.resize(face, # pylint:disable=no-member + dsize=(int(self.input_size), int(self.input_size)), + interpolation=interpolation) + faces.append(face) + rois.append(roi) + return faces, rois @staticmethod def move_box(box, offset): @@ -159,24 +156,27 @@ def pad_image(box, image): logger.trace("Padded shape: %s", retval.shape) return retval - def predict_landmarks(self, feed_dict): + def predict(self, batch): """ Predict the 68 point landmarks """ logger.trace("Predicting Landmarks") - image = np.expand_dims(np.transpose(feed_dict["image"], (2, 0, 1)), 0).astype("float32") - self.model.setInput(image) - prediction = self.model.forward() - pts_img = self.get_pts_from_predict(prediction, feed_dict["roi"]) - return pts_img + self.model.setInput(batch["feed"]) + batch["prediction"] = self.model.forward() + return batch + + def process_output(self, batch): + """ Process the output from the model """ + self.get_pts_from_predict(batch) + return batch @staticmethod - def get_pts_from_predict(prediction, roi): + def get_pts_from_predict(batch): """ Get points from predictor """ - logger.trace("Obtain points from prediction") - points = np.array(prediction).flatten() - points = np.reshape(points, (-1, 2)) - points *= (roi[2] - roi[0]) - points[:, 0] += roi[0] - points[:, 1] += roi[1] - retval = np.rint(points).astype("uint").tolist() - logger.trace("Predicted Landmarks: %s", retval) - return retval + for prediction, roi in zip(batch["prediction"], batch["roi"]): + points = np.array(prediction).flatten() + points = np.reshape(points, (-1, 2)) + points *= (roi[2] - roi[0]) + points[:, 0] += roi[0] + points[:, 1] += roi[1] + landmarks = np.rint(points).astype("uint").tolist() + batch.setdefault("landmarks", []).append(landmarks) + logger.trace("Predicted Landmarks: %s", batch["landmarks"]) diff --git a/plugins/extract/align/fan.py b/plugins/extract/align/fan.py index f9bc2a401d..0c0306efc9 100644 --- a/plugins/extract/align/fan.py +++ b/plugins/extract/align/fan.py @@ -5,122 +5,114 @@ """ import cv2 import numpy as np +import keras +from keras import backend as K +from lib.model.session import KSession from ._base import Aligner, logger class Align(Aligner): """ Perform transformation to align and get landmarks """ def __init__(self, **kwargs): - git_model_id = 0 - model_filename = "face-alignment-network_2d4_v1.pb" - super().__init__(git_model_id=git_model_id, - model_filename=model_filename, - colorspace="RGB", - input_size=256, - **kwargs) + git_model_id = 9 + model_filename = "face-alignment-network_2d4_keras_v1.h5" + super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.name = "FAN" + self.input_size = 256 + self.colorformat = "RGB" self.vram = 2240 - self.model = None + self.vram_warnings = 512 # Will run at this with warnings + self.vram_per_batch = 64 + self.batchsize = self.config["batch-size"] self.reference_scale = 195 - def initialize(self, *args, **kwargs): - """ Initialization tasks to run prior to alignments """ - try: - super().initialize(*args, **kwargs) - logger.info("Initializing Face Alignment Network...") - logger.debug("fan initialize: (args: %s kwargs: %s)", args, kwargs) - - _, _, vram_total = self.get_vram_free() - - if vram_total <= self.vram: - tf_ratio = 1.0 - else: - tf_ratio = self.vram / vram_total - logger.verbose("Reserving %sMB for face alignments", self.vram) - - self.model = FAN(self.model_path, ratio=tf_ratio) - - self.init.set() - logger.info("Initialized Face Alignment Network.") - except Exception as err: - self.error.set() - raise err - - # DETECTED FACE BOUNDING BOX PROCESSING - def align_image(self, detected_face, image): - """ Get center and scale, crop and align image around center """ - logger.trace("Aligning image around center") - center, scale = self.get_center_scale(detected_face) - image = self.crop(image, center, scale) + def init_model(self): + """ Initialize FAN model """ + model_kwargs = dict(custom_objects={'TorchBatchNorm2D': TorchBatchNorm2D}) + self.model = KSession(self.name, self.model_path, model_kwargs=model_kwargs) + self.model.load_model() + # Feed a placeholder so Aligner is primed for Manual tool + placeholder = np.zeros((self.batchsize, 3, self.input_size, self.input_size), + dtype="float32") + self.model.predict(placeholder) + + def process_input(self, batch): + """ Compile the detected faces for prediction """ + # TODO Batching + logger.trace("Aligning faces around center") + batch["center_scale"] = self.get_center_scale(batch["detected_faces"]) + faces = self.crop(batch) logger.trace("Aligned image around center") - return dict(image=image, center=center, scale=scale) + faces = self._normalize_faces(faces) + batch["feed"] = np.array(faces, dtype="float32").transpose((0, 3, 1, 2)) / 255.0 + return batch - def get_center_scale(self, detected_face): + def get_center_scale(self, detected_faces): """ Get the center and set scale of bounding box """ logger.trace("Calculating center and scale") - center = np.array([(detected_face["left"] + detected_face["right"]) / 2.0, - (detected_face["top"] + detected_face["bottom"]) / 2.0]) - - height = detected_face["bottom"] - detected_face["top"] - width = detected_face["right"] - detected_face["left"] - - center[1] -= height * 0.12 - - scale = (width + height) / self.reference_scale + l_center = [] + l_scale = [] + for face in detected_faces: + center = np.array([(face.left + face.right) / 2.0, (face.top + face.bottom) / 2.0]) + center[1] -= face.h * 0.12 + l_center.append(center) + l_scale.append((face.w + face.h) / self.reference_scale) + logger.trace("Calculated center and scale: %s, %s", l_center, l_scale) + return l_center, l_scale + + def crop(self, batch): # pylint:disable=too-many-locals + """ Crop image around the center point """ + logger.trace("Cropping images") + new_images = [] + for face, center, scale in zip(batch["detected_faces"], *batch["center_scale"]): + is_color = face.image.ndim > 2 + v_ul = self.transform([1, 1], center, scale, self.input_size).astype(np.int) + v_br = self.transform([self.input_size, self.input_size], + center, + scale, + self.input_size).astype(np.int) + if is_color: + new_dim = np.array([v_br[1] - v_ul[1], + v_br[0] - v_ul[0], + face.image.shape[2]], + dtype=np.int32) + new_img = np.zeros(new_dim, dtype=np.uint8) + else: + new_dim = np.array([v_br[1] - v_ul[1], + v_br[0] - v_ul[0]], + dtype=np.int) + new_img = np.zeros(new_dim, dtype=np.uint8) + height = face.image.shape[0] + width = face.image.shape[1] + new_x = np.array([max(1, -v_ul[0] + 1), min(v_br[0], width) - v_ul[0]], + dtype=np.int32) + new_y = np.array([max(1, -v_ul[1] + 1), + min(v_br[1], height) - v_ul[1]], + dtype=np.int32) + old_x = np.array([max(1, v_ul[0] + 1), min(v_br[0], width)], + dtype=np.int32) + old_y = np.array([max(1, v_ul[1] + 1), min(v_br[1], height)], + dtype=np.int32) + if is_color: + new_img[new_y[0] - 1:new_y[1], + new_x[0] - 1:new_x[1]] = face.image[old_y[0] - 1:old_y[1], + old_x[0] - 1:old_x[1], :] + else: + new_img[new_y[0] - 1:new_y[1], + new_x[0] - 1:new_x[1]] = face.image[old_y[0] - 1:old_y[1], + old_x[0] - 1:old_x[1]] - logger.trace("Calculated center and scale: %s, %s", center, scale) - return center, scale + if new_img.shape[0] < self.input_size: + interpolation = cv2.INTER_CUBIC # pylint:disable=no-member + else: + interpolation = cv2.INTER_AREA # pylint:disable=no-member - def crop(self, image, center, scale): # pylint:disable=too-many-locals - """ Crop image around the center point """ - logger.trace("Cropping image") - is_color = image.ndim > 2 - v_ul = self.transform([1, 1], center, scale, self.input_size).astype(np.int) - v_br = self.transform([self.input_size, self.input_size], - center, - scale, - self.input_size).astype(np.int) - if is_color: - new_dim = np.array([v_br[1] - v_ul[1], - v_br[0] - v_ul[0], - image.shape[2]], - dtype=np.int32) - new_img = np.zeros(new_dim, dtype=np.uint8) - else: - new_dim = np.array([v_br[1] - v_ul[1], - v_br[0] - v_ul[0]], - dtype=np.int) - new_img = np.zeros(new_dim, dtype=np.uint8) - height = image.shape[0] - width = image.shape[1] - new_x = np.array([max(1, -v_ul[0] + 1), min(v_br[0], width) - v_ul[0]], - dtype=np.int32) - new_y = np.array([max(1, -v_ul[1] + 1), - min(v_br[1], height) - v_ul[1]], - dtype=np.int32) - old_x = np.array([max(1, v_ul[0] + 1), min(v_br[0], width)], - dtype=np.int32) - old_y = np.array([max(1, v_ul[1] + 1), min(v_br[1], height)], - dtype=np.int32) - if is_color: - new_img[new_y[0] - 1:new_y[1], - new_x[0] - 1:new_x[1]] = image[old_y[0] - 1:old_y[1], - old_x[0] - 1:old_x[1], :] - else: - new_img[new_y[0] - 1:new_y[1], - new_x[0] - 1:new_x[1]] = image[old_y[0] - 1:old_y[1], - old_x[0] - 1:old_x[1]] - - if new_img.shape[0] < self.input_size: - interpolation = cv2.INTER_CUBIC # pylint:disable=no-member - else: - interpolation = cv2.INTER_AREA # pylint:disable=no-member - - new_img = cv2.resize(new_img, # pylint:disable=no-member - dsize=(int(self.input_size), int(self.input_size)), - interpolation=interpolation) - logger.trace("Cropped image") - return new_img + new_images.append(cv2.resize(new_img, # pylint:disable=no-member + dsize=(int(self.input_size), int(self.input_size)), + interpolation=interpolation)) + logger.trace("Cropped images") + return new_images @staticmethod def transform(point, center, scale, resolution): @@ -138,96 +130,120 @@ def transform(point, center, scale, resolution): logger.trace("Transformed Points: %s", retval) return retval - def predict_landmarks(self, feed_dict): + def predict(self, batch): """ Predict the 68 point landmarks """ logger.trace("Predicting Landmarks") - image = np.expand_dims( - feed_dict["image"].transpose((2, 0, 1)).astype(np.float32) / 255.0, 0) - prediction = self.model.predict(image)[-1] - pts_img = self.get_pts_from_predict(prediction, feed_dict["center"], feed_dict["scale"]) - retval = [(int(pt[0]), int(pt[1])) for pt in pts_img] - logger.trace("Predicted Landmarks: %s", retval) - return retval + batch["prediction"] = self.model.predict(batch["feed"])[-1] + logger.trace([pred.shape for pred in batch["prediction"]]) + return batch + + def process_output(self, batch): + """ Process the output from the model """ + self.get_pts_from_predict(batch) + return batch - def get_pts_from_predict(self, prediction, center, scale): + def get_pts_from_predict(self, batch): """ Get points from predictor """ logger.trace("Obtain points from prediction") - var_b = prediction.reshape((prediction.shape[0], - prediction.shape[1] * prediction.shape[2])) - var_c = var_b.argmax(1).reshape((prediction.shape[0], - 1)).repeat(2, - axis=1).astype(np.float) - var_c[:, 0] %= prediction.shape[2] - var_c[:, 1] = np.apply_along_axis( - lambda x: np.floor(x / prediction.shape[2]), - 0, - var_c[:, 1]) - - for i in range(prediction.shape[0]): - pt_x, pt_y = int(var_c[i, 0]), int(var_c[i, 1]) - if pt_x > 0 and pt_x < 63 and pt_y > 0 and pt_y < 63: - diff = np.array([prediction[i, pt_y, pt_x+1] - - prediction[i, pt_y, pt_x-1], - prediction[i, pt_y+1, pt_x] - - prediction[i, pt_y-1, pt_x]]) - - var_c[i] += np.sign(diff)*0.25 - - var_c += 0.5 - retval = [self.transform(var_c[i], center, scale, prediction.shape[2]) - for i in range(prediction.shape[0])] - logger.trace("Obtained points from prediction: %s", retval) - - return retval - - -class FAN(): - """The FAN Model. - Converted from pyTorch via ONNX from: - https://github.com/1adrianb/face-alignment """ - - def __init__(self, model_path, ratio=1.0): - # Must import tensorflow inside the spawned process - # for Windows machines - import tensorflow as tf - self.tf = tf # pylint: disable=invalid-name - - self.model_path = model_path - self.graph = self.load_graph() - self.input = self.graph.get_tensor_by_name("fa/input_1:0") - self.output = self.graph.get_tensor_by_name("fa/transpose_647:0") - self.session = self.set_session(ratio) - - def load_graph(self): - """ Load the tensorflow Model and weights """ - # pylint: disable=not-context-manager - logger.verbose("Initializing Face Alignment Network model...") - - with self.tf.gfile.GFile(self.model_path, "rb") as gfile: - graph_def = self.tf.GraphDef() - graph_def.ParseFromString(gfile.read()) - fa_graph = self.tf.Graph() - with fa_graph.as_default(): - self.tf.import_graph_def(graph_def, name="fa") - return fa_graph - - def set_session(self, vram_ratio): - """ Set the TF Session and initialize """ - # pylint: disable=not-context-manager, no-member - placeholder = np.zeros((1, 3, 256, 256)) - with self.graph.as_default(): - config = self.tf.ConfigProto() - config.gpu_options.per_process_gpu_memory_fraction = vram_ratio - session = self.tf.Session(config=config) - with session.as_default(): - if any("gpu" in str(device).lower() for device in session.list_devices()): - logger.debug("Using GPU") - else: - logger.warning("Using CPU") - session.run(self.output, feed_dict={self.input: placeholder}) - return session - - def predict(self, feed_item): - """ Predict landmarks in session """ - return self.session.run(self.output, - feed_dict={self.input: feed_item}) + landmarks = [] + for prediction, center, scale in zip(batch["prediction"], *batch["center_scale"]): + var_b = prediction.reshape((prediction.shape[0], + prediction.shape[1] * prediction.shape[2])) + var_c = var_b.argmax(1).reshape((prediction.shape[0], + 1)).repeat(2, + axis=1).astype(np.float) + var_c[:, 0] %= prediction.shape[2] + var_c[:, 1] = np.apply_along_axis( + lambda x: np.floor(x / prediction.shape[2]), + 0, + var_c[:, 1]) + + for i in range(prediction.shape[0]): + pt_x, pt_y = int(var_c[i, 0]), int(var_c[i, 1]) + if 63 > pt_x > 0 and 63 > pt_y > 0: + diff = np.array([prediction[i, pt_y, pt_x+1] + - prediction[i, pt_y, pt_x-1], + prediction[i, pt_y+1, pt_x] + - prediction[i, pt_y-1, pt_x]]) + + var_c[i] += np.sign(diff)*0.25 + + var_c += 0.5 + landmarks = [self.transform(var_c[i], center, scale, prediction.shape[2]) + for i in range(prediction.shape[0])] + batch.setdefault("landmarks", []).append(landmarks) + logger.trace("Obtained points from prediction: %s", batch["landmarks"]) + + +class TorchBatchNorm2D(keras.engine.base_layer.Layer): + # pylint:disable=too-many-instance-attributes + """" Required for FAN_keras model """ + def __init__(self, axis=-1, momentum=0.99, epsilon=1e-3, **kwargs): + super(TorchBatchNorm2D, self).__init__(**kwargs) + self.supports_masking = True + self.axis = axis + self.momentum = momentum + self.epsilon = epsilon + self._epsilon_const = K.constant(self.epsilon, dtype='float32') + + self.built = False + self.gamma = None + self.beta = None + self.moving_mean = None + self.moving_variance = None + + def build(self, input_shape): + dim = input_shape[self.axis] + if dim is None: + raise ValueError("Axis {} of input tensor should have a " + "defined dimension but the layer received " + "an input with shape {}." + .format(str(self.axis), str(input_shape))) + shape = (dim,) + self.gamma = self.add_weight(shape=shape, + name='gamma', + initializer='ones', + regularizer=None, + constraint=None) + self.beta = self.add_weight(shape=shape, + name='beta', + initializer='zeros', + regularizer=None, + constraint=None) + self.moving_mean = self.add_weight(shape=shape, + name='moving_mean', + initializer='zeros', + trainable=False) + self.moving_variance = self.add_weight(shape=shape, + name='moving_variance', + initializer='ones', + trainable=False) + self.built = True + + def call(self, inputs, **kwargs): + input_shape = K.int_shape(inputs) + + broadcast_shape = [1] * len(input_shape) + broadcast_shape[self.axis] = input_shape[self.axis] + + broadcast_moving_mean = K.reshape(self.moving_mean, broadcast_shape) + broadcast_moving_variance = K.reshape(self.moving_variance, + broadcast_shape) + broadcast_gamma = K.reshape(self.gamma, broadcast_shape) + broadcast_beta = K.reshape(self.beta, broadcast_shape) + invstd = ( + K.ones(shape=broadcast_shape, dtype='float32') + / K.sqrt(broadcast_moving_variance + self._epsilon_const) + ) + + return((inputs - broadcast_moving_mean) + * invstd + * broadcast_gamma + + broadcast_beta) + + def get_config(self): + config = {'axis': self.axis, + 'momentum': self.momentum, + 'epsilon': self.epsilon} + base_config = super(TorchBatchNorm2D, self).get_config() + return dict(list(base_config.items()) + list(config.items())) diff --git a/plugins/extract/align/fan_amd.py b/plugins/extract/align/fan_amd.py deleted file mode 100644 index cc5bce3f9d..0000000000 --- a/plugins/extract/align/fan_amd.py +++ /dev/null @@ -1,271 +0,0 @@ -#!/usr/bin/env python3 -""" Facial landmarks extractor for faceswap.py - Code adapted and modified from: - https://github.com/1adrianb/face-alignment -""" -import cv2 -import numpy as np -import keras -from keras import backend as K - -from ._base import Aligner, logger - - -class Align(Aligner): - """ Perform transformation to align and get landmarks """ - def __init__(self, **kwargs): - git_model_id = 9 - model_filename = "face-alignment-network_2d4_keras_v1.h5" - super().__init__(git_model_id=git_model_id, - model_filename=model_filename, - colorspace="RGB", - input_size=256, - **kwargs) - self.vram = 2240 - self.model = None - self.reference_scale = 195 - self.supports_plaidml = True - - def initialize(self, *args, **kwargs): - """ Initialization tasks to run prior to alignments """ - try: - super().initialize(*args, **kwargs) - logger.info("Initializing Face Alignment Network...") - logger.debug("fan initialize: (args: %s kwargs: %s)", args, kwargs) - self.model = FAN(self.model_path) - self.init.set() - logger.info("Initialized Face Alignment Network.") - except Exception as err: - self.error.set() - raise err - - # DETECTED FACE BOUNDING BOX PROCESSING - def align_image(self, detected_face, image): - """ Get center and scale, crop and align image around center """ - logger.trace("Aligning image around center") - center, scale = self.get_center_scale(detected_face) - image = self.crop(image, center, scale) - logger.trace("Aligned image around center") - return dict(image=image, center=center, scale=scale) - - def get_center_scale(self, detected_face): - """ Get the center and set scale of bounding box """ - logger.trace("Calculating center and scale") - center = np.array([(detected_face["left"] + detected_face["right"]) / 2.0, - (detected_face["top"] + detected_face["bottom"]) / 2.0]) - - height = detected_face["bottom"] - detected_face["top"] - width = detected_face["right"] - detected_face["left"] - - center[1] -= height * 0.12 - - scale = (width + height) / self.reference_scale - - logger.trace("Calculated center and scale: %s, %s", center, scale) - return center, scale - - def crop(self, image, center, scale): # pylint:disable=too-many-locals - """ Crop image around the center point """ - logger.trace("Cropping image") - is_color = image.ndim > 2 - v_ul = self.transform([1, 1], center, scale, self.input_size).astype(np.int) - v_br = self.transform([self.input_size, self.input_size], - center, - scale, - self.input_size).astype(np.int) - if is_color: - new_dim = np.array([v_br[1] - v_ul[1], - v_br[0] - v_ul[0], - image.shape[2]], - dtype=np.int32) - new_img = np.zeros(new_dim, dtype=np.uint8) - else: - new_dim = np.array([v_br[1] - v_ul[1], - v_br[0] - v_ul[0]], - dtype=np.int) - new_img = np.zeros(new_dim, dtype=np.uint8) - height = image.shape[0] - width = image.shape[1] - new_x = np.array([max(1, -v_ul[0] + 1), min(v_br[0], width) - v_ul[0]], - dtype=np.int32) - new_y = np.array([max(1, -v_ul[1] + 1), - min(v_br[1], height) - v_ul[1]], - dtype=np.int32) - old_x = np.array([max(1, v_ul[0] + 1), min(v_br[0], width)], - dtype=np.int32) - old_y = np.array([max(1, v_ul[1] + 1), min(v_br[1], height)], - dtype=np.int32) - if is_color: - new_img[new_y[0] - 1:new_y[1], - new_x[0] - 1:new_x[1]] = image[old_y[0] - 1:old_y[1], - old_x[0] - 1:old_x[1], :] - else: - new_img[new_y[0] - 1:new_y[1], - new_x[0] - 1:new_x[1]] = image[old_y[0] - 1:old_y[1], - old_x[0] - 1:old_x[1]] - - if new_img.shape[0] < self.input_size: - interpolation = cv2.INTER_CUBIC # pylint:disable=no-member - else: - interpolation = cv2.INTER_AREA # pylint:disable=no-member - - new_img = cv2.resize(new_img, # pylint:disable=no-member - dsize=(int(self.input_size), int(self.input_size)), - interpolation=interpolation) - logger.trace("Cropped image") - return new_img - - @staticmethod - def transform(point, center, scale, resolution): - """ Transform Image """ - logger.trace("Transforming Points") - pnt = np.array([point[0], point[1], 1.0]) - hscl = 200.0 * scale - eye = np.eye(3) - eye[0, 0] = resolution / hscl - eye[1, 1] = resolution / hscl - eye[0, 2] = resolution * (-center[0] / hscl + 0.5) - eye[1, 2] = resolution * (-center[1] / hscl + 0.5) - eye = np.linalg.inv(eye) - retval = np.matmul(eye, pnt)[0:2] - logger.trace("Transformed Points: %s", retval) - return retval - - def predict_landmarks(self, feed_dict): - """ Predict the 68 point landmarks """ - logger.trace("Predicting Landmarks") - image = np.expand_dims( - feed_dict["image"].transpose((2, 0, 1)).astype(np.float32) / 255.0, 0) - prediction = self.model.predict(image)[-1] - pts_img = self.get_pts_from_predict(prediction, feed_dict["center"], feed_dict["scale"]) - retval = [(int(pt[0]), int(pt[1])) for pt in pts_img] - logger.trace("Predicted Landmarks: %s", retval) - return retval - - def get_pts_from_predict(self, prediction, center, scale): - """ Get points from predictor """ - logger.trace("Obtain points from prediction") - var_b = prediction.reshape((prediction.shape[0], - prediction.shape[1] * prediction.shape[2])) - var_c = var_b.argmax(1).reshape((prediction.shape[0], - 1)).repeat(2, - axis=1).astype(np.float) - var_c[:, 0] %= prediction.shape[2] - var_c[:, 1] = np.apply_along_axis( - lambda x: np.floor(x / prediction.shape[2]), - 0, - var_c[:, 1]) - - for i in range(prediction.shape[0]): - pt_x, pt_y = int(var_c[i, 0]), int(var_c[i, 1]) - if pt_x > 0 and pt_x < 63 and pt_y > 0 and pt_y < 63: - diff = np.array([prediction[i, pt_y, pt_x+1] - - prediction[i, pt_y, pt_x-1], - prediction[i, pt_y+1, pt_x] - - prediction[i, pt_y-1, pt_x]]) - - var_c[i] += np.sign(diff)*0.25 - - var_c += 0.5 - retval = [self.transform(var_c[i], center, scale, prediction.shape[2]) - for i in range(prediction.shape[0])] - logger.trace("Obtained points from prediction: %s", retval) - - return retval - - -class TorchBatchNorm2D(keras.engine.base_layer.Layer): - """" Required for FAN_keras model """ - def __init__(self, axis=-1, momentum=0.99, epsilon=1e-3, **kwargs): - super(TorchBatchNorm2D, self).__init__(**kwargs) - self.supports_masking = True - self.axis = axis - self.momentum = momentum - self.epsilon = epsilon - self._epsilon_const = K.constant(self.epsilon, dtype='float32') - - self.built = False - self.gamma = None - self.beta = None - self.moving_mean = None - self.moving_variance = None - - def build(self, input_shape): - dim = input_shape[self.axis] - if dim is None: - raise ValueError("Axis {} of input tensor should have a " - "defined dimension but the layer received " - "an input with shape {}." - .format(str(self.axis), str(input_shape))) - shape = (dim,) - self.gamma = self.add_weight(shape=shape, - name='gamma', - initializer='ones', - regularizer=None, - constraint=None) - self.beta = self.add_weight(shape=shape, - name='beta', - initializer='zeros', - regularizer=None, - constraint=None) - self.moving_mean = self.add_weight(shape=shape, - name='moving_mean', - initializer='zeros', - trainable=False) - self.moving_variance = self.add_weight(shape=shape, - name='moving_variance', - initializer='ones', - trainable=False) - self.built = True - - def call(self, inputs, **kwargs): - input_shape = K.int_shape(inputs) - - broadcast_shape = [1] * len(input_shape) - broadcast_shape[self.axis] = input_shape[self.axis] - - broadcast_moving_mean = K.reshape(self.moving_mean, broadcast_shape) - broadcast_moving_variance = K.reshape(self.moving_variance, - broadcast_shape) - broadcast_gamma = K.reshape(self.gamma, broadcast_shape) - broadcast_beta = K.reshape(self.beta, broadcast_shape) - invstd = ( - K.ones(shape=broadcast_shape, dtype='float32') - / K.sqrt(broadcast_moving_variance + self._epsilon_const) - ) - - return((inputs - broadcast_moving_mean) - * invstd - * broadcast_gamma - + broadcast_beta) - - def get_config(self): - config = {'axis': self.axis, - 'momentum': self.momentum, - 'epsilon': self.epsilon} - base_config = super(TorchBatchNorm2D, self).get_config() - return dict(list(base_config.items()) + list(config.items())) - - -class FAN(): - """ - Converted from pyTorch from - https://github.com/1adrianb/face-alignment - """ - def __init__(self, model_path): - self.model_path = model_path - self.model = None - self.load_model() - - def load_model(self): - """ Load the Keras Model """ - logger.verbose("Initializing Face Alignment Network model (Keras version).") - self.model = keras.models.load_model( - self.model_path, - custom_objects={'TorchBatchNorm2D': TorchBatchNorm2D} - ) - - def predict(self, feed_item): - """ Predict landmarks in session """ - pred = self.model.predict(feed_item) - return [pred[-1].reshape((68, 64, 64))] diff --git a/plugins/extract/detect/s3fd_amd_defaults.py b/plugins/extract/align/fan_defaults.py similarity index 75% rename from plugins/extract/detect/s3fd_amd_defaults.py rename to plugins/extract/align/fan_defaults.py index e1496ec5e8..da7277ace4 100644 --- a/plugins/extract/detect/s3fd_amd_defaults.py +++ b/plugins/extract/align/fan_defaults.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ - The default options for the faceswap S3Fd-AMD Detect plugin. + The default options for the faceswap FAN Alignments plugin. Defaults files should be named _defaults.py Any items placed into this file will automatically get added to the relevant config .ini files @@ -22,6 +22,8 @@ , . default: [required] The default value for this option. info: [required] A string describing what this option does. + group: [optional]. A group for grouping options together in the GUI. If not + provided this will not group this option with any others. choices: [optional] If this option's datatype is of then valid selections can be defined here. This validates the option and also enables a combobox / radio option in the GUI. @@ -42,32 +44,21 @@ _HELPTEXT = ( - "S3FD-AMD Detector options. Uses keras backend to support AMD cards.\n" - "Fast on GPU, slow on CPU. Can detect more faces and fewer false " - "positives than other GPU detectors, but is a lot more resource intensive." + "FAN Aligner options.Fast on GPU, slow on CPU. Best aligner." ) _DEFAULTS = { - "confidence": { - "default": 50, - "info": "The confidence level at which the detector has succesfully found a face.\n" - "Higher levels will be more discriminating, lower levels will have more false " - "positives.", - "datatype": int, - "rounding": 5, - "min_max": (25, 100), - "choices": [], - "gui_radio": False, - "fixed": True, - }, "batch-size": { "default": 8, - "info": "The batch size to use. Normally higher batch sizes equal better performance.\n" - "A batchsize of 8 requires about 2 GB vram.", + "info": "The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered." + "\n\tAMD users: A batchsize of 8 requires about 4 GB vram.", "datatype": int, "rounding": 1, - "min_max": (1, 32), + "min_max": (1, 64), "choices": [], "gui_radio": False, "fixed": True, diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index 56f193be28..5791159664 100755 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -1,249 +1,319 @@ #!/usr/bin/env python3 """ Base class for Face Detector plugins - Plugins should inherit from this class - See the override methods for which methods are - required. +All Detector Plugins should inherit from this class. +See the override methods for which methods are required. - For each source frame, the plugin must pass a dict to finalize containing: - {"filename": , - "image": , - "detected_faces": }} +For each source frame, the plugin must pass a dict to finalize containing: - - Use the function self.to_bounding_box_dict(left, right, top, bottom) to define the dict - """ - -import logging -import os -import traceback -from io import StringIO +>>> {'filename': , +>>> 'image': , +>>> 'detected_faces': >> face = self.to_detected_face(, , , ) -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +""" +import cv2 +import numpy as np + +from lib.faces_detect import DetectedFace +from lib.utils import rotate_landmarks +from plugins.extract._base import Extractor, logger + + +class Detector(Extractor): + """ Detector Object + + Parent class for all Detector plugins + + Parameters + ---------- + git_model_id: int + The second digit in the github tag that identifies this model. See + https://github.com/deepfakes-models/faceswap-models for more information + model_filename: str + The name of the model file to be loaded + rotation: str, optional + Pass in a single number to use increments of that size up to 360, or pass in a ``list`` of + ``ints`` to enumerate exactly what angles to check. Can also pass in ``'on'`` to increment + at 90 degree intervals. Default: ``None`` + min_size: int, optional + Filters out faces detected below this size. Length, in pixels across the diagonal of the + bounding box. Set to ``0`` for off. Default: ``0`` + + Other Parameters + ---------------- + configfile: str, optional + Path to a custom configuration ``ini`` file. Default: Use system configfile + + See Also + -------- + plugins.extract.pipeline : The extraction pipeline for calling plugins + plugins.extract.detect : Detector plugins + plugins.extract._base : Parent class for all extraction plugins + plugins.extract.align._base : Aligner parent class for extraction plugins. + """ -def get_config(plugin_name, configfile=None): - """ Return the config for the requested model """ - return Config(plugin_name, configfile=configfile).config_dict + def __init__(self, git_model_id=None, model_filename=None, + configfile=None, rotation=None, min_size=0): + logger.debug("Initializing %s: (rotation: %s, min_size: %s)", self.__class__.__name__, + rotation, min_size) + super().__init__(git_model_id, + model_filename, + configfile=configfile) + self.rotation = self._get_rotation_angles(rotation) + self.min_size = min_size + self._plugin_type = "detect" -class Detector(): - """ Detector object """ - def __init__(self, loglevel, configfile=None, # pylint:disable=too-many-arguments - git_model_id=None, model_filename=None, rotation=None, min_size=0): - logger.debug("Initializing %s: (loglevel: %s, configfile: %s, git_model_id: %s, " - "model_filename: %s, rotation: %s, min_size: %s)", - self.__class__.__name__, loglevel, configfile, git_model_id, - model_filename, rotation, min_size) - self.config = get_config(".".join(self.__module__.split(".")[-2:]), configfile=configfile) - self.loglevel = loglevel - self.rotation = self.get_rotation_angles(rotation) - self.min_size = min_size - self.parent_is_pool = False - self.init = None - self.error = None - - # The input and output queues for the plugin. - # See lib.queue_manager.QueueManager for getting queues - self.queues = {"in": None, "out": None} - - # Path to model if required - self.model_path = self.get_model(git_model_id, model_filename) - - # Target image size for passing images through the detector - # Set to tuple of dimensions (x, y) or int of pixel count - self.target = None - - # Approximate VRAM used for the set target. Used to calculate - # how many parallel processes / batches can be run. - # Be conservative to avoid OOM. - self.vram = None - - # Set to true if the plugin supports PlaidML - self.supports_plaidml = False - - # For detectors that support batching, this should be set to - # the calculated batch size that the amount of available VRAM - # will support. It is also used for holding the number of threads/ - # processes for parallel processing plugins - self.batch_size = 1 logger.debug("Initialized _base %s", self.__class__.__name__) - # <<< OVERRIDE METHODS >>> # - def initialize(self, *args, **kwargs): - """ Inititalize the detector - Tasks to be run before any detection is performed. - Override for specific detector """ - logger.debug("initialize %s (PID: %s, args: %s, kwargs: %s)", - self.__class__.__name__, os.getpid(), args, kwargs) - self.init = kwargs.get("event", False) - self.error = kwargs.get("error", False) - self.queues["in"] = kwargs["in_queue"] - self.queues["out"] = kwargs["out_queue"] - - def detect_faces(self, *args, **kwargs): - """ Detect faces in rgb image - Override for specific detector - Must return a list of bounding box dicts (See module docstring)""" - try: - if not self.init: - self.initialize(*args, **kwargs) - except ValueError as err: - logger.error(err) - exit(1) - logger.debug("Detecting Faces (args: %s, kwargs: %s)", args, kwargs) - - # <<< GET MODEL >>> # - @staticmethod - def get_model(git_model_id, model_filename): - """ Check if model is available, if not, download and unzip it """ - if model_filename is None: - logger.debug("No model_filename specified. Returning None") - return None - if git_model_id is None: - logger.debug("No git_model_id specified. Returning None") - return None - cache_path = os.path.join(os.path.dirname(__file__), ".cache") - model = GetModel(model_filename, cache_path, git_model_id) - return model.model_path - - # <<< DETECTION WRAPPER >>> # - def run(self, *args, **kwargs): - """ Parent detect process. - This should always be called as the entry point so exceptions - are passed back to parent. - Do not override """ - try: - logger.debug("Executing detector run function") - self.detect_faces(*args, **kwargs) - except Exception as err: # pylint: disable=broad-except - logger.error("Caught exception in child process: %s: %s", os.getpid(), str(err)) - # Display traceback if in initialization stage - if not self.init.is_set(): - logger.exception("Traceback:") - tb_buffer = StringIO() - traceback.print_exc(file=tb_buffer) - logger.trace(tb_buffer.getvalue()) - exception = {"exception": (os.getpid(), tb_buffer)} - self.queues["out"].put(exception) - exit(1) + # <<< QUEUE METHODS >>> # + def get_batch(self, queue): + """ Get items for inputting to the detector plugin in batches + + Items are returned from the ``queue`` in batches of + :attr:`~plugins.extract._base.Extractor.batchsize` + + Remember to put ``'EOF'`` to the out queue after processing + the final batch + + Outputs items in the following format. All lists are of length + :attr:`~plugins.extract._base.Extractor.batchsize`: + + >>> {'filename': [], + >>> 'image': [], + >>> 'scaled_image': , + >>> 'scale': [], + >>> 'pad': [], + >>> 'detected_faces': [[>> # - def finalize(self, output): - """ This should be called as the final task of each plugin - Performs fianl processing and puts to the out queue """ - if isinstance(output, dict): - logger.trace("Item out: %s", {key: val - for key, val in output.items() - if key != "image"}) - # Prevent zero size faces - iheight, iwidth = output["image"].shape[:2] - output["detected_faces"] = [ - f for f in output.get("detected_faces", list()) - if f["right"] > 0 and f["left"] < iwidth - and f["bottom"] > 0 and f["top"] < iheight - ] - if self.min_size > 0 and output.get("detected_faces", None): - output["detected_faces"] = self.filter_small_faces(output["detected_faces"]) - else: - logger.trace("Item out: %s", output) - self.queues["out"].put(output) + def finalize(self, batch): + """ Finalize the output from Detector + + This should be called as the final task of each ``plugin``. + + It strips unneeded items from the :attr:`batch` ``dict`` and performs standard final + processing on each item + + Outputs items in the format: + + >>> {'image': [], + >>> 'filename': [), + >>> 'detected_faces': []} + + + Parameters + ---------- + batch : dict + The final ``dict`` from the `plugin` process. It must contain the keys ``image``, + ``filename``, ``faces`` + + Yields + ------ + dict + A ``dict`` for each frame containing the ``image``, ``filename`` and ``list`` of + ``detected_faces`` + """ + if not isinstance(batch, dict): + logger.trace("Item out: %s", batch) + return batch + + logger.trace("Item out: %s", {k: v.shape if isinstance(v, np.ndarray) else v + for k, v in batch.items()}) + + batch_faces = [[self.to_detected_face(face[0], face[1], face[2], face[3]) + for face in faces] + for faces in batch["prediction"]] + # Rotations + if any(m.any() for m in batch["rotmat"]) and any(batch_faces): + batch_faces = [[self._rotate_rect(face, rotmat) if rotmat.any() else face + for face in faces] + for faces, rotmat in zip(batch_faces, batch["rotmat"])] + + # Scale back out to original frame + batch["detected_faces"] = [[self.to_detected_face((face.left - pad[0]) / scale, + (face.top - pad[1]) / scale, + (face.right - pad[0]) / scale, + (face.bottom - pad[1]) / scale) + for face in faces] + for scale, pad, faces in zip(batch["scale"], + batch["pad"], + batch_faces)] + + # Remove zero sized faces + self._remove_zero_sized_faces(batch) + if self.min_size > 0 and batch.get("detected_faces", None): + batch["detected_faces"] = self._filter_small_faces(batch["detected_faces"]) + + self._remove_invalid_keys(batch, ("detected_faces", "filename", "image")) + batch = self._dict_lists_to_list_dicts(batch) + + for item in batch: + logger.trace("final output: %s", {k: v.shape if isinstance(v, np.ndarray) else v + for k, v in item.items()}) + yield item - def filter_small_faces(self, detected_faces): - """ Filter out any faces smaller than the min size threshold """ - retval = list() - for face in detected_faces: - width = face["right"] - face["left"] - height = face["bottom"] - face["top"] - face_size = (width ** 2 + height ** 2) ** 0.5 - if face_size < self.min_size: - logger.debug("Removing detected face: (face_size: %s, min_size: %s", - face_size, self.min_size) - continue - retval.append(face) - return retval + @staticmethod + def to_detected_face(left, top, right, bottom): + """ Return a :class:`~lib.faces_detect.DetectedFace` object for the bounding box """ + return DetectedFace(x=int(round(left)), + w=int(round(right - left)), + y=int(round(top)), + h=int(round(bottom - top))) + + # <<< PROTECTED ACCESS METHODS >>> # + # <<< PREDICT WRAPPER >>> # + def _predict(self, batch): + """ Wrap models predict function in rotations """ + batch["rotmat"] = [np.array([]) for _ in range(len(batch["feed"]))] + found_faces = [np.array([]) for _ in range(len(batch["feed"]))] + for angle in self.rotation: + # Rotate the batch and insert placeholders for already found faces + self._rotate_batch(batch, angle) + batch = self.predict(batch) + + if angle != 0 and any([face.any() for face in batch["prediction"]]): + logger.verbose("found face(s) by rotating image %s degrees", angle) + + found_faces = [face if not found.any() else found + for face, found in zip(batch["prediction"], found_faces)] + + if all([face.any() for face in found_faces]): + logger.trace("Faces found for all images") + break + + batch["prediction"] = found_faces + logger.trace("detect_prediction output: (filenames: %s, prediction: %s, rotmat: %s)", + batch["filename"], batch["prediction"], batch["rotmat"]) + return batch # <<< DETECTION IMAGE COMPILATION METHODS >>> # - def compile_detection_image(self, input_image, # pylint:disable=too-many-arguments - is_square=False, scale_up=False, to_rgb=False, - to_grayscale=False, pad_to=None): - """ Compile the detection image """ - image = input_image.copy() - if to_rgb: - image = image[:, :, ::-1] - elif to_grayscale: - image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # pylint: disable=no-member - scale = self.set_scale(image, is_square=is_square, scale_up=scale_up) - image = self.scale_image(image, scale, pad_to) - if pad_to is None: - return [image, scale] - pad_left = int(pad_to[0] - int(input_image.shape[1] * scale)) // 2 - pad_top = int(pad_to[1] - int(input_image.shape[0] * scale)) // 2 - return [image, scale, (pad_left, pad_top)] - - def set_scale(self, image, is_square=False, scale_up=False): - """ Set the scale factor for incoming image """ - height, width = image.shape[:2] - if is_square: - if isinstance(self.target, int): - dims = (self.target ** 0.5, self.target ** 0.5) - self.target = dims - source = max(height, width) - target = max(self.target) - else: - source = (width * height) ** 0.5 - if isinstance(self.target, tuple): - self.target = self.target[0] * self.target[1] - target = self.target ** 0.5 + def _compile_detection_image(self, input_image): + """ Compile the detection image for feeding into the model""" + image = self._convert_color(input_image) - if scale_up or target < source: - scale = target / source - else: - scale = 1.0 - logger.trace("Detector scale: %s", scale) + image_size = image.shape[:2] + scale = self._set_scale(image_size) + pad = self._set_padding(image_size, scale) + image = self._scale_image(image, image_size, scale) + image = self._pad_image(image) + logger.trace("compiled: (images shape: %s, scale: %s, pad: %s)", image.shape, scale, pad) + return image, scale, pad + + def _set_scale(self, image_size): + """ Set the scale factor for incoming image """ + scale = self.input_size / max(image_size) + logger.trace("Detector scale: %s", scale) return scale + def _set_padding(self, image_size, scale): + """ Set the image padding for non-square images """ + pad_left = int(self.input_size - int(image_size[1] * scale)) // 2 + pad_top = int(self.input_size - int(image_size[0] * scale)) // 2 + return pad_left, pad_top + @staticmethod - def scale_image(image, scale, pad_to=None): + def _scale_image(image, image_size, scale): """ Scale the image and optional pad to given size """ - # pylint: disable=no-member - height, width = image.shape[:2] - interpln = cv2.INTER_LINEAR if scale > 1.0 else cv2.INTER_AREA + interpln = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA # pylint:disable=no-member if scale != 1.0: - dims = (int(width * scale), int(height * scale)) - if scale < 1.0: - logger.debug("Resizing image from %sx%s to %s. Scale=%s", - width, height, "x".join(str(i) for i in dims), scale) - image = cv2.resize(image, dims, interpolation=interpln) - if pad_to: - image = Detector.pad_image(image, pad_to) + dims = (int(image_size[1] * scale), int(image_size[0] * scale)) + logger.trace("Resizing detection image from %s to %s. Scale=%s", + "x".join(str(i) for i in reversed(image_size)), + "x".join(str(i) for i in dims), scale) + image = cv2.resize(image, dims, interpolation=interpln) # pylint:disable=no-member + logger.trace("Resized image shape: %s", image.shape) return image - @staticmethod - def pad_image(image, target): - """ Pad an image to a square """ + def _pad_image(self, image): + """ Pad a resized image to input size """ height, width = image.shape[:2] - if width < target[0] or height < target[1]: - pad_l = (target[0] - width) // 2 - pad_r = (target[0] - width) - pad_l - pad_t = (target[1] - height) // 2 - pad_b = (target[1] - height) - pad_t - img = cv2.copyMakeBorder( # pylint:disable=no-member - image, pad_t, pad_b, pad_l, pad_r, - cv2.BORDER_CONSTANT, (0, 0, 0) # pylint:disable=no-member - ) - return img + if width < self.input_size or height < self.input_size: + pad_l = (self.input_size - width) // 2 + pad_r = (self.input_size - width) - pad_l + pad_t = (self.input_size - height) // 2 + pad_b = (self.input_size - height) - pad_t + image = cv2.copyMakeBorder( # pylint:disable=no-member + image, + pad_t, + pad_b, + pad_l, + pad_r, + cv2.BORDER_CONSTANT) # pylint:disable=no-member + logger.trace("Padded image shape: %s", image.shape) return image + # <<< FINALIZE METHODS >>> # + @staticmethod + def _remove_zero_sized_faces(batch): + """ Remove items from dict where detected face is of zero size + or face falls entirely outside of image """ + dims = [img.shape[:2] for img in batch["image"]] + logger.trace("image dims: %s", dims) + batch["detected_faces"] = [[face for face in faces + if face.right > 0 and face.left < dim[1] + and face.bottom > 0 and face.top < dim[0]] + for dim, faces in zip(dims, + batch.get("detected_faces", list()))] + + def _filter_small_faces(self, detected_faces): + """ Filter out any faces smaller than the min size threshold """ + retval = [] + for faces in detected_faces: + this_image = [] + for face in faces: + face_size = (face.w ** 2 + face.h ** 2) ** 0.5 + if face_size < self.min_size: + logger.debug("Removing detected face: (face_size: %s, min_size: %s", + face_size, self.min_size) + continue + this_image.append(face) + retval.append(this_image) + return retval + # <<< IMAGE ROTATION METHODS >>> # @staticmethod - def get_rotation_angles(rotation): + def _get_rotation_angles(rotation): """ Set the rotation angles. Includes backwards compatibility for the 'on' and 'off' options: - 'on' - increment 90 degrees @@ -259,11 +329,9 @@ def get_rotation_angles(rotation): if rotation.lower() == "on": rotation_angles.extend(range(90, 360, 90)) else: - passed_angles = [ - int(angle) - for angle in rotation.split(",") - if int(angle) != 0 - ] + passed_angles = [int(angle) + for angle in rotation.split(",") + if int(angle) != 0] if len(passed_angles) == 1: rotation_step_size = passed_angles[0] rotation_angles.extend(range(rotation_step_size, @@ -275,106 +343,54 @@ def get_rotation_angles(rotation): logger.debug("Rotation Angles: %s", rotation_angles) return rotation_angles - def rotate_image(self, image, angle): - """ Rotate the image by given angle and return - Image with rotation matrix """ + def _rotate_batch(self, batch, angle): + """ Rotate images in a batch by given angle + if any faces have already been detected for a batch, store the existing rotation + matrix and replace the feed image with a placeholder """ if angle == 0: - return image, None - return self.rotate_image_by_angle(image, angle) + # Set the initial batch so we always rotate from zero + batch["initial_feed"] = batch["feed"].copy() + return + + retval = dict() + for img, faces, rotmat in zip(batch["initial_feed"], batch["prediction"], batch["rotmat"]): + if faces.any(): + image = np.zeros_like(img) + matrix = rotmat + else: + image, matrix = self._rotate_image_by_angle(img, angle) + retval.setdefault("feed", []).append(image) + retval.setdefault("rotmat", []).append(matrix) + batch["feed"] = np.array(retval["feed"], dtype="float32") + batch["rotmat"] = retval["rotmat"] @staticmethod - def rotate_rect(bounding_box, rotation_matrix): + def _rotate_rect(bounding_box, rotation_matrix): """ Rotate a bounding box dict based on the rotation_matrix""" logger.trace("Rotating bounding box") bounding_box = rotate_landmarks(bounding_box, rotation_matrix) return bounding_box - @staticmethod - def rotate_image_by_angle(image, angle, - rotated_width=None, rotated_height=None): + def _rotate_image_by_angle(self, image, angle): """ Rotate an image by a given angle. From: https://stackoverflow.com/questions/22041699 """ - logger.trace("Rotating image: (angle: %s, rotated_width: %s, rotated_height: %s)", - angle, rotated_width, rotated_height) + logger.trace("Rotating image: (image: %s, angle: %s)", image.shape, angle) + channels_first = image.shape[0] <= 4 + if channels_first: + image = np.moveaxis(image, 0, 2) + height, width = image.shape[:2] image_center = (width/2, height/2) rotation_matrix = cv2.getRotationMatrix2D( # pylint: disable=no-member image_center, -1.*angle, 1.) - if rotated_width is None or rotated_height is None: - abs_cos = abs(rotation_matrix[0, 0]) - abs_sin = abs(rotation_matrix[0, 1]) - if rotated_width is None: - rotated_width = int(height*abs_sin + width*abs_cos) - if rotated_height is None: - rotated_height = int(height*abs_cos + width*abs_sin) - rotation_matrix[0, 2] += rotated_width/2 - image_center[0] - rotation_matrix[1, 2] += rotated_height/2 - image_center[1] + rotation_matrix[0, 2] += self.input_size / 2 - image_center[0] + rotation_matrix[1, 2] += self.input_size / 2 - image_center[1] logger.trace("Rotated image: (rotation_matrix: %s", rotation_matrix) - return (cv2.warpAffine(image, # pylint: disable=no-member + image = cv2.warpAffine(image, # pylint: disable=no-member rotation_matrix, - (rotated_width, rotated_height)), - rotation_matrix) - - # << QUEUE METHODS >> # - def get_item(self): - """ Yield one item from the queue """ - item = self.queues["in"].get() - if isinstance(item, dict): - logger.trace("Item in: %s", item["filename"]) - else: - logger.trace("Item in: %s", item) - if item == "EOF": - logger.debug("In Queue Exhausted") - # Re-put EOF into queue for other threads - self.queues["in"].put(item) - return item - - def get_batch(self): - """ Get items from the queue in batches of - self.batch_size - - First item in output tuple indicates whether the - queue is exhausted. - Second item is the batch - - Remember to put "EOF" to the out queue after processing - the final batch """ - exhausted = False - batch = list() - for _ in range(self.batch_size): - item = self.get_item() - if item == "EOF": - exhausted = True - break - batch.append(item) - logger.trace("Returning batch size: %s", len(batch)) - return (exhausted, batch) - - # <<< MISC METHODS >>> # - def get_vram_free(self): - """ Return free and total VRAM on card with most VRAM free""" - stats = GPUStats() - vram = stats.get_card_most_free(supports_plaidml=self.supports_plaidml) - logger.verbose("Using device %s with %sMB free of %sMB", - vram["device"], - int(vram["free"]), - int(vram["total"])) - return int(vram["card_id"]), int(vram["free"]), int(vram["total"]) + (self.input_size, self.input_size)) + if channels_first: + image = np.moveaxis(image, 2, 0) - @staticmethod - def to_bounding_box_dict(left, top, right, bottom): - """ Return a dict for the bounding box """ - return dict(left=int(round(left)), - right=int(round(right)), - top=int(round(top)), - bottom=int(round(bottom))) - - def set_predetected(self, width, height): - """ Set a bounding box dict for predetected faces """ - # Predetected_face is used for sort tool. - # Landmarks should not be extracted again from predetected faces, - # because face data is lost, resulting in a large variance - # against extract from original image - logger.debug("Setting predetected face") - return [self.to_bounding_box_dict(0, 0, width, height)] + return image, rotation_matrix diff --git a/plugins/extract/detect/cv2_dnn.py b/plugins/extract/detect/cv2_dnn.py index 0c39a7f74a..7e2330b16b 100755 --- a/plugins/extract/detect/cv2_dnn.py +++ b/plugins/extract/detect/cv2_dnn.py @@ -12,84 +12,50 @@ def __init__(self, **kwargs): git_model_id = 4 model_filename = ["resnet_ssd_v1.caffemodel", "resnet_ssd_v1.prototxt"] super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.target = (300, 300) # Doesn't use VRAM - self.vram = 0 - self.detector = None + self.name = "cv2-DNN Detector" + self.input_size = 300 + self.vram = 0 # CPU Only. Doesn't use VRAM + self.batchsize = 1 self.confidence = self.config["confidence"] / 100 - def initialize(self, *args, **kwargs): - """ Calculate batch size """ - super().initialize(*args, **kwargs) - logger.info("Initializing cv2 DNN Detector...") - logger.verbose("Using CPU for detection") - self.detector = cv2.dnn.readNetFromCaffe(self.model_path[1], # pylint: disable=no-member - self.model_path[0]) - self.detector.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) # pylint: disable=no-member - self.init.set() - logger.info("Initialized cv2 DNN Detector.") - - def detect_faces(self, *args, **kwargs): - """ Detect faces in grayscale image """ - super().detect_faces(*args, **kwargs) - while True: - item = self.get_item() - if item == "EOF": - break - logger.trace("Detecting faces: %s", item["filename"]) - [detect_image, scale] = self.compile_detection_image(item["image"], - is_square=True, - scale_up=True) - height, width = detect_image.shape[:2] - for angle in self.rotation: - current_image, rotmat = self.rotate_image(detect_image, angle) - logger.trace("Detecting faces") - - blob = cv2.dnn.blobFromImage(current_image, # pylint: disable=no-member - 1.0, - self.target, - [104, 117, 123], - False, - False) - self.detector.setInput(blob) - detected = self.detector.forward() - faces = list() - for i in range(detected.shape[2]): - confidence = detected[0, 0, i, 2] - if confidence >= self.confidence: - logger.trace("Accepting due to confidence %s >= %s", - confidence, self.confidence) - faces.append([(detected[0, 0, i, 3] * width), - (detected[0, 0, i, 4] * height), - (detected[0, 0, i, 5] * width), - (detected[0, 0, i, 6] * height)]) - - logger.trace("Detected faces: %s", [face for face in faces]) - - if angle != 0 and faces: - logger.verbose("found face(s) by rotating image %s degrees", angle) - - if faces: - break - - detected_faces = self.process_output(faces, rotmat, scale) - item["detected_faces"] = detected_faces - self.finalize(item) - - self.queues["out"].put("EOF") - logger.debug("Detecting Faces Complete") - - def process_output(self, faces, rotation_matrix, scale): + def init_model(self): + """ Initialize CV2 DNN Detector Model""" + self.model = cv2.dnn.readNetFromCaffe(self.model_path[1], # pylint: disable=no-member + self.model_path[0]) + self.model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) # pylint: disable=no-member + + def process_input(self, batch): + """ Compile the detection image(s) for prediction """ + batch["feed"] = cv2.dnn.blobFromImages(batch["scaled_image"], # pylint: disable=no-member + scalefactor=1.0, + size=(self.input_size, self.input_size), + mean=[104, 117, 123], + swapRB=False, + crop=False) + return batch + + def predict(self, batch): + """ Run model to get predictions """ + self.model.setInput(batch["feed"]) + predictions = self.model.forward() + batch["prediction"] = self.finalize_predictions(predictions) + return batch + + def finalize_predictions(self, predictions): + """ Filter faces based on confidence level """ + faces = list() + for i in range(predictions.shape[2]): + confidence = predictions[0, 0, i, 2] + if confidence >= self.confidence: + logger.trace("Accepting due to confidence %s >= %s", + confidence, self.confidence) + faces.append([(predictions[0, 0, i, 3] * self.input_size), + (predictions[0, 0, i, 4] * self.input_size), + (predictions[0, 0, i, 5] * self.input_size), + (predictions[0, 0, i, 6] * self.input_size)]) + logger.trace("faces: %s", faces) + return [np.array(faces)] + + def process_output(self, batch): """ Compile found faces for output """ - logger.trace("Processing Output: (faces: %s, rotation_matrix: %s)", - faces, rotation_matrix) - - faces = [self.to_bounding_box_dict(face[0], face[1], face[2], face[3]) for face in faces] - if isinstance(rotation_matrix, np.ndarray): - faces = [self.rotate_rect(face, rotation_matrix) - for face in faces] - detected = [self.to_bounding_box_dict(face["left"] / scale, face["top"] / scale, - face["right"] / scale, face["bottom"] / scale) - for face in faces] - - logger.trace("Processed Output: %s", detected) - return detected + return batch diff --git a/plugins/extract/detect/cv2_dnn_defaults.py b/plugins/extract/detect/cv2_dnn_defaults.py index 9bee627951..3402385581 100755 --- a/plugins/extract/detect/cv2_dnn_defaults.py +++ b/plugins/extract/detect/cv2_dnn_defaults.py @@ -22,6 +22,8 @@ , . default: [required] The default value for this option. info: [required] A string describing what this option does. + group: [optional]. A group for grouping options together in the GUI. If not + provided this will not group this option with any others. choices: [optional] If this option's datatype is of then valid selections can be defined here. This validates the option and also enables a combobox / radio option in the GUI. @@ -60,5 +62,5 @@ "choices": [], "gui_radio": False, "fixed": True, - } + }, } diff --git a/plugins/extract/detect/manual.py b/plugins/extract/detect/manual.py index 08f2771eb2..7bd8752ae4 100644 --- a/plugins/extract/detect/manual.py +++ b/plugins/extract/detect/manual.py @@ -1,32 +1,39 @@ #!/usr/bin/env python3 """ Manual face detection plugin """ -from ._base import Detector, logger +import numpy as np +from ._base import Detector class Detect(Detector): """ Manual Detector """ def __init__(self, **kwargs): super().__init__(**kwargs) + self.name = "Manual" + self.input_size = 1440 # Arbitrary size for manual tool + self.vram = 0 + self.vram_warnings = 0 + self.vram_per_batch = 1 + self.batchsize = 1 - def initialize(self, *args, **kwargs): - """ Create the mtcnn detector """ - super().initialize(*args, **kwargs) - logger.info("Initializing Manual Detector...") - self.init.set() - logger.info("Initialized Manual Detector.") - - def detect_faces(self, *args, **kwargs): - """ Return the given bounding box in a bounding box dict """ - super().detect_faces(*args, **kwargs) - while True: - item = self.get_item() - if item == "EOF": - break - face = item["face"] - - bounding_box = [self.to_bounding_box_dict(face[0], face[1], face[2], face[3])] - item["detected_faces"] = bounding_box - self.finalize(item) - - self.queues["out"].put("EOF") + def _compile_detection_image(self, input_image): + """ Override compile detection image for manual. No face is actually fed into a model """ + return input_image, 1, (0, 0) + + def init_model(self): + """ No model for Manual """ + return + + def process_input(self, batch): + """ No pre-processing for Manual. Just set a dummy feed """ + batch["feed"] = batch["scaled_image"] + return batch + + def predict(self, batch): + """ No prediction for Manual """ + batch["prediction"] = [np.array(batch["manual_face"])] + return batch + + def process_output(self, batch): + """ Post process the detected faces """ + return batch diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py index efa0e4af3e..47d036fcbb 100755 --- a/plugins/extract/detect/mtcnn.py +++ b/plugins/extract/detect/mtcnn.py @@ -3,39 +3,28 @@ from __future__ import absolute_import, division, print_function -import os - -from six import string_types, iteritems - import cv2 +from keras.layers import Conv2D, Dense, Flatten, Input, MaxPool2D, Permute, PReLU + import numpy as np -from lib.multithreading import MultiThread +from lib.model.session import KSession from ._base import Detector, logger -# Must import tensorflow inside the spawned process -# for Windows machines -tf = None # pylint: disable = invalid-name - - -def import_tensorflow(): - """ Import tensorflow from inside spawned process """ - global tf # pylint: disable = invalid-name,global-statement - import tensorflow as tflow - tf = tflow - - class Detect(Detector): """ MTCNN detector for face recognition """ def __init__(self, **kwargs): git_model_id = 2 - model_filename = ["mtcnn_det_v1.1.npy", "mtcnn_det_v1.2.npy", "mtcnn_det_v1.3.npy"] + model_filename = ["mtcnn_det_v2.1.h5", "mtcnn_det_v2.2.h5", "mtcnn_det_v2.3.h5"] super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.kwargs = self.validate_kwargs() - self.name = "mtcnn" - self.target = 2073600 # Uses approx 1.30 GB of VRAM + self.name = "MTCNN" + self.input_size = 1440 self.vram = 1408 + self.vram_warnings = 512 # Will run at this with warnings + self.vram_per_batch = 1 # TODO implement batch support + self.batchsize = 1 # TODO implement batch support + self.kwargs = self.validate_kwargs() def validate_kwargs(self): """ Validate that config options are correct. If not reset to default """ @@ -55,163 +44,45 @@ def validate_kwargs(self): valid = False if not valid: - kwargs = {"minsize": 20, # minimum size of face + kwargs = {"minsize": 20, # minimum size of face "threshold": [0.6, 0.7, 0.7], # three steps threshold "factor": 0.709} # scale factor logger.warning("Invalid MTCNN options in config. Running with defaults") logger.debug("Using mtcnn kwargs: %s", kwargs) return kwargs - def initialize(self, *args, **kwargs): - """ Create the mtcnn detector """ - try: - super().initialize(*args, **kwargs) - logger.info("Initializing MTCNN Detector...") - is_gpu = False - - # Must import tensorflow inside the spawned process - # for Windows machines - import_tensorflow() - _, vram_free, _ = self.get_vram_free() - mtcnn_graph = tf.Graph() - - # Windows machines sometimes misreport available vram, and overuse - # causing OOM. Allow growth fixes that - config = tf.ConfigProto() - config.gpu_options.allow_growth = True # pylint: disable=no-member - - with mtcnn_graph.as_default(): # pylint: disable=not-context-manager - sess = tf.Session(config=config) - with sess.as_default(): # pylint: disable=not-context-manager - pnet, rnet, onet = create_mtcnn(sess, self.model_path) - - if any("gpu" in str(device).lower() - for device in sess.list_devices()): - logger.debug("Using GPU") - is_gpu = True - mtcnn_graph.finalize() - - if not is_gpu: - alloc = 2048 - logger.warning("Using CPU") - else: - alloc = vram_free - logger.debug("Allocated for Tensorflow: %sMB", alloc) - - self.batch_size = int(alloc / self.vram) - - if self.batch_size < 1: - self.error.set() - raise ValueError("Insufficient VRAM available to continue " - "({}MB)".format(int(alloc))) - - logger.verbose("Processing in %s threads", self.batch_size) - - self.kwargs["pnet"] = pnet - self.kwargs["rnet"] = rnet - self.kwargs["onet"] = onet - - self.init.set() - logger.info("Initialized MTCNN Detector.") - except Exception as err: - self.error.set() - raise err - - def detect_faces(self, *args, **kwargs): - """ Detect faces in Multiple Threads """ - super().detect_faces(*args, **kwargs) - workers = MultiThread(target=self.detect_thread, thread_count=self.batch_size) - workers.start() - workers.join() - sentinel = self.queues["in"].get() - self.queues["out"].put(sentinel) - logger.debug("Detecting Faces complete") - - def detect_thread(self): - """ Detect faces in rgb image """ - logger.debug("Launching Detect") - while True: - item = self.get_item() - if item == "EOF": - break - logger.trace("Detecting faces: '%s'", item["filename"]) - [detect_image, scale] = self.compile_detection_image(item["image"], to_rgb=True) - - for angle in self.rotation: - current_image, rotmat = self.rotate_image(detect_image, angle) - faces, points = detect_face(current_image, **self.kwargs) - if angle != 0 and faces.any(): - logger.verbose("found face(s) by rotating image %s degrees", angle) - if faces.any(): - break - - detected_faces = self.process_output(faces, points, rotmat, scale) - item["detected_faces"] = detected_faces - self.finalize(item) - - logger.debug("Thread Completed Detect") - - def process_output(self, faces, points, rotation_matrix, scale): - """ Compile found faces for output """ - logger.trace("Processing Output: (faces: %s, points: %s, rotation_matrix: %s)", - faces, points, rotation_matrix) - faces = self.recalculate_bounding_box(faces, points) - faces = [self.to_bounding_box_dict(face[0], face[1], face[2], face[3]) for face in faces] - if isinstance(rotation_matrix, np.ndarray): - faces = [self.rotate_rect(face, rotation_matrix) - for face in faces] - detected = [self.to_bounding_box_dict(face["left"] / scale, face["top"] / scale, - face["right"] / scale, face["bottom"] / scale) - for face in faces] - logger.trace("Processed Output: %s", detected) - return detected + def init_model(self): + """ Initialize S3FD Model""" + self.model = MTCNN(self.model_path, **self.kwargs) - @staticmethod - def recalculate_bounding_box(faces, landmarks): - """ Recalculate the bounding box for Face Alignment. - - Resize the bounding box around features to present - a better box to Face Alignment. Helps its chances - on edge cases and helps remove 'jitter' """ - logger.trace("Recalculating Bounding Boxes: (faces: %s, landmarks: %s)", - faces, landmarks) - retval = list() - no_faces = len(faces) - if no_faces == 0: - return retval - face_landmarks = np.hsplit(landmarks, no_faces) - for idx in range(no_faces): - pts = np.reshape(face_landmarks[idx], (5, 2), order="F") - nose = pts[2] - - minmax = (np.amin(pts, axis=0), np.amax(pts, axis=0)) - padding = [(minmax[1][0] - minmax[0][0]) / 2, - (minmax[1][1] - minmax[0][1]) / 2] - - center = (minmax[1][0] - padding[0], minmax[1][1] - padding[1]) - offset = (center[0] - nose[0], nose[1] - center[1]) - center = (center[0] + offset[0], center[1] + offset[1]) - - padding[0] += padding[0] - padding[1] += padding[1] - - bounding = [center[0] - padding[0], center[1] - padding[1], - center[0] + padding[0], center[1] + padding[1]] - retval.append(bounding) - logger.trace("Recalculated Bounding Boxes: %s", retval) - return retval - - -# MTCNN Detector for face alignment -# Code adapted from: https://github.com/davidsandberg/facenet - -# Tensorflow implementation of the face detection / alignment algorithm + def process_input(self, batch): + """ Compile the detection image(s) for prediction """ + batch["feed"] = (batch["scaled_image"] - 127.5) / 127.5 + return batch + + def predict(self, batch): + """ Run model to get predictions """ + prediction, points = self.model.detect_faces(batch["feed"]) + logger.trace("filename: %s, prediction: %s, mtcnn_points: %s", + batch["filename"], prediction, points) + batch["prediction"], batch["mtcnn_points"] = [prediction], [points] + return batch + + def process_output(self, batch): + """ Post process the detected faces """ + return batch + + +# MTCNN Detector +# Code adapted from: https://github.com/xiangrufan/keras-mtcnn +# +# Keras implementation of the face detection / alignment algorithm # found at # https://github.com/kpzhang93/MTCNN_face_detection_alignment - +# # MIT License # -# Copyright (c) 2016 David Sandberg +# Copyright (c) 2016 Kaipeng Zhang # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -220,8 +91,8 @@ def recalculate_bounding_box(faces, landmarks): # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, @@ -232,311 +103,402 @@ def recalculate_bounding_box(faces, landmarks): # SOFTWARE. -def layer(operator): - """Decorator for composable network layers.""" +class PNet(KSession): + """ Keras PNet model for MTCNN """ + def __init__(self, model_path): + super().__init__("MTCNN-PNet", model_path) + self.define_model(self.model_definition) + self.load_model_weights() - def layer_decorated(self, *args, **kwargs): - # Automatically set a name if not provided. - name = kwargs.setdefault('name', self.get_unique_name(operator.__name__)) - # Figure out the layer inputs. - if len(self.terminals) == 0: # pylint: disable=len-as-condition - raise RuntimeError('No input variables found for layer %s.' % name) - elif len(self.terminals) == 1: - layer_input = self.terminals[0] - else: - layer_input = list(self.terminals) - # Perform the operation and get the output. - layer_output = operator(self, layer_input, *args, **kwargs) - # Add to layer LUT. - self.layers[name] = layer_output - # This output is now the input for the next layer. - self.feed(layer_output) - # Return self for chained calls. - return self - - return layer_decorated - - -class Network(): - """ Tensorflow Network """ - def __init__(self, inputs, trainable=True): - # The input nodes for this network - self.inputs = inputs - # The current list of terminal nodes - self.terminals = [] - # Mapping from layer names to layers - self.layers = dict(inputs) - # If true, the resulting variables are set as trainable - self.trainable = trainable - - self.setup() - - def setup(self): - """Construct the network. """ - raise NotImplementedError('Must be implemented by the subclass.') + @staticmethod + def model_definition(): + """ Keras PNetwork for MTCNN """ + input_ = Input(shape=(None, None, 3)) + var_x = Conv2D(10, (3, 3), strides=1, padding='valid', name='conv1')(input_) + var_x = PReLU(shared_axes=[1, 2], name='PReLU1')(var_x) + var_x = MaxPool2D(pool_size=2)(var_x) + var_x = Conv2D(16, (3, 3), strides=1, padding='valid', name='conv2')(var_x) + var_x = PReLU(shared_axes=[1, 2], name='PReLU2')(var_x) + var_x = Conv2D(32, (3, 3), strides=1, padding='valid', name='conv3')(var_x) + var_x = PReLU(shared_axes=[1, 2], name='PReLU3')(var_x) + classifier = Conv2D(2, (1, 1), activation='softmax', name='conv4-1')(var_x) + bbox_regress = Conv2D(4, (1, 1), name='conv4-2')(var_x) + return [input_], [classifier, bbox_regress] + + +class RNet(KSession): + """ Keras RNet model for MTCNN """ + def __init__(self, model_path): + super().__init__("MTCNN-RNet", model_path) + self.define_model(self.model_definition) + self.load_model_weights() + + @staticmethod + def model_definition(): + """ Keras RNetwork for MTCNN """ + input_ = Input(shape=(24, 24, 3)) + var_x = Conv2D(28, (3, 3), strides=1, padding='valid', name='conv1')(input_) + var_x = PReLU(shared_axes=[1, 2], name='prelu1')(var_x) + var_x = MaxPool2D(pool_size=3, strides=2, padding='same')(var_x) + + var_x = Conv2D(48, (3, 3), strides=1, padding='valid', name='conv2')(var_x) + var_x = PReLU(shared_axes=[1, 2], name='prelu2')(var_x) + var_x = MaxPool2D(pool_size=3, strides=2)(var_x) + + var_x = Conv2D(64, (2, 2), strides=1, padding='valid', name='conv3')(var_x) + var_x = PReLU(shared_axes=[1, 2], name='prelu3')(var_x) + var_x = Permute((3, 2, 1))(var_x) + var_x = Flatten()(var_x) + var_x = Dense(128, name='conv4')(var_x) + var_x = PReLU(name='prelu4')(var_x) + classifier = Dense(2, activation='softmax', name='conv5-1')(var_x) + bbox_regress = Dense(4, name='conv5-2')(var_x) + return [input_], [classifier, bbox_regress] + + +class ONet(KSession): + """ Keras ONet model for MTCNN """ + def __init__(self, model_path): + super().__init__("MTCNN-ONet", model_path) + self.define_model(self.model_definition) + self.load_model_weights() @staticmethod - def load(model_path, session, ignore_missing=False): - """Load network weights. - model_path: The path to the numpy-serialized network weights - session: The current TensorFlow session - ignore_missing: If true, serialized weights for missing layers are - ignored. + def model_definition(): + """ Keras ONetwork for MTCNN """ + input_ = Input(shape=(48, 48, 3)) + var_x = Conv2D(32, (3, 3), strides=1, padding='valid', name='conv1')(input_) + var_x = PReLU(shared_axes=[1, 2], name='prelu1')(var_x) + var_x = MaxPool2D(pool_size=3, strides=2, padding='same')(var_x) + var_x = Conv2D(64, (3, 3), strides=1, padding='valid', name='conv2')(var_x) + var_x = PReLU(shared_axes=[1, 2], name='prelu2')(var_x) + var_x = MaxPool2D(pool_size=3, strides=2)(var_x) + var_x = Conv2D(64, (3, 3), strides=1, padding='valid', name='conv3')(var_x) + var_x = PReLU(shared_axes=[1, 2], name='prelu3')(var_x) + var_x = MaxPool2D(pool_size=2)(var_x) + var_x = Conv2D(128, (2, 2), strides=1, padding='valid', name='conv4')(var_x) + var_x = PReLU(shared_axes=[1, 2], name='prelu4')(var_x) + var_x = Permute((3, 2, 1))(var_x) + var_x = Flatten()(var_x) + var_x = Dense(256, name='conv5')(var_x) + var_x = PReLU(name='prelu5')(var_x) + + classifier = Dense(2, activation='softmax', name='conv6-1')(var_x) + bbox_regress = Dense(4, name='conv6-2')(var_x) + landmark_regress = Dense(10, name='conv6-3')(var_x) + return [input_], [classifier, bbox_regress, landmark_regress] + + +class MTCNN(): + """ MTCNN Detector for face alignment """ + # TODO Batching + + def __init__(self, model_path, minsize, threshold, factor): """ - # pylint: disable=no-member - data_dict = np.load(model_path, encoding='latin1').item() - - for op_name in data_dict: - with tf.variable_scope(op_name, reuse=True): - for param_name, data in iteritems(data_dict[op_name]): - try: - var = tf.get_variable(param_name) - session.run(var.assign(data)) - except ValueError: - if not ignore_missing: - raise - - def feed(self, *args): - """Set the input(s) for the next operation by replacing the terminal nodes. - The arguments can be either layer names or the actual layers. + minsize: minimum faces' size + threshold: threshold=[th1, th2, th3], th1-3 are three steps's threshold + factor: the factor used to create a scaling pyramid of face sizes to + detect in the image. + pnet, rnet, onet: caffemodel """ - assert len(args) != 0 # pylint: disable=len-as-condition - self.terminals = [] - for fed_layer in args: - if isinstance(fed_layer, string_types): - try: - fed_layer = self.layers[fed_layer] - except KeyError: - raise KeyError('Unknown layer name fed: %s' % fed_layer) - self.terminals.append(fed_layer) - return self - - def get_output(self): - """Returns the current network output.""" - return self.terminals[-1] - - def get_unique_name(self, prefix): - """Returns an index-suffixed unique name for the given prefix. - This is used for auto-generating layer names based on the type-prefix. + logger.debug("Initializing: %s: (model_path: '%s')", + self.__class__.__name__, model_path) + self.minsize = minsize + self.threshold = threshold + self.factor = factor + + self.pnet = PNet(model_path[0]) + self.rnet = RNet(model_path[1]) + self.onet = ONet(model_path[2]) + logger.debug("Initialized: %s", self.__class__.__name__) + + def detect_faces(self, batch): + """Detects faces in an image, and returns bounding boxes and points for them. + batch: input batch """ - ident = sum(t.startswith(prefix) for t, _ in self.layers.items()) + 1 - return '%s_%d' % (prefix, ident) - - def make_var(self, name, shape): - """Creates a new TensorFlow variable.""" - return tf.get_variable(name, shape, trainable=self.trainable) - - @staticmethod - def validate_padding(padding): - """Verifies that the padding is one of the supported ones.""" - assert padding in ('SAME', 'VALID') - - @layer - def conv(self, # pylint: disable=too-many-arguments - inp, - k_h, - k_w, - c_o, - s_h, - s_w, - name, - relu=True, - padding='SAME', - group=1, - biased=True): - """ Conv Layer """ + total_boxes = np.empty((0, 9)) + points = np.empty(0) + # TODO Implement batch support + image = batch[0] + origin_h, origin_w = image.shape[:2] + rectangles = self.detect_pnet(image, origin_h, origin_w) + if not rectangles: + return total_boxes, points + rectangles = self.detect_rnet(image, rectangles, origin_h, origin_w) + if not rectangles: + return total_boxes, points + rectangles = self.detect_onet(image, rectangles, origin_h, origin_w) + if rectangles: + total_boxes = np.array([result[:5] for result in rectangles]) + points = np.array([result[5:] for result in rectangles]).T + return total_boxes, points + + def detect_pnet(self, image, height, width): # pylint: disable=too-many-locals - - # Verify that the padding is acceptable - self.validate_padding(padding) - # Get the number of channels in the input - c_i = int(inp.get_shape()[-1]) - # Verify that the grouping parameter is valid - assert c_i % group == 0 - assert c_o % group == 0 - # Convolution for a given input and kernel - convolve = lambda i, k: tf.nn.conv2d(i, k, [1, s_h, s_w, 1], padding=padding) # noqa - with tf.variable_scope(name) as scope: - kernel = self.make_var('weights', - shape=[k_h, k_w, c_i // group, c_o]) - # This is the common-case. Convolve the input without any - # further complications. - output = convolve(inp, kernel) - # Add the biases - if biased: - biases = self.make_var('biases', [c_o]) - output = tf.nn.bias_add(output, biases) - if relu: - # ReLU non-linearity - output = tf.nn.relu(output, name=scope.name) - return output - - @layer - def prelu(self, inp, name): - """ Prelu Layer """ - with tf.variable_scope(name): - i = int(inp.get_shape()[-1]) - alpha = self.make_var('alpha', shape=(i,)) - output = tf.nn.relu(inp) + tf.multiply(alpha, -tf.nn.relu(-inp)) - return output - - @layer - def max_pool(self, inp, k_h, k_w, # pylint: disable=too-many-arguments - s_h, s_w, name, padding='SAME'): - """ Max Pool Layer """ - self.validate_padding(padding) - return tf.nn.max_pool(inp, - ksize=[1, k_h, k_w, 1], - strides=[1, s_h, s_w, 1], - padding=padding, - name=name) - - @layer - def fc(self, inp, num_out, name, relu=True): # pylint: disable=invalid-name - """ FC Layer """ - with tf.variable_scope(name): - input_shape = inp.get_shape() - if input_shape.ndims == 4: - # The input is spatial. Vectorize it first. - dim = 1 - for this_dim in input_shape[1:].as_list(): - dim *= int(this_dim) - feed_in = tf.reshape(inp, [-1, dim]) - else: - feed_in, dim = (inp, input_shape[-1].value) - weights = self.make_var('weights', shape=[dim, num_out]) - biases = self.make_var('biases', [num_out]) - operator = tf.nn.relu_layer if relu else tf.nn.xw_plus_b - fc = operator(feed_in, weights, biases, name=name) # pylint: disable=invalid-name - return fc - - @layer - def softmax(self, target, axis, name=None): # pylint: disable=no-self-use - """ Multi dimensional softmax, - refer to https://github.com/tensorflow/tensorflow/issues/210 - compute softmax along the dimension of target - the native softmax only supports batch_size x dimension """ - - max_axis = tf.reduce_max(target, axis, keepdims=True) - target_exp = tf.exp(target-max_axis) - normalize = tf.reduce_sum(target_exp, axis, keepdims=True) - softmax = tf.div(target_exp, normalize, name) - return softmax - - -class PNet(Network): - """ Tensorflow PNet """ - def setup(self): - (self.feed('data') # pylint: disable=no-value-for-parameter, no-member - .conv(3, 3, 10, 1, 1, padding='VALID', relu=False, name='conv1') - .prelu(name='PReLU1') - .max_pool(2, 2, 2, 2, name='pool1') - .conv(3, 3, 16, 1, 1, padding='VALID', relu=False, name='conv2') - .prelu(name='PReLU2') - .conv(3, 3, 32, 1, 1, padding='VALID', relu=False, name='conv3') - .prelu(name='PReLU3') - .conv(1, 1, 2, 1, 1, relu=False, name='conv4-1') - .softmax(3, name='prob1')) - - (self.feed('PReLU3') # pylint: disable=no-value-for-parameter - .conv(1, 1, 4, 1, 1, relu=False, name='conv4-2')) - - -class RNet(Network): - """ Tensorflow RNet """ - def setup(self): - (self.feed('data') # pylint: disable=no-value-for-parameter, no-member - .conv(3, 3, 28, 1, 1, padding='VALID', relu=False, name='conv1') - .prelu(name='prelu1') - .max_pool(3, 3, 2, 2, name='pool1') - .conv(3, 3, 48, 1, 1, padding='VALID', relu=False, name='conv2') - .prelu(name='prelu2') - .max_pool(3, 3, 2, 2, padding='VALID', name='pool2') - .conv(2, 2, 64, 1, 1, padding='VALID', relu=False, name='conv3') - .prelu(name='prelu3') - .fc(128, relu=False, name='conv4') - .prelu(name='prelu4') - .fc(2, relu=False, name='conv5-1') - .softmax(1, name='prob1')) - - (self.feed('prelu4') # pylint: disable=no-value-for-parameter - .fc(4, relu=False, name='conv5-2')) - - -class ONet(Network): - """ Tensorflow ONet """ - def setup(self): - (self.feed('data') # pylint: disable=no-value-for-parameter, no-member - .conv(3, 3, 32, 1, 1, padding='VALID', relu=False, name='conv1') - .prelu(name='prelu1') - .max_pool(3, 3, 2, 2, name='pool1') - .conv(3, 3, 64, 1, 1, padding='VALID', relu=False, name='conv2') - .prelu(name='prelu2') - .max_pool(3, 3, 2, 2, padding='VALID', name='pool2') - .conv(3, 3, 64, 1, 1, padding='VALID', relu=False, name='conv3') - .prelu(name='prelu3') - .max_pool(2, 2, 2, 2, name='pool3') - .conv(2, 2, 128, 1, 1, padding='VALID', relu=False, name='conv4') - .prelu(name='prelu4') - .fc(256, relu=False, name='conv5') - .prelu(name='prelu5') - .fc(2, relu=False, name='conv6-1') - .softmax(1, name='prob1')) - - (self.feed('prelu5') # pylint: disable=no-value-for-parameter - .fc(4, relu=False, name='conv6-2')) - - (self.feed('prelu5') # pylint: disable=no-value-for-parameter - .fc(10, relu=False, name='conv6-3')) - - -def create_mtcnn(sess, model_path): - """ Create the network """ - if not model_path: - model_path, _ = os.path.split(os.path.realpath(__file__)) - - with tf.variable_scope('pnet'): - data = tf.placeholder(tf.float32, (None, None, None, 3), 'input') - pnet = PNet({'data': data}) - pnet.load(model_path[0], sess) - with tf.variable_scope('rnet'): - data = tf.placeholder(tf.float32, (None, 24, 24, 3), 'input') - rnet = RNet({'data': data}) - rnet.load(model_path[1], sess) - with tf.variable_scope('onet'): - data = tf.placeholder(tf.float32, (None, 48, 48, 3), 'input') - onet = ONet({'data': data}) - onet.load(model_path[2], sess) - - pnet_fun = lambda img: sess.run(('pnet/conv4-2/BiasAdd:0', # noqa - 'pnet/prob1:0'), - feed_dict={'pnet/input:0': img}) - rnet_fun = lambda img: sess.run(('rnet/conv5-2/conv5-2:0', # noqa - 'rnet/prob1:0'), - feed_dict={'rnet/input:0': img}) - onet_fun = lambda img: sess.run(('onet/conv6-2/conv6-2:0', # noqa - 'onet/conv6-3/conv6-3:0', - 'onet/prob1:0'), - feed_dict={'onet/input:0': img}) - return pnet_fun, rnet_fun, onet_fun - - -def detect_face(img, minsize, pnet, rnet, # pylint: disable=too-many-arguments - onet, threshold, factor): - """Detects faces in an image, and returns bounding boxes and points for them. - img: input image - minsize: minimum faces' size - pnet, rnet, onet: caffemodel - threshold: threshold=[th1, th2, th3], th1-3 are three steps's threshold - factor: the factor used to create a scaling pyramid of face sizes to - detect in the image. + """ first stage - fast proposal network (pnet) to obtain face candidates """ + scales = calculate_scales(height, width, self.minsize, self.factor) + rectangles = [] + for scale in scales: + scale_img = cv2.resize(image, # pylint:disable=no-member + (int(width * scale), int(height * scale))) + input_ = scale_img.reshape(1, *scale_img.shape) + output = self.pnet.predict(input_) + # .transpose(0, 2, 1, 3) should be added, but this seems wrong. + # first 0 select cls score, second 0 = batchnum, alway=0. 1 one hot repr + cls_prob = output[0][0][:, :, 1] + roi = output[1][0] + out_h, out_w = cls_prob.shape + out_side = max(out_h, out_w) + cls_prob = np.swapaxes(cls_prob, 0, 1) + roi = np.swapaxes(roi, 0, 2) + rectangle = detect_face_12net(cls_prob, + roi, + out_side, + 1 / scale, + width, + height, + self.threshold[0]) + rectangles.extend(rectangle) + return nms(rectangles, 0.7, 'iou') + + def detect_rnet(self, image, rectangles, height, width): + """ second stage - refinement of face candidates with rnet """ + crop_number = 0 + predict_24_batch = [] + for rect in rectangles: + crop_img = image[int(rect[1]):int(rect[3]), int(rect[0]):int(rect[2])] + scale_img = cv2.resize(crop_img, (24, 24)) # pylint:disable=no-member + predict_24_batch.append(scale_img) + crop_number += 1 + + predict_24_batch = np.array(predict_24_batch) + output = self.rnet.predict(predict_24_batch) + + cls_prob = output[0] # first 0 is to select cls, second batch number, always =0 + cls_prob = np.array(cls_prob) + roi_prob = output[1] # first 0 is to select roi, second batch number, always =0 + roi_prob = np.array(roi_prob) + return filter_face_24net(cls_prob, roi_prob, rectangles, width, height, self.threshold[1]) + + def detect_onet(self, image, rectangles, height, width): + """ third stage - further refinement and facial landmarks positions with onet """ + crop_number = 0 + predict_batch = [] + for rect in rectangles: + crop_img = image[int(rect[1]):int(rect[3]), int(rect[0]):int(rect[2])] + scale_img = cv2.resize(crop_img, (48, 48)) # pylint:disable=no-member + predict_batch.append(scale_img) + crop_number += 1 + + predict_batch = np.array(predict_batch) + + output = self.onet.predict(predict_batch) + cls_prob = output[0] + roi_prob = output[1] + pts_prob = output[2] # index + return filter_face_48net(cls_prob, + roi_prob, + pts_prob, + rectangles, + width, + height, + self.threshold[2]) + + +def detect_face_12net(cls_prob, roi, out_side, scale, width, height, threshold): + # pylint: disable=too-many-locals, too-many-arguments + """ Detect face position and calibrate bounding box on 12net feature map(matrix version) + Input: + cls_prob : softmax feature map for face classify + roi : feature map for regression + out_side : feature map's largest size + scale : current input image scale in multi-scales + width : image's origin width + height : image's origin height + threshold: 0.6 can have 99% recall rate + """ + in_side = 2*out_side+11 + stride = 0 + if out_side != 1: + stride = float(in_side-12)/(out_side-1) + (var_x, var_y) = np.where(cls_prob >= threshold) + boundingbox = np.array([var_x, var_y]).T + bb1 = np.fix((stride * (boundingbox) + 0) * scale) + bb2 = np.fix((stride * (boundingbox) + 11) * scale) + boundingbox = np.concatenate((bb1, bb2), axis=1) + dx_1 = roi[0][var_x, var_y] + dx_2 = roi[1][var_x, var_y] + dx3 = roi[2][var_x, var_y] + dx4 = roi[3][var_x, var_y] + score = np.array([cls_prob[var_x, var_y]]).T + offset = np.array([dx_1, dx_2, dx3, dx4]).T + boundingbox = boundingbox + offset*12.0*scale + rectangles = np.concatenate((boundingbox, score), axis=1) + rectangles = rect2square(rectangles) + pick = [] + for rect in rectangles: + x_1 = int(max(0, rect[0])) + y_1 = int(max(0, rect[1])) + x_2 = int(min(width, rect[2])) + y_2 = int(min(height, rect[3])) + sc_ = rect[4] + if x_2 > x_1 and y_2 > y_1: + pick.append([x_1, y_1, x_2, y_2, sc_]) + return nms(pick, 0.3, "iou") + + +def filter_face_24net(cls_prob, roi, rectangles, width, height, threshold): + # pylint: disable=too-many-locals, too-many-arguments + """ Filter face position and calibrate bounding box on 12net's output + Input: + cls_prob : softmax feature map for face classify + roi_prob : feature map for regression + rectangles: 12net's predict + width : image's origin width + height : image's origin height + threshold : 0.6 can have 97% recall rate + Output: + rectangles: possible face positions + """ + prob = cls_prob[:, 1] + pick = np.where(prob >= threshold) + rectangles = np.array(rectangles) + x_1 = rectangles[pick, 0] + y_1 = rectangles[pick, 1] + x_2 = rectangles[pick, 2] + y_2 = rectangles[pick, 3] + sc_ = np.array([prob[pick]]).T + dx_1 = roi[pick, 0] + dx_2 = roi[pick, 1] + dx3 = roi[pick, 2] + dx4 = roi[pick, 3] + r_width = x_2-x_1 + r_height = y_2-y_1 + x_1 = np.array([(x_1 + dx_1 * r_width)[0]]).T + y_1 = np.array([(y_1 + dx_2 * r_height)[0]]).T + x_2 = np.array([(x_2 + dx3 * r_width)[0]]).T + y_2 = np.array([(y_2 + dx4 * r_height)[0]]).T + rectangles = np.concatenate((x_1, y_1, x_2, y_2, sc_), axis=1) + rectangles = rect2square(rectangles) + pick = [] + for rect in rectangles: + x_1 = int(max(0, rect[0])) + y_1 = int(max(0, rect[1])) + x_2 = int(min(width, rect[2])) + y_2 = int(min(height, rect[3])) + sc_ = rect[4] + if x_2 > x_1 and y_2 > y_1: + pick.append([x_1, y_1, x_2, y_2, sc_]) + return nms(pick, 0.3, 'iou') + + +def filter_face_48net(cls_prob, roi, pts, rectangles, width, height, threshold): + # pylint: disable=too-many-locals, too-many-arguments + """ Filter face position and calibrate bounding box on 12net's output + Input: + cls_prob : cls_prob[1] is face possibility + roi : roi offset + pts : 5 landmark + rectangles: 12net's predict, rectangles[i][0:3] is the position, rectangles[i][4] is score + width : image's origin width + height : image's origin height + threshold : 0.7 can have 94% recall rate on CelebA-database + Output: + rectangles: face positions and landmarks + """ + prob = cls_prob[:, 1] + pick = np.where(prob >= threshold) + rectangles = np.array(rectangles) + x_1 = rectangles[pick, 0] + y_1 = rectangles[pick, 1] + x_2 = rectangles[pick, 2] + y_2 = rectangles[pick, 3] + sc_ = np.array([prob[pick]]).T + dx_1 = roi[pick, 0] + dx_2 = roi[pick, 1] + dx3 = roi[pick, 2] + dx4 = roi[pick, 3] + r_width = x_2-x_1 + r_height = y_2-y_1 + pts0 = np.array([(r_width * pts[pick, 0] + x_1)[0]]).T + pts1 = np.array([(r_height * pts[pick, 5] + y_1)[0]]).T + pts2 = np.array([(r_width * pts[pick, 1] + x_1)[0]]).T + pts3 = np.array([(r_height * pts[pick, 6] + y_1)[0]]).T + pts4 = np.array([(r_width * pts[pick, 2] + x_1)[0]]).T + pts5 = np.array([(r_height * pts[pick, 7] + y_1)[0]]).T + pts6 = np.array([(r_width * pts[pick, 3] + x_1)[0]]).T + pts7 = np.array([(r_height * pts[pick, 8] + y_1)[0]]).T + pts8 = np.array([(r_width * pts[pick, 4] + x_1)[0]]).T + pts9 = np.array([(r_height * pts[pick, 9] + y_1)[0]]).T + x_1 = np.array([(x_1 + dx_1 * r_width)[0]]).T + y_1 = np.array([(y_1 + dx_2 * r_height)[0]]).T + x_2 = np.array([(x_2 + dx3 * r_width)[0]]).T + y_2 = np.array([(y_2 + dx4 * r_height)[0]]).T + rectangles = np.concatenate((x_1, y_1, x_2, y_2, sc_, + pts0, pts1, pts2, pts3, pts4, pts5, pts6, pts7, pts8, pts9), + axis=1) + pick = [] + for rect in rectangles: + x_1 = int(max(0, rect[0])) + y_1 = int(max(0, rect[1])) + x_2 = int(min(width, rect[2])) + y_2 = int(min(height, rect[3])) + if x_2 > x_1 and y_2 > y_1: + pick.append([x_1, y_1, x_2, y_2, + rect[4], rect[5], rect[6], rect[7], rect[8], rect[9], + rect[10], rect[11], rect[12], rect[13], rect[14]]) + return nms(pick, 0.3, 'iom') + + +def nms(rectangles, threshold, method): + # pylint:disable=too-many-locals + """ apply NMS(non-maximum suppression) on ROIs in same scale(matrix version) + Input: + rectangles: rectangles[i][0:3] is the position, rectangles[i][4] is score + Output: + rectangles: same as input + """ + if not rectangles: + return rectangles + boxes = np.array(rectangles) + x_1 = boxes[:, 0] + y_1 = boxes[:, 1] + x_2 = boxes[:, 2] + y_2 = boxes[:, 3] + var_s = boxes[:, 4] + area = np.multiply(x_2-x_1+1, y_2-y_1+1) + s_sort = np.array(var_s.argsort()) + pick = [] + while len(s_sort) > 0: + # s_sort[-1] have hightest prob score, s_sort[0:-1]->others + xx_1 = np.maximum(x_1[s_sort[-1]], x_1[s_sort[0:-1]]) + yy_1 = np.maximum(y_1[s_sort[-1]], y_1[s_sort[0:-1]]) + xx_2 = np.minimum(x_2[s_sort[-1]], x_2[s_sort[0:-1]]) + yy_2 = np.minimum(y_2[s_sort[-1]], y_2[s_sort[0:-1]]) + width = np.maximum(0.0, xx_2 - xx_1 + 1) + height = np.maximum(0.0, yy_2 - yy_1 + 1) + inter = width * height + if method == 'iom': + var_o = inter / np.minimum(area[s_sort[-1]], area[s_sort[0:-1]]) + else: + var_o = inter / (area[s_sort[-1]] + area[s_sort[0:-1]] - inter) + pick.append(s_sort[-1]) + s_sort = s_sort[np.where(var_o <= threshold)[0]] + result_rectangle = boxes[pick].tolist() + return result_rectangle + + +def calculate_scales(height, width, minsize, factor): + """ Calculate multi-scale + Input: + height: Original image height + width: Original image width + minsize: Minimum size for a face to be accepted + factor: Scaling factor + Output: + scales : Multi-scale """ - # pylint: disable=too-many-locals,too-many-statements,too-many-branches factor_count = 0 - total_boxes = np.empty((0, 9)) - points = np.empty(0) - height = img.shape[0] - width = img.shape[1] minl = np.amin([height, width]) var_m = 12.0 / minsize minl = minl * var_m @@ -546,261 +508,21 @@ def detect_face(img, minsize, pnet, rnet, # pylint: disable=too-many-arguments scales += [var_m * np.power(factor, factor_count)] minl = minl * factor factor_count += 1 + logger.trace(scales) + return scales - # # # # # # # # # # # # # - # first stage - fast proposal network (pnet) to obtain face candidates - # # # # # # # # # # # # # - for scale in scales: - height_scale = int(np.ceil(height * scale)) - width_scale = int(np.ceil(width * scale)) - im_data = imresample(img, (height_scale, width_scale)) - im_data = (im_data - 127.5) * 0.0078125 - img_x = np.expand_dims(im_data, 0) - img_y = np.transpose(img_x, (0, 2, 1, 3)) - out = pnet(img_y) - out0 = np.transpose(out[0], (0, 2, 1, 3)) - out1 = np.transpose(out[1], (0, 2, 1, 3)) - - boxes, _ = generate_bounding_box(out1[0, :, :, 1].copy(), - out0[0, :, :, :].copy(), - scale, threshold[0]) - - # inter-scale nms - pick = nms(boxes.copy(), 0.5, 'Union') - if boxes.size > 0 and pick.size > 0: - boxes = boxes[pick, :] - total_boxes = np.append(total_boxes, boxes, axis=0) - - numbox = total_boxes.shape[0] - if numbox > 0: - pick = nms(total_boxes.copy(), 0.7, 'Union') - total_boxes = total_boxes[pick, :] - regw = total_boxes[:, 2]-total_boxes[:, 0] - regh = total_boxes[:, 3]-total_boxes[:, 1] - qq_1 = total_boxes[:, 0]+total_boxes[:, 5] * regw - qq_2 = total_boxes[:, 1]+total_boxes[:, 6] * regh - qq_3 = total_boxes[:, 2]+total_boxes[:, 7] * regw - qq_4 = total_boxes[:, 3]+total_boxes[:, 8] * regh - total_boxes = np.transpose(np.vstack([qq_1, qq_2, qq_3, qq_4, total_boxes[:, 4]])) - total_boxes = rerec(total_boxes.copy()) - total_boxes[:, 0:4] = np.fix(total_boxes[:, 0:4]).astype(np.int32) - d_y, ed_y, d_x, ed_x, var_y, e_y, var_x, e_x, tmpw, tmph = pad(total_boxes.copy(), - width, height) - - numbox = total_boxes.shape[0] - - # # # # # # # # # # # # # - # second stage - refinement of face candidates with rnet - # # # # # # # # # # # # # - - if numbox > 0: - tempimg = np.zeros((24, 24, 3, numbox)) - for k in range(0, numbox): - tmp = np.zeros((int(tmph[k]), int(tmpw[k]), 3)) - tmp[d_y[k] - 1:ed_y[k], d_x[k] - 1:ed_x[k], :] = img[var_y[k] - 1:e_y[k], - var_x[k]-1:e_x[k], :] - if tmp.shape[0] > 0 and tmp.shape[1] > 0 or tmp.shape[0] == 0 and tmp.shape[1] == 0: - tempimg[:, :, :, k] = imresample(tmp, (24, 24)) - else: - return np.empty() - tempimg = (tempimg-127.5)*0.0078125 - tempimg1 = np.transpose(tempimg, (3, 1, 0, 2)) - out = rnet(tempimg1) - out0 = np.transpose(out[0]) - out1 = np.transpose(out[1]) - score = out1[1, :] - ipass = np.where(score > threshold[1]) - total_boxes = np.hstack([total_boxes[ipass[0], 0:4].copy(), - np.expand_dims(score[ipass].copy(), 1)]) - m_v = out0[:, ipass[0]] - if total_boxes.shape[0] > 0: - pick = nms(total_boxes, 0.7, 'Union') - total_boxes = total_boxes[pick, :] - total_boxes = bbreg(total_boxes.copy(), np.transpose(m_v[:, pick])) - total_boxes = rerec(total_boxes.copy()) - - numbox = total_boxes.shape[0] - - # # # # # # # # # # # # # - # third stage - further refinement and facial landmarks positions with onet - # NB: Facial landmarks code commented out for faceswap - # # # # # # # # # # # # # - - if numbox > 0: - # third stage - total_boxes = np.fix(total_boxes).astype(np.int32) - d_y, ed_y, d_x, ed_x, var_y, e_y, var_x, e_x, tmpw, tmph = pad(total_boxes.copy(), - width, height) - tempimg = np.zeros((48, 48, 3, numbox)) - for k in range(0, numbox): - tmp = np.zeros((int(tmph[k]), int(tmpw[k]), 3)) - tmp[d_y[k] - 1:ed_y[k], d_x[k] - 1:ed_x[k], :] = img[var_y[k] - 1:e_y[k], - var_x[k] - 1:e_x[k], :] - if tmp.shape[0] > 0 and tmp.shape[1] > 0 or tmp.shape[0] == 0 and tmp.shape[1] == 0: - tempimg[:, :, :, k] = imresample(tmp, (48, 48)) - else: - return np.empty() - tempimg = (tempimg-127.5)*0.0078125 - tempimg1 = np.transpose(tempimg, (3, 1, 0, 2)) - out = onet(tempimg1) - out0 = np.transpose(out[0]) - out1 = np.transpose(out[1]) - out2 = np.transpose(out[2]) - score = out2[1, :] - points = out1 - ipass = np.where(score > threshold[2]) - points = points[:, ipass[0]] - total_boxes = np.hstack([total_boxes[ipass[0], 0:4].copy(), - np.expand_dims(score[ipass].copy(), 1)]) - m_v = out0[:, ipass[0]] - - width = total_boxes[:, 2] - total_boxes[:, 0] + 1 - height = total_boxes[:, 3] - total_boxes[:, 1] + 1 - points[0:5, :] = (np.tile(width, (5, 1)) * points[0:5, :] + - np.tile(total_boxes[:, 0], (5, 1)) - 1) - points[5:10, :] = (np.tile(height, (5, 1)) * points[5:10, :] + - np.tile(total_boxes[:, 1], (5, 1)) - 1) - if total_boxes.shape[0] > 0: - total_boxes = bbreg(total_boxes.copy(), np.transpose(m_v)) - pick = nms(total_boxes.copy(), 0.7, 'Min') - total_boxes = total_boxes[pick, :] - points = points[:, pick] - - return total_boxes, points - - -# function [boundingbox] = bbreg(boundingbox,reg) -def bbreg(boundingbox, reg): - """Calibrate bounding boxes""" - if reg.shape[1] == 1: - reg = np.reshape(reg, (reg.shape[2], reg.shape[3])) - - width = boundingbox[:, 2] - boundingbox[:, 0] + 1 - height = boundingbox[:, 3] - boundingbox[:, 1] + 1 - b_1 = boundingbox[:, 0] + reg[:, 0] * width - b_2 = boundingbox[:, 1] + reg[:, 1] * height - b_3 = boundingbox[:, 2] + reg[:, 2] * width - b_4 = boundingbox[:, 3] + reg[:, 3] * height - boundingbox[:, 0:4] = np.transpose(np.vstack([b_1, b_2, b_3, b_4])) - return boundingbox - - -def generate_bounding_box(imap, reg, scale, threshold): - """Use heatmap to generate bounding boxes""" - # pylint: disable=too-many-locals - stride = 2 - cellsize = 12 - - imap = np.transpose(imap) - d_x1 = np.transpose(reg[:, :, 0]) - d_y1 = np.transpose(reg[:, :, 1]) - d_x2 = np.transpose(reg[:, :, 2]) - d_y2 = np.transpose(reg[:, :, 3]) - dim_y, dim_x = np.where(imap >= threshold) - if dim_y.shape[0] == 1: - d_x1 = np.flipud(d_x1) - d_y1 = np.flipud(d_y1) - d_x2 = np.flipud(d_x2) - d_y2 = np.flipud(d_y2) - score = imap[(dim_y, dim_x)] - reg = np.transpose(np.vstack([d_x1[(dim_y, dim_x)], d_y1[(dim_y, dim_x)], - d_x2[(dim_y, dim_x)], d_y2[(dim_y, dim_x)]])) - if reg.size == 0: - reg = np.empty((0, 3)) - bbox = np.transpose(np.vstack([dim_y, dim_x])) - q_1 = np.fix((stride * bbox + 1) / scale) - q_2 = np.fix((stride * bbox + cellsize - 1 + 1) / scale) - boundingbox = np.hstack([q_1, q_2, np.expand_dims(score, 1), reg]) - return boundingbox, reg - - -# function pick = nms(boxes,threshold,type) -def nms(boxes, threshold, method): - """ Non_Max Suppression """ - # pylint: disable=too-many-locals - if boxes.size == 0: - return np.empty((0, 3)) - x_1 = boxes[:, 0] - y_1 = boxes[:, 1] - x_2 = boxes[:, 2] - y_2 = boxes[:, 3] - var_s = boxes[:, 4] - area = (x_2 - x_1 + 1) * (y_2 - y_1 + 1) - s_sort = np.argsort(var_s) - pick = np.zeros_like(var_s, dtype=np.int16) - counter = 0 - while s_sort.size > 0: - i = s_sort[-1] - pick[counter] = i - counter += 1 - idx = s_sort[0:-1] - xx_1 = np.maximum(x_1[i], x_1[idx]) - yy_1 = np.maximum(y_1[i], y_1[idx]) - xx_2 = np.minimum(x_2[i], x_2[idx]) - yy_2 = np.minimum(y_2[i], y_2[idx]) - width = np.maximum(0.0, xx_2-xx_1+1) - height = np.maximum(0.0, yy_2-yy_1+1) - inter = width * height - if method == 'Min': - var_o = inter / np.minimum(area[i], area[idx]) - else: - var_o = inter / (area[i] + area[idx] - inter) - s_sort = s_sort[np.where(var_o <= threshold)] - pick = pick[0:counter] - return pick - - -# function [d_y ed_y d_x ed_x y e_y x e_x tmp_width tmp_height] = pad(total_boxes,width,height) -def pad(total_boxes, width, height): - """Compute the padding coordinates (pad the bounding boxes to square)""" - tmp_width = (total_boxes[:, 2] - total_boxes[:, 0] + 1).astype(np.int32) - tmp_height = (total_boxes[:, 3] - total_boxes[:, 1] + 1).astype(np.int32) - numbox = total_boxes.shape[0] - - d_x = np.ones((numbox), dtype=np.int32) - d_y = np.ones((numbox), dtype=np.int32) - ed_x = tmp_width.copy().astype(np.int32) - ed_y = tmp_height.copy().astype(np.int32) - - dim_x = total_boxes[:, 0].copy().astype(np.int32) - dim_y = total_boxes[:, 1].copy().astype(np.int32) - e_x = total_boxes[:, 2].copy().astype(np.int32) - e_y = total_boxes[:, 3].copy().astype(np.int32) - - tmp = np.where(e_x > width) - ed_x.flat[tmp] = np.expand_dims(-e_x[tmp] + width + tmp_width[tmp], 1) - e_x[tmp] = width - - tmp = np.where(e_y > height) - ed_y.flat[tmp] = np.expand_dims(-e_y[tmp] + height + tmp_height[tmp], 1) - e_y[tmp] = height - - tmp = np.where(dim_x < 1) - d_x.flat[tmp] = np.expand_dims(2 - dim_x[tmp], 1) - dim_x[tmp] = 1 - - tmp = np.where(dim_y < 1) - d_y.flat[tmp] = np.expand_dims(2 - dim_y[tmp], 1) - dim_y[tmp] = 1 - - return d_y, ed_y, d_x, ed_x, dim_y, e_y, dim_x, e_x, tmp_width, tmp_height - - -# function [bbox_a] = rerec(bbox_a) -def rerec(bbox_a): - """Convert bbox_a to square.""" - height = bbox_a[:, 3]-bbox_a[:, 1] - width = bbox_a[:, 2]-bbox_a[:, 0] - length = np.maximum(width, height) - bbox_a[:, 0] = bbox_a[:, 0] + width * 0.5 - length * 0.5 - bbox_a[:, 1] = bbox_a[:, 1] + height * 0.5 - length * 0.5 - bbox_a[:, 2:4] = bbox_a[:, 0:2] + np.transpose(np.tile(length, (2, 1))) - return bbox_a - - -def imresample(img, size): - """ Resample image """ - # pylint: disable=no-member - im_data = cv2.resize(img, (size[1], size[0]), - interpolation=cv2.INTER_AREA) # @UndefinedVariable - return im_data + +def rect2square(rectangles): + """ change rectangles into squares (matrix version) + Input: + rectangles: rectangles[i][0:3] is the position, rectangles[i][4] is score + Output: + squares: same as input + """ + width = rectangles[:, 2] - rectangles[:, 0] + height = rectangles[:, 3] - rectangles[:, 1] + length = np.maximum(width, height).T + rectangles[:, 0] = rectangles[:, 0] + width * 0.5 - length * 0.5 + rectangles[:, 1] = rectangles[:, 1] + height * 0.5 - length * 0.5 + rectangles[:, 2:4] = rectangles[:, 0:2] + np.repeat([length], 2, axis=0).T + return rectangles diff --git a/plugins/extract/detect/mtcnn_defaults.py b/plugins/extract/detect/mtcnn_defaults.py index 66925276e5..e50778dee4 100755 --- a/plugins/extract/detect/mtcnn_defaults.py +++ b/plugins/extract/detect/mtcnn_defaults.py @@ -22,6 +22,8 @@ , . default: [required] The default value for this option. info: [required] A string describing what this option does. + group: [optional]. A group for grouping options together in the GUI. If not + provided this will not group this option with any others. choices: [optional] If this option's datatype is of then valid selections can be defined here. This validates the option and also enables a combobox / radio option in the GUI. diff --git a/plugins/extract/detect/s3fd.py b/plugins/extract/detect/s3fd.py index 88b2f7d526..238a11a3a3 100644 --- a/plugins/extract/detect/s3fd.py +++ b/plugins/extract/detect/s3fd.py @@ -7,189 +7,244 @@ """ from scipy.special import logsumexp - import numpy as np +import keras +import keras.backend as K -from lib.multithreading import MultiThread +from lib.model.session import KSession from ._base import Detector, logger class Detect(Detector): """ S3FD detector for face recognition """ def __init__(self, **kwargs): - git_model_id = 3 - model_filename = "s3fd_v1.pb" + git_model_id = 11 + model_filename = "s3fd_keras_v1.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.name = "s3fd" - self.target = (640, 640) # Uses approx 4 GB of VRAM - self.vram = 4224 - self.min_vram = 1024 # Will run at this with warnings - self.model = None - - def initialize(self, *args, **kwargs): - """ Create the s3fd detector """ - try: - super().initialize(*args, **kwargs) - logger.info("Initializing S3FD Detector...") - card_id, vram_free, vram_total = self.get_vram_free() - if vram_free <= self.vram: - tf_ratio = 1.0 + self.name = "S3FD" + self.input_size = 640 + self.vram = 4096 + self.vram_warnings = 1024 # Will run at this with warnings + self.vram_per_batch = 128 + self.batchsize = self.config["batch-size"] + + def init_model(self): + """ Initialize S3FD Model""" + confidence = self.config["confidence"] / 100 + model_kwargs = dict(custom_objects=dict(O2K_Add=O2K_Add, + O2K_Slice=O2K_Slice, + O2K_Sum=O2K_Sum, + O2K_Sqrt=O2K_Sqrt, + O2K_Pow=O2K_Pow, + O2K_ConstantLayer=O2K_ConstantLayer, + O2K_Div=O2K_Div)) + self.model = S3fd(self.model_path, model_kwargs, confidence) + + def process_input(self, batch): + """ Compile the detection image(s) for prediction """ + batch["feed"] = self.model.prepare_batch(batch["scaled_image"]) + return batch + + def predict(self, batch): + """ Run model to get predictions """ + predictions = self.model.predict(batch["feed"]) + batch["prediction"] = self.model.finalize_predictions(predictions) + logger.trace("filename: %s, prediction: %s", batch["filename"], batch["prediction"]) + return batch + + def process_output(self, batch): + """ Compile found faces for output """ + return batch + + +################################################################################ +# CUSTOM KERAS LAYERS +# generated by onnx2keras +################################################################################ +class O2K_ElementwiseLayer(keras.engine.Layer): + def __init__(self, **kwargs): + super(O2K_ElementwiseLayer, self).__init__(**kwargs) + + def call(self, *args): + raise NotImplementedError() + + def compute_output_shape(self, input_shape): + # TODO: do this nicer + ldims = len(input_shape[0]) + rdims = len(input_shape[1]) + if ldims > rdims: + return input_shape[0] + if rdims > ldims: + return input_shape[1] + lprod = np.prod(list(filter(bool, input_shape[0]))) + rprod = np.prod(list(filter(bool, input_shape[1]))) + return input_shape[0 if lprod > rprod else 1] + + +class O2K_Add(O2K_ElementwiseLayer): + def call(self, x, *args): + return x[0] + x[1] + + +class O2K_Slice(keras.engine.Layer): + def __init__(self, starts, ends, axes=None, steps=None, **kwargs): + self._starts = starts + self._ends = ends + self._axes = axes + self._steps = steps + super(O2K_Slice, self).__init__(**kwargs) + + def get_config(self): + config = super(O2K_Slice, self).get_config() + config.update({ + 'starts': self._starts, 'ends': self._ends, + 'axes': self._axes, 'steps': self._steps + }) + return config + + def get_slices(self, ndims): + axes = self._axes + steps = self._steps + if axes is None: + axes = tuple(range(ndims)) + if steps is None: + steps = (1,) * len(axes) + assert len(axes) == len(steps) == len(self._starts) == len(self._ends) + return list(zip(axes, self._starts, self._ends, steps)) + + def compute_output_shape(self, input_shape): + input_shape = list(input_shape) + for ax, start, end, steps in self.get_slices(len(input_shape)): + size = input_shape[ax] + if ax == 0: + raise AttributeError("Can not slice batch axis.") + if size is None: + if start < 0 or end < 0: + raise AttributeError("Negative slices not supported on symbolic axes") + logger.warning("Slicing symbolic axis might lead to problems.") + input_shape[ax] = (end - start) // steps + continue + if start < 0: + start = size - start + if end < 0: + end = size - end + input_shape[ax] = (min(size, end) - start) // steps + return tuple(input_shape) + + def call(self, x, *args): + ax_map = dict((x[0], slice(*x[1:])) for x in self.get_slices(K.ndim(x))) + shape = K.int_shape(x) + slices = [(ax_map[a] if a in ax_map else slice(None)) for a in range(len(shape))] + x = x[tuple(slices)] + return x + + +class O2K_ReduceLayer(keras.engine.Layer): + def __init__(self, axes=None, keepdims=True, **kwargs): + self._axes = [axes] if isinstance(axes, int) else axes + self._keepdims = bool(keepdims) + super(O2K_ReduceLayer, self).__init__(**kwargs) + + def get_config(self): + config = super(O2K_ReduceLayer, self).get_config() + config.update({ + 'axes': self._axes, + 'keepdims': self._keepdims + }) + return config + + def compute_output_shape(self, input_shape): + if self._axes is None: + return (1,)*len(input_shape) if self._keepdims else tuple() + ret = list(input_shape) + for i in sorted(self._axes, reverse=True): + if self._keepdims: + ret[i] = 1 else: - tf_ratio = self.vram / vram_total + ret.pop(i) + return tuple(ret) - logger.verbose("Reserving %s%% of total VRAM per s3fd thread", - round(tf_ratio * 100, 2)) + def call(self, x, *args): + raise NotImplementedError() - confidence = self.config["confidence"] / 100 - self.model = S3fd(self.model_path, self.target, tf_ratio, card_id, confidence) - if not self.model.is_gpu: - alloc = 2048 - logger.warning("Using CPU") - else: - logger.debug("Using GPU") - alloc = vram_free - logger.debug("Allocated for Tensorflow: %sMB", alloc) - - if self.min_vram < alloc < self.vram: - self.batch_size = 1 - logger.warning("You are running s3fd with %sMB VRAM. The model is optimized for " - "%sMB VRAM. Detection should still run but you may get " - "warnings/errors", int(alloc), self.vram) - else: - self.batch_size = int(alloc / self.vram) - if self.batch_size < 1: - raise ValueError("Insufficient VRAM available to continue " - "({}MB)".format(int(alloc))) - - logger.verbose("Processing in %s threads", self.batch_size) - - self.init.set() - logger.info("Initialized S3FD Detector.") - except Exception as err: - self.error.set() - raise err - - def detect_faces(self, *args, **kwargs): - """ Detect faces in Multiple Threads """ - super().detect_faces(*args, **kwargs) - workers = MultiThread(target=self.detect_thread, thread_count=self.batch_size) - workers.start() - workers.join() - sentinel = self.queues["in"].get() - self.queues["out"].put(sentinel) - logger.debug("Detecting Faces complete") - - def detect_thread(self): - """ Detect faces in rgb image """ - logger.debug("Launching Detect") - while True: - item = self.get_item() - if item == "EOF": - break - logger.trace("Detecting faces: '%s'", item["filename"]) - detect_image, scale = self.compile_detection_image(item["image"], is_square=True) - for angle in self.rotation: - current_image, rotmat = self.rotate_image(detect_image, angle) - faces = self.model.detect_face(current_image) - if angle != 0 and faces.any(): - logger.verbose("found face(s) by rotating image %s degrees", angle) - if faces.any(): - break - - detected_faces = self.process_output(faces, rotmat, scale) - item["detected_faces"] = detected_faces - self.finalize(item) - - logger.debug("Thread Completed Detect") - - def process_output(self, faces, rotation_matrix, scale): - """ Compile found faces for output """ - logger.trace("Processing Output: (faces: %s, rotation_matrix: %s)", faces, rotation_matrix) - faces = [self.to_bounding_box_dict(face[0], face[1], face[2], face[3]) for face in faces] - if isinstance(rotation_matrix, np.ndarray): - faces = [self.rotate_rect(face, rotation_matrix) - for face in faces] - detected = [self.to_bounding_box_dict(face["left"] / scale, face["top"] / scale, - face["right"] / scale, face["bottom"] / scale) - for face in faces] - logger.trace("Processed Output: %s", detected) - return detected - - -class S3fd(): - """ Tensorflow Network """ - def __init__(self, model_path, target_size, vram_ratio, card_id, confidence): - logger.debug("Initializing: %s: (model_path: '%s', target_size: %s, vram_ratio: %s, " - "card_id: %s)", - self.__class__.__name__, model_path, target_size, vram_ratio, card_id) - # Must import tensorflow inside the spawned process for Windows machines - import tensorflow as tf - self.is_gpu = False - self.tf = tf # pylint: disable=invalid-name - self.model_path = model_path +class O2K_Sum(O2K_ReduceLayer): + def call(self, x, *args): + return K.sum(x, self._axes, self._keepdims) + + +class O2K_Sqrt(keras.engine.Layer): + def call(self, x, *args): + return K.sqrt(x) + + +class O2K_Pow(keras.engine.Layer): + def call(self, x, *args): + return K.pow(*x) + + +class O2K_ConstantLayer(keras.engine.Layer): + def __init__(self, constant_obj, dtype, **kwargs): + self._dtype = np.dtype(dtype).name + self._constant = np.array(constant_obj, dtype=self._dtype) + super(O2K_ConstantLayer, self).__init__(**kwargs) + + def call(self, *args): + # pylint:disable=arguments-differ + data = K.constant(self._constant, dtype=self._dtype) + return data + + def compute_output_shape(self, input_shape): + return self._constant.shape + + def get_config(self): + config = super(O2K_ConstantLayer, self).get_config() + config.update({ + 'constant_obj': self._constant, + 'dtype': self._dtype + }) + return config + + +class O2K_Div(O2K_ElementwiseLayer): + # pylint:disable=arguments-differ + def call(self, x, *args): + return x[0] / x[1] + + +class S3fd(KSession): + """ Keras Network """ + def __init__(self, model_path, model_kwargs, confidence): + logger.debug("Initializing: %s: (model_path: '%s')", + self.__class__.__name__, model_path) + super().__init__("S3FD", model_path, model_kwargs) + self.load_model() self.confidence = confidence - self.graph = self.load_graph() - self.input = self.graph.get_tensor_by_name("s3fd/input_1:0") - self.output = self.get_outputs() - self.session = self.set_session(target_size, vram_ratio, card_id) logger.debug("Initialized: %s", self.__class__.__name__) - def load_graph(self): - """ Load the tensorflow Model and weights """ - # pylint: disable=not-context-manager - logger.verbose("Initializing S3FD Network model...") - with self.tf.gfile.GFile(self.model_path, "rb") as gfile: - graph_def = self.tf.GraphDef() - graph_def.ParseFromString(gfile.read()) - fa_graph = self.tf.Graph() - with fa_graph.as_default(): - self.tf.import_graph_def(graph_def, name="s3fd") - return fa_graph - - def get_outputs(self): - """ Return the output tensors """ - tensor_names = ["concat_31", "transpose_72", "transpose_75", "transpose_78", - "transpose_81", "transpose_84", "transpose_87", "transpose_90", - "transpose_93", "transpose_96", "transpose_99", "transpose_102"] - logger.debug("tensor_names: %s", tensor_names) - tensors = [self.graph.get_tensor_by_name("s3fd/{}:0".format(t_name)) - for t_name in tensor_names] - logger.debug("tensors: %s", tensors) - return tensors - - def set_session(self, target_size, vram_ratio, card_id): - """ Set the TF Session and initialize """ - # pylint: disable=not-context-manager, no-member - placeholder = np.zeros((1, 3, target_size[0], target_size[1])) - config = self.tf.ConfigProto() - if card_id != -1: - config.gpu_options.visible_device_list = str(card_id) - if vram_ratio != 1.0: - config.gpu_options.per_process_gpu_memory_fraction = vram_ratio - - with self.graph.as_default(): - session = self.tf.Session(config=config) - self.is_gpu = any("gpu" in str(device).lower() for device in session.list_devices()) - session.run(self.output, feed_dict={self.input: placeholder}) - return session - - def detect_face(self, feed_item): - """ Detect faces """ - feed_item = feed_item - np.array([104.0, 117.0, 123.0]) - feed_item = feed_item.transpose(2, 0, 1) - feed_item = feed_item.reshape((1,) + feed_item.shape).astype('float32') - bboxlist = self.session.run(self.output, feed_dict={self.input: feed_item}) - bboxlist = self.post_process(bboxlist) - - keep = self.nms(bboxlist, 0.3) - bboxlist = bboxlist[keep, :] - bboxlist = [x for x in bboxlist if x[-1] >= self.confidence] + @staticmethod + def prepare_batch(batch): + """ Prepare a batch for prediction """ + batch = batch - np.array([104.0, 117.0, 123.0]) + batch = batch.transpose(0, 3, 1, 2) + return batch - return np.array(bboxlist) + def finalize_predictions(self, bboxlists): + """ Detect faces """ + ret = list() + for i in range(bboxlists[0].shape[0]): + bboxlist = [x[i:i+1, ...] for x in bboxlists] + bboxlist = self.post_process(bboxlist) + keep = self.nms(bboxlist, 0.3) + bboxlist = bboxlist[keep, :] + bboxlist = [x for x in bboxlist if x[-1] >= self.confidence] + ret.append(np.array(bboxlist)) + return ret def post_process(self, bboxlist): - """ Perform post processing on output """ + """ Perform post processing on output + TODO: do this on the batch. + """ retval = list() for i in range(len(bboxlist) // 2): bboxlist[i * 2] = self.softmax(bboxlist[i * 2], axis=1) @@ -238,6 +293,7 @@ def decode(loc, priors, variances): @staticmethod def nms(dets, thresh): + # pylint:disable=too-many-locals """ Perform Non-Maximum Suppression """ keep = list() if len(dets) == 0: diff --git a/plugins/extract/detect/s3fd_amd.py b/plugins/extract/detect/s3fd_amd.py deleted file mode 100644 index 4dbcc06f94..0000000000 --- a/plugins/extract/detect/s3fd_amd.py +++ /dev/null @@ -1,492 +0,0 @@ -#!/usr/bin/env python3 -""" S3FD Face detection plugin -https://arxiv.org/abs/1708.05237 - -Adapted from S3FD Port in FAN: -https://github.com/1adrianb/face-alignment -""" - -from scipy.special import logsumexp -import numpy as np -from ._base import Detector, logger -import keras -import keras.backend as K -from lib.multithreading import FSThread -from lib.queue_manager import queue_manager -import queue -from os.path import basename - - -class Detect(Detector): - """ S3FD detector for face recognition """ - def __init__(self, **kwargs): - git_model_id = 11 - model_filename = "s3fd_keras_v1.h5" - super().__init__( - git_model_id=git_model_id, model_filename=model_filename, - **kwargs - ) - self.name = "s3fd_amd" - self.target = (640, 640) # Uses approx 4 GB of VRAM - self.vram = 4096 - self.min_vram = 1024 # Will run at this with warnings - self.model = None - self.got_input_eof = False - self.rotate_queue = None # set in the detect_faces method - self.supports_plaidml = True - - def initialize(self, *args, **kwargs): - """ Create the s3fd detector """ - try: - super().initialize(*args, **kwargs) - logger.info("Initializing S3FD-AMD Detector...") - confidence = self.config["confidence"] / 100 - self.batch_size = self.config["batch-size"] - self.model = S3fd_amd(self.model_path, self.target, confidence) - self.init.set() - logger.info( - "Initialized S3FD-AMD Detector with batchsize of %i.", self.batch_size - ) - except Exception as err: - self.error.set() - raise err - - def post_processing_thread(self, in_queue, again_queue): - # If -r is set we move images without found faces and remaining - # rotations to a queue which is "merged" with the intial input queue. - # This also means it is possible that we get data after an EOF. - # This is handled by counting open rotation jobs and propagating - # a second EOF as soon as we are really done through - # the preprocsessing thread (detect_faces) and the prediction thread. - open_rot_jobs = 0 - got_first_eof = False - while True: - job = in_queue.get() - if job == "EOF": - logger.debug("S3fd-amd post processing got EOF") - got_first_eof = True - else: - predictions, items = job - bboxes = self.model.finalize_predictions(predictions) - for bbox, item in zip(bboxes, items): - s3fd_opts = item["_s3fd"] - detected_faces = self.process_output(bbox, s3fd_opts) - did_rotation = s3fd_opts["rotations"].pop(0) != 0 - if detected_faces: - item["detected_faces"] = detected_faces - del item["_s3fd"] - self.finalize(item) - if did_rotation: - open_rot_jobs -= 1 - logger.trace("Found face after rotation.") - elif s3fd_opts["rotations"]: # we have remaining rotations - logger.trace("No face detected, remaining rotations: %s", s3fd_opts["rotations"]) - if not did_rotation: - open_rot_jobs += 1 - logger.trace("Rotate face %s and try again.", item["filename"]) - again_queue.put(item) - else: - logger.debug("No face detected for %s.", item["filename"]) - open_rot_jobs -= 1 - item["detected_faces"] = [] - del item["_s3fd"] - self.finalize(item) - if got_first_eof and open_rot_jobs <= 0: - logger.debug("Sending second EOF") - again_queue.put("EOF") - self.finalize("EOF") - break - - def prediction_thread(self, in_queue, out_queue): - got_first_eof = False - while True: - job = in_queue.get() - if job == "EOF": - logger.debug("S3fd-amd prediction processing got EOF") - if got_first_eof: - break - out_queue.put(job) - got_first_eof = True - continue - batch, items = job - predictions = self.model.predict(batch) - out_queue.put((predictions, items)) - - def detect_faces(self, *args, **kwargs): - """ Detect faces in rgb image """ - super().detect_faces(*args, **kwargs) - self.rotate_queue = queue_manager.get_queue("s3fd_rotate", 8, False) - prediction_queue = queue_manager.get_queue("s3fd_pred", 8, False) - post_queue = queue_manager.get_queue("s3fd_post", 8, False) - worker = FSThread( - target=self.prediction_thread, args=(prediction_queue, post_queue) - ) - post_worker = FSThread( - target=self.post_processing_thread, args=(post_queue, self.rotate_queue) - ) - worker.start() - post_worker.start() - - got_first_eof = False - while True: - worker.check_and_raise_error() - post_worker.check_and_raise_error() - got_eof, in_batch = self.get_batch() - batch = list() - for item in in_batch: - s3fd_opts = item.setdefault("_s3fd", {}) - if "scaled_img" not in s3fd_opts: - logger.trace("Resizing %s" % basename(item["filename"])) - detect_image, scale, pads = self.compile_detection_image( - item["image"], is_square=True, pad_to=self.target - ) - s3fd_opts["scale"] = scale - s3fd_opts["pads"] = pads - s3fd_opts["rotations"] = list(self.rotation) - s3fd_opts["rotmatrix"] = None # the first "rotation" is always 0 - img = s3fd_opts["scaled_img"] = detect_image - else: - logger.trace("Rotating %s" % basename(item["filename"])) - angle = s3fd_opts["rotations"][0] - img, rotmat = self.rotate_image_by_angle( - s3fd_opts["scaled_img"], angle, *self.target - ) - s3fd_opts["rotmatrix"] = rotmat - batch.append((img, item)) - - if batch: - batch_data = np.array([x[0] for x in batch], dtype="float32") - batch_data = self.model.prepare_batch(batch_data) - batch_items = [x[1] for x in batch] - prediction_queue.put((batch_data, batch_items)) - - if got_eof: - logger.debug("S3fd-amd main worker got EOF") - prediction_queue.put("EOF") - # Required to prevent hanging when less then BS items are in the - # again queue and we won't receive new images. - self.batch_size = 1 - if got_first_eof: - break - got_first_eof = True - - logger.debug("Joining s3fd-amd worker") - worker.join() - post_worker.join() - for qname in (): - queue_manager.del_queue(qname) - logger.debug("Detecting Faces complete") - - def process_output(self, faces, opts): - """ Compile found faces for output """ - logger.trace( - "Processing Output: (faces: %s, rotation_matrix: %s)", - faces, opts["rotmatrix"] - ) - detected = [] - scale = opts["scale"] - pad_l, pad_t = opts["pads"] - rot = opts["rotmatrix"] - for face in faces: - face = self.to_bounding_box_dict(face[0], face[1], face[2], face[3]) - if isinstance(rot, np.ndarray): - face = self.rotate_rect(face, rot) - face = self.to_bounding_box_dict( - (face["left"] - pad_l) / scale, - (face["top"] - pad_t) / scale, - (face["right"] - pad_l) / scale, - (face["bottom"] - pad_t) / scale - ) - detected.append(face) - logger.trace("Processed Output: %s", detected) - return detected - - def get_item(self): - """ - Yield one item from the input or rotation - queue while prioritizing rotation queue to - prevent deadlocks. - """ - try: - item = self.rotate_queue.get(block=self.got_input_eof) - return item - except queue.Empty: - pass - item = super(Detect, self).get_item() - if not isinstance(item, dict) and item == "EOF": - self.got_input_eof = True - return item - - -################################################################################ -# CUSTOM KERAS LAYERS -# generated by onnx2keras -################################################################################ -class O2K_ElementwiseLayer(keras.engine.Layer): - def __init__(self, **kwargs): - super(O2K_ElementwiseLayer, self).__init__(**kwargs) - - def call(self, *args): - raise NotImplementedError() - - def compute_output_shape(self, input_shape): - # TODO: do this nicer - ldims = len(input_shape[0]) - rdims = len(input_shape[1]) - if ldims > rdims: - return input_shape[0] - if rdims > ldims: - return input_shape[1] - lprod = np.prod(list(filter(bool, input_shape[0]))) - rprod = np.prod(list(filter(bool, input_shape[1]))) - return input_shape[0 if lprod > rprod else 1] - - -class O2K_Add(O2K_ElementwiseLayer): - def call(self, x, *args): - return x[0] + x[1] - - -class O2K_Slice(keras.engine.Layer): - def __init__(self, starts, ends, axes=None, steps=None, **kwargs): - self._starts = starts - self._ends = ends - self._axes = axes - self._steps = steps - super(O2K_Slice, self).__init__(**kwargs) - - def get_config(self): - config = super(O2K_Slice, self).get_config() - config.update({ - 'starts': self._starts, 'ends': self._ends, - 'axes': self._axes, 'steps': self._steps - }) - return config - - def get_slices(self, ndims): - axes = self._axes - steps = self._steps - if axes is None: - axes = tuple(range(ndims)) - if steps is None: - steps = (1,) * len(axes) - assert len(axes) == len(steps) == len(self._starts) == len(self._ends) - return list(zip(axes, self._starts, self._ends, steps)) - - def compute_output_shape(self, input_shape): - input_shape = list(input_shape) - for ax, start, end, steps in self.get_slices(len(input_shape)): - size = input_shape[ax] - if ax == 0: - raise AttributeError("Can not slice batch axis.") - if size is None: - if start < 0 or end < 0: - raise AttributeError("Negative slices not supported on symbolic axes") - logger.warning("Slicing symbolic axis might lead to problems.") - input_shape[ax] = (end - start) // steps - continue - if start < 0: - start = size - start - if end < 0: - end = size - end - input_shape[ax] = (min(size, end) - start) // steps - return tuple(input_shape) - - def call(self, x, *args): - ax_map = dict((x[0], slice(*x[1:])) for x in self.get_slices(K.ndim(x))) - shape = K.int_shape(x) - slices = [(ax_map[a] if a in ax_map else slice(None)) for a in range(len(shape))] - x = x[tuple(slices)] - return x - - -class O2K_ReduceLayer(keras.engine.Layer): - def __init__(self, axes=None, keepdims=True, **kwargs): - self._axes = [axes] if isinstance(axes, int) else axes - self._keepdims = bool(keepdims) - super(O2K_ReduceLayer, self).__init__(**kwargs) - - def get_config(self): - config = super(O2K_ReduceLayer, self).get_config() - config.update({ - 'axes': self._axes, - 'keepdims': self._keepdims - }) - return config - - def compute_output_shape(self, input_shape): - if self._axes is None: - return (1,)*len(input_shape) if self._keepdims else tuple() - ret = list(input_shape) - for i in sorted(self._axes, reverse=True): - if self._keepdims: - ret[i] = 1 - else: - ret.pop(i) - return tuple(ret) - - def call(self, x, *args): - raise NotImplementedError() - - -class O2K_Sum(O2K_ReduceLayer): - def call(self, x, *args): - return K.sum(x, self._axes, self._keepdims) - - -class O2K_Sqrt(keras.engine.Layer): - def call(self, x, *args): - return K.sqrt(x) - - -class O2K_Pow(keras.engine.Layer): - def call(self, x, *args): - return K.pow(*x) - - -class O2K_ConstantLayer(keras.engine.Layer): - def __init__(self, constant_obj, dtype, **kwargs): - self._dtype = np.dtype(dtype).name - self._constant = np.array(constant_obj, dtype=self._dtype) - super(O2K_ConstantLayer, self).__init__(**kwargs) - - def call(self, *args): - data = K.constant(self._constant, dtype=self._dtype) - return data - - def compute_output_shape(self, input_shape): - return self._constant.shape - - def get_config(self): - config = super(O2K_ConstantLayer, self).get_config() - config.update({ - 'constant_obj': self._constant, - 'dtype': self._dtype - }) - return config - - -class O2K_Div(O2K_ElementwiseLayer): - def call(self, x, *args): - return x[0] / x[1] - - -class S3fd_amd(): - """ Keras Network """ - def __init__(self, model_path, target_size, confidence): - logger.debug("Initializing: %s: (model_path: '%s')", - self.__class__.__name__, model_path) - self.model_path = model_path - self.confidence = confidence - self.model = self.load_model() - logger.debug("Initialized: %s", self.__class__.__name__) - - def load_model(self): - """ Load the keras Model and weights """ - logger.verbose("Initializing S3FD_amd Network model...") - layers = { - 'O2K_Add': O2K_Add, 'O2K_Slice': O2K_Slice, - 'O2K_Sum': O2K_Sum, 'O2K_Sqrt': O2K_Sqrt, - 'O2K_Pow': O2K_Pow, 'O2K_ConstantLayer': O2K_ConstantLayer, - 'O2K_Div': O2K_Div - } - model = keras.models.load_model(self.model_path, custom_objects=layers) - model._make_predict_function() # pylint: disable=protected-access - return model - - def prepare_batch(self, batch): - batch = batch - np.array([104.0, 117.0, 123.0]) - batch = batch.transpose(0, 3, 1, 2) - return batch - - def predict(self, batch): - bboxlists = self.model.predict(batch) - return bboxlists - - def finalize_predictions(self, bboxlists): - """ Detect faces """ - ret = list() - for i in range(bboxlists[0].shape[0]): - bboxlist = [x[i:i+1, ...] for x in bboxlists] - bboxlist = self.post_process(bboxlist) - keep = self.nms(bboxlist, 0.3) - bboxlist = bboxlist[keep, :] - bboxlist = [x for x in bboxlist if x[-1] >= self.confidence] - ret.append(np.array(bboxlist)) - return ret - - def post_process(self, bboxlist): - """ Perform post processing on output - TODO: do this on the batch. - """ - retval = list() - for i in range(len(bboxlist) // 2): - bboxlist[i * 2] = self.softmax(bboxlist[i * 2], axis=1) - for i in range(len(bboxlist) // 2): - ocls, oreg = bboxlist[i * 2], bboxlist[i * 2 + 1] - stride = 2 ** (i + 2) # 4,8,16,32,64,128 - poss = zip(*np.where(ocls[:, 1, :, :] > 0.05)) - for _, hindex, windex in poss: - axc, ayc = stride / 2 + windex * stride, stride / 2 + hindex * stride - score = ocls[0, 1, hindex, windex] - loc = np.ascontiguousarray(oreg[0, :, hindex, windex]).reshape((1, 4)) - priors = np.array([[axc / 1.0, ayc / 1.0, stride * 4 / 1.0, stride * 4 / 1.0]]) - variances = [0.1, 0.2] - box = self.decode(loc, priors, variances) - x_1, y_1, x_2, y_2 = box[0] * 1.0 - retval.append([x_1, y_1, x_2, y_2, score]) - retval = np.array(retval) - if len(retval) == 0: - retval = np.zeros((1, 5)) - return retval - - @staticmethod - def softmax(inp, axis): - """Compute softmax values for each sets of scores in x.""" - return np.exp(inp - logsumexp(inp, axis=axis, keepdims=True)) - - @staticmethod - def decode(loc, priors, variances): - """Decode locations from predictions using priors to undo - the encoding we did for offset regression at train time. - Args: - loc (tensor): location predictions for loc layers, - Shape: [num_priors,4] - priors (tensor): Prior boxes in center-offset form. - Shape: [num_priors,4]. - variances: (list[float]) Variances of priorboxes - Return: - decoded bounding box predictions - """ - boxes = np.concatenate((priors[:, :2] + loc[:, :2] * variances[0] * priors[:, 2:], - priors[:, 2:] * np.exp(loc[:, 2:] * variances[1])), - 1) - boxes[:, :2] -= boxes[:, 2:] / 2 - boxes[:, 2:] += boxes[:, :2] - return boxes - - @staticmethod - def nms(dets, thresh): - """ Perform Non-Maximum Suppression """ - keep = list() - if len(dets) == 0: - return keep - - x_1, y_1, x_2, y_2, scores = dets[:, 0], dets[:, 1], dets[:, 2], dets[:, 3], dets[:, 4] - areas = (x_2 - x_1 + 1) * (y_2 - y_1 + 1) - order = scores.argsort()[::-1] - - keep = [] - while order.size > 0: - i = order[0] - keep.append(i) - xx_1, yy_1 = np.maximum(x_1[i], x_1[order[1:]]), np.maximum(y_1[i], y_1[order[1:]]) - xx_2, yy_2 = np.minimum(x_2[i], x_2[order[1:]]), np.minimum(y_2[i], y_2[order[1:]]) - - width, height = np.maximum(0.0, xx_2 - xx_1 + 1), np.maximum(0.0, yy_2 - yy_1 + 1) - ovr = width * height / (areas[i] + areas[order[1:]] - width * height) - - inds = np.where(ovr <= thresh)[0] - order = order[inds + 1] - - return keep diff --git a/plugins/extract/detect/s3fd_defaults.py b/plugins/extract/detect/s3fd_defaults.py index 0f5589263a..59fcb0782d 100755 --- a/plugins/extract/detect/s3fd_defaults.py +++ b/plugins/extract/detect/s3fd_defaults.py @@ -16,28 +16,30 @@ dictionary requirements are listed below. The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid datatypes are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + group: [optional]. A group for grouping options together in the GUI. If not + provided this will not group this option with any others. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and datatypes this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and datatypes this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. """ @@ -59,5 +61,19 @@ "choices": [], "gui_radio": False, "fixed": True, + }, + "batch-size": { + "default": 8, + "info": "The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered." + "\n\tAMD users: A batchsize of 8 requires about 2 GB vram.", + "datatype": int, + "rounding": 1, + "min_max": (1, 64), + "choices": [], + "gui_radio": False, + "fixed": True, } } diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index fbfeaa51f8..632ed4dc91 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -2,102 +2,271 @@ """ Return a requested detector/aligner pipeline -Tensorflow does not like to release GPU VRAM, so these are launched in subprocesses -so that the vram is released on subprocess exit """ +Tensorflow does not like to release GPU VRAM, so parallel plugins need to be managed to work +together. + +This module sets up a pipeline for the extraction workflow, loading align and detect plugins +either in parallal or in series, giving easy access to input and output. + + """ import logging from lib.gpu_stats import GPUStats -from lib.multithreading import PoolProcess, SpawnProcess from lib.queue_manager import queue_manager, QueueEmpty +from lib.utils import get_backend from plugins.plugin_loader import PluginLoader logger = logging.getLogger(__name__) # pylint:disable=invalid-name class Extractor(): - """ Creates a detect/align pipeline and returns results from a generator - - Input queue is dynamically set depending on the current phase of extraction - and can be accessed from: - Extractor.input_queue + """ Creates a :mod:`~plugins.extract.detect`/:mod:`~plugins.extract.align` pipeline and yields + results frame by frame from the :attr:`detected_faces` generator + + :attr:`input_queue` is dynamically set depending on the current :attr:`phase` of extraction + + Parameters + ---------- + detector: str + The name of a detector plugin as exists in :mod:`plugins.extract.detect` + aligner: str + The name of an aligner plugin as exists in :mod:`plugins.extract.align` + configfile: str, optional + The path to a custom ``extract.ini`` configfile. If ``None`` then the system + :file:`config/extract.ini` file will be used. + multiprocess: bool, optional + Whether to attempt processing the plugins in parallel. This may get overridden + internally depending on the plugin combination. Default: ``False`` + rotate_images: str, optional + Used to set the :attr:`~plugins.extract.detect.rotation` attribute. Pass in a single number + to use increments of that size up to 360, or pass in a ``list`` of ``ints`` to enumerate + exactly what angles to check. Can also pass in ``'on'`` to increment at 90 degree + intervals. Default: ``None`` + min_size: int, optional + Used to set the :attr:`~plugins.extract.detect.min_size` attribute Filters out faces + detected below this size. Length, in pixels across the diagonal of the bounding box. Set + to ``0`` for off. Default: ``0`` + normalize_method: {`None`, 'clahe', 'hist', 'mean'}, optional + Used to set the :attr:`~plugins.extract.align.normalize_method` attribute. Normalize the + images fed to the aligner.Default: ``None`` + + Attributes + ---------- + phase: str + The current phase that the pipeline is running. Used in conjunction with :attr:`passes` and + :attr:`final_pass` to indicate to the caller which phase is being processed """ - def __init__(self, detector, aligner, loglevel, + def __init__(self, detector, aligner, configfile=None, multiprocess=False, rotate_images=None, min_size=20, normalize_method=None): - logger.debug("Initializing %s: (detector: %s, aligner: %s, loglevel: %s, configfile: %s, " + logger.debug("Initializing %s: (detector: %s, aligner: %s, configfile: %s, " "multiprocess: %s, rotate_images: %s, min_size: %s, " "normalize_method: %s)", self.__class__.__name__, detector, aligner, - loglevel, configfile, multiprocess, rotate_images, min_size, - normalize_method) + configfile, multiprocess, rotate_images, min_size, normalize_method) self.phase = "detect" - self.detector = self.load_detector(detector, loglevel, rotate_images, min_size, configfile) - self.aligner = self.load_aligner(aligner, loglevel, configfile, normalize_method) - self.is_parallel = self.set_parallel_processing(multiprocess) - self.processes = list() - self.queues = self.add_queues() + self._queue_size = 32 + self._vram_buffer = 320 # Leave a buffer for VRAM allocation + self._detector = self._load_detector(detector, rotate_images, min_size, configfile) + self._aligner = self._load_aligner(aligner, configfile, normalize_method) + self._is_parallel = self._set_parallel_processing(multiprocess) + self._queues = self._add_queues() logger.debug("Initialized %s", self.__class__.__name__) @property def input_queue(self): - """ Return the correct input queue depending on the current phase """ - if self.is_parallel or self.phase == "detect": + """ queue: Return the correct input queue depending on the current phase + + The input queue is the entry point into the extraction pipeline. A ``dict`` should + be put to the queue in the following format(s): + + For detect/single phase operations: + + >>> {'filename': , + >>> 'image': } + + For align (2nd pass operations): + + >>> {'filename': , + >>> 'image': , + >>> 'detected_faces: []} + + """ + if self._is_parallel or self.phase == "detect": qname = "extract_detect_in" else: qname = "extract_align_in" - retval = self.queues[qname] - logger.trace("%s: %s", qname, retval) - return retval - - @property - def output_queue(self): - """ Return the correct output queue depending on the current phase """ - qname = "extract_align_out" if self.final_pass else "extract_align_in" - retval = self.queues[qname] + retval = self._queues[qname] logger.trace("%s: %s", qname, retval) return retval @property def passes(self): - """ Return the number of passes the extractor needs to make """ - retval = 1 if self.is_parallel else 2 + """ int: Returns the total number of passes the extractor needs to make. + + This is calculated on several factors (vram available, plugin choice, + :attr:`multiprocess` etc.). It is useful for iterating over the pipeline + and handling accordingly. + + Example + ------- + >>> for phase in extractor.passes: + >>> if phase == 1: + >>> extractor.input_queue.put({"filename": "path/to/image/file", + >>> "image": np.array(image)}) + >>> else: + >>> extractor.input_queue.put({"filename": "path/to/image/file", + >>> "image": np.array(image), + >>> "detected_faces": [>> for face in extractor.detected_faces(): + >>> if extractor.final_pass: + >>> + >>> else: + >>> + >>> extractor.input_queue.put({"filename": "path/to/image/file", + >>> "image": np.array(image), + >>> "detected_faces": [>> for phase in extractor.passes: + >>> extractor.launch(): + >>> + """ + + if self._is_parallel: + self._launch_aligner() + self._launch_detector() + elif self.phase == "detect": + self._launch_detector() + else: + self._launch_aligner() + + def detected_faces(self): + """ Generator that returns results, frame by frame from the extraction pipeline + + This is the exit point for the extraction pipeline and is used to obtain the output + of any pipeline :attr:`phase` + + Yields + ------ + faces: dict + regardless of phase, the returned dictinary will contain, exclusively, ``filename``: + the filename of the source image, ``image``: the ``numpy.array`` of the source image + in BGR color format, ``detected_faces``: a list of + :class:`~lib.faces_detect.Detected_Face` objects. + + Example + ------- + >>> for face in extractor.detected_faces(): + >>> filename = face["filename"] + >>> image = face["image"] + >>> detected_faces = face["detected_faces"] + """ + logger.debug("Running Detection. Phase: '%s'", self.phase) + # If not multiprocessing, intercept the align in queue for + # detection phase + out_queue = self._output_queue + while True: + try: + if self._check_and_raise_error(): + break + faces = out_queue.get(True, 1) + if faces == "EOF": + break + except QueueEmpty: + continue + + yield faces + self._join_threads() + if self.final_pass: + # Cleanup queues + for q_name in self._queues.keys(): + queue_manager.del_queue(q_name) + logger.debug("Detection Complete") + else: + logger.debug("Switching to align phase") + self.phase = "align" + + # <<< INTERNAL METHODS >>> # + @property + def _output_queue(self): + """ Return the correct output queue depending on the current phase """ + qname = "extract_align_out" if self.final_pass else "extract_align_in" + retval = self._queues[qname] + logger.trace("%s: %s", qname, retval) + return retval + + @property + def _active_plugins(self): + """ Return the plugins that are currently active based on pass """ + if self.passes == 1: + retval = [self._detector, self._aligner] + elif self.passes == 2 and not self.final_pass: + retval = [self._detector] + else: + retval = [self._aligner] + logger.trace("Active plugins: %s", retval) + return retval + + def _add_queues(self): + """ Add the required processing queues to Queue Manager """ + queues = dict() + for task in ("extract_detect_in", "extract_align_in", "extract_align_out"): + # Limit queue size to avoid stacking ram + self._queue_size = 32 + if task == "extract_detect_in" or (not self._is_parallel + and task == "extract_align_in"): + self._queue_size = 64 + queue_manager.add_queue(task, maxsize=self._queue_size) + queues[task] = queue_manager.get_queue(task) + logger.debug("Queues: %s", queues) + return queues - if detector_vram == 0 or aligner_vram == 0: + def _set_parallel_processing(self, multiprocess): + """ Set whether to run detect and align together or separately """ + if self._detector.vram == 0 or self._aligner.vram == 0: logger.debug("At least one of aligner or detector have no VRAM requirement. " "Enabling parallel processing.") return True @@ -107,168 +276,94 @@ def set_parallel_processing(self, multiprocess): return False gpu_stats = GPUStats() - if gpu_stats.is_plaidml and (not self.detector.supports_plaidml or - not self.aligner.supports_plaidml): - logger.debug("At least one of aligner or detector does not support plaidML. " - "Enabling parallel processing.") - return True - - if not gpu_stats.is_plaidml and ( - (self.detector.supports_plaidml and aligner_vram != 0) or - (self.aligner.supports_plaidml and detector_vram != 0)): - logger.warning("Keras + non-Keras aligner/detector combination does not support " - "parallel processing. Switching to serial.") - return False - - if self.detector.supports_plaidml and self.aligner.supports_plaidml: - logger.debug("Both aligner and detector support plaidML. Disabling parallel " - "processing.") - return False - if gpu_stats.device_count == 0: logger.debug("No GPU detected. Enabling parallel processing.") return True - required_vram = detector_vram + aligner_vram + 320 # 320MB buffer + if get_backend() == "amd": + logger.debug("Parallel processing discabled by amd") + return False + + vram_required = self._detector.vram + self._aligner.vram + self._vram_buffer stats = gpu_stats.get_card_most_free() - free_vram = int(stats["free"]) + vram_free = int(stats["free"]) logger.verbose("%s - %sMB free of %sMB", stats["device"], - free_vram, + vram_free, int(stats["total"])) - if free_vram <= required_vram: + if vram_free <= vram_required: logger.warning("Not enough free VRAM for parallel processing. " "Switching to serial") return False + + self._set_extractor_batchsize(vram_required, vram_free) return True - def add_queues(self): - """ Add the required processing queues to Queue Manager """ - queues = dict() - for task in ("extract_detect_in", "extract_align_in", "extract_align_out"): - # Limit queue size to avoid stacking ram - size = 32 - if task == "extract_detect_in" or (not self.is_parallel - and task == "extract_align_in"): - size = 64 - queue_manager.add_queue(task, maxsize=size) - queues[task] = queue_manager.get_queue(task) - logger.debug("Queues: %s", queues) - return queues + # << INTERNAL PLUGIN HANDLING >> # + @staticmethod + def _load_detector(detector, rotation, min_size, configfile): + """ Set global arguments and load detector plugin """ + detector_name = detector.replace("-", "_").lower() + logger.debug("Loading Detector: '%s'", detector_name) + detector = PluginLoader.get_detector(detector_name)(rotation=rotation, + min_size=min_size, + configfile=configfile) + return detector - def launch(self): - """ Launches the plugins - This can be called multiple times depending on the phase/whether multiprocessing - is enabled. - - If multiprocessing: - launches both plugins, but aligner first so that it's VRAM can be allocated - prior to giving the remaining to the detector - If not multiprocessing: - Launches the relevant plugin for the current phase """ - if self.is_parallel: - logger.debug("Launching aligner and detector") - self.launch_aligner() - self.launch_detector() - elif self.phase == "detect": - logger.debug("Launching detector") - self.launch_detector() - else: - logger.debug("Launching aligner") - self.launch_aligner() + @staticmethod + def _load_aligner(aligner, configfile, normalize_method): + """ Set global arguments and load aligner plugin """ + aligner_name = aligner.replace("-", "_").lower() + logger.debug("Loading Aligner: '%s'", aligner_name) + aligner = PluginLoader.get_aligner(aligner_name)(configfile=configfile, + normalize_method=normalize_method) + return aligner - def launch_aligner(self): + def _launch_aligner(self): """ Launch the face aligner """ logger.debug("Launching Aligner") - kwargs = {"in_queue": self.queues["extract_align_in"], - "out_queue": self.queues["extract_align_out"]} - - process = SpawnProcess(self.aligner.run, **kwargs) - event = process.event - error = process.error - process.start() - self.processes.append(process) - - # Wait for Aligner to take it's VRAM - # The first ever load of the model for FAN has reportedly taken - # up to 3-4 minutes, hence high timeout. - # TODO investigate why this is and fix if possible - for mins in reversed(range(5)): - for seconds in range(60): - event.wait(seconds) - if event.is_set(): - break - if error.is_set(): - break - if event.is_set(): - break - if mins == 0 or error.is_set(): - raise ValueError("Error initializing Aligner") - logger.info("Waiting for Aligner... Time out in %s minutes", mins) - + kwargs = dict(in_queue=self._queues["extract_align_in"], + out_queue=self._queues["extract_align_out"], + queue_size=self._queue_size) + self._aligner.initialize(**kwargs) + self._aligner.start() logger.debug("Launched Aligner") - def launch_detector(self): + def _launch_detector(self): """ Launch the face detector """ logger.debug("Launching Detector") - kwargs = {"in_queue": self.queues["extract_detect_in"], - "out_queue": self.queues["extract_align_in"]} - mp_func = PoolProcess if self.detector.parent_is_pool else SpawnProcess - process = mp_func(self.detector.run, **kwargs) - - event = process.event if hasattr(process, "event") else None - error = process.error if hasattr(process, "error") else None - process.start() - self.processes.append(process) - - if event is None: - logger.debug("Launched Detector") - return - - for mins in reversed(range(5)): - for seconds in range(60): - event.wait(seconds) - if event.is_set(): - break - if error and error.is_set(): - break - if event.is_set(): - break - if mins == 0 or (error and error.is_set()): - raise ValueError("Error initializing Detector") - logger.info("Waiting for Detector... Time out in %s minutes", mins) - + kwargs = dict(in_queue=self._queues["extract_detect_in"], + out_queue=self._queues["extract_align_in"], + queue_size=self._queue_size) + self._detector.initialize(**kwargs) + self._detector.start() logger.debug("Launched Detector") - def detected_faces(self): - """ Detect faces from in an image """ - logger.debug("Running Detection. Phase: '%s'", self.phase) - # If not multiprocessing, intercept the align in queue for - # detection phase - out_queue = self.output_queue - while True: - try: - faces = out_queue.get(True, 1) - if faces == "EOF": - break - if isinstance(faces, dict) and faces.get("exception"): - pid = faces["exception"][0] - t_back = faces["exception"][1].getvalue() - err = "Error in child process {}. {}".format(pid, t_back) - raise Exception(err) - except QueueEmpty: - continue - - yield faces - for process in self.processes: - logger.trace("Joining process: %s", process) - process.join() - del process - if self.final_pass: - # Cleanup queues - for q_name in self.queues.keys(): - queue_manager.del_queue(q_name) - logger.debug("Detection Complete") - else: - logger.debug("Switching to align phase") - self.phase = "align" + def _set_extractor_batchsize(self, vram_required, vram_free): + """ Sets the batchsize of the used plugins based on their vram and + vram_per_batch_requirements """ + batch_required = ((self._aligner.vram_per_batch * self._aligner.batchsize) + + (self._detector.vram_per_batch * self._detector.batchsize)) + plugin_required = vram_required + batch_required + if plugin_required <= vram_free: + logger.verbose("Plugin requirements within threshold: (plugin_required: %sMB, " + "vram_free: %sMB)", plugin_required, vram_free) + return + # Hacky split across 2 plugins + available_for_batching = (vram_free - vram_required) // 2 + self._aligner.batchsize = max(1, available_for_batching // self._aligner.vram_per_batch) + self._detector.batchsize = max(1, available_for_batching // self._detector.vram_per_batch) + logger.verbose("Reset batchsizes: (aligner: %s, detector: %s)", + self._aligner.batchsize, self._detector.batchsize) + + def _join_threads(self): + """ Join threads for current pass """ + for plugin in self._active_plugins: + plugin.join() + + def _check_and_raise_error(self): + """ Check all threads for errors and raise if one occurs """ + for plugin in self._active_plugins: + if plugin.check_and_raise_error(): + return True + return False diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 610e1133dd..75d8655b04 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -5,8 +5,6 @@ import os from importlib import import_module -from lib.utils import get_backend - logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -61,15 +59,6 @@ def get_available_extractors(extractor_type): and not item.name.endswith("defaults.py") and item.name.endswith(".py") and item.name != "manual.py") - # TODO Remove this hacky fix when we move them to the same models - multi_versions = [extractor.replace("-amd", "") - for extractor in extractors if extractor.endswith("-amd")] - if get_backend() == "amd": - for extractor in multi_versions: - extractors.remove(extractor) - else: - for extractor in multi_versions: - extractors.remove("{}-amd".format(extractor)) return extractors @staticmethod diff --git a/requirements.txt b/requirements.txt index 8ab14be851..8399dd0153 100755 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ tqdm psutil pathlib numpy==1.16.2 -opencv-python>=4.0 +opencv-python==4.1.1.26 scikit-image Pillow==6.1.0 scikit-learn diff --git a/scripts/convert.py b/scripts/convert.py index cf51e73902..c184b1fa57 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -94,7 +94,7 @@ def add_queues(self): """ Add the queues for convert """ logger.debug("Adding queues. Queue size: %s", self.queue_size) for qname in ("convert_in", "convert_out", "patch"): - queue_manager.add_queue(qname, self.queue_size, multiprocessing_queue=False) + queue_manager.add_queue(qname, self.queue_size) def process(self): """ Process the conversion """ @@ -256,7 +256,6 @@ def load_extractor(self): "superior results") extractor = Extractor(detector="cv2-dnn", aligner="cv2-dnn", - loglevel=self.args.loglevel, multiprocess=False, rotate_images=None, min_size=20) @@ -283,7 +282,7 @@ def add_queue(self, task): q_name = task setattr(self, "{}_queue".format(task), - queue_manager.get_queue(q_name, multiprocessing_queue=False)) + queue_manager.get_queue(q_name)) logger.debug("Added queue for task: '%s'", task) def start_thread(self, task): @@ -381,15 +380,7 @@ def detect_faces(self, filename, image): self.extractor.input_queue.put(inp) faces = next(self.extractor.detected_faces()) - landmarks = faces["landmarks"] - detected_faces = faces["detected_faces"] - final_faces = list() - - for idx, face in enumerate(detected_faces): - detected_face = DetectedFace() - detected_face.from_bounding_box_dict(face) - detected_face.landmarksXY = landmarks[idx] - final_faces.append(detected_face) + final_faces = [face for face in faces["detected_faces"]] return final_faces # Saving tasks diff --git a/scripts/extract.py b/scripts/extract.py index 3d814b1cc2..7192464e26 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -8,7 +8,6 @@ from tqdm import tqdm -from lib.faces_detect import DetectedFace from lib.multithreading import MultiThread from lib.queue_manager import queue_manager from lib.utils import get_folder, hash_encode_image @@ -34,7 +33,6 @@ def __init__(self, arguments): normalization = None if self.args.normalization == "none" else self.args.normalization self.extractor = Extractor(self.args.detector, self.args.aligner, - self.args.loglevel, configfile=configfile, multiprocess=not self.args.singleprocess, rotate_images=self.args.rotate_images, @@ -239,15 +237,11 @@ def align_face(self, faces, align_eyes, size, filename): """ Align the detected face and add the destination file path """ final_faces = list() image = faces["image"] - landmarks = faces["landmarks"] detected_faces = faces["detected_faces"] - for idx, face in enumerate(detected_faces): - detected_face = DetectedFace() - detected_face.from_bounding_box_dict(face, image) - detected_face.landmarksXY = landmarks[idx] - detected_face.load_aligned(image, size=size, align_eyes=align_eyes) + for face in detected_faces: + face.load_aligned(image, size=size, align_eyes=align_eyes) final_faces.append({"file_location": self.output_dir / Path(filename).stem, - "face": detected_face}) + "face": face}) faces["detected_faces"] = final_faces def output_faces(self, filename, faces): diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 5a3b1ae3e7..5a763cdc1c 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -283,7 +283,6 @@ def get_items(self): face_filter = dict(detector=detector, aligner=aligner, - loglevel=self.args.loglevel, multiprocess=not self.args.singleprocess) filter_lists = dict() if hasattr(self.args, "ref_threshold"): @@ -405,7 +404,7 @@ def __init__(self, *args, **kwargs): self.filter = self.load_face_filter(**kwargs) logger.debug("Initialized %s", self.__class__.__name__) - def load_face_filter(self, filter_lists, ref_threshold, aligner, detector, loglevel, + def load_face_filter(self, filter_lists, ref_threshold, aligner, detector, multiprocess): """ Load faces to filter out of images """ if not any(val for val in filter_lists.values()): @@ -420,7 +419,6 @@ def load_face_filter(self, filter_lists, ref_threshold, aligner, detector, logle filter_files[1], detector, aligner, - loglevel, multiprocess, ref_threshold) logger.debug("Face filter: %s", facefilter) diff --git a/tools/lib_alignments/annotate.py b/tools/lib_alignments/annotate.py index 274c86015f..6c27b4d3c8 100644 --- a/tools/lib_alignments/annotate.py +++ b/tools/lib_alignments/annotate.py @@ -70,7 +70,7 @@ def draw_landmarks(self, color_id=3, radius=1): """ Draw the facial landmarks """ color = self.colors[color_id] for alignment in self.alignments: - landmarks = alignment["landmarksXY"] + landmarks = alignment["landmarks_xy"] logger.trace("Drawing Landmarks: (landmarks: %s, color: %s, radius: %s)", landmarks, color, radius) for (pos_x, pos_y) in landmarks: @@ -84,7 +84,7 @@ def draw_landmarks_mesh(self, color_id=4, thickness=1): """ Draw the facial landmarks """ color = self.colors[color_id] for alignment in self.alignments: - landmarks = alignment["landmarksXY"] + landmarks = alignment["landmarks_xy"] logger.trace("Drawing Landmarks Mesh: (landmarks: %s, color: %s, thickness: %s)", landmarks, color, thickness) for key, val in FACIAL_LANDMARKS_IDXS.items(): diff --git a/tools/lib_alignments/jobs.py b/tools/lib_alignments/jobs.py index 2ca6ac61bd..55b69255ba 100644 --- a/tools/lib_alignments/jobs.py +++ b/tools/lib_alignments/jobs.py @@ -667,7 +667,7 @@ def convert_dfl_alignment(dfl_alignments, f_hash, alignments): "y": top, "h": bottom - top, "hash": f_hash, - "landmarksXY": dfl_alignments["source_landmarks"]} + "landmarks_xy": dfl_alignments["source_landmarks"]} logger.trace("Adding alignment: (frame: '%s', alignment: %s", sourcefile, alignment) alignments.setdefault(sourcefile, list()).append(alignment) @@ -974,7 +974,7 @@ def normalize(self): continue # We should only be normalizing a single face, so just take # the first landmarks found - landmarks = np.array(val[0]["landmarksXY"]).reshape(68, 2, 1) + landmarks = np.array(val[0]["landmarks_xy"]).reshape(68, 2, 1) start = end end = start + landmarks.shape[2] # Store in one big array @@ -1047,7 +1047,7 @@ def update_alignments(self, landmarks): logger.trace("Updating: (frame: %s)", frame) landmarks_update = landmarks[:, :, idx].astype(int) landmarks_xy = landmarks_update.reshape(68, 2).tolist() - self.alignments.data[frame][0]["landmarksXY"] = landmarks_xy + self.alignments.data[frame][0]["landmarks_xy"] = landmarks_xy logger.trace("Updated: (frame: '%s', landmarks: %s)", frame, landmarks_xy) logger.debug("Updated alignments") diff --git a/tools/lib_alignments/jobs_manual.py b/tools/lib_alignments/jobs_manual.py index af261c7981..f5d2810a0a 100644 --- a/tools/lib_alignments/jobs_manual.py +++ b/tools/lib_alignments/jobs_manual.py @@ -7,10 +7,8 @@ import cv2 import numpy as np -from lib.multithreading import SpawnProcess -from lib.queue_manager import queue_manager, QueueEmpty -from lib.utils import get_backend -from plugins.plugin_loader import PluginLoader +from lib.queue_manager import queue_manager +from plugins.extract.pipeline import Extractor from . import Annotate, ExtractedFaces, Frames, Legacy logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -617,7 +615,7 @@ def frame_selector(self): def set_faces(self, frame): """ Pass the current frame faces to faces window """ faces = self.extracted_faces.get_faces_in_frame(frame) - landmarks = [{"landmarksXY": face.aligned_landmarks} + landmarks = [{"landmarks_xy": face.aligned_landmarks} for face in self.extracted_faces.faces] return FacesDisplay(faces, landmarks, self.extracted_faces.size, self.interface) @@ -772,8 +770,8 @@ def __init__(self, interface, loglevel): self.alignments = interface.alignments self.frames = interface.frames - self.extractor = dict() - self.init_extractor(loglevel) + self.queues = dict() + self.extractor = self.init_extractor() self.mouse_state = None self.last_move = None @@ -786,61 +784,17 @@ def __init__(self, interface, loglevel): "bounding_box_orig": list()} logger.debug("Initialized %s", self.__class__.__name__) - def init_extractor(self, loglevel): + def init_extractor(self): """ Initialize Aligner """ logger.debug("Initialize Extractor") - out_queue = queue_manager.get_queue("out") - - d_kwargs = {"in_queue": queue_manager.get_queue("in"), - "out_queue": queue_manager.get_queue("align")} - a_kwargs = {"in_queue": queue_manager.get_queue("align"), - "out_queue": out_queue} - - detector = PluginLoader.get_detector("manual")(loglevel=loglevel) - detect_process = SpawnProcess(detector.run, **d_kwargs) - d_event = detect_process.event - detect_process.start() - - plugins = ["fan_amd"] if get_backend() == "amd" else ["fan"] - plugins.append("cv2_dnn") - for plugin in plugins: - aligner = PluginLoader.get_aligner(plugin)(loglevel=loglevel, - normalize_method="hist") - align_process = SpawnProcess(aligner.run, **a_kwargs) - a_event = align_process.event - align_process.start() - - # Wait for Aligner to initialize - # The first ever load of the model for FAN has reportedly taken - # up to 3-4 minutes, hence high timeout. - a_event.wait(300) - if not a_event.is_set(): - if plugin.startswith("fan"): - align_process.join() - logger.error("Error initializing FAN. Trying CV2-DNN") - continue - else: - raise ValueError("Error inititalizing Aligner") - if plugin == "cv2_dnn": - break - - try: - err = None - err = out_queue.get(True, 1) - except QueueEmpty: - pass - if not err: - break - align_process.join() - logger.error("Error initializing FAN. Trying CV2-DNN") - - d_event.wait(10) - if not d_event.is_set(): - raise ValueError("Error inititalizing Detector") - - self.extractor["detect"] = detector - self.extractor["align"] = aligner + extractor = Extractor("manual", "fan", multiprocess=True, normalize_method="hist") + self.queues["in"] = extractor.input_queue + # Set the batchsizes to 1 + extractor.set_batchsize("detector", 1) + extractor.set_batchsize("aligner", 1) + extractor.launch() logger.debug("Initialized Extractor") + return extractor def on_event(self, event, x, y, flags, param): # pylint: disable=unused-argument,invalid-name """ Handle the mouse events """ @@ -970,22 +924,12 @@ def resize_bounding_box(self, pt_x, pt_y): def update_landmarks(self): """ Update the landmarks """ - queue_manager.get_queue("in").put({"image": self.media["image"], - "filename": self.media["frame_id"], - "face": self.media["bounding_box"]}) - landmarks = queue_manager.get_queue("out").get() - - if isinstance(landmarks, dict) and landmarks.get("exception"): - cv2.destroyAllWindows() # pylint: disable=no-member - pid = landmarks["exception"][0] - t_back = landmarks["exception"][1].getvalue() - err = "Error in child process {}. {}".format(pid, t_back) - raise Exception(err) - if landmarks == "EOF": - exit(0) - - alignment = self.extracted_to_alignment((landmarks["detected_faces"][0], - landmarks["landmarks"][0])) + self.queues["in"].put({"image": self.media["image"], + "filename": self.media["frame_id"], + "manual_face": self.media["bounding_box"]}) + detected_face = next(self.extractor.detected_faces())["detected_faces"][0] + alignment = detected_face.to_alignment() + frame = self.media["frame_id"] if self.interface.get_selected_face_id() is None: @@ -999,15 +943,3 @@ def update_landmarks(self): self.interface.state["edit"]["updated"] = True self.interface.state["edit"]["update_faces"] = True - - @staticmethod - def extracted_to_alignment(extract_data): - """ Convert Extracted Tuple to Alignments data """ - alignment = dict() - bbox, landmarks = extract_data - alignment["x"] = bbox["left"] - alignment["w"] = bbox["right"] - bbox["left"] - alignment["y"] = bbox["top"] - alignment["h"] = bbox["bottom"] - bbox["top"] - alignment["landmarksXY"] = landmarks - return alignment diff --git a/tools/sort.py b/tools/sort.py index 0391164e7a..b7c3f4ec1f 100644 --- a/tools/sort.py +++ b/tools/sort.py @@ -16,8 +16,7 @@ from lib.cli import FullHelpArgumentParser from lib import Serializer from lib.faces_detect import DetectedFace -from lib.multithreading import SpawnProcess -from lib.queue_manager import queue_manager, QueueEmpty +from lib.queue_manager import queue_manager from lib.utils import cv2_read_img from lib.vgg_face2_keras import VGGFace2 as VGGFace from plugins.plugin_loader import PluginLoader @@ -85,48 +84,22 @@ def process(self): self.sort_process() - def launch_aligner(self): + @staticmethod + def launch_aligner(): """ Load the aligner plugin to retrieve landmarks """ - out_queue = queue_manager.get_queue("out") - kwargs = {"in_queue": queue_manager.get_queue("in"), - "out_queue": out_queue} - - for plugin in ("fan", "cv2_dnn"): - aligner = PluginLoader.get_aligner(plugin)(loglevel=self.args.loglevel) - process = SpawnProcess(aligner.run, **kwargs) - event = process.event - process.start() - # Wait for Aligner to take init - # The first ever load of the model for FAN has reportedly taken - # up to 3-4 minutes, hence high timeout. - event.wait(300) - - if not event.is_set(): - if plugin == "fan": - process.join() - logger.error("Error initializing FAN. Trying CV2-DNN") - continue - else: - raise ValueError("Error inititalizing Aligner") - if plugin == "cv2_dnn": - return - - try: - err = None - err = out_queue.get(True, 1) - except QueueEmpty: - pass - if not err: - break - process.join() - logger.error("Error initializing FAN. Trying CV2-DNN") + kwargs = dict(in_queue=queue_manager.get_queue("in"), + out_queue=queue_manager.get_queue("out"), + queue_size=8) + aligner = PluginLoader.get_aligner("fan")(normalize_method="hist") + aligner.batchsize = 1 + aligner.initialize(**kwargs) + aligner.start() @staticmethod def alignment_dict(image): """ Set the image to a dict for alignment """ height, width = image.shape[:2] face = DetectedFace(x=0, w=width, y=0, h=height) - face = face.to_bounding_box_dict() return {"image": image, "detected_faces": [face]} @@ -134,9 +107,11 @@ def alignment_dict(image): def get_landmarks(filename): """ Extract the face from a frame (If not alignments file found) """ image = cv2_read_img(filename, raise_error=True) - queue_manager.get_queue("in").put(Sort.alignment_dict(image)) + feed = Sort.alignment_dict(image) + feed["filename"] = filename + queue_manager.get_queue("in").put(feed) face = queue_manager.get_queue("out").get() - landmarks = face["landmarks"][0] + landmarks = face["detected_faces"][0].landmarks_xy return landmarks def sort_process(self): From cd7d74e2798c560b7fc7ee560c5d5453c6bc2d42 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 15 Sep 2019 16:15:38 +0000 Subject: [PATCH 039/981] Update Sphinx conf --- docs/conf.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index aa8b1345ae..9c7f4804f1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -52,3 +52,5 @@ # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] + +master_doc = 'index' From b7cfd3066475ca5880c5b7f733bc955469a63bce Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 15 Sep 2019 16:29:34 +0000 Subject: [PATCH 040/981] Update index.rst --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 docs/index.rst diff --git a/docs/index.rst b/docs/index.rst old mode 100644 new mode 100755 index af05d8532f..511d36b598 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,7 +7,7 @@ faceswap.dev Developer Documentation ==================================== .. toctree:: - :maxdepth: 6 + :maxdepth: 4 :caption: Contents: full/modules From 3f48b350e7f41debd869ba39eeb1bc4b6129c289 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 15 Sep 2019 16:37:41 +0000 Subject: [PATCH 041/981] Update Sphinx requirements --- docs/sphinx_requirements.txt | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100755 docs/sphinx_requirements.txt diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt new file mode 100755 index 0000000000..fb2a38e5a2 --- /dev/null +++ b/docs/sphinx_requirements.txt @@ -0,0 +1,25 @@ +# NB Do not install from this requirements file +# It is for documentation purposes only + +tqdm +psutil +pathlib +numpy==1.16.2 +opencv-python==4.1.1.26 +scikit-image +Pillow==6.1.0 +scikit-learn +toposort +fastcluster +matplotlib==2.2.2 +imageio==2.5.0 +imageio-ffmpeg +ffmpy==0.2.2 +# Revert back to nvidia-ml-py3 when windows/system32 patch is implemented +git+https://github.com/deepfakes/nvidia-ml-py3.git +#nvidia-ml-py3 +h5py==2.9.0 +Keras==2.2.4 +pywin32 ; sys_platform == "win32" +pynvx==0.0.4 ; sys_platform == "darwin" +tensorflow From 3f8e87b0c9d0b37e32ccb77cd226a309667260d9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 16 Sep 2019 22:33:05 +0100 Subject: [PATCH 042/981] Update cli helptext --- lib/cli.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/cli.py b/lib/cli.py index cafe2402c8..6c9dff1363 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -550,7 +550,7 @@ def get_optional_arguments(): "default": default_detector, "group": "Plugins", "help": "R|Detector to use. Some of these have configurable settings in " - "'/config/extract.ini' or 'Edit > Configure Extract Plugins':" + "'/config/extract.ini' or 'Settings > Configure Extract Plugins':" "\nL|cv2-dnn: A CPU only extractor, is the least reliable, but uses least " "resources and runs fast on CPU. Use this if not using a GPU and time is " "important." @@ -777,7 +777,7 @@ def get_optional_arguments(): "default": "avg-color", "group": "plugins", "help": "R|Performs color adjustment to the swapped face. Some of these options have " - "configurable settings in '/config/convert.ini' or 'Edit > Configure " + "configurable settings in '/config/convert.ini' or 'Settings > Configure " "Convert Plugins':" "\nL|avg-color: Adjust the mean of each color channel in the swapped " "reconstruction to equal the mean of the masked area in the original image." @@ -802,7 +802,7 @@ def get_optional_arguments(): "group": "plugins", "default": "predicted", "help": "R|Mask to use to replace faces. Blending of the masks can be adjusted in " - "'/config/convert.ini' or 'Edit > Configure Convert Plugins':" + "'/config/convert.ini' or 'Settings > Configure Convert Plugins':" "\nL|components: An improved face hull mask using a facehull of 8 facial " "parts." "\nL|dfl_full: An improved face hull mask using a facehull of 3 facial parts." @@ -822,7 +822,7 @@ def get_optional_arguments(): "default": "none", "help": "R|Performs a scaling process to attempt to get better definition on the " "final swap. Some of these options have configurable settings in " - "'/config/convert.ini' or 'Edit > Configure Convert Plugins':" + "'/config/convert.ini' or 'Settings > Configure Convert Plugins':" "\nL|sharpen: Perform sharpening on the final face." "\nL|none: Don't perform any scaling operations."}) argument_list.append({"opts": ("-w", "--writer"), @@ -833,8 +833,8 @@ def get_optional_arguments(): "group": "plugins", "default": "opencv", "help": "R|The plugin to use to output the converted images. The " - "writers are configurable in '/config/convert.ini' or 'Edit " - "> Configure Convert Plugins:'" + "writers are configurable in '/config/convert.ini' or " + "'Settings > Configure Convert Plugins:'" "\nL|ffmpeg: [video] Writes out the convert straight to " "video. When the input is a series of images then the " "'-ref' (--reference-video) parameter must be set." @@ -1043,7 +1043,7 @@ def get_argument_list(): "default": PluginLoader.get_default_model(), "group": "model", "help": "R|Select which trainer to use. Trainers can be" - "configured from the edit menu or the config folder." + "configured from the Settings menu or the config folder." "\nL|original: The original model created by /u/deepfakes." "\nL|dfaker: 64px in/128px out model from dfaker. " "Enable 'warp-to-landmarks' for full dfaker method." From 97dc8c13f3f61912c706767429c5bcba6febf883 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 17 Sep 2019 00:43:30 +0100 Subject: [PATCH 043/981] Add docs build badge to README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2ef4c1a2ad..9707391e3b 100755 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@
Jennifer Lawrence/Steve Buscemi FaceSwap using the Villain model

-[![Build Status](https://travis-ci.org/deepfakes/faceswap.svg?branch=master)](https://travis-ci.org/deepfakes/faceswap) +[![Build Status](https://travis-ci.org/deepfakes/faceswap.svg?branch=master)](https://travis-ci.org/deepfakes/faceswap) [![Documentation Status](https://readthedocs.org/projects/faceswap/badge/?version=latest)](https://faceswap.readthedocs.io/en/latest/?badge=latest) Make sure you check out [INSTALL.md](INSTALL.md) before getting started. From a766891e1f3c61d5308553a474bc91cd0b2d5922 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 17 Sep 2019 09:15:43 +0100 Subject: [PATCH 044/981] align-eyes deprecation warning --- scripts/extract.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/extract.py b/scripts/extract.py index 7192464e26..28abb33c25 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -10,7 +10,7 @@ from lib.multithreading import MultiThread from lib.queue_manager import queue_manager -from lib.utils import get_folder, hash_encode_image +from lib.utils import get_folder, hash_encode_image, deprecation_warning from plugins.extract.pipeline import Extractor from scripts.fsmedia import Alignments, Images, PostProcess, Utils @@ -177,6 +177,10 @@ def run_extraction(self): to_process = self.process_item_count() size = self.args.size if hasattr(self.args, "size") else 256 align_eyes = self.args.align_eyes if hasattr(self.args, "align_eyes") else False + if align_eyes: + deprecation_warning("Align eyes (-ae --align-eyes)", + additional_info="This functionality will still be available " + "within the alignments tool.") exception = False for phase in range(self.extractor.passes): From a03abbd3b9323b1a4b1b9cca1884235953f1b44e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 17 Sep 2019 09:21:36 +0100 Subject: [PATCH 045/981] Display localtime() instead of gmtime() --- lib/gui/stats.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/gui/stats.py b/lib/gui/stats.py index a3e8a80787..a7860f715a 100644 --- a/lib/gui/stats.py +++ b/lib/gui/stats.py @@ -337,8 +337,8 @@ def format_stats(compiled_stats): logger.debug("Formatting stats") for summary in compiled_stats: hrs, mins, secs = convert_time(summary["elapsed"]) - summary["start"] = time.strftime("%x %X", time.gmtime(summary["start"])) - summary["end"] = time.strftime("%x %X", time.gmtime(summary["end"])) + summary["start"] = time.strftime("%x %X", time.localtime(summary["start"])) + summary["end"] = time.strftime("%x %X", time.localtime(summary["end"])) summary["elapsed"] = "{}:{}:{}".format(hrs, mins, secs) summary["rate"] = "{0:.1f}".format(summary["rate"]) return compiled_stats From abfcb21310fff07e37b4fd31428c1c9e3e4edfed Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 17 Sep 2019 09:41:31 +0100 Subject: [PATCH 046/981] Change total rate calculation in analysis --- lib/gui/stats.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/gui/stats.py b/lib/gui/stats.py index a7860f715a..f8b422ebaf 100644 --- a/lib/gui/stats.py +++ b/lib/gui/stats.py @@ -307,9 +307,9 @@ def total_stats(sessions_stats): """ Return total stats """ logger.debug("Compiling Totals") elapsed = 0 - rate = 0 - batchset = set() + examples = 0 iterations = 0 + batchset = set() total_summaries = len(sessions_stats) for idx, summary in enumerate(sessions_stats): if idx == 0: @@ -317,7 +317,7 @@ def total_stats(sessions_stats): if idx == total_summaries - 1: endtime = summary["end"] elapsed += summary["elapsed"] - rate += summary["rate"] + examples += (summary["batch"] * summary["iterations"]) batchset.add(summary["batch"]) iterations += summary["iterations"] batch = ",".join(str(bs) for bs in batchset) @@ -325,7 +325,7 @@ def total_stats(sessions_stats): "start": starttime, "end": endtime, "elapsed": elapsed, - "rate": rate / total_summaries, + "rate": examples / elapsed, "batch": batch, "iterations": iterations} logger.debug(totals) From b7cd51a2f1d59d4769c95d7c4b4002646304df6c Mon Sep 17 00:00:00 2001 From: kilroythethird <44308116+kilroythethird@users.noreply.github.com> Date: Thu, 19 Sep 2019 00:09:00 +0200 Subject: [PATCH 047/981] Optimized mtcnn a bit + added batching (#874) --- lib/model/session.py | 41 ++++- plugins/extract/_base.py | 2 +- plugins/extract/detect/mtcnn.py | 191 +++++++++++++---------- plugins/extract/detect/mtcnn_defaults.py | 13 ++ plugins/extract/pipeline.py | 6 +- 5 files changed, 159 insertions(+), 94 deletions(-) diff --git a/lib/model/session.py b/lib/model/session.py index 6fa2be7f67..fd86cbff1c 100644 --- a/lib/model/session.py +++ b/lib/model/session.py @@ -5,6 +5,7 @@ import tensorflow as tf from keras.models import load_model as k_load_model, Model +import numpy as np from lib.utils import get_backend @@ -39,7 +40,39 @@ def __init__(self, name, model_path, model_kwargs=None): self._model = None logger.trace("Initialized: %s", self.__class__.__name__,) - def predict(self, feed): + def _amd_predict_with_optimized_batchsizes(self, feed, batch_size): + """ Minimizes the amount of kernels to be compiled when using + the ``Amd`` backend with varying batchsizes while trying to keep + the batchsize as high as possible. + + Parameters + ---------- + feed: numpy.ndarray or list + The feed to be provided to the model as input. This should be a ``numpy.ndarray`` + for single inputs or a ``list`` of ``numpy.ndarrays`` for multiple inputs. + batch_size: int + The upper batchsize to use. + """ + if isinstance(feed, np.ndarray): + feed = [feed] + items = feed[0].shape[0] + done_items = 0 + results = list() + while done_items < items: + if batch_size < 4: # Not much difference in BS < 4 + batch_size = 1 + batch_items = ((items - done_items) // batch_size) * batch_size + if batch_items: + pred_data = [x[done_items:done_items + batch_items] for x in feed] + pred = self._model.predict(pred_data, batch_size=batch_size) + done_items += batch_items + results.append(pred) + batch_size //= 2 + if isinstance(results[0], np.ndarray): + return np.concatenate(results) + return [np.concatenate(x) for x in zip(*results)] + + def predict(self, feed, batch_size=None): """ Get predictions from the model in the correct session. This method is a wrapper for :func:`keras.predict()` function. @@ -51,11 +84,13 @@ def predict(self, feed): for single inputs or a ``list`` of ``numpy.ndarrays`` for multiple inputs. """ if self._session is None: - return self._model.predict(feed) + if batch_size is None: + return self._model.predict(feed) + return self._amd_predict_with_optimized_batchsizes(feed, batch_size) with self._session.as_default(): # pylint: disable=not-context-manager with self._session.graph.as_default(): - return self._model.predict(feed) + return self._model.predict(feed, batch_size=batch_size) def _set_session(self): """ Sets the session and graph. diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 8274801a9a..f4ed5a609d 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -322,7 +322,7 @@ def initialize(self, *args, **kwargs): self.__class__.__name__, args, kwargs) p_type = "Detector" if self._plugin_type == "detect" else "Aligner" logger.info("Initializing %s %s...", self.name, p_type) - self.queue_size = kwargs["queue_size"] + self.queue_size = 1 self._add_queues(kwargs["in_queue"], kwargs["out_queue"], ["predict", "post"]) self._compile_threads() self.init_model() diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py index 47d036fcbb..3be106735c 100755 --- a/plugins/extract/detect/mtcnn.py +++ b/plugins/extract/detect/mtcnn.py @@ -11,7 +11,6 @@ from lib.model.session import KSession from ._base import Detector, logger - class Detect(Detector): """ MTCNN detector for face recognition """ def __init__(self, **kwargs): @@ -19,11 +18,11 @@ def __init__(self, **kwargs): model_filename = ["mtcnn_det_v2.1.h5", "mtcnn_det_v2.2.h5", "mtcnn_det_v2.3.h5"] super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.name = "MTCNN" - self.input_size = 1440 + self.input_size = 640 self.vram = 1408 self.vram_warnings = 512 # Will run at this with warnings self.vram_per_batch = 1 # TODO implement batch support - self.batchsize = 1 # TODO implement batch support + self.batchsize = self.config["batch-size"] self.kwargs = self.validate_kwargs() def validate_kwargs(self): @@ -65,7 +64,7 @@ def predict(self, batch): prediction, points = self.model.detect_faces(batch["feed"]) logger.trace("filename: %s, prediction: %s, mtcnn_points: %s", batch["filename"], prediction, points) - batch["prediction"], batch["mtcnn_points"] = [prediction], [points] + batch["prediction"], batch["mtcnn_points"] = prediction, points return batch def process_output(self, batch): @@ -210,99 +209,119 @@ def __init__(self, model_path, minsize, threshold, factor): self.pnet = PNet(model_path[0]) self.rnet = RNet(model_path[1]) self.onet = ONet(model_path[2]) + self._pnet_scales = None logger.debug("Initialized: %s", self.__class__.__name__) def detect_faces(self, batch): """Detects faces in an image, and returns bounding boxes and points for them. batch: input batch """ - total_boxes = np.empty((0, 9)) - points = np.empty(0) - # TODO Implement batch support - image = batch[0] - origin_h, origin_w = image.shape[:2] - rectangles = self.detect_pnet(image, origin_h, origin_w) - if not rectangles: - return total_boxes, points - rectangles = self.detect_rnet(image, rectangles, origin_h, origin_w) - if not rectangles: - return total_boxes, points - rectangles = self.detect_onet(image, rectangles, origin_h, origin_w) - if rectangles: - total_boxes = np.array([result[:5] for result in rectangles]) - points = np.array([result[5:] for result in rectangles]).T - return total_boxes, points - - def detect_pnet(self, image, height, width): + origin_h, origin_w = batch.shape[1:3] + rectangles = self.detect_pnet(batch, origin_h, origin_w) + rectangles = self.detect_rnet(batch, rectangles, origin_h, origin_w) + rectangles = self.detect_onet(batch, rectangles, origin_h, origin_w) + ret_boxes = list() + ret_points = list() + for rects in rectangles: + if rects: + total_boxes = np.array([result[:5] for result in rects]) + points = np.array([result[5:] for result in rects]).T + else: + total_boxes = np.empty((0, 9)) + points = np.empty(0) + ret_boxes.append(total_boxes) + ret_points.append(points) + return ret_boxes, ret_points + + def detect_pnet(self, images, height, width): # pylint: disable=too-many-locals """ first stage - fast proposal network (pnet) to obtain face candidates """ - scales = calculate_scales(height, width, self.minsize, self.factor) - rectangles = [] - for scale in scales: - scale_img = cv2.resize(image, # pylint:disable=no-member - (int(width * scale), int(height * scale))) - input_ = scale_img.reshape(1, *scale_img.shape) - output = self.pnet.predict(input_) - # .transpose(0, 2, 1, 3) should be added, but this seems wrong. - # first 0 select cls score, second 0 = batchnum, alway=0. 1 one hot repr - cls_prob = output[0][0][:, :, 1] - roi = output[1][0] - out_h, out_w = cls_prob.shape + if self._pnet_scales is None: + self._pnet_scales = calculate_scales(height, width, self.minsize, self.factor) + rectangles = [[] for _ in range(images.shape[0])] + batch_items = images.shape[0] + for scale in self._pnet_scales: + rwidth, rheight = int(width * scale), int(height * scale) + batch = np.empty((batch_items, rheight, rwidth, 3), dtype="float32") + for b in range(batch_items): + batch[b, ...] = cv2.resize(images[b, ...], (rwidth, rheight)) + output = self.pnet.predict(batch) + cls_prob = output[0][..., 1] + roi = output[1] + out_h, out_w = cls_prob.shape[1:3] out_side = max(out_h, out_w) - cls_prob = np.swapaxes(cls_prob, 0, 1) - roi = np.swapaxes(roi, 0, 2) - rectangle = detect_face_12net(cls_prob, - roi, - out_side, - 1 / scale, - width, - height, - self.threshold[0]) - rectangles.extend(rectangle) - return nms(rectangles, 0.7, 'iou') - - def detect_rnet(self, image, rectangles, height, width): + cls_prob = np.swapaxes(cls_prob, 1, 2) + roi = np.swapaxes(roi, 1, 3) + for b in range(batch_items): + # first index 0 = cls score, 1 = one hot repr + rectangle = detect_face_12net(cls_prob[b, ...], + roi[b, ...], + out_side, + 1 / scale, + width, + height, + self.threshold[0]) + rectangles[b].extend(rectangle) + return [nms(x, 0.7, 'iou') for x in rectangles] + + def detect_rnet(self, images, rectangle_batch, height, width): """ second stage - refinement of face candidates with rnet """ - crop_number = 0 - predict_24_batch = [] - for rect in rectangles: - crop_img = image[int(rect[1]):int(rect[3]), int(rect[0]):int(rect[2])] - scale_img = cv2.resize(crop_img, (24, 24)) # pylint:disable=no-member - predict_24_batch.append(scale_img) - crop_number += 1 - - predict_24_batch = np.array(predict_24_batch) - output = self.rnet.predict(predict_24_batch) - - cls_prob = output[0] # first 0 is to select cls, second batch number, always =0 - cls_prob = np.array(cls_prob) - roi_prob = output[1] # first 0 is to select roi, second batch number, always =0 - roi_prob = np.array(roi_prob) - return filter_face_24net(cls_prob, roi_prob, rectangles, width, height, self.threshold[1]) - - def detect_onet(self, image, rectangles, height, width): + ret = [] + # TODO: batching + for b, rectangles in enumerate(rectangle_batch): + if not rectangles: + ret.append(list()) + continue + image = images[b] + crop_number = 0 + predict_24_batch = [] + for rect in rectangles: + crop_img = image[int(rect[1]):int(rect[3]), int(rect[0]):int(rect[2])] + scale_img = cv2.resize(crop_img, (24, 24)) # pylint:disable=no-member + predict_24_batch.append(scale_img) + crop_number += 1 + predict_24_batch = np.array(predict_24_batch) + output = self.rnet.predict(predict_24_batch, batch_size=128) + cls_prob = output[0] + cls_prob = np.array(cls_prob) + roi_prob = output[1] + roi_prob = np.array(roi_prob) + ret.append(filter_face_24net( + cls_prob, roi_prob, rectangles, width, height, self.threshold[1] + )) + return ret + + def detect_onet(self, images, rectangle_batch, height, width): """ third stage - further refinement and facial landmarks positions with onet """ - crop_number = 0 - predict_batch = [] - for rect in rectangles: - crop_img = image[int(rect[1]):int(rect[3]), int(rect[0]):int(rect[2])] - scale_img = cv2.resize(crop_img, (48, 48)) # pylint:disable=no-member - predict_batch.append(scale_img) - crop_number += 1 - - predict_batch = np.array(predict_batch) - - output = self.onet.predict(predict_batch) - cls_prob = output[0] - roi_prob = output[1] - pts_prob = output[2] # index - return filter_face_48net(cls_prob, - roi_prob, - pts_prob, - rectangles, - width, - height, - self.threshold[2]) + ret = list() + # TODO: batching + for b, rectangles in enumerate(rectangle_batch): + if not rectangles: + ret.append(list()) + continue + image = images[b] + crop_number = 0 + predict_batch = [] + for rect in rectangles: + crop_img = image[int(rect[1]):int(rect[3]), int(rect[0]):int(rect[2])] + scale_img = cv2.resize(crop_img, (48, 48)) # pylint:disable=no-member + predict_batch.append(scale_img) + crop_number += 1 + predict_batch = np.array(predict_batch) + output = self.onet.predict(predict_batch, batch_size=128) + cls_prob = output[0] + roi_prob = output[1] + pts_prob = output[2] # index + ret.append(filter_face_48net( + cls_prob, + roi_prob, + pts_prob, + rectangles, + width, + height, + self.threshold[2] + )) + return ret def detect_face_12net(cls_prob, roi, out_side, scale, width, height, threshold): diff --git a/plugins/extract/detect/mtcnn_defaults.py b/plugins/extract/detect/mtcnn_defaults.py index e50778dee4..f2d28bc534 100755 --- a/plugins/extract/detect/mtcnn_defaults.py +++ b/plugins/extract/detect/mtcnn_defaults.py @@ -106,4 +106,17 @@ "gui_radio": False, "fixed": True, }, + "batch-size": { + "default": 8, + "info": "The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered.", + "datatype": int, + "rounding": 1, + "min_max": (1, 64), + "choices": [], + "gui_radio": False, + "fixed": True, + } } diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 632ed4dc91..45a50f1ca9 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -323,8 +323,7 @@ def _launch_aligner(self): """ Launch the face aligner """ logger.debug("Launching Aligner") kwargs = dict(in_queue=self._queues["extract_align_in"], - out_queue=self._queues["extract_align_out"], - queue_size=self._queue_size) + out_queue=self._queues["extract_align_out"]) self._aligner.initialize(**kwargs) self._aligner.start() logger.debug("Launched Aligner") @@ -333,8 +332,7 @@ def _launch_detector(self): """ Launch the face detector """ logger.debug("Launching Detector") kwargs = dict(in_queue=self._queues["extract_detect_in"], - out_queue=self._queues["extract_align_in"], - queue_size=self._queue_size) + out_queue=self._queues["extract_align_in"]) self._detector.initialize(**kwargs) self._detector.start() logger.debug("Launched Detector") From feb5f75201eb3477f2f2d4b7196a4683936407f1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 18 Sep 2019 22:30:49 +0000 Subject: [PATCH 048/981] set mtcnn Nvidia defaults --- lib/model/session.py | 44 ++++++++++++++++----------------- plugins/extract/detect/mtcnn.py | 30 +++++++++++----------- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/lib/model/session.py b/lib/model/session.py index fd86cbff1c..3db05a02d6 100644 --- a/lib/model/session.py +++ b/lib/model/session.py @@ -18,8 +18,8 @@ class KSession(): This class acts as a wrapper for various :class:`keras.Model()` functions, ensuring that actions performed on a model are handled consistently within the correct graph. - Currently this only does anything for Nvidia users, making sure a unique graph and session is - provided for the given model. + This is an early implementation of this class, and should be expanded out over time + with relevant `AMD`, `CPU` and `NVIDIA` backend methods. Parameters ---------- @@ -40,6 +40,26 @@ def __init__(self, name, model_path, model_kwargs=None): self._model = None logger.trace("Initialized: %s", self.__class__.__name__,) + def predict(self, feed, batch_size=None): + """ Get predictions from the model in the correct session. + + This method is a wrapper for :func:`keras.predict()` function. + + Parameters + ---------- + feed: numpy.ndarray or list + The feed to be provided to the model as input. This should be a ``numpy.ndarray`` + for single inputs or a ``list`` of ``numpy.ndarrays`` for multiple inputs. + """ + if self._session is None: + if batch_size is None: + return self._model.predict(feed) + return self._amd_predict_with_optimized_batchsizes(feed, batch_size) + + with self._session.as_default(): # pylint: disable=not-context-manager + with self._session.graph.as_default(): + return self._model.predict(feed, batch_size=batch_size) + def _amd_predict_with_optimized_batchsizes(self, feed, batch_size): """ Minimizes the amount of kernels to be compiled when using the ``Amd`` backend with varying batchsizes while trying to keep @@ -72,26 +92,6 @@ def _amd_predict_with_optimized_batchsizes(self, feed, batch_size): return np.concatenate(results) return [np.concatenate(x) for x in zip(*results)] - def predict(self, feed, batch_size=None): - """ Get predictions from the model in the correct session. - - This method is a wrapper for :func:`keras.predict()` function. - - Parameters - ---------- - feed: numpy.ndarray or list - The feed to be provided to the model as input. This should be a ``numpy.ndarray`` - for single inputs or a ``list`` of ``numpy.ndarrays`` for multiple inputs. - """ - if self._session is None: - if batch_size is None: - return self._model.predict(feed) - return self._amd_predict_with_optimized_batchsizes(feed, batch_size) - - with self._session.as_default(): # pylint: disable=not-context-manager - with self._session.graph.as_default(): - return self._model.predict(feed, batch_size=batch_size) - def _set_session(self): """ Sets the session and graph. diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py index 3be106735c..d1901f71a2 100755 --- a/plugins/extract/detect/mtcnn.py +++ b/plugins/extract/detect/mtcnn.py @@ -11,6 +11,7 @@ from lib.model.session import KSession from ._base import Detector, logger + class Detect(Detector): """ MTCNN detector for face recognition """ def __init__(self, **kwargs): @@ -19,9 +20,9 @@ def __init__(self, **kwargs): super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.name = "MTCNN" self.input_size = 640 - self.vram = 1408 - self.vram_warnings = 512 # Will run at this with warnings - self.vram_per_batch = 1 # TODO implement batch support + self.vram = 320 + self.vram_warnings = 64 # Will run at this with warnings + self.vram_per_batch = 32 self.batchsize = self.config["batch-size"] self.kwargs = self.validate_kwargs() @@ -190,7 +191,7 @@ def model_definition(): class MTCNN(): """ MTCNN Detector for face alignment """ - # TODO Batching + # TODO Batching for rnet and onet def __init__(self, model_path, minsize, threshold, factor): """ @@ -243,8 +244,9 @@ def detect_pnet(self, images, height, width): for scale in self._pnet_scales: rwidth, rheight = int(width * scale), int(height * scale) batch = np.empty((batch_items, rheight, rwidth, 3), dtype="float32") - for b in range(batch_items): - batch[b, ...] = cv2.resize(images[b, ...], (rwidth, rheight)) + for idx in range(batch_items): + batch[idx, ...] = cv2.resize(images[idx, ...], # pylint:disable=no-member + (rwidth, rheight)) output = self.pnet.predict(batch) cls_prob = output[0][..., 1] roi = output[1] @@ -252,27 +254,27 @@ def detect_pnet(self, images, height, width): out_side = max(out_h, out_w) cls_prob = np.swapaxes(cls_prob, 1, 2) roi = np.swapaxes(roi, 1, 3) - for b in range(batch_items): + for idx in range(batch_items): # first index 0 = cls score, 1 = one hot repr - rectangle = detect_face_12net(cls_prob[b, ...], - roi[b, ...], + rectangle = detect_face_12net(cls_prob[idx, ...], + roi[idx, ...], out_side, 1 / scale, width, height, self.threshold[0]) - rectangles[b].extend(rectangle) + rectangles[idx].extend(rectangle) return [nms(x, 0.7, 'iou') for x in rectangles] def detect_rnet(self, images, rectangle_batch, height, width): """ second stage - refinement of face candidates with rnet """ ret = [] # TODO: batching - for b, rectangles in enumerate(rectangle_batch): + for idx, rectangles in enumerate(rectangle_batch): if not rectangles: ret.append(list()) continue - image = images[b] + image = images[idx] crop_number = 0 predict_24_batch = [] for rect in rectangles: @@ -295,11 +297,11 @@ def detect_onet(self, images, rectangle_batch, height, width): """ third stage - further refinement and facial landmarks positions with onet """ ret = list() # TODO: batching - for b, rectangles in enumerate(rectangle_batch): + for idx, rectangles in enumerate(rectangle_batch): if not rectangles: ret.append(list()) continue - image = images[b] + image = images[idx] crop_number = 0 predict_batch = [] for rect in rectangles: From a14bb9d6369ff5bc0fd4e8f956bc581bb03450d0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 18 Sep 2019 23:00:55 +0000 Subject: [PATCH 049/981] extract: Autoset batchsize if it is too large in singleprocess mode --- plugins/extract/pipeline.py | 57 ++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 45a50f1ca9..7e222928e7 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -70,6 +70,7 @@ def __init__(self, detector, aligner, self._detector = self._load_detector(detector, rotate_images, min_size, configfile) self._aligner = self._load_aligner(aligner, configfile, normalize_method) self._is_parallel = self._set_parallel_processing(multiprocess) + self._set_extractor_batchsize() self._queues = self._add_queues() logger.debug("Initialized %s", self.__class__.__name__) @@ -295,8 +296,6 @@ def _set_parallel_processing(self, multiprocess): logger.warning("Not enough free VRAM for parallel processing. " "Switching to serial") return False - - self._set_extractor_batchsize(vram_required, vram_free) return True # << INTERNAL PLUGIN HANDLING >> # @@ -337,22 +336,46 @@ def _launch_detector(self): self._detector.start() logger.debug("Launched Detector") - def _set_extractor_batchsize(self, vram_required, vram_free): - """ Sets the batchsize of the used plugins based on their vram and - vram_per_batch_requirements """ - batch_required = ((self._aligner.vram_per_batch * self._aligner.batchsize) + - (self._detector.vram_per_batch * self._detector.batchsize)) - plugin_required = vram_required + batch_required - if plugin_required <= vram_free: - logger.verbose("Plugin requirements within threshold: (plugin_required: %sMB, " - "vram_free: %sMB)", plugin_required, vram_free) + def _set_extractor_batchsize(self): + """ Sets the batchsize of the requested plugins based on their vram and + vram_per_batch_requirements if the the configured batchsize requires more + vram than is available. Nvidia only. """ + if (self._detector.vram == 0 and self._aligner.vram == 0) or get_backend() != "nvidia": + logger.debug("Either detector and aligner have no VRAM requirements or not running " + "on Nvidia. Not updating batchsize requirements/") return - # Hacky split across 2 plugins - available_for_batching = (vram_free - vram_required) // 2 - self._aligner.batchsize = max(1, available_for_batching // self._aligner.vram_per_batch) - self._detector.batchsize = max(1, available_for_batching // self._detector.vram_per_batch) - logger.verbose("Reset batchsizes: (aligner: %s, detector: %s)", - self._aligner.batchsize, self._detector.batchsize) + stats = GPUStats().get_card_most_free() + vram_free = int(stats["free"]) + if self._is_parallel: + vram_required = self._detector.vram + self._aligner.vram + self._vram_buffer + batch_required = ((self._aligner.vram_per_batch * self._aligner.batchsize) + + (self._detector.vram_per_batch * self._detector.batchsize)) + plugin_required = vram_required + batch_required + if plugin_required <= vram_free: + logger.debug("Plugin requirements within threshold: (plugin_required: %sMB, " + "vram_free: %sMB)", plugin_required, vram_free) + return + # Hacky split across 2 plugins + available_vram = (vram_free - vram_required) // 2 + for plugin in (self._aligner, self._detector): + self._set_plugin_batchsize(plugin, available_vram) + else: + for plugin in (self._aligner, self._detector): + vram_required = plugin.vram + self._vram_buffer + batch_required = plugin.vram_per_batch * plugin.batchsize + plugin_required = vram_required + batch_required + if plugin_required <= vram_free: + logger.debug("%s requirements within threshold: (plugin_required: %sMB, " + "vram_free: %sMB)", plugin.name, plugin_required, vram_free) + continue + available_vram = vram_free - vram_required + self._set_plugin_batchsize(plugin, available_vram) + + @staticmethod + def _set_plugin_batchsize(plugin, available_vram): + """ Set the batchsize for the given plugin based on given available vram """ + plugin.batchsize = max(1, available_vram // plugin.vram_per_batch) + logger.verbose("Reset batchsize for %s to %s", plugin.name, plugin.batchsize) def _join_threads(self): """ Join threads for current pass """ From 1cdbc5ea272bc82d09d964ff59ba32c57f18ef1d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 18 Sep 2019 23:37:22 +0000 Subject: [PATCH 050/981] Update cli for MTCNN --- lib/cli.py | 4 ++-- plugins/extract/pipeline.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/cli.py b/lib/cli.py index 6c9dff1363..3706e45fb1 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -554,8 +554,8 @@ def get_optional_arguments(): "\nL|cv2-dnn: A CPU only extractor, is the least reliable, but uses least " "resources and runs fast on CPU. Use this if not using a GPU and time is " "important." - "\nL|mtcnn: Fast on GPU, slow on CPU. Uses fewer resources than other GPU " - "detectors but can often return more false positives." + "\nL|mtcnn: Fast on CPU, Faster on GPU. Uses far fewer resources than other " + "GPU detectors but can often return more false positives." "\nL|s3fd: Fast on GPU, slow on CPU. Can detect more faces and " "fewer false positives than other GPU detectors, but is a lot more resource " "intensive."}) diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 7e222928e7..bc31147058 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -342,7 +342,7 @@ def _set_extractor_batchsize(self): vram than is available. Nvidia only. """ if (self._detector.vram == 0 and self._aligner.vram == 0) or get_backend() != "nvidia": logger.debug("Either detector and aligner have no VRAM requirements or not running " - "on Nvidia. Not updating batchsize requirements/") + "on Nvidia. Not updating batchsize requirements.") return stats = GPUStats().get_card_most_free() vram_free = int(stats["free"]) From d75963127facc514971a02261c3390b4d77db58f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 19 Sep 2019 17:43:28 +0100 Subject: [PATCH 051/981] Increase timeout for ffmpeg count frames and secs --- lib/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/utils.py b/lib/utils.py index f0340f91eb..6de7a35645 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -223,7 +223,7 @@ def convert_to_secs(*args): return retval -def count_frames_and_secs(path, timeout=15): +def count_frames_and_secs(path, timeout=60): """ Adapted From ffmpeg_imageio, to handle occasional hanging issue: https://github.com/imageio/imageio-ffmpeg From e2609c18442e4a97636fdf0ebde04783883a1a55 Mon Sep 17 00:00:00 2001 From: kilroythethird Date: Sun, 22 Sep 2019 18:24:32 +0200 Subject: [PATCH 052/981] Moved travis test to _travis + wmv support --- .gitignore | 2 ++ .travis.yml | 8 +++++--- simple_tests.py => _travis/simple_tests.py | 23 ++++++++++++++++------ lib/gui/utils.py | 1 + lib/utils.py | 2 +- 5 files changed, 26 insertions(+), 10 deletions(-) rename simple_tests.py => _travis/simple_tests.py (89%) diff --git a/.gitignore b/.gitignore index 8ce9f7b148..09b2c8d39d 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ !plugins/convert/* !tools !tools/lib* +!_travis +!_travis/* !.travis.yml *.ini *.pyc diff --git a/.travis.yml b/.travis.yml index 1332036aa3..de2b761562 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,6 @@ language: shell env: global: - CONDA_PYTHON=3.6 - - CONDA_BLD_PATH=${HOME}/conda-bld os: - linux @@ -21,6 +20,8 @@ cache: before_cache: # adapted from https://github.com/theochem/cgrid/blob/master/.travis.yml + - echo "Cleaning stuff in miniconda path. (${MINICONDA_PATH})" + - ls -la ${MINICONDA_PATH} - rm -rf ${MINICONDA_PATH}/conda-bld - rm -rf ${MINICONDA_PATH}/locks - rm -rf ${MINICONDA_PATH}/pkgs @@ -29,7 +30,8 @@ before_cache: - rm -rf ${MINICONDA_PATH}/envs/*/locks - rm -rf ${MINICONDA_PATH}/envs/*/pkgs - rm -rf ${MINICONDA_PATH}/envs/*/var - # Clean out test results + - ls -la ${MINICONDA_PATH} + - echo "Cleaning test results" - rm -rf ${HOME}/cache/tests/*/faces - rm -rf ${HOME}/cache/tests/*/conv - rm -rf ${HOME}/cache/tests/*/*.json @@ -89,5 +91,5 @@ install: - df -h script: - - python simple_tests.py; + - python _travis/simple_tests.py; diff --git a/simple_tests.py b/_travis/simple_tests.py similarity index 89% rename from simple_tests.py rename to _travis/simple_tests.py index 9f7b2e0183..89c8776b3c 100644 --- a/simple_tests.py +++ b/_travis/simple_tests.py @@ -84,10 +84,11 @@ def extract_args(detector, aligner, in_path, out_path, args=None): return _extract_args.split() -def train_args(model, model_path, faces, alignments, iterations=5, bs=8): +def train_args(model, model_path, faces, alignments, iterations=5, bs=8, extra_args=""): py_exe = sys.executable - args = "%s faceswap.py train -A %s -ala %s -B %s -alb %s -m %s -t %s -bs %i -it %s" % ( - py_exe, faces, alignments, faces, alignments, model_path, model, bs, iterations + args = "%s faceswap.py train -A %s -ala %s -B %s -alb %s -m %s -t %s -bs %i -it %s %s" % ( + py_exe, faces, alignments, faces, + alignments, model_path, model, bs, iterations, extra_args ) return args.split() @@ -120,6 +121,7 @@ def sort_args(in_path, out_path, sortby="face", groupby="hist", method="rename") os.makedirs(vid_base, exist_ok=True) os.makedirs(img_base, exist_ok=True) py_exe = sys.executable + was_trained = False vid_path = download_file(vid_src, pathjoin(vid_base, "test.mp4")) if not vid_path: @@ -157,15 +159,24 @@ def sort_args(in_path, out_path, sortby="face", groupby="hist", method="rename") ) ) - trained = run_test( - "Train lightweight model for 5 iterations.", + run_test( + "Train lightweight model for 1 iteration with WTL.", + train_args( + "lightweight", pathjoin(vid_base, "model"), + pathjoin(vid_base, "faces"), pathjoin(vid_base, "test_alignments.json"), + iterations=1, extra_args="-wl" + ) + ) + + was_trained = run_test( + "Train lightweight model for 5 iterations WITHOUT WTL.", train_args( "lightweight", pathjoin(vid_base, "model"), pathjoin(vid_base, "faces"), pathjoin(vid_base, "test_alignments.json") ) ) - if trained: + if was_trained: run_test( "Convert video.", convert_args( diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 834a40b7a3..37b020dec2 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -112,6 +112,7 @@ def filetypes(self): ("MP4", "*.mp4"), ("MPEG", "*.mpeg *.mpg"), ("WebM", "*.webm"), + ("Windows Media Video", "*.wmv"), all_files]} # Add in multi-select options for key, val in filetypes.items(): diff --git a/lib/utils.py b/lib/utils.py index 6de7a35645..88a527c1ae 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -29,7 +29,7 @@ _image_extensions = [ # pylint:disable=invalid-name ".bmp", ".jpeg", ".jpg", ".png", ".tif", ".tiff"] _video_extensions = [ # pylint:disable=invalid-name - ".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm"] + ".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv"] class Backend(): From 088c8389cc2a5723a3bdd575b25536e58fd669c1 Mon Sep 17 00:00:00 2001 From: kilroythethird Date: Sun, 22 Sep 2019 20:23:43 +0200 Subject: [PATCH 053/981] Add CONDA_BLD_PATH to travis again (derped) --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index de2b761562..dffbe98134 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,7 @@ language: shell env: global: - CONDA_PYTHON=3.6 + - CONDA_BLD_PATH=${HOME}/conda-bld os: - linux From 9a57af45d564a3646a92c33fce15a5f4f4e7a87f Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 22 Sep 2019 16:37:43 -0500 Subject: [PATCH 054/981] loss name correction --- plugins/train/_config.py | 2 +- plugins/train/model/_base.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/train/_config.py b/plugins/train/_config.py index f652565ccb..31d1dd578b 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -130,7 +130,7 @@ def set_globals(self): self.add_item( section=section, title="loss_function", datatype=str, group="loss", default="mae", - choices=["mae", "mse", "logcosh", "smooth_l1", "l_inf_norm", "ssim", "gmsd", + choices=["mae", "mse", "logcosh", "smooth_loss", "l_inf_norm", "ssim", "gmsd", "pixel_gradient_diff"], info="\n\t MAE - Mean absolute error will guide reconstructions of each pixel " "towards its median value in the training dataset. Robust to outliers but as " diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index cca6e90980..caa16fdecf 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -664,7 +664,7 @@ def loss_dict(self): loss_dict = dict(mae=losses.mean_absolute_error, mse=losses.mean_squared_error, logcosh=losses.logcosh, - smooth_l=generalized_loss, + smooth_loss=generalized_loss, l_inf_norm=l_inf_norm, ssim=DSSIMObjective(), gmsd=gmsd_loss, From 1c84d5e8ca1ab024b8eac6e19231f7be8f5fc06b Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 22 Sep 2019 16:38:21 -0500 Subject: [PATCH 055/981] loss name correction --- plugins/train/_config.py | 2 +- plugins/train/model/_base.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/train/_config.py b/plugins/train/_config.py index f652565ccb..31d1dd578b 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -130,7 +130,7 @@ def set_globals(self): self.add_item( section=section, title="loss_function", datatype=str, group="loss", default="mae", - choices=["mae", "mse", "logcosh", "smooth_l1", "l_inf_norm", "ssim", "gmsd", + choices=["mae", "mse", "logcosh", "smooth_loss", "l_inf_norm", "ssim", "gmsd", "pixel_gradient_diff"], info="\n\t MAE - Mean absolute error will guide reconstructions of each pixel " "towards its median value in the training dataset. Robust to outliers but as " diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index cca6e90980..caa16fdecf 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -664,7 +664,7 @@ def loss_dict(self): loss_dict = dict(mae=losses.mean_absolute_error, mse=losses.mean_squared_error, logcosh=losses.logcosh, - smooth_l=generalized_loss, + smooth_loss=generalized_loss, l_inf_norm=l_inf_norm, ssim=DSSIMObjective(), gmsd=gmsd_loss, From 139b5811772d7b1e0a45041490878fe2ef2e3ad6 Mon Sep 17 00:00:00 2001 From: kvrooman Date: Tue, 24 Sep 2019 05:16:03 -0500 Subject: [PATCH 056/981] Align eyes deprecation (#851) * align eyes removal * add align_eyes tool --- lib/align_eyes.py | 71 --------------- lib/aligner.py | 133 +++++++++------------------- lib/cli.py | 7 -- lib/faces_detect.py | 29 +++--- plugins/train/trainer/_base.py | 2 +- scripts/extract.py | 29 +++--- tools/cli.py | 11 ++- tools/lib_alignments/annotate.py | 35 ++++---- tools/lib_alignments/jobs.py | 7 +- tools/lib_alignments/jobs_manual.py | 4 +- tools/lib_alignments/media.py | 42 +++++++-- tools/preview.py | 2 +- 12 files changed, 129 insertions(+), 243 deletions(-) delete mode 100644 lib/align_eyes.py diff --git a/lib/align_eyes.py b/lib/align_eyes.py deleted file mode 100644 index dc8a1ef2d6..0000000000 --- a/lib/align_eyes.py +++ /dev/null @@ -1,71 +0,0 @@ -# Code borrowed from https://github.com/jrosebr1/imutils/blob/d5cb29d02cf178c399210d5a139a821dfb0ae136/imutils/face_utils/helpers.py -""" -The MIT License (MIT) - -Copyright (c) 2015-2016 Adrian Rosebrock, http://www.pyimagesearch.com - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -""" - -from collections import OrderedDict -import numpy as np -import cv2 - -# define a dictionary that maps the indexes of the facial -# landmarks to specific face regions -FACIAL_LANDMARKS_IDXS = OrderedDict([ - ("mouth", (48, 68)), - ("right_eyebrow", (17, 22)), - ("left_eyebrow", (22, 27)), - ("right_eye", (36, 42)), - ("left_eye", (42, 48)), - ("nose", (27, 36)), - ("jaw", (0, 17)), - ("chin", (8, 11)) -]) - -# Returns a rotation matrix that when applied to the 68 input facial landmarks -# results in landmarks with eyes aligned horizontally -def align_eyes(landmarks, size): - desiredLeftEye = (0.35, 0.35) # (y, x) value - desiredFaceWidth = desiredFaceHeight = size - - # extract the left and right eye (x, y)-coordinates - (lStart, lEnd) = FACIAL_LANDMARKS_IDXS["left_eye"] - (rStart, rEnd) = FACIAL_LANDMARKS_IDXS["right_eye"] - leftEyePts = landmarks[lStart:lEnd] - rightEyePts = landmarks[rStart:rEnd] - - # compute the center of mass for each eye - leftEyeCenter = leftEyePts.mean(axis=0).astype("int") - rightEyeCenter = rightEyePts.mean(axis=0).astype("int") - - # compute the angle between the eye centroids - dY = rightEyeCenter[0,1] - leftEyeCenter[0,1] - dX = rightEyeCenter[0,0] - leftEyeCenter[0,0] - angle = np.degrees(np.arctan2(dY, dX)) - 180 - - # compute center (x, y)-coordinates (i.e., the median point) - # between the two eyes in the input image - eyesCenter = ((leftEyeCenter[0,0] + rightEyeCenter[0,0]) // 2, (leftEyeCenter[0,1] + rightEyeCenter[0,1]) // 2) - - # grab the rotation matrix for rotating and scaling the face - M = cv2.getRotationMatrix2D(eyesCenter, angle, 1.0) - - return M diff --git a/lib/aligner.py b/lib/aligner.py index 40186615bb..2e00b9fd8f 100644 --- a/lib/aligner.py +++ b/lib/aligner.py @@ -7,7 +7,6 @@ import numpy as np from lib.umeyama import umeyama -from lib.align_eyes import align_eyes as func_align_eyes, FACIAL_LANDMARKS_IDXS logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -16,11 +15,11 @@ class Extract(): """ Based on the original https://www.reddit.com/r/deepfakes/ code sample + contribs """ - def extract(self, image, face, size, align_eyes): + def extract(self, image, face, size): """ Extract a face from an image """ - logger.trace("size: %s. align_eyes: %s", size, align_eyes) + logger.trace("size: %s", size) padding = int(size * 0.1875) - alignment = get_align_mat(face, size, align_eyes) + alignment = get_align_mat(face) extracted = self.transform(image, alignment, size, padding) logger.trace("Returning face and alignment matrix: (alignment_matrix: %s)", alignment) return extracted, alignment @@ -39,85 +38,73 @@ def transform(self, image, mat, size, padding=0): logger.trace("matrix: %s, size: %s. padding: %s", mat, size, padding) matrix = self.transform_matrix(mat, size, padding) interpolators = get_matrix_scaling(matrix) - return cv2.warpAffine( # pylint: disable=no-member - image, matrix, (size, size), flags=interpolators[0]) + retval = cv2.warpAffine(image, # pylint: disable=no-member + matrix, (size, size), flags=interpolators[0]) + return retval def transform_points(self, points, mat, size, padding=0): """ Transform points along matrix """ logger.trace("points: %s, matrix: %s, size: %s. padding: %s", points, mat, size, padding) matrix = self.transform_matrix(mat, size, padding) points = np.expand_dims(points, axis=1) - points = cv2.transform( # pylint: disable=no-member - points, matrix, points.shape) + points = cv2.transform(points, # pylint: disable=no-member + matrix, points.shape) retval = np.squeeze(points) logger.trace("Returning: %s", retval) return retval def get_original_roi(self, mat, size, padding=0): - """ Return the square aligned box location on the original - image """ + """ Return the square aligned box location on the original image """ logger.trace("matrix: %s, size: %s. padding: %s", mat, size, padding) matrix = self.transform_matrix(mat, size, padding) - points = np.array([[0, 0], - [0, size - 1], - [size - 1, size - 1], - [size - 1, 0]], np.int32) + points = np.array([[0, 0], [0, size - 1], [size - 1, size - 1], [size - 1, 0]], np.int32) points = points.reshape((-1, 1, 2)) matrix = cv2.invertAffineTransform(matrix) # pylint: disable=no-member logger.trace("Returning: (points: %s, matrix: %s", points, matrix) return cv2.transform(points, matrix) # pylint: disable=no-member @staticmethod - def get_feature_mask(aligned_landmarks_68, size, - padding=0, dilation=30): + def get_feature_mask(aligned_landmarks_68, size, padding=0, dilation=30): """ Return the face feature mask """ - # pylint: disable=no-member logger.trace("aligned_landmarks_68: %s, size: %s, padding: %s, dilation: %s", aligned_landmarks_68, size, padding, dilation) scale = size - 2 * padding translation = padding - pad_mat = np.matrix([[scale, 0.0, translation], - [0.0, scale, translation]]) + pad_mat = np.matrix([[scale, 0.0, translation], [0.0, scale, translation]]) aligned_landmarks_68 = np.expand_dims(aligned_landmarks_68, axis=1) - aligned_landmarks_68 = cv2.transform(aligned_landmarks_68, + aligned_landmarks_68 = cv2.transform(aligned_landmarks_68, # pylint: disable=no-member pad_mat, aligned_landmarks_68.shape) aligned_landmarks_68 = np.squeeze(aligned_landmarks_68) - - (l_start, l_end) = FACIAL_LANDMARKS_IDXS["left_eye"] - (r_start, r_end) = FACIAL_LANDMARKS_IDXS["right_eye"] - (m_start, m_end) = FACIAL_LANDMARKS_IDXS["mouth"] - (n_start, n_end) = FACIAL_LANDMARKS_IDXS["nose"] - (lb_start, lb_end) = FACIAL_LANDMARKS_IDXS["left_eyebrow"] - (rb_start, rb_end) = FACIAL_LANDMARKS_IDXS["right_eyebrow"] - (c_start, c_end) = FACIAL_LANDMARKS_IDXS["chin"] - - l_eye_points = aligned_landmarks_68[l_start:l_end].tolist() - l_brow_points = aligned_landmarks_68[lb_start:lb_end].tolist() - r_eye_points = aligned_landmarks_68[r_start:r_end].tolist() - r_brow_points = aligned_landmarks_68[rb_start:rb_end].tolist() - nose_points = aligned_landmarks_68[n_start:n_end].tolist() - chin_points = aligned_landmarks_68[c_start:c_end].tolist() - mouth_points = aligned_landmarks_68[m_start:m_end].tolist() - l_eye_points = l_eye_points + l_brow_points - r_eye_points = r_eye_points + r_brow_points - mouth_points = mouth_points + nose_points + chin_points - - l_eye_hull = cv2.convexHull(np.array(l_eye_points).reshape( - (-1, 2)).astype(int)).flatten().reshape((-1, 2)) - r_eye_hull = cv2.convexHull(np.array(r_eye_points).reshape( - (-1, 2)).astype(int)).flatten().reshape((-1, 2)) - mouth_hull = cv2.convexHull(np.array(mouth_points).reshape( - (-1, 2)).astype(int)).flatten().reshape((-1, 2)) + l_eye_points = aligned_landmarks_68[42:48].tolist() + l_brow_points = aligned_landmarks_68[22:27].tolist() + r_eye_points = aligned_landmarks_68[36:42].tolist() + r_brow_points = aligned_landmarks_68[17:22].tolist() + nose_points = aligned_landmarks_68[27:36].tolist() + chin_points = aligned_landmarks_68[8:11].tolist() + mouth_points = aligned_landmarks_68[48:68].tolist() + # TODO remove excessive reshapes and flattens + + l_eye = np.array(l_eye_points + l_brow_points).reshape((-1, 2)).astype(int).flatten() + r_eye = np.array(r_eye_points + r_brow_points).reshape((-1, 2)).astype(int).flatten() + mouth = np.array(mouth_points + nose_points + chin_points) + mouth = mouth.reshape((-1, 2)).astype(int).flatten() + l_eye_hull = cv2.convexHull(l_eye.reshape((-1, 2))) # pylint: disable=no-member + r_eye_hull = cv2.convexHull(r_eye.reshape((-1, 2))) # pylint: disable=no-member + mouth_hull = cv2.convexHull(mouth.reshape((-1, 2))) # pylint: disable=no-member mask = np.zeros((size, size, 3), dtype=float) - cv2.fillConvexPoly(mask, l_eye_hull, (1, 1, 1)) - cv2.fillConvexPoly(mask, r_eye_hull, (1, 1, 1)) - cv2.fillConvexPoly(mask, mouth_hull, (1, 1, 1)) + cv2.fillConvexPoly(mask, # pylint: disable=no-member + l_eye_hull, (1, 1, 1)) + cv2.fillConvexPoly(mask, # pylint: disable=no-member + r_eye_hull, (1, 1, 1)) + cv2.fillConvexPoly(mask, # pylint: disable=no-member + mouth_hull, (1, 1, 1)) if dilation > 0: kernel = np.ones((dilation, dilation), np.uint8) - mask = cv2.dilate(mask, kernel, iterations=1) + mask = cv2.dilate(mask, # pylint: disable=no-member + kernel, iterations=1) logger.trace("Returning: %s", mask) return mask @@ -128,53 +115,15 @@ def get_matrix_scaling(mat): x_scale = np.sqrt(mat[0, 0] * mat[0, 0] + mat[0, 1] * mat[0, 1]) y_scale = (mat[0, 0] * mat[1, 1] - mat[0, 1] * mat[1, 0]) / x_scale avg_scale = (x_scale + y_scale) * 0.5 - if avg_scale >= 1.0: - interpolators = cv2.INTER_CUBIC, cv2.INTER_AREA # pylint: disable=no-member + if avg_scale >= 1.: + interpolators = cv2.INTER_CUBIC, cv2.INTER_AREA # pylint: disable=no-member else: interpolators = cv2.INTER_AREA, cv2.INTER_CUBIC # pylint: disable=no-member logger.trace("interpolator: %s, inverse interpolator: %s", interpolators[0], interpolators[1]) return interpolators -def get_align_mat(face, size, should_align_eyes): +def get_align_mat(face): """ Return the alignment Matrix """ - logger.trace("size: %s, should_align_eyes: %s", size, should_align_eyes) mat_umeyama = umeyama(np.array(face.landmarks_xy[17:]), True)[0:2] - - if should_align_eyes is False: - return mat_umeyama - - mat_umeyama = mat_umeyama * size - - # Convert to matrix - landmarks = np.matrix(face.landmarks_xy) - - # cv2 expects points to be in the form - # np.array([ [[x1, y1]], [[x2, y2]], ... ]), we'll expand the dim - landmarks = np.expand_dims(landmarks, axis=1) - - # Align the landmarks using umeyama - umeyama_landmarks = cv2.transform( # pylint: disable=no-member - landmarks, - mat_umeyama, - landmarks.shape) - - # Determine a rotation matrix to align eyes horizontally - mat_align_eyes = func_align_eyes(umeyama_landmarks, size) - - # Extend the 2x3 transform matrices to 3x3 so we can multiply them - # and combine them as one - mat_umeyama = np.matrix(mat_umeyama) - mat_umeyama.resize((3, 3)) - mat_align_eyes = np.matrix(mat_align_eyes) - mat_align_eyes.resize((3, 3)) - mat_umeyama[2] = mat_align_eyes[2] = [0, 0, 1] - - # Combine the umeyama transform with the extra rotation matrix - transform_mat = mat_align_eyes * mat_umeyama - - # Remove the extra row added, shape needs to be 2x3 - transform_mat = np.delete(transform_mat, 2, 0) - transform_mat = transform_mat / size - logger.trace("Returning: %s", transform_mat) - return transform_mat + return mat_umeyama diff --git a/lib/cli.py b/lib/cli.py index 3706e45fb1..9264e60348 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -704,13 +704,6 @@ def get_optional_arguments(): "group": "output", "default": False, "help": "Draw landmarks on the ouput faces for debugging purposes."}) - argument_list.append({"opts": ("-ae", "--align-eyes"), - "action": "store_true", - "dest": "align_eyes", - "group": "output", - "default": False, - "help": "Perform extra alignment to ensure left/right eyes are at " - "the same height"}) argument_list.append({"opts": ("-sp", "--singleprocess"), "action": "store_true", "default": False, diff --git a/lib/faces_detect.py b/lib/faces_detect.py index a4a4b58443..b71298803a 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -141,7 +141,7 @@ def _image_to_face(self, image): self.left: self.right] # <<< Aligned Face methods and properties >>> # - def load_aligned(self, image, size=256, align_eyes=False, dtype=None): + def load_aligned(self, image, size=256, dtype=None): """ Align a face from a given image. Aligning a face is a relatively expensive task and is not required for all uses of @@ -175,13 +175,11 @@ def load_aligned(self, image, size=256, align_eyes=False, dtype=None): # Don't reload an already aligned face logger.trace("Skipping alignment calculation for already aligned face") else: - logger.trace("Loading aligned face: (size: %s, align_eyes: %s, dtype: %s)", - size, align_eyes, dtype) + logger.trace("Loading aligned face: (size: %s, dtype: %s)", size, dtype) padding = int(size * self._extract_ratio) // 2 self.aligned["size"] = size self.aligned["padding"] = padding - self.aligned["align_eyes"] = align_eyes - self.aligned["matrix"] = get_align_mat(self, size, align_eyes) + self.aligned["matrix"] = get_align_mat(self) self.aligned["face"] = None if image is not None and self.aligned["face"] is None: logger.trace("Getting aligned face") @@ -229,13 +227,10 @@ def load_feed_face(self, image, size=64, coverage_ratio=0.625, dtype=None): self.feed["size"] = size self.feed["padding"] = self._padding_from_coverage(size, coverage_ratio) - self.feed["matrix"] = get_align_mat(self, size, should_align_eyes=False) + self.feed["matrix"] = get_align_mat(self) - face = np.clip(AlignerExtract().transform(image, - self.feed["matrix"], - size, - self.feed["padding"])[:, :, :3] / 255.0, - 0.0, 1.0) + face = AlignerExtract().transform(image, self.feed["matrix"], size, self.feed["padding"]) + face = np.clip(face[:, :, :3] / 255., 0., 1.) self.feed["face"] = face if dtype is None else face.astype(dtype) logger.trace("Loaded feed face. (face_shape: %s, matrix: %s)", @@ -268,13 +263,13 @@ def load_reference_face(self, image, size=64, coverage_ratio=0.625, dtype=None): self.reference["size"] = size self.reference["padding"] = self._padding_from_coverage(size, coverage_ratio) - self.reference["matrix"] = get_align_mat(self, size, should_align_eyes=False) + self.reference["matrix"] = get_align_mat(self) - face = np.clip(AlignerExtract().transform(image, - self.reference["matrix"], - size, - self.reference["padding"])[:, :, :3] / 255.0, - 0.0, 1.0) + face = AlignerExtract().transform(image, + self.reference["matrix"], + size, + self.reference["padding"]) + face = np.clip(face[:, :, :3] / 255., 0., 1.) self.reference["face"] = face if dtype is None else face.astype(dtype) logger.trace("Loaded reference face. (face_shape: %s, matrix: %s)", diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 519eb60dd5..65825a09b1 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -700,6 +700,6 @@ def transform_landmarks(self, alignments): for face in faces: detected_face = DetectedFace() detected_face.from_alignment(face) - detected_face.load_aligned(None, size=self.size, align_eyes=False) + detected_face.load_aligned(None, size=self.size) landmarks[detected_face.hash] = detected_face.aligned_landmarks return landmarks diff --git a/scripts/extract.py b/scripts/extract.py index 28abb33c25..fabf9cd487 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -190,13 +190,14 @@ def run_extraction(self): detected_faces = dict() self.extractor.launch() self.check_thread_error() - for idx, faces in enumerate(tqdm(self.extractor.detected_faces(), - total=to_process, - file=sys.stdout, - desc="Running pass {} of {}: {}".format( - phase + 1, - self.extractor.passes, - self.extractor.phase.title()))): + desc = "Running pass {} of {}: {}".format(phase + 1, + self.extractor.passes, + self.extractor.phase.title()) + status_bar = tqdm(self.extractor.detected_faces(), + total=to_process, + file=sys.stdout, + desc=desc) + for idx, faces in enumerate(status_bar): self.check_thread_error() exception = faces.get("exception", False) if exception: @@ -204,13 +205,14 @@ def run_extraction(self): filename = faces["filename"] if self.extractor.final_pass: - self.output_processing(faces, align_eyes, size, filename) + self.output_processing(faces, size, filename) self.output_faces(filename, faces) if self.save_interval and (idx + 1) % self.save_interval == 0: self.alignments.save() else: del faces["image"] detected_faces[filename] = faces + status_bar.update(1) if is_final: logger.debug("Putting EOF to save") @@ -224,26 +226,25 @@ def check_thread_error(self): for thread in self.threads: thread.check_and_raise_error() - def output_processing(self, faces, align_eyes, size, filename): + def output_processing(self, faces, size, filename): """ Prepare faces for output """ - self.align_face(faces, align_eyes, size, filename) + self.align_face(faces, size, filename) self.post_process.do_actions(faces) faces_count = len(faces["detected_faces"]) if faces_count == 0: - logger.verbose("No faces were detected in image: %s", - os.path.basename(filename)) + logger.verbose("No faces were detected in image: %s", os.path.basename(filename)) if not self.verify_output and faces_count > 1: self.verify_output = True - def align_face(self, faces, align_eyes, size, filename): + def align_face(self, faces, size, filename): """ Align the detected face and add the destination file path """ final_faces = list() image = faces["image"] detected_faces = faces["detected_faces"] for face in detected_faces: - face.load_aligned(image, size=size, align_eyes=align_eyes) + face.load_aligned(image, size=size) final_faces.append({"file_location": self.output_dir / Path(filename).stem, "face": face}) faces["detected_faces"] = final_faces diff --git a/tools/cli.py b/tools/cli.py index f780e1d6bd..3ff1fdc8b5 100644 --- a/tools/cli.py +++ b/tools/cli.py @@ -40,16 +40,16 @@ def get_argument_list(self): "NB: All actions require an alignments file (-a) to be passed in." "\nL|'draw': Draw landmarks on frames in the selected folder/video. A " "subfolder will be created within the frames folder to hold the output." + - frames_dir + align_eyes + + frames_dir + "\nL|'extract': Re-extract faces from the source frames/video based on " "alignment data. This is a lot quicker than re-detecting faces. Can pass in " "the '-een' (--extract-every-n) parameter to only extract every nth frame." + frames_and_faces_dir + align_eyes + - "\nL|'extract-large' - Extract all faces that have not been upscaled. Useful " + "\nL|'extract-large': - Extract all faces that have not been upscaled. Useful " "for excluding low-res images from a training set.. Can pass in the '-een' " "(--extract-every-n) parameter to only extract every nth frame." + frames_and_faces_dir + align_eyes + - "\nL|'manual': Manually view and edit landmarks." + frames_dir + align_eyes + + "\nL|'manual': Manually view and edit landmarks." + frames_dir + "\nL|'merge': Merge multiple alignment files into one. Specify a space " "separated list of alignments files with the -a flag. Optionally specify a " "faces (-fc) folder to filter the final alignments file to only those faces " @@ -158,9 +158,8 @@ def get_argument_list(self): "group": "extract", "default": False, "help": "Perform extra alignment to ensure " - "left/right eyes are at the same " - "height. (Draw, Extract and manual " - "only)"}) + "left/right eyes are at the same " + "height. (Extract only)"}) argument_list.append({"opts": ("-dm", "--disable-monitor"), "action": "store_true", "group": "manual tool", diff --git a/tools/lib_alignments/annotate.py b/tools/lib_alignments/annotate.py index 6c27b4d3c8..2ad6112b36 100644 --- a/tools/lib_alignments/annotate.py +++ b/tools/lib_alignments/annotate.py @@ -6,8 +6,6 @@ import cv2 import numpy as np -from lib.align_eyes import FACIAL_LANDMARKS_IDXS - logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -32,7 +30,7 @@ def draw_black_image(self): """ Change image to black at correct dimensions """ logger.trace("Drawing black image") height, width = self.image.shape[:2] - self.image = np.zeros((height, width, 3), np.uint8) + self.image = np.zeros((height, width, 3), dtype="uint8") def draw_bounding_box(self, color_id=1, thickness=1): """ Draw the bounding box around faces """ @@ -43,10 +41,7 @@ def draw_bounding_box(self, color_id=1, thickness=1): logger.trace("Drawing bounding box: (top_left: %s, bottom_right: %s, color: %s, " "thickness: %s)", top_left, bottom_right, color, thickness) cv2.rectangle(self.image, # pylint: disable=no-member - top_left, - bottom_right, - color, - thickness) + top_left, bottom_right, color, thickness) def draw_extract_box(self, color_id=2, thickness=1): """ Draw the extracted face box """ @@ -65,6 +60,7 @@ def draw_extract_box(self, color_id=2, thickness=1): color, thickness) cv2.polylines(self.image, [roi], True, color, thickness) # pylint: disable=no-member + def draw_landmarks(self, color_id=3, radius=1): """ Draw the facial landmarks """ @@ -75,14 +71,19 @@ def draw_landmarks(self, color_id=3, radius=1): landmarks, color, radius) for (pos_x, pos_y) in landmarks: cv2.circle(self.image, # pylint: disable=no-member - (pos_x, pos_y), - radius, - color, - -1) + (pos_x, pos_y), radius, color, -1) def draw_landmarks_mesh(self, color_id=4, thickness=1): """ Draw the facial landmarks """ color = self.colors[color_id] + FACIAL_LANDMARKS_IDXS = OrderedDict([("mouth", (48, 68)), + ("right_eyebrow", (17, 22)), + ("left_eyebrow", (22, 27)), + ("right_eye", (36, 42)), + ("left_eye", (42, 48)), + ("nose", (27, 36)), + ("jaw", (0, 17)), + ("chin", (8, 11))]) for alignment in self.alignments: landmarks = alignment["landmarks_xy"] logger.trace("Drawing Landmarks Mesh: (landmarks: %s, color: %s, thickness: %s)", @@ -91,10 +92,7 @@ def draw_landmarks_mesh(self, color_id=4, thickness=1): points = np.array([landmarks[val[0]:val[1]]], np.int32) fill_poly = bool(key in ("right_eye", "left_eye", "mouth")) cv2.polylines(self.image, # pylint: disable=no-member - points, - fill_poly, - color, - thickness) + points, fill_poly, color, thickness) def draw_grey_out_faces(self, live_face): """ Grey out all faces except target """ @@ -106,9 +104,6 @@ def draw_grey_out_faces(self, live_face): if idx != int(live_face): logger.trace("Greying out face: (idx: %s, roi: %s)", idx, roi) cv2.fillPoly(overlay, roi, (0, 0, 0)) # pylint: disable=no-member + cv2.addWeighted(overlay, # pylint: disable=no-member - alpha, - self.image, - 1 - alpha, - 0, - self.image) + alpha, self.image, 1. - alpha, 0., self.image) diff --git a/tools/lib_alignments/jobs.py b/tools/lib_alignments/jobs.py index 55b69255ba..80178e94a9 100644 --- a/tools/lib_alignments/jobs.py +++ b/tools/lib_alignments/jobs.py @@ -287,8 +287,7 @@ def process(self): legacy.process() logger.info("[DRAW LANDMARKS]") # Tidy up cli output - self.extracted_faces = ExtractedFaces(self.frames, self.alignments, size=256, - align_eyes=self.arguments.align_eyes) + self.extracted_faces = ExtractedFaces(self.frames, self.alignments, size=256) frames_drawn = 0 for frame in tqdm(self.frames.file_list_sorted, desc="Drawing landmarks"): frame_name = frame["frame_fullname"] @@ -329,7 +328,9 @@ def __init__(self, alignments, arguments): self.type = arguments.job.replace("extract-", "") self.faces_dir = arguments.faces_dir self.frames = Frames(arguments.frames_dir) - self.extracted_faces = ExtractedFaces(self.frames, self.alignments, size=arguments.size, + self.extracted_faces = ExtractedFaces(self.frames, + self.alignments, + size=arguments.size, align_eyes=arguments.align_eyes) logger.debug("Initialized %s", self.__class__.__name__) diff --git a/tools/lib_alignments/jobs_manual.py b/tools/lib_alignments/jobs_manual.py index f5d2810a0a..53408a634e 100644 --- a/tools/lib_alignments/jobs_manual.py +++ b/tools/lib_alignments/jobs_manual.py @@ -445,7 +445,6 @@ def __init__(self, alignments, arguments): self.__class__.__name__, alignments, arguments) self.arguments = arguments self.alignments = alignments - self.align_eyes = arguments.align_eyes self.frames = Frames(arguments.frames_dir) self.extracted_faces = None self.interface = None @@ -460,8 +459,7 @@ def process(self): legacy.process() logger.info("[MANUAL PROCESSING]") # Tidy up cli output - self.extracted_faces = ExtractedFaces(self.frames, self.alignments, size=256, - align_eyes=self.align_eyes) + self.extracted_faces = ExtractedFaces(self.frames, self.alignments, size=256) self.interface = Interface(self.alignments, self.frames) self.help = Help(self.interface) self.mouse_handler = MouseHandler(self.interface, self.arguments.loglevel) diff --git a/tools/lib_alignments/media.py b/tools/lib_alignments/media.py index 05050dd94e..6dd867cfd3 100644 --- a/tools/lib_alignments/media.py +++ b/tools/lib_alignments/media.py @@ -4,12 +4,14 @@ import logging import os +import cv2 +import numpy as np from tqdm import tqdm -import cv2 # TODO imageio single frame seek seems slow. Look into this # import imageio +from lib.aligner import Extract as AlignerExtract from lib.alignments import Alignments from lib.faces_detect import DetectedFace from lib.utils import (_image_extensions, _video_extensions, count_frames_and_secs, cv2_read_img, @@ -192,6 +194,7 @@ def load_video_frame(self, filename): def save_image(output_folder, filename, image): """ Save an image """ output_file = os.path.join(output_folder, filename) + output_file = os.path.splitext(output_file)[0]+'.png' logger.trace("Saving image: '%s'", output_file) cv2.imwrite(output_file, image) # pylint: disable=no-member @@ -291,14 +294,12 @@ class ExtractedFaces(): """ Holds the extracted faces and matrix for alignments """ def __init__(self, frames, alignments, size=256, align_eyes=False): - logger.trace("Initializing %s: (size: %s, align_eyes: %s)", - self.__class__.__name__, size, align_eyes) + logger.trace("Initializing %s: size: %s", self.__class__.__name__, size) self.size = size self.padding = int(size * 0.1875) - self.align_eyes = align_eyes + self.align_eyes_bool = align_eyes self.alignments = alignments self.frames = frames - self.current_frame = None self.faces = list() logger.trace("Initialized %s", self.__class__.__name__) @@ -314,8 +315,7 @@ def get_faces(self, frame): self.faces = list() return image = self.frames.load_image(frame) - self.faces = [self.extract_one_face(alignment, image.copy()) - for alignment in alignments] + self.faces = [self.extract_one_face(alignment, image.copy()) for alignment in alignments] self.current_frame = frame def extract_one_face(self, alignment, image): @@ -324,7 +324,8 @@ def extract_one_face(self, alignment, image): self.current_frame, alignment) face = DetectedFace() face.from_alignment(alignment, image=image) - face.load_aligned(image, size=self.size, align_eyes=self.align_eyes) + face.load_aligned(image, size=self.size) + face = self.align_eyes(face, image) if self.align_eyes_bool else face return face def get_faces_in_frame(self, frame, update=False): @@ -362,3 +363,28 @@ def save_face_with_hash(filename, extension, face): with open(filename, "wb") as out_file: out_file.write(img) return f_hash + + def align_eyes(self, face, image): + """ Re-extract a face with the pupils forced to be absolutely horizontally aligned """ + umeyama_landmarks = face.aligned_landmarks + leftEyeCenter = umeyama_landmarks[42:48].mean(axis=0) + rightEyeCenter = umeyama_landmarks[36:42].mean(axis=0) + eyesCenter = umeyama_landmarks[36:48].mean(axis=0) + dY = rightEyeCenter[1] - leftEyeCenter[1] + dX = rightEyeCenter[0] - leftEyeCenter[0] + theta = np.pi - np.arctan2(dY, dX) + rot_cos = np.cos(theta) + rot_sin = np.sin(theta) + rotation_matrix = np.array([[rot_cos, -rot_sin, 0.], + [rot_sin, rot_cos, 0.], + [0., 0., 1.]]) + + mat_umeyama = np.concatenate((face.aligned["matrix"], np.array([[0., 0., 1.]])), axis=0) + corrected_mat = np.dot(rotation_matrix, mat_umeyama) + face.aligned["matrix"] = corrected_mat[:2] + face.aligned["face"] = AlignerExtract().transform(image, + face.aligned["matrix"], + face.aligned["size"], + int(face.aligned["size"] * 0.375) // 2) + logger.trace("Adjusted matrix: %s", face.aligned["matrix"]) + return face diff --git a/tools/preview.py b/tools/preview.py index 2d11e63426..87595c09a5 100644 --- a/tools/preview.py +++ b/tools/preview.py @@ -457,7 +457,7 @@ def crop_source_faces(self): for image in self.source: detected_face = image["detected_faces"][0] src_img = image["image"] - detected_face.load_aligned(src_img, self.size, align_eyes=False) + detected_face.load_aligned(src_img, self.size) matrix = detected_face.aligned["matrix"] self.faces.setdefault("filenames", list()).append(os.path.splitext(image["filename"])[0]) From 78bd012a99d3929a287adcc1b60f4fb46cde3b83 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 24 Sep 2019 11:19:58 +0100 Subject: [PATCH 057/981] Align Eyes Deprecation - Bugfixes, Linting, and warning removal --- scripts/extract.py | 5 ----- tools/lib_alignments/annotate.py | 9 +++++---- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/scripts/extract.py b/scripts/extract.py index fabf9cd487..948aae64a6 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -176,11 +176,6 @@ def run_extraction(self): """ Run Face Detection """ to_process = self.process_item_count() size = self.args.size if hasattr(self.args, "size") else 256 - align_eyes = self.args.align_eyes if hasattr(self.args, "align_eyes") else False - if align_eyes: - deprecation_warning("Align eyes (-ae --align-eyes)", - additional_info="This functionality will still be available " - "within the alignments tool.") exception = False for phase in range(self.extractor.passes): diff --git a/tools/lib_alignments/annotate.py b/tools/lib_alignments/annotate.py index 2ad6112b36..70bd71aed6 100644 --- a/tools/lib_alignments/annotate.py +++ b/tools/lib_alignments/annotate.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """ Tools for annotating an input image """ +from collections import OrderedDict + import logging import cv2 @@ -60,7 +62,6 @@ def draw_extract_box(self, color_id=2, thickness=1): color, thickness) cv2.polylines(self.image, [roi], True, color, thickness) # pylint: disable=no-member - def draw_landmarks(self, color_id=3, radius=1): """ Draw the facial landmarks """ @@ -76,7 +77,7 @@ def draw_landmarks(self, color_id=3, radius=1): def draw_landmarks_mesh(self, color_id=4, thickness=1): """ Draw the facial landmarks """ color = self.colors[color_id] - FACIAL_LANDMARKS_IDXS = OrderedDict([("mouth", (48, 68)), + facial_landmarks_idxs = OrderedDict([("mouth", (48, 68)), ("right_eyebrow", (17, 22)), ("left_eyebrow", (22, 27)), ("right_eye", (36, 42)), @@ -88,7 +89,7 @@ def draw_landmarks_mesh(self, color_id=4, thickness=1): landmarks = alignment["landmarks_xy"] logger.trace("Drawing Landmarks Mesh: (landmarks: %s, color: %s, thickness: %s)", landmarks, color, thickness) - for key, val in FACIAL_LANDMARKS_IDXS.items(): + for key, val in facial_landmarks_idxs.items(): points = np.array([landmarks[val[0]:val[1]]], np.int32) fill_poly = bool(key in ("right_eye", "left_eye", "mouth")) cv2.polylines(self.image, # pylint: disable=no-member @@ -104,6 +105,6 @@ def draw_grey_out_faces(self, live_face): if idx != int(live_face): logger.trace("Greying out face: (idx: %s, roi: %s)", idx, roi) cv2.fillPoly(overlay, roi, (0, 0, 0)) # pylint: disable=no-member - + cv2.addWeighted(overlay, # pylint: disable=no-member alpha, self.image, 1. - alpha, 0., self.image) From 66ed005ef3d824b4f8221f7fe5019ffca770a94e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 24 Sep 2019 12:16:05 +0100 Subject: [PATCH 058/981] Optimize Data Augmentation (#881) * Move image utils to lib.image * Add .pylintrc file * Remove some cv2 pylint ignores * TrainingData: Load images from disk in batches * TrainingData: get_landmarks to batch * TrainingData: transform and flip to batches * TrainingData: Optimize color augmentation * TrainingData: Optimize target and random_warp * TrainingData - Convert _get_closest_match for batching * TrainingData: Warp To Landmarks optimized * Save models to threadpoolexecutor * Move stack_images, Rename ImageManipulation. ImageAugmentation Docstrings * Masks: Set dtype and threshold for lib.masks based on input face * Docstrings and Documentation --- .gitignore | 1 + .pylintrc | 570 +++++++++++++++++ docs/full/lib.image.rst | 7 + docs/full/lib.rst | 2 + docs/full/lib.training_data.rst | 7 + docs/index.rst | 2 +- lib/alignments.py | 2 +- lib/face_filter.py | 6 +- lib/faces_detect.py | 87 +++ lib/image.py | 302 +++++++++ lib/model/masks.py | 20 +- lib/training_data.py | 1020 +++++++++++++++++++------------ lib/utils.py | 240 +------- plugins/extract/detect/_base.py | 3 +- plugins/train/model/_base.py | 24 +- plugins/train/trainer/_base.py | 75 ++- scripts/convert.py | 7 +- scripts/extract.py | 5 +- scripts/fsmedia.py | 9 +- scripts/train.py | 7 +- tools/lib_alignments/media.py | 10 +- tools/sort.py | 16 +- 22 files changed, 1733 insertions(+), 689 deletions(-) create mode 100644 .pylintrc create mode 100644 docs/full/lib.image.rst create mode 100644 docs/full/lib.training_data.rst create mode 100644 lib/image.py diff --git a/.gitignore b/.gitignore index 09b2c8d39d..0cbbddfbf9 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ !plugins/extract/* !plugins/train/* !plugins/convert/* +!.pylintrc !tools !tools/lib* !_travis diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000000..69079954cd --- /dev/null +++ b/.pylintrc @@ -0,0 +1,570 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-whitelist=cv2 + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Specify a configuration file. +#rcfile= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=print-statement, + parameter-unpacking, + unpacking-in-except, + old-raise-syntax, + backtick, + long-suffix, + old-ne-operator, + old-octal-literal, + import-star-module-level, + non-ascii-bytes-literal, + raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + apply-builtin, + basestring-builtin, + buffer-builtin, + cmp-builtin, + coerce-builtin, + execfile-builtin, + file-builtin, + long-builtin, + raw_input-builtin, + reduce-builtin, + standarderror-builtin, + unicode-builtin, + xrange-builtin, + coerce-method, + delslice-method, + getslice-method, + setslice-method, + no-absolute-import, + old-division, + dict-iter-method, + dict-view-method, + next-method-called, + metaclass-assignment, + indexing-exception, + raising-string, + reload-builtin, + oct-method, + hex-method, + nonzero-method, + cmp-method, + input-builtin, + round-builtin, + intern-builtin, + unichr-builtin, + map-builtin-not-iterating, + zip-builtin-not-iterating, + range-builtin-not-iterating, + filter-builtin-not-iterating, + using-cmp-argument, + eq-without-hash, + div-method, + idiv-method, + rdiv-method, + exception-message-attribute, + invalid-str-codec, + sys-max-int, + bad-python3-import, + deprecated-string-function, + deprecated-str-translate-call, + deprecated-itertools-function, + deprecated-types-field, + next-method-defined, + dict-items-not-iterating, + dict-keys-not-iterating, + dict-values-not-iterating, + deprecated-operator-function, + deprecated-urllib-function, + xreadlines-attribute, + deprecated-sys-function, + exception-escape, + comprehension-escape + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. +#class-attribute-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. +#variable-rgx= + + +[LOGGING] + +# Format style used to check logging format string. `old` means using % +# formatting, while `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package.. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma, + dict-separator + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[STRING] + +# This flag controls whether the implicit-str-concat-in-sequence should +# generate a warning on implicit string concatenation in sequences defined over +# several lines. +check-str-concat-over-line-jumps=no + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + + +[DESIGN] + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement. +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=optparse,tkinter.tix + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled). +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled). +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "BaseException, Exception". +overgeneral-exceptions=BaseException, + Exception diff --git a/docs/full/lib.image.rst b/docs/full/lib.image.rst new file mode 100644 index 0000000000..36ba2e51a6 --- /dev/null +++ b/docs/full/lib.image.rst @@ -0,0 +1,7 @@ +lib.image module +======================== + +.. automodule:: lib.image + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib.rst b/docs/full/lib.rst index a4726c5455..44ca9170e9 100644 --- a/docs/full/lib.rst +++ b/docs/full/lib.rst @@ -8,6 +8,8 @@ Subpackages lib.model lib.faces_detect + lib.image + lib.training_data Module contents --------------- diff --git a/docs/full/lib.training_data.rst b/docs/full/lib.training_data.rst new file mode 100644 index 0000000000..6865234bb1 --- /dev/null +++ b/docs/full/lib.training_data.rst @@ -0,0 +1,7 @@ +lib.training\_data module +========================= + +.. automodule:: lib.training_data + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/index.rst b/docs/index.rst index 511d36b598..ca88bba8dd 100755 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,7 +7,7 @@ faceswap.dev Developer Documentation ==================================== .. toctree:: - :maxdepth: 4 + :maxdepth: 2 :caption: Contents: full/modules diff --git a/lib/alignments.py b/lib/alignments.py index 8717947303..def51d80bf 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -8,8 +8,8 @@ import cv2 +from lib.faces_detect import rotate_landmarks from lib import Serializer -from lib.utils import rotate_landmarks logger = logging.getLogger(__name__) # pylint: disable=invalid-name diff --git a/lib/face_filter.py b/lib/face_filter.py index 31919715a2..36ef194b83 100644 --- a/lib/face_filter.py +++ b/lib/face_filter.py @@ -4,7 +4,7 @@ import logging from lib.vgg_face import VGGFace -from lib.utils import cv2_read_img +from lib.image import read_image from plugins.extract.pipeline import Extractor logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -47,10 +47,10 @@ def load_images(reference_file_paths, nreference_file_paths): """ Load the images """ retval = dict() for fpath in reference_file_paths: - retval[fpath] = {"image": cv2_read_img(fpath, raise_error=True), + retval[fpath] = {"image": read_image(fpath, raise_error=True), "type": "filter"} for fpath in nreference_file_paths: - retval[fpath] = {"image": cv2_read_img(fpath, raise_error=True), + retval[fpath] = {"image": read_image(fpath, raise_error=True), "type": "nfilter"} logger.debug("Loaded filter images: %s", {k: v["type"] for k, v in retval.items()}) return retval diff --git a/lib/faces_detect.py b/lib/faces_detect.py index b71298803a..6525af6bfb 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -2,6 +2,7 @@ """ Face and landmarks detection for faceswap.py """ import logging +import cv2 import numpy as np from lib.aligner import Extract as AlignerExtract, get_align_mat, get_matrix_scaling @@ -399,3 +400,89 @@ def reference_interpolators(self): if not self.reference: return None return get_matrix_scaling(self.reference_matrix) + + +def rotate_landmarks(face, rotation_matrix): + """ Rotates the 68 point landmarks and detection bounding box around the given rotation matrix. + + Paramaters + ---------- + face: DetectedFace or dict + A :class:`DetectedFace` or an `alignments file` ``dict`` containing the 68 point landmarks + and the `x`, `w`, `y`, `h` detection bounding box points. + rotation_matrix: numpy.ndarray + The rotation matrix to rotate the given object by. + + Returns + ------- + DetectedFace or dict + The rotated :class:`DetectedFace` or `alignments file` ``dict`` with the landmarks and + detection bounding box points rotated by the given matrix. The return type is the same as + the input type for ``face`` + """ + logger.trace("Rotating landmarks: (rotation_matrix: %s, type(face): %s", + rotation_matrix, type(face)) + rotated_landmarks = None + # Detected Face Object + if isinstance(face, DetectedFace): + bounding_box = [[face.x, face.y], + [face.x + face.w, face.y], + [face.x + face.w, face.y + face.h], + [face.x, face.y + face.h]] + landmarks = face.landmarks_xy + + # Alignments Dict + elif isinstance(face, dict) and "x" in face: + bounding_box = [[face.get("x", 0), face.get("y", 0)], + [face.get("x", 0) + face.get("w", 0), + face.get("y", 0)], + [face.get("x", 0) + face.get("w", 0), + face.get("y", 0) + face.get("h", 0)], + [face.get("x", 0), + face.get("y", 0) + face.get("h", 0)]] + landmarks = face.get("landmarks_xy", list()) + + else: + raise ValueError("Unsupported face type") + + logger.trace("Original landmarks: %s", landmarks) + + rotation_matrix = cv2.invertAffineTransform( + rotation_matrix) + rotated = list() + for item in (bounding_box, landmarks): + if not item: + continue + points = np.array(item, np.int32) + points = np.expand_dims(points, axis=0) + transformed = cv2.transform(points, + rotation_matrix).astype(np.int32) + rotated.append(transformed.squeeze()) + + # Bounding box should follow x, y planes, so get min/max + # for non-90 degree rotations + pt_x = min([pnt[0] for pnt in rotated[0]]) + pt_y = min([pnt[1] for pnt in rotated[0]]) + pt_x1 = max([pnt[0] for pnt in rotated[0]]) + pt_y1 = max([pnt[1] for pnt in rotated[0]]) + width = pt_x1 - pt_x + height = pt_y1 - pt_y + + if isinstance(face, DetectedFace): + face.x = int(pt_x) + face.y = int(pt_y) + face.w = int(width) + face.h = int(height) + face.r = 0 + if len(rotated) > 1: + rotated_landmarks = [tuple(point) for point in rotated[1].tolist()] + face.landmarks_xy = rotated_landmarks + else: + face["left"] = int(pt_x) + face["top"] = int(pt_y) + face["right"] = int(pt_x1) + face["bottom"] = int(pt_y1) + rotated_landmarks = face + + logger.trace("Rotated landmarks: %s", rotated_landmarks) + return face diff --git a/lib/image.py b/lib/image.py new file mode 100644 index 0000000000..3b3e99e26a --- /dev/null +++ b/lib/image.py @@ -0,0 +1,302 @@ +#!/usr/bin python3 +""" Utilities for working with images and videos """ + +import logging +import subprocess +import sys + +from concurrent import futures +from hashlib import sha1 + +import cv2 +import imageio_ffmpeg as im_ffm +import numpy as np + +from lib.utils import convert_to_secs, FaceswapError + +logger = logging.getLogger(__name__) # pylint:disable=invalid-name + +# ################### # +# <<< IMAGE UTILS >>> # +# ################### # + + +# <<< IMAGE IO >>> # + +def read_image(filename, raise_error=False): + """ Read an image file from a file location. + + Extends the functionality of :func:`cv2.imread()` by ensuring that an image was actually + loaded. Errors can be logged and ignored so that the process can continue on an image load + failure. + + Parameters + ---------- + filename: str + Full path to the image to be loaded. + raise_error: bool, optional + If ``True``, then any failures (including the returned image being ``None``) will be + raised. If ``False`` then an error message will be logged, but the error will not be + raised. Default: ``False`` + + Returns + ------- + numpy.ndarray + The image in `BGR` channel order. + + Example + ------- + >>> image_file = "/path/to/image.png" + >>> try: + >>> image = read_image(image_file, raise_error=True) + >>> except: + >>> raise ValueError("There was an error") + """ + logger.trace("Requested image: '%s'", filename) + success = True + image = None + try: + image = cv2.imread(filename) + if image is None: + raise ValueError + except TypeError: + success = False + msg = "Error while reading image (TypeError): '{}'".format(filename) + logger.error(msg) + if raise_error: + raise Exception(msg) + except ValueError: + success = False + msg = ("Error while reading image. This is most likely caused by special characters in " + "the filename: '{}'".format(filename)) + logger.error(msg) + if raise_error: + raise Exception(msg) + except Exception as err: # pylint:disable=broad-except + success = False + msg = "Failed to load image '{}'. Original Error: {}".format(filename, str(err)) + logger.error(msg) + if raise_error: + raise Exception(msg) + logger.trace("Loaded image: '%s'. Success: %s", filename, success) + return image + + +def read_image_batch(filenames): + """ Load a batch of images from the given file locations. + + Leverages multi-threading to load multiple images from disk at the same time + leading to vastly reduced image read times. + + Parameters + ---------- + filenames: list + A list of ``str`` full paths to the images to be loaded. + + Returns + ------- + numpy.ndarray + The batch of images in `BGR` channel order. + + Notes + ----- + As the images are compiled into a batch, they must be all of the same dimensions. + + Example + ------- + >>> image_filenames = ["/path/to/image_1.png", "/path/to/image_2.png", "/path/to/image_3.png"] + >>> images = read_image_batch(image_filenames) + """ + logger.trace("Requested batch: '%s'", filenames) + executor = futures.ThreadPoolExecutor() + with executor: + images = [executor.submit(read_image, filename, raise_error=True) + for filename in filenames] + batch = np.array([future.result() for future in futures.as_completed(images)]) + logger.trace("Returning images: %s", batch.shape) + return batch + + +def read_image_hash(filename): + """ Return the `sha1` hash of an image saved on disk. + + Parameters + ---------- + filename: str + Full path to the image to be loaded. + + Returns + ------- + str + The :func:`hashlib.hexdigest()` representation of the `sha1` hash of the given image. + Example + ------- + >>> image_file = "/path/to/image.png" + >>> image_hash = read_image_hash(image_file) + """ + img = read_image(filename, raise_error=True) + image_hash = sha1(img).hexdigest() + logger.trace("filename: '%s', hash: %s", filename, image_hash) + return image_hash + + +def encode_image_with_hash(image, extension): + """ Encode an image, and get the encoded image back with its `sha1` hash. + + Parameters + ---------- + image: numpy.ndarray + The image to be encoded in `BGR` channel order. + extension: str + A compatible `cv2` image file extension that the final image is to be saved to. + + Returns + ------- + image_hash: str + The :func:`hashlib.hexdigest()` representation of the `sha1` hash of the encoded image + encoded_image: bytes + The image encoded into the correct file format + + Example + ------- + >>> image_file = "/path/to/image.png" + >>> image = read_image(image_file) + >>> image_hash, encoded_image = encode_image_with_hash(image, ".jpg") + """ + encoded_image = cv2.imencode(extension, image)[1] + image_hash = sha1(cv2.imdecode(encoded_image, cv2.IMREAD_UNCHANGED)).hexdigest() + return image_hash, encoded_image + + +def batch_convert_color(batch, colorspace): + """ Convert a batch of images from one color space to another. + + Converts a batch of images by reshaping the batch prior to conversion rather than iterating + over the images. This leads to a significant speed up in the convert process. + + Parameters + ---------- + batch: numpy.ndarray + A batch of images. + colorspace: str + The OpenCV Color Conversion Code suffix. For example for BGR to LAB this would be + ``'BGR2LAB'``. + See https://docs.opencv.org/4.1.1/d8/d01/group__imgproc__color__conversions.html for a full + list of color codes. + + Returns + ------- + numpy.ndarray + The batch converted to the requested color space. + + Example + ------- + >>> images_bgr = numpy.array([image1, image2, image3]) + >>> images_lab = batch_convert_color(images_bgr, "BGR2LAB") + + Notes + ----- + This function is only compatible for color space conversions that have the same image shape + for source and destination color spaces. + + If you use :func:`batch_convert_color` with 8-bit images, the conversion will have some + information lost. For many cases, this will not be noticeable but it is recommended + to use 32-bit images in cases that need the full range of colors or that convert an image + before an operation and then convert back. + """ + logger.trace("Batch converting: (batch shape: %s, colorspace: %s)", batch.shape, colorspace) + original_shape = batch.shape + batch = batch.reshape((original_shape[0] * original_shape[1], *original_shape[2:])) + batch = cv2.cvtColor(batch, getattr(cv2, "COLOR_{}".format(colorspace))) + return batch.reshape(original_shape) + + +# ################### # +# <<< VIDEO UTILS >>> # +# ################### # + +def count_frames_and_secs(filename, timeout=60): + """ Count the number of frames and seconds in a video file. + + Adapted From :mod:`ffmpeg_imageio` to handle the issue of ffmpeg occasionally hanging + inside a subprocess. + + If the operation times out then the process will try to read the data again, up to a total + of 3 times. If the data still cannot be read then an exception will be raised. + + Note that this operation can be quite slow for large files. + + Parameters + ---------- + filename: str + Full path to the video to be analyzed. + timeout: str, optional + The amount of time in seconds to wait for the video data before aborting. + Default: ``60`` + + Returns + ------- + nframes: int + The number of frames in the given video file. + nsecs: float + The duration, in seconds, of the given video file. + + Example + ------- + >>> video = "/path/to/video.mp4" + >>> frames, secs = count_frames_and_secs(video) + """ + # https://stackoverflow.com/questions/2017843/fetch-frame-count-with-ffmpeg + + assert isinstance(filename, str), "Video path must be a string" + exe = im_ffm.get_ffmpeg_exe() + iswin = sys.platform.startswith("win") + logger.debug("iswin: '%s'", iswin) + cmd = [exe, "-i", filename, "-map", "0:v:0", "-c", "copy", "-f", "null", "-"] + logger.debug("FFMPEG Command: '%s'", " ".join(cmd)) + attempts = 3 + for attempt in range(attempts): + try: + logger.debug("attempt: %s of %s", attempt + 1, attempts) + out = subprocess.check_output(cmd, + stderr=subprocess.STDOUT, + shell=iswin, + timeout=timeout) + logger.debug("Succesfully communicated with FFMPEG") + break + except subprocess.CalledProcessError as err: + out = err.output.decode(errors="ignore") + raise RuntimeError("FFMEG call failed with {}:\n{}".format(err.returncode, out)) + except subprocess.TimeoutExpired as err: + this_attempt = attempt + 1 + if this_attempt == attempts: + msg = ("FFMPEG hung while attempting to obtain the frame count. " + "Sometimes this issue resolves itself, so you can try running again. " + "Otherwise use the Effmpeg Tool to extract the frames from your video into " + "a folder, and then run the requested Faceswap process on that folder.") + raise FaceswapError(msg) from err + logger.warning("FFMPEG hung while attempting to obtain the frame count. " + "Retrying %s of %s", this_attempt + 1, attempts) + continue + + # Note that other than with the subprocess calls below, ffmpeg wont hang here. + # Worst case Python will stop/crash and ffmpeg will continue running until done. + + nframes = nsecs = None + for line in reversed(out.splitlines()): + if not line.startswith(b"frame="): + continue + line = line.decode(errors="ignore") + logger.debug("frame line: '%s'", line) + idx = line.find("frame=") + if idx >= 0: + splitframes = line[idx:].split("=", 1)[-1].lstrip().split(" ", 1)[0].strip() + nframes = int(splitframes) + idx = line.find("time=") + if idx >= 0: + splittime = line[idx:].split("=", 1)[-1].lstrip().split(" ", 1)[0].strip() + nsecs = convert_to_secs(*splittime.split(":")) + logger.debug("nframes: %s, nsecs: %s", nframes, nsecs) + return nframes, nsecs + + raise RuntimeError("Could not get number of frames") # pragma: no cover diff --git a/lib/model/masks.py b/lib/model/masks.py index cb41bf76f5..d7c0d68fdd 100644 --- a/lib/model/masks.py +++ b/lib/model/masks.py @@ -43,6 +43,8 @@ def __init__(self, landmarks, face, channels=4): self.__class__.__name__, face.shape, channels, landmarks) self.landmarks = landmarks self.face = face + self.dtype = face.dtype + self.threshold = 255 if self.dtype == "uint8" else 255.0 self.channels = channels mask = self.build_mask() @@ -73,7 +75,7 @@ def merge_mask(self, mask): class dfl_full(Mask): # pylint: disable=invalid-name """ DFL facial mask """ def build_mask(self): - mask = np.zeros(self.face.shape[0:2] + (1, ), dtype=np.float32) + mask = np.zeros(self.face.shape[0:2] + (1, ), dtype=self.dtype) nose_ridge = (self.landmarks[27:31], self.landmarks[33:34]) jaw = (self.landmarks[0:17], @@ -90,14 +92,14 @@ def build_mask(self): for item in parts: merged = np.concatenate(item) - cv2.fillConvexPoly(mask, cv2.convexHull(merged), 255.) # pylint: disable=no-member + cv2.fillConvexPoly(mask, cv2.convexHull(merged), self.threshold) return mask class components(Mask): # pylint: disable=invalid-name """ Component model mask """ def build_mask(self): - mask = np.zeros(self.face.shape[0:2] + (1, ), dtype=np.float32) + mask = np.zeros(self.face.shape[0:2] + (1, ), dtype=self.dtype) r_jaw = (self.landmarks[0:9], self.landmarks[17:18]) l_jaw = (self.landmarks[8:17], self.landmarks[26:27]) @@ -117,7 +119,7 @@ def build_mask(self): for item in parts: merged = np.concatenate(item) - cv2.fillConvexPoly(mask, cv2.convexHull(merged), 255.) # pylint: disable=no-member + cv2.fillConvexPoly(mask, cv2.convexHull(merged), self.threshold) return mask @@ -126,7 +128,7 @@ class extended(Mask): # pylint: disable=invalid-name Based on components mask. Attempts to extend the eyebrow points up the forehead """ def build_mask(self): - mask = np.zeros(self.face.shape[0:2] + (1, ), dtype=np.float32) + mask = np.zeros(self.face.shape[0:2] + (1, ), dtype=self.dtype) landmarks = self.landmarks.copy() # mid points between the side of face and eye point @@ -161,15 +163,15 @@ def build_mask(self): for item in parts: merged = np.concatenate(item) - cv2.fillConvexPoly(mask, cv2.convexHull(merged), 255.) # pylint: disable=no-member + cv2.fillConvexPoly(mask, cv2.convexHull(merged), self.threshold) return mask class facehull(Mask): # pylint: disable=invalid-name """ Basic face hull mask """ def build_mask(self): - mask = np.zeros(self.face.shape[0:2] + (1, ), dtype=np.float32) - hull = cv2.convexHull( # pylint: disable=no-member + mask = np.zeros(self.face.shape[0:2] + (1, ), dtype=self.dtype) + hull = cv2.convexHull( np.array(self.landmarks).reshape((-1, 2))) - cv2.fillConvexPoly(mask, hull, 255.0, lineType=cv2.LINE_AA) # pylint: disable=no-member + cv2.fillConvexPoly(mask, hull, self.threshold, lineType=cv2.LINE_AA) return mask diff --git a/lib/training_data.py b/lib/training_data.py index 423f7441a7..2c3c67a4d9 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -1,88 +1,191 @@ #!/usr/bin/env python3 -""" Process training data for model training """ +""" Handles Data Augmentation for feeding Faceswap Models """ import logging from hashlib import sha1 -from random import random, shuffle, choice +from random import shuffle, choice import numpy as np import cv2 from scipy.interpolate import griddata +from lib.image import batch_convert_color, read_image_batch from lib.model import masks from lib.multithreading import BackgroundGenerator -from lib.queue_manager import queue_manager -from lib.umeyama import umeyama -from lib.utils import cv2_read_img, FaceswapError +from lib.utils import FaceswapError logger = logging.getLogger(__name__) # pylint: disable=invalid-name class TrainingDataGenerator(): - """ Generate training data for models """ + """ A Training Data Generator for compiling data for feeding to a model. + + This class is called from :mod:`plugins.train.trainer._base` and launches a background + iterator that compiles augmented data, target data and sample data. + + Parameters + ---------- + model_input_size: int + The expected input size for the model. It is assumed that the input to the model is always + a square image. This is the size, in pixels, of the `width` and the `height` of the input + to the model. + model_output_shapes: list + A list of tuples defining the output shapes from the model, in the order that the outputs + are returned. The tuples should be in (`height`, `width`, `channels`) format. + training_opts: dict + This is a dictionary of model training options as defined in + :mod:`plugins.train.model._base`. These options will be defined by the user from the + provided cli options or from the model ``config.ini``. At a minimum this ``dict`` should + contain the following keys: + + * **coverage_ratio** (`float`) - The ratio of the training image to be trained on. \ + Dictates how much of the image will be cropped out. Eg: a coverage ratio of 0.625 \ + will result in cropping a 160px box from a 256px image (256 * 0.625 = 160). + + * **augment_color** (`bool`) - ``True`` if color is to be augmented, otherwise ``False`` \ + + * **no_flip** (`bool`) - ``True`` if the image shouldn't be randomly flipped as part of \ + augmentation, otherwise ``False`` + + * **mask_type** (`str`) - The mask type to be used (as defined in \ + :mod:`lib.model.masks`). If not ``None`` then the additional key ``landmarks`` must be \ + provided. + + * **warp_to_landmarks** (`bool`) - ``True`` if the random warp method should warp to \ + similar landmarks from the other side, ``False`` if the standard random warp method \ + should be used. If ``True`` then the additional key ``landmarks`` must be provided. + + * **landmarks** (`numpy.ndarray`, `optional`). Required if using a :attr:`mask_type` is \ + not ``None`` or :attr:`warp_to_landmarks` is ``True``. The 68 point face landmarks from \ + an alignments file. + + config: dict + The configuration ``dict`` generated from :file:`config.train.ini` containing the trainer \ + plugin configuration options. + """ def __init__(self, model_input_size, model_output_shapes, training_opts, config): logger.debug("Initializing %s: (model_input_size: %s, model_output_shapes: %s, " "training_opts: %s, landmarks: %s, config: %s)", self.__class__.__name__, model_input_size, model_output_shapes, {key: val for key, val in training_opts.items() if key != "landmarks"}, bool(training_opts.get("landmarks", None)), config) - self.batchsize = 0 - self.model_input_size = model_input_size - self.model_output_shapes = model_output_shapes - self.training_opts = training_opts - self.mask_class = self.set_mask_class() - self.landmarks = self.training_opts.get("landmarks", None) + self._config = config + self._model_input_size = model_input_size + self._model_output_shapes = model_output_shapes + self._training_opts = training_opts + self._mask_class = self._set__mask_class() + self._landmarks = self._training_opts.get("landmarks", None) self._nearest_landmarks = {} - self.processing = ImageManipulation(model_input_size, - model_output_shapes, - training_opts.get("coverage_ratio", 0.625), - config) - logger.debug("Initialized %s", self.__class__.__name__) - def set_mask_class(self): - """ Set the mask function to use if using mask """ - mask_type = self.training_opts.get("mask_type", None) - if mask_type: - logger.debug("Mask type: '%s'", mask_type) - mask_class = getattr(masks, mask_type) - else: - mask_class = None - logger.debug("Mask class: %s", mask_class) - return mask_class + # Batchsize and processing class are set when this class is called by a batcher + # from lib.training_data + self._batchsize = 0 + self._processing = None + logger.debug("Initialized %s", self.__class__.__name__) def minibatch_ab(self, images, batchsize, side, do_shuffle=True, is_preview=False, is_timelapse=False): - """ Keep a queue filled to 8x Batch Size """ + """ A Background iterator to return augmented images, samples and targets. + + The exit point from this class and the sole attribute that should be referenced. Called + from :mod:`plugins.train.trainer._base`. Returns an iterator that yields images for + training, preview and timelapses. + + Parameters + ---------- + images: list + A list of image paths that will be used to compile the final augmented data from. + batchsize: int + The batchsize for this iterator. Images will be returned in ``numpy.ndarray`` s of + this size from the iterator. + side: {'a' or 'b'} + The side of the model that this iterator is for. + do_shuffle: bool, optional + Whether data should be shuffled prior to loading from disk. If true, each time the full + list of filenames are processed, the data will be reshuffled to make sure thay are not + returned in the same order. Default: ``True`` + is_preview: bool, optional + Indicates whether this iterator is generating preview images. If ``True`` then certain + augmentations will not be performed. Default: ``False`` + is_timelapse: bool optional + Indicates whether this iterator is generating Timelapse images. If ``True``, then + certain augmentations will not be performed. Default: ``False`` + + Yields + ------ + dict + The following items are contained in each ``dict`` yielded from this iterator: + + * **feed** (`numpy.ndarray`) - The feed for the model. The array returned is in the \ + format (`batchsize`, `height`, `width`, `channels`). This is the :attr:`x` parameter \ + for :func:`keras.models.model.train_on_batch`. + + * **targets** (`list`) - A list of 4-dimensional ``numpy.ndarray`` s in the order \ + and size of each output of the model as defined in :attr:`model_output_shapes`. the \ + format of these arrays will be (`batchsize`, `height`, `width`, `3`). This is \ + the :attr:`y` parameter for :func:`keras.models.model.train_on_batch` **NB:** \ + masks are not included in the ``targets`` list. If required for feeding into the \ + Keras model, they will need to be added to this list in \ + :mod:`plugins.train.trainer._base` from the ``masks`` key. + + * **masks** (`numpy.ndarray`) - A 4-dimensional array containing the target masks in \ + the format (`batchsize`, `height`, `width`, `1`). **NB:** This item will only exist \ + in the ``dict`` if the :attr:`mask_type` is not ``None`` + + * **samples** (`numpy.ndarray`) - A 4-dimensional array containg the samples for \ + feeding to the model's predict function for generating preview and timelapse samples. \ + The array will be in the format (`batchsize`, `height`, `width`, `channels`). **NB:** \ + This item will only exist in the ``dict`` if :attr:`is_preview` or \ + :attr:`is_timelapse` is ``True`` + """ logger.debug("Queue batches: (image_count: %s, batchsize: %s, side: '%s', do_shuffle: %s, " "is_preview, %s, is_timelapse: %s)", len(images), batchsize, side, do_shuffle, is_preview, is_timelapse) - self.batchsize = batchsize - is_display = is_preview or is_timelapse - args = (images, side, is_display, do_shuffle, batchsize) - batcher = BackgroundGenerator(self.minibatch, thread_count=2, args=args) + self._batchsize = batchsize + self._processing = ImageAugmentation(batchsize, + is_preview or is_timelapse, + self._model_input_size, + self._model_output_shapes, + self._training_opts.get("coverage_ratio", 0.625), + self._config) + args = (images, side, do_shuffle, batchsize) + batcher = BackgroundGenerator(self._minibatch, thread_count=2, args=args) return batcher.iterator() - def validate_samples(self, data): - """ Check the total number of images against batchsize and return - the total number of images """ + # << INTERNAL METHODS >> # + def _set__mask_class(self): + """ Returns the correct mask class from :mod:`lib`.model.masks` as defined in the + :attr:`mask_type` parameter. """ + mask_type = self._training_opts.get("mask_type", None) + if mask_type: + logger.debug("Mask type: '%s'", mask_type) + _mask_class = getattr(masks, mask_type) + else: + _mask_class = None + logger.debug("Mask class: %s", _mask_class) + return _mask_class + + def _validate_samples(self, data): + """ Ensures that the total number of images within :attr:`images` is greater or equal to + the selected :attr:`batchsize`. Raises an exception if this is not the case. """ length = len(data) msg = ("Number of images is lower than batch-size (Note that too few " "images may lead to bad training). # images: {}, " - "batch-size: {}".format(length, self.batchsize)) + "batch-size: {}".format(length, self._batchsize)) try: - assert length >= self.batchsize, msg + assert length >= self._batchsize, msg except AssertionError as err: msg += ("\nYou should increase the number of images in your training set or lower " "your batch-size.") raise FaceswapError(msg) from err - def minibatch(self, images, side, is_display, do_shuffle, batchsize): - """ A generator function that yields epoch, batchsize of warped_img - and batchsize of target_img from the load queue """ - logger.debug("Loading minibatch generator: (image_count: %s, side: '%s', is_display: %s, " - "do_shuffle: %s)", len(images), side, is_display, do_shuffle) - self.validate_samples(images) + def _minibatch(self, images, side, do_shuffle, batchsize): + """ A generator function that yields the augmented, target and sample images. + see :func:`minibatch_ab` for more details on the output. """ + logger.debug("Loading minibatch generator: (image_count: %s, side: '%s', do_shuffle: %s)", + len(images), side, do_shuffle) + self._validate_samples(images) def _img_iter(imgs): while True: @@ -93,369 +196,530 @@ def _img_iter(imgs): img_iter = _img_iter(images) while True: - batch = list() - for _ in range(batchsize): - img_path = next(img_iter) - data = self.process_face(img_path, side, is_display) - batch.append(data) - batch = list(zip(*batch)) - batch = [np.array(x, dtype="float32") for x in batch] - logger.trace("Yielding batch: (size: %s, item shapes: %s, side: '%s', " - "is_display: %s)", - len(batch), [item.shape for item in batch], side, is_display) - yield batch - - logger.debug("Finished minibatch generator: (side: '%s', is_display: %s)", - side, is_display) - - def process_face(self, filename, side, is_display): - """ Load an image and perform transformation and warping """ - logger.trace("Process face: (filename: '%s', side: '%s', is_display: %s)", - filename, side, is_display) - image = cv2_read_img(filename, raise_error=True) - if self.mask_class or self.training_opts["warp_to_landmarks"]: - src_pts = self.get_landmarks(filename, image, side) - if self.mask_class: - image = self.mask_class(src_pts, image, channels=4).mask - - image = self.processing.color_adjust(image, - self.training_opts["augment_color"], - is_display) - if not is_display: - image = self.processing.random_transform(image) - if not self.training_opts["no_flip"]: - image = self.processing.do_random_flip(image) - sample = image.copy()[:, :, :3] - - if self.training_opts["warp_to_landmarks"]: - dst_pts = self.get_closest_match(filename, side, src_pts) - processed = self.processing.random_warp_landmarks(image, src_pts, dst_pts) + img_paths = [next(img_iter) for _ in range(batchsize)] + yield self._process_batch(img_paths, side) + + logger.debug("Finished minibatch generator: (side: '%s')", side) + + def _process_batch(self, filenames, side): + """ Performs the augmentation and compiles target images and samples. See + :func:`minibatch_ab` for more details on the output. """ + logger.trace("Process batch: (filenames: '%s', side: '%s')", filenames, side) + batch = read_image_batch(filenames) + processed = dict() + to_landmarks = self._training_opts["warp_to_landmarks"] + + # Initialize processing training size on first image + if not self._processing.initialized: + self._processing.initialize(batch.shape[1]) + + # Get Landmarks prior to manipulating the image + if self._mask_class or to_landmarks: + batch_src_pts = self._get_landmarks(filenames, batch, side) + + # Color augmentation before mask is added + if self._training_opts["augment_color"]: + batch = self._processing.color_adjust(batch) + + # Add mask to batch prior to transforms and warps + if self._mask_class: + batch = np.array([self._mask_class(src_pts, image, channels=4).mask + for src_pts, image in zip(batch_src_pts, batch)]) + + # Random Transform and flip + batch = self._processing.transform(batch) + if not self._training_opts["no_flip"]: + batch = self._processing.random_flip(batch) + + # Add samples to output if this is for display + if self._processing.is_display: + processed["samples"] = batch[..., :3].astype("float32") / 255.0 + + # Get Targets + processed.update(self._processing.get_targets(batch)) + + # Random Warp + if to_landmarks: + warp_kwargs = dict(batch_src_points=batch_src_pts, + batch_dst_points=self._get_closest_match(filenames, + side, + batch_src_pts)) else: - processed = self.processing.random_warp(image) + warp_kwargs = dict() + processed["feed"] = self._processing.warp(batch[..., :3], to_landmarks, **warp_kwargs) + + logger.trace("Processed batch: (filenames: %s, side: '%s', processed: %s)", + filenames, + side, + {k: v.shape if isinstance(v, np.ndarray) else[i.shape for i in v] + for k, v in processed.items()}) - processed.insert(0, sample) - logger.trace("Processed face: (filename: '%s', side: '%s', shapes: %s)", - filename, side, [img.shape for img in processed]) return processed - def get_landmarks(self, filename, image, side): - """ Return the landmarks for this face """ - logger.trace("Retrieving landmarks: (filename: '%s', side: '%s'", filename, side) - lm_key = sha1(image).hexdigest() - try: - src_points = self.landmarks[side][lm_key] - except KeyError as err: - msg = ("At least one of your images does not have a matching entry in your alignments " - "file." + def _get_landmarks(self, filenames, batch, side): + """ Obtains the 68 Point Landmarks for the images in this batch. This is only called if + config item ``warp_to_landmarks`` is ``True`` or if :attr:`mask_type` is not ``None``. If + the landmarks for an image cannot be found, then an error is raised. """ + logger.trace("Retrieving landmarks: (filenames: '%s', side: '%s'", filenames, side) + src_points = [self._landmarks[side].get(sha1(face).hexdigest(), None) for face in batch] + + # Raise error on missing alignments + if not all(isinstance(pts, np.ndarray) for pts in src_points): + indices = [idx for idx, hsh in enumerate(src_points) if hsh is None] + missing = [filenames[idx] for idx in indices] + msg = ("Files missing alignments for this batch: {}" + "\nAt least one of your images does not have a matching entry in your " + "alignments file." "\nIf you are training with a mask or using 'warp to landmarks' then every " "face you intend to train on must exist within the alignments file." - "\nThe specific file that caused the failure was '{}' which has a hash of {}." - "\nMost likely there will be more than just this file missing from the " + "\nThe specific files that caused this failure are listed above." + "\nMost likely there will be more than just these files missing from the " "alignments file. You can use the Alignments Tool to help identify missing " - "alignments".format(lm_key, filename)) - raise FaceswapError(msg) from err + "alignments".format(missing)) + raise FaceswapError(msg) + logger.trace("Returning: (src_points: %s)", src_points) - return src_points - - def get_closest_match(self, filename, side, src_points): - """ Return closest matched landmarks from opposite set """ - logger.trace("Retrieving closest matched landmarks: (filename: '%s', src_points: '%s'", - filename, src_points) - landmarks = self.landmarks["a"] if side == "b" else self.landmarks["b"] - closest_hashes = self._nearest_landmarks.get(filename) - if not closest_hashes: - dst_points_items = list(landmarks.items()) - dst_points = list(x[1] for x in dst_points_items) + return np.array(src_points) + + def _get_closest_match(self, filenames, side, batch_src_points): + """ Only called if the config item ``warp_to_landmarks`` is ``True``. Gets the closest + matched 68 point landmarks from the opposite training set. """ + logger.trace("Retrieving closest matched landmarks: (filenames: '%s', src_points: '%s'", + filenames, batch_src_points) + landmarks = self._landmarks["a"] if side == "b" else self._landmarks["b"] + closest_hashes = [self._nearest_landmarks.get(filename) for filename in filenames] + if None in closest_hashes: + closest_hashes = self._cache_closest_hashes(filenames, batch_src_points, landmarks) + + batch_dst_points = np.array([landmarks[choice(hsh)] for hsh in closest_hashes]) + logger.trace("Returning: (batch_dst_points: %s)", batch_dst_points.shape) + return batch_dst_points + + def _cache_closest_hashes(self, filenames, batch_src_points, landmarks): + """ Cache the nearest landmarks for this batch """ + logger.trace("Caching closest hashes") + dst_landmarks = list(landmarks.items()) + dst_points = np.array([lm[1] for lm in dst_landmarks]) + batch_closest_hashes = list() + + for filename, src_points in zip(filenames, batch_src_points): closest = (np.mean(np.square(src_points - dst_points), axis=(1, 2))).argsort()[:10] - closest_hashes = tuple(dst_points_items[i][0] for i in closest) + closest_hashes = tuple(dst_landmarks[i][0] for i in closest) self._nearest_landmarks[filename] = closest_hashes - dst_points = landmarks[choice(closest_hashes)] - logger.trace("Returning: (dst_points: %s)", dst_points) - return dst_points + batch_closest_hashes.append(closest_hashes) + logger.trace("Cached closest hashes") + return batch_closest_hashes + + +class ImageAugmentation(): + """ Performs augmentation on batches of training images. + + Parameters + ---------- + batchsize: int + The number of images that will be fed through the augmentation functions at once. + is_display: bool + Whether the images being fed through will be used for Preview or Timelapse. Disables + the "warp" augmentation for these images. + input_size: int + The expected input size for the model. It is assumed that the input to the model is always + a square image. This is the size, in pixels, of the `width` and the `height` of the input + to the model. + output_shapes: list + A list of tuples defining the output shapes from the model, in the order that the outputs + are returned. The tuples should be in (`height`, `width`, `channels`) format. + coverage_ratio: float + The ratio of the training image to be trained on. Dictates how much of the image will be + cropped out. Eg: a coverage ratio of 0.625 will result in cropping a 160px box from a 256px + image (256 * 0.625 = 160). + config: dict + The configuration ``dict`` generated from :file:`config.train.ini` containing the trainer \ + plugin configuration options. + + Attributes + ---------- + initialized: bool + Flag to indicate whether :class:`ImageAugmentation` has been initialized with the training + image size in order to cache certain augmentation operations (see :func:`initialize`) + is_display: bool + Flag to indicate whether these augmentations are for timelapses/preview images (``True``) + or standard training data (``False)`` + """ + def __init__(self, batchsize, is_display, input_size, output_shapes, coverage_ratio, config): + logger.debug("Initializing %s: (batchsize: %s, is_display: %s, input_size: %s, " + "output_shapes: %s, coverage_ratio: %s, config: %s)", + self.__class__.__name__, batchsize, is_display, input_size, output_shapes, + coverage_ratio, config) + self.initialized = False + self.is_display = is_display -class ImageManipulation(): - """ Manipulations to be performed on training images """ - def __init__(self, input_size, output_shapes, coverage_ratio, config): - """ input_size: Size of the face input into the model - output_shapes: Shapes that come out of the model - coverage_ratio: Coverage ratio of full image. Eg: 256 * 0.625 = 160 - """ - logger.debug("Initializing %s: (input_size: %s, output_shapes: %s, coverage_ratio: %s, " - "config: %s)", self.__class__.__name__, input_size, output_shapes, - coverage_ratio, config) - self.config = config + # Set on first image load from initialize + self._training_size = 0 + self._constants = None + + self._batchsize = batchsize + self._config = config # Transform and Warp args - self.input_size = input_size - self.output_sizes = [shape[1] for shape in output_shapes if shape[2] == 3] - logger.debug("Output sizes: %s", self.output_sizes) + self._input_size = input_size + self._output_sizes = [shape[1] for shape in output_shapes if shape[2] == 3] + logger.debug("Output sizes: %s", self._output_sizes) # Warp args - self.coverage_ratio = coverage_ratio # Coverage ratio of full image. Eg: 256 * 0.625 = 160 - self.scale = 5 # Normal random variable scale - logger.debug("Initialized %s", self.__class__.__name__) + self._coverage_ratio = coverage_ratio + self._scale = 5 # Normal random variable scale - def color_adjust(self, img, augment_color, is_display): - """ Color adjust RGB image """ - logger.trace("Color adjusting image") - if not is_display and augment_color: - logger.trace("Augmenting color") - face, _ = self.separate_mask(img) - face = face.astype("uint8") - face = self.random_clahe(face) - face = self.random_lab(face) - img[:, :, :3] = face - return img.astype('float32') / 255.0 - - def random_clahe(self, image): - """ Randomly perform Contrast Limited Adaptive Histogram Equilization """ - contrast_random = random() - if contrast_random > self.config.get("color_clahe_chance", 50) / 100: - return image - - base_contrast = image.shape[0] // 128 - grid_base = random() * self.config.get("color_clahe_max_size", 4) - contrast_adjustment = int(grid_base * (base_contrast / 2)) - grid_size = base_contrast + contrast_adjustment - logger.trace("Adjusting Contrast. Grid Size: %s", grid_size) - - clahe = cv2.createCLAHE(clipLimit=2.0, # pylint: disable=no-member - tileGridSize=(grid_size, grid_size)) - for chan in range(3): - image[:, :, chan] = clahe.apply(image[:, :, chan]) - return image - - def random_lab(self, image): - """ Perform random color/lightness adjustment in L*a*b* colorspace """ - amount_l = self.config.get("color_lightness", 30) / 100 - amount_ab = self.config.get("color_ab", 8) / 100 - - randoms = [(random() * amount_l * 2) - amount_l, # L adjust - (random() * amount_ab * 2) - amount_ab, # A adjust - (random() * amount_ab * 2) - amount_ab] # B adjust + logger.debug("Initialized %s", self.__class__.__name__) - logger.trace("Random LAB adjustments: %s", randoms) - image = cv2.cvtColor( # pylint:disable=no-member - image, cv2.COLOR_BGR2LAB).astype("float32") / 255.0 # pylint:disable=no-member - - for idx, adjustment in enumerate(randoms): - if adjustment >= 0: - image[:, :, idx] = ((1 - image[:, :, idx]) * adjustment) + image[:, :, idx] - else: - image[:, :, idx] = image[:, :, idx] * (1 + adjustment) - image = cv2.cvtColor((image * 255.0).astype("uint8"), # pylint:disable=no-member - cv2.COLOR_LAB2BGR) # pylint:disable=no-member - return image + def initialize(self, training_size): + """ Initializes the caching of constants for use in various image augmentations. + + The training image size is not known prior to loading the images from disk and commencing + training, so it cannot be set in the ``__init__`` method. When the first training batch is + loaded this function should be called to initialize the class and perform various + calculations based on this input size to cache certain constants for image augmentation + calculations. + + Parameters + ---------- + training_size: int + The size of the training images stored on disk that are to be fed into + :class:`ImageAugmentation`. The training images should always be square and of the + same size. This is the size, in pixels, of the `width` and the `height` of the + training images. + """ + logger.debug("Initializing constants. training_size: %s", training_size) + self._training_size = training_size + coverage = int(self._training_size * self._coverage_ratio) + + # Color Aug + clahe_base_contrast = training_size // 128 + # Target Images + tgt_slices = slice(self._training_size // 2 - coverage // 2, + self._training_size // 2 + coverage // 2) + + # Random Warp + warp_range_ = np.linspace(self._training_size // 2 - coverage // 2, + self._training_size // 2 + coverage // 2, 5, dtype='float32') + warp_mapx = np.broadcast_to(warp_range_, (self._batchsize, 5, 5)).astype("float32") + warp_mapy = np.broadcast_to(warp_mapx[0].T, (self._batchsize, 5, 5)).astype("float32") + + warp_pad = int(1.25 * self._input_size) + warp_slices = slice(warp_pad // 10, -warp_pad // 10) + + # Random Warp Landmarks + p_mx = self._training_size - 1 + p_hf = (self._training_size // 2) - 1 + edge_anchors = np.array([(0, 0), (0, p_mx), (p_mx, p_mx), (p_mx, 0), + (p_hf, 0), (p_hf, p_mx), (p_mx, p_hf), (0, p_hf)]).astype("int32") + edge_anchors = np.broadcast_to(edge_anchors, (self._batchsize, 8, 2)) + grids = np.mgrid[0:p_mx:complex(self._training_size), 0:p_mx:complex(self._training_size)] + + self._constants = dict(clahe_base_contrast=clahe_base_contrast, + tgt_slices=tgt_slices, + warp_mapx=warp_mapx, + warp_mapy=warp_mapy, + warp_pad=warp_pad, + warp_slices=warp_slices, + warp_lm_edge_anchors=edge_anchors, + warp_lm_grids=grids) + self.initialized = True + logger.debug("Initialized constants: %s", self._constants) + + # <<< TARGET IMAGES >>> # + def get_targets(self, batch): + """ Returns the target images, and masks, if required. + + Parameters + ---------- + batch: numpy.ndarray + This should be a 4-dimensional array of training images in the format (`batchsize`, + `height`, `width`, `channels`). Targets should be requested after performing image + transformations but prior to performing warps. + + Returns + ------- + dict + The following keys will be within the returned dictionary: + + * **targets** (`list`) - A list of 4-dimensional ``numpy.ndarray`` s in the order \ + and size of each output of the model as defined in :attr:`output_shapes`. The \ + format of these arrays will be (`batchsize`, `height`, `width`, `3`). **NB:** \ + masks are not included in the ``targets`` list. If masks are to be included in the \ + output they will be returned as their own item from the ``masks`` key. + + * **masks** (`numpy.ndarray`) - A 4-dimensional array containing the target masks in \ + the format (`batchsize`, `height`, `width`, `1`). **NB:** This item will only exist \ + in the ``dict`` if a batch of 4 channel images has been passed in :attr:`batch` + """ + logger.trace("Compiling targets") + slices = self._constants["tgt_slices"] + target_batch = [np.array([cv2.resize(image[slices, slices, :], + (size, size), + cv2.INTER_AREA) + for image in batch]) + for size in self._output_sizes] + logger.trace("Target image shapes: %s", + [tgt.shape for tgt_images in target_batch for tgt in tgt_images]) + + retval = self._separate_target_mask(target_batch) + logger.trace("Final targets: %s", + {k: v.shape if isinstance(v, np.ndarray) else [img.shape for img in v] + for k, v in retval.items()}) + return retval @staticmethod - def separate_mask(image): - """ Return the image and the mask from a 4 channel image """ - mask = None - if image.shape[2] == 4: - logger.trace("Image contains mask") - mask = np.expand_dims(image[:, :, -1], axis=2) - image = image[:, :, :3] - else: - logger.trace("Image has no mask") - return image, mask - - def get_coverage(self, image): - """ Return coverage value for given image """ - coverage = int(image.shape[0] * self.coverage_ratio) - logger.trace("Coverage: %s", coverage) - return coverage + def _separate_target_mask(batch): + """ Return the batch and the batch of final masks - def random_transform(self, image): - """ Randomly transform an image """ - logger.trace("Randomly transforming image") - height, width = image.shape[0:2] - - rotation_range = self.config.get("rotation_range", 10) - rotation = np.random.uniform(-rotation_range, rotation_range) - - zoom_range = self.config.get("zoom_range", 5) / 100 - scale = np.random.uniform(1 - zoom_range, 1 + zoom_range) - - shift_range = self.config.get("shift_range", 5) / 100 - tnx = np.random.uniform(-shift_range, shift_range) * width - tny = np.random.uniform(-shift_range, shift_range) * height - - mat = cv2.getRotationMatrix2D( # pylint:disable=no-member - (width // 2, height // 2), rotation, scale) - mat[:, 2] += (tnx, tny) - result = cv2.warpAffine( # pylint:disable=no-member - image, mat, (width, height), - borderMode=cv2.BORDER_REPLICATE) # pylint:disable=no-member - - logger.trace("Randomly transformed image") - return result - - def do_random_flip(self, image): - """ Perform flip on image if random number is within threshold """ - logger.trace("Randomly flipping image") - random_flip = self.config.get("random_flip", 50) / 100 - if np.random.random() < random_flip: - logger.trace("Flip within threshold. Flipping") - retval = image[:, ::-1] + Returns the targets as a list of 4-dimensional ``numpy.ndarray`` s of shape (`batchsize`, + `height`, `width`, 3). If the :attr:`batch` is 4 channels, then the masks will be split + from the batch, with the largest output masks being returned in their own item. + """ + batch = [tgt.astype("float32") / 255.0 for tgt in batch] + if all(tgt.shape[-1] == 4 for tgt in batch): + logger.trace("Batch contains mask") + sizes = [item.shape[1] for item in batch] + mask_batch = np.expand_dims(batch[sizes.index(max(sizes))][..., -1], axis=-1) + batch = [item[..., :3] for item in batch] + logger.trace("batch shapes: %s, mask_batch shape: %s", + [tgt.shape for tgt in batch], mask_batch.shape) + retval = dict(targets=batch, masks=mask_batch) else: - logger.trace("Flip outside threshold. Not Flipping") - retval = image - logger.trace("Randomly flipped image") + logger.trace("Batch has no mask") + retval = dict(targets=batch) return retval - def random_warp(self, image): - """ get pair of random warped images from aligned face image """ - logger.trace("Randomly warping image") - height, width = image.shape[0:2] - coverage = self.get_coverage(image) // 2 - try: - assert height == width and height % 2 == 0 - except AssertionError as err: - msg = ("Training images should be square with an even number of pixels across each " - "side. An image was found with width: {}, height: {}." - "\nMost likely this is a frame rather than a face within your training set. " - "\nMake sure that the only images within your training set are faces generated " - "from the Extract process.".format(width, height)) - raise FaceswapError(msg) from err - - range_ = np.linspace(height // 2 - coverage, height // 2 + coverage, 5, dtype='float32') - mapx = np.broadcast_to(range_, (5, 5)).copy() - mapy = mapx.T - # mapx, mapy = np.float32(np.meshgrid(range_,range_)) # instead of broadcast - - pad = int(1.25 * self.input_size) - slices = slice(pad // 10, -pad // 10) - dst_slices = [slice(0, (size + 1), (size // 4)) for size in self.output_sizes] - interp = np.empty((2, self.input_size, self.input_size), dtype='float32') + # <<< COLOR AUGMENTATION >>> # + def color_adjust(self, batch): + """ Perform color augmentation on the passed in batch. - for i, map_ in enumerate([mapx, mapy]): - map_ = map_ + np.random.normal(size=(5, 5), scale=self.scale) - interp[i] = cv2.resize(map_, (pad, pad))[slices, slices] # pylint:disable=no-member + The color adjustment parameters are set in :file:`config.train.ini` - warped_image = cv2.remap( # pylint:disable=no-member - image, interp[0], interp[1], cv2.INTER_LINEAR) # pylint:disable=no-member - logger.trace("Warped image shape: %s", warped_image.shape) + Parameters + ---------- + batch: numpy.ndarray + The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, + `3`) and in `BGR` format. - src_points = np.stack([mapx.ravel(), mapy.ravel()], axis=-1) - dst_points = [np.mgrid[dst_slice, dst_slice] for dst_slice in dst_slices] - mats = [umeyama(src_points, True, dst_pts.T.reshape(-1, 2))[0:2] - for dst_pts in dst_points] + Returns + ---------- + numpy.ndarray + A 4-dimensional array of the same shape as :attr:`batch` with color augmentation + applied. + """ + if not self.is_display: + logger.trace("Augmenting color") + batch = batch_convert_color(batch, "BGR2LAB") + batch = self._random_clahe(batch) + batch = self._random_lab(batch) + batch = batch_convert_color(batch, "LAB2BGR") + return batch + + def _random_clahe(self, batch): + """ Randomly perform Contrast Limited Adaptive Histogram Equilization on + a batch of images """ + base_contrast = self._constants["clahe_base_contrast"] + + batch_random = np.random.rand(self._batchsize) + indices = np.where(batch_random > self._config.get("color_clahe_chance", 50) / 100)[0] + + grid_bases = np.rint(np.random.uniform(0, + self._config.get("color_clahe_max_size", 4), + size=indices.shape[0])).astype("uint8") + contrast_adjustment = (grid_bases * (base_contrast // 2)) + grid_sizes = contrast_adjustment + base_contrast + logger.trace("Adjusting Contrast. Grid Sizes: %s", grid_sizes) + + clahes = [cv2.createCLAHE(clipLimit=2.0, # pylint: disable=no-member + tileGridSize=(grid_size, grid_size)) + for grid_size in grid_sizes] + + for idx, clahe in zip(indices, clahes): + batch[idx, :, :, 0] = clahe.apply(batch[idx, :, :, 0]) + return batch + + def _random_lab(self, batch): + """ Perform random color/lightness adjustment in L*a*b* colorspace on a batch of images """ + amount_l = self._config.get("color_lightness", 30) / 100 + amount_ab = self._config.get("color_ab", 8) / 100 + adjust = np.array([amount_l, amount_ab, amount_ab], dtype="float32") + randoms = ( + (np.random.rand(self._batchsize, 1, 1, 3).astype("float32") * (adjust * 2)) - adjust) + logger.trace("Random LAB adjustments: %s", randoms) - target_images = [cv2.warpAffine(image, # pylint:disable=no-member - mat, - (self.output_sizes[idx], self.output_sizes[idx])) - for idx, mat in enumerate(mats)] + for image, rand in zip(batch, randoms): + for idx in range(rand.shape[-1]): + adjustment = rand[:, :, idx] + if adjustment >= 0: + image[:, :, idx] = ((255 - image[:, :, idx]) * adjustment) + image[:, :, idx] + else: + image[:, :, idx] = image[:, :, idx] * (1 + adjustment) + return batch + + # <<< IMAGE AUGMENTATION >>> # + def transform(self, batch): + """ Perform random transformation on the passed in batch. + + The transformation parameters are set in :file:`config.train.ini` + + Parameters + ---------- + batch: numpy.ndarray + The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, + `channels`) and in `BGR` format. + + Returns + ---------- + numpy.ndarray + A 4-dimensional array of the same shape as :attr:`batch` with transformation applied. + """ + if self.is_display: + return batch + logger.trace("Randomly transforming image") + rotation_range = self._config.get("rotation_range", 10) + zoom_range = self._config.get("zoom_range", 5) / 100 + shift_range = self._config.get("shift_range", 5) / 100 + + rotation = np.random.uniform(-rotation_range, + rotation_range, + size=self._batchsize).astype("float32") + scale = np.random.uniform(1 - zoom_range, + 1 + zoom_range, + size=self._batchsize).astype("float32") + tform = np.random.uniform( + -shift_range, + shift_range, + size=(self._batchsize, 2)).astype("float32") * self._training_size + + mats = np.array( + [cv2.getRotationMatrix2D((self._training_size // 2, self._training_size // 2), + rot, + scl) + for rot, scl in zip(rotation, scale)]).astype("float32") + mats[..., 2] += tform + + batch = np.array([cv2.warpAffine(image, + mat, + (self._training_size, self._training_size), + borderMode=cv2.BORDER_REPLICATE) + for image, mat in zip(batch, mats)]) - logger.trace("Target image shapes: %s", [tgt.shape for tgt in target_images]) - return self.compile_images(warped_image, target_images) + logger.trace("Randomly transformed image") + return batch - def random_warp_landmarks(self, image, src_points=None, dst_points=None): - """ get warped image, target image and target mask - From DFAKER plugin """ - logger.trace("Randomly warping landmarks") - size = image.shape[0] - coverage = self.get_coverage(image) // 2 - - p_mx = size - 1 - p_hf = (size // 2) - 1 - - edge_anchors = [(0, 0), (0, p_mx), (p_mx, p_mx), (p_mx, 0), - (p_hf, 0), (p_hf, p_mx), (p_mx, p_hf), (0, p_hf)] - grid_x, grid_y = np.mgrid[0:p_mx:complex(size), 0:p_mx:complex(size)] - - source = src_points - destination = (dst_points.copy().astype('float32') + - np.random.normal(size=dst_points.shape, scale=2.0)) - destination = destination.astype('uint8') - - face_core = cv2.convexHull(np.concatenate( # pylint:disable=no-member - [source[17:], destination[17:]], axis=0).astype(int)) - - source = [(pty, ptx) for ptx, pty in source] + edge_anchors - destination = [(pty, ptx) for ptx, pty in destination] + edge_anchors - - indicies_to_remove = set() - for fpl in source, destination: - for idx, (pty, ptx) in enumerate(fpl): - if idx > 17: - break - elif cv2.pointPolygonTest(face_core, # pylint:disable=no-member - (pty, ptx), - False) >= 0: - indicies_to_remove.add(idx) - - for idx in sorted(indicies_to_remove, reverse=True): - source.pop(idx) - destination.pop(idx) - - grid_z = griddata(destination, source, (grid_x, grid_y), method="linear") - map_x = np.append([], [ar[:, 1] for ar in grid_z]).reshape(size, size) - map_y = np.append([], [ar[:, 0] for ar in grid_z]).reshape(size, size) - map_x_32 = map_x.astype('float32') - map_y_32 = map_y.astype('float32') - - warped_image = cv2.remap(image, # pylint:disable=no-member - map_x_32, - map_y_32, - cv2.INTER_LINEAR, # pylint:disable=no-member - cv2.BORDER_TRANSPARENT) # pylint:disable=no-member - target_image = image - - # TODO Make sure this replacement is correct - slices = slice(size // 2 - coverage, size // 2 + coverage) -# slices = slice(size // 32, size - size // 32) # 8px on a 256px image - warped_image = cv2.resize( # pylint:disable=no-member - warped_image[slices, slices, :], (self.input_size, self.input_size), - cv2.INTER_AREA) # pylint:disable=no-member - logger.trace("Warped image shape: %s", warped_image.shape) - target_images = [cv2.resize(target_image[slices, slices, :], # pylint:disable=no-member - (size, size), - cv2.INTER_AREA) # pylint:disable=no-member - for size in self.output_sizes] - - logger.trace("Target image shapea: %s", [img.shape for img in target_images]) - return self.compile_images(warped_image, target_images) - - def compile_images(self, warped_image, target_images): - """ Compile the warped images, target images and mask for feed """ - warped_image, _ = self.separate_mask(warped_image) - final_target_images = list() - target_mask = None - for target_image in target_images: - image, mask = self.separate_mask(target_image) - final_target_images.append(image) - # Add the mask if it exists and is the same size as our largest output - if mask is not None and mask.shape[1] == max(self.output_sizes): - target_mask = mask - - retval = [warped_image] + final_target_images - if target_mask is not None: - logger.trace("Target mask shape: %s", target_mask.shape) - retval.append(target_mask) - - logger.trace("Final shapes: %s", [img.shape for img in retval]) - return retval + def random_flip(self, batch): + """ Perform random horizontal flipping on the passed in batch. + The probability of flipping an image is set in :file:`config.train.ini` -def stack_images(images): - """ Stack images """ - logger.debug("Stack images") + Parameters + ---------- + batch: numpy.ndarray + The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, + `channels`) and in `BGR` format. - def get_transpose_axes(num): - if num % 2 == 0: - logger.debug("Even number of images to stack") - y_axes = list(range(1, num - 1, 2)) - x_axes = list(range(0, num - 1, 2)) - else: - logger.debug("Odd number of images to stack") - y_axes = list(range(0, num - 1, 2)) - x_axes = list(range(1, num - 1, 2)) - return y_axes, x_axes, [num - 1] - - images_shape = np.array(images.shape) - new_axes = get_transpose_axes(len(images_shape)) - new_shape = [np.prod(images_shape[x]) for x in new_axes] - logger.debug("Stacked images") - return np.transpose( - images, - axes=np.concatenate(new_axes) - ).reshape(new_shape) + Returns + ---------- + numpy.ndarray + A 4-dimensional array of the same shape as :attr:`batch` with transformation applied. + """ + if not self.is_display: + logger.trace("Randomly flipping image") + randoms = np.random.rand(self._batchsize) + indices = np.where(randoms > self._config.get("random_flip", 50) / 100)[0] + batch[indices] = batch[indices, :, ::-1] + logger.trace("Randomly flipped %s images of %s", len(indices), self._batchsize) + return batch + + def warp(self, batch, to_landmarks=False, **kwargs): + """ Perform random warping on the passed in batch by one of two methods. + + Parameters + ---------- + batch: numpy.ndarray + The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, + `3`) and in `BGR` format. + to_landmarks: bool, optional + If ``False`` perform standard random warping of the input image. If ``True`` perform + warping to semi-random similar corresponding landmarks from the other side. Default: + ``False`` + kwargs: dict + If :attr:`to_landmarks` is ``True`` the following additional kwargs must be passed in: + + * **batch_src_points** (`numpy.ndarray`) - A batch of 68 point landmarks for the \ + source faces. This is a 3-dimensional array in the shape (`batchsize`, `68`, `2`). + + * **batch_dst_points** (`numpy.ndarray`) - A batch of randomly chosen closest match \ + destination faces landmarks. This is a 3-dimensional array in the shape (`batchsize`, \ + `68`, `2`). + Returns + ---------- + numpy.ndarray + A 4-dimensional array of the same shape as :attr:`batch` with warping applied. + """ + if to_landmarks: + return self._random_warp_landmarks(batch, **kwargs).astype("float32") / 255.0 + return self._random_warp(batch).astype("float32") / 255.0 + + def _random_warp(self, batch): + """ Randomly warp the input batch """ + logger.trace("Randomly warping batch") + mapx = self._constants["warp_mapx"] + mapy = self._constants["warp_mapy"] + pad = self._constants["warp_pad"] + slices = self._constants["warp_slices"] + + rands = np.random.normal(size=(self._batchsize, 2, 5, 5), + scale=self._scale).astype("float32") + batch_maps = np.stack((mapx, mapy), axis=1) + rands + batch_interp = np.array([[cv2.resize(map_, (pad, pad))[slices, slices] for map_ in maps] + for maps in batch_maps]) + warped_batch = np.array([cv2.remap(image, interp[0], interp[1], cv2.INTER_LINEAR) + for image, interp in zip(batch, batch_interp)]) + + logger.trace("Warped image shape: %s", warped_batch.shape) + return warped_batch + + def _random_warp_landmarks(self, batch, batch_src_points, batch_dst_points): + """ From dfaker. Warp the image to a similar set of landmarks from the opposite side """ + logger.trace("Randomly warping landmarks") + edge_anchors = self._constants["warp_lm_edge_anchors"] + grids = self._constants["warp_lm_grids"] + slices = self._constants["tgt_slices"] + + batch_dst = (batch_dst_points + np.random.normal(size=batch_dst_points.shape, + scale=2.0)).astype("int32") + + face_cores = [cv2.convexHull(np.concatenate([src[17:], dst[17:]], axis=0)) + for src, dst in zip(batch_src_points, batch_dst)] + + batch_src = np.append(batch_src_points, edge_anchors, axis=1) + batch_dst = np.append(batch_dst, edge_anchors, axis=1) + + rem_indices = [list(set(idx for fpl in (src, dst) + for idx, (pty, ptx) in enumerate(fpl) + if cv2.pointPolygonTest(face_core, (pty, ptx), False) >= 0)) + for src, dst, face_core in zip(batch_src[:, :18, :], + batch_dst[:, :18, :], + face_cores)] + batch_src = [np.delete(src, idxs, axis=0) for idxs, src in zip(rem_indices, batch_src)] + batch_dst = [np.delete(dst, idxs, axis=0) for idxs, dst in zip(rem_indices, batch_dst)] + + grid_z = np.array([griddata(dst, src, (grids[0], grids[1]), method="linear") + for src, dst in zip(batch_src, batch_dst)]) + maps = grid_z.reshape(self._batchsize, + self._training_size, + self._training_size, + 2).astype("float32") + warped_batch = np.array([cv2.remap(image, + map_[..., 1], + map_[..., 0], + cv2.INTER_LINEAR, + cv2.BORDER_TRANSPARENT) + for image, map_ in zip(batch, maps)]) + warped_batch = np.array([cv2.resize(image[slices, slices, :], + (self._input_size, self._input_size), + cv2.INTER_AREA) + for image in warped_batch]) + logger.trace("Warped batch shape: %s", warped_batch.shape) + return warped_batch diff --git a/lib/utils.py b/lib/utils.py index 88a527c1ae..ee8c6ff2a1 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -4,27 +4,18 @@ import json import logging import os -import subprocess import sys import urllib import warnings import zipfile -from hashlib import sha1 + from pathlib import Path from re import finditer from multiprocessing import current_process from socket import timeout as socket_timeout, error as socket_error -import imageio_ffmpeg as im_ffm from tqdm import tqdm -import numpy as np -import cv2 - - -from lib.faces_detect import DetectedFace - - # Global variables _image_extensions = [ # pylint:disable=invalid-name ".bmp", ".jpeg", ".jpg", ".png", ".tif", ".tiff"] @@ -132,81 +123,6 @@ def get_image_paths(directory): return dir_contents -def full_path_split(path): - """ Split a given path into all of it's separate components """ - logger = logging.getLogger(__name__) # pylint:disable=invalid-name - allparts = list() - while True: - parts = os.path.split(path) - if parts[0] == path: # sentinel for absolute paths - allparts.insert(0, parts[0]) - break - elif parts[1] == path: # sentinel for relative paths - allparts.insert(0, parts[1]) - break - else: - path = parts[0] - allparts.insert(0, parts[1]) - logger.trace("path: %s, allparts: %s", path, allparts) - return allparts - - -def cv2_read_img(filename, raise_error=False): - """ Read an image with cv2 and check that an image was actually loaded. - Logs an error if the image returned is None. or an error has occured. - - Pass raise_error=True if error should be raised """ - logger = logging.getLogger(__name__) # pylint:disable=invalid-name - logger.trace("Requested image: '%s'", filename) - success = True - image = None - try: - image = cv2.imread(filename) # pylint:disable=no-member,c-extension-no-member - if image is None: - raise ValueError - except TypeError: - success = False - msg = "Error while reading image (TypeError): '{}'".format(filename) - logger.error(msg) - if raise_error: - raise Exception(msg) - except ValueError: - success = False - msg = ("Error while reading image. This is most likely caused by special characters in " - "the filename: '{}'".format(filename)) - logger.error(msg) - if raise_error: - raise Exception(msg) - except Exception as err: # pylint:disable=broad-except - success = False - msg = "Failed to load image '{}'. Original Error: {}".format(filename, str(err)) - logger.error(msg) - if raise_error: - raise Exception(msg) - logger.trace("Loaded image: '%s'. Success: %s", filename, success) - return image - - -def hash_image_file(filename): - """ Return an image file's sha1 hash """ - logger = logging.getLogger(__name__) # pylint:disable=invalid-name - img = cv2_read_img(filename, raise_error=True) - img_hash = sha1(img).hexdigest() - logger.trace("filename: '%s', hash: %s", filename, img_hash) - return img_hash - - -def hash_encode_image(image, extension): - """ Encode the image, get the hash and return the hash with - encoded image """ - img = cv2.imencode(extension, image)[1] # pylint:disable=no-member,c-extension-no-member - f_hash = sha1( - cv2.imdecode( # pylint:disable=no-member,c-extension-no-member - img, - cv2.IMREAD_UNCHANGED)).hexdigest() # pylint:disable=no-member,c-extension-no-member - return f_hash, img - - def convert_to_secs(*args): """ converts a time to second. Either convert_to_secs(min, secs) or convert_to_secs(hours, mins, secs). """ @@ -223,73 +139,23 @@ def convert_to_secs(*args): return retval -def count_frames_and_secs(path, timeout=60): - """ - Adapted From ffmpeg_imageio, to handle occasional hanging issue: - https://github.com/imageio/imageio-ffmpeg - - Get the number of frames and number of seconds for the given video - file. Note that this operation can be quite slow for large files. - - Disclaimer: I've seen this produce different results from actually reading - the frames with older versions of ffmpeg (2.x). Therefore I cannot say - with 100% certainty that the returned values are always exact. - """ - # https://stackoverflow.com/questions/2017843/fetch-frame-count-with-ffmpeg - +def full_path_split(path): + """ Split a given path into all of it's separate components """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name - assert isinstance(path, str), "Video path must be a string" - exe = im_ffm.get_ffmpeg_exe() - iswin = sys.platform.startswith("win") - logger.debug("iswin: '%s'", iswin) - cmd = [exe, "-i", path, "-map", "0:v:0", "-c", "copy", "-f", "null", "-"] - logger.debug("FFMPEG Command: '%s'", " ".join(cmd)) - attempts = 3 - for attempt in range(attempts): - try: - logger.debug("attempt: %s of %s", attempt + 1, attempts) - out = subprocess.check_output(cmd, - stderr=subprocess.STDOUT, - shell=iswin, - timeout=timeout) - logger.debug("Succesfully communicated with FFMPEG") + allparts = list() + while True: + parts = os.path.split(path) + if parts[0] == path: # sentinel for absolute paths + allparts.insert(0, parts[0]) break - except subprocess.CalledProcessError as err: - out = err.output.decode(errors="ignore") - raise RuntimeError("FFMEG call failed with {}:\n{}".format(err.returncode, out)) - except subprocess.TimeoutExpired as err: - this_attempt = attempt + 1 - if this_attempt == attempts: - msg = ("FFMPEG hung while attempting to obtain the frame count. " - "Sometimes this issue resolves itself, so you can try running again. " - "Otherwise use the Effmpeg Tool to extract the frames from your video into " - "a folder, and then run the requested Faceswap process on that folder.") - raise FaceswapError(msg) from err - logger.warning("FFMPEG hung while attempting to obtain the frame count. " - "Retrying %s of %s", this_attempt + 1, attempts) - continue - - # Note that other than with the subprocess calls below, ffmpeg wont hang here. - # Worst case Python will stop/crash and ffmpeg will continue running until done. - - nframes = nsecs = None - for line in reversed(out.splitlines()): - if not line.startswith(b"frame="): - continue - line = line.decode(errors="ignore") - logger.debug("frame line: '%s'", line) - idx = line.find("frame=") - if idx >= 0: - splitframes = line[idx:].split("=", 1)[-1].lstrip().split(" ", 1)[0].strip() - nframes = int(splitframes) - idx = line.find("time=") - if idx >= 0: - splittime = line[idx:].split("=", 1)[-1].lstrip().split(" ", 1)[0].strip() - nsecs = convert_to_secs(*splittime.split(":")) - logger.debug("nframes: %s, nsecs: %s", nframes, nsecs) - return nframes, nsecs - - raise RuntimeError("Could not get number of frames") # pragma: no cover + elif parts[1] == path: # sentinel for relative paths + allparts.insert(0, parts[1]) + break + else: + path = parts[0] + allparts.insert(0, parts[1]) + logger.trace("path: %s, allparts: %s", path, allparts) + return allparts def backup_file(directory, filename): @@ -348,80 +214,6 @@ def deprecation_warning(func_name, additional_info=None): logger.warning(msg) -def rotate_landmarks(face, rotation_matrix): - # pylint:disable=c-extension-no-member - """ Rotate the landmarks and bounding box for faces - found in rotated images. - Pass in a DetectedFace object or Alignments dict """ - logger = logging.getLogger(__name__) # pylint:disable=invalid-name - logger.trace("Rotating landmarks: (rotation_matrix: %s, type(face): %s", - rotation_matrix, type(face)) - rotated_landmarks = None - # Detected Face Object - if isinstance(face, DetectedFace): - bounding_box = [[face.x, face.y], - [face.x + face.w, face.y], - [face.x + face.w, face.y + face.h], - [face.x, face.y + face.h]] - landmarks = face.landmarks_xy - - # Alignments Dict - elif isinstance(face, dict) and "x" in face: - bounding_box = [[face.get("x", 0), face.get("y", 0)], - [face.get("x", 0) + face.get("w", 0), - face.get("y", 0)], - [face.get("x", 0) + face.get("w", 0), - face.get("y", 0) + face.get("h", 0)], - [face.get("x", 0), - face.get("y", 0) + face.get("h", 0)]] - landmarks = face.get("landmarks_xy", list()) - - else: - raise ValueError("Unsupported face type") - - logger.trace("Original landmarks: %s", landmarks) - - rotation_matrix = cv2.invertAffineTransform( # pylint:disable=no-member - rotation_matrix) - rotated = list() - for item in (bounding_box, landmarks): - if not item: - continue - points = np.array(item, np.int32) - points = np.expand_dims(points, axis=0) - transformed = cv2.transform(points, # pylint:disable=no-member - rotation_matrix).astype(np.int32) - rotated.append(transformed.squeeze()) - - # Bounding box should follow x, y planes, so get min/max - # for non-90 degree rotations - pt_x = min([pnt[0] for pnt in rotated[0]]) - pt_y = min([pnt[1] for pnt in rotated[0]]) - pt_x1 = max([pnt[0] for pnt in rotated[0]]) - pt_y1 = max([pnt[1] for pnt in rotated[0]]) - width = pt_x1 - pt_x - height = pt_y1 - pt_y - - if isinstance(face, DetectedFace): - face.x = int(pt_x) - face.y = int(pt_y) - face.w = int(width) - face.h = int(height) - face.r = 0 - if len(rotated) > 1: - rotated_landmarks = [tuple(point) for point in rotated[1].tolist()] - face.landmarks_xy = rotated_landmarks - else: - face["left"] = int(pt_x) - face["top"] = int(pt_y) - face["right"] = int(pt_x1) - face["bottom"] = int(pt_y1) - rotated_landmarks = face - - logger.trace("Rotated landmarks: %s", rotated_landmarks) - return face - - def camel_case_split(identifier): """ Split a camel case name from: https://stackoverflow.com/questions/29916065 """ diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index 5791159664..6a29de1cee 100755 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -18,8 +18,7 @@ import cv2 import numpy as np -from lib.faces_detect import DetectedFace -from lib.utils import rotate_landmarks +from lib.faces_detect import DetectedFace, rotate_landmarks from plugins.extract._base import Extractor, logger diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index caa16fdecf..69d51dcbac 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -9,6 +9,7 @@ import sys import time +from concurrent import futures from json import JSONDecodeError import keras @@ -24,7 +25,6 @@ generalized_loss, l_inf_norm, gmsd_loss, gaussian_blur) from lib.model.nn_blocks import NNBlocks from lib.model.optimizers import Adam -from lib.multithreading import MultiThread from lib.utils import deprecation_warning, FaceswapError from plugins.train._config import Config @@ -466,21 +466,13 @@ def save_models(self): backup_func = self.backup.backup_model if self.should_backup(save_averages) else None if backup_func: logger.info("Backing up models...") - save_threads = list() - for network in self.networks.values(): - name = "save_{}".format(network.name) - save_threads.append(MultiThread(network.save, - name=name, - backup_func=backup_func)) - save_threads.append(MultiThread(self.state.save, - name="save_state", - backup_func=backup_func)) - for thread in save_threads: - thread.start() - for thread in save_threads: - if thread.has_error: - logger.error(thread.errors[0]) - thread.join() + executor = futures.ThreadPoolExecutor() + save_threads = [executor.submit(network.save, backup_func=backup_func) + for network in self.networks.values()] + save_threads.append(executor.submit(self.state.save, backup_func=backup_func)) + futures.wait(save_threads) + # call result() to capture errors + _ = [thread.result() for thread in save_threads] msg = "[Saved models]" if save_averages: lossmsg = ["{}_{}: {:.5f}".format(self.state.loss_names[side][0], diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 65825a09b1..e197435eb3 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -33,7 +33,7 @@ from lib.alignments import Alignments from lib.faces_detect import DetectedFace -from lib.training_data import TrainingDataGenerator, stack_images +from lib.training_data import TrainingDataGenerator from lib.utils import FaceswapError, get_folder, get_image_paths from plugins.train._config import Config @@ -292,10 +292,10 @@ def get_next(self, do_preview): """ Return the next batch from the generator Items should come out as: (warped, target [, mask]) """ batch = next(self.feed) - feed = batch[1] - batch = batch[2:] # Remove full size samples and feed from batch - mask = batch[-1] - batch = [[feed, mask], batch] if self.use_mask else [feed, batch] + if self.use_mask: + batch = [[batch["feed"], batch["masks"]], batch["targets"] + [batch["masks"]]] + else: + batch = [batch["feed"], batch["targets"]] self.generate_preview(do_preview) return batch @@ -309,13 +309,10 @@ def generate_preview(self, do_preview): if self.preview_feed is None: self.set_preview_feed() batch = next(self.preview_feed) - self.samples, feed = batch[:2] - batch = batch[2:] # Remove full size samples and feed from batch - self.target = batch[self.model.largest_face_index] + self.samples = batch["samples"] + self.target = [batch["targets"][self.model.largest_face_index]] if self.use_mask: - mask = batch[-1] - batch = [[feed, mask], batch] - self.target = [self.target, mask] + self.target += [batch["masks"]] def set_preview_feed(self): """ Set the preview dictionary """ @@ -347,15 +344,11 @@ def compile_sample(self, batch_size, samples=None, images=None): def compile_timelapse_sample(self): """ Timelapse samples """ batch = next(self.timelapse_feed) - samples, feed = batch[:2] - batchsize = len(samples) - batch = batch[2:] # Remove full size samples and feed from batch - images = batch[self.model.largest_face_index] + batchsize = len(batch["samples"]) + images = [batch["targets"][self.model.largest_face_index]] if self.use_mask: - mask = batch[-1] - batch = [[feed, mask], batch] - images = [images, mask] - sample = self.compile_sample(batchsize, samples=samples, images=images) + images = images + [batch["masks"]] + sample = self.compile_sample(batchsize, samples=batch["samples"], images=images) return sample def set_timelapse_feed(self, images, batchsize): @@ -405,10 +398,10 @@ def show_sample(self): for side, samples in self.images.items(): other_side = "a" if side == "b" else "b" - predictions = [preds["{}_{}".format(side, side)], + predictions = [preds["{0}_{0}".format(side)], preds["{}_{}".format(other_side, side)]] display = self.to_full_frame(side, samples, predictions) - headers[side] = self.get_headers(side, other_side, display[0].shape[1]) + headers[side] = self.get_headers(side, display[0].shape[1]) figures[side] = np.stack([display[0], display[1], display[2], ], axis=1) if self.images[side][0].shape[0] % 2 == 1: figures[side] = np.concatenate([figures[side], @@ -547,22 +540,22 @@ def overlay_foreground(backgrounds, foregrounds): logger.debug("Overlayed foreground. Shape: %s", retval.shape) return retval - def get_headers(self, side, other_side, width): + def get_headers(self, side, width): """ Set headers for images """ - logger.debug("side: '%s', other_side: '%s', width: %s", - side, other_side, width) + logger.debug("side: '%s', width: %s", + side, width) + titles = ("Original", "Swap") if side == "a" else ("Swap", "Original") side = side.upper() - other_side = other_side.upper() height = int(64 * self.scaling) total_width = width * 3 logger.debug("height: %s, total_width: %s", height, total_width) font = cv2.FONT_HERSHEY_SIMPLEX # pylint: disable=no-member - texts = ["Target {}".format(side), - "{} > {}".format(side, side), - "{} > {}".format(side, other_side)] + texts = ["{} ({})".format(titles[0], side), + "{0} > {0}".format(titles[0]), + "{} > {}".format(titles[0], titles[1])] text_sizes = [cv2.getTextSize(texts[idx], # pylint: disable=no-member font, - self.scaling, + self.scaling * 0.8, 1)[0] for idx in range(len(texts))] text_y = int((height + text_sizes[0][1]) / 2) @@ -576,7 +569,7 @@ def get_headers(self, side, other_side, width): text, (text_x[idx], text_y), font, - self.scaling, + self.scaling * 0.8, (0, 0, 0), 1, lineType=cv2.LINE_AA) # pylint: disable=no-member @@ -703,3 +696,25 @@ def transform_landmarks(self, alignments): detected_face.load_aligned(None, size=self.size) landmarks[detected_face.hash] = detected_face.aligned_landmarks return landmarks + + +def stack_images(images): + """ Stack images """ + logger.debug("Stack images") + + def get_transpose_axes(num): + if num % 2 == 0: + logger.debug("Even number of images to stack") + y_axes = list(range(1, num - 1, 2)) + x_axes = list(range(0, num - 1, 2)) + else: + logger.debug("Odd number of images to stack") + y_axes = list(range(0, num - 1, 2)) + x_axes = list(range(1, num - 1, 2)) + return y_axes, x_axes, [num - 1] + + images_shape = np.array(images.shape) + new_axes = get_transpose_axes(len(images_shape)) + new_shape = [np.prod(images_shape[x]) for x in new_axes] + logger.debug("Stacked images") + return np.transpose(images, axes=np.concatenate(new_axes)).reshape(new_shape) diff --git a/scripts/convert.py b/scripts/convert.py index c184b1fa57..a8f4e35c6e 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -17,9 +17,10 @@ from lib.convert import Converter from lib.faces_detect import DetectedFace from lib.gpu_stats import GPUStats +from lib.image import read_image_hash from lib.multithreading import MultiThread, total_cpus from lib.queue_manager import queue_manager -from lib.utils import FaceswapError, get_folder, get_image_paths, hash_image_file +from lib.utils import FaceswapError, get_folder, get_image_paths from plugins.extract.pipeline import Extractor from plugins.plugin_loader import PluginLoader @@ -682,7 +683,7 @@ def get_face_hashes(self): file_list = [path for path in get_image_paths(input_aligned_dir)] logger.info("Getting Face Hashes for selected Aligned Images") for face in tqdm(file_list, desc="Hashing Faces"): - face_hashes.append(hash_image_file(face)) + face_hashes.append(read_image_hash(face)) logger.debug("Face Hashes: %s", (len(face_hashes))) if not face_hashes: raise FaceswapError("Aligned directory is empty, no faces will be converted!") @@ -746,5 +747,5 @@ def add_hashes(self, hashes, faces_dir): continue hash_faces = all_faces[frame] for index, face_path in hash_faces.items(): - hash_faces[index] = hash_image_file(face_path) + hash_faces[index] = read_image_hash(face_path) self.alignments.add_face_hashes(frame, hash_faces) diff --git a/scripts/extract.py b/scripts/extract.py index 948aae64a6..5306c3e686 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -8,9 +8,10 @@ from tqdm import tqdm +from lib.image import encode_image_with_hash from lib.multithreading import MultiThread from lib.queue_manager import queue_manager -from lib.utils import get_folder, hash_encode_image, deprecation_warning +from lib.utils import get_folder, deprecation_warning from plugins.extract.pipeline import Extractor from scripts.fsmedia import Alignments, Images, PostProcess, Utils @@ -255,7 +256,7 @@ def output_faces(self, filename, faces): face = detected_face["face"] resized_face = face.aligned_face - face.hash, img = hash_encode_image(resized_face, extension) + face.hash, img = encode_image_with_hash(resized_face, extension) self.save_queue.put((out_filename, img)) final_faces.append(face.to_alignment()) self.alignments.data[os.path.basename(filename)] = final_faces diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 5a763cdc1c..b9cd8c8d69 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -16,8 +16,9 @@ from lib.aligner import Extract as AlignerExtract from lib.alignments import Alignments as AlignmentsBase from lib.face_filter import FaceFilter as FilterFunc -from lib.utils import (camel_case_split, count_frames_and_secs, cv2_read_img, get_folder, - get_image_paths, set_system_verbosity, _video_extensions) +from lib.image import count_frames_and_secs, read_image +from lib.utils import (camel_case_split, get_folder, get_image_paths, set_system_verbosity, + _video_extensions) logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -183,7 +184,7 @@ def load_disk_frames(self): """ Load frames from disk """ logger.debug("Input is separate Frames. Loading images") for filename in self.input_images: - image = cv2_read_img(filename, raise_error=False) + image = read_image(filename, raise_error=False) if image is None: continue yield filename, image @@ -212,7 +213,7 @@ def load_one_image(self, filename): logger.trace("Extracted frame_no %s from filename '%s'", frame_no, filename) retval = self.load_one_video_frame(int(frame_no)) else: - retval = cv2_read_img(filename, raise_error=True) + retval = read_image(filename, raise_error=True) return retval def load_one_video_frame(self, frame_no): diff --git a/scripts/train.py b/scripts/train.py index f834d0da6f..3ec713b854 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -12,10 +12,11 @@ import tensorflow as tf from keras.backend.tensorflow_backend import set_session +from lib.image import read_image from lib.keypress import KBHit from lib.multithreading import MultiThread -from lib.queue_manager import queue_manager -from lib.utils import cv2_read_img, get_folder, get_image_paths, set_system_verbosity +from lib.queue_manager import queue_manager # noqa pylint:disable=unused-import +from lib.utils import get_folder, get_image_paths, set_system_verbosity from plugins.plugin_loader import PluginLoader logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -176,7 +177,7 @@ def load_model(self): @property def image_size(self): """ Get the training set image size for storing in model data """ - image = cv2_read_img(self.images["a"][0], raise_error=True) + image = read_image(self.images["a"][0], raise_error=True) size = image.shape[0] logger.debug("Training image size: %s", size) return size diff --git a/tools/lib_alignments/media.py b/tools/lib_alignments/media.py index 6dd867cfd3..aae4661b36 100644 --- a/tools/lib_alignments/media.py +++ b/tools/lib_alignments/media.py @@ -14,8 +14,8 @@ from lib.aligner import Extract as AlignerExtract from lib.alignments import Alignments from lib.faces_detect import DetectedFace -from lib.utils import (_image_extensions, _video_extensions, count_frames_and_secs, cv2_read_img, - hash_image_file, hash_encode_image) +from lib.image import count_frames_and_secs, encode_image_with_hash, read_image, read_image_hash +from lib.utils import _image_extensions, _video_extensions logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -175,7 +175,7 @@ def load_image(self, filename): else: src = os.path.join(self.folder, filename) logger.trace("Loading image: '%s'", src) - image = cv2_read_img(src, raise_error=True) + image = read_image(src, raise_error=True) return image def load_video_frame(self, filename): @@ -210,7 +210,7 @@ def process_folder(self): continue filename = os.path.splitext(face)[0] file_extension = os.path.splitext(face)[1] - face_hash = hash_image_file(os.path.join(self.folder, face)) + face_hash = read_image_hash(os.path.join(self.folder, face)) retval = {"face_fullname": face, "face_name": filename, "face_extension": file_extension, @@ -358,7 +358,7 @@ def get_roi_size_for_frame(self, frame): @staticmethod def save_face_with_hash(filename, extension, face): """ Save a face and return it's hash """ - f_hash, img = hash_encode_image(face, extension) + f_hash, img = encode_image_with_hash(face, extension) logger.trace("Saving face: '%s'", filename) with open(filename, "wb") as out_file: out_file.write(img) diff --git a/tools/sort.py b/tools/sort.py index b7c3f4ec1f..9baf04f1e5 100644 --- a/tools/sort.py +++ b/tools/sort.py @@ -16,8 +16,8 @@ from lib.cli import FullHelpArgumentParser from lib import Serializer from lib.faces_detect import DetectedFace +from lib.image import read_image from lib.queue_manager import queue_manager -from lib.utils import cv2_read_img from lib.vgg_face2_keras import VGGFace2 as VGGFace from plugins.plugin_loader import PluginLoader @@ -106,7 +106,7 @@ def alignment_dict(image): @staticmethod def get_landmarks(filename): """ Extract the face from a frame (If not alignments file found) """ - image = cv2_read_img(filename, raise_error=True) + image = read_image(filename, raise_error=True) feed = Sort.alignment_dict(image) feed["filename"] = filename queue_manager.get_queue("in").put(feed) @@ -161,7 +161,7 @@ def sort_face(self): logger.info("Sorting by face similarity...") images = np.array(self.find_images(input_dir)) - preds = np.array([self.vgg_face.predict(cv2_read_img(img, raise_error=True)) + preds = np.array([self.vgg_face.predict(read_image(img, raise_error=True)) for img in tqdm(images, desc="loading", file=sys.stdout)]) logger.info("Sorting. Depending on ths size of your dataset, this may take a few " "minutes...") @@ -264,7 +264,7 @@ def sort_hist(self): logger.info("Sorting by histogram similarity...") img_list = [ - [img, cv2.calcHist([cv2_read_img(img, raise_error=True)], [0], None, [256], [0, 256])] + [img, cv2.calcHist([read_image(img, raise_error=True)], [0], None, [256], [0, 256])] for img in tqdm(self.find_images(input_dir), desc="Loading", file=sys.stdout) ] @@ -294,7 +294,7 @@ def sort_hist_dissim(self): img_list = [ [img, - cv2.calcHist([cv2_read_img(img, raise_error=True)], [0], None, [256], [0, 256]), 0] + cv2.calcHist([read_image(img, raise_error=True)], [0], None, [256], [0, 256]), 0] for img in tqdm(self.find_images(input_dir), desc="Loading", file=sys.stdout) ] @@ -548,7 +548,7 @@ def reload_images(self, group_method, img_list): input_dir = self.args.input_dir logger.info("Preparing to group...") if group_method == 'group_blur': - temp_list = [[img, self.estimate_blur(cv2_read_img(img, raise_error=True))] + temp_list = [[img, self.estimate_blur(read_image(img, raise_error=True))] for img in tqdm(self.find_images(input_dir), desc="Reloading", @@ -576,7 +576,7 @@ def reload_images(self, group_method, img_list): elif group_method == 'group_hist': temp_list = [ [img, - cv2.calcHist([cv2_read_img(img, raise_error=True)], [0], None, [256], [0, 256])] + cv2.calcHist([read_image(img, raise_error=True)], [0], None, [256], [0, 256])] for img in tqdm(self.find_images(input_dir), desc="Reloading", @@ -632,7 +632,7 @@ def estimate_blur(image_file): Estimate the amount of blur an image has with the variance of the Laplacian. Normalize by pixel number to offset the effect of image size on pixel gradients & variance """ - image = cv2_read_img(image_file, raise_error=True) + image = read_image(image_file, raise_error=True) if image.ndim == 3: image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blur_map = cv2.Laplacian(image, cv2.CV_32F) From cacb2ce2ac3bcf3ac0d070413160146626d868ad Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 24 Sep 2019 15:26:54 +0000 Subject: [PATCH 059/981] Preview + crashlog bugfixes --- lib/sysinfo.py | 7 +++---- plugins/train/trainer/_base.py | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/sysinfo.py b/lib/sysinfo.py index a874d26c76..8b8af626d9 100644 --- a/lib/sysinfo.py +++ b/lib/sysinfo.py @@ -344,10 +344,9 @@ def full_info(self): retval += ("{0: <20} {1}\n".format(key + ":", sys_info[key])) retval += "\n=============== Pip Packages ===============\n" retval += self.installed_pip - if not self.is_conda: - return retval - retval += "\n\n============== Conda Packages ==============\n" - retval += self.installed_conda + if self.is_conda: + retval += "\n\n============== Conda Packages ==============\n" + retval += self.installed_conda retval += self.state_file retval += "\n\n================= Configs ==================" retval += self.configs diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index e197435eb3..05f94dd55b 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -337,7 +337,7 @@ def compile_sample(self, batch_size, samples=None, images=None): if self.use_mask: retval = [tgt[0:num_images] for tgt in images] else: - retval = [images[0:num_images]] + retval = images[0:num_images] retval = samples + retval return retval From 54d5159ad41bf155ba0bf55cd09a22dbeec74a0b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 24 Sep 2019 15:59:34 +0000 Subject: [PATCH 060/981] trainer._base - explicit list extending for preview --- plugins/train/trainer/_base.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 05f94dd55b..e4aef16d8e 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -333,12 +333,11 @@ def compile_sample(self, batch_size, samples=None, images=None): num_images = min(batch_size, num_images) if batch_size is not None else num_images logger.debug("Compiling samples: (side: '%s', samples: %s)", self.side, num_images) images = images if images is not None else self.target - samples = [samples[0:num_images]] if samples is not None else [self.samples[0:num_images]] + retval = [samples[0:num_images]] if samples is not None else [self.samples[0:num_images]] if self.use_mask: - retval = [tgt[0:num_images] for tgt in images] + retval.extend(tgt[0:num_images] for tgt in images) else: - retval = images[0:num_images] - retval = samples + retval + retval.extend(images[0:num_images]) return retval def compile_timelapse_sample(self): From 5110315df2d5594cdcf292307be99cfb62bb41b6 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 24 Sep 2019 21:58:42 +0000 Subject: [PATCH 061/981] no-flip & no-augment-color deprecation warnings --- scripts/train.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/train.py b/scripts/train.py index 3ec713b854..bdab0c6ce8 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -16,7 +16,7 @@ from lib.keypress import KBHit from lib.multithreading import MultiThread from lib.queue_manager import queue_manager # noqa pylint:disable=unused-import -from lib.utils import get_folder, get_image_paths, set_system_verbosity +from lib.utils import get_folder, get_image_paths, set_system_verbosity, deprecation_warning from plugins.plugin_loader import PluginLoader logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -90,6 +90,16 @@ def process(self): """ Call the training process object """ logger.debug("Starting Training Process") logger.info("Training data directory: %s", self.args.model_dir) + + # TODO Move these args to config and remove these deprecation warnings + if hasattr(self.args, "no_flip") and self.args.no_flip: + deprecation_warning("`-nf`, ``--no-flip``", + additional_info="This option will be available within training " + "config settings (/config/train.ini).") + if hasattr(self.args, "no_augment_color") and self.args.no_flip: + deprecation_warning("`-nac`, ``--no-augment-color``", + additional_info="This option will be available within training " + "config settings (/config/train.ini).") set_system_verbosity(self.args.loglevel) thread = self.start_thread() # queue_manager.debug_monitor(1) From e83819fcb46dbb591d5117399c3005aaa61fdef0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 24 Sep 2019 22:26:21 +0000 Subject: [PATCH 062/981] Deprecation warning update --- scripts/train.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/train.py b/scripts/train.py index bdab0c6ce8..11ee54a62c 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -92,8 +92,8 @@ def process(self): logger.info("Training data directory: %s", self.args.model_dir) # TODO Move these args to config and remove these deprecation warnings - if hasattr(self.args, "no_flip") and self.args.no_flip: - deprecation_warning("`-nf`, ``--no-flip``", + if hasattr(self.args, "warp_to_landmarks") and self.args.warp_to_landmarks: + deprecation_warning("`-wl`, ``--warp-to-landmarks``", additional_info="This option will be available within training " "config settings (/config/train.ini).") if hasattr(self.args, "no_augment_color") and self.args.no_flip: From 48bd90326d56e6835fdfb5ee4fc5e6b2a37dfd09 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 25 Sep 2019 11:03:01 +0100 Subject: [PATCH 063/981] Update ISSUE_TEMPLATE.md --- .github/ISSUE_TEMPLATE/bug_report.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index dd84ea7824..68ebacd28e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -6,6 +6,11 @@ labels: '' assignees: '' --- +*Note: For general usage questions and help, please use either our [FaceSwap Forum](https://faceswap.dev/forum) +or [FaceSwap Discord server](https://discord.gg/FC54sYg). General usage questions are liable to be closed without +response.* + +**Crash reports MUST be included when reporting bugs.** **Describe the bug** A clear and concise description of what the bug is. @@ -25,14 +30,12 @@ If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] - -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] - + - Python Version [e.g. 3.5, 3.6] + - Conda Version [e.g. 4.5.12] + - Commit ID [e.g. e83819f] + - **Additional context** Add any other context about the problem here. + +**Crash Report** +The crash report generated in the root of your Faceswap folder \ No newline at end of file From 174e6950ea6ced56efd26c8188ba645e2fa4ac36 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 25 Sep 2019 13:01:24 +0100 Subject: [PATCH 064/981] Logging format fixes --- lib/faces_detect.py | 6 +++--- lib/logger.py | 39 +++++++++++++++++++++++++----------- lib/training_data.py | 7 ++++--- plugins/train/model/_base.py | 4 ++-- scripts/train.py | 12 +++++------ 5 files changed, 42 insertions(+), 26 deletions(-) diff --git a/lib/faces_detect.py b/lib/faces_detect.py index 6525af6bfb..94707b019b 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -191,9 +191,9 @@ def load_aligned(self, image, size=256, dtype=None): padding) self.aligned["face"] = face if dtype is None else face.astype(dtype) - logger.trace("Loaded aligned face: %s", {key: val - for key, val in self.aligned.items() - if key != "face"}) + logger.trace("Loaded aligned face: %s", {k: str(v) if isinstance(v, np.ndarray) else v + for k, v in self.aligned.items() + if k != "face"}) def _padding_from_coverage(self, size, coverage_ratio): """ Return the image padding for a face from coverage_ratio set against a diff --git a/lib/logger.py b/lib/logger.py index cd01436ecf..b1bbbce92f 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -4,13 +4,13 @@ import logging from logging.handlers import RotatingFileHandler import os -import re import sys import traceback from datetime import datetime from tqdm import tqdm +from numpy import ndarray class FaceswapLogger(logging.Logger): """ Create custom logger with custom levels """ @@ -39,20 +39,35 @@ def trace(self, msg, *args, **kwargs): class FaceswapFormatter(logging.Formatter): - """ Override formatter to strip newlines and multiple spaces from logger - Messages that begin with "R|" should be handled as is - """ + """ Override formatter to strip newlines from logger arguments """ def format(self, record): - if isinstance(record.msg, str): - if record.msg.startswith("R|"): - record.msg = record.msg[2:] - record.strip_spaces = False - elif record.strip_spaces: - record.msg = re.sub(" +", - " ", - record.msg.replace("\n", "\\n").replace("\r", "\\r")) + if isinstance(record.msg, str) and ("\n" in record.msg or "\r" in record.msg): + record.msg = record.msg.replace("\n", "\\n").replace("\r", "\\r") + if any(self.reformat_check(arg) for arg in record.args): + record.args = self.reformat_args(record.args) return super().format(record) + @staticmethod + def reformat_check(arg): + """ Check if the argument should be reformatted + The argument is a string with a line break + The argument is a numpy array + """ + return ((isinstance(arg, str) and ("\n" in arg or "\r" in arg)) + or isinstance(arg, ndarray)) + + @staticmethod + def reformat_args(args): + """ Reformat args that require new lines removing """ + new_args = [] + for arg in args: + if isinstance(arg, ndarray): + # Convert numpy arrays to string for reformatting + arg = str(ndarray) + if isinstance(arg, str) and ("\n" in arg or "\r" in arg): + arg = arg.replace("\n", "\\n").replace("\r", "\\r") + new_args.append(arg) + return tuple(new_args) class RollingBuffer(collections.deque): """File-like that keeps a certain number of lines of text in memory.""" diff --git a/lib/training_data.py b/lib/training_data.py index 2c3c67a4d9..1723cf1c28 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -260,7 +260,7 @@ def _get_landmarks(self, filenames, batch, side): """ Obtains the 68 Point Landmarks for the images in this batch. This is only called if config item ``warp_to_landmarks`` is ``True`` or if :attr:`mask_type` is not ``None``. If the landmarks for an image cannot be found, then an error is raised. """ - logger.trace("Retrieving landmarks: (filenames: '%s', side: '%s'", filenames, side) + logger.trace("Retrieving landmarks: (filenames: %s, side: '%s')", filenames, side) src_points = [self._landmarks[side].get(sha1(face).hexdigest(), None) for face in batch] # Raise error on missing alignments @@ -278,7 +278,7 @@ def _get_landmarks(self, filenames, batch, side): "alignments".format(missing)) raise FaceswapError(msg) - logger.trace("Returning: (src_points: %s)", src_points) + logger.trace("Returning: (src_points: %s)", [str(src) for src in src_points]) return np.array(src_points) def _get_closest_match(self, filenames, side, batch_src_points): @@ -423,7 +423,8 @@ def initialize(self, training_size): warp_lm_edge_anchors=edge_anchors, warp_lm_grids=grids) self.initialized = True - logger.debug("Initialized constants: %s", self._constants) + logger.debug("Initialized constants: %s", {k:str(v) if isinstance(v, np.ndarray) else v + for k, v in self._constants.items()}) # <<< TARGET IMAGES >>> # def get_targets(self, batch): diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 69d51dcbac..1fade9fd3f 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -421,12 +421,12 @@ def log_summary(self): return for side in sorted(list(self.predictors.keys())): logger.verbose("[%s %s Summary]:", self.name.title(), side.upper()) - self.predictors[side].summary(print_fn=lambda x: logger.verbose("R|%s", x)) + self.predictors[side].summary(print_fn=lambda x: logger.verbose("%s", x)) for name, nnmeta in self.networks.items(): if nnmeta.side is not None and nnmeta.side != side: continue logger.verbose("%s:", name.title()) - nnmeta.network.summary(print_fn=lambda x: logger.verbose("R|%s", x)) + nnmeta.network.summary(print_fn=lambda x: logger.verbose("%s", x)) def do_snapshot(self): """ Perform a model snapshot """ diff --git a/scripts/train.py b/scripts/train.py index 11ee54a62c..48484ae08a 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -253,15 +253,15 @@ def monitor(self, thread): """ Monitor the console, and generate + monitor preview if requested """ is_preview = self.args.preview logger.debug("Launching Monitor") - logger.info("R|===================================================") - logger.info("R| Starting") + logger.info("===================================================") + logger.info(" Starting") if is_preview: - logger.info("R| Using live preview") - logger.info("R| Press '%s' to save and quit", + logger.info(" Using live preview") + logger.info(" Press '%s' to save and quit", "Terminate" if self.args.redirect_gui else "ENTER") if not self.args.redirect_gui: - logger.info("R| Press 'S' to save model weights immediately") - logger.info("R|===================================================") + logger.info(" Press 'S' to save model weights immediately") + logger.info("===================================================") keypress = KBHit(is_gui=self.args.redirect_gui) err = False From f55f8fc6a38e7ae22e324c4a1b394611e4ef85ea Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 25 Sep 2019 13:01:46 +0100 Subject: [PATCH 065/981] Logging format fixes --- lib/logger.py | 2 ++ lib/training_data.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/logger.py b/lib/logger.py index b1bbbce92f..0d4b2fa1bf 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -12,6 +12,7 @@ from numpy import ndarray + class FaceswapLogger(logging.Logger): """ Create custom logger with custom levels """ def __init__(self, name): @@ -69,6 +70,7 @@ def reformat_args(args): new_args.append(arg) return tuple(new_args) + class RollingBuffer(collections.deque): """File-like that keeps a certain number of lines of text in memory.""" def write(self, buffer): diff --git a/lib/training_data.py b/lib/training_data.py index 1723cf1c28..52886b6073 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -423,7 +423,7 @@ def initialize(self, training_size): warp_lm_edge_anchors=edge_anchors, warp_lm_grids=grids) self.initialized = True - logger.debug("Initialized constants: %s", {k:str(v) if isinstance(v, np.ndarray) else v + logger.debug("Initialized constants: %s", {k: str(v) if isinstance(v, np.ndarray) else v for k, v in self._constants.items()}) # <<< TARGET IMAGES >>> # From 0654ac8effedb83d1d522005cb2dd5ec776761b3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 25 Sep 2019 18:05:07 +0100 Subject: [PATCH 066/981] GUI: Make readonly console less buggy --- lib/gui/_redirector.py | 154 +++++++++++++++++++++++++++++++++++++++++ lib/gui/utils.py | 21 ++++-- 2 files changed, 169 insertions(+), 6 deletions(-) create mode 100644 lib/gui/_redirector.py diff --git a/lib/gui/_redirector.py b/lib/gui/_redirector.py new file mode 100644 index 0000000000..df332911a7 --- /dev/null +++ b/lib/gui/_redirector.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +""" Widget redirector from IdleLib +https://github.com/python/cpython/blob/master/Lib/idlelib/redirector.py +""" + +from tkinter import TclError + + +class WidgetRedirector: + """Support for redirecting arbitrary widget subcommands. + + Some Tk operations don't normally pass through tkinter. For example, if a + character is inserted into a Text widget by pressing a key, a default Tk + binding to the widget's 'insert' operation is activated, and the Tk library + processes the insert without calling back into tkinter. + + Although a binding to could be made via tkinter, what we really want + to do is to hook the Tk 'insert' operation itself. For one thing, we want + a text.insert call in idle code to have the same effect as a key press. + + When a widget is instantiated, a Tcl command is created whose name is the + same as the pathname widget._w. This command is used to invoke the various + widget operations, e.g. insert (for a Text widget). We are going to hook + this command and provide a facility ('register') to intercept the widget + operation. We will also intercept method calls on the tkinter class + instance that represents the tk widget. + + In IDLE, WidgetRedirector is used in Percolator to intercept Text + commands. The function being registered provides access to the top + of a Percolator chain. At the bottom of the chain is a call to the + original Tk widget operation. + """ + def __init__(self, widget): + """Initialize attributes and setup redirection. + + _operations: dict mapping operation name to new function. + widget: the widget whose tcl command is to be intercepted. + tk: widget.tk, a convenience attribute, probably not needed. + orig: new name of the original tcl command. + + Since renaming to orig fails with TclError when orig already + exists, only one WidgetDirector can exist for a given widget. + """ + self._operations = {} + self.widget = widget # widget instance + self.tk_ = tk_ = widget.tk # widget's root + wgt = widget._w # pylint:disable=protected-access # widget's (full) Tk pathname + self.orig = wgt + "_orig" + # Rename the Tcl command within Tcl: + tk_.call("rename", wgt, self.orig) + # Create a new Tcl command whose name is the widget's pathname, and + # whose action is to dispatch on the operation passed to the widget: + tk_.createcommand(wgt, self.dispatch) + + def __repr__(self): + return "%s(%s<%s>)" % (self.__class__.__name__, + self.widget.__class__.__name__, + self.widget._w) # pylint:disable=protected-access + + def close(self): + "Unregister operations and revert redirection created by .__init__." + for operation in list(self._operations): + self.unregister(operation) + widget = self.widget + tk_ = widget.tk + wgt = widget._w # pylint:disable=protected-access + # Restore the original widget Tcl command. + tk_.deletecommand(wgt) + tk_.call("rename", self.orig, wgt) + del self.widget, self.tk_ # Should not be needed + # if instance is deleted after close, as in Percolator. + + def register(self, operation, function): + """Return OriginalCommand(operation) after registering function. + + Registration adds an operation: function pair to ._operations. + It also adds a widget function attribute that masks the tkinter + class instance method. Method masking operates independently + from command dispatch. + + If a second function is registered for the same operation, the + first function is replaced in both places. + """ + self._operations[operation] = function + setattr(self.widget, operation, function) + return OriginalCommand(self, operation) + + def unregister(self, operation): + """Return the function for the operation, or None. + + Deleting the instance attribute unmasks the class attribute. + """ + if operation in self._operations: + function = self._operations[operation] + del self._operations[operation] + try: + delattr(self.widget, operation) + except AttributeError: + pass + return function + return None + + def dispatch(self, operation, *args): + """Callback from Tcl which runs when the widget is referenced. + + If an operation has been registered in self._operations, apply the + associated function to the args passed into Tcl. Otherwise, pass the + operation through to Tk via the original Tcl function. + + Note that if a registered function is called, the operation is not + passed through to Tk. Apply the function returned by self.register() + to *args to accomplish that. For an example, see colorizer.py. + + """ + op_ = self._operations.get(operation) + try: + if op_: + return op_(*args) + return self.tk_.call((self.orig, operation) + args) + except TclError: + return "" + + +class OriginalCommand: + """Callable for original tk command that has been redirected. + + Returned by .register; can be used in the function registered. + redir = WidgetRedirector(text) + def my_insert(*args): + print("insert", args) + original_insert(*args) + original_insert = redir.register("insert", my_insert) + """ + + def __init__(self, redir, operation): + """Create .tk_call and .orig_and_operation for .__call__ method. + + .redir and .operation store the input args for __repr__. + .tk and .orig copy attributes of .redir (probably not needed). + """ + self.redir = redir + self.operation = operation + self.tk_ = redir.tk_ # redundant with self.redir + self.orig = redir.orig # redundant with self.redir + # These two could be deleted after checking recipient code. + self.tk_call = redir.tk_.call + self.orig_and_operation = (redir.orig, operation) + + def __repr__(self): + return "%s(%r, %r)" % (self.__class__.__name__, + self.redir, self.operation) + + def __call__(self, *args): + return self.tk_call(self.orig_and_operation + args) diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 37b020dec2..55658f8a3e 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -16,6 +16,7 @@ from lib.Serializer import JSONSerializer from ._config import Config as UserConfig +from ._redirector import WidgetRedirector logger = logging.getLogger(__name__) # pylint: disable=invalid-name _CONFIG = None @@ -511,6 +512,18 @@ def resize_image(self, name, framesize): self.previewtrain[name][1] = ImageTk.PhotoImage(displayimg) +class ReadOnlyText(tk.Text): # pylint: disable=too-many-ancestors + """ A read only text widget that redirects a standard tk.Text widget's insert and delete + attributes. + Source: https://stackoverflow.com/questions/3842155 + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.redirector = WidgetRedirector(self) + self.insert = self.redirector.register("insert", lambda *args, **kw: "break") + self.delete = self.redirector.register("delete", lambda *args, **kw: "break") + + class ConsoleOut(ttk.Frame): # pylint: disable=too-many-ancestors """ The Console out section of the GUI """ @@ -520,7 +533,7 @@ def __init__(self, parent, debug): ttk.Frame.__init__(self, parent) self.pack(side=tk.TOP, anchor=tk.W, padx=10, pady=(2, 0), fill=tk.BOTH, expand=True) - self.console = tk.Text(self) + self.console = ReadOnlyText(self) rc_menu = ContextMenu(self.console) rc_menu.cm_bind() self.console_clear = get_config().tk_vars['consoleclear'] @@ -539,7 +552,7 @@ def set_console_clear_var_trace(self): def build_console(self): """ Build and place the console """ logger.debug("Build console") - self.console.config(width=100, height=6, bg="gray90", fg="black", state="disabled") + self.console.config(width=100, height=6, bg="gray90", fg="black") self.console.pack(side=tk.LEFT, anchor=tk.N, fill=tk.BOTH, expand=True) scrollbar = ttk.Scrollbar(self, command=self.console.yview) @@ -576,9 +589,7 @@ def clear(self, *args): # pylint: disable=unused-argument if not self.console_clear.get(): logger.debug("Console not set for clearing. Skipping") return - self.console.configure(state="normal") self.console.delete(1.0, tk.END) - self.console.configure(state="disabled") self.console_clear.set(False) logger.debug("Cleared console") @@ -608,10 +619,8 @@ def get_tag(self, string): def write(self, string): """ Capture stdout/stderr """ - self.console.configure(state="normal") self.console.insert(tk.END, string, self.get_tag(string)) self.console.see(tk.END) - self.console.configure(state="disabled") @staticmethod def flush(): From c2e54bb18a9852ce0406e3b845554f51efb30558 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 25 Sep 2019 19:31:32 +0100 Subject: [PATCH 067/981] Fix graph pop up --- lib/gui/display_analysis.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/lib/gui/display_analysis.py b/lib/gui/display_analysis.py index 8f01e8c07e..4e3c26941a 100644 --- a/lib/gui/display_analysis.py +++ b/lib/gui/display_analysis.py @@ -7,7 +7,7 @@ import tkinter as tk from tkinter import ttk -from .control_helper import ControlBuilder +from .control_helper import ControlBuilder, ControlPanelOption from .display_graph import SessionGraph from .display_page import DisplayPage from .stats import Calculations, Session @@ -609,16 +609,14 @@ def opts_slider(self, frame): default = 0.90 rounding = 2 min_max = (0, 0.99) - - ctl = ControlBuilder(frame, - text, - dtype, - default, - label_width=19, - rounding=rounding, - min_max=min_max, - helptext=self.set_help(item)) - self.vars[item] = ctl.tk_var + slider = ControlPanelOption(text, + dtype, + default=default, + rounding=rounding, + min_max=min_max, + helptext=self.set_help(item)) + self.vars[item] = slider.tk_var + ControlBuilder(frame, slider, 1, 19, None, True) logger.debug("Built Sliders") def opts_buttons(self, frame): From 46efae3506ffd31900a70887b75b5a795f02d52d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 26 Sep 2019 18:22:22 +0100 Subject: [PATCH 068/981] Alignments tool: Remove extract-large and add `large` option --- tools/alignments.py | 4 +--- tools/cli.py | 34 ++++++++++++++++++---------------- tools/lib_alignments/jobs.py | 9 ++++----- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/tools/alignments.py b/tools/alignments.py index 96ec6d7411..27cee52136 100644 --- a/tools/alignments.py +++ b/tools/alignments.py @@ -47,9 +47,7 @@ def get_dest_format(self): def process(self): """ Main processing function of the Align tool """ - if self.args.job.startswith("extract"): - job = Extract - elif self.args.job == "update-hashes": + if self.args.job == "update-hashes": job = UpdateHashes elif self.args.job.startswith("remove-"): job = RemoveAlignments diff --git a/tools/cli.py b/tools/cli.py index 3ff1fdc8b5..3480698337 100644 --- a/tools/cli.py +++ b/tools/cli.py @@ -31,10 +31,10 @@ def get_argument_list(self): "opts": ("-j", "--job"), "action": Radio, "type": str, - "choices": ("draw", "extract", "extract-large", "manual", "merge", - "missing-alignments", "missing-frames", "legacy", "leftover-faces", - "multi-faces", "no-faces", "reformat", "remove-faces", "remove-frames", - "rename", "sort-x", "sort-y", "spatial", "update-hashes"), + "choices": ("draw", "extract", "manual", "merge", "missing-alignments", + "missing-frames", "legacy", "leftover-faces", "multi-faces", "no-faces", + "reformat", "remove-faces", "remove-frames", "rename", "sort-x", "sort-y", + "spatial", "update-hashes"), "required": True, "help": "R|Choose which action you want to perform. " "NB: All actions require an alignments file (-a) to be passed in." @@ -45,10 +45,6 @@ def get_argument_list(self): "alignment data. This is a lot quicker than re-detecting faces. Can pass in " "the '-een' (--extract-every-n) parameter to only extract every nth frame." + frames_and_faces_dir + align_eyes + - "\nL|'extract-large': - Extract all faces that have not been upscaled. Useful " - "for excluding low-res images from a training set.. Can pass in the '-een' " - "(--extract-every-n) parameter to only extract every nth frame." + - frames_and_faces_dir + align_eyes + "\nL|'manual': Manually view and edit landmarks." + frames_dir + "\nL|'merge': Merge multiple alignment files into one. Specify a space " "separated list of alignments files with the -a flag. Optionally specify a " @@ -140,10 +136,10 @@ def get_argument_list(self): "default": 1, "rounding": 1, "group": "extract", - "help": "Extract every 'nth' frame. This option will skip frames " - "when extracting faces. For example a value of 1 will " - "extract faces from every frame, a value of 10 will extract " - "faces from every 10th frame. (extract only)"}) + "help": "[Extract only] Extract every 'nth' frame. This option will " + "skip frames when extracting faces. For example a value of " + "1 will extract faces from every frame, a value of 10 will " + "extract faces from every 10th frame."}) argument_list.append({"opts": ("-sz", "--size"), "type": int, "action": Slider, @@ -151,15 +147,21 @@ def get_argument_list(self): "default": 256, "group": "extract", "rounding": 64, - "help": "The output size of extracted faces. (extract only)"}) + "help": "[Extract only] The output size of extracted faces."}) argument_list.append({"opts": ("-ae", "--align-eyes"), "action": "store_true", "dest": "align_eyes", "group": "extract", "default": False, - "help": "Perform extra alignment to ensure " - "left/right eyes are at the same " - "height. (Extract only)"}) + "help": "[Extract only] Perform extra alignment to ensure " + "left/right eyes are at the same height."}) + argument_list.append({"opts": ("-l", "--large"), + "action": "store_true", + "group": "extract", + "default": False, + "help": "[Extract only] Only extract faces that have not been " + "upscaled to the required size (`-sz`, `--size). Useful " + "for excluding low-res images from a training set."}) argument_list.append({"opts": ("-dm", "--disable-monitor"), "action": "store_true", "group": "manual tool", diff --git a/tools/lib_alignments/jobs.py b/tools/lib_alignments/jobs.py index 80178e94a9..c677ec3cf6 100644 --- a/tools/lib_alignments/jobs.py +++ b/tools/lib_alignments/jobs.py @@ -323,9 +323,8 @@ class Extract(): Alignment data """ def __init__(self, alignments, arguments): logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - self.alignments = alignments self.arguments = arguments - self.type = arguments.job.replace("extract-", "") + self.alignments = alignments self.faces_dir = arguments.faces_dir self.frames = Frames(arguments.frames_dir) self.extracted_faces = ExtractedFaces(self.frames, @@ -375,7 +374,7 @@ def export_faces(self): extracted_faces += self.output_faces(frame) - if extracted_faces != 0 and self.type != "large": + if extracted_faces != 0 and not self.arguments.large: self.alignments.save() logger.info("%s face(s) extracted", extracted_faces) @@ -390,7 +389,7 @@ def output_faces(self, frame): for idx, face in enumerate(faces): output = "{}_{}{}".format(frame_name, str(idx), extension) - if self.type == "large": + if self.arguments.large: self.frames.save_image(self.faces_dir, output, face.aligned_face) else: output = os.path.join(self.faces_dir, output) @@ -404,7 +403,7 @@ def output_faces(self, frame): def select_valid_faces(self, frame): """ Return valid faces for extraction """ faces = self.extracted_faces.get_faces_in_frame(frame) - if self.type != "large": + if not self.arguments.large: valid_faces = faces else: sizes = self.extracted_faces.get_roi_size_for_frame(frame) From 84932c0a87c09e609bebe3e02175df631dcc5efe Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 27 Sep 2019 16:32:02 +0100 Subject: [PATCH 069/981] Bugfix: Don't overide kernel_initializer if it is explicitly set in model definition --- lib/model/nn_blocks.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index 57db4dda4a..9c0567ffb3 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -53,6 +53,9 @@ def set_default_initializer(self, kwargs): to conv_aware or he_uniform(). if a specific initializer has been passed in then the specified initializer will be used rather than the default """ + if "kernel_initializer" in kwargs: + logger.debug("Using model specified initializer: %s", kwargs["kernel_initializer"]) + return kwargs if self.use_convaware_init: default = ConvolutionAware() if self.first_run: From 7bdaa81cbef16d3626b3044bfe5c279120a9c0a0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 29 Sep 2019 00:14:15 +0100 Subject: [PATCH 070/981] Optimize CAI --- lib/model/initializers.py | 40 +++++++++++++++------------------------ 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/lib/model/initializers.py b/lib/model/initializers.py index 8536a3bd0b..7aef85a554 100644 --- a/lib/model/initializers.py +++ b/lib/model/initializers.py @@ -92,7 +92,7 @@ class ConvolutionAware(initializers.Initializer): seed: A Python integer. Used to seed the random generator. # References Armen Aghajanyan, https://arxiv.org/abs/1702.06295 - # Adapted and fixed from: + # Adapted, fixed and optimized from: https://github.com/keras-team/keras-contrib/blob/master/keras_contrib/initializers/convaware.py """ @@ -153,40 +153,30 @@ def __call__(self, shape, dtype=None): return K.variable(self.orthogonal(shape), dtype=dtype) kernel_fourier_shape = correct_fft(np.zeros(kernel_shape)).shape - init = [] - for _ in range(filters_size): - basis = self._create_basis( - stack_size, np.prod(kernel_fourier_shape), dtype) - basis = basis.reshape((stack_size,) + kernel_fourier_shape) - filters = [correct_ifft(x, kernel_shape) + - np.random.normal(0, self.eps_std, kernel_shape) for - x in basis] - - init.append(filters) - - # Format of array is now: filters, stack, row, column - init = np.array(init) + basis = self._create_basis(filters_size, stack_size, np.prod(kernel_fourier_shape), dtype) + basis = basis.reshape((filters_size, stack_size,) + kernel_fourier_shape) + randoms = np.random.normal(0, self.eps_std, basis.shape[:-2] + kernel_shape) + init = correct_ifft(basis, kernel_shape) + randoms init = self._scale_filters(init, variance) return K.variable(init.transpose(transpose_dimensions), dtype=dtype, name="conv_aware") - def _create_basis(self, filters, size, dtype): + def _create_basis(self, filters_size, filters, size, dtype): if size == 1: - return np.random.normal(0.0, self.eps_std, (filters, size)) - + return np.random.normal(0.0, self.eps_std, (filters_size, filters, size)) nbb = filters // size + 1 - lst = [] - for _ in range(nbb): - var_a = np.random.normal(0.0, 1.0, (size, size)) - var_a = self._symmetrize(var_a) - var_u, _, _ = np.linalg.svd(var_a) - lst.extend(var_u.T.tolist()) - var_p = np.array(lst[:filters], dtype=dtype) + var_a = np.random.normal(0.0, 1.0, (filters_size, nbb, size, size)) + var_a = self._symmetrize(var_a) + var_u = np.linalg.svd(var_a)[0].transpose(0, 1, 3, 2) + var_p = np.reshape(var_u, (filters_size, nbb * size, size))[:, :filters, :].astype(dtype) return var_p @staticmethod def _symmetrize(var_a): - return var_a + var_a.T - np.diag(var_a.diagonal()) + var_b = np.transpose(var_a, axes=(0, 1, 3, 2)) + diag = var_a.diagonal(axis1=2, axis2=3) + var_c = np.array([[np.diag(arr) for arr in batch] for batch in diag]) + return var_a + var_b - var_c @staticmethod def _scale_filters(filters, variance): From bf2510dfc4be6bc9839a0c9db42914e9c427a25c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 29 Sep 2019 00:33:28 +0100 Subject: [PATCH 071/981] Force imageio to use ffmpeg --- plugins/convert/writer/ffmpeg.py | 2 +- scripts/fsmedia.py | 4 ++-- tools/effmpeg.py | 4 ++-- tools/lib_alignments/media.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/convert/writer/ffmpeg.py b/plugins/convert/writer/ffmpeg.py index 672ad991a1..bb0f4ba290 100644 --- a/plugins/convert/writer/ffmpeg.py +++ b/plugins/convert/writer/ffmpeg.py @@ -51,7 +51,7 @@ def valid_tune(self): @property def video_fps(self): """ Return the fps of source video """ - reader = imageio.get_reader(self.source_video) + reader = imageio.get_reader(self.source_video, "ffmpeg") retval = reader.get_meta_data()["fps"] reader.close() logger.debug(retval) diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index b9cd8c8d69..bc866caa0f 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -193,7 +193,7 @@ def load_video_frames(self): """ Return frames from a video file """ logger.debug("Input is video. Capturing frames") vidname = os.path.splitext(os.path.basename(self.args.input_dir))[0] - reader = imageio.get_reader(self.args.input_dir) + reader = imageio.get_reader(self.args.input_dir, "ffmpeg") for i, frame in enumerate(reader): # Convert to BGR for cv2 compatibility frame = frame[:, :, ::-1] @@ -219,7 +219,7 @@ def load_one_image(self, filename): def load_one_video_frame(self, frame_no): """ Load a single frame from a video file """ logger.trace("Loading video frame: %s", frame_no) - reader = imageio.get_reader(self.args.input_dir) + reader = imageio.get_reader(self.args.input_dir, "ffmpeg") reader.set_image_index(frame_no - 1) frame = reader.get_next_data()[:, :, ::-1] reader.close() diff --git a/tools/effmpeg.py b/tools/effmpeg.py index bae1d78c72..2adeb6cab7 100644 --- a/tools/effmpeg.py +++ b/tools/effmpeg.py @@ -360,7 +360,7 @@ def get_fps(input_=None, print_=False, **kwargs): logger.debug("input_: %s, print_: %s, kwargs: %s", input_, print_, kwargs) input_ = input_ if isinstance(input_, str) else input_.path logger.debug("input: %s", input_) - reader = imageio.get_reader(input_) + reader = imageio.get_reader(input_, "ffmpeg") _fps = reader.get_meta_data()["fps"] logger.debug(_fps) reader.close() @@ -374,7 +374,7 @@ def get_info(input_=None, print_=False, **kwargs): logger.debug("input_: %s, print_: %s, kwargs: %s", input_, print_, kwargs) input_ = input_ if isinstance(input_, str) else input_.path logger.debug("input: %s", input_) - reader = imageio.get_reader(input_) + reader = imageio.get_reader(input_, "ffmpeg") out = reader.get_meta_data() logger.debug(out) reader.close() diff --git a/tools/lib_alignments/media.py b/tools/lib_alignments/media.py index aae4661b36..bfd14ff08f 100644 --- a/tools/lib_alignments/media.py +++ b/tools/lib_alignments/media.py @@ -139,7 +139,7 @@ def check_input_folder(self): logger.verbose("Video exists at: '%s'", self.folder) retval = cv2.VideoCapture(self.folder) # pylint: disable=no-member # TODO ImageIO single frame seek seems slow. Look into this - # retval = imageio.get_reader(self.folder) + # retval = imageio.get_reader(self.folder, "ffmpeg") else: logger.verbose("Folder exists at '%s'", self.folder) retval = None From 66e19b8693e67a5851b91ab163e538cfa785e550 Mon Sep 17 00:00:00 2001 From: Chiara Gambone <31727137+ChiaraGambone@users.noreply.github.com> Date: Sun, 29 Sep 2019 01:36:59 +0200 Subject: [PATCH 072/981] Update INSTALL.md (#886) --- INSTALL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index d926d68729..e0112b3522 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -138,7 +138,7 @@ A desktop shortcut can be added to easily launch straight into the faceswap GUI: ## Updating faceswap It's good to keep faceswap up to date as new features are added and bugs are fixed. To do so: -- If using the GUI you can go to the Tools Menu and select "Check for Updates...". This will update faceswap to the latest code and update your dependencies. +- If using the GUI you can go to the Help menu and select "Check for Updates...". If updates are available go to the Help menu and select "Update Faceswap". Restart Faceswap to complete the update. - If you are not already in your virtual environment follow [these steps](#entering-your-virtual-environment) - Enter the faceswap folder: `cd faceswap` - Enter the following `git pull --all` @@ -280,4 +280,4 @@ Proceed to [../blob/master/USAGE.md](USAGE.md) ## Notes This guide is far from complete. Functionality may change over time, and new dependencies are added and removed as time goes on. -If you are experiencing issues, please raise them in the [faceswap Forum](https://faceswap.dev/forum) instead of the main repo. Usage questions raised in the issues within this repo are liable to be closed without response. \ No newline at end of file +If you are experiencing issues, please raise them in the [faceswap Forum](https://faceswap.dev/forum) instead of the main repo. Usage questions raised in the issues within this repo are liable to be closed without response. From 29a75b90d4e7e9b0aacdc55c7b7d063d629ac889 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 29 Sep 2019 12:06:04 +0100 Subject: [PATCH 073/981] Extract: Expose "allow_growth" option --- lib/model/session.py | 21 ++++++++++++++------- plugins/extract/_config.py | 17 ++++++++++++++++- plugins/extract/align/fan.py | 5 ++++- plugins/extract/detect/mtcnn.py | 27 ++++++++++++++------------- plugins/extract/detect/s3fd.py | 10 +++++----- 5 files changed, 53 insertions(+), 27 deletions(-) diff --git a/lib/model/session.py b/lib/model/session.py index 3db05a02d6..a9a825a7f3 100644 --- a/lib/model/session.py +++ b/lib/model/session.py @@ -27,14 +27,19 @@ class KSession(): The name of the model that is to be loaded model_path: str The path to the keras model file - model_kwargs: dict - Any kwargs that need to be passed to :func:`keras.models.load_models()` + model_kwargs: dict, optional + Any kwargs that need to be passed to :func:`keras.models.load_models()`. Default: None + allow_growth: bool, optional + Enable the Tensorflow GPU allow_growth configuration option. This option prevents " + Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation and " + slower performance. Default: False """ - def __init__(self, name, model_path, model_kwargs=None): - logger.trace("Initializing: %s (name: %s, model_path: %s, model_kwargs: %s)", - self.__class__.__name__, name, model_path, model_kwargs) + def __init__(self, name, model_path, model_kwargs=None, allow_growth=False): + logger.trace("Initializing: %s (name: %s, model_path: %s, model_kwargs: %s, " + "allow_growth: %s)", + self.__class__.__name__, name, model_path, model_kwargs, allow_growth) self._name = name - self._session = self._set_session() + self._session = self._set_session(allow_growth) self._model_path = model_path self._model_kwargs = model_kwargs self._model = None @@ -92,7 +97,7 @@ def _amd_predict_with_optimized_batchsizes(self, feed, batch_size): return np.concatenate(results) return [np.concatenate(x) for x in zip(*results)] - def _set_session(self): + def _set_session(self, allow_growth): """ Sets the session and graph. If the backend is AMD then this does nothing and the global ``Keras`` ``Session`` @@ -103,6 +108,8 @@ def _set_session(self): self.graph = tf.Graph() config = tf.ConfigProto() + if allow_growth and get_backend() == "nvidia": + config.gpu_options.allow_growth = True # pylint:disable=no-member session = tf.Session(graph=tf.Graph(), config=config) logger.debug("Creating tf.session: (graph: %s, session: %s, config: %s)", session.graph, session, config) diff --git a/plugins/extract/_config.py b/plugins/extract/_config.py index 2cb5e3fbdd..b9768fb03f 100644 --- a/plugins/extract/_config.py +++ b/plugins/extract/_config.py @@ -13,11 +13,12 @@ class Config(FaceswapConfig): - """ Config File for Models """ + """ Config File for Extraction """ def set_defaults(self): """ Set the default values for config """ logger.debug("Setting defaults") + self.set_globals() current_dir = os.path.dirname(__file__) for dirpath, _, filenames in os.walk(current_dir): default_files = [fname for fname in filenames if fname.endswith("_defaults.py")] @@ -41,3 +42,17 @@ def load_module(self, filename, module_path, plugin_type): for key, val in mod._DEFAULTS.items(): # pylint:disable=protected-access self.add_item(section=section, title=key, **val) logger.debug("Added defaults: %s", section) + + def set_globals(self): + """ + Set the global options for extract + """ + logger.debug("Setting global config") + section = "global" + self.add_section(title=section, info="Options that apply to all extraction plugins") + self.add_item( + section=section, title="allow_growth", datatype=bool, default=False, + info="[Nvidia Only]. Enable the Tensorflow GPU `allow_growth` configuration option. " + "This option prevents Tensorflow from allocating all of the GPU VRAM at launch " + "but can lead to higher VRAM fragmentation and slower performance. Should only " + "be enabled if you are having problems running extraction.") diff --git a/plugins/extract/align/fan.py b/plugins/extract/align/fan.py index 0c0306efc9..71e5606279 100644 --- a/plugins/extract/align/fan.py +++ b/plugins/extract/align/fan.py @@ -30,7 +30,10 @@ def __init__(self, **kwargs): def init_model(self): """ Initialize FAN model """ model_kwargs = dict(custom_objects={'TorchBatchNorm2D': TorchBatchNorm2D}) - self.model = KSession(self.name, self.model_path, model_kwargs=model_kwargs) + self.model = KSession(self.name, + self.model_path, + model_kwargs=model_kwargs, + allow_growth=self.config["allow_growth"]) self.model.load_model() # Feed a placeholder so Aligner is primed for Manual tool placeholder = np.zeros((self.batchsize, 3, self.input_size, self.input_size), diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py index d1901f71a2..4ba013b16e 100755 --- a/plugins/extract/detect/mtcnn.py +++ b/plugins/extract/detect/mtcnn.py @@ -53,7 +53,7 @@ def validate_kwargs(self): def init_model(self): """ Initialize S3FD Model""" - self.model = MTCNN(self.model_path, **self.kwargs) + self.model = MTCNN(self.model_path, self.config["allow_growth"], **self.kwargs) def process_input(self, batch): """ Compile the detection image(s) for prediction """ @@ -105,8 +105,8 @@ def process_output(self, batch): class PNet(KSession): """ Keras PNet model for MTCNN """ - def __init__(self, model_path): - super().__init__("MTCNN-PNet", model_path) + def __init__(self, model_path, allow_growth): + super().__init__("MTCNN-PNet", model_path, allow_growth=allow_growth) self.define_model(self.model_definition) self.load_model_weights() @@ -128,8 +128,8 @@ def model_definition(): class RNet(KSession): """ Keras RNet model for MTCNN """ - def __init__(self, model_path): - super().__init__("MTCNN-RNet", model_path) + def __init__(self, model_path, allow_growth): + super().__init__("MTCNN-RNet", model_path, allow_growth=allow_growth) self.define_model(self.model_definition) self.load_model_weights() @@ -158,8 +158,8 @@ def model_definition(): class ONet(KSession): """ Keras ONet model for MTCNN """ - def __init__(self, model_path): - super().__init__("MTCNN-ONet", model_path) + def __init__(self, model_path, allow_growth): + super().__init__("MTCNN-ONet", model_path, allow_growth=allow_growth) self.define_model(self.model_definition) self.load_model_weights() @@ -193,7 +193,7 @@ class MTCNN(): """ MTCNN Detector for face alignment """ # TODO Batching for rnet and onet - def __init__(self, model_path, minsize, threshold, factor): + def __init__(self, model_path, allow_growth, minsize, threshold, factor): """ minsize: minimum faces' size threshold: threshold=[th1, th2, th3], th1-3 are three steps's threshold @@ -201,15 +201,16 @@ def __init__(self, model_path, minsize, threshold, factor): detect in the image. pnet, rnet, onet: caffemodel """ - logger.debug("Initializing: %s: (model_path: '%s')", - self.__class__.__name__, model_path) + logger.debug("Initializing: %s: (model_path: '%s', allow_growth: %s, minsize: %s, " + "threshold: %s, factor: %s)", self.__class__.__name__, model_path, + allow_growth, minsize, threshold, factor) self.minsize = minsize self.threshold = threshold self.factor = factor - self.pnet = PNet(model_path[0]) - self.rnet = RNet(model_path[1]) - self.onet = ONet(model_path[2]) + self.pnet = PNet(model_path[0], allow_growth) + self.rnet = RNet(model_path[1], allow_growth) + self.onet = ONet(model_path[2], allow_growth) self._pnet_scales = None logger.debug("Initialized: %s", self.__class__.__name__) diff --git a/plugins/extract/detect/s3fd.py b/plugins/extract/detect/s3fd.py index 238a11a3a3..469db1715a 100644 --- a/plugins/extract/detect/s3fd.py +++ b/plugins/extract/detect/s3fd.py @@ -38,7 +38,7 @@ def init_model(self): O2K_Pow=O2K_Pow, O2K_ConstantLayer=O2K_ConstantLayer, O2K_Div=O2K_Div)) - self.model = S3fd(self.model_path, model_kwargs, confidence) + self.model = S3fd(self.model_path, model_kwargs, self.config["allow_growth"], confidence) def process_input(self, batch): """ Compile the detection image(s) for prediction """ @@ -214,10 +214,10 @@ def call(self, x, *args): class S3fd(KSession): """ Keras Network """ - def __init__(self, model_path, model_kwargs, confidence): - logger.debug("Initializing: %s: (model_path: '%s')", - self.__class__.__name__, model_path) - super().__init__("S3FD", model_path, model_kwargs) + def __init__(self, model_path, model_kwargs, allow_growth, confidence): + logger.debug("Initializing: %s: (model_path: '%s', allow_growth: %s)", + self.__class__.__name__, model_path, allow_growth) + super().__init__("S3FD", model_path, model_kwargs=model_kwargs, allow_growth=allow_growth) self.load_model() self.confidence = confidence logger.debug("Initialized: %s", self.__class__.__name__) From 861f78fff108618e6947d73683fae30f59234006 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 29 Sep 2019 17:18:50 +0100 Subject: [PATCH 074/981] Capture cuDNN error in extract and raise a useful message --- plugins/extract/_base.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index f4ed5a609d..5382bd365d 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -9,9 +9,11 @@ import cv2 import numpy as np +from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module + from lib.multithreading import MultiThread from lib.queue_manager import queue_manager -from lib.utils import GetModel +from lib.utils import GetModel, FaceswapError from ._config import Config logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -386,7 +388,18 @@ def _thread_process(self, function, in_queue, out_queue): batch = self._get_item(in_queue) if batch == "EOF": break - batch = function(batch) + try: + batch = function(batch) + except tf_errors.UnknownError as err: + msg = ("Tensorflow raised an unknown error. This is most likely caused by a " + "failure to launch cuDNN which can occur for some GPU/Tensorflow " + "combinations. You should enable `allow_growth` to attempt to resolve this " + "issue:" + "\nGUI: Go to Settings > Extract Plugins > Global and enable the " + "`allow_growth` option." + "\nCLI: Go to `faceswap/config/extract.ini` and change the `allow_growth " + "option to `True`.") + raise FaceswapError(msg) from err if func_name == "process_output": # Process output items to individual items from batch for item in self.finalize(batch): From 6ebc0bf2b48d7de7cc2690f4b1cc2d2dbdaa71e4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 30 Sep 2019 22:43:50 +0100 Subject: [PATCH 075/981] bugfig: GUI Stats - divide by zero error in rate totals --- lib/gui/stats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/gui/stats.py b/lib/gui/stats.py index f8b422ebaf..de3a7cc945 100644 --- a/lib/gui/stats.py +++ b/lib/gui/stats.py @@ -325,7 +325,7 @@ def total_stats(sessions_stats): "start": starttime, "end": endtime, "elapsed": elapsed, - "rate": examples / elapsed, + "rate": examples / elapsed if elapsed != 0 else 0, "batch": batch, "iterations": iterations} logger.debug(totals) From 6085e711b2b1472f1b96a16078c57d152fea6b64 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 2 Oct 2019 16:31:02 +0100 Subject: [PATCH 076/981] Fix occassional GUI resizing bug --- lib/gui/control_helper.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 1239651a1a..2f58f8dd03 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -8,6 +8,9 @@ from itertools import zip_longest from functools import partial + +from _tkinter import Tcl_Obj + from .tooltip import Tooltip from .utils import ContextMenu, FileHandler, get_config, get_images @@ -524,6 +527,7 @@ def config_cleaner(widget): val = widget.cget(key) if key in ("anchor", "justify") and val == "": continue + val = str(val) if isinstance(val, Tcl_Obj) else val # Return correct command from master command dict val = _RECREATE_OBJECTS["commands"][val] if key == "command" and val != "" else val new_config[key] = val @@ -599,8 +603,8 @@ class ControlBuilder(): blank_nones: bool Sets selected values to an empty string rather than None if this is true. """ - def __init__(self, parent, option, option_columns, label_width, # pylint: disable=too-many-arguments - checkbuttons_frame, blank_nones): + def __init__(self, parent, option, option_columns, # pylint: disable=too-many-arguments + label_width, checkbuttons_frame, blank_nones): logger.debug("Initializing %s: (parent: %s, option: %s, option_columns: %s, " "label_width: %s, checkbuttons_frame: %s, blank_nones: %s)", self.__class__.__name__, parent, option, option_columns, label_width, From 8e0315a3fbec7b630a350c675b6b6c3608004d71 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 2 Oct 2019 16:31:56 +0100 Subject: [PATCH 077/981] control helper: Import order --- lib/gui/control_helper.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 2f58f8dd03..e5512dc195 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -8,7 +8,6 @@ from itertools import zip_longest from functools import partial - from _tkinter import Tcl_Obj from .tooltip import Tooltip From 091bed36462d97ca4b25429c99b813db1eb9ce71 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 2 Oct 2019 17:44:45 +0100 Subject: [PATCH 078/981] nn_blocks: Update naming convention --- lib/model/nn_blocks.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index 9c0567ffb3..2ebc352427 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -81,6 +81,8 @@ def conv2d(self, inp, filters, kernel_size, strides=(1, 1), padding="same", **kw """ A standard conv2D layer with correct initialization """ logger.debug("inp: %s, filters: %s, kernel_size: %s, strides: %s, padding: %s, " "kwargs: %s)", inp, filters, kernel_size, strides, padding, kwargs) + if kwargs.get("name", None) is None: + kwargs["name"] = self.get_name("conv2d_{}".format(inp.shape[1])) kwargs = self.set_default_initializer(kwargs) var_x = Conv2D(filters, kernel_size, strides=strides, @@ -94,7 +96,7 @@ def conv(self, inp, filters, kernel_size=5, strides=2, padding="same", """ Convolution Layer""" logger.debug("inp: %s, filters: %s, kernel_size: %s, strides: %s, use_instance_norm: %s, " "kwargs: %s)", inp, filters, kernel_size, strides, use_instance_norm, kwargs) - name = self.get_name("conv") + name = self.get_name("conv_{}".format(inp.shape[1])) if self.use_reflect_padding: inp = ReflectionPadding2D(stride=strides, kernel_size=kernel_size, @@ -117,7 +119,7 @@ def upscale(self, inp, filters, kernel_size=3, padding="same", """ Upscale Layer """ logger.debug("inp: %s, filters: %s, kernel_size: %s, use_instance_norm: %s, kwargs: %s)", inp, filters, kernel_size, use_instance_norm, kwargs) - name = self.get_name("upscale") + name = self.get_name("upscale_{}".format(inp.shape[1])) if self.use_reflect_padding: inp = ReflectionPadding2D(stride=1, kernel_size=kernel_size, @@ -151,7 +153,7 @@ def res_block(self, inp, filters, kernel_size=3, padding="same", **kwargs): """ Residual block """ logger.debug("inp: %s, filters: %s, kernel_size: %s, kwargs: %s)", inp, filters, kernel_size, kwargs) - name = self.get_name("residual") + name = self.get_name("residual_{}".format(inp.shape[1])) var_x = LeakyReLU(alpha=0.2, name="{}_leakyrelu_0".format(name))(inp) if self.use_reflect_padding: var_x = ReflectionPadding2D(stride=1, @@ -189,7 +191,7 @@ def conv_sep(self, inp, filters, kernel_size=5, strides=2, **kwargs): """ Seperable Convolution Layer """ logger.debug("inp: %s, filters: %s, kernel_size: %s, strides: %s, kwargs: %s)", inp, filters, kernel_size, strides, kwargs) - name = self.get_name("separableconv2d") + name = self.get_name("separableconv2d_{}".format(inp.shape[1])) kwargs = self.set_default_initializer(kwargs) var_x = SeparableConv2D(filters, kernel_size=kernel_size, From e0b0bc8f43d6eccafccaa94e75274ccbfb464907 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 4 Oct 2019 10:34:15 +0000 Subject: [PATCH 079/981] Increase count_frames_and_secs timeout --- lib/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/image.py b/lib/image.py index 3b3e99e26a..1c5a39fa15 100644 --- a/lib/image.py +++ b/lib/image.py @@ -215,7 +215,7 @@ def batch_convert_color(batch, colorspace): # <<< VIDEO UTILS >>> # # ################### # -def count_frames_and_secs(filename, timeout=60): +def count_frames_and_secs(filename, timeout=90): """ Count the number of frames and seconds in a video file. Adapted From :mod:`ffmpeg_imageio` to handle the issue of ffmpeg occasionally hanging From c63b080937f1786c5e060f13990fcf890b739041 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 4 Oct 2019 10:35:05 +0000 Subject: [PATCH 080/981] Add non-fixed items to session item in state.json --- plugins/train/model/_base.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 1fade9fd3f..954b238278 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -881,7 +881,7 @@ def __init__(self, model_dir, model_name, config_changeable_items, self.config = dict() self.load(config_changeable_items) self.session_id = self.new_session_id() - self.create_new_session(no_logs, pingpong) + self.create_new_session(no_logs, pingpong, config_changeable_items) logger.debug("Initialized %s:", self.__class__.__name__) @property @@ -918,7 +918,7 @@ def new_session_id(self): logger.debug(session_id) return session_id - def create_new_session(self, no_logs, pingpong): + def create_new_session(self, no_logs, pingpong, config_changeable_items): """ Create a new session """ logger.debug("Creating new session. id: %s", self.session_id) self.sessions[self.session_id] = {"timestamp": time.time(), @@ -926,7 +926,8 @@ def create_new_session(self, no_logs, pingpong): "pingpong": pingpong, "loss_names": dict(), "batchsize": 0, - "iterations": 0} + "iterations": 0, + "config": config_changeable_items} def add_session_loss_names(self, side, loss_names): """ Add the session loss names to the sessions dictionary """ From 7a70ac62116ef75ef9cc58745d9f5d66122b7962 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 5 Oct 2019 11:42:44 +0100 Subject: [PATCH 081/981] GUI: Fix disappearing columns on resize --- lib/gui/control_helper.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index e5512dc195..dd4059b725 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -425,7 +425,7 @@ def scale_column_width(original_size, original_fontsize): @property def items(self): - """ Returns the number if items held in this containter """ + """ Returns the number of items held in this containter """ return self._items @property @@ -464,6 +464,9 @@ def rearrange_columns(self, width): self.compile_widget_config() self.destroy_children() self.repack_columns() + # Reset counters + self._items = 0 + self._idx = 0 self.pack_widget_clones(self._widget_config) def validate(self, width): @@ -547,9 +550,12 @@ def destroy_children(self): def repack_columns(self): """ Repack or unpack columns based on display columns """ for idx, subframe in enumerate(self.subframes): + logger.trace("Processing subframe: %s", subframe) if idx < self.columns and not subframe.winfo_ismapped(): + logger.trace("Packing subframe: %s", subframe) subframe.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N, expand=True, fill=tk.X) elif idx >= self.columns and subframe.winfo_ismapped(): + logger.trace("Forgetting subframe: %s", subframe) subframe.pack_forget() def pack_widget_clones(self, widget_dicts, old_children=None, new_children=None): @@ -561,6 +567,7 @@ def pack_widget_clones(self, widget_dicts, old_children=None, new_children=None) new_children = [] if new_children is None else new_children if widget_dict.get("parent", None) is not None: parent = new_children[old_children.index(widget_dict["parent"])] + logger.trace("old parent: '%s', new_parent: '%s'", widget_dict["parent"], parent) else: # Get the next subframe if this doesn't have a logged parent parent = self.subframe From 5887cb5ac40c7c990e9a03d49fb708a490139de2 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 6 Oct 2019 01:11:28 +0100 Subject: [PATCH 082/981] GUI Bugfix: Fix csv saving in analysis tab --- lib/gui/display_analysis.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/gui/display_analysis.py b/lib/gui/display_analysis.py index 4e3c26941a..bebf267930 100644 --- a/lib/gui/display_analysis.py +++ b/lib/gui/display_analysis.py @@ -182,14 +182,12 @@ def save_session(self): logger.debug("No save file. Returning") return - write_dicts = [val for val in self.summary.values()] - fieldnames = sorted(key for key in write_dicts[0].keys()) - logger.debug("Saving to: '%s'", savefile) + fieldnames = sorted(key for key in self.summary[0].keys()) with savefile as outfile: csvout = csv.DictWriter(outfile, fieldnames) csvout.writeheader() - for row in write_dicts: + for row in self.summary: csvout.writerow(row) From 995a8571547dec1a1852dc87b48e45a2485a4e01 Mon Sep 17 00:00:00 2001 From: kvrooman Date: Mon, 7 Oct 2019 10:16:18 -0500 Subject: [PATCH 083/981] Smart Mask Exposure for Extraction & Training (#831) Smart Masks - Initial Commit --- lib/cli.py | 95 ++++--- lib/convert.py | 2 +- lib/face_filter.py | 9 +- lib/faces_detect.py | 54 ++-- lib/image.py | 2 +- lib/training_data.py | 87 ++---- lib/vgg_face.py | 4 +- lib/vgg_face2_keras.py | 4 +- plugins/extract/_base.py | 8 +- plugins/extract/align/_base.py | 13 +- plugins/extract/align/cv2_dnn.py | 3 +- plugins/extract/align/fan.py | 2 +- plugins/extract/detect/_base.py | 23 +- plugins/extract/detect/cv2_dnn.py | 1 + plugins/extract/detect/mtcnn.py | 0 plugins/extract/{ => mask}/.cache/.keep | 0 plugins/extract/mask/__init__.py | 0 plugins/extract/mask/_base.py | 258 ++++++++++++++++++ plugins/extract/mask/components.py | 72 +++++ plugins/extract/mask/components_defaults.py | 68 +++++ plugins/extract/mask/extended.py | 92 +++++++ plugins/extract/mask/extended_defaults.py | 68 +++++ plugins/extract/mask/none.py | 46 ++++ plugins/extract/mask/none_defaults.py | 67 +++++ plugins/extract/mask/unet_dfl.py | 71 +++++ plugins/extract/mask/unet_dfl_defaults.py | 68 +++++ plugins/extract/mask/vgg_clear.py | 74 +++++ plugins/extract/mask/vgg_clear_defaults.py | 67 +++++ plugins/extract/mask/vgg_obstructed.py | 74 +++++ .../extract/mask/vgg_obstructed_defaults.py | 68 +++++ plugins/extract/pipeline.py | 136 +++++---- plugins/extract/recognition/.cache/.keep | 0 plugins/plugin_loader.py | 193 ++++++------- plugins/train/_config.py | 15 +- plugins/train/model/_base.py | 23 +- plugins/train/model/dfaker.py | 2 +- plugins/train/model/dfl_h128.py | 4 +- plugins/train/model/dfl_sae.py | 2 +- plugins/train/model/iae.py | 2 +- plugins/train/model/lightweight.py | 2 +- plugins/train/model/original.py | 2 +- plugins/train/model/realface.py | 4 +- plugins/train/model/unbalanced.py | 4 +- plugins/train/model/villain.py | 2 +- plugins/train/trainer/_base.py | 97 ++++--- scripts/convert.py | 2 +- scripts/extract.py | 26 +- scripts/fsmedia.py | 7 +- tools/preview.py | 3 +- 49 files changed, 1536 insertions(+), 390 deletions(-) mode change 100755 => 100644 plugins/extract/detect/_base.py mode change 100755 => 100644 plugins/extract/detect/cv2_dnn.py mode change 100755 => 100644 plugins/extract/detect/mtcnn.py rename plugins/extract/{ => mask}/.cache/.keep (100%) create mode 100644 plugins/extract/mask/__init__.py create mode 100644 plugins/extract/mask/_base.py create mode 100644 plugins/extract/mask/components.py create mode 100644 plugins/extract/mask/components_defaults.py create mode 100644 plugins/extract/mask/extended.py create mode 100644 plugins/extract/mask/extended_defaults.py create mode 100644 plugins/extract/mask/none.py create mode 100644 plugins/extract/mask/none_defaults.py create mode 100644 plugins/extract/mask/unet_dfl.py create mode 100644 plugins/extract/mask/unet_dfl_defaults.py create mode 100644 plugins/extract/mask/vgg_clear.py create mode 100644 plugins/extract/mask/vgg_clear_defaults.py create mode 100644 plugins/extract/mask/vgg_obstructed.py create mode 100644 plugins/extract/mask/vgg_obstructed_defaults.py create mode 100644 plugins/extract/recognition/.cache/.keep diff --git a/lib/cli.py b/lib/cli.py index 9264e60348..06350a5919 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -15,7 +15,6 @@ from lib.logger import crash_log, log_setup from lib.utils import FaceswapError, get_backend, safe_shutdown -from lib.model.masks import get_available_masks, get_default_mask from plugins.plugin_loader import PluginLoader logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -542,36 +541,65 @@ def get_optional_arguments(): "help": "Serializer for alignments file. If yaml is chosen and not " "available, then json will be used as the default " "fallback."}) - argument_list.append({ - "opts": ("-D", "--detector"), - "action": Radio, - "type": str.lower, - "choices": PluginLoader.get_available_extractors("detect"), - "default": default_detector, - "group": "Plugins", - "help": "R|Detector to use. Some of these have configurable settings in " - "'/config/extract.ini' or 'Settings > Configure Extract Plugins':" - "\nL|cv2-dnn: A CPU only extractor, is the least reliable, but uses least " - "resources and runs fast on CPU. Use this if not using a GPU and time is " - "important." - "\nL|mtcnn: Fast on CPU, Faster on GPU. Uses far fewer resources than other " - "GPU detectors but can often return more false positives." - "\nL|s3fd: Fast on GPU, slow on CPU. Can detect more faces and " - "fewer false positives than other GPU detectors, but is a lot more resource " - "intensive."}) - argument_list.append({ - "opts": ("-A", "--aligner"), - "action": Radio, - "type": str.lower, - "choices": PluginLoader.get_available_extractors("align"), - "default": default_aligner, - "group": "Plugins", - "help": "R|Aligner to use." - "\nL|cv2-dnn: A cpu only CNN based landmark detector. Faster, less " - "resource intensive, but less accurate. Only use this if not using a gpu " - " and time is important." - "\nL|fan: Face Alignment Network. Best aligner. GPU " - "heavy, slow when not running on GPU"}) + argument_list.append({"opts": ("-D", "--detector"), + "action": Radio, + "type": str.lower, + "choices": PluginLoader.get_available_extractors("detect"), + "default": default_detector, + "group": "Plugins", + "help": "R|Detector to use. Some of these have configurable " + "settings in '/config/extract.ini' or 'Settings > Configure " + "Extract 'Plugins':" + "\nL|cv2-dnn: A CPU only extractor which is the least " + "reliable and least resource intensive. Use this if not " + "using a GPU and time is important." + "\nL|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses " + "fewer resources than other GPU detectors but can often " + "return more false positives." + "\nL|s3fd: Best detector. Fast on GPU, slow on CPU. Can " + "detect more faces and fewer false positives than other " + "GPU detectors, but is a lot more resource intensive."}) + argument_list.append({"opts": ("-A", "--aligner"), + "action": Radio, + "type": str.lower, + "choices": PluginLoader.get_available_extractors("align"), + "default": default_aligner, + "group": "Plugins", + "help": "R|Aligner to use." + "\nL|cv2-dnn: A CPU only landmark detector. Faster, less " + "resource intensive, but less accurate. Only use this if " + "not using a GPU and time is important." + "\nL|fan: Best aligner. Fast on GPU, slow on CPU."}) + argument_list.append({"opts": ("-M", "--masker"), + "action": Radio, + "type": str.lower, + "choices": PluginLoader.get_available_extractors("mask"), + "default": "components", + "group": "Plugins", + "help": "R|Masker to use." + "\nL|none: An array of all ones is created to provide a 4th " + "channel that will not mask any portion of the image." + "\nL|components: Mask designed to provide facial " + "segmentation based on the positioning of landmark " + "locations. A convenx hull is constructed around the " + "exterior of the landmarks to create a mask." + "\nL|extended: Mask designed to provide facial segmentation " + "based on the positioning of landmark locations. A convenx " + "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({"opts": ("-nm", "--normalization"), "action": Radio, "type": str.lower, @@ -791,7 +819,7 @@ def get_optional_arguments(): "action": Radio, "type": str.lower, "dest": "mask_type", - "choices": get_available_masks() + ["predicted"], + "choices": ["dfl_full", "components", "extended", "predicted"], "group": "plugins", "default": "predicted", "help": "R|Mask to use to replace faces. Blending of the masks can be adjusted in " @@ -803,8 +831,7 @@ def get_optional_arguments(): "further up the forehead. May perform badly on difficult angles." "\nL|facehull: Face cutout based on landmarks." "\nL|predicted: The predicted mask generated from the model. If the model was " - "not trained with a mask then this will fallback to " - "'{}'".format(get_default_mask()) + + "not trained with a mask then this will fallback to components." "\nL|none: Don't use a mask."}) argument_list.append({ "opts": ("-sc", "--scaling"), diff --git a/lib/convert.py b/lib/convert.py index 3f782f2edb..bd7d6cd1f9 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -141,7 +141,7 @@ def get_new_image(self, predicted, frame_size): predicted["detected_faces"]): predicted_mask = new_face[:, :, -1] if new_face.shape[2] == 4 else None new_face = new_face[:, :, :3] - src_face = detected_face.reference_face + src_face = detected_face.reference_face / np.array(255.0, dtype="float32") interpolator = detected_face.reference_interpolators[1] new_face = self.pre_warp_adjustments(src_face, new_face, detected_face, predicted_mask) diff --git a/lib/face_filter.py b/lib/face_filter.py index 36ef194b83..096709d31e 100644 --- a/lib/face_filter.py +++ b/lib/face_filter.py @@ -36,7 +36,7 @@ def __init__(self, reference_file_paths, nreference_file_paths, detector, aligne # already performed allocation. For now we force CPU detectors. # self.align_faces(detector, aligner, multiprocess) - self.align_faces("cv2-dnn", "cv2-dnn", multiprocess) + self.align_faces("cv2-dnn", "cv2-dnn", "none", multiprocess) self.get_filter_encodings() self.threshold = threshold @@ -56,9 +56,12 @@ def load_images(reference_file_paths, nreference_file_paths): return retval # Extraction pipeline - def align_faces(self, detector_name, aligner_name, multiprocess): + def align_faces(self, detector_name, aligner_name, masker_name, multiprocess): """ Use the requested detectors to retrieve landmarks for filter images """ - extractor = Extractor(detector_name, aligner_name, multiprocess=multiprocess) + extractor = Extractor(detector_name, + aligner_name, + masker_name, + multiprocess=multiprocess) self.run_extractor(extractor) del extractor self.load_aligned_face() diff --git a/lib/faces_detect.py b/lib/faces_detect.py index 94707b019b..21bb53a00f 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -40,18 +40,22 @@ class DetectedFace(): The 68 point landmarks as discovered in :mod:`plugins.extract.align`. Should be a ``list`` of 68 `(x, y)` ``tuples`` with each of the landmark co-ordinates. """ - def __init__(self, image=None, x=None, w=None, y=None, h=None, landmarks_xy=None): - logger.trace("Initializing %s: (image: %s, x: %s, w: %s, y: %s, h:%s, landmarks_xy: %s)", + def __init__(self, image=None, x=None, w=None, y=None, h=None, + landmarks_xy=None, filename=None): + logger.trace("Initializing %s: (image: %s, x: %s, w: %s, y: %s, h:%s, " + "landmarks_xy: %s, filename: %s)", self.__class__.__name__, image.shape if image is not None and image.any() else image, - x, w, y, h, landmarks_xy) + x, w, y, h, landmarks_xy, filename) self.image = image self.x = x self.w = w self.y = y self.h = h self.landmarks_xy = landmarks_xy + self.filename = filename self.hash = None + self.face = None """ str: The hash of the face. This cannot be set until the file is saved due to image compression, but will be set if loading data from :func:`from_alignment` """ @@ -81,9 +85,9 @@ def bottom(self): return self.y + self.h @property - def _extract_ratio(self): - """ float: The ratio of padding to add for training images """ - return 0.375 + def training_coverage(self): + """ The coverage ratio to add for training images """ + return 1.0 def to_alignment(self): """ Return the detected face formatted for an alignments file @@ -130,6 +134,7 @@ def from_alignment(self, alignment, image=None): # Manual tool does not know the final hash so default to None self.hash = alignment.get("hash", None) if image is not None and image.any(): + self.image = image self._image_to_face(image) logger.trace("Created from alignment: (x: %s, w: %s, y: %s. h: %s, " "landmarks: %s)", @@ -138,11 +143,11 @@ def from_alignment(self, alignment, image=None): def _image_to_face(self, image): """ set self.image to be the cropped face from detected bounding box """ logger.trace("Cropping face from image") - self.image = image[self.top: self.bottom, + self.face = image[self.top: self.bottom, self.left: self.right] # <<< Aligned Face methods and properties >>> # - def load_aligned(self, image, size=256, dtype=None): + def load_aligned(self, image, size=256, coverage_ratio=1.0, dtype=None): """ Align a face from a given image. Aligning a face is a relatively expensive task and is not required for all uses of @@ -159,8 +164,8 @@ def load_aligned(self, image, size=256, dtype=None): The image that contains the face to be aligned size: int The size of the output face in pixels - align_eyes: bool, optional - Optionally perform additional alignment to align eyes. Default: `False` + coverage_ratio: float + The metric determining the field of view of the returned face dtype: str, optional Optionally set a ``dtype`` for the final face to be formatted in. Default: ``None`` @@ -177,9 +182,8 @@ def load_aligned(self, image, size=256, dtype=None): logger.trace("Skipping alignment calculation for already aligned face") else: logger.trace("Loading aligned face: (size: %s, dtype: %s)", size, dtype) - padding = int(size * self._extract_ratio) // 2 self.aligned["size"] = size - self.aligned["padding"] = padding + self.aligned["padding"] = self._padding_from_coverage(size, coverage_ratio) self.aligned["matrix"] = get_align_mat(self) self.aligned["face"] = None if image is not None and self.aligned["face"] is None: @@ -188,7 +192,7 @@ def load_aligned(self, image, size=256, dtype=None): image, self.aligned["matrix"], size, - padding) + self.aligned["padding"]) self.aligned["face"] = face if dtype is None else face.astype(dtype) logger.trace("Loaded aligned face: %s", {k: str(v) if isinstance(v, np.ndarray) else v @@ -198,8 +202,7 @@ def load_aligned(self, image, size=256, dtype=None): def _padding_from_coverage(self, size, coverage_ratio): """ Return the image padding for a face from coverage_ratio set against a pre-padded training image """ - adjusted_ratio = coverage_ratio - (1 - self._extract_ratio) - padding = round((size * adjusted_ratio) / 2) + padding = int((size * (coverage_ratio - 0.625)) / 2) logger.trace(padding) return padding @@ -230,8 +233,10 @@ def load_feed_face(self, image, size=64, coverage_ratio=0.625, dtype=None): self.feed["padding"] = self._padding_from_coverage(size, coverage_ratio) self.feed["matrix"] = get_align_mat(self) - face = AlignerExtract().transform(image, self.feed["matrix"], size, self.feed["padding"]) - face = np.clip(face[:, :, :3] / 255., 0., 1.) + face = AlignerExtract().transform(image, + self.feed["matrix"], + size, + self.feed["padding"]) self.feed["face"] = face if dtype is None else face.astype(dtype) logger.trace("Loaded feed face. (face_shape: %s, matrix: %s)", @@ -270,7 +275,6 @@ def load_reference_face(self, image, size=64, coverage_ratio=0.625, dtype=None): self.reference["matrix"], size, self.reference["padding"]) - face = np.clip(face[:, :, :3] / 255., 0., 1.) self.reference["face"] = face if dtype is None else face.astype(dtype) logger.trace("Loaded reference face. (face_shape: %s, matrix: %s)", @@ -335,6 +339,20 @@ def feed_face(self): return None return self.feed["face"] + @property + def feed_landmarks(self): + """ numpy.ndarray: The 68 point landmarks location transposed to the feed face box. + Only available after :func:`load_reference_face` has been called, otherwise returns + ``None``""" + if not self.feed: + return None + landmarks = AlignerExtract().transform_points(self.landmarks_xy, + self.feed["matrix"], + self.feed["size"], + self.feed["padding"]) + logger.trace("Returning: %s", landmarks) + return landmarks + @property def _feed_matrix(self): """ numpy.ndarray: The adjusted matrix face sized for feeding into a model. Only available diff --git a/lib/image.py b/lib/image.py index 1c5a39fa15..f822d2ba65 100644 --- a/lib/image.py +++ b/lib/image.py @@ -56,7 +56,7 @@ def read_image(filename, raise_error=False): success = True image = None try: - image = cv2.imread(filename) + image = cv2.imread(filename, cv2.IMREAD_UNCHANGED) if image is None: raise ValueError except TypeError: diff --git a/lib/training_data.py b/lib/training_data.py index 52886b6073..dce63676be 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -11,7 +11,6 @@ from scipy.interpolate import griddata from lib.image import batch_convert_color, read_image_batch -from lib.model import masks from lib.multithreading import BackgroundGenerator from lib.utils import FaceswapError @@ -48,17 +47,12 @@ class TrainingDataGenerator(): * **no_flip** (`bool`) - ``True`` if the image shouldn't be randomly flipped as part of \ augmentation, otherwise ``False`` - * **mask_type** (`str`) - The mask type to be used (as defined in \ - :mod:`lib.model.masks`). If not ``None`` then the additional key ``landmarks`` must be \ - provided. - * **warp_to_landmarks** (`bool`) - ``True`` if the random warp method should warp to \ similar landmarks from the other side, ``False`` if the standard random warp method \ should be used. If ``True`` then the additional key ``landmarks`` must be provided. - * **landmarks** (`numpy.ndarray`, `optional`). Required if using a :attr:`mask_type` is \ - not ``None`` or :attr:`warp_to_landmarks` is ``True``. The 68 point face landmarks from \ - an alignments file. + * **landmarks** (`numpy.ndarray`, `optional`). Required if :attr:`warp_to_landmarks` is \ + ``True``. The 68 point face landmarks from an alignments file. config: dict The configuration ``dict`` generated from :file:`config.train.ini` containing the trainer \ @@ -74,7 +68,6 @@ def __init__(self, model_input_size, model_output_shapes, training_opts, config) self._model_input_size = model_input_size self._model_output_shapes = model_output_shapes self._training_opts = training_opts - self._mask_class = self._set__mask_class() self._landmarks = self._training_opts.get("landmarks", None) self._nearest_landmarks = {} @@ -130,8 +123,7 @@ def minibatch_ab(self, images, batchsize, side, :mod:`plugins.train.trainer._base` from the ``masks`` key. * **masks** (`numpy.ndarray`) - A 4-dimensional array containing the target masks in \ - the format (`batchsize`, `height`, `width`, `1`). **NB:** This item will only exist \ - in the ``dict`` if the :attr:`mask_type` is not ``None`` + the format (`batchsize`, `height`, `width`, `1`). * **samples** (`numpy.ndarray`) - A 4-dimensional array containg the samples for \ feeding to the model's predict function for generating preview and timelapse samples. \ @@ -154,18 +146,6 @@ def minibatch_ab(self, images, batchsize, side, return batcher.iterator() # << INTERNAL METHODS >> # - def _set__mask_class(self): - """ Returns the correct mask class from :mod:`lib`.model.masks` as defined in the - :attr:`mask_type` parameter. """ - mask_type = self._training_opts.get("mask_type", None) - if mask_type: - logger.debug("Mask type: '%s'", mask_type) - _mask_class = getattr(masks, mask_type) - else: - _mask_class = None - logger.debug("Mask class: %s", _mask_class) - return _mask_class - def _validate_samples(self, data): """ Ensures that the total number of images within :attr:`images` is greater or equal to the selected :attr:`batchsize`. Raises an exception if this is not the case. """ @@ -207,24 +187,23 @@ def _process_batch(self, filenames, side): logger.trace("Process batch: (filenames: '%s', side: '%s')", filenames, side) batch = read_image_batch(filenames) processed = dict() - to_landmarks = self._training_opts["warp_to_landmarks"] # Initialize processing training size on first image if not self._processing.initialized: self._processing.initialize(batch.shape[1]) # Get Landmarks prior to manipulating the image - if self._mask_class or to_landmarks: + if self._training_opts["warp_to_landmarks"]: batch_src_pts = self._get_landmarks(filenames, batch, side) + batch_dst_pts = self._get_closest_match(filenames, side, batch_src_pts) + warp_kwargs = dict(batch_src_points=batch_src_pts, + batch_dst_points=batch_dst_pts) + else: + warp_kwargs = dict() - # Color augmentation before mask is added + # Color Augmentation of the image only if self._training_opts["augment_color"]: - batch = self._processing.color_adjust(batch) - - # Add mask to batch prior to transforms and warps - if self._mask_class: - batch = np.array([self._mask_class(src_pts, image, channels=4).mask - for src_pts, image in zip(batch_src_pts, batch)]) + batch[..., :3] = self._processing.color_adjust(batch[..., :3]) # Random Transform and flip batch = self._processing.transform(batch) @@ -238,15 +217,10 @@ def _process_batch(self, filenames, side): # Get Targets processed.update(self._processing.get_targets(batch)) - # Random Warp - if to_landmarks: - warp_kwargs = dict(batch_src_points=batch_src_pts, - batch_dst_points=self._get_closest_match(filenames, - side, - batch_src_pts)) - else: - warp_kwargs = dict() - processed["feed"] = self._processing.warp(batch[..., :3], to_landmarks, **warp_kwargs) + # Random Warp # TODO change masks to have a input mask and a warped target mask + processed["feed"] = [self._processing.warp(batch[..., :3], + self._training_opts["warp_to_landmarks"], + **warp_kwargs)] logger.trace("Processed batch: (filenames: %s, side: '%s', processed: %s)", filenames, @@ -258,8 +232,8 @@ def _process_batch(self, filenames, side): def _get_landmarks(self, filenames, batch, side): """ Obtains the 68 Point Landmarks for the images in this batch. This is only called if - config item ``warp_to_landmarks`` is ``True`` or if :attr:`mask_type` is not ``None``. If - the landmarks for an image cannot be found, then an error is raised. """ + config item ``warp_to_landmarks`` is ``True``. If the landmarks for an image cannot be + found, then an error is raised. """ logger.trace("Retrieving landmarks: (filenames: %s, side: '%s')", filenames, side) src_points = [self._landmarks[side].get(sha1(face).hexdigest(), None) for face in batch] @@ -270,7 +244,7 @@ def _get_landmarks(self, filenames, batch, side): msg = ("Files missing alignments for this batch: {}" "\nAt least one of your images does not have a matching entry in your " "alignments file." - "\nIf you are training with a mask or using 'warp to landmarks' then every " + "\nIf you are using 'warp to landmarks' then every " "face you intend to train on must exist within the alignments file." "\nThe specific files that caused this failure are listed above." "\nMost likely there will be more than just these files missing from the " @@ -449,18 +423,17 @@ def get_targets(self, batch): output they will be returned as their own item from the ``masks`` key. * **masks** (`numpy.ndarray`) - A 4-dimensional array containing the target masks in \ - the format (`batchsize`, `height`, `width`, `1`). **NB:** This item will only exist \ - in the ``dict`` if a batch of 4 channel images has been passed in :attr:`batch` + the format (`batchsize`, `height`, `width`, `1`). """ logger.trace("Compiling targets") slices = self._constants["tgt_slices"] target_batch = [np.array([cv2.resize(image[slices, slices, :], (size, size), cv2.INTER_AREA) - for image in batch]) + for image in batch], dtype='float32') / 255. for size in self._output_sizes] logger.trace("Target image shapes: %s", - [tgt.shape for tgt_images in target_batch for tgt in tgt_images]) + [tgt_images.shape[1:] for tgt_images in target_batch]) retval = self._separate_target_mask(target_batch) logger.trace("Final targets: %s", @@ -469,25 +442,19 @@ def get_targets(self, batch): return retval @staticmethod - def _separate_target_mask(batch): + def _separate_target_mask(size_list_of_batches): """ Return the batch and the batch of final masks Returns the targets as a list of 4-dimensional ``numpy.ndarray`` s of shape (`batchsize`, `height`, `width`, 3). If the :attr:`batch` is 4 channels, then the masks will be split from the batch, with the largest output masks being returned in their own item. """ - batch = [tgt.astype("float32") / 255.0 for tgt in batch] - if all(tgt.shape[-1] == 4 for tgt in batch): - logger.trace("Batch contains mask") - sizes = [item.shape[1] for item in batch] - mask_batch = np.expand_dims(batch[sizes.index(max(sizes))][..., -1], axis=-1) - batch = [item[..., :3] for item in batch] - logger.trace("batch shapes: %s, mask_batch shape: %s", - [tgt.shape for tgt in batch], mask_batch.shape) - retval = dict(targets=batch, masks=mask_batch) + targets = [batch[..., :3] for batch in size_list_of_batches] + if size_list_of_batches[-1].shape[-1] == 4: + masks = [size_list_of_batches[-1][..., 3:]] else: - logger.trace("Batch has no mask") - retval = dict(targets=batch) + masks = [np.ones((size_list_of_batches[-1].shape[:-1] + (1,)), dtype='float32')] + retval = dict(targets=targets, masks=masks) return retval # <<< COLOR AUGMENTATION >>> # diff --git a/lib/vgg_face.py b/lib/vgg_face.py index cd38270a93..ed92fc87fa 100644 --- a/lib/vgg_face.py +++ b/lib/vgg_face.py @@ -38,7 +38,7 @@ def __init__(self, backend="CPU"): def get_model(self, git_model_id, model_filename, backend): """ Check if model is available, if not, download and unzip it """ root_path = os.path.abspath(os.path.dirname(sys.argv[0])) - cache_path = os.path.join(root_path, "plugins", "extract", ".cache") + cache_path = os.path.join(root_path, "plugins", "extract", "recognition", ".cache") model = GetModel(model_filename, cache_path, git_model_id).model_path model = cv2.dnn.readNetFromCaffe(model[1], model[0]) # pylint: disable=no-member model.setPreferableTarget(self.get_backend(backend)) @@ -57,7 +57,7 @@ def predict(self, face): """ Return encodings for given image from vgg_face """ if face.shape[0] != self.input_size: face = self.resize_face(face) - blob = cv2.dnn.blobFromImage(face, # pylint: disable=no-member + blob = cv2.dnn.blobFromImage(face[..., :3], # pylint: disable=no-member 1.0, (self.input_size, self.input_size), self.average_img, diff --git a/lib/vgg_face2_keras.py b/lib/vgg_face2_keras.py index 5b11fc3598..d66cf99ab0 100644 --- a/lib/vgg_face2_keras.py +++ b/lib/vgg_face2_keras.py @@ -41,7 +41,7 @@ def __init__(self, backend="GPU", loglevel="INFO"): def get_model(self, git_model_id, model_filename, backend): """ Check if model is available, if not, download and unzip it """ root_path = os.path.abspath(os.path.dirname(sys.argv[0])) - cache_path = os.path.join(root_path, "plugins", "extract", ".cache") + cache_path = os.path.join(root_path, "plugins", "extract", "recognition", ".cache") model = GetModel(model_filename, cache_path, git_model_id).model_path if backend == "CPU": if os.environ.get("KERAS_BACKEND", "") == "plaidml.keras.backend": @@ -63,7 +63,7 @@ def predict(self, face): """ Return encodings for given image from vgg_face """ if face.shape[0] != self.input_size: face = self.resize_face(face) - face = np.expand_dims(face - self.average_img, axis=0) + face = face[None, :, :, :3] - self.average_img preds = self.model.predict(face) return preds[0, :] diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 5382bd365d..80854995e3 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -306,7 +306,7 @@ def _get_model(self, git_model_id, model_filename): logger.debug("No git_model_id specified. Returning None") return None plugin_path = os.path.join(*self.__module__.split(".")[:-1]) - if os.path.basename(plugin_path) in ("detect", "align"): + if os.path.basename(plugin_path) in ("detect", "align", "mask", "recognition"): base_path = os.path.dirname(os.path.realpath(sys.argv[0])) cache_path = os.path.join(base_path, plugin_path, ".cache") else: @@ -322,13 +322,13 @@ def initialize(self, *args, **kwargs): """ logger.debug("initialize %s: (args: %s, kwargs: %s)", self.__class__.__name__, args, kwargs) - p_type = "Detector" if self._plugin_type == "detect" else "Aligner" - logger.info("Initializing %s %s...", self.name, p_type) + logger.info("Initializing %s in %s phase...", self.name, self._plugin_type) self.queue_size = 1 self._add_queues(kwargs["in_queue"], kwargs["out_queue"], ["predict", "post"]) self._compile_threads() self.init_model() - logger.info("Initialized %s %s with batchsize of %s", self.name, p_type, self.batchsize) + logger.info("Initialized %s (%s) with batchsize of %s", + self.name, self._plugin_type, self.batchsize) def _add_queues(self, in_queue, out_queue, queues): """ Add the queues diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index c552741335..4c2d9b008a 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -50,10 +50,10 @@ class Aligner(Extractor): plugins.extract.align : Aligner plugins plugins.extract._base : Parent class for all extraction plugins plugins.extract.detect._base : Detector parent class for extraction plugins. - + plugins.extract.mask._base : Masker parent class for extraction plugins. """ - def __init__(self, git_model_id, model_filename, + def __init__(self, git_model_id=None, model_filename=None, configfile=None, normalize_method=None): logger.debug("Initializing %s: (normalize_method: %s)", self.__class__.__name__, normalize_method) @@ -183,9 +183,14 @@ def finalize(self, batch): """ - for face, landmarks in zip(batch["detected_faces"], batch["landmarks"]): + generator = zip(batch["detected_faces"], + batch["filename"], + batch["image"], + batch["landmarks"]) + for face, filename, image, landmarks in generator: face.landmarks_xy = [(int(round(pt[0])), int(round(pt[1]))) for pt in landmarks] - + face.image = image + face.filename = filename self._remove_invalid_keys(batch, ("detected_faces", "filename", "image")) logger.trace("Item out: %s", {key: val for key, val in batch.items() diff --git a/plugins/extract/align/cv2_dnn.py b/plugins/extract/align/cv2_dnn.py index 919e35ea60..577a964908 100644 --- a/plugins/extract/align/cv2_dnn.py +++ b/plugins/extract/align/cv2_dnn.py @@ -41,6 +41,7 @@ def __init__(self, **kwargs): self.input_size = 128 self.colorformat = "RGB" self.vram = 0 # Doesn't use GPU + self.vram_per_batch = 0 self.batchsize = 1 def init_model(self): @@ -52,7 +53,7 @@ def process_input(self, batch): """ Compile the detected faces for prediction """ faces, batch["roi"] = self.align_image(batch["detected_faces"]) faces = self._normalize_faces(faces) - batch["feed"] = np.array(faces, dtype="float32").transpose((0, 3, 1, 2)) + batch["feed"] = np.array(faces, dtype="float32")[..., :3].transpose((0, 3, 1, 2)) return batch def align_image(self, detected_faces): diff --git a/plugins/extract/align/fan.py b/plugins/extract/align/fan.py index 71e5606279..bee87643f6 100644 --- a/plugins/extract/align/fan.py +++ b/plugins/extract/align/fan.py @@ -48,7 +48,7 @@ def process_input(self, batch): faces = self.crop(batch) logger.trace("Aligned image around center") faces = self._normalize_faces(faces) - batch["feed"] = np.array(faces, dtype="float32").transpose((0, 3, 1, 2)) / 255.0 + batch["feed"] = np.array(faces, dtype="float32")[..., :3].transpose((0, 3, 1, 2)) / 255.0 return batch def get_center_scale(self, detected_faces): diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py old mode 100755 new mode 100644 index 6a29de1cee..81907e03a9 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -53,7 +53,7 @@ class Detector(Extractor): plugins.extract.detect : Detector plugins plugins.extract._base : Parent class for all extraction plugins plugins.extract.align._base : Aligner parent class for extraction plugins. - + plugins.extract.mask._base : Masker parent class for extraction plugins. """ def __init__(self, git_model_id=None, model_filename=None, @@ -228,7 +228,7 @@ def _predict(self, batch): # <<< DETECTION IMAGE COMPILATION METHODS >>> # def _compile_detection_image(self, input_image): """ Compile the detection image for feeding into the model""" - image = self._convert_color(input_image) + image = self._convert_color(input_image[..., :3]) image_size = image.shape[:2] scale = self._set_scale(image_size) @@ -272,13 +272,12 @@ def _pad_image(self, image): pad_r = (self.input_size - width) - pad_l pad_t = (self.input_size - height) // 2 pad_b = (self.input_size - height) - pad_t - image = cv2.copyMakeBorder( # pylint:disable=no-member - image, - pad_t, - pad_b, - pad_l, - pad_r, - cv2.BORDER_CONSTANT) # pylint:disable=no-member + image = cv2.copyMakeBorder(image, # pylint:disable=no-member + pad_t, + pad_b, + pad_l, + pad_r, + cv2.BORDER_CONSTANT) # pylint:disable=no-member logger.trace("Padded image shape: %s", image.shape) return image @@ -289,11 +288,11 @@ def _remove_zero_sized_faces(batch): or face falls entirely outside of image """ dims = [img.shape[:2] for img in batch["image"]] logger.trace("image dims: %s", dims) - batch["detected_faces"] = [[face for face in faces + batch["detected_faces"] = [[face + for face in faces if face.right > 0 and face.left < dim[1] and face.bottom > 0 and face.top < dim[0]] - for dim, faces in zip(dims, - batch.get("detected_faces", list()))] + for dim, faces in zip(dims, batch.get("detected_faces", []))] def _filter_small_faces(self, detected_faces): """ Filter out any faces smaller than the min size threshold """ diff --git a/plugins/extract/detect/cv2_dnn.py b/plugins/extract/detect/cv2_dnn.py old mode 100755 new mode 100644 index 7e2330b16b..7e9dbb6c77 --- a/plugins/extract/detect/cv2_dnn.py +++ b/plugins/extract/detect/cv2_dnn.py @@ -15,6 +15,7 @@ def __init__(self, **kwargs): self.name = "cv2-DNN Detector" self.input_size = 300 self.vram = 0 # CPU Only. Doesn't use VRAM + self.vram_per_batch = 0 self.batchsize = 1 self.confidence = self.config["confidence"] / 100 diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py old mode 100755 new mode 100644 diff --git a/plugins/extract/.cache/.keep b/plugins/extract/mask/.cache/.keep similarity index 100% rename from plugins/extract/.cache/.keep rename to plugins/extract/mask/.cache/.keep diff --git a/plugins/extract/mask/__init__.py b/plugins/extract/mask/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py new file mode 100644 index 0000000000..9539ab8790 --- /dev/null +++ b/plugins/extract/mask/_base.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +""" Base class for Face Masker plugins + Plugins should inherit from this class + + See the override methods for which methods are required. + + The plugin will receive a dict containing: + {"filename": , + "image": , + "detected_faces": } + + For each source item, the plugin must pass a dict to finalize containing: + {"filename": , + "image": , + "detected_faces": + """ + +import logging +import os +import traceback +import cv2 +import numpy as np +import keras + +from io import StringIO +from lib.faces_detect import DetectedFace +from lib.aligner import Extract +from plugins.extract._base import Extractor, logger + +logger = logging.getLogger(__name__) # pylint:disable=invalid-name + + +class Masker(Extractor): + """ Aligner plugin _base Object + + All Aligner plugins must inherit from this class + + Parameters + ---------- + git_model_id: int + The second digit in the github tag that identifies this model. See + https://github.com/deepfakes-models/faceswap-models for more information + model_filename: str + The name of the model file to be loaded + normalize_method: {`None`, 'clahe', 'hist', 'mean'}, optional + Normalize the images fed to the aligner. Default: ``None`` + + Other Parameters + ---------------- + configfile: str, optional + Path to a custom configuration ``ini`` file. Default: Use system configfile + + See Also + -------- + plugins.extract.align : Aligner plugins + plugins.extract._base : Parent class for all extraction plugins + plugins.extract.detect._base : Detector parent class for extraction plugins. + plugins.extract.align._base : Aligner parent class for extraction plugins. + """ + + def __init__(self, git_model_id=None, model_filename=None, + configfile=None, input_size=256, output_size=256, coverage_ratio=1.): + logger.debug("Initializing %s: (configfile: %s, input_size: %s, " + "output_size: %s, coverage_ratio: %s)", + self.__class__.__name__, configfile, input_size, output_size, coverage_ratio) + super().__init__(git_model_id, + model_filename, + configfile=configfile) + self.input_size = input_size + self.output_size = output_size + self.coverage_ratio = coverage_ratio + self.extract = Extract() + + self._plugin_type = "mask" + self._faces_per_filename = dict() # Tracking for recompiling face batches + self._rollover = [] # Items that are rolled over from the previous batch in get_batch + self._output_faces = [] + logger.debug("Initialized %s", self.__class__.__name__) + + def get_batch(self, queue): + """ Get items for inputting into the aligner from the queue in batches + + Items are returned from the ``queue`` in batches of + :attr:`~plugins.extract._base.Extractor.batchsize` + + To ensure consistent batchsizes for aligner the items are split into separate items for + each :class:`lib.faces_detect.DetectedFace` object. + + Remember to put ``'EOF'`` to the out queue after processing + the final batch + + Outputs items in the following format. All lists are of length + :attr:`~plugins.extract._base.Extractor.batchsize`: + + >>> {'filename': [], + >>> 'image': [], + >>> 'detected_faces': [[>> {'image': [], + >>> 'filename': [), + >>> 'detected_faces': []} + + Parameters + ---------- + batch : dict + The final ``dict`` from the `plugin` process. It must contain the `keys`: + ``detected_faces``, ``landmarks``, ``filename``, ``image`` + + Yields + ------ + dict + A ``dict`` for each frame containing the ``image``, ``filename`` and list of + :class:`lib.faces_detect.DetectedFace` objects. + + """ + self._remove_invalid_keys(batch, ("detected_faces", "filename", "image")) + logger.trace("Item out: %s", {key: val + for key, val in batch.items() + if key != "image"}) + for filename, image, face in zip(batch["filename"], + batch["image"], + batch["detected_faces"]): + self._output_faces.append(face) + if len(self._output_faces) != self._faces_per_filename[filename]: + continue + retval = dict(filename=filename, image=image, detected_faces=self._output_faces) + + self._output_faces = [] + logger.trace("Yielding: (filename: '%s', image: %s, detected_faces: %s)", + retval["filename"], retval["image"].shape, len(retval["detected_faces"])) + yield retval + + # <<< PROTECTED ACCESS METHODS >>> # + @staticmethod + def _resize(image, target_size): + """ resize input and output of mask models appropriately """ + height, width, channels = image.shape + image_size = max(height, width) + scale = target_size / image_size + if scale == 1.: + return image + method = cv2.INTER_CUBIC if scale > 1. else cv2.INTER_AREA # pylint: disable=no-member + resized = cv2.resize(image, (0, 0), fx=scale, fy=scale, interpolation=method) + resized = resized if channels > 1 else resized[..., None] + return resized + + @staticmethod + def postprocessing(mask): + """ Post-processing of Nirkin style segmentation masks """ + # Select_largest_segment + if pop_small_segments: + results = cv2.connectedComponentsWithStats(mask, # pylint: disable=no-member + 4, + cv2.CV_32S) # pylint: disable=no-member + _, labels, stats, _ = results + segments_ranked_by_area = np.argsort(stats[:, -1])[::-1] + mask[labels != segments_ranked_by_area[0, 0]] = 0. + + # Smooth contours + if smooth_contours: + iters = 2 + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, # pylint: disable=no-member + (5, 5)) + cv2.morphologyEx(mask, cv2.MORPH_OPEN, # pylint: disable=no-member + kernel, iterations=iters) + cv2.morphologyEx(mask, cv2.MORPH_CLOSE, # pylint: disable=no-member + kernel, iterations=iters) + cv2.morphologyEx(mask, cv2.MORPH_CLOSE, # pylint: disable=no-member + kernel, iterations=iters) + cv2.morphologyEx(mask, cv2.MORPH_OPEN, # pylint: disable=no-member + kernel, iterations=iters) + + # Fill holes + if fill_holes: + not_holes = mask.copy() + not_holes = np.pad(not_holes, ((2, 2), (2, 2), (0, 0)), 'constant') + cv2.floodFill(not_holes, None, (0, 0), 255) # pylint: disable=no-member + holes = cv2.bitwise_not(not_holes)[2:-2, 2:-2] # pylint: disable=no-member + mask = cv2.bitwise_or(mask, holes) # pylint: disable=no-member + mask = np.expand_dims(mask, axis=-1) + return mask diff --git a/plugins/extract/mask/components.py b/plugins/extract/mask/components.py new file mode 100644 index 0000000000..af225e006c --- /dev/null +++ b/plugins/extract/mask/components.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 + +import cv2 +import numpy as np +from ._base import Masker, logger + + +class Mask(Masker): + """ Perform transformation to align and get landmarks """ + def __init__(self, **kwargs): + git_model_id = None + model_filename = None + super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.name = "Components" + self.colorformat = "BGR" + self.vram = 0 + self.vram_warnings = 0 + self.vram_per_batch = 30 + self.batchsize = self.config["batch-size"] + + def init_model(self): + logger.debug("No mask model to initialize") + + def process_input(self, batch): + """ Compile the detected faces for prediction """ + batch["feed"] = np.array([face.image for face in batch["detected_faces"]]) + return batch + + def predict(self, batch): + """ Run model to get predictions """ + masks = np.zeros(batch["feed"].shape[:-1] + (1,), dtype='uint8') + for mask, face in zip(masks, batch["detected_faces"]): + parts = self.parse_parts(np.array(face.landmarks_xy)) + for item in parts: + item = np.concatenate(item) + hull = cv2.convexHull(item).astype('int32') # pylint: disable=no-member + cv2.fillConvexPoly(mask, hull, 255, lineType=cv2.LINE_AA) + batch["prediction"] = masks + return batch + + def process_output(self, batch): + """ Compile found faces for output """ + generator = zip(batch["feed"], batch["detected_faces"], batch["prediction"]) + for feed, face, prediction in generator: + face.image = np.concatenate((feed, prediction), axis=-1) + face.load_feed_face(face.image, + size=self.input_size, + coverage_ratio=self.coverage_ratio) + face.load_reference_face(face.image, + size=self.output_size, + coverage_ratio=self.coverage_ratio) + return batch + + @staticmethod + def parse_parts(landmarks): + """ Component facehull mask """ + r_jaw = (landmarks[0:9], landmarks[17:18]) + l_jaw = (landmarks[8:17], landmarks[26:27]) + r_cheek = (landmarks[17:20], landmarks[8:9]) + l_cheek = (landmarks[24:27], landmarks[8:9]) + nose_ridge = (landmarks[19:25], landmarks[8:9],) + r_eye = (landmarks[17:22], + landmarks[27:28], + landmarks[31:36], + landmarks[8:9]) + l_eye = (landmarks[22:27], + landmarks[27:28], + landmarks[31:36], + landmarks[8:9]) + nose = (landmarks[27:31], landmarks[31:36]) + parts = [r_jaw, l_jaw, r_cheek, l_cheek, nose_ridge, r_eye, l_eye, nose] + return parts diff --git a/plugins/extract/mask/components_defaults.py b/plugins/extract/mask/components_defaults.py new file mode 100644 index 0000000000..721ee94539 --- /dev/null +++ b/plugins/extract/mask/components_defaults.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" + The default options for the faceswap VGG clear plugin. + + Defaults files should be named _defaults.py + Any items placed into this file will automatically get added to the relevant config .ini files + within the faceswap/config folder. + + The following variables should be defined: + _HELPTEXT: A string describing what this plugin does + _DEFAULTS: A dictionary containing the options, defaults and meta information. The + dictionary should be defined as: + {: {}} + + should always be lower text. + dictionary requirements are listed below. + + The following keys are expected for the _DEFAULTS dict: + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid datatypes are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + group: [optional]. A group for grouping options together in the GUI. If not + provided this will not group this option with any others. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and datatypes this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and datatypes this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. +""" + + +_HELPTEXT = ( + "Components options. Mask designed to provide facial segmentation based on the positioning of " + "landmark locations. A convenx hull is constructed around the exterior of the landmarks to " + "create a mask." + ) + + +_DEFAULTS = { + "batch-size": { + "default": 8, + "info": "The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered." + "\n\tAMD users: A batchsize of 8 requires about xxxx GB vram.", + "datatype": int, + "rounding": 1, + "min_max": (1, 64), + "choices": [], + "gui_radio": False, + "fixed": True, + } +} diff --git a/plugins/extract/mask/extended.py b/plugins/extract/mask/extended.py new file mode 100644 index 0000000000..6e61733960 --- /dev/null +++ b/plugins/extract/mask/extended.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 + +import cv2 +import numpy as np +from ._base import Masker, logger + + +class Mask(Masker): + """ Perform transformation to align and get landmarks """ + def __init__(self, **kwargs): + git_model_id = None + model_filename = None + super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.name = "Extended" + self.colorformat = "BGR" + self.vram = 0 + self.vram_warnings = 0 + self.vram_per_batch = 30 + self.batchsize = self.config["batch-size"] + + def init_model(self): + logger.debug("No mask model to initialize") + + def process_input(self, batch): + """ Compile the detected faces for prediction """ + batch["feed"] = np.array([face.image for face in batch["detected_faces"]]) + return batch + + def predict(self, batch): + """ Run model to get predictions """ + masks = np.zeros(batch["feed"].shape[:-1] + (1,), dtype='uint8') + for mask, face in zip(masks, batch["detected_faces"]): + parts = self.parse_parts(np.array(face.landmarks_xy)) + for item in parts: + item = np.concatenate(item) + hull = cv2.convexHull(item).astype('int32') # pylint: disable=no-member + cv2.fillConvexPoly(mask, hull, 255, lineType=cv2.LINE_AA) + batch["prediction"] = masks + return batch + + def process_output(self, batch): + """ Compile found faces for output """ + generator = zip(batch["feed"], batch["detected_faces"], batch["prediction"]) + for feed, face, prediction in generator: + face.image = np.concatenate((feed, prediction), axis=-1) + face.load_feed_face(face.image, + size=self.input_size, + coverage_ratio=self.coverage_ratio) + face.load_reference_face(face.image, + size=self.output_size, + coverage_ratio=self.coverage_ratio) + return batch + + @staticmethod + def parse_parts(landmarks): + """ Extended facehull mask """ + # mid points between the side of face and eye point + ml_pnt = (landmarks[36] + landmarks[0]) // 2 + mr_pnt = (landmarks[16] + landmarks[45]) // 2 + + # mid points between the mid points and eye + ql_pnt = (landmarks[36] + ml_pnt) // 2 + qr_pnt = (landmarks[45] + mr_pnt) // 2 + + # Top of the eye arrays + bot_l = np.array((ql_pnt, landmarks[36], landmarks[37], landmarks[38], landmarks[39])) + bot_r = np.array((landmarks[42], landmarks[43], landmarks[44], landmarks[45], qr_pnt)) + + # Eyebrow arrays + top_l = landmarks[17:22] + top_r = landmarks[22:27] + + # Adjust eyebrow arrays + landmarks[17:22] = top_l + ((top_l - bot_l) // 2) + landmarks[22:27] = top_r + ((top_r - bot_r) // 2) + + r_jaw = (landmarks[0:9], landmarks[17:18]) + l_jaw = (landmarks[8:17], landmarks[26:27]) + r_cheek = (landmarks[17:20], landmarks[8:9]) + l_cheek = (landmarks[24:27], landmarks[8:9]) + nose_ridge = (landmarks[19:25], landmarks[8:9],) + r_eye = (landmarks[17:22], + landmarks[27:28], + landmarks[31:36], + landmarks[8:9]) + l_eye = (landmarks[22:27], + landmarks[27:28], + landmarks[31:36], + landmarks[8:9]) + nose = (landmarks[27:31], landmarks[31:36]) + parts = [r_jaw, l_jaw, r_cheek, l_cheek, nose_ridge, r_eye, l_eye, nose] + return parts diff --git a/plugins/extract/mask/extended_defaults.py b/plugins/extract/mask/extended_defaults.py new file mode 100644 index 0000000000..f85996eaa4 --- /dev/null +++ b/plugins/extract/mask/extended_defaults.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" + The default options for the faceswap extended mask plugin. + + Defaults files should be named _defaults.py + Any items placed into this file will automatically get added to the relevant config .ini files + within the faceswap/config folder. + + The following variables should be defined: + _HELPTEXT: A string describing what this plugin does + _DEFAULTS: A dictionary containing the options, defaults and meta information. The + dictionary should be defined as: + {: {}} + + should always be lower text. + dictionary requirements are listed below. + + The following keys are expected for the _DEFAULTS dict: + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid datatypes are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + group: [optional]. A group for grouping options together in the GUI. If not + provided this will not group this option with any others. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and datatypes this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and datatypes this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. +""" + + +_HELPTEXT = ( + "Extended options. Mask designed to provide facial segmentation based on the positioning of " + "landmark locations. A convenx hull is constructed around the landmarks and the mask is " + "extended upwards onto the forehead." + ) + + +_DEFAULTS = { + "batch-size": { + "default": 8, + "info": "The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered." + "\n\tAMD users: A batchsize of 8 requires about xxxx GB vram.", + "datatype": int, + "rounding": 1, + "min_max": (1, 64), + "choices": [], + "gui_radio": False, + "fixed": True, + } +} diff --git a/plugins/extract/mask/none.py b/plugins/extract/mask/none.py new file mode 100644 index 0000000000..7ba4403cf5 --- /dev/null +++ b/plugins/extract/mask/none.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 + +import numpy as np +from ._base import Masker, logger + + +class Mask(Masker): + """ Perform transformation to align and get landmarks """ + def __init__(self, **kwargs): + git_model_id = None + model_filename = None + super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.name = "without a mask" + self.colorformat = "BGR" + self.vram = 0 + self.vram_warnings = 0 + self.vram_per_batch = 30 + self.batchsize = self.config["batch-size"] + + def init_model(self): + logger.debug("No mask model to initialize") + + def process_input(self, batch): + """ Compile the detected faces for prediction """ + batch["feed"] = np.array([face.image for face in batch["detected_faces"]]) + return batch + + def predict(self, batch): + """ Run model to get predictions """ + batch["prediction"] = np.full(batch["feed"].shape[:-1] + (1,), + fill_value=255, + dtype='uint8') + return batch + + def process_output(self, batch): + """ Compile found faces for output """ + generator = zip(batch["feed"], batch["detected_faces"], batch["prediction"]) + for feed, face, prediction in generator: + face.image = np.concatenate((feed, prediction), axis=-1) + face.load_feed_face(face.image, + size=self.input_size, + coverage_ratio=self.coverage_ratio) + face.load_reference_face(face.image, + size=self.output_size, + coverage_ratio=self.coverage_ratio) + return batch diff --git a/plugins/extract/mask/none_defaults.py b/plugins/extract/mask/none_defaults.py new file mode 100644 index 0000000000..8a97a6e09d --- /dev/null +++ b/plugins/extract/mask/none_defaults.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +""" + The default options for the faceswap VGG clear plugin. + + Defaults files should be named _defaults.py + Any items placed into this file will automatically get added to the relevant config .ini files + within the faceswap/config folder. + + The following variables should be defined: + _HELPTEXT: A string describing what this plugin does + _DEFAULTS: A dictionary containing the options, defaults and meta information. The + dictionary should be defined as: + {: {}} + + should always be lower text. + dictionary requirements are listed below. + + The following keys are expected for the _DEFAULTS dict: + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid datatypes are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + group: [optional]. A group for grouping options together in the GUI. If not + provided this will not group this option with any others. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and datatypes this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and datatypes this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. +""" + + +_HELPTEXT = ( + "None options. An array of all ones is created to provide a 4th channel that will not mask " + "any portion of the image." + ) + + +_DEFAULTS = { + "batch-size": { + "default": 8, + "info": "The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered." + "\n\tAMD users: A batchsize of 8 requires about xxxx GB vram.", + "datatype": int, + "rounding": 1, + "min_max": (1, 64), + "choices": [], + "gui_radio": False, + "fixed": True, + } +} diff --git a/plugins/extract/mask/unet_dfl.py b/plugins/extract/mask/unet_dfl.py new file mode 100644 index 0000000000..9e2151fad3 --- /dev/null +++ b/plugins/extract/mask/unet_dfl.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 + +import cv2 +import keras +import numpy as np +from lib.model.session import KSession +from ._base import Masker, logger + + +class Mask(Masker): + """ Perform transformation to align and get landmarks """ + def __init__(self, **kwargs): + git_model_id = 6 + model_filename = "DFL_256_sigmoid_v1.h5" + super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.name = "U-Net Mask Network(256)" + self.mask_in_size = 256 + self.colorformat = "BGR" + self.vram = 3440 + self.vram_warnings = 1024 # TODO determine + self.vram_per_batch = 64 # TODO determine + self.batchsize = self.config["batch-size"] + + def init_model(self): + self.model = KSession(self.name, self.model_path, model_kwargs=dict()) + self.model.load_model() + self.input = np.zeros((self.batchsize, self.mask_in_size, self.mask_in_size, 3), + dtype="float32") + self.model.predict(self.input) + + def process_input(self, batch): + """ Compile the detected faces for prediction """ + for index, face in enumerate(batch["detected_faces"]): + face.load_aligned(face.image, + size=self.mask_in_size, + dtype='float32') + self.input[index] = face.aligned["face"][..., :3] + batch["feed"] = self.input / 255. + return batch + + def predict(self, batch): + """ Run model to get predictions """ + predictions = self.model.predict(batch["feed"]) + batch["prediction"] = predictions * 255. + return batch + + def process_output(self, batch): + """ Compile found faces for output """ + for idx, (face, predicts) in enumerate(zip(batch["detected_faces"], batch["prediction"])): + generator = (cv2.GaussianBlur(mask, (7, 7), 0) for mask in predicts) + predicted = np.array(tuple(generator)) + predicted[predicted < 10.] = 0. + predicted[predicted > 245.] = 255. + + face.load_feed_face(face.image, + size=self.input_size, + coverage_ratio=self.coverage_ratio) + feed_face = face.feed["face"][..., :3] + feed_mask = self._resize(predicted, self.input_size).astype('uint8') + batch["detected_faces"][idx].feed["face"] = np.concatenate((feed_face, + feed_mask), + axis=-1) + face.load_reference_face(face.image, + size=self.output_size, + coverage_ratio=self.coverage_ratio) + ref_face = face.reference["face"][..., :3] + ref_mask = self._resize(predicted, self.output_size).astype('uint8') + batch["detected_faces"][idx].reference["face"] = np.concatenate((ref_face, + ref_mask), + axis=-1) + return batch diff --git a/plugins/extract/mask/unet_dfl_defaults.py b/plugins/extract/mask/unet_dfl_defaults.py new file mode 100644 index 0000000000..c153920c89 --- /dev/null +++ b/plugins/extract/mask/unet_dfl_defaults.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" + The default options for the faceswap UNET dfl plugin. + + Defaults files should be named _defaults.py + Any items placed into this file will automatically get added to the relevant config .ini files + within the faceswap/config folder. + + The following variables should be defined: + _HELPTEXT: A string describing what this plugin does + _DEFAULTS: A dictionary containing the options, defaults and meta information. The + dictionary should be defined as: + {: {}} + + should always be lower text. + dictionary requirements are listed below. + + The following keys are expected for the _DEFAULTS dict: + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid datatypes are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + group: [optional]. A group for grouping options together in the GUI. If not + provided this will not group this option with any others. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and datatypes this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and datatypes this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. +""" + + +_HELPTEXT = ( + "UNET_DFL options. Mask designed to provide smart segmentation of mostly frontal faces. " + "The mask model has been trained by community members. Insert more commentary on testing " + "here. Profile faces may result in sub-par performance." + ) + + +_DEFAULTS = { + "batch-size": { + "default": 8, + "info": "The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered." + "\n\tAMD users: A batchsize of 8 requires about xxxx GB vram.", + "datatype": int, + "rounding": 1, + "min_max": (1, 64), + "choices": [], + "gui_radio": False, + "fixed": True, + } +} diff --git a/plugins/extract/mask/vgg_clear.py b/plugins/extract/mask/vgg_clear.py new file mode 100644 index 0000000000..5ee02a83c9 --- /dev/null +++ b/plugins/extract/mask/vgg_clear.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 + +import cv2 +import keras +import numpy as np +from lib.model.session import KSession +from ._base import Masker, logger + + +class Mask(Masker): + """ Perform transformation to align and get landmarks """ + def __init__(self, **kwargs): + git_model_id = 8 + model_filename = "Nirkin_300_softmax_v1.h5" + super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.name = "VGG Mask Network(300)" + self.mask_in_size = 300 + self.colorformat = "BGR" + self.vram = 2000 # TODO determine + self.vram_warnings = 1024 # TODO determine + self.vram_per_batch = 64 # TODO determine + self.batchsize = self.config["batch-size"] + + def init_model(self): + self.model = KSession(self.name, self.model_path, model_kwargs=dict()) + self.model.load_model() + o = keras.layers.core.Activation('softmax', + name='softmax')(self.model._model.layers[-1].output) + self.model._model = keras.models.Model(inputs=self.model._model.input, outputs=[o]) + self.input = np.zeros((self.batchsize, self.mask_in_size, self.mask_in_size, 3), + dtype="float32") + self.model.predict(self.input) + + def process_input(self, batch): + """ Compile the detected faces for prediction """ + for index, face in enumerate(batch["detected_faces"]): + face.load_aligned(face.image, + size=self.mask_in_size, + dtype='float32') + self.input[index] = face.aligned["face"][..., :3] + batch["feed"] = self.input - np.mean(self.input, axis=(1, 2))[:, None, None, :] + return batch + + def predict(self, batch): + """ Run model to get predictions """ + predictions = self.model.predict(batch["feed"]) + batch["prediction"] = predictions[..., 1:2] * 255. + return batch + + def process_output(self, batch): + """ Compile found faces for output """ + for idx, (face, predicts) in enumerate(zip(batch["detected_faces"], batch["prediction"])): + generator = (cv2.GaussianBlur(mask, (7, 7), 0) for mask in predicts) + predicted = np.array(tuple(generator)) + predicted[predicted < 10.] = 0. + predicted[predicted > 245.] = 255. + + face.load_feed_face(face.image, + size=self.input_size, + coverage_ratio=self.coverage_ratio) + feed_face = face.feed["face"][..., :3] + feed_mask = self._resize(predicted, self.input_size).astype('uint8') + batch["detected_faces"][idx].feed["face"] = np.concatenate((feed_face, + feed_mask), + axis=-1) + face.load_reference_face(face.image, + size=self.output_size, + coverage_ratio=self.coverage_ratio) + ref_face = face.reference["face"][..., :3] + ref_mask = self._resize(predicted, self.output_size).astype('uint8') + batch["detected_faces"][idx].reference["face"] = np.concatenate((ref_face, + ref_mask), + axis=-1) + return batch diff --git a/plugins/extract/mask/vgg_clear_defaults.py b/plugins/extract/mask/vgg_clear_defaults.py new file mode 100644 index 0000000000..5b120694ae --- /dev/null +++ b/plugins/extract/mask/vgg_clear_defaults.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +""" + The default options for the faceswap VGG clear plugin. + + Defaults files should be named _defaults.py + Any items placed into this file will automatically get added to the relevant config .ini files + within the faceswap/config folder. + + The following variables should be defined: + _HELPTEXT: A string describing what this plugin does + _DEFAULTS: A dictionary containing the options, defaults and meta information. The + dictionary should be defined as: + {: {}} + + should always be lower text. + dictionary requirements are listed below. + + The following keys are expected for the _DEFAULTS dict: + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid datatypes are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + group: [optional]. A group for grouping options together in the GUI. If not + provided this will not group this option with any others. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and datatypes this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and datatypes this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. +""" + + +_HELPTEXT = ( + "VGG_Clear options. Mask designed to provide smart segmentation of mostly frontal faces clear " + "of obstructions. Profile faces and obstructions may result in sub-par performance." + ) + + +_DEFAULTS = { + "batch-size": { + "default": 8, + "info": "The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered." + "\n\tAMD users: A batchsize of 8 requires about xxxx GB vram.", + "datatype": int, + "rounding": 1, + "min_max": (1, 64), + "choices": [], + "gui_radio": False, + "fixed": True, + } +} diff --git a/plugins/extract/mask/vgg_obstructed.py b/plugins/extract/mask/vgg_obstructed.py new file mode 100644 index 0000000000..85ae018b32 --- /dev/null +++ b/plugins/extract/mask/vgg_obstructed.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 + +import cv2 +import keras +import numpy as np +from lib.model.session import KSession +from ._base import Masker, logger + + +class Mask(Masker): + """ Perform transformation to align and get landmarks """ + def __init__(self, **kwargs): + git_model_id = 5 + model_filename = "Nirkin_500_softmax_v1.h5" + super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.name = "VGG Mask Network(500)" + self.mask_in_size = 500 + self.colorformat = "BGR" + self.vram = 3000 # TODO determine + self.vram_warnings = 1024 # TODO determine + self.vram_per_batch = 64 # TODO determine + self.batchsize = self.config["batch-size"] + + def init_model(self): + self.model = KSession(self.name, self.model_path, model_kwargs=dict()) + self.model.load_model() + o = keras.layers.core.Activation('softmax', + name='softmax')(self.model._model.layers[-1].output) + self.model._model = keras.models.Model(inputs=self.model._model.input, outputs=[o]) + self.input = np.zeros((self.batchsize, self.mask_in_size, self.mask_in_size, 3), + dtype="float32") + self.model.predict(self.input) + + def process_input(self, batch): + """ Compile the detected faces for prediction """ + for index, face in enumerate(batch["detected_faces"]): + face.load_aligned(face.image, + size=self.mask_in_size, + dtype='float32') + self.input[index] = face.aligned["face"][..., :3] + batch["feed"] = self.input - np.mean(self.input, axis=(1, 2))[:, None, None, :] + return batch + + def predict(self, batch): + """ Run model to get predictions """ + predictions = self.model.predict(batch["feed"]) + batch["prediction"] = predictions[..., 0:1] * -255. + 255. + return batch + + def process_output(self, batch): + """ Compile found faces for output """ + for idx, (face, predicts) in enumerate(zip(batch["detected_faces"], batch["prediction"])): + generator = (cv2.GaussianBlur(mask, (7, 7), 0) for mask in predicts) + predicted = np.array(tuple(generator)) + predicted[predicted < 10.] = 0. + predicted[predicted > 245.] = 255. + + face.load_feed_face(face.image, + size=self.input_size, + coverage_ratio=self.coverage_ratio) + feed_face = face.feed["face"][..., :3] + feed_mask = self._resize(predicted, self.input_size).astype('uint8') + batch["detected_faces"][idx].feed["face"] = np.concatenate((feed_face, + feed_mask), + axis=-1) + face.load_reference_face(face.image, + size=self.output_size, + coverage_ratio=self.coverage_ratio) + ref_face = face.reference["face"][..., :3] + ref_mask = self._resize(predicted, self.output_size).astype('uint8') + batch["detected_faces"][idx].reference["face"] = np.concatenate((ref_face, + ref_mask), + axis=-1) + return batch diff --git a/plugins/extract/mask/vgg_obstructed_defaults.py b/plugins/extract/mask/vgg_obstructed_defaults.py new file mode 100644 index 0000000000..d1ed3bfbbb --- /dev/null +++ b/plugins/extract/mask/vgg_obstructed_defaults.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" + The default options for the faceswap VGG obstructed plugin. + + Defaults files should be named _defaults.py + Any items placed into this file will automatically get added to the relevant config .ini files + within the faceswap/config folder. + + The following variables should be defined: + _HELPTEXT: A string describing what this plugin does + _DEFAULTS: A dictionary containing the options, defaults and meta information. The + dictionary should be defined as: + {: {}} + + should always be lower text. + dictionary requirements are listed below. + + The following keys are expected for the _DEFAULTS dict: + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid datatypes are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + group: [optional]. A group for grouping options together in the GUI. If not + provided this will not group this option with any others. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and datatypes this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and datatypes this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. +""" + + +_HELPTEXT = ( + "VGG_Obstructed options. 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." + ) + + +_DEFAULTS = { + "batch-size": { + "default": 8, + "info": "The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered." + "\n\tAMD users: A batchsize of 8 requires about xxxx GB vram.", + "datatype": int, + "rounding": 1, + "min_max": (1, 64), + "choices": [], + "gui_radio": False, + "fixed": True, + } +} diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index bc31147058..14293afcbe 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -57,18 +57,25 @@ class Extractor(): The current phase that the pipeline is running. Used in conjunction with :attr:`passes` and :attr:`final_pass` to indicate to the caller which phase is being processed """ - def __init__(self, detector, aligner, - configfile=None, multiprocess=False, rotate_images=None, min_size=20, - normalize_method=None): - logger.debug("Initializing %s: (detector: %s, aligner: %s, configfile: %s, " - "multiprocess: %s, rotate_images: %s, min_size: %s, " - "normalize_method: %s)", self.__class__.__name__, detector, aligner, - configfile, multiprocess, rotate_images, min_size, normalize_method) + def __init__(self, detector, aligner, masker, configfile=None, + multiprocess=False, rotate_images=None, min_size=20, + normalize_method=None, input_size=256, output_size=256, coverage_ratio=1.): + logger.debug("Initializing %s: (detector: %s, aligner: %s, masker: %s, " + "configfile: %s, multiprocess: %s, rotate_images: %s, min_size: %s, " + "normalize_method: %s, input_size: %s, output_size: %s, coverage_ratio: %s)", + self.__class__.__name__, detector, aligner, masker, configfile, + multiprocess, rotate_images, min_size, normalize_method, input_size, + output_size, coverage_ratio) self.phase = "detect" self._queue_size = 32 self._vram_buffer = 320 # Leave a buffer for VRAM allocation self._detector = self._load_detector(detector, rotate_images, min_size, configfile) self._aligner = self._load_aligner(aligner, configfile, normalize_method) + self._masker = self._load_masker(masker, + configfile, + input_size, + output_size, + coverage_ratio) self._is_parallel = self._set_parallel_processing(multiprocess) self._set_extractor_batchsize() self._queues = self._add_queues() @@ -93,10 +100,10 @@ def input_queue(self): >>> 'detected_faces: []} """ - if self._is_parallel or self.phase == "detect": - qname = "extract_detect_in" - else: - qname = "extract_align_in" + qname_dict = dict(detect="extract_detect_in", + align="extract_align_in", + mask="extract_mask_in") + qname = "extract_detect_in" if self._is_parallel else qname_dict[self.phase] retval = self._queues[qname] logger.trace("%s: %s", qname, retval) return retval @@ -120,7 +127,7 @@ def passes(self): >>> "image": np.array(image), >>> "detected_faces": [>> "image": np.array(image), >>> "detected_faces": [>> # @property def _output_queue(self): """ Return the correct output queue depending on the current phase """ - qname = "extract_align_out" if self.final_pass else "extract_align_in" + qname_dict = dict(detect="extract_align_in", + align="extract_mask_in", + mask="extract_mask_out") + qname = "extract_mask_out" if self.final_pass else qname_dict[self.phase] retval = self._queues[qname] logger.trace("%s: %s", qname, retval) return retval @@ -243,18 +256,23 @@ def _output_queue(self): def _active_plugins(self): """ Return the plugins that are currently active based on pass """ if self.passes == 1: - retval = [self._detector, self._aligner] - elif self.passes == 2 and not self.final_pass: + retval = [self._detector, self._aligner, self._masker] + elif self.passes == 3 and self.phase == 'detect': retval = [self._detector] - else: + elif self.passes == 3 and self.phase == 'align': retval = [self._aligner] + elif self.passes == 3 and self.phase == 'mask': + retval = [self._masker] + else: + retval = [None] logger.trace("Active plugins: %s", retval) return retval def _add_queues(self): """ Add the required processing queues to Queue Manager """ queues = dict() - for task in ("extract_detect_in", "extract_align_in", "extract_align_out"): + tasks = ["extract_detect_in", "extract_align_in", "extract_mask_in", "extract_mask_out"] + for task in tasks: # Limit queue size to avoid stacking ram self._queue_size = 32 if task == "extract_detect_in" or (not self._is_parallel @@ -266,11 +284,7 @@ def _add_queues(self): return queues def _set_parallel_processing(self, multiprocess): - """ Set whether to run detect and align together or separately """ - if self._detector.vram == 0 or self._aligner.vram == 0: - logger.debug("At least one of aligner or detector have no VRAM requirement. " - "Enabling parallel processing.") - return True + """ Set whether to run detect, align, and mask together or separately """ if not multiprocess: logger.debug("Parallel processing disabled by cli.") @@ -285,7 +299,10 @@ def _set_parallel_processing(self, multiprocess): logger.debug("Parallel processing discabled by amd") return False - vram_required = self._detector.vram + self._aligner.vram + self._vram_buffer + vram_required = (self._detector.vram + + self._aligner.vram + + self._masker.vram + + self._vram_buffer) stats = gpu_stats.get_card_most_free() vram_free = int(stats["free"]) logger.verbose("%s - %sMB free of %sMB", @@ -318,14 +335,16 @@ def _load_aligner(aligner, configfile, normalize_method): normalize_method=normalize_method) return aligner - def _launch_aligner(self): - """ Launch the face aligner """ - logger.debug("Launching Aligner") - kwargs = dict(in_queue=self._queues["extract_align_in"], - out_queue=self._queues["extract_align_out"]) - self._aligner.initialize(**kwargs) - self._aligner.start() - logger.debug("Launched Aligner") + @staticmethod + def _load_masker(masker, configfile, input_size, output_size, coverage_ratio): + """ Set global arguments and load masker plugin """ + masker_name = masker.replace("-", "_").lower() + logger.debug("Loading Masker: '%s'", masker_name) + masker = PluginLoader.get_masker(masker_name)(configfile=configfile, + input_size=input_size, + output_size=output_size, + coverage_ratio=coverage_ratio) + return masker def _launch_detector(self): """ Launch the face detector """ @@ -336,31 +355,54 @@ def _launch_detector(self): self._detector.start() logger.debug("Launched Detector") + def _launch_aligner(self): + """ Launch the face aligner """ + logger.debug("Launching Aligner") + kwargs = dict(in_queue=self._queues["extract_align_in"], + out_queue=self._queues["extract_mask_in"]) + self._aligner.initialize(**kwargs) + self._aligner.start() + logger.debug("Launched Aligner") + + def _launch_masker(self): + """ Launch the face masker """ + logger.debug("Launching Masker") + kwargs = dict(in_queue=self._queues["extract_mask_in"], + out_queue=self._queues["extract_mask_out"]) + self._masker.initialize(**kwargs) + self._masker.start() + logger.debug("Launched Masker") + def _set_extractor_batchsize(self): - """ Sets the batchsize of the requested plugins based on their vram and - vram_per_batch_requirements if the the configured batchsize requires more - vram than is available. Nvidia only. """ - if (self._detector.vram == 0 and self._aligner.vram == 0) or get_backend() != "nvidia": + """ + Sets the batchsize of the requested plugins based on their vram and + vram_per_batch_requirements if the the configured batchsize requires more + vram than is available. Nvidia only. + """ + if (self._detector.vram == 0 and self._aligner.vram == 0 and self._masker.vram == 0 + or get_backend() != "nvidia"): logger.debug("Either detector and aligner have no VRAM requirements or not running " "on Nvidia. Not updating batchsize requirements.") return stats = GPUStats().get_card_most_free() vram_free = int(stats["free"]) if self._is_parallel: - vram_required = self._detector.vram + self._aligner.vram + self._vram_buffer - batch_required = ((self._aligner.vram_per_batch * self._aligner.batchsize) + - (self._detector.vram_per_batch * self._detector.batchsize)) + vram_required = (self._detector.vram + self._aligner.vram + self._masker.vram + + self._vram_buffer) + batch_required = ((self._detector.vram_per_batch * self._detector.batchsize) + + (self._aligner.vram_per_batch * self._aligner.batchsize) + + (self._masker.vram_per_batch * self._masker.batchsize)) plugin_required = vram_required + batch_required if plugin_required <= vram_free: logger.debug("Plugin requirements within threshold: (plugin_required: %sMB, " "vram_free: %sMB)", plugin_required, vram_free) return - # Hacky split across 2 plugins - available_vram = (vram_free - vram_required) // 2 - for plugin in (self._aligner, self._detector): + # Hacky split across 3 plugins + available_vram = (vram_free - vram_required) // 3 + for plugin in (self._detector, self._aligner, self._masker): self._set_plugin_batchsize(plugin, available_vram) else: - for plugin in (self._aligner, self._detector): + for plugin in (self._detector, self._aligner, self._masker): vram_required = plugin.vram + self._vram_buffer batch_required = plugin.vram_per_batch * plugin.batchsize plugin_required = vram_required + batch_required diff --git a/plugins/extract/recognition/.cache/.keep b/plugins/extract/recognition/.cache/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 75d8655b04..0973c5083f 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -1,94 +1,99 @@ -#!/usr/bin/env python3 -""" Plugin loader for extract, training and model tasks """ - -import logging -import os -from importlib import import_module - -logger = logging.getLogger(__name__) # pylint: disable=invalid-name - - -class PluginLoader(): - """ Plugin loader for extract, training and model tasks """ - @staticmethod - def get_detector(name, disable_logging=False): - """ Return requested detector plugin """ - return PluginLoader._import("extract.detect", name, disable_logging) - - @staticmethod - def get_aligner(name, disable_logging=False): - """ Return requested detector plugin """ - return PluginLoader._import("extract.align", name, disable_logging) - - @staticmethod - def get_model(name, disable_logging=False): - """ Return requested model plugin """ - return PluginLoader._import("train.model", name, disable_logging) - - @staticmethod - def get_trainer(name, disable_logging=False): - """ Return requested trainer plugin """ - return PluginLoader._import("train.trainer", name, disable_logging) - - @staticmethod - def get_converter(category, name, disable_logging=False): - """ Return the converter sub plugin """ - return PluginLoader._import("convert.{}".format(category), name, disable_logging) - - @staticmethod - def _import(attr, name, disable_logging): - """ Import the plugin's module """ - name = name.replace("-", "_") - ttl = attr.split(".")[-1].title() - if not disable_logging: - logger.info("Loading %s from %s plugin...", ttl, name.title()) - attr = "model" if attr == "Trainer" else attr.lower() - mod = ".".join(("plugins", attr, name)) - module = import_module(mod) - return getattr(module, ttl) - - @staticmethod - def get_available_extractors(extractor_type): - """ Return a list of available aligners/detectors """ - extractpath = os.path.join(os.path.dirname(__file__), - "extract", - extractor_type) - extractors = sorted(item.name.replace(".py", "").replace("_", "-") - for item in os.scandir(extractpath) - if not item.name.startswith("_") - and not item.name.endswith("defaults.py") - and item.name.endswith(".py") - and item.name != "manual.py") - return extractors - - @staticmethod - def get_available_models(): - """ Return a list of available models """ - modelpath = os.path.join(os.path.dirname(__file__), "train", "model") - models = sorted(item.name.replace(".py", "").replace("_", "-") - for item in os.scandir(modelpath) - if not item.name.startswith("_") - and not item.name.endswith("defaults.py") - and item.name.endswith(".py")) - return models - - @staticmethod - def get_default_model(): - """ Return the default model """ - models = PluginLoader.get_available_models() - return 'original' if 'original' in models else models[0] - - @staticmethod - def get_available_convert_plugins(convert_category, add_none=True): - """ Return a list of available models """ - convertpath = os.path.join(os.path.dirname(__file__), - "convert", - convert_category) - converters = sorted(item.name.replace(".py", "").replace("_", "-") - for item in os.scandir(convertpath) - if not item.name.startswith("_") - and not item.name.endswith("defaults.py") - and item.name.endswith(".py")) - if add_none: - converters.insert(0, "none") - return converters +#!/usr/bin/env python3 +""" Plugin loader for extract, training and model tasks """ + +import logging +import os +from importlib import import_module + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class PluginLoader(): + """ Plugin loader for extract, training and model tasks """ + @staticmethod + def get_detector(name, disable_logging=False): + """ Return requested detector plugin """ + return PluginLoader._import("extract.detect", name, disable_logging) + + @staticmethod + def get_aligner(name, disable_logging=False): + """ Return requested detector plugin """ + return PluginLoader._import("extract.align", name, disable_logging) + + @staticmethod + def get_masker(name, disable_logging=False): + """ Return requested detector plugin """ + return PluginLoader._import("extract.mask", name, disable_logging) + + @staticmethod + def get_model(name, disable_logging=False): + """ Return requested model plugin """ + return PluginLoader._import("train.model", name, disable_logging) + + @staticmethod + def get_trainer(name, disable_logging=False): + """ Return requested trainer plugin """ + return PluginLoader._import("train.trainer", name, disable_logging) + + @staticmethod + def get_converter(category, name, disable_logging=False): + """ Return the converter sub plugin """ + return PluginLoader._import("convert.{}".format(category), name, disable_logging) + + @staticmethod + def _import(attr, name, disable_logging): + """ Import the plugin's module """ + name = name.replace("-", "_") + ttl = attr.split(".")[-1].title() + if not disable_logging: + logger.info("Loading %s from %s plugin...", ttl, name.title()) + attr = "model" if attr == "Trainer" else attr.lower() + mod = ".".join(("plugins", attr, name)) + module = import_module(mod) + return getattr(module, ttl) + + @staticmethod + def get_available_extractors(extractor_type): + """ Return a list of available aligners/detectors """ + extractpath = os.path.join(os.path.dirname(__file__), + "extract", + extractor_type) + extractors = sorted(item.name.replace(".py", "").replace("_", "-") + for item in os.scandir(extractpath) + if not item.name.startswith("_") + and not item.name.endswith("defaults.py") + and item.name.endswith(".py") + and item.name != "manual.py") + return extractors + + @staticmethod + def get_available_models(): + """ Return a list of available models """ + modelpath = os.path.join(os.path.dirname(__file__), "train", "model") + models = sorted(item.name.replace(".py", "").replace("_", "-") + for item in os.scandir(modelpath) + if not item.name.startswith("_") + and not item.name.endswith("defaults.py") + and item.name.endswith(".py")) + return models + + @staticmethod + def get_default_model(): + """ Return the default model """ + models = PluginLoader.get_available_models() + return 'original' if 'original' in models else models[0] + + @staticmethod + def get_available_convert_plugins(convert_category, add_none=True): + """ Return a list of available models """ + convertpath = os.path.join(os.path.dirname(__file__), + "convert", + convert_category) + converters = sorted(item.name.replace(".py", "").replace("_", "-") + for item in os.scandir(convertpath) + if not item.name.startswith("_") + and not item.name.endswith("defaults.py") + and item.name.endswith(".py")) + if add_none: + converters.insert(0, "none") + return converters diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 31d1dd578b..14db591c20 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -8,7 +8,6 @@ from importlib import import_module from lib.config import FaceswapConfig -from lib.model.masks import get_available_masks from lib.utils import full_path_split logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -69,15 +68,11 @@ def set_globals(self): "\n\t87.5%% spans from ear to ear." "\n\t100.0%% is a mugshot.") self.add_item( - section=section, title="mask_type", datatype=str, default="none", - choices=get_available_masks(), group="mask", - info="The mask to be used for training:" - "\n\t none: Doesn't use any mask." - "\n\t components: An improved face hull mask using a facehull of 8 facial parts" - "\n\t dfl_full: An improved face hull mask using a facehull of 3 facial parts" - "\n\t extended: Based on components mask. Extends the eyebrow points to further " - "up the forehead. May perform badly on difficult angles." - "\n\t facehull: Face cutout based on landmarks") + section=section, title="replicate_input_mask", datatype=bool, + default=False, group="mask", + info="Dedicate portions of the model to learning how to duplicate the input " + "mask. Increases VRAM usage in exchange for a learning a quick ability " + "to try to replicate more complex mask models.") self.add_item( section=section, title="mask_blur", datatype=bool, default=False, group="mask", info="Apply gaussian blur to the mask input. This has the effect of smoothing the " diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 954b238278..0401218126 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -106,7 +106,10 @@ def __init__(self, "augment_color": augment_color, "no_flip": no_flip, "pingpong": self.vram_savings.pingpong, - "snapshot_interval": snapshot_interval} + "snapshot_interval": snapshot_interval, + "replicate_input_mask": self.config["replicate_input_mask"], + "penalized_mask_loss": self.config["penalized_mask_loss"]} + if self.multiple_models_in_folder: deprecation_warning("Support for multiple model types within the same folder", @@ -223,7 +226,6 @@ def set_training_data(self): # Force number of preview images to between 2 and 16 self.training_opts["training_size"] = self.state.training_size self.training_opts["no_logs"] = self.state.current_session["no_logs"] - self.training_opts["mask_type"] = self.config.get("mask_type", None) self.training_opts["coverage_ratio"] = self.calculate_coverage_ratio() logger.debug("Set training data: %s", self.training_opts) @@ -261,12 +263,11 @@ def get_inputs(self): logger.debug("Getting inputs") inputs = [Input(shape=self.input_shape, name="face_in")] output_network = [network for network in self.networks.values() if network.is_output][0] - mask_idx = [idx for idx, name in enumerate(output_network.output_names) - if name.startswith("mask")] - if mask_idx: - # Add the final mask shape as input - mask_shape = output_network.output_shapes[mask_idx[0]] - inputs.append(Input(shape=mask_shape[1:], name="mask_in")) + if self.config["replicate_input_mask"] or self.config["penalized_mask_loss"]: + # penalized mask doesn't have a mask ouput, so we can't use output shapes + # mask should always be last output..this needs to be a rule + mask_shape = output_network.output_shapes[-1] + inputs.append(Input(shape=(mask_shape[1:-1] + (1,)), name="mask_in")) logger.debug("Got inputs: %s", inputs) return inputs @@ -445,7 +446,7 @@ def load_models(self, swapped): logger.error("Model could not be found in folder '%s'. Exiting", self.model_dir) exit(0) - if not self.is_legacy: + if not self.is_legacy or not self.predict: K.clear_session() model_mapping = self.map_models(swapped) for network in self.networks.values(): @@ -579,7 +580,7 @@ def rename_legacy(self): self.state.config["coverage"] = 62.5 self.state.config["subpixel_upscaling"] = False self.state.config["reflect_padding"] = False - self.state.config["mask_type"] = None + self.state.config["replicate_input_mask"] = False self.state.config["lowmem"] = False self.encoder_dim = 1024 @@ -744,7 +745,7 @@ def get_loss_functions(self): for idx, loss_name in enumerate(self.names): if loss_name.startswith("mask"): loss_funcs.append(self.selected_mask_loss) - elif self.mask_input is not None and self.config.get("penalized_mask_loss", False): + elif self.config["penalized_mask_loss"]: face_size = self.output_shapes[idx][1] mask_size = self.mask_shape[1] scaling = face_size / mask_size diff --git a/plugins/train/model/dfaker.py b/plugins/train/model/dfaker.py index 758c43b6d0..9f41e6d43a 100644 --- a/plugins/train/model/dfaker.py +++ b/plugins/train/model/dfaker.py @@ -40,7 +40,7 @@ def decoder(self): name="face_out") outputs = [var_x] - if self.config.get("mask_type", None): + if self.config.get("replicate_input_mask", False): var_y = input_ var_y = self.blocks.upscale(var_y, 512) var_y = self.blocks.upscale(var_y, 256) diff --git a/plugins/train/model/dfl_h128.py b/plugins/train/model/dfl_h128.py index 6bf9a6ffd6..78e140b703 100644 --- a/plugins/train/model/dfl_h128.py +++ b/plugins/train/model/dfl_h128.py @@ -50,8 +50,8 @@ def decoder(self): activation="sigmoid", name="face_out") outputs = [var_x] - # Mask - if self.config.get("mask_type", None): + + if self.config.get("replicate_input_mask", False): var_y = input_ var_y = self.blocks.upscale(var_y, self.encoder_dim) var_y = self.blocks.upscale(var_y, self.encoder_dim // 2) diff --git a/plugins/train/model/dfl_sae.py b/plugins/train/model/dfl_sae.py index e79822c2cf..fd9ad53bb4 100644 --- a/plugins/train/model/dfl_sae.py +++ b/plugins/train/model/dfl_sae.py @@ -31,7 +31,7 @@ def architecture(self): @property def use_mask(self): """ Return True if a mask has been set else false """ - return self.config.get("mask_type", None) is not None + return self.config.get("replicate_input_mask", False) @property def ae_dims(self): diff --git a/plugins/train/model/iae.py b/plugins/train/model/iae.py index b164fef680..4f1c1e8889 100644 --- a/plugins/train/model/iae.py +++ b/plugins/train/model/iae.py @@ -77,7 +77,7 @@ def decoder(self): name="face_out") outputs = [var_x] - if self.config.get("mask_type", None): + if self.config.get("replicate_input_mask", False): var_y = input_ var_y = self.blocks.upscale(var_y, 512) var_y = self.blocks.upscale(var_y, 256) diff --git a/plugins/train/model/lightweight.py b/plugins/train/model/lightweight.py index 1963c8c1b3..44f20922b5 100644 --- a/plugins/train/model/lightweight.py +++ b/plugins/train/model/lightweight.py @@ -47,7 +47,7 @@ def decoder(self): name="face_out") outputs = [var_x] - if self.config.get("mask_type", None): + if self.config.get("replicate_input_mask", False): var_y = input_ var_y = self.blocks.upscale(var_y, 512) var_y = self.blocks.upscale(var_y, 256) diff --git a/plugins/train/model/original.py b/plugins/train/model/original.py index 55d3bea1ea..09bedff639 100644 --- a/plugins/train/model/original.py +++ b/plugins/train/model/original.py @@ -73,7 +73,7 @@ def decoder(self): name="face_out") outputs = [var_x] - if self.config.get("mask_type", None): + if self.config.get("replicate_input_mask", False): var_y = input_ var_y = self.blocks.upscale(var_y, 256) var_y = self.blocks.upscale(var_y, 128) diff --git a/plugins/train/model/realface.py b/plugins/train/model/realface.py index 10562b802c..d9504515d4 100644 --- a/plugins/train/model/realface.py +++ b/plugins/train/model/realface.py @@ -134,7 +134,7 @@ def decoder_b(self): outputs = [var_x] - if self.config.get("mask_type", None) is not None: + if self.config.get("replicate_input_mask", False): var_y = var_xy mask_b_complexity = 384 for idx in range(self.upscalers_no-2): @@ -184,7 +184,7 @@ def decoder_a(self): outputs = [var_x] - if self.config.get("mask_type", None) is not None: + if self.config.get("replicate_input_mask", False): var_y = var_xy mask_a_complexity = 384 for idx in range(self.upscalers_no-2): diff --git a/plugins/train/model/unbalanced.py b/plugins/train/model/unbalanced.py index b8c2a08b69..323639c3bc 100644 --- a/plugins/train/model/unbalanced.py +++ b/plugins/train/model/unbalanced.py @@ -80,7 +80,7 @@ def decoder_a(self): name="face_out") outputs = [var_x] - if self.config.get("mask_type", None): + if self.config.get("replicate_input_mask", False): var_y = input_ var_y = self.blocks.upscale(var_y, decoder_complexity) var_y = self.blocks.upscale(var_y, decoder_complexity) @@ -129,7 +129,7 @@ def decoder_b(self): name="face_out") outputs = [var_x] - if self.config.get("mask_type", None): + if self.config.get("replicate_input_mask", False): var_y = input_ var_y = self.blocks.upscale(var_y, decoder_complexity) if not self.lowmem: diff --git a/plugins/train/model/villain.py b/plugins/train/model/villain.py index 4a0a67ae27..c55b2935e1 100644 --- a/plugins/train/model/villain.py +++ b/plugins/train/model/villain.py @@ -78,7 +78,7 @@ def decoder(self): name="face_out") outputs = [var_x] - if self.config.get("mask_type", None): + if self.config.get("replicate_input_mask", False): var_y = input_ var_y = self.blocks.upscale(var_y, 512) var_y = self.blocks.upscale(var_y, 256) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index e4aef16d8e..170d476e19 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -7,18 +7,17 @@ A training_opts dictionary can be set in the corresponding model. Accepted values: - alignments: dict containing paths to alignments files for keys 'a' and 'b' - preview_scaling: How much to scale the preview out by - training_size: Size of the training images - coverage_ratio: Ratio of face to be cropped out for training - mask_type: Type of mask to use. See lib.model.masks for valid mask names. - Set to None for not used - no_logs: Disable tensorboard logging - snapshot_interval: Interval for saving model snapshots - warp_to_landmarks: Use random_warp_landmarks instead of random_warp - augment_color: Perform random shifting of L*a*b* colors - no_flip: Don't perform a random flip on the image - pingpong: Train each side seperately per save iteration rather than together + alignments: dict containing paths to alignments files for keys 'a' and 'b' + preview_scaling: How much to scale the preview out by + training_size: Size of the training images + coverage_ratio: Ratio of face to be cropped out for training + replicate_input_mask: Replicate input masks with additional model dedicated layers + no_logs: Disable tensorboard logging + snapshot_interval: Interval for saving model snapshots + warp_to_landmarks: Use random_warp_landmarks instead of random_warp + augment_color: Perform random shifting of L*a*b* colors + no_flip: Don't perform a random flip on the image + pingpong: Train each side seperately per save iteration rather than together """ import logging @@ -28,14 +27,13 @@ import cv2 import numpy as np -import tensorflow as tf -from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module - from lib.alignments import Alignments from lib.faces_detect import DetectedFace from lib.training_data import TrainingDataGenerator from lib.utils import FaceswapError, get_folder, get_image_paths from plugins.train._config import Config +from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module +import tensorflow as tf logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -90,14 +88,15 @@ def timestamp(self): def landmarks_required(self): """ Return True if Landmarks are required """ opts = self.model.training_opts - retval = bool(opts.get("mask_type", None) or opts["warp_to_landmarks"]) + retval = opts["warp_to_landmarks"] logger.debug(retval) return retval @property def use_mask(self): """ Return True if a mask is requested """ - retval = bool(self.model.training_opts.get("mask_type", None)) + retval = (self.model.training_opts.get("replicate_input_mask", False) or + self.model.training_opts.get("penalized_mask_loss", True)) logger.debug(retval) return retval @@ -176,10 +175,11 @@ def train_one_step(self, viewer, timelapse_kwargs): for side, batcher in self.batchers.items(): if self.pingpong.active and side != self.pingpong.side: continue - loss[side] = batcher.train_one_batch(do_preview) + loss[side] = batcher.train_one_batch() if not do_preview and not do_timelapse: continue if do_preview: + batcher.generate_preview(do_preview) self.samples.images[side] = batcher.compile_sample(None) if do_timelapse: self.timelapse.get_sample(side, timelapse_kwargs) @@ -247,13 +247,14 @@ def __init__(self, side, images, model, use_mask, batch_size, config): self.config = config self.target = None self.samples = None - self.mask = None + self.masks = None generator = self.load_generator() self.feed = generator.minibatch_ab(images, batch_size, self.side) self.preview_feed = None self.timelapse_feed = None + self.set_preview_feed() def load_generator(self): """ Pass arguments to TrainingDataGenerator and return object """ @@ -267,12 +268,12 @@ def load_generator(self): self.config) return generator - def train_one_batch(self, do_preview): + def train_one_batch(self): """ Train a batch """ logger.trace("Training one step: (side: %s)", self.side) - batch = self.get_next(do_preview) + model_inputs, model_targets = self.get_next() try: - loss = self.model.predictors[self.side].train_on_batch(*batch) + loss = self.model.predictors[self.side].train_on_batch(x=model_inputs, y=model_targets) except tf_errors.ResourceExhaustedError as err: msg = ("You do not have enough GPU memory available to train the selected model at " "the selected settings. You can try a number of things:" @@ -288,31 +289,28 @@ def train_one_batch(self, do_preview): loss = loss if isinstance(loss, list) else [loss] return loss - def get_next(self, do_preview): + def get_next(self): """ Return the next batch from the generator - Items should come out as: (warped, target [, mask]) """ + Items should come out as: (sample, warped, targets, [mask]) """ + logger.debug("Generating targets") batch = next(self.feed) - if self.use_mask: - batch = [[batch["feed"], batch["masks"]], batch["targets"] + [batch["masks"]]] - else: - batch = [batch["feed"], batch["targets"]] - self.generate_preview(do_preview) - return batch + targets_use_mask = self.model.training_opts["replicate_input_mask"] + model_inputs = batch["feed"] + batch["masks"] if self.use_mask else batch["feed"] + model_targets = batch["targets"] + batch["masks"] if targets_use_mask else batch["targets"] + return model_inputs, model_targets def generate_preview(self, do_preview): """ Generate the preview if a preview iteration """ if not do_preview: self.samples = None self.target = None + self.masks = None return logger.debug("Generating preview") - if self.preview_feed is None: - self.set_preview_feed() batch = next(self.preview_feed) self.samples = batch["samples"] - self.target = [batch["targets"][self.model.largest_face_index]] - if self.use_mask: - self.target += [batch["masks"]] + self.target = batch["targets"][self.model.largest_face_index] + self.masks = batch["masks"][0] def set_preview_feed(self): """ Set the preview dictionary """ @@ -327,27 +325,27 @@ def set_preview_feed(self): is_preview=True) logger.debug("Set preview feed. Batchsize: %s", batchsize) - def compile_sample(self, batch_size, samples=None, images=None): + def compile_sample(self, batch_size, samples=None, images=None, masks=None): """ Training samples to display in the viewer """ num_images = self.config.get("preview_images", 14) num_images = min(batch_size, num_images) if batch_size is not None else num_images logger.debug("Compiling samples: (side: '%s', samples: %s)", self.side, num_images) images = images if images is not None else self.target - retval = [samples[0:num_images]] if samples is not None else [self.samples[0:num_images]] - if self.use_mask: - retval.extend(tgt[0:num_images] for tgt in images) - else: - retval.extend(images[0:num_images]) + masks = masks if masks is not None else self.masks + samples = samples if samples is not None else self.samples + retval = [samples[0:num_images], images[0:num_images], masks[0:num_images]] return retval def compile_timelapse_sample(self): """ Timelapse samples """ batch = next(self.timelapse_feed) batchsize = len(batch["samples"]) - images = [batch["targets"][self.model.largest_face_index]] - if self.use_mask: - images = images + [batch["masks"]] - sample = self.compile_sample(batchsize, samples=batch["samples"], images=images) + images = batch["targets"][self.model.largest_face_index] + masks = batch["masks"][0] + sample = self.compile_sample(batchsize, + samples=batch["samples"], + images=images, + masks=masks) return sample def set_timelapse_feed(self, images, batchsize): @@ -416,7 +414,7 @@ def show_sample(self): height = int(figure.shape[0] / width) figure = figure.reshape((width, height) + figure.shape[1:]) figure = stack_images(figure) - figure = np.vstack((header, figure)) + figure = np.concatenate((header, figure), axis=0) logger.debug("Compiled sample") return np.clip(figure * 255, 0, 255).astype('uint8') @@ -518,9 +516,8 @@ def compile_masked(faces, masks): for mask in masks3: mask[np.where((mask == [1., 1., 1.]).all(axis=2))] = [0., 0., 1.] for previews in faces: - images = np.array([cv2.addWeighted(img, 1.0, # pylint: disable=no-member - masks3[idx], 0.3, - 0) + images = np.array([cv2.addWeighted(img, # pylint: disable=no-member + 1.0, masks3[idx], 0.3, 0) for idx, img in enumerate(previews)]) retval.append(images) logger.debug("masked shapes: %s", [faces.shape for faces in retval]) @@ -533,7 +530,7 @@ def overlay_foreground(backgrounds, foregrounds): new_images = list() for idx, img in enumerate(backgrounds): img[offset:offset + foregrounds[idx].shape[0], - offset:offset + foregrounds[idx].shape[1]] = foregrounds[idx] + offset:offset + foregrounds[idx].shape[1], :3] = foregrounds[idx] new_images.append(img) retval = np.array(new_images) logger.debug("Overlayed foreground. Shape: %s", retval.shape) diff --git a/scripts/convert.py b/scripts/convert.py index a8f4e35c6e..0e3a146691 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -587,7 +587,7 @@ def load_aligned(self, item): def compile_feed_faces(detected_faces): """ Compile the faces for feeding into the predictor """ logger.trace("Compiling feed face. Batchsize: %s", len(detected_faces)) - feed_faces = np.stack([detected_face.feed_face for detected_face in detected_faces]) + feed_faces = np.stack([detected_face.feed_face / 255. for detected_face in detected_faces]) logger.trace("Compiled Feed faces. Shape: %s", feed_faces.shape) return feed_faces diff --git a/scripts/extract.py b/scripts/extract.py index 5306c3e686..b07d8534a6 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -34,6 +34,7 @@ def __init__(self, arguments): normalization = None if self.args.normalization == "none" else self.args.normalization self.extractor = Extractor(self.args.detector, self.args.aligner, + self.args.masker, configfile=configfile, multiprocess=not self.args.singleprocess, rotate_images=self.args.rotate_images, @@ -103,7 +104,7 @@ def load_images(self): logger.trace("Skipping image: '%s'", filename) continue item = {"filename": filename, - "image": image} + "image": image[..., :3]} load_queue.put(item) load_queue.put("EOF") logger.debug("Load Images: Complete") @@ -224,7 +225,12 @@ def check_thread_error(self): def output_processing(self, faces, size, filename): """ Prepare faces for output """ - self.align_face(faces, size, filename) + final_faces = list() + for detected_face in faces["detected_faces"]: + filename = self.output_dir / Path(detected_face.filename).stem + final_faces.append({"file_location": filename, + "face": detected_face}) + faces["detected_faces"] = final_faces self.post_process.do_actions(faces) faces_count = len(faces["detected_faces"]) @@ -234,28 +240,16 @@ def output_processing(self, faces, size, filename): if not self.verify_output and faces_count > 1: self.verify_output = True - def align_face(self, faces, size, filename): - """ Align the detected face and add the destination file path """ - final_faces = list() - image = faces["image"] - detected_faces = faces["detected_faces"] - for face in detected_faces: - face.load_aligned(image, size=size) - final_faces.append({"file_location": self.output_dir / Path(filename).stem, - "face": face}) - faces["detected_faces"] = final_faces - def output_faces(self, filename, faces): """ Output faces to save thread """ final_faces = list() for idx, detected_face in enumerate(faces["detected_faces"]): output_file = detected_face["file_location"] - extension = Path(filename).suffix + extension = '.png' out_filename = "{}_{}{}".format(str(output_file), str(idx), extension) face = detected_face["face"] - resized_face = face.aligned_face - + resized_face = face.feed_face face.hash, img = encode_image_with_hash(resized_face, extension) self.save_queue.put((out_filename, img)) final_faces.append(face.to_alignment()) diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index bc866caa0f..533e07bfa8 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -388,11 +388,10 @@ def process(self, output_item): face = detected_face["face"] logger.trace("Drawing Landmarks. Frame: '%s'. Face: %s", detected_face["file_location"].parts[-1], idx) - aligned_landmarks = face.aligned_landmarks + aligned_landmarks = face.feed_landmarks for (pos_x, pos_y) in aligned_landmarks: - cv2.circle( # pylint: disable=no-member - face.aligned_face, - (pos_x, pos_y), 2, (0, 0, 255), -1) + cv2.circle(face.feed_face, # pylint: disable=no-member + (pos_x, pos_y), 2, (0, 0, 255, 255), -1) class FaceFilter(PostProcessAction): diff --git a/tools/preview.py b/tools/preview.py index 87595c09a5..ef803ec782 100644 --- a/tools/preview.py +++ b/tools/preview.py @@ -21,7 +21,6 @@ from lib.gui.control_helper import set_slider_rounding from lib.convert import Converter from lib.faces_detect import DetectedFace -from lib.model.masks import get_available_masks from lib.multithreading import MultiThread from lib.utils import FaceswapError, set_system_verbosity from lib.queue_manager import queue_manager @@ -733,7 +732,7 @@ def add_comboboxes(self, parent, defaults): """ Add the comboboxes to the Action Frame """ for opt in self.options: if opt == "mask_type": - choices = get_available_masks() + ["predicted"] + choices = ["dfl_full", "components", "extended", "predicted"] else: choices = PluginLoader.get_available_convert_plugins(opt, True) choices = [self.format_to_display(choice) for choice in choices] From e35918cadf0b4bcfbf530e4cb68d52bd2e0bf93f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 10 Oct 2019 23:11:12 +0100 Subject: [PATCH 084/981] Standardize serialization (#903) * Standardize serialization - Linting - Standardize serializer use throughout code - Extend serializer to load and save files - Always load and save in utf-8 - Create documentation --- docs/full/lib.rst | 1 + docs/full/lib.serializer.rst | 7 + lib/Serializer.py | 104 ------------ lib/alignments.py | 34 ++-- lib/gui/menu.py | 15 +- lib/gui/stats.py | 13 +- lib/gui/utils.py | 31 ++-- lib/model/backup_restore.py | 13 +- lib/serializer.py | 287 ++++++++++++++++++++++++++++++++++ plugins/train/model/_base.py | 58 +++---- scripts/convert.py | 9 +- scripts/fsmedia.py | 7 +- tools/lib_alignments/media.py | 16 +- tools/sort.py | 10 +- 14 files changed, 379 insertions(+), 226 deletions(-) create mode 100644 docs/full/lib.serializer.rst delete mode 100644 lib/Serializer.py create mode 100644 lib/serializer.py diff --git a/docs/full/lib.rst b/docs/full/lib.rst index 44ca9170e9..83e96425fb 100644 --- a/docs/full/lib.rst +++ b/docs/full/lib.rst @@ -9,6 +9,7 @@ Subpackages lib.model lib.faces_detect lib.image + lib.serializer lib.training_data Module contents diff --git a/docs/full/lib.serializer.rst b/docs/full/lib.serializer.rst new file mode 100644 index 0000000000..19c2bcc44b --- /dev/null +++ b/docs/full/lib.serializer.rst @@ -0,0 +1,7 @@ +lib.serializer module +========================= + +.. automodule:: lib.serializer + :members: + :undoc-members: + :show-inheritance: diff --git a/lib/Serializer.py b/lib/Serializer.py deleted file mode 100644 index 23a01d624f..0000000000 --- a/lib/Serializer.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 -""" -Library providing convenient classes and methods for writing data to files. -""" -import logging -import json -import pickle - -try: - import yaml -except ImportError: - yaml = None - -logger = logging.getLogger(__name__) # pylint: disable=invalid-name - - -class Serializer(): - """ Parent Serializer class """ - ext = "" - woptions = "" - roptions = "" - - @classmethod - def marshal(cls, input_data): - """ Override for marshalling """ - raise NotImplementedError() - - @classmethod - def unmarshal(cls, input_string): - """ Override for unmarshalling """ - raise NotImplementedError() - - -class YAMLSerializer(Serializer): - """ YAML Serializer """ - ext = "yml" - woptions = "w" - roptions = "r" - - @classmethod - def marshal(cls, input_data): - return yaml.dump(input_data, default_flow_style=False) - - @classmethod - def unmarshal(cls, input_string): - return yaml.load(input_string) - - -class JSONSerializer(Serializer): - """ JSON Serializer """ - ext = "json" - woptions = "w" - roptions = "r" - - @classmethod - def marshal(cls, input_data): - return json.dumps(input_data, indent=2) - - @classmethod - def unmarshal(cls, input_string): - return json.loads(input_string) - - -class PickleSerializer(Serializer): - """ Picke Serializer """ - ext = "p" - woptions = "wb" - roptions = "rb" - - @classmethod - def marshal(cls, input_data): - return pickle.dumps(input_data) - - @classmethod - def unmarshal(cls, input_bytes): # pylint: disable=arguments-differ - return pickle.loads(input_bytes) - - -def get_serializer(serializer): - """ Return requested serializer """ - if serializer == "json": - return JSONSerializer - if serializer == "pickle": - return PickleSerializer - if serializer == "yaml" and yaml is not None: - return YAMLSerializer - if serializer == "yaml" and yaml is None: - logger.warning("You must have PyYAML installed to use YAML as the serializer." - "Switching to JSON as the serializer.") - return JSONSerializer - - -def get_serializer_from_ext(ext): - """ Get the sertializer from filename extension """ - if ext == ".json": - return JSONSerializer - if ext == ".p": - return PickleSerializer - if ext in (".yaml", ".yml") and yaml is not None: - return YAMLSerializer - if ext in (".yaml", ".yml") and yaml is None: - logger.warning("You must have PyYAML installed to use YAML as the serializer.\n" - "Switching to JSON as the serializer.") - return JSONSerializer diff --git a/lib/alignments.py b/lib/alignments.py index def51d80bf..fffb50f73e 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -9,7 +9,7 @@ import cv2 from lib.faces_detect import rotate_landmarks -from lib import Serializer +from lib.serializer import get_serializer, get_serializer_from_filename logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -85,15 +85,15 @@ def get_serializer(filename, serializer): filename, serializer) extension = os.path.splitext(filename)[1] if extension in (".json", ".p", ".yaml", ".yml"): - logger.debug("Serializer set from file extension: '%s'", extension) - retval = Serializer.get_serializer_from_ext(extension) + logger.debug("Serializer set from filename extension: '%s'", extension) + retval = get_serializer_from_filename(filename) elif serializer not in ("json", "pickle", "yaml"): raise ValueError("Error: {} is not a valid serializer. Use " "'json', 'pickle' or 'yaml'") else: logger.debug("Serializer set from argument: '%s'", serializer) - retval = Serializer.get_serializer(serializer) - logger.verbose("Using '%s' serializer for alignments", retval.ext) + retval = get_serializer(serializer) + logger.verbose("Using '%s' serializer for alignments", retval.file_extension) return retval def get_location(self, folder, filename): @@ -106,8 +106,9 @@ def get_location(self, folder, filename): else: location = os.path.join(str(folder), "{}.{}".format(filename, - self.serializer.ext)) - logger.debug("File extension set from serializer: '%s'", self.serializer.ext) + self.serializer.file_extension)) + logger.debug("File extension set from serializer: '%s'", + self.serializer.file_extension) logger.verbose("Alignments filepath: '%s'", location) return location @@ -121,13 +122,8 @@ def load(self): raise ValueError("Error: Alignments file not found at " "{}".format(self.file)) - try: - logger.info("Reading alignments from: '%s'", self.file) - with open(self.file, self.serializer.roptions) as align: - data = self.serializer.unmarshal(align.read()) - except IOError as err: - logger.error("'%s' not read: %s", self.file, err.strerror) - exit(1) + logger.info("Reading alignments from: '%s'", self.file) + data = self.serializer.load(self.file) logger.debug("Loaded alignments") return data @@ -140,13 +136,9 @@ def reload(self): def save(self): """ Write the serialized alignments file """ logger.debug("Saving alignments") - try: - logger.info("Writing alignments to: '%s'", self.file) - with open(self.file, self.serializer.woptions) as align: - align.write(self.serializer.marshal(self.data)) - logger.debug("Saved alignments") - except IOError as err: - logger.error("'%s' not written: %s", self.file, err.strerror) + logger.info("Writing alignments to: '%s'", self.file) + self.serializer.save(self.file, self.data) + logger.debug("Saved alignments") def backup(self): """ Backup copy of old alignments """ diff --git a/lib/gui/menu.py b/lib/gui/menu.py index a5d1e264bf..2faeeeb2c1 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -13,7 +13,7 @@ from subprocess import Popen, PIPE, STDOUT from lib.multithreading import MultiThread -from lib.Serializer import JSONSerializer +from lib.serializer import get_serializer import update_deps from .utils import get_config @@ -127,13 +127,12 @@ def build(self): def build_recent_menu(self): """ Load recent files into menu bar """ logger.debug("Building Recent Files menu") - serializer = JSONSerializer + serializer = get_serializer("json") menu_file = os.path.join(self.config.pathcache, ".recent.json") if not os.path.isfile(menu_file) or os.path.getsize(menu_file) == 0: self.clear_recent_files(serializer, menu_file) - with open(menu_file, "rb") as inp: - recent_files = serializer.unmarshal(inp.read().decode("utf-8")) - logger.debug("Loaded recent files: %s", recent_files) + recent_files = serializer.load(menu_file) + logger.debug("Loaded recent files: %s", recent_files) for recent_item in recent_files: filename, command = recent_item logger.debug("processing: ('%s', %s)", filename, command) @@ -153,9 +152,7 @@ def build_recent_menu(self): def clear_recent_files(serializer, menu_file): """ Creates or clears recent file list """ logger.debug("clearing recent files list: '%s'", menu_file) - recent_files = serializer.marshal(list()) - with open(menu_file, "wb") as out: - out.write(recent_files.encode("utf-8")) + serializer.save(menu_file, list()) def refresh_recent_menu(self): """ Refresh recent menu on save/load of files """ @@ -222,7 +219,7 @@ def output_sysinfo(self): try: from lib.sysinfo import sysinfo info = sysinfo - except Exception as err: + except Exception as err: # pylint:disable=broad-except info = "Error obtaining system info: {}".format(str(err)) self.clear_console() logger.debug("Obtained system information: %s", info) diff --git a/lib/gui/stats.py b/lib/gui/stats.py index de3a7cc945..16fbb4efb8 100644 --- a/lib/gui/stats.py +++ b/lib/gui/stats.py @@ -10,7 +10,7 @@ import numpy as np import tensorflow as tf -from lib.Serializer import JSONSerializer +from lib.serializer import get_serializer logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -101,7 +101,7 @@ class Session(): def __init__(self, model_dir=None, model_name=None): logger.debug("Initializing %s: (model_dir: %s, model_name: %s)", self.__class__.__name__, model_dir, model_name) - self.serializer = JSONSerializer + self.serializer = get_serializer("json") self.state = None self.modeldir = model_dir # Set and reset by wrapper for training sessions self.modelname = model_name # Set and reset by wrapper for training sessions @@ -231,13 +231,8 @@ def load_state_file(self): """ Load the current state file """ state_file = os.path.join(self.modeldir, "{}_state.json".format(self.modelname)) logger.debug("Loading State: '%s'", state_file) - try: - with open(state_file, "rb") as inp: - state = self.serializer.unmarshal(inp.read().decode("utf-8")) - self.state = state - logger.debug("Loaded state: %s", state) - except IOError as err: - logger.warning("Unable to load state file. Graphing disabled: %s", str(err)) + self.state = self.serializer.load(state_file) + logger.debug("Loaded state: %s", self.state) def get_iterations_for_session(self, session_id): """ Return the number of iterations for the given session id """ diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 55658f8a3e..095a918e63 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -13,7 +13,7 @@ from PIL import Image, ImageDraw, ImageTk -from lib.Serializer import JSONSerializer +from lib.serializer import get_serializer from ._config import Config as UserConfig from ._redirector import WidgetRedirector @@ -643,7 +643,7 @@ def __init__(self, root, cli_opts, scaling_factor, pathcache, statusbar, session self.scaling_factor = scaling_factor self.pathcache = pathcache self.statusbar = statusbar - self.serializer = JSONSerializer + self.serializer = get_serializer("json") self.tk_vars = self.set_tk_vars() self.user_config = UserConfig(None) self.user_config_dict = self.user_config.config_dict @@ -740,13 +740,14 @@ def load(self, command=None, filename=None): msg = "File does not exist: '{}'".format(filename) logger.error(msg) return - with open(filename, "r") as cfgfile: - cfg = self.serializer.unmarshal(cfgfile.read()) + cfg = self.serializer.load(filename) else: cfgfile = FileHandler("open", "config").retfile if not cfgfile: return - cfg = self.serializer.unmarshal(cfgfile.read()) + filename = cfgfile.name + cfgfile.close() + cfg = self.serializer.load(filename) if not command and len(cfg.keys()) == 1: command = list(cfg.keys())[0] @@ -764,8 +765,8 @@ def load(self, command=None, filename=None): else: self.command_notebook.select(self.command_tabs["tools"]) self.command_notebook.tools_notebook.select(self.tools_command_tabs[command]) - self.add_to_recent(cfgfile.name, command) - logger.debug("Loaded config: (command: '%s', cfgfile: '%s')", command, cfgfile) + self.add_to_recent(filename, command) + logger.debug("Loaded config: (command: '%s', filename: '%s')", command, filename) def get_command_options(self, cfg, command): """ return the saved options for the requested @@ -796,11 +797,12 @@ def save(self, command=None): cfgfile = FileHandler("save", "config").retfile if not cfgfile: return - cfg = self.cli_opts.get_option_values(command) - cfgfile.write(self.serializer.marshal(cfg)) + filename = cfgfile.name cfgfile.close() - self.add_to_recent(cfgfile.name, command) - logger.debug("Saved config: (command: '%s', cfgfile: '%s')", command, cfgfile) + cfg = self.cli_opts.get_option_values(command) + self.serializer.save(filename, cfg) + self.add_to_recent(filename, command) + logger.debug("Saved config: (command: '%s', filename: '%s')", command, filename) def add_to_recent(self, filename, command): """ Add to recent files """ @@ -809,8 +811,7 @@ def add_to_recent(self, filename, command): if not os.path.exists(recent_filename) or os.path.getsize(recent_filename) == 0: recent_files = list() else: - with open(recent_filename, "rb") as inp: - recent_files = self.serializer.unmarshal(inp.read().decode("utf-8")) + recent_files = self.serializer.load(recent_filename) logger.debug("Initial recent files: %s", recent_files) filenames = [recent[0] for recent in recent_files] if filename in filenames: @@ -819,9 +820,7 @@ def add_to_recent(self, filename, command): recent_files.insert(0, (filename, command)) recent_files = recent_files[:20] logger.debug("Final recent files: %s", recent_files) - recent_json = self.serializer.marshal(recent_files) - with open(recent_filename, "wb") as out: - out.write(recent_json.encode("utf-8")) + self.serializer.save(recent_filename, recent_files) class ContextMenu(tk.Menu): # pylint: disable=too-many-ancestors diff --git a/lib/model/backup_restore.py b/lib/model/backup_restore.py index 0666a5ac09..4a3261c279 100644 --- a/lib/model/backup_restore.py +++ b/lib/model/backup_restore.py @@ -7,7 +7,7 @@ from datetime import datetime from shutil import copyfile, copytree, rmtree -from lib import Serializer +from lib.serializer import get_serializer from lib.utils import get_folder logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -131,13 +131,12 @@ def restore_logs(self, archive_dir): def get_session_names(self): """ Get the existing session names from state file """ - serializer = Serializer.get_serializer("json") + serializer = get_serializer("json") state_file = os.path.join(self.model_dir, - "{}_state.{}".format(self.model_name, serializer.ext)) - with open(state_file, "rb") as inp: - state = serializer.unmarshal(inp.read().decode("utf-8")) - session_names = ["session_{}".format(key) - for key in state["sessions"].keys()] + "{}_state.{}".format(self.model_name, serializer.file_extension)) + state = serializer.load(state_file) + session_names = ["session_{}".format(key) + for key in state["sessions"].keys()] logger.debug("Session to restore: %s", session_names) return session_names diff --git a/lib/serializer.py b/lib/serializer.py new file mode 100644 index 0000000000..9370db2f60 --- /dev/null +++ b/lib/serializer.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +""" +Library for serializing python objects to and from various different serializer formats +""" +import logging +import json +import os +import pickle + +from lib.utils import FaceswapError + +try: + import yaml +except ImportError: + yaml = None + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class Serializer(): + """ A convenience class for various serializers. + + This class should not be called directly as it acts as the parent for various serializers. + All serializers should be called from :func:`get_serializer` or + :func:`get_serializer_from_filename` + + Example + ------- + >>> from lib.serializer import get_serializer + >>> serializer = get_serializer('json') + >>> json_file = '/path/to/json/file.json' + >>> data = serializer.load(json_file) + >>> serializer.save(json_file, data) + + """ + def __init__(self): + self._file_extension = None + self._write_option = "wb" + self._read_option = "rb" + + @property + def file_extension(self): + """ str: The file extension of the serializer """ + return self._file_extension + + def save(self, filename, data): + """ Serialize data and save to a file + + Parameters + ---------- + filename: str + The path to where the serialized file should be saved + data: varies + The data that is to be serialized to file + + Example + ------ + >>> serializer = get_serializer('json') + >>> data ['foo', 'bar'] + >>> json_file = '/path/to/json/file.json' + >>> serializer.save(json_file, data) + """ + logger.debug("filename: %s, data type: %s", filename, type(data)) + filename = self._check_extension(filename) + try: + with open(filename, self._write_option) as s_file: + s_file.write(self.marshal(data)) + except IOError as err: + msg = "Error writing to '{}': {}".format(filename, err.strerror) + raise FaceswapError(msg) from err + + def _check_extension(self, filename): + """ Check the filename has an extension. If not add the correct one for the serializer """ + extension = os.path.splitext(filename)[1] + retval = filename if extension else "{}.{}".format(filename, self.file_extension) + logger.debug("Original filename: '%s', final filename: '%s'", filename, retval) + return retval + + def load(self, filename): + """ Load data from an existing serialized file + + Parameters + ---------- + filename: str + The path to the serialized file + + Returns + ---------- + data: varies + The data in a python object format + + Example + ------ + >>> serializer = get_serializer('json') + >>> json_file = '/path/to/json/file.json' + >>> data = serializer.load(json_file) + """ + logger.debug("filename: %s", filename) + try: + with open(filename, self._read_option) as s_file: + data = s_file.read() + logger.debug("stored data type: %s", type(data)) + if isinstance(data, bytes): + data = data.decode("utf-8") + retval = self.unmarshal(data) + except IOError as err: + msg = "Error reading from '{}': {}".format(filename, err.strerror) + raise FaceswapError(msg) from err + logger.debug("data type: %s", type(retval)) + return retval + + def marshal(self, data): + """ Serialize an object + + Parameters + ---------- + data: varies + The data that is to be serialized + + Returns + ------- + data: varies + The data in a the serialized data format + + Example + ------ + >>> serializer = get_serializer('json') + >>> data ['foo', 'bar'] + >>> json_data = serializer.marshal(data) + """ + logger.debug("data type: %s", type(data)) + try: + retval = self._marshal(data) + except Exception as err: + msg = "Error serializing data for type {}: {}".format(type(data), str(err)) + raise FaceswapError(msg) from err + logger.debug("returned data type: %s", type(retval)) + return retval + + def unmarshal(self, serialized_data): + """ Unserialize data to its original object type + + Parameters + ---------- + serialized_data: varies + Data in serializer format that is to be unmarshalled to its original object + + Returns + ------- + data: varies + The data in a python object format + + Example + ------ + >>> serializer = get_serializer('json') + >>> json_data = + >>> data = serializer.unmarshal(json_data) + """ + logger.debug("data type: %s", type(serialized_data)) + try: + retval = self._unmarshal(serialized_data) + except Exception as err: + msg = "Error unserializing data for type {}: {}".format(type(serialized_data), + str(err)) + raise FaceswapError(msg) from err + logger.debug("returned data type: %s", type(retval)) + return retval + + @classmethod + def _marshal(cls, data): + """ Override for serializer specific marshalling """ + raise NotImplementedError() + + @classmethod + def _unmarshal(cls, data): + """ Override for serializer specific unmarshalling """ + raise NotImplementedError() + + +class _YAMLSerializer(Serializer): + """ YAML Serializer """ + def __init__(self): + super().__init__() + self._file_extension = "yml" + + @classmethod + def _marshal(cls, data): + return yaml.dump(data, default_flow_style=False).encode("utf-8") + + @classmethod + def _unmarshal(cls, data): + return yaml.load(data) + + +class _JSONSerializer(Serializer): + """ JSON Serializer """ + def __init__(self): + super().__init__() + self._file_extension = "json" + + @classmethod + def _marshal(cls, data): + return json.dumps(data, indent=2).encode("utf-8") + + @classmethod + def _unmarshal(cls, data): + return json.loads(data) + + +class _PickleSerializer(Serializer): + """ Pickle Serializer """ + def __init__(self): + super().__init__() + self._file_extension = "p" + + @classmethod + def _marshal(cls, data): + return pickle.dumps(data) + + @classmethod + def _unmarshal(cls, data): + return pickle.loads(data) + + +def get_serializer(serializer): + """ Obtain a serializer object + + Parameters + ---------- + serializer: {'json', 'pickle', yaml'} + The required serializer format + + Returns + ------- + serializer: :class:`Serializer` + A serializer object for handling the requested data format + + Example + ------- + >>> serializer = get_serializer('json') + """ + if serializer.lower() == "json": + return _JSONSerializer() + if serializer.lower() == "pickle": + return _PickleSerializer() + if serializer.lower() == "yaml" and yaml is not None: + return _YAMLSerializer() + if serializer.lower() == "yaml" and yaml is None: + logger.warning("You must have PyYAML installed to use YAML as the serializer." + "Switching to JSON as the serializer.") + logger.warning("Unrecognized serializer: '%s'. Returning json serializer", serializer) + return _JSONSerializer() + + +def get_serializer_from_filename(filename): + """ Obtain a serializer object from a filename + + Parameters + ---------- + filename: str + Filename to determine the serializer type from + + Returns + ------- + serializer: :class:`Serializer` + A serializer object for handling the requested data format + + Example + ------- + >>> filename = '/path/to/json/file.json' + >>> serializer = get_serializer_from_filename(filename) + """ + logger.debug("filename: '%s'", filename) + extension = os.path.splitext(filename)[1].lower() + logger.debug("extension: '%s'", extension) + + if extension == ".json": + return _JSONSerializer() + if extension == ".p": + return _PickleSerializer() + if extension in (".yaml", ".yml") and yaml is not None: + return _YAMLSerializer() + if extension in (".yaml", ".yml") and yaml is None: + logger.warning("You must have PyYAML installed to use YAML as the serializer.\n" + "Switching to JSON as the serializer.") + logger.warning("Unrecognized extension: '%s'. Returning json serializer", extension) + return _JSONSerializer() diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 954b238278..df561c385d 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -10,7 +10,6 @@ import time from concurrent import futures -from json import JSONDecodeError import keras from keras import losses @@ -19,7 +18,7 @@ from keras.models import load_model, Model from keras.utils import get_custom_objects, multi_gpu_model -from lib import Serializer +from lib.serializer import get_serializer from lib.model.backup_restore import Backup from lib.model.losses import (DSSIMObjective, PenalizedLoss, gradient_loss, mask_loss_wrapper, generalized_loss, l_inf_norm, gmsd_loss, gaussian_blur) @@ -868,8 +867,8 @@ def __init__(self, model_dir, model_name, config_changeable_items, "config_changeable_items: '%s', no_logs: %s, pingpong: %s, " "training_image_size: '%s'", self.__class__.__name__, model_dir, model_name, config_changeable_items, no_logs, pingpong, training_image_size) - self.serializer = Serializer.get_serializer("json") - filename = "{}_state.{}".format(model_name, self.serializer.ext) + self.serializer = get_serializer("json") + filename = "{}_state.{}".format(model_name, self.serializer.file_extension) self.filename = str(model_dir / filename) self.name = model_name self.iterations = 0 @@ -947,42 +946,33 @@ def increment_iterations(self): def load(self, config_changeable_items): """ Load state file """ logger.debug("Loading State") - try: - with open(self.filename, "rb") as inp: - state = self.serializer.unmarshal(inp.read().decode("utf-8")) - self.name = state.get("name", self.name) - self.sessions = state.get("sessions", dict()) - self.lowest_avg_loss = state.get("lowest_avg_loss", dict()) - self.iterations = state.get("iterations", 0) - self.training_size = state.get("training_size", 256) - self.inputs = state.get("inputs", dict()) - self.config = state.get("config", dict()) - logger.debug("Loaded state: %s", state) - self.replace_config(config_changeable_items) - except IOError as err: - logger.warning("No existing state file found. Generating.") - logger.debug("IOError: %s", str(err)) - except JSONDecodeError as err: - logger.debug("JSONDecodeError: %s:", str(err)) + if not os.path.exists(self.filename): + logger.info("No existing state file found. Generating.") + return + state = self.serializer.load(self.filename) + self.name = state.get("name", self.name) + self.sessions = state.get("sessions", dict()) + self.lowest_avg_loss = state.get("lowest_avg_loss", dict()) + self.iterations = state.get("iterations", 0) + self.training_size = state.get("training_size", 256) + self.inputs = state.get("inputs", dict()) + self.config = state.get("config", dict()) + logger.debug("Loaded state: %s", state) + self.replace_config(config_changeable_items) def save(self, backup_func=None): """ Save iteration number to state file """ logger.debug("Saving State") if backup_func: backup_func(self.filename) - try: - with open(self.filename, "wb") as out: - state = {"name": self.name, - "sessions": self.sessions, - "lowest_avg_loss": self.lowest_avg_loss, - "iterations": self.iterations, - "inputs": self.inputs, - "training_size": self.training_size, - "config": _CONFIG} - state_json = self.serializer.marshal(state) - out.write(state_json.encode("utf-8")) - except IOError as err: - logger.error("Unable to save model state: %s", str(err.strerror)) + state = {"name": self.name, + "sessions": self.sessions, + "lowest_avg_loss": self.lowest_avg_loss, + "iterations": self.iterations, + "inputs": self.inputs, + "training_size": self.training_size, + "config": _CONFIG} + self.serializer.save(self.filename, state) logger.debug("Saved State") def replace_config(self, config_changeable_items): diff --git a/scripts/convert.py b/scripts/convert.py index a8f4e35c6e..8caa6dd977 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -13,7 +13,7 @@ from tqdm import tqdm from scripts.fsmedia import Alignments, Images, PostProcess, Utils -from lib import Serializer +from lib.serializer import get_serializer from lib.convert import Converter from lib.faces_detect import DetectedFace from lib.gpu_stats import GPUStats @@ -419,7 +419,7 @@ def __init__(self, in_queue, queue_size, arguments): self.args = arguments self.in_queue = in_queue self.out_queue = queue_manager.get_queue("patch") - self.serializer = Serializer.get_serializer("json") + self.serializer = get_serializer("json") self.faces_count = 0 self.verify_output = False self.model = self.load_model() @@ -495,9 +495,8 @@ def get_trainer(self, model_dir): "option.".format(len(statefile))) statefile = os.path.join(str(model_dir), statefile[0]) - with open(statefile, "rb") as inp: - state = self.serializer.unmarshal(inp.read().decode("utf-8")) - trainer = state.get("name", None) + state = self.serializer.load(statefile) + trainer = state.get("name", None) if not trainer: raise FaceswapError("Trainer name could not be read from state file. " diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index bc866caa0f..5e62a9dfdf 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -114,12 +114,7 @@ def load(self): logger.warning("Skip Existing/Skip Faces selected, but no alignments file found!") return data - try: - with open(self.file, self.serializer.roptions) as align: - data = self.serializer.unmarshal(align.read()) - except IOError as err: - logger.error("Error: '%s' not read: %s", self.file, err.strerror) - exit(1) + data = self.serializer.load(self.file) if skip_faces: # Remove items from algnments that have no faces so they will diff --git a/tools/lib_alignments/media.py b/tools/lib_alignments/media.py index bfd14ff08f..a5f043da18 100644 --- a/tools/lib_alignments/media.py +++ b/tools/lib_alignments/media.py @@ -81,7 +81,7 @@ def set_destination_format(self, destination_format): self.serializer = self.get_serializer("", dst_fmt) filename = os.path.splitext(self.file)[0] - self.file = "{}.{}".format(filename, self.serializer.ext) + self.file = "{}.{}".format(filename, self.serializer.file_extension) logger.debug("Destination file: '%s'", self.file) def save(self): @@ -364,15 +364,15 @@ def save_face_with_hash(filename, extension, face): out_file.write(img) return f_hash - def align_eyes(self, face, image): + @staticmethod + def align_eyes(face, image): """ Re-extract a face with the pupils forced to be absolutely horizontally aligned """ umeyama_landmarks = face.aligned_landmarks - leftEyeCenter = umeyama_landmarks[42:48].mean(axis=0) - rightEyeCenter = umeyama_landmarks[36:42].mean(axis=0) - eyesCenter = umeyama_landmarks[36:48].mean(axis=0) - dY = rightEyeCenter[1] - leftEyeCenter[1] - dX = rightEyeCenter[0] - leftEyeCenter[0] - theta = np.pi - np.arctan2(dY, dX) + left_eye_center = umeyama_landmarks[42:48].mean(axis=0) + right_eye_center = umeyama_landmarks[36:42].mean(axis=0) + d_y = right_eye_center[1] - left_eye_center[1] + d_x = right_eye_center[0] - left_eye_center[0] + theta = np.pi - np.arctan2(d_y, d_x) rot_cos = np.cos(theta) rot_sin = np.sin(theta) rotation_matrix = np.array([[rot_cos, -rot_sin, 0.], diff --git a/tools/sort.py b/tools/sort.py index 9baf04f1e5..ae77c1a0f0 100644 --- a/tools/sort.py +++ b/tools/sort.py @@ -14,7 +14,7 @@ # faceswap imports from lib.cli import FullHelpArgumentParser -from lib import Serializer +from lib.serializer import get_serializer_from_filename from lib.faces_detect import DetectedFace from lib.image import read_image from lib.queue_manager import queue_manager @@ -69,10 +69,7 @@ def process(self): 'sort_log.json') # Set serializer based on logfile extension - serializer_ext = os.path.splitext( - self.args.log_file_path)[-1] - self.serializer = Serializer.get_serializer_from_ext( - serializer_ext) + self.serializer = get_serializer_from_filename(self.args.log_file_path) # Prepare sort, group and final process method names _sort = "sort_" + self.args.sort_method.lower() @@ -532,8 +529,7 @@ def final_process_folders(self, bins): def write_to_log(self, changes): """ Write the changes to log file """ logger.info("Writing sort log to: '%s'", self.args.log_file_path) - with open(self.args.log_file_path, 'w') as lfile: - lfile.write(self.serializer.marshal(changes)) + self.serializer.save(self.args.log_file_path, changes) def reload_images(self, group_method, img_list): """ From 6b56faad265f4ab521891a91c5a51294e737243f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 11 Oct 2019 00:39:21 +0000 Subject: [PATCH 085/981] Move utf-8 decoding to subclasses --- lib/serializer.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/serializer.py b/lib/serializer.py index 9370db2f60..dad5fc67ea 100644 --- a/lib/serializer.py +++ b/lib/serializer.py @@ -100,8 +100,6 @@ def load(self, filename): with open(filename, self._read_option) as s_file: data = s_file.read() logger.debug("stored data type: %s", type(data)) - if isinstance(data, bytes): - data = data.decode("utf-8") retval = self.unmarshal(data) except IOError as err: msg = "Error reading from '{}': {}".format(filename, err.strerror) @@ -189,7 +187,7 @@ def _marshal(cls, data): @classmethod def _unmarshal(cls, data): - return yaml.load(data) + return yaml.load(data.decode("utf-8")) class _JSONSerializer(Serializer): @@ -204,7 +202,7 @@ def _marshal(cls, data): @classmethod def _unmarshal(cls, data): - return json.loads(data) + return json.loads(data.decode("utf-8")) class _PickleSerializer(Serializer): From 468e2709de706aa4cc7d704b46d5221b9b92abfb Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 11 Oct 2019 18:17:39 +0000 Subject: [PATCH 086/981] Mask plugin cleanup - PEP8 Fixes - Remove config for non NN Masks - Tidy up defaults helptext - cli.py fix typos - Remove unused imports and functions _base.py - Standardize input_size param - Enable and update documentation - Change references from `aligner` to `masker` - Change input_size, output_size and coverage_ratio from kwargs to params - Move load_aligned to batch input iterator - Remove unnecessary self.input param - Add softmax layer append function to KSession - Remove references to KSession protected objects - Standardize plugin output into finalize method - Make masks full frame and add to lib.faces_detect - Add masks to alignments.json (temporary zipped base64 solution) --- docs/full/plugins.extract.mask._base.rst | 7 + docs/full/plugins.extract.mask.rst | 17 +++ docs/full/plugins.extract.rst | 1 + lib/cli.py | 4 +- lib/faces_detect.py | 41 ++++-- lib/model/session.py | 17 +++ plugins/extract/mask/_base.py | 133 ++++++++---------- plugins/extract/mask/components.py | 34 ++--- plugins/extract/mask/components_defaults.py | 68 --------- plugins/extract/mask/extended.py | 34 ++--- plugins/extract/mask/extended_defaults.py | 68 --------- plugins/extract/mask/none.py | 27 ++-- plugins/extract/mask/none_defaults.py | 67 --------- plugins/extract/mask/unet_dfl.py | 47 ++----- plugins/extract/mask/unet_dfl_defaults.py | 5 +- plugins/extract/mask/vgg_clear.py | 51 ++----- plugins/extract/mask/vgg_clear_defaults.py | 5 +- plugins/extract/mask/vgg_obstructed.py | 50 ++----- .../extract/mask/vgg_obstructed_defaults.py | 9 +- plugins/extract/pipeline.py | 32 ++--- plugins/train/trainer/_base.py | 7 +- 21 files changed, 222 insertions(+), 502 deletions(-) create mode 100644 docs/full/plugins.extract.mask._base.rst create mode 100644 docs/full/plugins.extract.mask.rst delete mode 100644 plugins/extract/mask/components_defaults.py delete mode 100644 plugins/extract/mask/extended_defaults.py delete mode 100644 plugins/extract/mask/none_defaults.py diff --git a/docs/full/plugins.extract.mask._base.rst b/docs/full/plugins.extract.mask._base.rst new file mode 100644 index 0000000000..ee9487e65e --- /dev/null +++ b/docs/full/plugins.extract.mask._base.rst @@ -0,0 +1,7 @@ +plugins.extract.mask._base module +====================================== + +.. automodule:: plugins.extract.mask._base + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/plugins.extract.mask.rst b/docs/full/plugins.extract.mask.rst new file mode 100644 index 0000000000..a74874478f --- /dev/null +++ b/docs/full/plugins.extract.mask.rst @@ -0,0 +1,17 @@ +plugins.extract.mask package +============================== + +Submodules +---------- + +.. toctree:: + + plugins.extract.mask._base + +Module contents +--------------- + +.. automodule:: plugins.extract.mask + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/plugins.extract.rst b/docs/full/plugins.extract.rst index 384a8c56af..3061ac99d6 100644 --- a/docs/full/plugins.extract.rst +++ b/docs/full/plugins.extract.rst @@ -8,6 +8,7 @@ Subpackages plugins.extract.align plugins.extract.detect + plugins.extract.mask Submodules ---------- diff --git a/lib/cli.py b/lib/cli.py index 06350a5919..4b9b7d2e63 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -581,10 +581,10 @@ def get_optional_arguments(): "channel that will not mask any portion of the image." "\nL|components: Mask designed to provide facial " "segmentation based on the positioning of landmark " - "locations. A convenx hull is constructed around the " + "locations. A convex hull is constructed around the " "exterior of the landmarks to create a mask." "\nL|extended: Mask designed to provide facial segmentation " - "based on the positioning of landmark locations. A convenx " + "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 " diff --git a/lib/faces_detect.py b/lib/faces_detect.py index 21bb53a00f..3361473f18 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -39,20 +39,26 @@ class DetectedFace(): landmarks_xy: list The 68 point landmarks as discovered in :mod:`plugins.extract.align`. Should be a ``list`` of 68 `(x, y)` ``tuples`` with each of the landmark co-ordinates. + mask: dict + The generated mask(s) for the face as generated in :mod:`plugins.extract.mask`. Must be a + dict of `{name (str): mask (numpy.ndarray)} """ def __init__(self, image=None, x=None, w=None, y=None, h=None, - landmarks_xy=None, filename=None): + landmarks_xy=None, mask=None, filename=None): logger.trace("Initializing %s: (image: %s, x: %s, w: %s, y: %s, h:%s, " "landmarks_xy: %s, filename: %s)", self.__class__.__name__, image.shape if image is not None and image.any() else image, - x, w, y, h, landmarks_xy, filename) + x, w, y, h, landmarks_xy, + {k: v.shape for k, v in mask} if mask is not None else mask, + filename) self.image = image - self.x = x - self.w = w - self.y = y - self.h = h + self.x = x # pylint:disable=invalid-name + self.w = w # pylint:disable=invalid-name + self.y = y # pylint:disable=invalid-name + self.h = h # pylint:disable=invalid-name self.landmarks_xy = landmarks_xy + self.mask = dict() if mask is None else mask self.filename = filename self.hash = None self.face = None @@ -96,7 +102,7 @@ def to_alignment(self): ------- alignment: dict The alignment dict will be returned with the keys ``x``, ``w``, ``y``, ``h``, - ``landmarks_xy``, ``hash``. + ``landmarks_xy``, ``mask``, ``hash``. """ alignment = dict() @@ -106,6 +112,7 @@ def to_alignment(self): alignment["h"] = self.h alignment["landmarks_xy"] = self.landmarks_xy alignment["hash"] = self.hash + alignment["mask"] = self.mask logger.trace("Returning: %s", alignment) return alignment @@ -117,8 +124,11 @@ def from_alignment(self, alignment, image=None): ---------- alignment: dict A dictionary entry for a face from an alignments file containing the keys - ``x``, ``w``, ``y``, ``h``, ``landmarks_xy``. Optionally the key ``hash`` - will be provided, but not all use cases will know the face hash at this time. + ``x``, ``w``, ``y``, ``h``, ``landmarks_xy``. + Optionally the key ``hash`` will be provided, but not all use cases will know the + face hash at this time. + Optionally the key ``mask`` will be provided, but legacy alignments will not have + this key. image: numpy.ndarray, optional If an image is passed in, then the ``image`` attribute will be set to the cropped face based on the passed in bounding box co-ordinates @@ -133,6 +143,8 @@ def from_alignment(self, alignment, image=None): self.landmarks_xy = alignment["landmarks_xy"] # Manual tool does not know the final hash so default to None self.hash = alignment.get("hash", None) + # Manual tool and legacy alignments will not have a mask + self.mask = alignment.get("mask", None) if image is not None and image.any(): self.image = image self._image_to_face(image) @@ -144,7 +156,7 @@ def _image_to_face(self, image): """ set self.image to be the cropped face from detected bounding box """ logger.trace("Cropping face from image") self.face = image[self.top: self.bottom, - self.left: self.right] + self.left: self.right] # <<< Aligned Face methods and properties >>> # def load_aligned(self, image, size=256, coverage_ratio=1.0, dtype=None): @@ -199,7 +211,8 @@ def load_aligned(self, image, size=256, coverage_ratio=1.0, dtype=None): for k, v in self.aligned.items() if k != "face"}) - def _padding_from_coverage(self, size, coverage_ratio): + @staticmethod + def _padding_from_coverage(size, coverage_ratio): """ Return the image padding for a face from coverage_ratio set against a pre-padded training image """ padding = int((size * (coverage_ratio - 0.625)) / 2) @@ -240,7 +253,7 @@ def load_feed_face(self, image, size=64, coverage_ratio=0.625, dtype=None): self.feed["face"] = face if dtype is None else face.astype(dtype) logger.trace("Loaded feed face. (face_shape: %s, matrix: %s)", - self.feed_face.shape, self._feed_matrix) + self.feed_face.shape, self.feed_matrix) def load_reference_face(self, image, size=64, coverage_ratio=0.625, dtype=None): """ Align a face in the correct dimensions for reference against the output from a model. @@ -354,7 +367,7 @@ def feed_landmarks(self): return landmarks @property - def _feed_matrix(self): + def feed_matrix(self): """ numpy.ndarray: The adjusted matrix face sized for feeding into a model. Only available after :func:`load_feed_face` has been called with an image, otherwise returns ``None`` """ if not self.feed: @@ -372,7 +385,7 @@ def feed_interpolators(self): ``None``""" if not self.feed: return None - return get_matrix_scaling(self._feed_matrix) + return get_matrix_scaling(self.feed_matrix) @property def reference_face(self): diff --git a/lib/model/session.py b/lib/model/session.py index a9a825a7f3..bff014eb07 100644 --- a/lib/model/session.py +++ b/lib/model/session.py @@ -4,6 +4,7 @@ import logging import tensorflow as tf +from keras.layers import Activation from keras.models import load_model as k_load_model, Model import numpy as np @@ -165,3 +166,19 @@ def load_model_weights(self): with self._session.as_default(): # pylint: disable=not-context-manager with self._session.graph.as_default(): self._model.load_weights(self._model_path) + + def append_softmax_activation(self, layer_index=-1): + """ Append a softmax activation layer to a model + + Occasionally a softmax activation layer needs to be added to a model's output. + This is a convenience fuction to append this layer to the loaded model. + + Parameters + ---------- + layer_index: int, optional + The layer index of the model to select the output from to use as an input to the + softmax activation layer. Default: -1 (The final layer of the model) + """ + logger.debug("Appending Softmax Activation to model: (layer_index: %s)", layer_index) + softmax = Activation("softmax", name="softmax")(self._model.layers[layer_index].output) + self._model = Model(inputs=self._model.input, outputs=[softmax]) diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 9539ab8790..ee866fb68b 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -1,39 +1,35 @@ #!/usr/bin/env python3 """ Base class for Face Masker plugins - Plugins should inherit from this class - See the override methods for which methods are required. +Plugins should inherit from this class - The plugin will receive a dict containing: - {"filename": , - "image": , - "detected_faces": } +See the override methods for which methods are required. - For each source item, the plugin must pass a dict to finalize containing: - {"filename": , - "image": , - "detected_faces": - """ +The plugin will receive a dict containing: + +>>> {"filename": , +>>> "image": , +>>> "detected_faces": } + +For each source item, the plugin must pass a dict to finalize containing: -import logging -import os -import traceback +>>> {"filename": , +>>> "image": , +>>> "detected_faces": } +""" + +import base64 +import zlib import cv2 import numpy as np -import keras -from io import StringIO -from lib.faces_detect import DetectedFace -from lib.aligner import Extract from plugins.extract._base import Extractor, logger -logger = logging.getLogger(__name__) # pylint:disable=invalid-name - -class Masker(Extractor): - """ Aligner plugin _base Object +class Masker(Extractor): # pylint:disable=abstract-method + """ Masker plugin _base Object - All Aligner plugins must inherit from this class + All Masker plugins must inherit from this class Parameters ---------- @@ -42,14 +38,17 @@ class Masker(Extractor): https://github.com/deepfakes-models/faceswap-models for more information model_filename: str The name of the model file to be loaded - normalize_method: {`None`, 'clahe', 'hist', 'mean'}, optional - Normalize the images fed to the aligner. Default: ``None`` Other Parameters ---------------- configfile: str, optional Path to a custom configuration ``ini`` file. Default: Use system configfile + Attributes + ---------- + blur_kernel, int + The size of the kernel for applying gaussian blur to the output of the mask + See Also -------- plugins.extract.align : Aligner plugins @@ -58,18 +57,14 @@ class Masker(Extractor): plugins.extract.align._base : Aligner parent class for extraction plugins. """ - def __init__(self, git_model_id=None, model_filename=None, - configfile=None, input_size=256, output_size=256, coverage_ratio=1.): - logger.debug("Initializing %s: (configfile: %s, input_size: %s, " - "output_size: %s, coverage_ratio: %s)", - self.__class__.__name__, configfile, input_size, output_size, coverage_ratio) + def __init__(self, git_model_id=None, model_filename=None, configfile=None): + logger.debug("Initializing %s: (configfile: %s, )", self.__class__.__name__, configfile) super().__init__(git_model_id, model_filename, configfile=configfile) - self.input_size = input_size - self.output_size = output_size - self.coverage_ratio = coverage_ratio - self.extract = Extract() + self.input_size = 256 # Overide for model specific input_size + self.blur_kernel = 5 # Overide for model specific blur_kernel size + self.coverage_ratio = 1.0 # Overide for model specific coverage_ratio self._plugin_type = "mask" self._faces_per_filename = dict() # Tracking for recompiling face batches @@ -78,12 +73,12 @@ def __init__(self, git_model_id=None, model_filename=None, logger.debug("Initialized %s", self.__class__.__name__) def get_batch(self, queue): - """ Get items for inputting into the aligner from the queue in batches + """ Get items for inputting into the masker from the queue in batches Items are returned from the ``queue`` in batches of :attr:`~plugins.extract._base.Extractor.batchsize` - To ensure consistent batchsizes for aligner the items are split into separate items for + To ensure consistent batchsizes for masker the items are split into separate items for each :class:`lib.faces_detect.DetectedFace` object. Remember to put ``'EOF'`` to the out queue after processing @@ -122,6 +117,10 @@ def get_batch(self, queue): self._queues["out"].put(item) continue for f_idx, face in enumerate(item["detected_faces"]): + face.load_feed_face(face.image, + size=self.input_size, + coverage_ratio=1.0, + dtype="float32") batch.setdefault("detected_faces", []).append(face) batch.setdefault("filename", []).append(item["filename"]) batch.setdefault("image", []).append(item["image"]) @@ -160,11 +159,11 @@ def _collect_item(self, queue): return item def _predict(self, batch): - """ Just return the aligner's predict function """ + """ Just return the masker's predict function """ return self.predict(batch) def finalize(self, batch): - """ Finalize the output from Aligner + """ Finalize the output from Masker This should be called as the final task of each `plugin`. @@ -181,7 +180,7 @@ def finalize(self, batch): ---------- batch : dict The final ``dict`` from the `plugin` process. It must contain the `keys`: - ``detected_faces``, ``landmarks``, ``filename``, ``image`` + ``detected_faces``, ``filename``, ``image`` Yields ------ @@ -190,6 +189,26 @@ def finalize(self, batch): :class:`lib.faces_detect.DetectedFace` objects. """ + if self.blur_kernel is not None: + predicted = np.array([cv2.GaussianBlur(mask, (self.blur_kernel, self.blur_kernel), 0) + for mask in batch["prediction"]]) + else: + predicted = batch["prediction"] + predicted[predicted < 0.04] = 0.0 + predicted[predicted > 0.96] = 1.0 + # TODO Convert this and landmarks_xy to numpy arrays once serialization + # decision is made, Hacky temp fix as can't serialize numpy arrays in json + # and tolist is hugely slow and gobbles ram + for mask, face in zip(batch["prediction"], batch["detected_faces"]): + placeholder = np.zeros(face.image.shape[:2] + (1, ), dtype="float32") + placeholder = (cv2.warpAffine( + mask, + face.feed_matrix, + (face.image.shape[1], face.image.shape[0]), + placeholder, + flags=cv2.WARP_INVERSE_MAP | face.feed_interpolators[1], + borderMode=cv2.BORDER_TRANSPARENT) * 255.0).astype("uint8") + face.mask[self.name] = base64.b64encode(zlib.compress(placeholder)).decode() self._remove_invalid_keys(batch, ("detected_faces", "filename", "image")) logger.trace("Item out: %s", {key: val for key, val in batch.items() @@ -220,39 +239,3 @@ def _resize(image, target_size): resized = cv2.resize(image, (0, 0), fx=scale, fy=scale, interpolation=method) resized = resized if channels > 1 else resized[..., None] return resized - - @staticmethod - def postprocessing(mask): - """ Post-processing of Nirkin style segmentation masks """ - # Select_largest_segment - if pop_small_segments: - results = cv2.connectedComponentsWithStats(mask, # pylint: disable=no-member - 4, - cv2.CV_32S) # pylint: disable=no-member - _, labels, stats, _ = results - segments_ranked_by_area = np.argsort(stats[:, -1])[::-1] - mask[labels != segments_ranked_by_area[0, 0]] = 0. - - # Smooth contours - if smooth_contours: - iters = 2 - kernel = cv2.getStructuringElement(cv2.MORPH_RECT, # pylint: disable=no-member - (5, 5)) - cv2.morphologyEx(mask, cv2.MORPH_OPEN, # pylint: disable=no-member - kernel, iterations=iters) - cv2.morphologyEx(mask, cv2.MORPH_CLOSE, # pylint: disable=no-member - kernel, iterations=iters) - cv2.morphologyEx(mask, cv2.MORPH_CLOSE, # pylint: disable=no-member - kernel, iterations=iters) - cv2.morphologyEx(mask, cv2.MORPH_OPEN, # pylint: disable=no-member - kernel, iterations=iters) - - # Fill holes - if fill_holes: - not_holes = mask.copy() - not_holes = np.pad(not_holes, ((2, 2), (2, 2), (0, 0)), 'constant') - cv2.floodFill(not_holes, None, (0, 0), 255) # pylint: disable=no-member - holes = cv2.bitwise_not(not_holes)[2:-2, 2:-2] # pylint: disable=no-member - mask = cv2.bitwise_or(mask, holes) # pylint: disable=no-member - mask = np.expand_dims(mask, axis=-1) - return mask diff --git a/plugins/extract/mask/components.py b/plugins/extract/mask/components.py index af225e006c..a497e56d4e 100644 --- a/plugins/extract/mask/components.py +++ b/plugins/extract/mask/components.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +""" Components Mask for faceswap.py """ import cv2 import numpy as np @@ -11,44 +12,35 @@ def __init__(self, **kwargs): git_model_id = None model_filename = None super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.input_size = 256 + self.blur_kernel = None self.name = "Components" - self.colorformat = "BGR" - self.vram = 0 - self.vram_warnings = 0 - self.vram_per_batch = 30 - self.batchsize = self.config["batch-size"] + self.vram = 0 # Doesn't use GPU + self.vram_per_batch = 0 + self.batchsize = 1 def init_model(self): logger.debug("No mask model to initialize") def process_input(self, batch): """ Compile the detected faces for prediction """ - batch["feed"] = np.array([face.image for face in batch["detected_faces"]]) + batch["feed"] = np.zeros((self.batchsize, self.input_size, self.input_size, 1), + dtype="float32") return batch def predict(self, batch): """ Run model to get predictions """ - masks = np.zeros(batch["feed"].shape[:-1] + (1,), dtype='uint8') - for mask, face in zip(masks, batch["detected_faces"]): - parts = self.parse_parts(np.array(face.landmarks_xy)) + for mask, face in zip(batch["feed"], batch["detected_faces"]): + parts = self.parse_parts(np.array(face.feed_landmarks)) for item in parts: item = np.concatenate(item) - hull = cv2.convexHull(item).astype('int32') # pylint: disable=no-member - cv2.fillConvexPoly(mask, hull, 255, lineType=cv2.LINE_AA) - batch["prediction"] = masks + hull = cv2.convexHull(item).astype("int32") # pylint: disable=no-member + cv2.fillConvexPoly(mask, hull, 1.0, lineType=cv2.LINE_AA) + batch["prediction"] = batch["feed"] return batch def process_output(self, batch): """ Compile found faces for output """ - generator = zip(batch["feed"], batch["detected_faces"], batch["prediction"]) - for feed, face, prediction in generator: - face.image = np.concatenate((feed, prediction), axis=-1) - face.load_feed_face(face.image, - size=self.input_size, - coverage_ratio=self.coverage_ratio) - face.load_reference_face(face.image, - size=self.output_size, - coverage_ratio=self.coverage_ratio) return batch @staticmethod diff --git a/plugins/extract/mask/components_defaults.py b/plugins/extract/mask/components_defaults.py deleted file mode 100644 index 721ee94539..0000000000 --- a/plugins/extract/mask/components_defaults.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -""" - The default options for the faceswap VGG clear plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} - - should always be lower text. - dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - group: [optional]. A group for grouping options together in the GUI. If not - provided this will not group this option with any others. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" - - -_HELPTEXT = ( - "Components options. Mask designed to provide facial segmentation based on the positioning of " - "landmark locations. A convenx hull is constructed around the exterior of the landmarks to " - "create a mask." - ) - - -_DEFAULTS = { - "batch-size": { - "default": 8, - "info": "The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered." - "\n\tAMD users: A batchsize of 8 requires about xxxx GB vram.", - "datatype": int, - "rounding": 1, - "min_max": (1, 64), - "choices": [], - "gui_radio": False, - "fixed": True, - } -} diff --git a/plugins/extract/mask/extended.py b/plugins/extract/mask/extended.py index 6e61733960..bd84d2df83 100644 --- a/plugins/extract/mask/extended.py +++ b/plugins/extract/mask/extended.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +""" Extended Mask for faceswap.py """ import cv2 import numpy as np @@ -11,44 +12,35 @@ def __init__(self, **kwargs): git_model_id = None model_filename = None super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.input_size = 256 + self.blur_kernel = None self.name = "Extended" - self.colorformat = "BGR" - self.vram = 0 - self.vram_warnings = 0 - self.vram_per_batch = 30 - self.batchsize = self.config["batch-size"] + self.vram = 0 # Doesn't use GPU + self.vram_per_batch = 0 + self.batchsize = 1 def init_model(self): logger.debug("No mask model to initialize") def process_input(self, batch): """ Compile the detected faces for prediction """ - batch["feed"] = np.array([face.image for face in batch["detected_faces"]]) + batch["feed"] = np.zeros((self.batchsize, self.input_size, self.input_size, 1), + dtype="float32") return batch def predict(self, batch): """ Run model to get predictions """ - masks = np.zeros(batch["feed"].shape[:-1] + (1,), dtype='uint8') - for mask, face in zip(masks, batch["detected_faces"]): - parts = self.parse_parts(np.array(face.landmarks_xy)) + for mask, face in zip(batch["feed"], batch["detected_faces"]): + parts = self.parse_parts(np.array(face.feed_landmarks)) for item in parts: item = np.concatenate(item) - hull = cv2.convexHull(item).astype('int32') # pylint: disable=no-member - cv2.fillConvexPoly(mask, hull, 255, lineType=cv2.LINE_AA) - batch["prediction"] = masks + hull = cv2.convexHull(item).astype("int32") # pylint: disable=no-member + cv2.fillConvexPoly(mask, hull, 1.0, lineType=cv2.LINE_AA) + batch["prediction"] = batch["feed"] return batch def process_output(self, batch): """ Compile found faces for output """ - generator = zip(batch["feed"], batch["detected_faces"], batch["prediction"]) - for feed, face, prediction in generator: - face.image = np.concatenate((feed, prediction), axis=-1) - face.load_feed_face(face.image, - size=self.input_size, - coverage_ratio=self.coverage_ratio) - face.load_reference_face(face.image, - size=self.output_size, - coverage_ratio=self.coverage_ratio) return batch @staticmethod diff --git a/plugins/extract/mask/extended_defaults.py b/plugins/extract/mask/extended_defaults.py deleted file mode 100644 index f85996eaa4..0000000000 --- a/plugins/extract/mask/extended_defaults.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -""" - The default options for the faceswap extended mask plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} - - should always be lower text. - dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - group: [optional]. A group for grouping options together in the GUI. If not - provided this will not group this option with any others. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" - - -_HELPTEXT = ( - "Extended options. Mask designed to provide facial segmentation based on the positioning of " - "landmark locations. A convenx hull is constructed around the landmarks and the mask is " - "extended upwards onto the forehead." - ) - - -_DEFAULTS = { - "batch-size": { - "default": 8, - "info": "The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered." - "\n\tAMD users: A batchsize of 8 requires about xxxx GB vram.", - "datatype": int, - "rounding": 1, - "min_max": (1, 64), - "choices": [], - "gui_radio": False, - "fixed": True, - } -} diff --git a/plugins/extract/mask/none.py b/plugins/extract/mask/none.py index 7ba4403cf5..b2cc6a4f55 100644 --- a/plugins/extract/mask/none.py +++ b/plugins/extract/mask/none.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +""" Dummy empty Mask for faceswap.py """ import numpy as np from ._base import Masker, logger @@ -10,37 +11,27 @@ def __init__(self, **kwargs): git_model_id = None model_filename = None super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.name = "without a mask" - self.colorformat = "BGR" + self.input_size = 256 + self.blur_kernel = None + self.name = "None" self.vram = 0 - self.vram_warnings = 0 - self.vram_per_batch = 30 - self.batchsize = self.config["batch-size"] + self.vram_per_batch = 0 + self.batchsize = 1 def init_model(self): logger.debug("No mask model to initialize") def process_input(self, batch): """ Compile the detected faces for prediction """ - batch["feed"] = np.array([face.image for face in batch["detected_faces"]]) + batch["feed"] = np.zeros((self.batchsize, self.input_size, self.input_size, 1), + dtype="float32") return batch def predict(self, batch): """ Run model to get predictions """ - batch["prediction"] = np.full(batch["feed"].shape[:-1] + (1,), - fill_value=255, - dtype='uint8') + batch["prediction"] = np.ones_like(batch["feed"], dtype="float32") return batch def process_output(self, batch): """ Compile found faces for output """ - generator = zip(batch["feed"], batch["detected_faces"], batch["prediction"]) - for feed, face, prediction in generator: - face.image = np.concatenate((feed, prediction), axis=-1) - face.load_feed_face(face.image, - size=self.input_size, - coverage_ratio=self.coverage_ratio) - face.load_reference_face(face.image, - size=self.output_size, - coverage_ratio=self.coverage_ratio) return batch diff --git a/plugins/extract/mask/none_defaults.py b/plugins/extract/mask/none_defaults.py deleted file mode 100644 index 8a97a6e09d..0000000000 --- a/plugins/extract/mask/none_defaults.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python3 -""" - The default options for the faceswap VGG clear plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} - - should always be lower text. - dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - group: [optional]. A group for grouping options together in the GUI. If not - provided this will not group this option with any others. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" - - -_HELPTEXT = ( - "None options. An array of all ones is created to provide a 4th channel that will not mask " - "any portion of the image." - ) - - -_DEFAULTS = { - "batch-size": { - "default": 8, - "info": "The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered." - "\n\tAMD users: A batchsize of 8 requires about xxxx GB vram.", - "datatype": int, - "rounding": 1, - "min_max": (1, 64), - "choices": [], - "gui_radio": False, - "fixed": True, - } -} diff --git a/plugins/extract/mask/unet_dfl.py b/plugins/extract/mask/unet_dfl.py index 9e2151fad3..21ae57e708 100644 --- a/plugins/extract/mask/unet_dfl.py +++ b/plugins/extract/mask/unet_dfl.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 +""" UNET DFL face mask plugin """ -import cv2 -import keras import numpy as np from lib.model.session import KSession from ._base import Masker, logger @@ -14,8 +13,8 @@ def __init__(self, **kwargs): model_filename = "DFL_256_sigmoid_v1.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.name = "U-Net Mask Network(256)" - self.mask_in_size = 256 - self.colorformat = "BGR" + self.input_size = 256 + self.blur_kernel = 5 self.vram = 3440 self.vram_warnings = 1024 # TODO determine self.vram_per_batch = 64 # TODO determine @@ -24,48 +23,22 @@ def __init__(self, **kwargs): def init_model(self): self.model = KSession(self.name, self.model_path, model_kwargs=dict()) self.model.load_model() - self.input = np.zeros((self.batchsize, self.mask_in_size, self.mask_in_size, 3), - dtype="float32") - self.model.predict(self.input) + placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), + dtype="float32") + self.model.predict(placeholder) def process_input(self, batch): """ Compile the detected faces for prediction """ - for index, face in enumerate(batch["detected_faces"]): - face.load_aligned(face.image, - size=self.mask_in_size, - dtype='float32') - self.input[index] = face.aligned["face"][..., :3] - batch["feed"] = self.input / 255. + batch["feed"] = np.array([face.feed_face[..., :3] + for face in batch["detected_faces"]], dtype="float32") / 255.0 + logger.trace("feed shape: %s", batch["feed"].shape) return batch def predict(self, batch): """ Run model to get predictions """ - predictions = self.model.predict(batch["feed"]) - batch["prediction"] = predictions * 255. + batch["prediction"] = self.model.predict(batch["feed"]) return batch def process_output(self, batch): """ Compile found faces for output """ - for idx, (face, predicts) in enumerate(zip(batch["detected_faces"], batch["prediction"])): - generator = (cv2.GaussianBlur(mask, (7, 7), 0) for mask in predicts) - predicted = np.array(tuple(generator)) - predicted[predicted < 10.] = 0. - predicted[predicted > 245.] = 255. - - face.load_feed_face(face.image, - size=self.input_size, - coverage_ratio=self.coverage_ratio) - feed_face = face.feed["face"][..., :3] - feed_mask = self._resize(predicted, self.input_size).astype('uint8') - batch["detected_faces"][idx].feed["face"] = np.concatenate((feed_face, - feed_mask), - axis=-1) - face.load_reference_face(face.image, - size=self.output_size, - coverage_ratio=self.coverage_ratio) - ref_face = face.reference["face"][..., :3] - ref_mask = self._resize(predicted, self.output_size).astype('uint8') - batch["detected_faces"][idx].reference["face"] = np.concatenate((ref_face, - ref_mask), - axis=-1) return batch diff --git a/plugins/extract/mask/unet_dfl_defaults.py b/plugins/extract/mask/unet_dfl_defaults.py index c153920c89..a00b170d7a 100644 --- a/plugins/extract/mask/unet_dfl_defaults.py +++ b/plugins/extract/mask/unet_dfl_defaults.py @@ -44,7 +44,7 @@ _HELPTEXT = ( - "UNET_DFL options. Mask designed to provide smart segmentation of mostly frontal faces. " + "UNET_DFL options. Mask designed to provide smart segmentation of mostly frontal faces.\n" "The mask model has been trained by community members. Insert more commentary on testing " "here. Profile faces may result in sub-par performance." ) @@ -56,8 +56,7 @@ "info": "The batch size to use. To a point, higher batch sizes equal better performance, " "but setting it too high can harm performance.\n" "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered." - "\n\tAMD users: A batchsize of 8 requires about xxxx GB vram.", + "accomodate then this will automatically be lowered.", "datatype": int, "rounding": 1, "min_max": (1, 64), diff --git a/plugins/extract/mask/vgg_clear.py b/plugins/extract/mask/vgg_clear.py index 5ee02a83c9..c22171b341 100644 --- a/plugins/extract/mask/vgg_clear.py +++ b/plugins/extract/mask/vgg_clear.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 +""" VGG Clear face mask plugin """ -import cv2 -import keras import numpy as np from lib.model.session import KSession from ._base import Masker, logger @@ -14,8 +13,8 @@ def __init__(self, **kwargs): model_filename = "Nirkin_300_softmax_v1.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.name = "VGG Mask Network(300)" - self.mask_in_size = 300 - self.colorformat = "BGR" + self.input_size = 300 + self.blur_kernel = 7 self.vram = 2000 # TODO determine self.vram_warnings = 1024 # TODO determine self.vram_per_batch = 64 # TODO determine @@ -24,51 +23,25 @@ def __init__(self, **kwargs): def init_model(self): self.model = KSession(self.name, self.model_path, model_kwargs=dict()) self.model.load_model() - o = keras.layers.core.Activation('softmax', - name='softmax')(self.model._model.layers[-1].output) - self.model._model = keras.models.Model(inputs=self.model._model.input, outputs=[o]) - self.input = np.zeros((self.batchsize, self.mask_in_size, self.mask_in_size, 3), - dtype="float32") - self.model.predict(self.input) + self.model.append_softmax_activation(layer_index=-1) + placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), + dtype="float32") + self.model.predict(placeholder) def process_input(self, batch): """ Compile the detected faces for prediction """ - for index, face in enumerate(batch["detected_faces"]): - face.load_aligned(face.image, - size=self.mask_in_size, - dtype='float32') - self.input[index] = face.aligned["face"][..., :3] - batch["feed"] = self.input - np.mean(self.input, axis=(1, 2))[:, None, None, :] + input_ = np.array([face.feed_face[..., :3] + for face in batch["detected_faces"]], dtype="float32") + batch["feed"] = input_ - np.mean(input_, axis=(1, 2))[:, None, None, :] + logger.trace("feed shape: %s", batch["feed"].shape) return batch def predict(self, batch): """ Run model to get predictions """ predictions = self.model.predict(batch["feed"]) - batch["prediction"] = predictions[..., 1:2] * 255. + batch["prediction"] = predictions[..., -1] return batch def process_output(self, batch): """ Compile found faces for output """ - for idx, (face, predicts) in enumerate(zip(batch["detected_faces"], batch["prediction"])): - generator = (cv2.GaussianBlur(mask, (7, 7), 0) for mask in predicts) - predicted = np.array(tuple(generator)) - predicted[predicted < 10.] = 0. - predicted[predicted > 245.] = 255. - - face.load_feed_face(face.image, - size=self.input_size, - coverage_ratio=self.coverage_ratio) - feed_face = face.feed["face"][..., :3] - feed_mask = self._resize(predicted, self.input_size).astype('uint8') - batch["detected_faces"][idx].feed["face"] = np.concatenate((feed_face, - feed_mask), - axis=-1) - face.load_reference_face(face.image, - size=self.output_size, - coverage_ratio=self.coverage_ratio) - ref_face = face.reference["face"][..., :3] - ref_mask = self._resize(predicted, self.output_size).astype('uint8') - batch["detected_faces"][idx].reference["face"] = np.concatenate((ref_face, - ref_mask), - axis=-1) return batch diff --git a/plugins/extract/mask/vgg_clear_defaults.py b/plugins/extract/mask/vgg_clear_defaults.py index 5b120694ae..003a943248 100644 --- a/plugins/extract/mask/vgg_clear_defaults.py +++ b/plugins/extract/mask/vgg_clear_defaults.py @@ -45,7 +45,7 @@ _HELPTEXT = ( "VGG_Clear options. Mask designed to provide smart segmentation of mostly frontal faces clear " - "of obstructions. Profile faces and obstructions may result in sub-par performance." + "of obstructions.\nProfile faces and obstructions may result in sub-par performance." ) @@ -55,8 +55,7 @@ "info": "The batch size to use. To a point, higher batch sizes equal better performance, " "but setting it too high can harm performance.\n" "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered." - "\n\tAMD users: A batchsize of 8 requires about xxxx GB vram.", + "accomodate then this will automatically be lowered.", "datatype": int, "rounding": 1, "min_max": (1, 64), diff --git a/plugins/extract/mask/vgg_obstructed.py b/plugins/extract/mask/vgg_obstructed.py index 85ae018b32..e1faf44ed8 100644 --- a/plugins/extract/mask/vgg_obstructed.py +++ b/plugins/extract/mask/vgg_obstructed.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 +""" VGG Obstructed face mask plugin """ -import cv2 -import keras import numpy as np from lib.model.session import KSession from ._base import Masker, logger @@ -14,8 +13,8 @@ def __init__(self, **kwargs): model_filename = "Nirkin_500_softmax_v1.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.name = "VGG Mask Network(500)" - self.mask_in_size = 500 - self.colorformat = "BGR" + self.input_size = 500 + self.blur_kernel = 9 self.vram = 3000 # TODO determine self.vram_warnings = 1024 # TODO determine self.vram_per_batch = 64 # TODO determine @@ -24,51 +23,24 @@ def __init__(self, **kwargs): def init_model(self): self.model = KSession(self.name, self.model_path, model_kwargs=dict()) self.model.load_model() - o = keras.layers.core.Activation('softmax', - name='softmax')(self.model._model.layers[-1].output) - self.model._model = keras.models.Model(inputs=self.model._model.input, outputs=[o]) - self.input = np.zeros((self.batchsize, self.mask_in_size, self.mask_in_size, 3), - dtype="float32") - self.model.predict(self.input) + self.model.append_softmax_activation(layer_index=-1) + placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), + dtype="float32") + self.model.predict(placeholder) def process_input(self, batch): """ Compile the detected faces for prediction """ - for index, face in enumerate(batch["detected_faces"]): - face.load_aligned(face.image, - size=self.mask_in_size, - dtype='float32') - self.input[index] = face.aligned["face"][..., :3] - batch["feed"] = self.input - np.mean(self.input, axis=(1, 2))[:, None, None, :] + input_ = [face.feed_face[..., :3] for face in batch["detected_faces"]] + batch["feed"] = input_ - np.mean(input_, axis=(1, 2))[:, None, None, :] + logger.trace("feed shape: %s", batch["feed"].shape) return batch def predict(self, batch): """ Run model to get predictions """ predictions = self.model.predict(batch["feed"]) - batch["prediction"] = predictions[..., 0:1] * -255. + 255. + batch["prediction"] = predictions[..., 0] * -1.0 + 1.0 return batch def process_output(self, batch): """ Compile found faces for output """ - for idx, (face, predicts) in enumerate(zip(batch["detected_faces"], batch["prediction"])): - generator = (cv2.GaussianBlur(mask, (7, 7), 0) for mask in predicts) - predicted = np.array(tuple(generator)) - predicted[predicted < 10.] = 0. - predicted[predicted > 245.] = 255. - - face.load_feed_face(face.image, - size=self.input_size, - coverage_ratio=self.coverage_ratio) - feed_face = face.feed["face"][..., :3] - feed_mask = self._resize(predicted, self.input_size).astype('uint8') - batch["detected_faces"][idx].feed["face"] = np.concatenate((feed_face, - feed_mask), - axis=-1) - face.load_reference_face(face.image, - size=self.output_size, - coverage_ratio=self.coverage_ratio) - ref_face = face.reference["face"][..., :3] - ref_mask = self._resize(predicted, self.output_size).astype('uint8') - batch["detected_faces"][idx].reference["face"] = np.concatenate((ref_face, - ref_mask), - axis=-1) return batch diff --git a/plugins/extract/mask/vgg_obstructed_defaults.py b/plugins/extract/mask/vgg_obstructed_defaults.py index d1ed3bfbbb..89e42cee23 100644 --- a/plugins/extract/mask/vgg_obstructed_defaults.py +++ b/plugins/extract/mask/vgg_obstructed_defaults.py @@ -44,9 +44,9 @@ _HELPTEXT = ( - "VGG_Obstructed options. 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." + "VGG_Obstructed options. Mask designed to provide smart segmentation of mostly frontal " + "faces.\nThe mask model has been specifically trained to recognize some facial obstructions " + "(hands and eyeglasses). Profile faces may result in sub-par performance." ) @@ -56,8 +56,7 @@ "info": "The batch size to use. To a point, higher batch sizes equal better performance, " "but setting it too high can harm performance.\n" "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered." - "\n\tAMD users: A batchsize of 8 requires about xxxx GB vram.", + "accomodate then this will automatically be lowered.", "datatype": int, "rounding": 1, "min_max": (1, 64), diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 14293afcbe..4cc28c92d9 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -59,23 +59,18 @@ class Extractor(): """ def __init__(self, detector, aligner, masker, configfile=None, multiprocess=False, rotate_images=None, min_size=20, - normalize_method=None, input_size=256, output_size=256, coverage_ratio=1.): + normalize_method=None): logger.debug("Initializing %s: (detector: %s, aligner: %s, masker: %s, " "configfile: %s, multiprocess: %s, rotate_images: %s, min_size: %s, " - "normalize_method: %s, input_size: %s, output_size: %s, coverage_ratio: %s)", + "normalize_method: %s)", self.__class__.__name__, detector, aligner, masker, configfile, - multiprocess, rotate_images, min_size, normalize_method, input_size, - output_size, coverage_ratio) + multiprocess, rotate_images, min_size, normalize_method) self.phase = "detect" self._queue_size = 32 self._vram_buffer = 320 # Leave a buffer for VRAM allocation self._detector = self._load_detector(detector, rotate_images, min_size, configfile) self._aligner = self._load_aligner(aligner, configfile, normalize_method) - self._masker = self._load_masker(masker, - configfile, - input_size, - output_size, - coverage_ratio) + self._masker = self._load_masker(masker, configfile) self._is_parallel = self._set_parallel_processing(multiprocess) self._set_extractor_batchsize() self._queues = self._add_queues() @@ -336,14 +331,11 @@ def _load_aligner(aligner, configfile, normalize_method): return aligner @staticmethod - def _load_masker(masker, configfile, input_size, output_size, coverage_ratio): + def _load_masker(masker, configfile): """ Set global arguments and load masker plugin """ masker_name = masker.replace("-", "_").lower() logger.debug("Loading Masker: '%s'", masker_name) - masker = PluginLoader.get_masker(masker_name)(configfile=configfile, - input_size=input_size, - output_size=output_size, - coverage_ratio=coverage_ratio) + masker = PluginLoader.get_masker(masker_name)(configfile=configfile) return masker def _launch_detector(self): @@ -374,15 +366,17 @@ def _launch_masker(self): logger.debug("Launched Masker") def _set_extractor_batchsize(self): - """ + """ Sets the batchsize of the requested plugins based on their vram and vram_per_batch_requirements if the the configured batchsize requires more vram than is available. Nvidia only. """ - if (self._detector.vram == 0 and self._aligner.vram == 0 and self._masker.vram == 0 - or get_backend() != "nvidia"): - logger.debug("Either detector and aligner have no VRAM requirements or not running " - "on Nvidia. Not updating batchsize requirements.") + if get_backend() != "nvidia": + logger.debug("Backend is not Nvidia. Not updating batchsize requirements") + return + if self._detector.vram == 0 and self._aligner.vram == 0 and self._masker.vram == 0: + logger.debug("Either detector, aligner or masker have no VRAM requirements. Not " + "updating batchsize requirements.") return stats = GPUStats().get_card_most_free() vram_free = int(stats["free"]) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 170d476e19..1247d68ad5 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -27,13 +27,14 @@ import cv2 import numpy as np +import tensorflow as tf +from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module + from lib.alignments import Alignments from lib.faces_detect import DetectedFace from lib.training_data import TrainingDataGenerator from lib.utils import FaceswapError, get_folder, get_image_paths from plugins.train._config import Config -from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module -import tensorflow as tf logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -342,7 +343,7 @@ def compile_timelapse_sample(self): batchsize = len(batch["samples"]) images = batch["targets"][self.model.largest_face_index] masks = batch["masks"][0] - sample = self.compile_sample(batchsize, + sample = self.compile_sample(batchsize, samples=batch["samples"], images=images, masks=masks) From 93b4dc61b1f4ba7c68e6d54c285c2dd31cb76d16 Mon Sep 17 00:00:00 2001 From: kilroythethird <44308116+kilroythethird@users.noreply.github.com> Date: Sat, 12 Oct 2019 11:27:46 +0200 Subject: [PATCH 087/981] Plaidml error message when no device is found (#895) --- lib/plaidml_tools.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/plaidml_tools.py b/lib/plaidml_tools.py index f245d6ae46..738388c42c 100644 --- a/lib/plaidml_tools.py +++ b/lib/plaidml_tools.py @@ -184,6 +184,10 @@ def set_largest_gpu(self): if _LOGGER: _LOGGER.debug("Obtaining largest %s device", category) indices = getattr(self, "{}_indices".format(category)) + if not indices: + _LOGGER.error("Failed to automatically detect your GPU.") + _LOGGER.error("Please run `plaidml-setup` to set up your GPU.") + exit() max_vram = max([self.vram[idx] for idx in indices]) if _LOGGER: _LOGGER.debug("Max VRAM: %s", max_vram) From 70ee1252833bdad60960f5191116bd6f67b37b4a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 13 Oct 2019 22:50:28 +0000 Subject: [PATCH 088/981] Serialize masks to alignments file - Add new serializers (npy + compressed) - Remove Serializer option from cli - Revert get_aligned call in scripts/extract - Default alignments to compressed - Size masks to 128px and compress - Remove mask thresholding/blur from generation code - Add Mask class to lib/faces_detect - Revert debug landmarks to aligned face - Revert non-extraction code to staging version --- lib/alignments.py | 30 +++----- lib/cli.py | 9 --- lib/convert.py | 2 +- lib/faces_detect.py | 113 ++++++++++++++++++++++++++++- lib/serializer.py | 106 +++++++++++++++++++++------ lib/training_data.py | 87 +++++++++++++++------- plugins/extract/mask/_base.py | 37 ++++------ plugins/train/_config.py | 15 ++-- plugins/train/model/_base.py | 23 +++--- plugins/train/model/dfaker.py | 2 +- plugins/train/model/dfl_h128.py | 4 +- plugins/train/model/dfl_sae.py | 2 +- plugins/train/model/iae.py | 2 +- plugins/train/model/lightweight.py | 2 +- plugins/train/model/original.py | 2 +- plugins/train/model/realface.py | 4 +- plugins/train/model/unbalanced.py | 4 +- plugins/train/model/villain.py | 2 +- plugins/train/trainer/_base.py | 92 +++++++++++------------ scripts/convert.py | 2 +- scripts/extract.py | 24 +++--- scripts/fsmedia.py | 21 +----- tools/preview.py | 3 +- 23 files changed, 384 insertions(+), 204 deletions(-) diff --git a/lib/alignments.py b/lib/alignments.py index fffb50f73e..294dd1a371 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -18,19 +18,15 @@ class Alignments(): """ Holds processes pertaining to the alignments file. folder: folder alignments file is stored in - filename: Filename of alignments file excluding extension. If a + filename: Filename of alignments file. If a valid extension is provided, then it will be used to - decide the serializer, and the serializer argument will - be ignored. - serializer: If provided, this will be the format that the data is - saved in (if data is to be saved). Can be 'json', 'pickle' - or 'yaml' + decide the serializer otherwise compressed pickle is used. """ # pylint: disable=too-many-public-methods - def __init__(self, folder, filename="alignments", serializer="json"): - logger.debug("Initializing %s: (folder: '%s', filename: '%s', serializer: '%s')", - self.__class__.__name__, folder, filename, serializer) - self.serializer = self.get_serializer(filename, serializer) + def __init__(self, folder, filename="alignments"): + logger.debug("Initializing %s: (folder: '%s', filename: '%s')", + self.__class__.__name__, folder, filename) + self.serializer = self.get_serializer(filename) self.file = self.get_location(folder, filename) self.data = self.load() @@ -74,25 +70,21 @@ def hashes_to_frame(self): # << INIT FUNCTIONS >> # @staticmethod - def get_serializer(filename, serializer): + def get_serializer(filename): """ Set the serializer to be used for loading and saving alignments If a filename with a valid extension is passed in this will be used as the serializer, otherwise the - specified serializer will be used """ - logger.debug("Getting serializer: (filename: '%s', serializer: '%s')", - filename, serializer) + compressed pickle will be used """ + logger.debug("Getting serializer: (filename: '%s')", filename) extension = os.path.splitext(filename)[1] if extension in (".json", ".p", ".yaml", ".yml"): logger.debug("Serializer set from filename extension: '%s'", extension) retval = get_serializer_from_filename(filename) - elif serializer not in ("json", "pickle", "yaml"): - raise ValueError("Error: {} is not a valid serializer. Use " - "'json', 'pickle' or 'yaml'") else: - logger.debug("Serializer set from argument: '%s'", serializer) - retval = get_serializer(serializer) + logger.debug("Returning default Pickle serializer") + retval = get_serializer("compressed") logger.verbose("Using '%s' serializer for alignments", retval.file_extension) return retval diff --git a/lib/cli.py b/lib/cli.py index 4b9b7d2e63..30bbea4a4a 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -532,15 +532,6 @@ def get_optional_arguments(): default_aligner = "fan" argument_list = [] - argument_list.append({"opts": ("--serializer", ), - "type": str.lower, - "dest": "serializer", - "default": "json", - "choices": ("json", "pickle", "yaml"), - "group": "Data", - "help": "Serializer for alignments file. If yaml is chosen and not " - "available, then json will be used as the default " - "fallback."}) argument_list.append({"opts": ("-D", "--detector"), "action": Radio, "type": str.lower, diff --git a/lib/convert.py b/lib/convert.py index bd7d6cd1f9..3f782f2edb 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -141,7 +141,7 @@ def get_new_image(self, predicted, frame_size): predicted["detected_faces"]): predicted_mask = new_face[:, :, -1] if new_face.shape[2] == 4 else None new_face = new_face[:, :, :3] - src_face = detected_face.reference_face / np.array(255.0, dtype="float32") + src_face = detected_face.reference_face interpolator = detected_face.reference_interpolators[1] new_face = self.pre_warp_adjustments(src_face, new_face, detected_face, predicted_mask) diff --git a/lib/faces_detect.py b/lib/faces_detect.py index 3361473f18..2e94bb0e02 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -2,6 +2,8 @@ """ Face and landmarks detection for faceswap.py """ import logging +from zlib import compress, decompress + import cv2 import numpy as np @@ -41,12 +43,12 @@ class DetectedFace(): of 68 `(x, y)` ``tuples`` with each of the landmark co-ordinates. mask: dict The generated mask(s) for the face as generated in :mod:`plugins.extract.mask`. Must be a - dict of `{name (str): mask (numpy.ndarray)} + dict of {**name** (`str`): :class:`Mask`}. """ def __init__(self, image=None, x=None, w=None, y=None, h=None, landmarks_xy=None, mask=None, filename=None): logger.trace("Initializing %s: (image: %s, x: %s, w: %s, y: %s, h:%s, " - "landmarks_xy: %s, filename: %s)", + "landmarks_xy: %s, mask: %s, filename: %s)", self.__class__.__name__, image.shape if image is not None and image.any() else image, x, w, y, h, landmarks_xy, @@ -95,6 +97,34 @@ def training_coverage(self): """ The coverage ratio to add for training images """ return 1.0 + def add_mask(self, name, mask, affine_matrix, frame_dims, interpolator): + """ Add a :class:`Mask` to this detected face + + The mask should be the original output from :mod:`plugins.extract.mask` + If a mask with this name already exists it will be overwritten by the given + mask. + + Parameters + ---------- + name: str + The name of the mask as defined by the :attr:`plugins.extract.mask._base.name` + parameter. + mask: numpy.ndarray + The mask that is to be added as output from :mod:`plugins.extract.mask` + It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` + affine_matrix: numpy.ndarray + The transformation matrix required to transform the mask to the original frame. + frame_dims: tuple + The `(height, width)` dimensions of the original frame that this mask was created from. + interpolator: + The CV2 interpolator required to transform this mask to it's original frame + """ + logger.trace("name: '%s', mask shape: %s, affine_matrix: %s, frame_dims: %s, " + "interpolator: %s", name, mask.shape, affine_matrix, frame_dims, interpolator) + fsmask = Mask() + fsmask.add(mask, affine_matrix, frame_dims, interpolator) + self.mask[name] = fsmask + def to_alignment(self): """ Return the detected face formatted for an alignments file @@ -436,7 +466,7 @@ def reference_interpolators(self): def rotate_landmarks(face, rotation_matrix): """ Rotates the 68 point landmarks and detection bounding box around the given rotation matrix. - Paramaters + Parameters ---------- face: DetectedFace or dict A :class:`DetectedFace` or an `alignments file` ``dict`` containing the 68 point landmarks @@ -517,3 +547,80 @@ def rotate_landmarks(face, rotation_matrix): logger.trace("Rotated landmarks: %s", rotated_landmarks) return face + + +class Mask(): + """ Face Mask information and convenience methods + + Holds a Faceswap mask as generated from :mod:`plugins.extract.mask` and the information + required to transform it to its original frame. + + Holds convenience methods to handle the warping, storing and retrieval of the mask. + + Parameters + ---------- + storage_size: int, optional + The size (in pixels) that the mask should be stored at. Default: 128. + + Attributes + ---------- + storage_dims: tuple + The `(height, width)` of the stored mask. + """ + + def __init__(self, storage_size=128): + self.storage_dims = (storage_size, storage_size) + + self._mask = None + self._original_dims = None + self._affine_matrix = None + self._frame_dims = None + self._intepolator = None + + @property + def mask(self): + """ numpy.ndarray: The mask at the size of :attr:`storage_dims` """ + return decompress(self._mask) + + @property + def full_frame_mask(self): + """ numpy.ndarray: The mask affined to the original full frame """ + mask = np.zeros(self._frame_dims + (1, ), dtype="uint8") + mask = cv2.warpAffine(cv2.resize(self.mask, self._original_dims, cv2.INTER_CUBIC), + self._affine_matrix, + self._frame_dims, + mask, + flags=cv2.WARP_INVERSE_MAP | self._intepolator, + borderMode=cv2.BORDER_TRANSPARENT) + logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, mask max: %s", + mask.shape, mask.dtype, mask.min(), mask.max()) + return mask + + def add(self, mask, affine_matrix, frame_dims, interpolator): + """ Add a Faceswap mask to this :class:`Mask`. + + The mask should be the original output from :mod:`plugins.extract.mask` + + Parameters + ---------- + mask: numpy.ndarray + The mask that is to be added as output from :mod:`plugins.extract.mask` + It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` + affine_matrix: numpy.ndarray + The transformation matrix required to transform the mask to the original frame. + frame_dims: tuple + The `(height, width)` dimensions of the original frame that this mask was created from. + interpolator: + The CV2 interpolator required to transform this mask to it's original frame + """ + logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, mask max: %s, " + "affine_matrix: %s, frame_dims: %s, interpolator: %s", mask.shape, mask.dtype, + mask.min(), mask.max(), affine_matrix, frame_dims, interpolator) + self._original_dims = mask.shape[:2] + self._affine_matrix = affine_matrix + self._frame_dims = frame_dims + self._intepolator = interpolator + mask = (cv2.resize(mask, + self.storage_dims, + interpolation=cv2.INTER_AREA) * 255.0).astype("uint8") + self._mask = compress(mask) diff --git a/lib/serializer.py b/lib/serializer.py index dad5fc67ea..1b5b7e442a 100644 --- a/lib/serializer.py +++ b/lib/serializer.py @@ -2,10 +2,16 @@ """ Library for serializing python objects to and from various different serializer formats """ -import logging + import json +import logging import os import pickle +import zlib + +from io import BytesIO + +import numpy as np from lib.utils import FaceswapError @@ -101,6 +107,7 @@ def load(self, filename): data = s_file.read() logger.debug("stored data type: %s", type(data)) retval = self.unmarshal(data) + except IOError as err: msg = "Error reading from '{}': {}".format(filename, err.strerror) raise FaceswapError(msg) from err @@ -187,7 +194,7 @@ def _marshal(cls, data): @classmethod def _unmarshal(cls, data): - return yaml.load(data.decode("utf-8")) + return yaml.load(data.decode("utf-8"), Loader=yaml.FullLoader) class _JSONSerializer(Serializer): @@ -209,7 +216,7 @@ class _PickleSerializer(Serializer): """ Pickle Serializer """ def __init__(self): super().__init__() - self._file_extension = "p" + self._file_extension = "pickle" @classmethod def _marshal(cls, data): @@ -220,12 +227,54 @@ def _unmarshal(cls, data): return pickle.loads(data) +class _NPYSerializer(Serializer): # pylint:disable=abstract-method + """ NPY Serializer """ + def __init__(self): + super().__init__() + self._file_extension = "npy" + self._bytes = BytesIO() + + def _marshal(self, data): + """ NPY Marshal to bytesIO so standard bytes writer can write out """ + b_handler = BytesIO() + np.save(b_handler, data) + b_handler.seek(0) + return b_handler.read() + + def _unmarshal(self, data): + """ NPY Unmarshal to bytesIO so we can use numpy loader """ + b_handler = BytesIO(data) + retval = np.load(b_handler) + del b_handler + if retval.dtype == "object": + retval = retval[()] + return retval + + +class _CompressedSerializer(Serializer): + """ A compressed pickle serializer for Faceswap """ + def __init__(self): + super().__init__() + self._file_extension = "fsc" + self._child = get_serializer("pickle") + + def _marshal(self, data): + """ Pickle and compress data """ + data = self._child._marshal(data) # pylint: disable=protected-access + return zlib.compress(data) + + def _unmarshal(self, data): + """ Decompress and unpicke data """ + data = zlib.decompress(data) + return self._child._unmarshal(data) # pylint: disable=protected-access + + def get_serializer(serializer): """ Obtain a serializer object Parameters ---------- - serializer: {'json', 'pickle', yaml'} + serializer: {'json', 'pickle', yaml', 'npy', 'compressed'} The required serializer format Returns @@ -237,17 +286,24 @@ def get_serializer(serializer): ------- >>> serializer = get_serializer('json') """ - if serializer.lower() == "json": - return _JSONSerializer() - if serializer.lower() == "pickle": - return _PickleSerializer() - if serializer.lower() == "yaml" and yaml is not None: - return _YAMLSerializer() - if serializer.lower() == "yaml" and yaml is None: + if serializer.lower() == "npy": + retval = _NPYSerializer() + elif serializer.lower() == "compressed": + retval = _CompressedSerializer() + elif serializer.lower() == "json": + retval = _JSONSerializer() + elif serializer.lower() == "pickle": + retval = _PickleSerializer() + elif serializer.lower() == "yaml" and yaml is not None: + retval = _YAMLSerializer() + elif serializer.lower() == "yaml" and yaml is None: logger.warning("You must have PyYAML installed to use YAML as the serializer." "Switching to JSON as the serializer.") - logger.warning("Unrecognized serializer: '%s'. Returning json serializer", serializer) - return _JSONSerializer() + retval = _JSONSerializer + else: + logger.warning("Unrecognized serializer: '%s'. Returning json serializer", serializer) + logger.debug(retval) + return retval def get_serializer_from_filename(filename): @@ -273,13 +329,21 @@ def get_serializer_from_filename(filename): logger.debug("extension: '%s'", extension) if extension == ".json": - return _JSONSerializer() - if extension == ".p": - return _PickleSerializer() - if extension in (".yaml", ".yml") and yaml is not None: - return _YAMLSerializer() - if extension in (".yaml", ".yml") and yaml is None: + retval = _JSONSerializer() + elif extension == ".p": + retval = _PickleSerializer() + elif extension == ".npy": + retval = _NPYSerializer() + elif extension == ".fsc": + retval = _CompressedSerializer() + elif extension in (".yaml", ".yml") and yaml is not None: + retval = _YAMLSerializer() + elif extension in (".yaml", ".yml") and yaml is None: logger.warning("You must have PyYAML installed to use YAML as the serializer.\n" "Switching to JSON as the serializer.") - logger.warning("Unrecognized extension: '%s'. Returning json serializer", extension) - return _JSONSerializer() + retval = _JSONSerializer() + else: + logger.warning("Unrecognized extension: '%s'. Returning json serializer", extension) + retval = _JSONSerializer() + logger.debug(retval) + return retval diff --git a/lib/training_data.py b/lib/training_data.py index dce63676be..52886b6073 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -11,6 +11,7 @@ from scipy.interpolate import griddata from lib.image import batch_convert_color, read_image_batch +from lib.model import masks from lib.multithreading import BackgroundGenerator from lib.utils import FaceswapError @@ -47,12 +48,17 @@ class TrainingDataGenerator(): * **no_flip** (`bool`) - ``True`` if the image shouldn't be randomly flipped as part of \ augmentation, otherwise ``False`` + * **mask_type** (`str`) - The mask type to be used (as defined in \ + :mod:`lib.model.masks`). If not ``None`` then the additional key ``landmarks`` must be \ + provided. + * **warp_to_landmarks** (`bool`) - ``True`` if the random warp method should warp to \ similar landmarks from the other side, ``False`` if the standard random warp method \ should be used. If ``True`` then the additional key ``landmarks`` must be provided. - * **landmarks** (`numpy.ndarray`, `optional`). Required if :attr:`warp_to_landmarks` is \ - ``True``. The 68 point face landmarks from an alignments file. + * **landmarks** (`numpy.ndarray`, `optional`). Required if using a :attr:`mask_type` is \ + not ``None`` or :attr:`warp_to_landmarks` is ``True``. The 68 point face landmarks from \ + an alignments file. config: dict The configuration ``dict`` generated from :file:`config.train.ini` containing the trainer \ @@ -68,6 +74,7 @@ def __init__(self, model_input_size, model_output_shapes, training_opts, config) self._model_input_size = model_input_size self._model_output_shapes = model_output_shapes self._training_opts = training_opts + self._mask_class = self._set__mask_class() self._landmarks = self._training_opts.get("landmarks", None) self._nearest_landmarks = {} @@ -123,7 +130,8 @@ def minibatch_ab(self, images, batchsize, side, :mod:`plugins.train.trainer._base` from the ``masks`` key. * **masks** (`numpy.ndarray`) - A 4-dimensional array containing the target masks in \ - the format (`batchsize`, `height`, `width`, `1`). + the format (`batchsize`, `height`, `width`, `1`). **NB:** This item will only exist \ + in the ``dict`` if the :attr:`mask_type` is not ``None`` * **samples** (`numpy.ndarray`) - A 4-dimensional array containg the samples for \ feeding to the model's predict function for generating preview and timelapse samples. \ @@ -146,6 +154,18 @@ def minibatch_ab(self, images, batchsize, side, return batcher.iterator() # << INTERNAL METHODS >> # + def _set__mask_class(self): + """ Returns the correct mask class from :mod:`lib`.model.masks` as defined in the + :attr:`mask_type` parameter. """ + mask_type = self._training_opts.get("mask_type", None) + if mask_type: + logger.debug("Mask type: '%s'", mask_type) + _mask_class = getattr(masks, mask_type) + else: + _mask_class = None + logger.debug("Mask class: %s", _mask_class) + return _mask_class + def _validate_samples(self, data): """ Ensures that the total number of images within :attr:`images` is greater or equal to the selected :attr:`batchsize`. Raises an exception if this is not the case. """ @@ -187,23 +207,24 @@ def _process_batch(self, filenames, side): logger.trace("Process batch: (filenames: '%s', side: '%s')", filenames, side) batch = read_image_batch(filenames) processed = dict() + to_landmarks = self._training_opts["warp_to_landmarks"] # Initialize processing training size on first image if not self._processing.initialized: self._processing.initialize(batch.shape[1]) # Get Landmarks prior to manipulating the image - if self._training_opts["warp_to_landmarks"]: + if self._mask_class or to_landmarks: batch_src_pts = self._get_landmarks(filenames, batch, side) - batch_dst_pts = self._get_closest_match(filenames, side, batch_src_pts) - warp_kwargs = dict(batch_src_points=batch_src_pts, - batch_dst_points=batch_dst_pts) - else: - warp_kwargs = dict() - # Color Augmentation of the image only + # Color augmentation before mask is added if self._training_opts["augment_color"]: - batch[..., :3] = self._processing.color_adjust(batch[..., :3]) + batch = self._processing.color_adjust(batch) + + # Add mask to batch prior to transforms and warps + if self._mask_class: + batch = np.array([self._mask_class(src_pts, image, channels=4).mask + for src_pts, image in zip(batch_src_pts, batch)]) # Random Transform and flip batch = self._processing.transform(batch) @@ -217,10 +238,15 @@ def _process_batch(self, filenames, side): # Get Targets processed.update(self._processing.get_targets(batch)) - # Random Warp # TODO change masks to have a input mask and a warped target mask - processed["feed"] = [self._processing.warp(batch[..., :3], - self._training_opts["warp_to_landmarks"], - **warp_kwargs)] + # Random Warp + if to_landmarks: + warp_kwargs = dict(batch_src_points=batch_src_pts, + batch_dst_points=self._get_closest_match(filenames, + side, + batch_src_pts)) + else: + warp_kwargs = dict() + processed["feed"] = self._processing.warp(batch[..., :3], to_landmarks, **warp_kwargs) logger.trace("Processed batch: (filenames: %s, side: '%s', processed: %s)", filenames, @@ -232,8 +258,8 @@ def _process_batch(self, filenames, side): def _get_landmarks(self, filenames, batch, side): """ Obtains the 68 Point Landmarks for the images in this batch. This is only called if - config item ``warp_to_landmarks`` is ``True``. If the landmarks for an image cannot be - found, then an error is raised. """ + config item ``warp_to_landmarks`` is ``True`` or if :attr:`mask_type` is not ``None``. If + the landmarks for an image cannot be found, then an error is raised. """ logger.trace("Retrieving landmarks: (filenames: %s, side: '%s')", filenames, side) src_points = [self._landmarks[side].get(sha1(face).hexdigest(), None) for face in batch] @@ -244,7 +270,7 @@ def _get_landmarks(self, filenames, batch, side): msg = ("Files missing alignments for this batch: {}" "\nAt least one of your images does not have a matching entry in your " "alignments file." - "\nIf you are using 'warp to landmarks' then every " + "\nIf you are training with a mask or using 'warp to landmarks' then every " "face you intend to train on must exist within the alignments file." "\nThe specific files that caused this failure are listed above." "\nMost likely there will be more than just these files missing from the " @@ -423,17 +449,18 @@ def get_targets(self, batch): output they will be returned as their own item from the ``masks`` key. * **masks** (`numpy.ndarray`) - A 4-dimensional array containing the target masks in \ - the format (`batchsize`, `height`, `width`, `1`). + the format (`batchsize`, `height`, `width`, `1`). **NB:** This item will only exist \ + in the ``dict`` if a batch of 4 channel images has been passed in :attr:`batch` """ logger.trace("Compiling targets") slices = self._constants["tgt_slices"] target_batch = [np.array([cv2.resize(image[slices, slices, :], (size, size), cv2.INTER_AREA) - for image in batch], dtype='float32') / 255. + for image in batch]) for size in self._output_sizes] logger.trace("Target image shapes: %s", - [tgt_images.shape[1:] for tgt_images in target_batch]) + [tgt.shape for tgt_images in target_batch for tgt in tgt_images]) retval = self._separate_target_mask(target_batch) logger.trace("Final targets: %s", @@ -442,19 +469,25 @@ def get_targets(self, batch): return retval @staticmethod - def _separate_target_mask(size_list_of_batches): + def _separate_target_mask(batch): """ Return the batch and the batch of final masks Returns the targets as a list of 4-dimensional ``numpy.ndarray`` s of shape (`batchsize`, `height`, `width`, 3). If the :attr:`batch` is 4 channels, then the masks will be split from the batch, with the largest output masks being returned in their own item. """ - targets = [batch[..., :3] for batch in size_list_of_batches] - if size_list_of_batches[-1].shape[-1] == 4: - masks = [size_list_of_batches[-1][..., 3:]] + batch = [tgt.astype("float32") / 255.0 for tgt in batch] + if all(tgt.shape[-1] == 4 for tgt in batch): + logger.trace("Batch contains mask") + sizes = [item.shape[1] for item in batch] + mask_batch = np.expand_dims(batch[sizes.index(max(sizes))][..., -1], axis=-1) + batch = [item[..., :3] for item in batch] + logger.trace("batch shapes: %s, mask_batch shape: %s", + [tgt.shape for tgt in batch], mask_batch.shape) + retval = dict(targets=batch, masks=mask_batch) else: - masks = [np.ones((size_list_of_batches[-1].shape[:-1] + (1,)), dtype='float32')] - retval = dict(targets=targets, masks=masks) + logger.trace("Batch has no mask") + retval = dict(targets=batch) return retval # <<< COLOR AUGMENTATION >>> # diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index ee866fb68b..358286905a 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -18,8 +18,6 @@ >>> "detected_faces": } """ -import base64 -import zlib import cv2 import numpy as np @@ -189,26 +187,23 @@ def finalize(self, batch): :class:`lib.faces_detect.DetectedFace` objects. """ - if self.blur_kernel is not None: - predicted = np.array([cv2.GaussianBlur(mask, (self.blur_kernel, self.blur_kernel), 0) - for mask in batch["prediction"]]) - else: - predicted = batch["prediction"] - predicted[predicted < 0.04] = 0.0 - predicted[predicted > 0.96] = 1.0 - # TODO Convert this and landmarks_xy to numpy arrays once serialization - # decision is made, Hacky temp fix as can't serialize numpy arrays in json - # and tolist is hugely slow and gobbles ram + # TODO Migrate these settings to retrieval rather than storage + # if self.blur_kernel is not None: + # predicted = np.array([cv2.GaussianBlur(mask, (self.blur_kernel, self.blur_kernel), 0) + # for mask in batch["prediction"]]) + # else: + # predicted = batch["prediction"] + # predicted[predicted < 0.04] = 0.0 + # predicted[predicted > 0.96] = 1.0 + # TODO Convert landmarks_xy to numpy arrays for mask, face in zip(batch["prediction"], batch["detected_faces"]): - placeholder = np.zeros(face.image.shape[:2] + (1, ), dtype="float32") - placeholder = (cv2.warpAffine( - mask, - face.feed_matrix, - (face.image.shape[1], face.image.shape[0]), - placeholder, - flags=cv2.WARP_INVERSE_MAP | face.feed_interpolators[1], - borderMode=cv2.BORDER_TRANSPARENT) * 255.0).astype("uint8") - face.mask[self.name] = base64.b64encode(zlib.compress(placeholder)).decode() + face.add_mask(self.name, + mask, + face.feed_matrix, + (face.image.shape[1], face.image.shape[0]), + face.feed_interpolators[1]) + face.feed = None + self._remove_invalid_keys(batch, ("detected_faces", "filename", "image")) logger.trace("Item out: %s", {key: val for key, val in batch.items() diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 14db591c20..31d1dd578b 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -8,6 +8,7 @@ from importlib import import_module from lib.config import FaceswapConfig +from lib.model.masks import get_available_masks from lib.utils import full_path_split logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -68,11 +69,15 @@ def set_globals(self): "\n\t87.5%% spans from ear to ear." "\n\t100.0%% is a mugshot.") self.add_item( - section=section, title="replicate_input_mask", datatype=bool, - default=False, group="mask", - info="Dedicate portions of the model to learning how to duplicate the input " - "mask. Increases VRAM usage in exchange for a learning a quick ability " - "to try to replicate more complex mask models.") + section=section, title="mask_type", datatype=str, default="none", + choices=get_available_masks(), group="mask", + info="The mask to be used for training:" + "\n\t none: Doesn't use any mask." + "\n\t components: An improved face hull mask using a facehull of 8 facial parts" + "\n\t dfl_full: An improved face hull mask using a facehull of 3 facial parts" + "\n\t extended: Based on components mask. Extends the eyebrow points to further " + "up the forehead. May perform badly on difficult angles." + "\n\t facehull: Face cutout based on landmarks") self.add_item( section=section, title="mask_blur", datatype=bool, default=False, group="mask", info="Apply gaussian blur to the mask input. This has the effect of smoothing the " diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index b495c086fb..df561c385d 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -105,10 +105,7 @@ def __init__(self, "augment_color": augment_color, "no_flip": no_flip, "pingpong": self.vram_savings.pingpong, - "snapshot_interval": snapshot_interval, - "replicate_input_mask": self.config["replicate_input_mask"], - "penalized_mask_loss": self.config["penalized_mask_loss"]} - + "snapshot_interval": snapshot_interval} if self.multiple_models_in_folder: deprecation_warning("Support for multiple model types within the same folder", @@ -225,6 +222,7 @@ def set_training_data(self): # Force number of preview images to between 2 and 16 self.training_opts["training_size"] = self.state.training_size self.training_opts["no_logs"] = self.state.current_session["no_logs"] + self.training_opts["mask_type"] = self.config.get("mask_type", None) self.training_opts["coverage_ratio"] = self.calculate_coverage_ratio() logger.debug("Set training data: %s", self.training_opts) @@ -262,11 +260,12 @@ def get_inputs(self): logger.debug("Getting inputs") inputs = [Input(shape=self.input_shape, name="face_in")] output_network = [network for network in self.networks.values() if network.is_output][0] - if self.config["replicate_input_mask"] or self.config["penalized_mask_loss"]: - # penalized mask doesn't have a mask ouput, so we can't use output shapes - # mask should always be last output..this needs to be a rule - mask_shape = output_network.output_shapes[-1] - inputs.append(Input(shape=(mask_shape[1:-1] + (1,)), name="mask_in")) + mask_idx = [idx for idx, name in enumerate(output_network.output_names) + if name.startswith("mask")] + if mask_idx: + # Add the final mask shape as input + mask_shape = output_network.output_shapes[mask_idx[0]] + inputs.append(Input(shape=mask_shape[1:], name="mask_in")) logger.debug("Got inputs: %s", inputs) return inputs @@ -445,7 +444,7 @@ def load_models(self, swapped): logger.error("Model could not be found in folder '%s'. Exiting", self.model_dir) exit(0) - if not self.is_legacy or not self.predict: + if not self.is_legacy: K.clear_session() model_mapping = self.map_models(swapped) for network in self.networks.values(): @@ -579,7 +578,7 @@ def rename_legacy(self): self.state.config["coverage"] = 62.5 self.state.config["subpixel_upscaling"] = False self.state.config["reflect_padding"] = False - self.state.config["replicate_input_mask"] = False + self.state.config["mask_type"] = None self.state.config["lowmem"] = False self.encoder_dim = 1024 @@ -744,7 +743,7 @@ def get_loss_functions(self): for idx, loss_name in enumerate(self.names): if loss_name.startswith("mask"): loss_funcs.append(self.selected_mask_loss) - elif self.config["penalized_mask_loss"]: + elif self.mask_input is not None and self.config.get("penalized_mask_loss", False): face_size = self.output_shapes[idx][1] mask_size = self.mask_shape[1] scaling = face_size / mask_size diff --git a/plugins/train/model/dfaker.py b/plugins/train/model/dfaker.py index 9f41e6d43a..758c43b6d0 100644 --- a/plugins/train/model/dfaker.py +++ b/plugins/train/model/dfaker.py @@ -40,7 +40,7 @@ def decoder(self): name="face_out") outputs = [var_x] - if self.config.get("replicate_input_mask", False): + if self.config.get("mask_type", None): var_y = input_ var_y = self.blocks.upscale(var_y, 512) var_y = self.blocks.upscale(var_y, 256) diff --git a/plugins/train/model/dfl_h128.py b/plugins/train/model/dfl_h128.py index 78e140b703..6bf9a6ffd6 100644 --- a/plugins/train/model/dfl_h128.py +++ b/plugins/train/model/dfl_h128.py @@ -50,8 +50,8 @@ def decoder(self): activation="sigmoid", name="face_out") outputs = [var_x] - - if self.config.get("replicate_input_mask", False): + # Mask + if self.config.get("mask_type", None): var_y = input_ var_y = self.blocks.upscale(var_y, self.encoder_dim) var_y = self.blocks.upscale(var_y, self.encoder_dim // 2) diff --git a/plugins/train/model/dfl_sae.py b/plugins/train/model/dfl_sae.py index fd9ad53bb4..e79822c2cf 100644 --- a/plugins/train/model/dfl_sae.py +++ b/plugins/train/model/dfl_sae.py @@ -31,7 +31,7 @@ def architecture(self): @property def use_mask(self): """ Return True if a mask has been set else false """ - return self.config.get("replicate_input_mask", False) + return self.config.get("mask_type", None) is not None @property def ae_dims(self): diff --git a/plugins/train/model/iae.py b/plugins/train/model/iae.py index 4f1c1e8889..b164fef680 100644 --- a/plugins/train/model/iae.py +++ b/plugins/train/model/iae.py @@ -77,7 +77,7 @@ def decoder(self): name="face_out") outputs = [var_x] - if self.config.get("replicate_input_mask", False): + if self.config.get("mask_type", None): var_y = input_ var_y = self.blocks.upscale(var_y, 512) var_y = self.blocks.upscale(var_y, 256) diff --git a/plugins/train/model/lightweight.py b/plugins/train/model/lightweight.py index 44f20922b5..1963c8c1b3 100644 --- a/plugins/train/model/lightweight.py +++ b/plugins/train/model/lightweight.py @@ -47,7 +47,7 @@ def decoder(self): name="face_out") outputs = [var_x] - if self.config.get("replicate_input_mask", False): + if self.config.get("mask_type", None): var_y = input_ var_y = self.blocks.upscale(var_y, 512) var_y = self.blocks.upscale(var_y, 256) diff --git a/plugins/train/model/original.py b/plugins/train/model/original.py index 09bedff639..55d3bea1ea 100644 --- a/plugins/train/model/original.py +++ b/plugins/train/model/original.py @@ -73,7 +73,7 @@ def decoder(self): name="face_out") outputs = [var_x] - if self.config.get("replicate_input_mask", False): + if self.config.get("mask_type", None): var_y = input_ var_y = self.blocks.upscale(var_y, 256) var_y = self.blocks.upscale(var_y, 128) diff --git a/plugins/train/model/realface.py b/plugins/train/model/realface.py index d9504515d4..10562b802c 100644 --- a/plugins/train/model/realface.py +++ b/plugins/train/model/realface.py @@ -134,7 +134,7 @@ def decoder_b(self): outputs = [var_x] - if self.config.get("replicate_input_mask", False): + if self.config.get("mask_type", None) is not None: var_y = var_xy mask_b_complexity = 384 for idx in range(self.upscalers_no-2): @@ -184,7 +184,7 @@ def decoder_a(self): outputs = [var_x] - if self.config.get("replicate_input_mask", False): + if self.config.get("mask_type", None) is not None: var_y = var_xy mask_a_complexity = 384 for idx in range(self.upscalers_no-2): diff --git a/plugins/train/model/unbalanced.py b/plugins/train/model/unbalanced.py index 323639c3bc..b8c2a08b69 100644 --- a/plugins/train/model/unbalanced.py +++ b/plugins/train/model/unbalanced.py @@ -80,7 +80,7 @@ def decoder_a(self): name="face_out") outputs = [var_x] - if self.config.get("replicate_input_mask", False): + if self.config.get("mask_type", None): var_y = input_ var_y = self.blocks.upscale(var_y, decoder_complexity) var_y = self.blocks.upscale(var_y, decoder_complexity) @@ -129,7 +129,7 @@ def decoder_b(self): name="face_out") outputs = [var_x] - if self.config.get("replicate_input_mask", False): + if self.config.get("mask_type", None): var_y = input_ var_y = self.blocks.upscale(var_y, decoder_complexity) if not self.lowmem: diff --git a/plugins/train/model/villain.py b/plugins/train/model/villain.py index c55b2935e1..4a0a67ae27 100644 --- a/plugins/train/model/villain.py +++ b/plugins/train/model/villain.py @@ -78,7 +78,7 @@ def decoder(self): name="face_out") outputs = [var_x] - if self.config.get("replicate_input_mask", False): + if self.config.get("mask_type", None): var_y = input_ var_y = self.blocks.upscale(var_y, 512) var_y = self.blocks.upscale(var_y, 256) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 1247d68ad5..e4aef16d8e 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -7,17 +7,18 @@ A training_opts dictionary can be set in the corresponding model. Accepted values: - alignments: dict containing paths to alignments files for keys 'a' and 'b' - preview_scaling: How much to scale the preview out by - training_size: Size of the training images - coverage_ratio: Ratio of face to be cropped out for training - replicate_input_mask: Replicate input masks with additional model dedicated layers - no_logs: Disable tensorboard logging - snapshot_interval: Interval for saving model snapshots - warp_to_landmarks: Use random_warp_landmarks instead of random_warp - augment_color: Perform random shifting of L*a*b* colors - no_flip: Don't perform a random flip on the image - pingpong: Train each side seperately per save iteration rather than together + alignments: dict containing paths to alignments files for keys 'a' and 'b' + preview_scaling: How much to scale the preview out by + training_size: Size of the training images + coverage_ratio: Ratio of face to be cropped out for training + mask_type: Type of mask to use. See lib.model.masks for valid mask names. + Set to None for not used + no_logs: Disable tensorboard logging + snapshot_interval: Interval for saving model snapshots + warp_to_landmarks: Use random_warp_landmarks instead of random_warp + augment_color: Perform random shifting of L*a*b* colors + no_flip: Don't perform a random flip on the image + pingpong: Train each side seperately per save iteration rather than together """ import logging @@ -89,15 +90,14 @@ def timestamp(self): def landmarks_required(self): """ Return True if Landmarks are required """ opts = self.model.training_opts - retval = opts["warp_to_landmarks"] + retval = bool(opts.get("mask_type", None) or opts["warp_to_landmarks"]) logger.debug(retval) return retval @property def use_mask(self): """ Return True if a mask is requested """ - retval = (self.model.training_opts.get("replicate_input_mask", False) or - self.model.training_opts.get("penalized_mask_loss", True)) + retval = bool(self.model.training_opts.get("mask_type", None)) logger.debug(retval) return retval @@ -176,11 +176,10 @@ def train_one_step(self, viewer, timelapse_kwargs): for side, batcher in self.batchers.items(): if self.pingpong.active and side != self.pingpong.side: continue - loss[side] = batcher.train_one_batch() + loss[side] = batcher.train_one_batch(do_preview) if not do_preview and not do_timelapse: continue if do_preview: - batcher.generate_preview(do_preview) self.samples.images[side] = batcher.compile_sample(None) if do_timelapse: self.timelapse.get_sample(side, timelapse_kwargs) @@ -248,14 +247,13 @@ def __init__(self, side, images, model, use_mask, batch_size, config): self.config = config self.target = None self.samples = None - self.masks = None + self.mask = None generator = self.load_generator() self.feed = generator.minibatch_ab(images, batch_size, self.side) self.preview_feed = None self.timelapse_feed = None - self.set_preview_feed() def load_generator(self): """ Pass arguments to TrainingDataGenerator and return object """ @@ -269,12 +267,12 @@ def load_generator(self): self.config) return generator - def train_one_batch(self): + def train_one_batch(self, do_preview): """ Train a batch """ logger.trace("Training one step: (side: %s)", self.side) - model_inputs, model_targets = self.get_next() + batch = self.get_next(do_preview) try: - loss = self.model.predictors[self.side].train_on_batch(x=model_inputs, y=model_targets) + loss = self.model.predictors[self.side].train_on_batch(*batch) except tf_errors.ResourceExhaustedError as err: msg = ("You do not have enough GPU memory available to train the selected model at " "the selected settings. You can try a number of things:" @@ -290,28 +288,31 @@ def train_one_batch(self): loss = loss if isinstance(loss, list) else [loss] return loss - def get_next(self): + def get_next(self, do_preview): """ Return the next batch from the generator - Items should come out as: (sample, warped, targets, [mask]) """ - logger.debug("Generating targets") + Items should come out as: (warped, target [, mask]) """ batch = next(self.feed) - targets_use_mask = self.model.training_opts["replicate_input_mask"] - model_inputs = batch["feed"] + batch["masks"] if self.use_mask else batch["feed"] - model_targets = batch["targets"] + batch["masks"] if targets_use_mask else batch["targets"] - return model_inputs, model_targets + if self.use_mask: + batch = [[batch["feed"], batch["masks"]], batch["targets"] + [batch["masks"]]] + else: + batch = [batch["feed"], batch["targets"]] + self.generate_preview(do_preview) + return batch def generate_preview(self, do_preview): """ Generate the preview if a preview iteration """ if not do_preview: self.samples = None self.target = None - self.masks = None return logger.debug("Generating preview") + if self.preview_feed is None: + self.set_preview_feed() batch = next(self.preview_feed) self.samples = batch["samples"] - self.target = batch["targets"][self.model.largest_face_index] - self.masks = batch["masks"][0] + self.target = [batch["targets"][self.model.largest_face_index]] + if self.use_mask: + self.target += [batch["masks"]] def set_preview_feed(self): """ Set the preview dictionary """ @@ -326,27 +327,27 @@ def set_preview_feed(self): is_preview=True) logger.debug("Set preview feed. Batchsize: %s", batchsize) - def compile_sample(self, batch_size, samples=None, images=None, masks=None): + def compile_sample(self, batch_size, samples=None, images=None): """ Training samples to display in the viewer """ num_images = self.config.get("preview_images", 14) num_images = min(batch_size, num_images) if batch_size is not None else num_images logger.debug("Compiling samples: (side: '%s', samples: %s)", self.side, num_images) images = images if images is not None else self.target - masks = masks if masks is not None else self.masks - samples = samples if samples is not None else self.samples - retval = [samples[0:num_images], images[0:num_images], masks[0:num_images]] + retval = [samples[0:num_images]] if samples is not None else [self.samples[0:num_images]] + if self.use_mask: + retval.extend(tgt[0:num_images] for tgt in images) + else: + retval.extend(images[0:num_images]) return retval def compile_timelapse_sample(self): """ Timelapse samples """ batch = next(self.timelapse_feed) batchsize = len(batch["samples"]) - images = batch["targets"][self.model.largest_face_index] - masks = batch["masks"][0] - sample = self.compile_sample(batchsize, - samples=batch["samples"], - images=images, - masks=masks) + images = [batch["targets"][self.model.largest_face_index]] + if self.use_mask: + images = images + [batch["masks"]] + sample = self.compile_sample(batchsize, samples=batch["samples"], images=images) return sample def set_timelapse_feed(self, images, batchsize): @@ -415,7 +416,7 @@ def show_sample(self): height = int(figure.shape[0] / width) figure = figure.reshape((width, height) + figure.shape[1:]) figure = stack_images(figure) - figure = np.concatenate((header, figure), axis=0) + figure = np.vstack((header, figure)) logger.debug("Compiled sample") return np.clip(figure * 255, 0, 255).astype('uint8') @@ -517,8 +518,9 @@ def compile_masked(faces, masks): for mask in masks3: mask[np.where((mask == [1., 1., 1.]).all(axis=2))] = [0., 0., 1.] for previews in faces: - images = np.array([cv2.addWeighted(img, # pylint: disable=no-member - 1.0, masks3[idx], 0.3, 0) + images = np.array([cv2.addWeighted(img, 1.0, # pylint: disable=no-member + masks3[idx], 0.3, + 0) for idx, img in enumerate(previews)]) retval.append(images) logger.debug("masked shapes: %s", [faces.shape for faces in retval]) @@ -531,7 +533,7 @@ def overlay_foreground(backgrounds, foregrounds): new_images = list() for idx, img in enumerate(backgrounds): img[offset:offset + foregrounds[idx].shape[0], - offset:offset + foregrounds[idx].shape[1], :3] = foregrounds[idx] + offset:offset + foregrounds[idx].shape[1]] = foregrounds[idx] new_images.append(img) retval = np.array(new_images) logger.debug("Overlayed foreground. Shape: %s", retval.shape) diff --git a/scripts/convert.py b/scripts/convert.py index 1bd8e9978b..8caa6dd977 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -586,7 +586,7 @@ def load_aligned(self, item): def compile_feed_faces(detected_faces): """ Compile the faces for feeding into the predictor """ logger.trace("Compiling feed face. Batchsize: %s", len(detected_faces)) - feed_faces = np.stack([detected_face.feed_face / 255. for detected_face in detected_faces]) + feed_faces = np.stack([detected_face.feed_face for detected_face in detected_faces]) logger.trace("Compiled Feed faces. Shape: %s", feed_faces.shape) return feed_faces diff --git a/scripts/extract.py b/scripts/extract.py index b07d8534a6..8979b1dc1b 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -11,7 +11,7 @@ from lib.image import encode_image_with_hash from lib.multithreading import MultiThread from lib.queue_manager import queue_manager -from lib.utils import get_folder, deprecation_warning +from lib.utils import get_folder from plugins.extract.pipeline import Extractor from scripts.fsmedia import Alignments, Images, PostProcess, Utils @@ -225,12 +225,7 @@ def check_thread_error(self): def output_processing(self, faces, size, filename): """ Prepare faces for output """ - final_faces = list() - for detected_face in faces["detected_faces"]: - filename = self.output_dir / Path(detected_face.filename).stem - final_faces.append({"file_location": filename, - "face": detected_face}) - faces["detected_faces"] = final_faces + self.align_face(faces, size, filename) self.post_process.do_actions(faces) faces_count = len(faces["detected_faces"]) @@ -240,16 +235,27 @@ def output_processing(self, faces, size, filename): if not self.verify_output and faces_count > 1: self.verify_output = True + def align_face(self, faces, size, filename): + """ Align the detected face and add the destination file path """ + final_faces = list() + image = faces["image"] + detected_faces = faces["detected_faces"] + for face in detected_faces: + face.load_aligned(image, size=size) + final_faces.append({"file_location": self.output_dir / Path(filename).stem, + "face": face}) + faces["detected_faces"] = final_faces + def output_faces(self, filename, faces): """ Output faces to save thread """ final_faces = list() for idx, detected_face in enumerate(faces["detected_faces"]): output_file = detected_face["file_location"] - extension = '.png' + extension = Path(filename).suffix out_filename = "{}_{}{}".format(str(output_file), str(idx), extension) face = detected_face["face"] - resized_face = face.feed_face + resized_face = face.aligned_face face.hash, img = encode_image_with_hash(resized_face, extension) self.save_queue.put((out_filename, img)) final_faces.append(face.to_alignment()) diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index dadbe64fa4..31a0bb0e03 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -57,10 +57,8 @@ def __init__(self, arguments, is_extract, input_is_video=False): self.args = arguments self.is_extract = is_extract folder, filename = self.set_folder_filename(input_is_video) - serializer = self.set_serializer() super().__init__(folder, - filename=filename, - serializer=serializer) + filename=filename) logger.debug("Initialized %s", self.__class__.__name__) def set_folder_filename(self, input_is_video): @@ -79,19 +77,6 @@ def set_folder_filename(self, input_is_video): logger.debug("Setting Alignments: (folder: '%s' filename: '%s')", folder, filename) return folder, filename - def set_serializer(self): - """ Set the serializer to be used for loading and - saving alignments """ - if hasattr(self.args, "serializer") and self.args.serializer: - logger.debug("Serializer provided: '%s'", self.args.serializer) - serializer = self.args.serializer - else: - # If there is a full filename then this will be overriden - # by filename extension - serializer = "json" - logger.debug("No Serializer defaulting to: '%s'", serializer) - return serializer - def load(self): """ Override parent loader to handle skip existing on extract """ data = dict() @@ -383,9 +368,9 @@ def process(self, output_item): face = detected_face["face"] logger.trace("Drawing Landmarks. Frame: '%s'. Face: %s", detected_face["file_location"].parts[-1], idx) - aligned_landmarks = face.feed_landmarks + aligned_landmarks = face.aligned_landmarks for (pos_x, pos_y) in aligned_landmarks: - cv2.circle(face.feed_face, # pylint: disable=no-member + cv2.circle(face.feed_landmarks, # pylint: disable=no-member (pos_x, pos_y), 2, (0, 0, 255, 255), -1) diff --git a/tools/preview.py b/tools/preview.py index ef803ec782..87595c09a5 100644 --- a/tools/preview.py +++ b/tools/preview.py @@ -21,6 +21,7 @@ from lib.gui.control_helper import set_slider_rounding from lib.convert import Converter from lib.faces_detect import DetectedFace +from lib.model.masks import get_available_masks from lib.multithreading import MultiThread from lib.utils import FaceswapError, set_system_verbosity from lib.queue_manager import queue_manager @@ -732,7 +733,7 @@ def add_comboboxes(self, parent, defaults): """ Add the comboboxes to the Action Frame """ for opt in self.options: if opt == "mask_type": - choices = ["dfl_full", "components", "extended", "predicted"] + choices = get_available_masks() + ["predicted"] else: choices = PluginLoader.get_available_convert_plugins(opt, True) choices = [self.format_to_display(choice) for choice in choices] From d60118fd6f846e09993bc2bc7b83522fccaaf409 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 14 Oct 2019 09:37:58 +0100 Subject: [PATCH 089/981] Capture cudnn launch error in extract model init --- plugins/extract/_base.py | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 5382bd365d..8d10f2446e 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -327,7 +327,20 @@ def initialize(self, *args, **kwargs): self.queue_size = 1 self._add_queues(kwargs["in_queue"], kwargs["out_queue"], ["predict", "post"]) self._compile_threads() - self.init_model() + try: + self.init_model() + except tf_errors.UnknownError as err: + if "failed to get convolution algorithm" in str(err).lower(): + msg = ("Tensorflow raised an unknown error. This is most likely caused by a " + "failure to launch cuDNN which can occur for some GPU/Tensorflow " + "combinations. You should enable `allow_growth` to attempt to resolve this " + "issue:" + "\nGUI: Go to Settings > Extract Plugins > Global and enable the " + "`allow_growth` option." + "\nCLI: Go to `faceswap/config/extract.ini` and change the `allow_growth " + "option to `True`.") + raise FaceswapError(msg) from err + raise err logger.info("Initialized %s %s with batchsize of %s", self.name, p_type, self.batchsize) def _add_queues(self, in_queue, out_queue, queues): @@ -391,15 +404,17 @@ def _thread_process(self, function, in_queue, out_queue): try: batch = function(batch) except tf_errors.UnknownError as err: - msg = ("Tensorflow raised an unknown error. This is most likely caused by a " - "failure to launch cuDNN which can occur for some GPU/Tensorflow " - "combinations. You should enable `allow_growth` to attempt to resolve this " - "issue:" - "\nGUI: Go to Settings > Extract Plugins > Global and enable the " - "`allow_growth` option." - "\nCLI: Go to `faceswap/config/extract.ini` and change the `allow_growth " - "option to `True`.") - raise FaceswapError(msg) from err + if "failed to get convolution algorithm" in str(err).lower(): + msg = ("Tensorflow raised an unknown error. This is most likely caused by a " + "failure to launch cuDNN which can occur for some GPU/Tensorflow " + "combinations. You should enable `allow_growth` to attempt to resolve " + "this issue:" + "\nGUI: Go to Settings > Extract Plugins > Global and enable the " + "`allow_growth` option." + "\nCLI: Go to `faceswap/config/extract.ini` and change the " + "`allow_growth option to `True`.") + raise FaceswapError(msg) from err + raise err if func_name == "process_output": # Process output items to individual items from batch for item in self.finalize(batch): From bc6ab7313f6a358ed205349f58227fcf199dbe06 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 14 Oct 2019 18:42:59 +0000 Subject: [PATCH 090/981] Mask extraction fixes - Save mask to alignments file as dict - Remove blur_kernel param from plugins - Correctly read out the mask buffer on decompress - Fix full frame mask output - Remove BORDER_TRANSPARENT in warp_affine (it is bugged. Don't use it) - Store the affine matrix for the saved mask size --- lib/faces_detect.py | 242 ++++++++++++++++--------- plugins/extract/mask/_base.py | 7 +- plugins/extract/mask/components.py | 1 - plugins/extract/mask/extended.py | 1 - plugins/extract/mask/none.py | 1 - plugins/extract/mask/unet_dfl.py | 3 +- plugins/extract/mask/vgg_clear.py | 3 +- plugins/extract/mask/vgg_obstructed.py | 3 +- 8 files changed, 166 insertions(+), 95 deletions(-) diff --git a/lib/faces_detect.py b/lib/faces_detect.py index 2e94bb0e02..efb460a720 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -97,7 +97,7 @@ def training_coverage(self): """ The coverage ratio to add for training images """ return 1.0 - def add_mask(self, name, mask, affine_matrix, frame_dims, interpolator): + def add_mask(self, name, mask, affine_matrix, frame_dims, interpolator, storage_size=128): """ Add a :class:`Mask` to this detected face The mask should be the original output from :mod:`plugins.extract.mask` @@ -116,12 +116,14 @@ def add_mask(self, name, mask, affine_matrix, frame_dims, interpolator): The transformation matrix required to transform the mask to the original frame. frame_dims: tuple The `(height, width)` dimensions of the original frame that this mask was created from. - interpolator: + interpolator, int: The CV2 interpolator required to transform this mask to it's original frame + storage_size, int (optional): + The size the mask is to be stored at. """ logger.trace("name: '%s', mask shape: %s, affine_matrix: %s, frame_dims: %s, " "interpolator: %s", name, mask.shape, affine_matrix, frame_dims, interpolator) - fsmask = Mask() + fsmask = Mask(storage_size=storage_size) fsmask.add(mask, affine_matrix, frame_dims, interpolator) self.mask[name] = fsmask @@ -142,7 +144,7 @@ def to_alignment(self): alignment["h"] = self.h alignment["landmarks_xy"] = self.landmarks_xy alignment["hash"] = self.hash - alignment["mask"] = self.mask + alignment["mask"] = {name: mask.to_dict() for name, mask in self.mask.items()} logger.trace("Returning: %s", alignment) return alignment @@ -174,13 +176,17 @@ def from_alignment(self, alignment, image=None): # Manual tool does not know the final hash so default to None self.hash = alignment.get("hash", None) # Manual tool and legacy alignments will not have a mask - self.mask = alignment.get("mask", None) + if alignment.get("mask", None) is not None: + self.mask = dict() + for name, mask_dict in alignment["mask"].items(): + self.mask[name] = Mask() + self.mask[name].from_dict(mask_dict) if image is not None and image.any(): self.image = image self._image_to_face(image) logger.trace("Created from alignment: (x: %s, w: %s, y: %s. h: %s, " - "landmarks: %s)", - self.x, self.w, self.y, self.h, self.landmarks_xy) + "landmarks: %s, mask: %s)", + self.x, self.w, self.y, self.h, self.landmarks_xy, self.mask) def _image_to_face(self, image): """ set self.image to be the cropped face from detected bounding box """ @@ -463,6 +469,151 @@ def reference_interpolators(self): return get_matrix_scaling(self.reference_matrix) +class Mask(): + """ Face Mask information and convenience methods + + Holds a Faceswap mask as generated from :mod:`plugins.extract.mask` and the information + required to transform it to its original frame. + + Holds convenience methods to handle the warping, storing and retrieval of the mask. + + Parameters + ---------- + storage_size: int, optional + The size (in pixels) that the mask should be stored at. Default: 128. + + Attributes + ---------- + stored_size: int + The size, in pixels, of the stored mask across its height and width. + """ + + def __init__(self, storage_size=128): + self.stored_size = storage_size + + self._mask = None + self._affine_matrix = None + self._frame_dims = None + self._intepolator = None + + @property + def mask(self): + """ numpy.ndarray: The mask at the size of :attr:`stored_size` """ + return np.frombuffer(decompress(self._mask), + dtype="uint8").reshape((self.stored_size, self.stored_size, 1)) + + @property + def full_frame_mask(self): + """ numpy.ndarray: The mask affined to the original full frame """ + frame = np.zeros(self._frame_dims + (1, ), dtype="uint8") + mask = cv2.warpAffine(self.mask, + self._affine_matrix, + self._frame_dims, + frame, + flags=cv2.WARP_INVERSE_MAP | self._intepolator, + borderMode=cv2.BORDER_CONSTANT) + logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, mask max: %s", + mask.shape, mask.dtype, mask.min(), mask.max()) + return mask + + def add(self, mask, affine_matrix, frame_dims, interpolator): + """ Add a Faceswap mask to this :class:`Mask`. + + The mask should be the original output from :mod:`plugins.extract.mask` + + Parameters + ---------- + mask: numpy.ndarray + The mask that is to be added as output from :mod:`plugins.extract.mask` + It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` + affine_matrix: numpy.ndarray + The transformation matrix required to transform the mask to the original frame. + frame_dims: tuple + The `(height, width)` dimensions of the original frame that this mask was created from. + interpolator: + The CV2 interpolator required to transform this mask to it's original frame + """ + logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, mask max: %s, " + "affine_matrix: %s, frame_dims: %s, interpolator: %s", mask.shape, mask.dtype, + mask.min(), mask.max(), affine_matrix, frame_dims, interpolator) + self._affine_matrix = self._adjust_affine_matrix(mask.shape[0], affine_matrix) + self._frame_dims = frame_dims + self._intepolator = interpolator + mask = (cv2.resize(mask, + (self.stored_size, self.stored_size), + interpolation=cv2.INTER_AREA) * 255.0).astype("uint8") + self._mask = compress(mask) + + def _adjust_affine_matrix(self, mask_size, affine_matrix): + """ Adjust the affine matrix for the mask's storage size + + Parameters + ---------- + mask_size: int + The original size of the mask. + affine_matrix: numpy.ndarray + The affine matrix to transform the mask at original size to the parent frame. + + Returns + ------- + affine_matrix: numpy,ndarray + The affine matrix adjusted for the mask at its stored dimensions. + """ + zoom = self.stored_size / mask_size + zoom_mat = np.array([[zoom, 0, 0.], [0, zoom, 0.]]) + adjust_mat = np.dot(zoom_mat, np.concatenate((affine_matrix, np.array([[0., 0., 1.]])))) + logger.trace("storage_size: %s, mask_size: %s, zoom: %s, original matrix: %s, " + "adjusted_matrix: %s", self.stored_size, mask_size, zoom, affine_matrix.shape, + adjust_mat.shape) + return adjust_mat + + def to_dict(self): + """ Convert the mask to a dictionary for saving to an alignments file + + Returns + ------- + dict: + The :class:`Mask` for saving to an alignments file. Contains the keys ``mask``, + ``affine_matrix``, ``frame_dims``, ``intepolator``, ``stored_size`` + """ + retval = dict() + for key in ("mask", "affine_matrix", "frame_dims", "intepolator", "stored_size"): + retval[key] = getattr(self, self._attr_name(key)) + logger.trace({k: v if k != "mask" else type(v) for k, v in retval.items()}) + return retval + + def from_dict(self, mask_dict): + """ Populates the :class:`Mask` from a dictionary loaded from an alignments file. + + Parameters + ---------- + mask_dict: dict + A dictionary stored in an alignments file containing the keys ``mask``, + ``affine_matrix``, ``frame_dims``, ``intepolator``, ``stored_size`` + """ + for key in ("mask", "affine_matrix", "frame_dims", "intepolator", "stored_size"): + setattr(self, self._attr_name(key), mask_dict[key]) + logger.trace("{} - {}", key, mask_dict[key] if key != "mask" else type(mask_dict[key])) + + @staticmethod + def _attr_name(dict_key): + """ The :class:`Mask` attribute name for the given dictionary key + + Parameters + ---------- + dict_key: str + The key name from an alignments dictionary + + Returns + ------- + attribute_name: str + The attribute name for the given key for :class:`Mask` + """ + retval = "_{}".format(dict_key) if dict_key != "stored_size" else dict_key + logger.trace("dict_key: %s, attribute_name: %s", dict_key, retval) + return retval + + def rotate_landmarks(face, rotation_matrix): """ Rotates the 68 point landmarks and detection bounding box around the given rotation matrix. @@ -547,80 +698,3 @@ def rotate_landmarks(face, rotation_matrix): logger.trace("Rotated landmarks: %s", rotated_landmarks) return face - - -class Mask(): - """ Face Mask information and convenience methods - - Holds a Faceswap mask as generated from :mod:`plugins.extract.mask` and the information - required to transform it to its original frame. - - Holds convenience methods to handle the warping, storing and retrieval of the mask. - - Parameters - ---------- - storage_size: int, optional - The size (in pixels) that the mask should be stored at. Default: 128. - - Attributes - ---------- - storage_dims: tuple - The `(height, width)` of the stored mask. - """ - - def __init__(self, storage_size=128): - self.storage_dims = (storage_size, storage_size) - - self._mask = None - self._original_dims = None - self._affine_matrix = None - self._frame_dims = None - self._intepolator = None - - @property - def mask(self): - """ numpy.ndarray: The mask at the size of :attr:`storage_dims` """ - return decompress(self._mask) - - @property - def full_frame_mask(self): - """ numpy.ndarray: The mask affined to the original full frame """ - mask = np.zeros(self._frame_dims + (1, ), dtype="uint8") - mask = cv2.warpAffine(cv2.resize(self.mask, self._original_dims, cv2.INTER_CUBIC), - self._affine_matrix, - self._frame_dims, - mask, - flags=cv2.WARP_INVERSE_MAP | self._intepolator, - borderMode=cv2.BORDER_TRANSPARENT) - logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, mask max: %s", - mask.shape, mask.dtype, mask.min(), mask.max()) - return mask - - def add(self, mask, affine_matrix, frame_dims, interpolator): - """ Add a Faceswap mask to this :class:`Mask`. - - The mask should be the original output from :mod:`plugins.extract.mask` - - Parameters - ---------- - mask: numpy.ndarray - The mask that is to be added as output from :mod:`plugins.extract.mask` - It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` - affine_matrix: numpy.ndarray - The transformation matrix required to transform the mask to the original frame. - frame_dims: tuple - The `(height, width)` dimensions of the original frame that this mask was created from. - interpolator: - The CV2 interpolator required to transform this mask to it's original frame - """ - logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, mask max: %s, " - "affine_matrix: %s, frame_dims: %s, interpolator: %s", mask.shape, mask.dtype, - mask.min(), mask.max(), affine_matrix, frame_dims, interpolator) - self._original_dims = mask.shape[:2] - self._affine_matrix = affine_matrix - self._frame_dims = frame_dims - self._intepolator = interpolator - mask = (cv2.resize(mask, - self.storage_dims, - interpolation=cv2.INTER_AREA) * 255.0).astype("uint8") - self._mask = compress(mask) diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 358286905a..0f23296203 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -65,6 +65,8 @@ def __init__(self, git_model_id=None, model_filename=None, configfile=None): self.coverage_ratio = 1.0 # Overide for model specific coverage_ratio self._plugin_type = "mask" + self._storage_name = self.__module__.split(".")[-1].replace("_", "-") + self._storage_size = 128 # Size to store masks at. Leave this at default self._faces_per_filename = dict() # Tracking for recompiling face batches self._rollover = [] # Items that are rolled over from the previous batch in get_batch self._output_faces = [] @@ -197,11 +199,12 @@ def finalize(self, batch): # predicted[predicted > 0.96] = 1.0 # TODO Convert landmarks_xy to numpy arrays for mask, face in zip(batch["prediction"], batch["detected_faces"]): - face.add_mask(self.name, + face.add_mask(self._storage_name, mask, face.feed_matrix, (face.image.shape[1], face.image.shape[0]), - face.feed_interpolators[1]) + face.feed_interpolators[1], + storage_size=self._storage_size) face.feed = None self._remove_invalid_keys(batch, ("detected_faces", "filename", "image")) diff --git a/plugins/extract/mask/components.py b/plugins/extract/mask/components.py index a497e56d4e..78702fbb9e 100644 --- a/plugins/extract/mask/components.py +++ b/plugins/extract/mask/components.py @@ -13,7 +13,6 @@ def __init__(self, **kwargs): model_filename = None super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.input_size = 256 - self.blur_kernel = None self.name = "Components" self.vram = 0 # Doesn't use GPU self.vram_per_batch = 0 diff --git a/plugins/extract/mask/extended.py b/plugins/extract/mask/extended.py index bd84d2df83..be9233ebb5 100644 --- a/plugins/extract/mask/extended.py +++ b/plugins/extract/mask/extended.py @@ -13,7 +13,6 @@ def __init__(self, **kwargs): model_filename = None super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.input_size = 256 - self.blur_kernel = None self.name = "Extended" self.vram = 0 # Doesn't use GPU self.vram_per_batch = 0 diff --git a/plugins/extract/mask/none.py b/plugins/extract/mask/none.py index b2cc6a4f55..a6c31ebe9f 100644 --- a/plugins/extract/mask/none.py +++ b/plugins/extract/mask/none.py @@ -12,7 +12,6 @@ def __init__(self, **kwargs): model_filename = None super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.input_size = 256 - self.blur_kernel = None self.name = "None" self.vram = 0 self.vram_per_batch = 0 diff --git a/plugins/extract/mask/unet_dfl.py b/plugins/extract/mask/unet_dfl.py index 21ae57e708..257e703410 100644 --- a/plugins/extract/mask/unet_dfl.py +++ b/plugins/extract/mask/unet_dfl.py @@ -12,9 +12,8 @@ def __init__(self, **kwargs): git_model_id = 6 model_filename = "DFL_256_sigmoid_v1.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.name = "U-Net Mask Network(256)" + self.name = "U-Net" self.input_size = 256 - self.blur_kernel = 5 self.vram = 3440 self.vram_warnings = 1024 # TODO determine self.vram_per_batch = 64 # TODO determine diff --git a/plugins/extract/mask/vgg_clear.py b/plugins/extract/mask/vgg_clear.py index c22171b341..07b3385035 100644 --- a/plugins/extract/mask/vgg_clear.py +++ b/plugins/extract/mask/vgg_clear.py @@ -12,9 +12,8 @@ def __init__(self, **kwargs): git_model_id = 8 model_filename = "Nirkin_300_softmax_v1.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.name = "VGG Mask Network(300)" + self.name = "VGG Clear" self.input_size = 300 - self.blur_kernel = 7 self.vram = 2000 # TODO determine self.vram_warnings = 1024 # TODO determine self.vram_per_batch = 64 # TODO determine diff --git a/plugins/extract/mask/vgg_obstructed.py b/plugins/extract/mask/vgg_obstructed.py index e1faf44ed8..5f5522619f 100644 --- a/plugins/extract/mask/vgg_obstructed.py +++ b/plugins/extract/mask/vgg_obstructed.py @@ -12,9 +12,8 @@ def __init__(self, **kwargs): git_model_id = 5 model_filename = "Nirkin_500_softmax_v1.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.name = "VGG Mask Network(500)" + self.name = "VGG Obstructed" self.input_size = 500 - self.blur_kernel = 9 self.vram = 3000 # TODO determine self.vram_warnings = 1024 # TODO determine self.vram_per_batch = 64 # TODO determine From 2e744da2cdf285ca56700a6770dd21791760a10b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 15 Oct 2019 10:27:21 +0100 Subject: [PATCH 091/981] Create FUNDING.yml --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..e72452b115 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +patreon: torzdf From 3c29f57c25c2ead0d2eb1202ba42a4bacd73124e Mon Sep 17 00:00:00 2001 From: deepfakes <34667098+deepfakes@users.noreply.github.com> Date: Tue, 15 Oct 2019 10:28:54 +0100 Subject: [PATCH 092/981] Update FUNDING.yml --- .github/FUNDING.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index e72452b115..a983a28097 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -patreon: torzdf +patreon: faceswap From eceee0724177b44f3e7272d43d19191bb5ed27a4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 15 Oct 2019 16:08:20 +0000 Subject: [PATCH 093/981] Legacy alignments update - Remove legacy update hashes - Remove legacy job from alignment-tools - Remove legacy landmark rotation - Add rotate face method to plugins/extract/detect - Update travis test for new alignments extension - Alignments format to .fsa - Remove serializer option from alignments-tool - Auto update legacy format alignment files to new format --- _travis/simple_tests.py | 6 +- lib/alignments.py | 145 +++++----------------------- lib/faces_detect.py | 86 ----------------- lib/gui/utils.py | 4 +- lib/serializer.py | 4 +- plugins/extract/detect/_base.py | 50 ++++++++-- plugins/train/trainer/_base.py | 7 +- scripts/convert.py | 60 ------------ tools/alignments.py | 15 +-- tools/cli.py | 11 +-- tools/lib_alignments/__init__.py | 2 +- tools/lib_alignments/jobs.py | 97 +------------------ tools/lib_alignments/jobs_manual.py | 6 +- tools/lib_alignments/media.py | 44 +-------- 14 files changed, 90 insertions(+), 447 deletions(-) diff --git a/_travis/simple_tests.py b/_travis/simple_tests.py index 89c8776b3c..52d053047b 100644 --- a/_travis/simple_tests.py +++ b/_travis/simple_tests.py @@ -154,7 +154,7 @@ def sort_args(in_path, out_path, sortby="face", groupby="hist", method="rename") "Rename sorted faces.", ( py_exe, "tools.py", "alignments", "-j", "rename", - "-a", pathjoin(vid_base, "test_alignments.json"), + "-a", pathjoin(vid_base, "test_alignments.fsa"), "-fc", pathjoin(vid_base, "faces_sorted"), ) ) @@ -163,7 +163,7 @@ def sort_args(in_path, out_path, sortby="face", groupby="hist", method="rename") "Train lightweight model for 1 iteration with WTL.", train_args( "lightweight", pathjoin(vid_base, "model"), - pathjoin(vid_base, "faces"), pathjoin(vid_base, "test_alignments.json"), + pathjoin(vid_base, "faces"), pathjoin(vid_base, "test_alignments.fsa"), iterations=1, extra_args="-wl" ) ) @@ -172,7 +172,7 @@ def sort_args(in_path, out_path, sortby="face", groupby="hist", method="rename") "Train lightweight model for 5 iterations WITHOUT WTL.", train_args( "lightweight", pathjoin(vid_base, "model"), - pathjoin(vid_base, "faces"), pathjoin(vid_base, "test_alignments.json") + pathjoin(vid_base, "faces"), pathjoin(vid_base, "test_alignments.fsa") ) ) diff --git a/lib/alignments.py b/lib/alignments.py index 294dd1a371..09f9b55352 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -6,10 +6,8 @@ import os from datetime import datetime -import cv2 - -from lib.faces_detect import rotate_landmarks from lib.serializer import get_serializer, get_serializer_from_filename +from lib.utils import FaceswapError logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -26,7 +24,7 @@ class Alignments(): def __init__(self, folder, filename="alignments"): logger.debug("Initializing %s: (folder: '%s', filename: '%s')", self.__class__.__name__, folder, filename) - self.serializer = self.get_serializer(filename) + self.serializer = get_serializer("compressed") self.file = self.get_location(folder, filename) self.data = self.load() @@ -69,38 +67,21 @@ def hashes_to_frame(self): # << INIT FUNCTIONS >> # - @staticmethod - def get_serializer(filename): - """ Set the serializer to be used for loading and - saving alignments - - If a filename with a valid extension is passed in - this will be used as the serializer, otherwise the - compressed pickle will be used """ - logger.debug("Getting serializer: (filename: '%s')", filename) - extension = os.path.splitext(filename)[1] - if extension in (".json", ".p", ".yaml", ".yml"): - logger.debug("Serializer set from filename extension: '%s'", extension) - retval = get_serializer_from_filename(filename) - else: - logger.debug("Returning default Pickle serializer") - retval = get_serializer("compressed") - logger.verbose("Using '%s' serializer for alignments", retval.file_extension) - return retval - def get_location(self, folder, filename): """ Return the path to alignments file """ logger.debug("Getting location: (folder: '%s', filename: '%s')", folder, filename) extension = os.path.splitext(filename)[1] if extension in (".json", ".p", ".yaml", ".yml"): - logger.debug("File extension set from filename: '%s'", extension) - location = os.path.join(str(folder), filename) - else: - location = os.path.join(str(folder), - "{}.{}".format(filename, - self.serializer.file_extension)) + # Reformat legacy alignments file + filename = self.update_file_format(folder, filename) + logger.debug("Updated legacy alignments. New filename: '%s'", filename) + elif not extension: + filename = "{}.{}".format(filename, self.serializer.file_extension) logger.debug("File extension set from serializer: '%s'", self.serializer.file_extension) + elif extension != ".fsa": + raise FaceswapError("{} is not a valid alignments file".format(filename)) + location = os.path.join(str(folder), filename) logger.verbose("Alignments filepath: '%s'", location) return location @@ -111,8 +92,8 @@ def load(self): Override for custom loading logic """ logger.debug("Loading alignments") if not self.have_alignments_file: - raise ValueError("Error: Alignments file not found at " - "{}".format(self.file)) + raise FaceswapError("Error: Alignments file not found at " + "{}".format(self.file)) logger.info("Reading alignments from: '%s'", self.file) data = self.serializer.load(self.file) @@ -262,95 +243,21 @@ def update_legacy(self): if self.has_legacy_landmarksxy(): logger.info("Updating legacy alignments") self.update_legacy_landmarksxy() - # < Rotation > # - # The old rotation method would rotate the image to find a face, then - # store the rotated landmarks along with a rotation value to tell the - # convert process that it had to rotate the frame to find the landmarks. - # This is problematic for numerous reasons. The process now rotates the - # landmarks to correctly correspond with the original frame. The below are - # functions to convert legacy alignments to the currently supported - # infrastructure. - # This can eventually be removed - - def get_legacy_rotation(self): - """ Return a list of frames with legacy rotations - Looks for an 'r' value in the alignments file that - is not zero """ - logger.debug("Getting alignments containing legacy rotations") - keys = list() - for key, val in self.data.items(): - if any(alignment.get("r", None) for alignment in val): - keys.append(key) - logger.debug("Got alignments containing legacy rotations: %s", len(keys)) - return keys - - def rotate_existing_landmarks(self, frame_name, frame): - """ Backwards compatability fix. Rotates the landmarks to - their correct position and deletes r - - NB: The original frame must be passed in otherwise - the transformation cannot be performed """ - logger.trace("Rotating existing landmarks for frame: '%s'", frame_name) - dims = frame.shape[:2] - for face in self.get_faces_in_frame(frame_name): - angle = face.get("r", 0) - if not angle: - logger.trace("Landmarks do not require rotation: '%s'", frame_name) - return - logger.trace("Rotating landmarks: (frame: '%s', angle: %s)", frame_name, angle) - r_mat = self.get_original_rotation_matrix(dims, angle) - rotate_landmarks(face, r_mat) - del face["r"] - logger.trace("Rotatated existing landmarks for frame: '%s'", frame_name) - @staticmethod - def get_original_rotation_matrix(dimensions, angle): - """ Calculate original rotation matrix and invert """ - logger.trace("Getting original rotation matrix: (dimensions: %s, angle: %s)", - dimensions, angle) - height, width = dimensions - center = (width/2, height/2) - r_mat = cv2.getRotationMatrix2D( # pylint: disable=no-member - center, -1.0 * angle, 1.) - - abs_cos = abs(r_mat[0, 0]) - abs_sin = abs(r_mat[0, 1]) - rotated_width = int(height*abs_sin + width*abs_cos) - rotated_height = int(height*abs_cos + width*abs_sin) - r_mat[0, 2] += rotated_width/2 - center[0] - r_mat[1, 2] += rotated_height/2 - center[1] - logger.trace("Returning rotation matrix: %s", r_mat) - return r_mat - - # # - # The old index based method of face matching is problematic. - # The SHA1 Hash of the extracted face is now stored in the alignments file. - # This has it's own issues, but they are far reduced from the index/filename method - # This can eventually be removed - def get_legacy_no_hashes(self): - """ Get alignments without face hashes """ - logger.debug("Getting alignments without face hashes") - keys = list() - for key, val in self.data.items(): - for alignment in val: - if "hash" not in alignment.keys(): - keys.append(key) - break - logger.debug("Got alignments without face hashes: %s", len(keys)) - return keys - - def add_face_hashes(self, frame_name, hashes): - """ Backward compatability fix. Add face hash to alignments """ - logger.trace("Adding face hash: (frame: '%s', hashes: %s)", frame_name, hashes) - faces = self.get_faces_in_frame(frame_name) - count_match = len(faces) - len(hashes) - if count_match != 0: - msg = "more" if count_match > 0 else "fewer" - logger.warning("There are %s %s face(s) in the alignments file than exist in the " - "faces folder. Check your sources for frame '%s'.", - abs(count_match), msg, frame_name) - for idx, i_hash in hashes.items(): - faces[idx]["hash"] = i_hash + # # + # Serializer is now a compressed pickle .fsa format. This used to be any number of serializers + def update_file_format(self, folder, filename): + """ Convert old style alignments format to new style format """ + logger.info("Reformatting legacy alignments file...") + old_location = os.path.join(str(folder), filename) + new_location = "{}.{}".format(os.path.splitext(old_location)[0], + self.serializer.file_extension) + logger.info("Old location: '%s', New location: '%s'", old_location, new_location) + + load_serializer = get_serializer_from_filename(old_location) + data = load_serializer.load(old_location) + self.serializer.save(new_location, data) + return os.path.basename(new_location) # # # Landmarks renamed from landmarksXY to landmarks_xy for PEP compliance diff --git a/lib/faces_detect.py b/lib/faces_detect.py index efb460a720..8f89d83f5f 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -612,89 +612,3 @@ def _attr_name(dict_key): retval = "_{}".format(dict_key) if dict_key != "stored_size" else dict_key logger.trace("dict_key: %s, attribute_name: %s", dict_key, retval) return retval - - -def rotate_landmarks(face, rotation_matrix): - """ Rotates the 68 point landmarks and detection bounding box around the given rotation matrix. - - Parameters - ---------- - face: DetectedFace or dict - A :class:`DetectedFace` or an `alignments file` ``dict`` containing the 68 point landmarks - and the `x`, `w`, `y`, `h` detection bounding box points. - rotation_matrix: numpy.ndarray - The rotation matrix to rotate the given object by. - - Returns - ------- - DetectedFace or dict - The rotated :class:`DetectedFace` or `alignments file` ``dict`` with the landmarks and - detection bounding box points rotated by the given matrix. The return type is the same as - the input type for ``face`` - """ - logger.trace("Rotating landmarks: (rotation_matrix: %s, type(face): %s", - rotation_matrix, type(face)) - rotated_landmarks = None - # Detected Face Object - if isinstance(face, DetectedFace): - bounding_box = [[face.x, face.y], - [face.x + face.w, face.y], - [face.x + face.w, face.y + face.h], - [face.x, face.y + face.h]] - landmarks = face.landmarks_xy - - # Alignments Dict - elif isinstance(face, dict) and "x" in face: - bounding_box = [[face.get("x", 0), face.get("y", 0)], - [face.get("x", 0) + face.get("w", 0), - face.get("y", 0)], - [face.get("x", 0) + face.get("w", 0), - face.get("y", 0) + face.get("h", 0)], - [face.get("x", 0), - face.get("y", 0) + face.get("h", 0)]] - landmarks = face.get("landmarks_xy", list()) - - else: - raise ValueError("Unsupported face type") - - logger.trace("Original landmarks: %s", landmarks) - - rotation_matrix = cv2.invertAffineTransform( - rotation_matrix) - rotated = list() - for item in (bounding_box, landmarks): - if not item: - continue - points = np.array(item, np.int32) - points = np.expand_dims(points, axis=0) - transformed = cv2.transform(points, - rotation_matrix).astype(np.int32) - rotated.append(transformed.squeeze()) - - # Bounding box should follow x, y planes, so get min/max - # for non-90 degree rotations - pt_x = min([pnt[0] for pnt in rotated[0]]) - pt_y = min([pnt[1] for pnt in rotated[0]]) - pt_x1 = max([pnt[0] for pnt in rotated[0]]) - pt_y1 = max([pnt[1] for pnt in rotated[0]]) - width = pt_x1 - pt_x - height = pt_y1 - pt_y - - if isinstance(face, DetectedFace): - face.x = int(pt_x) - face.y = int(pt_y) - face.w = int(width) - face.h = int(height) - face.r = 0 - if len(rotated) > 1: - rotated_landmarks = [tuple(point) for point in rotated[1].tolist()] - face.landmarks_xy = rotated_landmarks - else: - face["left"] = int(pt_x) - face["top"] = int(pt_y) - face["right"] = int(pt_x1) - face["bottom"] = int(pt_y1) - rotated_landmarks = face - - logger.trace("Rotated landmarks: %s", rotated_landmarks) - return face diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 095a918e63..abe40c129d 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -92,9 +92,7 @@ def filetypes(self): """ Set the filetypes for opening/saving """ all_files = ("All files", "*.*") filetypes = {"default": (all_files,), - "alignments": [("JSON", "*.json"), - ("Pickle", "*.p"), - ("YAML", "*.yaml *.yml"), + "alignments": [("Faceswap Alignments", "*.fsa"), all_files], "config": [("Faceswap GUI config files", "*.fsw"), all_files], "csv": [("Comma separated values", "*.csv"), all_files], diff --git a/lib/serializer.py b/lib/serializer.py index 1b5b7e442a..eca9222260 100644 --- a/lib/serializer.py +++ b/lib/serializer.py @@ -255,7 +255,7 @@ class _CompressedSerializer(Serializer): """ A compressed pickle serializer for Faceswap """ def __init__(self): super().__init__() - self._file_extension = "fsc" + self._file_extension = "fsa" self._child = get_serializer("pickle") def _marshal(self, data): @@ -334,7 +334,7 @@ def get_serializer_from_filename(filename): retval = _PickleSerializer() elif extension == ".npy": retval = _NPYSerializer() - elif extension == ".fsc": + elif extension == ".fsa": retval = _CompressedSerializer() elif extension in (".yaml", ".yml") and yaml is not None: retval = _YAMLSerializer() diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index 81907e03a9..21c31835e8 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -18,7 +18,7 @@ import cv2 import numpy as np -from lib.faces_detect import DetectedFace, rotate_landmarks +from lib.faces_detect import DetectedFace from plugins.extract._base import Extractor, logger @@ -164,7 +164,7 @@ def finalize(self, batch): for faces in batch["prediction"]] # Rotations if any(m.any() for m in batch["rotmat"]) and any(batch_faces): - batch_faces = [[self._rotate_rect(face, rotmat) if rotmat.any() else face + batch_faces = [[self._rotate_face(face, rotmat) if rotmat.any() else face for face in faces] for faces, rotmat in zip(batch_faces, batch["rotmat"])] @@ -363,11 +363,47 @@ def _rotate_batch(self, batch, angle): batch["rotmat"] = retval["rotmat"] @staticmethod - def _rotate_rect(bounding_box, rotation_matrix): - """ Rotate a bounding box dict based on the rotation_matrix""" - logger.trace("Rotating bounding box") - bounding_box = rotate_landmarks(bounding_box, rotation_matrix) - return bounding_box + def _rotate_face(face, rotation_matrix): + """ Rotates the detection bounding box around the given rotation matrix. + + Parameters + ---------- + face: :class:`DetectedFace` + A :class:`DetectedFace` containing the `x`, `w`, `y`, `h` detection bounding box + points. + rotation_matrix: numpy.ndarray + The rotation matrix to rotate the given object by. + + Returns + ------- + :class:`DetectedFace` + The same class with the detection bounding box points rotated by the given matrix. + """ + logger.trace("Rotating face: (face: %s, rotation_matrix: %s)", face, rotation_matrix) + bounding_box = [[face.left, face.top], + [face.right, face.top], + [face.right, face.bottom], + [face.left, face.bottom]] + rotation_matrix = cv2.invertAffineTransform(rotation_matrix) + + points = np.array(bounding_box, "int32") + points = np.expand_dims(points, axis=0) + transformed = cv2.transform(points, rotation_matrix).astype("int32") + rotated = transformed.squeeze() + + # Bounding box should follow x, y planes, so get min/max for non-90 degree rotations + pt_x = min([pnt[0] for pnt in rotated]) + pt_y = min([pnt[1] for pnt in rotated]) + pt_x1 = max([pnt[0] for pnt in rotated]) + pt_y1 = max([pnt[1] for pnt in rotated]) + width = pt_x1 - pt_x + height = pt_y1 - pt_y + + face.x = int(pt_x) + face.y = int(pt_y) + face.w = int(width) + face.h = int(height) + return face def _rotate_image_by_angle(self, image, angle): """ Rotate an image by a given angle. diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index e4aef16d8e..2e71e07236 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -676,12 +676,7 @@ def get_alignments(self): landmarks = dict() for side, fullpath in self.paths.items(): path, filename = os.path.split(fullpath) - filename, extension = os.path.splitext(filename) - serializer = extension[1:] - alignments = Alignments( - path, - filename=filename, - serializer=serializer) + alignments = Alignments(path, filename=filename) landmarks[side] = self.transform_landmarks(alignments) return landmarks diff --git a/scripts/convert.py b/scripts/convert.py index 8caa6dd977..f150c130fe 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -38,8 +38,6 @@ def __init__(self, arguments): self.images = Images(self.args) self.validate() self.alignments = Alignments(self.args, False, self.images.is_video) - # Update Legacy alignments - Legacy(self.alignments, self.images.input_images, arguments.input_aligned_dir) self.opts = OptionalActions(self.args, self.images.input_images, self.alignments) self.add_queues() @@ -690,61 +688,3 @@ def get_face_hashes(self): logger.warning("Aligned directory contains far fewer images than the input " "directory, are you sure this is the right folder?") return face_hashes - - -class Legacy(): - """ Update legacy alignments: - - Rotate landmarks and bounding boxes on legacy alignments - and remove the 'r' parameter - - Add face hashes to alignments file - """ - def __init__(self, alignments, frames, faces_dir): - self.alignments = alignments - self.frames = {os.path.basename(frame): frame - for frame in frames} - self.process(faces_dir) - - def process(self, faces_dir): - """ Run the rotate alignments process """ - rotated = self.alignments.get_legacy_rotation() - hashes = self.alignments.get_legacy_no_hashes() - if not rotated and not hashes: - return - if rotated: - logger.info("Legacy rotated frames found. Converting...") - self.rotate_landmarks(rotated) - self.alignments.save() - if hashes and faces_dir: - logger.info("Legacy alignments found. Adding Face Hashes...") - self.add_hashes(hashes, faces_dir) - self.alignments.save() - - def rotate_landmarks(self, rotated): - """ Rotate the landmarks """ - for rotate_item in tqdm(rotated, desc="Rotating Landmarks"): - frame = self.frames.get(rotate_item, None) - if frame is None: - logger.debug("Skipping missing frame: '%s'", rotate_item) - continue - self.alignments.rotate_existing_landmarks(rotate_item, frame) - - def add_hashes(self, hashes, faces_dir): - """ Add Face Hashes to the alignments file """ - all_faces = dict() - face_files = sorted(face for face in os.listdir(faces_dir) if "_" in face) - for face in face_files: - filename, extension = os.path.splitext(face) - index = filename[filename.rfind("_") + 1:] - if not index.isdigit(): - continue - orig_frame = filename[:filename.rfind("_")] + extension - all_faces.setdefault(orig_frame, dict())[int(index)] = os.path.join(faces_dir, face) - - for frame in tqdm(hashes): - if frame not in all_faces.keys(): - logger.warning("Skipping missing frame: '%s'", frame) - continue - hash_faces = all_faces[frame] - for index, face_path in hash_faces.items(): - hash_faces[index] = read_image_hash(face_path) - self.alignments.add_face_hashes(frame, hash_faces) diff --git a/tools/alignments.py b/tools/alignments.py index 27cee52136..a76742df95 100644 --- a/tools/alignments.py +++ b/tools/alignments.py @@ -4,7 +4,7 @@ from lib.utils import set_system_verbosity from .lib_alignments import (AlignmentData, Check, Draw, # noqa pylint: disable=unused-import - Extract, Legacy, Manual, Merge, Reformat, Rename, + Extract, Manual, Merge, Reformat, Rename, RemoveAlignments, Sort, Spatial, UpdateHashes) logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -29,22 +29,13 @@ def load_alignments(self): logger.error("More than one alignments file required for merging") exit(0) - dest_format = self.get_dest_format() if len(self.args.alignments_file) == 1: - retval = AlignmentData(self.args.alignments_file[0], dest_format) + retval = AlignmentData(self.args.alignments_file[0]) else: - retval = [AlignmentData(a_file, dest_format) for a_file in self.args.alignments_file] + retval = [AlignmentData(a_file) for a_file in self.args.alignments_file] logger.debug("Alignments: %s", retval) return retval - def get_dest_format(self): - """ Set the destination format for Alignments """ - dest_format = None - if hasattr(self.args, 'alignment_format') and self.args.alignment_format: - dest_format = self.args.alignment_format - logger.debug(dest_format) - return dest_format - def process(self): """ Main processing function of the Align tool """ if self.args.job == "update-hashes": diff --git a/tools/cli.py b/tools/cli.py index 3480698337..003775bf5e 100644 --- a/tools/cli.py +++ b/tools/cli.py @@ -32,7 +32,7 @@ def get_argument_list(self): "action": Radio, "type": str, "choices": ("draw", "extract", "manual", "merge", "missing-alignments", - "missing-frames", "legacy", "leftover-faces", "multi-faces", "no-faces", + "missing-frames", "leftover-faces", "multi-faces", "no-faces", "reformat", "remove-faces", "remove-frames", "rename", "sort-x", "sort-y", "spatial", "update-hashes"), "required": True, @@ -54,9 +54,6 @@ def get_argument_list(self): "alignments file." + output_opts + frames_dir + "\nL|'missing-frames': Identify frames in the alignments file that do not " "appear within the frames folder/video." + output_opts + frames_dir + - "\nL|'legacy': This updates legacy alignments to the latest format by " - "rotating the landmarks and bounding boxes and adding face_hashes." + - frames_and_faces_dir + "\nL|'leftover-faces': Identify faces in the faces folder that do not exist " "in the alignments file." + output_opts + faces_dir + "\nL|'multi-faces': Identify where multiple faces exist within the alignments " @@ -109,12 +106,6 @@ def get_argument_list(self): "group": "data", "help": "Directory containing source frames " "that faces were extracted from."}) - argument_list.append({"opts": ("-fmt", "--alignment_format"), - "type": str, - "choices": ("json", "pickle", "yaml"), - "group": "data", - "help": "The file format to save the alignment " - "data in. Defaults to same as source."}) argument_list.append({ "opts": ("-o", "--output"), "action": Radio, diff --git a/tools/lib_alignments/__init__.py b/tools/lib_alignments/__init__.py index 95a74e602b..f23a628cf7 100644 --- a/tools/lib_alignments/__init__.py +++ b/tools/lib_alignments/__init__.py @@ -1,4 +1,4 @@ from tools.lib_alignments.media import AlignmentData, ExtractedFaces, Faces, Frames from tools.lib_alignments.annotate import Annotate -from tools.lib_alignments.jobs import Check, Draw, Extract, Legacy, Merge, Reformat, RemoveAlignments, Rename, Sort, Spatial, UpdateHashes +from tools.lib_alignments.jobs import Check, Draw, Extract, Merge, Reformat, RemoveAlignments, Rename, Sort, Spatial, UpdateHashes from tools.lib_alignments.jobs_manual import Manual diff --git a/tools/lib_alignments/jobs.py b/tools/lib_alignments/jobs.py index c677ec3cf6..f2d74cd511 100644 --- a/tools/lib_alignments/jobs.py +++ b/tools/lib_alignments/jobs.py @@ -283,9 +283,6 @@ def set_output(self): def process(self): """ Run the draw alignments process """ - legacy = Legacy(self.alignments, None, frames=self.frames, child_process=True) - legacy.process() - logger.info("[DRAW LANDMARKS]") # Tidy up cli output self.extracted_faces = ExtractedFaces(self.frames, self.alignments, size=256) frames_drawn = 0 @@ -414,74 +411,6 @@ def select_valid_faces(self, frame): return valid_faces -class Legacy(): - """ Update legacy alignments: - - Rotate landmarks and bounding boxes on legacy alignments - and remove the 'r' parameter - - Add face hashes to alignments file - """ - - def __init__(self, alignments, arguments, frames=None, faces=None, child_process=False): - logger.debug("Initializing %s: (arguments: %s, child_process: %s)", - self.__class__.__name__, arguments, child_process) - self.alignments = alignments - if child_process: - self.frames = frames - self.faces = faces - else: - self.frames = Frames(arguments.frames_dir) - self.faces = Faces(arguments.faces_dir) - logger.debug("Initialized %s", self.__class__.__name__) - - def process(self): - """ Run the rotate alignments process """ - rotated = self.alignments.get_legacy_rotation() - hashes = self.alignments.get_legacy_no_hashes() - if (not self.frames or not rotated) and (not self.faces or not hashes): - return - logger.info("[UPDATE LEGACY LANDMARKS]") # Tidy up cli output - if rotated and self.frames: - logger.info("Legacy rotated frames found. Converting...") - self.rotate_landmarks(rotated) - self.alignments.save() - if hashes and self.faces: - logger.info("Legacy alignments found. Adding Face Hashes...") - self.add_hashes(hashes) - self.alignments.save() - - def rotate_landmarks(self, rotated): - """ Rotate the landmarks """ - for rotate_item in tqdm(rotated, desc="Rotating Landmarks"): - frame = self.frames.get(rotate_item, None) - if frame is None: - continue - self.alignments.rotate_existing_landmarks(rotate_item, frame) - - def add_hashes(self, hashes): - """ Add Face Hashes to the alignments file """ - all_faces = dict() - logger.info("Getting original filenames, indexes and hashes...") - for face in self.faces.file_list_sorted: - filename = face["face_name"] - extension = face["face_extension"] - if "_" not in face["face_name"]: - logger.warning("Unable to determine index of file. Skipping: '%s'", filename) - continue - index = filename[filename.rfind("_") + 1:] - if not index.isdigit(): - logger.warning("Unable to determine index of file. Skipping: '%s'", filename) - continue - orig_frame = filename[:filename.rfind("_")] + extension - all_faces.setdefault(orig_frame, dict())[int(index)] = face["face_hash"] - - logger.info("Updating hashes to alignments...") - for frame in hashes: - if frame not in all_faces.keys(): - logger.warning("Skipping missing frame: '%s'", frame) - continue - self.alignments.add_face_hashes(frame, all_faces[frame]) - - class Merge(): """ Merge two alignments files into one """ def __init__(self, alignments, arguments): @@ -693,10 +622,6 @@ def get_items(self, arguments): def process(self): """ run removal """ - if self.type == "faces": - legacy = Legacy(self.alignments, None, faces=self.items, child_process=True) - legacy.process() - logger.info("[REMOVE ALIGNMENTS DATA]") # Tidy up cli output del_count = 0 task = getattr(self, "remove_{}".format(self.type)) @@ -839,20 +764,17 @@ def __init__(self, alignments, arguments): self.faces = self.get_faces(arguments) logger.debug("Initialized %s", self.__class__.__name__) - def get_faces(self, arguments): - """ If faces argument is specified, load faces_dir - otherwise return None """ + @staticmethod + def get_faces(arguments): + """ If faces argument is specified, load faces_dir otherwise return None """ if not hasattr(arguments, "faces_dir") or not arguments.faces_dir: return None faces = Faces(arguments.faces_dir) - legacy = Legacy(self.alignments, None, faces=faces, child_process=True) - legacy.process() return faces def process(self): """ Execute the sort process """ logger.info("[SORT INDEXES]") # Tidy up cli output - self.check_legacy() reindexed = self.reindex_faces() if reindexed: self.alignments.save() @@ -860,19 +782,6 @@ def process(self): rename = Rename(self.alignments, None, self.faces) rename.process() - def check_legacy(self): - """ Legacy rotated alignments will not have the correct x, y - positions. Faces without hashes won't process. - Check for these and generate a warning and exit """ - rotated = self.alignments.get_legacy_rotation() - hashes = self.alignments.get_legacy_no_hashes() - if rotated or hashes: - logger.error("Legacy alignments found. Sort cannot continue. You should run legacy " - "tool to update the file prior to running sort: 'python tools.py " - "alignments -j legacy -a -fr -fc " - "'") - exit(0) - def reindex_faces(self): """ Re-Index the faces """ reindexed = 0 diff --git a/tools/lib_alignments/jobs_manual.py b/tools/lib_alignments/jobs_manual.py index 53408a634e..2cf4d63826 100644 --- a/tools/lib_alignments/jobs_manual.py +++ b/tools/lib_alignments/jobs_manual.py @@ -9,7 +9,7 @@ from lib.queue_manager import queue_manager from plugins.extract.pipeline import Extractor -from . import Annotate, ExtractedFaces, Frames, Legacy +from . import Annotate, ExtractedFaces, Frames logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -454,10 +454,6 @@ def __init__(self, alignments, arguments): def process(self): """ Process manual extraction """ - legacy = Legacy(self.alignments, self.arguments, - frames=self.frames, child_process=True) - legacy.process() - logger.info("[MANUAL PROCESSING]") # Tidy up cli output self.extracted_faces = ExtractedFaces(self.frames, self.alignments, size=256) self.interface = Interface(self.alignments, self.frames) diff --git a/tools/lib_alignments/media.py b/tools/lib_alignments/media.py index a5f043da18..f9a3941c8c 100644 --- a/tools/lib_alignments/media.py +++ b/tools/lib_alignments/media.py @@ -23,16 +23,15 @@ class AlignmentData(Alignments): """ Class to hold the alignment data """ - def __init__(self, alignments_file, destination_format): - logger.debug("Initializing %s: (alignments file: '%s', destination_format: '%s')", - self.__class__.__name__, alignments_file, destination_format) + def __init__(self, alignments_file): + logger.debug("Initializing %s: (alignments file: '%s')", + self.__class__.__name__, alignments_file) logger.info("[ALIGNMENT DATA]") # Tidy up cli output folder, filename = self.check_file_exists(alignments_file) if filename.lower() == "dfl": - self.set_dfl(destination_format) + self.file = filename return super().__init__(folder, filename=filename) - self.set_destination_format(destination_format) logger.verbose("%s items loaded", self.frames_count) logger.debug("Initialized %s", self.__class__.__name__) @@ -43,7 +42,7 @@ def check_file_exists(alignments_file): if filename.lower() == "dfl": folder = None filename = "dfl" - logger.info("Using extracted pngs for alignments") + logger.info("Using extracted DFL faces for alignments") elif not os.path.isfile(alignments_file): logger.error("ERROR: alignments file not found at: '%s'", alignments_file) exit(0) @@ -51,39 +50,6 @@ def check_file_exists(alignments_file): logger.verbose("Alignments file exists at '%s'", alignments_file) return folder, filename - def set_dfl(self, destination_format): - """ Set the alignments for dfl alignments """ - logger.debug("Alignments are DFL format") - self.file = "dfl" - self.set_destination_format(destination_format) - - def set_destination_format(self, destination_format): - """ Standardize the destination format to the correct extension """ - extensions = {".json": "json", - ".p": "pickle", - ".yml": "yaml", - ".yaml": "yaml"} - dst_fmt = None - file_ext = os.path.splitext(self.file)[1].lower() - logger.debug("File extension: '%s'", file_ext) - - if destination_format is not None: - dst_fmt = destination_format - elif self.file == "dfl": - dst_fmt = "json" - elif file_ext in extensions.keys(): - dst_fmt = extensions[file_ext] - else: - logger.error("'%s' is not a supported serializer. Exiting", file_ext) - exit(0) - - logger.verbose("Destination format set to '%s'", dst_fmt) - - self.serializer = self.get_serializer("", dst_fmt) - filename = os.path.splitext(self.file)[0] - self.file = "{}.{}".format(filename, self.serializer.file_extension) - logger.debug("Destination file: '%s'", self.file) - def save(self): """ Backup copy of old alignments and save new alignments """ self.backup() From 803b6ce02f0c8d35383169327bb492e81fdf2820 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 15 Oct 2019 22:21:45 +0000 Subject: [PATCH 094/981] Catch Cuda Driver Insufficient error and raise with useful message --- lib/model/session.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/model/session.py b/lib/model/session.py index a9a825a7f3..651ff69f58 100644 --- a/lib/model/session.py +++ b/lib/model/session.py @@ -4,10 +4,11 @@ import logging import tensorflow as tf +from tensorflow.python import errors_impl as tf_error # pylint:disable=no-name-in-module from keras.models import load_model as k_load_model, Model import numpy as np -from lib.utils import get_backend +from lib.utils import get_backend, FaceswapError logger = logging.getLogger(__name__) # pylint:disable=invalid-name @@ -110,8 +111,15 @@ def _set_session(self, allow_growth): config = tf.ConfigProto() if allow_growth and get_backend() == "nvidia": config.gpu_options.allow_growth = True # pylint:disable=no-member - session = tf.Session(graph=tf.Graph(), config=config) - logger.debug("Creating tf.session: (graph: %s, session: %s, config: %s)", + try: + session = tf.Session(graph=tf.Graph(), config=config) + except tf_error.InternalError as err: + if "driver version is insufficient" in str(err): + msg = ("Your Nvidia Graphics Driver is insufficient for running Faceswap. " + "Please upgrade to the latest version.") + raise FaceswapError(msg) from err + raise err + logger.debug("Created tf.session: (graph: %s, session: %s, config: %s)", session.graph, session, config) return session From d93e7b11140c11b43113686849a1c5c74d3a06f2 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 18 Oct 2019 15:44:25 +0000 Subject: [PATCH 095/981] Smart Mask - Extract code review - Lint simple_tests.py - Only reformat alignments file if it exists otherwise change filename - Update legacy alignments to new format at all stages - faces_detect.Mask.from_dict - logging format fix - convert.py fix otf for new pipeline - cli.py - Add note that masks not used. Revert convert masks - faces_detect.py - Revert non-extract code - Add .p and .pickle extensions for serializer - plugins/extract revert some changes - scripts/fsmedia - Revert code changes - Pipeline - cleanup - Consistant alpha channel stripping (fixes single-process) - Store landmarks as numpy array - Code attribution - Normalize feed face and reference face to 0.0 - 1.0 in convert - Lock in mask VRAM sized - Add documentation to plugin_loader - Update alignments tool to work with new format --- .pylintrc | 6 +- _travis/simple_tests.py | 52 +- docs/full/plugins.plugin_loader.rst | 7 + docs/full/plugins.rst | 1 + lib/aligner.py | 2 +- lib/alignments.py | 34 +- lib/cli.py | 877 +++++++++++++------------ lib/convert.py | 3 +- lib/faces_detect.py | 96 +-- lib/serializer.py | 4 +- plugins/extract/_base.py | 44 +- plugins/extract/align/_base.py | 20 +- plugins/extract/align/cv2_dnn.py | 5 +- plugins/extract/detect/_base.py | 4 +- plugins/extract/mask/_base.py | 12 +- plugins/extract/mask/unet_dfl.py | 18 +- plugins/extract/mask/vgg_clear.py | 21 +- plugins/extract/mask/vgg_obstructed.py | 21 +- plugins/extract/pipeline.py | 211 +++--- plugins/plugin_loader.py | 175 ++++- scripts/convert.py | 6 +- scripts/fsmedia.py | 14 +- tools/alignments.py | 6 +- tools/cli.py | 23 +- tools/lib_alignments/__init__.py | 2 +- tools/lib_alignments/jobs.py | 179 +++-- tools/lib_alignments/jobs_manual.py | 6 +- 27 files changed, 1043 insertions(+), 806 deletions(-) create mode 100644 docs/full/plugins.plugin_loader.rst diff --git a/.pylintrc b/.pylintrc index 69079954cd..5c52c8bf7d 100644 --- a/.pylintrc +++ b/.pylintrc @@ -19,7 +19,7 @@ ignore-patterns= # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the # number of processors available to use. -jobs=1 +jobs=0 # Control the amount of potential inferred values when inferring a single # object. This can help the performance when dealing with large functions or @@ -477,10 +477,10 @@ notes=FIXME, [DESIGN] # Maximum number of arguments for function / method. -max-args=5 +max-args=10 # Maximum number of attributes for a class (see R0902). -max-attributes=7 +max-attributes=10 # Maximum number of boolean expressions in an if statement. max-bool-expr=5 diff --git a/_travis/simple_tests.py b/_travis/simple_tests.py index 52d053047b..00dd9206d1 100644 --- a/_travis/simple_tests.py +++ b/_travis/simple_tests.py @@ -14,8 +14,8 @@ import os from os.path import join as pathjoin, expanduser -fail_count = 0 -test_count = 0 +FAIL_COUNT = 0 +TEST_COUNT = 0 _COLORS = { "FAIL": "\033[1;31m", "OK": "\033[1;32m", @@ -26,8 +26,10 @@ def print_colored(text, color="OK", bold=False): - # This might not work on windows, - # altho travis runs windows stuff in git bash, so it might ? + """ Print colored text + This might not work on windows, + although travis runs windows stuff in git bash, so it might ? + """ color = _COLORS.get(color, color) print("%s%s%s%s" % ( color, "" if not bold else _COLORS["BOLD"], text, _COLORS["ENDC"] @@ -35,33 +37,38 @@ def print_colored(text, color="OK", bold=False): def print_ok(text): + """ Print ok in colored text """ print_colored(text, "OK", True) def print_fail(text): + """ Print fail in colored text """ print_colored(text, "FAIL", True) def print_status(text): + """ Print status in colored text """ print_colored(text, "STATUS", True) def run_test(name, cmd): - global fail_count, test_count + """ run a test """ + global FAIL_COUNT, TEST_COUNT # pylint:disable=global-statement print_status("[?] running %s" % name) print("Cmd: %s" % " ".join(cmd)) - test_count += 1 + TEST_COUNT += 1 try: check_call(cmd) print_ok("[+] Test success") return True - except CalledProcessError as e: - print_fail("[-] Test failed with %s" % e) - fail_count += 1 + except CalledProcessError as err: + print_fail("[-] Test failed with %s" % err) + FAIL_COUNT += 1 return False def download_file(url, filename): # TODO: retry + """ Download a file from given url """ if os.path.isfile(filename): print_status("[?] '%s' already cached as '%s'" % (url, filename)) return filename @@ -69,12 +76,13 @@ def download_file(url, filename): # TODO: retry print_status("[?] Downloading '%s' to '%s'" % (url, filename)) video, _ = urlretrieve(url, filename) return video - except urllib.error.URLError as e: - print_fail("[-] Failed downloading: %s" % e) + except urllib.error.URLError as err: + print_fail("[-] Failed downloading: %s" % err) return None def extract_args(detector, aligner, in_path, out_path, args=None): + """ Extraction command """ py_exe = sys.executable _extract_args = "%s faceswap.py extract -i %s -o %s -D %s -A %s" % ( py_exe, in_path, out_path, detector, aligner @@ -84,16 +92,18 @@ def extract_args(detector, aligner, in_path, out_path, args=None): return _extract_args.split() -def train_args(model, model_path, faces, alignments, iterations=5, bs=8, extra_args=""): +def train_args(model, model_path, faces, alignments, iterations=5, batchsize=8, extra_args=""): + """ Train command """ py_exe = sys.executable args = "%s faceswap.py train -A %s -ala %s -B %s -alb %s -m %s -t %s -bs %i -it %s %s" % ( py_exe, faces, alignments, faces, - alignments, model_path, model, bs, iterations, extra_args + alignments, model_path, model, batchsize, iterations, extra_args ) return args.split() def convert_args(in_path, out_path, model_path, writer, args=None): + """ Convert command """ py_exe = sys.executable conv_args = "%s faceswap.py convert -i %s -o %s -m %s -w %s" % ( py_exe, in_path, out_path, model_path, writer @@ -104,6 +114,7 @@ def convert_args(in_path, out_path, model_path, writer, args=None): def sort_args(in_path, out_path, sortby="face", groupby="hist", method="rename"): + """ Sort command """ py_exe = sys.executable _sort_args = "%s tools.py sort -i %s -o %s -s %s -fp %s -g %s -k" % ( py_exe, in_path, out_path, sortby, method, groupby @@ -111,7 +122,8 @@ def sort_args(in_path, out_path, sortby="face", groupby="hist", method="rename") return _sort_args.split() -if __name__ == '__main__': +def main(): + """ Main testing script """ vid_src = "https://faceswap.dev/data/test.mp4" img_src = "https://archive.org/download/GPN-2003-00070/GPN-2003-00070.jpg" base_dir = pathjoin(expanduser("~"), "cache", "tests") @@ -136,7 +148,7 @@ def sort_args(in_path, out_path, sortby="face", groupby="hist", method="rename") if not img_path: print_fail("[-] Aborting") exit(1) - img_extract = run_test( + run_test( "Extraction images with cv2-dnn detector and cv2-dnn aligner.", extract_args("Cv2-Dnn", "Cv2-Dnn", img_base, pathjoin(img_base, "faces")) ) @@ -193,9 +205,13 @@ def sort_args(in_path, out_path, sortby="face", groupby="hist", method="rename") ) ) - if fail_count == 0: - print_ok("[+] Failed %i/%i tests." % (fail_count, test_count)) + if FAIL_COUNT == 0: + print_ok("[+] Failed %i/%i tests." % (FAIL_COUNT, TEST_COUNT)) exit(0) else: - print_fail("[-] Failed %i/%i tests." % (fail_count, test_count)) + print_fail("[-] Failed %i/%i tests." % (FAIL_COUNT, TEST_COUNT)) exit(1) + + +if __name__ == '__main__': + main() diff --git a/docs/full/plugins.plugin_loader.rst b/docs/full/plugins.plugin_loader.rst new file mode 100644 index 0000000000..dce7024d00 --- /dev/null +++ b/docs/full/plugins.plugin_loader.rst @@ -0,0 +1,7 @@ +plugins.plugin\_loader module +============================= + +.. automodule:: plugins.plugin_loader + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/plugins.rst b/docs/full/plugins.rst index c0659fecf3..37ea4a14cf 100644 --- a/docs/full/plugins.rst +++ b/docs/full/plugins.rst @@ -7,6 +7,7 @@ Subpackages .. toctree:: plugins.extract + plugins.plugin_loader Module contents --------------- diff --git a/lib/aligner.py b/lib/aligner.py index 2e00b9fd8f..e1f595c1aa 100644 --- a/lib/aligner.py +++ b/lib/aligner.py @@ -125,5 +125,5 @@ def get_matrix_scaling(mat): def get_align_mat(face): """ Return the alignment Matrix """ - mat_umeyama = umeyama(np.array(face.landmarks_xy[17:]), True)[0:2] + mat_umeyama = umeyama(face.landmarks_xy[17:], True)[0:2] return mat_umeyama diff --git a/lib/alignments.py b/lib/alignments.py index 09f9b55352..64f7ba9782 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -71,7 +71,7 @@ def get_location(self, folder, filename): """ Return the path to alignments file """ logger.debug("Getting location: (folder: '%s', filename: '%s')", folder, filename) extension = os.path.splitext(filename)[1] - if extension in (".json", ".p", ".yaml", ".yml"): + if extension in (".json", ".p", ".pickle", ".yaml", ".yml"): # Reformat legacy alignments file filename = self.update_file_format(folder, filename) logger.debug("Updated legacy alignments. New filename: '%s'", filename) @@ -82,6 +82,11 @@ def get_location(self, folder, filename): elif extension != ".fsa": raise FaceswapError("{} is not a valid alignments file".format(filename)) location = os.path.join(str(folder), filename) + if not os.path.exists(location): + # Test for old format alignments files and reformat if they exist + # This will be executed if an alignments file has not been explicitly provided + # therefore it will not have been picked up in the extension test + self.test_for_legacy(location) logger.verbose("Alignments filepath: '%s'", location) return location @@ -246,17 +251,34 @@ def update_legacy(self): # # # Serializer is now a compressed pickle .fsa format. This used to be any number of serializers + def test_for_legacy(self, location): + """ For alignments filenames passed in with out an extension, test for legacy formats """ + logger.debug("Checking for legacy alignments file formats: '%s'", location) + filename = os.path.splitext(location)[0] + for ext in (".json", ".p", ".pickle", ".yaml"): + legacy_filename = "{}{}".format(filename, ext) + if os.path.exists(legacy_filename): + logger.debug("Legacy alignments file exists: '%s'", legacy_filename) + _ = self.update_file_format(*os.path.split(legacy_filename)) + break + logger.debug("Legacy alignments file does not exist: '%s'", legacy_filename) + def update_file_format(self, folder, filename): """ Convert old style alignments format to new style format """ logger.info("Reformatting legacy alignments file...") old_location = os.path.join(str(folder), filename) new_location = "{}.{}".format(os.path.splitext(old_location)[0], self.serializer.file_extension) - logger.info("Old location: '%s', New location: '%s'", old_location, new_location) - - load_serializer = get_serializer_from_filename(old_location) - data = load_serializer.load(old_location) - self.serializer.save(new_location, data) + if os.path.exists(old_location): + if os.path.exists(new_location): + logger.info("Using existing updated alignments file found at '%s'. If you do not " + "wish to use this existing file then you should delete or rename it.", + new_location) + else: + logger.info("Old location: '%s', New location: '%s'", old_location, new_location) + load_serializer = get_serializer_from_filename(old_location) + data = load_serializer.load(old_location) + self.serializer.save(new_location, data) return os.path.basename(new_location) # # diff --git a/lib/cli.py b/lib/cli.py index 30bbea4a4a..d491cdf225 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -14,6 +14,7 @@ from importlib import import_module from lib.logger import crash_log, log_setup +from lib.model.masks import get_available_masks, get_default_mask from lib.utils import FaceswapError, get_backend, safe_shutdown from plugins.plugin_loader import PluginLoader @@ -399,38 +400,39 @@ def get_global_arguments(): """ Arguments that are used in ALL parts of Faceswap DO NOT override this """ global_args = list() - global_args.append({"opts": ("-C", "--configfile"), - "action": FileFullPaths, - "filetypes": "ini", - "type": str, - "group": "Global Options", - "help": "Optionally overide the saved config with the path to a " - "custom config file."}) - global_args.append({"opts": ("-L", "--loglevel"), - "type": str.upper, - "dest": "loglevel", - "default": "INFO", - "choices": ("INFO", "VERBOSE", "DEBUG", "TRACE"), - "group": "Global Options", - "help": "Log level. Stick with INFO or VERBOSE unless you need to " - "file an error report. Be careful with TRACE as it will " - "generate a lot of data"}) - global_args.append({"opts": ("-LF", "--logfile"), - "action": SaveFileFullPaths, - "filetypes": 'log', - "type": str, - "dest": "logfile", - "group": "Global Options", - "help": "Path to store the logfile. Leave blank to store in the " - "faceswap folder", - "default": None}) + global_args.append({ + "opts": ("-C", "--configfile"), + "action": FileFullPaths, + "filetypes": "ini", + "type": str, + "group": "Global Options", + "help": "Optionally overide the saved config with the path to a custom config file."}) + global_args.append({ + "opts": ("-L", "--loglevel"), + "type": str.upper, + "dest": "loglevel", + "default": "INFO", + "choices": ("INFO", "VERBOSE", "DEBUG", "TRACE"), + "group": "Global Options", + "help": "Log level. Stick with INFO or VERBOSE unless you need to file an error " + "report. Be careful with TRACE as it will generate a lot of data"}) + global_args.append({ + "opts": ("-LF", "--logfile"), + "action": SaveFileFullPaths, + "filetypes": 'log', + "type": str, + "dest": "logfile", + "group": "Global Options", + "help": "Path to store the logfile. Leave blank to store in the faceswap folder", + "default": None}) # This is a hidden argument to indicate that the GUI is being used, # so the preview window should be redirected Accordingly - global_args.append({"opts": ("-gui", "--gui"), - "action": "store_true", - "dest": "redirect_gui", - "default": False, - "help": argparse.SUPPRESS}) + global_args.append({ + "opts": ("-gui", "--gui"), + "action": "store_true", + "dest": "redirect_gui", + "default": False, + "help": argparse.SUPPRESS}) return global_args @staticmethod @@ -482,31 +484,32 @@ def get_argument_list(): """ Put the arguments in a list so that they are accessible from both argparse and gui """ argument_list = list() - argument_list.append({"opts": ("-i", "--input-dir"), - "action": DirOrFileFullPaths, - "filetypes": "video", - "dest": "input_dir", - "required": True, - "group": "Data", - "help": "Input directory or video. Either a directory containing " - "the image files you wish to process or path to a video " - "file. NB: This should be the source video/frames NOT the " - "source faces."}) - argument_list.append({"opts": ("-o", "--output-dir"), - "action": DirFullPaths, - "dest": "output_dir", - "required": True, - "group": "Data", - "help": "Output directory. This is where the converted files will " - "be saved."}) - argument_list.append({"opts": ("-al", "--alignments"), - "action": FileFullPaths, - "filetypes": "alignments", - "type": str, - "dest": "alignments_path", - "group": "Data", - "help": "Optional path to an alignments file. Leave blank if the " - "alignments file is at the default location."}) + argument_list.append({ + "opts": ("-i", "--input-dir"), + "action": DirOrFileFullPaths, + "filetypes": "video", + "dest": "input_dir", + "required": True, + "group": "Data", + "help": "Input directory or video. Either a directory containing the image files you " + "wish to process or path to a video file. NB: This should be the source video/" + "frames NOT the source faces."}) + argument_list.append({ + "opts": ("-o", "--output-dir"), + "action": DirFullPaths, + "dest": "output_dir", + "required": True, + "group": "Data", + "help": "Output directory. This is where the converted files will be saved."}) + argument_list.append({ + "opts": ("-al", "--alignments"), + "action": FileFullPaths, + "filetypes": "alignments", + "type": str, + "dest": "alignments_path", + "group": "Data", + "help": "Optional path to an alignments file. Leave blank if the alignments file is " + "at the default location."}) return argument_list @@ -532,219 +535,220 @@ def get_optional_arguments(): default_aligner = "fan" argument_list = [] - argument_list.append({"opts": ("-D", "--detector"), - "action": Radio, - "type": str.lower, - "choices": PluginLoader.get_available_extractors("detect"), - "default": default_detector, - "group": "Plugins", - "help": "R|Detector to use. Some of these have configurable " - "settings in '/config/extract.ini' or 'Settings > Configure " - "Extract 'Plugins':" - "\nL|cv2-dnn: A CPU only extractor which is the least " - "reliable and least resource intensive. Use this if not " - "using a GPU and time is important." - "\nL|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses " - "fewer resources than other GPU detectors but can often " - "return more false positives." - "\nL|s3fd: Best detector. Fast on GPU, slow on CPU. Can " - "detect more faces and fewer false positives than other " - "GPU detectors, but is a lot more resource intensive."}) - argument_list.append({"opts": ("-A", "--aligner"), - "action": Radio, - "type": str.lower, - "choices": PluginLoader.get_available_extractors("align"), - "default": default_aligner, - "group": "Plugins", - "help": "R|Aligner to use." - "\nL|cv2-dnn: A CPU only landmark detector. Faster, less " - "resource intensive, but less accurate. Only use this if " - "not using a GPU and time is important." - "\nL|fan: Best aligner. Fast on GPU, slow on CPU."}) - argument_list.append({"opts": ("-M", "--masker"), - "action": Radio, - "type": str.lower, - "choices": PluginLoader.get_available_extractors("mask"), - "default": "components", - "group": "Plugins", - "help": "R|Masker to use." - "\nL|none: An array of all ones is created to provide a 4th " - "channel that will not mask any portion of the image." - "\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|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({"opts": ("-nm", "--normalization"), - "action": Radio, - "type": str.lower, - "dest": "normalization", - "choices": ["none", "clahe", "hist", "mean"], - "default": "none", - "group": "plugins", - "help": "R|Performing normalization can help the aligner better " - "align faces with difficult lighting conditions at an " - "extraction speed cost. Different methods will yield " - "different results on different sets. NB: This does not " - "impact the output face, just the input to the aligner." - "\nL|none: Don't perform normalization on the face." - "\nL|clahe: Perform Contrast Limited Adaptive Histogram " - "Equalization on the face." - "\nL|hist: Equalize the histograms on the RGB channels." - "\nL|mean: Normalize the face colors to the mean."}) - argument_list.append({"opts": ("-r", "--rotate-images"), - "type": str, - "dest": "rotate_images", - "default": None, - "group": "plugins", - "help": "If a face isn't found, rotate the images to try to find a " - "face. Can find more faces at the cost of extraction speed. " - "Pass in a single number to use increments of that size up " - "to 360, or pass in a list of numbers to enumerate exactly " - "what angles to check"}) - argument_list.append({"opts": ("-min", "--min-size"), - "type": int, - "action": Slider, - "dest": "min_size", - "min_max": (0, 1080), - "default": 0, - "rounding": 20, - "group": "Face Processing", - "help": "Filters out faces detected below this size. Length, in " - "pixels across the diagonal of the bounding box. Set to 0 " - "for off"}) - argument_list.append({"opts": ("-n", "--nfilter"), - "action": FilesFullPaths, - "filetypes": "image", - "dest": "nfilter", - "nargs": "+", - "default": None, - "group": "Face Processing", - "help": "Optionally filter out people who you do not wish to " - "process by passing in an image of that person. Should be a " - "front portrait with a single person in the image. Multiple " - "images can be added space separated. NB: Using face filter " - "will significantly decrease extraction speed and its " - "accuracy cannot be guaranteed."}) - argument_list.append({"opts": ("-f", "--filter"), - "action": FilesFullPaths, - "filetypes": "image", - "dest": "filter", - "nargs": "+", - "default": None, - "group": "Face Processing", - "help": "Optionally select people you wish to process by passing in " - "an image of that person. Should be a front portrait with a " - "single person in the image. Multiple images can be added " - "space separated. NB: Using face filter will significantly " - "decrease extraction speed and its accuracy cannot be " - "guaranteed."}) - argument_list.append({"opts": ("-l", "--ref_threshold"), - "action": Slider, - "min_max": (0.01, 0.99), - "rounding": 2, - "type": float, - "dest": "ref_threshold", - "default": 0.4, - "group": "Face Processing", - "help": "For use with the optional nfilter/filter files. Threshold " - "for positive face recognition. Lower values are stricter. " - "NB: Using face filter will significantly decrease " - "extraction speed and its accuracy cannot be " - "guaranteed."}) - argument_list.append({"opts": ("-bt", "--blur-threshold"), - "type": float, - "action": Slider, - "min_max": (0.0, 100.0), - "rounding": 1, - "dest": "blur_thresh", - "default": 0.0, - "group": "Face Processing", - "help": "Automatically discard images blurrier than the specified " - "threshold. Discarded images are moved into a \"blurry\" " - "sub-folder. Lower values allow more blur. Set to 0.0 to " - "turn off."}) - argument_list.append({"opts": ("-een", "--extract-every-n"), - "type": int, - "action": Slider, - "dest": "extract_every_n", - "min_max": (1, 100), - "default": 1, - "rounding": 1, - "group": "output", - "help": "Extract every 'nth' frame. This option will skip frames " - "when extracting faces. For example a value of 1 will " - "extract faces from every frame, a value of 10 will extract " - "faces from every 10th frame."}) - argument_list.append({"opts": ("-sz", "--size"), - "type": int, - "action": Slider, - "min_max": (128, 512), - "default": 256, - "rounding": 64, - "group": "output", - "help": "The output size of extracted faces. Make sure that the " - "model you intend to train supports your required size. " - "This will only need to be changed for hi-res models."}) - argument_list.append({"opts": ("-si", "--save-interval"), - "dest": "save_interval", - "type": int, - "action": Slider, - "min_max": (0, 1000), - "rounding": 10, - "default": 0, - "group": "output", - "help": "Automatically save the alignments file after a set amount " - "of frames. By default the alignments file is only saved at " - "the end of the extraction process. NB: If extracting in 2 " - "passes then the alignments file will only start to be " - "saved out during the second pass. WARNING: Don't interrupt " - "the script when writing the file because it might get " - "corrupted. Set to 0 to turn off"}) - argument_list.append({"opts": ("-dl", "--debug-landmarks"), - "action": "store_true", - "dest": "debug_landmarks", - "group": "output", - "default": False, - "help": "Draw landmarks on the ouput faces for debugging purposes."}) - argument_list.append({"opts": ("-sp", "--singleprocess"), - "action": "store_true", - "default": False, - "backend": "nvidia", - "group": "settings", - "help": "Don't run extraction in parallel. Will run detection first " - "then alignment (2 passes). Useful if VRAM is at a " - "premium."}) - argument_list.append({"opts": ("-s", "--skip-existing"), - "action": "store_true", - "dest": "skip_existing", - "group": "settings", - "default": False, - "help": "Skips frames that have already been extracted and exist in " - "the alignments file"}) - argument_list.append({"opts": ("-sf", "--skip-existing-faces"), - "action": "store_true", - "dest": "skip_faces", - "group": "settings", - "default": False, - "help": "Skip frames that already have detected faces in the " - "alignments file"}) + argument_list.append({ + "opts": ("-D", "--detector"), + "action": Radio, + "type": str.lower, + "choices": PluginLoader.get_available_extractors("detect"), + "default": default_detector, + "group": "Plugins", + "help": "R|Detector to use. Some of these have configurable settings in " + "'/config/extract.ini' or 'Settings > Configure Extract 'Plugins':" + "\nL|cv2-dnn: A CPU only extractor which is the least reliable and least " + "resource intensive. Use this if not using a GPU and time is important." + "\nL|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " + "than other GPU detectors but can often return more false positives." + "\nL|s3fd: Best detector. Fast on GPU, slow on CPU. Can detect more faces and " + "fewer false positives than other GPU detectors, but is a lot more resource " + "intensive."}) + argument_list.append({ + "opts": ("-A", "--aligner"), + "action": Radio, + "type": str.lower, + "choices": PluginLoader.get_available_extractors("align"), + "default": default_aligner, + "group": "Plugins", + "help": "R|Aligner to use." + "\nL|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " + "but less accurate. Only use this if not using a GPU and time is important." + "\nL|fan: Best aligner. Fast on GPU, slow on CPU."}) + argument_list.append({ + "opts": ("-M", "--masker"), + "action": Radio, + "type": str.lower, + "choices": PluginLoader.get_available_extractors("mask"), + "default": "extended", + "group": "Plugins", + "help": "R|Masker to use. NB: Masker is not currently used by the rest of the process " + "but this will store a mask in the alignments file for use when it has been " + "implemented." + "\nL|none: An array of all ones is created to provide a 4th channel that will " + "not mask any portion of the image." + "\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|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({ + "opts": ("-nm", "--normalization"), + "action": Radio, + "type": str.lower, + "dest": "normalization", + "choices": ["none", "clahe", "hist", "mean"], + "default": "none", + "group": "plugins", + "help": "R|Performing normalization can help the aligner better align faces with " + "difficult lighting conditions at an extraction speed cost. Different methods " + "will yield different results on different sets. NB: This does not impact the " + "output face, just the input to the aligner." + "\nL|none: Don't perform normalization on the face." + "\nL|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " + "face." + "\nL|hist: Equalize the histograms on the RGB channels." + "\nL|mean: Normalize the face colors to the mean."}) + argument_list.append({ + "opts": ("-r", "--rotate-images"), + "type": str, + "dest": "rotate_images", + "default": None, + "group": "plugins", + "help": "If a face isn't found, rotate the images to try to find a face. Can find " + "more faces at the cost of extraction speed. Pass in a single number to use " + "increments of that size up to 360, or pass in a list of numbers to enumerate " + "exactly what angles to check."}) + argument_list.append({ + "opts": ("-min", "--min-size"), + "type": int, + "action": Slider, + "dest": "min_size", + "min_max": (0, 1080), + "default": 0, + "rounding": 20, + "group": "Face Processing", + "help": "Filters out faces detected below this size. Length, in pixels across the " + "diagonal of the bounding box. Set to 0 for off"}) + argument_list.append({ + "opts": ("-n", "--nfilter"), + "action": FilesFullPaths, + "filetypes": "image", + "dest": "nfilter", + "nargs": "+", + "default": None, + "group": "Face Processing", + "help": "Optionally filter out people who you do not wish to process by passing in an " + "image of that person. Should be a front portrait with a single person in the " + "image. Multiple images can be added space separated. NB: Using face filter " + "will significantly decrease extraction speed and its accuracy cannot be " + "guaranteed."}) + argument_list.append({ + "opts": ("-f", "--filter"), + "action": FilesFullPaths, + "filetypes": "image", + "dest": "filter", + "nargs": "+", + "default": None, + "group": "Face Processing", + "help": "Optionally select people you wish to process by passing in an image of that " + "person. Should be a front portrait with a single person in the image. " + "Multiple images can be added space separated. NB: Using face filter will " + "significantly decrease extraction speed and its accuracy cannot be " + "guaranteed."}) + argument_list.append({ + "opts": ("-l", "--ref_threshold"), + "action": Slider, + "min_max": (0.01, 0.99), + "rounding": 2, + "type": float, + "dest": "ref_threshold", + "default": 0.4, + "group": "Face Processing", + "help": "For use with the optional nfilter/filter files. Threshold for positive face " + "recognition. Lower values are stricter. NB: Using face filter will " + "significantly decrease extraction speed and its accuracy cannot be " + "guaranteed."}) + argument_list.append({ + "opts": ("-bt", "--blur-threshold"), + "type": float, + "action": Slider, + "min_max": (0.0, 100.0), + "rounding": 1, + "dest": "blur_thresh", + "default": 0.0, + "group": "Face Processing", + "help": "Automatically discard images blurrier than the specified threshold. " + "Discarded images are moved into a \"blurry\" sub-folder. Lower values allow " + "more blur. Set to 0.0 to turn off."}) + argument_list.append({ + "opts": ("-een", "--extract-every-n"), + "type": int, + "action": Slider, + "dest": "extract_every_n", + "min_max": (1, 100), + "default": 1, + "rounding": 1, + "group": "output", + "help": "Extract every 'nth' frame. This option will skip frames when extracting " + "faces. For example a value of 1 will extract faces from every frame, a value " + "of 10 will extract faces from every 10th frame."}) + argument_list.append({ + "opts": ("-sz", "--size"), + "type": int, + "action": Slider, + "min_max": (128, 512), + "default": 256, + "rounding": 64, + "group": "output", + "help": "The output size of extracted faces. Make sure that the model you intend to " + "train supports your required size. This will only need to be changed for " + "hi-res models."}) + argument_list.append({ + "opts": ("-si", "--save-interval"), + "dest": "save_interval", + "type": int, + "action": Slider, + "min_max": (0, 1000), + "rounding": 10, + "default": 0, + "group": "output", + "help": "Automatically save the alignments file after a set amount of frames. By " + "default the alignments file is only saved at the end of the extraction " + "process. NB: If extracting in 2 passes then the alignments file will only " + "start to be saved out during the second pass. WARNING: Don't interrupt the " + "script when writing the file because it might get corrupted. Set to 0 to " + "turn off"}) + argument_list.append({ + "opts": ("-dl", "--debug-landmarks"), + "action": "store_true", + "dest": "debug_landmarks", + "group": "output", + "default": False, + "help": "Draw landmarks on the ouput faces for debugging purposes."}) + argument_list.append({ + "opts": ("-sp", "--singleprocess"), + "action": "store_true", + "default": False, + "backend": "nvidia", + "group": "settings", + "help": "Don't run extraction in parallel. Will run each part of the extraction " + "process separately (one after the other) rather than all at the smae time. " + "Useful if VRAM is at a premium."}) + argument_list.append({ + "opts": ("-s", "--skip-existing"), + "action": "store_true", + "dest": "skip_existing", + "group": "settings", + "default": False, + "help": "Skips frames that have already been extracted and exist in the alignments " + "file"}) + argument_list.append({ + "opts": ("-sf", "--skip-existing-faces"), + "action": "store_true", + "dest": "skip_faces", + "group": "settings", + "default": False, + "help": "Skip frames that already have detected faces in the alignments file"}) return argument_list @@ -764,22 +768,24 @@ def get_optional_arguments(): """ Put the arguments in a list so that they are accessible from both argparse and gui """ argument_list = [] - argument_list.append({"opts": ("-ref", "--reference-video"), - "action": FileFullPaths, - "dest": "reference_video", - "filetypes": "video", - "type": str, - "group": "data", - "help": "Only required if converting from images to video. Provide " - "The original video that the source frames were extracted " - "from (for extracting the fps and audio)."}) - argument_list.append({"opts": ("-m", "--model-dir"), - "action": DirFullPaths, - "dest": "model_dir", - "required": True, - "group": "data", - "help": "Model directory. The directory containing the trained " - "model you wish to use for conversion."}) + argument_list.append({ + "opts": ("-ref", "--reference-video"), + "action": FileFullPaths, + "dest": "reference_video", + "filetypes": "video", + "type": str, + "group": "data", + "help": "Only required if converting from images to video. Provide The original video " + "that the source frames were extracted from (for extracting the fps and " + "audio)."}) + argument_list.append({ + "opts": ("-m", "--model-dir"), + "action": DirFullPaths, + "dest": "model_dir", + "required": True, + "group": "data", + "help": "Model directory. The directory containing the trained model you wish to use " + "for conversion."}) argument_list.append({ "opts": ("-c", "--color-adjustment"), "action": Radio, @@ -810,7 +816,7 @@ def get_optional_arguments(): "action": Radio, "type": str.lower, "dest": "mask_type", - "choices": ["dfl_full", "components", "extended", "predicted"], + "choices": get_available_masks() + ["predicted"], "group": "plugins", "default": "predicted", "help": "R|Mask to use to replace faces. Blending of the masks can be adjusted in " @@ -822,7 +828,8 @@ def get_optional_arguments(): "further up the forehead. May perform badly on difficult angles." "\nL|facehull: Face cutout based on landmarks." "\nL|predicted: The predicted mask generated from the model. If the model was " - "not trained with a mask then this will fallback to components." + "not trained with a mask then this will fallback to " + "'{}'".format(get_default_mask()) + "\nL|none: Don't use a mask."}) argument_list.append({ "opts": ("-sc", "--scaling"), @@ -836,150 +843,148 @@ def get_optional_arguments(): "'/config/convert.ini' or 'Settings > Configure Convert Plugins':" "\nL|sharpen: Perform sharpening on the final face." "\nL|none: Don't perform any scaling operations."}) - argument_list.append({"opts": ("-w", "--writer"), - "action": Radio, - "type": str, - "choices": PluginLoader.get_available_convert_plugins("writer", - False), - "group": "plugins", - "default": "opencv", - "help": "R|The plugin to use to output the converted images. The " - "writers are configurable in '/config/convert.ini' or " - "'Settings > Configure Convert Plugins:'" - "\nL|ffmpeg: [video] Writes out the convert straight to " - "video. When the input is a series of images then the " - "'-ref' (--reference-video) parameter must be set." - "\nL|gif: [animated image] Create an animated gif." - "\nL|opencv: [images] The fastest image writer, but less " - "options and formats than other plugins." - "\nL|pillow: [images] Slower than opencv, but has more " - "options and supports more formats."}) - argument_list.append({"opts": ("-osc", "--output-scale"), - "dest": "output_scale", - "action": Slider, - "type": int, - "default": 100, - "min_max": (25, 400), - "rounding": 1, - "group": "Frame Processing", - "help": "Scale the final output frames by this amount. 100%% will " - "output the frames at source dimensions. 50%% at half size " - "200%% at double size"}) - argument_list.append({"opts": ("-fr", "--frame-ranges"), - "nargs": "+", - "type": str, - "group": "Frame Processing", - "help": "Frame ranges to apply transfer to e.g. For frames 10 to 50 " - "and 90 to 100 use --frame-ranges 10-50 90-100. Frames " - "falling outside of the selected range will be discarded " - "unless '-k' (--keep-unchanged) is selected. NB: If you are " - "converting from images, then the filenames must end with " - "the frame-number!"}) - argument_list.append({"opts": ("-a", "--input-aligned-dir"), - "action": DirFullPaths, - "dest": "input_aligned_dir", - "group": "Face Processing", - "default": None, - "help": "If you have not cleansed your alignments file, then you " - "can filter out faces by defining a folder here that " - "contains the faces extracted from your input files/video. " - "If this folder is defined, then only faces that exist " - "within your alignments file and also exist within the " - "specified folder will be converted. Leaving this blank " - "will convert all faces that exist within the alignments " - "file."}) - argument_list.append({"opts": ("-n", "--nfilter"), - "action": FilesFullPaths, - "filetypes": "image", - "dest": "nfilter", - "nargs": "+", - "default": None, - "group": "Face Processing", - "help": "Optionally filter out people who you do not wish to " - "process by passing in an image of that person. Should be a " - "front portrait with a single person in the image. Multiple " - "images can be added space separated. NB: Using face filter " - "will significantly decrease extraction speed and its " - "accuracy cannot be guaranteed."}) - argument_list.append({"opts": ("-f", "--filter"), - "action": FilesFullPaths, - "filetypes": "image", - "dest": "filter", - "nargs": "+", - "default": None, - "group": "Face Processing", - "help": "Optionally select people you wish to process by passing in " - "an image of that person. Should be a front portrait with a " - "single person in the image. Multiple images can be added " - "space separated. NB: Using face filter will significantly " - "decrease extraction speed and its accuracy cannot be " - "guaranteed."}) - argument_list.append({"opts": ("-l", "--ref_threshold"), - "action": Slider, - "min_max": (0.01, 0.99), - "rounding": 2, - "type": float, - "dest": "ref_threshold", - "default": 0.4, - "group": "Face Processing", - "help": "For use with the optional nfilter/filter files. Threshold " - "for positive face recognition. Lower values are stricter. " - "NB: Using face filter will significantly decrease " - "extraction speed and its accuracy cannot be " - "guaranteed."}) - - argument_list.append({"opts": ("-j", "--jobs"), - "dest": "jobs", - "action": Slider, - "group": "settings", - "type": int, - "default": 0, - "min_max": (0, 40), - "rounding": 1, - "help": "The maximum number of parallel processes for performing " - "conversion. Converting images is system RAM heavy so it is " - "possible to run out of memory if you have a lot of " - "processes and not enough RAM to accommodate them all. " - "Setting this to 0 will use the maximum available. No " - "matter what you set this to, it will never attempt to use " - "more processes than are available on your system. If " - "singleprocess is enabled this setting will be ignored."}) - argument_list.append({"opts": ("-g", "--gpus"), - "type": int, - "backend": "nvidia", - "action": Slider, - "min_max": (1, 10), - "rounding": 1, - "group": "settings", - "default": 1, - "help": "Number of GPUs to use for conversion"}) - argument_list.append({"opts": ("-t", "--trainer"), - "type": str.lower, - "choices": PluginLoader.get_available_models(), - "group": "settings", - "help": "[LEGACY] This only needs to be selected if a legacy " - "model is being loaded or if there are multiple models in " - "the model folder"}) - argument_list.append({"opts": ("-k", "--keep-unchanged"), - "action": "store_true", - "dest": "keep_unchanged", - "group": "Frame Processing", - "default": False, - "help": "When used with --frame-ranges outputs the unchanged frames " - "that are not processed instead of discarding them."}) - argument_list.append({"opts": ("-s", "--swap-model"), - "action": "store_true", - "dest": "swap_model", - "group": "settings", - "default": False, - "help": "Swap the model. Instead converting from of A -> B, " - "converts B -> A"}) - argument_list.append({"opts": ("-sp", "--singleprocess"), - "action": "store_true", - "group": "settings", - "default": False, - "help": "Disable multiprocessing. Slower but less resource " - "intensive."}) + argument_list.append({ + "opts": ("-w", "--writer"), + "action": Radio, + "type": str, + "choices": PluginLoader.get_available_convert_plugins("writer", False), + "group": "plugins", + "default": "opencv", + "help": "R|The plugin to use to output the converted images. The writers are " + "configurable in '/config/convert.ini' or 'Settings > Configure Convert " + "Plugins:'" + "\nL|ffmpeg: [video] Writes out the convert straight to video. When the input " + "is a series of images then the '-ref' (--reference-video) parameter must be " + "set." + "\nL|gif: [animated image] Create an animated gif." + "\nL|opencv: [images] The fastest image writer, but less options and formats " + "than other plugins." + "\nL|pillow: [images] Slower than opencv, but has more options and supports " + "more formats."}) + argument_list.append({ + "opts": ("-osc", "--output-scale"), + "dest": "output_scale", + "action": Slider, + "type": int, + "default": 100, + "min_max": (25, 400), + "rounding": 1, + "group": "Frame Processing", + "help": "Scale the final output frames by this amount. 100%% will output the frames " + "at source dimensions. 50%% at half size 200%% at double size"}) + argument_list.append({ + "opts": ("-fr", "--frame-ranges"), + "nargs": "+", + "type": str, + "group": "Frame Processing", + "help": "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " + "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " + "will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " + "converting from images, then the filenames must end with the frame-number!"}) + argument_list.append({ + "opts": ("-a", "--input-aligned-dir"), + "action": DirFullPaths, + "dest": "input_aligned_dir", + "group": "Face Processing", + "default": None, + "help": "If you have not cleansed your alignments file, then you can filter out faces " + "by defining a folder here that contains the faces extracted from your input " + "files/video. If this folder is defined, then only faces that exist within " + "your alignments file and also exist within the specified folder will be " + "converted. Leaving this blank will convert all faces that exist within the " + "alignments file."}) + argument_list.append({ + "opts": ("-n", "--nfilter"), + "action": FilesFullPaths, + "filetypes": "image", + "dest": "nfilter", + "nargs": "+", + "default": None, + "group": "Face Processing", + "help": "Optionally filter out people who you do not wish to process by passing in an " + "image of that person. Should be a front portrait with a single person in the " + "image. Multiple images can be added space separated. NB: Using face filter " + "will significantly decrease extraction speed and its accuracy cannot be " + "guaranteed."}) + argument_list.append({ + "opts": ("-f", "--filter"), + "action": FilesFullPaths, + "filetypes": "image", + "dest": "filter", + "nargs": "+", + "default": None, + "group": "Face Processing", + "help": "Optionally select people you wish to process by passing in an image of that " + "person. Should be a front portrait with a single person in the image. " + "Multiple images can be added space separated. NB: Using face filter will " + "significantly decrease extraction speed and its accuracy cannot be " + "guaranteed."}) + argument_list.append({ + "opts": ("-l", "--ref_threshold"), + "action": Slider, + "min_max": (0.01, 0.99), + "rounding": 2, + "type": float, + "dest": "ref_threshold", + "default": 0.4, + "group": "Face Processing", + "help": "For use with the optional nfilter/filter files. Threshold for positive face " + "recognition. Lower values are stricter. NB: Using face filter will " + "significantly decrease extraction speed and its accuracy cannot be " + "guaranteed."}) + argument_list.append({ + "opts": ("-j", "--jobs"), + "dest": "jobs", + "action": Slider, + "group": "settings", + "type": int, + "default": 0, + "min_max": (0, 40), + "rounding": 1, + "help": "The maximum number of parallel processes for performing conversion. " + "Converting images is system RAM heavy so it is possible to run out of memory " + "if you have a lot of processes and not enough RAM to accommodate them all. " + "Setting this to 0 will use the maximum available. No matter what you set " + "this to, it will never attempt to use more processes than are available on " + "your system. If singleprocess is enabled this setting will be ignored."}) + argument_list.append({ + "opts": ("-g", "--gpus"), + "type": int, + "backend": "nvidia", + "action": Slider, + "min_max": (1, 10), + "rounding": 1, + "group": "settings", + "default": 1, + "help": "Number of GPUs to use for conversion"}) + argument_list.append({ + "opts": ("-t", "--trainer"), + "type": str.lower, + "choices": PluginLoader.get_available_models(), + "group": "settings", + "help": "[LEGACY] This only needs to be selected if a legacy model is being loaded or " + "if there are multiple models in the model folder"}) + argument_list.append({ + "opts": ("-k", "--keep-unchanged"), + "action": "store_true", + "dest": "keep_unchanged", + "group": "Frame Processing", + "default": False, + "help": "When used with --frame-ranges outputs the unchanged frames that are not " + "processed instead of discarding them."}) + argument_list.append({ + "opts": ("-s", "--swap-model"), + "action": "store_true", + "dest": "swap_model", + "group": "settings", + "default": False, + "help": "Swap the model. Instead converting from of A -> B, converts B -> A"}) + argument_list.append({ + "opts": ("-sp", "--singleprocess"), + "action": "store_true", + "group": "settings", + "default": False, + "help": "Disable multiprocessing. Slower but less resource intensive."}) return argument_list @@ -1270,10 +1275,10 @@ def get_argument_list(): """ Put the arguments in a list so that they are accessible from both argparse and gui """ argument_list = [] - argument_list.append({"opts": ("-d", "--debug"), - "action": "store_true", - "dest": "debug", - "default": False, - "help": "Output to Shell console instead of " - "GUI console"}) + argument_list.append({ + "opts": ("-d", "--debug"), + "action": "store_true", + "dest": "debug", + "default": False, + "help": "Output to Shell console instead of GUI console"}) return argument_list diff --git a/lib/convert.py b/lib/convert.py index 3f782f2edb..e9d6d1edfb 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -12,6 +12,7 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name + class Converter(): """ Swap a source face with a target """ def __init__(self, output_dir, output_size, output_has_mask, @@ -141,7 +142,7 @@ def get_new_image(self, predicted, frame_size): predicted["detected_faces"]): predicted_mask = new_face[:, :, -1] if new_face.shape[2] == 4 else None new_face = new_face[:, :, :3] - src_face = detected_face.reference_face + src_face = detected_face.reference_face[..., :3] / 255.0 interpolator = detected_face.reference_interpolators[1] new_face = self.pre_warp_adjustments(src_face, new_face, detected_face, predicted_mask) diff --git a/lib/faces_detect.py b/lib/faces_detect.py index 8f89d83f5f..5d3a565f1b 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -22,10 +22,8 @@ class DetectedFace(): Parameters ---------- - image: np.ndarray, optional - This is a generic image placeholder that should not be relied on to be holding a particular - image. It may hold the source frame that holds the face, a cropped face or a scaled image - depending on the method using this object. + image: numpy.ndarray, optional + Original frame that holds this face. Optional (not required if just storing coordinates) x: int The left most point (in pixels) of the face's bounding box as discovered in :mod:`plugins.extract.detect` @@ -44,6 +42,33 @@ class DetectedFace(): mask: dict The generated mask(s) for the face as generated in :mod:`plugins.extract.mask`. Must be a dict of {**name** (`str`): :class:`Mask`}. + + Attributes + ---------- + image: numpy.ndarray, optional + This is a generic image placeholder that should not be relied on to be holding a particular + image. It may hold the source frame that holds the face, a cropped face or a scaled image + depending on the method using this object. + x: int + The left most point (in pixels) of the face's bounding box as discovered in + :mod:`plugins.extract.detect` + w: int + The width (in pixels) of the face's bounding box as discovered in + :mod:`plugins.extract.detect` + y: int + The top most point (in pixels) of the face's bounding box as discovered in + :mod:`plugins.extract.detect` + h: int + The height (in pixels) of the face's bounding box as discovered in + :mod:`plugins.extract.detect` + landmarks_xy: list + The 68 point landmarks as discovered in :mod:`plugins.extract.align`. + mask: dict + The generated mask(s) for the face as generated in :mod:`plugins.extract.mask`. Is a + dict of {**name** (`str`): :class:`Mask`}. + hash: str + The hash of the face. This cannot be set until the file is saved due to image compression, + but will be set if loading data from :func:`from_alignment` """ def __init__(self, image=None, x=None, w=None, y=None, h=None, landmarks_xy=None, mask=None, filename=None): @@ -61,11 +86,7 @@ def __init__(self, image=None, x=None, w=None, y=None, h=None, self.h = h # pylint:disable=invalid-name self.landmarks_xy = landmarks_xy self.mask = dict() if mask is None else mask - self.filename = filename self.hash = None - self.face = None - """ str: The hash of the face. This cannot be set until the file is saved due to image - compression, but will be set if loading data from :func:`from_alignment` """ self.aligned = dict() self.feed = dict() @@ -93,9 +114,9 @@ def bottom(self): return self.y + self.h @property - def training_coverage(self): - """ The coverage ratio to add for training images """ - return 1.0 + def _extract_ratio(self): + """ float: The ratio of padding to add for training images """ + return 0.375 def add_mask(self, name, mask, affine_matrix, frame_dims, interpolator, storage_size=128): """ Add a :class:`Mask` to this detected face @@ -172,7 +193,10 @@ def from_alignment(self, alignment, image=None): self.w = alignment["w"] self.y = alignment["y"] self.h = alignment["h"] - self.landmarks_xy = alignment["landmarks_xy"] + landmarks = alignment["landmarks_xy"] + if not isinstance(landmarks, np.ndarray): + landmarks = np.array(landmarks, dtype="int32") + self.landmarks_xy = landmarks # Manual tool does not know the final hash so default to None self.hash = alignment.get("hash", None) # Manual tool and legacy alignments will not have a mask @@ -182,7 +206,6 @@ def from_alignment(self, alignment, image=None): self.mask[name] = Mask() self.mask[name].from_dict(mask_dict) if image is not None and image.any(): - self.image = image self._image_to_face(image) logger.trace("Created from alignment: (x: %s, w: %s, y: %s. h: %s, " "landmarks: %s, mask: %s)", @@ -191,11 +214,11 @@ def from_alignment(self, alignment, image=None): def _image_to_face(self, image): """ set self.image to be the cropped face from detected bounding box """ logger.trace("Cropping face from image") - self.face = image[self.top: self.bottom, - self.left: self.right] + self.image = image[self.top: self.bottom, + self.left: self.right] # <<< Aligned Face methods and properties >>> # - def load_aligned(self, image, size=256, coverage_ratio=1.0, dtype=None): + def load_aligned(self, image, size=256, dtype=None): """ Align a face from a given image. Aligning a face is a relatively expensive task and is not required for all uses of @@ -212,8 +235,6 @@ def load_aligned(self, image, size=256, coverage_ratio=1.0, dtype=None): The image that contains the face to be aligned size: int The size of the output face in pixels - coverage_ratio: float - The metric determining the field of view of the returned face dtype: str, optional Optionally set a ``dtype`` for the final face to be formatted in. Default: ``None`` @@ -230,28 +251,25 @@ def load_aligned(self, image, size=256, coverage_ratio=1.0, dtype=None): logger.trace("Skipping alignment calculation for already aligned face") else: logger.trace("Loading aligned face: (size: %s, dtype: %s)", size, dtype) + padding = int(size * self._extract_ratio) // 2 self.aligned["size"] = size - self.aligned["padding"] = self._padding_from_coverage(size, coverage_ratio) + self.aligned["padding"] = padding self.aligned["matrix"] = get_align_mat(self) self.aligned["face"] = None if image is not None and self.aligned["face"] is None: logger.trace("Getting aligned face") - face = AlignerExtract().transform( - image, - self.aligned["matrix"], - size, - self.aligned["padding"]) + face = AlignerExtract().transform(image, self.aligned["matrix"], size, padding) self.aligned["face"] = face if dtype is None else face.astype(dtype) logger.trace("Loaded aligned face: %s", {k: str(v) if isinstance(v, np.ndarray) else v for k, v in self.aligned.items() if k != "face"}) - @staticmethod - def _padding_from_coverage(size, coverage_ratio): + def _padding_from_coverage(self, size, coverage_ratio): """ Return the image padding for a face from coverage_ratio set against a pre-padded training image """ - padding = int((size * (coverage_ratio - 0.625)) / 2) + adjusted_ratio = coverage_ratio - (1 - self._extract_ratio) + padding = round((size * adjusted_ratio) / 2) logger.trace(padding) return padding @@ -281,11 +299,7 @@ def load_feed_face(self, image, size=64, coverage_ratio=0.625, dtype=None): self.feed["size"] = size self.feed["padding"] = self._padding_from_coverage(size, coverage_ratio) self.feed["matrix"] = get_align_mat(self) - - face = AlignerExtract().transform(image, - self.feed["matrix"], - size, - self.feed["padding"]) + face = AlignerExtract().transform(image, self.feed["matrix"], size, self.feed["padding"]) self.feed["face"] = face if dtype is None else face.astype(dtype) logger.trace("Loaded feed face. (face_shape: %s, matrix: %s)", @@ -448,7 +462,7 @@ def reference_landmarks(self): @property def reference_matrix(self): - """ numpy.ndarray: The adjusted matrix face sized for refence against a face coming out of + """ numpy.ndarray: The adjusted matrix face sized for reference against a face coming out of a model. Only available after :func:`load_reference_face` has been called, otherwise returns ``None``""" if not self.reference: @@ -494,7 +508,7 @@ def __init__(self, storage_size=128): self._mask = None self._affine_matrix = None self._frame_dims = None - self._intepolator = None + self._interpolator = None @property def mask(self): @@ -510,7 +524,7 @@ def full_frame_mask(self): self._affine_matrix, self._frame_dims, frame, - flags=cv2.WARP_INVERSE_MAP | self._intepolator, + flags=cv2.WARP_INVERSE_MAP | self._interpolator, borderMode=cv2.BORDER_CONSTANT) logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, mask max: %s", mask.shape, mask.dtype, mask.min(), mask.max()) @@ -538,7 +552,7 @@ def add(self, mask, affine_matrix, frame_dims, interpolator): mask.min(), mask.max(), affine_matrix, frame_dims, interpolator) self._affine_matrix = self._adjust_affine_matrix(mask.shape[0], affine_matrix) self._frame_dims = frame_dims - self._intepolator = interpolator + self._interpolator = interpolator mask = (cv2.resize(mask, (self.stored_size, self.stored_size), interpolation=cv2.INTER_AREA) * 255.0).astype("uint8") @@ -574,10 +588,10 @@ def to_dict(self): ------- dict: The :class:`Mask` for saving to an alignments file. Contains the keys ``mask``, - ``affine_matrix``, ``frame_dims``, ``intepolator``, ``stored_size`` + ``affine_matrix``, ``frame_dims``, ``interpolator``, ``stored_size`` """ retval = dict() - for key in ("mask", "affine_matrix", "frame_dims", "intepolator", "stored_size"): + for key in ("mask", "affine_matrix", "frame_dims", "interpolator", "stored_size"): retval[key] = getattr(self, self._attr_name(key)) logger.trace({k: v if k != "mask" else type(v) for k, v in retval.items()}) return retval @@ -589,11 +603,11 @@ def from_dict(self, mask_dict): ---------- mask_dict: dict A dictionary stored in an alignments file containing the keys ``mask``, - ``affine_matrix``, ``frame_dims``, ``intepolator``, ``stored_size`` + ``affine_matrix``, ``frame_dims``, ``interpolator``, ``stored_size`` """ - for key in ("mask", "affine_matrix", "frame_dims", "intepolator", "stored_size"): + for key in ("mask", "affine_matrix", "frame_dims", "interpolator", "stored_size"): setattr(self, self._attr_name(key), mask_dict[key]) - logger.trace("{} - {}", key, mask_dict[key] if key != "mask" else type(mask_dict[key])) + logger.trace("%s - %s", key, mask_dict[key] if key != "mask" else type(mask_dict[key])) @staticmethod def _attr_name(dict_key): diff --git a/lib/serializer.py b/lib/serializer.py index eca9222260..db6b85d94d 100644 --- a/lib/serializer.py +++ b/lib/serializer.py @@ -227,7 +227,7 @@ def _unmarshal(cls, data): return pickle.loads(data) -class _NPYSerializer(Serializer): # pylint:disable=abstract-method +class _NPYSerializer(Serializer): """ NPY Serializer """ def __init__(self): super().__init__() @@ -330,7 +330,7 @@ def get_serializer_from_filename(filename): if extension == ".json": retval = _JSONSerializer() - elif extension == ".p": + elif extension in (".p", ".pickle"): retval = _PickleSerializer() elif extension == ".npy": retval = _NPYSerializer() diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 2933375725..86d4bcc2ab 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -18,12 +18,12 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name -# TODO Cpu mode +# TODO CPU mode # TODO Run with warnings mode def _get_config(plugin_name, configfile=None): - """ Return the config for the requested model + """ Return the configuration for the requested model Parameters ---------- @@ -31,12 +31,12 @@ def _get_config(plugin_name, configfile=None): The module name of the child plugin. configfile: str, optional Path to a :file:`./config/.ini` file for this plugin. Default: use system - config. + configuration. Returns ------- config_dict, dict - A dictionary of configuration items from the config file + A dictionary of configuration items from the configuration file """ return Config(plugin_name, configfile=configfile).config_dict @@ -190,7 +190,7 @@ def predict(self, batch): For Detect: the expected output for the ``prediction`` key of the :attr:`batch` dict should be a ``list`` of :attr:`batchsize` of detected face points. These points should be either - a ``list``, ``tuple`` or ``numpy.array`` with the first 4 items being the `left`, + a ``list``, ``tuple`` or ``numpy.ndarray`` with the first 4 items being the `left`, `top`, `right`, `bottom` points, in that order """ raise NotImplementedError @@ -209,18 +209,18 @@ def process_output(self, batch): ----- For Align: The key ``landmarks`` must be returned in the :attr:`batch` ``dict`` from this method. - This should be a ``list`` or ``numpy.array`` of :attr:`batchsize` containing a - ``list``, ``tuple`` or ``numpy.array`` of `(x, y)` co-ords of the 68 point landmarks - as calculated from the :attr:`model`. + This should be a ``list`` or ``numpy.ndarray`` of :attr:`batchsize` containing a + ``list``, ``tuple`` or ``numpy.ndarray`` of `(x, y)` coordinates of the 68 point + landmarks as calculated from the :attr:`model`. """ raise NotImplementedError def _predict(self, batch): """ **Override method** (at `` level) - This method is overridable at the `` level (ie. + This method should be overridden at the `` level (IE. ``plugins.extract.detect._base`` or ``plugins.extract.align._base``) and should not - be overriden within plugins themselves. + be overridden within plugins themselves. It acts as a wrapper for the plugin's ``self.predict`` method and handles any predict processing that is consistent for all plugins within the `plugin_type` @@ -235,9 +235,9 @@ def _predict(self, batch): def finalize(self, batch): """ **Override method** (at `` level) - This method is overridable at the `` level (ie. + This method should be overridden at the `` level (IE. :mod:`plugins.extract.detect._base` or :mod:`plugins.extract.align._base`) and should not - be overriden within plugins themselves. + be overridden within plugins themselves. Handles consistent finalization for all plugins that exist within that plugin type. Its input is always the output from :func:`process_output()` @@ -252,9 +252,9 @@ def finalize(self, batch): def get_batch(self, queue): """ **Override method** (at `` level) - This method is overridable at the `` level (ie. + This method should be overridden at the `` level (IE. :mod:`plugins.extract.detect._base` or :mod:`plugins.extract.align._base`) and should not - be overriden within plugins themselves. + be overridden within plugins themselves. Get items from the queue in batches of :attr:`batchsize` @@ -316,13 +316,13 @@ def _get_model(self, git_model_id, model_filename): # <<< PLUGIN INITIALIZATION >>> # def initialize(self, *args, **kwargs): - """ Inititalize the extractor plugin + """ Initialize the extractor plugin Should be called from :mod:`~plugins.extract.pipeline` """ logger.debug("initialize %s: (args: %s, kwargs: %s)", self.__class__.__name__, args, kwargs) - logger.info("Initializing %s in %s phase...", self.name, self._plugin_type) + logger.info("Initializing %s (%s)...", self.name, self._plugin_type.title()) self.queue_size = 1 self._add_queues(kwargs["in_queue"], kwargs["out_queue"], ["predict", "post"]) self._compile_threads() @@ -341,11 +341,11 @@ def initialize(self, *args, **kwargs): raise FaceswapError(msg) from err raise err logger.info("Initialized %s (%s) with batchsize of %s", - self.name, self._plugin_type, self.batchsize) + self.name, self._plugin_type.title(), self.batchsize) def _add_queues(self, in_queue, out_queue, queues): """ Add the queues - in_queue and out_queue should be pre-created queue manager queues + in_queue and out_queue should be previously created queue manager queues. queues should be a list of queue names """ self._queues["in"] = in_queue self._queues["out"] = out_queue @@ -440,14 +440,14 @@ def _get_item(queue): # <<< MISC UTILITY METHODS >>> # def _convert_color(self, image): - """ Convert the image to the correct color format """ + """ Convert the image to the correct color format and strip alpha channel """ logger.trace("Converting image to color format: %s", self.colorformat) if self.colorformat == "RGB": - cvt_image = image[:, :, ::-1].copy() + cvt_image = image[..., 2::-1].copy() elif self.colorformat == "GRAY": - cvt_image = cv2.cvtColor(image.copy(), cv2.COLOR_BGR2GRAY) # pylint:disable=no-member + cvt_image = cv2.cvtColor(image.copy(), cv2.COLOR_BGR2GRAY) else: - cvt_image = image.copy() + cvt_image = image[..., :3].copy() return cvt_image @staticmethod diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index 4c2d9b008a..903afa3250 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -75,7 +75,7 @@ def get_batch(self, queue): Items are returned from the ``queue`` in batches of :attr:`~plugins.extract._base.Extractor.batchsize` - To ensure consistent batchsizes for aligner the items are split into separate items for + To ensure consistent batch sizes for aligner the items are split into separate items for each :class:`lib.faces_detect.DetectedFace` object. Remember to put ``'EOF'`` to the out queue after processing @@ -183,14 +183,10 @@ def finalize(self, batch): """ - generator = zip(batch["detected_faces"], - batch["filename"], - batch["image"], - batch["landmarks"]) - for face, filename, image, landmarks in generator: - face.landmarks_xy = [(int(round(pt[0])), int(round(pt[1]))) for pt in landmarks] - face.image = image - face.filename = filename + for face, landmarks in zip(batch["detected_faces"], batch["landmarks"]): + if not isinstance(landmarks, np.ndarray): + landmarks = np.array(landmarks) + face.landmarks_xy = np.rint(landmarks).astype("int32") self._remove_invalid_keys(batch, ("detected_faces", "filename", "image")) logger.trace("Item out: %s", {key: val for key, val in batch.items() @@ -218,7 +214,7 @@ def _predict(self, batch): def _normalize_faces(self, faces): """ Normalizes the face for feeding into model - The normalization method is dictated by the cli argument: + The normalization method is dictated by the command line argument: -nh (--normalization) """ if self.normalize_method is None: @@ -243,13 +239,13 @@ def _normalize_mean(face): def _normalize_hist(face): """ Equalize the RGB histogram channels """ for chan in range(3): - face[:, :, chan] = cv2.equalizeHist(face[:, :, chan]) # pylint: disable=no-member + face[:, :, chan] = cv2.equalizeHist(face[:, :, chan]) return face @staticmethod def _normalize_clahe(face): """ Perform Contrast Limited Adaptive Histogram Equalization """ - clahe = cv2.createCLAHE(clipLimit=2.0, # pylint: disable=no-member + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(4, 4)) for chan in range(3): face[:, :, chan] = clahe.apply(face[:, :, chan]) diff --git a/plugins/extract/align/cv2_dnn.py b/plugins/extract/align/cv2_dnn.py index 577a964908..a28321b1e0 100644 --- a/plugins/extract/align/cv2_dnn.py +++ b/plugins/extract/align/cv2_dnn.py @@ -140,7 +140,7 @@ def get_square_box(box): @staticmethod def pad_image(box, image): - """Pad image if facebox falls outside of boundaries """ + """Pad image if face-box falls outside of boundaries """ width, height = image.shape[:2] pad_l = 1 - box[0] if box[0] < 0 else 0 pad_t = 1 - box[1] if box[1] < 0 else 0 @@ -178,6 +178,5 @@ def get_pts_from_predict(batch): points *= (roi[2] - roi[0]) points[:, 0] += roi[0] points[:, 1] += roi[1] - landmarks = np.rint(points).astype("uint").tolist() - batch.setdefault("landmarks", []).append(landmarks) + batch.setdefault("landmarks", []).append(points) logger.trace("Predicted Landmarks: %s", batch["landmarks"]) diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index 21c31835e8..b68a885b42 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -85,7 +85,7 @@ def get_batch(self, queue): >>> {'filename': [], >>> 'image': [], - >>> 'scaled_image': , + >>> 'scaled_image': , >>> 'scale': [], >>> 'pad': [], >>> 'detected_faces': [[>> # def _compile_detection_image(self, input_image): """ Compile the detection image for feeding into the model""" - image = self._convert_color(input_image[..., :3]) + image = self._convert_color(input_image) image_size = image.shape[:2] scale = self._set_scale(image_size) diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 0f23296203..6dca64db8e 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -60,9 +60,9 @@ def __init__(self, git_model_id=None, model_filename=None, configfile=None): super().__init__(git_model_id, model_filename, configfile=configfile) - self.input_size = 256 # Overide for model specific input_size - self.blur_kernel = 5 # Overide for model specific blur_kernel size - self.coverage_ratio = 1.0 # Overide for model specific coverage_ratio + self.input_size = 256 # Override for model specific input_size + self.blur_kernel = 5 # Override for model specific blur_kernel size + self.coverage_ratio = 1.0 # Override for model specific coverage_ratio self._plugin_type = "mask" self._storage_name = self.__module__.split(".")[-1].replace("_", "-") @@ -78,7 +78,7 @@ def get_batch(self, queue): Items are returned from the ``queue`` in batches of :attr:`~plugins.extract._base.Extractor.batchsize` - To ensure consistent batchsizes for masker the items are split into separate items for + To ensure consistent batch sizes for masker the items are split into separate items for each :class:`lib.faces_detect.DetectedFace` object. Remember to put ``'EOF'`` to the out queue after processing @@ -117,6 +117,7 @@ def get_batch(self, queue): self._queues["out"].put(item) continue for f_idx, face in enumerate(item["detected_faces"]): + face.image = self._convert_color(item["image"]) face.load_feed_face(face.image, size=self.input_size, coverage_ratio=1.0, @@ -197,7 +198,6 @@ def finalize(self, batch): # predicted = batch["prediction"] # predicted[predicted < 0.04] = 0.0 # predicted[predicted > 0.96] = 1.0 - # TODO Convert landmarks_xy to numpy arrays for mask, face in zip(batch["prediction"], batch["detected_faces"]): face.add_mask(self._storage_name, mask, @@ -205,7 +205,7 @@ def finalize(self, batch): (face.image.shape[1], face.image.shape[0]), face.feed_interpolators[1], storage_size=self._storage_size) - face.feed = None + face.feed = dict() self._remove_invalid_keys(batch, ("detected_faces", "filename", "image")) logger.trace("Item out: %s", {key: val diff --git a/plugins/extract/mask/unet_dfl.py b/plugins/extract/mask/unet_dfl.py index 257e703410..74206e3c69 100644 --- a/plugins/extract/mask/unet_dfl.py +++ b/plugins/extract/mask/unet_dfl.py @@ -1,5 +1,17 @@ #!/usr/bin/env python3 -""" UNET DFL face mask plugin """ +""" UNET DFL face mask plugin + +Architecture and Pre-Trained Model based on... +TernausNet: U-Net with VGG11 Encoder Pre-Trained on ImageNet for Image Segmentation +https://arxiv.org/abs/1801.05746 +https://github.com/ternaus/TernausNet + +Source Implementation and fine-tune training.... +https://github.com/iperov/DeepFaceLab/blob/master/nnlib/TernausNet.py + +Model file sourced from... +https://github.com/iperov/DeepFaceLab/blob/master/nnlib/FANSeg_256_full_face.h5 +""" import numpy as np from lib.model.session import KSession @@ -15,8 +27,8 @@ def __init__(self, **kwargs): self.name = "U-Net" self.input_size = 256 self.vram = 3440 - self.vram_warnings = 1024 # TODO determine - self.vram_per_batch = 64 # TODO determine + self.vram_warnings = 256 + self.vram_per_batch = 48 self.batchsize = self.config["batch-size"] def init_model(self): diff --git a/plugins/extract/mask/vgg_clear.py b/plugins/extract/mask/vgg_clear.py index 07b3385035..c8828b8368 100644 --- a/plugins/extract/mask/vgg_clear.py +++ b/plugins/extract/mask/vgg_clear.py @@ -1,5 +1,18 @@ #!/usr/bin/env python3 -""" VGG Clear face mask plugin """ +""" VGG Clear face mask plugin + +Architecture and Pre-Trained Model based on... +On Face Segmentation, Face Swapping, and Face Perception +https://arxiv.org/abs/1704.06729 + +Source Implementation... +https://github.com/YuvalNirkin/face_segmentation + +Model file sourced from... +https://github.com/YuvalNirkin/face_segmentation/releases/download/1.1/face_seg_fcn8s_300_no_aug.zip + +Caffe model reimplemented in Keras by Kyle Vrooman +""" import numpy as np from lib.model.session import KSession @@ -14,9 +27,9 @@ def __init__(self, **kwargs): super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.name = "VGG Clear" self.input_size = 300 - self.vram = 2000 # TODO determine - self.vram_warnings = 1024 # TODO determine - self.vram_per_batch = 64 # TODO determine + self.vram = 3104 + self.vram_warnings = 1088 # at BS 1. OOMs at higher batchsizes + self.vram_per_batch = 96 self.batchsize = self.config["batch-size"] def init_model(self): diff --git a/plugins/extract/mask/vgg_obstructed.py b/plugins/extract/mask/vgg_obstructed.py index 5f5522619f..8731975d41 100644 --- a/plugins/extract/mask/vgg_obstructed.py +++ b/plugins/extract/mask/vgg_obstructed.py @@ -1,5 +1,18 @@ #!/usr/bin/env python3 -""" VGG Obstructed face mask plugin """ +""" VGG Obstructed face mask plugin + +Architecture and Pre-Trained Model based on... +On Face Segmentation, Face Swapping, and Face Perception +https://arxiv.org/abs/1704.06729 + +Source Implementation... +https://github.com/YuvalNirkin/face_segmentation + +Model file sourced from... +https://github.com/YuvalNirkin/face_segmentation/releases/download/1.0/face_seg_fcn8s.zip + +Caffe model reimplemented in Keras by Kyle Vrooman +""" import numpy as np from lib.model.session import KSession @@ -14,9 +27,9 @@ def __init__(self, **kwargs): super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.name = "VGG Obstructed" self.input_size = 500 - self.vram = 3000 # TODO determine - self.vram_warnings = 1024 # TODO determine - self.vram_per_batch = 64 # TODO determine + self.vram = 3936 + self.vram_warnings = 1088 # at BS 1. OOMs at higher batchsizes + self.vram_per_batch = 208 self.batchsize = self.config["batch-size"] def init_model(self): diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 4cc28c92d9..13e33cfceb 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -6,7 +6,7 @@ together. This module sets up a pipeline for the extraction workflow, loading align and detect plugins -either in parallal or in series, giving easy access to input and output. +either in parallel or in series, giving easy access to input and output. """ @@ -65,12 +65,13 @@ def __init__(self, detector, aligner, masker, configfile=None, "normalize_method: %s)", self.__class__.__name__, detector, aligner, masker, configfile, multiprocess, rotate_images, min_size, normalize_method) - self.phase = "detect" + self._flow = ["detect", "align", "mask"] + self.phase = self._flow[0] self._queue_size = 32 self._vram_buffer = 320 # Leave a buffer for VRAM allocation - self._detector = self._load_detector(detector, rotate_images, min_size, configfile) - self._aligner = self._load_aligner(aligner, configfile, normalize_method) - self._masker = self._load_masker(masker, configfile) + self._detect = self._load_detect(detector, rotate_images, min_size, configfile) + self._align = self._load_align(aligner, configfile, normalize_method) + self._mask = self._load_mask(masker, configfile) self._is_parallel = self._set_parallel_processing(multiprocess) self._set_extractor_batchsize() self._queues = self._add_queues() @@ -86,19 +87,16 @@ def input_queue(self): For detect/single phase operations: >>> {'filename': , - >>> 'image': } + >>> 'image': } For align (2nd pass operations): >>> {'filename': , - >>> 'image': , + >>> 'image': , >>> 'detected_faces: []} """ - qname_dict = dict(detect="extract_detect_in", - align="extract_align_in", - mask="extract_mask_in") - qname = "extract_detect_in" if self._is_parallel else qname_dict[self.phase] + qname = "extract_{}_in".format(self.phase) retval = self._queues[qname] logger.trace("%s: %s", qname, retval) return retval @@ -116,13 +114,13 @@ def passes(self): >>> for phase in extractor.passes: >>> if phase == 1: >>> extractor.input_queue.put({"filename": "path/to/image/file", - >>> "image": np.array(image)}) + >>> "image": numpy.array(image)}) >>> else: >>> extractor.input_queue.put({"filename": "path/to/image/file", - >>> "image": np.array(image), + >>> "image": numpy.array(image), >>> "detected_faces": [>> else: >>> >>> extractor.input_queue.put({"filename": "path/to/image/file", - >>> "image": np.array(image), + >>> "image": numpy.array(image), >>> "detected_faces": [>> # + @property + def _next_phase(self): + """ Return the next phase from the flow list """ + retval = self._flow[self._flow.index(self.phase) + 1] + logger.trace(retval) + return retval + + @property + def _final_phase(self): + """ Return the final phase from the flow list """ + retval = self._flow[-1] + logger.trace(retval) + return retval + + @property + def _total_vram_required(self): + """ Return vram required for all phases plus the buffer """ + retval = sum([getattr(self, "_{}".format(p)).vram for p in self._flow]) + self._vram_buffer + logger.trace(retval) + return retval + @property def _output_queue(self): """ Return the correct output queue depending on the current phase """ - qname_dict = dict(detect="extract_align_in", - align="extract_mask_in", - mask="extract_mask_out") - qname = "extract_mask_out" if self.final_pass else qname_dict[self.phase] + if self.final_pass: + qname = "extract_{}_out".format(self._final_phase) + else: + qname = "extract_{}_in".format(self._next_phase) retval = self._queues[qname] logger.trace("%s: %s", qname, retval) return retval + @property + def _all_plugins(self): + """ Return list of all plugin objects in this pipeline """ + retval = [getattr(self, "_{}".format(phase)) for phase in self._flow] + logger.trace("All Plugins: %s", retval) + return retval + @property def _active_plugins(self): """ Return the plugins that are currently active based on pass """ if self.passes == 1: - retval = [self._detector, self._aligner, self._masker] - elif self.passes == 3 and self.phase == 'detect': - retval = [self._detector] - elif self.passes == 3 and self.phase == 'align': - retval = [self._aligner] - elif self.passes == 3 and self.phase == 'mask': - retval = [self._masker] + retval = self._all_plugins else: - retval = [None] + retval = [getattr(self, "_{}".format(self.phase))] logger.trace("Active plugins: %s", retval) return retval def _add_queues(self): """ Add the required processing queues to Queue Manager """ queues = dict() - tasks = ["extract_detect_in", "extract_align_in", "extract_mask_in", "extract_mask_out"] + tasks = ["extract_{}_in".format(phase) for phase in self._flow] + tasks.append("extract_{}_out".format(self._final_phase)) for task in tasks: # Limit queue size to avoid stacking ram self._queue_size = 32 - if task == "extract_detect_in" or (not self._is_parallel - and task == "extract_align_in"): + if task == "extract_{}_in".format(self._flow[0]) or (not self._is_parallel + and not task.endswith("_out")): self._queue_size = 64 queue_manager.add_queue(task, maxsize=self._queue_size) queues[task] = queue_manager.get_queue(task) @@ -291,20 +307,16 @@ def _set_parallel_processing(self, multiprocess): return True if get_backend() == "amd": - logger.debug("Parallel processing discabled by amd") + logger.debug("Parallel processing disabled by amd") return False - vram_required = (self._detector.vram + - self._aligner.vram + - self._masker.vram + - self._vram_buffer) stats = gpu_stats.get_card_most_free() vram_free = int(stats["free"]) logger.verbose("%s - %sMB free of %sMB", stats["device"], vram_free, int(stats["total"])) - if vram_free <= vram_required: + if vram_free <= self._total_vram_required: logger.warning("Not enough free VRAM for parallel processing. " "Switching to serial") return False @@ -312,7 +324,16 @@ def _set_parallel_processing(self, multiprocess): # << INTERNAL PLUGIN HANDLING >> # @staticmethod - def _load_detector(detector, rotation, min_size, configfile): + def _load_align(aligner, configfile, normalize_method): + """ Set global arguments and load aligner plugin """ + aligner_name = aligner.replace("-", "_").lower() + logger.debug("Loading Aligner: '%s'", aligner_name) + aligner = PluginLoader.get_aligner(aligner_name)(configfile=configfile, + normalize_method=normalize_method) + return aligner + + @staticmethod + def _load_detect(detector, rotation, min_size, configfile): """ Set global arguments and load detector plugin """ detector_name = detector.replace("-", "_").lower() logger.debug("Loading Detector: '%s'", detector_name) @@ -322,81 +343,59 @@ def _load_detector(detector, rotation, min_size, configfile): return detector @staticmethod - def _load_aligner(aligner, configfile, normalize_method): - """ Set global arguments and load aligner plugin """ - aligner_name = aligner.replace("-", "_").lower() - logger.debug("Loading Aligner: '%s'", aligner_name) - aligner = PluginLoader.get_aligner(aligner_name)(configfile=configfile, - normalize_method=normalize_method) - return aligner - - @staticmethod - def _load_masker(masker, configfile): + def _load_mask(masker, configfile): """ Set global arguments and load masker plugin """ masker_name = masker.replace("-", "_").lower() logger.debug("Loading Masker: '%s'", masker_name) masker = PluginLoader.get_masker(masker_name)(configfile=configfile) return masker - def _launch_detector(self): - """ Launch the face detector """ - logger.debug("Launching Detector") - kwargs = dict(in_queue=self._queues["extract_detect_in"], - out_queue=self._queues["extract_align_in"]) - self._detector.initialize(**kwargs) - self._detector.start() - logger.debug("Launched Detector") - - def _launch_aligner(self): - """ Launch the face aligner """ - logger.debug("Launching Aligner") - kwargs = dict(in_queue=self._queues["extract_align_in"], - out_queue=self._queues["extract_mask_in"]) - self._aligner.initialize(**kwargs) - self._aligner.start() - logger.debug("Launched Aligner") - - def _launch_masker(self): - """ Launch the face masker """ - logger.debug("Launching Masker") - kwargs = dict(in_queue=self._queues["extract_mask_in"], - out_queue=self._queues["extract_mask_out"]) - self._masker.initialize(**kwargs) - self._masker.start() - logger.debug("Launched Masker") + def _launch_plugin(self, phase): + """ Launch an extraction plugin """ + logger.debug("Launching %s plugin", phase) + in_qname = "extract_{}_in".format(phase) + if phase == self._final_phase: + out_qname = "extract_{}_out".format(self._final_phase) + else: + next_phase = self._flow[self._flow.index(phase) + 1] + out_qname = "extract_{}_in".format(next_phase) + logger.debug("in_qname: %s, out_qname: %s", in_qname, out_qname) + kwargs = dict(in_queue=self._queues[in_qname], out_queue=self._queues[out_qname]) + + plugin = getattr(self, "_{}".format(phase)) + plugin.initialize(**kwargs) + plugin.start() + logger.debug("Launched %s plugin", phase) def _set_extractor_batchsize(self): """ - Sets the batchsize of the requested plugins based on their vram and - vram_per_batch_requirements if the the configured batchsize requires more + Sets the batch size of the requested plugins based on their vram and + vram_per_batch_requirements if the the configured batch size requires more vram than is available. Nvidia only. """ if get_backend() != "nvidia": logger.debug("Backend is not Nvidia. Not updating batchsize requirements") return - if self._detector.vram == 0 and self._aligner.vram == 0 and self._masker.vram == 0: - logger.debug("Either detector, aligner or masker have no VRAM requirements. Not " - "updating batchsize requirements.") + if sum([plugin.vram for plugin in self._all_plugins]) == 0: + logger.debug("No plugins use VRAM. Not updating batchsize requirements.") return + stats = GPUStats().get_card_most_free() vram_free = int(stats["free"]) if self._is_parallel: - vram_required = (self._detector.vram + self._aligner.vram + self._masker.vram + - self._vram_buffer) - batch_required = ((self._detector.vram_per_batch * self._detector.batchsize) + - (self._aligner.vram_per_batch * self._aligner.batchsize) + - (self._masker.vram_per_batch * self._masker.batchsize)) - plugin_required = vram_required + batch_required + batch_required = sum([plugin.vram_per_batch * plugin.batchsize + for plugin in self._all_plugins]) + plugin_required = self._total_vram_required + batch_required if plugin_required <= vram_free: logger.debug("Plugin requirements within threshold: (plugin_required: %sMB, " "vram_free: %sMB)", plugin_required, vram_free) return # Hacky split across 3 plugins - available_vram = (vram_free - vram_required) // 3 - for plugin in (self._detector, self._aligner, self._masker): + available_vram = (vram_free - self._total_vram_required) // 3 + for plugin in self._all_plugins: self._set_plugin_batchsize(plugin, available_vram) else: - for plugin in (self._detector, self._aligner, self._masker): + for plugin in self._all_plugins: vram_required = plugin.vram + self._vram_buffer batch_required = plugin.vram_per_batch * plugin.batchsize plugin_required = vram_required + batch_required @@ -409,7 +408,7 @@ def _set_extractor_batchsize(self): @staticmethod def _set_plugin_batchsize(plugin, available_vram): - """ Set the batchsize for the given plugin based on given available vram """ + """ Set the batch size for the given plugin based on given available vram """ plugin.batchsize = max(1, available_vram // plugin.vram_per_batch) logger.verbose("Reset batchsize for %s to %s", plugin.name, plugin.batchsize) diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 0973c5083f..c23e2fd52f 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Plugin loader for extract, training and model tasks """ +""" Plugin loader for Faceswap extract, training and convert tasks """ import logging import os @@ -9,40 +9,151 @@ class PluginLoader(): - """ Plugin loader for extract, training and model tasks """ + """ Retrieve, or get information on, Faceswap plugins + + Return a specific plugin, list available plugins, or get the default plugin for a + task. + + Example + ------- + >>> from plugins.plugin_loader import PluginLoader + >>> align_plugins = PluginLoader.get_available_extractors('align') + >>> aligner = PluginLoader.get_aligner('cv2-dnn') + """ @staticmethod def get_detector(name, disable_logging=False): - """ Return requested detector plugin """ + """ Return requested detector plugin + + Parameters + ---------- + name: str + The name of the requested detector plugin + disable_logging: bool, optional + Whether to disable the INFO log message that the plugin is being imported. + Default: `False` + + Returns + ------- + :class:`plugins.extract.detect` object: + An extraction detector plugin + """ return PluginLoader._import("extract.detect", name, disable_logging) @staticmethod def get_aligner(name, disable_logging=False): - """ Return requested detector plugin """ + """ Return requested aligner plugin + + Parameters + ---------- + name: str + The name of the requested aligner plugin + disable_logging: bool, optional + Whether to disable the INFO log message that the plugin is being imported. + Default: `False` + + Returns + ------- + :class:`plugins.extract.align` object: + An extraction aligner plugin + """ return PluginLoader._import("extract.align", name, disable_logging) @staticmethod def get_masker(name, disable_logging=False): - """ Return requested detector plugin """ + """ Return requested masker plugin + + Parameters + ---------- + name: str + The name of the requested masker plugin + disable_logging: bool, optional + Whether to disable the INFO log message that the plugin is being imported. + Default: `False` + + Returns + ------- + :class:`plugins.extract.mask` object: + An extraction masker plugin + """ return PluginLoader._import("extract.mask", name, disable_logging) @staticmethod def get_model(name, disable_logging=False): - """ Return requested model plugin """ + """ Return requested training model plugin + + Parameters + ---------- + name: str + The name of the requested training model plugin + disable_logging: bool, optional + Whether to disable the INFO log message that the plugin is being imported. + Default: `False` + + Returns + ------- + :class:`plugins.train.model` object: + A training model plugin + """ return PluginLoader._import("train.model", name, disable_logging) @staticmethod def get_trainer(name, disable_logging=False): - """ Return requested trainer plugin """ + """ Return requested training trainer plugin + + Parameters + ---------- + name: str + The name of the requested training trainer plugin + disable_logging: bool, optional + Whether to disable the INFO log message that the plugin is being imported. + Default: `False` + + Returns + ------- + :class:`plugins.train.trainer` object: + A training trainer plugin + """ return PluginLoader._import("train.trainer", name, disable_logging) @staticmethod def get_converter(category, name, disable_logging=False): - """ Return the converter sub plugin """ + """ Return requested converter plugin + + Converters work slightly differently to other faceswap plugins. They are created to do a + specific task (e.g. color adjustment, mask blending etc.), so multiple plugins will be + loaded in the convert phase, rather than just one plugin for the other phases. + + Parameters + ---------- + name: str + The name of the requested converter plugin + disable_logging: bool, optional + Whether to disable the INFO log message that the plugin is being imported. + Default: `False` + + Returns + ------- + :class:`plugins.convert` object: + A converter sub plugin + """ return PluginLoader._import("convert.{}".format(category), name, disable_logging) @staticmethod def _import(attr, name, disable_logging): - """ Import the plugin's module """ + """ Import the plugin's module + + Parameters + ---------- + name: str + The name of the requested converter plugin + disable_logging: bool + Whether to disable the INFO log message that the plugin is being imported. + + Returns + ------- + :class:`plugin` object: + A plugin + """ name = name.replace("-", "_") ttl = attr.split(".")[-1].title() if not disable_logging: @@ -54,7 +165,18 @@ def _import(attr, name, disable_logging): @staticmethod def get_available_extractors(extractor_type): - """ Return a list of available aligners/detectors """ + """ Return a list of available extractors of the given type + + Parameters + ---------- + extractor_type: {'aligner', 'detector', 'masker'} + The type of extractor to return the plugins for + + Returns + ------- + list: + A list of the available extractor plugin names for the given type + """ extractpath = os.path.join(os.path.dirname(__file__), "extract", extractor_type) @@ -68,7 +190,13 @@ def get_available_extractors(extractor_type): @staticmethod def get_available_models(): - """ Return a list of available models """ + """ Return a list of available training models + + Returns + ------- + list: + A list of the available training model plugin names + """ modelpath = os.path.join(os.path.dirname(__file__), "train", "model") models = sorted(item.name.replace(".py", "").replace("_", "-") for item in os.scandir(modelpath) @@ -79,13 +207,34 @@ def get_available_models(): @staticmethod def get_default_model(): - """ Return the default model """ + """ Return the default training model plugin name + + Returns + ------- + str: + The default faceswap training model + + """ models = PluginLoader.get_available_models() return 'original' if 'original' in models else models[0] @staticmethod def get_available_convert_plugins(convert_category, add_none=True): - """ Return a list of available models """ + """ Return a list of available converter plugins in the given category + + Parameters + ---------- + convert_category: {'color', 'mask', 'scaling', 'writer'} + The category of converter plugin to return the plugins for + add_none: bool, optional + Append "none" to the list of returned plugins. Default: True + + Returns + ------- + list + A list of the available converter plugin names in the given category + """ + convertpath = os.path.join(os.path.dirname(__file__), "convert", convert_category) diff --git a/scripts/convert.py b/scripts/convert.py index f150c130fe..390b339fee 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -255,7 +255,8 @@ def load_extractor(self): "superior results") extractor = Extractor(detector="cv2-dnn", aligner="cv2-dnn", - multiprocess=False, + masker="none", + multiprocess=True, rotate_images=None, min_size=20) extractor.launch() @@ -584,7 +585,8 @@ def load_aligned(self, item): def compile_feed_faces(detected_faces): """ Compile the faces for feeding into the predictor """ logger.trace("Compiling feed face. Batchsize: %s", len(detected_faces)) - feed_faces = np.stack([detected_face.feed_face for detected_face in detected_faces]) + feed_faces = np.stack([detected_face.feed_face[..., :3] + for detected_face in detected_faces]) / 255.0 logger.trace("Compiled Feed faces. Shape: %s", feed_faces.shape) return feed_faces diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 31a0bb0e03..b7f99f2874 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -323,11 +323,8 @@ def process(self, output_item): feature_mask = extractor.get_feature_mask( aligned_landmarks / size, size, padding) - feature_mask = cv2.blur( # pylint: disable=no-member - feature_mask, (10, 10)) - isolated_face = cv2.multiply( # pylint: disable=no-member - feature_mask, - resized_face.astype(float)).astype(np.uint8) + feature_mask = cv2.blur(feature_mask, (10, 10)) + isolated_face = cv2.multiply(feature_mask, resized_face.astype(float)).astype(np.uint8) blurry, focus_measure = self.is_blurry(isolated_face) if blurry: @@ -340,7 +337,7 @@ def process(self, output_item): def is_blurry(self, image): """ Convert to grayscale, and compute the focus measure of the image using the Variance of Laplacian method """ - gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # pylint: disable=no-member + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) focus_measure = self.variance_of_laplacian(gray) # if the focus measure is less than the supplied threshold, @@ -353,7 +350,7 @@ def is_blurry(self, image): def variance_of_laplacian(image): """ Compute the Laplacian of the image and then return the focus measure, which is simply the variance of the Laplacian """ - retval = cv2.Laplacian(image, cv2.CV_64F).var() # pylint: disable=no-member + retval = cv2.Laplacian(image, cv2.CV_64F).var() logger.trace("Returning: %s", retval) return retval @@ -370,8 +367,7 @@ def process(self, output_item): detected_face["file_location"].parts[-1], idx) aligned_landmarks = face.aligned_landmarks for (pos_x, pos_y) in aligned_landmarks: - cv2.circle(face.feed_landmarks, # pylint: disable=no-member - (pos_x, pos_y), 2, (0, 0, 255, 255), -1) + cv2.circle(face.aligned_face, (pos_x, pos_y), 2, (0, 0, 255), -1) class FaceFilter(PostProcessAction): diff --git a/tools/alignments.py b/tools/alignments.py index a76742df95..7575a1dd9b 100644 --- a/tools/alignments.py +++ b/tools/alignments.py @@ -3,8 +3,8 @@ import logging from lib.utils import set_system_verbosity -from .lib_alignments import (AlignmentData, Check, Draw, # noqa pylint: disable=unused-import - Extract, Manual, Merge, Reformat, Rename, +from .lib_alignments import (AlignmentData, Check, Dfl, Draw, # noqa pylint: disable=unused-import + Extract, Manual, Merge, Rename, RemoveAlignments, Sort, Spatial, UpdateHashes) logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -42,8 +42,6 @@ def process(self): job = UpdateHashes elif self.args.job.startswith("remove-"): job = RemoveAlignments - elif self.args.job.startswith("sort-"): - job = Sort elif self.args.job in("missing-alignments", "missing-frames", "multi-faces", "leftover-faces", "no-faces"): job = Check diff --git a/tools/cli.py b/tools/cli.py index 003775bf5e..c3331de2e8 100644 --- a/tools/cli.py +++ b/tools/cli.py @@ -31,13 +31,16 @@ def get_argument_list(self): "opts": ("-j", "--job"), "action": Radio, "type": str, - "choices": ("draw", "extract", "manual", "merge", "missing-alignments", + "choices": ("dfl", "draw", "extract", "manual", "merge", "missing-alignments", "missing-frames", "leftover-faces", "multi-faces", "no-faces", - "reformat", "remove-faces", "remove-frames", "rename", "sort-x", "sort-y", - "spatial", "update-hashes"), + "remove-faces", "remove-frames", "rename", "sort", "spatial", + "update-hashes"), "required": True, "help": "R|Choose which action you want to perform. " "NB: All actions require an alignments file (-a) to be passed in." + "\nL|'dfl': Create an alignments file from faces extracted from DeepFaceLab. " + "Specify 'dfl' as the 'alignments file' entry and the folder containing the " + "dfl faces as the 'faces folder' ('-a dfl -fc '" "\nL|'draw': Draw landmarks on frames in the selected folder/video. A " "subfolder will be created within the frames folder to hold the output." + frames_dir + @@ -60,24 +63,16 @@ def get_argument_list(self): "file." + output_opts + frames_or_faces_dir + "\nL|'no-faces': Identify frames that exist within the alignment file but no " "faces were detected." + output_opts + frames_dir + - "\nL|'reformat': Save a copy of alignments file in a different format. " - "Specify a format with the -fmt option. Alignments can be converted from " - "DeepFaceLab by specifing: '-a dfl -fc '" "\nL|'remove-faces': Remove deleted faces from an alignments file. The " - "original alignments file will be backed up. A different file format for the " - "alignments file can optionally be specified (-fmt)." + faces_dir + + "original alignments file will be backed up." + faces_dir + "\nL|'remove-frames': Remove deleted frames from an alignments file. The " - "original alignments file will be backed up. A different file format for " - "the alignments file can optionally be specified (-fmt)." + frames_dir + + "original alignments file will be backed up." + frames_dir + "\nL|'rename' - Rename faces to correspond with their parent frame and " "position index in the alignments file (i.e. how they are named after running " "extract)." + faces_dir + - "\nL|'sort-x': Re-index the alignments from left to right. For alignments " + "\nL|'sort': Re-index the alignments from left to right. For alignments " "with multiple faces this will ensure that the left-most face is at index 0 " "Optionally pass in a faces folder (-fc) to also rename extracted faces." - "\nL|'sort-y': Re-index the alignments from top to bottom. For alignments " - "with multiple faces this will ensure that the top-most face is at index 0. " - "Optionally pass in a faces folder (-fc) to also rename extracted faces." "\nL|'spatial': Perform spatial and temporal filtering to smooth alignments " "(EXPERIMENTAL!)" "\nL|'update-hashes': Recalculate the face hashes. Only use this if you have " diff --git a/tools/lib_alignments/__init__.py b/tools/lib_alignments/__init__.py index f23a628cf7..9945b7be83 100644 --- a/tools/lib_alignments/__init__.py +++ b/tools/lib_alignments/__init__.py @@ -1,4 +1,4 @@ from tools.lib_alignments.media import AlignmentData, ExtractedFaces, Faces, Frames from tools.lib_alignments.annotate import Annotate -from tools.lib_alignments.jobs import Check, Draw, Extract, Merge, Reformat, RemoveAlignments, Rename, Sort, Spatial, UpdateHashes +from tools.lib_alignments.jobs import Check, Dfl, Draw, Extract, Merge, RemoveAlignments, Rename, Sort, Spatial, UpdateHashes from tools.lib_alignments.jobs_manual import Manual diff --git a/tools/lib_alignments/jobs.py b/tools/lib_alignments/jobs.py index f2d74cd511..df017292b8 100644 --- a/tools/lib_alignments/jobs.py +++ b/tools/lib_alignments/jobs.py @@ -257,6 +257,93 @@ def move_faces(self, output_folder, items_output): os.rename(src, dst) +class Dfl(): + """ Reformat Alignment file """ + def __init__(self, alignments, arguments): + logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) + self.alignments = alignments + if self.alignments.file != "dfl.fsa": + logger.error("Alignments file must be specified as 'dfl' to reformat dfl alignmnets") + exit(0) + logger.debug("Loading DFL faces") + self.faces = Faces(arguments.faces_dir) + logger.debug("Initialized %s", self.__class__.__name__) + + def process(self): + """ Run reformat """ + logger.info("[REFORMAT DFL ALIGNMENTS]") # Tidy up cli output + self.alignments.data = self.load_dfl() + self.alignments.file = self.alignments.get_location(self.faces.folder, "alignments") + self.alignments.save() + + def load_dfl(self): + """ Load alignments from DeepFaceLab and format for Faceswap """ + alignments = dict() + for face in tqdm(self.faces.file_list_sorted, desc="Converting DFL Faces"): + if face["face_extension"] not in (".png", ".jpg"): + logger.verbose("'%s' is not a png or jpeg. Skipping", face["face_fullname"]) + continue + f_hash = face["face_hash"] + fullpath = os.path.join(self.faces.folder, face["face_fullname"]) + dfl = self.get_dfl_alignment(fullpath) + + if not dfl: + continue + + self.convert_dfl_alignment(dfl, f_hash, alignments) + return alignments + + @staticmethod + def get_dfl_alignment(filename): + """ Process the alignment of one face """ + ext = os.path.splitext(filename)[1] + + if ext.lower() in (".jpg", ".jpeg"): + img = Image.open(filename) + try: + dfl_alignments = pickle.loads(img.app["APP15"]) + dfl_alignments["source_rect"] = [n.item() # comes as non-JSONable np.int32 + for n in dfl_alignments["source_rect"]] + return dfl_alignments + except pickle.UnpicklingError: + return None + + with open(filename, "rb") as dfl: + header = dfl.read(8) + if header != b"\x89PNG\r\n\x1a\n": + logger.error("No Valid PNG header: %s", filename) + return None + while True: + chunk_start = dfl.tell() + chunk_hdr = dfl.read(8) + if not chunk_hdr: + break + chunk_length, chunk_name = struct.unpack("!I4s", chunk_hdr) + dfl.seek(chunk_start, os.SEEK_SET) + if chunk_name == b"fcWp": + chunk = dfl.read(chunk_length + 12) + retval = pickle.loads(chunk[8:-4]) + logger.trace("Loaded DFL Alignment: (filename: '%s', alignment: %s", + filename, retval) + return retval + dfl.seek(chunk_length+12, os.SEEK_CUR) + logger.error("Couldn't find DFL alignments: %s", filename) + + @staticmethod + def convert_dfl_alignment(dfl_alignments, f_hash, alignments): + """ Add DFL Alignments to alignments in Faceswap format """ + sourcefile = dfl_alignments["source_filename"] + left, top, right, bottom = dfl_alignments["source_rect"] + alignment = {"x": left, + "w": right - left, + "y": top, + "h": bottom - top, + "hash": f_hash, + "landmarks_xy": np.array(dfl_alignments["source_landmarks"], dtype="uint8")} + logger.trace("Adding alignment: (frame: '%s', alignment: %s", sourcefile, alignment) + alignments.setdefault(sourcefile, list()).append(alignment) + + class Draw(): """ Draw Alignments on passed in images """ def __init__(self, alignments, arguments): @@ -515,92 +602,6 @@ def set_destination_filename(self): self.final_alignments.file = filename -class Reformat(): - """ Reformat Alignment file """ - def __init__(self, alignments, arguments): - logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - self.alignments = alignments - if self.alignments.file == "dfl.json": - logger.debug("Loading DFL faces") - self.faces = Faces(arguments.faces_dir) - logger.debug("Initialized %s", self.__class__.__name__) - - def process(self): - """ Run reformat """ - logger.info("[REFORMAT ALIGNMENTS]") # Tidy up cli output - if self.alignments.file == "dfl.json": - self.alignments.data = self.load_dfl() - self.alignments.file = self.alignments.get_location(self.faces.folder, "alignments") - self.alignments.save() - - def load_dfl(self): - """ Load alignments from DeepFaceLab and format for Faceswap """ - alignments = dict() - for face in tqdm(self.faces.file_list_sorted, desc="Converting DFL Faces"): - if face["face_extension"] not in (".png", ".jpg"): - logger.verbose("'%s' is not a png or jpeg. Skipping", face["face_fullname"]) - continue - f_hash = face["face_hash"] - fullpath = os.path.join(self.faces.folder, face["face_fullname"]) - dfl = self.get_dfl_alignment(fullpath) - - if not dfl: - continue - - self.convert_dfl_alignment(dfl, f_hash, alignments) - return alignments - - @staticmethod - def get_dfl_alignment(filename): - """ Process the alignment of one face """ - ext = os.path.splitext(filename)[1] - - if ext.lower() in (".jpg", ".jpeg"): - img = Image.open(filename) - try: - dfl_alignments = pickle.loads(img.app["APP15"]) - dfl_alignments["source_rect"] = [n.item() # comes as non-JSONable np.int32 - for n in dfl_alignments["source_rect"]] - return dfl_alignments - except pickle.UnpicklingError: - return None - - with open(filename, "rb") as dfl: - header = dfl.read(8) - if header != b"\x89PNG\r\n\x1a\n": - logger.error("No Valid PNG header: %s", filename) - return None - while True: - chunk_start = dfl.tell() - chunk_hdr = dfl.read(8) - if not chunk_hdr: - break - chunk_length, chunk_name = struct.unpack("!I4s", chunk_hdr) - dfl.seek(chunk_start, os.SEEK_SET) - if chunk_name == b"fcWp": - chunk = dfl.read(chunk_length + 12) - retval = pickle.loads(chunk[8:-4]) - logger.trace("Loaded DFL Alignment: (filename: '%s', alignment: %s", - filename, retval) - return retval - dfl.seek(chunk_length+12, os.SEEK_CUR) - logger.error("Couldn't find DFL alignments: %s", filename) - - @staticmethod - def convert_dfl_alignment(dfl_alignments, f_hash, alignments): - """ Add DFL Alignments to alignments in Faceswap format """ - sourcefile = dfl_alignments["source_filename"] - left, top, right, bottom = dfl_alignments["source_rect"] - alignment = {"x": left, - "w": right - left, - "y": top, - "h": bottom - top, - "hash": f_hash, - "landmarks_xy": dfl_alignments["source_landmarks"]} - logger.trace("Adding alignment: (frame: '%s', alignment: %s", sourcefile, alignment) - alignments.setdefault(sourcefile, list()).append(alignment) - - class RemoveAlignments(): """ Remove items from alignments file """ def __init__(self, alignments, arguments): @@ -755,12 +756,10 @@ def check_multi_hashes(self, faces, frame, idx): class Sort(): - """ Sort alignments' index by the order they appear in - an image """ + """ Sort alignments' index by the order they appear in an image """ def __init__(self, alignments, arguments): logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self.alignments = alignments - self.axis = arguments.job.replace("sort-", "") self.faces = self.get_faces(arguments) logger.debug("Initialized %s", self.__class__.__name__) @@ -791,7 +790,7 @@ def reindex_faces(self): if count <= 1: logger.trace("0 or 1 face in frame. Not sorting: '%s'", frame) continue - sorted_alignments = sorted([item for item in alignments], key=lambda x: (x[self.axis])) + sorted_alignments = sorted([item for item in alignments], key=lambda x: (x["x"])) if sorted_alignments == alignments: logger.trace("Alignments already in correct order. Not sorting: '%s'", frame) continue diff --git a/tools/lib_alignments/jobs_manual.py b/tools/lib_alignments/jobs_manual.py index 2cf4d63826..fe56e4d7d2 100644 --- a/tools/lib_alignments/jobs_manual.py +++ b/tools/lib_alignments/jobs_manual.py @@ -781,11 +781,11 @@ def __init__(self, interface, loglevel): def init_extractor(self): """ Initialize Aligner """ logger.debug("Initialize Extractor") - extractor = Extractor("manual", "fan", multiprocess=True, normalize_method="hist") + extractor = Extractor("manual", "fan", "none", multiprocess=True, normalize_method="hist") self.queues["in"] = extractor.input_queue # Set the batchsizes to 1 - extractor.set_batchsize("detector", 1) - extractor.set_batchsize("aligner", 1) + for plugin_type in ("detect", "align", "mask"): + extractor.set_batchsize(plugin_type, 1) extractor.launch() logger.debug("Initialized Extractor") return extractor From ad035f2a243368b7f1d16903a544854b6d05a177 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 19 Oct 2019 23:53:42 +0000 Subject: [PATCH 096/981] Optimize Extract parallel VRAM allocation --- plugins/extract/align/fan_defaults.py | 2 +- plugins/extract/detect/s3fd.py | 4 +- plugins/extract/detect/s3fd_defaults.py | 2 +- plugins/extract/mask/unet_dfl.py | 4 +- plugins/extract/mask/vgg_clear.py | 4 +- plugins/extract/mask/vgg_clear_defaults.py | 2 +- plugins/extract/mask/vgg_obstructed.py | 2 +- .../extract/mask/vgg_obstructed_defaults.py | 2 +- plugins/extract/pipeline.py | 40 +++++++++++++++---- 9 files changed, 43 insertions(+), 19 deletions(-) diff --git a/plugins/extract/align/fan_defaults.py b/plugins/extract/align/fan_defaults.py index da7277ace4..1c08acdf6b 100644 --- a/plugins/extract/align/fan_defaults.py +++ b/plugins/extract/align/fan_defaults.py @@ -50,7 +50,7 @@ _DEFAULTS = { "batch-size": { - "default": 8, + "default": 12, "info": "The batch size to use. To a point, higher batch sizes equal better performance, " "but setting it too high can harm performance.\n" "\n\tNvidia users: If the batchsize is set higher than the your GPU can " diff --git a/plugins/extract/detect/s3fd.py b/plugins/extract/detect/s3fd.py index 469db1715a..70456f20e6 100644 --- a/plugins/extract/detect/s3fd.py +++ b/plugins/extract/detect/s3fd.py @@ -23,9 +23,9 @@ def __init__(self, **kwargs): super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.name = "S3FD" self.input_size = 640 - self.vram = 4096 + self.vram = 4112 self.vram_warnings = 1024 # Will run at this with warnings - self.vram_per_batch = 128 + self.vram_per_batch = 208 self.batchsize = self.config["batch-size"] def init_model(self): diff --git a/plugins/extract/detect/s3fd_defaults.py b/plugins/extract/detect/s3fd_defaults.py index 59fcb0782d..a0f78a3e05 100755 --- a/plugins/extract/detect/s3fd_defaults.py +++ b/plugins/extract/detect/s3fd_defaults.py @@ -63,7 +63,7 @@ "fixed": True, }, "batch-size": { - "default": 8, + "default": 4, "info": "The batch size to use. To a point, higher batch sizes equal better performance, " "but setting it too high can harm performance.\n" "\n\tNvidia users: If the batchsize is set higher than the your GPU can " diff --git a/plugins/extract/mask/unet_dfl.py b/plugins/extract/mask/unet_dfl.py index 74206e3c69..993b1c6ac8 100644 --- a/plugins/extract/mask/unet_dfl.py +++ b/plugins/extract/mask/unet_dfl.py @@ -26,9 +26,9 @@ def __init__(self, **kwargs): super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.name = "U-Net" self.input_size = 256 - self.vram = 3440 + self.vram = 3424 self.vram_warnings = 256 - self.vram_per_batch = 48 + self.vram_per_batch = 80 self.batchsize = self.config["batch-size"] def init_model(self): diff --git a/plugins/extract/mask/vgg_clear.py b/plugins/extract/mask/vgg_clear.py index c8828b8368..55a2174908 100644 --- a/plugins/extract/mask/vgg_clear.py +++ b/plugins/extract/mask/vgg_clear.py @@ -27,9 +27,9 @@ def __init__(self, **kwargs): super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.name = "VGG Clear" self.input_size = 300 - self.vram = 3104 + self.vram = 2944 self.vram_warnings = 1088 # at BS 1. OOMs at higher batchsizes - self.vram_per_batch = 96 + self.vram_per_batch = 400 self.batchsize = self.config["batch-size"] def init_model(self): diff --git a/plugins/extract/mask/vgg_clear_defaults.py b/plugins/extract/mask/vgg_clear_defaults.py index 003a943248..6ce28a9898 100644 --- a/plugins/extract/mask/vgg_clear_defaults.py +++ b/plugins/extract/mask/vgg_clear_defaults.py @@ -51,7 +51,7 @@ _DEFAULTS = { "batch-size": { - "default": 8, + "default": 6, "info": "The batch size to use. To a point, higher batch sizes equal better performance, " "but setting it too high can harm performance.\n" "\n\tNvidia users: If the batchsize is set higher than the your GPU can " diff --git a/plugins/extract/mask/vgg_obstructed.py b/plugins/extract/mask/vgg_obstructed.py index 8731975d41..480dd4aae2 100644 --- a/plugins/extract/mask/vgg_obstructed.py +++ b/plugins/extract/mask/vgg_obstructed.py @@ -29,7 +29,7 @@ def __init__(self, **kwargs): self.input_size = 500 self.vram = 3936 self.vram_warnings = 1088 # at BS 1. OOMs at higher batchsizes - self.vram_per_batch = 208 + self.vram_per_batch = 304 self.batchsize = self.config["batch-size"] def init_model(self): diff --git a/plugins/extract/mask/vgg_obstructed_defaults.py b/plugins/extract/mask/vgg_obstructed_defaults.py index 89e42cee23..9a21d760a0 100644 --- a/plugins/extract/mask/vgg_obstructed_defaults.py +++ b/plugins/extract/mask/vgg_obstructed_defaults.py @@ -52,7 +52,7 @@ _DEFAULTS = { "batch-size": { - "default": 8, + "default": 2, "info": "The batch size to use. To a point, higher batch sizes equal better performance, " "but setting it too high can harm performance.\n" "\n\tNvidia users: If the batchsize is set higher than the your GPU can " diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 13e33cfceb..badb90b4e7 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -68,7 +68,7 @@ def __init__(self, detector, aligner, masker, configfile=None, self._flow = ["detect", "align", "mask"] self.phase = self._flow[0] self._queue_size = 32 - self._vram_buffer = 320 # Leave a buffer for VRAM allocation + self._vram_buffer = 256 # Leave a buffer for VRAM allocation self._detect = self._load_detect(detector, rotate_images, min_size, configfile) self._align = self._load_align(aligner, configfile, normalize_method) self._mask = self._load_mask(masker, configfile) @@ -229,6 +229,37 @@ def detected_faces(self): logger.debug("Switching to %s phase", self.phase) # <<< INTERNAL METHODS >>> # + @property + def _parallel_scaling(self): + """ dict: key is number of parallel plugins being loaded, value is the scaling factor that + the total base vram for those plugins should be scaled by + + Notes + ----- + VRAM for parallel plugins does not stack in a linear manner. Calculating the precise + scaling for any given plugin combination is non trivial, however the following are + calculations based on running 2-5 plugins in parallel using s3fd, fan, unet, vgg-clear + and vgg-obstructed. The worst ratio is selected for each combination, plus a litle extra + to ensure that vram is not used up. + + If OOM errors are being reported, then these ratios should be relaxed some more + """ + retval = {2: 0.7, + 3: 0.55, + 4: 0.5, + 5: 0.4} + logger.trace(retval) + return retval + + @property + def _total_vram_required(self): + """ Return vram required for all phases plus the buffer """ + vrams = [getattr(self, "_{}".format(p)).vram for p in self._flow] + vram_required_count = sum(1 for p in vrams if p > 0) + retval = (sum(vrams) * self._parallel_scaling[vram_required_count]) + self._vram_buffer + logger.trace(retval) + return retval + @property def _next_phase(self): """ Return the next phase from the flow list """ @@ -243,13 +274,6 @@ def _final_phase(self): logger.trace(retval) return retval - @property - def _total_vram_required(self): - """ Return vram required for all phases plus the buffer """ - retval = sum([getattr(self, "_{}".format(p)).vram for p in self._flow]) + self._vram_buffer - logger.trace(retval) - return retval - @property def _output_queue(self): """ Return the correct output queue depending on the current phase """ From 25a2ac95c3b788834db0808307073047747998dd Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 21 Oct 2019 23:10:26 +0000 Subject: [PATCH 097/981] Add faster image hash reading mechanism Add batch hash reader to lib.images Utilize batch hash reader in tools.alignments --- lib/image.py | 39 +++++++++++++++++++++++++++++++++++ tools/lib_alignments/media.py | 24 ++++++++++++--------- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/lib/image.py b/lib/image.py index f822d2ba65..e346eca9c9 100644 --- a/lib/image.py +++ b/lib/image.py @@ -140,6 +140,45 @@ def read_image_hash(filename): return image_hash +def read_image_hash_batch(filenames): + """ Return the `sha` hash of a batch of images + + Leverages multi-threading to load multiple images from disk at the same time + leading to vastly reduced image read times. Creates a generator to retrieve filenames + with their hashes as they are calculated. + + Notes + ----- + The order of returned values is non-deterministic so will most likely not be returned in the + same order as the filenames + + Parameters + ---------- + filenames: list + A list of ``str`` full paths to the images to be loaded. + show_progress: bool, optional + Display a progress bar. Default: False + + Yields + ------- + tuple: (`filename`, :func:`hashlib.hexdigest()` representation of the `sha1` hash of the image) + Example + ------- + >>> image_filenames = ["/path/to/image_1.png", "/path/to/image_2.png", "/path/to/image_3.png"] + >>> for filename, hash in read_image_hash_batch(image_filenames): + >>> + """ + logger.trace("Requested batch: '%s'", filenames) + executor = futures.ThreadPoolExecutor() + with executor: + read_hashes = {executor.submit(read_image_hash, filename): filename + for filename in filenames} + for future in futures.as_completed(read_hashes): + retval = (read_hashes[future], future.result()) + logger.trace("Yielding: %s", retval) + yield retval + + def encode_image_with_hash(image, extension): """ Encode an image, and get the encoded image back with its `sha1` hash. diff --git a/tools/lib_alignments/media.py b/tools/lib_alignments/media.py index f9a3941c8c..01b69d5359 100644 --- a/tools/lib_alignments/media.py +++ b/tools/lib_alignments/media.py @@ -14,7 +14,8 @@ from lib.aligner import Extract as AlignerExtract from lib.alignments import Alignments from lib.faces_detect import DetectedFace -from lib.image import count_frames_and_secs, encode_image_with_hash, read_image, read_image_hash +from lib.image import (count_frames_and_secs, encode_image_with_hash, read_image, + read_image_hash_batch) from lib.utils import _image_extensions, _video_extensions logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -171,15 +172,18 @@ class Faces(MediaLoader): def process_folder(self): """ Iterate through the faces dir pulling out various information """ logger.info("Loading file list from %s", self.folder) - for face in tqdm(os.listdir(self.folder), desc="Reading Face Hashes"): - if not self.valid_extension(face): - continue - filename = os.path.splitext(face)[0] - file_extension = os.path.splitext(face)[1] - face_hash = read_image_hash(os.path.join(self.folder, face)) - retval = {"face_fullname": face, - "face_name": filename, - "face_extension": file_extension, + + filelist = [os.path.join(self.folder, face) + for face in os.listdir(self.folder) + if self.valid_extension(face)] + for fullpath, face_hash in tqdm(read_image_hash_batch(filelist), + total=len(filelist), + desc="Reading Face Hashes"): + filename = os.path.basename(fullpath) + face_name, extension = os.path.splitext(filename) + retval = {"face_fullname": filename, + "face_name": face_name, + "face_extension": extension, "face_hash": face_hash} logger.trace(retval) yield retval From 5d9e0a2109b78962669b4bd215bb4da91b8e15e2 Mon Sep 17 00:00:00 2001 From: deepfakes <34667098+deepfakes@users.noreply.github.com> Date: Tue, 22 Oct 2019 00:20:24 +0100 Subject: [PATCH 098/981] Update FUNDING.yml --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index a983a28097..45572b2246 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ patreon: faceswap +github: deepfakes From c065916f239d405f5f298376212725902df8c8b3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 23 Oct 2019 15:05:24 +0000 Subject: [PATCH 099/981] Extract - Mask updates - Remove none mask plugin - Make pipeline more flexible - Add support for pre-aligned faces to masker plugin - Migrate blur and threshold settings to mask output --- lib/cli.py | 13 ++++----- lib/faces_detect.py | 53 ++++++++++++++++++++++++++++++----- plugins/extract/mask/_base.py | 24 ++++++---------- plugins/extract/mask/none.py | 36 ------------------------ plugins/extract/pipeline.py | 47 ++++++++++++++++++++++++------- plugins/plugin_loader.py | 7 +++-- 6 files changed, 102 insertions(+), 78 deletions(-) delete mode 100644 plugins/extract/mask/none.py diff --git a/lib/cli.py b/lib/cli.py index d491cdf225..94e71aa09e 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -23,7 +23,7 @@ class ScriptExecutor(): """ Loads the relevant script modules and executes the script. - This class is initialised in each of the argparsers for the relevant + This class is initialized in each of the argparsers for the relevant command, then execute script is called within their set_default function. """ @@ -80,7 +80,7 @@ def test_tkinter(): tkinter app is available on their machine. If not exit gracefully. - This avoids having to import every tk function + This avoids having to import every tkinter function within the GUI in a wrapper and potentially spamming traceback errors to console """ @@ -321,9 +321,9 @@ def error(self, message): class SmartFormatter(argparse.HelpFormatter): """ Smart formatter for allowing raw formatting in help - text and lists in the helptext + text and lists in the help text - To use: prefix the help item with "R|" to overide + To use: prefix the help item with "R|" to override default formatting. List items can be marked with "L|" at the start of a newline @@ -566,14 +566,13 @@ def get_optional_arguments(): "opts": ("-M", "--masker"), "action": Radio, "type": str.lower, - "choices": PluginLoader.get_available_extractors("mask"), + "choices": PluginLoader.get_available_extractors("mask", add_none=True), "default": "extended", "group": "Plugins", "help": "R|Masker to use. NB: Masker is not currently used by the rest of the process " "but this will store a mask in the alignments file for use when it has been " "implemented." - "\nL|none: An array of all ones is created to provide a 4th channel that will " - "not mask any portion of the image." + "\nL|none: Don't use a mask." "\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." diff --git a/lib/faces_detect.py b/lib/faces_detect.py index 5d3a565f1b..84ba7cf6f9 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -273,7 +273,8 @@ def _padding_from_coverage(self, size, coverage_ratio): logger.trace(padding) return padding - def load_feed_face(self, image, size=64, coverage_ratio=0.625, dtype=None): + def load_feed_face(self, image, size=64, coverage_ratio=0.625, dtype=None, + is_aligned_face=False): """ Align a face in the correct dimensions for feeding into a model. Parameters @@ -286,6 +287,9 @@ def load_feed_face(self, image, size=64, coverage_ratio=0.625, dtype=None): the ratio of the extracted image that was used for training. Default: `0.625` dtype: str, optional Optionally set a ``dtype`` for the final face to be formatted in. Default: ``None`` + is_aligned_face: bool, optional + Indicates that the :attr:`image` is an aligned face rather than a frame. + Default: ``False`` Notes ----- @@ -293,13 +297,21 @@ def load_feed_face(self, image, size=64, coverage_ratio=0.625, dtype=None): - :func:`feed_face` - :func:`feed_interpolators` """ - logger.trace("Loading feed face: (size: %s, coverage_ratio: %s, dtype: %s)", - size, coverage_ratio, dtype) + logger.trace("Loading feed face: (size: %s, coverage_ratio: %s, dtype: %s, " + "is_aligned_face: %s)", size, coverage_ratio, dtype, is_aligned_face) self.feed["size"] = size self.feed["padding"] = self._padding_from_coverage(size, coverage_ratio) self.feed["matrix"] = get_align_mat(self) - face = AlignerExtract().transform(image, self.feed["matrix"], size, self.feed["padding"]) + if is_aligned_face: + original_size = image.shape[0] + interp = cv2.INTER_CUBIC if original_size < size else cv2.INTER_AREA + face = cv2.resize(image, (size, size), interpolation=interp) + else: + face = AlignerExtract().transform(image, + self.feed["matrix"], + size, + self.feed["padding"]) self.feed["face"] = face if dtype is None else face.astype(dtype) logger.trace("Loaded feed face. (face_shape: %s, matrix: %s)", @@ -510,11 +522,22 @@ def __init__(self, storage_size=128): self._frame_dims = None self._interpolator = None + self._blur_kernel = 0 + self._threshold = 0.0 + @property def mask(self): - """ numpy.ndarray: The mask at the size of :attr:`stored_size` """ - return np.frombuffer(decompress(self._mask), - dtype="uint8").reshape((self.stored_size, self.stored_size, 1)) + """ numpy.ndarray: The mask at the size of :attr:`stored_size` with any requested blurring + and threshold amount applied.""" + dims = (self.stored_size, self.stored_size, 1) + mask = np.frombuffer(decompress(self._mask), dtype="uint8").reshape(dims) + if self._threshold != 0.0: + mask[mask < self._threshold] = 0.0 + mask[mask > 255.0 - self._threshold] = 255.0 + if self._blur_kernel != 0: + mask = cv2.GaussianBlur(mask, (self._blur_kernel, self._blur_kernel), 0)[..., None] + logger.trace("mask shape: %s", mask.shape) + return mask @property def full_frame_mask(self): @@ -558,6 +581,22 @@ def add(self, mask, affine_matrix, frame_dims, interpolator): interpolation=cv2.INTER_AREA) * 255.0).astype("uint8") self._mask = compress(mask) + def set_blur_kernel_and_threshold(self, blur_kernel=0, threshold=0): + """ Set the internal blur kernel and threshold amount for returned masks + + Parameters + ---------- + blur_kernel: int, optional + The kernel size, in pixels to apply gaussian blurring to the mask. Set to 0 for no + blurring. Default: 0 + threshold: int, optional + The threshold amount to minimize/maximize mask values to 0 and 100. Percentage value. + Default: 0 + """ + logger.trace("blur_kernel: %s, threshold: %s", blur_kernel, threshold) + self._blur_kernel = blur_kernel + self._threshold = (threshold / 100.0) * 255.0 + def _adjust_affine_matrix(self, mask_size, affine_matrix): """ Adjust the affine matrix for the mask's storage size diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 6dca64db8e..e76d159959 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -36,17 +36,15 @@ class Masker(Extractor): # pylint:disable=abstract-method https://github.com/deepfakes-models/faceswap-models for more information model_filename: str The name of the model file to be loaded + image_is_aligned: bool, optional + Indicates that the passed in image is an aligned face rather than a frame. + Default: ``False`` Other Parameters ---------------- configfile: str, optional Path to a custom configuration ``ini`` file. Default: Use system configfile - Attributes - ---------- - blur_kernel, int - The size of the kernel for applying gaussian blur to the output of the mask - See Also -------- plugins.extract.align : Aligner plugins @@ -55,16 +53,17 @@ class Masker(Extractor): # pylint:disable=abstract-method plugins.extract.align._base : Aligner parent class for extraction plugins. """ - def __init__(self, git_model_id=None, model_filename=None, configfile=None): + def __init__(self, git_model_id=None, model_filename=None, configfile=None, + image_is_aligned=False): logger.debug("Initializing %s: (configfile: %s, )", self.__class__.__name__, configfile) super().__init__(git_model_id, model_filename, configfile=configfile) self.input_size = 256 # Override for model specific input_size - self.blur_kernel = 5 # Override for model specific blur_kernel size self.coverage_ratio = 1.0 # Override for model specific coverage_ratio self._plugin_type = "mask" + self._image_is_aligned = image_is_aligned self._storage_name = self.__module__.split(".")[-1].replace("_", "-") self._storage_size = 128 # Size to store masks at. Leave this at default self._faces_per_filename = dict() # Tracking for recompiling face batches @@ -121,7 +120,8 @@ def get_batch(self, queue): face.load_feed_face(face.image, size=self.input_size, coverage_ratio=1.0, - dtype="float32") + dtype="float32", + is_aligned_face=self._image_is_aligned) batch.setdefault("detected_faces", []).append(face) batch.setdefault("filename", []).append(item["filename"]) batch.setdefault("image", []).append(item["image"]) @@ -190,14 +190,6 @@ def finalize(self, batch): :class:`lib.faces_detect.DetectedFace` objects. """ - # TODO Migrate these settings to retrieval rather than storage - # if self.blur_kernel is not None: - # predicted = np.array([cv2.GaussianBlur(mask, (self.blur_kernel, self.blur_kernel), 0) - # for mask in batch["prediction"]]) - # else: - # predicted = batch["prediction"] - # predicted[predicted < 0.04] = 0.0 - # predicted[predicted > 0.96] = 1.0 for mask, face in zip(batch["prediction"], batch["detected_faces"]): face.add_mask(self._storage_name, mask, diff --git a/plugins/extract/mask/none.py b/plugins/extract/mask/none.py deleted file mode 100644 index a6c31ebe9f..0000000000 --- a/plugins/extract/mask/none.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python3 -""" Dummy empty Mask for faceswap.py """ - -import numpy as np -from ._base import Masker, logger - - -class Mask(Masker): - """ Perform transformation to align and get landmarks """ - def __init__(self, **kwargs): - git_model_id = None - model_filename = None - super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.input_size = 256 - self.name = "None" - self.vram = 0 - self.vram_per_batch = 0 - self.batchsize = 1 - - def init_model(self): - logger.debug("No mask model to initialize") - - def process_input(self, batch): - """ Compile the detected faces for prediction """ - batch["feed"] = np.zeros((self.batchsize, self.input_size, self.input_size, 1), - dtype="float32") - return batch - - def predict(self, batch): - """ Run model to get predictions """ - batch["prediction"] = np.ones_like(batch["feed"], dtype="float32") - return batch - - def process_output(self, batch): - """ Compile found faces for output """ - return batch diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index badb90b4e7..ba0a7d62b8 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -5,8 +5,8 @@ Tensorflow does not like to release GPU VRAM, so parallel plugins need to be managed to work together. -This module sets up a pipeline for the extraction workflow, loading align and detect plugins -either in parallel or in series, giving easy access to input and output. +This module sets up a pipeline for the extraction workflow, loading detect, align and mask +plugins either in parallel or in series, giving easy access to input and output. """ @@ -50,6 +50,9 @@ class Extractor(): normalize_method: {`None`, 'clahe', 'hist', 'mean'}, optional Used to set the :attr:`~plugins.extract.align.normalize_method` attribute. Normalize the images fed to the aligner.Default: ``None`` + image_is_aligned: bool, optional + Used to set the :attr:`~plugins.extract.mask.image_is_aligned` attribute. Indicates to the + masker that the fed in image is an aligned face rather than a frame.Default: ``False`` Attributes ---------- @@ -59,19 +62,19 @@ class Extractor(): """ def __init__(self, detector, aligner, masker, configfile=None, multiprocess=False, rotate_images=None, min_size=20, - normalize_method=None): + normalize_method=None, image_is_aligned=False): logger.debug("Initializing %s: (detector: %s, aligner: %s, masker: %s, " "configfile: %s, multiprocess: %s, rotate_images: %s, min_size: %s, " - "normalize_method: %s)", + "normalize_method: %s, image_is_aligned: %s)", self.__class__.__name__, detector, aligner, masker, configfile, - multiprocess, rotate_images, min_size, normalize_method) - self._flow = ["detect", "align", "mask"] + multiprocess, rotate_images, min_size, normalize_method, image_is_aligned) + self._flow = self._set_flow(detector, aligner, masker) self.phase = self._flow[0] self._queue_size = 32 self._vram_buffer = 256 # Leave a buffer for VRAM allocation self._detect = self._load_detect(detector, rotate_images, min_size, configfile) self._align = self._load_align(aligner, configfile, normalize_method) - self._mask = self._load_mask(masker, configfile) + self._mask = self._load_mask(masker, image_is_aligned, configfile) self._is_parallel = self._set_parallel_processing(multiprocess) self._set_extractor_batchsize() self._queues = self._add_queues() @@ -239,7 +242,7 @@ def _parallel_scaling(self): VRAM for parallel plugins does not stack in a linear manner. Calculating the precise scaling for any given plugin combination is non trivial, however the following are calculations based on running 2-5 plugins in parallel using s3fd, fan, unet, vgg-clear - and vgg-obstructed. The worst ratio is selected for each combination, plus a litle extra + and vgg-obstructed. The worst ratio is selected for each combination, plus a little extra to ensure that vram is not used up. If OOM errors are being reported, then these ratios should be relaxed some more @@ -302,6 +305,20 @@ def _active_plugins(self): logger.trace("Active plugins: %s", retval) return retval + @staticmethod + def _set_flow(detector, aligner, masker): + """ Set the flow list based on the input plugins """ + logger.debug("detector: %s, aligner: %s, masker: %s", detector, aligner, masker) + retval = [] + if detector is not None and detector.lower() != "none": + retval.append("detect") + if aligner is not None and aligner.lower() != "none": + retval.append("align") + if masker is not None and masker.lower() != "none": + retval.append("mask") + logger.debug("flow: %s", retval) + return retval + def _add_queues(self): """ Add the required processing queues to Queue Manager """ queues = dict() @@ -350,6 +367,9 @@ def _set_parallel_processing(self, multiprocess): @staticmethod def _load_align(aligner, configfile, normalize_method): """ Set global arguments and load aligner plugin """ + if aligner is None or aligner.lower() == "none": + logger.debug("No aligner selected. Returning None") + return None aligner_name = aligner.replace("-", "_").lower() logger.debug("Loading Aligner: '%s'", aligner_name) aligner = PluginLoader.get_aligner(aligner_name)(configfile=configfile, @@ -359,6 +379,9 @@ def _load_align(aligner, configfile, normalize_method): @staticmethod def _load_detect(detector, rotation, min_size, configfile): """ Set global arguments and load detector plugin """ + if detector is None or detector.lower() == "none": + logger.debug("No detector selected. Returning None") + return None detector_name = detector.replace("-", "_").lower() logger.debug("Loading Detector: '%s'", detector_name) detector = PluginLoader.get_detector(detector_name)(rotation=rotation, @@ -367,11 +390,15 @@ def _load_detect(detector, rotation, min_size, configfile): return detector @staticmethod - def _load_mask(masker, configfile): + def _load_mask(masker, image_is_aligned, configfile): """ Set global arguments and load masker plugin """ + if masker is None or masker.lower() == "none": + logger.debug("No masker selected. Returning None") + return None masker_name = masker.replace("-", "_").lower() logger.debug("Loading Masker: '%s'", masker_name) - masker = PluginLoader.get_masker(masker_name)(configfile=configfile) + masker = PluginLoader.get_masker(masker_name)(image_is_aligned=image_is_aligned, + configfile=configfile) return masker def _launch_plugin(self, phase): diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index c23e2fd52f..8f162af939 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -164,14 +164,15 @@ def _import(attr, name, disable_logging): return getattr(module, ttl) @staticmethod - def get_available_extractors(extractor_type): + def get_available_extractors(extractor_type, add_none=False): """ Return a list of available extractors of the given type Parameters ---------- extractor_type: {'aligner', 'detector', 'masker'} The type of extractor to return the plugins for - + add_none: bool, optional + Append "none" to the list of returned plugins. Default: False Returns ------- list: @@ -186,6 +187,8 @@ def get_available_extractors(extractor_type): and not item.name.endswith("defaults.py") and item.name.endswith(".py") and item.name != "manual.py") + if add_none: + extractors.insert(0, "none") return extractors @staticmethod From 86dda1212c5089ae1af8191a93266bddc884243f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 23 Oct 2019 18:34:27 +0000 Subject: [PATCH 100/981] Revert lib.image.read_image to standard loading --- lib/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/image.py b/lib/image.py index e346eca9c9..9913aca66c 100644 --- a/lib/image.py +++ b/lib/image.py @@ -56,7 +56,7 @@ def read_image(filename, raise_error=False): success = True image = None try: - image = cv2.imread(filename, cv2.IMREAD_UNCHANGED) + image = cv2.imread(filename) if image is None: raise ValueError except TypeError: From 6cda2176e237df8369277e04d5d4bef53c0c4851 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 24 Oct 2019 09:30:37 +0000 Subject: [PATCH 101/981] lib.alignments - cache hashes_to_frame --- lib/alignments.py | 19 +++++++++++-------- tools/lib_alignments/jobs.py | 6 ++---- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/lib/alignments.py b/lib/alignments.py index 64f7ba9782..e50dfa5ae6 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -29,6 +29,7 @@ def __init__(self, folder, filename="alignments"): self.data = self.load() self.update_legacy() + self._hashes_to_frame = dict() logger.debug("Initialized %s", self.__class__.__name__) # << PROPERTIES >> # @@ -56,14 +57,16 @@ def have_alignments_file(self): @property def hashes_to_frame(self): - """ Return a dict of each face_hash with their parent - frame name(s) and their index in the frame - """ - hash_faces = dict() - for frame_name, faces in self.data.items(): - for idx, face in enumerate(faces): - hash_faces.setdefault(face["hash"], dict())[frame_name] = idx - return hash_faces + """ Return :attr:`_hashes_to_frame`. Generate it if it does not exist. + The dict is of each face_hash with their parent frame name(s) and their index + in the frame + """ + if not self._hashes_to_frame: + logger.debug("Generating hashes to frame") + for frame_name, faces in self.data.items(): + for idx, face in enumerate(faces): + self._hashes_to_frame.setdefault(face["hash"], dict())[frame_name] = idx + return self._hashes_to_frame # << INIT FUNCTIONS >> # diff --git a/tools/lib_alignments/jobs.py b/tools/lib_alignments/jobs.py index df017292b8..7d33674dc3 100644 --- a/tools/lib_alignments/jobs.py +++ b/tools/lib_alignments/jobs.py @@ -114,12 +114,11 @@ def get_multi_faces_faces(self): """ Return Faces when there are multiple faces in a frame """ self.output_message = "Multiple faces in frame" seen_hash_dupes = set() - hashes_to_frame = self.alignments.hashes_to_frame for item in tqdm(self.items, desc=self.output_message): filename = item["face_fullname"] f_hash = item["face_hash"] frame_idx = [(frame, idx) - for frame, idx in hashes_to_frame[f_hash].items()] + for frame, idx in self.alignments.hashes_to_frame[f_hash].items()] if len(frame_idx) > 1: # If the same hash exists in multiple frames, select arbitrary frame @@ -160,10 +159,9 @@ def get_missing_frames(self): def get_leftover_faces(self): """yield each face that isn't in the alignments file.""" self.output_message = "Faces missing from the alignments file" - hashes_to_frame = self.alignments.hashes_to_frame for face in tqdm(self.items, desc=self.output_message): f_hash = face["face_hash"] - if f_hash not in hashes_to_frame: + if f_hash not in self.alignments.hashes_to_frame: logger.debug("Returning: '%s'", face["face_fullname"]) yield face["face_fullname"], -1 From 701b2f1f51e982edbe7c6c7241cba7887136e132 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 25 Oct 2019 17:18:04 +0000 Subject: [PATCH 102/981] Minor Fixes scripts.train - default to .fsa alignments file setup.py - Lower non-root message to info level --- scripts/train.py | 10 +++++----- setup.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/train.py b/scripts/train.py index 48484ae08a..bffc994b3e 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -38,7 +38,7 @@ def __init__(self, arguments): logger.debug("Initialized %s", self.__class__.__name__) def set_timelapse(self): - """ Set timelapse paths if requested """ + """ Set time-lapse paths if requested """ if (not self.args.timelapse_input_a and not self.args.timelapse_input_b and not self.args.timelapse_output): @@ -65,7 +65,7 @@ def set_timelapse(self): return kwargs def get_images(self): - """ Check the image dirs exist, contain images and return the image + """ Check the image folders exist, contain images and return the image objects """ logger.debug("Getting image paths") images = dict() @@ -194,13 +194,13 @@ def image_size(self): @property def alignments_paths(self): - """ Set the alignments path to input dirs if not provided """ + """ Set the alignments path to input folder if not provided """ alignments_paths = dict() for side in ("a", "b"): alignments_path = getattr(self.args, "alignments_path_{}".format(side)) if not alignments_path: image_path = getattr(self.args, "input_{}".format(side)) - alignments_path = os.path.join(image_path, "alignments.json") + alignments_path = os.path.join(image_path, "alignments.fsa") alignments_paths[side] = alignments_path logger.debug("Alignments paths: %s", alignments_paths) return alignments_paths @@ -311,7 +311,7 @@ def monitor(self, thread): @staticmethod def keypress_monitor(keypress_queue): - """ Monitor stdin for keypress """ + """ Monitor stdin for key press """ while True: keypress_queue.put(sys.stdin.read(1)) diff --git a/setup.py b/setup.py index 2f3223ec5e..53bb3fd37e 100755 --- a/setup.py +++ b/setup.py @@ -132,7 +132,7 @@ def check_permission(self): if self.is_admin: self.output.info("Running as Root/Admin") else: - self.output.warning("Running without root/admin privileges") + self.output.info("Running without root/admin privileges") def check_system(self): """ Check the system """ From e488baf5f47244879b8495924329d26397df2bfd Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 25 Oct 2019 17:25:06 +0000 Subject: [PATCH 103/981] Mask Updates - Remove storage of original frame_dims from alignments file - Require frame dims to be passed in to faces_detect.Mask when requesting full frame mask - Create copy of read only mask when adding blurring/threshold --- lib/faces_detect.py | 57 ++++++++++++++++++++--------------- plugins/extract/mask/_base.py | 1 - 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/lib/faces_detect.py b/lib/faces_detect.py index 84ba7cf6f9..82441896d7 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -118,7 +118,7 @@ def _extract_ratio(self): """ float: The ratio of padding to add for training images """ return 0.375 - def add_mask(self, name, mask, affine_matrix, frame_dims, interpolator, storage_size=128): + def add_mask(self, name, mask, affine_matrix, interpolator, storage_size=128): """ Add a :class:`Mask` to this detected face The mask should be the original output from :mod:`plugins.extract.mask` @@ -135,17 +135,15 @@ def add_mask(self, name, mask, affine_matrix, frame_dims, interpolator, storage_ It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` affine_matrix: numpy.ndarray The transformation matrix required to transform the mask to the original frame. - frame_dims: tuple - The `(height, width)` dimensions of the original frame that this mask was created from. interpolator, int: - The CV2 interpolator required to transform this mask to it's original frame + The CV2 interpolator required to transform this mask to it's original frame. storage_size, int (optional): - The size the mask is to be stored at. + The size the mask is to be stored at. Default: 128 """ - logger.trace("name: '%s', mask shape: %s, affine_matrix: %s, frame_dims: %s, " - "interpolator: %s", name, mask.shape, affine_matrix, frame_dims, interpolator) + logger.trace("name: '%s', mask shape: %s, affine_matrix: %s, interpolator: %s)", + name, mask.shape, affine_matrix, interpolator) fsmask = Mask(storage_size=storage_size) - fsmask.add(mask, affine_matrix, frame_dims, interpolator) + fsmask.add(mask, affine_matrix, interpolator) self.mask[name] = fsmask def to_alignment(self): @@ -519,7 +517,6 @@ def __init__(self, storage_size=128): self._mask = None self._affine_matrix = None - self._frame_dims = None self._interpolator = None self._blur_kernel = 0 @@ -531,6 +528,8 @@ def mask(self): and threshold amount applied.""" dims = (self.stored_size, self.stored_size, 1) mask = np.frombuffer(decompress(self._mask), dtype="uint8").reshape(dims) + if self._threshold != 0.0 or self._blur_kernel != 0: + mask = mask.copy() if self._threshold != 0.0: mask[mask < self._threshold] = 0.0 mask[mask > 255.0 - self._threshold] = 255.0 @@ -539,13 +538,24 @@ def mask(self): logger.trace("mask shape: %s", mask.shape) return mask - @property - def full_frame_mask(self): - """ numpy.ndarray: The mask affined to the original full frame """ - frame = np.zeros(self._frame_dims + (1, ), dtype="uint8") + def get_full_frame_mask(self, width, height): + """ Return the stored mask in a full size frame of the given dimensions + + Parameters + ---------- + width: int + The width of the original frame that the mask was extracted from + height: int + The height of the original frame that the mask was extracted from + + Returns + ------- + numpy.ndarray: The mask affined to the original full frame of the given dimensions + """ + frame = np.zeros((width, height, 1), dtype="uint8") mask = cv2.warpAffine(self.mask, self._affine_matrix, - self._frame_dims, + (width, height), frame, flags=cv2.WARP_INVERSE_MAP | self._interpolator, borderMode=cv2.BORDER_CONSTANT) @@ -553,7 +563,7 @@ def full_frame_mask(self): mask.shape, mask.dtype, mask.min(), mask.max()) return mask - def add(self, mask, affine_matrix, frame_dims, interpolator): + def add(self, mask, affine_matrix, interpolator): """ Add a Faceswap mask to this :class:`Mask`. The mask should be the original output from :mod:`plugins.extract.mask` @@ -565,16 +575,13 @@ def add(self, mask, affine_matrix, frame_dims, interpolator): It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` affine_matrix: numpy.ndarray The transformation matrix required to transform the mask to the original frame. - frame_dims: tuple - The `(height, width)` dimensions of the original frame that this mask was created from. - interpolator: + interpolator, int: The CV2 interpolator required to transform this mask to it's original frame """ logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, mask max: %s, " - "affine_matrix: %s, frame_dims: %s, interpolator: %s", mask.shape, mask.dtype, - mask.min(), mask.max(), affine_matrix, frame_dims, interpolator) + "affine_matrix: %s, interpolator: %s)", mask.shape, mask.dtype, mask.min(), + mask.max(), interpolator) self._affine_matrix = self._adjust_affine_matrix(mask.shape[0], affine_matrix) - self._frame_dims = frame_dims self._interpolator = interpolator mask = (cv2.resize(mask, (self.stored_size, self.stored_size), @@ -627,10 +634,10 @@ def to_dict(self): ------- dict: The :class:`Mask` for saving to an alignments file. Contains the keys ``mask``, - ``affine_matrix``, ``frame_dims``, ``interpolator``, ``stored_size`` + ``affine_matrix``, ``interpolator``, ``stored_size`` """ retval = dict() - for key in ("mask", "affine_matrix", "frame_dims", "interpolator", "stored_size"): + for key in ("mask", "affine_matrix", "interpolator", "stored_size"): retval[key] = getattr(self, self._attr_name(key)) logger.trace({k: v if k != "mask" else type(v) for k, v in retval.items()}) return retval @@ -642,9 +649,9 @@ def from_dict(self, mask_dict): ---------- mask_dict: dict A dictionary stored in an alignments file containing the keys ``mask``, - ``affine_matrix``, ``frame_dims``, ``interpolator``, ``stored_size`` + ``affine_matrix``, ``interpolator``, ``stored_size`` """ - for key in ("mask", "affine_matrix", "frame_dims", "interpolator", "stored_size"): + for key in ("mask", "affine_matrix", "interpolator", "stored_size"): setattr(self, self._attr_name(key), mask_dict[key]) logger.trace("%s - %s", key, mask_dict[key] if key != "mask" else type(mask_dict[key])) diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index e76d159959..da15912217 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -194,7 +194,6 @@ def finalize(self, batch): face.add_mask(self._storage_name, mask, face.feed_matrix, - (face.image.shape[1], face.image.shape[0]), face.feed_interpolators[1], storage_size=self._storage_size) face.feed = dict() From 4edc25448f008af693d3a52aebed0aedea912e71 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 25 Oct 2019 18:08:20 +0000 Subject: [PATCH 104/981] Mask tool tools.mask - A tool for creating masks for existing alignments files and outputting mask previews lib.image.BackgroundIO - A background image loader and saver --- docs/full/modules.rst | 1 + docs/full/tools.mask.rst | 7 + docs/full/tools.rst | 17 ++ lib/image.py | 300 +++++++++++++++++++++++++++++-- tools.py | 3 + tools/cli.py | 114 +++++++++++- tools/mask.py | 368 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 796 insertions(+), 14 deletions(-) create mode 100644 docs/full/tools.mask.rst create mode 100644 docs/full/tools.rst create mode 100644 tools/mask.py diff --git a/docs/full/modules.rst b/docs/full/modules.rst index f632b60805..09bbbacf4f 100644 --- a/docs/full/modules.rst +++ b/docs/full/modules.rst @@ -6,3 +6,4 @@ faceswap lib plugins + tools diff --git a/docs/full/tools.mask.rst b/docs/full/tools.mask.rst new file mode 100644 index 0000000000..5a5d3e0df3 --- /dev/null +++ b/docs/full/tools.mask.rst @@ -0,0 +1,7 @@ +tools.mask module +====================================== + +.. automodule:: tools.mask + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/tools.rst b/docs/full/tools.rst new file mode 100644 index 0000000000..45b22b5159 --- /dev/null +++ b/docs/full/tools.rst @@ -0,0 +1,17 @@ +tools package +============= + +Subpackages +----------- + +.. toctree:: + + tools.mask + +Module contents +--------------- + +.. automodule:: tools + :members: + :undoc-members: + :show-inheritance: diff --git a/lib/image.py b/lib/image.py index 9913aca66c..5466d8f1e4 100644 --- a/lib/image.py +++ b/lib/image.py @@ -3,16 +3,20 @@ import logging import subprocess +import os import sys from concurrent import futures from hashlib import sha1 import cv2 +import imageio import imageio_ffmpeg as im_ffm import numpy as np -from lib.utils import convert_to_secs, FaceswapError +from lib.multithreading import MultiThread +from lib.queue_manager import queue_manager, QueueEmpty +from lib.utils import convert_to_secs, FaceswapError, _video_extensions, get_image_paths logger = logging.getLogger(__name__) # pylint:disable=invalid-name @@ -23,7 +27,7 @@ # <<< IMAGE IO >>> # -def read_image(filename, raise_error=False): +def read_image(filename, raise_error=False, with_hash=False): """ Read an image file from a file location. Extends the functionality of :func:`cv2.imread()` by ensuring that an image was actually @@ -35,20 +39,23 @@ def read_image(filename, raise_error=False): filename: str Full path to the image to be loaded. raise_error: bool, optional - If ``True``, then any failures (including the returned image being ``None``) will be + If ``True`` then any failures (including the returned image being ``None``) will be raised. If ``False`` then an error message will be logged, but the error will not be raised. Default: ``False`` + with_hash: bool, optional + If ``True`` then returns the image's sha1 hash with the image. Default: ``False`` Returns ------- - numpy.ndarray - The image in `BGR` channel order. - + numpy.ndarray or tuple + If :attr:`with_hash` is ``False`` then returns a `numpy.ndarray` of the image in `BGR` + channel order. If :attr:`with_hash` is ``True`` then returns a `tuple` of (`numpy.ndarray`" + of the image in `BGR`, `str` of sha` hash of image) Example ------- >>> image_file = "/path/to/image.png" >>> try: - >>> image = read_image(image_file, raise_error=True) + >>> image = read_image(image_file, raise_error=True, with_hash=False) >>> except: >>> raise ValueError("There was an error") """ @@ -79,7 +86,8 @@ def read_image(filename, raise_error=False): if raise_error: raise Exception(msg) logger.trace("Loaded image: '%s'. Success: %s", filename, success) - return image + retval = (image, sha1(image).hexdigest()) if with_hash else image + return retval def read_image_batch(filenames): @@ -258,7 +266,7 @@ def count_frames_and_secs(filename, timeout=90): """ Count the number of frames and seconds in a video file. Adapted From :mod:`ffmpeg_imageio` to handle the issue of ffmpeg occasionally hanging - inside a subprocess. + inside a sub-process. If the operation times out then the process will try to read the data again, up to a total of 3 times. If the data still cannot be read then an exception will be raised. @@ -275,9 +283,9 @@ def count_frames_and_secs(filename, timeout=90): Returns ------- - nframes: int + frames: int The number of frames in the given video file. - nsecs: float + secs: float The duration, in seconds, of the given video file. Example @@ -318,7 +326,7 @@ def count_frames_and_secs(filename, timeout=90): "Retrying %s of %s", this_attempt + 1, attempts) continue - # Note that other than with the subprocess calls below, ffmpeg wont hang here. + # Note that other than with the sub-process calls below, ffmpeg wont hang here. # Worst case Python will stop/crash and ffmpeg will continue running until done. nframes = nsecs = None @@ -338,4 +346,270 @@ def count_frames_and_secs(filename, timeout=90): logger.debug("nframes: %s, nsecs: %s", nframes, nsecs) return nframes, nsecs - raise RuntimeError("Could not get number of frames") # pragma: no cover + raise RuntimeError("Could not get number of frames") + + +class BackgroundIO(): + """ Perform disk IO for images or videos in a background thread. + + Loads images or videos from a given location in a background thread. + Saves images to the given location in a background thread. + Images/Videos will be loaded or saved in deterministic order. + + Parameters + ---------- + path: str + The path to load or save images to/from. For loading this can be a folder which contains + images or a video file. For saving this must be an existing folder. + task: {'load', 'save'} + The task to be performed. ``'load'`` to load images/video frames, ``'save'`` to save + images. + load_with_hash: bool, optional + When loading images, set to ``True`` to return the sha1 hash of the image along with the + image. Default: ``False``. + queue_size: int, optional + The amount of images to hold in the internal buffer. Default: 16. + + Examples + -------- + Loading from a video file: + + >>> loader = BackgroundIO('/path/to/video.mp4', 'load') + >>> for filename, image in loader.load(): + >>> + + Loading faces with their sha1 hash: + + >>> loader = BackgroundIO('/path/to/faces/folder', 'load', load_with_hash=True) + >>> for filename, image, sha1_hash in loader.load(): + >>> + + Saving out images: + + >>> saver = BackgroundIO('/path/to/save/folder', 'save') + >>> for filename, image in : + >>> saver.save(filename, image) + >>> saver.close() + """ + + def __init__(self, path, task, load_with_hash=False, queue_size=16): + logger.debug("Initializing %s: (path: %s, task: %s, load_with_hash: %s, queue_size: %s)", + self.__class__.__name__, path, task, load_with_hash, queue_size) + self._location = path + + self._task = task.lower() + self._is_video = self._check_input() + self._input = self.location if self._is_video else get_image_paths(self.location) + self._count = count_frames_and_secs(self._input)[0] if self._is_video else len(self._input) + self._queue = queue_manager.get_queue(name="{}_{}".format(self.__class__.__name__, + self._task), + maxsize=queue_size) + self._thread = self._set_thread(io_args=(load_with_hash, )) + self._thread.start() + + @property + def count(self): + """ int: The number of images or video frames to be processed """ + return self._count + + @property + def location(self): + """ str: The folder or video that was passed in as the :attr:`path` parameter. """ + return self._location + + def _check_input(self): + """ Check whether the input path is valid and return if it is a video. + + Returns + ------- + bool: 'True' if input is a video 'False' if it is a folder. + """ + if not os.path.exists(self.location): + raise FaceswapError("The location '{}' does not exist".format(self.location)) + + if self._task == "save" and not os.path.isdir(self.location): + raise FaceswapError("The output location '{}' is not a folder".format(self.location)) + + is_video = (self._task == "load" and + os.path.isfile(self.location) and + os.path.splitext(self.location)[1].lower() in _video_extensions) + if is_video: + logger.debug("Input is video") + else: + logger.debug("Input is folder") + return is_video + + def _set_thread(self, io_args=None): + """ Set the load/save thread + + Parameters + ---------- + io_args: tuple, optional + The arguments to be passed to the load or save thread. Default: `None`. + + Returns + ------- + :class:`lib.multithreading.MultiThread`: Thread containing the load/save function. + """ + io_args = (self._queue) if io_args is None else (self._queue, *io_args) + retval = MultiThread(getattr(self, "_{}".format(self._task)), *io_args, thread_count=1) + logger.trace(retval) + return retval + + # LOADING # + def _load(self, *args): + """ The load thread. + + Loads from a folder of images or from a video and puts to a queue + + Parameters + ---------- + args: tuple + The arguments to be passed to the load iterator + """ + queue = args[0] + io_args = args[1:] + iterator = self._load_video if self._is_video else self._load_images + logger.debug("Load iterator: %s", iterator) + for retval in iterator(*io_args): + logger.trace("Putting to queue: %s", [v.shape if isinstance(v, np.ndarray) else v + for v in retval]) + queue.put(retval) + logger.trace("Putting EOF") + queue.put("EOF") + + def _load_video(self, *args): # pylint:disable=unused-argument + """ Generator for loading frames from a video + + Parameters + ---------- + args: tuple + Unused + + Yields + ------ + filename: str + The dummy filename of the loaded video frame. + image: numpy.ndarray + The loaded video frame. + """ + logger.debug("Loading frames from video: '%s'", self._input) + vidname = os.path.splitext(os.path.basename(self._input))[0] + reader = imageio.get_reader(self._input, "ffmpeg") + for i, frame in enumerate(reader): + # Convert to BGR for cv2 compatibility + frame = frame[:, :, ::-1] + filename = "{}_{:06d}.png".format(vidname, i + 1) + logger.trace("Loading video frame: '%s'", filename) + yield filename, frame + reader.close() + + def _load_images(self, with_hash): + """ Generator for loading images from a folder + + Parameters + ---------- + with_hash: bool + If ``True`` adds the sha1 hash to the output tuple as the final item. + + Yields + ------ + filename: str + The filename of the loaded image. + image: numpy.ndarray + The loaded image. + sha1_hash: str, optional + The sha1 hash of the loaded image. Only yielded if :class:`BackgroundIO` was + initialized with :attr:`load_with_hash` set to ``True`` and the :attr:`location` + is a folder of images. + """ + logger.debug("Loading images from folder: '%s'", self._input) + for filename in self._input: + image_read = read_image(filename, raise_error=False, with_hash=with_hash) + if with_hash: + retval = filename, *image_read + else: + retval = filename, image_read + if retval[1] is None: + logger.debug("Image not loaded: '%s'", filename) + continue + yield retval + + def load(self): + """ Generator for loading images from the given :attr:`location` + + If :class:`BackgroundIO` was initialized with :attr:`load_with_hash` set to ``True`` then + the sha1 hash of the image is added as the final item in the output `tuple`. + + Yields + ------ + filename: str + The filename of the loaded image. + image: numpy.ndarray + The loaded image. + sha1_hash: str, optional + The sha1 hash of the loaded image. Only yielded if :class:`BackgroundIO` was + initialized with :attr:`load_with_hash` set to ``True`` and the :attr:`location` + is a folder of images. + """ + while True: + self._thread.check_and_raise_error() + try: + retval = self._queue.get(True, 1) + except QueueEmpty: + continue + if retval == "EOF": + logger.trace("Got EOF") + break + logger.trace("Yielding: %s", [v.shape if isinstance(v, np.ndarray) else v + for v in retval]) + yield retval + self._thread.join() + + # SAVING # + @staticmethod + def _save(*args): + """ Saves images from the save queue to the given :attr:`location` inside a thread. + + Parameters + ---------- + args: tuple + The save arguments + """ + queue = args[0] + while True: + item = queue.get() + if item == "EOF": + logger.debug("EOF received") + break + filename, image = item + logger.trace("Saving image: '%s'", filename) + cv2.imwrite(filename, image) + + def save(self, filename, image): + """ Save the given image in the background thread + + Ensure that :func:`close` is called once all save operations are complete. + + Parameters + ---------- + filename: str + The filename of the image to be saved + image: numpy.ndarray + The image to be saved + """ + logger.trace("Putting to save queue: '%s'", filename) + self._queue.put((filename, image)) + + def close(self): + """ Closes down and joins the internal threads + + Must be called after a :func:`save` operation to ensure all items are saved before the + parent process exits. + """ + logger.debug("Received Close") + if self._task == "save": + logger.debug("Putting EOF to save queue") + self._queue.put("EOF") + self._thread.join() + logger.debug("Closed") diff --git a/tools.py b/tools.py index 0032d74ecd..fed945c080 100755 --- a/tools.py +++ b/tools.py @@ -37,6 +37,9 @@ def bad_args(args): # pylint:disable=unused-argument EFFMPEG = cli.EffmpegArgs(SUBPARSER, "effmpeg", "This command allows you to easily execute common ffmpeg tasks.") + MASK = cli.MaskArgs(SUBPARSER, + "mask", + "This command lets you generate masks for existing alignments.") RESTORE = cli.RestoreArgs(SUBPARSER, "restore", "This command lets you restore models from backup.") diff --git a/tools/cli.py b/tools/cli.py index c3331de2e8..eb80c5938d 100644 --- a/tools/cli.py +++ b/tools/cli.py @@ -6,10 +6,11 @@ from lib.cli import (ContextFullPaths, DirOrFileFullPaths, DirFullPaths, FileFullPaths, FilesFullPaths, SaveFileFullPaths, Radio, Slider) from lib.utils import _image_extensions +from plugins.plugin_loader import PluginLoader class AlignmentsArgs(FaceSwapArgs): - """ Class to parse the command line arguments for Aligments tool """ + """ Class to parse the command line arguments for Alignments tool """ @staticmethod def get_info(): @@ -430,6 +431,117 @@ def get_argument_list(self): return argument_list +class MaskArgs(FaceSwapArgs): + """ Class to parse the command line arguments for Mask tool """ + + @staticmethod + def get_info(): + """ Return command information """ + return "Mask tool\nGenerate masks for existing alignments files." + + def get_argument_list(self): + argument_list = list() + argument_list.append({ + "opts": ("-a", "--alignments"), + "action": FileFullPaths, + "type": str, + "group": "data", + "required": True, + "filetypes": "alignments", + "help": "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."}) + argument_list.append({ + "opts": ("-i", "--input"), + "action": DirOrFileFullPaths, + "type": str, + "group": "data", + "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": ("-M", "--masker"), + "action": Radio, + "type": str.lower, + "choices": PluginLoader.get_available_extractors("mask"), + "default": "extended", + "group": "process", + "help": "R|Masker to use." + "\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|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({ + "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({ + "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, 8), + "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. 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": 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"}) + + return argument_list + + class RestoreArgs(FaceSwapArgs): """ Class to restore model files from backup """ diff --git a/tools/mask.py b/tools/mask.py new file mode 100644 index 0000000000..c803c5bb69 --- /dev/null +++ b/tools/mask.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +""" Tool to generate masks and previews of masks for existing alignments file """ +import logging +import os + +import cv2 +import numpy as np +from tqdm import tqdm + +from lib.alignments import Alignments +from lib.faces_detect import DetectedFace +from lib.image import BackgroundIO + +from lib.multithreading import MultiThread +from lib.utils import set_system_verbosity, get_folder +from plugins.extract.pipeline import Extractor + + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class Mask(): + """ This tool is part of the Faceswap Tools suite and should be called from + ``python tools.py mask``. + + Faceswap Masks tool. Generate masks from existing alignments files, and output masks + for preview. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + """ + def __init__(self, arguments): + logger.debug("Initializing %s: (arguments: %s", self.__class__.__name__, arguments) + print(type(arguments)) + exit(0) + set_system_verbosity(arguments.loglevel) + self._update_type = arguments.processing + self._input_is_faces = arguments.input_type == "faces" + self._mask_type = arguments.masker + self._output_opts = dict(blur_kernel=arguments.blur_kernel, threshold=arguments.threshold) + + self._face_count = 0 + self._skip_count = 0 + self._update_count = 0 + + self._check_input(arguments.input) + self._saver = self._set_saver(arguments) + self._loader = BackgroundIO(arguments.input, + "load", + load_with_hash=self._input_is_faces, + queue_size=16) + self._alignments = Alignments(os.path.dirname(arguments.alignments), + filename=os.path.basename(arguments.alignments)) + + self._extractor = self._get_extractor() + self._extractor_input_thread = self._feed_extractor() + + logger.debug("Initialized %s", self.__class__.__name__) + + def _check_input(self, mask_input): + """ Check the input is valid. If it isn't exit with a logged error + + Parameters + ---------- + mask_input: str + Path to the input folder/video + """ + if not os.path.exists(mask_input): + logger.error("Location cannot be found: '%s'", mask_input) + exit(0) + if os.path.isfile(mask_input) and self._input_is_faces: + logger.error("Input type 'faces' was selected but input is not a folder: '%s'", + mask_input) + exit(0) + logger.debug("input '%s' is valid", mask_input) + + def _set_saver(self, arguments): + """ 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.BackgroundIO`: + If output is requested, returns a :class:`lib.image.BackgroundIO` in saver mode + 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.") + exit(0) + logger.debug("No output provided. Not creating saver") + return None + output_dir = str(get_folder(arguments.output, make_folder=True)) + logger.info("Saving preview masks to: '%s'", output_dir) + saver = BackgroundIO(output_dir, "save", queue_size=16) + logger.debug(saver) + return saver + + def _get_extractor(self): + """ Obtain a Mask extractor plugin and launch it + + 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, + image_is_aligned=self._input_is_faces) + extractor.launch() + logger.debug(extractor) + return extractor + + def _feed_extractor(self): + """ 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, + "_input_{}".format("faces" if self._input_is_faces else "frames")) + logger.debug("masker_input: %s", masker_input) + + args = tuple() if self._update_type == "output" else (self._extractor.input_queue, ) + input_thread = MultiThread(masker_input, *args, thread_count=1) + input_thread.start() + logger.debug(input_thread) + return input_thread + + def _input_faces(self, *args): + """ 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 + """ + logger.debug("args: %s", args) + if self._update_type != "output": + queue = args[0] + for filename, image, hsh in tqdm(self._loader.load(), total=self._loader.count): + if hsh not in self._alignments.hashes_to_frame: + self._skip_count += 1 + logger.warning("Skipping face not in alignments file: '%s'", filename) + continue + for frame, idx in self._alignments.hashes_to_frame[hsh].items(): + self._face_count += 1 + alignment = self._alignments.get_faces_in_frame(frame)[idx] + if self._check_for_missing(frame, idx, alignment): + continue + detected_face = self._get_detected_face(alignment) + if self._update_type == "output": + detected_face.image = image + self._save(frame, idx, detected_face) + else: + queue.put(dict(filename=filename, image=image, detected_faces=[detected_face])) + self._update_count += 1 + if self._update_type != "output": + queue.put("EOF") + + def _input_frames(self, *args): + """ 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 + """ + logger.debug("args: %s", args) + if self._update_type != "output": + queue = 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._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 + detected_faces = [] + for idx, alignment in enumerate(self._alignments.get_faces_in_frame(frame)): + self._face_count += 1 + if self._check_for_missing(frame, idx, alignment): + continue + detected_face = self._get_detected_face(alignment) + if self._update_type == "output": + detected_face.image = image + self._save(frame, idx, detected_face) + else: + detected_faces.append(detected_face) + self._update_count += 1 + if self._update_type != "output": + queue.put(dict(filename=filename, image=image, detected_faces=detected_faces)) + if self._update_type != "output": + queue.put("EOF") + + def _check_for_missing(self, frame, idx, alignment): + """ 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("Not updating existing mask for face: '%s' - %s", frame, idx) + return retval + + @staticmethod + def _get_detected_face(alignment): + """ Convert an alignment dict item to a detected_face object + + Parameters + ---------- + alignment: dict + The alignment dict for a face + + Returns + ------- + :class:`lib.FacesDetect.detected_face`: + The corresponding detected_face object for the alignment + """ + detected_face = DetectedFace() + detected_face.from_alignment(alignment) + return detected_face + + def process(self): + """ The entry point for the Mask tool from :file:`lib.tools.cli`. Runs the Mask process """ + logger.debug("Starting masker process") + updater = getattr(self, "_update_{}".format("faces" if self._input_is_faces else "frames")) + if self._update_type != "output": + for extractor_output in self._extractor.detected_faces(): + self._extractor_input_thread.check_and_raise_error() + updater(extractor_output) + self._extractor_input_thread.join() + if self._update_count != 0: + self._alignments.backup() + self._alignments.save() + else: + self._extractor_input_thread.join() + self._saver.close() + + if self._skip_count != 0: + logger.warning("%s face(s) skipped due to not existing in the alignments file", + self._skip_count) + if self._update_type != "output": + if self._update_count == 0: + logger.warning("No masks were updated of the %s faces seen", self._face_count) + else: + logger.info("Updated masks for %s faces of %s", + self._update_count, self._face_count) + logger.debug("Completed masker process") + + def _update_faces(self, extractor_output): + """ 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: dict + The output from the :class:`plugins.extract.pipeline.Extractor` object + """ + for face in extractor_output["detected_faces"]: + for frame, idx in self._alignments.hashes_to_frame[face.hash].items(): + self._alignments.update_face(frame, idx, face.to_alignment()) + if self._saver is not None: + self._save(frame, idx, face) + + def _update_frames(self, extractor_output): + """ 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: dict + The output from the :class:`plugins.extract.pipeline.Extractor` object + """ + 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: + self._save(frame, idx, face) + + def _save(self, frame, idx, detected_face): + """ Build the mask preview image and save + + Parameters + ---------- + frame: str + The frame name in the alignments file + idx: int + The index of the face for this frame in the alignments file + detected_face: `lib.FacesDetect.detected_face` + A detected_face object for a face + """ + filename = os.path.join(self._saver.location, + "{}_{}_{}_mask_preview.png".format(os.path.splitext(frame)[0], + idx, + self._mask_type)) + if detected_face.mask is None or detected_face.mask.get(self._mask_type, None) is None: + logger.warning("Mask type '%s' does not exist for frame '%s' index %s. Skipping", + self._mask_type, frame, idx) + return + image = self._create_image(detected_face) + logger.trace("filename: '%s', image_shape: %s", image.shape) + self._saver.save(filename, image) + + def _create_image(self, detected_face): + """ Create a mask preview image for saving out to disk + + Parameters + ---------- + detected_face: `lib.FacesDetect.detected_face` + A detected_face object for a face + + Returns + numpy.ndarray: + A preview image, containing 3 sub images: The original face, the masked face and + the mask. + """ + if self._input_is_faces: + face = detected_face.image + else: + detected_face.load_aligned(detected_face.image) + face = detected_face.aligned_face + size = face.shape[0] + detected_face.mask[self._mask_type].set_blur_kernel_and_threshold(**self._output_opts) + mask = cv2.resize(detected_face.mask[self._mask_type].mask, + (size, size), + interpolation=cv2.INTER_CUBIC)[..., None] + masked = (face.astype("float32") * mask.astype("float32") / 255.).astype("uint8") + mask = np.tile(mask, 3) + + for img in (face, masked, mask): + cv2.rectangle(img, (0, 0), (size - 1, size - 1), (255, 255, 255), 1) + + out_image = np.concatenate((face, masked, mask), axis=1) + return out_image From 650e00aa0aee469094e8055dd10e53bc2947de25 Mon Sep 17 00:00:00 2001 From: kvrooman Date: Fri, 25 Oct 2019 17:58:02 -0500 Subject: [PATCH 105/981] BUGFIX: recursive loading of images all sub-directories in sorter (#910) * bugfix --- tools/sort.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/sort.py b/tools/sort.py index ae77c1a0f0..e8494e2cf0 100644 --- a/tools/sort.py +++ b/tools/sort.py @@ -620,6 +620,7 @@ def find_images(input_dir): for file in files: if os.path.splitext(file)[1].lower() in extensions: result.append(os.path.join(root, file)) + break return result @staticmethod From 3d06ce99aaa95ede92d7f32eab78389f19f65b09 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 26 Oct 2019 02:08:38 +0100 Subject: [PATCH 106/981] lib.nn_blocks bugfix for upscaler --- lib/model/nn_blocks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index 2ebc352427..516e42152c 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -130,7 +130,7 @@ def upscale(self, inp, filters, kernel_size=3, padding="same", original_init = self.switch_kernel_initializer( kwargs, ICNR(initializer=kwargs["kernel_initializer"])) - var_x = self.conv2d(inp, filters * 4, + var_x = self.conv2d(inp, filters * scale_factor * scale_factor, kernel_size=kernel_size, padding=padding, name="{}_conv2d".format(name), From 9af77268971f4137bf7b50afc0536b656a358e3f Mon Sep 17 00:00:00 2001 From: Vyacheslav Linnik Date: Sun, 27 Oct 2019 00:51:04 +0300 Subject: [PATCH 107/981] Always set batchsize as int (#913) --- plugins/extract/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index ba0a7d62b8..7620bf74a3 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -460,7 +460,7 @@ def _set_extractor_batchsize(self): @staticmethod def _set_plugin_batchsize(plugin, available_vram): """ Set the batch size for the given plugin based on given available vram """ - plugin.batchsize = max(1, available_vram // plugin.vram_per_batch) + plugin.batchsize = int(max(1, available_vram // plugin.vram_per_batch)) logger.verbose("Reset batchsize for %s to %s", plugin.name, plugin.batchsize) def _join_threads(self): From 0e0b2faa1a205fe276a7c53bc600fca60b239234 Mon Sep 17 00:00:00 2001 From: kvrooman Date: Mon, 28 Oct 2019 05:00:04 -0500 Subject: [PATCH 108/981] ENHANCEMENT: Sort image multi-threading and code clarity (#912) * Use multi-threaded loading * fast reload * remove landmark stub * saves * correct face sort * pep8 --- tools/sort.py | 258 ++++++++++++++++++++------------------------------ 1 file changed, 104 insertions(+), 154 deletions(-) diff --git a/tools/sort.py b/tools/sort.py index e8494e2cf0..c977d66933 100644 --- a/tools/sort.py +++ b/tools/sort.py @@ -11,6 +11,7 @@ import numpy as np import cv2 from tqdm import tqdm +from concurrent import futures # faceswap imports from lib.cli import FullHelpArgumentParser @@ -88,28 +89,46 @@ def launch_aligner(): out_queue=queue_manager.get_queue("out"), queue_size=8) aligner = PluginLoader.get_aligner("fan")(normalize_method="hist") - aligner.batchsize = 1 + aligner.batchsize = 1 # TODO Put batches at a time or load from alignment file aligner.initialize(**kwargs) aligner.start() @staticmethod - def alignment_dict(image): + def alignment_dict(filename, image): """ Set the image to a dict for alignment """ height, width = image.shape[:2] face = DetectedFace(x=0, w=width, y=0, h=height) return {"image": image, + "filename": filename, "detected_faces": [face]} - @staticmethod - def get_landmarks(filename): - """ Extract the face from a frame (If not alignments file found) """ - image = read_image(filename, raise_error=True) - feed = Sort.alignment_dict(image) - feed["filename"] = filename - queue_manager.get_queue("in").put(feed) - face = queue_manager.get_queue("out").get() - landmarks = face["detected_faces"][0].landmarks_xy - return landmarks + def _get_landmarks(self): + """ Multi-threaded, parallel and sequentially ordered landmark loader """ + self.launch_aligner() + filename_list, image_list = self._get_images() + feed_list = list(map(Sort.alignment_dict, filename_list, image_list)) + landmarks = np.zeros((len(feed_list), 68, 2), dtype='float32') + + logger.info("Finding landmarks in images...") + for feed in tqdm(feed_list, desc="Putting...", file=sys.stdout): + queue_manager.get_queue("in").put(feed) + for index, _ in enumerate(tqdm(landmarks, desc="Aligning...", file=sys.stdout)): + face = queue_manager.get_queue("out").get() + landmarks[index] = np.array(face["detected_faces"][0].landmarks_xy) + + return filename_list, image_list, landmarks + + def _get_images(self): + """ Multi-threaded, parallel and sequentially ordered image loader """ + logger.info("Loading images...") + filename_list = self.find_images(self.args.input_dir) + with futures.ThreadPoolExecutor() as executor: + image_list = list(tqdm(executor.map(read_image, filename_list), + desc="Loading Images...", + file=sys.stdout, + total=len(filename_list))) + + return filename_list, image_list def sort_process(self): """ @@ -137,180 +156,134 @@ def sort_process(self): # Methods for sorting def sort_blur(self): """ Sort by blur amount """ - input_dir = self.args.input_dir - - logger.info("Sorting by blur...") - img_list = [[img, self.estimate_blur(img)] - for img in - tqdm(self.find_images(input_dir), - desc="Loading", - file=sys.stdout)] - logger.info("Sorting...") + logger.info("Sorting by estimated image blur...") + filename_list, image_list = self._get_images() - img_list = sorted(img_list, key=operator.itemgetter(1), reverse=True) + logger.info("Estimating blur...") + blurs = [self.estimate_blur(img) for img in image_list] + logger.info("Sorting...") + matched_list = list(zip(filename_list, blurs)) + img_list = sorted(matched_list, key=operator.itemgetter(1), reverse=True) return img_list def sort_face(self): - """ Sort by face similarity """ - input_dir = self.args.input_dir + """ Sort by identity similarity """ + logger.info("Sorting by identity similarity...") + filename_list, image_list = self._get_images() - logger.info("Sorting by face similarity...") + logger.info("Calculating face identifiers...") + preds = np.array([self.vgg_face.predict(img) + for img in tqdm(image_list, desc="Calculating...", file=sys.stdout)]) - images = np.array(self.find_images(input_dir)) - preds = np.array([self.vgg_face.predict(read_image(img, raise_error=True)) - for img in tqdm(images, desc="loading", file=sys.stdout)]) - logger.info("Sorting. Depending on ths size of your dataset, this may take a few " - "minutes...") + logger.info("Sorting by ward linkage...") indices = self.vgg_face.sorted_similarity(preds, method="ward") - img_list = images[indices] + img_list = np.array(filename_list)[indices] return img_list def sort_face_cnn(self): - """ Sort by CNN similarity """ - self.launch_aligner() - input_dir = self.args.input_dir + """ Sort by landmark similarity """ + logger.info("Sorting by landmark similarity...") + filename_list, image_list, landmarks = self._get_landmarks() + img_list = list(zip(filename_list, landmarks)) - logger.info("Sorting by face-cnn similarity...") - img_list = [] - for img in tqdm(self.find_images(input_dir), - desc="Loading", - file=sys.stdout): - landmarks = self.get_landmarks(img) - img_list.append([img, np.array(landmarks) - if landmarks - else np.zeros((68, 2))]) - - queue_manager.terminate_queues() + logger.info("Comparing landmarks and sorting...") img_list_len = len(img_list) - for i in tqdm(range(0, img_list_len - 1), - desc="Sorting", - file=sys.stdout): + for i in tqdm(range(0, img_list_len - 1), desc="Comparing...", file=sys.stdout): min_score = float("inf") j_min_score = i + 1 - for j in range(i + 1, len(img_list)): + for j in range(i + 1, img_list_len): fl1 = img_list[i][1] fl2 = img_list[j][1] score = np.sum(np.absolute((fl2 - fl1).flatten())) - if score < min_score: min_score = score j_min_score = j - (img_list[i + 1], - img_list[j_min_score]) = (img_list[j_min_score], - img_list[i + 1]) + (img_list[i + 1], img_list[j_min_score]) = (img_list[j_min_score], img_list[i + 1]) return img_list def sort_face_cnn_dissim(self): - """ Sort by CNN dissimilarity """ - self.launch_aligner() - input_dir = self.args.input_dir - - logger.info("Sorting by face-cnn dissimilarity...") - - img_list = [] - for img in tqdm(self.find_images(input_dir), - desc="Loading", - file=sys.stdout): - landmarks = self.get_landmarks(img) - img_list.append([img, np.array(landmarks) - if landmarks - else np.zeros((68, 2)), 0]) + """ Sort by landmark dissimilarity """ + logger.info("Sorting by landmark dissimilarity...") + filename_list, image_list, landmarks = self._get_landmarks() + scores = np.zeros(len(filename_list), dtype='float32') + img_list = list(list(items) for items in zip(filename_list, landmarks, scores)) + logger.info("Comparing landmarks...") img_list_len = len(img_list) - for i in tqdm(range(0, img_list_len - 1), - desc="Sorting", - file=sys.stdout): + for i in tqdm(range(0, img_list_len - 1), desc="Comparing...", file=sys.stdout): score_total = 0 - for j in range(i + 1, len(img_list)): + for j in range(i + 1, img_list_len): if i == j: continue fl1 = img_list[i][1] fl2 = img_list[j][1] score_total += np.sum(np.absolute((fl2 - fl1).flatten())) - img_list[i][2] = score_total logger.info("Sorting...") img_list = sorted(img_list, key=operator.itemgetter(2), reverse=True) - return img_list def sort_face_yaw(self): - """ Sort by yaw of face """ - self.launch_aligner() - input_dir = self.args.input_dir + """ Sort by estimated face yaw angle """ + logger.info("Sorting by estimated face yaw angle..") + filename_list, image_list, landmarks = self._get_landmarks() - img_list = [] - for img in tqdm(self.find_images(input_dir), - desc="Loading", - file=sys.stdout): - landmarks = self.get_landmarks(img) - img_list.append( - [img, self.calc_landmarks_face_yaw(np.array(landmarks))]) - - logger.info("Sorting by face-yaw...") - img_list = sorted(img_list, key=operator.itemgetter(1), reverse=True) + logger.info("Estimating yaw...") + yaws = [self.calc_landmarks_face_yaw(mark) for mark in landmarks] + logger.info("Sorting...") + matched_list = list(zip(filename_list, yaws)) + img_list = sorted(matched_list, key=operator.itemgetter(1), reverse=True) return img_list def sort_hist(self): - """ Sort by histogram of face similarity """ - input_dir = self.args.input_dir - + """ Sort by image histogram similarity """ logger.info("Sorting by histogram similarity...") + filename_list, image_list = self._get_images() + distance = cv2.HISTCMP_BHATTACHARYYA - img_list = [ - [img, cv2.calcHist([read_image(img, raise_error=True)], [0], None, [256], [0, 256])] - for img in - tqdm(self.find_images(input_dir), desc="Loading", file=sys.stdout) - ] + logger.info("Calculating histograms...") + histograms = [cv2.calcHist([img], [0], None, [256], [0, 256]) for img in image_list] + img_list = list(zip(filename_list, histograms)) + logger.info("Comparing histograms and sorting...") img_list_len = len(img_list) - for i in tqdm(range(0, img_list_len - 1), desc="Sorting", - file=sys.stdout): + for i in tqdm(range(0, img_list_len - 1), desc="Comparing", file=sys.stdout): min_score = float("inf") j_min_score = i + 1 - for j in range(i + 1, len(img_list)): - score = cv2.compareHist(img_list[i][1], - img_list[j][1], - cv2.HISTCMP_BHATTACHARYYA) + for j in range(i + 1, img_list_len): + score = cv2.compareHist(img_list[i][1], img_list[j][1], distance) if score < min_score: min_score = score j_min_score = j - (img_list[i + 1], - img_list[j_min_score]) = (img_list[j_min_score], - img_list[i + 1]) + (img_list[i + 1], img_list[j_min_score]) = (img_list[j_min_score], img_list[i + 1]) return img_list def sort_hist_dissim(self): - """ Sort by histigram of face dissimilarity """ - input_dir = self.args.input_dir - + """ Sort by image histogram dissimilarity """ logger.info("Sorting by histogram dissimilarity...") + filename_list, image_list = self._get_images() + scores = np.zeros(len(filename_list), dtype='float32') + distance = cv2.HISTCMP_BHATTACHARYYA - img_list = [ - [img, - cv2.calcHist([read_image(img, raise_error=True)], [0], None, [256], [0, 256]), 0] - for img in - tqdm(self.find_images(input_dir), desc="Loading", file=sys.stdout) - ] + logger.info("Calculating histograms...") + histograms = [cv2.calcHist([img], [0], None, [256], [0, 256]) for img in image_list] + img_list = list(list(items) for items in zip(filename_list, histograms, scores)) + logger.info("Comparing histograms...") img_list_len = len(img_list) - for i in tqdm(range(0, img_list_len), desc="Sorting", file=sys.stdout): + for i in tqdm(range(0, img_list_len), desc="Comparing", file=sys.stdout): score_total = 0 for j in range(0, img_list_len): if i == j: continue - score_total += cv2.compareHist(img_list[i][1], - img_list[j][1], - cv2.HISTCMP_BHATTACHARYYA) - + score_total += cv2.compareHist(img_list[i][1], img_list[j][1], distance) img_list[i][2] = score_total logger.info("Sorting...") img_list = sorted(img_list, key=operator.itemgetter(2), reverse=True) - return img_list # Methods for grouping @@ -544,40 +517,20 @@ def reload_images(self, group_method, img_list): input_dir = self.args.input_dir logger.info("Preparing to group...") if group_method == 'group_blur': - temp_list = [[img, self.estimate_blur(read_image(img, raise_error=True))] - for img in - tqdm(self.find_images(input_dir), - desc="Reloading", - file=sys.stdout)] + filename_list, image_list = self._get_images() + blurs = [self.estimate_blur(img) for img in image_list] + temp_list = list(zip(filename_list, blurs)) elif group_method == 'group_face_cnn': - self.launch_aligner() - temp_list = [] - for img in tqdm(self.find_images(input_dir), - desc="Reloading", - file=sys.stdout): - landmarks = self.get_landmarks(img) - temp_list.append([img, np.array(landmarks) - if landmarks - else np.zeros((68, 2))]) + filename_list, image_list, landmarks = self._get_landmarks() + temp_list = list(zip(filename_list, landmarks)) elif group_method == 'group_face_yaw': - self.launch_aligner() - temp_list = [] - for img in tqdm(self.find_images(input_dir), - desc="Reloading", - file=sys.stdout): - landmarks = self.get_landmarks(img) - temp_list.append( - [img, - self.calc_landmarks_face_yaw(np.array(landmarks))]) + filename_list, image_list, landmarks = self._get_landmarks() + yaws = [self.calc_landmarks_face_yaw(mark) for mark in landmarks] + temp_list = list(zip(filename_list, yaws)) elif group_method == 'group_hist': - temp_list = [ - [img, - cv2.calcHist([read_image(img, raise_error=True)], [0], None, [256], [0, 256])] - for img in - tqdm(self.find_images(input_dir), - desc="Reloading", - file=sys.stdout) - ] + filename_list, image_list = self._get_images() + histograms = [cv2.calcHist([img], [0], None, [256], [0, 256]) for img in image_list] + temp_list = list(zip(filename_list, histograms)) else: raise ValueError("{} group_method not found.".format(group_method)) @@ -602,9 +555,7 @@ def splice_lists(sorted_list, new_vals_list): new_list = [] # Make new list of just image paths to serve as an index val_index_list = [i[0] for i in new_vals_list] - for i in tqdm(range(len(sorted_list)), - desc="Splicing", - file=sys.stdout): + for i in tqdm(range(len(sorted_list)), desc="Splicing", file=sys.stdout): current_img = sorted_list[i] if isinstance(sorted_list[i], str) else sorted_list[i][0] new_val_index = val_index_list.index(current_img) new_list.append([current_img, new_vals_list[new_val_index][1]]) @@ -624,12 +575,11 @@ def find_images(input_dir): return result @staticmethod - def estimate_blur(image_file): + def estimate_blur(image): """ Estimate the amount of blur an image has with the variance of the Laplacian. Normalize by pixel number to offset the effect of image size on pixel gradients & variance """ - image = read_image(image_file, raise_error=True) if image.ndim == 3: image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blur_map = cv2.Laplacian(image, cv2.CV_32F) From 3f29b72a933ac4fbecd5ad0e4a40cb2ce0327d4d Mon Sep 17 00:00:00 2001 From: Artem Ivanov <37909402+andenixa@users.noreply.github.com> Date: Mon, 28 Oct 2019 13:03:54 +0300 Subject: [PATCH 109/981] DeLight model rc3 (#908) * DeLight model rc3 * lint wishes fulfilled --- plugins/train/model/dlight.py | 319 +++++++++++++++++++++++++ plugins/train/model/dlight_defaults.py | 80 +++++++ 2 files changed, 399 insertions(+) create mode 100644 plugins/train/model/dlight.py create mode 100644 plugins/train/model/dlight_defaults.py diff --git a/plugins/train/model/dlight.py b/plugins/train/model/dlight.py new file mode 100644 index 0000000000..41e261ab8c --- /dev/null +++ b/plugins/train/model/dlight.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +""" A lightweight variant of DFaker Model + By AnDenix, 2018-2019 + Based on the dfaker model: https://github.com/dfaker + + Acknowledgements: + kvrooman for numrious insights and invaluable aid + DeepHomage for lots of testing + """ + +import sys +import types + +from keras.initializers import RandomNormal +from keras.layers import Add, Dense, Flatten, Input, Reshape, AveragePooling2D, LeakyReLU +from keras.layers.convolutional import UpSampling2D, Conv2DTranspose +from keras.layers.core import Dropout +from keras.layers.merge import Concatenate +from keras.layers.normalization import BatchNormalization +from keras.models import Model as KerasModel + +from lib.utils import FaceswapError + +from ._base import logger +from .original import Model as OriginalModel + + +# [P] TODO Move upscale2x_hyb to nnblocks.py (after testing) +# <<< DeLight Model Blocks >>> # +def upscale2x_hyb(self, inp, filters, kernel_size=3, padding='same', + sr_ratio=0.5, scale_factor=2, interpolation='bilinear', + res_block_follows=False, **kwargs): + """Hybrid Upscale Layer""" + name = self.get_name("upscale2x_hyb") + var_x = inp + + sr_filters = int(filters * sr_ratio) + upscale_filters = filters - sr_filters + + var_x_sr = self.upscale(var_x, upscale_filters, kernel_size=kernel_size, + padding=padding, scale_factor=scale_factor, + res_block_follows=res_block_follows, **kwargs) + if upscale_filters > 0: + var_x_us = self.conv2d(var_x, upscale_filters, kernel_size=3, padding=padding, + name="{}_conv2d".format(name), **kwargs) + var_x_us = UpSampling2D(size=(scale_factor, scale_factor), interpolation=interpolation, + name="{}_upsampling2D".format(name))(var_x_us) + var_x = Concatenate(name="{}_concatenate".format(name))([var_x_sr, var_x_us]) + else: + var_x = var_x_sr + + return var_x + + +def upscale2x_fast(self, inp, filters, kernel_size=3, padding='same', + sr_ratio=0.5, scale_factor=2, interpolation='bilinear', + res_block_follows=False, **kwargs): + """Fast Upscale Layer""" + name = self.get_name("upscale2x_fast") + var_x = inp + + var_x2 = self.conv2d(var_x, filters, kernel_size=3, padding=padding, + name="{}_conv2d".format(name), **kwargs) + var_x2 = UpSampling2D(size=(scale_factor, scale_factor), interpolation=interpolation, + name="{}_upsampling2D".format(name))(var_x2) + + var_x1 = self.upscale(var_x, filters, kernel_size=kernel_size, + padding=padding, scale_factor=scale_factor, + res_block_follows=res_block_follows, **kwargs) + var_x = Add()([var_x2, var_x1]) + return var_x + + +class Model(OriginalModel): + """ DeLight Autoencoder Model """ + + def __init__(self, *args, **kwargs): + logger.debug("Initializing %s: (args: %s, kwargs: %s", + self.__class__.__name__, args, kwargs) + + kwargs["input_shape"] = (128, 128, 3) + kwargs["encoder_dim"] = -1 + self.dense_output = None + self.detail_level = None + super().__init__(*args, **kwargs) + + logger.debug("Initialized %s", self.__class__.__name__) + + def _detail_level_setup(self): + logger.debug('self.config[output_size]: %d', self.config["output_size"]) + + self.features = { + 'lowmem': 0, + 'fair': 1, + 'best': 2, + }[self.config["features"]] + logger.debug('self.features: %d', self.features) + + self.encoder_filters = 64 if self.features > 0 else 48 + logger.debug('self.encoder_filters: %d', self.encoder_filters) + bonum_fortunam = 128 + self.encoder_dim = { + 0: 512 + bonum_fortunam, + 1: 1024 + bonum_fortunam, + 2: 1536 + bonum_fortunam, + }[self.features] + logger.debug('self.encoder_dim: %d', self.encoder_dim) + + self.details = { + 'fast': 0, + 'good': 1, + }[self.config["details"]] + logger.debug('self.details: %d', self.details) + + try: + self.upscale_ratio = { + 128: 2, + 256: 4, + 384: 6 + }[self.config["output_size"]] + except KeyError: + logger.error("Config error: output_size must be one of: 128, 256, or 384.") + raise FaceswapError("Config error: output_size must be one of: 128, 256, or 384.") + logger.debug('output_size: %r', self.config["output_size"]) + logger.debug('self.upscale_ratio: %r', self.upscale_ratio) + + def build(self): + self._detail_level_setup() + # monkey patch-in nn_blocks + self.blocks.upscale2x_hyb = types.MethodType(upscale2x_hyb, self.blocks) + self.blocks.upscale2x_fast = types.MethodType(upscale2x_fast, self.blocks) + super().build() + + def add_networks(self): + """ Add the DeLight model weights """ + logger.debug("Adding networks") + self.add_network("decoder", "a", self.decoder_a(), is_output=True) + self.add_network("decoder", "b", + self.decoder_b() if self.details > 0 else self.decoder_b_fast(), + is_output=True) + self.add_network("encoder", None, self.encoder()) + logger.debug("Added networks") + + def compile_predictors(self, **kwargs): + self.set_networks_trainable() + super().compile_predictors(**kwargs) + + def set_networks_trainable(self): + train_encoder = True + train_decoder_a = True + train_decoder_b = True + + encoder = self.networks['encoder'].network + for layer in encoder.layers: + layer.trainable = train_encoder + + decoder_a = self.networks['decoder_a'].network + for layer in decoder_a.layers: + layer.trainable = train_decoder_a + + decoder_b = self.networks['decoder_b'].network + for layer in decoder_b.layers: + layer.trainable = train_decoder_b + + def encoder(self): + """ DeLight Encoder Network """ + input_ = Input(shape=self.input_shape) + var_x = input_ + + var_x1 = self.blocks.conv(var_x, self.encoder_filters // 2) + var_x2 = AveragePooling2D()(var_x) + var_x2 = LeakyReLU(0.1)(var_x2) + var_x = Concatenate()([var_x1, var_x2]) + + var_x1 = self.blocks.conv(var_x, self.encoder_filters) + var_x2 = AveragePooling2D()(var_x) + var_x2 = LeakyReLU(0.1)(var_x2) + var_x = Concatenate()([var_x1, var_x2]) + + var_x1 = self.blocks.conv(var_x, self.encoder_filters * 2) + var_x2 = AveragePooling2D()(var_x) + var_x2 = LeakyReLU(0.1)(var_x2) + var_x = Concatenate()([var_x1, var_x2]) + + var_x1 = self.blocks.conv(var_x, self.encoder_filters * 4) + var_x2 = AveragePooling2D()(var_x) + var_x2 = LeakyReLU(0.1)(var_x2) + var_x = Concatenate()([var_x1, var_x2]) + + var_x1 = self.blocks.conv(var_x, self.encoder_filters * 8) + var_x2 = AveragePooling2D()(var_x) + var_x2 = LeakyReLU(0.1)(var_x2) + var_x = Concatenate()([var_x1, var_x2]) + + var_x = Dense(self.encoder_dim)(Flatten()(var_x)) + var_x = Dropout(0.05)(var_x) + var_x = Dense(4 * 4 * 1024)(var_x) + var_x = Dropout(0.05)(var_x) + var_x = Reshape((4, 4, 1024))(var_x) + + return KerasModel(input_, var_x) + + def decoder_a(self): + """ DeLight Decoder A(old face) Network """ + input_ = Input(shape=(4, 4, 1024)) + decoder_a_complexity = 256 + mask_complexity = 128 + + var_xy = input_ + var_xy = UpSampling2D(self.upscale_ratio, interpolation='bilinear')(var_xy) + + var_x = var_xy + var_x = self.blocks.upscale2x_hyb(var_x, decoder_a_complexity) + var_x = self.blocks.upscale2x_hyb(var_x, decoder_a_complexity // 2) + var_x = self.blocks.upscale2x_hyb(var_x, decoder_a_complexity // 4) + var_x = self.blocks.upscale2x_hyb(var_x, decoder_a_complexity // 8) + + var_x = self.blocks.conv2d(var_x, 3, kernel_size=5, padding="same", + activation="sigmoid", name="face_out") + + outputs = [var_x] + + if self.config.get("mask_type", False): + var_y = var_xy # mask decoder + var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity) + var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 2) + var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 4) + var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 8) + + var_y = self.blocks.conv2d(var_y, 1, kernel_size=5, padding="same", + activation="sigmoid", name="mask_out") + + outputs.append(var_y) + + return KerasModel([input_], outputs=outputs) + + def decoder_b_fast(self): + """ DeLight Fast Decoder B(new face) Network """ + input_ = Input(shape=(4, 4, 1024)) + + decoder_b_complexity = 512 + mask_complexity = 128 + + var_xy = input_ + + var_xy = self.blocks.upscale(var_xy, 512, scale_factor=self.upscale_ratio) + var_x = var_xy + + var_x = self.blocks.upscale2x_fast(var_x, decoder_b_complexity) + var_x = self.blocks.upscale2x_fast(var_x, decoder_b_complexity // 2) + var_x = self.blocks.upscale2x_fast(var_x, decoder_b_complexity // 4) + var_x = self.blocks.upscale2x_fast(var_x, decoder_b_complexity // 8) + + var_x = self.blocks.conv2d(var_x, 3, kernel_size=5, padding="same", + activation="sigmoid", name="face_out") + + outputs = [var_x] + + if self.config.get("mask_type", False): + var_y = var_xy # mask decoder + + var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity) + var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 2) + var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 4) + var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 8) + + var_y = self.blocks.conv2d(var_y, 1, kernel_size=5, padding="same", + activation="sigmoid", name="mask_out") + + outputs.append(var_y) + + return KerasModel([input_], outputs=outputs) + + def decoder_b(self): + """ DeLight Decoder B(new face) Network """ + input_ = Input(shape=(4, 4, 1024)) + + decoder_b_complexity = 512 + mask_complexity = 128 + + var_xy = input_ + + var_xy = self.blocks.upscale2x_hyb(var_xy, 512, scale_factor=self.upscale_ratio) + + var_x = var_xy + + var_x = self.blocks.res_block(var_x, 512, use_bias=True) + var_x = self.blocks.res_block(var_x, 512, use_bias=False) + var_x = self.blocks.res_block(var_x, 512, use_bias=False) + var_x = self.blocks.upscale2x_hyb(var_x, decoder_b_complexity) + var_x = self.blocks.res_block(var_x, decoder_b_complexity, use_bias=True) + var_x = self.blocks.res_block(var_x, decoder_b_complexity, use_bias=False) + var_x = BatchNormalization()(var_x) + var_x = self.blocks.upscale2x_hyb(var_x, decoder_b_complexity // 2) + var_x = self.blocks.res_block(var_x, decoder_b_complexity // 2, use_bias=True) + var_x = self.blocks.upscale2x_hyb(var_x, decoder_b_complexity // 4) + var_x = self.blocks.res_block(var_x, decoder_b_complexity // 4, use_bias=False) + var_x = BatchNormalization()(var_x) + var_x = self.blocks.upscale2x_hyb(var_x, decoder_b_complexity // 8) + + var_x = self.blocks.conv2d(var_x, 3, kernel_size=5, padding="same", + activation="sigmoid", name="face_out") + + outputs = [var_x] + + if self.config.get("mask_type", False): + var_y = var_xy # mask decoder + + var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity) + var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 2) + var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 4) + var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 8) + + var_y = self.blocks.conv2d(var_y, 1, kernel_size=5, padding="same", + activation="sigmoid", name="mask_out") + + outputs.append(var_y) + + return KerasModel([input_], outputs=outputs) diff --git a/plugins/train/model/dlight_defaults.py b/plugins/train/model/dlight_defaults.py new file mode 100644 index 0000000000..ef7514f3bb --- /dev/null +++ b/plugins/train/model/dlight_defaults.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" + The default options for the faceswap Dfaker Model plugin. + + Defaults files should be named _defaults.py + Any items placed into this file will automatically get added to the relevant config .ini files + within the faceswap/config folder. + + The following variables should be defined: + _HELPTEXT: A string describing what this plugin does + _DEFAULTS: A dictionary containing the options, defaults and meta information. The + dictionary should be defined as: + {: {}} + + should always be lower text. + dictionary requirements are listed below. + + The following keys are expected for the _DEFAULTS dict: + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid datatypes are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and datatypes this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and datatypes this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. +""" + + +_HELPTEXT = ("A lightweight, high resolution Dfaker variant " + "(Adapted from https://github.com/dfaker/df)") + + +_DEFAULTS = { + "features": { + "default": "best", + "info": "Higher settings will allow learning more features such as tatoos, piercing," + "\nand wrinkles." + "\nStrongly affects VRAM usage.", + "datatype": str, + "choices": ["lowmem", "fair", "best"], + "gui_radio": True, + "fixed": True, + }, + "details": { + "default": "good", + "info": "Defines detail fidelity. Lower setting can appear 'rugged' while 'good' " + "might take onger time to train." + "\nAffects VRAM usage.", + "datatype": str, + "choices": ["fast", "good"], + "gui_radio": True, + "fixed": True, + }, + "output_size": { + "default": 256, + "info": "Output image resolution (in pixels).\nBe aware that larger resolution will " + "increase VRAM requirements.\nNB: Must be either 128, 256, or 384.", + "datatype": int, + "rounding": 128, + "min_max": (128, 384), + "choices": [], + "gui_radio": False, + "fixed": True, + }, +} From 3b2eaa591feef26c32b5e9fad7066218f3066805 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 28 Oct 2019 10:47:25 +0000 Subject: [PATCH 110/981] Remove debug code --- tools/mask.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tools/mask.py b/tools/mask.py index c803c5bb69..c77e272a64 100644 --- a/tools/mask.py +++ b/tools/mask.py @@ -33,8 +33,6 @@ class Mask(): """ def __init__(self, arguments): logger.debug("Initializing %s: (arguments: %s", self.__class__.__name__, arguments) - print(type(arguments)) - exit(0) set_system_verbosity(arguments.loglevel) self._update_type = arguments.processing self._input_is_faces = arguments.input_type == "faces" From ef49d121e16901810729600e76ebb201cee4e7ab Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 28 Oct 2019 11:56:38 +0000 Subject: [PATCH 111/981] Add masker to Travis Tests Bugfix: Parallel VRAM calculation for non-gpu extractors --- _travis/simple_tests.py | 10 +++++----- plugins/extract/pipeline.py | 4 +++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/_travis/simple_tests.py b/_travis/simple_tests.py index 00dd9206d1..3ea157e0a5 100644 --- a/_travis/simple_tests.py +++ b/_travis/simple_tests.py @@ -81,11 +81,11 @@ def download_file(url, filename): # TODO: retry return None -def extract_args(detector, aligner, in_path, out_path, args=None): +def extract_args(detector, aligner, masker, in_path, out_path, args=None): """ Extraction command """ py_exe = sys.executable - _extract_args = "%s faceswap.py extract -i %s -o %s -D %s -A %s" % ( - py_exe, in_path, out_path, detector, aligner + _extract_args = "%s faceswap.py extract -i %s -o %s -D %s -A %s -M %s" % ( + py_exe, in_path, out_path, detector, aligner, masker ) if args: _extract_args += " %s" % args @@ -141,7 +141,7 @@ def main(): exit(1) vid_extract = run_test( "Extraction video with cv2-dnn detector and cv2-dnn aligner.", - extract_args("Cv2-Dnn", "Cv2-Dnn", vid_path, pathjoin(vid_base, "faces")) + extract_args("Cv2-Dnn", "Cv2-Dnn", "extended", vid_path, pathjoin(vid_base, "faces")) ) img_path = download_file(img_src, pathjoin(img_base, "test_img.jpg")) @@ -150,7 +150,7 @@ def main(): exit(1) run_test( "Extraction images with cv2-dnn detector and cv2-dnn aligner.", - extract_args("Cv2-Dnn", "Cv2-Dnn", img_base, pathjoin(img_base, "faces")) + extract_args("Cv2-Dnn", "Cv2-Dnn", "extended", img_base, pathjoin(img_base, "faces")) ) if vid_extract: diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 7620bf74a3..cf4777aa99 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -247,7 +247,9 @@ def _parallel_scaling(self): If OOM errors are being reported, then these ratios should be relaxed some more """ - retval = {2: 0.7, + retval = {0: 1.0, + 1: 1.0, + 2: 0.7, 3: 0.55, 4: 0.5, 5: 0.4} From fb77f033c3be86a8eae9a61bcc09dcac959aa161 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 28 Oct 2019 17:08:32 +0000 Subject: [PATCH 112/981] Bugfix: Manual Tool - Strip mask from adjusted faces --- tools/lib_alignments/jobs_manual.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/lib_alignments/jobs_manual.py b/tools/lib_alignments/jobs_manual.py index fe56e4d7d2..2f933c0140 100644 --- a/tools/lib_alignments/jobs_manual.py +++ b/tools/lib_alignments/jobs_manual.py @@ -781,10 +781,10 @@ def __init__(self, interface, loglevel): def init_extractor(self): """ Initialize Aligner """ logger.debug("Initialize Extractor") - extractor = Extractor("manual", "fan", "none", multiprocess=True, normalize_method="hist") + extractor = Extractor("manual", "fan", None, multiprocess=True, normalize_method="hist") self.queues["in"] = extractor.input_queue # Set the batchsizes to 1 - for plugin_type in ("detect", "align", "mask"): + for plugin_type in ("detect", "align"): extractor.set_batchsize(plugin_type, 1) extractor.launch() logger.debug("Initialized Extractor") @@ -923,6 +923,8 @@ def update_landmarks(self): "manual_face": self.media["bounding_box"]}) detected_face = next(self.extractor.detected_faces())["detected_faces"][0] alignment = detected_face.to_alignment() + # Mask will now be incorrect for updated landmarks so delete + alignment["mask"] = dict() frame = self.media["frame_id"] From 8a721c7e7caa39243f91dcce0972a1dd1796b169 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 29 Oct 2019 11:38:29 +0000 Subject: [PATCH 113/981] masks: Ensure blur kernel is odd --- lib/faces_detect.py | 8 ++++++-- tools/cli.py | 8 +++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/faces_detect.py b/lib/faces_detect.py index 82441896d7..65124828bb 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -595,13 +595,17 @@ def set_blur_kernel_and_threshold(self, blur_kernel=0, threshold=0): ---------- blur_kernel: int, optional The kernel size, in pixels to apply gaussian blurring to the mask. Set to 0 for no - blurring. Default: 0 + blurring. Should be odd, if an even number is passed in (outside of 0) then it is + rounded up to the next odd number. Default: 0 threshold: int, optional The threshold amount to minimize/maximize mask values to 0 and 100. Percentage value. Default: 0 """ logger.trace("blur_kernel: %s, threshold: %s", blur_kernel, threshold) - self._blur_kernel = blur_kernel + if blur_kernel == 0 or blur_kernel % 2 == 1: + self._blur_kernel = blur_kernel + else: + self._blur_kernel = blur_kernel + 1 self._threshold = (threshold / 100.0) * 255.0 def _adjust_affine_matrix(self, mask_size, affine_matrix): diff --git a/tools/cli.py b/tools/cli.py index eb80c5938d..e8fdf10090 100644 --- a/tools/cli.py +++ b/tools/cli.py @@ -521,12 +521,14 @@ def get_argument_list(self): "action": Slider, "type": int, "group": "output", - "min_max": (0, 8), + "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. NB: " - "Only effects the output preview. Set to 0 for off"}) + "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, From cc576bc9caab2004b7cf5f9895c9f144a935b0aa Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 29 Oct 2019 22:27:25 +0000 Subject: [PATCH 114/981] Bugfix: lib.alignments - Remove buggy file extension check --- lib/alignments.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/alignments.py b/lib/alignments.py index e50dfa5ae6..bb71083fc7 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -78,12 +78,10 @@ def get_location(self, folder, filename): # Reformat legacy alignments file filename = self.update_file_format(folder, filename) logger.debug("Updated legacy alignments. New filename: '%s'", filename) - elif not extension: + else: filename = "{}.{}".format(filename, self.serializer.file_extension) logger.debug("File extension set from serializer: '%s'", self.serializer.file_extension) - elif extension != ".fsa": - raise FaceswapError("{} is not a valid alignments file".format(filename)) location = os.path.join(str(folder), filename) if not os.path.exists(location): # Test for old format alignments files and reformat if they exist From 68109fcc80b02c45cd069c2d220b8b9e0705ac3d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 29 Oct 2019 22:41:01 +0000 Subject: [PATCH 115/981] bugfix: plugins.extract.pipeline - Exclude CPU plugins from vram calculations --- lib/alignments.py | 6 +++--- plugins/extract/pipeline.py | 20 ++++++++++++++------ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/lib/alignments.py b/lib/alignments.py index bb71083fc7..675233280a 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -206,10 +206,10 @@ def update_face(self, frame, idx, alignment): self.data[frame][idx] = alignment def filter_hashes(self, hashlist, filter_out=False): - """ Filter in or out faces that match the hashlist + """ Filter in or out faces that match the hash list - filter_out=True: Remove faces that match in the hashlist - filter_out=False: Remove faces that are not in the hashlist + filter_out=True: Remove faces that match in the hash list + filter_out=False: Remove faces that are not in the hash list """ hashset = set(hashlist) for filename, frame in self.data.items(): diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index cf4777aa99..5ba84de7cd 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -443,12 +443,16 @@ def _set_extractor_batchsize(self): logger.debug("Plugin requirements within threshold: (plugin_required: %sMB, " "vram_free: %sMB)", plugin_required, vram_free) return - # Hacky split across 3 plugins - available_vram = (vram_free - self._total_vram_required) // 3 + # Hacky split across plugins that use vram + gpu_plugin_count = sum([1 for plugin in self._all_plugins if plugin.vram != 0]) + available_vram = (vram_free - self._total_vram_required) // gpu_plugin_count for plugin in self._all_plugins: - self._set_plugin_batchsize(plugin, available_vram) + if plugin.vram != 0: + self._set_plugin_batchsize(plugin, available_vram) else: for plugin in self._all_plugins: + if plugin.vram == 0: + continue vram_required = plugin.vram + self._vram_buffer batch_required = plugin.vram_per_batch * plugin.batchsize plugin_required = vram_required + batch_required @@ -461,9 +465,13 @@ def _set_extractor_batchsize(self): @staticmethod def _set_plugin_batchsize(plugin, available_vram): - """ Set the batch size for the given plugin based on given available vram """ - plugin.batchsize = int(max(1, available_vram // plugin.vram_per_batch)) - logger.verbose("Reset batchsize for %s to %s", plugin.name, plugin.batchsize) + """ Set the batch size for the given plugin based on given available vram. + Do not update plugins which have a vram_per_batch of 0 (CPU plugins) due to + zero division error. + """ + if plugin.vram_per_batch != 0: + plugin.batchsize = int(max(1, available_vram // plugin.vram_per_batch)) + logger.verbose("Reset batchsize for %s to %s", plugin.name, plugin.batchsize) def _join_threads(self): """ Join threads for current pass """ From f0a0bbae7408d19d0ca0c0798d0f1a125fe2ddbd Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 30 Oct 2019 09:58:57 +0000 Subject: [PATCH 116/981] Bugfix: lib.alignments. Check for .fsa file extension --- lib/alignments.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/alignments.py b/lib/alignments.py index 675233280a..0be9abc252 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -78,6 +78,8 @@ def get_location(self, folder, filename): # Reformat legacy alignments file filename = self.update_file_format(folder, filename) logger.debug("Updated legacy alignments. New filename: '%s'", filename) + if extension[1:] == self.serializer.file_extension: + logger.debug("Valid Alignments filename provided: '%s'", filename) else: filename = "{}.{}".format(filename, self.serializer.file_extension) logger.debug("File extension set from serializer: '%s'", From b1dd0dbdc5c9a517cb6e89e50cce749e86927d2c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 31 Oct 2019 01:45:27 +0000 Subject: [PATCH 117/981] bugfix: lib.image - Ensure video frame count is always read correctly --- lib/image.py | 122 +++++++++++++--------------------- scripts/fsmedia.py | 4 +- tools/lib_alignments/media.py | 4 +- 3 files changed, 52 insertions(+), 78 deletions(-) diff --git a/lib/image.py b/lib/image.py index 5466d8f1e4..52c21b0c80 100644 --- a/lib/image.py +++ b/lib/image.py @@ -4,7 +4,6 @@ import logging import subprocess import os -import sys from concurrent import futures from hashlib import sha1 @@ -13,6 +12,7 @@ import imageio import imageio_ffmpeg as im_ffm import numpy as np +from tqdm import tqdm from lib.multithreading import MultiThread from lib.queue_manager import queue_manager, QueueEmpty @@ -262,91 +262,65 @@ def batch_convert_color(batch, colorspace): # <<< VIDEO UTILS >>> # # ################### # -def count_frames_and_secs(filename, timeout=90): - """ Count the number of frames and seconds in a video file. +def count_frames(filename): + """ Count the number of frames in a video file - Adapted From :mod:`ffmpeg_imageio` to handle the issue of ffmpeg occasionally hanging - inside a sub-process. + Unfortunately there is no guaranteed accurate way to get a count of video frames + without iterating through the video. - If the operation times out then the process will try to read the data again, up to a total - of 3 times. If the data still cannot be read then an exception will be raised. - - Note that this operation can be quite slow for large files. + This counts the frames, displaying a progress bar to keep the user abreast of progress Parameters ---------- filename: str - Full path to the video to be analyzed. - timeout: str, optional - The amount of time in seconds to wait for the video data before aborting. - Default: ``60`` + Full path to the video to return the frame count from. Returns ------- - frames: int - The number of frames in the given video file. - secs: float - The duration, in seconds, of the given video file. - - Example - ------- - >>> video = "/path/to/video.mp4" - >>> frames, secs = count_frames_and_secs(video) + int: The number of frames in the given video file. """ - # https://stackoverflow.com/questions/2017843/fetch-frame-count-with-ffmpeg - assert isinstance(filename, str), "Video path must be a string" - exe = im_ffm.get_ffmpeg_exe() - iswin = sys.platform.startswith("win") - logger.debug("iswin: '%s'", iswin) - cmd = [exe, "-i", filename, "-map", "0:v:0", "-c", "copy", "-f", "null", "-"] + + cmd = [im_ffm.get_ffmpeg_exe(), "-i", filename, "-map", "0:v:0", "-f", "null", "-"] logger.debug("FFMPEG Command: '%s'", " ".join(cmd)) - attempts = 3 - for attempt in range(attempts): - try: - logger.debug("attempt: %s of %s", attempt + 1, attempts) - out = subprocess.check_output(cmd, - stderr=subprocess.STDOUT, - shell=iswin, - timeout=timeout) - logger.debug("Succesfully communicated with FFMPEG") + process = subprocess.Popen(cmd, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + universal_newlines=True) + pbar = None + duration = None + init_tqdm = False + update = 0 + frames = 0 + while True: + output = process.stdout.readline().strip() + if output == "" and process.poll() is not None: break - except subprocess.CalledProcessError as err: - out = err.output.decode(errors="ignore") - raise RuntimeError("FFMEG call failed with {}:\n{}".format(err.returncode, out)) - except subprocess.TimeoutExpired as err: - this_attempt = attempt + 1 - if this_attempt == attempts: - msg = ("FFMPEG hung while attempting to obtain the frame count. " - "Sometimes this issue resolves itself, so you can try running again. " - "Otherwise use the Effmpeg Tool to extract the frames from your video into " - "a folder, and then run the requested Faceswap process on that folder.") - raise FaceswapError(msg) from err - logger.warning("FFMPEG hung while attempting to obtain the frame count. " - "Retrying %s of %s", this_attempt + 1, attempts) - continue - - # Note that other than with the sub-process calls below, ffmpeg wont hang here. - # Worst case Python will stop/crash and ffmpeg will continue running until done. - - nframes = nsecs = None - for line in reversed(out.splitlines()): - if not line.startswith(b"frame="): - continue - line = line.decode(errors="ignore") - logger.debug("frame line: '%s'", line) - idx = line.find("frame=") - if idx >= 0: - splitframes = line[idx:].split("=", 1)[-1].lstrip().split(" ", 1)[0].strip() - nframes = int(splitframes) - idx = line.find("time=") - if idx >= 0: - splittime = line[idx:].split("=", 1)[-1].lstrip().split(" ", 1)[0].strip() - nsecs = convert_to_secs(*splittime.split(":")) - logger.debug("nframes: %s, nsecs: %s", nframes, nsecs) - return nframes, nsecs - - raise RuntimeError("Could not get number of frames") + + if output.startswith("Duration:"): + logger.debug("Duration line: %s", output) + idx = output.find("Duration:") + len("Duration:") + duration = int(convert_to_secs(*output[idx:].split(",", 1)[0].strip().split(":"))) + logger.debug("duration: %s", duration) + if output.startswith("frame="): + logger.debug("frame line: %s", output) + if not init_tqdm: + logger.debug("Initializing tqdm") + pbar = tqdm(desc="Counting Video Frames", total=duration, unit="secs") + init_tqdm = True + time_idx = output.find("time=") + len("time=") + frame_idx = output.find("frame=") + len("frame=") + frames = int(output[frame_idx:].strip().split(" ")[0].strip()) + vid_time = int(convert_to_secs(*output[time_idx:].split(" ")[0].strip().split(":"))) + logger.debug("frames: %s, vid_time: %s", frames, vid_time) + prev_update = update + update = vid_time + pbar.update(update - prev_update) + if pbar is not None: + pbar.close() + return_code = process.poll() + logger.debug("Return code: %s, frames: %s", return_code, frames) + return frames class BackgroundIO(): @@ -400,7 +374,7 @@ def __init__(self, path, task, load_with_hash=False, queue_size=16): self._task = task.lower() self._is_video = self._check_input() self._input = self.location if self._is_video else get_image_paths(self.location) - self._count = count_frames_and_secs(self._input)[0] if self._is_video else len(self._input) + self._count = count_frames(self._input) if self._is_video else len(self._input) self._queue = queue_manager.get_queue(name="{}_{}".format(self.__class__.__name__, self._task), maxsize=queue_size) diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index b7f99f2874..a6a0269c30 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -16,7 +16,7 @@ from lib.aligner import Extract as AlignerExtract from lib.alignments import Alignments as AlignmentsBase from lib.face_filter import FaceFilter as FilterFunc -from lib.image import count_frames_and_secs, read_image +from lib.image import count_frames, read_image from lib.utils import (camel_case_split, get_folder, get_image_paths, set_system_verbosity, _video_extensions) @@ -126,7 +126,7 @@ def __init__(self, arguments): def count_images(self): """ Number of images or frames """ if self.is_video: - retval = int(count_frames_and_secs(self.args.input_dir)[0]) + retval = int(count_frames(self.args.input_dir)) else: retval = len(self.input_images) return retval diff --git a/tools/lib_alignments/media.py b/tools/lib_alignments/media.py index 01b69d5359..3276075a3d 100644 --- a/tools/lib_alignments/media.py +++ b/tools/lib_alignments/media.py @@ -14,7 +14,7 @@ from lib.aligner import Extract as AlignerExtract from lib.alignments import Alignments from lib.faces_detect import DetectedFace -from lib.image import (count_frames_and_secs, encode_image_with_hash, read_image, +from lib.image import (count_frames, encode_image_with_hash, read_image, read_image_hash_batch) from lib.utils import _image_extensions, _video_extensions @@ -81,7 +81,7 @@ def count(self): if self._count is not None: return self._count if self.is_video: - self._count = int(count_frames_and_secs(self.folder)[0]) + self._count = int(count_frames(self.folder)) else: self._count = len(self.file_list_sorted) return self._count From 8085b4a80bc406f32d4d960a77de4c643a716946 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 31 Oct 2019 11:19:29 +0000 Subject: [PATCH 118/981] lib.logger - Remove newlines from log messages --- lib/image.py | 26 +++++++++++++++++++------ lib/logger.py | 54 +++++++++++++++++++++++---------------------------- 2 files changed, 44 insertions(+), 36 deletions(-) diff --git a/lib/image.py b/lib/image.py index 52c21b0c80..d3047ae048 100644 --- a/lib/image.py +++ b/lib/image.py @@ -262,26 +262,40 @@ def batch_convert_color(batch, colorspace): # <<< VIDEO UTILS >>> # # ################### # -def count_frames(filename): +def count_frames(filename, fast=False): """ Count the number of frames in a video file - Unfortunately there is no guaranteed accurate way to get a count of video frames - without iterating through the video. + There is no guaranteed accurate way to get a count of video frames without iterating through + a video and decoding every frame. - This counts the frames, displaying a progress bar to keep the user abreast of progress + :func:`count_frames` can return an accurate count (albeit fairly slowly) or a possibly less + accurate count, depending on the :attr:`fast` parameter. A progress bar is displayed. Parameters ---------- filename: str Full path to the video to return the frame count from. + fast: bool, optional + Whether to count the frames without decoding them. This is significantly faster but + accuracy is not guaranteed. Default: ``False``. Returns ------- - int: The number of frames in the given video file. + int: + The number of frames in the given video file. + + Example + ------- + >>> filename = "/path/to/video.mp4" + >>> frame_count = count_frames(filename) """ assert isinstance(filename, str), "Video path must be a string" - cmd = [im_ffm.get_ffmpeg_exe(), "-i", filename, "-map", "0:v:0", "-f", "null", "-"] + cmd = [im_ffm.get_ffmpeg_exe(), "-i", filename, "-map", "0:v:0"] + if fast: + cmd.extend(["-c", "copy"]) + cmd.extend(["-f", "null", "-"]) + logger.debug("FFMPEG Command: '%s'", " ".join(cmd)) process = subprocess.Popen(cmd, stderr=subprocess.STDOUT, diff --git a/lib/logger.py b/lib/logger.py index 0d4b2fa1bf..c0a934dd5e 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -10,8 +10,6 @@ from datetime import datetime from tqdm import tqdm -from numpy import ndarray - class FaceswapLogger(logging.Logger): """ Create custom logger with custom levels """ @@ -40,35 +38,31 @@ def trace(self, msg, *args, **kwargs): class FaceswapFormatter(logging.Formatter): - """ Override formatter to strip newlines from logger arguments """ + """ Override formatter to strip newlines the final message """ + def format(self, record): - if isinstance(record.msg, str) and ("\n" in record.msg or "\r" in record.msg): - record.msg = record.msg.replace("\n", "\\n").replace("\r", "\\r") - if any(self.reformat_check(arg) for arg in record.args): - record.args = self.reformat_args(record.args) - return super().format(record) - - @staticmethod - def reformat_check(arg): - """ Check if the argument should be reformatted - The argument is a string with a line break - The argument is a numpy array - """ - return ((isinstance(arg, str) and ("\n" in arg or "\r" in arg)) - or isinstance(arg, ndarray)) - - @staticmethod - def reformat_args(args): - """ Reformat args that require new lines removing """ - new_args = [] - for arg in args: - if isinstance(arg, ndarray): - # Convert numpy arrays to string for reformatting - arg = str(ndarray) - if isinstance(arg, str) and ("\n" in arg or "\r" in arg): - arg = arg.replace("\n", "\\n").replace("\r", "\\r") - new_args.append(arg) - return tuple(new_args) + record.message = record.getMessage() + # strip newlines + if "\n" in record.message or "\r" in record.message: + record.message = record.message.replace("\n", "\\n").replace("\r", "\\r") + + if self.usesTime(): + record.asctime = self.formatTime(record, self.datefmt) + msg = self.formatMessage(record) + if record.exc_info: + # Cache the traceback text to avoid converting it multiple times + # (it's constant anyway) + if not record.exc_text: + record.exc_text = self.formatException(record.exc_info) + if record.exc_text: + if msg[-1:] != "\n": + msg = msg + "\n" + msg = msg + record.exc_text + if record.stack_info: + if msg[-1:] != "\n": + msg = msg + "\n" + msg = msg + self.formatStack(record.stack_info) + return msg class RollingBuffer(collections.deque): From 11ab910c5e830dacc4030ac3b70e4bb49a5879b0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 31 Oct 2019 11:55:51 +0000 Subject: [PATCH 119/981] Reinstate fast frame count for convert and extract. Exit early if count is wrong. --- lib/image.py | 1 + lib/multithreading.py | 6 ++++++ scripts/convert.py | 3 +++ scripts/fsmedia.py | 2 +- 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/image.py b/lib/image.py index d3047ae048..b02add0daa 100644 --- a/lib/image.py +++ b/lib/image.py @@ -289,6 +289,7 @@ def count_frames(filename, fast=False): >>> filename = "/path/to/video.mp4" >>> frame_count = count_frames(filename) """ + logger.debug("filename: %s, fast: %s", filename, fast) assert isinstance(filename, str), "Video path must be a string" cmd = [im_ffm.get_ffmpeg_exe(), "-i", filename, "-map", "0:v:0"] diff --git a/lib/multithreading.py b/lib/multithreading.py index 62a0251840..3fe141a429 100644 --- a/lib/multithreading.py +++ b/lib/multithreading.py @@ -99,6 +99,12 @@ def start(self): self._threads.append(thread) logger.debug("Started all threads '%s': %s", self._name, len(self._threads)) + def completed(self): + """ Return False if there are any alive threads else True """ + retval = all(not thread.is_alive() for thread in self._threads) + logger.debug(retval) + return retval + def join(self): """ Join the running threads, catching and re-raising any errors """ logger.debug("Joining Threads: '%s'", self._name) diff --git a/scripts/convert.py b/scripts/convert.py index 390b339fee..06be4f899d 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -130,6 +130,9 @@ def convert_images(self): if self.disk_io.completion_event.is_set(): logger.debug("DiskIO completion event set. Joining Pool") break + if self.patch_threads.completed(): + logger.debug("All patch threads completed") + break sleep(1) self.patch_threads.join() diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index a6a0269c30..7de1e79280 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -126,7 +126,7 @@ def __init__(self, arguments): def count_images(self): """ Number of images or frames """ if self.is_video: - retval = int(count_frames(self.args.input_dir)) + retval = int(count_frames(self.args.input_dir, fast=True)) else: retval = len(self.input_images) return retval From b702e56b55dc0528e741e12269f4016190b57653 Mon Sep 17 00:00:00 2001 From: 50mkw Date: Fri, 1 Nov 2019 15:04:06 +0800 Subject: [PATCH 120/981] solve double fsa suffix bug --- lib/alignments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/alignments.py b/lib/alignments.py index 0be9abc252..287103ff83 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -81,7 +81,7 @@ def get_location(self, folder, filename): if extension[1:] == self.serializer.file_extension: logger.debug("Valid Alignments filename provided: '%s'", filename) else: - filename = "{}.{}".format(filename, self.serializer.file_extension) + filename = "{}.{}".format(os.path.splitext(filename)[0], self.serializer.file_extension) logger.debug("File extension set from serializer: '%s'", self.serializer.file_extension) location = os.path.join(str(folder), filename) From 26d41f931a82920204e705b157e36f13dc867dd2 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 2 Nov 2019 19:30:48 +0000 Subject: [PATCH 121/981] lib.logger - Change how crash logging gets its path --- lib/logger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/logger.py b/lib/logger.py index c0a934dd5e..80db322fcb 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -161,7 +161,7 @@ def get_loglevel(loglevel): def crash_log(): """ Write debug_buffer to a crash log on crash """ from lib.sysinfo import sysinfo - path = os.getcwd() + path = os.path.dirname(os.path.realpath(sys.argv[0])) filename = os.path.join(path, datetime.now().strftime("crash_report.%Y.%m.%d.%H%M%S%f.log")) freeze_log = list(debug_buffer) From 48a7276ae7418225d4f95e37bf798437b99fd16d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 3 Nov 2019 00:51:41 +0000 Subject: [PATCH 122/981] Add linux .install folder --- .gitignore | 1 + .install/linux/fs_logo.ico | Bin 0 -> 145298 bytes 2 files changed, 1 insertion(+) create mode 100644 .install/linux/fs_logo.ico diff --git a/.gitignore b/.gitignore index 0cbbddfbf9..c6eee93860 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ !Dockerfile* !requirements* !.install/ +!.install/linux !.install/windows !docs !docs/full diff --git a/.install/linux/fs_logo.ico b/.install/linux/fs_logo.ico new file mode 100644 index 0000000000000000000000000000000000000000..c96ff6105f1bf51db03e644295ef9a915bc0ae2d GIT binary patch literal 145298 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 yOO Date: Sun, 3 Nov 2019 23:04:16 +0000 Subject: [PATCH 123/981] scripts.convert - add allow growth option --- lib/cli.py | 9 +++++++++ scripts/convert.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/lib/cli.py b/lib/cli.py index 94e71aa09e..a102044813 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -963,6 +963,15 @@ def get_optional_arguments(): "group": "settings", "help": "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder"}) + argument_list.append({ + "opts": ("-ag", "--allow-growth"), + "action": "store_true", + "dest": "allow_growth", + "group": "settings", + "default": False, + "backend": "nvidia", + "help": "Sets allow_growth option of Tensorflow to spare memory on some " + "configurations."}) argument_list.append({ "opts": ("-k", "--keep-unchanged"), "action": "store_true", diff --git a/scripts/convert.py b/scripts/convert.py index 06be4f899d..e05f974e72 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -10,6 +10,8 @@ from cv2 import imwrite # pylint:disable=no-name-in-module import numpy as np +import tensorflow as tf +from keras.backend.tensorflow_backend import set_session from tqdm import tqdm from scripts.fsmedia import Alignments, Images, PostProcess, Utils @@ -424,6 +426,10 @@ def __init__(self, in_queue, queue_size, arguments): self.serializer = get_serializer("json") self.faces_count = 0 self.verify_output = False + + if arguments.allow_growth: + self.set_tf_allow_growth() + self.model = self.load_model() self.output_indices = {"face": self.model.largest_face_index, "mask": self.model.largest_mask_index} @@ -471,6 +477,18 @@ def get_batchsize(queue_size): logger.debug("Got batchsize: %s", batchsize) return batchsize + @staticmethod + def set_tf_allow_growth(): + """ Allow TensorFlow to manage VRAM growth """ + # pylint: disable=no-member + # TODO Move this temporary fix somewhere more appropriate + logger.debug("Setting Tensorflow 'allow_growth' option") + config = tf.ConfigProto() + config.gpu_options.allow_growth = True + config.gpu_options.visible_device_list = "0" + set_session(tf.Session(config=config)) + logger.debug("Set Tensorflow 'allow_growth' option") + def load_model(self): """ Load the model requested for conversion """ logger.debug("Loading Model") From 50bf8e5c31549d8195b5affe97997b6508af6680 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 4 Nov 2019 17:26:31 +0000 Subject: [PATCH 124/981] .install.linux Add linux installer source code --- .gitignore | 1 + .install/linux/faceswap_setup_x64.sh | 420 +++++++++++++++++++++++++++ 2 files changed, 421 insertions(+) create mode 100644 .install/linux/faceswap_setup_x64.sh diff --git a/.gitignore b/.gitignore index c6eee93860..cea035ad10 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ !*.png !*.py !*.rst +!*.sh !*.txt !.cache !Dockerfile* diff --git a/.install/linux/faceswap_setup_x64.sh b/.install/linux/faceswap_setup_x64.sh new file mode 100644 index 0000000000..e85b3b97ec --- /dev/null +++ b/.install/linux/faceswap_setup_x64.sh @@ -0,0 +1,420 @@ +#!/bin/bash + +TMP_DIR="/tmp/faceswap_install" +DL_CONDA="https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh" +DL_FACESWAP="https://github.com/deepfakes/faceswap.git" + +CONDA_PATHS=("/opt" "$HOME") +CONDA_NAMES=("/ana" "/mini") +CONDA_VERSIONS=("3" "2") +CONDA_BINS=("/bin/conda" "/condabin/conda") +DIR_CONDA="$HOME/miniconda3" +CONDA_EXECUTABLE="${DIR_CONDA}/bin/conda" +CONDA_TO_PATH=false +ENV_NAME="faceswap" + +DIR_FACESWAP="$HOME/faceswap" +VERSION="nvidia" + +DESKTOP=false + +header() { + # Format header text + length=${#1} + padding=$(( (72 - length) / 2)) + sep=$(printf '=%.0s' $(seq 1 $padding)) + echo "" + echo -e "\e[32m$sep $1 $sep" +} + +info () { + # output info message + while read -r line ; do + echo -e "\e[32mINFO\e[97m $line" + done <<< "$(echo "$1" | fmt -cu -w 70)" +} + +error () { + # output error message. + while read -r line ; do + echo -e "\e[31mERROR\e[97m $line" + done <<< "$(echo "$1" | fmt -cu -w 70)" +} + +yellow () { + # Change text color to yellow + echo -en "\e[33m" +} + +check_file_exists () { + # Check whether a file exists and return true or false + test -f "$1" +} + +check_folder_exists () { + # Check whether a folder exists and return true or false + test -d "$1" +} + +download_file () { + # Download a file to the temp folder + fname=$(basename -- "$1") + curl "$1" --output "$TMP_DIR/$fname" --progress-bar +} + +check_for_sudo() { + # Ensure user isn't running as sudo/root. We don't want to screw up any system install + if [ "$EUID" == 0 ] ; then + error "This install script should not be run with root privileges. Please run as a normal user." + exit 1 + fi +} + +create_tmp_dir() { + TMP_DIR="$(mktemp -d)" + if [ -z "$TMP_DIR" -o ! -d "$TMP_DIR" ]; then + # This shouldn't happen, but just in case to prevent the tmp cleanup function to mess things up. + error "Failed creating the temporary install directory." + exit 2 + fi + trap cleanup_tmp_dir EXIT +} + +cleanup_tmp_dir() { + rm -rf "$TMP_DIR" +} + +ask () { + # Ask for input. First parameter: Display text, 2nd parameter variable name + default="${!2}" + read -rp $'\e[36m'"$1 [default: '$default']: "$'\e[97m' inp + inp="${inp:-${default}}" + if [ "$inp" == "\n" ] ; then inp=${!2} ; fi + printf -v $2 "$inp" +} + +ask_yesno () { + # Ask yes or no. First Param: Question, 2nd param: Default + # Returns True for yes, False for No + case $2 in + [Yy]* ) opts="[YES/no]" ;; + [Nn]* ) opts="[yes/NO]" ;; + esac + while true; do + read -rp $'\e[36m'"$1 $opts: "$'\e[97m' yn + yn="${yn:-${2}}" + case $yn in + [Yy]* ) retval=true ; break ;; + [Nn]* ) retval=false ; break ;; + * ) echo "Please answer yes or no." ;; + esac + done + $retval +} + + +ask_version() { + # Ask which version of faceswap to install + while true; do + default=1 + read -rp $'\e[36m'"Select: 1 (NVIDIA), 2 (AMD), 3 (CPU) [default: $default]: "$'\e[97m' vers + vers="${vers:-${default}}" + case $vers in + 1) VERSION="nvidia" ; break ;; + 2) VERSION="amd" ; break ;; + 3) VERSION="cpu" ; break ;; + * ) echo "Invalid selection." ;; + esac + done +} + +banner () { + echo -e " \e[32m 001" + echo -e " \e[32m 11 10 010" + echo -e " \e[97m @@@@\e[32m 10" + echo -e " \e[97m @@@@@@@@\e[32m 00 1" + echo -e " \e[97m @@@@@@@@@@\e[32m 1 1 0" + echo -e " \e[97m @@@@@@@@\e[32m 0000 01111" + echo -e " \e[97m @@@@@@@@@@\e[32m 01 110 01 1" + echo -e " \e[97m@@@@@@@@@@@@\e[32m 111 010 0" + echo -e " \e[97m@@@@@@@@@@@@@@@@\e[32m 10 0" + echo -e " \e[97m@@@@@@@@@@@@@\e[32m 0010 1" + echo -e " \e[97m@@@@@@@@@ @@@\e[32m 100 1" + echo -e " \e[97m@@@@@@@ .@@@@\e[32m 10 1" + echo -e " \e[97m #@@@@@@@@@@@\e[32m 001 0" + echo -e " \e[97m @@@@@@@@@@@ ," + echo -e " \e[97m @@@@@@@@ @@@@@" + echo -e " \e[97m @@@@@@@@ @@@@@@@@" + echo -e " \e[97m @@@@@@@@@,@@@@@@@@ / _|" + echo -e " \e[97m %@@@@@@@@@@@@@@@@@ | |_ ___ " + echo -e " \e[97m @@@@@@@@@@@@@@ | _|/ __|" + echo -e " \e[97m @@@@@@@@@@@@ | | \__ \\" + echo -e " \e[97m @@@@@@@@@@( |_| |___/" + echo -e " \e[97m @@@@@@" + echo -e " \e[97m @@@@" + sleep 2 +} + +find_conda_install() { + if check_conda_path; + then true + elif check_conda_locations ; then true + else false + fi +} + +set_conda_dir_from_bin() { + # Set the DIR_CONDA variable from the bin file + DIR_CONDA=$(readlink -f "$(dirname "$1")/..") + info "Found existing conda install at: $DIR_CONDA" +} + +check_conda_path() { + # Check if conda is in PATH + conda_bin="$(which conda 2>/dev/null)" + if [[ "$?" == "0" ]]; then + set_conda_dir_from_bin "$conda_bin" + CONDA_EXECUTABLE="$conda_bin" + true + else + false + fi +} + +check_conda_locations() { + # Check common conda install locations + retval=false + for path in "${CONDA_PATHS[@]}"; do + for name in "${CONDA_NAMES[@]}" ; do + foldername="$path${name}conda" + for vers in "${CONDA_VERSIONS[@]}" ; do + for bin in "${CONDA_BINS[@]}" ; do + condabin="$foldername$vers$bin" + if check_file_exists "$condabin" ; then + set_conda_dir_from_bin "$condabin" + CONDA_EXECUTABLE="$condabin"; + retval=true + break 4 + fi + done + done + done + done + $retval +} + +user_input() { + # Get user options for install + header "Welcome to the Linux Faceswap Installer" + info "To get setup we need to gather some information about where you would like Faceswap\ + and Conda to be installed." + info "To accept the default values just hit the 'ENTER' key for each option. You will have\ + an opportunity to review your responses prior to commencing the install." + echo "" + info "\e[33mIMPORTANT:\e[97m Make sure that the user '$USER' has full permissions for all of the\ + destinations that you select." + read -rp $'\e[36m'"Press 'ENTER' to continue with the setup..."$'\e[36m' + conda_opts + faceswap_opts + post_install_opts +} + +conda_opts () { + # Options pertaining to the installation of conda + header "CONDA" + info "Faceswap uses Conda as it handles the installation of all prerequisites." + if find_conda_install && ask_yesno "Use the pre installed conda?" "Yes"; then + info "Using Conda install at $DIR_CONDA" + else + info "If you have an existing Conda install then enter the location here,\ + otherwise Miniconda3 will be installed in the given location." + err_msg="The location for Conda must not contain spaces (this is a specific\ + limitation of Conda)." + tmp_dir_conda="$DIR_CONDA" + while true ; do + ask "Please specify a location for Conda." "DIR_CONDA" + case ${DIR_CONDA} in + *\ * ) error "$err_msg" ; DIR_CONDA=$tmp_dir_conda ;; + * ) break ;; + esac + CONDA_EXECUTABLE="${DIR_CONDA}/bin/conda" + done + fi + if ! check_file_exists "$CONDA_EXECUTABLE" ; then + info "The Conda executable can be added to your PATH. This makes it easier to run Conda\ + commands directly. If you already have a pre-existing Conda install then you should\ + probably not enable this, otherwise this should be fine." + if ask_yesno "Add Conda executable to path?" "Yes" ; then CONDA_TO_PATH=true ; fi + fi + echo "" + info "Faceswap will be installed inside a Conda Environment. If an environment already\ + exists with the name specified then it will be deleted." + ask "Please specify a name for the Faceswap Conda Environmnet" "ENV_NAME" +} + +faceswap_opts () { + # Options pertaining to the installation of faceswap + header "FACESWAP" + info "Faceswap will be installed in the given location. If a folder exists at the\ + location you specify, then it will be deleted." + ask "Please specify a location for Faceswap" "DIR_FACESWAP" + echo "" + info "Faceswap can be run on NVIDIA or AMD GPUs or on CPU. You should make sure that you have the \ + latest graphics card drivers installed from the relevant vendor. Please select the version\ + of Faceswap you wish to install." + ask_version +} + +post_install_opts() { + # Post installation options + if check_folder_exists "$HOME/Desktop" ; then + header "POST INSTALLATION ACTIONS" + info "Launching Faceswap requires activating your Conda Environment and then running\ + Faceswap. The installer can simplify this by creating a desktop shortcut to launch\ + straight into the Faceswap GUI" + if ask_yesno "Create Desktop Shortcut?" "Yes" + then DESKTOP=true + fi + fi +} + +review() { + # Review user options and ask continue + header "Review install options" + info "Please review the selected installation options before proceeding:" + echo "" + if ! check_folder_exists "$DIR_CONDA" + then + echo " - MiniConda3 will be installed in '$DIR_CONDA'" + else + echo " - Existing Conda install at '$DIR_CONDA' will be used" + fi + if $CONDA_TO_PATH ; then echo " - MiniConda3 will be added to your PATH" ; fi + if check_env_exists ; then + echo -e " \e[33m- Existing Conda Environment '$ENV_NAME' will be removed\e[97m" + fi + echo " - Conda Environment '$ENV_NAME' will be created." + if check_folder_exists "$DIR_FACESWAP" ; then + echo -e " \e[33m- Existing Faceswap folder '$DIR_FACESWAP' will be removed\e[97m" + fi + echo " - Faceswap will be installed in '$DIR_FACESWAP'" + echo " - Installing for '$VERSION'" + if $DESKTOP ; then echo " - A Desktop shortcut will be created" ; fi + if ! ask_yesno "Do you wish to continue?" "No" ; then exit ; fi +} + +conda_install() { + # Download and install Mini Conda3 + if ! check_folder_exists "$DIR_CONDA" ; then + info "Downloading Miniconda3..." + yellow ; download_file $DL_CONDA + info "Installing Miniconda3..." + yellow ; fname="$(basename -- $DL_CONDA)" + bash "$TMP_DIR/$fname" -b -p "$DIR_CONDA" + if $CONDA_TO_PATH ; then + info "Adding Miniconda3 to PATH..." + yellow ; "$CONDA_EXECUTABLE" init + "$CONDA_EXECUTABLE" config --set auto_activate_base false + fi + fi +} + +check_env_exists() { + # Check if an environment with the given name exists + if check_file_exists "$CONDA_EXECUTABLE" ; then + "$CONDA_EXECUTABLE" env list | grep -qE "^${ENV_NAME}\W" + else false + fi +} + +delete_env() { + # Delete the env if it previously exists + if check_env_exists ; then + info "Removing pre-existing Virtual Environment" + yellow ; "$CONDA_EXECUTABLE" env remove -n "$ENV_NAME" + fi +} + +create_env() { + # Create Python 3.6 env for faceswap + delete_env + info "Creating Conda Virtual Environment..." + yellow ; "$CONDA_EXECUTABLE" create -n "$ENV_NAME" -q python=3.6 -y +} + + +activate_env() { + # Activate the conda environment + # shellcheck source=/dev/null + source "$DIR_CONDA/etc/profile.d/conda.sh" activate + conda activate "$ENV_NAME" +} + +install_git() { + # Install git inside conda environment + info "Installing Git..." + yellow ; conda install git -q -y +} + +delete_faceswap() { + # Delete existing faceswap folder + if check_folder_exists "$DIR_FACESWAP" ; then + info "Removing Faceswap folder: '$DIR_FACESWAP'" + rm -rf "$DIR_FACESWAP" + fi +} + +clone_faceswap() { + # Clone the faceswap repo + delete_faceswap + info "Downloading Faceswap..." + yellow ; git clone --depth 1 --no-single-branch "$DL_FACESWAP" "$DIR_FACESWAP" +} + +setup_faceswap() { + # Run faceswap setup script + info "Setting up Faceswap..." + if [ $VERSION != "cpu" ] ; then args="--$VERSION" ; else args="" ; fi + python "$DIR_FACESWAP/setup.py" --installer $args +} + +create_desktop_shortcut () { + # Create a shell script to launch the GUI and add a desktop shortcut + if $DESKTOP ; then + launcher="$DIR_FACESWAP/faceswap_gui_launcher.sh" + desktop_icon="$HOME/Desktop/faceswap.desktop" + launch_script="source \"$DIR_CONDA/etc/profile.d/conda.sh\" activate &&\n" + launch_script+="conda activate '$ENV_NAME' &&\n" + launch_script+="python \"$DIR_FACESWAP/faceswap.py\" gui\n" + echo -e "$launch_script" > "$launcher" + chmod +x "$launcher" + + desktop_file="[Desktop Entry]\n" + desktop_file+="Version=1.0\n" + desktop_file+="Type=Application\n" + desktop_file+="Terminal=true\n" + desktop_file+="Name=FaceSwap\n" + desktop_file+="Exec=bash $launcher\n" + desktop_file+="Comment=FaceSwap\n" + desktop_file+="Icon=$DIR_FACESWAP/.install/linux/fs_logo.ico\n" + echo -e "$desktop_file" > "$desktop_icon" + chmod +x "$desktop_icon" + fi ; +} + +check_for_sudo +banner +user_input +review +create_tmp_dir +conda_install +create_env +activate_env +install_git +clone_faceswap +setup_faceswap +create_desktop_shortcut +info "Faceswap installation is complete!" +if $DESKTOP ; then info "You can launch Faceswap from the icon on your desktop" ; exit ; fi +if $CONDA_TO_PATH ; then + info "You should close the terminal and re-open to activate Conda before proceeding" ; fi From 61497a9117d6d7f8355a9dd40cf6f87a7ff6aa22 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 4 Nov 2019 17:29:05 +0000 Subject: [PATCH 125/981] Updart INSTALL.md --- INSTALL.md | 64 +++++++++++++++++++++++++++--------------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index e0112b3522..e975c1c5ec 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,35 +1,35 @@ # Installing faceswap -- [Installing faceswap](#Installing-faceswap) -- [Prerequisites](#Prerequisites) - - [Hardware Requirements](#Hardware-Requirements) - - [Supported operating systems](#Supported-operating-systems) -- [Important before you proceed](#Important-before-you-proceed) -- [Windows Install Guide](#Windows-Install-Guide) - - [Installer](#Installer) - - [Manual Install](#Manual-Install) - - [Prerequisites](#Prerequisites-1) - - [Anaconda](#Anaconda) - - [Git](#Git) - - [Setup](#Setup) - - [Anaconda](#Anaconda-1) - - [Set up a virtual environment](#Set-up-a-virtual-environment) - - [Entering your virtual environment](#Entering-your-virtual-environment) +- [Installing faceswap](#installing-faceswap) +- [Prerequisites](#prerequisites) + - [Hardware Requirements](#hardware-requirements) + - [Supported operating systems](#supported-operating-systems) +- [Important before you proceed](#important-before-you-proceed) +- [Linux and Windows Install Guide](#linux-and-windows-install-guide) + - [Installer](#installer) + - [Manual Install](#manual-install) + - [Prerequisites](#prerequisites-1) + - [Anaconda](#anaconda) + - [Git](#git) + - [Setup](#setup) + - [Anaconda](#anaconda-1) + - [Set up a virtual environment](#set-up-a-virtual-environment) + - [Entering your virtual environment](#entering-your-virtual-environment) - [faceswap](#faceswap) - - [Easy install](#Easy-install) - - [Manual install](#Manual-install) - - [Running faceswap](#Running-faceswap) - - [Create a desktop shortcut](#Create-a-desktop-shortcut) - - [Updating faceswap](#Updating-faceswap) -- [General Install Guide](#General-Install-Guide) - - [Installing dependencies](#Installing-dependencies) - - [Git](#Git-1) - - [Python](#Python) - - [Virtual Environment](#Virtual-Environment) - - [Getting the faceswap code](#Getting-the-faceswap-code) - - [Setup](#Setup-1) - - [About some of the options](#About-some-of-the-options) - - [Run the project](#Run-the-project) - - [Notes](#Notes) + - [Easy install](#easy-install) + - [Manual install](#manual-install) + - [Running faceswap](#running-faceswap) + - [Create a desktop shortcut](#create-a-desktop-shortcut) + - [Updating faceswap](#updating-faceswap) +- [General Install Guide](#general-install-guide) + - [Installing dependencies](#installing-dependencies) + - [Git](#git-1) + - [Python](#python) + - [Virtual Environment](#virtual-environment) + - [Getting the faceswap code](#getting-the-faceswap-code) + - [Setup](#setup-1) + - [About some of the options](#about-some-of-the-options) + - [Run the project](#run-the-project) + - [Notes](#notes) # Prerequisites Machine learning essentially involves a ton of trial and error. You're letting a program try millions of different settings to land on an algorithm that sort of does what you want it to do. This process is really really slow unless you have the hardware required to speed this up. @@ -64,10 +64,10 @@ Alternatively, there is a docker image that is based on Debian. The developers are also not responsible for any damage you might cause to your own computer. -# Windows Install Guide +# Linux and Windows Install Guide ## Installer -Windows now has an installer which installs everything for you and creates a desktop shortcut to launch straight into the GUI. You can download the installer from https://github.com/deepfakes/faceswap/releases. +Windows and Linux now both have an installer which installs everything for you and creates a desktop shortcut to launch straight into the GUI. You can download the installer from https://github.com/deepfakes/faceswap/releases. If you have issues with the installer then read on for the more manual way to install faceswap on Windows. From c2d9a27205817f234ba61e9bd7906cbf6f80445a Mon Sep 17 00:00:00 2001 From: kilroythethird Date: Wed, 6 Nov 2019 20:43:01 +0100 Subject: [PATCH 126/981] Add json to the filter in the GUI alignment open dialog --- lib/alignments.py | 4 ++-- lib/gui/utils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/alignments.py b/lib/alignments.py index 287103ff83..90a77e690d 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -73,7 +73,7 @@ def hashes_to_frame(self): def get_location(self, folder, filename): """ Return the path to alignments file """ logger.debug("Getting location: (folder: '%s', filename: '%s')", folder, filename) - extension = os.path.splitext(filename)[1] + noext_name, extension = os.path.splitext(filename) if extension in (".json", ".p", ".pickle", ".yaml", ".yml"): # Reformat legacy alignments file filename = self.update_file_format(folder, filename) @@ -81,7 +81,7 @@ def get_location(self, folder, filename): if extension[1:] == self.serializer.file_extension: logger.debug("Valid Alignments filename provided: '%s'", filename) else: - filename = "{}.{}".format(os.path.splitext(filename)[0], self.serializer.file_extension) + filename = "{}.{}".format(noext_name, self.serializer.file_extension) logger.debug("File extension set from serializer: '%s'", self.serializer.file_extension) location = os.path.join(str(folder), filename) diff --git a/lib/gui/utils.py b/lib/gui/utils.py index abe40c129d..0c2b7dc524 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -92,7 +92,7 @@ def filetypes(self): """ Set the filetypes for opening/saving """ all_files = ("All files", "*.*") filetypes = {"default": (all_files,), - "alignments": [("Faceswap Alignments", "*.fsa"), + "alignments": [("Faceswap Alignments", "*.fsa *.json"), all_files], "config": [("Faceswap GUI config files", "*.fsw"), all_files], "csv": [("Comma separated values", "*.csv"), all_files], From ffd382993060ff0bb23fc85ec39a70bc8949c488 Mon Sep 17 00:00:00 2001 From: kilroythethird Date: Thu, 7 Nov 2019 04:28:04 +0100 Subject: [PATCH 127/981] Added allow_growth argument for preview --- tools/cli.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tools/cli.py b/tools/cli.py index e8fdf10090..31afc16b01 100644 --- a/tools/cli.py +++ b/tools/cli.py @@ -201,6 +201,13 @@ def get_argument_list(self): "default": False, "help": "Swap the model. Instead of A -> B, " "swap B -> A"}) + argument_list.append({"opts": ("-ag", "--allow-growth"), + "action": "store_true", + "dest": "allow_growth", + "default": False, + "backend": "nvidia", + "help": "Sets allow_growth option of Tensorflow to spare memory " + "on some configurations."}) return argument_list From 54b6e860084bf21f7971ed0c35ec96bc686d8403 Mon Sep 17 00:00:00 2001 From: kilroythethird Date: Mon, 11 Nov 2019 20:13:33 +0100 Subject: [PATCH 128/981] Changed MTCNN input to RGB --- plugins/extract/detect/mtcnn.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py index 4ba013b16e..230d6ca761 100644 --- a/plugins/extract/detect/mtcnn.py +++ b/plugins/extract/detect/mtcnn.py @@ -25,6 +25,7 @@ def __init__(self, **kwargs): self.vram_per_batch = 32 self.batchsize = self.config["batch-size"] self.kwargs = self.validate_kwargs() + self.colorformat = "RGB" def validate_kwargs(self): """ Validate that config options are correct. If not reset to default """ From b49c352e8f06667d42ffed5b630b890fd1c03184 Mon Sep 17 00:00:00 2001 From: kvrooman Date: Wed, 13 Nov 2019 06:17:59 -0600 Subject: [PATCH 129/981] # pylint:disable=no-member cleanup (#927) # pylint:disable=no-member cleanup --- lib/model/session.py | 2 +- lib/vgg_face.py | 17 ++++++----------- lib/vgg_face2_keras.py | 11 +++-------- plugins/extract/align/cv2_dnn.py | 28 ++++++++++++---------------- plugins/extract/detect/_base.py | 15 ++++++--------- plugins/extract/detect/mtcnn.py | 7 +++---- tools/preview.py | 16 ++++++---------- 7 files changed, 37 insertions(+), 59 deletions(-) diff --git a/lib/model/session.py b/lib/model/session.py index 6ddb3f6346..35586adc66 100644 --- a/lib/model/session.py +++ b/lib/model/session.py @@ -111,7 +111,7 @@ def _set_session(self, allow_growth): self.graph = tf.Graph() config = tf.ConfigProto() if allow_growth and get_backend() == "nvidia": - config.gpu_options.allow_growth = True # pylint:disable=no-member + config.gpu_options.allow_growth = True try: session = tf.Session(graph=tf.Graph(), config=config) except tf_error.InternalError as err: diff --git a/lib/vgg_face.py b/lib/vgg_face.py index ed92fc87fa..be917dba1a 100644 --- a/lib/vgg_face.py +++ b/lib/vgg_face.py @@ -40,7 +40,7 @@ def get_model(self, git_model_id, model_filename, backend): root_path = os.path.abspath(os.path.dirname(sys.argv[0])) cache_path = os.path.join(root_path, "plugins", "extract", "recognition", ".cache") model = GetModel(model_filename, cache_path, git_model_id).model_path - model = cv2.dnn.readNetFromCaffe(model[1], model[0]) # pylint: disable=no-member + model = cv2.dnn.readNetFromCaffe(model[1], model[0]) model.setPreferableTarget(self.get_backend(backend)) return model @@ -50,14 +50,14 @@ def get_backend(backend): if backend == "OPENCL": logger.info("Using OpenCL backend. If the process runs, you can safely ignore any of " "the failure messages.") - retval = getattr(cv2.dnn, "DNN_TARGET_{}".format(backend)) # pylint: disable=no-member + retval = getattr(cv2.dnn, "DNN_TARGET_{}".format(backend)) return retval def predict(self, face): """ Return encodings for given image from vgg_face """ if face.shape[0] != self.input_size: face = self.resize_face(face) - blob = cv2.dnn.blobFromImage(face[..., :3], # pylint: disable=no-member + blob = cv2.dnn.blobFromImage(face[..., :3], 1.0, (self.input_size, self.input_size), self.average_img, @@ -69,14 +69,9 @@ def predict(self, face): def resize_face(self, face): """ Resize incoming face to model_input_size """ - if face.shape[0] < self.input_size: - interpolation = cv2.INTER_CUBIC # pylint:disable=no-member - else: - interpolation = cv2.INTER_AREA # pylint:disable=no-member - - face = cv2.resize(face, # pylint:disable=no-member - dsize=(self.input_size, self.input_size), - interpolation=interpolation) + sizes = (self.input_size, self.input_size) + interpolation = cv2.INTER_CUBIC if face.shape[0] < self.input_size else cv2.INTER_AREA + face = cv2.resize(face, dsize=sizes, interpolation=interpolation) return face @staticmethod diff --git a/lib/vgg_face2_keras.py b/lib/vgg_face2_keras.py index d66cf99ab0..0f65e3e641 100644 --- a/lib/vgg_face2_keras.py +++ b/lib/vgg_face2_keras.py @@ -69,14 +69,9 @@ def predict(self, face): def resize_face(self, face): """ Resize incoming face to model_input_size """ - if face.shape[0] < self.input_size: - interpolation = cv2.INTER_CUBIC # pylint:disable=no-member - else: - interpolation = cv2.INTER_AREA # pylint:disable=no-member - - face = cv2.resize(face, # pylint:disable=no-member - dsize=(self.input_size, self.input_size), - interpolation=interpolation) + sizes = (self.input_size, self.input_size) + interpolation = cv2.INTER_CUBIC if face.shape[0] < self.input_size else cv2.INTER_AREA + face = cv2.resize(face, dsize=sizes, interpolation=interpolation) return face @staticmethod diff --git a/plugins/extract/align/cv2_dnn.py b/plugins/extract/align/cv2_dnn.py index a28321b1e0..c9308c1f96 100644 --- a/plugins/extract/align/cv2_dnn.py +++ b/plugins/extract/align/cv2_dnn.py @@ -59,6 +59,7 @@ def process_input(self, batch): def align_image(self, detected_faces): """ Align the incoming image for prediction """ logger.trace("Aligning image around center") + sizes = (self.input_size, self.input_size) rois = [] faces = [] for face in detected_faces: @@ -76,14 +77,8 @@ def align_image(self, detected_faces): image = self.pad_image(roi, face.image) face = image[roi[1]: roi[3], roi[0]: roi[2]] - if face.shape[0] < self.input_size: - interpolation = cv2.INTER_CUBIC # pylint:disable=no-member - else: - interpolation = cv2.INTER_AREA # pylint:disable=no-member - - face = cv2.resize(face, # pylint:disable=no-member - dsize=(int(self.input_size), int(self.input_size)), - interpolation=interpolation) + interpolation = cv2.INTER_CUBIC if face.shape[0] < self.input_size else cv2.INTER_AREA + face = cv2.resize(face, dsize=sizes, interpolation=interpolation) faces.append(face) rois.append(roi) return faces, rois @@ -127,11 +122,13 @@ def get_square_box(box): # Shift the box if any points fall below zero if left < 0: - right += abs(left) - left += abs(left) + shift_right = abs(left) + right += shift_right + left += shift_right if top < 0: - bottom += abs(top) - top += abs(top) + shift_down = abs(top) + bottom += shift_down + top += shift_down # Make sure box is always square. assert ((right - left) == (bottom - top)), 'Box is not square.' @@ -147,12 +144,12 @@ def pad_image(box, image): pad_r = box[2] - width if box[2] > width else 0 pad_b = box[3] - height if box[3] > height else 0 logger.trace("Padding: (l: %s, t: %s, r: %s, b: %s)", pad_l, pad_t, pad_r, pad_b) - retval = cv2.copyMakeBorder(image.copy(), # pylint: disable=no-member + retval = cv2.copyMakeBorder(image.copy(), pad_t, pad_b, pad_l, pad_r, - cv2.BORDER_CONSTANT, # pylint: disable=no-member + cv2.BORDER_CONSTANT, value=(0, 0, 0)) logger.trace("Padded shape: %s", retval.shape) return retval @@ -173,8 +170,7 @@ def process_output(self, batch): def get_pts_from_predict(batch): """ Get points from predictor """ for prediction, roi in zip(batch["prediction"], batch["roi"]): - points = np.array(prediction).flatten() - points = np.reshape(points, (-1, 2)) + points = np.reshape(prediction, (-1, 2)) points *= (roi[2] - roi[0]) points[:, 0] += roi[0] points[:, 1] += roi[1] diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index b68a885b42..0a2fb72f27 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -254,13 +254,13 @@ def _set_padding(self, image_size, scale): @staticmethod def _scale_image(image, image_size, scale): """ Scale the image and optional pad to given size """ - interpln = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA # pylint:disable=no-member + interpln = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA if scale != 1.0: dims = (int(image_size[1] * scale), int(image_size[0] * scale)) logger.trace("Resizing detection image from %s to %s. Scale=%s", "x".join(str(i) for i in reversed(image_size)), "x".join(str(i) for i in dims), scale) - image = cv2.resize(image, dims, interpolation=interpln) # pylint:disable=no-member + image = cv2.resize(image, dims, interpolation=interpln) logger.trace("Resized image shape: %s", image.shape) return image @@ -272,12 +272,12 @@ def _pad_image(self, image): pad_r = (self.input_size - width) - pad_l pad_t = (self.input_size - height) // 2 pad_b = (self.input_size - height) - pad_t - image = cv2.copyMakeBorder(image, # pylint:disable=no-member + image = cv2.copyMakeBorder(image, pad_t, pad_b, pad_l, pad_r, - cv2.BORDER_CONSTANT) # pylint:disable=no-member + cv2.BORDER_CONSTANT) logger.trace("Padded image shape: %s", image.shape) return image @@ -416,14 +416,11 @@ def _rotate_image_by_angle(self, image, angle): height, width = image.shape[:2] image_center = (width/2, height/2) - rotation_matrix = cv2.getRotationMatrix2D( # pylint: disable=no-member - image_center, -1.*angle, 1.) + rotation_matrix = cv2.getRotationMatrix2D(image_center, -1.*angle, 1.) rotation_matrix[0, 2] += self.input_size / 2 - image_center[0] rotation_matrix[1, 2] += self.input_size / 2 - image_center[1] logger.trace("Rotated image: (rotation_matrix: %s", rotation_matrix) - image = cv2.warpAffine(image, # pylint: disable=no-member - rotation_matrix, - (self.input_size, self.input_size)) + image = cv2.warpAffine(image, rotation_matrix, (self.input_size, self.input_size)) if channels_first: image = np.moveaxis(image, 2, 0) diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py index 230d6ca761..e60340ce98 100644 --- a/plugins/extract/detect/mtcnn.py +++ b/plugins/extract/detect/mtcnn.py @@ -247,8 +247,7 @@ def detect_pnet(self, images, height, width): rwidth, rheight = int(width * scale), int(height * scale) batch = np.empty((batch_items, rheight, rwidth, 3), dtype="float32") for idx in range(batch_items): - batch[idx, ...] = cv2.resize(images[idx, ...], # pylint:disable=no-member - (rwidth, rheight)) + batch[idx, ...] = cv2.resize(images[idx, ...], (rwidth, rheight)) output = self.pnet.predict(batch) cls_prob = output[0][..., 1] roi = output[1] @@ -281,7 +280,7 @@ def detect_rnet(self, images, rectangle_batch, height, width): predict_24_batch = [] for rect in rectangles: crop_img = image[int(rect[1]):int(rect[3]), int(rect[0]):int(rect[2])] - scale_img = cv2.resize(crop_img, (24, 24)) # pylint:disable=no-member + scale_img = cv2.resize(crop_img, (24, 24)) predict_24_batch.append(scale_img) crop_number += 1 predict_24_batch = np.array(predict_24_batch) @@ -308,7 +307,7 @@ def detect_onet(self, images, rectangle_batch, height, width): predict_batch = [] for rect in rectangles: crop_img = image[int(rect[1]):int(rect[3]), int(rect[0]):int(rect[2])] - scale_img = cv2.resize(crop_img, (48, 48)) # pylint:disable=no-member + scale_img = cv2.resize(crop_img, (48, 48)) predict_batch.append(scale_img) crop_number += 1 predict_batch = np.array(predict_batch) diff --git a/tools/preview.py b/tools/preview.py index 87595c09a5..5c33d0f872 100644 --- a/tools/preview.py +++ b/tools/preview.py @@ -408,7 +408,7 @@ def update_tk_image(self): self.build_faces_image() img = np.vstack((self.faces_source, self.faces_dest)) size = self.get_scale_size(img) - img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # pylint:disable=no-member + img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = Image.fromarray(img) img = img.resize(size, Image.ANTIALIAS) self.tk_image = ImageTk.PhotoImage(img) @@ -488,9 +488,9 @@ def header_text(self): """ Create header text for output image """ font_scale = self.size / 640 height = self.size // 8 - font = cv2.FONT_HERSHEY_SIMPLEX # pylint: disable=no-member + font = cv2.FONT_HERSHEY_SIMPLEX # Get size of placed text for positioning - text_sizes = [cv2.getTextSize(self.faces["filenames"][idx], # pylint: disable=no-member + text_sizes = [cv2.getTextSize(self.faces["filenames"][idx], font, font_scale, 1)[0] @@ -503,24 +503,20 @@ def header_text(self): self.faces["filenames"], text_sizes, text_x, text_y) header_box = np.ones((height, self.size * self.total_columns, 3), np.uint8) * 255 for idx, text in enumerate(self.faces["filenames"]): - cv2.putText(header_box, # pylint: disable=no-member + cv2.putText(header_box, text, (text_x[idx], text_y), font, font_scale, (0, 0, 0), 1, - lineType=cv2.LINE_AA) # pylint: disable=no-member + lineType=cv2.LINE_AA) logger.debug("header_box.shape: %s", header_box.shape) return header_box def draw_rect(self, image): """ draw border """ - cv2.rectangle(image, # pylint:disable=no-member - (0, 0), - (self.size - 1, self.size - 1), - (255, 255, 255), - 1) + cv2.rectangle(image, (0, 0), (self.size - 1, self.size - 1), (255, 255, 255), 1) image = np.clip(image, 0.0, 255.0) return image.astype("uint8") From 73ff840fbd68bc7737490217997a4e9e39fb7691 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 13 Nov 2019 19:16:25 +0000 Subject: [PATCH 130/981] scripts.extract - code optimizations Limit queue sizes to reduce RAM usage Rename lib.image.BackgroundIO to ImageIO Create separate ImagesLoader and ImagesSaver classes Load/Save images from centralized lib.image.ImageIO scripts.extract documentation --- docs/full/modules.rst | 1 + docs/full/scripts.extract.rst | 7 + docs/full/scripts.rst | 17 ++ lib/image.py | 417 +++++++++++++++++++++++++--------- plugins/extract/_base.py | 2 +- plugins/extract/pipeline.py | 8 +- scripts/extract.py | 376 +++++++++++++++--------------- tools/mask.py | 17 +- 8 files changed, 540 insertions(+), 305 deletions(-) create mode 100644 docs/full/scripts.extract.rst create mode 100644 docs/full/scripts.rst diff --git a/docs/full/modules.rst b/docs/full/modules.rst index 09bbbacf4f..0d40e863aa 100644 --- a/docs/full/modules.rst +++ b/docs/full/modules.rst @@ -6,4 +6,5 @@ faceswap lib plugins + scripts tools diff --git a/docs/full/scripts.extract.rst b/docs/full/scripts.extract.rst new file mode 100644 index 0000000000..6caa7b520e --- /dev/null +++ b/docs/full/scripts.extract.rst @@ -0,0 +1,7 @@ +scripts.extract +======================= + +.. automodule:: scripts.extract + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/scripts.rst b/docs/full/scripts.rst new file mode 100644 index 0000000000..baa53d1123 --- /dev/null +++ b/docs/full/scripts.rst @@ -0,0 +1,17 @@ +scripts package +=============== + +Subpackages +----------- + +.. toctree:: + + scripts.extract + +Module contents +--------------- + +.. automodule:: scripts + :members: + :undoc-members: + :show-inheritance: diff --git a/lib/image.py b/lib/image.py index b02add0daa..122552debd 100644 --- a/lib/image.py +++ b/lib/image.py @@ -338,143 +338,257 @@ def count_frames(filename, fast=False): return frames -class BackgroundIO(): +class ImageIO(): """ Perform disk IO for images or videos in a background thread. - Loads images or videos from a given location in a background thread. - Saves images to the given location in a background thread. - Images/Videos will be loaded or saved in deterministic order. + This is the parent thread for :class:`ImagesLoader` and :class:`ImagesSaver` and should not + be called directly. Parameters ---------- - path: str + path: str or list The path to load or save images to/from. For loading this can be a folder which contains - images or a video file. For saving this must be an existing folder. - task: {'load', 'save'} - The task to be performed. ``'load'`` to load images/video frames, ``'save'`` to save - images. - load_with_hash: bool, optional - When loading images, set to ``True`` to return the sha1 hash of the image along with the - image. Default: ``False``. + images, video file or a list of image files. For saving this must be an existing folder. + queue_size: int + The amount of images to hold in the internal buffer. + args: tuple, optional + The arguments to be passed to the loader or saver thread. Default: ``None`` + + See Also + -------- + lib.image.ImagesLoader : Background Image Loader inheriting from this class. + lib.image.ImagesSaver : Background Image Saver inheriting from this class. + """ + + def __init__(self, path, queue_size, args=None): + logger.debug("Initializing %s: (path: %s, queue_size: %s, args: %s)", + self.__class__.__name__, path, queue_size, args) + + self._args = tuple() if args is None else args + + self._location = path + self._check_location_exists() + + self._queue = queue_manager.get_queue(name=self.__class__.__name__, maxsize=queue_size) + self._thread = None + + @property + def location(self): + """ str: The folder or video that was passed in as the :attr:`path` parameter. """ + return self._location + + def _check_location_exists(self): + """ Check whether the input location exists. + + Raises + ------ + FaceswapError + If the given location does not exist + """ + if isinstance(self.location, str) and not os.path.exists(self.location): + raise FaceswapError("The location '{}' does not exist".format(self.location)) + if isinstance(self.location, (list, tuple)) and not all(os.path.exists(location) + for location in self.location): + raise FaceswapError("Not all locations in the input list exist") + + def _set_thread(self): + """ Set the load/save thread """ + if self._thread is not None: + return + self._thread = MultiThread(self._process, + self._queue, + name=self.__class__.__name__, + thread_count=1) + logger.trace(self._thread) + self._thread.start() + + def _process(self, queue): + """ Image IO process to be run in a thread. Override for loader/saver process. + + Parameters + ---------- + queue: queue.Queue() + The ImageIO Queue + """ + raise NotImplementedError + + def close(self): + """ Closes down and joins the internal threads """ + logger.debug("Received Close") + self._thread.join() + logger.debug("Closed") + + +class ImagesLoader(ImageIO): + """ Perform image loading from a folder of images or a video. + + Images will be loaded and returned in the order that they appear in the folder, or in the video + to ensure deterministic ordering. Loading occurs in a background thread, caching 8 images at a + time so that other processes do not need to wait on disk reads. + + See also :class:`ImageIO` for additional attributes. + + Parameters + ---------- + path: str or list + The path to load images from. This can be a folder which contains images a video file or a + list of image files. queue_size: int, optional - The amount of images to hold in the internal buffer. Default: 16. + The amount of images to hold in the internal buffer. Default: 8. + load_with_hash: bool, optional + Set to ``True`` to return the sha1 hash of the image along with the image. + Default: ``False``. + fast_count: bool, optional + When loading from video, the video needs to be parsed frame by frame to get an accurate + count. This can be done quite quickly without guaranteed accuracy, or slower with + guaranteed accuracy. Set to ``True`` to count quickly, or ``False`` to count slower + but accurately. Default: ``True``. + skip_list: list, optional + Optional list of frame/image indices to not load. Any indices provided here will be skipped + when reading images from the given location. Default: ``None`` Examples -------- Loading from a video file: - >>> loader = BackgroundIO('/path/to/video.mp4', 'load') + >>> loader = ImagesLoader('/path/to/video.mp4') >>> for filename, image in loader.load(): >>> Loading faces with their sha1 hash: - >>> loader = BackgroundIO('/path/to/faces/folder', 'load', load_with_hash=True) + >>> loader = ImagesLoader('/path/to/faces/folder', load_with_hash=True) >>> for filename, image, sha1_hash in loader.load(): >>> + """ - Saving out images: + def __init__(self, path, queue_size=8, load_with_hash=False, fast_count=True, skip_list=None): + logger.debug("Initializing %s: (path: %s, queue_size: %s, load_with_hash: %s, " + "fast_count: %s)", self.__class__.__name__, path, queue_size, + load_with_hash, fast_count) - >>> saver = BackgroundIO('/path/to/save/folder', 'save') - >>> for filename, image in : - >>> saver.save(filename, image) - >>> saver.close() - """ + args = (load_with_hash, ) + super().__init__(path, queue_size=queue_size, args=args) + self._skip_list = set() if skip_list is None else set(skip_list) - def __init__(self, path, task, load_with_hash=False, queue_size=16): - logger.debug("Initializing %s: (path: %s, task: %s, load_with_hash: %s, queue_size: %s)", - self.__class__.__name__, path, task, load_with_hash, queue_size) - self._location = path + self._is_video = self._check_for_video() - self._task = task.lower() - self._is_video = self._check_input() - self._input = self.location if self._is_video else get_image_paths(self.location) - self._count = count_frames(self._input) if self._is_video else len(self._input) - self._queue = queue_manager.get_queue(name="{}_{}".format(self.__class__.__name__, - self._task), - maxsize=queue_size) - self._thread = self._set_thread(io_args=(load_with_hash, )) - self._thread.start() + self._count = None + self._file_list = None + self._get_count_and_filelist(fast_count) @property def count(self): - """ int: The number of images or video frames to be processed """ + """ int: The number of images or video frames in the source location. This count includes + any files that will ultimately be skipped if a :attr:`skip_list` has been provided. See + also: :attr:`process_count`""" return self._count @property - def location(self): - """ str: The folder or video that was passed in as the :attr:`path` parameter. """ - return self._location + def process_count(self): + """ int: The number of images or video frames to be processed (IE the total count less + items that are to be skipped from the :attr:`skip_list`)""" + return self._count - len(self._skip_list) + + @property + def is_video(self): + """ bool: ``True`` if the input is a video, ``False`` if it is not """ + return self._is_video + + @property + def file_list(self): + """ list: A full list of files in the source location. This includes any files that will + ultimately be skipped if a :attr:`skip_list` has been provided. If the input is a video + then this is a list of dummy filenames as corresponding to an alignments file """ + return self._file_list - def _check_input(self): - """ Check whether the input path is valid and return if it is a video. + def add_skip_list(self, skip_list): + """ Add a skip list to this :class:`ImagesLoader` + + Parameters + ---------- + skip_list: list + A list of indices corresponding to the frame indices that should be skipped + """ + logger.debug(skip_list) + self._skip_list = set(skip_list) + + def _check_for_video(self): + """ Check whether the input is a video Returns ------- bool: 'True' if input is a video 'False' if it is a folder. - """ - if not os.path.exists(self.location): - raise FaceswapError("The location '{}' does not exist".format(self.location)) - if self._task == "save" and not os.path.isdir(self.location): - raise FaceswapError("The output location '{}' is not a folder".format(self.location)) + Raises + ------ + FaceswapError + If the given location is a file and does not have a valid video extension. - is_video = (self._task == "load" and - os.path.isfile(self.location) and - os.path.splitext(self.location)[1].lower() in _video_extensions) - if is_video: - logger.debug("Input is video") + """ + if os.path.isdir(self.location): + retval = False + elif os.path.splitext(self.location)[1].lower() in _video_extensions: + retval = True else: - logger.debug("Input is folder") - return is_video + raise FaceswapError("The input file '{}' is not a valid video".format(self.location)) + logger.debug("Input '%s' is_video: %s", self.location, retval) + return retval + + def _get_count_and_filelist(self, fast_count): + """ Set the count of images to be processed and set the file list - def _set_thread(self, io_args=None): - """ Set the load/save thread + If the input is a video, a dummy file list is created for checking against an + alignments file, otherwise it will be a list of full filenames. Parameters ---------- - io_args: tuple, optional - The arguments to be passed to the load or save thread. Default: `None`. - - Returns - ------- - :class:`lib.multithreading.MultiThread`: Thread containing the load/save function. + fast_count: bool + When loading from video, the video needs to be parsed frame by frame to get an accurate + count. This can be done quite quickly without guaranteed accuracy, or slower with + guaranteed accuracy. Set to ``True`` to count quickly, or ``False`` to count slower + but accurately. """ - io_args = (self._queue) if io_args is None else (self._queue, *io_args) - retval = MultiThread(getattr(self, "_{}".format(self._task)), *io_args, thread_count=1) - logger.trace(retval) - return retval + if self._is_video: + self._count = int(count_frames(self.location, fast=fast_count)) + self._file_list = [self._dummy_video_framename(i + 1) for i in range(self.count)] + else: + if isinstance(self.location, (list, tuple)): + self._file_list = self.location + else: + self._file_list = get_image_paths(self.location) + self._count = len(self.file_list) - # LOADING # - def _load(self, *args): + logger.debug("count: %s", self.count) + logger.trace("filelist: %s", self.file_list) + + def _process(self, queue): """ The load thread. Loads from a folder of images or from a video and puts to a queue Parameters ---------- - args: tuple - The arguments to be passed to the load iterator + queue: queue.Queue() + The ImageIO Queue """ - queue = args[0] - io_args = args[1:] - iterator = self._load_video if self._is_video else self._load_images + iterator = self._from_video if self._is_video else self._from_folder logger.debug("Load iterator: %s", iterator) - for retval in iterator(*io_args): + for retval in iterator(): + filename, image = retval[:2] + if image is None or (not image.any() and image.ndim not in (2, 3)): + # All black frames will return not np.any() so check dims too + logger.warning("Unable to open image. Skipping: '%s'", filename) + continue logger.trace("Putting to queue: %s", [v.shape if isinstance(v, np.ndarray) else v for v in retval]) queue.put(retval) logger.trace("Putting EOF") queue.put("EOF") - def _load_video(self, *args): # pylint:disable=unused-argument + def _from_video(self): """ Generator for loading frames from a video - Parameters - ---------- - args: tuple - Unused - Yields ------ filename: str @@ -482,24 +596,35 @@ def _load_video(self, *args): # pylint:disable=unused-argument image: numpy.ndarray The loaded video frame. """ - logger.debug("Loading frames from video: '%s'", self._input) - vidname = os.path.splitext(os.path.basename(self._input))[0] - reader = imageio.get_reader(self._input, "ffmpeg") - for i, frame in enumerate(reader): + logger.debug("Loading frames from video: '%s'", self.location) + reader = imageio.get_reader(self.location, "ffmpeg") + for idx, frame in enumerate(reader): + if idx in self._skip_list: + logger.trace("Skipping frame %s due to skip list") + continue # Convert to BGR for cv2 compatibility frame = frame[:, :, ::-1] - filename = "{}_{:06d}.png".format(vidname, i + 1) + filename = self._dummy_video_framename(idx + 1) logger.trace("Loading video frame: '%s'", filename) yield filename, frame reader.close() - def _load_images(self, with_hash): - """ Generator for loading images from a folder + def _dummy_video_framename(self, frame_no): + """ Return a dummy filename for video files Parameters ---------- - with_hash: bool - If ``True`` adds the sha1 hash to the output tuple as the final item. + frame_no: int + The frame number for the video frame + + Returns + ------- + str: A dummied filename for a video frame """ + vidname = os.path.splitext(os.path.basename(self.location))[0] + return "{}_{:06d}.png".format(vidname, frame_no + 1) + + def _from_folder(self): + """ Generator for loading images from a folder Yields ------ @@ -508,12 +633,16 @@ def _load_images(self, with_hash): image: numpy.ndarray The loaded image. sha1_hash: str, optional - The sha1 hash of the loaded image. Only yielded if :class:`BackgroundIO` was + The sha1 hash of the loaded image. Only yielded if :class:`ImageIO` was initialized with :attr:`load_with_hash` set to ``True`` and the :attr:`location` is a folder of images. """ - logger.debug("Loading images from folder: '%s'", self._input) - for filename in self._input: + with_hash = self._args[0] + logger.debug("Loading images from folder: '%s'. with_hash: %s", self.location, with_hash) + for idx, filename in enumerate(self.file_list): + if idx in self._skip_list: + logger.trace("Skipping frame %s due to skip list") + continue image_read = read_image(filename, raise_error=False, with_hash=with_hash) if with_hash: retval = filename, *image_read @@ -527,7 +656,7 @@ def _load_images(self, with_hash): def load(self): """ Generator for loading images from the given :attr:`location` - If :class:`BackgroundIO` was initialized with :attr:`load_with_hash` set to ``True`` then + If :class:`ImageIO` was initialized with :attr:`load_with_hash` set to ``True`` then the sha1 hash of the image is added as the final item in the output `tuple`. Yields @@ -537,10 +666,11 @@ def load(self): image: numpy.ndarray The loaded image. sha1_hash: str, optional - The sha1 hash of the loaded image. Only yielded if :class:`BackgroundIO` was + The sha1 hash of the loaded image. Only yielded if :class:`ImageIO` was initialized with :attr:`load_with_hash` set to ``True`` and the :attr:`location` is a folder of images. """ + self._set_thread() while True: self._thread.check_and_raise_error() try: @@ -555,25 +685,93 @@ def load(self): yield retval self._thread.join() - # SAVING # - @staticmethod - def _save(*args): + +class ImagesSaver(ImageIO): + """ Perform image saving to a destination folder. + + Images are saved in a background ThreadPoolExecutor to allow for concurrent saving. + See also :class:`ImageIO` for additional attributes. + + Parameters + ---------- + path: str + The folder to save images to. This must be an existing folder. + queue_size: int, optional + The amount of images to hold in the internal buffer. Default: 8. + as_bytes: bool, optional + ``True`` if the image is already encoded to bytes, ``False`` if the image is a + :class:`numpy.ndarray`. Default: ``False``. + + Examples + -------- + + >>> saver = ImagesSaver('/path/to/save/folder') + >>> for filename, image in : + >>> saver.save(filename, image) + >>> saver.close() + """ + + def __init__(self, path, queue_size=8, as_bytes=False): + logger.debug("Initializing %s: (path: %s, load_with_hash: %s, as_bytes: %s)", + self.__class__.__name__, path, queue_size, as_bytes) + + super().__init__(path, queue_size=queue_size) + self._as_bytes = as_bytes + + def _check_location_exists(self): + """ Check whether the output location exists and is a folder + + Raises + ------ + FaceswapError + If the given location does not exist or the location is not a folder + """ + if not isinstance(self.location, str): + raise FaceswapError("The output location must be a string not a " + "{}".format(type(self.location))) + super()._check_location_exists() + if not os.path.isdir(self.location): + raise FaceswapError("The output location '{}' is not a folder".format(self.location)) + + def _process(self, queue): """ Saves images from the save queue to the given :attr:`location` inside a thread. Parameters ---------- - args: tuple - The save arguments + queue: queue.Queue() + The ImageIO Queue """ - queue = args[0] + executor = futures.ThreadPoolExecutor(thread_name_prefix=self.__class__.__name__) while True: item = queue.get() if item == "EOF": logger.debug("EOF received") break - filename, image = item - logger.trace("Saving image: '%s'", filename) - cv2.imwrite(filename, image) + logger.trace("Submitting: '%s'", item[0]) + executor.submit(self._save, *item) + executor.shutdown() + + def _save(self, filename, image): + """ Save a single image inside a ThreadPoolExecutor + + Parameters + ---------- + filename: str + The filename of the image to be saved. Can include or exclude the folder location. + image: numpy.ndarray + The image to be saved + """ + if not os.path.commonprefix([self.location, filename]): + filename = os.path.join(self.location, filename) + try: + if self._as_bytes: + with open(filename, "wb") as out_file: + out_file.write(image) + else: + cv2.imwrite(filename, image) + logger.trace("Saved image: '%s'", filename) + except Exception as err: # pylint: disable=broad-except + logger.error("Failed to save image '%s'. Original Error: %s", filename, err) def save(self, filename, image): """ Save the given image in the background thread @@ -587,18 +785,13 @@ def save(self, filename, image): image: numpy.ndarray The image to be saved """ + self._set_thread() logger.trace("Putting to save queue: '%s'", filename) self._queue.put((filename, image)) def close(self): - """ Closes down and joins the internal threads - - Must be called after a :func:`save` operation to ensure all items are saved before the - parent process exits. - """ - logger.debug("Received Close") - if self._task == "save": - logger.debug("Putting EOF to save queue") - self._queue.put("EOF") - self._thread.join() - logger.debug("Closed") + """ Signal to the Save Threads that they should be closed and cleanly shutdown + the saver """ + logger.debug("Putting EOF to save queue") + self._queue.put("EOF") + super().close() diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 86d4bcc2ab..7e37a939fa 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -120,7 +120,7 @@ def __init__(self, git_model_id=None, model_filename=None, configfile=None): self.vram_per_batch = None # << THE FOLLOWING ARE SET IN self.initialize METHOD >> # - self.queue_size = 32 + self.queue_size = 1 """ int: Queue size for all internal queues. Set in :func:`initialize()` """ self.model = None diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 5ba84de7cd..dedb5ec451 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -70,7 +70,9 @@ def __init__(self, detector, aligner, masker, configfile=None, multiprocess, rotate_images, min_size, normalize_method, image_is_aligned) self._flow = self._set_flow(detector, aligner, masker) self.phase = self._flow[0] - self._queue_size = 32 + # We only ever need 1 item in each queue. This is 2 items cached (1 in queue 1 waiting + # for queue) at each point. Adding more just stacks RAM with no speed benefit. + self._queue_size = 1 self._vram_buffer = 256 # Leave a buffer for VRAM allocation self._detect = self._load_detect(detector, rotate_images, min_size, configfile) self._align = self._load_align(aligner, configfile, normalize_method) @@ -328,10 +330,6 @@ def _add_queues(self): tasks.append("extract_{}_out".format(self._final_phase)) for task in tasks: # Limit queue size to avoid stacking ram - self._queue_size = 32 - if task == "extract_{}_in".format(self._flow[0]) or (not self._is_parallel - and not task.endswith("_out")): - self._queue_size = 64 queue_manager.add_queue(task, maxsize=self._queue_size) queues[task] = queue_manager.get_queue(task) logger.debug("Queues: %s", queues) diff --git a/scripts/extract.py b/scripts/extract.py index 8979b1dc1b..b5c6be7719 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -1,128 +1,179 @@ #!/usr/bin python3 -""" The script to run the extract process of faceswap """ +""" Main entry point to the extract process of FaceSwap """ import logging import os import sys -from pathlib import Path from tqdm import tqdm -from lib.image import encode_image_with_hash +from lib.image import encode_image_with_hash, ImagesLoader, ImagesSaver from lib.multithreading import MultiThread -from lib.queue_manager import queue_manager from lib.utils import get_folder from plugins.extract.pipeline import Extractor -from scripts.fsmedia import Alignments, Images, PostProcess, Utils +from scripts.fsmedia import Alignments, PostProcess, Utils tqdm.monitor_interval = 0 # workaround for TqdmSynchronisationWarning logger = logging.getLogger(__name__) # pylint: disable=invalid-name class Extract(): - """ The extract process. """ + """ The Faceswap Face Extraction Process. + + The extraction process is responsible for detecting faces in a series of images/video, aligning + these faces and then generating a mask. + + It leverages a series of user selected plugins, chained together using + :mod:`plugins.extract.pipeline`. + + The extract process is self contained and should not be referenced by any other scripts, so it + contains no public properties. + + Parameters + ---------- + arguments: argparse.Namespace + The arguments to be passed to the extraction process as generated from Faceswap's command + line arguments + """ def __init__(self, arguments): logger.debug("Initializing %s: (args: %s", self.__class__.__name__, arguments) - self.args = arguments - Utils.set_verbosity(self.args.loglevel) - self.output_dir = get_folder(self.args.output_dir) - logger.info("Output Directory: %s", self.args.output_dir) - self.images = Images(self.args) - self.alignments = Alignments(self.args, True, self.images.is_video) - self.post_process = PostProcess(arguments) - configfile = self.args.configfile if hasattr(self.args, "configfile") else None - normalization = None if self.args.normalization == "none" else self.args.normalization - self.extractor = Extractor(self.args.detector, - self.args.aligner, - self.args.masker, - configfile=configfile, - multiprocess=not self.args.singleprocess, - rotate_images=self.args.rotate_images, - min_size=self.args.min_size, - normalize_method=normalization) - self.save_queue = queue_manager.get_queue("extract_save") - self.threads = list() - self.verify_output = False - self.save_interval = None - if hasattr(self.args, "save_interval"): - self.save_interval = self.args.save_interval + self._args = arguments + Utils.set_verbosity(self._args.loglevel) + + self._output_dir = str(get_folder(self._args.output_dir)) + + logger.info("Output Directory: %s", self._args.output_dir) + self._images = ImagesLoader(self._args.input_dir, load_with_hash=False, fast_count=True) + self._alignments = Alignments(self._args, True, self._images.is_video) + + self._existing_count = 0 + self._set_skip_list() + + self._post_process = PostProcess(arguments) + configfile = self._args.configfile if hasattr(self._args, "configfile") else None + normalization = None if self._args.normalization == "none" else self._args.normalization + self._extractor = Extractor(self._args.detector, + self._args.aligner, + self._args.masker, + configfile=configfile, + multiprocess=not self._args.singleprocess, + rotate_images=self._args.rotate_images, + min_size=self._args.min_size, + normalize_method=normalization) + self._threads = list() + self._verify_output = False logger.debug("Initialized %s", self.__class__.__name__) @property - def skip_num(self): - """ Number of frames to skip if extract_every_n is passed """ - return self.args.extract_every_n if hasattr(self.args, "extract_every_n") else 1 + def _save_interval(self): + """ int: The number of frames to be processed between each saving of the alignments file if + it has been provided, otherwise ``None`` """ + if hasattr(self._args, "save_interval"): + return self._args.save_interval + return None + + @property + def _skip_num(self): + """ int: Number of frames to skip if extract_every_n has been provided """ + return self._args.extract_every_n if hasattr(self._args, "extract_every_n") else 1 + + def _set_skip_list(self): + """ Add the skip list to the image loader + + Checks against `extract_every_n` and the existence of alignments data (can exist if + `skip_existing` or `skip_existing_faces` has been provided) and compiles a list of frame + indices that should not be processed, providing these to :class:`lib.image.ImagesLoader`. + """ + if self._skip_num == 1 and not self._alignments.data: + logger.debug("No frames to be skipped") + return + skip_list = [] + for idx, filename in enumerate(self._images.file_list): + if idx % self._skip_num != 0: + logger.trace("Adding image '%s' to skip list due to extract_every_n = %s", + filename, self._skip_num) + skip_list.append(idx) + # Items may be in the alignments file if skip-existing[-faces] is selected + elif os.path.basename(filename) in self._alignments.data: + self._existing_count += 1 + logger.trace("Removing image: '%s' due to previously existing", filename) + skip_list.append(idx) + if self._existing_count != 0: + logger.info("Skipping %s frames due to skip_existing/skip_existing_faces.", + self._existing_count) + logger.debug("Adding skip list: %s", skip_list) + self._images.add_skip_list(skip_list) def process(self): - """ Perform the extraction process """ + """ The entry point for triggering the Extraction Process. + + Should only be called from :class:`lib.cli.ScriptExecutor` + """ logger.info('Starting, this may take a while...') + # from lib.queue_manager import queue_manager # queue_manager.debug_monitor(3) - self.threaded_io("load") - self.threaded_io("save") - self.run_extraction() - for thread in self.threads: + self._threaded_redirector("load") + self._run_extraction() + for thread in self._threads: thread.join() - self.alignments.save() - Utils.finalize(self.images.images_found // self.skip_num, - self.alignments.faces_count, - self.verify_output) + self._alignments.save() + Utils.finalize(self._images.process_count + self._existing_count, + self._alignments.faces_count, + self._verify_output) - def threaded_io(self, task, io_args=None): - """ Perform I/O task in a background thread """ + def _threaded_redirector(self, task, io_args=None): + """ Redirect image input/output tasks to relevant queues in background thread + + Parameters + ---------- + task: str + The name of the task to be put into a background thread + io_args: tuple, optional + Any arguments that need to be provided to the background function + """ logger.debug("Threading task: (Task: '%s')", task) io_args = tuple() if io_args is None else (io_args, ) - if task == "load": - func = self.load_images - elif task == "save": - func = self.save_faces - elif task == "reload": - func = self.reload_images + func = getattr(self, "_{}".format(task)) io_thread = MultiThread(func, *io_args, thread_count=1) io_thread.start() - self.threads.append(io_thread) + self._threads.append(io_thread) + + def _load(self): + """ Load the images - def load_images(self): - """ Load the images """ + Loads images from :class:`lib.image.ImagesLoader`, formats them into a dict compatible + with :class:`plugins.extract.Pipeline.Extractor` and passes them into the extraction queue. + """ logger.debug("Load Images: Start") - load_queue = self.extractor.input_queue - idx = 0 - for filename, image in self.images.load(): - idx += 1 + load_queue = self._extractor.input_queue + for filename, image in self._images.load(): if load_queue.shutdown.is_set(): logger.debug("Load Queue: Stop signal received. Terminating") break - if idx % self.skip_num != 0: - logger.trace("Skipping image '%s' due to extract_every_n = %s", - filename, self.skip_num) - continue - if image is None or (not image.any() and image.ndim not in ((2, 3))): - # All black frames will return not np.any() so check dims too - logger.warning("Unable to open image. Skipping: '%s'", filename) - continue - imagename = os.path.basename(filename) - if imagename in self.alignments.data.keys(): - logger.trace("Skipping image: '%s'", filename) - continue item = {"filename": filename, "image": image[..., :3]} load_queue.put(item) load_queue.put("EOF") logger.debug("Load Images: Complete") - def reload_images(self, detected_faces): - """ Reload the images and pair to detected face """ + def _reload(self, detected_faces): + """ Reload the images and pair to detected face + + When the extraction pipeline is running in serial mode, images are reloaded from disk, + paired with their extraction data and passed back into the extraction queue + + Parameters + ---------- + detected_faces: dict + Dictionary of detected_faces with the filename as its key and a list of + :class:`lib.faces_detect.DetectedFace` as the values for pairing with reloaded images. + """ logger.debug("Reload Images: Start. Detected Faces Count: %s", len(detected_faces)) - load_queue = self.extractor.input_queue - idx = 0 - for filename, image in self.images.load(): - idx += 1 + load_queue = self._extractor.input_queue + for filename, image in self._images.load(): if load_queue.shutdown.is_set(): logger.debug("Reload Queue: Stop signal received. Terminating") break - if idx % self.skip_num != 0: - logger.trace("Skipping image '%s' due to extract_every_n = %s", - filename, self.skip_num) - continue logger.trace("Reloading image: '%s'", filename) detect_item = detected_faces.pop(filename, None) if not detect_item: @@ -133,130 +184,101 @@ def reload_images(self, detected_faces): load_queue.put("EOF") logger.debug("Reload Images: Complete") - def save_faces(self): - """ Save the generated faces """ - logger.debug("Save Faces: Start") - while True: - if self.save_queue.shutdown.is_set(): - logger.debug("Save Queue: Stop signal received. Terminating") - break - item = self.save_queue.get() - logger.trace(item) - if item == "EOF": - break - filename, face = item - - logger.trace("Saving face: '%s'", filename) - try: - with open(filename, "wb") as out_file: - out_file.write(face) - except Exception as err: # pylint: disable=broad-except - logger.error("Failed to save image '%s'. Original Error: %s", filename, err) - continue - logger.debug("Save Faces: Complete") - - def process_item_count(self): - """ Return the number of items to be processedd """ - processed = sum(os.path.basename(frame) in self.alignments.data.keys() - for frame in self.images.input_images) - logger.debug("Items already processed: %s", processed) - - if processed != 0 and self.args.skip_existing: - logger.info("Skipping previously extracted frames: %s", processed) - if processed != 0 and self.args.skip_faces: - logger.info("Skipping frames with detected faces: %s", processed) - - to_process = (self.images.images_found - processed) // self.skip_num - logger.debug("Items to be Processed: %s", to_process) - if to_process == 0: - logger.error("No frames to process. Exiting") - queue_manager.terminate_queues() - exit(0) - return to_process - - def run_extraction(self): - """ Run Face Detection """ - to_process = self.process_item_count() - size = self.args.size if hasattr(self.args, "size") else 256 + def _run_extraction(self): + """ The main Faceswap Extraction process + + Receives items from :class:`plugins.extract.Pipeline.Extractor` and either saves out the + faces and data (if on the final pass) or reprocesses data through the pipeline for serial + processing. + """ + size = self._args.size if hasattr(self._args, "size") else 256 + saver = ImagesSaver(self._output_dir, as_bytes=True) exception = False - for phase in range(self.extractor.passes): + for phase in range(self._extractor.passes): if exception: break - is_final = self.extractor.final_pass + is_final = self._extractor.final_pass detected_faces = dict() - self.extractor.launch() - self.check_thread_error() + self._extractor.launch() + self._check_thread_error() desc = "Running pass {} of {}: {}".format(phase + 1, - self.extractor.passes, - self.extractor.phase.title()) - status_bar = tqdm(self.extractor.detected_faces(), - total=to_process, + self._extractor.passes, + self._extractor.phase.title()) + status_bar = tqdm(self._extractor.detected_faces(), + total=self._images.process_count, file=sys.stdout, desc=desc) for idx, faces in enumerate(status_bar): - self.check_thread_error() + self._check_thread_error() exception = faces.get("exception", False) if exception: break - filename = faces["filename"] - if self.extractor.final_pass: - self.output_processing(faces, size, filename) - self.output_faces(filename, faces) - if self.save_interval and (idx + 1) % self.save_interval == 0: - self.alignments.save() + if self._extractor.final_pass: + self._output_processing(faces, size) + self._output_faces(saver, faces) + if self._save_interval and (idx + 1) % self._save_interval == 0: + self._alignments.save() else: del faces["image"] - detected_faces[filename] = faces status_bar.update(1) - if is_final: - logger.debug("Putting EOF to save") - self.save_queue.put("EOF") - else: + if not is_final: logger.debug("Reloading images") - self.threaded_io("reload", detected_faces) + self._threaded_redirector("reload", detected_faces) + saver.close() - def check_thread_error(self): - """ Check and raise thread errors """ - for thread in self.threads: + def _check_thread_error(self): + """ Check if any errors have occurred in the running threads and their errors """ + for thread in self._threads: thread.check_and_raise_error() - def output_processing(self, faces, size, filename): - """ Prepare faces for output """ - self.align_face(faces, size, filename) - self.post_process.do_actions(faces) + def _output_processing(self, faces, size): + """ Prepare faces for output + + Loads the aligned face, perform any processing actions and verify the output. + + Parameters: + faces: dict + Dictionary output from :class:`plugins.extract.Pipeline.Extractor` + size: int + The size that the aligned face should be created at + """ + for face in faces["detected_faces"]: + face.load_aligned(faces["image"], size=size) + + self._post_process.do_actions(faces) faces_count = len(faces["detected_faces"]) if faces_count == 0: - logger.verbose("No faces were detected in image: %s", os.path.basename(filename)) + logger.verbose("No faces were detected in image: %s", + os.path.basename(faces["filename"])) - if not self.verify_output and faces_count > 1: - self.verify_output = True + if not self._verify_output and faces_count > 1: + self._verify_output = True - def align_face(self, faces, size, filename): - """ Align the detected face and add the destination file path """ - final_faces = list() - image = faces["image"] - detected_faces = faces["detected_faces"] - for face in detected_faces: - face.load_aligned(image, size=size) - final_faces.append({"file_location": self.output_dir / Path(filename).stem, - "face": face}) - faces["detected_faces"] = final_faces - - def output_faces(self, filename, faces): - """ Output faces to save thread """ + def _output_faces(self, saver, faces): + """ Output faces to save thread + + Set the face filename based on the frame name and put the face to the + :class:`lib.image.ImagesSaver` save queue and add the face information to the alignments + data. + + Parameters + ---------- + saver: lib.images.ImagesSaver + The background saver for saving the image + faces: dict + The output dictionary from :class:`plugins.extract.Pipeline.Extractor` + """ + logger.debug("Save Faces: Start") final_faces = list() - for idx, detected_face in enumerate(faces["detected_faces"]): - output_file = detected_face["file_location"] - extension = Path(filename).suffix - out_filename = "{}_{}{}".format(str(output_file), str(idx), extension) - - face = detected_face["face"] - resized_face = face.aligned_face - face.hash, img = encode_image_with_hash(resized_face, extension) - self.save_queue.put((out_filename, img)) + filename, extension = os.path.splitext(os.path.basename(faces["filename"])) + for idx, face in enumerate(faces["detected_faces"]): + output_filename = "{}_{}{}".format(filename, str(idx), extension) + face.hash, image = encode_image_with_hash(face.aligned_face, extension) + + saver.save(output_filename, image) final_faces.append(face.to_alignment()) - self.alignments.data[os.path.basename(filename)] = final_faces + self._alignments.data[os.path.basename(faces["filename"])] = final_faces diff --git a/tools/mask.py b/tools/mask.py index c77e272a64..46c2184016 100644 --- a/tools/mask.py +++ b/tools/mask.py @@ -9,7 +9,7 @@ from lib.alignments import Alignments from lib.faces_detect import DetectedFace -from lib.image import BackgroundIO +from lib.image import ImagesLoader, ImagesSaver from lib.multithreading import MultiThread from lib.utils import set_system_verbosity, get_folder @@ -45,10 +45,7 @@ def __init__(self, arguments): self._check_input(arguments.input) self._saver = self._set_saver(arguments) - self._loader = BackgroundIO(arguments.input, - "load", - load_with_hash=self._input_is_faces, - queue_size=16) + self._loader = ImagesLoader(arguments.input, load_with_hash=self._input_is_faces) self._alignments = Alignments(os.path.dirname(arguments.alignments), filename=os.path.basename(arguments.alignments)) @@ -84,9 +81,9 @@ def _set_saver(self, arguments): Returns ------- - ``None`` or :class:`lib.image.BackgroundIO`: - If output is requested, returns a :class:`lib.image.BackgroundIO` in saver mode - otherwise returns ``None`` + ``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": @@ -96,7 +93,7 @@ def _set_saver(self, arguments): return None output_dir = str(get_folder(arguments.output, make_folder=True)) logger.info("Saving preview masks to: '%s'", output_dir) - saver = BackgroundIO(output_dir, "save", queue_size=16) + saver = ImagesSaver(output_dir) logger.debug(saver) return saver @@ -330,7 +327,7 @@ def _save(self, frame, idx, detected_face): self._mask_type, frame, idx) return image = self._create_image(detected_face) - logger.trace("filename: '%s', image_shape: %s", image.shape) + logger.trace("filename: '%s', image_shape: %s", filename, image.shape) self._saver.save(filename, image) def _create_image(self, detected_face): From baac4f53bfdcae348e5a273d0050b58ddccf9f9b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 13 Nov 2019 22:05:44 +0000 Subject: [PATCH 131/981] extract bugfixes - post_processing, correctly reference aligned face --- lib/image.py | 3 ++- scripts/fsmedia.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/image.py b/lib/image.py index 122552debd..642a3a347a 100644 --- a/lib/image.py +++ b/lib/image.py @@ -415,7 +415,8 @@ def _process(self, queue): def close(self): """ Closes down and joins the internal threads """ logger.debug("Received Close") - self._thread.join() + if self._thread is not None: + self._thread.join() logger.debug("Closed") diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 7de1e79280..62d53dbc43 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -314,7 +314,7 @@ def process(self, output_item): for idx, detected_face in enumerate(output_item["detected_faces"]): frame_name = detected_face["file_location"].parts[-1] - face = detected_face["face"] + face = detected_face.aligned_face logger.trace("Checking for blurriness. Frame: '%s', Face: %s", frame_name, idx) aligned_landmarks = face.aligned_landmarks resized_face = face.aligned_face @@ -362,7 +362,7 @@ class DebugLandmarks(PostProcessAction): # pylint: disable=too-few-public-metho def process(self, output_item): """ Draw landmarks on image """ for idx, detected_face in enumerate(output_item["detected_faces"]): - face = detected_face["face"] + face = detected_face.aligned_face logger.trace("Drawing Landmarks. Frame: '%s'. Face: %s", detected_face["file_location"].parts[-1], idx) aligned_landmarks = face.aligned_landmarks From 2921c2e51af9f07b0e08bfebc274588cd1246f2c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 14 Nov 2019 00:23:09 +0000 Subject: [PATCH 132/981] scripts.fsmedia - remove blurry face filter. Fix debug landmarks --- lib/cli.py | 12 -------- scripts/extract.py | 2 +- scripts/fsmedia.py | 72 +++------------------------------------------- 3 files changed, 5 insertions(+), 81 deletions(-) diff --git a/lib/cli.py b/lib/cli.py index a102044813..d014265a2b 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -667,18 +667,6 @@ def get_optional_arguments(): "recognition. Lower values are stricter. NB: Using face filter will " "significantly decrease extraction speed and its accuracy cannot be " "guaranteed."}) - argument_list.append({ - "opts": ("-bt", "--blur-threshold"), - "type": float, - "action": Slider, - "min_max": (0.0, 100.0), - "rounding": 1, - "dest": "blur_thresh", - "default": 0.0, - "group": "Face Processing", - "help": "Automatically discard images blurrier than the specified threshold. " - "Discarded images are moved into a \"blurry\" sub-folder. Lower values allow " - "more blur. Set to 0.0 to turn off."}) argument_list.append({ "opts": ("-een", "--extract-every-n"), "type": int, diff --git a/scripts/extract.py b/scripts/extract.py index b5c6be7719..bce58c16ea 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -272,7 +272,7 @@ def _output_faces(self, saver, faces): faces: dict The output dictionary from :class:`plugins.extract.Pipeline.Extractor` """ - logger.debug("Save Faces: Start") + logger.trace("Outputting faces for %s", faces["filename"]) final_faces = list() filename, extension = os.path.splitext(os.path.basename(faces["filename"])) for idx, face in enumerate(faces["detected_faces"]): diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 62d53dbc43..55f936b639 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -11,14 +11,11 @@ import cv2 import imageio -import numpy as np -from lib.aligner import Extract as AlignerExtract from lib.alignments import Alignments as AlignmentsBase from lib.face_filter import FaceFilter as FilterFunc from lib.image import count_frames, read_image -from lib.utils import (camel_case_split, get_folder, get_image_paths, set_system_verbosity, - _video_extensions) +from lib.utils import (camel_case_split, get_image_paths, set_system_verbosity, _video_extensions) logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -243,11 +240,6 @@ def get_items(self): and self.args.debug_landmarks): postprocess_items["DebugLandmarks"] = None - # Blurry Face - if hasattr(self.args, 'blur_thresh') and self.args.blur_thresh: - kwargs = {"blur_thresh": self.args.blur_thresh} - postprocess_items["BlurryFaceFilter"] = {"kwargs": kwargs} - # Face Filter post processing if ((hasattr(self.args, "filter") and self.args.filter is not None) or (hasattr(self.args, "nfilter") and @@ -300,71 +292,15 @@ def process(self, output_item): raise NotImplementedError -class BlurryFaceFilter(PostProcessAction): # pylint: disable=too-few-public-methods - """ Move blurry faces to a different folder - Extract Only """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.blur_thresh = kwargs["blur_thresh"] - logger.debug("Initialized %s", self.__class__.__name__) - - def process(self, output_item): - """ Detect and move blurry face """ - extractor = AlignerExtract() - - for idx, detected_face in enumerate(output_item["detected_faces"]): - frame_name = detected_face["file_location"].parts[-1] - face = detected_face.aligned_face - logger.trace("Checking for blurriness. Frame: '%s', Face: %s", frame_name, idx) - aligned_landmarks = face.aligned_landmarks - resized_face = face.aligned_face - size = face.aligned["size"] - padding = int(size * 0.1875) - feature_mask = extractor.get_feature_mask( - aligned_landmarks / size, - size, padding) - feature_mask = cv2.blur(feature_mask, (10, 10)) - isolated_face = cv2.multiply(feature_mask, resized_face.astype(float)).astype(np.uint8) - blurry, focus_measure = self.is_blurry(isolated_face) - - if blurry: - blur_folder = detected_face["file_location"].parts[:-1] - blur_folder = get_folder(Path(*blur_folder) / Path("blurry")) - detected_face["file_location"] = blur_folder / Path(frame_name) - logger.verbose("%s's focus measure of %s was below the blur threshold, " - "moving to 'blurry'", frame_name, "{0:.2f}".format(focus_measure)) - - def is_blurry(self, image): - """ Convert to grayscale, and compute the focus measure of the image using the - Variance of Laplacian method """ - gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) - focus_measure = self.variance_of_laplacian(gray) - - # if the focus measure is less than the supplied threshold, - # then the image should be considered "blurry" - retval = (focus_measure < self.blur_thresh, focus_measure) - logger.trace("Returning: (is_blurry: %s, focus_measure %s)", retval[0], retval[1]) - return retval - - @staticmethod - def variance_of_laplacian(image): - """ Compute the Laplacian of the image and then return the focus - measure, which is simply the variance of the Laplacian """ - retval = cv2.Laplacian(image, cv2.CV_64F).var() - logger.trace("Returning: %s", retval) - return retval - - class DebugLandmarks(PostProcessAction): # pylint: disable=too-few-public-methods """ Draw debug landmarks on face Extract Only """ def process(self, output_item): """ Draw landmarks on image """ - for idx, detected_face in enumerate(output_item["detected_faces"]): - face = detected_face.aligned_face - logger.trace("Drawing Landmarks. Frame: '%s'. Face: %s", - detected_face["file_location"].parts[-1], idx) + frame = os.path.splitext(os.path.basename(output_item["filename"]))[0] + for idx, face in enumerate(output_item["detected_faces"]): + logger.trace("Drawing Landmarks. Frame: '%s'. Face: %s", frame, idx) aligned_landmarks = face.aligned_landmarks for (pos_x, pos_y) in aligned_landmarks: cv2.circle(face.aligned_face, (pos_x, pos_y), 2, (0, 0, 255), -1) From 57b24dfedbca40d911dd94ebf8a3dad2df38ee04 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 14 Nov 2019 12:31:13 +0000 Subject: [PATCH 133/981] Bugfix: Extract - Fix serial processing --- lib/faces_detect.py | 2 +- lib/image.py | 10 +++++++--- lib/multithreading.py | 4 ++++ scripts/extract.py | 2 ++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/faces_detect.py b/lib/faces_detect.py index 65124828bb..85eb3807d6 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -580,7 +580,7 @@ def add(self, mask, affine_matrix, interpolator): """ logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, mask max: %s, " "affine_matrix: %s, interpolator: %s)", mask.shape, mask.dtype, mask.min(), - mask.max(), interpolator) + affine_matrix, mask.max(), interpolator) self._affine_matrix = self._adjust_affine_matrix(mask.shape[0], affine_matrix) self._interpolator = interpolator mask = (cv2.resize(mask, diff --git a/lib/image.py b/lib/image.py index 642a3a347a..0b26f24b26 100644 --- a/lib/image.py +++ b/lib/image.py @@ -393,13 +393,15 @@ def _check_location_exists(self): def _set_thread(self): """ Set the load/save thread """ - if self._thread is not None: + logger.debug("Setting thread") + if self._thread is not None and self._thread.is_alive(): + logger.debug("Thread pre-exists and is alive: %s", self._thread) return self._thread = MultiThread(self._process, self._queue, name=self.__class__.__name__, thread_count=1) - logger.trace(self._thread) + logger.debug("Set thread: %s", self._thread) self._thread.start() def _process(self, queue): @@ -578,7 +580,7 @@ def _process(self, queue): for retval in iterator(): filename, image = retval[:2] if image is None or (not image.any() and image.ndim not in (2, 3)): - # All black frames will return not np.any() so check dims too + # All black frames will return not numpy.any() so check dims too logger.warning("Unable to open image. Skipping: '%s'", filename) continue logger.trace("Putting to queue: %s", [v.shape if isinstance(v, np.ndarray) else v @@ -671,6 +673,7 @@ def load(self): initialized with :attr:`load_with_hash` set to ``True`` and the :attr:`location` is a folder of images. """ + logger.debug("Initializing Load Generator") self._set_thread() while True: self._thread.check_and_raise_error() @@ -684,6 +687,7 @@ def load(self): logger.trace("Yielding: %s", [v.shape if isinstance(v, np.ndarray) else v for v in retval]) yield retval + logger.debug("Closing Load Generator") self._thread.join() diff --git a/lib/multithreading.py b/lib/multithreading.py index 3fe141a429..58f72ebf4a 100644 --- a/lib/multithreading.py +++ b/lib/multithreading.py @@ -83,6 +83,10 @@ def check_and_raise_error(self): error = self.errors[0] raise error[1].with_traceback(error[2]) + def is_alive(self): + """ Return true if any thread is alive else false """ + return any(thread.is_alive() for thread in self._threads) + def start(self): """ Start a thread with the given method and args """ logger.debug("Starting thread(s): '%s'", self._name) diff --git a/scripts/extract.py b/scripts/extract.py index bce58c16ea..a348d12ce3 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -222,6 +222,8 @@ def _run_extraction(self): self._alignments.save() else: del faces["image"] + # cache detected faces for next run + detected_faces[faces["filename"]] = faces status_bar.update(1) if not is_final: From 79d127fa7e8d97f9efb2ea830317d52cb80eccea Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 14 Nov 2019 12:36:17 +0000 Subject: [PATCH 134/981] Update INSTALL.md --- INSTALL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/INSTALL.md b/INSTALL.md index e975c1c5ec..a211d8f8fa 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -95,7 +95,7 @@ Reboot your PC, so that everything you have just installed gets registered. - Give it the name: faceswap - **IMPORTANT**: Select python version 3.6 - Hit "Create" (NB: This may take a while as it will need to download Python 3.6) -![Anaconda virtual env setup](https://i.imgur.com/Tl5tyVq.png) +![Anaconda virtual env setup](https://i.imgur.com/59RHnLs.png) #### Entering your virtual environment To enter the virtual environment: From 64a400f6b4a27d15644b5e707615d8b0562514d7 Mon Sep 17 00:00:00 2001 From: kvrooman Date: Thu, 14 Nov 2019 06:49:47 -0600 Subject: [PATCH 135/981] Refine S3FD post-processing: NMS box voting (#902) --- plugins/extract/detect/s3fd.py | 97 ++++++++++++------------- plugins/extract/detect/s3fd_defaults.py | 2 +- 2 files changed, 48 insertions(+), 51 deletions(-) diff --git a/plugins/extract/detect/s3fd.py b/plugins/extract/detect/s3fd.py index 70456f20e6..a0f9188b18 100644 --- a/plugins/extract/detect/s3fd.py +++ b/plugins/extract/detect/s3fd.py @@ -220,28 +220,27 @@ def __init__(self, model_path, model_kwargs, allow_growth, confidence): super().__init__("S3FD", model_path, model_kwargs=model_kwargs, allow_growth=allow_growth) self.load_model() self.confidence = confidence + self.average_img = np.array([104.0, 117.0, 123.0]) logger.debug("Initialized: %s", self.__class__.__name__) - @staticmethod - def prepare_batch(batch): + def prepare_batch(self, batch): """ Prepare a batch for prediction """ - batch = batch - np.array([104.0, 117.0, 123.0]) + batch = batch - self.average_img batch = batch.transpose(0, 3, 1, 2) return batch - def finalize_predictions(self, bboxlists): + def finalize_predictions(self, bounding_boxes_scales): """ Detect faces """ ret = list() - for i in range(bboxlists[0].shape[0]): - bboxlist = [x[i:i+1, ...] for x in bboxlists] - bboxlist = self.post_process(bboxlist) - keep = self.nms(bboxlist, 0.3) - bboxlist = bboxlist[keep, :] - bboxlist = [x for x in bboxlist if x[-1] >= self.confidence] - ret.append(np.array(bboxlist)) + batch_size = range(bounding_boxes_scales[0].shape[0]) + for img in batch_size: + bboxlist = [scale[img:img+1] for scale in bounding_boxes_scales] + boxes = self._post_process(bboxlist) + bboxlist = self._nms(boxes, 0.5) + ret.append(bboxlist) return ret - def post_process(self, bboxlist): + def _post_process(self, bboxlist): """ Perform post processing on output TODO: do this on the batch. """ @@ -255,16 +254,14 @@ def post_process(self, bboxlist): for _, hindex, windex in poss: axc, ayc = stride / 2 + windex * stride, stride / 2 + hindex * stride score = ocls[0, 1, hindex, windex] - loc = np.ascontiguousarray(oreg[0, :, hindex, windex]).reshape((1, 4)) - priors = np.array([[axc / 1.0, ayc / 1.0, stride * 4 / 1.0, stride * 4 / 1.0]]) - variances = [0.1, 0.2] - box = self.decode(loc, priors, variances) - x_1, y_1, x_2, y_2 = box[0] * 1.0 - retval.append([x_1, y_1, x_2, y_2, score]) - retval = np.array(retval) - if len(retval) == 0: - retval = np.zeros((1, 5)) - return retval + if score >= self.confidence: + loc = np.ascontiguousarray(oreg[0, :, hindex, windex]).reshape((1, 4)) + priors = np.array([[axc / 1.0, ayc / 1.0, stride * 4 / 1.0, stride * 4 / 1.0]]) + box = self.decode(loc, priors) + x_1, y_1, x_2, y_2 = box[0] * 1.0 + retval.append([x_1, y_1, x_2, y_2, score]) + return_numpy = np.array(retval) if len(retval) != 0 else np.zeros((1, 5)) + return return_numpy @staticmethod def softmax(inp, axis): @@ -272,7 +269,7 @@ def softmax(inp, axis): return np.exp(inp - logsumexp(inp, axis=axis, keepdims=True)) @staticmethod - def decode(loc, priors, variances): + def decode(loc, priors): """Decode locations from predictions using priors to undo the encoding we did for offset regression at train time. Args: @@ -284,36 +281,36 @@ def decode(loc, priors, variances): Return: decoded bounding box predictions """ + variances = [0.1, 0.2] boxes = np.concatenate((priors[:, :2] + loc[:, :2] * variances[0] * priors[:, 2:], - priors[:, 2:] * np.exp(loc[:, 2:] * variances[1])), - 1) + priors[:, 2:] * np.exp(loc[:, 2:] * variances[1])), axis=1) boxes[:, :2] -= boxes[:, 2:] / 2 boxes[:, 2:] += boxes[:, :2] return boxes - @staticmethod - def nms(dets, thresh): - # pylint:disable=too-many-locals + def _nms(self, boxes, threshold): """ Perform Non-Maximum Suppression """ - keep = list() - if len(dets) == 0: - return keep - - x_1, y_1, x_2, y_2, scores = dets[:, 0], dets[:, 1], dets[:, 2], dets[:, 3], dets[:, 4] - areas = (x_2 - x_1 + 1) * (y_2 - y_1 + 1) - order = scores.argsort()[::-1] - - keep = [] - while order.size > 0: - i = order[0] - keep.append(i) - xx_1, yy_1 = np.maximum(x_1[i], x_1[order[1:]]), np.maximum(y_1[i], y_1[order[1:]]) - xx_2, yy_2 = np.minimum(x_2[i], x_2[order[1:]]), np.minimum(y_2[i], y_2[order[1:]]) - - width, height = np.maximum(0.0, xx_2 - xx_1 + 1), np.maximum(0.0, yy_2 - yy_1 + 1) - ovr = width * height / (areas[i] + areas[order[1:]] - width * height) - - inds = np.where(ovr <= thresh)[0] - order = order[inds + 1] - - return keep + retained_box_indices = list() + + areas = (boxes[:, 2] - boxes[:, 0] + 1) * (boxes[:, 3] - boxes[:, 1] + 1) + ranked_indices = boxes[:, 4].argsort()[::-1] + while ranked_indices.size > 0: + best = ranked_indices[0] + rest = ranked_indices[1:] + + max_of_xy = np.maximum(boxes[best, :2], boxes[rest, :2]) + min_of_xy = np.minimum(boxes[best, 2:4], boxes[rest, 2:4]) + width_height = np.maximum(0, min_of_xy - max_of_xy + 1) + intersection_areas = width_height[:, 0] * width_height[:, 1] + iou = intersection_areas / (areas[best] + areas[rest] - intersection_areas) + + overlapping_boxes = (iou > threshold).nonzero()[0] + if len(overlapping_boxes) != 0: + overlap_set = ranked_indices[overlapping_boxes + 1] + vote = np.average(boxes[overlap_set, :4], axis=0, weights=boxes[overlap_set, 4]) + boxes[best, :4] = vote + retained_box_indices.append(best) + + non_overlapping_boxes = (iou <= threshold).nonzero()[0] + ranked_indices = ranked_indices[non_overlapping_boxes + 1] + return boxes[retained_box_indices] diff --git a/plugins/extract/detect/s3fd_defaults.py b/plugins/extract/detect/s3fd_defaults.py index a0f78a3e05..3d65ad3383 100755 --- a/plugins/extract/detect/s3fd_defaults.py +++ b/plugins/extract/detect/s3fd_defaults.py @@ -51,7 +51,7 @@ _DEFAULTS = { "confidence": { - "default": 50, + "default": 70, "info": "The confidence level at which the detector has succesfully found a face.\n" "Higher levels will be more discriminating, lower levels will have more false " "positives.", From 2d229cee726c589996fa45d5f92894ad1d14485e Mon Sep 17 00:00:00 2001 From: kvrooman Date: Thu, 14 Nov 2019 06:54:29 -0600 Subject: [PATCH 136/981] Color channel sorting (#905) * Add Sort by Color Feature Sort by - Grayscale - Luma - Green to Red - Orange to Blue --- tools/cli.py | 17 ++++++++++++++--- tools/sort.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/tools/cli.py b/tools/cli.py index 31afc16b01..06291c0c0d 100644 --- a/tools/cli.py +++ b/tools/cli.py @@ -602,7 +602,8 @@ def get_argument_list(): "action": Radio, "type": str, "choices": ("blur", "face", "face-cnn", "face-cnn-dissim", - "face-yaw", "hist", "hist-dissim"), + "face-yaw", "hist", "hist-dissim", "color-gray", + "color-luma", "color-green", "color-orange"), "dest": 'sort_method', "group": "sort settings", "default": "face", @@ -623,8 +624,18 @@ def get_argument_list(): "\nL|'hist': Sort faces by their color histogram. You can " "adjust the threshold with the '-t' (--ref_threshold) " "option." - "\nL|'hist-dissim': Like 'hist' but sorts by " - "dissimilarity." + "\nL|'hist-dissim': Like 'hist' but sorts by dissimilarity." + "\nL|'color-gray': Sort images by the average intensity of " + "the converted grayscale color channel." + "\nL|'color-luma': Sort images by the average intensity of " + "the converted Y color channel. Bright lighting and " + "oversaturated images will be ranked first." + "\nL|'color-green': Sort images by the average intensity of " + "the converted Cg color channel. Green images will be " + "ranked first and red images will be last." + "\nL|'color-orange': Sort images by the average intensity " + "of the converted Co color channel. Orange images will be " + "ranked first and blue images will be last." "\nDefault: hist"}) argument_list.append({"opts": ('-k', '--keep'), "action": 'store_true', diff --git a/tools/sort.py b/tools/sort.py index c977d66933..f96087155f 100644 --- a/tools/sort.py +++ b/tools/sort.py @@ -76,6 +76,9 @@ def process(self): _sort = "sort_" + self.args.sort_method.lower() _group = "group_" + self.args.group_method.lower() _final = "final_process_" + self.args.final_process.lower() + if _sort.startswith('sort_color-'): + self.args.color_method = _sort.replace('sort_color-', '') + _sort = _sort[:10] self.args.sort_method = _sort.replace('-', '_') self.args.group_method = _group.replace('-', '_') self.args.final_process = _final.replace('-', '_') @@ -286,6 +289,31 @@ def sort_hist_dissim(self): img_list = sorted(img_list, key=operator.itemgetter(2), reverse=True) return img_list + def sort_color(self): + """ Score by channel average intensity """ + logger.info("Sorting by channel average intensity...") + desired_channel = {'gray': 0, 'luma': 0, 'orange': 1, 'green': 2} + method = self.args.color_method + channel_to_sort = next(v for (k, v) in desired_channel.items() if method.endswith(k)) + filename_list, image_list = self._get_images() + + logger.info("Converting to appropriate colorspace...") + same_size = all(img.size == image_list[0].size for img in image_list) + images = np.array(image_list, dtype='float32')[None, ...] if same_size else image_list + converted_images = self._convert_color(images, same_size, method) + + logger.info("Scoring each image...") + if same_size: + scores = np.average(converted_images[0], axis=(1, 2)) + else: + progress_bar = tqdm(converted_images, desc="Scoring", file=sys.stdout) + scores = np.array([np.average(image, axis=(0, 1)) for image in progress_bar]) + + logger.info("Sorting...") + matched_list = list(zip(filename_list, scores[:, channel_to_sort])) + sorted_file_img_list = sorted(matched_list, key=operator.itemgetter(1), reverse=True) + return sorted_file_img_list + # Methods for grouping def group_blur(self, img_list): """ Group into bins by blur """ @@ -536,6 +564,26 @@ def reload_images(self, group_method, img_list): return self.splice_lists(img_list, temp_list) + def _convert_color(self, imgs, same_size, method): + """ Helper function to convert colorspaces """ + + if method.endswith('gray'): + conversion = np.array([[0.0722], [0.7152], [0.2126]]) + else: + conversion = np.array([[0.25, 0.5, 0.25], [-0.5, 0.0, 0.5], [-0.25, 0.5, -0.25]]) + + if same_size: + path = 'greedy' + operation = 'bijk, kl -> bijl' if method.endswith('gray') else 'bijl, kl -> bijk' + else: + operation = 'ijk, kl -> ijl' if method.endswith('gray') else 'ijl, kl -> ijk' + path = np.einsum_path(operation, imgs[0][..., :3], conversion, optimize='optimal')[0] + + progress_bar = tqdm(imgs, desc="Converting", file=sys.stdout) + images = [np.einsum(operation, img[..., :3], conversion, optimize=path).astype('float32') + for img in progress_bar] + return images + @staticmethod def splice_lists(sorted_list, new_vals_list): """ From 36be6cd4d832471671d670f9f6c29f47aba07ebc Mon Sep 17 00:00:00 2001 From: kvrooman Date: Thu, 14 Nov 2019 07:01:35 -0600 Subject: [PATCH 137/981] Vectorize FAN post-processing (#926) --- plugins/extract/align/_base.py | 3 +- plugins/extract/align/fan.py | 180 ++++++++++++++------------------- 2 files changed, 79 insertions(+), 104 deletions(-) diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index 903afa3250..0d2e5d5e3f 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -245,8 +245,7 @@ def _normalize_hist(face): @staticmethod def _normalize_clahe(face): """ Perform Contrast Limited Adaptive Histogram Equalization """ - clahe = cv2.createCLAHE(clipLimit=2.0, - tileGridSize=(4, 4)) + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(4, 4)) for chan in range(3): face[:, :, chan] = clahe.apply(face[:, :, chan]) return face diff --git a/plugins/extract/align/fan.py b/plugins/extract/align/fan.py index bee87643f6..02bf34ef26 100644 --- a/plugins/extract/align/fan.py +++ b/plugins/extract/align/fan.py @@ -25,7 +25,7 @@ def __init__(self, **kwargs): self.vram_warnings = 512 # Will run at this with warnings self.vram_per_batch = 64 self.batchsize = self.config["batch-size"] - self.reference_scale = 195 + self.reference_scale = 200. / 195. def init_model(self): """ Initialize FAN model """ @@ -36,14 +36,13 @@ def init_model(self): allow_growth=self.config["allow_growth"]) self.model.load_model() # Feed a placeholder so Aligner is primed for Manual tool - placeholder = np.zeros((self.batchsize, 3, self.input_size, self.input_size), - dtype="float32") + placeholder_shape = (self.batchsize, 3, self.input_size, self.input_size) + placeholder = np.zeros(placeholder_shape, dtype="float32") self.model.predict(placeholder) def process_input(self, batch): """ Compile the detected faces for prediction """ - # TODO Batching - logger.trace("Aligning faces around center") + logger.debug("Aligning faces around center") batch["center_scale"] = self.get_center_scale(batch["detected_faces"]) faces = self.crop(batch) logger.trace("Aligned image around center") @@ -53,89 +52,74 @@ def process_input(self, batch): def get_center_scale(self, detected_faces): """ Get the center and set scale of bounding box """ - logger.trace("Calculating center and scale") - l_center = [] - l_scale = [] - for face in detected_faces: - center = np.array([(face.left + face.right) / 2.0, (face.top + face.bottom) / 2.0]) - center[1] -= face.h * 0.12 - l_center.append(center) - l_scale.append((face.w + face.h) / self.reference_scale) - logger.trace("Calculated center and scale: %s, %s", l_center, l_scale) - return l_center, l_scale + logger.debug("Calculating center and scale") + center_scale = np.empty((len(detected_faces), 68, 3), dtype='float32') + # TODO modify detected face to hold this data as a matrix + for index, face in enumerate(detected_faces): + x_center = (face.left + face.right) / 2.0 + y_center = (face.top + face.bottom) / 2.0 - face.h * 0.12 + scale = (face.w + face.h) * self.reference_scale + center_scale[index, :, 0] = np.full(68, x_center, dtype='float32') + center_scale[index, :, 1] = np.full(68, y_center, dtype='float32') + center_scale[index, :, 2] = np.full(68, scale, dtype='float32') + logger.trace("Calculated center and scale: %s, %s", center_scale) + return center_scale def crop(self, batch): # pylint:disable=too-many-locals """ Crop image around the center point """ - logger.trace("Cropping images") + logger.debug("Cropping images") + sizes = (self.input_size, self.input_size) + batch_shape = batch["center_scale"].shape[:2] + resolutions = np.full(batch_shape, self.input_size, dtype='float32') + matrix_ones = np.ones(batch_shape + (3,), dtype='float32') + matrix_size = np.full(batch_shape + (3,), self.input_size, dtype='float32') + matrix_size[..., 2] = 1.0 + upper_left = self.transform(matrix_ones, batch["center_scale"], resolutions) + bot_right = self.transform(matrix_size, batch["center_scale"], resolutions) + + # TODO second pass .. convert to matrix new_images = [] - for face, center, scale in zip(batch["detected_faces"], *batch["center_scale"]): - is_color = face.image.ndim > 2 - v_ul = self.transform([1, 1], center, scale, self.input_size).astype(np.int) - v_br = self.transform([self.input_size, self.input_size], - center, - scale, - self.input_size).astype(np.int) - if is_color: - new_dim = np.array([v_br[1] - v_ul[1], - v_br[0] - v_ul[0], - face.image.shape[2]], - dtype=np.int32) - new_img = np.zeros(new_dim, dtype=np.uint8) - else: - new_dim = np.array([v_br[1] - v_ul[1], - v_br[0] - v_ul[0]], - dtype=np.int) - new_img = np.zeros(new_dim, dtype=np.uint8) - height = face.image.shape[0] - width = face.image.shape[1] - new_x = np.array([max(1, -v_ul[0] + 1), min(v_br[0], width) - v_ul[0]], - dtype=np.int32) - new_y = np.array([max(1, -v_ul[1] + 1), - min(v_br[1], height) - v_ul[1]], - dtype=np.int32) - old_x = np.array([max(1, v_ul[0] + 1), min(v_br[0], width)], - dtype=np.int32) - old_y = np.array([max(1, v_ul[1] + 1), min(v_br[1], height)], - dtype=np.int32) - if is_color: - new_img[new_y[0] - 1:new_y[1], - new_x[0] - 1:new_x[1]] = face.image[old_y[0] - 1:old_y[1], - old_x[0] - 1:old_x[1], :] - else: - new_img[new_y[0] - 1:new_y[1], - new_x[0] - 1:new_x[1]] = face.image[old_y[0] - 1:old_y[1], - old_x[0] - 1:old_x[1]] - - if new_img.shape[0] < self.input_size: - interpolation = cv2.INTER_CUBIC # pylint:disable=no-member - else: - interpolation = cv2.INTER_AREA # pylint:disable=no-member - - new_images.append(cv2.resize(new_img, # pylint:disable=no-member - dsize=(int(self.input_size), int(self.input_size)), - interpolation=interpolation)) + for face, ul, br in zip(batch["detected_faces"], upper_left, bot_right): + height, width = face.image.shape[:2] + channels = 3 if face.image.ndim > 2 else 1 + br_width, br_height = br[0].astype('int32') + ul_width, ul_height = ul[0].astype('int32') + new_dim = (br_height - ul_height, br_width - ul_width, channels) + new_img = np.empty(new_dim, dtype=np.uint8) + + new_x = slice(max(0, -ul_width), min(br_width, width) - ul_width) + new_y = slice(max(0, -ul_height), min(br_height, height) - ul_height) + old_x = slice(max(0, ul_width), min(br_width, width)) + old_y = slice(max(0, ul_height), min(br_height, height)) + new_img[new_y, new_x] = face.image[old_y, old_x] + + interp = cv2.INTER_CUBIC if new_dim[0] < self.input_size else cv2.INTER_AREA + new_images.append(cv2.resize(new_img, dsize=sizes, interpolation=interp)) logger.trace("Cropped images") return new_images @staticmethod - def transform(point, center, scale, resolution): + def transform(points, center_scales, resolutions): """ Transform Image """ - logger.trace("Transforming Points") - pnt = np.array([point[0], point[1], 1.0]) - hscl = 200.0 * scale - eye = np.eye(3) - eye[0, 0] = resolution / hscl - eye[1, 1] = resolution / hscl - eye[0, 2] = resolution * (-center[0] / hscl + 0.5) - eye[1, 2] = resolution * (-center[1] / hscl + 0.5) - eye = np.linalg.inv(eye) - retval = np.matmul(eye, pnt)[0:2] + logger.debug("Transforming Points") + num_images, num_landmarks = points.shape[:2] + transform_matrix = np.eye(3, dtype='float32') + transform_matrix = np.repeat(transform_matrix[None, :], num_landmarks, axis=0) + transform_matrix = np.repeat(transform_matrix[None, :, :], num_images, axis=0) + scales = center_scales[:, :, 2] / resolutions + translations = center_scales[..., 2:3] * -0.5 + center_scales[..., :2] + transform_matrix[:, :, 0, 0] = scales # x scale + transform_matrix[:, :, 1, 1] = scales # y scale + transform_matrix[:, :, 0, 2] = translations[:, :, 0] # x translation + transform_matrix[:, :, 1, 2] = translations[:, :, 1] # y translation + new_points = np.einsum('abij, abj -> abi', transform_matrix, points, optimize='greedy') + retval = new_points[:, :, :2].astype('float32') logger.trace("Transformed Points: %s", retval) return retval def predict(self, batch): """ Predict the 68 point landmarks """ - logger.trace("Predicting Landmarks") + logger.debug("Predicting Landmarks") batch["prediction"] = self.model.predict(batch["feed"])[-1] logger.trace([pred.shape for pred in batch["prediction"]]) return batch @@ -147,34 +131,26 @@ def process_output(self, batch): def get_pts_from_predict(self, batch): """ Get points from predictor """ - logger.trace("Obtain points from prediction") - landmarks = [] - for prediction, center, scale in zip(batch["prediction"], *batch["center_scale"]): - var_b = prediction.reshape((prediction.shape[0], - prediction.shape[1] * prediction.shape[2])) - var_c = var_b.argmax(1).reshape((prediction.shape[0], - 1)).repeat(2, - axis=1).astype(np.float) - var_c[:, 0] %= prediction.shape[2] - var_c[:, 1] = np.apply_along_axis( - lambda x: np.floor(x / prediction.shape[2]), - 0, - var_c[:, 1]) - - for i in range(prediction.shape[0]): - pt_x, pt_y = int(var_c[i, 0]), int(var_c[i, 1]) - if 63 > pt_x > 0 and 63 > pt_y > 0: - diff = np.array([prediction[i, pt_y, pt_x+1] - - prediction[i, pt_y, pt_x-1], - prediction[i, pt_y+1, pt_x] - - prediction[i, pt_y-1, pt_x]]) - - var_c[i] += np.sign(diff)*0.25 - - var_c += 0.5 - landmarks = [self.transform(var_c[i], center, scale, prediction.shape[2]) - for i in range(prediction.shape[0])] - batch.setdefault("landmarks", []).append(landmarks) + logger.debug("Obtain points from prediction") + num_images, num_landmarks, height, width = batch["prediction"].shape + image_slice = np.repeat(np.arange(num_images)[:, None], num_landmarks, axis=1) + landmark_slice = np.repeat(np.arange(num_landmarks)[None, :], num_images, axis=0) + resolution = np.full((num_images, num_landmarks), 64, dtype='int32') + subpixel_landmarks = np.ones((num_images, num_landmarks, 3), dtype='float32') + + flat_indices = batch["prediction"].reshape(num_images, num_landmarks, -1).argmax(-1) + indices = np.array(np.unravel_index(flat_indices, (height, width))) + offsets = [(image_slice, landmark_slice, indices[0], indices[1] + 1), + (image_slice, landmark_slice, indices[0], indices[1] - 1), + (image_slice, landmark_slice, indices[0] + 1, indices[1]), + (image_slice, landmark_slice, indices[0] - 1, indices[1])] + x_subpixel_shift = batch["prediction"][offsets[0]] - batch["prediction"][offsets[1]] + y_subpixel_shift = batch["prediction"][offsets[2]] - batch["prediction"][offsets[3]] + # TODO improve rudimentary subpixel logic to centroid of 3x3 window algorithm + subpixel_landmarks[:, :, 0] = indices[1] + np.sign(x_subpixel_shift) * 0.25 + 0.5 + subpixel_landmarks[:, :, 1] = indices[0] + np.sign(y_subpixel_shift) * 0.25 + 0.5 + + batch["landmarks"] = self.transform(subpixel_landmarks, batch["center_scale"], resolution) logger.trace("Obtained points from prediction: %s", batch["landmarks"]) From 47681a8babe52ff3c315bb59e897fafab5800b85 Mon Sep 17 00:00:00 2001 From: kvrooman Date: Fri, 15 Nov 2019 05:01:37 -0600 Subject: [PATCH 138/981] Landmarks stored and used as floating point numbers (#928) * remove and fix int adjustments * masking rounding --- lib/aligner.py | 40 +++++++++++--------------- lib/faces_detect.py | 2 +- lib/model/masks.py | 2 +- lib/training_data.py | 5 ++-- plugins/extract/align/_base.py | 2 +- plugins/extract/mask/components.py | 4 +-- plugins/extract/mask/extended.py | 4 +-- plugins/extract/mask/unet_dfl.py | 2 +- plugins/extract/mask/vgg_clear.py | 2 +- plugins/extract/mask/vgg_obstructed.py | 2 +- scripts/fsmedia.py | 3 +- tools/lib_alignments/annotate.py | 22 ++++++-------- tools/lib_alignments/jobs.py | 4 +-- 13 files changed, 42 insertions(+), 52 deletions(-) diff --git a/lib/aligner.py b/lib/aligner.py index e1f595c1aa..d6abb7a612 100644 --- a/lib/aligner.py +++ b/lib/aligner.py @@ -38,8 +38,7 @@ def transform(self, image, mat, size, padding=0): logger.trace("matrix: %s, size: %s. padding: %s", mat, size, padding) matrix = self.transform_matrix(mat, size, padding) interpolators = get_matrix_scaling(matrix) - retval = cv2.warpAffine(image, # pylint: disable=no-member - matrix, (size, size), flags=interpolators[0]) + retval = cv2.warpAffine(image, matrix, (size, size), flags=interpolators[0]) return retval def transform_points(self, points, mat, size, padding=0): @@ -47,8 +46,7 @@ def transform_points(self, points, mat, size, padding=0): logger.trace("points: %s, matrix: %s, size: %s. padding: %s", points, mat, size, padding) matrix = self.transform_matrix(mat, size, padding) points = np.expand_dims(points, axis=1) - points = cv2.transform(points, # pylint: disable=no-member - matrix, points.shape) + points = cv2.transform(points, matrix, points.shape) retval = np.squeeze(points) logger.trace("Returning: %s", retval) return retval @@ -59,9 +57,9 @@ def get_original_roi(self, mat, size, padding=0): matrix = self.transform_matrix(mat, size, padding) points = np.array([[0, 0], [0, size - 1], [size - 1, size - 1], [size - 1, 0]], np.int32) points = points.reshape((-1, 1, 2)) - matrix = cv2.invertAffineTransform(matrix) # pylint: disable=no-member + matrix = cv2.invertAffineTransform(matrix) logger.trace("Returning: (points: %s, matrix: %s", points, matrix) - return cv2.transform(points, matrix) # pylint: disable=no-member + return cv2.transform(points, matrix) @staticmethod def get_feature_mask(aligned_landmarks_68, size, padding=0, dilation=30): @@ -72,7 +70,7 @@ def get_feature_mask(aligned_landmarks_68, size, padding=0, dilation=30): translation = padding pad_mat = np.matrix([[scale, 0.0, translation], [0.0, scale, translation]]) aligned_landmarks_68 = np.expand_dims(aligned_landmarks_68, axis=1) - aligned_landmarks_68 = cv2.transform(aligned_landmarks_68, # pylint: disable=no-member + aligned_landmarks_68 = cv2.transform(aligned_landmarks_68, pad_mat, aligned_landmarks_68.shape) aligned_landmarks_68 = np.squeeze(aligned_landmarks_68) @@ -85,26 +83,22 @@ def get_feature_mask(aligned_landmarks_68, size, padding=0, dilation=30): mouth_points = aligned_landmarks_68[48:68].tolist() # TODO remove excessive reshapes and flattens - l_eye = np.array(l_eye_points + l_brow_points).reshape((-1, 2)).astype(int).flatten() - r_eye = np.array(r_eye_points + r_brow_points).reshape((-1, 2)).astype(int).flatten() + l_eye = np.array(l_eye_points + l_brow_points).reshape((-1, 2)).astype('int32').flatten() + r_eye = np.array(r_eye_points + r_brow_points).reshape((-1, 2)).astype('int32').flatten() mouth = np.array(mouth_points + nose_points + chin_points) - mouth = mouth.reshape((-1, 2)).astype(int).flatten() - l_eye_hull = cv2.convexHull(l_eye.reshape((-1, 2))) # pylint: disable=no-member - r_eye_hull = cv2.convexHull(r_eye.reshape((-1, 2))) # pylint: disable=no-member - mouth_hull = cv2.convexHull(mouth.reshape((-1, 2))) # pylint: disable=no-member + mouth = mouth.reshape((-1, 2)).astype('int32').flatten() + l_eye_hull = cv2.convexHull(l_eye.reshape((-1, 2))) + r_eye_hull = cv2.convexHull(r_eye.reshape((-1, 2))) + mouth_hull = cv2.convexHull(mouth.reshape((-1, 2))) mask = np.zeros((size, size, 3), dtype=float) - cv2.fillConvexPoly(mask, # pylint: disable=no-member - l_eye_hull, (1, 1, 1)) - cv2.fillConvexPoly(mask, # pylint: disable=no-member - r_eye_hull, (1, 1, 1)) - cv2.fillConvexPoly(mask, # pylint: disable=no-member - mouth_hull, (1, 1, 1)) + cv2.fillConvexPoly(mask, l_eye_hull, (1, 1, 1)) + cv2.fillConvexPoly(mask, r_eye_hull, (1, 1, 1)) + cv2.fillConvexPoly(mask, mouth_hull, (1, 1, 1)) if dilation > 0: kernel = np.ones((dilation, dilation), np.uint8) - mask = cv2.dilate(mask, # pylint: disable=no-member - kernel, iterations=1) + mask = cv2.dilate(mask, kernel, iterations=1) logger.trace("Returning: %s", mask) return mask @@ -116,9 +110,9 @@ def get_matrix_scaling(mat): y_scale = (mat[0, 0] * mat[1, 1] - mat[0, 1] * mat[1, 0]) / x_scale avg_scale = (x_scale + y_scale) * 0.5 if avg_scale >= 1.: - interpolators = cv2.INTER_CUBIC, cv2.INTER_AREA # pylint: disable=no-member + interpolators = cv2.INTER_CUBIC, cv2.INTER_AREA else: - interpolators = cv2.INTER_AREA, cv2.INTER_CUBIC # pylint: disable=no-member + interpolators = cv2.INTER_AREA, cv2.INTER_CUBIC logger.trace("interpolator: %s, inverse interpolator: %s", interpolators[0], interpolators[1]) return interpolators diff --git a/lib/faces_detect.py b/lib/faces_detect.py index 85eb3807d6..e138b1a04e 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -193,7 +193,7 @@ def from_alignment(self, alignment, image=None): self.h = alignment["h"] landmarks = alignment["landmarks_xy"] if not isinstance(landmarks, np.ndarray): - landmarks = np.array(landmarks, dtype="int32") + landmarks = np.array(landmarks, dtype="float32") self.landmarks_xy = landmarks # Manual tool does not know the final hash so default to None self.hash = alignment.get("hash", None) diff --git a/lib/model/masks.py b/lib/model/masks.py index d7c0d68fdd..d3693b4ccd 100644 --- a/lib/model/masks.py +++ b/lib/model/masks.py @@ -41,7 +41,7 @@ class Mask(): def __init__(self, landmarks, face, channels=4): logger.trace("Initializing %s: (face_shape: %s, channels: %s, landmarks: %s)", self.__class__.__name__, face.shape, channels, landmarks) - self.landmarks = landmarks + self.landmarks = np.rint(landmarks).astype("int32") self.face = face self.dtype = face.dtype self.threshold = 255 if self.dtype == "uint8" else 255.0 diff --git a/lib/training_data.py b/lib/training_data.py index 52886b6073..84c201035f 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -689,10 +689,11 @@ def _random_warp_landmarks(self, batch, batch_src_points, batch_dst_points): slices = self._constants["tgt_slices"] batch_dst = (batch_dst_points + np.random.normal(size=batch_dst_points.shape, - scale=2.0)).astype("int32") + scale=2.0)) face_cores = [cv2.convexHull(np.concatenate([src[17:], dst[17:]], axis=0)) - for src, dst in zip(batch_src_points, batch_dst)] + for src, dst in zip(batch_src_points.astype("int32"), + batch_dst.astype("int32"))] batch_src = np.append(batch_src_points, edge_anchors, axis=1) batch_dst = np.append(batch_dst, edge_anchors, axis=1) diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index 0d2e5d5e3f..701fbdde75 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -186,7 +186,7 @@ def finalize(self, batch): for face, landmarks in zip(batch["detected_faces"], batch["landmarks"]): if not isinstance(landmarks, np.ndarray): landmarks = np.array(landmarks) - face.landmarks_xy = np.rint(landmarks).astype("int32") + face.landmarks_xy = landmarks self._remove_invalid_keys(batch, ("detected_faces", "filename", "image")) logger.trace("Item out: %s", {key: val for key, val in batch.items() diff --git a/plugins/extract/mask/components.py b/plugins/extract/mask/components.py index 78702fbb9e..3c2ffece96 100644 --- a/plugins/extract/mask/components.py +++ b/plugins/extract/mask/components.py @@ -32,8 +32,8 @@ def predict(self, batch): for mask, face in zip(batch["feed"], batch["detected_faces"]): parts = self.parse_parts(np.array(face.feed_landmarks)) for item in parts: - item = np.concatenate(item) - hull = cv2.convexHull(item).astype("int32") # pylint: disable=no-member + item = np.rint(np.concatenate(item)).astype("int32") + hull = cv2.convexHull(item) cv2.fillConvexPoly(mask, hull, 1.0, lineType=cv2.LINE_AA) batch["prediction"] = batch["feed"] return batch diff --git a/plugins/extract/mask/extended.py b/plugins/extract/mask/extended.py index be9233ebb5..a182c0af4f 100644 --- a/plugins/extract/mask/extended.py +++ b/plugins/extract/mask/extended.py @@ -32,8 +32,8 @@ def predict(self, batch): for mask, face in zip(batch["feed"], batch["detected_faces"]): parts = self.parse_parts(np.array(face.feed_landmarks)) for item in parts: - item = np.concatenate(item) - hull = cv2.convexHull(item).astype("int32") # pylint: disable=no-member + item = np.rint(np.concatenate(item)).astype("int32") + hull = cv2.convexHull(item) cv2.fillConvexPoly(mask, hull, 1.0, lineType=cv2.LINE_AA) batch["prediction"] = batch["feed"] return batch diff --git a/plugins/extract/mask/unet_dfl.py b/plugins/extract/mask/unet_dfl.py index 993b1c6ac8..79b8c5fbed 100644 --- a/plugins/extract/mask/unet_dfl.py +++ b/plugins/extract/mask/unet_dfl.py @@ -19,7 +19,7 @@ class Mask(Masker): - """ Perform transformation to align and get landmarks """ + """ Neural network to process face image into a segmentation mask of the face """ def __init__(self, **kwargs): git_model_id = 6 model_filename = "DFL_256_sigmoid_v1.h5" diff --git a/plugins/extract/mask/vgg_clear.py b/plugins/extract/mask/vgg_clear.py index 55a2174908..c363f4f1fc 100644 --- a/plugins/extract/mask/vgg_clear.py +++ b/plugins/extract/mask/vgg_clear.py @@ -20,7 +20,7 @@ class Mask(Masker): - """ Perform transformation to align and get landmarks """ + """ Neural network to process face image into a segmentation mask of the face """ def __init__(self, **kwargs): git_model_id = 8 model_filename = "Nirkin_300_softmax_v1.h5" diff --git a/plugins/extract/mask/vgg_obstructed.py b/plugins/extract/mask/vgg_obstructed.py index 480dd4aae2..60248c74dd 100644 --- a/plugins/extract/mask/vgg_obstructed.py +++ b/plugins/extract/mask/vgg_obstructed.py @@ -20,7 +20,7 @@ class Mask(Masker): - """ Perform transformation to align and get landmarks """ + """ Neural network to process face image into a segmentation mask of the face """ def __init__(self, **kwargs): git_model_id = 5 model_filename = "Nirkin_500_softmax_v1.h5" diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 55f936b639..dc4ab36cda 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -236,8 +236,7 @@ def get_items(self): """ Set the post processing actions """ postprocess_items = dict() # Debug Landmarks - if (hasattr(self.args, 'debug_landmarks') - and self.args.debug_landmarks): + if (hasattr(self.args, 'debug_landmarks') and self.args.debug_landmarks): postprocess_items["DebugLandmarks"] = None # Face Filter post processing diff --git a/tools/lib_alignments/annotate.py b/tools/lib_alignments/annotate.py index 70bd71aed6..fec4e8bbfc 100644 --- a/tools/lib_alignments/annotate.py +++ b/tools/lib_alignments/annotate.py @@ -42,8 +42,7 @@ def draw_bounding_box(self, color_id=1, thickness=1): bottom_right = (alignment["x"] + alignment["w"], alignment["y"] + alignment["h"]) logger.trace("Drawing bounding box: (top_left: %s, bottom_right: %s, color: %s, " "thickness: %s)", top_left, bottom_right, color, thickness) - cv2.rectangle(self.image, # pylint: disable=no-member - top_left, bottom_right, color, thickness) + cv2.rectangle(self.image, top_left, bottom_right, color, thickness) def draw_extract_box(self, color_id=2, thickness=1): """ Draw the extracted face box """ @@ -54,25 +53,24 @@ def draw_extract_box(self, color_id=2, thickness=1): logger.trace("Drawing Extract Box: (idx: %s, roi: %s)", idx, roi) top_left = [point for point in roi.squeeze()[0]] top_left = (top_left[0], top_left[1] - 10) - cv2.putText(self.image, # pylint: disable=no-member + cv2.putText(self.image, str(idx), top_left, - cv2.FONT_HERSHEY_DUPLEX, # pylint: disable=no-member + cv2.FONT_HERSHEY_DUPLEX, 1.0, color, thickness) - cv2.polylines(self.image, [roi], True, color, thickness) # pylint: disable=no-member + cv2.polylines(self.image, [roi], True, color, thickness) def draw_landmarks(self, color_id=3, radius=1): """ Draw the facial landmarks """ color = self.colors[color_id] for alignment in self.alignments: - landmarks = alignment["landmarks_xy"] + landmarks = alignment["landmarks_xy"].astype("int32") logger.trace("Drawing Landmarks: (landmarks: %s, color: %s, radius: %s)", landmarks, color, radius) for (pos_x, pos_y) in landmarks: - cv2.circle(self.image, # pylint: disable=no-member - (pos_x, pos_y), radius, color, -1) + cv2.circle(self.image, (pos_x, pos_y), radius, color, -1) def draw_landmarks_mesh(self, color_id=4, thickness=1): """ Draw the facial landmarks """ @@ -92,8 +90,7 @@ def draw_landmarks_mesh(self, color_id=4, thickness=1): for key, val in facial_landmarks_idxs.items(): points = np.array([landmarks[val[0]:val[1]]], np.int32) fill_poly = bool(key in ("right_eye", "left_eye", "mouth")) - cv2.polylines(self.image, # pylint: disable=no-member - points, fill_poly, color, thickness) + cv2.polylines(self.image, points, fill_poly, color, thickness) def draw_grey_out_faces(self, live_face): """ Grey out all faces except target """ @@ -104,7 +101,6 @@ def draw_grey_out_faces(self, live_face): for idx, roi in enumerate(self.roi): if idx != int(live_face): logger.trace("Greying out face: (idx: %s, roi: %s)", idx, roi) - cv2.fillPoly(overlay, roi, (0, 0, 0)) # pylint: disable=no-member + cv2.fillPoly(overlay, roi, (0, 0, 0)) - cv2.addWeighted(overlay, # pylint: disable=no-member - alpha, self.image, 1. - alpha, 0., self.image) + cv2.addWeighted(overlay, alpha, self.image, 1. - alpha, 0., self.image) diff --git a/tools/lib_alignments/jobs.py b/tools/lib_alignments/jobs.py index 7d33674dc3..5c54114a53 100644 --- a/tools/lib_alignments/jobs.py +++ b/tools/lib_alignments/jobs.py @@ -337,7 +337,7 @@ def convert_dfl_alignment(dfl_alignments, f_hash, alignments): "y": top, "h": bottom - top, "hash": f_hash, - "landmarks_xy": np.array(dfl_alignments["source_landmarks"], dtype="uint8")} + "landmarks_xy": np.array(dfl_alignments["source_landmarks"], dtype="float32")} logger.trace("Adding alignment: (frame: '%s', alignment: %s", sourcefile, alignment) alignments.setdefault(sourcefile, list()).append(alignment) @@ -951,7 +951,7 @@ def update_alignments(self, landmarks): logger.debug("Update alignments") for idx, frame in tqdm(self.mappings.items(), desc="Updating"): logger.trace("Updating: (frame: %s)", frame) - landmarks_update = landmarks[:, :, idx].astype(int) + landmarks_update = landmarks[:, :, idx] landmarks_xy = landmarks_update.reshape(68, 2).tolist() self.alignments.data[frame][0]["landmarks_xy"] = landmarks_xy logger.trace("Updated: (frame: '%s', landmarks: %s)", frame, landmarks_xy) From e4b7717c649851dd9cf5e878d013626714684173 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 15 Nov 2019 20:16:45 +0000 Subject: [PATCH 139/981] Minor fixes --- faceswap.py | 6 +++--- plugins/extract/align/fan.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/faceswap.py b/faceswap.py index 89d9514eeb..13e4311b45 100755 --- a/faceswap.py +++ b/faceswap.py @@ -5,9 +5,9 @@ import lib.cli as cli if sys.version_info[0] < 3: - raise Exception("This program requires at least python3.2") -if sys.version_info[0] == 3 and sys.version_info[1] < 2: - raise Exception("This program requires at least python3.2") + raise Exception("This program requires at least python3.6") +if sys.version_info[0] == 3 and sys.version_info[1] < 6: + raise Exception("This program requires at least python3.6") def bad_args(args): diff --git a/plugins/extract/align/fan.py b/plugins/extract/align/fan.py index 02bf34ef26..f6d256e033 100644 --- a/plugins/extract/align/fan.py +++ b/plugins/extract/align/fan.py @@ -62,7 +62,7 @@ def get_center_scale(self, detected_faces): center_scale[index, :, 0] = np.full(68, x_center, dtype='float32') center_scale[index, :, 1] = np.full(68, y_center, dtype='float32') center_scale[index, :, 2] = np.full(68, scale, dtype='float32') - logger.trace("Calculated center and scale: %s, %s", center_scale) + logger.trace("Calculated center and scale: %s", center_scale) return center_scale def crop(self, batch): # pylint:disable=too-many-locals From 578aec2e5c924437eca7cf67f4840d8a4706febb Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 15 Nov 2019 20:23:24 +0000 Subject: [PATCH 140/981] Update INSTALL.md --- INSTALL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/INSTALL.md b/INSTALL.md index a211d8f8fa..aeaf0c189d 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -154,7 +154,7 @@ Obtain git for your distribution from the [git website](https://git-scm.com/down The recommended install method is to use a Conda3 Environment as this will handle the installation of Nvidia's CUDA and cuDNN straight into your Conda Environment. This is by far the easiest and most reliable way to setup the project. - MiniConda3 is recommended: [MiniConda3](https://docs.conda.io/en/latest/miniconda.html) -Alternatively you can install Python (>= 3.2-3.7 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Tensorflow yourself, make sure you install no higher than version 10.0 of CUDA and 7.5.x of CUDNN. +Alternatively you can install Python (>= 3.6-3.7 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Tensorflow yourself, make sure you install no higher than version 10.0 of CUDA and 7.5.x of CUDNN. - Python distributions: - apt/yum install python3 (Linux) - [Installer](https://www.python.org/downloads/release/python-368/) (Windows) From d5c2063459b77f5ec00128d3fd065d9972a51754 Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 15 Nov 2019 18:09:12 -0600 Subject: [PATCH 141/981] fix to error on out of bounds --- plugins/extract/align/fan.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/extract/align/fan.py b/plugins/extract/align/fan.py index f6d256e033..b44fb73be4 100644 --- a/plugins/extract/align/fan.py +++ b/plugins/extract/align/fan.py @@ -140,10 +140,12 @@ def get_pts_from_predict(self, batch): flat_indices = batch["prediction"].reshape(num_images, num_landmarks, -1).argmax(-1) indices = np.array(np.unravel_index(flat_indices, (height, width))) - offsets = [(image_slice, landmark_slice, indices[0], indices[1] + 1), - (image_slice, landmark_slice, indices[0], indices[1] - 1), - (image_slice, landmark_slice, indices[0] + 1, indices[1]), - (image_slice, landmark_slice, indices[0] - 1, indices[1])] + min_clipped = np.minimum(indices + 1, height - 1) + max_clipped = np.maximum(indices - 1, 0) + offsets = [(image_slice, landmark_slice, indices[0], min_clipped[1]), + (image_slice, landmark_slice, indices[0], max_clipped[1]), + (image_slice, landmark_slice, min_clipped[0], indices[1]), + (image_slice, landmark_slice, max_clipped[0], indices[1])] x_subpixel_shift = batch["prediction"][offsets[0]] - batch["prediction"][offsets[1]] y_subpixel_shift = batch["prediction"][offsets[2]] - batch["prediction"][offsets[3]] # TODO improve rudimentary subpixel logic to centroid of 3x3 window algorithm From 3ba917ce0d18c0b684b53c5afbda084e376336e6 Mon Sep 17 00:00:00 2001 From: kilroythethird <44308116+kilroythethird@users.noreply.github.com> Date: Thu, 21 Nov 2019 03:28:26 +0100 Subject: [PATCH 142/981] Added masking output support to tools->mask (#937) --- tools/cli.py | 18 +++++++++++++ tools/mask.py | 74 ++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 70 insertions(+), 22 deletions(-) diff --git a/tools/cli.py b/tools/cli.py index 06291c0c0d..d3e3eef8af 100644 --- a/tools/cli.py +++ b/tools/cli.py @@ -547,6 +547,24 @@ def get_argument_list(self): "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": ("-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({ + "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.py b/tools/mask.py index 46c2184016..df6dd59530 100644 --- a/tools/mask.py +++ b/tools/mask.py @@ -38,6 +38,9 @@ def __init__(self, arguments): self._input_is_faces = arguments.input_type == "faces" self._mask_type = arguments.masker self._output_opts = dict(blur_kernel=arguments.blur_kernel, threshold=arguments.threshold) + self._output_type = arguments.output_type + self._output_full_frame = arguments.full_frame + self._output_suffix = self._get_output_suffix() self._face_count = 0 self._skip_count = 0 @@ -229,6 +232,19 @@ def _check_for_missing(self, frame, idx, alignment): logger.debug("Not updating existing mask for face: '%s' - %s", frame, idx) return retval + def _get_output_suffix(self): + """ The filename suffix, based on selected output options + + Returns + ------- + str: + The suffix to be appended to the output filename + """ + sfx = "{}_mask_preview_".format(self._mask_type) + sfx += "face_" if not self._output_full_frame or self._input_is_faces else "frame_" + sfx += "{}.png".format(self._output_type) + return sfx + @staticmethod def _get_detected_face(alignment): """ Convert an alignment dict item to a detected_face object @@ -318,10 +334,12 @@ def _save(self, frame, idx, detected_face): detected_face: `lib.FacesDetect.detected_face` A detected_face object for a face """ - filename = os.path.join(self._saver.location, - "{}_{}_{}_mask_preview.png".format(os.path.splitext(frame)[0], - idx, - self._mask_type)) + filename = os.path.join(self._saver.location, "{}_{}_{}".format( + os.path.splitext(frame)[0], + idx, + self._output_suffix) + ) + if detected_face.mask is None or detected_face.mask.get(self._mask_type, None) is None: logger.warning("Mask type '%s' does not exist for frame '%s' index %s. Skipping", self._mask_type, frame, idx) @@ -340,24 +358,36 @@ def _create_image(self, detected_face): Returns numpy.ndarray: - A preview image, containing 3 sub images: The original face, the masked face and - the mask. + 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 """ - if self._input_is_faces: - face = detected_face.image + mask = detected_face.mask[self._mask_type] + mask.set_blur_kernel_and_threshold(**self._output_opts) + if not self._output_full_frame or self._input_is_faces: + if self._input_is_faces: + face = detected_face.image + else: + detected_face.load_aligned(detected_face.image) + face = detected_face.aligned_face + mask = cv2.resize(detected_face.mask[self._mask_type].mask, + (face.shape[1], face.shape[0]), + interpolation=cv2.INTER_CUBIC)[..., None] else: - detected_face.load_aligned(detected_face.image) - face = detected_face.aligned_face - size = face.shape[0] - detected_face.mask[self._mask_type].set_blur_kernel_and_threshold(**self._output_opts) - mask = cv2.resize(detected_face.mask[self._mask_type].mask, - (size, size), - interpolation=cv2.INTER_CUBIC)[..., None] - masked = (face.astype("float32") * mask.astype("float32") / 255.).astype("uint8") - mask = np.tile(mask, 3) - - for img in (face, masked, mask): - cv2.rectangle(img, (0, 0), (size - 1, size - 1), (255, 255, 255), 1) - - out_image = np.concatenate((face, masked, mask), axis=1) + face = detected_face.image + mask = mask.get_full_frame_mask(face.shape[1], face.shape[0]) + mask = np.expand_dims(mask, -1) + + h, w = face.shape[:2] + if self._output_type == "combined": + masked = (face.astype("float32") * mask.astype("float32") / 255.).astype("uint8") + mask = np.tile(mask, 3) + for img in (face, masked, mask): + cv2.rectangle(img, (0, 0), (w - 1, h - 1), (255, 255, 255), 1) + out_image = np.concatenate((face, masked, mask), axis=1) + elif self._output_type == "mask": + out_image = mask + elif self._output_type == "masked": + out_image = np.concatenate([face, mask], axis=-1) return out_image From 9c588045aaac0efbdcf29ec11a8005c5e98ec650 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 22 Nov 2019 19:20:23 +0000 Subject: [PATCH 143/981] GUI Updates (#940) * lib.gui: Update icons * tools.cli: Add video filetype flag to mask input * lib.gui.popup_configure: Refresh GUI on config save * lib.gui._config: Add Icon size and load last session options * lib.gui.control_helper - Add control modification tracking * lib.gui - Add Projects and Last Session - Main root title handled by projects - Add Hotkeys - Create projects module - split fsw and fst filetypes - Add last_session saving * lib.project - Documentation - Ask confirm on closing unsaved project -Fixups * Track model folder changes - Shuffle some globals - Activate System Verbosity for GUI * lib.gui.utils - Documentation and clean up - lib.gui.custom_widgets - Create and document * Add GUI config option to disable auto loading model stats --- docs/full/lib.gui.custom_widgets.rst | 7 + docs/full/lib.gui.project.rst | 7 + docs/full/lib.gui.rst | 11 + docs/full/lib.gui.utils.rst | 7 + docs/full/lib.rst | 3 +- lib/gui/.cache/icons/clear.png | Bin 691 -> 13728 bytes lib/gui/.cache/icons/clear2.png | Bin 0 -> 15475 bytes lib/gui/.cache/icons/context.png | Bin 0 -> 8281 bytes lib/gui/.cache/icons/favicon.png | Bin 0 -> 3878 bytes lib/gui/.cache/icons/folder.png | Bin 0 -> 7275 bytes lib/gui/.cache/icons/generate.png | Bin 0 -> 5766 bytes lib/gui/.cache/icons/graph.png | Bin 574 -> 11873 bytes lib/gui/.cache/icons/load.png | Bin 0 -> 4695 bytes lib/gui/.cache/icons/load2.png | Bin 0 -> 4807 bytes lib/gui/.cache/icons/model.png | Bin 0 -> 14239 bytes lib/gui/.cache/icons/move.png | Bin 550 -> 19787 bytes lib/gui/.cache/icons/multi_load.png | Bin 0 -> 4885 bytes lib/gui/.cache/icons/new.png | Bin 0 -> 5486 bytes lib/gui/.cache/icons/open_file.png | Bin 406 -> 0 bytes lib/gui/.cache/icons/open_folder.png | Bin 263 -> 0 bytes lib/gui/.cache/icons/picture.png | Bin 0 -> 10013 bytes lib/gui/.cache/icons/reload.png | Bin 0 -> 14128 bytes lib/gui/.cache/icons/reload2.png | Bin 0 -> 14970 bytes lib/gui/.cache/icons/reset.png | Bin 773 -> 0 bytes lib/gui/.cache/icons/save.png | Bin 530 -> 5388 bytes lib/gui/.cache/icons/save2.png | Bin 0 -> 5378 bytes lib/gui/.cache/icons/save_as.png | Bin 0 -> 8843 bytes lib/gui/.cache/icons/save_as2.png | Bin 0 -> 8766 bytes lib/gui/.cache/icons/settings.png | Bin 0 -> 34859 bytes lib/gui/.cache/icons/settings_convert.png | Bin 0 -> 15315 bytes lib/gui/.cache/icons/settings_extract.png | Bin 0 -> 11380 bytes lib/gui/.cache/icons/settings_train.png | Bin 0 -> 26420 bytes lib/gui/.cache/icons/start.png | Bin 0 -> 9063 bytes lib/gui/.cache/icons/stop.png | Bin 0 -> 8003 bytes lib/gui/.cache/icons/video.png | Bin 0 -> 4694 bytes lib/gui/.cache/icons/zoom.png | Bin 642 -> 18426 bytes lib/gui/__init__.py | 7 +- lib/gui/_config.py | 27 +- lib/gui/_redirector.py | 154 --- lib/gui/command.py | 128 +-- lib/gui/control_helper.py | 186 ++- lib/gui/custom_widgets.py | 635 +++++++++++ lib/gui/display_analysis.py | 76 +- lib/gui/display_command.py | 2 +- lib/gui/display_graph.py | 8 +- lib/gui/display_page.py | 2 +- lib/gui/menu.py | 211 +++- lib/gui/options.py | 12 +- lib/gui/popup_configure.py | 24 +- lib/gui/project.py | 993 ++++++++++++++++ lib/gui/statusbar.py | 82 -- lib/gui/tooltip.py | 165 --- lib/gui/utils.py | 1264 ++++++++++++--------- lib/gui/wrapper.py | 12 +- scripts/gui.py | 157 +-- tools/cli.py | 3 +- tools/preview.py | 14 +- 57 files changed, 2983 insertions(+), 1214 deletions(-) create mode 100644 docs/full/lib.gui.custom_widgets.rst create mode 100644 docs/full/lib.gui.project.rst create mode 100644 docs/full/lib.gui.rst create mode 100644 docs/full/lib.gui.utils.rst create mode 100644 lib/gui/.cache/icons/clear2.png create mode 100644 lib/gui/.cache/icons/context.png create mode 100644 lib/gui/.cache/icons/favicon.png create mode 100644 lib/gui/.cache/icons/folder.png create mode 100644 lib/gui/.cache/icons/generate.png mode change 100755 => 100644 lib/gui/.cache/icons/graph.png create mode 100644 lib/gui/.cache/icons/load.png create mode 100644 lib/gui/.cache/icons/load2.png create mode 100644 lib/gui/.cache/icons/model.png create mode 100644 lib/gui/.cache/icons/multi_load.png create mode 100644 lib/gui/.cache/icons/new.png delete mode 100755 lib/gui/.cache/icons/open_file.png delete mode 100755 lib/gui/.cache/icons/open_folder.png create mode 100644 lib/gui/.cache/icons/picture.png create mode 100644 lib/gui/.cache/icons/reload.png create mode 100644 lib/gui/.cache/icons/reload2.png delete mode 100755 lib/gui/.cache/icons/reset.png create mode 100644 lib/gui/.cache/icons/save2.png create mode 100644 lib/gui/.cache/icons/save_as.png create mode 100644 lib/gui/.cache/icons/save_as2.png create mode 100644 lib/gui/.cache/icons/settings.png create mode 100644 lib/gui/.cache/icons/settings_convert.png create mode 100644 lib/gui/.cache/icons/settings_extract.png create mode 100644 lib/gui/.cache/icons/settings_train.png create mode 100644 lib/gui/.cache/icons/start.png create mode 100644 lib/gui/.cache/icons/stop.png create mode 100644 lib/gui/.cache/icons/video.png delete mode 100644 lib/gui/_redirector.py create mode 100644 lib/gui/custom_widgets.py create mode 100644 lib/gui/project.py delete mode 100644 lib/gui/statusbar.py delete mode 100755 lib/gui/tooltip.py diff --git a/docs/full/lib.gui.custom_widgets.rst b/docs/full/lib.gui.custom_widgets.rst new file mode 100644 index 0000000000..e67fd0764b --- /dev/null +++ b/docs/full/lib.gui.custom_widgets.rst @@ -0,0 +1,7 @@ +lib.gui.custom\_widgets module +============================== + +.. automodule:: lib.gui.custom_widgets + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib.gui.project.rst b/docs/full/lib.gui.project.rst new file mode 100644 index 0000000000..75b99be397 --- /dev/null +++ b/docs/full/lib.gui.project.rst @@ -0,0 +1,7 @@ +lib.gui.project module +====================== + +.. automodule:: lib.gui.project + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib.gui.rst b/docs/full/lib.gui.rst new file mode 100644 index 0000000000..e8d44f6716 --- /dev/null +++ b/docs/full/lib.gui.rst @@ -0,0 +1,11 @@ +lib.gui package +=============== + +Submodules +---------- + +.. toctree:: + + lib.gui.custom_widgets + lib.gui.project + lib.gui.utils diff --git a/docs/full/lib.gui.utils.rst b/docs/full/lib.gui.utils.rst new file mode 100644 index 0000000000..b3904a36f2 --- /dev/null +++ b/docs/full/lib.gui.utils.rst @@ -0,0 +1,7 @@ +lib.gui.utils module +==================== + +.. automodule:: lib.gui.utils + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib.rst b/docs/full/lib.rst index 83e96425fb..d1aebceecd 100644 --- a/docs/full/lib.rst +++ b/docs/full/lib.rst @@ -6,9 +6,10 @@ Subpackages .. toctree:: - lib.model lib.faces_detect + lib.gui lib.image + lib.model lib.serializer lib.training_data diff --git a/lib/gui/.cache/icons/clear.png b/lib/gui/.cache/icons/clear.png index 0f2c6364a99712eab878422ae503fed14ce1ebf9..78060cbe71e4456e391df38a342f8a5336db7d6c 100755 GIT binary patch literal 13728 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 Nj>ZG^PpY;N{|AF%*be{z literal 691 zcmV;k0!;mhP)448r#lL7&jGuxFt6pJIbaIwck+<}QuBrvTuvSffU<&9YNu#0Cf4gH8uG0|2Ip z=!>C%-*eN}-Nga`X93QRP_PWJB5Q&pUV>wOiqAzAjcpAx5h~cn&OBv!j+=BjdAsgj zZ2)ibDh~D$FchE!*xy6&I;W!AXjUOO9XxNhPBN^S3Y4@A1M!Rsh5|vihQBMQh-cLn z4y6Pv%ebiu(wc!@qLJLOmjEc3t<9g3qNt#>sW|E*IN&09nbW*O{RAcgJp0ysOHmZm z8(Vo=t<`6xV!5IxI13PP5hNrPud>=nNGbv@0?sgrLa{7wR4TIs0M0-B5(|y&J+3Eh z7GT}buy#xZ5Kk<|9!%{!NdcfJ*KVXmX;CM)i`%g!F`p_|vT002ovPDHLkV1oPVCc^*# diff --git a/lib/gui/.cache/icons/clear2.png b/lib/gui/.cache/icons/clear2.png new file mode 100644 index 0000000000000000000000000000000000000000..1ace1a5193af070e2420df32ebadde5b96897179 GIT binary patch literal 15475 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 literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/context.png b/lib/gui/.cache/icons/context.png new file mode 100644 index 0000000000000000000000000000000000000000..7176139cf3843ebb187d587eaf510834cf4dc963 GIT binary patch literal 8281 zcmb_>WmHv9^ys-EPy0rz9go=l<5s&hd>c0QfFu>e%b*?2(9H z|N0}X5(WLL;-*cEPNyv$3nhZFu+wAUD@W1iE)Z*VJ(H6|rX0wL0)P4h{Yb3Ei5-JE zkG4sl_bDttDrV?@Js{tCw(WX*^s!-1VyEIFvu+#>j*gwAD4+#_VwXzOJX;Iv|IxR; z$t@Lv$>a{;qu1HcdpBJ4c@Q)+g+&L*K?W>_t*tfyO)kKKHF$dpfM(vL27mzLuk^&A92h`H zXd5L9IEVrj0DC6Z;D6nD2@ zDmj113%s?95dg@65hHec@!&g#TQxQ&_^A=soNK=m^@+jCYU6Qhyxd(10M@;NCLTF? z>L{S1s8E;3JmzC$2Q#dkch|9YwRlnuK+fKx{)PKL*~sO7ZeCc}+T5I1{vmB_Ii&yo z(Z1UpuK)1nHc0I6_2pW}Z>A7_(+~yZo3-wdKdOaP<6kg7SS@eIEByVA_4Jo|is6Tn zRkHy*(S{a*$0ymej~D!f4AIhGp7%0OKH2ZJ5lct8>(03^!6T~vn4Mq`ZF{r>G zihSBJx^#Mq)aT-FkwhX*isVNQ$$>){%P3heSeit5)D z;MV9%>+u>NWP%VmIEEmyk2IYn%_~I%RRi^T#S%IyrWel^F@!L{AGEqz84^lV>RDQz z-Tol6;^&JrP~>EsC5R{F!4HU%`60wa5G}*c+WZ+wsir`8l3-GP(p8&rkFzxWg(3xG z!r-Efb1j<4S57*Pf$+`D&7MtxP0CIBb7Ra*D=CkH8=c>5+Cxst+{l;r9@gTxz2SLQZqf76OAA$;*JO=L8TL`kKvGB0t8!hzJ`+VR`2SIe`NL>l$v zpZb%-dkVYByWl@Q*f26Qg%}DQr+|n z*c;gciD5&SiOPvAiInU-x}{}BWxvX>^e*&mCTblz9V>j~&=lwwRLmFtydY98oN z>e!cxmPD4>m$>Ol76chSM57w^sP<^c6lmqtm~XW8(P3GW*ZFeNJL8l`ybFQ+V5u2h z2=r!eO?jYhjTJ8|%@vUmP!`ay)4up>CyBO^;&GGVrH-+~R-Akh6!VMdM(<5sk#;a@@wC@qk7Ntp!?u*SA3~}ZGc*b&4jvy zJw_(h3-NFhqvq*fI@6Ld?U9lxv`n;2UjG1Vo)F8Qk&lzd`{>wbvca_B(&y5b&w|QA z&!VEXuXdcKt=3%hyhv14RJDI}WHfJdHWiXC&Ns)ulfIKam%jSl!pPT%v$3SH$yTX$ z)2PVsxUtPO+(^W*&_K_~v`)Ss@1>38z#t$Ea? zsz#T__m=inr1sx?t9p(T+7sTlypMYjN5hF+z%8dXq7L$`e@pc8^~+yrV;RSi?|c6w z?eW+fjN^~b3v4tJ4nP0ln5mOKrC?8L-|QK75fvM)5<1MCEWw>l*|###zNzeW>0!F= zKY5v%UYFi|RI>E#Sn$~KIC43AhFaiDenjRoOe)B-vl- z_Iw&5-?)?^>=I?%PGXBfEiYOm`?&24M`jE%4V2!fP>FD}R|&el=~(@HbP@SG^EcK; z0BH)(fT_D*?pXJq@M4luZUWAiNm8m&>cU@7U*WTtkbGo5C-hpoYY7;<4YN*C?w8nMl>qt)8m>f3dW zMh}T3m1l8`9*xuuEqxZsILUrGBIqVq|I%0(p`y=4ZLr;5;UrQk=Ha)5xj~G=5%C(e zLaZsKk=NlSMnxrc3v~bJ-suV+Pp&jr&fU0998(6g zUA;J*Su$?BevlaKSkeROsm<8U*xoDM*Ii3iMVIZ}B;C81R;s&r9QA`I!4GJRxQ%no z(=*hQ;=0=Lvm6Hsxc#&pkC`VvTsVR_dT|+jzZua9%EYe+;NtN1LMc zrnaZ%agt6uPm@pANxL_;GH%frk(ij>|Kg}JJw^XPIzu7E%;vRlX6e7JECo(V1QYNrJZ$_Cb{UT+ zgA$(_KOBJ*agZe}AtLfy^6kUk-(s0@n{k#b(#O2V;VM!Ro}inVgSoMZl!@Hr^W=n1 zm%ze@efRs88}&@iaCA>4Mpw9Bykab$%A#Cy*y3 zJRyTqF8*2za4jM7O*(5uZ8ZS!X9fUh7y#TpA=*6vc*hF>`xXH3G6eug-M*Oj$^!uL z2PN58y1t8tEAJ~z-evZmU5g0#9A6M>I^-@UO+rHHw7l~tE-fFxXY{R3@He&PYceEp zd>#``u7~n_1gaKs&c9_aQ3G+mhgNeB$cO2*faLI%RUBKBL@V3tudf>0k1}^sJ+I;e zVhY*8Mcsm>Hp-Hcrb;su7j6PpSL9(|*3Yl?jrPFDdP0gOvRX}b zmQQu7;rgb+vVK*HZY(p2nFj2010Szy0-Ul!?cY2Gt)1gM9M>)W{bBR17qwv;Wbrk9 z6s%e5Z`zv2Ugu{PJb|x4Q;!+46e`XN`$3DOxONI_bD1i?CTR=SjE_dj8E$i(p?mOd zZ&7Mz#)NVUyswA1T->>&RjnAGN|e$NqDUnV>WVM5L){L!d^h&_v;Z%dNp5#xdBhu; zHG%)&R6>PH4fLQOx2}Lne^nyD)b8OHEEoVHUz40M&I5M&3!51n=V~19X4_FtlrK}* zfR+(rj>_9BxB0w=m zd1m8Xd8FUI2CIv-g@HIr@$JFdrQsSF{>=-QE~Lo*?LM+&uP_!~`mG9zlyv(e!-2{N zx&2!R)=n%Dpfm-~05M$tspIXc$VV)Ox{Zxry0d}_Ey%(WHd?zZk0eH#bULci0+=o} zEm-=U-$_WNB_mb2y`!fK+|U1{tiB zn}TlZF1X2lr-E4r=cE~PDr5i{x7X&WSjUOarfg^wrw%6Y31i3{9=q&Qfdg1zK zVIB}Q35Nqd%N~)9{L$@P#*Mr}Ld7yrHoq(diR`&q=9;AOoij0w9bhr{b-5!Jp-cWS z6nF%Kvl^=eZGF1W@4pPd4)aj*GTO2ce7T-PFq+!;^hE+YqO-!i+;gK&ZJ z8Cr(nvFwaB;a^(jQJkV3SR>`MvPp%0rszzyAD;uVNq)ee5=sOn>xCwy!G9?cclGu= z&)R)fiz?GLRsUAL(CW}^aUAK0-V~<^{%L9TYg)fKk%IE)hu?&WV1<8RKAAnU#rA5W z$KD}fMX%kO_X+p4V8}HU3t`;uRi!ft3-A9o>}hI`4mvgh2X86U$GB{!NBf@gG^q0@CRRtdG_1Snj6HfrjGjU+736OK2aPIG3^TU)BvgmQtA+U9ZJ5 zg(P1sNG1wEIFPciHldhqle*)QtntPryi=qEMY)>23c0AoTAmlNF>m-UL+hO^tZmY`EI1UmN)*jFyhw$P1(1?q z-XD+_UrbM?(P}cOJMB^MsPCwaMvPVX`M^v+-1j7X9vxe|UBtqVCLF(Zi2zO{FYup6 zfKzB?QK18yCX*Sb^IMK$Q{;LB#MRRqqoFRsx_u8ASxv=Yol`&!a5Izo+@pxnwOK=) zn}Cr7vWL>(f(@BP01rGi3?328@o6#3}H|i&O^=o4}h5C@Hh73l+*!sYQxL`;l&Ey^ z>(>fd)iwkLibutJZ2#|cKGjw6TydALTNjt-XyY|R#UbO;#9O4`-HH6oxM}#%n7#GGGx1h>I4s9Iwh~q9**t1n zv)Mm>^*WVNT})~Oc}IT6W$Q|QNO`&v+Q`=OmVqv*_PPb2Ya$&B94iYux`=!1WG%W( zXDu#d2;8xCa5^7#9` z`!ON{N$zD=-t6BTJ4P(%i6t4P7>vVdG|CGqSGg6M7RL!tA^cKmJqsGq9&)VFMYY(3 zwQ&P^MFi42o)$$>KgV%zbY4PRfG9+vn?lvj{snosV){(rxE=cGGtLO05=@$tp&tPcA?nAXR*_rot zuLbpSsCVU>;gOc6ms0p1eY!Nsga6^Q==%m9SG=d@MWQrQ!=fr6HrjnEy^?@M+xoz| ze9??o>Q7L0t>7ad1d+3x^MrAK4?d62xLEQy7Ygj={=iZ!;y0|8B6wS~Dj9Q8%G2$D z+S9!M<9_}~k`HngX4LS}AmCj#-{bjp(&k(D?J_EHFX8jwXl#f6pZ2U--GkjcDj&}H zn*%!HMo(7G-ajcEtp~Jr+;BN_l1@8qf__^2Utjd2>B(TMHYs=B@C>=Mkr&lo7`|Pi z;df2i%Jvv)@IwOFN@g7XI|NLzH%@cO`+CoqZ>XeBM8BD)2<5~rNp~#_2SSM&oRg z8aMX)?ERYO2NQcS;hb_%LX}`r?o~W(vHh=|JZg>KN$0+$+jaqOor&9ifrj)=!n4)a zZJl25n;e=3P?x@q@wBtsTX`Uw`4vtWwWs~*Jc6UttSQvV$xl_qP0LusA}oea47)(!FXr^Br;UcmblpBk7)YfaNyxpYTix(81 z2h7u1v#qw%zYEMTuNKGC@;_#E7T$C?6W-p&x<;=A;HIXzahk;4mrsgWUMrRDAgKS{9Z|SN&OHi)A^6$RJ`@!Rfx2YYnL2rhQ*uD9IW9aEE zMRwq$oSqI+;oV)!^>AW>Uv!Hc7jbCXp#d;r(L`e5AhiR0VSDIvZnX7iZxo9=m&elI0c4kee2zspuQG)Ki`AJ4Eu_3}CHky!>8!I?xpnDm3 zr)F3D{H5)TZr;re zbA**!9Fs&7%Z5?nuD~cZNRS#R#x4Db#?lcPTOOmFqwIzwSP_9kt9s~jb1~gCmAEE~gM#LYtXdg@xnI@5jDbkE%fcZBskLS<&$tz#MNDQVC(X{sy8eD0rV@9(R zp_E3JS~3keB%(CHLrMfv@%b(hRPkb_Ie5soK%8LOsvn$#{szyPH#M{91A;nDtJ?|P zT~y5sby*v*VHHfO_Qmy!PoGUAatyUS`YViB`vhe15*aADg;WmCA8NNetGwb~Sq(Cz zM{*Fxlz;kjRL|Ri{Q{fbY*=XJ4Ir>*Om54nMB^GCED=>YKJK7ATc=N8a z*ijCf^#1C4-!hkBw7P^ko0(|1`!ZB? z*?339eA`fiMc~f;Wlu2N(1U7l?$Jo`{;lry&Fh-5geo#}I@_sm$UwCYLGbR20U14U zM6URvyVh>Z7)epdjj(=?rL+S<;+#ip?ea0o$)qU|t^c^QZ(x76u#YaDy-VamGm@L{ z=>Dg+WVun_42hjVZ4@|SMjTSEr@Wf_sN=yNG`?eNH1H4Cex$2~mH;E?$WTMU90)%U z>SBq5aQJzf`&ClUD4D;HTv7M23b;Bq+%RH5Ey#XL=(N&^b7=mEv8`O5+sC)iMLbaQ z5oner*4xRwxdINIY&m1DW7vU@b4Y=Al!%nM#?p`j?12fzK!pE(4I%^?r@^$cNJ+CV zvXr;g1|b;@NsBhZ@xA7BD#V{Q5kvat1t)4s5+`!Dtvt~+k!diTtKat%p%j-ia1dNg z9TOo609yJ`dN>;%NEv!IB4=Kv?`%+TN#REqgFqtQ2w$gAjOcqYXflOT7(Sc1mm>%I z%1I@>?=|Wp9L@$f%H_=rk;(kkw2m{F|$Kb3Ut=OyxeyvdpUrsxdOMt9;e z166OgsP)-@{pV*hw(!>!50QY_;;&8!aI<*D75$W+j!ZKTn zd`~+6ib}RWVBK^SH*gPUZ8Wu=)P1k@%syj>lYi7-;%-kjY|7H&Y5x;*_0Ds;cfXMy z_(O+Cp-PzP0h3BrSYuRMV}@Yhz&p5^)HpI}^wnH2_*8ls47asxVGzfVWqL`R6CZAm z)aUQBf<`laM-V+7R&ObdQ>=!#vaTlVIqG7tDg;bOE{}s-uK;w3-K7w)kq-lx2>$dW z){#Ne)HdEK+A_KcxJ~Oo`^Ta+d_2UBaDr$p_Vx(`42_ca@pESMBkpbiB{_B3Drt+* F{{eqfW7Gfu literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/favicon.png b/lib/gui/.cache/icons/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..4c8f094327119b8d146f53ad5aaa8137582f6cbf GIT binary patch literal 3878 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 o1dbQ0g`ULs}JoNEpgQ=^t`TF}H+&x_Z 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 I)Zowm7mhqN;Q#;t literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/generate.png b/lib/gui/.cache/icons/generate.png new file mode 100644 index 0000000000000000000000000000000000000000..f952175a3c4a5aff83857c16e35226147bff3238 GIT binary patch literal 5766 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 literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/graph.png b/lib/gui/.cache/icons/graph.png old mode 100755 new mode 100644 index 7056a9a0a9bb06f487f5b9c3b3220508fb20f67b..253647ea7f05e251d9ace53954751b29a93e9415 GIT binary patch literal 11873 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 M07*qoM6N<$g0TGg(EtDd diff --git a/lib/gui/.cache/icons/load.png b/lib/gui/.cache/icons/load.png new file mode 100644 index 0000000000000000000000000000000000000000..b8b2cad99fd366f1e53644c05c866989cc1d47e3 GIT binary patch literal 4695 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 literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/load2.png b/lib/gui/.cache/icons/load2.png new file mode 100644 index 0000000000000000000000000000000000000000..64ded4a4ac85f5d80428cdb553e293efbc9c345a GIT binary patch literal 4807 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 literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/model.png b/lib/gui/.cache/icons/model.png new file mode 100644 index 0000000000000000000000000000000000000000..6f5bd408d8db7f20103ad493c6dff45d22e7d2c2 GIT binary patch literal 14239 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

{|~Hd%T|2;6oL6tVwhr6^>=J%B7otZ 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 literal 550 zcmV+>0@?kEP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0lP^=K~y+Tos!Qh z1YsD*pLut7cXrXPEJ?}=Ar!@d)8^#3l&j=|QvL&SlthtG4(`ex)jBwFQTApJeqC58 zkzYGAJG0|?-+6Y}+0DVH-rnc?%=>-bci#7rz{U2t8ME^FQHik$lwmt7NMvp=x0c4c zlcWVs0W^lS?8mA6W=NrL(c2_gZzSR_avZF+IjLmse|#p_cRm}%tNNdJd8^T^&V5)Pe_pE;xeG*sHU4B^5h z7UA=h&U_3tN-)t={Cm8>s~!Fge~F$tfM=Bwh?&#xn67>$NdF1LjWZtr$(x^^FbqA# zUY_okNekXI3S4mW(X|CSHdg`c+I_Y-){!<%!#Txyrg%{vH+*<6H6!cTjS1&QHqWwG zve(x_Qc(ElZe&B(E@Yp77z?~QuP`?lXw7Nr0Z9Qjy3M+p-7A>6E@?UJ@m`$fal~*$ oaqxwASUm0ZD?4aBK}`UB1JtXFF!EWE^Z)<=07*qoM6N<$g0=titN;K2 diff --git a/lib/gui/.cache/icons/multi_load.png b/lib/gui/.cache/icons/multi_load.png new file mode 100644 index 0000000000000000000000000000000000000000..05ce02261c3c5d54f4260d7754fc018f2cbf1aa9 GIT binary patch literal 4885 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 literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/open_file.png b/lib/gui/.cache/icons/open_file.png deleted file mode 100755 index e91a27b603c1723b77f1c2fdd1aa83f7f34663e4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 406 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf2?p zUk71ECym(^Ktah8*NBqf{Irtt#G+J&^73-M%)IR4c+T8uW`rbdRt+4oTe#PZmY2P-RxX8YfGk@v){hjHEq{_Ii*GO zSPv9F+j1lYidzJl+jZ4%2K-1^mEuHWKw%ZcqB yk3B~dhx1Zd<33@%S>?AF8g6Knq)9#d%(?qS{8nWp6<`oDFnGH9xvX2?p zUk71ECym(^Ktah8*NBqf{Irtt#G+J&^73-M%)IR4?Vc&Y93cS&F=s^{GTGPvO|_`~TlsWNRQa(j&*u8N z>}0sfz{bYbc3pm-VNCsxclXcF-#@dX=-|8H?}02kEpJNpl!(j>d9qYu#tezVthgO% z_D33i2)H|mJ!D~-Yms%J!=RzHk#W)@HXa5B9XI8#BEC-|f$m}OboFyt=akR{0C^5v AaR2}S diff --git a/lib/gui/.cache/icons/picture.png b/lib/gui/.cache/icons/picture.png new file mode 100644 index 0000000000000000000000000000000000000000..e667c6624457ffe1ecac29b44e18014320ad12de GIT binary patch literal 10013 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 R=>YySK5C(#uj_X0e*q+-)u8|Y literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/reload2.png b/lib/gui/.cache/icons/reload2.png new file mode 100644 index 0000000000000000000000000000000000000000..ba57c1dae17517d6cd49d0d73f178c2d1bd667bb GIT binary patch literal 14970 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 literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/reset.png b/lib/gui/.cache/icons/reset.png deleted file mode 100755 index bc5cd44bd68157179fa972d37e3a6f85d7fcf939..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 773 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 U#)~GD*#H0l07*qoM6N<$f{haF2><{9 diff --git a/lib/gui/.cache/icons/save2.png b/lib/gui/.cache/icons/save2.png new file mode 100644 index 0000000000000000000000000000000000000000..8005fd00f92598e5d022e12679a8b358a2686968 GIT binary patch literal 5378 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 literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/save_as.png b/lib/gui/.cache/icons/save_as.png new file mode 100644 index 0000000000000000000000000000000000000000..02de122a84c5261c96aa1616ee786ccdbc5475d0 GIT binary patch literal 8843 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 PJ*2LxrIM>;@$~-yHv!C! literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/settings.png b/lib/gui/.cache/icons/settings.png new file mode 100644 index 0000000000000000000000000000000000000000..b2fc465e37669416e42f5eedebf403b098e23a8a GIT binary patch literal 34859 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 BsD1zd literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/settings_train.png b/lib/gui/.cache/icons/settings_train.png new file mode 100644 index 0000000000000000000000000000000000000000..fb2ae566050905c274d1ece647a05a0631e4cb79 GIT binary patch literal 26420 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 Uv1QiALg07d+!bt*j%~<)0ixL$ivR!s literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/start.png b/lib/gui/.cache/icons/start.png new file mode 100644 index 0000000000000000000000000000000000000000..1aba1c2fd936bddf56a88fa4d8e27fabfdb49bb7 GIT binary patch literal 9063 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 diff --git a/lib/gui/__init__.py b/lib/gui/__init__.py index dca41f2ae0..b611c3c341 100644 --- a/lib/gui/__init__.py +++ b/lib/gui/__init__.py @@ -1,9 +1,10 @@ from lib.gui.command import CommandNotebook +from lib.gui.custom_widgets import ConsoleOut, StatusBar from lib.gui.display import DisplayNotebook from lib.gui.options import CliOptions -from lib.gui.menu import MainMenuBar +from lib.gui.menu import MainMenuBar, TaskBar from lib.gui.popup_configure import popup_config +from lib.gui.project import LastSession from lib.gui.stats import Session -from lib.gui.statusbar import StatusBar -from lib.gui.utils import ConsoleOut, get_config, get_images, initialize_config, initialize_images +from lib.gui.utils import get_config, get_images, initialize_config, initialize_images from lib.gui.wrapper import ProcessWrapper diff --git a/lib/gui/_config.py b/lib/gui/_config.py index cf6a8646b1..323ec3e4fd 100644 --- a/lib/gui/_config.py +++ b/lib/gui/_config.py @@ -26,8 +26,8 @@ def set_globals(self): logger.debug("Setting global config") section = "global" self.add_section(title=section, - info="Faceswap GUI Options.\nNB: Faceswap will need to be restarted for " - "any changes to take effect.") + info="Faceswap GUI Options.\nConfigure the appearance and behaviour of " + "the GUI") self.add_item( section=section, title="fullscreen", datatype=bool, default=False, group="startup", info="Start Faceswap maximized.") @@ -43,6 +43,10 @@ def set_globals(self): section=section, title="console_panel_height", datatype=int, default=20, min_max=(10, 90), rounding=1, group="layout", info="How tall the bottom console panel is as a percentage of GUI height at startup.") + self.add_item( + section=section, title="icon_size", datatype=int, default=14, + min_max=(10, 20), rounding=1, group="layout", + info="Pixel size for icons. NB: Size is scaled by DPI.") self.add_item( section=section, title="font", datatype=str, choices=get_clean_fonts(), @@ -51,6 +55,23 @@ def set_globals(self): section=section, title="font_size", datatype=int, default=9, min_max=(6, 12), rounding=1, group="font", info="Global font size.") + self.add_item( + section=section, title="autosave_last_session", datatype=str, default="prompt", + choices=["never", "prompt", "always"], group="startup", gui_radio=True, + info="Automatically save the current settings on close and reload on startup" + "\n\tnever - Don't autosave session" + "\n\tprompt - Prompt to reload last session on launch" + "\n\talways - Always load last session on launch") + self.add_item( + section=section, title="timeout", datatype=int, default=120, + min_max=(10, 600), rounding=10, group="behaviour", + info="Training can take some time to save and shutdown. Set the timeout in seconds " + "before giving up and force quitting.") + self.add_item( + section=section, title="auto_load_model_stats", datatype=bool, default=True, + group="behaviour", + info="Auto load model statistics into the Analysis tab when selecting a model " + "in Train or Convert tabs.") def get_commands(): @@ -70,7 +91,7 @@ def get_commands(): def get_clean_fonts(): - """ Return the font list with any @prefixed or non-unicode characters stripped + """ Return the font list with any @prefixed or non-Unicode characters stripped and default prefixed """ cleaned_fonts = sorted([fnt for fnt in font.families() if not fnt.startswith("@") and not any([ord(c) > 127 for c in fnt])]) diff --git a/lib/gui/_redirector.py b/lib/gui/_redirector.py deleted file mode 100644 index df332911a7..0000000000 --- a/lib/gui/_redirector.py +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env python3 -""" Widget redirector from IdleLib -https://github.com/python/cpython/blob/master/Lib/idlelib/redirector.py -""" - -from tkinter import TclError - - -class WidgetRedirector: - """Support for redirecting arbitrary widget subcommands. - - Some Tk operations don't normally pass through tkinter. For example, if a - character is inserted into a Text widget by pressing a key, a default Tk - binding to the widget's 'insert' operation is activated, and the Tk library - processes the insert without calling back into tkinter. - - Although a binding to could be made via tkinter, what we really want - to do is to hook the Tk 'insert' operation itself. For one thing, we want - a text.insert call in idle code to have the same effect as a key press. - - When a widget is instantiated, a Tcl command is created whose name is the - same as the pathname widget._w. This command is used to invoke the various - widget operations, e.g. insert (for a Text widget). We are going to hook - this command and provide a facility ('register') to intercept the widget - operation. We will also intercept method calls on the tkinter class - instance that represents the tk widget. - - In IDLE, WidgetRedirector is used in Percolator to intercept Text - commands. The function being registered provides access to the top - of a Percolator chain. At the bottom of the chain is a call to the - original Tk widget operation. - """ - def __init__(self, widget): - """Initialize attributes and setup redirection. - - _operations: dict mapping operation name to new function. - widget: the widget whose tcl command is to be intercepted. - tk: widget.tk, a convenience attribute, probably not needed. - orig: new name of the original tcl command. - - Since renaming to orig fails with TclError when orig already - exists, only one WidgetDirector can exist for a given widget. - """ - self._operations = {} - self.widget = widget # widget instance - self.tk_ = tk_ = widget.tk # widget's root - wgt = widget._w # pylint:disable=protected-access # widget's (full) Tk pathname - self.orig = wgt + "_orig" - # Rename the Tcl command within Tcl: - tk_.call("rename", wgt, self.orig) - # Create a new Tcl command whose name is the widget's pathname, and - # whose action is to dispatch on the operation passed to the widget: - tk_.createcommand(wgt, self.dispatch) - - def __repr__(self): - return "%s(%s<%s>)" % (self.__class__.__name__, - self.widget.__class__.__name__, - self.widget._w) # pylint:disable=protected-access - - def close(self): - "Unregister operations and revert redirection created by .__init__." - for operation in list(self._operations): - self.unregister(operation) - widget = self.widget - tk_ = widget.tk - wgt = widget._w # pylint:disable=protected-access - # Restore the original widget Tcl command. - tk_.deletecommand(wgt) - tk_.call("rename", self.orig, wgt) - del self.widget, self.tk_ # Should not be needed - # if instance is deleted after close, as in Percolator. - - def register(self, operation, function): - """Return OriginalCommand(operation) after registering function. - - Registration adds an operation: function pair to ._operations. - It also adds a widget function attribute that masks the tkinter - class instance method. Method masking operates independently - from command dispatch. - - If a second function is registered for the same operation, the - first function is replaced in both places. - """ - self._operations[operation] = function - setattr(self.widget, operation, function) - return OriginalCommand(self, operation) - - def unregister(self, operation): - """Return the function for the operation, or None. - - Deleting the instance attribute unmasks the class attribute. - """ - if operation in self._operations: - function = self._operations[operation] - del self._operations[operation] - try: - delattr(self.widget, operation) - except AttributeError: - pass - return function - return None - - def dispatch(self, operation, *args): - """Callback from Tcl which runs when the widget is referenced. - - If an operation has been registered in self._operations, apply the - associated function to the args passed into Tcl. Otherwise, pass the - operation through to Tk via the original Tcl function. - - Note that if a registered function is called, the operation is not - passed through to Tk. Apply the function returned by self.register() - to *args to accomplish that. For an example, see colorizer.py. - - """ - op_ = self._operations.get(operation) - try: - if op_: - return op_(*args) - return self.tk_.call((self.orig, operation) + args) - except TclError: - return "" - - -class OriginalCommand: - """Callable for original tk command that has been redirected. - - Returned by .register; can be used in the function registered. - redir = WidgetRedirector(text) - def my_insert(*args): - print("insert", args) - original_insert(*args) - original_insert = redir.register("insert", my_insert) - """ - - def __init__(self, redir, operation): - """Create .tk_call and .orig_and_operation for .__call__ method. - - .redir and .operation store the input args for __repr__. - .tk and .orig copy attributes of .redir (probably not needed). - """ - self.redir = redir - self.operation = operation - self.tk_ = redir.tk_ # redundant with self.redir - self.orig = redir.orig # redundant with self.redir - # These two could be deleted after checking recipient code. - self.tk_call = redir.tk_.call - self.orig_and_operation = (redir.orig, operation) - - def __repr__(self): - return "%s(%r, %r)" % (self.__class__.__name__, - self.redir, self.operation) - - def __call__(self, *args): - return self.tk_call(self.orig_and_operation + args) diff --git a/lib/gui/command.py b/lib/gui/command.py index ca75b34fe8..08af864e13 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -5,8 +5,8 @@ import tkinter as tk from tkinter import ttk -from .control_helper import set_slider_rounding, ControlPanel -from .tooltip import Tooltip +from .control_helper import ControlPanel +from .custom_widgets import Tooltip from .utils import get_images, get_config logger = logging.getLogger(__name__) # pylint:disable=invalid-name @@ -24,9 +24,22 @@ def __init__(self, parent): self.tools_notebook = ToolsNotebook(self) self.set_running_task_trace() self.build_tabs() - get_config().command_notebook = self + self.modified_vars = self._set_modified_vars() + get_config().set_command_notebook(self) logger.debug("Initialized %s", self.__class__.__name__) + @property + def tab_names(self): + """ dict: Command tab titles with their IDs """ + return {self.tab(tab_id, "text").lower(): tab_id + for tab_id in range(0, self.index("end"))} + + @property + def tools_tab_names(self): + """ dict: Tools tab titles with their IDs """ + return {self.tools_notebook.tab(tab_id, "text").lower(): tab_id + for tab_id in range(0, self.tools_notebook.index("end"))} + def set_running_task_trace(self): """ Set trigger action for the running task to change the action buttons text and command """ @@ -56,15 +69,34 @@ def change_action_button(self, *args): for cmd, action in self.actionbtns.items(): btnact = action if tk_vars["runningtask"].get(): - ttl = "Terminate" + ttl = " Stop" + img = get_images().icons["stop"] hlp = "Exit the running process" else: - ttl = cmd.title() + ttl = " {}".format(cmd.title()) + img = get_images().icons["start"] hlp = "Run the {} script".format(cmd.title()) logger.debug("Updated Action Button: '%s'", ttl) - btnact.config(text=ttl) + btnact.config(text=ttl, image=img) Tooltip(btnact, text=hlp, wraplength=200) + def _set_modified_vars(self): + """ Set the tkinter variable for each tab to indicate whether contents + have been modified """ + tkvars = dict() + for tab in self.tab_names: + if tab == "tools": + for ttab in self.tools_tab_names: + var = tk.BooleanVar() + var.set(False) + tkvars[ttab] = var + continue + var = tk.BooleanVar() + var.set(False) + tkvars[tab] = var + logger.debug("Set modified vars: %s", tkvars) + return tkvars + class ToolsNotebook(ttk.Notebook): # pylint:disable=too-many-ancestors """ Tools sit in their own tab, but need to inherit objects from the main command notebook """ @@ -124,86 +156,38 @@ def __init__(self, parent): self.add_action_button(parent.category, parent.actionbtns) - self.add_util_buttons() logger.debug("Initialized %s", self.__class__.__name__) def add_action_button(self, category, actionbtns): """ Add the action buttons for page """ logger.debug("Add action buttons: '%s'", self.title) actframe = ttk.Frame(self) - actframe.pack(fill=tk.X, side=tk.LEFT) - tk_vars = get_config().tk_vars + actframe.pack(fill=tk.X, side=tk.RIGHT) + tk_vars = get_config().tk_vars var_value = "{},{}".format(category, self.command) - btnact = ttk.Button(actframe, - text=self.title, - width=10, - command=lambda: tk_vars["action"].set(var_value)) - btnact.pack(side=tk.LEFT) - Tooltip(btnact, - text="Run the {} script".format(self.title), - wraplength=200) - actionbtns[self.command] = btnact - btngen = ttk.Button(actframe, - text="Generate", - width=10, + image=get_images().icons["generate"], + text=" Generate", + compound=tk.LEFT, + width=14, command=lambda: tk_vars["generate"].set(var_value)) btngen.pack(side=tk.LEFT, padx=5) - if self.command == "train": - self.add_timeout(actframe) Tooltip(btngen, text="Output command line options to the console", wraplength=200) - logger.debug("Added action buttons: '%s'", self.title) - def add_timeout(self, actframe): - """ Add a timeout option for training """ - logger.debug("Adding timeout box for %s", self.command) - tk_var = get_config().tk_vars["traintimeout"] - min_max = (10, 600) - - frameto = ttk.Frame(actframe) - frameto.pack(padx=5, pady=5, side=tk.RIGHT, fill=tk.X, expand=True) - lblto = ttk.Label(frameto, text="Timeout:", anchor=tk.W) - lblto.pack(side=tk.LEFT) - sldto = ttk.Scale(frameto, - variable=tk_var, - from_=min_max[0], - to=min_max[1], - command=lambda val, var=tk_var, dt=int, rn=10, mm=min_max: - set_slider_rounding(val, var, dt, rn, mm)) - sldto.pack(padx=5, side=tk.LEFT, fill=tk.X, expand=True) - tboxto = ttk.Entry(frameto, width=3, textvariable=tk_var, justify=tk.RIGHT) - tboxto.pack(side=tk.RIGHT) - helptxt = ("Training can take some time to save and shutdown. " - "Set the timeout in seconds before giving up and force quitting.") - Tooltip(sldto, - text=helptxt, - wraplength=200) - Tooltip(tboxto, - text=helptxt, + btnact = ttk.Button(actframe, + image=get_images().icons["start"], + text=" {}".format(self.title), + compound=tk.LEFT, + width=14, + command=lambda: tk_vars["action"].set(var_value)) + btnact.pack(side=tk.LEFT, fill=tk.X, expand=True) + Tooltip(btnact, + text="Run the {} script".format(self.title), wraplength=200) - logger.debug("Added timeout box for %s", self.command) - - def add_util_buttons(self): - """ Add the section utility buttons """ - logger.debug("Add util buttons") - utlframe = ttk.Frame(self) - utlframe.pack(side=tk.RIGHT) - - config = get_config() - for utl in ("load", "save", "clear", "reset"): - logger.debug("Adding button: '%s'", utl) - img = get_images().icons[utl] - action_cls = config if utl in (("save", "load")) else config.cli_opts - action = getattr(action_cls, utl) - btnutl = ttk.Button(utlframe, - image=img, - command=lambda cmd=action: cmd(self.command)) - btnutl.pack(padx=2, side=tk.LEFT) - Tooltip(btnutl, - text=utl.capitalize() + " " + self.title + " config", - wraplength=200) - logger.debug("Added util buttons") + actionbtns[self.command] = btnact + + logger.debug("Added action buttons: '%s'", self.title) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index dd4059b725..dee44a8843 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -10,8 +10,9 @@ from _tkinter import Tcl_Obj -from .tooltip import Tooltip -from .utils import ContextMenu, FileHandler, get_config, get_images +from .custom_widgets import ContextMenu +from .custom_widgets import Tooltip +from .utils import FileHandler, get_config, get_images logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -21,7 +22,7 @@ _RECREATE_OBJECTS = dict(tooltips=dict(), commands=dict(), contextmenus=dict()) -def get_tooltip(widget, text, wraplength=600): +def _get_tooltip(widget, text, wraplength=600): """ Store the tooltip layout and widget id in _TOOLTIPS and return a tooltip """ _RECREATE_OBJECTS["tooltips"][str(widget)] = {"text": text, "wraplength": wraplength} @@ -30,7 +31,7 @@ def get_tooltip(widget, text, wraplength=600): return Tooltip(widget, text=text, wraplength=wraplength) -def get_contextmenu(widget): +def _get_contextmenu(widget): """ Create a context menu, store its mapping and return """ rc_menu = ContextMenu(widget) _RECREATE_OBJECTS["contextmenus"][str(widget)] = rc_menu @@ -39,7 +40,7 @@ def get_contextmenu(widget): return rc_menu -def add_command(name, func): +def _add_command(name, func): """ For controls that execute commands, the command must be added to the _COMMAND list so that it can be added back to the widget during cloning """ logger.debug("Adding to commands: %s - %s", name, func) @@ -47,7 +48,22 @@ def add_command(name, func): def set_slider_rounding(value, var, d_type, round_to, min_max): - """ Set the underlying variable to correct number based on slider rounding """ + """ Set the value of sliders underlying variable based on their datatype, + rounding value and min/max. + + Parameters + ---------- + var: tkinter.Var + The variable to set the value for + d_type: [:class:`int`, :class:`float`] + The type of value that is stored in :attr:`var` + round_to: int + If :attr:`dtype` is :class:`float` then this is the decimal place rounding for :attr:`var`. + If :attr:`dtype` is :class:`int` then this is the number of steps between each increment + for :attr:`var` + min_max: tuple (`int`, `int`) + The (``min``, ``max``) values that this slider accepts + """ if d_type == float: var.set(round(float(value), round_to)) else: @@ -56,12 +72,6 @@ def set_slider_rounding(value, var, d_type, round_to, min_max): var.set(value) -def adjust_wraplength(event): - """ dynamically adjust the wraplength of a label on event """ - label = event.widget - label.configure(wraplength=event.width - 1) - - class ControlPanelOption(): """ A class to hold a control panel option. A list of these is expected @@ -94,19 +104,26 @@ class ControlPanelOption(): Expects a dict: {sysbrowser: str, filetypes: str} helptext: str, optional Sets the tooltip text + track_modified: bool, optional + Set whether to set a callback trace indicating that the parameter has been modified. + Default: False + command: str, optional + Required if tracking modified. The command that this option belongs to. Default: None """ def __init__(self, title, dtype, # pylint:disable=too-many-arguments group=None, default=None, initial_value=None, choices=None, is_radio=False, - rounding=None, min_max=None, sysbrowser=None, helptext=None): + rounding=None, min_max=None, sysbrowser=None, helptext=None, + track_modified=False, command=None): logger.debug("Initializing %s: (title: '%s', dtype: %s, group: %s, default: %s, " "initial_value: %s, choices: %s, is_radio: %s, rounding: %s, min_max: %s, " - "sysbrowser: %s, helptext: '%s')", self.__class__.__name__, title, dtype, - group, default, initial_value, choices, is_radio, rounding, min_max, - sysbrowser, helptext) + "sysbrowser: %s, helptext: '%s', track_modified: %s, command: '%s')", + self.__class__.__name__, title, dtype, group, default, initial_value, choices, + is_radio, rounding, min_max, sysbrowser, helptext, track_modified, command) self.dtype = dtype self.sysbrowser = sysbrowser + self._command = command self._options = dict(title=title, group=group, default=default, @@ -117,7 +134,7 @@ def __init__(self, title, dtype, # pylint:disable=too-many-arguments min_max=min_max, helptext=helptext) self.control = self.get_control() - self.tk_var = self.get_tk_var() + self.tk_var = self.get_tk_var(track_modified) logger.debug("Initialized %s", self.__class__.__name__) @property @@ -208,7 +225,7 @@ def get_control(self): logger.debug("Setting control '%s' to %s", self.title, control) return control - def get_tk_var(self): + def get_tk_var(self, track_modified): """ Correct variable type for control """ if self.dtype == bool: var = tk.BooleanVar() @@ -220,8 +237,43 @@ def get_tk_var(self): var = tk.StringVar() logger.debug("Setting tk variable: (name: '%s', dtype: %s, tk_var: %s)", self.name, self.dtype, var) + if track_modified and self._command is not None: + logger.debug("Tracking variable modification: %s", self.name) + var.trace("w", + lambda name, index, mode, cmd=self._command: self._modified_callback(cmd)) + + if track_modified and self._command in ("train", "convert") and self.title == "Model Dir": + var.trace("w", lambda name, index, mode, v=var: self._model_callback(v)) + return var + @staticmethod + def _modified_callback(command): + """ Set the modified variable for this tab to TRUE + + On initial setup the notebook won't yet exist, and we don't want to track the changes + for initial variables anyway, so make sure notebook exists prior to performing the callback + """ + config = get_config() + if config.command_notebook is None: + return + config.set_modified_true(command) + + @staticmethod + def _model_callback(var): + """ Set a callback to load model stats for existing models when a model + folder is selected """ + config = get_config() + if not config.user_config_dict["auto_load_model_stats"]: + logger.debug("Session updating disabled by user config") + return + if config.tk_vars["runningtask"].get(): + logger.debug("Task running. Not updating session") + return + folder = var.get() + logger.debug("Setting analysis model folder callback: '%s'", folder) + get_config().tk_vars["analysis_folder"].set(folder) + class ControlPanel(ttk.Frame): # pylint:disable=too-many-ancestors """ @@ -233,7 +285,7 @@ class ControlPanel(ttk.Frame): # pylint:disable=too-many-ancestors Parameters ---------- - parent: tk object + parent: tkinter object Parent widget that should hold this control panel options: list of ControlPanelOptions objects The list of controls that are to be built into this control panel @@ -242,18 +294,18 @@ class ControlPanel(ttk.Frame): # pylint:disable=too-many-ancestors Defaults to 20 columns: int, optional The maximum number of columns that this control panel should be able - to accomodate. Setting to 1 means that there will only be 1 column + to accommodate. Setting to 1 means that there will only be 1 column regardless of how wide the control panel is. Higher numbers will dynamically fill extra columns if space permits. Defaults to 1 option_columns: int, optional - For checkbutton and radiobutton containers, how many options should + For check-button and radio-button containers, how many options should be displayed on each row. Defaults to 4 header_text: str, optional If provided, will place an information box at the top of the control panel with these contents. blank_nones: bool, optional - How the control panel should handle Nones. If set to True then Nones - will be converted to empty strings. Default: False + How the control panel should handle None values. If set to True then None values will be + converted to empty strings. Default: False """ def __init__(self, parent, options, # pylint:disable=too-many-arguments @@ -285,8 +337,14 @@ def __init__(self, parent, options, # pylint:disable=too-many-arguments logger.debug("Initialized %s", self.__class__.__name__) + @staticmethod + def _adjust_wraplength(event): + """ dynamically adjust the wrap length of a label on event """ + label = event.widget + label.configure(wraplength=event.width - 1) + def get_opts_frame(self): - """ Return an autofill container for the options inside a main frame """ + """ Return an auto-fill container for the options inside a main frame """ mainframe = ttk.Frame(self.canvas) if self.header_text is not None: self.add_info(mainframe) @@ -315,7 +373,7 @@ def add_info(self, frame): continue style = "Header.TLabel" if idx == 0 else "Body.TLabel" info = ttk.Label(label_frame, text=line, style=style, anchor=tk.W) - info.bind("", adjust_wraplength) + info.bind("", self._adjust_wraplength) info.pack(fill=tk.X, padx=0, pady=0, expand=True, side=tk.TOP) def build_panel(self, blank_nones): @@ -396,7 +454,7 @@ def checkbuttons_frame(self, frame): class AutoFillContainer(): - """ A container object that autofills columns """ + """ A container object that auto-fills columns """ def __init__(self, parent, columns): logger.debug("Initializing: %s: (parent: %s, columns: %s)", self.__class__.__name__, parent, columns) @@ -425,12 +483,12 @@ def scale_column_width(original_size, original_fontsize): @property def items(self): - """ Returns the number of items held in this containter """ + """ Returns the number of items held in this container """ return self._items @property def subframe(self): - """ Returns the next subframe to be populated """ + """ Returns the next sub-frame to be populated """ frame = self.subframes[self._idx] next_idx = self._idx + 1 if self._idx + 1 < self.columns else 0 logger.debug("current_idx: %s, next_idx: %s", self._idx, next_idx) @@ -439,7 +497,7 @@ def subframe(self): return frame def set_subframes(self): - """ Set a subrame for each possible column """ + """ Set a sub-frame for each possible column """ subframes = [] for idx in range(self.max_columns): name = "af_subframe_{}".format(idx) @@ -520,7 +578,7 @@ def get_all_children_config(self, widget, child_list): def config_cleaner(widget): """ Some options don't like to be copied, so this returns a cleaned configuration from a widget - We use config() instead of configure() because some items (TScale) do + We use config() instead of configure() because some items (ttk Scale) do not populate configure()""" new_config = dict() for key in widget.config(): @@ -569,7 +627,7 @@ def pack_widget_clones(self, widget_dicts, old_children=None, new_children=None) parent = new_children[old_children.index(widget_dict["parent"])] logger.trace("old parent: '%s', new_parent: '%s'", widget_dict["parent"], parent) else: - # Get the next subframe if this doesn't have a logged parent + # Get the next sub-frame if this doesn't have a logged parent parent = self.subframe clone = widget_dict["class"](parent, name=widget_dict["name"]) if widget_dict["config"] is not None: @@ -600,11 +658,11 @@ class ControlBuilder(): option: ControlPanelOption object Holds all of the required option information option_columns: int - Number of options to put on a single row for checkbuttons/radiobuttons + Number of options to put on a single row for check-buttons/radio-buttons label_width: int Sets the width of the control label - checkbuttons_frame: tk.frame - If a checkbutton frame is passed in, then checkbuttons will be placed in this frame + checkbuttons_frame: tkinter.frame + If a check-button frame is passed in, then check-buttons will be placed in this frame rather than the main options frame blank_nones: bool Sets selected values to an empty string rather than None if this is true. @@ -629,7 +687,7 @@ def __init__(self, parent, option, option_columns, # pylint: disable=too-many-a self.build_control() logger.debug("Initialized: %s", self.__class__.__name__) - # Frame, control type and varable + # Frame, control type and variable def control_frame(self, parent): """ Frame to hold control and it's label """ logger.debug("Build control frame") @@ -660,7 +718,7 @@ def build_control_label(self): lbl = ttk.Label(self.frame, text=self.option.title, width=self.label_width, anchor=tk.W) lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) if self.option.helptext is not None: - get_tooltip(lbl, text=self.option.helptext, wraplength=600) + _get_tooltip(lbl, text=self.option.helptext, wraplength=600) logger.debug("Built control label: (widget: '%s', title: '%s'", self.option.name, self.option.title) @@ -678,7 +736,7 @@ def build_one_control(self): if self.option.control != ttk.Checkbutton: ctl.pack(padx=5, pady=5, fill=tk.X, expand=True) if self.option.helptext is not None and not self.helpset: - get_tooltip(ctl, text=self.option.helptext, wraplength=600) + _get_tooltip(ctl, text=self.option.helptext, wraplength=600) logger.debug("Built control: '%s'", self.option.name) @@ -707,7 +765,7 @@ def radio_control(self): helptext = "{}\n\n - {}".format( '. '.join(item.capitalize() for item in helptext.split('. ')), intro) - get_tooltip(radio, text=helptext, wraplength=600) + _get_tooltip(radio, text=helptext, wraplength=600) radio.pack(anchor=tk.W) logger.debug("Added radio option %s", choice) return radio_holder.parent @@ -729,8 +787,8 @@ def slider_control(self): round_to=self.option.rounding, min_max=self.option.min_max) ctl = self.option.control(self.frame, variable=self.option.tk_var, command=cmd) - add_command(ctl.cget("command"), cmd) - rc_menu = get_contextmenu(tbox) + _add_command(ctl.cget("command"), cmd) + rc_menu = _get_contextmenu(tbox) rc_menu.cm_bind() ctl["from_"] = self.option.min_max[0] ctl["to"] = self.option.min_max[1] @@ -745,13 +803,14 @@ def control_to_optionsframe(self): ctl = self.option.control(self.frame, variable=self.option.tk_var, text=None) else: if self.option.sysbrowser is not None: - self.filebrowser = FileBrowser(self.option.tk_var, + self.filebrowser = FileBrowser(self.option.name, + self.option.tk_var, self.frame, self.option.sysbrowser) ctl = self.option.control(self.frame, textvariable=self.option.tk_var, font=get_config().default_font) - rc_menu = get_contextmenu(ctl) + rc_menu = _get_contextmenu(ctl) rc_menu.cm_bind() if self.option.choices: logger.debug("Adding combo choices: %s", self.option.choices) @@ -760,14 +819,14 @@ def control_to_optionsframe(self): return ctl def control_to_checkframe(self): - """ Add checkbuttons to the checkbutton frame """ + """ Add check-buttons to the check-button frame """ logger.debug("Add control checkframe: '%s'", self.option.name) chkframe = self.chkbtns.subframe ctl = self.option.control(chkframe, variable=self.option.tk_var, text=self.option.title, name=self.option.name) - get_tooltip(ctl, text=self.option.helptext, wraplength=600) + _get_tooltip(ctl, text=self.option.helptext, wraplength=600) ctl.pack(side=tk.TOP, anchor=tk.W) logger.debug("Added control checkframe: '%s'", self.option.name) return ctl @@ -775,9 +834,10 @@ def control_to_checkframe(self): class FileBrowser(): """ Add FileBrowser buttons to control and handle routing """ - def __init__(self, tk_var, control_frame, sysbrowser_dict): + def __init__(self, opt_name, tk_var, control_frame, sysbrowser_dict): logger.debug("Initializing: %s: (tk_var: %s, control_frame: %s, sysbrowser_dict: %s)", self.__class__.__name__, tk_var, control_frame, sysbrowser_dict) + self._opt_name = opt_name self.tk_var = tk_var self.frame = control_frame self.browser = sysbrowser_dict["browser"] @@ -793,9 +853,13 @@ def helptext(self): """ Dict containing tooltip text for buttons """ retval = dict(folder="Select a folder...", load="Select a file...", - load_multi="Select one or more files...", + load2="Select a file...", + picture="Select a folder of images...", + video="Select a video...", + model="Select a model folder...", + multi_load="Select one or more files...", context="Select a file or folder...", - save="Select a save location...") + save_as="Select a save location...") return retval @staticmethod @@ -812,14 +876,30 @@ def format_action_option(action_option): def add_browser_buttons(self): """ Add correct file browser button for control """ logger.debug("Adding browser buttons: (sysbrowser: %s", self.browser) + frame = ttk.Frame(self.frame) + frame.pack(side=tk.RIGHT, padx=(0, 5)) + for browser in self.browser: - img = get_images().icons[browser] + if browser == "save": + lbl = "save_as" + elif browser == "load" and self.filetypes == "video": + lbl = self.filetypes + elif browser == "load": + lbl = "load2" + elif browser == "folder" and (self._opt_name.startswith(("frames", "faces")) + or "input" in self._opt_name): + lbl = "picture" + elif browser == "folder" and "model" in self._opt_name: + lbl = "model" + else: + lbl = browser + img = get_images().icons[lbl] action = getattr(self, "ask_" + browser) cmd = partial(action, filepath=self.tk_var, filetypes=self.filetypes) - fileopn = ttk.Button(self.frame, image=img, command=cmd) - add_command(fileopn.cget("command"), cmd) - fileopn.pack(padx=(0, 5), side=tk.RIGHT) - get_tooltip(fileopn, text=self.helptext[browser], wraplength=600) + fileopn = ttk.Button(frame, image=img, command=cmd) + _add_command(fileopn.cget("command"), cmd) + fileopn.pack(padx=0, side=tk.RIGHT) + _get_tooltip(fileopn, text=self.helptext[lbl], wraplength=600) logger.debug("Added browser buttons: (action: %s, filetypes: %s", action, self.filetypes) @@ -853,7 +933,7 @@ def ask_load(filepath, filetypes): filepath.set(filename) @staticmethod - def ask_load_multi(filepath, filetypes): + def ask_multi_load(filepath, filetypes): """ Pop-up to get path to a file """ filenames = FileHandler("filename_multi", filetypes).retfile if filenames: diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py new file mode 100644 index 0000000000..4d290920b1 --- /dev/null +++ b/lib/gui/custom_widgets.py @@ -0,0 +1,635 @@ +#!/usr/bin/env python3 +""" Custom widgets for Faceswap GUI """ + +import logging +import platform +import re +import sys +import tkinter as tk +from tkinter import ttk, TclError + +from .utils import get_config + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class ContextMenu(tk.Menu): # pylint: disable=too-many-ancestors + """ A Pop up menu to be triggered when right clicking on widgets that this menu has been + applied to. + + This widget provides a simple right click pop up menu to the widget passed in with `Cut`, + `Copy`, `Paste` and `Select all` menu items. + + Parameters + ---------- + widget: tkinter object + The widget to apply the :class:`ContextMenu` to + + Example + ------- + >>> text_box = ttk.Entry(parent) + >>> text_box.pack() + >>> right_click_menu = ContextMenu(text_box) + >>> right_click_menu.cm_bind() + """ + def __init__(self, widget): + logger.debug("Initializing %s: (widget_class: '%s')", + self.__class__.__name__, widget.winfo_class()) + super().__init__(tearoff=0) + self._widget = widget + self._standard_actions() + logger.debug("Initialized %s", self.__class__.__name__) + + def _standard_actions(self): + """ Standard menu actions """ + self.add_command(label="Cut", command=lambda: self._widget.event_generate("<>")) + self.add_command(label="Copy", command=lambda: self._widget.event_generate("<>")) + self.add_command(label="Paste", command=lambda: self._widget.event_generate("<>")) + self.add_separator() + self.add_command(label="Select all", command=self._select_all) + + def cm_bind(self): + """ Bind the menu to the given widgets Right Click event + + After associating a widget with this :class:`ContextMenu` this function should be called + to bind it to the right click button + """ + button = "" if platform.system() == "Darwin" else "" + logger.debug("Binding '%s' to '%s'", button, self._widget.winfo_class()) + scaling_factor = get_config().scaling_factor if get_config() is not None else 1.0 + x_offset = int(34 * scaling_factor) + self._widget.bind(button, + lambda event: self.tk_popup(event.x_root + x_offset, event.y_root, 0)) + + def _select_all(self): + """ Select all for Text or Entry widgets """ + logger.debug("Selecting all for '%s'", self._widget.winfo_class()) + if self._widget.winfo_class() == "Text": + self._widget.focus_force() + self._widget.tag_add("sel", "1.0", "end") + else: + self._widget.focus_force() + self._widget.select_range(0, tk.END) + + +class ConsoleOut(ttk.Frame): # pylint: disable=too-many-ancestors + """ The Console out section of the GUI. + + A Read only text box for displaying the output from stdout/stderr. + + All handling is internal to this method. To clear the console, the stored tkinter variable in + :attr:`~lib.gui.Config.tk_vars` ``consoleclear`` should be triggered. + + Parameters + ---------- + parent: tkinter object + The Console's parent widget + debug: bool + ``True`` if console output should not be directed to this widget otherwise ``False`` + + """ + + def __init__(self, parent, debug): + logger.debug("Initializing %s: (parent: %s, debug: %s)", + self.__class__.__name__, parent, debug) + super().__init__(parent) + self.pack(side=tk.TOP, anchor=tk.W, padx=10, pady=(2, 0), + fill=tk.BOTH, expand=True) + self._console = _ReadOnlyText(self) + rc_menu = ContextMenu(self._console) + rc_menu.cm_bind() + self._console_clear = get_config().tk_vars['consoleclear'] + self._set_console_clear_var_trace() + self._debug = debug + self._build_console() + self._add_tags() + logger.debug("Initialized %s", self.__class__.__name__) + + def _set_console_clear_var_trace(self): + """ Set a trace on the consoleclear tkinter variable to trigger :func:`_clear` """ + logger.debug("Set clear trace") + self._console_clear.trace("w", self._clear) + + def _build_console(self): + """ Build and place the console and add stdout/stderr redirection """ + logger.debug("Build console") + self._console.config(width=100, height=6, bg="gray90", fg="black") + self._console.pack(side=tk.LEFT, anchor=tk.N, fill=tk.BOTH, expand=True) + + scrollbar = ttk.Scrollbar(self, command=self._console.yview) + scrollbar.pack(side=tk.LEFT, fill="y") + self._console.configure(yscrollcommand=scrollbar.set) + + self._redirect_console() + logger.debug("Built console") + + def _add_tags(self): + """ Add tags to text widget to color based on output """ + logger.debug("Adding text color tags") + self._console.tag_config("default", foreground="#1E1E1E") + self._console.tag_config("stderr", foreground="#E25056") + self._console.tag_config("info", foreground="#2B445E") + self._console.tag_config("verbose", foreground="#008140") + self._console.tag_config("warning", foreground="#F77B00") + self._console.tag_config("critical", foreground="red") + self._console.tag_config("error", foreground="red") + + def _redirect_console(self): + """ Redirect stdout/stderr to console Text Box """ + logger.debug("Redirect console") + if self._debug: + logger.info("Console debug activated. Outputting to main terminal") + else: + sys.stdout = _SysOutRouter(self._console, "stdout") + sys.stderr = _SysOutRouter(self._console, "stderr") + logger.debug("Redirected console") + + def _clear(self, *args): # pylint: disable=unused-argument + """ Clear the console output screen """ + logger.debug("Clear console") + if not self._console_clear.get(): + logger.debug("Console not set for clearing. Skipping") + return + self._console.delete(1.0, tk.END) + self._console_clear.set(False) + logger.debug("Cleared console") + + +class _ReadOnlyText(tk.Text): # pylint: disable=too-many-ancestors + """ A read only text widget. + + Standard tkinter Text widgets are read/write by default. As we want to make the console + display writable by the Faceswap process but not the user, we need to redirect its insert and + delete attributes. + + Source: https://stackoverflow.com/questions/3842155 + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.redirector = _WidgetRedirector(self) + self.insert = self.redirector.register("insert", lambda *args, **kw: "break") + self.delete = self.redirector.register("delete", lambda *args, **kw: "break") + + +class _SysOutRouter(): + """ Route stdout/stderr to the given text box. + + Parameters + ---------- + console: tkinter Object + The widget that will receive the output from stderr/stdout + out_type: ['stdout', 'stderr'] + The output type to redirect + """ + + def __init__(self, console, out_type): + logger.debug("Initializing %s: (console: %s, out_type: '%s')", + self.__class__.__name__, console, out_type) + self._console = console + self._out_type = out_type + self._recolor = re.compile(r".+?(\s\d+:\d+:\d+\s)(?P[A-Z]+)\s") + logger.debug("Initialized %s", self.__class__.__name__) + + def _get_tag(self, string): + """ Set the tag based on regex of log output """ + if self._out_type == "stderr": + # Output all stderr in red + return self._out_type + + output = self._recolor.match(string) + if not output: + return "default" + tag = output.groupdict()["lvl"].strip().lower() + return tag + + def write(self, string): + """ Capture stdout/stderr """ + self._console.insert(tk.END, string, self._get_tag(string)) + self._console.see(tk.END) + + @staticmethod + def flush(): + """ If flush is forced, send it to normal terminal """ + sys.__stdout__.flush() + + +class _WidgetRedirector: + """Support for redirecting arbitrary widget sub-commands. + + Some Tk operations don't normally pass through tkinter. For example, if a + character is inserted into a Text widget by pressing a key, a default Tk + binding to the widget's 'insert' operation is activated, and the Tk library + processes the insert without calling back into tkinter. + + Although a binding to could be made via tkinter, what we really want + to do is to hook the Tk 'insert' operation itself. For one thing, we want + a text.insert call in idle code to have the same effect as a key press. + + When a widget is instantiated, a Tcl command is created whose name is the + same as the path name widget._w. This command is used to invoke the various + widget operations, e.g. insert (for a Text widget). We are going to hook + this command and provide a facility ('register') to intercept the widget + operation. We will also intercept method calls on the tkinter class + instance that represents the tk widget. + + In IDLE, WidgetRedirector is used in Percolator to intercept Text + commands. The function being registered provides access to the top + of a Percolator chain. At the bottom of the chain is a call to the + original Tk widget operation. + + Attributes + ----------- + _operations: dict + Dictionary mapping operation name to new function. widget: the widget whose tcl command + is to be intercepted. + tk: widget.tk + A convenience attribute, probably not needed. + orig: str + new name of the original tcl command. + + Notes + ----- + Since renaming to orig fails with TclError when orig already exists, only one + WidgetDirector can exist for a given widget. + """ + def __init__(self, widget): + self._operations = {} + self.widget = widget # widget instance + self.tk_ = tk_ = widget.tk # widget's root + wgt = widget._w # pylint:disable=protected-access # widget's (full) Tk pathname + self.orig = wgt + "_orig" + # Rename the Tcl command within Tcl: + tk_.call("rename", wgt, self.orig) + # Create a new Tcl command whose name is the widget's path name, and + # whose action is to dispatch on the operation passed to the widget: + tk_.createcommand(wgt, self.dispatch) + + def __repr__(self): + return "%s(%s<%s>)" % (self.__class__.__name__, + self.widget.__class__.__name__, + self.widget._w) # pylint:disable=protected-access + + def close(self): + "Unregister operations and revert redirection created by .__init__." + for operation in list(self._operations): + self.unregister(operation) + widget = self.widget + tk_ = widget.tk + wgt = widget._w # pylint:disable=protected-access + # Restore the original widget Tcl command. + tk_.deletecommand(wgt) + tk_.call("rename", self.orig, wgt) + del self.widget, self.tk_ # Should not be needed + # if instance is deleted after close, as in Percolator. + + def register(self, operation, function): + """Return _OriginalCommand(operation) after registering function. + + Registration adds an operation: function pair to ._operations. + It also adds a widget function attribute that masks the tkinter + class instance method. Method masking operates independently + from command dispatch. + + If a second function is registered for the same operation, the + first function is replaced in both places. + """ + self._operations[operation] = function + setattr(self.widget, operation, function) + return _OriginalCommand(self, operation) + + def unregister(self, operation): + """Return the function for the operation, or None. + + Deleting the instance attribute unmasks the class attribute. + """ + if operation in self._operations: + function = self._operations[operation] + del self._operations[operation] + try: + delattr(self.widget, operation) + except AttributeError: + pass + return function + return None + + def dispatch(self, operation, *args): + """Callback from Tcl which runs when the widget is referenced. + + If an operation has been registered in self._operations, apply the + associated function to the args passed into Tcl. Otherwise, pass the + operation through to Tk via the original Tcl function. + + Note that if a registered function is called, the operation is not + passed through to Tk. Apply the function returned by self.register() + to *args to accomplish that. For an example, see colorizer.py. + + """ + op_ = self._operations.get(operation) + try: + if op_: + return op_(*args) + return self.tk_.call((self.orig, operation) + args) + except TclError: + return "" + + +class _OriginalCommand: + """Callable for original tk command that has been redirected. + + Returned by .register; can be used in the function registered. + redir = WidgetRedirector(text) + def my_insert(*args): + print("insert", args) + original_insert(*args) + original_insert = redir.register("insert", my_insert) + """ + + def __init__(self, redir, operation): + """Create .tk_call and .orig_and_operation for .__call__ method. + + .redir and .operation store the input args for __repr__. + .tk and .orig copy attributes of .redir (probably not needed). + """ + self.redir = redir + self.operation = operation + self.tk_ = redir.tk_ # redundant with self.redir + self.orig = redir.orig # redundant with self.redir + # These two could be deleted after checking recipient code. + self.tk_call = redir.tk_.call + self.orig_and_operation = (redir.orig, operation) + + def __repr__(self): + return "%s(%r, %r)" % (self.__class__.__name__, + self.redir, self.operation) + + def __call__(self, *args): + return self.tk_call(self.orig_and_operation + args) + + +class StatusBar(ttk.Frame): # pylint: disable=too-many-ancestors + """ Status Bar for displaying the Status Message and Progress Bar at the + bottom of the GUI. """ + + def __init__(self, parent): + ttk.Frame.__init__(self, parent) + self.pack(side=tk.BOTTOM, padx=10, pady=2, fill=tk.X, expand=False) + + self._status_message = tk.StringVar() + self._pbar_message = tk.StringVar() + self._pbar_position = tk.IntVar() + + self._status_message.set("Ready") + + self._status() + self._pbar = self._progress_bar() + + @property + def status_message(self): + """:class:`tkinter.StringVar`: The variable to hold the status bar message on the left + hand side of the status bar. """ + return self._status_message + + def _status(self): + """ Place Status label into left of the status bar. """ + statusframe = ttk.Frame(self) + statusframe.pack(side=tk.LEFT, anchor=tk.W, fill=tk.X, expand=False) + + lbltitle = ttk.Label(statusframe, text="Status:", width=6, anchor=tk.W) + lbltitle.pack(side=tk.LEFT, expand=False) + + lblstatus = ttk.Label(statusframe, + width=40, + textvariable=self._status_message, + anchor=tk.W) + lblstatus.pack(side=tk.LEFT, anchor=tk.W, fill=tk.X, expand=True) + + def _progress_bar(self): + """ Place progress bar into right of the status bar. """ + progressframe = ttk.Frame(self) + progressframe.pack(side=tk.RIGHT, anchor=tk.E, fill=tk.X) + + lblmessage = ttk.Label(progressframe, textvariable=self._pbar_message) + lblmessage.pack(side=tk.LEFT, padx=3, fill=tk.X, expand=True) + + pbar = ttk.Progressbar(progressframe, + length=200, + variable=self._pbar_position, + maximum=100, + mode="determinate") + pbar.pack(side=tk.LEFT, padx=2, fill=tk.X, expand=True) + pbar.pack_forget() + return pbar + + def progress_start(self, mode): + """ Set progress bar mode and display, + + Parameters + ---------- + mode: ["indeterminate", "determinate"] + The mode that the progress bar should be executed in + """ + self._progress_set_mode(mode) + self._pbar.pack() + + def progress_stop(self): + """ Reset progress bar and hide """ + self._pbar_message.set("") + self._pbar_position.set(0) + self._progress_set_mode("determinate") + self._pbar.pack_forget() + + def _progress_set_mode(self, mode): + """ Set the progress bar mode """ + self._pbar.config(mode=mode) + if mode == "indeterminate": + self._pbar.config(maximum=100) + self._pbar.start() + else: + self._pbar.stop() + self._pbar.config(maximum=100) + + def progress_update(self, message, position, update_position=True): + """ Update the GUIs progress bar and position. + + Parameters + ---------- + message: str + The message to display next to the progress bar + position: int + The position that the progress bar should be set to + update_position: bool, optional + If ``True`` then the progress bar will be updated to the position given in + :attr:`position`. If ``False`` the progress bar will not be updates. Default: ``True`` + """ + self._pbar_message.set(message) + if update_position: + self._pbar_position.set(position) + + +class Tooltip: + """ + Create a tooltip for a given widget as the mouse goes on it. + + Parameters + ---------- + widget: tkinter object + The widget to apply the tool-tip to + background: str, optional + The hex code for the background color. Default:'#FFFFEA' + pad: tuple, optional + (left, top, right, bottom) padding for the tool-tip. Default: (5, 3, 5, 3) + text: str, optional + The text to be displayed in the tool-tip. Default: 'widget info' + waittime: int, optional + The time in miliseconds to wait before showing the tool-tip. Default: 400 + wraplength: int, optional + The text length for each line before wrapping. Default: 250 + + Example + ------- + >>> button = ttk.Button(parent, text="Exit") + >>> Tooltip(button, text="Click to exit") + >>> button.pack() + + Notes + ----- + Adapted from StackOverflow: http://stackoverflow.com/questions/3221956 and + http://www.daniweb.com/programming/software-development/code/484591/a-tooltip-class-for-tkinter + + + - Originally written by vegaseat on 2014.09.09. + - Modified to include a delay time by Victor Zaccardo on 2016.03.25. + - Modified to correct extreme right and extreme bottom behavior by Alberto Vassena on \ + 2016.11.05. + - Modified to stay inside the screen whenever the tooltip might go out on the top but still \ + the screen is higher than the tooltip by Alberto Vassena on 2016.11.05. + - Modified to use the more flexible mouse positioning by Alberto Vassena on 2016.11.05. + - Modified to add customizable background color, padding, waittime and wraplength on creation \ + by Alberto Vassena on 2016.11.05. + + Tested on Ubuntu 16.04/16.10, running Python 3.5.2 + """ + def __init__(self, widget, *, background="#FFFFEA", pad=(5, 3, 5, 3), text="widget info", + waittime=400, wraplength=250): + + self._waittime = waittime # in milliseconds, originally 500 + self._wraplength = wraplength # in pixels, originally 180 + self._widget = widget + self._text = text + self._widget.bind("", self._on_enter) + self._widget.bind("", self._on_leave) + self._widget.bind("", self._on_leave) + self._background = background + self._pad = pad + self._ident = None + self._topwidget = None + + def _on_enter(self, event=None): # pylint:disable=unused-argument + """ Schedule on an enter event """ + self._schedule() + + def _on_leave(self, event=None): # pylint:disable=unused-argument + """ Unschedule on a leave event """ + self._unschedule() + self._hide() + + def _schedule(self): + """ Show the tooltip after wait period """ + self._unschedule() + self._ident = self._widget.after(self._waittime, self._show) + + def _unschedule(self): + """ Hide the tooltip """ + id_ = self._ident + self._ident = None + if id_: + self._widget.after_cancel(id_) + + def _show(self): + """ Show the tooltip """ + def tip_pos_calculator(widget, label, + *, + tip_delta=(10, 5), pad=(5, 3, 5, 3)): + """ Calculate the tooltip position """ + + s_width, s_height = widget.winfo_screenwidth(), widget.winfo_screenheight() + + width, height = (pad[0] + label.winfo_reqwidth() + pad[2], + pad[1] + label.winfo_reqheight() + pad[3]) + + mouse_x, mouse_y = widget.winfo_pointerxy() + + x_1, y_1 = mouse_x + tip_delta[0], mouse_y + tip_delta[1] + x_2, y_2 = x_1 + width, y_1 + height + + x_delta = x_2 - s_width + if x_delta < 0: + x_delta = 0 + y_delta = y_2 - s_height + if y_delta < 0: + y_delta = 0 + + offscreen = (x_delta, y_delta) != (0, 0) + + if offscreen: + + if x_delta: + x_1 = mouse_x - tip_delta[0] - width + + if y_delta: + y_1 = mouse_y - tip_delta[1] - height + + offscreen_again = y_1 < 0 # out on the top + + if offscreen_again: + # No further checks will be done. + + # TIP: + # A further mod might auto-magically augment the + # wraplength when the tooltip is too high to be + # kept inside the screen. + y_1 = 0 + + return x_1, y_1 + + background = self._background + pad = self._pad + widget = self._widget + + # creates a toplevel window + self._topwidget = tk.Toplevel(widget) + if platform.system() == "Darwin": + # For Mac OS + self._topwidget.tk.call("::tk::unsupported::MacWindowStyle", + "style", self._topwidget._w, # pylint:disable=protected-access + "help", "none") + + # Leaves only the label and removes the app window + self._topwidget.wm_overrideredirect(True) + + win = tk.Frame(self._topwidget, + background=background, + borderwidth=0) + label = tk.Label(win, + text=self._text, + justify=tk.LEFT, + background=background, + relief=tk.SOLID, + borderwidth=0, + wraplength=self._wraplength) + + label.grid(padx=(pad[0], pad[2]), + pady=(pad[1], pad[3]), + sticky=tk.NSEW) + win.grid() + + xpos, ypos = tip_pos_calculator(widget, label) + + self._topwidget.wm_geometry("+%d+%d" % (xpos, ypos)) + + def _hide(self): + """ Hide the tooltip """ + topwidget = self._topwidget + if topwidget: + topwidget.destroy() + self._topwidget = None diff --git a/lib/gui/display_analysis.py b/lib/gui/display_analysis.py index bebf267930..8bd3da23f4 100644 --- a/lib/gui/display_analysis.py +++ b/lib/gui/display_analysis.py @@ -11,7 +11,7 @@ from .display_graph import SessionGraph from .display_page import DisplayPage from .stats import Calculations, Session -from .tooltip import Tooltip +from .custom_widgets import Tooltip from .utils import FileHandler, get_config, get_images, LongRunningTask logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -29,13 +29,15 @@ def __init__(self, parent, tabname, helptext): self.add_options() self.add_main_frame() self.thread = None # Thread for compiling stats data in background - self.set_training_callback() + self.set_callbacks() logger.debug("Initialized: %s", self.__class__.__name__) - def set_training_callback(self): + def set_callbacks(self): """ Add a callback to update analysis when the training graph is updated """ - get_config().tk_vars["refreshgraph"].trace("w", self.update_current_session) - get_config().tk_vars["istraining"].trace("w", self.remove_current_session) + tkv = get_config().tk_vars + tkv["refreshgraph"].trace("w", self.update_current_session) + tkv["istraining"].trace("w", self.remove_current_session) + tkv["analysis_folder"].trace("w", self.populate_from_folder) def update_current_session(self, *args): # pylint:disable=unused-argument """ Update the current session data on a graph update callback """ @@ -57,7 +59,7 @@ def set_vars(self): return {"selected_id": selected_id} def add_main_frame(self): - """ Add the main frame to the subnotebook + """ Add the main frame to the sub-notebook to hold stats and session data """ logger.debug("Adding main frame") mainframe = self.subnotebook_add_page("stats") @@ -79,12 +81,38 @@ def reset_session_info(self): logger.debug("Resetting session info") self.set_info("No session data loaded") - def load_session(self): + def populate_from_folder(self, *args): # pylint:disable=unused-argument + """ Populate the Analysis tab from just a model folder. Triggered + when tkinter variable ``analysis_folder`` is set. + """ + folder = get_config().tk_vars["analysis_folder"].get() + if not folder or not os.path.isdir(folder): + logger.debug("Not a valid folder") + self.clear_session() + return + + state_files = [fname + for fname in os.listdir(folder) + if fname.endswith("_state.json")] + if not state_files: + logger.debug("No state files found in folder: '%s'", folder) + self.clear_session() + return + + state_file = state_files[0] + if len(state_files) > 1: + logger.debug("Multiple models found. Selecting: '%s'", state_file) + + if self.thread is None: + self.load_session(fullpath=os.path.join(folder, state_file)) + + def load_session(self, fullpath=None): """ Load previously saved sessions """ logger.debug("Loading session") - fullpath = FileHandler("filename", "state").retfile - if not fullpath: - return + if fullpath is None: + fullpath = FileHandler("filename", "state").retfile + if not fullpath: + return self.clear_session() logger.debug("state_file: '%s'", fullpath) model_dir, state_file = os.path.split(fullpath) @@ -155,7 +183,7 @@ def set_session_summary(self, message): @staticmethod def summarise_data(session): - """ Summarise data in a LongRunningThread as it can take a while """ + """ Summarize data in a LongRunningThread as it can take a while """ return session.full_summary def clear_session(self): @@ -217,10 +245,10 @@ def add_buttons(self): @staticmethod def set_help(btntype): - """ Set the helptext for option buttons """ + """ Set the help text for option buttons """ logger.debug("Setting help") hlp = "" - if btntype == "reset": + if btntype == "reload": hlp = "Load/Refresh stats for the currently training session" elif btntype == "clear": hlp = "Clear currently displayed session stats" @@ -261,7 +289,7 @@ def __init__(self, parent, selected_id, helptext): logger.debug("Initialized: %s", self.__class__.__name__) def add_label(self): - """ Add Treeview Title """ + """ Add tree-view Title """ logger.debug("Adding Treeview title") lbl = ttk.Label(self.sub_frame, text="Session Stats", anchor=tk.CENTER) lbl.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5) @@ -275,7 +303,7 @@ def resize_frame(self, event): logger.debug("Resized Analysis Frame") def tree_configure(self, helptext): - """ Build a treeview widget to hold the sessions stats """ + """ Build a tree-view widget to hold the sessions stats """ logger.debug("Configuring Treeview") self.tree.configure(yscrollcommand=self.scrollbar.set) self.tree.tag_configure("total", background="black", foreground="white") @@ -285,7 +313,7 @@ def tree_configure(self, helptext): return self.tree_columns() def tree_columns(self): - """ Add the columns to the totals treeview """ + """ Add the columns to the totals tree-view """ logger.debug("Adding Treeview columns") columns = (("session", 40, "#"), ("start", 130, None), @@ -307,7 +335,7 @@ def tree_columns(self): return [column[0] for column in columns] def tree_insert_data(self, sessions_summary): - """ Insert the data into the totals treeview """ + """ Insert the data into the totals tree-view """ logger.debug("Inserting treeview data") self.tree.configure(height=len(sessions_summary)) @@ -461,7 +489,7 @@ def build(self): logger.debug("Built popup") def set_callback(self): - """ Set a tk boolean var to callback when graph is ready to build """ + """ Set a tkinter Boolean var to callback when graph is ready to build """ logger.debug("Setting tk graph build variable") var = tk.BooleanVar() var.set(False) @@ -517,7 +545,7 @@ def opts_combobox(self, frame): cmb.current(0) cmb.pack(fill=tk.X, side=tk.RIGHT) - cmd = self.optbtn_reset if item == "Display" else self.graph_scale + cmd = self.optbtn_reload if item == "Display" else self.graph_scale var.trace("w", cmd) self.vars[item.lower().strip()] = var @@ -527,7 +555,7 @@ def opts_combobox(self, frame): @staticmethod def add_section(frame, title): - """ Add a seperator and section title """ + """ Add a separator and section title """ sep = ttk.Frame(frame, height=2, relief=tk.SOLID) sep.pack(fill=tk.X, pady=(5, 0), side=tk.TOP) lbl = ttk.Label(frame, text=title) @@ -629,7 +657,7 @@ def opts_buttons(self, frame): anchor=tk.W) lblstatus.pack(side=tk.LEFT, anchor=tk.W, fill=tk.X, expand=True) - for btntype in ("reset", "save"): + for btntype in ("reload", "save"): cmd = getattr(self, "optbtn_{}".format(btntype)) btn = ttk.Button(btnframe, image=get_images().icons[btntype], @@ -655,7 +683,7 @@ def optbtn_save(self): csvout.writerow(fieldnames) csvout.writerows(zip(*[save_data[key] for key in fieldnames])) - def optbtn_reset(self, *args): # pylint: disable=unused-argument + def optbtn_reload(self, *args): # pylint: disable=unused-argument """ Action for reset button press and checkbox changes""" logger.debug("Refreshing Graph") if not self.graph_initialised: @@ -677,10 +705,10 @@ def graph_scale(self, *args): # pylint: disable=unused-argument @staticmethod def set_help(control): - """ Set the helptext for option buttons """ + """ Set the help text for option buttons """ hlp = "" control = control.lower() - if control == "reset": + if control == "reload": hlp = "Refresh graph" elif control == "save": hlp = "Save display data to csv" diff --git a/lib/gui/display_command.py b/lib/gui/display_command.py index 6c15ff9a1b..4626dac46f 100644 --- a/lib/gui/display_command.py +++ b/lib/gui/display_command.py @@ -10,7 +10,7 @@ from .display_graph import TrainingGraph from .display_page import DisplayOptionalPage -from .tooltip import Tooltip +from .custom_widgets import Tooltip from .stats import Calculations from .control_helper import set_slider_rounding from .utils import FileHandler, get_config, get_images diff --git a/lib/gui/display_graph.py b/lib/gui/display_graph.py index 05a2062c1c..6a2652c190 100755 --- a/lib/gui/display_graph.py +++ b/lib/gui/display_graph.py @@ -17,7 +17,7 @@ from matplotlib.backends.backend_tkagg import (FigureCanvasTkAgg, NavigationToolbar2Tk) # noqa -from .tooltip import Tooltip # noqa +from .custom_widgets import Tooltip # noqa from .utils import get_config, get_images, LongRunningTask # noqa logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -34,7 +34,7 @@ class NavigationToolbar(NavigationToolbar2Tk): # pylint: disable=too-many-ances def _Button(frame, text, file, command, extension=".gif"): # pylint: disable=arguments-differ """ Map Buttons to their own frame. Use custom button icons, Use ttk buttons pack to the right """ - iconmapping = {"home": "reset", + iconmapping = {"home": "reload", "filesave": "save", "zoom_to_rect": "zoom"} icon = iconmapping[file] if iconmapping.get(file, None) else file @@ -44,7 +44,7 @@ def _Button(frame, text, file, command, extension=".gif"): # pylint: disable=ar return btn def _init_toolbar(self): - """ Same as original but ttk widgets and standard tooltips used. Separator added and + """ Same as original but ttk widgets and standard tool-tips used. Separator added and message label packed to the left """ xmin, xmax = self.canvas.figure.bbox.intervalx height, width = 50, xmax-xmin @@ -308,7 +308,7 @@ def resize_fig(self): """ Resize the figure back to the canvas """ class Event(): # pylint: disable=too-few-public-methods """ Event class that needs to be passed to plotcanvas.resize """ - pass + pass # pylint: disable=unnecessary-pass Event.width = self.winfo_width() Event.height = self.winfo_height() self.plotcanvas.resize(Event) # pylint: disable=no-value-for-parameter diff --git a/lib/gui/display_page.py b/lib/gui/display_page.py index a984ffc496..d872e86a21 100644 --- a/lib/gui/display_page.py +++ b/lib/gui/display_page.py @@ -5,7 +5,7 @@ import tkinter as tk from tkinter import ttk -from .tooltip import Tooltip +from .custom_widgets import Tooltip from .utils import get_images logger = logging.getLogger(__name__) # pylint: disable=invalid-name diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 2faeeeb2c1..047001acdd 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -6,24 +6,28 @@ import os import sys import tkinter as tk +from tkinter import ttk import webbrowser - from importlib import import_module from subprocess import Popen, PIPE, STDOUT from lib.multithreading import MultiThread from lib.serializer import get_serializer - import update_deps -from .utils import get_config + from .popup_configure import popup_config +from .custom_widgets import Tooltip +from .utils import get_config, get_images _RESOURCES = [("faceswap.dev - Guides and Forum", "https://www.faceswap.dev"), ("Patreon - Support this project", "https://www.patreon.com/faceswap"), ("Discord - The FaceSwap Discord server", "https://discord.gg/VasFUAy"), ("Github - Our Source Code", "https://github.com/deepfakes/faceswap")] +_CONFIG_FILES = [] +_CONFIGS = dict() + logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -56,6 +60,7 @@ def __init__(self, parent): def scan_for_plugin_configs(self): """ Scan for config.ini file locations """ + global _CONFIGS, _CONFIG_FILES # pylint:disable=global-statement root_path = os.path.abspath(os.path.dirname(sys.argv[0])) plugins_path = os.path.join(root_path, "plugins") logger.debug("Scanning path: '%s'", plugins_path) @@ -66,6 +71,12 @@ def scan_for_plugin_configs(self): config = self.load_config(plugin_type) configs[plugin_type] = config logger.debug("Configs loaded: %s", sorted(list(configs.keys()))) + keys = list(configs.keys()) + for key in ("extract", "train", "convert"): + if key in keys: + _CONFIG_FILES.append(keys.pop(keys.index(key))) + _CONFIG_FILES.extend([key for key in sorted(keys)]) + _CONFIGS = configs return configs @staticmethod @@ -80,8 +91,9 @@ def load_config(plugin_type): def build(self): """ Add the settings menu to the menu bar """ + # pylint: disable=cell-var-from-loop logger.debug("Building settings menu") - for name in sorted(list(self.configs.keys())): + for name in _CONFIG_FILES: label = "Configure {} Plugins...".format(name.title()) config = self.configs[name] self.add_command( @@ -103,7 +115,7 @@ def __init__(self, parent): logger.debug("Initializing %s", self.__class__.__name__) super().__init__(parent, tearoff=0) self.root = parent.root - self.config = get_config() + self._config = get_config() self.recent_menu = tk.Menu(self, tearoff=0, postcommand=self.refresh_recent_menu) self.build() logger.debug("Initialized %s", self.__class__.__name__) @@ -111,35 +123,78 @@ def __init__(self, parent): def build(self): """ Add the file menu to the menu bar """ logger.debug("Building File menu") - self.add_command(label="Load full config...", underline=0, command=self.config.load) - self.add_command(label="Save full config...", underline=0, command=self.config.save) + self.add_command(label="New Project...", + underline=0, + accelerator="Ctrl+N", + command=self._config.project.new) + self.root.bind_all("", self._config.project.new) + self.add_command(label="Open Project...", + underline=0, + accelerator="Ctrl+O", + command=self._config.project.load) + self.root.bind_all("", self._config.project.load) + self.add_command(label="Save Project", + underline=0, + accelerator="Ctrl+S", + command=lambda: self._config.project.save(save_as=False)) + self.root.bind_all("", lambda e: self._config.project.save(e, save_as=False)) + self.add_command(label="Save Project as...", + underline=13, + accelerator="Ctrl+Alt+S", + command=lambda: self._config.project.save(save_as=True)) + self.root.bind_all("", lambda e: self._config.project.save(e, save_as=True)) + self.add_command(label="Reload Project from Disk", + underline=0, + accelerator="F5", + command=self._config.project.reload) + self.root.bind_all("", self._config.project.reload) + self.add_command(label="Close Project", + underline=0, + accelerator="Ctrl+W", + command=self._config.project.close) + self.root.bind_all("", self._config.project.close) + self.add_separator() + self.add_command(label="Open Task...", + underline=5, + accelerator="Ctrl+Alt+T", + command=lambda: self._config.tasks.load(current_tab=False)) + self.root.bind_all("", + lambda e: self._config.tasks.load(e, current_tab=False)) self.add_separator() self.add_cascade(label="Open recent", underline=6, menu=self.recent_menu) self.add_separator() - self.add_command(label="Reset all to default", + self.add_command(label="Quit", underline=0, - command=self.config.cli_opts.reset) - self.add_command(label="Clear all", underline=0, command=self.config.cli_opts.clear) - self.add_separator() - self.add_command(label="Quit", underline=0, command=self.root.close_app) + accelerator="Alt+F4", + command=self.root.close_app) + self.root.bind_all("", self.root.close_app) logger.debug("Built File menu") def build_recent_menu(self): """ Load recent files into menu bar """ logger.debug("Building Recent Files menu") serializer = get_serializer("json") - menu_file = os.path.join(self.config.pathcache, ".recent.json") + menu_file = os.path.join(self._config.pathcache, ".recent.json") if not os.path.isfile(menu_file) or os.path.getsize(menu_file) == 0: self.clear_recent_files(serializer, menu_file) recent_files = serializer.load(menu_file) logger.debug("Loaded recent files: %s", recent_files) for recent_item in recent_files: filename, command = recent_item + # Legacy project files didn't have a command stored + command = command if command else "project" logger.debug("processing: ('%s', %s)", filename, command) - lbl_command = command if command else "All" + if command.lower() == "project": + load_func = self._config.project.load + lbl = command + kwargs = dict(filename=filename) + else: + load_func = self._config.tasks.load + lbl = "{} Task".format(command) + kwargs = dict(filename=filename, current_tab=False) self.recent_menu.add_command( - label="{} ({})".format(filename, lbl_command.title()), - command=lambda fnm=filename, cmd=command: self.config.load(cmd, fnm)) + label="{} ({})".format(filename, lbl.title()), + command=lambda kw=kwargs, fn=load_func: fn(**kw)) self.recent_menu.add_separator() self.recent_menu.add_command( label="Clear recent files", @@ -191,6 +246,7 @@ def build(self): def build_recources_menu(self): """ Build resources menu """ + # pylint: disable=cell-var-from-loop logger.debug("Building Resources Files menu") for resource in _RESOURCES: self.recources_menu.add_command( @@ -215,7 +271,6 @@ def output_sysinfo(self): logger.debug("Obtaining system information") self.root.config(cursor="watch") self.clear_console() - print("Obtaining system information...") try: from lib.sysinfo import sysinfo info = sysinfo @@ -227,7 +282,7 @@ def output_sysinfo(self): self.root.config(cursor="") def check(self): - """ Check for updates and clone repo """ + """ Check for updates and clone repository """ logger.debug("Checking for updates...") self.root.config(cursor="watch") encoding = locale.getpreferredencoding() @@ -236,7 +291,7 @@ def check(self): self.root.config(cursor="") def update(self): - """ Check for updates and clone repo """ + """ Check for updates and clone repository """ logger.debug("Updating Faceswap...") self.root.config(cursor="watch") encoding = locale.getpreferredencoding() @@ -305,3 +360,121 @@ def do_update(encoding): else: retval = True return retval + + +class TaskBar(ttk.Frame): # pylint: disable=too-many-ancestors + """ Task bar buttons """ + def __init__(self, parent): + super().__init__(parent) + self._config = get_config() + self.pack(side=tk.TOP, anchor=tk.W, fill=tk.X, expand=False) + self._btn_frame = ttk.Frame(self) + self._btn_frame.pack(side=tk.TOP, pady=2, anchor=tk.W, fill=tk.X, expand=False) + + self._project_btns() + self._group_separator() + self._task_btns() + self._group_separator() + self._settings_btns() + self._section_separator() + + def _project_btns(self): + frame = ttk.Frame(self._btn_frame) + frame.pack(side=tk.LEFT, anchor=tk.W, expand=False, padx=2) + + for btntype in ("new", "load", "save", "save_as", "reload"): + logger.debug("Adding button: '%s'", btntype) + + loader, kwargs = self._loader_and_kwargs(btntype) + cmd = getattr(self._config.project, loader) + btn = ttk.Button(frame, + image=get_images().icons[btntype], + command=lambda fn=cmd, kw=kwargs: fn(**kw)) + btn.pack(side=tk.LEFT, anchor=tk.W) + hlp = self.set_help(btntype) + Tooltip(btn, text=hlp, wraplength=200) + + def _task_btns(self): + frame = ttk.Frame(self._btn_frame) + frame.pack(side=tk.LEFT, anchor=tk.W, expand=False, padx=2) + + for loadtype in ("load", "save", "save_as", "clear", "reload"): + btntype = "{}2".format(loadtype) + logger.debug("Adding button: '%s'", btntype) + + loader, kwargs = self._loader_and_kwargs(loadtype) + if loadtype == "load": + kwargs["current_tab"] = True + cmd = getattr(self._config.tasks, loader) + btn = ttk.Button( + frame, + image=get_images().icons[btntype], + command=lambda fn=cmd, kw=kwargs: fn(**kw)) + btn.pack(side=tk.LEFT, anchor=tk.W) + hlp = self.set_help(btntype) + Tooltip(btn, text=hlp, wraplength=200) + + @staticmethod + def _loader_and_kwargs(btntype): + if btntype == "save": + loader = btntype + kwargs = dict(save_as=False) + elif btntype == "save_as": + loader = "save" + kwargs = dict(save_as=True) + else: + loader = btntype + kwargs = dict() + logger.debug("btntype: %s, loader: %s, kwargs: %s", btntype, loader, kwargs) + return loader, kwargs + + def _settings_btns(self): + # pylint: disable=cell-var-from-loop + frame = ttk.Frame(self._btn_frame) + frame.pack(side=tk.LEFT, anchor=tk.W, expand=False, padx=2) + root = get_config().root + for name in _CONFIG_FILES: + config = _CONFIGS[name] + btntype = "settings_{}".format(name) + btntype = btntype if btntype in get_images().icons else "settings" + logger.debug("Adding button: '%s'", btntype) + btn = ttk.Button( + frame, + image=get_images().icons[btntype], + command=lambda conf=(name, config), root=root: popup_config(conf, root)) + btn.pack(side=tk.LEFT, anchor=tk.W) + hlp = "Configure {} settings...".format(name.title()) + Tooltip(btn, text=hlp, wraplength=200) + + @staticmethod + def set_help(btntype): + """ Set the helptext for option buttons """ + logger.debug("Setting help") + hlp = "" + task = "currently selected Task" if btntype[-1] == "2" else "Project" + if btntype.startswith("reload"): + hlp = "Reload {} from disk".format(task) + if btntype == "new": + hlp = "Crate a new {}...".format(task) + if btntype.startswith("clear"): + hlp = "Reset {} to default".format(task) + elif btntype.startswith("save") and "_" not in btntype: + hlp = "Save {}".format(task) + elif btntype.startswith("save_as"): + hlp = "Save {} as...".format(task) + elif btntype.startswith("load"): + msg = task + if msg.endswith("Task"): + msg += " from a task or project file" + hlp = "Load {}...".format(msg) + return hlp + + def _group_separator(self): + separator = ttk.Separator(self._btn_frame, orient="vertical") + separator.pack(padx=(2, 1), fill=tk.Y, side=tk.LEFT) + + def _section_separator(self): + frame = ttk.Frame(self) + frame.pack(side=tk.BOTTOM, fill=tk.X) + separator = ttk.Separator(frame, orient="horizontal") + separator.pack(fill=tk.X, side=tk.LEFT, expand=True) diff --git a/lib/gui/options.py b/lib/gui/options.py index 124f60932d..7de3945b30 100644 --- a/lib/gui/options.py +++ b/lib/gui/options.py @@ -36,7 +36,7 @@ def build_options(self): @staticmethod def get_cli_classes(cli_source): - """ Parse the cli scripts for the arg classes """ + """ Parse the cli scripts for the argument classes """ mod_classes = list() for name, obj in inspect.getmembers(cli_source): if inspect.isclass(obj) and name.lower().endswith("args") \ @@ -105,7 +105,9 @@ def process_options(self, command_options, command): rounding=self.get_rounding(opt), min_max=opt.get("min_max", None), sysbrowser=self.get_sysbrowser(opt, command_options, command), - helptext=opt["help"]) + helptext=opt["help"], + track_modified=True, + command=command) gui_options[title] = dict(cpanel_option=cpanel_option, opts=opt["opts"], nargs=opt.get("nargs", None)) @@ -163,7 +165,7 @@ def get_sysbrowser(self, option, options, command): if action == cli.FileFullPaths: retval["browser"] = ["load"] elif action == cli.FilesFullPaths: - retval["browser"] = ["load_multi"] + retval["browser"] = ["multi_load"] elif action == cli.SaveFileFullPaths: retval["browser"] = ["save"] elif action == cli.DirOrFileFullPaths: @@ -241,7 +243,7 @@ def get_option_values(self, command=None): continue cmd_dict[key] = val["cpanel_option"].get() ctl_dict[cmd] = cmd_dict - logger.debug("command: '%s', ctl_dict: '%s'", command, ctl_dict) + logger.debug("command: '%s', ctl_dict: %s", command, ctl_dict) return ctl_dict def get_one_option_variable(self, command, title): @@ -258,7 +260,7 @@ def gen_cli_arguments(self, command): optval = str(option["cpanel_option"].get()) opt = option["opts"][0] if command in ("extract", "convert") and opt == "-o": - get_images().pathoutput = optval + get_images().set_faceswap_output_path(optval) if optval in ("False", ""): continue elif optval == "True": diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 9a2f8f6653..76c90d676f 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -9,7 +9,7 @@ from tkinter import ttk from .control_helper import ControlPanel, ControlPanelOption -from .tooltip import Tooltip +from .custom_widgets import Tooltip from .utils import get_config, get_images logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -32,11 +32,12 @@ class ConfigurePlugins(tk.Toplevel): def __init__(self, config, root): logger.debug("Initializing %s", self.__class__.__name__) super().__init__() - name, self.config = config - self.title("{} Plugins".format(name.title())) + self._name, self.config = config + self.title("{} Plugins".format(self._name.title())) self.tk.call('wm', 'iconphoto', self._w, get_images().icons["favicon"]) - self.set_geometry(root) + self._root = root + self.set_geometry() self.page_frame = ttk.Frame(self) self.page_frame.pack(fill=tk.BOTH, expand=True) @@ -47,11 +48,11 @@ def __init__(self, config, root): self.update() logger.debug("Initialized %s", self.__class__.__name__) - def set_geometry(self, root): + def set_geometry(self): """ Set pop-up geometry """ scaling_factor = get_config().scaling_factor - pos_x = root.winfo_x() + 80 - pos_y = root.winfo_y() + 80 + pos_x = self._root.winfo_x() + 80 + pos_y = self._root.winfo_y() + 80 width = int(600 * scaling_factor) height = int(400 * scaling_factor) logger.debug("Pop up Geometry: %sx%s, %s+%s", width, height, pos_x, pos_y) @@ -187,6 +188,13 @@ def save_config(self): new_config.set(section, item, str(new_opt)) self.config.config = new_config self.config.save_config() - print("Saved config: '{}'".format(self.config.configfile)) + logger.info("Saved config: '%s'", self.config.configfile) self.destroy() + + running_task = get_config().tk_vars["runningtask"].get() + if self._name.lower() == "gui" and not running_task: + self._root.rebuild() + elif self._name.lower() == "gui" and running_task: + logger.info("Can't redraw GUI whilst a task is running. GUI Settings will be applied " + "at the next restart.") logger.debug("Saved config") diff --git a/lib/gui/project.py b/lib/gui/project.py new file mode 100644 index 0000000000..bc214f3f7e --- /dev/null +++ b/lib/gui/project.py @@ -0,0 +1,993 @@ +#!/usr/bin/env python3 +""" Handling of Faceswap GUI Projects, Tasks and Last Session """ + +import logging +import os +import tkinter as tk +from tkinter import messagebox + +from lib.serializer import get_serializer + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class _GuiSession(): # pylint:disable=too-few-public-methods + """ Parent class for GUI Session Handlers. + + Parameters + ---------- + config: :class:`lib.gui.utils.Config` + The master GUI config + file_handler: :class:`lib.gui.utils.FileHandler` + A file handler object + + """ + def __init__(self, config, file_handler=None): + # NB file_handler has to be passed in to avoid circular imports + logger.debug("Initializing: %s: (config: %s, file_handler: %s)", + self.__class__.__name__, config, file_handler) + self._serializer = get_serializer("json") + self._config = config + + self._default_opts = None + self._options = None + self._file_handler = file_handler + self._filename = None + self._saved_tasks = None + self._modified = False + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def _active_tab(self): + """ str: The name of the currently selected :class:`lib.gui.command.CommandNotebook` + tab. """ + notebook = self._config.command_notebook + toolsbook = self._config.tools_notebook + command = notebook.tab(notebook.select(), "text").lower() + if command == "tools": + command = toolsbook.tab(toolsbook.select(), "text").lower() + logger.debug("Active tab: %s", command) + return command + + @property + def _modified_vars(self): + """ dict: The tkinter Boolean vars indicating the modified state for each tab. """ + return self._config.modified_vars + + @property + def _file_exists(self): + """ bool: ``True`` if :attr:`_filename` exists otherwise ``False``. """ + return self._filename is not None and os.path.isfile(self._filename) + + @property + def _cli_options(self): + """ dict: the raw cli options from :attr:`_options` with project fields removed. """ + return {key: val for key, val in self._options.items() if isinstance(val, dict)} + + @property + def _dirname(self): + """ str: The folder name that :attr:`_filename` resides in. Returns ``None`` if + filename is ``None``. """ + return os.path.dirname(self._filename) if self._filename is not None else None + + @property + def _basename(self): + """ str: The base name of :attr:`_filename`. Returns ``None`` if filename is ``None``. """ + return os.path.basename(self._filename) if self._filename is not None else None + + def _current_gui_state(self, command=None): + """ The current state of the GUI. + + Parameters + ---------- + command: str, optional + If provided, returns the state of just the given tab command. If ``None`` returns options + for all tabs. Default ``None`` + + Returns + ------- + dict: The options currently set in the GUI + """ + return self._config.cli_opts.get_option_values(command) + + def _set_filename(self, filename=None, sess_type="project"): + """ Set the :attr:`_filename` attribute. + + :attr:`_filename` is set either from a given filename or the result from + a :attr:`_file_handler`. + + Parameters + ---------- + filename: str, optional + An optional filename. If given then this filename will be used otherwise it will be + collected by a :attr:`_file_handler` + + sess_type: {all, project, task}, optional + The session type that the filename is being set for. Dictates the type of file handler + that is opened. + + Returns + ------- + bool: `True` if filename has been successfully set otherwise ``False`` + """ + logger.debug("filename: '%s', sess_type: '%s'", filename, sess_type) + handler = "config_{}".format(sess_type) + + if filename is None: + logger.debug("Popping file handler") + cfgfile = self._file_handler("open", handler).retfile + if not cfgfile: + logger.debug("No filename given") + return False + filename = cfgfile.name + cfgfile.close() + + if not os.path.isfile(filename): + msg = "File does not exist: '{}'".format(filename) + logger.error(msg) + return False + ext = os.path.splitext(filename)[1] + if (sess_type == "project" and ext != ".fsw") or (sess_type == "task" and ext != ".fst"): + logger.debug("Invalid file extension for session type: (sess_type: '%s', " + "extension: '%s')", sess_type, ext) + return False + logger.debug("Setting filename: '%s'", filename) + self._filename = filename + return True + + # GUI STATE SETTING + def _set_options(self, command=None): + """ Set the GUI options based on the currently stored properties of :attr:`_options` + and sets the active tab. + + Parameters + ---------- + command: str, optional + The tab to set the options for. If None then sets options for all tabs. + Default: ``None`` + """ + opts = self._get_options_for_command(command) if command else self._cli_options + logger.debug("command: %s, opts: %s", command, opts) + if opts is None: + logger.debug("No options found. Returning") + return + for cmd, opt in opts.items(): + self._set_gui_state_for_command(cmd, opt) + tab_name = self._options.get("tab_name", None) if command is None else command + tab_name = tab_name if tab_name is not None else "extract" + logger.debug("tab_name: %s", tab_name) + self._config.set_active_tab_by_name(tab_name) + + def _get_options_for_command(self, command): + """ Return a single command's options from :attr:`_options` formatted consistently with + an all options dict. + + Parameters + ---------- + command: str + The command to return the options for + + Returns + ------- + dict: The options for a single command in the format {command: options}. If the command + is not found then returns ``None`` + """ + logger.debug(command) + opts = self._options.get(command, None) + retval = {command: opts} + if not opts: + self._config.tk_vars["consoleclear"].set(True) + logger.info("No %s section found in file", command) + retval = None + logger.debug(retval) + return retval + + def _set_gui_state_for_command(self, command, options): + """ Set the GUI state for the given command. + + Parameters + ---------- + command: str + The tab to set the options for + options: dict + The option values to set the GUI to + """ + logger.debug("command: %s: options: %s", command, options) + if not options: + logger.debug("No options provided, not updating GUI") + return + for srcopt, srcval in options.items(): + optvar = self._config.cli_opts.get_one_option_variable(command, srcopt) + if not optvar: + continue + logger.trace("setting option: (srcopt: %s, optvar: %s, srcval: %s)", + srcopt, optvar, srcval) + optvar.set(srcval) + + def _reset_modified_var(self, command=None): + """ Reset :attr:`_modified_vars` variables back to unmodified (`False`) for all + commands or for the given command. + + Parameters + ---------- + command: str, optional + The command to reset the modified tkinter variable for. If ``None`` then all tkinter + modified variables are reset to `False`. Default: ``None`` + """ + for key, tk_var in self._modified_vars.items(): + if (command is None or command == key) and tk_var.get(): + logger.debug("Reset modified state for: %s", command) + tk_var.set(False) + + # RECENT FILE HANDLING + def _add_to_recent(self, command=None): + """ Add the file for this session to the recent files list. + + Parameters + ---------- + command: str, optional + The command that this session relates to. If `None` then the whole project is added. + Default: ``None`` + """ + logger.debug(command) + if self._filename is None: + logger.debug("No filename for selected file. Not adding to recent.") + return + recent_filename = os.path.join(self._config.pathcache, ".recent.json") + logger.debug("Adding to recent files '%s': (%s, %s)", + recent_filename, self._filename, command) + if not os.path.exists(recent_filename) or os.path.getsize(recent_filename) == 0: + logger.debug("Starting with empty recent_files list") + recent_files = [] + else: + logger.debug("loading recent_files list: %s", recent_filename) + recent_files = self._serializer.load(recent_filename) + logger.debug("Initial recent files: %s", recent_files) + recent_files = self._del_from_recent(self._filename, recent_files) + ftype = "project" if command is None else command + recent_files.insert(0, (self._filename, ftype)) + recent_files = recent_files[:20] + logger.debug("Final recent files: %s", recent_files) + self._serializer.save(recent_filename, recent_files) + + def _del_from_recent(self, filename, recent_files=None, save=False): + """ Remove an item from the recent files list. + + Parameters + ---------- + filename: str + The filename to be removed from the recent files list + recent_files: list, optional + If the recent files list has already been loaded, it can be passed in to avoid + loading again. If ``None`` then load the recent files list from disk. Default: ``None`` + save: bool, optional + Whether the recent files list should be saved after removing the file. ``True`` saves + the file, ``False`` does not. Default: ``False`` + """ + recent_filename = os.path.join(self._config.pathcache, ".recent.json") + if recent_files is None: + logger.debug("Loading file list from disk: %s", recent_filename) + if not os.path.exists(recent_filename) or os.path.getsize(recent_filename) == 0: + logger.debug("No recent file list") + return None + recent_files = self._serializer.load(recent_filename) + filenames = [recent[0] for recent in recent_files] + if filename in filenames: + idx = filenames.index(filename) + logger.debug("Removing from recent file list: %s", filename) + del recent_files[idx] + if save: + logger.debug("Saving recent files list: %s", recent_filename) + self._serializer.save(recent_filename, recent_files) + else: + logger.debug("Filename '%s' does not appear in recent file list", filename) + return recent_files + + def _get_lone_task(self): + """ Get the sole command name from :attr:`_options`. + + Returns + ------- + str: The only existing command name in the current :attr:`_options` dict or ``None`` if + there are multiple commands stored. + """ + command = None + if len(self._cli_options) == 1: + command = list(self._cli_options.keys())[0] + logger.debug(command) + return command + + # DISK IO + def _load(self): + """ Load GUI options from :attr:`_filename` location and set to :attr:`_options`. + + Returns + ------- + bool: ``True`` if successfully loaded otherwise ``False`` + """ + if self._file_exists: + logger.debug("Loading config") + self._options = self._serializer.load(self._filename) + retval = True + else: + logger.debug("File doesn't exist. Aborting") + retval = False + return retval + + def _save_as_to_filename(self, session_type): + """ Set :attr:`_filename` from a save as dialog. + + Parameters + ---------- + session_type: ['all', 'task', 'project'] + The type of session to pop the save as dialog for. Limits the allowed filetypes + + Returns + ------- + bool: + True if :attr:`filename` successfully set otherwise ``False`` + """ + logger.debug("Popping save as file handler. session_type: '%s'", session_type) + title = "Save {}As...".format("{} ".format(session_type.title()) + if session_type != "all" else "") + cfgfile = self._file_handler("save", + "config_{}".format(session_type), + title=title, + initialdir=self._dirname).retfile + if not cfgfile: + logger.debug("No filename provided. session_type: '%s'", session_type) + return False + self._filename = cfgfile.name + logger.debug("Set filename: (session_type: '%s', filename: '%s'", + session_type, self._filename) + cfgfile.close() + return True + + def _save(self, command=None): + """ Collect the options in the current GUI state and save. + + Obtains the current options set in the GUI with the selected tab and applies them to + :attr:`_options`. Saves :attr:`_options` to :attr:`_filename`. Resets :attr:_modified_vars + for either the given command or all commands, + + Parameters + ---------- + command: str, optional + The tab to collect the current state for. If ``None`` then collects the current + state for all tabs. Default: ``None`` + """ + self._options = self._current_gui_state(command) + self._options["tab_name"] = self._active_tab + logger.debug("Saving options: (filename: %s, options: %s", self._filename, self._options) + self._serializer.save(self._filename, self._options) + self._reset_modified_var(command) + self._add_to_recent(command) + + +class Tasks(_GuiSession): + """ Faceswap ``.fst`` Task File handling. + + Faceswap tasks handle the management of each individual task tab in the GUI. Unlike + :class:`Projects`, Tasks contains all the active tasks currently running, rather than an + individual task. + + Parameters + ---------- + config: :class:`lib.gui.utils.Config` + The master GUI config + file_handler: :class:`lib.gui.utils.FileHandler` + A file handler object + """ + def __init__(self, config, file_handler): + super().__init__(config, file_handler) + self._tasks = dict() + + @property + def _is_project(self): + """ str: ``True`` if all tasks are from an overarching session project else ``False``.""" + retval = False if not self._tasks else all(v.get("is_project", False) + for v in self._tasks.values()) + return retval + + @property + def _project_filename(self): + """ str: The overarching session project filename.""" + fname = None + if not self._is_project: + return fname + + for val in self._tasks.values(): + fname = val["filename"] + break + return fname + + def load(self, *args, # pylint:disable=unused-argument + filename=None, current_tab=True): + """ Load a task into this :class:`Tasks` class. + + Tasks can be loaded from project ``.fsw`` files or task ``.fst`` files, depending on where + this function is being called from. + + Parameters + ---------- + *args: tuple + Unused, but needs to be present for arguments passed by tkinter event handling + filename: str, optional + If a filename is passed in, This will be used, otherwise a file handler will be + launched to select the relevant file. + current_tab: bool, optional + ``True`` if the task to be loaded must be for the currently selected tab. ``False`` + if loading a task into any tab. If current_tab is `True` then tasks can be loaded from + ``.fsw`` and ``.fst`` files, otherwise they can only be loaded from ``.fst`` files. + Default: ``True`` + """ + logger.debug("Loading task config: (filename: '%s', current_tab: '%s')", + filename, current_tab) + + # Option to load specific task from project files: + sess_type = "all" if current_tab else "task" + + is_legacy = (not self._is_project and + filename is not None and sess_type == "task" and + os.path.splitext(filename)[1] == ".fsw") + if is_legacy: + filename = self._update_legacy_task(filename) + + filename_set = self._set_filename(filename, sess_type=sess_type) + if not filename_set: + return + loaded = self._load() + if not loaded: + return + + command = self._active_tab if current_tab else self._get_lone_task() + if command is None: + logger.error("Unable to determine task from the given file: '%s'", filename) + return + if command not in self._options: + logger.error("No '%s' task in '%s'", command, self._filename) + return + + self._set_options(command) + if self._is_project: + self._filename = self._project_filename + elif self._filename.endswith(".fsw"): + self._filename = None + + self._add_to_recent(command) + self._add_task(command) + if is_legacy: + self.save() + + logger.debug("Loaded task config: (command: '%s', filename: '%s')", command, filename) + + def _update_legacy_task(self, filename): + """ Update legacy ``.fsw`` tasks to ``.fst`` tasks. + + Tasks loaded from the recent files menu may be passed in with a ``.fsw`` extension. + This renames the file and removes it from the recent file list. + + Parameters + ---------- + filename: str + The filename of the `.fsw` file that needs converting + + Returns + ------- + str: + The new filename of the updated tasks file + """ + # TODO remove this code after a period of time. Implemented November 2019 + + logger.debug("original filename: '%s'", filename) + fname, ext = os.path.splitext(filename) + if ext != ".fsw": + logger.debug("Not a .fsw file: '%s'", filename) + return filename + + new_filename = "{}.fst".format(fname) + logger.debug("Renaming '%s' to '%s'", filename, new_filename) + os.rename(filename, new_filename) + self._del_from_recent(filename, save=True) + logger.debug("new filename: '%s'", new_filename) + return new_filename + + def save(self, save_as=False): + """ Save the current GUI state for the active tab to a ``.fst`` faceswap task file. + + Parameters + ---------- + save_as: bool, optional + Whether to save to the stored filename, or pop open a file handler to ask for a + location. If there is no stored filename, then a file handler will automatically be + popped. + """ + logger.debug("Saving config...") + self._set_active_task() + save_as = save_as or self._is_project or self._filename is None + + if save_as and not self._save_as_to_filename("task"): + return + + command = self._active_tab + self._save(command=command) + self._add_task(command) + if not save_as: + logger.info("Saved project to: '%s'", self._filename) + else: + logger.debug("Saved project to: '%s'", self._filename) + + def clear(self): + """ Reset all GUI options to their default values for the active tab. """ + self._config.cli_opts.reset(self._active_tab) + + def reload(self): + """ Reset currently selected tab GUI options to their last saved state. """ + self._set_active_task() + + if self._options is None: + logger.info("No active task to reload") + return + logger.debug("Reloading task") + self.load(filename=self._filename, current_tab=True) + if self._is_project: + self._reset_modified_var(self._active_tab) + + def _add_task(self, command): + """ Add the currently active task to the internal :attr:`_tasks` dict. + + If the currently stored task is from an overarching session project, then + only the options are updated. When resetting a tab to saved a project will always + be preferred to a task loaded into the project, so the original reference file name + stays with the project. + + Parameters + ---------- + command: str + The tab that pertains to the currently active task + + """ + self._tasks[command] = dict(filename=self._filename, + options=self._options, + is_project=self._is_project) + + def clear_tasks(self): + """ Clears all of the stored tasks. + + This is required when loading a task stored in a legacy project file, and is only to be + called by :class:`Project` when a project has been loaded which is in fact a task. + """ + logger.debug("Clearing stored tasks") + self._tasks = dict() + + def add_project_task(self, filename, command, options): + """ Add an individual task from a loaded :class:`Project` to the internal :attr:`_tasks` + dict. + + Project tasks take priority over any other tasks, so the individual tasks from a new + project must be placed in the _tasks dict. + + Parameters + ---------- + filename: str + The filename of the session project file + command: str + The tab that this task's options belong to + options: dict + The options for this task loaded from the project + """ + self._tasks[command] = dict(filename=filename, options=options, is_project=True) + + def _set_active_task(self, command=None): + """ Set the active :attr:`_filename` and :attr:`_options` to currently selected tab's + options. + + Parameters + ---------- + command: str, optional + If a command is passed in then set the given tab to active, If this is none set the tab + which currently has focus to active. Default: ``None`` + """ + logger.debug(command) + command = self._active_tab if command is None else command + task = self._tasks.get(command, None) + if task is None: + self._filename, self._options = (None, None) + else: + self._filename, self._options = (task.get("filename", None), task.get("options", None)) + logger.debug("tab: %s, filename: %s, options: %s", + self._active_tab, self._filename, self._options) + + +class Project(_GuiSession): + """ Faceswap ``.fsw`` Project File handling. + + Faceswap projects handle the management of all task tabs in the GUI and updates + the main Faceswap title bar with the project name and modified state. + + Parameters + ---------- + config: :class:`lib.gui.utils.Config` + The master GUI config + file_handler: :class:`lib.gui.utils.FileHandler` + A file handler object + """ + + def __init__(self, config, file_handler): + super().__init__(config, file_handler) + self._update_root_title() + + @property + def filename(self): + """ str: The currently active project filename. """ + return self._filename + + @property + def cli_options(self): + """ dict: the raw cli options from :attr:`_options` with project fields removed. """ + return self._cli_options + + @property + def _project_modified(self): + """bool: ``True`` if the project has been modified otherwise ``False``. """ + return any([var.get() for var in self._modified_vars.values()]) + + @property + def _tasks(self): + """ :class:`Tasks`: The current session's :class:``Tasks``. """ + return self._config.tasks + + def initialize_default_options(self): + """ Collect the default options. and store locally. + + The Default GUI options are stored on Faceswap startup. + + Exposed as the :attr:`_default_opts` for a project cannot be set until after the main + Command Tabs have been loaded. + """ + self._default_opts = self._current_gui_state() + self._set_default_options() + + def _set_default_options(self): + """ Set the default options. The Default GUI options are stored on Faceswap startup. + + Exposed as the :attr:`_default_opts` for a project cannot be set until after the main + Command Tabs have been loaded. + """ + self._options = self._default_opts + + # MODIFIED STATE CALLBACK + def set_modified_callback(self): + """ Adds a callback to each of the :attr:`_modified_vars` tkinter variables + When one of these variables is changed, triggers :func:`_modified_callback` + with the command that was changed. + + This is exposed as the callback can only be added after the main Command Tabs have + been drawn, and their options' initial values have been set. + + """ + for key, tkvar in self._modified_vars.items(): + logger.debug("Adding callback for tab: %s", key) + tkvar.trace("w", self._modified_callback) + + def _modified_callback(self, *args): # pylint:disable=unused-argument + """ Update the project modified state on a GUI modification change and + update the Faceswap title bar. """ + if self._project_modified and self._current_gui_state() == self._cli_options: + logger.debug("Project is same as stored. Setting modified to False") + self._reset_modified_var() + + if self._modified != self._project_modified: + logger.debug("Updating project state from variable: (modified: %s)", + self._project_modified) + self._modified = self._project_modified + self._update_root_title() + + def load(self, *args, filename=None): # pylint:disable=unused-argument + """ Load a project from a saved ``.fsw`` project file. + + Parameters + ---------- + *args: tuple + Unused, but needs to be present for arguments passed by tkinter event handling + filename: str, optional + If a filename is passed in, This will be used, otherwise a file handler will be + launched to select the relevant file. + """ + logger.debug("Loading project config: (filename: '%s')", filename) + filename_set = self._set_filename(filename, sess_type="project") + + if not filename_set: + logger.debug("No filename set") + return + loaded = self._load() + if not loaded: + logger.debug("Options not loaded") + return + + # Legacy .fsw files could store projects or tasks. Check if this is a legacy file + # and hand off file to Tasks if necessary + command = self._get_lone_task() + legacy = command is not None + if legacy: + self._handoff_legacy_task() + return + + self._set_options() + self._update_tasks() + self._add_to_recent() + self._reset_modified_var() + self._update_root_title() + logger.debug("Loaded project config: (command: '%s', filename: '%s')", command, filename) + + def _handoff_legacy_task(self): + """ Update legacy tasks saved with the old file extension ``.fsw`` to tasks ``.fst``. + + Hands off file handling to :class:`Tasks` and resets project to default. + """ + logger.debug("Updating legacy task '%s", self._filename) + filename = self._filename + self._filename = None + self._set_default_options() + self._tasks.clear_tasks() + self._tasks.load(filename=filename, current_tab=False) + logger.debug("Updated legacy task and reset project") + + def _update_tasks(self): + """ Add the tasks from the loaded project to the :class:`Tasks` class. """ + for key, val in self._cli_options.items(): + opts = {key: val} + opts["tab_name"] = key + self._tasks.add_project_task(self._filename, key, opts) + + def reload(self, *args): # pylint:disable=unused-argument + """ Reset all GUI's option tabs to their last saved state. + + Parameters + ---------- + *args: tuple + Unused, but needs to be present for arguments passed by tkinter event handling + """ + if self._options is None: + logger.info("No active project to reload") + return + logger.debug("Reloading project") + self._set_options() + self._update_tasks() + self._reset_modified_var() + self._update_root_title() + + def _update_root_title(self): + """ Update the root Window title with the project name. Add a asterisk + if the file is modified. """ + text = "" if self._basename is None else self._basename + text += "*" if self._modified else "" + self._config.set_root_title(text=text) + + def save(self, *args, save_as=False): # pylint:disable=unused-argument + """ Save the current GUI state to a ``.fsw`` project file. + + Parameters + ---------- + *args: tuple + Unused, but needs to be present for arguments passed by tkinter event handling + save_as: bool, optional + Whether to save to the stored filename, or pop open a file handler to ask for a + location. If there is no stored filename, then a file handler will automatically be + popped. + """ + logger.debug("Saving config as...") + + save_as = save_as or self._filename is None + if save_as and not self._save_as_to_filename("project"): + return + self._save() + self._update_tasks() + self._update_root_title() + if not save_as: + logger.info("Saved project to: '%s'", self._filename) + else: + logger.debug("Saved project to: '%s'", self._filename) + + def new(self, *args): # pylint:disable=unused-argument + """ Create a new project with default options. + + Pops a file handler to select location. + + Parameters + ---------- + *args: tuple + Unused, but needs to be present for arguments passed by tkinter event handling + """ + logger.debug("Creating new project") + if not self.confirm_close(): + logger.debug("Creating new project cancelled") + return + + cfgfile = self._file_handler("save", + "config_project", + title="New Project...", + initialdir=self._basename).retfile + if not cfgfile: + logger.debug("No filename selected") + return + self._filename = cfgfile.name + cfgfile.close() + + self._set_default_options() + self._config.cli_opts.reset() + self._save() + self._update_root_title() + + def close(self, *args): # pylint:disable=unused-argument + """ Clear the current project and set all options to default. + + Parameters + ---------- + *args: tuple + Unused, but needs to be present for arguments passed by tkinter event handling + """ + logger.debug("Close requested") + if not self.confirm_close(): + logger.debug("Close cancelled") + return + self._config.cli_opts.reset() + self._filename = None + self._set_default_options() + self._reset_modified_var() + self._update_root_title() + self._config.set_active_tab_by_name(self._config.user_config_dict["tab"]) + + def confirm_close(self): + """ Pop a message box to get confirmation that an unsaved project should be closed + + Returns + ------- + bool: ``True`` if user confirms close, ``False`` if user cancels close + """ + if not self._modified: + logger.debug("Project is not modified") + return True + confirmtxt = "You have unsaved changes.\n\nAre you sure you want to close the project?" + if messagebox.askokcancel("Close", confirmtxt, default="cancel", icon="warning"): + logger.debug("Close Cancelled") + return True + logger.debug("Close confirmed") + return False + + +class LastSession(_GuiSession): + """ Faceswap Last Session handling. + + Faceswap :class:`LastSession` handles saving the state of the Faceswap GUI at close and + reloading the state at launch. + + Last Session behavior can be configured in :file:`config.gui.ini`. + + Parameters + ---------- + config: :class:`lib.gui.utils.Config` + The master GUI config + """ + + def __init__(self, config): + super().__init__(config) + self._filename = os.path.join(self._config.pathcache, ".last_session.json") + if not self._enabled: + return + + if self._save_option == "prompt": + self.ask_load() + elif self._save_option == "always": + self.load() + + @property + def _save_option(self): + """ str: The user config autosave option. """ + return self._config.user_config_dict.get("autosave_last_session", "never") + + @property + def _enabled(self): + """ bool: ``True`` if autosave is enabled otherwise ``False``. """ + return self._save_option != "never" + + def from_dict(self, options): + """ Set the :attr:`_options` property based on the given options dictionary + and update the GUI to use these values. + + This function is required for reloading the GUI state when the GUI has been force + refreshed on a config change. + + Parameters + ---------- + options: dict + The options to set. Should be the output of :func:`to_dict` + """ + logger.debug("Setting options from dict: %s", options) + self._options = options + self._set_options() + + def to_dict(self): + """ Collect the current GUI options and place them in a dict for retrieval or storage. + + This function is required for reloading the GUI state when the GUI has been force + refreshed on a config change. + + Returns + ------- + dict: The current cli options ready for saving or retrieval by :func:`from_dict` + """ + opts = self._current_gui_state() + logger.debug("Collected opts: %s", opts) + if not opts or opts == self._default_opts: + logger.debug("Default session, or no opts found. Not saving last session.") + return None + opts["tab_name"] = self._active_tab + opts["project"] = self._config.project.filename + logger.debug("Added project items: %s", {k: v for k, v in opts.items() + if k in ("tab_name", "project")}) + return opts + + def ask_load(self): + """ Pop a message box to ask the user if they wish to load their last session. """ + if not self._file_exists: + logger.debug("No last session file found") + elif tk.messagebox.askyesno("Last Session", "Load last session?"): + logger.debug("Loading last session at user request") + self.load() + else: + logger.debug("Not loading last session at user request") + + def load(self): + """ Load the last session. + + Loads the last saved session options. Checks if a previous project was loaded + and whether there have been changes since the last saved version of the project. + Sets the display and :class:`Project` and :class:`Task` objects accordingly. + """ + loaded = self._load() + if not loaded: + return + needs_update = self._set_project() + if needs_update: + self._set_options() + + def _set_project(self): + """ Set the :class:`Project` if session is resuming from one. + + Returns + ------- + bool: + ``True`` If the GUI still needs to be updated from the last session, ``False`` if + the returned GUI state is the last session + """ + if self._options.get("project", None) is None: + logger.debug("No project stored") + retval = True + else: + logger.debug("Loading stored project") + self._config.project.load(filename=self._options["project"]) + retval = self._cli_options != self._config.project.cli_options + + logger.debug("Needs update: %s", retval) + return retval + + def save(self): + """ Save a snapshot of currently set GUI config options. + + Called on Faceswap shutdown. + """ + if not self._enabled: + logger.debug("LastSession not enabled") + if os.path.exists(self._filename): + logger.debug("Deleting existing LastSession file") + os.remove(self._filename) + return + + opts = self.to_dict() + if opts is None and os.path.exists(self._filename): + logger.debug("Last session default or blank. Clearing saved last session.") + os.remove(self._filename) + if opts is not None: + self._serializer.save(self._filename, opts) + logger.debug("Saved last session. (filename: '%s', opts: %s", self._filename, opts) diff --git a/lib/gui/statusbar.py b/lib/gui/statusbar.py deleted file mode 100644 index f6bdb0e9fd..0000000000 --- a/lib/gui/statusbar.py +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin python3 -""" Status bar for the GUI """ - -import tkinter as tk -from tkinter import ttk - - -class StatusBar(ttk.Frame): # pylint: disable=too-many-ancestors - """ Status Bar for displaying the Status Message and - Progress Bar """ - - def __init__(self, parent): - ttk.Frame.__init__(self, parent) - self.pack(side=tk.BOTTOM, padx=10, pady=2, fill=tk.X, expand=False) - - self.status_message = tk.StringVar() - self.pbar_message = tk.StringVar() - self.pbar_position = tk.IntVar() - - self.status_message.set("Ready") - - self.status() - self.pbar = self.progress_bar() - - def status(self): - """ Place Status into bottom bar """ - statusframe = ttk.Frame(self) - statusframe.pack(side=tk.LEFT, anchor=tk.W, fill=tk.X, expand=False) - - lbltitle = ttk.Label(statusframe, text="Status:", width=6, anchor=tk.W) - lbltitle.pack(side=tk.LEFT, expand=False) - - lblstatus = ttk.Label(statusframe, - width=40, - textvariable=self.status_message, - anchor=tk.W) - lblstatus.pack(side=tk.LEFT, anchor=tk.W, fill=tk.X, expand=True) - - def progress_bar(self): - """ Place progress bar into bottom bar """ - progressframe = ttk.Frame(self) - progressframe.pack(side=tk.RIGHT, anchor=tk.E, fill=tk.X) - - lblmessage = ttk.Label(progressframe, textvariable=self.pbar_message) - lblmessage.pack(side=tk.LEFT, padx=3, fill=tk.X, expand=True) - - pbar = ttk.Progressbar(progressframe, - length=200, - variable=self.pbar_position, - maximum=100, - mode="determinate") - pbar.pack(side=tk.LEFT, padx=2, fill=tk.X, expand=True) - pbar.pack_forget() - return pbar - - def progress_start(self, mode): - """ Set progress bar mode and display """ - self.progress_set_mode(mode) - self.pbar.pack() - - def progress_stop(self): - """ Reset progress bar and hide """ - self.pbar_message.set("") - self.pbar_position.set(0) - self.progress_set_mode("determinate") - self.pbar.pack_forget() - - def progress_set_mode(self, mode): - """ Set the progress bar mode """ - self.pbar.config(mode=mode) - if mode == "indeterminate": - self.pbar.config(maximum=100) - self.pbar.start() - else: - self.pbar.stop() - self.pbar.config(maximum=100) - - def progress_update(self, message, position, update_position=True): - """ Update the GUIs progress bar and position """ - self.pbar_message.set(message) - if update_position: - self.pbar_position.set(position) diff --git a/lib/gui/tooltip.py b/lib/gui/tooltip.py deleted file mode 100755 index d89e8eb58c..0000000000 --- a/lib/gui/tooltip.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin python3 -""" Tooltip. Pops up help messages for the GUI """ -import platform -import tkinter as tk - - -class Tooltip: - """ - Create a tooltip for a given widget as the mouse goes on it. - - Adapted from StackOverflow: - - http://stackoverflow.com/questions/3221956/ - what-is-the-simplest-way-to-make-tooltips- - in-tkinter/36221216#36221216 - - http://www.daniweb.com/programming/software-development/ - code/484591/a-tooltip-class-for-tkinter - - - Originally written by vegaseat on 2014.09.09. - - - Modified to include a delay time by Victor Zaccardo on 2016.03.25. - - - Modified - - to correct extreme right and extreme bottom behavior, - - to stay inside the screen whenever the tooltip might go out on - the top but still the screen is higher than the tooltip, - - to use the more flexible mouse positioning, - - to add customizable background color, padding, waittime and - wraplength on creation - by Alberto Vassena on 2016.11.05. - - Tested on Ubuntu 16.04/16.10, running Python 3.5.2 - - """ - - def __init__(self, widget, - *, - background="#FFFFEA", - pad=(5, 3, 5, 3), - text="widget info", - waittime=400, - wraplength=250): - - self.waittime = waittime # in milliseconds, originally 500 - self.wraplength = wraplength # in pixels, originally 180 - self.widget = widget - self.text = text - self.widget.bind("", self.on_enter) - self.widget.bind("", self.on_leave) - self.widget.bind("", self.on_leave) - self.background = background - self.pad = pad - self.ident = None - self.topwidget = None - - def on_enter(self, event=None): - """ Schedule on an enter event """ - self.schedule() - - def on_leave(self, event=None): - """ Unschedule on a leave event """ - self.unschedule() - self.hide() - - def schedule(self): - """ Show the tooltip after wait period """ - self.unschedule() - self.ident = self.widget.after(self.waittime, self.show) - - def unschedule(self): - """ Hide the tooltip """ - id_ = self.ident - self.ident = None - if id_: - self.widget.after_cancel(id_) - - def show(self): - """ Show the tooltip """ - def tip_pos_calculator(widget, label, - *, - tip_delta=(10, 5), pad=(5, 3, 5, 3)): - """ Calculate the tooltip position """ - - s_width, s_height = widget.winfo_screenwidth(), widget.winfo_screenheight() - - width, height = (pad[0] + label.winfo_reqwidth() + pad[2], - pad[1] + label.winfo_reqheight() + pad[3]) - - mouse_x, mouse_y = widget.winfo_pointerxy() - - x_1, y_1 = mouse_x + tip_delta[0], mouse_y + tip_delta[1] - x_2, y_2 = x_1 + width, y_1 + height - - x_delta = x_2 - s_width - if x_delta < 0: - x_delta = 0 - y_delta = y_2 - s_height - if y_delta < 0: - y_delta = 0 - - offscreen = (x_delta, y_delta) != (0, 0) - - if offscreen: - - if x_delta: - x_1 = mouse_x - tip_delta[0] - width - - if y_delta: - y_1 = mouse_y - tip_delta[1] - height - - offscreen_again = y_1 < 0 # out on the top - - if offscreen_again: - # No further checks will be done. - - # TIP: - # A further mod might auto-magically augment the - # wraplength when the tooltip is too high to be - # kept inside the screen. - y_1 = 0 - - return x_1, y_1 - - background = self.background - pad = self.pad - widget = self.widget - - # creates a toplevel window - self.topwidget = tk.Toplevel(widget) - if platform.system() == "Darwin": - # For Mac OS - self.topwidget.tk.call("::tk::unsupported::MacWindowStyle", - "style", self.topwidget._w, - "help", "none") - - # Leaves only the label and removes the app window - self.topwidget.wm_overrideredirect(True) - - win = tk.Frame(self.topwidget, - background=background, - borderwidth=0) - label = tk.Label(win, - text=self.text, - justify=tk.LEFT, - background=background, - relief=tk.SOLID, - borderwidth=0, - wraplength=self.wraplength) - - label.grid(padx=(pad[0], pad[2]), - pady=(pad[1], pad[3]), - sticky=tk.NSEW) - win.grid() - - xpos, ypos = tip_pos_calculator(widget, label) - - self.topwidget.wm_geometry("+%d+%d" % (xpos, ypos)) - - def hide(self): - """ Hide the tooltip """ - topwidget = self.topwidget - if topwidget: - topwidget.destroy() - self.topwidget = None diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 0c2b7dc524..0d32701b81 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -2,99 +2,149 @@ """ Utility functions for the GUI """ import logging import os -import platform -import re import sys import tkinter as tk -from tkinter import filedialog, ttk +from tkinter import filedialog from threading import Event, Thread from queue import Queue import numpy as np from PIL import Image, ImageDraw, ImageTk -from lib.serializer import get_serializer - from ._config import Config as UserConfig -from ._redirector import WidgetRedirector +from .project import Project, Tasks logger = logging.getLogger(__name__) # pylint: disable=invalid-name _CONFIG = None _IMAGES = None +PATHCACHE = os.path.join(os.path.realpath(os.path.dirname(sys.argv[0])), "lib", "gui", ".cache") + +def initialize_config(root, cli_opts, statusbar, session): + """ Initialize the GUI Master :class:`Config` and add to global constant. -def initialize_config(root, cli_opts, scaling_factor, pathcache, statusbar, session): - """ Initialize the config and add to global constant """ + This should only be called once on first GUI startup. Future access to :class:`Config` + should only be executed through :func:`get_config`. + + Parameters + ---------- + root: :class:`tkinter.Tk` + The root Tkinter object + cli_opts: :class:`lib.gui.options.CliOpts` + The command line options object + statusbar: :class:`lib.gui.custom_widgets.StatusBar` + The GUI Status bar + session: :class:`lib.gui.stats.Session` + The current training Session + """ global _CONFIG # pylint: disable=global-statement if _CONFIG is not None: - return - logger.debug("Initializing config: (root: %s, cli_opts: %s, tk_vars: %s, pathcache: %s, " - "statusbar: %s, session: %s)", root, cli_opts, scaling_factor, pathcache, - statusbar, session) - _CONFIG = Config(root, cli_opts, scaling_factor, pathcache, statusbar, session) + return None + logger.debug("Initializing config: (root: %s, cli_opts: %s, " + "statusbar: %s, session: %s)", root, cli_opts, statusbar, session) + _CONFIG = Config(root, cli_opts, statusbar, session) + return _CONFIG def get_config(): - """ return the _CONFIG constant """ + """ Get the Master GUI configuration. + + Returns + ------- + :class:`Config` + The Master GUI Config + """ return _CONFIG -def initialize_images(pathcache=None): - """ Initialize the images and add to global constant """ +def initialize_images(): + """ Initialize the :class:`Images` handler and add to global constant. + + This should only be called once on first GUI startup. Future access to :class:`Images` + handler should only be executed through :func:`get_images`. + """ global _IMAGES # pylint: disable=global-statement if _IMAGES is not None: return logger.debug("Initializing images") - _IMAGES = Images(pathcache) + _IMAGES = Images() def get_images(): - """ return the _CONFIG constant """ + """ Get the Master GUI Images handler. + + Returns + ------- + :class:`Images` + The Master GUI Images handler + """ return _IMAGES -class FileHandler(): - """ Raise a filedialog box and capture input """ +class FileHandler(): # pylint:disable=too-few-public-methods + """ Handles all GUI File Dialog actions and tasks. + + Parameters + ---------- + handletype: ['open', 'save', 'filename', 'filename_multi', 'savefilename', 'context'] + The type of file dialog to return. `open` and `save` will perform the open and save actions + and return the file. `filename` returns the filename from an `open` dialog. + `filename_multi` allows for multi-selection of files and returns a list of files selected. + `savefilename` returns the filename from a `save as` dialog. `context` is a context + sensitive parameter that returns a certain dialog based on the current options + filetype: ['default', 'alignments', 'config_project', 'config_task', 'config_all', 'csv', \ + 'image', 'ini', 'state', 'log', 'video'] + The type of file that this dialog is for. `default` allows selection of any files. Other + options limit the file type selection + title: str, optional + The title to display on the file dialog. If `None` then the default title will be used. + Default: ``None`` + initialdir: str, optional + The folder to initially open with the file dialog. If `None` then tkinter will decide. + Default: ``None`` + command: str, optional + Required for context handling file dialog, otherwise unused. Default: ``None`` + action: str, optional + Required for context handling file dialog, otherwise unused. Default: ``None`` + variable: :class:`tkinter.StringVar`, optional + Required for context handling file dialog, otherwise unused. The variable to associate + with this file dialog. Default: ``None`` + + Attributes + ---------- + retfile: str or object + The return value from the file dialog + + Example + ------- + >>> handler = FileHandler('filename', 'video', title='Select a video...') + >>> video_file = handler.retfile + >>> print(video_file) + '/path/to/selected/video.mp4' + """ - def __init__(self, handletype, filetype, command=None, action=None, - variable=None): - logger.debug("Initializing %s: (Handletype: '%s', filetype: '%s', command: '%s', action: " - "'%s', variable: %s)", self.__class__.__name__, handletype, filetype, command, + def __init__(self, handletype, filetype, title=None, initialdir=None, command=None, + action=None, variable=None): + logger.debug("Initializing %s: (Handletype: '%s', filetype: '%s', title: '%s', " + "initialdir: '%s, 'command: '%s', action: '%s', variable: %s)", + self.__class__.__name__, handletype, filetype, title, initialdir, command, action, variable) - self.handletype = handletype - self.contexts = { - "effmpeg": { - "input": {"extract": "filename", - "gen-vid": "dir", - "get-fps": "filename", - "get-info": "filename", - "mux-audio": "filename", - "rescale": "filename", - "rotate": "filename", - "slice": "filename"}, - "output": {"extract": "dir", - "gen-vid": "savefilename", - "get-fps": "nothing", - "get-info": "nothing", - "mux-audio": "savefilename", - "rescale": "savefilename", - "rotate": "savefilename", - "slice": "savefilename"} - } - } - self.defaults = self.set_defaults() - self.kwargs = self.set_kwargs(filetype, command, action, variable) - self.retfile = getattr(self, self.handletype.lower())() + self._handletype = handletype + self._defaults = self._set_defaults() + self._kwargs = self._set_kwargs(title, initialdir, filetype, command, action, variable) + self.retfile = getattr(self, "_{}".format(self._handletype.lower()))() logger.debug("Initialized %s", self.__class__.__name__) @property - def filetypes(self): - """ Set the filetypes for opening/saving """ + def _filetypes(self): + """ dict: The accepted extensions for each file type for opening/saving """ all_files = ("All files", "*.*") filetypes = {"default": (all_files,), "alignments": [("Faceswap Alignments", "*.fsa *.json"), all_files], - "config": [("Faceswap GUI config files", "*.fsw"), all_files], + "config_project": [("Faceswap Project files", "*.fsw"), all_files], + "config_task": [("Faceswap Task files", "*.fst"), all_files], + "config_all": [("Faceswap Project and Task files", "*.fst *.fsw"), all_files], "csv": [("Comma separated values", "*.csv"), all_files], "image": [("Bitmap", "*.bmp"), ("JPG", "*.jpeg *.jpg"), @@ -122,221 +172,386 @@ def filetypes(self): val.insert(0, tuple(multi)) return filetypes - def set_defaults(self): - """ Set the default filetype to be first in list of filetypes, - or set a custom filetype if the first is not correct """ + @property + def _contexts(self): + """dict: Mapping of commands, actions and their corresponding file dialog for context + handle types. """ + return { + "effmpeg": { + "input": { + "extract": "filename", + "gen-vid": "dir", + "get-fps": "filename", + "get-info": "filename", + "mux-audio": "filename", + "rescale": "filename", + "rotate": "filename", + "slice": "filename"}, + "output": { + "extract": "dir", + "gen-vid": "savefilename", + "get-fps": "nothing", + "get-info": "nothing", + "mux-audio": "savefilename", + "rescale": "savefilename", + "rotate": "savefilename", + "slice": "savefilename"} + } + } + + def _set_defaults(self): + """ Set the default file type for the file dialog. Generally the first found file type + will be used, but this is overridden if it is not appropriate. + + Returns + ------- + dict: + The default file extension for each file type + """ defaults = {key: val[0][1].replace("*", "") - for key, val in self.filetypes.items()} + for key, val in self._filetypes.items()} defaults["default"] = None defaults["video"] = ".mp4" defaults["image"] = ".png" logger.debug(defaults) return defaults - def set_kwargs(self, filetype, command, action, variable=None): - """ Generate the required kwargs for the requested browser """ - logger.debug("Setting Kwargs: (filetype: '%s', command: '%s': action: '%s', " - "variable: '%s')", filetype, command, action, variable) + def _set_kwargs(self, title, initialdir, filetype, command, action, variable=None): + """ Generate the required kwargs for the requested file dialog browser. + + Returns + ------- + dict: + The key word arguments for the file dialog to be launched + """ + logger.debug("Setting Kwargs: (title: %s, initialdir: %s, filetype: '%s', " + "command: '%s': action: '%s', variable: '%s')", + title, initialdir, filetype, command, action, variable) kwargs = dict() - if self.handletype.lower() == "context": - self.set_context_handletype(command, action, variable) + if self._handletype.lower() == "context": + self._set_context_handletype(command, action, variable) + + if title is not None: + kwargs["title"] = title - if self.handletype.lower() in ( + if initialdir is not None: + kwargs["initialdir"] = initialdir + + if self._handletype.lower() in ( "open", "save", "filename", "filename_multi", "savefilename"): - kwargs["filetypes"] = self.filetypes[filetype] - if self.defaults.get(filetype, None): - kwargs['defaultextension'] = self.defaults[filetype] - if self.handletype.lower() == "save": + kwargs["filetypes"] = self._filetypes[filetype] + if self._defaults.get(filetype, None): + kwargs['defaultextension'] = self._defaults[filetype] + if self._handletype.lower() == "save": kwargs["mode"] = "w" - if self.handletype.lower() == "open": + if self._handletype.lower() == "open": kwargs["mode"] = "r" logger.debug("Set Kwargs: %s", kwargs) return kwargs - def set_context_handletype(self, command, action, variable): - """ Choose the correct file browser action based on context """ - if self.contexts[command].get(variable, None) is not None: - handletype = self.contexts[command][variable][action] + def _set_context_handletype(self, command, action, variable): + """ Sets the correct handle type based on context. + + Parameters + ---------- + command: str + The command that is being executed. Used to look up the context actions + action: str + The action that is being performed. Used to look up the correct file dialog + variable: :class:`tkinter.StringVar` + The variable associated with this file dialog + """ + if self._contexts[command].get(variable, None) is not None: + handletype = self._contexts[command][variable][action] else: - handletype = self.contexts[command][action] + handletype = self._contexts[command][action] logger.debug(handletype) - self.handletype = handletype + self._handletype = handletype - def open(self): - """ Open a file """ + def _open(self): + """ Open a file. """ logger.debug("Popping Open browser") - return filedialog.askopenfile(**self.kwargs) + return filedialog.askopenfile(**self._kwargs) - def save(self): - """ Save a file """ + def _save(self): + """ Save a file. """ logger.debug("Popping Save browser") - return filedialog.asksaveasfile(**self.kwargs) + return filedialog.asksaveasfile(**self._kwargs) - def dir(self): - """ Get a directory location """ + def _dir(self): + """ Get a directory location. """ logger.debug("Popping Dir browser") - return filedialog.askdirectory(**self.kwargs) + return filedialog.askdirectory(**self._kwargs) - def savedir(self): - """ Get a save dir location """ + def _savedir(self): + """ Get a save directory location. """ logger.debug("Popping SaveDir browser") - return filedialog.askdirectory(**self.kwargs) + return filedialog.askdirectory(**self._kwargs) - def filename(self): - """ Get an existing file location """ + def _filename(self): + """ Get an existing file location. """ logger.debug("Popping Filename browser") - return filedialog.askopenfilename(**self.kwargs) + return filedialog.askopenfilename(**self._kwargs) - def filename_multi(self): - """ Get multiple existing file locations """ + def _filename_multi(self): + """ Get multiple existing file locations. """ logger.debug("Popping Filename browser") - return filedialog.askopenfilenames(**self.kwargs) + return filedialog.askopenfilenames(**self._kwargs) - def savefilename(self): - """ Get a save file location """ + def _savefilename(self): + """ Get a save file location. """ logger.debug("Popping SaveFilename browser") - return filedialog.asksaveasfilename(**self.kwargs) + return filedialog.asksaveasfilename(**self._kwargs) @staticmethod - def nothing(): # pylint: disable=useless-return - """ Method that does nothing, used for disabling open/save pop up """ + def _nothing(): # pylint: disable=useless-return + """ Method that does nothing, used for disabling open/save pop up. """ logger.debug("Popping Nothing browser") return class Images(): - """ Holds locations of images and actual images + """ The centralized image repository for holding all icons and images required by the GUI. - Don't call directly. Call get_images() + This class should be initialized on GUI startup through :func:`initialize_images`. Any further + access to this class should be through :func:`get_images`. """ - - def __init__(self, pathcache=None): + def __init__(self): logger.debug("Initializing %s", self.__class__.__name__) - pathcache = get_config().pathcache if pathcache is None else pathcache - self.pathicons = os.path.join(pathcache, "icons") - self.pathpreview = os.path.join(pathcache, "preview") - self.pathoutput = None - self.previewoutput = None - self.previewtrain = dict() - self.previewcache = dict(modified=None, # cache for extract and convert - images=None, - filenames=list(), - placeholder=None) - self.errcount = 0 - self.icons = dict() - self.icons["folder"] = ImageTk.PhotoImage(file=os.path.join( - self.pathicons, "open_folder.png")) - self.icons["load"] = ImageTk.PhotoImage(file=os.path.join( - self.pathicons, "open_file.png")) - self.icons["load_multi"] = ImageTk.PhotoImage(file=os.path.join( - self.pathicons, "open_file.png")) - self.icons["context"] = ImageTk.PhotoImage(file=os.path.join( - self.pathicons, "open_file.png")) - self.icons["save"] = ImageTk.PhotoImage(file=os.path.join(self.pathicons, "save.png")) - self.icons["reset"] = ImageTk.PhotoImage(file=os.path.join(self.pathicons, "reset.png")) - self.icons["clear"] = ImageTk.PhotoImage(file=os.path.join(self.pathicons, "clear.png")) - self.icons["graph"] = ImageTk.PhotoImage(file=os.path.join(self.pathicons, "graph.png")) - self.icons["zoom"] = ImageTk.PhotoImage(file=os.path.join(self.pathicons, "zoom.png")) - self.icons["move"] = ImageTk.PhotoImage(file=os.path.join(self.pathicons, "move.png")) - self.icons["favicon"] = ImageTk.PhotoImage(file=os.path.join(self.pathicons, "logo.png")) - logger.debug("Initialized %s: (icons: %s)", self.__class__.__name__, self.icons) + self._pathpreview = os.path.join(PATHCACHE, "preview") + self._pathoutput = None + self._previewoutput = None + self._previewtrain = dict() + self._previewcache = dict(modified=None, # cache for extract and convert + images=None, + filenames=list(), + placeholder=None) + self._errcount = 0 + self._icons = self._load_icons() + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def previewoutput(self): + """ Tuple or ``None``: First item in the tuple is the extract or convert preview image + (:class:`PIL.Image`), the second item is the image in a format that tkinter can display + (:class:`PIL.ImageTK.PhotoImage`). + + The value of the property is ``None`` if no extract or convert task is running or there are + no files available in the output folder. """ + return self._previewoutput + + @property + def previewtrain(self): + """ dict or ``None``: The training preview images. Dictionary key is the image name + (`str`). Dictionary values are a `list` of the training image (:class:`PIL.Image`), the + image formatted for tkinter display (:class:`PIL.ImageTK.PhotoImage`), the last + modification time of the image (`float`). + + The value of this property is ``None`` if training is not running or there are no preview + images available. + """ + return self._previewtrain + + @property + def icons(self): + """ dict: The faceswap icons for all parts of the GUI. The dictionary key is the icon + name (`str`) the value is the icon sized and formatted for display + (:class:`PIL.ImageTK.PhotoImage`). + + Example + ------- + >>> icons = get_images().icons + >>> save = icons["save"] + >>> button = ttk.Button(parent, image=save) + >>> button.pack() + """ + return self._icons + + @staticmethod + def _load_icons(): + """ Scan the icons cache folder and load the icons into :attr:`icons` for retrieval + throughout the GUI. + + Returns + ------- + dict: + The icons formatted as described in :attr:`icons` + + """ + size = get_config().user_config_dict.get("icon_size", 16) + size = int(round(size * get_config().scaling_factor)) + icons = dict() + pathicons = os.path.join(PATHCACHE, "icons") + for fname in os.listdir(pathicons): + name, ext = os.path.splitext(fname) + if ext != ".png": + continue + img = Image.open(os.path.join(pathicons, fname)) + img = ImageTk.PhotoImage(img.resize((size, size), resample=Image.HAMMING)) + icons[name] = img + logger.debug(icons) + return icons + + def set_faceswap_output_path(self, location): + """ Set the path that will contain the output from an Extract or Convert task. + + Required so that the GUI can fetch output images to display for return in + :attr:`previewoutput`. + + Parameters + ---------- + location: str + The output location that has been specified for an Extract or Convert task + """ + self._pathoutput = location def delete_preview(self): - """ Delete the preview files """ + """ Delete the preview files in the cache folder and reset the image cache. + + Should be called when terminating tasks, or when Faceswap starts up or shuts down. + """ logger.debug("Deleting previews") - for item in os.listdir(self.pathpreview): + for item in os.listdir(self._pathpreview): if item.startswith(".gui_training_preview") and item.endswith(".jpg"): - fullitem = os.path.join(self.pathpreview, item) + fullitem = os.path.join(self._pathpreview, item) logger.debug("Deleting: '%s'", fullitem) os.remove(fullitem) - for fname in self.previewcache["filenames"]: + for fname in self._previewcache["filenames"]: if os.path.basename(fname) == ".gui_preview.jpg": logger.debug("Deleting: '%s'", fname) try: os.remove(fname) except FileNotFoundError: logger.debug("File does not exist: %s", fname) - self.clear_image_cache() + self._clear_image_cache() - def clear_image_cache(self): - """ Clear all cached images """ + def _clear_image_cache(self): + """ Clear all cached images. """ logger.debug("Clearing image cache") - self.pathoutput = None - self.previewoutput = None - self.previewtrain = dict() - self.previewcache = dict(modified=None, # cache for extract and convert - images=None, - filenames=list(), - placeholder=None) + self._pathoutput = None + self._previewoutput = None + self._previewtrain = dict() + self._previewcache = dict(modified=None, # cache for extract and convert + images=None, + filenames=list(), + placeholder=None) @staticmethod - def get_images(imgpath): - """ Get the images stored within the given directory """ - logger.debug("Getting images: '%s'", imgpath) - if not os.path.isdir(imgpath): + def _get_images(image_path): + """ Get the images stored within the given directory. + + Parameters + ---------- + image_path: str + The folder containing images to be scanned + + Returns + ------- + list: + The image filenames stored within the given folder + + """ + logger.debug("Getting images: '%s'", image_path) + if not os.path.isdir(image_path): logger.debug("Folder does not exist") return None - files = [os.path.join(imgpath, f) - for f in os.listdir(imgpath) if f.lower().endswith((".png", ".jpg"))] + files = [os.path.join(image_path, f) + for f in os.listdir(image_path) if f.lower().endswith((".png", ".jpg"))] logger.debug("Image files: %s", files) return files def load_latest_preview(self, thumbnail_size, frame_dims): - """ Load the latest preview image for extract and convert """ + """ Load the latest preview image for extract and convert. + + Retrieves the latest preview images from the faceswap output folder, resizes to thumbnails + and lays out for display. Places the images into :attr:`previewoutput` for loading into + the display panel. + + Parameters + ---------- + thumbnail_size: int + The size of each thumbnail that should be created + frame_dims: tuple + The (width (`int`), height (`int`)) of the display panel that will display the preview + """ logger.debug("Loading preview image: (thumbnail_size: %s, frame_dims: %s)", thumbnail_size, frame_dims) - imagefiles = self.get_images(self.pathoutput) - gui_preview = os.path.join(self.pathoutput, ".gui_preview.jpg") - if not imagefiles or (len(imagefiles) == 1 and gui_preview not in imagefiles): + image_files = self._get_images(self._pathoutput) + gui_preview = os.path.join(self._pathoutput, ".gui_preview.jpg") + if not image_files or (len(image_files) == 1 and gui_preview not in image_files): logger.debug("No preview to display") - self.previewoutput = None + self._previewoutput = None return # Filter to just the gui_preview if it exists in folder output - imagefiles = [gui_preview] if gui_preview in imagefiles else imagefiles - logger.debug("Image Files: %s", len(imagefiles)) + image_files = [gui_preview] if gui_preview in image_files else image_files + logger.debug("Image Files: %s", len(image_files)) - imagefiles = self.get_newest_filenames(imagefiles) - if not imagefiles: + image_files = self._get_newest_filenames(image_files) + if not image_files: return - self.load_images_to_cache(imagefiles, frame_dims, thumbnail_size) - if imagefiles == [gui_preview]: + self._load_images_to_cache(image_files, frame_dims, thumbnail_size) + if image_files == [gui_preview]: # Delete the preview image so that the main scripts know to output another logger.debug("Deleting preview image") - os.remove(imagefiles[0]) - show_image = self.place_previews(frame_dims) + os.remove(image_files[0]) + show_image = self._place_previews(frame_dims) if not show_image: - self.previewoutput = None + self._previewoutput = None return - logger.debug("Displaying preview: %s", self.previewcache["filenames"]) - self.previewoutput = (show_image, ImageTk.PhotoImage(show_image)) - - def get_newest_filenames(self, imagefiles): - """ Return image filenames that have been modified since the last check """ - if self.previewcache["modified"] is None: - retval = imagefiles + logger.debug("Displaying preview: %s", self._previewcache["filenames"]) + self._previewoutput = (show_image, ImageTk.PhotoImage(show_image)) + + def _get_newest_filenames(self, image_files): + """ Return image filenames that have been modified since the last check. + + Parameters + ---------- + image_files: list + The list of image files to check the modification date for + + Returns + ------- + list: + A list of images that have been modified since the last check + """ + if self._previewcache["modified"] is None: + retval = image_files else: - retval = [fname for fname in imagefiles - if os.path.getmtime(fname) > self.previewcache["modified"]] + retval = [fname for fname in image_files + if os.path.getmtime(fname) > self._previewcache["modified"]] if not retval: logger.debug("No new images in output folder") else: - self.previewcache["modified"] = max([os.path.getmtime(img) for img in retval]) + self._previewcache["modified"] = max([os.path.getmtime(img) for img in retval]) logger.debug("Number new images: %s, Last Modified: %s", - len(retval), self.previewcache["modified"]) + len(retval), self._previewcache["modified"]) return retval - def load_images_to_cache(self, imagefiles, frame_dims, thumbnail_size): - """ Load new images and append to cache, filtering to the number of display images """ - logger.debug("Number imagefiles: %s, frame_dims: %s, thumbnail_size: %s", - len(imagefiles), frame_dims, thumbnail_size) + def _load_images_to_cache(self, image_files, frame_dims, thumbnail_size): + """ Load preview images to the image cache. + + Load new images and append to cache, filtering the cache the number of thumbnails that will + fit inside the display panel. + + Parameters + ---------- + image_files: list + A list of new image files that have been modified since the last check + frame_dims: tuple + The (width (`int`), height (`int`)) of the display panel that will display the preview + thumbnail_size: int + The size of each thumbnail that should be created + """ + logger.debug("Number image_files: %s, frame_dims: %s, thumbnail_size: %s", + len(image_files), frame_dims, thumbnail_size) num_images = (frame_dims[0] // thumbnail_size) * (frame_dims[1] // thumbnail_size) logger.debug("num_images: %s", num_images) if num_images == 0: return samples = list() - start_idx = len(imagefiles) - num_images if len(imagefiles) > num_images else 0 - show_files = sorted(imagefiles, key=os.path.getctime)[start_idx:] + start_idx = len(image_files) - num_images if len(image_files) > num_images else 0 + show_files = sorted(image_files, key=os.path.getctime)[start_idx:] for fname in show_files: img = Image.open(fname) width, height = img.size @@ -353,56 +568,38 @@ def load_images_to_cache(self, imagefiles, frame_dims, thumbnail_size): draw.rectangle(((0, 0), (thumbnail_size, thumbnail_size)), outline="#E5E5E5", width=1) samples.append(np.array(img)) samples = np.array(samples) - self.previewcache["filenames"] = (self.previewcache["filenames"] + - show_files)[-num_images:] - cache = self.previewcache["images"] + self._previewcache["filenames"] = (self._previewcache["filenames"] + + show_files)[-num_images:] + cache = self._previewcache["images"] if cache is None: logger.debug("Creating new cache") cache = samples[-num_images:] else: logger.debug("Appending to existing cache") cache = np.concatenate((cache, samples))[-num_images:] - self.previewcache["images"] = cache - logger.debug("Cache shape: %s", self.previewcache["images"].shape) - - @staticmethod - def get_preview_samples(imagefiles, num_images, thumbnail_size): - """ Return a subset of the imagefiles images - Exclude final file so we don't accidentally load a file that is being saved """ - logger.debug("num_images: %s", num_images) - samples = list() - start_idx = len(imagefiles) - (num_images + 1) - end_idx = len(imagefiles) - 1 - logger.debug("start_idx: %s, end_idx: %s", start_idx, end_idx) - show_files = sorted(imagefiles, key=os.path.getctime)[start_idx: end_idx] - for fname in show_files: - img = Image.open(fname) - width, height = img.size - scaling = thumbnail_size / max(width, height) - logger.debug("image width: %s, height: %s, scaling: %s", width, height, scaling) - img = img.resize((int(width * scaling), int(height * scaling))) - if img.size[0] != img.size[1]: - # Pad to square - new_img = Image.new("RGB", (thumbnail_size, thumbnail_size)) - new_img.paste(img, ((thumbnail_size - img.size[0])//2, - (thumbnail_size - img.size[1])//2)) - img = new_img - draw = ImageDraw.Draw(img) - draw.rectangle(((0, 0), (thumbnail_size, thumbnail_size)), outline="#E5E5E5", width=1) - samples.append(np.array(img)) - samples = np.array(samples) - logger.debug("Samples shape: %s", samples.shape) - return show_files, samples - - def place_previews(self, frame_dims): - """ Stack the preview images to fit display """ - if self.previewcache.get("images", None) is None: + self._previewcache["images"] = cache + logger.debug("Cache shape: %s", self._previewcache["images"].shape) + + def _place_previews(self, frame_dims): + """ Format the preview thumbnails stored in the cache into a grid fitting the display + panel. + + Parameters + ---------- + frame_dims: tuple + The (width (`int`), height (`int`)) of the display panel that will display the preview + + Returns + :class:`PIL.Image`: + The final preview display image + """ + if self._previewcache.get("images", None) is None: logger.debug("No images in cache. Returning None") return None - samples = self.previewcache["images"].copy() + samples = self._previewcache["images"].copy() num_images, thumbnail_size = samples.shape[:2] - if self.previewcache["placeholder"] is None: - self.create_placeholder(thumbnail_size) + if self._previewcache["placeholder"] is None: + self._create_placeholder(thumbnail_size) logger.debug("num_images: %s, thumbnail_size: %s", num_images, thumbnail_size) cols, rows = frame_dims[0] // thumbnail_size, frame_dims[1] // thumbnail_size @@ -413,7 +610,7 @@ def place_previews(self, frame_dims): remainder = (cols * rows) - num_images if remainder != 0: logger.debug("Padding sample display. Remainder: %s", remainder) - placeholder = np.concatenate([np.expand_dims(self.previewcache["placeholder"], + placeholder = np.concatenate([np.expand_dims(self._previewcache["placeholder"], 0)] * remainder) samples = np.concatenate((samples, placeholder)) @@ -422,77 +619,110 @@ def place_previews(self, frame_dims): logger.debug("display shape: %s", display.shape) return Image.fromarray(display) - def create_placeholder(self, thumbnail_size): - """ Create a placeholder image for when there are fewer samples available - then columns to display them """ + def _create_placeholder(self, thumbnail_size): + """ Create a placeholder image for when there are fewer thumbnails available + than columns to display them. + + Parameters + ---------- + thumbnail_size: int + The size of the thumbnail that the placeholder should replicate + """ logger.debug("Creating placeholder. thumbnail_size: %s", thumbnail_size) placeholder = Image.new("RGB", (thumbnail_size, thumbnail_size)) draw = ImageDraw.Draw(placeholder) draw.rectangle(((0, 0), (thumbnail_size, thumbnail_size)), outline="#E5E5E5", width=1) placeholder = np.array(placeholder) - self.previewcache["placeholder"] = placeholder + self._previewcache["placeholder"] = placeholder logger.debug("Created placeholder. shape: %s", placeholder.shape) def load_training_preview(self): - """ Load the training preview images """ + """ Load the training preview images. + + Reads the training image currently stored in the cache folder and loads them to + :attr:`previewtrain` for retrieval in the GUI. + """ logger.debug("Loading Training preview images") - imagefiles = self.get_images(self.pathpreview) + image_files = self._get_images(self._pathpreview) modified = None - if not imagefiles: + if not image_files: logger.debug("No preview to display") - self.previewtrain = dict() + self._previewtrain = dict() return - for img in imagefiles: + for img in image_files: modified = os.path.getmtime(img) if modified is None else modified name = os.path.basename(img) name = os.path.splitext(name)[0] name = name[name.rfind("_") + 1:].title() try: logger.debug("Displaying preview: '%s'", img) - size = self.get_current_size(name) - self.previewtrain[name] = [Image.open(img), None, modified] + size = self._get_current_size(name) + self._previewtrain[name] = [Image.open(img), None, modified] self.resize_image(name, size) - self.errcount = 0 + self._errcount = 0 except ValueError: # This is probably an error reading the file whilst it's # being saved so ignore it for now and only pick up if # there have been multiple consecutive fails logger.warning("Unable to display preview: (image: '%s', attempt: %s)", - img, self.errcount) - if self.errcount < 10: - self.errcount += 1 + img, self._errcount) + if self._errcount < 10: + self._errcount += 1 else: logger.error("Error reading the preview file for '%s'", img) print("Error reading the preview file for {}".format(name)) - self.previewtrain[name] = None - - def get_current_size(self, name): - """ Return the size of the currently displayed image """ + self._previewtrain[name] = None + + def _get_current_size(self, name): + """ Return the size of the currently displayed training preview image. + + Parameters + ---------- + name: str + The name of the training image to get the size for + + Returns + ------- + width: int + The width of the training image + height: int + The height of the training image + """ logger.debug("Getting size: '%s'", name) - if not self.previewtrain.get(name, None): + if not self._previewtrain.get(name, None): return None - img = self.previewtrain[name][1] + img = self._previewtrain[name][1] if not img: return None logger.debug("Got size: (name: '%s', width: '%s', height: '%s')", name, img.width(), img.height()) return img.width(), img.height() - def resize_image(self, name, framesize): - """ Resize the training preview image - based on the passed in frame size """ - logger.debug("Resizing image: (name: '%s', framesize: %s", name, framesize) - displayimg = self.previewtrain[name][0] - if framesize: - frameratio = float(framesize[0]) / float(framesize[1]) + def resize_image(self, name, frame_dims): + """ Resize the training preview image based on the passed in frame size. + + If the canvas that holds the preview image changes, update the image size + to fit the new canvas and refresh :attr:`previewtrain`. + + Parameters + ---------- + name: str + The name of the training image to be resized + frame_dims: tuple + The (width (`int`), height (`int`)) of the display panel that will display the preview + """ + logger.debug("Resizing image: (name: '%s', frame_dims: %s", name, frame_dims) + displayimg = self._previewtrain[name][0] + if frame_dims: + frameratio = float(frame_dims[0]) / float(frame_dims[1]) imgratio = float(displayimg.size[0]) / float(displayimg.size[1]) if frameratio <= imgratio: - scale = framesize[0] / float(displayimg.size[0]) - size = (framesize[0], int(displayimg.size[1] * scale)) + scale = frame_dims[0] / float(displayimg.size[0]) + size = (frame_dims[0], int(displayimg.size[1] * scale)) else: - scale = framesize[1] / float(displayimg.size[1]) - size = (int(displayimg.size[0] * scale), framesize[1]) + scale = frame_dims[1] / float(displayimg.size[1]) + size = (int(displayimg.size[0] * scale), frame_dims[1]) logger.debug("Scaling: (scale: %s, size: %s", scale, size) # Hacky fix to force a reload if it happens to find corrupted @@ -506,187 +736,238 @@ def resize_image(self, name, framesize): raise continue break - - self.previewtrain[name][1] = ImageTk.PhotoImage(displayimg) + self._previewtrain[name][1] = ImageTk.PhotoImage(displayimg) -class ReadOnlyText(tk.Text): # pylint: disable=too-many-ancestors - """ A read only text widget that redirects a standard tk.Text widget's insert and delete - attributes. - Source: https://stackoverflow.com/questions/3842155 +class Config(): + """ The centralized configuration class for holding items that should be made available to all + parts of the GUI. + + This class should be initialized on GUI startup through :func:`initialize_config`. Any further + access to this class should be through :func:`get_config`. + + Parameters + ---------- + root: :class:`tkinter.Tk` + The root Tkinter object + cli_opts: :class:`lib.gui.options.CliOpts` + The command line options object + statusbar: :class:`lib.gui.custom_widgets.StatusBar` + The GUI Status bar + session: :class:`lib.gui.stats.Session` + The current training Session """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.redirector = WidgetRedirector(self) - self.insert = self.redirector.register("insert", lambda *args, **kw: "break") - self.delete = self.redirector.register("delete", lambda *args, **kw: "break") - - -class ConsoleOut(ttk.Frame): # pylint: disable=too-many-ancestors - """ The Console out section of the GUI """ - - def __init__(self, parent, debug): - logger.debug("Initializing %s: (parent: %s, debug: %s)", - self.__class__.__name__, parent, debug) - ttk.Frame.__init__(self, parent) - self.pack(side=tk.TOP, anchor=tk.W, padx=10, pady=(2, 0), - fill=tk.BOTH, expand=True) - self.console = ReadOnlyText(self) - rc_menu = ContextMenu(self.console) - rc_menu.cm_bind() - self.console_clear = get_config().tk_vars['consoleclear'] - self.set_console_clear_var_trace() - self.debug = debug - self.build_console() - self.add_tags() + def __init__(self, root, cli_opts, statusbar, session): + logger.debug("Initializing %s: (root %s, cli_opts: %s, statusbar: %s, session: %s)", + self.__class__.__name__, root, cli_opts, statusbar, session) + self._constants = dict( + root=root, + scaling_factor=self._get_scaling(root), + default_font=tk.font.nametofont("TkDefaultFont").configure()["family"]) + self._gui_objects = dict( + cli_opts=cli_opts, + tk_vars=self._set_tk_vars(), + project=Project(self, FileHandler), + tasks=Tasks(self, FileHandler), + status_bar=statusbar, + command_notebook=None) # set in command.py + self._user_config = UserConfig(None) + self.session = session + self._default_font = tk.font.nametofont("TkDefaultFont").configure()["family"] logger.debug("Initialized %s", self.__class__.__name__) - def set_console_clear_var_trace(self): - """ Set the trigger actions for the clear console var - when it has been triggered from elsewhere """ - logger.debug("Set clear trace") - self.console_clear.trace("w", self.clear) - - def build_console(self): - """ Build and place the console """ - logger.debug("Build console") - self.console.config(width=100, height=6, bg="gray90", fg="black") - self.console.pack(side=tk.LEFT, anchor=tk.N, fill=tk.BOTH, expand=True) - - scrollbar = ttk.Scrollbar(self, command=self.console.yview) - scrollbar.pack(side=tk.LEFT, fill="y") - self.console.configure(yscrollcommand=scrollbar.set) - - self.redirect_console() - logger.debug("Built console") - - def add_tags(self): - """ Add tags to text widget to color based on output """ - logger.debug("Adding text color tags") - self.console.tag_config("default", foreground="#1E1E1E") - self.console.tag_config("stderr", foreground="#E25056") - self.console.tag_config("info", foreground="#2B445E") - self.console.tag_config("verbose", foreground="#008140") - self.console.tag_config("warning", foreground="#F77B00") - self.console.tag_config("critical", foreground="red") - self.console.tag_config("error", foreground="red") - - def redirect_console(self): - """ Redirect stdout/stderr to console frame """ - logger.debug("Redirect console") - if self.debug: - logger.info("Console debug activated. Outputting to main terminal") - else: - sys.stdout = SysOutRouter(console=self.console, out_type="stdout") - sys.stderr = SysOutRouter(console=self.console, out_type="stderr") - logger.debug("Redirected console") - - def clear(self, *args): # pylint: disable=unused-argument - """ Clear the console output screen """ - logger.debug("Clear console") - if not self.console_clear.get(): - logger.debug("Console not set for clearing. Skipping") - return - self.console.delete(1.0, tk.END) - self.console_clear.set(False) - logger.debug("Cleared console") + # Constants + @property + def root(self): + """ :class:`tkinter.Tk`: The root tkinter window. """ + return self._constants["root"] + @property + def scaling_factor(self): + """ float: The scaling factor for current display. """ + return self._constants["scaling_factor"] -class SysOutRouter(): - """ Route stdout/stderr to the console window """ + @property + def pathcache(self): + """ str: The path to the GUI cache folder """ + return PATHCACHE - def __init__(self, console=None, out_type=None): - logger.debug("Initializing %s: (console: %s, out_type: '%s')", - self.__class__.__name__, console, out_type) - self.console = console - self.out_type = out_type - self.recolor = re.compile(r".+?(\s\d+:\d+:\d+\s)(?P[A-Z]+)\s") - logger.debug("Initialized %s", self.__class__.__name__) + # GUI Objects + @property + def cli_opts(self): + """ :class:`lib.gui.options.CliOptions`: The command line options for this GUI Session. """ + return self._gui_objects["cli_opts"] - def get_tag(self, string): - """ Set the tag based on regex of log output """ - if self.out_type == "stderr": - # Output all stderr in red - return self.out_type + @property + def tk_vars(self): + """ dict: The global tkinter variables. """ + return self._gui_objects["tk_vars"] - output = self.recolor.match(string) - if not output: - return "default" - tag = output.groupdict()["lvl"].strip().lower() - return tag + @property + def project(self): + """ :class:`lib.gui.project.Project`: The project session handler. """ + return self._gui_objects["project"] - def write(self, string): - """ Capture stdout/stderr """ - self.console.insert(tk.END, string, self.get_tag(string)) - self.console.see(tk.END) + @property + def tasks(self): + """ :class:`lib.gui.project.Tasks`: The session tasks handler. """ + return self._gui_objects["tasks"] - @staticmethod - def flush(): - """ If flush is forced, send it to normal terminal """ - sys.__stdout__.flush() + @property + def statusbar(self): + """ :class:`lib.gui.custom_widgets.StatusBar`: The GUI StatusBar + :class:`tkinter.ttk.Frame`. """ + return self._gui_objects["status_bar"] + @property + def command_notebook(self): + """ :class:`lib.gui.command.CommandNoteboook`: The main Faceswap Command Notebook. """ + return self._gui_objects["command_notebook"] -class Config(): - """ Global configuration settings + # Convenience GUI Objects + @property + def tools_notebook(self): + """ :class:`lib.gui.command.ToolsNotebook`: The Faceswap Tools sub-Notebook. """ + return self.command_notebook.tools_notebook - Don't call directly. Call get_config() - """ + @property + def modified_vars(self): + """ dict: The command notebook modified tkinter variables. """ + return self.command_notebook.modified_vars - def __init__(self, root, cli_opts, scaling_factor, pathcache, statusbar, session): - logger.debug("Initializing %s: (root %s, cli_opts: %s, scaling_factor: %s, pathcache: %s, " - "statusbar: %s, session: %s)", self.__class__.__name__, root, cli_opts, - scaling_factor, pathcache, statusbar, session) - self.root = root - self.cli_opts = cli_opts - self.scaling_factor = scaling_factor - self.pathcache = pathcache - self.statusbar = statusbar - self.serializer = get_serializer("json") - self.tk_vars = self.set_tk_vars() - self.user_config = UserConfig(None) - self.user_config_dict = self.user_config.config_dict - self.command_notebook = None # set in command.py - self.session = session - logger.debug("Initialized %s", self.__class__.__name__) + @property + def _command_tabs(self): + """ dict: Command tab titles with their IDs. """ + return self.command_notebook.tab_names + + @property + def _tools_tabs(self): + """ dict: Tools command tab titles with their IDs. """ + return self.command_notebook.tools_tab_names + + # Config + @property + def user_config(self): + """ dict: The GUI config in dict form. """ + return self._user_config + + @property + def user_config_dict(self): + """ dict: The GUI config in dict form. """ + return self._user_config.config_dict @property def default_font(self): - """ Return the selected font """ + """ tuple: The selected font as configured in user settings. First item is the font (`str`) + second item the font size (`int`). """ font = self.user_config_dict["font"] - if font == "default": - font = tk.font.nametofont("TkDefaultFont").configure()["family"] + font = self._default_font if font == "default" else font return (font, self.user_config_dict["font_size"]) - @property - def command_tabs(self): - """ Return dict of command tab titles with their IDs """ - return {self.command_notebook.tab(tab_id, "text").lower(): tab_id - for tab_id in range(0, self.command_notebook.index("end"))} + @staticmethod + def _get_scaling(root): + """ Get the display DPI. + + Returns + ------- + float: + The scaling factor + """ + dpi = root.winfo_fpixels("1i") + scaling = dpi / 72.0 + logger.debug("dpi: %s, scaling: %s'", dpi, scaling) + return scaling + + def set_command_notebook(self, notebook): + """ Set the command notebook to the :attr:`command_notebook` attribute + and enable the modified callback for :attr:`project`. + + Parameters + ---------- + notebook: :class:`lib.gui.command.CommandNotebook` + The main command notebook for the Faceswap GUI + """ + logger.debug("Setting commane notebook: %s", notebook) + self._gui_objects["command_notebook"] = notebook + self.project.set_modified_callback() + + def set_active_tab_by_name(self, name): + """ Sets the :attr:`command_notebook` or :attr:`tools_notebook` to active based on given + name. + + Parameters + ---------- + name: str + The name of the tab to set active + """ + name = name.lower() + if name in self._command_tabs: + tab_id = self._command_tabs[name] + logger.debug("Setting active tab to: (name: %s, id: %s)", name, tab_id) + self.command_notebook.select(tab_id) + elif name in self._tools_tabs: + self.command_notebook.select(self._command_tabs["tools"]) + tab_id = self._tools_tabs[name] + logger.debug("Setting active Tools tab to: (name: %s, id: %s)", name, tab_id) + self.tools_notebook.select() + else: + logger.debug("Name couldn't be found. Setting to id 0: %s", name) + self.command_notebook.select(0) - @property - def tools_command_tabs(self): - """ Return dict of tools command tab titles with their IDs """ - return {self.command_notebook.tools_notebook.tab(tab_id, "text").lower(): tab_id - for tab_id in range(0, self.command_notebook.tools_notebook.index("end"))} + def set_modified_true(self, command): + """ Set the modified variable to ``True`` for the given command in :attr:`modified_vars`. + + Parameters + ---------- + command: str + The command to set the modified state to ``True`` + + """ + tkvar = self.modified_vars.get(command, None) + if tkvar is None: + logger.debug("No tkvar for command: '%s'", command) + return + tkvar.set(True) + logger.debug("Set modified var to True for: '%s'", command) + + def refresh_config(self): + """ Reload the user config from file. """ + self._user_config = UserConfig(None) def set_cursor_busy(self, widget=None): - """ Set the root or widget cursor to busy """ + """ Set the root or widget cursor to busy. + + Parameters + ---------- + widget: tkinter object, optional + The widget to set busy cursor for. If the provided value is ``None`` then sets the + cursor busy for the whole of the GUI. Default: ``None``. + """ logger.debug("Setting cursor to busy. widget: %s", widget) widget = self.root if widget is None else widget widget.config(cursor="watch") widget.update_idletasks() def set_cursor_default(self, widget=None): - """ Set the root or widget cursor to default """ + """ Set the root or widget cursor to default. + + Parameters + ---------- + widget: tkinter object, optional + The widget to set default cursor for. If the provided value is ``None`` then sets the + cursor busy for the whole of the GUI. Default: ``None`` + """ logger.debug("Setting cursor to default. widget: %s", widget) widget = self.root if widget is None else widget widget.config(cursor="") widget.update_idletasks() @staticmethod - def set_tk_vars(): - """ TK Variables to be triggered by to indicate - what state various parts of the GUI should be in """ + def _set_tk_vars(): + """ Set the global tkinter variables stored for easy access in :class:`Config`. + + The variables are available through :attr:`tk_vars`. + """ display = tk.StringVar() display.set(None) @@ -714,8 +995,8 @@ def set_tk_vars(): updatepreview = tk.BooleanVar() updatepreview.set(False) - traintimeout = tk.IntVar() - traintimeout.set(120) + analysis_folder = tk.StringVar() + analysis_folder.set(None) tk_vars = {"display": display, "runningtask": runningtask, @@ -726,142 +1007,38 @@ def set_tk_vars(): "refreshgraph": refreshgraph, "smoothgraph": smoothgraph, "updatepreview": updatepreview, - "traintimeout": traintimeout} + "analysis_folder": analysis_folder} logger.debug(tk_vars) return tk_vars - def load(self, command=None, filename=None): - """ Pop up load dialog for a saved config file """ - logger.debug("Loading config: (command: '%s')", command) - if filename: - if not os.path.isfile(filename): - msg = "File does not exist: '{}'".format(filename) - logger.error(msg) - return - cfg = self.serializer.load(filename) - else: - cfgfile = FileHandler("open", "config").retfile - if not cfgfile: - return - filename = cfgfile.name - cfgfile.close() - cfg = self.serializer.load(filename) - - if not command and len(cfg.keys()) == 1: - command = list(cfg.keys())[0] - - opts = self.get_command_options(cfg, command) if command else cfg - if not opts: - return + def set_root_title(self, text=None): + """ Set the main title text for Faceswap. - for cmd, opts in opts.items(): - self.set_command_args(cmd, opts) + The title will always begin with 'Faceswap.py'. Additional text can be appended. - if command: - if command in self.command_tabs: - self.command_notebook.select(self.command_tabs[command]) - else: - self.command_notebook.select(self.command_tabs["tools"]) - self.command_notebook.tools_notebook.select(self.tools_command_tabs[command]) - self.add_to_recent(filename, command) - logger.debug("Loaded config: (command: '%s', filename: '%s')", command, filename) - - def get_command_options(self, cfg, command): - """ return the saved options for the requested - command, if not loading global options """ - opts = cfg.get(command, None) - retval = {command: opts} - if not opts: - self.tk_vars["consoleclear"].set(True) - print("No {} section found in file".format(command)) - logger.info("No %s section found in file", command) - retval = None - logger.debug(retval) - return retval + Parameters + ---------- + text: str, optional + Additional text to be appended to the GUI title bar. Default: ``None`` + """ + title = "Faceswap.py" + title += " - {}".format(text) if text is not None and text else "" + self.root.title(title) - def set_command_args(self, command, options): - """ Pass the saved config items back to the CliOptions """ - if not options: - return - for srcopt, srcval in options.items(): - optvar = self.cli_opts.get_one_option_variable(command, srcopt) - if not optvar: - continue - optvar.set(srcval) - - def save(self, command=None): - """ Save the current GUI state to a config file in json format """ - logger.debug("Saving config: (command: '%s')", command) - cfgfile = FileHandler("save", "config").retfile - if not cfgfile: - return - filename = cfgfile.name - cfgfile.close() - cfg = self.cli_opts.get_option_values(command) - self.serializer.save(filename, cfg) - self.add_to_recent(filename, command) - logger.debug("Saved config: (command: '%s', filename: '%s')", command, filename) - - def add_to_recent(self, filename, command): - """ Add to recent files """ - recent_filename = os.path.join(self.pathcache, ".recent.json") - logger.debug("Adding to recent files '%s': (%s, %s)", recent_filename, filename, command) - if not os.path.exists(recent_filename) or os.path.getsize(recent_filename) == 0: - recent_files = list() - else: - recent_files = self.serializer.load(recent_filename) - logger.debug("Initial recent files: %s", recent_files) - filenames = [recent[0] for recent in recent_files] - if filename in filenames: - idx = filenames.index(filename) - del recent_files[idx] - recent_files.insert(0, (filename, command)) - recent_files = recent_files[:20] - logger.debug("Final recent files: %s", recent_files) - self.serializer.save(recent_filename, recent_files) - - -class ContextMenu(tk.Menu): # pylint: disable=too-many-ancestors - """ Pop up menu """ - def __init__(self, widget): - logger.debug("Initializing %s: (widget_class: '%s')", - self.__class__.__name__, widget.winfo_class()) - super().__init__(tearoff=0) - self.widget = widget - self.standard_actions() - logger.debug("Initialized %s", self.__class__.__name__) - def standard_actions(self): - """ Standard menu actions """ - self.add_command(label="Cut", command=lambda: self.widget.event_generate("<>")) - self.add_command(label="Copy", command=lambda: self.widget.event_generate("<>")) - self.add_command(label="Paste", command=lambda: self.widget.event_generate("<>")) - self.add_separator() - self.add_command(label="Select all", command=self.select_all) - - def cm_bind(self): - """ Bind the menu to the widget's Right Click event """ - button = "" if platform.system() == "Darwin" else "" - logger.debug("Binding '%s' to '%s'", button, self.widget.winfo_class()) - scaling_factor = get_config().scaling_factor if get_config() is not None else 1.0 - x_offset = int(34 * scaling_factor) - self.widget.bind(button, - lambda event: self.tk_popup(event.x_root + x_offset, event.y_root, 0)) - - def select_all(self): - """ Select all for Text or Entry widgets """ - logger.debug("Selecting all for '%s'", self.widget.winfo_class()) - if self.widget.winfo_class() == "Text": - self.widget.focus_force() - self.widget.tag_add("sel", "1.0", "end") - else: - self.widget.focus_force() - self.widget.select_range(0, tk.END) +class LongRunningTask(Thread): + """ Runs long running tasks in a background thread to prevent the GUI from becoming + unresponsive. + This is sub-classed from :class:`Threading.Thread` so check documentation there for base + parameters. Additional parameters listed below. -class LongRunningTask(Thread): - """ For long running tasks, to stop the GUI becoming unresponsive - Run in a thread and handle cursor events """ + Parameters + ---------- + widget: tkinter object, optional + The widget that this :class:`LongRunningTask` is associated with. Used for setting the busy + cursor in the correct location. Default: ``None``. + """ def __init__(self, group=None, target=None, name=None, args=(), kwargs=None, *, daemon=True, widget=None): logger.debug("Initializing %s: (group: %s, target: %s, name: %s, args: %s, kwargs: %s, " @@ -870,15 +1047,22 @@ def __init__(self, group=None, target=None, name=None, args=(), kwargs=None, *, super().__init__(group=group, target=target, name=name, args=args, kwargs=kwargs, daemon=daemon) self.err = None - self.widget = widget + self._widget = widget self._config = get_config() - self._config.set_cursor_busy(widget=self.widget) - self.complete = Event() + self._config.set_cursor_busy(widget=self._widget) + self._complete = Event() self._queue = Queue() logger.debug("Initialized %s", self.__class__.__name__,) + @property + def complete(self): + """ :class:`threading.Event`: Event is set if the thread has completed its task, + otherwise it is unset. + """ + return self._complete + def run(self): - """ Run the target in a thread """ + """ Commence the given task in a background thread. """ try: if self._target: retval = self._target(*self._args, **self._kwargs) @@ -888,24 +1072,32 @@ def run(self): logger.debug("Error in thread (%s): %s", self._name, self.err[1].with_traceback(self.err[2])) finally: - self.complete.set() - # Avoid a refcycle if the thread is running a function with + self._complete.set() + # Avoid a ref-cycle if the thread is running a function with # an argument that has a member that points to the thread. del self._target, self._args, self._kwargs def get_result(self): - """ Return the result from the queue """ - if not self.complete.is_set(): + """ Return the result from the given task. + + Returns + ------- + varies: + The result of the thread will depend on the given task. If a call is made to + :func:`get_result` prior to the thread completing its task then ``None`` will be + returned + """ + if not self._complete.is_set(): logger.warning("Aborting attempt to retrieve result from a LongRunningTask that is " "still running") return None if self.err: logger.debug("Error caught in thread") - self._config.set_cursor_default(widget=self.widget) + self._config.set_cursor_default(widget=self._widget) raise self.err[1].with_traceback(self.err[2]) logger.debug("Getting result from thread") retval = self._queue.get() logger.debug("Got result from thread") - self._config.set_cursor_default(widget=self.widget) + self._config.set_cursor_default(widget=self._widget) return retval diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index 36cebc782a..1eab26c0bc 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -24,18 +24,18 @@ class ProcessWrapper(): """ Builds command, launches and terminates the underlying faceswap process. Updates GUI display depending on state """ - def __init__(self, pathscript=None): - logger.debug("Initializing %s: (pathscript: %s)", self.__class__.__name__, pathscript) + def __init__(self): + logger.debug("Initializing %s", self.__class__.__name__) self.tk_vars = get_config().tk_vars self.set_callbacks() - self.pathscript = pathscript + self.pathscript = os.path.realpath(os.path.dirname(sys.argv[0])) self.command = None self.statusbar = get_config().statusbar self.task = FaceswapControl(self) logger.debug("Initialized %s", self.__class__.__name__) def set_callbacks(self): - """ Set the tk variable callbacks """ + """ Set the tkinter variable callbacks """ logger.debug("Setting tk variable traces") self.tk_vars["action"].trace("w", self.action_command) self.tk_vars["generate"].trace("w", self.generate_command) @@ -364,7 +364,7 @@ def terminate_in_thread(self, command, process): """ Terminate the subprocess """ logger.debug("Terminating wrapper") if command == "train": - timeout = self.config.tk_vars["traintimeout"].get() + timeout = self.config.user_config_dict.get("timeout", 120) logger.debug("Sending Exit Signal") print("Sending Exit Signal", flush=True) now = time() @@ -390,7 +390,7 @@ def terminate_in_thread(self, command, process): @staticmethod def generate_windows_keypress(character): - """ Generate an 'Enter' keypress to terminate Windows training """ + """ Generate an 'Enter' key press to terminate Windows training """ buf = win32console.PyINPUT_RECORDType( # pylint:disable=c-extension-no-member win32console.KEY_EVENT) # pylint:disable=c-extension-no-member buf.KeyDown = 1 diff --git a/scripts/gui.py b/scripts/gui.py index f2032d927c..df5f70de07 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -2,14 +2,14 @@ """ The optional GUI for faceswap """ import logging -import os import sys import tkinter as tk from tkinter import messagebox, ttk -from lib.gui import (CliOptions, CommandNotebook, ConsoleOut, Session, DisplayNotebook, - get_config, get_images, initialize_images, initialize_config, MainMenuBar, - ProcessWrapper, StatusBar) +from lib.gui import (TaskBar, CliOptions, CommandNotebook, ConsoleOut, Session, DisplayNotebook, + get_images, initialize_images, initialize_config, LastSession, + MainMenuBar, ProcessWrapper, StatusBar) +from lib.utils import set_system_verbosity logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -17,39 +17,40 @@ class FaceswapGui(tk.Tk): """ The Graphical User Interface """ - def __init__(self, pathscript): + def __init__(self, debug): logger.debug("Initializing %s", self.__class__.__name__) super().__init__() - self.initialize_globals(pathscript) + self._init_args = dict(debug=debug) + self._config = self.initialize_globals() self.set_fonts() self.set_styles() self.set_geometry() - self.wrapper = ProcessWrapper(pathscript) + self.wrapper = ProcessWrapper() self.objects = dict() get_images().delete_preview() self.protocol("WM_DELETE_WINDOW", self.close_app) + self.build_gui() + self._last_session = LastSession(self._config) logger.debug("Initialized %s", self.__class__.__name__) - def initialize_globals(self, pathscript): + def initialize_globals(self): """ Initialize config and images global constants """ cliopts = CliOptions() - scaling_factor = self.get_scaling() - pathcache = os.path.join(pathscript, "lib", "gui", ".cache") statusbar = StatusBar(self) session = Session() - initialize_config(self, cliopts, scaling_factor, pathcache, statusbar, session) + config = initialize_config(self, cliopts, statusbar, session) initialize_images() + return config - @staticmethod - def set_fonts(): + def set_fonts(self): """ Set global default font """ - tk.font.nametofont("TkFixedFont").configure(size=get_config().default_font[1]) + tk.font.nametofont("TkFixedFont").configure(size=self._config.default_font[1]) for font in ("TkDefaultFont", "TkHeadingFont", "TkMenuFont"): - tk.font.nametofont(font).configure(family=get_config().default_font[0], - size=get_config().default_font[1]) + tk.font.nametofont(font).configure(family=self._config.default_font[0], + size=self._config.default_font[1]) @staticmethod def set_styles(): @@ -57,17 +58,10 @@ def set_styles(): gui_style = ttk.Style() gui_style.configure('TLabelframe.Label', foreground="#0046D5", relief=tk.SOLID) - def get_scaling(self): - """ Get the display DPI """ - dpi = self.winfo_fpixels("1i") - scaling = dpi / 72.0 - logger.debug("dpi: %s, scaling: %s'", dpi, scaling) - return scaling - def set_geometry(self): """ Set GUI geometry """ - fullscreen = get_config().user_config_dict["fullscreen"] - scaling_factor = get_config().scaling_factor + fullscreen = self._config.user_config_dict["fullscreen"] + scaling_factor = self._config.scaling_factor if fullscreen: initial_dimensions = (self.winfo_screenwidth(), self.winfo_screenheight()) @@ -83,20 +77,29 @@ def set_geometry(self): str(initial_dimensions[1]))) logger.debug("Geometry: %sx%s", *initial_dimensions) - def build_gui(self, debug_console): + def build_gui(self, rebuild=False): """ Build the GUI """ logger.debug("Building GUI") - self.title("Faceswap.py") - self.tk.call('wm', 'iconphoto', self._w, get_images().icons["favicon"]) - self.configure(menu=MainMenuBar(self)) + if not rebuild: + self.tk.call('wm', 'iconphoto', self._w, get_images().icons["favicon"]) + self.configure(menu=MainMenuBar(self)) + if rebuild: + objects = list(self.objects.keys()) + for obj in objects: + self.objects[obj].destroy() + del self.objects[obj] + + self.objects["taskbar"] = TaskBar(self) self.add_containers() - self.objects["command"] = CommandNotebook(self.objects["containers"]["top"]) - self.objects["display"] = DisplayNotebook(self.objects["containers"]["top"]) - self.objects["console"] = ConsoleOut(self.objects["containers"]["bottom"], debug_console) + self.objects["command"] = CommandNotebook(self.objects["container_top"]) + self.objects["display"] = DisplayNotebook(self.objects["container_top"]) + self.objects["console"] = ConsoleOut(self.objects["container_bottom"], + self._init_args["debug"]) self.set_initial_focus() self.set_layout() + self._config.project.initialize_default_options() logger.debug("Built GUI") def add_containers(self): @@ -121,75 +124,93 @@ def add_containers(self): bottomcontainer = ttk.Frame(maincontainer, name="frame_bottom") maincontainer.add(bottomcontainer) - self.objects["containers"] = dict(main=maincontainer, - top=topcontainer, - bottom=bottomcontainer) + self.objects["container_main"] = maincontainer + self.objects["container_top"] = topcontainer + self.objects["container_bottom"] = bottomcontainer logger.debug("Added containers") - @staticmethod - def set_initial_focus(): + def set_initial_focus(self): """ Set the tab focus from settings """ - config = get_config() - tab = config.user_config_dict["tab"] + tab = self._config.user_config_dict["tab"] logger.debug("Setting focus for tab: %s", tab) - tabs = config.command_tabs - if tab in tabs: - config.command_notebook.select(tabs[tab]) - else: - tool_tabs = config.tools_command_tabs - if tab in tool_tabs: - config.command_notebook.select(tabs["tools"]) - config.command_notebook.tools_notebook.select(tool_tabs[tab]) + self._config.set_active_tab_by_name(tab) logger.debug("Focus set to: %s", tab) def set_layout(self): """ Set initial layout """ self.update_idletasks() - root = get_config().root - config = get_config().user_config_dict - r_width = root.winfo_width() - r_height = root.winfo_height() - w_ratio = config["options_panel_width"] / 100.0 - h_ratio = 1 - (config["console_panel_height"] / 100.0) + config_opts = self._config.user_config_dict + r_width = self.winfo_width() + r_height = self.winfo_height() + w_ratio = config_opts["options_panel_width"] / 100.0 + h_ratio = 1 - (config_opts["console_panel_height"] / 100.0) width = round(r_width * w_ratio) height = round(r_height * h_ratio) logger.debug("Setting Initial Layout: (root_width: %s, root_height: %s, width_ratio: %s, " "height_ratio: %s, width: %s, height: %s", r_width, r_height, w_ratio, h_ratio, width, height) - self.objects["containers"]["top"].sash_place(0, width, 1) - self.objects["containers"]["main"].sash_place(0, 1, height) + self.objects["container_top"].sash_place(0, width, 1) + self.objects["container_main"].sash_place(0, 1, height) self.update_idletasks() - def close_app(self): + def rebuild(self): + """ Rebuild the GUI on config change """ + logger.debug("Redrawing GUI") + session_state = self._last_session.to_dict() + self._config.refresh_config() + get_images().__init__() + self.set_fonts() + self.build_gui(rebuild=True) + if session_state is not None: + self._last_session.from_dict(session_state) + logger.debug("GUI Redrawn") + + def close_app(self, *args): # pylint: disable=unused-argument """ Close Python. This is here because the graph animation function continues to run even when tkinter has gone away """ logger.debug("Close Requested") - confirm = messagebox.askokcancel - confirmtxt = "Processes are still running. Are you sure...?" - tk_vars = get_config().tk_vars - if (tk_vars["runningtask"].get() - and not confirm("Close", confirmtxt)): - logger.debug("Close Cancelled") + + if not self._confirm_close_on_running_task(): + return + if not self._config.project.confirm_close(): return - if tk_vars["runningtask"].get(): + + if self._config.tk_vars["runningtask"].get(): self.wrapper.task.terminate() + + self._last_session.save() get_images().delete_preview() self.quit() logger.debug("Closed GUI") exit() + def _confirm_close_on_running_task(self): + """ Pop a confirmation box to close the GUI if a task is running + + Returns + ------- + bool: ``True`` if user confirms close, ``False`` if user cancels close + """ + if not self._config.tk_vars["runningtask"].get(): + logger.debug("No tasks currently running") + return True + + confirmtxt = "Processes are still running.\n\nAre you sure you want to exit?" + if not messagebox.askokcancel("Close", confirmtxt, default="cancel", icon="warning"): + logger.debug("Close Cancelled") + return True + logger.debug("Close confirmed") + return False + class Gui(): # pylint: disable=too-few-public-methods """ The GUI process. """ def __init__(self, arguments): - cmd = sys.argv[0] - pathscript = os.path.realpath(os.path.dirname(cmd)) - self.args = arguments - self.root = FaceswapGui(pathscript) + set_system_verbosity(arguments.loglevel) + self.root = FaceswapGui(arguments.debug) def process(self): """ Builds the GUI """ - self.root.build_gui(self.args.debug) self.root.mainloop() diff --git a/tools/cli.py b/tools/cli.py index d3e3eef8af..28cb2a92b4 100644 --- a/tools/cli.py +++ b/tools/cli.py @@ -207,7 +207,7 @@ def get_argument_list(self): "default": False, "backend": "nvidia", "help": "Sets allow_growth option of Tensorflow to spare memory " - "on some configurations."}) + "on some configurations."}) return argument_list @@ -462,6 +462,7 @@ def get_argument_list(self): "action": DirOrFileFullPaths, "type": str, "group": "data", + "filetypes": "video", "required": True, "help": "Directory containing extracted faces, source frames, or a video file."}) argument_list.append({ diff --git a/tools/preview.py b/tools/preview.py index 5c33d0f872..5fbe9f02b3 100644 --- a/tools/preview.py +++ b/tools/preview.py @@ -6,7 +6,7 @@ import tkinter as tk from tkinter import ttk import os -import sys + from configparser import ConfigParser from threading import Event, Lock @@ -16,7 +16,8 @@ from lib.aligner import Extract as AlignerExtract from lib.cli import ConvertArgs -from lib.gui.utils import get_images, initialize_images, ContextMenu +from lib.gui.custom_widgets import ContextMenu +from lib.gui.utils import get_images, initialize_images from lib.gui.tooltip import Tooltip from lib.gui.control_helper import set_slider_rounding from lib.convert import Converter @@ -71,9 +72,7 @@ def __init__(self, arguments): def initialize_tkinter(self): """ Initialize tkinter for standalone or GUI """ logger.debug("Initializing tkinter") - pathscript = os.path.realpath(os.path.dirname(sys.argv[0])) - pathcache = os.path.join(pathscript, "lib", "gui", ".cache") - initialize_images(pathcache=pathcache) + initialize_images() self.set_geometry() self.root.title("Faceswap.py - Convert Settings") self.root.tk.call( @@ -784,7 +783,7 @@ def start_busy_indicator(self): self.busy_indicator.start() def add_actions(self, parent): - """ Add Actio Buttons """ + """ Add Action Buttons """ logger.debug("Adding util buttons") frame = ttk.Frame(parent) frame.pack(padx=5, pady=(5, 10), side=tk.BOTTOM, fill=tk.X, anchor=tk.E) @@ -942,7 +941,7 @@ def add_frame_separator(self): return sep def add_actions(self, parent, config_key): - """ Add Actio Buttons """ + """ Add Action Buttons """ logger.debug("Adding util buttons") title = config_key.split(".")[1].replace("_", " ").title() @@ -968,7 +967,6 @@ def add_actions(self, parent, config_key): class ControlBuilder(): - # TODO Expand out for cli options """ Builds and returns a frame containing a tkinter control with label From 3a4966ce9390ebc62fd0eabc31a6f35d7b5e5df3 Mon Sep 17 00:00:00 2001 From: Bryan <3223233+bryanlyon@users.noreply.github.com> Date: Fri, 22 Nov 2019 12:12:00 -0800 Subject: [PATCH 144/981] Add support for .ts and .vob extensions for mpeg files --- lib/gui/utils.py | 2 +- lib/utils.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 0d32701b81..aa58a3908a 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -159,7 +159,7 @@ def _filetypes(self): ("Matroska", "*.mkv"), ("MOV", "*.mov"), ("MP4", "*.mp4"), - ("MPEG", "*.mpeg *.mpg"), + ("MPEG", "*.mpeg *.mpg *.ts *.vob"), ("WebM", "*.webm"), ("Windows Media Video", "*.wmv"), all_files]} diff --git a/lib/utils.py b/lib/utils.py index ee8c6ff2a1..b9af6cb404 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -20,7 +20,8 @@ _image_extensions = [ # pylint:disable=invalid-name ".bmp", ".jpeg", ".jpg", ".png", ".tif", ".tiff"] _video_extensions = [ # pylint:disable=invalid-name - ".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv"] + ".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv", + ".ts", ".vob"] class Backend(): From 3ce448ba90fc6a95ce38a7ce554cc57b69430ff6 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 22 Nov 2019 22:08:24 +0000 Subject: [PATCH 145/981] plugins.convert.ffmpef - Explicit stream selection on mux audio --- plugins/convert/writer/ffmpeg.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/convert/writer/ffmpeg.py b/plugins/convert/writer/ffmpeg.py index bb0f4ba290..3fb3ccf37d 100644 --- a/plugins/convert/writer/ffmpeg.py +++ b/plugins/convert/writer/ffmpeg.py @@ -158,7 +158,7 @@ def mux_audio(self): exe = im_ffm.get_ffmpeg_exe() inputs = OrderedDict([(self.video_tmp_file, None), (self.source_video, None)]) - outputs = {self.video_file: "-map 0:0 -map 1:1 -c: copy"} + outputs = {self.video_file: "-map 0:v:0 -map 1:a:0 -c: copy"} ffm = FFmpeg(executable=exe, global_options="-hide_banner -nostats -v 0 -y", inputs=inputs, From 21c55122b67189783b2e80fd3f5c4773dd3623f6 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 22 Nov 2019 23:28:01 +0000 Subject: [PATCH 146/981] lib.gui - Fix check for updates when launched from desktop shortcut (linux) --- lib/gui/menu.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 047001acdd..48c4daf87b 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -312,7 +312,8 @@ def check_for_updates(encoding, check=False): update = False msg = "" gitcmd = "git remote update && git status -uno" - cmd = Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT) + working_dir = os.path.dirname(os.path.realpath(sys.argv[0])) + cmd = Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, cwd=working_dir) stdout, _ = cmd.communicate() retcode = cmd.poll() if retcode != 0: @@ -344,7 +345,8 @@ def do_update(encoding): """ Update Faceswap """ logger.info("A new version is available. Updating...") gitcmd = "git pull" - cmd = Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, bufsize=1) + working_dir = os.path.dirname(os.path.realpath(sys.argv[0])) + cmd = Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, bufsize=1, cwd=working_dir) while True: output = cmd.stdout.readline().decode(encoding) if output == "" and cmd.poll() is not None: From dba5ccdd13dbaab5036f3333fa3997b120fb755b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 23 Nov 2019 14:07:55 +0000 Subject: [PATCH 147/981] bugfix: scripts.train - Check images are in input folders --- scripts/train.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/train.py b/scripts/train.py index bffc994b3e..a3b9a954ee 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -75,11 +75,11 @@ def get_images(self): logger.error("Error: '%s' does not exist", image_dir) exit(1) - if not os.listdir(image_dir): + images[side] = get_image_paths(image_dir) + if not images[side]: logger.error("Error: '%s' contains no images", image_dir) exit(1) - images[side] = get_image_paths(image_dir) logger.info("Model A Directory: %s", self.args.input_a) logger.info("Model B Directory: %s", self.args.input_b) logger.debug("Got image paths: %s", [(key, str(len(val)) + " images") From a6d77fc6450e13f00ded691c4bd4a452b29e6be9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 23 Nov 2019 15:23:45 +0000 Subject: [PATCH 148/981] bugfix: lib.gui - Remove last session file if user selects not to load --- lib/gui/project.py | 34 +++++++++++++++------------------- lib/gui/utils.py | 19 +++++++++++++++++++ scripts/gui.py | 2 +- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/lib/gui/project.py b/lib/gui/project.py index bc214f3f7e..e459e30025 100644 --- a/lib/gui/project.py +++ b/lib/gui/project.py @@ -29,7 +29,6 @@ def __init__(self, config, file_handler=None): self._serializer = get_serializer("json") self._config = config - self._default_opts = None self._options = None self._file_handler = file_handler self._filename = None @@ -64,6 +63,11 @@ def _cli_options(self): """ dict: the raw cli options from :attr:`_options` with project fields removed. """ return {key: val for key, val in self._options.items() if isinstance(val, dict)} + @property + def _default_options(self): + """ dict: The default options for all tabs """ + return self._config.default_options + @property def _dirname(self): """ str: The folder name that :attr:`_filename` resides in. Returns ``None`` if @@ -637,24 +641,14 @@ def _tasks(self): """ :class:`Tasks`: The current session's :class:``Tasks``. """ return self._config.tasks - def initialize_default_options(self): - """ Collect the default options. and store locally. - - The Default GUI options are stored on Faceswap startup. - - Exposed as the :attr:`_default_opts` for a project cannot be set until after the main - Command Tabs have been loaded. - """ - self._default_opts = self._current_gui_state() - self._set_default_options() - - def _set_default_options(self): + def set_default_options(self): """ Set the default options. The Default GUI options are stored on Faceswap startup. - Exposed as the :attr:`_default_opts` for a project cannot be set until after the main + Exposed as the :attr:`_default_options` for a project cannot be set until after the main Command Tabs have been loaded. """ - self._options = self._default_opts + logger.debug("Setting options to default") + self._options = self._default_options # MODIFIED STATE CALLBACK def set_modified_callback(self): @@ -728,7 +722,7 @@ def _handoff_legacy_task(self): logger.debug("Updating legacy task '%s", self._filename) filename = self._filename self._filename = None - self._set_default_options() + self.set_default_options() self._tasks.clear_tasks() self._tasks.load(filename=filename, current_tab=False) logger.debug("Updated legacy task and reset project") @@ -814,7 +808,7 @@ def new(self, *args): # pylint:disable=unused-argument self._filename = cfgfile.name cfgfile.close() - self._set_default_options() + self.set_default_options() self._config.cli_opts.reset() self._save() self._update_root_title() @@ -833,7 +827,7 @@ def close(self, *args): # pylint:disable=unused-argument return self._config.cli_opts.reset() self._filename = None - self._set_default_options() + self.set_default_options() self._reset_modified_var() self._update_root_title() self._config.set_active_tab_by_name(self._config.user_config_dict["tab"]) @@ -919,7 +913,7 @@ def to_dict(self): """ opts = self._current_gui_state() logger.debug("Collected opts: %s", opts) - if not opts or opts == self._default_opts: + if not opts or opts == self._default_options: logger.debug("Default session, or no opts found. Not saving last session.") return None opts["tab_name"] = self._active_tab @@ -937,6 +931,8 @@ def ask_load(self): self.load() else: logger.debug("Not loading last session at user request") + logger.debug("Deleting LastSession file") + os.remove(self._filename) def load(self): """ Load the last session. diff --git a/lib/gui/utils.py b/lib/gui/utils.py index aa58a3908a..abfacd7592 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -769,6 +769,7 @@ def __init__(self, root, cli_opts, statusbar, session): tk_vars=self._set_tk_vars(), project=Project(self, FileHandler), tasks=Tasks(self, FileHandler), + default_options=None, status_bar=statusbar, command_notebook=None) # set in command.py self._user_config = UserConfig(None) @@ -813,6 +814,11 @@ def tasks(self): """ :class:`lib.gui.project.Tasks`: The session tasks handler. """ return self._gui_objects["tasks"] + @property + def default_options(self): + """ dict: The default options for all tabs """ + return self._gui_objects["default_options"] + @property def statusbar(self): """ :class:`lib.gui.custom_widgets.StatusBar`: The GUI StatusBar @@ -878,6 +884,19 @@ def _get_scaling(root): logger.debug("dpi: %s, scaling: %s'", dpi, scaling) return scaling + def set_default_options(self): + """ Set the default options for :mod:`lib.gui.projects` + + The Default GUI options are stored on Faceswap startup. + + Exposed as the :attr:`_default_opts` for a project cannot be set until after the main + Command Tabs have been loaded. + """ + default = self.cli_opts.get_option_values() + logger.debug(default) + self._gui_objects["default_options"] = default + self.project.set_default_options() + def set_command_notebook(self, notebook): """ Set the command notebook to the :attr:`command_notebook` attribute and enable the modified callback for :attr:`project`. diff --git a/scripts/gui.py b/scripts/gui.py index df5f70de07..7bb28acc8e 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -99,7 +99,7 @@ def build_gui(self, rebuild=False): self._init_args["debug"]) self.set_initial_focus() self.set_layout() - self._config.project.initialize_default_options() + self._config.set_default_options() logger.debug("Built GUI") def add_containers(self): From cf76ddaf7590717273c4fbc59a9814fa2f8f4c9a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 23 Nov 2019 15:50:30 +0000 Subject: [PATCH 149/981] bugfix: lib.gui - Remove non-existant files from recent files list --- lib/gui/menu.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 48c4daf87b..a76710c7c9 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -179,8 +179,13 @@ def build_recent_menu(self): self.clear_recent_files(serializer, menu_file) recent_files = serializer.load(menu_file) logger.debug("Loaded recent files: %s", recent_files) + removed_files = [] for recent_item in recent_files: filename, command = recent_item + if not os.path.isfile(filename): + logger.debug("File does not exist. Flagging for removal: '%s'", filename) + removed_files.append(recent_item) + continue # Legacy project files didn't have a command stored command = command if command else "project" logger.debug("processing: ('%s', %s)", filename, command) @@ -195,6 +200,11 @@ def build_recent_menu(self): self.recent_menu.add_command( label="{} ({})".format(filename, lbl.title()), command=lambda kw=kwargs, fn=load_func: fn(**kw)) + if removed_files: + for recent_item in removed_files: + logger.debug("Removing from recent files: `%s`", recent_item[0]) + recent_files.remove(recent_item) + serializer.save(menu_file, recent_files) self.recent_menu.add_separator() self.recent_menu.add_command( label="Clear recent files", From a817b94503aa690ac0c34521a10d0d7a982ea612 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 23 Nov 2019 16:24:08 +0000 Subject: [PATCH 150/981] bugfix: lib.gui - Correctly handle tasks loaded from the file/recent menus --- lib/gui/project.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/gui/project.py b/lib/gui/project.py index e459e30025..d274a89031 100644 --- a/lib/gui/project.py +++ b/lib/gui/project.py @@ -79,6 +79,13 @@ def _basename(self): """ str: The base name of :attr:`_filename`. Returns ``None`` if filename is ``None``. """ return os.path.basename(self._filename) if self._filename is not None else None + @property + def _stored_tab_name(self): + """str: The tab_name stored in :attr:`_options` or ``None`` if it does not exist """ + if self._options is None: + return None + return self._options.get("tab_name", None) + def _current_gui_state(self, command=None): """ The current state of the GUI. @@ -435,6 +442,7 @@ def load(self, *args, # pylint:disable=unused-argument filename is not None and sess_type == "task" and os.path.splitext(filename)[1] == ".fsw") if is_legacy: + logger.debug("Legacy task found: '%s'", filename) filename = self._update_legacy_task(filename) filename_set = self._set_filename(filename, sess_type=sess_type) @@ -444,7 +452,8 @@ def load(self, *args, # pylint:disable=unused-argument if not loaded: return - command = self._active_tab if current_tab else self._get_lone_task() + command = self._active_tab if current_tab else self._stored_tab_name + command = self._get_lone_task() if command is None else command if command is None: logger.error("Unable to determine task from the given file: '%s'", filename) return @@ -453,12 +462,13 @@ def load(self, *args, # pylint:disable=unused-argument return self._set_options(command) + self._add_to_recent(command) + if self._is_project: self._filename = self._project_filename elif self._filename.endswith(".fsw"): self._filename = None - self._add_to_recent(command) self._add_task(command) if is_legacy: self.save() From b55e499b0b54b9b2da627466c49dcd81bde72a94 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 23 Nov 2019 19:15:06 +0000 Subject: [PATCH 151/981] bugfix: lib.gui + tools.preview - Update icon names --- lib/gui/display_command.py | 2 +- tools/preview.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/gui/display_command.py b/lib/gui/display_command.py index 4626dac46f..b4a7477a08 100644 --- a/lib/gui/display_command.py +++ b/lib/gui/display_command.py @@ -188,7 +188,7 @@ def add_option_refresh(self): logger.debug("Adding refresh option") tk_var = get_config().tk_vars["refreshgraph"] btnrefresh = ttk.Button(self.optsframe, - image=get_images().icons["reset"], + image=get_images().icons["reload"], command=lambda: tk_var.set(True)) btnrefresh.pack(padx=2, side=tk.RIGHT) Tooltip(btnrefresh, diff --git a/tools/preview.py b/tools/preview.py index 5fbe9f02b3..bd1f3cfc7e 100644 --- a/tools/preview.py +++ b/tools/preview.py @@ -788,7 +788,7 @@ def add_actions(self, parent): frame = ttk.Frame(parent) frame.pack(padx=5, pady=(5, 10), side=tk.BOTTOM, fill=tk.X, anchor=tk.E) - for utl in ("save", "clear", "reset"): + for utl in ("save", "clear", "refresh"): logger.debug("Adding button: '%s'", utl) img = get_images().icons[utl] if utl == "save": @@ -797,7 +797,7 @@ def add_actions(self, parent): elif utl == "clear": text = "Reset full config to default values" action = self.config_tools.reset_config_default - elif utl == "reset": + elif utl == "refresh": text = "Reset full config to saved values" action = self.config_tools.reset_config_saved @@ -945,7 +945,7 @@ def add_actions(self, parent, config_key): logger.debug("Adding util buttons") title = config_key.split(".")[1].replace("_", " ").title() - for utl in ("save", "clear", "reset"): + for utl in ("save", "clear", "reload"): logger.debug("Adding button: '%s'", utl) img = get_images().icons[utl] if utl == "save": @@ -954,7 +954,7 @@ def add_actions(self, parent, config_key): elif utl == "clear": text = "Reset {} config to default values".format(title) action = parent.config_tools.reset_config_default - elif utl == "reset": + elif utl == "reload": text = "Reset {} config to saved values".format(title) action = parent.config_tools.reset_config_saved From 83c28e7e9e26c0e8ce9a46c8b7759b21fa750ede Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 23 Nov 2019 19:17:51 +0000 Subject: [PATCH 152/981] bugfix tools.preview - Update icon names --- tools/preview.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/preview.py b/tools/preview.py index bd1f3cfc7e..dcecce2d17 100644 --- a/tools/preview.py +++ b/tools/preview.py @@ -788,7 +788,7 @@ def add_actions(self, parent): frame = ttk.Frame(parent) frame.pack(padx=5, pady=(5, 10), side=tk.BOTTOM, fill=tk.X, anchor=tk.E) - for utl in ("save", "clear", "refresh"): + for utl in ("save", "clear", "reload"): logger.debug("Adding button: '%s'", utl) img = get_images().icons[utl] if utl == "save": @@ -797,7 +797,7 @@ def add_actions(self, parent): elif utl == "clear": text = "Reset full config to default values" action = self.config_tools.reset_config_default - elif utl == "refresh": + elif utl == "reload": text = "Reset full config to saved values" action = self.config_tools.reset_config_saved From e481053bd0323748c9d73a53e5b7fb02cffd4dd8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 24 Nov 2019 00:21:44 +0000 Subject: [PATCH 153/981] bugfix: tools.preview - Load tooltip from custom_widgets --- tools/preview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/preview.py b/tools/preview.py index dcecce2d17..1becd7a025 100644 --- a/tools/preview.py +++ b/tools/preview.py @@ -18,7 +18,7 @@ from lib.cli import ConvertArgs from lib.gui.custom_widgets import ContextMenu from lib.gui.utils import get_images, initialize_images -from lib.gui.tooltip import Tooltip +from lib.gui.custom_widgets import Tooltip from lib.gui.control_helper import set_slider_rounding from lib.convert import Converter from lib.faces_detect import DetectedFace From 0bcd0c49b63e3c70b2474126e3f5c574f2d8842f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 24 Nov 2019 01:28:01 +0000 Subject: [PATCH 154/981] sort by face - Automatically switch to vector_linkage if not enough free RAM --- lib/vgg_face2_keras.py | 72 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/lib/vgg_face2_keras.py b/lib/vgg_face2_keras.py index 0f65e3e641..b071454043 100644 --- a/lib/vgg_face2_keras.py +++ b/lib/vgg_face2_keras.py @@ -10,11 +10,12 @@ import logging import sys import os +import psutil import cv2 import numpy as np -from fastcluster import linkage -from lib.utils import GetModel, set_system_verbosity +from fastcluster import linkage, linkage_vector +from lib.utils import GetModel, set_system_verbosity, FaceswapError logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -38,7 +39,8 @@ def __init__(self, backend="GPU", loglevel="INFO"): logger.debug("Initialized %s", self.__class__.__name__) # <<< GET MODEL >>> # - def get_model(self, git_model_id, model_filename, backend): + @staticmethod + def get_model(git_model_id, model_filename, backend): """ Check if model is available, if not, download and unzip it """ root_path = os.path.abspath(os.path.dirname(sys.argv[0])) cache_path = os.path.join(root_path, "plugins", "extract", "recognition", ".cache") @@ -95,14 +97,74 @@ def sorted_similarity(self, predictions, method="ward"): the order implied by the hierarchical tree (dendrogram) """ logger.info("Sorting face distances. Depending on your dataset this may take some time...") - num_predictions = predictions.shape[0] - result_linkage = linkage(predictions, method=method, preserve_input=False) + num_predictions, dims = predictions.shape + + clustering_method = self._get_clustering_method(num_predictions, dims) + + kwargs = dict(method=method) + if clustering_method == "linkage": + kwargs["preserve_input"] = False + func = linkage + else: + func = linkage_vector + + result_linkage = func(predictions, **kwargs) + print(result_linkage.shape) + exit(0) result_order = self.seriation(result_linkage, num_predictions, num_predictions + num_predictions - 2) return result_order + @staticmethod + def _get_clustering_method(item_count, dims): + """ Calculate the RAM that will be required to sort these images and select the appropriate + clustering method. + + From fastcluster documentation: + "While the linkage method requires Θ(N:sup:`2`) memory for clustering of N points, this + [vector] method needs Θ(N D)for N points in RD, which is usually much smaller." + also: + "half the memory can be saved by specifying :attr:`preserve_input`=``False``" + + To avoid undercalculating we divide the memory calculation by 1.7 instead of 2 + + Parameters + ---------- + item_count: int + The number of images that are to be processed + dims: int + The number of dimensions in the vgg_face output + + Returns + ------- + str: 'linkage' or 'vector' + """ + np_float = 24 # bytes size of a numpy float + divider = 1024 * 1024 # bytes to MB + + free_ram = psutil.virtual_memory().free / divider + linkage_required = (((item_count ** 2) * np_float) / 1.7) / divider + vector_required = ((item_count * dims) * np_float) / divider + logger.debug("free_ram: %sMB, linkage_required: %sMB, vector_required: %sMB", + int(free_ram), int(linkage_required), int(vector_required)) + + if linkage_required < free_ram: + logger.verbose("Using linkage method") + retval = "linkage" + elif vector_required < free_ram: + logger.warning("Not enough RAM to perform linkage clustering. Using vector " + "clustering. This will be significantly slower. Free RAM: %sMB. " + "Required for linkage method: %sMB", + int(free_ram), int(linkage_required)) + retval = "vector" + else: + raise FaceswapError("Not enough RAM available to sort faces. Try reducing " + "the size of your dataset. Free RAM: {}MB. " + "Required RAM: {}MB".format(int(free_ram), int(vector_required))) + return retval + def seriation(self, tree, points, current_index): """ Seriation method for sorted similarity input: From 4591a02ee113339c515050d97134721c63ad2afb Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 24 Nov 2019 13:54:33 +0000 Subject: [PATCH 155/981] bugfix: tools.preview - Fix for new icons bugfix: lib.vgg_face2_keras - Remove debug code documentation: lib.vgg_face2_keras --- docs/full/lib.rst | 1 + docs/full/lib.vgg_face2_keras.rst | 7 ++ lib/vgg_face2_keras.py | 185 +++++++++++++++++++++--------- tools/preview.py | 3 +- 4 files changed, 142 insertions(+), 54 deletions(-) create mode 100644 docs/full/lib.vgg_face2_keras.rst diff --git a/docs/full/lib.rst b/docs/full/lib.rst index d1aebceecd..ce6032124e 100644 --- a/docs/full/lib.rst +++ b/docs/full/lib.rst @@ -12,6 +12,7 @@ Subpackages lib.model lib.serializer lib.training_data + lib.vgg_face2_keras Module contents --------------- diff --git a/docs/full/lib.vgg_face2_keras.rst b/docs/full/lib.vgg_face2_keras.rst new file mode 100644 index 0000000000..eeb608820c --- /dev/null +++ b/docs/full/lib.vgg_face2_keras.rst @@ -0,0 +1,7 @@ +lib.vgg\_face2\_keras module +============================ + +.. automodule:: lib.vgg_face2_keras + :members: + :undoc-members: + :show-inheritance: diff --git a/lib/vgg_face2_keras.py b/lib/vgg_face2_keras.py index b071454043..5dfbeaeac9 100644 --- a/lib/vgg_face2_keras.py +++ b/lib/vgg_face2_keras.py @@ -1,11 +1,5 @@ #!/usr/bin python3 -""" VGG_Face2 inference -Model exported from: https://github.com/WeidiXie/Keras-VGGFace2-ResNet50 -which is based on: https://www.robots.ox.ac.uk/~vgg/software/vgg_face/ - -Licensed under Creative Commons Attribution License. -https://creativecommons.org/licenses/by-nc/4.0/ -""" +""" VGG_Face2 inference and sorting """ import logging import sys @@ -22,7 +16,27 @@ class VGGFace2(): """ VGG Face feature extraction. - Input images should be in BGR Order """ + + Extracts feature vectors from faces in order to compare similarity. + + Parameters + ---------- + backend: ['GPU', 'CPU'] + Whether to run inference on a GPU or on the CPU + loglevel: ['INFO', 'VERBODE', 'DEBUG', 'TRACE'] + The system log level + + Notes + ----- + Input images should be in BGR Order + + Model exported from: https://github.com/WeidiXie/Keras-VGGFace2-ResNet50 which is based on: + https://www.robots.ox.ac.uk/~vgg/software/vgg_face/ + + + Licensed under Creative Commons Attribution License. + https://creativecommons.org/licenses/by-nc/4.0/ + """ def __init__(self, backend="GPU", loglevel="INFO"): logger.debug("Initializing %s: (backend: %s, loglevel: %s)", @@ -35,13 +49,29 @@ def __init__(self, backend="GPU", loglevel="INFO"): # Average image provided in https://github.com/ox-vgg/vgg_face2 self.average_img = np.array([91.4953, 103.8827, 131.0912]) - self.model = self.get_model(git_model_id, model_filename, backend) + self.model = self._get_model(git_model_id, model_filename, backend) logger.debug("Initialized %s", self.__class__.__name__) # <<< GET MODEL >>> # @staticmethod - def get_model(git_model_id, model_filename, backend): - """ Check if model is available, if not, download and unzip it """ + def _get_model(git_model_id, model_filename, backend): + """ Check if model is available, if not, download and unzip it + + Parameters + ---------- + git_model_id: int + The second digit in the github tag that identifies this model. See + https://github.com/deepfakes-models/faceswap-models for more information + model_filename: str + The name of the model to be loaded (see :class:`lib.utils.GetModel` for more + information) + backend: ['GPU', 'CPU'] + Whether to run inference on a GPU or on the CPU + + See Also + -------- + lib.utils.GetModel: The model downloading and allocation class. + """ root_path = os.path.abspath(os.path.dirname(sys.argv[0])) cache_path = os.path.join(root_path, "plugins", "extract", "recognition", ".cache") model = GetModel(model_filename, cache_path, git_model_id).model_path @@ -62,15 +92,37 @@ def get_model(git_model_id, model_filename, backend): }) def predict(self, face): - """ Return encodings for given image from vgg_face """ + """ Return encodings for given image from vgg_face2. + + Parameters + ---------- + face: numpy.ndarray + The face to be fed through the predictor. Should be in BGR channel order + + Returns + ------- + numpy.ndarray + The encodings for the face + """ if face.shape[0] != self.input_size: - face = self.resize_face(face) + face = self._resize_face(face) face = face[None, :, :, :3] - self.average_img preds = self.model.predict(face) return preds[0, :] - def resize_face(self, face): - """ Resize incoming face to model_input_size """ + def _resize_face(self, face): + """ Resize incoming face to model_input_size. + + Parameters + ---------- + face: numpy.ndarray + The face to be fed through the predictor. Should be in BGR channel order + + Returns + ------- + numpy.ndarray + The face resized to model input size + """ sizes = (self.input_size, self.input_size) interpolation = cv2.INTER_CUBIC if face.shape[0] < self.input_size else cv2.INTER_AREA face = cv2.resize(face, dsize=sizes, interpolation=interpolation) @@ -78,47 +130,64 @@ def resize_face(self, face): @staticmethod def find_cosine_similiarity(source_face, test_face): - """ Find the cosine similarity between a source face and a test face """ + """ Find the cosine similarity between two faces. + + Parameters + ---------- + source_face: numpy.ndarray + The first face to test against :attr:`test_face` + test_face: numpy.ndarray + The second face to test against :attr:`source_face` + + Returns + ------- + float: + The cosine similarity between the two faces + """ var_a = np.matmul(np.transpose(source_face), test_face) var_b = np.sum(np.multiply(source_face, source_face)) var_c = np.sum(np.multiply(test_face, test_face)) return 1 - (var_a / (np.sqrt(var_b) * np.sqrt(var_c))) def sorted_similarity(self, predictions, method="ward"): - """ Sort a matrix of predictions by similarity Adapted from: - https://gmarti.gitlab.io/ml/2017/09/07/how-to-sort-distance-matrix.html - input: - - predictions is a stacked matrix of vgg_face predictions shape: (x, 4096) - - method = ["ward","single","average","complete"] - output: - - result_order is a list of indices with the order implied by the hierarhical tree - - sorted_similarity transforms a distance matrix into a sorted distance matrix according to - the order implied by the hierarchical tree (dendrogram) + """ Sort a matrix of predictions by similarity. + + Transforms a distance matrix into a sorted distance matrix according to the order implied + by the hierarchical tree (dendrogram). + + Parameters + ---------- + predictions: numpy.ndarray + A stacked matrix of vgg_face2 predictions of the shape (`N`, `D`) where `N` is the + number of observations and `D` are the number of dimensions. NB: The given + :attr:`predictions` will be overwritten to save memory. If you still require the + original values you should take a copy prior to running this method + method: ['single','centroid','median','ward'] + The clustering method to use. + + Returns + ------- + list: + List of indices with the order implied by the hierarchical tree """ logger.info("Sorting face distances. Depending on your dataset this may take some time...") num_predictions, dims = predictions.shape - clustering_method = self._get_clustering_method(num_predictions, dims) - kwargs = dict(method=method) - if clustering_method == "linkage": + if self._use_vector_linkage(num_predictions, dims): + func = linkage_vector + else: kwargs["preserve_input"] = False func = linkage - else: - func = linkage_vector result_linkage = func(predictions, **kwargs) - print(result_linkage.shape) - exit(0) - result_order = self.seriation(result_linkage, - num_predictions, - num_predictions + num_predictions - 2) - + result_order = self._seriation(result_linkage, + num_predictions, + num_predictions + num_predictions - 2) return result_order @staticmethod - def _get_clustering_method(item_count, dims): + def _use_vector_linkage(item_count, dims): """ Calculate the RAM that will be required to sort these images and select the appropriate clustering method. @@ -128,7 +197,7 @@ def _get_clustering_method(item_count, dims): also: "half the memory can be saved by specifying :attr:`preserve_input`=``False``" - To avoid undercalculating we divide the memory calculation by 1.7 instead of 2 + To avoid under calculating we divide the memory calculation by 1.8 instead of 2 Parameters ---------- @@ -139,45 +208,55 @@ def _get_clustering_method(item_count, dims): Returns ------- - str: 'linkage' or 'vector' + bool: + ``True`` if vector_linkage should be used. ``False`` if linkage should be used """ np_float = 24 # bytes size of a numpy float divider = 1024 * 1024 # bytes to MB free_ram = psutil.virtual_memory().free / divider - linkage_required = (((item_count ** 2) * np_float) / 1.7) / divider + linkage_required = (((item_count ** 2) * np_float) / 1.8) / divider vector_required = ((item_count * dims) * np_float) / divider logger.debug("free_ram: %sMB, linkage_required: %sMB, vector_required: %sMB", int(free_ram), int(linkage_required), int(vector_required)) if linkage_required < free_ram: logger.verbose("Using linkage method") - retval = "linkage" + retval = False elif vector_required < free_ram: logger.warning("Not enough RAM to perform linkage clustering. Using vector " "clustering. This will be significantly slower. Free RAM: %sMB. " "Required for linkage method: %sMB", int(free_ram), int(linkage_required)) - retval = "vector" + retval = True else: raise FaceswapError("Not enough RAM available to sort faces. Try reducing " "the size of your dataset. Free RAM: {}MB. " "Required RAM: {}MB".format(int(free_ram), int(vector_required))) + logger.debug(retval) return retval - def seriation(self, tree, points, current_index): - """ Seriation method for sorted similarity - input: - - tree is a hierarchical tree (dendrogram) - - points is the number of points given to the clustering process - - current_index is the position in the tree for the recursive traversal - output: - - order implied by the hierarchical tree + def _seriation(self, tree, points, current_index): + """ Seriation method for sorted similarity. - seriation computes the order implied by a hierarchical tree (dendrogram) + Seriation computes the order implied by a hierarchical tree (dendrogram). + + Parameters + ---------- + tree: numpy.ndarray + A hierarchical tree (dendrogram) + points: int + The number of points given to the clustering process + current_index: int + The position in the tree for the recursive traversal + + Returns + ------- + list: + The indices in the order implied by the hierarchical tree """ if current_index < points: return [current_index] left = int(tree[current_index-points, 0]) right = int(tree[current_index-points, 1]) - return self.seriation(tree, points, left) + self.seriation(tree, points, right) + return self._seriation(tree, points, left) + self._seriation(tree, points, right) diff --git a/tools/preview.py b/tools/preview.py index 1becd7a025..5c05eb9417 100644 --- a/tools/preview.py +++ b/tools/preview.py @@ -17,7 +17,7 @@ from lib.aligner import Extract as AlignerExtract from lib.cli import ConvertArgs from lib.gui.custom_widgets import ContextMenu -from lib.gui.utils import get_images, initialize_images +from lib.gui.utils import get_images, initialize_config, initialize_images from lib.gui.custom_widgets import Tooltip from lib.gui.control_helper import set_slider_rounding from lib.convert import Converter @@ -72,6 +72,7 @@ def __init__(self, arguments): def initialize_tkinter(self): """ Initialize tkinter for standalone or GUI """ logger.debug("Initializing tkinter") + initialize_config(self.root, None, None, None) initialize_images() self.set_geometry() self.root.title("Faceswap.py - Convert Settings") From 18660da1c92994615e88a032242c552295dc96e3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 25 Nov 2019 02:02:02 +0000 Subject: [PATCH 156/981] lib.gui - Resize icons and add attribution --- lib/gui/.cache/icons/LICENSE.md | 3 +++ lib/gui/.cache/icons/clear.png | Bin 13728 -> 7479 bytes lib/gui/.cache/icons/clear2.png | Bin 15475 -> 7553 bytes lib/gui/.cache/icons/context.png | Bin 8281 -> 4676 bytes lib/gui/.cache/icons/folder.png | Bin 7275 -> 4148 bytes lib/gui/.cache/icons/generate.png | Bin 5766 -> 3923 bytes lib/gui/.cache/icons/graph.png | Bin 11873 -> 6580 bytes lib/gui/.cache/icons/load.png | Bin 4695 -> 3571 bytes lib/gui/.cache/icons/load2.png | Bin 4807 -> 3946 bytes lib/gui/.cache/icons/model.png | Bin 14239 -> 5428 bytes lib/gui/.cache/icons/move.png | Bin 19787 -> 6529 bytes lib/gui/.cache/icons/multi_load.png | Bin 4885 -> 3944 bytes lib/gui/.cache/icons/new.png | Bin 5486 -> 4305 bytes lib/gui/.cache/icons/picture.png | Bin 10013 -> 5287 bytes lib/gui/.cache/icons/reload.png | Bin 14128 -> 8036 bytes lib/gui/.cache/icons/reload2.png | Bin 14970 -> 8008 bytes lib/gui/.cache/icons/save.png | Bin 5388 -> 4072 bytes lib/gui/.cache/icons/save2.png | Bin 5378 -> 4075 bytes lib/gui/.cache/icons/save_as.png | Bin 8843 -> 5155 bytes lib/gui/.cache/icons/save_as2.png | Bin 8766 -> 5156 bytes lib/gui/.cache/icons/settings.png | Bin 34859 -> 7995 bytes lib/gui/.cache/icons/settings_convert.png | Bin 15315 -> 8369 bytes lib/gui/.cache/icons/settings_extract.png | Bin 11380 -> 7304 bytes lib/gui/.cache/icons/settings_train.png | Bin 26420 -> 6381 bytes lib/gui/.cache/icons/start.png | Bin 9063 -> 5735 bytes lib/gui/.cache/icons/stop.png | Bin 8003 -> 4964 bytes lib/gui/.cache/icons/video.png | Bin 4694 -> 3945 bytes lib/gui/.cache/icons/zoom.png | Bin 18426 -> 5826 bytes 28 files changed, 3 insertions(+) create mode 100644 lib/gui/.cache/icons/LICENSE.md diff --git a/lib/gui/.cache/icons/LICENSE.md b/lib/gui/.cache/icons/LICENSE.md new file mode 100644 index 0000000000..31157e1ea9 --- /dev/null +++ b/lib/gui/.cache/icons/LICENSE.md @@ -0,0 +1,3 @@ +Icons made by [smashicons](https://www.flaticon.com/authors/smashicons) from [www.flaticon.com](www.flaticon.com) + +Colorized and adapted by @torzdf \ No newline at end of file diff --git a/lib/gui/.cache/icons/clear.png b/lib/gui/.cache/icons/clear.png index 78060cbe71e4456e391df38a342f8a5336db7d6c..551de5259faf8593359de2dc6ff5b9c6b255f691 100755 GIT binary patch delta 4808 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 diff --git a/lib/gui/.cache/icons/clear2.png b/lib/gui/.cache/icons/clear2.png index 1ace1a5193af070e2420df32ebadde5b96897179..f7e5826ca8f96c0b1da027cc053dd0f0264560db 100644 GIT binary patch delta 4882 zcmV+t6YcEtc!4`1iBL{Q4GJ0x0000DNk~Le0000$0000$2nGNE0IF$m-mxK`3V#x; zNkl+$A|QxBcqAbqF|V7On|tp$_w4!O+?xmF zUJ{75oz7nCtd)~{&OZD5-M{_YzkkkSSfIEQoz$rjpd?`XmVdzrmvCGh6|Sifn7fY^=p^cxdf!L)e3evW) zLjj@!5jyuFR&#y)@c^(H_|er8aWw?=1J(fd1L-K>&KZt(^i=f0BN46?q-`Uk z7D)RFM<-BJ4Mhm-Z~*iCaesox{y?beWL$chfFA=-0X-VO@bXo+7dN&AnwA6=;@hQ^;OuBP&Tj}2m^0@Qhx-vfM3T5P`yPH zNT0p}ExX7ObKCm#_8B`0fRoFe4t|z!d&ro+9M8y`u+0eA zHh=aeZKSj@1{b0C&wnOdbsT9~g)tJgU5*5`2V!3g%oJ`9nX^}6iHG4Mo*a zgciHhzE^%-_evDa==43!(c{wDZS(=TxCZ4BI{N|AvW5YZfi20F!{rEg8n`14UdtYV zjD(!A=mvJn1+2PqY<~^5zZyH(jOgb>QT0A2LQyr4QLNfi*ndqG*!~Md{TEOa5MH07 zAfpzlZ%}6v3W2=XB=&k6d;oY{5rT|aD{vKDj~#YQa$&d$9^FUFf&ak{hq{VP&TxFU zEkk%xo$atMz-u~oTMNy5HW98m+2uFka+5k`9^NsZ!H!LiaCr%zd5^|dHz1v5`XI2j zHxh6Yun|ywQ-9~-8F@>)*eiyI(1}0N{KkvOs6|e09{EEG$jZv1zW!q@e?5_!a@+-D z5r*fg^@O78*x>+;rN1Fka|WTRj2<(VfrGL!3>W^!dcx<9p`~S@4;%_n3mG-h2jn4i zm2l-p0Mme7K;^~#iv%9rF9F?KI3CZqY3;MDs5)l#hky9@Zv|l5j9I*Q@E~Oe4^mcE z#>VHKN7Ho7x(XV1K8+n}ahAhXfR|<)O}n4PtgApX44&Kk0%c`ol$9Oiy@T&Deda6x z{QI|JR)5(3Jv$u2Gj1B*!tnrl43oDf67X$ciJFo|`pi|J8tv;@(KK4#`YmQdE!Rz) z#E$L741XNZA0ZS}As9byEF(sZVta8hR-lPUbvd5l6T0-#JMcjocda8*eHvBO*tlsU zOBOFi6#^j?`gy%9STLW`(p^+nRU*SJxQ9<}w-ZnljO?L=PM0Ag;ekL1cq5qv?z?XdSp)iCGVmpLEq`XircJ1thFMpE|BdHSqy<9jg?XkS ztu_bz`EpcEWBrEpEV=8hOCHV4$Y9NVYn&Z190X}~sJ5tu=+~dLNps?lKNQ25kbp;k zR9DV0T*Idz%}_$Msq2YFd&ymk`N@Ai1wdQ*2Q=^9gn|X42WE#*QKW_c&F6`nFGtff z)_<>G&)rLxCVWNLwS+2Nnju`nr{KyN7Po00NkqU3c!09^4?AF`aw!W^ zg3ugHiUP1ShV>F1@Gx+jYw%F|O`PL6Hh-6_OM_^jj!5+x_V3?ER#rAQOqrZ;U^m}9 zm6X(UUM<~;)liEaY{p$MzGGlrzzYY1F$Rym)Gx7Z)U*MZH7AI+H0d#?X;)_g`T;)&(tS71L(RzP-no$yBYP;ezkdd+ zv6j-(os1YYnsH;tBqbq=;Ela|Fze4_w>IJ)GOokJYX>jzzqy6r@pl0D{ttf0!{2@= zDflOU`ZMld^GyeQ;W$!nyd7-Wtv9NuI;yVGR(=>D7x)!m#tE1Q+^eOglXBw%kV*oV zR)WxR<&P#(bpk8U$oAsm%aSntwtrh`Z42|({(YGB=aG?C+y&Pn?Wp7D3xR*%7J|nQ zI2PDL-{A+}e>5rh&0AjNYhS%527WBQTbF~PCEjhML`_X6Tz(Kc5=sXS03XH)_%3j* zr)VOsq1QvyOzM0nstb3)SR&P@umVkNFD_={q{)mNQIznt*|TO6XbbW7{(t?LbrlFz zV&slSs5*f^71R2`+wI^V|Bru93jT$cwz6W`-PqA6#x+C9n7JB>*0KF1h$kqnenkDX zn01u^7O+i?iT`7OFLm-9)YPoYbmWjA3=i&taYU+5U^ zbL8+L!d0hGb%}Z51TF8r27kb}*4)n%YadJc>0Phw;Y*7b6A6dVv+~H8xe7)1^e7&o zC@PA8wvz_|(t%&8z;(a_sy9VRne@2?+P}M?gpDw~xbsI6srrz%rUteZ7hjfyg9OPBM@U;MWd98J)6zqW@v7Tr#;wFP}ZHh&qP`zpfYLq>b< za|oA*(8+g^W+V+L7BQDm(fem3+$ooz{mz15MG)RhGG^a{o|#39znMjgZs+Yk{wd)* znx?X0!#b8MUEX0gEM3lq4eJmhIrv43Zl}fHjGmcA#>`a+x36~r89Rz_r=a)Gj#~|r zMJ!{3mYEy#uX`DfuzwwUBG9|UVn|AUD&;U(n|tVZwiVdb^QfkC_*3%gR-<1tygeLub@(O1Fi?a5Fhsk_C;Kj$AtP<*IpfHf zvl7+g<;dZ~%$PBQv**rT_TBQ+XP7>H21kz^Lb%;z&VN~jo?X-@;JY|TvA@J87r_NZ z^MWEeto|NzY`0t9G8*}WGEE)suo91+^ci-;jahS&hLT?q4F)JIDq`o(5{3-PzwEoi zh8D1U_iKzDKOQ?AqOo*6X7zCt!_%kX_4Zv6h)CPUjzp4LVZ8<4So#chD9G@lB7R>| z!mxs&J%8>aFE@vh-<2?G%vkJ5gvM9bV^*I)G2DHCUBQk-;?d%W0P1BlN;DX_YAH|* zH)i!o8h1M23x^M zUOsj>Nb~C(uv;#mXs#<>0#;*PySX87slcFP5t_iXF}K3RaoirTDL zxqq6yufN9L*LSmWGG${#Nw{9af&4=0eF^#29V+U$cG^6)Bplx9_wC6_-3*a3< zn)Nl_9DzrB7ouH;|U*W5O(_@>TjC*hml`j@2NoH6~jt|aVw78z>kF$s#$ zu<9!vg%1_LF%>Xz3K_2zE;p_r<2ofnl7J8S9q{$Fq^9}Uwyl^M({D@q-pbWq<$t-2 z&jOHk<7~VWK97-Ign~(=`ZVtx_!F&dVP?;onbc00IC%_?6ZJmw}+Z@3+4hGRCl1PShfaoDX*?Ic7j$&BfVNgX$K3?oL3rntBmD}T_4S$hUo z{^%G9-Gs$;1M}QrT0cAha2BV$uRSbo0@nby*r5QPqRFxN`lX^DcxMNpGe_}wyga*M zBTMf3Vp8xAJ^WqP#-hC5kyA*!<%_Y}LD@Cj4!8@(w@bplH}{f~n$FEPPfbd~fPsV9 zy?YmtraG|0xbnw$GkFS%0Du2mTZy(bfs-lz-Nl)qQ^41vfo9z`cqpoG&_x|7D8eRK zwu@-M&-Z`uL;msJzfTJO@wHF#=p*0j0RP!9A*J2Dm`VvKxC_P-_19oE)beWS&dZW8 zW%4A1Zt&W!T?nDz88Nxtm+F{(7iP@~THf0Y&<1=3r^vEPZtyhlJbzLWIQXg)X70`~ ziv2G?KQAfxwLks|4?TFF1HR}w(ms2aV}BhhAn#6GV^)u^u@Bt@f?I|2b z+Gm#_ZP~MkvmHgE;eWez8J@zi0Nj7yx7fVp#e}c4kEPqS?yT5)-u^jLfcQ#I_?di?M1jDJ7=SPWx(>(1V;uZ$oP$GxOao2g8kQYr?<(MFQ{djW4}*z%qJx*047xMxr=}5Q4z1 zW@kf<8a{If)$K;h%t29&PwD)Cf}*)l6iN8h0h(Taj&Svv_#{1yQw$mHd2d(N{?8nm z`u}5deK0qFvj-=;BjT^_=wPch~;nkI(<_05$obi@h*MWdHyG07*qoM6N<$ Ef>m-4HdRL@&YUL=U3Z6hyQjh;nC$9zlv|Q4>V;9)eLrh#oac z5H)&djCVfY-}?|t?;_wIe3{a_Ri0=Z+24YjGrS;+wapwiXRxC;Of z!Yu@VlMsGvpB6o;%|N9UWLVt955B;=a7|wXi*-V<9;Sp+>tM=o4dWPXGBD z{Lhc(@9eEA&r-Brd>H&U@Nb|$Y%Woov+Z>1L)u`7{ovrmCEk{@$O;_3`)=M4P=R0r z&cWPPdH@dGiwF-dLPxL?qtqzoN*gwNKm0y6%TNi>EA-AY zINSP!$^mrf--p3Kv`^f^EWh`kC66s~z#dwmyN7V?K|vhZ`iwi34X=SWN@>k$at$$U zdVt~Rja;CjYE668Uirq3qi`fd`b+2G8^G*FWvM>scj;!j@r;)EF{Kh<~aNqoG* z&xr(03gGnD&+cD9gJk8Dsq*G&rgn%snv!MKRFYo%OM?$UZDR$KIz*I_+UDHgP{{7k zS9kB*G{f_hQ#%RTwb>-QQ1q%&7vGpLd|p`ype!0I1GeKo#SiYf!C%oz>@FJs@880# zb|dt5h+3B+%<`uaEG$)>tiCiQupa#+Aqk=^5A>uT<;#fZ$ze>Czw{j!5w&C4?+O}$ z!=|=R!IU~fav(qQiULp}9s1Sd?jzKk^wbLc_~4=H@dehe395!APNCzD%@o=c@bjWB zq}fj!Y}*yB*`AFbFMox4qU3b=cMy6ij9wLl3j!~n9#GLL({#Th8C>35`0ZICq=ZNc zR!!MT&VR_vVz>hPIQ|CFY%gWz`Qo3umN$PT=!DGq10hC@y^|%ws%tfztv)~HR>!>M zLLoBInaBuBTYgU+x#}5iYm8_%?+AL4fBPo>!@LB6*HUsrx0m`UHS= zQqCOiHU`3fyTM*@b@z(+Bhp`OgvK7ll0(UXty}n~K%`cTeNv;UgrKSm(|s#4>SOSw ziMSwCd1{W%eHcdPu%coM8$M2N1I8af)AXlX8j6SK zdVnos>~532@Ac;^KcS-stTA_K%~N{~-o9SgwT<=cD1%V2w3sHK1zj)G`+(oS>YCDZ zzdl>k5}weW+1}_p1oF0yMx$(_53kX=C3+Dh=6v=fNBL!D$3p3tN)J z?dV5G9*+&*)08R}57(?N=^PNHunFm;vZDw}&wx6BnKia){)@M0DyKSkEOjw`Mmn#; z!kWK)BUKa0u0M7nP=C|H7P27V(e&lyqpjjz630`Z4i2F|DFNA8qfd&Oj5@;M?crs> zk1bl-3K;|_guYw49X7G4l{=rv`Qmpdvj!%=SVx?WnEL67)7nWs>F`mbiDTeTUxhpG z>a=|UpO+`|KvH?_#9(GH6Hecu82Whi<@?0Eur7%nJbx)yDd62?bpWknCpKQZ?6B_2 z4m+ai`F5dt0}OjSPeYN~^55X2LukZ!;tG*mNsTTmv{MI{z8D%6kUv=SRi`EB2F^bH zPvNs`@Ibr&M(6q&U!-~Ae~I^nfR_xQ;FR_gw}@zN-#MbBM(RlB9G0-hH@;%fM0Y20 zdooXe5fYlwbDuswQY(sB2h*y=KeKBJPo(Z@{RGh}C`sIy)N>+?rw)rWkzI7c_y(2b z4f!5x4cy-$@vqT;W_!NfhuBW&WB#W#`8g3gVW7IgK~bZYW9(K<4=BW{WZUA_doqK? za8JFa!#}StQS~$@%b(bn`jYII0+4aTMI$x|+ur??+|rcv7KGzD@?e`ZS>{1QDTu^Q zYvCfwwKs~^rTUD1%KbM#4rvazi1Kh?rir6=QM6n)ONxB?gG9HA5*6P0EE=(r^`{*B6KkjEfc4209j1W)r;P?E`HgMh(C;H<3$5;Z zdt%Jjw>QK&^JLG0$H*~JpC~4t$LTq5kahYQq4+kfboQOke|}|y9MjHnP1av=NNvsK ze?Qw_-Il(b&h@6sGGt&;uO12ZyEDHq^G+{D31Z}NJWV9Buq3tih z4f6?Z|3fJ|HmVMCxw_L^IvfU#GU)>e+8eIzm0gJ#Ct`9)M5ou^lT?N0tar;BU)2P< zmAnqG+y`P_hpnr7p{7&U8%6%D|8@m#kt7P;g)`p_`_ckx-@;`1Kz0Rm<_+?UJ=*983OUTKMHJRG*12~SGKS^>q+{YWqz)A>W^W2 zN565a%4zAEtdLC`kLrK!_(QMZRZ-4)B6|6ccP5wyUr1J6C=NbFK zK93LX9rpiiJ7mwFkt}$XMm;93X@lME0?-$6ucp|s@U8N(q&*9Z+V=@ZM%{4B@g?x}>G4SAZ zo*^H-cRC{gaPs?Hjz-S`qNXaTKGZ6&%4}7CyOKYejohz)uN^uYXI^d!U;EqKOR4az z`E&19wn+%rJ>1e+QMuj!yl#wVhH6`s0js;46Je6k$pbXZ1+M)yKwu2md1S;UuQ?H+ zKy>7v6e{u{@=dVwmQDG_vn_(sQEwDYJ-Bq0`xmR~9AnhH^xg94$Gm@2^nSg)=J;+# z4|2g>HK)b!yD=71lWeM^j)~SM10Ig3Jq_Gz8s4c3YUG+5_DLP%TQadff3-~|m+p24F_QiTy?N_DS{OJ3b)r!wkEV;DUedyvKn~6v>;TOl0QFyCO`^+ z&%Y^naCom%Wn_rNuJYV}c~-%K4)!hBLZD;@bnbfNs-!V@kl5R+x^?a!HD?iatn-%O z2%owK7rCli@VWIyPsoV6j>GtM?xJYYU2)*oL&N4O0px<@!f)lN@@X_@$l62{;ggOC zBs!DaMt<`b1Jh6bMgFdk{{wjMH#2cxSjTj!RPHi9nX7=Xe`gExI-9z_x1ip7FEk+O zve7|t!8h0981_1ELN)q@qZ#08*(jdIE0lbD_lEa-O4${UTN?-P#)h+0neBb^a&E6~ zTW7e`y@?HlmM70PPY*rdKV4$`=5IfuFTOLMu&zW~%K~<%F0D1_MO_bCFz40ex+}#j zOS3S6)WJP`nGH3GnV$+(&K6slD6u>u@iD~4=<+}EzhEPLE=4Ym=}vI>eSIsxK2le! zN%O_zV!7|*=<{CW&5%d5 zI5+dJ3M52BVQl$ZKAtNy5mR6Lvxb!@O2(IHmZTcm7rAV|uu_*L1nfSdc&Ss%zrS~=e>Yh`0u zqF78nmCT6jeosdRweA8BQca4=ZWbnd@?GQg?qPO7y+(XfrWHfd_T$T~F`{Wif0%+( z3ux^`nIh4>UD=BM3L7$BP#4esano60vhpxmqv3y)p; zp7*uV{?kWwx8O=is2Ccu>n>7FR2v8sSL5%Bak0OjR0%?sL>!2s=u33G4rwe>5n_^xoGj?Jru=*e#>F!i;XfQu@pMZAz;NuscOeR z6b~SE(=HVf&Z7O-K*gXv1s zX3)W-EDKbh71bZ$q|w<@sFx2-FuvbJfrLXd%XIJg92vVn>F)}++`)^bS&sOm^X!q zd*n}q-WZWF+SY)D?4?zUkl#KZNYi^h1VsicY-j)WDn7N6ge`unwR}JtAXiyepzuhq zTDRo!%s|eJ7$Pn^Ab%(GD6rkULDff-f^?$s^KE)yY&2I&o|p&v*iU%k5_o6uWH0 z>;_3yv9}x*5jk_Cef~HO`}WJ}2oWve)zX&nYadSX=BiA5iqJoBPRqI8L7UJEVLC8k#PDm<^I{Qk_sQ| zs%$lQD*brplMZBv?#2QBt0;@($azP}%0N0q^E!QBeU+djBLRY&<{zj*OO*5qGUotPI|jlU1r zW6c{l{-k`YWVd}!jd>>`VTAR%11}K?rg{EE*#+$4YS--%R##6cV1AC$Q~p~$Adrq1%UHAs5T;e%FHf@lD3i0$F;y_&WO?)7e~iulS*%BZ8owLDrBH6rZJ)L6U5 zml-vvaqL{n{jAD1t4ykl#3YBFS*oYs-Av=MaZ_7-_$2?3Lz(oG9m+m3AQY1MEtOQ9 z=FhXq2k092vd9nmv>;2*Yqrbn^J`~OJp6~S8ZSXw`WF7Z2n#i8iuml>!c&N5#viWU zTvuE;H;_N{XVHOM5lI04o=NIHK#CJdiCnLvfYrpbek))kmcL&j9M&D%asZNI!xl*q zV+HIoX=h3a0VJsXGr!ZhLc4VY?1FQ$$X^W8i9sK!b4JmC&x5L#qj)9ip0tKzBYZw@ zGs)3>I6nDDiN06#1UD7@R0b(oQEdXBG{q_T3ZNWGChy@?nb2g9rM~eATVHVk{OTSc zpryvc9ErZ{^!jvmIV^2&o=_Dx-b#wTX3TOG;J1^gaf=*sBTP6wKNx&{23#K?MSnTq z60=OBW`cri7>z0&?uvuzOU7t`q19Xj&B=QJ(GpBoAq8a7x_^|DpP4g0h&Q}SkcMsB zduM;nQe}e`or-)QNwWNC_wLiZC$IC)d;!<+A9Ghi5hUCu2Es7q(wPU1&9MDy?Yz8a z*E4(VMELOg=^|!0)&$g-)k98lMt-6O3Z@L^K>+-0L5=yh&npUh;=O_eZJ(3zDl@Qs z`60p`1~fSDitBC9a!u52D7{LF$c_|lhn_NH-*eOC2xfvkjZK)|257A3GdbUdfL5H* z^5BiS7pH~&S)_r3#(VA_g)A0ms@6MiEu=tEm>jcA8A1~dVJfn^>W7e0ylp)qCtvMX z0Vh~eO>o53+dTH(8eLa*bWqWrybVu7Qgy&P($#yI{86T(eDe5{B4vVqsP#C_RM~Ro z^5)tfZx`!ls9P$fQs~;m4Qrh>0b9y&Qg{}XMb+R~T9QQ6Xu8s%;kP}x2OyhIkYrh| zY=+6+CJZyG=>6}~{Bc)f6`+vwG+YqOc&p)l2z?YSNm_CfzkOfZbWgi%>Jh+k6Kz(c z4`H;)a80lmly!cy)m_RT1}xki#Xejaii@|cCI~QMp)agpo&2BL*I9Jj57mH*;WVOq zv?|!#)tU>puE<-3F38D`W8RvtAvfVcfPRDhfY?!<^kV=Pw=!qY#f3R71zTcWZuW~E z-TiW%e;IOWW-aHqkaiGDiK%*5zR>)G$_#fc#-Z-4>aZvwIbxd1OW7Rg2@`WQXCuY9 z1rb{|<)*}C$2mA$3QzMvP?IRkA1m<^4fio|ILtUTBYxz_+ze-iJBOg(VRwt;PwrX) zCsD%x?I_;leCfx6Fbz=uLuPcXx%7Wxjj6m(dA~q+%#VP(Bs96TEZng3xdRnIFTLco zv7B3#M+ZbTZ!xjQ-4Z}Qzm_t7mvT;hB13BX z25+8t0PT-rnMo9G##o%2O)Z#?p_MEFiqt9yb-xACe8X>}zz&ZCp|qDhqU6BiYig+W zv0l@PXB}F4jhE@2gLn5#0b{r=Hv9ngW8AAE>9l_MdXpefw9ZPcpY^h;`0>mUF4p@2 z6EPrd*A8Oxbc2ef@X5Vh%FtQhA9+aC#EV}v`JY@elrHSsbgNWZm(rtegJohR1`pa){@o7`;P1Rhn zl;^;hSum&-wanxbGPQ)(5Dar6ANRV1UHAGn4EhcFy5luUW*W>*vqvh*$e(ubPcsVw zv_?2e;f1w46%=*SqaSaKtxV&8o2<-c`tLU+3x2Vt8PPPZnvd_^NJwI7x5R&Zgss|MoTRh%Wd3xDn_VmnHyP3$pDmqtLD_j zg|q;U^L$5pH~s?j%us;ylT5rmk$p|@EsQ3LK@gVrIOG8U5B?{%^znPR=D%ab5V^X4 zZU&22vs|3tv%#yPt&M!wyP|g{EW$&w&@uPpx$+S)1ysfE6J1bOGK)O_D^S_`&9#_p zCt`Lx9I1!r)qK|XCB|20ze>a{X4Zg6l%H14EF&XUrTv|&a>aX2Do)I4qDYv&Y5Pu# zgIK2+{=lWKJX?tF`_hNFT2^IMZiLB`!B~Q*Md_r9nG!H1rkdhfW?X$L-EcXG-wMKA z8X58HD;e}PLl0sj|5+#v=4&dEVw=|YsfoI-)=Z7~GzP(zcV(90xU>KQ zR7MPFHu1^>@9*@Htci+5#ayjYaCKjAJu_$@w;`ZIf-uVKfO=Iw&X#sJueP@W8h8vV zHU7|bHPNc#74H*94)J9j%mO;d9~-JsP%JzIm<-JII7Hx5A)v0infBiN^VN{s-t&X5 z!~|Q9Fi~RM{x1x6Y9WF0%a9j88NG$pYqb1nua?*AosOzBYoPuwfZ7--;mF%)Uck*s z@7d5&+aUs+EA_4L{CUS3qoB5My00=47q4IK2E4We?19aWcTb8+KKVlOw$EJhosY;( zmHKFQ&TbWe1F83e;DIM)1yt29^f6x>bDBlP(_mfi%#|?LUNIgi4d%S1#01?iY11Vs|HU zng>1A(9`Z=b;xrx(voDC1$*{|$M@nVd4@CQ{!gf4&UdrRCS--`)w+_NLy0&i$Jofu9wRS3t*=9@Q(O{l(;K3rB<@!X(o}huzHX*5H+ZkI4Q!Bp_#RMKKw&7kO{bLGf|;diU9c2M!2Y`X1f>O)x=S`opatX*n&mbo$;Y1xK9EoP= z|Ff%pEd7}wxYk#Do&}IV=fh>gd*F`NSDIbr|0_K^sz}nlZ_VSbkylmc`&|gZZf^e9 zvvf2&0Zw0aPf-l1!cX>Ai~>yegck<~Io){bTSeE7`<6XhL6;|BVE6F^C1!s^BqbWH zuKc8FG&)^nwp0e9BRAit!UY%r=^df!eYp{J=`@ZMdtT@8v#BLW+LI}nj;3MvJCIZi z1(wnlfvZ2q`>Z0xKkYJ_Hh1VCceko6uod<*D6|t_4g1{>z*vbZE)PHeFYIiDD3s-< zB+YRAPr-`=fuyEOLMwCdH<{z}SD2fUNe@Z%2XD2tCaYnCNh0eR2&hI9kzC^`8E`yl z-qV}K&2r@s+7dlzkJJhzx^VyraoEXUl*O1oDN&zmsO%(b9ay}YyBdXKYQ37{!mR?V z*r2aN>lhD0n-d@4uBbAZcx_#Wyx&+;WlbNGftD1}5CZg4I)9MOwtAu|g4f|TCZO3z z+>Q~K#8&s(KYL^s$u8#9<&ppsk>p{8KNJkx+cN9yHGL#92P6p@#$_+{N`3iZBI&xE zO6=(t9OCkH;sqKVAx~AQ?w^k5w_Sh(+u^m>Zpslm4KVo!?2z;SRM;$I(IV+1+1&zP zt3bo&s}|>*uP9!A-6L6i(#p7(eWCvpY-oyID^ekk*~swZoNguM+PWYQ=+E*@f8H2^ z$kzbMdBJA@r3Of`QGho$+SRN9SOYZvD^ z(+^~Y0xMf&k};bI~atUqW4yZGK7OI*+! z{gX@%{8+v?b`eaFRAu`47=oQKo)R}ZmR0qP8%%P%Sj{%s``-2sig8je)9BLrrCBkL zL0zow?FIfVxSn)be1WeOO8-R)WG%iP1@I;JMD5kXOS8GEN>1Zf2I@ba4o0KqgYl$} z90V9-t|6nssgH4anPFpkwWanBsdE$S)Be2bppmfkFD611UkTSufGjp*Xp=-Gue8@*3VTfYLsg;m zU%Re8Fisxvdt6YY(?c~pqW(BPQ@I^LyLIGHJeQnj!VCo-S~^8rhw~QL1Novc-ERaw zpWb&(mt~RWhQ&-%(lwo^4qXs|SnAigt**BL;PkUKCN&tsc7q8psd8|6>ZhOn#@xn*rDVA_u^sq)7NuX zbeb96P*ml`7K-W5jsRPAm*79^`3CMea zS?TZCX;#ycYZd9I!7GO7-$(p2W2zq!li-@ST0KW$Uo>5trb z3L`QjTy%QBBDRo&TYM)gn_mjBuCqY=JU6Y0Nrj?r{hGr3yu#7n4!H5hey;2?n{d1( z!x+h!^METHWI|zCpkm|-7*-rlk^n<*{KGn`d3)H>g^RuyUYW>jU2rkODWDu@H)NAk z?t2ot97)7%9JuF!q;Pybx_#UZV(*)Njlco5k=)br>$Fp_O*z|Wj z*=HT8bJtBWk`va)q(HG|7ofHOd0?&>2)d@awPou{Yf|@fy+7 zJH9~njkDT2C-0eJR_Kb`uK?1hiOXqLT2PT;i@OZirGF0tZW>2^QYBMuU4S6M7_2wf zmu|6G=PgdaA%l|(S_S;%q-wD<1eD+Wj>6lz!t!hUc)$^p*L(^^VXy*7X2vJ@)k5#M z`89jDUXbWKIj7wi-Wh!1`Ni7%y6L|B(dKxG21fM{I>m&oN~W;#gsGL=0Qc@jdV~;= zgF-u64#j)ThcJAznKwqdVS&3~8v>sC@PCi1n^I-GEDPf}lk`l?IIXRfmspxr4!KzQ zq7mEb8g)rYgc20WZ6QMPv011IRrdm&psdfziT}eBa`Plux z2KAhf+-izEv+n^Fy5#9Ux(>%aQm*{-=67Z|66=rJ-~Q@1Q{C74U3AWQnHM9#bxDM* z>o&jvL-@8EHSE0U$a)H_LGmG5csdVvt#SOYkS=Y71rw}RX#xn_}$Ev>n)ss^$ z@ApWMhGQ5W7lb6*4edk+sBgUfbYZ9X$;H30=ji4%frMrL*sS?vyjFrW z1r)WV$U$pZrqzp~xtje!Zt634zmQO%^x`TOW%_0%S1l@_BQ5LI+cpcH^^q?~@%MK< zy4&)C5>;u!wSQohbvWM=*IgLf6`n-B{O&W>x+k&T{4O|7uVAf2))#{MDwKA}$3l$p zP3W1TzNf(OGHT55dGbSmmbXsv1&XN{1xmJ|MuX`gQL+z|K>Ee#!iE>OQ0{*=MGSYo zAkZ7TQU6Zz@Com!B2mUYtX=wpinl&!9&#G+hIIGg#(F8HSbKT zUuYun;g)=t`VhjxG9cMm2zbh)`+H16)+jZ|e>+205XFu9S+FQ3`Meo?{0a`Q)RpO^NCWCFjFX!7M?j<%TPKq7a37V@S#LL@ET#zHDzZb zA{I6ZMG3=zoS-XEz;){Q)pqnNe*-Rts$25s zJyX6`!u!PBu-wF&o0oYQVBdp1zu1JYQF z8j{Zbk+E4b^&e5VB#yjf_hpH>eUyZIu?__+ytnPw0{Fqj)xDsgbRr;2b+hi5^_x(X zwCP;Y!a?HK6hc*v#{Jsvl4C*{FFzE zQh$m7fpp~yOBO!4J2r~7J8=%map!!4dF$1QwP7_-EFc`m6*HRR_}rR6H2gHwkU{<* zu~M2{NOqZL!FPB(8@u9nHgxX8K&b9wmYKgwRDZwlQDnUtHHR)(b`<25Yw~4l3H@s& zUib{-JCq#$BL0YjC)KoRcT0G1@!x<)att_sw^wUN#~Y=L}{ztgeN)6XngDUlQ~7aBhqhoxIJ|7Ibi zqpv>dg#o&L#_uM_W_M2glvpQPiRSmbc+TP^fF%NVHn?=Zzd(@*spVtD4W|bAn!Ur2 z99g|@+MY+L(aROHW;9&f?ckI$yQej>6!7e1iS{#Hd>wezIL!GdJL z(8#CPDx)@l935}g&kI};BL!|CcI}20#h6}Ysh}PHshYD;$Yv{&Hp7X_Otuy)+a|0 zO2uK-0si%~<_}H?ge0<$()>0MmHcPfkd5y-U`eyU($sxM4|GR3N})xchsnMlp%8k1 zr2Qi)7xjY%2{%NVps_vPa!?Sp**etdMPuEn0IfWFF=ydMS|M!`X#~mf;(G)HqRQFZ zP7+9$?nJok2YP$TPQK-Y_cT9Q;ue2NDc}peyQv#vFUER9R_$1tXqV0BtMSXLZ-k|i z@@S$5p0C2goY>)_3*7%Nmwd(cBYmT6^eE|BKe*}`$Yg!8EnOVln!1~<_mMdpiVQI@u@Q67Dg_xVnKiwb&I}M)Dz>pWG6nJAML`1 zl;I#+THt=>m*Zqd1+bvuQ}gWc7T2C0$U*{>$q9P1{XNDl( z-P5&nhZt*X8bKVj^~{mthM4e-=OC#o>4JG@i|#d$!s7-#5St|FEsWH;7p6#uRdyr*<10d+F>H|X U;t9(b;rtHhY8q;ksX2!KAH%~`9RL6T diff --git a/lib/gui/.cache/icons/context.png b/lib/gui/.cache/icons/context.png index 7176139cf3843ebb187d587eaf510834cf4dc963..4354c1bc99a2a3fd4be6a02515965cbe1fbbce2f 100644 GIT binary patch delta 1983 zcmV;w2SE7QK*S^=iBL{Q4GJ0x0000DNk~Le0000$0000$2nGNE0IF$m-mxK`3V#Q0 zNkl*#{8;ES*zD*Ga`AmV_E zE<}9sL1Fh*WN;PS1wmP1ebB*2Q4mBQcEm3RXE!TJXEKwI?j+smkLr*5zCLtSr;~It z>1-;K?wNvms8pqL&+ndd{^#6V5r6DFo(5h8E&!T?9dp1n;5L1B2;h&A;qUgm38>?4 zu+ioE0>Ig{BqK#nE+Bva$k_pb<|^>aCk{r+#M8C=4!*Vxu5Dt9M2SLc( zav{E)YXev*RB5^%vXAt>=b}dlny$x6p}GaYJV3r!#cDczRqz}{BCV!FzSssZFM(yi zu2?qf-0Ij+lJ#mE5H&^i>G@5V0pbV@Ht?4y+WtWF@RZs?0`#Q5QJnmC2+PAzyd(7 zIJi<6OJHtO0Tw#j;cQ$BSWSmh!+X-M9aLQIQ zy;`z3b8*3!WCi@@hcEDniE)};I$9+qg-RXQ3y7pY-wB`&{ z{1qr|>={+tB=BzM8rc?EII^zLFflGoAkt}21{i8bOyfEbgsqPc)0t?m&IjMy1mHMe zn7wv^v+ZGYJd=&3(Dpn83GW7Kjdooy4D|orAy`^$#d2i4Fbs^3ehhu$1k`Q(zx@^M zx)>k4gnt+tgCJ-DaNYan5&_uW775Jm4xmuQYB-DxWeCEAJwXWYeXLi1MzEIOar?vz zUnBFy=W+h|Z)&gov^C&~DNg<1C3G0{5GfL?VUydG!1-eu%LK?uQW z(dvxLi696p5~<-P5Bz1vq}*>+c$K>RBqFjJ{S(n=VHJ5(zOw{(F!q^28y*|;gP z-NWw+bu_3WDKY`u^*WoNxgB}5%YX0PjTcC(4X3Ynij>U=eUDH9KXy z9%Jtv#r6C1Q`wZESglx8YBqqoT>^IlkOMZFj(6^jKmUtQfB4B*Li+AVrwrbF>kduF z1K5ZbL+@W+0Jk`tf1o~2=dErb-^~L;;K#trz)AY=JNa(8uol_0Bm4e;02EAa%WDs6 RdmsP+002ovPDHLkV1m3ym%#u4 delta 5616 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 Ck6}pw diff --git a/lib/gui/.cache/icons/folder.png b/lib/gui/.cache/icons/folder.png index 1eab92f8eb13ad3bfb98b02dee622521c859de9f..e2c1be628add5a9c2e75d1dd1a9b065adb189084 100644 GIT binary patch delta 1450 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 diff --git a/lib/gui/.cache/icons/generate.png b/lib/gui/.cache/icons/generate.png index f952175a3c4a5aff83857c16e35226147bff3238..d5cc9270f8fa542880d12c9fae9b505c922351f2 100644 GIT binary patch delta 1224 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 M07*qoM6N<$g0Mz>8~^|S delta 9236 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

!If}Q^A?ybxmKch zewmZ2Sfxp*@X7+HPLKXc16W4|IuVon;kyX8^^i>5N~1VEe2*F-AT5^Y+3BfwtPp)Q zGW_S5s7D!^a@$f~dsP}EpzxL8Fs3UPW)V5+S_13=Ir~zB)3HuXB5d;vP@;x&GR;yHEcA9@S!)uQ8S?A+}=~CZ2E-|QjJ=(8=7=g!E0JfD*_^4xx zkgekA**{gzz**8rM%*upX;Uc&&4qc*lFuT?N-#ER{v+@iH2OF6J0MmxWkCPJS8a;&yH%r?~4-{Cy zaU&GY&eH(5SIU1Yoml(}ID{Xs%~u7%DuR)nAf<>@_Lx+LB3_?$Z)<}$^YIxXkI4L8 z%aS(;gpHvcgX6KXN)O)BY>74OwxJ4viKt{~hXz9fH>wo$cA{`pZvmblx;%;>6}yua z)x|tb(dwgpQ(nVgKjJaD1T1$Z-?(N+O~r0bA&m1Gw0pvyhK(3gZoqwdzh$|}(K5Wz@p0)$F_NITFC>{BMv2cxePgXsJ}gZFB0e02~> ziTtc9&tFO7HbT!a+tZ{wYM23E73)Op;9Ne!1c?LBHKvdPahL&!X2Nd^oG52?k%M5_ zbj8)F5JL{HCE(vJvS^qhfPriPN*p-qhY3N^S}7q{3iRWV4ssByzHTDf2Aq`qXr@Ez z5XeN}fozdgz^mp4p)0(}bnb*-1uziuAvz_juS}IqRXquvXMsu}=9(BaPszHczj6pNN`CHmfM*8v!uLsOI$X=M`*cEK@taKoH)y1ey*%tXO+RDYB(* zCper1gtv9O_jS=W?W*bdurAUGaOZIQ;Ke--qRRkI%34zRWs8{)R;NM(&|zCP;7@~} ztso!z;9-HxYdyA*lvc> fs)k`g1swknQ=$J;Wj_4S2W;A~b^U|2UeW&#W)45k diff --git a/lib/gui/.cache/icons/load.png b/lib/gui/.cache/icons/load.png index b8b2cad99fd366f1e53644c05c866989cc1d47e3..d94d224061c5c5533a729c6f611fe78a19ae770a 100644 GIT binary patch delta 869 zcmcbv@>yE3Gr-TCmrII^fq{Y7)59eQNIQTq2OE%Fm05UiqvAZSdS*FK7srr_TW@ar zXNbDX9QgSD+?jXYHOC@4yI8ljUkKyZJle(b-{I&F_O7s3sw-wRFIlkkh2{l^C2?OB zss#2tQ)rUoiW1-#WBvcYG1DP1*h{~+K1g`-^gZ9p%G0Vk1Y5pcdVYJm$$t4WPi3EN z|9tc3*)z>^87g>m9{p`+eTWx;%n$`R(yJlI1 zXeoH=s-KI0{xpK~fk=VLdImNJ?W5+ituo@TxtVS??LL>yz%9& zGgck(nYQ|fDHDTFcv982^-oW|b-yF^%a+BD`E{d8KEo=xxl!*sd(s{_h@Wj0s87%> zQ0X^zxVrlBORqz_LbZQ>dwuTae71&fmdpmxT z@_EU&w3ado{=S7~zFfh!}34i%_ z)nxrJ-SqlBlZ-qY!Wj3({dfPr(p2bxrGIe7i~IXqol+!b`Wdo1r-&rZ@HLJ~Ef)B7 z{j$62v(x8qy}Wz#?>-o7gy6 z7V=j1wJ|QawsFDE0}uGCD}Nj}o^v`))x^#4({+~GbbVp<`fY^_424;dccPYLpH-hZ zmqU56q@3hY4OgFL#6i?H)AQ2g}_(R3{cr(6QN5BU*~dHgEO!i^MLn>_euPHTBwS) z?RkSj#LsLW&X~b$4w>gc%H>YXSeX?^+z!h3xl-2+bz3pURv$;v*NRqn*8J;NO%|Hc zuB{e8;d3iSyVFmd9C_Vuz#G?(e#<_a=Y9~Sn$Dl}m!~Vc0%|<`$q20*nKsX_|9HRO zLH)F_Yh>w@hZFu!aDBe;q2{Q{d-yBa=ViR+uJitLhlOf`WsfsMlBp8l)qKY5`_Ba_ zh*ncb;e~YrPKTl;9%EZc9!-`{^XtjH^A1`*%mZ)dn>piq9^Q)I;@Wni(eM?(62zqB2Z}xTVP9{3#@c; zF1}}&3Ui*f*$30|Tu_aN-qjt(ae(njM+0)z9?ccl-SQG+`*djxN*HzXfML-Xj&2QA z%A)zXg9|BB_RddU^+F*)n#BG+GIgd?L>C2*pT&-8F`H0RgHFwPO{Hdf_KipkXZwdC zxcokdH>;jlpUef8T8uK&PmzZ&9P2YcPARK9oLi&Fnix)f=#Wiwu@FH3zCWjWs8Ig{p!Hkz7r%94MFkDN90{{_W%Yc*yvN!X ztMRX?tKl(*V1Il{Wa1AL^;&+jCZVpn1DcNMv=*&MZ-G;dj>;#)487W_$-4z1$Z?D1 zy)S0mj|J}vdTE6sFpi2KhO`-(=TqxojntuJ)?^RJbeQe+n;o0|jo};OEx4vL24tL1 zuN?#y1Lgw1^uvZTW4j-Pq#$%C$u*v4k*t<%JB%X-@}+rxbIcr;Kf`FXSRxOre4I6*%}y*iF0N& z@mvBWt|Br5N%6}~2IbxkzNtxqEyE`s=p6>ceKlQ=FARe=j&`wdLOlH)DR-Mu>rL3sl@S@MC3Iae&JL({jH50bgSTA>doT?G1()z`NBjnxSwT-mQt^ z45X#X(+AA8eq6f30TUHgDLrw+W{TPGHz}t}svX!eD+;1wZ!VM!dam?&c1`zOtP4L{ z&>7#c%sz6|uz#MVkjy|iJJuGrAP8Y()wQ7IWP-Ni-Q@Mu<|w%JchVpffB`5Q4>}5- z{|WzM0R4gRzh!?P^RGZcf1COLE&Uw;^k*{vY;VIL&IpoL8;!c@Y3bS;R@ai8(Y^B+ z-h1_FZF1|VKjdNrIi?URxxxR?m^$}%(3@(mSXgfxS$;+$ZG2sKK6=Tl@3^=b`(MRk tCTI@gk8@7n@aS6Q8rz^P%6iwg0+m}Fzhl3DG5~;wm%AVJ*1?FZ-vNRt{z?D< diff --git a/lib/gui/.cache/icons/load2.png b/lib/gui/.cache/icons/load2.png index 64ded4a4ac85f5d80428cdb553e293efbc9c345a..31d4bc0fca597db3c39d70f644a4f3a488ded4a8 100644 GIT binary patch delta 1247 zcmV<51R(pzCF&j_iBL{Q4GJ0x0000DNk~Le0000$0000|2nGNE0OWVlPO%}L3V#HZ zNklsS;4hzE1K-SqYKv9z?rmwzw8pZ^v3 zOG!L&2SAo(Zf|et?CfM=VIeeU{R%96G^V%%5IP*Xy1HIN=mO>e-KPc+YTe!4%+1Y( zxdIRp0{}p8Z!h!nVKMi-7s96i@B(3Wb~elf%q9!~fWE#yW@cu>T)<4i008Lk?`L{? zI?M%3Ckz09fq?-gCnv*Pz<*@I000;q9Asi*BFqI$Bn$w6p`jth$H&85z0KWxF=|2gxYvY0agm2Q)M^U^E&d>H%3< zSsyBXii(PGI-OBju{u9L$K&w;xOIUZkW*1nfz@h#cR*_AfcyJV`G;c%eW>thPQ=kt-9o6E||3YC?W z>Hyf>++=TWPcz;4`udv9%}wu$Tq~00BS%5CGy?3OgJQuCA^$u_TtAolS0Tu6h8>W;0DqOj%ux=Kw=jXD6VRx5V9J=g-VHoM)9)oN7_Ky!05X0w@{ot>DAA+Og)7aR^&CQJ_ zTG3!Iu)e-dZEdYOL%?RUX(o7h-aQ`J`KeA1Xl-r9X0vg4c&G^}Tv}R6Yip}|01O5L zV`F1t77zdg00BS%5C8-K0YCr{06q(V&#?9N&46FU7*-2-JRZ#+r4=oE{l0PV#YwFn zrWQ>A$27;sJU%`GeADrKoZb%!ngafnfD4`vCzKN`cpc#UZvbp)FN{jx#2x?u002ov JPDHLkV1g>%DFgrj delta 2115 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 delta 11621 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 AumAu6 diff --git a/lib/gui/.cache/icons/multi_load.png b/lib/gui/.cache/icons/multi_load.png index 05ce02261c3c5d54f4260d7754fc018f2cbf1aa9..94f648e03145e1df4548c8f4c690fce2200ab2cc 100644 GIT binary patch delta 1245 zcmV<31S0#DCg>g^iBL{Q4GJ0x0000DNk~Le0000$0000`2nGNE0I9!oMX@2C3V#HX zNkltCi4njg0OI4H zJ^*gF8&0P)@A{jYo9OK9ELdWU#bSvm62L?f40=K!6bk)N{hOPcoXVG#mF4UScDo%? zsWcgai#89SC<+FH0oT{pIoH+e_2};I&Z*AE#s&g`K-T36f`CLKK}AJHa+pp6c%=n^ zTrS7p;2>iO&hPib?|=8x(bS(MGYe$9%bTOUmW&i`XlTIx{yr^jjg5`PJ?On;04Nj+ zSS%J=+ES@h+!sQbEO~{PfwL!iIEA6BaskAIJkMF*f%Dxp@ZnFRm< zTCG-?1q1*B00DpifR{=T_;&xE4LU$3lO=C`F#}+0YYW4}!w84NMF*f(tFgMeiu(F` z<^Zg(ujBIak`*o;3Wcz{yUQ$qk&zLE!(lu9^?K3M)5BZ{+}+)=%B^#` zkxGjPxLhtYH#f6F3d>|NbaZqu3qYwgsAZ91h52GG?Xl%E}6BMzyAF$XV$ z6EhG&L{J4FVn7vOML-^0;LeL#pW4+5wjHJa~wq2sU9c?6mPiE z0aOciX)oA$FeieAK~f-Luu_QspxV(*1_^_eLOhAk4K)EGJVyrTmk1CcP>>_-MK`|77=t9KThbmCkP$wrSzaVD?jJ$y_M*(vPB#VI+)gznpXYy&GH>#bk4XvwADeYKnscxXe%vbKz z?8we;yU;NOn8yzs^v^K30<``Cg9bR`B3ZqsCw)F=ZzG$4)DgBf?!dwzrRCWgM)M~M zM(p#FyC@7V~@Ia!@iWyRH2oUf;vBe8UsG$nrHl`jJAC7w78|JHsG0qB| z>M9`B$aw9I`Nf~J=ASc@teD8yZUfHx2Md_LExwSjf^i{Z0fP&Ap;vM2aYoBA2WDWd zJv{$H!Uo0`x15I3>I-%vQq6o8FZOnb&zG!XC`S)5U_8_V0}v?;;gLf|fk=`u2ik#A zt=;Kb-@pbeK3m)(8ceHwOXhvPJo5yD{hjJ>^DlJpB*=5|wn+n{@?ZeSXUq%0B{ovx zF=%za!~u?RhIadf2NRe%dT%g%duaW^Zr0hhJCF4p5?a`t`DKp!r{gT+>rqO5R4wnlLQkMuG&=3@h;;WXU~a-!*eerTsY|HpCKR#%!>sdgr#^)fqBD% zfeXEM(tmt2-6?qiV+-3x#@~S%26q~2HB8!MA~t`2vwwZ%$D7MGew5%T*dQ#G!}5Rw zkD&io(GwKjU`#`b zQ&^fS*z$S{|C}SC>;>Rj8W;*laSkspAYw2*kg(q~`93p4tc^fWqV;V%U@L>c)78&q Iol`;+0LGo6GXMYp diff --git a/lib/gui/.cache/icons/new.png b/lib/gui/.cache/icons/new.png index 5dcf4beb5d3f1186125fe1d3d25d55b76c84be8d..51e298336da188f094b1df257bf9e9569c5faa68 100644 GIT binary patch delta 1609 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 L^BQqVqR9U*+tOST diff --git a/lib/gui/.cache/icons/picture.png b/lib/gui/.cache/icons/picture.png index e667c6624457ffe1ecac29b44e18014320ad12de..e0bbafd5d3af3af18780ad7ffebc04003617d910 100644 GIT binary patch delta 2598 zcmV+>3fc9YPNyj$iBL{Q4GJ0x0000DNk~Le0000$0000$2nGNE0IF$m-mxK`3V#XG zNkl0w0|uTz(oak2@FAK zPj+Ec?gucG8~>1d^}>~%jS~pAZU71Kn5RzM06+`?rir8LI3c}tZ+c09Y2N&kHZJV| z+5xl!Xa~>^pdCOvfVL}2)8a~T^Sk4;ogE-l6%iK)LIT?XCz@mkx4R*fR)4nxBnhEv zSSP$W zMe|9T@n__Ry5Sg+pFaTMa(@&4cvnpM-;5Z<+*f`@_f8$KZ3jsRA~_c8{&j#qz2_xV zcb?$i@<^OHJ8Bbt!UporMo?$G?8UO_&y$=O&ozGtUogzIK!|YIork+8 zMgGFpK6S-U|YaDo8<+%5%AT35nhv(cuuZIkGBk(%-V0OsivU|B6PaiM95_;`?| z76x$L{Q+2(O(3Y;B0Jo;#(q!frlv8&lfY4lM8AG8N10vye zWBvC8fz|Kf7|}Sy{PsBhh4ZkgtD{Gv*#S5X$TIG6&x0&;=6}LfCcU|a@`J}ff_S%z zOH(*mdzn8h-b_K+0RUXjj6&|+yV)W_;dWzHpTU>A0Q>SqR8?i{*s;;0dEp$sg>x|~ zDi8??_Y{uHzziTk>Gv3F`V)<>m}McSrXt*N7+)RY%C!I+%MY@nqKcBeNBHN?{p4== zm;>L`0HFSID1Ql4rUFj0!~nwW#;U2o_x613i?smc=H{|w$r9q?;wUdK#|{RFRD6!q zF&U*#U$E^)#}=xJ@#R6xW8VM-?+yTfWuo?f9HrMI*k^07uU(<;YJk(VSE#AGM#QiX z@d?C@8B6?cUj|KW77rj?uBh_43$QPo2Vm~pxy+a`1Al<5tStKV>xb9t#WDUz+ckdp(UNg-`bBTL_ zB2*Q}=f}T%33lx{0A73THD=A4)u7$Pi4*DCwJR^Y@B)`ET|zI~1cpJ}gvmIj36|M> z^#CUn0)It8%NU9_^vNh;Api%*FmQCe(N(lBUIJN0dZ5Ey*#)7hIH54U#S5{jPXaJ~ z`gGoSDPDqb_ z>iVs#E(9VWkx0cp>R+FQZ{F)zXV2czWeHWqiGM`!uULwC^lJd7Oqs&crAu$yFC!y^ zojZ4uk&yvF_&?>*-BVxxe?#(?D~ZW6!sSBWyn(=qCD>OlW1XwP_x1v;)78;}N~3#H z!3l@)FIy73z??jJGVj0t{#^~5mX=0&c{yXojERQhl?U*>`4_CSHI2H-mI0uu2n71t zHGk-v3vf&W_w`k`>(1j_G!OIG@mnFtGC~LfdGE%QA2)6s`T6tml4^w7l4r?M}JaSSQvwMPkFwg@Z59H5s5_Dy?ZxKFo03< zIdae5I9IP=Rvo@aH+VP7$Hiftslor&yx7?tF=7P8#l?6$9)=GePEt}5Wo2bxL@@U6 zL++M}(x)#%Na!012=6Kb;OVEIrlh2V#KgoVEl75DHV;1dU=+M*VC?%8>-zECqADq z20&`ErPo~O8`l%aTaFVBp{go*d3ns4Gv~Hlr%s*92OoTZqTCEcl9Q9!zI{7Ad-iM* z+dN{#2)1wEPLCcvn(hUJrh#b@{Cht7^{v~bOBcLeZ_N7MNhVL8%(`{!Vt>1&zP|pB zzP_n4aNs}&4<7u(Bf%R53Hw4Vf&BL{jvNADz<>d4+O&xQ0|wmFZ;c;69+%6-i!Z*2 zuIuFH=2Bl@&#G0ckffIJ(q>bh5fbt7SZAuKUoa=8{IkzK%dTC!?pgU8GIs1(3JVKK zN=gDCKR=(BUV5p)M1Q=RV1I+KHUXoe0^h>9vE*dhv}tVFvV|^Px-@AbqeqXXsHljJ z9XkTBX3ZKVO`1d`68Q-LkR*i5O{n-IeDA!4efc6n2eu&58p}4rX`R^6JQ#4f?7&^@b4n+3uA&|ck zC!{0GGOxb+Dhn1YK-0AQ;+k4oY}>vG1GoTmmXcGV^*qzWJa#lzM^hAqL4yWinkH5Y zU31+}5khe4)G7RaKR{g!0Buw1Vn;yg-U;DJ1Ji2VDl0Nh)q82|vzHp$0BO*}NPoIS}CDhPCx*)wsM?es1f)I*e=papL6Pom@2uiPlfD|c83(|`s zCB z%M<`$&|erpNe=zoxnJQ@kIbhA+4c0bu9-dfx{>$rPNq3=KrLvw+xgZv4wE}PO`j%c zcx7d!w9VAtd-gU=mW7FW=1yf>U;SWa`YgppK#_(jXsKJ<+2jhdiNO`n8eRD#+aiIx z_}c-V!ckF7^<$51{>W)NN&;1D*O4C^lJ7UV;x~$~rUnFIT9I`0XfqK2G$W;Swxgr> zxnoLFcZG|RN^j%(PZ%=dJRC!Z0Fcq->Xbz=It?@sou-pwy&Bb;&QRzvlc1mz_uiN2 z!zBCD1CPK6EO83i=tu=orFXz`Daa6*MLP8&Q>lAYyQID7OeY|kOi~8;YvaF}gl@rq zZQ_4E{{MJNYR^QcLCPIH22dhpKtO{M8tQ-D|F5@$>;c^2jeExhi}K;P@{hv)iWS=% z{npT^h$)^^s`ADK8bdi8l((>r3;%0m&Ymz-`Jn> zo7g1?-Sh}w$1vH_<YwqPLM1l!{TFX9$-S$r@xWGqm7QO1E)BBGQJfc~^-LppcTv%7&%O0#r$f91m(ibP(*~47*h;y|3)Z0{dAONc%W-tz-bpKXs>e}<)8v<*Fiaf%?GpvX{*FE(VA#Ht_7@M~p`|(8owEo=c@3R7Q)O@&eW#P{bNiLn zyhS@qz=*{cWew6du6S_$#x?Mx5%l%=Zp*Q9$1UA&YZkY7`3m7(w;x_J!Gk8-fhG#( zs>a?Pdp`&1Kfcr*hV*|1TQA_d(iL&{X1)zL)_d2GFPi(N_ZEC%y{&$ zfY{^M0OtMNrZ>?|sbc_wZQLkYOzEfm-^Ge}f>gPi$tifJ+B}*ZTomIp+WKJ1$7cdp zFEOoWq@pq-ty5qZ)z%=H1T2_BMXL6NtX{-%E#dJbEN6QPKTBydjDmB;9ilL;y^LQ} zC2;b;WjD_7eQ>rBja!1e=;>1#034|Fzqa;7clLPv@MDZ~C|;@Zx`e}=RiweAYE-pI z$KAomsw18Wl@LvEQdod{Pv;8~<3*UxQT6+Iq8*AdN9M?!Ss^`M6g1e{h$*!2+hnLk+GjjwYYGy!uwukJZpQHq>Uxz7LUDM?mSJHA|NjE#sd%MiEAwHUiCR zN25JMgyCHU{V-thwZz9_CX0eHKj(2f2SKu`NlJuroXEiwf`n~Ez`DiSRR7Fog&mfr z>8%a|1B%kKOd6W7q&bWCL{@vM>MxHMjM~?@)=Fv`7)J1D=#~X*oG^=bTQ}A(wP>SM z-YL)-svnXUvW{8qj-o@d9<5uwKohqh_TFGup+ICFSZfVc!Mw z-KiW*k3Q*+E6=RX`CsRNu6Dc}Z^(-v3yM}Vaj^Y`1VgAo9HeqDU*%O;1SOsw7ue5s z&V}Ky^JI_$x(`x#9O^s^5&>Sr$QCxb(K{} zlW}!Y>Z_A$>&DBz%kC|Uq$ORKu@i35rN)ucA{hw*yeW@gI7rNGhVPVwhINU0} zf&&^Czf*i}$#VBCo96jGWQ~26WO3E~=G1vQ$v1y@gXlsXn`L#NA)D_@3O98j)#HIM{Epr~yAJ3~4_i@qFM~-AkBnH1A$}u`DCAwDhO*a{0?|F54-&<`&Z zD!mP~X}t9yDL*X@W0Z3hDI>$E6u3b*$55Ba71_XK612{=vJffT5YXPIVrgbP-;P7*u++#L~W-_a$xVIKs-&R48;!K$* z{Nm>@Qi==FcD1^OhBS|8SV>}gA~t7R64`xxx&w>v%nNXL1B`$;@W$2t>-S`S6Pd@l zC)T^Yni(q(At7+mF!iQM-i{OZ{Cd)Vi#il_J0jy_XaBhO<+_I}4h|(JSY-G-@uJDI7}n zO$n6S-w_w$C}Agox(UAy<+a)+XMBh2oRxHT#F7AUe_8=^I--}3mA98*EW zQ{Tj;CM89WqqR&;GZuqDM<=KBf`U|}=A*c)5l1RyUy}KufL)AcqVf#BK6l>%^$cST zpWE@*Qa;YYs5uTZ;Fz0gm;?4s$fIEK@$to=gtRn0^uH&Tmbt*HjU$WZb{&&PG3)`G zXE62iyW)5+Z}pl^5n}yVx!afqheYI6ciwqJMwyja3EC zu{K}`8h7mSw0G@_rH+<*KnVM#HSOGmGqTxHj^aKwkQnM(^+ziWa?Hp`lG5^W+(hNA z;`y2dy;4?5VEF0HhT%Po=jao$lDn2H5lPG6UGfiNJ_wL1feL4{0b;+{cW`mm8zC&Jf*&?tEI&)Az}P=OPOn|$=z~) zbC!8!G_Pm8L^lQtKCr#rDsHBE=`#j4+8-<=)VGZl;MP95dGqGj`T-8Va-98lrXc(B zfg3EH1l)OF0iO-YYSf0i)x)d(Aut4X$0$JVtKBC3^)(F=%+-J=8?QKPkFWaTyeD5? z`u+R2&vpm?$ob(*Gm*Q_&ey6YaZ$9YX?FRXoXqyL>K8x~DW+nVTXuQsrCV0*UxS4< zMJf~nqcv)N&puVVdX*`-gA@PFFBO$lrBT#XakIqdaO3vIY>RQ|!|R(fQe-=?#m@vf zW=F;9IFxA03`ouWl?qT=JOMV_370PO<^ANfWYD{DL&w>f|GI%ehZBhm@MpQzF=i5d zI9SBr>=auBnz12|0|jxy;>1MzUmP zk7YW0d)?~1Xu$fyX8zlN=5itCFB$am*s~$;UN!G|2;3Vf-2#@`pIbMgFCK2qC3?-Z z>LVDSyreaZ76$JcfnKHhQe&eMoU?f#U_bko6db*Euj44pv=@sW=Q%m31wHqFEOdkf zK|X>TRoZ6sCb7iR$)|)Gk?!cS3mW${`^EC@2CaXAr>Y!g%Pc%vXZ0)%?T=eZt6Sc)ZWwrlj5p%-{e6-7b&)GU9JtCM2( zDF^&IX$W9PhkcT5Rb;aG>|Xx641R-f-%95K)v&Qwwo1@2<7Oj&`F9`9cvaXm$BMKj z+jM?xb}uMRw5Fitr0K9r9UL6Qyy8M4k&yC5T`$vcb*)@wC0S1=#b$#@AM>fg=2AVA zgHqU_$%#lNb(Ljp|HjN~&<}6(F<)i;t>3-xD=R(1F1u-;sn%CQiY~ar%wq%LUVqBs zz}2xL$W0oiKTA+FT8oj>awg@ggmj~25@wAERiC4&?+6dyqg)($fW^hf^2MWSES%wQu zb58I2wt$!z9MTC&YHCOAKOD^-Gm-oI{$^%o4~|}l%#fym@r2KxKKXnfyf_ZU#hWEM z@9ZVHMMVw$N02`_m4tCzQjTO0EeM52Eb~GuBWB&WL+~C4zD4v1|KTwJvLzizpvPnF zZ}<0bv2Zr>l5ru`SDnGFCZ4bK2|)>r;OBB9Dew7qown|e3=>?T!6RZVW5D;Ms+Z(V zk3xq=z+kT6RV1VgZ?)O+3!vKXy}iB`_CO`K1UNQ?CwV=={8LafdYlAmJ7_L|`EsHj zZh6^vu?yC||H**!7(PlOqjj<(;vgoR-q>JVW3jwDz%}su$H-jkZdusz;pzfbO_b^U z%c`mv?1RTff)*vW_k)@}XPF>-&{Zu8(vPEMrY>mxAuOlPaW;Jd_;4pg!Rco2EIuOS zLsp_p%DcS`UTs5%!NnydQs^;mk8f`-W@cu>uSU4fLExASaboO@hs(&e0Qm)W${LHr zj0}3{ETW#tBXXG_m(dGCBkqFY)4h-RoJLMLS3T3Rv*RG@N@O!4axs8u_n#X>K`RYn zAf>H0QJH-omPt&XMxN!vd=MrcDf)N5kxp>1xJo}$R;}|>L4_S$GBq{b`H_W6NWSUC z!b_*=>s!B&x4dF3YTG(t8^m)TF=m5h|0n;4k0cquB#hc-X++0 zcf=_8{gi3_9Qs|)+8O~_WDE6282l2LZr(_1uePi-bDVr>{^0kJU>K1@N=iyH=L4q_ zdhZm!FyQx5Wq^8Fn}>(TUIvl=hNPYbM@QXD{%EjLX{gI*JO=prKIyPW3^4oXzkT@bd$BmbREoTASH93_*BeB zGI$@Is}z)SNl*M>`bXsTvSL`=aAQJZ9q2VL>gtSwwnZJ3n^xa`tz*_dQn{X;mzS(Y z+)d4xUx0RN(|T>(wy$DdaZ3F82c&ggqUr5LFKpWag+xWQ&WNX{o$HW%a^cc-In(!v zuWZ{S?|;|JRSZa=-oHZf75Il14`%QYp+Nr5vHn4*5q3%>(lkEvvZZ4`n8cbfK$`LR zvF{1N_hkb4A{Q=8aT>o`lG^Q9zt}wMM+32f2k_DAv+3Gf$!SR=Cax6vUGiHG^RMU^=hf_3PbBJ+YPI2uyoj=4MuVH8b#3 z^mZg>>%4HP$~~iDifP ze0%G++t22$7mWWt^q2(_rQzY>3TXt!yPhetF!)qjzne?mhucu#R+o5Co~%35{KFLv z{q20!Fa)n|O0Qr+s7Bd15X!ubl6KLj2Te`sp&cycHcjuK)AFpWn_&tXIf`JMvleNqy9yoT!e;^9djEs7Lxi z{@e$BBcn`q&f7H>No;a|A@IE(0&|QWY?#l^W`Wu$^j_$!zDS~f{dy#nW@%|fuLf-f zDnS&PB8uXOuSB6+!yf2`y@@D2SnsbqZLu~EzUO^aBEtaTwC4-!s#%3nm4D2|5{@uA_yYpC_ zxc@?}et%965-$@I6U+q;MZI8fG*{ulk4($#bQw%e+}V-rDZTZ}Z{O~`&sBKLzYPoC zUybPQ));Exf&`V;@8^35s1M;v^I5Is;9wdO${hY?Q3i8S{+nMfK^OuB-^csJ^;+!c zc^x!>6%VfjcZoYoJr0sAbgV-oMjh86FlO_&{)FpGl!65E=Dpc;9S;EhHmf$=2(+?d zF_=wk^j_dRckWzVYHHR#vB7!du5TFv-t_{8wQV5q^71;=TiHX_*xBD_9Kt+jn`34? zo`#}-eQk2D_g+wSxyy~fc#fnLOzkH!f~N|&KuVo4-H&TPtSg6IB93chlPfmPf+m66*RELd^RG1O*&YgDzU7!6E%NEPY=>Z zwzn&nmX%S3-&^d8P|hl zNqcmv*r89p6(=js9rT3R1>z%SRA@T?nsMfh8XXG@CMZ9u58SktXHP+eZ9($X4{`q> zjeE5UaQp(L#{@#loR*gd8tonn|0i@oN(aeEEJSPWXE+=#?b)+<^C}DI)aS{wNtO_x z>8-#7BO6~l7Ca*kj*Ms~GKpD5hTp)3pZY*OXDdI7jD(6h^xf}WtI5aG9)LTG@BY5_ zSis0CI5|2kjgDH?L<95a)tVe4nNbZ%L<6RMwczJ9DD=`M_C-Q7^%Q|5GLlR;XLbMS zVb#Z_#>I6#g0o5bubMn%LK0m7_}Xt>ZEd8S9CH*o+)@7`&Ks$&*{c?~VFls3=}9!f ziM9}l0}p5J?LwaN+MhB#8734H!1Nc~JA+dBc4MQX7c(vG-a$HVB^|BDEiVsRO`Llb z3Ged#tyGaiBr14A32PmfGPf(6gh@-#H@7B7;>jQ73kh^o)0=_0Dj^m-d(;3k@a?2d zxyc$$ZABeTpvdgj1gLY;bYe4ZLr3iYwwNPxm8_Q3gn#~=bH$^7)UNCr-ra2f*g+1` zO{h|*94|j>iyq7^%#gkl7(!WSCJB|U61(i)SSXOI>v4yid;Q;wVgF~9D)8SG#r|E@ zE8PXS!R(hv2dYO~Oloj7<}1@WkAm56qX*g7`EwTUU4oA1;_fzIr1vqqPlKUL`DfAY zU;Bx84Xbztk0#@Dlg0P}8}H9+kfaPVpK$@I`zNf|g3fxNvLv9deM9S&hJECJ0NRk5 AZU6uP diff --git a/lib/gui/.cache/icons/reload.png b/lib/gui/.cache/icons/reload.png index a6082ca08a75ba7486a06b62e560867a016f1f95..1677233c7f7b4d6a5b45db6f1a17a52f506bd1dc 100644 GIT binary patch delta 5369 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 diff --git a/lib/gui/.cache/icons/reload2.png b/lib/gui/.cache/icons/reload2.png index ba57c1dae17517d6cd49d0d73f178c2d1bd667bb..b10fefe757ae9b5fdb8d0c50a36e7c143441bfa4 100644 GIT binary patch delta 5341 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 delta 12358 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 LtbXj^9s7R(P?6kw diff --git a/lib/gui/.cache/icons/save.png b/lib/gui/.cache/icons/save.png index 09e3cb3bf198477a28f8c1faae2c7197bb19ce7b..25b764a93a6c15083d147626e07f00e168348130 100755 GIT binary patch delta 1374 zcmV-k1)=(kD(D{}iBL{Q4GJ0x0000DNk~Le0000$0000%2nGNE0AV$vd$A#&3V#I? zNklx({>D27<;1Jb^kpzz>>KA1>_7(ohEs3lk*5L#hRN}=PiRO7P&NF>rD43tvD<8ids1qI;q z`Nq7SNF<2GVmQuym8BgA9gTvN1b6NR7!F|zaA9l!lMt=-P%6Z*jzl7a!(k$k2!>%i za9t^dWmz;gH&a(vH+m(m3xB3L8iXH!ZNT>h1yD#4Lg2bC=g*(#?%lhi4_DJ98jT`^ z$X?Ot5XynC)7Ieu2(no1nXoM6qDthVN<=W2TVdA)DG2V|jlKSv!C&X6A#5rM0LIcs znE$~?JoIr3zNa_lUO^hdyf=4YHLT5qa5B?G7A*jzMELy(e*lK*gMXYLSQeqZ`|+*c zF!<}@v|_9;ZU9z&`yYhQhmPKxQvbOqCE0g_|K8o7 za#)kj+ygM=oJ~UnrGH4wzQ#07)~#DNrZW@>1PU5|$P0iFVkqU`;fhcwL|a?ixbASK zD?DQWq?Gu4zG)AL*tU(|@5i#NnFY|-*T?ql+h;T+HZuUyZH-2wbar-@{MsmI0J5S2 zlBu9WQ7F$u0&IKI4%5V2p%lWlDcg52KksZR7LBHfj>o5G1b>Kef|fJpI#2ls$XrX$ zBAHv>pa;MM-~sRecmQ%WgIXhO8!Wr%7C>2CR7k|pj+6SFHvr4Rx!gsv_s^nP{*EcG zi&^tH#`5K|ePKrWdBejn}aC#bGoR?w(S z-aQ@+a_0269DjW8@YDj3QnF^vT9!S!yy$xJ>b0v=Zw8Cn22AqiJROj41z(-`l9raE zB$Eo6WeaQY9MP+zT*w5s;XxP0L%6f4)3D1 zwQbzvZLwI4&6_t-Rkd_>O@Tdo_pxX1KF=ANvr6#X&woGBezKjUQWM@H(^?~?ps}%$ zjn8gkV4$BbK0i)F!y2A@{)N&3pp?t5aF`1hE={WZsZ-z3d;JEV9s7hshmTNIwV19; zT`Z_rSQ=A6N|PrV>L*>kzOEL4+S=56&Fa;ZmoF%-DbRBCQ^Ie(jmlvP48|E&*VF*; z(#wrp?ti*S<>Dm>X_OX#ve0~7J@ZQFErcMpAc&YeZwzBT&Zpfh2R{OkICAfz9jo})A1UlX_kSj>+9 g56DF<;LiU800b-gcfzhQKmY&$07*qoM6N<$g1s4pBme*a delta 2700 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 delta 2690 zcmaDY-=wA38Q|y6%O%Cdz`(%k>ERN@z`*#8fq{vEgAFKBaKcY@qvAZSdM;g07srr_ zTW{_-20d~RX?U2Tm41M`fHS{A=f=ZZ$vx6KH!6Axd9B3`@a|w?mniN$)1~D;S8&7R z3De(4_eXuc^zFW^?q{9Tt9Tfs0zyOcxW4{MWMME-JzqTCGm+ilj$j$Xk=PrI4tH8* z5sX6j8;k8n2Ss_R(Tw37!>zTgOQct!UOZfYIV|kjE%i-`k z=>1B7;T08BEU#> zxWjxD6m8!f3PEvd8vu&Y!hFqlmGjh*ydnVd0z*C6TM+g@FzdmtK(bjEK1<7F7!W{&4JzhT8gY_UEF0gAAJYpon$IE!27uh{P?SxH04I-;m22J01 zjtM&hgH8Da`}Q^lhoZQn%nciufgWezux3yIX0JC4aDF|y00tkT2d)8X1`a;l4yZgn zK3GXT%uzrd!c2$@QTPgP+!zpEAfy^y9%!J$a#6PrTUXtYW^(BLba1UANA>1YXK&RD zFk~4>`12?#u7AXcl8g{34-yA>`Bf_on#yvf7dtapy?poYq(IBu^F7OqfxOi1Q4)uy zeg-8qLuHo# z4XhWkx6EDLv1`Ko_kYjaYm0l(xa_Co?giadybJBm)nDp2W=LFqRbb*jHT`!D8!q3- zotyTEPvMP;Q0c?M4yG2_?Ve{O1eiaf?X;@Du;UO% zh4lBkS&HvCpK~g_;SgdF#~oZmgw}%TT*?oOcV9guV3lGrck4E{TR(0!Y$$d-^Zwm@ zo~6v&PXBCA4|mV`UwR|^Q8sh^p>wU$+h&xX|D9gOQ@iTGyv=s^W_Iw4t>-n>}TjuuV zd@FhXpzPgeznyk#Oa;pBT-(|7d`?nrM6$vghtqd9INsP;_;_0W*~SgRnUC4BdF#Q+ zBIt(R^R|YqY=`_<{z*yr9=|b*IgwrF4=`fRMoZ4yY|3`1FItkf z3)xcBVO@8o{95}~)&Lm-Ou!f_zvpTi8lfL4zliUxR!_sll4)P*UdQJ%Lxg zcCIeU-xYsjtymkw0!h!Ok<&ttF))N1R*3SW1OQ1Myx?8W#&GLu_S_1IxthRcC4;A{ KpUXO@geCw9gVnwO diff --git a/lib/gui/.cache/icons/save_as.png b/lib/gui/.cache/icons/save_as.png index 02de122a84c5261c96aa1616ee786ccdbc5475d0..99d014eff9a264ec398033fc34ee6715ad84f63a 100644 GIT binary patch delta 2465 zcmV;S310S#MWZMoiBL{Q4GJ0x0000DNk~Le0000$0000%2nGNE0AV$vd$A#&3V#Vs zNkl=z^b|_IQG~)yVF-$s@x3Pd|M*#yGqY0wb(S)c$ z z4&j;E%8x9~j`{QF1E5aJN0~4Yv!Q|JHLD4{^*X>%U}sklYyg-hR=^(;-w}XdFg(M+ zG))450G4GX6@bg->h$|yFo@spN7GtOmZfP}J|9RDo;&6OL_?ScRCf-b2hFl9drQW& zZfIzrq@;v~h6WrCN1N|W(|^S2bh2#OGP1L?+rNmegDkfP;lIEv;FY8TNM;j4pzAts zzx_6+PMvChy2>&>pAR8K#}{fJ!T?}<*gLd=Af>856HX`6putFk1|vM4_)gY!kOZDP z=Av9SzNNQc7{a780)S)a1@wF9QTkU@;u2!DTzbdf0w07*i) z-3Ye|QT8A%2u>&I=bDzyPv7MNyeOT@_PI+i2k9U>sKc!yD`3m};- z!X`vP*nD;Xh=L%G$h04avj;#Jf^aEF1J6r+FMtpr%b`t5;%%BH7)I&^LNPW_*D+5V zN4Q*AzUE%dpHs3Kb$`eBZs~$h!fI$|9{@W!I}H_-6mk0&vMiIAm)B`Bq^fFs$yeBx z2std(A&2^s0YC_0Z;21MNqTxZRaI48ro)k`aHsdDC}3I`pMQ>CdlV!G^6=pxIT8mT zNfIuX>$Dd{6h*=9cH?w9I~T927{`v${M=vB57l59x}9X<_J4xY3%al80P5=MC@d`O z(~?-n;yvhx-ly@&6_^ceQ)^4pKsyDJ42ri00K!^*J|BDc?oGY$!Q(-zuBP#cZnl2~@u8>24E z;BcTHIYQG@D}QbA*Nh*DLz4LWYc)x23q zLSPssFTHW7{Tbb(SUQC^;dY}PJV;|@g>CRA=Vh{XVF8LmLPD^1;SJ2bDlbU@QrzH8 z$QjPsg$1ZiCjresGYkyFprUjdw@<$;>H^Z43!pvmlYjFvS-<#(uz0=I))^*Vm%_M9 zhD8Mxo)pdn;uK$a#idk~Oh=X^{F+WnY$|~MX(}(gR!!;hEfM0qUO;V^gl0@Vd`fX0HLY`_qKJy=eRQ(X_hKE|AQa zn9ZttXJMHZT11tH@U-9bp93se{gNHMx+h}Q}QxddDjet1;J?XUN_tJ)Ua^XKkPo&*nbIlif_TvwU7(#HUOs+?a=!ewIBCE z_-lde1O)yUU$CeBl)Oxy3A?&TgV!Q{`x`ZsF5iMb5Q62NG!=FBtyq#kM8WD(k#H(# z2M^%i^>QCgeh1Kc)7Oj}$>VolYrDE=@!Q_0VQ!@OUr)nRG$%Cjx*oI2l(KHn=~U1p z7k_BY5T3C&P0i)8vXH^^M_K!R9v55pyhquJt+C>@V4G>*V*sk##dFVXBqQU(ZdYY` zQf2DbuOENe@GOr!QepQOUpa<~(&^~Bg>D!z;-7hWFH4@@iD8)C79TeNNs`FP$>qXf z7xn7sZ{6}dm5)DZ@85JqF3ab(iYH3^ntz>pS+shGZS7r?uEbM3CzL98QLU2VG)cYQ zHf~r?Vc|_Q`}|??mr_wW9V1FSK)M<$9wg+S4G%4*bfZ+4QgOhxt0?|!4g)>dw)a) z*Uw_nlHav^%1k`6+)2iak1{7`&jztodkj*S2wS`j6Xj3C$?{Up=Uv6 zqL08m<@a$<`F%Y;f7h;mapL4R08ASHzh)*cZNaSQg*IB=QE!EZ4$Z|XdW{dQ6FPDtGm^a^S zU0e8H-3baG*ubXW7jyB@!8G~;w)n-pAUNY#=X z`DNCHRBn5XMG-0O!^`ex>3?tkki0}Qk&{P^7{TB{=Tm>;WRF5#NfLJG8VzCgLz}pI zY!+2J_xGCkBmnq1`}XhS%P+o&SELe$JEuI;^`OPwD<2VFH5@k zqU9tgjbDC|F}*LK>xtg0v-{?bgi+@aTHqXC#F>uIi96PusPGWrMa0}#|S9=QL0 z1`PNSMpU7)FPow$96o&5-q-fOg?XYrv=kN_XJdT!Nh=V-Kb}5IpMY;<;9J1S599v_ f#M7L>xBnjiXgh80lRw9P00000NkvXXu0mjfk?^Ko delta 6182 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 X+{jQSC{2RVKWd+~omIiFM=ty~KiK(U diff --git a/lib/gui/.cache/icons/save_as2.png b/lib/gui/.cache/icons/save_as2.png index c98ec3987dd50c48285408385117bc2e2683b24e..07cf750ad4666e8ee1106b89acac7f32bad9c939 100644 GIT binary patch delta 2466 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 diff --git a/lib/gui/.cache/icons/settings.png b/lib/gui/.cache/icons/settings.png index b2fc465e37669416e42f5eedebf403b098e23a8a..874fe42cb0c32ba5e9d31f684f9dd2d0099d711a 100644 GIT binary patch delta 5329 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 gxSKwfKO%ve9;h1w)>y?0@vQQM~hhfI5ZO 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 delta 12705 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 U9d5}WPyhe`07*qoM6N<$f<#3X*8l(j delta 23899 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 diff --git a/lib/gui/.cache/icons/start.png b/lib/gui/.cache/icons/start.png index 1aba1c2fd936bddf56a88fa4d8e27fabfdb49bb7..5923d35d92e797f9f4a510f608bf67221f39e977 100644 GIT binary patch delta 3050 zcmV0GUu!q8a2NC=pAYqQQdiDoV>#VVDjG z)hY$11KMI09Htgf>!XEgP!OpURD=L!NT?XgBML;IhDX5gxXFF(p8j$6LX3oxaBql1 ze>3~X?%jL#e1GS6e(xiQFZqC6;D1^m59kS`1F3+!!Mh=#8mIzx1D^xifE|FJ?-3;d zMZjyoUO=7e5)CdIzxFy~6**(altc{2>H+)=7zK1Y^MM36GQB+_s}thNROpz3Xp;yY z4=CllBp?_>ovei;HK?zvQI&_WtB#@SLiN9~7bph)1bo?A7|{L;Bn0AyAt%wg9euImjQ+iJk!2w3*n)xO}F2{0tMOwEuZ-=MbcB(ioJcIDyd zTQ$IQ&gE}O1`Gn`0apVU*?(ymcML@2_d@x+5Q;#=itT1K4RD(Xe*jgw3+tT{tbK=~ zzkUi#1U58vSfd$JnF725bP-8jT%&Kq`1y|^CsT`dC<3;My=t`;ge@%n26!7N6o7gA4amEPpW1PhUyg{Z5;-hB{YD1WdPIN0!2d5*s)I_A!0 z=4K-ADMSUsP50ygq(Ic1L>N*#GA*bRR4|OZrw}tY6TszQO1uyWm@2XV_MO zs_}b(gk^{pMBdw?0Ooz~hR~hl626K`!!&1b|Ej^BD$=0+4;Op;OzqD<>fY6DLk& z`kxw))a zvxYfy=HN^8VSWA;p{HJkVM$D@2>5Xf`-iI(a|sj(+c-pz(T|=D(c9DomneDOsc z!ltEQ-ai~MAP*I`nh2iyp?=T=s(%%vsgtJ>vh{Ib_3G73nly=ByLRbSAKDjr-z})r zWZgW`RNW;7>g4IDzOOccLqHzYHLYqJoIZV;nKNhd+}ycXwvB9m1?K&Qh{0FuR9$n^ z^g=*&P4w=N2~+^X)WK?`Y8{qv!-fq^m@t9u+qY{))xS5!gFi&2wMW$jFMrBn2?h4S z>ga8S36ulam4`6`LCv8{WD)~`0JCS$=9xKj2!=w4ppVBNZP2(Js*{eQzSpZX~(D_vKi zV;z{81|0rIW2}cc8d$FZ_S>iGGS!wHaQmRRWrp_b*~6qslUTWOB>+ZFXUwr9AU8|f z^>}5ZJucLi9oVPp0QNhW&t!%D=uQf(wPgr@T}*TIZnuuU7A;yt|Ni|octheEb0g-A zhfqB;QT{+oYZ2deet#swUx&4}Ec#@rgZW#Pp#?ylwf`t;>&}=~YpSZMc=+Lm88>bm zM~@!G=$3|i`UA*&Z$TMGOsi1m&b7HwTX$mZKMGLiV5R;R0H_3(01sKqN-+EN(p_LN zs1Ftw7oSzcBX2|Efnde}V_KYBQF0eoi?U&+@0i`zIW!7;5kR8snO)bfdG? z425VI2!9=F*M7_!`(jSI6V=x?0qnt$!c|;-nwiUZF9!WX-{l2&J%Qyh>!pe$WRn3V_=4NdQ0?-F*Cjee{GO z!y)8#y>uU5lMOzQ;ML5Fmxgb|=NDc#_G=#yDfxF4(XCVzA%#ntv_dyySeoZ4Z_2TDyM zpBL8si||V;b>0A81*SH-Q~t+r9{~NaRX4kIH%6xv=$Y9%dW6pv>Lw*<@bll%sWSKw z-A3Q+R#^+(a9n_`vhC8{7(Oo|x2yKmTT3e4kh;v8hh6P z*!2}g_bYWriEX#MG#C;RJ&4LfM4o?}$olf=FRuXiae-%uniBU$(EW9_z+iQx#TE2nsg5}X1Uq29d!clk^tf?>iLsS1hHW!1nxoi(k sHl*+6(b;&=WqWwKF+I_$kI(;i019F61v|rK)c^nh07*qoM6N<$f`h%V-2eap delta 6404 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 z z{%w^cb%2CutH=44evh9|p8dRQ$|e<1epK5Q5_Vv4C8+IX)dCaBG`I?2H-Yc?z=dG` zBm%ZV2A1bs3{WS4GNO(s!+>HcQPxB(VJFMXC?(-dLMiP~;YMsZ9#J(YB^e{ieB;ml a7SOKQeq-&Z75~ES17<|dnW~$dl=(lpv699B diff --git a/lib/gui/.cache/icons/stop.png b/lib/gui/.cache/icons/stop.png index 9bd2038f1acb39563621d404b51895a1b0fc0874..ee7e590ac2005b54b3de9400a4a0fe3c334c3a5e 100644 GIT binary patch delta 2273 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 diff --git a/lib/gui/.cache/icons/video.png b/lib/gui/.cache/icons/video.png index bd56560383c7bf4e255fd0e89f084d1c16222aa7..1851c2a0ffc4790c7deaec67b37d436958f87dd1 100644 GIT binary patch delta 1246 zcmV<41R?v@BL&2*T6Y*9W5L)PIV|D!zoym`PoSC-=li64^6D<^8$@@acA#XDC z{meXXp6~O#LjZmN@CX2*w+09BntuRP0T}4bQPTj_02tY9*3)&}54#h5-^bD6q2?1I z_R+_nzCOU`V#rVkywSkr@4rFe97DsyuA(TRSDrDZoSdAfEdT(3_4Rek&dw6&okg9U zozeIw4{>T&k^Jf@l%e+l*A)YRrolgYi^|tekuj4f7K=16Fwjv55z+ScHh&ft7OuY9 z`oJ_zHI+(*2Mp&RB7g(jaljc92Y}~+I}X4BB0?sUQPb&k=ycOG-`za#e#47AhAWEL zTa0e!+OAbWj}Hkz0+0YC00}?>=;0A$f`(y4CibRjf{287j6?*}G$XYbhJlvn2>>iF zFMGLM&gYzW6v-HaWmjXQs(<2&S5;Vc6^o0DuCD80fOxrFR$AHv09#vI+N}=Kmk>Ch z6|hDf8yg$3JBI)OuURCBy3N&l653vuLI}0A1OV37*4&AS2|vsZUtCtQGfpNIR^VPC?6ak-dq$r zjbgEg>FMd~AyBPWBM)L(R$zz8y99ZTFaW?+karo_Vau|R&1OTVw`-M&kOUwBNB|Om z1Rw!O00?U)wnie6z&&UVgNTqwME5V-wFa4BsZ?TyVMMmZT-Vh_ZjloaT-W8hySpr^ zHC8H>0y`W4$baYa8oD<}Dc+}xTiww#oL^kf+}vEOi*ckIcL45B=l516k~`bs`T2Rb z+Xjiv&7WePC>FTG@O>Y;9>?RyUtD(EAko?n5!m6KogLn^hzJAlAeW=l^9mk3`380)PDrh@nzdK-IwmLZQGGrnp$NZ zAOT1K5`Y9C0Z0HnK7v$O?Xm}0)3i1>LEu4NAwcpIoX^d3?PwI*v<5S6+x9HW;$hid zrBWfzxk?1+F%HBC2oA?Q8_(N!i`sZ=~73bn({Oi{h&1#a``?BU^&rjU=+2P5z( zj=Q%a|1-Br095Y4`uhMrS1>$0blXQ~kLAPD05;IWkI(-z0Mu&V?q1Nm@c;k-07*qo IM6N<$g3t6pasU7T delta 2001 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 delta 15842 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 bS*Zvx7M5HxspCt8ivaGj?xlQHtB3y=^29g^ From 46309771bbddd202267030f42a6f067294f7a626 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 29 Nov 2019 00:46:27 +0000 Subject: [PATCH 157/981] plugins.extract - Create ExtractMedia class for pipeline flow Bugfix - Fix memory leak in extract --- plugins/extract/_base.py | 59 ++++----- plugins/extract/align/_base.py | 96 +++++++-------- plugins/extract/align/cv2_dnn.py | 8 +- plugins/extract/align/fan.py | 28 +++-- plugins/extract/detect/_base.py | 72 ++++++----- plugins/extract/detect/cv2_dnn.py | 2 +- plugins/extract/detect/manual.py | 2 +- plugins/extract/detect/mtcnn.py | 8 +- plugins/extract/detect/s3fd.py | 7 +- plugins/extract/mask/_base.py | 84 ++++++------- plugins/extract/pipeline.py | 191 ++++++++++++++++++++++++------ scripts/extract.py | 72 ++++++----- scripts/fsmedia.py | 31 +++-- 13 files changed, 367 insertions(+), 293 deletions(-) diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 7e37a939fa..8cb3ffcc54 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -1,20 +1,18 @@ #!/usr/bin/env python3 -""" Base class for Faceswap :mod:`~plugins.extract.detect` and :mod:`~plugins.extract.align` -Plugins +""" Base class for Faceswap :mod:`~plugins.extract.detect`, :mod:`~plugins.extract.align` and +:mod:`~plugins.extract.mask` Plugins """ import logging import os import sys -import cv2 -import numpy as np - from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module from lib.multithreading import MultiThread from lib.queue_manager import queue_manager from lib.utils import GetModel, FaceswapError from ._config import Config +from .pipeline import ExtractMedia logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -44,7 +42,7 @@ def _get_config(plugin_name, configfile=None): class Extractor(): """ Extractor Plugin Object - All ``_base`` classes for Aligners and Detectors inherit from this class. + All ``_base`` classes for Aligners, Detectors and Maskers inherit from this class. This class sets up a pipeline for working with ML plugins. @@ -96,6 +94,7 @@ class Extractor(): -------- plugins.extract.detect._base : Detector parent class for extraction plugins. plugins.extract.align._base : Aligner parent class for extraction plugins. + plugins.extract.mask._base : Masker parent class for extraction plugins. plugins.extract.pipeline : The extract pipeline that configures and calls all plugins """ @@ -139,6 +138,10 @@ def __init__(self, git_model_id=None, model_filename=None, configfile=None): self._threads = [] """ list: Internal threads for this plugin """ + self._extract_media = dict() + """ dict: The :class:`plugins.extract.pipeline.ExtractMedia` objects currently being + processed. Stored at input for pairing back up on output of extractor process """ + # << THE FOLLOWING PROTECTED ATTRIBUTES ARE SET IN PLUGIN TYPE _base.py >>> # self._plugin_type = None """ str: Plugin type. ``detect`` or ``align`` @@ -236,8 +239,8 @@ def finalize(self, batch): """ **Override method** (at `` level) This method should be overridden at the `` level (IE. - :mod:`plugins.extract.detect._base` or :mod:`plugins.extract.align._base`) and should not - be overridden within plugins themselves. + :mod:`plugins.extract.detect._base`, :mod:`plugins.extract.align._base` or + :mod:`plugins.extract.mask._base`) and should not be overridden within plugins themselves. Handles consistent finalization for all plugins that exist within that plugin type. Its input is always the output from :func:`process_output()` @@ -253,10 +256,11 @@ def get_batch(self, queue): """ **Override method** (at `` level) This method should be overridden at the `` level (IE. - :mod:`plugins.extract.detect._base` or :mod:`plugins.extract.align._base`) and should not - be overridden within plugins themselves. + :mod:`plugins.extract.detect._base`, :mod:`plugins.extract.align._base` or + :mod:`plugins.extract.mask._base`) and should not be overridden within plugins themselves. - Get items from the queue in batches of :attr:`batchsize` + Get :class:`~plugins.extract.pipeline.ExtractMedia` items from the queue in batches of + :attr:`batchsize` Parameters ---------- @@ -425,40 +429,19 @@ def _thread_process(self, function, in_queue, out_queue): out_queue.put("EOF") # <<< QUEUE METHODS >>> # - @staticmethod - def _get_item(queue): + def _get_item(self, queue): """ Yield one item from a queue """ item = queue.get() - if isinstance(item, dict): - logger.trace("item: %s, queue: %s", - {k: v.shape if isinstance(v, np.ndarray) else v - for k, v in item.items()}, - queue) + if isinstance(item, ExtractMedia): + logger.trace("filename: '%s', image shape: %s, detected_faces: %s, queue: %s, " + "item: %s", + item.filename, item.image_shape, item.detected_faces, queue, item) + self._extract_media[item.filename] = item else: logger.trace("item: %s, queue: %s", item, queue) return item - # <<< MISC UTILITY METHODS >>> # - def _convert_color(self, image): - """ Convert the image to the correct color format and strip alpha channel """ - logger.trace("Converting image to color format: %s", self.colorformat) - if self.colorformat == "RGB": - cvt_image = image[..., 2::-1].copy() - elif self.colorformat == "GRAY": - cvt_image = cv2.cvtColor(image.copy(), cv2.COLOR_BGR2GRAY) - else: - cvt_image = image[..., :3].copy() - return cvt_image - @staticmethod def _dict_lists_to_list_dicts(dictionary): """ Convert a dictionary of lists to a list of dictionaries """ return [dict(zip(dictionary, val)) for val in zip(*dictionary.values())] - - @staticmethod - def _remove_invalid_keys(dictionary, valid_keys): - """ Remove items from dict that are no longer required """ - for key in list(dictionary.keys()): - if key not in valid_keys: - logger.trace("Removing from output: '%s'", key) - del dictionary[key] diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index 701fbdde75..e6dfc8bb1d 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -4,16 +4,11 @@ All Aligner Plugins should inherit from this class. See the override methods for which methods are required. -The plugin will receive a dict containing: - ->>> {"filename": [], ->>> "image": [], ->>> "detected_faces": [>> {"filename": [], ->>> "image": [], >>> "landmarks": [list of 68 point face landmarks] >>> "detected_faces": []} """ @@ -22,10 +17,10 @@ import cv2 import numpy as np -from plugins.extract._base import Extractor, logger +from plugins.extract._base import Extractor, logger, ExtractMedia -class Aligner(Extractor): +class Aligner(Extractor): # pylint:disable=abstract-method """ Aligner plugin _base Object All Aligner plugins must inherit from this class @@ -47,6 +42,7 @@ class Aligner(Extractor): See Also -------- + plugins.extract.pipeline : The extraction pipeline for calling plugins plugins.extract.align : Aligner plugins plugins.extract._base : Parent class for all extraction plugins plugins.extract.detect._base : Detector parent class for extraction plugins. @@ -64,7 +60,7 @@ def __init__(self, git_model_id=None, model_filename=None, self._plugin_type = "align" self._faces_per_filename = dict() # Tracking for recompiling face batches - self._rollover = [] # Items that are rolled over from the previous batch in get_batch + self._rollover = None # Items that are rolled over from the previous batch in get_batch self._output_faces = [] logger.debug("Initialized %s", self.__class__.__name__) @@ -75,8 +71,11 @@ def get_batch(self, queue): Items are returned from the ``queue`` in batches of :attr:`~plugins.extract._base.Extractor.batchsize` + Items are received as :class:`~plugins.extract.pipeline.ExtractMedia` objects and converted + to ``dict`` for internal processing. + To ensure consistent batch sizes for aligner the items are split into separate items for - each :class:`lib.faces_detect.DetectedFace` object. + each :class:`~lib.faces_detect.DetectedFace` object. Remember to put ``'EOF'`` to the out queue after processing the final batch @@ -109,26 +108,25 @@ def get_batch(self, queue): logger.trace("EOF received") exhausted = True break - # Put frames with no faces into the out queue to keep TQDM consistent - if not item["detected_faces"]: + if not item.detected_faces: self._queues["out"].put(item) continue - for f_idx, face in enumerate(item["detected_faces"]): - face.image = self._convert_color(item["image"]) + converted_image = item.get_image_copy(self.colorformat) + for f_idx, face in enumerate(item.detected_faces): + batch.setdefault("image", []).append(converted_image) batch.setdefault("detected_faces", []).append(face) - batch.setdefault("filename", []).append(item["filename"]) - batch.setdefault("image", []).append(item["image"]) + batch.setdefault("filename", []).append(item.filename) idx += 1 if idx == self.batchsize: - frame_faces = len(item["detected_faces"]) + frame_faces = len(item.detected_faces) if f_idx + 1 != frame_faces: - self._rollover = {k: v[f_idx + 1:] if k == "detected_faces" else v - for k, v in item.items()} + self._rollover = ExtractMedia(item.filename, item.image) + self._rollover.add_detected_faces(item.detected_faces[f_idx + 1:]) logger.trace("Rolled over %s faces of %s to next batch for '%s'", - len(self._rollover["detected_faces"]), - frame_faces, item["filename"]) + len(self._rollover.detected_faces), frame_faces, + item.filename) break if batch: logger.trace("Returning batch: %s", {k: v.shape if isinstance(v, np.ndarray) else v @@ -138,20 +136,20 @@ def get_batch(self, queue): return exhausted, batch def _collect_item(self, queue): - """ Collect the item from the _rollover dict or from the queue + """ Collect the item from the :attr:`_rollover` dict or from the queue Add face count per frame to self._faces_per_filename for joining batches back up in finalize """ - if self._rollover: + if self._rollover is not None: logger.trace("Getting from _rollover: (filename: `%s`, faces: %s)", - self._rollover["filename"], len(self._rollover["detected_faces"])) + self._rollover.filename, len(self._rollover.detected_faces)) item = self._rollover - self._rollover = dict() + self._rollover = None else: item = self._get_item(queue) if item != "EOF": logger.trace("Getting from queue: (filename: %s, faces: %s)", - item["filename"], len(item["detected_faces"])) - self._faces_per_filename[item["filename"]] = len(item["detected_faces"]) + item.filename, len(item.detected_faces)) + self._faces_per_filename[item.filename] = len(item.detected_faces) return item # <<< FINALIZE METHODS >>> # @@ -160,49 +158,42 @@ def finalize(self, batch): This should be called as the final task of each `plugin`. - It strips unneeded items from the :attr:`batch` ``dict`` and pairs the detected faces back - up with their original frame before yielding each frame. - - Outputs items in the format: - - >>> {'image': [], - >>> 'filename': [), - >>> 'detected_faces': []} + Pairs the detected faces back up with their original frame before yielding each frame. Parameters ---------- batch : dict The final ``dict`` from the `plugin` process. It must contain the `keys`: - ``detected_faces``, ``landmarks``, ``filename``, ``image`` + ``detected_faces``, ``landmarks``, ``filename`` Yields ------ - dict - A ``dict`` for each frame containing the ``image``, ``filename`` and list of - :class:`lib.faces_detect.DetectedFace` objects. - + :class:`~plugins.extract.pipeline.ExtractMedia` + The :attr:`DetectedFaces` list will be populated for this class with the bounding boxes + and landmarks for the detected faces found in the frame. """ for face, landmarks in zip(batch["detected_faces"], batch["landmarks"]): if not isinstance(landmarks, np.ndarray): landmarks = np.array(landmarks) face.landmarks_xy = landmarks - self._remove_invalid_keys(batch, ("detected_faces", "filename", "image")) - logger.trace("Item out: %s", {key: val - for key, val in batch.items() - if key != "image"}) - for filename, image, face in zip(batch["filename"], - batch["image"], - batch["detected_faces"]): + + logger.trace("Item out: %s", {key: val.shape if isinstance(val, np.ndarray) else val + for key, val in batch.items()}) + + for filename, face in zip(batch["filename"], batch["detected_faces"]): self._output_faces.append(face) if len(self._output_faces) != self._faces_per_filename[filename]: continue - retval = dict(filename=filename, image=image, detected_faces=self._output_faces) + output = self._extract_media.pop(filename) + output.add_detected_faces(self._output_faces) self._output_faces = [] - logger.trace("Yielding: (filename: '%s', image: %s, detected_faces: %s)", - retval["filename"], retval["image"].shape, len(retval["detected_faces"])) - yield retval + + logger.trace("Final Output: (filename: '%s', image shape: %s, detected_faces: %s, " + "item: %s)", + output.filename, output.image_shape, output.detected_faces, output) + yield output # <<< PROTECTED METHODS >>> # # <<< PREDICT WRAPPER >>> # @@ -214,8 +205,7 @@ def _predict(self, batch): def _normalize_faces(self, faces): """ Normalizes the face for feeding into model - The normalization method is dictated by the command line argument: - -nh (--normalization) + The normalization method is dictated by the command line argument `-nh (--normalization)` """ if self.normalize_method is None: return faces diff --git a/plugins/extract/align/cv2_dnn.py b/plugins/extract/align/cv2_dnn.py index c9308c1f96..26b2afff0b 100644 --- a/plugins/extract/align/cv2_dnn.py +++ b/plugins/extract/align/cv2_dnn.py @@ -51,18 +51,18 @@ def init_model(self): def process_input(self, batch): """ Compile the detected faces for prediction """ - faces, batch["roi"] = self.align_image(batch["detected_faces"]) + faces, batch["roi"] = self.align_image(batch) faces = self._normalize_faces(faces) batch["feed"] = np.array(faces, dtype="float32")[..., :3].transpose((0, 3, 1, 2)) return batch - def align_image(self, detected_faces): + def align_image(self, batch): """ Align the incoming image for prediction """ logger.trace("Aligning image around center") sizes = (self.input_size, self.input_size) rois = [] faces = [] - for face in detected_faces: + for face, image in zip(batch["detected_faces"], batch["image"]): box = (face.left, face.top, face.right, @@ -74,7 +74,7 @@ def align_image(self, detected_faces): # Make box square. roi = self.get_square_box(box_moved) # Pad the image if face is outside of boundaries - image = self.pad_image(roi, face.image) + image = self.pad_image(roi, image) face = image[roi[1]: roi[3], roi[0]: roi[2]] interpolation = cv2.INTER_CUBIC if face.shape[0] < self.input_size else cv2.INTER_AREA diff --git a/plugins/extract/align/fan.py b/plugins/extract/align/fan.py index b44fb73be4..a4924bf432 100644 --- a/plugins/extract/align/fan.py +++ b/plugins/extract/align/fan.py @@ -54,7 +54,6 @@ def get_center_scale(self, detected_faces): """ Get the center and set scale of bounding box """ logger.debug("Calculating center and scale") center_scale = np.empty((len(detected_faces), 68, 3), dtype='float32') - # TODO modify detected face to hold this data as a matrix for index, face in enumerate(detected_faces): x_center = (face.left + face.right) / 2.0 y_center = (face.top + face.bottom) / 2.0 - face.h * 0.12 @@ -79,19 +78,22 @@ def crop(self, batch): # pylint:disable=too-many-locals # TODO second pass .. convert to matrix new_images = [] - for face, ul, br in zip(batch["detected_faces"], upper_left, bot_right): - height, width = face.image.shape[:2] - channels = 3 if face.image.ndim > 2 else 1 - br_width, br_height = br[0].astype('int32') - ul_width, ul_height = ul[0].astype('int32') - new_dim = (br_height - ul_height, br_width - ul_width, channels) + for image, top_left, bottom_right in zip(batch["image"], upper_left, bot_right): + height, width = image.shape[:2] + channels = 3 if image.ndim > 2 else 1 + bottom_right_width, bottom_right_height = bottom_right[0].astype('int32') + top_left_width, top_left_height = top_left[0].astype('int32') + new_dim = (bottom_right_height - top_left_height, + bottom_right_width - top_left_width, + channels) new_img = np.empty(new_dim, dtype=np.uint8) - new_x = slice(max(0, -ul_width), min(br_width, width) - ul_width) - new_y = slice(max(0, -ul_height), min(br_height, height) - ul_height) - old_x = slice(max(0, ul_width), min(br_width, width)) - old_y = slice(max(0, ul_height), min(br_height, height)) - new_img[new_y, new_x] = face.image[old_y, old_x] + new_x = slice(max(0, -top_left_width), min(bottom_right_width, width) - top_left_width) + new_y = slice(max(0, -top_left_height), + min(bottom_right_height, height) - top_left_height) + old_x = slice(max(0, top_left_width), min(bottom_right_width, width)) + old_y = slice(max(0, top_left_height), min(bottom_right_height, height)) + new_img[new_y, new_x] = image[old_y, old_x] interp = cv2.INTER_CUBIC if new_dim[0] < self.input_size else cv2.INTER_AREA new_images.append(cv2.resize(new_img, dsize=sizes, interpolation=interp)) @@ -148,7 +150,7 @@ def get_pts_from_predict(self, batch): (image_slice, landmark_slice, max_clipped[0], indices[1])] x_subpixel_shift = batch["prediction"][offsets[0]] - batch["prediction"][offsets[1]] y_subpixel_shift = batch["prediction"][offsets[2]] - batch["prediction"][offsets[3]] - # TODO improve rudimentary subpixel logic to centroid of 3x3 window algorithm + # TODO improve rudimentary sub-pixel logic to centroid of 3x3 window algorithm subpixel_landmarks[:, :, 0] = indices[1] + np.sign(x_subpixel_shift) * 0.25 + 0.5 subpixel_landmarks[:, :, 1] = indices[0] + np.sign(y_subpixel_shift) * 0.25 + 0.5 diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index 0a2fb72f27..c7ff95f390 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -4,10 +4,11 @@ All Detector Plugins should inherit from this class. See the override methods for which methods are required. +The plugin will receive a :class:`~plugins.extract.pipeline.ExtractMedia` object. + For each source frame, the plugin must pass a dict to finalize containing: >>> {'filename': , ->>> 'image': , >>> 'detected_faces': >> {'filename': [], - >>> 'image': [], - >>> 'scaled_image': , + >>> 'image': , >>> 'scale': [], >>> 'pad': [], >>> 'detected_faces': [[>> {'image': [], - >>> 'filename': [), - >>> 'detected_faces': []} - - Parameters ---------- batch : dict - The final ``dict`` from the `plugin` process. It must contain the keys ``image``, - ``filename``, ``faces`` + The final ``dict`` from the `plugin` process. It must contain the keys ``filename``, + ``faces`` Yields ------ - dict - A ``dict`` for each frame containing the ``image``, ``filename`` and ``list`` of - ``detected_faces`` + :class:`~plugins.extract.pipeline.ExtractMedia` + The :attr:`DetectedFaces` list will be populated for this class with the bounding boxes + for the detected faces found in the frame. """ if not isinstance(batch, dict): logger.trace("Item out: %s", batch) @@ -183,13 +176,14 @@ def finalize(self, batch): if self.min_size > 0 and batch.get("detected_faces", None): batch["detected_faces"] = self._filter_small_faces(batch["detected_faces"]) - self._remove_invalid_keys(batch, ("detected_faces", "filename", "image")) batch = self._dict_lists_to_list_dicts(batch) - for item in batch: - logger.trace("final output: %s", {k: v.shape if isinstance(v, np.ndarray) else v - for k, v in item.items()}) - yield item + output = self._extract_media.pop(item["filename"]) + output.add_detected_faces(item["detected_faces"]) + logger.trace("final output: (filename: '%s', image shape: %s, detected_faces: %s, " + "item: %s", output.filename, output.image_shape, output.detected_faces, + output) + yield output @staticmethod def to_detected_face(left, top, right, bottom): @@ -226,15 +220,19 @@ def _predict(self, batch): return batch # <<< DETECTION IMAGE COMPILATION METHODS >>> # - def _compile_detection_image(self, input_image): - """ Compile the detection image for feeding into the model""" - image = self._convert_color(input_image) + def _compile_detection_image(self, item): + """ Compile the detection image for feeding into the model - image_size = image.shape[:2] - scale = self._set_scale(image_size) - pad = self._set_padding(image_size, scale) + Parameters + ---------- + item: :class:`plugins.extract.pipeline.ExtractMedia` + The input item from the pipeline + """ + image = item.get_image_copy(self.colorformat) + scale = self._set_scale(item.image_size) + pad = self._set_padding(item.image_size, scale) - image = self._scale_image(image, image_size, scale) + image = self._scale_image(image, item.image_size, scale) image = self._pad_image(image) logger.trace("compiled: (images shape: %s, scale: %s, pad: %s)", image.shape, scale, pad) return image, scale, pad diff --git a/plugins/extract/detect/cv2_dnn.py b/plugins/extract/detect/cv2_dnn.py index 7e9dbb6c77..08ce23aa89 100644 --- a/plugins/extract/detect/cv2_dnn.py +++ b/plugins/extract/detect/cv2_dnn.py @@ -27,7 +27,7 @@ def init_model(self): def process_input(self, batch): """ Compile the detection image(s) for prediction """ - batch["feed"] = cv2.dnn.blobFromImages(batch["scaled_image"], # pylint: disable=no-member + batch["feed"] = cv2.dnn.blobFromImages(batch["image"], # pylint: disable=no-member scalefactor=1.0, size=(self.input_size, self.input_size), mean=[104, 117, 123], diff --git a/plugins/extract/detect/manual.py b/plugins/extract/detect/manual.py index 7bd8752ae4..5953e9b277 100644 --- a/plugins/extract/detect/manual.py +++ b/plugins/extract/detect/manual.py @@ -26,7 +26,7 @@ def init_model(self): def process_input(self, batch): """ No pre-processing for Manual. Just set a dummy feed """ - batch["feed"] = batch["scaled_image"] + batch["feed"] = batch["image"] return batch def predict(self, batch): diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py index e60340ce98..13942d99f6 100644 --- a/plugins/extract/detect/mtcnn.py +++ b/plugins/extract/detect/mtcnn.py @@ -58,7 +58,7 @@ def init_model(self): def process_input(self, batch): """ Compile the detection image(s) for prediction """ - batch["feed"] = (batch["scaled_image"] - 127.5) / 127.5 + batch["feed"] = (batch["image"] - 127.5) / 127.5 return batch def predict(self, batch): @@ -197,7 +197,7 @@ class MTCNN(): def __init__(self, model_path, allow_growth, minsize, threshold, factor): """ minsize: minimum faces' size - threshold: threshold=[th1, th2, th3], th1-3 are three steps's threshold + threshold: threshold=[th1, th2, th3], th1-3 are three steps threshold factor: the factor used to create a scaling pyramid of face sizes to detect in the image. pnet, rnet, onet: caffemodel @@ -256,7 +256,7 @@ def detect_pnet(self, images, height, width): cls_prob = np.swapaxes(cls_prob, 1, 2) roi = np.swapaxes(roi, 1, 3) for idx in range(batch_items): - # first index 0 = cls score, 1 = one hot repr + # first index 0 = class score, 1 = one hot repr rectangle = detect_face_12net(cls_prob[idx, ...], roi[idx, ...], out_side, @@ -492,7 +492,7 @@ def nms(rectangles, threshold, method): s_sort = np.array(var_s.argsort()) pick = [] while len(s_sort) > 0: - # s_sort[-1] have hightest prob score, s_sort[0:-1]->others + # s_sort[-1] have highest prob score, s_sort[0:-1]->others xx_1 = np.maximum(x_1[s_sort[-1]], x_1[s_sort[0:-1]]) yy_1 = np.maximum(y_1[s_sort[-1]], y_1[s_sort[0:-1]]) xx_2 = np.minimum(x_2[s_sort[-1]], x_2[s_sort[0:-1]]) diff --git a/plugins/extract/detect/s3fd.py b/plugins/extract/detect/s3fd.py index a0f9188b18..cc516fd09a 100644 --- a/plugins/extract/detect/s3fd.py +++ b/plugins/extract/detect/s3fd.py @@ -42,7 +42,7 @@ def init_model(self): def process_input(self, batch): """ Compile the detection image(s) for prediction """ - batch["feed"] = self.model.prepare_batch(batch["scaled_image"]) + batch["feed"] = self.model.prepare_batch(batch["image"]) return batch def predict(self, batch): @@ -277,7 +277,7 @@ def decode(loc, priors): Shape: [num_priors,4] priors (tensor): Prior boxes in center-offset form. Shape: [num_priors,4]. - variances: (list[float]) Variances of priorboxes + variances: (list[float]) Variances of prior boxes Return: decoded bounding box predictions """ @@ -288,7 +288,8 @@ def decode(loc, priors): boxes[:, 2:] += boxes[:, :2] return boxes - def _nms(self, boxes, threshold): + @staticmethod + def _nms(boxes, threshold): """ Perform Non-Maximum Suppression """ retained_box_indices = list() diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index da15912217..37aed6a422 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -5,23 +5,18 @@ See the override methods for which methods are required. -The plugin will receive a dict containing: - ->>> {"filename": , ->>> "image": , ->>> "detected_faces": } +The plugin will receive a :class:`~plugins.extract.pipeline.ExtractMedia` object. For each source item, the plugin must pass a dict to finalize containing: >>> {"filename": , ->>> "image": , >>> "detected_faces": } """ import cv2 import numpy as np -from plugins.extract._base import Extractor, logger +from plugins.extract._base import Extractor, ExtractMedia, logger class Masker(Extractor): # pylint:disable=abstract-method @@ -47,6 +42,7 @@ class Masker(Extractor): # pylint:disable=abstract-method See Also -------- + plugins.extract.pipeline : The extraction pipeline for calling plugins plugins.extract.align : Aligner plugins plugins.extract._base : Parent class for all extraction plugins plugins.extract.detect._base : Detector parent class for extraction plugins. @@ -67,7 +63,7 @@ def __init__(self, git_model_id=None, model_filename=None, configfile=None, self._storage_name = self.__module__.split(".")[-1].replace("_", "-") self._storage_size = 128 # Size to store masks at. Leave this at default self._faces_per_filename = dict() # Tracking for recompiling face batches - self._rollover = [] # Items that are rolled over from the previous batch in get_batch + self._rollover = None # Items that are rolled over from the previous batch in get_batch self._output_faces = [] logger.debug("Initialized %s", self.__class__.__name__) @@ -77,8 +73,11 @@ def get_batch(self, queue): Items are returned from the ``queue`` in batches of :attr:`~plugins.extract._base.Extractor.batchsize` + Items are received as :class:`~plugins.extract.pipeline.ExtractMedia` objects and converted + to ``dict`` for internal processing. + To ensure consistent batch sizes for masker the items are split into separate items for - each :class:`lib.faces_detect.DetectedFace` object. + each :class:`~lib.faces_detect.DetectedFace` object. Remember to put ``'EOF'`` to the out queue after processing the final batch @@ -87,7 +86,6 @@ def get_batch(self, queue): :attr:`~plugins.extract._base.Extractor.batchsize`: >>> {'filename': [], - >>> 'image': [], >>> 'detected_faces': [[>> {'image': [], - >>> 'filename': [), - >>> 'detected_faces': []} + Pairs the detected faces back up with their original frame before yielding each frame. Parameters ---------- batch : dict The final ``dict`` from the `plugin` process. It must contain the `keys`: - ``detected_faces``, ``filename``, ``image`` + ``detected_faces``, ``filename`` Yields ------ - dict - A ``dict`` for each frame containing the ``image``, ``filename`` and list of - :class:`lib.faces_detect.DetectedFace` objects. - + :class:`~plugins.extract.pipeline.ExtractMedia` + The :attr:`DetectedFaces` list will be populated for this class with the bounding + boxes, landmarks and masks for the detected faces found in the frame. """ for mask, face in zip(batch["prediction"], batch["detected_faces"]): face.add_mask(self._storage_name, @@ -198,22 +187,19 @@ def finalize(self, batch): storage_size=self._storage_size) face.feed = dict() - self._remove_invalid_keys(batch, ("detected_faces", "filename", "image")) - logger.trace("Item out: %s", {key: val - for key, val in batch.items() - if key != "image"}) - for filename, image, face in zip(batch["filename"], - batch["image"], - batch["detected_faces"]): + logger.trace("Item out: %s", {key: val.shape if isinstance(val, np.ndarray) else val + for key, val in batch.items()}) + for filename, face in zip(batch["filename"], batch["detected_faces"]): self._output_faces.append(face) if len(self._output_faces) != self._faces_per_filename[filename]: continue - retval = dict(filename=filename, image=image, detected_faces=self._output_faces) + output = self._extract_media.pop(filename) + output.add_detected_faces(self._output_faces) self._output_faces = [] logger.trace("Yielding: (filename: '%s', image: %s, detected_faces: %s)", - retval["filename"], retval["image"].shape, len(retval["detected_faces"])) - yield retval + output.filename, output.image_shape, len(output.detected_faces)) + yield output # <<< PROTECTED ACCESS METHODS >>> # @staticmethod diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index dedb5ec451..6b5546eb29 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Return a requested detector/aligner pipeline +Return a requested detector/aligner/masker pipeline Tensorflow does not like to release GPU VRAM, so parallel plugins need to be managed to work together. @@ -12,6 +12,8 @@ import logging +import cv2 + from lib.gpu_stats import GPUStats from lib.queue_manager import queue_manager, QueueEmpty from lib.utils import get_backend @@ -21,8 +23,9 @@ class Extractor(): - """ Creates a :mod:`~plugins.extract.detect`/:mod:`~plugins.extract.align` pipeline and yields - results frame by frame from the :attr:`detected_faces` generator + """ Creates a :mod:`~plugins.extract.detect`/:mod:`~plugins.extract.align``/\ + :mod:`~plugins.extract.mask` pipeline and yields results frame by frame from the + :attr:`detected_faces` generator :attr:`input_queue` is dynamically set depending on the current :attr:`phase` of extraction @@ -32,6 +35,8 @@ class Extractor(): The name of a detector plugin as exists in :mod:`plugins.extract.detect` aligner: str The name of an aligner plugin as exists in :mod:`plugins.extract.align` + masker: str + The name of a masker plugin as exists in :mod:`plugins.extract.mask` configfile: str, optional The path to a custom ``extract.ini`` configfile. If ``None`` then the system :file:`config/extract.ini` file will be used. @@ -39,19 +44,19 @@ class Extractor(): Whether to attempt processing the plugins in parallel. This may get overridden internally depending on the plugin combination. Default: ``False`` rotate_images: str, optional - Used to set the :attr:`~plugins.extract.detect.rotation` attribute. Pass in a single number + Used to set the :attr:`plugins.extract.detect.rotation` attribute. Pass in a single number to use increments of that size up to 360, or pass in a ``list`` of ``ints`` to enumerate exactly what angles to check. Can also pass in ``'on'`` to increment at 90 degree intervals. Default: ``None`` min_size: int, optional - Used to set the :attr:`~plugins.extract.detect.min_size` attribute Filters out faces + Used to set the :attr:`plugins.extract.detect.min_size` attribute Filters out faces detected below this size. Length, in pixels across the diagonal of the bounding box. Set to ``0`` for off. Default: ``0`` normalize_method: {`None`, 'clahe', 'hist', 'mean'}, optional - Used to set the :attr:`~plugins.extract.align.normalize_method` attribute. Normalize the + Used to set the :attr:`plugins.extract.align.normalize_method` attribute. Normalize the images fed to the aligner.Default: ``None`` image_is_aligned: bool, optional - Used to set the :attr:`~plugins.extract.mask.image_is_aligned` attribute. Indicates to the + Used to set the :attr:`plugins.extract.mask.image_is_aligned` attribute. Indicates to the masker that the fed in image is an aligned face rather than a frame.Default: ``False`` Attributes @@ -86,20 +91,14 @@ def __init__(self, detector, aligner, masker, configfile=None, def input_queue(self): """ queue: Return the correct input queue depending on the current phase - The input queue is the entry point into the extraction pipeline. A ``dict`` should - be put to the queue in the following format(s): - - For detect/single phase operations: - - >>> {'filename': , - >>> 'image': } - - For align (2nd pass operations): + The input queue is the entry point into the extraction pipeline. An :class:`ExtractMedia` + object should be put to the queue. - >>> {'filename': , - >>> 'image': , - >>> 'detected_faces: []} + For detect/single phase operations the :attr:`ExtractMedia.filename` and + :attr:`~ExtractMedia.image` attributes should be populated. + For align/mask (2nd/3rd pass operations) the :attr:`ExtractMedia.detected_faces` should + also be populated by calling :func:`ExtractMedia.set_detected_faces`. """ qname = "extract_{}_in".format(self.phase) retval = self._queues[qname] @@ -118,12 +117,11 @@ def passes(self): ------- >>> for phase in extractor.passes: >>> if phase == 1: - >>> extractor.input_queue.put({"filename": "path/to/image/file", - >>> "image": numpy.array(image)}) + >>> extract_media = ExtractMedia("path/to/image/file", image) + >>> extractor.input_queue.put(extract_media) >>> else: - >>> extractor.input_queue.put({"filename": "path/to/image/file", - >>> "image": numpy.array(image), - >>> "detected_faces": [>> extract_media.set_image(image) + >>> extractor.input_queue.put(extract_media) """ retval = 1 if self._is_parallel else len(self._flow) logger.trace(retval) @@ -142,10 +140,9 @@ def final_pass(self): >>> if extractor.final_pass: >>> >>> else: + >>> extract_media.set_image(image) >>> - >>> extractor.input_queue.put({"filename": "path/to/image/file", - >>> "image": numpy.array(image), - >>> "detected_faces": [>> extractor.input_queue.put(extract_media) """ retval = self._is_parallel or self.phase == self._final_phase logger.trace(retval) @@ -195,18 +192,15 @@ def detected_faces(self): Yields ------ - faces: dict - regardless of phase, the returned dictionary will contain, exclusively, ``filename``: - the filename of the source image, ``image``: the ``numpy.array`` of the source image - in BGR color format, ``detected_faces``: a list of - :class:`~lib.faces_detect.Detected_Face` objects. + faces: :class:`ExtractMedia` + The populated extracted media object. Example ------- - >>> for face in extractor.detected_faces(): - >>> filename = face["filename"] - >>> image = face["image"] - >>> detected_faces = face["detected_faces"] + >>> for extract_media in extractor.detected_faces(): + >>> filename = extract_media.filename + >>> image = extract_media.image + >>> detected_faces = extract_media.detected_faces """ logger.debug("Running Detection. Phase: '%s'", self.phase) # If not multiprocessing, intercept the align in queue for @@ -482,3 +476,128 @@ def _check_and_raise_error(self): if plugin.check_and_raise_error(): return True return False + + +class ExtractMedia(): + """ An object that passes through the :class:`~plugins.extract.pipeline.Extractor` pipeline. + + Parameters + ---------- + filename: str + The base name of the original frame's filename + image: :class:`numpy.ndarray` + The original frame + """ + + def __init__(self, filename, image): + logger.trace("Initializing %s: (filename: '%s', image shape: %s)", + self.__class__.__name__, filename, image.shape) + self._filename = filename + self._image = image + self._detected_faces = None + + @property + def filename(self): + """ str: The base name of the :attr:`image` filename. """ + return self._filename + + @property + def image(self): + """ :class:`numpy.ndarray`: The source frame for this object. """ + return self._image + + @property + def image_shape(self): + """ tuple: The shape of the stored :attr:`image`. """ + return self._image.shape + + @property + def image_size(self): + """ tuple: The (`height`, `width`) of the stored :attr:`image`. """ + return self._image.shape[:2] + + @property + def detected_faces(self): + """list: A list of :class:`~lib.faces_detect.DetectedFace` objects in the + :attr:`image`. """ + return self._detected_faces + + def get_image_copy(self, colorformat): + """ Get a copy of the image in the requested color format. + + Parameters + ---------- + colorformat: ['BGR', 'RGB', 'GRAY'] + The requested color format of :attr:`image` + + Returns + ------- + :class:`numpy.ndarray`: + A copy of :attr:`image` in the requested :attr:`colorformat` + """ + logger.trace("Requested color format '%s' for frame '%s'", colorformat, self._filename) + image = getattr(self, "_image_as_{}".format(colorformat.lower()))() + return image + + def add_detected_faces(self, faces): + """ Add detected faces to the object. Called at the end of each extraction phase. + + Parameters + ---------- + faces: list + A list of :class:`~lib.faces_detect.DetectedFace` objects + """ + logger.trace("Adding detected faces for filename: '%s'. (faces: %s, lrtb: %s)", + self._filename, faces, + [(face.left, face.right, face.top, face.bottom) for face in faces]) + self._detected_faces = faces + + def remove_image(self): + """ Delete the image and reset :attr:`image` to ``None``. + + Required for multi-phase extraction to avoid the frames stacking RAM. + """ + logger.trace("Removing image for filename: '%s'", self._filename) + del self._image + self._image = None + + def set_image(self, image): + """ Add the image back into :attr:`image` + + Required for multi-phase extraction adds the image back to this object. + + Parameters + ---------- + image: :class:`numpy.ndarry` + The original frame to be re-applied to for this :attr:`filename` + """ + logger.trace("Reapplying image: (filename: `%s`, image shape: %s)", + self._filename, image.shape) + self._image = image + + def _image_as_bgr(self): + """ Get a copy of the source frame in BGR format. + + Returns + ------- + :class:`numpy.ndarray`: + A copy of :attr:`image` in BGR color format """ + return self._image[..., :3].copy() + + def _image_as_rgb(self): + """ Get a copy of the source frame in RGB format. + + Returns + ------- + :class:`numpy.ndarray`: + A copy of :attr:`image` in RGB color format """ + return self._image[..., 2::-1].copy() + + def _image_as_gray(self): + """ Get a copy of the source frame in gray-scale format. + + Returns + ------- + :class:`numpy.ndarray`: + A copy of :attr:`image` in gray-scale color format """ + return cv2.cvtColor(self._image.copy(), cv2.COLOR_BGR2GRAY) diff --git a/scripts/extract.py b/scripts/extract.py index a348d12ce3..2b1b2c0cd9 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -10,7 +10,7 @@ from lib.image import encode_image_with_hash, ImagesLoader, ImagesSaver from lib.multithreading import MultiThread from lib.utils import get_folder -from plugins.extract.pipeline import Extractor +from plugins.extract.pipeline import Extractor, ExtractMedia from scripts.fsmedia import Alignments, PostProcess, Utils tqdm.monitor_interval = 0 # workaround for TqdmSynchronisationWarning @@ -110,8 +110,7 @@ def process(self): Should only be called from :class:`lib.cli.ScriptExecutor` """ logger.info('Starting, this may take a while...') - # from lib.queue_manager import queue_manager - # queue_manager.debug_monitor(3) + # from lib.queue_manager import queue_manager ; queue_manager.debug_monitor(3) self._threaded_redirector("load") self._run_extraction() for thread in self._threads: @@ -150,8 +149,7 @@ def _load(self): if load_queue.shutdown.is_set(): logger.debug("Load Queue: Stop signal received. Terminating") break - item = {"filename": filename, - "image": image[..., :3]} + item = ExtractMedia(filename, image[..., :3]) load_queue.put(item) load_queue.put("EOF") logger.debug("Load Images: Complete") @@ -165,8 +163,8 @@ def _reload(self, detected_faces): Parameters ---------- detected_faces: dict - Dictionary of detected_faces with the filename as its key and a list of - :class:`lib.faces_detect.DetectedFace` as the values for pairing with reloaded images. + Dictionary of :class:`plugins.extract.pipeline.ExtractMedia` with the filename as the + key for repopulating the image attribute. """ logger.debug("Reload Images: Start. Detected Faces Count: %s", len(detected_faces)) load_queue = self._extractor.input_queue @@ -175,12 +173,12 @@ def _reload(self, detected_faces): logger.debug("Reload Queue: Stop signal received. Terminating") break logger.trace("Reloading image: '%s'", filename) - detect_item = detected_faces.pop(filename, None) - if not detect_item: + extract_media = detected_faces.pop(filename, None) + if not extract_media: logger.warning("Couldn't find faces for: %s", filename) continue - detect_item["image"] = image - load_queue.put(detect_item) + extract_media.set_image(image) + load_queue.put(extract_media) load_queue.put("EOF") logger.debug("Reload Images: Complete") @@ -209,21 +207,17 @@ def _run_extraction(self): total=self._images.process_count, file=sys.stdout, desc=desc) - for idx, faces in enumerate(status_bar): + for idx, extract_media in enumerate(status_bar): self._check_thread_error() - exception = faces.get("exception", False) - if exception: - break - - if self._extractor.final_pass: - self._output_processing(faces, size) - self._output_faces(saver, faces) + if is_final: + self._output_processing(extract_media, size) + self._output_faces(saver, extract_media) if self._save_interval and (idx + 1) % self._save_interval == 0: self._alignments.save() else: - del faces["image"] - # cache detected faces for next run - detected_faces[faces["filename"]] = faces + extract_media.remove_image() + # cache extract_media for next run + detected_faces[extract_media.filename] = extract_media status_bar.update(1) if not is_final: @@ -236,51 +230,53 @@ def _check_thread_error(self): for thread in self._threads: thread.check_and_raise_error() - def _output_processing(self, faces, size): + def _output_processing(self, extract_media, size): """ Prepare faces for output Loads the aligned face, perform any processing actions and verify the output. Parameters: - faces: dict - Dictionary output from :class:`plugins.extract.Pipeline.Extractor` + extract_media: :class:`plugins.extract.pipeline.ExtractMedia` + Output from :class:`plugins.extract.pipeline.Extractor` size: int The size that the aligned face should be created at """ - for face in faces["detected_faces"]: - face.load_aligned(faces["image"], size=size) + for face in extract_media.detected_faces: + face.load_aligned(extract_media.image, size=size) - self._post_process.do_actions(faces) + self._post_process.do_actions(extract_media) + extract_media.remove_image() - faces_count = len(faces["detected_faces"]) + faces_count = len(extract_media.detected_faces) if faces_count == 0: logger.verbose("No faces were detected in image: %s", - os.path.basename(faces["filename"])) + os.path.basename(extract_media.filename)) if not self._verify_output and faces_count > 1: self._verify_output = True - def _output_faces(self, saver, faces): + def _output_faces(self, saver, extract_media): """ Output faces to save thread Set the face filename based on the frame name and put the face to the - :class:`lib.image.ImagesSaver` save queue and add the face information to the alignments + :class:`~lib.image.ImagesSaver` save queue and add the face information to the alignments data. Parameters ---------- saver: lib.images.ImagesSaver The background saver for saving the image - faces: dict - The output dictionary from :class:`plugins.extract.Pipeline.Extractor` + extract_media: :class:`~plugins.extract.pipeline.ExtractMedia` + The output from :class:`~plugins.extract.Pipeline.Extractor` """ - logger.trace("Outputting faces for %s", faces["filename"]) + logger.trace("Outputting faces for %s", extract_media.filename) final_faces = list() - filename, extension = os.path.splitext(os.path.basename(faces["filename"])) - for idx, face in enumerate(faces["detected_faces"]): + filename, extension = os.path.splitext(os.path.basename(extract_media.filename)) + for idx, face in enumerate(extract_media.detected_faces): output_filename = "{}_{}{}".format(filename, str(idx), extension) face.hash, image = encode_image_with_hash(face.aligned_face, extension) saver.save(output_filename, image) final_faces.append(face.to_alignment()) - self._alignments.data[os.path.basename(faces["filename"])] = final_faces + self._alignments.data[os.path.basename(extract_media.filename)] = final_faces + del extract_media diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index dc4ab36cda..0c5611b6bd 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -99,7 +99,7 @@ def load(self): data = self.serializer.load(self.file) if skip_faces: - # Remove items from algnments that have no faces so they will + # Remove items from alignments that have no faces so they will # be re-detected del_keys = [key for key, val in data.items() if not val] logger.debug("Frames with no faces selected for redetection: %s", len(del_keys)) @@ -269,24 +269,23 @@ def get_items(self): logger.debug("Postprocess Items: %s", postprocess_items) return postprocess_items - def do_actions(self, output_item): + def do_actions(self, extract_media): """ Perform the requested post-processing actions """ for action in self.actions: logger.debug("Performing postprocess action: '%s'", action.__class__.__name__) - action.process(output_item) + action.process(extract_media) class PostProcessAction(): # pylint: disable=too-few-public-methods - """ Parent class for Post Processing Actions - Usuable in Extract or Convert or both + """ Parent class for Post Processing Actions. Usable in Extract or Convert or both depending on context """ def __init__(self, *args, **kwargs): logger.debug("Initializing %s: (args: %s, kwargs: %s)", self.__class__.__name__, args, kwargs) - self.valid = True # Set to False if invalid params passed in to disable + self.valid = True # Set to False if invalid parameters passed in to disable logger.debug("Initialized base class %s", self.__class__.__name__) - def process(self, output_item): + def process(self, extract_media): """ Override for specific post processing action """ raise NotImplementedError @@ -295,10 +294,10 @@ class DebugLandmarks(PostProcessAction): # pylint: disable=too-few-public-metho """ Draw debug landmarks on face Extract Only """ - def process(self, output_item): + def process(self, extract_media): """ Draw landmarks on image """ - frame = os.path.splitext(os.path.basename(output_item["filename"]))[0] - for idx, face in enumerate(output_item["detected_faces"]): + frame = os.path.splitext(os.path.basename(extract_media.filename))[0] + for idx, face in enumerate(extract_media.detected_faces): logger.trace("Drawing Landmarks. Frame: '%s'. Face: %s", frame, idx) aligned_landmarks = face.aligned_landmarks for (pos_x, pos_y) in aligned_landmarks: @@ -352,19 +351,19 @@ def set_face_filter(f_type, f_args): logger.debug("Face Filter files: %s", filter_files) return filter_files - def process(self, output_item): + def process(self, extract_media): """ Filter in/out wanted/unwanted faces """ if not self.filter: return ret_faces = list() - for idx, detect_face in enumerate(output_item["detected_faces"]): + for idx, detect_face in enumerate(extract_media.detected_faces): check_item = detect_face["face"] if isinstance(detect_face, dict) else detect_face - check_item.load_aligned(output_item["image"]) + check_item.load_aligned(extract_media.image) if not self.filter.check(check_item): logger.verbose("Skipping not recognized face: (Frame: %s Face %s)", - output_item["filename"], idx) + extract_media.filename, idx) continue logger.trace("Accepting recognised face. Frame: %s. Face: %s", - output_item["filename"], idx) + extract_media.filename, idx) ret_faces.append(detect_face) - output_item["detected_faces"] = ret_faces + extract_media.detected_faces = ret_faces From 9db67cee43a55210d5b3bb1c2c71d73ea5962ac8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 29 Nov 2019 12:13:06 +0000 Subject: [PATCH 158/981] bugfix - ImagesSaver - Auto prepend destination folder --- lib/image.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/image.py b/lib/image.py index 0b26f24b26..6f30b10c02 100644 --- a/lib/image.py +++ b/lib/image.py @@ -467,8 +467,8 @@ class ImagesLoader(ImageIO): def __init__(self, path, queue_size=8, load_with_hash=False, fast_count=True, skip_list=None): logger.debug("Initializing %s: (path: %s, queue_size: %s, load_with_hash: %s, " - "fast_count: %s)", self.__class__.__name__, path, queue_size, - load_with_hash, fast_count) + "fast_count: %s, skip_list: %s)", self.__class__.__name__, path, queue_size, + load_with_hash, fast_count, skip_list) args = (load_with_hash, ) super().__init__(path, queue_size=queue_size, args=args) @@ -603,7 +603,7 @@ def _from_video(self): reader = imageio.get_reader(self.location, "ffmpeg") for idx, frame in enumerate(reader): if idx in self._skip_list: - logger.trace("Skipping frame %s due to skip list") + logger.trace("Skipping frame %s due to skip list", idx) continue # Convert to BGR for cv2 compatibility frame = frame[:, :, ::-1] @@ -717,7 +717,7 @@ class ImagesSaver(ImageIO): """ def __init__(self, path, queue_size=8, as_bytes=False): - logger.debug("Initializing %s: (path: %s, load_with_hash: %s, as_bytes: %s)", + logger.debug("Initializing %s: (path: %s, queue_size: %s, as_bytes: %s)", self.__class__.__name__, path, queue_size, as_bytes) super().__init__(path, queue_size=queue_size) @@ -762,12 +762,12 @@ def _save(self, filename, image): Parameters ---------- filename: str - The filename of the image to be saved. Can include or exclude the folder location. + The filename of the image to be saved. NB: Any folders passed in with the filename + will be stripped and replaced with :attr:`location`. image: numpy.ndarray The image to be saved """ - if not os.path.commonprefix([self.location, filename]): - filename = os.path.join(self.location, filename) + filename = os.path.join(self.location, os.path.basename(filename)) try: if self._as_bytes: with open(filename, "wb") as out_file: From 16aca4a0ec81c022a4ad90c92c69cacbe3446766 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 29 Nov 2019 13:01:32 +0000 Subject: [PATCH 159/981] bugfix - gui - Don't raise error when clearing slider entry widget --- lib/gui/control_helper.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index dee44a8843..99ba12f9ed 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -8,7 +8,7 @@ from itertools import zip_longest from functools import partial -from _tkinter import Tcl_Obj +from _tkinter import Tcl_Obj, TclError from .custom_widgets import ContextMenu from .custom_widgets import Tooltip @@ -203,8 +203,25 @@ def helptext(self): return helptext def get(self): - """ Return the value from the tk_var """ - return self.tk_var.get() + """ Return the value from the tk_var + + Notes + ----- + tk variables don't like empty values if it's not a stringVar. This seems to be pretty + much the only reason that a get() call would fail, so replace any numerical variable + with it's numerical zero equivalent on a TCL Error. Only impacts variables linked + to Entry widgets. + """ + try: + val = self.tk_var.get() + except TclError: + if isinstance(self.tk_var, tk.IntVar): + val = 0 + elif isinstance(self.tk_var, tk.DoubleVar): + val = 0.0 + else: + raise + return val def set(self, value): """ Set the tk_var to a new value """ From 4fdeb67fba6cfb2193f8ab37a654de434b56baa0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 29 Nov 2019 23:53:00 +0000 Subject: [PATCH 160/981] Bugfix - Manual tool. Use new ExtractMedia class --- plugins/extract/align/_base.py | 6 ++-- plugins/extract/detect/manual.py | 39 ------------------------- plugins/extract/mask/_base.py | 6 ++-- plugins/extract/pipeline.py | 11 +++++--- plugins/plugin_loader.py | 3 +- tools/lib_alignments/jobs_manual.py | 44 ++++++++++++++--------------- 6 files changed, 38 insertions(+), 71 deletions(-) delete mode 100644 plugins/extract/detect/manual.py diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index e6dfc8bb1d..7690384b10 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -122,8 +122,10 @@ def get_batch(self, queue): if idx == self.batchsize: frame_faces = len(item.detected_faces) if f_idx + 1 != frame_faces: - self._rollover = ExtractMedia(item.filename, item.image) - self._rollover.add_detected_faces(item.detected_faces[f_idx + 1:]) + self._rollover = ExtractMedia( + item.filename, + item.image, + detected_faces=item.detected_faces[f_idx + 1:]) logger.trace("Rolled over %s faces of %s to next batch for '%s'", len(self._rollover.detected_faces), frame_faces, item.filename) diff --git a/plugins/extract/detect/manual.py b/plugins/extract/detect/manual.py deleted file mode 100644 index 5953e9b277..0000000000 --- a/plugins/extract/detect/manual.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -""" Manual face detection plugin """ - -import numpy as np -from ._base import Detector - - -class Detect(Detector): - """ Manual Detector """ - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.name = "Manual" - self.input_size = 1440 # Arbitrary size for manual tool - self.vram = 0 - self.vram_warnings = 0 - self.vram_per_batch = 1 - self.batchsize = 1 - - def _compile_detection_image(self, input_image): - """ Override compile detection image for manual. No face is actually fed into a model """ - return input_image, 1, (0, 0) - - def init_model(self): - """ No model for Manual """ - return - - def process_input(self, batch): - """ No pre-processing for Manual. Just set a dummy feed """ - batch["feed"] = batch["image"] - return batch - - def predict(self, batch): - """ No prediction for Manual """ - batch["prediction"] = [np.array(batch["manual_face"])] - return batch - - def process_output(self, batch): - """ Post process the detected faces """ - return batch diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 37aed6a422..13b9096407 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -126,8 +126,10 @@ def get_batch(self, queue): if idx == self.batchsize: frame_faces = len(item.detected_faces) if f_idx + 1 != frame_faces: - self._rollover = ExtractMedia(item.filename, item.image) - self._rollover.add_detected_faces(item.detected_faces[f_idx + 1:]) + self._rollover = ExtractMedia( + item.filename, + item.image, + detected_faces=item.detected_faces[f_idx + 1:]) logger.trace("Rolled over %s faces of %s to next batch for '%s'", len(self._rollover.detected_faces), frame_faces, item.filename) diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 6b5546eb29..0bb7f55447 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -487,14 +487,17 @@ class ExtractMedia(): The base name of the original frame's filename image: :class:`numpy.ndarray` The original frame + detected_faces: list, optional + A list of :class:`~lib.faces_detect.DetectedFace` objects. Detected faces can be added + later with :func:`add_detected_faces`. Default: None """ - def __init__(self, filename, image): - logger.trace("Initializing %s: (filename: '%s', image shape: %s)", - self.__class__.__name__, filename, image.shape) + def __init__(self, filename, image, detected_faces=None): + logger.trace("Initializing %s: (filename: '%s', image shape: %s, detected_faces: %s)", + self.__class__.__name__, filename, image.shape, detected_faces) self._filename = filename self._image = image - self._detected_faces = None + self._detected_faces = detected_faces @property def filename(self): diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 8f162af939..525fc76b7c 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -185,8 +185,7 @@ def get_available_extractors(extractor_type, add_none=False): for item in os.scandir(extractpath) if not item.name.startswith("_") and not item.name.endswith("defaults.py") - and item.name.endswith(".py") - and item.name != "manual.py") + and item.name.endswith(".py")) if add_none: extractors.insert(0, "none") return extractors diff --git a/tools/lib_alignments/jobs_manual.py b/tools/lib_alignments/jobs_manual.py index 2f933c0140..ead991e20d 100644 --- a/tools/lib_alignments/jobs_manual.py +++ b/tools/lib_alignments/jobs_manual.py @@ -7,8 +7,9 @@ import cv2 import numpy as np +from lib.faces_detect import DetectedFace from lib.queue_manager import queue_manager -from plugins.extract.pipeline import Extractor +from plugins.extract.pipeline import Extractor, ExtractMedia from . import Annotate, ExtractedFaces, Frames logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -720,7 +721,7 @@ def annotate_faces(self): def build_faces_image(self, size): """ Display associated faces """ total_faces = len(self.faces) - logger.trace("Building faces panel. (total_faces: %s", total_faces) + logger.trace("Building faces panel. (total_faces: %s)", total_faces) if not total_faces: logger.trace("Returning empty row") image = self.build_faces_row(list(), size) @@ -781,11 +782,10 @@ def __init__(self, interface, loglevel): def init_extractor(self): """ Initialize Aligner """ logger.debug("Initialize Extractor") - extractor = Extractor("manual", "fan", None, multiprocess=True, normalize_method="hist") + extractor = Extractor(None, "fan", None, multiprocess=True, normalize_method="hist") self.queues["in"] = extractor.input_queue - # Set the batchsizes to 1 - for plugin_type in ("detect", "align"): - extractor.set_batchsize(plugin_type, 1) + # Set the batchsize to 1 + extractor.set_batchsize("align", 1) extractor.launch() logger.debug("Initialized Extractor") return extractor @@ -829,8 +829,8 @@ def initialize(self): self.center = None self.last_move = None self.mouse_state = None - self.media["bounding_box"] = list() - self.media["bounding_box_orig"] = list() + self.media["bounding_box"] = DetectedFace() + self.media["bounding_box_orig"] = None def set_bounding_box(self, pt_x, pt_y): """ Select or create bounding box """ @@ -882,10 +882,10 @@ def bounding_from_center(self): pt_x, pt_y = self.center width, height = self.dims scale = self.interface.get_frame_scaling() - self.media["bounding_box"] = [int((pt_x / scale) - width / 2), - int((pt_y / scale) - height / 2), - int((pt_x / scale) + width / 2), - int((pt_y / scale) + height / 2)] + self.media["bounding_box"].x = int((pt_x / scale) - width / 2) + self.media["bounding_box"].y = int((pt_y / scale) - height / 2) + self.media["bounding_box"].w = width + self.media["bounding_box"].h = height def move_bounding_box(self, pt_x, pt_y): """ Move the bounding box """ @@ -896,7 +896,6 @@ def move_bounding_box(self, pt_x, pt_y): def resize_bounding_box(self, pt_x, pt_y): """ Resize the bounding box """ scale = self.interface.get_frame_scaling() - if not self.last_move: self.last_move = (pt_x, pt_y) self.media["bounding_box_orig"] = self.media["bounding_box"] @@ -907,21 +906,22 @@ def resize_bounding_box(self, pt_x, pt_y): original = self.media["bounding_box_orig"] updated = self.media["bounding_box"] - minsize = int(10 / scale) + minsize = int(20 / scale) center = (int(self.center[0] / scale), int(self.center[1] / scale)) - updated[0] = min(center[0] - minsize, original[0] - move_x) - updated[1] = min(center[1] - minsize, original[1] - move_y) - updated[2] = max(center[0] + minsize, original[2] + move_x) - updated[3] = max(center[1] + minsize, original[3] + move_y) + updated.x = min(center[0] - (minsize // 2), original.x - move_x) + updated.y = min(center[1] - (minsize // 2), original.y - move_y) + updated.w = max(minsize, original.w + move_x) + updated.h = max(minsize, original.h + move_y) self.update_landmarks() self.last_move = (pt_x, pt_y) def update_landmarks(self): """ Update the landmarks """ - self.queues["in"].put({"image": self.media["image"], - "filename": self.media["frame_id"], - "manual_face": self.media["bounding_box"]}) - detected_face = next(self.extractor.detected_faces())["detected_faces"][0] + feed = ExtractMedia(self.media["frame_id"], + self.media["image"], + detected_faces=[self.media["bounding_box"]]) + self.queues["in"].put(feed) + detected_face = next(self.extractor.detected_faces()).detected_faces[0] alignment = detected_face.to_alignment() # Mask will now be incorrect for updated landmarks so delete alignment["mask"] = dict() From 8f5a7a653528fd887a6106e610964a01591ed09c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 30 Nov 2019 00:17:19 +0000 Subject: [PATCH 161/981] Bugfix: Sort by Yaw - Use Pipeline for landmarks --- tools/sort.py | 45 ++++++++++++++++++++------------------------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/tools/sort.py b/tools/sort.py index f96087155f..a739720256 100644 --- a/tools/sort.py +++ b/tools/sort.py @@ -6,21 +6,20 @@ import os import sys import operator +from concurrent import futures from shutil import copyfile import numpy as np import cv2 from tqdm import tqdm -from concurrent import futures # faceswap imports from lib.cli import FullHelpArgumentParser from lib.serializer import get_serializer_from_filename from lib.faces_detect import DetectedFace from lib.image import read_image -from lib.queue_manager import queue_manager from lib.vgg_face2_keras import VGGFace2 as VGGFace -from plugins.plugin_loader import PluginLoader +from plugins.extract.pipeline import Extractor, ExtractMedia from . import cli @@ -35,6 +34,7 @@ def __init__(self, arguments): self.changes = None self.serializer = None self.vgg_face = None + self.extractor_in_queue = None def process(self): """ Main processing function of the sort tool """ @@ -88,36 +88,31 @@ def process(self): @staticmethod def launch_aligner(): """ Load the aligner plugin to retrieve landmarks """ - kwargs = dict(in_queue=queue_manager.get_queue("in"), - out_queue=queue_manager.get_queue("out"), - queue_size=8) - aligner = PluginLoader.get_aligner("fan")(normalize_method="hist") - aligner.batchsize = 1 # TODO Put batches at a time or load from alignment file - aligner.initialize(**kwargs) - aligner.start() + extractor = Extractor(None, "fan", None, normalize_method="hist") + extractor.set_batchsize("align", 1) + extractor.launch() + return extractor @staticmethod def alignment_dict(filename, image): - """ Set the image to a dict for alignment """ + """ Set the image to an ExtractMedia object for alignment """ height, width = image.shape[:2] face = DetectedFace(x=0, w=width, y=0, h=height) - return {"image": image, - "filename": filename, - "detected_faces": [face]} + return ExtractMedia(filename, image, detected_faces=[face]) def _get_landmarks(self): """ Multi-threaded, parallel and sequentially ordered landmark loader """ - self.launch_aligner() + extractor = self.launch_aligner() filename_list, image_list = self._get_images() feed_list = list(map(Sort.alignment_dict, filename_list, image_list)) landmarks = np.zeros((len(feed_list), 68, 2), dtype='float32') logger.info("Finding landmarks in images...") - for feed in tqdm(feed_list, desc="Putting...", file=sys.stdout): - queue_manager.get_queue("in").put(feed) - for index, _ in enumerate(tqdm(landmarks, desc="Aligning...", file=sys.stdout)): - face = queue_manager.get_queue("out").get() - landmarks[index] = np.array(face["detected_faces"][0].landmarks_xy) + # TODO thread the put to queue so we don't have to put and get at the same time + # Or even better, set up a proper background loader from disk (i.e. use lib.image.ImageIO) + for idx, feed in enumerate(tqdm(feed_list, desc="Aligning...", file=sys.stdout)): + extractor.input_queue.put(feed) + landmarks[idx] = next(extractor.detected_faces()).detected_faces[0].landmarks_xy return filename_list, image_list, landmarks @@ -187,7 +182,7 @@ def sort_face(self): def sort_face_cnn(self): """ Sort by landmark similarity """ logger.info("Sorting by landmark similarity...") - filename_list, image_list, landmarks = self._get_landmarks() + filename_list, _, landmarks = self._get_landmarks() img_list = list(zip(filename_list, landmarks)) logger.info("Comparing landmarks and sorting...") @@ -208,7 +203,7 @@ def sort_face_cnn(self): def sort_face_cnn_dissim(self): """ Sort by landmark dissimilarity """ logger.info("Sorting by landmark dissimilarity...") - filename_list, image_list, landmarks = self._get_landmarks() + filename_list, _, landmarks = self._get_landmarks() scores = np.zeros(len(filename_list), dtype='float32') img_list = list(list(items) for items in zip(filename_list, landmarks, scores)) @@ -231,7 +226,7 @@ def sort_face_cnn_dissim(self): def sort_face_yaw(self): """ Sort by estimated face yaw angle """ logger.info("Sorting by estimated face yaw angle..") - filename_list, image_list, landmarks = self._get_landmarks() + filename_list, _, landmarks = self._get_landmarks() logger.info("Estimating yaw...") yaws = [self.calc_landmarks_face_yaw(mark) for mark in landmarks] @@ -542,7 +537,6 @@ def reload_images(self, group_method, img_list): :return: img_list but with the comparative values that the chosen grouping method expects. """ - input_dir = self.args.input_dir logger.info("Preparing to group...") if group_method == 'group_blur': filename_list, image_list = self._get_images() @@ -564,7 +558,8 @@ def reload_images(self, group_method, img_list): return self.splice_lists(img_list, temp_list) - def _convert_color(self, imgs, same_size, method): + @staticmethod + def _convert_color(imgs, same_size, method): """ Helper function to convert colorspaces """ if method.endswith('gray'): From 44b7461ca7ae17c3dd755fa241449c75e3e39c83 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 30 Nov 2019 00:32:25 +0000 Subject: [PATCH 162/981] sort - remove unused variable --- tools/sort.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/sort.py b/tools/sort.py index a739720256..a857f96305 100644 --- a/tools/sort.py +++ b/tools/sort.py @@ -34,7 +34,6 @@ def __init__(self, arguments): self.changes = None self.serializer = None self.vgg_face = None - self.extractor_in_queue = None def process(self): """ Main processing function of the sort tool """ From aede5f0f4414a44242b077c1b6cc0a65860f5547 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 30 Nov 2019 19:33:59 +0000 Subject: [PATCH 163/981] bugfix: Extract - Calculate zero sized faces prior to scaling up --- plugins/extract/detect/_base.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index c7ff95f390..e562e97dbb 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -161,6 +161,9 @@ def finalize(self, batch): for face in faces] for faces, rotmat in zip(batch_faces, batch["rotmat"])] + # Remove zero sized faces + batch_faces = self._remove_zero_sized_faces(batch_faces) + # Scale back out to original frame batch["detected_faces"] = [[self.to_detected_face((face.left - pad[0]) / scale, (face.top - pad[1]) / scale, @@ -171,8 +174,6 @@ def finalize(self, batch): batch["pad"], batch_faces)] - # Remove zero sized faces - self._remove_zero_sized_faces(batch) if self.min_size > 0 and batch.get("detected_faces", None): batch["detected_faces"] = self._filter_small_faces(batch["detected_faces"]) @@ -280,17 +281,17 @@ def _pad_image(self, image): return image # <<< FINALIZE METHODS >>> # - @staticmethod - def _remove_zero_sized_faces(batch): - """ Remove items from dict where detected face is of zero size + def _remove_zero_sized_faces(self, batch_faces): + """ Remove items from batch_faces where detected face is of zero size or face falls entirely outside of image """ - dims = [img.shape[:2] for img in batch["image"]] - logger.trace("image dims: %s", dims) - batch["detected_faces"] = [[face - for face in faces - if face.right > 0 and face.left < dim[1] - and face.bottom > 0 and face.top < dim[0]] - for dim, faces in zip(dims, batch.get("detected_faces", []))] + logger.trace("Input sizes: %s", [len(face) for face in batch_faces]) + retval = [[face + for face in faces + if face.right > 0 and face.left < self.input_size + and face.bottom > 0 and face.top < self.input_size] + for faces in batch_faces] + logger.trace("Output sizes: %s", [len(face) for face in retval]) + return retval def _filter_small_faces(self, detected_faces): """ Filter out any faces smaller than the min size threshold """ From ae94d36b3964802e9ca9e33ed6423966a7d209b7 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 1 Dec 2019 13:26:03 +0000 Subject: [PATCH 164/981] Installer updates Windows - Install Conda git Linux + Windows - Always create GUI Launcher shortcut --- .install/linux/faceswap_setup_x64.sh | 18 +-- .install/windows/git_install.inf | 18 --- .install/windows/install.nsi | 163 ++++++++++----------------- 3 files changed, 71 insertions(+), 128 deletions(-) delete mode 100644 .install/windows/git_install.inf diff --git a/.install/linux/faceswap_setup_x64.sh b/.install/linux/faceswap_setup_x64.sh index e85b3b97ec..84ebca4ada 100644 --- a/.install/linux/faceswap_setup_x64.sh +++ b/.install/linux/faceswap_setup_x64.sh @@ -378,17 +378,20 @@ setup_faceswap() { python "$DIR_FACESWAP/setup.py" --installer $args } +create_gui_launcher () { + # Create a shortcut to launch into the GUI + launcher="$DIR_FACESWAP/faceswap_gui_launcher.sh" + launch_script="source \"$DIR_CONDA/etc/profile.d/conda.sh\" activate &&\n" + launch_script+="conda activate '$ENV_NAME' &&\n" + launch_script+="python \"$DIR_FACESWAP/faceswap.py\" gui\n" + echo -e "$launch_script" > "$launcher" + chmod +x "$launcher" +} + create_desktop_shortcut () { # Create a shell script to launch the GUI and add a desktop shortcut if $DESKTOP ; then - launcher="$DIR_FACESWAP/faceswap_gui_launcher.sh" desktop_icon="$HOME/Desktop/faceswap.desktop" - launch_script="source \"$DIR_CONDA/etc/profile.d/conda.sh\" activate &&\n" - launch_script+="conda activate '$ENV_NAME' &&\n" - launch_script+="python \"$DIR_FACESWAP/faceswap.py\" gui\n" - echo -e "$launch_script" > "$launcher" - chmod +x "$launcher" - desktop_file="[Desktop Entry]\n" desktop_file+="Version=1.0\n" desktop_file+="Type=Application\n" @@ -413,6 +416,7 @@ activate_env install_git clone_faceswap setup_faceswap +create_gui_launcher create_desktop_shortcut info "Faceswap installation is complete!" if $DESKTOP ; then info "You can launch Faceswap from the icon on your desktop" ; exit ; fi diff --git a/.install/windows/git_install.inf b/.install/windows/git_install.inf deleted file mode 100644 index c0cf808a95..0000000000 --- a/.install/windows/git_install.inf +++ /dev/null @@ -1,18 +0,0 @@ -[Setup] -Lang=default -Dir=C:\Program Files\Git -Group=Git -NoIcons=0 -SetupType=default -Components=ext,ext\shellhere,ext\guihere,gitlfs,assoc,assoc_sh -Tasks= -EditorOption=VisualStudioCode -CustomEditorPath= -PathOption=Cmd -SSHOption=OpenSSH -CURLOption=OpenSSL -CRLFOption=CRLFAlways -BashTerminalOption=MinTTY -PerformanceTweaksFSCache=Enabled -UseCredentialManager=Enabled -EnableSymlinks=Disabled diff --git a/.install/windows/install.nsi b/.install/windows/install.nsi index 6b18917a6b..f7ccabfed9 100644 --- a/.install/windows/install.nsi +++ b/.install/windows/install.nsi @@ -10,9 +10,6 @@ OutFile "faceswap_setup_x64.exe" Name "Faceswap" InstallDir $PROFILE\faceswap -# Download sites -!define wwwGit "https://github.com/git-for-windows/git/releases/download/v2.20.1.windows.1/Git-2.20.1-64-bit.exe" - # Sometimes miniconda breaks. Uncomment/comment the following 2 lines to pin !define wwwConda "https://repo.anaconda.com/miniconda/Miniconda3-latest-Windows-x86_64.exe" #!define wwwConda "https://repo.anaconda.com/miniconda/Miniconda3-4.5.12-Windows-x86_64.exe" @@ -24,7 +21,6 @@ InstallDir $PROFILE\faceswap # Install cli flags !define flagsConda "/S /RegisterPython=0 /AddToPath=0 /D=$PROFILE\MiniConda3" -!define flagsGit "/SILENT /NORESTART /NOCANCEL /SP /CLOSEAPPLICATIONS /RESTARTAPPLICATIONS" !define flagsRepo "--depth 1 --no-single-branch ${wwwRepo}" !define flagsEnv "-y python=3.6" @@ -38,11 +34,9 @@ Var dirAnacondaAll Var dirConda # Items to Install -Var InstallGit Var InstallConda # Misc -Var gitInf Var InstallFailed Var lblPos Var hasAVX @@ -89,10 +83,8 @@ Function .onInit StrCpy $dirAnaconda "$PROFILE\Anaconda3" StrCpy $dirMinicondaAll "$ProgramData\Miniconda3" StrCpy $dirAnacondaAll "$ProgramData\Anaconda3" - StrCpy $gitInf "$dirTemp\git_install.inf" StrCpy $envName "faceswap" SetOutPath "$dirTemp" - File git_install.inf Call CheckPrerequisites FunctionEnd @@ -129,12 +121,6 @@ Function pgPrereqCreate ${NSD_CreateGroupBox} 5% 5% 90% 35% "The following applications will be installed" Pop $0 - ${If} $InstallGit == 1 - ${NSD_CreateLabel} 10% $lblPos% 80% 14u "Git for Windows" - Pop $0 - intOp $lblPos $lblPos + 7 - ${EndIf} - ${If} $InstallConda == 1 ${NSD_CreateLabel} 10% $lblPos% 80% 14u "MiniConda 3" Pop $0 @@ -238,16 +224,6 @@ Function CheckCustomCondaPath FunctionEnd Function CheckPrerequisites - #Git - nsExec::ExecToStack "git --version" - pop $0 - pop $1 - ${If} $0 == 0 - StrCpy $Log "$log(check) Git installed: $1" - ${Else} - StrCpy $InstallGit 1 - ${EndIf} - # Conda # miniconda nsExec::ExecToStack "$\"$dirMiniconda\Scripts\conda.exe$\" -V" @@ -301,86 +277,45 @@ FunctionEnd Section Install Push $Log Call MultiDetailPrint - Call InstallPrerequisites - Call CloneRepo + Call InstallConda Call SetEnvironment + Call InstallGit + Call CloneRepo Call SetupFaceSwap + Call AddGuiLauncher Call DesktopShortcut ExecShell "open" "${wwwFaceswap}" DetailPrint "Visit ${wwwFaceswap} for help and support." SectionEnd -Function InstallPrerequisites - # GIT - ${If} $InstallGit == 1 - DetailPrint "Downloading Git..." - inetc::get /caption "Downloading Git..." /canceltext "Cancel" ${wwwGit} "git_installer.exe" /end - Pop $0 # return value = exit code, "OK" means OK - ${If} $0 == "OK" - DetailPrint "Installing Git..." - SetDetailsPrint listonly - ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirTemp\git_installer.exe$\" ${flagsGit} /LOADINF=$\"$gitInf$\"" - pop $0 - ExecDos::wait $0 - pop $0 - SetDetailsPrint both - ${If} $0 != 0 - DetailPrint "Error Installing Git" - StrCpy $InstallFailed 1 - ${EndIf} - ${Else} - DetailPrint "Error Downloading Git" - StrCpy $InstallFailed 1 - ${EndIf} - ${EndIf} - - # CONDA - ${If} $InstallConda == 1 - DetailPrint "Downloading Miniconda3..." - inetc::get /caption "Downloading Miniconda3." /canceltext "Cancel" ${wwwConda} "Miniconda3.exe" /end - Pop $0 - ${If} $0 == "OK" - DetailPrint "Installing Miniconda3. This will take a few minutes..." - SetDetailsPrint listonly - ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirTemp\Miniconda3.exe$\" ${flagsConda}" - pop $0 - ExecDos::wait $0 - pop $0 - StrCpy $dirConda "$dirMiniconda" - SetDetailsPrint both - ${If} $0 != 0 - DetailPrint "Error Installing Miniconda3" - StrCpy $InstallFailed 1 - ${EndIf} - ${Else} - DetailPrint "Error Downloading Miniconda3" +Function InstallConda + ${If} $InstallConda == 1 + DetailPrint "Downloading Miniconda3..." + inetc::get /caption "Downloading Miniconda3." /canceltext "Cancel" ${wwwConda} "Miniconda3.exe" /end + Pop $0 + ${If} $0 == "OK" + DetailPrint "Installing Miniconda3. This will take a few minutes..." + SetDetailsPrint listonly + ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirTemp\Miniconda3.exe$\" ${flagsConda}" + pop $0 + ExecDos::wait $0 + pop $0 + StrCpy $dirConda "$dirMiniconda" + SetDetailsPrint both + ${If} $0 != 0 + DetailPrint "Error Installing Miniconda3" StrCpy $InstallFailed 1 ${EndIf} + ${Else} + DetailPrint "Error Downloading Miniconda3" + StrCpy $InstallFailed 1 ${EndIf} + ${EndIf} ${If} $InstallFailed == 1 Call Abort ${Else} - DetailPrint "All Prerequisites installed." - ${EndIf} -FunctionEnd - -Function CloneRepo - DetailPrint "Downloading Faceswap..." - SetDetailsPrint listonly - ${If} $InstallGit == 1 - StrCpy $9 "$\"$PROGRAMFILES64\git\bin\git.exe$\"" - ${Else} - StrCpy $9 "git" - ${EndIf} - ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$9 clone ${flagsRepo} $\"$INSTDIR$\"" - pop $0 - ExecDos::wait $0 - pop $0 - SetDetailsPrint both - ${If} $0 != 0 - DetailPrint "Error Downloading Faceswap" - Call Abort + DetailPrint "Miniconda3 installed." ${EndIf} FunctionEnd @@ -420,24 +355,42 @@ Function SetEnvironment ${EndIf} FunctionEnd +Function InstallGit + DetailPrint "Installing Git..." + SetDetailsPrint listonly + ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda activate $\"$envName$\" && conda install git -y -q && conda deactivate" + pop $0 + ExecDos::wait $0 + pop $0 + SetDetailsPrint both + ${If} $0 != 0 + DetailPrint "Error Installing Git" + StrCpy $InstallFailed 1 + ${EndIf} +FunctionEnd + +Function CloneRepo + DetailPrint "Downloading Faceswap..." + SetDetailsPrint listonly + ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda activate $\"$envName$\" && git clone ${flagsRepo} $\"$INSTDIR$\" && conda deactivate" + pop $0 + ExecDos::wait $0 + pop $0 + SetDetailsPrint both + ${If} $0 != 0 + DetailPrint "Error Downloading Faceswap" + Call Abort + ${EndIf} +FunctionEnd + Function SetupFaceSwap DetailPrint "Setting up FaceSwap Environment... This may take a while" StrCpy $0 "${flagsSetup}" ${If} $setupType != "cpu" StrCpy $0 "$0 --$setupType" ${EndIf} - SetDetailsPrint listonly - ; Create a temporary .bat file for setting up faceswap so the path can be set for Git - ; Required for installing pynvml from github - FileOpen $9 "$dirTemp\_install_faceswap.bat" w - ${If} $InstallGit == 1 - FileWrite $9 "SET PATH=%PATH%;$PROGRAMFILES64\git\cmd$\r$\n" - ${EndIf} - FileWrite $9 "$\"$dirConda\scripts\activate.bat$\" && conda activate $\"$envName$\" && python $\"$INSTDIR\setup.py$\" $0 && conda deactivate$\r$\n" - FileClose $9 - - ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirTemp\_install_faceswap.bat$\"" + ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda activate $\"$envName$\" && python $\"$INSTDIR\setup.py$\" $0 && conda deactivate" pop $0 ExecDos::wait $0 pop $0 @@ -448,12 +401,16 @@ Function SetupFaceSwap ${EndIf} FunctionEnd -Function DesktopShortcut - DetailPrint "Creating Desktop Shortcut" +Function AddGuiLauncher + DetailPrint "Creating GUI Launcher" SetOutPath "$INSTDIR" StrCpy $0 "faceswap_win_launcher.bat" FileOpen $9 "$INSTDIR\$0" w FileWrite $9 "$\"$dirConda\scripts\activate.bat$\" && conda activate $\"$envName$\" && python $\"$INSTDIR/faceswap.py$\" gui$\r$\n" FileClose $9 +FunctionEnd + +Function DesktopShortcut + DetailPrint "Creating Desktop Shortcut" CreateShortCut "$DESKTOP\FaceSwap.lnk" "$\"$INSTDIR\$0$\"" "" "$INSTDIR\.install\windows\fs_logo.ico" FunctionEnd \ No newline at end of file From edf4dfd07b44de03933ebd1b94adeab4749cec39 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 2 Dec 2019 12:54:28 +0000 Subject: [PATCH 165/981] Bugfix - Convert on-the-fly to ExtractMedia object --- scripts/convert.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/scripts/convert.py b/scripts/convert.py index e05f974e72..0d90372db4 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -23,7 +23,7 @@ from lib.multithreading import MultiThread, total_cpus from lib.queue_manager import queue_manager from lib.utils import FaceswapError, get_folder, get_image_paths -from plugins.extract.pipeline import Extractor +from plugins.extract.pipeline import Extractor, ExtractMedia from plugins.plugin_loader import PluginLoader logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -380,12 +380,10 @@ def check_alignments(self, frame): def detect_faces(self, filename, image): """ Extract the face from a frame (If alignments file not found) """ - inp = {"filename": filename, - "image": image} - self.extractor.input_queue.put(inp) + self.extractor.input_queue.put(ExtractMedia(filename, image)) faces = next(self.extractor.detected_faces()) - final_faces = [face for face in faces["detected_faces"]] + final_faces = [face for face in faces.detected_faces] return final_faces # Saving tasks From 28442837cd090b6d424659e5a434d93efef4dc97 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 2 Dec 2019 13:08:14 +0000 Subject: [PATCH 166/981] bugfix - Convert - On-The-Fly to conversion to ExtractMedia object --- scripts/convert.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/scripts/convert.py b/scripts/convert.py index e05f974e72..0d90372db4 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -23,7 +23,7 @@ from lib.multithreading import MultiThread, total_cpus from lib.queue_manager import queue_manager from lib.utils import FaceswapError, get_folder, get_image_paths -from plugins.extract.pipeline import Extractor +from plugins.extract.pipeline import Extractor, ExtractMedia from plugins.plugin_loader import PluginLoader logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -380,12 +380,10 @@ def check_alignments(self, frame): def detect_faces(self, filename, image): """ Extract the face from a frame (If alignments file not found) """ - inp = {"filename": filename, - "image": image} - self.extractor.input_queue.put(inp) + self.extractor.input_queue.put(ExtractMedia(filename, image)) faces = next(self.extractor.detected_faces()) - final_faces = [face for face in faces["detected_faces"]] + final_faces = [face for face in faces.detected_faces] return final_faces # Saving tasks From e3f494aa41a2ffe2d4df97f14530c9f00b24facf Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 2 Dec 2019 15:54:45 +0000 Subject: [PATCH 167/981] bugfix: Mask Tool - To ExtractMedia object --- tools/mask.py | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/tools/mask.py b/tools/mask.py index df6dd59530..8cedb83d19 100644 --- a/tools/mask.py +++ b/tools/mask.py @@ -13,7 +13,7 @@ from lib.multithreading import MultiThread from lib.utils import set_system_verbosity, get_folder -from plugins.extract.pipeline import Extractor +from plugins.extract.pipeline import Extractor, ExtractMedia logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -154,7 +154,19 @@ def _input_faces(self, *args): self._skip_count += 1 logger.warning("Skipping face not in alignments file: '%s'", filename) continue - for frame, idx in self._alignments.hashes_to_frame[hsh].items(): + + frames = self._alignments.hashes_to_frame[hsh] + if len(frames) > 1: + # Filter the output by filename in case of multiple frames with the same face + logger.debug("Filtering multiple hashes to current filename: (filename: '%s', " + "frames: %s", filename, frames) + lookup = os.path.splitext(os.path.basename(filename))[0] + frames = {k: v + for k, v in frames.items() + if lookup.startswith(os.path.splitext(k)[0])} + logger.debug("Filtered: (filename: '%s', frame: '%s')", filename, frames) + + for frame, idx in frames.items(): self._face_count += 1 alignment = self._alignments.get_faces_in_frame(frame)[idx] if self._check_for_missing(frame, idx, alignment): @@ -164,7 +176,7 @@ def _input_faces(self, *args): detected_face.image = image self._save(frame, idx, detected_face) else: - queue.put(dict(filename=filename, image=image, detected_faces=[detected_face])) + queue.put(ExtractMedia(filename, image, detected_faces=[detected_face])) self._update_count += 1 if self._update_type != "output": queue.put("EOF") @@ -203,7 +215,7 @@ def _input_frames(self, *args): detected_faces.append(detected_face) self._update_count += 1 if self._update_type != "output": - queue.put(dict(filename=filename, image=image, detected_faces=detected_faces)) + queue.put(ExtractMedia(filename, image, detected_faces=[detected_face])) if self._update_type != "output": queue.put("EOF") @@ -300,7 +312,7 @@ def _update_faces(self, extractor_output): extractor_output: dict The output from the :class:`plugins.extract.pipeline.Extractor` object """ - for face in extractor_output["detected_faces"]: + for face in extractor_output.detected_faces: for frame, idx in self._alignments.hashes_to_frame[face.hash].items(): self._alignments.update_face(frame, idx, face.to_alignment()) if self._saver is not None: @@ -316,8 +328,8 @@ def _update_frames(self, extractor_output): extractor_output: dict The output from the :class:`plugins.extract.pipeline.Extractor` object """ - frame = os.path.basename(extractor_output["filename"]) - for idx, face in enumerate(extractor_output["detected_faces"]): + 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: self._save(frame, idx, face) @@ -337,8 +349,7 @@ def _save(self, frame, idx, detected_face): filename = os.path.join(self._saver.location, "{}_{}_{}".format( os.path.splitext(frame)[0], idx, - self._output_suffix) - ) + self._output_suffix)) if detected_face.mask is None or detected_face.mask.get(self._mask_type, None) is None: logger.warning("Mask type '%s' does not exist for frame '%s' index %s. Skipping", @@ -379,12 +390,12 @@ def _create_image(self, detected_face): mask = mask.get_full_frame_mask(face.shape[1], face.shape[0]) mask = np.expand_dims(mask, -1) - h, w = face.shape[:2] + height, width = face.shape[:2] if self._output_type == "combined": masked = (face.astype("float32") * mask.astype("float32") / 255.).astype("uint8") mask = np.tile(mask, 3) for img in (face, masked, mask): - cv2.rectangle(img, (0, 0), (w - 1, h - 1), (255, 255, 255), 1) + cv2.rectangle(img, (0, 0), (width - 1, height - 1), (255, 255, 255), 1) out_image = np.concatenate((face, masked, mask), axis=1) elif self._output_type == "mask": out_image = mask From c8c26014550c7a1b386e346976c30214c37ab3ed Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 5 Dec 2019 12:50:00 +0000 Subject: [PATCH 168/981] bugfix: tools.alignments - Fix DFL conversion --- tools/lib_alignments/media.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tools/lib_alignments/media.py b/tools/lib_alignments/media.py index 3276075a3d..cc59ace6e7 100644 --- a/tools/lib_alignments/media.py +++ b/tools/lib_alignments/media.py @@ -12,7 +12,7 @@ # import imageio from lib.aligner import Extract as AlignerExtract -from lib.alignments import Alignments +from lib.alignments import Alignments, get_serializer from lib.faces_detect import DetectedFace from lib.image import (count_frames, encode_image_with_hash, read_image, read_image_hash_batch) @@ -30,7 +30,8 @@ def __init__(self, alignments_file): logger.info("[ALIGNMENT DATA]") # Tidy up cli output folder, filename = self.check_file_exists(alignments_file) if filename.lower() == "dfl": - self.file = filename + self.serializer = get_serializer("compressed") + self.file = "{}.{}".format(filename.lower(), self.serializer.file_extension) return super().__init__(folder, filename=filename) logger.verbose("%s items loaded", self.frames_count) @@ -170,7 +171,7 @@ class Faces(MediaLoader): """ Object to hold the faces that are to be swapped out """ def process_folder(self): - """ Iterate through the faces dir pulling out various information """ + """ Iterate through the faces folder pulling out various information """ logger.info("Loading file list from %s", self.folder) filelist = [os.path.join(self.folder, face) @@ -209,7 +210,7 @@ class Frames(MediaLoader): """ Object to hold the frames that are to be checked against """ def process_folder(self): - """ Iterate through the frames dir pulling the base filename """ + """ Iterate through the frames folder pulling the base filename """ iterator = self.process_video if self.is_video else self.process_frames for item in iterator(): yield item From 43a4d06540b2eeecba4aeb0dfdfaf4f289d916ec Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 5 Dec 2019 16:02:01 +0000 Subject: [PATCH 169/981] Smart Masks - Training Implementation (#914) * Smart Masks - Training - Reinstate smart mask training code - Reinstate mask_type back to model.config - change 'replicate_input_mask to 'learn_mask' - Add learn mask option - Add mask loading from alignments to plugins.train.trainer - Add mask_blur and mask threshold options - _base.py - Pass mask options through training_opts dict - plugins.train.model - check for mask_type not None for learn_mask and penalized_mask_loss - Limit alignments loading to just those faces that appear in the training folder - Raise error if not all training images have an alignment, and alignment file is required - lib.training_data - Mask generation code - lib.faces_detect - cv2 dimension stripping bugfix - Remove cv2 linting code * Update mask helptext in cli.py * Fix Warp to Landmarks Remove SHA1 hashing from training data * Update mask training config * Capture missing masks at training init * lib.image.read_image_batch - Return filenames with batch for ordering * scripts.train - Documentation * plugins.train.trainer - documentation * Ensure backward compatibility. Fix convert for new predicted masks * Update removed masks to components for legacy models. --- docs/full/plugins.extract.align.rst | 17 - docs/full/plugins.extract.detect._base.rst | 2 +- docs/full/plugins.extract.detect.rst | 17 - docs/full/plugins.extract.mask.rst | 17 - docs/full/plugins.extract.rst | 14 +- docs/full/plugins.rst | 9 +- docs/full/plugins.train.rst | 9 + docs/full/plugins.train.trainer._base.rst | 7 + docs/full/scripts.extract.rst | 2 +- docs/full/scripts.rst | 1 + docs/full/scripts.train.rst | 7 + lib/cli.py | 6 +- lib/convert.py | 7 +- lib/image.py | 25 +- lib/training_data.py | 190 +-- lib/utils.py | 2 + plugins/train/_config.py | 54 +- plugins/train/model/_base.py | 123 +- plugins/train/model/dfaker.py | 2 +- plugins/train/model/dfl_h128.py | 4 +- plugins/train/model/dfl_sae.py | 2 +- plugins/train/model/iae.py | 2 +- plugins/train/model/lightweight.py | 2 +- plugins/train/model/original.py | 2 +- plugins/train/model/realface.py | 4 +- plugins/train/model/unbalanced.py | 4 +- plugins/train/model/villain.py | 2 +- plugins/train/trainer/_base.py | 1329 +++++++++++++++----- scripts/convert.py | 10 +- scripts/extract.py | 3 +- scripts/train.py | 350 ++++-- 31 files changed, 1505 insertions(+), 720 deletions(-) delete mode 100644 docs/full/plugins.extract.align.rst delete mode 100644 docs/full/plugins.extract.detect.rst delete mode 100644 docs/full/plugins.extract.mask.rst create mode 100644 docs/full/plugins.train.rst create mode 100644 docs/full/plugins.train.trainer._base.rst create mode 100644 docs/full/scripts.train.rst diff --git a/docs/full/plugins.extract.align.rst b/docs/full/plugins.extract.align.rst deleted file mode 100644 index 7ae7a06f36..0000000000 --- a/docs/full/plugins.extract.align.rst +++ /dev/null @@ -1,17 +0,0 @@ -plugins.extract.align package -============================= - -Submodules ----------- - -.. toctree:: - - plugins.extract.align._base - -Module contents ---------------- - -.. automodule:: plugins.extract.align - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/plugins.extract.detect._base.rst b/docs/full/plugins.extract.detect._base.rst index 3ee95a1762..d89e4e321c 100644 --- a/docs/full/plugins.extract.detect._base.rst +++ b/docs/full/plugins.extract.detect._base.rst @@ -1,5 +1,5 @@ plugins.extract.detect._base module -====================================== +=================================== .. automodule:: plugins.extract.detect._base :members: diff --git a/docs/full/plugins.extract.detect.rst b/docs/full/plugins.extract.detect.rst deleted file mode 100644 index 27f2d9d137..0000000000 --- a/docs/full/plugins.extract.detect.rst +++ /dev/null @@ -1,17 +0,0 @@ -plugins.extract.detect package -============================== - -Submodules ----------- - -.. toctree:: - - plugins.extract.detect._base - -Module contents ---------------- - -.. automodule:: plugins.extract.detect - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/plugins.extract.mask.rst b/docs/full/plugins.extract.mask.rst deleted file mode 100644 index a74874478f..0000000000 --- a/docs/full/plugins.extract.mask.rst +++ /dev/null @@ -1,17 +0,0 @@ -plugins.extract.mask package -============================== - -Submodules ----------- - -.. toctree:: - - plugins.extract.mask._base - -Module contents ---------------- - -.. automodule:: plugins.extract.mask - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/plugins.extract.rst b/docs/full/plugins.extract.rst index 3061ac99d6..8c4ea2e5da 100644 --- a/docs/full/plugins.extract.rst +++ b/docs/full/plugins.extract.rst @@ -6,9 +6,9 @@ Subpackages .. toctree:: - plugins.extract.align - plugins.extract.detect - plugins.extract.mask + plugins.extract.align._base + plugins.extract.detect._base + plugins.extract.mask._base Submodules ---------- @@ -17,11 +17,3 @@ Submodules plugins.extract._base plugins.extract.pipeline - -Module contents ---------------- - -.. automodule:: plugins.extract - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/plugins.rst b/docs/full/plugins.rst index 37ea4a14cf..bfaecffcf4 100644 --- a/docs/full/plugins.rst +++ b/docs/full/plugins.rst @@ -8,11 +8,4 @@ Subpackages plugins.extract plugins.plugin_loader - -Module contents ---------------- - -.. automodule:: plugins - :members: - :undoc-members: - :show-inheritance: + plugins.train diff --git a/docs/full/plugins.train.rst b/docs/full/plugins.train.rst new file mode 100644 index 0000000000..17a8166c00 --- /dev/null +++ b/docs/full/plugins.train.rst @@ -0,0 +1,9 @@ +plugins.train package +===================== + +Subpackages +----------- + +.. toctree:: + + plugins.train.trainer._base diff --git a/docs/full/plugins.train.trainer._base.rst b/docs/full/plugins.train.trainer._base.rst new file mode 100644 index 0000000000..188f869bec --- /dev/null +++ b/docs/full/plugins.train.trainer._base.rst @@ -0,0 +1,7 @@ +plugins.train.trainer._base module +================================== + +.. automodule:: plugins.train.trainer._base + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/scripts.extract.rst b/docs/full/scripts.extract.rst index 6caa7b520e..742f40bab2 100644 --- a/docs/full/scripts.extract.rst +++ b/docs/full/scripts.extract.rst @@ -1,5 +1,5 @@ scripts.extract -======================= +=============== .. automodule:: scripts.extract :members: diff --git a/docs/full/scripts.rst b/docs/full/scripts.rst index baa53d1123..ebf3c048da 100644 --- a/docs/full/scripts.rst +++ b/docs/full/scripts.rst @@ -7,6 +7,7 @@ Subpackages .. toctree:: scripts.extract + scripts.train Module contents --------------- diff --git a/docs/full/scripts.train.rst b/docs/full/scripts.train.rst new file mode 100644 index 0000000000..111f2b3a14 --- /dev/null +++ b/docs/full/scripts.train.rst @@ -0,0 +1,7 @@ +scripts.train +============= + +.. automodule:: scripts.train + :members: + :undoc-members: + :show-inheritance: diff --git a/lib/cli.py b/lib/cli.py index d014265a2b..f8d312bd07 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -569,9 +569,9 @@ def get_optional_arguments(): "choices": PluginLoader.get_available_extractors("mask", add_none=True), "default": "extended", "group": "Plugins", - "help": "R|Masker to use. NB: Masker is not currently used by the rest of the process " - "but this will store a mask in the alignments file for use when it has been " - "implemented." + "help": "R|Masker to use. NB - masks generated here can be used for training, and " + "converting with the 'predicted' mask. Availability of all masks specified " + "here for convert is coming soon." "\nL|none: Don't use a mask." "\nL|components: Mask designed to provide facial segmentation based on the " "positioning of landmark locations. A convex hull is constructed around the " diff --git a/lib/convert.py b/lib/convert.py index e9d6d1edfb..d29296607f 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -99,11 +99,8 @@ def process(self, in_queue, out_queue, completion_queue=None): item["filename"], str(err)) image = item["image"] # UNCOMMENT THIS CODE BLOCK TO PRINT TRACEBACK ERRORS - # import sys - # import traceback - # exc_info = sys.exc_info() - # traceback.print_exception(*exc_info) - + # import sys ; import traceback + # exc_info = sys.exc_info() ; traceback.print_exception(*exc_info) logger.trace("Out queue put: %s", item["filename"]) out_queue.put((item["filename"], image)) logger.debug("Completed convert process") diff --git a/lib/image.py b/lib/image.py index 6f30b10c02..024a65c33d 100644 --- a/lib/image.py +++ b/lib/image.py @@ -96,6 +96,12 @@ def read_image_batch(filenames): Leverages multi-threading to load multiple images from disk at the same time leading to vastly reduced image read times. + Notes + ----- + Images are loaded concurrently, so the order of the returned batch will likely not be the same + as the order of the input filenames. Filenames are returned with the batch in the correct order + corresponding to the returned batch. + Parameters ---------- filenames: list @@ -103,6 +109,8 @@ def read_image_batch(filenames): Returns ------- + list + Filenames in the correct order as they are returned numpy.ndarray The batch of images in `BGR` channel order. @@ -113,16 +121,21 @@ def read_image_batch(filenames): Example ------- >>> image_filenames = ["/path/to/image_1.png", "/path/to/image_2.png", "/path/to/image_3.png"] - >>> images = read_image_batch(image_filenames) + >>> filenames, images = read_image_batch(image_filenames) """ logger.trace("Requested batch: '%s'", filenames) executor = futures.ThreadPoolExecutor() with executor: - images = [executor.submit(read_image, filename, raise_error=True) - for filename in filenames] - batch = np.array([future.result() for future in futures.as_completed(images)]) - logger.trace("Returning images: %s", batch.shape) - return batch + images = {executor.submit(read_image, filename, raise_error=True): filename + for filename in filenames} + batch = [] + filenames = [] + for future in futures.as_completed(images): + batch.append(future.result()) + filenames.append(images[future]) + batch = np.array(batch) + logger.trace("Returning images: (filenames: %s, batch shape: %s)", filenames, batch.shape) + return filenames, batch def read_image_hash(filename): diff --git a/lib/training_data.py b/lib/training_data.py index 84c201035f..b8cffd7d29 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -3,7 +3,6 @@ import logging -from hashlib import sha1 from random import shuffle, choice import numpy as np @@ -11,7 +10,6 @@ from scipy.interpolate import griddata from lib.image import batch_convert_color, read_image_batch -from lib.model import masks from lib.multithreading import BackgroundGenerator from lib.utils import FaceswapError @@ -40,7 +38,7 @@ class TrainingDataGenerator(): contain the following keys: * **coverage_ratio** (`float`) - The ratio of the training image to be trained on. \ - Dictates how much of the image will be cropped out. Eg: a coverage ratio of 0.625 \ + Dictates how much of the image will be cropped out. E.G: a coverage ratio of 0.625 \ will result in cropping a 160px box from a 256px image (256 * 0.625 = 160). * **augment_color** (`bool`) - ``True`` if color is to be augmented, otherwise ``False`` \ @@ -48,17 +46,17 @@ class TrainingDataGenerator(): * **no_flip** (`bool`) - ``True`` if the image shouldn't be randomly flipped as part of \ augmentation, otherwise ``False`` - * **mask_type** (`str`) - The mask type to be used (as defined in \ - :mod:`lib.model.masks`). If not ``None`` then the additional key ``landmarks`` must be \ - provided. - * **warp_to_landmarks** (`bool`) - ``True`` if the random warp method should warp to \ similar landmarks from the other side, ``False`` if the standard random warp method \ should be used. If ``True`` then the additional key ``landmarks`` must be provided. - * **landmarks** (`numpy.ndarray`, `optional`). Required if using a :attr:`mask_type` is \ - not ``None`` or :attr:`warp_to_landmarks` is ``True``. The 68 point face landmarks from \ - an alignments file. + * **landmarks** (`dict`, `optional`). Required if :attr:`warp_to_landmarks` is \ + ``True``. Returning dictionary has a key of **side** (`str`) the value of which is a \ + `dict` of {**filename** (`str`): **68 point landmarks** (`numpy.ndarray`)}. + + * **masks** (`dict`, `optional`). Required if :attr:`penalized_mask_loss` or \ + :attr:`learn_mask` is ``True``. Returning dictionary has a key of **side** (`str`) the \ + value of which is a `dict` of {**filename** (`str`): :class:`lib.faces_detect.Mask`}. config: dict The configuration ``dict`` generated from :file:`config.train.ini` containing the trainer \ @@ -66,16 +64,20 @@ class TrainingDataGenerator(): """ def __init__(self, model_input_size, model_output_shapes, training_opts, config): logger.debug("Initializing %s: (model_input_size: %s, model_output_shapes: %s, " - "training_opts: %s, landmarks: %s, config: %s)", + "training_opts: %s, landmarks: %s, masks: %s, config: %s)", self.__class__.__name__, model_input_size, model_output_shapes, - {key: val for key, val in training_opts.items() if key != "landmarks"}, - bool(training_opts.get("landmarks", None)), config) + {key: val + for key, val in training_opts.items() if key not in ("landmarks", "masks")}, + {key: len(val) + for key, val in training_opts.get("landmarks", dict()).items()}, + {key: len(val) for key, val in training_opts.get("masks", dict()).items()}, + config) self._config = config self._model_input_size = model_input_size self._model_output_shapes = model_output_shapes self._training_opts = training_opts - self._mask_class = self._set__mask_class() self._landmarks = self._training_opts.get("landmarks", None) + self._masks = self._training_opts.get("masks", None) self._nearest_landmarks = {} # Batchsize and processing class are set when this class is called by a batcher @@ -90,7 +92,7 @@ def minibatch_ab(self, images, batchsize, side, The exit point from this class and the sole attribute that should be referenced. Called from :mod:`plugins.train.trainer._base`. Returns an iterator that yields images for - training, preview and timelapses. + training, preview and time-lapses. Parameters ---------- @@ -103,13 +105,13 @@ def minibatch_ab(self, images, batchsize, side, The side of the model that this iterator is for. do_shuffle: bool, optional Whether data should be shuffled prior to loading from disk. If true, each time the full - list of filenames are processed, the data will be reshuffled to make sure thay are not + list of filenames are processed, the data will be reshuffled to make sure they are not returned in the same order. Default: ``True`` is_preview: bool, optional Indicates whether this iterator is generating preview images. If ``True`` then certain augmentations will not be performed. Default: ``False`` is_timelapse: bool optional - Indicates whether this iterator is generating Timelapse images. If ``True``, then + Indicates whether this iterator is generating time-lapse images. If ``True``, then certain augmentations will not be performed. Default: ``False`` Yields @@ -130,14 +132,13 @@ def minibatch_ab(self, images, batchsize, side, :mod:`plugins.train.trainer._base` from the ``masks`` key. * **masks** (`numpy.ndarray`) - A 4-dimensional array containing the target masks in \ - the format (`batchsize`, `height`, `width`, `1`). **NB:** This item will only exist \ - in the ``dict`` if the :attr:`mask_type` is not ``None`` - - * **samples** (`numpy.ndarray`) - A 4-dimensional array containg the samples for \ - feeding to the model's predict function for generating preview and timelapse samples. \ - The array will be in the format (`batchsize`, `height`, `width`, `channels`). **NB:** \ - This item will only exist in the ``dict`` if :attr:`is_preview` or \ - :attr:`is_timelapse` is ``True`` + the format (`batchsize`, `height`, `width`, `1`). + + * **samples** (`numpy.ndarray`) - A 4-dimensional array containing the samples for \ + feeding to the model's predict function for generating preview and time-lapse \ + samples. The array will be in the format (`batchsize`, `height`, `width`, \ + `channels`). **NB:** This item will only exist in the ``dict`` if :attr:`is_preview` \ + or :attr:`is_timelapse` is ``True`` """ logger.debug("Queue batches: (image_count: %s, batchsize: %s, side: '%s', do_shuffle: %s, " "is_preview, %s, is_timelapse: %s)", len(images), batchsize, side, do_shuffle, @@ -154,18 +155,6 @@ def minibatch_ab(self, images, batchsize, side, return batcher.iterator() # << INTERNAL METHODS >> # - def _set__mask_class(self): - """ Returns the correct mask class from :mod:`lib`.model.masks` as defined in the - :attr:`mask_type` parameter. """ - mask_type = self._training_opts.get("mask_type", None) - if mask_type: - logger.debug("Mask type: '%s'", mask_type) - _mask_class = getattr(masks, mask_type) - else: - _mask_class = None - logger.debug("Mask class: %s", _mask_class) - return _mask_class - def _validate_samples(self, data): """ Ensures that the total number of images within :attr:`images` is greater or equal to the selected :attr:`batchsize`. Raises an exception if this is not the case. """ @@ -205,26 +194,26 @@ def _process_batch(self, filenames, side): """ Performs the augmentation and compiles target images and samples. See :func:`minibatch_ab` for more details on the output. """ logger.trace("Process batch: (filenames: '%s', side: '%s')", filenames, side) - batch = read_image_batch(filenames) + filenames, batch = read_image_batch(filenames) + batch = self._apply_mask(filenames, batch, side) processed = dict() - to_landmarks = self._training_opts["warp_to_landmarks"] # Initialize processing training size on first image if not self._processing.initialized: self._processing.initialize(batch.shape[1]) # Get Landmarks prior to manipulating the image - if self._mask_class or to_landmarks: - batch_src_pts = self._get_landmarks(filenames, batch, side) + if self._training_opts["warp_to_landmarks"]: + batch_src_pts = self._get_landmarks(filenames, side) + batch_dst_pts = self._get_closest_match(filenames, side, batch_src_pts) + warp_kwargs = dict(batch_src_points=batch_src_pts, + batch_dst_points=batch_dst_pts) + else: + warp_kwargs = dict() - # Color augmentation before mask is added + # Color Augmentation of the image only if self._training_opts["augment_color"]: - batch = self._processing.color_adjust(batch) - - # Add mask to batch prior to transforms and warps - if self._mask_class: - batch = np.array([self._mask_class(src_pts, image, channels=4).mask - for src_pts, image in zip(batch_src_pts, batch)]) + batch[..., :3] = self._processing.color_adjust(batch[..., :3]) # Random Transform and flip batch = self._processing.transform(batch) @@ -238,15 +227,10 @@ def _process_batch(self, filenames, side): # Get Targets processed.update(self._processing.get_targets(batch)) - # Random Warp - if to_landmarks: - warp_kwargs = dict(batch_src_points=batch_src_pts, - batch_dst_points=self._get_closest_match(filenames, - side, - batch_src_pts)) - else: - warp_kwargs = dict() - processed["feed"] = self._processing.warp(batch[..., :3], to_landmarks, **warp_kwargs) + # Random Warp # TODO change masks to have a input mask and a warped target mask + processed["feed"] = [self._processing.warp(batch[..., :3], + self._training_opts["warp_to_landmarks"], + **warp_kwargs)] logger.trace("Processed batch: (filenames: %s, side: '%s', processed: %s)", filenames, @@ -256,21 +240,53 @@ def _process_batch(self, filenames, side): return processed - def _get_landmarks(self, filenames, batch, side): + def _apply_mask(self, filenames, batch, side): + """ Applies the mask to the 4th channel of the image. If masks are not being used + applies a dummy all ones mask """ + logger.trace("Input batch shape: %s, side: %s", batch.shape, side) + if self._masks is None: + logger.trace("Creating dummy masks. side: %s", side) + masks = np.ones_like(batch[..., :1], dtype=batch.dtype) + else: + logger.trace("Obtaining masks for batch. side: %s", side) + masks = np.array([self._masks[side][filename].mask + for filename, face in zip(filenames, batch)], dtype=batch.dtype) + masks = self._resize_masks(batch.shape[1], masks) + + logger.trace("masks shape: %s", masks.shape) + batch = np.concatenate((batch, masks), axis=-1) + logger.trace("Output batch shape: %s, side: %s", batch.shape, side) + return batch + + @staticmethod + def _resize_masks(target_size, masks): + """ Resize the masks to the target size """ + logger.trace("target size: %s, masks shape: %s", target_size, masks.shape) + mask_size = masks.shape[1] + if target_size == mask_size: + logger.trace("Mask and targets the same size. Not resizing") + return masks + interpolator = cv2.INTER_CUBIC if mask_size < target_size else cv2.INTER_AREA + masks = np.array([cv2.resize(mask, + (target_size, target_size), + interpolation=interpolator)[..., None] + for mask in masks]) + logger.trace("Resized masks: %s", masks.shape) + return masks + + def _get_landmarks(self, filenames, side): """ Obtains the 68 Point Landmarks for the images in this batch. This is only called if - config item ``warp_to_landmarks`` is ``True`` or if :attr:`mask_type` is not ``None``. If - the landmarks for an image cannot be found, then an error is raised. """ + config item ``warp_to_landmarks`` is ``True``. If the landmarks for an image cannot be + found, then an error is raised. """ logger.trace("Retrieving landmarks: (filenames: %s, side: '%s')", filenames, side) - src_points = [self._landmarks[side].get(sha1(face).hexdigest(), None) for face in batch] - + src_points = [self._landmarks[side].get(filename, None) for filename in filenames] # Raise error on missing alignments if not all(isinstance(pts, np.ndarray) for pts in src_points): - indices = [idx for idx, hsh in enumerate(src_points) if hsh is None] - missing = [filenames[idx] for idx in indices] + missing = [filenames[idx] for idx, pts in enumerate(src_points) if pts is None] msg = ("Files missing alignments for this batch: {}" "\nAt least one of your images does not have a matching entry in your " "alignments file." - "\nIf you are training with a mask or using 'warp to landmarks' then every " + "\nIf you are using 'warp to landmarks' then every " "face you intend to train on must exist within the alignments file." "\nThe specific files that caused this failure are listed above." "\nMost likely there will be more than just these files missing from the " @@ -319,7 +335,7 @@ class ImageAugmentation(): batchsize: int The number of images that will be fed through the augmentation functions at once. is_display: bool - Whether the images being fed through will be used for Preview or Timelapse. Disables + Whether the images being fed through will be used for Preview or Time-lapse. Disables the "warp" augmentation for these images. input_size: int The expected input size for the model. It is assumed that the input to the model is always @@ -330,8 +346,8 @@ class ImageAugmentation(): are returned. The tuples should be in (`height`, `width`, `channels`) format. coverage_ratio: float The ratio of the training image to be trained on. Dictates how much of the image will be - cropped out. Eg: a coverage ratio of 0.625 will result in cropping a 160px box from a 256px - image (256 * 0.625 = 160). + cropped out. E.G: a coverage ratio of 0.625 will result in cropping a 160px box from a " + "256px image (256 * 0.625 = 160). config: dict The configuration ``dict`` generated from :file:`config.train.ini` containing the trainer \ plugin configuration options. @@ -342,7 +358,7 @@ class ImageAugmentation(): Flag to indicate whether :class:`ImageAugmentation` has been initialized with the training image size in order to cache certain augmentation operations (see :func:`initialize`) is_display: bool - Flag to indicate whether these augmentations are for timelapses/preview images (``True``) + Flag to indicate whether these augmentations are for time-lapses/preview images (``True``) or standard training data (``False)`` """ def __init__(self, batchsize, is_display, input_size, output_shapes, coverage_ratio, config): @@ -449,18 +465,17 @@ def get_targets(self, batch): output they will be returned as their own item from the ``masks`` key. * **masks** (`numpy.ndarray`) - A 4-dimensional array containing the target masks in \ - the format (`batchsize`, `height`, `width`, `1`). **NB:** This item will only exist \ - in the ``dict`` if a batch of 4 channel images has been passed in :attr:`batch` + the format (`batchsize`, `height`, `width`, `1`). """ logger.trace("Compiling targets") slices = self._constants["tgt_slices"] target_batch = [np.array([cv2.resize(image[slices, slices, :], (size, size), cv2.INTER_AREA) - for image in batch]) + for image in batch], dtype='float32') / 255. for size in self._output_sizes] logger.trace("Target image shapes: %s", - [tgt.shape for tgt_images in target_batch for tgt in tgt_images]) + [tgt_images.shape[1:] for tgt_images in target_batch]) retval = self._separate_target_mask(target_batch) logger.trace("Final targets: %s", @@ -469,25 +484,19 @@ def get_targets(self, batch): return retval @staticmethod - def _separate_target_mask(batch): + def _separate_target_mask(target_batch): """ Return the batch and the batch of final masks Returns the targets as a list of 4-dimensional ``numpy.ndarray`` s of shape (`batchsize`, - `height`, `width`, 3). If the :attr:`batch` is 4 channels, then the masks will be split - from the batch, with the largest output masks being returned in their own item. + `height`, `width`, 3). + + The target masks are returned as its own item and is the 4th channel of the final target + output. """ - batch = [tgt.astype("float32") / 255.0 for tgt in batch] - if all(tgt.shape[-1] == 4 for tgt in batch): - logger.trace("Batch contains mask") - sizes = [item.shape[1] for item in batch] - mask_batch = np.expand_dims(batch[sizes.index(max(sizes))][..., -1], axis=-1) - batch = [item[..., :3] for item in batch] - logger.trace("batch shapes: %s, mask_batch shape: %s", - [tgt.shape for tgt in batch], mask_batch.shape) - retval = dict(targets=batch, masks=mask_batch) - else: - logger.trace("Batch has no mask") - retval = dict(targets=batch) + logger.trace("target_batch shapes: %s", [tgt.shape for tgt in target_batch]) + retval = dict(targets=[batch[..., :3] for batch in target_batch], + masks=[target_batch[-1][..., 3:]]) + logger.trace("returning: %s", {k: [tgt.shape for tgt in v] for k, v in retval.items()}) return retval # <<< COLOR AUGMENTATION >>> # @@ -517,7 +526,7 @@ def color_adjust(self, batch): return batch def _random_clahe(self, batch): - """ Randomly perform Contrast Limited Adaptive Histogram Equilization on + """ Randomly perform Contrast Limited Adaptive Histogram Equalization on a batch of images """ base_contrast = self._constants["clahe_base_contrast"] @@ -540,7 +549,8 @@ def _random_clahe(self, batch): return batch def _random_lab(self, batch): - """ Perform random color/lightness adjustment in L*a*b* colorspace on a batch of images """ + """ Perform random color/lightness adjustment in L*a*b* color space on a batch of + images """ amount_l = self._config.get("color_lightness", 30) / 100 amount_ab = self._config.get("color_ab", 8) / 100 adjust = np.array([amount_l, amount_ab, amount_ab], dtype="float32") diff --git a/lib/utils.py b/lib/utils.py index b9af6cb404..272c44f3f3 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -413,6 +413,7 @@ def write_zipfile(self, response, downloaded_size): break pbar.update(len(buffer)) out_file.write(buffer) + pbar.close() def unzip_model(self): """ Unzip the model file to the cachedir """ @@ -446,3 +447,4 @@ def write_model(self, zip_file): pbar.update(len(buffer)) out_file.write(buffer) zip_file.close() + pbar.close() diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 31d1dd578b..0326bc7160 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -8,8 +8,8 @@ from importlib import import_module from lib.config import FaceswapConfig -from lib.model.masks import get_available_masks from lib.utils import full_path_split +from plugins.plugin_loader import PluginLoader logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -70,19 +70,49 @@ def set_globals(self): "\n\t100.0%% is a mugshot.") self.add_item( section=section, title="mask_type", datatype=str, default="none", - choices=get_available_masks(), group="mask", - info="The mask to be used for training:" - "\n\t none: Doesn't use any mask." - "\n\t components: An improved face hull mask using a facehull of 8 facial parts" - "\n\t dfl_full: An improved face hull mask using a facehull of 3 facial parts" - "\n\t extended: Based on components mask. Extends the eyebrow points to further " - "up the forehead. May perform badly on difficult angles." - "\n\t facehull: Face cutout based on landmarks") + choices=PluginLoader.get_available_extractors("mask", add_none=True), group="mask", + gui_radio=True, + info="The mask to be used for training. If you have selected 'Learn Mask' or " + "'Penalized Mask Loss' you must select a value other than 'none'. The required " + "mask should have been selected as part of the Extract process. If it does not " + "exist in the alignments file then it will be generated prior to training " + "commencing." + "\n\tnone: Don't use a mask." + "\n\tcomponents: 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." + "\n\textended: 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." + "\n\tvgg-clear: Mask designed to provide smart segmentation of mostly frontal " + "faces clear of obstructions. Profile faces and obstructions may result in " + "sub-par performance." + "\n\tvgg-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." + "\n\tunet-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.") self.add_item( - section=section, title="mask_blur", datatype=bool, default=False, group="mask", + section=section, title="mask_blur_kernel", datatype=int, min_max=(0, 9), + rounding=1, default=3, group="mask", info="Apply gaussian blur to the mask input. This has the effect of smoothing the " - "edges of the mask, which can help with poorly calculated masks, and give less " - "of a hard edge to the predicted mask.") + "edges of the mask, which can help with poorly calculated masks and give less " + "of a hard edge to the predicted mask. The size is in pixels (calculated from " + "a 128px mask). Set to 0 to not apply gaussian blur. This value should be odd, " + "if an even number is passed in then it will be rounded to the next odd number.") + self.add_item( + section=section, title="mask_threshold", datatype=int, default=4, + min_max=(0, 50), rounding=1, group="mask", + info="Sets pixels that are near white to white and near black to black. Set to 0 for " + "off.") + self.add_item( + section=section, title="learn_mask", datatype=bool, default=False, group="mask", + info="Dedicate a portion of the model to learning how to duplicate the input " + "mask. Increases VRAM usage in exchange for learning a quick ability to try " + "to replicate more complex mask models.") self.add_item( section=section, title="icnr_init", datatype=bool, default=False, group="initialization", diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index df561c385d..2b31f97b7a 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -105,7 +105,18 @@ def __init__(self, "augment_color": augment_color, "no_flip": no_flip, "pingpong": self.vram_savings.pingpong, - "snapshot_interval": snapshot_interval} + "snapshot_interval": snapshot_interval, + "training_size": self.state.training_size, + "no_logs": self.state.current_session["no_logs"], + "coverage_ratio": self.calculate_coverage_ratio(), + "mask_type": self.config["mask_type"], + "mask_blur_kernel": self.config["mask_blur_kernel"], + "mask_threshold": self.config["mask_threshold"], + "learn_mask": (self.config["learn_mask"] and + self.config["mask_type"] is not None), + "penalized_mask_loss": (self.config["penalized_mask_loss"] and + self.config["mask_type"] is not None)} + logger.debug("training_opts: %s", self.training_opts) if self.multiple_models_in_folder: deprecation_warning("Support for multiple model types within the same folder", @@ -113,7 +124,6 @@ def __init__(self, "avoid issues in future.") self.build() - self.set_training_data() logger.debug("Initialized ModelBase (%s)", self.__class__.__name__) @property @@ -206,6 +216,12 @@ def largest_mask_index(self): logger.debug(retval) return retval + @property + def feed_mask(self): + """ bool: ``True`` if the model expects a mask to be fed into input otherwise ``False`` """ + return self.config["mask_type"] is not None and (self.config["learn_mask"] or + self.config["penalized_mask_loss"]) + def load_config(self): """ Load the global config for reference in self.config """ global _CONFIG # pylint: disable=global-statement @@ -214,18 +230,6 @@ def load_config(self): logger.debug("Loading config for: %s", model_name) _CONFIG = Config(model_name, configfile=self.configfile).config_dict - def set_training_data(self): - """ Override to set model specific training data. - - super() this method for defaults otherwise be sure to add """ - logger.debug("Setting training data") - # Force number of preview images to between 2 and 16 - self.training_opts["training_size"] = self.state.training_size - self.training_opts["no_logs"] = self.state.current_session["no_logs"] - self.training_opts["mask_type"] = self.config.get("mask_type", None) - self.training_opts["coverage_ratio"] = self.calculate_coverage_ratio() - logger.debug("Set training data: %s", self.training_opts) - def calculate_coverage_ratio(self): """ Coverage must be a ratio, leading to a cropped shape divisible by 2 """ coverage_ratio = self.config.get("coverage", 62.5) / 100 @@ -260,12 +264,11 @@ def get_inputs(self): logger.debug("Getting inputs") inputs = [Input(shape=self.input_shape, name="face_in")] output_network = [network for network in self.networks.values() if network.is_output][0] - mask_idx = [idx for idx, name in enumerate(output_network.output_names) - if name.startswith("mask")] - if mask_idx: - # Add the final mask shape as input - mask_shape = output_network.output_shapes[mask_idx[0]] - inputs.append(Input(shape=mask_shape[1:], name="mask_in")) + if self.feed_mask: + # TODO penalized mask doesn't have a mask output, so we can't use output shapes + # mask should always be last output..this needs to be a rule + mask_shape = output_network.output_shapes[-1] + inputs.append(Input(shape=(mask_shape[1:-1] + (1,)), name="mask_in")) logger.debug("Got inputs: %s", inputs) return inputs @@ -374,7 +377,7 @@ def get_optimizer(self, lr=5e-5, beta_1=0.5, beta_2=0.999): # pylint: disable=i opt_kwargs = dict(lr=lr, beta_1=beta_1, beta_2=beta_2) if (self.config.get("clipnorm", False) and keras.backend.backend() != "plaidml.keras.backend"): - # NB: Clipnorm is ballooning VRAM useage, which is not expected behaviour + # NB: Clipnorm is ballooning VRAM usage, which is not expected behavior # and may be a bug in Keras/TF. # PlaidML has a bug regarding the clipnorm parameter # See: https://github.com/plaidml/plaidml/issues/228 @@ -444,7 +447,7 @@ def load_models(self, swapped): logger.error("Model could not be found in folder '%s'. Exiting", self.model_dir) exit(0) - if not self.is_legacy: + if not self.is_legacy or not self.predict: K.clear_session() model_mapping = self.map_models(swapped) for network in self.networks.values(): @@ -579,6 +582,9 @@ def rename_legacy(self): self.state.config["subpixel_upscaling"] = False self.state.config["reflect_padding"] = False self.state.config["mask_type"] = None + self.state.config["mask_blur_kernel"] = 3 + self.state.config["mask_threshold"] = 4 + self.state.config["learn_mask"] = False self.state.config["lowmem"] = False self.encoder_dim = 1024 @@ -624,7 +630,7 @@ def set_optimizer_savings(self, optimizer_savings): return optimizer_savings def set_gradient_type(self, memory_saving_gradients): - """ Monkeypatch Memory Saving Gradients if requested """ + """ Monkey-patch Memory Saving Gradients if requested """ if memory_saving_gradients and self.is_plaidml: logger.warning("Memory Saving Gradients not supported on plaidML. Disabling") memory_saving_gradients = False @@ -743,7 +749,7 @@ def get_loss_functions(self): for idx, loss_name in enumerate(self.names): if loss_name.startswith("mask"): loss_funcs.append(self.selected_mask_loss) - elif self.mask_input is not None and self.config.get("penalized_mask_loss", False): + elif self.config["penalized_mask_loss"] and self.config["mask_type"] is not None: face_size = self.output_shapes[idx][1] mask_size = self.mask_shape[1] scaling = face_size / mask_size @@ -980,12 +986,12 @@ def replace_config(self, config_changeable_items): Check for any fixed=False parameters changes and log info changes """ global _CONFIG # pylint: disable=global-statement + legacy_update = self._update_legacy_config() # Add any new items to state config for legacy purposes for key, val in _CONFIG.items(): if key not in self.config.keys(): logger.info("Adding new config item to state file: '%s': '%s'", key, val) self.config[key] = val - legacy_update = self.update_legacy_config() self.update_changed_config_items(config_changeable_items) logger.debug("Replacing config. Old config: %s", _CONFIG) _CONFIG = self.config @@ -994,18 +1000,63 @@ def replace_config(self, config_changeable_items): logger.debug("Replaced config. New config: %s", _CONFIG) logger.info("Using configuration saved in state file") - def update_legacy_config(self): - """ Update legacy state config files with the new loss formating + def _update_legacy_config(self): + """ Legacy updates for new config additions. + + When new config items are added to the Faceswap code, existing model state files need to be + updated to handle these new items. + + Current existing legacy update items: + + * loss - If old `dssim_loss` is ``true`` set new `loss_function` to `ssim` otherwise + set it to `mae`. Remove old `dssim_loss` item + + * masks - If `penalized_mask_loss` exists but `learn_mask` does not, then add the + latter and set to the same value as `penalized_mask_loss`. + + * masks type - Replace removed masks 'dfl_full' and 'facehull' with `components` mask + + Returns + ------- + bool + ``True`` if legacy items exist and state file has been updated, otherwise ``False`` """ - prior = "dssim_loss" - new = "loss_function" - if prior not in self.config: - return False - self.config[new] = "ssim" if self.config[prior] else "mae" - del self.config[prior] - logger.info("Updated config from older dssim format. New config loss function: %s", - self.config[new]) - return True + logger.debug("Checking for legacy state file update") + priors = ["dssim_loss", "penalized_mask_loss", "mask_type"] + new_items = ["loss_function", "learn_mask", "mask_type"] + updated = False + for old, new in zip(priors, new_items): + if old not in self.config: + logger.debug("Legacy item '%s' not in config. Skipping update", old) + continue + + # dssim_loss > loss_function + if old == "dssim_loss": + self.config[new] = "ssim" if self.config[old] else "mae" + del self.config[old] + updated = True + logger.info("Updated config from legacy dssim format. New config loss " + "function: '%s'", self.config[new]) + continue + + # Add learn mask option and set to True if model has "penalized_mask_loss" specified + if old == "penalized_mask_loss" and new not in self.config: + self.config[new] = self.config["penalized_mask_loss"] + updated = True + logger.info("Added new 'learn_mask' config item for this model. Value set to: %s", + self.config[new]) + continue + + # Replace removed masks with most similar equivalent + if old == "mask_type" and self.config[old] in ("facehull", "dfl_full"): + old_mask = self.config[old] + self.config[new] = "components" + updated = True + logger.info("Updated 'mask_type' from '%s' to '%s' for this model", + old_mask, self.config[new]) + + logger.debug("State file updated for legacy config: %s", updated) + return updated def update_changed_config_items(self, config_changeable_items): """ Update any parameters which are not fixed and have been changed """ diff --git a/plugins/train/model/dfaker.py b/plugins/train/model/dfaker.py index 758c43b6d0..d6b19be395 100644 --- a/plugins/train/model/dfaker.py +++ b/plugins/train/model/dfaker.py @@ -40,7 +40,7 @@ def decoder(self): name="face_out") outputs = [var_x] - if self.config.get("mask_type", None): + if self.config.get("learn_mask", False): var_y = input_ var_y = self.blocks.upscale(var_y, 512) var_y = self.blocks.upscale(var_y, 256) diff --git a/plugins/train/model/dfl_h128.py b/plugins/train/model/dfl_h128.py index 6bf9a6ffd6..887d379937 100644 --- a/plugins/train/model/dfl_h128.py +++ b/plugins/train/model/dfl_h128.py @@ -50,8 +50,8 @@ def decoder(self): activation="sigmoid", name="face_out") outputs = [var_x] - # Mask - if self.config.get("mask_type", None): + + if self.config.get("learn_mask", False): var_y = input_ var_y = self.blocks.upscale(var_y, self.encoder_dim) var_y = self.blocks.upscale(var_y, self.encoder_dim // 2) diff --git a/plugins/train/model/dfl_sae.py b/plugins/train/model/dfl_sae.py index e79822c2cf..4d2212125d 100644 --- a/plugins/train/model/dfl_sae.py +++ b/plugins/train/model/dfl_sae.py @@ -31,7 +31,7 @@ def architecture(self): @property def use_mask(self): """ Return True if a mask has been set else false """ - return self.config.get("mask_type", None) is not None + return self.config.get("learn_mask", False) @property def ae_dims(self): diff --git a/plugins/train/model/iae.py b/plugins/train/model/iae.py index b164fef680..775305e1f7 100644 --- a/plugins/train/model/iae.py +++ b/plugins/train/model/iae.py @@ -77,7 +77,7 @@ def decoder(self): name="face_out") outputs = [var_x] - if self.config.get("mask_type", None): + if self.config.get("learn_mask", False): var_y = input_ var_y = self.blocks.upscale(var_y, 512) var_y = self.blocks.upscale(var_y, 256) diff --git a/plugins/train/model/lightweight.py b/plugins/train/model/lightweight.py index 1963c8c1b3..366e2802d2 100644 --- a/plugins/train/model/lightweight.py +++ b/plugins/train/model/lightweight.py @@ -47,7 +47,7 @@ def decoder(self): name="face_out") outputs = [var_x] - if self.config.get("mask_type", None): + if self.config.get("learn_mask", False): var_y = input_ var_y = self.blocks.upscale(var_y, 512) var_y = self.blocks.upscale(var_y, 256) diff --git a/plugins/train/model/original.py b/plugins/train/model/original.py index 55d3bea1ea..fa79862860 100644 --- a/plugins/train/model/original.py +++ b/plugins/train/model/original.py @@ -73,7 +73,7 @@ def decoder(self): name="face_out") outputs = [var_x] - if self.config.get("mask_type", None): + if self.config.get("learn_mask", False): var_y = input_ var_y = self.blocks.upscale(var_y, 256) var_y = self.blocks.upscale(var_y, 128) diff --git a/plugins/train/model/realface.py b/plugins/train/model/realface.py index 10562b802c..48df05c59a 100644 --- a/plugins/train/model/realface.py +++ b/plugins/train/model/realface.py @@ -134,7 +134,7 @@ def decoder_b(self): outputs = [var_x] - if self.config.get("mask_type", None) is not None: + if self.config.get("learn_mask", False): var_y = var_xy mask_b_complexity = 384 for idx in range(self.upscalers_no-2): @@ -184,7 +184,7 @@ def decoder_a(self): outputs = [var_x] - if self.config.get("mask_type", None) is not None: + if self.config.get("learn_mask", False): var_y = var_xy mask_a_complexity = 384 for idx in range(self.upscalers_no-2): diff --git a/plugins/train/model/unbalanced.py b/plugins/train/model/unbalanced.py index b8c2a08b69..d7e136fdd4 100644 --- a/plugins/train/model/unbalanced.py +++ b/plugins/train/model/unbalanced.py @@ -80,7 +80,7 @@ def decoder_a(self): name="face_out") outputs = [var_x] - if self.config.get("mask_type", None): + if self.config.get("learn_mask", False): var_y = input_ var_y = self.blocks.upscale(var_y, decoder_complexity) var_y = self.blocks.upscale(var_y, decoder_complexity) @@ -129,7 +129,7 @@ def decoder_b(self): name="face_out") outputs = [var_x] - if self.config.get("mask_type", None): + if self.config.get("learn_mask", False): var_y = input_ var_y = self.blocks.upscale(var_y, decoder_complexity) if not self.lowmem: diff --git a/plugins/train/model/villain.py b/plugins/train/model/villain.py index 4a0a67ae27..90c202032f 100644 --- a/plugins/train/model/villain.py +++ b/plugins/train/model/villain.py @@ -78,7 +78,7 @@ def decoder(self): name="face_out") outputs = [var_x] - if self.config.get("mask_type", None): + if self.config.get("learn_mask", False): var_y = input_ var_y = self.blocks.upscale(var_y, 512) var_y = self.blocks.upscale(var_y, 256) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 2e71e07236..98ebd95e33 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -1,24 +1,45 @@ #!/usr/bin/env python3 +""" Base Class for Faceswap Trainer plugins. All Trainer plugins should be inherited from +this class. +At present there is only the :class:`~plugins.train.trainer.original` plugin, so that entirely +inherits from this class. -""" Base Trainer Class for Faceswap - - Trainers should be inherited from this class. - - A training_opts dictionary can be set in the corresponding model. - Accepted values: - alignments: dict containing paths to alignments files for keys 'a' and 'b' - preview_scaling: How much to scale the preview out by - training_size: Size of the training images - coverage_ratio: Ratio of face to be cropped out for training - mask_type: Type of mask to use. See lib.model.masks for valid mask names. - Set to None for not used - no_logs: Disable tensorboard logging - snapshot_interval: Interval for saving model snapshots - warp_to_landmarks: Use random_warp_landmarks instead of random_warp - augment_color: Perform random shifting of L*a*b* colors - no_flip: Don't perform a random flip on the image - pingpong: Train each side seperately per save iteration rather than together +This class heavily references the :attr:`plugins.train.model._base.ModelBase.training_opts` +``dict``. The following keys are expected from this ``dict``: + + * **alignments** (`dict`, `optional`) - If training with a mask or the warp to landmarks \ + command line option is selected then this is required, otherwise it can be ``None``. The \ + dictionary should contain 2 keys ("a" and "b") with the values being the path to the \ + alignments file for the corresponding side. + + * **preview_scaling** (`int`) - How much to scale displayed preview image by. + + * **training_size** ('int') - Size of the training images in pixels. + + * **coverage_ratio** ('float') - Ratio of face to be cropped out of the training image. + + * **mask_type** ('str') - The type of mask to select from the alignments file. + + * **mask_blur_kernel** ('int') - The size of the kernel to use for gaussian blurring the mask. + + * **mask_threshold** ('int') - The threshold for min/maxing mask to 0/100. + + * **learn_mask** ('bool') - Whether the mask should be trained in the model. + + * **penalized_mask_loss** ('bool') - Whether the mask should be penalized from loss. + + * **no_logs** ('bool') - Whether Tensorboard logging should be disabled. + + * **snapshot_interval** ('int') - How many iterations between model snapshot saves. + + * **warp_to_landmarks** ('bool') - Whether to use random_warp_landmarks instead of random_warp. + + * **augment_color** ('bool') - Whether to use color augmentation. + + * **no_flip** ('bool') - Whether to turn off random horizontal flipping. + + * **pingpong** ('bool') - Train each side separately per save iteration rather than together. """ import logging @@ -30,9 +51,11 @@ import tensorflow as tf from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module +from tqdm import tqdm from lib.alignments import Alignments from lib.faces_detect import DetectedFace +from lib.image import read_image_hash_batch from lib.training_data import TrainingDataGenerator from lib.utils import FaceswapError, get_folder, get_image_paths from plugins.train._config import Config @@ -40,80 +63,133 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name -def get_config(plugin_name, configfile=None): - """ Return the config for the requested model """ +def _get_config(plugin_name, configfile=None): + """ Return the configuration for the requested trainer. + + Parameters + ---------- + plugin_name: str + The name of the plugin to load the configuration for + configfile: str, optional + A custom configuration file. If ``None`` then configuration is loaded from the default + :file:`.config.train.ini` file. Default: ``None`` + + Returns + ------- + :class:`lib.config.FaceswapConfig` + The configuration file for the requested plugin + """ return Config(plugin_name, configfile=configfile).config_dict class TrainerBase(): - """ Base Trainer """ + """ Trainer plugin base Object. + + All Trainer plugins must inherit from this class. + + Parameters + ---------- + model: plugin from :mod:`plugins.train.model` + The model that will be running this trainer + images: dict + The file paths for the images to be trained on for each side. The dictionary should contain + 2 keys ("a" and "b") with the values being a list of full paths corresponding to each side. + batch_size: int + The requested batch size for iteration to be trained through the model. + configfile: str + The path to a custom configuration file. If ``None`` is passed then configuration is loaded + from the default :file:`.config.train.ini` file. + """ def __init__(self, model, images, batch_size, configfile): logger.debug("Initializing %s: (model: '%s', batch_size: %s)", self.__class__.__name__, model, batch_size) - self.config = get_config(".".join(self.__module__.split(".")[-2:]), configfile=configfile) - self.batch_size = batch_size - self.model = model - self.model.state.add_session_batchsize(batch_size) - self.images = images - self.sides = sorted(key for key in self.images.keys()) - - self.process_training_opts() - self.pingpong = PingPong(model, self.sides) - - self.batchers = {side: Batcher(side, - images[side], - self.model, - self.use_mask, - batch_size, - self.config) - for side in self.sides} - - self.tensorboard = self.set_tensorboard() - self.samples = Samples(self.model, - self.use_mask, - self.model.training_opts["coverage_ratio"], - self.model.training_opts["preview_scaling"]) - self.timelapse = Timelapse(self.model, - self.use_mask, - self.model.training_opts["coverage_ratio"], - self.config.get("preview_images", 14), - self.batchers) + self._config = _get_config(".".join(self.__module__.split(".")[-2:]), + configfile=configfile) + self._model = model + self._model.state.add_session_batchsize(batch_size) + self._images = images + self._sides = sorted(key for key in self._images.keys()) + + self._process_training_opts() + self._pingpong = PingPong(model, self._sides) + + self._batchers = {side: Batcher(side, + images[side], + self._model, + self._use_mask, + batch_size, + self._config) + for side in self._sides} + + self._tensorboard = self._set_tensorboard() + self._samples = Samples(self._model, + self._use_mask, + self._model.training_opts["coverage_ratio"], + self._model.training_opts["preview_scaling"]) + self._timelapse = Timelapse(self._model, + self._use_mask, + self._model.training_opts["coverage_ratio"], + self._config.get("preview_images", 14), + self._batchers) logger.debug("Initialized %s", self.__class__.__name__) @property - def timestamp(self): - """ Standardised timestamp for loss reporting """ + def pingpong(self): + """ :class:`pingpong`: Ping-pong object for ping-pong memory saving training. """ + return self._pingpong + + @property + def _timestamp(self): + """ str: Current time formatted as HOURS:MINUTES:SECONDS """ return time.strftime("%H:%M:%S") @property - def landmarks_required(self): - """ Return True if Landmarks are required """ - opts = self.model.training_opts - retval = bool(opts.get("mask_type", None) or opts["warp_to_landmarks"]) + def _landmarks_required(self): + """ bool: ``True`` if Landmarks are required otherwise ``False ``""" + retval = self._model.training_opts["warp_to_landmarks"] logger.debug(retval) return retval @property - def use_mask(self): - """ Return True if a mask is requested """ - retval = bool(self.model.training_opts.get("mask_type", None)) + def _use_mask(self): + """ bool: ``True`` if a mask is required otherwise ``False`` """ + retval = (self._model.training_opts["learn_mask"] or + self._model.training_opts["penalized_mask_loss"]) logger.debug(retval) return retval - def process_training_opts(self): - """ Override for processing model specific training options """ - logger.debug(self.model.training_opts) - if self.landmarks_required: - landmarks = Landmarks(self.model.training_opts).landmarks - self.model.training_opts["landmarks"] = landmarks + def _process_training_opts(self): + """ Extrapolate alignments and masks from the alignments file into + :attr:`_model.training_opts`.""" + logger.debug(self._model.training_opts) + if not self._landmarks_required and not self._use_mask: + return - def set_tensorboard(self): - """ Set up tensorboard callback """ - if self.model.training_opts["no_logs"]: + alignments = TrainingAlignments(self._model.training_opts, self._images) + if self._landmarks_required: + logger.debug("Adding landmarks to training opts dict") + self._model.training_opts["landmarks"] = alignments.landmarks + + if self._use_mask: + logger.debug("Adding masks to training opts dict") + self._model.training_opts["masks"] = alignments.masks + + def _set_tensorboard(self): + """ Set up Tensorboard callback for logging loss. + + Bypassed if command line option "no-logs" has been selected. + + Returns + ------- + dict: + 2 Dictionary keys of "a" and "b" the values of which are the + :class:`tf.keras.callbacks.TensorBoard` objects for the respective sides. + """ + if self._model.training_opts["no_logs"]: logger.verbose("TensorBoard logging disabled") return None - if self.pingpong.active: + if self._pingpong.active: # Currently TensorBoard uses the tf.session, meaning that VRAM does not # get cleared when model switching # TODO find a fix for this @@ -125,21 +201,23 @@ def set_tensorboard(self): logger.debug("Enabling TensorBoard Logging") tensorboard = dict() - for side in self.sides: + for side in self._sides: logger.debug("Setting up TensorBoard Logging. Side: %s", side) - log_dir = os.path.join(str(self.model.model_dir), - "{}_logs".format(self.model.name), + log_dir = os.path.join(str(self._model.model_dir), + "{}_logs".format(self._model.name), side, - "session_{}".format(self.model.state.session_id)) - tbs = tf.keras.callbacks.TensorBoard(log_dir=log_dir, **self.tensorboard_kwargs) - tbs.set_model(self.model.predictors[side]) + "session_{}".format(self._model.state.session_id)) + tbs = tf.keras.callbacks.TensorBoard(log_dir=log_dir, **self._tensorboard_kwargs) + tbs.set_model(self._model.predictors[side]) tensorboard[side] = tbs logger.info("Enabled TensorBoard Logging") return tensorboard @property - def tensorboard_kwargs(self): - """ TF 1.13 + needs an additional kwarg which is not valid for earlier versions """ + def _tensorboard_kwargs(self): + """ dict: The keyword arguments to be passed to :class:`tf.keras.callbacks.TensorBoard`. + NB: Tensorflow 1.13 + needs an additional keyword argument which is not valid for earlier + versions """ kwargs = dict(histogram_freq=0, # Must be 0 or hangs batch_size=64, write_graph=True, @@ -153,126 +231,198 @@ def tensorboard_kwargs(self): logger.debug(kwargs) return kwargs - def print_loss(self, loss): - """ Override for specific model loss formatting """ + def __print_loss(self, loss): + """ Outputs the loss for the current iteration to the console. + + Parameters + ---------- + loss: dict + The loss for each side. The dictionary should contain 2 keys ("a" and "b") with the + values being a list of loss values for the current iteration corresponding to + each side. + """ logger.trace(loss) output = ["Loss {}: {:.5f}".format(side.capitalize(), loss[side][0]) for side in sorted(loss.keys())] output = ", ".join(output) - print("[{}] [#{:05d}] {}".format(self.timestamp, self.model.iterations, output), end='\r') + print("[{}] [#{:05d}] {}".format(self._timestamp, + self._model.iterations, + output), end='\r') def train_one_step(self, viewer, timelapse_kwargs): - """ Train a batch """ - logger.trace("Training one step: (iteration: %s)", self.model.iterations) + """ Running training on a batch of images for each side. + + Triggered from the training cycle in :class:`scripts.train.Train`. + + Notes + ----- + As every iteration is called explicitly, the Parameters defined should always be ``None`` + except on save iterations. + + Parameters + ---------- + viewer: :func:`scripts.train.Train._show` + The function that will display the preview image + timelapse_kwargs: dict + The keyword arguments for generating time-lapse previews. If a time-lapse preview is + not required then this should be ``None``. Otherwise all values should be full paths + the keys being `input_a`, `input_b`, `output`. + """ + logger.trace("Training one step: (iteration: %s)", self._model.iterations) do_preview = viewer is not None do_timelapse = timelapse_kwargs is not None - snapshot_interval = self.model.training_opts.get("snapshot_interval", 0) + snapshot_interval = self._model.training_opts.get("snapshot_interval", 0) do_snapshot = (snapshot_interval != 0 and - self.model.iterations >= snapshot_interval and - self.model.iterations % snapshot_interval == 0) + self._model.iterations >= snapshot_interval and + self._model.iterations % snapshot_interval == 0) loss = dict() try: - for side, batcher in self.batchers.items(): - if self.pingpong.active and side != self.pingpong.side: + for side, batcher in self._batchers.items(): + if self._pingpong.active and side != self._pingpong.side: continue - loss[side] = batcher.train_one_batch(do_preview) + loss[side] = batcher.train_one_batch() if not do_preview and not do_timelapse: continue if do_preview: - self.samples.images[side] = batcher.compile_sample(None) + batcher.generate_preview(do_preview) + self._samples.images[side] = batcher.compile_sample(None) if do_timelapse: - self.timelapse.get_sample(side, timelapse_kwargs) + self._timelapse.get_sample(side, timelapse_kwargs) - self.model.state.increment_iterations() + self._model.state.increment_iterations() for side, side_loss in loss.items(): - self.store_history(side, side_loss) - self.log_tensorboard(side, side_loss) + self._store_history(side, side_loss) + self._log_tensorboard(side, side_loss) - if not self.pingpong.active: - self.print_loss(loss) + if not self._pingpong.active: + self.__print_loss(loss) else: for key, val in loss.items(): - self.pingpong.loss[key] = val - self.print_loss(self.pingpong.loss) + self._pingpong.loss[key] = val + self.__print_loss(self._pingpong.loss) if do_preview: - samples = self.samples.show_sample() + samples = self._samples.show_sample() if samples is not None: viewer(samples, "Training - 'S': Save Now. 'ENTER': Save and Quit") if do_timelapse: - self.timelapse.output_timelapse() + self._timelapse.output_timelapse() if do_snapshot: - self.model.do_snapshot() + self._model.do_snapshot() except Exception as err: raise err - def store_history(self, side, loss): - """ Store the history of this step """ + def _store_history(self, side, loss): + """ Store the loss for this step into :attr:`model.history`. + + Parameters + ---------- + side: {"a", "b"} + The side to store the loss for + loss: list + The list of loss ``floats`` for this side + """ logger.trace("Updating loss history: '%s'", side) - self.model.history[side].append(loss[0]) # Either only loss or total loss + self._model.history[side].append(loss[0]) # Either only loss or total loss logger.trace("Updated loss history: '%s'", side) - def log_tensorboard(self, side, loss): - """ Log loss to TensorBoard log """ - if not self.tensorboard: + def _log_tensorboard(self, side, loss): + """ Log current loss to Tensorboard log files + + Parameters + ---------- + side: {"a", "b"} + The side to store the loss for + loss: list + The list of loss ``floats`` for this side + """ + if not self._tensorboard: return logger.trace("Updating TensorBoard log: '%s'", side) logs = {log[0]: log[1] - for log in zip(self.model.state.loss_names[side], loss)} - self.tensorboard[side].on_batch_end(self.model.state.iterations, logs) + for log in zip(self._model.state.loss_names[side], loss)} + self._tensorboard[side].on_batch_end(self._model.state.iterations, logs) logger.trace("Updated TensorBoard log: '%s'", side) def clear_tensorboard(self): - """ Indicate training end to Tensorboard """ - if not self.tensorboard: + """ Stop Tensorboard logging. + + Tensorboard logging needs to be explicitly shutdown on training termination. Called from + :class:`scripts.train.Train` when training is stopped. + """ + if not self._tensorboard: return - for side, tensorboard in self.tensorboard.items(): + for side, tensorboard in self._tensorboard.items(): logger.debug("Ending Tensorboard. Side: '%s'", side) tensorboard.on_train_end(None) class Batcher(): - """ Batch images from a single side """ + """ Handles the processing of a Batch for a single side. + + Parameters + ---------- + side: {"a" or "b"} + The side that this :class:`Batcher` belongs to + images: list + The list of full paths to the training images for this :class:`Batcher` + model: plugin from :mod:`plugins.train.model` + The selected model that will be running this trainer + use_mask: bool + ``True`` if a mask is required for training otherwise ``False`` + batch_size: int + The size of the batch to be processed at each iteration + config: :class:`lib.config.FaceswapConfig` + The configuration for this trainer + """ def __init__(self, side, images, model, use_mask, batch_size, config): - logger.debug("Initializing %s: side: '%s', num_images: %s, batch_size: %s, config: %s)", - self.__class__.__name__, side, len(images), batch_size, config) - self.model = model - self.use_mask = use_mask - self.side = side - self.images = images - self.config = config - self.target = None - self.samples = None - self.mask = None - - generator = self.load_generator() - self.feed = generator.minibatch_ab(images, batch_size, self.side) - - self.preview_feed = None - self.timelapse_feed = None - - def load_generator(self): - """ Pass arguments to TrainingDataGenerator and return object """ - logger.debug("Loading generator: %s", self.side) - input_size = self.model.input_shape[0] - output_shapes = self.model.output_shapes + logger.debug("Initializing %s: side: '%s', num_images: %s, use_mask: %s, batch_size: %s, " + "config: %s)", + self.__class__.__name__, side, len(images), use_mask, batch_size, config) + self._model = model + self._use_mask = use_mask + self._side = side + self._images = images + self._config = config + self._target = None + self._samples = None + self._masks = None + + generator = self._load_generator() + self._feed = generator.minibatch_ab(images, batch_size, self._side) + + self._preview_feed = None + self._timelapse_feed = None + self._set_preview_feed() + + def _load_generator(self): + """ Load the :class:`lib.training_data.TrainingDataGenerator` for this batcher """ + logger.debug("Loading generator: %s", self._side) + input_size = self._model.input_shape[0] + output_shapes = self._model.output_shapes logger.debug("input_size: %s, output_shapes: %s", input_size, output_shapes) generator = TrainingDataGenerator(input_size, output_shapes, - self.model.training_opts, - self.config) + self._model.training_opts, + self._config) return generator - def train_one_batch(self, do_preview): - """ Train a batch """ - logger.trace("Training one step: (side: %s)", self.side) - batch = self.get_next(do_preview) + def train_one_batch(self): + """ Train on a single batch of images for this :class:`Batcher` + + Returns + ------- + list + The list of loss values (as ``float``) for this batch + """ + logger.trace("Training one step: (side: %s)", self._side) + model_inputs, model_targets = self._get_next() try: - loss = self.model.predictors[self.side].train_on_batch(*batch) + loss = self._model.predictors[self._side].train_on_batch(model_inputs, model_targets) except tf_errors.ResourceExhaustedError as err: msg = ("You do not have enough GPU memory available to train the selected model at " "the selected settings. You can try a number of things:" @@ -288,93 +438,177 @@ def train_one_batch(self, do_preview): loss = loss if isinstance(loss, list) else [loss] return loss - def get_next(self, do_preview): - """ Return the next batch from the generator - Items should come out as: (warped, target [, mask]) """ - batch = next(self.feed) - if self.use_mask: - batch = [[batch["feed"], batch["masks"]], batch["targets"] + [batch["masks"]]] - else: - batch = [batch["feed"], batch["targets"]] - self.generate_preview(do_preview) - return batch + def _get_next(self): + """ Return the next batch from the :class:`lib.training_data.TrainingDataGenerator` for + this batcher ready for feeding into the model. + + Returns + ------- + model_inputs: list + A list of :class:`numpy.ndarray` for feeding into the model + model_targets: list + A list of :class:`numpy.ndarray` for comparing the output of the model + """ + logger.trace("Generating targets") + batch = next(self._feed) + targets_use_mask = self._model.training_opts["learn_mask"] + model_inputs = batch["feed"] + batch["masks"] if self._use_mask else batch["feed"] + model_targets = batch["targets"] + batch["masks"] if targets_use_mask else batch["targets"] + return model_inputs, model_targets def generate_preview(self, do_preview): - """ Generate the preview if a preview iteration """ + """ Generate the preview images. + + Parameters + ---------- + do_preview: bool + Whether the previews should be generated. ``True`` if they should ``False`` if they + should not be generated, in which case currently stored previews should be deleted. + """ if not do_preview: - self.samples = None - self.target = None + self._samples = None + self._target = None + self._masks = None return logger.debug("Generating preview") - if self.preview_feed is None: - self.set_preview_feed() - batch = next(self.preview_feed) - self.samples = batch["samples"] - self.target = [batch["targets"][self.model.largest_face_index]] - if self.use_mask: - self.target += [batch["masks"]] - - def set_preview_feed(self): - """ Set the preview dictionary """ - logger.debug("Setting preview feed: (side: '%s')", self.side) - preview_images = self.config.get("preview_images", 14) + batch = next(self._preview_feed) + self._samples = batch["samples"] + self._target = batch["targets"][self._model.largest_face_index] + self._masks = batch["masks"][0] + + def _set_preview_feed(self): + """ Set the preview feed for this batcher. + + Creates a generator from :class:`lib.training_data.TrainingDataGenerator` specifically + for previews for the batcher. + """ + logger.debug("Setting preview feed: (side: '%s')", self._side) + preview_images = self._config.get("preview_images", 14) preview_images = min(max(preview_images, 2), 16) - batchsize = min(len(self.images), preview_images) - self.preview_feed = self.load_generator().minibatch_ab(self.images, - batchsize, - self.side, - do_shuffle=True, - is_preview=True) + batchsize = min(len(self._images), preview_images) + self._preview_feed = self._load_generator().minibatch_ab(self._images, + batchsize, + self._side, + do_shuffle=True, + is_preview=True) logger.debug("Set preview feed. Batchsize: %s", batchsize) - def compile_sample(self, batch_size, samples=None, images=None): - """ Training samples to display in the viewer """ - num_images = self.config.get("preview_images", 14) + def compile_sample(self, batch_size, samples=None, images=None, masks=None): + """ Compile the preview samples for display. + + Parameters + ---------- + batch_size: int + The requested batch size for each training iterations + samples: :class:`numpy.ndarray`, optional + The sample images that should be used for creating the preview. If ``None`` then the + samples will be generated from the internal random image generator. + Default: ``None`` + images: :class:`numpy.ndarray`, optional + The target images that should be used for creating the preview. If ``None`` then the + targets will be generated from the internal random image generator. + Default: ``None`` + masks: :class:`numpy.ndarray`, optional + The masks that should be used for creating the preview. If ``None`` then the + masks will be generated from the internal random image generator. + Default: ``None`` + + Returns + ------- + list + The list of samples, targets and masks as :class:`numpy.ndarrays` for creating a + preview image + """ + num_images = self._config.get("preview_images", 14) num_images = min(batch_size, num_images) if batch_size is not None else num_images - logger.debug("Compiling samples: (side: '%s', samples: %s)", self.side, num_images) - images = images if images is not None else self.target - retval = [samples[0:num_images]] if samples is not None else [self.samples[0:num_images]] - if self.use_mask: - retval.extend(tgt[0:num_images] for tgt in images) - else: - retval.extend(images[0:num_images]) + logger.debug("Compiling samples: (side: '%s', samples: %s)", self._side, num_images) + images = images if images is not None else self._target + masks = masks if masks is not None else self._masks + samples = samples if samples is not None else self._samples + retval = [samples[0:num_images], images[0:num_images], masks[0:num_images]] return retval def compile_timelapse_sample(self): - """ Timelapse samples """ - batch = next(self.timelapse_feed) + """ Compile the sample images for creating a time-lapse frame. + + Returns + ------- + list + The list of samples, targets and masks as :class:`numpy.ndarrays` for creating a + time-lapse frame + """ + batch = next(self._timelapse_feed) batchsize = len(batch["samples"]) - images = [batch["targets"][self.model.largest_face_index]] - if self.use_mask: - images = images + [batch["masks"]] - sample = self.compile_sample(batchsize, samples=batch["samples"], images=images) + images = batch["targets"][self._model.largest_face_index] + masks = batch["masks"][0] + sample = self.compile_sample(batchsize, + samples=batch["samples"], + images=images, + masks=masks) return sample - def set_timelapse_feed(self, images, batchsize): - """ Set the timelapse dictionary """ - logger.debug("Setting timelapse feed: (side: '%s', input_images: '%s', batchsize: %s)", - self.side, images, batchsize) - self.timelapse_feed = self.load_generator().minibatch_ab(images[:batchsize], - batchsize, self.side, - do_shuffle=False, - is_timelapse=True) - logger.debug("Set timelapse feed") + def set_timelapse_feed(self, images, batch_size): + """ Set the time-lapse feed for this batcher. + + Creates a generator from :class:`lib.training_data.TrainingDataGenerator` specifically + for generating time-lapse previews for the batcher. + + Parameters + ---------- + images: list + The list of full paths to the images for creating the time-lapse for this + :class:`Batcher` + batch_size: int + The number of images to be used to create the time-lapse preview. + """ + logger.debug("Setting time-lapse feed: (side: '%s', input_images: '%s', batch_size: %s)", + self._side, images, batch_size) + self._timelapse_feed = self._load_generator().minibatch_ab(images[:batch_size], + batch_size, self._side, + do_shuffle=False, + is_timelapse=True) + logger.debug("Set time-lapse feed") class Samples(): - """ Display samples for preview and timelapse """ + """ Compile samples for display for preview and time-lapse + + Parameters + ---------- + model: plugin from :mod:`plugins.train.model` + The selected model that will be running this trainer + use_mask: bool + ``True`` if a mask should be displayed otherwise ``False`` + coverage_ratio: float + Ratio of face to be cropped out of the training image. + scaling: float, optional + The amount to scale the final preview image by. Default: `1.0` + + Attributes + ---------- + images: dict + The :class:`numpy.ndarray` training images for generating previews on each side. The + dictionary should contain 2 keys ("a" and "b") with the values being the training images + for generating samples corresponding to each side. + """ def __init__(self, model, use_mask, coverage_ratio, scaling=1.0): logger.debug("Initializing %s: model: '%s', use_mask: %s, coverage_ratio: %s)", self.__class__.__name__, model, use_mask, coverage_ratio) - self.model = model - self.use_mask = use_mask + self._model = model + self._use_mask = use_mask self.images = dict() - self.coverage_ratio = coverage_ratio - self.scaling = scaling + self._coverage_ratio = coverage_ratio + self._scaling = scaling logger.debug("Initialized %s", self.__class__.__name__) def show_sample(self): - """ Display preview data """ + """ Compile a preview image. + + Returns + ------- + :class:`numpy.ndarry` + A compiled preview image ready for display or saving + """ if len(self.images) != 2: logger.debug("Ping Pong training - Only one side trained. Aborting preview") return None @@ -384,23 +618,23 @@ def show_sample(self): headers = dict() for side, samples in self.images.items(): faces = samples[1] - if self.model.input_shape[0] / faces.shape[1] != 1.0: - feeds[side] = self.resize_sample(side, faces, self.model.input_shape[0]) - feeds[side] = feeds[side].reshape((-1, ) + self.model.input_shape) + if self._model.input_shape[0] / faces.shape[1] != 1.0: + feeds[side] = self._resize_sample(side, faces, self._model.input_shape[0]) + feeds[side] = feeds[side].reshape((-1, ) + self._model.input_shape) else: feeds[side] = faces - if self.use_mask: + if self._use_mask: mask = samples[-1] feeds[side] = [feeds[side], mask] - preds = self.get_predictions(feeds["a"], feeds["b"]) + preds = self._get_predictions(feeds["a"], feeds["b"]) for side, samples in self.images.items(): other_side = "a" if side == "b" else "b" predictions = [preds["{0}_{0}".format(side)], preds["{}_{}".format(other_side, side)]] - display = self.to_full_frame(side, samples, predictions) - headers[side] = self.get_headers(side, display[0].shape[1]) + display = self._to_full_frame(side, samples, predictions) + headers[side] = self._get_headers(side, display[0].shape[1]) figures[side] = np.stack([display[0], display[1], display[2], ], axis=1) if self.images[side][0].shape[0] % 2 == 1: figures[side] = np.concatenate([figures[side], @@ -409,73 +643,123 @@ def show_sample(self): width = 4 side_cols = width // 2 if side_cols != 1: - headers = self.duplicate_headers(headers, side_cols) + headers = self._duplicate_headers(headers, side_cols) header = np.concatenate([headers["a"], headers["b"]], axis=1) figure = np.concatenate([figures["a"], figures["b"]], axis=0) height = int(figure.shape[0] / width) figure = figure.reshape((width, height) + figure.shape[1:]) - figure = stack_images(figure) - figure = np.vstack((header, figure)) + figure = _stack_images(figure) + figure = np.concatenate((header, figure), axis=0) logger.debug("Compiled sample") return np.clip(figure * 255, 0, 255).astype('uint8') @staticmethod - def resize_sample(side, sample, target_size): - """ Resize samples where predictor expects different shape from processed image """ + def _resize_sample(side, sample, target_size): + """ Resize a given image to the target size. + + Parameters + ---------- + sample: :class:`numpy.ndarray` + The sample to be resized + target_size: int + The size that the sample should be resized to + + Returns + ------- + :class:`numpy.ndarray` + The sample resized to the target size + """ scale = target_size / sample.shape[1] if scale == 1.0: return sample logger.debug("Resizing sample: (side: '%s', sample.shape: %s, target_size: %s, scale: %s)", side, sample.shape, target_size, scale) - interpn = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA # pylint: disable=no-member - retval = np.array([cv2.resize(img, # pylint: disable=no-member - (target_size, target_size), - interpn) + interpn = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA + retval = np.array([cv2.resize(img, (target_size, target_size), interpn) for img in sample]) logger.debug("Resized sample: (side: '%s' shape: %s)", side, retval.shape) return retval - def get_predictions(self, feed_a, feed_b): - """ Return the sample predictions from the model """ + def _get_predictions(self, feed_a, feed_b): + """ Feed the samples to the model and return predictions + + Parameters + ---------- + feed_a: list + List of :class:`numpy.ndarray` of feed images for the "a" side + feed_a: list + List of :class:`numpy.ndarray` of feed images for the "b" side + + Returns + list: + List of :class:`numpy.ndarray` of predictions received from the model + """ logger.debug("Getting Predictions") preds = dict() - preds["a_a"] = self.model.predictors["a"].predict(feed_a) - preds["b_a"] = self.model.predictors["b"].predict(feed_a) - preds["a_b"] = self.model.predictors["a"].predict(feed_b) - preds["b_b"] = self.model.predictors["b"].predict(feed_b) + preds["a_a"] = self._model.predictors["a"].predict(feed_a) + preds["b_a"] = self._model.predictors["b"].predict(feed_a) + preds["a_b"] = self._model.predictors["a"].predict(feed_b) + preds["b_b"] = self._model.predictors["b"].predict(feed_b) # Get the returned largest image from predictors that emit multiple items if not isinstance(preds["a_a"], np.ndarray): for key, val in preds.items(): - preds[key] = val[self.model.largest_face_index] + preds[key] = val[self._model.largest_face_index] logger.debug("Returning predictions: %s", {key: val.shape for key, val in preds.items()}) return preds - def to_full_frame(self, side, samples, predictions): - """ Patch the images into the full frame """ + def _to_full_frame(self, side, samples, predictions): + """ Patch targets and prediction images into images of training image size. + + Parameters + ---------- + side: {"a" or "b"} + The side that these samples are for + samples: list + List of :class:`numpy.ndarray` of target images and feed images + predictions: list + List of :class: `numpy.ndarray` of predictions from the model + """ logger.debug("side: '%s', number of sample arrays: %s, prediction.shapes: %s)", side, len(samples), [pred.shape for pred in predictions]) full, faces = samples[:2] images = [faces] + predictions full_size = full.shape[1] - target_size = int(full_size * self.coverage_ratio) + target_size = int(full_size * self._coverage_ratio) if target_size != full_size: - frame = self.frame_overlay(full, target_size, (0, 0, 255)) + frame = self._frame_overlay(full, target_size, (0, 0, 255)) - if self.use_mask: - images = self.compile_masked(images, samples[-1]) - images = [self.resize_sample(side, image, target_size) for image in images] + if self._use_mask: + images = self._compile_masked(images, samples[-1]) + images = [self._resize_sample(side, image, target_size) for image in images] if target_size != full_size: - images = [self.overlay_foreground(frame, image) for image in images] - if self.scaling != 1.0: - new_size = int(full_size * self.scaling) - images = [self.resize_sample(side, image, new_size) for image in images] + images = [self._overlay_foreground(frame, image) for image in images] + if self._scaling != 1.0: + new_size = int(full_size * self._scaling) + images = [self._resize_sample(side, image, new_size) for image in images] return images @staticmethod - def frame_overlay(images, target_size, color): - """ Add roi frame to a backfround image """ + def _frame_overlay(images, target_size, color): + """ Add a frame overlay to preview images indicating the region of interest. + + This is the red border that appears in the preview images. + + Parameters + ---------- + images: :class:`numpy.ndarray` + The samples to apply the frame to + target_size: int + The size of the sample within the full size frame + color: tuple + The (Blue, Green, Red) color to use for the frame + + Returns + ------- + :class:`numpy,ndarray` + The samples with the frame overlay applied + """ logger.debug("full_size: %s, target_size: %s, color: %s", images.shape[1], target_size, color) new_images = list() @@ -484,78 +768,97 @@ def frame_overlay(images, target_size, color): length = target_size // 4 t_l, b_r = (padding, full_size - padding) for img in images: - cv2.rectangle(img, # pylint: disable=no-member - (t_l, t_l), - (t_l + length, t_l + length), - color, - 3) - cv2.rectangle(img, # pylint: disable=no-member - (b_r, t_l), - (b_r - length, t_l + length), - color, - 3) - cv2.rectangle(img, # pylint: disable=no-member - (b_r, b_r), - (b_r - length, - b_r - length), - color, - 3) - cv2.rectangle(img, # pylint: disable=no-member - (t_l, b_r), - (t_l + length, b_r - length), - color, - 3) + cv2.rectangle(img, (t_l, t_l), (t_l + length, t_l + length), color, 3) + cv2.rectangle(img, (b_r, t_l), (b_r - length, t_l + length), color, 3) + cv2.rectangle(img, (b_r, b_r), (b_r - length, b_r - length), color, 3) + cv2.rectangle(img, (t_l, b_r), (t_l + length, b_r - length), color, 3) new_images.append(img) retval = np.array(new_images) logger.debug("Overlayed background. Shape: %s", retval.shape) return retval @staticmethod - def compile_masked(faces, masks): - """ Add the mask to the faces for masked preview """ + def _compile_masked(faces, masks): + """ Add the mask to the faces for masked preview. + + Places an opaque red layer over areas of the face that are masked out. + + Parameters + ---------- + faces: :class:`numpy.ndarray` + The sample faces that are to have the mask applied + masks: :class:`numpy.ndarray` + The masks that are to be applied to the faces + + Returns + ------- + list + List of :class:`numpy.ndarray` faces with the opaque mask layer applied + """ retval = list() masks3 = np.tile(1 - np.rint(masks), 3) for mask in masks3: mask[np.where((mask == [1., 1., 1.]).all(axis=2))] = [0., 0., 1.] for previews in faces: - images = np.array([cv2.addWeighted(img, 1.0, # pylint: disable=no-member - masks3[idx], 0.3, - 0) + images = np.array([cv2.addWeighted(img, 1.0, masks3[idx], 0.3, 0) for idx, img in enumerate(previews)]) retval.append(images) logger.debug("masked shapes: %s", [faces.shape for faces in retval]) return retval @staticmethod - def overlay_foreground(backgrounds, foregrounds): - """ Overlay the training images into the center of the background """ + def _overlay_foreground(backgrounds, foregrounds): + """ Overlay the preview images into the center of the background images + + Parameters + ---------- + backgrounds: list + List of :class:`numpy.ndarray` background images for placing the preview images onto + backgrounds: list + List of :class:`numpy.ndarray` preview images for placing onto the background images + + Returns + ------- + :class:`numpy.ndarray` + The preview images compiled into the full frame size for each preview + """ offset = (backgrounds.shape[1] - foregrounds.shape[1]) // 2 new_images = list() for idx, img in enumerate(backgrounds): img[offset:offset + foregrounds[idx].shape[0], - offset:offset + foregrounds[idx].shape[1]] = foregrounds[idx] + offset:offset + foregrounds[idx].shape[1], :3] = foregrounds[idx] new_images.append(img) retval = np.array(new_images) logger.debug("Overlayed foreground. Shape: %s", retval.shape) return retval - def get_headers(self, side, width): - """ Set headers for images """ + def _get_headers(self, side, width): + """ Set header row for the final preview frame + + Parameters + ---------- + side: {"a" or "b"} + The side that the headers should be generated for + width: int + The width of each column in the preview frame + + Returns + ------- + :class:`numpy.ndarray` + The column headings for the given side + """ logger.debug("side: '%s', width: %s", side, width) titles = ("Original", "Swap") if side == "a" else ("Swap", "Original") side = side.upper() - height = int(64 * self.scaling) + height = int(64 * self._scaling) total_width = width * 3 logger.debug("height: %s, total_width: %s", height, total_width) - font = cv2.FONT_HERSHEY_SIMPLEX # pylint: disable=no-member + font = cv2.FONT_HERSHEY_SIMPLEX texts = ["{} ({})".format(titles[0], side), "{0} > {0}".format(titles[0]), "{} > {}".format(titles[0], titles[1])] - text_sizes = [cv2.getTextSize(texts[idx], # pylint: disable=no-member - font, - self.scaling * 0.8, - 1)[0] + text_sizes = [cv2.getTextSize(texts[idx], font, self._scaling * 0.8, 1)[0] for idx in range(len(texts))] text_y = int((height + text_sizes[0][1]) / 2) text_x = [int((width - text_sizes[idx][0]) / 2) + width * idx @@ -564,20 +867,33 @@ def get_headers(self, side, width): texts, text_sizes, text_x, text_y) header_box = np.ones((height, total_width, 3), np.float32) for idx, text in enumerate(texts): - cv2.putText(header_box, # pylint: disable=no-member + cv2.putText(header_box, text, (text_x[idx], text_y), font, - self.scaling * 0.8, + self._scaling * 0.8, (0, 0, 0), 1, - lineType=cv2.LINE_AA) # pylint: disable=no-member + lineType=cv2.LINE_AA) logger.debug("header_box.shape: %s", header_box.shape) return header_box @staticmethod - def duplicate_headers(headers, columns): - """ Duplicate headers for the number of columns displayed """ + def _duplicate_headers(headers, columns): + """ Duplicate headers for the number of columns displayed for each side. + + Parameters + ---------- + headers: :class:`numpy.ndarray` + The header to be duplicated + columns: int + The number of columns that the header needs to be duplicated for + + Returns + ------- + :class:`numpy.ndarray` + The original headers duplicated by the number of columns + """ for side, header in headers.items(): duped = tuple([header for _ in range(columns)]) headers[side] = np.concatenate(duped, axis=1) @@ -586,114 +902,435 @@ def duplicate_headers(headers, columns): class Timelapse(): - """ Create the timelapse """ - def __init__(self, model, use_mask, coverage_ratio, preview_images, batchers): + """ Create a time-lapse preview image. + + Parameters + ---------- + model: plugin from :mod:`plugins.train.model` + The selected model that will be running this trainer + use_mask: bool + ``True`` if a mask should be displayed otherwise ``False`` + coverage_ratio: float + Ratio of face to be cropped out of the training image. + scaling: float, optional + The amount to scale the final preview image by. Default: `1.0` + image_count: int + The number of preview images to be displayed in the time-lapse + batchers: dict + The dictionary should contain 2 keys ("a" and "b") with the values being the + :class:`Batcher` for each side. + """ + def __init__(self, model, use_mask, coverage_ratio, image_count, batchers): logger.debug("Initializing %s: model: %s, use_mask: %s, coverage_ratio: %s, " - "preview_images: %s, batchers: '%s')", self.__class__.__name__, model, - use_mask, coverage_ratio, preview_images, batchers) - self.preview_images = preview_images - self.samples = Samples(model, use_mask, coverage_ratio) - self.model = model - self.batchers = batchers - self.output_file = None + "image_count: %s, batchers: '%s')", self.__class__.__name__, model, + use_mask, coverage_ratio, image_count, batchers) + self._num_images = image_count + self._samples = Samples(model, use_mask, coverage_ratio) + self._model = model + self._batchers = batchers + self._output_file = None logger.debug("Initialized %s", self.__class__.__name__) def get_sample(self, side, timelapse_kwargs): - """ Perform timelapse """ - logger.debug("Getting timelapse samples: '%s'", side) - if not self.output_file: - self.setup(**timelapse_kwargs) - self.samples.images[side] = self.batchers[side].compile_timelapse_sample() - logger.debug("Got timelapse samples: '%s' - %s", side, len(self.samples.images[side])) - - def setup(self, input_a=None, input_b=None, output=None): - """ Set the timelapse output folder """ - logger.debug("Setting up timelapse") + """ Compile the time-lapse preview + + Parameters + ---------- + side: {"a" or "b"} + The side that the time-lapse is being generated for + timelapse_kwargs: dict + The keyword arguments for setting up the time-lapse. All values should be full paths + the keys being `input_a`, `input_b`, `output` + """ + logger.debug("Getting time-lapse samples: '%s'", side) + if not self._output_file: + self._setup(**timelapse_kwargs) + self._samples.images[side] = self._batchers[side].compile_timelapse_sample() + logger.debug("Got time-lapse samples: '%s' - %s", side, len(self._samples.images[side])) + + def _setup(self, input_a=None, input_b=None, output=None): + """ Setup the time-lapse folder locations and the time-lapse feed. + + Parameters + ---------- + input_a: str + The full path to the time-lapse input folder containing faces for the "a" side + input_b: str + The full path to the time-lapse input folder containing faces for the "b" side + output: str, optional + The full path to the time-lapse output folder. If ``None`` is provided this will + default to the model folder + """ + logger.debug("Setting up time-lapse") if output is None: - output = str(get_folder(os.path.join(str(self.model.model_dir), - "{}_timelapse".format(self.model.name)))) - self.output_file = str(output) - logger.debug("Timelapse output set to '%s'", self.output_file) + output = str(get_folder(os.path.join(str(self._model.model_dir), + "{}_timelapse".format(self._model.name)))) + self._output_file = str(output) + logger.debug("Time-lapse output set to '%s'", self._output_file) images = {"a": get_image_paths(input_a), "b": get_image_paths(input_b)} batchsize = min(len(images["a"]), len(images["b"]), - self.preview_images) + self._num_images) for side, image_files in images.items(): - self.batchers[side].set_timelapse_feed(image_files, batchsize) - logger.debug("Set up timelapse") + self._batchers[side].set_timelapse_feed(image_files, batchsize) + logger.debug("Set up time-lapse") def output_timelapse(self): - """ Set the timelapse dictionary """ - logger.debug("Ouputting timelapse") - image = self.samples.show_sample() + """ Write the created time-lapse to the specified output folder. """ + logger.debug("Ouputting time-lapse") + image = self._samples.show_sample() if image is None: return - filename = os.path.join(self.output_file, str(int(time.time())) + ".jpg") + filename = os.path.join(self._output_file, str(int(time.time())) + ".jpg") - cv2.imwrite(filename, image) # pylint: disable=no-member - logger.debug("Created timelapse: '%s'", filename) + cv2.imwrite(filename, image) + logger.debug("Created time-lapse: '%s'", filename) class PingPong(): - """ Side switcher for pingpong training """ + """ Side switcher for ping-pong training (memory saving feature) + + Parameters + ---------- + model: plugin from :mod:`plugins.train.model` + The selected model that will be running this trainer + sides: list + The sorted sides that are to be trained. Generally ["a", "b"] + + Attributes + ---------- + side: str + The side that is currently being trained + loss: dict + The loss for each side for ping pong training for the current ping pong session + """ def __init__(self, model, sides): logger.debug("Initializing %s: (model: '%s')", self.__class__.__name__, model) - self.active = model.training_opts.get("pingpong", False) - self.model = model - self.sides = sides + self._model = model + self._sides = sides self.side = sorted(sides)[0] self.loss = {side: [0] for side in sides} logger.debug("Initialized %s", self.__class__.__name__) + @property + def active(self): + """ bool: ``True`` if Ping Pong training is active otherwise ``False``. """ + return self._model.training_opts.get("pingpong", False) + def switch(self): - """ Switch pingpong side """ + """ Switch ping-pong training from one side of the model to the other """ if not self.active: return - retval = [side for side in self.sides if side != self.side][0] + retval = [side for side in self._sides if side != self.side][0] logger.info("Switching training to side %s", retval.title()) self.side = retval - self.reload_model() + self._reload_model() - def reload_model(self): - """ Load the model for just the current side """ + def _reload_model(self): + """ Clear out the model from VRAM and reload for the next side to be trained with ping-pong + training """ logger.verbose("Ping-Pong re-loading model") - self.model.reset_pingpong() + self._model.reset_pingpong() + + +class TrainingAlignments(): + """ Obtain Landmarks and required mask from alignments file. + + Parameters + ---------- + training_opts: dict + The dictionary of model training options (see module doc-string for information about + contents) + image_list: dict + The file paths for the images to be trained on for each side. The dictionary should contain + 2 keys ("a" and "b") with the values being a list of full paths corresponding to each side. + """ + def __init__(self, training_opts, image_list): + logger.debug("Initializing %s: (training_opts: '%s', image counts: %s)", + self.__class__.__name__, training_opts, + {k: len(v) for k, v in image_list.items()}) + self._training_opts = training_opts + self._hashes = self._get_image_hashes(image_list) + self._detected_faces = self._load_alignments() + self._check_all_faces() + logger.debug("Initialized %s", self.__class__.__name__) + @property + def landmarks(self): + """ dict: The :class:`numpy.ndarray` aligned landmarks for keys "a" and "b" """ + retval = {side: self._transform_landmarks(side, detected_faces) + for side, detected_faces in self._detected_faces.items()} + logger.trace(retval) + return retval -class Landmarks(): - """ Set Landmarks for training into the model's training options""" - def __init__(self, training_opts): - logger.debug("Initializing %s: (training_opts: '%s')", - self.__class__.__name__, training_opts) - self.size = training_opts.get("training_size", 256) - self.paths = training_opts["alignments"] - self.landmarks = self.get_alignments() - logger.debug("Initialized %s", self.__class__.__name__) + @property + def masks(self): + """ dict: The :class:`lib.faces_detect.Mask` objects of requested mask type for + keys a" and "b" + """ + retval = {side: self._get_masks(side, detected_faces) + for side, detected_faces in self._detected_faces.items()} + logger.trace(retval) + return retval - def get_alignments(self): - """ Obtain the landmarks for each faceset """ - landmarks = dict() - for side, fullpath in self.paths.items(): + # Load alignments + @staticmethod + def _get_image_hashes(image_list): + """ Return the hashes for all images used for training. + + Parameters + ---------- + image_list: dict + The file paths for the images to be trained on for each side. The dictionary should + contain 2 keys ("a" and "b") with the values being a list of full paths corresponding + to each side. + + Returns + ------- + dict + For keys "a" and "b" the values are a ``dict`` containing keys "hashes" and "filenames" + with their values being a list of hashes and filenames that exist within the training + data folder + """ + hashes = {key: dict(hashes=[], filenames=[]) for key in image_list} + pbar = tqdm(desc="Reading training images", + total=sum(len(val) for val in image_list.values())) + for side, filelist in image_list.items(): + logger.debug("side: %s, file count: %s", side, len(filelist)) + for filename, hsh in read_image_hash_batch(filelist): + hashes[side]["hashes"].append(hsh) + hashes[side]["filenames"].append(filename) + pbar.update(1) + pbar.close() + logger.trace(hashes) + return hashes + + def _load_alignments(self): + """ Load the alignments and convert to :class:`lib.faces_detect.DetectedFace` objects. + + Returns + ------- + dict + For keys "a" and "b" values are a list of :class:`lib.faces_detect.DetectedFace` + objects. + """ + logger.debug("Loading alignments") + retval = dict() + for side, fullpath in self._training_opts["alignments"].items(): + logger.debug("side: '%s', path: '%s'", side, fullpath) path, filename = os.path.split(fullpath) alignments = Alignments(path, filename=filename) - landmarks[side] = self.transform_landmarks(alignments) - return landmarks + retval[side] = self._to_detected_faces(alignments, side) + logger.debug("Returning: %s", {k: len(v) for k, v in retval.items()}) + return retval - def transform_landmarks(self, alignments): - """ For each face transform landmarks and return """ - landmarks = dict() - for _, faces, _, _ in alignments.yield_faces(): - for face in faces: + def _to_detected_faces(self, alignments, side): + """ Convert alignments to DetectedFace objects. + + Filter the detected faces to only those that exist in the training folders. + + Parameters + ---------- + alignments: :class:`lib.alignments.Alignments` + The alignments for the current faces + side: {"a" or "b"} + The side being processed + + Returns + ------- + list + List of :class:`lib.faces_detect.DetectedFace` objects + """ + skip_count = 0 + side_hashes = set(self._hashes[side]["hashes"]) + detected_faces = [] + for _, faces, _, filename in alignments.yield_faces(): + for idx, face in enumerate(faces): + if not self._validate_face(face, filename, idx, side, side_hashes): + skip_count += 1 + continue detected_face = DetectedFace() detected_face.from_alignment(face) - detected_face.load_aligned(None, size=self.size) - landmarks[detected_face.hash] = detected_face.aligned_landmarks + detected_faces.append(detected_face) + logger.debug("Detected Faces count: %s, Skipped faces count: %s", + len(detected_faces), skip_count) + if skip_count != 0: + logger.warning("%s alignments have been removed as their corresponding faces do not " + "exist in the input folder for side %s. Run in verbose mode if you " + "wish to see which alignments have been excluded.", + skip_count, side.upper()) + return detected_faces + + def _validate_face(self, face, filename, idx, side, side_hashes): + """ Validate that the currently processing face has a corresponding hash entry and the + requested mask exists + + Parameters + ---------- + face: dict + A face retrieved from an alignments file + filename: str + The original frame filename that the given face comes from + idx: int + The index of the face in the frame + side: {'A', 'B'} + The side that this face belongs to + side_hashes: set + A set of hashes that exist in the alignments folder for these faces + + Returns + ------- + bool + ``True`` if the face is valid otherwise ``False`` + + Raises + ------ + FaceswapError + If the current face doesn't pass validation + """ + mask_type = self._training_opts["mask_type"] + if mask_type is not None and "mask" not in face: + msg = ("You have selected a Mask Type in your training configuration options but at " + "least one face has no mask stored for it.\nYou should generate the required " + "masks with the Mask Tool or set the Mask Type configuration option to `none`." + "\nThe face that caused this failure was side: `{}`, frame: `{}`, index: {}. " + "However there are probably more faces without masks".format( + side.upper(), filename, idx)) + raise FaceswapError(msg) + + if mask_type is not None and mask_type not in face["mask"]: + msg = ("At least one of your faces does not have the mask `{}` stored for it.\nYou " + "should run the Mask Tool to generate this mask for your faceset or " + "select a different mask in the training configuration options.\n" + "The face that caused this failure was [side: `{}`, frame: `{}`, index: {}]. " + "The masks that exist for this face are: {}.\nBe aware that there are probably " + "more faces without this Mask Type".format( + mask_type, side.upper(), filename, idx, list(face["mask"].keys()))) + raise FaceswapError(msg) + + if face["hash"] not in side_hashes: + logger.verbose("Skipping alignment for non-existant face in frame '%s' index: %s", + filename, idx) + return False + return True + + def _check_all_faces(self): + """ Ensure that all faces in the training folder exist in the alignments file. + If not, output missing filenames + + Raises + ------ + FaceswapError + If there are faces in the training folder which do not exist in the alignments file + """ + logger.debug("Checking faces exist in alignments") + missing_alignments = dict() + for side, train_hashes in self._hashes.items(): + align_hashes = set(face.hash for face in self._detected_faces[side]) + if not align_hashes.issuperset(train_hashes["hashes"]): + missing_alignments[side] = [ + os.path.basename(filename) + for hsh, filename in zip(train_hashes["hashes"], train_hashes["filenames"]) + if hsh not in align_hashes] + if missing_alignments: + msg = ("There are faces in your training folder(s) which do not exist in your " + "alignments file. Training cannot continue. See above for a full list of " + "files missing alignments.") + for side, filelist in missing_alignments.items(): + logger.error("Faces missing alignments for side %s: %s", + side.capitalize(), filelist) + raise FaceswapError(msg) + + # Get landmarks + def _transform_landmarks(self, side, detected_faces): + """ Transform frame landmarks to their aligned face variant. + + Parameters + ---------- + side: {"a" or "b"} + The side currently being processed + detected_faces: list + A list of :class:`lib.faces_detect.DetectedFace` objects + + Returns + ------- + dict + The face filenames as keys with the aligned landmarks as value. + """ + landmarks = dict() + for face in detected_faces: + face.load_aligned(None, size=self._training_opts["training_size"]) + for filename in self._hash_to_filenames(side, face.hash): + landmarks[filename] = face.aligned_landmarks return landmarks + # Get masks + def _get_masks(self, side, detected_faces): + """ For each face, obtain the mask and set the requested blurring and threshold level. + + Parameters + ---------- + side: {"a" or "b"} + The side currently being processed + detected_faces: list + A list of :class:`lib.faces_detect.DetectedFace` objects + + Returns + ------- + dict + The face filenames as keys with the :class:`lib.faces_detect.Mask` as value. + """ + + masks = dict() + for face in detected_faces: + mask = face.mask[self._training_opts["mask_type"]] + mask.set_blur_kernel_and_threshold(blur_kernel=self._training_opts["mask_blur_kernel"], + threshold=self._training_opts["mask_threshold"]) + for filename in self._hash_to_filenames(side, face.hash): + masks[filename] = mask + return masks + + def _hash_to_filenames(self, side, face_hash): + """ For a given hash return all the filenames that match for the given side. + + Notes + ----- + Multiple faces can have the same hash, so this makes sure that all filenames are updated + for all instances of a hash. + + Parameters + ---------- + side: {"a" or "b"} + The side currently being processed + face_hash: str + The sha1 hash of the face to obtain the filename for + + Returns + ------- + list + The filenames that exist for the given hash + """ + side_hashes = self._hashes[side] + hash_indices = [idx for idx, hsh in enumerate(side_hashes["hashes"]) if hsh == face_hash] + retval = [side_hashes["filenames"][idx] for idx in hash_indices] + logger.trace("side: %s, hash: %s, filenames: %s", side, face_hash, retval) + return retval + + +def _stack_images(images): + """ Stack images evenly for preview. + + Parameters + ---------- + images: :class:`numpy.ndarray` + The preview images to be stacked -def stack_images(images): - """ Stack images """ + Returns + ------- + :class:`numpy.ndarray` + The stacked preview images + """ logger.debug("Stack images") def get_transpose_axes(num): diff --git a/scripts/convert.py b/scripts/convert.py index 0d90372db4..47cc6dbe59 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -59,7 +59,7 @@ def __init__(self, arguments): @property def queue_size(self): - """ Set 16 for singleprocess otherwise 32 """ + """ Set 16 for single process otherwise 32 """ if self.args.singleprocess: retval = 16 else: @@ -204,7 +204,7 @@ def total_count(self): logger.debug(retval) return retval - # Initalization + # Initialization def get_writer(self): """ Return the writer plugin """ args = [self.args.output_dir] @@ -311,7 +311,7 @@ def load(self, *args): # pylint: disable=unused-argument logger.debug("Load Queue: Stop signal received. Terminating") break if image is None or (not image.any() and image.ndim not in (2, 3)): - # All black frames will return not np.any() so check dims too + # All black frames will return not numpy.any() so check dims too logger.warning("Unable to open image. Skipping: '%s'", filename) continue if self.check_skipframe(filename): @@ -462,7 +462,7 @@ def input_mask(self): @property def has_predicted_mask(self): """ Return whether this model has a predicted mask """ - return bool(self.model.state.mask_shapes) + return bool(self.model.state.config.get("learn_mask", False)) @staticmethod def get_batchsize(queue_size): @@ -613,7 +613,7 @@ def predict(self, feed_faces, batch_size=None): """ Perform inference on the feed """ logger.trace("Predicting: Batchsize: %s", len(feed_faces)) feed = [feed_faces] - if self.has_predicted_mask: + if self.model.feed_mask: feed.append(np.repeat(self.input_mask, feed_faces.shape[0], axis=0)) logger.trace("Input shape(s): %s", [item.shape for item in feed]) diff --git a/scripts/extract.py b/scripts/extract.py index 2b1b2c0cd9..bb9c50030c 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -235,7 +235,8 @@ def _output_processing(self, extract_media, size): Loads the aligned face, perform any processing actions and verify the output. - Parameters: + Parameters + ---------- extract_media: :class:`plugins.extract.pipeline.ExtractMedia` Output from :class:`plugins.extract.pipeline.Extractor` size: int diff --git a/scripts/train.py b/scripts/train.py index a3b9a954ee..ad51d1c331 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -1,5 +1,5 @@ #!/usr/bin python3 -""" The script to run the training process of faceswap """ +""" Main entry point to the training process of FaceSwap """ import logging import os @@ -15,7 +15,6 @@ from lib.image import read_image from lib.keypress import KBHit from lib.multithreading import MultiThread -from lib.queue_manager import queue_manager # noqa pylint:disable=unused-import from lib.utils import get_folder, get_image_paths, set_system_verbosity, deprecation_warning from plugins.plugin_loader import PluginLoader @@ -23,54 +22,103 @@ class Train(): - """ The training process. """ + """ The Faceswap Training Process. + + The training process is responsible for training a model on a set of source faces and a set of + destination faces. + + The training process is self contained and should not be referenced by any other scripts, so it + contains no public properties. + + Parameters + ---------- + arguments: argparse.Namespace + The arguments to be passed to the training process as generated from Faceswap's command + line arguments + """ def __init__(self, arguments): logger.debug("Initializing %s: (args: %s", self.__class__.__name__, arguments) - self.args = arguments - self.timelapse = self.set_timelapse() - self.images = self.get_images() - self.stop = False - self.save_now = False - self.preview_buffer = dict() - self.lock = Lock() - - self.trainer_name = self.args.trainer + self._args = arguments + self._timelapse = self._set_timelapse() + self._images = self._get_images() + self._stop = False + self._save_now = False + self._preview_buffer = dict() + self._lock = Lock() + + self.trainer_name = self._args.trainer logger.debug("Initialized %s", self.__class__.__name__) - def set_timelapse(self): - """ Set time-lapse paths if requested """ - if (not self.args.timelapse_input_a and - not self.args.timelapse_input_b and - not self.args.timelapse_output): + @property + def _image_size(self): + """ int: The training image size. Reads the first image in the training folder and returns + the size. """ + image = read_image(self._images["a"][0], raise_error=True) + size = image.shape[0] + logger.debug("Training image size: %s", size) + return size + + @property + def _alignments_paths(self): + """ dict: The alignments paths for each of the source and destination faces. Key is the + side, value is the path to the alignments file """ + alignments_paths = dict() + for side in ("a", "b"): + alignments_path = getattr(self._args, "alignments_path_{}".format(side)) + if not alignments_path: + image_path = getattr(self._args, "input_{}".format(side)) + alignments_path = os.path.join(image_path, "alignments.fsa") + alignments_paths[side] = alignments_path + logger.debug("Alignments paths: %s", alignments_paths) + return alignments_paths + + def _set_timelapse(self): + """ Set time-lapse paths if requested. + + Returns + ------- + dict + The time-lapse keyword arguments for passing to the trainer + + """ + if (not self._args.timelapse_input_a and + not self._args.timelapse_input_b and + not self._args.timelapse_output): return None - if not self.args.timelapse_input_a or not self.args.timelapse_input_b: + if not self._args.timelapse_input_a or not self._args.timelapse_input_b: raise ValueError("To enable the timelapse, you have to supply " "all the parameters (--timelapse-input-A and " "--timelapse-input-B).") timelapse_output = None - if self.args.timelapse_output is not None: - timelapse_output = str(get_folder(self.args.timelapse_output)) + if self._args.timelapse_output is not None: + timelapse_output = str(get_folder(self._args.timelapse_output)) - for folder in (self.args.timelapse_input_a, - self.args.timelapse_input_b, + for folder in (self._args.timelapse_input_a, + self._args.timelapse_input_b, timelapse_output): if folder is not None and not os.path.isdir(folder): raise ValueError("The Timelapse path '{}' does not exist".format(folder)) - kwargs = {"input_a": self.args.timelapse_input_a, - "input_b": self.args.timelapse_input_b, + kwargs = {"input_a": self._args.timelapse_input_a, + "input_b": self._args.timelapse_input_b, "output": timelapse_output} logger.debug("Timelapse enabled: %s", kwargs) return kwargs - def get_images(self): - """ Check the image folders exist, contain images and return the image - objects """ + def _get_images(self): + """ Check the image folders exist and contains images and obtain image paths. + + Returns + ------- + dict + The image paths for each side. The key is the side, the value is the list of paths + for that side. + """ logger.debug("Getting image paths") images = dict() for side in ("a", "b"): - image_dir = getattr(self.args, "input_{}".format(side)) + image_dir = getattr(self._args, "input_{}".format(side)) if not os.path.isdir(image_dir): logger.error("Error: '%s' does not exist", image_dir) exit(1) @@ -80,45 +128,62 @@ def get_images(self): logger.error("Error: '%s' contains no images", image_dir) exit(1) - logger.info("Model A Directory: %s", self.args.input_a) - logger.info("Model B Directory: %s", self.args.input_b) + logger.info("Model A Directory: %s", self._args.input_a) + logger.info("Model B Directory: %s", self._args.input_b) logger.debug("Got image paths: %s", [(key, str(len(val)) + " images") for key, val in images.items()]) return images def process(self): - """ Call the training process object """ + """ The entry point for triggering the Training Process. + + Should only be called from :class:`lib.cli.ScriptExecutor` + """ logger.debug("Starting Training Process") - logger.info("Training data directory: %s", self.args.model_dir) + logger.info("Training data directory: %s", self._args.model_dir) # TODO Move these args to config and remove these deprecation warnings - if hasattr(self.args, "warp_to_landmarks") and self.args.warp_to_landmarks: + if hasattr(self._args, "warp_to_landmarks") and self._args.warp_to_landmarks: deprecation_warning("`-wl`, ``--warp-to-landmarks``", additional_info="This option will be available within training " "config settings (/config/train.ini).") - if hasattr(self.args, "no_augment_color") and self.args.no_flip: + if hasattr(self._args, "no_augment_color") and self._args.no_flip: deprecation_warning("`-nac`, ``--no-augment-color``", additional_info="This option will be available within training " "config settings (/config/train.ini).") - set_system_verbosity(self.args.loglevel) - thread = self.start_thread() - # queue_manager.debug_monitor(1) + set_system_verbosity(self._args.loglevel) + thread = self._start_thread() + # from lib.queue_manager import queue_manager; queue_manager.debug_monitor(1) - err = self.monitor(thread) + err = self._monitor(thread) - self.end_thread(thread, err) + self._end_thread(thread, err) logger.debug("Completed Training Process") - def start_thread(self): - """ Put the training process in a thread so we can keep control """ + def _start_thread(self): + """ Put the :func:`_training` into a background thread so we can keep control. + + Returns + ------- + :class:`lib.multithreading.MultiThread` + The background thread for running training + """ logger.debug("Launching Trainer thread") - thread = MultiThread(target=self.training) + thread = MultiThread(target=self._training) thread.start() logger.debug("Launched Trainer thread") return thread - def end_thread(self, thread, err): - """ On termination output message and join thread back to main """ + def _end_thread(self, thread, err): + """ Output message and join thread back to main on termination. + + Parameters + ---------- + thread: :class:`lib.multithreading.MultiThread` + The background training thread + err: bool + Whether an error has been detected in :func:`_monitor` + """ logger.debug("Ending Training thread") if err: msg = "Error caught! Exiting..." @@ -127,27 +192,27 @@ def end_thread(self, thread, err): msg = ("Exit requested! The trainer will complete its current cycle, " "save the models and quit (This can take a couple of minutes " "depending on your training speed).") - if not self.args.redirect_gui: + if not self._args.redirect_gui: msg += " If you want to kill it now, press Ctrl + c" log = logger.info log(msg) - self.stop = True + self._stop = True thread.join() sys.stdout.flush() - logger.debug("Ended Training thread") + logger.debug("Ended training thread") - def training(self): - """ The training process to be run inside a thread """ + def _training(self): + """ The training process to be run inside a thread. """ try: sleep(1) # Let preview instructions flush out to logger logger.debug("Commencing Training") logger.info("Loading data, this may take a while...") - if self.args.allow_growth: - self.set_tf_allow_growth() - model = self.load_model() - trainer = self.load_trainer(model) - self.run_training_cycle(model, trainer) + if self._args.allow_growth: + self._set_tf_allow_growth() + model = self._load_model() + trainer = self._load_trainer(model) + self._run_training_cycle(model, trainer) except KeyboardInterrupt: try: logger.debug("Keyboard Interrupt Caught. Saving Weights and exiting") @@ -159,117 +224,130 @@ def training(self): except Exception as err: raise err - def load_model(self): - """ Load the model requested for training """ + def _load_model(self): + """ Load the model requested for training. + + Returns + ------- + :file:`plugins.train.model` plugin + The requested model plugin + """ logger.debug("Loading Model") - model_dir = get_folder(self.args.model_dir) - configfile = self.args.configfile if hasattr(self.args, "configfile") else None - augment_color = not self.args.no_augment_color + model_dir = get_folder(self._args.model_dir) + configfile = self._args.configfile if hasattr(self._args, "configfile") else None + augment_color = not self._args.no_augment_color model = PluginLoader.get_model(self.trainer_name)( model_dir, - gpus=self.args.gpus, + gpus=self._args.gpus, configfile=configfile, - snapshot_interval=self.args.snapshot_interval, - no_logs=self.args.no_logs, - warp_to_landmarks=self.args.warp_to_landmarks, + snapshot_interval=self._args.snapshot_interval, + no_logs=self._args.no_logs, + warp_to_landmarks=self._args.warp_to_landmarks, augment_color=augment_color, - no_flip=self.args.no_flip, - training_image_size=self.image_size, - alignments_paths=self.alignments_paths, - preview_scale=self.args.preview_scale, - pingpong=self.args.pingpong, - memory_saving_gradients=self.args.memory_saving_gradients, - optimizer_savings=self.args.optimizer_savings, + no_flip=self._args.no_flip, + training_image_size=self._image_size, + alignments_paths=self._alignments_paths, + preview_scale=self._args.preview_scale, + pingpong=self._args.pingpong, + memory_saving_gradients=self._args.memory_saving_gradients, + optimizer_savings=self._args.optimizer_savings, predict=False) logger.debug("Loaded Model") return model - @property - def image_size(self): - """ Get the training set image size for storing in model data """ - image = read_image(self.images["a"][0], raise_error=True) - size = image.shape[0] - logger.debug("Training image size: %s", size) - return size + def _load_trainer(self, model): + """ Load the trainer requested for training. - @property - def alignments_paths(self): - """ Set the alignments path to input folder if not provided """ - alignments_paths = dict() - for side in ("a", "b"): - alignments_path = getattr(self.args, "alignments_path_{}".format(side)) - if not alignments_path: - image_path = getattr(self.args, "input_{}".format(side)) - alignments_path = os.path.join(image_path, "alignments.fsa") - alignments_paths[side] = alignments_path - logger.debug("Alignments paths: %s", alignments_paths) - return alignments_paths + Parameters + ---------- + model: :file:`plugins.train.model` plugin + The requested model plugin - def load_trainer(self, model): - """ Load the trainer requested for training """ + Returns + ------- + :file:`plugins.train.trainer` plugin + The requested model trainer plugin + """ logger.debug("Loading Trainer") trainer = PluginLoader.get_trainer(model.trainer) trainer = trainer(model, - self.images, - self.args.batch_size, - self.args.configfile) + self._images, + self._args.batch_size, + self._args.configfile) logger.debug("Loaded Trainer") return trainer - def run_training_cycle(self, model, trainer): - """ Perform the training cycle """ + def _run_training_cycle(self, model, trainer): + """ Perform the training cycle. + + Handles the background training, updating previews/time-lapse on each save interval, + and saving the model. + + Parameters + ---------- + model: :file:`plugins.train.model` plugin + The requested model plugin + trainer: :file:`plugins.train.trainer` plugin + The requested model trainer plugin + """ logger.debug("Running Training Cycle") - if self.args.write_image or self.args.redirect_gui or self.args.preview: - display_func = self.show + if self._args.write_image or self._args.redirect_gui or self._args.preview: + display_func = self._show else: display_func = None - for iteration in range(0, self.args.iterations): + for iteration in range(0, self._args.iterations): logger.trace("Training iteration: %s", iteration) - save_iteration = iteration % self.args.save_interval == 0 - viewer = display_func if save_iteration or self.save_now else None - timelapse = self.timelapse if save_iteration else None + save_iteration = iteration % self._args.save_interval == 0 + viewer = display_func if save_iteration or self._save_now else None + timelapse = self._timelapse if save_iteration else None trainer.train_one_step(viewer, timelapse) - if self.stop: + if self._stop: logger.debug("Stop received. Terminating") break if save_iteration: logger.trace("Save Iteration: (iteration: %s", iteration) - if self.args.pingpong: + if self._args.pingpong: model.save_models() trainer.pingpong.switch() else: model.save_models() - elif self.save_now: + elif self._save_now: logger.trace("Save Requested: (iteration: %s", iteration) model.save_models() - self.save_now = False + self._save_now = False logger.debug("Training cycle complete") model.save_models() trainer.clear_tensorboard() - self.stop = True + self._stop = True - def monitor(self, thread): - """ Monitor the console, and generate + monitor preview if requested """ - is_preview = self.args.preview + def _monitor(self, thread): + """ Monitor the background :func:`_training` thread for key presses and errors. + + Returns + ------- + bool + ``True`` if there has been an error in the background thread otherwise ``False`` + """ + is_preview = self._args.preview logger.debug("Launching Monitor") logger.info("===================================================") logger.info(" Starting") if is_preview: logger.info(" Using live preview") logger.info(" Press '%s' to save and quit", - "Terminate" if self.args.redirect_gui else "ENTER") - if not self.args.redirect_gui: + "Terminate" if self._args.redirect_gui else "ENTER") + if not self._args.redirect_gui: logger.info(" Press 'S' to save model weights immediately") logger.info("===================================================") - keypress = KBHit(is_gui=self.args.redirect_gui) + keypress = KBHit(is_gui=self._args.redirect_gui) err = False while True: try: if is_preview: - with self.lock: - for name, image in self.preview_buffer.items(): + with self._lock: + for name, image in self._preview_buffer.items(): cv2.imshow(name, image) # pylint: disable=no-member cv_key = cv2.waitKey(1000) # pylint: disable=no-member else: @@ -279,7 +357,7 @@ def monitor(self, thread): logger.debug("Thread error detected") err = True break - if self.stop: + if self._stop: logger.debug("Stop received") break @@ -289,7 +367,7 @@ def monitor(self, thread): break if is_preview and cv_key == ord("s"): logger.info("Save requested") - self.save_now = True + self._save_now = True # Console Monitor if keypress.kbhit(): @@ -299,7 +377,7 @@ def monitor(self, thread): break if console_key in ("s", "S"): logger.info("Save requested") - self.save_now = True + self._save_now = True sleep(1) except KeyboardInterrupt: @@ -310,14 +388,11 @@ def monitor(self, thread): return err @staticmethod - def keypress_monitor(keypress_queue): - """ Monitor stdin for key press """ - while True: - keypress_queue.put(sys.stdin.read(1)) + def _set_tf_allow_growth(): + """ Allow TensorFlow to manage VRAM growth. - @staticmethod - def set_tf_allow_growth(): - """ Allow TensorFlow to manage VRAM growth """ + Enables the Tensorflow allow_growth option if requested in the command line arguments + """ # pylint: disable=no-member logger.debug("Setting Tensorflow 'allow_growth' option") config = tf.ConfigProto() @@ -326,28 +401,39 @@ def set_tf_allow_growth(): set_session(tf.Session(config=config)) logger.debug("Set Tensorflow 'allow_growth' option") - def show(self, image, name=""): - """ Generate the preview and write preview file output """ + def _show(self, image, name=""): + """ Generate the preview and write preview file output. + + Handles the output and display of preview images. + + Parameters + ---------- + image: :class:`numpy.ndarray` + The preview image to be displayed and/or written out + name: str, optional + The name of the image for saving or display purposes. If an empty string is passed + then it will automatically be names. Default: "" + """ logger.trace("Updating preview: (name: %s)", name) try: scriptpath = os.path.realpath(os.path.dirname(sys.argv[0])) - if self.args.write_image: + if self._args.write_image: logger.trace("Saving preview to disk") img = "training_preview.jpg" imgfile = os.path.join(scriptpath, img) cv2.imwrite(imgfile, image) # pylint: disable=no-member logger.trace("Saved preview to: '%s'", img) - if self.args.redirect_gui: + if self._args.redirect_gui: logger.trace("Generating preview for GUI") img = ".gui_training_preview.jpg" imgfile = os.path.join(scriptpath, "lib", "gui", ".cache", "preview", img) cv2.imwrite(imgfile, image) # pylint: disable=no-member logger.trace("Generated preview for GUI: '%s'", img) - if self.args.preview: + if self._args.preview: logger.trace("Generating preview for display: '%s'", name) - with self.lock: - self.preview_buffer[name] = image + with self._lock: + self._preview_buffer[name] = image logger.trace("Generated preview for display: '%s'", name) except Exception as err: logging.error("could not preview sample") From ec782089d37508d6ce2a92d074ccc06462f181eb Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 5 Dec 2019 17:32:31 +0000 Subject: [PATCH 170/981] gui: Add branch switcher to help menu --- lib/gui/menu.py | 114 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 108 insertions(+), 6 deletions(-) diff --git a/lib/gui/menu.py b/lib/gui/menu.py index a76710c7c9..7bbb687c0d 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -27,6 +27,8 @@ _CONFIG_FILES = [] _CONFIGS = dict() +_WORKING_DIR = os.path.dirname(os.path.realpath(sys.argv[0])) + logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -232,6 +234,7 @@ def __init__(self, parent): super().__init__(parent, tearoff=0) self.root = parent.root self.recources_menu = tk.Menu(self, tearoff=0) + self._branches_menu = tk.Menu(self, tearoff=0) self.build() logger.debug("Initialized %s", self.__class__.__name__) @@ -245,8 +248,10 @@ def build(self): self.add_command(label="Update Faceswap...", underline=0, command=lambda action="update": self.in_thread(action)) + if self._build_branches_menu(): + self.add_cascade(label="Switch Branch", underline=7, menu=self._branches_menu) self.add_separator() - self.build_recources_menu() + self._build_recources_menu() self.add_cascade(label="Resources", underline=0, menu=self.recources_menu) self.add_separator() self.add_command(label="Output System Information", @@ -254,7 +259,106 @@ def build(self): command=lambda action="output_sysinfo": self.in_thread(action)) logger.debug("Built help menu") - def build_recources_menu(self): + def _build_branches_menu(self): + """ Build branch selection menu. + + Queries git for available branches and builds a menu based on output. + + Returns + ------- + bool + ``True`` if menu was successfully built otherwise ``False`` + """ + stdout = self._get_branches() + if stdout is None: + return False + + branches = self._filter_branches(stdout) + if not branches: + return False + + for branch in branches: + self._branches_menu.add_command( + label=branch, + command=lambda b=branch: self._switch_branch(b)) + return True + + @staticmethod + def _get_branches(): + """ Get the available github branches + + Returns + ------- + str + The list of branches available. If no branches were found or there was an + error then `None` is returned + """ + gitcmd = "git branch -a" + cmd = Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, cwd=_WORKING_DIR) + stdout, _ = cmd.communicate() + retcode = cmd.poll() + if retcode != 0: + logger.debug("Unable to list git branches. return code: %s, message: %s", + retcode, stdout.decode().strip().replace("\n", " - ")) + return None + return stdout.decode(locale.getpreferredencoding()) + + @staticmethod + def _filter_branches(stdout): + """ Filter the branches, remove duplicates and the current branch and return a sorted + list. + + Parameters + ---------- + stdout: str + The output from the git branch query converted to a string + + Returns + ------- + list + Unique list of available branches sorted in alphabetical order + """ + current = None + branches = set() + for line in stdout.splitlines(): + branch = line[line.rfind("/") + 1:] if "/" in line else line.strip() + if branch.startswith("*"): + branch = branch.replace("*", "").strip() + current = branch + continue + branches.add(branch) + logger.debug("Found branches: %s", branches) + if current in branches: + logger.debug("Removing current branch from output: %s", current) + branches.remove(current) + + branches = sorted(list(branches), key=str.casefold) + logger.debug("Final branches: %s", branches) + return branches + + @staticmethod + def _switch_branch(branch): + """ Change the currently checked out branch, and return a notification. + + Parameters + ---------- + str + The branch to switch to + """ + logger.info("Switching branch to '%s'...", branch) + gitcmd = "git checkout {}".format(branch) + cmd = Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, cwd=_WORKING_DIR) + stdout, _ = cmd.communicate() + retcode = cmd.poll() + if retcode != 0: + logger.error("Unable to switch branch. return code: %s, message: %s", + retcode, stdout.decode().strip().replace("\n", " - ")) + return + logger.info("Succesfully switched to '%s'. You may want to check for updates to make sure " + "that you have the latest code.", branch) + logger.info("Please restart Faceswap to complete the switch.") + + def _build_recources_menu(self): """ Build resources menu """ # pylint: disable=cell-var-from-loop logger.debug("Building Resources Files menu") @@ -322,8 +426,7 @@ def check_for_updates(encoding, check=False): update = False msg = "" gitcmd = "git remote update && git status -uno" - working_dir = os.path.dirname(os.path.realpath(sys.argv[0])) - cmd = Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, cwd=working_dir) + cmd = Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, cwd=_WORKING_DIR) stdout, _ = cmd.communicate() retcode = cmd.poll() if retcode != 0: @@ -355,8 +458,7 @@ def do_update(encoding): """ Update Faceswap """ logger.info("A new version is available. Updating...") gitcmd = "git pull" - working_dir = os.path.dirname(os.path.realpath(sys.argv[0])) - cmd = Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, bufsize=1, cwd=working_dir) + cmd = Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, bufsize=1, cwd=_WORKING_DIR) while True: output = cmd.stdout.readline().decode(encoding) if output == "" and cmd.poll() is not None: From 6f7ae98936d87d7c60cabb00715fe60aff7ddd42 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 5 Dec 2019 20:13:22 +0000 Subject: [PATCH 171/981] bugfix: tools..mask - Fix generation of new masks for face input --- tools/mask.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/mask.py b/tools/mask.py index 8cedb83d19..61a657ad85 100644 --- a/tools/mask.py +++ b/tools/mask.py @@ -316,6 +316,7 @@ def _update_faces(self, extractor_output): for frame, idx in self._alignments.hashes_to_frame[face.hash].items(): 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 _update_frames(self, extractor_output): From 4ee9eac4f4fba1b51a51b9e24d67a98b1abd7ffe Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 6 Dec 2019 03:10:47 +0000 Subject: [PATCH 172/981] tools.alignments - Rewrite rename job to be more robust --- tools/lib_alignments/jobs.py | 244 ++++++++++++++++++++++++----------- 1 file changed, 172 insertions(+), 72 deletions(-) diff --git a/tools/lib_alignments/jobs.py b/tools/lib_alignments/jobs.py index 5c54114a53..0da68cb785 100644 --- a/tools/lib_alignments/jobs.py +++ b/tools/lib_alignments/jobs.py @@ -219,7 +219,7 @@ def output_file(self, output_message, items_discovered): f_output.write(output_message) def move_file(self, items_output): - """ Move the identified frames to a new subfolder """ + """ Move the identified frames to a new sub folder """ now = datetime.now().strftime("%Y%m%d_%H%M%S") folder_name = "{}{}_{}".format(self.get_filename_prefix(), self.output_message.replace(" ", "_").lower(), now) @@ -232,7 +232,7 @@ def move_file(self, items_output): move(output_folder, items_output) def move_frames(self, output_folder, items_output): - """ Move frames into single subfolder """ + """ Move frames into single sub folder """ logger.info("Moving %s frame(s) to '%s'", len(items_output), output_folder) for frame in items_output: src = os.path.join(self.source_dir, frame) @@ -241,7 +241,7 @@ def move_frames(self, output_folder, items_output): os.rename(src, dst) def move_faces(self, output_folder, items_output): - """ Make additional subfolders for each face that appears + """ Make additional sub folders for each face that appears Enables easier manual sorting """ logger.info("Moving %s faces(s) to '%s'", len(items_output), output_folder) for frame, idx in items_output: @@ -668,89 +668,189 @@ def remove_faces(self): class Rename(): - """ Rename faces to match their source frame and position index """ + """ Rename faces in a folder to match their filename as stored in an alignments file. + + Parameters + ---------- + alignments: :class:`tools.lib_alignments.media.AlignmentData` + The alignments data loaded from an alignments file for this rename job + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + faces: :class:`tools.lib_alignments.media.Faces`, Optional + An optional faces object, if the rename task is being called by another job. + Default: ``None`` + """ def __init__(self, alignments, arguments, faces=None): logger.debug("Initializing %s: (arguments: %s, faces: %s)", self.__class__.__name__, arguments, faces) self.alignments = alignments self.faces = faces if faces else Faces(arguments.faces_dir) - self.seen_multihash = set() logger.debug("Initialized %s", self.__class__.__name__) def process(self): """ Process the face renaming """ logger.info("[RENAME FACES]") # Tidy up cli output - rename_count = 0 - for frame, details, _, frame_fullname in tqdm(self.alignments.yield_faces(), - desc="Renaming Faces", - total=self.alignments.frames_count): - rename_count += self.rename_faces(frame, frame_fullname, details) + rename_mappings = self._build_rename_list() + rename_count = self._rename_faces(rename_mappings) logger.info("%s faces renamed", rename_count) - def rename_faces(self, frame, frame_fullname, details): - """ Rename faces - Done in 2 iterations as two files cannot share the same name """ - logger.trace("Renaming faces for frame: '%s'", frame_fullname) - temp_ext = ".temp_move" - frame_faces = [(x["hash"], idx) for idx, x in enumerate(details)] - rename_count = 0 - rename_files = list() - for f_hash, idx in frame_faces: - faces = self.faces.items[f_hash] - if len(faces) == 1: - face_name, face_ext = faces[0] - else: - face_name, face_ext = self.check_multi_hashes(faces, frame, idx) - old = face_name + face_ext - new = "{}_{}{}".format(frame, idx, face_ext) - if old == new: - logger.trace("Face does not require renaming: '%s'", old) + def _build_rename_list(self): + """ Build a list of source and destination filenames for renaming. + + Validates that all files in the faces folder have a corresponding match in the alignments + file. Orders the rename list by destination filename to avoid potential for filename clash. + + Returns + ------- + list + List of tuples of (`source filename`, `destination filename`) ordered by destination + filename + """ + source_filenames = [] + dest_filenames = [] + errors = [] + pbar = tqdm(desc="Building Rename Lists", total=self.faces.count) + for disk_hash, disk_faces in self.faces.items.items(): + align_faces = self.alignments.hashes_to_frame.get(disk_hash, None) + face_error = self._validate_hash_match(disk_faces, align_faces) + if face_error is not None: + errors.extend(face_error) + pbar.update(len(disk_faces)) continue - rename_files.append((old, new)) - for action in ("temp", "final"): - for files in rename_files: - old, new = files - old_file = old if action == "temp" else old + temp_ext - new_file = old + temp_ext if action == "temp" else new - src = os.path.join(self.faces.folder, old_file) - dst = os.path.join(self.faces.folder, new_file) - logger.trace("Renaming: '%s' to '%s'", old_file, new_file) - os.rename(src, dst) - if action == "final": - rename_count += 1 - logger.verbose("Renamed '%s' to '%s'", old, new) - return rename_count + src_faces, dst_faces = self._get_filename_mapping(disk_faces, align_faces) + source_filenames.extend(src_faces) + dest_filenames.extend(dst_faces) + pbar.update(len(src_faces)) + pbar.close() + if errors: + logger.error("There are faces in the given folder that do not correspond to entries " + "in the alignments file. Please check your data, and if neccesarry run " + "the `remove-faces` job. To get a list of faces missing alignments " + "entries, run with VERBOSE logging") + logger.verbose("Files in faces folder not in alignments file: %s", errors) + exit(1) + return self._sort_mappings(source_filenames, dest_filenames) - def check_multi_hashes(self, faces, frame, idx): - """ Check filenames for where multiple faces have the - same hash (e.g. for freeze frames) """ - logger.debug("Multiple hashes: (frame: faces: %s, frame: '%s', idx: %s", faces, frame, idx) - frame_idx = "{}_{}".format(frame, idx) - retval = None - for face_name, extension in faces: - if (face_name, extension) in self.seen_multihash: - # Don't return a filename that has already been processed - logger.debug("Already seen: %s", (face_name, extension)) + @staticmethod + def _validate_hash_match(disk_faces, align_faces): + """ Validate that the hash has returned corresponding faces from disk and alignments file. + + Parameters + ---------- + disk_faces: list + List of tuples of (`file name`, `file extension`) for all faces that exist for the + current hash + align_faces: dict + `frame filename`: `index` for all faces that exist in the alignments file for the + current hash + + Returns + ------- + list + List of disk_faces that do not correspond to a matching entry in the alignments file. + Returns `None` if there is a valid match + """ + if align_faces is None: + logger.debug("No matching hash found for faces: %s", disk_faces) + return [face[0] + face[1] for face in disk_faces] + if len(disk_faces) != len(align_faces): + logger.debug("Number of faces mismatch for hash: (disk_faces: %s, align_faces: %s)", + disk_faces, align_faces) + return [face[0] + face[1] for face in disk_faces[: len(align_faces)]] + return None + + @staticmethod + def _get_filename_mapping(disk_faces, align_faces): + """ Map the source filenames for this hash to the destination filenames. + + Parameters + ---------- + disk_faces: list + List of tuples of (`file name`, `file extension`) for all faces that exist for the + current hash + align_faces: dict + `frame filename`: `index` for all faces that exist in the alignments file for the + current hash + + Returns + ------- + source_filenames: list + List of source filenames to be renamed for this hash + dest_filenames: list + List of destination filenames that faces for this hash are to be renamed to + List of disk_faces that do not correspond to a matching entry in the alignments file. + Returns `None` if there is a valid match + """ + source_filenames = [] + dest_filenames = [] + # Force deterministic order on alignments dict for multi hash faces + sorted_aligned = sorted([(frame, idx) for frame, idx in align_faces.items()]) + for disk_face, align_face in zip(disk_faces, sorted_aligned): + extension = disk_face[1] + src_fname = disk_face[0] + extension + + dst_frame = os.path.splitext(align_face[0])[0] + dst_fname = "{}_{}{}".format(dst_frame, align_face[1], extension) + logger.debug("Mapping rename from '%s' to '%s'", src_fname, dst_fname) + source_filenames.append(src_fname) + dest_filenames.append(dst_fname) + return source_filenames, dest_filenames + + @staticmethod + def _sort_mappings(sources, destinations): + """ Sort the mapping lists by destinations to avoid filename clash. + + Parameters + ---------- + sources: list + List of source filenames in the same order as :attr:`destinations` + destinations: dict + List of destination filenames in the same order as :attr:`sources` + + Returns + ------- + list + List of tuples of (`source filename`, `destination filename`) ordered by destination + filename + """ + sorted_indices = [idx for idx, _ in sorted(enumerate(destinations), key=lambda x: x[1])] + mappings = [(sources[idx], destinations[idx]) for idx in sorted_indices] + logger.trace("filename mappings: %s", mappings) + return mappings + + def _rename_faces(self, filename_mappings): + """ Rename faces back to their original name as exists in the alignments file. + + If the source and destination filename are the same then skip that file. + + Parameters + ---------- + filename_mappings: list + List of tuples of (`source filename`, `destination filename`) ordered by destination + filename + + Returns + ------- + int + The number of faces that have been renamed + """ + rename_count = 0 + for src, dst in tqdm(filename_mappings, desc="Renaming Faces"): + if src == dst: + logger.debug("Skipping rename of '%s' as destination name is same as souce", src) continue - if face_name == frame_idx: - # If a matching filename already exists return that - retval = (face_name, extension) - logger.debug("Matching filename found: %s", retval) - self.seen_multihash.add(retval) - break - if face_name.startswith(frame): - # If a matching framename already exists return that - retval = (face_name, extension) - logger.debug("Matching freamename found: %s", retval) - self.seen_multihash.add(retval) - break - if not retval: - # If no matches, just pop the first filename - retval = [face for face in faces if face not in self.seen_multihash][0] - logger.debug("No matches found. Choosing: %s", retval) - self.seen_multihash.add(retval) - logger.debug("Returning: %s", retval) - return retval + old = os.path.join(self.faces.folder, src) + new = os.path.join(self.faces.folder, dst) + if os.path.exists(new): + # This should never happen, but is a safety measure to prevent deletion of faces + # when multiple files have the same hash. + logger.debug("Skipping renaming to an existing file: (src: '%s', dst: '%s'", + src, dst) + continue + logger.verbose("Renaming '%s' to '%s'", old, new) + os.rename(old, new) + rename_count += 1 + return rename_count class Sort(): @@ -922,7 +1022,7 @@ def spatially_filter(self): # Convert back to shapes (numKeypoint, num_dims, numFrames) landmarks_norm_rec = np.reshape(landmarks_norm_table_rec.T, [68, 2, landmarks_norm.shape[2]]) - # Transform back to image coords + # Transform back to image co-ordinates retval = self.normalized_to_original(landmarks_norm_rec, self.normalized["scale_factors"], self.normalized["mean_coords"]) From aca1f44472aaf963aefc7517837b3b4709be937c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 7 Dec 2019 18:14:32 +0000 Subject: [PATCH 173/981] bugfix - Extract - Generate dummy video filenames correctly --- lib/image.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/image.py b/lib/image.py index 6f30b10c02..6445c0cafb 100644 --- a/lib/image.py +++ b/lib/image.py @@ -607,24 +607,29 @@ def _from_video(self): continue # Convert to BGR for cv2 compatibility frame = frame[:, :, ::-1] - filename = self._dummy_video_framename(idx + 1) + filename = self._dummy_video_framename(idx) logger.trace("Loading video frame: '%s'", filename) yield filename, frame reader.close() - def _dummy_video_framename(self, frame_no): + def _dummy_video_framename(self, index): """ Return a dummy filename for video files Parameters ---------- - frame_no: int - The frame number for the video frame + index: int + The index number for the frame in the video file + + Notes + ----- + Indexes start at 0, frame numbers start at 1, so index is incremented by 1 + when creating the filename Returns ------- str: A dummied filename for a video frame """ vidname = os.path.splitext(os.path.basename(self.location))[0] - return "{}_{:06d}.png".format(vidname, frame_no + 1) + return "{}_{:06d}.png".format(vidname, index + 1) def _from_folder(self): """ Generator for loading images from a folder From e2373fd8726b3e840bcce45d69cbfd8180f240cb Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 7 Dec 2019 19:18:32 +0000 Subject: [PATCH 174/981] Add job to fix frames where alignments are out by 1 --- tools/alignments.py | 2 +- tools/cli.py | 7 ++++- tools/lib_alignments/__init__.py | 2 +- tools/lib_alignments/jobs.py | 50 ++++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/tools/alignments.py b/tools/alignments.py index 7575a1dd9b..b5cde21157 100644 --- a/tools/alignments.py +++ b/tools/alignments.py @@ -4,7 +4,7 @@ from lib.utils import set_system_verbosity from .lib_alignments import (AlignmentData, Check, Dfl, Draw, # noqa pylint: disable=unused-import - Extract, Manual, Merge, Rename, + Extract, Fix, Manual, Merge, Rename, RemoveAlignments, Sort, Spatial, UpdateHashes) logger = logging.getLogger(__name__) # pylint: disable=invalid-name diff --git a/tools/cli.py b/tools/cli.py index 28cb2a92b4..3f45996ebe 100644 --- a/tools/cli.py +++ b/tools/cli.py @@ -32,7 +32,7 @@ def get_argument_list(self): "opts": ("-j", "--job"), "action": Radio, "type": str, - "choices": ("dfl", "draw", "extract", "manual", "merge", "missing-alignments", + "choices": ("dfl", "draw", "extract", "fix", "manual", "merge", "missing-alignments", "missing-frames", "leftover-faces", "multi-faces", "no-faces", "remove-faces", "remove-frames", "rename", "sort", "spatial", "update-hashes"), @@ -49,6 +49,11 @@ def get_argument_list(self): "alignment data. This is a lot quicker than re-detecting faces. Can pass in " "the '-een' (--extract-every-n) parameter to only extract every nth frame." + frames_and_faces_dir + align_eyes + + # TODO - Remove the fix job after a period of time. Implemented 2019/12/07 + "\nL|'fix': There was a bug when extracting from video which would shift all " + "the faces out by 1 frame. This was a shortlived bug, but this job will fix " + "alignments files that have this issue. NB: Only run this on alignments files " + "that you know need fixing." "\nL|'manual': Manually view and edit landmarks." + frames_dir + "\nL|'merge': Merge multiple alignment files into one. Specify a space " "separated list of alignments files with the -a flag. Optionally specify a " diff --git a/tools/lib_alignments/__init__.py b/tools/lib_alignments/__init__.py index 9945b7be83..9901b891d3 100644 --- a/tools/lib_alignments/__init__.py +++ b/tools/lib_alignments/__init__.py @@ -1,4 +1,4 @@ from tools.lib_alignments.media import AlignmentData, ExtractedFaces, Faces, Frames from tools.lib_alignments.annotate import Annotate -from tools.lib_alignments.jobs import Check, Dfl, Draw, Extract, Merge, RemoveAlignments, Rename, Sort, Spatial, UpdateHashes +from tools.lib_alignments.jobs import Check, Dfl, Draw, Extract, Fix, Merge, RemoveAlignments, Rename, Sort, Spatial, UpdateHashes from tools.lib_alignments.jobs_manual import Manual diff --git a/tools/lib_alignments/jobs.py b/tools/lib_alignments/jobs.py index 0da68cb785..b7e6a16334 100644 --- a/tools/lib_alignments/jobs.py +++ b/tools/lib_alignments/jobs.py @@ -5,6 +5,7 @@ import os import pickle import struct +import sys from datetime import datetime from PIL import Image @@ -496,6 +497,55 @@ def select_valid_faces(self, frame): return valid_faces +class Fix(): + """ Fix alignments that were impacted by the 'out by one' bug when extracting from video + + TODO This is a temporary job that should be deleted after a period of time. + Implemented 2019/12/07 + """ + def __init__(self, alignments, arguments): + logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) + self.alignments = alignments + logger.debug("Initialized %s", self.__class__.__name__) + + def process(self): + """ Run the fix process """ + if not self._check_file_needs_fixing(): + sys.exit(0) + logger.info("[FIXING FRAMES]") + self._fix() + self.alignments.save() + + def _check_file_needs_fixing(self): + """ Check that these alignments are in video format and that the first frame in the " + "alignments file does not already start with 1 """ + retval = True + min_frame = min(key for key in self.alignments.data.keys()) + logger.debug("First frame: '%s'", min_frame) + fname = os.path.splitext(min_frame)[0] + frame_id = fname.split("_")[-1] + if ("_") not in fname or not frame_id.isdigit(): + logger.info("Alignments file not generated from a video. Nothing to do.") + retval = False + elif int(frame_id) == 1: + logger.info("Alignments file does not require fixing. First frame: '%s'", fname) + retval = False + logger.debug(retval) + return retval + + def _fix(self): + """ Renumber frame names, reducing each one by 1 """ + frame_names = sorted(key for key in self.alignments.data.keys()) + for old_name in tqdm(frame_names, desc="Fixing Alignments file"): + fname, ext = os.path.splitext(old_name) + vid_name, new_frame_id = ("_".join(fname.split("_")[:-1]), + int(fname.split("_")[-1]) - 1) + new_name = "{}_{:06d}{}".format(vid_name, new_frame_id, ext) + logger.debug("Re-assigning: '%s' > '%s'", old_name, new_name) + self.alignments.data[new_name] = self.alignments.data[old_name] + del self.alignments.data[old_name] + + class Merge(): """ Merge two alignments files into one """ def __init__(self, alignments, arguments): From 8f21bb073d786bcdc47b3d78d2f4b54d496e3cca Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 7 Dec 2019 19:25:17 +0000 Subject: [PATCH 175/981] bugfix: lib.image - Get video filelist correctly --- lib/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/image.py b/lib/image.py index 6445c0cafb..3004503e70 100644 --- a/lib/image.py +++ b/lib/image.py @@ -554,7 +554,7 @@ def _get_count_and_filelist(self, fast_count): """ if self._is_video: self._count = int(count_frames(self.location, fast=fast_count)) - self._file_list = [self._dummy_video_framename(i + 1) for i in range(self.count)] + self._file_list = [self._dummy_video_framename(i) for i in range(self.count)] else: if isinstance(self.location, (list, tuple)): self._file_list = self.location From ac1cf8fbe9c1e09f014bbceadb9c9d52814f0e94 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 8 Dec 2019 10:54:26 +0000 Subject: [PATCH 176/981] lib.alignments: improve re-extraction speed by 500% --- lib/image.py | 2 +- tools/lib_alignments/jobs.py | 168 ++++++++++++++++++++++------------ tools/lib_alignments/media.py | 35 +++++-- 3 files changed, 137 insertions(+), 68 deletions(-) diff --git a/lib/image.py b/lib/image.py index 3004503e70..7948c69cfb 100644 --- a/lib/image.py +++ b/lib/image.py @@ -321,7 +321,7 @@ def count_frames(filename, fast=False): logger.debug("frame line: %s", output) if not init_tqdm: logger.debug("Initializing tqdm") - pbar = tqdm(desc="Counting Video Frames", total=duration, unit="secs") + pbar = tqdm(desc="Counting Video Frames", leave=False, total=duration, unit="secs") init_tqdm = True time_idx = output.find("time=") + len("time=") frame_idx = output.find("frame=") + len("frame=") diff --git a/tools/lib_alignments/jobs.py b/tools/lib_alignments/jobs.py index b7e6a16334..e26391ca41 100644 --- a/tools/lib_alignments/jobs.py +++ b/tools/lib_alignments/jobs.py @@ -401,97 +401,143 @@ def annotate_image(self, frame): self.frames.save_image(self.output_folder, frame, image) -class Extract(): - """ Re-extract faces from source frames based on - Alignment data """ +class Extract(): # pylint:disable=too-few-public-methods + """ Re-extract faces from source frames based on Alignment data + + Parameters + ---------- + alignments: :class:`tools.lib_alignments.media.AlignmentData` + The alignments data loaded from an alignments file for this rename job + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + """ def __init__(self, alignments, arguments): logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - self.arguments = arguments - self.alignments = alignments - self.faces_dir = arguments.faces_dir - self.frames = Frames(arguments.frames_dir) - self.extracted_faces = ExtractedFaces(self.frames, - self.alignments, - size=arguments.size, - align_eyes=arguments.align_eyes) + self._arguments = arguments + self._alignments = alignments + self._faces_dir = arguments.faces_dir + self._frames = Frames(arguments.frames_dir) + self._extracted_faces = ExtractedFaces(self._frames, + self._alignments, + size=arguments.size, + align_eyes=arguments.align_eyes) logger.debug("Initialized %s", self.__class__.__name__) def process(self): - """ Run extraction """ + """ Run the re-extraction from Alignments file process""" logger.info("[EXTRACT FACES]") # Tidy up cli output - self.check_folder() - self.export_faces() + self._check_folder() + self._export_faces() - def check_folder(self): - """ Check that the faces folder doesn't pre-exist - and create """ + def _check_folder(self): + """ Check that the faces folder doesn't pre-exist and create. """ err = None - if not self.faces_dir: + if not self._faces_dir: err = "ERROR: Output faces folder not provided." - elif not os.path.isdir(self.faces_dir): - logger.debug("Creating folder: '%s'", self.faces_dir) - os.makedirs(self.faces_dir) - elif os.listdir(self.faces_dir): - err = "ERROR: Output faces folder should be empty: '{}'".format(self.faces_dir) + elif not os.path.isdir(self._faces_dir): + logger.debug("Creating folder: '%s'", self._faces_dir) + os.makedirs(self._faces_dir) + elif os.listdir(self._faces_dir): + err = "ERROR: Output faces folder should be empty: '{}'".format(self._faces_dir) if err: logger.error(err) exit(0) - logger.verbose("Creating output folder at '%s'", self.faces_dir) + logger.verbose("Creating output folder at '%s'", self._faces_dir) - def export_faces(self): - """ Export the faces """ + def _export_faces(self): + """ Export the faces to the output folder and update the alignments file with + new hashes. """ extracted_faces = 0 - skip_num = self.arguments.extract_every_n - if skip_num != 1: - logger.info("Skipping every %s frames", skip_num) - for idx, frame in enumerate(tqdm(self.frames.file_list_sorted, - desc="Saving extracted faces")): - frame_name = frame["frame_fullname"] - if idx % skip_num != 0: - logger.trace("Skipping '%s' due to extract_every_n = %s", frame_name, skip_num) + skip_list = self._set_skip_list() + count = self._frames.count if skip_list is None else self._frames.count - len(skip_list) + for filename, image in tqdm(self._frames.stream(skip_list=skip_list), + total=count, desc="Saving extracted faces"): + if not self._alignments.frame_exists(filename): + logger.verbose("Skipping '%s' - Alignments not found", filename) continue + extracted_faces += self._output_faces(filename, image) + if extracted_faces != 0 and not self._arguments.large: + self._alignments.save() + logger.info("%s face(s) extracted", extracted_faces) - if not self.alignments.frame_exists(frame_name): - logger.verbose("Skipping '%s' - Alignments not found", frame_name) - continue + def _set_skip_list(self): + """ Set the indices for frames that should be skipped based on the `extract_every_n` + command line option. + + Returns + ------- + list or ``None`` + A list of indices to be skipped if extract_every_n is not `1` otherwise + returns ``None`` + """ + skip_num = self._arguments.extract_every_n + if skip_num == 1: + logger.debug("Not skipping any frames") + return None + skip_list = [] + for idx, item in enumerate(self._frames.file_list_sorted): + if idx % skip_num != 0: + logger.trace("Adding image '%s' to skip list due to extract_every_n = %s", + item["frame_fullname"], skip_num) + skip_list.append(idx) + logger.debug("Adding skip list: %s", skip_list) + return skip_list - extracted_faces += self.output_faces(frame) + def _output_faces(self, filename, image): + """ For each frame save out the faces and update the face hash back to alignments - if extracted_faces != 0 and not self.arguments.large: - self.alignments.save() - logger.info("%s face(s) extracted", extracted_faces) + Parameters + ---------- + filename: str + The filename (without the full path) of the current frame + image: :class:`numpy.ndarray` + The full frame that faces are to be extracted from - def output_faces(self, frame): - """ Output the frame's faces to file """ - logger.trace("Outputting frame: %s", frame) + Returns + ------- + int + The total number of faces that have been extracted + """ + logger.trace("Outputting frame: %s", filename) face_count = 0 - frame_fullname = frame["frame_fullname"] - frame_name = frame["frame_name"] - extension = os.path.splitext(frame_fullname)[1] - faces = self.select_valid_faces(frame_fullname) + frame_name, extension = os.path.splitext(filename) + faces = self._select_valid_faces(filename, image) for idx, face in enumerate(faces): output = "{}_{}{}".format(frame_name, str(idx), extension) - if self.arguments.large: - self.frames.save_image(self.faces_dir, output, face.aligned_face) + if self._arguments.large: + self._frames.save_image(self._faces_dir, output, face.aligned_face) else: - output = os.path.join(self.faces_dir, output) - f_hash = self.extracted_faces.save_face_with_hash(output, - extension, - face.aligned_face) - self.alignments.data[frame_fullname][idx]["hash"] = f_hash + output = os.path.join(self._faces_dir, output) + f_hash = self._extracted_faces.save_face_with_hash(output, + extension, + face.aligned_face) + self._alignments.data[filename][idx]["hash"] = f_hash face_count += 1 return face_count - def select_valid_faces(self, frame): - """ Return valid faces for extraction """ - faces = self.extracted_faces.get_faces_in_frame(frame) - if not self.arguments.large: + def _select_valid_faces(self, frame, image): + """ Return the aligned faces from a frame that meet the selection criteria, + + Parameters + ---------- + frame: str + The filename (without the full path) of the current frame + image: :class:`numpy.ndarray` + The full frame that faces are to be extracted from + + Returns + ------- + list: + List of valid :class:`lib,faces_detect.DetectedFace` objects + """ + faces = self._extracted_faces.get_faces_in_frame(frame, image=image) + if not self._arguments.large: valid_faces = faces else: - sizes = self.extracted_faces.get_roi_size_for_frame(frame) + sizes = self._extracted_faces.get_roi_size_for_frame(frame) valid_faces = [faces[idx] for idx, size in enumerate(sizes) - if size >= self.extracted_faces.size] + if size >= self._extracted_faces.size] logger.trace("frame: '%s', total_faces: %s, valid_faces: %s", frame, len(faces), len(valid_faces)) return valid_faces diff --git a/tools/lib_alignments/media.py b/tools/lib_alignments/media.py index cc59ace6e7..4eca7e7161 100644 --- a/tools/lib_alignments/media.py +++ b/tools/lib_alignments/media.py @@ -14,7 +14,7 @@ from lib.aligner import Extract as AlignerExtract from lib.alignments import Alignments, get_serializer from lib.faces_detect import DetectedFace -from lib.image import (count_frames, encode_image_with_hash, read_image, +from lib.image import (count_frames, encode_image_with_hash, ImagesLoader, read_image, read_image_hash_batch) from lib.utils import _image_extensions, _video_extensions @@ -158,6 +158,29 @@ def load_video_frame(self, filename): # image = self.vid_reader.get_next_data()[:, :, ::-1] return image + def stream(self, skip_list=None): + """ Load the images in :attr:`folder` in the order they are received from + :class:`lib.image.ImagesLoader` in a background thread. + + Parameters + ---------- + skip_list: list, optional + A list of frame indices that should not be loaded. Pass ``None`` if all images should + be loaded. Default: ``None`` + + Yields + ------ + str + The filename of the image that is being returned + numpy.ndarray + The image that has been loaded from disk + """ + loader = ImagesLoader(self.folder, queue_size=32) + if skip_list is not None: + loader.add_skip_list(skip_list) + for filename, image in loader.load(): + yield filename, image + @staticmethod def save_image(output_folder, filename, image): """ Save an image """ @@ -275,7 +298,7 @@ def __init__(self, frames, alignments, size=256, align_eyes=False): self.faces = list() logger.trace("Initialized %s", self.__class__.__name__) - def get_faces(self, frame): + def get_faces(self, frame, image=None): """ Return faces and transformed landmarks for each face in a given frame with it's alignments""" logger.trace("Getting faces for frame: '%s'", frame) @@ -285,8 +308,8 @@ def get_faces(self, frame): if not alignments: self.faces = list() return - image = self.frames.load_image(frame) - self.faces = [self.extract_one_face(alignment, image.copy()) for alignment in alignments] + image = self.frames.load_image(frame) if image is None else image + self.faces = [self.extract_one_face(alignment, image) for alignment in alignments] self.current_frame = frame def extract_one_face(self, alignment, image): @@ -299,11 +322,11 @@ def extract_one_face(self, alignment, image): face = self.align_eyes(face, image) if self.align_eyes_bool else face return face - def get_faces_in_frame(self, frame, update=False): + def get_faces_in_frame(self, frame, update=False, image=None): """ Return the faces for the selected frame """ logger.trace("frame: '%s', update: %s", frame, update) if self.current_frame != frame or update: - self.get_faces(frame) + self.get_faces(frame, image=image) return self.faces def get_roi_size_for_frame(self, frame): From 5a9565ca82553963ca20488ac153d1a825b99f01 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 8 Dec 2019 16:52:35 +0000 Subject: [PATCH 177/981] bugfix: lib.tools.alignments - Extract faces from frames as well as video --- tools/lib_alignments/jobs.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/lib_alignments/jobs.py b/tools/lib_alignments/jobs.py index e26391ca41..7afa352e24 100644 --- a/tools/lib_alignments/jobs.py +++ b/tools/lib_alignments/jobs.py @@ -452,10 +452,11 @@ def _export_faces(self): count = self._frames.count if skip_list is None else self._frames.count - len(skip_list) for filename, image in tqdm(self._frames.stream(skip_list=skip_list), total=count, desc="Saving extracted faces"): - if not self._alignments.frame_exists(filename): - logger.verbose("Skipping '%s' - Alignments not found", filename) + frame_name = os.path.basename(filename) + if not self._alignments.frame_exists(frame_name): + logger.verbose("Skipping '%s' - Alignments not found", frame_name) continue - extracted_faces += self._output_faces(filename, image) + extracted_faces += self._output_faces(frame_name, image) if extracted_faces != 0 and not self._arguments.large: self._alignments.save() logger.info("%s face(s) extracted", extracted_faces) From 790b04a3147c4408ef9f497f7f83b3700bf0f530 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 8 Dec 2019 18:06:16 +0000 Subject: [PATCH 178/981] lib.alignments - Auto update legacy list landmarks to numpy array --- lib/alignments.py | 33 +++++++++++++++++++++++++++-- tools/lib_alignments/jobs_manual.py | 33 +++++++++++++---------------- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/lib/alignments.py b/lib/alignments.py index 90a77e690d..3f985b9e30 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -6,6 +6,8 @@ import os from datetime import datetime +import numpy as np + from lib.serializer import get_serializer, get_serializer_from_filename from lib.utils import FaceswapError @@ -248,9 +250,17 @@ def yield_original_index_reverse(image_alignments, number_alignments): def update_legacy(self): """ Update legacy alignments """ + updated = False if self.has_legacy_landmarksxy(): - logger.info("Updating legacy alignments") + logger.info("Updating legacy landmarksXY to landmarks_xy") self.update_legacy_landmarksxy() + updated = True + if self.has_legacy_landmarks_list(): + logger.info("Updating legacy landmarks from list to numpy array") + self.update_legacy_landmarks_list() + updated = True + if updated: + self.save() # # # Serializer is now a compressed pickle .fsa format. This used to be any number of serializers @@ -304,4 +314,23 @@ def update_legacy_landmarksxy(self): alignment["landmarks_xy"] = alignment.pop("landmarksXY") update_count += 1 logger.debug("Updated landmarks_xy: %s", update_count) - self.save() + + # Landmarks stored as list instead of numpy array + def has_legacy_landmarks_list(self): + """ check for legacy landmarks stored as list """ + logger.debug("checking legacy landmarks as list") + retval = not all(isinstance(face["landmarks_xy"], np.ndarray) + for faces in self.data.values() + for face in faces) + return retval + + def update_legacy_landmarks_list(self): + """ Update landmarksXY to landmarks_xy and save alignments """ + update_count = 0 + for alignments in self.data.values(): + for alignment in alignments: + test = alignment["landmarks_xy"] + if not isinstance(test, np.ndarray): + alignment["landmarks_xy"] = np.array(test, dtype="float32") + update_count += 1 + logger.debug("Updated landmarks_xy: %s", update_count) diff --git a/tools/lib_alignments/jobs_manual.py b/tools/lib_alignments/jobs_manual.py index ead991e20d..7617289907 100644 --- a/tools/lib_alignments/jobs_manual.py +++ b/tools/lib_alignments/jobs_manual.py @@ -479,18 +479,21 @@ def display_frames(self): frame, faces = self.get_frame() press = self.get_keys() + self.interface.set_redraw(True) while True: - self.help.render() - cv2.imshow("Frame", frame) - cv2.imshow("Faces", faces) - key = cv2.waitKey(1) + if self.interface.redraw(): + self.help.render() + cv2.imshow("Frame", frame) + cv2.imshow("Faces", faces) + self.interface.set_redraw(False) + key = cv2.waitKey(1000) if self.window_closed(is_windows, is_conda, key): queue_manager.terminate_queues() break - if key: + if key and key != -1: logger.trace("Keypress received: '%s'", key) if key in press.keys(): action = press[key]["action"] @@ -509,7 +512,6 @@ def display_frames(self): logger.trace("Redraw requested") frame, faces = self.get_frame() - self.interface.set_redraw(False) cv2.destroyAllWindows() @@ -584,23 +586,18 @@ def frame_selector(self): while True: if navigation["last_request"] == 0: break - elif navigation["frame_idx"] in (0, navigation["max_frame"]): + if navigation["frame_idx"] in (0, navigation["max_frame"]): break - elif skip_mode == "standard": + if skip_mode == "standard": break - elif (skip_mode == "no faces" - and not self.alignments.frame_has_faces(frame)): + if skip_mode == "no faces" and not self.alignments.frame_has_faces(frame): break - elif (skip_mode == "multi-faces" - and self.alignments.frame_has_multiple_faces(frame)): + if skip_mode == "multi-faces" and self.alignments.frame_has_multiple_faces(frame): break - elif (skip_mode == "has faces" - and self.alignments.frame_has_faces(frame)): + if skip_mode == "has faces" and self.alignments.frame_has_faces(frame): break - else: - self.interface.iterate_frame("navigation", - navigation["last_request"]) - frame = frame_list[navigation["frame_idx"]]["frame_fullname"] + self.interface.iterate_frame("navigation", navigation["last_request"]) + frame = frame_list[navigation["frame_idx"]]["frame_fullname"] image = self.frames.load_image(frame) navigation["last_request"] = 0 From 4e654eb328a31062447f07237be9943dd13cf3a2 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 9 Dec 2019 12:50:27 +0000 Subject: [PATCH 179/981] plugins.train.trainer - Speed up mask caching --- lib/image.py | 4 +- plugins/train/trainer/_base.py | 172 ++++++++++++++++++--------------- 2 files changed, 98 insertions(+), 78 deletions(-) diff --git a/lib/image.py b/lib/image.py index 51c87cef7b..e090559f3a 100644 --- a/lib/image.py +++ b/lib/image.py @@ -177,8 +177,6 @@ def read_image_hash_batch(filenames): ---------- filenames: list A list of ``str`` full paths to the images to be loaded. - show_progress: bool, optional - Display a progress bar. Default: False Yields ------- @@ -192,8 +190,10 @@ def read_image_hash_batch(filenames): logger.trace("Requested batch: '%s'", filenames) executor = futures.ThreadPoolExecutor() with executor: + logger.debug("Submitting %s items to executor", len(filenames)) read_hashes = {executor.submit(read_image_hash, filename): filename for filename in filenames} + logger.debug("Succesfully submitted %s items to executor", len(filenames)) for future in futures.as_completed(read_hashes): retval = (read_hashes[future], future.result()) logger.trace("Yielding: %s", retval) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 98ebd95e33..981e9fbc54 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -1051,11 +1051,13 @@ def __init__(self, training_opts, image_list): self.__class__.__name__, training_opts, {k: len(v) for k, v in image_list.items()}) self._training_opts = training_opts + self._check_alignments_exist() self._hashes = self._get_image_hashes(image_list) self._detected_faces = self._load_alignments() self._check_all_faces() logger.debug("Initialized %s", self.__class__.__name__) + # Get landmarks @property def landmarks(self): """ dict: The :class:`numpy.ndarray` aligned landmarks for keys "a" and "b" """ @@ -1064,6 +1066,29 @@ def landmarks(self): logger.trace(retval) return retval + def _transform_landmarks(self, side, detected_faces): + """ Transform frame landmarks to their aligned face variant. + + Parameters + ---------- + side: {"a" or "b"} + The side currently being processed + detected_faces: list + A list of :class:`lib.faces_detect.DetectedFace` objects + + Returns + ------- + dict + The face filenames as keys with the aligned landmarks as value. + """ + landmarks = dict() + for face in detected_faces: + face.load_aligned(None, size=self._training_opts["training_size"]) + for filename in self._hash_to_filenames(side, face.hash): + landmarks[filename] = face.aligned_landmarks + return landmarks + + # Get masks @property def masks(self): """ dict: The :class:`lib.faces_detect.Mask` objects of requested mask type for @@ -1074,7 +1099,46 @@ def masks(self): logger.trace(retval) return retval - # Load alignments + def _get_masks(self, side, detected_faces): + """ For each face, obtain the mask and set the requested blurring and threshold level. + + Parameters + ---------- + side: {"a" or "b"} + The side currently being processed + detected_faces: dict + Key is the hash of the face, value is the corresponding + :class:`lib.faces_detect.DetectedFace` object + + Returns + ------- + dict + The face filenames as keys with the :class:`lib.faces_detect.Mask` as value. + """ + + masks = dict() + for fhash, face in detected_faces.items(): + mask = face.mask[self._training_opts["mask_type"]] + mask.set_blur_kernel_and_threshold(blur_kernel=self._training_opts["mask_blur_kernel"], + threshold=self._training_opts["mask_threshold"]) + for filename in self._hash_to_filenames(side, fhash): + masks[filename] = mask + return masks + + # Pre flight checks + def _check_alignments_exist(self): + """ Ensure the alignments files exist prior to running any longer running tasks. + + Raises + ------ + FaceswapError + If at least one alignments file does not exist + """ + for fullpath in self._training_opts["alignments"].values(): + if not os.path.exists(fullpath): + raise FaceswapError("Alignments file does not exist: `{}`".format(fullpath)) + + # Hashes for image folders @staticmethod def _get_image_hashes(image_list): """ Return the hashes for all images used for training. @@ -1089,31 +1153,30 @@ def _get_image_hashes(image_list): Returns ------- dict - For keys "a" and "b" the values are a ``dict`` containing keys "hashes" and "filenames" - with their values being a list of hashes and filenames that exist within the training - data folder + For keys "a" and "b" the values are a ``dict`` with the key being the sha1 hash and + the value being a list of filenames that correspond to the hash for images that exist + within the training data folder """ - hashes = {key: dict(hashes=[], filenames=[]) for key in image_list} - pbar = tqdm(desc="Reading training images", - total=sum(len(val) for val in image_list.values())) + hashes = {key: dict() for key in image_list} for side, filelist in image_list.items(): logger.debug("side: %s, file count: %s", side, len(filelist)) - for filename, hsh in read_image_hash_batch(filelist): - hashes[side]["hashes"].append(hsh) - hashes[side]["filenames"].append(filename) - pbar.update(1) - pbar.close() + for filename, hsh in tqdm(read_image_hash_batch(filelist), + desc="Reading training images ({})".format(side.upper()), + total=len(filelist), + leave=False): + hashes[side].setdefault(hsh, list()).append(filename) logger.trace(hashes) return hashes + # Hashes for Detected Faces def _load_alignments(self): """ Load the alignments and convert to :class:`lib.faces_detect.DetectedFace` objects. Returns ------- dict - For keys "a" and "b" values are a list of :class:`lib.faces_detect.DetectedFace` - objects. + For keys "a" and "b" values are a dict with the key being the sha1 hash of the face + and the value being the corresponding :class:`lib.faces_detect.DetectedFace` object. """ logger.debug("Loading alignments") retval = dict() @@ -1139,22 +1202,27 @@ def _to_detected_faces(self, alignments, side): Returns ------- - list - List of :class:`lib.faces_detect.DetectedFace` objects + dict + key is sha1 hash of face, value is the corresponding + :class:`lib.faces_detect.DetectedFace` object """ skip_count = 0 - side_hashes = set(self._hashes[side]["hashes"]) - detected_faces = [] + dupe_count = 0 + side_hashes = set(self._hashes[side]) + detected_faces = dict() for _, faces, _, filename in alignments.yield_faces(): for idx, face in enumerate(faces): + if face["hash"] in detected_faces: + dupe_count += 1 + logger.debug("Face already exists, skipping: '%s'", filename) if not self._validate_face(face, filename, idx, side, side_hashes): skip_count += 1 continue detected_face = DetectedFace() detected_face.from_alignment(face) - detected_faces.append(detected_face) - logger.debug("Detected Faces count: %s, Skipped faces count: %s", - len(detected_faces), skip_count) + detected_faces[face["hash"]] = detected_face + logger.debug("Detected Faces count: %s, Skipped faces count: %s, duplicate faces " + "count: %s", len(detected_faces), skip_count, dupe_count) if skip_count != 0: logger.warning("%s alignments have been removed as their corresponding faces do not " "exist in the input folder for side %s. Run in verbose mode if you " @@ -1162,6 +1230,7 @@ def _to_detected_faces(self, alignments, side): skip_count, side.upper()) return detected_faces + # Validation def _validate_face(self, face, filename, idx, side, side_hashes): """ Validate that the currently processing face has a corresponding hash entry and the requested mask exists @@ -1227,11 +1296,12 @@ def _check_all_faces(self): logger.debug("Checking faces exist in alignments") missing_alignments = dict() for side, train_hashes in self._hashes.items(): - align_hashes = set(face.hash for face in self._detected_faces[side]) - if not align_hashes.issuperset(train_hashes["hashes"]): + align_hashes = set(self._detected_faces[side]) + if not align_hashes.issuperset(set(train_hashes)): missing_alignments[side] = [ os.path.basename(filename) - for hsh, filename in zip(train_hashes["hashes"], train_hashes["filenames"]) + for hsh, filenames in train_hashes.items() + for filename in filenames if hsh not in align_hashes] if missing_alignments: msg = ("There are faces in your training folder(s) which do not exist in your " @@ -1242,55 +1312,7 @@ def _check_all_faces(self): side.capitalize(), filelist) raise FaceswapError(msg) - # Get landmarks - def _transform_landmarks(self, side, detected_faces): - """ Transform frame landmarks to their aligned face variant. - - Parameters - ---------- - side: {"a" or "b"} - The side currently being processed - detected_faces: list - A list of :class:`lib.faces_detect.DetectedFace` objects - - Returns - ------- - dict - The face filenames as keys with the aligned landmarks as value. - """ - landmarks = dict() - for face in detected_faces: - face.load_aligned(None, size=self._training_opts["training_size"]) - for filename in self._hash_to_filenames(side, face.hash): - landmarks[filename] = face.aligned_landmarks - return landmarks - - # Get masks - def _get_masks(self, side, detected_faces): - """ For each face, obtain the mask and set the requested blurring and threshold level. - - Parameters - ---------- - side: {"a" or "b"} - The side currently being processed - detected_faces: list - A list of :class:`lib.faces_detect.DetectedFace` objects - - Returns - ------- - dict - The face filenames as keys with the :class:`lib.faces_detect.Mask` as value. - """ - - masks = dict() - for face in detected_faces: - mask = face.mask[self._training_opts["mask_type"]] - mask.set_blur_kernel_and_threshold(blur_kernel=self._training_opts["mask_blur_kernel"], - threshold=self._training_opts["mask_threshold"]) - for filename in self._hash_to_filenames(side, face.hash): - masks[filename] = mask - return masks - + # Utils def _hash_to_filenames(self, side, face_hash): """ For a given hash return all the filenames that match for the given side. @@ -1311,9 +1333,7 @@ def _hash_to_filenames(self, side, face_hash): list The filenames that exist for the given hash """ - side_hashes = self._hashes[side] - hash_indices = [idx for idx, hsh in enumerate(side_hashes["hashes"]) if hsh == face_hash] - retval = [side_hashes["filenames"][idx] for idx in hash_indices] + retval = self._hashes[side][face_hash] logger.trace("side: %s, hash: %s, filenames: %s", side, face_hash, retval) return retval From 6afa6a9eb552973506e98f3b87ae84bf4cd62d11 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 9 Dec 2019 17:54:35 +0000 Subject: [PATCH 180/981] bugfix: plugins.train.trainer._base - Fix landmarks loading --- plugins/train/trainer/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 981e9fbc54..04ae7be21d 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -1082,7 +1082,7 @@ def _transform_landmarks(self, side, detected_faces): The face filenames as keys with the aligned landmarks as value. """ landmarks = dict() - for face in detected_faces: + for face in detected_faces.values(): face.load_aligned(None, size=self._training_opts["training_size"]) for filename in self._hash_to_filenames(side, face.hash): landmarks[filename] = face.aligned_landmarks From ef03be17064a7653fef41f62c7cd789596dfec8a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 10 Dec 2019 02:01:20 +0000 Subject: [PATCH 181/981] Update Dependencies (#950) * 1st Round update for Python 3.7, TF1.15, Keras2.3 Move Tensorflow logging verbosity prior to first tensorflow import Keras Optimizers and nn_block update lib.logger - Change tf deprecation messages from WARNING to DEBUG Raise Tensorflow Max version check to 1.15 Update requirements and conda check for python 3.7+ Update install scripts, travis and documentation to Python 3.7 * Revert Keras to 2.2.4 --- .install/linux/faceswap_setup_x64.sh | 4 ++-- .install/windows/install.nsi | 2 +- .travis.yml | 2 +- INSTALL.md | 4 ++-- lib/cli.py | 5 +++-- lib/logger.py | 10 ++++++++++ lib/sysinfo.py | 3 ++- lib/vgg_face2_keras.py | 3 +-- requirements.txt | 12 ++++++------ scripts/convert.py | 1 - scripts/extract.py | 1 - scripts/fsmedia.py | 7 +------ scripts/gui.py | 2 -- scripts/train.py | 3 +-- setup.py | 11 ++++++----- tools/alignments.py | 3 --- tools/mask.py | 3 +-- tools/preview.py | 3 +-- 18 files changed, 38 insertions(+), 41 deletions(-) diff --git a/.install/linux/faceswap_setup_x64.sh b/.install/linux/faceswap_setup_x64.sh index 84ebca4ada..cf9894a872 100644 --- a/.install/linux/faceswap_setup_x64.sh +++ b/.install/linux/faceswap_setup_x64.sh @@ -336,10 +336,10 @@ delete_env() { } create_env() { - # Create Python 3.6 env for faceswap + # Create Python 3.7 env for faceswap delete_env info "Creating Conda Virtual Environment..." - yellow ; "$CONDA_EXECUTABLE" create -n "$ENV_NAME" -q python=3.6 -y + yellow ; "$CONDA_EXECUTABLE" create -n "$ENV_NAME" -q python=3.7 -y } diff --git a/.install/windows/install.nsi b/.install/windows/install.nsi index f7ccabfed9..9d4b54c313 100644 --- a/.install/windows/install.nsi +++ b/.install/windows/install.nsi @@ -22,7 +22,7 @@ InstallDir $PROFILE\faceswap # Install cli flags !define flagsConda "/S /RegisterPython=0 /AddToPath=0 /D=$PROFILE\MiniConda3" !define flagsRepo "--depth 1 --no-single-branch ${wwwRepo}" -!define flagsEnv "-y python=3.6" +!define flagsEnv "-y python=3.7" # Folders Var ProgramData diff --git a/.travis.yml b/.travis.yml index dffbe98134..026d8b71b9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,7 @@ language: shell env: global: - - CONDA_PYTHON=3.6 + - CONDA_PYTHON=3.7 - CONDA_BLD_PATH=${HOME}/conda-bld os: diff --git a/INSTALL.md b/INSTALL.md index aeaf0c189d..1f6e16e180 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -93,8 +93,8 @@ Reboot your PC, so that everything you have just installed gets registered. - Select "Create" at the bottom - In the pop up: - Give it the name: faceswap - - **IMPORTANT**: Select python version 3.6 - - Hit "Create" (NB: This may take a while as it will need to download Python 3.6) + - **IMPORTANT**: Select python version 3.7 + - Hit "Create" (NB: This may take a while as it will need to download Python 3.7) ![Anaconda virtual env setup](https://i.imgur.com/59RHnLs.png) #### Entering your virtual environment diff --git a/lib/cli.py b/lib/cli.py index f8d312bd07..89310b1109 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -15,7 +15,7 @@ from lib.logger import crash_log, log_setup from lib.model.masks import get_available_masks, get_default_mask -from lib.utils import FaceswapError, get_backend, safe_shutdown +from lib.utils import FaceswapError, get_backend, safe_shutdown, set_system_verbosity from plugins.plugin_loader import PluginLoader logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -46,7 +46,7 @@ def import_script(self): def test_for_tf_version(): """ Check that the minimum required Tensorflow version is installed """ min_ver = 1.12 - max_ver = 1.14 + max_ver = 1.15 try: # Ensure tensorflow doesn't pin all threads to one core when using tf-mkl os.environ["KMP_AFFINITY"] = "disabled" @@ -113,6 +113,7 @@ def check_display(): def execute_script(self, arguments): """ Run the script for called command """ + set_system_verbosity(arguments.loglevel) is_gui = hasattr(arguments, "redirect_gui") and arguments.redirect_gui log_setup(arguments.loglevel, arguments.logfile, self.command, is_gui) logger.debug("Executing: %s. PID: %s", self.command, os.getpid()) diff --git a/lib/logger.py b/lib/logger.py index 80db322fcb..b567f3c2cb 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -42,6 +42,7 @@ class FaceswapFormatter(logging.Formatter): def format(self, record): record.message = record.getMessage() + record = self.rewrite_tf_deprecation(record) # strip newlines if "\n" in record.message or "\r" in record.message: record.message = record.message.replace("\n", "\\n").replace("\r", "\\r") @@ -64,6 +65,15 @@ def format(self, record): msg = msg + self.formatStack(record.stack_info) return msg + @staticmethod + def rewrite_tf_deprecation(record): + """ Change TF deprecation messages from WARNING to DEBUG """ + if record.levelno == 30 and (record.funcName == "_tfmw_add_deprecation_warning" or + record.module == "deprecation"): + record.levelno = 10 + record.levelname = "DEBUG" + return record + class RollingBuffer(collections.deque): """File-like that keeps a certain number of lines of text in memory.""" diff --git a/lib/sysinfo.py b/lib/sysinfo.py index 8b8af626d9..bf3cb3c847 100644 --- a/lib/sysinfo.py +++ b/lib/sysinfo.py @@ -44,7 +44,8 @@ def encoding(self): @property def is_conda(self): """ Boolean for whether in a conda environment """ - return "conda" in sys.version.lower() + return ("conda" in sys.version.lower() or + os.path.exists(os.path.join(sys.prefix, 'conda-meta'))) @property def is_linux(self): diff --git a/lib/vgg_face2_keras.py b/lib/vgg_face2_keras.py index 5dfbeaeac9..c80b1af3a0 100644 --- a/lib/vgg_face2_keras.py +++ b/lib/vgg_face2_keras.py @@ -9,7 +9,7 @@ import cv2 import numpy as np from fastcluster import linkage, linkage_vector -from lib.utils import GetModel, set_system_verbosity, FaceswapError +from lib.utils import GetModel, FaceswapError logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -41,7 +41,6 @@ class VGGFace2(): def __init__(self, backend="GPU", loglevel="INFO"): logger.debug("Initializing %s: (backend: %s, loglevel: %s)", self.__class__.__name__, backend, loglevel) - set_system_verbosity(loglevel) backend = backend.upper() git_model_id = 10 model_filename = ["vggface2_resnet50_v2.h5"] diff --git a/requirements.txt b/requirements.txt index 8399dd0153..ebf3c621dc 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1,15 @@ tqdm psutil pathlib -numpy==1.16.2 -opencv-python==4.1.1.26 +numpy==1.17.4 +opencv-python==4.1.2.30 scikit-image -Pillow==6.1.0 +Pillow==6.2.1 scikit-learn toposort fastcluster -matplotlib==2.2.2 -imageio==2.5.0 +matplotlib==3.1.1 +imageio==2.6.1 imageio-ffmpeg ffmpy==0.2.2 # Revert back to nvidia-ml-py3 when windows/system32 patch is implemented @@ -18,7 +18,7 @@ git+https://github.com/deepfakes/nvidia-ml-py3.git h5py==2.9.0 Keras==2.2.4 pywin32 ; sys_platform == "win32" -pynvx==0.0.4 ; sys_platform == "darwin" +pynvx==1.0.0 ; sys_platform == "darwin" # tensorflow is included within the docker image. # If you are looking for dependencies for a manual install, diff --git a/scripts/convert.py b/scripts/convert.py index 47cc6dbe59..3affd740d4 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -34,7 +34,6 @@ class Convert(): def __init__(self, arguments): logger.debug("Initializing %s: (args: %s)", self.__class__.__name__, arguments) self.args = arguments - Utils.set_verbosity(self.args.loglevel) self.patch_threads = None self.images = Images(self.args) diff --git a/scripts/extract.py b/scripts/extract.py index bb9c50030c..b09dcfe9cd 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -38,7 +38,6 @@ class Extract(): def __init__(self, arguments): logger.debug("Initializing %s: (args: %s", self.__class__.__name__, arguments) self._args = arguments - Utils.set_verbosity(self._args.loglevel) self._output_dir = str(get_folder(self._args.output_dir)) diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 0c5611b6bd..27da35ddb1 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -15,7 +15,7 @@ from lib.alignments import Alignments as AlignmentsBase from lib.face_filter import FaceFilter as FilterFunc from lib.image import count_frames, read_image -from lib.utils import (camel_case_split, get_image_paths, set_system_verbosity, _video_extensions) +from lib.utils import (camel_case_split, get_image_paths, _video_extensions) logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -24,11 +24,6 @@ class Utils(): """ Holds utility functions that are required by more than one media object """ - @staticmethod - def set_verbosity(loglevel): - """ Set the system output verbosity """ - set_system_verbosity(loglevel) - @staticmethod def finalize(images_found, num_faces_detected, verify_output): """ Finalize the image processing """ diff --git a/scripts/gui.py b/scripts/gui.py index 7bb28acc8e..1d39a2c38a 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -9,7 +9,6 @@ from lib.gui import (TaskBar, CliOptions, CommandNotebook, ConsoleOut, Session, DisplayNotebook, get_images, initialize_images, initialize_config, LastSession, MainMenuBar, ProcessWrapper, StatusBar) -from lib.utils import set_system_verbosity logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -208,7 +207,6 @@ def _confirm_close_on_running_task(self): class Gui(): # pylint: disable=too-few-public-methods """ The GUI process. """ def __init__(self, arguments): - set_system_verbosity(arguments.loglevel) self.root = FaceswapGui(arguments.debug) def process(self): diff --git a/scripts/train.py b/scripts/train.py index ad51d1c331..2679d3dc6a 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -15,7 +15,7 @@ from lib.image import read_image from lib.keypress import KBHit from lib.multithreading import MultiThread -from lib.utils import get_folder, get_image_paths, set_system_verbosity, deprecation_warning +from lib.utils import get_folder, get_image_paths, deprecation_warning from plugins.plugin_loader import PluginLoader logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -151,7 +151,6 @@ def process(self): deprecation_warning("`-nac`, ``--no-augment-color``", additional_info="This option will be available within training " "config settings (/config/train.ini).") - set_system_verbosity(self._args.loglevel) thread = self._start_thread() # from lib.queue_manager import queue_manager; queue_manager.debug_monitor(1) diff --git a/setup.py b/setup.py index 53bb3fd37e..ec6483eebe 100755 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ INSTALL_FAILED = False # Revisions of tensorflow-gpu and cuda/cudnn requirements TENSORFLOW_REQUIREMENTS = {"==1.12.0": ["9.0", "7.2"], - ">=1.13.1,<1.15": ["10.0", "7.4"]} # TF 2.0 Not currently supported + ">=1.13.1,<1.16": ["10.0", "7.4"]} # TF 2.0 Not currently supported # Mapping of Python packages to their conda names if different from pypi or in non-default channel CONDA_MAPPING = { # "opencv-python": ("opencv", "conda-forge"), # Periodic issues with conda-forge opencv @@ -74,7 +74,8 @@ def py_version(self): @property def is_conda(self): """ Check whether using Conda """ - return bool("conda" in sys.version.lower()) + return ("conda" in sys.version.lower() or + os.path.exists(os.path.join(sys.prefix, 'conda-meta'))) @property def ld_library_path(self): @@ -220,7 +221,7 @@ def update_tf_dep(self): return if not self.enable_cuda: - self.required_packages.append("tensorflow==1.14.0") + self.required_packages.append("tensorflow==1.15.0") return tf_ver = None @@ -266,9 +267,9 @@ def update_tf_dep(self): def update_tf_dep_conda(self): """ Update Conda TF Dependency """ if not self.enable_cuda: - self.required_packages.append("tensorflow==1.14.0") + self.required_packages.append("tensorflow==1.15.0") else: - self.required_packages.append("tensorflow-gpu==1.14.0") + self.required_packages.append("tensorflow-gpu==1.15.0") def update_amd_dep(self): """ Update amd dependency for AMD cards """ diff --git a/tools/alignments.py b/tools/alignments.py index b5cde21157..b7ef64007e 100644 --- a/tools/alignments.py +++ b/tools/alignments.py @@ -1,8 +1,6 @@ #!/usr/bin/env python3 """ Tools for manipulating the alignments seralized file """ import logging - -from lib.utils import set_system_verbosity from .lib_alignments import (AlignmentData, Check, Dfl, Draw, # noqa pylint: disable=unused-import Extract, Fix, Manual, Merge, Rename, RemoveAlignments, Sort, Spatial, UpdateHashes) @@ -15,7 +13,6 @@ class Alignments(): def __init__(self, arguments): logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) self.args = arguments - set_system_verbosity(self.args.loglevel) self.alignments = self.load_alignments() logger.debug("Initialized %s", self.__class__.__name__) diff --git a/tools/mask.py b/tools/mask.py index 61a657ad85..550cad1858 100644 --- a/tools/mask.py +++ b/tools/mask.py @@ -12,7 +12,7 @@ from lib.image import ImagesLoader, ImagesSaver from lib.multithreading import MultiThread -from lib.utils import set_system_verbosity, get_folder +from lib.utils import get_folder from plugins.extract.pipeline import Extractor, ExtractMedia @@ -33,7 +33,6 @@ class Mask(): """ def __init__(self, arguments): logger.debug("Initializing %s: (arguments: %s", self.__class__.__name__, arguments) - set_system_verbosity(arguments.loglevel) self._update_type = arguments.processing self._input_is_faces = arguments.input_type == "faces" self._mask_type = arguments.masker diff --git a/tools/preview.py b/tools/preview.py index 5c05eb9417..2c25ba75c2 100644 --- a/tools/preview.py +++ b/tools/preview.py @@ -24,7 +24,7 @@ from lib.faces_detect import DetectedFace from lib.model.masks import get_available_masks from lib.multithreading import MultiThread -from lib.utils import FaceswapError, set_system_verbosity +from lib.utils import FaceswapError from lib.queue_manager import queue_manager from scripts.fsmedia import Alignments, Images from scripts.convert import Predict @@ -42,7 +42,6 @@ class Preview(): def __init__(self, arguments): logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) - set_system_verbosity(arguments.loglevel) self.config_tools = ConfigTools() self.lock = Lock() self.trigger_patch = Event() From 0d1b146d767b703ceeda59efd85a89353187a51a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 10 Dec 2019 17:57:33 +0000 Subject: [PATCH 182/981] Generate default config files on launch Will also generate the config files just running `-h` --- faceswap.py | 4 +++- lib/config.py | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/faceswap.py b/faceswap.py index 13e4311b45..a426d6ae65 100755 --- a/faceswap.py +++ b/faceswap.py @@ -3,6 +3,7 @@ import sys import lib.cli as cli +from lib.config import generate_configs if sys.version_info[0] < 3: raise Exception("This program requires at least python3.6") @@ -13,10 +14,11 @@ def bad_args(args): """ Print help on bad arguments """ PARSER.print_help() - exit(0) + sys.exit(0) if __name__ == "__main__": + generate_configs() PARSER = cli.FullHelpArgumentParser() SUBPARSER = PARSER.add_subparsers() EXTRACT = cli.ExtractArgs(SUBPARSER, diff --git a/lib/config.py b/lib/config.py index c4e41f7f77..6f81c6f5e5 100644 --- a/lib/config.py +++ b/lib/config.py @@ -8,6 +8,7 @@ import sys from collections import OrderedDict from configparser import ConfigParser +from importlib import import_module logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -339,3 +340,22 @@ def handle_config(self): self.load_config() self.validate_config() logger.debug("Handled config") + + +def generate_configs(): + """ Generate config files if they don't exist. + + This script is run prior to anything being set up, so don't use logging + Generates the default config files for plugins in the faceswap config folder + """ + + base_path = os.path.realpath(os.path.dirname(sys.argv[0])) + plugins_path = os.path.join(base_path, "plugins") + configs_path = os.path.join(base_path, "config") + for dirpath, _, filenames in os.walk(plugins_path): + if "_config.py" in filenames: + section = os.path.split(dirpath)[-1] + config_file = os.path.join(configs_path, "{}.ini".format(section)) + if not os.path.exists(config_file): + mod = import_module("plugins.{}.{}".format(section, "_config")) + mod.Config(None) From 6efed854908ae00f0f476ac926fbce7b1f1a2210 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 12 Dec 2019 01:22:02 +0000 Subject: [PATCH 183/981] lib,cli: Add suppressed colab flag --- lib/cli.py | 6 ++++++ plugins/train/trainer/_base.py | 20 +++++++++++++------- scripts/train.py | 12 ++++++------ 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/lib/cli.py b/lib/cli.py index 89310b1109..d4d448a6a2 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -434,6 +434,12 @@ def get_global_arguments(): "dest": "redirect_gui", "default": False, "help": argparse.SUPPRESS}) + global_args.append({ + "opts": ("-colab", "--colab"), + "action": "store_true", + "dest": "colab", + "default": False, + "help": argparse.SUPPRESS}) return global_args @staticmethod diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 04ae7be21d..75b3239a7f 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -231,7 +231,7 @@ def _tensorboard_kwargs(self): logger.debug(kwargs) return kwargs - def __print_loss(self, loss): + def __print_loss(self, loss, is_colab): """ Outputs the loss for the current iteration to the console. Parameters @@ -240,16 +240,20 @@ def __print_loss(self, loss): The loss for each side. The dictionary should contain 2 keys ("a" and "b") with the values being a list of loss values for the current iteration corresponding to each side. + is_colab: bool + ``True`` if FaceSwap is executing in a Google Colab session, otherwise ``False`` """ logger.trace(loss) output = ["Loss {}: {:.5f}".format(side.capitalize(), loss[side][0]) for side in sorted(loss.keys())] output = ", ".join(output) - print("[{}] [#{:05d}] {}".format(self._timestamp, - self._model.iterations, - output), end='\r') + output = "[{}] [#{:05d}] {}".format(self._timestamp, self._model.iterations, output) + if not is_colab: + print(output, end='\r') + else: + print(output) - def train_one_step(self, viewer, timelapse_kwargs): + def train_one_step(self, viewer, timelapse_kwargs, is_colab): """ Running training on a batch of images for each side. Triggered from the training cycle in :class:`scripts.train.Train`. @@ -267,6 +271,8 @@ def train_one_step(self, viewer, timelapse_kwargs): The keyword arguments for generating time-lapse previews. If a time-lapse preview is not required then this should be ``None``. Otherwise all values should be full paths the keys being `input_a`, `input_b`, `output`. + is_colab: bool + ``True`` if FaceSwap is executing in a Google Colab session, otherwise ``False`` """ logger.trace("Training one step: (iteration: %s)", self._model.iterations) do_preview = viewer is not None @@ -297,11 +303,11 @@ def train_one_step(self, viewer, timelapse_kwargs): self._log_tensorboard(side, side_loss) if not self._pingpong.active: - self.__print_loss(loss) + self.__print_loss(loss, is_colab) else: for key, val in loss.items(): self._pingpong.loss[key] = val - self.__print_loss(self._pingpong.loss) + self.__print_loss(self._pingpong.loss, is_colab) if do_preview: samples = self._samples.show_sample() diff --git a/scripts/train.py b/scripts/train.py index 2679d3dc6a..0cd4f6c327 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -121,12 +121,12 @@ def _get_images(self): image_dir = getattr(self._args, "input_{}".format(side)) if not os.path.isdir(image_dir): logger.error("Error: '%s' does not exist", image_dir) - exit(1) + sys.exit(1) images[side] = get_image_paths(image_dir) if not images[side]: logger.error("Error: '%s' contains no images", image_dir) - exit(1) + sys.exit(1) logger.info("Model A Directory: %s", self._args.input_a) logger.info("Model B Directory: %s", self._args.input_b) @@ -219,7 +219,7 @@ def _training(self): trainer.clear_tensorboard() except KeyboardInterrupt: logger.info("Saving model weights has been cancelled!") - exit(0) + sys.exit(0) except Exception as err: raise err @@ -300,7 +300,7 @@ def _run_training_cycle(self, model, trainer): save_iteration = iteration % self._args.save_interval == 0 viewer = display_func if save_iteration or self._save_now else None timelapse = self._timelapse if save_iteration else None - trainer.train_one_step(viewer, timelapse) + trainer.train_one_step(viewer, timelapse, self._args.colab) if self._stop: logger.debug("Stop received. Terminating") break @@ -335,8 +335,8 @@ def _monitor(self, thread): if is_preview: logger.info(" Using live preview") logger.info(" Press '%s' to save and quit", - "Terminate" if self._args.redirect_gui else "ENTER") - if not self._args.redirect_gui: + "Stop" if self._args.redirect_gui or self._args.colab else "ENTER") + if not self._args.redirect_gui and not self._args.colab: logger.info(" Press 'S' to save model weights immediately") logger.info("===================================================") From c4b6ed0ba8fdefdb01e19db03e92a0574d55ce78 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 12 Dec 2019 01:39:30 +0000 Subject: [PATCH 184/981] Small bugfix --- plugins/train/trainer/_base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 75b3239a7f..9c114cbdf2 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -251,7 +251,8 @@ def __print_loss(self, loss, is_colab): if not is_colab: print(output, end='\r') else: - print(output) + # Colab doesn't output unless we spam it into the log + logger.info(output) def train_one_step(self, viewer, timelapse_kwargs, is_colab): """ Running training on a batch of images for each side. From c1e6080d92356cb8196e72e3026e6c0421151ddb Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 12 Dec 2019 13:27:14 +0000 Subject: [PATCH 185/981] Training: Cleaner loss printing --- plugins/train/model/_base.py | 2 ++ plugins/train/trainer/_base.py | 18 +++++------------- scripts/train.py | 2 +- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 2b31f97b7a..ed1d215308 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -464,6 +464,8 @@ def load_models(self, swapped): def save_models(self): """ Backup and save the models """ logger.debug("Backing up and saving models") + # Insert a new line to avoid spamming the same row as loss output + print("") save_averages = self.get_save_averages() backup_func = self.backup.backup_model if self.should_backup(save_averages) else None if backup_func: diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 9c114cbdf2..60b65c30f6 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -231,7 +231,7 @@ def _tensorboard_kwargs(self): logger.debug(kwargs) return kwargs - def __print_loss(self, loss, is_colab): + def __print_loss(self, loss): """ Outputs the loss for the current iteration to the console. Parameters @@ -240,21 +240,15 @@ def __print_loss(self, loss, is_colab): The loss for each side. The dictionary should contain 2 keys ("a" and "b") with the values being a list of loss values for the current iteration corresponding to each side. - is_colab: bool - ``True`` if FaceSwap is executing in a Google Colab session, otherwise ``False`` """ logger.trace(loss) output = ["Loss {}: {:.5f}".format(side.capitalize(), loss[side][0]) for side in sorted(loss.keys())] output = ", ".join(output) output = "[{}] [#{:05d}] {}".format(self._timestamp, self._model.iterations, output) - if not is_colab: - print(output, end='\r') - else: - # Colab doesn't output unless we spam it into the log - logger.info(output) + print("\r{}".format(output), end="") - def train_one_step(self, viewer, timelapse_kwargs, is_colab): + def train_one_step(self, viewer, timelapse_kwargs): """ Running training on a batch of images for each side. Triggered from the training cycle in :class:`scripts.train.Train`. @@ -272,8 +266,6 @@ def train_one_step(self, viewer, timelapse_kwargs, is_colab): The keyword arguments for generating time-lapse previews. If a time-lapse preview is not required then this should be ``None``. Otherwise all values should be full paths the keys being `input_a`, `input_b`, `output`. - is_colab: bool - ``True`` if FaceSwap is executing in a Google Colab session, otherwise ``False`` """ logger.trace("Training one step: (iteration: %s)", self._model.iterations) do_preview = viewer is not None @@ -304,11 +296,11 @@ def train_one_step(self, viewer, timelapse_kwargs, is_colab): self._log_tensorboard(side, side_loss) if not self._pingpong.active: - self.__print_loss(loss, is_colab) + self.__print_loss(loss) else: for key, val in loss.items(): self._pingpong.loss[key] = val - self.__print_loss(self._pingpong.loss, is_colab) + self.__print_loss(self._pingpong.loss) if do_preview: samples = self._samples.show_sample() diff --git a/scripts/train.py b/scripts/train.py index 0cd4f6c327..035bc9cefb 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -300,7 +300,7 @@ def _run_training_cycle(self, model, trainer): save_iteration = iteration % self._args.save_interval == 0 viewer = display_func if save_iteration or self._save_now else None timelapse = self._timelapse if save_iteration else None - trainer.train_one_step(viewer, timelapse, self._args.colab) + trainer.train_one_step(viewer, timelapse) if self._stop: logger.debug("Stop received. Terminating") break From 2e3e6025598ed9381ece147a60a069e85cdcfb3c Mon Sep 17 00:00:00 2001 From: kvrooman Date: Sun, 15 Dec 2019 06:49:51 -0600 Subject: [PATCH 186/981] bugfix: lib.tools.mask (#953) --- tools/mask.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/mask.py b/tools/mask.py index 550cad1858..b276a67424 100644 --- a/tools/mask.py +++ b/tools/mask.py @@ -214,7 +214,7 @@ def _input_frames(self, *args): detected_faces.append(detected_face) self._update_count += 1 if self._update_type != "output": - queue.put(ExtractMedia(filename, image, detected_faces=[detected_face])) + queue.put(ExtractMedia(filename, image, detected_faces=detected_faces)) if self._update_type != "output": queue.put("EOF") From e1d832f38378b84b55e4d5a494af127b904f7769 Mon Sep 17 00:00:00 2001 From: kvrooman Date: Sun, 15 Dec 2019 06:51:30 -0600 Subject: [PATCH 187/981] Clarification of Phases in Extract (#949) * Clarification of Phases When multi-processing, the status bar description lists the phase as only Detect. This is misleading and there likely should be a reference to the multiple simutaneous phases being run. * Simplify Communication --- scripts/extract.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/extract.py b/scripts/extract.py index b09dcfe9cd..ce2a049d68 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -191,6 +191,7 @@ def _run_extraction(self): size = self._args.size if hasattr(self._args, "size") else 256 saver = ImagesSaver(self._output_dir, as_bytes=True) exception = False + phase_desc = "Extraction" for phase in range(self._extractor.passes): if exception: @@ -199,9 +200,11 @@ def _run_extraction(self): detected_faces = dict() self._extractor.launch() self._check_thread_error() + if self._args.singleprocess: + phase_desc = self._extractor.phase.title() desc = "Running pass {} of {}: {}".format(phase + 1, self._extractor.passes, - self._extractor.phase.title()) + phase_desc) status_bar = tqdm(self._extractor.detected_faces(), total=self._images.process_count, file=sys.stdout, From 9ebc0abc8e85a2fa722aaaf75f8c8793e95ea12f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 16 Dec 2019 00:59:00 +0000 Subject: [PATCH 188/981] tools.sort - Optimize sort by face --- lib/vgg_face2_keras.py | 4 ++-- tools/sort.py | 23 ++++++++++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/lib/vgg_face2_keras.py b/lib/vgg_face2_keras.py index c80b1af3a0..8ef6ea704f 100644 --- a/lib/vgg_face2_keras.py +++ b/lib/vgg_face2_keras.py @@ -159,7 +159,7 @@ def sorted_similarity(self, predictions, method="ward"): predictions: numpy.ndarray A stacked matrix of vgg_face2 predictions of the shape (`N`, `D`) where `N` is the number of observations and `D` are the number of dimensions. NB: The given - :attr:`predictions` will be overwritten to save memory. If you still require the + :attr:`predictions` will be overwritten to save memory. If you still require the original values you should take a copy prior to running this method method: ['single','centroid','median','ward'] The clustering method to use. @@ -213,7 +213,7 @@ def _use_vector_linkage(item_count, dims): np_float = 24 # bytes size of a numpy float divider = 1024 * 1024 # bytes to MB - free_ram = psutil.virtual_memory().free / divider + free_ram = psutil.virtual_memory().available / divider linkage_required = (((item_count ** 2) * np_float) / 1.8) / divider vector_required = ((item_count * dims) * np_float) / divider logger.debug("free_ram: %sMB, linkage_required: %sMB, vector_required: %sMB", diff --git a/tools/sort.py b/tools/sort.py index a857f96305..d8f2e358ea 100644 --- a/tools/sort.py +++ b/tools/sort.py @@ -17,7 +17,7 @@ from lib.cli import FullHelpArgumentParser from lib.serializer import get_serializer_from_filename from lib.faces_detect import DetectedFace -from lib.image import read_image +from lib.image import ImagesLoader, read_image from lib.vgg_face2_keras import VGGFace2 as VGGFace from plugins.extract.pipeline import Extractor, ExtractMedia @@ -34,6 +34,8 @@ def __init__(self, arguments): self.changes = None self.serializer = None self.vgg_face = None + # TODO set this as ImagesLoader in init. Need to move all processes to use it + self._loader = None def process(self): """ Main processing function of the sort tool """ @@ -167,15 +169,22 @@ def sort_blur(self): def sort_face(self): """ Sort by identity similarity """ logger.info("Sorting by identity similarity...") - filename_list, image_list = self._get_images() - logger.info("Calculating face identifiers...") - preds = np.array([self.vgg_face.predict(img) - for img in tqdm(image_list, desc="Calculating...", file=sys.stdout)]) + # TODO This should be set in init + self._loader = ImagesLoader(self.args.input_dir) + + filenames = [] + preds = np.empty((self._loader.count, 512), dtype="float32") + for idx, (filename, image) in enumerate(tqdm(self._loader.load(), + desc="Classifying Faces...", + total=self._loader.count)): + filenames.append(filename) + preds[idx] = self.vgg_face.predict(image) logger.info("Sorting by ward linkage...") + indices = self.vgg_face.sorted_similarity(preds, method="ward") - img_list = np.array(filename_list)[indices] + img_list = np.array(filenames)[indices] return img_list def sort_face_cnn(self): @@ -736,7 +745,7 @@ def get_avg_score_faces_cnn(fl1, references): def bad_args(args): # pylint: disable=unused-argument """ Print help on bad arguments """ PARSER.print_help() - exit(0) + sys.exit(0) if __name__ == "__main__": From 8fee68e2b9a1b685e4cf0ae161bb0c71b660b4c3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 19 Dec 2019 23:41:32 +0000 Subject: [PATCH 189/981] bugfix: plugins.train.model._base - Legacy update. Set `learn_mask` to False on legacy models if a `mask_type` does not exist otherwise `True` --- plugins/train/model/_base.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index ed1d215308..8fa9dc8241 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -1013,8 +1013,8 @@ def _update_legacy_config(self): * loss - If old `dssim_loss` is ``true`` set new `loss_function` to `ssim` otherwise set it to `mae`. Remove old `dssim_loss` item - * masks - If `penalized_mask_loss` exists but `learn_mask` does not, then add the - latter and set to the same value as `penalized_mask_loss`. + * masks - If `learn_mask` does not exist then it is set to ``True`` if `mask_type` is + not ``None`` otherwised it is set to ``False``. * masks type - Replace removed masks 'dfl_full' and 'facehull' with `components` mask @@ -1024,7 +1024,7 @@ def _update_legacy_config(self): ``True`` if legacy items exist and state file has been updated, otherwise ``False`` """ logger.debug("Checking for legacy state file update") - priors = ["dssim_loss", "penalized_mask_loss", "mask_type"] + priors = ["dssim_loss", "mask_type", "mask_type"] new_items = ["loss_function", "learn_mask", "mask_type"] updated = False for old, new in zip(priors, new_items): @@ -1042,15 +1042,16 @@ def _update_legacy_config(self): continue # Add learn mask option and set to True if model has "penalized_mask_loss" specified - if old == "penalized_mask_loss" and new not in self.config: - self.config[new] = self.config["penalized_mask_loss"] + if old == "mask_type" and new == "learn_mask" and new not in self.config: + self.config[new] = self.config["mask_type"] is not None updated = True logger.info("Added new 'learn_mask' config item for this model. Value set to: %s", self.config[new]) continue # Replace removed masks with most similar equivalent - if old == "mask_type" and self.config[old] in ("facehull", "dfl_full"): + if old == "mask_type" and new == "mask_type" and self.config[old] in ("facehull", + "dfl_full"): old_mask = self.config[old] self.config[new] = "components" updated = True From dc2787d22a60e0a8ad45f0b03cc34934667142a0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 21 Dec 2019 10:15:31 +0000 Subject: [PATCH 190/981] Bugfix: GUI - Crash when resizing options panel. --- lib/gui/control_helper.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 99ba12f9ed..8d1a7d292f 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -602,7 +602,9 @@ def config_cleaner(widget): if key == "class": continue val = widget.cget(key) - if key in ("anchor", "justify") and val == "": + # Some keys default to "" but tkinter doesn't like to set config to this value + # so skip them to use default value. + if key in ("anchor", "justify", "compound") and val == "": continue val = str(val) if isinstance(val, Tcl_Obj) else val # Return correct command from master command dict From ba41a9c37ad115fcfa2bb2ec5c7adc9a5dad900e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 27 Dec 2019 03:50:25 +0000 Subject: [PATCH 191/981] bugfix: dlight model change "mask_type" to "learn_mask" in decoders --- plugins/train/model/dlight.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/train/model/dlight.py b/plugins/train/model/dlight.py index 41e261ab8c..679edaf321 100644 --- a/plugins/train/model/dlight.py +++ b/plugins/train/model/dlight.py @@ -220,7 +220,7 @@ def decoder_a(self): outputs = [var_x] - if self.config.get("mask_type", False): + if self.config.get("learn_mask", False): var_y = var_xy # mask decoder var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity) var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 2) @@ -256,7 +256,7 @@ def decoder_b_fast(self): outputs = [var_x] - if self.config.get("mask_type", False): + if self.config.get("learn_mask", False): var_y = var_xy # mask decoder var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity) @@ -303,7 +303,7 @@ def decoder_b(self): outputs = [var_x] - if self.config.get("mask_type", False): + if self.config.get("learn_mask", False): var_y = var_xy # mask decoder var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity) From 1bdc9da02f805f45c81485078a91d5410a64cfc6 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 29 Dec 2019 23:13:25 +0000 Subject: [PATCH 192/981] Smart Masks to Convert (#957) - scripts.convert - Use Smart Masks for Convert * Make on-the-fly conversion an explicit option - Move BlurMask to lib.faces_detect - tools.preview - Fix for smart masks * Subclass from tk.Tk * Options to lib.gui.control_helper *variable cleanup - lib.logger - Demote more tensorflow deprecation messages - Documentation: * lib.faces_detect.BlurMask * plugins.convert.mask * lib.convert * scripts.convert * scripts.fsmedia * tools.preview --- docs/full/lib.convert.rst | 7 + docs/full/lib.gui.rst | 3 - docs/full/lib.image.rst | 2 +- docs/full/lib.model.rst | 11 - docs/full/lib.rst | 19 +- docs/full/plugins.convert.mask.rst | 27 + docs/full/plugins.convert.rst | 9 + docs/full/plugins.extract._base.rst | 2 +- docs/full/plugins.extract.rst | 11 +- docs/full/plugins.rst | 5 + docs/full/plugins.train.rst | 3 - docs/full/scripts.extract.rst | 7 - docs/full/scripts.rst | 30 +- docs/full/scripts.train.rst | 7 - docs/full/tools.mask.rst | 7 - docs/full/tools.rst | 17 +- lib/alignments.py | 37 + lib/cli.py | 57 +- lib/convert.py | 360 ++-- lib/faces_detect.py | 168 +- lib/gui/control_helper.py | 13 +- lib/logger.py | 2 +- lib/model/masks.py | 177 -- plugins/convert/mask/_base.py | 210 +-- plugins/convert/mask/box_blend.py | 71 +- plugins/convert/mask/mask_blend.py | 169 +- plugins/convert/mask/mask_blend_defaults.py | 28 +- plugins/train/trainer/_base.py | 4 +- scripts/convert.py | 945 +++++++---- scripts/extract.py | 10 +- scripts/fsmedia.py | 516 ++++-- tools/mask.py | 4 +- tools/preview.py | 1654 +++++++++++-------- 33 files changed, 2909 insertions(+), 1683 deletions(-) create mode 100644 docs/full/lib.convert.rst create mode 100644 docs/full/plugins.convert.mask.rst create mode 100644 docs/full/plugins.convert.rst delete mode 100644 docs/full/scripts.extract.rst delete mode 100644 docs/full/scripts.train.rst delete mode 100644 docs/full/tools.mask.rst delete mode 100644 lib/model/masks.py diff --git a/docs/full/lib.convert.rst b/docs/full/lib.convert.rst new file mode 100644 index 0000000000..b5f5c90524 --- /dev/null +++ b/docs/full/lib.convert.rst @@ -0,0 +1,7 @@ +lib.convert module +================== + +.. automodule:: lib.convert + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib.gui.rst b/docs/full/lib.gui.rst index e8d44f6716..565b83056a 100644 --- a/docs/full/lib.gui.rst +++ b/docs/full/lib.gui.rst @@ -1,9 +1,6 @@ lib.gui package =============== -Submodules ----------- - .. toctree:: lib.gui.custom_widgets diff --git a/docs/full/lib.image.rst b/docs/full/lib.image.rst index 36ba2e51a6..a9f0e86c38 100644 --- a/docs/full/lib.image.rst +++ b/docs/full/lib.image.rst @@ -1,5 +1,5 @@ lib.image module -======================== +================ .. automodule:: lib.image :members: diff --git a/docs/full/lib.model.rst b/docs/full/lib.model.rst index b75d7a96ca..c2c18da812 100644 --- a/docs/full/lib.model.rst +++ b/docs/full/lib.model.rst @@ -1,17 +1,6 @@ lib.model package ================= -Submodules ----------- - .. toctree:: lib.model.session - -Module contents ---------------- - -.. automodule:: lib.model - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/lib.rst b/docs/full/lib.rst index ce6032124e..a23f6524a2 100644 --- a/docs/full/lib.rst +++ b/docs/full/lib.rst @@ -1,23 +1,20 @@ lib package =========== -Subpackages ------------ - .. toctree:: + lib.convert lib.faces_detect - lib.gui lib.image - lib.model lib.serializer lib.training_data lib.vgg_face2_keras -Module contents ---------------- -.. automodule:: lib - :members: - :undoc-members: - :show-inheritance: +Subpackages +----------- + +.. toctree:: + + lib.gui + lib.model diff --git a/docs/full/plugins.convert.mask.rst b/docs/full/plugins.convert.mask.rst new file mode 100644 index 0000000000..b6bc41dfad --- /dev/null +++ b/docs/full/plugins.convert.mask.rst @@ -0,0 +1,27 @@ +plugins.convert.mask package +============================ + +plugins.convert.mask._base module +--------------------------------- + +.. automodule:: plugins.convert.mask._base + :members: + :undoc-members: + :show-inheritance: + +plugins.convert.mask.box_blend module +------------------------------------- + +.. automodule:: plugins.convert.mask.box_blend + :members: + :undoc-members: + :show-inheritance: + +plugins.convert.mask.mask_blend module +-------------------------------------- + +.. automodule:: plugins.convert.mask.mask_blend + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/full/plugins.convert.rst b/docs/full/plugins.convert.rst new file mode 100644 index 0000000000..bfdb120249 --- /dev/null +++ b/docs/full/plugins.convert.rst @@ -0,0 +1,9 @@ +plugins.convert package +======================= + +Subpackages +----------- + +.. toctree:: + + plugins.convert.mask diff --git a/docs/full/plugins.extract._base.rst b/docs/full/plugins.extract._base.rst index 242ab986ee..131a5dd717 100644 --- a/docs/full/plugins.extract._base.rst +++ b/docs/full/plugins.extract._base.rst @@ -1,5 +1,5 @@ plugins.extract._base module -=============================== +============================ .. automodule:: plugins.extract._base :members: diff --git a/docs/full/plugins.extract.rst b/docs/full/plugins.extract.rst index 8c4ea2e5da..d150f9ff71 100644 --- a/docs/full/plugins.extract.rst +++ b/docs/full/plugins.extract.rst @@ -1,19 +1,10 @@ plugins.extract package ======================= -Subpackages ------------ - .. toctree:: + plugins.extract._base plugins.extract.align._base plugins.extract.detect._base plugins.extract.mask._base - -Submodules ----------- - -.. toctree:: - - plugins.extract._base plugins.extract.pipeline diff --git a/docs/full/plugins.rst b/docs/full/plugins.rst index bfaecffcf4..d07e762132 100644 --- a/docs/full/plugins.rst +++ b/docs/full/plugins.rst @@ -1,6 +1,10 @@ plugins package =============== +.. toctree:: + + plugins.plugin_loader + Subpackages ----------- @@ -9,3 +13,4 @@ Subpackages plugins.extract plugins.plugin_loader plugins.train + plugins.convert diff --git a/docs/full/plugins.train.rst b/docs/full/plugins.train.rst index 17a8166c00..5be6a76f0d 100644 --- a/docs/full/plugins.train.rst +++ b/docs/full/plugins.train.rst @@ -1,9 +1,6 @@ plugins.train package ===================== -Subpackages ------------ - .. toctree:: plugins.train.trainer._base diff --git a/docs/full/scripts.extract.rst b/docs/full/scripts.extract.rst deleted file mode 100644 index 742f40bab2..0000000000 --- a/docs/full/scripts.extract.rst +++ /dev/null @@ -1,7 +0,0 @@ -scripts.extract -=============== - -.. automodule:: scripts.extract - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/scripts.rst b/docs/full/scripts.rst index ebf3c048da..0c4c68c5a9 100644 --- a/docs/full/scripts.rst +++ b/docs/full/scripts.rst @@ -1,18 +1,30 @@ scripts package =============== -Subpackages ------------ - -.. toctree:: +scripts.extract module +---------------------- +.. automodule:: scripts.extract + :members: + :undoc-members: + :show-inheritance: - scripts.extract - scripts.train +scripts.train module +-------------------- +.. automodule:: scripts.train + :members: + :undoc-members: + :show-inheritance: -Module contents ---------------- +scripts.convert module +---------------------- +.. automodule:: scripts.convert + :members: + :undoc-members: + :show-inheritance: -.. automodule:: scripts +scripts.fsmedia module +---------------------- +.. automodule:: scripts.fsmedia :members: :undoc-members: :show-inheritance: diff --git a/docs/full/scripts.train.rst b/docs/full/scripts.train.rst deleted file mode 100644 index 111f2b3a14..0000000000 --- a/docs/full/scripts.train.rst +++ /dev/null @@ -1,7 +0,0 @@ -scripts.train -============= - -.. automodule:: scripts.train - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/tools.mask.rst b/docs/full/tools.mask.rst deleted file mode 100644 index 5a5d3e0df3..0000000000 --- a/docs/full/tools.mask.rst +++ /dev/null @@ -1,7 +0,0 @@ -tools.mask module -====================================== - -.. automodule:: tools.mask - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/tools.rst b/docs/full/tools.rst index 45b22b5159..d6a3a584fd 100644 --- a/docs/full/tools.rst +++ b/docs/full/tools.rst @@ -1,17 +1,18 @@ tools package ============= -Subpackages ------------ +tools.mask module +----------------- -.. toctree:: - - tools.mask +.. automodule:: tools.mask + :members: + :undoc-members: + :show-inheritance: -Module contents ---------------- +tools.preview module +-------------------- -.. automodule:: tools +.. automodule:: tools.preview :members: :undoc-members: :show-inheritance: diff --git a/lib/alignments.py b/lib/alignments.py index 3f985b9e30..1557c392c6 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -160,6 +160,43 @@ def frame_has_multiple_faces(self, frame): logger.trace("'%s': %s", frame, retval) return retval + def mask_is_valid(self, mask_type): + """ Ensure the given ``mask_type`` is valid for this alignments file. + + Every face in the alignments file must have the given mask type to successfully + pass the test. + + Parameters + ---------- + mask_type: str + The mask type to check against the current alignments + + Returns + ------- + bool: + ``True`` if all faces in the current alignments possess the given ``mask_type`` + otherwise ``False`` + """ + retval = any([(face.get("mask", None) is not None and + face["mask"].get(mask_type, None) is not None) + for faces in self.data.values() + for face in faces]) + logger.debug(retval) + return retval + + @property + def mask_summary(self): + """ Dict: The mask types and the number of faces which have each type that exist with in + the loaded alignments """ + masks = dict() + for faces in self.data.values(): + for face in faces: + if face.get("mask", None) is None: + masks["none"] = masks.get("none", 0) + 1 + for key in face.get("mask", dict): + masks[key] = masks.get(key, 0) + 1 + return masks + # << DATA >> # def get_faces_in_frame(self, frame): diff --git a/lib/cli.py b/lib/cli.py index d4d448a6a2..6573f73bea 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -14,7 +14,6 @@ from importlib import import_module from lib.logger import crash_log, log_setup -from lib.model.masks import get_available_masks, get_default_mask from lib.utils import FaceswapError, get_backend, safe_shutdown, set_system_verbosity from plugins.plugin_loader import PluginLoader @@ -576,9 +575,7 @@ def get_optional_arguments(): "choices": PluginLoader.get_available_extractors("mask", add_none=True), "default": "extended", "group": "Plugins", - "help": "R|Masker to use. NB - masks generated here can be used for training, and " - "converting with the 'predicted' mask. Availability of all masks specified " - "here for convert is coming soon." + "help": "R|Masker to use." "\nL|none: Don't use a mask." "\nL|components: Mask designed to provide facial segmentation based on the " "positioning of landmark locations. A convex hull is constructed around the " @@ -808,23 +805,32 @@ def get_optional_arguments(): argument_list.append({ "opts": ("-M", "--mask-type"), "action": Radio, - "type": str.lower, "dest": "mask_type", - "choices": get_available_masks() + ["predicted"], - "group": "plugins", - "default": "predicted", - "help": "R|Mask to use to replace faces. Blending of the masks can be adjusted in " - "'/config/convert.ini' or 'Settings > Configure Convert Plugins':" - "\nL|components: An improved face hull mask using a facehull of 8 facial " - "parts." - "\nL|dfl_full: An improved face hull mask using a facehull of 3 facial parts." - "\nL|extended: Based on components mask. Extends the eyebrow points to " - "further up the forehead. May perform badly on difficult angles." - "\nL|facehull: Face cutout based on landmarks." - "\nL|predicted: The predicted mask generated from the model. If the model was " - "not trained with a mask then this will fallback to " - "'{}'".format(get_default_mask()) + - "\nL|none: Don't use a mask."}) + "type": str.lower, + "choices": PluginLoader.get_available_extractors("mask", + add_none=True) + ["predicted"], + "default": "extended", + "group": "Plugins", + "help": "R|Masker to use. NB: The mask you require must exist within the alignments " + "file. You can add additional masks with the Mask Tool." + "\nL|none: Don't use a mask." + "\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|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({ "opts": ("-sc", "--scaling"), "action": Radio, @@ -967,6 +973,17 @@ def get_optional_arguments(): "backend": "nvidia", "help": "Sets allow_growth option of Tensorflow to spare memory on some " "configurations."}) + argument_list.append({ + "opts": ("-otf", "--on-the-fly"), + "action": "store_true", + "dest": "on_the_fly", + "group": "settings", + "default": False, + "help": "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " + "alignments file for your destination video. However, if you wish you can " + "generate the alignments on-the-fly by enabling this option. This will use " + "an inferior extraction pipeline and will lead to substandard results. If an " + "alignments file is found, this option will be ignored."}) argument_list.append({ "opts": ("-k", "--keep-unchanged"), "action": "store_true", diff --git a/lib/convert.py b/lib/convert.py index d29296607f..157fa536ff 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 -""" Converter for faceswap.py - Based on: https://gist.github.com/anonymous/d3815aba83a8f79779451262599b0955 - found on https://www.reddit.com/r/deepfakes/ """ +""" Converter for Faceswap """ import logging @@ -14,69 +12,132 @@ class Converter(): - """ Swap a source face with a target """ - def __init__(self, output_dir, output_size, output_has_mask, - draw_transparent, pre_encode, arguments, configfile=None): - logger.debug("Initializing %s: (output_dir: '%s', output_size: %s, output_has_mask: %s, " - "draw_transparent: %s, pre_encode: %s, arguments: %s, configfile: %s)", - self.__class__.__name__, output_dir, output_size, output_has_mask, - draw_transparent, pre_encode, arguments, configfile) - self.output_dir = output_dir - self.draw_transparent = draw_transparent - self.writer_pre_encode = pre_encode - self.scale = arguments.output_scale / 100 - self.output_size = output_size - self.output_has_mask = output_has_mask - self.args = arguments - self.configfile = configfile - self.adjustments = dict(box=None, mask=None, color=None, seamless=None, scaling=None) - self.load_plugins() + """ The converter is responsible for swapping the original face(s) in a frame with the output + of a trained Faceswap model. + + Parameters + ---------- + output_size: int + The size of the face, in pixels, that is output from the Faceswap model + coverage_ratio: float + The ratio of the training image that was used for training the Faceswap model + draw_transparent: bool + Whether the final output should be drawn onto a transparent layer rather than the original + frame. Only available with certain writer plugins. + pre_encode: python function + Some writer plugins support the pre-encoding of images prior to saving out. As patching is + done in multiple threads, but writing is done in a single thread, it can speed up the + process to do any pre-encoding as part of the converter process. + arguments: :class:`argparse.Namespace` + The arguments that were passed to the convert process as generated from Faceswap's command + line arguments + configfile: str, optional + Optional location of custom configuration ``ini`` file. If ``None`` then use the default + config location. Default: ``None`` + """ + def __init__(self, output_size, coverage_ratio, draw_transparent, pre_encode, + arguments, configfile=None): + logger.debug("Initializing %s: (output_size: %s, coverage_ratio: %s, draw_transparent: " + "%s, pre_encode: %s, arguments: %s, configfile: %s)", self.__class__.__name__, + output_size, coverage_ratio, draw_transparent, pre_encode, arguments, + configfile) + self._output_size = output_size + self._coverage_ratio = coverage_ratio + self._draw_transparent = draw_transparent + self._writer_pre_encode = pre_encode + self._args = arguments + self._configfile = configfile + + self._scale = arguments.output_scale / 100 + self._adjustments = dict(box=None, mask=None, color=None, seamless=None, scaling=None) + + self._load_plugins() logger.debug("Initialized %s", self.__class__.__name__) + @property + def cli_arguments(self): + """:class:`argparse.Namespace`: The command line arguments passed to the convert + process """ + return self._args + def reinitialize(self, config): - """ reinitialize converter """ + """ Reinitialize this :class:`Converter`. + + Called as part of the :mod:`~tools.preview` tool. Resets all adjustments then loads the + plugins as specified in the given config. + + Parameters + ---------- + config: :class:`lib.config.FaceswapConfig` + Pre-loaded :class:`lib.config.FaceswapConfig`. used over any configuration on disk. + """ logger.debug("Reinitializing converter") - self.adjustments = dict(box=None, mask=None, color=None, seamless=None, scaling=None) - self.load_plugins(config=config, disable_logging=True) + self._adjustments = dict(box=None, mask=None, color=None, seamless=None, scaling=None) + self._load_plugins(config=config, disable_logging=True) logger.debug("Reinitialized converter") - def load_plugins(self, config=None, disable_logging=False): - """ Load the requested adjustment plugins """ + def _load_plugins(self, config=None, disable_logging=False): + """ Load the requested adjustment plugins. + + Loads the :mod:`plugins.converter` plugins that have been requested for this conversion + session. + + Parameters + ---------- + config: :class:`lib.config.FaceswapConfig`, optional + Optional pre-loaded :class:`lib.config.FaceswapConfig`. If passed, then this will be + used over any configuration on disk. If ``None`` then it is ignored. Default: ``None`` + disable_logging: bool, optional + Plugin loader outputs logging info every time a plugin is loaded. Set to ``True`` to + suppress these messages otherwise ``False``. Default: ``False`` + """ logger.debug("Loading plugins. config: %s", config) - self.adjustments["box"] = PluginLoader.get_converter( + self._adjustments["box"] = PluginLoader.get_converter( "mask", "box_blend", - disable_logging=disable_logging)("none", - self.output_size, - configfile=self.configfile, + disable_logging=disable_logging)(self._output_size, + configfile=self._configfile, config=config) - self.adjustments["mask"] = PluginLoader.get_converter( + self._adjustments["mask"] = PluginLoader.get_converter( "mask", "mask_blend", - disable_logging=disable_logging)(self.args.mask_type, - self.output_size, - self.output_has_mask, - configfile=self.configfile, + disable_logging=disable_logging)(self._args.mask_type, + self._output_size, + self._coverage_ratio, + configfile=self._configfile, config=config) - if self.args.color_adjustment != "none" and self.args.color_adjustment is not None: - self.adjustments["color"] = PluginLoader.get_converter( + if self._args.color_adjustment != "none" and self._args.color_adjustment is not None: + self._adjustments["color"] = PluginLoader.get_converter( "color", - self.args.color_adjustment, - disable_logging=disable_logging)(configfile=self.configfile, config=config) + self._args.color_adjustment, + disable_logging=disable_logging)(configfile=self._configfile, config=config) - if self.args.scaling != "none" and self.args.scaling is not None: - self.adjustments["scaling"] = PluginLoader.get_converter( + if self._args.scaling != "none" and self._args.scaling is not None: + self._adjustments["scaling"] = PluginLoader.get_converter( "scaling", - self.args.scaling, - disable_logging=disable_logging)(configfile=self.configfile, config=config) - logger.debug("Loaded plugins: %s", self.adjustments) - - def process(self, in_queue, out_queue, completion_queue=None): - """ Process items from the queue """ - logger.debug("Starting convert process. (in_queue: %s, out_queue: %s, completion_queue: " - "%s)", in_queue, out_queue, completion_queue) + self._args.scaling, + disable_logging=disable_logging)(configfile=self._configfile, config=config) + logger.debug("Loaded plugins: %s", self._adjustments) + + def process(self, in_queue, out_queue): + """ Main convert process. + + Takes items from the in queue, runs the relevant adjustments, patches faces to final frame + and outputs patched frame to the out queue. + + Parameters + ---------- + in_queue: :class:`queue.Queue` + The output from :class:`scripts.convert.Predictor`. Contains detected faces from the + Faceswap model as well as the frame to be patched. + out_queue: :class:`queue.Queue` + The queue to place patched frames into for writing by one of Faceswap's + :mod:`plugins.convert.writer` plugins. + """ + logger.debug("Starting convert process. (in_queue: %s, out_queue: %s)", + in_queue, out_queue) while True: items = in_queue.get() if items == "EOF": @@ -92,7 +153,7 @@ def process(self, in_queue, out_queue, completion_queue=None): for item in items: logger.trace("Patch queue got: '%s'", item["filename"]) try: - image = self.patch_image(item) + image = self._patch_image(item) except Exception as err: # pylint: disable=broad-except # Log error and output original frame logger.error("Failed to convert image: '%s'. Reason: %s", @@ -104,30 +165,59 @@ def process(self, in_queue, out_queue, completion_queue=None): logger.trace("Out queue put: %s", item["filename"]) out_queue.put((item["filename"], image)) logger.debug("Completed convert process") - # Signal that this process has finished - if completion_queue is not None: - completion_queue.put(1) - def patch_image(self, predicted): - """ Patch the image """ + def _patch_image(self, predicted): + """ Patch a swapped face onto a frame. + + Run selected adjustments and swap the faces in a frame. + + Parameters + ---------- + predicted: dict + The output from :class:`scripts.convert.Predictor`. + + Returns + ------- + :class: `numpy.ndarray` or pre-encoded image output + The final frame ready for writing by a :mod:`plugins.convert.writer` plugin. + Frame is either an array, or the pre-encoded output from the writer's pre-encode + function (if it has one) + + """ logger.trace("Patching image: '%s'", predicted["filename"]) frame_size = (predicted["image"].shape[1], predicted["image"].shape[0]) - new_image, background = self.get_new_image(predicted, frame_size) - patched_face = self.post_warp_adjustments(background, new_image) - patched_face = self.scale_image(patched_face) + new_image, background = self._get_new_image(predicted, frame_size) + patched_face = self._post_warp_adjustments(background, new_image) + patched_face = self._scale_image(patched_face) patched_face *= 255.0 - patched_face = np.rint( - patched_face, - out=np.empty(patched_face.shape, dtype="uint8"), - casting='unsafe' - ) - if self.writer_pre_encode is not None: - patched_face = self.writer_pre_encode(patched_face) + patched_face = np.rint(patched_face, + out=np.empty(patched_face.shape, dtype="uint8"), + casting='unsafe') + if self._writer_pre_encode is not None: + patched_face = self._writer_pre_encode(patched_face) logger.trace("Patched image: '%s'", predicted["filename"]) return patched_face - def get_new_image(self, predicted, frame_size): - """ Get the new face from the predictor and apply box manipulations """ + def _get_new_image(self, predicted, frame_size): + """ Get the new face from the predictor and apply pre-warp manipulations. + + Applies any requested adjustments to the raw output of the Faceswap model + before transforming the image into the target frame. + + Parameters + ---------- + predicted: dict + The output from :class:`scripts.convert.Predictor`. + frame_size: tuple + The (`width`, `height`) of the final frame in pixels + + Returns + ------- + placeholder: :class: `numpy.ndarray` + The original frame with the swapped faces patched onto it + background: :class: `numpy.ndarray` + The original frame + """ logger.trace("Getting: (filename: '%s', faces: %s)", predicted["filename"], len(predicted["swapped_faces"])) @@ -139,19 +229,17 @@ def get_new_image(self, predicted, frame_size): predicted["detected_faces"]): predicted_mask = new_face[:, :, -1] if new_face.shape[2] == 4 else None new_face = new_face[:, :, :3] - src_face = detected_face.reference_face[..., :3] / 255.0 interpolator = detected_face.reference_interpolators[1] - new_face = self.pre_warp_adjustments(src_face, new_face, detected_face, predicted_mask) + new_face = self._pre_warp_adjustments(new_face, detected_face, predicted_mask) # Warp face with the mask - cv2.warpAffine( # pylint: disable=no-member - new_face, - detected_face.reference_matrix, - frame_size, - placeholder, - flags=cv2.WARP_INVERSE_MAP | interpolator, # pylint: disable=no-member - borderMode=cv2.BORDER_TRANSPARENT) # pylint: disable=no-member + cv2.warpAffine(new_face, + detected_face.reference_matrix, + frame_size, + placeholder, + flags=cv2.WARP_INVERSE_MAP | interpolator, + borderMode=cv2.BORDER_TRANSPARENT) np.clip(placeholder, 0.0, 1.0, out=placeholder) logger.trace("Got filename: '%s'. (placeholders: %s)", @@ -159,43 +247,97 @@ def get_new_image(self, predicted, frame_size): return placeholder, background - def pre_warp_adjustments(self, old_face, new_face, detected_face, predicted_mask): - """ Run the pre-warp adjustments """ - logger.trace("old_face shape: %s, new_face shape: %s, predicted_mask shape: %s", - old_face.shape, new_face.shape, + def _pre_warp_adjustments(self, new_face, detected_face, predicted_mask): + """ Run any requested adjustments that can be performed on the raw output from the Faceswap + model. + + Any adjustments that can be performed before warping the face into the final frame are + performed here. + + Parameters + ---------- + new_face: :class:`numpy.ndarray` + The swapped face received from the faceswap model. + detected_face: :class:`~lib.faces_detect.DetectedFace` + The detected_face object as defined in :class:`scripts.convert.Predictor` + predicted_mask: :class:`numpy.ndarray` or ``None`` + The predicted mask output from the Faceswap model. ``None`` if the model + did not learn a mask + + Returns + ------- + :class:`numpy.ndarray` + The face output from the Faceswap Model with any requested pre-warp adjustments + performed. + """ + logger.trace("new_face shape: %s, predicted_mask shape: %s", new_face.shape, predicted_mask.shape if predicted_mask is not None else None) - new_face = self.adjustments["box"].run(new_face) - new_face, raw_mask = self.get_image_mask(new_face, detected_face, predicted_mask) - if self.adjustments["color"] is not None: - new_face = self.adjustments["color"].run(old_face, new_face, raw_mask) - if self.adjustments["seamless"] is not None: - new_face = self.adjustments["seamless"].run(old_face, new_face, raw_mask) + old_face = detected_face.reference_face[..., :3] / 255.0 + new_face = self._adjustments["box"].run(new_face) + new_face, raw_mask = self._get_image_mask(new_face, detected_face, predicted_mask) + if self._adjustments["color"] is not None: + new_face = self._adjustments["color"].run(old_face, new_face, raw_mask) + if self._adjustments["seamless"] is not None: + new_face = self._adjustments["seamless"].run(old_face, new_face, raw_mask) logger.trace("returning: new_face shape %s", new_face.shape) return new_face - def get_image_mask(self, new_face, detected_face, predicted_mask): - """ Get the image mask """ + def _get_image_mask(self, new_face, detected_face, predicted_mask): + """ Return any selected image mask and intersect with any box mask. + + Places the requested mask into the new face's Alpha channel, intersecting with any box + mask that has already been applied. + + Parameters + ---------- + new_face: :class:`numpy.ndarray` + The swapped face received from the faceswap model, with any box mask applied + detected_face: :class:`~lib.faces_detect.DetectedFace` + The detected_face object as defined in :class:`scripts.convert.Predictor` + predicted_mask: :class:`numpy.ndarray` or ``None`` + The predicted mask output from the Faceswap model. ``None`` if the model + did not learn a mask + + Returns + :class:`numpy.ndarray` + The swapped face with the requested mask added to the Alpha channel + """ logger.trace("Getting mask. Image shape: %s", new_face.shape) - mask, raw_mask = self.adjustments["mask"].run(detected_face, predicted_mask) + mask, raw_mask = self._adjustments["mask"].run(detected_face, predicted_mask) if new_face.shape[2] == 4: logger.trace("Combining mask with alpha channel box mask") new_face[:, :, -1] = np.minimum(new_face[:, :, -1], mask.squeeze()) else: logger.trace("Adding mask to alpha channel") new_face = np.concatenate((new_face, mask), -1) - np.clip(new_face, 0.0, 1.0, out=new_face) logger.trace("Got mask. Image shape: %s", new_face.shape) return new_face, raw_mask - def post_warp_adjustments(self, background, new_image): - """ Apply fixes to the image after warping """ - if self.adjustments["scaling"] is not None: - new_image = self.adjustments["scaling"].run(new_image) + def _post_warp_adjustments(self, background, new_image): + """ Perform any requested adjustments to the swapped faces after they have been transformed + into the final frame. + + Parameters + ---------- + background: :class:`numpy.ndarray` + The original frame + new_image: :class:`numpy.ndarray` + A blank frame of original frame size with the faces warped onto it + + Returns + ------- + :class:`numpy.ndarray` + The final merged and swapped frame with any requested post-warp adjustments applied + """ + if self._adjustments["scaling"] is not None: + new_image = self._adjustments["scaling"].run(new_image) - if self.draw_transparent: + if self._draw_transparent: frame = new_image else: - foreground, mask = np.split(new_image, (3, ), axis=-1) + foreground, mask = np.split(new_image, # pylint:disable=unbalanced-tuple-unpacking + (3, ), + axis=-1) foreground *= mask background *= (1.0 - mask) background += foreground @@ -203,15 +345,29 @@ def post_warp_adjustments(self, background, new_image): np.clip(frame, 0.0, 1.0, out=frame) return frame - def scale_image(self, frame): - """ Scale the image if requested """ - if self.scale == 1: + def _scale_image(self, frame): + """ Scale the final image if requested. + + If output scale has been requested in command line arguments, scale the output + otherwise return the final frame. + + Parameters + ---------- + frame: :class:`numpy.ndarray` + The final frame with faces swapped + + Returns + ------- + :class:`numpy.ndarray` + The final frame scaled by the requested scaling factor + """ + if self._scale == 1: return frame logger.trace("source frame: %s", frame.shape) - interp = cv2.INTER_CUBIC if self.scale > 1 else cv2.INTER_AREA # pylint: disable=no-member - dims = (round((frame.shape[1] / 2 * self.scale) * 2), - round((frame.shape[0] / 2 * self.scale) * 2)) - frame = cv2.resize(frame, dims, interpolation=interp) # pylint: disable=no-member + interp = cv2.INTER_CUBIC if self._scale > 1 else cv2.INTER_AREA + dims = (round((frame.shape[1] / 2 * self._scale) * 2), + round((frame.shape[0] / 2 * self._scale) * 2)) + frame = cv2.resize(frame, dims, interpolation=interp) logger.trace("resized frame: %s", frame.shape) np.clip(frame, 0.0, 1.0, out=frame) return frame diff --git a/lib/faces_detect.py b/lib/faces_detect.py index e138b1a04e..ec068c20a8 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -519,6 +519,7 @@ def __init__(self, storage_size=128): self._affine_matrix = None self._interpolator = None + self._blur = dict() self._blur_kernel = 0 self._threshold = 0.0 @@ -528,13 +529,16 @@ def mask(self): and threshold amount applied.""" dims = (self.stored_size, self.stored_size, 1) mask = np.frombuffer(decompress(self._mask), dtype="uint8").reshape(dims) - if self._threshold != 0.0 or self._blur_kernel != 0: + if self._threshold != 0.0 or self._blur["kernel"] != 0: mask = mask.copy() if self._threshold != 0.0: mask[mask < self._threshold] = 0.0 mask[mask > 255.0 - self._threshold] = 255.0 - if self._blur_kernel != 0: - mask = cv2.GaussianBlur(mask, (self._blur_kernel, self._blur_kernel), 0)[..., None] + if self._blur["kernel"] != 0: + mask = BlurMask(self._blur["type"], + mask, + self._blur["kernel"], + passes=self._blur["passes"]).blurred logger.trace("mask shape: %s", mask.shape) return mask @@ -588,7 +592,8 @@ def add(self, mask, affine_matrix, interpolator): interpolation=cv2.INTER_AREA) * 255.0).astype("uint8") self._mask = compress(mask) - def set_blur_kernel_and_threshold(self, blur_kernel=0, threshold=0): + def set_blur_and_threshold(self, + blur_kernel=0, blur_type="gaussian", blur_passes=1, threshold=0): """ Set the internal blur kernel and threshold amount for returned masks Parameters @@ -597,15 +602,20 @@ def set_blur_kernel_and_threshold(self, blur_kernel=0, threshold=0): The kernel size, in pixels to apply gaussian blurring to the mask. Set to 0 for no blurring. Should be odd, if an even number is passed in (outside of 0) then it is rounded up to the next odd number. Default: 0 + blur_type: ["gaussian", "normalized"], optional + The blur type to use. ``gaussian`` or ``normalized`` box filter. Default: ``gaussian`` + blur_passes: int, optional + The number of passed to perform when blurring. Default: 1 threshold: int, optional The threshold amount to minimize/maximize mask values to 0 and 100. Percentage value. Default: 0 """ logger.trace("blur_kernel: %s, threshold: %s", blur_kernel, threshold) - if blur_kernel == 0 or blur_kernel % 2 == 1: - self._blur_kernel = blur_kernel - else: - self._blur_kernel = blur_kernel + 1 + if blur_type is not None: + blur_kernel += 0 if blur_kernel == 0 or blur_kernel % 2 == 1 else 1 + self._blur["kernel"] = blur_kernel + self._blur["type"] = blur_type + self._blur["passes"] = blur_passes self._threshold = (threshold / 100.0) * 255.0 def _adjust_affine_matrix(self, mask_size, affine_matrix): @@ -676,3 +686,145 @@ def _attr_name(dict_key): retval = "_{}".format(dict_key) if dict_key != "stored_size" else dict_key logger.trace("dict_key: %s, attribute_name: %s", dict_key, retval) return retval + + +class BlurMask(): + """ Factory class to return the correct blur object for requested blur type. + + Works for square images only. Currently supports Gaussian and Normalized Box Filters. + + Parameters + ---------- + blur_type: ["gaussian", "normalized"] + The type of blur to use + mask: :class:`numpy.ndarray` + The mask to apply the blur to + kernel: int or float + Either the kernel size (in pixels) or the size of the kernel as a ratio of mask size + is_ratio: bool, optional + Whether the given :attr:`kernel` parameter is a ratio or not. If ``True`` then the + actual kernel size will be calculated from the given ratio and the mask size. If + ``False`` then the kernel size will be set directly from the :attr:`kernel` parameter. + Default: ``False`` + passes: int, optional + The number of passes to perform when blurring. Default: ``1`` + + Example + ------- + >>> print(mask.shape) + (128, 128, 1) + >>> new_mask = BlurMask("gaussian", mask, 3, is_ratio=False, passes=1).blurred + >>> print(new_mask.shape) + (128, 128, 1) + """ + def __init__(self, blur_type, mask, kernel, is_ratio=False, passes=1): + logger.trace("Initializing %s: (blur_type: '%s', mask_shape: %s, kernel: %s, " + "is_ratio: %s, passes: %s)", self.__class__.__name__, blur_type, mask.shape, + kernel, is_ratio, passes) + self._blur_type = blur_type.lower() + self._mask = mask + self._passes = passes + kernel_size = self._get_kernel_size(kernel, is_ratio) + self._kernel_size = self._get_kernel_tuple(kernel_size) + logger.trace("Initialized %s", self.__class__.__name__) + + @property + def blurred(self): + """ :class:`numpy.ndarray`: The final mask with blurring applied. """ + func = self._func_mapping[self._blur_type] + kwargs = self._get_kwargs() + blurred = self._mask + for i in range(self._passes): + ksize = int(kwargs["ksize"][0]) + logger.trace("Pass: %s, kernel_size: %s", i + 1, (ksize, ksize)) + blurred = func(blurred, **kwargs) + ksize = int(round(ksize * self._multipass_factor)) + kwargs["ksize"] = self._get_kernel_tuple(ksize) + blurred = blurred[..., None] + logger.trace("Returning blurred mask. Shape: %s", blurred.shape) + return blurred + + @property + def _multipass_factor(self): + """ For multiple passes the kernel must be scaled down. This value is + different for box filter and gaussian """ + factor = dict(gaussian=0.8, normalized=0.5) + return factor[self._blur_type] + + @property + def _sigma(self): + """ int: The Sigma for Gaussian Blur. Returns 0 to force calculation from kernel size. """ + return 0 + + @property + def _func_mapping(self): + """ dict: :attr:`_blur_type` mapped to cv2 Function name. """ + return dict(gaussian=cv2.GaussianBlur, # pylint: disable = no-member + normalized=cv2.blur) # pylint: disable = no-member + + @property + def _kwarg_requirements(self): + """ dict: :attr:`_blur_type` mapped to cv2 Function required keyword arguments. """ + return dict(gaussian=["ksize", "sigmaX"], + normalized=["ksize"]) + + @property + def _kwarg_mapping(self): + """ dict: cv2 function keyword arguments mapped to their parameters. """ + return dict(ksize=self._kernel_size, + sigmaX=self._sigma) + + def _get_kernel_size(self, kernel, is_ratio): + """ Set the kernel size to absolute value. + + If :attr:`is_ratio` is ``True`` then the kernel size is calculated from the given ratio and + the :attr:`_mask` size, otherwise the given kernel size is just returned. + + Parameters + ---------- + kernel: int or float + Either the kernel size (in pixels) or the size of the kernel as a ratio of mask size + is_ratio: bool, optional + Whether the given :attr:`kernel` parameter is a ratio or not. If ``True`` then the + actual kernel size will be calculated from the given ratio and the mask size. If + ``False`` then the kernel size will be set directly from the :attr:`kernel` parameter. + + Returns + ------- + int + The size (in pixels) of the blur kernel + """ + if not is_ratio: + return kernel + + mask_diameter = np.sqrt(np.sum(self._mask)) + radius = round(max(1., mask_diameter * kernel / 100.)) + kernel_size = int(radius * 2 + 1) + logger.trace("kernel_size: %s", kernel_size) + return kernel_size + + @staticmethod + def _get_kernel_tuple(kernel_size): + """ Make sure kernel_size is odd and return it as a tuple. + + Parameters + ---------- + kernel_size: int + The size in pixels of the blur kernel + + Returns + ------- + tuple + The kernel size as a tuple of ('int', 'int') + """ + kernel_size += 1 if kernel_size % 2 == 0 else 0 + retval = (kernel_size, kernel_size) + logger.trace(retval) + return retval + + def _get_kwargs(self): + """ dict: the valid keyword arguments for the requested :attr:`_blur_type` """ + retval = {kword: self._kwarg_mapping[kword] + for kword in self._kwarg_requirements[self._blur_type]} + logger.trace("BlurMask kwargs: %s", retval) + return retval diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 8d1a7d292f..f7ad70d99b 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -161,7 +161,7 @@ def default(self): @property def value(self): - """ Return either selected value or default """ + """ Return either initial value or default """ val = self._options["initial_value"] val = self.default if val is None else val return val @@ -227,6 +227,17 @@ def set(self, value): """ Set the tk_var to a new value """ self.tk_var.set(value) + def set_initial_value(self, value): + """ Set the initial_value to the given value + + Parameters + ---------- + value: varies + The value to set the initial value attribute to + """ + logger.debug("Setting inital value for %s to %s", self.name, value) + self._options["initial_value"] = value + def get_control(self): """ Set the correct control type based on the datatype or for this option """ if self.choices and self.is_radio: diff --git a/lib/logger.py b/lib/logger.py index b567f3c2cb..21e3ef2156 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -69,7 +69,7 @@ def format(self, record): def rewrite_tf_deprecation(record): """ Change TF deprecation messages from WARNING to DEBUG """ if record.levelno == 30 and (record.funcName == "_tfmw_add_deprecation_warning" or - record.module == "deprecation"): + record.module in("deprecation", "deprecation_wrapper")): record.levelno = 10 record.levelname = "DEBUG" return record diff --git a/lib/model/masks.py b/lib/model/masks.py deleted file mode 100644 index d3693b4ccd..0000000000 --- a/lib/model/masks.py +++ /dev/null @@ -1,177 +0,0 @@ -#!/usr/bin/env python3 -""" Masks functions for faceswap.py """ - -import inspect -import logging -import sys - -import cv2 -import numpy as np - -logger = logging.getLogger(__name__) # pylint: disable=invalid-name - - -def get_available_masks(): - """ Return a list of the available masks for cli """ - masks = sorted([name for name, obj in inspect.getmembers(sys.modules[__name__]) - if inspect.isclass(obj) and name != "Mask"]) - masks.append("none") - logger.debug(masks) - return masks - - -def get_default_mask(): - """ Set the default mask for cli """ - masks = get_available_masks() - default = "dfl_full" - default = default if default in masks else masks[0] - logger.debug(default) - return default - - -class Mask(): - """ Parent class for masks - - the output mask will be .mask - channels: 1, 3 or 4: - 1 - Returns a single channel mask - 3 - Returns a 3 channel mask - 4 - Returns the original image with the mask in the alpha channel """ - - def __init__(self, landmarks, face, channels=4): - logger.trace("Initializing %s: (face_shape: %s, channels: %s, landmarks: %s)", - self.__class__.__name__, face.shape, channels, landmarks) - self.landmarks = np.rint(landmarks).astype("int32") - self.face = face - self.dtype = face.dtype - self.threshold = 255 if self.dtype == "uint8" else 255.0 - self.channels = channels - - mask = self.build_mask() - self.mask = self.merge_mask(mask) - logger.trace("Initialized %s", self.__class__.__name__) - - def build_mask(self): - """ Override to build the mask """ - raise NotImplementedError - - def merge_mask(self, mask): - """ Return the mask in requested shape """ - logger.trace("mask_shape: %s", mask.shape) - assert self.channels in (1, 3, 4), "Channels should be 1, 3 or 4" - assert mask.shape[2] == 1 and mask.ndim == 3, "Input mask be 3 dimensions with 1 channel" - - if self.channels == 3: - retval = np.tile(mask, 3) - elif self.channels == 4: - retval = np.concatenate((self.face, mask), -1) - else: - retval = mask - - logger.trace("Final mask shape: %s", retval.shape) - return retval - - -class dfl_full(Mask): # pylint: disable=invalid-name - """ DFL facial mask """ - def build_mask(self): - mask = np.zeros(self.face.shape[0:2] + (1, ), dtype=self.dtype) - - nose_ridge = (self.landmarks[27:31], self.landmarks[33:34]) - jaw = (self.landmarks[0:17], - self.landmarks[48:68], - self.landmarks[0:1], - self.landmarks[8:9], - self.landmarks[16:17]) - eyes = (self.landmarks[17:27], - self.landmarks[0:1], - self.landmarks[27:28], - self.landmarks[16:17], - self.landmarks[33:34]) - parts = [jaw, nose_ridge, eyes] - - for item in parts: - merged = np.concatenate(item) - cv2.fillConvexPoly(mask, cv2.convexHull(merged), self.threshold) - return mask - - -class components(Mask): # pylint: disable=invalid-name - """ Component model mask """ - def build_mask(self): - mask = np.zeros(self.face.shape[0:2] + (1, ), dtype=self.dtype) - - r_jaw = (self.landmarks[0:9], self.landmarks[17:18]) - l_jaw = (self.landmarks[8:17], self.landmarks[26:27]) - r_cheek = (self.landmarks[17:20], self.landmarks[8:9]) - l_cheek = (self.landmarks[24:27], self.landmarks[8:9]) - nose_ridge = (self.landmarks[19:25], self.landmarks[8:9],) - r_eye = (self.landmarks[17:22], - self.landmarks[27:28], - self.landmarks[31:36], - self.landmarks[8:9]) - l_eye = (self.landmarks[22:27], - self.landmarks[27:28], - self.landmarks[31:36], - self.landmarks[8:9]) - nose = (self.landmarks[27:31], self.landmarks[31:36]) - parts = [r_jaw, l_jaw, r_cheek, l_cheek, nose_ridge, r_eye, l_eye, nose] - - for item in parts: - merged = np.concatenate(item) - cv2.fillConvexPoly(mask, cv2.convexHull(merged), self.threshold) - return mask - - -class extended(Mask): # pylint: disable=invalid-name - """ Extended mask - Based on components mask. Attempts to extend the eyebrow points up the forehead - """ - def build_mask(self): - mask = np.zeros(self.face.shape[0:2] + (1, ), dtype=self.dtype) - - landmarks = self.landmarks.copy() - # mid points between the side of face and eye point - ml_pnt = (landmarks[36] + landmarks[0]) // 2 - mr_pnt = (landmarks[16] + landmarks[45]) // 2 - - # mid points between the mid points and eye - ql_pnt = (landmarks[36] + ml_pnt) // 2 - qr_pnt = (landmarks[45] + mr_pnt) // 2 - - # Top of the eye arrays - bot_l = np.array((ql_pnt, landmarks[36], landmarks[37], landmarks[38], landmarks[39])) - bot_r = np.array((landmarks[42], landmarks[43], landmarks[44], landmarks[45], qr_pnt)) - - # Eyebrow arrays - top_l = landmarks[17:22] - top_r = landmarks[22:27] - - # Adjust eyebrow arrays - landmarks[17:22] = top_l + ((top_l - bot_l) // 2) - landmarks[22:27] = top_r + ((top_r - bot_r) // 2) - - r_jaw = (landmarks[0:9], landmarks[17:18]) - l_jaw = (landmarks[8:17], landmarks[26:27]) - r_cheek = (landmarks[17:20], landmarks[8:9]) - l_cheek = (landmarks[24:27], landmarks[8:9]) - nose_ridge = (landmarks[19:25], landmarks[8:9],) - r_eye = (landmarks[17:22], landmarks[27:28], landmarks[31:36], landmarks[8:9]) - l_eye = (landmarks[22:27], landmarks[27:28], landmarks[31:36], landmarks[8:9]) - nose = (landmarks[27:31], landmarks[31:36]) - parts = [r_jaw, l_jaw, r_cheek, l_cheek, nose_ridge, r_eye, l_eye, nose] - - for item in parts: - merged = np.concatenate(item) - cv2.fillConvexPoly(mask, cv2.convexHull(merged), self.threshold) - return mask - - -class facehull(Mask): # pylint: disable=invalid-name - """ Basic face hull mask """ - def build_mask(self): - mask = np.zeros(self.face.shape[0:2] + (1, ), dtype=self.dtype) - hull = cv2.convexHull( - np.array(self.landmarks).reshape((-1, 2))) - cv2.fillConvexPoly(mask, hull, self.threshold, lineType=cv2.LINE_AA) - return mask diff --git a/plugins/convert/mask/_base.py b/plugins/convert/mask/_base.py index 88d38f46e9..0008dd35e2 100644 --- a/plugins/convert/mask/_base.py +++ b/plugins/convert/mask/_base.py @@ -1,42 +1,105 @@ #!/usr/bin/env python3 -""" Parent class for mask adjustments for faceswap.py converter """ +""" Base class for Faceswap :mod:`~plugins.convert.mask` Plugins """ import logging -import cv2 import numpy as np -from lib.model import masks as model_masks from plugins.convert._config import Config logger = logging.getLogger(__name__) # pylint: disable=invalid-name -def get_config(plugin_name, configfile=None): - """ Return the config for the requested model """ +def _get_config(plugin_name, configfile=None): + """ Return the :attr:`lib.config.FaceswapConfig.config_dict` for the requested plugin. + + Parameters + ---------- + plugin_name: str + The name of the plugin to retrieve the config for + configfile: str, optional + Optional location of custom configuration ``ini`` file. If ``None`` then use the default + config location. Default: ``None`` + + Returns + ------- + dict + The configuration in dictionary form for the given plugin_name from + :attr:`lib.config.FaceswapConfig.config_dict` + """ return Config(plugin_name, configfile=configfile).config_dict class Adjustment(): - """ Parent class for adjustments """ - def __init__(self, mask_type, output_size, predicted_available, configfile=None, config=None): + """ Parent class for Mask Adjustment Plugins. + + All mask plugins must inherit from this class. + + Parameters + ---------- + mask_type: str + The type of mask that this plugin is being used for + output_size: int + The size, in pixels, of the output from the Faceswap model. + configfile: str, Optional + Optional location of custom configuration ``ini`` file. If ``None`` then use the default + config location. Default: ``None`` + config: :class:`lib.config.FaceswapConfig`, Optional + Optional pre-loaded :class:`lib.config.FaceswapConfig`. If passed, then this will be used + over any configuration on disk. If ``None`` then it is ignored. Default: ``None`` + + + Attributes + ---------- + config: dict + The configuration dictionary for this plugin. + mask_type: str + The type of mask that this plugin is being used for. + """ + def __init__(self, mask_type, output_size, configfile=None, config=None): logger.debug("Initializing %s: (arguments: '%s', output_size: %s, " - "predicted_available: %s, configfile: %s, config: %s)", - self.__class__.__name__, mask_type, output_size, predicted_available, - configfile, config) - self.config = self.set_config(configfile, config) + "configfile: %s, config: %s)", self.__class__.__name__, mask_type, + output_size, configfile, config) + self.config = self._set_config(configfile, config) logger.debug("config: %s", self.config) - self.mask_type = self.get_mask_type(mask_type, predicted_available) - self.dummy = np.zeros((output_size, output_size, 3), dtype='float32') - - self.skip = self.config.get("type", None) is None + self.mask_type = mask_type + self._dummy = np.zeros((output_size, output_size, 3), dtype='float32') logger.debug("Initialized %s", self.__class__.__name__) - def set_config(self, configfile, config): - """ Set the config to either global config or passed in config """ + @property + def dummy(self): + """:class:`numpy.ndarray`: A dummy mask of all zeros of the shape: + (:attr:`output_size`, :attr:`output_size`, `3`) + """ + return self._dummy + + @property + def skip(self): + """bool: ``True`` if the blur type config attribute is ``None`` otherwise ``False`` """ + return self.config.get("type", None) is None + + def _set_config(self, configfile, config): + """ Set the correct configuration for the plugin based on whether a config file + or pre-loaded config has been passed in. + + Parameters + ---------- + configfile: str + Location of custom configuration ``ini`` file. If ``None`` then use the + default config location + config: :class:`lib.config.FaceswapConfig` + Pre-loaded :class:`lib.config.FaceswapConfig`. If passed, then this will be + used over any configuration on disk. If ``None`` then it is ignored. + + Returns + ------- + dict + The configuration in dictionary form for the given from + :attr:`lib.config.FaceswapConfig.config_dict` + """ section = ".".join(self.__module__.split(".")[-2:]) if config is None: - retval = get_config(section, configfile=configfile) + retval = _get_config(section, configfile=configfile) else: config.section = section retval = config.config_dict @@ -44,20 +107,13 @@ def set_config(self, configfile, config): logger.debug("Config: %s", retval) return retval - @staticmethod - def get_mask_type(mask_type, predicted_available): - """ Return the requested mask type. Return default mask if - predicted requested but not available """ - logger.debug("Requested mask_type: %s", mask_type) - if mask_type == "predicted" and not predicted_available: - mask_type = model_masks.get_default_mask() - logger.warning("Predicted selected, but the model was not trained with a mask. " - "Switching to '%s'", mask_type) - logger.debug("Returning mask_type: %s", mask_type) - return mask_type - def process(self, *args, **kwargs): - """ Override for specific color adjustment process """ + """ Override for specific mask adjustment plugin processes. + + Input parameters will vary from plugin to plugin. + + Should return a :class:`numpy.ndarray` mask with the plugin's actions applied + """ raise NotImplementedError def run(self, *args, **kwargs): @@ -66,95 +122,3 @@ def run(self, *args, **kwargs): self.__module__, args, kwargs) retval = self.process(*args, **kwargs) return retval - - -class BlurMask(): - """ Factory class to return the correct blur object for requested blur - Works for square images only. - Currently supports Gaussian and Normalized Box Filters - """ - def __init__(self, blur_type, mask, kernel_ratio, passes=1): - """ image_size = height or width of original image - mask = the mask to apply the blurring to - kernel_ratio = kernel ratio as percentage of mask size - diameter = True calculates approx diameter of mask for kernel, False - passes = the number of passes to perform the blur """ - logger.trace("Initializing %s: (blur_type: '%s', mask_shape: %s, kernel_ratio: %s, " - "passes: %s)", self.__class__.__name__, blur_type, mask.shape, kernel_ratio, - passes) - self.blur_type = blur_type.lower() - self.mask = mask - self.passes = passes - kernel_size = self.get_kernel_size(kernel_ratio) - self.kernel_size = self.get_kernel_tuple(kernel_size) - logger.trace("Initialized %s", self.__class__.__name__) - - @property - def blurred(self): - """ The final blurred mask """ - func = self.func_mapping[self.blur_type] - kwargs = self.get_kwargs() - blurred = self.mask - for i in range(self.passes): - ksize = int(kwargs["ksize"][0]) - logger.trace("Pass: %s, kernel_size: %s", i + 1, (ksize, ksize)) - blurred = func(blurred, **kwargs) - ksize = int(round(ksize * self.multipass_factor)) - kwargs["ksize"] = self.get_kernel_tuple(ksize) - logger.trace("Returning blurred mask. Shape: %s", blurred.shape) - return blurred - - @property - def multipass_factor(self): - """ Multipass Factor - For multiple passes the kernel must be scaled down. This value is - different for box filter and gaussian """ - factor = dict(gaussian=0.8, normalized=0.5) - return factor[self.blur_type] - - @property - def sigma(self): - """ Sigma for Gaussian Blur - Returns zero so it is calculated from kernel size """ - return 0 - - @property - def func_mapping(self): - """ Return a dict of function name to cv2 function """ - return dict(gaussian=cv2.GaussianBlur, # pylint: disable = no-member - normalized=cv2.blur) # pylint: disable = no-member - - @property - def kwarg_requirements(self): - """ Return a dict of function name to a list of required kwargs """ - return dict(gaussian=["ksize", "sigmaX"], - normalized=["ksize"]) - - @property - def kwarg_mapping(self): - """ Return a dict of kwarg names to config item names """ - return dict(ksize=self.kernel_size, - sigmaX=self.sigma) - - def get_kernel_size(self, radius_ratio): - """ Set the kernel size to absolute """ - mask_diameter = np.sqrt(np.sum(self.mask)) - radius = round(max(1., mask_diameter * radius_ratio / 100.)) - kernel_size = int(radius * 2 + 1) - logger.trace("kernel_size: %s", kernel_size) - return kernel_size - - @staticmethod - def get_kernel_tuple(kernel_size): - """ Make sure kernel_size is odd and return it as a tupe """ - kernel_size += 1 if kernel_size % 2 == 0 else 0 - retval = (kernel_size, kernel_size) - logger.trace(retval) - return retval - - def get_kwargs(self): - """ return valid kwargs for the requested blur """ - retval = {kword: self.kwarg_mapping[kword] - for kword in self.kwarg_requirements[self.blur_type]} - logger.trace("BlurMask kwargs: %s", retval) - return retval diff --git a/plugins/convert/mask/box_blend.py b/plugins/convert/mask/box_blend.py index c1d34f7301..a7dd4fb2ea 100644 --- a/plugins/convert/mask/box_blend.py +++ b/plugins/convert/mask/box_blend.py @@ -1,26 +1,42 @@ #!/usr/bin/env python3 -""" Adjustments for the swap box for faceswap.py converter """ +""" Plugin to blend the edges of the face box that comes out of the Faceswap Model into the final +frame. """ import numpy as np -from ._base import Adjustment, BlurMask, logger +from lib.faces_detect import BlurMask +from ._base import Adjustment, logger class Mask(Adjustment): - """ Manipulations that occur on the swap box - Actions performed here occur prior to warping the face back to the background frame - - For actions that occur identically for each frame (e.g. blend_box), constants can - be placed into self.func_constants to be compiled at launch, then referenced for - each face. """ - def __init__(self, mask_type, output_size, predicted_available=False, **kwargs): - super().__init__(mask_type, output_size, predicted_available, **kwargs) - self.mask = self.get_mask() if not self.skip else None - - def get_mask(self): - """ The box for every face will be identical, so set the mask just once - As gaussian blur technically blurs both sides of the mask, reduce the mask ratio by - half to give a more expected box """ + """ Manipulations to perform on the edges of the box that is received from the Faceswap model. + + As the size of the box coming out of the model is identical for every face, the mask to be + applied is just calculated once (at launch). + + Parameters + ---------- + output_size: int + The size of the output from the Faceswap model. + **kwargs: dict, optional + See the parent :class:`~plugins.convert.mask._base` for additional keyword arguments. + """ + def __init__(self, output_size, **kwargs): + super().__init__("none", output_size, **kwargs) + self.mask = self._get_mask() if not self.skip else None + + def _get_mask(self): + """ Create a mask to be used at the edges of the face box. + + The box for every face will be identical, so the mask is set just once on initialization. + As gaussian blur technically blurs both sides of the mask, the mask ratio is reduced by + half to give a more expected box. + + Returns + ------- + :class:`numpy.ndarray` + The mask to be used at the edges of the box output from the Faceswap model + """ logger.debug("Building box mask") mask_ratio = self.config["distance"] / 200 facesize = self.dummy.shape[0] @@ -31,18 +47,31 @@ def get_mask(self): mask = BlurMask(self.config["type"], mask, self.config["radius"], - self.config["passes"]).blurred + is_ratio=True, + passes=self.config["passes"]).blurred logger.debug("Built box mask. Shape: %s", mask.shape) return mask - def process(self, new_face): - """ The blend box function. Adds the created mask to the alpha channel """ + def process(self, new_face): # pylint:disable=arguments-differ + """ Apply the box mask to the swapped face. + + Parameters + ---------- + new_face: :class:`numpy.ndarray` + The swapped face that has been output from the Faceswap model + + Returns + ------- + :class:`numpy.ndarray` + The input face is returned with the box mask added to the alpha channel if a blur type + has been specified in the plugin configuration. If this configuration is set to + ``None`` then the input face is returned with no mask applied. + """ if self.skip: logger.trace("Skipping blend box") return new_face logger.trace("Blending box") - mask = np.expand_dims(self.mask, axis=-1) - new_face = np.clip(np.concatenate((new_face, mask), axis=-1), 0.0, 1.0) + new_face = np.concatenate((new_face, self.mask), axis=-1) logger.trace("Blended box") return new_face diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index e626a10cfc..a09d4b646c 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -1,75 +1,160 @@ #!/usr/bin/env python3 -""" Adjustments for the mask for faceswap.py converter """ +""" Plugin to blend the edges of the face between the swap and the original face. """ import cv2 import numpy as np -from lib.model import masks as model_masks -from ._base import Adjustment, BlurMask, logger +from ._base import Adjustment, logger class Mask(Adjustment): - """ Return the requested mask """ - def __init__(self, mask_type, output_size, predicted_available, **kwargs): - super().__init__(mask_type, output_size, predicted_available, **kwargs) - self.do_erode = self.config.get("erosion", 0) != 0 - self.do_blend = self.config.get("type", None) is not None - - def process(self, detected_face, predicted_mask=None): - """ Return mask and perform processing """ - mask = self.get_mask(detected_face, predicted_mask) + """ Manipulations to perform to the mask that is to be applied to the output of the Faceswap + model. + + Parameters + ---------- + mask_type: str + The mask type to use for this plugin + output_size: int + The size of the output from the Faceswap model. + coverage_ratio: float + The coverage ratio that the Faceswap model was trained at. + **kwargs: dict, optional + See the parent :class:`~plugins.convert.mask._base` for additional keyword arguments. + """ + def __init__(self, mask_type, output_size, coverage_ratio, **kwargs): + super().__init__(mask_type, output_size, **kwargs) + self._do_erode = self.config.get("erosion", 0) != 0 + self._coverage_ratio = coverage_ratio + + def process(self, detected_face, predicted_mask=None): # pylint:disable=arguments-differ + """ Obtain the requested mask type and perform any defined mask manipulations. + + Parameters + ---------- + detected_face: :class:`lib.faces_detect.DetectedFace` + The DetectedFace object as returned from :class:`scripts.convert.Predictor`. + predicted_mask: :class:`numpy.ndarray`, optional + The predicted mask as output from the Faceswap Model, if the model was trained + with a mask, otherwise ``None``. Default: ``None``. + + Returns + ------- + mask: :class:`numpy.ndarray` + The mask with all requested manipulations applied + raw_mask: :class:`numpy.ndarray` + The mask with no erosion/dilation applied + """ + mask = self._get_mask(detected_face, predicted_mask) raw_mask = mask.copy() - if not self.skip and self.do_erode: - mask = self.erode(mask) - if not self.skip and self.do_blend: - mask = self.blend(mask) + if not self.skip and self._do_erode: + mask = self._erode(mask) raw_mask = np.expand_dims(raw_mask, axis=-1) if raw_mask.ndim != 3 else raw_mask mask = np.expand_dims(mask, axis=-1) if mask.ndim != 3 else mask logger.trace("mask shape: %s, raw_mask shape: %s", mask.shape, raw_mask.shape) - return mask, raw_mask + return mask.astype("float32") / 255.0, raw_mask.astype("float32") / 255.0 + + def _get_mask(self, detected_face, predicted_mask): + """ Return the requested mask with any requested blurring applied. - def get_mask(self, detected_face, predicted_mask): - """ Return the mask from lib/model/masks and intersect with box """ + Parameters + ---------- + detected_face: :class:`lib.faces_detect.DetectedFace` + The DetectedFace object as returned from :class:`scripts.convert.Predictor`. + predicted_mask: :class:`numpy.ndarray` + The predicted mask as output from the Faceswap Model if the model was trained + with a mask, otherwise ``None`` + + Returns + ------- + :class:`numpy.ndarray` + The mask sized to Faceswap model output with any requested blurring applied. + """ if self.mask_type == "none": # Return a dummy mask if not using a mask mask = np.ones_like(self.dummy[:, :, 1]) elif self.mask_type == "predicted": mask = predicted_mask else: - landmarks = detected_face.reference_landmarks - mask = getattr(model_masks, self.mask_type)(landmarks, self.dummy, channels=1).mask - np.nan_to_num(mask, copy=False) - np.clip(mask, 0.0, 1.0, out=mask) + mask = detected_face.mask[self.mask_type] + mask.set_blur_and_threshold(blur_kernel=self.config["kernel_size"], + blur_type=self.config["type"], + blur_passes=self.config["passes"], + threshold=self.config["threshold"]) + mask = self._crop_to_coverage(mask.mask) + + mask_size = mask.shape[0] + face_size = self.dummy.shape[0] + if mask_size != face_size: + interp = cv2.INTER_CUBIC if mask_size < face_size else cv2.INTER_AREA + mask = cv2.resize(mask, + self.dummy.shape[:2], + interpolation=interp)[..., None] + logger.trace(mask.shape) + return mask + + def _crop_to_coverage(self, mask): + """ Crop the mask to the correct dimensions based on coverage ratio. + + Parameters + ---------- + mask: :class:`numpy.ndarray` + The original mask to be cropped + + Returns + ------- + :class:`numpy.ndarray` + The cropped mask + """ + if self._coverage_ratio == 1.0: + return mask + mask_size = mask.shape[0] + padding = round((mask_size * (1 - self._coverage_ratio)) / 2) + mask_slice = slice(padding, mask_size - padding) + mask = mask[mask_slice, mask_slice, :] + logger.trace("mask_size: %s, coverage: %s, padding: %s, final shape: %s", + mask_size, self._coverage_ratio, padding, mask.shape) return mask # MASK MANIPULATIONS - def erode(self, mask): - """ Erode/dilate mask if requested """ - kernel = self.get_erosion_kernel(mask) + def _erode(self, mask): + """ Erode or dilate mask the mask based on configuration options. + + Parameters + ---------- + mask: :class:`numpy.ndarray` + The mask to be eroded or dilated + + Returns + ------- + :class:`numpy.ndarray` + The mask with erosion/dilation applied + """ + kernel = self._get_erosion_kernel(mask) if self.config["erosion"] > 0: logger.trace("Eroding mask") - mask = cv2.erode(mask, kernel, iterations=1) # pylint: disable=no-member + mask = cv2.erode(mask, kernel, iterations=1) else: logger.trace("Dilating mask") - mask = cv2.dilate(mask, kernel, iterations=1) # pylint: disable=no-member + mask = cv2.dilate(mask, kernel, iterations=1) return mask - def get_erosion_kernel(self, mask): - """ Get the erosion kernel """ + def _get_erosion_kernel(self, mask): + """ Get the erosion kernel. + + Parameters + ---------- + mask: :class:`numpy.ndarray` + The mask to be eroded or dilated + + Returns + ------- + :class:`numpy.ndarray` + The erosion kernel to be used for erosion/dilation + """ erosion_ratio = self.config["erosion"] / 100 mask_radius = np.sqrt(np.sum(mask)) / 2 kernel_size = max(1, int(abs(erosion_ratio * mask_radius))) - erosion_kernel = cv2.getStructuringElement( # pylint: disable=no-member - cv2.MORPH_ELLIPSE, # pylint: disable=no-member - (kernel_size, kernel_size)) + erosion_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) logger.trace("erosion_kernel shape: %s", erosion_kernel.shape) return erosion_kernel - - def blend(self, mask): - """ Blur mask if requested """ - logger.trace("Blending mask") - mask = BlurMask(self.config["type"], - mask, - self.config["radius"], - self.config["passes"]).blurred - return mask diff --git a/plugins/convert/mask/mask_blend_defaults.py b/plugins/convert/mask/mask_blend_defaults.py index deb2a4b687..618060e8ab 100755 --- a/plugins/convert/mask/mask_blend_defaults.py +++ b/plugins/convert/mask/mask_blend_defaults.py @@ -59,15 +59,15 @@ "gui_radio": True, "fixed": True, }, - "radius": { - "default": 3.0, - "info": "Radius dictates how much blending should occur.\nThis figure is set as a " - "percentage of the mask diameter to give the radius in pixels. Eg: for a mask " - "with diameter 200px, a percentage of 6% would give a final radius of 3px.\n" - "Higher percentage means more blending.", - "datatype": float, + "kernel_size": { + "default": 3, + "info": "The kernel size dictates how much blending should occur.\n" + "The size is the diameter of the kernel in pixels (calculated from a 128px mask). " + " This value should be odd, if an even number is passed in then it will be " + "rounded to the next odd number. Higher sizes means more blending.", + "datatype": int, "rounding": 1, - "min_max": (0.1, 25.0), + "min_max": (1, 9), "choices": [], "gui_radio": False, "group": "settings", @@ -87,6 +87,18 @@ "group": "settings", "fixed": True, }, + "threshold": { + "default": 4, + "info": "Sets pixels that are near white to white and near black to black. Set to 0 for " + "off.", + "datatype": int, + "rounding": 1, + "min_max": (0, 50), + "choices": [], + "gui_radio": False, + "group": "settings", + "fixed": True, + }, "erosion": { "default": 0.0, "info": "Erosion kernel size as a percentage of the mask radius area.\nPositive " diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 60b65c30f6..5a61edbade 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -1118,8 +1118,8 @@ def _get_masks(self, side, detected_faces): masks = dict() for fhash, face in detected_faces.items(): mask = face.mask[self._training_opts["mask_type"]] - mask.set_blur_kernel_and_threshold(blur_kernel=self._training_opts["mask_blur_kernel"], - threshold=self._training_opts["mask_threshold"]) + mask.set_blur_and_threshold(blur_kernel=self._training_opts["mask_blur_kernel"], + threshold=self._training_opts["mask_threshold"]) for filename in self._hash_to_filenames(side, fhash): masks[filename] = mask return masks diff --git a/scripts/convert.py b/scripts/convert.py index 3affd740d4..b9cbdff397 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -1,5 +1,5 @@ #!/usr/bin python3 -""" The script to run the convert process of faceswap """ +""" Main entry point to the convert process of FaceSwap """ import logging import re @@ -8,13 +8,13 @@ from threading import Event from time import sleep -from cv2 import imwrite # pylint:disable=no-name-in-module +import cv2 import numpy as np import tensorflow as tf from keras.backend.tensorflow_backend import set_session from tqdm import tqdm -from scripts.fsmedia import Alignments, Images, PostProcess, Utils +from scripts.fsmedia import Alignments, Images, PostProcess, finalize from lib.serializer import get_serializer from lib.convert import Converter from lib.faces_detect import DetectedFace @@ -29,37 +29,54 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name -class Convert(): - """ The convert process. """ +class Convert(): # pylint:disable=too-few-public-methods + """ The Faceswap Face Conversion Process. + + The conversion process is responsible for swapping the faces on source frames with the output + from a trained model. + + It leverages a series of user selected post-processing plugins, executed from + :class:`lib.convert.Converter`. + + The convert process is self contained and should not be referenced by any other scripts, so it + contains no public properties. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The arguments to be passed to the convert process as generated from Faceswap's command + line arguments + """ def __init__(self, arguments): logger.debug("Initializing %s: (args: %s)", self.__class__.__name__, arguments) - self.args = arguments - - self.patch_threads = None - self.images = Images(self.args) - self.validate() - self.alignments = Alignments(self.args, False, self.images.is_video) - self.opts = OptionalActions(self.args, self.images.input_images, self.alignments) - - self.add_queues() - self.disk_io = DiskIO(self.alignments, self.images, arguments) - self.predictor = Predict(self.disk_io.load_queue, self.queue_size, arguments) - - configfile = self.args.configfile if hasattr(self.args, "configfile") else None - self.converter = Converter(get_folder(self.args.output_dir), - self.predictor.output_size, - self.predictor.has_predicted_mask, - self.disk_io.draw_transparent, - self.disk_io.pre_encode, - arguments, - configfile=configfile) + self._args = arguments + + self._patch_threads = None + self._images = Images(self._args) + self._alignments = Alignments(self._args, False, self._images.is_video) + + self._opts = OptionalActions(self._args, self._images.input_images, self._alignments) + + self._add_queues() + self._disk_io = DiskIO(self._alignments, self._images, arguments) + self._predictor = Predict(self._disk_io.load_queue, self._queue_size, arguments) + self._validate() + get_folder(self._args.output_dir) + + configfile = self._args.configfile if hasattr(self._args, "configfile") else None + self._converter = Converter(self._predictor.output_size, + self._predictor.coverage_ratio, + self._disk_io.draw_transparent, + self._disk_io.pre_encode, + arguments, + configfile=configfile) logger.debug("Initialized %s", self.__class__.__name__) @property - def queue_size(self): - """ Set 16 for single process otherwise 32 """ - if self.args.singleprocess: + def _queue_size(self): + """ int: Size of the converter queues. 16 for single process otherwise 32 """ + if self._args.singleprocess: retval = 16 else: retval = 32 @@ -67,47 +84,83 @@ def queue_size(self): return retval @property - def pool_processes(self): - """ return the maximum number of pooled processes to use """ - if self.args.singleprocess: + def _pool_processes(self): + """ int: The number of threads to run in parallel. Based on user options and number of + available processors. """ + if self._args.singleprocess: retval = 1 - elif self.args.jobs > 0: - retval = min(self.args.jobs, total_cpus(), self.images.images_found) + elif self._args.jobs > 0: + retval = min(self._args.jobs, total_cpus(), self._images.images_found) else: - retval = min(total_cpus(), self.images.images_found) + retval = min(total_cpus(), self._images.images_found) retval = 1 if retval == 0 else retval logger.debug(retval) return retval - def validate(self): - """ Make the output folder if it doesn't exist and check that video flag is - a valid choice """ - if (self.args.writer == "ffmpeg" and - not self.images.is_video and - self.args.reference_video is None): + def _validate(self): + """ Validate the Command Line Options. + + Ensure that certain cli selections are valid and won't result in an error. Checks: + * If frames have been passed in with video output, ensure user supplies reference + video. + * If a mask-type is selected, ensure it exists in the alignments file. + * If a predicted mask-type is selected, ensure model has been trained with a mask + otherwise attempt to select first available masks, otherwise raise error. + + Raises + ------ + FaceswapError + If an invalid selection has been found. + + """ + if (self._args.writer == "ffmpeg" and + not self._images.is_video and + self._args.reference_video is None): raise FaceswapError("Output as video selected, but using frames as input. You must " "provide a reference video ('-ref', '--reference-video').") - output_dir = get_folder(self.args.output_dir) - logger.info("Output Directory: %s", output_dir) - - def add_queues(self): - """ Add the queues for convert """ - logger.debug("Adding queues. Queue size: %s", self.queue_size) + if (self._args.mask_type not in ("none", "predicted") and + not self._alignments.mask_is_valid(self._args.mask_type)): + msg = ("You have selected the Mask Type `{}` but at least one face does not have this " + "mask stored in the Alignments File.\nYou should generate the required masks " + "with the Mask Tool or set the Mask Type option to an existing Mask Type.\nA " + "summary of existing masks is as follows:\nTotal faces: {}, Masks: " + "{}".format(self._args.mask_type, self._alignments.faces_count, + self._alignments.mask_summary)) + raise FaceswapError(msg) + if self._args.mask_type == "predicted" and not self._predictor.has_predicted_mask: + available_masks = [k for k, v in self._alignments.mask_summary.items() + if k != "none" and v == self._alignments.faces_count] + if not available_masks: + msg = ("Predicted Mask selected, but the model was not trained with a mask and no " + "masks are stored in the Alignments File.\nYou should generate the " + "required masks with the Mask Tool or set the Mask Type to `none`.") + raise FaceswapError(msg) + mask_type = available_masks[0] + logger.warning("Predicted Mask selected, but the model was not trained with a " + "mask. Selecting first available mask: '%s'", mask_type) + self._args.mask_type = mask_type + + def _add_queues(self): + """ Add the queues for in, patch and out. """ + logger.debug("Adding queues. Queue size: %s", self._queue_size) for qname in ("convert_in", "convert_out", "patch"): - queue_manager.add_queue(qname, self.queue_size) + queue_manager.add_queue(qname, self._queue_size) def process(self): - """ Process the conversion """ + """ The entry point for triggering the Conversion Process. + + Should only be called from :class:`lib.cli.ScriptExecutor` + """ logger.debug("Starting Conversion") # queue_manager.debug_monitor(5) try: - self.convert_images() - self.disk_io.save_thread.join() + self._convert_images() + self._disk_io.save_thread.join() queue_manager.terminate_queues() - Utils.finalize(self.images.images_found, - self.predictor.faces_count, - self.predictor.verify_output) + finalize(self._images.images_found, + self._predictor.faces_count, + self._predictor.verify_output) logger.debug("Completed Conversion") except MemoryError as err: msg = ("Faceswap ran out of RAM running convert. Conversion is very system RAM " @@ -117,119 +170,171 @@ def process(self): "'singleprocess' flag (-sp) or lowering the number of parallel jobs (-j).") raise FaceswapError(msg) from err - def convert_images(self): - """ Convert the images """ + def _convert_images(self): + """ Start the multi-threaded patching process, monitor all threads for errors and join on + completion. """ logger.debug("Converting images") save_queue = queue_manager.get_queue("convert_out") patch_queue = queue_manager.get_queue("patch") - self.patch_threads = MultiThread(self.converter.process, patch_queue, save_queue, - thread_count=self.pool_processes, name="patch") + self._patch_threads = MultiThread(self._converter.process, patch_queue, save_queue, + thread_count=self._pool_processes, name="patch") - self.patch_threads.start() + self._patch_threads.start() while True: - self.check_thread_error() - if self.disk_io.completion_event.is_set(): + self._check_thread_error() + if self._disk_io.completion_event.is_set(): logger.debug("DiskIO completion event set. Joining Pool") break - if self.patch_threads.completed(): + if self._patch_threads.completed(): logger.debug("All patch threads completed") break sleep(1) - self.patch_threads.join() + self._patch_threads.join() logger.debug("Putting EOF") save_queue.put("EOF") logger.debug("Converted images") - def check_thread_error(self): - """ Check and raise thread errors """ - for thread in (self.predictor.thread, - self.disk_io.load_thread, - self.disk_io.save_thread, - self.patch_threads): + def _check_thread_error(self): + """ Monitor all running threads for errors, and raise accordingly. """ + for thread in (self._predictor.thread, + self._disk_io.load_thread, + self._disk_io.save_thread, + self._patch_threads): thread.check_and_raise_error() class DiskIO(): - """ Background threads to: - Load images from disk and get the detected faces - Save images back to disk """ + """ Disk Input/Output for the converter process. + + Background threads to: + * Load images from disk and get the detected faces + * Save images back to disk + + Parameters + ---------- + alignments: :class:`lib.alignmnents.Alignments` + The alignments for the input video + images: :class:`scripts.fsmedia.Images` + The input images + arguments: :class:`argparse.Namespace` + The arguments that were passed to the convert process as generated from Faceswap's command + line arguments + """ + def __init__(self, alignments, images, arguments): logger.debug("Initializing %s: (alignments: %s, images: %s, arguments: %s)", self.__class__.__name__, alignments, images, arguments) - self.alignments = alignments - self.images = images - self.args = arguments - self.pre_process = PostProcess(arguments) - self.completion_event = Event() + self._alignments = alignments + self._images = images + self._args = arguments + self._pre_process = PostProcess(arguments) + self._completion_event = Event() # For frame skipping - self.imageidxre = re.compile(r"(\d+)(?!.*\d\.)(?=\.\w+$)") - self.frame_ranges = self.get_frame_ranges() - self.writer = self.get_writer() + self._imageidxre = re.compile(r"(\d+)(?!.*\d\.)(?=\.\w+$)") + self._frame_ranges = self._get_frame_ranges() + self._writer = self._get_writer() # Extractor for on the fly detection - self.extractor = self.load_extractor() + self._extractor = self._load_extractor() - self.load_queue = None - self.save_queue = None - self.load_thread = None - self.save_thread = None - self.init_threads() + self._queues = dict(load=None, save=None) + self._threads = dict(oad=None, save=None) + self._init_threads() logger.debug("Initialized %s", self.__class__.__name__) + @property + def completion_event(self): + """ :class:`event.Event`: Event is set when the DiskIO Save task is complete """ + return self._completion_event + @property def draw_transparent(self): - """ Draw transparent is an image writer only parameter. - Return the value here for easy access for predictor """ - return self.writer.config.get("draw_transparent", False) + """ bool: ``True`` if the selected writer's Draw_transparent configuration item is set + otherwise ``False`` """ + return self._writer.config.get("draw_transparent", False) @property def pre_encode(self): - """ Return the writer's pre-encoder """ + """ python function: Selected writer's pre-encode function, if it has one, + otherwise ``None`` """ dummy = np.zeros((20, 20, 3), dtype="uint8") - test = self.writer.pre_encode(dummy) - retval = None if test is None else self.writer.pre_encode + test = self._writer.pre_encode(dummy) + retval = None if test is None else self._writer.pre_encode logger.debug("Writer pre_encode function: %s", retval) return retval @property - def total_count(self): - """ Return the total number of frames to be converted """ - if self.frame_ranges and not self.args.keep_unchanged: - retval = sum([fr[1] - fr[0] + 1 for fr in self.frame_ranges]) + def save_thread(self): + """ :class:`lib.multithreading.MultiThread`: The thread that is running the image writing + operation. """ + return self._threads["save"] + + @property + def load_thread(self): + """ :class:`lib.multithreading.MultiThread`: The thread that is running the image loading + operation. """ + return self._threads["load"] + + @property + def load_queue(self): + """ :class:`queue.Queue()`: The queue that images and detected faces are loaded into. """ + return self._queues["load"] + + @property + def _total_count(self): + """ int: The total number of frames to be converted """ + if self._frame_ranges and not self._args.keep_unchanged: + retval = sum([fr[1] - fr[0] + 1 for fr in self._frame_ranges]) else: - retval = self.images.images_found + retval = self._images.images_found logger.debug(retval) return retval # Initialization - def get_writer(self): - """ Return the writer plugin """ - args = [self.args.output_dir] - if self.args.writer in ("ffmpeg", "gif"): - args.extend([self.total_count, self.frame_ranges]) - if self.args.writer == "ffmpeg": - if self.images.is_video: - args.append(self.args.input_dir) + def _get_writer(self): + """ Load the selected writer plugin. + + Returns + ------- + :mod:`plugins.convert.writer` plugin + The requested writer plugin + """ + args = [self._args.output_dir] + if self._args.writer in ("ffmpeg", "gif"): + args.extend([self._total_count, self._frame_ranges]) + if self._args.writer == "ffmpeg": + if self._images.is_video: + args.append(self._args.input_dir) else: - args.append(self.args.reference_video) + args.append(self._args.reference_video) logger.debug("Writer args: %s", args) - configfile = self.args.configfile if hasattr(self.args, "configfile") else None - return PluginLoader.get_converter("writer", self.args.writer)(*args, configfile=configfile) - - def get_frame_ranges(self): - """ split out the frame ranges and parse out 'min' and 'max' values """ - if not self.args.frame_ranges: + configfile = self._args.configfile if hasattr(self._args, "configfile") else None + return PluginLoader.get_converter("writer", self._args.writer)(*args, + configfile=configfile) + + def _get_frame_ranges(self): + """ Obtain the frame ranges that are to be converted. + + If frame ranges have been specified, then split the command line formatted arguments into + ranges that can be used. + + Returns + list or ``None`` + A list of frames to be processed, or ``None`` if the command line argument was not + used + """ + if not self._args.frame_ranges: logger.debug("No frame range set") return None minframe, maxframe = None, None - if self.images.is_video: - minframe, maxframe = 1, self.images.images_found + if self._images.is_video: + minframe, maxframe = 1, self._images.images_found else: - indices = [int(self.imageidxre.findall(os.path.basename(filename))[0]) - for filename in self.images.input_images] + indices = [int(self._imageidxre.findall(os.path.basename(filename))[0]) + for filename in self._images.input_images] if indices: minframe, maxframe = min(indices), max(indices) logger.debug("minframe: %s, maxframe: %s", minframe, maxframe) @@ -239,7 +344,7 @@ def get_frame_ranges(self): "from filenames") retval = list() - for rng in self.args.frame_ranges: + for rng in self._args.frame_ranges: if "-" not in rng: raise FaceswapError("Frame Ranges not specified in the correct format") start, end = rng.split("-") @@ -247,16 +352,35 @@ def get_frame_ranges(self): logger.debug("frame ranges: %s", retval) return retval - def load_extractor(self): - """ Set on the fly extraction """ - if self.alignments.have_alignments_file: + def _load_extractor(self): + """ Load the CV2-DNN Face Extractor Chain. + + For On-The-Fly conversion we use a CPU based extractor to avoid stacking the GPU. + Results are poor. + + Returns + ------- + :class:`plugins.extract.Pipeline.Extractor` + The face extraction chain to be used for on-the-fly conversion + """ + if not self._alignments.have_alignments_file and not self._args.on_the_fly: + logger.error("No alignments file found. Please provide an alignments file for your " + "destination video (recommended) or enable on-the-fly conversion (not " + "recommended).") + sys.exit(1) + if self._alignments.have_alignments_file: + if self._args.on_the_fly: + logger.info("On-The-Fly conversion selected, but an alignments file was found. " + "Using pre-existing alignments file: '%s'", self._alignments.file) + else: + logger.debug("Alignments file found: '%s'", self._alignments.file) return None logger.debug("Loading extractor") - logger.warning("No Alignments file found. Extracting on the fly.") - logger.warning("NB: This will use the inferior cv2-dnn for extraction " - "and landmarks. It is recommended to perfom Extract first for " - "superior results") + logger.warning("On-The-Fly conversion selected. This will use the inferior cv2-dnn for " + "extraction and will produce poor results.") + logger.warning("It is recommended to generate an alignments file for your destination " + "video with Extract first for superior results.") extractor = Extractor(detector="cv2-dnn", aligner="cv2-dnn", masker="none", @@ -267,16 +391,25 @@ def load_extractor(self): logger.debug("Loaded extractor") return extractor - def init_threads(self): - """ Initialize queues and threads """ + def _init_threads(self): + """ Initialize queues and threads. + + Creates the load and save queues and the load and save threads. Starts the threads. + """ logger.debug("Initializing DiskIO Threads") for task in ("load", "save"): - self.add_queue(task) - self.start_thread(task) + self._add_queue(task) + self._start_thread(task) logger.debug("Initialized DiskIO Threads") - def add_queue(self, task): - """ Add the queue to queue_manager and set queue attribute """ + def _add_queue(self, task): + """ Add the queue to queue_manager and to :attr:`self._queues` for the given task. + + Parameters + ---------- + task: {"load", "save"} + The task that the queue is to be added for + """ logger.debug("Adding queue for task: '%s'", task) if task == "load": q_name = "convert_in" @@ -284,83 +417,135 @@ def add_queue(self, task): q_name = "convert_out" else: q_name = task - setattr(self, - "{}_queue".format(task), - queue_manager.get_queue(q_name)) + self._queues[task] = queue_manager.get_queue(q_name) logger.debug("Added queue for task: '%s'", task) - def start_thread(self, task): - """ Start the DiskIO thread """ + def _start_thread(self, task): + """ Create the thread for the given task, add it it :attr:`self._threads` and start it. + + Parameters + ---------- + task: {"load", "save"} + The task that the thread is to be created for + """ logger.debug("Starting thread: '%s'", task) - args = self.completion_event if task == "save" else None - func = getattr(self, task) + args = self._completion_event if task == "save" else None + func = getattr(self, "_{}".format(task)) io_thread = MultiThread(func, args, thread_count=1) io_thread.start() - setattr(self, "{}_thread".format(task), io_thread) + self._threads[task] = io_thread logger.debug("Started thread: '%s'", task) # Loading tasks - def load(self, *args): # pylint: disable=unused-argument - """ Load the images with detected_faces""" + def _load(self, *args): # pylint: disable=unused-argument + """ Load frames from disk. + + In a background thread: + * Loads frames from disk. + * Discards or passes through cli selected skipped frames + * Pairs the frame with its :class:`~lib.faces_detect.DetectedFace` objects + * Performs any pre-processing actions + * Puts the frame and detected faces to the load queue + """ logger.debug("Load Images: Start") idx = 0 - for filename, image in self.images.load(): + for filename, image in self._images.load(): idx += 1 - if self.load_queue.shutdown.is_set(): + if self._queues["load"].shutdown.is_set(): logger.debug("Load Queue: Stop signal received. Terminating") break if image is None or (not image.any() and image.ndim not in (2, 3)): # All black frames will return not numpy.any() so check dims too logger.warning("Unable to open image. Skipping: '%s'", filename) continue - if self.check_skipframe(filename): - if self.args.keep_unchanged: + if self._check_skipframe(filename): + if self._args.keep_unchanged: logger.trace("Saving unchanged frame: %s", filename) - out_file = os.path.join(self.args.output_dir, os.path.basename(filename)) - self.save_queue.put((out_file, image)) + out_file = os.path.join(self._args.output_dir, os.path.basename(filename)) + self._queues["save"].put((out_file, image)) else: logger.trace("Discarding frame: '%s'", filename) continue - detected_faces = self.get_detected_faces(filename, image) + detected_faces = self._get_detected_faces(filename, image) item = dict(filename=filename, image=image, detected_faces=detected_faces) - self.pre_process.do_actions(item) - self.load_queue.put(item) + self._pre_process.do_actions(item) + self._queues["load"].put(item) logger.debug("Putting EOF") - self.load_queue.put("EOF") + self._queues["load"].put("EOF") logger.debug("Load Images: Complete") - def check_skipframe(self, filename): - """ Check whether frame is to be skipped """ - if not self.frame_ranges: + def _check_skipframe(self, filename): + """ Check whether a frame is to be skipped. + + Parameters + ---------- + filename: str + The filename of the frame to check + + Returns + ------- + bool + ``True`` if the frame is to be skipped otherwise ``False`` + """ + if not self._frame_ranges: return None - indices = self.imageidxre.findall(filename) + indices = self._imageidxre.findall(filename) if not indices: logger.warning("Could not determine frame number. Frame will be converted: '%s'", filename) return False idx = int(indices[0]) if indices else None - skipframe = not any(map(lambda b: b[0] <= idx <= b[1], self.frame_ranges)) + skipframe = not any(map(lambda b: b[0] <= idx <= b[1], self._frame_ranges)) logger.trace("idx: %s, skipframe: %s", idx, skipframe) return skipframe - def get_detected_faces(self, filename, image): - """ Return detected faces from alignments or detector """ + def _get_detected_faces(self, filename, image): + """ Return the detected faces for the given image. + + If we have an alignments file, then the detected faces are created from that file. If + we're running On-The-Fly then they will be extracted from the extractor. + + Parameters + ---------- + filename: str + The filename to return the detected faces for + image: :class:`numpy.ndarray` + The frame that the detected faces exist in + + Returns + ------- + list + List of :class:`lib.faces_detect.DetectedFace` objects + """ logger.trace("Getting faces for: '%s'", filename) - if not self.extractor: - detected_faces = self.alignments_faces(os.path.basename(filename), image) + if not self._extractor: + detected_faces = self._alignments_faces(os.path.basename(filename), image) else: - detected_faces = self.detect_faces(filename, image) + detected_faces = self._detect_faces(filename, image) logger.trace("Got %s faces for: '%s'", len(detected_faces), filename) return detected_faces - def alignments_faces(self, frame, image): - """ Get the face from alignments file """ - if not self.check_alignments(frame): + def _alignments_faces(self, frame_name, image): + """ Return detected faces from an alignments file. + + Parameters + ---------- + frame_name: str + The name of the frame to return the detected faces for + image: :class:`numpy.ndarray` + The frame that the detected faces exist in + + Returns + ------- + list + List of :class:`lib.faces_detect.DetectedFace` objects + """ + if not self._check_alignments(frame_name): return list() - faces = self.alignments.get_faces_in_frame(frame) + faces = self._alignments.get_faces_in_frame(frame_name) detected_faces = list() for rawface in faces: @@ -369,34 +554,71 @@ def alignments_faces(self, frame, image): detected_faces.append(face) return detected_faces - def check_alignments(self, frame): - """ If we have no alignments for this image, skip it """ - have_alignments = self.alignments.frame_exists(frame) + def _check_alignments(self, frame_name): + """ Ensure that we have alignments for the current frame. + + If we have no alignments for this image, skip it and output a message. + + Parameters + ---------- + frame_name: str + The name of the frame to check that we have alignments for + + Returns + ------- + bool + ``True`` if we have alignments for this face, otherwise ``False`` + """ + have_alignments = self._alignments.frame_exists(frame_name) if not have_alignments: tqdm.write("No alignment found for {}, " - "skipping".format(frame)) + "skipping".format(frame_name)) return have_alignments - def detect_faces(self, filename, image): - """ Extract the face from a frame (If alignments file not found) """ - self.extractor.input_queue.put(ExtractMedia(filename, image)) - faces = next(self.extractor.detected_faces()) + def _detect_faces(self, filename, image): + """ Extract the face from a frame for On-The-Fly conversion. + + Pulls detected faces out of the Extraction pipeline. + + Parameters + ---------- + filename: str + The filename to return the detected faces for + image: :class:`numpy.ndarray` + The frame that the detected faces exist in + + Returns + ------- + list + List of :class:`lib.faces_detect.DetectedFace` objects + """ + self._extractor.input_queue.put(ExtractMedia(filename, image)) + faces = next(self._extractor.detected_faces()) final_faces = [face for face in faces.detected_faces] return final_faces # Saving tasks - def save(self, completion_event): - """ Save the converted images """ + def _save(self, completion_event): + """ Save the converted images. + + Puts the selected writer into a background thread and feeds it from the output of the + patch queue. + + Parameters + ---------- + completion_event: :class:`event.Event` + An even that this process triggers when it has finished saving + """ logger.debug("Save Images: Start") - write_preview = self.args.redirect_gui and self.writer.is_stream - preview_image = os.path.join(self.writer.output_folder, ".gui_preview.jpg") + write_preview = self._args.redirect_gui and self._writer.is_stream + preview_image = os.path.join(self._writer.output_folder, ".gui_preview.jpg") logger.debug("Write preview for gui: %s", write_preview) - for idx in tqdm(range(self.total_count), desc="Converting", file=sys.stdout): - if self.save_queue.shutdown.is_set(): + for idx in tqdm(range(self._total_count), desc="Converting", file=sys.stdout): + if self._queues["save"].shutdown.is_set(): logger.debug("Save Queue: Stop signal received. Terminating") break - item = self.save_queue.get() + item = self._queues["save"].get() if item == "EOF": logger.debug("EOF Received") break @@ -404,68 +626,111 @@ def save(self, completion_event): # Write out preview image for the GUI every 10 frames if writing to stream if write_preview and idx % 10 == 0 and not os.path.exists(preview_image): logger.debug("Writing GUI Preview image: '%s'", preview_image) - imwrite(preview_image, image) - self.writer.write(filename, image) - self.writer.close() + cv2.imwrite(preview_image, image) + self._writer.write(filename, image) + self._writer.close() completion_event.set() logger.debug("Save Faces: Complete") class Predict(): - """ Predict faces from incoming queue """ + """ Obtains the output from the Faceswap model. + + Parameters + ---------- + in_queue: :class:`queue.Queue` + The queue that contains images and detected faces for feeding the model + queue_size: int + The maximum size of the input queue + arguments: :class:`argparse.Namespace` + The arguments that were passed to the convert process as generated from Faceswap's command + line arguments + """ def __init__(self, in_queue, queue_size, arguments): logger.debug("Initializing %s: (args: %s, queue_size: %s, in_queue: %s)", self.__class__.__name__, arguments, queue_size, in_queue) - self.batchsize = self.get_batchsize(queue_size) - self.args = arguments - self.in_queue = in_queue - self.out_queue = queue_manager.get_queue("patch") - self.serializer = get_serializer("json") - self.faces_count = 0 - self.verify_output = False + self._batchsize = self._get_batchsize(queue_size) + self._args = arguments + self._in_queue = in_queue + self._out_queue = queue_manager.get_queue("patch") + self._serializer = get_serializer("json") + self._faces_count = 0 + self._verify_output = False if arguments.allow_growth: - self.set_tf_allow_growth() + self._set_tf_allow_growth() + + self._model = self._load_model() + self._output_indices = {"face": self._model.largest_face_index, + "mask": self._model.largest_mask_index} + self._predictor = self._model.converter(self._args.swap_model) + self._thread = self._launch_predictor() + logger.debug("Initialized %s: (out_queue: %s)", self.__class__.__name__, self._out_queue) - self.model = self.load_model() - self.output_indices = {"face": self.model.largest_face_index, - "mask": self.model.largest_mask_index} - self.predictor = self.model.converter(self.args.swap_model) - self.queues = dict() + @property + def thread(self): + """ :class:`~lib.multithreading.MultiThread`: The thread that is running the prediction + function from the Faceswap model. """ + return self._thread + + @property + def in_queue(self): + """ :class:`queue.Queue`: The input queue to the predictor. """ + return self._in_queue + + @property + def out_queue(self): + """ :class:`queue.Queue`: The output queue from the predictor. """ + return self._out_queue + + @property + def faces_count(self): + """ int: The total number of faces seen by the Predictor. """ + return self._faces_count - self.thread = MultiThread(self.predict_faces, thread_count=1) - self.thread.start() - logger.debug("Initialized %s: (out_queue: %s)", self.__class__.__name__, self.out_queue) + @property + def verify_output(self): + """ bool: ``True`` if multiple faces have been found in frames, otherwise ``False``. """ + return self._verify_output @property def coverage_ratio(self): - """ Return coverage ratio from training options """ - return self.model.training_opts["coverage_ratio"] + """ float: The coverage ratio that the model was trained at. """ + return self._model.training_opts["coverage_ratio"] @property - def input_size(self): - """ Return the model input size """ - return self.model.input_shape[0] + def has_predicted_mask(self): + """ bool: ``True`` if the model was trained to learn a mask, otherwise ``False``. """ + return bool(self._model.state.config.get("learn_mask", False)) @property def output_size(self): - """ Return the model output size """ - return self.model.output_shape[0] + """ int: The size in pixels of the Faceswap model output. """ + return self._model.output_shape[0] @property - def input_mask(self): - """ Return the input mask """ - mask = np.zeros((1, ) + self.model.state.mask_shapes[0], dtype="float32") - return mask + def _input_size(self): + """ int: The size in pixels of the Faceswap model input. """ + return self._model.input_shape[0] @property - def has_predicted_mask(self): - """ Return whether this model has a predicted mask """ - return bool(self.model.state.config.get("learn_mask", False)) + def _input_mask(self): + """ :class:`numpy.ndarray`: A dummy mask for inputting to the model. """ + mask = np.zeros((1, ) + self._model.state.mask_shapes[0], dtype="float32") + return mask @staticmethod - def get_batchsize(queue_size): - """ Get the batchsize """ + def _get_batchsize(queue_size): + """ Get the batch size for feeding the model. + + Sets the batch size to 1 if inference is being run on CPU, otherwise the minimum of the + :attr:`self._queue_size` and 16. + + Returns + ------- + int + The batch size that the model is to be fed at. + """ logger.debug("Getting batchsize") is_cpu = GPUStats().device_count == 0 batchsize = 1 if is_cpu else 16 @@ -475,10 +740,12 @@ def get_batchsize(queue_size): return batchsize @staticmethod - def set_tf_allow_growth(): - """ Allow TensorFlow to manage VRAM growth """ + def _set_tf_allow_growth(): + """ Enables the TensorFlow configuration option "allow_growth". + + TODO Move this temporary fix somewhere more appropriate + """ # pylint: disable=no-member - # TODO Move this temporary fix somewhere more appropriate logger.debug("Setting Tensorflow 'allow_growth' option") config = tf.ConfigProto() config.gpu_options.allow_growth = True @@ -486,23 +753,44 @@ def set_tf_allow_growth(): set_session(tf.Session(config=config)) logger.debug("Set Tensorflow 'allow_growth' option") - def load_model(self): - """ Load the model requested for conversion """ + def _load_model(self): + """ Load the Faceswap model. + + Returns + ------- + :mod:`plugins.train.model` plugin + The trained model in the specified model folder + """ logger.debug("Loading Model") - model_dir = get_folder(self.args.model_dir, make_folder=False) + model_dir = get_folder(self._args.model_dir, make_folder=False) if not model_dir: - raise FaceswapError("{} does not exist.".format(self.args.model_dir)) - trainer = self.get_trainer(model_dir) - gpus = 1 if not hasattr(self.args, "gpus") else self.args.gpus + raise FaceswapError("{} does not exist.".format(self._args.model_dir)) + trainer = self._get_model_name(model_dir) + gpus = 1 if not hasattr(self._args, "gpus") else self._args.gpus model = PluginLoader.get_model(trainer)(model_dir, gpus, predict=True) logger.debug("Loaded Model") return model - def get_trainer(self, model_dir): - """ Return the trainer name if provided, or read from state file """ - if hasattr(self.args, "trainer") and self.args.trainer: - logger.debug("Trainer name provided: '%s'", self.args.trainer) - return self.args.trainer + def _get_model_name(self, model_dir): + """ Return the name of the Faceswap model used. + + If a "trainer" option has been selected in the command line arguments, use that value, + otherwise retrieve the name of the model from the model's state file. + + Parameters + ---------- + model_dir: str + The folder that contains the trained Faceswap model + + Returns + ------- + str + The name of the Faceswap model being used. + + """ + if hasattr(self._args, "trainer") and self._args.trainer: + logger.debug("Trainer name provided: '%s'", self._args.trainer) + return self._args.trainer statefile = [fname for fname in os.listdir(str(model_dir)) if fname.endswith("_state.json")] @@ -512,7 +800,7 @@ def get_trainer(self, model_dir): "option.".format(len(statefile))) statefile = os.path.join(str(model_dir), statefile[0]) - state = self.serializer.load(statefile) + state = self._serializer.load(statefile) trainer = state.get("name", None) if not trainer: @@ -521,26 +809,44 @@ def get_trainer(self, model_dir): logger.debug("Trainer from state file: '%s'", trainer) return trainer - def predict_faces(self): - """ Get detected faces from images """ + def _launch_predictor(self): + """ Launch the prediction process in a background thread. + + Starts the prediction thread and returns the thread. + + Returns + ------- + :class:`~lib.multithreading.MultiThread` + The started Faceswap model prediction thread. + """ + thread = MultiThread(self._predict_faces, thread_count=1) + thread.start() + return thread + + def _predict_faces(self): + """ Run Prediction on the Faceswap model in a background thread. + + Reads from the :attr:`self._in_queue`, prepares images for prediction + then puts the predictions back to the :attr:`self.out_queue` + """ faces_seen = 0 consecutive_no_faces = 0 batch = list() is_plaidml = GPUStats().is_plaidml while True: - item = self.in_queue.get() + item = self._in_queue.get() if item != "EOF": logger.trace("Got from queue: '%s'", item["filename"]) faces_count = len(item["detected_faces"]) # Safety measure. If a large stream of frames appear that do not have faces, # these will stack up into RAM. Keep a count of consecutive frames with no faces. - # If self.batchsize number of frames appear, force the current batch through + # If self._batchsize number of frames appear, force the current batch through # to clear RAM. consecutive_no_faces = consecutive_no_faces + 1 if faces_count == 0 else 0 - self.faces_count += faces_count + self._faces_count += faces_count if faces_count > 1: - self.verify_output = True + self._verify_output = True logger.verbose("Found more than one face in an image! '%s'", os.path.basename(item["filename"])) @@ -549,8 +855,8 @@ def predict_faces(self): faces_seen += faces_count batch.append(item) - if item != "EOF" and (faces_seen < self.batchsize and - consecutive_no_faces < self.batchsize): + if item != "EOF" and (faces_seen < self._batchsize and + consecutive_no_faces < self._batchsize): logger.trace("Continuing. Current batchsize: %s, consecutive_no_faces: %s", faces_seen, consecutive_no_faces) continue @@ -561,16 +867,16 @@ def predict_faces(self): detected_batch = [detected_face for item in batch for detected_face in item["detected_faces"]] if faces_seen != 0: - feed_faces = self.compile_feed_faces(detected_batch) + feed_faces = self._compile_feed_faces(detected_batch) batch_size = None - if is_plaidml and feed_faces.shape[0] != self.batchsize: + if is_plaidml and feed_faces.shape[0] != self._batchsize: logger.verbose("Fallback to BS=1") batch_size = 1 - predicted = self.predict(feed_faces, batch_size) + predicted = self._predict(feed_faces, batch_size) else: predicted = list() - self.queue_out_frames(batch, predicted) + self._queue_out_frames(batch, predicted) consecutive_no_faces = 0 faces_seen = 0 @@ -579,18 +885,28 @@ def predict_faces(self): logger.debug("EOF Received") break logger.debug("Putting EOF") - self.out_queue.put("EOF") + self._out_queue.put("EOF") logger.debug("Load queue complete") def load_aligned(self, item): - """ Load the feed faces and reference output faces """ + """ Load the model's feed faces and the reference output faces. + + For each detected face in the incoming item, load the feed face and reference face + images, correctly sized for input and output respectively. + + Parameters + ---------- + item: dict + The incoming image and list of :class:`~lib.faces_detect.DetectedFace` objects + + """ logger.trace("Loading aligned faces: '%s'", item["filename"]) for detected_face in item["detected_faces"]: detected_face.load_feed_face(item["image"], - size=self.input_size, + size=self._input_size, coverage_ratio=self.coverage_ratio, dtype="float32") - if self.input_size == self.output_size: + if self._input_size == self.output_size: detected_face.reference = detected_face.feed else: detected_face.load_reference_face(item["image"], @@ -600,27 +916,52 @@ def load_aligned(self, item): logger.trace("Loaded aligned faces: '%s'", item["filename"]) @staticmethod - def compile_feed_faces(detected_faces): - """ Compile the faces for feeding into the predictor """ + def _compile_feed_faces(detected_faces): + """ Compile a batch of faces for feeding into the Predictor. + + Parameters + ---------- + detected_faces: list + List of `~lib.faces_detect.DetectedFace` objects + + Returns + ------- + :class:`numpy.ndarray` + A batch of faces ready for feeding into the Faceswap model. + """ logger.trace("Compiling feed face. Batchsize: %s", len(detected_faces)) feed_faces = np.stack([detected_face.feed_face[..., :3] for detected_face in detected_faces]) / 255.0 logger.trace("Compiled Feed faces. Shape: %s", feed_faces.shape) return feed_faces - def predict(self, feed_faces, batch_size=None): - """ Perform inference on the feed """ + def _predict(self, feed_faces, batch_size=None): + """ Run the Faceswap models' prediction function. + + Parameters + ---------- + feed_faces: :class:`numpy.ndarray` + The batch to be fed into the model + batch_size: int, optional + Used for plaidml only. Indicates to the model what batch size is being processed. + Default: ``None`` + + Returns + ------- + :class:`numpy.ndarray` + The swapped faces for the given batch + """ logger.trace("Predicting: Batchsize: %s", len(feed_faces)) feed = [feed_faces] - if self.model.feed_mask: - feed.append(np.repeat(self.input_mask, feed_faces.shape[0], axis=0)) + if self._model.feed_mask: + feed.append(np.repeat(self._input_mask, feed_faces.shape[0], axis=0)) logger.trace("Input shape(s): %s", [item.shape for item in feed]) - predicted = self.predictor(feed, batch_size=batch_size) + predicted = self._predictor(feed, batch_size=batch_size) predicted = predicted if isinstance(predicted, list) else [predicted] logger.trace("Output shape(s): %s", [predict.shape for predict in predicted]) - predicted = self.filter_multi_out(predicted) + predicted = self._filter_multi_out(predicted) # Compile masks into alpha channel or keep raw faces predicted = np.concatenate(predicted, axis=-1) if len(predicted) == 2 else predicted[0] @@ -629,19 +970,44 @@ def predict(self, feed_faces, batch_size=None): logger.trace("Final shape: %s", predicted.shape) return predicted - def filter_multi_out(self, predicted): - """ Filter the predicted output to the final output """ + def _filter_multi_out(self, predicted): + """ Filter the model output to just the required image. + + Some models have multi-scale outputs, so just make sure we take the largest + output. + + Parameters + ---------- + predicted: :class:`numpy.ndarray` + The predictions retrieved from the Faceswap model. + + Returns + ------- + :class:`numpy.ndarray` + The predictions with any superfluous outputs removed. + """ if not predicted: return predicted - face = predicted[self.output_indices["face"]] - mask_idx = self.output_indices["mask"] + face = predicted[self._output_indices["face"]] + mask_idx = self._output_indices["mask"] mask = predicted[mask_idx] if mask_idx is not None else None predicted = [face, mask] if mask is not None else [face] logger.trace("Filtered output shape(s): %s", [predict.shape for predict in predicted]) return predicted - def queue_out_frames(self, batch, swapped_faces): - """ Compile the batch back to original frames and put to out_queue """ + def _queue_out_frames(self, batch, swapped_faces): + """ Compile the batch back to original frames and put to the Out Queue. + + For batching, faces are split away from their frames. This compiles all detected faces + back to their parent frame before putting each frame to the out queue in batches. + + Parameters + ---------- + batch: dict + The batch that was used as the input for the model predict function + swapped_faces: :class:`numpy.ndarray` + The predictions returned from the model's predict function + """ logger.trace("Queueing out batch. Batchsize: %s", len(batch)) pointer = 0 for item in batch: @@ -655,40 +1021,59 @@ def queue_out_frames(self, batch, swapped_faces): item["filename"], len(item["detected_faces"]), item["swapped_faces"].shape[0]) pointer += num_faces - self.out_queue.put(batch) + self._out_queue.put(batch) logger.trace("Queued out batch. Batchsize: %s", len(batch)) -class OptionalActions(): - """ Process the optional actions for convert """ +class OptionalActions(): # pylint:disable=too-few-public-methods + """ Process specific optional actions for Convert. + + Currently only handles skip faces. This class should probably be (re)moved. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The arguments that were passed to the convert process as generated from Faceswap's command + line arguments + input_images: list + List of input image files + alignments: :class:`lib.alignments.Alignments` + The alignments file for this conversion + """ - def __init__(self, args, input_images, alignments): + def __init__(self, arguments, input_images, alignments): logger.debug("Initializing %s", self.__class__.__name__) - self.args = args - self.input_images = input_images - self.alignments = alignments + self._args = arguments + self._input_images = input_images + self._alignments = alignments - self.remove_skipped_faces() + self._remove_skipped_faces() logger.debug("Initialized %s", self.__class__.__name__) # SKIP FACES # - def remove_skipped_faces(self): - """ Remove deleted faces from the loaded alignments """ + def _remove_skipped_faces(self): + """ If the user has specified an input aligned directory, remove any non-matching faces + from the alignments file. """ logger.debug("Filtering Faces") - face_hashes = self.get_face_hashes() + face_hashes = self._get_face_hashes() if not face_hashes: logger.debug("No face hashes. Not skipping any faces") return - pre_face_count = self.alignments.faces_count - self.alignments.filter_hashes(face_hashes, filter_out=False) - logger.info("Faces filtered out: %s", pre_face_count - self.alignments.faces_count) - - def get_face_hashes(self): - """ Check for the existence of an aligned directory for identifying - which faces in the target frames should be swapped. - If it exists, obtain the hashes of the faces in the folder """ + pre_face_count = self._alignments.faces_count + self._alignments.filter_hashes(face_hashes, filter_out=False) + logger.info("Faces filtered out: %s", pre_face_count - self._alignments.faces_count) + + def _get_face_hashes(self): + """ Check for the existence of an aligned directory for identifying which faces in the + target frames should be swapped. + + Returns + ------- + list + A list of face hashes that exist in the given input aligned directory. + """ face_hashes = list() - input_aligned_dir = self.args.input_aligned_dir + input_aligned_dir = self._args.input_aligned_dir if input_aligned_dir is None: logger.verbose("Aligned directory not specified. All faces listed in the " @@ -704,7 +1089,7 @@ def get_face_hashes(self): logger.debug("Face Hashes: %s", (len(face_hashes))) if not face_hashes: raise FaceswapError("Aligned directory is empty, no faces will be converted!") - if len(face_hashes) <= len(self.input_images) / 3: + if len(face_hashes) <= len(self._input_images) / 3: logger.warning("Aligned directory contains far fewer images than the input " "directory, are you sure this is the right folder?") return face_hashes diff --git a/scripts/extract.py b/scripts/extract.py index ce2a049d68..f7490f8e4b 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -11,13 +11,13 @@ from lib.multithreading import MultiThread from lib.utils import get_folder from plugins.extract.pipeline import Extractor, ExtractMedia -from scripts.fsmedia import Alignments, PostProcess, Utils +from scripts.fsmedia import Alignments, PostProcess, finalize tqdm.monitor_interval = 0 # workaround for TqdmSynchronisationWarning logger = logging.getLogger(__name__) # pylint: disable=invalid-name -class Extract(): +class Extract(): # pylint:disable=too-few-public-methods """ The Faceswap Face Extraction Process. The extraction process is responsible for detecting faces in a series of images/video, aligning @@ -115,9 +115,9 @@ def process(self): for thread in self._threads: thread.join() self._alignments.save() - Utils.finalize(self._images.process_count + self._existing_count, - self._alignments.faces_count, - self._verify_output) + finalize(self._images.process_count + self._existing_count, + self._alignments.faces_count, + self._verify_output) def _threaded_redirector(self, task, io_args=None): """ Redirect image input/output tasks to relevant queues in background thread diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 27da35ddb1..eb28633cd7 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 -""" Holds the classes for the 3 main Faceswap 'media' objects for - input (extract) and output (convert) tasks. Those being: - Images - Faces - Alignments""" +""" Helper functions for :mod:`~scripts.extract` and :mod:`~scripts.convert`. + +Holds the classes for the 2 main Faceswap 'media' objects: Images and Alignments. + +Holds optional pre/post processing functions for convert and extract. +""" import logging import os +import sys from pathlib import Path import cv2 @@ -20,68 +22,112 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name -class Utils(): - """ Holds utility functions that are required by more than one media - object """ - - @staticmethod - def finalize(images_found, num_faces_detected, verify_output): - """ Finalize the image processing """ +def finalize(images_found, num_faces_detected, verify_output): + """ Output summary statistics at the end of the extract or convert processes. + + Parameters + ---------- + images_found: int + The number of images/frames that were processed + num_faces_detected: int + The number of faces that have been detected + verify_output: bool + ``True`` if multiple faces were detected in frames otherwise ``False``. + """ + logger.info("-------------------------") + logger.info("Images found: %s", images_found) + logger.info("Faces detected: %s", num_faces_detected) + logger.info("-------------------------") + + if verify_output: + logger.info("Note:") + logger.info("Multiple faces were detected in one or more pictures.") + logger.info("Double check your results.") logger.info("-------------------------") - logger.info("Images found: %s", images_found) - logger.info("Faces detected: %s", num_faces_detected) - logger.info("-------------------------") - - if verify_output: - logger.info("Note:") - logger.info("Multiple faces were detected in one or more pictures.") - logger.info("Double check your results.") - logger.info("-------------------------") - logger.info("Process Succesfully Completed. Shutting Down...") + logger.info("Process Succesfully Completed. Shutting Down...") class Alignments(AlignmentsBase): - """ Override main alignments class for extract """ + """ Override :class:`lib.alignments.Alignments` to add custom loading based on command + line arguments. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The command line arguments that were passed to Faceswap + is_extract: bool + ``True`` if the process calling this class is extraction otherwise ``False`` + input_is_video: bool, optional + ``True`` if the input to the process is a video, ``False`` if it is a folder of images. + Default: False + """ def __init__(self, arguments, is_extract, input_is_video=False): logger.debug("Initializing %s: (is_extract: %s, input_is_video: %s)", self.__class__.__name__, is_extract, input_is_video) - self.args = arguments - self.is_extract = is_extract - folder, filename = self.set_folder_filename(input_is_video) - super().__init__(folder, - filename=filename) + self._args = arguments + self._is_extract = is_extract + folder, filename = self._set_folder_filename(input_is_video) + super().__init__(folder, filename=filename) logger.debug("Initialized %s", self.__class__.__name__) - def set_folder_filename(self, input_is_video): - """ Return the folder for the alignments file""" - if self.args.alignments_path: - logger.debug("Alignments File provided: '%s'", self.args.alignments_path) - folder, filename = os.path.split(str(self.args.alignments_path)) + def _set_folder_filename(self, input_is_video): + """ Return the folder and the filename for the alignments file. + + If the input is a video, the alignments file will be stored in the same folder + as the video, with filename `_alignments`. + + If the input is a folder of images, the alignments file will be stored in folder with + the images and just be called 'alignments' + + Parameters + ---------- + input_is_video: bool, optional + ``True`` if the input to the process is a video, ``False`` if it is a folder of images. + + Returns + ------- + folder: str + The folder where the alignments file will be stored + filename: str + The filename of the alignments file + """ + if self._args.alignments_path: + logger.debug("Alignments File provided: '%s'", self._args.alignments_path) + folder, filename = os.path.split(str(self._args.alignments_path)) elif input_is_video: - logger.debug("Alignments from Video File: '%s'", self.args.input_dir) - folder, filename = os.path.split(self.args.input_dir) + logger.debug("Alignments from Video File: '%s'", self._args.input_dir) + folder, filename = os.path.split(self._args.input_dir) filename = "{}_alignments".format(os.path.splitext(filename)[0]) else: - logger.debug("Alignments from Input Folder: '%s'", self.args.input_dir) - folder = str(self.args.input_dir) + logger.debug("Alignments from Input Folder: '%s'", self._args.input_dir) + folder = str(self._args.input_dir) filename = "alignments" logger.debug("Setting Alignments: (folder: '%s' filename: '%s')", folder, filename) return folder, filename def load(self): - """ Override parent loader to handle skip existing on extract """ + """ Override the parent :func:`~lib.alignments.Alignments.load` to handle skip existing + frames and faces on extract. + + If skip existing has been selected, existing alignments are loaded and returned to the + calling script. + + Returns + ------- + dict + Any alignments that have already been extracted if skip existing has been selected + otherwise an empty dictionary + """ data = dict() - if not self.is_extract: + if not self._is_extract: if not self.have_alignments_file: return data data = super().load() return data - skip_existing = bool(hasattr(self.args, 'skip_existing') - and self.args.skip_existing) - skip_faces = bool(hasattr(self.args, 'skip_faces') - and self.args.skip_faces) + skip_existing = hasattr(self._args, 'skip_existing') and self._args.skip_existing + skip_faces = hasattr(self._args, 'skip_faces') and self._args.skip_faces if not skip_existing and not skip_faces: logger.debug("No skipping selected. Returning empty dictionary") @@ -106,66 +152,131 @@ def load(self): class Images(): - """ Holds the full frames/images """ + """ Handles the loading of frames from a folder of images or a video file for extract + and convert processes. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The command line arguments that were passed to Faceswap + """ def __init__(self, arguments): logger.debug("Initializing %s", self.__class__.__name__) - self.args = arguments - self.is_video = self.check_input_folder() - self.input_images = self.get_input_images() - self.images_found = self.count_images() + self._args = arguments + self._is_video = self._check_input_folder() + self._input_images = self._get_input_images() + self._images_found = self._count_images() logger.debug("Initialized %s", self.__class__.__name__) - def count_images(self): - """ Number of images or frames """ - if self.is_video: - retval = int(count_frames(self.args.input_dir, fast=True)) + @property + def is_video(self): + """bool: ``True`` if the input is a video file otherwise ``False``. """ + return self._is_video + + @property + def input_images(self): + """str or list: Path to the video file if the input is a video otherwise list of + image paths. """ + return self._input_images + + @property + def images_found(self): + """int: The number of frames that exist in the video file, or the folder of images. """ + return self._images_found + + def _count_images(self): + """ Get the number of Frames from a video file or folder of images. + + Returns + ------- + int + The number of frames in the image source + """ + if self._is_video: + retval = int(count_frames(self._args.input_dir, fast=True)) else: - retval = len(self.input_images) + retval = len(self._input_images) return retval - def check_input_folder(self): - """ Check whether the input is a folder or video """ - if not os.path.exists(self.args.input_dir): - logger.error("Input location %s not found.", self.args.input_dir) - exit(1) - if (os.path.isfile(self.args.input_dir) and - os.path.splitext(self.args.input_dir)[1].lower() in _video_extensions): - logger.info("Input Video: %s", self.args.input_dir) + def _check_input_folder(self): + """ Check whether the input is a folder or video. + + Returns + ------- + bool + ``True`` if the input is a video otherwise ``False`` + """ + if not os.path.exists(self._args.input_dir): + logger.error("Input location %s not found.", self._args.input_dir) + sys.exit(1) + if (os.path.isfile(self._args.input_dir) and + os.path.splitext(self._args.input_dir)[1].lower() in _video_extensions): + logger.info("Input Video: %s", self._args.input_dir) retval = True else: - logger.info("Input Directory: %s", self.args.input_dir) + logger.info("Input Directory: %s", self._args.input_dir) retval = False return retval - def get_input_images(self): - """ Return the list of images or video file that is to be processed """ - if self.is_video: - input_images = self.args.input_dir + def _get_input_images(self): + """ Return the list of images or path to video file that is to be processed. + + Returns + ------- + str or list + Path to the video file if the input is a video otherwise list of image paths. + """ + if self._is_video: + input_images = self._args.input_dir else: - input_images = get_image_paths(self.args.input_dir) + input_images = get_image_paths(self._args.input_dir) return input_images def load(self): - """ Load an image and yield it with it's filename """ - iterator = self.load_video_frames if self.is_video else self.load_disk_frames + """ Generator to load frames from a folder of images or from a video file. + + Yields + ------ + filename: str + The filename of the current frame + image: :class:`numpy.ndarray` + A single frame + """ + iterator = self._load_video_frames if self._is_video else self._load_disk_frames for filename, image in iterator(): yield filename, image - def load_disk_frames(self): - """ Load frames from disk """ + def _load_disk_frames(self): + """ Generator to load frames from a folder of images. + + Yields + ------ + filename: str + The filename of the current frame + image: :class:`numpy.ndarray` + A single frame + """ logger.debug("Input is separate Frames. Loading images") - for filename in self.input_images: + for filename in self._input_images: image = read_image(filename, raise_error=False) if image is None: continue yield filename, image - def load_video_frames(self): - """ Return frames from a video file """ + def _load_video_frames(self): + """ Generator to load frames from a video file. + + Yields + ------ + filename: str + The filename of the current frame + image: :class:`numpy.ndarray` + A single frame + """ logger.debug("Input is video. Capturing frames") - vidname = os.path.splitext(os.path.basename(self.args.input_dir))[0] - reader = imageio.get_reader(self.args.input_dir, "ffmpeg") + vidname = os.path.splitext(os.path.basename(self._args.input_dir))[0] + reader = imageio.get_reader(self._args.input_dir, "ffmpeg") for i, frame in enumerate(reader): # Convert to BGR for cv2 compatibility frame = frame[:, :, ::-1] @@ -175,40 +286,78 @@ def load_video_frames(self): reader.close() def load_one_image(self, filename): - """ load requested image """ + """ Obtain a single image for the given filename. + + Parameters + ---------- + filename: str + The filename to return the image for + + Returns + ------ + :class:`numpy.ndarray` + The image for the requested filename, + + """ logger.trace("Loading image: '%s'", filename) - if self.is_video: + if self._is_video: if filename.isdigit(): frame_no = filename else: frame_no = os.path.splitext(filename)[0][filename.rfind("_") + 1:] logger.trace("Extracted frame_no %s from filename '%s'", frame_no, filename) - retval = self.load_one_video_frame(int(frame_no)) + retval = self._load_one_video_frame(int(frame_no)) else: retval = read_image(filename, raise_error=True) return retval - def load_one_video_frame(self, frame_no): - """ Load a single frame from a video file """ + def _load_one_video_frame(self, frame_no): + """ Obtain a single frame from a video file. + + Parameters + ---------- + frame_no: int + The frame index for the required frame + + Returns + ------ + :class:`numpy.ndarray` + The image for the requested frame index, + """ logger.trace("Loading video frame: %s", frame_no) - reader = imageio.get_reader(self.args.input_dir, "ffmpeg") + reader = imageio.get_reader(self._args.input_dir, "ffmpeg") reader.set_image_index(frame_no - 1) frame = reader.get_next_data()[:, :, ::-1] reader.close() return frame -class PostProcess(): - """ Optional post processing tasks """ +class PostProcess(): # pylint:disable=too-few-public-methods + """ Optional pre/post processing tasks for convert and extract. + + Builds a pipeline of actions that have optionally been requested to be performed + in this session. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The command line arguments that were passed to Faceswap + """ def __init__(self, arguments): logger.debug("Initializing %s", self.__class__.__name__) - self.args = arguments - self.actions = self.set_actions() + self._args = arguments + self._actions = self._set_actions() logger.debug("Initialized %s", self.__class__.__name__) - def set_actions(self): - """ Compile the actions to be performed into a list """ - postprocess_items = self.get_items() + def _set_actions(self): + """ Compile the requested actions to be performed into a list + + Returns + ------- + list + The list of :class:`PostProcessAction` to be performed + """ + postprocess_items = self._get_items() actions = list() for action, options in postprocess_items.items(): options = dict() if options is None else options @@ -227,35 +376,45 @@ def set_actions(self): return actions - def get_items(self): - """ Set the post processing actions """ + def _get_items(self): + """ Check the passed in command line arguments for requested actions, + + For any requested actions, add the item to the actions list along with + any relevant arguments and keyword arguments. + + Returns + ------- + dict + The name of the action to be performed as the key. Any action specific + arguments and keyword arguments as the value. + """ postprocess_items = dict() # Debug Landmarks - if (hasattr(self.args, 'debug_landmarks') and self.args.debug_landmarks): + if (hasattr(self._args, 'debug_landmarks') and self._args.debug_landmarks): postprocess_items["DebugLandmarks"] = None # Face Filter post processing - if ((hasattr(self.args, "filter") and self.args.filter is not None) or - (hasattr(self.args, "nfilter") and - self.args.nfilter is not None)): + if ((hasattr(self._args, "filter") and self._args.filter is not None) or + (hasattr(self._args, "nfilter") and + self._args.nfilter is not None)): - if hasattr(self.args, "detector"): - detector = self.args.detector.replace("-", "_").lower() + if hasattr(self._args, "detector"): + detector = self._args.detector.replace("-", "_").lower() else: detector = "cv2_dnn" - if hasattr(self.args, "aligner"): - aligner = self.args.aligner.replace("-", "_").lower() + if hasattr(self._args, "aligner"): + aligner = self._args.aligner.replace("-", "_").lower() else: aligner = "cv2_dnn" face_filter = dict(detector=detector, aligner=aligner, - multiprocess=not self.args.singleprocess) + multiprocess=not self._args.singleprocess) filter_lists = dict() - if hasattr(self.args, "ref_threshold"): - face_filter["ref_threshold"] = self.args.ref_threshold + if hasattr(self._args, "ref_threshold"): + face_filter["ref_threshold"] = self._args.ref_threshold for filter_type in ('filter', 'nfilter'): - filter_args = getattr(self.args, filter_type, None) + filter_args = getattr(self._args, filter_type, None) filter_args = None if not filter_args else filter_args filter_lists[filter_type] = filter_args face_filter["filter_lists"] = filter_lists @@ -265,32 +424,79 @@ def get_items(self): return postprocess_items def do_actions(self, extract_media): - """ Perform the requested post-processing actions """ - for action in self.actions: + """ Perform the requested optional post-processing actions on the given image. + + Parameters + ---------- + extract_media: :class:`~plugins.extract.pipeline.ExtractMedia` + The :class:`~plugins.extract.pipeline.ExtractMedia` object to perform the + action on. + + Returns + ------- + :class:`~plugins.extract.pipeline.ExtractMedia` + The original :class:`~plugins.extract.pipeline.ExtractMedia` with any actions applied + """ + for action in self._actions: logger.debug("Performing postprocess action: '%s'", action.__class__.__name__) action.process(extract_media) class PostProcessAction(): # pylint: disable=too-few-public-methods - """ Parent class for Post Processing Actions. Usable in Extract or Convert or both - depending on context """ + """ Parent class for Post Processing Actions. + + Usable in Extract or Convert or both depending on context. Any post-processing actions should + inherit from this class. + + Parameters + ----------- + args: tuple + Varies for specific post process action + kwargs: dict + Varies for specific post process action + """ def __init__(self, *args, **kwargs): logger.debug("Initializing %s: (args: %s, kwargs: %s)", self.__class__.__name__, args, kwargs) - self.valid = True # Set to False if invalid parameters passed in to disable + self._valid = True # Set to False if invalid parameters passed in to disable logger.debug("Initialized base class %s", self.__class__.__name__) + @property + def valid(self): + """bool: ``True`` if the action if the parameters passed in for this action are valid, + otherwise ``False`` """ + return self._valid + def process(self, extract_media): - """ Override for specific post processing action """ + """ Override for specific post processing action + + Parameters + ---------- + extract_media: :class:`~plugins.extract.pipeline.ExtractMedia` + The :class:`~plugins.extract.pipeline.ExtractMedia` object to perform the + action on. + """ raise NotImplementedError class DebugLandmarks(PostProcessAction): # pylint: disable=too-few-public-methods - """ Draw debug landmarks on face - Extract Only """ + """ Draw debug landmarks on face output. Extract Only """ def process(self, extract_media): - """ Draw landmarks on image """ + """ Draw landmarks on a face. + + Parameters + ---------- + extract_media: :class:`~plugins.extract.pipeline.ExtractMedia` + The :class:`~plugins.extract.pipeline.ExtractMedia` object that contains the faces to + draw the landmarks on to + + Returns + ------- + :class:`~plugins.extract.pipeline.ExtractMedia` + The original :class:`~plugins.extract.pipeline.ExtractMedia` with landmarks drawn + onto the face + """ frame = os.path.splitext(os.path.basename(extract_media.filename))[0] for idx, face in enumerate(extract_media.detected_faces): logger.trace("Drawing Landmarks. Frame: '%s'. Face: %s", frame, idx) @@ -300,23 +506,59 @@ def process(self, extract_media): class FaceFilter(PostProcessAction): - """ Filter in or out faces based on input image(s) - Extract or Convert """ + """ Filter in or out faces based on input image(s). Extract or Convert + + Parameters + ----------- + args: tuple + Unused + kwargs: dict + Keyword arguments for face filter: + + * **detector** (`str`) - The detector to use + + * **aligner** (`str`) - The aligner to use + + * **multiprocess** (`bool`) - Whether to run the extraction pipeline in single process \ + mode or not + + * **ref_threshold** (`float`) - The reference threshold for a positive match + + * **filter_lists** (`dict`) - The filter and nfilter image paths + """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.info("Extracting and aligning face for Face Filter...") - self.filter = self.load_face_filter(**kwargs) + self._filter = self._load_face_filter(**kwargs) logger.debug("Initialized %s", self.__class__.__name__) - def load_face_filter(self, filter_lists, ref_threshold, aligner, detector, - multiprocess): - """ Load faces to filter out of images """ + def _load_face_filter(self, filter_lists, ref_threshold, aligner, detector, multiprocess): + """ Set up and load the :class:`~lib.face_filter.FaceFilter`. + + Parameters + ---------- + filter_lists: dict + The filter and nfilter image paths + ref_threshold: float + The reference threshold for a positive match + aligner: str + The aligner to use + detector: str + The detector to use + multiprocess: bool + Whether to run the extraction pipeline in single process mode or not + + Returns + ------- + :class:`~lib.face_filter.FaceFilter` + The face filter + """ if not any(val for val in filter_lists.values()): return None facefilter = None - filter_files = [self.set_face_filter(f_type, filter_lists[f_type]) + filter_files = [self._set_face_filter(f_type, filter_lists[f_type]) for f_type in ("filter", "nfilter")] if any(filters for filters in filter_files): @@ -332,8 +574,21 @@ def load_face_filter(self, filter_lists, ref_threshold, aligner, detector, return facefilter @staticmethod - def set_face_filter(f_type, f_args): - """ Set the required filters """ + def _set_face_filter(f_type, f_args): + """ Check filter files exist and add the filter file paths to a list. + + Parameters + ---------- + f_type: {"filter", "nfilter"} + The type of filter to create this list for + f_args: str or list + The filter image(s) to use + + Returns + ------- + list + The confirmed existing paths to filter files to use + """ if not f_args: return list() @@ -347,14 +602,27 @@ def set_face_filter(f_type, f_args): return filter_files def process(self, extract_media): - """ Filter in/out wanted/unwanted faces """ - if not self.filter: + """ Filters in or out any wanted or unwanted faces based on command line arguments. + + Parameters + ---------- + extract_media: :class:`~plugins.extract.pipeline.ExtractMedia` + The :class:`~plugins.extract.pipeline.ExtractMedia` object to perform the + face filtering on. + + Returns + ------- + :class:`~plugins.extract.pipeline.ExtractMedia` + The original :class:`~plugins.extract.pipeline.ExtractMedia` with any requested filters + applied + """ + if not self._filter: return ret_faces = list() for idx, detect_face in enumerate(extract_media.detected_faces): check_item = detect_face["face"] if isinstance(detect_face, dict) else detect_face check_item.load_aligned(extract_media.image) - if not self.filter.check(check_item): + if not self._filter.check(check_item): logger.verbose("Skipping not recognized face: (Frame: %s Face %s)", extract_media.filename, idx) continue diff --git a/tools/mask.py b/tools/mask.py index b276a67424..b866cb4e26 100644 --- a/tools/mask.py +++ b/tools/mask.py @@ -21,7 +21,7 @@ class Mask(): """ This tool is part of the Faceswap Tools suite and should be called from - ``python tools.py mask``. + ``python tools.py mask`` command. Faceswap Masks tool. Generate masks from existing alignments files, and output masks for preview. @@ -375,7 +375,7 @@ def _create_image(self, detected_face): - The masked face """ mask = detected_face.mask[self._mask_type] - mask.set_blur_kernel_and_threshold(**self._output_opts) + 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 = detected_face.image diff --git a/tools/preview.py b/tools/preview.py index 2c25ba75c2..7040732e8b 100644 --- a/tools/preview.py +++ b/tools/preview.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Tool to preview swaps and tweak the config prior to running a convert """ +""" Tool to preview swaps and tweak configuration prior to running a convert """ import logging import random @@ -16,13 +16,11 @@ from lib.aligner import Extract as AlignerExtract from lib.cli import ConvertArgs -from lib.gui.custom_widgets import ContextMenu from lib.gui.utils import get_images, initialize_config, initialize_images from lib.gui.custom_widgets import Tooltip -from lib.gui.control_helper import set_slider_rounding +from lib.gui.control_helper import ControlPanel, ControlPanelOption from lib.convert import Converter from lib.faces_detect import DetectedFace -from lib.model.masks import get_available_masks from lib.multithreading import MultiThread from lib.utils import FaceswapError from lib.queue_manager import queue_manager @@ -35,159 +33,236 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name -class Preview(): - """ Loads up 5 semi-random face swaps and displays them, cropped, in place in the final frame. - Allows user to live tweak settings, before saving the final config to - ./config/convert.ini """ +class Preview(tk.Tk): # pylint:disable=too-few-public-methods + """ This tool is part of the Faceswap Tools suite and should be called from + ``python tools.py preview`` command. + + Loads up 5 semi-random face swaps and displays them, cropped, in place in the final frame. + Allows user to live tweak settings, before saving the final config to + :file:`./config/convert.ini` + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + """ def __init__(self, arguments): logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) - self.config_tools = ConfigTools() - self.lock = Lock() - self.trigger_patch = Event() - - self.root = tk.Tk() - self.scaling = self.get_scaling() + super().__init__() + self._config_tools = ConfigTools() + self._lock = Lock() + self._scaling = self._get_scaling() - self.tk_vars = dict(refresh=tk.BooleanVar(), busy=tk.BooleanVar()) - for val in self.tk_vars.values(): + self._tk_vars = dict(refresh=tk.BooleanVar(), busy=tk.BooleanVar()) + for val in self._tk_vars.values(): val.set(False) - self.display = FacesDisplay(256, 64, self.tk_vars) - self.samples = Samples(arguments, 5, self.display, self.lock, self.trigger_patch) - self.patch = Patch(arguments, - self.samples, - self.display, - self.lock, - self.trigger_patch, - self.config_tools, - self.tk_vars) - - self.initialize_tkinter() - self.image_canvas = None - self.opts_book = None - self.cli_frame = None # cli frame holds cli options + self._display = FacesDisplay(256, 64, self._tk_vars) + + trigger_patch = Event() + self._samples = Samples(arguments, 5, self._display, self._lock, trigger_patch) + self._patch = Patch(arguments, + self._samples, + self._display, + self._lock, + trigger_patch, + self._config_tools, + self._tk_vars) + + self._initialize_tkinter() + self._image_canvas = None + self._opts_book = None + self._cli_frame = None # cli frame holds cli options logger.debug("Initialized %s", self.__class__.__name__) - def initialize_tkinter(self): - """ Initialize tkinter for standalone or GUI """ + def _initialize_tkinter(self): + """ Initialize a standalone tkinter instance. """ logger.debug("Initializing tkinter") - initialize_config(self.root, None, None, None) + initialize_config(self, None, None, None) initialize_images() - self.set_geometry() - self.root.title("Faceswap.py - Convert Settings") - self.root.tk.call( + self._set_geometry() + self.title("Faceswap.py - Convert Settings") + self.tk.call( "wm", "iconphoto", - self.root._w, get_images().icons["favicon"]) # pylint:disable=protected-access + self._w, get_images().icons["favicon"]) # pylint:disable=protected-access logger.debug("Initialized tkinter") - def get_scaling(self): - """ Get dpi and update scaling for the display """ - dpi = self.root.winfo_fpixels("1i") + def _get_scaling(self): + """ Get dpi and update scaling for the display. + + Returns + ------- + float: The scaling factor for display + """ + dpi = self.winfo_fpixels("1i") scaling = dpi / 72.0 logger.debug("dpi: %s, scaling: %s'", dpi, scaling) return scaling - def set_geometry(self): - """ Set GUI geometry """ - self.root.tk.call("tk", "scaling", self.scaling) - width = int(940 * self.scaling) - height = int(600 * self.scaling) + def _set_geometry(self): + """ Set the GUI window geometry. """ + self.tk.call("tk", "scaling", self._scaling) + width = int(940 * self._scaling) + height = int(600 * self._scaling) logger.debug("Geometry: %sx%s", width, height) - self.root.geometry("{}x{}+80+80".format(str(width), str(height))) + self.geometry("{}x{}+80+80".format(str(width), str(height))) def process(self): - """ The preview process """ - self.build_ui() - self.root.mainloop() + """ The entry point for the Preview tool from :file:`lib.tools.cli`. + + Launch the tkinter preview Window and run main loop. + """ + self._build_ui() + self.mainloop() - def refresh(self, *args): - """ Refresh the display """ + def _refresh(self, *args): + """ Load new faces to display in preview. + + Parameters + ---------- + *args: tuple + Unused, but required for tkinter callback. + """ logger.trace("Refreshing swapped faces. args: %s", args) - self.tk_vars["busy"].set(True) - self.config_tools.update_config() - with self.lock: - self.patch.converter_arguments = self.cli_frame.convert_args - self.patch.current_config = self.config_tools.config - self.patch.trigger.set() + self._tk_vars["busy"].set(True) + self._config_tools.update_config() + with self._lock: + self._patch.converter_arguments = self._cli_frame.convert_args + self._patch.current_config = self._config_tools.config + self._patch.trigger.set() logger.trace("Refreshed swapped faces") - def build_ui(self): - """ Build the UI elements for displaying preview and options """ - container = tk.PanedWindow(self.root, + def _build_ui(self): + """ Build the elements for displaying preview images and options panels. """ + container = tk.PanedWindow(self, sashrelief=tk.RIDGE, sashwidth=4, sashpad=8, orient=tk.VERTICAL) container.pack(fill=tk.BOTH, expand=True) - container.preview_display = self.display - self.image_canvas = ImagesCanvas(container, self.tk_vars) - container.add(self.image_canvas, height=400 * self.scaling) + container.preview_display = self._display + self._image_canvas = ImagesCanvas(container, self._tk_vars) + container.add(self._image_canvas, height=400 * self._scaling) options_frame = ttk.Frame(container) - self.cli_frame = ActionFrame(options_frame, - self.patch.converter.args.color_adjustment.replace("-", "_"), - self.patch.converter.args.mask_type.replace("-", "_"), - self.patch.converter.args.scaling.replace("-", "_"), - self.config_tools, - self.refresh, - self.samples.generate, - self.tk_vars) - self.opts_book = OptionsBook(options_frame, self.config_tools, self.refresh, self.scaling) + self._cli_frame = ActionFrame( + options_frame, + list(self._samples.alignments.mask_summary.keys()), + self._samples.predictor.has_predicted_mask, + self._patch.converter.cli_arguments.color_adjustment.replace("-", "_"), + self._patch.converter.cli_arguments.mask_type.replace("-", "_"), + self._patch.converter.cli_arguments.scaling.replace("-", "_"), + self._config_tools, + self._refresh, + self._samples.generate, + self._tk_vars) + self._opts_book = OptionsBook(options_frame, + self._config_tools, + self._refresh, + self._scaling) container.add(options_frame) class Samples(): - """ Holds 5 random test faces """ + """ The display samples. + + Obtains and holds :attr:`sample_size` semi random test faces for displaying in the + preview GUI. + + The file list is split into evenly sized groups of :attr:`sample_size`. When a display set is + generated, a random image from each of the groups is selected to provide an array of images + across the length of the video. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + sample_size: int + The number of samples to take from the input video/images + display: :class:`FacesDisplay` + The display section of the Preview GUI. + lock: :class:`threading.Lock` + A threading lock to prevent multiple GUI updates at the same time. + trigger_patch: :class:`threading.Event` + An event to indicate that a converter patch should be run + """ def __init__(self, arguments, sample_size, display, lock, trigger_patch): logger.debug("Initializing %s: (arguments: '%s', sample_size: %s, display: %s, lock: %s, " "trigger_patch: %s)", self.__class__.__name__, arguments, sample_size, display, lock, trigger_patch) - self.sample_size = sample_size - self.display = display - self.lock = lock - self.trigger_patch = trigger_patch - self.input_images = list() - self.predicted_images = list() - - self.images = Images(arguments) - self.alignments = Alignments(arguments, - is_extract=False, - input_is_video=self.images.is_video) - if not self.alignments.have_alignments_file: - logger.error("Alignments file not found at: '%s'", self.alignments.file) + self._sample_size = sample_size + self._display = display + self._lock = lock + self._trigger_patch = trigger_patch + self._input_images = list() + self._predicted_images = list() + + self._images = Images(arguments) + self._alignments = Alignments(arguments, + is_extract=False, + input_is_video=self._images.is_video) + if not self._alignments.have_alignments_file: + logger.error("Alignments file not found at: '%s'", self._alignments.file) exit(1) - self.filelist = self.get_filelist() - self.indices = self.get_indices() + self._filelist = self._get_filelist() + self._indices = self._get_indices() - self.predictor = Predict(queue_manager.get_queue("preview_predict_in"), - sample_size, - arguments) + self._predictor = Predict(queue_manager.get_queue("preview_predict_in"), + sample_size, + arguments) self.generate() logger.debug("Initialized %s", self.__class__.__name__) @property - def random_choice(self): - """ Return for random indices from the indices group """ - retval = [random.choice(indices) for indices in self.indices] + def sample_size(self): + """ int: The number of samples to take from the input video/images """ + return self._sample_size + + @property + def predicted_images(self): + """ list: The predicted faces output from the Faceswap model """ + return self._predicted_images + + @property + def alignments(self): + """ :class:`~lib.alignments.Alignments`: The alignments for the preview faces """ + return self._alignments + + @property + def predictor(self): + """ :class:`~scripts.convert.Predict`: The Predictor for the Faceswap model """ + return self._predictor + + @property + def _random_choice(self): + """ list: Random indices from the :attr:`_indices` group """ + retval = [random.choice(indices) for indices in self._indices] logger.debug(retval) return retval - def get_filelist(self): - """ Return a list of files, filtering out those frames which do not contain faces """ + def _get_filelist(self): + """ Get a list of files for the input, filtering out those frames which do + not contain faces. + + Returns + ------- + list + A list of filenames of frames that contain faces. + """ logger.debug("Filtering file list to frames with faces") - if self.images.is_video: - filelist = ["{}_{:06d}.png".format(os.path.splitext(self.images.input_images)[0], + if self._images.is_video: + filelist = ["{}_{:06d}.png".format(os.path.splitext(self._images.input_images)[0], frame_no) - for frame_no in range(1, self.images.images_found + 1)] + for frame_no in range(1, self._images.images_found + 1)] else: - filelist = self.images.input_images + filelist = self._images.input_images retval = [filename for filename in filelist - if self.alignments.frame_has_faces(os.path.basename(filename))] - logger.debug("Filtered out frames: %s", self.images.images_found - len(retval)) + if self._alignments.frame_has_faces(os.path.basename(filename))] + logger.debug("Filtered out frames: %s", self._images.images_found - len(retval)) try: assert retval except AssertionError as err: @@ -197,18 +272,26 @@ def get_filelist(self): raise FaceswapError(msg) from err return retval - def get_indices(self): - """ Returns a list of 'self.sample_size' evenly sized partition indices - pertaining to the filtered file list """ + def _get_indices(self): + """ Get indices for each sample group. + + Obtain :attr:`self.sample_size` evenly sized groups of indices + pertaining to the filtered :attr:`self._file_list` + + Returns + ------- + list + list of indices relating to the filtered file list, split into groups + """ # Remove start and end values to get a list divisible by self.sample_size - no_files = len(self.filelist) - crop = no_files % self.sample_size + no_files = len(self._filelist) + crop = no_files % self._sample_size top_tail = list(range(no_files))[ crop // 2:no_files - (crop - (crop // 2))] # Partition the indices size = len(top_tail) - retval = [top_tail[start:start + size // self.sample_size] - for start in range(0, size, size // self.sample_size)] + retval = [top_tail[start:start + size // self._sample_size] + for start in range(0, size, size // self._sample_size)] logger.debug("Indices pools: %s", ["{}: (start: {}, end: {}, size: {})".format(idx, min(pool), max(pool), @@ -217,87 +300,152 @@ def get_indices(self): return retval def generate(self): - """ Generate a random test set """ - self.load_frames() - self.predict() - self.trigger_patch.set() - - def load_frames(self): - """ Load a sample of random frames """ - self.input_images = list() - for selection in self.random_choice: - filename = os.path.basename(self.filelist[selection]) - image = self.images.load_one_image(self.filelist[selection]) + """ Generate a sample set. + + Selects :attr:`sample_size` random faces. Runs them through prediction to obtain the + swap, then trigger the patch event to run the faces through patching. + """ + self._load_frames() + self._predict() + self._trigger_patch.set() + + def _load_frames(self): + """ Load a sample of random frames. + + * Picks a random face from each indices group. + + * Takes the first face from the image (if there) are multiple faces. Adds the images to \ + :attr:`self._input_images`. + + * Sets :attr:`_display.source` to the input images and flags that the display should \ + be updated + """ + self._input_images = list() + for selection in self._random_choice: + filename = os.path.basename(self._filelist[selection]) + image = self._images.load_one_image(self._filelist[selection]) # Get first face only - face = self.alignments.get_faces_in_frame(filename)[0] + face = self._alignments.get_faces_in_frame(filename)[0] detected_face = DetectedFace() detected_face.from_alignment(face, image=image) - self.input_images.append({"filename": filename, - "image": image, - "detected_faces": [detected_face]}) - self.display.source = self.input_images - self.display.update_source = True - logger.debug("Selected frames: %s", [frame["filename"] for frame in self.input_images]) - - def predict(self): - """ Predict from the loaded frames """ - with self.lock: - self.predicted_images = list() - for frame in self.input_images: - self.predictor.in_queue.put(frame) + self._input_images.append({"filename": filename, + "image": image, + "detected_faces": [detected_face]}) + self._display.source = self._input_images + self._display.update_source = True + logger.debug("Selected frames: %s", [frame["filename"] for frame in self._input_images]) + + def _predict(self): + """ Predict from the loaded frames. + + With a threading lock (to prevent stacking), run the selected faces through the Faceswap + model predict function and add the output to :attr:`predicted` + """ + with self._lock: + self._predicted_images = list() + for frame in self._input_images: + self._predictor.in_queue.put(frame) idx = 0 - while idx < self.sample_size: - logger.debug("Predicting face %s of %s", idx + 1, self.sample_size) - items = self.predictor.out_queue.get() + while idx < self._sample_size: + logger.debug("Predicting face %s of %s", idx + 1, self._sample_size) + items = self._predictor.out_queue.get() if items == "EOF": logger.debug("Received EOF") break for item in items: - self.predicted_images.append(item) - logger.debug("Predicted face %s of %s", idx + 1, self.sample_size) + self._predicted_images.append(item) + logger.debug("Predicted face %s of %s", idx + 1, self._sample_size) idx += 1 logger.debug("Predicted faces") class Patch(): - """ The patch pipeline - To be run within it's own thread """ + """ The Patch pipeline + + Runs in it's own thread. Takes the output from the Faceswap model predictor and runs the faces + through the convert pipeline using the currently selected options. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + samples: :class:`Samples` + The Samples for display. + display: :class:`FacesDisplay` + The display section of the Preview GUI. + lock: :class:`threading.Lock` + A threading lock to prevent multiple GUI updates at the same time. + trigger: :class:`threading.Event` + An event to indicate that a converter patch should be run + config_tools: :class:`ConfigTools` + Tools for loading and saving configuration files + tk_vars: dict + Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` + + Attributes + ---------- + converter_arguments: dict + The currently selected converter command line arguments for the patch queue + current_config::class:`lib.config.FaceswapConfig` + The currently set configuration for the patch queue + """ def __init__(self, arguments, samples, display, lock, trigger, config_tools, tk_vars): logger.debug("Initializing %s: (arguments: '%s', samples: %s: display: %s, lock: %s," " trigger: %s, config_tools: %s, tk_vars %s)", self.__class__.__name__, arguments, samples, display, lock, trigger, config_tools, tk_vars) - self.samples = samples - self.queue_patch_in = queue_manager.get_queue("preview_patch_in") - self.display = display - self.lock = lock - self.trigger = trigger + self._samples = samples + self._queue_patch_in = queue_manager.get_queue("preview_patch_in") + self._display = display + self._lock = lock + self._trigger = trigger self.current_config = config_tools.config self.converter_arguments = None # Updated converter arguments dict configfile = arguments.configfile if hasattr(arguments, "configfile") else None - self.converter = Converter(output_dir=None, - output_size=self.samples.predictor.output_size, - output_has_mask=self.samples.predictor.has_predicted_mask, - draw_transparent=False, - pre_encode=None, - configfile=configfile, - arguments=self.generate_converter_arguments(arguments)) - - self.shutdown = Event() - - self.thread = MultiThread(self.process, - self.trigger, - self.shutdown, - self.queue_patch_in, - self.samples, - tk_vars, - thread_count=1, - name="patch_thread") - self.thread.start() + self._converter = Converter(output_size=self._samples.predictor.output_size, + coverage_ratio=self._samples.predictor.coverage_ratio, + draw_transparent=False, + pre_encode=None, + arguments=self._generate_converter_arguments(arguments), + configfile=configfile) + self._shutdown = Event() + + self._thread = MultiThread(self._process, + self._trigger, + self._shutdown, + self._queue_patch_in, + self._samples, + tk_vars, + thread_count=1, + name="patch_thread") + self._thread.start() + + @property + def trigger(self): + """ :class:`threading.Event`: The trigger to indicate that a patching run should + commence. """ + return self._trigger + + @property + def converter(self): + """ :class:`lib.convert.Converter`: The converter to use for patching the images. """ + return self._converter @staticmethod - def generate_converter_arguments(arguments): - """ Get the default converter arguments """ + def _generate_converter_arguments(arguments): + """ Add the default converter arguments to the initial arguments. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + + Returns + ---------- + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in with converter default + arguments added + """ converter_arguments = ConvertArgs(None, "convert").get_optional_arguments() for item in converter_arguments: value = item.get("default", None) @@ -313,8 +461,25 @@ def generate_converter_arguments(arguments): logger.debug(arguments) return arguments - def process(self, trigger_event, shutdown_event, patch_queue_in, samples, tk_vars): - """ Wait for event trigger and run when process when set """ + def _process(self, trigger_event, shutdown_event, patch_queue_in, samples, tk_vars): + """ The face patching process. + + Runs in a thread, and waits for an event to be set. Once triggered, runs a patching + cycle and sets the :class:`Display` destination images. + + Parameters + ---------- + trigger_event: :class:`threading.Event` + Set by parent process when a patching run should be executed + shutdown_event :class:`threading.Event` + Set by parent process if a shutdown has been requested + patch_queue_in: :class:`queue.Queue` + The input queue for the patching process + samples: :class:`Samples` + The Samples for display. + tk_vars: dict + Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` + """ patch_queue_out = queue_manager.get_queue("preview_patch_out") while True: trigger = trigger_event.wait(1) @@ -327,30 +492,38 @@ def process(self, trigger_event, shutdown_event, patch_queue_in, samples, tk_var trigger_event.clear() tk_vars["busy"].set(True) queue_manager.flush_queue("preview_patch_in") - self.feed_swapped_faces(patch_queue_in, samples) - with self.lock: - self.update_converter_arguments() - self.converter.reinitialize(config=self.current_config) - swapped = self.patch_faces(patch_queue_in, patch_queue_out, samples.sample_size) - with self.lock: - self.display.destination = swapped + self._feed_swapped_faces(patch_queue_in, samples) + with self._lock: + self._update_converter_arguments() + self._converter.reinitialize(config=self.current_config) + swapped = self._patch_faces(patch_queue_in, patch_queue_out, samples.sample_size) + with self._lock: + self._display.destination = swapped tk_vars["refresh"].set(True) tk_vars["busy"].set(False) - def update_converter_arguments(self): - """ Update the converter arguments """ + def _update_converter_arguments(self): + """ Update the converter arguments to the currently selected values. """ logger.debug("Updating Converter cli arguments") if self.converter_arguments is None: logger.debug("No arguments to update") return for key, val in self.converter_arguments.items(): logger.debug("Updating %s to %s", key, val) - setattr(self.converter.args, key, val) + setattr(self._converter.cli_arguments, key, val) logger.debug("Updated Converter cli arguments") @staticmethod - def feed_swapped_faces(patch_queue_in, samples): - """ Feed swapped faces to the converter and trigger a run """ + def _feed_swapped_faces(patch_queue_in, samples): + """ Feed swapped faces to the converter's in-queue. + + Parameters + ---------- + patch_queue_in: :class:`queue.Queue` + The input queue for the patching process + samples: :class:`Samples` + The Samples for display. + """ logger.trace("feeding swapped faces to converter") for item in samples.predicted_images: patch_queue_in.put(item) @@ -358,10 +531,25 @@ def feed_swapped_faces(patch_queue_in, samples): logger.trace("Putting EOF to converter") patch_queue_in.put("EOF") - def patch_faces(self, queue_in, queue_out, sample_size): - """ Patch faces """ + def _patch_faces(self, queue_in, queue_out, sample_size): + """ Patch faces. + + Run the convert process on the swapped faces and return the patched faces. + + patch_queue_in: :class:`queue.Queue` + The input queue for the patching process + queue_out: :class:`queue.Queue` + The output queue from the patching process + sample_size: int + The number of samples to be displayed + + Returns + ------- + list + The swapped faces patched with the selected convert settings + """ logger.trace("Patching faces") - self.converter.process(queue_in, queue_out) + self._converter.process(queue_in, queue_out) swapped = list() idx = 0 while idx < sample_size: @@ -375,14 +563,40 @@ def patch_faces(self, queue_in, queue_out, sample_size): class FacesDisplay(): - """ Compiled faces into a single image """ + """ Compiles the 2 rows of sample faces (original and swapped) into a single image + + Parameters + ---------- + size: int + The size of each individual face sample in pixels + padding: int + The amount of extra padding to apply to the outside of the face + tk_vars: dict + Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` + + Attributes + ---------- + update_source: bool + Flag to indicate that the source images for the preview have been updated, so the preview + should be recompiled. + source: list + The list of :class:`numpy.ndarray` source preview images for top row of display + destination: list + The list of :class:`numpy.ndarray` swapped and patched preview images for bottom row of + display + """ def __init__(self, size, padding, tk_vars): logger.trace("Initializing %s: (size: %s, padding: %s, tk_vars: %s)", self.__class__.__name__, size, padding, tk_vars) - self.size = size - self.display_dims = (1, 1) - self.tk_vars = tk_vars - self.padding = padding + self._size = size + self._display_dims = (1, 1) + self._tk_vars = tk_vars + self._padding = padding + + self._faces = dict() + self._faces_source = None + self._faces_dest = None + self._tk_image = None # Set from Samples self.update_source = False @@ -390,118 +604,152 @@ def __init__(self, size, padding, tk_vars): # Set from Patch self.destination = list() # Swapped + patched images - self.faces = dict() - self.faces_source = None - self.faces_dest = None - self.tk_image = None logger.trace("Initialized %s", self.__class__.__name__) @property - def total_columns(self): + def tk_image(self): + """ :class:`PIL.ImageTk.PhotoImage`: The compiled preview display in tkinter display + format """ + return self._tk_image + + @property + def _total_columns(self): """ Return the total number of images that are being displayed """ return len(self.source) + def set_display_dimensions(self, dimensions): + """ Adjust the size of the frame that will hold the preview samples. + + Parameters + ---------- + dimensions: tuple + The (`width`, `height`) of the frame that holds the preview + """ + self._display_dims = dimensions + def update_tk_image(self): - """ Return compiled images images in TK PIL format resized for frame """ + """ Build the full preview images and compile :attr:`tk_image` for display. """ logger.trace("Updating tk image") - self.build_faces_image() - img = np.vstack((self.faces_source, self.faces_dest)) - size = self.get_scale_size(img) + self._build_faces_image() + img = np.vstack((self._faces_source, self._faces_dest)) + size = self._get_scale_size(img) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = Image.fromarray(img) img = img.resize(size, Image.ANTIALIAS) - self.tk_image = ImageTk.PhotoImage(img) - self.tk_vars["refresh"].set(False) + self._tk_image = ImageTk.PhotoImage(img) + self._tk_vars["refresh"].set(False) logger.trace("Updated tk image") - def get_scale_size(self, image): - """ Return the scale and size for passed in display image """ - frameratio = float(self.display_dims[0]) / float(self.display_dims[1]) + def _get_scale_size(self, image): + """ Get the size that the full preview image should be resized to fit in the + display window. + + Parameters + ---------- + image: :class:`numpy.ndarray` + The full sized compiled preview image + + Returns + ------- + tuple + The (`width`, `height`) that the display image should be sized to fit in the display + window + """ + frameratio = float(self._display_dims[0]) / float(self._display_dims[1]) imgratio = float(image.shape[1]) / float(image.shape[0]) if frameratio <= imgratio: - scale = self.display_dims[0] / float(image.shape[1]) - size = (self.display_dims[0], max(1, int(image.shape[0] * scale))) + scale = self._display_dims[0] / float(image.shape[1]) + size = (self._display_dims[0], max(1, int(image.shape[0] * scale))) else: - scale = self.display_dims[1] / float(image.shape[0]) - size = (max(1, int(image.shape[1] * scale)), self.display_dims[1]) + scale = self._display_dims[1] / float(image.shape[0]) + size = (max(1, int(image.shape[1] * scale)), self._display_dims[1]) logger.trace("scale: %s, size: %s", scale, size) return size - def build_faces_image(self): - """ Display associated faces """ + def _build_faces_image(self): + """ Compile the source and destination rows of the preview image. """ logger.trace("Building Faces Image") update_all = self.update_source - self.faces_from_frames() + self._faces_from_frames() if update_all: - header = self.header_text() - source = np.hstack([self.draw_rect(face) for face in self.faces["src"]]) - self.faces_source = np.vstack((header, source)) - self.faces_dest = np.hstack([self.draw_rect(face) for face in self.faces["dst"]]) + header = self._header_text() + source = np.hstack([self._draw_rect(face) for face in self._faces["src"]]) + self._faces_source = np.vstack((header, source)) + self._faces_dest = np.hstack([self._draw_rect(face) for face in self._faces["dst"]]) logger.debug("source row shape: %s, swapped row shape: %s", - self.faces_dest.shape, self.faces_source.shape) + self._faces_dest.shape, self._faces_source.shape) - def faces_from_frames(self): - """ Compile faces from the original images and return a row for each of source and dest """ + def _faces_from_frames(self): + """ Extract the preview faces from the source frames and apply the requisite padding. """ logger.debug("Extracting faces from frames: Number images: %s", len(self.source)) if self.update_source: - self.crop_source_faces() - self.crop_destination_faces() - logger.debug("Extracted faces from frames: %s", {k: len(v) for k, v in self.faces.items()}) - - def crop_source_faces(self): - """ Update the main faces dict with new source faces and matrices """ + self._crop_source_faces() + self._crop_destination_faces() + logger.debug("Extracted faces from frames: %s", + {k: len(v) for k, v in self._faces.items()}) + + def _crop_source_faces(self): + """ Extract the source faces from the source frames, along with their filenames and the + transformation matrix used to extract the faces. """ logger.debug("Updating source faces") - self.faces = dict() + self._faces = dict() for image in self.source: detected_face = image["detected_faces"][0] src_img = image["image"] - detected_face.load_aligned(src_img, self.size) + detected_face.load_aligned(src_img, self._size) matrix = detected_face.aligned["matrix"] - self.faces.setdefault("filenames", - list()).append(os.path.splitext(image["filename"])[0]) - self.faces.setdefault("matrix", list()).append(matrix) - self.faces.setdefault("src", list()).append(AlignerExtract().transform( + self._faces.setdefault("filenames", + list()).append(os.path.splitext(image["filename"])[0]) + self._faces.setdefault("matrix", list()).append(matrix) + self._faces.setdefault("src", list()).append(AlignerExtract().transform( src_img, matrix, - self.size, - self.padding)) + self._size, + self._padding)) self.update_source = False logger.debug("Updated source faces") - def crop_destination_faces(self): - """ Update the main faces dict with new destination faces based on source matrices """ + def _crop_destination_faces(self): + """ Extract the swapped faces from the swapped frames using the source face destination + matrices. """ logger.debug("Updating destination faces") - self.faces["dst"] = list() + self._faces["dst"] = list() destination = self.destination if self.destination else [np.ones_like(src["image"]) for src in self.source] for idx, image in enumerate(destination): - self.faces["dst"].append(AlignerExtract().transform( + self._faces["dst"].append(AlignerExtract().transform( image, - self.faces["matrix"][idx], - self.size, - self.padding)) + self._faces["matrix"][idx], + self._size, + self._padding)) logger.debug("Updated destination faces") - def header_text(self): - """ Create header text for output image """ - font_scale = self.size / 640 - height = self.size // 8 + def _header_text(self): + """ Create the header text displaying the frame name for each preview column. + + Returns + ------- + :class:`numpy.ndarray` + The header row of the preview image containing the frame names for each column + """ + font_scale = self._size / 640 + height = self._size // 8 font = cv2.FONT_HERSHEY_SIMPLEX # Get size of placed text for positioning - text_sizes = [cv2.getTextSize(self.faces["filenames"][idx], + text_sizes = [cv2.getTextSize(self._faces["filenames"][idx], font, font_scale, 1)[0] - for idx in range(self.total_columns)] - # Get X and Y co-ords for each text item + for idx in range(self._total_columns)] + # Get X and Y co-ordinates for each text item text_y = int((height + text_sizes[0][1]) / 2) - text_x = [int((self.size - text_sizes[idx][0]) / 2) + self.size * idx - for idx in range(self.total_columns)] + text_x = [int((self._size - text_sizes[idx][0]) / 2) + self._size * idx + for idx in range(self._total_columns)] logger.debug("filenames: %s, text_sizes: %s, text_x: %s, text_y: %s", - self.faces["filenames"], text_sizes, text_x, text_y) - header_box = np.ones((height, self.size * self.total_columns, 3), np.uint8) * 255 - for idx, text in enumerate(self.faces["filenames"]): + self._faces["filenames"], text_sizes, text_x, text_y) + header_box = np.ones((height, self._size * self._total_columns, 3), np.uint8) * 255 + for idx, text in enumerate(self._faces["filenames"]): cv2.putText(header_box, text, (text_x[idx], text_y), @@ -513,87 +761,147 @@ def header_text(self): logger.debug("header_box.shape: %s", header_box.shape) return header_box - def draw_rect(self, image): - """ draw border """ - cv2.rectangle(image, (0, 0), (self.size - 1, self.size - 1), (255, 255, 255), 1) + def _draw_rect(self, image): + """ Place a white border around a given image. + + Parameters + ---------- + image: :class:`numpy.ndarray` + The image to place a border on to + Returns + ------- + :class:`numpy.ndarray` + The given image with a border drawn around the outside + """ + cv2.rectangle(image, (0, 0), (self._size - 1, self._size - 1), (255, 255, 255), 1) image = np.clip(image, 0.0, 255.0) return image.astype("uint8") class ConfigTools(): - """ Saving and resetting config values and stores selected variables """ + """ Tools for loading, saving, setting and retrieving configuration file values. + + Attributes + ---------- + tk_vars: dict + Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` + """ def __init__(self): - self.config = Config(None) - self.config_dicts = self.get_config_dicts() # Holds currently saved config + self._config = Config(None) self.tk_vars = dict() + self._config_dicts = self._get_config_dicts() # Holds currently saved config + + @property + def config(self): + """ :class:`plugins.convert._config.Config` The convert configuration """ + return self._config + + @property + def config_dicts(self): + """ dict: The convert configuration options in dictionary form.""" + return self._config_dicts @property def sections(self): - """ Return the sorted unique section names from the configs """ - return sorted(set(plugin.split(".")[0] for plugin in self.config.config.sections() + """ list: The sorted section names that exist within the convert Configuration options. """ + return sorted(set(plugin.split(".")[0] for plugin in self._config.config.sections() if plugin.split(".")[0] != "writer")) @property def plugins_dict(self): - """ Return dict of sections with sorted list of containing plugins """ - return {section: sorted([plugin.split(".")[1] for plugin in self.config.config.sections() + """ dict: Dictionary of configuration option sections as key with a list of containing + plugins as the value """ + return {section: sorted([plugin.split(".")[1] for plugin in self._config.config.sections() if plugin.split(".")[0] == section]) for section in self.sections} def update_config(self): - """ Update config with selected values """ + """ Update :attr:`config` with the currently selected values from the GUI. """ for section, items in self.tk_vars.items(): for item, value in items.items(): try: new_value = str(value.get()) except tk.TclError as err: # When manually filling in text fields, blank values will - # raise an error on numeric datatypes so return 0 + # raise an error on numeric data types so return 0 logger.debug("Error getting value. Defaulting to 0. Error: %s", str(err)) new_value = str(0) - old_value = self.config.config[section][item] + old_value = self._config.config[section][item] if new_value != old_value: logger.trace("Updating config: %s, %s from %s to %s", section, item, old_value, new_value) - self.config.config[section][item] = new_value - - def get_config_dicts(self): - """ Hold a custom config dict for the config """ + self._config.config[section][item] = new_value + + def _get_config_dicts(self): + """ Obtain a custom configuration dictionary for convert configuration items in use + by the preview tool formatted for control helper. + + Returns + ------- + dict + Each configuration section as keys, with the values as a dict of option: + :class:`lib.gui.control_helper.ControlOption` pairs. """ + logger.debug("Formatting Config for GUI") config_dicts = dict() - for section in self.config.config.sections(): - if section == "writer": + for section in self._config.config.sections(): + if section.startswith("writer."): continue - default_dict = self.config.defaults[section] - for key in default_dict.keys(): + for key, val in self._config.defaults[section].items(): if key == "helptext": + config_dicts.setdefault(section, dict())[key] = val continue - default_dict[key]["value"] = self.config.get(section, key) - config_dicts[section] = default_dict + cp_option = ControlPanelOption(title=key, + dtype=val["type"], + group=val["group"], + default=val["default"], + initial_value=self._config.get(section, key), + choices=val["choices"], + is_radio=val["gui_radio"], + rounding=val["rounding"], + min_max=val["min_max"], + helptext=val["helptext"]) + self.tk_vars.setdefault(section, dict())[key] = cp_option.tk_var + config_dicts.setdefault(section, dict())[key] = cp_option + logger.debug("Formatted Config for GUI: %s", config_dicts) return config_dicts - def reset_config_saved(self, section=None): - """ Reset config to saved values """ + def reset_config_to_saved(self, section=None): + """ Reset the GUI parameters to their saved values within the configuration file. + + Parameters + ---------- + section: str, optional + The configuration section to reset the values for, If ``None`` provided then all + sections are reset. Default: ``None`` + """ logger.debug("Resetting to saved config: %s", section) sections = [section] if section is not None else list(self.tk_vars.keys()) for config_section in sections: - for item, options in self.config_dicts[config_section].items(): + for item, options in self._config_dicts[config_section].items(): if item == "helptext": continue - val = options["value"] + val = options.value if val != self.tk_vars[config_section][item].get(): self.tk_vars[config_section][item].set(val) logger.debug("Setting %s - %s to saved value %s", config_section, item, val) logger.debug("Reset to saved config: %s", section) - def reset_config_default(self, section=None): - """ Reset config to default values """ + def reset_config_to_default(self, section=None): + """ Reset the GUI parameters to their default configuration values. + + Parameters + ---------- + section: str, optional + The configuration section to reset the values for, If ``None`` provided then all + sections are reset. Default: ``None`` + """ logger.debug("Resetting to default: %s", section) sections = [section] if section is not None else list(self.tk_vars.keys()) for config_section in sections: - for item, options in self.config.defaults[config_section].items(): + for item, options in self._config_dicts[config_section].items(): if item == "helptext": continue - default = options["default"] + default = options.default if default != self.tk_vars[config_section][item].get(): self.tk_vars[config_section][item].set(default) logger.debug("Setting %s - %s to default value %s", @@ -601,205 +909,388 @@ def reset_config_default(self, section=None): logger.debug("Reset to default: %s", section) def save_config(self, section=None): - """ Save config """ + """ Save the configuration ``.ini`` file with the currently stored values. + + Parameters + ---------- + section: str, optional + The configuration section to save, If ``None`` provided then all sections are saved. + Default: ``None`` + """ logger.debug("Saving %s config", section) new_config = ConfigParser(allow_no_value=True) - for config_section, items in self.config_dicts.items(): + for config_section, items in self._config_dicts.items(): logger.debug("Adding section: '%s')", config_section) - self.config.insert_config_section(config_section, items["helptext"], config=new_config) + self._config.insert_config_section(config_section, + items["helptext"], + config=new_config) for item, options in items.items(): if item == "helptext": continue if ((section is not None and config_section != section) or config_section not in self.tk_vars): - new_opt = options["value"] # Keep saved item for other sections + new_opt = options.value # Keep saved item for other sections logger.debug("Retaining option: (item: '%s', value: '%s')", item, new_opt) else: new_opt = self.tk_vars[config_section][item].get() logger.debug("Setting option: (item: '%s', value: '%s')", item, new_opt) - helptext = options["helptext"] - helptext = self.config.format_help(helptext, is_section=False) + # Set config_dicts value to new saved value + options.set_initial_value(new_opt) + helptext = self._config.format_help(options.helptext, is_section=False) new_config.set(config_section, helptext) new_config.set(config_section, item, str(new_opt)) - self.config.config = new_config - self.config.save_config() - print("Saved config: '{}'".format(self.config.configfile)) - # Update config dict to newly saved - self.config_dicts = self.get_config_dicts() - logger.debug("Saved config") + self._config.config = new_config + self._config.save_config() + logger.info("Saved config: '%s'", self._config.configfile) class ImagesCanvas(ttk.Frame): # pylint:disable=too-many-ancestors - """ Canvas to hold the images """ + """ tkinter Canvas that holds the preview images. + + Parameters + ---------- + parent: tkinter object + The parent tkinter object that holds the canvas + tk_vars: dict + Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` + """ def __init__(self, parent, tk_vars): logger.debug("Initializing %s: (parent: %s, tk_vars: %s)", self.__class__.__name__, parent, tk_vars) super().__init__(parent) self.pack(expand=True, fill=tk.BOTH, padx=2, pady=2) - self.refresh_display_trigger = tk_vars["refresh"] - self.refresh_display_trigger.trace("w", self.refresh_display_callback) - self.display = parent.preview_display - self.canvas = tk.Canvas(self, bd=0, highlightthickness=0) - self.canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True) - self.displaycanvas = self.canvas.create_image(0, 0, - image=self.display.tk_image, - anchor=tk.NW) - self.bind("", self.resize) + self._refresh_display_trigger = tk_vars["refresh"] + self._refresh_display_trigger.trace("w", self._refresh_display_callback) + self._display = parent.preview_display + self._canvas = tk.Canvas(self, bd=0, highlightthickness=0) + self._canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + self._displaycanvas = self._canvas.create_image(0, 0, + image=self._display.tk_image, + anchor=tk.NW) + self.bind("", self._resize) logger.debug("Initialized %s", self.__class__.__name__) - def refresh_display_callback(self, *args): + def _refresh_display_callback(self, *args): """ Add a trace to refresh display on callback """ - if not self.refresh_display_trigger.get(): + if not self._refresh_display_trigger.get(): return logger.trace("Refresh display trigger received: %s", args) - self.reload() + self._reload() - def resize(self, event): - """ Resize the image to fit the frame, maintaining aspect ratio """ + def _resize(self, event): + """ Resize the image to fit the frame, maintaining aspect ratio """ logger.trace("Resizing preview image") framesize = (event.width, event.height) - self.display.display_dims = framesize - self.reload() + self._display.set_display_dimensions(framesize) + self._reload() - def reload(self): + def _reload(self): """ Reload the preview image """ logger.trace("Reloading preview image") - self.display.update_tk_image() - self.canvas.itemconfig(self.displaycanvas, image=self.display.tk_image) + self._display.update_tk_image() + self._canvas.itemconfig(self._displaycanvas, image=self._display.tk_image) class ActionFrame(ttk.Frame): # pylint: disable=too-many-ancestors - """ Frame that holds the left hand side options panel """ - def __init__(self, parent, selected_color, selected_mask_type, selected_scaling, - config_tools, patch_callback, refresh_callback, tk_vars): - logger.debug("Initializing %s: (selected_color: %s, selected_mask_type: %s, " - "selected_scaling: %s, config_tools, patch_callback: %s, " - "refresh_callback: %s, tk_vars: %s)", self.__class__.__name__, selected_color, + """ Frame that holds the left hand side options panel containing the command line options. + + Parameters + ---------- + parent: tkinter object + The parent tkinter object that holds the Action Frame + available_masks: list + The available masks that exist within the alignments file + has_predicted_mask: bool + Whether the model was trained with a mask + selected_color: str + The selected color adjustment type + selected_mask_type: str + The selected mask type + selected_scaling: str + The selected scaling type + config_tools: :class:`ConfigTools` + Tools for loading and saving configuration files + patch_callback: python function + The function to execute when a patch callback is received + refresh_callback: python function + The function to execute when a refresh callback is received + tk_vars: dict + Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` + """ + def __init__(self, parent, available_masks, has_predicted_mask, selected_color, + selected_mask_type, selected_scaling, config_tools, patch_callback, + refresh_callback, tk_vars): + logger.debug("Initializing %s: (available_masks: %s, has_predicted_mask: %s, " + "selected_color: %s, selected_mask_type: %s, selected_scaling: %s, " + "patch_callback: %s, refresh_callback: %s, tk_vars: %s)", + self.__class__.__name__, available_masks, has_predicted_mask, selected_color, selected_mask_type, selected_scaling, patch_callback, refresh_callback, tk_vars) - self.config_tools = config_tools + self._config_tools = config_tools super().__init__(parent) self.pack(side=tk.LEFT, anchor=tk.N, fill=tk.Y) - self.options = ["color", "mask_type", "scaling"] - self.busy_tkvar = tk_vars["busy"] - self.tk_vars = dict() + self._options = ["color", "mask_type", "scaling"] + self._busy_tkvar = tk_vars["busy"] + self._tk_vars = dict() d_locals = locals() - defaults = {opt: self.format_to_display(d_locals["selected_{}".format(opt)]) - for opt in self.options} - self.busy_indicator = self.build_frame(defaults, refresh_callback, patch_callback) + defaults = {opt: self._format_to_display(d_locals["selected_{}".format(opt)]) + for opt in self._options} + self._busy_indicator = self._build_frame(defaults, + refresh_callback, + patch_callback, + available_masks, + has_predicted_mask) @property def convert_args(self): - """ Return a dict of cli arguments for converter based on selected options """ + """ dict: Currently selected Command line arguments from the :class:`ActionFrame`. """ return {opt if opt != "color" else "color_adjustment": - self.format_from_display(self.tk_vars[opt].get()) - for opt in self.options} + self._format_from_display(self._tk_vars[opt].get()) + for opt in self._options} @staticmethod - def format_from_display(var): - """ Format a variable from display version """ + def _format_from_display(var): + """ Format a variable from the display version to the command line action version. + + Parameters + ---------- + var: str + The variable name to format + + Returns + ------- + str + The formatted variable name + """ return var.replace(" ", "_").lower() @staticmethod - def format_to_display(var): - """ Format a variable from display version """ + def _format_to_display(var): + """ Format a variable from the command line action version to the display version. + Parameters + ---------- + var: str + The variable name to format + + Returns + ------- + str + The formatted variable name + """ return var.replace("_", " ").replace("-", " ").title() - def build_frame(self, defaults, refresh_callback, patch_callback): - """ Build the action frame """ + def _build_frame(self, defaults, refresh_callback, patch_callback, + available_masks, has_predicted_mask): + """ Build the :class:`ActionFrame`. + + Parameters + ---------- + defaults: dict + The default command line options + patch_callback: python function + The function to execute when a patch callback is received + refresh_callback: python function + The function to execute when a refresh callback is received + available_masks: list + The available masks that exist within the alignments file + has_predicted_mask: bool + Whether the model was trained with a mask + + Returns + ------- + ttk.Progressbar + A Progress bar to indicate that the Preview tool is busy + """ logger.debug("Building Action frame") - top_frame = ttk.Frame(self) - top_frame.pack(side=tk.TOP, fill=tk.BOTH, anchor=tk.N, expand=True) + bottom_frame = ttk.Frame(self) bottom_frame.pack(side=tk.BOTTOM, fill=tk.X, anchor=tk.S) + top_frame = ttk.Frame(self) + top_frame.pack(side=tk.TOP, fill=tk.BOTH, anchor=tk.N, expand=True) - self.add_comboboxes(top_frame, defaults) - busy_indicator = self.add_busy_indicator(top_frame) - self.add_refresh_button(top_frame, refresh_callback) - self.add_patch_callback(patch_callback) - self.add_actions(bottom_frame) + self._add_cli_choices(top_frame, defaults, available_masks, has_predicted_mask) + + busy_indicator = self._add_busy_indicator(bottom_frame) + self._add_refresh_button(bottom_frame, refresh_callback) + self._add_patch_callback(patch_callback) + self._add_actions(bottom_frame) logger.debug("Built Action frame") return busy_indicator - def add_comboboxes(self, parent, defaults): - """ Add the comboboxes to the Action Frame """ - for opt in self.options: + def _add_cli_choices(self, parent, defaults, available_masks, has_predicted_mask): + """ Create :class:`lib.gui.control_helper.ControlPanel` object for the command + line options. + + parent: :class:`ttk.Frame` + The frame to hold the command line choices + defaults: dict + The default command line options + available_masks: list + The available masks that exist within the alignments file + has_predicted_mask: bool + Whether the model was trained with a mask + """ + cp_options = self._get_control_panel_options(defaults, available_masks, has_predicted_mask) + panel_kwargs = dict(blank_nones=False, label_width=10) + ControlPanel(parent, cp_options, header_text=None, **panel_kwargs) + + def _get_control_panel_options(self, defaults, available_masks, has_predicted_mask): + """ Create :class:`lib.gui.control_helper.ControlPanelOption` objects for the command + line options. + + defaults: dict + The default command line options + available_masks: list + The available masks that exist within the alignments file + has_predicted_mask: bool + Whether the model was trained with a mask + + Returns + ------- + list + The list of `lib.gui.control_helper.ControlPanelOption` objects for the Action Frame + """ + cp_options = [] + for opt in self._options: if opt == "mask_type": - choices = get_available_masks() + ["predicted"] + choices = self._create_mask_choices(defaults, available_masks, has_predicted_mask) else: choices = PluginLoader.get_available_convert_plugins(opt, True) - choices = [self.format_to_display(choice) for choice in choices] - ctl = ControlBuilder(parent, - opt, - str, - defaults[opt], - choices=choices, - is_radio=False, - label_width=10, - control_width=12) - self.tk_vars[opt] = ctl.tk_var + cp_option = ControlPanelOption(title=opt, + dtype=str, + default=defaults[opt], + initial_value=defaults[opt], + choices=choices, + is_radio=False) + self._tk_vars[opt] = cp_option.tk_var + cp_options.append(cp_option) + return cp_options + + @staticmethod + def _create_mask_choices(defaults, available_masks, has_predicted_mask): + """ Set the mask choices and default mask based on available masks. + + Parameters + ---------- + defaults: dict + The default command line options + available_masks: list + The available masks that exist within the alignments file + has_predicted_mask: bool + Whether the model was trained with a mask + + Returns + ------- + list + The masks that are available to use from the alignments file + """ + logger.debug("Initial mask choices: %s", available_masks) + if has_predicted_mask: + available_masks += ["predicted"] + if "none" not in available_masks: + available_masks += ["none"] + if defaults["mask_type"] not in available_masks: + logger.debug("Setting default mask to first available: %s", available_masks[0]) + defaults["mask_type"] = available_masks[0] + logger.debug("Final mask choices: %s", available_masks) + return available_masks @staticmethod - def add_refresh_button(parent, refresh_callback): - """ Add button to refresh the images """ + def _add_refresh_button(parent, refresh_callback): + """ Add a button to refresh the images. + + Parameters + ---------- + refresh_callback: python function + The function to execute when the refresh button is pressed + """ btn = ttk.Button(parent, text="Update Samples", command=refresh_callback) - btn.pack(padx=5, pady=5, side=tk.BOTTOM, fill=tk.X, anchor=tk.S) + btn.pack(padx=5, pady=5, side=tk.TOP, fill=tk.X, anchor=tk.N) + + def _add_patch_callback(self, patch_callback): + """ Add callback to re-patch images on action option change. - def add_patch_callback(self, patch_callback): - """ Add callback to repatch images on action option change """ - for tk_var in self.tk_vars.values(): + Parameters + ---------- + patch_callback: python function + The function to execute when the images require patching + """ + for tk_var in self._tk_vars.values(): tk_var.trace("w", patch_callback) - def add_busy_indicator(self, parent): - """ Place progress bar into bottom bar to indicate when processing """ + def _add_busy_indicator(self, parent): + """ Place progress bar into bottom bar to indicate when processing. + + Parameters + ---------- + parent: tkinter object + The tkinter object that holds the busy indicator + + Returns + ------- + ttk.Progressbar + A Progress bar to indicate that the Preview tool is busy + """ logger.debug("Placing busy indicator") pbar = ttk.Progressbar(parent, mode="indeterminate") - pbar.pack(side=tk.BOTTOM, padx=5, pady=5, fill=tk.X) + pbar.pack(side=tk.LEFT) pbar.pack_forget() - self.busy_tkvar.trace("w", self.busy_indicator_trace) + self._busy_tkvar.trace("w", self._busy_indicator_trace) return pbar - def busy_indicator_trace(self, *args): - """ Show or hide busy indicator """ + def _busy_indicator_trace(self, *args): + """ Show or hide busy indicator based on whether the preview is updating. + + Parameters + ---------- + args: unused + Required for tkinter event, but unused + """ logger.trace("Busy indicator trace: %s", args) - if self.busy_tkvar.get(): - self.start_busy_indicator() + if self._busy_tkvar.get(): + self._start_busy_indicator() else: - self.stop_busy_indicator() + self._stop_busy_indicator() - def stop_busy_indicator(self): + def _stop_busy_indicator(self): """ Stop and hide progress bar """ logger.debug("Stopping busy indicator") - self.busy_indicator.stop() - self.busy_indicator.pack_forget() + self._busy_indicator.stop() + self._busy_indicator.pack_forget() - def start_busy_indicator(self): + def _start_busy_indicator(self): """ Start and display progress bar """ logger.debug("Starting busy indicator") - self.busy_indicator.pack(side=tk.BOTTOM, padx=5, pady=5, fill=tk.X) - self.busy_indicator.start() + self._busy_indicator.pack(side=tk.LEFT, padx=5, pady=(5, 10), fill=tk.X, expand=True) + self._busy_indicator.start() + + def _add_actions(self, parent): + """ Add Action Buttons to the :class:`ActionFrame` - def add_actions(self, parent): - """ Add Action Buttons """ + Parameters + ---------- + parent: tkinter object + The tkinter object that holds the action buttons + """ logger.debug("Adding util buttons") frame = ttk.Frame(parent) - frame.pack(padx=5, pady=(5, 10), side=tk.BOTTOM, fill=tk.X, anchor=tk.E) + frame.pack(padx=5, pady=(5, 10), side=tk.RIGHT, fill=tk.X, anchor=tk.E) for utl in ("save", "clear", "reload"): logger.debug("Adding button: '%s'", utl) img = get_images().icons[utl] if utl == "save": text = "Save full config" - action = self.config_tools.save_config + action = self._config_tools.save_config elif utl == "clear": text = "Reset full config to default values" - action = self.config_tools.reset_config_default + action = self._config_tools.reset_config_to_default elif utl == "reload": text = "Reset full config to saved values" - action = self.config_tools.reset_config_saved + action = self._config_tools.reset_config_to_saved btnutl = ttk.Button(frame, image=img, @@ -810,141 +1301,137 @@ def add_actions(self, parent): class OptionsBook(ttk.Notebook): # pylint:disable=too-many-ancestors - """ Convert settings Options Frame """ + """ The notebook that holds the Convert configuration options. + + Parameters + ---------- + parent: tkinter object + The parent tkinter object that holds the Options book + config_tools: :class:`ConfigTools` + Tools for loading and saving configuration files + patch_callback: python function + The function to execute when a patch callback is received + scaling: float + The scaling factor for display + + Attributes + ---------- + config_tools: :class:`ConfigTools` + Tools for loading and saving configuration files + """ def __init__(self, parent, config_tools, patch_callback, scaling): logger.debug("Initializing %s: (parent: %s, config: %s, scaling: %s)", self.__class__.__name__, parent, config_tools, scaling) super().__init__(parent) self.pack(side=tk.RIGHT, anchor=tk.N, fill=tk.BOTH, expand=True) self.config_tools = config_tools - self.scaling = scaling + self._scaling = scaling - self.tabs = dict() - self.build_tabs() - self.build_sub_tabs() - self.add_patch_callback(patch_callback) + self._tabs = dict() + self._build_tabs() + self._build_sub_tabs() + self._add_patch_callback(patch_callback) logger.debug("Initialized %s", self.__class__.__name__) - def build_tabs(self): - """ Build the tabs for the relevant section """ + def _build_tabs(self): + """ Build the notebook tabs for the each configuration section. """ logger.debug("Build Tabs") for section in self.config_tools.sections: tab = ttk.Notebook(self) - self.tabs[section] = {"tab": tab} + self._tabs[section] = {"tab": tab} self.add(tab, text=section.replace("_", " ").title()) - def build_sub_tabs(self): - """ Build the sub tabs for the relevant plugin """ + def _build_sub_tabs(self): + """ Build the notebook sub tabs for each convert section's plugin. """ for section, plugins in self.config_tools.plugins_dict.items(): for plugin in plugins: config_key = ".".join((section, plugin)) config_dict = self.config_tools.config_dicts[config_key] - tab = ConfigFrame(self, - config_key, - config_dict) - self.tabs[section][plugin] = tab - self.tabs[section]["tab"].add(tab, text=plugin.replace("_", " ").title()) - - def add_patch_callback(self, patch_callback): - """ Add callback to repatch images on config option change """ + tab = ConfigFrame(self, config_key, config_dict) + self._tabs[section][plugin] = tab + self._tabs[section]["tab"].add(tab, text=plugin.replace("_", " ").title()) + + def _add_patch_callback(self, patch_callback): + """ Add callback to re-patch images on configuration option change. + + Parameters + ---------- + patch_callback: python function + The function to execute when the images require patching + """ for plugins in self.config_tools.tk_vars.values(): for tk_var in plugins.values(): tk_var.trace("w", patch_callback) class ConfigFrame(ttk.Frame): # pylint: disable=too-many-ancestors - """ Config Frame - Holds the Options for config """ + """ Holds the configuration options for a convert plugin inside the :class:`OptionsBook`. + + Parameters + ---------- + parent: tkinter object + The tkinter object that will hold this configuration frame + config_key: str + The section/plugin key for these configuration options + options: dict + The options for this section/plugin + """ def __init__(self, parent, config_key, options): logger.debug("Initializing %s", self.__class__.__name__) super().__init__(parent) self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) - self.options = options - self.static_dims = [0, 0] - - self.canvas_frame = ttk.Frame(self) - self.canvas_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) - - self.canvas = tk.Canvas(self.canvas_frame, bd=0, highlightthickness=0) - self.canvas.pack(side=tk.LEFT, fill=tk.Y) - - self.optsframe = ttk.Frame(self.canvas) - self.optscanvas = self.canvas.create_window((0, 0), window=self.optsframe, anchor=tk.NW) - - self.scrollbar = self.add_scrollbar() + self._options = options - self.frame_separator = self.add_frame_separator() - self.action_frame = ttk.Frame(self) - self.action_frame.pack(padx=5, pady=5, side=tk.BOTTOM, fill=tk.X, anchor=tk.E) - - self.build_frame(parent, config_key) - - self.bind("", self.resize_frame) + self._action_frame = ttk.Frame(self) + self._action_frame.pack(padx=0, pady=(0, 5), side=tk.BOTTOM, fill=tk.X, anchor=tk.E) + self._add_frame_separator() + self._build_frame(parent, config_key) logger.debug("Initialized %s", self.__class__.__name__) - def build_frame(self, parent, config_key): - """ Build the options frame for this command """ - logger.debug("Add Config Frame") + def _build_frame(self, parent, config_key): + """ Build the options frame for this command - for key, val in self.options.items(): - if key == "helptext": - continue - value = val.get("value", val["default"]) - ctl = ControlBuilder(self.optsframe, - key, - val["type"], - value, - selected_value=None, - choices=val["choices"], - is_radio=val["gui_radio"], - rounding=val["rounding"], - min_max=val["min_max"], - helptext=val["helptext"], - radio_columns=4) - parent.config_tools.tk_vars.setdefault(config_key, dict())[key] = ctl.tk_var - self.add_frame_separator() - self.add_actions(parent, config_key) + Parameters + ---------- + parent: tkinter object + The tkinter object that will hold this configuration frame + config_key: str + The section/plugin key for these configuration options + """ + logger.debug("Add Config Frame") + panel_kwargs = dict(columns=2, option_columns=2, blank_nones=False) + frame = ttk.Frame(self) + frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + cp_options = [opt for key, opt in self._options.items() if key != "helptext"] + ControlPanel(frame, cp_options, header_text=None, **panel_kwargs) + self._add_actions(parent, config_key) logger.debug("Added Config Frame") - def add_scrollbar(self): - """ Add a scrollbar to the options frame """ - logger.debug("Add Config Scrollbar") - scrollbar = ttk.Scrollbar(self.canvas_frame, command=self.canvas.yview) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - self.canvas.config(yscrollcommand=scrollbar.set) - self.optsframe.bind("", self.update_scrollbar) - logger.debug("Added Config Scrollbar") - return scrollbar - - def update_scrollbar(self, event): # pylint: disable=unused-argument - """ Update the options frame scrollbar """ - self.canvas.configure(scrollregion=self.canvas.bbox("all")) - - def resize_frame(self, event): - """ Resize the options frame to fit the canvas """ - logger.debug("Resize Config Frame") - canvas_width = event.width - self.scrollbar.winfo_reqwidth() - canvas_height = event.height - (self.action_frame.winfo_reqheight() + - self.frame_separator.winfo_reqheight() + 16) - self.canvas.configure(width=canvas_width, height=canvas_height) - self.canvas.itemconfig(self.optscanvas, width=canvas_width, height=canvas_height) - logger.debug("Resized Config Frame") - - def add_frame_separator(self): - """ Add a separator between top and bottom frames """ + def _add_frame_separator(self): + """ Add a separator between top and bottom frames. """ logger.debug("Add frame seperator") - sep = ttk.Frame(self, height=2, relief=tk.RIDGE) - sep.pack(fill=tk.X, pady=(5, 0), side=tk.TOP) + sep = ttk.Frame(self._action_frame, height=2, relief=tk.RIDGE) + sep.pack(fill=tk.X, pady=5, side=tk.TOP) logger.debug("Added frame seperator") - return sep - def add_actions(self, parent, config_key): - """ Add Action Buttons """ + def _add_actions(self, parent, config_key): + """ Add Action Buttons. + + Parameters + ---------- + parent: tkinter object + The tkinter object that will hold this configuration frame + config_key: str + The section/plugin key for these configuration options + """ logger.debug("Adding util buttons") title = config_key.split(".")[1].replace("_", " ").title() + btn_frame = ttk.Frame(self._action_frame) + btn_frame.pack(padx=5, side=tk.BOTTOM, fill=tk.X) for utl in ("save", "clear", "reload"): logger.debug("Adding button: '%s'", utl) img = get_images().icons[utl] @@ -953,225 +1440,14 @@ def add_actions(self, parent, config_key): action = parent.config_tools.save_config elif utl == "clear": text = "Reset {} config to default values".format(title) - action = parent.config_tools.reset_config_default + action = parent.config_tools.reset_config_to_default elif utl == "reload": text = "Reset {} config to saved values".format(title) - action = parent.config_tools.reset_config_saved + action = parent.config_tools.reset_config_to_saved - btnutl = ttk.Button(self.action_frame, + btnutl = ttk.Button(btn_frame, image=img, command=lambda cmd=action: cmd(config_key)) btnutl.pack(padx=2, side=tk.RIGHT) Tooltip(btnutl, text=text, wraplength=200) logger.debug("Added util buttons") - - -class ControlBuilder(): - """ - Builds and returns a frame containing a tkinter control with label - - Currently only setup for config items - - Parameters - ---------- - parent: tkinter object - Parent tkinter object - title: str - Title of the control. Will be used for label text - dtype: datatype object - Datatype of the control - default: str - Default value for the control - selected_value: str, optional - Selected value for the control. If None, default will be used - choices: list or tuple, object - Used for combo boxes and radio control option setting - is_radio: bool, optional - Specifies to use a Radio control instead of combobox if choices are passed - rounding: int or float, optional - For slider controls. Sets the stepping - min_max: int or float, optional - For slider controls. Sets the min and max values - helptext: str, optional - Sets the tooltip text - radio_columns: int, optional - Sets the number of columns to use for grouping radio buttons - label_width: int, optional - Sets the width of the control label. Defaults to 20 - control_width: int, optional - Sets the width of the control. Default is to auto expand - """ - def __init__(self, parent, title, dtype, default, - selected_value=None, choices=None, is_radio=False, rounding=None, - min_max=None, helptext=None, radio_columns=3, label_width=20, control_width=None): - logger.debug("Initializing %s: (parent: %s, title: %s, dtype: %s, default: %s, " - "selected_value: %s, choices: %s, is_radio: %s, rounding: %s, min_max: %s, " - "helptext: %s, radio_columns: %s, label_width: %s, control_width: %s)", - self.__class__.__name__, parent, title, dtype, default, selected_value, - choices, is_radio, rounding, min_max, helptext, radio_columns, label_width, - control_width) - - self.title = title - self.default = default - - self.frame = self.control_frame(parent, helptext) - self.control = self.set_control(dtype, choices, is_radio) - self.tk_var = self.set_tk_var(dtype, selected_value) - - self.build_control(choices, - dtype, - rounding, - min_max, - radio_columns, - label_width, - control_width) - logger.debug("Initialized: %s", self.__class__.__name__) - - # Frame, control type and varable - def control_frame(self, parent, helptext): - """ Frame to hold control and it's label """ - logger.debug("Build control frame") - frame = ttk.Frame(parent) - frame.pack(side=tk.TOP, fill=tk.X) - if helptext is not None: - helptext = self.format_helptext(helptext) - Tooltip(frame, text=helptext, wraplength=720) - logger.debug("Built control frame") - return frame - - def format_helptext(self, helptext): - """ Format the help text for tooltips """ - logger.debug("Format control help: '%s'", self.title) - helptext = helptext.replace("\n\t", "\n - ").replace("%%", "%") - helptext = self.title + " - " + helptext - logger.debug("Formatted control help: (title: '%s', help: '%s'", self.title, helptext) - return helptext - - def set_control(self, dtype, choices, is_radio): - """ Set the correct control type based on the datatype or for this option """ - if choices and is_radio: - control = ttk.Radiobutton - elif choices: - control = ttk.Combobox - elif dtype == bool: - control = ttk.Checkbutton - elif dtype in (int, float): - control = ttk.Scale - else: - control = ttk.Entry - logger.debug("Setting control '%s' to %s", self.title, control) - return control - - def set_tk_var(self, dtype, selected_value): - """ Correct variable type for control """ - logger.debug("Setting tk variable: (title: '%s', dtype: %s, selected_value: %s)", - self.title, dtype, selected_value) - if dtype == bool: - var = tk.BooleanVar - elif dtype == int: - var = tk.IntVar - elif dtype == float: - var = tk.DoubleVar - else: - var = tk.StringVar - var = var(self.frame) - val = self.default if selected_value is None else selected_value - var.set(val) - logger.debug("Set tk variable: (title: '%s', type: %s, value: '%s')", - self.title, type(var), val) - return var - - # Build the full control - def build_control(self, choices, dtype, rounding, min_max, radio_columns, - label_width, control_width): - """ Build the correct control type for the option passed through """ - logger.debug("Build confog option control") - self.build_control_label(label_width) - self.build_one_control(choices, dtype, rounding, min_max, radio_columns, control_width) - logger.debug("Built option control") - - def build_control_label(self, label_width): - """ Label for control """ - logger.debug("Build control label: (title: '%s', label_width: %s)", - self.title, label_width) - title = self.title.replace("_", " ").title() - lbl = ttk.Label(self.frame, text=title, width=label_width, anchor=tk.W) - lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) - logger.debug("Built control label: '%s'", self.title) - - def build_one_control(self, choices, dtype, rounding, min_max, radio_columns, control_width): - """ Build and place the option controls """ - logger.debug("Build control: (title: '%s', control: %s, choices: %s, dtype: %s, " - "rounding: %s, min_max: %s: radio_columns: %s, control_width: %s)", - self.title, self.control, choices, dtype, rounding, min_max, radio_columns, - control_width) - if self.control == ttk.Scale: - ctl = self.slider_control(dtype, rounding, min_max) - elif self.control == ttk.Radiobutton: - ctl = self.radio_control(choices, radio_columns) - else: - ctl = self.control_to_optionsframe(choices) - self.set_control_width(ctl, control_width) - ctl.pack(padx=5, pady=5, fill=tk.X, expand=True) - logger.debug("Built control: '%s'", self.title) - - @staticmethod - def set_control_width(ctl, control_width): - """ Set the control width if required """ - if control_width is not None: - ctl.config(width=control_width) - - def radio_control(self, choices, columns): - """ Create a group of radio buttons """ - logger.debug("Adding radio group: %s", self.title) - ctl = ttk.Frame(self.frame) - frames = list() - for _ in range(columns): - frame = ttk.Frame(ctl) - frame.pack(padx=5, pady=5, fill=tk.X, expand=True, side=tk.LEFT, anchor=tk.N) - frames.append(frame) - - for idx, choice in enumerate(choices): - frame_id = idx % columns - radio = ttk.Radiobutton(frames[frame_id], - text=choice.title(), - value=choice, - variable=self.tk_var) - radio.pack(anchor=tk.W) - logger.debug("Adding radio option %s to column %s", choice, frame_id) - logger.debug("Added radio group: '%s'", self.title) - return ctl - - def slider_control(self, dtype, rounding, min_max): - """ A slider control with corresponding Entry box """ - logger.debug("Add slider control to Options Frame: (title: '%s', dtype: %s, rounding: %s, " - "min_max: %s)", self.title, dtype, rounding, min_max) - tbox = ttk.Entry(self.frame, width=8, textvariable=self.tk_var, justify=tk.RIGHT) - tbox.pack(padx=(0, 5), side=tk.RIGHT) - ctl = self.control( - self.frame, - variable=self.tk_var, - command=lambda val, var=self.tk_var, dt=dtype, rn=rounding, mm=min_max: - set_slider_rounding(val, var, dt, rn, mm)) - rc_menu = ContextMenu(tbox) - rc_menu.cm_bind() - ctl["from_"] = min_max[0] - ctl["to"] = min_max[1] - logger.debug("Added slider control to Options Frame: %s", self.title) - return ctl - - def control_to_optionsframe(self, choices): - """ Standard non-check buttons sit in the main options frame """ - logger.debug("Add control to Options Frame: (title: '%s', control: %s, choices: %s)", - self.title, self.control, choices) - if self.control == ttk.Checkbutton: - ctl = self.control(self.frame, variable=self.tk_var, text=None) - else: - ctl = self.control(self.frame, textvariable=self.tk_var) - rc_menu = ContextMenu(ctl) - rc_menu.cm_bind() - if choices: - logger.debug("Adding combo choices: %s", choices) - ctl["values"] = [choice for choice in choices] - logger.debug("Added control to Options Frame: %s", self.title) - return ctl From 91da1bba8e36f2a7df44c1d0e4a1ae3ef260dbd0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 30 Dec 2019 12:40:34 +0000 Subject: [PATCH 193/981] tools.mask - Bugfixes - Missing masks - fix memory leak - Missing masks - Handle multiple faces in frames properly --- tools/mask.py | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/tools/mask.py b/tools/mask.py index b866cb4e26..3d46415b04 100644 --- a/tools/mask.py +++ b/tools/mask.py @@ -2,6 +2,7 @@ """ Tool to generate masks and previews of masks for existing alignments file """ import logging import os +import sys import cv2 import numpy as np @@ -66,11 +67,11 @@ def _check_input(self, mask_input): """ if not os.path.exists(mask_input): logger.error("Location cannot be found: '%s'", mask_input) - exit(0) + sys.exit(0) if os.path.isfile(mask_input) and self._input_is_faces: logger.error("Input type 'faces' was selected but input is not a folder: '%s'", mask_input) - exit(0) + sys.exit(0) logger.debug("input '%s' is valid", mask_input) def _set_saver(self, arguments): @@ -90,7 +91,7 @@ def _set_saver(self, arguments): 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.") - exit(0) + sys.exit(0) logger.debug("No output provided. Not creating saver") return None output_dir = str(get_folder(arguments.output, make_folder=True)) @@ -201,19 +202,24 @@ def _input_frames(self, *args): if not self._alignments.frame_has_faces(frame): logger.debug("Skipping frame with no faces: '%s'", frame) continue - detected_faces = [] - for idx, alignment in enumerate(self._alignments.get_faces_in_frame(frame)): - self._face_count += 1 - if self._check_for_missing(frame, idx, alignment): - continue - detected_face = self._get_detected_face(alignment) - if self._update_type == "output": + + faces_in_frame = self._alignments.get_faces_in_frame(frame) + self._face_count += 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: - detected_faces.append(detected_face) - self._update_count += 1 - if self._update_type != "output": + else: + self._update_count += len(detected_faces) queue.put(ExtractMedia(filename, image, detected_faces=detected_faces)) if self._update_type != "output": queue.put("EOF") @@ -240,7 +246,7 @@ def _check_for_missing(self, frame, idx, alignment): alignment.get("mask", None) is not None and alignment["mask"].get(self._mask_type, None) is not None) if retval: - logger.debug("Not updating existing mask for face: '%s' - %s", frame, idx) + logger.debug("Mask pre-exists for face: '%s' - %s", frame, idx) return retval def _get_output_suffix(self): From f2333e1f9d91c9af6270b044a94a408b16f8c9e6 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 30 Dec 2019 18:10:54 +0000 Subject: [PATCH 194/981] tools.preview - Limit mask selection to only masks available for all faces plugins.mask - Correctly set dtype for "None" mask --- plugins/convert/mask/mask_blend.py | 2 +- tools/preview.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index a09d4b646c..1beadc7a02 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -72,7 +72,7 @@ def _get_mask(self, detected_face, predicted_mask): """ if self.mask_type == "none": # Return a dummy mask if not using a mask - mask = np.ones_like(self.dummy[:, :, 1]) + mask = np.ones_like(self.dummy[:, :, 1], dtype="uint8") * 255 elif self.mask_type == "predicted": mask = predicted_mask else: diff --git a/tools/preview.py b/tools/preview.py index 7040732e8b..922a40039d 100644 --- a/tools/preview.py +++ b/tools/preview.py @@ -6,6 +6,7 @@ import tkinter as tk from tkinter import ttk import os +import sys from configparser import ConfigParser from threading import Event, Lock @@ -75,6 +76,14 @@ def __init__(self, arguments): self._cli_frame = None # cli frame holds cli options logger.debug("Initialized %s", self.__class__.__name__) + @property + def _available_masks(self): + """ list: The mask names that are available for every face in the alignmnets file """ + retval = [key + for key, val in self._samples.alignments.mask_summary.items() + if val == self._samples.alignments.faces_count] + return retval + def _initialize_tkinter(self): """ Initialize a standalone tkinter instance. """ logger.debug("Initializing tkinter") @@ -148,7 +157,7 @@ def _build_ui(self): options_frame = ttk.Frame(container) self._cli_frame = ActionFrame( options_frame, - list(self._samples.alignments.mask_summary.keys()), + self._available_masks, self._samples.predictor.has_predicted_mask, self._patch.converter.cli_arguments.color_adjustment.replace("-", "_"), self._patch.converter.cli_arguments.mask_type.replace("-", "_"), @@ -205,7 +214,7 @@ def __init__(self, arguments, sample_size, display, lock, trigger_patch): input_is_video=self._images.is_video) if not self._alignments.have_alignments_file: logger.error("Alignments file not found at: '%s'", self._alignments.file) - exit(1) + sys.exit(1) self._filelist = self._get_filelist() self._indices = self._get_indices() From 497779d2930c74b6099c1dc44c8f1e96edbf8d67 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 31 Dec 2019 17:27:45 +0000 Subject: [PATCH 195/981] lib.gui Centralize get_scaling and set_geometry to utils.config Suppress Error when rebuilding GUI for TreeView --- lib/gui/display_analysis.py | 8 ++++++-- lib/gui/utils.py | 29 ++++++++++++++++++++++++++ scripts/gui.py | 23 ++------------------- tools/preview.py | 41 ++++++++----------------------------- 4 files changed, 45 insertions(+), 56 deletions(-) diff --git a/lib/gui/display_analysis.py b/lib/gui/display_analysis.py index 8bd3da23f4..aa1e797dfa 100644 --- a/lib/gui/display_analysis.py +++ b/lib/gui/display_analysis.py @@ -352,8 +352,12 @@ def tree_insert_data(self, sessions_summary): def tree_clear(self): """ Clear the totals tree """ logger.debug("Clearing treeview data") - self.tree.delete(* self.tree.get_children()) - self.tree.configure(height=1) + try: + self.tree.delete(* self.tree.get_children()) + self.tree.configure(height=1) + except tk.TclError: + # Catch non-existent tree view when rebuilding the GUI + pass def select_item(self, event): """ Update the session summary info with diff --git a/lib/gui/utils.py b/lib/gui/utils.py index abfacd7592..e26d1514d6 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -1044,6 +1044,35 @@ def set_root_title(self, text=None): title += " - {}".format(text) if text is not None and text else "" self.root.title(title) + def set_geometry(self, width, height, fullscreen=False): + """ Set the geometry for the root tkinter object. + + Parameters + ---------- + width: int + The width to set the window to (prior to scaling) + height: int + The height to set the window to (prior to scaling) + fullscreen: bool, optional + Whether to set the window to full-screen mode. If ``True`` then :attr:`width` and + :attr:`height` are ignored. Default: ``False`` + """ + self.root.tk.call("tk", "scaling", self.scaling_factor) + if fullscreen: + initial_dimensions = (self.root.winfo_screenwidth(), self.root.winfo_screenheight()) + else: + initial_dimensions = (round(width * self.scaling_factor), + round(height * self.scaling_factor)) + + if fullscreen and sys.platform == "win32": + self.root.state('zoomed') + elif fullscreen: + self.root.attributes('-zoomed', True) + else: + self.root.geometry("{}x{}+80+80".format(str(initial_dimensions[0]), + str(initial_dimensions[1]))) + logger.debug("Geometry: %sx%s", *initial_dimensions) + class LongRunningTask(Thread): """ Runs long running tasks in a background thread to prevent the GUI from becoming diff --git a/scripts/gui.py b/scripts/gui.py index 1d39a2c38a..a390f11a4b 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -24,7 +24,7 @@ def __init__(self, debug): self._config = self.initialize_globals() self.set_fonts() self.set_styles() - self.set_geometry() + self._config.set_geometry(1200, 640, self._config.user_config_dict["fullscreen"]) self.wrapper = ProcessWrapper() self.objects = dict() @@ -57,25 +57,6 @@ def set_styles(): gui_style = ttk.Style() gui_style.configure('TLabelframe.Label', foreground="#0046D5", relief=tk.SOLID) - def set_geometry(self): - """ Set GUI geometry """ - fullscreen = self._config.user_config_dict["fullscreen"] - scaling_factor = self._config.scaling_factor - - if fullscreen: - initial_dimensions = (self.winfo_screenwidth(), self.winfo_screenheight()) - else: - initial_dimensions = (round(1200 * scaling_factor), round(640 * scaling_factor)) - - if fullscreen and sys.platform == "win32": - self.state('zoomed') - elif fullscreen: - self.attributes('-zoomed', True) - else: - self.geometry("{}x{}+80+80".format(str(initial_dimensions[0]), - str(initial_dimensions[1]))) - logger.debug("Geometry: %sx%s", *initial_dimensions) - def build_gui(self, rebuild=False): """ Build the GUI """ logger.debug("Building GUI") @@ -183,7 +164,7 @@ def close_app(self, *args): # pylint: disable=unused-argument get_images().delete_preview() self.quit() logger.debug("Closed GUI") - exit() + sys.exit(0) def _confirm_close_on_running_task(self): """ Pop a confirmation box to close the GUI if a task is running diff --git a/tools/preview.py b/tools/preview.py index 922a40039d..37cd45d943 100644 --- a/tools/preview.py +++ b/tools/preview.py @@ -17,7 +17,7 @@ from lib.aligner import Extract as AlignerExtract from lib.cli import ConvertArgs -from lib.gui.utils import get_images, initialize_config, initialize_images +from lib.gui.utils import get_images, get_config, initialize_config, initialize_images from lib.gui.custom_widgets import Tooltip from lib.gui.control_helper import ControlPanel, ControlPanelOption from lib.convert import Converter @@ -53,7 +53,6 @@ def __init__(self, arguments): super().__init__() self._config_tools = ConfigTools() self._lock = Lock() - self._scaling = self._get_scaling() self._tk_vars = dict(refresh=tk.BooleanVar(), busy=tk.BooleanVar()) for val in self._tk_vars.values(): @@ -78,7 +77,7 @@ def __init__(self, arguments): @property def _available_masks(self): - """ list: The mask names that are available for every face in the alignmnets file """ + """ list: The mask names that are available for every face in the alignments file """ retval = [key for key, val in self._samples.alignments.mask_summary.items() if val == self._samples.alignments.faces_count] @@ -89,7 +88,7 @@ def _initialize_tkinter(self): logger.debug("Initializing tkinter") initialize_config(self, None, None, None) initialize_images() - self._set_geometry() + get_config().set_geometry(940, 600, fullscreen=False) self.title("Faceswap.py - Convert Settings") self.tk.call( "wm", @@ -97,26 +96,6 @@ def _initialize_tkinter(self): self._w, get_images().icons["favicon"]) # pylint:disable=protected-access logger.debug("Initialized tkinter") - def _get_scaling(self): - """ Get dpi and update scaling for the display. - - Returns - ------- - float: The scaling factor for display - """ - dpi = self.winfo_fpixels("1i") - scaling = dpi / 72.0 - logger.debug("dpi: %s, scaling: %s'", dpi, scaling) - return scaling - - def _set_geometry(self): - """ Set the GUI window geometry. """ - self.tk.call("tk", "scaling", self._scaling) - width = int(940 * self._scaling) - height = int(600 * self._scaling) - logger.debug("Geometry: %sx%s", width, height) - self.geometry("{}x{}+80+80".format(str(width), str(height))) - def process(self): """ The entry point for the Preview tool from :file:`lib.tools.cli`. @@ -152,7 +131,7 @@ def _build_ui(self): container.pack(fill=tk.BOTH, expand=True) container.preview_display = self._display self._image_canvas = ImagesCanvas(container, self._tk_vars) - container.add(self._image_canvas, height=400 * self._scaling) + container.add(self._image_canvas, height=400 * get_config().scaling_factor) options_frame = ttk.Frame(container) self._cli_frame = ActionFrame( @@ -168,8 +147,7 @@ def _build_ui(self): self._tk_vars) self._opts_book = OptionsBook(options_frame, self._config_tools, - self._refresh, - self._scaling) + self._refresh) container.add(options_frame) @@ -1320,21 +1298,18 @@ class OptionsBook(ttk.Notebook): # pylint:disable=too-many-ancestors Tools for loading and saving configuration files patch_callback: python function The function to execute when a patch callback is received - scaling: float - The scaling factor for display Attributes ---------- config_tools: :class:`ConfigTools` Tools for loading and saving configuration files """ - def __init__(self, parent, config_tools, patch_callback, scaling): - logger.debug("Initializing %s: (parent: %s, config: %s, scaling: %s)", - self.__class__.__name__, parent, config_tools, scaling) + def __init__(self, parent, config_tools, patch_callback): + logger.debug("Initializing %s: (parent: %s, config: %s)", + self.__class__.__name__, parent, config_tools) super().__init__(parent) self.pack(side=tk.RIGHT, anchor=tk.N, fill=tk.BOTH, expand=True) self.config_tools = config_tools - self._scaling = scaling self._tabs = dict() self._build_tabs() From 86d039c24202d5d39f008e56b9b50a22adecfa8d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 3 Jan 2020 14:13:58 +0000 Subject: [PATCH 196/981] Convert - bugfixes - Default mask to an available mask in Preview tool - Correctly output predicted mask --- plugins/convert/mask/mask_blend.py | 10 ++++------ tools/preview.py | 28 ++++++++++++++++++++-------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index 1beadc7a02..778be9ebab 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -49,10 +49,8 @@ def process(self, detected_face, predicted_mask=None): # pylint:disable=argumen raw_mask = mask.copy() if not self.skip and self._do_erode: mask = self._erode(mask) - raw_mask = np.expand_dims(raw_mask, axis=-1) if raw_mask.ndim != 3 else raw_mask - mask = np.expand_dims(mask, axis=-1) if mask.ndim != 3 else mask logger.trace("mask shape: %s, raw_mask shape: %s", mask.shape, raw_mask.shape) - return mask.astype("float32") / 255.0, raw_mask.astype("float32") / 255.0 + return mask, raw_mask def _get_mask(self, detected_face, predicted_mask): """ Return the requested mask with any requested blurring applied. @@ -72,9 +70,9 @@ def _get_mask(self, detected_face, predicted_mask): """ if self.mask_type == "none": # Return a dummy mask if not using a mask - mask = np.ones_like(self.dummy[:, :, 1], dtype="uint8") * 255 + mask = np.ones_like(self.dummy[:, :, 1], dtype="float32")[..., None] elif self.mask_type == "predicted": - mask = predicted_mask + mask = predicted_mask[..., None] else: mask = detected_face.mask[self.mask_type] mask.set_blur_and_threshold(blur_kernel=self.config["kernel_size"], @@ -82,7 +80,6 @@ def _get_mask(self, detected_face, predicted_mask): blur_passes=self.config["passes"], threshold=self.config["threshold"]) mask = self._crop_to_coverage(mask.mask) - mask_size = mask.shape[0] face_size = self.dummy.shape[0] if mask_size != face_size: @@ -90,6 +87,7 @@ def _get_mask(self, detected_face, predicted_mask): mask = cv2.resize(mask, self.dummy.shape[:2], interpolation=interp)[..., None] + mask = mask.astype("float32") / 255.0 logger.trace(mask.shape) return mask diff --git a/tools/preview.py b/tools/preview.py index 37cd45d943..ddd8e81260 100644 --- a/tools/preview.py +++ b/tools/preview.py @@ -62,6 +62,7 @@ def __init__(self, arguments): trigger_patch = Event() self._samples = Samples(arguments, 5, self._display, self._lock, trigger_patch) self._patch = Patch(arguments, + self._available_masks, self._samples, self._display, self._lock, @@ -356,6 +357,8 @@ class Patch(): ---------- arguments: :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in from :mod:`tools.py` + available_masks: list + The masks that are available for convert samples: :class:`Samples` The Samples for display. display: :class:`FacesDisplay` @@ -376,10 +379,12 @@ class Patch(): current_config::class:`lib.config.FaceswapConfig` The currently set configuration for the patch queue """ - def __init__(self, arguments, samples, display, lock, trigger, config_tools, tk_vars): - logger.debug("Initializing %s: (arguments: '%s', samples: %s: display: %s, lock: %s," - " trigger: %s, config_tools: %s, tk_vars %s)", self.__class__.__name__, - arguments, samples, display, lock, trigger, config_tools, tk_vars) + def __init__(self, arguments, available_masks, samples, + display, lock, trigger, config_tools, tk_vars): + logger.debug("Initializing %s: (arguments: '%s', available_masks: %s, samples: %s, " + "display: %s, lock: %s, trigger: %s, config_tools: %s, tk_vars %s)", + self.__class__.__name__, arguments, available_masks, samples, display, lock, + trigger, config_tools, tk_vars) self._samples = samples self._queue_patch_in = queue_manager.get_queue("preview_patch_in") self._display = display @@ -393,7 +398,8 @@ def __init__(self, arguments, samples, display, lock, trigger, config_tools, tk_ coverage_ratio=self._samples.predictor.coverage_ratio, draw_transparent=False, pre_encode=None, - arguments=self._generate_converter_arguments(arguments), + arguments=self._generate_converter_arguments(arguments, + available_masks), configfile=configfile) self._shutdown = Event() @@ -419,20 +425,23 @@ def converter(self): return self._converter @staticmethod - def _generate_converter_arguments(arguments): - """ Add the default converter arguments to the initial arguments. + def _generate_converter_arguments(arguments, available_masks): + """ Add the default converter arguments to the initial arguments. Ensure the mask selection + is available. Parameters ---------- arguments: :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in from :mod:`tools.py` - + available_masks: list + The masks that are available for convert Returns ---------- arguments: :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in with converter default arguments added """ + valid_masks = available_masks + ["none"] converter_arguments = ConvertArgs(None, "convert").get_optional_arguments() for item in converter_arguments: value = item.get("default", None) @@ -440,6 +449,9 @@ def _generate_converter_arguments(arguments): if value is None: continue option = item.get("dest", item["opts"][1].replace("--", "")) + if option == "mask_type" and value not in valid_masks: + logger.debug("Amending default mask from '%s' to '%s'", value, valid_masks[0]) + value = valid_masks[0] # Skip options already in arguments if hasattr(arguments, option): continue From ff76461a2750afb3612ae4d4947a9156528a6c6d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 3 Jan 2020 14:54:49 +0000 Subject: [PATCH 197/981] lib.cli - Add dfaker tooltip and typo fix. --- lib/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/cli.py b/lib/cli.py index 6573f73bea..da8b230dd7 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -1078,13 +1078,14 @@ def get_argument_list(): "choices": PluginLoader.get_available_models(), "default": PluginLoader.get_default_model(), "group": "model", - "help": "R|Select which trainer to use. Trainers can be" + "help": "R|Select which trainer to use. Trainers can be " "configured from the Settings menu or the config folder." "\nL|original: The original model created by /u/deepfakes." "\nL|dfaker: 64px in/128px out model from dfaker. " "Enable 'warp-to-landmarks' for full dfaker method." "\nL|dfl-h128. 128px in/out model from deepfacelab" "\nL|dfl-sae. Adaptable model from deepfacelab" + "\nL|dlight. A lightweight, high resolution DFaker variant." "\nL|iae: A model that uses intermediate layers to try to " "get better details" "\nL|lightweight: A lightweight model for low-end cards. " From 1be2fd1e5536d8ea46588e4ac89ec6015ab6c245 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Fri, 10 Jan 2020 14:05:12 +0200 Subject: [PATCH 198/981] plugins.train.model.villain - Change fixed input size to variable (#960) --- plugins/train/model/villain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/train/model/villain.py b/plugins/train/model/villain.py index 90c202032f..e662f60bd5 100644 --- a/plugins/train/model/villain.py +++ b/plugins/train/model/villain.py @@ -38,7 +38,7 @@ def encoder(self): tmp_x = var_x res_cycles = 8 if self.config.get("lowmem", False) else 16 for _ in range(res_cycles): - nn_x = self.blocks.res_block(var_x, 128, **kwargs) + nn_x = self.blocks.res_block(var_x, in_conv_filters, **kwargs) var_x = nn_x # consider adding scale before this layer to scale the residual chain var_x = add([var_x, tmp_x]) From 04b3f860a56b03f5f3b618f04196a7e7a553f1cc Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 20 Jan 2020 00:05:34 +0000 Subject: [PATCH 199/981] setup.py - pin plaidml to 0.6.4 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ec6483eebe..6f89e3c39d 100755 --- a/setup.py +++ b/setup.py @@ -274,7 +274,7 @@ def update_tf_dep_conda(self): def update_amd_dep(self): """ Update amd dependency for AMD cards """ if self.enable_amd: - self.required_packages.append("plaidml-keras==0.6.4") + self.required_packages.extend(["plaidml-keras==0.6.4", "plaidml==0.6.4"]) def set_config(self): """ Set the backend in the faceswap config file """ From ca4060f45fee4811919ccd31cd2b09e9e4324ec4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 25 Jan 2020 19:33:50 +0000 Subject: [PATCH 200/981] lib.image.read_image_batch - Force deterministic return order --- lib/image.py | 32 ++++++++++++++------------------ lib/training_data.py | 2 +- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/lib/image.py b/lib/image.py index e090559f3a..e6a03bb890 100644 --- a/lib/image.py +++ b/lib/image.py @@ -93,14 +93,8 @@ def read_image(filename, raise_error=False, with_hash=False): def read_image_batch(filenames): """ Load a batch of images from the given file locations. - Leverages multi-threading to load multiple images from disk at the same time - leading to vastly reduced image read times. - - Notes - ----- - Images are loaded concurrently, so the order of the returned batch will likely not be the same - as the order of the input filenames. Filenames are returned with the batch in the correct order - corresponding to the returned batch. + Leverages multi-threading to load multiple images from disk at the same time leading to vastly + reduced image read times. Parameters ---------- @@ -109,10 +103,8 @@ def read_image_batch(filenames): Returns ------- - list - Filenames in the correct order as they are returned numpy.ndarray - The batch of images in `BGR` channel order. + The batch of images in `BGR` channel order returned in the order of :attr:`filenames` Notes ----- @@ -121,21 +113,25 @@ def read_image_batch(filenames): Example ------- >>> image_filenames = ["/path/to/image_1.png", "/path/to/image_2.png", "/path/to/image_3.png"] - >>> filenames, images = read_image_batch(image_filenames) + >>> images = read_image_batch(image_filenames) """ logger.trace("Requested batch: '%s'", filenames) executor = futures.ThreadPoolExecutor() with executor: images = {executor.submit(read_image, filename, raise_error=True): filename for filename in filenames} - batch = [] - filenames = [] + batch = [None for _ in range(len(filenames))] + # There is no guarantee that the same filename will not be passed through multiple times + # (and when shuffle is true this can definitely happen), so we can't just call + # filenames.index(). + return_indices = {filename: [idx for idx, fname in enumerate(filenames) + if fname == filename] + for filename in set(filenames)} for future in futures.as_completed(images): - batch.append(future.result()) - filenames.append(images[future]) - batch = np.array(batch) + batch[return_indices[images[future]].pop()] = future.result() + batch = np.array(batch) logger.trace("Returning images: (filenames: %s, batch shape: %s)", filenames, batch.shape) - return filenames, batch + return batch def read_image_hash(filename): diff --git a/lib/training_data.py b/lib/training_data.py index b8cffd7d29..ba6cba79ed 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -194,7 +194,7 @@ def _process_batch(self, filenames, side): """ Performs the augmentation and compiles target images and samples. See :func:`minibatch_ab` for more details on the output. """ logger.trace("Process batch: (filenames: '%s', side: '%s')", filenames, side) - filenames, batch = read_image_batch(filenames) + batch = read_image_batch(filenames) batch = self._apply_mask(filenames, batch, side) processed = dict() From 76bf61099687802927cef95725294e2b2716d6e7 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 26 Jan 2020 00:34:59 +0000 Subject: [PATCH 201/981] plugins.extract.mask - Enable allow_growth option for mask tool --- plugins/extract/mask/unet_dfl.py | 3 ++- plugins/extract/mask/vgg_clear.py | 7 ++++--- plugins/extract/mask/vgg_obstructed.py | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/plugins/extract/mask/unet_dfl.py b/plugins/extract/mask/unet_dfl.py index 79b8c5fbed..dd60727f31 100644 --- a/plugins/extract/mask/unet_dfl.py +++ b/plugins/extract/mask/unet_dfl.py @@ -32,7 +32,8 @@ def __init__(self, **kwargs): self.batchsize = self.config["batch-size"] def init_model(self): - self.model = KSession(self.name, self.model_path, model_kwargs=dict()) + self.model = KSession(self.name, self.model_path, + model_kwargs=dict(), allow_growth=self.config["allow_growth"]) self.model.load_model() placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), dtype="float32") diff --git a/plugins/extract/mask/vgg_clear.py b/plugins/extract/mask/vgg_clear.py index c363f4f1fc..3b5cb46682 100644 --- a/plugins/extract/mask/vgg_clear.py +++ b/plugins/extract/mask/vgg_clear.py @@ -11,7 +11,7 @@ Model file sourced from... https://github.com/YuvalNirkin/face_segmentation/releases/download/1.1/face_seg_fcn8s_300_no_aug.zip -Caffe model reimplemented in Keras by Kyle Vrooman +Caffe model re-implemented in Keras by Kyle Vrooman """ import numpy as np @@ -28,12 +28,13 @@ def __init__(self, **kwargs): self.name = "VGG Clear" self.input_size = 300 self.vram = 2944 - self.vram_warnings = 1088 # at BS 1. OOMs at higher batchsizes + self.vram_warnings = 1088 # at BS 1. OOMs at higher batch sizes self.vram_per_batch = 400 self.batchsize = self.config["batch-size"] def init_model(self): - self.model = KSession(self.name, self.model_path, model_kwargs=dict()) + self.model = KSession(self.name, self.model_path, + model_kwargs=dict(), allow_growth=self.config["allow_growth"]) self.model.load_model() self.model.append_softmax_activation(layer_index=-1) placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), diff --git a/plugins/extract/mask/vgg_obstructed.py b/plugins/extract/mask/vgg_obstructed.py index 60248c74dd..95d7056fe8 100644 --- a/plugins/extract/mask/vgg_obstructed.py +++ b/plugins/extract/mask/vgg_obstructed.py @@ -11,7 +11,7 @@ Model file sourced from... https://github.com/YuvalNirkin/face_segmentation/releases/download/1.0/face_seg_fcn8s.zip -Caffe model reimplemented in Keras by Kyle Vrooman +Caffe model re-implemented in Keras by Kyle Vrooman """ import numpy as np @@ -33,7 +33,8 @@ def __init__(self, **kwargs): self.batchsize = self.config["batch-size"] def init_model(self): - self.model = KSession(self.name, self.model_path, model_kwargs=dict()) + self.model = KSession(self.name, self.model_path, + model_kwargs=dict(), allow_growth=self.config["allow_growth"]) self.model.load_model() self.model.append_softmax_activation(layer_index=-1) placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), From fc3a24ad334aa96f665bf1980b3d1024e0cf8743 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 3 Feb 2020 10:23:06 +0000 Subject: [PATCH 202/981] Update sphinx_requirements.txt --- docs/sphinx_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index fb2a38e5a2..572d7cea4b 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -22,4 +22,4 @@ h5py==2.9.0 Keras==2.2.4 pywin32 ; sys_platform == "win32" pynvx==0.0.4 ; sys_platform == "darwin" -tensorflow +tensorflow==1.15 From 4cb2b4045402e5cae9aeb57a44c3af8557eb18dc Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 13 Feb 2020 09:46:44 +0000 Subject: [PATCH 203/981] bugfix: lib.alignments - mask summary datatype --- lib/alignments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/alignments.py b/lib/alignments.py index 1557c392c6..1295ff3a31 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -193,7 +193,7 @@ def mask_summary(self): for face in faces: if face.get("mask", None) is None: masks["none"] = masks.get("none", 0) + 1 - for key in face.get("mask", dict): + for key in face.get("mask", dict()): masks[key] = masks.get(key, 0) + 1 return masks From e6a27953820652f1000ab3571d176b4821fcdbf4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 16 Feb 2020 11:06:05 +0000 Subject: [PATCH 204/981] lib.gui.stats - Skip sessions with data corruption --- lib/gui/stats.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/lib/gui/stats.py b/lib/gui/stats.py index 16fbb4efb8..a9619ff52f 100644 --- a/lib/gui/stats.py +++ b/lib/gui/stats.py @@ -10,6 +10,7 @@ import numpy as np import tensorflow as tf +from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module from lib.serializer import get_serializer logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -86,13 +87,18 @@ def get_timestamps(self, session=None): if session is not None and sess != session: logger.debug("Skipping sessions: %s", sess) continue - for logfile in sides.values(): - timestamps = [event.wall_time - for event in tf.train.summary_iterator(logfile) - if event.summary.value] - logger.debug("Total timestamps for session %s: %s", sess, len(timestamps)) - all_timestamps[sess] = timestamps - break # break after first file read + try: + for logfile in sides.values(): + timestamps = [event.wall_time + for event in tf.train.summary_iterator(logfile) + if event.summary.value] + logger.debug("Total timestamps for session %s: %s", sess, len(timestamps)) + all_timestamps[sess] = timestamps + break # break after first file read + except tf_errors.DataLossError as err: + logger.warning("The logs for Session %s are corrupted and cannot be displayed. " + "The totals do not include this session. Original error message: " + "'%s'", sess, str(err)) return all_timestamps @@ -119,7 +125,7 @@ def batchsize(self): @property def config(self): """ Return config and other information """ - retval = {key: val for key, val in self.state["config"]} + retval = self.state["config"].copy() retval["training_size"] = self.state["training_size"] retval["input_size"] = [val[0] for key, val in self.state["inputs"].items() if key.startswith("face")][0] @@ -193,7 +199,7 @@ def total_loss(self): """ Return collated loss for all session """ loss_dict = dict() all_loss = self.tb_logs.get_loss() - for key in sorted(int(idx) for idx in all_loss.keys()): + for key in sorted(int(idx) for idx in all_loss): for loss_key, side_loss in all_loss[key].items(): for side, loss in side_loss.items(): loss_dict.setdefault(loss_key, dict()).setdefault(side, list()).extend(loss) @@ -397,7 +403,7 @@ def get_raw(self): if len(iterations) > 1: # Crop all losses to the same number of items if self.iterations == 0: - raw = {lossname: list() for lossname in raw.keys()} + raw = {lossname: list() for lossname in raw} else: raw = {lossname: loss[:self.iterations] for lossname, loss in raw.items()} @@ -498,10 +504,8 @@ def calc_avg(self, data): if idx < presample or idx >= datapoints - postsample: avgs.append(None) continue - else: - avg = sum(data[idx - presample:idx + postsample]) \ - / self.args["avg_samples"] - avgs.append(avg) + avg = sum(data[idx - presample:idx + postsample]) / self.args["avg_samples"] + avgs.append(avg) logger.debug("Calculated Average") return avgs From f1b1535514c04a9855c896fabbdace9bc1c94a20 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 19 Feb 2020 10:21:00 +0000 Subject: [PATCH 205/981] tools.alignments - Fix update hashes --- tools/lib_alignments/media.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tools/lib_alignments/media.py b/tools/lib_alignments/media.py index 4eca7e7161..bb48b3a94a 100644 --- a/tools/lib_alignments/media.py +++ b/tools/lib_alignments/media.py @@ -4,6 +4,8 @@ import logging import os +import sys + import cv2 import numpy as np from tqdm import tqdm @@ -47,7 +49,7 @@ def check_file_exists(alignments_file): logger.info("Using extracted DFL faces for alignments") elif not os.path.isfile(alignments_file): logger.error("ERROR: alignments file not found at: '%s'", alignments_file) - exit(0) + sys.exit(0) if folder: logger.verbose("Alignments file exists at '%s'", alignments_file) return folder, filename @@ -57,6 +59,19 @@ def save(self): self.backup() super().save() + def add_face_hashes(self, frame_name, hashes): + """ Recalculate face hashes """ + logger.trace("Adding face hash: (frame: '%s', hashes: %s)", frame_name, hashes) + faces = self.get_faces_in_frame(frame_name) + count_match = len(faces) - len(hashes) + if count_match != 0: + msg = "more" if count_match > 0 else "fewer" + logger.warning("There are %s %s face(s) in the alignments file than exist in the " + "faces folder. Check your sources for frame '%s'.", + abs(count_match), msg, frame_name) + for idx, i_hash in hashes.items(): + faces[idx]["hash"] = i_hash + class MediaLoader(): """ Class to load filenames from folder """ @@ -99,7 +114,7 @@ def check_input_folder(self): "found".format(loadtype, self.folder)) if err: logger.error(err) - exit(0) + sys.exit(0) if (loadtype == "Frames" and os.path.isfile(self.folder) and From 4483553195efee42fafa73228dda5ee0da691294 Mon Sep 17 00:00:00 2001 From: xirvian <53950982+xirvian@users.noreply.github.com> Date: Fri, 21 Feb 2020 12:45:21 +0100 Subject: [PATCH 206/981] Fix no-augment-color deprecation warning (#972) * Update sphinx_requirements.txt * Fix no-augment-color deprecation warning Co-authored-by: torzdf <36920800+torzdf@users.noreply.github.com> --- docs/sphinx_requirements.txt | 2 +- scripts/train.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index fb2a38e5a2..572d7cea4b 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -22,4 +22,4 @@ h5py==2.9.0 Keras==2.2.4 pywin32 ; sys_platform == "win32" pynvx==0.0.4 ; sys_platform == "darwin" -tensorflow +tensorflow==1.15 diff --git a/scripts/train.py b/scripts/train.py index 035bc9cefb..947dccbf7a 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -147,7 +147,7 @@ def process(self): deprecation_warning("`-wl`, ``--warp-to-landmarks``", additional_info="This option will be available within training " "config settings (/config/train.ini).") - if hasattr(self._args, "no_augment_color") and self._args.no_flip: + if hasattr(self._args, "no_augment_color") and self._args.no_augment_color: deprecation_warning("`-nac`, ``--no-augment-color``", additional_info="This option will be available within training " "config settings (/config/train.ini).") From 673727ecda3a11f5edee2e54b0829f87aee7ef04 Mon Sep 17 00:00:00 2001 From: Jakub Kramarz Date: Mon, 2 Mar 2020 11:16:35 +0100 Subject: [PATCH 207/981] Update Tensorflow Docker image (#976) * Update Tensorflow Docker image It is not possible to build project against TF 1.12 container, as it is based on old version of Python. Also, solves #970 Co-authored-by: torzdf <36920800+torzdf@users.noreply.github.com> --- Dockerfile.gpu | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Dockerfile.gpu b/Dockerfile.gpu index 41ab995adb..e8d45763d1 100755 --- a/Dockerfile.gpu +++ b/Dockerfile.gpu @@ -1,4 +1,6 @@ -FROM tensorflow/tensorflow:1.12.0-gpu-py3 +FROM tensorflow/tensorflow:1.15.0-gpu-py3 + +ENV DEBIAN_FRONTEND noninteractive RUN add-apt-repository -y ppa:jonathonf/ffmpeg-4 \ && apt-get update -qq -y \ @@ -12,11 +14,6 @@ RUN pip3 --no-cache-dir install -r /opt/requirements.txt && rm /opt/requirements RUN pip3 install jupyter matplotlib RUN pip3 install jupyter_http_over_ws RUN jupyter serverextension enable --py jupyter_http_over_ws -# patch for tensorflow:latest-gpu-py3 image -RUN cd /usr/local/cuda/lib64 \ - && mv stubs/libcuda.so ./ \ - && ln -s libcuda.so libcuda.so.1 \ - && ldconfig WORKDIR "/notebooks" CMD ["jupyter-notebook", "--allow-root" ,"--port=8888" ,"--no-browser" ,"--ip=0.0.0.0"] From 2b5b871156836aa49fab3f7f636acab1d01d9b6b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 2 Mar 2020 17:13:32 +0000 Subject: [PATCH 208/981] lib.alignments - Slight update (#978) * lib.alignments update: - Minor structure change (faces to nested dictionary) - Refactor internal and external methods - Documentation --- docs/full/lib.alignments.rst | 7 + docs/full/lib.rst | 1 + lib/alignments.py | 558 ++++++++++++++++++++++++---------- scripts/extract.py | 2 +- scripts/fsmedia.py | 8 +- tools/lib_alignments/jobs.py | 34 +-- tools/lib_alignments/media.py | 24 +- 7 files changed, 441 insertions(+), 193 deletions(-) create mode 100644 docs/full/lib.alignments.rst diff --git a/docs/full/lib.alignments.rst b/docs/full/lib.alignments.rst new file mode 100644 index 0000000000..850dc4a272 --- /dev/null +++ b/docs/full/lib.alignments.rst @@ -0,0 +1,7 @@ +lib.alignments module +===================== + +.. automodule:: lib.alignments + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/full/lib.rst b/docs/full/lib.rst index a23f6524a2..88d2e4b6c8 100644 --- a/docs/full/lib.rst +++ b/docs/full/lib.rst @@ -3,6 +3,7 @@ lib package .. toctree:: + lib.alignments lib.convert lib.faces_detect lib.image diff --git a/lib/alignments.py b/lib/alignments.py index 1295ff3a31..8641d62ffe 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -""" Alignments file functions for reading, writing and manipulating - a serialized alignments file """ +""" Alignments file functions for reading, writing and manipulating the data stored in a +serialized alignments file. """ import logging import os @@ -15,22 +15,31 @@ class Alignments(): - """ Holds processes pertaining to the alignments file. - - folder: folder alignments file is stored in - filename: Filename of alignments file. If a - valid extension is provided, then it will be used to - decide the serializer otherwise compressed pickle is used. + """ The alignments file is a custom serialized ``.fsa`` file that holds information for each + frame for a video or series of images. + + Specifically, it holds a list of faces that appear in each frame. Each face contains + information detailing their detected bounding box location within the frame, the 68 point + facial landmarks and any masks that have been extracted. + + Additionally it can also hold video meta information (timestamp and whether a frame is a + key frame.) + + Parameters + ---------- + folder: str + The folder that contains the alignments ``.fsa`` file + filename: str, optional + The filename of the ``.fsa`` alignments file. If not provided then the given folder will be + checked for a default alignments file filename. Default: "alignments" """ - # pylint: disable=too-many-public-methods def __init__(self, folder, filename="alignments"): logger.debug("Initializing %s: (folder: '%s', filename: '%s')", self.__class__.__name__, folder, filename) - self.serializer = get_serializer("compressed") - self.file = self.get_location(folder, filename) - - self.data = self.load() - self.update_legacy() + self._serializer = get_serializer("compressed") + self._file = self._get_location(folder, filename) + self._data = self._load() + self._update_legacy() self._hashes_to_frame = dict() logger.debug("Initialized %s", self.__class__.__name__) @@ -38,99 +47,150 @@ def __init__(self, folder, filename="alignments"): @property def frames_count(self): - """ Return current frames count """ - retval = len(self.data) + """ int: The number of frames that appear in the alignments :attr:`data`. """ + retval = len(self._data) logger.trace(retval) return retval @property def faces_count(self): - """ Return current faces count """ - retval = sum(len(faces) for faces in self.data.values()) + """ int: The total number of faces that appear in the alignments :attr:`data`. """ + retval = sum(len(val["faces"]) for val in self._data.values()) logger.trace(retval) return retval + @property + def file(self): + """ str: The full path to the currently loaded alignments file. """ + return self._file + + @property + def data(self): + """ dict: The loaded alignments :attr:`file` in dictionary form. """ + return self._data + @property def have_alignments_file(self): - """ Return whether an alignments file exists """ - retval = os.path.exists(self.file) + """ bool: ``True`` if an alignments file exists at location :attr:`file` otherwise + ``False``. """ + retval = os.path.exists(self._file) logger.trace(retval) return retval @property def hashes_to_frame(self): - """ Return :attr:`_hashes_to_frame`. Generate it if it does not exist. - The dict is of each face_hash with their parent frame name(s) and their index - in the frame + """ dict: The SHA1 hash of the face mapped to the frame(s) and face index within the frame + that the hash corresponds to. The structure of the dictionary is: + + {**SHA1_hash** (`str`): {**filename** (`str`): **face_index** (`int`)}}. + + Notes + ----- + The first time this property is referenced, the dictionary will be created and cached. + Subsequent references will be made to this cached dictionary. """ if not self._hashes_to_frame: logger.debug("Generating hashes to frame") - for frame_name, faces in self.data.items(): - for idx, face in enumerate(faces): + for frame_name, val in self._data.items(): + for idx, face in enumerate(val["faces"]): self._hashes_to_frame.setdefault(face["hash"], dict())[frame_name] = idx return self._hashes_to_frame + @property + def mask_summary(self): + """ dict: The mask type names stored in the alignments :attr:`data` as key with the number + of faces which possess the mask type as value. """ + masks = dict() + for val in self._data.values(): + for face in val["faces"]: + if face.get("mask", None) is None: + masks["none"] = masks.get("none", 0) + 1 + for key in face.get("mask", dict()): + masks[key] = masks.get(key, 0) + 1 + return masks + # << INIT FUNCTIONS >> # - def get_location(self, folder, filename): - """ Return the path to alignments file """ + def _get_location(self, folder, filename): + """ Obtains the location of an alignments file. + + If a legacy alignments file is provided/discovered, then the alignments file will be + updated to the custom ``.fsa`` format and saved. + + Parameters + ---------- + folder: str + The folder that the alignments file is located in + filename: str + The filename of the alignments file + + Returns + ------- + str + The full path to the alignments file + """ logger.debug("Getting location: (folder: '%s', filename: '%s')", folder, filename) noext_name, extension = os.path.splitext(filename) if extension in (".json", ".p", ".pickle", ".yaml", ".yml"): # Reformat legacy alignments file - filename = self.update_file_format(folder, filename) + filename = self._update_file_format(folder, filename) logger.debug("Updated legacy alignments. New filename: '%s'", filename) - if extension[1:] == self.serializer.file_extension: + if extension[1:] == self._serializer.file_extension: logger.debug("Valid Alignments filename provided: '%s'", filename) else: - filename = "{}.{}".format(noext_name, self.serializer.file_extension) + filename = "{}.{}".format(noext_name, self._serializer.file_extension) logger.debug("File extension set from serializer: '%s'", - self.serializer.file_extension) + self._serializer.file_extension) location = os.path.join(str(folder), filename) if not os.path.exists(location): - # Test for old format alignments files and reformat if they exist - # This will be executed if an alignments file has not been explicitly provided - # therefore it will not have been picked up in the extension test - self.test_for_legacy(location) + # Test for old format alignments files and reformat if they exist. This will be + # executed if an alignments file has not been explicitly provided therefore it will not + # have been picked up in the extension test + self._test_for_legacy(location) logger.verbose("Alignments filepath: '%s'", location) return location # << I/O >> # - def load(self): - """ Load the alignments data - Override for custom loading logic """ + def _load(self): + """ Load the alignments data from the serialized alignments :attr:`file`. + + Returns + ------- + dict: + The loaded alignments data + """ logger.debug("Loading alignments") if not self.have_alignments_file: raise FaceswapError("Error: Alignments file not found at " - "{}".format(self.file)) + "{}".format(self._file)) - logger.info("Reading alignments from: '%s'", self.file) - data = self.serializer.load(self.file) + logger.info("Reading alignments from: '%s'", self._file) + data = self._serializer.load(self._file) logger.debug("Loaded alignments") return data - def reload(self): - """ Read the alignments data from the correct format """ - logger.debug("Re-loading alignments") - self.data = self.load() - logger.debug("Re-loaded alignments") - def save(self): - """ Write the serialized alignments file """ + """ Write the contents of :attr:`data` to a serialized ``.fsa`` file at the location + :attr:`file`. """ logger.debug("Saving alignments") - logger.info("Writing alignments to: '%s'", self.file) - self.serializer.save(self.file, self.data) + logger.info("Writing alignments to: '%s'", self._file) + self._serializer.save(self._file, self._data) logger.debug("Saved alignments") def backup(self): - """ Backup copy of old alignments """ + """ Create a backup copy of the alignments :attr:`file`. + + Creates a copy of the serialized alignments :attr:`file` appending a + timestamp onto the end of the file name and storing in the same folder as + the original :attr:`file`. + """ logger.debug("Backing up alignments") - if not os.path.isfile(self.file): + if not os.path.isfile(self._file): logger.debug("No alignments to back up") return now = datetime.now().strftime("%Y%m%d_%H%M%S") - src = self.file + src = self._file split = os.path.splitext(src) dst = split[0] + "_" + now + split[1] logger.info("Backing up original alignments to '%s'", dst) @@ -139,37 +199,76 @@ def backup(self): # << VALIDATION >> # - def frame_exists(self, frame): - """ return path of images that have faces """ - retval = frame in self.data.keys() - logger.trace("'%s': %s", frame, retval) + def frame_exists(self, frame_name): + """ Check whether a given frame_name exists within the alignments :attr:`data`. + + Parameters + ---------- + frame_name: str + The frame name to check. This should be the base name of the frame, not the full path + + Returns + ------- + bool + ``True`` if the given frame_name exists within the alignments :attr:`data` + otherwise ``False`` + """ + retval = frame_name in self._data.keys() + logger.trace("'%s': %s", frame_name, retval) return retval - def frame_has_faces(self, frame): - """ Return true if frame exists and has faces """ - retval = bool(self.data.get(frame, list())) - logger.trace("'%s': %s", frame, retval) + def frame_has_faces(self, frame_name): + """ Check whether a given frame_name exists within the alignments :attr:`data` and contains + at least 1 face. + + Parameters + ---------- + frame_name: str + The frame name to check. This should be the base name of the frame, not the full path + + Returns + ------- + bool + ``True`` if the given frame_name exists within the alignments :attr:`data` and has at + least 1 face associated with it, otherwise ``False`` + """ + retval = bool(self._data.get(frame_name, dict()).get("faces", [])) + logger.trace("'%s': %s", frame_name, retval) return retval - def frame_has_multiple_faces(self, frame): - """ Return true if frame exists and has faces """ - if not frame: + def frame_has_multiple_faces(self, frame_name): + """ Check whether a given frame_name exists within the alignments :attr:`data` and contains + more than 1 face. + + Parameters + ---------- + frame_name: str + The frame_name name to check. This should be the base name of the frame, not the full + path + + Returns + ------- + bool + ``True`` if the given frame_name exists within the alignments :attr:`data` and has more + than 1 face associated with it, otherwise ``False`` + """ + if not frame_name: retval = False else: - retval = bool(len(self.data.get(frame, list())) > 1) - logger.trace("'%s': %s", frame, retval) + retval = bool(len(self._data.get(frame_name, dict()).get("faces", [])) > 1) + logger.trace("'%s': %s", frame_name, retval) return retval def mask_is_valid(self, mask_type): - """ Ensure the given ``mask_type`` is valid for this alignments file. + """ Ensure the given ``mask_type`` is valid for the alignments :attr:`data`. - Every face in the alignments file must have the given mask type to successfully + Every face in the alignments :attr:`data` must have the given mask type to successfully pass the test. Parameters ---------- mask_type: str - The mask type to check against the current alignments + The mask type to check against the current alignments :attr:`data` Returns ------- @@ -179,86 +278,138 @@ def mask_is_valid(self, mask_type): """ retval = any([(face.get("mask", None) is not None and face["mask"].get(mask_type, None) is not None) - for faces in self.data.values() - for face in faces]) + for val in self._data.values() + for face in val["faces"]]) logger.debug(retval) return retval - @property - def mask_summary(self): - """ Dict: The mask types and the number of faces which have each type that exist with in - the loaded alignments """ - masks = dict() - for faces in self.data.values(): - for face in faces: - if face.get("mask", None) is None: - masks["none"] = masks.get("none", 0) + 1 - for key in face.get("mask", dict()): - masks[key] = masks.get(key, 0) + 1 - return masks - # << DATA >> # - def get_faces_in_frame(self, frame): - """ Return the alignments for the selected frame """ - logger.trace("Getting faces for frame: '%s'", frame) - return self.data.get(frame, list()) - - def get_full_frame_name(self, frame): - """ Return a frame with extension for when the extension is - not known """ - retval = next(key for key in self.data.keys() - if key.startswith(frame)) - logger.trace("Requested: '%s', Returning: '%s'", frame, retval) - return retval + def get_faces_in_frame(self, frame_name): + """ Obtain the faces from :attr:`data` associated with a given frame_name. - def count_faces_in_frame(self, frame): - """ Return number of alignments within frame """ - retval = len(self.data.get(frame, list())) + Parameters + ---------- + frame_name: str + The frame name to return faces for. This should be the base name of the frame, not the + full path + + Returns + ------- + list + The list of face dictionaries that appear within the requested frame_name + """ + logger.trace("Getting faces for frame_name: '%s'", frame_name) + return self._data.get(frame_name, dict()).get("faces", []) + + def _count_faces_in_frame(self, frame_name): + """ Return number of faces that appear within :attr:`data` for the given frame_name. + + Parameters + ---------- + frame_name: str + The frame name to return the count for. This should be the base name of the frame, not + the full path + + Returns + ------- + int + The number of faces that appear in the given frame_name + """ + retval = len(self._data.get(frame_name, dict()).get("faces", [])) logger.trace(retval) return retval # << MANIPULATION >> # - def delete_face_at_index(self, frame, idx): - """ Delete the face alignment for given frame at given index """ - logger.debug("Deleting face %s for frame '%s'", idx, frame) - idx = int(idx) - if idx + 1 > self.count_faces_in_frame(frame): - logger.debug("No face to delete: (frame: '%s', idx %s)", frame, idx) + def delete_face_at_index(self, frame_name, face_index): + """ Delete the face for the given frame_name at the given face index from :attr:`data`. + + Parameters + ---------- + frame_name: str + The frame name to remove the face from. This should be the base name of the frame, not + the full path + face_index: int + The index number of the face within the given frame_name to remove + + Returns + ------- + bool + ``True`` if a face was successfully deleted otherwise ``False`` + """ + 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): + logger.debug("No face to delete: (frame_name: '%s', face_index %s)", + frame_name, face_index) return False - del self.data[frame][idx] - logger.debug("Deleted face: (frame: '%s', idx %s)", frame, idx) + del self._data[frame_name]["faces"][face_index] + logger.debug("Deleted face: (frame_name: '%s', face_index %s)", frame_name, face_index) return True - def add_face(self, frame, alignment): - """ Add a new face for a frame and return it's index """ - logger.debug("Adding face to frame: '%s'", frame) - if frame not in self.data: - self.data[frame] = [] - self.data[frame].append(alignment) - retval = self.count_faces_in_frame(frame) - 1 + def add_face(self, frame_name, face): + """ Add a new face for the given frame_name in :attr:`data` and return it's index. + + Parameters + ---------- + frame_name: str + The frame name to add the face to. This should be the base name of the frame, not the + full path + face: dict + The face information to add to the given frame_name, correctly formatted for storing in + :attr:`data` + + Returns + ------- + int + The index of the newly added face within :attr:`data` for the given frame_name + """ + logger.debug("Adding face to frame_name: '%s'", frame_name) + if frame_name not in self._data: + self._data[frame_name] = dict(faces=[]) + self._data[frame_name]["faces"].append(face) + retval = self._count_faces_in_frame(frame_name) - 1 logger.debug("Returning new face index: %s", retval) return retval - def update_face(self, frame, idx, alignment): - """ Replace a face for given frame and index """ - logger.debug("Updating face %s for frame '%s'", idx, frame) - self.data[frame][idx] = alignment + def update_face(self, frame_name, face_index, face): + """ Update the face for the given frame_name at the given face index in :attr:`data`. + + Parameters + ---------- + frame_name: str + The frame name to update the face for. This should be the base name of the frame, not + the full path + face_index: int + The index number of the face within the given frame_name to update + face: dict + The face information to update to the given frame_name at the given face_index, + correctly formatted for storing in :attr:`data` + """ + logger.debug("Updating face %s for frame_name '%s'", face_index, frame_name) + self._data[frame_name]["faces"][face_index] = face - def filter_hashes(self, hashlist, filter_out=False): - """ Filter in or out faces that match the hash list + def filter_hashes(self, hash_list, filter_out=False): + """ Remove faces from :attr:`data` based on a given hash list. - filter_out=True: Remove faces that match in the hash list - filter_out=False: Remove faces that are not in the hash list + Parameters + ---------- + hash_list: list + List of SHA1 hashes in `str` format to use as a filter against :attr:`data` + filter_out: bool, optional + ``True`` if faces should be removed from :attr:`data` when there is a corresponding + match in the given hash_list. ``False`` if faces should be kept in :attr:`data` when + there is a corresponding match in the given hash_list, but removed if there is no + match. Default: ``False`` """ - hashset = set(hashlist) - for filename, frame in self.data.items(): - for idx, face in reversed(list(enumerate(frame))): + hashset = set(hash_list) + for filename, val in self._data.items(): + for idx, face in reversed(list(enumerate(val["faces"]))): if ((filter_out and face.get("hash", None) in hashset) or (not filter_out and face.get("hash", None) not in hashset)): logger.verbose("Filtering out face: (filename: %s, index: %s)", filename, idx) - del frame[idx] + del val["faces"][idx] else: logger.trace("Not filtering out face: (filename: %s, index: %s)", filename, idx) @@ -266,59 +417,93 @@ def filter_hashes(self, hashlist, filter_out=False): # << GENERATORS >> # def yield_faces(self): - """ Yield face alignments for one image """ - for frame_fullname, alignments in self.data.items(): + """ Generator to obtain all faces with meta information from :attr:`data`. The results + are yielded by frame. + + Notes + ----- + The yielded order is non-deterministic. + + Yields + ------ + frame_name: str + The frame name that the face belongs to. This is the base name of the frame, as it + appears in :attr:`data`, not the full path + faces: list + The list of face `dict` objects that exist for this frame + face_count: int + The number of faces that exist within :attr:`data` for this frame + frame_fullname: str + The full path (folder and filename) for the yielded frame + """ + for frame_fullname, val in self._data.items(): frame_name = os.path.splitext(frame_fullname)[0] - face_count = len(alignments) + face_count = len(val["faces"]) logger.trace("Yielding: (frame: '%s', faces: %s, frame_fullname: '%s')", frame_name, face_count, frame_fullname) - yield frame_name, alignments, face_count, frame_fullname - - @staticmethod - def yield_original_index_reverse(image_alignments, number_alignments): - """ Return the correct original index for - alignment in reverse order """ - for idx, _ in enumerate(reversed(image_alignments)): - original_idx = number_alignments - 1 - idx - logger.trace("Yielding: face index %s", original_idx) - yield original_idx + yield frame_name, val["faces"], face_count, frame_fullname # << LEGACY FUNCTIONS >> # - def update_legacy(self): - """ Update legacy alignments """ + def _update_legacy(self): + """ Check whether the alignments are legacy, and if so update them to current alignments + format. """ updated = False - if self.has_legacy_landmarksxy(): + if self._has_legacy_structure(): + self._update_legacy_structure() + + if self._has_legacy_landmarksxy(): logger.info("Updating legacy landmarksXY to landmarks_xy") - self.update_legacy_landmarksxy() + self._update_legacy_landmarksxy() updated = True - if self.has_legacy_landmarks_list(): + if self._has_legacy_landmarks_list(): logger.info("Updating legacy landmarks from list to numpy array") - self.update_legacy_landmarks_list() + self._update_legacy_landmarks_list() updated = True if updated: self.save() # # - # Serializer is now a compressed pickle .fsa format. This used to be any number of serializers - def test_for_legacy(self, location): - """ For alignments filenames passed in with out an extension, test for legacy formats """ + # Serializer is now a compressed pickle custom format. This used to be any number + # of serializers + def _test_for_legacy(self, location): + """ For alignments filenames passed in without an extension, test for legacy + serialization formats and update to current ``.fsa`` format if any are found. + + Parameters + ---------- + location: str + The folder location to check for legacy alignments + """ logger.debug("Checking for legacy alignments file formats: '%s'", location) filename = os.path.splitext(location)[0] for ext in (".json", ".p", ".pickle", ".yaml"): legacy_filename = "{}{}".format(filename, ext) if os.path.exists(legacy_filename): logger.debug("Legacy alignments file exists: '%s'", legacy_filename) - _ = self.update_file_format(*os.path.split(legacy_filename)) + _ = self._update_file_format(*os.path.split(legacy_filename)) break logger.debug("Legacy alignments file does not exist: '%s'", legacy_filename) - def update_file_format(self, folder, filename): - """ Convert old style alignments format to new style format """ + def _update_file_format(self, folder, filename): + """ Convert old style serialized alignments to new ``.fsa`` format. + + Parameters + ---------- + folder: str + The folder that the legacy alignments exist in + filename: str + The file name of the legacy alignments + + Returns + ------- + str + The full path to the newly created ``.fsa`` alignments file + """ logger.info("Reformatting legacy alignments file...") old_location = os.path.join(str(folder), filename) new_location = "{}.{}".format(os.path.splitext(old_location)[0], - self.serializer.file_extension) + self._serializer.file_extension) if os.path.exists(old_location): if os.path.exists(new_location): logger.info("Using existing updated alignments file found at '%s'. If you do not " @@ -328,44 +513,79 @@ def update_file_format(self, folder, filename): logger.info("Old location: '%s', New location: '%s'", old_location, new_location) load_serializer = get_serializer_from_filename(old_location) data = load_serializer.load(old_location) - self.serializer.save(new_location, data) + self._serializer.save(new_location, data) return os.path.basename(new_location) + # # + # Alignments were structured: {frame_name: }. We need to be able to store + # information at the frame level, so new structure is: {frame_name: {faces: }} + def _has_legacy_structure(self): + """ Test whether the alignments file is laid out in the old structure of + `{frame_name: [faces]}` + + Returns + ------- + bool + ``True`` if the file has legacy structure otherwise ``False`` + """ + retval = any(isinstance(val, list) for val in self._data.values()) + logger.debug("legacy structure: %s", retval) + return retval + + def _update_legacy_structure(self): + """ Update legacy alignments files from the format `{frame_name: [faces}` to the + format `{frame_name: {faces: [faces]}`.""" + for key, val in self._data.items(): + self._data[key] = dict(faces=val) + logger.debug("Updated alignments file structure") + # # # Landmarks renamed from landmarksXY to landmarks_xy for PEP compliance - def has_legacy_landmarksxy(self): - """ check for legacy landmarksXY keys """ + def _has_legacy_landmarksxy(self): + """ check for legacy landmarksXY keys. + + Returns + ------- + bool + ``True`` if the alignments file contains legacy `landmarksXY` keys otherwise ``False`` + """ logger.debug("checking legacy landmarksXY") retval = (any(key == "landmarksXY" - for alignments in self.data.values() - for alignment in alignments + for val in self._data.values() + for alignment in val["faces"] for key in alignment)) logger.debug("legacy landmarksXY: %s", retval) return retval - def update_legacy_landmarksxy(self): - """ Update landmarksXY to landmarks_xy and save alignments """ + def _update_legacy_landmarksxy(self): + """ Update legacy `landmarksXY` keys to PEP compliant `landmarks_xy` keys. """ update_count = 0 - for alignments in self.data.values(): - for alignment in alignments: + for val in self._data.values(): + for alignment in val["faces"]: alignment["landmarks_xy"] = alignment.pop("landmarksXY") update_count += 1 logger.debug("Updated landmarks_xy: %s", update_count) # Landmarks stored as list instead of numpy array - def has_legacy_landmarks_list(self): - """ check for legacy landmarks stored as list """ + def _has_legacy_landmarks_list(self): + """ check for legacy landmarks stored as `list` rather than :class:`numpy.ndarray`. + + Returns + ------- + bool + ``True`` if not all landmarks are :class:`numpy.ndarray` otherwise ``False`` + """ logger.debug("checking legacy landmarks as list") retval = not all(isinstance(face["landmarks_xy"], np.ndarray) - for faces in self.data.values() - for face in faces) + for val in self._data.values() + for face in val["faces"]) return retval - def update_legacy_landmarks_list(self): - """ Update landmarksXY to landmarks_xy and save alignments """ + def _update_legacy_landmarks_list(self): + """ Update landmarks stored as `list` to :class:`numpy.ndarray`. """ update_count = 0 - for alignments in self.data.values(): - for alignment in alignments: + for val in self._data.values(): + for alignment in val["faces"]: test = alignment["landmarks_xy"] if not isinstance(test, np.ndarray): alignment["landmarks_xy"] = np.array(test, dtype="float32") diff --git a/scripts/extract.py b/scripts/extract.py index f7490f8e4b..f8dc675b5a 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -281,5 +281,5 @@ def _output_faces(self, saver, extract_media): saver.save(output_filename, image) final_faces.append(face.to_alignment()) - self._alignments.data[os.path.basename(extract_media.filename)] = final_faces + self._alignments.data[os.path.basename(extract_media.filename)] = dict(faces=final_faces) del extract_media diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index eb28633cd7..042ec3b2e9 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -106,8 +106,8 @@ def _set_folder_filename(self, input_is_video): logger.debug("Setting Alignments: (folder: '%s' filename: '%s')", folder, filename) return folder, filename - def load(self): - """ Override the parent :func:`~lib.alignments.Alignments.load` to handle skip existing + def _load(self): + """ Override the parent :func:`~lib.alignments.Alignments._load` to handle skip existing frames and faces on extract. If skip existing has been selected, existing alignments are loaded and returned to the @@ -123,7 +123,7 @@ def load(self): if not self._is_extract: if not self.have_alignments_file: return data - data = super().load() + data = super()._load() return data skip_existing = hasattr(self._args, 'skip_existing') and self._args.skip_existing @@ -137,7 +137,7 @@ def load(self): logger.warning("Skip Existing/Skip Faces selected, but no alignments file found!") return data - data = self.serializer.load(self.file) + data = self._serializer.load(self.file) if skip_faces: # Remove items from alignments that have no faces so they will diff --git a/tools/lib_alignments/jobs.py b/tools/lib_alignments/jobs.py index 7afa352e24..3160f8c723 100644 --- a/tools/lib_alignments/jobs.py +++ b/tools/lib_alignments/jobs.py @@ -40,7 +40,7 @@ def get_source_dir(self, arguments): if (hasattr(arguments, "faces_dir") and arguments.faces_dir and hasattr(arguments, "frames_dir") and arguments.frames_dir): logger.error("Only select a source frames (-fr) or source faces (-fc) folder") - exit(0) + sys.exit(1) elif hasattr(arguments, "faces_dir") and arguments.faces_dir: self.type = "faces" source_dir = arguments.faces_dir @@ -49,7 +49,7 @@ def get_source_dir(self, arguments): source_dir = arguments.frames_dir else: logger.error("No source folder (-fr or -fc) was provided") - exit(0) + sys.exit(1) logger.debug("type: '%s', source_dir: '%s'", self.type, source_dir) return source_dir @@ -75,7 +75,7 @@ def validate(self): if self.type == "faces" and self.job not in ("multi-faces", "leftover-faces"): logger.warning("The selected folder is not valid. Faces folder (-fc) is only " "supported for 'multi-faces' and 'leftover-faces'") - exit(0) + sys.exit(1) def compile_output(self): """ Compile list of frames that meet criteria """ @@ -186,7 +186,7 @@ def output_results(self, items_output): output_message += " {} ({})\r\n".format(self.output_message, len(items_output)) output_message += "-----------------------------------------------\r\n" - output_message += "\r\n".join([frame for frame in items_output]) + output_message += "\r\n".join(items_output) if self.output == "console": for line in output_message.splitlines(): logger.info(line) @@ -263,7 +263,7 @@ def __init__(self, alignments, arguments): self.alignments = alignments if self.alignments.file != "dfl.fsa": logger.error("Alignments file must be specified as 'dfl' to reformat dfl alignmnets") - exit(0) + sys.exit(1) logger.debug("Loading DFL faces") self.faces = Faces(arguments.faces_dir) logger.debug("Initialized %s", self.__class__.__name__) @@ -271,8 +271,7 @@ def __init__(self, alignments, arguments): def process(self): """ Run reformat """ logger.info("[REFORMAT DFL ALIGNMENTS]") # Tidy up cli output - self.alignments.data = self.load_dfl() - self.alignments.file = self.alignments.get_location(self.faces.folder, "alignments") + self.alignments.data_from_dfl(self.load_dfl(), self.faces.folder) self.alignments.save() def load_dfl(self): @@ -330,7 +329,7 @@ def get_dfl_alignment(filename): @staticmethod def convert_dfl_alignment(dfl_alignments, f_hash, alignments): - """ Add DFL Alignments to alignments in Faceswap format """ + """ Add Deep Face Lab Alignments to alignments in Faceswap format """ sourcefile = dfl_alignments["source_filename"] left, top, right, bottom = dfl_alignments["source_rect"] alignment = {"x": left, @@ -340,7 +339,7 @@ def convert_dfl_alignment(dfl_alignments, f_hash, alignments): "hash": f_hash, "landmarks_xy": np.array(dfl_alignments["source_landmarks"], dtype="float32")} logger.trace("Adding alignment: (frame: '%s', alignment: %s", sourcefile, alignment) - alignments.setdefault(sourcefile, list()).append(alignment) + alignments.setdefault(sourcefile, dict()).setdefault("faces", []).append(alignment) class Draw(): @@ -441,7 +440,7 @@ def _check_folder(self): err = "ERROR: Output faces folder should be empty: '{}'".format(self._faces_dir) if err: logger.error(err) - exit(0) + sys.exit(0) logger.verbose("Creating output folder at '%s'", self._faces_dir) def _export_faces(self): @@ -513,7 +512,7 @@ def _output_faces(self, filename, image): f_hash = self._extracted_faces.save_face_with_hash(output, extension, face.aligned_face) - self._alignments.data[filename][idx]["hash"] = f_hash + self._alignments.data[filename]["faces"][idx]["hash"] = f_hash face_count += 1 return face_count @@ -685,7 +684,8 @@ def merge_alignment(self, frame, alignment, idx): logger.debug("Merging alignment: (frame: %s, src_idx: %s, hash: %s)", frame, idx, alignment["hash"]) self._hashes_to_frame.setdefault(alignment["hash"], dict())[frame] = idx - self.final_alignments.data.setdefault(frame, list()).append(alignment) + self.final_alignments.data.setdefault(frame, + dict()).setdefault("faces", []).append(alignment) def set_destination_filename(self): """ Set the destination filename """ @@ -825,7 +825,7 @@ def _build_rename_list(self): "the `remove-faces` job. To get a list of faces missing alignments " "entries, run with VERBOSE logging") logger.verbose("Files in faces folder not in alignments file: %s", errors) - exit(1) + sys.exit(1) return self._sort_mappings(source_filenames, dest_filenames) @staticmethod @@ -990,7 +990,7 @@ def reindex_faces(self): logger.trace("Alignments already in correct order. Not sorting: '%s'", frame) continue logger.trace("Sorting alignments for frame: '%s'", frame) - self.alignments.data[key] = sorted_alignments + self.alignments.data[key]["faces"] = sorted_alignments reindexed += 1 logger.info("%s Frames had their faces reindexed", reindexed) return reindexed @@ -1067,12 +1067,12 @@ def normalized_to_original(shapes_normalized, scale_factors, mean_coords): def normalize(self): """ Compile all original and normalized alignments """ logger.debug("Normalize") - count = sum(1 for val in self.alignments.data.values() if val) + count = sum(1 for val in self.alignments.data.values() if val["faces"]) landmarks_all = np.zeros((68, 2, int(count))) end = 0 for key in tqdm(sorted(self.alignments.data.keys()), desc="Compiling"): - val = self.alignments.data[key] + val = self.alignments.data[key]["faces"] if not val: continue # We should only be normalizing a single face, so just take @@ -1150,7 +1150,7 @@ def update_alignments(self, landmarks): logger.trace("Updating: (frame: %s)", frame) landmarks_update = landmarks[:, :, idx] landmarks_xy = landmarks_update.reshape(68, 2).tolist() - self.alignments.data[frame][0]["landmarks_xy"] = landmarks_xy + self.alignments.data[frame]["faces"][0]["landmarks_xy"] = landmarks_xy logger.trace("Updated: (frame: '%s', landmarks: %s)", frame, landmarks_xy) logger.debug("Updated alignments") diff --git a/tools/lib_alignments/media.py b/tools/lib_alignments/media.py index bb48b3a94a..7ea3f0eb8a 100644 --- a/tools/lib_alignments/media.py +++ b/tools/lib_alignments/media.py @@ -32,8 +32,8 @@ def __init__(self, alignments_file): logger.info("[ALIGNMENT DATA]") # Tidy up cli output folder, filename = self.check_file_exists(alignments_file) if filename.lower() == "dfl": - self.serializer = get_serializer("compressed") - self.file = "{}.{}".format(filename.lower(), self.serializer.file_extension) + self._serializer = get_serializer("compressed") + self.file = "{}.{}".format(filename.lower(), self._serializer.file_extension) return super().__init__(folder, filename=filename) logger.verbose("%s items loaded", self.frames_count) @@ -59,6 +59,12 @@ def save(self): self.backup() super().save() + def reload(self): + """ Read the alignments data from the correct format """ + logger.debug("Re-loading alignments") + self._data = self._load() + logger.debug("Re-loaded alignments") + def add_face_hashes(self, frame_name, hashes): """ Recalculate face hashes """ logger.trace("Adding face hash: (frame: '%s', hashes: %s)", frame_name, hashes) @@ -72,6 +78,20 @@ def add_face_hashes(self, frame_name, hashes): for idx, i_hash in hashes.items(): faces[idx]["hash"] = i_hash + def data_from_dfl(self, alignments, faces_folder): + """ Set :attr:`data` from alignments extracted from a Deep Face Lab face set. + + Parameters + ---------- + alignments: dict + The extracted alignments from a Deep Face Lab face set + faces_folder: str + The folder that the faces are in, where the newly generated alignments file will + be saved + """ + self._data = alignments + self._file = self._get_location(faces_folder, "alignments") + class MediaLoader(): """ Class to load filenames from folder """ From 99926254b73ab72819db74b4b233d98c2a58a528 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 2 Mar 2020 17:35:15 +0000 Subject: [PATCH 209/981] Update sphinx_requirements.txt --- docs/sphinx_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index 572d7cea4b..83f2c36daf 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -22,4 +22,4 @@ h5py==2.9.0 Keras==2.2.4 pywin32 ; sys_platform == "win32" pynvx==0.0.4 ; sys_platform == "darwin" -tensorflow==1.15 +tensorflow==1.12 From 7100b9e0f405dc92214fceb01936234263f1df8e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 2 Mar 2020 18:03:47 +0000 Subject: [PATCH 210/981] Update sphinx_requirements.txt --- docs/sphinx_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index 83f2c36daf..4f83645e73 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -22,4 +22,4 @@ h5py==2.9.0 Keras==2.2.4 pywin32 ; sys_platform == "win32" pynvx==0.0.4 ; sys_platform == "darwin" -tensorflow==1.12 +tensorflow==1.12.0 From bd72add55fdefd0836eb291f144a93fc08c8ceae Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 2 Mar 2020 18:07:28 +0000 Subject: [PATCH 211/981] Update sphinx_requirements.txt --- docs/sphinx_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index 4f83645e73..7f8a503509 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -22,4 +22,4 @@ h5py==2.9.0 Keras==2.2.4 pywin32 ; sys_platform == "win32" pynvx==0.0.4 ; sys_platform == "darwin" -tensorflow==1.12.0 +tensorflow==1.13.1 From 6e08c952f811eec01d887b016b7af6557ef392c0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 4 Mar 2020 15:09:39 +0000 Subject: [PATCH 212/981] bug fix: tools.alignments. Set filename correctly when merging alignments --- tools/lib_alignments/jobs.py | 2 +- tools/lib_alignments/media.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/tools/lib_alignments/jobs.py b/tools/lib_alignments/jobs.py index 3160f8c723..f602b1b719 100644 --- a/tools/lib_alignments/jobs.py +++ b/tools/lib_alignments/jobs.py @@ -694,7 +694,7 @@ def set_destination_filename(self): now = datetime.now().strftime("%Y%m%d_%H%M%S") filename = os.path.join(folder, "alignments_merged_{}{}".format(now, ext)) logger.debug("Output set to: '%s'", filename) - self.final_alignments.file = filename + self.final_alignments.set_filename(filename) class RemoveAlignments(): diff --git a/tools/lib_alignments/media.py b/tools/lib_alignments/media.py index 7ea3f0eb8a..d5830c105d 100644 --- a/tools/lib_alignments/media.py +++ b/tools/lib_alignments/media.py @@ -90,7 +90,17 @@ def data_from_dfl(self, alignments, faces_folder): be saved """ self._data = alignments - self._file = self._get_location(faces_folder, "alignments") + self.set_filename(self._get_location(faces_folder, "alignments")) + + def set_filename(self, filename): + """ Set the :attr:`_file` to the given filename. + + Parameters + ---------- + filename: str + The full path and filename to se the alignments file name to + """ + self._file = filename class MediaLoader(): From e58fd535d582f580d48d9d41854ad5ad982ade15 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 6 Mar 2020 12:10:40 +0000 Subject: [PATCH 213/981] bugfix: CV2-DNN aligner. Fix Assertion error when bounding box falls out of frame --- plugins/extract/align/cv2_dnn.py | 51 ++++++++++++++------------------ 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/plugins/extract/align/cv2_dnn.py b/plugins/extract/align/cv2_dnn.py index 26b2afff0b..c8971aec89 100644 --- a/plugins/extract/align/cv2_dnn.py +++ b/plugins/extract/align/cv2_dnn.py @@ -51,7 +51,7 @@ def init_model(self): def process_input(self, batch): """ Compile the detected faces for prediction """ - faces, batch["roi"] = self.align_image(batch) + faces, batch["roi"], batch["offsets"] = self.align_image(batch) faces = self._normalize_faces(faces) batch["feed"] = np.array(faces, dtype="float32")[..., :3].transpose((0, 3, 1, 2)) return batch @@ -62,6 +62,7 @@ def align_image(self, batch): sizes = (self.input_size, self.input_size) rois = [] faces = [] + offsets = [] for face, image in zip(batch["detected_faces"], batch["image"]): box = (face.left, face.top, @@ -73,15 +74,17 @@ def align_image(self, batch): # Make box square. roi = self.get_square_box(box_moved) - # Pad the image if face is outside of boundaries - image = self.pad_image(roi, image) - face = image[roi[1]: roi[3], roi[0]: roi[2]] + # Pad the image and adjust roi if face is outside of boundaries + image, offset = self.pad_image(roi, image) + face = image[roi[1] + offset[1]: roi[3] + offset[1], + roi[0] + offset[0]: roi[2] + offset[0]] interpolation = cv2.INTER_CUBIC if face.shape[0] < self.input_size else cv2.INTER_AREA face = cv2.resize(face, dsize=sizes, interpolation=interpolation) faces.append(face) rois.append(roi) - return faces, rois + offsets.append(offset) + return faces, rois, offsets @staticmethod def move_box(box, offset): @@ -120,16 +123,6 @@ def get_square_box(box): if diff % 2 == 1: bottom += 1 - # Shift the box if any points fall below zero - if left < 0: - shift_right = abs(left) - right += shift_right - left += shift_right - if top < 0: - shift_down = abs(top) - bottom += shift_down - top += shift_down - # Make sure box is always square. assert ((right - left) == (bottom - top)), 'Box is not square.' @@ -138,21 +131,23 @@ def get_square_box(box): @staticmethod def pad_image(box, image): """Pad image if face-box falls outside of boundaries """ - width, height = image.shape[:2] + height, width = image.shape[:2] pad_l = 1 - box[0] if box[0] < 0 else 0 pad_t = 1 - box[1] if box[1] < 0 else 0 pad_r = box[2] - width if box[2] > width else 0 pad_b = box[3] - height if box[3] > height else 0 logger.trace("Padding: (l: %s, t: %s, r: %s, b: %s)", pad_l, pad_t, pad_r, pad_b) - retval = cv2.copyMakeBorder(image.copy(), - pad_t, - pad_b, - pad_l, - pad_r, - cv2.BORDER_CONSTANT, - value=(0, 0, 0)) - logger.trace("Padded shape: %s", retval.shape) - return retval + padded_image = cv2.copyMakeBorder(image.copy(), + pad_t, + pad_b, + pad_l, + pad_r, + cv2.BORDER_CONSTANT, + value=(0, 0, 0)) + offsets = (pad_l - pad_r, pad_t - pad_b) + logger.trace("image_shape: %s, Padded shape: %s, box: %s, offsets: %s", + image.shape, padded_image.shape, box, offsets) + return padded_image, offsets def predict(self, batch): """ Predict the 68 point landmarks """ @@ -169,10 +164,10 @@ def process_output(self, batch): @staticmethod def get_pts_from_predict(batch): """ Get points from predictor """ - for prediction, roi in zip(batch["prediction"], batch["roi"]): + for prediction, roi, offset in zip(batch["prediction"], batch["roi"], batch["offsets"]): points = np.reshape(prediction, (-1, 2)) points *= (roi[2] - roi[0]) - points[:, 0] += roi[0] - points[:, 1] += roi[1] + points[:, 0] += (roi[0] - offset[0]) + points[:, 1] += (roi[1] - offset[1]) batch.setdefault("landmarks", []).append(points) logger.trace("Predicted Landmarks: %s", batch["landmarks"]) From 00068f84515dd52e3f6b57b05bc0f33987946b58 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 10 Mar 2020 13:32:09 +0000 Subject: [PATCH 214/981] Core updates (#981) * Icon + minor files update * lib.faces_detect - Add option to force reload an aligned face - Make sure a default blur and threshold parameter is set - Add ability to replace an existing mask --- .gitignore | 1 + docs/full/lib.alignments.rst | 2 +- lib/faces_detect.py | 43 +++++++++++++++++++++++++-- lib/gui/.cache/icons/beginning.png | Bin 0 -> 5057 bytes lib/gui/.cache/icons/boundingbox.png | Bin 0 -> 4124 bytes lib/gui/.cache/icons/copy_next.png | Bin 0 -> 4625 bytes lib/gui/.cache/icons/copy_prev.png | Bin 0 -> 4634 bytes lib/gui/.cache/icons/draw.png | Bin 0 -> 4613 bytes lib/gui/.cache/icons/end.png | Bin 0 -> 5068 bytes lib/gui/.cache/icons/erase.png | Bin 0 -> 5621 bytes lib/gui/.cache/icons/extractbox.png | Bin 0 -> 3205 bytes lib/gui/.cache/icons/landmarks.png | Bin 0 -> 4961 bytes lib/gui/.cache/icons/mask.png | Bin 0 -> 4764 bytes lib/gui/.cache/icons/next.png | Bin 0 -> 4342 bytes lib/gui/.cache/icons/pause.png | Bin 0 -> 3379 bytes lib/gui/.cache/icons/play.png | Bin 0 -> 4322 bytes lib/gui/.cache/icons/prev.png | Bin 0 -> 4363 bytes lib/gui/.cache/icons/reload3.png | Bin 0 -> 4710 bytes lib/gui/.cache/icons/view.png | Bin 0 -> 3953 bytes 19 files changed, 42 insertions(+), 4 deletions(-) create mode 100755 lib/gui/.cache/icons/beginning.png create mode 100755 lib/gui/.cache/icons/boundingbox.png create mode 100755 lib/gui/.cache/icons/copy_next.png create mode 100755 lib/gui/.cache/icons/copy_prev.png create mode 100755 lib/gui/.cache/icons/draw.png create mode 100755 lib/gui/.cache/icons/end.png create mode 100755 lib/gui/.cache/icons/erase.png create mode 100755 lib/gui/.cache/icons/extractbox.png create mode 100755 lib/gui/.cache/icons/landmarks.png create mode 100755 lib/gui/.cache/icons/mask.png create mode 100755 lib/gui/.cache/icons/next.png create mode 100755 lib/gui/.cache/icons/pause.png create mode 100755 lib/gui/.cache/icons/play.png create mode 100755 lib/gui/.cache/icons/prev.png create mode 100755 lib/gui/.cache/icons/reload3.png create mode 100755 lib/gui/.cache/icons/view.png diff --git a/.gitignore b/.gitignore index cea035ad10..c859bd4be0 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ !.pylintrc !tools !tools/lib* +!tools/lib*/* !_travis !_travis/* !.travis.yml diff --git a/docs/full/lib.alignments.rst b/docs/full/lib.alignments.rst index 850dc4a272..ed994b4e07 100644 --- a/docs/full/lib.alignments.rst +++ b/docs/full/lib.alignments.rst @@ -4,4 +4,4 @@ lib.alignments module .. automodule:: lib.alignments :members: :undoc-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: diff --git a/lib/faces_detect.py b/lib/faces_detect.py index ec068c20a8..b253cad584 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -216,7 +216,7 @@ def _image_to_face(self, image): self.left: self.right] # <<< Aligned Face methods and properties >>> # - def load_aligned(self, image, size=256, dtype=None): + def load_aligned(self, image, size=256, dtype=None, force=False): """ Align a face from a given image. Aligning a face is a relatively expensive task and is not required for all uses of @@ -235,6 +235,8 @@ def load_aligned(self, image, size=256, dtype=None): The size of the output face in pixels dtype: str, optional Optionally set a ``dtype`` for the final face to be formatted in. Default: ``None`` + force: bool, optional + Force an update of the aligned face, even if it is already loaded. Default: ``False`` Notes ----- @@ -244,7 +246,7 @@ def load_aligned(self, image, size=256, dtype=None): - :func:`aligned_face` - :func:`adjusted_interpolators` """ - if self.aligned: + if self.aligned and not force: # Don't reload an already aligned face logger.trace("Skipping alignment calculation for already aligned face") else: @@ -254,7 +256,7 @@ def load_aligned(self, image, size=256, dtype=None): self.aligned["padding"] = padding self.aligned["matrix"] = get_align_mat(self) self.aligned["face"] = None - if image is not None and self.aligned["face"] is None: + if image is not None and (self.aligned["face"] is None or force): logger.trace("Getting aligned face") face = AlignerExtract().transform(image, self.aligned["matrix"], size, padding) self.aligned["face"] = face if dtype is None else face.astype(dtype) @@ -522,6 +524,7 @@ def __init__(self, storage_size=128): self._blur = dict() self._blur_kernel = 0 self._threshold = 0.0 + self.set_blur_and_threshold() @property def mask(self): @@ -542,6 +545,29 @@ def mask(self): logger.trace("mask shape: %s", mask.shape) return mask + @property + def original_roi(self): + """ :class: `numpy.ndarray`: The original region of interest of the mask in the + source frame. """ + points = np.array([[0, 0], + [0, self.stored_size - 1], + [self.stored_size - 1, self.stored_size - 1], + [self.stored_size - 1, 0]], np.int32).reshape((-1, 1, 2)) + matrix = cv2.invertAffineTransform(self._affine_matrix) + roi = cv2.transform(points, matrix).reshape((4, 2)) + logger.trace("Returning: %s", roi) + return roi + + @property + def affine_matrix(self): + """ :class: `numpy.ndarray`: The affine matrix to transpose the mask to a full frame. """ + return self._affine_matrix + + @property + def interpolator(self): + """ int: The cv2 interpolator required to transpose the mask to a full frame. """ + return self._interpolator + def get_full_frame_mask(self, width, height): """ Return the stored mask in a full size frame of the given dimensions @@ -587,6 +613,17 @@ def add(self, mask, affine_matrix, interpolator): affine_matrix, mask.max(), interpolator) self._affine_matrix = self._adjust_affine_matrix(mask.shape[0], affine_matrix) self._interpolator = interpolator + self.replace_mask(mask) + + def replace_mask(self, mask): + """ Replace the existing :attr:`_mask` with the given mask. + + Parameters + ---------- + mask: numpy.ndarray + The mask that is to be added as output from :mod:`plugins.extract.mask`. + It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` + """ mask = (cv2.resize(mask, (self.stored_size, self.stored_size), interpolation=cv2.INTER_AREA) * 255.0).astype("uint8") diff --git a/lib/gui/.cache/icons/beginning.png b/lib/gui/.cache/icons/beginning.png new file mode 100755 index 0000000000000000000000000000000000000000..a9fdb1f788ca54f2f5141689d13720f893e20bb4 GIT binary patch literal 5057 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%0DFgiRn zCD@r*I`vlK;AW z@2jl%q{pO9XUm<5*^&Y;`?H==l#ohH)2b<^ure{}nGIj-$7pG3iQC+#*n@LQcGVn@ zLZaH*VO**almyRFFO)Hg#Hl-}mM1MWwLpTUeV|qig_;QWnV+AR3v_SD6Q}2Va`qT{ zadDxk6-~kOt>M|3@@f-Su-xRS#-m_AJ1rIUaU<)o$E4hqt7@!i`I+L=Czx|`>e zp2V#dNo@!im)OS&T91s@2nh=h)>n9;)LVlSDJ#(!AhIlrzuvt zr?&;U(Jtzr8A5pPvgOlOk@MA1?t;>>PH4PZVP z0Y>ZGa>zk|NaFoWQL7`i>*|{fK&J-&2K@g(Unv3Yl6${d?VJ9biP~GIcq9@6M8!aH zlC&$l?UJv;k3wgh7j2Zbg}>_bCxOY-g(6gNF>#=n;O2C&U^uvFMS)p9&{PTnd7zza zyifK?j+yN_m#jQGRIDYP0IX3Z@E zbp8GPo1-|Ukx##d5`X=&s`jy~{fwV~g~O@ThkVl0*B=j+4~IM8X`5rGT+J<3uF``L zh~zJ}R$_ZHDV`R94ybqFwMb=U-MPoqmro;MQXBve*mqhD}+Y%I1zQlrBb;e$Q= zwK|G!O-V5=t*os1F7@n^glv%;`~!H)z*8a!h5E9@QNAxSUhT_JC5(#y&Kh)Gpn8G3 zJFMljqR`exf;#Q%gH$|hW?pp8E+gZik6oLi1M?&?{an%IA9DrTK#q=%kWO$%r$TJt z^XyX0!N!mc`CQYKalnD)(Qk`Ib?hH~^wz{x$M#OmcmK^1(-uS>v!F$>irB?P6llUo zPcO$>K99g6_Y_oA*3=TT6$}bGJU))^&nW%uNsf#x_S4Ul2!}N^&b3G4J*Pxsn93p} z$lrSAl0}dT$&KIYPJXjbW8axj*Hl9Y-205(v9vSJJ&c3HJu}P7z}4Yre7!@^JxC0$ z){i%(fVv?&`|d5sg(*E>McUuL($xVQC-XkfK;vhHsHJ8Cjk+lnk*sP~ryn)Djs4n< z>mGHSciEu|#jpn?GqPxgVM6k0O|HYSIe2c&2ej=j=87JG?tkXB4t0k9JHLRQma%4) IhJED!0DSkV3jhEB literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/copy_next.png b/lib/gui/.cache/icons/copy_next.png new file mode 100755 index 0000000000000000000000000000000000000000..e6df6fc7ad8515fd21afea43f265376bcaba7c9d GIT binary patch literal 4625 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 Q_W%F@07*qoM6N<$g7(*_%m4rY literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/draw.png b/lib/gui/.cache/icons/draw.png new file mode 100755 index 0000000000000000000000000000000000000000..c79809bf423e73ce37a98ae9259860a64d4f883f GIT binary patch literal 4613 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 literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/end.png b/lib/gui/.cache/icons/end.png new file mode 100755 index 0000000000000000000000000000000000000000..c79ee55ebd0ac034a18e556f5192c8cedbe9053d GIT binary patch literal 5068 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 P00000NkvXXu0mjfctgCo literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/extractbox.png b/lib/gui/.cache/icons/extractbox.png new file mode 100755 index 0000000000000000000000000000000000000000..bd82f0c54aff7c4c89103a0435e2dccf258af79c GIT binary patch literal 3205 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 literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/mask.png b/lib/gui/.cache/icons/mask.png new file mode 100755 index 0000000000000000000000000000000000000000..ffdc2fa1f30fbc50124c86e974aac20e0d5623a3 GIT binary patch literal 4764 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 literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/pause.png b/lib/gui/.cache/icons/pause.png new file mode 100755 index 0000000000000000000000000000000000000000..c10c1933e0a01c8d8b91b40827b28f3bf80d809e GIT binary patch literal 3379 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 literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/play.png b/lib/gui/.cache/icons/play.png new file mode 100755 index 0000000000000000000000000000000000000000..225f777b76f66e85182d68f9b7c1c274d7cce029 GIT binary patch literal 4322 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 FV1fjk42S># literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/reload3.png b/lib/gui/.cache/icons/reload3.png new file mode 100755 index 0000000000000000000000000000000000000000..5526f5565c747efe14cd4592227ea7dec3f2647f GIT binary patch literal 4710 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 literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/view.png b/lib/gui/.cache/icons/view.png new file mode 100755 index 0000000000000000000000000000000000000000..879ad4450216d7704cf118b8e1ffb9af83073186 GIT binary patch literal 3953 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 JI;i8z{{gLKVlV&z literal 0 HcmV?d00001 From 924d53789ba0ad40158f082839482cbf9060e67c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 10 Mar 2020 14:48:12 +0000 Subject: [PATCH 215/981] Core updates (#982) * plugins.extract.align - Expose normalization method * lib.gui core updates - Minor updates to support future development. --- lib/gui/control_helper.py | 64 ++++++++++------- lib/gui/custom_widgets.py | 126 ++++++++++++++++++++++----------- lib/gui/popup_configure.py | 6 +- lib/gui/wrapper.py | 8 +-- plugins/extract/align/_base.py | 18 ++++- plugins/extract/pipeline.py | 21 ++++-- 6 files changed, 161 insertions(+), 82 deletions(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index f7ad70d99b..7143f058f5 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -321,10 +321,12 @@ class ControlPanel(ttk.Frame): # pylint:disable=too-many-ancestors The width that labels for controls should be set to. Defaults to 20 columns: int, optional + The initial number of columns to set the layout for. Default: 1 + max_columns: int, optional The maximum number of columns that this control panel should be able to accommodate. Setting to 1 means that there will only be 1 column regardless of how wide the control panel is. Higher numbers will - dynamically fill extra columns if space permits. Defaults to 1 + dynamically fill extra columns if space permits. Defaults to 4 option_columns: int, optional For check-button and radio-button containers, how many options should be displayed on each row. Defaults to 4 @@ -334,14 +336,19 @@ class ControlPanel(ttk.Frame): # pylint:disable=too-many-ancestors blank_nones: bool, optional How the control panel should handle None values. If set to True then None values will be converted to empty strings. Default: False + scrollbar: bool, optional + ``True`` if a scrollbar should be added to the control panel, otherwise ``False``. + Default: ``True`` """ def __init__(self, parent, options, # pylint:disable=too-many-arguments - label_width=20, columns=1, option_columns=4, header_text=None, blank_nones=True): + label_width=20, columns=1, max_columns=4, option_columns=4, header_text=None, + blank_nones=True, scrollbar=True): logger.debug("Initializing %s: (parent: '%s', options: %s, label_width: %s, columns: %s, " - "option_columns: %s, header_text: %s, blank_nones: %s)", - self.__class__.__name__, parent, options, label_width, columns, - option_columns, header_text, blank_nones) + "max_columns: %s, option_columns: %s, header_text: %s, blank_nones: %s, " + "scrollbar: %s)", + self.__class__.__name__, parent, options, label_width, columns, max_columns, + option_columns, header_text, blank_nones, scrollbar) super().__init__(parent) self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) @@ -350,18 +357,18 @@ def __init__(self, parent, options, # pylint:disable=too-many-arguments self.controls = [] self.label_width = label_width self.columns = columns + self.max_columns = max_columns self.option_columns = option_columns self.header_text = header_text self.group_frames = dict() - self.canvas = tk.Canvas(self, bd=0, highlightthickness=0) - self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + self._canvas = tk.Canvas(self, bd=0, highlightthickness=0) + self._canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) self.mainframe, self.optsframe = self.get_opts_frame() - self.optscanvas = self.canvas.create_window((0, 0), window=self.mainframe, anchor=tk.NW) - - self.build_panel(blank_nones) + self._optscanvas = self._canvas.create_window((0, 0), window=self.mainframe, anchor=tk.NW) + self.build_panel(blank_nones, scrollbar) logger.debug("Initialized %s", self.__class__.__name__) @@ -373,12 +380,12 @@ def _adjust_wraplength(event): def get_opts_frame(self): """ Return an auto-fill container for the options inside a main frame """ - mainframe = ttk.Frame(self.canvas) + mainframe = ttk.Frame(self._canvas) if self.header_text is not None: self.add_info(mainframe) optsframe = ttk.Frame(mainframe, name="opts_frame") optsframe.pack(expand=True, fill=tk.BOTH) - holder = AutoFillContainer(optsframe, self.columns) + holder = AutoFillContainer(optsframe, self.columns, self.max_columns) logger.debug("Opts frames: '%s'", holder) return mainframe, holder @@ -404,11 +411,12 @@ def add_info(self, frame): info.bind("", self._adjust_wraplength) info.pack(fill=tk.X, padx=0, pady=0, expand=True, side=tk.TOP) - def build_panel(self, blank_nones): + def build_panel(self, blank_nones, scrollbar): """ Build the options frame for this command """ logger.debug("Add Config Frame") - self.add_scrollbar() - self.canvas.bind("", self.resize_frame) + if scrollbar: + self.add_scrollbar() + self._canvas.bind("", self.resize_frame) for option in self.options: group_frame = self.get_group_frame(option.group) @@ -452,21 +460,21 @@ def get_group_frame(self, group): def add_scrollbar(self): """ Add a scrollbar to the options frame """ logger.debug("Add Config Scrollbar") - scrollbar = ttk.Scrollbar(self, command=self.canvas.yview) + scrollbar = ttk.Scrollbar(self, command=self._canvas.yview) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - self.canvas.config(yscrollcommand=scrollbar.set) + self._canvas.config(yscrollcommand=scrollbar.set) self.mainframe.bind("", self.update_scrollbar) logger.debug("Added Config Scrollbar") def update_scrollbar(self, event): # pylint: disable=unused-argument """ Update the options frame scrollbar """ - self.canvas.configure(scrollregion=self.canvas.bbox("all")) + self._canvas.configure(scrollregion=self._canvas.bbox("all")) def resize_frame(self, event): """ Resize the options frame to fit the canvas """ logger.debug("Resize Config Frame") canvas_width = event.width - self.canvas.itemconfig(self.optscanvas, width=canvas_width) + self._canvas.itemconfig(self._optscanvas, width=canvas_width) self.optsframe.rearrange_columns(canvas_width) logger.debug("Resized Config Frame") @@ -476,21 +484,22 @@ def checkbuttons_frame(self, frame): otherwise in a standard frame """ logger.debug("Add Options CheckButtons Frame") chk_frame = ttk.Frame(frame, name="chkbuttons") - holder = AutoFillContainer(chk_frame, self.option_columns) + holder = AutoFillContainer(chk_frame, self.option_columns, self.option_columns) logger.debug("Added Options CheckButtons Frame") return holder class AutoFillContainer(): """ A container object that auto-fills columns """ - def __init__(self, parent, columns): - logger.debug("Initializing: %s: (parent: %s, columns: %s)", self.__class__.__name__, - parent, columns) - self.max_columns = 4 + def __init__(self, parent, initial_columns, max_columns): + logger.debug("Initializing: %s: (parent: %s, initial_columns: %s, max_columns: %s)", + self.__class__.__name__, parent, initial_columns, max_columns) + self.max_columns = max_columns + self.columns = initial_columns + self.parent = parent +# self.columns = min(columns, self.max_columns) self.single_column_width = self.scale_column_width(288, 9) self.max_width = self.max_columns * self.single_column_width - self.parent = parent - self.columns = min(columns, self.max_columns) self._items = 0 self._idx = 0 self._widget_config = [] # Master list of all children in order @@ -783,7 +792,7 @@ def radio_control(self): ctl = ttk.LabelFrame(self.frame, text=self.option.title, name="radio_labelframe") - radio_holder = AutoFillContainer(ctl, self.option_columns) + radio_holder = AutoFillContainer(ctl, self.option_columns, self.option_columns) for choice in self.option.choices: radio = ttk.Radiobutton(radio_holder.subframe, text=choice.replace("_", " ").title(), @@ -845,6 +854,7 @@ def control_to_optionsframe(self): if self.option.choices: logger.debug("Adding combo choices: %s", self.option.choices) ctl["values"] = [choice for choice in self.option.choices] + ctl["state"] = "readonly" logger.debug("Added control to Options Frame: %s", self.option.name) return ctl diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index 4d290920b1..b40b6fdcc0 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -56,10 +56,7 @@ def cm_bind(self): """ button = "" if platform.system() == "Darwin" else "" logger.debug("Binding '%s' to '%s'", button, self._widget.winfo_class()) - scaling_factor = get_config().scaling_factor if get_config() is not None else 1.0 - x_offset = int(34 * scaling_factor) - self._widget.bind(button, - lambda event: self.tk_popup(event.x_root + x_offset, event.y_root, 0)) + self._widget.bind(button, lambda event: self.tk_popup(event.x_root, event.y_root)) def _select_all(self): """ Select all for Text or Entry widgets """ @@ -72,6 +69,52 @@ def _select_all(self): self._widget.select_range(0, tk.END) +class RightClickMenu(tk.Menu): # pylint: disable=too-many-ancestors + """ A Pop up menu that can be bound to a right click mouse event to bring up a context menu + + Parameters + ---------- + labels: list + A list of label titles that will appear in the right click menu + actions: list + A list of python functions that are called when the corresponding label is clicked on + hotkeys: list, optional + The hotkeys corresponding to the labels. If using hotkeys, then there must be an entry in + the list for every label even if they don't all use hotkeys. Labels without a hotkey can be + an empty string or ``None``. Passing ``None`` instead of a list means that no actions will + be given hotkeys. NB: The hotkey is not bound by this class, that needs to be done in code. + Giving hotkeys here means that they will be displayed in the menu though. Default: ``None`` + """ + # TODO This should probably be merged with Context Menu + def __init__(self, labels, actions, hotkeys=None): + logger.debug("Initializing %s: (labels: %s, actions: %s)", self.__class__.__name__, labels, + actions) + super().__init__(tearoff=0) + self._labels = labels + self._actions = actions + self._hotkeys = hotkeys + self._create_menu() + logger.debug("Initialized %s", self.__class__.__name__) + + def _create_menu(self): + """ Create the menu based on :attr:`_labels` and :attr:`_actions`. """ + for idx, (label, action) in enumerate(zip(self._labels, self._actions)): + kwargs = dict(label=label, command=action) + if isinstance(self._hotkeys, (list, tuple)) and self._hotkeys[idx]: + kwargs["accelerator"] = self._hotkeys[idx] + self.add_command(**kwargs) + + def popup(self, event): + """ Pop up the right click menu. + + Parameters + ---------- + event: class:`tkinter.Event` + The tkinter mouse event calling this popup + """ + self.tk_popup(event.x_root, event.y_root) + + class ConsoleOut(ttk.Frame): # pylint: disable=too-many-ancestors """ The Console out section of the GUI. @@ -367,30 +410,48 @@ def __call__(self, *args): class StatusBar(ttk.Frame): # pylint: disable=too-many-ancestors - """ Status Bar for displaying the Status Message and Progress Bar at the - bottom of the GUI. """ + """ Status Bar for displaying the Status Message and Progress Bar at the bottom of the GUI. - def __init__(self, parent): + Parameters + ---------- + parent: tkinter object + The parent tkinter widget that will hold the status bar + hide_status: bool, optional + ``True`` to hide the status message that appears at the far left hand side of the status + frame otherwise ``False``. Default: ``False`` + """ + + def __init__(self, parent, hide_status=False): ttk.Frame.__init__(self, parent) self.pack(side=tk.BOTTOM, padx=10, pady=2, fill=tk.X, expand=False) - self._status_message = tk.StringVar() + self._message = tk.StringVar() self._pbar_message = tk.StringVar() self._pbar_position = tk.IntVar() - self._status_message.set("Ready") + self._message.set("Ready") - self._status() + self._status(hide_status) self._pbar = self._progress_bar() @property - def status_message(self): + def message(self): """:class:`tkinter.StringVar`: The variable to hold the status bar message on the left hand side of the status bar. """ - return self._status_message + return self._message + + def _status(self, hide_status): + """ Place Status label into left of the status bar. + + Parameters + ---------- + hide_status: bool, optional + ``True`` to hide the status message that appears at the far left hand side of the + status frame otherwise ``False`` + """ + if hide_status: + return - def _status(self): - """ Place Status label into left of the status bar. """ statusframe = ttk.Frame(self) statusframe.pack(side=tk.LEFT, anchor=tk.W, fill=tk.X, expand=False) @@ -399,7 +460,7 @@ def _status(self): lblstatus = ttk.Label(statusframe, width=40, - textvariable=self._status_message, + textvariable=self._message, anchor=tk.W) lblstatus.pack(side=tk.LEFT, anchor=tk.W, fill=tk.X, expand=True) @@ -420,7 +481,7 @@ def _progress_bar(self): pbar.pack_forget() return pbar - def progress_start(self, mode): + def start(self, mode): """ Set progress bar mode and display, Parameters @@ -428,17 +489,17 @@ def progress_start(self, mode): mode: ["indeterminate", "determinate"] The mode that the progress bar should be executed in """ - self._progress_set_mode(mode) + self._set_mode(mode) self._pbar.pack() - def progress_stop(self): + def stop(self): """ Reset progress bar and hide """ self._pbar_message.set("") self._pbar_position.set(0) - self._progress_set_mode("determinate") + self._set_mode("determinate") self._pbar.pack_forget() - def _progress_set_mode(self, mode): + def _set_mode(self, mode): """ Set the progress bar mode """ self._pbar.config(mode=mode) if mode == "indeterminate": @@ -481,7 +542,7 @@ class Tooltip: text: str, optional The text to be displayed in the tool-tip. Default: 'widget info' waittime: int, optional - The time in miliseconds to wait before showing the tool-tip. Default: 400 + The time in milliseconds to wait before showing the tool-tip. Default: 400 wraplength: int, optional The text length for each line before wrapping. Default: 250 @@ -495,19 +556,6 @@ class Tooltip: ----- Adapted from StackOverflow: http://stackoverflow.com/questions/3221956 and http://www.daniweb.com/programming/software-development/code/484591/a-tooltip-class-for-tkinter - - - - Originally written by vegaseat on 2014.09.09. - - Modified to include a delay time by Victor Zaccardo on 2016.03.25. - - Modified to correct extreme right and extreme bottom behavior by Alberto Vassena on \ - 2016.11.05. - - Modified to stay inside the screen whenever the tooltip might go out on the top but still \ - the screen is higher than the tooltip by Alberto Vassena on 2016.11.05. - - Modified to use the more flexible mouse positioning by Alberto Vassena on 2016.11.05. - - Modified to add customizable background color, padding, waittime and wraplength on creation \ - by Alberto Vassena on 2016.11.05. - - Tested on Ubuntu 16.04/16.10, running Python 3.5.2 """ def __init__(self, widget, *, background="#FFFFEA", pad=(5, 3, 5, 3), text="widget info", waittime=400, wraplength=250): @@ -529,7 +577,7 @@ def _on_enter(self, event=None): # pylint:disable=unused-argument self._schedule() def _on_leave(self, event=None): # pylint:disable=unused-argument - """ Unschedule on a leave event """ + """ remove schedule on a leave event """ self._unschedule() self._hide() @@ -583,11 +631,9 @@ def tip_pos_calculator(widget, label, if offscreen_again: # No further checks will be done. - # TIP: - # A further mod might auto-magically augment the - # wraplength when the tooltip is too high to be - # kept inside the screen. + # A further mod might auto-magically augment the wrap length when the tooltip is + # too high to be kept inside the screen. y_1 = 0 return x_1, y_1 @@ -596,7 +642,7 @@ def tip_pos_calculator(widget, label, pad = self._pad widget = self._widget - # creates a toplevel window + # Creates a top level window self._topwidget = tk.Toplevel(widget) if platform.system() == "Darwin": # For Mac OS diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 76c90d676f..9a7dfd7aba 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -106,12 +106,12 @@ def build_page(self, container, category): """ Build a plugin config page """ logger.debug("Building plugin config page: '%s'", category) plugins = sorted(list(key for key in self.config_cpanel_dict[category].keys())) - panel_kwargs = dict(columns=2, option_columns=2, blank_nones=False) + panel_kwargs = dict(columns=2, max_columns=2, option_columns=2, blank_nones=False) if any(plugin != category for plugin in plugins): page = ttk.Notebook(container) page.pack(side=tk.TOP, fill=tk.BOTH, expand=True) for plugin in plugins: - cp_options = [opt for opt in self.config_cpanel_dict[category][plugin].values()] + cp_options = list(self.config_cpanel_dict[category][plugin].values()) frame = ControlPanel(page, cp_options, header_text=self.plugin_info[plugin], @@ -120,7 +120,7 @@ def build_page(self, container, category): title = title.replace("_", " ").title() page.add(frame, text=title) else: - cp_options = [opt for opt in self.config_cpanel_dict[category][plugins[0]].values()] + cp_options = list(self.config_cpanel_dict[category][plugins[0]].values()) page = ControlPanel(container, cp_options, header_text=self.plugin_info[plugins[0]], diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index 1eab26c0bc..c31b84344f 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -74,9 +74,9 @@ def prepare(self, category): self.tk_vars["istraining"].set(True) print("Loading...") - self.statusbar.status_message.set("Executing - {}.py".format(self.command)) + self.statusbar.message.set("Executing - {}.py".format(self.command)) mode = "indeterminate" if self.command in ("effmpeg", "train") else "determinate" - self.statusbar.progress_start(mode) + self.statusbar.start(mode) args = self.build_args(category) self.tk_vars["display"].set(self.command) @@ -126,8 +126,8 @@ def terminate(self, message): self.tk_vars["runningtask"].set(False) if self.task.command == "train": self.tk_vars["istraining"].set(False) - self.statusbar.progress_stop() - self.statusbar.status_message.set(message) + self.statusbar.stop() + self.statusbar.message.set(message) self.tk_vars["display"].set(None) get_images().delete_preview() get_config().session.__init__() diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index 7690384b10..885d101033 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -56,7 +56,8 @@ def __init__(self, git_model_id=None, model_filename=None, super().__init__(git_model_id, model_filename, configfile=configfile) - self.normalize_method = normalize_method + self._normalize_method = None + self.set_normalize_method(normalize_method) self._plugin_type = "align" self._faces_per_filename = dict() # Tracking for recompiling face batches @@ -64,6 +65,17 @@ def __init__(self, git_model_id=None, model_filename=None, self._output_faces = [] logger.debug("Initialized %s", self.__class__.__name__) + def set_normalize_method(self, method): + """ Set the normalization method for feeding faces into the aligner. + + Parameters + ---------- + method: {"none", "clahe", "hist", "mean"} + The normalization method to apply to faces prior to feeding into the model + """ + method = None if method is None or method.lower() == "none" else method + self._normalize_method = method + # << QUEUE METHODS >>> # def get_batch(self, queue): """ Get items for inputting into the aligner from the queue in batches @@ -209,10 +221,10 @@ def _normalize_faces(self, faces): The normalization method is dictated by the command line argument `-nh (--normalization)` """ - if self.normalize_method is None: + if self._normalize_method is None: return faces logger.trace("Normalizing faces") - meth = getattr(self, "_normalize_{}".format(self.normalize_method.lower())) + meth = getattr(self, "_normalize_{}".format(self._normalize_method.lower())) faces = [meth(face) for face in faces] logger.trace("Normalized faces") return faces diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 0bb7f55447..f1c9e16d06 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -455,6 +455,17 @@ def _set_extractor_batchsize(self): available_vram = vram_free - vram_required self._set_plugin_batchsize(plugin, available_vram) + def set_aligner_normalization_method(self, method): + """ Change the normalization method for faces fed into the aligner. + + Parameters + ---------- + method: {"none", "clahe", "hist", "mean"} + The normalization method to apply to faces prior to feeding into the aligner's model + """ + logger.debug("Setting to: '%s'", method) + self._align.set_normalize_method(method) + @staticmethod def _set_plugin_batchsize(plugin, available_vram): """ Set the batch size for the given plugin based on given available vram. @@ -525,21 +536,21 @@ def detected_faces(self): :attr:`image`. """ return self._detected_faces - def get_image_copy(self, colorformat): + def get_image_copy(self, color_format): """ Get a copy of the image in the requested color format. Parameters ---------- - colorformat: ['BGR', 'RGB', 'GRAY'] + color_format: ['BGR', 'RGB', 'GRAY'] The requested color format of :attr:`image` Returns ------- :class:`numpy.ndarray`: - A copy of :attr:`image` in the requested :attr:`colorformat` + A copy of :attr:`image` in the requested :attr:`color_format` """ - logger.trace("Requested color format '%s' for frame '%s'", colorformat, self._filename) - image = getattr(self, "_image_as_{}".format(colorformat.lower()))() + logger.trace("Requested color format '%s' for frame '%s'", color_format, self._filename) + image = getattr(self, "_image_as_{}".format(color_format.lower()))() return image def add_detected_faces(self, faces): From 4153a7ea0dfc8188881391d6a2d0c535d678ac07 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 16 Mar 2020 19:12:52 +0000 Subject: [PATCH 216/981] Tools Restructure (#990) * Restructure tools to allow dynamic (plugable) loading * Update gui cli parsing to allow new tools structure --- .gitignore | 3 +- docs/full/tools.rst | 12 +- lib/cli.py | 2 +- lib/gui/options.py | 34 +- tools.py | 43 +- tools/alignments/__init__.py | 0 tools/{ => alignments}/alignments.py | 13 +- .../annotate.py | 0 tools/alignments/cli.py | 162 ++++ tools/{lib_alignments => alignments}/jobs.py | 3 +- .../jobs_manual.py | 3 +- tools/{lib_alignments => alignments}/media.py | 0 tools/cli.py | 787 ------------------ tools/effmpeg/__init__.py | 0 tools/effmpeg/cli.py | 235 ++++++ tools/{ => effmpeg}/effmpeg.py | 29 +- tools/lib_alignments/__init__.py | 4 - tools/mask/__init__.py | 0 tools/mask/cli.py | 139 ++++ tools/{ => mask}/mask.py | 0 tools/preview/__init__.py | 0 tools/preview/cli.py | 58 ++ tools/{ => preview}/preview.py | 0 tools/restore/__init__.py | 0 tools/restore/cli.py | 27 + tools/{ => restore}/restore.py | 9 +- tools/sort/__init__.py | 0 tools/sort/cli.py | 197 +++++ tools/{ => sort}/sort.py | 25 - 29 files changed, 893 insertions(+), 892 deletions(-) create mode 100644 tools/alignments/__init__.py rename tools/{ => alignments}/alignments.py (84%) rename tools/{lib_alignments => alignments}/annotate.py (100%) create mode 100644 tools/alignments/cli.py rename tools/{lib_alignments => alignments}/jobs.py (99%) rename tools/{lib_alignments => alignments}/jobs_manual.py (99%) rename tools/{lib_alignments => alignments}/media.py (100%) delete mode 100644 tools/cli.py create mode 100644 tools/effmpeg/__init__.py create mode 100644 tools/effmpeg/cli.py rename tools/{ => effmpeg}/effmpeg.py (96%) delete mode 100644 tools/lib_alignments/__init__.py create mode 100644 tools/mask/__init__.py create mode 100644 tools/mask/cli.py rename tools/{ => mask}/mask.py (100%) create mode 100644 tools/preview/__init__.py create mode 100644 tools/preview/cli.py rename tools/{ => preview}/preview.py (100%) create mode 100644 tools/restore/__init__.py create mode 100644 tools/restore/cli.py rename tools/{ => restore}/restore.py (94%) create mode 100644 tools/sort/__init__.py create mode 100644 tools/sort/cli.py rename tools/{ => sort}/sort.py (97%) diff --git a/.gitignore b/.gitignore index c859bd4be0..692dde593b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,8 +32,7 @@ !plugins/convert/* !.pylintrc !tools -!tools/lib* -!tools/lib*/* +!tools/* !_travis !_travis/* !.travis.yml diff --git a/docs/full/tools.rst b/docs/full/tools.rst index d6a3a584fd..1a7e29d7ef 100644 --- a/docs/full/tools.rst +++ b/docs/full/tools.rst @@ -1,18 +1,18 @@ tools package ============= -tools.mask module ------------------ +tools.mask.mask module +---------------------- -.. automodule:: tools.mask +.. automodule:: tools.mask.mask :members: :undoc-members: :show-inheritance: -tools.preview module --------------------- +tools.preview.preview module +---------------------------- -.. automodule:: tools.preview +.. automodule:: tools.preview.preview :members: :undoc-members: :show-inheritance: diff --git a/lib/cli.py b/lib/cli.py index da8b230dd7..d16ffdb6c7 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -35,7 +35,7 @@ def import_script(self): self.test_for_tf_version() self.test_for_gui() cmd = os.path.basename(sys.argv[0]) - src = "tools" if cmd == "tools.py" else "scripts" + src = "tools.{}".format(self.command.lower()) if cmd == "tools.py" else "scripts" mod = ".".join((src, self.command.lower())) module = import_module(mod) script = getattr(module, self.command.title()) diff --git a/lib/gui/options.py b/lib/gui/options.py index 7de3945b30..89a982e99e 100644 --- a/lib/gui/options.py +++ b/lib/gui/options.py @@ -2,12 +2,14 @@ """ Cli Options for the GUI """ import inspect from argparse import SUPPRESS +from importlib import import_module import logging +import os import re +import sys from collections import OrderedDict from lib import cli -import tools.cli as ToolsCli from .utils import get_images from .control_helper import ControlPanelOption @@ -28,16 +30,21 @@ def build_options(self): """ Get the commands that belong to each category """ for category in self.categories: logger.debug("Building '%s'", category) - src = ToolsCli if category == "tools" else cli - mod_classes = self.get_cli_classes(src) - self.commands[category] = self.sort_commands(category, mod_classes) - self.opts.update(self.extract_options(src, mod_classes)) + if category == "tools": + mod_classes = self._get_tools_cli_classes() + self.commands[category] = self.sort_commands(category, mod_classes) + for tool in sorted(mod_classes): + self.opts.update(self.extract_options(mod_classes[tool], [tool])) + else: + mod_classes = self.get_cli_classes(cli) + self.commands[category] = self.sort_commands(category, mod_classes) + self.opts.update(self.extract_options(cli, mod_classes)) logger.debug("Built '%s'", category) @staticmethod def get_cli_classes(cli_source): """ Parse the cli scripts for the argument classes """ - mod_classes = list() + mod_classes = [] for name, obj in inspect.getmembers(cli_source): if inspect.isclass(obj) and name.lower().endswith("args") \ and name.lower() not in (("faceswapargs", @@ -47,6 +54,19 @@ def get_cli_classes(cli_source): logger.debug(mod_classes) return mod_classes + @staticmethod + def _get_tools_cli_classes(): + """ Parse the tools cli scripts for the argument classes """ + base_path = os.path.realpath(os.path.dirname(sys.argv[0])) + tools_dir = os.path.join(base_path, "tools") + mod_classes = dict() + for tool_name in sorted(os.listdir(tools_dir)): + cli_file = os.path.join(tools_dir, tool_name, "cli.py") + if os.path.exists(cli_file): + mod = ".".join(("tools", tool_name, "cli")) + mod_classes["{}Args".format(tool_name.title())] = import_module(mod) + return mod_classes + def sort_commands(self, category, classes): """ Format classes into command names and sort: Specific workflow order for faceswap. @@ -263,7 +283,7 @@ def gen_cli_arguments(self, command): get_images().set_faceswap_output_path(optval) if optval in ("False", ""): continue - elif optval == "True": + if optval == "True": yield (opt, ) else: if option.get("nargs", None): diff --git a/tools.py b/tools.py index fed945c080..ab690bf6d5 100755 --- a/tools.py +++ b/tools.py @@ -1,8 +1,11 @@ #!/usr/bin/env python3 """ The master tools.py script """ +import os import sys + +from importlib import import_module + # Importing the various tools -import tools.cli as cli from lib.cli import FullHelpArgumentParser # Python version check @@ -15,7 +18,21 @@ def bad_args(args): # pylint:disable=unused-argument """ Print help on bad arguments """ PARSER.print_help() - exit(0) + sys.exit(0) + + +def _get_cli_opts(): + """ Optain the subparsers and cli options for available tools """ + base_path = os.path.realpath(os.path.dirname(sys.argv[0])) + tools_dir = os.path.join(base_path, "tools") + for tool_name in sorted(os.listdir(tools_dir)): + cli_file = os.path.join(tools_dir, tool_name, "cli.py") + if os.path.exists(cli_file): + mod = ".".join(("tools", tool_name, "cli")) + module = import_module(mod) + cliarg_class = getattr(module, "{}Args".format(tool_name.title())) + help_text = getattr(module, "_HELPTEXT") + yield tool_name, help_text, cliarg_class if __name__ == "__main__": @@ -26,26 +43,8 @@ def bad_args(args): # pylint:disable=unused-argument PARSER = FullHelpArgumentParser() SUBPARSER = PARSER.add_subparsers() - ALIGN = cli.AlignmentsArgs(SUBPARSER, - "alignments", - "This command lets you perform various tasks pertaining to an " - "alignments file.") - PREVIEW = cli.PreviewArgs(SUBPARSER, - "preview", - "This command allows you to preview swaps to tweak convert " - "settings.") - EFFMPEG = cli.EffmpegArgs(SUBPARSER, - "effmpeg", - "This command allows you to easily execute common ffmpeg tasks.") - MASK = cli.MaskArgs(SUBPARSER, - "mask", - "This command lets you generate masks for existing alignments.") - RESTORE = cli.RestoreArgs(SUBPARSER, - "restore", - "This command lets you restore models from backup.") - SORT = cli.SortArgs(SUBPARSER, - "sort", - "This command lets you sort images using various methods.") + for tool, helptext, cli_args in _get_cli_opts(): + cli_args(SUBPARSER, tool, helptext) PARSER.set_defaults(func=bad_args) ARGUMENTS = PARSER.parse_args() ARGUMENTS.func(ARGUMENTS) diff --git a/tools/alignments/__init__.py b/tools/alignments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/alignments.py b/tools/alignments/alignments.py similarity index 84% rename from tools/alignments.py rename to tools/alignments/alignments.py index b7ef64007e..a6c6b8acf3 100644 --- a/tools/alignments.py +++ b/tools/alignments/alignments.py @@ -1,9 +1,12 @@ #!/usr/bin/env python3 """ Tools for manipulating the alignments seralized file """ +import sys import logging -from .lib_alignments import (AlignmentData, Check, Dfl, Draw, # noqa pylint: disable=unused-import - Extract, Fix, Manual, Merge, Rename, - RemoveAlignments, Sort, Spatial, UpdateHashes) + +from .media import AlignmentData +from .jobs import (Check, Dfl, Draw, Extract, Fix, Merge, # noqa pylint: disable=unused-import + Rename, RemoveAlignments, Sort, Spatial, UpdateHashes) +from .jobs_manual import Manual # noqa pylint: disable=unused-import logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -21,10 +24,10 @@ def load_alignments(self): logger.debug("Loading alignments") if len(self.args.alignments_file) > 1 and self.args.job != "merge": logger.error("Multiple alignments files are only permitted for merging") - exit(0) + sys.exit(0) if len(self.args.alignments_file) == 1 and self.args.job == "merge": logger.error("More than one alignments file required for merging") - exit(0) + sys.exit(0) if len(self.args.alignments_file) == 1: retval = AlignmentData(self.args.alignments_file[0]) diff --git a/tools/lib_alignments/annotate.py b/tools/alignments/annotate.py similarity index 100% rename from tools/lib_alignments/annotate.py rename to tools/alignments/annotate.py diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py new file mode 100644 index 0000000000..9b86ab019b --- /dev/null +++ b/tools/alignments/cli.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +""" Command Line Arguments for tools """ +from lib.cli import FaceSwapArgs +from lib.cli import DirOrFileFullPaths, DirFullPaths, FilesFullPaths, Radio, Slider + +_HELPTEXT = "This command lets you perform various tasks pertaining to an alignments file." + + +class AlignmentsArgs(FaceSwapArgs): + """ Class to parse the command line arguments for Alignments tool """ + + @staticmethod + def get_info(): + """ Return command information """ + return ("Alignments tool\nThis tool allows you to perform numerous actions on or using an " + "alignments file against its corresponding faceset/frame source.") + + def get_argument_list(self): + frames_dir = " Must Pass in a frames folder/source video file (-fr)." + faces_dir = " Must Pass in a faces folder (-fc)." + frames_or_faces_dir = (" Must Pass in either a frames folder/source video file OR a" + "faces folder (-fr or -fc).") + frames_and_faces_dir = (" Must Pass in a frames folder/source video file AND a faces " + "folder (-fr and -fc).") + output_opts = " Use the output option (-o) to process results." + align_eyes = " Can optionally use the align-eyes switch (-ae)." + argument_list = list() + argument_list.append({ + "opts": ("-j", "--job"), + "action": Radio, + "type": str, + "choices": ("dfl", "draw", "extract", "fix", "manual", "merge", "missing-alignments", + "missing-frames", "leftover-faces", "multi-faces", "no-faces", + "remove-faces", "remove-frames", "rename", "sort", "spatial", + "update-hashes"), + "required": True, + "help": "R|Choose which action you want to perform. " + "NB: All actions require an alignments file (-a) to be passed in." + "\nL|'dfl': Create an alignments file from faces extracted from DeepFaceLab. " + "Specify 'dfl' as the 'alignments file' entry and the folder containing the " + "dfl faces as the 'faces folder' ('-a dfl -fc '" + "\nL|'draw': Draw landmarks on frames in the selected folder/video. A " + "subfolder will be created within the frames folder to hold the output." + + frames_dir + + "\nL|'extract': Re-extract faces from the source frames/video based on " + "alignment data. This is a lot quicker than re-detecting faces. Can pass in " + "the '-een' (--extract-every-n) parameter to only extract every nth frame." + + frames_and_faces_dir + align_eyes + + # TODO - Remove the fix job after a period of time. Implemented 2019/12/07 + "\nL|'fix': There was a bug when extracting from video which would shift all " + "the faces out by 1 frame. This was a shortlived bug, but this job will fix " + "alignments files that have this issue. NB: Only run this on alignments files " + "that you know need fixing." + "\nL|'manual': Manually view and edit landmarks." + frames_dir + + "\nL|'merge': Merge multiple alignment files into one. Specify a space " + "separated list of alignments files with the -a flag. Optionally specify a " + "faces (-fc) folder to filter the final alignments file to only those faces " + "that appear within the provided folder." + "\nL|'missing-alignments': Identify frames that do not exist in the " + "alignments file." + output_opts + frames_dir + + "\nL|'missing-frames': Identify frames in the alignments file that do not " + "appear within the frames folder/video." + output_opts + frames_dir + + "\nL|'leftover-faces': Identify faces in the faces folder that do not exist " + "in the alignments file." + output_opts + faces_dir + + "\nL|'multi-faces': Identify where multiple faces exist within the alignments " + "file." + output_opts + frames_or_faces_dir + + "\nL|'no-faces': Identify frames that exist within the alignment file but no " + "faces were detected." + output_opts + frames_dir + + "\nL|'remove-faces': Remove deleted faces from an alignments file. The " + "original alignments file will be backed up." + faces_dir + + "\nL|'remove-frames': Remove deleted frames from an alignments file. The " + "original alignments file will be backed up." + frames_dir + + "\nL|'rename' - Rename faces to correspond with their parent frame and " + "position index in the alignments file (i.e. how they are named after running " + "extract)." + faces_dir + + "\nL|'sort': Re-index the alignments from left to right. For alignments " + "with multiple faces this will ensure that the left-most face is at index 0 " + "Optionally pass in a faces folder (-fc) to also rename extracted faces." + "\nL|'spatial': Perform spatial and temporal filtering to smooth alignments " + "(EXPERIMENTAL!)" + "\nL|'update-hashes': Recalculate the face hashes. Only use this if you have " + "altered the extracted faces (e.g. colour adjust). The files MUST be " + "named '_face index' (i.e. how they are named after running " + "extract)." + faces_dir}) + argument_list.append({"opts": ("-a", "--alignments_file"), + "action": FilesFullPaths, + "dest": "alignments_file", + "nargs": "+", + "group": "data", + "required": True, + "filetypes": "alignments", + "help": "Full path to the alignments file to be processed. If " + "merging alignments, then multiple files can be selected, " + "space separated"}) + argument_list.append({"opts": ("-fc", "-faces_folder"), + "action": DirFullPaths, + "dest": "faces_dir", + "group": "data", + "help": "Directory containing extracted faces."}) + argument_list.append({"opts": ("-fr", "-frames_folder"), + "action": DirOrFileFullPaths, + "dest": "frames_dir", + "filetypes": "video", + "group": "data", + "help": "Directory containing source frames " + "that faces were extracted from."}) + argument_list.append({ + "opts": ("-o", "--output"), + "action": Radio, + "type": str, + "choices": ("console", "file", "move"), + "group": "processing", + "default": "console", + "help": "R|How to output discovered items ('faces' and 'frames' only):" + "\nL|'console': Print the list of frames to the screen. (DEFAULT)" + "\nL|'file': Output the list of frames to a text file (stored within the " + " source directory)." + "\nL|'move': Move the discovered items to a sub-folder within the source " + "directory."}) + argument_list.append({"opts": ("-een", "--extract-every-n"), + "type": int, + "action": Slider, + "dest": "extract_every_n", + "min_max": (1, 100), + "default": 1, + "rounding": 1, + "group": "extract", + "help": "[Extract only] Extract every 'nth' frame. This option will " + "skip frames when extracting faces. For example a value of " + "1 will extract faces from every frame, a value of 10 will " + "extract faces from every 10th frame."}) + argument_list.append({"opts": ("-sz", "--size"), + "type": int, + "action": Slider, + "min_max": (128, 512), + "default": 256, + "group": "extract", + "rounding": 64, + "help": "[Extract only] The output size of extracted faces."}) + argument_list.append({"opts": ("-ae", "--align-eyes"), + "action": "store_true", + "dest": "align_eyes", + "group": "extract", + "default": False, + "help": "[Extract only] Perform extra alignment to ensure " + "left/right eyes are at the same height."}) + argument_list.append({"opts": ("-l", "--large"), + "action": "store_true", + "group": "extract", + "default": False, + "help": "[Extract only] Only extract faces that have not been " + "upscaled to the required size (`-sz`, `--size). Useful " + "for excluding low-res images from a training set."}) + argument_list.append({"opts": ("-dm", "--disable-monitor"), + "action": "store_true", + "group": "manual tool", + "dest": "disable_monitor", + "default": False, + "help": "Enable this option if manual " + "alignments window is closing " + "instantly. (Manual only)"}) + return argument_list diff --git a/tools/lib_alignments/jobs.py b/tools/alignments/jobs.py similarity index 99% rename from tools/lib_alignments/jobs.py rename to tools/alignments/jobs.py index f602b1b719..647bb3dd38 100644 --- a/tools/lib_alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -14,7 +14,8 @@ from sklearn import decomposition from tqdm import tqdm -from . import Annotate, ExtractedFaces, Faces, Frames +from .annotate import Annotate +from .media import ExtractedFaces, Faces, Frames logger = logging.getLogger(__name__) # pylint: disable=invalid-name diff --git a/tools/lib_alignments/jobs_manual.py b/tools/alignments/jobs_manual.py similarity index 99% rename from tools/lib_alignments/jobs_manual.py rename to tools/alignments/jobs_manual.py index 7617289907..491b7c43d9 100644 --- a/tools/lib_alignments/jobs_manual.py +++ b/tools/alignments/jobs_manual.py @@ -10,7 +10,8 @@ from lib.faces_detect import DetectedFace from lib.queue_manager import queue_manager from plugins.extract.pipeline import Extractor, ExtractMedia -from . import Annotate, ExtractedFaces, Frames +from .annotate import Annotate +from .media import ExtractedFaces, Frames logger = logging.getLogger(__name__) # pylint: disable=invalid-name diff --git a/tools/lib_alignments/media.py b/tools/alignments/media.py similarity index 100% rename from tools/lib_alignments/media.py rename to tools/alignments/media.py diff --git a/tools/cli.py b/tools/cli.py deleted file mode 100644 index 3f45996ebe..0000000000 --- a/tools/cli.py +++ /dev/null @@ -1,787 +0,0 @@ -#!/usr/bin/env python3 -""" Command Line Arguments for tools """ -from argparse import SUPPRESS - -from lib.cli import FaceSwapArgs -from lib.cli import (ContextFullPaths, DirOrFileFullPaths, DirFullPaths, FileFullPaths, - FilesFullPaths, SaveFileFullPaths, Radio, Slider) -from lib.utils import _image_extensions -from plugins.plugin_loader import PluginLoader - - -class AlignmentsArgs(FaceSwapArgs): - """ Class to parse the command line arguments for Alignments tool """ - - @staticmethod - def get_info(): - """ Return command information """ - return ("Alignments tool\nThis tool allows you to perform numerous actions on or using an " - "alignments file against its corresponding faceset/frame source.") - - def get_argument_list(self): - frames_dir = " Must Pass in a frames folder/source video file (-fr)." - faces_dir = " Must Pass in a faces folder (-fc)." - frames_or_faces_dir = (" Must Pass in either a frames folder/source video file OR a" - "faces folder (-fr or -fc).") - frames_and_faces_dir = (" Must Pass in a frames folder/source video file AND a faces " - "folder (-fr and -fc).") - output_opts = " Use the output option (-o) to process results." - align_eyes = " Can optionally use the align-eyes switch (-ae)." - argument_list = list() - argument_list.append({ - "opts": ("-j", "--job"), - "action": Radio, - "type": str, - "choices": ("dfl", "draw", "extract", "fix", "manual", "merge", "missing-alignments", - "missing-frames", "leftover-faces", "multi-faces", "no-faces", - "remove-faces", "remove-frames", "rename", "sort", "spatial", - "update-hashes"), - "required": True, - "help": "R|Choose which action you want to perform. " - "NB: All actions require an alignments file (-a) to be passed in." - "\nL|'dfl': Create an alignments file from faces extracted from DeepFaceLab. " - "Specify 'dfl' as the 'alignments file' entry and the folder containing the " - "dfl faces as the 'faces folder' ('-a dfl -fc '" - "\nL|'draw': Draw landmarks on frames in the selected folder/video. A " - "subfolder will be created within the frames folder to hold the output." + - frames_dir + - "\nL|'extract': Re-extract faces from the source frames/video based on " - "alignment data. This is a lot quicker than re-detecting faces. Can pass in " - "the '-een' (--extract-every-n) parameter to only extract every nth frame." + - frames_and_faces_dir + align_eyes + - # TODO - Remove the fix job after a period of time. Implemented 2019/12/07 - "\nL|'fix': There was a bug when extracting from video which would shift all " - "the faces out by 1 frame. This was a shortlived bug, but this job will fix " - "alignments files that have this issue. NB: Only run this on alignments files " - "that you know need fixing." - "\nL|'manual': Manually view and edit landmarks." + frames_dir + - "\nL|'merge': Merge multiple alignment files into one. Specify a space " - "separated list of alignments files with the -a flag. Optionally specify a " - "faces (-fc) folder to filter the final alignments file to only those faces " - "that appear within the provided folder." - "\nL|'missing-alignments': Identify frames that do not exist in the " - "alignments file." + output_opts + frames_dir + - "\nL|'missing-frames': Identify frames in the alignments file that do not " - "appear within the frames folder/video." + output_opts + frames_dir + - "\nL|'leftover-faces': Identify faces in the faces folder that do not exist " - "in the alignments file." + output_opts + faces_dir + - "\nL|'multi-faces': Identify where multiple faces exist within the alignments " - "file." + output_opts + frames_or_faces_dir + - "\nL|'no-faces': Identify frames that exist within the alignment file but no " - "faces were detected." + output_opts + frames_dir + - "\nL|'remove-faces': Remove deleted faces from an alignments file. The " - "original alignments file will be backed up." + faces_dir + - "\nL|'remove-frames': Remove deleted frames from an alignments file. The " - "original alignments file will be backed up." + frames_dir + - "\nL|'rename' - Rename faces to correspond with their parent frame and " - "position index in the alignments file (i.e. how they are named after running " - "extract)." + faces_dir + - "\nL|'sort': Re-index the alignments from left to right. For alignments " - "with multiple faces this will ensure that the left-most face is at index 0 " - "Optionally pass in a faces folder (-fc) to also rename extracted faces." - "\nL|'spatial': Perform spatial and temporal filtering to smooth alignments " - "(EXPERIMENTAL!)" - "\nL|'update-hashes': Recalculate the face hashes. Only use this if you have " - "altered the extracted faces (e.g. colour adjust). The files MUST be " - "named '_face index' (i.e. how they are named after running " - "extract)." + faces_dir}) - argument_list.append({"opts": ("-a", "--alignments_file"), - "action": FilesFullPaths, - "dest": "alignments_file", - "nargs": "+", - "group": "data", - "required": True, - "filetypes": "alignments", - "help": "Full path to the alignments file to be processed. If " - "merging alignments, then multiple files can be selected, " - "space separated"}) - argument_list.append({"opts": ("-fc", "-faces_folder"), - "action": DirFullPaths, - "dest": "faces_dir", - "group": "data", - "help": "Directory containing extracted faces."}) - argument_list.append({"opts": ("-fr", "-frames_folder"), - "action": DirOrFileFullPaths, - "dest": "frames_dir", - "filetypes": "video", - "group": "data", - "help": "Directory containing source frames " - "that faces were extracted from."}) - argument_list.append({ - "opts": ("-o", "--output"), - "action": Radio, - "type": str, - "choices": ("console", "file", "move"), - "group": "processing", - "default": "console", - "help": "R|How to output discovered items ('faces' and 'frames' only):" - "\nL|'console': Print the list of frames to the screen. (DEFAULT)" - "\nL|'file': Output the list of frames to a text file (stored within the " - " source directory)." - "\nL|'move': Move the discovered items to a sub-folder within the source " - "directory."}) - argument_list.append({"opts": ("-een", "--extract-every-n"), - "type": int, - "action": Slider, - "dest": "extract_every_n", - "min_max": (1, 100), - "default": 1, - "rounding": 1, - "group": "extract", - "help": "[Extract only] Extract every 'nth' frame. This option will " - "skip frames when extracting faces. For example a value of " - "1 will extract faces from every frame, a value of 10 will " - "extract faces from every 10th frame."}) - argument_list.append({"opts": ("-sz", "--size"), - "type": int, - "action": Slider, - "min_max": (128, 512), - "default": 256, - "group": "extract", - "rounding": 64, - "help": "[Extract only] The output size of extracted faces."}) - argument_list.append({"opts": ("-ae", "--align-eyes"), - "action": "store_true", - "dest": "align_eyes", - "group": "extract", - "default": False, - "help": "[Extract only] Perform extra alignment to ensure " - "left/right eyes are at the same height."}) - argument_list.append({"opts": ("-l", "--large"), - "action": "store_true", - "group": "extract", - "default": False, - "help": "[Extract only] Only extract faces that have not been " - "upscaled to the required size (`-sz`, `--size). Useful " - "for excluding low-res images from a training set."}) - argument_list.append({"opts": ("-dm", "--disable-monitor"), - "action": "store_true", - "group": "manual tool", - "dest": "disable_monitor", - "default": False, - "help": "Enable this option if manual " - "alignments window is closing " - "instantly. (Manual only)"}) - return argument_list - - -class PreviewArgs(FaceSwapArgs): - """ Class to parse the command line arguments for Preview (Convert Settings) tool """ - - @staticmethod - def get_info(): - """ Return command information """ - return "Preview tool\nAllows you to configure your convert settings with a live preview" - - def get_argument_list(self): - - argument_list = list() - argument_list.append({"opts": ("-i", "--input-dir"), - "action": DirOrFileFullPaths, - "filetypes": "video", - "dest": "input_dir", - "group": "data", - "required": True, - "help": "Input directory or video. Either a directory containing " - "the image files you wish to process or path to a video " - "file."}) - argument_list.append({"opts": ("-al", "--alignments"), - "action": FileFullPaths, - "filetypes": "alignments", - "type": str, - "group": "data", - "dest": "alignments_path", - "help": "Path to the alignments file for the input, if not at the " - "default location"}) - argument_list.append({"opts": ("-m", "--model-dir"), - "action": DirFullPaths, - "dest": "model_dir", - "group": "data", - "required": True, - "help": "Model directory. A directory containing the trained model " - "you wish to process."}) - argument_list.append({"opts": ("-s", "--swap-model"), - "action": "store_true", - "dest": "swap_model", - "default": False, - "help": "Swap the model. Instead of A -> B, " - "swap B -> A"}) - argument_list.append({"opts": ("-ag", "--allow-growth"), - "action": "store_true", - "dest": "allow_growth", - "default": False, - "backend": "nvidia", - "help": "Sets allow_growth option of Tensorflow to spare memory " - "on some configurations."}) - - return argument_list - - -class EffmpegArgs(FaceSwapArgs): - """ Class to parse the command line arguments for EFFMPEG tool """ - - @staticmethod - def get_info(): - """ Return command information """ - return "A wrapper for ffmpeg for performing image <> video converting." - - @staticmethod - def __parse_transpose(value): - index = 0 - opts = ["(0, 90CounterClockwise&VerticalFlip)", - "(1, 90Clockwise)", - "(2, 90CounterClockwise)", - "(3, 90Clockwise&VerticalFlip)"] - if len(value) == 1: - index = int(value) - else: - for i in range(5): - if value in opts[i]: - index = i - break - return opts[index] - - def get_argument_list(self): - argument_list = list() - argument_list.append({"opts": ('-a', '--action'), - "action": Radio, - "dest": "action", - "choices": ("extract", "gen-vid", "get-fps", - "get-info", "mux-audio", "rescale", - "rotate", "slice"), - "default": "extract", - "help": "R|Choose which action you want ffmpeg " - "ffmpeg to do." - "\nL|'extract': turns videos into images " - "\nL|'gen-vid': turns images into videos " - "\nL|'get-fps' returns the chosen video's fps." - "\nL|'get-info' returns information about a video." - "\nL|'mux-audio' add audio from one video to another." - "\nL|'rescale' resize video." - "\nL|'rotate' rotate video." - "\nL|'slice' cuts a portion of the video into a separate " - "video file."}) - - argument_list.append({"opts": ('-i', '--input'), - "action": ContextFullPaths, - "dest": "input", - "default": "input", - "help": "Input file.", - "group": "data", - "required": True, - "action_option": "-a", - "filetypes": "video"}) - - argument_list.append({"opts": ('-o', '--output'), - "action": ContextFullPaths, - "group": "data", - "default": "", - "dest": "output", - "help": "Output file. If no output is " - "specified then: if the output is " - "meant to be a video then a video " - "called 'out.mkv' will be created in " - "the input directory; if the output is " - "meant to be a directory then a " - "directory called 'out' will be " - "created inside the input " - "directory." - "Note: the chosen output file " - "extension will determine the file " - "encoding.", - "action_option": "-a", - "filetypes": "video"}) - - argument_list.append({"opts": ('-r', '--reference-video'), - "action": FileFullPaths, - "dest": "ref_vid", - "group": "data", - "default": None, - "help": "Path to reference video if 'input' " - "was not a video.", - "filetypes": "video"}) - - argument_list.append({"opts": ('-fps', '--fps'), - "type": str, - "dest": "fps", - "group": "output", - "default": "-1.0", - "help": "Provide video fps. Can be an integer, " - "float or fraction. Negative values " - "will make the program try to get the " - "fps from the input or reference " - "videos."}) - - argument_list.append({"opts": ("-ef", "--extract-filetype"), - "action": Radio, - "choices": _image_extensions, - "dest": "extract_ext", - "group": "output", - "default": ".png", - "help": "Image format that extracted images " - "should be saved as. '.bmp' will offer " - "the fastest extraction speed, but " - "will take the most storage space. " - "'.png' will be slower but will take " - "less storage."}) - - argument_list.append({"opts": ('-s', '--start'), - "type": str, - "dest": "start", - "group": "clip", - "default": "00:00:00", - "help": "Enter the start time from which an " - "action is to be applied. " - "Default: 00:00:00, in HH:MM:SS " - "format. You can also enter the time " - "with or without the colons, e.g. " - "00:0000 or 026010."}) - - argument_list.append({"opts": ('-e', '--end'), - "type": str, - "dest": "end", - "group": "clip", - "default": "00:00:00", - "help": "Enter the end time to which an action " - "is to be applied. If both an end time " - "and duration are set, then the end " - "time will be used and the duration " - "will be ignored. " - "Default: 00:00:00, in HH:MM:SS."}) - - argument_list.append({"opts": ('-d', '--duration'), - "type": str, - "dest": "duration", - "group": "clip", - "default": "00:00:00", - "help": "Enter the duration of the chosen " - "action, for example if you enter " - "00:00:10 for slice, then the first 10 " - "seconds after and including the start " - "time will be cut out into a new " - "video. " - "Default: 00:00:00, in HH:MM:SS " - "format. You can also enter the time " - "with or without the colons, e.g. " - "00:0000 or 026010."}) - - argument_list.append({"opts": ('-m', '--mux-audio'), - "action": "store_true", - "dest": "mux_audio", - "group": "output", - "default": False, - "help": "Mux the audio from the reference " - "video into the input video. This " - "option is only used for the 'gen-vid' " - "action. 'mux-audio' action has this " - "turned on implicitly."}) - - argument_list.append( - {"opts": ('-tr', '--transpose'), - "choices": ("(0, 90CounterClockwise&VerticalFlip)", - "(1, 90Clockwise)", - "(2, 90CounterClockwise)", - "(3, 90Clockwise&VerticalFlip)"), - "type": lambda v: self.__parse_transpose(v), - "dest": "transpose", - "group": "rotate", - "default": None, - "help": "Transpose the video. If transpose is " - "set, then degrees will be ignored. For " - "cli you can enter either the number " - "or the long command name, " - "e.g. to use (1, 90Clockwise) " - "-tr 1 or -tr 90Clockwise"}) - - argument_list.append({"opts": ('-de', '--degrees'), - "type": str, - "dest": "degrees", - "default": None, - "group": "rotate", - "help": "Rotate the video clockwise by the " - "given number of degrees."}) - - argument_list.append({"opts": ('-sc', '--scale'), - "type": str, - "dest": "scale", - "group": "output", - "default": "1920x1080", - "help": "Set the new resolution scale if the " - "chosen action is 'rescale'."}) - - argument_list.append({"opts": ('-pr', '--preview'), - "action": "store_true", - "dest": "preview", - "default": False, - # TODO Fix preview or remove - "help": SUPPRESS, - # "help": "Uses ffplay to preview the effects of " - # "actions that have a video output. " - # "Currently preview does not work when " - # "muxing audio." - }) - - argument_list.append({"opts": ('-q', '--quiet'), - "action": "store_true", - "dest": "quiet", - "group": "settings", - "default": False, - "help": "Reduces output verbosity so that only " - "serious errors are printed. If both " - "quiet and verbose are set, verbose " - "will override quiet."}) - - argument_list.append({"opts": ('-v', '--verbose'), - "action": "store_true", - "dest": "verbose", - "group": "settings", - "default": False, - "help": "Increases output verbosity. If both " - "quiet and verbose are set, verbose " - "will override quiet."}) - - return argument_list - - -class MaskArgs(FaceSwapArgs): - """ Class to parse the command line arguments for Mask tool """ - - @staticmethod - def get_info(): - """ Return command information """ - return "Mask tool\nGenerate masks for existing alignments files." - - def get_argument_list(self): - argument_list = list() - argument_list.append({ - "opts": ("-a", "--alignments"), - "action": FileFullPaths, - "type": str, - "group": "data", - "required": True, - "filetypes": "alignments", - "help": "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."}) - 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": ("-M", "--masker"), - "action": Radio, - "type": str.lower, - "choices": PluginLoader.get_available_extractors("mask"), - "default": "extended", - "group": "process", - "help": "R|Masker to use." - "\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|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({ - "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({ - "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": 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({ - "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({ - "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({ - "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 - - -class RestoreArgs(FaceSwapArgs): - """ Class to restore model files from backup """ - - @staticmethod - def get_info(): - """ Return command information """ - return "A tool for restoring models from backup (.bk) files" - - @staticmethod - def get_argument_list(): - """ Put the arguments in a list so that they are accessible from both argparse and gui """ - argument_list = list() - argument_list.append({"opts": ("-m", "--model-dir"), - "action": DirFullPaths, - "dest": "model_dir", - "required": True, - "help": "Model directory. A directory containing the model " - "you wish to restore from backup."}) - return argument_list - - -class SortArgs(FaceSwapArgs): - """ Class to parse the command line arguments for sort tool """ - - @staticmethod - def get_info(): - """ Return command information """ - return "Sort faces using a number of different techniques" - - @staticmethod - def get_argument_list(): - """ Put the arguments in a list so that they are accessible from both argparse and gui """ - argument_list = list() - argument_list.append({"opts": ('-i', '--input'), - "action": DirFullPaths, - "dest": "input_dir", - "group": "data", - "help": "Input directory of aligned faces.", - "required": True}) - - argument_list.append({"opts": ('-o', '--output'), - "action": DirFullPaths, - "dest": "output_dir", - "group": "data", - "help": "Output directory for sorted aligned " - "faces."}) - - argument_list.append({"opts": ('-s', '--sort-by'), - "action": Radio, - "type": str, - "choices": ("blur", "face", "face-cnn", "face-cnn-dissim", - "face-yaw", "hist", "hist-dissim", "color-gray", - "color-luma", "color-green", "color-orange"), - "dest": 'sort_method', - "group": "sort settings", - "default": "face", - "help": "R|Sort by method. Choose how images are sorted. " - "\nL|'blur': Sort faces by blurriness." - "\nL|'face': Use VGG Face to sort by face similarity. This " - "uses a pairwise clustering algorithm to check the " - "distances between 4096 features on every face in your set " - "and order them appropriately. WARNING: On very large " - "datasets it is possible to run out of memory performing " - "this calculation." - "\nL|'face-cnn': Sort faces by their landmarks. You can " - "adjust the threshold with the '-t' (--ref_threshold) " - "option." - "\nL|'face-cnn-dissim': Like 'face-cnn' but sorts by " - "dissimilarity." - "\nL|'face-yaw': Sort faces by Yaw (rotation left to right)." - "\nL|'hist': Sort faces by their color histogram. You can " - "adjust the threshold with the '-t' (--ref_threshold) " - "option." - "\nL|'hist-dissim': Like 'hist' but sorts by dissimilarity." - "\nL|'color-gray': Sort images by the average intensity of " - "the converted grayscale color channel." - "\nL|'color-luma': Sort images by the average intensity of " - "the converted Y color channel. Bright lighting and " - "oversaturated images will be ranked first." - "\nL|'color-green': Sort images by the average intensity of " - "the converted Cg color channel. Green images will be " - "ranked first and red images will be last." - "\nL|'color-orange': Sort images by the average intensity " - "of the converted Co color channel. Orange images will be " - "ranked first and blue images will be last." - "\nDefault: hist"}) - argument_list.append({"opts": ('-k', '--keep'), - "action": 'store_true', - "dest": 'keep_original', - "default": False, - "group": "output", - "help": "Keeps the original files in the input " - "directory. Be careful when using this " - "with rename grouping and no specified " - "output directory as this would keep " - "the original and renamed files in the " - "same directory."}) - argument_list.append({"opts": ('-t', '--ref_threshold'), - "action": Slider, - "min_max": (-1.0, 10.0), - "rounding": 2, - "type": float, - "dest": 'min_threshold', - "group": "sort settings", - "default": -1.0, - "help": "Float value. " - "Minimum threshold to use for grouping comparison with " - "'face-cnn' and 'hist' methods. The lower the value the " - "more discriminating the grouping is. Leaving -1.0 will " - "allow the program set the default value automatically. " - "For face-cnn 7.2 should be enough, with 4 being very " - "discriminating. For hist 0.3 should be enough, with 0.2 " - "being very discriminating. Be careful setting a value " - "that's too low in a directory with many images, as this " - "could result in a lot of directories being created. " - "Defaults: face-cnn 7.2, hist 0.3"}) - - argument_list.append({"opts": ('-fp', '--final-process'), - "action": Radio, - "type": str, - "choices": ("folders", "rename"), - "dest": 'final_process', - "default": "rename", - "group": "output", - "help": "R|Default: rename." - "\nL|'folders': files are sorted using " - "the -s/--sort-by method, then they " - "are organized into folders using " - "the -g/--group-by grouping method." - "\nL|'rename': files are sorted using " - "the -s/--sort-by then they are " - "renamed."}) - - argument_list.append({"opts": ('-g', '--group-by'), - "action": Radio, - "type": str, - "choices": ("blur", "face-cnn", "face-yaw", "hist"), - "dest": 'group_method', - "group": "output", - "default": "hist", - "help": "Group by method. " - "When -fp/--final-processing by " - "folders choose the how the images are " - "grouped after sorting. " - "Default: hist"}) - - argument_list.append({"opts": ('-b', '--bins'), - "action": Slider, - "min_max": (1, 100), - "rounding": 1, - "type": int, - "dest": 'num_bins', - "group": "output", - "default": 5, - "help": "Integer value. " - "Number of folders that will be used " - "to group by blur and face-yaw. " - "For blur folder 0 will be the least " - "blurry, while the last folder will be " - "the blurriest. " - "For face-yaw the number of bins is by " - "how much 180 degrees is divided. So " - "if you use 18, then each folder will " - "be a 10 degree increment. Folder 0 " - "will contain faces looking the most " - "to the left whereas the last folder " - "will contain the faces looking the " - "most to the right. " - "If the number of images doesn't " - "divide evenly into the number of " - "bins, the remaining images get put in " - "the last bin." - "Default value: 5"}) - - argument_list.append({"opts": ("-be", "--backend"), - "action": Radio, - "type": str.upper, - "choices": ("CPU", "GPU"), - "default": "GPU", - "group": "settings", - "help": "Backend to use for VGG Face inference." - "Only used for sort by 'face'."}) - - argument_list.append({"opts": ('-l', '--log-changes'), - "action": 'store_true', - "group": "settings", - "default": False, - "help": "Logs file renaming changes if " - "grouping by renaming, or it logs the " - "file copying/movement if grouping by " - "folders. If no log file is specified " - "with '--log-file', then a " - "'sort_log.json' file will be created " - "in the input directory."}) - - argument_list.append({"opts": ('-lf', '--log-file'), - "action": SaveFileFullPaths, - "filetypes": "alignments", - "group": "settings", - "dest": 'log_file_path', - "default": 'sort_log.json', - "help": "Specify a log file to use for saving " - "the renaming or grouping information. " - "If specified extension isn't 'json' " - "or 'yaml', then json will be used as " - "the serializer, with the supplied " - "filename. " - "Default: sort_log.json"}) - - return argument_list diff --git a/tools/effmpeg/__init__.py b/tools/effmpeg/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/effmpeg/cli.py b/tools/effmpeg/cli.py new file mode 100644 index 0000000000..bd102821ca --- /dev/null +++ b/tools/effmpeg/cli.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +""" Command Line Arguments for tools """ +from argparse import SUPPRESS + +from lib.cli import FaceSwapArgs +from lib.cli import ContextFullPaths, FileFullPaths, Radio +from lib.utils import _image_extensions + +_HELPTEXT = "This command allows you to easily execute common ffmpeg tasks." + + +class EffmpegArgs(FaceSwapArgs): + """ Class to parse the command line arguments for EFFMPEG tool """ + + @staticmethod + def get_info(): + """ Return command information """ + return "A wrapper for ffmpeg for performing image <> video converting." + + @staticmethod + def __parse_transpose(value): + index = 0 + opts = ["(0, 90CounterClockwise&VerticalFlip)", + "(1, 90Clockwise)", + "(2, 90CounterClockwise)", + "(3, 90Clockwise&VerticalFlip)"] + if len(value) == 1: + index = int(value) + else: + for i in range(5): + if value in opts[i]: + index = i + break + return opts[index] + + def get_argument_list(self): + argument_list = list() + argument_list.append({"opts": ('-a', '--action'), + "action": Radio, + "dest": "action", + "choices": ("extract", "gen-vid", "get-fps", + "get-info", "mux-audio", "rescale", + "rotate", "slice"), + "default": "extract", + "help": "R|Choose which action you want ffmpeg " + "ffmpeg to do." + "\nL|'extract': turns videos into images " + "\nL|'gen-vid': turns images into videos " + "\nL|'get-fps' returns the chosen video's fps." + "\nL|'get-info' returns information about a video." + "\nL|'mux-audio' add audio from one video to another." + "\nL|'rescale' resize video." + "\nL|'rotate' rotate video." + "\nL|'slice' cuts a portion of the video into a separate " + "video file."}) + + argument_list.append({"opts": ('-i', '--input'), + "action": ContextFullPaths, + "dest": "input", + "default": "input", + "help": "Input file.", + "group": "data", + "required": True, + "action_option": "-a", + "filetypes": "video"}) + + argument_list.append({"opts": ('-o', '--output'), + "action": ContextFullPaths, + "group": "data", + "default": "", + "dest": "output", + "help": "Output file. If no output is " + "specified then: if the output is " + "meant to be a video then a video " + "called 'out.mkv' will be created in " + "the input directory; if the output is " + "meant to be a directory then a " + "directory called 'out' will be " + "created inside the input " + "directory." + "Note: the chosen output file " + "extension will determine the file " + "encoding.", + "action_option": "-a", + "filetypes": "video"}) + + argument_list.append({"opts": ('-r', '--reference-video'), + "action": FileFullPaths, + "dest": "ref_vid", + "group": "data", + "default": None, + "help": "Path to reference video if 'input' " + "was not a video.", + "filetypes": "video"}) + + argument_list.append({"opts": ('-fps', '--fps'), + "type": str, + "dest": "fps", + "group": "output", + "default": "-1.0", + "help": "Provide video fps. Can be an integer, " + "float or fraction. Negative values " + "will make the program try to get the " + "fps from the input or reference " + "videos."}) + + argument_list.append({"opts": ("-ef", "--extract-filetype"), + "action": Radio, + "choices": _image_extensions, + "dest": "extract_ext", + "group": "output", + "default": ".png", + "help": "Image format that extracted images " + "should be saved as. '.bmp' will offer " + "the fastest extraction speed, but " + "will take the most storage space. " + "'.png' will be slower but will take " + "less storage."}) + + argument_list.append({"opts": ('-s', '--start'), + "type": str, + "dest": "start", + "group": "clip", + "default": "00:00:00", + "help": "Enter the start time from which an " + "action is to be applied. " + "Default: 00:00:00, in HH:MM:SS " + "format. You can also enter the time " + "with or without the colons, e.g. " + "00:0000 or 026010."}) + + argument_list.append({"opts": ('-e', '--end'), + "type": str, + "dest": "end", + "group": "clip", + "default": "00:00:00", + "help": "Enter the end time to which an action " + "is to be applied. If both an end time " + "and duration are set, then the end " + "time will be used and the duration " + "will be ignored. " + "Default: 00:00:00, in HH:MM:SS."}) + + argument_list.append({"opts": ('-d', '--duration'), + "type": str, + "dest": "duration", + "group": "clip", + "default": "00:00:00", + "help": "Enter the duration of the chosen " + "action, for example if you enter " + "00:00:10 for slice, then the first 10 " + "seconds after and including the start " + "time will be cut out into a new " + "video. " + "Default: 00:00:00, in HH:MM:SS " + "format. You can also enter the time " + "with or without the colons, e.g. " + "00:0000 or 026010."}) + + argument_list.append({"opts": ('-m', '--mux-audio'), + "action": "store_true", + "dest": "mux_audio", + "group": "output", + "default": False, + "help": "Mux the audio from the reference " + "video into the input video. This " + "option is only used for the 'gen-vid' " + "action. 'mux-audio' action has this " + "turned on implicitly."}) + + argument_list.append( + {"opts": ('-tr', '--transpose'), + "choices": ("(0, 90CounterClockwise&VerticalFlip)", + "(1, 90Clockwise)", + "(2, 90CounterClockwise)", + "(3, 90Clockwise&VerticalFlip)"), + "type": lambda v: self.__parse_transpose(v), + "dest": "transpose", + "group": "rotate", + "default": None, + "help": "Transpose the video. If transpose is " + "set, then degrees will be ignored. For " + "cli you can enter either the number " + "or the long command name, " + "e.g. to use (1, 90Clockwise) " + "-tr 1 or -tr 90Clockwise"}) + + argument_list.append({"opts": ('-de', '--degrees'), + "type": str, + "dest": "degrees", + "default": None, + "group": "rotate", + "help": "Rotate the video clockwise by the " + "given number of degrees."}) + + argument_list.append({"opts": ('-sc', '--scale'), + "type": str, + "dest": "scale", + "group": "output", + "default": "1920x1080", + "help": "Set the new resolution scale if the " + "chosen action is 'rescale'."}) + + argument_list.append({"opts": ('-pr', '--preview'), + "action": "store_true", + "dest": "preview", + "default": False, + # TODO Fix preview or remove + "help": SUPPRESS, + # "help": "Uses ffplay to preview the effects of " + # "actions that have a video output. " + # "Currently preview does not work when " + # "muxing audio." + }) + + argument_list.append({"opts": ('-q', '--quiet'), + "action": "store_true", + "dest": "quiet", + "group": "settings", + "default": False, + "help": "Reduces output verbosity so that only " + "serious errors are printed. If both " + "quiet and verbose are set, verbose " + "will override quiet."}) + + argument_list.append({"opts": ('-v', '--verbose'), + "action": "store_true", + "dest": "verbose", + "group": "settings", + "default": False, + "help": "Increases output verbosity. If both " + "quiet and verbose are set, verbose " + "will override quiet."}) + + return argument_list diff --git a/tools/effmpeg.py b/tools/effmpeg/effmpeg.py similarity index 96% rename from tools/effmpeg.py rename to tools/effmpeg/effmpeg.py index 2adeb6cab7..32eedbdc1a 100644 --- a/tools/effmpeg.py +++ b/tools/effmpeg/effmpeg.py @@ -10,8 +10,8 @@ # -> figure out if ffmpeg | ffplay would work on windows and mac import logging import os -import sys import subprocess +import sys import datetime from collections import OrderedDict @@ -20,14 +20,7 @@ from ffmpy import FFmpeg, FFRuntimeError # faceswap imports -from lib.cli import FullHelpArgumentParser from lib.utils import _image_extensions, _video_extensions -from . import cli - -if sys.version_info[0] < 3: - raise Exception("This program requires at least python3.2") -if sys.version_info[0] == 3 and sys.version_info[1] < 2: - raise Exception("This program requires at least python3.2") logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -274,7 +267,7 @@ def process(self): except ValueError: logger.error("You have entered an invalid value for degrees: %s", self.args.degrees) - exit(1) + sys.exit(1) # Set executable based on whether previewing or not if self.args.preview and self.args.action in self._actions_can_preview: @@ -593,21 +586,3 @@ def parse_time(txt): retval = hours + ':' + minutes + ':' + seconds logger.debug("txt: '%s', retval: %s", txt, retval) return retval - - -def bad_args(args): # pylint: disable=unused-argument - """ Print help on bad arguments """ - PARSER.print_help() - exit(0) - - -if __name__ == "__main__": - print('"Easy"-ffmpeg wrapper.\n') - - PARSER = FullHelpArgumentParser() - SUBPARSER = PARSER.add_subparsers() - EFFMPEG = cli.EffmpegArgs( - SUBPARSER, "effmpeg", "Wrapper for various common ffmpeg commands.") - PARSER.set_defaults(func=bad_args) - ARGUMENTS = PARSER.parse_args() - ARGUMENTS.func(ARGUMENTS) diff --git a/tools/lib_alignments/__init__.py b/tools/lib_alignments/__init__.py deleted file mode 100644 index 9901b891d3..0000000000 --- a/tools/lib_alignments/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from tools.lib_alignments.media import AlignmentData, ExtractedFaces, Faces, Frames -from tools.lib_alignments.annotate import Annotate -from tools.lib_alignments.jobs import Check, Dfl, Draw, Extract, Fix, Merge, RemoveAlignments, Rename, Sort, Spatial, UpdateHashes -from tools.lib_alignments.jobs_manual import Manual diff --git a/tools/mask/__init__.py b/tools/mask/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/mask/cli.py b/tools/mask/cli.py new file mode 100644 index 0000000000..491cbab6e6 --- /dev/null +++ b/tools/mask/cli.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" Command Line Arguments for tools """ +from lib.cli import FaceSwapArgs +from lib.cli import (DirOrFileFullPaths, DirFullPaths, FileFullPaths, Radio, Slider) +from plugins.plugin_loader import PluginLoader + +_HELPTEXT = "This command lets you generate masks for existing alignments." + + +class MaskArgs(FaceSwapArgs): + """ Class to parse the command line arguments for Mask tool """ + + @staticmethod + def get_info(): + """ Return command information """ + return "Mask tool\nGenerate masks for existing alignments files." + + def get_argument_list(self): + argument_list = list() + argument_list.append({ + "opts": ("-a", "--alignments"), + "action": FileFullPaths, + "type": str, + "group": "data", + "required": True, + "filetypes": "alignments", + "help": "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."}) + 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": ("-M", "--masker"), + "action": Radio, + "type": str.lower, + "choices": PluginLoader.get_available_extractors("mask"), + "default": "extended", + "group": "process", + "help": "R|Masker to use." + "\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|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({ + "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({ + "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": 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({ + "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({ + "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({ + "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.py b/tools/mask/mask.py similarity index 100% rename from tools/mask.py rename to tools/mask/mask.py diff --git a/tools/preview/__init__.py b/tools/preview/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/preview/cli.py b/tools/preview/cli.py new file mode 100644 index 0000000000..2bd04e29f0 --- /dev/null +++ b/tools/preview/cli.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" Command Line Arguments for tools """ +from lib.cli import FaceSwapArgs +from lib.cli import DirOrFileFullPaths, DirFullPaths, FileFullPaths + +_HELPTEXT = "This command allows you to preview swaps to tweak convert settings." + + +class PreviewArgs(FaceSwapArgs): + """ Class to parse the command line arguments for Preview (Convert Settings) tool """ + + @staticmethod + def get_info(): + """ Return command information """ + return "Preview tool\nAllows you to configure your convert settings with a live preview" + + def get_argument_list(self): + + argument_list = list() + argument_list.append({"opts": ("-i", "--input-dir"), + "action": DirOrFileFullPaths, + "filetypes": "video", + "dest": "input_dir", + "group": "data", + "required": True, + "help": "Input directory or video. Either a directory containing " + "the image files you wish to process or path to a video " + "file."}) + argument_list.append({"opts": ("-al", "--alignments"), + "action": FileFullPaths, + "filetypes": "alignments", + "type": str, + "group": "data", + "dest": "alignments_path", + "help": "Path to the alignments file for the input, if not at the " + "default location"}) + argument_list.append({"opts": ("-m", "--model-dir"), + "action": DirFullPaths, + "dest": "model_dir", + "group": "data", + "required": True, + "help": "Model directory. A directory containing the trained model " + "you wish to process."}) + argument_list.append({"opts": ("-s", "--swap-model"), + "action": "store_true", + "dest": "swap_model", + "default": False, + "help": "Swap the model. Instead of A -> B, " + "swap B -> A"}) + argument_list.append({"opts": ("-ag", "--allow-growth"), + "action": "store_true", + "dest": "allow_growth", + "default": False, + "backend": "nvidia", + "help": "Sets allow_growth option of Tensorflow to spare memory " + "on some configurations."}) + + return argument_list diff --git a/tools/preview.py b/tools/preview/preview.py similarity index 100% rename from tools/preview.py rename to tools/preview/preview.py diff --git a/tools/restore/__init__.py b/tools/restore/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/restore/cli.py b/tools/restore/cli.py new file mode 100644 index 0000000000..52f1b95c6d --- /dev/null +++ b/tools/restore/cli.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +""" Command Line Arguments for tools """ +from lib.cli import FaceSwapArgs +from lib.cli import DirFullPaths + +_HELPTEXT = "This command lets you restore models from backup." + + +class RestoreArgs(FaceSwapArgs): + """ Class to restore model files from backup """ + + @staticmethod + def get_info(): + """ Return command information """ + return "A tool for restoring models from backup (.bk) files" + + @staticmethod + def get_argument_list(): + """ Put the arguments in a list so that they are accessible from both argparse and gui """ + argument_list = list() + argument_list.append({"opts": ("-m", "--model-dir"), + "action": DirFullPaths, + "dest": "model_dir", + "required": True, + "help": "Model directory. A directory containing the model " + "you wish to restore from backup."}) + return argument_list diff --git a/tools/restore.py b/tools/restore/restore.py similarity index 94% rename from tools/restore.py rename to tools/restore/restore.py index 20b480bae5..497a254d51 100644 --- a/tools/restore.py +++ b/tools/restore/restore.py @@ -3,6 +3,7 @@ import logging import os +import sys from lib.model.backup_restore import Backup @@ -29,19 +30,19 @@ def validate(self): """ Make sure there is only one model in the target folder """ if not os.path.exists(self.model_dir): logger.error("Folder does not exist: '%s'", self.model_dir) - exit(1) + sys.exit(1) chkfiles = [fname for fname in os.listdir(self.model_dir) if fname.endswith("_state.json")] bkfiles = [fname for fname in os.listdir(self.model_dir) if fname.endswith(".bk")] if not chkfiles: logger.error("Could not find a model in the supplied folder: '%s'", self.model_dir) - exit(1) + sys.exit(1) if len(chkfiles) > 1: logger.error("More than one model found in the supplied folder: '%s'", self.model_dir) - exit(1) + sys.exit(1) if not bkfiles: logger.error("Could not find any backup files in the supplied folder: '%s'", self.model_dir) - exit(1) + sys.exit(1) self.model_name = chkfiles[0].replace("_state.json", "") logger.info("%s Model found", self.model_name.title()) logger.verbose("Backup files: %s)", bkfiles) diff --git a/tools/sort/__init__.py b/tools/sort/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/sort/cli.py b/tools/sort/cli.py new file mode 100644 index 0000000000..8d9abe84c3 --- /dev/null +++ b/tools/sort/cli.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +""" Command Line Arguments for tools """ +from lib.cli import FaceSwapArgs +from lib.cli import DirFullPaths, SaveFileFullPaths, Radio, Slider + +_HELPTEXT = "This command lets you sort images using various methods." + + +class SortArgs(FaceSwapArgs): + """ Class to parse the command line arguments for sort tool """ + + @staticmethod + def get_info(): + """ Return command information """ + return "Sort faces using a number of different techniques" + + @staticmethod + def get_argument_list(): + """ Put the arguments in a list so that they are accessible from both argparse and gui """ + argument_list = list() + argument_list.append({"opts": ('-i', '--input'), + "action": DirFullPaths, + "dest": "input_dir", + "group": "data", + "help": "Input directory of aligned faces.", + "required": True}) + + argument_list.append({"opts": ('-o', '--output'), + "action": DirFullPaths, + "dest": "output_dir", + "group": "data", + "help": "Output directory for sorted aligned " + "faces."}) + + argument_list.append({"opts": ('-s', '--sort-by'), + "action": Radio, + "type": str, + "choices": ("blur", "face", "face-cnn", "face-cnn-dissim", + "face-yaw", "hist", "hist-dissim", "color-gray", + "color-luma", "color-green", "color-orange"), + "dest": 'sort_method', + "group": "sort settings", + "default": "face", + "help": "R|Sort by method. Choose how images are sorted. " + "\nL|'blur': Sort faces by blurriness." + "\nL|'face': Use VGG Face to sort by face similarity. This " + "uses a pairwise clustering algorithm to check the " + "distances between 4096 features on every face in your set " + "and order them appropriately. WARNING: On very large " + "datasets it is possible to run out of memory performing " + "this calculation." + "\nL|'face-cnn': Sort faces by their landmarks. You can " + "adjust the threshold with the '-t' (--ref_threshold) " + "option." + "\nL|'face-cnn-dissim': Like 'face-cnn' but sorts by " + "dissimilarity." + "\nL|'face-yaw': Sort faces by Yaw (rotation left to right)." + "\nL|'hist': Sort faces by their color histogram. You can " + "adjust the threshold with the '-t' (--ref_threshold) " + "option." + "\nL|'hist-dissim': Like 'hist' but sorts by dissimilarity." + "\nL|'color-gray': Sort images by the average intensity of " + "the converted grayscale color channel." + "\nL|'color-luma': Sort images by the average intensity of " + "the converted Y color channel. Bright lighting and " + "oversaturated images will be ranked first." + "\nL|'color-green': Sort images by the average intensity of " + "the converted Cg color channel. Green images will be " + "ranked first and red images will be last." + "\nL|'color-orange': Sort images by the average intensity " + "of the converted Co color channel. Orange images will be " + "ranked first and blue images will be last." + "\nDefault: hist"}) + argument_list.append({"opts": ('-k', '--keep'), + "action": 'store_true', + "dest": 'keep_original', + "default": False, + "group": "output", + "help": "Keeps the original files in the input " + "directory. Be careful when using this " + "with rename grouping and no specified " + "output directory as this would keep " + "the original and renamed files in the " + "same directory."}) + argument_list.append({"opts": ('-t', '--ref_threshold'), + "action": Slider, + "min_max": (-1.0, 10.0), + "rounding": 2, + "type": float, + "dest": 'min_threshold', + "group": "sort settings", + "default": -1.0, + "help": "Float value. " + "Minimum threshold to use for grouping comparison with " + "'face-cnn' and 'hist' methods. The lower the value the " + "more discriminating the grouping is. Leaving -1.0 will " + "allow the program set the default value automatically. " + "For face-cnn 7.2 should be enough, with 4 being very " + "discriminating. For hist 0.3 should be enough, with 0.2 " + "being very discriminating. Be careful setting a value " + "that's too low in a directory with many images, as this " + "could result in a lot of directories being created. " + "Defaults: face-cnn 7.2, hist 0.3"}) + + argument_list.append({"opts": ('-fp', '--final-process'), + "action": Radio, + "type": str, + "choices": ("folders", "rename"), + "dest": 'final_process', + "default": "rename", + "group": "output", + "help": "R|Default: rename." + "\nL|'folders': files are sorted using " + "the -s/--sort-by method, then they " + "are organized into folders using " + "the -g/--group-by grouping method." + "\nL|'rename': files are sorted using " + "the -s/--sort-by then they are " + "renamed."}) + + argument_list.append({"opts": ('-g', '--group-by'), + "action": Radio, + "type": str, + "choices": ("blur", "face-cnn", "face-yaw", "hist"), + "dest": 'group_method', + "group": "output", + "default": "hist", + "help": "Group by method. " + "When -fp/--final-processing by " + "folders choose the how the images are " + "grouped after sorting. " + "Default: hist"}) + + argument_list.append({"opts": ('-b', '--bins'), + "action": Slider, + "min_max": (1, 100), + "rounding": 1, + "type": int, + "dest": 'num_bins', + "group": "output", + "default": 5, + "help": "Integer value. " + "Number of folders that will be used " + "to group by blur and face-yaw. " + "For blur folder 0 will be the least " + "blurry, while the last folder will be " + "the blurriest. " + "For face-yaw the number of bins is by " + "how much 180 degrees is divided. So " + "if you use 18, then each folder will " + "be a 10 degree increment. Folder 0 " + "will contain faces looking the most " + "to the left whereas the last folder " + "will contain the faces looking the " + "most to the right. " + "If the number of images doesn't " + "divide evenly into the number of " + "bins, the remaining images get put in " + "the last bin." + "Default value: 5"}) + + argument_list.append({"opts": ("-be", "--backend"), + "action": Radio, + "type": str.upper, + "choices": ("CPU", "GPU"), + "default": "GPU", + "group": "settings", + "help": "Backend to use for VGG Face inference." + "Only used for sort by 'face'."}) + + argument_list.append({"opts": ('-l', '--log-changes'), + "action": 'store_true', + "group": "settings", + "default": False, + "help": "Logs file renaming changes if " + "grouping by renaming, or it logs the " + "file copying/movement if grouping by " + "folders. If no log file is specified " + "with '--log-file', then a " + "'sort_log.json' file will be created " + "in the input directory."}) + + argument_list.append({"opts": ('-lf', '--log-file'), + "action": SaveFileFullPaths, + "filetypes": "alignments", + "group": "settings", + "dest": 'log_file_path', + "default": 'sort_log.json', + "help": "Specify a log file to use for saving " + "the renaming or grouping information. " + "If specified extension isn't 'json' " + "or 'yaml', then json will be used as " + "the serializer, with the supplied " + "filename. " + "Default: sort_log.json"}) + + return argument_list diff --git a/tools/sort.py b/tools/sort/sort.py similarity index 97% rename from tools/sort.py rename to tools/sort/sort.py index d8f2e358ea..7f341a03f7 100644 --- a/tools/sort.py +++ b/tools/sort/sort.py @@ -14,15 +14,12 @@ from tqdm import tqdm # faceswap imports -from lib.cli import FullHelpArgumentParser from lib.serializer import get_serializer_from_filename from lib.faces_detect import DetectedFace from lib.image import ImagesLoader, read_image from lib.vgg_face2_keras import VGGFace2 as VGGFace from plugins.extract.pipeline import Extractor, ExtractMedia -from . import cli - logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -740,25 +737,3 @@ def get_avg_score_faces_cnn(fl1, references): score = np.sum(np.absolute((fl2 - fl1).flatten())) scores.append(score) return sum(scores) / len(scores) - - -def bad_args(args): # pylint: disable=unused-argument - """ Print help on bad arguments """ - PARSER.print_help() - sys.exit(0) - - -if __name__ == "__main__": - __WARNING_STRING = "Important: face-cnn method will cause an error when " - __WARNING_STRING += "this tool is called directly instead of through the " - __WARNING_STRING += "tools.py command script." - print(__WARNING_STRING) - print("Images sort tool.\n") - - PARSER = FullHelpArgumentParser() - SUBPARSER = PARSER.add_subparsers() - SORT = cli.SortArgs( - SUBPARSER, "sort", "Sort images using various methods.") - PARSER.set_defaults(func=bad_args) - ARGUMENTS = PARSER.parse_args() - ARGUMENTS.func(ARGUMENTS) From 3d88630f4f70e8930e131e2b01e4626e96880f56 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 23 Mar 2020 13:15:21 +0000 Subject: [PATCH 217/981] Core Update (#995) * lib.alignments - Add Video Meta Data methods * lib.image - Monkey Path ImageIO for video scanning --- lib/alignments.py | 34 ++++ lib/image.py | 423 ++++++++++++++++++++++++++++++++++++++++----- scripts/extract.py | 2 +- tools/mask/mask.py | 63 ++++--- 4 files changed, 446 insertions(+), 76 deletions(-) diff --git a/lib/alignments.py b/lib/alignments.py index 8641d62ffe..94d81c04b4 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -109,6 +109,23 @@ def mask_summary(self): masks[key] = masks.get(key, 0) + 1 return masks + @property + def video_meta_data(self): + """ dict: The frame meta data stored in the alignments file. If data does not exist in the + alignments file then ``None`` is returned for each Key """ + retval = dict(pts_time=None, keyframes=None) + pts_time = [] + keyframes = [] + for idx, key in enumerate(sorted(self.data)): + if "video_meta" not in self.data[key]: + return retval + meta = self.data[key]["video_meta"] + pts_time.append(meta["pts_time"]) + if meta["keyframe"]: + keyframes.append(idx) + retval = dict(pts_time=pts_time, keyframes=keyframes) + return retval + # << INIT FUNCTIONS >> # def _get_location(self, folder, filename): @@ -197,6 +214,23 @@ def backup(self): os.rename(src, dst) logger.debug("Backed up alignments") + def save_video_meta_data(self, pts_time, keyframes): + """ Save video meta data to the alignments file. + Parameters + ---------- + pts_time: list + A list of presentation timestamps (`float`) in frame index order for every frame in + the input video + keyframes: list + A list of frame indices corresponding to the key frames in the input video + """ + logger.info("Saving video meta information to Alignments file") + for idx, key in enumerate(sorted(self.data)): + meta = dict(pts_time=pts_time[idx], + keyframe=idx in keyframes) + self.data[key]["video_meta"] = meta + self.save() + # << VALIDATION >> # def frame_exists(self, frame_name): diff --git a/lib/image.py b/lib/image.py index e6a03bb890..d9e93357d5 100644 --- a/lib/image.py +++ b/lib/image.py @@ -2,9 +2,12 @@ """ Utilities for working with images and videos """ import logging +import re import subprocess import os +import sys +from bisect import bisect from concurrent import futures from hashlib import sha1 @@ -27,6 +30,197 @@ # <<< IMAGE IO >>> # +class FfmpegReader(imageio.plugins.ffmpeg.FfmpegFormat.Reader): + """ Monkey patch imageio ffmpeg to use keyframes whilst seeking """ + def __init__(self, format, request): + super().__init__(format, request) + self._frame_pts = None + self._keyframes = None + + def get_frame_info(self, frame_pts=None, keyframes=None): + """ Store the source video's keyframes in :attr:`_frame_info" for the current video for use + in :func:`initialize`. + + Parameters + ---------- + frame_pts: list, optional + A list corresponding to the video frame count of the pts_time per frame. If this and + `keyframes` are provided, then analyzing the video is skipped and the values from the + given lists are used. Default: ``None`` + keyframes: list, optional + A list containing the frame numbers of each key frame. if this and `frame_pts` are + provided, then analyzing the video is skipped and the values from the given lists are + used. Default: ``None`` + """ + if frame_pts is not None and keyframes is not None: + logger.debug("Video meta information provided. Not analyzing video") + self._frame_pts = frame_pts + self._keyframes = keyframes + return len(frame_pts), dict(pts_time=self._frame_pts, keyframes=self._keyframes) + + assert isinstance(self._filename, str), "Video path must be a string" + cmd = [im_ffm.get_ffmpeg_exe(), + "-hide_banner", + "-copyts", + "-i", self._filename, + "-vf", "showinfo", + "-start_number", "0", + "-an", + "-f", "null", + "-"] + logger.debug("FFMPEG Command: '%s'", " ".join(cmd)) + process = subprocess.Popen(cmd, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + universal_newlines=True) + frame_pts = [] + key_frames = [] + last_update = 0 + pbar = tqdm(desc="Analyzing Video", + leave=False, + total=int(self._meta["duration"]), + unit="secs") + while True: + output = process.stdout.readline().strip() + if output == "" and process.poll() is not None: + break + if "iskey" not in output: + continue + logger.trace("Keyframe line: %s", output) + line = re.split(r"\s+|:\s*", output) + pts_time = float(line[line.index("pts_time") + 1]) + frame_no = int(line[line.index("n") + 1]) + frame_pts.append(pts_time) + if "iskey:1" in output: + key_frames.append(frame_no) + + logger.trace("pts_time: %s, frame_no: %s", pts_time, frame_no) + if int(pts_time) == last_update: + # Floating points make TQDM display poorly, so only update on full + # second increments + continue + pbar.update(int(pts_time) - last_update) + last_update = int(pts_time) + pbar.close() + return_code = process.poll() + frame_count = len(frame_pts) + logger.debug("Return code: %s, frame_pts: %s, keyframes: %s, frame_count: %s", + return_code, frame_pts, key_frames, frame_count) + + self._frame_pts = frame_pts + self._keyframes = key_frames + return frame_count, dict(pts_time=self._frame_pts, keyframes=self._keyframes) + + def _previous_keyframe_info(self, index=0): + """ Return the previous keyframe's pts_time and frame number """ + prev_keyframe_idx = bisect(self._keyframes, index) - 1 + prev_keyframe = self._keyframes[prev_keyframe_idx] + prev_pts_time = self._frame_pts[prev_keyframe] + logger.trace("keyframe pts_time: %s, keyframe: %s", prev_pts_time, prev_keyframe) + return prev_pts_time, prev_keyframe + + def _initialize(self, index=0): + """ Replace ImageIO _initialize with a version that explictly uses keyframes. + + Notes + ----- + This introduces a minor change by seeking fast to the previous keyframe and then discarding + subsequent frames until the desired frame is reached. In testing, setting -ss flag either + prior to input, or both prior (fast) and after (slow) would not always bring back the + correct frame for all videos. Navigating to the previous keyframe then discarding frames + until the correct frame is reached appears to work well. + """ + # pylint: disable-all + if self._read_gen is not None: + self._read_gen.close() + + iargs = [] + oargs = [] + skip_frames = 0 + + # Create input args + iargs += self._arg_input_params + if self.request._video: + iargs += ["-f", CAM_FORMAT] # noqa + if self._arg_pixelformat: + iargs += ["-pix_fmt", self._arg_pixelformat] + if self._arg_size: + iargs += ["-s", self._arg_size] + elif index > 0: # re-initialize / seek + # Note: only works if we initialized earlier, and now have meta. Some info here: + # https://trac.ffmpeg.org/wiki/Seeking + # There are two ways to seek, one before -i (input_params) and after (output_params). + # The former is fast, because it uses keyframes, the latter is slow but accurate. + # According to the article above, the fast method should also be accurate from ffmpeg + # version 2.1, however in version 4.1 our tests start failing again. Not sure why, but + # we can solve this by combining slow and fast. + # Further note: The old method would go back 10 seconds and then seek slow. This was + # still somewhat unresponsive and did not always land on the correct frame. This monkey + # patched version goes to the previous keyframe then discards frames until the correct + # frame is landed on. + if self._frame_pts is None: + self.get_frame_info() + + keyframe_pts, keyframe = self._previous_keyframe_info(index) + seek_fast = keyframe_pts + skip_frames = index - keyframe + + # We used to have this epsilon earlier, when we did not use + # the slow seek. I don't think we need it anymore. + # epsilon = -1 / self._meta["fps"] * 0.1 + iargs += ["-ss", "%.06f" % (seek_fast)] + + # Output args, for writing to pipe + if self._arg_size: + oargs += ["-s", self._arg_size] + if self.request.kwargs.get("fps", None): + fps = float(self.request.kwargs["fps"]) + oargs += ["-r", "%.02f" % fps] + oargs += self._arg_output_params + + # Get pixelformat and bytes per pixel + pix_fmt = self._pix_fmt + bpp = self._depth * self._bytes_per_channel + + # Create generator + rf = self._ffmpeg_api.read_frames + self._read_gen = rf( + self._filename, pix_fmt, bpp, input_params=iargs, output_params=oargs + ) + + # Read meta data. This start the generator (and ffmpeg subprocess) + if self.request._video: + # With cameras, catch error and turn into IndexError + try: + meta = self._read_gen.__next__() + except IOError as err: + err_text = str(err) + if "darwin" in sys.platform: + if "Unknown input format: 'avfoundation'" in err_text: + err_text += ( + "Try installing FFMPEG using " + "home brew to get a version with " + "support for cameras." + ) + raise IndexError( + "No camera at {}.\n\n{}".format(self.request._video, err_text) + ) + else: + self._meta.update(meta) + elif index == 0: + self._meta.update(self._read_gen.__next__()) + else: + frames_skipped = 0 + while skip_frames != frames_skipped: + # Skip frames that are not the desired frame + _ = self._read_gen.__next__() + frames_skipped += 1 + self._read_gen.__next__() # we already have meta data + + +imageio.plugins.ffmpeg.FfmpegFormat.Reader = FfmpegReader + + def read_image(filename, raise_error=False, with_hash=False): """ Read an image file from a file location. @@ -330,7 +524,7 @@ def count_frames(filename, fast=False): logger.debug("frame line: %s", output) if not init_tqdm: logger.debug("Initializing tqdm") - pbar = tqdm(desc="Counting Video Frames", leave=False, total=duration, unit="secs") + pbar = tqdm(desc="Analyzing Video", leave=False, total=duration, unit="secs") init_tqdm = True time_idx = output.find("time=") + len("time=") frame_idx = output.find("frame=") + len("frame=") @@ -401,7 +595,7 @@ def _check_location_exists(self): raise FaceswapError("Not all locations in the input list exist") def _set_thread(self): - """ Set the load/save thread """ + """ Set the background thread for the load and save iterators and launch it. """ logger.debug("Setting thread") if self._thread is not None and self._thread.is_alive(): logger.debug("Thread pre-exists and is alive: %s", self._thread) @@ -428,6 +622,7 @@ def close(self): logger.debug("Received Close") if self._thread is not None: self._thread.join() + self._thread = None logger.debug("Closed") @@ -447,9 +642,6 @@ class ImagesLoader(ImageIO): list of image files. queue_size: int, optional The amount of images to hold in the internal buffer. Default: 8. - load_with_hash: bool, optional - Set to ``True`` to return the sha1 hash of the image along with the image. - Default: ``False``. fast_count: bool, optional When loading from video, the video needs to be parsed frame by frame to get an accurate count. This can be done quite quickly without guaranteed accuracy, or slower with @@ -457,7 +649,11 @@ class ImagesLoader(ImageIO): but accurately. Default: ``True``. skip_list: list, optional Optional list of frame/image indices to not load. Any indices provided here will be skipped - when reading images from the given location. Default: ``None`` + when executing the :func:`load` function from the given location. Default: ``None`` + count: int, optional + If the number of images that the loader will encounter is already known, it can be passed + in here to skip the image counting step, which can save time at launch. Set to ``None`` if + the count is not already known. Default: ``None`` Examples -------- @@ -466,28 +662,26 @@ class ImagesLoader(ImageIO): >>> loader = ImagesLoader('/path/to/video.mp4') >>> for filename, image in loader.load(): >>> - - Loading faces with their sha1 hash: - - >>> loader = ImagesLoader('/path/to/faces/folder', load_with_hash=True) - >>> for filename, image, sha1_hash in loader.load(): - >>> """ - def __init__(self, path, queue_size=8, load_with_hash=False, fast_count=True, skip_list=None): - logger.debug("Initializing %s: (path: %s, queue_size: %s, load_with_hash: %s, " - "fast_count: %s, skip_list: %s)", self.__class__.__name__, path, queue_size, - load_with_hash, fast_count, skip_list) + def __init__(self, + path, + queue_size=8, + fast_count=True, + skip_list=None, + count=None): + logger.debug("Initializing %s: (path: %s, queue_size: %s, fast_count: %s, skip_list: %s, " + "count: %s)", self.__class__.__name__, path, queue_size, fast_count, + skip_list, count) - args = (load_with_hash, ) - super().__init__(path, queue_size=queue_size, args=args) + super().__init__(path, queue_size=queue_size) self._skip_list = set() if skip_list is None else set(skip_list) - self._is_video = self._check_for_video() + self._fps = self._get_fps() self._count = None self._file_list = None - self._get_count_and_filelist(fast_count) + self._get_count_and_filelist(fast_count, count) @property def count(self): @@ -507,6 +701,12 @@ def is_video(self): """ bool: ``True`` if the input is a video, ``False`` if it is not """ return self._is_video + @property + def fps(self): + """ float: For an input folder of images, this will always return 25fps. If the input is a + video, then the fps of the video will be returned. """ + return self._fps + @property def file_list(self): """ list: A full list of files in the source location. This includes any files that will @@ -520,7 +720,8 @@ def add_skip_list(self, skip_list): Parameters ---------- skip_list: list - A list of indices corresponding to the frame indices that should be skipped + A list of indices corresponding to the frame indices that should be skipped by the + :func:`load` function. """ logger.debug(skip_list) self._skip_list = set(skip_list) @@ -547,7 +748,26 @@ def _check_for_video(self): logger.debug("Input '%s' is_video: %s", self.location, retval) return retval - def _get_count_and_filelist(self, fast_count): + def _get_fps(self): + """ Get the Frames per Second. + + If the input is a folder of images than 25.0 will be returned, as it is not possible to + calculate the fps just from frames alone. For video files the correct FPS will be returned. + + Returns + ------- + float: The Frames per Second of the input sources + """ + if self._is_video: + reader = imageio.get_reader(self.location, "ffmpeg") + retval = reader.get_meta_data()["fps"] + reader.close() + else: + retval = 25.0 + logger.debug(retval) + return retval + + def _get_count_and_filelist(self, fast_count, count): """ Set the count of images to be processed and set the file list If the input is a video, a dummy file list is created for checking against an @@ -560,16 +780,20 @@ def _get_count_and_filelist(self, fast_count): count. This can be done quite quickly without guaranteed accuracy, or slower with guaranteed accuracy. Set to ``True`` to count quickly, or ``False`` to count slower but accurately. + count: int + The number of images that the loader will encounter if already known, otherwise + ``None`` """ if self._is_video: - self._count = int(count_frames(self.location, fast=fast_count)) + self._count = int(count_frames(self.location, + fast=fast_count)) if count is None else count self._file_list = [self._dummy_video_framename(i) for i in range(self.count)] else: if isinstance(self.location, (list, tuple)): self._file_list = self.location else: self._file_list = get_image_paths(self.location) - self._count = len(self.file_list) + self._count = len(self.file_list) if count is None else count logger.debug("count: %s", self.count) logger.trace("filelist: %s", self.file_list) @@ -649,32 +873,24 @@ def _from_folder(self): The filename of the loaded image. image: numpy.ndarray The loaded image. - sha1_hash: str, optional - The sha1 hash of the loaded image. Only yielded if :class:`ImageIO` was - initialized with :attr:`load_with_hash` set to ``True`` and the :attr:`location` - is a folder of images. """ - with_hash = self._args[0] - logger.debug("Loading images from folder: '%s'. with_hash: %s", self.location, with_hash) + logger.debug("Loading frames from folder: '%s'", self.location) for idx, filename in enumerate(self.file_list): if idx in self._skip_list: logger.trace("Skipping frame %s due to skip list") continue - image_read = read_image(filename, raise_error=False, with_hash=with_hash) - if with_hash: - retval = filename, *image_read - else: - retval = filename, image_read + image_read = read_image(filename, raise_error=False, with_hash=False) + retval = filename, image_read if retval[1] is None: - logger.debug("Image not loaded: '%s'", filename) + logger.warning("Frame not loaded: '%s'", filename) continue yield retval def load(self): """ Generator for loading images from the given :attr:`location` - If :class:`ImageIO` was initialized with :attr:`load_with_hash` set to ``True`` then - the sha1 hash of the image is added as the final item in the output `tuple`. + If :class:`FacesLoader` is in use then the sha1 hash of the image is added as the final + item in the output `tuple`. Yields ------ @@ -682,10 +898,9 @@ def load(self): The filename of the loaded image. image: numpy.ndarray The loaded image. - sha1_hash: str, optional - The sha1 hash of the loaded image. Only yielded if :class:`ImageIO` was - initialized with :attr:`load_with_hash` set to ``True`` and the :attr:`location` - is a folder of images. + sha1_hash: str, (:class:`FacesLoader` only) + The sha1 hash of the loaded image. Only yielded if :class:`FacesLoader` is being + executed. """ logger.debug("Initializing Load Generator") self._set_thread() @@ -702,7 +917,129 @@ def load(self): for v in retval]) yield retval logger.debug("Closing Load Generator") - self._thread.join() + self.close() + + +class FacesLoader(ImagesLoader): + """ Loads faces from a faces folder along with the face's hash. + + Examples + -------- + Loading faces with their sha1 hash: + + >>> loader = FacesLoader('/path/to/faces/folder') + >>> for filename, face, sha1_hash in loader.load(): + >>> + """ + def __init__(self, path, skip_list=None, count=None): + logger.debug("Initializing %s: (path: %s, count: %s)", self.__class__.__name__, + path, count) + super().__init__(path, queue_size=8, skip_list=skip_list, count=count) + + def _from_folder(self): + """ Generator for loading images from a folder + Faces will only ever be loaded from a folder, so this is the only function requiring + an override + + Yields + ------ + filename: str + The filename of the loaded image. + image: numpy.ndarray + The loaded image. + sha1_hash: str + The sha1 hash of the loaded image. + """ + logger.debug("Loading images from folder: '%s'", self.location) + for idx, filename in enumerate(self.file_list): + if idx in self._skip_list: + logger.trace("Skipping face %s due to skip list") + continue + image_read = read_image(filename, raise_error=False, with_hash=True) + retval = filename, *image_read + if retval[1] is None: + logger.warning("Face not loaded: '%s'", filename) + continue + yield retval + + +class SingleFrameLoader(ImagesLoader): + """ Allows direct access to a frame by filename or frame index. + + As we are interested in instant access to frames, there is no requirement to process in a + background thread, as either way we need to wait for the frame to load. + + Parameters + ---------- + video_meta_data: dict, optional + Existing video meta information containing the pts_time and iskey flags for the given + video. Used in conjunction with single_frame_reader for faster seeks. Providing this means + that the video does not need to be scanned again. Set to ``None`` if the video is to be + scanned. Default: ``None`` + """ + def __init__(self, path, video_meta_data=None): + logger.debug("Initializing %s: (path: %s, video_meta_data: %s)", + self.__class__.__name__, path, video_meta_data) + self._video_meta_data = dict() if video_meta_data is None else video_meta_data + self._reader = None + super().__init__(path, queue_size=1, fast_count=False) + + @property + def video_meta_data(self): + """ dict: For videos contains the keys `frame_pts` holding a list of time stamps for each + frame and `keyframes` holding the frame index of each key frame. + + Notes + ----- + Only populated if the input is a video and single frame reader is being used, otherwise + returns ``None``. + """ + return self._video_meta_data + + def _get_count_and_filelist(self, fast_count, count): + if self._is_video: + self._reader = imageio.get_reader(self.location, "ffmpeg") + count, video_meta_data = self._reader.get_frame_info( + frame_pts=self._video_meta_data.get("pts_time", None), + keyframes=self._video_meta_data.get("keyframes", None)) + self._video_meta_data = video_meta_data + super()._get_count_and_filelist(fast_count, count) + + def image_from_index(self, index): + """ Return a single image from :attr:`file_list` for the given index. + + Parameters + ---------- + index: int + The index number (frame number) of the frame to retrieve. NB: The first frame is + index `0` + + Returns + ------- + filename: str + The filename of the returned image + image: :class:`numpy.ndarray` + The image for the given index + + Notes + ----- + Retrieving frames from video files can be slow as the whole video file needs to be + iterated to retrieve the requested frame. If a frame has already been retrieved, then + retrieving frames of a higher index will be quicker than retrieving frames of a lower + index, as iteration needs to start from the beginning again when navigating backwards. + + We do not use a background thread for this task, as it is assumed that requesting an image + by index will be done when required. + """ + if self.is_video: + image = self._reader.get_data(index)[..., ::-1] + filename = self._dummy_video_framename(index) + else: + filename = self.file_list[index] + image = read_image(filename, raise_error=True) + filename = os.path.basename(filename) + logger.trace("index: %s, filename: %s image shape: %s", index, filename, image.shape) + return filename, image class ImagesSaver(ImageIO): diff --git a/scripts/extract.py b/scripts/extract.py index f8dc675b5a..6275aca045 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -42,7 +42,7 @@ def __init__(self, arguments): self._output_dir = str(get_folder(self._args.output_dir)) logger.info("Output Directory: %s", self._args.output_dir) - self._images = ImagesLoader(self._args.input_dir, load_with_hash=False, fast_count=True) + self._images = ImagesLoader(self._args.input_dir, fast_count=True) self._alignments = Alignments(self._args, True, self._images.is_video) self._existing_count = 0 diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 3d46415b04..aaf2726dde 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -10,17 +10,17 @@ from lib.alignments import Alignments from lib.faces_detect import DetectedFace -from lib.image import ImagesLoader, ImagesSaver +from lib.image import FacesLoader, ImagesLoader, ImagesSaver from lib.multithreading import MultiThread from lib.utils import get_folder from plugins.extract.pipeline import Extractor, ExtractMedia -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) # pylint:disable=invalid-name -class Mask(): +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. @@ -37,18 +37,17 @@ def __init__(self, arguments): self._update_type = arguments.processing self._input_is_faces = arguments.input_type == "faces" self._mask_type = arguments.masker - self._output_opts = dict(blur_kernel=arguments.blur_kernel, threshold=arguments.threshold) - self._output_type = arguments.output_type - self._output_full_frame = arguments.full_frame - self._output_suffix = self._get_output_suffix() - - self._face_count = 0 - self._skip_count = 0 - self._update_count = 0 + self._output = dict(opts=dict(blur_kernel=arguments.blur_kernel, + threshold=arguments.threshold), + type=arguments.output_type, + full_frame=arguments.full_frame, + suffix=self._get_output_suffix()) + self._counts = dict(face=0, skip=0, update=0) self._check_input(arguments.input) self._saver = self._set_saver(arguments) - self._loader = ImagesLoader(arguments.input, load_with_hash=self._input_is_faces) + loader = FacesLoader if self._input_is_faces else ImagesLoader + self._loader = loader(arguments.input) self._alignments = Alignments(os.path.dirname(arguments.alignments), filename=os.path.basename(arguments.alignments)) @@ -151,7 +150,7 @@ def _input_faces(self, *args): queue = args[0] for filename, image, hsh in tqdm(self._loader.load(), total=self._loader.count): if hsh not in self._alignments.hashes_to_frame: - self._skip_count += 1 + self._counts["skip"] += 1 logger.warning("Skipping face not in alignments file: '%s'", filename) continue @@ -167,7 +166,7 @@ def _input_faces(self, *args): logger.debug("Filtered: (filename: '%s', frame: '%s')", filename, frames) for frame, idx in frames.items(): - self._face_count += 1 + self._counts["face"] += 1 alignment = self._alignments.get_faces_in_frame(frame)[idx] if self._check_for_missing(frame, idx, alignment): continue @@ -177,7 +176,7 @@ def _input_faces(self, *args): self._save(frame, idx, detected_face) else: queue.put(ExtractMedia(filename, image, detected_faces=[detected_face])) - self._update_count += 1 + self._counts["update"] += 1 if self._update_type != "output": queue.put("EOF") @@ -196,7 +195,7 @@ def _input_frames(self, *args): 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 + self._counts["skip"] += 1 logger.warning("Skipping frame not in alignments file: '%s'", frame) continue if not self._alignments.frame_has_faces(frame): @@ -204,7 +203,7 @@ def _input_frames(self, *args): continue faces_in_frame = self._alignments.get_faces_in_frame(frame) - self._face_count += len(faces_in_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 @@ -219,7 +218,7 @@ def _input_frames(self, *args): detected_face.image = image self._save(frame, idx, detected_face) else: - self._update_count += len(detected_faces) + self._counts["update"] += len(detected_faces) queue.put(ExtractMedia(filename, image, detected_faces=detected_faces)) if self._update_type != "output": queue.put("EOF") @@ -258,8 +257,8 @@ def _get_output_suffix(self): The suffix to be appended to the output filename """ sfx = "{}_mask_preview_".format(self._mask_type) - sfx += "face_" if not self._output_full_frame or self._input_is_faces else "frame_" - sfx += "{}.png".format(self._output_type) + sfx += "face_" if not self._output["full_frame"] or self._input_is_faces else "frame_" + sfx += "{}.png".format(self._output["type"]) return sfx @staticmethod @@ -289,22 +288,22 @@ def process(self): self._extractor_input_thread.check_and_raise_error() updater(extractor_output) self._extractor_input_thread.join() - if self._update_count != 0: + if self._counts["update"] != 0: self._alignments.backup() self._alignments.save() else: self._extractor_input_thread.join() self._saver.close() - if self._skip_count != 0: + if self._counts["skip"] != 0: logger.warning("%s face(s) skipped due to not existing in the alignments file", - self._skip_count) + self._counts["skip"]) if self._update_type != "output": - if self._update_count == 0: - logger.warning("No masks were updated of the %s faces seen", self._face_count) + 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._update_count, self._face_count) + self._counts["update"], self._counts["face"]) logger.debug("Completed masker process") def _update_faces(self, extractor_output): @@ -355,7 +354,7 @@ def _save(self, frame, idx, detected_face): filename = os.path.join(self._saver.location, "{}_{}_{}".format( os.path.splitext(frame)[0], idx, - self._output_suffix)) + self._output["suffix"])) if detected_face.mask is None or detected_face.mask.get(self._mask_type, None) is None: logger.warning("Mask type '%s' does not exist for frame '%s' index %s. Skipping", @@ -381,8 +380,8 @@ def _create_image(self, detected_face): - The masked face """ mask = detected_face.mask[self._mask_type] - mask.set_blur_and_threshold(**self._output_opts) - if not self._output_full_frame or self._input_is_faces: + 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 = detected_face.image else: @@ -397,14 +396,14 @@ def _create_image(self, detected_face): mask = np.expand_dims(mask, -1) height, width = face.shape[:2] - if self._output_type == "combined": + if self._output["type"] == "combined": masked = (face.astype("float32") * mask.astype("float32") / 255.).astype("uint8") mask = np.tile(mask, 3) for img in (face, masked, mask): cv2.rectangle(img, (0, 0), (width - 1, height - 1), (255, 255, 255), 1) out_image = np.concatenate((face, masked, mask), axis=1) - elif self._output_type == "mask": + elif self._output["type"] == "mask": out_image = mask - elif self._output_type == "masked": + elif self._output["type"] == "masked": out_image = np.concatenate([face, mask], axis=-1) return out_image From 9fe4a65236d3ebb24173a3e433134ef20581c2df Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 25 Mar 2020 23:38:57 +0000 Subject: [PATCH 218/981] Bugfix: Mask Tool --- tools/mask/mask.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tools/mask/mask.py b/tools/mask/mask.py index aaf2726dde..bd31835cb9 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -41,7 +41,7 @@ def __init__(self, arguments): threshold=arguments.threshold), type=arguments.output_type, full_frame=arguments.full_frame, - suffix=self._get_output_suffix()) + suffix=self._get_output_suffix(arguments)) self._counts = dict(face=0, skip=0, update=0) self._check_input(arguments.input) @@ -248,8 +248,13 @@ def _check_for_missing(self, frame, idx, alignment): logger.debug("Mask pre-exists for face: '%s' - %s", frame, idx) return retval - def _get_output_suffix(self): - """ The filename suffix, based on selected output options + def _get_output_suffix(self, arguments): + """ The filename suffix, based on selected output options. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The command line arguments for the mask tool Returns ------- @@ -257,8 +262,8 @@ def _get_output_suffix(self): The suffix to be appended to the output filename """ sfx = "{}_mask_preview_".format(self._mask_type) - sfx += "face_" if not self._output["full_frame"] or self._input_is_faces else "frame_" - sfx += "{}.png".format(self._output["type"]) + sfx += "face_" if not arguments.full_frame or self._input_is_faces else "frame_" + sfx += "{}.png".format(arguments.output_type) return sfx @staticmethod From 86a5921c8d8f4f3948f78755ba8fd3a092aae298 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 26 Mar 2020 17:28:04 +0000 Subject: [PATCH 219/981] Bugfix - Alignments Tool, DFL Conversion --- tools/alignments/media.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/alignments/media.py b/tools/alignments/media.py index d5830c105d..2b72339606 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -33,7 +33,7 @@ def __init__(self, alignments_file): folder, filename = self.check_file_exists(alignments_file) if filename.lower() == "dfl": self._serializer = get_serializer("compressed") - self.file = "{}.{}".format(filename.lower(), self._serializer.file_extension) + self._file = "{}.{}".format(filename.lower(), self._serializer.file_extension) return super().__init__(folder, filename=filename) logger.verbose("%s items loaded", self.frames_count) From 08be32cd352a67af34757013095e1a2814a77ddc Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 27 Mar 2020 11:18:46 +0000 Subject: [PATCH 220/981] GUI Sliders - Validate text box entry --- lib/gui/control_helper.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 7143f058f5..af2f6b7507 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -814,11 +814,15 @@ def slider_control(self): logger.debug("Add slider control to Options Frame: (widget: '%s', dtype: %s, " "rounding: %s, min_max: %s)", self.option.name, self.option.dtype, self.option.rounding, self.option.min_max) + validate = self.slider_check_int if self.option.dtype == int else self.slider_check_float + vcmd = (self.frame.register(validate)) tbox = ttk.Entry(self.frame, width=8, textvariable=self.option.tk_var, justify=tk.RIGHT, - font=get_config().default_font) + font=get_config().default_font, + validate="all", + validatecommand=(vcmd, "%P")) tbox.pack(padx=(0, 5), side=tk.RIGHT) cmd = partial(set_slider_rounding, var=self.option.tk_var, @@ -834,6 +838,34 @@ def slider_control(self): logger.debug("Added slider control to Options Frame: %s", self.option.name) return ctl + @staticmethod + def slider_check_int(value): + """ Validate a slider's text entry box for integer values. + + Parameters + ---------- + value: str + The slider text entry value to validate + """ + if value.isdigit() or value == "": + return True + return False + + @staticmethod + def slider_check_float(value): + """ Validate a slider's text entry box for float values. + Parameters + ---------- + value: str + The slider text entry value to validate + """ + if value: + try: + float(value) + except ValueError: + return False + return True + def control_to_optionsframe(self): """ Standard non-check buttons sit in the main options frame """ logger.debug("Add control to Options Frame: (widget: '%s', control: %s, choices: %s)", From 9c2414a7e14983dd3ce0bffa270869a74a4f68ad Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 27 Mar 2020 12:41:18 +0000 Subject: [PATCH 221/981] lib.alignments - Update video meta data to handle training alignments files/frame ranges --- lib/alignments.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/lib/alignments.py b/lib/alignments.py index 94d81c04b4..8d648af296 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -216,6 +216,11 @@ def backup(self): def save_video_meta_data(self, pts_time, keyframes): """ Save video meta data to the alignments file. + + If the alignments file does not have an entry for every frame (e.g. if Extract Every N + was used) then the frame is added to the alignments file with no faces, so that they video + meta data can be stored. + Parameters ---------- pts_time: list @@ -224,11 +229,17 @@ def save_video_meta_data(self, pts_time, keyframes): keyframes: list A list of frame indices corresponding to the key frames in the input video """ + sample_filename = next(fname for fname in self.data) + basename = sample_filename[:sample_filename.rfind("_")] + logger.info("sample filename: %s, base filename: %s", sample_filename, basename) logger.info("Saving video meta information to Alignments file") - for idx, key in enumerate(sorted(self.data)): - meta = dict(pts_time=pts_time[idx], - keyframe=idx in keyframes) - self.data[key]["video_meta"] = meta + for idx, pts in enumerate(pts_time): + meta = dict(pts_time=pts, keyframe=idx in keyframes) + key = "{}_{:06d}.png".format(basename, idx + 1) + if key not in self.data: + self.data[key] = dict(video_meta=meta, faces=[]) + else: + self.data[key]["video_meta"] = meta self.save() # << VALIDATION >> # From 8d20fb8e8c175f0471dee8b45f98035fd34eaaac Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 29 Mar 2020 11:43:52 +0100 Subject: [PATCH 222/981] Bugfix - lib.image - Make patched ffmpeg reader optional --- lib/image.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/lib/image.py b/lib/image.py index d9e93357d5..b930dd75e2 100644 --- a/lib/image.py +++ b/lib/image.py @@ -36,6 +36,7 @@ def __init__(self, format, request): super().__init__(format, request) self._frame_pts = None self._keyframes = None + self.use_patch = False def get_frame_info(self, frame_pts=None, keyframes=None): """ Store the source video's keyframes in :attr:`_frame_info" for the current video for use @@ -158,17 +159,24 @@ def _initialize(self, index=0): # still somewhat unresponsive and did not always land on the correct frame. This monkey # patched version goes to the previous keyframe then discards frames until the correct # frame is landed on. - if self._frame_pts is None: + if self.use_patch and self._frame_pts is None: self.get_frame_info() - keyframe_pts, keyframe = self._previous_keyframe_info(index) - seek_fast = keyframe_pts - skip_frames = index - keyframe + if self.use_patch: + keyframe_pts, keyframe = self._previous_keyframe_info(index) + seek_fast = keyframe_pts + skip_frames = index - keyframe + else: + starttime = index / self._meta["fps"] + seek_slow = min(10, starttime) + seek_fast = starttime - seek_slow # We used to have this epsilon earlier, when we did not use # the slow seek. I don't think we need it anymore. # epsilon = -1 / self._meta["fps"] * 0.1 iargs += ["-ss", "%.06f" % (seek_fast)] + if not self.use_patch: + oargs += ["-ss", "%.06f" % (seek_slow)] # Output args, for writing to pipe if self._arg_size: @@ -210,11 +218,12 @@ def _initialize(self, index=0): elif index == 0: self._meta.update(self._read_gen.__next__()) else: - frames_skipped = 0 - while skip_frames != frames_skipped: - # Skip frames that are not the desired frame - _ = self._read_gen.__next__() - frames_skipped += 1 + if self.use_patch: + frames_skipped = 0 + while skip_frames != frames_skipped: + # Skip frames that are not the desired frame + _ = self._read_gen.__next__() + frames_skipped += 1 self._read_gen.__next__() # we already have meta data @@ -999,6 +1008,7 @@ def video_meta_data(self): def _get_count_and_filelist(self, fast_count, count): if self._is_video: self._reader = imageio.get_reader(self.location, "ffmpeg") + self._reader.use_patch = True count, video_meta_data = self._reader.get_frame_info( frame_pts=self._video_meta_data.get("pts_time", None), keyframes=self._video_meta_data.get("keyframes", None)) From e7c5b7b6330333a071574f0291be066ab2324ba6 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 29 Mar 2020 18:24:58 +0100 Subject: [PATCH 223/981] Bugfix: cv2-dnn aligner - Assertion image is empty error --- plugins/extract/align/cv2_dnn.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/plugins/extract/align/cv2_dnn.py b/plugins/extract/align/cv2_dnn.py index c8971aec89..317518b55f 100644 --- a/plugins/extract/align/cv2_dnn.py +++ b/plugins/extract/align/cv2_dnn.py @@ -63,22 +63,21 @@ def align_image(self, batch): rois = [] faces = [] offsets = [] - for face, image in zip(batch["detected_faces"], batch["image"]): - box = (face.left, - face.top, - face.right, - face.bottom) - diff_height_width = face.h - face.w + for det_face, image in zip(batch["detected_faces"], batch["image"]): + box = (det_face.left, + det_face.top, + det_face.right, + det_face.bottom) + diff_height_width = det_face.h - det_face.w offset_y = int(abs(diff_height_width / 2)) box_moved = self.move_box(box, [0, offset_y]) - # Make box square. roi = self.get_square_box(box_moved) # Pad the image and adjust roi if face is outside of boundaries image, offset = self.pad_image(roi, image) - face = image[roi[1] + offset[1]: roi[3] + offset[1], - roi[0] + offset[0]: roi[2] + offset[0]] + face = image[roi[1] + abs(offset[1]): roi[3] + abs(offset[1]), + roi[0] + abs(offset[0]): roi[2] + abs(offset[0])] interpolation = cv2.INTER_CUBIC if face.shape[0] < self.input_size else cv2.INTER_AREA face = cv2.resize(face, dsize=sizes, interpolation=interpolation) faces.append(face) From bb3e3d0f1633b66023dd0a0b82b41f69381af6ff Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 30 Mar 2020 19:24:29 +0100 Subject: [PATCH 224/981] Extraction - Auto generate Components and Extended Masks --- lib/alignments.py | 2 +- lib/cli.py | 43 +++++++----- plugins/extract/_base.py | 25 ++++--- plugins/extract/align/_base.py | 4 +- plugins/extract/align/cv2_dnn.py | 2 +- plugins/extract/align/fan.py | 2 +- plugins/extract/align/fan_defaults.py | 6 +- plugins/extract/detect/_base.py | 2 +- plugins/extract/detect/cv2_dnn_defaults.py | 6 +- plugins/extract/detect/mtcnn.py | 2 +- plugins/extract/detect/mtcnn_defaults.py | 6 +- plugins/extract/detect/s3fd_defaults.py | 6 +- plugins/extract/mask/_base.py | 2 +- plugins/extract/mask/components.py | 2 +- plugins/extract/mask/extended.py | 2 +- plugins/extract/mask/unet_dfl_defaults.py | 6 +- plugins/extract/mask/vgg_clear_defaults.py | 6 +- plugins/extract/mask/vgg_obstructed.py | 2 +- .../extract/mask/vgg_obstructed_defaults.py | 6 +- plugins/extract/pipeline.py | 67 ++++++++++++++++--- scripts/extract.py | 2 +- 21 files changed, 131 insertions(+), 70 deletions(-) diff --git a/lib/alignments.py b/lib/alignments.py index 8d648af296..de54b0ecc0 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -231,7 +231,7 @@ def save_video_meta_data(self, pts_time, keyframes): """ sample_filename = next(fname for fname in self.data) basename = sample_filename[:sample_filename.rfind("_")] - logger.info("sample filename: %s, base filename: %s", sample_filename, basename) + logger.debug("sample filename: %s, base filename: %s", sample_filename, basename) logger.info("Saving video meta information to Alignments file") for idx, pts in enumerate(pts_time): meta = dict(pts_time=pts, keyframe=idx in keyframes) diff --git a/lib/cli.py b/lib/cli.py index d16ffdb6c7..4f7ac22c4c 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -47,15 +47,15 @@ def test_for_tf_version(): min_ver = 1.12 max_ver = 1.15 try: - # Ensure tensorflow doesn't pin all threads to one core when using tf-mkl + # Ensure tensorflow doesn't pin all threads to one core when using Math Kernel Library os.environ["KMP_AFFINITY"] = "disabled" - import tensorflow as tf + import tensorflow as tf # pylint:disable=import-outside-toplevel except ImportError as err: raise FaceswapError("There was an error importing Tensorflow. This is most likely " "because you do not have TensorFlow installed, or you are trying " "to run tensorflow-gpu on a system without an Nvidia graphics " "card. Original import error: {}".format(str(err))) - tf_ver = float(".".join(tf.__version__.split(".")[:2])) + tf_ver = float(".".join(tf.__version__.split(".")[:2])) # pylint:disable=no-member if tf_ver < min_ver: raise FaceswapError("The minimum supported Tensorflow is version {} but you have " "version {} installed. Please upgrade Tensorflow.".format( @@ -85,7 +85,7 @@ def test_tkinter(): try: # pylint: disable=unused-variable - import tkinter # noqa pylint: disable=unused-import + import tkinter # noqa pylint: disable=unused-import,import-outside-toplevel except ImportError: logger.error( "It looks like TkInter isn't installed for your OS, so " @@ -153,11 +153,11 @@ def setup_amd(loglevel): """ Test for plaidml and setup for AMD """ logger.debug("Setting up for AMD") try: - import plaidml # noqa pylint:disable=unused-import + import plaidml # noqa pylint:disable=unused-import,import-outside-toplevel except ImportError: logger.error("PlaidML not found. Run `pip install plaidml-keras` for AMD support") return False - from lib.plaidml_tools import setup_plaidml + from lib.plaidml_tools import setup_plaidml # pylint:disable=import-outside-toplevel setup_plaidml(loglevel) logger.debug("setup up for PlaidML") return True @@ -255,14 +255,14 @@ class FilesFullPaths(FileFullPaths): # pylint: disable=too-few-public-methods """ Class that the gui uses to determine that the input can take multiple files as an input. Inherits functionality from FileFullPaths Has the effect of giving the user 2 Open Dialogue buttons in the gui """ - pass + pass # pylint: disable=unnecessary-pass class DirOrFileFullPaths(FileFullPaths): # pylint: disable=too-few-public-methods """ Class that the gui uses to determine that the input can take a folder or a filename. Inherits functionality from FileFullPaths Has the effect of giving the user 2 Open Dialogue buttons in the gui """ - pass + pass # pylint: disable=unnecessary-pass class SaveFileFullPaths(FileFullPaths): @@ -568,21 +568,20 @@ def get_optional_arguments(): "\nL|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " "but less accurate. Only use this if not using a GPU and time is important." "\nL|fan: Best aligner. Fast on GPU, slow on CPU."}) + mask_choices = [mask + for mask in PluginLoader.get_available_extractors("mask", add_none=True) + if mask not in ("components", "extended")] argument_list.append({ "opts": ("-M", "--masker"), "action": Radio, "type": str.lower, - "choices": PluginLoader.get_available_extractors("mask", add_none=True), - "default": "extended", + "choices": mask_choices, + "default": "none", "group": "Plugins", - "help": "R|Masker to use." + "help": "R|Additional Masker to use. NB: The Extended and Components (landmark based) " + "masks are automatically generated on extraction. Any mask selected here " + "will be generated in addition to these default masks." "\nL|none: Don't use a mask." - "\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|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." @@ -593,7 +592,15 @@ def get_optional_arguments(): "\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."}) + "performance." + "\nThe auto generated masks are as follows:" + "\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|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."}) argument_list.append({ "opts": ("-nm", "--normalization"), "action": Radio, diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 8cb3ffcc54..5f1df4e71f 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -77,7 +77,7 @@ class Extractor(): input_size: int The input size to the model in pixels across one edge. The input size should always be square. - colorformat: str + color_format: str Color format for model. Must be ``'BGR'``, ``'RGB'`` or ``'GRAY'``. Defaults to ``'BGR'`` if not explicitly set. vram: int @@ -113,7 +113,7 @@ def __init__(self, git_model_id=None, model_filename=None, configfile=None): # << SET THE FOLLOWING IN PLUGINS __init__ IF DIFFERENT FROM DEFAULT >> # self.name = None self.input_size = None - self.colorformat = "BGR" + self.color_format = "BGR" self.vram = None self.vram_warnings = None # Will run at this with warnings self.vram_per_batch = None @@ -328,7 +328,10 @@ def initialize(self, *args, **kwargs): self.__class__.__name__, args, kwargs) logger.info("Initializing %s (%s)...", self.name, self._plugin_type.title()) self.queue_size = 1 - self._add_queues(kwargs["in_queue"], kwargs["out_queue"], ["predict", "post"]) + name = self.name.replace(" ", "_").lower() + self._add_queues(kwargs["in_queue"], + kwargs["out_queue"], + ["predict_{}".format(name), "post_{}".format(name)]) self._compile_threads() try: self.init_model() @@ -362,17 +365,19 @@ def _add_queues(self, in_queue, out_queue, queues): def _compile_threads(self): """ Compile the threads into self._threads list """ logger.debug("Compiling %s threads", self._plugin_type) - self._add_thread("{}_input".format(self._plugin_type), + name = self.name.replace(" ", "_").lower() + base_name = "{}_{}".format(self._plugin_type, name) + self._add_thread("{}_input".format(base_name), self.process_input, self._queues["in"], - self._queues["predict"]) - self._add_thread("{}_predict".format(self._plugin_type), + self._queues["predict_{}".format(name)]) + self._add_thread("{}_predict".format(base_name), self._predict, - self._queues["predict"], - self._queues["post"]) - self._add_thread("{}_output".format(self._plugin_type), + self._queues["predict_{}".format(name)], + self._queues["post_{}".format(name)]) + self._add_thread("{}_output".format(base_name), self.process_output, - self._queues["post"], + self._queues["post_{}".format(name)], self._queues["out"]) logger.debug("Compiled %s threads: %s", self._plugin_type, self._threads) diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index 885d101033..a0aeb7fa26 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -125,7 +125,7 @@ def get_batch(self, queue): self._queues["out"].put(item) continue - converted_image = item.get_image_copy(self.colorformat) + converted_image = item.get_image_copy(self.color_format) for f_idx, face in enumerate(item.detected_faces): batch.setdefault("image", []).append(converted_image) batch.setdefault("detected_faces", []).append(face) @@ -219,7 +219,7 @@ def _predict(self, batch): def _normalize_faces(self, faces): """ Normalizes the face for feeding into model - The normalization method is dictated by the command line argument `-nh (--normalization)` + The normalization method is dictated by the normalization command line argument """ if self._normalize_method is None: return faces diff --git a/plugins/extract/align/cv2_dnn.py b/plugins/extract/align/cv2_dnn.py index 317518b55f..1458cefda9 100644 --- a/plugins/extract/align/cv2_dnn.py +++ b/plugins/extract/align/cv2_dnn.py @@ -39,7 +39,7 @@ def __init__(self, **kwargs): self.name = "cv2-DNN Aligner" self.input_size = 128 - self.colorformat = "RGB" + self.color_format = "RGB" self.vram = 0 # Doesn't use GPU self.vram_per_batch = 0 self.batchsize = 1 diff --git a/plugins/extract/align/fan.py b/plugins/extract/align/fan.py index a4924bf432..3be1d14511 100644 --- a/plugins/extract/align/fan.py +++ b/plugins/extract/align/fan.py @@ -20,7 +20,7 @@ def __init__(self, **kwargs): super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.name = "FAN" self.input_size = 256 - self.colorformat = "RGB" + self.color_format = "RGB" self.vram = 2240 self.vram_warnings = 512 # Will run at this with warnings self.vram_per_batch = 64 diff --git a/plugins/extract/align/fan_defaults.py b/plugins/extract/align/fan_defaults.py index 1c08acdf6b..a790c6ed23 100644 --- a/plugins/extract/align/fan_defaults.py +++ b/plugins/extract/align/fan_defaults.py @@ -18,7 +18,7 @@ The following keys are expected for the _DEFAULTS dict: datatype: [required] A python type class. This limits the type of data that can be provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , + correct type to faceswap. Valid data types are: , , , . default: [required] The default value for this option. info: [required] A string describing what this option does. @@ -29,10 +29,10 @@ a combobox / radio option in the GUI. gui_radio: [optional] If are defined, this indicates that the GUI should use radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required + min_max: [partial] For and data types this is required otherwise it is ignored. Should be a tuple of min and max accepted values. This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is + rounding: [partial] For and data types this is required otherwise it is ignored. Used for the GUI slider. For floats, this is the number of decimal places to display. For ints this is the step size. fixed: [optional] [train only]. Training configurations are fixed when the model is diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index e562e97dbb..345903ae79 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -229,7 +229,7 @@ def _compile_detection_image(self, item): item: :class:`plugins.extract.pipeline.ExtractMedia` The input item from the pipeline """ - image = item.get_image_copy(self.colorformat) + image = item.get_image_copy(self.color_format) scale = self._set_scale(item.image_size) pad = self._set_padding(item.image_size, scale) diff --git a/plugins/extract/detect/cv2_dnn_defaults.py b/plugins/extract/detect/cv2_dnn_defaults.py index 3402385581..ad2f995d02 100755 --- a/plugins/extract/detect/cv2_dnn_defaults.py +++ b/plugins/extract/detect/cv2_dnn_defaults.py @@ -18,7 +18,7 @@ The following keys are expected for the _DEFAULTS dict: datatype: [required] A python type class. This limits the type of data that can be provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , + correct type to faceswap. Valid data types are: , , , . default: [required] The default value for this option. info: [required] A string describing what this option does. @@ -29,10 +29,10 @@ a combobox / radio option in the GUI. gui_radio: [optional] If are defined, this indicates that the GUI should use radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required + min_max: [partial] For and data types this is required otherwise it is ignored. Should be a tuple of min and max accepted values. This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is + rounding: [partial] For and data types this is required otherwise it is ignored. Used for the GUI slider. For floats, this is the number of decimal places to display. For ints this is the step size. fixed: [optional] [train only]. Training configurations are fixed when the model is diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py index 13942d99f6..4e623aa628 100644 --- a/plugins/extract/detect/mtcnn.py +++ b/plugins/extract/detect/mtcnn.py @@ -25,7 +25,7 @@ def __init__(self, **kwargs): self.vram_per_batch = 32 self.batchsize = self.config["batch-size"] self.kwargs = self.validate_kwargs() - self.colorformat = "RGB" + self.color_format = "RGB" def validate_kwargs(self): """ Validate that config options are correct. If not reset to default """ diff --git a/plugins/extract/detect/mtcnn_defaults.py b/plugins/extract/detect/mtcnn_defaults.py index f2d28bc534..2d7a7251c7 100755 --- a/plugins/extract/detect/mtcnn_defaults.py +++ b/plugins/extract/detect/mtcnn_defaults.py @@ -18,7 +18,7 @@ The following keys are expected for the _DEFAULTS dict: datatype: [required] A python type class. This limits the type of data that can be provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , + correct type to faceswap. Valid data types are: , , , . default: [required] The default value for this option. info: [required] A string describing what this option does. @@ -29,10 +29,10 @@ a combobox / radio option in the GUI. gui_radio: [optional] If are defined, this indicates that the GUI should use radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required + min_max: [partial] For and data types this is required otherwise it is ignored. Should be a tuple of min and max accepted values. This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is + rounding: [partial] For and data types this is required otherwise it is ignored. Used for the GUI slider. For floats, this is the number of decimal places to display. For ints this is the step size. fixed: [optional] [train only]. Training configurations are fixed when the model is diff --git a/plugins/extract/detect/s3fd_defaults.py b/plugins/extract/detect/s3fd_defaults.py index 3d65ad3383..1f7100f462 100755 --- a/plugins/extract/detect/s3fd_defaults.py +++ b/plugins/extract/detect/s3fd_defaults.py @@ -18,7 +18,7 @@ The following keys are expected for the _DEFAULTS dict: datatype: [required] A python type class. This limits the type of data that can be provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , + correct type to faceswap. Valid data types are: , , , . default: [required] The default value for this option. info: [required] A string describing what this option does. @@ -29,10 +29,10 @@ a combobox / radio option in the GUI. gui_radio: [optional] If are defined, this indicates that the GUI should use radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required + min_max: [partial] For and data types this is required otherwise it is ignored. Should be a tuple of min and max accepted values. This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is + rounding: [partial] For and data types this is required otherwise it is ignored. Used for the GUI slider. For floats, this is the number of decimal places to display. For ints this is the step size. fixed: [optional] [train only]. Training configurations are fixed when the model is diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 13b9096407..3dd3eced46 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -114,7 +114,7 @@ def get_batch(self, queue): self._queues["out"].put(item) continue for f_idx, face in enumerate(item.detected_faces): - face.load_feed_face(item.get_image_copy(self.colorformat), + face.load_feed_face(item.get_image_copy(self.color_format), size=self.input_size, coverage_ratio=1.0, dtype="float32", diff --git a/plugins/extract/mask/components.py b/plugins/extract/mask/components.py index 3c2ffece96..6dd6f02285 100644 --- a/plugins/extract/mask/components.py +++ b/plugins/extract/mask/components.py @@ -44,7 +44,7 @@ def process_output(self, batch): @staticmethod def parse_parts(landmarks): - """ Component facehull mask """ + """ Component face hull mask """ r_jaw = (landmarks[0:9], landmarks[17:18]) l_jaw = (landmarks[8:17], landmarks[26:27]) r_cheek = (landmarks[17:20], landmarks[8:9]) diff --git a/plugins/extract/mask/extended.py b/plugins/extract/mask/extended.py index a182c0af4f..93995f0bfa 100644 --- a/plugins/extract/mask/extended.py +++ b/plugins/extract/mask/extended.py @@ -44,7 +44,7 @@ def process_output(self, batch): @staticmethod def parse_parts(landmarks): - """ Extended facehull mask """ + """ Extended face hull mask """ # mid points between the side of face and eye point ml_pnt = (landmarks[36] + landmarks[0]) // 2 mr_pnt = (landmarks[16] + landmarks[45]) // 2 diff --git a/plugins/extract/mask/unet_dfl_defaults.py b/plugins/extract/mask/unet_dfl_defaults.py index a00b170d7a..5956610e75 100644 --- a/plugins/extract/mask/unet_dfl_defaults.py +++ b/plugins/extract/mask/unet_dfl_defaults.py @@ -18,7 +18,7 @@ The following keys are expected for the _DEFAULTS dict: datatype: [required] A python type class. This limits the type of data that can be provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , + correct type to faceswap. Valid data types are: , , , . default: [required] The default value for this option. info: [required] A string describing what this option does. @@ -29,10 +29,10 @@ a combobox / radio option in the GUI. gui_radio: [optional] If are defined, this indicates that the GUI should use radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required + min_max: [partial] For and data types this is required otherwise it is ignored. Should be a tuple of min and max accepted values. This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is + rounding: [partial] For and data types this is required otherwise it is ignored. Used for the GUI slider. For floats, this is the number of decimal places to display. For ints this is the step size. fixed: [optional] [train only]. Training configurations are fixed when the model is diff --git a/plugins/extract/mask/vgg_clear_defaults.py b/plugins/extract/mask/vgg_clear_defaults.py index 6ce28a9898..ef2c307fb1 100644 --- a/plugins/extract/mask/vgg_clear_defaults.py +++ b/plugins/extract/mask/vgg_clear_defaults.py @@ -18,7 +18,7 @@ The following keys are expected for the _DEFAULTS dict: datatype: [required] A python type class. This limits the type of data that can be provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , + correct type to faceswap. Valid data types are: , , , . default: [required] The default value for this option. info: [required] A string describing what this option does. @@ -29,10 +29,10 @@ a combobox / radio option in the GUI. gui_radio: [optional] If are defined, this indicates that the GUI should use radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required + min_max: [partial] For and data types this is required otherwise it is ignored. Should be a tuple of min and max accepted values. This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is + rounding: [partial] For and data types this is required otherwise it is ignored. Used for the GUI slider. For floats, this is the number of decimal places to display. For ints this is the step size. fixed: [optional] [train only]. Training configurations are fixed when the model is diff --git a/plugins/extract/mask/vgg_obstructed.py b/plugins/extract/mask/vgg_obstructed.py index 95d7056fe8..487712f00d 100644 --- a/plugins/extract/mask/vgg_obstructed.py +++ b/plugins/extract/mask/vgg_obstructed.py @@ -28,7 +28,7 @@ def __init__(self, **kwargs): self.name = "VGG Obstructed" self.input_size = 500 self.vram = 3936 - self.vram_warnings = 1088 # at BS 1. OOMs at higher batchsizes + self.vram_warnings = 1088 # at BS 1. OOMs at higher batch sizes self.vram_per_batch = 304 self.batchsize = self.config["batch-size"] diff --git a/plugins/extract/mask/vgg_obstructed_defaults.py b/plugins/extract/mask/vgg_obstructed_defaults.py index 9a21d760a0..0588ee1c66 100644 --- a/plugins/extract/mask/vgg_obstructed_defaults.py +++ b/plugins/extract/mask/vgg_obstructed_defaults.py @@ -18,7 +18,7 @@ The following keys are expected for the _DEFAULTS dict: datatype: [required] A python type class. This limits the type of data that can be provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , + correct type to faceswap. Valid data types are: , , , . default: [required] The default value for this option. info: [required] A string describing what this option does. @@ -29,10 +29,10 @@ a combobox / radio option in the GUI. gui_radio: [optional] If are defined, this indicates that the GUI should use radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required + min_max: [partial] For and data types this is required otherwise it is ignored. Should be a tuple of min and max accepted values. This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is + rounding: [partial] For and data types this is required otherwise it is ignored. Used for the GUI slider. For floats, this is the number of decimal places to display. For ints this is the step size. fixed: [optional] [train only]. Training configurations are fixed when the model is diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index f1c9e16d06..092f597449 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -73,6 +73,7 @@ def __init__(self, detector, aligner, masker, configfile=None, "normalize_method: %s, image_is_aligned: %s)", self.__class__.__name__, detector, aligner, masker, configfile, multiprocess, rotate_images, min_size, normalize_method, image_is_aligned) + masker = [masker] if not isinstance(masker, list) else masker self._flow = self._set_flow(detector, aligner, masker) self.phase = self._flow[0] # We only ever need 1 item in each queue. This is 2 items cached (1 in queue 1 waiting @@ -81,7 +82,7 @@ def __init__(self, detector, aligner, masker, configfile=None, self._vram_buffer = 256 # Leave a buffer for VRAM allocation self._detect = self._load_detect(detector, rotate_images, min_size, configfile) self._align = self._load_align(aligner, configfile, normalize_method) - self._mask = self._load_mask(masker, image_is_aligned, configfile) + self._mask = [self._load_mask(mask, image_is_aligned, configfile) for mask in masker] self._is_parallel = self._set_parallel_processing(multiprocess) self._set_extractor_batchsize() self._queues = self._add_queues() @@ -255,10 +256,18 @@ def _parallel_scaling(self): @property def _total_vram_required(self): """ Return vram required for all phases plus the buffer """ - vrams = [getattr(self, "_{}".format(p)).vram for p in self._flow] - vram_required_count = sum(1 for p in vrams if p > 0) - retval = (sum(vrams) * self._parallel_scaling[vram_required_count]) + self._vram_buffer - logger.trace(retval) + vrams = dict() + for phase in self._flow: + plugin_type, idx = self._get_plugin_type_and_index(phase) + attr = getattr(self, "_{}".format(plugin_type)) + attr = attr[idx] if idx is not None else attr + vrams[phase] = attr.vram + vram_required_count = sum(1 for p in vrams.values() if p > 0) + logger.debug("VRAM requirements: %s. Plugins requiring VRAM: %s", + vrams, vram_required_count) + retval = (sum(vrams.values()) * + self._parallel_scaling[vram_required_count]) + self._vram_buffer + logger.debug("Total VRAM required: %s", retval) return retval @property @@ -289,7 +298,13 @@ def _output_queue(self): @property def _all_plugins(self): """ Return list of all plugin objects in this pipeline """ - retval = [getattr(self, "_{}".format(phase)) for phase in self._flow] + retval = [] + for phase in self._flow: + plugin_type, idx = self._get_plugin_type_and_index(phase) + attr = getattr(self, "_{}".format(plugin_type)) + attr = attr[idx] if idx is not None else attr + retval.append(attr) + attr = getattr(self, "_{}".format(plugin_type)) logger.trace("All Plugins: %s", retval) return retval @@ -312,14 +327,46 @@ def _set_flow(detector, aligner, masker): retval.append("detect") if aligner is not None and aligner.lower() != "none": retval.append("align") - if masker is not None and masker.lower() != "none": - retval.append("mask") + for idx, mask in enumerate(masker): + if mask is not None and mask.lower() != "none": + retval.append("mask_{}".format(idx)) logger.debug("flow: %s", retval) return retval + @staticmethod + def _get_plugin_type_and_index(flow_phase): + """ Obtain the plugin type and index for the plugin for the given flow phase. + + When multiple plugins for the same phase are allowed (e.g. Mask) this will return + the plugin type and the index of the plugin required. If only one plugin is allowed + then the plugin type will be returned and the index will be ``None``. + + Parameters + ---------- + flow_phase: str + The phase within :attr:`_flow` that is to have the plugin type and index returned + + Returns + ------- + plugin_type: str + The plugin type for the given flow phase + index: int + The index of this plugin type within the flow, if there are multiple plugins in use + otherwise ``None`` if there is only 1 plugin in use for the given phase + """ + idx = flow_phase.split("_")[-1] + if idx.isdigit(): + idx = int(idx) + plugin_type = "_".join(flow_phase.split("_")[:-1]) + else: + plugin_type = flow_phase + idx = None + return plugin_type, idx + def _add_queues(self): """ Add the required processing queues to Queue Manager """ queues = dict() + tasks = [] tasks = ["extract_{}_in".format(phase) for phase in self._flow] tasks.append("extract_{}_out".format(self._final_phase)) for task in tasks: @@ -407,7 +454,9 @@ def _launch_plugin(self, phase): logger.debug("in_qname: %s, out_qname: %s", in_qname, out_qname) kwargs = dict(in_queue=self._queues[in_qname], out_queue=self._queues[out_qname]) - plugin = getattr(self, "_{}".format(phase)) + plugin_type, idx = self._get_plugin_type_and_index(phase) + plugin = getattr(self, "_{}".format(plugin_type)) + plugin = plugin[idx] if idx is not None else plugin plugin.initialize(**kwargs) plugin.start() logger.debug("Launched %s plugin", phase) diff --git a/scripts/extract.py b/scripts/extract.py index 6275aca045..eacaed400a 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -53,7 +53,7 @@ def __init__(self, arguments): normalization = None if self._args.normalization == "none" else self._args.normalization self._extractor = Extractor(self._args.detector, self._args.aligner, - self._args.masker, + [self._args.masker, "components", "extended"], configfile=configfile, multiprocess=not self._args.singleprocess, rotate_images=self._args.rotate_images, From 8d4f381e7ce7aec4ded719aaf84cbbb1401b3955 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 31 Mar 2020 15:24:32 +0100 Subject: [PATCH 225/981] Extract - Better VRAM Allocation in Extraction Pipeline --- plugins/extract/pipeline.py | 258 +++++++++++++++++++++++++----------- scripts/extract.py | 6 +- 2 files changed, 182 insertions(+), 82 deletions(-) diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 092f597449..620fe1ecaa 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -75,15 +75,16 @@ def __init__(self, detector, aligner, masker, configfile=None, multiprocess, rotate_images, min_size, normalize_method, image_is_aligned) masker = [masker] if not isinstance(masker, list) else masker self._flow = self._set_flow(detector, aligner, masker) - self.phase = self._flow[0] # We only ever need 1 item in each queue. This is 2 items cached (1 in queue 1 waiting # for queue) at each point. Adding more just stacks RAM with no speed benefit. self._queue_size = 1 - self._vram_buffer = 256 # Leave a buffer for VRAM allocation + self._vram_stats = self._get_vram_stats() self._detect = self._load_detect(detector, rotate_images, min_size, configfile) self._align = self._load_align(aligner, configfile, normalize_method) self._mask = [self._load_mask(mask, image_is_aligned, configfile) for mask in masker] self._is_parallel = self._set_parallel_processing(multiprocess) + self._phases = self._set_phases(multiprocess) + self._phase_index = 0 self._set_extractor_batchsize() self._queues = self._add_queues() logger.debug("Initialized %s", self.__class__.__name__) @@ -101,7 +102,7 @@ def input_queue(self): For align/mask (2nd/3rd pass operations) the :attr:`ExtractMedia.detected_faces` should also be populated by calling :func:`ExtractMedia.set_detected_faces`. """ - qname = "extract_{}_in".format(self.phase) + qname = "extract_{}_in".format(self._current_phase[0]) retval = self._queues[qname] logger.trace("%s: %s", qname, retval) return retval @@ -124,7 +125,17 @@ def passes(self): >>> extract_media.set_image(image) >>> extractor.input_queue.put(extract_media) """ - retval = 1 if self._is_parallel else len(self._flow) + retval = len(self._phases) + logger.trace(retval) + return retval + + @property + def phase_text(self): + """ str: The plugins that are running in the current phase, formatted for info text + output. """ + plugin_types = set(self._get_plugin_type_and_index(phase)[0] + for phase in self._current_phase) + retval = ", ".join(plugin_type.title() for plugin_type in list(plugin_types)) logger.trace(retval) return retval @@ -145,7 +156,7 @@ def final_pass(self): >>> >>> extractor.input_queue.put(extract_media) """ - retval = self._is_parallel or self.phase == self._final_phase + retval = self._phase_index == len(self._phases) - 1 logger.trace(retval) return retval @@ -178,12 +189,8 @@ def launch(self): >>> extractor.launch(): >>> """ - - if self._is_parallel: - for phase in self._flow: - self._launch_plugin(phase) - else: - self._launch_plugin(self.phase) + for phase in self._current_phase: + self._launch_plugin(phase) def detected_faces(self): """ Generator that returns results, frame by frame from the extraction pipeline @@ -203,7 +210,7 @@ def detected_faces(self): >>> image = extract_media.image >>> detected_faces = extract_media.detected_faces """ - logger.debug("Running Detection. Phase: '%s'", self.phase) + logger.debug("Running Detection. Phase: '%s'", self._current_phase) # If not multiprocessing, intercept the align in queue for # detection phase out_queue = self._output_queue @@ -225,8 +232,8 @@ def detected_faces(self): queue_manager.del_queue(q_name) logger.debug("Detection Complete") else: - self.phase = self._next_phase - logger.debug("Switching to %s phase", self.phase) + self._phase_index += 1 + logger.debug("Switching to phase: %s", self._current_phase) # <<< INTERNAL METHODS >>> # @property @@ -254,26 +261,33 @@ def _parallel_scaling(self): return retval @property - def _total_vram_required(self): - """ Return vram required for all phases plus the buffer """ - vrams = dict() + def _vram_per_phase(self): + """ dict: The amount of vram required for each phase in :attr:`_flow`. """ + retval = dict() for phase in self._flow: plugin_type, idx = self._get_plugin_type_and_index(phase) attr = getattr(self, "_{}".format(plugin_type)) attr = attr[idx] if idx is not None else attr - vrams[phase] = attr.vram + retval[phase] = attr.vram + logger.trace(retval) + return retval + + @property + def _total_vram_required(self): + """ Return vram required for all phases plus the buffer """ + vrams = self._vram_per_phase vram_required_count = sum(1 for p in vrams.values() if p > 0) logger.debug("VRAM requirements: %s. Plugins requiring VRAM: %s", vrams, vram_required_count) retval = (sum(vrams.values()) * - self._parallel_scaling[vram_required_count]) + self._vram_buffer + self._parallel_scaling[vram_required_count]) logger.debug("Total VRAM required: %s", retval) return retval @property - def _next_phase(self): - """ Return the next phase from the flow list """ - retval = self._flow[self._flow.index(self.phase) + 1] + def _current_phase(self): + """ list: The current phase from :attr:`_phases` that is running through the extractor. """ + retval = self._phases[self._phase_index] logger.trace(retval) return retval @@ -290,7 +304,7 @@ def _output_queue(self): if self.final_pass: qname = "extract_{}_out".format(self._final_phase) else: - qname = "extract_{}_in".format(self._next_phase) + qname = "extract_{}_in".format(self._phases[self._phase_index + 1][0]) retval = self._queues[qname] logger.trace("%s: %s", qname, retval) return retval @@ -304,17 +318,17 @@ def _all_plugins(self): attr = getattr(self, "_{}".format(plugin_type)) attr = attr[idx] if idx is not None else attr retval.append(attr) - attr = getattr(self, "_{}".format(plugin_type)) logger.trace("All Plugins: %s", retval) return retval @property def _active_plugins(self): """ Return the plugins that are currently active based on pass """ - if self.passes == 1: - retval = self._all_plugins - else: - retval = [getattr(self, "_{}".format(self.phase))] + retval = [] + for phase in self._current_phase: + plugin_type, idx = self._get_plugin_type_and_index(phase) + attr = getattr(self, "_{}".format(plugin_type)) + retval.append(attr[idx] if idx is not None else attr) logger.trace("Active plugins: %s", retval) return retval @@ -327,9 +341,9 @@ def _set_flow(detector, aligner, masker): retval.append("detect") if aligner is not None and aligner.lower() != "none": retval.append("align") - for idx, mask in enumerate(masker): - if mask is not None and mask.lower() != "none": - retval.append("mask_{}".format(idx)) + retval.extend(["mask_{}".format(idx) + for idx, mask in enumerate(masker) + if mask is not None and mask.lower() != "none"]) logger.debug("flow: %s", retval) return retval @@ -376,15 +390,38 @@ def _add_queues(self): logger.debug("Queues: %s", queues) return queues + @staticmethod + def _get_vram_stats(): + """ Obtain statistics on available VRAM and subtract a constant buffer from available vram. + + Returns + ------- + dict + Statistics on available VRAM + """ + vram_buffer = 256 # Leave a buffer for VRAM allocation + gpu_stats = GPUStats() + stats = gpu_stats.get_card_most_free() + retval = dict(count=gpu_stats.device_count, + device=stats["device"], + vram_free=int(stats["free"] - vram_buffer), + vram_total=int(stats["total"])) + logger.debug(retval) + return retval + def _set_parallel_processing(self, multiprocess): - """ Set whether to run detect, align, and mask together or separately """ + """ Set whether to run detect, align, and mask together or separately. + Parameters + ---------- + multiprocess: bool + ``True`` if the single-process command line flag has not been set otherwise ``False`` + """ if not multiprocess: logger.debug("Parallel processing disabled by cli.") return False - gpu_stats = GPUStats() - if gpu_stats.device_count == 0: + if self._vram_stats["count"] == 0: logger.debug("No GPU detected. Enabling parallel processing.") return True @@ -392,18 +429,64 @@ def _set_parallel_processing(self, multiprocess): logger.debug("Parallel processing disabled by amd") return False - stats = gpu_stats.get_card_most_free() - vram_free = int(stats["free"]) logger.verbose("%s - %sMB free of %sMB", - stats["device"], - vram_free, - int(stats["total"])) - if vram_free <= self._total_vram_required: + self._vram_stats["device"], + self._vram_stats["vram_free"], + self._vram_stats["vram_total"]) + if self._vram_stats["vram_free"] <= self._total_vram_required: logger.warning("Not enough free VRAM for parallel processing. " "Switching to serial") return False return True + def _set_phases(self, multiprocess): + """ If not enough VRAM is available, then chunk :attr:`_flow` up into phases that will fit + into VRAM, otherwise return the single flow. + + Parameters + ---------- + multiprocess: bool + ``True`` if the single-process command line flag has not been set otherwise ``False`` + + Returns + ------- + list: + The jobs to be undertaken split into phases that fit into GPU RAM + """ + force_single_process = not multiprocess or get_backend() == "amd" + phases = [] + current_phase = [] + available = self._vram_stats["vram_free"] + for phase in self._flow: + num_plugins = len([p for p in current_phase if self._vram_per_phase[p] > 0]) + num_plugins += 1 if self._vram_per_phase[phase] > 0 else 0 + scaling = self._parallel_scaling[num_plugins] + required = sum(self._vram_per_phase[p] for p in current_phase + [phase]) * scaling + logger.debug("Num plugins for phase: %s, scaling: %s, vram required: %s", + num_plugins, scaling, required) + if required <= available and not force_single_process: + logger.debug("Required: %s, available: %s. Adding phase '%s' to current phase: %s", + required, available, phase, current_phase) + current_phase.append(phase) + elif len(current_phase) == 0 or force_single_process: + # Amount of VRAM required to run a single plugin is greater than available. We add + # it anyway, and hope it will run with warnings, as the alternative is to not run + # at all. + # This will also run if forcing single process + logger.debug("Required: %s, available: %s. Single plugin has higher requirements " + "than available or forcing single process: '%s'", + required, available, phase) + phases.append([phase]) + else: + logger.debug("Required: %s, available: %s. Adding phase to flow: %s", + required, available, current_phase) + phases.append(current_phase) + current_phase = [phase] + if current_phase: + phases.append(current_phase) + logger.debug("Total phases: %s, Phases: %s", len(phases), phases) + return phases + # << INTERNAL PLUGIN HANDLING >> # @staticmethod def _load_align(aligner, configfile, normalize_method): @@ -463,46 +546,30 @@ def _launch_plugin(self, phase): def _set_extractor_batchsize(self): """ - Sets the batch size of the requested plugins based on their vram and - vram_per_batch_requirements if the the configured batch size requires more - vram than is available. Nvidia only. + Sets the batch size of the requested plugins based on their vram, their + vram_per_batch_requirements and the number of plugins being loaded in the current phase. + Only adjusts if the the configured batch size requires more vram than is available. Nvidia + only. """ if get_backend() != "nvidia": logger.debug("Backend is not Nvidia. Not updating batchsize requirements") return - if sum([plugin.vram for plugin in self._all_plugins]) == 0: + if sum([plugin.vram for plugin in self._active_plugins]) == 0: logger.debug("No plugins use VRAM. Not updating batchsize requirements.") return - stats = GPUStats().get_card_most_free() - vram_free = int(stats["free"]) - if self._is_parallel: - batch_required = sum([plugin.vram_per_batch * plugin.batchsize - for plugin in self._all_plugins]) - plugin_required = self._total_vram_required + batch_required - if plugin_required <= vram_free: - logger.debug("Plugin requirements within threshold: (plugin_required: %sMB, " - "vram_free: %sMB)", plugin_required, vram_free) - return - # Hacky split across plugins that use vram - gpu_plugin_count = sum([1 for plugin in self._all_plugins if plugin.vram != 0]) - available_vram = (vram_free - self._total_vram_required) // gpu_plugin_count - for plugin in self._all_plugins: - if plugin.vram != 0: - self._set_plugin_batchsize(plugin, available_vram) - else: - for plugin in self._all_plugins: - if plugin.vram == 0: - continue - vram_required = plugin.vram + self._vram_buffer - batch_required = plugin.vram_per_batch * plugin.batchsize - plugin_required = vram_required + batch_required - if plugin_required <= vram_free: - logger.debug("%s requirements within threshold: (plugin_required: %sMB, " - "vram_free: %sMB)", plugin.name, plugin_required, vram_free) - continue - available_vram = vram_free - vram_required - self._set_plugin_batchsize(plugin, available_vram) + batch_required = sum([plugin.vram_per_batch * plugin.batchsize + for plugin in self._active_plugins]) + gpu_plugins = [p for p in self._current_phase if self._vram_per_phase[p] > 0] + plugins_required = sum([self._vram_per_phase[p] + for p in gpu_plugins]) * self._parallel_scaling[len(gpu_plugins)] + if plugins_required + batch_required <= self._vram_stats["vram_free"]: + logger.debug("Plugin requirements within threshold: (plugins_required: %sMB, " + "vram_free: %sMB)", plugins_required, self._vram_stats["vram_free"]) + return + # Hacky split across plugins that use vram + available_vram = (self._vram_stats["vram_free"] - plugins_required) // len(gpu_plugins) + self._set_plugin_batchsize(gpu_plugins, available_vram) def set_aligner_normalization_method(self, method): """ Change the normalization method for faces fed into the aligner. @@ -515,15 +582,50 @@ def set_aligner_normalization_method(self, method): logger.debug("Setting to: '%s'", method) self._align.set_normalize_method(method) - @staticmethod - def _set_plugin_batchsize(plugin, available_vram): + def _set_plugin_batchsize(self, gpu_plugins, available_vram): """ Set the batch size for the given plugin based on given available vram. Do not update plugins which have a vram_per_batch of 0 (CPU plugins) due to zero division error. """ - if plugin.vram_per_batch != 0: - plugin.batchsize = int(max(1, available_vram // plugin.vram_per_batch)) - logger.verbose("Reset batchsize for %s to %s", plugin.name, plugin.batchsize) + plugins = [self._active_plugins[idx] + for idx, plugin in enumerate(self._current_phase) + if plugin in gpu_plugins] + vram_per_batch = [plugin.vram_per_batch for plugin in plugins] + ratios = [vram / sum(vram_per_batch) for vram in vram_per_batch] + requested_batchsizes = [plugin.batchsize for plugin in plugins] + batchsizes = [min(requested, max(1, int((available_vram * ratio) / plugin.vram_per_batch))) + for ratio, plugin, requested in zip(ratios, plugins, requested_batchsizes)] + remaining = available_vram - sum(batchsize * plugin.vram_per_batch + for batchsize, plugin in zip(batchsizes, plugins)) + sorted_indices = [i[0] for i in sorted(enumerate(plugins), + key=lambda x: x[1].vram_per_batch, reverse=True)] + + logger.debug("requested_batchsizes: %s, batchsizes: %s, remaining vram: %s", + requested_batchsizes, batchsizes, remaining) + + while remaining > min(plugin.vram_per_batch + for plugin in plugins) and requested_batchsizes != batchsizes: + for idx in sorted_indices: + plugin = plugins[idx] + if plugin.vram_per_batch > remaining: + logger.debug("Not enough VRAM to increase batch size of %s. Required: %sMB, " + "Available: %sMB", plugin, plugin.vram_per_batch, remaining) + continue + if plugin.batchsize == batchsizes[idx]: + logger.debug("Threshold reached for %s. Batch size: %s", + plugin, plugin.batchsize) + continue + logger.debug("Incrementing batch size of %s to %s", plugin, batchsizes[idx] + 1) + batchsizes[idx] += 1 + remaining -= plugin.vram_per_batch + logger.debug("Remaining VRAM to allocate: %sMB", remaining) + + if batchsizes != requested_batchsizes: + text = ", ".join(["{}: {}".format(plugin.__class__.__name__, batchsize) + for plugin, batchsize in zip(plugins, batchsizes)]) + for plugin, batchsize in zip(plugins, batchsizes): + plugin.batchsize = batchsize + logger.info("Reset batch sizes due to available VRAM: %s", text) def _join_threads(self): """ Join threads for current pass """ diff --git a/scripts/extract.py b/scripts/extract.py index eacaed400a..4f5026250a 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -191,7 +191,6 @@ def _run_extraction(self): size = self._args.size if hasattr(self._args, "size") else 256 saver = ImagesSaver(self._output_dir, as_bytes=True) exception = False - phase_desc = "Extraction" for phase in range(self._extractor.passes): if exception: @@ -200,11 +199,10 @@ def _run_extraction(self): detected_faces = dict() self._extractor.launch() self._check_thread_error() - if self._args.singleprocess: - phase_desc = self._extractor.phase.title() + ph_desc = "Extraction" if self._extractor.passes == 1 else self._extractor.phase_text desc = "Running pass {} of {}: {}".format(phase + 1, self._extractor.passes, - phase_desc) + ph_desc) status_bar = tqdm(self._extractor.detected_faces(), total=self._images.process_count, file=sys.stdout, From ab4ef756710a16a318e5f57a1c3ffca8ea28913a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 2 Apr 2020 15:53:29 +0100 Subject: [PATCH 226/981] Extract - Allow multiple pipelines to be loaded --- plugins/extract/_base.py | 16 +++++++----- plugins/extract/align/_base.py | 5 ++-- plugins/extract/detect/_base.py | 5 ++-- plugins/extract/mask/_base.py | 5 ++-- plugins/extract/pipeline.py | 45 ++++++++++++++++++++------------- 5 files changed, 46 insertions(+), 30 deletions(-) diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 5f1df4e71f..005c12e6c0 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -65,7 +65,10 @@ class Extractor(): ---------------- configfile: str, optional Path to a custom configuration ``ini`` file. Default: Use system configfile - + instance: int, optional + If this plugin is being executed multiple times (i.e. multiple pipelines have been + launched), the instance of the plugin must be passed in for naming convention reasons. + Default: 0 The following attributes should be set in the plugin's :func:`__init__` method after initializing the parent. @@ -98,11 +101,12 @@ class Extractor(): plugins.extract.pipeline : The extract pipeline that configures and calls all plugins """ - def __init__(self, git_model_id=None, model_filename=None, configfile=None): - logger.debug("Initializing %s: (git_model_id: %s, model_filename: %s, " - " configfile: %s)", self.__class__.__name__, git_model_id, - model_filename, configfile) + def __init__(self, git_model_id=None, model_filename=None, configfile=None, instance=0): + logger.debug("Initializing %s: (git_model_id: %s, model_filename: %s, instance: %s, " + "configfile: %s, )", self.__class__.__name__, git_model_id, model_filename, + instance, configfile) + self._instance = instance self.config = _get_config(".".join(self.__module__.split(".")[-2:]), configfile=configfile) """ dict: Config for this plugin, loaded from ``extract.ini`` configfile """ @@ -358,7 +362,7 @@ def _add_queues(self, in_queue, out_queue, queues): self._queues["out"] = out_queue for q_name in queues: self._queues[q_name] = queue_manager.get_queue( - name="{}_{}".format(self._plugin_type, q_name), + name="{}{}_{}".format(self._plugin_type, self._instance, q_name), maxsize=self.queue_size) # <<< THREAD METHODS >>> # diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index a0aeb7fa26..e01641872f 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -50,12 +50,13 @@ class Aligner(Extractor): # pylint:disable=abstract-method """ def __init__(self, git_model_id=None, model_filename=None, - configfile=None, normalize_method=None): + configfile=None, instance=0, normalize_method=None): logger.debug("Initializing %s: (normalize_method: %s)", self.__class__.__name__, normalize_method) super().__init__(git_model_id, model_filename, - configfile=configfile) + configfile=configfile, + instance=instance) self._normalize_method = None self.set_normalize_method(normalize_method) diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index 345903ae79..3cf7b3fa5c 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -58,12 +58,13 @@ class Detector(Extractor): # pylint:disable=abstract-method """ def __init__(self, git_model_id=None, model_filename=None, - configfile=None, rotation=None, min_size=0): + configfile=None, instance=0, rotation=None, min_size=0): logger.debug("Initializing %s: (rotation: %s, min_size: %s)", self.__class__.__name__, rotation, min_size) super().__init__(git_model_id, model_filename, - configfile=configfile) + configfile=configfile, + instance=instance) self.rotation = self._get_rotation_angles(rotation) self.min_size = min_size diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 3dd3eced46..9c4cfc9ecc 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -50,11 +50,12 @@ class Masker(Extractor): # pylint:disable=abstract-method """ def __init__(self, git_model_id=None, model_filename=None, configfile=None, - image_is_aligned=False): + instance=0, image_is_aligned=False): logger.debug("Initializing %s: (configfile: %s, )", self.__class__.__name__, configfile) super().__init__(git_model_id, model_filename, - configfile=configfile) + configfile=configfile, + instance=instance) self.input_size = 256 # Override for model specific input_size self.coverage_ratio = 1.0 # Override for model specific coverage_ratio diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 620fe1ecaa..f9e2d2baf3 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -20,6 +20,14 @@ from plugins.plugin_loader import PluginLoader logger = logging.getLogger(__name__) # pylint:disable=invalid-name +_INSTANCES = -1 # Tracking for multiple instances of pipeline + + +def _get_instance(): + """ Increment the global :attr:`_INSTANCES` and obtain the current instance value """ + global _INSTANCES # pylint:disable=global-statement + _INSTANCES += 1 + return _INSTANCES class Extractor(): @@ -73,6 +81,7 @@ def __init__(self, detector, aligner, masker, configfile=None, "normalize_method: %s, image_is_aligned: %s)", self.__class__.__name__, detector, aligner, masker, configfile, multiprocess, rotate_images, min_size, normalize_method, image_is_aligned) + self._instance = _get_instance() masker = [masker] if not isinstance(masker, list) else masker self._flow = self._set_flow(detector, aligner, masker) # We only ever need 1 item in each queue. This is 2 items cached (1 in queue 1 waiting @@ -102,7 +111,7 @@ def input_queue(self): For align/mask (2nd/3rd pass operations) the :attr:`ExtractMedia.detected_faces` should also be populated by calling :func:`ExtractMedia.set_detected_faces`. """ - qname = "extract_{}_in".format(self._current_phase[0]) + qname = "extract{}_{}_in".format(self._instance, self._current_phase[0]) retval = self._queues[qname] logger.trace("%s: %s", qname, retval) return retval @@ -302,9 +311,10 @@ def _final_phase(self): def _output_queue(self): """ Return the correct output queue depending on the current phase """ if self.final_pass: - qname = "extract_{}_out".format(self._final_phase) + qname = "extract{}_{}_out".format(self._instance, self._final_phase) else: - qname = "extract_{}_in".format(self._phases[self._phase_index + 1][0]) + qname = "extract{}_{}_in".format(self._instance, + self._phases[self._phase_index + 1][0]) retval = self._queues[qname] logger.trace("%s: %s", qname, retval) return retval @@ -380,9 +390,8 @@ def _get_plugin_type_and_index(flow_phase): def _add_queues(self): """ Add the required processing queues to Queue Manager """ queues = dict() - tasks = [] - tasks = ["extract_{}_in".format(phase) for phase in self._flow] - tasks.append("extract_{}_out".format(self._final_phase)) + tasks = ["extract{}_{}_in".format(self._instance, phase) for phase in self._flow] + tasks.append("extract{}_{}_out".format(self._instance, self._final_phase)) for task in tasks: # Limit queue size to avoid stacking ram queue_manager.add_queue(task, maxsize=self._queue_size) @@ -488,8 +497,7 @@ def _set_phases(self, multiprocess): return phases # << INTERNAL PLUGIN HANDLING >> # - @staticmethod - def _load_align(aligner, configfile, normalize_method): + def _load_align(self, aligner, configfile, normalize_method): """ Set global arguments and load aligner plugin """ if aligner is None or aligner.lower() == "none": logger.debug("No aligner selected. Returning None") @@ -497,11 +505,11 @@ def _load_align(aligner, configfile, normalize_method): aligner_name = aligner.replace("-", "_").lower() logger.debug("Loading Aligner: '%s'", aligner_name) aligner = PluginLoader.get_aligner(aligner_name)(configfile=configfile, - normalize_method=normalize_method) + normalize_method=normalize_method, + instance=self._instance) return aligner - @staticmethod - def _load_detect(detector, rotation, min_size, configfile): + def _load_detect(self, detector, rotation, min_size, configfile): """ Set global arguments and load detector plugin """ if detector is None or detector.lower() == "none": logger.debug("No detector selected. Returning None") @@ -510,11 +518,11 @@ def _load_detect(detector, rotation, min_size, configfile): logger.debug("Loading Detector: '%s'", detector_name) detector = PluginLoader.get_detector(detector_name)(rotation=rotation, min_size=min_size, - configfile=configfile) + configfile=configfile, + instance=self._instance) return detector - @staticmethod - def _load_mask(masker, image_is_aligned, configfile): + def _load_mask(self, masker, image_is_aligned, configfile): """ Set global arguments and load masker plugin """ if masker is None or masker.lower() == "none": logger.debug("No masker selected. Returning None") @@ -522,18 +530,19 @@ def _load_mask(masker, image_is_aligned, configfile): masker_name = masker.replace("-", "_").lower() logger.debug("Loading Masker: '%s'", masker_name) masker = PluginLoader.get_masker(masker_name)(image_is_aligned=image_is_aligned, - configfile=configfile) + configfile=configfile, + instance=self._instance) return masker def _launch_plugin(self, phase): """ Launch an extraction plugin """ logger.debug("Launching %s plugin", phase) - in_qname = "extract_{}_in".format(phase) + in_qname = "extract{}_{}_in".format(self._instance, phase) if phase == self._final_phase: - out_qname = "extract_{}_out".format(self._final_phase) + out_qname = "extract{}_{}_out".format(self._instance, self._final_phase) else: next_phase = self._flow[self._flow.index(phase) + 1] - out_qname = "extract_{}_in".format(next_phase) + out_qname = "extract{}_{}_in".format(self._instance, next_phase) logger.debug("in_qname: %s, out_qname: %s", in_qname, out_qname) kwargs = dict(in_queue=self._queues[in_qname], out_queue=self._queues[out_qname]) From 8412941a642de336d0fa33e037a5a299cf1f3d7a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 5 Apr 2020 11:43:38 +0100 Subject: [PATCH 227/981] Update Travis Test for Masker --- _travis/simple_tests.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/_travis/simple_tests.py b/_travis/simple_tests.py index 3ea157e0a5..3a3e490894 100644 --- a/_travis/simple_tests.py +++ b/_travis/simple_tests.py @@ -81,11 +81,11 @@ def download_file(url, filename): # TODO: retry return None -def extract_args(detector, aligner, masker, in_path, out_path, args=None): +def extract_args(detector, aligner, in_path, out_path, args=None): """ Extraction command """ py_exe = sys.executable - _extract_args = "%s faceswap.py extract -i %s -o %s -D %s -A %s -M %s" % ( - py_exe, in_path, out_path, detector, aligner, masker + _extract_args = "%s faceswap.py extract -i %s -o %s -D %s -A %s" % ( + py_exe, in_path, out_path, detector, aligner ) if args: _extract_args += " %s" % args @@ -138,19 +138,19 @@ def main(): vid_path = download_file(vid_src, pathjoin(vid_base, "test.mp4")) if not vid_path: print_fail("[-] Aborting") - exit(1) + sys.exit(1) vid_extract = run_test( "Extraction video with cv2-dnn detector and cv2-dnn aligner.", - extract_args("Cv2-Dnn", "Cv2-Dnn", "extended", vid_path, pathjoin(vid_base, "faces")) + extract_args("Cv2-Dnn", "Cv2-Dnn", vid_path, pathjoin(vid_base, "faces")) ) img_path = download_file(img_src, pathjoin(img_base, "test_img.jpg")) if not img_path: print_fail("[-] Aborting") - exit(1) + sys.exit(1) run_test( "Extraction images with cv2-dnn detector and cv2-dnn aligner.", - extract_args("Cv2-Dnn", "Cv2-Dnn", "extended", img_base, pathjoin(img_base, "faces")) + extract_args("Cv2-Dnn", "Cv2-Dnn", img_base, pathjoin(img_base, "faces")) ) if vid_extract: @@ -207,10 +207,10 @@ def main(): if FAIL_COUNT == 0: print_ok("[+] Failed %i/%i tests." % (FAIL_COUNT, TEST_COUNT)) - exit(0) + sys.exit(0) else: print_fail("[-] Failed %i/%i tests." % (FAIL_COUNT, TEST_COUNT)) - exit(1) + sys.exit(1) if __name__ == '__main__': From 8bc00d6dab1525c8d735095e5087081bbe49963e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 6 Apr 2020 15:38:02 +0100 Subject: [PATCH 228/981] Documentation update --- docs/conf.py | 10 ++- docs/full/lib.faces_detect.rst | 7 --- docs/full/lib.gui.custom_widgets.rst | 7 --- docs/full/lib.gui.project.rst | 7 --- docs/full/lib.gui.rst | 8 --- docs/full/lib.gui.utils.rst | 7 --- docs/full/lib.image.rst | 7 --- docs/full/lib.model.rst | 6 -- docs/full/lib.model.session.rst | 7 --- docs/full/lib.rst | 21 ------- docs/full/lib.serializer.rst | 7 --- docs/full/lib.training_data.rst | 7 --- .../alignments.rst} | 4 +- .../full/{lib.convert.rst => lib/convert.rst} | 4 +- docs/full/lib/faces_detect.rst | 21 +++++++ docs/full/lib/gui.rst | 55 ++++++++++++++++ docs/full/lib/image.rst | 20 ++++++ docs/full/lib/lib.rst | 9 +++ docs/full/lib/model.rst | 14 +++++ docs/full/lib/serializer.rst | 15 +++++ docs/full/lib/training_data.rst | 16 +++++ .../vgg_face2_keras.rst} | 4 +- docs/full/modules.rst | 6 +- docs/full/plugins.convert.mask.rst | 27 -------- docs/full/plugins.convert.rst | 9 --- docs/full/plugins.extract._base.rst | 7 --- docs/full/plugins.extract.align._base.rst | 7 --- docs/full/plugins.extract.detect._base.rst | 7 --- docs/full/plugins.extract.mask._base.rst | 7 --- docs/full/plugins.extract.pipeline.rst | 7 --- docs/full/plugins.extract.rst | 10 --- docs/full/plugins.rst | 16 ----- docs/full/plugins.train.rst | 6 -- docs/full/plugins.train.trainer._base.rst | 7 --- docs/full/plugins/convert.rst | 35 +++++++++++ docs/full/plugins/extract.rst | 62 +++++++++++++++++++ .../plugin_loader.rst} | 5 +- docs/full/plugins/plugins.rst | 10 +++ docs/full/plugins/train.rst | 20 ++++++ docs/full/scripts.rst | 41 +++++++++--- docs/full/tools.rst | 24 +++++-- 41 files changed, 349 insertions(+), 227 deletions(-) delete mode 100644 docs/full/lib.faces_detect.rst delete mode 100644 docs/full/lib.gui.custom_widgets.rst delete mode 100644 docs/full/lib.gui.project.rst delete mode 100644 docs/full/lib.gui.rst delete mode 100644 docs/full/lib.gui.utils.rst delete mode 100644 docs/full/lib.image.rst delete mode 100644 docs/full/lib.model.rst delete mode 100644 docs/full/lib.model.session.rst delete mode 100644 docs/full/lib.rst delete mode 100644 docs/full/lib.serializer.rst delete mode 100644 docs/full/lib.training_data.rst rename docs/full/{lib.alignments.rst => lib/alignments.rst} (66%) mode change 100644 => 100755 rename docs/full/{lib.convert.rst => lib/convert.rst} (68%) mode change 100644 => 100755 create mode 100755 docs/full/lib/faces_detect.rst create mode 100755 docs/full/lib/gui.rst create mode 100755 docs/full/lib/image.rst create mode 100644 docs/full/lib/lib.rst create mode 100755 docs/full/lib/model.rst create mode 100755 docs/full/lib/serializer.rst create mode 100755 docs/full/lib/training_data.rst rename docs/full/{lib.vgg_face2_keras.rst => lib/vgg_face2_keras.rst} (61%) mode change 100644 => 100755 delete mode 100644 docs/full/plugins.convert.mask.rst delete mode 100644 docs/full/plugins.convert.rst delete mode 100644 docs/full/plugins.extract._base.rst delete mode 100644 docs/full/plugins.extract.align._base.rst delete mode 100644 docs/full/plugins.extract.detect._base.rst delete mode 100644 docs/full/plugins.extract.mask._base.rst delete mode 100644 docs/full/plugins.extract.pipeline.rst delete mode 100644 docs/full/plugins.extract.rst delete mode 100644 docs/full/plugins.rst delete mode 100644 docs/full/plugins.train.rst delete mode 100644 docs/full/plugins.train.trainer._base.rst create mode 100755 docs/full/plugins/convert.rst create mode 100755 docs/full/plugins/extract.rst rename docs/full/{plugins.plugin_loader.rst => plugins/plugin_loader.rst} (58%) mode change 100644 => 100755 create mode 100644 docs/full/plugins/plugins.rst create mode 100755 docs/full/plugins/train.rst diff --git a/docs/conf.py b/docs/conf.py index 9c7f4804f1..c9c1c23e16 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,7 +30,7 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ['sphinx.ext.napoleon', ] +extensions = ['sphinx.ext.napoleon', "sphinx.ext.autosummary", "sphinx_automodapi.automodapi", ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -53,4 +53,12 @@ # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] +html_context = { + 'css_files': [ + '_static/theme_overrides.css', # override wide tables in RTD theme + ], + } + master_doc = 'index' + +autosummary_generate = True diff --git a/docs/full/lib.faces_detect.rst b/docs/full/lib.faces_detect.rst deleted file mode 100644 index e2469620c1..0000000000 --- a/docs/full/lib.faces_detect.rst +++ /dev/null @@ -1,7 +0,0 @@ -lib.faces\_detect module -======================== - -.. automodule:: lib.faces_detect - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/lib.gui.custom_widgets.rst b/docs/full/lib.gui.custom_widgets.rst deleted file mode 100644 index e67fd0764b..0000000000 --- a/docs/full/lib.gui.custom_widgets.rst +++ /dev/null @@ -1,7 +0,0 @@ -lib.gui.custom\_widgets module -============================== - -.. automodule:: lib.gui.custom_widgets - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/lib.gui.project.rst b/docs/full/lib.gui.project.rst deleted file mode 100644 index 75b99be397..0000000000 --- a/docs/full/lib.gui.project.rst +++ /dev/null @@ -1,7 +0,0 @@ -lib.gui.project module -====================== - -.. automodule:: lib.gui.project - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/lib.gui.rst b/docs/full/lib.gui.rst deleted file mode 100644 index 565b83056a..0000000000 --- a/docs/full/lib.gui.rst +++ /dev/null @@ -1,8 +0,0 @@ -lib.gui package -=============== - -.. toctree:: - - lib.gui.custom_widgets - lib.gui.project - lib.gui.utils diff --git a/docs/full/lib.gui.utils.rst b/docs/full/lib.gui.utils.rst deleted file mode 100644 index b3904a36f2..0000000000 --- a/docs/full/lib.gui.utils.rst +++ /dev/null @@ -1,7 +0,0 @@ -lib.gui.utils module -==================== - -.. automodule:: lib.gui.utils - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/lib.image.rst b/docs/full/lib.image.rst deleted file mode 100644 index a9f0e86c38..0000000000 --- a/docs/full/lib.image.rst +++ /dev/null @@ -1,7 +0,0 @@ -lib.image module -================ - -.. automodule:: lib.image - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/lib.model.rst b/docs/full/lib.model.rst deleted file mode 100644 index c2c18da812..0000000000 --- a/docs/full/lib.model.rst +++ /dev/null @@ -1,6 +0,0 @@ -lib.model package -================= - -.. toctree:: - - lib.model.session diff --git a/docs/full/lib.model.session.rst b/docs/full/lib.model.session.rst deleted file mode 100644 index e80025ad18..0000000000 --- a/docs/full/lib.model.session.rst +++ /dev/null @@ -1,7 +0,0 @@ -lib.model.session module -======================== - -.. automodule:: lib.model.session - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/lib.rst b/docs/full/lib.rst deleted file mode 100644 index 88d2e4b6c8..0000000000 --- a/docs/full/lib.rst +++ /dev/null @@ -1,21 +0,0 @@ -lib package -=========== - -.. toctree:: - - lib.alignments - lib.convert - lib.faces_detect - lib.image - lib.serializer - lib.training_data - lib.vgg_face2_keras - - -Subpackages ------------ - -.. toctree:: - - lib.gui - lib.model diff --git a/docs/full/lib.serializer.rst b/docs/full/lib.serializer.rst deleted file mode 100644 index 19c2bcc44b..0000000000 --- a/docs/full/lib.serializer.rst +++ /dev/null @@ -1,7 +0,0 @@ -lib.serializer module -========================= - -.. automodule:: lib.serializer - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/lib.training_data.rst b/docs/full/lib.training_data.rst deleted file mode 100644 index 6865234bb1..0000000000 --- a/docs/full/lib.training_data.rst +++ /dev/null @@ -1,7 +0,0 @@ -lib.training\_data module -========================= - -.. automodule:: lib.training_data - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/lib.alignments.rst b/docs/full/lib/alignments.rst old mode 100644 new mode 100755 similarity index 66% rename from docs/full/lib.alignments.rst rename to docs/full/lib/alignments.rst index ed994b4e07..015ddfa085 --- a/docs/full/lib.alignments.rst +++ b/docs/full/lib/alignments.rst @@ -1,5 +1,5 @@ -lib.alignments module -===================== +alignments module +================= .. automodule:: lib.alignments :members: diff --git a/docs/full/lib.convert.rst b/docs/full/lib/convert.rst old mode 100644 new mode 100755 similarity index 68% rename from docs/full/lib.convert.rst rename to docs/full/lib/convert.rst index b5f5c90524..ca6add4efb --- a/docs/full/lib.convert.rst +++ b/docs/full/lib/convert.rst @@ -1,5 +1,5 @@ -lib.convert module -================== +convert module +============== .. automodule:: lib.convert :members: diff --git a/docs/full/lib/faces_detect.rst b/docs/full/lib/faces_detect.rst new file mode 100755 index 0000000000..ab1e1fbe39 --- /dev/null +++ b/docs/full/lib/faces_detect.rst @@ -0,0 +1,21 @@ +******************** +faces\_detect module +******************** + +Handles detected and aligned faces objects and their associated masks. + +.. contents:: Contents + :local: + +Module Summary +============== +.. automodsumm:: lib.faces_detect + :classes-only: + :skip: AlignerExtract + +Module +====== +.. automodule:: lib.faces_detect + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib/gui.rst b/docs/full/lib/gui.rst new file mode 100755 index 0000000000..c84521402f --- /dev/null +++ b/docs/full/lib/gui.rst @@ -0,0 +1,55 @@ +*********** +gui package +*********** + +The GUI Package contains the entire code base for Faceswap's optional GUI. The GUI itself itself +is largely self-generated from the command line options specified in :mod:`lib.cli`. + +.. contents:: Contents + :depth: 1 + :local: + +gui.custom\_widgets module +========================== +Module Summary +-------------- +.. automodsumm:: lib.gui.custom_widgets + :classes-only: + :skip: TclError + +Module +------ +.. automodule:: lib.gui.custom_widgets + :members: + :undoc-members: + :show-inheritance: + +gui.project module +================== +Module Summary +-------------- +.. automodsumm:: lib.gui.project + :classes-only: + +Module +------ +.. automodule:: lib.gui.project + :members: + :undoc-members: + :show-inheritance: + +gui.utils module +================ +Module Summary +-------------- +.. automodsumm:: lib.gui.utils + :skip: Event, Thread, Project, Tasks, UserConfig, PATHCACHE, Queue, logger + +Module +------ +.. automodule:: lib.gui.utils + :members: + :undoc-members: + :show-inheritance: + + diff --git a/docs/full/lib/image.rst b/docs/full/lib/image.rst new file mode 100755 index 0000000000..53be975b7a --- /dev/null +++ b/docs/full/lib/image.rst @@ -0,0 +1,20 @@ +************ +image module +************ + +Handles loading and manipulation of images in Faceswap. + +.. contents:: Contents + :local: + +Module Summary +============== +.. automodsumm:: lib.image + :skip: FaceswapError, MultiThread, QueueEmpty, bisect, logger, queue_manager, get_image_paths, tqdm, sha1, convert_to_secs + +Module +====== +.. automodule:: lib.image + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib/lib.rst b/docs/full/lib/lib.rst new file mode 100644 index 0000000000..09dffd99c8 --- /dev/null +++ b/docs/full/lib/lib.rst @@ -0,0 +1,9 @@ +lib package +=========== + +The lib package holds core functionality used throughout Faceswap. + +.. toctree:: + :glob: + + * diff --git a/docs/full/lib/model.rst b/docs/full/lib/model.rst new file mode 100755 index 0000000000..baa60cd3a0 --- /dev/null +++ b/docs/full/lib/model.rst @@ -0,0 +1,14 @@ +model package +============= + +.. contents:: Contents + :local: + +model.session module +-------------------- + +.. automodule:: lib.model.session + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/full/lib/serializer.rst b/docs/full/lib/serializer.rst new file mode 100755 index 0000000000..00711525cc --- /dev/null +++ b/docs/full/lib/serializer.rst @@ -0,0 +1,15 @@ +***************** +serializer module +***************** + +Module Summary +============== +.. automodsumm:: lib.serializer + :skip: FaceswapError, BytesIO, logger + +Module +====== +.. automodule:: lib.serializer + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib/training_data.rst b/docs/full/lib/training_data.rst new file mode 100755 index 0000000000..fa697e05d8 --- /dev/null +++ b/docs/full/lib/training_data.rst @@ -0,0 +1,16 @@ +********************* +training\_data module +********************* + +Module Summary +============== +.. automodsumm:: lib.training_data + :classes-only: + :skip: FaceswapError, BackgroundGenerator + +Module +====== +.. automodule:: lib.training_data + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib.vgg_face2_keras.rst b/docs/full/lib/vgg_face2_keras.rst old mode 100644 new mode 100755 similarity index 61% rename from docs/full/lib.vgg_face2_keras.rst rename to docs/full/lib/vgg_face2_keras.rst index eeb608820c..adb6acedcb --- a/docs/full/lib.vgg_face2_keras.rst +++ b/docs/full/lib/vgg_face2_keras.rst @@ -1,5 +1,5 @@ -lib.vgg\_face2\_keras module -============================ +vgg\_face2\_keras module +======================== .. automodule:: lib.vgg_face2_keras :members: diff --git a/docs/full/modules.rst b/docs/full/modules.rst index 0d40e863aa..d5eefe9039 100644 --- a/docs/full/modules.rst +++ b/docs/full/modules.rst @@ -2,9 +2,9 @@ faceswap ======== .. toctree:: - :maxdepth: 4 + :maxdepth: 3 - lib - plugins + lib/lib + plugins/plugins scripts tools diff --git a/docs/full/plugins.convert.mask.rst b/docs/full/plugins.convert.mask.rst deleted file mode 100644 index b6bc41dfad..0000000000 --- a/docs/full/plugins.convert.mask.rst +++ /dev/null @@ -1,27 +0,0 @@ -plugins.convert.mask package -============================ - -plugins.convert.mask._base module ---------------------------------- - -.. automodule:: plugins.convert.mask._base - :members: - :undoc-members: - :show-inheritance: - -plugins.convert.mask.box_blend module -------------------------------------- - -.. automodule:: plugins.convert.mask.box_blend - :members: - :undoc-members: - :show-inheritance: - -plugins.convert.mask.mask_blend module --------------------------------------- - -.. automodule:: plugins.convert.mask.mask_blend - :members: - :undoc-members: - :show-inheritance: - diff --git a/docs/full/plugins.convert.rst b/docs/full/plugins.convert.rst deleted file mode 100644 index bfdb120249..0000000000 --- a/docs/full/plugins.convert.rst +++ /dev/null @@ -1,9 +0,0 @@ -plugins.convert package -======================= - -Subpackages ------------ - -.. toctree:: - - plugins.convert.mask diff --git a/docs/full/plugins.extract._base.rst b/docs/full/plugins.extract._base.rst deleted file mode 100644 index 131a5dd717..0000000000 --- a/docs/full/plugins.extract._base.rst +++ /dev/null @@ -1,7 +0,0 @@ -plugins.extract._base module -============================ - -.. automodule:: plugins.extract._base - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/plugins.extract.align._base.rst b/docs/full/plugins.extract.align._base.rst deleted file mode 100644 index b8ce7b5976..0000000000 --- a/docs/full/plugins.extract.align._base.rst +++ /dev/null @@ -1,7 +0,0 @@ -plugins.extract.align._base module -====================================== - -.. automodule:: plugins.extract.align._base - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/plugins.extract.detect._base.rst b/docs/full/plugins.extract.detect._base.rst deleted file mode 100644 index d89e4e321c..0000000000 --- a/docs/full/plugins.extract.detect._base.rst +++ /dev/null @@ -1,7 +0,0 @@ -plugins.extract.detect._base module -=================================== - -.. automodule:: plugins.extract.detect._base - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/plugins.extract.mask._base.rst b/docs/full/plugins.extract.mask._base.rst deleted file mode 100644 index ee9487e65e..0000000000 --- a/docs/full/plugins.extract.mask._base.rst +++ /dev/null @@ -1,7 +0,0 @@ -plugins.extract.mask._base module -====================================== - -.. automodule:: plugins.extract.mask._base - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/plugins.extract.pipeline.rst b/docs/full/plugins.extract.pipeline.rst deleted file mode 100644 index f36acf820d..0000000000 --- a/docs/full/plugins.extract.pipeline.rst +++ /dev/null @@ -1,7 +0,0 @@ -plugins.extract.pipeline module -=============================== - -.. automodule:: plugins.extract.pipeline - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/plugins.extract.rst b/docs/full/plugins.extract.rst deleted file mode 100644 index d150f9ff71..0000000000 --- a/docs/full/plugins.extract.rst +++ /dev/null @@ -1,10 +0,0 @@ -plugins.extract package -======================= - -.. toctree:: - - plugins.extract._base - plugins.extract.align._base - plugins.extract.detect._base - plugins.extract.mask._base - plugins.extract.pipeline diff --git a/docs/full/plugins.rst b/docs/full/plugins.rst deleted file mode 100644 index d07e762132..0000000000 --- a/docs/full/plugins.rst +++ /dev/null @@ -1,16 +0,0 @@ -plugins package -=============== - -.. toctree:: - - plugins.plugin_loader - -Subpackages ------------ - -.. toctree:: - - plugins.extract - plugins.plugin_loader - plugins.train - plugins.convert diff --git a/docs/full/plugins.train.rst b/docs/full/plugins.train.rst deleted file mode 100644 index 5be6a76f0d..0000000000 --- a/docs/full/plugins.train.rst +++ /dev/null @@ -1,6 +0,0 @@ -plugins.train package -===================== - -.. toctree:: - - plugins.train.trainer._base diff --git a/docs/full/plugins.train.trainer._base.rst b/docs/full/plugins.train.trainer._base.rst deleted file mode 100644 index 188f869bec..0000000000 --- a/docs/full/plugins.train.trainer._base.rst +++ /dev/null @@ -1,7 +0,0 @@ -plugins.train.trainer._base module -================================== - -.. automodule:: plugins.train.trainer._base - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/plugins/convert.rst b/docs/full/plugins/convert.rst new file mode 100755 index 0000000000..a6a4719558 --- /dev/null +++ b/docs/full/plugins/convert.rst @@ -0,0 +1,35 @@ +*************** +convert package +*************** + +The Convert Package handles the various plugins available for performing conversion in Faceswap + +.. contents:: Contents + :local: + +mask package +============ + +mask._base module +----------------- + +.. automodule:: plugins.convert.mask._base + :members: + :undoc-members: + :show-inheritance: + +mask.box_blend module +--------------------- + +.. automodule:: plugins.convert.mask.box_blend + :members: + :undoc-members: + :show-inheritance: + +mask.mask_blend module +---------------------- + +.. automodule:: plugins.convert.mask.mask_blend + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/plugins/extract.rst b/docs/full/plugins/extract.rst new file mode 100755 index 0000000000..4fe91f2b8d --- /dev/null +++ b/docs/full/plugins/extract.rst @@ -0,0 +1,62 @@ +*************** +extract package +*************** + +The Extract Package handles the various plugins available for extracting face sets in Faceswap. + +.. contents:: Contents + :depth: 1 + :local: + +pipeline module +=============== +Module Summary +-------------- +.. automodsumm:: plugins.extract.pipeline + :classes-only: + :skip: GPUStats, PluginLoader, QueueEmpty + +Module +------ +.. automodule:: plugins.extract.pipeline + :members: + :undoc-members: + :show-inheritance: + +extract plugins package +======================= + +.. contents:: Contents + :local: + +_base module +------------ + +.. automodule:: plugins.extract._base + :members: + :undoc-members: + :show-inheritance: + +detect._base module +------------------- + +.. automodule:: plugins.extract.detect._base + :members: + :undoc-members: + :show-inheritance: + +align._base module +------------------ + +.. automodule:: plugins.extract.align._base + :members: + :undoc-members: + :show-inheritance: + +mask._base module +----------------- + +.. automodule:: plugins.extract.mask._base + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/full/plugins.plugin_loader.rst b/docs/full/plugins/plugin_loader.rst old mode 100644 new mode 100755 similarity index 58% rename from docs/full/plugins.plugin_loader.rst rename to docs/full/plugins/plugin_loader.rst index dce7024d00..677ab6cfaa --- a/docs/full/plugins.plugin_loader.rst +++ b/docs/full/plugins/plugin_loader.rst @@ -1,5 +1,6 @@ -plugins.plugin\_loader module -============================= +********************* +plugin\_loader module +********************* .. automodule:: plugins.plugin_loader :members: diff --git a/docs/full/plugins/plugins.rst b/docs/full/plugins/plugins.rst new file mode 100644 index 0000000000..5313aa8f1c --- /dev/null +++ b/docs/full/plugins/plugins.rst @@ -0,0 +1,10 @@ +plugins package +=============== + +The plugins package holds Extraction, Training and Conversion plugins for Faceswap. + +.. toctree:: + :glob: + + * + diff --git a/docs/full/plugins/train.rst b/docs/full/plugins/train.rst new file mode 100755 index 0000000000..a8974af05d --- /dev/null +++ b/docs/full/plugins/train.rst @@ -0,0 +1,20 @@ +************* +train package +************* + +The Train Package handles the Model and Trainer plugins for training models in Faceswap. + +trainer._base module +==================== +Module Summary +-------------- +.. automodsumm:: plugins.train.trainer._base + :classes-only: + :skip: Alignments, Config, DetectedFace, FaceswapError, TrainingDataGenerator, tqdm + +Module +------ +.. automodule:: plugins.train.trainer._base + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/full/scripts.rst b/docs/full/scripts.rst index 0c4c68c5a9..3a001e3e50 100644 --- a/docs/full/scripts.rst +++ b/docs/full/scripts.rst @@ -1,29 +1,52 @@ +*************** scripts package -=============== +*************** -scripts.extract module ----------------------- +The Scripts Package is the entry point into Faceswap. + +.. contents:: Contents + :local: + :depth: 1 + +extract module +============== .. automodule:: scripts.extract :members: :undoc-members: :show-inheritance: -scripts.train module --------------------- +train module +============ .. automodule:: scripts.train :members: :undoc-members: :show-inheritance: -scripts.convert module ----------------------- +convert module +============== + +Module Summary +-------------- +.. automodsumm:: scripts.convert + :classes-only: + :skip: Alignments, Converter, DetectedFace, Event, ExtractMedia, Extractor, FaceswapError, GPUStats, Images, MultiThread, PluginLoader, PostProcess, tqdm + +Module +------ .. automodule:: scripts.convert :members: :undoc-members: :show-inheritance: -scripts.fsmedia module ----------------------- +fsmedia module +============== +Module Summary +-------------- +.. automodsumm:: scripts.fsmedia + :skip: AlignmentsBase, FilterFunc, Path, camel_case_split, count_frames, get_image_paths, logger, read_image + +Module +------ .. automodule:: scripts.fsmedia :members: :undoc-members: diff --git a/docs/full/tools.rst b/docs/full/tools.rst index 1a7e29d7ef..888ba55025 100644 --- a/docs/full/tools.rst +++ b/docs/full/tools.rst @@ -1,17 +1,31 @@ +************* tools package -============= +************* -tools.mask.mask module ----------------------- +The Tools Package provides various tools for working with Faceswap outside of the core functionality. + +.. contents:: Contents + :depth: 1 + :local: + +mask module +=========== .. automodule:: tools.mask.mask :members: :undoc-members: :show-inheritance: -tools.preview.preview module ----------------------------- +preview module +============== +Module Summary +-------------- +.. automodsumm:: tools.preview.preview + :classes-only: + :skip: AlignerExtract, Alignments, Config, ConfigParser, ControlPanel, ControlPanelOption, ConvertArgs, Converter, DetectedFace, Event, FaceswapError, Images, MultiThread, PluginLoader, Predict, Tooltip +Module +------ .. automodule:: tools.preview.preview :members: :undoc-members: From 10115de56b188d6aa60c252c45a89da0f7aa7b09 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 6 Apr 2020 15:50:17 +0100 Subject: [PATCH 229/981] Update .gitignore --- .gitignore | 2 ++ docs/_static/theme_overrides.css | 14 ++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 docs/_static/theme_overrides.css diff --git a/.gitignore b/.gitignore index 692dde593b..7fc677aef8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ * !setup.cfg +!*.css !*.ico !*.inf !*.keep @@ -18,6 +19,7 @@ !.install/windows !docs !docs/full +!docs/_static !config/ !lib/ !lib/* diff --git a/docs/_static/theme_overrides.css b/docs/_static/theme_overrides.css new file mode 100644 index 0000000000..abc9c0fcee --- /dev/null +++ b/docs/_static/theme_overrides.css @@ -0,0 +1,14 @@ +/* override table width restrictions */ +@media screen and (min-width: 767px) { + + .wy-table-responsive table td { + /* !important prevents the common CSS stylesheets from overriding + this as on RTD they are loaded after this stylesheet */ + white-space: normal !important; + } + + .wy-table-responsive { + overflow: visible !important; + } + } + \ No newline at end of file From f126dc49357633e3264a380189b44e68af35ca01 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 8 Apr 2020 11:32:31 +0100 Subject: [PATCH 230/981] Documentation Update --- docs/_static/logo.png | Bin 0 -> 7486 bytes docs/conf.py | 12 +++++- docs/full/lib/faces_detect.rst | 18 ++++---- docs/full/lib/gui.rst | 72 ++++++++++++++++++++------------ docs/full/lib/image.rst | 27 ++++++++---- docs/full/lib/model.rst | 5 +-- docs/full/lib/serializer.rst | 16 ++++--- docs/full/lib/training_data.rst | 16 +++---- docs/full/plugins/extract.rst | 20 +++++---- docs/full/plugins/train.rst | 21 ++++++---- docs/full/scripts.rst | 39 ++++++++++------- docs/full/tools.rst | 28 +++++++++---- lib/gui/custom_widgets.py | 2 +- 13 files changed, 174 insertions(+), 102 deletions(-) create mode 100755 docs/_static/logo.png diff --git a/docs/_static/logo.png b/docs/_static/logo.png new file mode 100755 index 0000000000000000000000000000000000000000..fc26247981c9eb3375b0756327edbf4e4909990f GIT binary patch literal 7486 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 IM6N<$f|ykw7XSbN literal 0 HcmV?d00001 diff --git a/docs/conf.py b/docs/conf.py index c9c1c23e16..bbd311d5ac 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,7 +30,7 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ['sphinx.ext.napoleon', "sphinx.ext.autosummary", "sphinx_automodapi.automodapi", ] +extensions = ['sphinx.ext.napoleon', "sphinx.ext.autosummary", ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -47,6 +47,14 @@ # a list of builtin themes. # html_theme = 'sphinx_rtd_theme' +html_theme_options = { + 'analytics_id': 'UA-145659566-2', + 'logo_only': True, + # Toc options + 'navigation_depth': -1, +} +html_logo = '_static/logo.png' +latext_logo = '_static/logo.png' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -57,7 +65,7 @@ 'css_files': [ '_static/theme_overrides.css', # override wide tables in RTD theme ], - } + } master_doc = 'index' diff --git a/docs/full/lib/faces_detect.rst b/docs/full/lib/faces_detect.rst index ab1e1fbe39..c5b688a7bc 100755 --- a/docs/full/lib/faces_detect.rst +++ b/docs/full/lib/faces_detect.rst @@ -4,17 +4,17 @@ faces\_detect module Handles detected and aligned faces objects and their associated masks. -.. contents:: Contents - :local: +.. rubric:: Module Summary -Module Summary -============== -.. automodsumm:: lib.faces_detect - :classes-only: - :skip: AlignerExtract +.. autosummary:: + :nosignatures: + + ~lib.faces_detect.BlurMask + ~lib.faces_detect.DetectedFace + ~lib.faces_detect.Mask + +.. rubric:: Module -Module -====== .. automodule:: lib.faces_detect :members: :undoc-members: diff --git a/docs/full/lib/gui.rst b/docs/full/lib/gui.rst index c84521402f..60646309a0 100755 --- a/docs/full/lib/gui.rst +++ b/docs/full/lib/gui.rst @@ -6,47 +6,67 @@ The GUI Package contains the entire code base for Faceswap's optional GUI. The G is largely self-generated from the command line options specified in :mod:`lib.cli`. .. contents:: Contents - :depth: 1 :local: -gui.custom\_widgets module -========================== -Module Summary --------------- -.. automodsumm:: lib.gui.custom_widgets - :classes-only: - :skip: TclError +custom\_widgets module +====================== + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.gui.custom_widgets.ConsoleOut + ~lib.gui.custom_widgets.ContextMenu + ~lib.gui.custom_widgets.RightClickMenu + ~lib.gui.custom_widgets.StatusBar + ~lib.gui.custom_widgets.Tooltip + +.. rubric:: Module -Module ------- .. automodule:: lib.gui.custom_widgets :members: :undoc-members: :show-inheritance: -gui.project module -================== -Module Summary --------------- -.. automodsumm:: lib.gui.project - :classes-only: +project module +============== + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.gui.project.LastSession + ~lib.gui.project.Project + ~lib.gui.project.Tasks + +.. rubric:: Module -Module ------- .. automodule:: lib.gui.project :members: :undoc-members: :show-inheritance: -gui.utils module -================ -Module Summary --------------- -.. automodsumm:: lib.gui.utils - :skip: Event, Thread, Project, Tasks, UserConfig, PATHCACHE, Queue, logger +utils module +============ + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.gui.utils.Config + ~lib.gui.utils.FileHandler + ~lib.gui.utils.Images + ~lib.gui.utils.LongRunningTask + ~lib.gui.utils.get_config + ~lib.gui.utils.get_images + ~lib.gui.utils.initialize_config + ~lib.gui.utils.initialize_images + +.. rubric:: Module -Module ------- .. automodule:: lib.gui.utils :members: :undoc-members: diff --git a/docs/full/lib/image.rst b/docs/full/lib/image.rst index 53be975b7a..2f94c773f6 100755 --- a/docs/full/lib/image.rst +++ b/docs/full/lib/image.rst @@ -4,16 +4,27 @@ image module Handles loading and manipulation of images in Faceswap. -.. contents:: Contents - :local: +.. rubric:: Module Summary -Module Summary -============== -.. automodsumm:: lib.image - :skip: FaceswapError, MultiThread, QueueEmpty, bisect, logger, queue_manager, get_image_paths, tqdm, sha1, convert_to_secs +.. autosummary:: + :nosignatures: + + ~lib.image.FacesLoader + ~lib.image.FfmpegReader + ~lib.image.ImageIO + ~lib.image.ImagesLoader + ~lib.image.ImagesSaver + ~lib.image.SingleFrameLoader + ~lib.image.batch_convert_color + ~lib.image.count_frames + ~lib.image.encode_image_with_hash + ~lib.image.read_image + ~lib.image.read_image_batch + ~lib.image.read_image_hash + ~lib.image.read_image_hash_batch + +.. rubric:: Module -Module -====== .. automodule:: lib.image :members: :undoc-members: diff --git a/docs/full/lib/model.rst b/docs/full/lib/model.rst index baa60cd3a0..b5cf9d43de 100755 --- a/docs/full/lib/model.rst +++ b/docs/full/lib/model.rst @@ -1,11 +1,8 @@ model package ============= -.. contents:: Contents - :local: - model.session module --------------------- +--------------------- .. automodule:: lib.model.session :members: diff --git a/docs/full/lib/serializer.rst b/docs/full/lib/serializer.rst index 00711525cc..50370c19f9 100755 --- a/docs/full/lib/serializer.rst +++ b/docs/full/lib/serializer.rst @@ -2,13 +2,17 @@ serializer module ***************** -Module Summary -============== -.. automodsumm:: lib.serializer - :skip: FaceswapError, BytesIO, logger +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.serializer.Serializer + ~lib.serializer.get_serializer + ~lib.serializer.get_serializer_from_filename + +.. rubric:: Module -Module -====== .. automodule:: lib.serializer :members: :undoc-members: diff --git a/docs/full/lib/training_data.rst b/docs/full/lib/training_data.rst index fa697e05d8..5aa266e863 100755 --- a/docs/full/lib/training_data.rst +++ b/docs/full/lib/training_data.rst @@ -2,14 +2,16 @@ training\_data module ********************* -Module Summary -============== -.. automodsumm:: lib.training_data - :classes-only: - :skip: FaceswapError, BackgroundGenerator +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.training_data.ImageAugmentation + ~lib.training_data.TrainingDataGenerator + +.. rubric:: Module -Module -====== .. automodule:: lib.training_data :members: :undoc-members: diff --git a/docs/full/plugins/extract.rst b/docs/full/plugins/extract.rst index 4fe91f2b8d..103b9e7f26 100755 --- a/docs/full/plugins/extract.rst +++ b/docs/full/plugins/extract.rst @@ -5,19 +5,21 @@ extract package The Extract Package handles the various plugins available for extracting face sets in Faceswap. .. contents:: Contents - :depth: 1 :local: pipeline module =============== -Module Summary --------------- -.. automodsumm:: plugins.extract.pipeline - :classes-only: - :skip: GPUStats, PluginLoader, QueueEmpty - -Module ------- + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~plugins.extract.pipeline.ExtractMedia + ~plugins.extract.pipeline.Extractor + +.. rubric:: Module + .. automodule:: plugins.extract.pipeline :members: :undoc-members: diff --git a/docs/full/plugins/train.rst b/docs/full/plugins/train.rst index a8974af05d..fc4c87138d 100755 --- a/docs/full/plugins/train.rst +++ b/docs/full/plugins/train.rst @@ -6,14 +6,21 @@ The Train Package handles the Model and Trainer plugins for training models in F trainer._base module ==================== -Module Summary --------------- -.. automodsumm:: plugins.train.trainer._base - :classes-only: - :skip: Alignments, Config, DetectedFace, FaceswapError, TrainingDataGenerator, tqdm -Module ------- +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~plugins.train.trainer._base.Batcher + ~plugins.train.trainer._base.PingPong + ~plugins.train.trainer._base.Samples + ~plugins.train.trainer._base.Timelapse + ~plugins.train.trainer._base.TrainerBase + ~plugins.train.trainer._base.TrainingAlignments + +.. rubric:: Module + .. automodule:: plugins.train.trainer._base :members: :undoc-members: diff --git a/docs/full/scripts.rst b/docs/full/scripts.rst index 3a001e3e50..eb8871b4ca 100644 --- a/docs/full/scripts.rst +++ b/docs/full/scripts.rst @@ -6,7 +6,6 @@ The Scripts Package is the entry point into Faceswap. .. contents:: Contents :local: - :depth: 1 extract module ============== @@ -25,14 +24,18 @@ train module convert module ============== -Module Summary --------------- -.. automodsumm:: scripts.convert - :classes-only: - :skip: Alignments, Converter, DetectedFace, Event, ExtractMedia, Extractor, FaceswapError, GPUStats, Images, MultiThread, PluginLoader, PostProcess, tqdm +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~scripts.convert.Convert + ~scripts.convert.DiskIO + ~scripts.convert.OptionalActions + ~scripts.convert.Predict + +.. rubric:: Module -Module ------- .. automodule:: scripts.convert :members: :undoc-members: @@ -40,13 +43,21 @@ Module fsmedia module ============== -Module Summary --------------- -.. automodsumm:: scripts.fsmedia - :skip: AlignmentsBase, FilterFunc, Path, camel_case_split, count_frames, get_image_paths, logger, read_image -Module ------- +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~scripts.fsmedia.Alignments + ~scripts.fsmedia.DebugLandmarks + ~scripts.fsmedia.FaceFilter + ~scripts.fsmedia.Images + ~scripts.fsmedia.PostProcess + ~scripts.fsmedia.finalize + +.. rubric:: Module + .. automodule:: scripts.fsmedia :members: :undoc-members: diff --git a/docs/full/tools.rst b/docs/full/tools.rst index 888ba55025..9c8928437a 100644 --- a/docs/full/tools.rst +++ b/docs/full/tools.rst @@ -5,7 +5,6 @@ tools package The Tools Package provides various tools for working with Faceswap outside of the core functionality. .. contents:: Contents - :depth: 1 :local: mask module @@ -18,14 +17,25 @@ mask module preview module ============== -Module Summary --------------- -.. automodsumm:: tools.preview.preview - :classes-only: - :skip: AlignerExtract, Alignments, Config, ConfigParser, ControlPanel, ControlPanelOption, ConvertArgs, Converter, DetectedFace, Event, FaceswapError, Images, MultiThread, PluginLoader, Predict, Tooltip - -Module ------- + +.. rubric:: Module Summary + + +.. autosummary:: + :nosignatures: + + ~tools.preview.preview.ActionFrame + ~tools.preview.preview.ConfigFrame + ~tools.preview.preview.ConfigTools + ~tools.preview.preview.FacesDisplay + ~tools.preview.preview.ImagesCanvas + ~tools.preview.preview.OptionsBook + ~tools.preview.preview.Patch + ~tools.preview.preview.Preview + ~tools.preview.preview.Samples + +.. rubric:: Module + .. automodule:: tools.preview.preview :members: :undoc-members: diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index b40b6fdcc0..a5d6b7fef4 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -422,7 +422,7 @@ class StatusBar(ttk.Frame): # pylint: disable=too-many-ancestors """ def __init__(self, parent, hide_status=False): - ttk.Frame.__init__(self, parent) + super().__init__(parent) self.pack(side=tk.BOTTOM, padx=10, pady=2, fill=tk.X, expand=False) self._message = tk.StringVar() From 9461c597736ad6ac419a392edeca9221aa53e238 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 9 Apr 2020 14:33:29 +0100 Subject: [PATCH 231/981] Bugfixes and minor updates: - Remove preview option from effmpeg tool - Remove json filetypes from GUI for alignments files - Capture and raise empty timelapse folder errors - Move convert to use centralized ImagesLoader --- lib/gui/utils.py | 2 +- scripts/convert.py | 28 ++++++++++++------------ scripts/train.py | 32 +++++++++++++++------------- tools/effmpeg/cli.py | 29 ------------------------- tools/effmpeg/effmpeg.py | 46 +++++++++------------------------------- 5 files changed, 41 insertions(+), 96 deletions(-) diff --git a/lib/gui/utils.py b/lib/gui/utils.py index e26d1514d6..e9dad561ee 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -140,7 +140,7 @@ def _filetypes(self): """ dict: The accepted extensions for each file type for opening/saving """ all_files = ("All files", "*.*") filetypes = {"default": (all_files,), - "alignments": [("Faceswap Alignments", "*.fsa *.json"), + "alignments": [("Faceswap Alignments", "*.fsa"), all_files], "config_project": [("Faceswap Project files", "*.fsw"), all_files], "config_task": [("Faceswap Task files", "*.fst"), all_files], diff --git a/scripts/convert.py b/scripts/convert.py index b9cbdff397..23da2a0942 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -14,12 +14,12 @@ from keras.backend.tensorflow_backend import set_session from tqdm import tqdm -from scripts.fsmedia import Alignments, Images, PostProcess, finalize +from scripts.fsmedia import Alignments, PostProcess, finalize from lib.serializer import get_serializer from lib.convert import Converter from lib.faces_detect import DetectedFace from lib.gpu_stats import GPUStats -from lib.image import read_image_hash +from lib.image import read_image_hash, ImagesLoader from lib.multithreading import MultiThread, total_cpus from lib.queue_manager import queue_manager from lib.utils import FaceswapError, get_folder, get_image_paths @@ -52,10 +52,10 @@ def __init__(self, arguments): self._args = arguments self._patch_threads = None - self._images = Images(self._args) + self._images = ImagesLoader(self._args.input_dir, fast_count=True) self._alignments = Alignments(self._args, False, self._images.is_video) - self._opts = OptionalActions(self._args, self._images.input_images, self._alignments) + self._opts = OptionalActions(self._args, self._images.file_list, self._alignments) self._add_queues() self._disk_io = DiskIO(self._alignments, self._images, arguments) @@ -90,9 +90,9 @@ def _pool_processes(self): if self._args.singleprocess: retval = 1 elif self._args.jobs > 0: - retval = min(self._args.jobs, total_cpus(), self._images.images_found) + retval = min(self._args.jobs, total_cpus(), self._images.count) else: - retval = min(total_cpus(), self._images.images_found) + retval = min(total_cpus(), self._images.count) retval = 1 if retval == 0 else retval logger.debug(retval) return retval @@ -158,7 +158,7 @@ def process(self): self._disk_io.save_thread.join() queue_manager.terminate_queues() - finalize(self._images.images_found, + finalize(self._images.count, self._predictor.faces_count, self._predictor.verify_output) logger.debug("Completed Conversion") @@ -215,7 +215,7 @@ class DiskIO(): ---------- alignments: :class:`lib.alignmnents.Alignments` The alignments for the input video - images: :class:`scripts.fsmedia.Images` + images: :class:`lib.image.ImagesLoader` The input images arguments: :class:`argparse.Namespace` The arguments that were passed to the convert process as generated from Faceswap's command @@ -288,7 +288,7 @@ def _total_count(self): if self._frame_ranges and not self._args.keep_unchanged: retval = sum([fr[1] - fr[0] + 1 for fr in self._frame_ranges]) else: - retval = self._images.images_found + retval = self._images.count logger.debug(retval) return retval @@ -331,10 +331,10 @@ def _get_frame_ranges(self): minframe, maxframe = None, None if self._images.is_video: - minframe, maxframe = 1, self._images.images_found + minframe, maxframe = 1, self._images.count else: indices = [int(self._imageidxre.findall(os.path.basename(filename))[0]) - for filename in self._images.input_images] + for filename in self._images.file_list] if indices: minframe, maxframe = min(indices), max(indices) logger.debug("minframe: %s, maxframe: %s", minframe, maxframe) @@ -594,9 +594,7 @@ def _detect_faces(self, filename, image): """ self._extractor.input_queue.put(ExtractMedia(filename, image)) faces = next(self._extractor.detected_faces()) - - final_faces = [face for face in faces.detected_faces] - return final_faces + return faces.detected_faces # Saving tasks def _save(self, completion_event): @@ -1082,7 +1080,7 @@ def _get_face_hashes(self): logger.warning("Aligned directory not found. All faces listed in the " "alignments file will be converted") else: - file_list = [path for path in get_image_paths(input_aligned_dir)] + file_list = get_image_paths(input_aligned_dir) logger.info("Getting Face Hashes for selected Aligned Images") for face in tqdm(file_list, desc="Hashing Faces"): face_hashes.append(read_image_hash(face)) diff --git a/scripts/train.py b/scripts/train.py index 947dccbf7a..7976d43567 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -15,7 +15,8 @@ from lib.image import read_image from lib.keypress import KBHit from lib.multithreading import MultiThread -from lib.utils import get_folder, get_image_paths, deprecation_warning +from lib.utils import (get_folder, get_image_paths, deprecation_warning, FaceswapError, + _image_extensions) from plugins.plugin_loader import PluginLoader logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -85,21 +86,22 @@ def _set_timelapse(self): not self._args.timelapse_input_b and not self._args.timelapse_output): return None - if not self._args.timelapse_input_a or not self._args.timelapse_input_b: - raise ValueError("To enable the timelapse, you have to supply " - "all the parameters (--timelapse-input-A and " - "--timelapse-input-B).") - - timelapse_output = None - if self._args.timelapse_output is not None: - timelapse_output = str(get_folder(self._args.timelapse_output)) - - for folder in (self._args.timelapse_input_a, - self._args.timelapse_input_b, - timelapse_output): - if folder is not None and not os.path.isdir(folder): - raise ValueError("The Timelapse path '{}' does not exist".format(folder)) + if (not self._args.timelapse_input_a or + not self._args.timelapse_input_b or + not self._args.timelapse_output): + raise FaceswapError("To enable the timelapse, you have to supply all the parameters " + "(--timelapse-input-A, --timelapse-input-B and " + "--timelapse-output).") + timelapse_output = str(get_folder(self._args.timelapse_output)) + + for folder in (self._args.timelapse_input_a, self._args.timelapse_input_b): + if folder is not None and not os.path.isdir(folder): + raise FaceswapError("The Timelapse path '{}' does not exist".format(folder)) + exts = [os.path.splitext(fname)[-1] for fname in os.listdir(folder)] + if not any(ext in _image_extensions for ext in exts): + raise FaceswapError("The Timelapse path '{}' does not contain any valid " + "images".format(folder)) kwargs = {"input_a": self._args.timelapse_input_a, "input_b": self._args.timelapse_input_b, "output": timelapse_output} diff --git a/tools/effmpeg/cli.py b/tools/effmpeg/cli.py index bd102821ca..1ff08fc1f9 100644 --- a/tools/effmpeg/cli.py +++ b/tools/effmpeg/cli.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ -from argparse import SUPPRESS - from lib.cli import FaceSwapArgs from lib.cli import ContextFullPaths, FileFullPaths, Radio from lib.utils import _image_extensions @@ -53,7 +51,6 @@ def get_argument_list(self): "\nL|'rotate' rotate video." "\nL|'slice' cuts a portion of the video into a separate " "video file."}) - argument_list.append({"opts": ('-i', '--input'), "action": ContextFullPaths, "dest": "input", @@ -63,7 +60,6 @@ def get_argument_list(self): "required": True, "action_option": "-a", "filetypes": "video"}) - argument_list.append({"opts": ('-o', '--output'), "action": ContextFullPaths, "group": "data", @@ -83,7 +79,6 @@ def get_argument_list(self): "encoding.", "action_option": "-a", "filetypes": "video"}) - argument_list.append({"opts": ('-r', '--reference-video'), "action": FileFullPaths, "dest": "ref_vid", @@ -92,7 +87,6 @@ def get_argument_list(self): "help": "Path to reference video if 'input' " "was not a video.", "filetypes": "video"}) - argument_list.append({"opts": ('-fps', '--fps'), "type": str, "dest": "fps", @@ -103,7 +97,6 @@ def get_argument_list(self): "will make the program try to get the " "fps from the input or reference " "videos."}) - argument_list.append({"opts": ("-ef", "--extract-filetype"), "action": Radio, "choices": _image_extensions, @@ -116,7 +109,6 @@ def get_argument_list(self): "will take the most storage space. " "'.png' will be slower but will take " "less storage."}) - argument_list.append({"opts": ('-s', '--start'), "type": str, "dest": "start", @@ -128,7 +120,6 @@ def get_argument_list(self): "format. You can also enter the time " "with or without the colons, e.g. " "00:0000 or 026010."}) - argument_list.append({"opts": ('-e', '--end'), "type": str, "dest": "end", @@ -140,7 +131,6 @@ def get_argument_list(self): "time will be used and the duration " "will be ignored. " "Default: 00:00:00, in HH:MM:SS."}) - argument_list.append({"opts": ('-d', '--duration'), "type": str, "dest": "duration", @@ -156,7 +146,6 @@ def get_argument_list(self): "format. You can also enter the time " "with or without the colons, e.g. " "00:0000 or 026010."}) - argument_list.append({"opts": ('-m', '--mux-audio'), "action": "store_true", "dest": "mux_audio", @@ -167,7 +156,6 @@ def get_argument_list(self): "option is only used for the 'gen-vid' " "action. 'mux-audio' action has this " "turned on implicitly."}) - argument_list.append( {"opts": ('-tr', '--transpose'), "choices": ("(0, 90CounterClockwise&VerticalFlip)", @@ -184,7 +172,6 @@ def get_argument_list(self): "or the long command name, " "e.g. to use (1, 90Clockwise) " "-tr 1 or -tr 90Clockwise"}) - argument_list.append({"opts": ('-de', '--degrees'), "type": str, "dest": "degrees", @@ -192,7 +179,6 @@ def get_argument_list(self): "group": "rotate", "help": "Rotate the video clockwise by the " "given number of degrees."}) - argument_list.append({"opts": ('-sc', '--scale'), "type": str, "dest": "scale", @@ -200,19 +186,6 @@ def get_argument_list(self): "default": "1920x1080", "help": "Set the new resolution scale if the " "chosen action is 'rescale'."}) - - argument_list.append({"opts": ('-pr', '--preview'), - "action": "store_true", - "dest": "preview", - "default": False, - # TODO Fix preview or remove - "help": SUPPRESS, - # "help": "Uses ffplay to preview the effects of " - # "actions that have a video output. " - # "Currently preview does not work when " - # "muxing audio." - }) - argument_list.append({"opts": ('-q', '--quiet'), "action": "store_true", "dest": "quiet", @@ -222,7 +195,6 @@ def get_argument_list(self): "serious errors are printed. If both " "quiet and verbose are set, verbose " "will override quiet."}) - argument_list.append({"opts": ('-v', '--verbose'), "action": "store_true", "dest": "verbose", @@ -231,5 +203,4 @@ def get_argument_list(self): "help": "Increases output verbosity. If both " "quiet and verbose are set, verbose " "will override quiet."}) - return argument_list diff --git a/tools/effmpeg/effmpeg.py b/tools/effmpeg/effmpeg.py index 32eedbdc1a..025b7d4a31 100644 --- a/tools/effmpeg/effmpeg.py +++ b/tools/effmpeg/effmpeg.py @@ -5,9 +5,6 @@ @author: Lev Velykoivanenko (velykoivanenko.lev@gmail.com) """ -# TODO: integrate preview into gui window -# TODO: add preview support when muxing audio -# -> figure out if ffmpeg | ffplay would work on windows and mac import logging import os import subprocess @@ -128,8 +125,6 @@ class Effmpeg(): _actions_req_fps = ["extract", "gen_vid"] _actions_req_ref_video = ["mux_audio"] - _actions_can_preview = ["gen_vid", "mux_audio", "rescale", "rotate", - "slice"] _actions_can_use_ref_video = ["gen_vid"] _actions_have_dir_output = ["extract"] _actions_have_vid_output = ["gen_vid", "mux_audio", "rescale", "rotate", @@ -269,11 +264,6 @@ def process(self): self.args.degrees) sys.exit(1) - # Set executable based on whether previewing or not - if self.args.preview and self.args.action in self._actions_can_preview: - self.exe = 'ffplay' - self.output = DataItem() - # Set verbosity of output self.__set_verbosity(self.args.quiet, self.args.verbose) @@ -298,7 +288,6 @@ def effmpeg_process(self): "transpose": self.args.transpose, "scale": self.args.scale, "print_": self.print_, - "preview": self.args.preview, "exe": self.exe} action = getattr(self, self.args.action) action(**kwargs) @@ -322,23 +311,18 @@ def extract(input_=None, output=None, fps=None, # pylint:disable=unused-argumen @staticmethod def gen_vid(input_=None, output=None, fps=None, # pylint:disable=unused-argument - mux_audio=False, ref_vid=None, preview=False, exe=None, **kwargs): + mux_audio=False, ref_vid=None, exe=None, **kwargs): """ Generate Video """ - logger.debug("input: %s, output: %s, fps: %s, mux_audio: %s, ref_vid: '%s', preview: %s, " - "exe: '%s'", input, output, fps, mux_audio, ref_vid, preview, exe) + logger.debug("input: %s, output: %s, fps: %s, mux_audio: %s, ref_vid: '%s'exe: '%s'", + input, output, fps, mux_audio, ref_vid, exe) filename = Effmpeg.__get_extracted_filename(input_.path) _input_opts = Effmpeg._common_ffmpeg_args[:] _input_path = os.path.join(input_.path, filename) _fps_arg = '-r ' + str(fps) + ' ' _input_opts += _fps_arg + "-f image2 " - _output_opts = _fps_arg - if not preview: - _output_opts = '-y ' + _output_opts + ' -c:v libx264' + _output_opts = '-y ' + _fps_arg + ' -c:v libx264' if mux_audio: _ref_vid_opts = '-c copy -map 0:0 -map 1:1' - if preview: - raise ValueError("Preview for gen-vid with audio muxing is " - "not supported.") _output_opts = _ref_vid_opts + ' ' + _output_opts _inputs = OrderedDict([(_input_path, _input_opts), (ref_vid.path, None)]) else: @@ -380,19 +364,17 @@ def get_info(input_=None, print_=False, **kwargs): @staticmethod def rescale(input_=None, output=None, scale=None, # pylint:disable=unused-argument - preview=False, exe=None, **kwargs): + exe=None, **kwargs): """ Rescale Video """ _input_opts = Effmpeg._common_ffmpeg_args[:] - _output_opts = '-vf scale="' + str(scale) + '"' - if not preview: - _output_opts = '-y ' + _output_opts + _output_opts = '-y -vf scale="' + str(scale) + '"' _inputs = {input_.path: _input_opts} _outputs = {output.path: _output_opts} Effmpeg.__run_ffmpeg(exe=exe, inputs=_inputs, outputs=_outputs) @staticmethod def rotate(input_=None, output=None, degrees=None, # pylint:disable=unused-argument - transpose=None, preview=None, exe=None, **kwargs): + transpose=None, exe=None, **kwargs): """ Rotate Video """ if transpose is None and degrees is None: raise ValueError("You have not supplied a valid transpose or " @@ -400,9 +382,7 @@ def rotate(input_=None, output=None, degrees=None, # pylint:disable=unused-argu "{}".format(transpose, degrees)) _input_opts = Effmpeg._common_ffmpeg_args[:] - _output_opts = '-vf ' - if not preview: - _output_opts = '-y -c:a copy ' + _output_opts + _output_opts = '-y -c:a copy -vf ' _bilinear = '' if transpose is not None: _output_opts += 'transpose="' + str(transpose) + '"' @@ -418,28 +398,22 @@ def rotate(input_=None, output=None, degrees=None, # pylint:disable=unused-argu @staticmethod def mux_audio(input_=None, output=None, ref_vid=None, # pylint:disable=unused-argument - preview=None, exe=None, **kwargs): + exe=None, **kwargs): """ Mux Audio """ _input_opts = Effmpeg._common_ffmpeg_args[:] _ref_vid_opts = None _output_opts = '-y -c copy -map 0:0 -map 1:1 -shortest' - if preview: - raise ValueError("Preview with audio muxing is not supported.") - # if not preview: - # _output_opts = '-y ' + _output_opts _inputs = OrderedDict([(input_.path, _input_opts), (ref_vid.path, _ref_vid_opts)]) _outputs = {output.path: _output_opts} Effmpeg.__run_ffmpeg(exe=exe, inputs=_inputs, outputs=_outputs) @staticmethod def slice(input_=None, output=None, start=None, # pylint:disable=unused-argument - duration=None, preview=None, exe=None, **kwargs): + duration=None, exe=None, **kwargs): """ Slice Video """ _input_opts = Effmpeg._common_ffmpeg_args[:] _input_opts += "-ss " + start _output_opts = "-t " + duration + " " - if not preview: - _output_opts = '-y ' + _output_opts + "-vcodec copy -acodec copy" _inputs = {input_.path: _input_opts} _output = {output.path: _output_opts} Effmpeg.__run_ffmpeg(exe=exe, inputs=_inputs, outputs=_output) From add55ccb3f65d1c103508d557a0e2005f9a67329 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 15 Apr 2020 11:40:41 +0100 Subject: [PATCH 232/981] - Catch Frame count mismatch when analysing presentation time stamps - Don't output a crash report when a FaceswapError is generated --- lib/alignments.py | 15 +++++++++++++++ lib/cli.py | 3 --- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/alignments.py b/lib/alignments.py index de54b0ecc0..4d071fa752 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -240,6 +240,21 @@ def save_video_meta_data(self, pts_time, keyframes): self.data[key] = dict(video_meta=meta, faces=[]) else: self.data[key]["video_meta"] = meta + logger.debug("Alignments count: %s, timestamp count: %s", len(self.data), len(pts_time)) + if len(self.data) != len(pts_time): + raise FaceswapError( + "There is a mismatch between the number of frames found in the video file ({}) " + "and the number of frames found in the alignments file ({})." + "\nThis can be caused by a number of issues:" + "\n - The video has a Variable Frame Rate and FFMPEG is having a hard time " + "calculating the correct number of frames." + "\n - The video was not cut on a key frame and FFMPEG has dummied in some extra " + "frames to fill the gap." + "\n - You are working with a Merged Alignments file. This is not supported for " + "your current use case." + "\nYou should either extract the video to individual frames, re-encode the " + "video at a constant frame rate and re-run extraction or work with a dedicated " + "alignments file for your requested video.".format(len(pts_time), len(self.data))) self.save() # << VALIDATION >> # diff --git a/lib/cli.py b/lib/cli.py index 4f7ac22c4c..bc1ac30988 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -130,9 +130,6 @@ def execute_script(self, arguments): except FaceswapError as err: for line in str(err).splitlines(): logger.error(line) - crash_file = crash_log() - logger.info("To get more information on this error see the crash report written to " - "'%s'", crash_file) except KeyboardInterrupt: # pylint: disable=try-except-raise raise except SystemExit: From ff8d85118e0a43f359084c6abfd1fd399611cf5b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 22 Apr 2020 00:04:21 +0100 Subject: [PATCH 233/981] Cli Restructure + Multi-Mask Select on Extract (#1012) - Split up cli.py to smaller modules - Enable Multi Mask Selection in Extraction - Handle multi option selection options in the GUI - Document lib/cli --- docs/full/lib/cli.rst | 65 ++ docs/full/lib/gui.rst | 3 +- faceswap.py | 53 +- lib/cli.py | 1309 -------------------------------- lib/cli/__init__.py | 0 lib/cli/actions.py | 367 +++++++++ lib/cli/args.py | 1138 +++++++++++++++++++++++++++ lib/cli/launcher.py | 193 +++++ lib/gui/control_helper.py | 139 +++- lib/gui/custom_widgets.py | 81 ++ lib/gui/options.py | 28 +- plugins/extract/pipeline.py | 7 +- scripts/convert.py | 2 +- scripts/extract.py | 8 +- scripts/train.py | 2 +- tools.py | 2 +- tools/alignments/alignments.py | 2 +- tools/alignments/cli.py | 4 +- tools/effmpeg/cli.py | 4 +- tools/mask/cli.py | 4 +- tools/preview/cli.py | 4 +- tools/preview/preview.py | 2 +- tools/restore/cli.py | 4 +- tools/sort/cli.py | 4 +- 24 files changed, 2014 insertions(+), 1411 deletions(-) create mode 100644 docs/full/lib/cli.rst delete mode 100644 lib/cli.py create mode 100644 lib/cli/__init__.py create mode 100644 lib/cli/actions.py create mode 100644 lib/cli/args.py create mode 100644 lib/cli/launcher.py diff --git a/docs/full/lib/cli.rst b/docs/full/lib/cli.rst new file mode 100644 index 0000000000..2a0d2c79a6 --- /dev/null +++ b/docs/full/lib/cli.rst @@ -0,0 +1,65 @@ +*********** +cli package +*********** + +The CLI Package handles the Command Line Arguments that act as the entry point into Faceswap. + +.. contents:: Contents + :local: + +args module +=========== + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.cli.args.ConvertArgs + ~lib.cli.args.ExtractArgs + ~lib.cli.args.ExtractConvertArgs + ~lib.cli.args.FaceSwapArgs + ~lib.cli.args.FullHelpArgumentParser + ~lib.cli.args.GuiArgs + ~lib.cli.args.SmartFormatter + ~lib.cli.args.TrainArgs + +.. rubric:: Module + +.. automodule:: lib.cli.args + :members: + :undoc-members: + :show-inheritance: + +actions module +============== + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.cli.actions.ContextFullPaths + ~lib.cli.actions.DirFullPaths + ~lib.cli.actions.DirOrFileFullPaths + ~lib.cli.actions.FileFullPaths + ~lib.cli.actions.FilesFullPaths + ~lib.cli.actions.MultiOption + ~lib.cli.actions.Radio + ~lib.cli.actions.SaveFileFullPaths + ~lib.cli.actions.Slider + +.. rubric:: Module + +.. automodule:: lib.cli.actions + :members: + :undoc-members: + :show-inheritance: + +launcher module +=============== + +.. automodule:: lib.cli.launcher + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/full/lib/gui.rst b/docs/full/lib/gui.rst index 60646309a0..8655ca3cd9 100755 --- a/docs/full/lib/gui.rst +++ b/docs/full/lib/gui.rst @@ -3,7 +3,7 @@ gui package *********** The GUI Package contains the entire code base for Faceswap's optional GUI. The GUI itself itself -is largely self-generated from the command line options specified in :mod:`lib.cli`. +is largely self-generated from the command line options specified in :mod:`lib.cli.args`. .. contents:: Contents :local: @@ -18,6 +18,7 @@ custom\_widgets module ~lib.gui.custom_widgets.ConsoleOut ~lib.gui.custom_widgets.ContextMenu + ~lib.gui.custom_widgets.MultiOption ~lib.gui.custom_widgets.RightClickMenu ~lib.gui.custom_widgets.StatusBar ~lib.gui.custom_widgets.Tooltip diff --git a/faceswap.py b/faceswap.py index a426d6ae65..c3d825d35d 100755 --- a/faceswap.py +++ b/faceswap.py @@ -2,7 +2,7 @@ """ The master faceswap.py script """ import sys -import lib.cli as cli +from lib.cli import args from lib.config import generate_configs if sys.version_info[0] < 3: @@ -11,28 +11,37 @@ raise Exception("This program requires at least python3.6") -def bad_args(args): - """ Print help on bad arguments """ - PARSER.print_help() +_PARSER = args.FullHelpArgumentParser() + + +def _bad_args(): + """ Print help to console when bad arguments are provided. """ + _PARSER.print_help() sys.exit(0) -if __name__ == "__main__": +def _main(): + """ The main entry point into Faceswap. + + - Generates the config files, if they don't pre-exist. + - Compiles the :class:`~lib.cli.args.FullHelpArgumentParser` objects for each section of + Faceswap. + - Sets the default values and launches the relevant script. + - Outputs help if invalid parameters are provided. + """ generate_configs() - PARSER = cli.FullHelpArgumentParser() - SUBPARSER = PARSER.add_subparsers() - EXTRACT = cli.ExtractArgs(SUBPARSER, - "extract", - "Extract the faces from pictures") - TRAIN = cli.TrainArgs(SUBPARSER, - "train", - "This command trains the model for the two faces A and B") - CONVERT = cli.ConvertArgs(SUBPARSER, - "convert", - "Convert a source image to a new one with the face swapped") - GUI = cli.GuiArgs(SUBPARSER, - "gui", - "Launch the Faceswap Graphical User Interface") - PARSER.set_defaults(func=bad_args) - ARGUMENTS = PARSER.parse_args() - ARGUMENTS.func(ARGUMENTS) + + subparser = _PARSER.add_subparsers() + args.ExtractArgs(subparser, "extract", "Extract the faces from pictures") + args.TrainArgs(subparser, "train", "This command trains the model for the two faces A and B") + args.ConvertArgs(subparser, + "convert", + "Convert a source image to a new one with the face swapped") + args.GuiArgs(subparser, "gui", "Launch the Faceswap Graphical User Interface") + _PARSER.set_defaults(func=_bad_args) + arguments = _PARSER.parse_args() + arguments.func(arguments) + + +if __name__ == "__main__": + _main() diff --git a/lib/cli.py b/lib/cli.py deleted file mode 100644 index bc1ac30988..0000000000 --- a/lib/cli.py +++ /dev/null @@ -1,1309 +0,0 @@ -#!/usr/bin/env python3 -""" Command Line Arguments """ - -# pylint: disable=too-many-lines - -import argparse -import logging -import os -import platform -import re -import sys -import textwrap - -from importlib import import_module - -from lib.logger import crash_log, log_setup -from lib.utils import FaceswapError, get_backend, safe_shutdown, set_system_verbosity -from plugins.plugin_loader import PluginLoader - -logger = logging.getLogger(__name__) # pylint: disable=invalid-name - - -class ScriptExecutor(): - """ Loads the relevant script modules and executes the script. - This class is initialized in each of the argparsers for the relevant - command, then execute script is called within their set_default - function. """ - - def __init__(self, command, subparsers=None): - self.command = command.lower() - self.subparsers = subparsers - - def import_script(self): - """ Only import a script's modules when running that script.""" - self.test_for_tf_version() - self.test_for_gui() - cmd = os.path.basename(sys.argv[0]) - src = "tools.{}".format(self.command.lower()) if cmd == "tools.py" else "scripts" - mod = ".".join((src, self.command.lower())) - module = import_module(mod) - script = getattr(module, self.command.title()) - return script - - @staticmethod - def test_for_tf_version(): - """ Check that the minimum required Tensorflow version is installed """ - min_ver = 1.12 - max_ver = 1.15 - try: - # Ensure tensorflow doesn't pin all threads to one core when using Math Kernel Library - os.environ["KMP_AFFINITY"] = "disabled" - import tensorflow as tf # pylint:disable=import-outside-toplevel - except ImportError as err: - raise FaceswapError("There was an error importing Tensorflow. This is most likely " - "because you do not have TensorFlow installed, or you are trying " - "to run tensorflow-gpu on a system without an Nvidia graphics " - "card. Original import error: {}".format(str(err))) - tf_ver = float(".".join(tf.__version__.split(".")[:2])) # pylint:disable=no-member - if tf_ver < min_ver: - raise FaceswapError("The minimum supported Tensorflow is version {} but you have " - "version {} installed. Please upgrade Tensorflow.".format( - min_ver, tf_ver)) - if tf_ver > max_ver: - raise FaceswapError("The maximumum supported Tensorflow is version {} but you have " - "version {} installed. Please downgrade Tensorflow.".format( - max_ver, tf_ver)) - logger.debug("Installed Tensorflow Version: %s", tf_ver) - - def test_for_gui(self): - """ If running the gui, check the prerequisites """ - if self.command != "gui": - return - self.test_tkinter() - self.check_display() - - @staticmethod - def test_tkinter(): - """ If the user is running the GUI, test whether the - tkinter app is available on their machine. If not - exit gracefully. - - This avoids having to import every tkinter function - within the GUI in a wrapper and potentially spamming - traceback errors to console """ - - try: - # pylint: disable=unused-variable - import tkinter # noqa pylint: disable=unused-import,import-outside-toplevel - except ImportError: - logger.error( - "It looks like TkInter isn't installed for your OS, so " - "the GUI has been disabled. To enable the GUI please " - "install the TkInter application. You can try:") - logger.info("Anaconda: conda install tk") - logger.info("Windows/macOS: Install ActiveTcl Community Edition from " - "http://www.activestate.com") - logger.info("Ubuntu/Mint/Debian: sudo apt install python3-tk") - logger.info("Arch: sudo pacman -S tk") - logger.info("CentOS/Redhat: sudo yum install tkinter") - logger.info("Fedora: sudo dnf install python3-tkinter") - raise FaceswapError("TkInter not found") - - @staticmethod - def check_display(): - """ Check whether there is a display to output the GUI. If running on - Windows then assume not running in headless mode """ - if not os.environ.get("DISPLAY", None) and os.name != "nt": - if platform.system() == "Darwin": - logger.info("macOS users need to install XQuartz. " - "See https://support.apple.com/en-gb/HT201341") - raise FaceswapError("No display detected. GUI mode has been disabled.") - - def execute_script(self, arguments): - """ Run the script for called command """ - set_system_verbosity(arguments.loglevel) - is_gui = hasattr(arguments, "redirect_gui") and arguments.redirect_gui - log_setup(arguments.loglevel, arguments.logfile, self.command, is_gui) - logger.debug("Executing: %s. PID: %s", self.command, os.getpid()) - success = False - if get_backend() == "amd": - plaidml_found = self.setup_amd(arguments.loglevel) - if not plaidml_found: - safe_shutdown(got_error=True) - return - try: - script = self.import_script() - process = script(arguments) - process.process() - success = True - except FaceswapError as err: - for line in str(err).splitlines(): - logger.error(line) - except KeyboardInterrupt: # pylint: disable=try-except-raise - raise - except SystemExit: - pass - except Exception: # pylint: disable=broad-except - crash_file = crash_log() - logger.exception("Got Exception on main handler:") - logger.critical("An unexpected crash has occurred. Crash report written to '%s'. " - "You MUST provide this file if seeking assistance. Please verify you " - "are running the latest version of faceswap before reporting", - crash_file) - - finally: - safe_shutdown(got_error=not success) - - @staticmethod - def setup_amd(loglevel): - """ Test for plaidml and setup for AMD """ - logger.debug("Setting up for AMD") - try: - import plaidml # noqa pylint:disable=unused-import,import-outside-toplevel - except ImportError: - logger.error("PlaidML not found. Run `pip install plaidml-keras` for AMD support") - return False - from lib.plaidml_tools import setup_plaidml # pylint:disable=import-outside-toplevel - setup_plaidml(loglevel) - logger.debug("setup up for PlaidML") - return True - - -class Radio(argparse.Action): # pylint: disable=too-few-public-methods - """ Adds support for the GUI Radio buttons - - Just a wrapper class to tell the gui to use radio buttons instead of combo boxes - """ - def __init__(self, option_strings, dest, nargs=None, **kwargs): - if nargs is not None: - raise ValueError("nargs not allowed") - super().__init__(option_strings, dest, **kwargs) - - def __call__(self, parser, namespace, values, option_string=None): - setattr(namespace, self.dest, values) - - -class Slider(argparse.Action): # pylint: disable=too-few-public-methods - """ Adds support for the GUI slider - - An additional option 'min_max' must be provided containing tuple of min and max accepted - values. - - 'rounding' sets the decimal places for floats or the step interval for ints. - """ - def __init__(self, option_strings, dest, nargs=None, min_max=None, rounding=None, **kwargs): - if nargs is not None: - raise ValueError("nargs not allowed") - super().__init__(option_strings, dest, **kwargs) - self.min_max = min_max - self.rounding = rounding - - def _get_kwargs(self): - names = ["option_strings", - "dest", - "nargs", - "const", - "default", - "type", - "choices", - "help", - "metavar", - "min_max", # Tuple containing min and max values of scale - "rounding"] # Decimal places to round floats to or step interval for ints - return [(name, getattr(self, name)) for name in names] - - def __call__(self, parser, namespace, values, option_string=None): - setattr(namespace, self.dest, values) - - -class FullPaths(argparse.Action): # pylint: disable=too-few-public-methods - """ Expand user- and relative-paths """ - def __call__(self, parser, namespace, values, option_string=None): - if isinstance(values, (list, tuple)): - vals = [os.path.abspath(os.path.expanduser(val)) for val in values] - else: - vals = os.path.abspath(os.path.expanduser(values)) - setattr(namespace, self.dest, vals) - - -class DirFullPaths(FullPaths): - """ Class that gui uses to determine if you need to open a directory """ - # pylint: disable=too-few-public-methods,unnecessary-pass - pass - - -class FileFullPaths(FullPaths): - """ - Class that gui uses to determine if you need to open a file. - - see lib/gui/utils.py FileHandler for current GUI filetypes - """ - # pylint: disable=too-few-public-methods - def __init__(self, option_strings, dest, nargs=None, filetypes=None, **kwargs): - super().__init__(option_strings, dest, nargs, **kwargs) - self.filetypes = filetypes - - def _get_kwargs(self): - names = ["option_strings", - "dest", - "nargs", - "const", - "default", - "type", - "choices", - "help", - "metavar", - "filetypes"] - return [(name, getattr(self, name)) for name in names] - - -class FilesFullPaths(FileFullPaths): # pylint: disable=too-few-public-methods - """ Class that the gui uses to determine that the input can take multiple files as an input. - Inherits functionality from FileFullPaths - Has the effect of giving the user 2 Open Dialogue buttons in the gui """ - pass # pylint: disable=unnecessary-pass - - -class DirOrFileFullPaths(FileFullPaths): # pylint: disable=too-few-public-methods - """ Class that the gui uses to determine that the input can take a folder or a filename. - Inherits functionality from FileFullPaths - Has the effect of giving the user 2 Open Dialogue buttons in the gui """ - pass # pylint: disable=unnecessary-pass - - -class SaveFileFullPaths(FileFullPaths): - """ - Class that gui uses to determine if you need to save a file. - - see lib/gui/utils.py FileHandler for current GUI filetypes - """ - # pylint: disable=too-few-public-methods,unnecessary-pass - pass - - -class ContextFullPaths(FileFullPaths): - """ - Class that gui uses to determine if you need to open a file or a - directory based on which action you are choosing - - To use ContextFullPaths the action_option item should indicate which - cli option dictates the context of the filesystem dialogue - - Bespoke actions are then set in lib/gui/utils.py FileHandler - """ - # pylint: disable=too-few-public-methods, too-many-arguments - def __init__(self, option_strings, dest, nargs=None, filetypes=None, - action_option=None, **kwargs): - if nargs is not None: - raise ValueError("nargs not allowed") - super(ContextFullPaths, self).__init__(option_strings, dest, - filetypes=None, **kwargs) - self.action_option = action_option - self.filetypes = filetypes - - def _get_kwargs(self): - names = ["option_strings", - "dest", - "nargs", - "const", - "default", - "type", - "choices", - "help", - "metavar", - "filetypes", - "action_option"] - return [(name, getattr(self, name)) for name in names] - - -class FullHelpArgumentParser(argparse.ArgumentParser): - """ Identical to the built-in argument parser, but on error it - prints full help message instead of just usage information """ - def error(self, message): - self.print_help(sys.stderr) - args = {"prog": self.prog, "message": message} - self.exit(2, "%(prog)s: error: %(message)s\n" % args) - - -class SmartFormatter(argparse.HelpFormatter): - """ Smart formatter for allowing raw formatting in help - text and lists in the help text - - To use: prefix the help item with "R|" to override - default formatting. List items can be marked with "L|" - at the start of a newline - - adapted from: https://stackoverflow.com/questions/3853722 """ - - def __init__(self, - prog, - indent_increment=2, - max_help_position=24, - width=None): - - super().__init__(prog, indent_increment, max_help_position, width) - self._whitespace_matcher_limited = re.compile(r'[ \r\f\v]+', re.ASCII) - - def _split_lines(self, text, width): - if text.startswith("R|"): - text = self._whitespace_matcher_limited.sub(' ', text).strip()[2:] - output = list() - for txt in text.splitlines(): - indent = "" - if txt.startswith("L|"): - indent = " " - txt = " - {}".format(txt[2:]) - output.extend(textwrap.wrap(txt, width, subsequent_indent=indent)) - return output - return argparse.HelpFormatter._split_lines(self, text, width) - - -class FaceSwapArgs(): - """ Faceswap argument parser functions that are universal - to all commands. Should be the parent function of all - subsequent argparsers """ - def __init__(self, subparser, command, - description="default", subparsers=None): - - self.global_arguments = self.get_global_arguments() - self.info = self.get_info() - self.argument_list = self.get_argument_list() - self.optional_arguments = self.get_optional_arguments() - self.process_suppressions() - if not subparser: - return - - self.parser = self.create_parser(subparser, command, description) - - self.add_arguments() - - script = ScriptExecutor(command, subparsers) - self.parser.set_defaults(func=script.execute_script) - - @staticmethod - def get_info(): - """ Return command information for display in the GUI. - Override for command specific info """ - return None - - @staticmethod - def get_argument_list(): - """ Put the arguments in a list so that they are accessible from both - argparse and gui override for command specific arguments """ - argument_list = [] - return argument_list - - @staticmethod - def get_optional_arguments(): - """ Put the arguments in a list so that they are accessible from both - argparse and gui. This is used for when there are sub-children - (e.g. convert and extract) Override this for custom arguments """ - argument_list = [] - return argument_list - - @staticmethod - def get_global_arguments(): - """ Arguments that are used in ALL parts of Faceswap - DO NOT override this """ - global_args = list() - global_args.append({ - "opts": ("-C", "--configfile"), - "action": FileFullPaths, - "filetypes": "ini", - "type": str, - "group": "Global Options", - "help": "Optionally overide the saved config with the path to a custom config file."}) - global_args.append({ - "opts": ("-L", "--loglevel"), - "type": str.upper, - "dest": "loglevel", - "default": "INFO", - "choices": ("INFO", "VERBOSE", "DEBUG", "TRACE"), - "group": "Global Options", - "help": "Log level. Stick with INFO or VERBOSE unless you need to file an error " - "report. Be careful with TRACE as it will generate a lot of data"}) - global_args.append({ - "opts": ("-LF", "--logfile"), - "action": SaveFileFullPaths, - "filetypes": 'log', - "type": str, - "dest": "logfile", - "group": "Global Options", - "help": "Path to store the logfile. Leave blank to store in the faceswap folder", - "default": None}) - # This is a hidden argument to indicate that the GUI is being used, - # so the preview window should be redirected Accordingly - global_args.append({ - "opts": ("-gui", "--gui"), - "action": "store_true", - "dest": "redirect_gui", - "default": False, - "help": argparse.SUPPRESS}) - global_args.append({ - "opts": ("-colab", "--colab"), - "action": "store_true", - "dest": "colab", - "default": False, - "help": argparse.SUPPRESS}) - return global_args - - @staticmethod - def create_parser(subparser, command, description): - """ Create the parser for the selected command """ - parser = subparser.add_parser( - command, - help=description, - description=description, - epilog="Questions and feedback: https://faceswap.dev/forum", - formatter_class=SmartFormatter) - return parser - - def add_arguments(self): - """ Parse the arguments passed in from argparse """ - options = self.global_arguments + self.argument_list + self.optional_arguments - for option in options: - args = option["opts"] - kwargs = {key: option[key] - for key in option.keys() if key not in ("opts", "group")} - self.parser.add_argument(*args, **kwargs) - - def process_suppressions(self): - """ Suppress option if it is not available for running backend """ - fs_backend = get_backend() - for opt_list in [self.global_arguments, self.argument_list, self.optional_arguments]: - for opts in opt_list: - if opts.get("backend", None) is None: - continue - opt_backend = opts.pop("backend") - if isinstance(opt_backend, (list, tuple)): - opt_backend = [backend.lower() for backend in opt_backend] - else: - opt_backend = [opt_backend.lower()] - if fs_backend not in opt_backend: - opts["help"] = argparse.SUPPRESS - - -class ExtractConvertArgs(FaceSwapArgs): - """ This class is used as a parent class to capture arguments that - will be used in both the extract and convert process. - - Arguments that can be used in both of these processes should be - placed here, but no further processing should be done. This class - just captures arguments """ - - @staticmethod - def get_argument_list(): - """ Put the arguments in a list so that they are accessible from both - argparse and gui """ - argument_list = list() - argument_list.append({ - "opts": ("-i", "--input-dir"), - "action": DirOrFileFullPaths, - "filetypes": "video", - "dest": "input_dir", - "required": True, - "group": "Data", - "help": "Input directory or video. Either a directory containing the image files you " - "wish to process or path to a video file. NB: This should be the source video/" - "frames NOT the source faces."}) - argument_list.append({ - "opts": ("-o", "--output-dir"), - "action": DirFullPaths, - "dest": "output_dir", - "required": True, - "group": "Data", - "help": "Output directory. This is where the converted files will be saved."}) - argument_list.append({ - "opts": ("-al", "--alignments"), - "action": FileFullPaths, - "filetypes": "alignments", - "type": str, - "dest": "alignments_path", - "group": "Data", - "help": "Optional path to an alignments file. Leave blank if the alignments file is " - "at the default location."}) - return argument_list - - -class ExtractArgs(ExtractConvertArgs): - """ Class to parse the command line arguments for extraction. - Inherits base options from ExtractConvertArgs where arguments - that are used for both extract and convert should be placed """ - - @staticmethod - def get_info(): - """ Return command information """ - return ("Extract faces from image or video sources.\n" - "Extraction plugins can be configured in the 'Settings' Menu") - - @staticmethod - def get_optional_arguments(): - """ Put the arguments in a list so that they are accessible from both - argparse and gui """ - if get_backend() == "cpu": - default_detector = default_aligner = "cv2-dnn" - else: - default_detector = "s3fd" - default_aligner = "fan" - - argument_list = [] - argument_list.append({ - "opts": ("-D", "--detector"), - "action": Radio, - "type": str.lower, - "choices": PluginLoader.get_available_extractors("detect"), - "default": default_detector, - "group": "Plugins", - "help": "R|Detector to use. Some of these have configurable settings in " - "'/config/extract.ini' or 'Settings > Configure Extract 'Plugins':" - "\nL|cv2-dnn: A CPU only extractor which is the least reliable and least " - "resource intensive. Use this if not using a GPU and time is important." - "\nL|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " - "than other GPU detectors but can often return more false positives." - "\nL|s3fd: Best detector. Fast on GPU, slow on CPU. Can detect more faces and " - "fewer false positives than other GPU detectors, but is a lot more resource " - "intensive."}) - argument_list.append({ - "opts": ("-A", "--aligner"), - "action": Radio, - "type": str.lower, - "choices": PluginLoader.get_available_extractors("align"), - "default": default_aligner, - "group": "Plugins", - "help": "R|Aligner to use." - "\nL|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " - "but less accurate. Only use this if not using a GPU and time is important." - "\nL|fan: Best aligner. Fast on GPU, slow on CPU."}) - mask_choices = [mask - for mask in PluginLoader.get_available_extractors("mask", add_none=True) - if mask not in ("components", "extended")] - argument_list.append({ - "opts": ("-M", "--masker"), - "action": Radio, - "type": str.lower, - "choices": mask_choices, - "default": "none", - "group": "Plugins", - "help": "R|Additional Masker to use. NB: The Extended and Components (landmark based) " - "masks are automatically generated on extraction. Any mask selected here " - "will be generated in addition to these default masks." - "\nL|none: Don't use a mask." - "\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." - "\nThe auto generated masks are as follows:" - "\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|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."}) - argument_list.append({ - "opts": ("-nm", "--normalization"), - "action": Radio, - "type": str.lower, - "dest": "normalization", - "choices": ["none", "clahe", "hist", "mean"], - "default": "none", - "group": "plugins", - "help": "R|Performing normalization can help the aligner better align faces with " - "difficult lighting conditions at an extraction speed cost. Different methods " - "will yield different results on different sets. NB: This does not impact the " - "output face, just the input to the aligner." - "\nL|none: Don't perform normalization on the face." - "\nL|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " - "face." - "\nL|hist: Equalize the histograms on the RGB channels." - "\nL|mean: Normalize the face colors to the mean."}) - argument_list.append({ - "opts": ("-r", "--rotate-images"), - "type": str, - "dest": "rotate_images", - "default": None, - "group": "plugins", - "help": "If a face isn't found, rotate the images to try to find a face. Can find " - "more faces at the cost of extraction speed. Pass in a single number to use " - "increments of that size up to 360, or pass in a list of numbers to enumerate " - "exactly what angles to check."}) - argument_list.append({ - "opts": ("-min", "--min-size"), - "type": int, - "action": Slider, - "dest": "min_size", - "min_max": (0, 1080), - "default": 0, - "rounding": 20, - "group": "Face Processing", - "help": "Filters out faces detected below this size. Length, in pixels across the " - "diagonal of the bounding box. Set to 0 for off"}) - argument_list.append({ - "opts": ("-n", "--nfilter"), - "action": FilesFullPaths, - "filetypes": "image", - "dest": "nfilter", - "nargs": "+", - "default": None, - "group": "Face Processing", - "help": "Optionally filter out people who you do not wish to process by passing in an " - "image of that person. Should be a front portrait with a single person in the " - "image. Multiple images can be added space separated. NB: Using face filter " - "will significantly decrease extraction speed and its accuracy cannot be " - "guaranteed."}) - argument_list.append({ - "opts": ("-f", "--filter"), - "action": FilesFullPaths, - "filetypes": "image", - "dest": "filter", - "nargs": "+", - "default": None, - "group": "Face Processing", - "help": "Optionally select people you wish to process by passing in an image of that " - "person. Should be a front portrait with a single person in the image. " - "Multiple images can be added space separated. NB: Using face filter will " - "significantly decrease extraction speed and its accuracy cannot be " - "guaranteed."}) - argument_list.append({ - "opts": ("-l", "--ref_threshold"), - "action": Slider, - "min_max": (0.01, 0.99), - "rounding": 2, - "type": float, - "dest": "ref_threshold", - "default": 0.4, - "group": "Face Processing", - "help": "For use with the optional nfilter/filter files. Threshold for positive face " - "recognition. Lower values are stricter. NB: Using face filter will " - "significantly decrease extraction speed and its accuracy cannot be " - "guaranteed."}) - argument_list.append({ - "opts": ("-een", "--extract-every-n"), - "type": int, - "action": Slider, - "dest": "extract_every_n", - "min_max": (1, 100), - "default": 1, - "rounding": 1, - "group": "output", - "help": "Extract every 'nth' frame. This option will skip frames when extracting " - "faces. For example a value of 1 will extract faces from every frame, a value " - "of 10 will extract faces from every 10th frame."}) - argument_list.append({ - "opts": ("-sz", "--size"), - "type": int, - "action": Slider, - "min_max": (128, 512), - "default": 256, - "rounding": 64, - "group": "output", - "help": "The output size of extracted faces. Make sure that the model you intend to " - "train supports your required size. This will only need to be changed for " - "hi-res models."}) - argument_list.append({ - "opts": ("-si", "--save-interval"), - "dest": "save_interval", - "type": int, - "action": Slider, - "min_max": (0, 1000), - "rounding": 10, - "default": 0, - "group": "output", - "help": "Automatically save the alignments file after a set amount of frames. By " - "default the alignments file is only saved at the end of the extraction " - "process. NB: If extracting in 2 passes then the alignments file will only " - "start to be saved out during the second pass. WARNING: Don't interrupt the " - "script when writing the file because it might get corrupted. Set to 0 to " - "turn off"}) - argument_list.append({ - "opts": ("-dl", "--debug-landmarks"), - "action": "store_true", - "dest": "debug_landmarks", - "group": "output", - "default": False, - "help": "Draw landmarks on the ouput faces for debugging purposes."}) - argument_list.append({ - "opts": ("-sp", "--singleprocess"), - "action": "store_true", - "default": False, - "backend": "nvidia", - "group": "settings", - "help": "Don't run extraction in parallel. Will run each part of the extraction " - "process separately (one after the other) rather than all at the smae time. " - "Useful if VRAM is at a premium."}) - argument_list.append({ - "opts": ("-s", "--skip-existing"), - "action": "store_true", - "dest": "skip_existing", - "group": "settings", - "default": False, - "help": "Skips frames that have already been extracted and exist in the alignments " - "file"}) - argument_list.append({ - "opts": ("-sf", "--skip-existing-faces"), - "action": "store_true", - "dest": "skip_faces", - "group": "settings", - "default": False, - "help": "Skip frames that already have detected faces in the alignments file"}) - return argument_list - - -class ConvertArgs(ExtractConvertArgs): - """ Class to parse the command line arguments for conversion. - Inherits base options from ExtractConvertArgs where arguments - that are used for both extract and convert should be placed """ - - @staticmethod - def get_info(): - """ Return command information """ - return ("Swap the original faces in a source video/images to your final faces.\n" - "Conversion plugins can be configured in the 'Settings' Menu") - - @staticmethod - def get_optional_arguments(): - """ Put the arguments in a list so that they are accessible from both - argparse and gui """ - argument_list = [] - argument_list.append({ - "opts": ("-ref", "--reference-video"), - "action": FileFullPaths, - "dest": "reference_video", - "filetypes": "video", - "type": str, - "group": "data", - "help": "Only required if converting from images to video. Provide The original video " - "that the source frames were extracted from (for extracting the fps and " - "audio)."}) - argument_list.append({ - "opts": ("-m", "--model-dir"), - "action": DirFullPaths, - "dest": "model_dir", - "required": True, - "group": "data", - "help": "Model directory. The directory containing the trained model you wish to use " - "for conversion."}) - argument_list.append({ - "opts": ("-c", "--color-adjustment"), - "action": Radio, - "type": str.lower, - "dest": "color_adjustment", - "choices": PluginLoader.get_available_convert_plugins("color", True), - "default": "avg-color", - "group": "plugins", - "help": "R|Performs color adjustment to the swapped face. Some of these options have " - "configurable settings in '/config/convert.ini' or 'Settings > Configure " - "Convert Plugins':" - "\nL|avg-color: Adjust the mean of each color channel in the swapped " - "reconstruction to equal the mean of the masked area in the original image." - "\nL|color-transfer: Transfers the color distribution from the source to the " - "target image using the mean and standard deviations of the L*a*b* " - "color space." - "\nL|manual-balance: Manually adjust the balance of the image in a variety of " - "color spaces. Best used with the Preview tool to set correct values." - "\nL|match-hist: Adjust the histogram of each color channel in the swapped " - "reconstruction to equal the histogram of the masked area in the original " - "image." - "\nL|seamless-clone: Use cv2's seamless clone function to remove extreme " - "gradients at the mask seam by smoothing colors. Generally does not give " - "very satisfactory results." - "\nL|none: Don't perform color adjustment."}) - argument_list.append({ - "opts": ("-M", "--mask-type"), - "action": Radio, - "dest": "mask_type", - "type": str.lower, - "choices": PluginLoader.get_available_extractors("mask", - add_none=True) + ["predicted"], - "default": "extended", - "group": "Plugins", - "help": "R|Masker to use. NB: The mask you require must exist within the alignments " - "file. You can add additional masks with the Mask Tool." - "\nL|none: Don't use a mask." - "\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|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({ - "opts": ("-sc", "--scaling"), - "action": Radio, - "type": str.lower, - "choices": PluginLoader.get_available_convert_plugins("scaling", True), - "group": "plugins", - "default": "none", - "help": "R|Performs a scaling process to attempt to get better definition on the " - "final swap. Some of these options have configurable settings in " - "'/config/convert.ini' or 'Settings > Configure Convert Plugins':" - "\nL|sharpen: Perform sharpening on the final face." - "\nL|none: Don't perform any scaling operations."}) - argument_list.append({ - "opts": ("-w", "--writer"), - "action": Radio, - "type": str, - "choices": PluginLoader.get_available_convert_plugins("writer", False), - "group": "plugins", - "default": "opencv", - "help": "R|The plugin to use to output the converted images. The writers are " - "configurable in '/config/convert.ini' or 'Settings > Configure Convert " - "Plugins:'" - "\nL|ffmpeg: [video] Writes out the convert straight to video. When the input " - "is a series of images then the '-ref' (--reference-video) parameter must be " - "set." - "\nL|gif: [animated image] Create an animated gif." - "\nL|opencv: [images] The fastest image writer, but less options and formats " - "than other plugins." - "\nL|pillow: [images] Slower than opencv, but has more options and supports " - "more formats."}) - argument_list.append({ - "opts": ("-osc", "--output-scale"), - "dest": "output_scale", - "action": Slider, - "type": int, - "default": 100, - "min_max": (25, 400), - "rounding": 1, - "group": "Frame Processing", - "help": "Scale the final output frames by this amount. 100%% will output the frames " - "at source dimensions. 50%% at half size 200%% at double size"}) - argument_list.append({ - "opts": ("-fr", "--frame-ranges"), - "nargs": "+", - "type": str, - "group": "Frame Processing", - "help": "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " - "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " - "will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " - "converting from images, then the filenames must end with the frame-number!"}) - argument_list.append({ - "opts": ("-a", "--input-aligned-dir"), - "action": DirFullPaths, - "dest": "input_aligned_dir", - "group": "Face Processing", - "default": None, - "help": "If you have not cleansed your alignments file, then you can filter out faces " - "by defining a folder here that contains the faces extracted from your input " - "files/video. If this folder is defined, then only faces that exist within " - "your alignments file and also exist within the specified folder will be " - "converted. Leaving this blank will convert all faces that exist within the " - "alignments file."}) - argument_list.append({ - "opts": ("-n", "--nfilter"), - "action": FilesFullPaths, - "filetypes": "image", - "dest": "nfilter", - "nargs": "+", - "default": None, - "group": "Face Processing", - "help": "Optionally filter out people who you do not wish to process by passing in an " - "image of that person. Should be a front portrait with a single person in the " - "image. Multiple images can be added space separated. NB: Using face filter " - "will significantly decrease extraction speed and its accuracy cannot be " - "guaranteed."}) - argument_list.append({ - "opts": ("-f", "--filter"), - "action": FilesFullPaths, - "filetypes": "image", - "dest": "filter", - "nargs": "+", - "default": None, - "group": "Face Processing", - "help": "Optionally select people you wish to process by passing in an image of that " - "person. Should be a front portrait with a single person in the image. " - "Multiple images can be added space separated. NB: Using face filter will " - "significantly decrease extraction speed and its accuracy cannot be " - "guaranteed."}) - argument_list.append({ - "opts": ("-l", "--ref_threshold"), - "action": Slider, - "min_max": (0.01, 0.99), - "rounding": 2, - "type": float, - "dest": "ref_threshold", - "default": 0.4, - "group": "Face Processing", - "help": "For use with the optional nfilter/filter files. Threshold for positive face " - "recognition. Lower values are stricter. NB: Using face filter will " - "significantly decrease extraction speed and its accuracy cannot be " - "guaranteed."}) - argument_list.append({ - "opts": ("-j", "--jobs"), - "dest": "jobs", - "action": Slider, - "group": "settings", - "type": int, - "default": 0, - "min_max": (0, 40), - "rounding": 1, - "help": "The maximum number of parallel processes for performing conversion. " - "Converting images is system RAM heavy so it is possible to run out of memory " - "if you have a lot of processes and not enough RAM to accommodate them all. " - "Setting this to 0 will use the maximum available. No matter what you set " - "this to, it will never attempt to use more processes than are available on " - "your system. If singleprocess is enabled this setting will be ignored."}) - argument_list.append({ - "opts": ("-g", "--gpus"), - "type": int, - "backend": "nvidia", - "action": Slider, - "min_max": (1, 10), - "rounding": 1, - "group": "settings", - "default": 1, - "help": "Number of GPUs to use for conversion"}) - argument_list.append({ - "opts": ("-t", "--trainer"), - "type": str.lower, - "choices": PluginLoader.get_available_models(), - "group": "settings", - "help": "[LEGACY] This only needs to be selected if a legacy model is being loaded or " - "if there are multiple models in the model folder"}) - argument_list.append({ - "opts": ("-ag", "--allow-growth"), - "action": "store_true", - "dest": "allow_growth", - "group": "settings", - "default": False, - "backend": "nvidia", - "help": "Sets allow_growth option of Tensorflow to spare memory on some " - "configurations."}) - argument_list.append({ - "opts": ("-otf", "--on-the-fly"), - "action": "store_true", - "dest": "on_the_fly", - "group": "settings", - "default": False, - "help": "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " - "alignments file for your destination video. However, if you wish you can " - "generate the alignments on-the-fly by enabling this option. This will use " - "an inferior extraction pipeline and will lead to substandard results. If an " - "alignments file is found, this option will be ignored."}) - argument_list.append({ - "opts": ("-k", "--keep-unchanged"), - "action": "store_true", - "dest": "keep_unchanged", - "group": "Frame Processing", - "default": False, - "help": "When used with --frame-ranges outputs the unchanged frames that are not " - "processed instead of discarding them."}) - argument_list.append({ - "opts": ("-s", "--swap-model"), - "action": "store_true", - "dest": "swap_model", - "group": "settings", - "default": False, - "help": "Swap the model. Instead converting from of A -> B, converts B -> A"}) - argument_list.append({ - "opts": ("-sp", "--singleprocess"), - "action": "store_true", - "group": "settings", - "default": False, - "help": "Disable multiprocessing. Slower but less resource intensive."}) - return argument_list - - -class TrainArgs(FaceSwapArgs): - """ Class to parse the command line arguments for training """ - - @staticmethod - def get_info(): - """ Return command information """ - return ("Train a model on extracted original (A) and swap (B) faces.\n" - "Training models can take a long time. Anything from 24hrs to over a week\n" - "Model plugins can be configured in the 'Settings' Menu") - - @staticmethod - def get_argument_list(): - """ Put the arguments in a list so that they are accessible from both - argparse and gui """ - argument_list = list() - argument_list.append({"opts": ("-A", "--input-A"), - "action": DirFullPaths, - "dest": "input_a", - "required": True, - "group": "faces", - "help": "Input directory. A directory containing training images " - "for face A. This is the original face, i.e. the face that " - "you want to remove and replace with face B."}) - argument_list.append({"opts": ("-ala", "--alignments-A"), - "action": FileFullPaths, - "filetypes": 'alignments', - "type": str, - "dest": "alignments_path_a", - "default": None, - "group": "faces", - "help": "Path to alignments file for training set A. Only required " - "if you are using a masked model or warp-to-landmarks is " - "enabled. Defaults to /alignments.json if not " - "provided."}) - argument_list.append({"opts": ("-B", "--input-B"), - "action": DirFullPaths, - "dest": "input_b", - "required": True, - "group": "faces", - "help": "Input directory. A directory containing training images " - "for face B. This is the swap face, i.e. the face that " - "you want to place onto the head of person A."}) - argument_list.append({"opts": ("-alb", "--alignments-B"), - "action": FileFullPaths, - "filetypes": 'alignments', - "type": str, - "dest": "alignments_path_b", - "default": None, - "group": "faces", - "help": "Path to alignments file for training set B. Only required " - "if you are using a masked model or warp-to-landmarks is " - "enabled. Defaults to /alignments.json if not " - "provided."}) - argument_list.append({"opts": ("-m", "--model-dir"), - "action": DirFullPaths, - "dest": "model_dir", - "required": True, - "group": "model", - "help": "Model directory. This is where the training data will be " - "stored. You should always specify a new folder for new " - "models. If starting a new model, select either an empty " - "folder, or a folder which does not exist (which will be " - "created). If continuing to train an existing model, " - "specify the location of the existing model."}) - argument_list.append({"opts": ("-t", "--trainer"), - "action": Radio, - "type": str.lower, - "choices": PluginLoader.get_available_models(), - "default": PluginLoader.get_default_model(), - "group": "model", - "help": "R|Select which trainer to use. Trainers can be " - "configured from the Settings menu or the config folder." - "\nL|original: The original model created by /u/deepfakes." - "\nL|dfaker: 64px in/128px out model from dfaker. " - "Enable 'warp-to-landmarks' for full dfaker method." - "\nL|dfl-h128. 128px in/out model from deepfacelab" - "\nL|dfl-sae. Adaptable model from deepfacelab" - "\nL|dlight. A lightweight, high resolution DFaker variant." - "\nL|iae: A model that uses intermediate layers to try to " - "get better details" - "\nL|lightweight: A lightweight model for low-end cards. " - "Don't expect great results. Can train as low as 1.6GB " - "with batch size 8." - "\nL|realface: A high detail, dual density model based on " - "DFaker, with customizable in/out resolution. The " - "autoencoders are unbalanced so B>A swaps won't work " - "so well. By andenixa et al. Very configurable." - "\nL|unbalanced: 128px in/out model from andenixa. The " - "autoencoders are unbalanced so B>A swaps won't work so " - "well. Very configurable." - "\nL|villain: 128px in/out model from villainguy. Very " - "resource hungry (11GB for batchsize 16). Good for " - "details, but more susceptible to color differences."}) - argument_list.append({"opts": ("-bs", "--batch-size"), - "type": int, - "action": Slider, - "min_max": (2, 256), - "rounding": 2, - "dest": "batch_size", - "default": 64, - "group": "training", - "help": "Batch size. This is the number of images processed through " - "the model for each iteration. Larger batches require more " - "GPU RAM."}) - argument_list.append({"opts": ("-it", "--iterations"), - "type": int, - "action": Slider, - "min_max": (0, 5000000), - "rounding": 20000, - "default": 1000000, - "group": "training", - "help": "Length of training in iterations. This is only really used " - "for automation. There is no 'correct' number of iterations " - "a model should be trained for. You should stop training " - "when you are happy with the previews. However, if you want " - "the model to stop automatically at a set number of " - "iterations, you can set that value here."}) - argument_list.append({"opts": ("-g", "--gpus"), - "type": int, - "backend": "nvidia", - "action": Slider, - "min_max": (1, 10), - "rounding": 1, - "group": "training", - "default": 1, - "help": "Number of GPUs to use for training"}) - argument_list.append({"opts": ("-msg", "--memory-saving-gradients"), - "action": "store_true", - "dest": "memory_saving_gradients", - "group": "VRAM Savings", - "default": False, - "backend": "nvidia", - "help": "Trades off VRAM usage against computation time. Can fit " - "larger models into memory at a cost of slower training " - "speed. 50%%-150%% batch size increase for 20%%-50%% longer " - "training time. NB: Launch time will be significantly " - "delayed. Switching sides using ping-pong training will " - "take longer."}) - argument_list.append({"opts": ("-o", "--optimizer-savings"), - "dest": "optimizer_savings", - "action": "store_true", - "default": False, - "group": "VRAM Savings", - "backend": "nvidia", - "help": "To save VRAM some optimizer gradient calculations can be " - "performed on the CPU rather than the GPU. This allows you " - "to increase batchsize at a training speed/system RAM " - "cost."}) - argument_list.append({"opts": ("-pp", "--ping-pong"), - "action": "store_true", - "dest": "pingpong", - "group": "VRAM Savings", - "default": False, - "backend": "nvidia", - "help": "Enable ping pong training. Trains one side at a time, " - "switching sides at each save iteration. Training will " - "take 2 to 4 times longer, with about a 30%%-50%% reduction " - "in VRAM useage. NB: Preview won't show until both sides " - "have been trained once."}) - argument_list.append({"opts": ("-s", "--save-interval"), - "type": int, - "action": Slider, - "min_max": (10, 1000), - "rounding": 10, - "dest": "save_interval", - "group": "Saving", - "default": 100, - "help": "Sets the number of iterations between each model save."}) - argument_list.append({"opts": ("-ss", "--snapshot-interval"), - "type": int, - "action": Slider, - "min_max": (0, 100000), - "rounding": 5000, - "dest": "snapshot_interval", - "group": "Saving", - "default": 25000, - "help": "Sets the number of iterations before saving a backup " - "snapshot of the model in it's current state. Set to 0 for " - "off."}) - argument_list.append({"opts": ("-tia", "--timelapse-input-A"), - "action": DirFullPaths, - "dest": "timelapse_input_a", - "default": None, - "group": "timelapse", - "help": "Optional for creating a timelapse. Timelapse will save an " - "image of your selected faces into the timelapse-output " - "folder at every save iteration. This should be the " - "input folder of 'A' faces that you would like to use for " - "creating the timelapse. You must also supply a " - "--timelapse-output and a --timelapse-input-B parameter."}) - argument_list.append({"opts": ("-tib", "--timelapse-input-B"), - "action": DirFullPaths, - "dest": "timelapse_input_b", - "default": None, - "group": "timelapse", - "help": "Optional for creating a timelapse. Timelapse will save an " - "image of your selected faces into the timelapse-output " - "folder at every save iteration. This should be the " - "input folder of 'B' faces that you would like to use for " - "creating the timelapse. You must also supply a " - "--timelapse-output and a --timelapse-input-A parameter."}) - argument_list.append({"opts": ("-to", "--timelapse-output"), - "action": DirFullPaths, - "dest": "timelapse_output", - "default": None, - "group": "timelapse", - "help": "Optional for creating a timelapse. Timelapse will save an " - "image of your selected faces into the timelapse-output " - "folder at every save iteration. If the input folders are " - "supplied but no output folder, it will default to your " - "model folder /timelapse/"}) - argument_list.append({"opts": ("-ps", "--preview-scale"), - "type": int, - "action": Slider, - "dest": "preview_scale", - "min_max": (25, 200), - "group": "preview", - "rounding": 25, - "default": 50, - "help": "Percentage amount to scale the preview by."}) - argument_list.append({"opts": ("-p", "--preview"), - "action": "store_true", - "dest": "preview", - "group": "preview", - "default": False, - "help": "Show training preview output. in a separate window."}) - argument_list.append({"opts": ("-w", "--write-image"), - "action": "store_true", - "dest": "write_image", - "group": "preview", - "default": False, - "help": "Writes the training result to a file. The image will be " - "stored in the root of your FaceSwap folder."}) - argument_list.append({"opts": ("-ag", "--allow-growth"), - "action": "store_true", - "dest": "allow_growth", - "group": "model", - "default": False, - "backend": "nvidia", - "help": "Sets allow_growth option of Tensorflow to spare memory " - "on some configurations."}) - argument_list.append({"opts": ("-nl", "--no-logs"), - "action": "store_true", - "dest": "no_logs", - "group": "training", - "default": False, - "help": "Disables TensorBoard logging. NB: Disabling logs means " - "that you will not be able to use the graph or analysis " - "for this session in the GUI."}) - argument_list.append({"opts": ("-wl", "--warp-to-landmarks"), - "action": "store_true", - "dest": "warp_to_landmarks", - "group": "training", - "default": False, - "help": "Warps training faces to closely matched Landmarks from the " - "opposite face-set rather than randomly warping the face. " - "This is the 'dfaker' way of doing warping. Alignments " - "files for both sets of faces must be provided if using " - "this option."}) - argument_list.append({"opts": ("-nf", "--no-flip"), - "action": "store_true", - "dest": "no_flip", - "group": "training", - "default": False, - "help": "To effectively learn, a random set of images are flipped " - "horizontally. Sometimes it is desirable for this not to " - "occur. Generally this should be left off except for " - "during 'fit training'."}) - argument_list.append({"opts": ("-nac", "--no-augment-color"), - "action": "store_true", - "dest": "no_augment_color", - "group": "training", - "default": False, - "help": "Color augmentation helps make the model less susceptible " - "to color differences between the A and B sets, at an " - "increased training time cost. Enable this option to " - "disable color augmentation."}) - return argument_list - - -class GuiArgs(FaceSwapArgs): - """ Class to parse the command line arguments for training """ - - @staticmethod - def get_argument_list(): - """ Put the arguments in a list so that they are accessible from both - argparse and gui """ - argument_list = [] - argument_list.append({ - "opts": ("-d", "--debug"), - "action": "store_true", - "dest": "debug", - "default": False, - "help": "Output to Shell console instead of GUI console"}) - return argument_list diff --git a/lib/cli/__init__.py b/lib/cli/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/cli/actions.py b/lib/cli/actions.py new file mode 100644 index 0000000000..c5599d2b04 --- /dev/null +++ b/lib/cli/actions.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +""" Custom :class:`argparse.Action` objects for Faceswap's Command Line Interface. + +The custom actions within this module allow for custom manipulation of Command Line Arguments +as well as adding a mechanism for indicating to the GUI how specific options should be rendered. +""" + +import argparse +import os + + +# << FILE HANDLING >> + +class _FullPaths(argparse.Action): # pylint: disable=too-few-public-methods + """ Parent class for various file type and file path handling classes. + + Expands out given paths to their full absolute paths. This class should not be + called directly. It is the base class for the various different file handling + methods. + """ + def __call__(self, parser, namespace, values, option_string=None): + if isinstance(values, (list, tuple)): + vals = [os.path.abspath(os.path.expanduser(val)) for val in values] + else: + vals = os.path.abspath(os.path.expanduser(values)) + setattr(namespace, self.dest, vals) + + +class DirFullPaths(_FullPaths): + """ Adds support for a Directory browser in the GUI. + + This is a standard :class:`argparse.Action` (with stock parameters) which indicates to the GUI + that a dialog box should be opened in order to browse for a folder. + + No additional parameters are required. + + Example + ------- + >>> argument_list = [] + >>> argument_list.append(dict( + >>> opts=("-f", "--folder_location"), + >>> action=DirFullPaths)), + """ + # pylint: disable=too-few-public-methods,unnecessary-pass + pass + + +class FileFullPaths(_FullPaths): + """ Adds support for a File browser to select a single file in the GUI. + + This extends the standard :class:`argparse.Action` and adds an additional parameter + :attr:`filetypes`, indicating to the GUI that it should pop a file browser for opening a file + and limit the results to the file types listed. As well as the standard parameters, the + following parameter is required: + + Parameters + ---------- + filetypes: str + The accepted file types for this option. This is the key for the GUIs lookup table which + can be found in :class:`lib.gui.utils.FileHandler` + + Example + ------- + >>> argument_list = [] + >>> argument_list.append(dict( + >>> opts=("-f", "--video_location"), + >>> action=FileFullPaths, + >>> filetypes="video))" + """ + # pylint: disable=too-few-public-methods + def __init__(self, *args, filetypes=None, **kwargs): + super().__init__(*args, **kwargs) + self.filetypes = filetypes + + def _get_kwargs(self): + names = ["option_strings", + "dest", + "nargs", + "const", + "default", + "type", + "choices", + "help", + "metavar", + "filetypes"] + return [(name, getattr(self, name)) for name in names] + + +class FilesFullPaths(FileFullPaths): # pylint: disable=too-few-public-methods + """ Adds support for a File browser to select multiple files in the GUI. + + This extends the standard :class:`argparse.Action` and adds an additional parameter + :attr:`filetypes`, indicating to the GUI that it should pop a file browser, and limit + the results to the file types listed. Multiple files can be selected for opening, so the + :attr:`nargs` parameter must be set. As well as the standard parameters, the following + parameter is required: + + Parameters + ---------- + filetypes: str + The accepted file types for this option. This is the key for the GUIs lookup table which + can be found in :class:`lib.gui.utils.FileHandler` + + Example + ------- + >>> argument_list = [] + >>> argument_list.append(dict( + >>> opts=("-f", "--images"), + >>> action=FilesFullPaths, + >>> filetypes="image", + >>> nargs="+")) + """ + def __init__(self, *args, filetypes=None, **kwargs): + if kwargs.get("nargs", None) is None: + opt = kwargs["option_strings"] + raise ValueError("nargs must be provided for FilesFullPaths: {}".format(opt)) + super().__init__(*args, **kwargs) + + +class DirOrFileFullPaths(FileFullPaths): # pylint: disable=too-few-public-methods + """ Adds support to the GUI to launch either a file browser or a folder browser. + + Some inputs (for example source frames) can come from a folder of images or from a + video file. This indicates to the GUI that it should place 2 buttons (one for a folder + browser, one for a file browser) for file/folder browsing. + + The standard :class:`argparse.Action` is extended with the additional parameter + :attr:`filetypes`, indicating to the GUI that it should pop a file browser, and limit + the results to the file types listed. As well as the standard parameters, the following + parameter is required: + + Parameters + ---------- + filetypes: str + The accepted file types for this option. This is the key for the GUIs lookup table which + can be found in :class:`lib.gui.utils.FileHandler`. NB: This parameter is only used for + the file browser and not the folder browser + + Example + ------- + >>> argument_list = [] + >>> argument_list.append(dict( + >>> opts=("-f", "--input_frames"), + >>> action=DirOrFileFullPaths, + >>> filetypes="video))" + """ + pass # pylint: disable=unnecessary-pass + + +class SaveFileFullPaths(FileFullPaths): + """ Adds support for a Save File dialog in the GUI. + + This extends the standard :class:`argparse.Action` and adds an additional parameter + :attr:`filetypes`, indicating to the GUI that it should pop a save file browser, and limit + the results to the file types listed. As well as the standard parameters, the following + parameter is required: + + Parameters + ---------- + filetypes: str + The accepted file types for this option. This is the key for the GUIs lookup table which + can be found in :class:`lib.gui.utils.FileHandler` + + Example + ------- + >>> argument_list = [] + >>> argument_list.append(dict( + >>> opts=("-f", "--video_out"), + >>> action=SaveFileFullPaths, + >>> filetypes="video")) + """ + # pylint: disable=too-few-public-methods,unnecessary-pass + pass + + +class ContextFullPaths(FileFullPaths): + """ Adds support for context sensitive browser dialog opening in the GUI. + + For some tasks, the type of action (file load, folder open, file save etc.) can vary + depending on the task to be performed (a good example of this is the effmpeg tool). + Using this action indicates to the GUI that the type of dialog to be launched can change + depending on another option. As well as the standard parameters, the below parameters are + required. NB: :attr:`nargs` are explicitly disallowed. + + Parameters + ---------- + filetypes: str + The accepted file types for this option. This is the key for the GUIs lookup table which + can be found in :class:`lib.gui.utils.FileHandler` + action_option: str + The command line option that dictates the context of the file dialog to be opened. + Bespoke actions are set in :class:`lib.gui.utils.FileHandler` + + Example + ------- + Assuming an argument has already been set with option string `-a` indicating the action to be + performed, the following will pop a different type of dialog depending on the action selected: + + >>> argument_list = [] + >>> argument_list.append(dict( + >>> opts=("-f", "--input_video"), + >>> action=ContextFullPaths, + >>> filetypes="video", + >>> action_option="-a")) + """ + # pylint: disable=too-few-public-methods, too-many-arguments + def __init__(self, *args, filetypes=None, action_option=None, **kwargs): + opt = kwargs["option_strings"] + if kwargs.get("nargs", None) is not None: + raise ValueError("nargs not allowed for ContextFullPaths: {}".format(opt)) + if filetypes is None: + raise ValueError("filetypes is required for ContextFullPaths: {}".format(opt)) + if action_option is None: + raise ValueError("action_option is required for ContextFullPaths: {}".format(opt)) + super().__init__(*args, filetypes=filetypes, **kwargs) + self.action_option = action_option + + def _get_kwargs(self): + names = ["option_strings", + "dest", + "nargs", + "const", + "default", + "type", + "choices", + "help", + "metavar", + "filetypes", + "action_option"] + return [(name, getattr(self, name)) for name in names] + + +# << GUI DISPLAY OBJECTS >> + +class Radio(argparse.Action): # pylint: disable=too-few-public-methods + """ Adds support for a GUI Radio options box. + + This is a standard :class:`argparse.Action` (with stock parameters) which indicates to the GUI + that the options passed should be rendered as a group of Radio Buttons rather than a combo box. + + No additional parameters are required, but the :attr:`choices` parameter must be provided as + these will be the Radio Box options. :attr:`nargs` are explicitly disallowed. + + Example + ------- + >>> argument_list = [] + >>> argument_list.append(dict( + >>> opts=("-f", "--foobar"), + >>> action=Radio, + >>> choices=["foo", "bar")) + """ + def __init__(self, *args, **kwargs): + opt = kwargs["option_strings"] + if kwargs.get("nargs", None) is not None: + raise ValueError("nargs not allowed for Radio buttons: {}".format(opt)) + if not kwargs.get("choices", []): + raise ValueError("Choices must be provided for Radio buttons: {}".format(opt)) + super().__init__(*args, **kwargs) + + def __call__(self, parser, namespace, values, option_string=None): + setattr(namespace, self.dest, values) + + +class MultiOption(argparse.Action): # pylint: disable=too-few-public-methods + """ Adds support for multiple option checkboxes in the GUI. + + This is a standard :class:`argparse.Action` (with stock parameters) which indicates to the GUI + that the options passed should be rendered as a group of Radio Buttons rather than a combo box. + + The :attr:`choices` parameter must be provided as this provides the valid option choices. + + Example + ------- + >>> argument_list = [] + >>> argument_list.append(dict( + >>> opts=("-f", "--foobar"), + >>> action=MultiOption, + >>> choices=["foo", "bar")) + """ + def __init__(self, *args, **kwargs): + opt = kwargs["option_strings"] + if not kwargs.get("nargs", []): + raise ValueError("nargs must be provided for MultiOption: {}".format(opt)) + if not kwargs.get("choices", []): + raise ValueError("Choices must be provided for MultiOption: {}".format(opt)) + super().__init__(*args, **kwargs) + + def __call__(self, parser, namespace, values, option_string=None): + setattr(namespace, self.dest, values) + + +class Slider(argparse.Action): # pylint: disable=too-few-public-methods + """ Adds support for a slider in the GUI. + + The standard :class:`argparse.Action` is extended with the additional parameters listed below. + The :attr:`default` value must be supplied and the :attr:`type` must be either :class:`int` or + :class:`float`. :attr:`nargs` are explicitly disallowed. + + Parameters + ---------- + min_max: tuple + The (`min`, `max`) values that the slider's range should be set to. The values should be a + pair of `float` or `int` data types, depending on the data type of the slider. NB: These + min/max values are not enforced, they are purely for setting the slider range. Values + outside of this range can still be explicitly passed in from the cli. + rounding: int + If the underlying data type for the option is a `float` then this value is the number of + decimal places to round the slider values to. If the underlying data type for the option is + an `int` then this is the step interval between each value for the slider. + + Examples + -------- + For integer values: + + >>> argument_list = [] + >>> argument_list.append(dict( + >>> opts=("-f", "--foobar"), + >>> action=Slider, + >>> min_max=(0, 10) + >>> rounding=1 + >>> type=int, + >>> default=5)) + + For floating point values: + + >>> argument_list = [] + >>> argument_list.append(dict( + >>> opts=("-f", "--foobar"), + >>> action=Slider, + >>> min_max=(0.00, 1.00) + >>> rounding=2 + >>> type=float, + >>> default=5.00)) + """ + def __init__(self, *args, min_max=None, rounding=None, **kwargs): + opt = kwargs["option_strings"] + if kwargs.get("nargs", None) is not None: + raise ValueError("nargs not allowed for Slider: {}".format(opt)) + if kwargs.get("default", None) is None: + raise ValueError("A default value must be supplied for Slider: {}".format(opt)) + if kwargs.get("type", None) not in (int, float): + raise ValueError("Sliders only accept int and float data types: {}".format(opt)) + if min_max is None: + raise ValueError("min_max must be provided for Sliders: {}".format(opt)) + if rounding is None: + raise ValueError("rounding must be provided for Sliders: {}".format(opt)) + + super().__init__(*args, **kwargs) + self.min_max = min_max + self.rounding = rounding + + def _get_kwargs(self): + names = ["option_strings", + "dest", + "nargs", + "const", + "default", + "type", + "choices", + "help", + "metavar", + "min_max", # Tuple containing min and max values of scale + "rounding"] # Decimal places to round floats to or step interval for ints + return [(name, getattr(self, name)) for name in names] + + def __call__(self, parser, namespace, values, option_string=None): + setattr(namespace, self.dest, values) diff --git a/lib/cli/args.py b/lib/cli/args.py new file mode 100644 index 0000000000..a205c64f82 --- /dev/null +++ b/lib/cli/args.py @@ -0,0 +1,1138 @@ +#!/usr/bin/env python3 +""" The Command Line Argument options for faceswap.py """ +import argparse +import logging +import re +import sys +import textwrap + +from lib.utils import get_backend +from plugins.plugin_loader import PluginLoader + +from .actions import (DirFullPaths, DirOrFileFullPaths, FileFullPaths, FilesFullPaths, MultiOption, + Radio, SaveFileFullPaths, Slider) +from .launcher import ScriptExecutor + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class FullHelpArgumentParser(argparse.ArgumentParser): + """ Extends :class:`argparse.ArgumentParser` to output full help on bad arguments. """ + def error(self, message): + self.print_help(sys.stderr) + self.exit(2, "{}: error: {}\n".format(self.prog, message)) + + +class SmartFormatter(argparse.HelpFormatter): + """ Extends the class :class:`argparse.HelpFormatter` to allow custom formatting in help text. + + Adapted from: https://stackoverflow.com/questions/3853722 + + Notes + ----- + Prefix help text with "R|" to override default formatting and use explicitly defined formatting + within the help text. + Prefixing a new line within the help text with "L|" will turn that line into a list item in + both the cli help text and the GUI. + """ + def __init__(self, prog, indent_increment=2, max_help_position=24, width=None): + super().__init__(prog, indent_increment, max_help_position, width) + self._whitespace_matcher_limited = re.compile(r'[ \r\f\v]+', re.ASCII) + + def _split_lines(self, text, width): + """ Split the given text by the given display width. + + If the text is not prefixed with "R|" then the standard + :func:`argparse.HelpFormatter._split_lines` function is used, otherwise raw + formatting is processed, + + Parameters + ---------- + text: str + The help text that is to be formatted for display + width: int + The display width, in characters, for the help text + """ + if text.startswith("R|"): + text = self._whitespace_matcher_limited.sub(' ', text).strip()[2:] + output = list() + for txt in text.splitlines(): + indent = "" + if txt.startswith("L|"): + indent = " " + txt = " - {}".format(txt[2:]) + output.extend(textwrap.wrap(txt, width, subsequent_indent=indent)) + return output + return argparse.HelpFormatter._split_lines(self, text, width) + + +class FaceSwapArgs(): + """ Faceswap argument parser functions that are universal to all commands. + + This is the parent class to all subsequent argparsers which holds global arguments that pertain + to all commands. + + Process the incoming command line arguments, validates then launches the relevant faceswap + script with the given arguments. + + Parameters + ---------- + subparser: :class:`argparse._SubParsersAction` + The subparser for the given command + command: str + The faceswap command that is to be executed + description: str, optional + The description for the given command. Default: "default" + """ + def __init__(self, subparser, command, description="default"): + self.global_arguments = self._get_global_arguments() + self.info = self.get_info() + self.argument_list = self.get_argument_list() + self.optional_arguments = self.get_optional_arguments() + self._process_suppressions() + if not subparser: + return + self.parser = self._create_parser(subparser, command, description) + self._add_arguments() + script = ScriptExecutor(command) + self.parser.set_defaults(func=script.execute_script) + + @staticmethod + def get_info(): + """ Returns the information text for the current command. + + This function should be overridden with the actual command help text for each + commands' parser. + + Returns + ------- + str + The information text for this command. + """ + return None + + @staticmethod + def get_argument_list(): + """ Returns the argument list for the current command. + + The argument list should be a list of dictionaries pertaining to each option for a command. + This function should be overridden with the actual argument list for each command's + argument list. + + See existing parsers for examples. + + Returns + ------- + list + The list of command line options for the given command + """ + argument_list = [] + return argument_list + + @staticmethod + def get_optional_arguments(): + """ Returns the optional argument list for the current command. + + The optional arguments list is not always required, but is used when there are shared + options between multiple commands (e.g. convert and extract). Only override if required. + + Returns + ------- + list + The list of optional command line options for the given command + """ + argument_list = [] + return argument_list + + @staticmethod + def _get_global_arguments(): + """ Returns the global Arguments list that are required for ALL commands in Faceswap. + + This method should NOT be overridden. + + Returns + ------- + list + The list of global command line options for all Faceswap commands. + """ + global_args = list() + global_args.append(dict( + opts=("-C", "--configfile"), + action=FileFullPaths, + filetypes="ini", + type=str, + group="Global Options", + help="Optionally overide the saved config with the path to a custom config file.")) + global_args.append(dict( + opts=("-L", "--loglevel"), + type=str.upper, + dest="loglevel", + default="INFO", + choices=("INFO", "VERBOSE", "DEBUG", "TRACE"), + group="Global Options", + help="Log level. Stick with INFO or VERBOSE unless you need to file an error report. " + "Be careful with TRACE as it will generate a lot of data")) + global_args.append(dict( + opts=("-LF", "--logfile"), + action=SaveFileFullPaths, + filetypes='log', + type=str, + dest="logfile", + default=None, + group="Global Options", + help="Path to store the logfile. Leave blank to store in the faceswap folder")) + # These are hidden arguments to indicate that the GUI/Colab is being used + global_args.append(dict( + opts=("-gui", "--gui"), + action="store_true", + dest="redirect_gui", + default=False, + help=argparse.SUPPRESS)) + global_args.append(dict( + opts=("-colab", "--colab"), + action="store_true", + dest="colab", + default=False, + help=argparse.SUPPRESS)) + return global_args + + @staticmethod + def _create_parser(subparser, command, description): + """ Create the parser for the selected command. + + Parameters + ---------- + command: str + The faceswap command that is to be executed + description: str + The description for the given command + + Returns + ------- + :class:`~lib.cli.args.FullHelpArgumentParser` + The parser for the given command + """ + parser = subparser.add_parser(command, + help=description, + description=description, + epilog="Questions and feedback: https://faceswap.dev/forum", + formatter_class=SmartFormatter) + return parser + + def _add_arguments(self): + """ Parse the list of dictionaries containing the command line arguments and convert to + argparse parser arguments. """ + options = self.global_arguments + self.argument_list + self.optional_arguments + for option in options: + args = option["opts"] + kwargs = {key: option[key] for key in option.keys() if key not in ("opts", "group")} + self.parser.add_argument(*args, **kwargs) + + def _process_suppressions(self): + """ Certain options are only available for certain backends. + + Suppresses command line options that are not available for the running backend. + """ + fs_backend = get_backend() + for opt_list in [self.global_arguments, self.argument_list, self.optional_arguments]: + for opts in opt_list: + if opts.get("backend", None) is None: + continue + opt_backend = opts.pop("backend") + if isinstance(opt_backend, (list, tuple)): + opt_backend = [backend.lower() for backend in opt_backend] + else: + opt_backend = [opt_backend.lower()] + if fs_backend not in opt_backend: + opts["help"] = argparse.SUPPRESS + + +class ExtractConvertArgs(FaceSwapArgs): + """ Parent class to capture arguments that will be used in both extract and convert processes. + + Extract and Convert share a fair amount of arguments, so arguments that can be used in both of + these processes should be placed here. + + No further processing is done in this class (this is handled by the children), this just + captures the shared arguments. + """ + + @staticmethod + def get_argument_list(): + """ Returns the argument list for shared Extract and Convert arguments. + + Returns + ------- + list + The list of command line options for the given Extract and Convert + """ + argument_list = list() + argument_list.append(dict( + opts=("-i", "--input-dir"), + action=DirOrFileFullPaths, + filetypes="video", + dest="input_dir", + required=True, + group="Data", + help="Input directory or video. Either a directory containing the image files you " + "wish to process or path to a video file. NB: This should be the source video/" + "frames NOT the source faces.")) + argument_list.append(dict( + opts=("-o", "--output-dir"), + action=DirFullPaths, + dest="output_dir", + required=True, + group="Data", + help="Output directory. This is where the converted files will be saved.")) + argument_list.append(dict( + opts=("-al", "--alignments"), + action=FileFullPaths, + filetypes="alignments", + type=str, + dest="alignments_path", + group="Data", + help="Optional path to an alignments file. Leave blank if the alignments file is " + "at the default location.")) + return argument_list + + +class ExtractArgs(ExtractConvertArgs): + """ Creates the command line arguments for extraction. + + This class inherits base options from :class:`ExtractConvertArgs` where arguments that are used + for both Extract and Convert should be placed. + + Commands explicit to Extract should be added in :func:`get_optional_arguments` + """ + + @staticmethod + def get_info(): + """ The information text for the Extract command. + + Returns + ------- + str + The information text for the Extract command. + """ + return ("Extract faces from image or video sources.\n" + "Extraction plugins can be configured in the 'Settings' Menu") + + @staticmethod + def get_optional_arguments(): + """ Returns the argument list unique to the Extract command. + + Returns + ------- + list + The list of optional command line options for the Extract command + """ + if get_backend() == "cpu": + default_detector = default_aligner = "cv2-dnn" + else: + default_detector = "s3fd" + default_aligner = "fan" + + argument_list = [] + argument_list.append(dict( + opts=("-D", "--detector"), + action=Radio, + type=str.lower, + default=default_detector, + choices=PluginLoader.get_available_extractors("detect"), + group="Plugins", + help="R|Detector to use. Some of these have configurable settings in " + "'/config/extract.ini' or 'Settings > Configure Extract 'Plugins':" + "\nL|cv2-dnn: A CPU only extractor which is the least reliable and least " + "resource intensive. Use this if not using a GPU and time is important." + "\nL|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " + "than other GPU detectors but can often return more false positives." + "\nL|s3fd: Best detector. Fast on GPU, slow on CPU. Can detect more faces and " + "fewer false positives than other GPU detectors, but is a lot more resource " + "intensive.")) + argument_list.append(dict( + opts=("-A", "--aligner"), + action=Radio, + type=str.lower, + default=default_aligner, + choices=PluginLoader.get_available_extractors("align"), + group="Plugins", + help="R|Aligner to use." + "\nL|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " + "but less accurate. Only use this if not using a GPU and time is important." + "\nL|fan: Best aligner. Fast on GPU, slow on CPU.")) + argument_list.append(dict( + opts=("-M", "--masker"), + action=MultiOption, + type=str.lower, + nargs="+", + choices=[mask for mask in PluginLoader.get_available_extractors("mask") + if mask not in ("components", "extended")], + group="Plugins", + help="R|Additional Masker(s) to use. The masks generated here will all take up GPU " + "RAM. You can select none, one or multiple masks, but the extraction may take " + "longer the more you select. NB: The Extended and Components (landmark based) " + "masks are automatically generated on extraction." + "\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." + "\nThe auto generated masks are as follows:" + "\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|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." + "\n(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)")) + argument_list.append(dict( + opts=("-nm", "--normalization"), + action=Radio, + type=str.lower, + dest="normalization", + default="none", + choices=["none", "clahe", "hist", "mean"], + group="plugins", + help="R|Performing normalization can help the aligner better align faces with " + "difficult lighting conditions at an extraction speed cost. Different methods " + "will yield different results on different sets. NB: This does not impact the " + "output face, just the input to the aligner." + "\nL|none: Don't perform normalization on the face." + "\nL|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " + "face." + "\nL|hist: Equalize the histograms on the RGB channels." + "\nL|mean: Normalize the face colors to the mean.")) + argument_list.append(dict( + opts=("-r", "--rotate-images"), + type=str, + dest="rotate_images", + default=None, + group="plugins", + help="If a face isn't found, rotate the images to try to find a face. Can find " + "more faces at the cost of extraction speed. Pass in a single number to use " + "increments of that size up to 360, or pass in a list of numbers to enumerate " + "exactly what angles to check.")) + argument_list.append(dict( + opts=("-min", "--min-size"), + action=Slider, + min_max=(0, 1080), + rounding=20, + type=int, + dest="min_size", + default=0, + group="Face Processing", + help="Filters out faces detected below this size. Length, in pixels across the " + "diagonal of the bounding box. Set to 0 for off")) + argument_list.append(dict( + opts=("-n", "--nfilter"), + action=FilesFullPaths, + filetypes="image", + dest="nfilter", + default=None, + nargs="+", + group="Face Processing", + help="Optionally filter out people who you do not wish to process by passing in an " + "image of that person. Should be a front portrait with a single person in the " + "image. Multiple images can be added space separated. NB: Using face filter " + "will significantly decrease extraction speed and its accuracy cannot be " + "guaranteed.")) + argument_list.append(dict( + opts=("-f", "--filter"), + action=FilesFullPaths, + filetypes="image", + dest="filter", + default=None, + nargs="+", + group="Face Processing", + help="Optionally select people you wish to process by passing in an image of that " + "person. Should be a front portrait with a single person in the image. " + "Multiple images can be added space separated. NB: Using face filter will " + "significantly decrease extraction speed and its accuracy cannot be " + "guaranteed.")) + argument_list.append(dict( + opts=("-l", "--ref_threshold"), + action=Slider, + min_max=(0.01, 0.99), + rounding=2, + type=float, + dest="ref_threshold", + default=0.4, + group="Face Processing", + help="For use with the optional nfilter/filter files. Threshold for positive face " + "recognition. Lower values are stricter. NB: Using face filter will " + "significantly decrease extraction speed and its accuracy cannot be " + "guaranteed.")) + argument_list.append(dict( + opts=("-een", "--extract-every-n"), + action=Slider, + min_max=(1, 100), + rounding=1, + type=int, + dest="extract_every_n", + default=1, + group="output", + help="Extract every 'nth' frame. This option will skip frames when extracting " + "faces. For example a value of 1 will extract faces from every frame, a value " + "of 10 will extract faces from every 10th frame.")) + argument_list.append(dict( + opts=("-sz", "--size"), + action=Slider, + min_max=(128, 512), + rounding=64, + type=int, + default=256, + group="output", + help="The output size of extracted faces. Make sure that the model you intend to " + "train supports your required size. This will only need to be changed for " + "hi-res models.")) + argument_list.append(dict( + opts=("-si", "--save-interval"), + action=Slider, + min_max=(0, 1000), + rounding=10, + type=int, + dest="save_interval", + default=0, + group="output", + help="Automatically save the alignments file after a set amount of frames. By " + "default the alignments file is only saved at the end of the extraction " + "process. NB: If extracting in 2 passes then the alignments file will only " + "start to be saved out during the second pass. WARNING: Don't interrupt the " + "script when writing the file because it might get corrupted. Set to 0 to " + "turn off")) + argument_list.append(dict( + opts=("-dl", "--debug-landmarks"), + action="store_true", + dest="debug_landmarks", + default=False, + group="output", + help="Draw landmarks on the ouput faces for debugging purposes.")) + argument_list.append(dict( + opts=("-sp", "--singleprocess"), + action="store_true", + default=False, + backend="nvidia", + group="settings", + help="Don't run extraction in parallel. Will run each part of the extraction " + "process separately (one after the other) rather than all at the smae time. " + "Useful if VRAM is at a premium.")) + argument_list.append(dict( + opts=("-s", "--skip-existing"), + action="store_true", + dest="skip_existing", + default=False, + group="settings", + help="Skips frames that have already been extracted and exist in the alignments " + "file")) + argument_list.append(dict( + opts=("-sf", "--skip-existing-faces"), + action="store_true", + dest="skip_faces", + default=False, + group="settings", + help="Skip frames that already have detected faces in the alignments file")) + return argument_list + + +class ConvertArgs(ExtractConvertArgs): + """ Creates the command line arguments for conversion. + + This class inherits base options from :class:`ExtractConvertArgs` where arguments that are used + for both Extract and Convert should be placed. + + Commands explicit to Convert should be added in :func:`get_optional_arguments` + """ + + @staticmethod + def get_info(): + """ The information text for the Convert command. + + Returns + ------- + str + The information text for the Convert command. + """ + return ("Swap the original faces in a source video/images to your final faces.\n" + "Conversion plugins can be configured in the 'Settings' Menu") + + @staticmethod + def get_optional_arguments(): + """ Returns the argument list unique to the Convert command. + + Returns + ------- + list + The list of optional command line options for the Convert command + """ + + argument_list = [] + argument_list.append(dict( + opts=("-ref", "--reference-video"), + action=FileFullPaths, + filetypes="video", + type=str, + dest="reference_video", + group="data", + help="Only required if converting from images to video. Provide The original video " + "that the source frames were extracted from (for extracting the fps and " + "audio).")) + argument_list.append(dict( + opts=("-m", "--model-dir"), + action=DirFullPaths, + dest="model_dir", + required=True, + group="data", + help="Model directory. The directory containing the trained model you wish to use " + "for conversion.")) + argument_list.append(dict( + opts=("-c", "--color-adjustment"), + action=Radio, + type=str.lower, + dest="color_adjustment", + default="avg-color", + choices=PluginLoader.get_available_convert_plugins("color", True), + group="plugins", + help="R|Performs color adjustment to the swapped face. Some of these options have " + "configurable settings in '/config/convert.ini' or 'Settings > Configure " + "Convert Plugins':" + "\nL|avg-color: Adjust the mean of each color channel in the swapped " + "reconstruction to equal the mean of the masked area in the original image." + "\nL|color-transfer: Transfers the color distribution from the source to the " + "target image using the mean and standard deviations of the L*a*b* " + "color space." + "\nL|manual-balance: Manually adjust the balance of the image in a variety of " + "color spaces. Best used with the Preview tool to set correct values." + "\nL|match-hist: Adjust the histogram of each color channel in the swapped " + "reconstruction to equal the histogram of the masked area in the original " + "image." + "\nL|seamless-clone: Use cv2's seamless clone function to remove extreme " + "gradients at the mask seam by smoothing colors. Generally does not give " + "very satisfactory results." + "\nL|none: Don't perform color adjustment.")) + argument_list.append(dict( + opts=("-M", "--mask-type"), + action=Radio, + type=str.lower, + dest="mask_type", + default="extended", + choices=PluginLoader.get_available_extractors("mask", add_none=True) + ["predicted"], + group="Plugins", + help="R|Masker to use. NB: The mask you require must exist within the alignments " + "file. You can add additional masks with the Mask Tool." + "\nL|none: Don't use a mask." + "\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|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=("-sc", "--scaling"), + action=Radio, + type=str.lower, + default="none", + choices=PluginLoader.get_available_convert_plugins("scaling", True), + group="plugins", + help="R|Performs a scaling process to attempt to get better definition on the " + "final swap. Some of these options have configurable settings in " + "'/config/convert.ini' or 'Settings > Configure Convert Plugins':" + "\nL|sharpen: Perform sharpening on the final face." + "\nL|none: Don't perform any scaling operations.")) + argument_list.append(dict( + opts=("-w", "--writer"), + action=Radio, + type=str, + default="opencv", + choices=PluginLoader.get_available_convert_plugins("writer", False), + group="plugins", + help="R|The plugin to use to output the converted images. The writers are " + "configurable in '/config/convert.ini' or 'Settings > Configure Convert " + "Plugins:'" + "\nL|ffmpeg: [video] Writes out the convert straight to video. When the input " + "is a series of images then the '-ref' (--reference-video) parameter must be " + "set." + "\nL|gif: [animated image] Create an animated gif." + "\nL|opencv: [images] The fastest image writer, but less options and formats " + "than other plugins." + "\nL|pillow: [images] Slower than opencv, but has more options and supports " + "more formats.")) + argument_list.append(dict( + opts=("-osc", "--output-scale"), + action=Slider, + min_max=(25, 400), + rounding=1, + type=int, + dest="output_scale", + default=100, + group="Frame Processing", + help="Scale the final output frames by this amount. 100%% will output the frames " + "at source dimensions. 50%% at half size 200%% at double size")) + argument_list.append(dict( + opts=("-fr", "--frame-ranges"), + type=str, + nargs="+", + group="Frame Processing", + help="Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " + "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " + "will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " + "converting from images, then the filenames must end with the frame-number!")) + argument_list.append(dict( + opts=("-a", "--input-aligned-dir"), + action=DirFullPaths, + dest="input_aligned_dir", + default=None, + group="Face Processing", + help="If you have not cleansed your alignments file, then you can filter out faces " + "by defining a folder here that contains the faces extracted from your input " + "files/video. If this folder is defined, then only faces that exist within " + "your alignments file and also exist within the specified folder will be " + "converted. Leaving this blank will convert all faces that exist within the " + "alignments file.")) + argument_list.append(dict( + opts=("-n", "--nfilter"), + action=FilesFullPaths, + filetypes="image", + dest="nfilter", + default=None, + nargs="+", + group="Face Processing", + help="Optionally filter out people who you do not wish to process by passing in an " + "image of that person. Should be a front portrait with a single person in the " + "image. Multiple images can be added space separated. NB: Using face filter " + "will significantly decrease extraction speed and its accuracy cannot be " + "guaranteed.")) + argument_list.append(dict( + opts=("-f", "--filter"), + action=FilesFullPaths, + filetypes="image", + dest="filter", + default=None, + nargs="+", + group="Face Processing", + help="Optionally select people you wish to process by passing in an image of that " + "person. Should be a front portrait with a single person in the image. " + "Multiple images can be added space separated. NB: Using face filter will " + "significantly decrease extraction speed and its accuracy cannot be " + "guaranteed.")) + argument_list.append(dict( + opts=("-l", "--ref_threshold"), + action=Slider, + min_max=(0.01, 0.99), + rounding=2, + type=float, + dest="ref_threshold", + default=0.4, + group="Face Processing", + help="For use with the optional nfilter/filter files. Threshold for positive face " + "recognition. Lower values are stricter. NB: Using face filter will " + "significantly decrease extraction speed and its accuracy cannot be " + "guaranteed.")) + argument_list.append(dict( + opts=("-j", "--jobs"), + action=Slider, + min_max=(0, 40), + rounding=1, + type=int, + dest="jobs", + default=0, + group="settings", + help="The maximum number of parallel processes for performing conversion. " + "Converting images is system RAM heavy so it is possible to run out of memory " + "if you have a lot of processes and not enough RAM to accommodate them all. " + "Setting this to 0 will use the maximum available. No matter what you set " + "this to, it will never attempt to use more processes than are available on " + "your system. If singleprocess is enabled this setting will be ignored.")) + argument_list.append(dict( + opts=("-g", "--gpus"), + action=Slider, + min_max=(1, 10), + rounding=1, + type=int, + default=1, + backend="nvidia", + group="settings", + help="Number of GPUs to use for conversion")) + argument_list.append(dict( + opts=("-t", "--trainer"), + type=str.lower, + choices=PluginLoader.get_available_models(), + group="settings", + help="[LEGACY] This only needs to be selected if a legacy model is being loaded or " + "if there are multiple models in the model folder")) + argument_list.append(dict( + opts=("-ag", "--allow-growth"), + action="store_true", + dest="allow_growth", + default=False, + backend="nvidia", + group="settings", + help="Sets allow_growth option of Tensorflow to spare memory on some " + "configurations.")) + argument_list.append(dict( + opts=("-otf", "--on-the-fly"), + action="store_true", + dest="on_the_fly", + default=False, + group="settings", + help="Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " + "alignments file for your destination video. However, if you wish you can " + "generate the alignments on-the-fly by enabling this option. This will use " + "an inferior extraction pipeline and will lead to substandard results. If an " + "alignments file is found, this option will be ignored.")) + argument_list.append(dict( + opts=("-k", "--keep-unchanged"), + action="store_true", + dest="keep_unchanged", + default=False, + group="Frame Processing", + help="When used with --frame-ranges outputs the unchanged frames that are not " + "processed instead of discarding them.")) + argument_list.append(dict( + opts=("-s", "--swap-model"), + action="store_true", + dest="swap_model", + default=False, + group="settings", + help="Swap the model. Instead converting from of A -> B, converts B -> A")) + argument_list.append(dict( + opts=("-sp", "--singleprocess"), + action="store_true", + default=False, + group="settings", + help="Disable multiprocessing. Slower but less resource intensive.")) + return argument_list + + +class TrainArgs(FaceSwapArgs): + """ Creates the command line arguments for training. """ + + @staticmethod + def get_info(): + """ The information text for the Train command. + + Returns + ------- + str + The information text for the Train command. + """ + return ("Train a model on extracted original (A) and swap (B) faces.\n" + "Training models can take a long time. Anything from 24hrs to over a week\n" + "Model plugins can be configured in the 'Settings' Menu") + + @staticmethod + def get_argument_list(): + """ Returns the argument list for Train arguments. + + Returns + ------- + list + The list of command line options for training + """ + argument_list = list() + argument_list.append(dict( + opts=("-A", "--input-A"), + action=DirFullPaths, + dest="input_a", + required=True, + group="faces", + help="Input directory. A directory containing training images for face A. This is " + "the original face, i.e. the face that you want to remove and replace with " + "face B.")) + argument_list.append(dict( + opts=("-ala", "--alignments-A"), + action=FileFullPaths, + filetypes='alignments', + type=str, + dest="alignments_path_a", + default=None, + group="faces", + help="Path to alignments file for training set A. Only required if you are using a " + "masked model or warp-to-landmarks is enabled. Defaults to " + "/alignments.json if not provided.")) + argument_list.append(dict( + opts=("-B", "--input-B"), + action=DirFullPaths, + dest="input_b", + required=True, + group="faces", + help="Input directory. A directory containing training images for face B. This is " + "the swap face, i.e. the face that you want to place onto the head of person " + "A.")) + argument_list.append(dict( + opts=("-alb", "--alignments-B"), + action=FileFullPaths, + filetypes='alignments', + type=str, + dest="alignments_path_b", + default=None, + group="faces", + help="Path to alignments file for training set B. Only required if you are using a " + "masked model or warp-to-landmarks is enabled. Defaults to " + "/alignments.json if not provided.")) + argument_list.append(dict( + opts=("-m", "--model-dir"), + action=DirFullPaths, + dest="model_dir", + required=True, + group="model", + help="Model directory. This is where the training data will be stored. You should " + "always specify a new folder for new models. If starting a new model, select " + "either an empty folder, or a folder which does not exist (which will be " + "created). If continuing to train an existing model, specify the location of " + "the existing model.")) + argument_list.append(dict( + opts=("-t", "--trainer"), + action=Radio, + type=str.lower, + default=PluginLoader.get_default_model(), + choices=PluginLoader.get_available_models(), + group="model", + help="R|Select which trainer to use. Trainers can be configured from the Settings " + "menu or the config folder." + "\nL|original: The original model created by /u/deepfakes." + "\nL|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " + "for full dfaker method." + "\nL|dfl-h128. 128px in/out model from deepfacelab" + "\nL|dfl-sae. Adaptable model from deepfacelab" + "\nL|dlight. A lightweight, high resolution DFaker variant." + "\nL|iae: A model that uses intermediate layers to try to get better details" + "\nL|lightweight: A lightweight model for low-end cards. Don't expect great " + "results. Can train as low as 1.6GB with batch size 8." + "\nL|realface: A high detail, dual density model based on DFaker, with " + "customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " + "won't work so well. By andenixa et al. Very configurable." + "\nL|unbalanced: 128px in/out model from andenixa. The autoencoders are " + "unbalanced so B>A swaps won't work so well. Very configurable." + "\nL|villain: 128px in/out model from villainguy. Very resource hungry (11GB " + "for batchsize 16). Good for details, but more susceptible to color " + "differences.")) + argument_list.append(dict( + opts=("-bs", "--batch-size"), + action=Slider, + min_max=(2, 256), + rounding=2, + type=int, + dest="batch_size", + default=64, + group="training", + help="Batch size. This is the number of images processed through the model for " + "each iteration. Larger batches require more GPU RAM.")) + argument_list.append(dict( + opts=("-it", "--iterations"), + action=Slider, + min_max=(0, 5000000), + rounding=20000, + type=int, + default=1000000, + group="training", + help="Length of training in iterations. This is only really used for automation. " + "There is no 'correct' number of iterations a model should be trained for. " + "You should stop training when you are happy with the previews. However, if " + "you want the model to stop automatically at a set number of iterations, you " + "can set that value here.")) + argument_list.append(dict( + opts=("-g", "--gpus"), + action=Slider, + min_max=(1, 10), + rounding=1, + type=int, + default=1, + backend="nvidia", + group="training", + help="Number of GPUs to use for training")) + argument_list.append(dict( + opts=("-msg", "--memory-saving-gradients"), + action="store_true", + dest="memory_saving_gradients", + default=False, + backend="nvidia", + group="VRAM Savings", + help="Trades off VRAM usage against computation time. Can fit larger models into " + "memory at a cost of slower training speed. 50%%-150%% batch size increase " + "for 20%%-50%% longer training time. NB: Launch time will be significantly " + "delayed. Switching sides using ping-pong training will take longer.")) + argument_list.append(dict( + opts=("-o", "--optimizer-savings"), + action="store_true", + dest="optimizer_savings", + default=False, + backend="nvidia", + group="VRAM Savings", + help="To save VRAM some optimizer gradient calculations can be performed on the " + "CPU rather than the GPU. This allows you to increase batchsize at a training " + "speed/system RAM cost.")) + argument_list.append(dict( + opts=("-pp", "--ping-pong"), + action="store_true", + dest="pingpong", + default=False, + backend="nvidia", + group="VRAM Savings", + help="Enable ping pong training. Trains one side at a time, switching sides at " + "each save iteration. Training will take 2 to 4 times longer, with about a " + "30%%-50%% reduction in VRAM useage. NB: Preview won't show until both sides " + "have been trained once.")) + argument_list.append(dict( + opts=("-s", "--save-interval"), + action=Slider, + min_max=(10, 1000), + rounding=10, + type=int, + dest="save_interval", + default=100, + group="Saving", + help="Sets the number of iterations between each model save.")) + argument_list.append(dict( + opts=("-ss", "--snapshot-interval"), + action=Slider, + min_max=(0, 100000), + rounding=5000, + type=int, + dest="snapshot_interval", + default=25000, + group="Saving", + help="Sets the number of iterations before saving a backup snapshot of the model " + "in it's current state. Set to 0 for off.")) + argument_list.append(dict( + opts=("-tia", "--timelapse-input-A"), + action=DirFullPaths, + dest="timelapse_input_a", + default=None, + group="timelapse", + help="Optional for creating a timelapse. Timelapse will save an image of your " + "selected faces into the timelapse-output folder at every save iteration. " + "This should be the input folder of 'A' faces that you would like to use for " + "creating the timelapse. You must also supply a --timelapse-output and a " + "--timelapse-input-B parameter.")) + argument_list.append(dict( + opts=("-tib", "--timelapse-input-B"), + action=DirFullPaths, + dest="timelapse_input_b", + default=None, + group="timelapse", + help="Optional for creating a timelapse. Timelapse will save an image of your " + "selected faces into the timelapse-output folder at every save iteration. " + "This should be the input folder of 'B' faces that you would like to use for " + "creating the timelapse. You must also supply a --timelapse-output and a " + "--timelapse-input-A parameter.")) + argument_list.append(dict( + opts=("-to", "--timelapse-output"), + action=DirFullPaths, + dest="timelapse_output", + default=None, + group="timelapse", + help="Optional for creating a timelapse. Timelapse will save an image of your " + "selected faces into the timelapse-output folder at every save iteration. If " + "the input folders are supplied but no output folder, it will default to your " + "model folder /timelapse/")) + argument_list.append(dict( + opts=("-ps", "--preview-scale"), + action=Slider, + min_max=(25, 200), + rounding=25, + type=int, + dest="preview_scale", + default=50, + group="preview", + help="Percentage amount to scale the preview by.")) + argument_list.append(dict( + opts=("-p", "--preview"), + action="store_true", + dest="preview", + default=False, + group="preview", + help="Show training preview output. in a separate window.")) + argument_list.append(dict( + opts=("-w", "--write-image"), + action="store_true", + dest="write_image", + default=False, + group="preview", + help="Writes the training result to a file. The image will be stored in the root " + "of your FaceSwap folder.")) + argument_list.append(dict( + opts=("-ag", "--allow-growth"), + action="store_true", + dest="allow_growth", + default=False, + backend="nvidia", + group="model", + help="Sets allow_growth option of Tensorflow to spare memory on some " + "configurations.")) + argument_list.append(dict( + opts=("-nl", "--no-logs"), + action="store_true", + dest="no_logs", + default=False, + group="training", + help="Disables TensorBoard logging. NB: Disabling logs means that you will not be " + "able to use the graph or analysis for this session in the GUI.")) + argument_list.append(dict( + opts=("-wl", "--warp-to-landmarks"), + action="store_true", + dest="warp_to_landmarks", + default=False, + group="training", + help="Warps training faces to closely matched Landmarks from the opposite face-set " + "rather than randomly warping the face. This is the 'dfaker' way of doing " + "warping. Alignments files for both sets of faces must be provided if using " + "this option.")) + argument_list.append(dict( + opts=("-nf", "--no-flip"), + action="store_true", + dest="no_flip", + default=False, + group="training", + help="To effectively learn, a random set of images are flipped horizontally. " + "Sometimes it is desirable for this not to occur. Generally this should be " + "left off except for during 'fit training'.")) + argument_list.append(dict( + opts=("-nac", "--no-augment-color"), + action="store_true", + dest="no_augment_color", + default=False, + group="training", + help="Color augmentation helps make the model less susceptible to color " + "differences between the A and B sets, at an increased training time cost. " + "Enable this option to disable color augmentation.")) + return argument_list + + +class GuiArgs(FaceSwapArgs): + """ Creates the command line arguments for the GUI. """ + + @staticmethod + def get_argument_list(): + """ Returns the argument list for GUI arguments. + + Returns + ------- + list + The list of command line options for the GUI + """ + argument_list = [] + argument_list.append(dict( + opts=("-d", "--debug"), + action="store_true", + dest="debug", + default=False, + help="Output to Shell console instead of GUI console")) + return argument_list diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py new file mode 100644 index 0000000000..aaa9b97513 --- /dev/null +++ b/lib/cli/launcher.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +""" Launches the correct script with the given Command Line Arguments """ +import logging +import os +import platform +import sys + +from importlib import import_module +from lib.logger import crash_log, log_setup +from lib.utils import FaceswapError, get_backend, safe_shutdown, set_system_verbosity + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class ScriptExecutor(): # pylint:disable=too-few-public-methods + """ Loads the relevant script modules and executes the script. + + This class is initialized in each of the argparsers for the relevant + command, then execute script is called within their set_default + function. + + Parameters + ---------- + command: str + The faceswap command that is being executed + """ + def __init__(self, command): + self._command = command.lower() + + def _import_script(self): + """ Imports the relevant script as indicated by :attr:`_command` from the scripts folder. + + Returns + ------- + class: Faceswap Script + The uninitialized script from the faceswap scripts folder. + """ + self._test_for_tf_version() + self._test_for_gui() + cmd = os.path.basename(sys.argv[0]) + src = "tools.{}".format(self._command.lower()) if cmd == "tools.py" else "scripts" + mod = ".".join((src, self._command.lower())) + module = import_module(mod) + script = getattr(module, self._command.title()) + return script + + @staticmethod + def _test_for_tf_version(): + """ Check that the required Tensorflow version is installed. + + Raises + ------ + FaceswapError + If Tensorflow is not found, or is not between versions 1.12 and 1.15 + """ + min_ver = 1.12 + max_ver = 1.15 + try: + # Ensure tensorflow doesn't pin all threads to one core when using Math Kernel Library + os.environ["KMP_AFFINITY"] = "disabled" + import tensorflow as tf # pylint:disable=import-outside-toplevel + except ImportError as err: + raise FaceswapError("There was an error importing Tensorflow. This is most likely " + "because you do not have TensorFlow installed, or you are trying " + "to run tensorflow-gpu on a system without an Nvidia graphics " + "card. Original import error: {}".format(str(err))) + tf_ver = float(".".join(tf.__version__.split(".")[:2])) # pylint:disable=no-member + if tf_ver < min_ver: + raise FaceswapError("The minimum supported Tensorflow is version {} but you have " + "version {} installed. Please upgrade Tensorflow.".format( + min_ver, tf_ver)) + if tf_ver > max_ver: + raise FaceswapError("The maximumum supported Tensorflow is version {} but you have " + "version {} installed. Please downgrade Tensorflow.".format( + max_ver, tf_ver)) + logger.debug("Installed Tensorflow Version: %s", tf_ver) + + def _test_for_gui(self): + """ If running the gui, performs check to ensure necessary prerequisites are present. """ + if self._command != "gui": + return + self._test_tkinter() + self._check_display() + + @staticmethod + def _test_tkinter(): + """ If the user is running the GUI, test whether the tkinter app is available on their + machine. If not exit gracefully. + + This avoids having to import every tkinter function within the GUI in a wrapper and + potentially spamming traceback errors to console. + + Raises + ------ + FaceswapError + If tkinter cannot be imported + """ + try: + # pylint: disable=unused-variable + import tkinter # noqa pylint: disable=unused-import,import-outside-toplevel + except ImportError: + logger.error("It looks like TkInter isn't installed for your OS, so the GUI has been " + "disabled. To enable the GUI please install the TkInter application. You " + "can try:") + logger.info("Anaconda: conda install tk") + logger.info("Windows/macOS: Install ActiveTcl Community Edition from " + "http://www.activestate.com") + logger.info("Ubuntu/Mint/Debian: sudo apt install python3-tk") + logger.info("Arch: sudo pacman -S tk") + logger.info("CentOS/Redhat: sudo yum install tkinter") + logger.info("Fedora: sudo dnf install python3-tkinter") + raise FaceswapError("TkInter not found") + + @staticmethod + def _check_display(): + """ Check whether there is a display to output the GUI to. + + If running on Windows then it is assumed that we are not running in headless mode + + Raises + ------ + FaceswapError + If a DISPLAY environmental cannot be found + """ + if not os.environ.get("DISPLAY", None) and os.name != "nt": + if platform.system() == "Darwin": + logger.info("macOS users need to install XQuartz. " + "See https://support.apple.com/en-gb/HT201341") + raise FaceswapError("No display detected. GUI mode has been disabled.") + + def execute_script(self, arguments): + """ Performs final set up and launches the requested :attr:`_command` with the given + command line arguments. + + Monitors for errors and attempts to shut down the process cleanly on exit. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The command line arguments to be passed to the executing script. + """ + set_system_verbosity(arguments.loglevel) + is_gui = hasattr(arguments, "redirect_gui") and arguments.redirect_gui + log_setup(arguments.loglevel, arguments.logfile, self._command, is_gui) + logger.debug("Executing: %s. PID: %s", self._command, os.getpid()) + success = False + if get_backend() == "amd": + plaidml_found = self._setup_amd(arguments.loglevel) + if not plaidml_found: + safe_shutdown(got_error=True) + return + try: + script = self._import_script() + process = script(arguments) + process.process() + success = True + except FaceswapError as err: + for line in str(err).splitlines(): + logger.error(line) + except KeyboardInterrupt: # pylint: disable=try-except-raise + raise + except SystemExit: + pass + except Exception: # pylint: disable=broad-except + crash_file = crash_log() + logger.exception("Got Exception on main handler:") + logger.critical("An unexpected crash has occurred. Crash report written to '%s'. " + "You MUST provide this file if seeking assistance. Please verify you " + "are running the latest version of faceswap before reporting", + crash_file) + + finally: + safe_shutdown(got_error=not success) + + @staticmethod + def _setup_amd(log_level): + """ Test for plaidml and perform setup for AMD. + + Parameters + ---------- + log_level: str + The requested log level to run at + """ + logger.debug("Setting up for AMD") + try: + import plaidml # noqa pylint:disable=unused-import,import-outside-toplevel + except ImportError: + logger.error("PlaidML not found. Run `pip install plaidml-keras` for AMD support") + return False + from lib.plaidml_tools import setup_plaidml # pylint:disable=import-outside-toplevel + setup_plaidml(log_level) + logger.debug("setup up for PlaidML") + return True diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index af2f6b7507..e878646430 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -10,8 +10,7 @@ from _tkinter import Tcl_Obj, TclError -from .custom_widgets import ContextMenu -from .custom_widgets import Tooltip +from .custom_widgets import ContextMenu, MultiOption, Tooltip from .utils import FileHandler, get_config, get_images logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -95,6 +94,8 @@ class ControlPanelOption(): Used for combo boxes and radio control option setting is_radio: bool, optional Specifies to use a Radio control instead of combobox if choices are passed + is_multi_option: + Specifies to use a Multi Check Button option group for the specified control rounding: int or float, optional For slider controls. Sets the stepping min_max: int or float, optional @@ -113,13 +114,14 @@ class ControlPanelOption(): def __init__(self, title, dtype, # pylint:disable=too-many-arguments group=None, default=None, initial_value=None, choices=None, is_radio=False, - rounding=None, min_max=None, sysbrowser=None, helptext=None, - track_modified=False, command=None): + is_multi_option=False, rounding=None, min_max=None, sysbrowser=None, + helptext=None, track_modified=False, command=None): logger.debug("Initializing %s: (title: '%s', dtype: %s, group: %s, default: %s, " - "initial_value: %s, choices: %s, is_radio: %s, rounding: %s, min_max: %s, " - "sysbrowser: %s, helptext: '%s', track_modified: %s, command: '%s')", - self.__class__.__name__, title, dtype, group, default, initial_value, choices, - is_radio, rounding, min_max, sysbrowser, helptext, track_modified, command) + "initial_value: %s, choices: %s, is_radio: %s, is_multi_option: %s, " + "rounding: %s, min_max: %s, sysbrowser: %s, helptext: '%s', " + "track_modified: %s, command: '%s')", self.__class__.__name__, title, dtype, + group, default, initial_value, choices, is_radio, is_multi_option, rounding, + min_max, sysbrowser, helptext, track_modified, command) self.dtype = dtype self.sysbrowser = sysbrowser @@ -130,6 +132,7 @@ def __init__(self, title, dtype, # pylint:disable=too-many-arguments initial_value=initial_value, choices=choices, is_radio=is_radio, + is_multi_option=is_multi_option, rounding=rounding, min_max=min_max, helptext=helptext) @@ -176,6 +179,12 @@ def is_radio(self): """ Return is_radio """ return self._options["is_radio"] + @property + def is_multi_option(self): + """ bool: ``True`` if the control should be contained in a multi check button group, + otherwise ``False``. """ + return self._options["is_multi_option"] + @property def rounding(self): """ Return rounding """ @@ -241,13 +250,15 @@ def set_initial_value(self, value): def get_control(self): """ Set the correct control type based on the datatype or for this option """ if self.choices and self.is_radio: - control = ttk.Radiobutton + control = "radio" + elif self.choices and self.is_multi_option: + control = "multi" elif self.choices: control = ttk.Combobox elif self.dtype == bool: control = ttk.Checkbutton elif self.dtype in (int, float): - control = ttk.Scale + control = "scale" else: control = ttk.Entry logger.debug("Setting control '%s' to %s", self.title, control) @@ -590,7 +601,9 @@ def compile_widget_config(self): "pack_info": self.pack_config_cleaner(child), "name": child.winfo_name(), "config": self.config_cleaner(child), - "children": self.get_all_children_config(child, [])} + "children": self.get_all_children_config(child, []), + # Some children have custom kwargs, so keep dicts in sync + "custom_kwargs": dict()} for idx, child in enumerate(children)] logger.debug("Compiled AutoFillContainer children: %s", self._widget_config) @@ -599,6 +612,15 @@ def get_all_children_config(self, widget, child_list): for child in widget.winfo_children(): if child.winfo_ismapped(): id_ = str(child) + if child.__class__.__name__ == "MultiOption": + # MultiOption checkbox groups are a custom object with additional parameter + # requirements. + custom_kwargs = dict( + value=child._value, # pylint:disable=protected-access + variable=child._master_variable) # pylint:disable=protected-access + else: + custom_kwargs = dict() + child_list.append({ "class": child.__class__, "id": id_, @@ -607,7 +629,8 @@ def get_all_children_config(self, widget, child_list): "pack_info": self.pack_config_cleaner(child), "name": child.winfo_name(), "config": self.config_cleaner(child), - "parent": child.winfo_parent()}) + "parent": child.winfo_parent(), + "custom_kwargs": custom_kwargs}) self.get_all_children_config(child, child_list) return child_list @@ -668,7 +691,9 @@ def pack_widget_clones(self, widget_dicts, old_children=None, new_children=None) else: # Get the next sub-frame if this doesn't have a logged parent parent = self.subframe - clone = widget_dict["class"](parent, name=widget_dict["name"]) + clone = widget_dict["class"](parent, + name=widget_dict["name"], + **widget_dict["custom_kwargs"]) if widget_dict["config"] is not None: clone.configure(**widget_dict["config"]) if widget_dict["tooltip"] is not None: @@ -746,7 +771,7 @@ def set_tk_var(self, blank_nones): def build_control(self): """ Build the correct control type for the option passed through """ logger.debug("Build config option control") - if self.option.control not in (ttk.Checkbutton, ttk.Radiobutton): + if self.option.control not in (ttk.Checkbutton, "radio", "multi"): self.build_control_label() self.build_one_control() logger.debug("Built option control") @@ -764,10 +789,10 @@ def build_control_label(self): def build_one_control(self): """ Build and place the option controls """ logger.debug("Build control: '%s')", self.option.name) - if self.option.control == ttk.Scale: + if self.option.control == "scale": ctl = self.slider_control() - elif self.option.control == ttk.Radiobutton: - ctl = self.radio_control() + elif self.option.control in ("radio", "multi"): + ctl = self._multi_option_control(self.option.control) elif self.option.control == ttk.Checkbutton: ctl = self.control_to_checkframe() else: @@ -779,35 +804,65 @@ def build_one_control(self): logger.debug("Built control: '%s'", self.option.name) - def radio_control(self): - """ Create a group of radio buttons """ - logger.debug("Adding radio group: %s", self.option.name) - all_help = [line for line in self.option.helptext.splitlines()] - if any(line.startswith(" - ") for line in all_help): - intro = all_help[0] - helpitems = {re.sub(r'[^A-Za-z0-9\-]+', '', - line.split()[1].lower()): " ".join(line.split()[1:]) - for line in all_help - if line.startswith(" - ")} + def _multi_option_control(self, option_type): + """ Create a group of buttons for single or multi-select + + Parameters + ---------- + option_type: {"radio", "multi"} + The type of boxes that this control should hold. "radio" for single item select, + "multi" for multi item select. + + """ + logger.debug("Adding %s group: %s", option_type, self.option.name) + help_intro, help_items = self._get_multi_help_items(self.option.helptext) ctl = ttk.LabelFrame(self.frame, text=self.option.title, - name="radio_labelframe") - radio_holder = AutoFillContainer(ctl, self.option_columns, self.option_columns) + name="{}_labelframe".format(option_type)) + holder = AutoFillContainer(ctl, self.option_columns, self.option_columns) for choice in self.option.choices: - radio = ttk.Radiobutton(radio_holder.subframe, - text=choice.replace("_", " ").title(), - value=choice, - variable=self.option.tk_var) - if choice.lower() in helpitems: + ctl = ttk.Radiobutton if option_type == "radio" else MultiOption + ctl = ctl(holder.subframe, + text=choice.replace("_", " ").title(), + value=choice, + variable=self.option.tk_var) + if choice.lower() in help_items: self.helpset = True - helptext = helpitems[choice.lower()].capitalize() + helptext = help_items[choice.lower()].capitalize() helptext = "{}\n\n - {}".format( '. '.join(item.capitalize() for item in helptext.split('. ')), - intro) - _get_tooltip(radio, text=helptext, wraplength=600) - radio.pack(anchor=tk.W) - logger.debug("Added radio option %s", choice) - return radio_holder.parent + help_intro) + _get_tooltip(ctl, text=helptext, wraplength=600) + ctl.pack(anchor=tk.W) + logger.debug("Added %s option %s", option_type, choice) + return holder.parent + + @staticmethod + def _get_multi_help_items(helptext): + """ Split the help text up, for formatted help text, into the individual options + for multi/radio buttons. + + Parameters + ---------- + helptext: str + The raw help text for this cli. option + + Returns + ------- + tuple (`str`, `dict`) + The help text intro and a dictionary containing the help text split into separate + entries for each option choice + """ + logger.debug("raw help: %s", helptext) + all_help = helptext.splitlines() + intro = "" + if any(line.startswith(" - ") for line in all_help): + intro = all_help[0] + retval = (intro, {re.sub(r'[^A-Za-z0-9\-]+', '', + line.split()[1].lower()): " ".join(line.split()[1:]) + for line in all_help if line.startswith(" - ")}) + logger.debug("help items: %s", retval) + return retval def slider_control(self): """ A slider control with corresponding Entry box """ @@ -829,7 +884,7 @@ def slider_control(self): d_type=self.option.dtype, round_to=self.option.rounding, min_max=self.option.min_max) - ctl = self.option.control(self.frame, variable=self.option.tk_var, command=cmd) + ctl = ttk.Scale(self.frame, variable=self.option.tk_var, command=cmd) _add_command(ctl.cget("command"), cmd) rc_menu = _get_contextmenu(tbox) rc_menu.cm_bind() @@ -885,7 +940,7 @@ def control_to_optionsframe(self): rc_menu.cm_bind() if self.option.choices: logger.debug("Adding combo choices: %s", self.option.choices) - ctl["values"] = [choice for choice in self.option.choices] + ctl["values"] = self.option.choices ctl["state"] = "readonly" logger.debug("Added control to Options Frame: %s", self.option.name) return ctl diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index a5d6b7fef4..8228bb25bd 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -679,3 +679,84 @@ def _hide(self): if topwidget: topwidget.destroy() self._topwidget = None + + +class MultiOption(ttk.Checkbutton): # pylint: disable=too-many-ancestors + """ Similar to the standard :class:`ttk.Radio` widget, but with the ability to select + multiple pre-defined options. Selected options are generated as `nargs` for the argument + parser to consume. + + Parameters + ---------- + parent: :class:`ttk.Frame` + The tkinter parent widget for the check button + value: str + The raw option value for this check button + variable: :class:`tkinter.StingVar` + The master variable for the group of check buttons that this check button will belong to. + The output of this variable will be a string containing a space separated list of the + selected check button options + """ + def __init__(self, parent, value, variable, **kwargs): + self._tk_var = tk.BooleanVar() + self._tk_var.set(False) + super().__init__(parent, variable=self._tk_var, **kwargs) + self._value = value + self._master_variable = variable + self._tk_var.trace("w", self._on_update) + self._master_variable.trace("w", self._on_master_update) + + @property + def _master_list(self): + """ list: The contents of the check box group's :attr:`_master_variable` in list form. + Selected check boxes will appear in this list. """ + retval = self._master_variable.get().split() + logger.trace(retval) + return retval + + @property + def _master_needs_update(self): + """ bool: ``True`` if :attr:`_master_variable` requires updating otherwise ``False``. """ + active = self._tk_var.get() + retval = ((active and self._value not in self._master_list) or + (not active and self._value in self._master_list)) + logger.trace(retval) + return retval + + def _on_update(self, *args): # pylint: disable=unused-argument + """ Update the master variable on a check button change. + + The value for this checked option is added or removed from the :attr:`_master_variable` + on a ``True``, ``False`` change for this check button. + + Parameters + ---------- + args: tuple + Required for variable callback, but unused + """ + if not self._master_needs_update: + return + new_vals = self._master_list + [self._value] if self._tk_var.get() else [ + val + for val in self._master_list + if val != self._value] + val = " ".join(new_vals) + logger.trace("Setting master variable to: %s", val) + self._master_variable.set(val) + + def _on_master_update(self, *args): # pylint: disable=unused-argument + """ Update the check button on a master variable change (e.g. load .fsw file in the GUI). + + The value for this option is set to ``True`` or ``False`` depending on it's existence in + the :attr:`_master_variable` + + Parameters + ---------- + args: tuple + Required for variable callback, but unused + """ + if not self._master_needs_update: + return + state = self._value in self._master_list + logger.trace("Setting '%s' to %s", self._value, state) + self._tk_var.set(state) diff --git a/lib/gui/options.py b/lib/gui/options.py index 89a982e99e..a207c43bcc 100644 --- a/lib/gui/options.py +++ b/lib/gui/options.py @@ -9,7 +9,7 @@ import sys from collections import OrderedDict -from lib import cli +from lib.cli import actions, args as cli from .utils import get_images from .control_helper import ControlPanelOption @@ -121,7 +121,8 @@ def process_options(self, command_options, command): group=opt.get("group", None), default=opt.get("default", None), choices=opt.get("choices", None), - is_radio=opt.get("action", "") == cli.Radio, + is_radio=opt.get("action", "") == actions.Radio, + is_multi_option=opt.get("action", "") == actions.MultiOption, rounding=self.get_rounding(opt), min_max=opt.get("min_max", None), sysbrowser=self.get_sysbrowser(opt, command_options, command), @@ -167,13 +168,12 @@ def get_rounding(opt): def get_sysbrowser(self, option, options, command): """ Return the system file browser and file types if required else None """ action = option.get("action", None) - if action not in (cli.FullPaths, - cli.DirFullPaths, - cli.FileFullPaths, - cli.FilesFullPaths, - cli.DirOrFileFullPaths, - cli.SaveFileFullPaths, - cli.ContextFullPaths): + if action not in (actions.DirFullPaths, + actions.FileFullPaths, + actions.FilesFullPaths, + actions.DirOrFileFullPaths, + actions.SaveFileFullPaths, + actions.ContextFullPaths): return None retval = dict() @@ -182,15 +182,15 @@ def get_sysbrowser(self, option, options, command): self.expand_action_option(option, options) action_option = option["action_option"] retval["filetypes"] = option.get("filetypes", "default") - if action == cli.FileFullPaths: + if action == actions.FileFullPaths: retval["browser"] = ["load"] - elif action == cli.FilesFullPaths: + elif action == actions.FilesFullPaths: retval["browser"] = ["multi_load"] - elif action == cli.SaveFileFullPaths: + elif action == actions.SaveFileFullPaths: retval["browser"] = ["save"] - elif action == cli.DirOrFileFullPaths: + elif action == actions.DirOrFileFullPaths: retval["browser"] = ["folder", "load"] - elif action == cli.ContextFullPaths and action_option: + elif action == actions.ContextFullPaths and action_option: retval["browser"] = ["context"] retval["command"] = command retval["action_option"] = action_option diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index f9e2d2baf3..cadf2b5ba3 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -43,8 +43,9 @@ class Extractor(): The name of a detector plugin as exists in :mod:`plugins.extract.detect` aligner: str The name of an aligner plugin as exists in :mod:`plugins.extract.align` - masker: str - The name of a masker plugin as exists in :mod:`plugins.extract.mask` + masker: str or list + The name of a masker plugin(s) as exists in :mod:`plugins.extract.mask`. + This can be a single masker or a list of multiple maskers configfile: str, optional The path to a custom ``extract.ini`` configfile. If ``None`` then the system :file:`config/extract.ini` file will be used. @@ -65,7 +66,7 @@ class Extractor(): images fed to the aligner.Default: ``None`` image_is_aligned: bool, optional Used to set the :attr:`plugins.extract.mask.image_is_aligned` attribute. Indicates to the - masker that the fed in image is an aligned face rather than a frame.Default: ``False`` + masker that the fed in image is an aligned face rather than a frame. Default: ``False`` Attributes ---------- diff --git a/scripts/convert.py b/scripts/convert.py index 23da2a0942..bdc28dfb27 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -149,7 +149,7 @@ def _add_queues(self): def process(self): """ The entry point for triggering the Conversion Process. - Should only be called from :class:`lib.cli.ScriptExecutor` + Should only be called from :class:`lib.cli.launcher.ScriptExecutor` """ logger.debug("Starting Conversion") # queue_manager.debug_monitor(5) diff --git a/scripts/extract.py b/scripts/extract.py index 4f5026250a..71a284f8ad 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -38,7 +38,6 @@ class Extract(): # pylint:disable=too-few-public-methods def __init__(self, arguments): logger.debug("Initializing %s: (args: %s", self.__class__.__name__, arguments) self._args = arguments - self._output_dir = str(get_folder(self._args.output_dir)) logger.info("Output Directory: %s", self._args.output_dir) @@ -51,9 +50,12 @@ def __init__(self, arguments): self._post_process = PostProcess(arguments) configfile = self._args.configfile if hasattr(self._args, "configfile") else None normalization = None if self._args.normalization == "none" else self._args.normalization + + maskers = ["components", "extended"] + maskers += self._args.masker if self._args.masker else [] self._extractor = Extractor(self._args.detector, self._args.aligner, - [self._args.masker, "components", "extended"], + maskers, configfile=configfile, multiprocess=not self._args.singleprocess, rotate_images=self._args.rotate_images, @@ -106,7 +108,7 @@ def _set_skip_list(self): def process(self): """ The entry point for triggering the Extraction Process. - Should only be called from :class:`lib.cli.ScriptExecutor` + Should only be called from :class:`lib.cli.launcher.ScriptExecutor` """ logger.info('Starting, this may take a while...') # from lib.queue_manager import queue_manager ; queue_manager.debug_monitor(3) diff --git a/scripts/train.py b/scripts/train.py index 7976d43567..fecc53dfee 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -139,7 +139,7 @@ def _get_images(self): def process(self): """ The entry point for triggering the Training Process. - Should only be called from :class:`lib.cli.ScriptExecutor` + Should only be called from :class:`lib.cli.launcher.ScriptExecutor` """ logger.debug("Starting Training Process") logger.info("Training data directory: %s", self._args.model_dir) diff --git a/tools.py b/tools.py index ab690bf6d5..d842885a4a 100755 --- a/tools.py +++ b/tools.py @@ -6,7 +6,7 @@ from importlib import import_module # Importing the various tools -from lib.cli import FullHelpArgumentParser +from lib.cli.args import FullHelpArgumentParser # Python version check if sys.version_info[0] < 3: diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index a6c6b8acf3..fb103c335d 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Tools for manipulating the alignments seralized file """ +""" Tools for manipulating the alignments serialized file """ import sys import logging diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py index 9b86ab019b..60dd6f9291 100644 --- a/tools/alignments/cli.py +++ b/tools/alignments/cli.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ -from lib.cli import FaceSwapArgs -from lib.cli import DirOrFileFullPaths, DirFullPaths, FilesFullPaths, Radio, Slider +from lib.cli.args import FaceSwapArgs +from lib.cli.actions import DirOrFileFullPaths, DirFullPaths, FilesFullPaths, Radio, Slider _HELPTEXT = "This command lets you perform various tasks pertaining to an alignments file." diff --git a/tools/effmpeg/cli.py b/tools/effmpeg/cli.py index 1ff08fc1f9..7af0aca93b 100644 --- a/tools/effmpeg/cli.py +++ b/tools/effmpeg/cli.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ -from lib.cli import FaceSwapArgs -from lib.cli import ContextFullPaths, FileFullPaths, Radio +from lib.cli.args import FaceSwapArgs +from lib.cli.actions import ContextFullPaths, FileFullPaths, Radio from lib.utils import _image_extensions _HELPTEXT = "This command allows you to easily execute common ffmpeg tasks." diff --git a/tools/mask/cli.py b/tools/mask/cli.py index 491cbab6e6..5407c4ef83 100644 --- a/tools/mask/cli.py +++ b/tools/mask/cli.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ -from lib.cli import FaceSwapArgs -from lib.cli import (DirOrFileFullPaths, DirFullPaths, FileFullPaths, Radio, Slider) +from lib.cli.args import FaceSwapArgs +from lib.cli.actions import (DirOrFileFullPaths, DirFullPaths, FileFullPaths, Radio, Slider) from plugins.plugin_loader import PluginLoader _HELPTEXT = "This command lets you generate masks for existing alignments." diff --git a/tools/preview/cli.py b/tools/preview/cli.py index 2bd04e29f0..4e767cc149 100644 --- a/tools/preview/cli.py +++ b/tools/preview/cli.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ -from lib.cli import FaceSwapArgs -from lib.cli import DirOrFileFullPaths, DirFullPaths, FileFullPaths +from lib.cli.args import FaceSwapArgs +from lib.cli.actions import DirOrFileFullPaths, DirFullPaths, FileFullPaths _HELPTEXT = "This command allows you to preview swaps to tweak convert settings." diff --git a/tools/preview/preview.py b/tools/preview/preview.py index ddd8e81260..0fbe3b7d23 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -16,7 +16,7 @@ from PIL import Image, ImageTk from lib.aligner import Extract as AlignerExtract -from lib.cli import ConvertArgs +from lib.cli.args import ConvertArgs from lib.gui.utils import get_images, get_config, initialize_config, initialize_images from lib.gui.custom_widgets import Tooltip from lib.gui.control_helper import ControlPanel, ControlPanelOption diff --git a/tools/restore/cli.py b/tools/restore/cli.py index 52f1b95c6d..4376eefb8a 100644 --- a/tools/restore/cli.py +++ b/tools/restore/cli.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ -from lib.cli import FaceSwapArgs -from lib.cli import DirFullPaths +from lib.cli.args import FaceSwapArgs +from lib.cli.actions import DirFullPaths _HELPTEXT = "This command lets you restore models from backup." diff --git a/tools/sort/cli.py b/tools/sort/cli.py index 8d9abe84c3..c2f27c1761 100644 --- a/tools/sort/cli.py +++ b/tools/sort/cli.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ -from lib.cli import FaceSwapArgs -from lib.cli import DirFullPaths, SaveFileFullPaths, Radio, Slider +from lib.cli.args import FaceSwapArgs +from lib.cli.actions import DirFullPaths, SaveFileFullPaths, Radio, Slider _HELPTEXT = "This command lets you sort images using various methods." From 3f0a0168660de11a6e9edf1d1ccd3aa5133884ea Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 23 Apr 2020 13:50:23 +0100 Subject: [PATCH 234/981] bugfix: Extract - raise error if Keras fails to import --- lib/utils.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/utils.py b/lib/utils.py index 272c44f3f3..f93da65f13 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -126,7 +126,7 @@ def get_image_paths(directory): def convert_to_secs(*args): """ converts a time to second. Either convert_to_secs(min, secs) or - convert_to_secs(hours, mins, secs). """ + convert_to_secs(hours, minutes, secs). """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name logger.debug("from time: %s", args) retval = 0.0 @@ -174,11 +174,16 @@ def backup_file(directory, filename): def keras_backend_quiet(): - """ Suppresses the "Using x backend" message when importing - backend from keras """ + """ Suppresses the "Using x backend" message when importing backend from keras. + + Make sure output is redirected back to stderr if there is an error. """ stderr = sys.stderr sys.stderr = open(os.devnull, 'w') - from keras import backend as K + try: + from keras import backend as K # pylint:disable=import-outside-toplevel + except: + sys.stderr = stderr + raise sys.stderr = stderr return K From 2b6601382f652d83068f596a9c9b5ec99cd7808a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 23 Apr 2020 14:29:32 +0100 Subject: [PATCH 235/981] Bugfix: logger - Still output crash report if system information fails to load --- lib/logger.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/logger.py b/lib/logger.py index 21e3ef2156..66e77550f5 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -170,14 +170,18 @@ def get_loglevel(loglevel): def crash_log(): """ Write debug_buffer to a crash log on crash """ - from lib.sysinfo import sysinfo + original_traceback = traceback.format_exc() path = os.path.dirname(os.path.realpath(sys.argv[0])) filename = os.path.join(path, datetime.now().strftime("crash_report.%Y.%m.%d.%H%M%S%f.log")) - freeze_log = list(debug_buffer) + try: + from lib.sysinfo import sysinfo # pylint:disable=import-outside-toplevel + except Exception: # pylint:disable=broad-except + sysinfo = ("\n\nThere was an error importing System Information from lib.sysinfo. This is " + "probably a bug which should be fixed:\n{}".format(traceback.format_exc())) with open(filename, "w") as outfile: outfile.writelines(freeze_log) - traceback.print_exc(file=outfile) + outfile.write(original_traceback) outfile.write(sysinfo) return filename From cbba53ea67cb48d8886041ae594ca2dfda48a4c8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 24 Apr 2020 16:41:27 +0100 Subject: [PATCH 236/981] Core Updates (#1015) - Remove lib.utils.keras_backend_quiet and replace with get_backend() where relevant - Document lib.gpu_stats and lib.sys_info - Remove call to GPUStats.is_plaidml from convert and replace with get_backend() - lib.gui.menu - typofix --- docs/full/lib/gpu_stats.rst | 7 + docs/full/lib/sysinfo.rst | 7 + lib/gpu_stats.py | 418 +++++++++++++++++++------------- lib/gui/menu.py | 2 +- lib/sysinfo.py | 467 ++++++++++++++++++++++-------------- lib/utils.py | 38 +-- scripts/convert.py | 8 +- 7 files changed, 573 insertions(+), 374 deletions(-) create mode 100755 docs/full/lib/gpu_stats.rst create mode 100755 docs/full/lib/sysinfo.rst diff --git a/docs/full/lib/gpu_stats.rst b/docs/full/lib/gpu_stats.rst new file mode 100755 index 0000000000..6535ee23a6 --- /dev/null +++ b/docs/full/lib/gpu_stats.rst @@ -0,0 +1,7 @@ +gpu\_stats module +================= + +.. automodule:: lib.gpu_stats + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib/sysinfo.rst b/docs/full/lib/sysinfo.rst new file mode 100755 index 0000000000..409c310b78 --- /dev/null +++ b/docs/full/lib/sysinfo.rst @@ -0,0 +1,7 @@ +sysinfo module +============== + +.. automodule:: lib.sysinfo + :members: + :undoc-members: + :show-inheritance: diff --git a/lib/gpu_stats.py b/lib/gpu_stats.py index 5024d0ee1e..72e64d8843 100644 --- a/lib/gpu_stats.py +++ b/lib/gpu_stats.py @@ -1,13 +1,17 @@ #!/usr/bin python3 -""" Information on available Nvidia GPUs """ +""" Collects and returns Information on available GPUs. + +The information returned from this module provides information for both Nvidia and AMD GPUs. +However, the information available for Nvidia is far more thorough than what is available for +AMD, where we need to plug into plaidML to pull stats. The quality of this data will vary +depending on the OS' particular OpenCL implementation. +""" import logging import os import platform -from lib.utils import keras_backend_quiet - -K = keras_backend_quiet() +from lib.utils import get_backend if platform.system() == 'Darwin': import pynvx # pylint: disable=import-error @@ -24,70 +28,128 @@ class GPUStats(): - """ Holds information about system GPU(s) """ + """ Holds information and statistics about the GPU(s) available on the currently + running system. + + Parameters + ---------- + log: bool, optional + Whether the class should output information to the logger. There may be occasions where the + logger has not yet been set up when this class is queried. Attempting to log in these + instances will raise an error. If GPU stats are being queried prior to the logger being + available then this parameter should be set to ``False``. Otherwise set to ``True``. + Default: ``True`` + """ def __init__(self, log=True): - self.logger = None - if log: - # Logger is held internally, as we don't want to log - # when obtaining system stats on crash - self.logger = logging.getLogger(__name__) # pylint: disable=invalid-name - self.logger.debug("Initializing %s", self.__class__.__name__) - - self.plaid = None - self.initialized = False - self.device_count = 0 - self.active_devices = list() - self.handles = list() - self.driver = None - self.devices = list() - self.vram = None - - self.initialize(log) - - self.driver = self.get_driver() - self.devices = self.get_devices() - self.vram = self.get_vram() - if not self.active_devices: - if self.logger: - self.logger.warning("No GPU detected. Switching to CPU mode") + # Logger is held internally, as we don't want to log when obtaining system stats on crash + self._logger = logging.getLogger(__name__) if log else None + self._log("debug", "Initializing {}".format(self.__class__.__name__)) + + self._plaid = None + self._initialized = False + self._device_count = 0 + self._active_devices = list() + self._handles = list() + self._driver = None + self._devices = list() + self._vram = None + + self._initialize(log) + + self._driver = self._get_driver() + self._devices = self._get_devices() + self._vram = self._get_vram() + if not self._active_devices: + self._log("warning", "No GPU detected. Switching to CPU mode") return - self.shutdown() - if self.logger: - self.logger.debug("Initialized %s", self.__class__.__name__) + self._shutdown() + self._log("debug", "Initialized {}".format(self.__class__.__name__)) + + @property + def device_count(self): + """int: The number of GPU devices discovered on the system. """ + return self._device_count + + @property + def _is_plaidml(self): + """ bool: ``True`` if the backend is plaidML otherwise ``False``. """ + return self._plaid is not None @property - def is_plaidml(self): - """ Return whether running on plaidML backend """ - return self.plaid is not None - - def initialize(self, log=False): - """ Initialize pynvml """ - if not self.initialized: - if K.backend() == "plaidml.keras.backend": - loglevel = "INFO" - if self.logger: - self.logger.debug("plaidML Detected. Using plaidMLStats") - loglevel = self.logger.getEffectiveLevel() - self.plaid = plaidlib(loglevel=loglevel, log=log) + def sys_info(self): + """ dict: GPU Stats that are required for system information logging. + + The dictionary contains the following data: + + **vram** (`list`): the total amount of VRAM in Megabytes for each GPU as pertaining to + :attr:`_handles` + + **driver** (`str`): The GPU driver version that is installed on the OS + + **devices** (`list`): The device name of each GPU on the system as pertaining + to :attr:`_handles` + + **devices_active** (`list`): The device name of each active GPU on the system as + pertaining to :attr:`_handles` + """ + return dict(vram=self._vram, + driver=self._driver, + devices=self._devices, + devices_active=self._active_devices) + + def _log(self, level, message): + """ If the class has been initialized with :attr:`log` as `True` then log the message + otherwise skip logging. + + Parameters + ---------- + level: str + The log level to log at + message: str + The message to log + """ + if self._logger is None: + return + logger = getattr(self._logger, level.lower()) + logger(message) + + def _initialize(self, log=False): + """ Initialize the library that will be returning stats for the system's GPU(s). + For Nvidia (on Linux and Windows) the library is `pynvml`. For Nvidia (on macOS) the + library is `pynvx`. For AMD `plaidML` is used. + + Parameters + ---------- + log: bool, optional + Whether the class should output information to the logger. There may be occasions where + the logger has not yet been set up when this class is queried. Attempting to log in + these instances will raise an error. If GPU stats are being queried prior to the + logger being available then this parameter should be set to ``False``. Otherwise set + to ``True``. Default: ``False`` + """ + if not self._initialized: + if get_backend() == "amd": + self._log("debug", "AMD Detected. Using plaidMLStats") + loglevel = "INFO" if self._logger is None else self._logger.getEffectiveLevel() + self._plaid = plaidlib(loglevel=loglevel, log=log) elif IS_MACOS: - if self.logger: - self.logger.debug("macOS Detected. Using pynvx") + self._log("debug", "macOS Detected. Using pynvx") try: pynvx.cudaInit() except RuntimeError: - self.initialized = True + self._initialized = True return else: try: - if self.logger: - self.logger.debug("OS is not macOS. Using pynvml") + self._log("debug", "OS is not macOS. Trying pynvml") pynvml.nvmlInit() except (pynvml.NVMLError_LibraryNotFound, # pylint: disable=no-member pynvml.NVMLError_DriverNotLoaded, # pylint: disable=no-member pynvml.NVMLError_NoPermission) as err: # pylint: disable=no-member if plaidlib is not None: - self.plaid = plaidlib(log=log) + self._log("debug", "pynvml errored. Trying plaidML") + self._plaid = plaidlib(log=log) else: msg = ("There was an error reading from the Nvidia Machine Learning " "Library. Either you do not have an Nvidia GPU (in which case " @@ -95,77 +157,83 @@ def initialize(self, log=False): "incorrectly installed drivers. If this is the case, Please remove " "and reinstall your Nvidia drivers before reporting." "Original Error: {}".format(str(err))) - if self.logger: - self.logger.warning(msg) - self.initialized = True + self._log("warning", msg) + self._initialized = True return except Exception as err: # pylint: disable=broad-except msg = ("An unhandled exception occured loading pynvml. " "Original error: {}".format(str(err))) - if self.logger: - self.logger.error(msg) + if self._logger: + self._logger.error(msg) else: print(msg) - self.initialized = True + self._initialized = True return - self.initialized = True - self.get_device_count() - self.get_active_devices() - self.get_handles() - - def shutdown(self): - """ Shutdown pynvml """ - if self.initialized: - self.handles = list() - if not IS_MACOS and not self.plaid: + self._initialized = True + self._get_device_count() + self._get_active_devices() + self._get_handles() + + def _shutdown(self): + """ Shutdown pynvml if it was the library used for obtaining stats and set + :attr:`_initialized` back to ``False``. """ + if self._initialized: + self._handles = list() + if not IS_MACOS and not self._is_plaidml: pynvml.nvmlShutdown() - self.initialized = False + self._initialized = False - def get_device_count(self): - """ Return count of Nvidia devices """ - if self.plaid is not None: - self.device_count = self.plaid.device_count + def _get_device_count(self): + """ Detect the number of GPUs attached to the system and allocate to + :attr:`_device_count`. """ + if self._is_plaidml: + self._device_count = self._plaid.device_count elif IS_MACOS: - self.device_count = pynvx.cudaDeviceGetCount(ignore=True) + self._device_count = pynvx.cudaDeviceGetCount(ignore=True) else: try: - self.device_count = pynvml.nvmlDeviceGetCount() + self._device_count = pynvml.nvmlDeviceGetCount() except pynvml.NVMLError: - self.device_count = 0 - if self.logger: - self.logger.debug("GPU Device count: %s", self.device_count) - - def get_active_devices(self): - """ Return list of active Nvidia devices """ - if self.plaid is not None: - self.active_devices = self.plaid.active_devices + self._device_count = 0 + self._log("debug", "GPU Device count: {}".format(self._device_count)) + + def _get_active_devices(self): + """ Obtain the indices of active GPUs (those that have not been explicitly excluded by + CUDA_VISIBLE_DEVICES or plaidML) and allocate to :attr:`_active_devices`. """ + if self._is_plaidml: + self._active_devices = self._plaid.active_devices else: devices = os.environ.get("CUDA_VISIBLE_DEVICES", None) - if self.device_count == 0: - self.active_devices = list() + if self._device_count == 0: + self._active_devices = list() elif devices is not None: - self.active_devices = [int(i) for i in devices.split(",") if devices] + self._active_devices = [int(i) for i in devices.split(",") if devices] else: - self.active_devices = list(range(self.device_count)) - if self.logger: - self.logger.debug("Active GPU Devices: %s", self.active_devices) - - def get_handles(self): - """ Return all listed Nvidia handles """ - if self.plaid is not None: - self.handles = self.plaid.devices + self._active_devices = list(range(self._device_count)) + self._log("debug", "Active GPU Devices: {}".format(self._active_devices)) + + def _get_handles(self): + """ Obtain the internal handle identifiers for the system GPUs and allocate to + :attr:`_handles`. """ + if self._is_plaidml: + self._handles = self._plaid.devices elif IS_MACOS: - self.handles = pynvx.cudaDeviceGetHandles(ignore=True) + self._handles = pynvx.cudaDeviceGetHandles(ignore=True) else: - self.handles = [pynvml.nvmlDeviceGetHandleByIndex(i) - for i in range(self.device_count)] - if self.logger: - self.logger.debug("GPU Handles found: %s", len(self.handles)) - - def get_driver(self): - """ Get the driver version """ - if self.plaid is not None: - driver = self.plaid.drivers + self._handles = [pynvml.nvmlDeviceGetHandleByIndex(i) + for i in range(self._device_count)] + self._log("debug", "GPU Handles found: {}".format(len(self._handles))) + + def _get_driver(self): + """ Obtain and return the installed driver version for the system's GPUs. + + Returns + ------- + str + The currently installed GPU driver version + """ + if self._is_plaidml: + driver = self._plaid.drivers elif IS_MACOS: driver = pynvx.cudaSystemGetDriverVersion(ignore=True) else: @@ -173,100 +241,116 @@ def get_driver(self): driver = pynvml.nvmlSystemGetDriverVersion().decode("utf-8") except pynvml.NVMLError: driver = "No Nvidia driver found" - if self.logger: - self.logger.debug("GPU Driver: %s", driver) + self._log("debug", "GPU Driver: {}".format(driver)) return driver - def get_devices(self): - """ Return name of devices """ - self.initialize() - if self.device_count == 0: + def _get_devices(self): + """ Obtain the name of the installed devices. The quality of this information depends on + the backend and OS being used, but it should be sufficient for identifying cards. + + Returns + ------- + list + List of device names for connected GPUs as corresponding to the values in + :attr:`_handles` + """ + self._initialize() + if self._device_count == 0: names = list() - if self.plaid is not None: - names = self.plaid.names + if self._is_plaidml: + names = self._plaid.names elif IS_MACOS: names = [pynvx.cudaGetName(handle, ignore=True) - for handle in self.handles] + for handle in self._handles] else: names = [pynvml.nvmlDeviceGetName(handle).decode("utf-8") - for handle in self.handles] - if self.logger: - self.logger.debug("GPU Devices: %s", names) + for handle in self._handles] + self._log("debug", "GPU Devices: {}".format(names)) return names - def get_vram(self): - """ Return total vram in megabytes per device """ - self.initialize() - if self.device_count == 0: + def _get_vram(self): + """ Obtain the total VRAM in Megabytes for each connected GPU. + + Returns + ------- + list + List of floats containing the total amount of VRAM in Megabytes for each connected GPU + as corresponding to the values in :attr:`_handles + """ + self._initialize() + if self._device_count == 0: vram = list() - elif self.plaid: - vram = self.plaid.vram + elif self._is_plaidml: + vram = self._plaid.vram elif IS_MACOS: vram = [pynvx.cudaGetMemTotal(handle, ignore=True) / (1024 * 1024) - for handle in self.handles] + for handle in self._handles] else: vram = [pynvml.nvmlDeviceGetMemoryInfo(handle).total / (1024 * 1024) - for handle in self.handles] - if self.logger: - self.logger.debug("GPU VRAM: %s", vram) + for handle in self._handles] + self._log("debug", "GPU VRAM: {}".format(vram)) return vram - def get_used(self): - """ Return the vram in use """ - self.initialize() - if self.plaid: - # NB There is no useful way to get allocated VRAM on PlaidML. - # OpenCL loads and unloads VRAM as required, so this returns 0 - # It's not particularly useful - vram = [0 for idx in range(self.device_count)] + def _get_free_vram(self): + """ Obtain the amount of VRAM that is available, in Megabytes, for each connected GPU. - elif IS_MACOS: - vram = [pynvx.cudaGetMemUsed(handle, ignore=True) / (1024 * 1024) - for handle in self.handles] - else: - vram = [pynvml.nvmlDeviceGetMemoryInfo(handle).used / (1024 * 1024) - for handle in self.handles] - self.shutdown() + Returns + ------- + list + List of floats containing the amount of VRAM available, in Megabytes, for each + connected GPU as corresponding to the values in :attr:`_handles - if self.logger: - self.logger.verbose("GPU VRAM used: %s", vram) - return vram + Notes + ----- + There is no useful way to get free VRAM on PlaidML. OpenCL loads and unloads VRAM as + required, so this returns the total memory available per card for AMD cards, which us + not particularly useful. - def get_free(self): - """ Return the vram available """ - self.initialize() - if self.plaid: - # NB There is no useful way to get free VRAM on PlaidML. - # OpenCL loads and unloads VRAM as required, so this returns the total memory - # It's not particularly useful - vram = self.plaid.vram + """ + self._initialize() + if self._is_plaidml: + vram = self._plaid.vram elif IS_MACOS: vram = [pynvx.cudaGetMemFree(handle, ignore=True) / (1024 * 1024) - for handle in self.handles] + for handle in self._handles] else: vram = [pynvml.nvmlDeviceGetMemoryInfo(handle).free / (1024 * 1024) - for handle in self.handles] - self.shutdown() - if self.logger: - self.logger.debug("GPU VRAM free: %s", vram) + for handle in self._handles] + self._shutdown() + self._log("debug", "GPU VRAM free: {}".format(vram)) return vram - def get_card_most_free(self, supports_plaidml=True): - """ Return the card and available VRAM for active card with - most VRAM free """ - if self.device_count == 0 or (self.is_plaidml and not supports_plaidml): + def get_card_most_free(self): + """ Obtain statistics for the GPU with the most available free VRAM. + + Returns + ------- + dict + The dictionary contains the following data: + + **card_id** (`int`): The index of the card as pertaining to :attr:`_handles` + + **device** (`str`): The name of the device + + **free** (`float`): The amount of available VRAM on the GPU + + **total** (`float`): the total amount of VRAM on the GPU + + If a GPU is not detected then the **card_id** is returned as ``-1`` and the amount + of free and total RAM available is fixed to 2048 Megabytes. + """ + if self._device_count == 0: return {"card_id": -1, - "device": "No Nvidia devices found", + "device": "No GPU devices found", "free": 2048, "total": 2048} - free_vram = [self.get_free()[i] for i in self.active_devices] + free_vram = [self._get_free_vram()[i] for i in self._active_devices] vram_free = max(free_vram) - card_id = self.active_devices[free_vram.index(vram_free)] + card_id = self._active_devices[free_vram.index(vram_free)] retval = {"card_id": card_id, - "device": self.devices[card_id], + "device": self._devices[card_id], "free": vram_free, - "total": self.vram[card_id]} - if self.logger: - self.logger.debug("Active GPU Card with most free VRAM: %s", retval) + "total": self._vram[card_id]} + self._log("debug", "Active GPU Card with most free VRAM: {}".format(retval)) return retval diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 7bbb687c0d..61e336e9d5 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -569,7 +569,7 @@ def set_help(btntype): if btntype.startswith("reload"): hlp = "Reload {} from disk".format(task) if btntype == "new": - hlp = "Crate a new {}...".format(task) + hlp = "Create a new {}...".format(task) if btntype.startswith("clear"): hlp = "Reset {} to default".format(task) elif btntype.startswith("save") and "_" not in btntype: diff --git a/lib/sysinfo.py b/lib/sysinfo.py index bf3cb3c847..8abe965c01 100644 --- a/lib/sysinfo.py +++ b/lib/sysinfo.py @@ -1,5 +1,5 @@ #!/usr/bin python3 -""" Obtain information about the running system, environment and gpu """ +""" Obtain information about the running system, environment and GPU. """ import json import locale @@ -14,58 +14,52 @@ from lib.gpu_stats import GPUStats -class SysInfo(): - """ System and Python Information """ - # pylint: disable=too-many-instance-attributes,too-many-public-methods - +class _SysInfo(): + """ Obtain information about the System, Python and GPU """ def __init__(self): - gpu_stats = GPUStats(log=False) - self.state_file = State().state_file - self.configs = Configs().configs - self.platform = platform.platform() - self.system = platform.system() - self.machine = platform.machine() - self.release = platform.release() - self.processor = platform.processor() - self.cpu_count = os.cpu_count() - self.py_implementation = platform.python_implementation() - self.py_version = platform.python_version() - self._cuda_path = self.get_cuda_path() - self.vram = gpu_stats.vram - self.gfx_driver = gpu_stats.driver - self.gfx_devices = gpu_stats.devices - self.gfx_devices_active = gpu_stats.active_devices + self._state_file = _State().state_file + self._configs = _Configs().configs + self._system = dict(platform=platform.platform(), + system=platform.system(), + machine=platform.machine(), + release=platform.release(), + processor=platform.processor(), + cpu_count=os.cpu_count()) + self._python = dict(implementation=platform.python_implementation(), + version=platform.python_version()) + self._gpu = GPUStats(log=False).sys_info + self._cuda_path = self._get_cuda_path() @property - def encoding(self): - """ Return system preferred encoding """ + def _encoding(self): + """ str: The system preferred encoding """ return locale.getpreferredencoding() @property - def is_conda(self): - """ Boolean for whether in a conda environment """ + def _is_conda(self): + """ bool: `True` if running in a Conda environment otherwise ``False``. """ return ("conda" in sys.version.lower() or os.path.exists(os.path.join(sys.prefix, 'conda-meta'))) @property - def is_linux(self): - """ Boolean for whether system is Linux """ - return self.system.lower() == "linux" + def _is_linux(self): + """ bool: `True` if running on a Linux system otherwise ``False``. """ + return self._system["system"].lower() == "linux" @property - def is_macos(self): - """ Boolean for whether system is macOS """ - return self.system.lower() == "darwin" + def _is_macos(self): + """ bool: `True` if running on a macOS system otherwise ``False``. """ + return self._system["system"].lower() == "darwin" @property - def is_windows(self): - """ Boolean for whether system is Windows """ - return self.system.lower() == "windows" + def _is_windows(self): + """ bool: `True` if running on a Windows system otherwise ``False``. """ + return self._system["system"].lower() == "windows" @property - def is_virtual_env(self): - """ Boolean for whether running in a virtual environment """ - if not self.is_conda: + def _is_virtual_env(self): + """ bool: `True` if running inside a virtual environment otherwise ``False``. """ + if not self._is_conda: retval = (hasattr(sys, "real_prefix") or (hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix)) else: @@ -74,47 +68,42 @@ def is_virtual_env(self): return retval @property - def ram(self): - """ Return RAM stats """ - return psutil.virtual_memory() - - @property - def ram_free(self): - """ return free RAM """ - return getattr(self.ram, "free") + def _ram_free(self): + """ int: The amount of free RAM in bytes. """ + return psutil.virtual_memory().free @property - def ram_total(self): - """ return total RAM """ - return getattr(self.ram, "total") + def _ram_total(self): + """ int: The amount of total RAM in bytes. """ + return psutil.virtual_memory().total @property - def ram_available(self): - """ return available RAM """ - return getattr(self.ram, "available") + def _ram_available(self): + """ int: The amount of available RAM in bytes. """ + return psutil.virtual_memory().available @property - def ram_used(self): - """ return used RAM """ - return getattr(self.ram, "used") + def _ram_used(self): + """ int: The amount of used RAM in bytes. """ + return psutil.virtual_memory().used @property - def fs_command(self): - """ Return the executed faceswap command """ + def _fs_command(self): + """ str: The command line command used to execute faceswap. """ return " ".join(sys.argv) @property - def installed_pip(self): - """ Installed pip packages """ + def _installed_pip(self): + """ str: The list of installed pip packages within Faceswap's scope. """ pip = Popen("{} -m pip freeze".format(sys.executable), shell=True, stdout=PIPE) installed = pip.communicate()[0].decode().splitlines() return "\n".join(installed) @property - def installed_conda(self): - """ Installed Conda packages """ - if not self.is_conda: + def _installed_conda(self): + """ str: The list of installed Conda packages within Faceswap's scope. """ + if not self._is_conda: return None conda = Popen("conda list", shell=True, stdout=PIPE, stderr=PIPE) stdout, stderr = conda.communicate() @@ -124,9 +113,9 @@ def installed_conda(self): return "\n".join(installed) @property - def conda_version(self): - """ Get conda version """ - if not self.is_conda: + def _conda_version(self): + """ str: The installed version of Conda, or `N/A` if Conda is not installed. """ + if not self._is_conda: return "N/A" conda = Popen("conda --version", shell=True, stdout=PIPE, stderr=PIPE) stdout, stderr = conda.communicate() @@ -136,8 +125,8 @@ def conda_version(self): return "\n".join(version) @property - def git_branch(self): - """ Get the current git branch """ + def _git_branch(self): + """ str: The git branch that is currently being used to execute Faceswap. """ git = Popen("git status", shell=True, stdout=PIPE, stderr=PIPE) stdout, stderr = git.communicate() if stderr: @@ -146,8 +135,8 @@ def git_branch(self): return branch @property - def git_commits(self): - """ Get last 5 git commits """ + def _git_commits(self): + """ str: The last 5 git commits for the currently running Faceswap. """ git = Popen("git log --pretty=oneline --abbrev-commit -n 5", shell=True, stdout=PIPE, stderr=PIPE) stdout, stderr = git.communicate() @@ -157,41 +146,42 @@ def git_commits(self): return ". ".join(commits) @property - def cuda_keys_windows(self): - """ Return the OS Environ CUDA Keys for Windows """ + def _cuda_keys_windows(self): + """ list: The CUDA Path environment variables stored for Windows users. """ return [key for key in os.environ.keys() if key.lower().startswith("cuda_path_v")] @property - def cuda_version(self): - """ Get the installed CUDA version """ + def _cuda_version(self): + """ str: The installed CUDA version. """ + # TODO Handle multiple CUDA installs chk = Popen("nvcc -V", shell=True, stdout=PIPE, stderr=PIPE) stdout, stderr = chk.communicate() if not stderr: - version = re.search(r".*release (?P\d+\.\d+)", stdout.decode(self.encoding)) + version = re.search(r".*release (?P\d+\.\d+)", stdout.decode(self._encoding)) version = version.groupdict().get("cuda", None) if version: return version # Failed to load nvcc - if self.is_linux: - version = self.cuda_version_linux() - elif self.is_windows: - version = self.cuda_version_windows() + if self._is_linux: + version = self._cuda_version_linux() + elif self._is_windows: + version = self._cuda_version_windows() else: version = "Unsupported OS" - if self.is_conda: + if self._is_conda: version += ". Check Conda packages for Conda Cuda" return version @property - def cudnn_version(self): - """ Get the installed cuDNN version """ - if self.is_linux: - cudnn_checkfiles = self.cudnn_checkfiles_linux() - elif self.is_windows: - cudnn_checkfiles = self.cudnn_checkfiles_windows() + def _cudnn_version(self): + """ str: The installed cuDNN version. """ + if self._is_linux: + cudnn_checkfiles = self._cudnn_checkfiles_linux() + elif self._is_windows: + cudnn_checkfiles = self._cudnn_checkfiles_windows() else: retval = "Unsupported OS" - if self.is_conda: + if self._is_conda: retval += ". Check Conda packages for Conda cuDNN" return retval @@ -203,7 +193,7 @@ def cudnn_version(self): if not cudnn_checkfile: retval = "No global version found" - if self.is_conda: + if self._is_conda: retval += ". Check Conda packages for Conda cuDNN" return retval @@ -223,14 +213,19 @@ def cudnn_version(self): break if found != 3: retval = "No global version found" - if self.is_conda: + if self._is_conda: retval += ". Check Conda packages for Conda cuDNN" return retval return "{}.{}.{}".format(major, minor, patchlevel) @staticmethod - def cudnn_checkfiles_linux(): - """ Return the checkfile locations for linux """ + def _cudnn_checkfiles_linux(): + """ Obtain the location of the files to check for cuDNN location in Linux. + + Returns + str: + The location of the header files for cuDNN + """ chk = os.popen("ldconfig -p | grep -P \"libcudnn.so.\\d+\" | head -n 1").read() if "libcudnn.so." not in chk: return list() @@ -242,30 +237,47 @@ def cudnn_checkfiles_linux(): os.path.join(cudnn_path, "cudnn.h")] return cudnn_checkfiles - def cudnn_checkfiles_windows(self): - """ Return the checkfile locations for windows """ + def _cudnn_checkfiles_windows(self): + """ Obtain the location of the files to check for cuDNN location in Windows. + + Returns + str: + The location of the header files for cuDNN + """ # TODO A more reliable way of getting the windows location - if not self._cuda_path and not self.cuda_keys_windows: + if not self._cuda_path and not self._cuda_keys_windows: return list() if not self._cuda_path: - self._cuda_path = os.environ[self.cuda_keys_windows[0]] + self._cuda_path = os.environ[self._cuda_keys_windows[0]] cudnn_checkfile = os.path.join(self._cuda_path, "include", "cudnn.h") return [cudnn_checkfile] - def get_cuda_path(self): - """ Return the correct CUDA Path """ - if self.is_linux: - path = self.cuda_path_linux() - elif self.is_windows: - path = self.cuda_path_windows() + def _get_cuda_path(self): + """ Obtain the path to Cuda install location. + + Returns + ------- + str + The path to the install location of Cuda on the system + """ + if self._is_linux: + path = self._cuda_path_linux() + elif self._is_windows: + path = self._cuda_path_windows() else: path = None return path @staticmethod - def cuda_path_linux(): - """ Get the path to Cuda on linux systems """ + def _cuda_path_linux(): + """ Obtain the path to Cuda install location on Linux. + + Returns + ------- + str + The path to the install location of Cuda on a Linux system + """ ld_library_path = os.environ.get("LD_LIBRARY_PATH", None) chk = os.popen("ldconfig -p | grep -P \"libcudart.so.\\d+.\\d+\" | head -n 1").read() if ld_library_path and not chk: @@ -280,13 +292,24 @@ def cuda_path_linux(): return chk[chk.find("=>") + 3:chk.find("targets") - 1] @staticmethod - def cuda_path_windows(): - """ Get the path to Cuda on Windows systems """ + def _cuda_path_windows(): + """ Obtain the path to Cuda install location on Windows. + + Returns + ------- + str + The path to the install location of Cuda on a Windows system + """ cuda_path = os.environ.get("CUDA_PATH", None) return cuda_path - def cuda_version_linux(self): - """ Get CUDA version for linux systems """ + def _cuda_version_linux(self): + """ Obtain the installed version of Cuda on a Linux system. + + Returns + ------- + The installed CUDA version on a Linux system + """ ld_library_path = os.environ.get("LD_LIBRARY_PATH", None) chk = os.popen("ldconfig -p | grep -P \"libcudart.so.\\d+.\\d+\" | head -n 1").read() if ld_library_path and not chk: @@ -298,110 +321,166 @@ def cuda_version_linux(self): break if not chk: retval = "No global version found" - if self.is_conda: + if self._is_conda: retval += ". Check Conda packages for Conda Cuda" return retval cudavers = chk.strip().replace("libcudart.so.", "") return cudavers[:cudavers.find(" ")] - def cuda_version_windows(self): - """ Get CUDA version for Windows systems """ - cuda_keys = self.cuda_keys_windows + def _cuda_version_windows(self): + """ Obtain the installed version of Cuda on a Windows system. + + Returns + ------- + The installed CUDA version on a Windows system + """ + cuda_keys = self._cuda_keys_windows if not cuda_keys: retval = "No global version found" - if self.is_conda: + if self._is_conda: retval += ". Check Conda packages for Conda Cuda" return retval cudavers = [key.lower().replace("cuda_path_v", "").replace("_", ".") for key in cuda_keys] return " ".join(cudavers) def full_info(self): - """ Format system info human readable """ + """ Obtain extensive system information stats, formatted into a human readable format. + + Returns + ------- + str + The system information for the currently running system, formatted for output to + console or a log file. + """ retval = "\n============ System Information ============\n" - sys_info = {"os_platform": self.platform, - "os_machine": self.machine, - "os_release": self.release, - "py_conda_version": self.conda_version, - "py_implementation": self.py_implementation, - "py_version": self.py_version, - "py_command": self.fs_command, - "py_virtual_env": self.is_virtual_env, - "sys_cores": self.cpu_count, - "sys_processor": self.processor, - "sys_ram": self.format_ram(), - "encoding": self.encoding, - "git_branch": self.git_branch, - "git_commits": self.git_commits, - "gpu_cuda": self.cuda_version, - "gpu_cudnn": self.cudnn_version, - "gpu_driver": self.gfx_driver, + sys_info = {"os_platform": self._system["platform"], + "os_machine": self._system["machine"], + "os_release": self._system["release"], + "py_conda_version": self._conda_version, + "py_implementation": self._python["implementation"], + "py_version": self._python["version"], + "py_command": self._fs_command, + "py_virtual_env": self._is_virtual_env, + "sys_cores": self._system["cpu_count"], + "sys_processor": self._system["processor"], + "sys_ram": self._format_ram(), + "encoding": self._encoding, + "git_branch": self._git_branch, + "git_commits": self._git_commits, + "gpu_cuda": self._cuda_version, + "gpu_cudnn": self._cudnn_version, + "gpu_driver": self._gpu["driver"], "gpu_devices": ", ".join(["GPU_{}: {}".format(idx, device) - for idx, device in enumerate(self.gfx_devices)]), + for idx, device in enumerate(self._gpu["devices"])]), "gpu_vram": ", ".join(["GPU_{}: {}MB".format(idx, int(vram)) - for idx, vram in enumerate(self.vram)]), + for idx, vram in enumerate(self._gpu["vram"])]), "gpu_devices_active": ", ".join(["GPU_{}".format(idx) - for idx in self.gfx_devices_active])} + for idx in self._gpu["devices_active"]])} for key in sorted(sys_info.keys()): retval += ("{0: <20} {1}\n".format(key + ":", sys_info[key])) retval += "\n=============== Pip Packages ===============\n" - retval += self.installed_pip - if self.is_conda: + retval += self._installed_pip + if self._is_conda: retval += "\n\n============== Conda Packages ==============\n" - retval += self.installed_conda - retval += self.state_file + retval += self._installed_conda + retval += self._state_file retval += "\n\n================= Configs ==================" - retval += self.configs + retval += self._configs return retval - def format_ram(self): - """ Format the RAM stats for human output """ + def _format_ram(self): + """ Format the RAM stats into Megabytes to make it more readable. + + Returns + ------- + str + The total, available, used and free RAM displayed in Megabytes + """ retval = list() for name in ("total", "available", "used", "free"): - value = getattr(self, "ram_{}".format(name)) + value = getattr(self, "_ram_{}".format(name)) value = int(value / (1024 * 1024)) retval.append("{}: {}MB".format(name.capitalize(), value)) return ", ".join(retval) def get_sysinfo(): - """ Return sys info or error message if there is an error """ + """ Obtain extensive system information stats, formatted into a human readable format. + If an error occurs obtaining the system information, then the error message is returned + instead. + + Returns + ------- + str + The system information for the currently running system, formatted for output to + console or a log file. + """ try: - retval = SysInfo().full_info() + retval = _SysInfo().full_info() except Exception as err: # pylint: disable=broad-except retval = "Exception occured trying to retrieve sysinfo: {}".format(err) return retval -class Configs(): - """ Parses the config files in /config and outputs the information """ +class _Configs(): + """ Parses the config files in /faceswap/config and outputs the information stored within them + in a human readable format. """ def __init__(self): self.config_dir = os.path.join(os.path.abspath(os.path.dirname(sys.argv[0])), "config") - self.configs = self.get_configs() + self.configs = self._get_configs() + + def _get_configs(self): + """ Obtain the formatted configurations from the config folder. - def get_configs(self): - """ Return the configs from the config dir """ + Returns + ------- + str + The current configuration in the config files formatted in a human readable format + """ config_files = [os.path.join(self.config_dir, cfile) for cfile in os.listdir(self.config_dir) if os.path.basename(cfile) == ".faceswap" or os.path.splitext(cfile)[1] == ".ini"] - return self.parse_configs(config_files) + return self._parse_configs(config_files) - def parse_configs(self, config_files): - """ Parse the config files into the output format """ + def _parse_configs(self, config_files): + """ Parse the given list of config files into a human readable format. + + Parameters + ---------- + config_files: list + A list of paths to the faceswap config files + + Returns + ------- + str + The current configuration in the config files formatted in a human readable format + """ formatted = "" for cfile in config_files: fname = os.path.basename(cfile) ext = os.path.splitext(cfile)[1] formatted += "\n--------- {} ---------\n".format(fname) if ext == ".ini": - formatted += self.parse_ini(cfile) + formatted += self._parse_ini(cfile) elif fname == ".faceswap": - formatted += self.parse_json(cfile) + formatted += self._parse_json(cfile) return formatted - def parse_ini(self, config_file): - """ Parse an INI file converting it to a dict """ + def _parse_ini(self, config_file): + """ Parse an ``.ini`` formatted config file into a human readable format. + + Parameters + ---------- + config_file: str + The path to the config.ini file + + Returns + ------- + str + The current configuration in the config file formatted in a human readable format + """ formatted = "" with open(config_file, "r") as cfile: for line in cfile.readlines(): @@ -412,50 +491,88 @@ def parse_ini(self, config_file): if len(item) == 1: formatted += "\n{}\n".format(item[0].strip()) else: - formatted += self.format_text(item[0], item[1]) + formatted += self._format_text(item[0], item[1]) return formatted - def parse_json(self, config_file): - """ Parse a Json File converting it to a dict """ + def _parse_json(self, config_file): + """ Parse an ``.json`` formatted config file into a python dictionary. + + Parameters + ---------- + config_file: str + The path to the config.json file + + Returns + ------- + dict + The current configuration in the config file formatted as a python dictionary + """ formatted = "" with open(config_file, "r") as cfile: conf_dict = json.load(cfile) for key in sorted(conf_dict.keys()): - formatted += self.format_text(key, conf_dict[key]) + formatted += self._format_text(key, conf_dict[key]) return formatted @staticmethod - def format_text(key, val): - """Format the text for output """ - return "{0: <25} {1}\n".format(key.strip() + ":", val.strip()) - - -class State(): - """ State file for training command """ + def _format_text(key, value): + """Format a key value pair into a consistently spaced string output for display. + + Parameters + ---------- + key: str + The label for this display item + value: str + The value for this display item + + Returns + ------- + str + The formatted key value pair for display + """ + return "{0: <25} {1}\n".format(key.strip() + ":", value.strip()) + + +class _State(): + """ Parses the state file in the current model directory, if the model is training, and + formats the content into a human readable format. """ def __init__(self): - self.model_dir = self.get_arg("-m", "--model-dir") - self.trainer = self.get_arg("-t", "--trainer") - self.state_file = self.get_state_file() + self._model_dir = self._get_arg("-m", "--model-dir") + self._trainer = self._get_arg("-t", "--trainer") + self.state_file = self._get_state_file() @property - def is_training(self): - """ Return whether this has been called during training """ + def _is_training(self): + """ bool: ``True`` if this function has been called during a training session + otherwise ``False``. """ return len(sys.argv) > 1 and sys.argv[1].lower() == "train" @staticmethod - def get_arg(*args): - """ Return the value for a given option from sys.argv. Returns None if not found """ + def _get_arg(*args): + """ Obtain the value for a given command line option from sys.argv. + + Returns + ------- + str or ``None`` + The value of the given command line option, if it exists, otherwise ``None`` + """ cmd = sys.argv for opt in args: if opt in cmd: return cmd[cmd.index(opt) + 1] return None - def get_state_file(self): - """ Return the state file in a string """ - if not self.is_training or self.model_dir is None or self.trainer is None: + def _get_state_file(self): + """ Parses the model's state file and compiles the contents into a human readable string. + + Returns + ------- + str + The state file formatted into a human readable format + """ + if not self._is_training or self._model_dir is None or self._trainer is None: return "" - fname = os.path.join(self.model_dir, "{}_state.json".format(self.trainer)) + fname = os.path.join(self._model_dir, "{}_state.json".format(self._trainer)) if not os.path.isfile(fname): return "" diff --git a/lib/utils.py b/lib/utils.py index f93da65f13..f65a0546b4 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -149,12 +149,11 @@ def full_path_split(path): if parts[0] == path: # sentinel for absolute paths allparts.insert(0, parts[0]) break - elif parts[1] == path: # sentinel for relative paths + if parts[1] == path: # sentinel for relative paths allparts.insert(0, parts[1]) break - else: - path = parts[0] - allparts.insert(0, parts[1]) + path = parts[0] + allparts.insert(0, parts[1]) logger.trace("path: %s, allparts: %s", path, allparts) return allparts @@ -173,21 +172,6 @@ def backup_file(directory, filename): os.rename(origfile, backupfile) -def keras_backend_quiet(): - """ Suppresses the "Using x backend" message when importing backend from keras. - - Make sure output is redirected back to stderr if there is an error. """ - stderr = sys.stderr - sys.stderr = open(os.devnull, 'w') - try: - from keras import backend as K # pylint:disable=import-outside-toplevel - except: - sys.stderr = stderr - raise - sys.stderr = stderr - return K - - def set_system_verbosity(loglevel): """ Set the verbosity level of tensorflow and suppresses future and deprecation warnings from any modules @@ -200,7 +184,7 @@ def set_system_verbosity(loglevel): 3 - filter out ERROR logs """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name - from lib.logger import get_loglevel + from lib.logger import get_loglevel # pylint:disable=import-outside-toplevel numeric_level = get_loglevel(loglevel) loglevel = "2" if numeric_level > 15 else "0" logger.debug("System Verbosity level: %s", loglevel) @@ -233,10 +217,10 @@ def safe_shutdown(got_error=False): """ Close queues, threads and processes in event of crash """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name logger.debug("Safely shutting down") - from lib.queue_manager import queue_manager + from lib.queue_manager import queue_manager # pylint:disable=import-outside-toplevel queue_manager.terminate_queues() logger.debug("Cleanup complete. Shutting down queue manager and exiting") - exit(1 if got_error else 0) + sys.exit(1 if got_error else 0) class FaceswapError(Exception): @@ -370,7 +354,7 @@ def get(self): os.remove(self._model_zip_path) def download_model(self): - """ Download model zip to cache dir """ + """ Download model zip to cache folder """ self.logger.info("Downloading model: '%s' from: %s", self._model_name, self._url_download) for attempt in range(self.retries): try: @@ -395,7 +379,7 @@ def download_model(self): self.logger.info("Alternatively, you can manually download the model from: %s " "and unzip the contents to: %s", self._url_download, self.cache_dir) - exit(1) + sys.exit(1) def write_zipfile(self, response, downloaded_size): """ Write the model zip file to disk """ @@ -421,17 +405,17 @@ def write_zipfile(self, response, downloaded_size): pbar.close() def unzip_model(self): - """ Unzip the model file to the cachedir """ + """ Unzip the model file to the cache folder """ self.logger.info("Extracting: '%s'", self._model_name) try: zip_file = zipfile.ZipFile(self._model_zip_path, "r") self.write_model(zip_file) except Exception as err: # pylint:disable=broad-except self.logger.error("Unable to extract model file: %s", str(err)) - exit(1) + sys.exit(1) def write_model(self, zip_file): - """ Extract files from zipfile and write, with progress bar """ + """ Extract files from zip file and write, with progress bar """ length = sum(f.file_size for f in zip_file.infolist()) fnames = zip_file.namelist() self.logger.debug("Zipfile: Filenames: %s, Total Size: %s", fnames, length) diff --git a/scripts/convert.py b/scripts/convert.py index bdc28dfb27..659c036b6e 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -10,9 +10,9 @@ import cv2 import numpy as np +from tqdm import tqdm import tensorflow as tf from keras.backend.tensorflow_backend import set_session -from tqdm import tqdm from scripts.fsmedia import Alignments, PostProcess, finalize from lib.serializer import get_serializer @@ -22,7 +22,7 @@ from lib.image import read_image_hash, ImagesLoader from lib.multithreading import MultiThread, total_cpus from lib.queue_manager import queue_manager -from lib.utils import FaceswapError, get_folder, get_image_paths +from lib.utils import FaceswapError, get_backend, get_folder, get_image_paths from plugins.extract.pipeline import Extractor, ExtractMedia from plugins.plugin_loader import PluginLoader @@ -830,7 +830,7 @@ def _predict_faces(self): faces_seen = 0 consecutive_no_faces = 0 batch = list() - is_plaidml = GPUStats().is_plaidml + is_amd = get_backend() == "amd" while True: item = self._in_queue.get() if item != "EOF": @@ -867,7 +867,7 @@ def _predict_faces(self): if faces_seen != 0: feed_faces = self._compile_feed_faces(detected_batch) batch_size = None - if is_plaidml and feed_faces.shape[0] != self._batchsize: + if is_amd and feed_faces.shape[0] != self._batchsize: logger.verbose("Fallback to BS=1") batch_size = 1 predicted = self._predict(feed_faces, batch_size) From d5f42b63c85a4d58c95a1857488e16d4fb7e4b6a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 26 Apr 2020 14:40:35 +0100 Subject: [PATCH 237/981] Bugfix: lib.gui.project - Reset invalid choices to default if an invalid choice is discovered when loading a .fsw file --- lib/gui/project.py | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/lib/gui/project.py b/lib/gui/project.py index d274a89031..c0bdb02816 100644 --- a/lib/gui/project.py +++ b/lib/gui/project.py @@ -86,6 +86,28 @@ def _stored_tab_name(self): return None return self._options.get("tab_name", None) + @property + def _selected_to_choices(self): + """ dict: The selected value and valid choices for multi-option, radio or combo options. + """ + valid_choices = {cmd: {opt: dict(choices=val["cpanel_option"].choices, + is_multi=val["cpanel_option"].is_multi_option) + for opt, val in data.items() + if isinstance(val, dict) and "cpanel_option" in val + and val["cpanel_option"].choices is not None} + for cmd, data in self._config.cli_opts.opts.items()} + logger.trace("valid_choices: %s", valid_choices) + retval = {command: {option: {"value": value, + "is_multi": valid_choices[command][option]["is_multi"], + "choices": valid_choices[command][option]["choices"]} + for option, value in options.items() + if value and command in valid_choices + and option in valid_choices[command]} + for command, options in self._options.items() + if isinstance(options, dict)} + logger.trace("returning: %s", retval) + return retval + def _current_gui_state(self, command=None): """ The current state of the GUI. @@ -319,12 +341,31 @@ def _load(self): if self._file_exists: logger.debug("Loading config") self._options = self._serializer.load(self._filename) + self._check_valid_choices() retval = True else: logger.debug("File doesn't exist. Aborting") retval = False return retval + def _check_valid_choices(self): + """ Check whether the loaded file has any selected combo/radio/multi-option values that are + no longer valid and remove them so that they are not passed into faceswap. """ + for command, options in self._selected_to_choices.items(): + for option, data in options.items(): + if ((data["is_multi"] and all(v in data["choices"] for v in data["value"].split())) + or not data["is_multi"] and data["value"] in data["choices"]): + continue + if data["is_multi"]: + val = " ".join([v for v in data["value"].split() if v in data["choices"]]) + else: + val = "" + val = self._default_options[command][option] if not val else val + logger.debug("Updating invalid value to default: (command: '%s', option: '%s', " + "original value: '%s', new value: '%s')", command, option, + self._options[command][option], val) + self._options[command][option] = val + def _save_as_to_filename(self, session_type): """ Set :attr:`_filename` from a save as dialog. @@ -492,7 +533,6 @@ def _update_legacy_task(self, filename): The new filename of the updated tasks file """ # TODO remove this code after a period of time. Implemented November 2019 - logger.debug("original filename: '%s'", filename) fname, ext = os.path.splitext(filename) if ext != ".fsw": From 88f9092a103a61964d023c1e09d483f1113d2afd Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 6 May 2020 15:28:56 +0000 Subject: [PATCH 238/981] AMD: Fix ICNR Initialization for PlaidML backend --- docs/full/lib/model.rst | 22 ++++ lib/model/initializers.py | 227 +++++++++++++++++++++++++++----------- 2 files changed, 187 insertions(+), 62 deletions(-) diff --git a/docs/full/lib/model.rst b/docs/full/lib/model.rst index b5cf9d43de..4fa62d422b 100755 --- a/docs/full/lib/model.rst +++ b/docs/full/lib/model.rst @@ -1,6 +1,28 @@ model package ============= +The Model Package handles interfacing with the neural network backend and holds custom objects. + +.. contents:: Contents + :local: + + +model.initializers module +------------------------- + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.model.initializers.ConvolutionAware + ~lib.model.initializers.ICNR + +.. automodule:: lib.model.initializers + :members: + :undoc-members: + :show-inheritance: + model.session module --------------------- diff --git a/lib/model/initializers.py b/lib/model/initializers.py index 7aef85a554..3ed5ad235d 100644 --- a/lib/model/initializers.py +++ b/lib/model/initializers.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 -""" Custom Initializers for faceswap.py - Initializers from: - shoanlu GAN: https://github.com/shaoanlu/faceswap-GAN""" +""" Custom Initializers for faceswap.py """ import logging import sys @@ -13,50 +11,55 @@ from keras import initializers from keras.utils.generic_utils import get_custom_objects -logger = logging.getLogger(__name__) # pylint: disable=invalid-name - - -def icnr_keras(shape, dtype=None): - """ - Custom initializer for subpix upscaling - From https://github.com/kostyaev/ICNR - Note: upscale factor is fixed to 2, and the base initializer is fixed to random normal. - """ - # TODO Roll this into ICNR_init when porting GAN 2.2 - shape = list(shape) - scale = 2 - initializer = tf.keras.initializers.RandomNormal(0, 0.02) +from lib.utils import get_backend - new_shape = shape[:3] + [int(shape[3] / (scale ** 2))] - var_x = initializer(new_shape, dtype) - var_x = tf.transpose(var_x, perm=[2, 0, 1, 3]) - var_x = tf.image.resize_nearest_neighbor(var_x, size=(shape[0] * scale, shape[1] * scale)) - var_x = tf.space_to_depth(var_x, block_size=scale) - var_x = tf.transpose(var_x, perm=[1, 2, 0, 3]) - return var_x +logger = logging.getLogger(__name__) # pylint: disable=invalid-name class ICNR(initializers.Initializer): # pylint: disable=invalid-name - ''' - ICNR initializer for checkerboard artifact free sub pixel convolution + """ ICNR initializer for checkerboard artifact free sub pixel convolution + + Parameters + ---------- + initializer: :class:`keras.initializers.Initializer` + The initializer used for sub kernels (orthogonal, glorot uniform, etc.) + scale: int + scaling factor of sub pixel convolution (up sampling from 8x8 to 16x16 is scale 2) + + Returns + ------- + tensor + The modified kernel weights - Andrew Aitken et al. Checkerboard artifact free sub-pixel convolution - https://arxiv.org/pdf/1707.02937.pdf https://distill.pub/2016/deconv-checkerboard/ + Example + ------- + >>> x = conv2d(... weights_initializer=ICNR(initializer=he_uniform(), scale=2)) - Parameters: - initializer: initializer used for sub kernels (orthogonal, glorot uniform, etc.) - scale: scale factor of sub pixel convolution (upsampling from 8x8 to 16x16 is scale 2) - Return: - The modified kernel weights - Example: - x = conv2d(... weights_initializer=ICNR(initializer=he_uniform(), scale=2)) - ''' + References + ---------- + Andrew Aitken et al. Checkerboard artifact free sub-pixel convolution + https://arxiv.org/pdf/1707.02937.pdf, https://distill.pub/2016/deconv-checkerboard/ + """ def __init__(self, initializer, scale=2): self.scale = scale self.initializer = initializer - def __call__(self, shape, dtype='float32'): # tf needs partition_info=None + def __call__(self, shape, dtype="float32"): + """ Call function for the ICNR initializer. + + Parameters + ---------- + shape: tuple or list + The required resized shape for the output tensor + dtype: str + The data type for the tensor + + Returns + ------- + tensor + The modified kernel weights + """ shape = list(shape) if self.scale == 1: return self.initializer(shape) @@ -64,18 +67,82 @@ def __call__(self, shape, dtype='float32'): # tf needs partition_info=None if isinstance(self.initializer, dict): self.initializer = initializers.deserialize(self.initializer) var_x = self.initializer(new_shape, dtype) - var_x = tf.transpose(var_x, perm=[2, 0, 1, 3]) - var_x = tf.image.resize_nearest_neighbor( - var_x, - size=(shape[0] * self.scale, shape[1] * self.scale), - align_corners=True) - var_x = tf.space_to_depth(var_x, block_size=self.scale, data_format='NHWC') - var_x = tf.transpose(var_x, perm=[1, 2, 0, 3]) + var_x = K.permute_dimensions(var_x, [2, 0, 1, 3]) + var_x = self._resize_nearest_neighbour(var_x, + (shape[0] * self.scale, shape[1] * self.scale)) + var_x = self._space_to_depth(var_x) + var_x = K.permute_dimensions(var_x, [1, 2, 0, 3]) + logger.debug("Output: %s", var_x) return var_x + def _resize_nearest_neighbour(self, input_tensor, size): + """ Resize a tensor using nearest neighbor interpolation. + + Notes + ----- + Tensorflow has a bug that resizes the image incorrectly if :attr:`align_corners` is not set + to ``True``. Keras Backend does not set this flag, so we explicitly call the Tensorflow + operation for non-amd backends. + + Parameters + ---------- + input_tensor: tensor + The tensor to be resized + tuple: int + The (`h`, `w`) that the tensor should be resized to (used for non-amd backends only) + + Returns + ------- + tensor + The input tensor resized to the given size + """ + if get_backend() == "amd": + retval = K.resize_images(input_tensor, self.scale, self.scale, "channels_last", + interpolation="nearest") + else: + retval = tf.image.resize_nearest_neighbor(input_tensor, size=size, align_corners=True) + logger.debug("Input Tensor: %s, Output Tensor: %s", input_tensor, retval) + return retval + + def _space_to_depth(self, input_tensor): + """ Space to depth implementation. + + PlaidML does not have a space to depth operation, so calculate if backend is amd + otherwise returns the :func:`tensorflow.space_to_depth` operation. + + Parameters + ---------- + input_tensor: tensor + The tensor to be manipulated + + Returns + ------- + tensor + The manipulated input tensor + """ + if get_backend() == "amd": + batch, height, width, depth = input_tensor.shape.dims + new_height = height // self.scale + new_width = width // self.scale + reshaped = K.reshape(input_tensor, + (batch, new_height, self.scale, new_width, self.scale, depth)) + retval = K.reshape(K.permute_dimensions(reshaped, [0, 1, 3, 2, 4, 5]), + (batch, new_height, new_width, -1)) + else: + retval = tf.space_to_depth(input_tensor, block_size=self.scale, data_format="NHWC") + logger.debug("Input Tensor: %s, Output Tensor: %s", input_tensor, retval) + return retval + def get_config(self): - config = {'scale': self.scale, - 'initializer': self.initializer + """ Return the ICNR Initializer configuration. + + Returns + ------- + dict + The configuration for ICNR Initialization + """ + config = {"scale": self.scale, + "initializer": self.initializer } base_config = super(ICNR, self).get_config() return dict(list(base_config.items()) + list(config.items())) @@ -83,25 +150,37 @@ def get_config(self): class ConvolutionAware(initializers.Initializer): """ - Initializer that generates orthogonal convolution filters in the fourier - space. If this initializer is passed a shape that is not 3D or 4D, - orthogonal initialization will be used. - # Arguments - eps_std: Standard deviation for the random normal noise used to break - symmetry in the inverse fourier transform. - seed: A Python integer. Used to seed the random generator. - # References - Armen Aghajanyan, https://arxiv.org/abs/1702.06295 - # Adapted, fixed and optimized from: + Initializer that generates orthogonal convolution filters in the Fourier space. If this + initializer is passed a shape that is not 3D or 4D, orthogonal initialization will be used. + + Adapted, fixed and optimized from: https://github.com/keras-team/keras-contrib/blob/master/keras_contrib/initializers/convaware.py + + Parameters + ---------- + eps_std: float + The Standard deviation for the random normal noise used to break symmetry in the inverse + Fourier transform. + seed: int, optional + Used to seed the random generator. Default: ``None`` + + Returns + ------- + tensor + The modified kernel weights + + References + ---------- + Armen Aghajanyan, https://arxiv.org/abs/1702.06295 + + Notes + ----- + Convolutional Aware Initialization takes a long time. Keras model loading loads a model, + performs initialization and then loads weights, which is an unnecessary waste of time. + init defaults to False so that this is bypassed when loading a saved model passing zeros. """ def __init__(self, eps_std=0.05, seed=None, init=False): - # Convolutional Aware Initialization takes a long time. - # Keras model loading loads a model, performs initialization and then - # loads weights, which is an unnecessary waste of time. - # init defaults to False so that this is bypassed when loading a saved model - # passing zeros self._init = init self.eps_std = eps_std self.seed = seed @@ -109,6 +188,20 @@ def __init__(self, eps_std=0.05, seed=None, init=False): self.he_uniform = initializers.he_uniform() def __call__(self, shape, dtype=None): + """ Call function for the ICNR initializer. + + Parameters + ---------- + shape: tuple or list + The required shape for the output tensor + dtype: str + The data type for the tensor + + Returns + ------- + tensor + The modified kernel weights + """ dtype = K.floatx() if dtype is None else dtype if self._init: logger.info("Calculating Convolution Aware Initializer for shape: %s", shape) @@ -162,6 +255,7 @@ def __call__(self, shape, dtype=None): return K.variable(init.transpose(transpose_dimensions), dtype=dtype, name="conv_aware") def _create_basis(self, filters_size, filters, size, dtype): + """ Create the basis for convolutional aware initialization """ if size == 1: return np.random.normal(0.0, self.eps_std, (filters_size, filters, size)) nbb = filters // size + 1 @@ -173,6 +267,7 @@ def _create_basis(self, filters_size, filters, size, dtype): @staticmethod def _symmetrize(var_a): + """ Make the given tensor symmetrical. """ var_b = np.transpose(var_a, axes=(0, 1, 3, 2)) diag = var_a.diagonal(axis1=2, axis2=3) var_c = np.array([[np.diag(arr) for arr in batch] for batch in diag]) @@ -180,14 +275,22 @@ def _symmetrize(var_a): @staticmethod def _scale_filters(filters, variance): + """ Scale the given filters. """ c_var = np.var(filters) var_p = np.sqrt(variance / c_var) return filters * var_p def get_config(self): + """ Return the Convolutional Aware Initializer configuration. + + Returns + ------- + dict + The configuration for ICNR Initialization + """ return { - 'eps_std': self.eps_std, - 'seed': self.seed + "eps_std": self.eps_std, + "seed": self.seed } From d27897a63d3f031650f7643cb73deec32f72e508 Mon Sep 17 00:00:00 2001 From: bryanlyon <3223233+bryanlyon@users.noreply.github.com> Date: Wed, 6 May 2020 10:23:13 -0700 Subject: [PATCH 239/981] Slight tweak of wording --- USAGE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/USAGE.md b/USAGE.md index 38eb9e4793..1472b35237 100755 --- a/USAGE.md +++ b/USAGE.md @@ -149,7 +149,7 @@ It should now start swapping faces of all these pictures. ## General Tips -You can see the full list of arguments for training by hovering over the options in the GUI or passing the help flag. i.e: +You can see the full list of arguments for Converting by hovering over the options in the GUI or passing the help flag. i.e: ```bash python faceswap.py convert -h From 815c843f63ea7cba29915c609550a4047a945794 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 12 May 2020 22:46:04 +0000 Subject: [PATCH 240/981] Simple backend unit tests (#1020) * Add simple backend tests for lib.model * Document lib.model * Fix GMSD Loss for AMD * Remove obsolete code from lib.model --- .gitignore | 3 + .travis.yml | 12 +- docs/full/lib/model.rst | 74 ++ lib/model/initializers.py | 2 +- lib/model/layers.py | 596 ++++++++++----- lib/model/losses.py | 1018 +++++++++---------------- lib/model/nn_blocks.py | 507 ++++++------ lib/model/normalization.py | 271 ++----- lib/model/optimizers.py | 88 ++- lib/utils.py | 15 +- plugins/train/model/dlight.py | 4 +- tests/__init__.py | 0 tests/lib/__init__.py | 0 tests/lib/model/__init__.py | 0 tests/lib/model/initializers_test.py | 62 ++ tests/lib/model/layers_test.py | 138 ++++ tests/lib/model/losses_test.py | 75 ++ tests/lib/model/nn_blocks_test.py | 78 ++ tests/lib/model/normalization_test.py | 37 + tests/lib/model/optimizers_test.py | 77 ++ tests/startup_test.py | 18 + 21 files changed, 1771 insertions(+), 1304 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/lib/__init__.py create mode 100644 tests/lib/model/__init__.py create mode 100644 tests/lib/model/initializers_test.py create mode 100644 tests/lib/model/layers_test.py create mode 100644 tests/lib/model/losses_test.py create mode 100644 tests/lib/model/nn_blocks_test.py create mode 100644 tests/lib/model/normalization_test.py create mode 100644 tests/lib/model/optimizers_test.py create mode 100644 tests/startup_test.py diff --git a/.gitignore b/.gitignore index 7fc677aef8..8eaaea6bec 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,9 @@ !plugins/train/* !plugins/convert/* !.pylintrc +!tests +!tests/* +!tests/*/* !tools !tools/* !_travis diff --git a/.travis.yml b/.travis.yml index 026d8b71b9..84e56cc1f7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -87,10 +87,16 @@ install: - conda env list | grep faceswap || conda create -q --name faceswap python=$CONDA_PYTHON; - conda activate faceswap; - conda --version ; python --version ; pip --version; - - python setup.py --installer; + # We set up for plaidML as we can then use both the plaidML and Tensorflow backends for testing + - python setup.py --installer --amd; + - conda install pytest; # For debugging purposes - df -h script: - - python _travis/simple_tests.py; - + - rm -f ~/.plaidml; + - echo "{\"PLAIDML_DEVICE_IDS\":[\"llvm_cpu.0\"],\"PLAIDML_EXPERIMENTAL\":true}" > ~/.plaidml; + - FACESWAP_BACKEND="amd" KERAS_BACKEND="plaidml.keras.backend" PYTHONPATH=$PWD:$PYTHONPATH py.test -v tests/; + - rm -f ~/.plaidml; + - FACESWAP_BACKEND="cpu" KERAS_BACKEND="tensorflow" PYTHONPATH=$PWD:$PYTHONPATH py.test -v tests/; + - FACESWAP_BACKEND="cpu" KERAS_BACKEND="tensorflow" python _travis/simple_tests.py; diff --git a/docs/full/lib/model.rst b/docs/full/lib/model.rst index 4fa62d422b..d8402afeea 100755 --- a/docs/full/lib/model.rst +++ b/docs/full/lib/model.rst @@ -23,6 +23,80 @@ model.initializers module :undoc-members: :show-inheritance: +model.layers module +------------------- + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.model.layers.GlobalMinPooling2D + ~lib.model.layers.GlobalStdDevPooling2D + ~lib.model.layers.L2_normalize + ~lib.model.layers.PixelShuffler + ~lib.model.layers.ReflectionPadding2D + ~lib.model.layers.SubPixelUpscaling + +.. automodule:: lib.model.layers + :members: + :undoc-members: + :show-inheritance: + +model.losses module +------------------- + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.model.losses.DSSIMObjective + ~lib.model.losses.PenalizedLoss + ~lib.model.losses.gaussian_blur + ~lib.model.losses.generalized_loss + ~lib.model.losses.gmsd_loss + ~lib.model.losses.gradient_loss + ~lib.model.losses.l_inf_norm + ~lib.model.losses.mask_loss_wrapper + ~lib.model.losses.scharr_edges + +.. automodule:: lib.model.losses + :members: + :undoc-members: + :show-inheritance: + +model.nn_blocks module +---------------------- + +.. automodule:: lib.model.nn_blocks + :members: + :undoc-members: + :show-inheritance: + +model.normalization module +-------------------------- + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.model.normalization.InstanceNormalization + +.. automodule:: lib.model.normalization + :members: + :undoc-members: + :show-inheritance: + +model.optimizers module +----------------------- + +.. automodule:: lib.model.optimizers + :members: + :undoc-members: + :show-inheritance: + model.session module --------------------- diff --git a/lib/model/initializers.py b/lib/model/initializers.py index 3ed5ad235d..7d4f28b93d 100644 --- a/lib/model/initializers.py +++ b/lib/model/initializers.py @@ -33,7 +33,7 @@ class ICNR(initializers.Initializer): # pylint: disable=invalid-name Example ------- - >>> x = conv2d(... weights_initializer=ICNR(initializer=he_uniform(), scale=2)) + >>> x = conv2d(... weights_initializer=ICNR(initializer=he_uniform(), scale=2)) References ---------- diff --git a/lib/model/layers.py b/lib/model/layers.py index 4f6bd1ee45..206b4ea857 100644 --- a/lib/model/layers.py +++ b/lib/model/layers.py @@ -1,8 +1,5 @@ #!/usr/bin/env python3 -""" Custom Layers for faceswap.py - Layers from: - the original https://www.reddit.com/r/deepfakes/ code sample + contribs - shoanlu GAN: https://github.com/shaoanlu/faceswap-GAN""" +""" Custom Layers for faceswap.py. """ from __future__ import absolute_import @@ -15,25 +12,75 @@ from keras.engine import InputSpec, Layer from keras.utils import conv_utils from keras.utils.generic_utils import get_custom_objects -from keras import initializers from keras.layers.pooling import _GlobalPooling2D -if K.backend() == "plaidml.keras.backend": +from lib.utils import get_backend + +if get_backend() == "amd": from lib.plaidml_utils import pad else: from tensorflow import pad + class PixelShuffler(Layer): - """ PixelShuffler layer for Keras - by t-ae: https://gist.github.com/t-ae/6e1016cc188104d123676ccef3264981 """ - # pylint: disable=C0103 + """ PixelShuffler layer for Keras. + + This layer requires a Convolution2D prior to it, having output filters computed according to + the formula :math:`filters = k * (scale_factor * scale_factor)` where `k` is a user defined + number of filters (generally larger than 32) and `scale_factor` is the up-scaling factor + (generally 2). + + This layer performs the depth to space operation on the convolution filters, and returns a + tensor with the size as defined below. + + Notes + ----- + In practice, it is useful to have a second convolution layer after the + :class:`PixelShuffler` layer to speed up the learning process. However, if you are stacking + multiple :class:`PixelShuffler` blocks, it may increase the number of parameters greatly, + so the Convolution layer after :class:`PixelShuffler` layer can be removed. + + Example + ------- + >>> # A standard sub-pixel up-scaling block + >>> x = Convolution2D(256, 3, 3, padding="same", activation="relu")(...) + >>> u = PixelShuffler(size=(2, 2))(x) + [Optional] + >>> x = Convolution2D(256, 3, 3, padding="same", activation="relu")(u) + + Parameters + ---------- + size: tuple, optional + The (`h`, `w`) scaling factor for up-scaling. Default: `(2, 2)` + data_format: ["channels_first", "channels_last", ``None``], optional + The data format for the input. Default: ``None`` + kwargs: dict + The standard Keras Layer keyword arguments (if any) + + References + ---------- + https://gist.github.com/t-ae/6e1016cc188104d123676ccef3264981 + """ def __init__(self, size=(2, 2), data_format=None, **kwargs): - super(PixelShuffler, self).__init__(**kwargs) + super().__init__(**kwargs) self.data_format = K.normalize_data_format(data_format) - self.size = conv_utils.normalize_tuple(size, 2, 'size') + self.size = conv_utils.normalize_tuple(size, 2, "size") def call(self, inputs, **kwargs): - + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: tensor + Input tensor, or list/tuple of input tensors + kwargs: dict + Additional keyword arguments + + Returns + ------- + tensor + A tensor or list/tuple of tensors + """ input_shape = K.int_shape(inputs) if len(input_shape) != 4: raise ValueError('Inputs should have rank ' + @@ -41,31 +88,45 @@ def call(self, inputs, **kwargs): '; Received input shape:', str(input_shape)) if self.data_format == 'channels_first': - batch_size, c, h, w = input_shape + batch_size, channels, height, width = input_shape if batch_size is None: batch_size = -1 - rh, rw = self.size - oh, ow = h * rh, w * rw - oc = c // (rh * rw) + r_height, r_width = self.size + o_height, o_width = height * r_height, width * r_width + o_channels = channels // (r_height * r_width) - out = K.reshape(inputs, (batch_size, rh, rw, oc, h, w)) + out = K.reshape(inputs, (batch_size, r_height, r_width, o_channels, height, width)) out = K.permute_dimensions(out, (0, 3, 4, 1, 5, 2)) - out = K.reshape(out, (batch_size, oc, oh, ow)) + out = K.reshape(out, (batch_size, o_channels, o_height, o_width)) elif self.data_format == 'channels_last': - batch_size, h, w, c = input_shape + batch_size, height, width, channels = input_shape if batch_size is None: batch_size = -1 - rh, rw = self.size - oh, ow = h * rh, w * rw - oc = c // (rh * rw) + r_height, r_width = self.size + o_height, o_width = height * r_height, width * r_width + o_channels = channels // (r_height * r_width) - out = K.reshape(inputs, (batch_size, h, w, rh, rw, oc)) + out = K.reshape(inputs, (batch_size, height, width, r_height, r_width, o_channels)) out = K.permute_dimensions(out, (0, 1, 3, 2, 4, 5)) - out = K.reshape(out, (batch_size, oh, ow, oc)) + out = K.reshape(out, (batch_size, o_height, o_width, o_channels)) return out def compute_output_shape(self, input_shape): + """Computes the output shape of the layer. + Assumes that the layer will be built to match that input shape provided. + + Parameters + ---------- + input_shape: tuple or list of tuples + Shape tuple (tuple of integers) or list of shape tuples (one per output tensor of the + layer). Shape tuples can include None for free dimensions, instead of an integer. + + Returns + ------- + tuple + An input shape tuple + """ if len(input_shape) != 4: raise ValueError('Inputs should have rank ' + str(4) + @@ -106,87 +167,70 @@ def compute_output_shape(self, input_shape): return retval def get_config(self): - config = {'size': self.size, - 'data_format': self.data_format} - base_config = super(PixelShuffler, self).get_config() - - return dict(list(base_config.items()) + list(config.items())) + """Returns the config of the layer. + A layer config is a Python dictionary (serializable) containing the configuration of a + layer. The same layer can be reinstated later (without its trained weights) from this + configuration. -class Scale(Layer): - """ - GAN Custom Scal Layer - Code borrows from https://github.com/flyyufelix/cnn_finetune - """ - def __init__(self, weights=None, axis=-1, gamma_init='zero', **kwargs): - self.axis = axis - self.gamma = None - self.gamma_init = initializers.get(gamma_init) - self.initial_weights = weights - super(Scale, self).__init__(**kwargs) - - def build(self, input_shape): - self.input_spec = [InputSpec(shape=input_shape)] + The configuration of a layer does not include connectivity information, nor the layer + class name. These are handled by `Network` (one layer of abstraction above). - # Compatibility with TensorFlow >= 1.0.0 - self.gamma = K.variable(self.gamma_init((1,)), name='{}_gamma'.format(self.name)) - self.trainable_weights = [self.gamma] - - if self.initial_weights is not None: - self.set_weights(self.initial_weights) - del self.initial_weights - - def call(self, x, mask=None): - return self.gamma * x + Returns + -------- + dict + A python dictionary containing the layer configuration + """ + config = {'size': self.size, + 'data_format': self.data_format} + base_config = super(PixelShuffler, self).get_config() - def get_config(self): - config = {"axis": self.axis} - base_config = super(Scale, self).get_config() return dict(list(base_config.items()) + list(config.items())) class SubPixelUpscaling(Layer): - # pylint: disable=C0103 - """ Sub-pixel convolutional upscaling layer based on the paper "Real-Time - Single Image and Video Super-Resolution Using an Efficient Sub-Pixel - Convolutional Neural Network" (https://arxiv.org/abs/1609.05158). - This layer requires a Convolution2D prior to it, having output filters - computed according to the formula : - filters = k * (scale_factor * scale_factor) - where k = a user defined number of filters (generally larger than 32) - scale_factor = the upscaling factor (generally 2) - This layer performs the depth to space operation on the convolution - filters, and returns a tensor with the size as defined below. - # Example : - ```python - # A standard subpixel upscaling block - x = Convolution2D(256, 3, 3, padding="same", activation="relu")(...) - u = SubPixelUpscaling(scale_factor=2)(x) - [Optional] - x = Convolution2D(256, 3, 3, padding="same", activation="relu")(u) - ``` - In practice, it is useful to have a second convolution layer after the - SubPixelUpscaling layer to speed up the learning process. - However, if you are stacking multiple SubPixelUpscaling blocks, - it may increase the number of parameters greatly, so the Convolution - layer after SubPixelUpscaling layer can be removed. - # Arguments - scale_factor: Upscaling factor. - data_format: Can be None, "channels_first" or "channels_last". - # Input shape - 4D tensor with shape: - `(samples, k * (scale_factor * scale_factor) channels, rows, cols)` - if data_format="channels_first" - or 4D tensor with shape: - `(samples, rows, cols, k * (scale_factor * scale_factor) channels)` - if data_format="channels_last". - # Output shape - 4D tensor with shape: - `(samples, k channels, rows * scale_factor, cols * scale_factor))` - if data_format="channels_first" - or 4D tensor with shape: - `(samples, rows * scale_factor, cols * scale_factor, k channels)` - if data_format="channels_last". + """ Sub-pixel convolutional up-scaling layer. + + This layer requires a Convolution2D prior to it, having output filters computed according to + the formula :math:`filters = k * (scale_factor * scale_factor)` where `k` is a user defined + number of filters (generally larger than 32) and `scale_factor` is the up-scaling factor + (generally 2). + + This layer performs the depth to space operation on the convolution filters, and returns a + tensor with the size as defined below. + + Notes + ----- + This method is deprecated as it just performs the same as :class:`PixelShuffler` + using explicit Tensorflow ops. The method is kept in the repository to support legacy + models that have been created with this layer. + + In practice, it is useful to have a second convolution layer after the + :class:`SubPixelUpscaling` layer to speed up the learning process. However, if you are stacking + multiple :class:`SubPixelUpscaling` blocks, it may increase the number of parameters greatly, + so the Convolution layer after :class:`SubPixelUpscaling` layer can be removed. + + Example + ------- + >>> # A standard sub-pixel up-scaling block + >>> x = Convolution2D(256, 3, 3, padding="same", activation="relu")(...) + >>> u = SubPixelUpscaling(scale_factor=2)(x) + [Optional] + >>> x = Convolution2D(256, 3, 3, padding="same", activation="relu")(u) + + Parameters + ---------- + size: int, optional + The up-scaling factor. Default: `2` + data_format: ["channels_first", "channels_last", ``None``], optional + The data format for the input. Default: ``None`` + kwargs: dict + The standard Keras Layer keyword arguments (if any) + + References + ---------- + based on the paper "Real-Time Single Image and Video Super-Resolution Using an Efficient + Sub-Pixel Convolutional Neural Network" (https://arxiv.org/abs/1609.05158). """ def __init__(self, scale_factor=2, data_format=None, **kwargs): @@ -196,29 +240,67 @@ def __init__(self, scale_factor=2, data_format=None, **kwargs): self.data_format = K.normalize_data_format(data_format) def build(self, input_shape): + """Creates the layer weights. + + Must be implemented on all layers that have weights. + + Parameters + ---------- + input_shape: tensor + Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to + reference for weight shape computations. + """ pass - def call(self, x, mask=None): - y = self.depth_to_space(x, self.scale_factor, self.data_format) - return y + def call(self, input_tensor, mask=None): # pylint:disable=unused-argument,arguments-differ + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: tensor + Input tensor, or list/tuple of input tensors + kwargs: dict + Additional keyword arguments + + Returns + ------- + tensor + A tensor or list/tuple of tensors + """ + retval = self._depth_to_space(input_tensor, self.scale_factor, self.data_format) + return retval def compute_output_shape(self, input_shape): + """Computes the output shape of the layer. + + Assumes that the layer will be built to match that input shape provided. + + Parameters + ---------- + input_shape: tuple or list of tuples + Shape tuple (tuple of integers) or list of shape tuples (one per output tensor of the + layer). Shape tuples can include None for free dimensions, instead of an integer. + + Returns + ------- + tuple + An input shape tuple + """ if self.data_format == "channels_first": - b, k, r, c = input_shape - return (b, - k // (self.scale_factor ** 2), - r * self.scale_factor, - c * self.scale_factor) - b, r, c, k = input_shape - return (b, - r * self.scale_factor, - c * self.scale_factor, - k // (self.scale_factor ** 2)) + batch, channels, rows, columns = input_shape + return (batch, + channels // (self.scale_factor ** 2), + rows * self.scale_factor, + columns * self.scale_factor) + batch, rows, columns, channels = input_shape + return (batch, + rows * self.scale_factor, + columns * self.scale_factor, + channels // (self.scale_factor ** 2)) @classmethod - def depth_to_space(cls, ipt, scale, data_format=None): - """ Uses phase shift algorithm to convert channels/depth - for spatial resolution """ + def _depth_to_space(cls, ipt, scale, data_format=None): + """ Uses phase shift algorithm to convert channels/depth for spatial resolution """ if data_format is None: data_format = K.image_data_format() data_format = data_format.lower() @@ -228,42 +310,69 @@ def depth_to_space(cls, ipt, scale, data_format=None): return out @staticmethod - def _postprocess_conv2d_output(x, data_format): + def _postprocess_conv2d_output(input_tensor, data_format): """Transpose and cast the output from conv2d if needed. - # Arguments - x: A tensor. - data_format: string, `"channels_last"` or `"channels_first"`. - # Returns - A tensor. + + Parameters + ---------- + input_tensor: tensor + The input that requires transposing and casting + data_format: str + `"channels_last"` or `"channels_first"` + + Returns + ------- + tensor + The transposed and cast input tensor """ if data_format == "channels_first": - x = tf.transpose(x, (0, 3, 1, 2)) + input_tensor = tf.transpose(input_tensor, (0, 3, 1, 2)) if K.floatx() == "float64": - x = tf.cast(x, "float64") - return x + input_tensor = tf.cast(input_tensor, "float64") + return input_tensor @staticmethod - def _preprocess_conv2d_input(x, data_format): + def _preprocess_conv2d_input(input_tensor, data_format): """Transpose and cast the input before the conv2d. - # Arguments - x: input tensor. - data_format: string, `"channels_last"` or `"channels_first"`. - # Returns - A tensor. + + Parameters + ---------- + input_tensor: tensor + The input that requires transposing and casting + data_format: str + `"channels_last"` or `"channels_first"` + + Returns + ------- + tensor + The transposed and cast input tensor """ - if K.dtype(x) == "float64": - x = tf.cast(x, "float32") + if K.dtype(input_tensor) == "float64": + input_tensor = tf.cast(input_tensor, "float32") if data_format == "channels_first": - # TF uses the last dimension as channel dimension, - # instead of the 2nd one. - # TH input shape: (samples, input_depth, rows, cols) - # TF input shape: (samples, rows, cols, input_depth) - x = tf.transpose(x, (0, 2, 3, 1)) - return x + # Tensorflow uses the last dimension as channel dimension, instead of the 2nd one. + # Theano input shape: (samples, input_depth, rows, cols) + # Tensorflow input shape: (samples, rows, cols, input_depth) + input_tensor = tf.transpose(input_tensor, (0, 2, 3, 1)) + return input_tensor def get_config(self): + """Returns the config of the layer. + + A layer config is a Python dictionary (serializable) containing the configuration of a + layer. The same layer can be reinstated later (without its trained weights) from this + configuration. + + The configuration of a layer does not include connectivity information, nor the layer + class name. These are handled by `Network` (one layer of abstraction above). + + Returns + -------- + dict + A python dictionary containing the layer configuration + """ config = {"scale_factor": self.scale_factor, "data_format": self.data_format} base_config = super(SubPixelUpscaling, self).get_config() @@ -272,37 +381,53 @@ def get_config(self): class ReflectionPadding2D(Layer): """Reflection-padding layer for 2D input (e.g. picture). - This layer can add rows and columns - at the top, bottom, left and right side of an image tensor. - Input shape: ONLY WORKS ON CHANNELS LAST NOW - 4D tensor with shape: - - If `data_format` is `"channels_last"`: - `(batch, rows, cols, channels)` - - If `data_format` is `"channels_first"`: - `(batch, channels, rows, cols)` - Output shape: - 4D tensor with shape: - - If `data_format` is `"channels_last"`: - `(batch, padded_rows, padded_cols, channels)` - - If `data_format` is `"channels_first"`: - `(batch, channels, padded_rows, padded_cols)` + + This layer can add rows and columns at the top, bottom, left and right side of an image tensor. + + Parameters + ---------- + stride: int, optional + The stride of the following convolution. Default: `2` + kernel_size: int, optional + The kernel size of the following convolution. Default: `5` + kwargs: dict + The standard Keras Layer keyword arguments (if any) """ def __init__(self, stride=2, kernel_size=5, **kwargs): - ''' - # Arguments - stride: stride of following convolution (2) - kernel_size: kernel size of following convolution (5,5) - ''' self.stride = stride self.kernel_size = kernel_size - super(ReflectionPadding2D, self).__init__(**kwargs) + super().__init__(**kwargs) def build(self, input_shape): + """Creates the layer weights. + + Must be implemented on all layers that have weights. + + Parameters + ---------- + input_shape: tensor + Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to + reference for weight shape computations. + """ self.input_spec = [InputSpec(shape=input_shape)] - super(ReflectionPadding2D, self).build(input_shape) + super().build(input_shape) def compute_output_shape(self, input_shape): - """ If you are using "channels_last" configuration""" + """Computes the output shape of the layer. + + Assumes that the layer will be built to match that input shape provided. + + Parameters + ---------- + input_shape: tuple or list of tuples + Shape tuple (tuple of integers) or list of shape tuples (one per output tensor of the + layer). Shape tuples can include None for free dimensions, instead of an integer. + + Returns + ------- + tuple + An input shape tuple + """ input_shape = self.input_spec[0].shape in_width, in_height = input_shape[2], input_shape[1] kernel_width, kernel_height = self.kernel_size, self.kernel_size @@ -314,14 +439,28 @@ def compute_output_shape(self, input_shape): if (in_width % self.stride) == 0: padding_width = max(kernel_width - self.stride, 0) else: - padding_width = max(kernel_width- (in_width % self.stride), 0) + padding_width = max(kernel_width - (in_width % self.stride), 0) return (input_shape[0], input_shape[1] + padding_height, input_shape[2] + padding_width, input_shape[3]) - def call(self, x, mask=None): + def call(self, x, mask=None): # pylint:disable=unused-argument,arguments-differ + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: tensor + Input tensor, or list/tuple of input tensors + kwargs: dict + Additional keyword arguments + + Returns + ------- + tensor + A tensor or list/tuple of tensors + """ input_shape = self.input_spec[0].shape in_width, in_height = input_shape[2], input_shape[1] kernel_width, kernel_height = self.kernel_size, self.kernel_size @@ -333,7 +472,7 @@ def call(self, x, mask=None): if (in_width % self.stride) == 0: padding_width = max(kernel_width - self.stride, 0) else: - padding_width = max(kernel_width- (in_width % self.stride), 0) + padding_width = max(kernel_width - (in_width % self.stride), 0) padding_top = padding_height // 2 padding_bot = padding_height - padding_top @@ -348,6 +487,20 @@ def call(self, x, mask=None): 'REFLECT') def get_config(self): + """Returns the config of the layer. + + A layer config is a Python dictionary (serializable) containing the configuration of a + layer. The same layer can be reinstated later (without its trained weights) from this + configuration. + + The configuration of a layer does not include connectivity information, nor the layer + class name. These are handled by `Network` (one layer of abstraction above). + + Returns + -------- + dict + A python dictionary containing the layer configuration + """ config = {'stride': self.stride, 'kernel_size': self.kernel_size} base_config = super(ReflectionPadding2D, self).get_config() @@ -355,31 +508,23 @@ def get_config(self): class GlobalMinPooling2D(_GlobalPooling2D): - """Global minimum pooling operation for spatial data. - # Arguments - data_format: A string, - one of `channels_last` (default) or `channels_first`. - The ordering of the dimensions in the inputs. - `channels_last` corresponds to inputs with shape - `(batch, height, width, channels)` while `channels_first` - corresponds to inputs with shape - `(batch, channels, height, width)`. - It defaults to the `image_data_format` value found in your - Keras config file at `~/.keras/keras.json`. - If you never set it, then it will be "channels_last". - # Input shape - - If `data_format='channels_last'`: - 4D tensor with shape: - `(batch_size, rows, cols, channels)` - - If `data_format='channels_first'`: - 4D tensor with shape: - `(batch_size, channels, rows, cols)` - # Output shape - 2D tensor with shape: - `(batch_size, channels)` - """ + """Global minimum pooling operation for spatial data. """ def call(self, inputs): + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: tensor + Input tensor, or list/tuple of input tensors + kwargs: dict + Additional keyword arguments + + Returns + ------- + tensor + A tensor or list/tuple of tensors + """ if self.data_format == 'channels_last': pooled = K.min(inputs, axis=[1, 2]) else: @@ -388,52 +533,81 @@ def call(self, inputs): class GlobalStdDevPooling2D(_GlobalPooling2D): - """Global standard deviation pooling operation for spatial data. - # Arguments - data_format: A string, - one of `channels_last` (default) or `channels_first`. - The ordering of the dimensions in the inputs. - `channels_last` corresponds to inputs with shape - `(batch, height, width, channels)` while `channels_first` - corresponds to inputs with shape - `(batch, channels, height, width)`. - It defaults to the `image_data_format` value found in your - Keras config file at `~/.keras/keras.json`. - If you never set it, then it will be "channels_last". - # Input shape - - If `data_format='channels_last'`: - 4D tensor with shape: - `(batch_size, rows, cols, channels)` - - If `data_format='channels_first'`: - 4D tensor with shape: - `(batch_size, channels, rows, cols)` - # Output shape - 2D tensor with shape: - `(batch_size, channels)` - """ + """Global standard deviation pooling operation for spatial data. """ def call(self, inputs): + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: tensor + Input tensor, or list/tuple of input tensors + kwargs: dict + Additional keyword arguments + + Returns + ------- + tensor + A tensor or list/tuple of tensors + """ if self.data_format == 'channels_last': pooled = K.std(inputs, axis=[1, 2]) else: pooled = K.std(inputs, axis=[2, 3]) return pooled -class L2_normalize(Layer): + +class L2_normalize(Layer): # Pylint:disable=invalid-name + """ Normalizes a tensor w.r.t. the L2 norm alongside the specified axis. + + Parameters + ---------- + axis: int + The axis to perform normalization across + kwargs: dict + The standard Keras Layer keyword arguments (if any) + """ def __init__(self, axis, **kwargs): self.axis = axis super(L2_normalize, self).__init__(**kwargs) - def call(self, x): - return K.l2_normalize(x, self.axis) + def call(self, inputs): # pylint:disable=arguments-differ + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: tensor + Input tensor, or list/tuple of input tensors + kwargs: dict + Additional keyword arguments + + Returns + ------- + tensor + A tensor or list/tuple of tensors + """ + return K.l2_normalize(inputs, self.axis) def get_config(self): + """Returns the config of the layer. + + A layer config is a Python dictionary (serializable) containing the configuration of a + layer. The same layer can be reinstated later (without its trained weights) from this + configuration. + + The configuration of a layer does not include connectivity information, nor the layer + class name. These are handled by `Network` (one layer of abstraction above). + + Returns + -------- + dict + A python dictionary containing the layer configuration + """ config = super(L2_normalize, self).get_config() config["axis"] = self.axis return config - # Update layers into Keras custom objects for name, obj in inspect.getmembers(sys.modules[__name__]): if inspect.isclass(obj) and obj.__module__ == __name__: diff --git a/lib/model/losses.py b/lib/model/losses.py index 95d77dbe0e..706e6f7546 100644 --- a/lib/model/losses.py +++ b/lib/model/losses.py @@ -1,37 +1,37 @@ #!/usr/bin/env python3 -""" Custom Loss Functions for faceswap.py - Losses from: - keras.contrib - dfaker: https://github.com/dfaker/df - shoanlu GAN: https://github.com/shaoanlu/faceswap-GAN""" +""" Custom Loss Functions for faceswap.py """ from __future__ import absolute_import import logging import keras.backend as K -from keras.layers import Lambda, concatenate import numpy as np import tensorflow as tf -from tensorflow.distributions import Beta -from .normalization import InstanceNormalization -if K.backend() == "plaidml.keras.backend": +from lib.utils import get_backend + +if get_backend() == "amd": from plaidml.op import extract_image_patches + from lib.plaidml_utils import pad else: from tensorflow import extract_image_patches # pylint: disable=ungrouped-imports - + from tensorflow import pad logger = logging.getLogger(__name__) # pylint: disable=invalid-name def mask_loss_wrapper(loss_func, preprocessing_func=None): - """ A wrapper for mask loss that can perform pre-processing on the input - prior to calling the loss function - loss_func: The loss function to use - preprocessing_func: The preprocessing function to use. Should take a Keras Input - as it's only argument """ - + """ A wrapper for mask loss that can perform pre-processing on the input prior to calling the + loss function. + + Parameters + ---------- + loss_func: class or function + The actual loss function to use + preprocessing_func: function + The pre-processing function to use. Should take a Keras Input as it's only argument + """ def func(y_true, y_pred): """ Process input if a processing function has been passed, otherwise just return loss """ if preprocessing_func is not None: @@ -44,7 +44,25 @@ def func(y_true, y_pred): class DSSIMObjective(): """ DSSIM Loss Function - Code copy and pasted, with minor ammendments from: + Difference of Structural Similarity (DSSIM loss function). Clipped between 0 and 0.5 + + Parameters + ---------- + k_1: float, optional + Parameter of the SSIM. Default: `0.01` + k_2: float, optional + Parameter of the SSIM. Default: `0.03` + kernel_size: int, optional + Size of the sliding window Default: `3` + max_value: float, optional + Max value of the output. Default: `1.0` + + Notes + ------ + You should add a regularization term like a l2 loss in addition to this one. + + References + ---------- https://github.com/keras-team/keras-contrib/blob/master/keras_contrib/losses/dssim.py MIT License @@ -67,46 +85,59 @@ class DSSIMObjective(): AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. """ - # pylint: disable=C0103 - def __init__(self, k1=0.01, k2=0.03, kernel_size=3, max_value=1.0): - """ - Difference of Structural Similarity (DSSIM loss function). Clipped - between 0 and 0.5 - Note : You should add a regularization term like a l2 loss in - addition to this one. - Note : In theano, the `kernel_size` must be a factor of the output - size. So 3 could not be the `kernel_size` for an output of 32. - # Arguments - k1: Parameter of the SSIM (default 0.01) - k2: Parameter of the SSIM (default 0.03) - kernel_size: Size of the sliding window (default 3) - max_value: Max value of the output (default 1.0) - """ + SOFTWARE. + """ + def __init__(self, k_1=0.01, k_2=0.03, kernel_size=3, max_value=1.0): self.__name__ = 'DSSIMObjective' self.kernel_size = kernel_size - self.k1 = k1 - self.k2 = k2 + self.k_1 = k_1 + self.k_2 = k_2 self.max_value = max_value - self.c_1 = (self.k1 * self.max_value) ** 2 - self.c_2 = (self.k2 * self.max_value) ** 2 + self.c_1 = (self.k_1 * self.max_value) ** 2 + self.c_2 = (self.k_2 * self.max_value) ** 2 self.dim_ordering = K.image_data_format() self.backend = K.backend() @staticmethod - def __int_shape(x): - return K.int_shape(x) + def __int_shape(input_tensor): + """ Returns the shape of tensor or variable as a tuple of int or None entries. + + Parameters + ---------- + input_tensor: tensor or variable + The input to return the shape for + + Returns + ------- + tuple + A tuple of integers (or None entries) + """ + return K.int_shape(input_tensor) def __call__(self, y_true, y_pred): - # There are additional parameters for this function - # Note: some of the 'modes' for edge behavior do not yet have a - # gradient definition in the Theano tree and cannot be used for - # learning + """ Call the DSSIM Loss Function. + + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + + Returns + ------- + tensor + The DSSIM Loss value + + Notes + ----- + There are additional parameters for this function. some of the 'modes' for edge behavior + do not yet have a gradient definition in the Theano tree and cannot be used for learning + """ kernel = [self.kernel_size, self.kernel_size] y_true = K.reshape(y_true, [-1] + list(self.__int_shape(y_pred)[1:])) y_pred = K.reshape(y_pred, [-1] + list(self.__int_shape(y_pred)[1:])) - patches_pred = self.extract_image_patches(y_pred, kernel, kernel, @@ -124,7 +155,7 @@ def __call__(self, y_true, y_pred): # Get variance var_true = K.var(patches_true, axis=-1) var_pred = K.var(patches_pred, axis=-1) - # Get std dev + # Get standard deviation covar_true_pred = K.mean( patches_true * patches_pred, axis=-1) - u_true * u_pred @@ -132,18 +163,27 @@ def __call__(self, y_true, y_pred): 2 * covar_true_pred + self.c_2) denom = (K.square(u_true) + K.square(u_pred) + self.c_1) * ( var_pred + var_true + self.c_2) - ssim /= denom # no need for clipping, c_1 + c_2 make the denom non-zero + ssim /= denom # no need for clipping, c_1 + c_2 make the denorm non-zero return K.mean((1.0 - ssim) / 2.0) @staticmethod def _preprocess_padding(padding): - """Convert keras' padding to tensorflow's padding. - # Arguments - padding: string, `"same"` or `"valid"`. - # Returns - a string, `"SAME"` or `"VALID"`. - # Raises - ValueError: if `padding` is invalid. + """Convert keras padding to tensorflow padding. + + Parameters + ---------- + padding: string, + `"same"` or `"valid"`. + + Returns + ------- + str + `"SAME"` or `"VALID"`. + + Raises + ------ + ValueError + If `padding` is invalid. """ if padding == 'same': padding = 'SAME' @@ -153,43 +193,76 @@ def _preprocess_padding(padding): raise ValueError('Invalid padding:', padding) return padding - def extract_image_patches(self, x, ksizes, ssizes, padding='same', - data_format='channels_last'): - """ - Extract the patches from an image - # Parameters - x : The input image - ksizes : 2-d tuple with the kernel size - ssizes : 2-d tuple with the strides size - padding : 'same' or 'valid' - data_format : 'channels_last' or 'channels_first' - # Returns - The (k_w, k_h) patches extracted - TF ==> (batch_size, w, h, k_w, k_h, c) - TH ==> (batch_size, w, h, c, k_w, k_h) + def extract_image_patches(self, input_tensor, k_sizes, s_sizes, + padding='same', data_format='channels_last'): + """ Extract the patches from an image. + + Parameters + ---------- + input_tensor: tensor + The input image + k_sizes: tuple + 2-d tuple with the kernel size + s_sizes: tuple + 2-d tuple with the strides size + padding: str, optional + `"same"` or `"valid"`. Default: `"same"` + data_format: str, optional. + `"channels_last"` or `"channels_first"`. Default: `"channels_last"` + + Returns + ------- + The (k_w, k_h) patches extracted + Tensorflow ==> (batch_size, w, h, k_w, k_h, c) + Theano ==> (batch_size, w, h, c, k_w, k_h) """ - kernel = [1, ksizes[0], ksizes[1], 1] - strides = [1, ssizes[0], ssizes[1], 1] + kernel = [1, k_sizes[0], k_sizes[1], 1] + strides = [1, s_sizes[0], s_sizes[1], 1] padding = self._preprocess_padding(padding) if data_format == 'channels_first': - x = K.permute_dimensions(x, (0, 2, 3, 1)) - patches = extract_image_patches(x, kernel, strides, [1, 1, 1, 1], padding) + input_tensor = K.permute_dimensions(input_tensor, (0, 2, 3, 1)) + patches = extract_image_patches(input_tensor, kernel, strides, [1, 1, 1, 1], padding) return patches # <<< START: from Dfaker >>> # def PenalizedLoss(mask, loss_func, # pylint: disable=invalid-name mask_prop=1.0, mask_scaling=1.0, preprocessing_func=None): - """ Plaidml + tf Penalized loss function - mask_scaling: For multi-decoder output the target mask will likely be at - full size scaling, so this is the scaling factor to reduce - the mask by. - preprocessing_func: The preprocessing function to use. Should take a Keras Input - as it's only input + """ Plaidml and Tensorflow Penalized Loss function. + + Applies the given loss function just to the masked area of the image. + + Parameters + ---------- + mask: input tensor + The mask for the current image + loss_func: function + The actual loss function to use + mask_prop: float, optional + The amount of mask propagation. Default: `1.0` + mask_scaling: float, optional + For multi-decoder output the target mask will likely be at full size scaling, so this is + the scaling factor to reduce the mask by. Default: `1.0` + preprocessing_func: function, optional + If preprocessing is required on the input mask, then this should be the function to use. + The function should take a Keras Input as it's only argument. Set to ``None`` if no + preprocessing is to be performed. Default: ``None`` """ - - def scale_mask(mask, scaling): - """ Scale the input mask to be the same size as the input face """ + def _scale_mask(mask, scaling): + """ Scale the input mask to be the same size as the input face + + Parameters + ---------- + mask: input tensor + The mask for the current image + scaling: float + The amount to scale the input mask by + + Returns + ------- + tensor + The resized input mask + """ if scaling != 1.0: size = round(1 / scaling) mask = K.pool2d(mask, @@ -201,15 +274,32 @@ def scale_mask(mask, scaling): logger.debug("resized tensor: %s", mask) return mask - mask = scale_mask(mask, mask_scaling) + mask = _scale_mask(mask, mask_scaling) if preprocessing_func is not None: mask = preprocessing_func(mask) mask_as_k_inv_prop = 1 - mask_prop mask = (mask * mask_prop) + mask_as_k_inv_prop - def inner_loss(y_true, y_pred): - # Branching because tensorflows broadcasting is wonky and - # plaidmls concatenate is implemented ineficient. + def _inner_loss(y_true, y_pred): + """ Apply the loss function to the masked area of the image. + + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + + Returns + ------- + tensor + The Loss value + + Notes + ----- + Branching because TensorFlow's broadcasting is wonky and plaidML's concatenate is + implemented inefficiently. + """ if K.backend() == "plaidml.keras.backend": n_true = y_true * mask n_pred = y_pred * mask @@ -217,235 +307,40 @@ def inner_loss(y_true, y_pred): n_true = K.concatenate([y_true[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) n_pred = K.concatenate([y_pred[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) return loss_func(n_true, n_pred) - return inner_loss + return _inner_loss # <<< END: from Dfaker >>> # -# <<< START: from DFL >>> # -def style_loss(gaussian_blur_radius=0.0, loss_weight=1.0, wnd_size=0, step_size=1): - """ Style Loss from DeepFaceLab - https://github.com/iperov/DeepFaceLab """ - - if gaussian_blur_radius > 0.0: - gblur = gaussian_blur(gaussian_blur_radius) - - def std(content, style, loss_weight): - content_nc = K.int_shape(content)[-1] - style_nc = K.int_shape(style)[-1] - if content_nc != style_nc: - raise Exception("style_loss() content_nc != style_nc") - - axes = [1, 2] - c_mean, c_var = K.mean(content, axis=axes, keepdims=True), K.var(content, - axis=axes, - keepdims=True) - s_mean, s_var = K.mean(style, axis=axes, keepdims=True), K.var(style, - axis=axes, - keepdims=True) - c_std, s_std = K.sqrt(c_var + 1e-5), K.sqrt(s_var + 1e-5) - - mean_loss = K.sum(K.square(c_mean-s_mean)) - std_loss = K.sum(K.square(c_std-s_std)) - - return (mean_loss + std_loss) * (loss_weight / float(content_nc)) - - def func(target, style): - if wnd_size == 0: - if gaussian_blur_radius > 0.0: - return std(gblur(target), gblur(style), loss_weight=loss_weight) - return std(target, style, loss_weight=loss_weight) - - # currently unused - if K.backend() == "plaidml.keras.backend": - logger.warning("plaidML backend does not support style_loss. Disabling") - return 0 - shp = K.int_shape(target)[1] - k = (shp - wnd_size) // step_size + 1 - if gaussian_blur_radius > 0.0: - target, style = gblur(target), gblur(style) - target = tf.image.extract_image_patches(target, - [1, k, k, 1], - [1, 1, 1, 1], - [1, step_size, step_size, 1], - "VALID") - style = tf.image.extract_image_patches(style, - [1, k, k, 1], - [1, 1, 1, 1], - [1, step_size, step_size, 1], - "VALID") - return std(target, style, loss_weight) - - return func -# <<< END: from DFL >>> # - - -# <<< START: from Shoanlu GAN >>> # -def first_order(var_x, axis=1): - """ First Order Function from Shoanlu GAN """ - img_nrows = var_x.shape[1] - img_ncols = var_x.shape[2] - if axis == 1: - return K.abs(var_x[:, :img_nrows - 1, :img_ncols - 1, :] - var_x[:, 1:, :img_ncols - 1, :]) - if axis == 2: - return K.abs(var_x[:, :img_nrows - 1, :img_ncols - 1, :] - var_x[:, :img_nrows - 1, 1:, :]) - return None - - -def calc_loss(pred, target, loss='l2'): - """ Calculate Loss from Shoanlu GAN """ - if loss.lower() == "l2": - return K.mean(K.square(pred - target)) - if loss.lower() == "l1": - return K.mean(K.abs(pred - target)) - if loss.lower() == "cross_entropy": - return -K.mean(K.log(pred + K.epsilon()) * target + - K.log(1 - pred + K.epsilon()) * (1 - target)) - raise ValueError('Recieve an unknown loss type: {}.'.format(loss)) - - -def cyclic_loss(net_g1, net_g2, real1): - """ Cyclic Loss Function from Shoanlu GAN """ - fake2 = net_g2(real1)[-1] # fake2 ABGR - fake2 = Lambda(lambda x: x[:, :, :, 1:])(fake2) # fake2 BGR - cyclic1 = net_g1(fake2)[-1] # cyclic1 ABGR - cyclic1 = Lambda(lambda x: x[:, :, :, 1:])(cyclic1) # cyclic1 BGR - loss = calc_loss(cyclic1, real1, loss='l1') - return loss - - -def adversarial_loss(net_d, real, fake_abgr, distorted, gan_training="mixup_LSGAN", **weights): - """ Adversarial Loss Function from Shoanlu GAN """ - alpha = Lambda(lambda x: x[:, :, :, :1])(fake_abgr) - fake_bgr = Lambda(lambda x: x[:, :, :, 1:])(fake_abgr) - fake = alpha * fake_bgr + (1-alpha) * distorted - - if gan_training == "mixup_LSGAN": - dist = Beta(0.2, 0.2) - lam = dist.sample() - mixup = lam * concatenate([real, distorted]) + (1 - lam) * concatenate([fake, distorted]) - pred_fake = net_d(concatenate([fake, distorted])) - pred_mixup = net_d(mixup) - loss_d = calc_loss(pred_mixup, lam * K.ones_like(pred_mixup), "l2") - loss_g = weights['w_D'] * calc_loss(pred_fake, K.ones_like(pred_fake), "l2") - mixup2 = lam * concatenate([real, - distorted]) + (1 - lam) * concatenate([fake_bgr, - distorted]) - pred_fake_bgr = net_d(concatenate([fake_bgr, distorted])) - pred_mixup2 = net_d(mixup2) - loss_d += calc_loss(pred_mixup2, lam * K.ones_like(pred_mixup2), "l2") - loss_g += weights['w_D'] * calc_loss(pred_fake_bgr, K.ones_like(pred_fake_bgr), "l2") - elif gan_training == "relativistic_avg_LSGAN": - real_pred = net_d(concatenate([real, distorted])) - fake_pred = net_d(concatenate([fake, distorted])) - loss_d = K.mean(K.square(real_pred - K.ones_like(fake_pred)))/2 - loss_d += K.mean(K.square(fake_pred - K.zeros_like(fake_pred)))/2 - loss_g = weights['w_D'] * K.mean(K.square(fake_pred - K.ones_like(fake_pred))) - - fake_pred2 = net_d(concatenate([fake_bgr, distorted])) - loss_d += K.mean(K.square(real_pred - K.mean(fake_pred2, axis=0) - - K.ones_like(fake_pred2)))/2 - loss_d += K.mean(K.square(fake_pred2 - K.mean(real_pred, axis=0) - - K.zeros_like(fake_pred2)))/2 - loss_g += weights['w_D'] * K.mean(K.square(real_pred - K.mean(fake_pred2, axis=0) - - K.zeros_like(fake_pred2)))/2 - loss_g += weights['w_D'] * K.mean(K.square(fake_pred2 - K.mean(real_pred, axis=0) - - K.ones_like(fake_pred2)))/2 - else: - raise ValueError("Receive an unknown GAN training method: {gan_training}") - return loss_d, loss_g - - -def reconstruction_loss(real, fake_abgr, mask_eyes, model_outputs, **weights): - """ Reconstruction Loss Function from Shoanlu GAN """ - alpha = Lambda(lambda x: x[:, :, :, :1])(fake_abgr) - fake_bgr = Lambda(lambda x: x[:, :, :, 1:])(fake_abgr) - - loss_g = weights['w_recon'] * calc_loss(fake_bgr, real, "l1") - loss_g += weights['w_eyes'] * K.mean(K.abs(mask_eyes*(fake_bgr - real))) - - for out in model_outputs[:-1]: - out_size = out.get_shape().as_list() - resized_real = tf.image.resize_images(real, out_size[1:3]) - loss_g += weights['w_recon'] * calc_loss(out, resized_real, "l1") - return loss_g - - -def edge_loss(real, fake_abgr, mask_eyes, **weights): - """ Edge Loss Function from Shoanlu GAN """ - alpha = Lambda(lambda x: x[:, :, :, :1])(fake_abgr) - fake_bgr = Lambda(lambda x: x[:, :, :, 1:])(fake_abgr) - - loss_g = weights['w_edge'] * calc_loss(first_order(fake_bgr, axis=1), - first_order(real, axis=1), "l1") - loss_g += weights['w_edge'] * calc_loss(first_order(fake_bgr, axis=2), - first_order(real, axis=2), "l1") - shape_mask_eyes = mask_eyes.get_shape().as_list() - resized_mask_eyes = tf.image.resize_images(mask_eyes, - [shape_mask_eyes[1]-1, shape_mask_eyes[2]-1]) - loss_g += weights['w_eyes'] * K.mean(K.abs(resized_mask_eyes * - (first_order(fake_bgr, axis=1) - - first_order(real, axis=1)))) - loss_g += weights['w_eyes'] * K.mean(K.abs(resized_mask_eyes * - (first_order(fake_bgr, axis=2) - - first_order(real, axis=2)))) - return loss_g - - -def perceptual_loss(real, fake_abgr, distorted, vggface_feats, **weights): - """ Perceptual Loss Function from Shoanlu GAN """ - alpha = Lambda(lambda x: x[:, :, :, :1])(fake_abgr) - fake_bgr = Lambda(lambda x: x[:, :, :, 1:])(fake_abgr) - fake = alpha * fake_bgr + (1-alpha) * distorted - - def preprocess_vggface(var_x): - var_x = (var_x + 1.) / 2. * 255. # channel order: BGR - var_x -= [91.4953, 103.8827, 131.0912] - return var_x - - real_sz224 = tf.image.resize_images(real, [224, 224]) - real_sz224 = Lambda(preprocess_vggface)(real_sz224) - dist = Beta(0.2, 0.2) - lam = dist.sample() # use mixup trick here to reduce foward pass from 2 times to 1. - mixup = lam*fake_bgr + (1-lam)*fake - fake_sz224 = tf.image.resize_images(mixup, [224, 224]) - fake_sz224 = Lambda(preprocess_vggface)(fake_sz224) - real_feat112, real_feat55, real_feat28, real_feat7 = vggface_feats(real_sz224) - fake_feat112, fake_feat55, fake_feat28, fake_feat7 = vggface_feats(fake_sz224) - - # Apply instance norm on VGG(ResNet) features - # From MUNIT https://github.com/NVlabs/MUNIT - loss_g = 0 - - def instnorm(): - return InstanceNormalization() - - loss_g += weights['w_pl'][0] * calc_loss(instnorm()(fake_feat7), - instnorm()(real_feat7), "l2") - loss_g += weights['w_pl'][1] * calc_loss(instnorm()(fake_feat28), - instnorm()(real_feat28), "l2") - loss_g += weights['w_pl'][2] * calc_loss(instnorm()(fake_feat55), - instnorm()(real_feat55), "l2") - loss_g += weights['w_pl'][3] * calc_loss(instnorm()(fake_feat112), - instnorm()(real_feat112), "l2") - return loss_g -# <<< END: from Shoanlu GAN >>> # - - def generalized_loss(y_true, y_pred, alpha=1.0, beta=1.0/255.0): - """ - generalized function used to return a large variety of mathematical loss functions - primary benefit is smooth, differentiable version of L1 loss - - Barron, J. A More General Robust Loss Function - https://arxiv.org/pdf/1701.03077.pdf - Parameters: - alpha: penalty factor. larger number give larger weight to large deviations - beta: scale factor used to adjust to the input scale (i.e. inputs of mean 1e-4 or 256 ) - Return: - a loss value from the results of function(y_pred - y_true) - Example: - a=1.0, x>>c , c=1.0/255.0 will give a smoothly differentiable version of L1 / MAE loss - a=1.999999 (lim as a->2), beta=1.0/255.0 will give L2 / RMSE loss + """ Generalized function used to return a large variety of mathematical loss functions. + + The primary benefit is a smooth, differentiable version of L1 loss. + + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + alpha: float, optional + Penalty factor. Larger number give larger weight to large deviations. Default: `1.0` + beta: float, optional + Scale factor used to adjust to the input scale (i.e. inputs of mean `1e-4` or `256`). + Default: `1.0/255.0` + + Returns + ------- + tensor + The loss value from the results of function(y_pred - y_true) + + References + ---------- + Barron, J. A More General Robust Loss Function - https://arxiv.org/pdf/1701.03077.pdf + + Example + ------- + >>> a=1.0, x>>c , c=1.0/255.0 # will give a smoothly differentiable version of L1 / MAE loss + >>> a=1.999999 (limit as a->2), beta=1.0/255.0 # will give L2 / RMSE loss """ diff = y_pred - y_true second = (K.pow(K.pow(diff/beta, 2.) / K.abs(2.-alpha) + 1., (alpha/2.)) - 1.) @@ -454,18 +349,21 @@ def generalized_loss(y_true, y_pred, alpha=1.0, beta=1.0/255.0): return loss -def l_p_norm(y_true, y_pred, p_norm=np.inf): - """ - Calculate the L-p norm as a loss function, - valid choics of p are [0,1,no.inf] - """ - diff = y_true - y_pred - loss = tf.norm(diff, ord=p_norm, axis=-1) - return loss - - def l_inf_norm(y_true, y_pred): - """ Calculate the L-inf norm as a loss function """ + """ Calculate the L-inf norm as a loss function. + + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + + Returns + ------- + tensor + The loss value + """ diff = K.abs(y_true - y_pred) max_loss = K.max(diff, axis=(1, 2), keepdims=True) loss = K.mean(max_loss, axis=-1) @@ -473,53 +371,65 @@ def l_inf_norm(y_true, y_pred): def gradient_loss(y_true, y_pred): - """ - Calculates the first and second order gradient difference between pixels of - an image in the x and y dimensions. These gradients are then compared between - the ground truth and the predicted image and the difference is taken. When - used as a loss, its minimization will result in predicted images approaching - the same level of sharpness / blurriness as the ground truth. - - TV+TV2 Regularization with Nonconvex Sparseness-Inducing Penalty - for Image Restoration, Chengwu Lu & Hua Huang, 2014 - (http://downloads.hindawi.com/journals/mpe/2014/790547.pdf) - - Parameters: - y_true: The predicted frames at each scale - y_true: The ground truth frames at each scale - Return: - The GD loss + """ Gradient Loss Function. + + Calculates the first and second order gradient difference between pixels of an image in the x + and y dimensions. These gradients are then compared between the ground truth and the predicted + image and the difference is taken. When used as a loss, its minimization will result in + predicted images approaching the same level of sharpness / blurriness as the ground truth. + + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + + Returns + ------- + tensor + The loss value + + References + ---------- + TV+TV2 Regularization with Non-Convex Sparseness-Inducing Penalty for Image Restoration, + Chengwu Lu & Hua Huang, 2014 - http://downloads.hindawi.com/journals/mpe/2014/790547.pdf """ - def diff_x(img): + def _diff_x(img): + """ X Difference """ x_left = img[:, :, 1:2, :] - img[:, :, 0:1, :] x_inner = img[:, :, 2:, :] - img[:, :, :-2, :] x_right = img[:, :, -1:, :] - img[:, :, -2:-1, :] x_out = K.concatenate([x_left, x_inner, x_right], axis=2) return x_out * 0.5 - def diff_y(img): + def _diff_y(img): + """ Y Difference """ y_top = img[:, 1:2, :, :] - img[:, 0:1, :, :] y_inner = img[:, 2:, :, :] - img[:, :-2, :, :] y_bot = img[:, -1:, :, :] - img[:, -2:-1, :, :] y_out = K.concatenate([y_top, y_inner, y_bot], axis=1) return y_out * 0.5 - def diff_xx(img): + def _diff_xx(img): + """ X-X Difference """ x_left = img[:, :, 1:2, :] + img[:, :, 0:1, :] x_inner = img[:, :, 2:, :] + img[:, :, :-2, :] x_right = img[:, :, -1:, :] + img[:, :, -2:-1, :] x_out = K.concatenate([x_left, x_inner, x_right], axis=2) return x_out - 2.0 * img - def diff_yy(img): + def _diff_yy(img): + """ Y-Y Difference """ y_top = img[:, 1:2, :, :] + img[:, 0:1, :, :] y_inner = img[:, 2:, :, :] + img[:, :-2, :, :] y_bot = img[:, -1:, :, :] + img[:, -2:-1, :, :] y_out = K.concatenate([y_top, y_inner, y_bot], axis=1) return y_out - 2.0 * img - def diff_xy(img): + def _diff_xy(img): + """ X-Y Difference """ # xout1 top_left = img[:, 1:2, 1:2, :] + img[:, 0:1, 0:1, :] inner_left = img[:, 2:, 1:2, :] + img[:, :-2, 0:1, :] @@ -559,59 +469,65 @@ def diff_xy(img): tv_weight = 1.0 tv2_weight = 1.0 loss = 0.0 - loss += tv_weight * (generalized_loss(diff_x(y_true), diff_x(y_pred), alpha=1.9999) + - generalized_loss(diff_y(y_true), diff_y(y_pred), alpha=1.9999)) - loss += tv2_weight * (generalized_loss(diff_xx(y_true), diff_xx(y_pred), alpha=1.9999) + - generalized_loss(diff_yy(y_true), diff_yy(y_pred), alpha=1.9999) + - generalized_loss(diff_xy(y_true), diff_xy(y_pred), alpha=1.9999) * 2.) + loss += tv_weight * (generalized_loss(_diff_x(y_true), _diff_x(y_pred), alpha=1.9999) + + generalized_loss(_diff_y(y_true), _diff_y(y_pred), alpha=1.9999)) + loss += tv2_weight * (generalized_loss(_diff_xx(y_true), _diff_xx(y_pred), alpha=1.9999) + + generalized_loss(_diff_yy(y_true), _diff_yy(y_pred), alpha=1.9999) + + generalized_loss(_diff_xy(y_true), _diff_xy(y_pred), alpha=1.9999) * 2.) loss = loss / (tv_weight + tv2_weight) # TODO simplify to use MSE instead return loss def scharr_edges(image, magnitude): - """ - Returns a tensor holding modified Scharr edge maps. - Arguments: - image: Image tensor with shape [batch_size, h, w, d] and type float32. - The image(s) must be 2x2 or larger. - magnitude: Boolean to determine if the edge magnitude or edge direction is returned - Returns: - Tensor holding edge maps for each channel. Returns a tensor with shape - [batch_size, h, w, d, 2] where the last two dimensions hold [[dy[0], dx[0]], - [dy[1], dx[1]], ..., [dy[d-1], dx[d-1]]] calculated using the Scharr filter. + """ Returns a tensor holding modified Scharr edge maps. + + Parameters + ---------- + image: tensor + Image tensor with shape [batch_size, h, w, d] and type float32. The image(s) must be 2x2 + or larger. + magnitude: bool + Boolean to determine if the edge magnitude or edge direction is returned + + Returns + ------- + tensor + Tensor holding edge maps for each channel. Returns a tensor with shape `[batch_size, h, w, + d, 2]` where the last two dimensions hold `[[dy[0], dx[0]], [dy[1], dx[1]], ..., [dy[d-1], + dx[d-1]]]` calculated using the Scharr filter. """ # Define vertical and horizontal Scharr filters. - static_image_shape = image.get_shape() + static_image_shape = image.shape.dims if get_backend() == "amd" else image.get_shape() image_shape = K.shape(image) # 5x5 modified Scharr kernel ( reshape to (5,5,1,2) ) - matrix = [[[[0.00070, 0.00070]], - [[0.00520, 0.00370]], - [[0.03700, 0.00000]], - [[0.00520, -0.0037]], - [[0.00070, -0.0007]]], - [[[0.00370, 0.00520]], - [[0.11870, 0.11870]], - [[0.25890, 0.00000]], - [[0.11870, -0.1187]], - [[0.00370, -0.0052]]], - [[[0.00000, 0.03700]], - [[0.00000, 0.25890]], - [[0.00000, 0.00000]], - [[0.00000, -0.2589]], - [[0.00000, -0.0370]]], - [[[-0.0037, 0.00520]], - [[-0.1187, 0.11870]], - [[-0.2589, 0.00000]], - [[-0.1187, -0.1187]], - [[-0.0037, -0.0052]]], - [[[-0.0007, 0.00070]], - [[-0.0052, 0.00370]], - [[-0.0370, 0.00000]], - [[-0.0052, -0.0037]], - [[-0.0007, -0.0007]]]] + matrix = np.array([[[[0.00070, 0.00070]], + [[0.00520, 0.00370]], + [[0.03700, 0.00000]], + [[0.00520, -0.0037]], + [[0.00070, -0.0007]]], + [[[0.00370, 0.00520]], + [[0.11870, 0.11870]], + [[0.25890, 0.00000]], + [[0.11870, -0.1187]], + [[0.00370, -0.0052]]], + [[[0.00000, 0.03700]], + [[0.00000, 0.25890]], + [[0.00000, 0.00000]], + [[0.00000, -0.2589]], + [[0.00000, -0.0370]]], + [[[-0.0037, 0.00520]], + [[-0.1187, 0.11870]], + [[-0.2589, 0.00000]], + [[-0.1187, -0.1187]], + [[-0.0037, -0.0052]]], + [[[-0.0007, 0.00070]], + [[-0.0052, 0.00370]], + [[-0.0370, 0.00000]], + [[-0.0052, -0.0037]], + [[-0.0007, -0.0007]]]]) num_kernels = [2] kernels = K.constant(matrix, dtype='float32') kernels = K.tile(kernels, [1, 1, image_shape[-1], 1]) @@ -619,7 +535,7 @@ def scharr_edges(image, magnitude): # Use depth-wise convolution to calculate edge maps per channel. # Output tensor has shape [batch_size, h, w, d * num_kernels]. pad_sizes = [[0, 0], [2, 2], [2, 2], [0, 0]] - padded = tf.pad(image, pad_sizes, mode='REFLECT') + padded = pad(image, pad_sizes, mode='REFLECT') output = K.depthwise_conv2d(padded, kernels) if not magnitude: # direction of edges @@ -627,19 +543,34 @@ def scharr_edges(image, magnitude): shape = K.concatenate([image_shape, num_kernels], axis=0) output = K.reshape(output, shape=shape) output.set_shape(static_image_shape.concatenate(num_kernels)) - output = tf.atan(K.squeeze(output[:, :, :, :, 0] / output[:, :, :, :, 1])) - # magnitude of edges -- unified x & y edges don't work well with NN - + output = tf.atan(K.squeeze(output[:, :, :, :, 0] / output[:, :, :, :, 1], axis=None)) + # magnitude of edges -- unified x & y edges don't work well with Neural Networks return output def gmsd_loss(y_true, y_pred): - """ - Improved image quality metric over MS-SSIM with easier calc + """ Gradient Magnitude Similarity Deviation Loss. + + Improved image quality metric over MS-SSIM with easier calculations + + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + + Returns + ------- + tensor + The loss value + + References + ---------- http://www4.comp.polyu.edu.hk/~cslzhang/IQA/GMSD/GMSD.htm https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf - """ + """ true_edge = scharr_edges(y_true, True) pred_edge = scharr_edges(y_pred, True) ephsilon = 0.0025 @@ -651,291 +582,58 @@ def gmsd_loss(y_true, y_pred): return gmsd -def ms_ssim_calc(img1, img2, max_val=1.0, power_factors=(0.0517, 0.3295, 0.3462, 0.2726)): - """ - Computes the MS-SSIM between img1 and img2. - This function assumes that `img1` and `img2` are image batches, i.e. the last - three dimensions are [height, width, channels]. - Note: The true SSIM is only defined on grayscale. This function does not - perform any colorspace transform. (If input is already YUV, then it will - compute YUV SSIM average.) - Original paper: Wang, Zhou, Eero P. Simoncelli, and Alan C. Bovik. "Multiscale - structural similarity for image quality assessment." Signals, Systems and - Computers, 2004. - Arguments: - img1: First image batch. - img2: Second image batch. Must have the same rank as img1. - max_val: The dynamic range of the images (i.e., the difference between the - maximum the and minimum allowed values). - power_factors: Iterable of weights for each of the scales. The number of - scales used is the length of the list. Index 0 is the unscaled - resolution's weight and each increasing scale corresponds to the image - being downsampled by 2. Defaults to (0.0448, 0.2856, 0.3001, 0.2363, - 0.1333), which are the values obtained in the original paper. - Returns: - A tensor containing an MS-SSIM value for each image in batch. The values - are in range [0, 1]. Returns a tensor with shape: - broadcast(img1.shape[:-3], img2.shape[:-3]). - """ - - def _verify_compatible_image_shapes(img1, img2): - """ - Checks if two image tensors are compatible for applying SSIM or PSNR. - This function checks if two sets of images have ranks at least 3, and if the - last three dimensions match. - Args: - img1: Tensor containing the first image batch. - img2: Tensor containing the second image batch. - Returns: - A tuple containing: the first tensor shape, the second tensor shape, and a - list of control_flow_ops.Assert() ops implementing the checks. - Raises: - ValueError: When static shape check fails. - """ - shape1 = img1.get_shape().with_rank_at_least(3) - shape2 = img2.get_shape().with_rank_at_least(3) - shape1[-3:].assert_is_compatible_with(shape2[-3:]) - - if shape1.ndims is not None and shape2.ndims is not None: - for dim1, dim2 in zip(reversed(shape1[:-3]), reversed(shape2[:-3])): - if not (dim1 == 1 or dim2 == 1 or dim1.is_compatible_with(dim2)): - raise ValueError('Two images are not compatible: %s and %s' % (shape1, shape2)) - - # Now assign shape tensors. - shape1, shape2 = tf.shape_n([img1, img2]) - - # TODO(sjhwang): Check if shape1[:-3] and shape2[:-3] are broadcastable. - checks = [] - checks.append(tf.Assert(tf.greater_equal(tf.size(shape1), 3), - [shape1, shape2], summarize=10)) - checks.append(tf.Assert(tf.reduce_all(tf.equal(shape1[-3:], shape2[-3:])), - [shape1, shape2], summarize=10)) - - return shape1, shape2, checks - - def _ssim_per_channel(img1, img2, max_val=1.0): - """ - Computes SSIM index between img1 and img2 per color channel. - This function matches the standard SSIM implementation from: - Wang, Z., Bovik, A. C., Sheikh, H. R., & Simoncelli, E. P. (2004). Image - quality assessment: from error visibility to structural similarity. IEEE - transactions on image processing. - Details: - - 11x11 Gaussian filter of width 1.5 is used. - - k1 = 0.01, k2 = 0.03 as in the original paper. - Args: - img1: First image batch. - img2: Second image batch. - max_val: The dynamic range of the images (i.e., the difference between the - maximum the and minimum allowed values). - Returns: - A pair of tensors containing and channel-wise SSIM and contrast-structure - values. The shape is [..., channels]. - """ - - def _fspecial_gauss(size, sigma): - """ Function to mimic the 'fspecial' gaussian MATLAB function. """ - - size = tf.convert_to_tensor(size, 'int32') - sigma = tf.convert_to_tensor(sigma) - coords = tf.cast(tf.range(size), sigma.dtype) - coords -= tf.cast(size - 1, sigma.dtype) / 2.0 - - gauss = tf.square(coords) - gauss *= -0.5 / tf.square(sigma) - gauss = tf.reshape(gauss, shape=[1, -1]) + tf.reshape(gauss, shape=[-1, 1]) - gauss = tf.reshape(gauss, shape=[1, -1]) # For tf.nn.softmax(). - gauss = tf.nn.softmax(gauss) - return tf.reshape(gauss, shape=[size, size, 1, 1]) - - def _ssim_helper(img1, img2, max_val, kernel, compensation=1.): - """ - Helper function for computing SSIM. - SSIM estimates covariances with weighted sums. The default parameters - use a biased estimate of the covariance: - Suppose `reducer` is a weighted sum, then the mean estimators are - mu_x = sum_i w_i x_i, - mu_y = sum_i w_i y_i, - where w_i's are the weighted-sum weights, and covariance estimator is - cov_{xy} = sum_i w_i (x_i - mu_x) (y_i - mu_y) - with assumption sum_i w_i = 1. This covariance estimator is biased, since - E[cov_{xy}] = (1 - sum_i w_i ^ 2) Cov(X, Y). - For SSIM measure with unbiased covariance estimators, pass as `compensation` - argument (1 - sum_i w_i ^ 2). - Arguments: - img1: First set of images. - img2: Second set of images. - reducer: Function that computes 'local' averages from set of images. - For non-covolutional version, this is usually tf.reduce_mean(img1, [1, 2]), - and for convolutional version, this is usually tf.nn.avg_pool or - tf.nn.conv2d with weighted-sum kernel. - max_val: The dynamic range (i.e., the difference between the maximum - possible allowed value and the minimum allowed value). - compensation: Compensation factor. See above. - Returns: - A pair containing the luminance measure, and the contrast-structure measure. - """ - - def reducer(img1, kernel): - shape = tf.shape(img1) - img1 = tf.reshape(img1, shape=tf.concat([[-1], shape[-3:]], 0)) - img2 = tf.nn.depthwise_conv2d(img1, kernel, strides=[1, 1, 1, 1], padding='VALID') - return tf.reshape(img2, tf.concat([shape[:-3], tf.shape(img2)[1:]], 0)) - - c_one = (0.01 * max_val) ** 2 - c_two = ((0.03 * max_val)) ** 2 * compensation - - # SSIM luminance measure is - # (2 * mu_x * mu_y + c_one) / (mu_x ** 2 + mu_y ** 2 + c_one). - mean0 = reducer(img1, kernel) - mean1 = reducer(img2, kernel) - num0 = mean0 * mean1 * 2. - den0 = tf.square(mean0) + tf.square(mean1) - luminance = (num0 + c_one) / (den0 + c_one) - - # SSIM contrast-structure measure is - # (2 * cov_{xy} + c_two) / (cov_{xx} + cov_{yy} + c_two). - # Note that `reducer` is a weighted sum with weight w_k, \sum_i w_i = 1, then - # cov_{xy} = \sum_i w_i (x_i - \mu_x) (y_i - \mu_y) - # = \sum_i w_i x_i y_i - (\sum_i w_i x_i) (\sum_j w_j y_j). - num1 = reducer(img1 * img2, kernel) * 2.0 - den1 = reducer(tf.square(img1) + tf.square(img2), kernel) - c_s = (num1 - num0 + c_two) / (den1 - den0 + c_two) - - # SSIM score is the product of the luminance and contrast-structure measures. - return luminance, c_s - - filter_size = tf.constant(9, dtype='int32') # changed from 11 to 9 due - filter_sigma = tf.constant(1.5, dtype=img1.dtype) - - shape1, shape2 = tf.shape_n([img1, img2]) - checks = [tf.Assert(tf.reduce_all(tf.greater_equal(shape1[-3:-1], filter_size)), - [shape1, filter_size], summarize=8), - tf.Assert(tf.reduce_all(tf.greater_equal(shape2[-3:-1], filter_size)), - [shape2, filter_size], summarize=8)] - - # Enforce the check to run before computation. - with tf.control_dependencies(checks): - img1 = tf.identity(img1) - - # TODO(sjhwang): Try to cache kernels and compensation factor. - kernel = _fspecial_gauss(filter_size, filter_sigma) - kernel = tf.tile(kernel, multiples=[1, 1, shape1[-1], 1]) - - # The correct compensation factor is `1.0 - tf.reduce_sum(tf.square(kernel))`, - # but to match MATLAB implementation of MS-SSIM, we use 1.0 instead. - compensation = 1. - - # TODO(sjhwang): Try FFT. - # TODO(sjhwang): Gaussian kernel is separable in space. Consider applying - # 1-by-n and n-by-1 Gaussain filters instead of an n-by-n filter. - - luminance, c_s = _ssim_helper(img1, img2, max_val, kernel, compensation) - - # Average over the second and the third from the last: height, width. - axes = tf.constant([-3, -2], dtype='int32') - ssim_val = tf.reduce_mean(luminance * c_s, axes) - c_s = tf.reduce_mean(c_s, axes) - return ssim_val, c_s - - def do_pad(images, remainder): - padding = tf.expand_dims(remainder, -1) - padding = tf.pad(padding, [[1, 0], [1, 0]]) - return [tf.pad(x, padding, mode='SYMMETRIC') for x in images] - - # Shape checking. - shape1 = img1.get_shape().with_rank_at_least(3) - shape2 = img2.get_shape().with_rank_at_least(3) - shape1[-3:].merge_with(shape2[-3:]) - - with tf.name_scope(None, 'MS-SSIM', [img1, img2]): - shape1, shape2, checks = _verify_compatible_image_shapes(img1, img2) - with tf.control_dependencies(checks): - img1 = tf.identity(img1) - - # Need to convert the images to float32. Scale max_val accordingly so that - # SSIM is computed correctly. - max_val = tf.cast(max_val, img1.dtype) - max_val = tf.image.convert_image_dtype(max_val, 'float32') - img1 = tf.image.convert_image_dtype(img1, 'float32') - img2 = tf.image.convert_image_dtype(img2, 'float32') - - imgs = [img1, img2] - shapes = [shape1, shape2] - - # img1 and img2 are assumed to be a (multi-dimensional) batch of - # 3-dimensional images (height, width, channels). `heads` contain the batch - # dimensions, and `tails` contain the image dimensions. - heads = [s[:-3] for s in shapes] - tails = [s[-3:] for s in shapes] - - divisor = [1, 2, 2, 1] - divisor_tensor = tf.constant(divisor[1:], dtype='int32') - - mc_s = [] - for k in range(len(power_factors)): - with tf.name_scope(None, 'Scale%d' % k, imgs): - if k > 0: - # Avg pool takes rank 4 tensors. Flatten leading dimensions. - zipped = zip(imgs, tails) - flat_imgs = [tf.reshape(x, tf.concat([[-1], t], 0)) for x, t in zipped] - remainder = tails[0] % divisor_tensor - need_padding = tf.reduce_any(tf.not_equal(remainder, 0)) - padded = tf.cond(need_padding, - lambda: do_pad(flat_imgs, remainder), lambda: flat_imgs) - - downscaled = [tf.nn.avg_pool(x, - ksize=divisor, - strides=divisor, - padding='VALID') for x in padded] - tails = [x[1:] for x in tf.shape_n(downscaled)] - zipper = zip(downscaled, heads, tails) - imgs = [tf.reshape(x, tf.concat([h, t], 0)) for x, h, t in zipper] - - # Overwrite previous ssim value since we only need the last one. - ssim_per_channel, c_s = _ssim_per_channel(*imgs, max_val=max_val) - mc_s.append(tf.nn.relu(c_s)) - - # Remove the c_s score for the last scale. In the MS-SSIM calculation, - # we use the l(p) at the highest scale. l(p) * c_s(p) is ssim(p). - mc_s.pop() # Remove the c_s score for the last scale. - mcs_and_ssim = tf.stack(mc_s + [tf.nn.relu(ssim_per_channel)], axis=-1) - # Take weighted geometric mean across the scale axis. - ms_ssim = tf.reduce_prod(tf.pow(mcs_and_ssim, power_factors), [-1]) - - return tf.reduce_mean(ms_ssim, [-1]) # Avg over color channels. - - -def ms_ssim_loss(y_true, y_pred): - """ Keras loss function for MS-SSIM """ - expanded = K.expand_dims(1.0 - ms_ssim_calc(y_true, y_pred), axis=-1) - loss = K.expand_dims(expanded, axis=-1) - # need to expand to [1,height,width] dimensions for Keras. modify to not be hard-coded - return K.tile(loss, [1, 64, 64]) - - # Gaussian Blur is here as it is only used for losses. # It was previously kept in lib/model/masks but the import of keras backend # breaks plaidml def gaussian_blur(radius=2.0): - """ From https://github.com/iperov/DeepFaceLab - Used for blurring mask in training """ - def gaussian(var_x, radius, sigma): + """ Apply gaussian blur to an input. + + Used for blurring mask in training. + + Parameters + ---------- + radius: float, optional + The kernel radius for applying gaussian blur. Default: `2.0` + + Returns + ------- + tensor + The input tensor with gaussian blurring applied + + References + ---------- + https://github.com/iperov/DeepFaceLab + """ + def _gaussian(var_x, radius, sigma): + """ Obtain the gaussian kernel. """ return np.exp(-(float(var_x) - float(radius)) ** 2 / (2 * sigma ** 2)) - def make_kernel(sigma): + def _make_kernel(sigma): + """ Make the gaussian kernel. """ kernel_size = max(3, int(2 * 2 * sigma + 1)) mean = np.floor(0.5 * kernel_size) - kernel_1d = np.array([gaussian(x, mean, sigma) for x in range(kernel_size)]) + kernel_1d = np.array([_gaussian(x, mean, sigma) for x in range(kernel_size)]) np_kernel = np.outer(kernel_1d, kernel_1d).astype(dtype=K.floatx()) kernel = np_kernel / np.sum(np_kernel) return kernel - gauss_kernel = make_kernel(radius) + gauss_kernel = _make_kernel(radius) gauss_kernel = gauss_kernel[:, :, np.newaxis, np.newaxis] - def func(input_): - inputs = [input_[:, :, :, i:i + 1] for i in range(K.int_shape(input_)[-1])] + def func(input_tensor): + """ Apply gaussian blurring to the input tensor + + Parameters + ---------- + input_tensor: tensor + The input to have gaussian blurring applied. + + Returns + ------- + tensor + The input with gaussian blurring applied + """ + inputs = [input_tensor[:, :, :, i:i + 1] for i in range(K.int_shape(input_tensor)[-1])] outputs = [K.conv2d(inp, K.constant(gauss_kernel), strides=(1, 1), padding="same") for inp in inputs] return K.concatenate(outputs, axis=-1) diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index 516e42152c..b2d3aebe30 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -1,31 +1,54 @@ #!/usr/bin/env python3 -""" Neural Network Blocks for faceswap.py - Blocks from: - the original https://www.reddit.com/r/deepfakes/ code sample + contribs - dfaker: https://github.com/dfaker/df - shoanlu GAN: https://github.com/shaoanlu/faceswap-GAN""" +""" Neural Network Blocks for faceswap.py. """ import logging -import tensorflow as tf -import keras.backend as K -from keras.layers import (add, Add, BatchNormalization, concatenate, Lambda, regularizers, - Permute, Reshape, SeparableConv2D, Softmax, UpSampling2D) +from keras.layers import Add, SeparableConv2D from keras.layers.advanced_activations import LeakyReLU from keras.layers.convolutional import Conv2D from keras.layers.core import Activation from keras.initializers import he_uniform, VarianceScaling from .initializers import ICNR, ConvolutionAware -from .layers import PixelShuffler, SubPixelUpscaling, ReflectionPadding2D, Scale -from .normalization import GroupNormalization, InstanceNormalization +from .layers import PixelShuffler, SubPixelUpscaling, ReflectionPadding2D +from .normalization import InstanceNormalization logger = logging.getLogger(__name__) # pylint: disable=invalid-name class NNBlocks(): - """ Blocks to use for creating models """ - def __init__(self, use_subpixel=False, use_icnr_init=False, use_convaware_init=False, - use_reflect_padding=False, first_run=True): + """ Blocks that are often used for multiple models are stored here for easy access. + + This class is always brought in as ``self.blocks`` in all model plugins so that all models + have access to them. + + The parameters passed into this class should ultimately originate from the user's training + configuration file, rather than being hard-coded at the plugin level. + + Parameters + ---------- + use_subpixel: bool, Optional + ``True`` if sub-pixel up-scaling layer should be used instead of pixel shuffler for + up-scaling. This option is deprecated as sub-pixel up-scaling is Nvidia only, but is kept + for legacy models. Default: ``False`` + use_icnr_init: bool, Optional + ``True`` if ICNR initialization should be used rather than the default. Default: ``False`` + use_convaware_init: bool, Optional + ``True`` if Convolutional Aware initialization should be used rather than the default. + Default: ``False`` + use_reflect_padding: bool, Optional + ``True`` if Reflect Padding initialization should be used rather than the padding. + Default: ``False`` + first_run: bool, Optional + ``True`` if a model is being created for the first time, ``False`` if a model is being + resumed. Used to prevent Convolutional Aware weights from being calculated when a model + is being reloaded. Default: ``True`` + """ + def __init__(self, + use_subpixel=False, + use_icnr_init=False, + use_convaware_init=False, + use_reflect_padding=False, + first_run=True): logger.debug("Initializing %s: (use_subpixel: %s, use_icnr_init: %s, use_convaware_init: " "%s, use_reflect_padding: %s, first_run: %s)", self.__class__.__name__, use_subpixel, use_icnr_init, use_convaware_init, @@ -41,18 +64,45 @@ def __init__(self, use_subpixel=False, use_icnr_init=False, use_convaware_init=F "few minutes...") logger.debug("Initialized %s", self.__class__.__name__) - def get_name(self, name): - """ Return unique layer name for requested block """ + def _get_name(self, name): + """ Return unique layer name for requested block. + + As blocks can be used multiple times, auto appends an integer to the end of the requested + name to keep all block names unique + + Parameters + ---------- + name: str + The requested name for the layer + + Returns + ------- + str + The unique name for this layer + """ self.names[name] = self.names.setdefault(name, -1) + 1 name = "{}_{}".format(name, self.names[name]) logger.debug("Generating block name: %s", name) return name - def set_default_initializer(self, kwargs): - """ Sets the default initializer for conv2D and Seperable conv2D layers - to conv_aware or he_uniform(). - if a specific initializer has been passed in then the specified initializer - will be used rather than the default """ + def _set_default_initializer(self, kwargs): + """ Sets the default initializer for convolution 2D and Seperable convolution 2D layers + to Convolutional Aware or he_uniform. + + if a specific initializer has been passed in from the model plugin, then the specified + initializer will be used rather than the default. + + Parameters + ---------- + kwargs: dict + The keyword arguments for the current layer + + Returns + ------- + dict + The keyword arguments for the current layer with the initializer updated to + the select default value + """ if "kernel_initializer" in kwargs: logger.debug("Using model specified initializer: %s", kwargs["kernel_initializer"]) return kwargs @@ -69,40 +119,126 @@ def set_default_initializer(self, kwargs): return kwargs @staticmethod - def switch_kernel_initializer(kwargs, initializer): - """ Switch the initializer in the given kwargs to the given initializer - and return the previous initializer to caller """ + def _switch_kernel_initializer(kwargs, initializer): + """ Switch the initializer in the given kwargs to the given initializer and return the + previous initializer to caller. + + For residual blocks and up-scaling, user selected initializer methods should replace those + set by the model. This method updates the initializer for the layer, and returns the + original initializer so that it can be set back to the layer's key word arguments for + subsequent layers where the initializer should not be switched. + + Parameters + ---------- + kwargs: dict + The keyword arguments for the current layer + initializer: keras or faceswap initializer class + The initializer that should replace the current initializer that exists in keyword + arguments + + Returns + ------- + keras or faceswap initializer class + The original initializer that existed in the given keyword arguments + """ original = kwargs.get("kernel_initializer", None) kwargs["kernel_initializer"] = initializer logger.debug("Switched kernel_initializer from %s to %s", original, initializer) return original - def conv2d(self, inp, filters, kernel_size, strides=(1, 1), padding="same", **kwargs): - """ A standard conv2D layer with correct initialization """ - logger.debug("inp: %s, filters: %s, kernel_size: %s, strides: %s, padding: %s, " - "kwargs: %s)", inp, filters, kernel_size, strides, padding, kwargs) + def conv2d(self, input_tensor, filters, kernel_size, strides=(1, 1), padding="same", **kwargs): + """ A standard Convolution 2D layer with correct initialization. + + This layer creates a convolution kernel that is convolved with the layer input to produce + a tensor of outputs. + + Parameters + ---------- + input_tensor: tensor + The input tensor to the layer + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution) + kernel_size: int + An integer or tuple/list of 2 integers, specifying the height and width of the 2D + convolution window. Can be a single integer to specify the same value for all spatial + dimensions + strides: tuple, optional + An integer or tuple/list of 2 integers, specifying the strides of the convolution along + the height and width. Can be a single integer to specify the same value for all spatial + dimensions. Default: `(1, 1)` + padding: ["valid", "same"], optional + The padding to use. Default: `"same"` + kwargs: dict + Any additional Keras standard layer keyword arguments + + Returns + ------- + tensor + The output tensor from the Convolution 2D Layer + """ + logger.debug("input_tensor: %s, filters: %s, kernel_size: %s, strides: %s, padding: %s, " + "kwargs: %s)", input_tensor, filters, kernel_size, strides, padding, kwargs) if kwargs.get("name", None) is None: - kwargs["name"] = self.get_name("conv2d_{}".format(inp.shape[1])) - kwargs = self.set_default_initializer(kwargs) + kwargs["name"] = self._get_name("conv2d_{}".format(input_tensor.shape[1])) + kwargs = self._set_default_initializer(kwargs) var_x = Conv2D(filters, kernel_size, strides=strides, padding=padding, - **kwargs)(inp) + **kwargs)(input_tensor) return var_x # <<< Original Model Blocks >>> # - def conv(self, inp, filters, kernel_size=5, strides=2, padding="same", + def conv(self, input_tensor, filters, kernel_size=5, strides=2, padding="same", use_instance_norm=False, res_block_follows=False, **kwargs): - """ Convolution Layer""" - logger.debug("inp: %s, filters: %s, kernel_size: %s, strides: %s, use_instance_norm: %s, " - "kwargs: %s)", inp, filters, kernel_size, strides, use_instance_norm, kwargs) - name = self.get_name("conv_{}".format(inp.shape[1])) + """ A standard Convolution 2D layer which applies user specified configuration to the + layer. + + Adds reflection padding if it has been selected by the user, and other post-processing + if requested by the plugin. + + Parameters + ---------- + input_tensor: tensor + The input tensor to the layer + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution) + kernel_size: int, optional + An integer or tuple/list of 2 integers, specifying the height and width of the 2D + convolution window. Can be a single integer to specify the same value for all spatial + dimensions. Default: 5 + strides: tuple or int, optional + An integer or tuple/list of 2 integers, specifying the strides of the convolution along + the height and width. Can be a single integer to specify the same value for all spatial + dimensions. Default: `2` + padding: ["valid", "same"], optional + The padding to use. Default: `"same"` + use_instance_norm: bool, optional + ``True`` if instance normalization should be applied after the convolutional layer. + Default: ``False`` + res_block_follows: bool, optional + If a residual block will follow this layer, then this should be set to `True` to add + a leaky ReLu after the convolutional layer. Default: ``False`` + kwargs: dict + Any additional Keras standard layer keyword arguments + + Returns + ------- + tensor + The output tensor from the Convolution 2D Layer + """ + logger.debug("input_tensor: %s, filters: %s, kernel_size: %s, strides: %s, " + "use_instance_norm: %s, kwargs: %s)", input_tensor, filters, kernel_size, + strides, use_instance_norm, kwargs) + name = self._get_name("conv_{}".format(input_tensor.shape[1])) if self.use_reflect_padding: - inp = ReflectionPadding2D(stride=strides, - kernel_size=kernel_size, - name="{}_reflectionpadding2d".format(name))(inp) + input_tensor = ReflectionPadding2D( + stride=strides, + kernel_size=kernel_size, + name="{}_reflectionpadding2d".format(name))(input_tensor) padding = "valid" - var_x = self.conv2d(inp, filters, + var_x = self.conv2d(input_tensor, filters, kernel_size=kernel_size, strides=strides, padding=padding, @@ -114,29 +250,63 @@ def conv(self, inp, filters, kernel_size=5, strides=2, padding="same", var_x = LeakyReLU(0.1, name="{}_leakyrelu".format(name))(var_x) return var_x - def upscale(self, inp, filters, kernel_size=3, padding="same", + def upscale(self, input_tensor, filters, kernel_size=3, padding="same", use_instance_norm=False, res_block_follows=False, scale_factor=2, **kwargs): - """ Upscale Layer """ - logger.debug("inp: %s, filters: %s, kernel_size: %s, use_instance_norm: %s, kwargs: %s)", - inp, filters, kernel_size, use_instance_norm, kwargs) - name = self.get_name("upscale_{}".format(inp.shape[1])) + """ An upscale layer for sub-pixel up-scaling. + + Adds reflection padding if it has been selected by the user, and other post-processing + if requested by the plugin. + + Parameters + ---------- + input_tensor: tensor + The input tensor to the layer + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution) + kernel_size: int, optional + An integer or tuple/list of 2 integers, specifying the height and width of the 2D + convolution window. Can be a single integer to specify the same value for all spatial + dimensions. Default: 3 + padding: ["valid", "same"], optional + The padding to use. Default: `"same"` + use_instance_norm: bool, optional + ``True`` if instance normalization should be applied after the convolutional layer. + Default: ``False`` + res_block_follows: bool, optional + If a residual block will follow this layer, then this should be set to `True` to add + a leaky ReLu after the convolutional layer. Default: ``False`` + scale_factor: int, optional + The amount to upscale the image. Default: `2` + kwargs: dict + Any additional Keras standard layer keyword arguments + + Returns + ------- + tensor + The output tensor from the Upscale layer + """ + logger.debug("input_tensor: %s, filters: %s, kernel_size: %s, use_instance_norm: %s, " + "kwargs: %s)", input_tensor, filters, kernel_size, use_instance_norm, kwargs) + name = self._get_name("upscale_{}".format(input_tensor.shape[1])) if self.use_reflect_padding: - inp = ReflectionPadding2D(stride=1, - kernel_size=kernel_size, - name="{}_reflectionpadding2d".format(name))(inp) + input_tensor = ReflectionPadding2D( + stride=1, + kernel_size=kernel_size, + name="{}_reflectionpadding2d".format(name))(input_tensor) padding = "valid" - kwargs = self.set_default_initializer(kwargs) + kwargs = self._set_default_initializer(kwargs) if self.use_icnr_init: - original_init = self.switch_kernel_initializer( + original_init = self._switch_kernel_initializer( kwargs, ICNR(initializer=kwargs["kernel_initializer"])) - var_x = self.conv2d(inp, filters * scale_factor * scale_factor, + var_x = self.conv2d(input_tensor, filters * scale_factor * scale_factor, kernel_size=kernel_size, padding=padding, name="{}_conv2d".format(name), **kwargs) if self.use_icnr_init: - self.switch_kernel_initializer(kwargs, original_init) + self._switch_kernel_initializer(kwargs, original_init) if use_instance_norm: var_x = InstanceNormalization(name="{}_instancenorm".format(name))(var_x) if not res_block_follows: @@ -149,12 +319,34 @@ def upscale(self, inp, filters, kernel_size=3, padding="same", return var_x # <<< DFaker Model Blocks >>> # - def res_block(self, inp, filters, kernel_size=3, padding="same", **kwargs): - """ Residual block """ - logger.debug("inp: %s, filters: %s, kernel_size: %s, kwargs: %s)", - inp, filters, kernel_size, kwargs) - name = self.get_name("residual_{}".format(inp.shape[1])) - var_x = LeakyReLU(alpha=0.2, name="{}_leakyrelu_0".format(name))(inp) + def res_block(self, input_tensor, filters, kernel_size=3, padding="same", **kwargs): + """ Residual block. + + Parameters + ---------- + input_tensor: tensor + The input tensor to the layer + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution) + kernel_size: int, optional + An integer or tuple/list of 2 integers, specifying the height and width of the 2D + convolution window. Can be a single integer to specify the same value for all spatial + dimensions. Default: 3 + padding: ["valid", "same"], optional + The padding to use. Default: `"same"` + kwargs: dict + Any additional Keras standard layer keyword arguments + + Returns + ------- + tensor + The output tensor from the Upscale layer + """ + logger.debug("input_tensor: %s, filters: %s, kernel_size: %s, kwargs: %s)", + input_tensor, filters, kernel_size, kwargs) + name = self._get_name("residual_{}".format(input_tensor.shape[1])) + var_x = LeakyReLU(alpha=0.2, name="{}_leakyrelu_0".format(name))(input_tensor) if self.use_reflect_padding: var_x = ReflectionPadding2D(stride=1, kernel_size=kernel_size, @@ -172,7 +364,7 @@ def res_block(self, inp, filters, kernel_size=3, padding="same", **kwargs): name="{}_reflectionpadding2d_1".format(name))(var_x) padding = "valid" if not self.use_convaware_init: - original_init = self.switch_kernel_initializer(kwargs, VarianceScaling( + original_init = self._switch_kernel_initializer(kwargs, VarianceScaling( scale=0.2, mode="fan_in", distribution="uniform")) @@ -181,178 +373,47 @@ def res_block(self, inp, filters, kernel_size=3, padding="same", **kwargs): padding=padding, **kwargs) if not self.use_convaware_init: - self.switch_kernel_initializer(kwargs, original_init) - var_x = Add()([var_x, inp]) + self._switch_kernel_initializer(kwargs, original_init) + var_x = Add()([var_x, input_tensor]) var_x = LeakyReLU(alpha=0.2, name="{}_leakyrelu_3".format(name))(var_x) return var_x # <<< Unbalanced Model Blocks >>> # - def conv_sep(self, inp, filters, kernel_size=5, strides=2, **kwargs): - """ Seperable Convolution Layer """ - logger.debug("inp: %s, filters: %s, kernel_size: %s, strides: %s, kwargs: %s)", - inp, filters, kernel_size, strides, kwargs) - name = self.get_name("separableconv2d_{}".format(inp.shape[1])) - kwargs = self.set_default_initializer(kwargs) + def conv_sep(self, input_tensor, filters, kernel_size=5, strides=2, **kwargs): + """ Seperable Convolution Layer. + + Parameters + ---------- + input_tensor: tensor + The input tensor to the layer + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution) + kernel_size: int, optional + An integer or tuple/list of 2 integers, specifying the height and width of the 2D + convolution window. Can be a single integer to specify the same value for all spatial + dimensions. Default: 5 + strides: tuple or int, optional + An integer or tuple/list of 2 integers, specifying the strides of the convolution along + the height and width. Can be a single integer to specify the same value for all spatial + dimensions. Default: `2` + kwargs: dict + Any additional Keras standard layer keyword arguments + + Returns + ------- + tensor + The output tensor from the Upscale layer + """ + logger.debug("input_tensor: %s, filters: %s, kernel_size: %s, strides: %s, kwargs: %s)", + input_tensor, filters, kernel_size, strides, kwargs) + name = self._get_name("separableconv2d_{}".format(input_tensor.shape[1])) + kwargs = self._set_default_initializer(kwargs) var_x = SeparableConv2D(filters, kernel_size=kernel_size, strides=strides, padding="same", name="{}_seperableconv2d".format(name), - **kwargs)(inp) + **kwargs)(input_tensor) var_x = Activation("relu", name="{}_relu".format(name))(var_x) return var_x - -# <<< GAN V2.2 Blocks >>> # -# TODO Merge these into NNBLock class when porting GAN2.2 - - -# Gan Constansts: -GAN22_CONV_INIT = "he_normal" -GAN22_REGULARIZER = 1e-4 - - -# Gan Blocks: -def normalization(inp, norm="none", group="16"): - """ GAN Normalization """ - if norm == "layernorm": - var_x = GroupNormalization(group=group)(inp) - elif norm == "batchnorm": - var_x = BatchNormalization()(inp) - elif norm == "groupnorm": - var_x = GroupNormalization(group=16)(inp) - elif norm == "instancenorm": - var_x = InstanceNormalization()(inp) - elif norm == "hybrid": - if group % 2 == 1: - raise ValueError("Output channels must be an even number for hybrid norm, " - "received {}.".format(group)) - filt = group - var_x_0 = Lambda(lambda var_x: var_x[..., :filt // 2])(var_x) - var_x_1 = Lambda(lambda var_x: var_x[..., filt // 2:])(var_x) - var_x_0 = Conv2D(filt // 2, - kernel_size=1, - kernel_regularizer=regularizers.l2(GAN22_REGULARIZER), - kernel_initializer=GAN22_CONV_INIT)(var_x_0) - var_x_1 = InstanceNormalization()(var_x_1) - var_x = concatenate([var_x_0, var_x_1], axis=-1) - else: - var_x = inp - return var_x - - -def upscale_ps(inp, filters, initializer, use_norm=False, norm="none"): - """ GAN Upscaler - Pixel Shuffler """ - var_x = Conv2D(filters * 4, - kernel_size=3, - kernel_regularizer=regularizers.l2(GAN22_REGULARIZER), - kernel_initializer=initializer, - padding="same")(inp) - var_x = LeakyReLU(0.2)(var_x) - var_x = normalization(var_x, norm, filters) if use_norm else var_x - var_x = PixelShuffler()(var_x) - return var_x - - -def upscale_nn(inp, filters, use_norm=False, norm="none"): - """ GAN Neural Network """ - var_x = UpSampling2D()(inp) - var_x = reflect_padding_2d(var_x, 1) - var_x = Conv2D(filters, - kernel_size=3, - kernel_regularizer=regularizers.l2(GAN22_REGULARIZER), - kernel_initializer="he_normal")(var_x) - var_x = normalization(var_x, norm, filters) if use_norm else var_x - return var_x - - -def reflect_padding_2d(inp, pad=1): - """ GAN Reflect Padding (2D) """ - var_x = Lambda(lambda var_x: tf.pad(var_x, - [[0, 0], [pad, pad], [pad, pad], [0, 0]], - mode="REFLECT"))(inp) - return var_x - - -def conv_gan(inp, filters, use_norm=False, strides=2, norm="none"): - """ GAN Conv Block """ - var_x = Conv2D(filters, - kernel_size=3, - strides=strides, - kernel_regularizer=regularizers.l2(GAN22_REGULARIZER), - kernel_initializer=GAN22_CONV_INIT, - use_bias=False, - padding="same")(inp) - var_x = Activation("relu")(var_x) - var_x = normalization(var_x, norm, filters) if use_norm else var_x - return var_x - - -def conv_d_gan(inp, filters, use_norm=False, norm="none"): - """ GAN Discriminator Conv Block """ - var_x = inp - var_x = Conv2D(filters, - kernel_size=4, - strides=2, - kernel_regularizer=regularizers.l2(GAN22_REGULARIZER), - kernel_initializer=GAN22_CONV_INIT, - use_bias=False, - padding="same")(var_x) - var_x = LeakyReLU(alpha=0.2)(var_x) - var_x = normalization(var_x, norm, filters) if use_norm else var_x - return var_x - - -def res_block_gan(inp, filters, use_norm=False, norm="none"): - """ GAN Res Block """ - var_x = Conv2D(filters, - kernel_size=3, - kernel_regularizer=regularizers.l2(GAN22_REGULARIZER), - kernel_initializer=GAN22_CONV_INIT, - use_bias=False, - padding="same")(inp) - var_x = LeakyReLU(alpha=0.2)(var_x) - var_x = normalization(var_x, norm, filters) if use_norm else var_x - var_x = Conv2D(filters, - kernel_size=3, - kernel_regularizer=regularizers.l2(GAN22_REGULARIZER), - kernel_initializer=GAN22_CONV_INIT, - use_bias=False, - padding="same")(var_x) - var_x = add([var_x, inp]) - var_x = LeakyReLU(alpha=0.2)(var_x) - var_x = normalization(var_x, norm, filters) if use_norm else var_x - return var_x - - -def self_attn_block(inp, n_c, squeeze_factor=8): - """ GAN Self Attention Block - Code borrows from https://github.com/taki0112/Self-Attention-GAN-Tensorflow - """ - msg = "Input channels must be >= {}, recieved nc={}".format(squeeze_factor, n_c) - assert n_c // squeeze_factor > 0, msg - var_x = inp - shape_x = var_x.get_shape().as_list() - - var_f = Conv2D(n_c // squeeze_factor, 1, - kernel_regularizer=regularizers.l2(GAN22_REGULARIZER))(var_x) - var_g = Conv2D(n_c // squeeze_factor, 1, - kernel_regularizer=regularizers.l2(GAN22_REGULARIZER))(var_x) - var_h = Conv2D(n_c, 1, kernel_regularizer=regularizers.l2(GAN22_REGULARIZER))(var_x) - - shape_f = var_f.get_shape().as_list() - shape_g = var_g.get_shape().as_list() - shape_h = var_h.get_shape().as_list() - flat_f = Reshape((-1, shape_f[-1]))(var_f) - flat_g = Reshape((-1, shape_g[-1]))(var_g) - flat_h = Reshape((-1, shape_h[-1]))(var_h) - - var_s = Lambda(lambda var_x: K.batch_dot(var_x[0], - Permute((2, 1))(var_x[1])))([flat_g, flat_f]) - - beta = Softmax(axis=-1)(var_s) - var_o = Lambda(lambda var_x: K.batch_dot(var_x[0], var_x[1]))([beta, flat_h]) - var_o = Reshape(shape_x[1:])(var_o) - var_o = Scale()(var_o) - - out = add([var_o, inp]) - return out diff --git a/lib/model/normalization.py b/lib/model/normalization.py index ec4dbb1f5e..60036fdd94 100644 --- a/lib/model/normalization.py +++ b/lib/model/normalization.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 -""" Normaliztion methods for faceswap.py - Code from: - shoanlu GAN: https://github.com/shaoanlu/faceswap-GAN""" +""" Normalization methods for faceswap.py. """ import sys import inspect @@ -12,58 +10,57 @@ from keras.utils.generic_utils import get_custom_objects -def to_list(inp): - """ Convert to list """ - if not isinstance(inp, (list, tuple)): - return [inp] - return list(inp) - - class InstanceNormalization(Layer): """Instance normalization layer (Lei Ba et al, 2016, Ulyanov et al., 2016). - Normalize the activations of the previous layer at each step, - i.e. applies a transformation that maintains the mean activation - close to 0 and the activation standard deviation close to 1. - # Arguments - axis: Integer, the axis that should be normalized - (typically the features axis). - For instance, after a `Conv2D` layer with - `data_format="channels_first"`, - set `axis=1` in `InstanceNormalization`. - Setting `axis=None` will normalize all values in each instance of the batch. - Axis 0 is the batch dimension. `axis` cannot be set to 0 to avoid errors. - epsilon: Small float added to variance to avoid dividing by zero. - center: If True, add offset of `beta` to normalized tensor. - If False, `beta` is ignored. - scale: If True, multiply by `gamma`. - If False, `gamma` is not used. - When the next layer is linear (also e.g. `nn.relu`), - this can be disabled since the scaling - will be done by the next layer. - beta_initializer: Initializer for the beta weight. - gamma_initializer: Initializer for the gamma weight. - beta_regularizer: Optional regularizer for the beta weight. - gamma_regularizer: Optional regularizer for the gamma weight. - beta_constraint: Optional constraint for the beta weight. - gamma_constraint: Optional constraint for the gamma weight. - # Input shape - Arbitrary. Use the keyword argument `input_shape` - (tuple of integers, does not include the samples axis) - when using this layer as the first layer in a model. - # Output shape - Same shape as input. - # References - - [Layer Normalization](https://arxiv.org/abs/1607.06450) - - [Instance Normalization: The Missing Ingredient for Fast - Stylization](https://arxiv.org/abs/1607.08022) + + Normalize the activations of the previous layer at each step, i.e. applies a transformation + that maintains the mean activation close to 0 and the activation standard deviation close to 1. + + Parameters + ---------- + axis: int, optional + The axis that should be normalized (typically the features axis). For instance, after a + `Conv2D` layer with `data_format="channels_first"`, set `axis=1` in + :class:`InstanceNormalization`. Setting `axis=None` will normalize all values in each + instance of the batch. Axis 0 is the batch dimension. `axis` cannot be set to 0 to avoid + errors. Default: ``None`` + epsilon: float, optional + Small float added to variance to avoid dividing by zero. Default: `1e-3` + center: bool, optional + If ``True``, add offset of `beta` to normalized tensor. If ``False``, `beta` is ignored. + Default: ``True`` + scale: bool, optional + If ``True``, multiply by `gamma`. If ``False``, `gamma` is not used. When the next layer + is linear (also e.g. `relu`), this can be disabled since the scaling will be done by + the next layer. Default: ``True`` + beta_initializer: str, optional + Initializer for the beta weight. Default: `"zeros"` + gamma_initializer: str, optional + Initializer for the gamma weight. Default: `"ones"` + beta_regularizer: str, optional + Optional regularizer for the beta weight. Default: ``None`` + gamma_regularizer: str, optional + Optional regularizer for the gamma weight. Default: ``None`` + beta_constraint: float, optional + Optional constraint for the beta weight. Default: ``None`` + gamma_constraint: float, optional + Optional constraint for the gamma weight. Default: ``None`` + + References + ---------- + - Layer Normalization - https://arxiv.org/abs/1607.06450 + + - Instance Normalization: The Missing Ingredient for Fast Stylization - + https://arxiv.org/abs/1607.08022 + """ def __init__(self, axis=None, epsilon=1e-3, center=True, scale=True, - beta_initializer='zeros', - gamma_initializer='ones', + beta_initializer="zeros", + gamma_initializer="ones", beta_regularizer=None, gamma_regularizer=None, beta_constraint=None, @@ -71,7 +68,7 @@ def __init__(self, **kwargs): self.beta = None self.gamma = None - super(InstanceNormalization, self).__init__(**kwargs) + super().__init__(**kwargs) self.supports_masking = True self.axis = axis self.epsilon = epsilon @@ -85,12 +82,22 @@ def __init__(self, self.gamma_constraint = constraints.get(gamma_constraint) def build(self, input_shape): + """Creates the layer weights. + + Must be implemented on all layers that have weights. + + Parameters + ---------- + input_shape: tensor + Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to + reference for weight shape computations. + """ ndim = len(input_shape) if self.axis == 0: - raise ValueError('Axis cannot be zero') + raise ValueError("Axis cannot be zero") if (self.axis is not None) and (ndim == 2): - raise ValueError('Cannot specify axis for rank 1 tensor') + raise ValueError("Cannot specify axis for rank 1 tensor") self.input_spec = InputSpec(ndim=ndim) @@ -101,7 +108,7 @@ def build(self, input_shape): if self.scale: self.gamma = self.add_weight(shape=shape, - name='gamma', + name="gamma", initializer=self.gamma_initializer, regularizer=self.gamma_regularizer, constraint=self.gamma_constraint) @@ -109,7 +116,7 @@ def build(self, input_shape): self.gamma = None if self.center: self.beta = self.add_weight(shape=shape, - name='beta', + name="beta", initializer=self.beta_initializer, regularizer=self.beta_regularizer, constraint=self.beta_constraint) @@ -117,7 +124,19 @@ def build(self, input_shape): self.beta = None self.built = True - def call(self, inputs, training=None): + def call(self, inputs, training=None): # pylint:disable=arguments-differ,unused-argument + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: tensor + Input tensor, or list/tuple of input tensors + + Returns + ------- + tensor + A tensor or list/tuple of tensors + """ input_shape = K.int_shape(inputs) reduction_axes = list(range(0, len(input_shape))) @@ -144,146 +163,22 @@ def call(self, inputs, training=None): def get_config(self): config = { - 'axis': self.axis, - 'epsilon': self.epsilon, - 'center': self.center, - 'scale': self.scale, - 'beta_initializer': initializers.serialize(self.beta_initializer), - 'gamma_initializer': initializers.serialize(self.gamma_initializer), - 'beta_regularizer': regularizers.serialize(self.beta_regularizer), - 'gamma_regularizer': regularizers.serialize(self.gamma_regularizer), - 'beta_constraint': constraints.serialize(self.beta_constraint), - 'gamma_constraint': constraints.serialize(self.gamma_constraint) + "axis": self.axis, + "epsilon": self.epsilon, + "center": self.center, + "scale": self.scale, + "beta_initializer": initializers.serialize(self.beta_initializer), + "gamma_initializer": initializers.serialize(self.gamma_initializer), + "beta_regularizer": regularizers.serialize(self.beta_regularizer), + "gamma_regularizer": regularizers.serialize(self.gamma_regularizer), + "beta_constraint": constraints.serialize(self.beta_constraint), + "gamma_constraint": constraints.serialize(self.gamma_constraint) } base_config = super(InstanceNormalization, self).get_config() return dict(list(base_config.items()) + list(config.items())) -class GroupNormalization(Layer): - """ Group Normalization - from: shoanlu GAN: https://github.com/shaoanlu/faceswap-GAN""" - - def __init__(self, axis=-1, - gamma_init='one', beta_init='zero', - gamma_regularizer=None, beta_regularizer=None, - epsilon=1e-6, - group=32, - data_format=None, - **kwargs): - self.beta = None - self.gamma = None - super(GroupNormalization, self).__init__(**kwargs) - - self.axis = to_list(axis) - self.gamma_init = initializers.get(gamma_init) - self.beta_init = initializers.get(beta_init) - self.gamma_regularizer = regularizers.get(gamma_regularizer) - self.beta_regularizer = regularizers.get(beta_regularizer) - self.epsilon = epsilon - self.group = group - self.data_format = K.normalize_data_format(data_format) - - self.supports_masking = True - - def build(self, input_shape): - self.input_spec = [InputSpec(shape=input_shape)] - shape = [1 for _ in input_shape] - if self.data_format == 'channels_last': - channel_axis = -1 - shape[channel_axis] = input_shape[channel_axis] - elif self.data_format == 'channels_first': - channel_axis = 1 - shape[channel_axis] = input_shape[channel_axis] - # for i in self.axis: - # shape[i] = input_shape[i] - self.gamma = self.add_weight(shape=shape, - initializer=self.gamma_init, - regularizer=self.gamma_regularizer, - name='gamma') - self.beta = self.add_weight(shape=shape, - initializer=self.beta_init, - regularizer=self.beta_regularizer, - name='beta') - self.built = True - - def call(self, inputs, mask=None): - input_shape = K.int_shape(inputs) - if len(input_shape) != 4 and len(input_shape) != 2: - raise ValueError('Inputs should have rank ' + - str(4) + " or " + str(2) + - '; Received input shape:', str(input_shape)) - - if len(input_shape) == 4: - if self.data_format == 'channels_last': - batch_size, height, width, channels = input_shape - if batch_size is None: - batch_size = -1 - - if channels < self.group: - raise ValueError('Input channels should be larger than group size' + - '; Received input channels: ' + str(channels) + - '; Group size: ' + str(self.group)) - - var_x = K.reshape(inputs, (batch_size, - height, - width, - self.group, - channels // self.group)) - mean = K.mean(var_x, axis=[1, 2, 4], keepdims=True) - std = K.sqrt(K.var(var_x, axis=[1, 2, 4], keepdims=True) + self.epsilon) - var_x = (var_x - mean) / std - - var_x = K.reshape(var_x, (batch_size, height, width, channels)) - retval = self.gamma * var_x + self.beta - elif self.data_format == 'channels_first': - batch_size, channels, height, width = input_shape - if batch_size is None: - batch_size = -1 - - if channels < self.group: - raise ValueError('Input channels should be larger than group size' + - '; Received input channels: ' + str(channels) + - '; Group size: ' + str(self.group)) - - var_x = K.reshape(inputs, (batch_size, - self.group, - channels // self.group, - height, - width)) - mean = K.mean(var_x, axis=[2, 3, 4], keepdims=True) - std = K.sqrt(K.var(var_x, axis=[2, 3, 4], keepdims=True) + self.epsilon) - var_x = (var_x - mean) / std - - var_x = K.reshape(var_x, (batch_size, channels, height, width)) - retval = self.gamma * var_x + self.beta - - elif len(input_shape) == 2: - reduction_axes = list(range(0, len(input_shape))) - del reduction_axes[0] - batch_size, _ = input_shape - if batch_size is None: - batch_size = -1 - - mean = K.mean(inputs, keepdims=True) - std = K.sqrt(K.var(inputs, keepdims=True) + self.epsilon) - var_x = (inputs - mean) / std - - retval = self.gamma * var_x + self.beta - return retval - - def get_config(self): - config = {'epsilon': self.epsilon, - 'axis': self.axis, - 'gamma_init': initializers.serialize(self.gamma_init), - 'beta_init': initializers.serialize(self.beta_init), - 'gamma_regularizer': regularizers.serialize(self.gamma_regularizer), - 'beta_regularizer': regularizers.serialize(self.gamma_regularizer), - 'group': self.group} - base_config = super(GroupNormalization, self).get_config() - return dict(list(base_config.items()) + list(config.items())) - - -# Update normalizations into Keras custom objects +# Update normalization into Keras custom objects for name, obj in inspect.getmembers(sys.modules[__name__]): if inspect.isclass(obj) and obj.__module__ == __name__: get_custom_objects().update({name: obj}) diff --git a/lib/model/optimizers.py b/lib/model/optimizers.py index 3d8bffe5f7..e2b50e4040 100644 --- a/lib/model/optimizers.py +++ b/lib/model/optimizers.py @@ -12,25 +12,86 @@ class Adam(KerasAdam): - """Adapted Keras Adam Optimizer to allow support of calculations - on CPU for Tensorflow. - - Adapted from https://github.com/iperov/DeepFaceLab + """Adapted Keras Adam Optimizer to allow support of calculations on CPU for Tensorflow. + + Default parameters follow those provided in the original paper. Adapted from + https://github.com/iperov/DeepFaceLab + + Parameters + ---------- + lr: float, optional + >= `0`. Learning rate. Default: `0.001` + beta_1: float, optional + `0` < beta < `1` Generally close to `1`. Default: `0.9` + beta_2: float, optional + `0` < beta < `1`. Generally close to `1`. Default: `0.999` + epsilon: float, optional + >= `0`. Fuzz factor. If ``None``, defaults to `K.epsilon()`. Default: ``None`` + decay: float, optional + >= 0. Learning rate decay over each update. Default: `0` + amsgrad: bool, optional + ``True`` to apply the AMSGrad variant of this algorithm from the paper "On the Convergence + of Adam and Beyond" otherwise ``False``. Default: ``False`` + cpu_mode: bool, optional + Set to ``True`` to perform some of the calculations on CPU for Nvidia backends, otherwise + ``False``. Default: ``False`` + kwargs: dict + Any additional standard Keras optimizer keyword arguments + + References + ---------- + - Adam - A Method for Stochastic Optimization - https://arxiv.org/abs/1412.6980v8 + + - On the Convergence of Adam and Beyond - https://openreview.net/forum?id=ryQu7f-RZ """ - def __init__(self, lr=0.001, beta_1=0.9, beta_2=0.999, - epsilon=None, decay=0., amsgrad=False, cpu_mode=0, **kwargs): + def __init__(self, + lr=0.001, + beta_1=0.9, + beta_2=0.999, + epsilon=None, + decay=0., + amsgrad=False, + cpu_mode=False, + **kwargs): super().__init__(lr, beta_1, beta_2, epsilon, decay, **kwargs) - self.cpu_mode = self.set_cpu_mode(cpu_mode) + self.cpu_mode = self._set_cpu_mode(cpu_mode) @staticmethod - def set_cpu_mode(cpu_mode): - """ Set the CPU mode to 0 if not using tensorflow, else passed in arg """ + def _set_cpu_mode(cpu_mode): + """ Sets the CPU mode to False if not using Tensorflow, otherwise the given value. + + Parameters + ---------- + cpu_mode: bool + Set to ``True`` to perform some of the calculations on CPU for Nvidia backends, + otherwise ``False``. + + Returns + ------- + bool + ``True`` if some calculations should be performed on CPU otherwise ``False`` + """ retval = False if K.backend() != "tensorflow" else cpu_mode logger.debug("Optimizer CPU Mode set to %s", retval) return retval def get_updates(self, loss, params): + """ Obtain the optimizer loss updates. + + Parameters + ---------- + loss: list + List of tensors + + params: list + List of tensors + + Returns + ------- + list + List of tensors + """ grads = self.get_gradients(loss, params) self.updates = [K.update_add(self.iterations, 1)] @@ -46,9 +107,9 @@ def get_updates(self, loss, params): # Pass off to CPU if requested if self.cpu_mode: with K.tf.device("/cpu:0"): - ms, vs, vhats = self.update_1(params) + ms, vs, vhats = self._update_1(params) else: - ms, vs, vhats = self.update_1(params) + ms, vs, vhats = self._update_1(params) self.weights = [self.iterations] + ms + vs + vhats @@ -73,8 +134,9 @@ def get_updates(self, loss, params): self.updates.append(K.update(p, new_p)) return self.updates - def update_1(self, params): - """ First update on CPU or GPU """ + def _update_1(self, params): + """ Perform the first update. Run under CPU context if running on Tensorflow and CPU mode + is enabled, otherwise run on the default device. """ ms = [K.zeros(K.int_shape(p), dtype=K.dtype(p)) for p in params] vs = [K.zeros(K.int_shape(p), dtype=K.dtype(p)) for p in params] if self.amsgrad: diff --git a/lib/utils.py b/lib/utils.py index f65a0546b4..b74760a4e6 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -25,8 +25,10 @@ class Backend(): - """ Return the backend from config/.faceswap - if file doesn't exist, create it """ + """ Return the backend from config/.faceswap of from the `FACESWAP_BACKEND` Environment + Variable. + + If file doesn't exist and a variable hasn't been set, create the config file. """ def __init__(self): self.backends = {"1": "amd", "2": "cpu", "3": "nvidia"} self.config_file = self.get_config_file() @@ -40,7 +42,14 @@ def get_config_file(): return config_file def get_backend(self): - """ Return the backend from config/.faceswap """ + """ Return the backend from either the `FACESWAP_BACKEND` Environment Variable or from + the :loc:`config/.faceswap` configuration file. """ + # Check if environment variable is set, if so use that + if "FACESWAP_BACKEND" in os.environ: + fs_backend = os.environ["FACESWAP_BACKEND"].lower() + print("Setting Faceswap backend from environment variable to " + "{}".format(fs_backend.upper())) + return fs_backend # Intercept for sphinx docs build if sys.argv[0].endswith("sphinx-build"): return "nvidia" diff --git a/plugins/train/model/dlight.py b/plugins/train/model/dlight.py index 679edaf321..8e1e677fa5 100644 --- a/plugins/train/model/dlight.py +++ b/plugins/train/model/dlight.py @@ -31,7 +31,7 @@ def upscale2x_hyb(self, inp, filters, kernel_size=3, padding='same', sr_ratio=0.5, scale_factor=2, interpolation='bilinear', res_block_follows=False, **kwargs): """Hybrid Upscale Layer""" - name = self.get_name("upscale2x_hyb") + name = self._get_name("upscale2x_hyb") var_x = inp sr_filters = int(filters * sr_ratio) @@ -56,7 +56,7 @@ def upscale2x_fast(self, inp, filters, kernel_size=3, padding='same', sr_ratio=0.5, scale_factor=2, interpolation='bilinear', res_block_follows=False, **kwargs): """Fast Upscale Layer""" - name = self.get_name("upscale2x_fast") + name = self._get_name("upscale2x_fast") var_x = inp var_x2 = self.conv2d(var_x, filters, kernel_size=3, padding=padding, diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/model/__init__.py b/tests/lib/model/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/model/initializers_test.py b/tests/lib/model/initializers_test.py new file mode 100644 index 0000000000..5d6e85af25 --- /dev/null +++ b/tests/lib/model/initializers_test.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" Tests for Faceswap Initializers. + +Adapted from Keras tests. +""" + +from keras import initializers as k_initializers +from keras import backend as K +import pytest +import numpy as np + +from lib.model import initializers +from lib.utils import get_backend + + +CONV_SHAPE = (3, 3, 256, 2048) +CONV_ID = get_backend().upper() + + +def _runner(init, shape, target_mean=None, target_std=None, + target_max=None, target_min=None): + variable = K.variable(init(shape)) + output = K.get_value(variable) + lim = 3e-2 + if target_std is not None: + assert abs(output.std() - target_std) < lim + if target_mean is not None: + assert abs(output.mean() - target_mean) < lim + if target_max is not None: + assert abs(output.max() - target_max) < lim + if target_min is not None: + assert abs(output.min() - target_min) < lim + + +@pytest.mark.parametrize('tensor_shape', [CONV_SHAPE], ids=[CONV_ID]) +def test_icnr(tensor_shape): + """ ICNR Initialization Test + + Parameters + ---------- + tensor_shape: tuple + The shape of the tensor to feed to the initializer + """ + fan_in, _ = k_initializers._compute_fans(tensor_shape) # pylint:disable=protected-access + std = np.sqrt(2. / fan_in) + _runner(initializers.ICNR(initializer=k_initializers.he_uniform(), scale=2), tensor_shape, + target_mean=0, target_std=std) + + +@pytest.mark.parametrize('tensor_shape', [CONV_SHAPE], ids=[CONV_ID]) +def test_convolution_aware(tensor_shape): + """ Convolution Aware Initialization Test + + Parameters + ---------- + tensor_shape: tuple + The shape of the tensor to feed to the initializer + """ + fan_in, _ = k_initializers._compute_fans(tensor_shape) # pylint:disable=protected-access + std = np.sqrt(2. / fan_in) + _runner(initializers.ConvolutionAware(seed=123, init=True), tensor_shape, + target_mean=0, target_std=std) diff --git a/tests/lib/model/layers_test.py b/tests/lib/model/layers_test.py new file mode 100644 index 0000000000..9eb4463f83 --- /dev/null +++ b/tests/lib/model/layers_test.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" Tests for Faceswap Custom Layers. + +Adapted from Keras tests. +""" + + +import pytest +import numpy as np +from keras import Input, Model, backend as K +from keras.utils.generic_utils import has_arg + +from numpy.testing import assert_allclose + +from lib.model import layers +from lib.utils import get_backend + + +CONV_SHAPE = (3, 3, 256, 2048) +CONV_ID = get_backend().upper() + + +def layer_test(layer_cls, kwargs={}, input_shape=None, input_dtype=None, + input_data=None, expected_output=None, + expected_output_dtype=None, fixed_batch_size=False): + """Test routine for a layer with a single input tensor + and single output tensor. + """ + # generate input data + if input_data is None: + assert input_shape + if not input_dtype: + input_dtype = K.floatx() + input_data_shape = list(input_shape) + for i, var_e in enumerate(input_data_shape): + if var_e is None: + input_data_shape[i] = np.random.randint(1, 4) + input_data = (10 * np.random.random(input_data_shape)) + input_data = input_data.astype(input_dtype) + else: + if input_shape is None: + input_shape = input_data.shape + if input_dtype is None: + input_dtype = input_data.dtype + if expected_output_dtype is None: + expected_output_dtype = input_dtype + + # instantiation + layer = layer_cls(**kwargs) + + # test get_weights , set_weights at layer level + weights = layer.get_weights() + layer.set_weights(weights) + + if isinstance(layer, layers.ReflectionPadding2D): + layer.build(input_shape) + expected_output_shape = layer.compute_output_shape(input_shape) + + # test in functional API + if fixed_batch_size: + inp = Input(batch_shape=input_shape, dtype=input_dtype) + else: + inp = Input(shape=input_shape[1:], dtype=input_dtype) + outp = layer(inp) + assert K.dtype(outp) == expected_output_dtype + + # check with the functional API + model = Model(inp, outp) + + actual_output = model.predict(input_data) + actual_output_shape = actual_output.shape + for expected_dim, actual_dim in zip(expected_output_shape, + actual_output_shape): + if expected_dim is not None: + assert expected_dim == actual_dim + + if expected_output is not None: + assert_allclose(actual_output, expected_output, rtol=1e-3) + + # test serialization, weight setting at model level + model_config = model.get_config() + recovered_model = model.__class__.from_config(model_config) + if model.weights: + weights = model.get_weights() + recovered_model.set_weights(weights) + _output = recovered_model.predict(input_data) + assert_allclose(_output, actual_output, rtol=1e-3) + + # test training mode (e.g. useful when the layer has a + # different behavior at training and testing time). + if has_arg(layer.call, 'training'): + model.compile('rmsprop', 'mse') + model.train_on_batch(input_data, actual_output) + + # test instantiation from layer config + layer_config = layer.get_config() + layer_config['batch_input_shape'] = input_shape + layer = layer.__class__.from_config(layer_config) + + # for further checks in the caller function + return actual_output + + +@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) +def test_pixel_shuffler(dummy): # pylint:disable=unused-argument + """ Pixel Shuffler layer test """ + layer_test(layers.PixelShuffler, input_shape=(2, 4, 4, 1024)) + + +@pytest.mark.skipif(get_backend() == "amd", reason="amd does not support this layer") +@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) +def test_subpixel_upscaling(dummy): # pylint:disable=unused-argument + """ Sub Pixel Upscaling layer test """ + layer_test(layers.SubPixelUpscaling, input_shape=(2, 4, 4, 1024)) + + +@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) +def test_reflection_padding_2d(dummy): # pylint:disable=unused-argument + """ Reflection Padding 2D layer test """ + layer_test(layers.ReflectionPadding2D, input_shape=(2, 4, 4, 512)) + + +@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) +def test_global_min_pooling_2d(dummy): # pylint:disable=unused-argument + """ Global Min Pooling 2D layer test """ + layer_test(layers.GlobalMinPooling2D, input_shape=(2, 4, 4, 1024)) + + +@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) +def test_global_std_pooling_2d(dummy): # pylint:disable=unused-argument + """ Global Standard Deviation Pooling 2D layer test """ + layer_test(layers.GlobalStdDevPooling2D, input_shape=(2, 4, 4, 1024)) + + +@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) +def test_l2_normalize(dummy): # pylint:disable=unused-argument + """ L2 Normalize layer test """ + layer_test(layers.L2_normalize, kwargs={"axis": 1}, input_shape=(2, 4, 4, 1024)) diff --git a/tests/lib/model/losses_test.py b/tests/lib/model/losses_test.py new file mode 100644 index 0000000000..0276a1b193 --- /dev/null +++ b/tests/lib/model/losses_test.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +""" Tests for Faceswap Losses. + +Adapted from Keras tests. +""" + +import pytest +import numpy as np +from numpy.testing import assert_allclose + +from keras import backend as K +from keras.layers import Conv2D +from keras.models import Sequential +from keras.optimizers import Adam + +from lib.model import losses +from lib.utils import get_backend + + +_PARAMS = [(losses.gradient_loss, (1, 5, 6, 7), (1, 5, 6)), + (losses.generalized_loss, (5, 6, 7), (5, 6)), + # TODO Make sure these output dimensions are correct + (losses.l_inf_norm, (1, 5, 6, 7), (1, 1, 1)), + # TODO Make sure these output dimensions are correct + (losses.gmsd_loss, (1, 5, 6, 7), (1, 1, 1))] +_IDS = ["gradient_loss", "generalized_loss", "l_inf_norm", "gmsd_loss"] +_IDS = ["{}[{}]".format(loss, get_backend().upper()) for loss in _IDS] + + +@pytest.mark.parametrize(["loss_func", "input_shape", "output_shape"], _PARAMS, ids=_IDS) +def test_objective_shapes(loss_func, input_shape, output_shape): + """ Basic shape tests for loss functions. """ + y_a = K.variable(np.random.random(input_shape)) + y_b = K.variable(np.random.random(input_shape)) + objective_output = loss_func(y_a, y_b) + assert K.eval(objective_output).shape == output_shape + + +@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) +@pytest.mark.xfail(get_backend() == "amd", reason="plaidML generates NaNs") +def test_dssim_channels_last(dummy): # pylint:disable=unused-argument + """ Basic test for DSSIM Loss """ + prev_data = K.image_data_format() + K.set_image_data_format('channels_last') + for input_dim, kernel_size in zip([32, 33], [2, 3]): + input_shape = [input_dim, input_dim, 3] + var_x = np.random.random_sample(4 * input_dim * input_dim * 3) + var_x = var_x.reshape([4] + input_shape) + var_y = np.random.random_sample(4 * input_dim * input_dim * 3) + var_y = var_y.reshape([4] + input_shape) + + model = Sequential() + model.add(Conv2D(32, (3, 3), padding='same', input_shape=input_shape, + activation='relu')) + model.add(Conv2D(3, (3, 3), padding='same', input_shape=input_shape, + activation='relu')) + adam = Adam(lr=0.001, beta_1=0.9, beta_2=0.999, epsilon=1e-8) + model.compile(loss=losses.DSSIMObjective(kernel_size=kernel_size), + metrics=['mse'], + optimizer=adam) + model.fit(var_x, var_y, batch_size=2, epochs=1, shuffle='batch') + + # Test same + x_1 = K.constant(var_x, 'float32') + x_2 = K.constant(var_x, 'float32') + dssim = losses.DSSIMObjective(kernel_size=kernel_size) + assert_allclose(0.0, K.eval(dssim(x_1, x_2)), atol=1e-4) + + # Test opposite + x_1 = K.zeros([4] + input_shape) + x_2 = K.ones([4] + input_shape) + dssim = losses.DSSIMObjective(kernel_size=kernel_size) + assert_allclose(0.5, K.eval(dssim(x_1, x_2)), atol=1e-4) + + K.set_image_data_format(prev_data) diff --git a/tests/lib/model/nn_blocks_test.py b/tests/lib/model/nn_blocks_test.py new file mode 100644 index 0000000000..9c2c5c980a --- /dev/null +++ b/tests/lib/model/nn_blocks_test.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +""" Tests for Faceswap Custom Layers. + +Adapted from Keras tests. +""" + +from itertools import product + +import pytest +import numpy as np + +from keras import Input, Model, backend as K +from numpy.testing import assert_allclose + +from lib.model.nn_blocks import NNBlocks +from lib.utils import get_backend + +_PARAMS = ["use_subpixel", "use_icnr_init", "use_convaware_init", "use_reflect_padding"] +_VALUES = list(product([True, False], repeat=len(_PARAMS))) +_IDS = ["{}[{}]".format("|".join([_PARAMS[idx] for idx, b in enumerate(v) if b]), + get_backend().upper()) for v in _VALUES] + + +def block_test(layer_func, kwargs={}, input_shape=None): + """Test routine for a faceswaps neural network blocks. + + Tests are simple and are to ensure that the blocks compile on both tensorflow + and plaidml backends + """ + # generate input data + assert input_shape + input_dtype = K.floatx() + input_data_shape = list(input_shape) + for i, var_e in enumerate(input_data_shape): + if var_e is None: + input_data_shape[i] = np.random.randint(1, 4) + input_data = (10 * np.random.random(input_data_shape)) + input_data = input_data.astype(input_dtype) + expected_output_dtype = input_dtype + + # test in functional API + inp = Input(shape=input_shape[1:], dtype=input_dtype) + outp = layer_func(inp, **kwargs) + assert K.dtype(outp) == expected_output_dtype + + # check with the functional API + model = Model(inp, outp) + + actual_output = model.predict(input_data) + + # test serialization, weight setting at model level + model_config = model.get_config() + recovered_model = model.__class__.from_config(model_config) + if model.weights: + weights = model.get_weights() + recovered_model.set_weights(weights) + _output = recovered_model.predict(input_data) + assert_allclose(_output, actual_output, rtol=1e-3) + + # for further checks in the caller function + return actual_output + + +@pytest.mark.parametrize(_PARAMS, _VALUES, ids=_IDS) +def test_blocks(use_subpixel, use_icnr_init, use_convaware_init, use_reflect_padding): + """ Test for all blocks contained within the NNBlocks Class """ + if get_backend() == "amd" and use_subpixel: + # Subpixel upscaling does not work on plaidml so skip this test + pytest.skip("Subpixel upscaling not supported in plaidML") + cls_ = NNBlocks(use_subpixel=use_subpixel, + use_icnr_init=use_icnr_init, + use_convaware_init=use_convaware_init, + use_reflect_padding=use_reflect_padding) + block_test(cls_.conv2d, input_shape=(2, 5, 5, 128), kwargs=dict(filters=1024, kernel_size=3)) + block_test(cls_.conv, input_shape=(2, 8, 8, 32), kwargs=dict(filters=64)) + block_test(cls_.conv_sep, input_shape=(2, 8, 8, 32), kwargs=dict(filters=64)) + block_test(cls_.upscale, input_shape=(2, 4, 4, 128), kwargs=dict(filters=64)) + block_test(cls_.res_block, input_shape=(2, 2, 2, 64), kwargs=dict(filters=64)) diff --git a/tests/lib/model/normalization_test.py b/tests/lib/model/normalization_test.py new file mode 100644 index 0000000000..49715b0576 --- /dev/null +++ b/tests/lib/model/normalization_test.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +""" Tests for Faceswap Normalization. + +Adapted from Keras tests. +""" + +from keras import regularizers +import pytest + +from lib.model import normalization +from lib.utils import get_backend + +from .layers_test import layer_test + + +@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) +def test_instance_normalization(dummy): # pylint:disable=unused-argument + """ Basic test for instance normalization. """ + layer_test(normalization.InstanceNormalization, + kwargs={'epsilon': 0.1, + 'gamma_regularizer': regularizers.l2(0.01), + 'beta_regularizer': regularizers.l2(0.01)}, + input_shape=(3, 4, 2)) + layer_test(normalization.InstanceNormalization, + kwargs={'epsilon': 0.1, + 'axis': 1}, + input_shape=(1, 4, 1)) + layer_test(normalization.InstanceNormalization, + kwargs={'gamma_initializer': 'ones', + 'beta_initializer': 'ones'}, + input_shape=(3, 4, 2, 4)) + layer_test(normalization.InstanceNormalization, + kwargs={'epsilon': 0.1, + 'axis': 1, + 'scale': False, + 'center': False}, + input_shape=(3, 4, 2, 4)) diff --git a/tests/lib/model/optimizers_test.py b/tests/lib/model/optimizers_test.py new file mode 100644 index 0000000000..09c446d953 --- /dev/null +++ b/tests/lib/model/optimizers_test.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +""" Tests for Faceswap Initializers. + +Adapted from Keras tests. +""" +import pytest + +from keras import optimizers as k_optimizers +from keras.layers import Dense, Activation +from keras.models import Sequential +from keras.utils import test_utils +from keras.utils.np_utils import to_categorical +import numpy as np +from numpy.testing import assert_allclose + +from lib.model import optimizers +from lib.utils import get_backend + + +def get_test_data(): + """ Obtain radomized test data for training """ + np.random.seed(1337) + (x_train, y_train), _ = test_utils.get_test_data(num_train=1000, + num_test=200, + input_shape=(10,), + classification=True, + num_classes=2) + y_train = to_categorical(y_train) + return x_train, y_train + + +def _test_optimizer(optimizer, target=0.75): + x_train, y_train = get_test_data() + + model = Sequential() + model.add(Dense(10, input_shape=(x_train.shape[1],))) + model.add(Activation('relu')) + model.add(Dense(y_train.shape[1])) + model.add(Activation('softmax')) + model.compile(loss='categorical_crossentropy', + optimizer=optimizer, + metrics=['accuracy']) + + history = model.fit(x_train, y_train, epochs=2, batch_size=16, verbose=0) + # TODO PlaidML fails this test + assert history.history['acc'][-1] >= target + config = k_optimizers.serialize(optimizer) + optim = k_optimizers.deserialize(config) + new_config = k_optimizers.serialize(optim) + new_config['class_name'] = new_config['class_name'].lower() + assert config == new_config + + # Test constraints. + model = Sequential() + dense = Dense(10, + input_shape=(x_train.shape[1],), + kernel_constraint=lambda x: 0. * x + 1., + bias_constraint=lambda x: 0. * x + 2.,) + model.add(dense) + model.add(Activation('relu')) + model.add(Dense(y_train.shape[1])) + model.add(Activation('softmax')) + model.compile(loss='categorical_crossentropy', + optimizer=optimizer, + metrics=['accuracy']) + model.train_on_batch(x_train[:10], y_train[:10]) + kernel, bias = dense.get_weights() + assert_allclose(kernel, 1.) + assert_allclose(bias, 2.) + + +@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) +@pytest.mark.xfail(get_backend() == "amd", reason="plaidML fails the standard accuracy test") +def test_adam(dummy): # pylint:disable=unused-argument + """ Test for custom adam optimizer """ + _test_optimizer(optimizers.Adam()) + _test_optimizer(optimizers.Adam(decay=1e-3)) diff --git a/tests/startup_test.py b/tests/startup_test.py new file mode 100644 index 0000000000..4389fcacaa --- /dev/null +++ b/tests/startup_test.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +""" Sanity checks for Faceswap. """ + +import inspect + +import pytest +from keras import backend as K + +from lib.utils import get_backend + + +@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) +def test_backend(dummy): # pylint:disable=unused-argument + """ Sanity check to ensure that Keras backend is returning the correct object type. """ + backend = get_backend() + test_var = K.variable((1, 1, 4, 4)) + lib = inspect.getmodule(test_var).__name__.split(".")[0] + assert (backend == "cpu" and lib == "tensorflow") or (backend == "amd" and lib == "plaidml") From 92bc9af9577a042e368169eb74c8a432708050f4 Mon Sep 17 00:00:00 2001 From: bryanlyon <3223233+bryanlyon@users.noreply.github.com> Date: Wed, 13 May 2020 04:28:30 -0700 Subject: [PATCH 241/981] Extraction: Added an option to skip outputting face images (#1021) --- lib/cli/args.py | 7 +++++++ scripts/extract.py | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index a205c64f82..60024efba8 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -538,6 +538,13 @@ def get_optional_arguments(): default=False, group="settings", help="Skip frames that already have detected faces in the alignments file")) + argument_list.append(dict( + opts=("-ssf", "--skip-saving-faces"), + action="store_true", + dest="skip_saving_faces", + default=False, + group="settings", + help="Skip saving out the face images")) return argument_list diff --git a/scripts/extract.py b/scripts/extract.py index 71a284f8ad..40880f31fb 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -213,7 +213,8 @@ def _run_extraction(self): self._check_thread_error() if is_final: self._output_processing(extract_media, size) - self._output_faces(saver, extract_media) + if not self._args.skip_saving_faces: + self._output_faces(saver, extract_media) if self._save_interval and (idx + 1) % self._save_interval == 0: self._alignments.save() else: From ac40b0f52f5a745aa058f92339302065177dd28b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 13 May 2020 13:50:48 +0100 Subject: [PATCH 242/981] Remove subpixel upscaling option (#1024) --- lib/model/nn_blocks.py | 20 +++++--------------- plugins/train/_config.py | 7 ------- plugins/train/model/_base.py | 12 +++++------- tests/lib/model/nn_blocks_test.py | 12 ++++-------- 4 files changed, 14 insertions(+), 37 deletions(-) diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index b2d3aebe30..180d0649ee 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -9,7 +9,7 @@ from keras.layers.core import Activation from keras.initializers import he_uniform, VarianceScaling from .initializers import ICNR, ConvolutionAware -from .layers import PixelShuffler, SubPixelUpscaling, ReflectionPadding2D +from .layers import PixelShuffler, ReflectionPadding2D from .normalization import InstanceNormalization logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -26,10 +26,6 @@ class NNBlocks(): Parameters ---------- - use_subpixel: bool, Optional - ``True`` if sub-pixel up-scaling layer should be used instead of pixel shuffler for - up-scaling. This option is deprecated as sub-pixel up-scaling is Nvidia only, but is kept - for legacy models. Default: ``False`` use_icnr_init: bool, Optional ``True`` if ICNR initialization should be used rather than the default. Default: ``False`` use_convaware_init: bool, Optional @@ -44,18 +40,16 @@ class NNBlocks(): is being reloaded. Default: ``True`` """ def __init__(self, - use_subpixel=False, use_icnr_init=False, use_convaware_init=False, use_reflect_padding=False, first_run=True): - logger.debug("Initializing %s: (use_subpixel: %s, use_icnr_init: %s, use_convaware_init: " - "%s, use_reflect_padding: %s, first_run: %s)", - self.__class__.__name__, use_subpixel, use_icnr_init, use_convaware_init, + logger.debug("Initializing %s: (use_icnr_init: %s, use_convaware_init: %s, " + "use_reflect_padding: %s, first_run: %s)", + self.__class__.__name__, use_icnr_init, use_convaware_init, use_reflect_padding, first_run) self.names = dict() self.first_run = first_run - self.use_subpixel = use_subpixel self.use_icnr_init = use_icnr_init self.use_convaware_init = use_convaware_init self.use_reflect_padding = use_reflect_padding @@ -311,11 +305,7 @@ def upscale(self, input_tensor, filters, kernel_size=3, padding="same", var_x = InstanceNormalization(name="{}_instancenorm".format(name))(var_x) if not res_block_follows: var_x = LeakyReLU(0.1, name="{}_leakyrelu".format(name))(var_x) - if self.use_subpixel: - var_x = SubPixelUpscaling(name="{}_subpixel".format(name), - scale_factor=scale_factor)(var_x) - else: - var_x = PixelShuffler(name="{}_pixelshuffler".format(name), size=scale_factor)(var_x) + var_x = PixelShuffler(name="{}_pixelshuffler".format(name), size=scale_factor)(var_x) return var_x # <<< DFaker Model Blocks >>> # diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 0326bc7160..7ebce93dd3 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -135,13 +135,6 @@ def set_globals(self): "\n\t Building the model will likely take several minutes as the calculations " "for this initialization technique are expensive. This will only impact starting " "a new model.") - self.add_item( - section=section, title="subpixel_upscaling", datatype=bool, - default=False, group="network", - info="Use subpixel upscaling rather than pixel shuffler. These techniques " - "are both designed to produce better resolving upscaling than other " - "methods. Each perform the same operations, but using different TF opts." - "\n\t https://arxiv.org/pdf/1609.05158.pdf") self.add_item( section=section, title="reflect_padding", datatype=bool, default=False, group="network", diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 8fa9dc8241..2540448f0f 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -83,8 +83,7 @@ def __init__(self, self.vram_savings.pingpong, training_image_size) - self.blocks = NNBlocks(use_subpixel=self.config["subpixel_upscaling"], - use_icnr_init=self.config["icnr_init"], + self.blocks = NNBlocks(use_icnr_init=self.config["icnr_init"], use_convaware_init=self.config["conv_aware_init"], use_reflect_padding=self.config["reflect_padding"], first_run=self.state.first_run) @@ -377,9 +376,9 @@ def get_optimizer(self, lr=5e-5, beta_1=0.5, beta_2=0.999): # pylint: disable=i opt_kwargs = dict(lr=lr, beta_1=beta_1, beta_2=beta_2) if (self.config.get("clipnorm", False) and keras.backend.backend() != "plaidml.keras.backend"): - # NB: Clipnorm is ballooning VRAM usage, which is not expected behavior - # and may be a bug in Keras/TF. - # PlaidML has a bug regarding the clipnorm parameter + # NB: Clip-norm is ballooning VRAM usage, which is not expected behavior + # and may be a bug in Keras/Tensorflow. + # PlaidML has a bug regarding the clip-norm parameter # See: https://github.com/plaidml/plaidml/issues/228 # Workaround by simply removing it. # TODO: Remove this as soon it is fixed in PlaidML. @@ -581,7 +580,6 @@ def rename_legacy(self): self.state.inputs = {"face:0": [64, 64, 3]} self.state.training_size = 256 self.state.config["coverage"] = 62.5 - self.state.config["subpixel_upscaling"] = False self.state.config["reflect_padding"] = False self.state.config["mask_type"] = None self.state.config["mask_blur_kernel"] = 3 @@ -1014,7 +1012,7 @@ def _update_legacy_config(self): set it to `mae`. Remove old `dssim_loss` item * masks - If `learn_mask` does not exist then it is set to ``True`` if `mask_type` is - not ``None`` otherwised it is set to ``False``. + not ``None`` otherwise it is set to ``False``. * masks type - Replace removed masks 'dfl_full' and 'facehull' with `components` mask diff --git a/tests/lib/model/nn_blocks_test.py b/tests/lib/model/nn_blocks_test.py index 9c2c5c980a..9515a2dc9d 100644 --- a/tests/lib/model/nn_blocks_test.py +++ b/tests/lib/model/nn_blocks_test.py @@ -15,14 +15,14 @@ from lib.model.nn_blocks import NNBlocks from lib.utils import get_backend -_PARAMS = ["use_subpixel", "use_icnr_init", "use_convaware_init", "use_reflect_padding"] +_PARAMS = ["use_icnr_init", "use_convaware_init", "use_reflect_padding"] _VALUES = list(product([True, False], repeat=len(_PARAMS))) _IDS = ["{}[{}]".format("|".join([_PARAMS[idx] for idx, b in enumerate(v) if b]), get_backend().upper()) for v in _VALUES] def block_test(layer_func, kwargs={}, input_shape=None): - """Test routine for a faceswaps neural network blocks. + """Test routine for faceswap neural network blocks. Tests are simple and are to ensure that the blocks compile on both tensorflow and plaidml backends @@ -62,13 +62,9 @@ def block_test(layer_func, kwargs={}, input_shape=None): @pytest.mark.parametrize(_PARAMS, _VALUES, ids=_IDS) -def test_blocks(use_subpixel, use_icnr_init, use_convaware_init, use_reflect_padding): +def test_blocks(use_icnr_init, use_convaware_init, use_reflect_padding): """ Test for all blocks contained within the NNBlocks Class """ - if get_backend() == "amd" and use_subpixel: - # Subpixel upscaling does not work on plaidml so skip this test - pytest.skip("Subpixel upscaling not supported in plaidML") - cls_ = NNBlocks(use_subpixel=use_subpixel, - use_icnr_init=use_icnr_init, + cls_ = NNBlocks(use_icnr_init=use_icnr_init, use_convaware_init=use_convaware_init, use_reflect_padding=use_reflect_padding) block_test(cls_.conv2d, input_shape=(2, 5, 5, 128), kwargs=dict(filters=1024, kernel_size=3)) From 127d3dbe99b9ae57c5df7d783a2cbb934d7eaad7 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 31 May 2020 16:25:36 +0100 Subject: [PATCH 243/981] Dependencies update (#1028) * Dependencies update - Split requirements.txt into separate version files - More flexible package pinning - Update dependencies - Fix pynvml being constantly re-downloaded on update - Update dockerfiles - update INSTALL.md --- Dockerfile.cpu | 4 +- Dockerfile.gpu | 4 +- INSTALL.md | 11 +- requirements.txt => _requirements_base.txt | 32 ++--- requirements_amd.txt | 4 + requirements_cpu.txt | 2 + requirements_nvidia.txt | 2 + setup.py | 143 ++++++++++----------- 8 files changed, 105 insertions(+), 97 deletions(-) rename requirements.txt => _requirements_base.txt (68%) mode change 100755 => 100644 create mode 100644 requirements_amd.txt create mode 100644 requirements_cpu.txt create mode 100644 requirements_nvidia.txt diff --git a/Dockerfile.cpu b/Dockerfile.cpu index 954792f42f..1b2e4c16cf 100755 --- a/Dockerfile.cpu +++ b/Dockerfile.cpu @@ -6,9 +6,9 @@ RUN add-apt-repository -y ppa:jonathonf/ffmpeg-4 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -COPY requirements.txt /opt/ +COPY _requirements_base.txt /opt/ RUN pip3 install --upgrade pip -RUN pip3 --no-cache-dir install -r /opt/requirements.txt && rm /opt/requirements.txt +RUN pip3 --no-cache-dir install -r /opt/_requirements_base.txt && rm /opt/_requirements_base.txt WORKDIR "/srv" CMD ["/bin/bash"] diff --git a/Dockerfile.gpu b/Dockerfile.gpu index e8d45763d1..62a6e52c95 100755 --- a/Dockerfile.gpu +++ b/Dockerfile.gpu @@ -8,9 +8,9 @@ RUN add-apt-repository -y ppa:jonathonf/ffmpeg-4 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -COPY requirements.txt /opt/ +COPY _requirements_base.txt /opt/ RUN pip3 install --upgrade pip -RUN pip3 --no-cache-dir install -r /opt/requirements.txt && rm /opt/requirements.txt +RUN pip3 --no-cache-dir install -r /opt/_requirements_base.txt && rm /opt/_requirements_base.txt RUN pip3 install jupyter matplotlib RUN pip3 install jupyter_http_over_ws RUN jupyter serverextension enable --py jupyter_http_over_ws diff --git a/INSTALL.md b/INSTALL.md index 1f6e16e180..e8657512e3 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -16,7 +16,7 @@ - [Entering your virtual environment](#entering-your-virtual-environment) - [faceswap](#faceswap) - [Easy install](#easy-install) - - [Manual install](#manual-install) + - [Manual install](#manual-install-1) - [Running faceswap](#running-faceswap) - [Create a desktop shortcut](#create-a-desktop-shortcut) - [Updating faceswap](#updating-faceswap) @@ -115,11 +115,12 @@ To enter the virtual environment: #### Manual install Do not follow these steps if the Easy Install above completed succesfully. +If you are using an Nvidia card make sure you have the correct versions of Cuda/cuDNN installed for the required version of Tensorflow - Install tkinter (required for the GUI) by typing: `conda install tk` -- Install requirements: `pip install -r requirements.txt` -- Install Tensorflow (either GPU or CPU version depending on your setup): - - GPU Version: `conda install tensorflow-gpu` - - Non GPU Version: `conda install tensorflow` +- Install requirements: + - For Nvidia GPU users: `pip install -r requirements_nvidia.txt` + - For AMD GPU users: `pip install -r requirements_amd.txt` + - For CPU users: `pip install -r requirements_cpu.txt` ## Running faceswap - If you are not already in your virtual environment follow [these steps](#entering-your-virtual-environment) diff --git a/requirements.txt b/_requirements_base.txt old mode 100755 new mode 100644 similarity index 68% rename from requirements.txt rename to _requirements_base.txt index ebf3c621dc..2621c15980 --- a/requirements.txt +++ b/_requirements_base.txt @@ -1,23 +1,23 @@ -tqdm -psutil -pathlib -numpy==1.17.4 -opencv-python==4.1.2.30 -scikit-image -Pillow==6.2.1 -scikit-learn -toposort -fastcluster -matplotlib==3.1.1 -imageio==2.6.1 -imageio-ffmpeg -ffmpy==0.2.2 +tqdm>=4.42 +psutil>=5.7.0 +pathlib==1.0.1 +numpy>=1.18.0 +opencv-python>=4.1.2.0 +scikit-image>=0.16.2 +Pillow>=7.0.0 +scikit-learn>=0.22.0 +toposort==1.5 +fastcluster==1.1.26 +matplotlib>=3.0.3 +imageio>=2.8.0 +imageio-ffmpeg>=0.4.2 +ffmpy==0.2.3 # Revert back to nvidia-ml-py3 when windows/system32 patch is implemented git+https://github.com/deepfakes/nvidia-ml-py3.git #nvidia-ml-py3 -h5py==2.9.0 +h5py>=2.10.0 Keras==2.2.4 -pywin32 ; sys_platform == "win32" +pywin32>=227 ; sys_platform == "win32" pynvx==1.0.0 ; sys_platform == "darwin" # tensorflow is included within the docker image. diff --git a/requirements_amd.txt b/requirements_amd.txt new file mode 100644 index 0000000000..ecd1dd97bd --- /dev/null +++ b/requirements_amd.txt @@ -0,0 +1,4 @@ +-r _requirements_base.txt +tensorflow>=1.12.0,<=1.15.3 +plaidml-keras==0.6.4 +plaidml==0.6.4 diff --git a/requirements_cpu.txt b/requirements_cpu.txt new file mode 100644 index 0000000000..7c6097cf0d --- /dev/null +++ b/requirements_cpu.txt @@ -0,0 +1,2 @@ +-r _requirements_base.txt +tensorflow>=1.12.0,<=1.15.3 diff --git a/requirements_nvidia.txt b/requirements_nvidia.txt new file mode 100644 index 0000000000..eff1ede17b --- /dev/null +++ b/requirements_nvidia.txt @@ -0,0 +1,2 @@ +-r _requirements_base.txt +tensorflow-gpu>=1.12.0,<=1.15.3 diff --git a/setup.py b/setup.py index 6f89e3c39d..1546df078c 100755 --- a/setup.py +++ b/setup.py @@ -5,13 +5,15 @@ import ctypes import json import locale +import platform +import operator import os import re import sys -import platform - from subprocess import CalledProcessError, run, PIPE, Popen +from pkg_resources import parse_requirements + INSTALL_FAILED = False # Revisions of tensorflow-gpu and cuda/cudnn requirements TENSORFLOW_REQUIREMENTS = {"==1.12.0": ["9.0", "7.2"], @@ -41,7 +43,7 @@ def __init__(self, logger=None, updater=False): self.enable_amd = False self.enable_docker = False self.enable_cuda = False - self.required_packages = self.get_required_packages() + self.required_packages = list() self.missing_packages = list() self.conda_missing_packages = list() @@ -104,8 +106,7 @@ def is_virtualenv(self): def process_arguments(self): """ Process any cli arguments """ - argv = [arg for arg in sys.argv] - for arg in argv: + for arg in sys.argv: if arg == "--installer": self.is_installer = True if arg == "--nvidia": @@ -113,18 +114,34 @@ def process_arguments(self): if arg == "--amd": self.enable_amd = True - @staticmethod - def get_required_packages(): + def get_required_packages(self): """ Load requirements list """ - packages = list() + if self.enable_amd: + suffix = "amd.txt" + elif self.enable_cuda: + suffix = "nvidia.txt" + else: + suffix = "cpu.txt" + req_files = ["_requirements_base.txt", f"requirements_{suffix}"] pypath = os.path.dirname(os.path.realpath(__file__)) - requirements_file = os.path.join(pypath, "requirements.txt") - with open(requirements_file) as req: - for package in req.readlines(): - package = package.strip() - if package and (not package.startswith("#")): - packages.append(package) - return packages + requirements = list() + git_requirements = list() + for req_file in req_files: + requirements_file = os.path.join(pypath, req_file) + with open(requirements_file) as req: + for package in req.readlines(): + package = package.strip() + # parse_requirements can't handle git dependencies, so extract and then + # manually add to final list + if package and package.startswith("git+"): + git_requirements.append((package, [])) + continue + if package and (not package.startswith(("#", "-r"))): + requirements.append(package) + self.required_packages = [(pkg.name, pkg.specs) + for pkg in parse_requirements(requirements) + if pkg.marker is None or pkg.marker.evaluate()] + self.required_packages.extend(git_requirements) def check_permission(self): """ Check for Admin permissions """ @@ -143,7 +160,7 @@ def check_system(self): self.output.info("Setup in %s %s" % (self.os_version[0], self.os_version[1])) if not self.updater and not self.os_version[0] in ["Windows", "Linux", "Darwin"]: self.output.error("Your system %s is not supported!" % self.os_version[0]) - exit(1) + sys.exit(1) def check_python(self): """ Check python and virtual environment status """ @@ -154,7 +171,7 @@ def check_python(self): and self.py_version[1] == "64bit") and not self.updater: self.output.error("Please run this script with Python version 3.3, 3.4, 3.5, 3.6 or " "3.7 64bit and try again.") - exit(1) + sys.exit(1) def output_runtime_info(self): """ Output runtime info """ @@ -172,7 +189,7 @@ def check_pip(self): import pip # noqa pylint:disable=unused-import except ImportError: self.output.error("Import pip failed. Please Install python3-pip and try again") - exit(1) + sys.exit(1) def upgrade_pip(self): """ Upgrade pip to latest version """ @@ -216,12 +233,8 @@ def get_installed_conda_packages(self): def update_tf_dep(self): """ Update Tensorflow Dependency """ - if self.is_conda: - self.update_tf_dep_conda() - return - - if not self.enable_cuda: - self.required_packages.append("tensorflow==1.15.0") + if self.is_conda or not self.enable_cuda: + # CPU/AMD doesn't need Cuda and Conda handles Cuda and cuDNN so nothing to do here return tf_ver = None @@ -234,6 +247,10 @@ def update_tf_dep(self): tf_ver = key break if tf_ver: + # Remove the version of tensorflow in requirements.txt and add the correct version that + # corresponds to the installed Cuda/cuDNN versions + self.required_packages = [pkg for pkg in self.required_packages + if not pkg.startswith("tensorflow-gpu")] tf_ver = "tensorflow-gpu{}".format(tf_ver) self.required_packages.append(tf_ver) return @@ -264,18 +281,6 @@ def update_tf_dep(self): elif custom_tf: self.required_packages.append(custom_tf) - def update_tf_dep_conda(self): - """ Update Conda TF Dependency """ - if not self.enable_cuda: - self.required_packages.append("tensorflow==1.15.0") - else: - self.required_packages.append("tensorflow-gpu==1.15.0") - - def update_amd_dep(self): - """ Update amd dependency for AMD cards """ - if self.enable_amd: - self.required_packages.extend(["plaidml-keras==0.6.4", "plaidml==0.6.4"]) - def set_config(self): """ Set the backend in the faceswap config file """ if self.enable_amd: @@ -346,8 +351,6 @@ def __init__(self, environment): # Checks not required for installer if self.env.is_installer: - self.env.update_tf_dep() - self.env.update_amd_dep() return # Ask AMD/Docker/Cuda @@ -360,7 +363,7 @@ def __init__(self, environment): if self.env.enable_docker: self.docker_tips() self.env.set_config() - exit(0) + sys.exit(0) # Check for CUDA and cuDNN if self.env.enable_cuda and self.env.is_conda: @@ -374,7 +377,6 @@ def __init__(self, environment): self.env.cuda_version = input("Manually specify CUDA version: ") self.env.update_tf_dep() - self.env.update_amd_dep() if self.env.os_version[0] == "Windows": self.tips.pip() @@ -556,11 +558,17 @@ def cudnn_checkfiles_windows(self): class Install(): """ Install the requirements """ def __init__(self, environment): + self._operators = {"==": operator.eq, + ">=": operator.ge, + "<=": operator.le, + ">": operator.gt, + "<": operator.lt} self.output = environment.output self.env = environment if not self.env.is_installer and not self.env.updater: self.ask_continue() + self.env.get_required_packages() self.check_missing_dep() self.check_conda_missing_dep() if (self.env.updater and @@ -579,39 +587,26 @@ def ask_continue(self): inp = input("Please ensure your System Dependencies are met. Continue? [y/N] ") if inp in ("", "N", "n"): self.output.error("Please install system dependencies to continue") - exit(1) + sys.exit(1) def check_missing_dep(self): """ Check for missing dependencies """ - for pkg in self.env.required_packages: - pkg = self.check_os_requirements(pkg) - if pkg is None: - continue - key = pkg.split("==")[0] + for key, specs in self.env.required_packages: if self.env.is_conda: # Get Conda alias for Key key = CONDA_MAPPING.get(key, (key, None))[0] + if (key == "git+https://github.com/deepfakes/nvidia-ml-py3.git" and + self.env.installed_packages.get("nvidia-ml-py3", "") == "7.352.1"): + # Annoying explicit hack to get around our custom version of nvidia-ml=py3 being + # constantly re-downloaded + continue if key not in self.env.installed_packages: - self.env.missing_packages.append(pkg) + self.env.missing_packages.append((key, specs)) continue - else: - if len(pkg.split("==")) > 1: - if pkg.split("==")[1] != self.env.installed_packages.get(key): - self.env.missing_packages.append(pkg) - continue - - @staticmethod - def check_os_requirements(package): - """ Check that the required package is required for this OS """ - if ";" not in package and "sys_platform" not in package: - return package - package = "".join(package.split()) - pkg, tags = package.split(";") - tags = tags.split("==") - sys_platform = tags[tags.index("sys_platform") + 1].replace('"', "").replace("'", "") - if sys_platform == sys.platform: - return pkg - return None + installed_vers = self.env.installed_packages.get(key, "") + if specs and not all(self._operators[spec[0]](installed_vers, spec[1]) + for spec in specs): + self.env.missing_packages.append((key, specs)) def check_conda_missing_dep(self): """ Check for conda missing dependencies """ @@ -622,11 +617,10 @@ def check_conda_missing_dep(self): if key not in self.env.installed_packages: self.env.conda_missing_packages.append(pkg) continue - else: - if len(pkg[0].split("==")) > 1: - if pkg[0].split("==")[1] != self.env.installed_conda_packages.get(key): - self.env.conda_missing_packages.append(pkg) - continue + if len(pkg[0].split("==")) > 1: + if pkg[0].split("==")[1] != self.env.installed_conda_packages.get(key): + self.env.conda_missing_packages.append(pkg) + continue def install_missing_dep(self): """ Install missing dependencies """ @@ -639,7 +633,9 @@ def install_missing_dep(self): def install_python_packages(self): """ Install required pip packages """ self.output.info("Installing Required Python Packages. This may take some time...") - for pkg in self.env.missing_packages: + for pkg, version in self.env.missing_packages: + if version: + pkg = "{}{}".format(pkg, ",".join("".join(spec) for spec in version)) if self.env.is_conda and not pkg.startswith("git"): verbose = pkg.startswith("tensorflow") or self.env.updater pkg = CONDA_MAPPING.get(pkg, (pkg, None)) @@ -658,6 +654,9 @@ def install_conda_packages(self): def conda_installer(self, package, channel=None, verbose=False, conda_only=False): """ Install a conda package """ + # Packages with special characters need to be enclosed in double quotes + if any(char in package for char in (" ", "<", ">", "*", "|")): + package = "\"{}\"".format(package) success = True condaexe = ["conda", "install", "-y"] if not verbose or self.env.updater: @@ -805,5 +804,5 @@ def pip(self): Checks(ENV) ENV.set_config() if INSTALL_FAILED: - exit(1) + sys.exit(1) Install(ENV) From 4a68b3f023ac3675c340231b0da103027860d3f7 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 7 Jun 2020 17:06:00 +0000 Subject: [PATCH 244/981] Move custom dlight blocks to nn_blocks --- lib/model/nn_blocks.py | 83 ++++++++++++++++- plugins/train/__init__.py | 0 plugins/train/model/dlight.py | 149 +++++++++--------------------- tests/lib/model/nn_blocks_test.py | 2 + 4 files changed, 128 insertions(+), 106 deletions(-) create mode 100644 plugins/train/__init__.py diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index 180d0649ee..91a66afb88 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -3,7 +3,7 @@ import logging -from keras.layers import Add, SeparableConv2D +from keras.layers import Add, Concatenate, SeparableConv2D, UpSampling2D from keras.layers.advanced_activations import LeakyReLU from keras.layers.convolutional import Conv2D from keras.layers.core import Activation @@ -308,6 +308,87 @@ def upscale(self, input_tensor, filters, kernel_size=3, padding="same", var_x = PixelShuffler(name="{}_pixelshuffler".format(name), size=scale_factor)(var_x) return var_x + # <<< DLight Model Blocks >>> # + def upscale2x(self, input_tensor, filters, + kernel_size=3, padding="same", interpolation="bilinear", res_block_follows=False, + sr_ratio=0.5, scale_factor=2, fast=False, **kwargs): + """ Custom hybrid upscale layer for sub-pixel up-scaling. + + Most of up-scaling is approximating lighting gradients which can be accurately achieved + using linear fitting. This layer attempts to improve memory consumption by splitting + with bilinear and convolutional layers so that the sub-pixel update will get details + whilst the bilinear filter will get lighting. + + Adds reflection padding if it has been selected by the user, and other post-processing + if requested by the plugin. + + Parameters + ---------- + input_tensor: tensor + The input tensor to the layer + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution) + kernel_size: int, optional + An integer or tuple/list of 2 integers, specifying the height and width of the 2D + convolution window. Can be a single integer to specify the same value for all spatial + dimensions. Default: 3 + padding: ["valid", "same"], optional + The padding to use. Default: `"same"` + interpolation: ["nearest", "bilinear"], optional + Interpolation to use for up-sampling. Default: `"bilinear"` + res_block_follows: bool, optional + If a residual block will follow this layer, then this should be set to `True` to add + a leaky ReLu after the convolutional layer. Default: ``False`` + scale_factor: int, optional + The amount to upscale the image. Default: `2` + sr_ratio: float, optional + The proportion of super resolution (pixel shuffler) filters to use. Non-fast mode only. + Default: `0.5` + kwargs: dict + Any additional Keras standard layer keyword arguments + fast: bool, optional + Use a faster up-scaling method that may appear more rugged. Default: ``False`` + + Returns + ------- + tensor + The output tensor from the Upscale layer + """ + name = self._get_name("upscale2x_{}".format("fast" if fast else "hyb")) + var_x = input_tensor + if not fast: + sr_filters = int(filters * sr_ratio) + filters = filters - sr_filters + var_x_sr = self.upscale(var_x, filters, + kernel_size=kernel_size, + padding=padding, + scale_factor=scale_factor, + res_block_follows=res_block_follows, + **kwargs) + + if fast or (not fast and filters > 0): + var_x2 = self.conv2d(var_x, filters, + kernel_size=3, + padding=padding, + name="{}_conv2d".format(name), + **kwargs) + var_x2 = UpSampling2D(size=(scale_factor, scale_factor), + interpolation=interpolation, + name="{}_upsampling2D".format(name))(var_x2) + if fast: + var_x1 = self.upscale(var_x, filters, + kernel_size=kernel_size, + padding=padding, + scale_factor=scale_factor, + res_block_follows=res_block_follows, **kwargs) + var_x = Add()([var_x2, var_x1]) + else: + var_x = Concatenate(name="{}_concatenate".format(name))([var_x_sr, var_x2]) + else: + var_x = var_x_sr + return var_x + # <<< DFaker Model Blocks >>> # def res_block(self, input_tensor, filters, kernel_size=3, padding="same", **kwargs): """ Residual block. diff --git a/plugins/train/__init__.py b/plugins/train/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/train/model/dlight.py b/plugins/train/model/dlight.py index 8e1e677fa5..3b9fc9b346 100644 --- a/plugins/train/model/dlight.py +++ b/plugins/train/model/dlight.py @@ -3,17 +3,13 @@ By AnDenix, 2018-2019 Based on the dfaker model: https://github.com/dfaker - Acknowledgements: - kvrooman for numrious insights and invaluable aid + Acknowledgments: + kvrooman for numerous insights and invaluable aid DeepHomage for lots of testing """ -import sys -import types - -from keras.initializers import RandomNormal -from keras.layers import Add, Dense, Flatten, Input, Reshape, AveragePooling2D, LeakyReLU -from keras.layers.convolutional import UpSampling2D, Conv2DTranspose +from keras.layers import Dense, Flatten, Input, Reshape, AveragePooling2D, LeakyReLU +from keras.layers import UpSampling2D from keras.layers.core import Dropout from keras.layers.merge import Concatenate from keras.layers.normalization import BatchNormalization @@ -25,52 +21,6 @@ from .original import Model as OriginalModel -# [P] TODO Move upscale2x_hyb to nnblocks.py (after testing) -# <<< DeLight Model Blocks >>> # -def upscale2x_hyb(self, inp, filters, kernel_size=3, padding='same', - sr_ratio=0.5, scale_factor=2, interpolation='bilinear', - res_block_follows=False, **kwargs): - """Hybrid Upscale Layer""" - name = self._get_name("upscale2x_hyb") - var_x = inp - - sr_filters = int(filters * sr_ratio) - upscale_filters = filters - sr_filters - - var_x_sr = self.upscale(var_x, upscale_filters, kernel_size=kernel_size, - padding=padding, scale_factor=scale_factor, - res_block_follows=res_block_follows, **kwargs) - if upscale_filters > 0: - var_x_us = self.conv2d(var_x, upscale_filters, kernel_size=3, padding=padding, - name="{}_conv2d".format(name), **kwargs) - var_x_us = UpSampling2D(size=(scale_factor, scale_factor), interpolation=interpolation, - name="{}_upsampling2D".format(name))(var_x_us) - var_x = Concatenate(name="{}_concatenate".format(name))([var_x_sr, var_x_us]) - else: - var_x = var_x_sr - - return var_x - - -def upscale2x_fast(self, inp, filters, kernel_size=3, padding='same', - sr_ratio=0.5, scale_factor=2, interpolation='bilinear', - res_block_follows=False, **kwargs): - """Fast Upscale Layer""" - name = self._get_name("upscale2x_fast") - var_x = inp - - var_x2 = self.conv2d(var_x, filters, kernel_size=3, padding=padding, - name="{}_conv2d".format(name), **kwargs) - var_x2 = UpSampling2D(size=(scale_factor, scale_factor), interpolation=interpolation, - name="{}_upsampling2D".format(name))(var_x2) - - var_x1 = self.upscale(var_x, filters, kernel_size=kernel_size, - padding=padding, scale_factor=scale_factor, - res_block_follows=res_block_follows, **kwargs) - var_x = Add()([var_x2, var_x1]) - return var_x - - class Model(OriginalModel): """ DeLight Autoencoder Model """ @@ -82,42 +32,33 @@ def __init__(self, *args, **kwargs): kwargs["encoder_dim"] = -1 self.dense_output = None self.detail_level = None + self.features = None + self.encoder_filters = None + self.encoder_dim = None + self.details = None + self.upscale_ratio = None super().__init__(*args, **kwargs) logger.debug("Initialized %s", self.__class__.__name__) def _detail_level_setup(self): logger.debug('self.config[output_size]: %d', self.config["output_size"]) - - self.features = { - 'lowmem': 0, - 'fair': 1, - 'best': 2, - }[self.config["features"]] + self.features = dict(lowmem=0, fair=1, best=2)[self.config["features"]] logger.debug('self.features: %d', self.features) - self.encoder_filters = 64 if self.features > 0 else 48 logger.debug('self.encoder_filters: %d', self.encoder_filters) bonum_fortunam = 128 - self.encoder_dim = { - 0: 512 + bonum_fortunam, - 1: 1024 + bonum_fortunam, - 2: 1536 + bonum_fortunam, - }[self.features] + self.encoder_dim = {0: 512 + bonum_fortunam, + 1: 1024 + bonum_fortunam, + 2: 1536 + bonum_fortunam}[self.features] logger.debug('self.encoder_dim: %d', self.encoder_dim) - - self.details = { - 'fast': 0, - 'good': 1, - }[self.config["details"]] + self.details = dict(fast=0, good=1)[self.config["details"]] logger.debug('self.details: %d', self.details) try: - self.upscale_ratio = { - 128: 2, - 256: 4, - 384: 6 - }[self.config["output_size"]] + self.upscale_ratio = {128: 2, + 256: 4, + 384: 6}[self.config["output_size"]] except KeyError: logger.error("Config error: output_size must be one of: 128, 256, or 384.") raise FaceswapError("Config error: output_size must be one of: 128, 256, or 384.") @@ -126,9 +67,6 @@ def _detail_level_setup(self): def build(self): self._detail_level_setup() - # monkey patch-in nn_blocks - self.blocks.upscale2x_hyb = types.MethodType(upscale2x_hyb, self.blocks) - self.blocks.upscale2x_fast = types.MethodType(upscale2x_fast, self.blocks) super().build() def add_networks(self): @@ -141,11 +79,12 @@ def add_networks(self): self.add_network("encoder", None, self.encoder()) logger.debug("Added networks") - def compile_predictors(self, **kwargs): + def compile_predictors(self, **kwargs): # pylint: disable=arguments-differ self.set_networks_trainable() super().compile_predictors(**kwargs) def set_networks_trainable(self): + """ Set the network state to trainable """ train_encoder = True train_decoder_a = True train_decoder_b = True @@ -210,10 +149,10 @@ def decoder_a(self): var_xy = UpSampling2D(self.upscale_ratio, interpolation='bilinear')(var_xy) var_x = var_xy - var_x = self.blocks.upscale2x_hyb(var_x, decoder_a_complexity) - var_x = self.blocks.upscale2x_hyb(var_x, decoder_a_complexity // 2) - var_x = self.blocks.upscale2x_hyb(var_x, decoder_a_complexity // 4) - var_x = self.blocks.upscale2x_hyb(var_x, decoder_a_complexity // 8) + var_x = self.blocks.upscale2x(var_x, decoder_a_complexity, fast=False) + var_x = self.blocks.upscale2x(var_x, decoder_a_complexity // 2, fast=False) + var_x = self.blocks.upscale2x(var_x, decoder_a_complexity // 4, fast=False) + var_x = self.blocks.upscale2x(var_x, decoder_a_complexity // 8, fast=False) var_x = self.blocks.conv2d(var_x, 3, kernel_size=5, padding="same", activation="sigmoid", name="face_out") @@ -222,10 +161,10 @@ def decoder_a(self): if self.config.get("learn_mask", False): var_y = var_xy # mask decoder - var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity) - var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 2) - var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 4) - var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 8) + var_y = self.blocks.upscale2x(var_y, mask_complexity, fast=False) + var_y = self.blocks.upscale2x(var_y, mask_complexity // 2, fast=False) + var_y = self.blocks.upscale2x(var_y, mask_complexity // 4, fast=False) + var_y = self.blocks.upscale2x(var_y, mask_complexity // 8, fast=False) var_y = self.blocks.conv2d(var_y, 1, kernel_size=5, padding="same", activation="sigmoid", name="mask_out") @@ -246,10 +185,10 @@ def decoder_b_fast(self): var_xy = self.blocks.upscale(var_xy, 512, scale_factor=self.upscale_ratio) var_x = var_xy - var_x = self.blocks.upscale2x_fast(var_x, decoder_b_complexity) - var_x = self.blocks.upscale2x_fast(var_x, decoder_b_complexity // 2) - var_x = self.blocks.upscale2x_fast(var_x, decoder_b_complexity // 4) - var_x = self.blocks.upscale2x_fast(var_x, decoder_b_complexity // 8) + var_x = self.blocks.upscale2x(var_x, decoder_b_complexity, fast=True) + var_x = self.blocks.upscale2x(var_x, decoder_b_complexity // 2, fast=True) + var_x = self.blocks.upscale2x(var_x, decoder_b_complexity // 4, fast=True) + var_x = self.blocks.upscale2x(var_x, decoder_b_complexity // 8, fast=True) var_x = self.blocks.conv2d(var_x, 3, kernel_size=5, padding="same", activation="sigmoid", name="face_out") @@ -259,10 +198,10 @@ def decoder_b_fast(self): if self.config.get("learn_mask", False): var_y = var_xy # mask decoder - var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity) - var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 2) - var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 4) - var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 8) + var_y = self.blocks.upscale2x(var_y, mask_complexity, fast=False) + var_y = self.blocks.upscale2x(var_y, mask_complexity // 2, fast=False) + var_y = self.blocks.upscale2x(var_y, mask_complexity // 4, fast=False) + var_y = self.blocks.upscale2x(var_y, mask_complexity // 8, fast=False) var_y = self.blocks.conv2d(var_y, 1, kernel_size=5, padding="same", activation="sigmoid", name="mask_out") @@ -280,23 +219,23 @@ def decoder_b(self): var_xy = input_ - var_xy = self.blocks.upscale2x_hyb(var_xy, 512, scale_factor=self.upscale_ratio) + var_xy = self.blocks.upscale2x(var_xy, 512, scale_factor=self.upscale_ratio, fast=False) var_x = var_xy var_x = self.blocks.res_block(var_x, 512, use_bias=True) var_x = self.blocks.res_block(var_x, 512, use_bias=False) var_x = self.blocks.res_block(var_x, 512, use_bias=False) - var_x = self.blocks.upscale2x_hyb(var_x, decoder_b_complexity) + var_x = self.blocks.upscale2x(var_x, decoder_b_complexity, fast=False) var_x = self.blocks.res_block(var_x, decoder_b_complexity, use_bias=True) var_x = self.blocks.res_block(var_x, decoder_b_complexity, use_bias=False) var_x = BatchNormalization()(var_x) - var_x = self.blocks.upscale2x_hyb(var_x, decoder_b_complexity // 2) + var_x = self.blocks.upscale2x(var_x, decoder_b_complexity // 2, fast=False) var_x = self.blocks.res_block(var_x, decoder_b_complexity // 2, use_bias=True) - var_x = self.blocks.upscale2x_hyb(var_x, decoder_b_complexity // 4) + var_x = self.blocks.upscale2x(var_x, decoder_b_complexity // 4, fast=False) var_x = self.blocks.res_block(var_x, decoder_b_complexity // 4, use_bias=False) var_x = BatchNormalization()(var_x) - var_x = self.blocks.upscale2x_hyb(var_x, decoder_b_complexity // 8) + var_x = self.blocks.upscale2x(var_x, decoder_b_complexity // 8, fast=False) var_x = self.blocks.conv2d(var_x, 3, kernel_size=5, padding="same", activation="sigmoid", name="face_out") @@ -306,10 +245,10 @@ def decoder_b(self): if self.config.get("learn_mask", False): var_y = var_xy # mask decoder - var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity) - var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 2) - var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 4) - var_y = self.blocks.upscale2x_hyb(var_y, mask_complexity // 8) + var_y = self.blocks.upscale2x(var_y, mask_complexity, fast=False) + var_y = self.blocks.upscale2x(var_y, mask_complexity // 2, fast=False) + var_y = self.blocks.upscale2x(var_y, mask_complexity // 4, fast=False) + var_y = self.blocks.upscale2x(var_y, mask_complexity // 8, fast=False) var_y = self.blocks.conv2d(var_y, 1, kernel_size=5, padding="same", activation="sigmoid", name="mask_out") diff --git a/tests/lib/model/nn_blocks_test.py b/tests/lib/model/nn_blocks_test.py index 9515a2dc9d..ebe8256f8c 100644 --- a/tests/lib/model/nn_blocks_test.py +++ b/tests/lib/model/nn_blocks_test.py @@ -72,3 +72,5 @@ def test_blocks(use_icnr_init, use_convaware_init, use_reflect_padding): block_test(cls_.conv_sep, input_shape=(2, 8, 8, 32), kwargs=dict(filters=64)) block_test(cls_.upscale, input_shape=(2, 4, 4, 128), kwargs=dict(filters=64)) block_test(cls_.res_block, input_shape=(2, 2, 2, 64), kwargs=dict(filters=64)) + block_test(cls_.upscale2x, input_shape=(2, 4, 4, 128), kwargs=dict(filters=64, fast=False)) + block_test(cls_.upscale2x, input_shape=(2, 4, 4, 128), kwargs=dict(filters=64, fast=True)) From f19a04505ce1ba72108dd0877b2a4465f6b94a5e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 27 Jun 2020 18:48:50 +0000 Subject: [PATCH 245/981] Core updates --- lib/alignments.py | 72 +++++++++++++++++++++++- lib/faces_detect.py | 5 +- lib/gui/control_helper.py | 113 +++++++++++++++++++++++++++++--------- lib/image.py | 34 ++++++++++++ 4 files changed, 196 insertions(+), 28 deletions(-) diff --git a/lib/alignments.py b/lib/alignments.py index 4d071fa752..0903cdcd43 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -41,6 +41,7 @@ def __init__(self, folder, filename="alignments"): self._data = self._load() self._update_legacy() self._hashes_to_frame = dict() + self._thumbnails = Thumbnails(self) logger.debug("Initialized %s", self.__class__.__name__) # << PROPERTIES >> # @@ -126,6 +127,12 @@ def video_meta_data(self): retval = dict(pts_time=pts_time, keyframes=keyframes) return retval + @property + def thumbnails(self): + """ :class:`~lib.alignments.Thumbnails`: The low resolution thumbnail images that exist + within the alignments file """ + return self._thumbnails + # << INIT FUNCTIONS >> # def _get_location(self, folder, filename): @@ -475,7 +482,6 @@ def filter_hashes(self, hash_list, filter_out=False): filename, idx) # << GENERATORS >> # - def yield_faces(self): """ Generator to obtain all faces with meta information from :attr:`data`. The results are yielded by frame. @@ -651,3 +657,67 @@ def _update_legacy_landmarks_list(self): alignment["landmarks_xy"] = np.array(test, dtype="float32") update_count += 1 logger.debug("Updated landmarks_xy: %s", update_count) + + +class Thumbnails(): + """ Thumbnail images stored in the alignments file. + + The thumbnails are stored as low resolution (64px), low quality jpg in the alignments file + and are used for the Manual Alignments tool. + + Parameters + ---------- + alignments: :class:'~lib.alignments.Alignments` + The parent alignments class that these thumbs belong to + """ + def __init__(self, alignments): + logger.debug("Initializing %s: (alignments: %s)", self.__class__.__name__, alignments) + self._alignments_dict = alignments.data + self._frame_list = list(sorted(self._alignments_dict)) + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def has_thumbails(self): + """ bool: ``True`` if all faces in the alignments file contain thumbnail images + otherwise ``False``. """ + retval = all("thumb" in face + for frame in self._alignments_dict.values() + for face in frame["faces"]) + logger.trace(retval) + return retval + + def get_thumbnail_by_index(self, frame_index, face_index): + """ Obtain a jpg thumbnail from the given frame index for the given face index + + Parameters + ---------- + frame_index: int + The frame index that contains the thumbnail + face_index: int + The face index within the frame to retrieve the thumbnail for + + Returns + ------- + :class:`numpy.ndarray` + The encoded jpg thumbnail + """ + retval = self._alignments_dict[self._frame_list[frame_index]]["faces"][face_index]["thumb"] + logger.trace("frame index: %s, face_index: %s, thumb shape: %s", + frame_index, face_index, retval.shape) + return retval + + def add_thumbnail(self, frame, face_index, thumb): + """ Add a thumbnail for the given face index for the given frame. + + Parameters + ---------- + frame: str + The name of the frame to add the thumbnail for + face_index: int + The face index within the given frame to add the thumbnail for + thumb: :class:`numpy.ndarray` + The encoded jpg thumbnail at 64px to add to the alignments file + """ + logger.debug("frame: %s, face_index: %s, thumb shape: %s thumb dtype: %s", + frame, face_index, thumb.shape, thumb.dtype) + self._alignments_dict[frame]["faces"][face_index]["thumb"] = thumb diff --git a/lib/faces_detect.py b/lib/faces_detect.py index b253cad584..f0e5f88b25 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -191,10 +191,13 @@ def from_alignment(self, alignment, image=None): self.w = alignment["w"] self.y = alignment["y"] self.h = alignment["h"] + self.aligned = dict() + self.feed = dict() + self.reference = dict() landmarks = alignment["landmarks_xy"] if not isinstance(landmarks, np.ndarray): landmarks = np.array(landmarks, dtype="float32") - self.landmarks_xy = landmarks + self.landmarks_xy = landmarks.copy() # Manual tool does not know the final hash so default to None self.hash = alignment.get("hash", None) # Manual tool and legacy alignments will not have a mask diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index e878646430..da189da79d 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -4,7 +4,7 @@ import re import tkinter as tk -from tkinter import ttk +from tkinter import colorchooser, ttk from itertools import zip_longest from functools import partial @@ -85,13 +85,17 @@ class ControlPanelOption(): group: str, optional The group that this control should sit with. If provided, all controls in the same group will be placed together. Default: None + subgroup: str, optional + The subgroup that this option belongs to. If provided, will group options in the same + subgroups together for the same layout as option/check boxes. Default: ``None`` default: str, optional Default value for the control. If None is provided, then action will be dictated by whether "blank_nones" is set in ControlPanel initial_value: str, optional Initial value for the control. If None, default will be used choices: list or tuple, object - Used for combo boxes and radio control option setting + Used for combo boxes and radio control option setting. Set to `"colorchooser"` for a color + selection dialog. is_radio: bool, optional Specifies to use a Radio control instead of combobox if choices are passed is_multi_option: @@ -113,20 +117,22 @@ class ControlPanelOption(): """ def __init__(self, title, dtype, # pylint:disable=too-many-arguments - group=None, default=None, initial_value=None, choices=None, is_radio=False, - is_multi_option=False, rounding=None, min_max=None, sysbrowser=None, - helptext=None, track_modified=False, command=None): - logger.debug("Initializing %s: (title: '%s', dtype: %s, group: %s, default: %s, " - "initial_value: %s, choices: %s, is_radio: %s, is_multi_option: %s, " - "rounding: %s, min_max: %s, sysbrowser: %s, helptext: '%s', " - "track_modified: %s, command: '%s')", self.__class__.__name__, title, dtype, - group, default, initial_value, choices, is_radio, is_multi_option, rounding, - min_max, sysbrowser, helptext, track_modified, command) + group=None, subgroup=None, default=None, initial_value=None, choices=None, + is_radio=False, is_multi_option=False, rounding=None, min_max=None, + sysbrowser=None, helptext=None, track_modified=False, command=None): + logger.debug("Initializing %s: (title: '%s', dtype: %s, group: %s, subgroup: %s, " + "default: %s, initial_value: %s, choices: %s, is_radio: %s, " + "is_multi_option: %s, rounding: %s, min_max: %s, sysbrowser: %s, " + "helptext: '%s', track_modified: %s, command: '%s')", self.__class__.__name__, + title, dtype, group, subgroup, default, initial_value, choices, is_radio, + is_multi_option, rounding, min_max, sysbrowser, helptext, track_modified, + command) self.dtype = dtype self.sysbrowser = sysbrowser self._command = command self._options = dict(title=title, + subgroup=subgroup, group=group, default=default, initial_value=initial_value, @@ -157,6 +163,11 @@ def group(self): group = "_master" if group is None else group return group + @property + def subgroup(self): + """ str: The subgroup for the option, or ``None`` if none provided. """ + return self._options["subgroup"] + @property def default(self): """ Return either selected value or default """ @@ -253,6 +264,8 @@ def get_control(self): control = "radio" elif self.choices and self.is_multi_option: control = "multi" + elif self.choices and self.choices == "colorchooser": + control = "colorchooser" elif self.choices: control = ttk.Combobox elif self.dtype == bool: @@ -373,6 +386,7 @@ def __init__(self, parent, options, # pylint:disable=too-many-arguments self.header_text = header_text self.group_frames = dict() + self._sub_group_frames = dict() self._canvas = tk.Canvas(self, bd=0, highlightthickness=0) self._canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) @@ -431,7 +445,10 @@ def build_panel(self, blank_nones, scrollbar): for option in self.options: group_frame = self.get_group_frame(option.group) - ctl = ControlBuilder(group_frame["frame"], + sub_group_frame = self._get_subgroup_frame(group_frame["frame"], option.subgroup) + frame = group_frame["frame"] if sub_group_frame is None else sub_group_frame.subframe + + ctl = ControlBuilder(frame, option, label_width=self.label_width, checkbuttons_frame=group_frame["chkbtns"], @@ -499,6 +516,18 @@ def checkbuttons_frame(self, frame): logger.debug("Added Options CheckButtons Frame") return holder + def _get_subgroup_frame(self, parent, subgroup): + if subgroup is None: + return subgroup + if subgroup not in self._sub_group_frames: + sub_frame = ttk.Frame(parent, name="subgroup_{}".format(subgroup)) + self._sub_group_frames[subgroup] = AutoFillContainer(sub_frame, + self.option_columns, + self.option_columns) + sub_frame.pack(anchor=tk.W, expand=True, fill=tk.X) + logger.debug("Added Subgroup Frame: %s", subgroup) + return self._sub_group_frames[subgroup] + class AutoFillContainer(): """ A container object that auto-fills columns """ @@ -771,7 +800,7 @@ def set_tk_var(self, blank_nones): def build_control(self): """ Build the correct control type for the option passed through """ logger.debug("Build config option control") - if self.option.control not in (ttk.Checkbutton, "radio", "multi"): + if self.option.control not in (ttk.Checkbutton, "radio", "multi", "colorchooser"): self.build_control_label() self.build_one_control() logger.debug("Built option control") @@ -793,6 +822,8 @@ def build_one_control(self): ctl = self.slider_control() elif self.option.control in ("radio", "multi"): ctl = self._multi_option_control(self.option.control) + elif self.option.control == "colorchooser": + ctl = self._color_control() elif self.option.control == ttk.Checkbutton: ctl = self.control_to_checkframe() else: @@ -925,19 +956,18 @@ def control_to_optionsframe(self): """ Standard non-check buttons sit in the main options frame """ logger.debug("Add control to Options Frame: (widget: '%s', control: %s, choices: %s)", self.option.name, self.option.control, self.option.choices) - if self.option.control == ttk.Checkbutton: - ctl = self.option.control(self.frame, variable=self.option.tk_var, text=None) - else: - if self.option.sysbrowser is not None: - self.filebrowser = FileBrowser(self.option.name, - self.option.tk_var, - self.frame, - self.option.sysbrowser) - ctl = self.option.control(self.frame, - textvariable=self.option.tk_var, - font=get_config().default_font) - rc_menu = _get_contextmenu(ctl) - rc_menu.cm_bind() + if self.option.sysbrowser is not None: + self.filebrowser = FileBrowser(self.option.name, + self.option.tk_var, + self.frame, + self.option.sysbrowser) + + ctl = self.option.control(self.frame, + textvariable=self.option.tk_var, + font=get_config().default_font) + rc_menu = _get_contextmenu(ctl) + rc_menu.cm_bind() + if self.option.choices: logger.debug("Adding combo choices: %s", self.option.choices) ctl["values"] = self.option.choices @@ -945,6 +975,37 @@ def control_to_optionsframe(self): logger.debug("Added control to Options Frame: %s", self.option.name) return ctl + def _color_control(self): + """ Clickable label holding the currently selected color """ + logger.debug("Add control to Options Frame: (widget: '%s', control: %s, choices: %s)", + self.option.name, self.option.control, self.option.choices) + frame = ttk.Frame(self.frame) + ctl = tk.Frame(frame, + bg=self.option.default, + bd=2, + cursor="hand1", + relief=tk.SUNKEN, + width=round(int(20 * get_config().scaling_factor)), + height=round(int(12 * get_config().scaling_factor))) + ctl.bind("", lambda *e, c=ctl, t=self.option.title: self._ask_color(c, t)) + ctl.pack(side=tk.LEFT, anchor=tk.W) + lbl = ttk.Label(frame, text=self.option.title, width=self.label_width, anchor=tk.W) + lbl.pack(padx=2, pady=5, side=tk.RIGHT, anchor=tk.N) + frame.pack(side=tk.LEFT, anchor=tk.W) + if self.option.helptext is not None: + _get_tooltip(lbl, text=self.option.helptext, wraplength=600) + logger.debug("Added control to Options Frame: %s", self.option.name) + return ctl + + def _ask_color(self, frame, title): + """ Pop ask color dialog set to variable and change frame color """ + color = self.option.tk_var.get() + chosen = colorchooser.askcolor(color=color, title="{} Color".format(title))[1] + if chosen is None: + return + frame.config(bg=chosen) + self.option.tk_var.set(chosen) + def control_to_checkframe(self): """ Add check-buttons to the check-button frame """ logger.debug("Add control checkframe: '%s'", self.option.name) diff --git a/lib/image.py b/lib/image.py index b930dd75e2..0393c1c6f4 100644 --- a/lib/image.py +++ b/lib/image.py @@ -470,6 +470,40 @@ def batch_convert_color(batch, colorspace): return batch.reshape(original_shape) +def hex_to_rgb(hexcode): + """ Convert a hex number to it's RGB counterpart. + + Parameters + ---------- + hexcode: str + The hex code to convert (e.g. `"#0d25ac"`) + + Returns + ------- + tuple + The hex code as a 3 integer (`R`, `G`, `B`) tuple + """ + value = hexcode.lstrip("#") + chars = len(value) + return tuple(int(value[i:i + chars // 3], 16) for i in range(0, chars, chars // 3)) + + +def rgb_to_hex(rgb): + """ Convert an RGB tuple to it's hex counterpart. + + Parameters + ---------- + rgb: tuple + The (`R`, `G`, `B`) integer values to convert (e.g. `(0, 255, 255)`) + + Returns + ------- + str: + The 6 digit hex code with leading `#` applied + """ + return "#{:02x}{:02x}{:02x}".format(*rgb) + + # ################### # # <<< VIDEO UTILS >>> # # ################### # From 93aa2801a0c9159f5ca4ad1428c6033c19cddebf Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 30 Jun 2020 11:20:54 +0000 Subject: [PATCH 246/981] Minor core updates --- lib/faces_detect.py | 25 +++++++++++++++++++------ lib/gui/control_helper.py | 2 +- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/lib/faces_detect.py b/lib/faces_detect.py index f0e5f88b25..9e3a370177 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -85,6 +85,7 @@ def __init__(self, image=None, x=None, w=None, y=None, h=None, self.y = y # pylint:disable=invalid-name self.h = h # pylint:disable=invalid-name self.landmarks_xy = landmarks_xy + self.thumbnail = None self.mask = dict() if mask is None else mask self.hash = None @@ -153,9 +154,9 @@ def to_alignment(self): ------- alignment: dict The alignment dict will be returned with the keys ``x``, ``w``, ``y``, ``h``, - ``landmarks_xy``, ``mask``, ``hash``. + ``landmarks_xy``, ``mask``, ``hash``. The additional key ``thumb`` will be provided + if the detected face object contains a thumbnail. """ - alignment = dict() alignment["x"] = self.x alignment["w"] = self.w @@ -164,10 +165,12 @@ def to_alignment(self): alignment["landmarks_xy"] = self.landmarks_xy alignment["hash"] = self.hash alignment["mask"] = {name: mask.to_dict() for name, mask in self.mask.items()} + if self.thumbnail is not None: + alignment["thumb"] = self.thumbnail logger.trace("Returning: %s", alignment) return alignment - def from_alignment(self, alignment, image=None): + def from_alignment(self, alignment, image=None, with_thumb=False): """ Set the attributes of this class from an alignments file and optionally load the face into the ``image`` attribute. @@ -176,6 +179,8 @@ def from_alignment(self, alignment, image=None): alignment: dict A dictionary entry for a face from an alignments file containing the keys ``x``, ``w``, ``y``, ``h``, ``landmarks_xy``. + Optionally the key ``thumb`` will be provided. This is for use in the manual tool and + contains the compressed jpg thumbnail of the face to be allocated to :attr:`thumbnail. Optionally the key ``hash`` will be provided, but not all use cases will know the face hash at this time. Optionally the key ``mask`` will be provided, but legacy alignments will not have @@ -183,6 +188,9 @@ def from_alignment(self, alignment, image=None): image: numpy.ndarray, optional If an image is passed in, then the ``image`` attribute will be set to the cropped face based on the passed in bounding box co-ordinates + with_thumb: bool, optional + Whether to load the jpg thumbnail into the detected face object, if provided. + Default: ``False`` """ logger.trace("Creating from alignment: (alignment: %s, has_image: %s)", @@ -191,16 +199,21 @@ def from_alignment(self, alignment, image=None): self.w = alignment["w"] self.y = alignment["y"] self.h = alignment["h"] - self.aligned = dict() - self.feed = dict() - self.reference = dict() landmarks = alignment["landmarks_xy"] if not isinstance(landmarks, np.ndarray): landmarks = np.array(landmarks, dtype="float32") self.landmarks_xy = landmarks.copy() + + if with_thumb: + # Thumbnails currently only used for manual tool. Default to None + self.thumbnail = alignment.get("thumb", None) # Manual tool does not know the final hash so default to None self.hash = alignment.get("hash", None) # Manual tool and legacy alignments will not have a mask + self.aligned = dict() + self.feed = dict() + self.reference = dict() + if alignment.get("mask", None) is not None: self.mask = dict() for name, mask_dict in alignment["mask"].items(): diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index da189da79d..aad12b39d6 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -983,7 +983,7 @@ def _color_control(self): ctl = tk.Frame(frame, bg=self.option.default, bd=2, - cursor="hand1", + cursor="hand2", relief=tk.SUNKEN, width=round(int(20 * get_config().scaling_factor)), height=round(int(12 * get_config().scaling_factor))) From 6595cdf06268a3bd0ec408e1f71fc42441d85bf7 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 3 Jul 2020 14:42:33 +0000 Subject: [PATCH 247/981] Add icons --- lib/gui/.cache/icons/point.png | Bin 0 -> 3880 bytes lib/gui/.cache/icons/selection.png | Bin 0 -> 3218 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100755 lib/gui/.cache/icons/point.png create mode 100755 lib/gui/.cache/icons/selection.png diff --git a/lib/gui/.cache/icons/point.png b/lib/gui/.cache/icons/point.png new file mode 100755 index 0000000000000000000000000000000000000000..471953dc1165f4592476c3f4c4efa1ea2d5e8210 GIT binary patch literal 3880 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 literal 0 HcmV?d00001 diff --git a/lib/gui/.cache/icons/selection.png b/lib/gui/.cache/icons/selection.png new file mode 100755 index 0000000000000000000000000000000000000000..877513c8adef81fe75e901e6350ed049eff7ae8e GIT binary patch literal 3218 zcmV;D3~lp?P)Hq)=PiaF#P*7-ZbZ>KLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&neh&#Q1i z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_IfqH8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4 zfg=2N-7=cNnjjOr{yriy6mMFgG#l znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U zt5vFIcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya? z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0 z?2xS?_ve_-kiKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$ z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4 z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i& z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01 z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3| zawq-H%e&ckC+@AhPrP6BKT#_XdT7&;F71j}Joy zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F} z0005KNklV%n+O*I^MqESlzelvx~gWWcx92fDDiUGC&5%0Ivp&*>lm6?o{ho3~Z2egsvPJa0P5)9U&ov5JCtc zgm6az*dzJp^e%AYME3%)1+)O#B=5>Ajt;(Jn%fc3u*Hre15&LGwZvq=53i1Ne`}5T zxf{Gqa@91Y3q06-2b={Ingy1Ju;EkSJ|J!hmsVUc`flzn_Gb!XlHT^LOU?7W%cuJk$1la01wlM`dN#E Date: Sat, 4 Jul 2020 10:38:46 +0100 Subject: [PATCH 248/981] Sort by face - Inherit allow_growth option from extract.ini --- lib/vgg_face2_keras.py | 25 ++++++++++++++++++++----- tools/sort/sort.py | 10 +++++++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/lib/vgg_face2_keras.py b/lib/vgg_face2_keras.py index 8ef6ea704f..5c49c8080d 100644 --- a/lib/vgg_face2_keras.py +++ b/lib/vgg_face2_keras.py @@ -38,9 +38,9 @@ class VGGFace2(): https://creativecommons.org/licenses/by-nc/4.0/ """ - def __init__(self, backend="GPU", loglevel="INFO"): - logger.debug("Initializing %s: (backend: %s, loglevel: %s)", - self.__class__.__name__, backend, loglevel) + def __init__(self, backend="GPU", allow_growth=False, loglevel="INFO"): + logger.debug("Initializing %s: (backend: %s, allow_growth: %s, loglevel: %s)", + self.__class__.__name__, backend, allow_growth, loglevel) backend = backend.upper() git_model_id = 10 model_filename = ["vggface2_resnet50_v2.h5"] @@ -48,12 +48,12 @@ def __init__(self, backend="GPU", loglevel="INFO"): # Average image provided in https://github.com/ox-vgg/vgg_face2 self.average_img = np.array([91.4953, 103.8827, 131.0912]) - self.model = self._get_model(git_model_id, model_filename, backend) + self.model = self._get_model(git_model_id, model_filename, backend, allow_growth) logger.debug("Initialized %s", self.__class__.__name__) # <<< GET MODEL >>> # @staticmethod - def _get_model(git_model_id, model_filename, backend): + def _get_model(git_model_id, model_filename, backend, allow_growth): """ Check if model is available, if not, download and unzip it Parameters @@ -66,6 +66,8 @@ def _get_model(git_model_id, model_filename, backend): information) backend: ['GPU', 'CPU'] Whether to run inference on a GPU or on the CPU + allow_growth: bool + ``True`` if Tensorflow's allow_growth option should be set, otherwise ``False`` See Also -------- @@ -78,6 +80,19 @@ def _get_model(git_model_id, model_filename, backend): if os.environ.get("KERAS_BACKEND", "") == "plaidml.keras.backend": logger.info("Switching to tensorflow backend.") os.environ["KERAS_BACKEND"] = "tensorflow" + + if allow_growth: + # TODO This needs to be centralized. Just a hacky fix to read the allow growth config + # option from the Extraction config file + logger.info("Enabling Tensorflow 'allow_growth' option") + import tensorflow as tf + from keras.backend.tensorflow_backend import set_session + config = tf.ConfigProto() + config.gpu_options.allow_growth = True + config.gpu_options.visible_device_list = "0" + set_session(tf.Session(config=config)) + logger.debug("Set Tensorflow 'allow_growth' option") + import keras from lib.model.layers import L2_normalize if backend == "CPU": diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 7f341a03f7..a7446ca738 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -17,8 +17,10 @@ from lib.serializer import get_serializer_from_filename from lib.faces_detect import DetectedFace from lib.image import ImagesLoader, read_image +from lib.utils import get_backend from lib.vgg_face2_keras import VGGFace2 as VGGFace from plugins.extract.pipeline import Extractor, ExtractMedia +from plugins.extract._config import Config logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -56,7 +58,13 @@ def process(self): # Load VGG Face if sorting by face if self.args.sort_method.lower() == "face": - self.vgg_face = VGGFace(backend=self.args.backend, loglevel=self.args.loglevel) + conf = Config("global", configfile=self.args.configfile) + allow_growth = (conf.config_dict["allow_growth"] and + self.args.backend.lower() == "gpu" and + get_backend() == "nvidia") + self.vgg_face = VGGFace(backend=self.args.backend, + allow_growth=allow_growth, + loglevel=self.args.loglevel) # If logging is enabled, prepare container if self.args.log_changes: From 59ade741a6345b0ced47605f5a4466091acbb998 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 4 Jul 2020 17:11:15 +0000 Subject: [PATCH 249/981] lib.alignments - Pad pts timestamps when video does not start on a keyframe --- lib/alignments.py | 48 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/lib/alignments.py b/lib/alignments.py index 0903cdcd43..41e63409f9 100644 --- a/lib/alignments.py +++ b/lib/alignments.py @@ -236,10 +236,14 @@ def save_video_meta_data(self, pts_time, keyframes): keyframes: list A list of frame indices corresponding to the key frames in the input video """ + if pts_time[0] != 0: + pts_time, keyframes = self._pad_leading_frames(pts_time, keyframes) + sample_filename = next(fname for fname in self.data) basename = sample_filename[:sample_filename.rfind("_")] logger.debug("sample filename: %s, base filename: %s", sample_filename, basename) logger.info("Saving video meta information to Alignments file") + for idx, pts in enumerate(pts_time): meta = dict(pts_time=pts, keyframe=idx in keyframes) key = "{}_{:06d}.png".format(basename, idx + 1) @@ -247,6 +251,7 @@ def save_video_meta_data(self, pts_time, keyframes): self.data[key] = dict(video_meta=meta, faces=[]) else: self.data[key]["video_meta"] = meta + logger.debug("Alignments count: %s, timestamp count: %s", len(self.data), len(pts_time)) if len(self.data) != len(pts_time): raise FaceswapError( @@ -255,8 +260,6 @@ def save_video_meta_data(self, pts_time, keyframes): "\nThis can be caused by a number of issues:" "\n - The video has a Variable Frame Rate and FFMPEG is having a hard time " "calculating the correct number of frames." - "\n - The video was not cut on a key frame and FFMPEG has dummied in some extra " - "frames to fill the gap." "\n - You are working with a Merged Alignments file. This is not supported for " "your current use case." "\nYou should either extract the video to individual frames, re-encode the " @@ -264,6 +267,45 @@ def save_video_meta_data(self, pts_time, keyframes): "alignments file for your requested video.".format(len(pts_time), len(self.data))) self.save() + @classmethod + def _pad_leading_frames(cls, pts_time, keyframes): + """ Calculate the number of frames to pad the video by when the first frame is not + a key frame. + + A somewhat crude method by obtaining the gaps between existing frames and calculating + how many frames should be inserted at the beginning based on the first presentation + timestamp. + + Parameters + ---------- + pts_time: list + A list of presentation timestamps (`float`) in frame index order for every frame in + the input video + + Returns + ------- + tuple + The presentation time stamps with extra frames padded to the beginning and the + keyframes adjusted to include the new frames + """ + start_pts = pts_time[0] + logger.debug("Video not cut on keyframe. Start pts: %s", start_pts) + gaps = [] + prev_time = None + for item in pts_time: + if prev_time is not None: + gaps.append(item - prev_time) + prev_time = item + data_points = len(gaps) + avg_gap = sum(gaps) / data_points + frame_count = int(round(start_pts / avg_gap)) + pad_pts = [avg_gap * i for i in range(frame_count)] + logger.debug("data_points: %s, avg_gap: %s, frame_count: %s, pad_pts: %s", + data_points, avg_gap, frame_count, pad_pts) + pts_time = pad_pts + pts_time + keyframes = [i + frame_count for i in keyframes] + return pts_time, keyframes + # << VALIDATION >> # def frame_exists(self, frame_name): @@ -677,7 +719,7 @@ def __init__(self, alignments): logger.debug("Initialized %s", self.__class__.__name__) @property - def has_thumbails(self): + def has_thumbnails(self): """ bool: ``True`` if all faces in the alignments file contain thumbnail images otherwise ``False``. """ retval = all("thumb" in face From f634f52a1e59fee8a980f23b59b95465f258bea6 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 6 Jul 2020 11:00:17 +0000 Subject: [PATCH 250/981] lib.gui.utils - Spelling fixes lib.gui.custom_widgets - popup progressbar --- lib/gui/custom_widgets.py | 117 ++++++++++++++++++++++++++++++++++++++ lib/gui/project.py | 4 +- lib/gui/utils.py | 28 +++++---- 3 files changed, 136 insertions(+), 13 deletions(-) diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index 8228bb25bd..5d20e9a945 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -8,6 +8,8 @@ import tkinter as tk from tkinter import ttk, TclError +import numpy as np + from .utils import get_config logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -760,3 +762,118 @@ def _on_master_update(self, *args): # pylint: disable=unused-argument state = self._value in self._master_list logger.trace("Setting '%s' to %s", self._value, state) self._tk_var.set(state) + + +class PopupProgress(tk.Toplevel): + """ A simple pop up progress bar that appears of the center of the root window. + + When this is called, the root will be disabled until the :func:`close` method is called. + + Parameters + ---------- + title: str + The title to appear above the progress bar + total: int or float + The total count of items for the progress bar + + Example + ------- + >>> total = 100 + >>> progress = PopupProgress("My title...", total) + >>> for i in range(total): + >>> progress.update(1) + >>> progress.close() + """ + def __init__(self, title, total): + super().__init__() + self._total = total + if platform.system() == "Darwin": # For Mac OS + self.tk.call("::tk::unsupported::MacWindowStyle", + "style", self._w, # pylint:disable=protected-access + "help", "none") + # Leaves only the label and removes the app window + self.wm_overrideredirect(True) + self.transient() + + self._lbl_title = self._set_title(title) + self._progress_bar = self._get_progress_bar() + + offset = np.array((self.master.winfo_rootx(), self.master.winfo_rooty())) + # TODO find way to get dimensions of the pop up without it flicking onto the screen + self.update_idletasks() + center = np.array(( + (self.master.winfo_width() // 2) - (self.winfo_width() // 2), + (self.master.winfo_height() // 2) - (self.winfo_height() // 2))) + offset + self.wm_geometry("+{}+{}".format(*center)) + get_config().set_cursor_busy() + self.grab_set() + + @property + def progress_bar(self): + """ :class:`tkinter.ttk.Progressbar`: The progress bar object within the pop up window. """ + return self._progress_bar + + def _set_title(self, title): + """ Set the initial title of the pop up progress bar. + + Parameters + ---------- + title: str + The title to appear above the progress bar + + Returns + ------- + :class:`tkinter.ttk.Label` + The heading label for the progress bar + """ + frame = ttk.Frame(self) + frame.pack(side=tk.TOP, padx=5, pady=5) + lbl = ttk.Label(frame, text=title) + lbl.pack(side=tk.TOP, pady=(5, 0), expand=True, fill=tk.X) + return lbl + + def _get_progress_bar(self): + """ Set up the progress bar with the supplied total. + + Returns + ------- + :class:`tkinter.ttk.Progressbar` + The configured progress bar for the pop up window + """ + frame = ttk.Frame(self) + frame.pack(side=tk.BOTTOM, padx=5, pady=(0, 5)) + pbar = ttk.Progressbar(frame, + length=400, + maximum=self._total, + mode="determinate") + pbar.pack(side=tk.LEFT) + return pbar + + def step(self, amount): + """ Increment the progress bar. + + Parameters + ---------- + amount: int or float + The amount to increment the progress bar by + """ + self._progress_bar.step(amount) + self._progress_bar.update_idletasks() + + def stop(self): + """ Stop the progress bar, re-enable the root window and destroy the pop up window. """ + self._progress_bar.stop() + get_config().set_cursor_default() + self.grab_release() + self.destroy() + + def update_title(self, title): + """ Update the title that displays above the progress bar. + + Parameters + ---------- + title: str + The title to appear above the progress bar + """ + self._lbl_title.config(text=title) + self._lbl_title.update_idletasks() diff --git a/lib/gui/project.py b/lib/gui/project.py index c0bdb02816..20ba01c2f8 100644 --- a/lib/gui/project.py +++ b/lib/gui/project.py @@ -385,7 +385,7 @@ def _save_as_to_filename(self, session_type): cfgfile = self._file_handler("save", "config_{}".format(session_type), title=title, - initialdir=self._dirname).retfile + initial_folder=self._dirname).retfile if not cfgfile: logger.debug("No filename provided. session_type: '%s'", session_type) return False @@ -851,7 +851,7 @@ def new(self, *args): # pylint:disable=unused-argument cfgfile = self._file_handler("save", "config_project", title="New Project...", - initialdir=self._basename).retfile + initial_folder=self._basename).retfile if not cfgfile: logger.debug("No filename selected") return diff --git a/lib/gui/utils.py b/lib/gui/utils.py index e9dad561ee..31adfadeae 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -86,20 +86,21 @@ class FileHandler(): # pylint:disable=too-few-public-methods Parameters ---------- - handletype: ['open', 'save', 'filename', 'filename_multi', 'savefilename', 'context'] + handle_type: ['open', 'save', 'filename', 'filename_multi', 'savefilename', 'context', `dir`] The type of file dialog to return. `open` and `save` will perform the open and save actions and return the file. `filename` returns the filename from an `open` dialog. `filename_multi` allows for multi-selection of files and returns a list of files selected. `savefilename` returns the filename from a `save as` dialog. `context` is a context - sensitive parameter that returns a certain dialog based on the current options - filetype: ['default', 'alignments', 'config_project', 'config_task', 'config_all', 'csv', \ + sensitive parameter that returns a certain dialog based on the current options. `dir` asks + for a folder location. + file_type: ['default', 'alignments', 'config_project', 'config_task', 'config_all', 'csv', \ 'image', 'ini', 'state', 'log', 'video'] The type of file that this dialog is for. `default` allows selection of any files. Other options limit the file type selection title: str, optional The title to display on the file dialog. If `None` then the default title will be used. Default: ``None`` - initialdir: str, optional + initial_folder: str, optional The folder to initially open with the file dialog. If `None` then tkinter will decide. Default: ``None`` command: str, optional @@ -123,15 +124,20 @@ class FileHandler(): # pylint:disable=too-few-public-methods '/path/to/selected/video.mp4' """ - def __init__(self, handletype, filetype, title=None, initialdir=None, command=None, + def __init__(self, handle_type, file_type, title=None, initial_folder=None, command=None, action=None, variable=None): - logger.debug("Initializing %s: (Handletype: '%s', filetype: '%s', title: '%s', " - "initialdir: '%s, 'command: '%s', action: '%s', variable: %s)", - self.__class__.__name__, handletype, filetype, title, initialdir, command, - action, variable) - self._handletype = handletype + logger.debug("Initializing %s: (handle_type: '%s', file_type: '%s', title: '%s', " + "initial_folder: '%s, 'command: '%s', action: '%s', variable: %s)", + self.__class__.__name__, handle_type, file_type, title, initial_folder, + command, action, variable) + self._handletype = handle_type self._defaults = self._set_defaults() - self._kwargs = self._set_kwargs(title, initialdir, filetype, command, action, variable) + self._kwargs = self._set_kwargs(title, + initial_folder, + file_type, + command, + action, + variable) self.retfile = getattr(self, "_{}".format(self._handletype.lower()))() logger.debug("Initialized %s", self.__class__.__name__) From 5f9d8fa0f8bb58a615ab20ff23facc457b50253e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 6 Jul 2020 16:01:59 +0000 Subject: [PATCH 251/981] lib.gui.custom_widgets - Force popup progress bar to top --- lib/gui/custom_widgets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index 5d20e9a945..cf8b276700 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -793,6 +793,7 @@ def __init__(self, title, total): "help", "none") # Leaves only the label and removes the app window self.wm_overrideredirect(True) + self.attributes('-topmost', 'true') self.transient() self._lbl_title = self._set_title(title) From ab21033965b1ce50ed0c0201c8fba54b5ef24c65 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 8 Jul 2020 18:31:07 +0100 Subject: [PATCH 252/981] argparse bugfix --- faceswap.py | 3 ++- tools.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/faceswap.py b/faceswap.py index c3d825d35d..9f60adce78 100755 --- a/faceswap.py +++ b/faceswap.py @@ -14,8 +14,9 @@ _PARSER = args.FullHelpArgumentParser() -def _bad_args(): +def _bad_args(*args): # pylint:disable=unused-argument """ Print help to console when bad arguments are provided. """ + print(args) _PARSER.print_help() sys.exit(0) diff --git a/tools.py b/tools.py index d842885a4a..835d85ecd0 100755 --- a/tools.py +++ b/tools.py @@ -15,7 +15,7 @@ raise Exception("This program requires at least python3.2") -def bad_args(args): # pylint:disable=unused-argument +def bad_args(*args): # pylint:disable=unused-argument """ Print help on bad arguments """ PARSER.print_help() sys.exit(0) From 03d2d179a9816ec9c0f2d9ac8eeea2d0be6fe05b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 9 Jul 2020 11:41:03 +0000 Subject: [PATCH 253/981] scripts.extract - Save jpg thumbnails to alignments file --- lib/image.py | 26 ++++++++++++++++++++++++++ scripts/extract.py | 7 ++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/lib/image.py b/lib/image.py index 0393c1c6f4..8d25b8334c 100644 --- a/lib/image.py +++ b/lib/image.py @@ -427,6 +427,32 @@ def encode_image_with_hash(image, extension): return image_hash, encoded_image +def generate_thumbnail(image, size=80, quality=60): + """ Generate a jpg thumbnail for the given image. + + Parameters + ---------- + image: :class:`numpy.ndarray` + Three channel BGR image to convert to a jpg thumbnail + size: int + The width and height, in pixels, that the thumbnail should be generated at + quality: int + The jpg quality setting to use + + Returns + :class:`numpy.ndarray` + The given image encoded to a jpg at the given size and quality settings + """ + logger.trace("Input shape: %s, size: %s, quality: %s", image.shape, size, quality) + orig_size = image.shape[0] + if orig_size != size: + interp = cv2.INTER_AREA if orig_size > size else cv2.INTER_CUBIC + image = cv2.resize(image, (size, size), interpolation=interp) + retval = cv2.imencode(".jpg", image, [cv2.IMWRITE_JPEG_QUALITY, quality])[1] + logger.trace("Output shape: %s", retval.shape) + return retval + + def batch_convert_color(batch, colorspace): """ Convert a batch of images from one color space to another. diff --git a/scripts/extract.py b/scripts/extract.py index 40880f31fb..69b96fef34 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -7,7 +7,7 @@ from tqdm import tqdm -from lib.image import encode_image_with_hash, ImagesLoader, ImagesSaver +from lib.image import encode_image_with_hash, generate_thumbnail, ImagesLoader, ImagesSaver from lib.multithreading import MultiThread from lib.utils import get_folder from plugins.extract.pipeline import Extractor, ExtractMedia @@ -236,7 +236,8 @@ def _check_thread_error(self): def _output_processing(self, extract_media, size): """ Prepare faces for output - Loads the aligned face, perform any processing actions and verify the output. + Loads the aligned face, generate the thumbnail, perform any processing actions and verify + the output. Parameters ---------- @@ -247,7 +248,7 @@ def _output_processing(self, extract_media, size): """ for face in extract_media.detected_faces: face.load_aligned(extract_media.image, size=size) - + face.thumbnail = generate_thumbnail(face.aligned_face, size=80, quality=60) self._post_process.do_actions(extract_media) extract_media.remove_image() From 8a19d21acead9d326652cdacadeac6aeadec0c31 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 11 Jul 2020 17:54:48 +0100 Subject: [PATCH 254/981] setup.py - Fix conda-forge imports Remove unused icons --- lib/gui/.cache/icons/point.png | Bin 3880 -> 0 bytes lib/gui/.cache/icons/selection.png | Bin 3218 -> 0 bytes setup.py | 9 +++++---- 3 files changed, 5 insertions(+), 4 deletions(-) delete mode 100755 lib/gui/.cache/icons/point.png delete mode 100755 lib/gui/.cache/icons/selection.png diff --git a/lib/gui/.cache/icons/point.png b/lib/gui/.cache/icons/point.png deleted file mode 100755 index 471953dc1165f4592476c3f4c4efa1ea2d5e8210..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3880 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 diff --git a/lib/gui/.cache/icons/selection.png b/lib/gui/.cache/icons/selection.png deleted file mode 100755 index 877513c8adef81fe75e901e6350ed049eff7ae8e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3218 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 Date: Mon, 13 Jul 2020 16:46:53 +0100 Subject: [PATCH 255/981] requirements.txt - typofix --- _requirements_base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_requirements_base.txt b/_requirements_base.txt index 2621c15980..abe6086630 100644 --- a/_requirements_base.txt +++ b/_requirements_base.txt @@ -4,7 +4,7 @@ pathlib==1.0.1 numpy>=1.18.0 opencv-python>=4.1.2.0 scikit-image>=0.16.2 -Pillow>=7.0.0 +pillow>=7.0.0 scikit-learn>=0.22.0 toposort==1.5 fastcluster==1.1.26 From 339facf997dfed752e4fb1dca9cf65e3d3f5b822 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 23 Jul 2020 19:12:52 +0100 Subject: [PATCH 256/981] Remove debug code --- lib/gui/stats.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/gui/stats.py b/lib/gui/stats.py index a9619ff52f..307ba485bb 100644 --- a/lib/gui/stats.py +++ b/lib/gui/stats.py @@ -450,9 +450,6 @@ def calc_rate_total(self): batchsize = batchsizes[sess_id] timestamps = total_timestamps[sess_id] iterations = range(len(timestamps) - 1) - print("===========\n") - print(timestamps[:100]) - print([batchsize / (timestamps[i + 1] - timestamps[i]) for i in iterations][:100]) rate.extend([batchsize / (timestamps[i + 1] - timestamps[i]) for i in iterations]) logger.debug("Calculated totals rate: Item_count: %s", len(rate)) return rate From 0e63c2967b451ad4491152ee3ac4458cc43d59ab Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 25 Jul 2020 09:09:38 +0100 Subject: [PATCH 257/981] documentation update --- docs/full/lib/image.rst | 3 +++ lib/image.py | 1 + 2 files changed, 4 insertions(+) diff --git a/docs/full/lib/image.rst b/docs/full/lib/image.rst index 2f94c773f6..089d339cc1 100755 --- a/docs/full/lib/image.rst +++ b/docs/full/lib/image.rst @@ -18,10 +18,13 @@ Handles loading and manipulation of images in Faceswap. ~lib.image.batch_convert_color ~lib.image.count_frames ~lib.image.encode_image_with_hash + ~lib.image.generate_thumbnail + ~lib.image.hex_to_rgb ~lib.image.read_image ~lib.image.read_image_batch ~lib.image.read_image_hash ~lib.image.read_image_hash_batch + ~lib.image.rgb_to_hex .. rubric:: Module diff --git a/lib/image.py b/lib/image.py index 8d25b8334c..58ed40c6ce 100644 --- a/lib/image.py +++ b/lib/image.py @@ -440,6 +440,7 @@ def generate_thumbnail(image, size=80, quality=60): The jpg quality setting to use Returns + ------- :class:`numpy.ndarray` The given image encoded to a jpg at the given size and quality settings """ From 3fd26b51a6745e5081f91e21e436b32bb255ccf6 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 25 Jul 2020 11:05:29 +0100 Subject: [PATCH 258/981] Manual Tool (#1038) Initial Commit --- .gitignore | 2 + docs/full/modules.rst | 2 +- docs/full/tools/manual.faceviewer.rst | 50 + docs/full/tools/manual.frameviewer.rst | 109 ++ docs/full/tools/manual.rst | 57 + docs/full/{ => tools}/tools.rst | 18 +- tools/alignments/alignments.py | 39 +- tools/manual/__init__.py | 0 tools/manual/cli.py | 56 + tools/manual/detected_faces.py | 1040 +++++++++++++++++ tools/manual/faceviewer/__init__.py | 0 tools/manual/faceviewer/frame.py | 742 ++++++++++++ tools/manual/faceviewer/viewport.py | 1011 ++++++++++++++++ tools/manual/frameviewer/__init__.py | 0 tools/manual/frameviewer/control.py | 289 +++++ tools/manual/frameviewer/editor/__init__.py | 8 + tools/manual/frameviewer/editor/_base.py | 623 ++++++++++ .../manual/frameviewer/editor/bounding_box.py | 403 +++++++ .../manual/frameviewer/editor/extract_box.py | 401 +++++++ tools/manual/frameviewer/editor/landmarks.py | 457 ++++++++ tools/manual/frameviewer/editor/mask.py | 544 +++++++++ tools/manual/frameviewer/frame.py | 741 ++++++++++++ tools/manual/manual.py | 845 ++++++++++++++ 23 files changed, 7426 insertions(+), 11 deletions(-) create mode 100644 docs/full/tools/manual.faceviewer.rst create mode 100644 docs/full/tools/manual.frameviewer.rst create mode 100644 docs/full/tools/manual.rst rename docs/full/{ => tools}/tools.rst (79%) create mode 100644 tools/manual/__init__.py create mode 100644 tools/manual/cli.py create mode 100644 tools/manual/detected_faces.py create mode 100644 tools/manual/faceviewer/__init__.py create mode 100644 tools/manual/faceviewer/frame.py create mode 100644 tools/manual/faceviewer/viewport.py create mode 100644 tools/manual/frameviewer/__init__.py create mode 100644 tools/manual/frameviewer/control.py create mode 100644 tools/manual/frameviewer/editor/__init__.py create mode 100644 tools/manual/frameviewer/editor/_base.py create mode 100644 tools/manual/frameviewer/editor/bounding_box.py create mode 100644 tools/manual/frameviewer/editor/extract_box.py create mode 100644 tools/manual/frameviewer/editor/landmarks.py create mode 100644 tools/manual/frameviewer/editor/mask.py create mode 100644 tools/manual/frameviewer/frame.py create mode 100644 tools/manual/manual.py diff --git a/.gitignore b/.gitignore index 8eaaea6bec..40e94ba27d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,8 @@ !tests/*/* !tools !tools/* +!tools/*/* +!tools/*/*/* !_travis !_travis/* !.travis.yml diff --git a/docs/full/modules.rst b/docs/full/modules.rst index d5eefe9039..8dd0581022 100644 --- a/docs/full/modules.rst +++ b/docs/full/modules.rst @@ -7,4 +7,4 @@ faceswap lib/lib plugins/plugins scripts - tools + tools/tools diff --git a/docs/full/tools/manual.faceviewer.rst b/docs/full/tools/manual.faceviewer.rst new file mode 100644 index 0000000000..ed3209f4f1 --- /dev/null +++ b/docs/full/tools/manual.faceviewer.rst @@ -0,0 +1,50 @@ +****************** +faceviewer package +****************** + +Handles the display of faces in the Face Viewer section of Faceswap's Manual Tool. + +.. contents:: Contents + :local: + +frame module +============ + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~tools.manual.faceviewer.frame.ContextMenu + ~tools.manual.faceviewer.frame.FacesActionsFrame + ~tools.manual.faceviewer.frame.FacesFrame + ~tools.manual.faceviewer.frame.FacesViewer + ~tools.manual.faceviewer.frame.Grid + +.. rubric:: Module + +.. automodule:: tools.manual.faceviewer.frame + :members: + :undoc-members: + :show-inheritance: + +viewport module +=============== + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~tools.manual.faceviewer.viewport.ActiveFrame + ~tools.manual.faceviewer.viewport.HoverBox + ~tools.manual.faceviewer.viewport.TKFace + ~tools.manual.faceviewer.viewport.Viewport + ~tools.manual.faceviewer.viewport.VisibleObjects + +.. rubric:: Module + +.. automodule:: tools.manual.faceviewer.viewport + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/full/tools/manual.frameviewer.rst b/docs/full/tools/manual.frameviewer.rst new file mode 100644 index 0000000000..ee6f084afe --- /dev/null +++ b/docs/full/tools/manual.frameviewer.rst @@ -0,0 +1,109 @@ +****************** +frameviewer module +****************** + +Handles the display of frames in the Frame Viewer section of Faceswap's Manual Tool. + +.. contents:: Contents + :local: + +frame module +============ + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~tools.manual.frameviewer.frame.ActionsFrame + ~tools.manual.frameviewer.frame.BackgroundImage + ~tools.manual.frameviewer.frame.DisplayFrame + ~tools.manual.frameviewer.frame.FrameViewer + ~tools.manual.frameviewer.frame.Navigation + +.. rubric:: Module + +.. automodule:: tools.manual.frameviewer.frame + :members: + :undoc-members: + :show-inheritance: + +control module +============== + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~tools.manual.frameviewer.control.BackgroundImage + ~tools.manual.frameviewer.control.Navigation + +.. rubric:: Module + +.. automodule:: tools.manual.frameviewer.control + :members: + :undoc-members: + :show-inheritance: + +editor package +============== +.. contents:: Contents + :local: + +_base module +------------ + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~tools.manual.frameviewer.editor._base.Editor + ~tools.manual.frameviewer.editor._base.View + +.. rubric:: Module + +.. automodule:: tools.manual.frameviewer.editor._base + :members: + :undoc-members: + :show-inheritance: + +bounding_box module +------------------- +.. automodule:: tools.manual.frameviewer.editor.bounding_box + :members: + :undoc-members: + :show-inheritance: + +extract_box module +------------------ +.. automodule:: tools.manual.frameviewer.editor.extract_box + :members: + :undoc-members: + :show-inheritance: + +landmarks module +---------------- + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~tools.manual.frameviewer.editor.landmarks.Landmarks + ~tools.manual.frameviewer.editor.landmarks.Mesh + +.. rubric:: Module + +.. automodule:: tools.manual.frameviewer.editor.landmarks + :members: + :undoc-members: + :show-inheritance: + +mask module +----------- +.. automodule:: tools.manual.frameviewer.editor.mask + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/full/tools/manual.rst b/docs/full/tools/manual.rst new file mode 100644 index 0000000000..f2428395c3 --- /dev/null +++ b/docs/full/tools/manual.rst @@ -0,0 +1,57 @@ +************** +manual package +************** + +.. contents:: Contents + :local: + +Subpackages +=========== +The following subpackages handle the main two display areas of the Manual Tool's GUI. + +.. toctree:: + :maxdepth: 4 + + manual.faceviewer + manual.frameviewer + +manual module +============= +The Manual Module is the main entry point into the Manual Editor Tool. + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~tools.manual.manual.Aligner + ~tools.manual.manual.FrameLoader + ~tools.manual.manual.Manual + ~tools.manual.manual.TkGlobals + +.. rubric:: Module + +.. automodule:: tools.manual.manual + :members: + :undoc-members: + :show-inheritance: + +detected_faces module +===================== + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~tools.manual.detected_faces.DetectedFaces + ~tools.manual.detected_faces.FaceUpdate + ~tools.manual.detected_faces.Filter + ~tools.manual.detected_faces.ThumbsCreator + +.. rubric:: Module + +.. automodule:: tools.manual.detected_faces + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/tools.rst b/docs/full/tools/tools.rst similarity index 79% rename from docs/full/tools.rst rename to docs/full/tools/tools.rst index 9c8928437a..07468d3cea 100644 --- a/docs/full/tools.rst +++ b/docs/full/tools/tools.rst @@ -7,6 +7,21 @@ The Tools Package provides various tools for working with Faceswap outside of th .. contents:: Contents :local: +Subpackages +=========== + +.. toctree:: + :maxdepth: 1 + + manual + +alignments module +================= +.. automodule:: tools.alignments.alignments + :members: + :undoc-members: + :show-inheritance: + mask module =========== @@ -16,11 +31,10 @@ mask module :show-inheritance: preview module -============== +=============== .. rubric:: Module Summary - .. autosummary:: :nosignatures: diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index fb103c335d..8fd277df73 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -11,16 +11,33 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name -class Alignments(): - """ Perform tasks relating to alignments file """ +class Alignments(): # pylint:disable=too-few-public-methods + """ The main entry point for Faceswap's Alignments Tool. This tool is part of the Faceswap + Tools suite and should be called from the ``python tools.py alignments`` command. + + The tool allows for manipulation, and working with Faceswap alignments files. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + """ def __init__(self, arguments): logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) self.args = arguments - self.alignments = self.load_alignments() + self.alignments = self._load_alignments() logger.debug("Initialized %s", self.__class__.__name__) - def load_alignments(self): - """ Loading alignments """ + def _load_alignments(self): + """ Loads the given alignments file(s) prior to running the selected job. + + Returns + ------- + :class:`~tools.alignments.media.AlignmentData` or list + The alignments data formatted for use by the alignments tool. If multiple alignments + files have been selected, then this will be a list of + :class:`~tools.alignments.media.AlignmentData` objects + """ logger.debug("Loading alignments") if len(self.args.alignments_file) > 1 and self.args.job != "merge": logger.error("Multiple alignments files are only permitted for merging") @@ -37,13 +54,19 @@ def load_alignments(self): return retval def process(self): - """ Main processing function of the Align tool """ + """ The entry point for the Alignments tool from :mod:`lib.tools.alignments.cli`. + + Launches the selected alignments job. + """ + if self.args.job == "manual": + logger.warning("The 'manual' job is deprecated and will be removed from a future " + "update. Please use the new 'manual' tool.") if self.args.job == "update-hashes": job = UpdateHashes elif self.args.job.startswith("remove-"): job = RemoveAlignments - elif self.args.job in("missing-alignments", "missing-frames", - "multi-faces", "leftover-faces", "no-faces"): + elif self.args.job in ("missing-alignments", "missing-frames", + "multi-faces", "leftover-faces", "no-faces"): job = Check else: job = globals()[self.args.job.title()] diff --git a/tools/manual/__init__.py b/tools/manual/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/manual/cli.py b/tools/manual/cli.py new file mode 100644 index 0000000000..db9278eb1a --- /dev/null +++ b/tools/manual/cli.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +""" The Command Line Arguments for the Manual Editor tool. """ +from lib.cli.args import FaceSwapArgs, DirOrFileFullPaths, FileFullPaths + +_HELPTEXT = ("This command lets you perform various actions on frames, " + "faces and alignments files using visual tools.") + + +class ManualArgs(FaceSwapArgs): + """ Generate the command line options for the Manual Editor Tool.""" + + @staticmethod + def get_info(): + """ Obtain the information about what the Manual Tool does. """ + return ("A tool to perform various actions on frames, faces and alignments files using " + "visual tools") + + @staticmethod + def get_argument_list(): + """ Generate the command line argument list for the Manual Tool. """ + argument_list = list() + argument_list.append(dict( + opts=("-al", "--alignments"), + action=FileFullPaths, + filetypes="alignments", + type=str, + group="data", + dest="alignments_path", + help="Path to the alignments file for the input, if not at the default location")) + argument_list.append(dict( + opts=("-fr", "--frames"), + action=DirOrFileFullPaths, + filetypes="video", + required=True, + group="data", + help="Video file or directory containing source frames that faces were extracted " + "from.")) + argument_list.append(dict( + opts=("-t", "--thumb-regen"), + action="store_true", + dest="thumb_regen", + default=False, + group="options", + help="Force regeneration of the low resolution jpg thumbnails in the alignments " + "file.")) + argument_list.append(dict( + opts=("-s", "--single-process"), + action="store_true", + dest="single_process", + default=False, + group="options", + help="The process attempts to speed up generation of thumbnails by extracting from " + "the video in parallel threads. For some videos, this causes the caching " + "process to hang. If this happens, then set this option to generate the " + "thumbnails in a slower, but more stable single thread.")) + return argument_list diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py new file mode 100644 index 0000000000..27ddbd31ea --- /dev/null +++ b/tools/manual/detected_faces.py @@ -0,0 +1,1040 @@ +#!/usr/bin/env python3 +""" Alignments handling for Faceswap's Manual Adjustments tool. Handles the conversion of +alignments data to :class:`~lib.faces_detect.DetectedFace` objects, and the update of these faces +when edits are made in the GUI. """ + +import logging +import os +import tkinter as tk +from copy import deepcopy +from queue import Queue, Empty +from time import sleep +from threading import Lock + +import cv2 +import imageio +import numpy as np +from tqdm import tqdm + +from lib.aligner import Extract as AlignerExtract +from lib.alignments import Alignments +from lib.faces_detect import DetectedFace +from lib.gui.custom_widgets import PopupProgress +from lib.gui.utils import FileHandler +from lib.image import SingleFrameLoader, ImagesLoader, ImagesSaver, encode_image_with_hash +from lib.multithreading import MultiThread +from lib.utils import get_folder + + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class DetectedFaces(): + """ Handles the manipulation of :class:`~lib.faces_detect.DetectedFace` objects stored + in the alignments file. Acts as a parent class for the IO operations (saving and loading from + an alignments file), the face update operations (when changes are made to alignments in the + GUI) and the face filters (when a user changes the filter navigation mode.) + + Parameters + ---------- + tk_globals: :class:`~tools.manual.manual.TkGlobals` + The tkinter variables that apply to the whole of the GUI + alignments_path: str + The full path to the alignments file + input_location: str + The location of the input folder of frames or video file + extractor: :class:`~tools.manual.manual.Aligner` + The pipeline for passing faces through the aligner and retrieving results + """ + def __init__(self, tk_globals, alignments_path, input_location, extractor): + logger.debug("Initializing %s: (tk_globals: %s. alignments_path: %s, input_location: %s " + "extractor: %s)", self.__class__.__name__, tk_globals, alignments_path, + input_location, extractor) + self._globals = tk_globals + self._frame_faces = [] + self._updated_frame_indices = set() + + self._alignments = self._get_alignments(alignments_path, input_location) + self._extractor = extractor + self._tk_vars = self._set_tk_vars() + self._children = dict(io=_DiskIO(self, input_location), + update=FaceUpdate(self), + filter=Filter(self)) + logger.debug("Initialized %s", self.__class__.__name__) + + # <<<< PUBLIC PROPERTIES >>>> # + # << SUBCLASSES >> # + @property + def extractor(self): + """ :class:`~tools.manual.manual.Aligner`: The pipeline for passing faces through the + aligner and retrieving results. """ + return self._extractor + + @property + def filter(self): + """ :class:`Filter`: Handles returning of faces and stats based on the current user set + navigation mode filter. """ + return self._children["filter"] + + @property + def update(self): + """ :class:`FaceUpdate`: Handles the adding, removing and updating of + :class:`~lib.faces_detect.DetectedFace` stored within the alignments file. """ + return self._children["update"] + + # << TKINTER VARIABLES >> # + @property + def tk_unsaved(self): + """ :class:`tkinter.BooleanVar`: The variable indicating whether the alignments have been + updated since the last save. """ + return self._tk_vars["unsaved"] + + @property + def tk_edited(self): + """ :class:`tkinter.BooleanVar`: The variable indicating whether an edit has occurred + meaning a GUI redraw needs to be triggered. """ + return self._tk_vars["edited"] + + @property + def tk_face_count_changed(self): + """ :class:`tkinter.BooleanVar`: The variable indicating whether a face has been added or + removed meaning the :class:`FaceViewer` grid redraw needs to be triggered. """ + return self._tk_vars["face_count_changed"] + + # << STATISTICS >> # + @property + def available_masks(self): + """ dict: The mask type names stored in the alignments; type as key with the number + of faces which possess the mask type as value. """ + return self._alignments.mask_summary + + @property + def current_faces(self): + """ list: The most up to date full list of :class:`~lib.faces_detect.DetectedFace` + objects. """ + return self._frame_faces + + @property + def video_meta_data(self): + """ dict: The frame meta data stored in the alignments file. If data does not exist in the + alignments file then ``None`` is returned for each Key """ + return self._alignments.video_meta_data + + @property + def face_count_per_index(self): + """ list: Count of faces for each frame. List is in frame index order. + + The list needs to be calculated on the fly as the number of faces in a frame + can change based on user actions. """ + return [len(faces) for faces in self._frame_faces] + + # <<<< PUBLIC METHODS >>>> # + def is_frame_updated(self, frame_index): + """ bool: ``True`` if the given frame index has updated faces within it otherwise + ``False`` """ + return frame_index in self._updated_frame_indices + + def load_faces(self): + """ Load the faces as :class:`~lib.faces_detect.DetectedFace` objects from the alignments + file. """ + self._children["io"].load() + + def save(self): + """ Save the alignments file with the latest edits. """ + self._children["io"].save() + + def revert_to_saved(self, frame_index): + """ Revert the frame's alignments to their saved version for the given frame index. + + Parameters + ---------- + frame_index: int + The frame that should have their faces reverted to their saved version + """ + self._children["io"].revert_to_saved(frame_index) + + def extract(self): + """ Extract the faces in the current video to a user supplied folder. """ + self._children["io"].extract() + + def save_video_meta_data(self, pts_time, keyframes): + """ Save video meta data to the alignments file. This is executed if the video meta data + does not already exist in the alignments file, so the video does not need to be scanned + on every use of the Manual Tool. + + Parameters + ---------- + pts_time: list + A list of presentation timestamps (`float`) in frame index order for every frame in + the input video + keyframes: list + A list of frame indices corresponding to the key frames in the input video. + """ + if self._globals.is_video: + self._alignments.save_video_meta_data(pts_time, keyframes) + + # <<<< PRIVATE METHODS >>> # + # << INIT >> # + @staticmethod + def _set_tk_vars(): + """ Set the required tkinter variables. + + The alignments specific `unsaved` and `edited` are set here. + The global variables are added into the dictionary with `None` as value, so the + objects exist. Their actual variables are populated during :func:`load_faces`. + + Returns + ------- + dict + The internal variable name as key with the tkinter variable as value + """ + retval = dict() + for name in ("unsaved", "edited", "face_count_changed"): + var = tk.BooleanVar() + var.set(False) + retval[name] = var + logger.debug(retval) + return retval + + def _get_alignments(self, alignments_path, input_location): + """ Get the :class:`~lib.alignments.Alignments` object for the given location. + + Parameters + ---------- + alignments_path: str + Full path to the alignments file. If empty string is passed then location is calculated + from the source folder + input_location: str + The location of the input folder of frames or video file + + Returns + ------- + :class:`~lib.alignments.Alignments` + The alignments object for the given input location + """ + logger.debug("alignments_path: %s, input_location: %s", alignments_path, input_location) + if alignments_path: + folder, filename = os.path.split(alignments_path) + else: + filename = "alignments.fsa" + if self._globals.is_video: + folder, vid = os.path.split(os.path.splitext(input_location)[0]) + filename = "{}_{}".format(vid, filename) + else: + folder = input_location + retval = Alignments(folder, filename) + logger.debug("folder: %s, filename: %s, alignments: %s", folder, filename, retval) + return retval + + +class _DiskIO(): # pylint:disable=too-few-public-methods + """ Handles the loading of :class:`~lib.faces_detect.DetectedFaces` from the alignments file + into :class:`DetectedFaces` and the saving of this data (in the opposite direction) to an + alignments file. + + Parameters + ---------- + detected_faces: :class:`DetectedFaces` + The parent :class:`DetectedFaces` object + input_location: str + The location of the input folder of frames or video file + """ + def __init__(self, detected_faces, input_location): + logger.debug("Initializing %s: (detected_faces: %s, input_location: %s)", + self.__class__.__name__, detected_faces, input_location) + self._input_location = input_location + self._alignments = detected_faces._alignments + self._frame_faces = detected_faces._frame_faces + self._updated_frame_indices = detected_faces._updated_frame_indices + self._tk_unsaved = detected_faces.tk_unsaved + self._tk_edited = detected_faces.tk_edited + self._tk_face_count_changed = detected_faces.tk_face_count_changed + self._globals = detected_faces._globals + self._sorted_frame_names = sorted(self._alignments.data) + logger.debug("Initialized %s", self.__class__.__name__) + + def load(self): + """ Load the faces from the alignments file, convert to + :class:`~lib.faces_detect.DetectedFace`. objects and add to :attr:`_frame_faces`. """ + for key in sorted(self._alignments.data): + this_frame_faces = [] + for item in self._alignments.data[key]["faces"]: + face = DetectedFace() + face.from_alignment(item, with_thumb=True) + this_frame_faces.append(face) + self._frame_faces.append(this_frame_faces) + + def save(self): + """ Convert updated :class:`~lib.faces_detect.DetectedFace` objects to alignments format + and save the alignments file. """ + if not self._tk_unsaved.get(): + logger.debug("Alignments not updated. Returning") + return + frames = list(self._updated_frame_indices) + logger.verbose("Saving alignments for %s updated frames", len(frames)) + + for idx, faces in zip(frames, np.array(self._frame_faces)[np.array(frames)]): + frame = self._sorted_frame_names[idx] + self._alignments.data[frame]["faces"] = [face.to_alignment() for face in faces] + + self._alignments.backup() + self._alignments.save() + self._updated_frame_indices.clear() + self._tk_unsaved.set(False) + + def revert_to_saved(self, frame_index): + """ Revert the frame's alignments to their saved version for the given frame index. + + Parameters + ---------- + frame_index: int + The frame that should have their faces reverted to their saved version + """ + if frame_index not in self._updated_frame_indices: + logger.debug("Alignments not amended. Returning") + return + logger.verbose("Reverting alignments for frame_index %s", frame_index) + alignments = self._alignments.data[self._sorted_frame_names[frame_index]]["faces"] + faces = self._frame_faces[frame_index] + + reset_grid = self._add_remove_faces(alignments, faces) + + for detected_face, face in zip(faces, alignments): + detected_face.from_alignment(face) + + self._updated_frame_indices.remove(frame_index) + if not self._updated_frame_indices: + self._tk_unsaved.set(False) + + if reset_grid: + self._tk_face_count_changed.set(True) + else: + self._tk_edited.set(True) + self._globals.tk_update.set(True) + + @classmethod + def _add_remove_faces(cls, alignments, faces): + """ On a revert, ensure that the alignments and detected face object counts for each frame + are in sync. """ + num_alignments = len(alignments) + num_faces = len(faces) + if num_alignments == num_faces: + retval = False + elif num_alignments > num_faces: + faces.extend([DetectedFace() for _ in range(num_faces, num_alignments)]) + retval = True + else: + del faces[num_alignments:] + retval = True + return retval + + def extract(self): + """ Extract the current faces to a folder. + + To stop the GUI becoming completely unresponsive (particularly in Windows) the extract is + done in a background thread, with the process count passed back in a queue to the main + thread to update the progress bar. + """ + dirname = FileHandler("dir", None, + initial_folder=os.path.dirname(self._input_location), + title="Select output folder...").retfile + if not dirname: + return + logger.debug(dirname) + + queue = Queue() + pbar = PopupProgress("Extracting Faces...", self._alignments.frames_count + 1) + thread = MultiThread(self._background_extract, dirname, queue) + thread.start() + self._monitor_extract(thread, queue, pbar) + + def _monitor_extract(self, thread, queue, pbar): + """ Monitor the extraction thread, and update the progress bar. + + On completion, save alignments and clear progress bar. + + Parameters + ---------- + thread: :class:`lib.multithreading.MultiThread` + The thread that is performing the extraction task + queue: :class:`queue.Queue` + The queue that the worker thread is putting it's incremental counts to + pbar: :class:`lib.gui.custom_widget.PopupProgress` + The popped up progress bar + """ + thread.check_and_raise_error() + if not thread.is_alive(): + thread.join() + # Update hashes in alignments file. + pbar.update_title("Saving Alignments...") + self._alignments.backup() + self._alignments.save() + self._updated_frame_indices.clear() + self._tk_unsaved.set(False) + pbar.stop() + return + + while True: + try: + pbar.step(queue.get(False, 0)) + except Empty: + break + pbar.after(100, self._monitor_extract, thread, queue, pbar) + + def _background_extract(self, output_folder, progress_queue): + """ Perform the background extraction in a thread so GUI doesn't become unresponsive. + + Parameters + ---------- + output_folder: str + The location to save the output faces to + progress_queue: :class:`queue.Queue` + The queue to place incrememental counts to for updating the GUI's progress bar + """ + saver = ImagesSaver(str(get_folder(output_folder)), as_bytes=True) + loader = ImagesLoader(self._input_location, count=self._alignments.frames_count) + for frame_idx, (filename, image) in enumerate(loader.load()): + logger.trace("Outputting frame: %s: %s", frame_idx, filename) + frame_name, extension = os.path.splitext(filename) + final_faces = [] + progress_queue.put(1) + for face_idx, face in enumerate(self._frame_faces[frame_idx]): + output = "{}_{}{}".format(frame_name, str(face_idx), extension) + face.load_aligned(image, size=256, force=True) # TODO user selectable size + face.hash, b_image = encode_image_with_hash(face.aligned_face, extension) + saver.save(output, b_image) + final_faces.append(face.to_alignment()) + face.aligned = dict() + self._alignments.data[filename]["faces"] = final_faces + saver.close() + + +class Filter(): + """ Returns stats and frames for filtered frames based on the user selected navigation mode + filter. + + Parameters + ---------- + detected_faces: :class:`DetectedFaces` + The parent :class:`DetectedFaces` object + """ + def __init__(self, detected_faces): + logger.debug("Initializing %s: (detected_faces: %s)", + self.__class__.__name__, detected_faces) + self._globals = detected_faces._globals + self._detected_faces = detected_faces + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def count(self): + """ int: The number of frames that meet the filter criteria returned by + :attr:`~tools.manual.manual.TkGlobals.filter_mode`. """ + face_count_per_index = self._detected_faces.face_count_per_index + if self._globals.filter_mode == "No Faces": + retval = sum(1 for fcount in face_count_per_index if fcount == 0) + elif self._globals.filter_mode == "Has Face(s)": + retval = sum(1 for fcount in face_count_per_index if fcount != 0) + elif self._globals.filter_mode == "Multiple Faces": + retval = sum(1 for fcount in face_count_per_index if fcount > 1) + else: + retval = len(face_count_per_index) + logger.trace("filter mode: %s, frame count: %s", self._globals.filter_mode, retval) + return retval + + @property + def raw_indices(self): + """ dict: The frame and face indices that meet the current filter criteria for each + displayed face. """ + frame_indices = [] + face_indices = [] + if self._globals.filter_mode != "No Faces": + for frame_idx, face_count in enumerate(self._detected_faces.face_count_per_index): + if face_count <= 1 and self._globals.filter_mode == "Multiple Faces": + continue + for face_idx in range(face_count): + frame_indices.append(frame_idx) + face_indices.append(face_idx) + logger.trace("frame_indices: %s, face_indices: %s", frame_indices, face_indices) + retval = dict(frame=frame_indices, face=face_indices) + return retval + + @property + def frames_list(self): + """ list: The list of frame indices that meet the filter criteria returned by + :attr:`~tools.manual.manual.TkGlobals.filter_mode`. """ + face_count_per_index = self._detected_faces.face_count_per_index + if self._globals.filter_mode == "No Faces": + retval = [idx for idx, count in enumerate(face_count_per_index) if count == 0] + elif self._globals.filter_mode == "Multiple Faces": + retval = [idx for idx, count in enumerate(face_count_per_index) if count > 1] + elif self._globals.filter_mode == "Has Face(s)": + retval = [idx for idx, count in enumerate(face_count_per_index) if count != 0] + else: + retval = range(len(face_count_per_index)) + logger.trace("filter mode: %s, number_frames: %s", self._globals.filter_mode, len(retval)) + return retval + + +class FaceUpdate(): + """ Perform updates on :class:`~lib.faces_detect.DetectedFace` objects stored in + :class:`DetectedFaces` when changes are made within the GUI. + + Parameters + ---------- + detected_faces: :class:`DetectedFaces` + The parent :class:`DetectedFaces` object + """ + def __init__(self, detected_faces): + logger.debug("Initializing %s: (detected_faces: %s)", + self.__class__.__name__, detected_faces) + self._detected_faces = detected_faces + self._globals = detected_faces._globals + self._frame_faces = detected_faces._frame_faces + self._updated_frame_indices = detected_faces._updated_frame_indices + self._tk_unsaved = detected_faces.tk_unsaved + self._extractor = detected_faces.extractor + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def _tk_edited(self): + """ :class:`tkinter.BooleanVar`: The variable indicating whether an edit has occurred + meaning a GUI redraw needs to be triggered. + + Notes + ----- + The variable is still a ``None`` when this class is initialized, so referenced explicitly. + """ + return self._detected_faces.tk_edited + + @property + def _tk_face_count_changed(self): + """ :class:`tkinter.BooleanVar`: The variable indicating whether an edit has occurred + meaning a GUI redraw needs to be triggered. + + Notes + ----- + The variable is still a ``None`` when this class is initialized, so referenced explicitly. + """ + return self._detected_faces.tk_face_count_changed + + def _faces_at_frame_index(self, frame_index): + """ Checks whether the frame has already been added to :attr:`_updated_frame_indices` and + adds it. Triggers the unsaved variable if this is the first edited frame. Returns the + detected face objects for the given frame. + + Parameters + ---------- + frame_index: int + The frame index to check whether there are updated alignments available + + Returns + ------- + list + The :class:`~lib.faces_detect.DetectedFace` objects for the requested frame + """ + if not self._updated_frame_indices and not self._tk_unsaved.get(): + self._tk_unsaved.set(True) + self._updated_frame_indices.add(frame_index) + retval = self._frame_faces[frame_index] + return retval + + def _generate_thumbnail(self, face): + """ Generate the jpg thumbnail from the currently active frame for the detected face and + assign to it's `thumbnail` attribute. + + Parameters + ---------- + face: class:`~lib.faces_detect.DetectedFace` + The detected face object to generate the thumbnail for + """ + face.load_aligned(self._globals.current_frame["image"], 80, force=True) + jpg = cv2.imencode(".jpg", face.aligned_face, [cv2.IMWRITE_JPEG_QUALITY, 60])[1] + face.thumbnail = jpg + face.aligned = dict() + + def add(self, frame_index, pnt_x, width, pnt_y, height): + """ Add a :class:`~lib.faces_detect.DetectedFace` object to the current frame with the + given dimensions. + + Parameters + ---------- + frame_index: int + The frame that the face is being set for + pnt_x: int + The left point of the bounding box + width: int + The width of the bounding box + pnt_y: int + The top point of the bounding box + height: int + The height of the bounding box + """ + face = DetectedFace() + faces = self._faces_at_frame_index(frame_index) + faces.append(face) + face_index = len(faces) - 1 + + self.bounding_box(frame_index, face_index, pnt_x, width, pnt_y, height, aligner="cv2-dnn") + self._tk_face_count_changed.set(True) + + def delete(self, frame_index, face_index): + """ Delete the :class:`~lib.faces_detect.DetectedFace` object for the given frame and face + indices. + + Parameters + ---------- + frame_index: int + The frame that the face is being set for + face_index: int + The face index within the frame + """ + logger.debug("Deleting face at frame index: %s face index: %s", frame_index, face_index) + faces = self._faces_at_frame_index(frame_index) + del faces[face_index] + self._tk_face_count_changed.set(True) + self._globals.tk_update.set(True) + + def bounding_box(self, frame_index, face_index, pnt_x, width, pnt_y, height, aligner="FAN"): + """ Update the bounding box for the :class:`~lib.faces_detect.DetectedFace` object at the + given frame and face indices, with the given dimensions and update the 68 point landmarks + from the :class:`~tools.manual.manual.Aligner` for the updated bounding box. + + Parameters + ---------- + frame_index: int + The frame that the face is being set for + face_index: int + The face index within the frame + pnt_x: int + The left point of the bounding box + width: int + The width of the bounding box + pnt_y: int + The top point of the bounding box + height: int + The height of the bounding box + aligner: ["cv2-dnn", "FAN"], optional + The aligner to use to generate the landmarks. Default: "FAN" + """ + logger.trace("frame_index: %s, face_index %s, pnt_x %s, width %s, pnt_y %s, height %s, " + "aligner: %s", frame_index, face_index, pnt_x, width, pnt_y, height, aligner) + face = self._faces_at_frame_index(frame_index)[face_index] + face.x = pnt_x + face.w = width + face.y = pnt_y + face.h = height + face.landmarks_xy = self._extractor.get_landmarks(frame_index, face_index, aligner) + self._globals.tk_update.set(True) + + def landmark(self, frame_index, face_index, landmark_index, shift_x, shift_y, is_zoomed): + """ Shift a single landmark point for the :class:`~lib.faces_detect.DetectedFace` object + at the given frame and face indices by the given x and y values. + + Parameters + ---------- + frame_index: int + The frame that the face is being set for + face_index: int + The face index within the frame + landmark_index: int or list + The landmark index to shift. If a list is provided, this should be a list of landmark + indices to be shifted + shift_x: int + The amount to shift the landmark by along the x axis + shift_y: int + The amount to shift the landmark by along the y axis + is_zoomed: bool + ``True`` if landmarks are being adjusted on a zoomed image otherwise ``False`` + """ + face = self._faces_at_frame_index(frame_index)[face_index] + if is_zoomed: + if not np.any(face.aligned_landmarks): # This will be None on a resize + face.load_aligned(None, size=min(self._globals.frame_display_dims)) + landmark = face.aligned_landmarks[landmark_index] + landmark += (shift_x, shift_y) + matrix = AlignerExtract.transform_matrix(face.aligned["matrix"], + face.aligned["size"], + face.aligned["padding"]) + matrix = cv2.invertAffineTransform(matrix) + if landmark.ndim == 1: + landmark = np.reshape(landmark, (1, 1, 2)) + landmark = cv2.transform(landmark, matrix, landmark.shape).squeeze() + face.landmarks_xy[landmark_index] = landmark + else: + for lmk, idx in zip(landmark, landmark_index): + lmk = np.reshape(lmk, (1, 1, 2)) + lmk = cv2.transform(lmk, matrix, lmk.shape).squeeze() + face.landmarks_xy[idx] = lmk + else: + face.landmarks_xy[landmark_index] += (shift_x, shift_y) + face.mask = self._extractor.get_masks(frame_index, face_index) + self._globals.tk_update.set(True) + + def landmarks(self, frame_index, face_index, shift_x, shift_y): + """ Shift all of the landmarks and bounding box for the + :class:`~lib.faces_detect.DetectedFace` object at the given frame and face indices by the + given x and y values and update the masks. + + Parameters + ---------- + frame_index: int + The frame that the face is being set for + face_index: int + The face index within the frame + shift_x: int + The amount to shift the landmarks by along the x axis + shift_y: int + The amount to shift the landmarks by along the y axis + + Notes + ----- + Whilst the bounding box does not need to be shifted, it is anyway, to ensure that it is + aligned with the newly adjusted landmarks. + """ + face = self._faces_at_frame_index(frame_index)[face_index] + face.x += shift_x + face.y += shift_y + face.landmarks_xy += (shift_x, shift_y) + face.mask = self._extractor.get_masks(frame_index, face_index) + self._globals.tk_update.set(True) + + def landmarks_rotate(self, frame_index, face_index, angle, center): + """ Rotate the landmarks on an Extract Box rotate for the + :class:`~lib.faces_detect.DetectedFace` object at the given frame and face indices for the + given angle from the given center point. + + Parameters + ---------- + frame_index: int + The frame that the face is being set for + face_index: int + The face index within the frame + angle: :class:`numpy.ndarray` + The angle, in radians to rotate the points by + center: :class:`numpy.ndarray` + The center point of the Landmark's Extract Box + """ + face = self._faces_at_frame_index(frame_index)[face_index] + rot_mat = cv2.getRotationMatrix2D(tuple(center), angle, 1.) + face.landmarks_xy = cv2.transform(np.expand_dims(face.landmarks_xy, axis=0), + rot_mat).squeeze() + face.mask = self._extractor.get_masks(frame_index, face_index) + self._globals.tk_update.set(True) + + def landmarks_scale(self, frame_index, face_index, scale, center): + """ Scale the landmarks on an Extract Box resize for the + :class:`~lib.faces_detect.DetectedFace` object at the given frame and face indices from the + given center point. + + Parameters + ---------- + frame_index: int + The frame that the face is being set for + face_index: int + The face index within the frame + scale: float + The amount to scale the landmarks by + center: :class:`numpy.ndarray` + The center point of the Landmark's Extract Box + """ + face = self._faces_at_frame_index(frame_index)[face_index] + face.landmarks_xy = ((face.landmarks_xy - center) * scale) + center + face.mask = self._extractor.get_masks(frame_index, face_index) + self._globals.tk_update.set(True) + + def mask(self, frame_index, face_index, mask, mask_type): + """ Update the mask on an edit for the :class:`~lib.faces_detect.DetectedFace` object at + the given frame and face indices, for the given mask and mask type. + + Parameters + ---------- + frame_index: int + The frame that the face is being set for + face_index: int + The face index within the frame + mask: class:`numpy.ndarray`: + The mask to replace + mask_type: str + The name of the mask that is to be replaced + """ + face = self._faces_at_frame_index(frame_index)[face_index] + face.mask[mask_type].replace_mask(mask) + self._tk_edited.set(True) + self._globals.tk_update.set(True) + + def copy(self, frame_index, direction): + """ Copy the alignments from the previous or next frame that has alignments + to the current frame. + + Parameters + ---------- + frame_index: int + The frame that the needs to have alignments copied to it + direction: ["prev", "next"] + Whether to copy alignments from the previous frame with alignments, or the next + frame with alignments + """ + logger.debug("frame: %s, direction: %s", frame_index, direction) + faces = self._faces_at_frame_index(frame_index) + frames_with_faces = [idx for idx, faces in enumerate(self._detected_faces.current_faces) + if len(faces) > 0] + if direction == "prev": + idx = next((idx for idx in reversed(frames_with_faces) + if idx < frame_index), None) + else: + idx = next((idx for idx in frames_with_faces + if idx > frame_index), None) + if idx is None: + # No previous/next frame available + return + logger.debug("Copying alignments from frame %s to frame: %s", idx, frame_index) + faces.extend(deepcopy(self._faces_at_frame_index(idx))) + self._tk_face_count_changed.set(True) + self._globals.tk_update.set(True) + + def post_edit_trigger(self, frame_index, face_index): + """ Update the jpg thumbnail and the viewport thumbnail on a face edit. + + Parameters + ---------- + frame_index: int + The frame that the face is being set for + face_index: int + The face index within the frame + """ + face = self._frame_faces[frame_index][face_index] + face.load_aligned(self._globals.current_frame["image"], 80, force=True) + jpg = cv2.imencode(".jpg", face.aligned_face, [cv2.IMWRITE_JPEG_QUALITY, 60])[1] + face.thumbnail = jpg + face.aligned = dict() + self._tk_edited.set(True) + + +class ThumbsCreator(): + """ Background loader to generate thumbnails for the alignments file. Generates low resolution + thumbnails in parallel threads for faster processing. + + Parameters + ---------- + detected_faces: :class:`~tool.manual.faces.DetectedFaces` + The :class:`~lib.faces_detect.DetectedFace` objects for this video + input_location: str + The location of the input folder of frames or video file + """ + def __init__(self, detected_faces, input_location, single_process): + logger.debug("Initializing %s: (detected_faces: %s, input_location: %s, " + "single_process: %s)", self.__class__.__name__, detected_faces, + input_location, single_process) + self._size = 80 + self._jpeg_quality = 60 + self._pbar = dict(pbar=None, lock=Lock()) + self._meta = dict(key_frames=detected_faces.video_meta_data.get("keyframes", None), + pts_times=detected_faces.video_meta_data.get("pts_time", None)) + self._location = input_location + self._alignments = detected_faces._alignments + self._frame_faces = detected_faces._frame_faces + + self._is_video = all(val is not None for val in self._meta.values()) + self._num_threads = os.cpu_count() - 2 + if self._is_video and single_process: + self._num_threads = 1 + elif self._is_video and not single_process: + self._num_threads = min(self._num_threads, len(self._meta["key_frames"])) + else: + self._num_threads = max(self._num_threads, 32) + self._threads = [] + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def has_thumbs(self): + """ bool: ``True`` if the underlying alignments file holds thumbnail images + otherwise ``False``. """ + return self._alignments.thumbnails.has_thumbnails + + def generate_cache(self): + """ Extract the face thumbnails from a video or folder of images into the + alignments file. """ + self._pbar["pbar"] = tqdm(desc="Caching Thumbnails", + leave=False, + total=len(self._frame_faces)) + if self._is_video: + self._launch_video() + else: + self._launch_folder() + while True: + self._check_and_raise_error() + if all(not thread.is_alive() for thread in self._threads): + break + sleep(1) + self._join_threads() + self._pbar["pbar"].close() + self._alignments.save() + + # << PRIVATE METHODS >> # + def _check_and_raise_error(self): + """ Monitor the loading threads for errors and raise if any occur. """ + for thread in self._threads: + thread.check_and_raise_error() + + def _join_threads(self): + """ Join the loading threads """ + logger.debug("Joining face viewer loading threads") + for thread in self._threads: + thread.join() + + def _launch_video(self): + """ Launch multiple :class:`lib.multithreading.MultiThread` objects to load faces from + a video file. + + Splits the video into segments and passes each of these segments to separate background + threads for some speed up. + """ + key_frame_split = len(self._meta["key_frames"]) // self._num_threads + key_frames = self._meta["key_frames"] + pts_times = self._meta["pts_times"] + for idx in range(self._num_threads): + is_final = idx == self._num_threads - 1 + start_idx = idx * key_frame_split + keyframe_idx = len(key_frames) - 1 if is_final else start_idx + key_frame_split + end_idx = key_frames[keyframe_idx] + start_pts = pts_times[key_frames[start_idx]] + end_pts = False if idx + 1 == self._num_threads else pts_times[end_idx] + starting_index = pts_times.index(start_pts) + if end_pts: + segment_count = len(pts_times[key_frames[start_idx]:end_idx]) + else: + segment_count = len(pts_times[key_frames[start_idx]:]) + logger.debug("thread index: %s, start_idx: %s, end_idx: %s, start_pts: %s, " + "end_pts: %s, starting_index: %s, segment_count: %s", idx, start_idx, + end_idx, start_pts, end_pts, starting_index, segment_count) + thread = MultiThread(self._load_from_video, + start_pts, + end_pts, + starting_index, + segment_count) + thread.start() + self._threads.append(thread) + + def _launch_folder(self): + """ Launch :class:`lib.multithreading.MultiThread` to retrieve faces from a + folder of images. + + Goes through the file list one at a time, passing each file to a separate background + thread for some speed up. + """ + reader = SingleFrameLoader(self._location) + num_threads = min(reader.count, self._num_threads) + frame_split = reader.count // self._num_threads + logger.debug("total images: %s, num_threads: %s, frames_per_thread: %s", + reader.count, num_threads, frame_split) + for idx in range(num_threads): + is_final = idx == num_threads - 1 + start_idx = idx * frame_split + end_idx = reader.count if is_final else start_idx + frame_split + thread = MultiThread(self._load_from_folder, reader, start_idx, end_idx) + thread.start() + self._threads.append(thread) + + def _load_from_video(self, pts_start, pts_end, start_index, segment_count): + """ Loads faces from video for the given segment of the source video. + + Each segment of the video is extracted from in a different background thread. + + Parameters + ---------- + pts_start: float + The start time to cut the segment out of the video + pts_end: float + The end time to cut the segment out of the video + start_index: int + The frame index that this segment starts from. Used for calculating the actual frame + index of each frame extracted + segment_count: int + The number of frames that appear in this segment. Used for ending early in case more + frames come out of the segment than should appear (sometimes more frames are picked up + at the end of the segment, so these are discarded) + """ + logger.debug("pts_start: %s, pts_end: %s, start_index: %s, segment_count: %s", + pts_start, pts_end, start_index, segment_count) + reader = self._get_reader(pts_start, pts_end) + idx = 0 + sample_filename = next(fname for fname in self._alignments.data) + vidname = sample_filename[:sample_filename.rfind("_")] + for idx, frame in enumerate(reader): + frame_idx = idx + start_index + filename = "{}_{:06d}.png".format(vidname, frame_idx + 1) + self._set_thumbail(filename, frame[..., ::-1], frame_idx) + if idx == segment_count - 1: + # Sometimes extra frames are picked up at the end of a segment, so stop + # processing when segment frame count has been hit. + break + reader.close() + logger.debug("Segment complete: (starting_frame_index: %s, processed_count: %s)", + start_index, idx) + + def _get_reader(self, pts_start, pts_end): + """ Get an imageio iterator for this thread's segment. + + Parameters + ---------- + pts_start: float + The start time to cut the segment out of the video + pts_end: float + The end time to cut the segment out of the video + + Returns + ------- + :class:`imageio.Reader` + A reader iterator for the requested segment of video + """ + input_params = ["-ss", str(pts_start)] + if pts_end: + input_params.extend(["-to", str(pts_end)]) + logger.debug("pts_start: %s, pts_end: %s, input_params: %s", + pts_start, pts_end, input_params) + return imageio.get_reader(self._location, "ffmpeg", input_params=input_params) + + def _load_from_folder(self, reader, start_index, end_index): + """ Loads faces from the given range of frame indices from a folder of images. + + Each frame range is extracted in a different background thread. + + Parameters + ---------- + reader: :class:`lib.image.SingleFrameLoader` + The reader that is used to retrieve the requested frame + start_index: int + The starting frame index for the images to extract faces from + end_index: int + The end frame index for the images to extract faces from + """ + logger.debug("reader: %s, start_index: %s, end_index: %s", + reader, start_index, end_index) + for frame_index in range(start_index, end_index): + filename, frame = reader.image_from_index(frame_index) + self._set_thumbail(filename, frame, frame_index) + logger.debug("Segment complete: (start_index: %s, processed_count: %s)", + start_index, end_index - start_index) + + def _set_thumbail(self, filename, frame, frame_index): + """ Extracts the faces from the frame and adds to alignments file + + Parameters + ---------- + filename: str + The filename of the frame within the alignments file + frame: :class:`numpy.ndarray` + The frame that contains the faces + frame_index: int + The frame index of this frame in the :attr:`_frame_faces` + """ + for face_idx, face in enumerate(self._frame_faces[frame_index]): + face.load_aligned(frame, size=self._size, force=True) + jpg = cv2.imencode(".jpg", + face.aligned_face, + [cv2.IMWRITE_JPEG_QUALITY, self._jpeg_quality])[1] + face.thumbnail = jpg + self._alignments.thumbnails.add_thumbnail(filename, face_idx, jpg) + face.aligned["face"] = None + with self._pbar["lock"]: + self._pbar["pbar"].update(1) diff --git a/tools/manual/faceviewer/__init__.py b/tools/manual/faceviewer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/manual/faceviewer/frame.py b/tools/manual/faceviewer/frame.py new file mode 100644 index 0000000000..169a87d8bd --- /dev/null +++ b/tools/manual/faceviewer/frame.py @@ -0,0 +1,742 @@ +#!/usr/bin/env python3 +""" The Faces Viewer Frame and Canvas for Faceswap's Manual Tool. """ +import colorsys +import logging +import platform +import tkinter as tk +from tkinter import ttk +from math import floor, ceil +from threading import Thread, Event + +import numpy as np + +from lib.gui.custom_widgets import RightClickMenu, Tooltip +from lib.gui.utils import get_config, get_images +from lib.image import hex_to_rgb, rgb_to_hex + +from .viewport import Viewport + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class FacesFrame(ttk.Frame): # pylint:disable=too-many-ancestors + """ The faces display frame (bottom section of GUI). This frame holds the faces viewport and + the tkinter objects. + + Parameters + ---------- + parent: :class:`tkinter.PanedWindow` + The paned window that the faces frame resides in + tk_globals: :class:`~tools.manual.manual.TkGlobals` + The tkinter variables that apply to the whole of the GUI + detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` + The :class:`~lib.faces_detect.DetectedFace` objects for this video + display_frame: :class:`~tools.manual.frameviewer.frame.DisplayFrame` + The section of the Manual Tool that holds the frames viewer + """ + def __init__(self, parent, tk_globals, detected_faces, display_frame): + logger.debug("Initializing %s: (parent: %s, tk_globals: %s, detected_faces: %s, " + "display_frame: %s)", self.__class__.__name__, parent, tk_globals, + detected_faces, display_frame) + super().__init__(parent) + self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + self._actions_frame = FacesActionsFrame(self) + + self._faces_frame = ttk.Frame(self) + self._faces_frame.pack_propagate(0) + self._faces_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + self._event = Event() + self._canvas = FacesViewer(self._faces_frame, + tk_globals, + self._actions_frame._tk_vars, + detected_faces, + display_frame, + self._event) + self._add_scrollbar() + logger.debug("Initialized %s", self.__class__.__name__) + + def _add_scrollbar(self): + """ Add a scrollbar to the faces frame """ + logger.debug("Add Faces Viewer Scrollbar") + scrollbar = ttk.Scrollbar(self._faces_frame, command=self._on_scroll) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + self._canvas.config(yscrollcommand=scrollbar.set) + self.bind("", self._update_viewport) + logger.debug("Added Faces Viewer Scrollbar") + self.update_idletasks() # Update so scrollbar width is correct + return scrollbar.winfo_width() + + def _on_scroll(self, *event): + """ Callback on scrollbar scroll. Updates the canvas location and displays/hides + thumbnail images. + + Parameters + ---------- + event :class:`tkinter.Event` + The scrollbar callback event + """ + self._canvas.yview(*event) + self._canvas.viewport.update() + + def _update_viewport(self, event): # pylint: disable=unused-argument + """ Update the faces viewport and scrollbar. + + Parameters + ---------- + event: :class:`tkinter.Event` + Unused but required + """ + self._canvas.viewport.update() + self._canvas.configure(scrollregion=self._canvas.bbox("backdrop")) + + def canvas_scroll(self, direction): + """ Scroll the canvas on an up/down or page-up/page-down key press. + + Notes + ----- + To protect against a held down key press stacking tasks and locking up the GUI + a background thread is launched and discards subsequent key presses whilst the + previous update occurs. + + Parameters + ---------- + direction: ["up", "down", "page-up", "page-down"] + The request page scroll direction and amount. + """ + + if self._event.is_set(): + logger.trace("Update already running. Aborting repeated keypress") + return + logger.trace("Running update on received key press: %s", direction) + + amount = 1 if direction.endswith("down") else -1 + units = "pages" if direction.startswith("page") else "units" + self._event.set() + thread = Thread(target=self._canvas.canvas_scroll, + args=(amount, units, self._event)) + thread.start() + + def set_annotation_display(self, key): + """ Set the optional annotation overlay based on keyboard shortcut. + + Parameters + ---------- + key: str + The pressed key + """ + self._actions_frame.on_click(self._actions_frame.key_bindings[key]) + + +class FacesActionsFrame(ttk.Frame): # pylint:disable=too-many-ancestors + """ The left hand action frame holding the optional annotation buttons. + + Parameters + ---------- + parent: :class:`FacesFrame` + The Faces frame that this actions frame reside in + """ + def __init__(self, parent): + logger.debug("Initializing %s: (parent: %s)", + self.__class__.__name__, parent) + super().__init__(parent) + self.pack(side=tk.LEFT, fill=tk.Y, padx=(2, 4), pady=2) + self._tk_vars = dict() + self._configure_styles() + self._buttons = self._add_buttons() + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def key_bindings(self): + """ dict: The mapping of key presses to optional annotations to display. Keyboard shortcuts + utilize the function keys. """ + return {"F{}".format(idx + 9): display for idx, display in enumerate(("mesh", "mask"))} + + @property + def _helptext(self): + """ dict: `button key`: `button helptext`. The help text to display for each button. """ + inverse_keybindings = {val: key for key, val in self.key_bindings.items()} + retval = dict(mesh="Display the landmarks mesh", + mask="Display the mask") + for item in retval: + retval[item] += " ({})".format(inverse_keybindings[item]) + return retval + + def _configure_styles(self): + """ Configure the background color for button frame and the button styles. """ + style = ttk.Style() + style.configure("display.TFrame", background='#d3d3d3') + style.configure("display_selected.TButton", relief="flat", background="#bedaf1") + style.configure("display_deselected.TButton", relief="flat") + self.config(style="display.TFrame") + + def _add_buttons(self): + """ Add the display buttons to the Faces window. + + Returns + ------- + dict + The display name and its associated button. + """ + frame = ttk.Frame(self) + frame.pack(side=tk.TOP, fill=tk.Y) + buttons = dict() + for display in self.key_bindings.values(): + var = tk.BooleanVar() + var.set(False) + self._tk_vars[display] = var + + lookup = "landmarks" if display == "mesh" else display + button = ttk.Button(frame, + image=get_images().icons[lookup], + command=lambda t=display: self.on_click(t), + style="display_deselected.TButton") + button.state(["!pressed", "!focus"]) + button.pack() + Tooltip(button, text=self._helptext[display]) + buttons[display] = button + return buttons + + def on_click(self, display): + """ Click event for the optional annotation buttons. Loads and unloads the annotations from + the faces viewer. + + Parameters + ---------- + display: str + The display name for the button that has called this event as exists in + :attr:`_buttons` + """ + is_pressed = not self._tk_vars[display].get() + style = "display_selected.TButton" if is_pressed else "display_deselected.TButton" + state = ["pressed", "focus"] if is_pressed else ["!pressed", "!focus"] + btn = self._buttons[display] + btn.configure(style=style) + btn.state(state) + self._tk_vars[display].set(is_pressed) + + +class FacesViewer(tk.Canvas): # pylint:disable=too-many-ancestors + """ The :class:`tkinter.Canvas` that holds the faces viewer section of the Manual Tool. + + Parameters + ---------- + parent: :class:`tkinter.ttk.Frame` + The parent frame for the canvas + tk_globals: :class:`~tools.manual.manual.TkGlobals` + The tkinter variables that apply to the whole of the GUI + tk_action_vars: dict + The :class:`tkinter.BooleanVar` objects for selectable optional annotations + as set by the buttons in the :class:`FacesActionsFrame` + detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` + The :class:`~lib.faces_detect.DetectedFace` objects for this video + display_frame: :class:`~tools.manual.frameviewer.frame.DisplayFrame` + The section of the Manual Tool that holds the frames viewer + event: :class:`threading.Event` + The threading event object for repeated key press protection + """ + def __init__(self, parent, tk_globals, tk_action_vars, detected_faces, display_frame, event): + logger.debug("Initializing %s: (parent: %s, tk_globals: %s, tk_action_vars: %s, " + "detected_faces: %s, display_frame: %s, event: %s)", self.__class__.__name__, + parent, tk_globals, tk_action_vars, detected_faces, display_frame, event) + super().__init__(parent, bd=0, highlightthickness=0, bg="#bcbcbc") + self.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.E) + self._sizes = dict(tiny=32, small=64, medium=96, large=128, extralarge=192) + + self._globals = tk_globals + self._tk_optional_annotations = tk_action_vars + self._event = event + self._display_frame = display_frame + self._grid = Grid(self, detected_faces) + self._view = Viewport(self, detected_faces.tk_edited) + self._annotation_colors = dict(mesh=self.get_muted_color("Mesh"), + box=self.control_colors["ExtractBox"]) + + ContextMenu(self, detected_faces) + self._bind_mouse_wheel_scrolling() + self._set_tk_callbacks(detected_faces) + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def face_size(self): + """ int: The currently selected thumbnail size in pixels """ + scaling = get_config().scaling_factor + size = self._sizes[self._globals.tk_faces_size.get().lower().replace(" ", "")] + return int(round(size * scaling)) + + @property + def viewport(self): + """ :class:`~tools.manual.faceviewer.viewport.Viewport`: The viewport area of the + faces viewer. """ + return self._view + + @property + def grid(self): + """ :class:`Grid`: The grid for the current :class:`FacesViewer`. """ + return self._grid + + @property + def optional_annotations(self): + """ dict: The values currently set for the selectable optional annotations. """ + return {opt: val.get() for opt, val in self._tk_optional_annotations.items()} + + @property + def selected_mask(self): + """ str: The currently selected mask from the display frame control panel. """ + return self._display_frame.tk_selected_mask.get().lower() + + @property + def control_colors(self): + """ :dict: The frame Editor name as key with the current user selected hex code as + value. """ + return ({key: val.get() for key, val in self._display_frame.tk_control_colors.items()}) + + # << CALLBACK FUNCTIONS >> # + def _set_tk_callbacks(self, detected_faces): + """ Set the tkinter variable call backs. + + Redraw the grid on a face size change, a filter change or on add/remove faces. + Updates the annotation colors when user amends a color drop down. + Updates the mask type when the user changes the selected mask types + Toggles the face viewer annotations on an optional annotation button press. + """ + for var in (self._globals.tk_faces_size, self._globals.tk_filter_mode): + var.trace("w", lambda *e, v=var: self.refresh_grid(v)) + var = detected_faces.tk_face_count_changed + var.trace("w", lambda *e, v=var: self.refresh_grid(v, retain_position=True)) + + self._display_frame.tk_control_colors["Mesh"].trace( + "w", lambda *e: self._update_mesh_color()) + self._display_frame.tk_control_colors["ExtractBox"].trace( + "w", lambda *e: self._update_box_color()) + self._display_frame.tk_selected_mask.trace("w", lambda *e: self._update_mask_type()) + + for opt, var in self._tk_optional_annotations.items(): + var.trace("w", lambda *e, o=opt: self._toggle_annotations(o)) + + self.bind("", lambda *e: self._view.update()) + + def refresh_grid(self, trigger_var, retain_position=False): + """ Recalculate the full grid and redraw. Used when the active filter pull down is used, a + face has been added or removed, or the face thumbnail size has changed. + + Parameters + ---------- + trigger_var: :class:`tkinter.BooleanVar` + The tkinter variable that has triggered the grid update. Will either be the variable + indicating that the face size have been changed, or the variable indicating that the + selected filter mode has been changed. + retain_position: bool, optional + ``True`` if the grid should be set back to the position it was at after the update has + been processed, otherwise ``False``. Default: ``False``. + """ + if not trigger_var.get(): + return + size_change = isinstance(trigger_var, tk.StringVar) + move_to = self.yview()[0] if retain_position else 0.0 + self._grid.update() + if move_to != 0.0: + self.yview_moveto(move_to) + if size_change: + self._view.reset() + self._view.update() + if not size_change: + trigger_var.set(False) + + def _update_mask_type(self): + """ Update the displayed mask in the :class:`FacesViewer` canvas when the user changes + the mask type. """ + state = "normal" if self.optional_annotations["mask"] else "hidden" + logger.debug("Updating mask type: (mask_type: %s. state: %s)", self.selected_mask, state) + self._view.toggle_mask(state, self.selected_mask) + + # << MOUSE HANDLING >> + def _bind_mouse_wheel_scrolling(self): + """ Bind mouse wheel to scroll the :class:`FacesViewer` canvas. """ + if platform.system() == "Linux": + self.bind("", self._scroll) + self.bind("", self._scroll) + else: + self.bind("", self._scroll) + + def _scroll(self, event): + """ Handle mouse wheel scrolling over the :class:`FacesViewer` canvas. + + Update is run in a thread to avoid repeated scroll actions stacking and locking up the GUI. + + Parameters + ---------- + event: :class:`tkinter.Event` + The event fired by the mouse scrolling + """ + if self._event.is_set(): + logger.trace("Update already running. Aborting repeated mousewheel") + return + if platform.system() == "Darwin": + adjust = event.delta + elif platform.system() == "Windows": + adjust = event.delta / 120 + elif event.num == 5: + adjust = -1 + else: + adjust = 1 + self._event.set() + thread = Thread(target=self.canvas_scroll, args=(-1 * adjust, "units", self._event)) + thread.start() + + def canvas_scroll(self, amount, units, event): + """ Scroll the canvas on an up/down or page-up/page-down key press. + + Parameters + ---------- + amount: int + The number of units to scroll the canvas + units: ["page", "units"] + The unit type to scroll by + event: :class:`threading.Event` + event to indicate to the calling process whether the scroll is still updating + """ + self.yview_scroll(int(amount), units) + self._view.update() + self._view.hover_box.on_hover(None) + event.clear() + + # << OPTIONAL ANNOTATION METHODS >> # + def _update_mesh_color(self): + """ Update the mesh color when user updates the control panel. """ + color = self.get_muted_color("Mesh") + if self._annotation_colors["mesh"] == color: + return + highlight_color = self.control_colors["Mesh"] + + self.itemconfig("viewport_polygon", outline=color) + self.itemconfig("viewport_line", fill=color) + self.itemconfig("active_mesh_polygon", outline=highlight_color) + self.itemconfig("active_mesh_line", fill=highlight_color) + self._annotation_colors["mesh"] = color + + def _update_box_color(self): + """ Update the active box color when user updates the control panel. """ + color = self.control_colors["ExtractBox"] + + if self._annotation_colors["box"] == color: + return + self.itemconfig("active_highlighter", outline=color) + self._annotation_colors["box"] = color + + def get_muted_color(self, color_key): + """ Creates a muted version of the given annotation color for non-active faces. + + Parameters + ---------- + color_key: str + The annotation key to obtain the color for from :attr:`control_colors` + """ + scale = 0.65 + hls = np.array(colorsys.rgb_to_hls(*hex_to_rgb(self.control_colors[color_key]))) + scale = (1 - scale) + 1 if hls[1] < 120 else scale + hls[1] = max(0., min(256., scale * hls[1])) + rgb = np.clip(np.rint(colorsys.hls_to_rgb(*hls)).astype("uint8"), 0, 255) + retval = rgb_to_hex(rgb) + return retval + + def _toggle_annotations(self, annotation): + """ Toggle optional annotations on or off after the user depresses an optional button. + + Parameters + ---------- + annotation: ["mesh", "mask"] + The optional annotation to toggle on or off + """ + state = "normal" if self.optional_annotations[annotation] else "hidden" + logger.debug("Toggle annotation: (annotation: %s, state: %s)", annotation, state) + if annotation == "mesh": + self._view.toggle_mesh(state) + if annotation == "mask": + self._view.toggle_mask(state, self.selected_mask) + + +class Grid(): + """ Holds information on the current filtered grid layout. + + The grid keeps information on frame indices, face indices, x and y positions and detected face + objects laid out in a numpy array to reflect the current full layout of faces within the face + viewer based on the currently selected filter and face thumbnail size. + + Parameters + ---------- + canvas: :class:`tkinter.Canvas` + The :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas + detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` + The :class:`~lib.faces_detect.DetectedFace` objects for this video + """ + def __init__(self, canvas, detected_faces): + logger.debug("Initializing %s: (detected_faces: %s)", + self.__class__.__name__, detected_faces) + self._canvas = canvas + self._detected_faces = detected_faces + self._raw_indices = detected_faces.filter.raw_indices + self._frames_list = detected_faces.filter.frames_list + + self._is_valid = False + self._face_size = None + self._grid = None + self._display_faces = None + + self._canvas.update_idletasks() + self._canvas.create_rectangle(0, 0, 0, 0, tags=["backdrop"]) + self.update() + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def face_size(self): + """ int: The pixel size of each thumbnail within the face viewer. """ + return self._face_size + + @property + def is_valid(self): + """ bool: ``True`` if the current filter means that the grid holds faces. ``False`` if + there are no faces displayed in the grid. """ + return self._is_valid + + @property + def columns_rows(self): + """ tuple: the (`columns`, `rows`) required to hold all display images. """ + retval = tuple(reversed(self._grid.shape[1:])) if self._is_valid else (0, 0) + return retval + + @property + def dimensions(self): + """ tuple: The (`width`, `height`) required to hold all display images. """ + if self._is_valid: + retval = tuple(dim * self._face_size for dim in reversed(self._grid.shape[1:])) + else: + retval = (0, 0) + return retval + + @property + def _visible_row_indices(self): + """tuple: A 1 dimensional array of the (`top_row_index`, `bottom_row_index`) of the grid + currently in the viewable area. + """ + height = self.dimensions[1] + visible = (max(0, floor(height * self._canvas.yview()[0]) - self._face_size), + ceil(height * self._canvas.yview()[1])) + logger.trace("visible: %s", visible) + y_points = self._grid[3, :, 1] + top = np.searchsorted(y_points, visible[0], side="left") + bottom = np.searchsorted(y_points, visible[1], side="right") + return top, bottom + + @property + def visible_area(self): + """:class:`numpy.ndarray`: A numpy array of shape (`4`, `rows`, `columns`) corresponding + to the viewable area of the display grid. 1st dimension contains frame indices, 2nd + dimension face indices. The 3rd and 4th dimension contain the x and y position of the top + left corner of the face respectively. + + Any locations that are not populated by a face will have a frame and face index of -1 + """ + if not self._is_valid: + retval = None, None + else: + top, bottom = self._visible_row_indices + retval = self._grid[:, top:bottom, :], self._display_faces[top:bottom, :] + logger.trace([r if r is None else r.shape for r in retval]) + return retval + + def y_coord_from_frame(self, frame_index): + """ Return the y coordinate for the first face that appears in the given frame. + + Parameters + ---------- + frame_index: int + The frame index to locate in the grid + + Returns + ------- + int + The y coordinate of the first face for the given frame + """ + return min(self._grid[3][np.where(self._grid[0] == frame_index)]) + + def frame_has_faces(self, frame_index): + """ Check whether the given frame index contains any faces. + + Parameters + ---------- + frame_index: int + The frame index to locate in the grid + + Returns + ------- + bool + ``True`` if there are faces in the given frame otherwise ``False`` + """ + return self._is_valid and np.any(self._grid[0] == frame_index) + + def update(self): + """ Update the underlying grid. + + Called on initialization, on a filter change or on add/remove faces. Recalculates the + underlying grid for the current filter view and updates the attributes :attr:`_grid`, + :attr:`_display_faces`, :attr:`_raw_indices`, :attr:`_frames_list` and :attr:`is_valid` + """ + self._face_size = self._canvas.face_size + self._raw_indices = self._detected_faces.filter.raw_indices + self._frames_list = self._detected_faces.filter.frames_list + self._get_grid() + self._get_display_faces() + self._canvas.coords("backdrop", 0, 0, *self.dimensions) + self._canvas.configure(scrollregion=(self._canvas.bbox("backdrop"))) + self._canvas.yview_moveto(0.0) + + def _get_grid(self): + """ Get the grid information for faces currently displayed in the :class:`FacesViewer`. + + Returns + :class:`numpy.ndarray` + A numpy array of shape (`4`, `rows`, `columns`) corresponding to the display grid. + 1st dimension contains frame indices, 2nd dimension face indices. The 3rd and 4th + dimension contain the x and y position of the top left corner of the face respectively. + + Any locations that are not populated by a face will have a frame and face index of -1 + """ + labels = self._get_labels() + if not self._is_valid: + logger.debug("Setting grid to None for no faces.") + self._grid = None + return + x_coords = np.linspace(0, + labels.shape[2] * self._face_size, + num=labels.shape[2], + endpoint=False, + dtype="int") + y_coords = np.linspace(0, + labels.shape[1] * self._face_size, + num=labels.shape[1], + endpoint=False, + dtype="int") + self._grid = np.array((*labels, *np.meshgrid(x_coords, y_coords)), dtype="int") + logger.debug(self._grid.shape) + + def _get_labels(self): + """ Get the frame and face index for each grid position for the current filter. + + Returns + ------- + :class:`numpy.ndarray` + Array of dimensions (2, rows, columns) corresponding to the display grid, with frame + index as the first dimension and face index within the frame as the 2nd dimension. + + Any remaining placeholders at the end of the grid which are not populated with a face + are given the index -1 + """ + face_count = len(self._raw_indices["frame"]) + self._is_valid = face_count != 0 + if not self._is_valid: + return None + columns = self._canvas.winfo_width() // self._face_size + rows = ceil(face_count / columns) + remainder = face_count % columns + padding = [] if remainder == 0 else [-1 for _ in range(columns - remainder)] + labels = np.array((self._raw_indices["frame"] + padding, + self._raw_indices["face"] + padding), + dtype="int").reshape((2, rows, columns)) + logger.debug(labels.shape) + return labels + + def _get_display_faces(self): + """ Get the detected faces for the current filter and arrange to grid. + + Returns + ------- + :class:`numpy.ndarray` + Array of dimensions (rows, columns) corresponding to the display grid, containing the + corresponding :class:`lib.faces_detect.DetectFace` object + + Any remaining placeholders at the end of the grid which are not populated with a face + are replaced with ``None`` + """ + if not self._is_valid: + logger.debug("Setting display_faces to None for no faces.") + self._display_faces = None + return + current_faces = self._detected_faces.current_faces + columns, rows = self.columns_rows + face_count = len(self._raw_indices["frame"]) + padding = [None for _ in range(face_count, columns * rows)] + self._display_faces = np.array([None if idx is None else current_faces[idx][face_idx] + for idx, face_idx + in zip(self._raw_indices["frame"] + padding, + self._raw_indices["face"] + padding)], + dtype="object").reshape(rows, columns) + logger.debug("faces: (shape: %s, dtype: %s)", + self._display_faces.shape, self._display_faces.dtype) + + def transport_index_from_frame(self, frame_index): + """ Return the main frame's transport index for the given frame index based on the current + filter criteria. + + Parameters + ---------- + frame_index: int + The absolute index for the frame within the full frames list + + Returns + ------- + int + The index of the requested frame within the filtered frames view. + """ + retval = self._frames_list.index(frame_index) if frame_index in self._frames_list else None + logger.trace("frame_index: %s, transport_index: %s", frame_index, retval) + return retval + + +class ContextMenu(): # pylint:disable=too-few-public-methods + """ Enables a right click context menu for the + :class:`~tools.manual.faceviewer.frame.FacesViewer`. + + Parameters + ---------- + canvas: :class:`tkinter.Canvas` + The :class:`FacesViewer` canvas + detected_faces: :class:`~tools.manual.detected_faces` + The manual tool's detected faces class + """ + def __init__(self, canvas, detected_faces): + logger.debug("Initializing: %s (canvas: %s, detected_faces: %s)", + self.__class__.__name__, canvas, detected_faces) + self._canvas = canvas + self._detected_faces = detected_faces + self._menu = RightClickMenu(["Delete Face"], [self._delete_face]) + self._frame_index = None + self._face_index = None + self._canvas.bind("" if platform.system() == "Darwin" else "", + self._pop_menu) + logger.debug("Initialized: %s", self.__class__.__name__) + + def _pop_menu(self, event): + """ Pop up the context menu on a right click mouse event. + + Parameters + ---------- + event: :class:`tkinter.Event` + The mouse event that has triggered the pop up menu + """ + frame_idx, face_idx = self._canvas.viewport.face_from_point( + self._canvas.canvasx(event.x), self._canvas.canvasy(event.y))[:2] + if frame_idx == -1: + logger.trace("No valid item under mouse") + self._frame_index = self._face_index = None + return + self._frame_index = frame_idx + self._face_index = face_idx + logger.trace("Popping right click menu") + self._menu.popup(event) + + def _delete_face(self): + """ Delete the selected face on a right click mouse delete action. """ + logger.trace("Right click delete received. frame_id: %s, face_id: %s", + self._frame_index, self._face_index) + self._detected_faces.update.delete(self._frame_index, self._face_index) + self._frame_index = self._face_index = None diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py new file mode 100644 index 0000000000..a467f9671a --- /dev/null +++ b/tools/manual/faceviewer/viewport.py @@ -0,0 +1,1011 @@ +#!/usr/bin/env python3 +""" Handles the visible area of the :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas. """ + +import logging +import tkinter as tk + +import cv2 +import numpy as np +from PIL import Image, ImageTk + + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class Viewport(): + """ Handles the display of faces and annotations in the currently viewable area of the canvas. + + Parameters + ---------- + canvas: :class:`tkinter.Canvas` + The :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas + tk_edited_variable: :class:`tkinter.BooleanVar` + The variable that indicates that a face has been edited + """ + def __init__(self, canvas, tk_edited_variable): + logger.debug("Initializing: %s: (canvas: %s, tk_edited_variable: %s)", + self.__class__.__name__, canvas, tk_edited_variable) + self._canvas = canvas + self._grid = canvas.grid + self._tk_selected_editor = canvas._display_frame.tk_selected_action + self._landmark_mapping = dict(mouth_inner=(60, 68), + mouth_outer=(48, 60), + right_eyebrow=(17, 22), + left_eyebrow=(22, 27), + right_eye=(36, 42), + left_eye=(42, 48), + nose=(27, 36), + jaw=(0, 17), + chin=(8, 11)) + self._landmarks = dict() + self._tk_faces = dict() + self._objects = VisibleObjects(self) + self._hoverbox = HoverBox(self) + self._active_frame = ActiveFrame(self, tk_edited_variable) + self._tk_selected_editor.trace( + "w", lambda *e: self._active_frame.reload_annotations()) + + @property + def face_size(self): + """ int: The pixel size of each thumbnail """ + return self._grid.face_size + + @property + def mesh_kwargs(self): + """ dict: The color and state keyword arguments for the objects that make up a single + face's mesh annotation based on the current user selected options. Key is the object + type (`polygon` or `line`), value are the keyword arguments for that type. """ + state = "normal" if self._canvas.optional_annotations["mesh"] else "hidden" + color = self._canvas.get_muted_color("Mesh") + kwargs = dict(polygon=dict(fill="", outline=color, state=state), + line=dict(fill=color, state=state)) + return kwargs + + @property + def hover_box(self): + """ :class:`HoverBox`: The hover box for the viewport. """ + return self._hoverbox + + @property + def selected_editor(self): + """ str: The currently selected editor. """ + return self._tk_selected_editor.get().lower() + + def toggle_mesh(self, state): + """ Toggles the mesh optional annotations on and off. + + Parameters + ---------- + state: ["hidden", "normal"] + The state to set the mesh annotations to + """ + logger.debug("Toggling mesh annotations to: %s", state) + self._canvas.itemconfig("viewport_mesh", state=state) + self.update() + + def toggle_mask(self, state, mask_type): + """ Toggles the mask optional annotation on and off. + + Parameters + ---------- + state: ["hidden", "normal"] + Whether the mask should be displayed or hidden + mask_type: str + The type of mask to overlay onto the face + """ + logger.debug("Toggling mask annotations to: %s. mask_type: %s", state, mask_type) + for (frame_idx, face_idx), det_faces in zip( + self._objects.visible_grid[:2].transpose(1, 2, 0).reshape(-1, 2), + self._objects.visible_faces.flatten()): + if frame_idx == -1: + continue + key = "_".join([str(frame_idx), str(face_idx)]) + mask = None if state == "hidden" else det_faces.mask.get(mask_type, None) + mask = mask if mask is None else mask.mask.squeeze() + self._tk_faces[key].update_mask(mask) + self.update() + + def reset(self): + """ Reset all the cached objects on a face size change. """ + self._landmarks = dict() + self._tk_faces = dict() + + def update(self): + """ Update the viewport. + + Obtains the objects that are currently visible. Updates the visible area of the canvas + and reloads the active frame's annotations. """ + self._objects.update() + self._update_viewport() + self._active_frame.reload_annotations() + + def _update_viewport(self): + """ Update the viewport + + Clear out cached objects that are not currently in view. Populate the cache for any + faces that are now in view. Populate the correct face image and annotations for each + object in the viewport based on current location. If optional mesh annotations are + enabled, then calculates newly displayed meshes. """ + if not self._grid.is_valid: + return + self._discard_tk_faces() + + if self._canvas.optional_annotations["mesh"]: # Display any hidden end of row meshes + self._canvas.itemconfig("viewport_mesh", state="normal") + + for collection in zip(self._objects.visible_grid.transpose(1, 2, 0), + self._objects.images, + self._objects.meshes, + self._objects.visible_faces): + for (frame_idx, face_idx, pnt_x, pnt_y), image_id, mesh_ids, face in zip(*collection): + top_left = np.array((pnt_x, pnt_y)) + if frame_idx == self._active_frame.frame_index: + logger.trace("Skipping active frame: %s", frame_idx) + continue + if frame_idx == -1: + logger.debug("Blanking non-existant face") + self._canvas.itemconfig(image_id, image="") + for area in mesh_ids.values(): + for mesh_id in area: + self._canvas.itemconfig(mesh_id, state="hidden") + continue + + tk_face = self.get_tk_face(frame_idx, face_idx, face) + self._canvas.itemconfig(image_id, image=tk_face.photo) + if (self._canvas.optional_annotations["mesh"] + or frame_idx == self._active_frame.frame_index): + landmarks = self.get_landmarks(frame_idx, face_idx, face, top_left) + self._locate_mesh(mesh_ids, landmarks) + + def _discard_tk_faces(self): + """ Remove any :class:`TKFace` objects from the cache that are not currently displayed. """ + keys = ["{}_{}".format(pnt_x, pnt_y) + for pnt_x, pnt_y in self._objects.visible_grid[:2].T.reshape(-1, 2)] + for key in list(self._tk_faces): + if key not in keys: + del self._tk_faces[key] + logger.trace("keys: %s allocated_faces: %s", keys, len(self._tk_faces)) + + def get_tk_face(self, frame_index, face_index, face): + """ Obtain the :class:`TKFace` object for the given face from the cache. If the face does + not exist in the cache, then it is generated and added prior to returning. + + Parameters + ---------- + frame_index: int + The frame index to obtain the face for + face_index: int + The face index of the face within the requested frame + face: :class:`~lib.faces_detect.DetectedFace` + The detected face object, containing the thumbnail jpg + + Returns + ------- + :class:`TKFace` + An object for displaying in the faces viewer canvas populated with the aligned mesh + landmarks and face thumbnail + """ + is_active = frame_index == self._active_frame.frame_index + key = "_".join([str(frame_index), str(face_index)]) + if key not in self._tk_faces or is_active: + logger.trace("creating new tk_face: (key: %s, is_active: %s)", key, is_active) + if is_active: + face.load_aligned(self._active_frame.current_frame, + size=self.face_size, + force=True) + image = face.aligned_face + face.aligned = dict() + else: + image = face.thumbnail + tk_face = self._get_tk_face_object(face, image, is_active) + self._tk_faces[key] = tk_face + else: + logger.trace("tk_face exists: %s", key) + tk_face = self._tk_faces[key] + return tk_face + + def _get_tk_face_object(self, face, image, is_active): + """ Obtain an existing unallocated, or a newly created :class:`TKFace` and populate it with + face information from the requested frame and face index. + + If the face is currently active, then the face is generated from the currently displayed + frame, otherwise it is generated from the jpg thumbnail. + + Parameters + ---------- + face: :class:`lib.faces_detect.DetectedFace` + A detected face object to create the :class:`TKFace` from + image: :class:`numpy.ndarray` + The jpg thumbnail or the 3 channel image for the face + is_active: bool + ``True`` if the face in the currently active frame otherwise ``False`` + + Returns + ------- + :class:`TKFace` + An object for displaying in the faces viewer canvas populated with the aligned face + image with a mask applied, if required. + """ + get_mask = (self._canvas.optional_annotations["mask"] or + (is_active and self.selected_editor == "mask")) + mask = face.mask.get(self._canvas.selected_mask, None) if get_mask else None + mask = mask if mask is None else mask.mask.squeeze() + tk_face = TKFace(image, size=self.face_size, mask=mask) + logger.trace("face: %s, tk_face: %s", face, tk_face) + return tk_face + + def get_landmarks(self, frame_index, face_index, face, top_left, refresh=False): + """ Obtain the landmark points for each mesh annotation. + + First tries to obtain the aligned landmarks from the cache. If the landmarks do not exist + in the cache, or a refresh has been requested, then the landmarks are calculated from the + detected face object. + + Parameters + ---------- + frame_index: int + The frame index to obtain the face for + face_index: int + The face index of the face within the requested frame + top_left: tuple + The top left (x, y) points of the face's bounding box within the viewport + refresh: bool, optional + Whether to force a reload of the face's aligned landmarks, even if they already exist + within the cache. Default: ``False`` + + Returns + ------- + dict + The key is the tkinter canvas object type for each part of the mesh annotation + (`polygon`, `line`). The value is a list containing the (x, y) coordinates of each + part of the mesh annotation, from the top left corner location. + """ + key = "{}_{}".format(frame_index, face_index) + landmarks = self._landmarks.get(key, None) + if not landmarks or refresh: + face.load_aligned(None, size=self.face_size, force=True) + landmarks = dict(polygon=[], line=[]) + for area, val in self._landmark_mapping.items(): + points = face.aligned_landmarks[val[0]:val[1]] + top_left + shape = "polygon" if area.endswith("eye") or area.startswith("mouth") else "line" + landmarks[shape].append(points) + self._landmarks[key] = landmarks + return landmarks + + def _locate_mesh(self, mesh_ids, landmarks): + """ Place the mesh annotation canvas objects in the correct location. + + Parameters + ---------- + mesh_ids: list + The list of mesh id objects to set coordinates for + landmarks: dict + The mesh point groupings and whether each group should be a line or a polygon + """ + for key, area in landmarks.items(): + for coords, mesh_id in zip(area, mesh_ids[key]): + self._canvas.coords(mesh_id, *coords.flatten()) + + def face_from_point(self, point_x, point_y): + """ Given an (x, y) point on the :class:`Viewport`, obtain the face information at that + location. + + Parameters + ---------- + point_x: int + The x position on the canvas of the point to retrieve the face for + point_y: int + The y position on the canvas of the point to retrieve the face for + + Returns + ------- + :class:`numpy.ndarray` + Array of shape (4, ) containing the (`frame index`, `face index`, `x_point of top left + corner`, `y point of top left corner`) of the face at the given coordinates. + + If the given coordinates are not over a face, then the frame and face indices will be + -1 + """ + if point_x > self._grid.dimensions[0]: + retval = np.array((-1, -1, -1, -1)) + else: + x_idx = np.searchsorted(self._objects.visible_grid[2, 0, :], point_x, side="left") - 1 + y_idx = np.searchsorted(self._objects.visible_grid[3, :, 0], point_y, side="left") - 1 + if x_idx < 0 or y_idx < 0: + retval = np.array((-1, -1, -1, -1)) + else: + retval = self._objects.visible_grid[:, y_idx, x_idx] + logger.trace(retval) + return retval + + def move_active_to_top(self): + """ Check whether the active frame is going off the bottom of the viewport, if so: move it + to the top of the viewport. """ + self._active_frame.move_to_top() + + +class VisibleObjects(): + """ Holds the objects from the :class:`~tools.manual.faceviewer.frame.Grid` that appear in the + viewable area of the :class:`Viewport`. + + Parameters + ---------- + viewport: :class:`Viewport` + The viewport object for the :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas + """ + def __init__(self, viewport): + self._viewport = viewport + self._canvas = viewport._canvas + self._grid = viewport._grid + self._size = viewport.face_size + + self._visible_grid = None + self._visible_faces = None + self._images = [] + self._meshes = [] + self._recycled = dict(images=[], meshes=[]) + + @property + def visible_grid(self): + """ :class:`numpy.ndarray`: The currently visible section of the + :class:`~tools.manual.faceviewer.frame.Grid` + + A numpy array of shape (`4`, `rows`, `columns`) corresponding to the viewable area of the + display grid. 1st dimension contains frame indices, 2nd dimension face indices. The 3rd and + 4th dimension contain the x and y position of the top left corner of the face respectively. + + Any locations that are not populated by a face will have a frame and face index of -1. """ + return self._visible_grid + + @property + def visible_faces(self): + """ :class:`numpy.ndarray`: The currently visible :class:`~lib.faces_detect.DetectedFace` + objects. + + A numpy array of shape (`rows`, `columns`) corresponding to the viewable area of the + display grid and containing the detected faces at their currently viewable position. + + Any locations that are not populated by a face will have ``None`` in it's place. """ + return self._visible_faces + + @property + def images(self): + """ :class:`numpy.ndarray`: The viewport's tkinter canvas image objects. + + A numpy array of shape (`rows`, `columns`) corresponding to the viewable area of the + display grid and containing the tkinter canvas image object for the face at the + corresponding location. """ + return self._images + + @property + def meshes(self): + """ :class:`numpy.ndarray`: The viewport's tkinter canvas mesh annotation objects. + + A numpy array of shape (`rows`, `columns`) corresponding to the viewable area of the + display grid and containing a dictionary of the corresponding tkinter polygon and line + objects required to build a face's mesh annotation for the face at the corresponding + location. """ + return self._meshes + + @property + def _top_left(self): + """ :class:`numpy.ndarray`: The canvas (`x`, `y`) position of the face currently in the + viewable area's top left position. """ + return np.array(self._canvas.coords(self._images[0][0]), dtype="int") + + def update(self): + """ Load and unload thumbnails in the visible area of the faces viewer. """ + self._visible_grid, self._visible_faces = self._grid.visible_area + if (isinstance(self._images, np.ndarray) and + self._visible_grid.shape[-1] != self._images.shape[-1]): + self._recycle_objects() + + required_rows = self._visible_grid.shape[1] if self._grid.is_valid else 0 + existing_rows = len(self._images) + logger.trace("existing_rows: %s. required_rows: %s", existing_rows, required_rows) + + if existing_rows > required_rows: + for image_id in self._images[required_rows: existing_rows].flatten(): + logger.trace("Hiding image id: %s", image_id) + self._canvas.itemconfig(image_id, image="") + + if existing_rows < required_rows: + self._add_rows(existing_rows, required_rows) + + self._shift() + + def _recycle_objects(self): + """ On a column count change, place all existing objects into the recycle bin so that + they can be used for the new grid shape and reset the objects size to the new size. """ + self._size = self._viewport.face_size + images = self._images.flatten().tolist() + meshes = self._meshes.flatten().tolist() + + for image_id in images: + self._canvas.itemconfig(image_id, image="") + self._canvas.coords(image_id, 0, 0) + for mesh in meshes: + for key, mesh_ids in mesh.items(): + coords = (0, 0, 0, 0) if key == "line" else (0, 0) + for mesh_id in mesh_ids: + self._canvas.coords(mesh_id, *coords) + + self._recycled["images"].extend(images) + self._recycled["meshes"].extend(meshes) + logger.trace("Recycled objects: %s", self._recycled) + + self._images = [] + self._meshes = [] + + def _add_rows(self, existing_rows, required_rows): + """ Add rows to the viewport. + + Parameters + ---------- + existing_rows: int + The number of existing rows within the viewport + required_rows: int + The number of rows required by the viewport + """ + columns = self._grid.columns_rows[0] + if not isinstance(self._images, np.ndarray): + base_coords = [(col * self._size, 0) for col in range(columns)] + else: + base_coords = [self._canvas.coords(item_id) for item_id in self._images[0]] + logger.debug("existing rows: %s, required_rows: %s, base_coords: %s", + existing_rows, required_rows, base_coords) + images = [] + meshes = [] + for row in range(existing_rows, required_rows): + y_coord = base_coords[0][1] + (row * self._size) + images.append(np.array([self._get_image((coords[0], y_coord)) + for coords in base_coords])) + meshes.append(np.array([self._get_mesh() for _ in range(columns)])) + images = np.array(images) + meshes = np.array(meshes) + + if not isinstance(self._images, np.ndarray): + logger.debug("Adding initial viewport objects: (image shapes: %s, mesh shapes: %s)", + images.shape, meshes.shape) + self._images = images + self._meshes = meshes + else: + logger.debug("Adding new viewport objects: (image shapes: %s, mesh shapes: %s)", + images.shape, meshes.shape) + self._images = np.concatenate((self._images, images)) + self._meshes = np.concatenate((self._meshes, meshes)) + logger.debug("self._images: %s, self._meshes: %s", self._images.shape, self._meshes.shape) + + def _get_image(self, coordinates): + """ Create or recycle a tkinter canvas image object with the given coordinates. + + Parameters + ---------- + coordinates: tuple + The (`x`, `y`) coordinates for the top left corner of the image + + Returns + ------- + int + The canvas object id for the created image + """ + if self._recycled["images"]: + image_id = self._recycled["images"].pop() + self._canvas.coords(image_id, *coordinates) + logger.trace("Recycled image: %s", image_id) + else: + image_id = self._canvas.create_image(*coordinates, + anchor=tk.NW, + tags=["viewport", "viewport_image"]) + logger.trace("Created new image: %s", image_id) + return image_id + + def _get_mesh(self): + """ Get the mesh annotation for the landmarks. This is made up of a series of polygons + or lines, depending on which part of the face is being annotated. Creates a new series of + objects, or pulls existing objects from the recycled objects pool if they are available. + + Returns + ------- + dict + The dictionary of line and polygon tkinter canvas object ids for the mesh annotation + """ + kwargs = self._viewport.mesh_kwargs + logger.trace("self.mesh_kwargs: %s", kwargs) + if self._recycled["meshes"]: + mesh = self._recycled["meshes"].pop() + for key, mesh_ids in mesh.items(): + for mesh_id in mesh_ids: + self._canvas.itemconfig(mesh_id, **kwargs[key]) + logger.trace("Recycled mesh: %s", mesh) + else: + tags = ["viewport", "viewport_mesh"] + mesh = dict(polygon=[self._canvas.create_polygon(0, 0, + width=1, + tags=tags + ["viewport_polygon"], + **kwargs["polygon"]) + for _ in range(4)], + line=[self._canvas.create_line(0, 0, 0, 0, + width=1, + tags=tags + ["viewport_line"], + **kwargs["line"]) + for _ in range(5)]) + logger.trace("Created new mesh: %s", mesh) + return mesh + + def _shift(self): + """ Shift the viewport in the y direction if required + + Returns + ------- + bool + ``True`` if the viewport was shifted otherwise ``False`` + """ + current_y = self._top_left[1] + required_y = self._visible_grid[3, 0, 0] if self._grid.is_valid else 0 + logger.trace("current_y: %s, required_y: %s", current_y, required_y) + if current_y == required_y: + logger.trace("No move required") + return False + shift_amount = required_y - current_y + logger.trace("Shifting viewport: %s", shift_amount) + self._canvas.move("viewport", 0, shift_amount) + return True + + +class HoverBox(): # pylint:disable=too-few-public-methods + """ Handle the current mouse location when over the :class:`Viewport`. + + Highlights the face currently underneath the cursor and handles actions when clicking + on a face. + + Parameters + ---------- + viewport: :class:`Viewport` + The viewport object for the :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas + """ + def __init__(self, viewport): + logger.debug("Initializing: %s (viewport: %s)", self.__class__.__name__, viewport) + self._viewport = viewport + self._canvas = viewport._canvas + self._grid = viewport._canvas.grid + self._globals = viewport._canvas._globals + self._navigation = viewport._canvas._display_frame.navigation + self._box = self._canvas.create_rectangle(0, 0, self._size, self._size, + outline="#0000ff", + width=2, + state="hidden", + fill="#0000ff", + stipple="gray12", + tags="hover_box") + self._current_frame_index = None + self._current_face_index = None + self._canvas.bind("", lambda e: self._clear()) + self._canvas.bind("", self.on_hover) + self._canvas.bind("", lambda e: self._select_frame()) + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def _size(self): + """ int: the currently set viewport face size in pixels. """ + return self._viewport.face_size + + def on_hover(self, event): + """ Highlight the face and set the mouse cursor for the mouse's current location. + + Parameters + ---------- + event: :class:`tkinter.Event` or ``None`` + The tkinter mouse event. Provides the current location of the mouse cursor. If ``None`` + is passed as the event (for example when this function is being called outside of a + mouse event) then the location of the cursor will be calculated + """ + if event is None: + pnts = np.array((self._canvas.winfo_pointerx(), self._canvas.winfo_pointery())) + pnts -= np.array((self._canvas.winfo_rootx(), self._canvas.winfo_rooty())) + else: + pnts = (event.x, event.y) + + coords = (int(self._canvas.canvasx(pnts[0])), int(self._canvas.canvasy(pnts[1]))) + face = self._viewport.face_from_point(*coords) + frame_idx, face_idx = face[:2] + is_zoomed = self._globals.is_zoomed + + if (-1 in face or (frame_idx == self._globals.frame_index + and (not is_zoomed or + (is_zoomed and face_idx == self._globals.tk_face_index.get())))): + self._clear() + self._canvas.config(cursor="") + self._current_frame_index = None + self._current_face_index = None + return + + self._canvas.config(cursor="hand2") + self._highlight(face[2:]) + self._current_frame_index = frame_idx + self._current_face_index = face_idx + + def _clear(self): + """ Hide the hover box when the mouse is not over a face. """ + if self._canvas.itemcget(self._box, "state") != "hidden": + self._canvas.itemconfig(self._box, state="hidden") + + def _highlight(self, top_left): + """ Display the hover box around the face that the mouse is currently over. + + Parameters + ---------- + top_left: tuple + The top left point of the highlight box location + """ + coords = (*top_left, *top_left + self._size) + self._canvas.coords(self._box, *coords) + self._canvas.itemconfig(self._box, state="normal") + self._canvas.tag_raise(self._box) + + def _select_frame(self): + """ Select the face and the subsequent frame (in the editor view) when a face is clicked + on in the :class:`Viewport`. + """ + frame_id = self._current_frame_index + is_zoomed = self._globals.is_zoomed + if frame_id is None or (frame_id == self._globals.frame_index and not is_zoomed): + return + face_idx = self._current_face_index if is_zoomed else 0 + self._globals.tk_face_index.set(face_idx) + transport_id = self._grid.transport_index_from_frame(frame_id) + logger.trace("frame_index: %s, transport_id: %s, face_idx: %s", + frame_id, transport_id, face_idx) + if transport_id is None: + return + self._navigation.stop_playback() + self._globals.tk_transport_index.set(transport_id) + self._viewport.move_active_to_top() + self.on_hover(None) + + +class ActiveFrame(): + """ Handles the display of faces and annotations for the currently active frame. + + Parameters + ---------- + canvas: :class:`tkinter.Canvas` + The :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas + tk_edited_variable: :class:`tkinter.BooleanVar` + The tkinter callback variable indicating that a face has been edited + """ + def __init__(self, viewport, tk_edited_variable): + logger.debug("Initializing: %s (viewport: %s, tk_edited_variable: %s)", + self.__class__.__name__, viewport, tk_edited_variable) + self._objects = viewport._objects + self._viewport = viewport + self._grid = viewport._grid + self._tk_faces = viewport._tk_faces + self._canvas = viewport._canvas + self._globals = viewport._canvas._globals + self._navigation = viewport._canvas._display_frame.navigation + self._last_execution = dict(frame_index=-1, size=viewport.face_size) + self._tk_vars = dict(selected_editor=self._canvas._display_frame.tk_selected_action, + edited=tk_edited_variable) + self._assets = dict(images=[], meshes=[], faces=[], boxes=[]) + + self._globals.tk_update_active_viewport.trace("w", lambda *e: self._reload_callback()) + tk_edited_variable.trace("w", lambda *e: self._update_on_edit()) + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def frame_index(self): + """ int: The frame index of the currently displayed frame. """ + return self._globals.frame_index + + @property + def current_frame(self): + """ :class:`numpy.ndarray`: A BGR version of the frame currently being displayed. """ + return self._globals.current_frame["image"] + + @property + def _size(self): + """ int: The size of the thumbnails displayed in the viewport, in pixels. """ + return self._viewport.face_size + + @property + def _optional_annotations(self): + """ dict: The currently selected optional annotations """ + return self._canvas.optional_annotations + + def _reload_callback(self): + """ If a frame has changed, triggering the variable, then update the active frame. Return + having done nothing if the variable is resetting. """ + if self._globals.tk_update_active_viewport.get(): + self.reload_annotations() + + def reload_annotations(self): + """ Handles the reloading of annotations for the currently active faces. + + Highlights the faces within the viewport of those faces that exist in the currently + displaying frame. Applies annotations based on the optional annotations and current + editor selections. + """ + logger.trace("Reloading annotations") + if np.any(self._assets["images"]): + self._clear_previous() + + self._set_active_objects() + self._check_active_in_view() + + if not np.any(self._assets["images"]): + logger.trace("No active faces. Returning") + self._last_execution["frame_index"] = self.frame_index + return + + if self._last_execution["frame_index"] != self.frame_index: + self.move_to_top() + self._create_new_boxes() + + self._update_face() + self._canvas.tag_raise("active_highlighter") + self._globals.tk_update_active_viewport.set(False) + self._last_execution["frame_index"] = self.frame_index + + def _clear_previous(self): + """ Reverts the previously selected annotations to their default state. """ + logger.trace("Clearing previous active frame") + self._canvas.itemconfig("active_highlighter", state="hidden") + + for key in ("polygon", "line"): + tag = "active_mesh_{}".format(key) + self._canvas.itemconfig(tag, **self._viewport.mesh_kwargs[key]) + self._canvas.dtag(tag) + + if self._viewport.selected_editor == "mask" and not self._optional_annotations["mask"]: + for key, tk_face in self._tk_faces.items(): + if key.startswith("{}_".format(self._last_execution["frame_index"])): + tk_face.update_mask(None) + + def _set_active_objects(self): + """ Collect the objects that exist in the currently active frame from the main grid. """ + if self._grid.is_valid: + rows, cols = np.where(self._objects.visible_grid[0] == self.frame_index) + logger.trace("Setting active objects: (rows: %s, columns: %s)", rows, cols) + self._assets["images"] = self._objects.images[rows, cols] + self._assets["meshes"] = self._objects.meshes[rows, cols] + self._assets["faces"] = self._objects.visible_faces[rows, cols] + else: + logger.trace("No valid grid. Clearing active objects") + self._assets["images"] = [] + self._assets["meshes"] = [] + self._assets["faces"] = [] + + def _check_active_in_view(self): + """ If the frame has changed, there are faces in the frame, but they don't appear in the + viewport, then bring the active faces to the top of the viewport. """ + if (not np.any(self._assets["images"]) and + self._last_execution["frame_index"] != self.frame_index and + self._grid.frame_has_faces(self.frame_index)): + y_coord = self._grid.y_coord_from_frame(self.frame_index) + logger.trace("Active not in view. Moving to: %s", y_coord) + self._canvas.yview_moveto(y_coord / self._canvas.bbox("backdrop")[3]) + self._viewport.update() + + def move_to_top(self): + """ Move the currently selected frame's faces to the top of the viewport if they are moving + off the bottom of the viewer. """ + height = self._canvas.bbox("backdrop")[3] + bot = int(self._canvas.coords(self._assets["images"][-1])[1] + self._size) + + y_top, y_bot = (int(round(pnt * height)) for pnt in self._canvas.yview()) + + if y_top < bot < y_bot: # bottom face is still in fully visible area + logger.trace("Active faces in frame. Returning") + return + + top = int(self._canvas.coords(self._assets["images"][0])[1]) + if y_top == top: + logger.trace("Top face already on top row. Returning") + return + + if self._canvas.winfo_height() > self._size: + logger.trace("Viewport taller than single face height. Moving Active faces to top: %s", + top) + self._canvas.yview_moveto(top / height) + self._viewport.update() + elif self._canvas.winfo_height() <= self._size and y_top != top: + logger.trace("Viewport shorter than single face height. Moving Active faces to " + "top: %s", top) + self._canvas.yview_moveto(top / height) + self._viewport.update() + + def _create_new_boxes(self): + """ The highlight boxes (border around selected faces) are the only additional annotations + that are required for the highlighter. If more faces are displayed in the current frame + than highlight boxes are available, then new boxes are created to accommodate the + additional faces. """ + new_boxes_count = max(0, len(self._assets["images"]) - len(self._assets["boxes"])) + if new_boxes_count == 0: + return + logger.debug("new_boxes_count: %s", new_boxes_count) + for _ in range(new_boxes_count): + box = self._canvas.create_rectangle(0, + 0, + self._viewport.face_size, self._viewport.face_size, + outline="#00FF00", + width=2, + state="hidden", + tags=["active_highlighter"]) + logger.trace("Created new highlight_box: %s", box) + self._assets["boxes"].append(box) + + def _update_on_edit(self): + """ Update the active faces on a frame edit. """ + if not self._tk_vars["edited"].get(): + return + self._set_active_objects() + self._update_face() + self._tk_vars["edited"].set(False) + + def _update_face(self): + """ Update the highlighted annotations for faces in the currently selected frame. """ + for face_idx, (image_id, mesh_ids, box_id, det_face), in enumerate( + zip(self._assets["images"], + self._assets["meshes"], + self._assets["boxes"], + self._assets["faces"])): + top_left = np.array(self._canvas.coords(image_id)) + coords = (*top_left, *top_left + self._size) + tk_face = self._viewport.get_tk_face(self.frame_index, face_idx, det_face) + self._canvas.itemconfig(image_id, image=tk_face.photo) + self._show_box(box_id, coords) + self._show_mesh(mesh_ids, face_idx, det_face, top_left) + self._last_execution["size"] = self._viewport.face_size + + def _show_box(self, item_id, coordinates): + """ Display the highlight box around the given coordinates. + + Parameters + ---------- + item_id: int + The tkinter canvas object identifier for the highlight box + coordinates: :class:`numpy.ndarray` + The (x, y, x1, y1) coordinates of the top left corner of the box + """ + self._canvas.coords(item_id, *coordinates) + self._canvas.itemconfig(item_id, state="normal") + + def _show_mesh(self, mesh_ids, face_index, detected_face, top_left): + """ Display the mesh annotation for the given face, at the given location. + + Parameters + ---------- + mesh_ids: dict + Dictionary containing the `polygon` and `line` tkinter canvas identifiers that make up + the mesh for the given face + face_index: int + The face index within the frame for the given face + detected_face: :class:`~lib.faces_detect.DetectedFace` + The detected face object that contains the landmarks for generating the mesh + top_left: tuple + The (x, y) top left co-ordinates of the mesh's bounding box + """ + state = "normal" if (self._tk_vars["selected_editor"].get() != "Mask" or + self._optional_annotations["mesh"]) else "hidden" + kwargs = dict(polygon=dict(fill="", outline=self._canvas.control_colors["Mesh"]), + line=dict(fill=self._canvas.control_colors["Mesh"])) + + edited = (self._tk_vars["edited"].get() and + self._tk_vars["selected_editor"].get() not in ("Mask", "View")) + relocate = self._viewport.face_size != self._last_execution["size"] or ( + state == "normal" and not self._optional_annotations["mesh"]) + if relocate or edited: + landmarks = self._viewport.get_landmarks(self.frame_index, + face_index, + detected_face, + top_left, + edited) + for key, kwarg in kwargs.items(): + for idx, mesh_id in enumerate(mesh_ids[key]): + if relocate: + self._canvas.coords(mesh_id, *landmarks[key][idx].flatten()) + self._canvas.itemconfig(mesh_id, state=state, **kwarg) + self._canvas.addtag_withtag("active_mesh_{}".format(key), mesh_id) + + +class TKFace(): + """ An object that holds a single :class:`tkinter.PhotoImage` face, ready for placement in the + :class:`Viewport`, Handles the placement of and removal of masks for the face as well as + updates on any edits. + + Parameters + ---------- + face: :class:`numpy.ndarray` + The face, sized correctly as a 3 channel BGR image or an encoded jpg to create a + :class:`tkinter.PhotoImage` from + size: int, optional + The pixel size of the face image. Default: `128` + mask: :class:`numpy.ndarray` or ``None``, optional + The mask to be applied to the face image. Pass ``None`` if no mask is to be used. + Default ``None`` + """ + def __init__(self, face, size=128, mask=None): + logger.trace("Initializing %s: (face: %s, size: %s, mask: %s)", + self.__class__.__name__, + face if face is None else face.shape, + size, + mask if mask is None else mask.shape) + self._size = size + if face.ndim == 2 and face.shape[1] == 1: + self._face = self._image_from_jpg(face) + else: + self._face = face[..., 2::-1] + self._photo = ImageTk.PhotoImage(self._generate_tk_face_data(mask)) + + logger.trace("Initialized %s", self.__class__.__name__) + + # << PUBLIC PROPERTIES >> # + @property + def photo(self): + """ :class:`tkinter.PhotoImage`: The face in a format that can be placed on the + :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas. """ + return self._photo + + # << PUBLIC METHODS >> # + def update(self, face, mask): + """ Update the :attr:`photo` with the given face and mask. + + Parameters + ---------- + face: :class:`numpy.ndarray` + The face, sized correctly as a 3 channel BGR image + mask: :class:`numpy.ndarray` or ``None`` + The mask to be applied to the face image. Pass ``None`` if no mask is to be used + """ + self._face = face[..., 2::-1] + self._photo.paste(self._generate_tk_face_data(mask)) + + def update_mask(self, mask): + """ Update the mask in the 4th channel of :attr:`photo` to the given mask. + + Parameters + ---------- + mask: :class:`numpy.ndarray` or ``None`` + The mask to be applied to the face image. Pass ``None`` if no mask is to be used + """ + self._photo.paste(self._generate_tk_face_data(mask)) + + # << PRIVATE METHODS >> # + def _image_from_jpg(self, face): + """ Convert an encoded jpg into 3 channel BGR image. + + Parameters + ---------- + face: :class:`numpy.ndarray` + The encoded jpg as a two dimension numpy array + + Returns + ------- + :class:`numpy.ndarray` + The decoded jpg as a 3 channel BGR image + """ + face = cv2.imdecode(face, cv2.IMREAD_UNCHANGED) + interp = cv2.INTER_CUBIC if face.shape[0] < self._size else cv2.INTER_AREA + if face.shape[0] != self._size: + face = cv2.resize(face, (self._size, self._size), interpolation=interp) + return face[..., 2::-1] + + def _generate_tk_face_data(self, mask): + """ Create the :class:`tkinter.PhotoImage` from the currant :attr:`_face`. + + Parameters + ---------- + mask: :class:`numpy.ndarray` or ``None`` + The mask to add to the image. ``None`` if a mask is not being used + + Returns + ------- + :class:`tkinter.PhotoImage` + The face formatted for the :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas. + """ + mask = np.ones(self._face.shape[:2], dtype="uint8") * 255 if mask is None else mask + if mask.shape[0] != self._size: + mask = cv2.resize(mask, self._face.shape[:2], interpolation=cv2.INTER_AREA) + img = np.concatenate((self._face, mask[..., None]), axis=-1) + return Image.fromarray(img) diff --git a/tools/manual/frameviewer/__init__.py b/tools/manual/frameviewer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/manual/frameviewer/control.py b/tools/manual/frameviewer/control.py new file mode 100644 index 0000000000..6d76ea82bf --- /dev/null +++ b/tools/manual/frameviewer/control.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +""" Handles Navigation and Background Image for the Frame Viewer section of the manual +tool GUI. """ + +import logging +import tkinter as tk + +import cv2 +import numpy as np +from PIL import Image, ImageTk + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class Navigation(): + """ Handles playback and frame navigation for the Frame Viewer Window. + + Parameters + ---------- + display_frame: :class:`DisplayFrame` + The parent frame viewer window + """ + def __init__(self, display_frame): + logger.debug("Initializing %s", self.__class__.__name__) + self._globals = display_frame._globals + self._det_faces = display_frame._det_faces + self._nav = display_frame._nav + self._tk_is_playing = tk.BooleanVar() + self._tk_is_playing.set(False) + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def _current_nav_frame_count(self): + """ int: The current frame count for the transport slider """ + return self._nav["scale"].cget("to") + 1 + + def nav_scale_callback(self, *args, reset_progress=True): # pylint:disable=unused-argument + """ Adjust transport slider scale for different filters. + + Returns + ------- + bool + ``True`` if the navigation scale has been updated otherwise ``False`` + """ + if reset_progress: + self.stop_playback() + frame_count = self._det_faces.filter.count + if self._current_nav_frame_count == frame_count: + logger.trace("Filtered count has not changed. Returning") + return False + max_frame = max(0, frame_count - 1) + logger.debug("Filtered frame count has changed. Updating from %s to %s", + self._current_nav_frame_count, frame_count) + self._nav["scale"].config(to=max_frame) + self._nav["label"].config(text="/{}".format(max_frame)) + state = "disabled" if max_frame == 0 else "normal" + self._nav["entry"].config(state=state) + if reset_progress: + self._globals.tk_transport_index.set(0) + return True + + @property + def tk_is_playing(self): + """ :class:`tkinter.BooleanVar`: Whether the stream is currently playing. """ + return self._tk_is_playing + + def handle_play_button(self): + """ Handle the play button. + + Switches the :attr:`tk_is_playing` variable. + """ + is_playing = self.tk_is_playing.get() + self.tk_is_playing.set(not is_playing) + + def stop_playback(self): + """ Stop play back if playing """ + if self.tk_is_playing.get(): + logger.trace("Stopping playback") + self.tk_is_playing.set(False) + + def increment_frame(self, frame_count=None, is_playing=False): + """ Update The frame navigation position to the next frame based on filter. """ + if not is_playing: + self.stop_playback() + position = self._globals.tk_transport_index.get() + face_count_change = self._check_face_count_change() + if face_count_change: + position -= 1 + frame_count = self._det_faces.filter.count if frame_count is None else frame_count + if not face_count_change and (frame_count == 0 or position == frame_count - 1): + logger.debug("End of Stream. Not incrementing") + self.stop_playback() + return + self._globals.tk_transport_index.set(min(position + 1, max(0, frame_count - 1))) + + def decrement_frame(self): + """ Update The frame navigation position to the previous frame based on filter. """ + self.stop_playback() + position = self._globals.tk_transport_index.get() + face_count_change = self._check_face_count_change() + if face_count_change: + position += 1 + if not face_count_change and (self._det_faces.filter.count == 0 or position == 0): + logger.debug("End of Stream. Not incrementing") + return + self._globals.tk_transport_index.set(min(max(0, self._det_faces.filter.count - 1), + max(0, position - 1))) + + def _check_face_count_change(self): + """ Check whether the face count for the current filter has changed, and update the + transport scale appropriately. + + Perform additional check on whether the current frame still meets the selected navigation + mode filter criteria. + + Returns + ------- + bool + ``True`` if the currently active frame no longer meets the filter criteria otherwise + ``False`` + """ + filter_mode = self._globals.filter_mode + if filter_mode not in ("No Faces", "Multiple Faces"): + return False + if not self.nav_scale_callback(reset_progress=False): + return False + face_count = len(self._det_faces.current_faces[self._globals.frame_index]) + if (filter_mode == "No Faces" and face_count != 0) or (filter_mode == "Multiple Faces" + and face_count < 2): + return True + return False + + def goto_first_frame(self): + """ Go to the first frame that meets the filter criteria. """ + self.stop_playback() + position = self._globals.tk_transport_index.get() + if position == 0: + return + self._globals.tk_transport_index.set(0) + + def goto_last_frame(self): + """ Go to the last frame that meets the filter criteria. """ + self.stop_playback() + position = self._globals.tk_transport_index.get() + frame_count = self._det_faces.filter.count + if position == frame_count - 1: + return + self._globals.tk_transport_index.set(frame_count - 1) + + +class BackgroundImage(): + """ The background image of the canvas """ + def __init__(self, canvas): + self._canvas = canvas + self._globals = canvas._globals + self._det_faces = canvas._det_faces + placeholder = np.ones((*reversed(self._globals.frame_display_dims), 3), dtype="uint8") + self._tk_frame = ImageTk.PhotoImage(Image.fromarray(placeholder)) + self._tk_face = ImageTk.PhotoImage(Image.fromarray(placeholder)) + self._image = self._canvas.create_image(self._globals.frame_display_dims[0] / 2, + self._globals.frame_display_dims[1] / 2, + image=self._tk_frame, + anchor=tk.CENTER, + tags="main_image") + + @property + def _current_view_mode(self): + """ str: `frame` if global zoom mode variable is set to ``False`` other wise `face`. """ + retval = "face" if self._globals.is_zoomed else "frame" + logger.trace(retval) + return retval + + def refresh(self, view_mode): + """ Update the displayed frame. + + Parameters + ---------- + view_mode: ["frame", "face"] + The currently active editor's selected view mode. + """ + self._switch_image(view_mode) + getattr(self, "_update_tk_{}".format(self._current_view_mode))() + logger.trace("Updating background frame") + + def _switch_image(self, view_mode): + """ Switch the image between the full frame image and the zoomed face image. + + Parameters + ---------- + view_mode: ["frame", "face"] + The currently active editor's selected view mode. + """ + if view_mode == self._current_view_mode: + return + logger.trace("Switching background image from '%s' to '%s'", + self._current_view_mode, view_mode) + img = getattr(self, "_tk_{}".format(view_mode)) + self._canvas.itemconfig(self._image, image=img) + self._globals.tk_is_zoomed.set(view_mode == "face") + self._globals.tk_face_index.set(0) + + def _update_tk_face(self): + """ Update the currently zoomed face. """ + face = self._get_zoomed_face() + padding = self._get_padding((min(self._globals.frame_display_dims), + min(self._globals.frame_display_dims))) + face = cv2.copyMakeBorder(face, *padding, cv2.BORDER_CONSTANT) + if self._tk_frame.height() != face.shape[0]: + self._resize_frame() + + logger.trace("final shape: %s", face.shape) + self._tk_face.paste(Image.fromarray(face)) + + def _get_zoomed_face(self): + """ Get the zoomed face or a blank image if no faces are available. + + Returns + ------- + :class:`numpy.ndarray` + The face sized to the shortest dimensions of the face viewer + """ + frame_idx = self._globals.frame_index + face_idx = self._globals.face_index + faces_in_frame = self._det_faces.face_count_per_index[frame_idx] + size = min(self._globals.frame_display_dims) + + if face_idx + 1 > faces_in_frame: + logger.debug("Resetting face index to 0 for more faces in frame than current index: (" + "faces_in_frame: %s, zoomed_face_index: %s", faces_in_frame, face_idx) + self._globals.tk_face_index.set(0) + + if faces_in_frame == 0: + face = np.ones((size, size, 3), dtype="uint8") + else: + det_face = self._det_faces.current_faces[frame_idx][face_idx] + det_face.load_aligned(self._globals.current_frame["image"], size=size, force=True) + face = det_face.aligned_face.copy() + det_face.aligned["image"] = None + + logger.trace("face shape: %s", face.shape) + return face[..., 2::-1] + + def _update_tk_frame(self): + """ Place the currently held frame into :attr:`_tk_frame`. """ + img = cv2.resize(self._globals.current_frame["image"], + self._globals.current_frame["display_dims"], + interpolation=self._globals.current_frame["interpolation"])[..., 2::-1] + padding = self._get_padding(img.shape[:2]) + if any(padding): + img = cv2.copyMakeBorder(img, *padding, cv2.BORDER_CONSTANT) + logger.trace("final shape: %s", img.shape) + + if self._tk_frame.height() != img.shape[0]: + self._resize_frame() + + self._tk_frame.paste(Image.fromarray(img)) + + def _get_padding(self, size): + """ Obtain the Left, Top, Right, Bottom padding required to place the square face or frame + in to the Photo Image + + Returns + ------- + tuple + The (Left, Top, Right, Bottom) padding to apply to the face image in pixels + """ + pad_lt = ((self._globals.frame_display_dims[1] - size[0]) // 2, + (self._globals.frame_display_dims[0] - size[1]) // 2) + padding = (pad_lt[0], + self._globals.frame_display_dims[1] - size[0] - pad_lt[0], + pad_lt[1], + self._globals.frame_display_dims[0] - size[1] - pad_lt[1]) + logger.debug("Frame dimensions: %s, size: %s, padding: %s", + self._globals.frame_display_dims, size, padding) + return padding + + def _resize_frame(self): + """ Resize the :attr:`_tk_frame`, attr:`_tk_face` photo images, update the canvas to + offset the image correctly. + """ + logger.trace("Resizing video frame on resize event: %s", self._globals.frame_display_dims) + placeholder = np.ones((*reversed(self._globals.frame_display_dims), 3), dtype="uint8") + self._tk_frame = ImageTk.PhotoImage(Image.fromarray(placeholder)) + self._tk_face = ImageTk.PhotoImage(Image.fromarray(placeholder)) + self._canvas.coords(self._image, + self._globals.frame_display_dims[0] / 2, + self._globals.frame_display_dims[1] / 2) + img = self._tk_face if self._current_view_mode == "face" else self._tk_frame + self._canvas.itemconfig(self._image, image=img) diff --git a/tools/manual/frameviewer/editor/__init__.py b/tools/manual/frameviewer/editor/__init__.py new file mode 100644 index 0000000000..8a7244abe4 --- /dev/null +++ b/tools/manual/frameviewer/editor/__init__.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +""" The Frame Viewer for Faceswap's Manual Tool. """ + +from ._base import View # noqa +from .bounding_box import BoundingBox # noqa +from .extract_box import ExtractBox # noqa +from .landmarks import Landmarks, Mesh # noqa +from .mask import Mask # noqa diff --git a/tools/manual/frameviewer/editor/_base.py b/tools/manual/frameviewer/editor/_base.py new file mode 100644 index 0000000000..0aa117453e --- /dev/null +++ b/tools/manual/frameviewer/editor/_base.py @@ -0,0 +1,623 @@ +#!/usr/bin/env python3 +""" Editor objects for the manual adjustments tool """ + +import logging +import tkinter as tk + +from collections import OrderedDict + +import numpy as np + +from lib.gui.control_helper import ControlPanelOption + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class Editor(): + """ Parent Class for Object Editors. + + Editors allow the user to use a variety of tools to manipulate alignments from the main + display frame. + + Parameters + ---------- + canvas: :class:`tkinter.Canvas` + The canvas that holds the image and annotations + detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` + The _detected_faces data for this manual session + control_text: str + The text that is to be displayed at the top of the Editor's control panel. + """ + def __init__(self, canvas, detected_faces, control_text="", key_bindings=None): + logger.debug("Initializing %s: (canvas: '%s', detected_faces: %s, control_text: %s)", + self.__class__.__name__, canvas, detected_faces, control_text) + self._canvas = canvas + self._globals = canvas._globals + self._det_faces = detected_faces + + self._current_color = dict() + self._actions = OrderedDict() + self._controls = dict(header=control_text, controls=[]) + self._add_key_bindings(key_bindings) + + self._add_actions() + self._add_controls() + self._add_annotation_format_controls() + + self._mouse_location = None + self._drag_data = dict() + self._drag_callback = None + self.bind_mouse_motion() + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def _default_colors(self): + """ dict: The default colors for each annotation """ + return {"BoundingBox": "#0000ff", + "ExtractBox": "#00ff00", + "Landmarks": "#ff00ff", + "Mask": "#ff0000", + "Mesh": "#00ffff"} + + @property + def _is_active(self): + """ bool: ``True`` if this editor is currently active otherwise ``False``. + + Notes + ----- + When initializing, the active_editor parameter will not be set in the parent, + so return ``False`` in this instance + """ + return hasattr(self._canvas, "active_editor") and self._canvas.active_editor == self + + @property + def view_mode(self): + """ ["frame", "face"]: The view mode for the currently selected editor. If the editor does + not have a view mode that can be updated, then `"frame"` will be returned. """ + tk_var = self._actions.get("magnify", dict()).get("tk_var", None) + retval = "frame" if tk_var is None or not tk_var.get() else "face" + return retval + + @property + def _zoomed_roi(self): + """ :class:`numpy.ndarray`: The (`left`, `top`, `right`, `bottom`) roi of the zoomed face + in the display frame. """ + half_size = min(self._globals.frame_display_dims) / 2 + left = self._globals.frame_display_dims[0] / 2 - half_size + top = 0 + right = self._globals.frame_display_dims[0] / 2 + half_size + bottom = self._globals.frame_display_dims[1] + retval = np.rint(np.array((left, top, right, bottom))).astype("int32") + logger.trace("Zoomed ROI: %s", retval) + return retval + + @property + def _zoomed_dims(self): + """ tuple: The (`width`, `height`) of the zoomed ROI. """ + roi = self._zoomed_roi + return (roi[2] - roi[0], roi[3] - roi[1]) + + @property + def _control_vars(self): + """ dict: The tk control panel variables for the currently selected editor. """ + return self._canvas.control_tk_vars.get(self.__class__.__name__, dict()) + + @property + def controls(self): + """ dict: The control panel options and header text for the current editor """ + return self._controls + + @property + def _control_color(self): + """ str: The hex color code set in the control panel for the current editor. """ + annotation = self.__class__.__name__ + return self._annotation_formats[annotation]["color"].get() + + @property + def _annotation_formats(self): + """ dict: The format (color, opacity etc.) of each editor's annotation display. """ + return self._canvas.annotation_formats + + @property + def actions(self): + """ list: The optional action buttons for the actions frame in the GUI for the + current editor """ + return self._actions + + @property + def _face_iterator(self): + """ list: The detected face objects to be iterated. This will either be all faces in the + frame (normal view) or the single zoomed in face (zoom mode). """ + if self._globals.frame_index == -1: + faces = [] + else: + faces = self._det_faces.current_faces[self._globals.frame_index] + faces = ([faces[self._globals.face_index]] + if self._globals.is_zoomed and faces else faces) + return faces + + def _add_key_bindings(self, key_bindings): + """ Add the editor specific key bindings for the currently viewed editor. + + Parameters + ---------- + key_bindings: dict + The key binding to method dictionary for this editor. + """ + if key_bindings is None: + return + for key, method in key_bindings.items(): + logger.debug("Binding key '%s' to method %s for editor '%s'", + key, method, self.__class__.__name__) + self._canvas.key_bindings.setdefault(key, dict())["bound_to"] = None + self._canvas.key_bindings[key][self.__class__.__name__] = method + + @staticmethod + def _get_anchor_points(bounding_box): + """ Retrieve the (x, y) co-ordinates for each of the 4 corners of a bounding box's anchors + for both the displayed anchors and the anchor grab locations. + + Parameters + ---------- + bounding_box: tuple + The (`top-left`, `top-right`, `bottom-right`, `bottom-left`) (x, y) coordinates of the + bounding box + + Returns + display_anchors: tuple + The (`top`, `left`, `bottom`, `right`) co-ordinates for each circle at each point + of the bounding box corners, sized for display + grab_anchors: tuple + The (`top`, `left`, `bottom`, `right`) co-ordinates for each circle at each point + of the bounding box corners, at a larger size for grabbing with a mouse + """ + radius = 3 + grab_radius = radius * 3 + display_anchors = tuple((cnr[0] - radius, cnr[1] - radius, + cnr[0] + radius, cnr[1] + radius) + for cnr in bounding_box) + grab_anchors = tuple((cnr[0] - grab_radius, cnr[1] - grab_radius, + cnr[0] + grab_radius, cnr[1] + grab_radius) + for cnr in bounding_box) + return display_anchors, grab_anchors + + def update_annotation(self): # pylint:disable=no-self-use + """ Update the display annotations for the current objects. + + Override for specific editors. + """ + logger.trace("Default annotations. Not storing Objects") + + def hide_annotation(self, tag=None): + """ Hide annotations for this editor. + + Parameters + ---------- + tag: str, optional + The specific tag to hide annotations for. If ``None`` then all annotations for this + editor are hidden, otherwise only the annotations specified by the given tag are + hidden. Default: ``None`` + """ + tag = self.__class__.__name__ if tag is None else tag + logger.trace("Hiding annotations for tag: %s", tag) + self._canvas.itemconfig(tag, state="hidden") + + def _object_tracker(self, key, object_type, face_index, + coordinates, object_kwargs): + """ Create an annotation object and add it to :attr:`_objects` or update an existing + annotation if it has already been created. + + Parameters + ---------- + key: str + The key for this annotation in :attr:`_objects` + object_type: str + This can be any string that is a natural extension to :class:`tkinter.Canvas.create_` + face_index: int + The index of the face within the current frame + coordinates: tuple or list + The bounding box coordinates for this object + object_kwargs: dict + The keyword arguments for this object + + Returns + ------- + int: + The tkinter canvas item identifier for the created object + """ + object_color_keys = self._get_object_color_keys(key, object_type) + tracking_id = "_".join((key, str(face_index))) + face_tag = "face_{}".format(face_index) + face_objects = set(self._canvas.find_withtag(face_tag)) + annotation_objects = set(self._canvas.find_withtag(key)) + existing_object = tuple(face_objects.intersection(annotation_objects)) + if not existing_object: + item_id = self._add_new_object(key, + object_type, + face_index, + coordinates, + object_kwargs) + update_color = bool(object_color_keys) + else: + item_id = existing_object[0] + update_color = self._update_existing_object( + existing_object[0], + coordinates, + object_kwargs, + tracking_id, + object_color_keys) + if update_color: + self._current_color[tracking_id] = object_kwargs[object_color_keys[0]] + return item_id + + @staticmethod + def _get_object_color_keys(key, object_type): + """ The canvas object's parameter that needs to be adjusted for color varies based on + the type of object that is being used. Returns the correct parameter based on object. + + Parameters + ---------- + key: str + The key for this annotation's tag creation + object_type: str + This can be any string that is a natural extension to :class:`tkinter.Canvas.create_` + + Returns + ------- + list: + The list of keyword arguments for this objects color parameter(s) or an empty list + if it is not relevant for this object + """ + if object_type in ("line", "text"): + retval = ["fill"] + elif object_type == "image": + retval = [] + elif object_type == "oval" and key.startswith("lm_dsp_"): + retval = ["fill", "outline"] + else: + retval = ["outline"] + logger.trace("returning %s for key: %s, object_type: %s", retval, key, object_type) + return retval + + def _add_new_object(self, key, object_type, face_index, coordinates, object_kwargs): + """ Add a new object to the canvas. + + Parameters + ---------- + key: str + The key for this annotation's tag creation + object_type: str + This can be any string that is a natural extension to :class:`tkinter.Canvas.create_` + face_index: int + The index of the face within the current frame + coordinates: tuple or list + The bounding box coordinates for this object + object_kwargs: dict + The keyword arguments for this object + + Returns + ------- + int: + The tkinter canvas item identifier for the created object + """ + logger.debug("Adding object: (key: '%s', object_type: '%s', face_index: %s, " + "coordinates: %s, object_kwargs: %s)", key, object_type, face_index, + coordinates, object_kwargs) + object_kwargs["tags"] = self._set_object_tags(face_index, key) + item_id = getattr(self._canvas, + "create_{}".format(object_type))(*coordinates, **object_kwargs) + return item_id + + def _set_object_tags(self, face_index, key): + """ Create the tkinter object tags for the incoming object. + + Parameters + ---------- + face_index: int + The face index within the current frame for the face that tags are being created for + key: str + The base tag for this object, for which additional tags will be generated + + Returns + ------- + list + The generated tags for the current object + """ + tags = ["face_{}".format(face_index), + self.__class__.__name__, + "{}_face_{}".format(self.__class__.__name__, face_index), + key, + "{}_face_{}".format(key, face_index)] + if "_" in key: + split_key = key.split("_") + if split_key[-1].isdigit(): + base_tag = "_".join(split_key[:-1]) + tags.append(base_tag) + tags.append("{}_face_{}".format(base_tag, face_index)) + return tags + + def _update_existing_object(self, item_id, coordinates, object_kwargs, + tracking_id, object_color_keys): + """ Update an existing tracked object. + + Parameters + ---------- + item_id: int + The canvas object item_id to be updated + coordinates: tuple or list + The bounding box coordinates for this object + object_kwargs: dict + The keyword arguments for this object + tracking_id: str + The tracking identifier for this object's color + object_color_keys: list + The list of keyword arguments for this object to update for color + + Returns + ------- + bool + ``True`` if :attr:`_current_color` should be updated otherwise ``False`` + """ + update_color = (object_color_keys and + object_kwargs[object_color_keys[0]] != self._current_color[tracking_id]) + update_kwargs = dict(state=object_kwargs.get("state", "normal")) + if update_color: + for key in object_color_keys: + update_kwargs[key] = object_kwargs[object_color_keys[0]] + if self._canvas.type(item_id) == "image" and "image" in object_kwargs: + update_kwargs["image"] = object_kwargs["image"] + logger.trace("Updating coordinates: (item_id: '%s', object_kwargs: %s, " + "coordinates: %s, update_kwargs: %s", item_id, object_kwargs, + coordinates, update_kwargs) + self._canvas.itemconfig(item_id, **update_kwargs) + self._canvas.coords(item_id, *coordinates) + return update_color + + # << MOUSE CALLBACKS >> + # Mouse cursor display + def bind_mouse_motion(self): + """ Binds the mouse motion for the current editor's mouse event to the editor's + :func:`_update_cursor` function. + + Called on initialization and active editor update. + """ + self._canvas.bind("", self._update_cursor) + + def _update_cursor(self, event): # pylint: disable=unused-argument + """ The mouse cursor display as bound to the mouse's event.. + + The default is to always return a standard cursor, so this method should be overridden for + editor specific cursor update. + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. Unused for default tracking, but available for specific editor + tracking. + """ + self._canvas.config(cursor="") + + # Mouse click and drag actions + def set_mouse_click_actions(self): + """ Add the bindings for left mouse button click and drag actions. + + This binds the mouse to the :func:`_drag_start`, :func:`_drag` and :func:`_drag_stop` + methods. + + By default these methods do nothing (except for :func:`_drag_stop` which resets + :attr:`_drag_data`. + + This bindings should be added for all editors. To add additional bindings, + `super().set_mouse_click_actions` should be called prior to adding them.. + """ + logger.debug("Setting mouse bindings") + self._canvas.bind("", self._drag_start) + self._canvas.bind("", self._drag_stop) + self._canvas.bind("", self._drag) + + def _drag_start(self, event): # pylint:disable=unused-argument + """ The action to perform when the user starts clicking and dragging the mouse. + + The default does nothing except reset the attr:`drag_data` and attr:`drag_callback`. + Override for Editor specific click and drag start actions. + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. Unused but for default action, but available for editor + specific actions + """ + self._drag_data = dict() + self._drag_callback = None + + def _drag(self, event): + """ The default callback for the drag part of a mouse click and drag action. + + :attr:`_drag_callback` should be set in :func:`self._drag_start`. This callback will then + be executed on a mouse drag event. + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. + """ + if self._drag_callback is None: + return + self._drag_callback(event) + + def _drag_stop(self, event): # pylint:disable=unused-argument + """ The action to perform when the user stops clicking and dragging the mouse. + + Default is to set :attr:`_drag_data` to `dict`. Override for Editor specific stop actions. + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. Unused but required + """ + self._drag_data = dict() + + def _scale_to_display(self, points): + """ Scale and offset the given points to the current display scale and offset values. + + Parameters + ---------- + points: :class:`numpy.ndarray` + Array of x, y co-ordinates to adjust + + Returns + ------- + :class:`numpy.ndarray` + The adjusted x, y co-ordinates for display purposes rounded to the nearest integer + """ + retval = np.rint((points * self._globals.current_frame["scale"]) + + self._canvas.offset).astype("int32") + logger.trace("Original points: %s, scaled points: %s", points, retval) + return retval + + def scale_from_display(self, points, do_offset=True): + """ Scale and offset the given points from the current display to the correct original + values. + + Parameters + ---------- + points: :class:`numpy.ndarray` + Array of x, y co-ordinates to adjust + offset: bool, optional + ``True`` if the offset should be calculated otherwise ``False``. Default: ``True`` + + Returns + ------- + :class:`numpy.ndarray` + The adjusted x, y co-ordinates to the original frame location rounded to the nearest + integer + """ + offset = self._canvas.offset if do_offset else (0, 0) + retval = np.rint((points - offset) / self._globals.current_frame["scale"]).astype("int32") + logger.trace("Original points: %s, scaled points: %s", points, retval) + return retval + + # << ACTION CONTROL PANEL OPTIONS >> + def _add_actions(self): + """ Add the Action buttons for this editor's optional left hand side action sections. + + The default does nothing. Override for editor specific actions. + """ + self._actions = self._actions + + def _add_action(self, title, icon, helptext, group=None, hotkey=None): + """ Add an action dictionary to :attr:`_actions`. This will create a button in the optional + actions frame to the left hand side of the frames viewer. + + Parameters + ---------- + title: str + The title of the action to be generated + icon: str + The name of the icon that is used to display this action's button + helptext: str + The tooltip text to display for this action + group: str, optional + If a group is passed in, then any buttons belonging to that group will be linked (i.e. + only one button can be active at a time.). If ``None`` is passed in then the button + will act independently. Default: ``None`` + hotkey: str, optional + The hotkey binding for this action. Set to ``None`` if there is no hotkey binding. + Default: ``None`` + """ + var = tk.BooleanVar() + action = dict(icon=icon, helptext=helptext, group=group, tk_var=var, hotkey=hotkey) + logger.debug("Adding action: %s", action) + self._actions[title] = action + + def _add_controls(self): + """ Add the controls for this editor's control panel. + + The default does nothing. Override for editor specific controls. + """ + self._controls = self._controls + + def _add_control(self, option, global_control=False): + """ Add a control panel control to :attr:`_controls` and add a trace to the variable + to update display. + + Parameters + ---------- + option: :class:`lib.gui.control_helper.ControlPanelOption' + The control panel option to add to this editor's control + global_control: bool, optional + Whether the given control is a global control (i.e. annotation formatting). + Default: ``False`` + """ + self._controls["controls"].append(option) + if global_control: + logger.debug("Added global control: '%s' for editor: '%s'", + option.title, self.__class__.__name__) + return + logger.debug("Added local control: '%s' for editor: '%s'", + option.title, self.__class__.__name__) + editor_key = self.__class__.__name__ + group_key = option.group.replace(" ", "").lower() + group_key = "none" if group_key == "_master" else group_key + annotation_key = option.title.replace(" ", "") + self._canvas.control_tk_vars.setdefault( + editor_key, dict()).setdefault(group_key, dict())[annotation_key] = option.tk_var + + def _add_annotation_format_controls(self): + """ Add the annotation display (color/size) controls to :attr:`_annotation_formats`. + + These should be universal and available for all editors. + """ + editors = ("Bounding Box", "Extract Box", "Landmarks", "Mask", "Mesh") + if not self._annotation_formats: + opacity = ControlPanelOption("Mask Opacity", + int, + group="Color", + min_max=(0, 100), + default=40, + rounding=1, + helptext="Set the mask opacity") + for editor in editors: + annotation_key = editor.replace(" ", "") + logger.debug("Adding to global format controls: '%s'", editor) + colors = ControlPanelOption(editor, + str, + group="Color", + subgroup="colors", + choices="colorchooser", + default=self._default_colors[annotation_key], + helptext="Set the annotation color") + colors.set(self._default_colors[annotation_key]) + self._annotation_formats.setdefault(annotation_key, dict())["color"] = colors + self._annotation_formats[annotation_key]["mask_opacity"] = opacity + + for editor in editors: + annotation_key = editor.replace(" ", "") + for group, ctl in self._annotation_formats[annotation_key].items(): + logger.debug("Adding global format control to editor: (editor:'%s', group: '%s')", + editor, group) + self._add_control(ctl, global_control=True) + + +class View(Editor): + """ The view Editor. + + Does not allow any editing, just used for previewing annotations. + + This is the default start-up editor. + + Parameters + ---------- + canvas: :class:`tkinter.Canvas` + The canvas that holds the image and annotations + detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` + The _detected_faces data for this manual session + """ + def __init__(self, canvas, detected_faces): + control_text = "Viewer\nPreview the frame's annotations." + super().__init__(canvas, detected_faces, control_text) + + def _add_actions(self): + """ Add the optional action buttons to the viewer. Current actions are Zoom. """ + self._add_action("magnify", "zoom", "Magnify/Demagnify the View", group=None, hotkey="M") + self._actions["magnify"]["tk_var"].trace("w", lambda *e: self._globals.tk_update.set(True)) diff --git a/tools/manual/frameviewer/editor/bounding_box.py b/tools/manual/frameviewer/editor/bounding_box.py new file mode 100644 index 0000000000..58a7b30c49 --- /dev/null +++ b/tools/manual/frameviewer/editor/bounding_box.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +""" Bounding Box Editor for the manual adjustments tool """ + +import platform +from functools import partial + +import numpy as np + +from lib.gui.custom_widgets import RightClickMenu +from ._base import ControlPanelOption, Editor, logger + + +class BoundingBox(Editor): + """ The Bounding Box Editor. + + Adjusting the bounding box feeds the aligner to generate new 68 point landmarks. + + Parameters + ---------- + canvas: :class:`tkinter.Canvas` + The canvas that holds the image and annotations + detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` + The _detected_faces data for this manual session + """ + def __init__(self, canvas, detected_faces): + self._tk_aligner = None + self._right_click_menu = RightClickMenu(["Delete Face"], + [self._delete_current_face], + ["Del"]) + control_text = ("Bounding Box Editor\nEdit the bounding box being fed into the aligner " + "to recalculate the landmarks.\n\n" + " - Grab the corner anchors to resize the bounding box.\n" + " - Click and drag the bounding box to relocate.\n" + " - Click in empty space to create a new bounding box.\n" + " - Right click a bounding box to delete a face.") + key_bindings = {"": self._delete_current_face} + super().__init__(canvas, detected_faces, + control_text=control_text, key_bindings=key_bindings) + + @property + def _corner_order(self): + """ dict: The position index of bounding box corners """ + return {0: ("top", "left"), + 1: ("top", "right"), + 2: ("bottom", "right"), + 3: ("bottom", "left")} + + @property + def _bounding_boxes(self): + """ list: The :func:`tkinter.Canvas.coords` for all displayed bounding boxes. """ + item_ids = self._canvas.find_withtag("bb_box") + return [self._canvas.coords(item_id) for item_id in item_ids + if self._canvas.itemcget(item_id, "state") != "hidden"] + + def _add_controls(self): + """ Controls for feeding the Aligner. Exposes Normalization Method as a parameter. """ + align_ctl = ControlPanelOption( + "Aligner", + str, + group="Aligner", + choices=["cv2-dnn", "FAN"], + default="FAN", + is_radio=True, + helptext="Aligner to use. FAN will obtain better alignments, but cv2-dnn can be " + "useful if FAN cannot get decent alignments and you want to set a base to " + "edit from.") + self._tk_aligner = align_ctl.tk_var + self._add_control(align_ctl) + + norm_ctl = ControlPanelOption( + "Normalization method", + str, + group="Aligner", + choices=["none", "clahe", "hist", "mean"], + default="hist", + is_radio=True, + helptext="Normalization method to use for feeding faces to the aligner. This can help " + "the aligner better align faces with difficult lighting conditions. " + "Different methods will yield different results on different sets. NB: This " + "does not impact the output face, just the input to the aligner." + "\n\tnone: Don't perform normalization on the face." + "\n\tclahe: Perform Contrast Limited Adaptive Histogram Equalization on the " + "face." + "\n\thist: Equalize the histograms on the RGB channels." + "\n\tmean: Normalize the face colors to the mean.") + var = norm_ctl.tk_var + var.trace("w", + lambda *e, v=var: self._det_faces.extractor.set_normalization_method(v.get())) + self._add_control(norm_ctl) + + def update_annotation(self): + """ Get the latest bounding box data from alignments and update. """ + if self._globals.is_zoomed: + logger.trace("Image is zoomed. Hiding Bounding Box.") + self.hide_annotation() + return + key = "bb_box" + color = self._control_color + for idx, face in enumerate(self._face_iterator): + box = np.array([(face.left, face.top), (face.right, face.bottom)]) + box = self._scale_to_display(box).astype("int32").flatten() + kwargs = dict(outline=color, width=1) + logger.trace("frame_index: %s, face_index: %s, box: %s, kwargs: %s", + self._globals.frame_index, idx, box, kwargs) + self._object_tracker(key, "rectangle", idx, box, kwargs) + self._update_anchor_annotation(idx, box, color) + logger.trace("Updated bounding box annotations") + + def _update_anchor_annotation(self, face_index, bounding_box, color): + """ Update the anchor annotations for each corner of the bounding box. + + The anchors only display when the bounding box editor is active. + + Parameters + ---------- + face_index: int + The index of the face being annotated + bounding_box: :class:`numpy.ndarray` + The scaled bounding box to get the corner anchors for + color: str + The hex color of the bounding box line + """ + if not self._is_active: + self.hide_annotation("bb_anc_dsp") + self.hide_annotation("bb_anc_grb") + return + fill_color = "gray" + activefill_color = "white" if self._is_active else "" + anchor_points = self._get_anchor_points(((bounding_box[0], bounding_box[1]), + (bounding_box[2], bounding_box[1]), + (bounding_box[2], bounding_box[3]), + (bounding_box[0], bounding_box[3]))) + for idx, (anc_dsp, anc_grb) in enumerate(zip(*anchor_points)): + dsp_kwargs = dict(outline=color, fill=fill_color, width=1) + grb_kwargs = dict(outline="", fill="", width=1, activefill=activefill_color) + dsp_key = "bb_anc_dsp_{}".format(idx) + grb_key = "bb_anc_grb_{}".format(idx) + self._object_tracker(dsp_key, "oval", face_index, anc_dsp, dsp_kwargs) + self._object_tracker(grb_key, "oval", face_index, anc_grb, grb_kwargs) + logger.trace("Updated bounding box anchor annotations") + + # << MOUSE HANDLING >> + # Mouse cursor display + def _update_cursor(self, event): + """ Set the cursor action. + + Update :attr:`_mouse_location` with the current cursor position and display appropriate + icon. + + If the cursor is over a corner anchor, then pop resize icon. + If the cursor is over a bounding box, then pop move icon. + If the cursor is over the image, then pop add icon. + + Parameters + ---------- + event: :class:`tkinter.Event` + The current tkinter mouse event + """ + if self._check_cursor_anchors(): + return + if self._check_cursor_bounding_box(event): + return + if self._check_cursor_image(event): + return + + self._canvas.config(cursor="") + self._mouse_location = None + + def _check_cursor_anchors(self): + """ Check whether the cursor is over a corner anchor. + + If it is, set the appropriate cursor type and set :attr:`_mouse_location` to + ("anchor", (`face index`, `anchor index`) + + Returns + ------- + bool + ``True`` if cursor is over an anchor point otherwise ``False`` + """ + anchors = set(self._canvas.find_withtag("bb_anc_grb")) + item_ids = set(self._canvas.find_withtag("current")).intersection(anchors) + if not item_ids: + return False + item_id = list(item_ids)[0] + tags = self._canvas.gettags(item_id) + face_idx = int(next(tag for tag in tags if tag.startswith("face_")).split("_")[-1]) + corner_idx = int(next(tag for tag in tags + if tag.startswith("bb_anc_grb_") + and "face_" not in tag).split("_")[-1]) + self._canvas.config(cursor="{}_{}_corner".format(*self._corner_order[corner_idx])) + self._mouse_location = ("anchor", "{}_{}".format(face_idx, corner_idx)) + return True + + def _check_cursor_bounding_box(self, event): + """ Check whether the cursor is over a bounding box. + + If it is, set the appropriate cursor type and set :attr:`_mouse_location` to: + ("box", `face index`) + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event + + Returns + ------- + bool + ``True`` if cursor is over a bounding box otherwise ``False`` + + Notes + ----- + We can't use tags on unfilled rectangles as the interior of the rectangle is not tagged. + """ + for face_idx, bbox in enumerate(self._bounding_boxes): + if bbox[0] <= event.x <= bbox[2] and bbox[1] <= event.y <= bbox[3]: + self._canvas.config(cursor="fleur") + self._mouse_location = ("box", str(face_idx)) + return True + return False + + def _check_cursor_image(self, event): + """ Check whether the cursor is over the image. + + If it is, set the appropriate cursor type and set :attr:`_mouse_location` to: + ("image", ) + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event + + Returns + ------- + bool + ``True`` if cursor is over a bounding box otherwise ``False`` + """ + if self._globals.frame_index == -1: + return False + display_dims = self._globals.current_frame["display_dims"] + if (self._canvas.offset[0] <= event.x <= display_dims[0] + self._canvas.offset[0] and + self._canvas.offset[1] <= event.y <= display_dims[1] + self._canvas.offset[1]): + self._canvas.config(cursor="plus") + self._mouse_location = ("image", ) + return True + return False + + # Mouse Actions + def set_mouse_click_actions(self): + """ Add context menu to OS specific right click action. """ + super().set_mouse_click_actions() + self._canvas.bind("" if platform.system() == "Darwin" else "", + self._context_menu) + + def _drag_start(self, event): + """ The action to perform when the user starts clicking and dragging the mouse. + + If :attr:`_mouse_location` indicates a corner anchor, then the bounding box is resized + based on the adjusted corner, and the alignments re-generated. + + If :attr:`_mouse_location` indicates a bounding box, then the bounding box is moved, and + the alignments re-generated. + + If :attr:`_mouse_location` indicates being over the main image, then a new bounding box is + created, and alignments generated. + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. + """ + if self._mouse_location is None: + self._drag_data = dict() + self._drag_callback = None + return + if self._mouse_location[0] == "anchor": + corner_idx = int(self._mouse_location[1].split("_")[-1]) + self._drag_data["corner"] = self._corner_order[corner_idx] + self._drag_callback = self._resize + elif self._mouse_location[0] == "box": + self._drag_data["current_location"] = (event.x, event.y) + self._drag_callback = self._move + elif self._mouse_location[0] == "image": + self._create_new_bounding_box(event) + # Refresh cursor and _mouse_location for new bounding box and reset _drag_start + self._update_cursor(event) + self._drag_start(event) + + def _drag_stop(self, event): # pylint: disable=unused-argument + """ Trigger a viewport thumbnail update on click + drag release + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. Required but unused. + """ + if self._mouse_location is None: + return + face_idx = int(self._mouse_location[1].split("_")[0]) + self._det_faces.update.post_edit_trigger(self._globals.frame_index, face_idx) + + def _create_new_bounding_box(self, event): + """ Create a new bounding box when user clicks on image, outside of existing boxes. + + The bounding box is created as a square located around the click location, with dimensions + 1 quarter the size of the frame's shortest side + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event + """ + size = min(self._globals.current_frame["display_dims"]) // 8 + box = (event.x - size, event.y - size, event.x + size, event.y + size) + logger.debug("Creating new bounding box: %s ", box) + self._det_faces.update.add(self._globals.frame_index, *self._coords_to_bounding_box(box)) + + def _resize(self, event): + """ Resizes a bounding box on a corner anchor drag event. + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. + """ + face_idx = int(self._mouse_location[1].split("_")[0]) + face_tag = "bb_box_face_{}".format(face_idx) + box = self._canvas.coords(face_tag) + logger.trace("Face Index: %s, Corner Index: %s. Original ROI: %s", + face_idx, self._drag_data["corner"], box) + # Switch top/bottom and left/right and set partial so indices match and we don't + # need branching logic for min/max. + limits = (partial(min, box[2] - 20), + partial(min, box[3] - 20), + partial(max, box[0] + 20), + partial(max, box[1] + 20)) + rect_xy_indices = [("left", "top", "right", "bottom").index(pnt) + for pnt in self._drag_data["corner"]] + box[rect_xy_indices[1]] = limits[rect_xy_indices[1]](event.x) + box[rect_xy_indices[0]] = limits[rect_xy_indices[0]](event.y) + logger.trace("New ROI: %s", box) + self._det_faces.update.bounding_box(self._globals.frame_index, + face_idx, + *self._coords_to_bounding_box(box), + aligner=self._tk_aligner.get()) + + def _move(self, event): + """ Moves the bounding box on a bounding box drag event. + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. + """ + logger.trace("event: %s, mouse_location: %s", event, self._mouse_location) + face_idx = int(self._mouse_location[1]) + shift = (event.x - self._drag_data["current_location"][0], + event.y - self._drag_data["current_location"][1]) + face_tag = "bb_box_face_{}".format(face_idx) + coords = np.array(self._canvas.coords(face_tag)) + (*shift, *shift) + logger.trace("face_tag: %s, shift: %s, new co-ords: %s", face_tag, shift, coords) + self._det_faces.update.bounding_box(self._globals.frame_index, + face_idx, + *self._coords_to_bounding_box(coords), + aligner=self._tk_aligner.get()) + self._drag_data["current_location"] = (event.x, event.y) + + def _coords_to_bounding_box(self, coords): + """ Converts tkinter coordinates to :class:`lib.faces_detect.DetectedFace` bounding + box format, scaled up and offset for feeding the model. + + Returns + ------- + tuple + The (`x`, `width`, `y`, `height`) integer points of the bounding box. + """ + logger.trace("in: %s", coords) + coords = self.scale_from_display( + np.array(coords).reshape((2, 2))).flatten().astype("int32") + logger.trace("out: %s", coords) + return (coords[0], coords[2] - coords[0], coords[1], coords[3] - coords[1]) + + def _context_menu(self, event): + """ Create a right click context menu to delete the alignment that is being + hovered over. """ + if self._mouse_location is None or self._mouse_location[0] != "box": + return + self._right_click_menu.popup(event) + + def _delete_current_face(self, *args): # pylint:disable=unused-argument + """ Called by the right click delete event. Deletes the face that the mouse is currently + over. + + Parameters + ---------- + args: tuple (unused) + The event parameter is passed in by the hot key binding, so args is required + """ + if self._mouse_location is None or self._mouse_location[0] != "box": + logger.debug("Delete called without valid location. _mouse_location: %s", + self._mouse_location) + return + logger.debug("Deleting face. _mouse_location: %s", self._mouse_location) + self._det_faces.update.delete(self._globals.frame_index, int(self._mouse_location[1])) diff --git a/tools/manual/frameviewer/editor/extract_box.py b/tools/manual/frameviewer/editor/extract_box.py new file mode 100644 index 0000000000..d781221c82 --- /dev/null +++ b/tools/manual/frameviewer/editor/extract_box.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +""" Extract Box Editor for the manual adjustments tool """ + +import platform + +import numpy as np + +from lib.gui.custom_widgets import RightClickMenu +from lib.gui.utils import get_config +from ._base import Editor, logger + + +class ExtractBox(Editor): + """ The Extract Box Editor. + + Adjust the calculated Extract Box to shift all of the 68 point landmarks in place. + + Parameters + ---------- + canvas: :class:`tkinter.Canvas` + The canvas that holds the image and annotations + detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` + The _detected_faces data for this manual session + """ + def __init__(self, canvas, detected_faces): + self._right_click_menu = RightClickMenu(["Delete Face"], + [self._delete_current_face], + ["Del"]) + control_text = ("Extract Box Editor\nMove the extract box that has been generated by the " + "aligner. Click and drag:\n\n" + " - Inside the bounding box to relocate the landmarks.\n" + " - The corner anchors to resize the landmarks.\n" + " - Outside of the corners to rotate the landmarks.") + key_bindings = {"": self._delete_current_face} + super().__init__(canvas, detected_faces, + control_text=control_text, key_bindings=key_bindings) + + @property + def _corner_order(self): + """ dict: The position index of bounding box corners """ + return {0: ("top", "left"), + 3: ("top", "right"), + 2: ("bottom", "right"), + 1: ("bottom", "left")} + + def update_annotation(self): + """ Draw the latest Extract Boxes around the faces. """ + color = self._control_color + roi = self._zoomed_roi + for idx, face in enumerate(self._face_iterator): + logger.trace("Drawing Extract Box: (idx: %s, roi: %s)", idx, face.original_roi) + if self._globals.is_zoomed: + box = np.array((roi[0], roi[1], roi[2], roi[1], roi[2], roi[3], roi[0], roi[3])) + else: + face.load_aligned(None, force=True) + box = self._scale_to_display(face.original_roi).flatten() + top_left = box[:2] - 10 + kwargs = dict(fill=color, font=("Default", 20, "bold"), text=str(idx)) + self._object_tracker("eb_text", "text", idx, top_left, kwargs) + kwargs = dict(fill="", outline=color, width=1) + self._object_tracker("eb_box", "polygon", idx, box, kwargs) + self._update_anchor_annotation(idx, box, color) + logger.trace("Updated extract box annotations") + + def _update_anchor_annotation(self, face_index, extract_box, color): + """ Update the anchor annotations for each corner of the extract box. + + The anchors only display when the extract box editor is active. + + Parameters + ---------- + face_index: int + The index of the face being annotated + extract_box: :class:`numpy.ndarray` + The scaled extract box to get the corner anchors for + color: str + The hex color of the extract box line + """ + if not self._is_active or self._globals.is_zoomed: + self.hide_annotation("eb_anc_dsp") + self.hide_annotation("eb_anc_grb") + return + fill_color = "gray" + activefill_color = "white" if self._is_active else "" + anchor_points = self._get_anchor_points((extract_box[:2], + extract_box[2:4], + extract_box[4:6], + extract_box[6:])) + for idx, (anc_dsp, anc_grb) in enumerate(zip(*anchor_points)): + dsp_kwargs = dict(outline=color, fill=fill_color, width=1) + grb_kwargs = dict(outline="", fill="", width=1, activefill=activefill_color) + dsp_key = "eb_anc_dsp_{}".format(idx) + grb_key = "eb_anc_grb_{}".format(idx) + self._object_tracker(dsp_key, "oval", face_index, anc_dsp, dsp_kwargs) + self._object_tracker(grb_key, "oval", face_index, anc_grb, grb_kwargs) + logger.trace("Updated extract box anchor annotations") + + # << MOUSE HANDLING >> + # Mouse cursor display + def _update_cursor(self, event): + """ Update the cursor when it is hovering over an extract box and update + :attr:`_mouse_location` with the current cursor position. + + Parameters + ---------- + event: :class:`tkinter.Event` + The current tkinter mouse event + """ + if self._check_cursor_anchors(): + return + if self._check_cursor_box(): + return + if self._check_cursor_rotate(event): + return + self._canvas.config(cursor="") + self._mouse_location = None + + def _check_cursor_anchors(self): + """ Check whether the cursor is over a corner anchor. + + If it is, set the appropriate cursor type and set :attr:`_mouse_location` to + ("anchor", `face index`, `corner_index`) + + Returns + ------- + bool + ``True`` if cursor is over an anchor point otherwise ``False`` + """ + anchors = set(self._canvas.find_withtag("eb_anc_grb")) + item_ids = set(self._canvas.find_withtag("current")).intersection(anchors) + if not item_ids: + return False + item_id = list(item_ids)[0] + tags = self._canvas.gettags(item_id) + face_idx = int(next(tag for tag in tags if tag.startswith("face_")).split("_")[-1]) + corner_idx = int(next(tag for tag in tags + if tag.startswith("eb_anc_grb_") + and "face_" not in tag).split("_")[-1]) + + self._canvas.config(cursor="{}_{}_corner".format(*self._corner_order[corner_idx])) + self._mouse_location = ("anchor", face_idx, corner_idx) + return True + + def _check_cursor_box(self): + """ Check whether the cursor is inside an extract box. + + If it is, set the appropriate cursor type and set :attr:`_mouse_location` to + ("box", `face index`) + + Returns + ------- + bool + ``True`` if cursor is over a rotate point otherwise ``False`` + """ + extract_boxes = set(self._canvas.find_withtag("eb_box")) + item_ids = set(self._canvas.find_withtag("current")).intersection(extract_boxes) + if not item_ids: + return False + item_id = list(item_ids)[0] + self._canvas.config(cursor="fleur") + self._mouse_location = ("box", next(int(tag.split("_")[-1]) + for tag in self._canvas.gettags(item_id) + if tag.startswith("face_"))) + return True + + def _check_cursor_rotate(self, event): + """ Check whether the cursor is in an area to rotate the extract box. + + If it is, set the appropriate cursor type and set :attr:`_mouse_location` to + ("rotate", `face index`) + + Notes + ----- + This code is executed after the check has been completed to see if the mouse is inside + the extract box. For this reason, we don't bother running a check to see if the mouse + is inside the box, as this code will never run if that is the case. + + Parameters + ---------- + event: :class:`tkinter.Event` + The current tkinter mouse event + + Returns + ------- + bool + ``True`` if cursor is over a rotate point otherwise ``False`` + """ + distance = 30 + boxes = np.array([np.array(self._canvas.coords(item_id)).reshape(4, 2) + for item_id in self._canvas.find_withtag("eb_box") + if self._canvas.itemcget(item_id, "state") != "hidden"]) + position = np.array((event.x, event.y)).astype("float32") + for face_idx, points in enumerate(boxes): + if any(np.all(position > point - distance) and np.all(position < point + distance) + for point in points): + self._canvas.config(cursor="exchange") + self._mouse_location = ("rotate", face_idx) + return True + return False + + # Mouse click actions + def set_mouse_click_actions(self): + """ Add context menu to OS specific right click action. """ + super().set_mouse_click_actions() + self._canvas.bind("" if platform.system() == "Darwin" else "", + self._context_menu) + + def _drag_start(self, event): + """ The action to perform when the user starts clicking and dragging the mouse. + + Selects the correct extract box action based on the initial cursor position. + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. + """ + if self._mouse_location is None: + self._drag_data = dict() + self._drag_callback = None + return + self._drag_data["current_location"] = np.array((event.x, event.y)) + callback = dict(anchor=self._resize, rotate=self._rotate, box=self._move) + self._drag_callback = callback[self._mouse_location[0]] + + def _drag_stop(self, event): # pylint: disable=unused-argument + """ Trigger a viewport thumbnail update on click + drag release + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. Required but unused. + """ + if self._mouse_location is None: + return + self._det_faces.update.post_edit_trigger(self._globals.frame_index, + self._mouse_location[1]) + + def _move(self, event): + """ Updates the underlying detected faces landmarks based on mouse dragging delta, + which moves the Extract box on a drag event. + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. + """ + if not self._drag_data: + return + shift_x = event.x - self._drag_data["current_location"][0] + shift_y = event.y - self._drag_data["current_location"][1] + scaled_shift = self.scale_from_display(np.array((shift_x, shift_y)), do_offset=False) + self._det_faces.update.landmarks(self._globals.frame_index, + self._mouse_location[1], + *scaled_shift) + self._drag_data["current_location"] = (event.x, event.y) + + def _resize(self, event): + """ Resizes the landmarks contained within an extract box on a corner anchor drag event. + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. + """ + face_idx = self._mouse_location[1] + face_tag = "eb_box_face_{}".format(face_idx) + position = np.array((event.x, event.y)) + box = np.array(self._canvas.coords(face_tag)) + center = np.array((sum(box[0::2]) / 4, sum(box[1::2]) / 4)) + if not self._check_in_bounds(center, box, position): + logger.trace("Drag out of bounds. Not updating") + self._drag_data["current_location"] = position + return + + start = self._drag_data["current_location"] + distance = ((np.linalg.norm(center - start) - np.linalg.norm(center - position)) + * get_config().scaling_factor) + size = ((box[2] - box[0]) ** 2 + (box[3] - box[1]) ** 2) ** 0.5 + scale = 1 - (distance / size) + logger.trace("face_index: %s, center: %s, start: %s, position: %s, distance: %s, " + "size: %s, scale: %s", face_idx, center, start, position, distance, size, + scale) + if size * scale < 20: + # Don't over shrink the box + logger.trace("Box would size to less than 20px. Not updating") + self._drag_data["current_location"] = position + return + + self._det_faces.update.landmarks_scale(self._globals.frame_index, + face_idx, + scale, + self.scale_from_display(center)) + self._drag_data["current_location"] = position + + def _check_in_bounds(self, center, box, position): + """ Ensure that a resize drag does is not going to cross the center point from it's initial + corner location. + + Parameters + ---------- + center: :class:`numpy.ndarray` + The (`x`, `y`) center point of the face extract box + box: :class:`numpy.ndarray` + The canvas coordinates of the extract box polygon's corners + position: : class:`numpy.ndarray` + The current (`x`, `y`) position of the mouse cursor + + Returns + ------- + bool + ``True`` if the drag operation does not cross the center point otherwise ``False`` + """ + # Generate lines that span the full frame (x and y) along the center point + center_x = np.array(((center[0], 0), (center[0], self._globals.frame_display_dims[1]))) + center_y = np.array(((0, center[1]), (self._globals.frame_display_dims[0], center[1]))) + + # Generate a line coming from the current corner location to the current cursor position + full_line = np.array((box[self._mouse_location[2] * 2:self._mouse_location[2] * 2 + 2], + position)) + logger.trace("center: %s, center_x_line: %s, center_y_line: %s, full_line: %s", + center, center_x, center_y, full_line) + + # Check whether any of the generated lines intersect + for line in (center_x, center_y): + if (self._is_ccw(full_line[0], *line) != self._is_ccw(full_line[1], *line) and + self._is_ccw(*full_line, line[0]) != self._is_ccw(*full_line, line[1])): + logger.trace("line: %s crosses center: %s", full_line, center) + return False + return True + + @staticmethod + def _is_ccw(point_a, point_b, point_c): + """ Check whether 3 points are counter clockwise from each other. + + Parameters + ---------- + point_a: :class:`numpy.ndarray` + The first (`x`, `y`) point to check for counter clockwise ordering + point_b: :class:`numpy.ndarray` + The second (`x`, `y`) point to check for counter clockwise ordering + point_c: :class:`numpy.ndarray` + The third (`x`, `y`) point to check for counter clockwise ordering + + Returns + ------- + bool + ``True`` if the 3 points are provided in counter clockwise order otherwise ``False`` + """ + return ((point_c[1] - point_a[1]) * (point_b[0] - point_a[0]) > + (point_b[1] - point_a[1]) * (point_c[0] - point_a[0])) + + def _rotate(self, event): + """ Rotates the landmarks contained within an extract box on a corner rotate drag event. + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. + """ + face_idx = self._mouse_location[1] + face_tag = "eb_box_face_{}".format(face_idx) + box = np.array(self._canvas.coords(face_tag)) + position = np.array((event.x, event.y)) + + center = np.array((sum(box[0::2]) / 4, sum(box[1::2]) / 4)) + init_to_center = self._drag_data["current_location"] - center + new_to_center = position - center + angle = np.rad2deg(np.arctan2(*new_to_center) - np.arctan2(*init_to_center)) + logger.trace("face_index: %s, box: %s, center: %s, init_to_center: %s, new_to_center: %s" + "center: %s, angle: %s", face_idx, box, center, init_to_center, new_to_center, + center, angle) + + self._det_faces.update.landmarks_rotate(self._globals.frame_index, + face_idx, + angle, + self.scale_from_display(center)) + self._drag_data["current_location"] = position + + def _get_scale(self): + """ Obtain the scaling for the extract box resize """ + + def _context_menu(self, event): + """ Create a right click context menu to delete the alignment that is being + hovered over. """ + if self._mouse_location is None or self._mouse_location[0] != "box": + return + self._right_click_menu.popup(event) + + def _delete_current_face(self, *args): # pylint:disable=unused-argument + """ Called by the right click delete event. Deletes the face that the mouse is currently + over. + + Parameters + ---------- + args: tuple (unused) + The event parameter is passed in by the hot key binding, so args is required + """ + if self._mouse_location is None or self._mouse_location[0] != "box": + return + self._det_faces.update.delete(self._globals.frame_index, self._mouse_location[1]) diff --git a/tools/manual/frameviewer/editor/landmarks.py b/tools/manual/frameviewer/editor/landmarks.py new file mode 100644 index 0000000000..c2d2b3464f --- /dev/null +++ b/tools/manual/frameviewer/editor/landmarks.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python3 +""" Landmarks Editor and Landmarks Mesh viewer for the manual adjustments tool """ +import numpy as np + +from ._base import Editor, logger + + +class Landmarks(Editor): + """ The Landmarks Editor. + + Adjust individual landmark points and re-generate Extract Box. + + Parameters + ---------- + canvas: :class:`tkinter.Canvas` + The canvas that holds the image and annotations + detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` + The _detected_faces data for this manual session + """ + def __init__(self, canvas, detected_faces): + control_text = ("Landmark Point Editor\nEdit the individual landmark points.\n\n" + " - Click and drag individual points to relocate.\n" + " - Draw a box to select multiple points to relocate.") + self._selection_box = canvas.create_rectangle(0, 0, 0, 0, + dash=(2, 4), + state="hidden", + outline="gray", + fill="blue", + stipple="gray12") + super().__init__(canvas, detected_faces, control_text) + # Clear selection box on an editor or frame change + self._canvas._tk_action_var.trace("w", lambda *e: self._reset_selection()) + self._globals.tk_frame_index.trace("w", lambda *e: self._reset_selection()) + + def _add_actions(self): + """ Add the optional action buttons to the viewer. Current actions are Point, Select + and Zoom. """ + self._add_action("magnify", "zoom", "Magnify/Demagnify the View", group=None, hotkey="M") + self._actions["magnify"]["tk_var"].trace("w", self._toggle_zoom) + + # CALLBACKS + def _toggle_zoom(self, *args): # pylint:disable=unused-argument + """ Clear any selections when switching mode and perform an update. + + Parameters + ---------- + args: tuple + tkinter callback arguments. Required but unused. + """ + self._reset_selection() + self._globals.tk_update.set(True) + + def _reset_selection(self, event=None): # pylint:disable=unused-argument + """ Reset the selection box and the selected landmark annotations. """ + self._canvas.itemconfig("lm_selected", outline=self._control_color) + self._canvas.dtag("lm_selected") + self._canvas.itemconfig(self._selection_box, + stipple="gray12", + fill="blue", + outline="gray", + state="hidden") + self._canvas.coords(self._selection_box, 0, 0, 0, 0) + self._drag_data = dict() + if event is not None: + self._drag_start(event) + + def update_annotation(self): + """ Get the latest Landmarks points and update. """ + zoomed_offset = self._zoomed_roi[:2] + for face_idx, face in enumerate(self._face_iterator): + face_index = self._globals.face_index if self._globals.is_zoomed else face_idx + if self._globals.is_zoomed: + landmarks = face.aligned_landmarks + zoomed_offset + # Hide all landmarks and only display selected + self._canvas.itemconfig("lm_dsp", state="hidden") + self._canvas.itemconfig("lm_dsp_face_{}".format(face_index), state="normal") + else: + landmarks = self._scale_to_display(face.landmarks_xy) + for lm_idx, landmark in enumerate(landmarks): + self._display_landmark(landmark, face_index, lm_idx) + self._label_landmark(landmark, face_index, lm_idx) + self._grab_landmark(landmark, face_index, lm_idx) + logger.trace("Updated landmark annotations") + + def _display_landmark(self, bounding_box, face_index, landmark_index): + """ Add an individual landmark display annotation to the canvas. + + Parameters + ---------- + bounding_box: :class:`numpy.ndarray` + The (left, top), (right, bottom) (x, y) coordinates of the oval bounding box for this + landmark + face_index: int + The index of the face within the current frame + landmark_index: int + The index point of this landmark + """ + radius = 1 + color = self._control_color + bbox = (bounding_box[0] - radius, bounding_box[1] - radius, + bounding_box[0] + radius, bounding_box[1] + radius) + key = "lm_dsp_{}".format(landmark_index) + kwargs = dict(outline=color, fill=color, width=radius) + self._object_tracker(key, "oval", face_index, bbox, kwargs) + + def _label_landmark(self, bounding_box, face_index, landmark_index): + """ Add a text label for a landmark to the canvas. + + Parameters + ---------- + bounding_box: :class:`numpy.ndarray` + The (left, top), (right, bottom) (x, y) coordinates of the oval bounding box for this + landmark + face_index: int + The index of the face within the current frame + landmark_index: int + The index point of this landmark + """ + if not self._is_active: + return + top_left = np.array(bounding_box[:2]) - 20 + # NB The text must be visible to be able to get the bounding box, so set to hidden + # after the bounding box has been retrieved + + keys = ["lm_lbl_{}".format(landmark_index), "lm_lbl_bg_{}".format(landmark_index)] + text_kwargs = dict(fill="black", font=("Default", 10), text=str(landmark_index + 1)) + bg_kwargs = dict(fill="#ffffea", outline="black") + + text_id = self._object_tracker(keys[0], "text", face_index, top_left, text_kwargs) + bbox = self._canvas.bbox(text_id) + bbox = [bbox[0] - 2, bbox[1] - 2, bbox[2] + 2, bbox[3] + 2] + bg_id = self._object_tracker(keys[1], "rectangle", face_index, bbox, bg_kwargs) + self._canvas.tag_lower(bg_id, text_id) + self._canvas.itemconfig(text_id, state="hidden") + self._canvas.itemconfig(bg_id, state="hidden") + + def _grab_landmark(self, bounding_box, face_index, landmark_index): + """ Add an individual landmark grab anchor to the canvas. + + Parameters + ---------- + bounding_box: :class:`numpy.ndarray` + The (left, top), (right, bottom) (x, y) coordinates of the oval bounding box for this + landmark + face_index: int + The index of the face within the current frame + landmark_index: int + The index point of this landmark + """ + if not self._is_active: + return + radius = 7 + bbox = (bounding_box[0] - radius, bounding_box[1] - radius, + bounding_box[0] + radius, bounding_box[1] + radius) + key = "lm_grb_{}".format(landmark_index) + kwargs = dict(outline="", + fill="", + width=1, + dash=(2, 4)) + self._object_tracker(key, "oval", face_index, bbox, kwargs) + + # << MOUSE HANDLING >> + # Mouse cursor display + def _update_cursor(self, event): + """ Set the cursor action. + + Launch the cursor update action for the currently selected edit mode. + + Parameters + ---------- + event: :class:`tkinter.Event` + The current tkinter mouse event + """ + self._hide_labels() + if self._drag_data: + self._update_cursor_select_mode(event) + else: + objs = self._canvas.find_withtag("lm_grb_face_{}".format(self._globals.face_index) + if self._globals.is_zoomed else "lm_grb") + item_ids = set(self._canvas.find_overlapping(event.x - 6, + event.y - 6, + event.x + 6, + event.y + 6)).intersection(objs) + bboxes = [self._canvas.bbox(idx) for idx in item_ids] + item_id = next((idx for idx, bbox in zip(item_ids, bboxes) + if bbox[0] <= event.x <= bbox[2] and bbox[1] <= event.y <= bbox[3]), + None) + if item_id: + self._update_cursor_point_mode(item_id) + else: + self._canvas.config(cursor="") + self._mouse_location = None + return + + def _hide_labels(self): + """ Clear all landmark text labels from display """ + self._canvas.itemconfig("lm_lbl", state="hidden") + self._canvas.itemconfig("lm_lbl_bg", state="hidden") + self._canvas.itemconfig("lm_grb", fill="", outline="") + + def _update_cursor_point_mode(self, item_id): + """ Update the cursor when the mouse is over an individual landmark's grab anchor. Displays + the landmark label for the landmark under the cursor. Updates :attr:`_mouse_location` with + the current cursor position. + + Parameters + ---------- + item_id: int + The tkinter canvas object id for the landmark point that the cursor is over + """ + self._canvas.itemconfig(item_id, outline="yellow") + tags = self._canvas.gettags(item_id) + face_idx = int(next(tag for tag in tags if tag.startswith("face_")).split("_")[-1]) + lm_idx = int(next(tag for tag in tags if tag.startswith("lm_grb_")).split("_")[-1]) + obj_idx = (face_idx, lm_idx) + + self._canvas.config(cursor="none") + for prefix in ("lm_lbl_", "lm_lbl_bg_"): + tag = "{}{}_face_{}".format(prefix, lm_idx, face_idx) + logger.trace("Displaying: %s tag: %s", self._canvas.type(tag), tag) + self._canvas.itemconfig(tag, state="normal") + self._mouse_location = obj_idx + + def _update_cursor_select_mode(self, event): + """ Update the mouse cursor when in select mode. + + Standard cursor returned when creating a new selection box. Move cursor returned when over + an existing selection box + + Parameters + ---------- + event: :class:`tkinter.Event` + The current tkinter mouse event + """ + bbox = self._canvas.coords(self._selection_box) + if bbox[0] <= event.x <= bbox[2] and bbox[1] <= event.y <= bbox[3]: + self._canvas.config(cursor="fleur") + else: + self._canvas.config(cursor="") + + # Mouse actions + def _drag_start(self, event): + """ The action to perform when the user starts clicking and dragging the mouse. + + The underlying Detected Face's landmark is updated for the point being edited. + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. + """ + sel_box = self._canvas.coords(self._selection_box) + if self._mouse_location is not None: # Point edit mode + self._drag_data["start_location"] = (event.x, event.y) + self._drag_callback = self._move_point + elif not self._drag_data: # Initial point selection box + self._drag_data["start_location"] = (event.x, event.y) + self._drag_callback = self._select + elif sel_box[0] <= event.x <= sel_box[2] and sel_box[1] <= event.y <= sel_box[3]: + # Move point selection box + self._drag_data["start_location"] = (event.x, event.y) + self._drag_callback = self._move_selection + else: # Reset + self._drag_data = dict() + self._drag_callback = None + self._reset_selection(event) + + def _drag_stop(self, event): # pylint: disable=unused-argument + """ In select mode, call the select mode callback. + + In point mode: trigger a viewport thumbnail update on click + drag release + + If there is drag data, and there are selected points in the drag data then + trigger the selected points stop code. + + Otherwise reset the selection box and return + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. Required but unused. + """ + if self._mouse_location is not None: # Point edit mode + self._det_faces.update.post_edit_trigger(self._globals.frame_index, + self._mouse_location[0]) + self._mouse_location = None + self._drag_data = dict() + elif self._drag_data and self._drag_data.get("selected", False): + self._drag_stop_selected() + else: + logger.debug("No selected data. Clearing. drag_data: %s", self._drag_data) + self._reset_selection() + + def _drag_stop_selected(self): + """ Action to perform when mouse drag is stopped in selected points editor mode. + + If there is already a selection, update the viewport thumbnail + + If this is a new selection, then obtain the selected points and track + """ + if "face_index" in self._drag_data: # Selected data has been moved + self._det_faces.update.post_edit_trigger(self._globals.frame_index, + self._drag_data["face_index"]) + return + + # This is a new selection + face_idx = set() + landmark_indices = [] + + for item_id in self._canvas.find_withtag("lm_selected"): + tags = self._canvas.gettags(item_id) + face_idx.add(next(int(tag.split("_")[-1]) + for tag in tags if tag.startswith("face_"))) + landmark_indices.append(next(int(tag.split("_")[-1]) + for tag in tags + if tag.startswith("lm_dsp_") and "face" not in tag)) + if len(face_idx) != 1: + logger.trace("Not exactly 1 face in selection. Aborting. Face indices: %s", face_idx) + self._reset_selection() + return + + self._drag_data["face_index"] = face_idx.pop() + self._drag_data["landmarks"] = landmark_indices + self._canvas.itemconfig(self._selection_box, stipple="", fill="", outline="#ffff00") + self._snap_selection_to_points() + + def _snap_selection_to_points(self): + """ Snap the selection box to the selected points. + + As the landmarks are calculated and redrawn, the selection box can drift. This is + particularly true in zoomed mode. The selection box is therefore redrawn to bind just + outside of the selected points. + """ + all_coords = np.array([self._canvas.coords(item_id) + for item_id in self._canvas.find_withtag("lm_selected")]) + mins = np.min(all_coords, axis=0) + maxes = np.max(all_coords, axis=0) + box_coords = [np.min(mins[[0, 2]] - 5), + np.min(mins[[1, 3]] - 5), + np.max(maxes[[0, 2]] + 5), + np.max(maxes[[1, 3]]) + 5] + self._canvas.coords(self._selection_box, *box_coords) + + def _move_point(self, event): + """ Moves the selected landmark point box and updates the underlying landmark on a point + drag event. + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. + """ + face_idx, lm_idx = self._mouse_location + shift_x = event.x - self._drag_data["start_location"][0] + shift_y = event.y - self._drag_data["start_location"][1] + + if self._globals.is_zoomed: + scaled_shift = np.array((shift_x, shift_y)) + else: + scaled_shift = self.scale_from_display(np.array((shift_x, shift_y)), do_offset=False) + self._det_faces.update.landmark(self._globals.frame_index, + face_idx, + lm_idx, + *scaled_shift, + self._globals.is_zoomed) + self._drag_data["start_location"] = (event.x, event.y) + + def _select(self, event): + """ Create a selection box on mouse drag event when in "select" mode + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. + """ + if self._canvas.itemcget(self._selection_box, "state") == "hidden": + self._canvas.itemconfig(self._selection_box, state="normal") + coords = (*self._drag_data["start_location"], event.x, event.y) + self._canvas.coords(self._selection_box, *coords) + enclosed = set(self._canvas.find_enclosed(*coords)) + landmarks = set(self._canvas.find_withtag("lm_dsp")) + + for item_id in list(enclosed.intersection(landmarks)): + self._canvas.addtag_withtag("lm_selected", item_id) + self._canvas.itemconfig("lm_selected", outline="#ffff00") + self._drag_data["selected"] = True + + def _move_selection(self, event): + """ Move a selection box and the landmarks contained when in "select" mode and a selection + box has been drawn. """ + shift_x = event.x - self._drag_data["start_location"][0] + shift_y = event.y - self._drag_data["start_location"][1] + if self._globals.is_zoomed: + scaled_shift = np.array((shift_x, shift_y)) + else: + scaled_shift = self.scale_from_display(np.array((shift_x, shift_y)), do_offset=False) + self._canvas.move(self._selection_box, shift_x, shift_y) + + self._det_faces.update.landmark(self._globals.frame_index, + self._drag_data["face_index"], + self._drag_data["landmarks"], + *scaled_shift, + self._globals.is_zoomed) + self._snap_selection_to_points() + self._drag_data["start_location"] = (event.x, event.y) + + +class Mesh(Editor): + """ The Landmarks Mesh Display. + + There are no editing options for Mesh editor. It is purely aesthetic and updated when other + editors are used. + + Parameters + ---------- + canvas: :class:`tkinter.Canvas` + The canvas that holds the image and annotations + detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` + The _detected_faces data for this manual session + """ + def __init__(self, canvas, detected_faces): + self._landmark_mapping = dict(mouth_inner=(60, 68), + mouth_outer=(48, 60), + right_eyebrow=(17, 22), + left_eyebrow=(22, 27), + right_eye=(36, 42), + left_eye=(42, 48), + nose=(27, 36), + jaw=(0, 17), + chin=(8, 11)) + super().__init__(canvas, detected_faces, None) + + def update_annotation(self): + """ Get the latest Landmarks and update the mesh.""" + key = "mesh" + color = self._control_color + zoomed_offset = self._zoomed_roi[:2] + for face_idx, face in enumerate(self._face_iterator): + face_index = self._globals.face_index if self._globals.is_zoomed else face_idx + if self._globals.is_zoomed: + landmarks = face.aligned_landmarks + zoomed_offset + # Hide all meshes and only display selected + self._canvas.itemconfig("Mesh", state="hidden") + self._canvas.itemconfig("Mesh_face_{}".format(face_index), state="normal") + else: + landmarks = self._scale_to_display(face.landmarks_xy) + logger.trace("Drawing Landmarks Mesh: (landmarks: %s, color: %s)", landmarks, color) + for idx, (segment, val) in enumerate(self._landmark_mapping.items()): + key = "mesh_{}".format(idx) + pts = landmarks[val[0]:val[1]].flatten() + if segment in ("right_eye", "left_eye", "mouth_inner", "mouth_outer"): + kwargs = dict(fill="", outline=color, width=1) + self._object_tracker(key, "polygon", face_index, pts, kwargs) + else: + self._object_tracker(key, "line", face_index, pts, dict(fill=color, width=1)) + # Place mesh as bottom annotation + self._canvas.tag_raise(self.__class__.__name__, "main_image") diff --git a/tools/manual/frameviewer/editor/mask.py b/tools/manual/frameviewer/editor/mask.py new file mode 100644 index 0000000000..9a102ae833 --- /dev/null +++ b/tools/manual/frameviewer/editor/mask.py @@ -0,0 +1,544 @@ +#!/usr/bin/env python3 +""" Mask Editor for the manual adjustments tool """ +import tkinter as tk + +import numpy as np +import cv2 +from PIL import Image, ImageTk + +from ._base import ControlPanelOption, Editor, logger + + +class Mask(Editor): + """ The mask Editor. + + Edit a mask in the alignments file. + + Parameters + ---------- + canvas: :class:`tkinter.Canvas` + The canvas that holds the image and annotations + detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` + The _detected_faces data for this manual session + """ + def __init__(self, canvas, detected_faces): + self._meta = [] + self._tk_faces = [] + self._internal_size = 512 + control_text = ("Mask Editor\nEdit the mask." + "\n - NB: For Landmark based masks (e.g. components/extended) it is " + "better to make sure the landmarks are correct rather than editing the " + "mask directly. Any change to the landmarks after editing the mask will " + "override your manual edits.") + key_bindings = {"[": lambda *e, i=False: self._adjust_brush_radius(increase=i), + "]": lambda *e, i=True: self._adjust_brush_radius(increase=i)} + super().__init__(canvas, detected_faces, + control_text=control_text, key_bindings=key_bindings) + # Bind control click for reverse painting + self._canvas.bind("", self._control_click) + self._mask_type = self._set_tk_mask_change_callback() + self._mouse_location = [ + self._canvas.create_oval(0, 0, 0, 0, outline="black", state="hidden"), False] + + @property + def _opacity(self): + """ float: The mask opacity setting from the control panel from 0.0 - 1.0. """ + annotation = self.__class__.__name__ + return self._annotation_formats[annotation]["mask_opacity"].get() / 100.0 + + @property + def _brush_radius(self): + """ int: The radius of the brush to use as set in control panel options """ + return self._control_vars["brush"]["BrushSize"].get() + + @property + def _edit_mode(self): + """ str: The currently selected edit mode based on optional action button. + One of "draw" or "erase" """ + action = [name for name, option in self._actions.items() + if option["group"] == "paint" and option["tk_var"].get()] + return "draw" if not action else action[0] + + @property + def _cursor_color(self): + """ str: The hex code for the selected cursor color """ + return self._control_vars["brush"]["CursorColor"].get() + + def _add_actions(self): + """ Add the optional action buttons to the viewer. Current actions are Draw, Erase + and Zoom. """ + self._add_action("magnify", "zoom", "Magnify/Demagnify the View", group=None, hotkey="M") + self._add_action("draw", "draw", "Draw Tool", group="paint", hotkey="D") + self._add_action("erase", "erase", "Erase Tool", group="paint", hotkey="E") + self._actions["magnify"]["tk_var"].trace("w", lambda *e: self._globals.tk_update.set(True)) + + def _add_controls(self): + """ Add the mask specific control panel controls. + + Current controls are: + - the mask type to edit + - the size of brush to use + - the cursor display color + """ + masks = sorted(msk.title() for msk in list(self._det_faces.available_masks) + ["None"]) + default = masks[0] if len(masks) == 1 else [mask for mask in masks if mask != "None"][0] + self._add_control(ControlPanelOption("Mask type", + str, + group="Display", + choices=masks, + default=default, + is_radio=True, + helptext="Select which mask to edit")) + self._add_control(ControlPanelOption("Brush Size", + int, + group="Brush", + min_max=(1, 100), + default=10, + rounding=1, + helptext="Set the brush size. ([ - decrease, " + "] - increase)")) + self._add_control(ControlPanelOption("Cursor Color", + str, + group="Brush", + choices="colorchooser", + default="#ffffff", + helptext="Select the brush cursor color.")) + + def _set_tk_mask_change_callback(self): + """ Add a trace to change the displayed mask on a mask type change. """ + var = self._control_vars["display"]["MaskType"] + var.trace("w", lambda *e: self._on_mask_type_change()) + return var.get() + + def _on_mask_type_change(self): + """ Update the displayed mask on a mask type change """ + mask_type = self._control_vars["display"]["MaskType"].get() + if mask_type == self._mask_type: + return + self._meta = dict(position=self._globals.frame_index) + self._mask_type = mask_type + self._globals.tk_update.set(True) + + def hide_annotation(self, tag=None): + """ Clear the mask :attr:`_meta` dict when hiding the annotation. """ + super().hide_annotation() + self._meta = dict() + + def update_annotation(self): + """ Update the mask annotation with the latest mask. """ + position = self._globals.frame_index + if position != self._meta.get("position", -1): + # Reset meta information when moving to a new frame + self._meta = dict(position=position) + key = self.__class__.__name__ + mask_type = self._control_vars["display"]["MaskType"].get().lower() + color = self._control_color[1:] + rgb_color = np.array(tuple(int(color[i:i + 2], 16) for i in (0, 2, 4))) + roi_color = self._annotation_formats["ExtractBox"]["color"].get() + opacity = self._opacity + for idx, face in enumerate(self._face_iterator): + face_idx = self._globals.face_index if self._globals.is_zoomed else idx + mask = face.mask.get(mask_type, None) + if mask is None: + continue + self._set_face_meta_data(mask, face_idx) + self._update_mask_image(key.lower(), face_idx, rgb_color, opacity) + self._update_roi_box(mask, face_idx, roi_color) + + self._canvas.tag_raise(self._mouse_location[0]) # Always keep brush cursor on top + logger.trace("Updated mask annotation") + + def _set_face_meta_data(self, mask, face_index): + """ Set the metadata for the current face if it has changed or is new. + + Parameters + ---------- + mask: :class:`numpy.ndarray` + The one channel mask cropped to the ROI + face_index: int + The index pertaining to the current face + """ + masks = self._meta.get("mask", None) + if masks is not None and len(masks) - 1 == face_index: + logger.trace("Meta information already defined for face: %s", face_index) + return + + logger.debug("Defining meta information for face: %s", face_index) + scale = self._internal_size / mask.mask.shape[0] + self._set_full_frame_meta(mask, scale) + dims = (self._internal_size, self._internal_size) + self._meta.setdefault("mask", []).append(cv2.resize(mask.mask, + dims, + interpolation=cv2.INTER_CUBIC)) + + def _set_full_frame_meta(self, mask, mask_scale): + """ Sets the meta information for displaying the mask in full frame mode. + + Parameters + ---------- + mask: :class:`lib.faces_detect.Mask` + The mask object + mask_scale: float + The scaling factor from the stored mask size to the internal mask size + + Sets the following parameters to :attr:`_meta`: + - roi_mask: the rectangular ROI box from the full frame that contains the original ROI + for the full frame mask + - top_left: The location that the roi_mask should be placed in the display frame + - affine_matrix: The matrix for transposing the mask to a full frame + - interpolator: The cv2 interpolation method to use for transposing mask to a + full frame + - slices: The (`x`, `y`) slice objects required to extract the mask ROI + from the full frame + """ + frame_dims = self._globals.current_frame["display_dims"] + scaled_mask_roi = np.rint(mask.original_roi * + self._globals.current_frame["scale"]).astype("int32") + + # Scale and clip the ROI to fit within display frame boundaries + clipped_roi = scaled_mask_roi.clip(min=(0, 0), max=frame_dims) + + # Obtain min and max points to get ROI as a rectangle + min_max = dict(min=clipped_roi.min(axis=0), max=clipped_roi.max(axis=0)) + + # Create a bounding box rectangle ROI + roi_dims = np.rint((min_max["max"][1] - min_max["min"][1], + min_max["max"][0] - min_max["min"][0])).astype("uint16") + roi = dict(mask=np.zeros(roi_dims, dtype="uint8")[..., None], + corners=np.expand_dims(scaled_mask_roi - min_max["min"], axis=0)) + # Block out areas outside of the actual mask ROI polygon + cv2.fillPoly(roi["mask"], roi["corners"], 255) + logger.trace("Setting Full Frame mask ROI. shape: %s", roi["mask"].shape) + + # obtain the slices for cropping mask from full frame + xy_slices = (slice(int(round(min_max["min"][1])), int(round(min_max["max"][1]))), + slice(int(round(min_max["min"][0])), int(round(min_max["max"][0])))) + + # Adjust affine matrix for internal mask size and display dimensions + adjustments = (np.array([[mask_scale, 0., 0.], [0., mask_scale, 0.]]), + np.array([[1 / self._globals.current_frame["scale"], 0., 0.], + [0., 1 / self._globals.current_frame["scale"], 0.], + [0., 0., 1.]])) + in_matrix = np.dot(adjustments[0], + np.concatenate((mask.affine_matrix, np.array([[0., 0., 1.]])))) + affine_matrix = np.dot(in_matrix, adjustments[1]) + + # Get the size of the mask roi box in the frame + side_sizes = (scaled_mask_roi[1][0] - scaled_mask_roi[0][0], + scaled_mask_roi[1][1] - scaled_mask_roi[0][1]) + mask_roi_size = (side_sizes[0] ** 2 + side_sizes[1] ** 2) ** 0.5 + + self._meta.setdefault("roi_mask", []).append(roi["mask"]) + self._meta.setdefault("affine_matrix", []).append(affine_matrix) + self._meta.setdefault("interpolator", []).append(mask.interpolator) + self._meta.setdefault("slices", []).append(xy_slices) + self._meta.setdefault("top_left", []).append(min_max["min"] + self._canvas.offset) + self._meta.setdefault("mask_roi_size", []).append(mask_roi_size) + + def _update_mask_image(self, key, face_index, rgb_color, opacity): + """ Obtain a mask, overlay over image and add to canvas or update. + + Parameters + ---------- + key: str + The base annotation name for creating tags + face_index: int + The index of the face within the current frame + rgb_color: tuple + The color that the mask should be displayed as + opacity: float + The opacity to apply to the mask + """ + mask = (self._meta["mask"][face_index] * opacity).astype("uint8") + if self._globals.is_zoomed: + display_image = self._update_mask_image_zoomed(mask, rgb_color) + top_left = self._zoomed_roi[:2] + # Hide all masks and only display selected + self._canvas.itemconfig("Mask", state="hidden") + self._canvas.itemconfig("Mask_face_{}".format(face_index), state="normal") + else: + display_image = self._update_mask_image_full_frame(mask, rgb_color, face_index) + top_left = self._meta["top_left"][face_index] + + if len(self._tk_faces) < face_index + 1: + logger.trace("Adding new Photo Image for face index: %s", face_index) + self._tk_faces.append(ImageTk.PhotoImage(display_image)) + elif self._tk_faces[face_index].width() != display_image.width: + logger.trace("Replacing existing Photo Image on width change for face index: %s", + face_index) + self._tk_faces[face_index] = ImageTk.PhotoImage(display_image) + else: + logger.trace("Updating existing image") + self._tk_faces[face_index].paste(display_image) + + self._object_tracker(key, + "image", + face_index, + top_left, + dict(image=self._tk_faces[face_index], anchor=tk.NW)) + + def _update_mask_image_zoomed(self, mask, rgb_color): + """ Update the mask image when zoomed in. + + Parameters + ---------- + mask: :class:`numpy.ndarray` + The raw mask + rgb_color: tuple + The rgb color selected for the mask + + Returns + ------- + :class: `PIL.Image` + The zoomed mask image formatted for display + """ + rgb = np.tile(rgb_color, self._zoomed_dims + (1, )).astype("uint8") + mask = cv2.resize(mask, + tuple(reversed(self._zoomed_dims)), + interpolation=cv2.INTER_CUBIC)[..., None] + rgba = np.concatenate((rgb, mask), axis=2) + return Image.fromarray(rgba) + + def _update_mask_image_full_frame(self, mask, rgb_color, face_index): + """ Update the mask image when in full frame view. + + Parameters + ---------- + mask: :class:`numpy.ndarray` + The raw mask + rgb_color: tuple + The rgb color selected for the mask + face_index: int + The index of the face being displayed + + Returns + ------- + :class: `PIL.Image` + The full frame mask image formatted for display + """ + frame_dims = self._globals.current_frame["display_dims"] + frame = np.zeros(frame_dims + (1, ), dtype="uint8") + interpolator = self._meta["interpolator"][face_index] + slices = self._meta["slices"][face_index] + mask = cv2.warpAffine(mask, + self._meta["affine_matrix"][face_index], + frame_dims, + frame, + flags=cv2.WARP_INVERSE_MAP | interpolator, + borderMode=cv2.BORDER_CONSTANT)[slices[0], slices[1]][..., None] + rgb = np.tile(rgb_color, mask.shape).astype("uint8") + rgba = np.concatenate((rgb, np.minimum(mask, self._meta["roi_mask"][face_index])), axis=2) + return Image.fromarray(rgba) + + def _update_roi_box(self, mask, face_index, color): + """ Update the region of interest box for the current mask. + + mask: :class:`~lib.faces_detect.Mask` + The current mask object to create an ROI box for + face_index: int + The index of the face within the current frame + color: str + The hex color code that the mask should be displayed as + """ + if self._globals.is_zoomed: + roi = self._zoomed_roi + box = np.array((roi[0], roi[1], roi[2], roi[1], roi[2], roi[3], roi[0], roi[3])) + else: + box = self._scale_to_display(mask.original_roi).flatten() + top_left = box[:2] - 10 + kwargs = dict(fill=color, font=("Default", 20, "bold"), text=str(face_index)) + self._object_tracker("mask_text", "text", face_index, top_left, kwargs) + kwargs = dict(fill="", outline=color, width=1) + self._object_tracker("mask_roi", "polygon", face_index, box, kwargs) + if self._globals.is_zoomed: + # Raise box above zoomed image + self._canvas.tag_raise("mask_roi_face_{}".format(face_index)) + + # << MOUSE HANDLING >> + # Mouse cursor display + def _update_cursor(self, event): + """ Set the cursor action. + + Update :attr:`_mouse_location` with the current cursor position and display appropriate + icon. + + Checks whether the mouse is over a mask ROI box and pops the paint icon. + + Parameters + ---------- + event: :class:`tkinter.Event` + The current tkinter mouse event + """ + roi_boxes = self._canvas.find_withtag("mask_roi") + item_ids = set(self._canvas.find_withtag("current")).intersection(roi_boxes) + if not item_ids: + self._canvas.config(cursor="") + self._canvas.itemconfig(self._mouse_location[0], state="hidden") + self._mouse_location[1] = None + return + item_id = list(item_ids)[0] + tags = self._canvas.gettags(item_id) + face_idx = int(next(tag for tag in tags if tag.startswith("face_")).split("_")[-1]) + + radius = self._brush_radius + coords = (event.x - radius, event.y - radius, event.x + radius, event.y + radius) + self._canvas.config(cursor="none") + self._canvas.coords(self._mouse_location[0], *coords) + self._canvas.itemconfig(self._mouse_location[0], + state="normal", + outline=self._cursor_color) + self._mouse_location[1] = face_idx + self._canvas.update_idletasks() + + def _control_click(self, event): + """ The action to perform when the user starts clicking and dragging the mouse whilst + pressing the control button. + + For editing the mask this will activate the opposite action than what is currently selected + (e.g. it will erase if draw is set and it will draw if erase is set) + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. + """ + self._drag_start(event, control_click=True) + + def _drag_start(self, event, control_click=False): # pylint:disable=arguments-differ + """ The action to perform when the user starts clicking and dragging the mouse. + + Paints on the mask with the appropriate draw or erase action. + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. + control_click: bool, optional + Indicates whether the control button is depressed when drag has commenced. If ``True`` + then the opposite of the selected action is performed. Default: ``False`` + """ + face_idx = self._mouse_location[1] + if face_idx is None: + self._drag_data = dict() + self._drag_callback = None + else: + self._drag_data["starting_location"] = np.array((event.x, event.y)) + self._drag_data["control_click"] = control_click + self._drag_data["color"] = np.array(tuple(int(self._control_color[1:][i:i + 2], 16) + for i in (0, 2, 4))) + self._drag_data["opacity"] = self._opacity + self._drag_callback = self._paint + + def _paint(self, event): + """ Paint or erase from Mask and update cursor on click and drag. + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. + """ + face_idx = self._mouse_location[1] + line = np.array((self._drag_data["starting_location"], (event.x, event.y))) + line, scale = self._transform_points(face_idx, line) + brush_radius = int(round(self._brush_radius * scale)) + color = 0 if self._edit_mode == "erase" else 255 + # Reverse action on control click + color = abs(color - 255) if self._drag_data["control_click"] else color + cv2.line(self._meta["mask"][face_idx], + tuple(line[0]), + tuple(line[1]), + color, + brush_radius * 2) + self._update_mask_image("mask", + face_idx, + self._drag_data["color"], + self._drag_data["opacity"]) + self._drag_data["starting_location"] = np.array((event.x, event.y)) + self._update_cursor(event) + + def _transform_points(self, face_index, points): + """ Transform the edit points from a full frame or zoomed view back to the mask. + + Parameters + ---------- + face_index: int + The index of the face within the current frame + points: :class:`numpy.ndarray` + The points that are to be translated from the viewer to the underlying + Detected Face + """ + if self._globals.is_zoomed: + offset = self._zoomed_roi[:2] + scale = self._internal_size / self._zoomed_dims[0] + t_points = np.rint((points - offset) * scale).astype("int32").squeeze() + else: + scale = self._internal_size / self._meta["mask_roi_size"][face_index] + t_points = np.expand_dims(points - self._canvas.offset, axis=0) + t_points = cv2.transform(t_points, self._meta["affine_matrix"][face_index]).squeeze() + t_points = np.rint(t_points).astype("int32") + logger.trace("original points: %s, transformed points: %s, scale: %s", + points, t_points, scale) + return t_points, scale + + def _drag_stop(self, event): + """ The action to perform when the user stops clicking and dragging the mouse. + + If a line hasn't been drawn then draw a circle. Update alignments. + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse event. Unused but required + """ + if not self._drag_data: + return + face_idx = self._mouse_location[1] + location = np.array(((event.x, event.y), )) + color = 0 if self._edit_mode == "erase" else 255 + # Reverse action on control click + color = abs(color - 255) if self._drag_data["control_click"] else color + if np.array_equal(self._drag_data["starting_location"], location[0]): + points, scale = self._transform_points(face_idx, location) + brush_radius = int(round(self._brush_radius * scale)) + cv2.circle(self._meta["mask"][face_idx], tuple(points), brush_radius, color, + thickness=-1) + self._mask_to_alignments(face_idx) + self._drag_data = dict() + self._update_cursor(event) + + def _mask_to_alignments(self, face_index): + """ Update the annotated mask to alignments. + + Parameters + ---------- + face_index: int + The index of the face in the current frame + """ + mask_type = self._control_vars["display"]["MaskType"].get().lower() + mask = self._meta["mask"][face_index].astype("float32") / 255.0 + self._det_faces.update.mask(self._globals.frame_index, face_index, mask, mask_type) + + def _adjust_brush_radius(self, increase=True): # pylint:disable=unused-argument + """ Adjust the brush radius up or down by 2px. + + Sets the control panel option for brush radius to 2 less or 2 more than its current value + + Parameters + ---------- + increase: bool, optional + ``True`` to increment brush radius, ``False`` to decrement. Default: ``True`` + """ + radius_var = self._control_vars["brush"]["BrushSize"] + current_val = radius_var.get() + new_val = min(100, current_val + 2) if increase else max(1, current_val - 2) + logger.trace("Adjusting brush radius from %s to %s", current_val, new_val) + radius_var.set(new_val) + + delta = new_val - current_val + if delta == 0: + return + current_coords = self._canvas.coords(self._mouse_location[0]) + new_coords = tuple(coord - delta if idx < 2 else coord + delta + for idx, coord in enumerate(current_coords)) + logger.trace("Adjusting brush coordinates from %s to %s", current_coords, new_coords) + self._canvas.coords(self._mouse_location[0], new_coords) diff --git a/tools/manual/frameviewer/frame.py b/tools/manual/frameviewer/frame.py new file mode 100644 index 0000000000..e42e581a4f --- /dev/null +++ b/tools/manual/frameviewer/frame.py @@ -0,0 +1,741 @@ +#!/usr/bin/env python3 +""" The frame viewer section of the manual tool GUI """ +import logging +import tkinter as tk +from tkinter import ttk, TclError + +from functools import partial +from time import time + +from lib.gui.control_helper import set_slider_rounding +from lib.gui.custom_widgets import Tooltip +from lib.gui.utils import get_images + +from .control import Navigation, BackgroundImage +from .editor import (BoundingBox, ExtractBox, Landmarks, Mask, # noqa pylint:disable=unused-import + Mesh, View) + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class DisplayFrame(ttk.Frame): # pylint:disable=too-many-ancestors + """ The main video display frame (top left section of GUI). + + Parameters + ---------- + parent: :class:`tkinter.PanedWindow` + The paned window that the display frame resides in + tk_globals: :class:`~tools.manual.manual.TkGlobals` + The tkinter variables that apply to the whole of the GUI + detected_faces: :class:`tools.manual.detected_faces.DetectedFaces` + The detected faces stored in the alignments file + """ + def __init__(self, parent, tk_globals, detected_faces): + logger.debug("Initializing %s: (parent: %s, tk_globals: %s, detected_faces: %s)", + self.__class__.__name__, parent, tk_globals, detected_faces) + super().__init__(parent) + + self._globals = tk_globals + self._det_faces = detected_faces + + self._actions_frame = ActionsFrame(self) + main_frame = ttk.Frame(self) + + self._transport_frame = ttk.Frame(main_frame) + self._nav = self._add_nav() + self._navigation = Navigation(self) + self._buttons = self._add_transport() + self._add_transport_tk_trace() + + video_frame = ttk.Frame(main_frame) + video_frame.bind("", self._resize) + + self._canvas = FrameViewer(video_frame, + self._globals, + self._det_faces, + self._actions_frame.actions, + self._actions_frame.tk_selected_action) + + self._actions_frame.add_optional_buttons(self.editors) + + self._transport_frame.pack(side=tk.BOTTOM, padx=5, fill=tk.X) + video_frame.pack(side=tk.TOP, expand=True, fill=tk.BOTH) + main_frame.pack(side=tk.RIGHT, expand=True, fill=tk.BOTH) + self.pack(side=tk.LEFT, anchor=tk.NW, expand=True, fill=tk.BOTH) + + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def _helptext(self): + """ dict: {`name`: `help text`} Helptext lookup for navigation buttons """ + return dict( + play="Play/Pause (SPACE)", + beginning="Go to First Frame (HOME)", + prev="Go to Previous Frame (Z)", + next="Go to Next Frame (X)", + end="Go to Last Frame (END)", + extract="Extract the faces to a folder... (Ctrl+E)", + save="Save the Alignments file (Ctrl+S)", + mode="Filter Frames to only those Containing the Selected Item (F)") + + @property + def _btn_action(self): + """ dict: {`name`: `action`} Command lookup for navigation buttons """ + actions = dict(play=self._navigation.handle_play_button, + beginning=self._navigation.goto_first_frame, + prev=self._navigation.decrement_frame, + next=self._navigation.increment_frame, + end=self._navigation.goto_last_frame, + extract=self._det_faces.extract, + save=self._det_faces.save) + return actions + + @property + def tk_selected_action(self): + """ :class:`tkinter.StringVar`: The variable holding the currently selected action """ + return self._actions_frame.tk_selected_action + + @property + def active_editor(self): + """ :class:`Editor`: The current editor in use based on :attr:`selected_action`. """ + return self._canvas.active_editor + + @property + def editors(self): + """ dict: All of the :class:`Editor` that the canvas holds """ + return self._canvas.editors + + @property + def navigation(self): + """ :class:`~tools.manual.frameviewer.control.Navigation`: Class that handles frame + Navigation and transport. """ + return self._navigation + + @property + def tk_control_colors(self): + """ :dict: Editor key with :class:`tkinter.StringVar` containing the selected color hex + code for each annotation """ + return {key: val["color"].tk_var for key, val in self._canvas.annotation_formats.items()} + + @property + def tk_selected_mask(self): + """ :dict: Editor key with :class:`tkinter.StringVar` containing the selected color hex + code for each annotation """ + return self._canvas.control_tk_vars["Mask"]["display"]["MaskType"] + + @property + def _filter_modes(self): + """ list: The filter modes combo box values """ + return ["All Frames", "Has Face(s)", "No Faces", "Multiple Faces"] + + def _add_nav(self): + """ Add the slider to navigate through frames """ + self._globals.tk_transport_index.trace("w", self._set_frame_index) + max_frame = self._globals.frame_count - 1 + + frame = ttk.Frame(self._transport_frame) + + frame.pack(side=tk.TOP, fill=tk.X, pady=(0, 5)) + lbl_frame = ttk.Frame(frame) + lbl_frame.pack(side=tk.RIGHT) + tbox = ttk.Entry(lbl_frame, + width=7, + textvariable=self._globals.tk_transport_index, + justify=tk.RIGHT) + tbox.pack(padx=0, side=tk.LEFT) + lbl = ttk.Label(lbl_frame, text="/{}".format(max_frame)) + lbl.pack(side=tk.RIGHT) + + cmd = partial(set_slider_rounding, + var=self._globals.tk_transport_index, + d_type=int, + round_to=1, + min_max=(0, max_frame)) + + nav = ttk.Scale(frame, + variable=self._globals.tk_transport_index, + from_=0, + to=max_frame, + command=cmd) + nav.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + return dict(entry=tbox, scale=nav, label=lbl) + + def _set_frame_index(self, *args): # pylint:disable=unused-argument + """ Set the actual frame index based on current slider position and filter mode. """ + try: + slider_position = self._globals.tk_transport_index.get() + except TclError: + # don't update the slider when the entry box has been cleared of any value + return + frames = self._det_faces.filter.frames_list + actual_position = max(0, min(len(frames) - 1, slider_position)) + if actual_position != slider_position: + self._globals.tk_transport_index.set(actual_position) + frame_idx = frames[actual_position] if frames else -1 + logger.trace("slider_position: %s, frame_idx: %s", actual_position, frame_idx) + self._globals.tk_frame_index.set(frame_idx) + + def _add_transport(self): + """ Add video transport controls """ + frame = ttk.Frame(self._transport_frame) + frame.pack(side=tk.BOTTOM, fill=tk.X) + icons = get_images().icons + buttons = dict() + for action in ("play", "beginning", "prev", "next", "end", "save", "extract", "mode"): + padx = (0, 6) if action in ("play", "prev", "mode") else (0, 0) + side = tk.RIGHT if action in ("extract", "save", "mode") else tk.LEFT + state = ["!disabled"] if action != "save" else ["disabled"] + if action != "mode": + icon = action if action != "extract" else "folder" + wgt = ttk.Button(frame, image=icons[icon], command=self._btn_action[action]) + wgt.state(state) + else: + wgt = self._add_filter_mode_combo(frame) + wgt.pack(side=side, padx=padx) + Tooltip(wgt, text=self._helptext[action]) + buttons[action] = wgt + logger.debug("Transport buttons: %s", buttons) + return buttons + + def _add_transport_tk_trace(self): + """ Add the tkinter variable traces to buttons """ + self._navigation.tk_is_playing.trace("w", self._play) + self._det_faces.tk_unsaved.trace("w", self._toggle_save_state) + + def _add_filter_mode_combo(self, frame): + """ Add the navigation mode combo box to the transport frame """ + self._globals.tk_filter_mode.set("All Frames") + self._globals.tk_filter_mode.trace("w", self._navigation.nav_scale_callback) + nav_frame = ttk.Frame(frame) + lbl = ttk.Label(nav_frame, text="Filter:") + lbl.pack(side=tk.LEFT, padx=(0, 5)) + combo = ttk.Combobox( + nav_frame, + textvariable=self._globals.tk_filter_mode, + state="readonly", + values=self._filter_modes) + combo.pack(side=tk.RIGHT) + return nav_frame + + def cycle_filter_mode(self): + """ Cycle the navigation mode combo entry """ + current_mode = self._globals.filter_mode + idx = (self._filter_modes.index(current_mode) + 1) % len(self._filter_modes) + self._globals.tk_filter_mode.set(self._filter_modes[idx]) + + def set_action(self, key): + """ Set the current action based on keyboard shortcut + + Parameters + ---------- + key: str + The pressed key + """ + # Allow key pad keys for numeric presses + key = key.replace("KP_", "") if key.startswith("KP_") else key + self._actions_frame.on_click(self._actions_frame.key_bindings[key]) + + def _resize(self, event): + """ Resize the image to fit the frame, maintaining aspect ratio """ + framesize = (event.width, event.height) + logger.trace("Resizing video frame. Framesize: %s", framesize) + self._globals.set_frame_display_dims(*framesize) + self._globals.tk_update.set(True) + + # << TRANSPORT >> # + def _play(self, *args, frame_count=None): # pylint:disable=unused-argument + """ Play the video file. """ + start = time() + is_playing = self._navigation.tk_is_playing.get() + icon = "pause" if is_playing else "play" + self._buttons["play"].config(image=get_images().icons[icon]) + + if not is_playing: + logger.debug("Pause detected. Stopping.") + return + + # Populate the filtered frames count on first frame + frame_count = self._det_faces.filter.count if frame_count is None else frame_count + self._navigation.increment_frame(frame_count=frame_count, is_playing=True) + delay = 16 # Cap speed at approx 60fps max. Unlikely to hit, but just in case + duration = int((time() - start) * 1000) + delay = max(1, delay - duration) + self.after(delay, lambda f=frame_count: self._play(f)) + + def _toggle_save_state(self, *args): # pylint:disable=unused-argument + """ Toggle the state of the save button when alignments are updated. """ + state = ["!disabled"] if self._det_faces.tk_unsaved.get() else ["disabled"] + self._buttons["save"].state(state) + + +class ActionsFrame(ttk.Frame): # pylint:disable=too-many-ancestors + """ The left hand action frame holding the action buttons. + + Parameters + ---------- + parent: :class:`DisplayFrame` + The Display frame that the Actions reside in + """ + def __init__(self, parent): + super().__init__(parent) + self.pack(side=tk.LEFT, fill=tk.Y, padx=(2, 4), pady=2) + self._globals = parent._globals + self._det_faces = parent._det_faces + + self._configure_styles() + self._actions = ("View", "BoundingBox", "ExtractBox", "Landmarks", "Mask") + self._initial_action = "View" + self._buttons = self._add_buttons() + self._static_buttons = self._add_static_buttons() + self._selected_action = self._set_selected_action_tkvar() + self._optional_buttons = dict() # Has to be set from parent after canvas is initialized + + @property + def actions(self): + """ tuple: The available action names as a tuple of strings. """ + return self._actions + + @property + def tk_selected_action(self): + """ :class:`tkinter.StringVar`: The variable holding the currently selected action """ + return self._selected_action + + @property + def key_bindings(self): + """ dict: {`key`: `action`}. The mapping of key presses to actions. Keyboard shortcut is + the first letter of each action. """ + return {"F{}".format(idx + 1): action for idx, action in enumerate(self._actions)} + + @property + def _helptext(self): + """ dict: `button key`: `button helptext`. The help text to display for each button. """ + inverse_keybindings = {val: key for key, val in self.key_bindings.items()} + retval = dict(View="View alignments", + BoundingBox="Bounding box editor", + ExtractBox="Location editor", + Mask="Mask editor", + Landmarks="Landmark point editor") + for item in retval: + retval[item] += " ({})".format(inverse_keybindings[item]) + return retval + + def _configure_styles(self): + """ Configure background color for Actions widget """ + style = ttk.Style() + style.configure("actions.TFrame", background='#d3d3d3') + style.configure("actions_selected.TButton", relief="flat", background="#bedaf1") + style.configure("actions_deselected.TButton", relief="flat") + self.config(style="actions.TFrame") + + def _add_buttons(self): + """ Add the action buttons to the Display window. + + Returns + ------- + dict: + The action name and its associated button. + """ + frame = ttk.Frame(self) + frame.pack(side=tk.TOP, fill=tk.Y) + buttons = dict() + for action in self.key_bindings.values(): + if action == self._initial_action: + btn_style = "actions_selected.TButton" + state = (["pressed", "focus"]) + else: + btn_style = "actions_deselected.TButton" + state = (["!pressed", "!focus"]) + + button = ttk.Button(frame, + image=get_images().icons[action.lower()], + command=lambda t=action: self.on_click(t), + style=btn_style) + button.state(state) + button.pack() + Tooltip(button, text=self._helptext[action]) + buttons[action] = button + return buttons + + def on_click(self, action): + """ Click event for all of the main buttons. + + Parameters + ---------- + action: str + The action name for the button that has called this event as exists in :attr:`_buttons` + """ + for title, button in self._buttons.items(): + if action == title: + button.configure(style="actions_selected.TButton") + button.state(["pressed", "focus"]) + else: + button.configure(style="actions_deselected.TButton") + button.state(["!pressed", "!focus"]) + self._selected_action.set(action) + + def _set_selected_action_tkvar(self): + """ Set the tkinter string variable that holds the currently selected editor action. + Add traceback to display or hide editor specific optional buttons. + + Returns + ------- + :class:`tkinter.StringVar + The variable that holds the currently selected action + """ + var = tk.StringVar() + var.set(self._initial_action) + var.trace("w", self._display_optional_buttons) + return var + + def _add_static_buttons(self): + """ Add the buttons to copy alignments from previous and next frames """ + lookup = dict(copy_prev=("Previous", "C"), copy_next=("Next", "V"), reload=("", "R")) + frame = ttk.Frame(self) + frame.pack(side=tk.TOP, fill=tk.Y) + sep = ttk.Frame(frame, height=2, relief=tk.RIDGE) + sep.pack(fill=tk.X, pady=5, side=tk.TOP) + buttons = dict() + tk_frame_index = self._globals.tk_frame_index + for action in ("copy_prev", "copy_next", "reload"): + if action == "reload": + icon = "reload3" + cmd = lambda f=tk_frame_index: self._det_faces.revert_to_saved(f.get()) # noqa + helptext = "Revert to saved Alignments ({})".format(lookup[action][1]) + else: + icon = action + direction = action.replace("copy_", "") + cmd = lambda f=tk_frame_index, d=direction: self._det_faces.update.copy( # noqa + f.get(), d) + helptext = "Copy {} Alignments ({})".format(*lookup[action]) + state = ["!disabled"] if action == "copy_next" else ["disabled"] + button = ttk.Button(frame, + image=get_images().icons[icon], + command=cmd, + style="actions_deselected.TButton") + button.state(state) + button.pack() + Tooltip(button, text=helptext) + buttons[action] = button + self._globals.tk_frame_index.trace("w", self._disable_enable_copy_buttons) + self._globals.tk_update.trace("w", self._disable_enable_reload_button) + return buttons + + def _disable_enable_copy_buttons(self, *args): # pylint: disable=unused-argument + """ Disable or enable the static buttons """ + position = self._globals.frame_index + face_count_per_index = self._det_faces.face_count_per_index + prev_exists = position != -1 and any(count != 0 + for count in face_count_per_index[:position]) + next_exists = position != -1 and any(count != 0 + for count in face_count_per_index[position + 1:]) + states = dict(prev=["!disabled"] if prev_exists else ["disabled"], + next=["!disabled"] if next_exists else ["disabled"]) + for direction in ("prev", "next"): + self._static_buttons["copy_{}".format(direction)].state(states[direction]) + + def _disable_enable_reload_button(self, *args): # pylint: disable=unused-argument + """ Disable or enable the static buttons """ + position = self._globals.frame_index + state = ["!disabled"] if (position != -1 and + self._det_faces.is_frame_updated(position)) else ["disabled"] + self._static_buttons["reload"].state(state) + + def add_optional_buttons(self, editors): + """ Add the optional editor specific action buttons """ + for name, editor in editors.items(): + actions = editor.actions + if not actions: + self._optional_buttons[name] = None + continue + frame = ttk.Frame(self) + sep = ttk.Frame(frame, height=2, relief=tk.RIDGE) + sep.pack(fill=tk.X, pady=5, side=tk.TOP) + seen_groups = set() + for action in actions.values(): + group = action["group"] + if group is not None and group not in seen_groups: + btn_style = "actions_selected.TButton" + state = (["pressed", "focus"]) + action["tk_var"].set(True) + seen_groups.add(group) + else: + btn_style = "actions_deselected.TButton" + state = (["!pressed", "!focus"]) + action["tk_var"].set(False) + button = ttk.Button(frame, + image=get_images().icons[action["icon"]], + style=btn_style) + button.config(command=lambda b=button: self._on_optional_click(b)) + button.state(state) + button.pack() + + helptext = action["helptext"] + hotkey = action["hotkey"] + helptext += "" if hotkey is None else " ({})".format(hotkey.upper()) + Tooltip(button, text=helptext) + self._optional_buttons.setdefault( + name, dict())[button] = dict(hotkey=hotkey, + group=group, + tk_var=action["tk_var"]) + self._optional_buttons[name]["frame"] = frame + self._display_optional_buttons() + + def _on_optional_click(self, button): + """ Click event for all of the optional buttons. + + Parameters + ---------- + button: str + The action name for the button that has called this event as exists in :attr:`_buttons` + """ + options = self._optional_buttons[self._selected_action.get()] + group = options[button]["group"] + for child in options["frame"].winfo_children(): + if child.winfo_class() != "TButton": + continue + child_group = options[child]["group"] + if child == button and group is not None: + child.configure(style="actions_selected.TButton") + child.state(["pressed", "focus"]) + options[child]["tk_var"].set(True) + elif child != button and group is not None and child_group == group: + child.configure(style="actions_deselected.TButton") + child.state(["!pressed", "!focus"]) + options[child]["tk_var"].set(False) + elif group is None and child_group is None: + if child.cget("style") == "actions_selected.TButton": + child.configure(style="actions_deselected.TButton") + child.state(["!pressed", "!focus"]) + options[child]["tk_var"].set(False) + else: + child.configure(style="actions_selected.TButton") + child.state(["pressed", "focus"]) + options[child]["tk_var"].set(True) + + def _display_optional_buttons(self, *args): # pylint:disable=unused-argument + """ Pack or forget the optional buttons depending on active editor """ + self._unbind_optional_hotkeys() + for editor, option in self._optional_buttons.items(): + if option is None: + continue + if editor == self._selected_action.get(): + logger.debug("Displaying optional buttons for '%s'", editor) + option["frame"].pack(side=tk.TOP, fill=tk.Y) + for child in option["frame"].winfo_children(): + if child.winfo_class() != "TButton": + continue + hotkey = option[child]["hotkey"] + if hotkey is not None: + logger.debug("Binding optional hotkey for editor '%s': %s", editor, hotkey) + self.winfo_toplevel().bind(hotkey.lower(), + lambda e, b=child: self._on_optional_click(b)) + elif option["frame"].winfo_ismapped(): + logger.debug("Hiding optional buttons for '%s'", editor) + option["frame"].pack_forget() + + def _unbind_optional_hotkeys(self): + """ Unbind all mapped optional button hotkeys """ + for editor, option in self._optional_buttons.items(): + if option is None or not option["frame"].winfo_ismapped(): + continue + for child in option["frame"].winfo_children(): + if child.winfo_class() != "TButton": + continue + hotkey = option[child]["hotkey"] + if hotkey is not None: + logger.debug("Unbinding optional hotkey for editor '%s': %s", editor, hotkey) + self.winfo_toplevel().unbind(hotkey.lower()) + + +class FrameViewer(tk.Canvas): # pylint:disable=too-many-ancestors + """ Annotation onto tkInter Canvas. + + Parameters + ---------- + parent: :class:`tkinter.ttk.Frame` + The parent frame for the canvas + tk_globals: :class:`~tools.manual.manual.TkGlobals` + The tkinter variables that apply to the whole of the GUI + detected_faces: :class:`AlignmentsData` + The alignments data for this manual session + actions: tuple + The available actions from :attr:`ActionFrame.actions` + tk_action_var: :class:`tkinter.StringVar` + The variable holding the currently selected action + """ + def __init__(self, parent, tk_globals, detected_faces, actions, tk_action_var): + logger.debug("Initializing %s: (parent: %s, tk_globals: %s, detected_faces: %s, " + "actions: %s, tk_action_var: %s)", self.__class__.__name__, + parent, tk_globals, detected_faces, actions, tk_action_var) + super().__init__(parent, bd=0, highlightthickness=0, background="black") + self.pack(side=tk.TOP, fill=tk.BOTH, expand=True, anchor=tk.E) + self._globals = tk_globals + self._det_faces = detected_faces + self._actions = actions + self._tk_action_var = tk_action_var + self._image = BackgroundImage(self) + self._editor_globals = dict(control_tk_vars=dict(), + annotation_formats=dict(), + key_bindings=dict()) + self._max_face_count = 0 + self._editors = self._get_editors() + self._add_callbacks() + self._change_active_editor() + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def selected_action(self): + """str: The name of the currently selected Editor action """ + return self._tk_action_var.get() + + @property + def control_tk_vars(self): + """ dict: dictionary of tkinter variables as populated by the right hand control panel. + Tracking for all control panel variables, for access from all editors. """ + return self._editor_globals["control_tk_vars"] + + @property + def key_bindings(self): + """ dict: dictionary of key bindings for each editor for access from all editors. """ + return self._editor_globals["key_bindings"] + + @property + def annotation_formats(self): + """ dict: The selected formatting options for each annotation """ + return self._editor_globals["annotation_formats"] + + @property + def active_editor(self): + """ :class:`Editor`: The current editor in use based on :attr:`selected_action`. """ + return self._editors[self.selected_action] + + @property + def editors(self): + """ dict: All of the :class:`Editor` objects that exist """ + return self._editors + + @property + def editor_display(self): + """ dict: List of editors and any additional annotations they should display. """ + return dict(View=["BoundingBox", "ExtractBox", "Landmarks", "Mesh"], + BoundingBox=["Mesh"], + ExtractBox=["Mesh"], + Landmarks=["ExtractBox", "Mesh"], + Mask=[]) + + @property + def offset(self): + """ tuple: The (`width`, `height`) offset of the canvas based on the size of the currently + displayed image """ + frame_dims = self._globals.current_frame["display_dims"] + offset_x = (self._globals.frame_display_dims[0] - frame_dims[0]) / 2 + offset_y = (self._globals.frame_display_dims[1] - frame_dims[1]) / 2 + logger.trace("offset_x: %s, offset_y: %s", offset_x, offset_y) + return offset_x, offset_y + + def _get_editors(self): + """ Get the object editors for the canvas. + + Returns + ------ + dict + The {`action`: :class:`Editor`} dictionary of editors for :attr:`_actions` name. + """ + editors = dict() + for editor_name in self._actions + ("Mesh", ): + editor = eval(editor_name)(self, # pylint:disable=eval-used + self._det_faces) + editors[editor_name] = editor + logger.debug(editors) + return editors + + def _add_callbacks(self): + """ Add the callback trace functions to the :class:`tkinter.Variable` s + + Adds callbacks for: + :attr:`_globals.tk_update` Update the display for the current image + :attr:`__tk_action_var` Update the mouse display tracking for current action + """ + self._globals.tk_update.trace("w", self._update_display) + self._tk_action_var.trace("w", self._change_active_editor) + + def _change_active_editor(self, *args): # pylint:disable=unused-argument + """ Update the display for the active editor. + + Hide the annotations that are not relevant for the selected editor. + Set the selected editor's cursor tracking. + + Parameters + ---------- + args: tuple, unused + Required for tkinter callback but unused + """ + to_display = [self.selected_action] + self.editor_display[self.selected_action] + to_hide = [editor for editor in self._editors if editor not in to_display] + for editor in to_hide: + self._editors[editor].hide_annotation() + + self.active_editor.bind_mouse_motion() + self.active_editor.set_mouse_click_actions() + self._globals.tk_update.set(True) + + def _update_display(self, *args): # pylint:disable=unused-argument + """ Update the display on frame cache update + + Notes + ----- + A little hacky, but the editors to display or hide are processed in alphabetical + order, so that they are always processed in the same order (for tag lowering and raising) + """ + if not self._globals.tk_update.get(): + return + self._image.refresh(self.active_editor.view_mode) + to_display = sorted([self.selected_action] + self.editor_display[self.selected_action]) + self._hide_additional_faces() + for editor in to_display: + self._editors[editor].update_annotation() + self._bind_unbind_keys() + self._globals.tk_update.set(False) + self.update_idletasks() + + def _hide_additional_faces(self): + """ Hide additional faces if the number of faces on the canvas reduces on a frame + change. """ + if self._globals.is_zoomed: + current_face_count = 1 + elif self._globals.frame_index == -1: + current_face_count = 0 + else: + current_face_count = len(self._det_faces.current_faces[self._globals.frame_index]) + + if current_face_count > self._max_face_count: + # Most faces seen to date so nothing to hide. Update max count and return + logger.debug("Incrementing max face count from: %s to: %s", + self._max_face_count, current_face_count) + self._max_face_count = current_face_count + return + for idx in range(current_face_count, self._max_face_count): + tag = "face_{}".format(idx) + if any(self.itemcget(item_id, "state") != "hidden" + for item_id in self.find_withtag(tag)): + logger.debug("Hiding face tag '%s'", tag) + self.itemconfig(tag, state="hidden") + + def _bind_unbind_keys(self): + """ Bind or unbind this editor's hotkeys depending on whether it is active. """ + unbind_keys = [key for key, binding in self.key_bindings.items() + if binding["bound_to"] is not None + and binding["bound_to"] != self.selected_action] + for key in unbind_keys: + logger.debug("Unbinding key '%s'", key) + self.winfo_toplevel().unbind(key) + self.key_bindings[key]["bound_to"] = None + + bind_keys = {key: binding[self.selected_action] + for key, binding in self.key_bindings.items() + if self.selected_action in binding + and binding["bound_to"] != self.selected_action} + for key, method in bind_keys.items(): + logger.debug("Binding key '%s' to method %s", key, method) + self.winfo_toplevel().bind(key, method) + self.key_bindings[key]["bound_to"] = self.selected_action diff --git a/tools/manual/manual.py b/tools/manual/manual.py new file mode 100644 index 0000000000..f55a6a7446 --- /dev/null +++ b/tools/manual/manual.py @@ -0,0 +1,845 @@ +#!/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. """ +import logging +import os +import sys +import tkinter as tk +from tkinter import ttk +from time import sleep + +import cv2 +import numpy as np + +from lib.gui.control_helper import ControlPanel +from lib.gui.utils import get_images, get_config, initialize_config, initialize_images +from lib.image import SingleFrameLoader +from lib.multithreading import MultiThread +from lib.utils import _video_extensions +from plugins.extract.pipeline import Extractor, ExtractMedia + +from .detected_faces import DetectedFaces, ThumbsCreator +from .faceviewer.frame import FacesFrame +from .frameviewer.frame import DisplayFrame + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class Manual(tk.Tk): + """ The main entry point for Faceswap's Manual Editor Tool. This tool is part of the Faceswap + Tools suite and should be called from ``python tools.py manual`` command. + + Allows for visual interaction with frames, faces and alignments file to perform various + adjustments to the alignments file. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + """ + + def __init__(self, arguments): + logger.debug("Initializing %s: (arguments: '%s')", self.__class__.__name__, arguments) + super().__init__() + self._initialize_tkinter() + self._globals = TkGlobals(arguments.frames) + + extractor = Aligner(self._globals) + self._detected_faces = DetectedFaces(self._globals, + arguments.alignments_path, + arguments.frames, + extractor) + + video_meta_data = self._detected_faces.video_meta_data + loader = FrameLoader(self._globals, arguments.frames, video_meta_data) + + self._detected_faces.load_faces() + self._containers = self._create_containers() + self._wait_for_threads(extractor, loader, video_meta_data) + self._generate_thumbs(arguments.frames, arguments.thumb_regen, arguments.single_process) + + self._display = DisplayFrame(self._containers["top"], + self._globals, + self._detected_faces) + _Options(self._containers["top"], self._globals, self._display) + + self._faces_frame = FacesFrame(self._containers["bottom"], + self._globals, + self._detected_faces, + self._display) + self._display.tk_selected_action.set("View") + + self.bind("", self._handle_key_press) + self._set_initial_layout() + logger.debug("Initialized %s", self.__class__.__name__) + + def _wait_for_threads(self, extractor, loader, video_meta_data): + """ The :class:`Aligner` and :class:`FramesLoader` are launched in background threads. + Wait for them to be initialized prior to proceeding. + + Parameters + ---------- + extractor: :class:`Aligner` + The extraction pipeline for the Manual Tool + loader: :class:`FramesLoader` + The frames loader for the Manual Tool + video_meta_data: dict + The video meta data that exists within the alignments file + + Notes + ----- + Because some of the initialize checks perform extra work once their threads are complete, + they should only return ``True`` once, and should not be queried again. + """ + extractor_init = False + frames_init = False + while True: + extractor_init = extractor_init if extractor_init else extractor.is_initialized + frames_init = frames_init if frames_init else loader.is_initialized + if extractor_init and frames_init: + logger.debug("Threads inialized") + break + logger.debug("Threads not initialized. Waiting...") + sleep(1) + + extractor.link_faces(self._detected_faces) + if any(val is None for val in video_meta_data.values()): + logger.debug("Saving video meta data to alignments file") + self._detected_faces.save_video_meta_data(**loader.video_meta_data) + + def _generate_thumbs(self, input_location, force, single_process): + """ Check whether thumbnails are stored in the alignments file and if not generate them. + + Parameters + ---------- + input_location: str + The input video or folder of images + force: bool + ``True`` if the thumbnails should be regenerated even if they exist, otherwise + ``False`` + single_process: bool + ``True`` will extract thumbs from a video in a single process, ``False`` will run + parallel threads + """ + thumbs = ThumbsCreator(self._detected_faces, input_location, single_process) + if thumbs.has_thumbs and not force: + return + logger.debug("Generating thumbnails cache") + thumbs.generate_cache() + logger.debug("Generated thumbnails cache") + + def _initialize_tkinter(self): + """ Initialize a standalone tkinter instance. """ + logger.debug("Initializing tkinter") + for widget in ("TButton", "TCheckbutton", "TRadiobutton"): + self.unbind_class(widget, "") + initialize_config(self, None, None, None) + initialize_images() + get_config().set_geometry(940, 600, fullscreen=True) + self.title("Faceswap.py - Visual Alignments") + logger.debug("Initialized tkinter") + + def _create_containers(self): + """ Create the paned window containers for various GUI elements + + Returns + ------- + dict: + The main containers of the manual tool. + """ + logger.debug("Creating containers") + main = tk.PanedWindow(self, + sashrelief=tk.RIDGE, + sashwidth=2, + sashpad=4, + orient=tk.VERTICAL, + name="pw_main") + main.pack(fill=tk.BOTH, expand=True) + + top = ttk.Frame(main, name="frame_top") + main.add(top) + + bottom = ttk.Frame(main, name="frame_bottom") + main.add(bottom) + retval = dict(main=main, top=top, bottom=bottom) + logger.debug("Created containers: %s", retval) + return retval + + def _handle_key_press(self, event): + """ Keyboard shortcuts + + Parameters + ---------- + event: :class:`tkinter.Event()` + The tkinter key press event + + Notes + ----- + The following keys are reserved for the :mod:`tools.lib_manual.editor` classes + * Delete - Used for deleting faces + * [] - decrease / increase brush size + * B, D, E, M - Optional Actions (Brush, Drag, Erase, Zoom) + """ + # Alt modifier appears to be broken in Windows so don't use it. + modifiers = {0x0001: 'shift', + 0x0004: 'ctrl'} + + tk_pos = self._globals.tk_frame_index + bindings = { + "z": self._display.navigation.decrement_frame, + "x": self._display.navigation.increment_frame, + "space": self._display.navigation.handle_play_button, + "home": self._display.navigation.goto_first_frame, + "end": self._display.navigation.goto_last_frame, + "down": lambda d="down": self._faces_frame.canvas_scroll(d), + "up": lambda d="up": self._faces_frame.canvas_scroll(d), + "next": lambda d="page-down": self._faces_frame.canvas_scroll(d), + "prior": lambda d="page-up": self._faces_frame.canvas_scroll(d), + "f": self._display.cycle_filter_mode, + "f1": lambda k=event.keysym: self._display.set_action(k), + "f2": lambda k=event.keysym: self._display.set_action(k), + "f3": lambda k=event.keysym: self._display.set_action(k), + "f4": lambda k=event.keysym: self._display.set_action(k), + "f5": lambda k=event.keysym: self._display.set_action(k), + "f9": lambda k=event.keysym: self._faces_frame.set_annotation_display(k), + "f10": lambda k=event.keysym: self._faces_frame.set_annotation_display(k), + "c": lambda f=tk_pos.get(), d="prev": self._detected_faces.update.copy(f, d), + "v": lambda f=tk_pos.get(), d="next": self._detected_faces.update.copy(f, d), + "ctrl_s": self._detected_faces.save, + "r": lambda f=tk_pos.get(): self._detected_faces.revert_to_saved(f)} + + # Allow keypad keys to be used for numbers + press = event.keysym.replace("KP_", "") if event.keysym.startswith("KP_") else event.keysym + modifier = "_".join(val for key, val in modifiers.items() if event.state & key != 0) + key_press = "_".join([modifier, press]) if modifier else press + if key_press.lower() in bindings: + logger.trace("key press: %s, action: %s", key_press, bindings[key_press.lower()]) + self.focus_set() + bindings[key_press.lower()]() + + def _set_initial_layout(self): + """ Set the favicon and the bottom frame position to correct location to display full + frame window. + + Notes + ----- + The favicon pops the tkinter GUI (without loaded elements) as soon as it is called, so + this is set last. + """ + logger.debug("Setting initial layout") + self.tk.call("wm", + "iconphoto", + self._w, get_images().icons["favicon"]) # pylint:disable=protected-access + location = int(self.winfo_screenheight() // 1.5) + self._containers["main"].sash_place(0, 1, location) + self.update_idletasks() + + def process(self): + """ The entry point for the Visual Alignments tool from :mod:`lib.tools.manual.cli`. + + Launch the tkinter Visual Alignments Window and run main loop. + """ + logger.debug("Launching mainloop") + self.mainloop() + + +class _Options(ttk.Frame): # pylint:disable=too-many-ancestors + """ Control panel options for currently displayed Editor. This is the right hand panel of the + GUI that holds editor specific settings and annotation display settings. + + parent: :class:`tkinter.ttk.Frame` + The parent frame for the control panel options + tk_globals: :class:`~tools.manual.manual.TkGlobals` + The tkinter variables that apply to the whole of the GUI + display_frame: :class:`DisplayFrame` + The frame that holds the editors + """ + def __init__(self, parent, tk_globals, display_frame): + logger.debug("Initializing %s: (parent: %s, tk_globals: %s, display_frame: %s)", + self.__class__.__name__, parent, tk_globals, display_frame) + super().__init__(parent) + + self._globals = tk_globals + self._display_frame = display_frame + self._control_panels = self._initialize() + self._set_tk_callbacks() + self._update_options() + self.pack(side=tk.RIGHT, fill=tk.Y) + logger.debug("Initialized %s", self.__class__.__name__) + + def _initialize(self): + """ Initialize all of the control panels, then display the default panel. + + Adds the control panel to :attr:`_control_panels` and sets the traceback to update + display when a panel option has been changed. + + Notes + ----- + All panels must be initialized at the beginning so that the global format options are not + reset to default when the editor is first selected. + + The Traceback must be set after the panel has first been packed as otherwise it interferes + with the loading of the faces pane. + """ + self._initialize_face_options() + frame = ttk.Frame(self) + frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + panels = dict() + for name, editor in self._display_frame.editors.items(): + logger.debug("Initializing control panel for '%s' editor", name) + controls = editor.controls + panel = ControlPanel(frame, controls["controls"], + option_columns=2, + columns=1, + max_columns=1, + header_text=controls["header"], + blank_nones=False, + label_width=18, + scrollbar=False) + panel.pack_forget() + panels[name] = panel + return panels + + def _initialize_face_options(self): + """ Set the Face Viewer options panel, beneath the standard control options. """ + frame = ttk.Frame(self) + frame.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=5) + size_frame = ttk.Frame(frame) + size_frame.pack(side=tk.RIGHT) + lbl = ttk.Label(size_frame, text="Face Size:") + lbl.pack(side=tk.LEFT) + cmb = ttk.Combobox(size_frame, + value=["Tiny", "Small", "Medium", "Large", "Extra Large"], + state="readonly", + textvariable=self._globals.tk_faces_size) + self._globals.tk_faces_size.set("Medium") + cmb.pack(side=tk.RIGHT, padx=5) + + def _set_tk_callbacks(self): + """ Sets the callback to change to the relevant control panel options when the selected + editor is changed, and the display update on panel option change.""" + self._display_frame.tk_selected_action.trace("w", self._update_options) + seen_controls = set() + for name, editor in self._display_frame.editors.items(): + for ctl in editor.controls["controls"]: + if ctl in seen_controls: + # Some controls are re-used (annotation format), so skip if trace has already + # been set + continue + logger.debug("Adding control update callback: (editor: %s, control: %s)", + name, ctl.title) + seen_controls.add(ctl) + ctl.tk_var.trace("w", lambda *e: self._globals.tk_update.set(True)) + + def _update_options(self, *args): # pylint:disable=unused-argument + """ Update the control panel display for the current editor. + + If the options have not already been set, then adds the control panel to + :attr:`_control_panels`. Displays the current editor's control panel + + Parameters + ---------- + args: tuple + Unused but required for tkinter variable callback + """ + self._clear_options_frame() + editor = self._display_frame.tk_selected_action.get() + logger.debug("Displaying control panel for editor: '%s'", editor) + self._control_panels[editor].pack(expand=True, fill=tk.BOTH) + + def _clear_options_frame(self): + """ Hides the currently displayed control panel """ + for editor, panel in self._control_panels.items(): + if panel.winfo_ismapped(): + logger.debug("Hiding control panel for: %s", editor) + panel.pack_forget() + + +class TkGlobals(): + """ Holds Tkinter Variables and other frame information that need to be accessible from all + areas of the GUI. + + Parameters + ---------- + input_location: str + The location of the input folder of frames or video file + """ + def __init__(self, input_location): + logger.debug("Initializing %s: (input_location: %s)", + self.__class__.__name__, input_location) + self._tk_vars = self._get_tk_vars() + + self._is_video = self._check_input(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) + logger.debug("Initialized %s", self.__class__.__name__) + + @classmethod + def _get_tk_vars(cls): + """ Create and initialize the tkinter variables. + + Returns + ------- + dict + The variable name as key, the variable as value + """ + retval = dict() + for name in ("frame_index", "transport_index", "face_index"): + var = tk.IntVar() + var.set(0) + retval[name] = var + for name in ("update", "update_active_viewport", "is_zoomed"): + var = tk.BooleanVar() + var.set(False) + retval[name] = var + for name in ("filter_mode", "faces_size"): + retval[name] = tk.StringVar() + return retval + + @property + def current_frame(self): + """ dict: The currently displayed frame in the frame viewer with it's meta information. Key + and Values are as follows: + + **image** (:class:`numpy.ndarry`): The currently displayed frame in original dimensions + + **scale** (`float`): The scaling factor to use to resize the image to the display + window + + **interpolation** (`int`): The opencv interpolator ID to use for resizing the image to + the display window + + **display_dims** (`tuple`): The size of the currently displayed frame, sized for the + display window + + **filename** (`str`): The filename of the currently displayed frame + """ + return self._current_frame + + @property + def frame_count(self): + """ int: The total number of frames for the input location """ + return self._frame_count + + @property + def tk_face_index(self): + """ :class:`tkinter.IntVar`: The variable that holds the face index of the selected face + within the current frame when in zoomed mode. """ + return self._tk_vars["face_index"] + + @property + def tk_update_active_viewport(self): + """ :class:`tkinter.BooleanVar`: Boolean Variable that is traced by the viewport's active + frame to update.. """ + return self._tk_vars["update_active_viewport"] + + @property + def face_index(self): + """ int: The currently displayed face index when in zoomed mode. """ + return self._tk_vars["face_index"].get() + + @property + def frame_display_dims(self): + """ tuple: The (`width`, `height`) of the video display frame in pixels. """ + return self._frame_display_dims + + @property + def frame_index(self): + """ int: The currently displayed frame index. NB This returns -1 if there are no frames + that meet the currently selected filter criteria. """ + return self._tk_vars["frame_index"].get() + + @property + def tk_frame_index(self): + """ :class:`tkinter.IntVar`: The variable holding the current frame index. """ + return self._tk_vars["frame_index"] + + @property + def filter_mode(self): + """ str: The currently selected navigation mode. """ + return self._tk_vars["filter_mode"].get() + + @property + def tk_filter_mode(self): + """ :class:`tkinter.StringVar`: The variable holding the currently selected navigation + filter mode. """ + return self._tk_vars["filter_mode"] + + @property + def tk_faces_size(self): + """ :class:`tkinter.StringVar`: The variable holding the currently selected Faces Viewer + thumbnail size. """ + return self._tk_vars["faces_size"] + + @property + def is_video(self): + """ bool: ``True`` if the input is a video file, ``False`` if it is a folder of images. """ + return self._is_video + + @property + def tk_is_zoomed(self): + """ :class:`tkinter.BooleanVar`: The variable holding the value indicating whether the + frame viewer is zoomed into a face or zoomed out to the full frame. """ + return self._tk_vars["is_zoomed"] + + @property + def is_zoomed(self): + """ bool: ``True`` if the frame viewer is zoomed into a face, ``False`` if the frame viewer + is displaying a full frame. """ + return self._tk_vars["is_zoomed"].get() + + @property + def tk_transport_index(self): + """ :class:`tkinter.IntVar`: The current index of the display frame's transport slider. """ + return self._tk_vars["transport_index"] + + @property + def tk_update(self): + """ :class:`tkinter.BooleanVar`: The variable holding the trigger that indicates that a + full update needs to occur. """ + return self._tk_vars["update"] + + @staticmethod + def _check_input(frames_location): + """ Check whether the input is a video + + Parameters + ---------- + frames_location: str + The input location for video or images + + Returns + ------- + bool: 'True' if input is a video 'False' if it is a folder. + """ + if os.path.isdir(frames_location): + retval = False + elif os.path.splitext(frames_location)[1].lower() in _video_extensions: + retval = True + else: + logger.error("The input location '%s' is not valid", frames_location) + sys.exit(1) + logger.debug("Input '%s' is_video: %s", frames_location, retval) + return retval + + def set_frame_count(self, count): + """ Set the count of total number of frames to :attr:`frame_count` when the + :class:`FramesLoader` has completed loading. + + Parameters + ---------- + count: int + The number of frames that exist for this session + """ + logger.debug("Setting frame_count to : %s", count) + self._frame_count = count + + def set_current_frame(self, image, filename): + """ Set the frame and meta information for the currently displayed frame. Populates the + attribute :attr:`current_frame` + + Parameters + ---------- + image: :class:`numpy.ndarray` + The image used to display in the Frame Viewer + filename: str + The filename of the current frame + """ + scale = min(self.frame_display_dims[0] / image.shape[1], + self.frame_display_dims[1] / image.shape[0]) + self._current_frame["image"] = image + self._current_frame["filename"] = filename + self._current_frame["scale"] = scale + self._current_frame["interpolation"] = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA + self._current_frame["display_dims"] = (int(round(image.shape[1] * scale)), + int(round(image.shape[0] * scale))) + logger.trace({k: v.shape if isinstance(v, np.ndarray) else v + for k, v in self._current_frame.items()}) + + def set_frame_display_dims(self, width, height): + """ Set the size, in pixels, of the video frame display window and resize the displayed + frame. + + Used on a frame resize callback, sets the :attr:frame_display_dims`. + + Parameters + ---------- + width: int + The width of the frame holding the video canvas in pixels + height: int + The height of the frame holding the video canvas in pixels + """ + self._frame_display_dims = (int(width), int(height)) + image = self._current_frame["image"] + scale = min(self.frame_display_dims[0] / image.shape[1], + self.frame_display_dims[1] / image.shape[0]) + self._current_frame["scale"] = scale + self._current_frame["interpolation"] = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA + self._current_frame["display_dims"] = (int(round(image.shape[1] * scale)), + int(round(image.shape[0] * scale))) + logger.trace({k: v.shape if isinstance(v, np.ndarray) else v + for k, v in self._current_frame.items()}) + + +class Aligner(): + """ The :class:`Aligner` class sets up an extraction pipeline for each of the current Faceswap + Aligners, along with the Landmarks based Maskers. When new landmarks are required, the bounding + boxes from the GUI are passed to this class for pushing through the pipeline. The resulting + Landmarks and Masks are then returned. + + Parameters + ---------- + tk_globals: :class:`~tools.manual.manual.TkGlobals` + The tkinter variables that apply to the whole of the GUI + """ + def __init__(self, tk_globals): + logger.debug("Initializing: %s (tk_globals: %s)", self.__class__.__name__, tk_globals) + self._globals = tk_globals + self._aligners = {"cv2-dnn": None, "FAN": None, "mask": None} + self._aligner = "FAN" + self._detected_faces = None + self._frame_index = None + self._face_index = None + self._init_thread = self._background_init_aligner() + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def _in_queue(self): + """ :class:`queue.Queue` - The input queue to the extraction pipeline. """ + return self._aligners[self._aligner].input_queue + + @property + def _feed_face(self): + """ :class:`plugins.extract.pipeline.ExtractMedia`: The current face for feeding into the + aligner, formatted for the pipeline """ + face = self._detected_faces.current_faces[self._frame_index][self._face_index] + return ExtractMedia( + self._globals.current_frame["filename"], + self._globals.current_frame["image"], + detected_faces=[face]) + + @property + def is_initialized(self): + """ 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") + self._init_thread.check_and_raise_error() + else: + logger.trace("Aligner initialized") + self._init_thread.join() + return not thread_is_alive + + def _background_init_aligner(self): + """ Launch the aligner in a background thread so we can run other tasks whilst + waiting for initialization """ + logger.debug("Launching aligner initialization thread") + thread = MultiThread(self._init_aligner, + thread_count=1, + name="{}.init_aligner".format(self.__class__.__name__)) + thread.start() + logger.debug("Launched aligner initialization thread") + return thread + + def _init_aligner(self): + """ 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"): + logger.debug("Initializing aligner: %s", model) + plugin = None if model == "mask" else model + aligner = Extractor(None, plugin, ["components", "extended"], + multiprocess=True, normalize_method="hist") + if plugin: + aligner.set_batchsize("align", 1) # Set the batchsize to 1 + aligner.launch() + logger.debug("Initialized %s Extractor", model) + self._aligners[model] = aligner + + def link_faces(self, detected_faces): + """ 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. + + Once the Aligner has initialized, this function is called to add the + :class:`~tools.manual.detected_faces.DetectedFaces` class as a property of the Aligner. + + Parameters + ---------- + detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` + The class that holds the :class:`~lib.faces_detect.DetectedFace` objects for the + current Manual session + """ + logger.debug("Linking detected_faces: %s", detected_faces) + self._detected_faces = detected_faces + + def get_landmarks(self, frame_index, face_index, aligner): + """ 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. + + Parameters + ---------- + frame_index: int + 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"] + The aligner to use to extract the face + + Returns + ------- + :class:`numpy.ndarray` + The 68 point landmark alignments + """ + logger.trace("frame_index: %s, face_index: %s, aligner: %s", + 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) + return detected_face.landmarks_xy + + def get_masks(self, frame_index, face_index): + """ 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 + + Parameters + ---------- + frame_index: int + The frame index to extract the aligned face for + face_index: int + The face index within the current frame to extract the face for + + Returns + ------- + dict + The updated masks + """ + logger.trace("frame_index: %s, face_index: %s", frame_index, face_index) + self._frame_index = frame_index + self._face_index = face_index + self._aligner = "mask" + self._in_queue.put(self._feed_face) + detected_face = next(self._aligners["mask"].detected_faces()).detected_faces[0] + logger.debug("mask: %s", detected_face.mask) + return detected_face.mask + + def set_normalization_method(self, method): + """ 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 + The normalization method to use + """ + logger.debug("Setting normalization method to: '%s'", method) + for plugin, aligner in self._aligners.items(): + if plugin == "mask": + continue + aligner.set_aligner_normalization_method(method) + + +class FrameLoader(): + """ Loads the frames, sets the frame count to :attr:`TkGlobals.frame_count` and handles the + return of the correct frame for the GUI. + + Parameters + ---------- + tk_globals: :class:`~tools.manual.manual.TkGlobals` + The tkinter variables that apply to the whole of the GUI + frames_location: str + The path to the input frames + video_meta_data: dict + The meta data held within the alignments file, if it exists and the input is a video + """ + def __init__(self, tk_globals, frames_location, video_meta_data): + logger.debug("Initializing %s: (tk_globals: %s, frames_location: '%s', " + "video_meta_data: %s)", self.__class__.__name__, tk_globals, frames_location, + video_meta_data) + self._globals = tk_globals + self._loader = None + self._current_idx = 0 + self._init_thread = self._background_init_frames(frames_location, video_meta_data) + self._globals.tk_frame_index.trace("w", self._set_frame) + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def is_initialized(self): + """ bool: ``True`` if the Frame Loader has completed initialization otherwise + ``False``. """ + thread_is_alive = self._init_thread.is_alive() + if thread_is_alive: + self._init_thread.check_and_raise_error() + else: + self._init_thread.join() + # Setting the initial frame cannot be done in the thread, so set when queried from main + self._set_frame(initialize=True) + return not thread_is_alive + + @property + def video_meta_data(self): + """ dict: The pts_time and key frames for the loader. """ + return self._loader.video_meta_data + + def _background_init_frames(self, frames_location, video_meta_data): + """ Launch the images loader in a background thread so we can run other tasks whilst + waiting for initialization. """ + thread = MultiThread(self._load_images, + frames_location, + video_meta_data, + thread_count=1, + name="{}.init_frames".format(self.__class__.__name__)) + thread.start() + return thread + + def _load_images(self, frames_location, video_meta_data): + """ Load the images in a background thread. """ + self._loader = SingleFrameLoader(frames_location, video_meta_data=video_meta_data) + self._globals.set_frame_count(self._loader.count) + + def _set_frame(self, *args, initialize=False): # pylint:disable=unused-argument + """ Set the currently loaded frame to :attr:`_current_frame` and trigger a full GUI update. + + If the loader has not been initialized, or the navigation position is the same as the + current position and the face is not zoomed in, then this returns having done nothing. + + Parameters + ---------- + args: tuple + :class:`tkinter.Event` arguments. Required but not used. + initialize: bool, optional + ``True`` if initializing for the first frame to be displayed otherwise ``False``. + Default: ``False`` + """ + position = self._globals.frame_index + if not initialize and (position == self._current_idx and not self._globals.is_zoomed): + logger.trace("Update criteria not met. Not updating: (initialize: %s, position: %s, " + "current_idx: %s, is_zoomed: %s)", initialize, position, + self._current_idx, self._globals.is_zoomed) + return + if position == -1: + filename = "No Frame" + frame = np.ones(self._globals.frame_display_dims + (3, ), dtype="uint8") + else: + filename, frame = self._loader.image_from_index(position) + logger.trace("filename: %s, frame: %s, position: %s", filename, frame.shape, position) + self._globals.set_current_frame(frame, filename) + self._current_idx = position + self._globals.tk_update.set(True) + self._globals.tk_update_active_viewport.set(True) From cd910769858703c4db6cdff56074a6c49af678e6 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 6 Aug 2020 09:17:06 +0100 Subject: [PATCH 259/981] Windows Installer - Install Conda to C:\ if spaces in user path --- .install/windows/install.nsi | 105 ++++++++++++++++++++++++----------- 1 file changed, 72 insertions(+), 33 deletions(-) diff --git a/.install/windows/install.nsi b/.install/windows/install.nsi index 9d4b54c313..8b146b2479 100644 --- a/.install/windows/install.nsi +++ b/.install/windows/install.nsi @@ -206,7 +206,6 @@ Function CheckSetupType StrCpy $Log "$log(check) Setting up for: $setupType$\n" FunctionEnd - Function CheckCustomCondaPath ${NSD_GetText} $ctlCondaText $2 ${If} $2 != "" @@ -223,41 +222,57 @@ Function CheckCustomCondaPath ${EndIf} FunctionEnd +Function CheckConda + # miniconda + nsExec::ExecToStack "$\"$dirMiniconda\Scripts\conda.exe$\" -V" + pop $0 + pop $1 + + nsExec::ExecToStack "$\"$dirMinicondaAll\Scripts\conda.exe$\" -V" + pop $2 + pop $3 + + # anaconda + nsExec::ExecToStack "$\"$dirAnaconda\Scripts\conda.exe$\" -V" + pop $4 + pop $5 + + nsExec::ExecToStack "$\"$dirAnacondaAll\Scripts\conda.exe$\" -V" + pop $6 + pop $7 + + ${If} $0 == 0 + StrCpy $dirConda "$dirMiniconda" + StrCpy $Log "$log(check) MiniConda installed: $1" + ${ElseIf} $2 == 0 + StrCpy $dirConda "$dirMinicondaAll" + StrCpy $Log "$log(check) MiniConda installed: $3" + ${ElseIf} $4 == 0 + StrCpy $dirConda "$dirAnaconda" + StrCpy $Log "$log(check) AnaConda installed: $5" + ${ElseIf} $6 == 0 + StrCpy $dirConda "$dirAnacondaAll" + StrCpy $Log "$log(check) AnaConda installed: $7" + ${EndIf} +FunctionEnd + Function CheckPrerequisites # Conda - # miniconda - nsExec::ExecToStack "$\"$dirMiniconda\Scripts\conda.exe$\" -V" - pop $0 - pop $1 - - nsExec::ExecToStack "$\"$dirMinicondaAll\Scripts\conda.exe$\" -V" - pop $2 - pop $3 - - # anaconda - nsExec::ExecToStack "$\"$dirAnaconda\Scripts\conda.exe$\" -V" - pop $4 - pop $5 - - nsExec::ExecToStack "$\"$dirAnacondaAll\Scripts\conda.exe$\" -V" - pop $6 - pop $7 + Call CheckConda + Push $PROFILE + Call CheckForSpaces + Pop $R0 + # If spaces in user profile look for and install Conda in C: + ${If} $dirConda == "" + ${AndIf} $R0 != 0 + StrCpy $dirMiniconda "C:\Miniconda3" + StrCpy $dirAnaconda "C:\Anaconda3" + Call CheckConda + ${EndIf} - ${If} $0 == 0 - StrCpy $dirConda "$dirMiniconda" - StrCpy $Log "$log(check) MiniConda installed: $1" - ${ElseIf} $2 == 0 - StrCpy $dirConda "$dirMinicondaAll" - StrCpy $Log "$log(check) MiniConda installed: $3" - ${ElseIf} $4 == 0 - StrCpy $dirConda "$dirAnaconda" - StrCpy $Log "$log(check) AnaConda installed: $5" - ${ElseIf} $6 == 0 - StrCpy $dirConda "$dirAnacondaAll" - StrCpy $Log "$log(check) AnaConda installed: $7" - ${Else} - StrCpy $InstallConda 1 - ${EndIf} + ${If} $dirConda == "" + StrCpy $InstallConda 1 + ${EndIf} # CPU Capabilities ${If} ${CPUSupports} "AVX2" @@ -274,6 +289,30 @@ Function CheckPrerequisites StrCpy $Log "$Log(check) Completed check for installed applications$\n" FunctionEnd +Function CheckForSpaces +# Check a string for space (Used for defining MiniConda install Location) + Exch $R0 + Push $R1 + Push $R2 + Push $R3 + StrCpy $R1 -1 + StrCpy $R3 $R0 + StrCpy $R0 0 + loop: + StrCpy $R2 $R3 1 $R1 + IntOp $R1 $R1 - 1 + StrCmp $R2 "" done + StrCmp $R2 " " 0 loop + IntOp $R0 $R0 + 1 + Goto loop + done: + Pop $R3 + Pop $R2 + Pop $R1 + Exch $R0 + +FunctionEnd + Section Install Push $Log Call MultiDetailPrint From 2a1457f21fa78ebfe555f097081aa8ae4a094135 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 7 Aug 2020 11:16:11 +0100 Subject: [PATCH 260/981] convert - Add a "skip_mux" option for ffmpeg writer --- plugins/convert/writer/ffmpeg.py | 17 +- plugins/convert/writer/ffmpeg_defaults.py | 239 +++++++++------------- 2 files changed, 113 insertions(+), 143 deletions(-) diff --git a/plugins/convert/writer/ffmpeg.py b/plugins/convert/writer/ffmpeg.py index 3fb3ccf37d..4031f00de8 100644 --- a/plugins/convert/writer/ffmpeg.py +++ b/plugins/convert/writer/ffmpeg.py @@ -145,15 +145,17 @@ def mux_audio(self): ImageIO is a useful lib for frames > video as it also packages the ffmpeg binary however muxing audio is non-trivial, so this is done afterwards with ffmpy. A future fix could be implemented to mux audio with the frames """ + if self.config["skip_mux"]: + logger.info("Skipping audio muxing due to configuration settings.") + self._rename_tmp_file() + return + logger.info("Muxing Audio...") if self.frame_ranges is not None: logger.warning("Muxing audio is not currently supported for limited frame ranges." "The output video has been created but you will need to mux audio " "yourself") - os.rename(self.video_tmp_file, self.video_file) - logger.debug("Removing temp file") - if os.path.isfile(self.video_tmp_file): - os.remove(self.video_tmp_file) + self._rename_tmp_file() return exe = im_ffm.get_ffmpeg_exe() @@ -183,3 +185,10 @@ def mux_audio(self): logger.debug("Removing temp file") if os.path.isfile(self.video_tmp_file): os.remove(self.video_tmp_file) + + def _rename_tmp_file(self): + """ Rename the temporary video file if not muxing audio. """ + os.rename(self.video_tmp_file, self.video_file) + logger.debug("Removing temp file") + if os.path.isfile(self.video_tmp_file): + os.remove(self.video_tmp_file) diff --git a/plugins/convert/writer/ffmpeg_defaults.py b/plugins/convert/writer/ffmpeg_defaults.py index 9c0da9f948..4063048411 100755 --- a/plugins/convert/writer/ffmpeg_defaults.py +++ b/plugins/convert/writer/ffmpeg_defaults.py @@ -44,142 +44,103 @@ _HELPTEXT = "Options for encoding converted frames to video." -_DEFAULTS = { - "container": { - "default": "mp4", - "info": "Video container to use.", - "datatype": str, - "rounding": None, - "min_max": None, - "choices": ["avi", "flv", "mkv", "mov", "mp4", "mpeg", "webm"], - "gui_radio": True, - "fixed": True, - }, - "codec": { - "default": "libx264", - "info": "Video codec to use:" - "\n\t libx264: H.264. A widely supported and commonly used codec." - "\n\t libx265: H.265 / HEVC video encoder application library.", - "datatype": str, - "rounding": None, - "min_max": None, - "choices": ["libx264", "libx265"], - "gui_radio": True, - "fixed": True, - }, - "crf": { - "default": 23, - "info": "Constant Rate Factor: 0 is lossless and 51 is worst quality possible. A " - "lower value generally leads to higher quality, and a subjectively sane range " - "is 17-28. Consider 17 or 18 to be visually lossless or nearly so; it should " - "look the same or nearly the same as the input but it isn't technically " - "lossless.\nThe range is exponential, so increasing the CRF value +6 results " - "in roughly half the bitrate / file size, while -6 leads to roughly twice the " - "bitrate.", - "datatype": int, - "rounding": 1, - "min_max": (0, 51), - "choices": [], - "gui_radio": False, - "group": "quality", - "fixed": True, - }, - "preset": { - "default": "medium", - "info": "A preset is a collection of options that will provide a certain encoding " - "speed to compression ratio.\nA slower preset will provide better compression " - "(compression is quality per filesize).\nUse the slowest preset that you have " - "patience for.", - "datatype": str, - "rounding": None, - "min_max": None, - "choices": [ - "ultrafast", - "superfast", - "veryfast", - "faster", - "fast", - "medium", - "slow", - "slower", - "veryslow", - ], - "gui_radio": True, - "group": "quality", - "fixed": True, - }, - "tune": { - "default": "none", - "info": "Change settings based upon the specifics of your input:" - "\n\t none: Don't perform any additional tuning." - "\n\t film: [H.264 only] Use for high quality movie content; lowers " - "deblocking." - "\n\t animation: [H.264 only] Good for cartoons; uses higher deblocking and " - "more reference frames." - "\n\t grain: Preserves the grain structure in old, grainy film material." - "\n\t stillimage: [H.264 only] Good for slideshow-like content." - "\n\t fastdecode: Allows faster decoding by disabling certain filters." - "\n\t zerolatency: Good for fast encoding and low-latency streaming.", - "datatype": str, - "rounding": None, - "min_max": None, - "choices": [ - "none", - "film", - "animation", - "grain", - "stillimage", - "fastdecode", - "zerolatency", - ], - "gui_radio": False, - "group": "settings", - "fixed": True, - }, - "profile": { - "default": "auto", - "info": "[H.264 Only] Limit the output to a specific H.264 profile. Don't change this " - "unless your target device only supports a certain profile.", - "datatype": str, - "rounding": None, - "min_max": None, - "choices": ["auto", "baseline", "main", "high", "high10", "high422", "high444"], - "gui_radio": False, - "group": "settings", - "fixed": True, - }, - "level": { - "default": "auto", - "info": "[H.264 Only] Set the encoder level, Don't change this unless your target " - "device only supports a certain level.", - "datatype": str, - "rounding": None, - "min_max": None, - "choices": [ - "auto", - "1", - "1b", - "1.1", - "1.2", - "1.3", - "2", - "2.1", - "2.2", - "3", - "3.1", - "3.2", - "4", - "4.1", - "4.2", - "5", - "5.1", - "5.2", - "6", - "6.1", - "6.2", - ], - "gui_radio": False, - "group": "settings", - "fixed": True, - }, -} +_DEFAULTS = dict( + container=dict( + default="mp4", + info="Video container to use.", + datatype=str, + rounding=None, + min_max=None, + choices=["avi", "flv", "mkv", "mov", "mp4", "mpeg", "webm"], + gui_radio=True, + ), + codec=dict( + default="libx264", + info="Video codec to use:" + "\n\t libx264: H.264. A widely supported and commonly used codec." + "\n\t libx265: H.265 / HEVC video encoder application library.", + datatype=str, + rounding=None, + min_max=None, + choices=["libx264", "libx265"], + gui_radio=True, + ), + crf=dict( + default=23, + info="Constant Rate Factor: 0 is lossless and 51 is worst quality possible. A " + "lower value generally leads to higher quality, and a subjectively sane range " + "is 17-28. Consider 17 or 18 to be visually lossless or nearly so; it should " + "look the same or nearly the same as the input but it isn't technically " + "lossless.\nThe range is exponential, so increasing the CRF value +6 results " + "in roughly half the bitrate / file size, while -6 leads to roughly twice the " + "bitrate.", + datatype=int, + rounding=1, + min_max=(0, 51), + choices=[], + gui_radio=False, + group="quality", + ), + preset=dict( + default="medium", + info="A preset is a collection of options that will provide a certain encoding " + "speed to compression ratio.\nA slower preset will provide better compression " + "(compression is quality per filesize).\nUse the slowest preset that you have " + "patience for.", + datatype=str, + rounding=None, + min_max=None, + choices=["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", + "slower", "veryslow"], + gui_radio=True, + group="quality", + ), + tune=dict( + default="none", + info="Change settings based upon the specifics of your input:" + "\n\t none: Don't perform any additional tuning." + "\n\t film: [H.264 only] Use for high quality movie content; lowers deblocking." + "\n\t animation: [H.264 only] Good for cartoons; uses higher deblocking and more " + "reference frames." + "\n\t grain: Preserves the grain structure in old, grainy film material." + "\n\t stillimage: [H.264 only] Good for slideshow-like content." + "\n\t fastdecode: Allows faster decoding by disabling certain filters." + "\n\t zerolatency: Good for fast encoding and low-latency streaming.", + datatype=str, + rounding=None, + min_max=None, + choices=["none", "film", "animation", "grain", "stillimage", "fastdecode", "zerolatency"], + gui_radio=False, + group="settings", + ), + profile=dict( + default="auto", + info="[H.264 Only] Limit the output to a specific H.264 profile. Don't change this " + "unless your target device only supports a certain profile.", + datatype=str, + rounding=None, + min_max=None, + choices=["auto", "baseline", "main", "high", "high10", "high422", "high444"], + gui_radio=False, + group="settings", + ), + level=dict( + default="auto", + info="[H.264 Only] Set the encoder level, Don't change this unless your target " + "device only supports a certain level.", + datatype=str, + rounding=None, + min_max=None, + choices=["auto", "1", "1b", "1.1", "1.2", "1.3", "2", "2.1", "2.2", "3", "3.1", "3.2", "4", + "4.1", "4.2", "5", "5.1", "5.2", "6", "6.1", "6.2"], + gui_radio=False, + group="settings", + ), + skip_mux=dict( + default=False, + info="Skip muxing audio to the final video output. This will result in a video without an " + "audio track.", + datatype=bool, + group="settings", + ), +) From 59023adef493a1b603ced53efbc77b14d18d157d Mon Sep 17 00:00:00 2001 From: "Yutaka \"FMS_Cat\" Obuchi" Date: Sun, 9 Aug 2020 18:40:10 +0900 Subject: [PATCH 261/981] Update .dockerignore (#1041) --- .dockerignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index c42a40019d..66cb3564d1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,3 @@ * -!requirements* \ No newline at end of file +!requirements* +!_requirements* From d8557c1970939ee9bb90bd41edcd86c6fcf84d19 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 12 Aug 2020 10:36:41 +0100 Subject: [PATCH 262/981] Faceswap 2.0 (#1045) * Core Updates - Remove lib.utils.keras_backend_quiet and replace with get_backend() where relevant - Document lib.gpu_stats and lib.sys_info - Remove call to GPUStats.is_plaidml from convert and replace with get_backend() - lib.gui.menu - typofix * Update Dependencies Bump Tensorflow Version Check * Port extraction to tf2 * Add custom import finder for loading Keras or tf.keras depending on backend * Add `tensorflow` to KerasFinder search path * Basic TF2 training running * model.initializers - docstring fix * Fix and pass tests for tf2 * Replace Keras backend tests with faceswap backend tests * Initial optimizers update * Monkey patch tf.keras optimizer * Remove custom Adam Optimizers and Memory Saving Gradients * Remove multi-gpu option. Add Distribution to cli * plugins.train.model._base: Add Mirror, Central and Default distribution strategies * Update tensorboard kwargs for tf2 * Penalized Loss - Fix for TF2 and AMD * Fix syntax for tf2.1 * requirements typo fix * Explicit None for clipnorm if using a distribution strategy * Fix penalized loss for distribution strategies * Update Dlight * typo fix * Pin to TF2.2 * setup.py - Install tensorflow from pip if not available in Conda * Add reduction options and set default for mirrored distribution strategy * Explicitly use default strategy rather than nullcontext * lib.model.backup_restore documentation * Remove mirrored strategy reduction method and default based on OS * Initial restructure - training * Remove PingPong Start model.base refactor * Model saving and resuming enabled * More tidying up of model.base * Enable backup and snapshotting * Re-enable state file Remove loss names from state file Fix print loss function Set snapshot iterations correctly * Revert original model to Keras Model structure rather than custom layer Output full model and sub model summary Change NNBlocks to callables rather than custom keras layers * Apply custom Conv2D layer * Finalize NNBlock restructure Update Dfaker blocks * Fix reloading model under a different distribution strategy * Pass command line arguments through to trainer * Remove training_opts from model and reference params directly * Tidy up model __init__ * Re-enable tensorboard logging Suppress "Model Not Compiled" warning * Fix timelapse * lib.model.nnblocks - Bugfix residual block Port dfaker bugfix original * dfl-h128 ported * DFL SAE ported * IAE Ported * dlight ported * port lightweight * realface ported * unbalanced ported * villain ported * lib.cli.args - Update Batchsize + move allow_growth to config * Remove output shape definition Get image sizes per side rather than globally * Strip mask input from encoder * Fix learn mask and output learned mask to preview * Trigger Allow Growth prior to setting strategy * Fix GUI Graphing * GUI - Display batchsize correctly + fix training graphs * Fix penalized loss * Enable mixed precision training * Update analysis displayed batch to match input * Penalized Loss - Multi-GPU Fix * Fix all losses for TF2 * Fix Reflect Padding * Allow different input size for each side of the model * Fix conv-aware initialization on reload * Switch allow_growth order * Move mixed_precision to cli * Remove distrubution strategies * Compile penalized loss sub-function into LossContainer * Bump default save interval to 250 Generate preview on first iteration but don't save Fix iterations to start at 1 instead of 0 Remove training deprecation warnings Bump some scripts.train loglevels * Add ability to refresh preview on demand on pop-up window * Enable refresh of training preview from GUI * Fix Convert Debug logging in Initializers * Fix Preview Tool * Update Legacy TF1 weights to TF2 Catch stats error on loading stats with missing logs * lib.gui.popup_configure - Make more responsive + document * Multiple Outputs supported in trainer Original Model - Mask output bugfix * Make universal inference model for convert Remove scaling from penalized mask loss (now handled at input to y_true) * Fix inference model to work properly with all models * Fix multi-scale output for convert * Fix clipnorm issue with distribution strategies Edit error message on OOM * Update plaidml losses * Add missing file * Disable gmsd loss for plaidnl * PlaidML - Basic training working * clipnorm rewriting for mixed-precision * Inference model creation bugfixes * Remove debug code * Bugfix: Default clipnorm to 1.0 * Remove all mask inputs from training code * Remove mask inputs from convert * GUI - Analysis Tab - Docstrings * Fix rate in totals row * lib.gui - Only update display pages if they have focus * Save the model on first iteration * plaidml - Fix SSIM loss with penalized loss * tools.alignments - Remove manual and fix jobs * GUI - Remove case formatting on help text * gui MultiSelect custom widget - Set default values on init * vgg_face2 - Move to plugins.extract.recognition and use plugins._base base class cli - Add global GPU Exclude Option tools.sort - Use global GPU Exlude option for backend lib.model.session - Exclude all GPUs when running in CPU mode lib.cli.launcher - Set backend to CPU mode when all GPUs excluded * Cascade excluded devices to GPU Stats * Explicit GPU selection for Train and Convert * Reduce Tensorflow Min GPU Multiprocessor Count to 4 * remove compat.v1 code from extract * Force TF to skip mixed precision compatibility check if GPUs have been filtered * Add notes to config for non-working AMD losses * Rasie error if forcing extract to CPU mode * Fix loading of legace dfl-sae weights + dfl-sae typo fix * Remove unused requirements Update sphinx requirements Fix broken rst file locations * docs: lib.gui.display * clipnorm amd condition check * documentation - gui.display_analysis * Documentation - gui.popup_configure * Documentation - lib.logger * Documentation - lib.model.initializers * Documentation - lib.model.layers * Documentation - lib.model.losses * Documentation - lib.model.nn_blocks * Documetation - lib.model.normalization * Documentation - lib.model.session * Documentation - lib.plaidml_stats * Documentation: lib.training_data * Documentation: lib.utils * Documentation: plugins.train.model._base * GUI Stats: prevent stats from using GPU * Documentation - Original Model * Documentation: plugins.model.trainer._base * linting * unit tests: initializers + losses * unit tests: nn_blocks * bugfix - Exclude gpu devices in train, not include * Enable Exclude-Gpus in Extract * Enable exclude gpus in tools * Disallow multiple plugin types in a single model folder * Automatically add exclude_gpus argument in for cpu backends * Cpu backend fixes * Relax optimizer test threshold * Default Train settings - Set mask to Extended * Update Extractor cli help text Update to Python 3.8 * Fix FAN to run on CPU * lib.plaidml_tools - typofix * Linux installer - check for curl * linux installer - typo fix --- .install/linux/faceswap_setup_x64.sh | 14 +- .install/windows/install.nsi | 2 +- .travis.yml | 2 +- Dockerfile.cpu | 2 +- Dockerfile.gpu | 2 +- INSTALL.md | 6 +- _requirements_base.txt | 13 - docs/full/lib/gui.rst | 22 + docs/full/lib/logger.rst | 8 + docs/full/lib/model.rst | 53 +- docs/full/lib/plaidml_stats.rst | 7 + docs/full/lib/utils.rst | 8 + docs/full/lib/vgg_face2_keras.rst | 7 - docs/full/plugins/extract.rst | 8 + docs/full/plugins/train.rst | 35 +- docs/sphinx_requirements.txt | 39 +- lib/cli/args.py | 137 +- lib/cli/launcher.py | 83 +- lib/gpu_stats.py | 50 +- lib/gui/__init__.py | 4 +- lib/gui/control_helper.py | 7 +- lib/gui/custom_widgets.py | 2 +- lib/gui/display.py | 148 +- lib/gui/display_analysis.py | 364 +-- lib/gui/display_command.py | 36 +- lib/gui/display_graph.py | 4 +- lib/gui/display_page.py | 52 +- lib/gui/menu.py | 7 +- lib/gui/popup_configure.py | 191 +- lib/gui/stats.py | 190 +- lib/gui/utils.py | 42 + lib/gui/wrapper.py | 31 +- lib/logger.py | 238 +- lib/model/__init__.py | 9 + lib/model/backup_restore.py | 141 +- lib/model/initializers.py | 142 +- lib/model/layers.py | 114 +- lib/model/{losses.py => losses_plaid.py} | 520 ++--- lib/model/losses_tf.py | 556 +++++ lib/model/memory_saving_gradients.py | 439 ---- lib/model/nn_blocks.py | 901 +++---- lib/model/normalization.py | 23 +- lib/model/optimizers.py | 146 -- lib/model/session.py | 175 +- lib/plaidml_tools.py | 236 +- lib/training_data.py | 179 +- lib/utils.py | 476 ++-- plugins/extract/_base.py | 16 +- plugins/extract/align/_base.py | 5 +- plugins/extract/align/fan.py | 95 +- plugins/extract/detect/_base.py | 5 +- plugins/extract/detect/mtcnn.py | 71 +- plugins/extract/detect/s3fd.py | 403 +++- plugins/extract/mask/_base.py | 5 +- plugins/extract/mask/unet_dfl.py | 7 +- plugins/extract/mask/vgg_clear.py | 7 +- plugins/extract/mask/vgg_obstructed.py | 7 +- plugins/extract/pipeline.py | 27 +- plugins/extract/recognition/__init__.py | 0 .../extract/recognition}/vgg_face2_keras.py | 100 +- plugins/train/_config.py | 19 +- plugins/train/model/_base.py | 2060 ++++++++++------- plugins/train/model/dfaker.py | 53 +- plugins/train/model/dfl_h128.py | 58 +- plugins/train/model/dfl_sae.py | 216 +- plugins/train/model/dlight.py | 204 +- plugins/train/model/iae.py | 106 +- plugins/train/model/lightweight.py | 56 +- plugins/train/model/original.py | 202 +- plugins/train/model/realface.py | 128 +- plugins/train/model/unbalanced.py | 160 +- plugins/train/model/villain.py | 73 +- plugins/train/model/villain_defaults.py | 2 +- plugins/train/trainer/_base.py | 961 ++++---- requirements_amd.txt | 5 +- requirements_cpu.txt | 2 +- requirements_nvidia.txt | 2 +- scripts/convert.py | 107 +- scripts/extract.py | 1 + scripts/gui.py | 4 +- scripts/train.py | 140 +- setup.py | 40 +- tests/__init__.py | 7 + tests/lib/model/initializers_test.py | 9 +- tests/lib/model/layers_test.py | 9 +- tests/lib/model/losses_test.py | 56 +- tests/lib/model/nn_blocks_test.py | 34 +- tests/lib/model/normalization_test.py | 2 +- tests/lib/model/optimizers_test.py | 51 +- tests/startup_test.py | 15 +- tests/utils.py | 125 + tools/alignments/alignments.py | 6 +- tools/alignments/cli.py | 255 +- tools/alignments/jobs.py | 49 - tools/alignments/jobs_manual.py | 939 -------- tools/manual/manual.py | 20 +- tools/mask/mask.py | 11 +- tools/preview/cli.py | 68 +- tools/sort/cli.py | 9 - tools/sort/sort.py | 19 +- 100 files changed, 6720 insertions(+), 6182 deletions(-) create mode 100755 docs/full/lib/logger.rst create mode 100755 docs/full/lib/plaidml_stats.rst create mode 100755 docs/full/lib/utils.rst delete mode 100755 docs/full/lib/vgg_face2_keras.rst rename lib/model/{losses.py => losses_plaid.py} (53%) create mode 100644 lib/model/losses_tf.py delete mode 100644 lib/model/memory_saving_gradients.py delete mode 100644 lib/model/optimizers.py create mode 100644 plugins/extract/recognition/__init__.py rename {lib => plugins/extract/recognition}/vgg_face2_keras.py (70%) create mode 100644 tests/utils.py delete mode 100644 tools/alignments/jobs_manual.py diff --git a/.install/linux/faceswap_setup_x64.sh b/.install/linux/faceswap_setup_x64.sh index cf9894a872..3d861c64d7 100644 --- a/.install/linux/faceswap_setup_x64.sh +++ b/.install/linux/faceswap_setup_x64.sh @@ -70,6 +70,15 @@ check_for_sudo() { fi } +check_for_curl() { + # Ensure that curl is available on the system + if ! command -V curl &> /dev/null ; then + error "'curl' is required for running the Faceswap installer, but could not be found. \ + Please install 'curl' using the package manager for your distribution before proceeding." + exit 1 + fi +} + create_tmp_dir() { TMP_DIR="$(mktemp -d)" if [ -z "$TMP_DIR" -o ! -d "$TMP_DIR" ]; then @@ -336,10 +345,10 @@ delete_env() { } create_env() { - # Create Python 3.7 env for faceswap + # Create Python 3.8 env for faceswap delete_env info "Creating Conda Virtual Environment..." - yellow ; "$CONDA_EXECUTABLE" create -n "$ENV_NAME" -q python=3.7 -y + yellow ; "$CONDA_EXECUTABLE" create -n "$ENV_NAME" -q python=3.8 -y } @@ -406,6 +415,7 @@ create_desktop_shortcut () { } check_for_sudo +check_for_curl banner user_input review diff --git a/.install/windows/install.nsi b/.install/windows/install.nsi index 8b146b2479..e2d2bddbcf 100644 --- a/.install/windows/install.nsi +++ b/.install/windows/install.nsi @@ -22,7 +22,7 @@ InstallDir $PROFILE\faceswap # Install cli flags !define flagsConda "/S /RegisterPython=0 /AddToPath=0 /D=$PROFILE\MiniConda3" !define flagsRepo "--depth 1 --no-single-branch ${wwwRepo}" -!define flagsEnv "-y python=3.7" +!define flagsEnv "-y python=3.8" # Folders Var ProgramData diff --git a/.travis.yml b/.travis.yml index 84e56cc1f7..9075e4af8e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,7 @@ language: shell env: global: - - CONDA_PYTHON=3.7 + - CONDA_PYTHON=3.8 - CONDA_BLD_PATH=${HOME}/conda-bld os: diff --git a/Dockerfile.cpu b/Dockerfile.cpu index 1b2e4c16cf..bb30b48883 100755 --- a/Dockerfile.cpu +++ b/Dockerfile.cpu @@ -1,4 +1,4 @@ -FROM tensorflow/tensorflow:1.12.0-py3 +FROM tensorflow/tensorflow:2.2.0-py3 RUN add-apt-repository -y ppa:jonathonf/ffmpeg-4 \ && apt-get update -qq -y \ diff --git a/Dockerfile.gpu b/Dockerfile.gpu index 62a6e52c95..9087b79ee9 100755 --- a/Dockerfile.gpu +++ b/Dockerfile.gpu @@ -1,4 +1,4 @@ -FROM tensorflow/tensorflow:1.15.0-gpu-py3 +FROM tensorflow/tensorflow:2.2.0-gpu-py3 ENV DEBIAN_FRONTEND noninteractive diff --git a/INSTALL.md b/INSTALL.md index e8657512e3..9c0cc63286 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -93,8 +93,8 @@ Reboot your PC, so that everything you have just installed gets registered. - Select "Create" at the bottom - In the pop up: - Give it the name: faceswap - - **IMPORTANT**: Select python version 3.7 - - Hit "Create" (NB: This may take a while as it will need to download Python 3.7) + - **IMPORTANT**: Select python version 3.8 + - Hit "Create" (NB: This may take a while as it will need to download Python) ![Anaconda virtual env setup](https://i.imgur.com/59RHnLs.png) #### Entering your virtual environment @@ -155,7 +155,7 @@ Obtain git for your distribution from the [git website](https://git-scm.com/down The recommended install method is to use a Conda3 Environment as this will handle the installation of Nvidia's CUDA and cuDNN straight into your Conda Environment. This is by far the easiest and most reliable way to setup the project. - MiniConda3 is recommended: [MiniConda3](https://docs.conda.io/en/latest/miniconda.html) -Alternatively you can install Python (>= 3.6-3.7 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Tensorflow yourself, make sure you install no higher than version 10.0 of CUDA and 7.5.x of CUDNN. +Alternatively you can install Python (>= 3.6-3.8 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Tensorflow yourself, make sure you install no higher than version 10.0 of CUDA and 7.5.x of CUDNN. - Python distributions: - apt/yum install python3 (Linux) - [Installer](https://www.python.org/downloads/release/python-368/) (Windows) diff --git a/_requirements_base.txt b/_requirements_base.txt index abe6086630..af6547d5b7 100644 --- a/_requirements_base.txt +++ b/_requirements_base.txt @@ -3,10 +3,8 @@ psutil>=5.7.0 pathlib==1.0.1 numpy>=1.18.0 opencv-python>=4.1.2.0 -scikit-image>=0.16.2 pillow>=7.0.0 scikit-learn>=0.22.0 -toposort==1.5 fastcluster==1.1.26 matplotlib>=3.0.3 imageio>=2.8.0 @@ -15,16 +13,5 @@ ffmpy==0.2.3 # Revert back to nvidia-ml-py3 when windows/system32 patch is implemented git+https://github.com/deepfakes/nvidia-ml-py3.git #nvidia-ml-py3 -h5py>=2.10.0 -Keras==2.2.4 pywin32>=227 ; sys_platform == "win32" pynvx==1.0.0 ; sys_platform == "darwin" - -# tensorflow is included within the docker image. -# If you are looking for dependencies for a manual install, - -# NB: Tensorflow version 1.12 is the minimum supported version of Tensorflow. -# If your graphics card support is below Cuda 9.0 you will need to either -# compile tensorflow yourself or download a custom version. -# Install 1.12.0<=tensorflow-gpu<=1.13.0 for CUDA 9.0 -# or 1.13.1<=tensorflow-gpu<1.15 for CUDA 10.0 diff --git a/docs/full/lib/gui.rst b/docs/full/lib/gui.rst index 8655ca3cd9..c0fca80358 100755 --- a/docs/full/lib/gui.rst +++ b/docs/full/lib/gui.rst @@ -30,6 +30,28 @@ custom\_widgets module :undoc-members: :show-inheritance: +display module +============== +.. automodule:: lib.gui.display + :members: + :undoc-members: + :show-inheritance: + + +display\_analysis module +======================== +.. autoclass:: lib.gui.display_analysis.Analysis + :members: + :undoc-members: + :show-inheritance: + +popup_configure module +====================== +.. automodule:: lib.gui.popup_configure + :members: + :undoc-members: + :show-inheritance: + project module ============== diff --git a/docs/full/lib/logger.rst b/docs/full/lib/logger.rst new file mode 100755 index 0000000000..82c375a95d --- /dev/null +++ b/docs/full/lib/logger.rst @@ -0,0 +1,8 @@ +************* +logger module +************* + +.. automodule:: lib.logger + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib/model.rst b/docs/full/lib/model.rst index d8402afeea..5396f89a4d 100755 --- a/docs/full/lib/model.rst +++ b/docs/full/lib/model.rst @@ -6,6 +6,13 @@ The Model Package handles interfacing with the neural network backend and holds .. contents:: Contents :local: +model.backup_restore module +--------------------------- + +.. automodule:: lib.model.backup_restore + :members: + :undoc-members: + :show-inheritance: model.initializers module ------------------------- @@ -17,6 +24,7 @@ model.initializers module ~lib.model.initializers.ConvolutionAware ~lib.model.initializers.ICNR + ~lib.model.initializers.compute_fans .. automodule:: lib.model.initializers :members: @@ -46,22 +54,23 @@ model.layers module model.losses module ------------------- +The losses listed here are generated from the docstrings in :mod:`lib.model.losses_tf`, however +the functions are excactly the same for :mod:`lib.model.losses_plaid`. The correct loss module will +be imported as :mod:`lib.model.losses` depending on the backend in use. + .. rubric:: Module Summary .. autosummary:: :nosignatures: - ~lib.model.losses.DSSIMObjective - ~lib.model.losses.PenalizedLoss - ~lib.model.losses.gaussian_blur - ~lib.model.losses.generalized_loss - ~lib.model.losses.gmsd_loss - ~lib.model.losses.gradient_loss - ~lib.model.losses.l_inf_norm - ~lib.model.losses.mask_loss_wrapper - ~lib.model.losses.scharr_edges - -.. automodule:: lib.model.losses + ~lib.model.losses_tf.DSSIMObjective + ~lib.model.losses_tf.PenalizedLoss + ~lib.model.losses_tf.GeneralizedLoss + ~lib.model.losses_tf.GMSDLoss + ~lib.model.losses_tf.GradientLoss + ~lib.model.losses_tf.LInfNorm + +.. automodule:: lib.model.losses_tf :members: :undoc-members: :show-inheritance: @@ -69,6 +78,20 @@ model.losses module model.nn_blocks module ---------------------- +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.model.nn_blocks.Conv2D + ~lib.model.nn_blocks.Conv2DBlock + ~lib.model.nn_blocks.Conv2DOutput + ~lib.model.nn_blocks.ResidualBlock + ~lib.model.nn_blocks.SeparableConv2DBlock + ~lib.model.nn_blocks.Upscale2xBlock + ~lib.model.nn_blocks.UpscaleBlock + ~lib.model.nn_blocks.set_config + .. automodule:: lib.model.nn_blocks :members: :undoc-members: @@ -89,14 +112,6 @@ model.normalization module :undoc-members: :show-inheritance: -model.optimizers module ------------------------ - -.. automodule:: lib.model.optimizers - :members: - :undoc-members: - :show-inheritance: - model.session module --------------------- diff --git a/docs/full/lib/plaidml_stats.rst b/docs/full/lib/plaidml_stats.rst new file mode 100755 index 0000000000..72c19bd8c5 --- /dev/null +++ b/docs/full/lib/plaidml_stats.rst @@ -0,0 +1,7 @@ +plaidml\_tools module +===================== + +.. automodule:: lib.plaidml_tools + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib/utils.rst b/docs/full/lib/utils.rst new file mode 100755 index 0000000000..53fefa7c0b --- /dev/null +++ b/docs/full/lib/utils.rst @@ -0,0 +1,8 @@ +************ +utils module +************ + +.. automodule:: lib.utils + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib/vgg_face2_keras.rst b/docs/full/lib/vgg_face2_keras.rst deleted file mode 100755 index adb6acedcb..0000000000 --- a/docs/full/lib/vgg_face2_keras.rst +++ /dev/null @@ -1,7 +0,0 @@ -vgg\_face2\_keras module -======================== - -.. automodule:: lib.vgg_face2_keras - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/plugins/extract.rst b/docs/full/plugins/extract.rst index 103b9e7f26..59da672e2d 100755 --- a/docs/full/plugins/extract.rst +++ b/docs/full/plugins/extract.rst @@ -59,6 +59,14 @@ mask._base module ----------------- .. automodule:: plugins.extract.mask._base + :members: + :undoc-members: + :show-inheritance: + +vgg\_face2\_keras module +------------------------ + +.. automodule:: plugins.extract.recognition.vgg_face2_keras :members: :undoc-members: :show-inheritance: \ No newline at end of file diff --git a/docs/full/plugins/train.rst b/docs/full/plugins/train.rst index fc4c87138d..4f0675d23b 100755 --- a/docs/full/plugins/train.rst +++ b/docs/full/plugins/train.rst @@ -4,23 +4,40 @@ train package The Train Package handles the Model and Trainer plugins for training models in Faceswap. -trainer._base module -==================== + +.. contents:: Contents + :local: + +model._base module +================== .. rubric:: Module Summary .. autosummary:: :nosignatures: - - ~plugins.train.trainer._base.Batcher - ~plugins.train.trainer._base.PingPong - ~plugins.train.trainer._base.Samples - ~plugins.train.trainer._base.Timelapse - ~plugins.train.trainer._base.TrainerBase - ~plugins.train.trainer._base.TrainingAlignments + + ~plugins.train.model._base.KerasModel + ~plugins.train.model._base.ModelBase + ~plugins.train.model._base.State .. rubric:: Module +.. automodule:: plugins.train.model._base + :members: + :undoc-members: + :show-inheritance: + +model.original module +===================== + +.. automodule:: plugins.train.model.original + :members: + :undoc-members: + :show-inheritance: + +trainer._base module +==================== + .. automodule:: plugins.train.trainer._base :members: :undoc-members: diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index 7f8a503509..9d38b6f16f 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -1,25 +1,20 @@ # NB Do not install from this requirements file # It is for documentation purposes only -tqdm -psutil -pathlib -numpy==1.16.2 -opencv-python==4.1.1.26 -scikit-image -Pillow==6.1.0 -scikit-learn -toposort -fastcluster -matplotlib==2.2.2 -imageio==2.5.0 -imageio-ffmpeg -ffmpy==0.2.2 -# Revert back to nvidia-ml-py3 when windows/system32 patch is implemented -git+https://github.com/deepfakes/nvidia-ml-py3.git -#nvidia-ml-py3 -h5py==2.9.0 -Keras==2.2.4 -pywin32 ; sys_platform == "win32" -pynvx==0.0.4 ; sys_platform == "darwin" -tensorflow==1.13.1 +tqdm==4.42 +psutil==5.7.0 +pathlib==1.0.1 +numpy==1.18.0 +opencv-python==4.1.2.30 +pillow==7.0.0 +scikit-learn==0.22.0 +fastcluster==1.1.26 +matplotlib==3.0.3 +imageio==2.8.0 +imageio-ffmpeg==0.4.2 +ffmpy==0.2.3 +nvidia-ml-py3 +pywin32==227 ; sys_platform == "win32" +pynvx==1.0.0 ; sys_platform == "darwin" +plaidml-keras==0.7.0 +tensorflow==2.2.0 diff --git a/lib/cli/args.py b/lib/cli/args.py index 60024efba8..f676a250e4 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 """ The Command Line Argument options for faceswap.py """ + +# pylint:disable=too-many-lines import argparse import logging import re @@ -7,6 +9,8 @@ import textwrap from lib.utils import get_backend +from lib.gpu_stats import GPUStats + from plugins.plugin_loader import PluginLoader from .actions import (DirFullPaths, DirOrFileFullPaths, FileFullPaths, FilesFullPaths, MultiOption, @@ -14,6 +18,7 @@ from .launcher import ScriptExecutor logger = logging.getLogger(__name__) # pylint: disable=invalid-name +_GPUS = GPUStats().cli_devices class FullHelpArgumentParser(argparse.ArgumentParser): @@ -156,6 +161,19 @@ def _get_global_arguments(): The list of global command line options for all Faceswap commands. """ global_args = list() + if _GPUS: + global_args.append(dict( + opts=("-X", "--exclude-gpus"), + dest="exclude_gpus", + action=MultiOption, + type=str.lower, + nargs="+", + choices=[str(idx) for idx in range(len(_GPUS))], + group="Global Options", + help="R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " + "to any GPU(s) that you do not wish to be made available to Faceswap. " + "Selecting all GPUs here will force Faceswap into CPU mode." + "\nL|{}".format(" \nL|".join(_GPUS)))) global_args.append(dict( opts=("-C", "--configfile"), action=FileFullPaths, @@ -344,11 +362,10 @@ def get_optional_arguments(): "'/config/extract.ini' or 'Settings > Configure Extract 'Plugins':" "\nL|cv2-dnn: A CPU only extractor which is the least reliable and least " "resource intensive. Use this if not using a GPU and time is important." - "\nL|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " - "than other GPU detectors but can often return more false positives." - "\nL|s3fd: Best detector. Fast on GPU, slow on CPU. Can detect more faces and " - "fewer false positives than other GPU detectors, but is a lot more resource " - "intensive.")) + "\nL|mtcnn: Good detector. GPU only. Uses fewer resources than other GPU " + "detectors but can often return more false positives." + "\nL|s3fd: Best detector. GPU only. Can detect more faces and fewer false " + "positives than other GPU detectors, but is a lot more resource intensive.")) argument_list.append(dict( opts=("-A", "--aligner"), action=Radio, @@ -768,15 +785,12 @@ def get_optional_arguments(): "this to, it will never attempt to use more processes than are available on " "your system. If singleprocess is enabled this setting will be ignored.")) argument_list.append(dict( - opts=("-g", "--gpus"), - action=Slider, - min_max=(1, 10), - rounding=1, - type=int, - default=1, + opts=("-d", "--distributed"), + action="store_true", + default=False, backend="nvidia", group="settings", - help="Number of GPUs to use for conversion")) + help="Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs.")) argument_list.append(dict( opts=("-t", "--trainer"), type=str.lower, @@ -784,15 +798,6 @@ def get_optional_arguments(): group="settings", help="[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder")) - argument_list.append(dict( - opts=("-ag", "--allow-growth"), - action="store_true", - dest="allow_growth", - default=False, - backend="nvidia", - group="settings", - help="Sets allow_growth option of Tensorflow to spare memory on some " - "configurations.")) argument_list.append(dict( opts=("-otf", "--on-the-fly"), action="store_true", @@ -917,9 +922,9 @@ def get_argument_list(): "\nL|original: The original model created by /u/deepfakes." "\nL|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " "for full dfaker method." - "\nL|dfl-h128. 128px in/out model from deepfacelab" - "\nL|dfl-sae. Adaptable model from deepfacelab" - "\nL|dlight. A lightweight, high resolution DFaker variant." + "\nL|dfl-h128: 128px in/out model from deepfacelab" + "\nL|dfl-sae: Adaptable model from deepfacelab" + "\nL|dlight: A lightweight, high resolution DFaker variant." "\nL|iae: A model that uses intermediate layers to try to get better details" "\nL|lightweight: A lightweight model for low-end cards. Don't expect great " "results. Can train as low as 1.6GB with batch size 8." @@ -928,20 +933,22 @@ def get_argument_list(): "won't work so well. By andenixa et al. Very configurable." "\nL|unbalanced: 128px in/out model from andenixa. The autoencoders are " "unbalanced so B>A swaps won't work so well. Very configurable." - "\nL|villain: 128px in/out model from villainguy. Very resource hungry (11GB " - "for batchsize 16). Good for details, but more susceptible to color " - "differences.")) + "\nL|villain: 128px in/out model from villainguy. Very resource hungry (You " + "will require a GPU with a fair amount of VRAM). Good for details, but more " + "susceptible to color differences.")) argument_list.append(dict( opts=("-bs", "--batch-size"), action=Slider, - min_max=(2, 256), - rounding=2, + min_max=(1, 256), + rounding=1, type=int, dest="batch_size", - default=64, + default=16, group="training", - help="Batch size. This is the number of images processed through the model for " - "each iteration. Larger batches require more GPU RAM.")) + help="Batch size. This is the number of images processed through the model for each " + "side per iteration. NB: As the model is fed 2 sides at a time, the actual " + "number of images within the model at any one time is double the number that you " + "set here. Larger batches require more GPU RAM.")) argument_list.append(dict( opts=("-it", "--iterations"), action=Slider, @@ -956,47 +963,28 @@ def get_argument_list(): "you want the model to stop automatically at a set number of iterations, you " "can set that value here.")) argument_list.append(dict( - opts=("-g", "--gpus"), - action=Slider, - min_max=(1, 10), - rounding=1, - type=int, - default=1, - backend="nvidia", - group="training", - help="Number of GPUs to use for training")) - argument_list.append(dict( - opts=("-msg", "--memory-saving-gradients"), + opts=("-d", "--distributed"), action="store_true", - dest="memory_saving_gradients", default=False, backend="nvidia", - group="VRAM Savings", - help="Trades off VRAM usage against computation time. Can fit larger models into " - "memory at a cost of slower training speed. 50%%-150%% batch size increase " - "for 20%%-50%% longer training time. NB: Launch time will be significantly " - "delayed. Switching sides using ping-pong training will take longer.")) - argument_list.append(dict( - opts=("-o", "--optimizer-savings"), - action="store_true", - dest="optimizer_savings", - default=False, - backend="nvidia", - group="VRAM Savings", - help="To save VRAM some optimizer gradient calculations can be performed on the " - "CPU rather than the GPU. This allows you to increase batchsize at a training " - "speed/system RAM cost.")) + group="training", + help="Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs.")) argument_list.append(dict( - opts=("-pp", "--ping-pong"), + opts=("-mp", "--mixed-precision"), action="store_true", - dest="pingpong", + dest="mixed_precision", default=False, backend="nvidia", - group="VRAM Savings", - help="Enable ping pong training. Trains one side at a time, switching sides at " - "each save iteration. Training will take 2 to 4 times longer, with about a " - "30%%-50%% reduction in VRAM useage. NB: Preview won't show until both sides " - "have been trained once.")) + group="training", + help="R|NVIDIA GPUs can run operations in float16 faster than in float32. Mixed " + "precision allows you to use a mix of float16 with float32, to get the " + "performance benefits from float16 and the numeric stability benefits from " + "float32.\nWhile mixed precision will run on most Nvidia models, it will only " + "speed up training on more recent GPUs. Those with compute capability 7.0 or " + "higher will see the greatest performance benefit from mixed precision because " + "they have Tensor Cores. Older GPUs offer no math performance benefit for using " + "mixed precision, however memory and bandwidth savings can enable some speedups. " + "Generally RTX GPUs and later will offer the most benefit.")) argument_list.append(dict( opts=("-s", "--save-interval"), action=Slider, @@ -1004,7 +992,7 @@ def get_argument_list(): rounding=10, type=int, dest="save_interval", - default=100, + default=250, group="Saving", help="Sets the number of iterations between each model save.")) argument_list.append(dict( @@ -1075,15 +1063,6 @@ def get_argument_list(): group="preview", help="Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder.")) - argument_list.append(dict( - opts=("-ag", "--allow-growth"), - action="store_true", - dest="allow_growth", - default=False, - backend="nvidia", - group="model", - help="Sets allow_growth option of Tensorflow to spare memory on some " - "configurations.")) argument_list.append(dict( opts=("-nl", "--no-logs"), action="store_true", @@ -1097,7 +1076,7 @@ def get_argument_list(): action="store_true", dest="warp_to_landmarks", default=False, - group="training", + group="augmentation", help="Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " "warping. Alignments files for both sets of faces must be provided if using " @@ -1107,7 +1086,7 @@ def get_argument_list(): action="store_true", dest="no_flip", default=False, - group="training", + group="augmentation", help="To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " "left off except for during 'fit training'.")) @@ -1116,7 +1095,7 @@ def get_argument_list(): action="store_true", dest="no_augment_color", default=False, - group="training", + group="augmentation", help="Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " "Enable this option to disable color augmentation.")) diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index aaa9b97513..6a4f743a48 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -6,8 +6,11 @@ import sys from importlib import import_module + +from lib.gpu_stats import set_exclude_devices, GPUStats from lib.logger import crash_log, log_setup -from lib.utils import FaceswapError, get_backend, safe_shutdown, set_system_verbosity +from lib.utils import (FaceswapError, get_backend, KerasFinder, safe_shutdown, set_backend, + set_system_verbosity) logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -51,12 +54,13 @@ def _test_for_tf_version(): Raises ------ FaceswapError - If Tensorflow is not found, or is not between versions 1.12 and 1.15 + If Tensorflow is not found, or is not between versions 2.2 and 2.2 """ - min_ver = 1.12 - max_ver = 1.15 + min_ver = 2.2 + max_ver = 2.2 try: # Ensure tensorflow doesn't pin all threads to one core when using Math Kernel Library + os.environ["TF_MIN_GPU_MULTIPROCESSOR_COUNT"] = "4" os.environ["KMP_AFFINITY"] = "disabled" import tensorflow as tf # pylint:disable=import-outside-toplevel except ImportError as err: @@ -142,13 +146,10 @@ def execute_script(self, arguments): set_system_verbosity(arguments.loglevel) is_gui = hasattr(arguments, "redirect_gui") and arguments.redirect_gui log_setup(arguments.loglevel, arguments.logfile, self._command, is_gui) - logger.debug("Executing: %s. PID: %s", self._command, os.getpid()) success = False - if get_backend() == "amd": - plaidml_found = self._setup_amd(arguments.loglevel) - if not plaidml_found: - safe_shutdown(got_error=True) - return + + if self._command != "gui": + self._configure_backend(arguments) try: script = self._import_script() process = script(arguments) @@ -172,14 +173,66 @@ def execute_script(self, arguments): finally: safe_shutdown(got_error=not success) - @staticmethod - def _setup_amd(log_level): + def _configure_backend(self, arguments): + """ Configure the backend. + + Exclude any GPUs for use by Faceswap when requested. + + Set Faceswap backend to CPU if all GPUs have been deselected. + + Add the Keras import interception code. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The command line arguments passed to Faceswap. + """ + if not hasattr(arguments, "exclude_gpus"): + # Cpu backends will not have this attribute + logger.debug("Adding missing exclude gpus argument to namespace") + setattr(arguments, "exclude_gpus", None) + + if arguments.exclude_gpus: + if not all(idx.isdigit() for idx in arguments.exclude_gpus): + logger.error("GPUs passed to the ['-X', '--exclude-gpus'] argument must all be " + "integers.") + sys.exit(1) + arguments.exclude_gpus = [int(idx) for idx in arguments.exclude_gpus] + set_exclude_devices(arguments.exclude_gpus) + + if ((get_backend() == "cpu" or GPUStats().exclude_all_devices) and + (self._command == "extract" and arguments.detector in ("mtcnn", "s3fd"))): + logger.error("Extracting on CPU is not currently for detector: '%s'", + arguments.detector.upper()) + sys.exit(0) + + if GPUStats().exclude_all_devices and get_backend() != "cpu": + msg = "Switching backend to CPU" + if get_backend() == "amd": + msg += (". Using Tensorflow for CPU operations.") + os.environ["KERAS_BACKEND"] = "tensorflow" + set_backend("cpu") + logger.info(msg) + + # Add Keras finder to the meta_path list as the first item + sys.meta_path.insert(0, KerasFinder()) + + logger.debug("Executing: %s. PID: %s", self._command, os.getpid()) + + if get_backend() == "amd": + plaidml_found = self._setup_amd(arguments) + if not plaidml_found: + safe_shutdown(got_error=True) + sys.exit(1) + + @classmethod + def _setup_amd(cls, arguments): """ Test for plaidml and perform setup for AMD. Parameters ---------- - log_level: str - The requested log level to run at + arguments: :class:`argparse.Namespace` + The command line arguments passed to Faceswap. """ logger.debug("Setting up for AMD") try: @@ -188,6 +241,6 @@ def _setup_amd(log_level): logger.error("PlaidML not found. Run `pip install plaidml-keras` for AMD support") return False from lib.plaidml_tools import setup_plaidml # pylint:disable=import-outside-toplevel - setup_plaidml(log_level) + setup_plaidml(arguments.loglevel, arguments.exclude_gpus) logger.debug("setup up for PlaidML") return True diff --git a/lib/gpu_stats.py b/lib/gpu_stats.py index 72e64d8843..3509893a5a 100644 --- a/lib/gpu_stats.py +++ b/lib/gpu_stats.py @@ -27,6 +27,25 @@ plaidlib = None +_EXCLUDE_DEVICES = [] + + +def set_exclude_devices(devices): + """ Add any explicitly selected GPU devices to the global list of devices to be excluded + from use by Faceswap. + + Parameters + ---------- + devices: list + list of indices corresponding to the GPU devices connected to the computer + """ + logger = logging.getLogger(__name__) + logger.debug("Excluding GPU indicies: %s", devices) + if not devices: + return + _EXCLUDE_DEVICES.extend(devices) + + class GPUStats(): """ Holds information and statistics about the GPU(s) available on the currently running system. @@ -71,6 +90,16 @@ def device_count(self): """int: The number of GPU devices discovered on the system. """ return self._device_count + @property + def cli_devices(self): + """ list: List of available devices for use in faceswap's command line arguments """ + return ["{}: {}".format(idx, device) for idx, device in enumerate(self._devices)] + + @property + def exclude_all_devices(self): + """ bool: ``True`` if all GPU devices have been explicitly disabled otherwise ``False`` """ + return all(idx in _EXCLUDE_DEVICES for idx in range(len(self._devices))) + @property def _is_plaidml(self): """ bool: ``True`` if the backend is plaidML otherwise ``False``. """ @@ -132,7 +161,7 @@ def _initialize(self, log=False): if get_backend() == "amd": self._log("debug", "AMD Detected. Using plaidMLStats") loglevel = "INFO" if self._logger is None else self._logger.getEffectiveLevel() - self._plaid = plaidlib(loglevel=loglevel, log=log) + self._plaid = plaidlib(log_level=loglevel, log=log) elif IS_MACOS: self._log("debug", "macOS Detected. Using pynvx") try: @@ -199,18 +228,21 @@ def _get_device_count(self): def _get_active_devices(self): """ Obtain the indices of active GPUs (those that have not been explicitly excluded by - CUDA_VISIBLE_DEVICES or plaidML) and allocate to :attr:`_active_devices`. """ + CUDA_VISIBLE_DEVICES, plaidML or command line arguments) and allocate to + :attr:`_active_devices`. """ if self._is_plaidml: self._active_devices = self._plaid.active_devices else: - devices = os.environ.get("CUDA_VISIBLE_DEVICES", None) if self._device_count == 0: - self._active_devices = list() - elif devices is not None: - self._active_devices = [int(i) for i in devices.split(",") if devices] + self._active_devices = [] else: - self._active_devices = list(range(self._device_count)) - self._log("debug", "Active GPU Devices: {}".format(self._active_devices)) + devices = [idx for idx in range(self._device_count) if idx not in _EXCLUDE_DEVICES] + env_devices = os.environ.get("CUDA_VISIBLE_DEVICES", "") + if env_devices: + env_devices = [int(i) for i in env_devices.split(",")] + devices = [idx for idx in devices if idx in env_devices] + self._active_devices = devices + self._log("debug", "Active GPU Devices: {}".format(self._active_devices)) def _get_handles(self): """ Obtain the internal handle identifiers for the system GPUs and allocate to @@ -340,7 +372,7 @@ def get_card_most_free(self): If a GPU is not detected then the **card_id** is returned as ``-1`` and the amount of free and total RAM available is fixed to 2048 Megabytes. """ - if self._device_count == 0: + if len(self._active_devices) == 0: return {"card_id": -1, "device": "No GPU devices found", "free": 2048, diff --git a/lib/gui/__init__.py b/lib/gui/__init__.py index b611c3c341..8bf41c9b3d 100644 --- a/lib/gui/__init__.py +++ b/lib/gui/__init__.py @@ -3,8 +3,8 @@ from lib.gui.display import DisplayNotebook from lib.gui.options import CliOptions from lib.gui.menu import MainMenuBar, TaskBar -from lib.gui.popup_configure import popup_config from lib.gui.project import LastSession from lib.gui.stats import Session -from lib.gui.utils import get_config, get_images, initialize_config, initialize_images +from lib.gui.utils import (get_config, get_images, initialize_config, initialize_images, + preview_trigger) from lib.gui.wrapper import ProcessWrapper diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index aad12b39d6..91f43d25ee 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -217,7 +217,6 @@ def helptext(self): helptext = helptext[2:].replace("\nL|", "\n - ").replace("\n", "\n\n") else: helptext = helptext.replace("\n\t", "\n - ").replace("%%", "%") - helptext = ". ".join(i.capitalize() for i in helptext.split(". ")) helptext = self.title + " - " + helptext logger.debug("Formatted control help: (name: '%s', help: '%s'", self.name, helptext) return helptext @@ -859,10 +858,8 @@ def _multi_option_control(self, option_type): variable=self.option.tk_var) if choice.lower() in help_items: self.helpset = True - helptext = help_items[choice.lower()].capitalize() - helptext = "{}\n\n - {}".format( - '. '.join(item.capitalize() for item in helptext.split('. ')), - help_intro) + helptext = help_items[choice.lower()] + helptext = "{}\n\n - {}".format(helptext, help_intro) _get_tooltip(ctl, text=helptext, wraplength=600) ctl.pack(anchor=tk.W) logger.debug("Added %s option %s", option_type, choice) diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index cf8b276700..873e22139e 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -701,7 +701,7 @@ class MultiOption(ttk.Checkbutton): # pylint: disable=too-many-ancestors """ def __init__(self, parent, value, variable, **kwargs): self._tk_var = tk.BooleanVar() - self._tk_var.set(False) + self._tk_var.set(value == variable.get()) super().__init__(parent, variable=self._tk_var, **kwargs) self._value = value self._master_variable = variable diff --git a/lib/gui/display.py b/lib/gui/display.py index 6e0f1d770f..de83e2f951 100644 --- a/lib/gui/display.py +++ b/lib/gui/display.py @@ -1,8 +1,9 @@ #!/usr/bin python3 """ Display Frame of the Faceswap GUI - What is displayed in the Display Frame varies - depending on what tasked is being run """ +This is the large right hand area of the GUI. At default, the Analysis tab is always displayed +here. Further optional tabs will also be displayed depending on the currently executing Faceswap +task. """ import logging import tkinter as tk @@ -16,29 +17,46 @@ class DisplayNotebook(ttk.Notebook): # pylint: disable=too-many-ancestors - """ The display tabs """ + """ The tkinter Notebook that holds the display items. + + Parameters + ---------- + parent: :class:`tk.PanedWindow` + The paned window that holds the Display Notebook + """ def __init__(self, parent): logger.debug("Initializing %s", self.__class__.__name__) super().__init__(parent) parent.add(self) tk_vars = get_config().tk_vars - self.wrapper_var = tk_vars["display"] - self.runningtask = tk_vars["runningtask"] - - self.set_wrapper_var_trace() - self.add_static_tabs() - self.static_tabs = [child for child in self.tabs()] + self._wrapper_var = tk_vars["display"] + self._runningtask = tk_vars["runningtask"] + + self._set_wrapper_var_trace() + self._add_static_tabs() + # pylint:disable=unnecessary-comprehension + self._static_tabs = [child for child in self.tabs()] + self.bind("<>", self._on_tab_change) logger.debug("Initialized %s", self.__class__.__name__) - def set_wrapper_var_trace(self): - """ Set the trigger actions for the display vars - when they have been triggered in the Process Wrapper """ + @property + def runningtask(self): + """ :class:`tkinter.BooleanVar`: The global tkinter variable that indicates whether a + Faceswap task is currently running or not. """ + return self._runningtask + + def _set_wrapper_var_trace(self): + """ Sets the trigger to update the displayed notebook's pages when the global tkinter + variable `display` is updated in the :class:`~lib.gui.wrapper.ProcessWrapper`. """ logger.debug("Setting wrapper var trace") - self.wrapper_var.trace("w", self.update_displaybook) + self._wrapper_var.trace("w", self._update_displaybook) + + def _add_static_tabs(self): + """ Add the tabs to the Display Notebook that are permanently displayed. - def add_static_tabs(self): - """ Add tabs that are permanently available """ + Currently this is just the `Analysis` tab. + """ logger.debug("Adding static tabs") for tab in ("job queue", "analysis"): if tab == "job queue": @@ -48,32 +66,52 @@ def add_static_tabs(self): "Summary statistics for each training session"} frame = Analysis(self, tab, helptext) else: - frame = self.add_frame() + frame = self._add_frame() self.add(frame, text=tab.title()) - def add_frame(self): - """ Add a single frame for holding tab's contents """ + def _add_frame(self): + """ Add a single frame for holding a static tab's contents. + + Returns + ------- + ttk.Frame + The frame, packed into position + """ logger.debug("Adding frame") frame = ttk.Frame(self) frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5) return frame - def command_display(self, command): - """ Select what to display based on incoming - command """ - build_tabs = getattr(self, "{}_tabs".format(command)) + def _command_display(self, command): + """ Build the relevant command specific tabs based on the incoming Faceswap command. + + Parameters + ---------- + command: str + The Faceswap command that is being executed + """ + build_tabs = getattr(self, "_{}_tabs".format(command)) build_tabs() - def extract_tabs(self, command="extract"): - """ Build the extract tabs """ + def _extract_tabs(self, command="extract"): + """ Build the display tabs that are used for Faceswap extract and convert tasks. + + Notes + ----- + The same display tabs are used for both convert and extract tasks. + + command: [`"extract"`, `"convert"`], optional + The command that the display tabs are being built for. Default: `"extract"` + + """ logger.debug("Build extract tabs") helptext = ("Updates preview from output every 5 " "seconds to limit disk contention") PreviewExtract(self, "preview", helptext, 5000, command) logger.debug("Built extract tabs") - def train_tabs(self): - """ Build the train tabs """ + def _train_tabs(self): + """ Build the display tabs that are used for the Faceswap train task.""" logger.debug("Build train tabs") for tab in ("graph", "preview"): if tab == "graph": @@ -84,17 +122,21 @@ def train_tabs(self): PreviewTrain(self, "preview", helptext, 1000) logger.debug("Built train tabs") - def convert_tabs(self): - """ Build the convert tabs - Currently identical to Extract, so just call that """ + def _convert_tabs(self): + """ Build the display tabs that are used for the Faceswap convert task. + + Notes + ----- + The tabs displayed are the same as used for extract, so :func:`_extract_tabs` is called. + """ logger.debug("Build convert tabs") - self.extract_tabs(command="convert") + self._extract_tabs(command="convert") logger.debug("Built convert tabs") - def remove_tabs(self): - """ Remove all command specific tabs """ + def _remove_tabs(self): + """ Remove all optional displayed command specific tabs from the notebook. """ for child in self.tabs(): - if child in self.static_tabs: + if child in self._static_tabs: continue logger.debug("removing child: %s", child) child_name = child.split(".")[-1] @@ -102,10 +144,40 @@ def remove_tabs(self): child_object.close() # Call the OptionalDisplayPage close() method self.forget(child) - def update_displaybook(self, *args): # pylint: disable=unused-argument - """ Set the display tabs based on executing task """ - command = self.wrapper_var.get() - self.remove_tabs() + def _update_displaybook(self, *args): # pylint: disable=unused-argument + """ Callback to be executed when the global tkinter variable `display` + (:attr:`wrapper_var`) is updated when a Faceswap task is executed. + + Currently only updates when a core faceswap task (extract, train or convert) is executed. + + Parameters + ---------- + args: tuple + Required for tkinter callback events, but unused. + + """ + command = self._wrapper_var.get() + self._remove_tabs() if not command or command not in ("extract", "train", "convert"): return - self.command_display(command) + self._command_display(command) + + def _on_tab_change(self, event): # pylint:disable=unused-argument + """ Event trigger for tab change events. + + Calls the selected tabs :func:`on_tab_select` method, if it exists, otherwise returns. + + Parameters + ---------- + event: tkinter callback event + Required, but unused + """ + selected = self.select().split(".")[-1] + logger.debug("Selected tab: %s", selected) + selected_object = self.children[selected] + if hasattr(selected_object, "on_tab_select"): + logger.debug("Calling on_tab_select for '%s'", selected_object) + selected_object.on_tab_select() + else: + logger.debug("Object does not have on_tab_select method. Returning: '%s'", + selected_object) diff --git a/lib/gui/display_analysis.py b/lib/gui/display_analysis.py index aa1e797dfa..c4d79297e3 100644 --- a/lib/gui/display_analysis.py +++ b/lib/gui/display_analysis.py @@ -18,77 +18,122 @@ class Analysis(DisplayPage): # pylint: disable=too-many-ancestors - """ Session analysis tab """ - def __init__(self, parent, tabname, helptext): - logger.debug("Initializing: %s: (parent, %s, tabname: '%s', helptext: '%s')", - self.__class__.__name__, parent, tabname, helptext) - super().__init__(parent, tabname, helptext) - - self.summary = None - self.session = None - self.add_options() - self.add_main_frame() - self.thread = None # Thread for compiling stats data in background - self.set_callbacks() + """ Session Analysis Tab. + + The area of the GUI that holds the session summary stats for model training sessions. + + Parameters + ---------- + parent: :class:`lib.gui.display.DisplayNotebook` + The :class:`ttk.Notebook` that holds this session summary statistics page + tab_name: str + The name of the tab to be displayed in the notebook + helptext: str + The help text to display for the summary statistics page + """ + def __init__(self, parent, tab_name, helptext): + logger.debug("Initializing: %s: (parent, %s, tab_name: '%s', helptext: '%s')", + self.__class__.__name__, parent, tab_name, helptext) + super().__init__(parent, tab_name, helptext) + self._summary = None + self._session = None + + self._reset_session_info() + _Options(self) + self._stats = self._get_main_frame() + + self._thread = None # Thread for compiling stats data in background + self._set_callbacks() logger.debug("Initialized: %s", self.__class__.__name__) - def set_callbacks(self): - """ Add a callback to update analysis when the training graph is updated """ - tkv = get_config().tk_vars - tkv["refreshgraph"].trace("w", self.update_current_session) - tkv["istraining"].trace("w", self.remove_current_session) - tkv["analysis_folder"].trace("w", self.populate_from_folder) + def set_vars(self): + """ Set the analysis specific tkinter variables to :attr:`vars`. + + The tracked variables are the global variables that: + * Trigger when a graph refresh has been requested. + * Trigger training is commenced or halted + * The variable holding the location of the current Tensorboard log folder. + + Returns + ------- + dict + The dictionary of variable names to tkinter variables + """ + return dict(selected_id=tk.StringVar(), + refresh_graph=get_config().tk_vars["refreshgraph"], + is_training=get_config().tk_vars["istraining"], + analysis_folder=get_config().tk_vars["analysis_folder"]) + + def on_tab_select(self): + """ Callback for when the analysis tab is selected. + + If Faceswap is currently training a model, then update the statistics with the latest + values. + """ + if not self.vars["is_training"].get(): + return + logger.debug("Analysis update callback received") + self._reset_session() + + def _get_main_frame(self): + """ Get the main frame to the sub-notebook to hold stats and session data. + + Returns + ------- + :class:`StatsData` + The frame that holds the analysis statistics for the Analysis notebook page + """ + logger.debug("Getting main stats frame") + mainframe = self.subnotebook_add_page("stats") + retval = StatsData(mainframe, self.vars["selected_id"], self.helptext["stats"]) + logger.debug("got main frame: %s", retval) + return retval + + def _set_callbacks(self): + """ Adds callbacks to update the analysis summary statistics and add them to :attr:`vars` + + Training graph refresh - Updates the stats for the current training session when the graph + has been updated. + + When training is commenced - Removes the currently displayed session. + + When the analysis folder has been populated - Updates the stats from that folder. + """ + self.vars["refresh_graph"].trace("w", self._update_current_session) + self.vars["is_training"].trace("w", self._remove_current_session) + self.vars["analysis_folder"].trace("w", self._populate_from_folder) - def update_current_session(self, *args): # pylint:disable=unused-argument - """ Update the current session data on a graph update callback """ - if not get_config().tk_vars["refreshgraph"].get(): + def _update_current_session(self, *args): # pylint:disable=unused-argument + """ Update the currently training session data on a graph update callback. """ + if not self.vars["refresh_graph"].get(): + return + if not self._tab_is_active: + logger.debug("Analyis tab not selected. Not updating stats") return logger.debug("Analysis update callback received") - self.reset_session() + self._reset_session() - def remove_current_session(self, *args): # pylint:disable=unused-argument - """ Remove the current session data on a istraining=False callback """ - if get_config().tk_vars["istraining"].get(): + def _remove_current_session(self, *args): # pylint:disable=unused-argument + """ Remove the current session data on a is_training=False callback """ + if self.vars["is_training"].get(): return logger.debug("Remove current training Analysis callback received") - self.clear_session() + self._clear_session() - def set_vars(self): - """ Analysis specific vars """ - selected_id = tk.StringVar() - return {"selected_id": selected_id} - - def add_main_frame(self): - """ Add the main frame to the sub-notebook - to hold stats and session data """ - logger.debug("Adding main frame") - mainframe = self.subnotebook_add_page("stats") - self.stats = StatsData(mainframe, - self.vars["selected_id"], - self.helptext["stats"]) - logger.debug("Added main frame") - - def add_options(self): - """ Add the options bar """ - logger.debug("Adding options") - self.reset_session_info() - options = Options(self) - options.add_options() - logger.debug("Added options") - - def reset_session_info(self): + def _reset_session_info(self): """ Reset the session info status to default """ logger.debug("Resetting session info") self.set_info("No session data loaded") - def populate_from_folder(self, *args): # pylint:disable=unused-argument - """ Populate the Analysis tab from just a model folder. Triggered - when tkinter variable ``analysis_folder`` is set. + def _populate_from_folder(self, *args): # pylint:disable=unused-argument + """ Populate the Analysis tab from a model folder. + + Triggered when :attr:`vars` ``analysis_folder`` variable is is set. """ - folder = get_config().tk_vars["analysis_folder"].get() + folder = self.vars["analysis_folder"].get() if not folder or not os.path.isdir(folder): logger.debug("Not a valid folder") - self.clear_session() + self._clear_session() return state_files = [fname @@ -96,40 +141,33 @@ def populate_from_folder(self, *args): # pylint:disable=unused-argument if fname.endswith("_state.json")] if not state_files: logger.debug("No state files found in folder: '%s'", folder) - self.clear_session() + self._clear_session() return state_file = state_files[0] if len(state_files) > 1: logger.debug("Multiple models found. Selecting: '%s'", state_file) - if self.thread is None: - self.load_session(fullpath=os.path.join(folder, state_file)) - - def load_session(self, fullpath=None): - """ Load previously saved sessions """ - logger.debug("Loading session") - if fullpath is None: - fullpath = FileHandler("filename", "state").retfile - if not fullpath: - return - self.clear_session() - logger.debug("state_file: '%s'", fullpath) - model_dir, state_file = os.path.split(fullpath) - logger.debug("model_dir: '%s'", model_dir) - model_name = self.get_model_name(model_dir, state_file) - if not model_name: - return - self.session = Session(model_dir=model_dir, model_name=model_name) - self.session.initialize_session(is_training=False) - msg = fullpath - if len(msg) > 70: - msg = "...{}".format(msg[-70:]) - self.set_session_summary(msg) - - @staticmethod - def get_model_name(model_dir, state_file): - """ Get the state file from the model directory """ + if self._thread is None: + self._load_session(full_path=os.path.join(folder, state_file)) + + @classmethod + def _get_model_name(cls, model_dir, state_file): + """ Obtain the model name from a state file's file name. + + Parameters + ---------- + model_dir: str + The folder that the model's state file resides in + state_file: str + The filename of the model's state file + + Returns + ------- + str or ``None`` + The name of the model extracted from the state file's file name or ``None`` if no + log folders were found in the model folder + """ logger.debug("Getting model name") model_name = state_file.replace("_state.json", "") logger.debug("model_name: %s", model_name) @@ -139,69 +177,101 @@ def get_model_name(model_dir, state_file): return None return model_name - def reset_session(self): - """ Reset currently training sessions """ - logger.debug("Reset current training session") - self.clear_session() - session = get_config().session - if not session.initialized: - logger.debug("Training not running") - return - if session.logging_disabled: - logger.trace("Logging disabled. Not triggering analysis update") - return - msg = "Currently running training session" - self.session = session - # Reload the state file to get approx currently training iterations - self.session.load_state_file() - self.set_session_summary(msg) - - def set_session_summary(self, message): + def _set_session_summary(self, message): """ Set the summary data and info message """ - if self.thread is None: + if self._thread is None: logger.debug("Setting session summary. (message: '%s')", message) - self.thread = LongRunningTask(target=self.summarise_data, - args=(self.session, ), - widget=self) - self.thread.start() - self.after(1000, lambda msg=message: self.set_session_summary(msg)) - elif not self.thread.complete.is_set(): + self._thread = LongRunningTask(target=self._summarise_data, + args=(self._session, ), + widget=self) + self._thread.start() + self.after(1000, lambda msg=message: self._set_session_summary(msg)) + elif not self._thread.complete.is_set(): logger.debug("Data not yet available") - self.after(1000, lambda msg=message: self.set_session_summary(msg)) + self.after(1000, lambda msg=message: self._set_session_summary(msg)) else: logger.debug("Retrieving data from thread") - result = self.thread.get_result() + result = self._thread.get_result() if result is None: logger.debug("No result from session summary. Clearing analysis view") - self.clear_session() + self._clear_session() return - self.summary = result - self.thread = None + self._summary = result + self._thread = None self.set_info("Session: {}".format(message)) - self.stats.session = self.session - self.stats.tree_insert_data(self.summary) + self._stats.session = self._session + self._stats.tree_insert_data(self._summary) - @staticmethod - def summarise_data(session): + @classmethod + def _summarise_data(cls, session): """ Summarize data in a LongRunningThread as it can take a while """ return session.full_summary - def clear_session(self): - """ Clear sessions stats """ + def _clear_session(self): + """ Clear the currently displayed analysis data from the Tree-View. """ logger.debug("Clearing session") - if self.session is None: + if self._session is None: logger.trace("No session loaded. Returning") return - self.summary = None - self.stats.session = None - self.stats.tree_clear() - self.reset_session_info() - self.session = None - - def save_session(self): - """ Save sessions stats to csv """ + self._summary = None + self._stats.session = None + self._stats.tree_clear() + self._reset_session_info() + self._session = None + + def _load_session(self, full_path=None): + """ Load the session statistics from a model's state file into the Analysis tab of the GUI + display window. + + If a model's log files cannot be found within the model folder then the session is cleared. + + Parameters + ---------- + full_path: str, optional + The path to the state file to load session information from. If this is ``None`` then + a file dialog is popped to enable the user to choose a state file. Default: ``None`` + """ + logger.debug("Loading session") + if full_path is None: + full_path = FileHandler("filename", "state").retfile + if not full_path: + return + self._clear_session() + logger.debug("state_file: '%s'", full_path) + model_dir, state_file = os.path.split(full_path) + logger.debug("model_dir: '%s'", model_dir) + model_name = self._get_model_name(model_dir, state_file) + if not model_name: + return + self._session = Session(model_dir=model_dir, model_name=model_name) + self._session.initialize_session(is_training=False) + msg = full_path + if len(msg) > 70: + msg = "...{}".format(msg[-70:]) + self._set_session_summary(msg) + + def _reset_session(self): + """ Reset currently training sessions. Clears the current session and loads in the latest + data. """ + logger.debug("Reset current training session") + self._clear_session() + session = get_config().session + if not session.initialized: + logger.debug("Training not running") + return + if session.logging_disabled: + logger.trace("Logging disabled. Not triggering analysis update") + return + msg = "Currently running training session" + self._session = session + # Reload the state file to get approx currently training iterations + self._session.load_state_file() + self._set_session_summary(msg) + + def _save_session(self): + """ Launch a file dialog pop-up to save the current analysis data to a CSV file. """ logger.debug("Saving session") - if not self.summary: + if not self._summary: logger.debug("No summary data loaded. Nothing to save") print("No summary data loaded. Nothing to save") return @@ -211,40 +281,42 @@ def save_session(self): return logger.debug("Saving to: '%s'", savefile) - fieldnames = sorted(key for key in self.summary[0].keys()) + fieldnames = sorted(key for key in self._summary[0].keys()) with savefile as outfile: csvout = csv.DictWriter(outfile, fieldnames) csvout.writeheader() - for row in self.summary: + for row in self._summary: csvout.writerow(row) -class Options(): - """ Options bar of Analysis tab """ +class _Options(): # pylint:disable=too-few-public-methods + """ Options buttons for the Analysis tab. + + Parameters + ---------- + parent: :class:`Analysis` + The Analysis Display Tab that holds the options buttons + """ def __init__(self, parent): - logger.debug("Initializing: %s", self.__class__.__name__) - self.optsframe = parent.optsframe - self.parent = parent + logger.debug("Initializing: %s (parent: %s)", self.__class__.__name__, parent) + self._parent = parent + self._add_buttons() logger.debug("Initialized: %s", self.__class__.__name__) - def add_options(self): - """ Add the display tab options """ - self.add_buttons() - - def add_buttons(self): + def _add_buttons(self): """ Add the option buttons """ for btntype in ("clear", "save", "load"): logger.debug("Adding button: '%s'", btntype) - cmd = getattr(self.parent, "{}_session".format(btntype)) - btn = ttk.Button(self.optsframe, + cmd = getattr(self._parent, "_{}_session".format(btntype)) + btn = ttk.Button(self._parent.optsframe, image=get_images().icons[btntype], command=cmd) btn.pack(padx=2, side=tk.RIGHT) - hlp = self.set_help(btntype) + hlp = self._set_help(btntype) Tooltip(btn, text=hlp, wraplength=200) - @staticmethod - def set_help(btntype): + @classmethod + def _set_help(cls, btntype): """ Set the help text for option buttons """ logger.debug("Setting help") hlp = "" diff --git a/lib/gui/display_command.py b/lib/gui/display_command.py index b4a7477a08..08c347f336 100644 --- a/lib/gui/display_command.py +++ b/lib/gui/display_command.py @@ -13,7 +13,7 @@ from .custom_widgets import Tooltip from .stats import Calculations from .control_helper import set_slider_rounding -from .utils import FileHandler, get_config, get_images +from .utils import FileHandler, get_config, get_images, preview_trigger logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -73,6 +73,23 @@ def __init__(self, *args, **kwargs): self.update_preview = get_config().tk_vars["updatepreview"] super().__init__(*args, **kwargs) + def add_options(self): + """ Add the additional options """ + self.add_option_refresh() + super().add_options() + + def add_option_refresh(self): + """ Add refresh button to refresh preview immediately """ + logger.debug("Adding refresh option") + btnrefresh = ttk.Button(self.optsframe, + image=get_images().icons["reload"], + command=preview_trigger().set) + btnrefresh.pack(padx=2, side=tk.RIGHT) + Tooltip(btnrefresh, + text="Preview updates at every model save. Click to refresh now.", + wraplength=200) + logger.debug("Added refresh option") + def display_item_set(self): """ Load the latest preview if available """ logger.trace("Loading latest preview") @@ -173,9 +190,9 @@ def save_preview(self, location): class GraphDisplay(DisplayOptionalPage): # pylint: disable=too-many-ancestors """ The Graph Tab of the Display section """ - def __init__(self, parent, tabname, helptext, waittime, command=None): + def __init__(self, parent, tab_name, helptext, waittime, command=None): self.trace_var = None - super().__init__(parent, tabname, helptext, waittime, command) + super().__init__(parent, tab_name, helptext, waittime, command) def add_options(self): """ Add the additional options """ @@ -232,7 +249,7 @@ def display_item_set(self): smooth_amount_var = get_config().tk_vars["smoothgraph"] if session.initialized and session.logging_disabled: logger.trace("Logs disabled. Hiding graph") - self.set_info("Graph is disabled as 'no-logs' or 'pingpong' has been selected") + self.set_info("Graph is disabled as 'no-logs' has been selected") self.display_item = None if self.trace_var is not None: smooth_amount_var.trace_vdelete("w", self.trace_var) @@ -250,20 +267,19 @@ def display_item_set(self): def display_item_process(self): """ Add a single graph to the graph window """ - logger.trace("Adding graph") + logger.debug("Adding graph") existing = list(self.subnotebook_get_titles_ids().keys()) - display_tabs = sorted(self.display_item.loss_keys) - if any(key.startswith("total") for key in display_tabs): - total_idx = [idx for idx, key in enumerate(display_tabs) if key.startswith("total")][0] - display_tabs.insert(0, display_tabs.pop(total_idx)) + loss_keys = [key for key in self.display_item.loss_keys if key != "total"] + display_tabs = sorted(set(key[:-1].rstrip("_") for key in loss_keys)) for loss_key in display_tabs: tabname = loss_key.replace("_", " ").title() if tabname in existing: continue + display_keys = [key for key in loss_keys if key.startswith(loss_key)] data = Calculations(session=get_config().session, display="loss", - loss_keys=[loss_key], + loss_keys=display_keys, selections=["raw", "smoothed"], smooth_amount=get_config().tk_vars["smoothgraph"].get()) self.add_child(tabname, data) diff --git a/lib/gui/display_graph.py b/lib/gui/display_graph.py index 6a2652c190..895b2b6792 100755 --- a/lib/gui/display_graph.py +++ b/lib/gui/display_graph.py @@ -14,8 +14,8 @@ from matplotlib import style # noqa from matplotlib.figure import Figure # noqa -from matplotlib.backends.backend_tkagg import (FigureCanvasTkAgg, - NavigationToolbar2Tk) # noqa +from matplotlib.backends.backend_tkagg import (FigureCanvasTkAgg, # noqa + NavigationToolbar2Tk) from .custom_widgets import Tooltip # noqa from .utils import get_config, get_images, LongRunningTask # noqa diff --git a/lib/gui/display_page.py b/lib/gui/display_page.py index d872e86a21..f8405c3cac 100644 --- a/lib/gui/display_page.py +++ b/lib/gui/display_page.py @@ -14,15 +14,15 @@ class DisplayPage(ttk.Frame): # pylint: disable=too-many-ancestors """ Parent frame holder for each tab. Defines uniform structure for each tab to inherit from """ - def __init__(self, parent, tabname, helptext): - logger.debug("Initializing %s: (tabname: '%s', helptext: %s)", - self.__class__.__name__, tabname, helptext) + def __init__(self, parent, tab_name, helptext): + logger.debug("Initializing %s: (tab_name: '%s', helptext: %s)", + self.__class__.__name__, tab_name, helptext) ttk.Frame.__init__(self, parent) - self.pack(fill=tk.BOTH, side=tk.TOP, anchor=tk.NW) + self._parent = parent self.runningtask = parent.runningtask self.helptext = helptext - self.tabname = tabname + self.tabname = tab_name self.vars = {"info": tk.StringVar()} self.add_optional_vars(self.set_vars()) @@ -33,9 +33,17 @@ def __init__(self, parent, tabname, helptext): self.add_frame_separator() self.set_mainframe_single_tab_style() + + self.pack(fill=tk.BOTH, side=tk.TOP, anchor=tk.NW) parent.add(self, text=self.tabname.title()) + logger.debug("Initialized %s", self.__class__.__name__,) + @property + def _tab_is_active(self): + """ bool: ``True`` if the tab currently has focus otherwise ``False`` """ + return self._parent.tab(self._parent.select(), "text").lower() == self.tabname.lower() + def add_optional_vars(self, varsdict): """ Add page specific variables """ if isinstance(varsdict, dict): @@ -48,6 +56,11 @@ def set_vars(): """ Override to return a dict of page specific variables """ return dict() + def on_tab_select(self): # pylint:disable=no-self-use + """ Override for specific actions when the current tab is selected """ + logger.debug("Returning as 'on_tab_select' not implemented for %s", + self.__class__.__name__) + def add_subnotebook(self): """ Add the main frame notebook """ logger.debug("Adding subnotebook") @@ -150,11 +163,12 @@ def subnotebook_page_from_id(self, tab_id): class DisplayOptionalPage(DisplayPage): # pylint: disable=too-many-ancestors """ Parent Context Sensitive Display Tab """ - def __init__(self, parent, tabname, helptext, waittime, command=None): + def __init__(self, parent, tab_name, helptext, waittime, command=None): logger.debug("%s: OptionalPage args: (waittime: %s, command: %s)", self.__class__.__name__, waittime, command) - DisplayPage.__init__(self, parent, tabname, helptext) + DisplayPage.__init__(self, parent, tab_name, helptext) + self._waittime = waittime self.command = command self.display_item = None @@ -163,7 +177,7 @@ def __init__(self, parent, tabname, helptext, waittime, command=None): parent.select(self) self.update_idletasks() - self.update_page(waittime) + self._update_page() @staticmethod def set_vars(): @@ -183,6 +197,14 @@ def set_vars(): logger.debug(tk_vars) return tk_vars + def on_tab_select(self): + """ Callback for when the optional tab is selected. + + Run the tab's update code when the tab is selected. + """ + logger.debug("Callback received for '%s' tab", self.tabname) + self._update_page() + # INFO LABEL def set_info_text(self): """ Set waiting for display text """ @@ -213,7 +235,7 @@ def add_option_save(self): wraplength=200) def add_option_enable(self): - """ Add checkbutton to enable/disable page """ + """ Add check-button to enable/disable page """ logger.debug("Adding enable option") chkenable = ttk.Checkbutton(self.optsframe, variable=self.vars["enabled"], @@ -229,7 +251,7 @@ def save_items(self): raise NotImplementedError() def on_chkenable_change(self): - """ Update the display immediately on a checkbutton change """ + """ Update the display immediately on a check-button change """ logger.debug("Enabled checkbox changed") if self.vars["enabled"].get(): self.subnotebook_show() @@ -237,15 +259,15 @@ def on_chkenable_change(self): self.subnotebook_hide() self.set_info_text() - def update_page(self, waittime): + def _update_page(self): """ Update the latest preview item """ - if not self.runningtask.get(): + if not self.runningtask.get() or not self._tab_is_active: return if self.vars["enabled"].get(): logger.trace("Updating page") self.display_item_set() self.load_display() - self.after(waittime, lambda t=waittime: self.update_page(t)) + self.after(self._waittime, self._update_page) def display_item_set(self): """ Override for display specific loading """ @@ -253,9 +275,9 @@ def display_item_set(self): def load_display(self): """ Load the display """ - if not self.display_item: + if not self.display_item or not self._tab_is_active: return - logger.debug("Loading display") + logger.debug("Loading display for tab: %s", self.tabname) self.display_item_process() self.vars["ready"].set(True) self.set_info_text() diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 61e336e9d5..218bb41d08 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -101,13 +101,13 @@ def build(self): self.add_command( label=label, underline=10, - command=lambda conf=(name, config), root=self.root: popup_config(conf, root)) + command=lambda n=name, c=config: popup_config(n, c)) self.add_separator() conf = get_config().user_config self.add_command( label="GUI Settings...", underline=10, - command=lambda conf=("GUI", conf), root=self.root: popup_config(conf, root)) + command=lambda n="GUI", c=conf: popup_config(n, c)) logger.debug("Built settings menu") @@ -546,7 +546,6 @@ def _settings_btns(self): # pylint: disable=cell-var-from-loop frame = ttk.Frame(self._btn_frame) frame.pack(side=tk.LEFT, anchor=tk.W, expand=False, padx=2) - root = get_config().root for name in _CONFIG_FILES: config = _CONFIGS[name] btntype = "settings_{}".format(name) @@ -555,7 +554,7 @@ def _settings_btns(self): btn = ttk.Button( frame, image=get_images().icons[btntype], - command=lambda conf=(name, config), root=root: popup_config(conf, root)) + command=lambda n=name, c=config: popup_config(n, c)) btn.pack(side=tk.LEFT, anchor=tk.W) hlp = "Configure {} settings...".format(name.title()) Tooltip(btn, text=hlp, wraplength=200) diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 9a7dfd7aba..9cb2b01b72 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -1,5 +1,5 @@ #!/usr/bin python3 -""" Configure Plugins popup of the Faceswap GUI """ +""" The pop-up window of the Faceswap GUI for the setting of configuration options. """ from collections import OrderedDict from configparser import ConfigParser @@ -16,40 +16,62 @@ POPUP = dict() -def popup_config(config, root): - """ Close any open popup and open requested popup """ +def popup_config(name, configuration): + """ Open the settings for the requested configuration file and close any already active + pop-ups. + + Parameters + ---------- + name: str + The name of the configuration file. Used for the pop-up title bar. + configuration: :class:`~lib.config.FaceswapConfig` + The configuration options for the requested pop-up window + """ + logger.debug("name: %s, configuration: %s", name, configuration) if POPUP: p_key = list(POPUP.keys())[0] logger.debug("Closing open popup: '%s'", p_key) POPUP[p_key].destroy() del POPUP[p_key] - window = ConfigurePlugins(config, root) - POPUP[config[0]] = window - - -class ConfigurePlugins(tk.Toplevel): - """ Pop up for detailed graph/stats for selected session """ - def __init__(self, config, root): - logger.debug("Initializing %s", self.__class__.__name__) + window = _ConfigurePlugins(name, configuration) + POPUP[name] = window + logger.debug("Current pop-up: %s", POPUP) + + +class _ConfigurePlugins(tk.Toplevel): + """ Pop-up window for the setting of Faceswap Configuration Options. + + Parameters + ---------- + name: str + The name of the configuration file. Used for the pop-up title bar. + configuration: :class:`~lib.config.FaceswapConfig` + The configuration options for the requested pop-up window + """ + def __init__(self, name, configuration): + logger.debug("Initializing %s: (name: %s, configuration: %s)", + self.__class__.__name__, name, configuration) super().__init__() - self._name, self.config = config - self.title("{} Plugins".format(self._name.title())) - self.tk.call('wm', 'iconphoto', self._w, get_images().icons["favicon"]) + self._name = name + self._config = configuration + self._root = get_config().root - self._root = root - self.set_geometry() + self._set_geometry() - self.page_frame = ttk.Frame(self) - self.page_frame.pack(fill=tk.BOTH, expand=True) + self._page_frame = ttk.Frame(self) + self._plugin_info = dict() - self.plugin_info = dict() - self.config_cpanel_dict = self.get_config() - self.build() + self._config_cpanel_dict = self._get_config() + self._build() self.update() + + self._page_frame.pack(fill=tk.BOTH, expand=True) + self.title("{} Plugins".format(self._name.title())) + self.tk.call('wm', 'iconphoto', self._w, get_images().icons["favicon"]) logger.debug("Initialized %s", self.__class__.__name__) - def set_geometry(self): - """ Set pop-up geometry """ + def _set_geometry(self): + """ Set the geometry of the pop-up window """ scaling_factor = get_config().scaling_factor pos_x = self._root.winfo_x() + 80 pos_y = self._root.winfo_y() + 80 @@ -58,26 +80,34 @@ def set_geometry(self): logger.debug("Pop up Geometry: %sx%s, %s+%s", width, height, pos_x, pos_y) self.geometry("{}x{}+{}+{}".format(width, height, pos_x, pos_y)) - def get_config(self): - """ Format config into a dict of ControlPanelOptions """ + def _get_config(self): + """ Format the configuration options stored in :attr:`_config` into a dict of + :class:`~lib.gui.control_helper.ControlPanelOption's for placement into option frames. + + Returns + ------- + dict + A dictionary of section names to :class:`~lib.gui.control_helper.ControlPanelOption` + objects + """ logger.debug("Formatting Config for GUI") conf = dict() - for section in self.config.config.sections(): - self.config.section = section + for section in self._config.config.sections(): + self._config.section = section category = section.split(".")[0] - options = self.config.defaults[section] + options = self._config.defaults[section] section = section.split(".")[-1] conf.setdefault(category, dict())[section] = OrderedDict() for key, val in options.items(): if key == "helptext": - self.plugin_info[section] = val + self._plugin_info[section] = val continue conf[category][section][key] = ControlPanelOption( title=key, dtype=val["type"], group=val["group"], default=val["default"], - initial_value=self.config.config_dict.get(key, val["default"]), + initial_value=self._config.config_dict.get(key, val["default"]), choices=val["choices"], is_radio=val["gui_radio"], rounding=val["rounding"], @@ -86,77 +116,96 @@ def get_config(self): logger.debug("Formatted Config for GUI: %s", conf) return conf - def build(self): - """ Build the config popup """ + def _build(self): + """ Build the configuration pop-up window""" logger.debug("Building plugin config popup") - container = ttk.Notebook(self.page_frame) - container.pack(fill=tk.BOTH, expand=True) - categories = sorted(list(self.config_cpanel_dict.keys())) + container = ttk.Notebook(self._page_frame) + categories = sorted(list(self._config_cpanel_dict.keys())) if "global" in categories: # Move global to first item categories.insert(0, categories.pop(categories.index("global"))) for category in categories: - page = self.build_page(container, category) + page = self._build_page(container, category) container.add(page, text=category.title()) - self.add_frame_separator() - self.add_actions() + self._add_frame_separator() + self._add_actions() + + container.pack(fill=tk.BOTH, expand=True) logger.debug("Built plugin config popup") - def build_page(self, container, category): - """ Build a plugin config page """ + def _build_page(self, container, category): + """ Build a single tab within the plugin's configuration pop-up. + + Parameters + ---------- + container: :class:`ttk.Notebook` + The notebook to place the category options into + category: str + The name of the categories to build options for + + Returns + ------- + :class:'~lib.gui.control_helper.ControlPanel` or :class:`ttk.Notebook` + The control panel options in a Control Panel frame (for single plugin configurations) + or a Notebook containing tabs with Control Panel frames (for multi-plugin + configurations) + """ logger.debug("Building plugin config page: '%s'", category) - plugins = sorted(list(key for key in self.config_cpanel_dict[category].keys())) + plugins = sorted(list(key for key in self._config_cpanel_dict[category].keys())) panel_kwargs = dict(columns=2, max_columns=2, option_columns=2, blank_nones=False) if any(plugin != category for plugin in plugins): page = ttk.Notebook(container) - page.pack(side=tk.TOP, fill=tk.BOTH, expand=True) for plugin in plugins: - cp_options = list(self.config_cpanel_dict[category][plugin].values()) + cp_options = list(self._config_cpanel_dict[category][plugin].values()) frame = ControlPanel(page, cp_options, - header_text=self.plugin_info[plugin], + header_text=self._plugin_info[plugin], **panel_kwargs) title = plugin[plugin.rfind(".") + 1:] title = title.replace("_", " ").title() page.add(frame, text=title) + page.pack(side=tk.TOP, fill=tk.BOTH, expand=True) else: - cp_options = list(self.config_cpanel_dict[category][plugins[0]].values()) + cp_options = list(self._config_cpanel_dict[category][plugins[0]].values()) page = ControlPanel(container, cp_options, - header_text=self.plugin_info[plugins[0]], + header_text=self._plugin_info[plugins[0]], **panel_kwargs) logger.debug("Built plugin config page: '%s'", category) return page - def add_frame_separator(self): - """ Add a separator between top and bottom frames """ + def _add_frame_separator(self): + """ Add a separator between the configuration options and the action buttons. """ logger.debug("Add frame seperator") - sep = ttk.Frame(self.page_frame, height=2, relief=tk.RIDGE) + sep = ttk.Frame(self._page_frame, height=2, relief=tk.RIDGE) sep.pack(fill=tk.X, pady=(5, 0), side=tk.BOTTOM) logger.debug("Added frame seperator") - def add_actions(self): - """ Add Action buttons """ + def _add_actions(self): + """ Add Action buttons to the bottom of the pop-up window. """ logger.debug("Add action buttons") - frame = ttk.Frame(self.page_frame) - frame.pack(fill=tk.BOTH, padx=5, pady=5, side=tk.BOTTOM) + frame = ttk.Frame(self._page_frame) btn_cls = ttk.Button(frame, text="Cancel", width=10, command=self.destroy) - btn_cls.pack(padx=2, side=tk.RIGHT) + btn_ok = ttk.Button(frame, text="OK", width=10, command=self._save) + btn_rst = ttk.Button(frame, text="Reset", width=10, command=self._reset) + Tooltip(btn_cls, text="Close without saving", wraplength=720) - btn_ok = ttk.Button(frame, text="OK", width=10, command=self.save_config) - btn_ok.pack(padx=2, side=tk.RIGHT) Tooltip(btn_ok, text="Close and save config", wraplength=720) - btn_rst = ttk.Button(frame, text="Reset", width=10, command=self.reset) - btn_rst.pack(padx=2, side=tk.RIGHT) Tooltip(btn_rst, text="Reset all plugins to default values", wraplength=720) + + frame.pack(fill=tk.BOTH, padx=5, pady=5, side=tk.BOTTOM) + btn_cls.pack(padx=2, side=tk.RIGHT) + btn_ok.pack(padx=2, side=tk.RIGHT) + btn_rst.pack(padx=2, side=tk.RIGHT) + logger.debug("Added action buttons") - def reset(self): - """ Reset all config options to default """ + def _reset(self): + """ Reset all configuration options to their default values. """ logger.debug("Resetting config") - for section, items in self.config.defaults.items(): + for section, items in self._config.defaults.items(): logger.debug("Resetting section: '%s'", section) lookup = [section.split(".")[0], section.split(".")[-1]] for item, def_opt in items.items(): @@ -164,18 +213,18 @@ def reset(self): continue default = def_opt["default"] logger.debug("Resetting: '%s' to '%s'", item, default) - self.config_cpanel_dict[lookup[0]][lookup[1]][item].set(default) + self._config_cpanel_dict[lookup[0]][lookup[1]][item].set(default) - def save_config(self): - """ Save the config file """ + def _save(self): + """ Save the configuration file to disk. """ logger.debug("Saving config") options = {".".join((key, sect)) if sect != key else key: opts - for key, value in self.config_cpanel_dict.items() + for key, value in self._config_cpanel_dict.items() for sect, opts in value.items()} new_config = ConfigParser(allow_no_value=True) - for section, items in self.config.defaults.items(): + for section, items in self._config.defaults.items(): logger.debug("Adding section: '%s')", section) - self.config.insert_config_section(section, items["helptext"], config=new_config) + self._config.insert_config_section(section, items["helptext"], config=new_config) for item, def_opt in items.items(): if item == "helptext": continue @@ -183,12 +232,12 @@ def save_config(self): logger.debug("Adding option: (item: '%s', default: %s new: '%s'", item, def_opt, new_opt) helptext = def_opt["helptext"] - helptext = self.config.format_help(helptext, is_section=False) + helptext = self._config.format_help(helptext, is_section=False) new_config.set(section, helptext) new_config.set(section, item, str(new_opt)) - self.config.config = new_config - self.config.save_config() - logger.info("Saved config: '%s'", self.config.configfile) + self._config.config = new_config + self._config.save_config() + logger.info("Saved config: '%s'", self._config.configfile) self.destroy() running_task = get_config().tk_vars["runningtask"].get() diff --git a/lib/gui/stats.py b/lib/gui/stats.py index 307ba485bb..960368da46 100644 --- a/lib/gui/stats.py +++ b/lib/gui/stats.py @@ -11,6 +11,7 @@ import numpy as np import tensorflow as tf from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module +from tensorflow.core.util import event_pb2 from lib.serializer import get_serializer logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -29,11 +30,18 @@ def convert_time(timestamp): class TensorBoardLogs(): """ Parse and return data from TensorBoard logs """ def __init__(self, logs_folder): + tf.config.set_visible_devices([], "GPU") # Don't use the GPU for stats self.folder_base = logs_folder - self.log_filenames = self.set_log_filenames() + self.log_filenames = self._get_log_filenames() - def set_log_filenames(self): - """ Set the TensorBoard log filenames for all existing sessions """ + def _get_log_filenames(self): + """ Get the TensorBoard log filenames for all existing sessions. + + Returns + ------- + dict + The full path of each log file for each training session that has been run + """ logger.debug("Loading log filenames. base_dir: '%s'", self.folder_base) log_filenames = dict() for dirpath, _, filenames in os.walk(self.folder_base): @@ -41,64 +49,89 @@ def set_log_filenames(self): continue logfiles = [filename for filename in filenames if filename.startswith("events.out.tfevents")] - # Take the last logfile, in case of previous crash + # Take the last log file, in case of previous crash logfile = os.path.join(dirpath, sorted(logfiles)[-1]) - side, session = os.path.split(dirpath) - side = os.path.split(side)[1] - session = int(session[session.rfind("_") + 1:]) - log_filenames.setdefault(session, dict())[side] = logfile + session = os.path.split(os.path.split(dirpath)[0])[1] + session = session[session.rfind("_") + 1:] + if not session.isdigit(): + logger.warning("Unable to load session data for model") + return log_filenames + session = int(session) + log_filenames[session] = logfile logger.debug("logfiles: %s", log_filenames) return log_filenames - def get_loss(self, side=None, session=None): - """ Read the loss from the TensorBoard logs - Specify a side or a session or leave at None for all + def get_loss(self, session=None): + """ Read the loss from the TensorBoard event logs + + Parameters + ---------- + session: int, optional + The Session ID to return the loss for. Set to ``None`` to return all session + losses. Default ``None`` + + Returns + ------- + dict + A list of loss values for each step for the requested session """ - logger.debug("Getting loss: (side: %s, session: %s)", side, session) + logger.debug("Getting loss: (session: %s)", session) all_loss = dict() - for sess, sides in self.log_filenames.items(): + for sess, logfile in self.log_filenames.items(): if session is not None and sess != session: logger.debug("Skipping session: %s", sess) continue loss = dict() - for sde, logfile in sides.items(): - if side is not None and sde != side: - logger.debug("Skipping side: %s", sde) + events = [event_pb2.Event.FromString(record.numpy()) + for record in tf.data.TFRecordDataset(logfile)] + for event in events: + if not event.summary.value or not event.summary.value[0].tag.startswith("batch_"): continue - for event in tf.train.summary_iterator(logfile): - for summary in event.summary.value: - if "loss" not in summary.tag: - continue - tag = summary.tag.replace("batch_", "") - loss.setdefault(tag, - dict()).setdefault(sde, - list()).append(summary.simple_value) + summary = event.summary.value[0] + tag = summary.tag.replace("batch_", "") + loss.setdefault(tag, []).append(summary.simple_value) all_loss[sess] = loss + logger.debug(all_loss) return all_loss def get_timestamps(self, session=None): - """ Read the timestamps from the TensorBoard logs - Specify a session or leave at None for all - NB: For all intents and purposes timestamps are the same for - both sides, so just read from one side """ + """ Read the timestamps from the TensorBoard logs. + + As loss timestamps are slightly different for each loss, we collect the timestamp from the + `batch_total` key. + + Parameters + ---------- + session: int, optional + The Session ID to return the timestamps for. Set to ``None`` to return all session + timestamps. Default ``None`` + + Returns + ------- + dict + The timestamps for each event for the requested session + """ + logger.debug("Getting timestamps") all_timestamps = dict() - for sess, sides in self.log_filenames.items(): + for sess, logfile in self.log_filenames.items(): if session is not None and sess != session: logger.debug("Skipping sessions: %s", sess) continue try: - for logfile in sides.values(): - timestamps = [event.wall_time - for event in tf.train.summary_iterator(logfile) - if event.summary.value] - logger.debug("Total timestamps for session %s: %s", sess, len(timestamps)) - all_timestamps[sess] = timestamps - break # break after first file read + events = [event_pb2.Event.FromString(record.numpy()) + for record in tf.data.TFRecordDataset(logfile)] + timestamps = [event.wall_time + for event in events + if event.summary.value + and event.summary.value[0].tag == "batch_total"] + logger.debug("Total timestamps for session %s: %s", sess, len(timestamps)) + all_timestamps[sess] = timestamps except tf_errors.DataLossError as err: logger.warning("The logs for Session %s are corrupted and cannot be displayed. " "The totals do not include this session. Original error message: " "'%s'", sess, str(err)) + logger.debug(all_timestamps) return all_timestamps @@ -133,7 +166,7 @@ def config(self): @property def full_summary(self): - """ Retun all sessions summary data""" + """ Return all sessions summary data""" return self.summary.compile_stats() @property @@ -144,23 +177,22 @@ def iterations(self): @property def logging_disabled(self): """ Return whether logging is disabled for this session """ - return self.session["no_logs"] or self.session["pingpong"] + return self.session["no_logs"] @property def loss(self): - """ Return loss from logs for current session """ + """ dict: The loss for the current session id for each loss key """ loss_dict = self.tb_logs.get_loss(session=self.session_id)[self.session_id] return loss_dict @property def loss_keys(self): - """ Return list of unique session loss keys """ + """ list: The loss keys for the current session, or loss keys for all sessions. """ if self.session_id is None: - loss_keys = self.total_loss_keys + retval = self._total_loss_keys else: - loss_keys = set(loss_key for side_keys in self.session["loss_names"].values() - for loss_key in side_keys) - return list(loss_keys) + retval = self.session["loss_names"] + return retval @property def lowest_loss(self): @@ -196,27 +228,25 @@ def total_iterations(self): @property def total_loss(self): - """ Return collated loss for all session """ + """ dict: The collated loss for all sessions for each loss key """ loss_dict = dict() all_loss = self.tb_logs.get_loss() - for key in sorted(int(idx) for idx in all_loss): - for loss_key, side_loss in all_loss[key].items(): - for side, loss in side_loss.items(): - loss_dict.setdefault(loss_key, dict()).setdefault(side, list()).extend(loss) + for key in sorted(all_loss): + for loss_key, loss in all_loss[key].items(): + loss_dict.setdefault(loss_key, []).extend(loss) return loss_dict @property - def total_loss_keys(self): - """ Return list of unique session loss keys across all sessions """ + def _total_loss_keys(self): + """ list: The loss keys for all sessions. """ loss_keys = set(loss_key for session in self.state["sessions"].values() - for loss_keys in session["loss_names"].values() - for loss_key in loss_keys) + for loss_key in session["loss_names"]) return list(loss_keys) @property def total_timestamps(self): - """ Return timestamps from logs seperated per session for all sessions """ + """ Return timestamps from logs separated per session for all sessions """ return self.tb_logs.get_timestamps() def initialize_session(self, is_training=False, session_id=None): @@ -280,13 +310,14 @@ def sessions_stats(self): iterations = self.session.get_iterations_for_session(sess_idx) elapsed = ts_data["end_time"] - ts_data["start_time"] batchsize = self.session.total_batchsize.get(sess_idx, 0) - compiled.append({"session": sess_idx, - "start": ts_data["start_time"], - "end": ts_data["end_time"], - "elapsed": elapsed, - "rate": (batchsize * iterations) / elapsed if elapsed != 0 else 0, - "batch": batchsize, - "iterations": iterations}) + compiled.append( + {"session": sess_idx, + "start": ts_data["start_time"], + "end": ts_data["end_time"], + "elapsed": elapsed, + "rate": ((batchsize * 2) * iterations) / elapsed if elapsed != 0 else 0, + "batch": batchsize, + "iterations": iterations}) compiled = sorted(compiled, key=lambda k: k["session"]) return compiled @@ -294,7 +325,7 @@ def compile_stats(self): """ Compile sessions stats with totals, format and return """ logger.debug("Compiling sessions summary data") compiled_stats = self.sessions_stats - if compiled_stats is None: + if not compiled_stats: return compiled_stats logger.debug("sessions_stats: %s", compiled_stats) total_stats = self.total_stats(compiled_stats) @@ -318,7 +349,7 @@ def total_stats(sessions_stats): if idx == total_summaries - 1: endtime = summary["end"] elapsed += summary["elapsed"] - examples += (summary["batch"] * summary["iterations"]) + examples += ((summary["batch"] * 2) * summary["iterations"]) batchset.add(summary["batch"]) iterations += summary["iterations"] batch = ",".join(str(bs) for bs in batchset) @@ -376,28 +407,32 @@ def refresh(self): logger.warning("Session data is not initialized. Not refreshing") return None self.iterations = 0 - self.stats = self.get_raw() + self.stats = self._get_raw() self.get_calculations() self.remove_raw() logger.debug("Refreshed") return self - def get_raw(self): - """ Add raw data to stats dict """ - logger.debug("Getting Raw Data") + def _get_raw(self): + """ Obtain the raw loss values. + Returns + ------- + dict + The loss name as key with list of loss values as value + """ + logger.debug("Getting Raw Data") raw = dict() iterations = set() if self.display.lower() == "loss": loss_dict = self.session.total_loss if self.is_totals else self.session.loss - for loss_name, side_loss in loss_dict.items(): + for loss_name, loss in loss_dict.items(): if loss_name not in self.loss_keys: continue - for side, loss in side_loss.items(): - if self.args["flatten_outliers"]: - loss = self.flatten_outliers(loss) - iterations.add(len(loss)) - raw["raw_{}_{}".format(loss_name, side)] = loss + if self.args["flatten_outliers"]: + loss = self.flatten_outliers(loss) + iterations.add(len(loss)) + raw["raw_{}".format(loss_name)] = loss self.iterations = 0 if not iterations else min(iterations) if len(iterations) > 1: @@ -407,7 +442,7 @@ def get_raw(self): else: raw = {lossname: loss[:self.iterations] for lossname, loss in raw.items()} - else: # Rate calulation + else: # Rate calculation data = self.calc_rate_total() if self.is_totals else self.calc_rate() if self.args["flatten_outliers"]: data = self.flatten_outliers(data) @@ -433,7 +468,7 @@ def calc_rate(self): batchsize = self.session.batchsize timestamps = self.session.timestamps iterations = range(len(timestamps) - 1) - rate = [batchsize / (timestamps[i + 1] - timestamps[i]) for i in iterations] + rate = [(batchsize * 2) / (timestamps[i + 1] - timestamps[i]) for i in iterations] logger.debug("Calculated rate: Item_count: %s", len(rate)) return rate @@ -450,7 +485,8 @@ def calc_rate_total(self): batchsize = batchsizes[sess_id] timestamps = total_timestamps[sess_id] iterations = range(len(timestamps) - 1) - rate.extend([batchsize / (timestamps[i + 1] - timestamps[i]) for i in iterations]) + rate.extend([(batchsize * 2) / (timestamps[i + 1] - timestamps[i]) + for i in iterations]) logger.debug("Calculated totals rate: Item_count: %s", len(rate)) return rate @@ -508,7 +544,7 @@ def calc_avg(self, data): def calc_smoothed(self, data): """ Smooth the data """ - last = data[0] # First value in the plot (first timestep) + last = data[0] # First value in the plot (first time step) weight = self.args["smooth_amount"] smoothed = list() for point in data: diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 31adfadeae..a735880d18 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -17,6 +17,7 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name _CONFIG = None _IMAGES = None +_PREVIEW_TRIGGER = None PATHCACHE = os.path.join(os.path.realpath(os.path.dirname(sys.argv[0])), "lib", "gui", ".cache") @@ -1155,3 +1156,44 @@ def get_result(self): logger.debug("Got result from thread") self._config.set_cursor_default(widget=self._widget) return retval + + +class PreviewTrigger(): + """ Trigger to indicate to underlying Faceswap process that the preview image should + be updated. + + Writes a file to the cache folder that is picked up by the main process. + """ + def __init__(self): + logger.debug("Initializing: %s", self.__class__.__name__) + self._trigger_file = os.path.join(PATHCACHE, ".preview_trigger") + logger.debug("Initialized: %s (trigger_file: %s)", + self.__class__.__name__, self._trigger_file) + + def set(self): + """ Place the trigger file into the cache folder """ + if not os.path.isfile(self._trigger_file): + with open(self._trigger_file, "w"): + pass + logger.debug("Set preview update trigger: %s", self._trigger_file) + + def clear(self): + """ Remove the trigger file from the cache folder """ + if os.path.isfile(self._trigger_file): + os.remove(self._trigger_file) + logger.debug("Removed preview update trigger: %s", self._trigger_file) + + +def preview_trigger(): + """ Set the global preview trigger if it has not always been set and return. + + Returns + ------- + :class:`PreviewTrigger` + The trigger to indicate to the main faceswap process that it should perform a training + preview update + """ + global _PREVIEW_TRIGGER # pylint:disable=global-statement + if _PREVIEW_TRIGGER is None: + _PREVIEW_TRIGGER = PreviewTrigger() + return _PREVIEW_TRIGGER diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index c31b84344f..38b3ee9ef1 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -11,7 +11,7 @@ import psutil -from .utils import get_config, get_images, LongRunningTask +from .utils import get_config, get_images, LongRunningTask, preview_trigger if os.name == "nt": import win32console # pylint: disable=import-error @@ -131,6 +131,7 @@ def terminate(self, message): self.tk_vars["display"].set(None) get_images().delete_preview() get_config().session.__init__() + preview_trigger().clear() self.command = None logger.debug("Terminated Faceswap processes") print("Process exited.") @@ -188,19 +189,21 @@ def read_stdout(self): (self.command == "effmpeg" and self.capture_ffmpeg(output)) or (self.command not in ("train", "effmpeg") and self.capture_tqdm(output))): continue - if (self.command == "train" and - self.wrapper.tk_vars["istraining"].get() and - "[saved models]" in output.strip().lower()): - logger.debug("Trigger GUI Training update") - logger.trace("tk_vars: %s", {itm: var.get() - for itm, var in self.wrapper.tk_vars.items()}) - if not self.config.session.initialized: - # Don't initialize session until after the first save as state - # file must exist first - logger.debug("Initializing curret training session") - self.config.session.initialize_session(is_training=True) - self.wrapper.tk_vars["updatepreview"].set(True) - self.wrapper.tk_vars["refreshgraph"].set(True) + if self.command == "train" and self.wrapper.tk_vars["istraining"].get(): + if "[saved models]" in output.strip().lower(): + logger.debug("Trigger GUI Training update") + logger.trace("tk_vars: %s", {itm: var.get() + for itm, var in self.wrapper.tk_vars.items()}) + if not self.config.session.initialized: + # Don't initialize session until after the first save as state + # file must exist first + logger.debug("Initializing curret training session") + self.config.session.initialize_session(is_training=True) + self.wrapper.tk_vars["updatepreview"].set(True) + self.wrapper.tk_vars["refreshgraph"].set(True) + if "[preview updated]" in output.strip().lower(): + self.wrapper.tk_vars["updatepreview"].set(True) + continue print(output.strip()) returncode = self.process.poll() message = self.set_final_status(returncode) diff --git a/lib/logger.py b/lib/logger.py index 66e77550f5..35edb9c259 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -1,5 +1,5 @@ #!/usr/bin/python -""" Logging Setup """ +""" Logging Functions for Faceswap. """ import collections import logging from logging.handlers import RotatingFileHandler @@ -12,7 +12,7 @@ class FaceswapLogger(logging.Logger): - """ Create custom logger with custom levels """ + """ A standard :class:`logging.logger` with additional "verbose" and "trace" levels added. """ def __init__(self, name): for new_level in (("VERBOSE", 15), ("TRACE", 5)): level_name, level_num = new_level @@ -23,26 +23,61 @@ def __init__(self, name): super().__init__(name) def verbose(self, msg, *args, **kwargs): - """ - Log 'msg % args' with severity 'VERBOSE'. + # pylint:disable=wrong-spelling-in-docstring + """ Create a log message at severity level 15. + + Parameters + ---------- + msg: str + The log message to be recorded at Verbose level + args: tuple + Standard logging arguments + kwargs: dict + Standard logging key word arguments """ if self.isEnabledFor(15): self._log(15, msg, args, **kwargs) def trace(self, msg, *args, **kwargs): - """ - Log 'msg % args' with severity 'VERBOSE'. + # pylint:disable=wrong-spelling-in-docstring + """ Create a log message at severity level 5. + + Parameters + ---------- + msg: str + The log message to be recorded at Trace level + args: tuple + Standard logging arguments + kwargs: dict + Standard logging key word arguments """ if self.isEnabledFor(5): self._log(5, msg, args, **kwargs) class FaceswapFormatter(logging.Formatter): - """ Override formatter to strip newlines the final message """ + """ Overrides the standard :class:`logging.Formatter`. + + Strip newlines from incoming log messages. + + Rewrites some upstream warning messages to debug level to avoid spamming the console. + """ def format(self, record): + """ Strip new lines from log records and rewrite certain warning messages to debug level. + + Parameters + ---------- + record : :class:`logging.LogRecord` + The incoming log record to be formatted for entry into the logger. + + Returns + ------- + str + The formatted log message + """ record.message = record.getMessage() - record = self.rewrite_tf_deprecation(record) + record = self._rewrite_warnings(record) # strip newlines if "\n" in record.message or "\r" in record.message: record.message = record.message.replace("\n", "\\n").replace("\r", "\\r") @@ -65,62 +100,132 @@ def format(self, record): msg = msg + self.formatStack(record.stack_info) return msg - @staticmethod - def rewrite_tf_deprecation(record): - """ Change TF deprecation messages from WARNING to DEBUG """ + @classmethod + def _rewrite_warnings(cls, record): + """ Change certain warning messages from WARNING to DEBUG to avoid passing non-important + information to output. + + Parameters + ---------- + record: :class:`logging.LogRecord` + The log record to check for rewriting + """ if record.levelno == 30 and (record.funcName == "_tfmw_add_deprecation_warning" or - record.module in("deprecation", "deprecation_wrapper")): + record.module in ("deprecation", "deprecation_wrapper")): record.levelno = 10 record.levelname = "DEBUG" return record class RollingBuffer(collections.deque): - """File-like that keeps a certain number of lines of text in memory.""" + """File-like that keeps a certain number of lines of text in memory for writing out to the + crash log. """ + def write(self, buffer): - """ Write line to buffer """ + """ Splits lines from the incoming buffer and writes them out to the rolling buffer. + + Parameters + ---------- + buffer: str + The log messages to write to the rolling buffer + """ for line in buffer.rstrip().splitlines(): self.append(line + "\n") class TqdmHandler(logging.StreamHandler): - """ Use TQDM Write for outputting to console """ + """ Overrides :class:`logging.StreamHandler` to use :func:`tqdm.tqdm.write` rather than writing + to :func:`sys.stderr` so that log messages do not mess up tqdm progress bars. """ + def emit(self, record): + """ Format the incoming message and pass to :func:`tqdm.tqdm.write`. + + Parameters + ---------- + record : :class:`logging.LogRecord` + The incoming log record to be formatted for entry into the logger. + """ msg = self.format(record) tqdm.write(msg) -def set_root_logger(loglevel=logging.INFO): - """ Setup the root logger. """ +def _set_root_logger(loglevel=logging.INFO): + """ Setup the root logger. + + Parameters + ---------- + loglevel: int, optional + The log level to set the root logger to. Default :attr:`logging.INFO` + + Returns + ------- + :class:`logging.Logger` + The root logger for Faceswap + """ rootlogger = logging.getLogger() rootlogger.setLevel(loglevel) return rootlogger -def log_setup(loglevel, logfile, command, is_gui=False): - """ initial log set up. """ +def log_setup(loglevel, log_file, command, is_gui=False): + """ Set up logging for Faceswap. + + Sets up the root logger, the formatting for the crash logger and the file logger, and sets up + the crash, file and stream log handlers. + + Parameters + ---------- + loglevel: str + The requested log level that Faceswap should be run at. + log_file: str + The location of the log file to write Faceswap's log to + command: str + The Faceswap command that is being run. Used to dictate whether the log file should + have "_gui" appended to the filename or not. + is_gui: bool, optional + Whether Faceswap is running in the GUI or not. Dictates where the stream handler should + output messages to. Default: ``False`` + """ numeric_loglevel = get_loglevel(loglevel) root_loglevel = min(logging.DEBUG, numeric_loglevel) - rootlogger = set_root_logger(loglevel=root_loglevel) + rootlogger = _set_root_logger(loglevel=root_loglevel) log_format = FaceswapFormatter("%(asctime)s %(processName)-15s %(threadName)-15s " "%(module)-15s %(funcName)-25s %(levelname)-8s %(message)s", datefmt="%m/%d/%Y %H:%M:%S") - f_handler = file_handler(numeric_loglevel, logfile, log_format, command) - s_handler = stream_handler(numeric_loglevel, is_gui) - c_handler = crash_handler(log_format) + f_handler = _file_handler(numeric_loglevel, log_file, log_format, command) + s_handler = _stream_handler(numeric_loglevel, is_gui) + c_handler = _crash_handler(log_format) rootlogger.addHandler(f_handler) rootlogger.addHandler(s_handler) rootlogger.addHandler(c_handler) logging.info("Log level set to: %s", loglevel.upper()) -def file_handler(loglevel, logfile, log_format, command): - """ Add a logging rotating file handler """ - if logfile is not None: - filename = logfile +def _file_handler(loglevel, log_file, log_format, command): + """ Add a rotating file handler for the current Faceswap session. 1 backup is always kept. + + Parameters + ---------- + loglevel: str + The requested log level that messages should be logged at. + log_file: str + The location of the log file to write Faceswap's log to + log_format: :class:`FaceswapFormatter: + The formatting to store log messages as + command: str + The Faceswap command that is being run. Used to dictate whether the log file should + have "_gui" appended to the filename or not. + + Returns + ------- + :class:`logging.RotatingFileHandler` + The logging file handler + """ + if log_file is not None: + filename = log_file else: filename = os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), "faceswap") - # Windows has issues sharing the log file with subprocesses, so log GUI separately + # Windows has issues sharing the log file with sub-processes, so log GUI separately filename += "_gui.log" if command == "gui" else ".log" should_rotate = os.path.isfile(filename) @@ -132,8 +237,23 @@ def file_handler(loglevel, logfile, log_format, command): return log_file -def stream_handler(loglevel, is_gui): - """ Add a logging cli handler """ +def _stream_handler(loglevel, is_gui): + """ Add a stream handler for the current Faceswap session. The stream handler will only ever + output at a maximum of VERBOSE level to avoid spamming the console. + + Parameters + ---------- + loglevel: str + The requested log level that messages should be logged at. + is_gui: bool, optional + Whether Faceswap is running in the GUI or not. Dictates where the stream handler should + output messages to. + + Returns + ------- + :class:`TqdmHandler` or :class:`logging.StreamHandler` + The stream handler to use + """ # Don't set stdout to lower than verbose loglevel = max(loglevel, 15) log_format = FaceswapFormatter("%(asctime)s %(levelname)-8s %(message)s", @@ -150,30 +270,59 @@ def stream_handler(loglevel, is_gui): return log_console -def crash_handler(log_format): - """ Add a handler that sores the last 100 debug lines to 'debug_buffer' - for use in crash reports """ - log_crash = logging.StreamHandler(debug_buffer) +def _crash_handler(log_format): + """ Add a handler that stores the last 100 debug lines to :attr:'_debug_buffer' for use in + crash reports. + + Parameters + ---------- + log_format: :class:`FaceswapFormatter: + The formatting to store log messages as + + Returns + ------- + :class:`logging.StreamHandler` + The crash log handler + """ + log_crash = logging.StreamHandler(_debug_buffer) log_crash.setFormatter(log_format) log_crash.setLevel(logging.DEBUG) return log_crash def get_loglevel(loglevel): - """ Check valid log level supplied and return numeric log level """ + """ Check whether a valid log level has been supplied, and return the numeric log level that + corresponds to the given string level. + + Parameters + ---------- + loglevel: str + The loglevel that has been requested + + Returns + ------- + int + The numeric representation of the given loglevel + """ numeric_level = getattr(logging, loglevel.upper(), None) if not isinstance(numeric_level, int): raise ValueError("Invalid log level: %s" % loglevel) - return numeric_level def crash_log(): - """ Write debug_buffer to a crash log on crash """ + """ On a crash, write out the contents of :func:`_debug_buffer` containing the last 100 lines + of debug messages to a crash report in the root Faceswap folder. + + Returns + ------- + str + The filename of the file that contains the crash report + """ original_traceback = traceback.format_exc() path = os.path.dirname(os.path.realpath(sys.argv[0])) filename = os.path.join(path, datetime.now().strftime("crash_report.%Y.%m.%d.%H%M%S%f.log")) - freeze_log = list(debug_buffer) + freeze_log = list(_debug_buffer) try: from lib.sysinfo import sysinfo # pylint:disable=import-outside-toplevel except Exception: # pylint:disable=broad-except @@ -186,20 +335,21 @@ def crash_log(): return filename -old_factory = logging.getLogRecordFactory() # pylint: disable=invalid-name +_old_factory = logging.getLogRecordFactory() -def faceswap_logrecord(*args, **kwargs): - """ Add a flag to logging.LogRecord to not strip formatting from particular records """ - record = old_factory(*args, **kwargs) +def _faceswap_logrecord(*args, **kwargs): + """ Add a flag to :class:`logging.LogRecord` to not strip formatting from particular + records. """ + record = _old_factory(*args, **kwargs) record.strip_spaces = True return record -logging.setLogRecordFactory(faceswap_logrecord) +logging.setLogRecordFactory(_faceswap_logrecord) # Set logger class to custom logger logging.setLoggerClass(FaceswapLogger) # Stores the last 100 debug messages -debug_buffer = RollingBuffer(maxlen=100) # pylint: disable=invalid-name +_debug_buffer = RollingBuffer(maxlen=100) diff --git a/lib/model/__init__.py b/lib/model/__init__.py index e69de29bb2..ef4d5036c8 100644 --- a/lib/model/__init__.py +++ b/lib/model/__init__.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +""" Conditional imports depending on whether the AMD version is installed or not """ + +from lib.utils import get_backend + +if get_backend() == "amd": + from . import losses_plaid as losses +else: + from . import losses_tf as losses diff --git a/lib/model/backup_restore.py b/lib/model/backup_restore.py index 4a3261c279..6026228663 100644 --- a/lib/model/backup_restore.py +++ b/lib/model/backup_restore.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -""" Functions for backing up, restoring and snapshotting models """ +""" Functions for backing up, restoring and creating model snapshots. """ import logging import os @@ -14,8 +14,16 @@ class Backup(): - """ Holds information about model location and functions for backing up - Restoring and Snapshotting models """ + """ Performs the back up of models at each save iteration, and the restoring of models from + their back up location. + + Parameters + ---------- + model_dir: str + The folder that contains the model to be backed up + model_name: str + The name of the model that is to be backed up + """ def __init__(self, model_dir, model_name): logger.debug("Initializing %s: (model_dir: '%s', model_name: '%s')", self.__class__.__name__, model_dir, model_name) @@ -23,8 +31,22 @@ def __init__(self, model_dir, model_name): self.model_name = model_name logger.debug("Initialized %s", self.__class__.__name__) - def check_valid(self, filename, for_restore=False): - """ Check if the passed in filename is valid for a backup operation """ + def _check_valid(self, filename, for_restore=False): + """ Check if the passed in filename is valid for a backup or restore operation. + + Parameters + ---------- + filename: str + The filename that is to be checked for backup or restore + for_restore: bool, optional + ``True`` if the checks are to be performed for restoring a model, ``False`` if the + checks are to be performed for backing up a model. Default: ``False`` + + Returns + ------- + bool + ``True`` if the given file is valid for a backup/restore operation otherwise ``False`` + """ fullpath = os.path.join(self.model_dir, filename) if not filename.startswith(self.model_name): # Any filename that does not start with the model name are invalid @@ -45,19 +67,38 @@ def check_valid(self, filename, for_restore=False): return retval @staticmethod - def backup_model(fullpath): - """ Backup Model File - Fullpath should be the path to an h5.py file or a state.json file """ - backupfile = fullpath + ".bk" - logger.verbose("Backing up: '%s' to '%s'", fullpath, backupfile) + def backup_model(full_path): + """ Backup a model file. + + The backed up file is saved with the original filename in the original location with `.bk` + appended to the end of the name. + + Parameters + ---------- + full_path: str + The full path to a `.h5` model file or a `.json` state file + """ + backupfile = full_path + ".bk" if os.path.exists(backupfile): os.remove(backupfile) - if os.path.exists(fullpath): - os.rename(fullpath, backupfile) + if os.path.exists(full_path): + logger.verbose("Backing up: '%s' to '%s'", full_path, backupfile) + os.rename(full_path, backupfile) def snapshot_models(self, iterations): - """ Take a snapshot of the model at current state and back up """ - logger.info("Saving snapshot") + """ Take a snapshot of the model at the current state and back it up. + + The snapshot is a copy of the model folder located in the same root location + as the original model file, with the number of iterations appended to the end + of the folder name. + + Parameters + ---------- + iterations: int + The number of iterations that the model has trained when performing the snapshot. + """ + print("") # New line so log message doesn't append to last loss output + logger.verbose("Saving snapshot") snapshot_dir = "{}_snapshot_{}_iters".format(self.model_dir, iterations) if os.path.isdir(snapshot_dir): @@ -66,7 +107,7 @@ def snapshot_models(self, iterations): dst = str(get_folder(snapshot_dir)) for filename in os.listdir(self.model_dir): - if not self.check_valid(filename, for_restore=False): + if not self._check_valid(filename, for_restore=False): logger.debug("Not snapshotting file: '%s'", filename) continue srcfile = os.path.join(self.model_dir, filename) @@ -74,26 +115,33 @@ def snapshot_models(self, iterations): copyfunc = copytree if os.path.isdir(srcfile) else copyfile logger.debug("Saving snapshot: '%s' > '%s'", srcfile, dstfile) copyfunc(srcfile, dstfile) - logger.info("Saved snapshot") + logger.info("Saved snapshot (%s iterations)", iterations) def restore(self): """ Restores a model from backup. - This will place all existing models/logs into a folder named: - - "_archived_" - Copy all .bk files to replace original files - Remove logs from after the restore session_id from the logs folder """ - archive_dir = self.move_archived() - self.restore_files() - self.restore_logs(archive_dir) - - def move_archived(self): - """ Move archived files to archived folder and return archived folder name """ + + The original model files are migrated into a folder within the original model folder + named `_archived_`. The `.bk` backup files are then moved to + the location of the previously existing model files. Logs that were generated after the + the last backup was taken are removed. """ + archive_dir = self._move_archived() + self._restore_files() + self._restore_logs(archive_dir) + + def _move_archived(self): + """ Move archived files to the archived folder. + + Returns + ------- + str + The name of the generated archive folder + """ logger.info("Archiving existing model files...") now = datetime.now().strftime("%Y%m%d_%H%M%S") archive_dir = os.path.join(self.model_dir, "{}_archived_{}".format(self.model_name, now)) os.mkdir(archive_dir) for filename in os.listdir(self.model_dir): - if not self.check_valid(filename, for_restore=False): + if not self._check_valid(filename, for_restore=False): logger.debug("Not moving file to archived: '%s'", filename) continue logger.verbose("Moving '%s' to archived model folder: '%s'", filename, archive_dir) @@ -103,11 +151,11 @@ def move_archived(self): logger.verbose("Archived existing model files") return archive_dir - def restore_files(self): + def _restore_files(self): """ Restore files from .bk """ logger.info("Restoring models from backup...") for filename in os.listdir(self.model_dir): - if not self.check_valid(filename, for_restore=True): + if not self._check_valid(filename, for_restore=True): logger.debug("Not restoring file: '%s'", filename) continue dstfile = os.path.splitext(filename)[0] @@ -117,11 +165,17 @@ def restore_files(self): copyfile(src, dst) logger.verbose("Restored models from backup") - def restore_logs(self, archive_dir): - """ Restore the log files since before archive """ + def _restore_logs(self, archive_dir): + """ Restores the log files up to and including the last backup. + + Parameters + ---------- + archive_dir: str + The full path to the model's archive folder + """ logger.info("Restoring Logs...") - session_names = self.get_session_names() - log_dirs = self.get_log_dirs(archive_dir, session_names) + session_names = self._get_session_names() + log_dirs = self._get_log_dirs(archive_dir, session_names) for log_dir in log_dirs: src = os.path.join(archive_dir, log_dir) dst = os.path.join(self.model_dir, log_dir) @@ -129,8 +183,8 @@ def restore_logs(self, archive_dir): copytree(src, dst) logger.verbose("Restored Logs") - def get_session_names(self): - """ Get the existing session names from state file """ + def _get_session_names(self): + """ Get the existing session names from a state file. """ serializer = get_serializer("json") state_file = os.path.join(self.model_dir, "{}_state.{}".format(self.model_name, serializer.file_extension)) @@ -140,8 +194,21 @@ def get_session_names(self): logger.debug("Session to restore: %s", session_names) return session_names - def get_log_dirs(self, archive_dir, session_names): - """ Get the session logdir paths in the archive folder """ + def _get_log_dirs(self, archive_dir, session_names): + """ Get the session log directory paths in the archive folder. + + Parameters + ---------- + archive_dir: str + The full path to the model's archive folder + session_names: list + The name of the training sessions that exist for the model + + Returns + ------- + list + The full paths to the log folders + """ archive_logs = os.path.join(archive_dir, "{}_logs".format(self.model_name)) paths = [os.path.join(dirpath.replace(archive_dir, "")[1:], folder) for dirpath, dirnames, _ in os.walk(archive_logs) diff --git a/lib/model/initializers.py b/lib/model/initializers.py index 7d4f28b93d..c436342284 100644 --- a/lib/model/initializers.py +++ b/lib/model/initializers.py @@ -9,13 +9,61 @@ import tensorflow as tf from keras import backend as K from keras import initializers -from keras.utils.generic_utils import get_custom_objects +from keras.utils import get_custom_objects from lib.utils import get_backend logger = logging.getLogger(__name__) # pylint: disable=invalid-name +def compute_fans(shape, data_format='channels_last'): + """Computes the number of input and output units for a weight shape. + + Ported directly from Keras as the location moves between keras and tensorflow-keras + + Parameters + ---------- + shape: tuple + shape tuple of integers + data_format: str + Image data format to use for convolution kernels. Note that all kernels in Keras are + standardized on the `"channels_last"` ordering (even when inputs are set to + `"channels_first"`). + + Returns + ------- + tuple + A tuple of scalars, `(fan_in, fan_out)`. + + Raises + ------ + ValueError + In case of invalid `data_format` argument. + """ + if len(shape) == 2: + fan_in = shape[0] + fan_out = shape[1] + elif len(shape) in {3, 4, 5}: + # Assuming convolution kernels (1D, 2D or 3D). + # Theano kernel shape: (depth, input_depth, ...) + # Tensorflow kernel shape: (..., input_depth, depth) + if data_format == 'channels_first': + receptive_field_size = np.prod(shape[2:]) + fan_in = shape[1] * receptive_field_size + fan_out = shape[0] * receptive_field_size + elif data_format == 'channels_last': + receptive_field_size = np.prod(shape[:-2]) + fan_in = shape[-2] * receptive_field_size + fan_out = shape[-1] * receptive_field_size + else: + raise ValueError('Invalid data_format: ' + data_format) + else: + # No specific assumptions. + fan_in = np.sqrt(np.prod(shape)) + fan_out = np.sqrt(np.prod(shape)) + return fan_in, fan_out + + class ICNR(initializers.Initializer): # pylint: disable=invalid-name """ ICNR initializer for checkerboard artifact free sub pixel convolution @@ -23,8 +71,9 @@ class ICNR(initializers.Initializer): # pylint: disable=invalid-name ---------- initializer: :class:`keras.initializers.Initializer` The initializer used for sub kernels (orthogonal, glorot uniform, etc.) - scale: int - scaling factor of sub pixel convolution (up sampling from 8x8 to 16x16 is scale 2) + scale: int, optional + scaling factor of sub pixel convolution (up sampling from 8x8 to 16x16 is scale 2). + Default: `2` Returns ------- @@ -68,42 +117,16 @@ def __call__(self, shape, dtype="float32"): self.initializer = initializers.deserialize(self.initializer) var_x = self.initializer(new_shape, dtype) var_x = K.permute_dimensions(var_x, [2, 0, 1, 3]) - var_x = self._resize_nearest_neighbour(var_x, - (shape[0] * self.scale, shape[1] * self.scale)) + var_x = K.resize_images(var_x, + self.scale, + self.scale, + "channels_last", + interpolation="nearest") var_x = self._space_to_depth(var_x) var_x = K.permute_dimensions(var_x, [1, 2, 0, 3]) - logger.debug("Output: %s", var_x) + logger.debug("Output shape: %s", var_x.shape) return var_x - def _resize_nearest_neighbour(self, input_tensor, size): - """ Resize a tensor using nearest neighbor interpolation. - - Notes - ----- - Tensorflow has a bug that resizes the image incorrectly if :attr:`align_corners` is not set - to ``True``. Keras Backend does not set this flag, so we explicitly call the Tensorflow - operation for non-amd backends. - - Parameters - ---------- - input_tensor: tensor - The tensor to be resized - tuple: int - The (`h`, `w`) that the tensor should be resized to (used for non-amd backends only) - - Returns - ------- - tensor - The input tensor resized to the given size - """ - if get_backend() == "amd": - retval = K.resize_images(input_tensor, self.scale, self.scale, "channels_last", - interpolation="nearest") - else: - retval = tf.image.resize_nearest_neighbor(input_tensor, size=size, align_corners=True) - logger.debug("Input Tensor: %s, Output Tensor: %s", input_tensor, retval) - return retval - def _space_to_depth(self, input_tensor): """ Space to depth implementation. @@ -129,8 +152,8 @@ def _space_to_depth(self, input_tensor): retval = K.reshape(K.permute_dimensions(reshaped, [0, 1, 3, 2, 4, 5]), (batch, new_height, new_width, -1)) else: - retval = tf.space_to_depth(input_tensor, block_size=self.scale, data_format="NHWC") - logger.debug("Input Tensor: %s, Output Tensor: %s", input_tensor, retval) + retval = tf.nn.space_to_depth(input_tensor, block_size=self.scale, data_format="NHWC") + logger.debug("Input shape: %s, Output shape: %s", input_tensor.shape, retval.shape) return retval def get_config(self): @@ -158,11 +181,15 @@ class ConvolutionAware(initializers.Initializer): Parameters ---------- - eps_std: float + eps_std: float, optional The Standard deviation for the random normal noise used to break symmetry in the inverse - Fourier transform. + Fourier transform. Default: 0.05 seed: int, optional Used to seed the random generator. Default: ``None`` + initialized: bool, optional + This should always be set to ``False``. To avoid Keras re-calculating the values every time + the model is loaded, this parameter is internally set on first time initialization. + Default:``False`` Returns ------- @@ -172,20 +199,14 @@ class ConvolutionAware(initializers.Initializer): References ---------- Armen Aghajanyan, https://arxiv.org/abs/1702.06295 - - Notes - ----- - Convolutional Aware Initialization takes a long time. Keras model loading loads a model, - performs initialization and then loads weights, which is an unnecessary waste of time. - init defaults to False so that this is bypassed when loading a saved model passing zeros. """ - def __init__(self, eps_std=0.05, seed=None, init=False): - self._init = init + def __init__(self, eps_std=0.05, seed=None, initialized=False): self.eps_std = eps_std self.seed = seed self.orthogonal = initializers.Orthogonal() self.he_uniform = initializers.he_uniform() + self.initialized = initialized def __call__(self, shape, dtype=None): """ Call function for the ICNR initializer. @@ -202,20 +223,18 @@ def __call__(self, shape, dtype=None): tensor The modified kernel weights """ - dtype = K.floatx() if dtype is None else dtype - if self._init: - logger.info("Calculating Convolution Aware Initializer for shape: %s", shape) - else: - logger.debug("Bypassing Convolutional Aware Initializer for saved model") - # Dummy in he_uniform just in case there aren't any weighs being loaded - # and it needs some kind of initialization + # TODO Tensorflow appears to pass in a :class:`tensorflow.python.framework.dtypes.DType` + # object which causes this to error, so currently just reverts to default dtype if a string + # is not passed in. + if self.initialized: # Avoid re-calculating initializer when loading a saved model return self.he_uniform(shape, dtype=dtype) - + dtype = K.floatx() if not isinstance(dtype, str) else dtype + logger.info("Calculating Convolution Aware Initializer for shape: %s", shape) rank = len(shape) if self.seed is not None: np.random.seed(self.seed) - fan_in, _ = initializers._compute_fans(shape) # pylint:disable=protected-access + fan_in, _ = compute_fans(shape) # pylint:disable=protected-access variance = 2 / fan_in if rank == 3: @@ -243,6 +262,7 @@ def __call__(self, shape, dtype=None): correct_ifft = np.fft.irfftn else: + self.initialized = True return K.variable(self.orthogonal(shape), dtype=dtype) kernel_fourier_shape = correct_fft(np.zeros(kernel_shape)).shape @@ -252,10 +272,13 @@ def __call__(self, shape, dtype=None): randoms = np.random.normal(0, self.eps_std, basis.shape[:-2] + kernel_shape) init = correct_ifft(basis, kernel_shape) + randoms init = self._scale_filters(init, variance) + self.initialized = True return K.variable(init.transpose(transpose_dimensions), dtype=dtype, name="conv_aware") def _create_basis(self, filters_size, filters, size, dtype): """ Create the basis for convolutional aware initialization """ + logger.debug("filters_size: %s, filters: %s, size: %s, dtype: %s", + filters_size, filters, size, dtype) if size == 1: return np.random.normal(0.0, self.eps_std, (filters_size, filters, size)) nbb = filters // size + 1 @@ -288,10 +311,9 @@ def get_config(self): dict The configuration for ICNR Initialization """ - return { - "eps_std": self.eps_std, - "seed": self.seed - } + return dict(eps_std=self.eps_std, + seed=self.seed, + initialized=self.initialized) # Update initializers into Keras custom objects diff --git a/lib/model/layers.py b/lib/model/layers.py index 206b4ea857..574414d764 100644 --- a/lib/model/layers.py +++ b/lib/model/layers.py @@ -9,17 +9,17 @@ import tensorflow as tf import keras.backend as K -from keras.engine import InputSpec, Layer -from keras.utils import conv_utils -from keras.utils.generic_utils import get_custom_objects -from keras.layers.pooling import _GlobalPooling2D +from keras.layers import InputSpec, Layer +from keras.utils import get_custom_objects from lib.utils import get_backend if get_backend() == "amd": from lib.plaidml_utils import pad + from keras.utils import conv_utils # pylint:disable=ungrouped-imports else: from tensorflow import pad + from tensorflow.python.keras.utils import conv_utils class PixelShuffler(Layer): @@ -63,10 +63,13 @@ class PixelShuffler(Layer): """ def __init__(self, size=(2, 2), data_format=None, **kwargs): super().__init__(**kwargs) - self.data_format = K.normalize_data_format(data_format) - self.size = conv_utils.normalize_tuple(size, 2, "size") + if get_backend() == "amd": + self.data_format = K.normalize_data_format(data_format) + else: + self.data_format = conv_utils.normalize_data_format(data_format) + self.size = conv_utils.normalize_tuple(size, 2, 'size') - def call(self, inputs, **kwargs): + def call(self, inputs, **kwargs): # pylint:disable=unused-argument """This is where the layer's logic lives. Parameters @@ -74,7 +77,7 @@ def call(self, inputs, **kwargs): inputs: tensor Input tensor, or list/tuple of input tensors kwargs: dict - Additional keyword arguments + Additional keyword arguments. Unused Returns ------- @@ -237,7 +240,10 @@ def __init__(self, scale_factor=2, data_format=None, **kwargs): super(SubPixelUpscaling, self).__init__(**kwargs) self.scale_factor = scale_factor - self.data_format = K.normalize_data_format(data_format) + if get_backend() == "amd": + self.data_format = K.normalize_data_format(data_format) + else: + self.data_format = conv_utils.normalize_data_format(data_format) def build(self, input_shape): """Creates the layer weights. @@ -250,9 +256,9 @@ def build(self, input_shape): Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to reference for weight shape computations. """ - pass + pass # pylint: disable=unnecessary-pass - def call(self, input_tensor, mask=None): # pylint:disable=unused-argument,arguments-differ + def call(self, inputs, **kwargs): # pylint:disable=unused-argument """This is where the layer's logic lives. Parameters @@ -260,14 +266,14 @@ def call(self, input_tensor, mask=None): # pylint:disable=unused-argument,argum inputs: tensor Input tensor, or list/tuple of input tensors kwargs: dict - Additional keyword arguments + Additional keyword arguments. Unused Returns ------- tensor A tensor or list/tuple of tensors """ - retval = self._depth_to_space(input_tensor, self.scale_factor, self.data_format) + retval = self._depth_to_space(inputs, self.scale_factor, self.data_format) return retval def compute_output_shape(self, input_shape): @@ -305,17 +311,17 @@ def _depth_to_space(cls, ipt, scale, data_format=None): data_format = K.image_data_format() data_format = data_format.lower() ipt = cls._preprocess_conv2d_input(ipt, data_format) - out = tf.depth_to_space(ipt, scale) + out = tf.nn.depth_to_space(ipt, scale) out = cls._postprocess_conv2d_output(out, data_format) return out @staticmethod - def _postprocess_conv2d_output(input_tensor, data_format): + def _postprocess_conv2d_output(inputs, data_format): """Transpose and cast the output from conv2d if needed. Parameters ---------- - input_tensor: tensor + inputs: tensor The input that requires transposing and casting data_format: str `"channels_last"` or `"channels_first"` @@ -327,19 +333,19 @@ def _postprocess_conv2d_output(input_tensor, data_format): """ if data_format == "channels_first": - input_tensor = tf.transpose(input_tensor, (0, 3, 1, 2)) + inputs = tf.transpose(inputs, (0, 3, 1, 2)) if K.floatx() == "float64": - input_tensor = tf.cast(input_tensor, "float64") - return input_tensor + inputs = tf.cast(inputs, "float64") + return inputs @staticmethod - def _preprocess_conv2d_input(input_tensor, data_format): + def _preprocess_conv2d_input(inputs, data_format): """Transpose and cast the input before the conv2d. Parameters ---------- - input_tensor: tensor + inputs: tensor The input that requires transposing and casting data_format: str `"channels_last"` or `"channels_first"` @@ -349,14 +355,14 @@ def _preprocess_conv2d_input(input_tensor, data_format): tensor The transposed and cast input tensor """ - if K.dtype(input_tensor) == "float64": - input_tensor = tf.cast(input_tensor, "float32") + if K.dtype(inputs) == "float64": + inputs = tf.cast(inputs, "float32") if data_format == "channels_first": # Tensorflow uses the last dimension as channel dimension, instead of the 2nd one. # Theano input shape: (samples, input_depth, rows, cols) # Tensorflow input shape: (samples, rows, cols, input_depth) - input_tensor = tf.transpose(input_tensor, (0, 2, 3, 1)) - return input_tensor + inputs = tf.transpose(inputs, (0, 2, 3, 1)) + return inputs def get_config(self): """Returns the config of the layer. @@ -394,8 +400,12 @@ class ReflectionPadding2D(Layer): The standard Keras Layer keyword arguments (if any) """ def __init__(self, stride=2, kernel_size=5, **kwargs): + if isinstance(stride, (tuple, list)): + assert len(stride) == 2 and stride[0] == stride[1] + stride = stride[0] self.stride = stride self.kernel_size = kernel_size + self.input_spec = None super().__init__(**kwargs) def build(self, input_shape): @@ -446,7 +456,7 @@ def compute_output_shape(self, input_shape): input_shape[2] + padding_width, input_shape[3]) - def call(self, x, mask=None): # pylint:disable=unused-argument,arguments-differ + def call(self, var_x, mask=None): # pylint:disable=unused-argument,arguments-differ """This is where the layer's logic lives. Parameters @@ -479,7 +489,7 @@ def call(self, x, mask=None): # pylint:disable=unused-argument,arguments-differ padding_left = padding_width // 2 padding_right = padding_width - padding_left - return pad(x, + return pad(var_x, [[0, 0], [padding_top, padding_bot], [padding_left, padding_right], @@ -507,10 +517,54 @@ class name. These are handled by `Network` (one layer of abstraction above). return dict(list(base_config.items()) + list(config.items())) +class _GlobalPooling2D(Layer): + """Abstract class for different global pooling 2D layers. + + From keras as access to pooling is trickier in tensorflow.keras + """ + def __init__(self, data_format=None, **kwargs): + super(_GlobalPooling2D, self).__init__(**kwargs) + if get_backend() == "amd": + self.data_format = K.normalize_data_format(data_format) + else: + self.data_format = conv_utils.normalize_data_format(data_format) + self.input_spec = InputSpec(ndim=4) + + def compute_output_shape(self, input_shape): + """ Compute the output shape based on the input shape. + + Parameters + ---------- + input_shape: tuple + The input shape to the layer + """ + if self.data_format == 'channels_last': + return (input_shape[0], input_shape[3]) + return (input_shape[0], input_shape[1]) + + def call(self, inputs, **kwargs): + """ Override to call the layer. + + Parameters + ---------- + inputs: Tensor + The input to the layer + kwargs: dict + Additional keyword arguments + """ + raise NotImplementedError + + def get_config(self): + """ Set the Keras config """ + config = {'data_format': self.data_format} + base_config = super(_GlobalPooling2D, self).get_config() + return dict(list(base_config.items()) + list(config.items())) + + class GlobalMinPooling2D(_GlobalPooling2D): """Global minimum pooling operation for spatial data. """ - def call(self, inputs): + def call(self, inputs, **kwargs): """This is where the layer's logic lives. Parameters @@ -535,7 +589,7 @@ def call(self, inputs): class GlobalStdDevPooling2D(_GlobalPooling2D): """Global standard deviation pooling operation for spatial data. """ - def call(self, inputs): + def call(self, inputs, **kwargs): """This is where the layer's logic lives. Parameters @@ -557,7 +611,7 @@ def call(self, inputs): return pooled -class L2_normalize(Layer): # Pylint:disable=invalid-name +class L2_normalize(Layer): # pylint:disable=invalid-name """ Normalizes a tensor w.r.t. the L2 norm alongside the specified axis. Parameters diff --git a/lib/model/losses.py b/lib/model/losses_plaid.py similarity index 53% rename from lib/model/losses.py rename to lib/model/losses_plaid.py index 706e6f7546..621589aa02 100644 --- a/lib/model/losses.py +++ b/lib/model/losses_plaid.py @@ -5,40 +5,17 @@ import logging -import keras.backend as K +from keras import backend as K + import numpy as np import tensorflow as tf -from lib.utils import get_backend - -if get_backend() == "amd": - from plaidml.op import extract_image_patches - from lib.plaidml_utils import pad -else: - from tensorflow import extract_image_patches # pylint: disable=ungrouped-imports - from tensorflow import pad +from plaidml.op import extract_image_patches -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +from lib.plaidml_utils import pad +from lib.utils import FaceswapError - -def mask_loss_wrapper(loss_func, preprocessing_func=None): - """ A wrapper for mask loss that can perform pre-processing on the input prior to calling the - loss function. - - Parameters - ---------- - loss_func: class or function - The actual loss function to use - preprocessing_func: function - The pre-processing function to use. Should take a Keras Input as it's only argument - """ - def func(y_true, y_pred): - """ Process input if a processing function has been passed, otherwise just return loss """ - if preprocessing_func is not None: - y_true = K.reshape(y_true, [-1] + list(K.int_shape(y_pred)[1:])) - y_true = preprocessing_func(y_true) - return loss_func(y_true, y_pred) - return func +logger = logging.getLogger(__name__) # pylint:disable=invalid-name class DSSIMObjective(): @@ -96,7 +73,6 @@ def __init__(self, k_1=0.01, k_2=0.03, kernel_size=3, max_value=1.0): self.c_1 = (self.k_1 * self.max_value) ** 2 self.c_2 = (self.k_2 * self.max_value) ** 2 self.dim_ordering = K.image_data_format() - self.backend = K.backend() @staticmethod def __int_shape(input_tensor): @@ -225,152 +201,140 @@ def extract_image_patches(self, input_tensor, k_sizes, s_sizes, return patches -# <<< START: from Dfaker >>> # -def PenalizedLoss(mask, loss_func, # pylint: disable=invalid-name - mask_prop=1.0, mask_scaling=1.0, preprocessing_func=None): - """ Plaidml and Tensorflow Penalized Loss function. +class PenalizedLoss(): # pylint:disable=too-few-public-methods + """ Penalized Loss function. Applies the given loss function just to the masked area of the image. Parameters ---------- - mask: input tensor - The mask for the current image loss_func: function The actual loss function to use mask_prop: float, optional The amount of mask propagation. Default: `1.0` - mask_scaling: float, optional - For multi-decoder output the target mask will likely be at full size scaling, so this is - the scaling factor to reduce the mask by. Default: `1.0` - preprocessing_func: function, optional - If preprocessing is required on the input mask, then this should be the function to use. - The function should take a Keras Input as it's only argument. Set to ``None`` if no - preprocessing is to be performed. Default: ``None`` """ - def _scale_mask(mask, scaling): - """ Scale the input mask to be the same size as the input face + def __init__(self, loss_func, mask_prop=1.0): + self._loss_func = loss_func + self._mask_prop = mask_prop + + def __call__(self, y_true, y_pred): + """ Apply the loss function to the masked area of the image. Parameters ---------- - mask: input tensor - The mask for the current image - scaling: float - The amount to scale the input mask by + y_true: tensor or variable + The ground truth value. This should contain the mask in the 4th channel that will be + split off for penalizing. + y_pred: tensor or variable + The predicted value Returns ------- tensor - The resized input mask + The Loss value """ - if scaling != 1.0: - size = round(1 / scaling) - mask = K.pool2d(mask, - pool_size=(size, size), - strides=(size, size), - padding="valid", - data_format=K.image_data_format(), - pool_mode="avg") - logger.debug("resized tensor: %s", mask) - return mask - - mask = _scale_mask(mask, mask_scaling) - if preprocessing_func is not None: - mask = preprocessing_func(mask) - mask_as_k_inv_prop = 1 - mask_prop - mask = (mask * mask_prop) + mask_as_k_inv_prop + mask = self._prepare_mask(K.expand_dims(y_true[..., -1], axis=-1)) + y_true = y_true[..., :-1] + n_true = y_true * mask + n_pred = y_pred * mask + if isinstance(self._loss_func, DSSIMObjective): + # Extract Image Patches in SSIM requires that y_pred be of a known shape, so + # specifically reshape the tensor. + n_pred = K.reshape(n_pred, K.int_shape(y_pred)) + return self._loss_func(n_true, n_pred) + + def _prepare_mask(self, mask): + """ Prepare the masks for calculating loss - def _inner_loss(y_true, y_pred): - """ Apply the loss function to the masked area of the image. - - Parameters + Parameters ---------- - y_true: tensor or variable - The ground truth value - y_pred: tensor or variable - The predicted value + mask: :class:`numpy.ndarray` + The masks for the current batch Returns ------- tensor - The Loss value - - Notes - ----- - Branching because TensorFlow's broadcasting is wonky and plaidML's concatenate is - implemented inefficiently. + The prepared mask for applying to loss """ - if K.backend() == "plaidml.keras.backend": - n_true = y_true * mask - n_pred = y_pred * mask - else: - n_true = K.concatenate([y_true[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) - n_pred = K.concatenate([y_pred[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) - return loss_func(n_true, n_pred) - return _inner_loss -# <<< END: from Dfaker >>> # + mask_as_k_inv_prop = 1 - self._mask_prop + mask = (mask * self._mask_prop) + mask_as_k_inv_prop + return mask -def generalized_loss(y_true, y_pred, alpha=1.0, beta=1.0/255.0): - """ Generalized function used to return a large variety of mathematical loss functions. +class GeneralizedLoss(): # pylint:disable=too-few-public-methods + """ Generalized function used to return a large variety of mathematical loss functions. The primary benefit is a smooth, differentiable version of L1 loss. + References + ---------- + Barron, J. A More General Robust Loss Function - https://arxiv.org/pdf/1701.03077.pdf + + Example + ------- + >>> a=1.0, x>>c , c=1.0/255.0 # will give a smoothly differentiable version of L1 / MAE loss + >>> a=1.999999 (limit as a->2), beta=1.0/255.0 # will give L2 / RMSE loss + Parameters ---------- - y_true: tensor or variable - The ground truth value - y_pred: tensor or variable - The predicted value alpha: float, optional Penalty factor. Larger number give larger weight to large deviations. Default: `1.0` beta: float, optional Scale factor used to adjust to the input scale (i.e. inputs of mean `1e-4` or `256`). Default: `1.0/255.0` + """ + def __init__(self, alpha=1.0, beta=1.0/255.0): + self.alpha = alpha + self.beta = beta - Returns - ------- - tensor - The loss value from the results of function(y_pred - y_true) + def __call__(self, y_true, y_pred): + """ Call the Generalized Loss Function - References - ---------- - Barron, J. A More General Robust Loss Function - https://arxiv.org/pdf/1701.03077.pdf + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value - Example - ------- - >>> a=1.0, x>>c , c=1.0/255.0 # will give a smoothly differentiable version of L1 / MAE loss - >>> a=1.999999 (limit as a->2), beta=1.0/255.0 # will give L2 / RMSE loss - """ - diff = y_pred - y_true - second = (K.pow(K.pow(diff/beta, 2.) / K.abs(2.-alpha) + 1., (alpha/2.)) - 1.) - loss = (K.abs(2.-alpha)/alpha) * second - loss = K.mean(loss, axis=-1) * beta - return loss + Returns + ------- + tensor + The loss value from the results of function(y_pred - y_true) + """ + diff = y_pred - y_true + second = (K.pow(K.pow(diff/self.beta, 2.) / K.abs(2. - self.alpha) + 1., + (self.alpha / 2.)) - 1.) + loss = (K.abs(2. - self.alpha)/self.alpha) * second + loss = K.mean(loss, axis=-1) * self.beta + return loss -def l_inf_norm(y_true, y_pred): - """ Calculate the L-inf norm as a loss function. +class LInfNorm(): # pylint:disable=too-few-public-methods + """ Calculate the L-inf norm as a loss function. """ - Parameters - ---------- - y_true: tensor or variable - The ground truth value - y_pred: tensor or variable - The predicted value + def __call__(self, y_true, y_pred): + """ Call the L-inf norm loss function. - Returns - ------- - tensor - The loss value - """ - diff = K.abs(y_true - y_pred) - max_loss = K.max(diff, axis=(1, 2), keepdims=True) - loss = K.mean(max_loss, axis=-1) - return loss + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + + Returns + ------- + tensor + The loss value + """ + diff = K.abs(y_true - y_pred) + max_loss = K.max(diff, axis=(1, 2), keepdims=True) + loss = K.mean(max_loss, axis=-1) + return loss -def gradient_loss(y_true, y_pred): +class GradientLoss(): # pylint:disable=too-few-public-methods """ Gradient Loss Function. Calculates the first and second order gradient difference between pixels of an image in the x @@ -378,25 +342,44 @@ def gradient_loss(y_true, y_pred): image and the difference is taken. When used as a loss, its minimization will result in predicted images approaching the same level of sharpness / blurriness as the ground truth. - Parameters - ---------- - y_true: tensor or variable - The ground truth value - y_pred: tensor or variable - The predicted value - - Returns - ------- - tensor - The loss value - References ---------- TV+TV2 Regularization with Non-Convex Sparseness-Inducing Penalty for Image Restoration, Chengwu Lu & Hua Huang, 2014 - http://downloads.hindawi.com/journals/mpe/2014/790547.pdf """ + def __init__(self): + self.generalized_loss = GeneralizedLoss(alpha=1.9999) + + def __call__(self, y_true, y_pred): + """ Call the gradient loss function. - def _diff_x(img): + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + + Returns + ------- + tensor + The loss value + """ + tv_weight = 1.0 + tv2_weight = 1.0 + loss = 0.0 + loss += tv_weight * (self.generalized_loss(self._diff_x(y_true), self._diff_x(y_pred)) + + self.generalized_loss(self._diff_y(y_true), self._diff_y(y_pred))) + loss += tv2_weight * (self.generalized_loss(self._diff_xx(y_true), self._diff_xx(y_pred)) + + self.generalized_loss(self._diff_yy(y_true), self._diff_yy(y_pred)) + + self.generalized_loss(self._diff_xy(y_true), self._diff_xy(y_pred)) + * 2.) + loss = loss / (tv_weight + tv2_weight) + # TODO simplify to use MSE instead + return loss + + @classmethod + def _diff_x(cls, img): """ X Difference """ x_left = img[:, :, 1:2, :] - img[:, :, 0:1, :] x_inner = img[:, :, 2:, :] - img[:, :, :-2, :] @@ -404,7 +387,8 @@ def _diff_x(img): x_out = K.concatenate([x_left, x_inner, x_right], axis=2) return x_out * 0.5 - def _diff_y(img): + @classmethod + def _diff_y(cls, img): """ Y Difference """ y_top = img[:, 1:2, :, :] - img[:, 0:1, :, :] y_inner = img[:, 2:, :, :] - img[:, :-2, :, :] @@ -412,7 +396,8 @@ def _diff_y(img): y_out = K.concatenate([y_top, y_inner, y_bot], axis=1) return y_out * 0.5 - def _diff_xx(img): + @classmethod + def _diff_xx(cls, img): """ X-X Difference """ x_left = img[:, :, 1:2, :] + img[:, :, 0:1, :] x_inner = img[:, :, 2:, :] + img[:, :, :-2, :] @@ -420,7 +405,8 @@ def _diff_xx(img): x_out = K.concatenate([x_left, x_inner, x_right], axis=2) return x_out - 2.0 * img - def _diff_yy(img): + @classmethod + def _diff_yy(cls, img): """ Y-Y Difference """ y_top = img[:, 1:2, :, :] + img[:, 0:1, :, :] y_inner = img[:, 2:, :, :] + img[:, :-2, :, :] @@ -428,7 +414,8 @@ def _diff_yy(img): y_out = K.concatenate([y_top, y_inner, y_bot], axis=1) return y_out - 2.0 * img - def _diff_xy(img): + @classmethod + def _diff_xy(cls, img): """ X-Y Difference """ # xout1 top_left = img[:, 1:2, 1:2, :] + img[:, 0:1, 0:1, :] @@ -466,175 +453,112 @@ def _diff_xy(img): xy_out2 = K.concatenate([xy_left, xy_mid, xy_right], axis=2) return (xy_out1 - xy_out2) * 0.25 - tv_weight = 1.0 - tv2_weight = 1.0 - loss = 0.0 - loss += tv_weight * (generalized_loss(_diff_x(y_true), _diff_x(y_pred), alpha=1.9999) + - generalized_loss(_diff_y(y_true), _diff_y(y_pred), alpha=1.9999)) - loss += tv2_weight * (generalized_loss(_diff_xx(y_true), _diff_xx(y_pred), alpha=1.9999) + - generalized_loss(_diff_yy(y_true), _diff_yy(y_pred), alpha=1.9999) + - generalized_loss(_diff_xy(y_true), _diff_xy(y_pred), alpha=1.9999) * 2.) - loss = loss / (tv_weight + tv2_weight) - # TODO simplify to use MSE instead - return loss - - -def scharr_edges(image, magnitude): - """ Returns a tensor holding modified Scharr edge maps. - - Parameters - ---------- - image: tensor - Image tensor with shape [batch_size, h, w, d] and type float32. The image(s) must be 2x2 - or larger. - magnitude: bool - Boolean to determine if the edge magnitude or edge direction is returned - - Returns - ------- - tensor - Tensor holding edge maps for each channel. Returns a tensor with shape `[batch_size, h, w, - d, 2]` where the last two dimensions hold `[[dy[0], dx[0]], [dy[1], dx[1]], ..., [dy[d-1], - dx[d-1]]]` calculated using the Scharr filter. - """ - # Define vertical and horizontal Scharr filters. - static_image_shape = image.shape.dims if get_backend() == "amd" else image.get_shape() - image_shape = K.shape(image) - - # 5x5 modified Scharr kernel ( reshape to (5,5,1,2) ) - matrix = np.array([[[[0.00070, 0.00070]], - [[0.00520, 0.00370]], - [[0.03700, 0.00000]], - [[0.00520, -0.0037]], - [[0.00070, -0.0007]]], - [[[0.00370, 0.00520]], - [[0.11870, 0.11870]], - [[0.25890, 0.00000]], - [[0.11870, -0.1187]], - [[0.00370, -0.0052]]], - [[[0.00000, 0.03700]], - [[0.00000, 0.25890]], - [[0.00000, 0.00000]], - [[0.00000, -0.2589]], - [[0.00000, -0.0370]]], - [[[-0.0037, 0.00520]], - [[-0.1187, 0.11870]], - [[-0.2589, 0.00000]], - [[-0.1187, -0.1187]], - [[-0.0037, -0.0052]]], - [[[-0.0007, 0.00070]], - [[-0.0052, 0.00370]], - [[-0.0370, 0.00000]], - [[-0.0052, -0.0037]], - [[-0.0007, -0.0007]]]]) - num_kernels = [2] - kernels = K.constant(matrix, dtype='float32') - kernels = K.tile(kernels, [1, 1, image_shape[-1], 1]) - - # Use depth-wise convolution to calculate edge maps per channel. - # Output tensor has shape [batch_size, h, w, d * num_kernels]. - pad_sizes = [[0, 0], [2, 2], [2, 2], [0, 0]] - padded = pad(image, pad_sizes, mode='REFLECT') - output = K.depthwise_conv2d(padded, kernels) - - if not magnitude: # direction of edges - # Reshape to [batch_size, h, w, d, num_kernels]. - shape = K.concatenate([image_shape, num_kernels], axis=0) - output = K.reshape(output, shape=shape) - output.set_shape(static_image_shape.concatenate(num_kernels)) - output = tf.atan(K.squeeze(output[:, :, :, :, 0] / output[:, :, :, :, 1], axis=None)) - # magnitude of edges -- unified x & y edges don't work well with Neural Networks - return output - - -def gmsd_loss(y_true, y_pred): +class GMSDLoss(): # pylint:disable=too-few-public-methods """ Gradient Magnitude Similarity Deviation Loss. Improved image quality metric over MS-SSIM with easier calculations - Parameters - ---------- - y_true: tensor or variable - The ground truth value - y_pred: tensor or variable - The predicted value - - Returns - ------- - tensor - The loss value - References ---------- http://www4.comp.polyu.edu.hk/~cslzhang/IQA/GMSD/GMSD.htm https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf - """ - true_edge = scharr_edges(y_true, True) - pred_edge = scharr_edges(y_pred, True) - ephsilon = 0.0025 - upper = 2.0 * true_edge * pred_edge - lower = K.square(true_edge) + K.square(pred_edge) - gms = (upper + ephsilon) / (lower + ephsilon) - gmsd = K.std(gms, axis=(1, 2, 3), keepdims=True) - gmsd = K.squeeze(gmsd, axis=-1) - return gmsd - -# Gaussian Blur is here as it is only used for losses. -# It was previously kept in lib/model/masks but the import of keras backend -# breaks plaidml -def gaussian_blur(radius=2.0): - """ Apply gaussian blur to an input. - - Used for blurring mask in training. - - Parameters - ---------- - radius: float, optional - The kernel radius for applying gaussian blur. Default: `2.0` - - Returns - ------- - tensor - The input tensor with gaussian blurring applied - - References - ---------- - https://github.com/iperov/DeepFaceLab - """ - def _gaussian(var_x, radius, sigma): - """ Obtain the gaussian kernel. """ - return np.exp(-(float(var_x) - float(radius)) ** 2 / (2 * sigma ** 2)) - - def _make_kernel(sigma): - """ Make the gaussian kernel. """ - kernel_size = max(3, int(2 * 2 * sigma + 1)) - mean = np.floor(0.5 * kernel_size) - kernel_1d = np.array([_gaussian(x, mean, sigma) for x in range(kernel_size)]) - np_kernel = np.outer(kernel_1d, kernel_1d).astype(dtype=K.floatx()) - kernel = np_kernel / np.sum(np_kernel) - return kernel + def __call__(self, y_true, y_pred): + """ Return the Gradient Magnitude Similarity Deviation Loss. - gauss_kernel = _make_kernel(radius) - gauss_kernel = gauss_kernel[:, :, np.newaxis, np.newaxis] + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value - def func(input_tensor): - """ Apply gaussian blurring to the input tensor + Returns + ------- + tensor + The loss value + """ + raise FaceswapError("GMSD Loss is not currently compatible with PlaidML. Please select a " + "different Loss method.") + + true_edge = self._scharr_edges(y_true, True) + pred_edge = self._scharr_edges(y_pred, True) + ephsilon = 0.0025 + upper = 2.0 * true_edge * pred_edge + lower = K.square(true_edge) + K.square(pred_edge) + gms = (upper + ephsilon) / (lower + ephsilon) + gmsd = K.std(gms, axis=(1, 2, 3), keepdims=True) + gmsd = K.squeeze(gmsd, axis=-1) + return gmsd + + @classmethod + def _scharr_edges(cls, image, magnitude): + """ Returns a tensor holding modified Scharr edge maps. Parameters ---------- - input_tensor: tensor - The input to have gaussian blurring applied. + image: tensor + Image tensor with shape [batch_size, h, w, d] and type float32. The image(s) must be + 2x2 or larger. + magnitude: bool + Boolean to determine if the edge magnitude or edge direction is returned Returns ------- tensor - The input with gaussian blurring applied + Tensor holding edge maps for each channel. Returns a tensor with shape `[batch_size, h, + w, d, 2]` where the last two dimensions hold `[[dy[0], dx[0]], [dy[1], dx[1]], ..., + [dy[d-1], dx[d-1]]]` calculated using the Scharr filter. """ - inputs = [input_tensor[:, :, :, i:i + 1] for i in range(K.int_shape(input_tensor)[-1])] - outputs = [K.conv2d(inp, K.constant(gauss_kernel), strides=(1, 1), padding="same") - for inp in inputs] - return K.concatenate(outputs, axis=-1) - return func + + # Define vertical and horizontal Scharr filters. + # TODO PlaidML: AttributeError: 'Value' object has no attribute 'get_shape' + static_image_shape = image.get_shape() + image_shape = K.shape(image) + + # 5x5 modified Scharr kernel ( reshape to (5,5,1,2) ) + matrix = np.array([[[[0.00070, 0.00070]], + [[0.00520, 0.00370]], + [[0.03700, 0.00000]], + [[0.00520, -0.0037]], + [[0.00070, -0.0007]]], + [[[0.00370, 0.00520]], + [[0.11870, 0.11870]], + [[0.25890, 0.00000]], + [[0.11870, -0.1187]], + [[0.00370, -0.0052]]], + [[[0.00000, 0.03700]], + [[0.00000, 0.25890]], + [[0.00000, 0.00000]], + [[0.00000, -0.2589]], + [[0.00000, -0.0370]]], + [[[-0.0037, 0.00520]], + [[-0.1187, 0.11870]], + [[-0.2589, 0.00000]], + [[-0.1187, -0.1187]], + [[-0.0037, -0.0052]]], + [[[-0.0007, 0.00070]], + [[-0.0052, 0.00370]], + [[-0.0370, 0.00000]], + [[-0.0052, -0.0037]], + [[-0.0007, -0.0007]]]]) + num_kernels = [2] + kernels = K.constant(matrix, dtype='float32') + kernels = K.tile(kernels, [1, 1, image_shape[-1], 1]) + + # Use depth-wise convolution to calculate edge maps per channel. + # Output tensor has shape [batch_size, h, w, d * num_kernels]. + pad_sizes = [[0, 0], [2, 2], [2, 2], [0, 0]] + padded = pad(image, pad_sizes, mode='REFLECT') + output = K.depthwise_conv2d(padded, kernels) + + if not magnitude: # direction of edges + # Reshape to [batch_size, h, w, d, num_kernels]. + shape = K.concatenate([image_shape, num_kernels], axis=0) + output = K.reshape(output, shape=shape) + output.set_shape(static_image_shape.concatenate(num_kernels)) + output = tf.atan(K.squeeze(output[:, :, :, :, 0] / output[:, :, :, :, 1], axis=None)) + # magnitude of edges -- unified x & y edges don't work well with Neural Networks + return output diff --git a/lib/model/losses_tf.py b/lib/model/losses_tf.py new file mode 100644 index 0000000000..3d676e7db5 --- /dev/null +++ b/lib/model/losses_tf.py @@ -0,0 +1,556 @@ +#!/usr/bin/env python3 +""" Custom Loss Functions for faceswap.py """ + +from __future__ import absolute_import + +import logging + +import numpy as np +import tensorflow as tf +from tensorflow.python.keras.engine import compile_utils + +from keras import backend as K + +logger = logging.getLogger(__name__) # pylint:disable=invalid-name + + +class DSSIMObjective(tf.keras.losses.Loss): + """ DSSIM Loss Function + + Difference of Structural Similarity (DSSIM loss function). Clipped between 0 and 0.5 + + Parameters + ---------- + k_1: float, optional + Parameter of the SSIM. Default: `0.01` + k_2: float, optional + Parameter of the SSIM. Default: `0.03` + kernel_size: int, optional + Size of the sliding window Default: `3` + max_value: float, optional + Max value of the output. Default: `1.0` + + Notes + ------ + You should add a regularization term like a l2 loss in addition to this one. + + References + ---------- + https://github.com/keras-team/keras-contrib/blob/master/keras_contrib/losses/dssim.py + + MIT License + + Copyright (c) 2017 Fariz Rahman + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + """ + def __init__(self, k_1=0.01, k_2=0.03, kernel_size=3, max_value=1.0): + super().__init__(name="DSSIMObjective") + self.kernel_size = kernel_size + self.k_1 = k_1 + self.k_2 = k_2 + self.max_value = max_value + self.c_1 = (self.k_1 * self.max_value) ** 2 + self.c_2 = (self.k_2 * self.max_value) ** 2 + self.dim_ordering = K.image_data_format() + + @staticmethod + def __int_shape(input_tensor): + """ Returns the shape of tensor or variable as a tuple of int or None entries. + + Parameters + ---------- + input_tensor: tensor or variable + The input to return the shape for + + Returns + ------- + tuple + A tuple of integers (or None entries) + """ + return K.int_shape(input_tensor) + + def call(self, y_true, y_pred): + """ Call the DSSIM Loss Function. + + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + + Returns + ------- + tensor + The DSSIM Loss value + + Notes + ----- + There are additional parameters for this function. some of the 'modes' for edge behavior + do not yet have a gradient definition in the Theano tree and cannot be used for learning + """ + + kernel = [self.kernel_size, self.kernel_size] + y_true = K.reshape(y_true, [-1] + list(self.__int_shape(y_pred)[1:])) + y_pred = K.reshape(y_pred, [-1] + list(self.__int_shape(y_pred)[1:])) + patches_pred = self.extract_image_patches(y_pred, + kernel, + kernel, + 'valid', + self.dim_ordering) + patches_true = self.extract_image_patches(y_true, + kernel, + kernel, + 'valid', + self.dim_ordering) + + # Get mean + u_true = K.mean(patches_true, axis=-1) + u_pred = K.mean(patches_pred, axis=-1) + # Get variance + var_true = K.var(patches_true, axis=-1) + var_pred = K.var(patches_pred, axis=-1) + # Get standard deviation + covar_true_pred = K.mean( + patches_true * patches_pred, axis=-1) - u_true * u_pred + + ssim = (2 * u_true * u_pred + self.c_1) * ( + 2 * covar_true_pred + self.c_2) + denom = (K.square(u_true) + K.square(u_pred) + self.c_1) * ( + var_pred + var_true + self.c_2) + ssim /= denom # no need for clipping, c_1 + c_2 make the denorm non-zero + return K.mean((1.0 - ssim) / 2.0) + + @staticmethod + def _preprocess_padding(padding): + """Convert keras padding to tensorflow padding. + + Parameters + ---------- + padding: string, + `"same"` or `"valid"`. + + Returns + ------- + str + `"SAME"` or `"VALID"`. + + Raises + ------ + ValueError + If `padding` is invalid. + """ + if padding == 'same': + padding = 'SAME' + elif padding == 'valid': + padding = 'VALID' + else: + raise ValueError('Invalid padding:', padding) + return padding + + def extract_image_patches(self, input_tensor, k_sizes, s_sizes, + padding='same', data_format='channels_last'): + """ Extract the patches from an image. + + Parameters + ---------- + input_tensor: tensor + The input image + k_sizes: tuple + 2-d tuple with the kernel size + s_sizes: tuple + 2-d tuple with the strides size + padding: str, optional + `"same"` or `"valid"`. Default: `"same"` + data_format: str, optional. + `"channels_last"` or `"channels_first"`. Default: `"channels_last"` + + Returns + ------- + The (k_w, k_h) patches extracted + Tensorflow ==> (batch_size, w, h, k_w, k_h, c) + Theano ==> (batch_size, w, h, c, k_w, k_h) + """ + kernel = [1, k_sizes[0], k_sizes[1], 1] + strides = [1, s_sizes[0], s_sizes[1], 1] + padding = self._preprocess_padding(padding) + if data_format == 'channels_first': + input_tensor = K.permute_dimensions(input_tensor, (0, 2, 3, 1)) + patches = tf.image.extract_patches(input_tensor, kernel, strides, [1, 1, 1, 1], padding) + return patches + + +class PenalizedLoss(tf.keras.losses.Loss): + """ Penalized Loss function. + + Applies the given loss function just to the masked area of the image. + + Parameters + ---------- + loss_func: function + The actual loss function to use + mask_prop: float, optional + The amount of mask propagation. Default: `1.0` + """ + def __init__(self, loss_func, mask_prop=1.0): + super().__init__(name="penalized_loss") + self._loss_func = compile_utils.LossesContainer(loss_func) + self._mask_prop = mask_prop + + def call(self, y_true, y_pred): + """ Apply the loss function to the masked area of the image. + + Parameters + ---------- + y_true: tensor or variable + The ground truth value. This should contain the mask in the 4th channel that will be + split off for penalizing. + y_pred: tensor or variable + The predicted value + + Returns + ------- + tensor + The Loss value + """ + mask = self._prepare_mask(K.expand_dims(y_true[..., -1], axis=-1)) + y_true = y_true[..., :-1] + n_true = K.concatenate([y_true[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) + n_pred = K.concatenate([y_pred[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) + return self._loss_func(n_true, n_pred) + + def _prepare_mask(self, mask): + """ Prepare the masks for calculating loss + + Parameters + ---------- + mask: :class:`numpy.ndarray` + The masks for the current batch + + Returns + ------- + tensor + The prepared mask for applying to loss + """ + mask_as_k_inv_prop = 1 - self._mask_prop + mask = (mask * self._mask_prop) + mask_as_k_inv_prop + return mask + + +class GeneralizedLoss(tf.keras.losses.Loss): + """ Generalized function used to return a large variety of mathematical loss functions. + + The primary benefit is a smooth, differentiable version of L1 loss. + + References + ---------- + Barron, J. A More General Robust Loss Function - https://arxiv.org/pdf/1701.03077.pdf + + Example + ------- + >>> a=1.0, x>>c , c=1.0/255.0 # will give a smoothly differentiable version of L1 / MAE loss + >>> a=1.999999 (limit as a->2), beta=1.0/255.0 # will give L2 / RMSE loss + + Parameters + ---------- + alpha: float, optional + Penalty factor. Larger number give larger weight to large deviations. Default: `1.0` + beta: float, optional + Scale factor used to adjust to the input scale (i.e. inputs of mean `1e-4` or `256`). + Default: `1.0/255.0` + """ + def __init__(self, alpha=1.0, beta=1.0/255.0): + super().__init__(name="generalized_loss") + self.alpha = alpha + self.beta = beta + + def call(self, y_true, y_pred): + """ Call the Generalized Loss Function + + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + + Returns + ------- + tensor + The loss value from the results of function(y_pred - y_true) + """ + diff = y_pred - y_true + second = (K.pow(K.pow(diff/self.beta, 2.) / K.abs(2. - self.alpha) + 1., + (self.alpha / 2.)) - 1.) + loss = (K.abs(2. - self.alpha)/self.alpha) * second + loss = K.mean(loss, axis=-1) * self.beta + return loss + + +class LInfNorm(tf.keras.losses.Loss): + """ Calculate the L-inf norm as a loss function. """ + + def call(self, y_true, y_pred): + """ Call the L-inf norm loss function. + + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + + Returns + ------- + tensor + The loss value + """ + diff = K.abs(y_true - y_pred) + max_loss = K.max(diff, axis=(1, 2), keepdims=True) + loss = K.mean(max_loss, axis=-1) + return loss + + +class GradientLoss(tf.keras.losses.Loss): + """ Gradient Loss Function. + + Calculates the first and second order gradient difference between pixels of an image in the x + and y dimensions. These gradients are then compared between the ground truth and the predicted + image and the difference is taken. When used as a loss, its minimization will result in + predicted images approaching the same level of sharpness / blurriness as the ground truth. + + References + ---------- + TV+TV2 Regularization with Non-Convex Sparseness-Inducing Penalty for Image Restoration, + Chengwu Lu & Hua Huang, 2014 - http://downloads.hindawi.com/journals/mpe/2014/790547.pdf + """ + def __init__(self): + super().__init__(name="generalized_loss") + self.generalized_loss = GeneralizedLoss(alpha=1.9999) + + def call(self, y_true, y_pred): + """ Call the gradient loss function. + + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + + Returns + ------- + tensor + The loss value + """ + tv_weight = 1.0 + tv2_weight = 1.0 + loss = 0.0 + loss += tv_weight * (self.generalized_loss(self._diff_x(y_true), self._diff_x(y_pred)) + + self.generalized_loss(self._diff_y(y_true), self._diff_y(y_pred))) + loss += tv2_weight * (self.generalized_loss(self._diff_xx(y_true), self._diff_xx(y_pred)) + + self.generalized_loss(self._diff_yy(y_true), self._diff_yy(y_pred)) + + self.generalized_loss(self._diff_xy(y_true), self._diff_xy(y_pred)) + * 2.) + loss = loss / (tv_weight + tv2_weight) + # TODO simplify to use MSE instead + return loss + + @classmethod + def _diff_x(cls, img): + """ X Difference """ + x_left = img[:, :, 1:2, :] - img[:, :, 0:1, :] + x_inner = img[:, :, 2:, :] - img[:, :, :-2, :] + x_right = img[:, :, -1:, :] - img[:, :, -2:-1, :] + x_out = K.concatenate([x_left, x_inner, x_right], axis=2) + return x_out * 0.5 + + @classmethod + def _diff_y(cls, img): + """ Y Difference """ + y_top = img[:, 1:2, :, :] - img[:, 0:1, :, :] + y_inner = img[:, 2:, :, :] - img[:, :-2, :, :] + y_bot = img[:, -1:, :, :] - img[:, -2:-1, :, :] + y_out = K.concatenate([y_top, y_inner, y_bot], axis=1) + return y_out * 0.5 + + @classmethod + def _diff_xx(cls, img): + """ X-X Difference """ + x_left = img[:, :, 1:2, :] + img[:, :, 0:1, :] + x_inner = img[:, :, 2:, :] + img[:, :, :-2, :] + x_right = img[:, :, -1:, :] + img[:, :, -2:-1, :] + x_out = K.concatenate([x_left, x_inner, x_right], axis=2) + return x_out - 2.0 * img + + @classmethod + def _diff_yy(cls, img): + """ Y-Y Difference """ + y_top = img[:, 1:2, :, :] + img[:, 0:1, :, :] + y_inner = img[:, 2:, :, :] + img[:, :-2, :, :] + y_bot = img[:, -1:, :, :] + img[:, -2:-1, :, :] + y_out = K.concatenate([y_top, y_inner, y_bot], axis=1) + return y_out - 2.0 * img + + @classmethod + def _diff_xy(cls, img): + """ X-Y Difference """ + # xout1 + top_left = img[:, 1:2, 1:2, :] + img[:, 0:1, 0:1, :] + inner_left = img[:, 2:, 1:2, :] + img[:, :-2, 0:1, :] + bot_left = img[:, -1:, 1:2, :] + img[:, -2:-1, 0:1, :] + xy_left = K.concatenate([top_left, inner_left, bot_left], axis=1) + + top_mid = img[:, 1:2, 2:, :] + img[:, 0:1, :-2, :] + mid_mid = img[:, 2:, 2:, :] + img[:, :-2, :-2, :] + bot_mid = img[:, -1:, 2:, :] + img[:, -2:-1, :-2, :] + xy_mid = K.concatenate([top_mid, mid_mid, bot_mid], axis=1) + + top_right = img[:, 1:2, -1:, :] + img[:, 0:1, -2:-1, :] + inner_right = img[:, 2:, -1:, :] + img[:, :-2, -2:-1, :] + bot_right = img[:, -1:, -1:, :] + img[:, -2:-1, -2:-1, :] + xy_right = K.concatenate([top_right, inner_right, bot_right], axis=1) + + # Xout2 + top_left = img[:, 0:1, 1:2, :] + img[:, 1:2, 0:1, :] + inner_left = img[:, :-2, 1:2, :] + img[:, 2:, 0:1, :] + bot_left = img[:, -2:-1, 1:2, :] + img[:, -1:, 0:1, :] + xy_left = K.concatenate([top_left, inner_left, bot_left], axis=1) + + top_mid = img[:, 0:1, 2:, :] + img[:, 1:2, :-2, :] + mid_mid = img[:, :-2, 2:, :] + img[:, 2:, :-2, :] + bot_mid = img[:, -2:-1, 2:, :] + img[:, -1:, :-2, :] + xy_mid = K.concatenate([top_mid, mid_mid, bot_mid], axis=1) + + top_right = img[:, 0:1, -1:, :] + img[:, 1:2, -2:-1, :] + inner_right = img[:, :-2, -1:, :] + img[:, 2:, -2:-1, :] + bot_right = img[:, -2:-1, -1:, :] + img[:, -1:, -2:-1, :] + xy_right = K.concatenate([top_right, inner_right, bot_right], axis=1) + + xy_out1 = K.concatenate([xy_left, xy_mid, xy_right], axis=2) + xy_out2 = K.concatenate([xy_left, xy_mid, xy_right], axis=2) + return (xy_out1 - xy_out2) * 0.25 + + +class GMSDLoss(tf.keras.losses.Loss): + """ Gradient Magnitude Similarity Deviation Loss. + + Improved image quality metric over MS-SSIM with easier calculations + + References + ---------- + http://www4.comp.polyu.edu.hk/~cslzhang/IQA/GMSD/GMSD.htm + https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf + """ + + def call(self, y_true, y_pred): + """ Return the Gradient Magnitude Similarity Deviation Loss. + + + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + + Returns + ------- + tensor + The loss value + """ + true_edge = self._scharr_edges(y_true, True) + pred_edge = self._scharr_edges(y_pred, True) + ephsilon = 0.0025 + upper = 2.0 * true_edge * pred_edge + lower = K.square(true_edge) + K.square(pred_edge) + gms = (upper + ephsilon) / (lower + ephsilon) + gmsd = K.std(gms, axis=(1, 2, 3), keepdims=True) + gmsd = K.squeeze(gmsd, axis=-1) + return gmsd + + @classmethod + def _scharr_edges(cls, image, magnitude): + """ Returns a tensor holding modified Scharr edge maps. + + Parameters + ---------- + image: tensor + Image tensor with shape [batch_size, h, w, d] and type float32. The image(s) must be + 2x2 or larger. + magnitude: bool + Boolean to determine if the edge magnitude or edge direction is returned + + Returns + ------- + tensor + Tensor holding edge maps for each channel. Returns a tensor with shape `[batch_size, h, + w, d, 2]` where the last two dimensions hold `[[dy[0], dx[0]], [dy[1], dx[1]], ..., + [dy[d-1], dx[d-1]]]` calculated using the Scharr filter. + """ + + # Define vertical and horizontal Scharr filters. + static_image_shape = image.get_shape() + image_shape = K.shape(image) + + # 5x5 modified Scharr kernel ( reshape to (5,5,1,2) ) + matrix = np.array([[[[0.00070, 0.00070]], + [[0.00520, 0.00370]], + [[0.03700, 0.00000]], + [[0.00520, -0.0037]], + [[0.00070, -0.0007]]], + [[[0.00370, 0.00520]], + [[0.11870, 0.11870]], + [[0.25890, 0.00000]], + [[0.11870, -0.1187]], + [[0.00370, -0.0052]]], + [[[0.00000, 0.03700]], + [[0.00000, 0.25890]], + [[0.00000, 0.00000]], + [[0.00000, -0.2589]], + [[0.00000, -0.0370]]], + [[[-0.0037, 0.00520]], + [[-0.1187, 0.11870]], + [[-0.2589, 0.00000]], + [[-0.1187, -0.1187]], + [[-0.0037, -0.0052]]], + [[[-0.0007, 0.00070]], + [[-0.0052, 0.00370]], + [[-0.0370, 0.00000]], + [[-0.0052, -0.0037]], + [[-0.0007, -0.0007]]]]) + num_kernels = [2] + kernels = K.constant(matrix, dtype='float32') + kernels = K.tile(kernels, [1, 1, image_shape[-1], 1]) + + # Use depth-wise convolution to calculate edge maps per channel. + # Output tensor has shape [batch_size, h, w, d * num_kernels]. + pad_sizes = [[0, 0], [2, 2], [2, 2], [0, 0]] + padded = tf.pad(image, pad_sizes, mode='REFLECT') + output = K.depthwise_conv2d(padded, kernels) + + if not magnitude: # direction of edges + # Reshape to [batch_size, h, w, d, num_kernels]. + shape = K.concatenate([image_shape, num_kernels], axis=0) + output = K.reshape(output, shape=shape) + output.set_shape(static_image_shape.concatenate(num_kernels)) + output = tf.atan(K.squeeze(output[:, :, :, :, 0] / output[:, :, :, :, 1], axis=None)) + # magnitude of edges -- unified x & y edges don't work well with Neural Networks + return output diff --git a/lib/model/memory_saving_gradients.py b/lib/model/memory_saving_gradients.py deleted file mode 100644 index 8a893a2cb0..0000000000 --- a/lib/model/memory_saving_gradients.py +++ /dev/null @@ -1,439 +0,0 @@ -#!/usr/bin/env python3 -""" Memory saving gradients. -Adapted from: https://github.com/openai/gradient-checkpointing - -The MIT License - -Copyright (c) 2018 OpenAI (http://openai.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -""" - -import contextlib -import logging -import time -import sys - -import numpy as np -import tensorflow as tf -import tensorflow.contrib.graph_editor as ge # pylint: disable=no-name-in-module -from toposort import toposort - - -logger = logging.getLogger(__name__) # pylint: disable=invalid-name -sys.setrecursionlimit(10000) -# refers back to current module if we decide to split helpers out -util = sys.modules[__name__] - -# getting rid of "WARNING:tensorflow:VARIABLES collection name is deprecated" -setattr(tf.GraphKeys, "VARIABLES", "variables") - -# save original gradients since tf.gradient could be monkey-patched to point -# to our version -from tensorflow.python.ops import gradients as tf_grads_lib # pylint: disable=no-name-in-module -tf_gradients = tf_grads_lib.gradients - -MIN_CHECKPOINT_NODE_SIZE = 1024 # use lower value during testing - - -# specific versions we can use to do process-wide replacement of tf.gradients -def gradients_speed(ys, xs, grad_ys=None, **kwargs): - return gradients(ys, xs, grad_ys, checkpoints='speed', **kwargs) - - -def gradients_memory(ys, xs, grad_ys=None, **kwargs): - return gradients(ys, xs, grad_ys, checkpoints='memory', **kwargs) - - -def gradients_collection(ys, xs, grad_ys=None, **kwargs): - return gradients(ys, xs, grad_ys, checkpoints='collection', **kwargs) - - -def gradients(ys, xs, # pylint: disable: too-many-statements, too-many-branches - grad_ys=None, checkpoints='collection', **kwargs): - ''' - Authors: Tim Salimans & Yaroslav Bulatov - - memory efficient gradient implementation inspired by "Training Deep Nets with Sublinear Memory - Cost" by Chen et al. 2016 (https://arxiv.org/abs/1604.06174) - - ys,xs,grad_ys,kwargs are the arguments to standard tensorflow tf.gradients - (https://www.tensorflow.org/versions/r0.12/api_docs/python/train.html#gradients) - - 'checkpoints' can either be - - a list consisting of tensors from the forward pass of the neural net - that we should re-use when calculating the gradients in the backward pass - all other tensors that do not appear in this list will be re-computed - - a string specifying how this list should be determined. currently we support - - 'speed': checkpoint all outputs of convolutions and matmuls. these ops are usually - the most expensive, so checkpointing them maximizes the running speed - (this is a good option if nonlinearities, concats, batchnorms, etc are - taking up a lot of memory) - - 'memory': try to minimize the memory usage - (currently using a very simple strategy that identifies a number of - bottleneck tensors in the graph to checkpoint) - - 'collection': look for a tensorflow collection named 'checkpoints', which holds the - tensors to checkpoint - ''' - - # print("Calling memsaving gradients with", checkpoints) - if not isinstance(ys, list): - ys = [ys] - if not isinstance(xs, list): - xs = [xs] - - bwd_ops = ge.get_backward_walk_ops([y.op for y in ys], - inclusive=True) - - debug_print("bwd_ops: {}".format(bwd_ops)) - - # forward ops are all ops that are candidates for recomputation - fwd_ops = ge.get_forward_walk_ops([x.op for x in xs], - inclusive=True, - within_ops=bwd_ops) - debug_print("fwd_ops: {}".format(fwd_ops)) - - # exclude ops with no inputs - fwd_ops = [op for op in fwd_ops if op.inputs] - - # don't recompute xs, remove variables - xs_ops = _to_ops(xs) - fwd_ops = [op for op in fwd_ops if op not in xs_ops] - fwd_ops = [op for op in fwd_ops if '/assign' not in op.name] - fwd_ops = [op for op in fwd_ops if '/Assign' not in op.name] - fwd_ops = [op for op in fwd_ops if '/read' not in op.name] - ts_all = ge.filter_ts(fwd_ops, True) # get the tensors - ts_all = [t for t in ts_all if '/read' not in t.name] - ts_all = set(ts_all) - set(xs) - set(ys) - - # construct list of tensors to checkpoint during forward pass, if not - # given as input - if type(checkpoints) is not list: - if checkpoints == 'collection': - checkpoints = tf.get_collection('checkpoints') - - elif checkpoints == 'speed': - # checkpoint all expensive ops to maximize running speed - checkpoints = ge.filter_ts_from_regex(fwd_ops, 'conv2d|Conv|MatMul') - - elif checkpoints == 'memory': - - # remove very small tensors and some weird ops - def fixdims(t): # tf.Dimension values are not compatible with int, convert manually - try: - return [int(e if e.value is not None else 64) for e in t] - except: - return [0] # unknown shape - ts_all = [t for t in ts_all if np.prod(fixdims(t.shape)) > MIN_CHECKPOINT_NODE_SIZE] - ts_all = [t for t in ts_all if 'L2Loss' not in t.name] - ts_all = [t for t in ts_all if 'entropy' not in t.name] - ts_all = [t for t in ts_all if 'FusedBatchNorm' not in t.name] - ts_all = [t for t in ts_all if 'Switch' not in t.name] - ts_all = [t for t in ts_all if 'dropout' not in t.name] - # DV: FP16_FIX - need to add 'Cast' layer here to make it work for FP16 - ts_all = [t for t in ts_all if 'Cast' not in t.name] - - # filter out all tensors that are inputs of the backward graph - with util.capture_ops() as bwd_ops: - tf_gradients(ys, xs, grad_ys, **kwargs) - - bwd_inputs = [t for op in bwd_ops for t in op.inputs] - # list of tensors in forward graph that is in input to bwd graph - ts_filtered = list(set(bwd_inputs).intersection(ts_all)) - debug_print("Using tensors {}".format(ts_filtered)) - - # try two slightly different ways of getting bottlenecks tensors - # to checkpoint - for ts in [ts_filtered, ts_all]: - - # get all bottlenecks in the graph - bottleneck_ts = [] - for t in ts: - b = set(ge.get_backward_walk_ops(t.op, inclusive=True, within_ops=fwd_ops)) - f = set(ge.get_forward_walk_ops(t.op, inclusive=False, within_ops=fwd_ops)) - # check that there are not shortcuts - b_inp = set([inp for op in b for inp in op.inputs]).intersection(ts_all) - f_inp = set([inp for op in f for inp in op.inputs]).intersection(ts_all) - if not set(b_inp).intersection(f_inp) and len(b_inp)+len(f_inp) >= len(ts_all): - bottleneck_ts.append(t) # we have a bottleneck! - else: - debug_print("Rejected bottleneck candidate and ops {}".format( - [t] + list(set(ts_all) - set(b_inp) - set(f_inp)))) - - # success? or try again without filtering? - if len(bottleneck_ts) >= np.sqrt(len(ts_filtered)): # enough bottlenecks found! - break - - if not bottleneck_ts: - raise Exception('unable to find bottleneck tensors! please provide checkpoint ' - 'nodes manually, or use checkpoints="speed".') - - # sort the bottlenecks - bottlenecks_sorted_lists = tf_toposort(bottleneck_ts, within_ops=fwd_ops) - sorted_bottlenecks = [t for ts in bottlenecks_sorted_lists for t in ts] - - # save an approximately optimal number ~ sqrt(N) - N = len(ts_filtered) - if len(bottleneck_ts) <= np.ceil(np.sqrt(N)): - checkpoints = sorted_bottlenecks - else: - step = int(np.ceil(len(bottleneck_ts) / np.sqrt(N))) - checkpoints = sorted_bottlenecks[step::step] - - else: - raise Exception('%s is unsupported input for "checkpoints"' % (checkpoints,)) - - checkpoints = list(set(checkpoints).intersection(ts_all)) - - # at this point automatic selection happened and checkpoints is list of nodes - assert isinstance(checkpoints, list) - - debug_print("Checkpoint nodes used: {}".format(checkpoints)) - # better error handling of special cases - # xs are already handled as checkpoint nodes, so no need to include them - xs_intersect_checkpoints = set(xs).intersection(set(checkpoints)) - if xs_intersect_checkpoints: - debug_print("Warning, some input nodes are also checkpoint nodes: {}".format( - xs_intersect_checkpoints)) - ys_intersect_checkpoints = set(ys).intersection(set(checkpoints)) - debug_print("ys: {}, checkpoints:{}, intersect: {}".format( - ys, checkpoints, ys_intersect_checkpoints)) - # saving an output node (ys) gives no benefit in memory while creating - # new edge cases, exclude them - if ys_intersect_checkpoints: - debug_print("Warning, some output nodes are also checkpoints nodes: {}".format( - format_ops(ys_intersect_checkpoints))) - - # remove initial and terminal nodes from checkpoints list if present - checkpoints = list(set(checkpoints) - set(ys) - set(xs)) - - # check that we have some nodes to checkpoint - if not checkpoints: - raise Exception('no checkpoints nodes found or given as input! ') - - # disconnect dependencies between checkpointed tensors - checkpoints_disconnected = {} - for x in checkpoints: - if x.op and x.op.name is not None: - grad_node = tf.stop_gradient(x, name=x.op.name+"_sg") - else: - grad_node = tf.stop_gradient(x) - checkpoints_disconnected[x] = grad_node - - # partial derivatives to the checkpointed tensors and xs - ops_to_copy = fast_backward_ops(seed_ops=[y.op for y in ys], - stop_at_ts=checkpoints, within_ops=fwd_ops) - debug_print("Found {} ops to copy within fwd_ops {}, seed {}, stop_at {}".format( - len(ops_to_copy), fwd_ops, [r.op for r in ys], checkpoints)) - debug_print("ops_to_copy = {}".format(ops_to_copy)) - debug_print("Processing list {}".format(ys)) - _, info = ge.copy_with_input_replacements(ge.sgv(ops_to_copy), {}) - for origin_op, op in info._transformed_ops.items(): - op._set_device(origin_op.node_def.device) - copied_ops = info._transformed_ops.values() - debug_print("Copied {} to {}".format(ops_to_copy, copied_ops)) - ge.reroute_ts(checkpoints_disconnected.values(), - checkpoints_disconnected.keys(), - can_modify=copied_ops) - debug_print("Rewired {} in place of {} restricted to {}".format( - checkpoints_disconnected.values(), checkpoints_disconnected.keys(), copied_ops)) - - # get gradients with respect to current boundary + original x's - copied_ys = [info._transformed_ops[y.op]._outputs[0] for y in ys] - boundary = list(checkpoints_disconnected.values()) - dv = tf_gradients(ys=copied_ys, xs=boundary+xs, grad_ys=grad_ys, **kwargs) - debug_print("Got gradients {}".format(dv)) - debug_print("for %s", copied_ys) - debug_print("with respect to {}".format(boundary+xs)) - - inputs_to_do_before = [y.op for y in ys] - if grad_ys is not None: - inputs_to_do_before += grad_ys - wait_to_do_ops = list(copied_ops) + [g.op for g in dv if g is not None] - my_add_control_inputs(wait_to_do_ops, inputs_to_do_before) - - # partial derivatives to the checkpointed nodes - # dictionary of "node: backprop" for nodes in the boundary - d_checkpoints = {r: dr for r, dr in zip(checkpoints_disconnected.keys(), - dv[:len(checkpoints_disconnected)])} - # partial derivatives to xs (usually the params of the neural net) - d_xs = dv[len(checkpoints_disconnected):] - - # incorporate derivatives flowing through the checkpointed nodes - checkpoints_sorted_lists = tf_toposort(checkpoints, within_ops=fwd_ops) - for ts in checkpoints_sorted_lists[::-1]: - debug_print("Processing list {}".format(ts)) - checkpoints_other = [r for r in checkpoints if r not in ts] - checkpoints_disconnected_other = [checkpoints_disconnected[r] for r in checkpoints_other] - - # copy part of the graph below current checkpoint node, stopping at - # other checkpoints nodes - ops_to_copy = fast_backward_ops(within_ops=fwd_ops, - seed_ops=[r.op for r in ts], - stop_at_ts=checkpoints_other) - debug_print("Found {} ops to copy within {}, seed {}, stop_at {}".format( - len(ops_to_copy), fwd_ops, [r.op for r in ts], checkpoints_other)) - debug_print("ops_to_copy = {}".format(ops_to_copy)) - if not ops_to_copy: # we're done! - break - _, info = ge.copy_with_input_replacements(ge.sgv(ops_to_copy), {}) - for origin_op, op in info._transformed_ops.items(): - op._set_device(origin_op.node_def.device) - copied_ops = info._transformed_ops.values() - debug_print("Copied {} to {}".format(ops_to_copy, copied_ops)) - ge.reroute_ts(checkpoints_disconnected_other, checkpoints_other, can_modify=copied_ops) - debug_print("Rewired %s in place of %s restricted to %s", - checkpoints_disconnected_other, checkpoints_other, copied_ops) - - # gradient flowing through the checkpointed node - boundary = [info._transformed_ops[r.op]._outputs[0] for r in ts] - substitute_backprops = [d_checkpoints[r] for r in ts] - dv = tf_gradients(boundary, - checkpoints_disconnected_other+xs, - grad_ys=substitute_backprops, **kwargs) - debug_print("Got gradients {}".format(dv)) - debug_print("for {}".format(boundary)) - debug_print("with respect to {}".format(checkpoints_disconnected_other+xs)) - debug_print("with boundary backprop substitutions {}".format(substitute_backprops)) - - inputs_to_do_before = [d_checkpoints[r].op for r in ts] - wait_to_do_ops = list(copied_ops) + [g.op for g in dv if g is not None] - my_add_control_inputs(wait_to_do_ops, inputs_to_do_before) - - # partial derivatives to the checkpointed nodes - for r, dr in zip(checkpoints_other, dv[:len(checkpoints_other)]): - if dr is not None: - if d_checkpoints[r] is None: - d_checkpoints[r] = dr - else: - d_checkpoints[r] += dr - - def _unsparsify(var_x): - if not isinstance(var_x, tf.IndexedSlices): - return var_x - assert var_x.dense_shape is not None, \ - "memory_saving_gradients encountered sparse gradients of unknown shape" - indices = var_x.indices - while indices.shape.ndims < var_x.values.shape.ndims: - indices = tf.expand_dims(indices, -1) - return tf.scatter_nd(indices, var_x.values, var_x.dense_shape) - - # partial derivatives to xs (usually the params of the neural net) - d_xs_new = dv[len(checkpoints_other):] - for j in range(len(xs)): - if d_xs_new[j] is not None: - if d_xs[j] is None: - d_xs[j] = _unsparsify(d_xs_new[j]) - else: - d_xs[j] += _unsparsify(d_xs_new[j]) - - return d_xs - - -def tf_toposort(ts_inp, within_ops=None): - """ Tensorflow topological sort """ - all_ops = ge.get_forward_walk_ops([x.op for x in ts_inp], within_ops=within_ops) - - deps = {} - for tf_op in all_ops: - for outp in tf_op.outputs: - deps[outp] = set(tf_op.inputs) - sorted_ts = toposort(deps) - - # only keep the tensors from our original list - ts_sorted_lists = [] - for lst in sorted_ts: - keep = list(set(lst).intersection(ts_inp)) - if keep: - ts_sorted_lists.append(keep) - return ts_sorted_lists - - -def fast_backward_ops(within_ops, seed_ops, stop_at_ts): - """ Fast backward ops """ - bwd_ops = set(ge.get_backward_walk_ops(seed_ops, stop_at_ts=stop_at_ts)) - ops = bwd_ops.intersection(within_ops).difference([t.op for t in stop_at_ts]) - return list(ops) - - -@contextlib.contextmanager -def capture_ops(): - """Decorator to capture ops created in the block. - with capture_ops() as ops: - # create some ops - print(ops) # => prints ops created. - """ - - micros = int(time.time()*10**6) - scope_name = str(micros) - op_list = [] - with tf.name_scope(scope_name): - yield op_list - - graph = tf.get_default_graph() - op_list.extend(ge.select_ops(scope_name+"/.*", graph=graph)) - - -def _to_op(tensor_or_op): - """ Convert to op """ - if hasattr(tensor_or_op, "op"): - return tensor_or_op.op - return tensor_or_op - - -def _to_ops(iterable): - """ Convert to ops """ - if not _is_iterable(iterable): - return iterable - return [_to_op(i) for i in iterable] - - -def _is_iterable(obj): - """ Check if object is iterable """ - try: - _ = iter(obj) - except Exception: # pylint: disable=broad-except - return False - return True - - -def debug_print(msg, *args): - """ Debug logging """ - formatted_args = [format_ops(arg) for arg in args] - logger.debug("%s: %s", msg, formatted_args) - - -def format_ops(ops, sort_outputs=True): - """Helper method for printing ops. Converts Tensor/Operation op to op.name, - rest to str(op).""" - - if hasattr(ops, '__iter__') and not isinstance(ops, str): - lst = [(op.name if hasattr(op, "name") else str(op)) for op in ops] - if sort_outputs: - return sorted(lst) - return lst - return ops.name if hasattr(ops, "name") else str(ops) - - -def my_add_control_inputs(wait_to_do_ops, inputs_to_do_before): - """ Add control inputs """ - for tf_op in wait_to_do_ops: - ctl_inp = [i for i in inputs_to_do_before - if tf_op.control_inputs is None or i not in tf_op.control_inputs] - ge.add_control_inputs(tf_op, ctl_inp) diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index 91a66afb88..498a989efb 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -3,11 +3,10 @@ import logging -from keras.layers import Add, Concatenate, SeparableConv2D, UpSampling2D -from keras.layers.advanced_activations import LeakyReLU -from keras.layers.convolutional import Conv2D -from keras.layers.core import Activation +from keras.layers import (Activation, Add, Concatenate, Conv2D as KConv2D, LeakyReLU, + SeparableConv2D, UpSampling2D) from keras.initializers import he_uniform, VarianceScaling + from .initializers import ICNR, ConvolutionAware from .layers import PixelShuffler, ReflectionPadding2D from .normalization import InstanceNormalization @@ -15,476 +14,584 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name -class NNBlocks(): - """ Blocks that are often used for multiple models are stored here for easy access. +_CONFIG = dict() +_NAMES = dict() + - This class is always brought in as ``self.blocks`` in all model plugins so that all models - have access to them. +def set_config(configuration): + """ Set the global configuration parameters from the user's config file. - The parameters passed into this class should ultimately originate from the user's training - configuration file, rather than being hard-coded at the plugin level. + These options are used when creating layers for new models. Parameters ---------- - use_icnr_init: bool, Optional - ``True`` if ICNR initialization should be used rather than the default. Default: ``False`` - use_convaware_init: bool, Optional - ``True`` if Convolutional Aware initialization should be used rather than the default. - Default: ``False`` - use_reflect_padding: bool, Optional - ``True`` if Reflect Padding initialization should be used rather than the padding. + configuration: dict + The configuration options that exist in the training configuration files that pertain + specifically to Custom Faceswap Layers. The keys should be: `icnr_init`, `conv_aware_init` + and 'reflect_padding' + """ + global _CONFIG # pylint:disable=global-statement + _CONFIG = configuration + logger.debug("Set NNBlock configuration to: %s", _CONFIG) + + +def _get_name(name): + """ Return unique layer name for requested block. + + As blocks can be used multiple times, auto appends an integer to the end of the requested + name to keep all block names unique + + Parameters + ---------- + name: str + The requested name for the layer + + Returns + ------- + str + The unique name for this layer + """ + global _NAMES # pylint:disable=global-statement + _NAMES[name] = _NAMES.setdefault(name, -1) + 1 + name = "{}_{}".format(name, _NAMES[name]) + logger.debug("Generating block name: %s", name) + return name + + +# << CONVOLUTIONS >> +class Conv2D(KConv2D): # pylint:disable=too-few-public-methods + """ A standard Keras Convolution 2D layer with parameters updated to be more appropriate for + Faceswap architecture. + + Parameters are the same, with the same defaults, as a standard :class:`keras.layers.Conv2D` + except where listed below. The default initializer is updated to `he_uniform` or `convolutional + aware` based on user configuration settings. + + Parameters + ---------- + padding: str, optional + One of `"valid"` or `"same"` (case-insensitive). Default: `"same"`. Note that `"same"` is + slightly inconsistent across backends with `strides` != 1, as described + `here `_. + check_icnr_init: `bool`, optional + ``True`` if the user configuration options should be checked to apply ICNR initialization + to the layer. This should only be passed in from :class:`UpscaleBlock` layers. Default: ``False`` - first_run: bool, Optional - ``True`` if a model is being created for the first time, ``False`` if a model is being - resumed. Used to prevent Convolutional Aware weights from being calculated when a model - is being reloaded. Default: ``True`` """ - def __init__(self, - use_icnr_init=False, - use_convaware_init=False, - use_reflect_padding=False, - first_run=True): - logger.debug("Initializing %s: (use_icnr_init: %s, use_convaware_init: %s, " - "use_reflect_padding: %s, first_run: %s)", - self.__class__.__name__, use_icnr_init, use_convaware_init, - use_reflect_padding, first_run) - self.names = dict() - self.first_run = first_run - self.use_icnr_init = use_icnr_init - self.use_convaware_init = use_convaware_init - self.use_reflect_padding = use_reflect_padding - if self.use_convaware_init and self.first_run: - logger.info("Using Convolutional Aware Initialization. Model generation will take a " - "few minutes...") - logger.debug("Initialized %s", self.__class__.__name__) - - def _get_name(self, name): - """ Return unique layer name for requested block. - - As blocks can be used multiple times, auto appends an integer to the end of the requested - name to keep all block names unique + def __init__(self, *args, padding="same", check_icnr_init=False, **kwargs): + if kwargs.get("name", None) is None: + kwargs["name"] = _get_name("conv2d_{}".format(args[0])) + initializer = self._get_default_initializer(kwargs.pop("kernel_initializer", None)) + if check_icnr_init and _CONFIG["icnr_init"]: + initializer = ICNR(initializer=initializer) + logger.debug("Using ICNR Initializer: %s", initializer) + super().__init__(*args, padding=padding, kernel_initializer=initializer, **kwargs) + + @classmethod + def _get_default_initializer(cls, initializer): + """ Returns a default initializer of Convolutional Aware or he_uniform for convolutional + layers. Parameters ---------- - name: str - The requested name for the layer + initializer: :class:`keras.initializers.Initializer` or None + The initializer that has been passed into the model. If this value is ``None`` then a + default initializer will be returned based on the configuration choices, otherwise + the given initializer will be returned. Returns ------- - str - The unique name for this layer + :class:`keras.initializers.Initializer` + The kernel initializer to use for this convolutional layer. Either the original given + initializer, he_uniform or convolutional aware (if selected in config options) """ - self.names[name] = self.names.setdefault(name, -1) + 1 - name = "{}_{}".format(name, self.names[name]) - logger.debug("Generating block name: %s", name) - return name - - def _set_default_initializer(self, kwargs): - """ Sets the default initializer for convolution 2D and Seperable convolution 2D layers - to Convolutional Aware or he_uniform. - - if a specific initializer has been passed in from the model plugin, then the specified - initializer will be used rather than the default. - - Parameters - ---------- - kwargs: dict - The keyword arguments for the current layer - - Returns - ------- - dict - The keyword arguments for the current layer with the initializer updated to - the select default value - """ - if "kernel_initializer" in kwargs: - logger.debug("Using model specified initializer: %s", kwargs["kernel_initializer"]) - return kwargs - if self.use_convaware_init: - default = ConvolutionAware() - if self.first_run: - # Indicate the Convolutional Aware should be calculated on first run - default._init = True # pylint:disable=protected-access + if initializer is None: + retval = ConvolutionAware() if _CONFIG["conv_aware_init"] else he_uniform() + logger.debug("Set default kernel_initializer: %s", retval) else: - default = he_uniform() - if kwargs.get("kernel_initializer", None) != default: - kwargs["kernel_initializer"] = default - logger.debug("Set default kernel_initializer to: %s", kwargs["kernel_initializer"]) - return kwargs - - @staticmethod - def _switch_kernel_initializer(kwargs, initializer): - """ Switch the initializer in the given kwargs to the given initializer and return the - previous initializer to caller. - - For residual blocks and up-scaling, user selected initializer methods should replace those - set by the model. This method updates the initializer for the layer, and returns the - original initializer so that it can be set back to the layer's key word arguments for - subsequent layers where the initializer should not be switched. + retval = initializer + logger.debug("Using model supplied initializer: %s", retval) + return retval + + +class Conv2DOutput(): # pylint:disable=too-few-public-methods + """ A Convolution 2D layer that separates out the activation layer to explicitly set the data + type on the activation to float 32 to fully support mixed precision training. + + The Convolution 2D layer uses default parameters to be more appropriate for Faceswap + architecture. + + Parameters are the same, with the same defaults, as a standard :class:`keras.layers.Conv2D` + except where listed below. The default initializer is updated to he_uniform or convolutional + aware based on user config settings. + + Parameters + ---------- + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution) + kernel_size: int or tuple/list of 2 ints + The height and width of the 2D convolution window. Can be a single integer to specify the + same value for all spatial dimensions. + activation: str, optional + The activation function to apply to the output. Default: `"sigmoid"` + padding: str, optional + One of `"valid"` or `"same"` (case-insensitive). Default: `"same"`. Note that `"same"` is + slightly inconsistent across backends with `strides` != 1, as described + `here `_. + kwargs: dict + Any additional Keras standard layer keyword arguments to pass to the Convolutional 2D layer + """ + def __init__(self, filters, kernel_size, activation="sigmoid", padding="same", **kwargs): + self._name = kwargs.pop("name") if "name" in kwargs else _get_name( + "conv_output_{}".format(filters)) + self._filters = filters + self._kernel_size = kernel_size + self._activation = activation + self._padding = padding + self._kwargs = kwargs + + def __call__(self, inputs): + """ Call the Faceswap Convolutional Output Layer. Parameters ---------- - kwargs: dict - The keyword arguments for the current layer - initializer: keras or faceswap initializer class - The initializer that should replace the current initializer that exists in keyword - arguments + inputs: Tensor + The input to the layer Returns ------- - keras or faceswap initializer class - The original initializer that existed in the given keyword arguments + Tensor + The output tensor from the Convolution 2D Layer """ - original = kwargs.get("kernel_initializer", None) - kwargs["kernel_initializer"] = initializer - logger.debug("Switched kernel_initializer from %s to %s", original, initializer) - return original + var_x = Conv2D(self._filters, + self._kernel_size, + padding=self._padding, + name="{}_conv2d".format(self._name), + **self._kwargs)(inputs) + var_x = Activation(self._activation, dtype="float32", name=self._name)(var_x) + return var_x + + +class Conv2DBlock(): # pylint:disable=too-few-public-methods + """ A standard Convolution 2D layer which applies user specified configuration to the + layer. + + Adds reflection padding if it has been selected by the user, and other post-processing + if requested by the plugin. - def conv2d(self, input_tensor, filters, kernel_size, strides=(1, 1), padding="same", **kwargs): - """ A standard Convolution 2D layer with correct initialization. + Adds instance normalization if requested. Adds a LeakyReLU if a residual block follows. - This layer creates a convolution kernel that is convolved with the layer input to produce - a tensor of outputs. + Parameters + ---------- + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution) + kernel_size: int, optional + An integer or tuple/list of 2 integers, specifying the height and width of the 2D + convolution window. Can be a single integer to specify the same value for all spatial + dimensions. Default: 5 + strides: tuple or int, optional + An integer or tuple/list of 2 integers, specifying the strides of the convolution along the + height and width. Can be a single integer to specify the same value for all spatial + dimensions. Default: `2` + padding: ["valid", "same"], optional + The padding to use. NB: If reflect padding has been selected in the user configuration + options, then this argument will be ignored in favor of reflect padding. Default: `"same"` + use_instance_norm: bool, optional + ``True`` if instance normalization should be applied after the convolutional layer. + Default: ``False`` + res_block_follows: bool, optional + If a residual block will follow this layer, then this should be set to ``True`` to add a + leaky ReLu after the convolutional layer. Default: ``False`` + kwargs: dict + Any additional Keras standard layer keyword arguments to pass to the Convolutional 2D layer + """ + def __init__(self, + filters, + kernel_size=5, + strides=2, + padding="same", + use_instance_norm=False, + res_block_follows=False, + **kwargs): + self._name = kwargs.pop("name") if "name" in kwargs else _get_name( + "conv_{}".format(filters)) + logger.debug("name: %s, filters: %s, kernel_size: %s, strides: %s, padding: %s, " + "use_instance_norm: %s, res_block_follows: %s, kwargs: %s)", + self._name, filters, kernel_size, strides, padding, use_instance_norm, + res_block_follows, kwargs) + self._use_reflect_padding = _CONFIG["reflect_padding"] + + self._filters = filters + self._kernel_size = kernel_size + self._strides = strides + self._padding = "valid" if self._use_reflect_padding else padding + self._kwargs = kwargs + self._use_instance_norm = use_instance_norm + self._res_block_follows = res_block_follows + + def __call__(self, inputs): + """ Call the Faceswap Convolutional Layer. Parameters ---------- - input_tensor: tensor - The input tensor to the layer - filters: int - The dimensionality of the output space (i.e. the number of output filters in the - convolution) - kernel_size: int - An integer or tuple/list of 2 integers, specifying the height and width of the 2D - convolution window. Can be a single integer to specify the same value for all spatial - dimensions - strides: tuple, optional - An integer or tuple/list of 2 integers, specifying the strides of the convolution along - the height and width. Can be a single integer to specify the same value for all spatial - dimensions. Default: `(1, 1)` - padding: ["valid", "same"], optional - The padding to use. Default: `"same"` - kwargs: dict - Any additional Keras standard layer keyword arguments + inputs: Tensor + The input to the layer Returns ------- - tensor + Tensor The output tensor from the Convolution 2D Layer """ - logger.debug("input_tensor: %s, filters: %s, kernel_size: %s, strides: %s, padding: %s, " - "kwargs: %s)", input_tensor, filters, kernel_size, strides, padding, kwargs) - if kwargs.get("name", None) is None: - kwargs["name"] = self._get_name("conv2d_{}".format(input_tensor.shape[1])) - kwargs = self._set_default_initializer(kwargs) - var_x = Conv2D(filters, kernel_size, - strides=strides, - padding=padding, - **kwargs)(input_tensor) + if self._use_reflect_padding: + inputs = ReflectionPadding2D(stride=self._strides, + kernel_size=self._kernel_size, + name="{}_reflectionpadding2d".format(self._name))(inputs) + var_x = Conv2D(self._filters, + self._kernel_size, + strides=self._strides, + padding=self._padding, + name="{}_conv2d".format(self._name), + **self._kwargs)(inputs) + if self._use_instance_norm: + var_x = InstanceNormalization(name="{}_instancenorm".format(self._name))(var_x) + if not self._res_block_follows: + var_x = LeakyReLU(0.1, name="{}_leakyrelu".format(self._name))(var_x) return var_x - # <<< Original Model Blocks >>> # - def conv(self, input_tensor, filters, kernel_size=5, strides=2, padding="same", - use_instance_norm=False, res_block_follows=False, **kwargs): - """ A standard Convolution 2D layer which applies user specified configuration to the - layer. - Adds reflection padding if it has been selected by the user, and other post-processing - if requested by the plugin. +class SeparableConv2DBlock(): # pylint:disable=too-few-public-methods + """ Seperable Convolution Block. + + Parameters + ---------- + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution) + kernel_size: int, optional + An integer or tuple/list of 2 integers, specifying the height and width of the 2D + convolution window. Can be a single integer to specify the same value for all spatial + dimensions. Default: 5 + strides: tuple or int, optional + An integer or tuple/list of 2 integers, specifying the strides of the convolution along + the height and width. Can be a single integer to specify the same value for all spatial + dimensions. Default: `2` + kwargs: dict + Any additional Keras standard layer keyword arguments to pass to the Separable + Convolutional 2D layer + """ + def __init__(self, filters, kernel_size=5, strides=2, **kwargs): + self._name = _get_name("separableconv2d_{}".format(filters)) + logger.debug("name: %s, filters: %s, kernel_size: %s, strides: %s, kwargs: %s)", + self._name, filters, kernel_size, strides, kwargs) + + self._filters = filters + self._kernel_size = kernel_size + self._strides = strides + + initializer = self._get_default_initializer(kwargs.pop("kernel_initializer", None)) + kwargs["kernel_initializer"] = initializer + self._kwargs = kwargs + + @classmethod + def _get_default_initializer(cls, initializer): + """ Returns a default initializer of Convolutional Aware or he_uniform for convolutional + layers. Parameters ---------- - input_tensor: tensor - The input tensor to the layer - filters: int - The dimensionality of the output space (i.e. the number of output filters in the - convolution) - kernel_size: int, optional - An integer or tuple/list of 2 integers, specifying the height and width of the 2D - convolution window. Can be a single integer to specify the same value for all spatial - dimensions. Default: 5 - strides: tuple or int, optional - An integer or tuple/list of 2 integers, specifying the strides of the convolution along - the height and width. Can be a single integer to specify the same value for all spatial - dimensions. Default: `2` - padding: ["valid", "same"], optional - The padding to use. Default: `"same"` - use_instance_norm: bool, optional - ``True`` if instance normalization should be applied after the convolutional layer. - Default: ``False`` - res_block_follows: bool, optional - If a residual block will follow this layer, then this should be set to `True` to add - a leaky ReLu after the convolutional layer. Default: ``False`` - kwargs: dict - Any additional Keras standard layer keyword arguments + initializer: :class:`keras.initializers.Initializer` or None + The initializer that has been passed into the model. If this value is ``None`` then a + default initializer will be returned based on the configuration choices, otherwise + the given initializer will be returned. Returns ------- - tensor - The output tensor from the Convolution 2D Layer + :class:`keras.initializers.Initializer` + The kernel initializer to use for this convolutional layer. Either the original given + initializer, he_uniform or convolutional aware (if selected in config options) """ - logger.debug("input_tensor: %s, filters: %s, kernel_size: %s, strides: %s, " - "use_instance_norm: %s, kwargs: %s)", input_tensor, filters, kernel_size, - strides, use_instance_norm, kwargs) - name = self._get_name("conv_{}".format(input_tensor.shape[1])) - if self.use_reflect_padding: - input_tensor = ReflectionPadding2D( - stride=strides, - kernel_size=kernel_size, - name="{}_reflectionpadding2d".format(name))(input_tensor) - padding = "valid" - var_x = self.conv2d(input_tensor, filters, - kernel_size=kernel_size, - strides=strides, - padding=padding, - name="{}_conv2d".format(name), - **kwargs) - if use_instance_norm: - var_x = InstanceNormalization(name="{}_instancenorm".format(name))(var_x) - if not res_block_follows: - var_x = LeakyReLU(0.1, name="{}_leakyrelu".format(name))(var_x) - return var_x - - def upscale(self, input_tensor, filters, kernel_size=3, padding="same", - use_instance_norm=False, res_block_follows=False, scale_factor=2, **kwargs): - """ An upscale layer for sub-pixel up-scaling. + if initializer is None: + retval = ConvolutionAware() if _CONFIG["conv_aware_init"] else he_uniform() + logger.debug("Set default kernel_initializer: %s", retval) + else: + retval = initializer + logger.debug("Using model supplied initializer: %s", retval) + return retval - Adds reflection padding if it has been selected by the user, and other post-processing - if requested by the plugin. + def __call__(self, inputs): + """ Call the Faceswap Separable Convolutional 2D Block. Parameters ---------- - input_tensor: tensor - The input tensor to the layer - filters: int - The dimensionality of the output space (i.e. the number of output filters in the - convolution) - kernel_size: int, optional - An integer or tuple/list of 2 integers, specifying the height and width of the 2D - convolution window. Can be a single integer to specify the same value for all spatial - dimensions. Default: 3 - padding: ["valid", "same"], optional - The padding to use. Default: `"same"` - use_instance_norm: bool, optional - ``True`` if instance normalization should be applied after the convolutional layer. - Default: ``False`` - res_block_follows: bool, optional - If a residual block will follow this layer, then this should be set to `True` to add - a leaky ReLu after the convolutional layer. Default: ``False`` - scale_factor: int, optional - The amount to upscale the image. Default: `2` - kwargs: dict - Any additional Keras standard layer keyword arguments + inputs: Tensor + The input to the layer Returns ------- - tensor - The output tensor from the Upscale layer + Tensor + The output tensor from the Upscale Layer """ - logger.debug("input_tensor: %s, filters: %s, kernel_size: %s, use_instance_norm: %s, " - "kwargs: %s)", input_tensor, filters, kernel_size, use_instance_norm, kwargs) - name = self._get_name("upscale_{}".format(input_tensor.shape[1])) - if self.use_reflect_padding: - input_tensor = ReflectionPadding2D( - stride=1, - kernel_size=kernel_size, - name="{}_reflectionpadding2d".format(name))(input_tensor) - padding = "valid" - kwargs = self._set_default_initializer(kwargs) - if self.use_icnr_init: - original_init = self._switch_kernel_initializer( - kwargs, - ICNR(initializer=kwargs["kernel_initializer"])) - var_x = self.conv2d(input_tensor, filters * scale_factor * scale_factor, - kernel_size=kernel_size, - padding=padding, - name="{}_conv2d".format(name), - **kwargs) - if self.use_icnr_init: - self._switch_kernel_initializer(kwargs, original_init) - if use_instance_norm: - var_x = InstanceNormalization(name="{}_instancenorm".format(name))(var_x) - if not res_block_follows: - var_x = LeakyReLU(0.1, name="{}_leakyrelu".format(name))(var_x) - var_x = PixelShuffler(name="{}_pixelshuffler".format(name), size=scale_factor)(var_x) + var_x = SeparableConv2D(self._filters, + kernel_size=self._kernel_size, + strides=self._strides, + padding="same", + name="{}_seperableconv2d".format(self._name), + **self._kwargs)(inputs) + var_x = Activation("relu", name="{}_relu".format(self._name))(var_x) return var_x - # <<< DLight Model Blocks >>> # - def upscale2x(self, input_tensor, filters, - kernel_size=3, padding="same", interpolation="bilinear", res_block_follows=False, - sr_ratio=0.5, scale_factor=2, fast=False, **kwargs): - """ Custom hybrid upscale layer for sub-pixel up-scaling. - Most of up-scaling is approximating lighting gradients which can be accurately achieved - using linear fitting. This layer attempts to improve memory consumption by splitting - with bilinear and convolutional layers so that the sub-pixel update will get details - whilst the bilinear filter will get lighting. +# << UPSCALING >> + +class UpscaleBlock(): # pylint:disable=too-few-public-methods + """ An upscale layer for sub-pixel up-scaling. + + Adds reflection padding if it has been selected by the user, and other post-processing + if requested by the plugin. + + Parameters + ---------- + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution) + kernel_size: int, optional + An integer or tuple/list of 2 integers, specifying the height and width of the 2D + convolution window. Can be a single integer to specify the same value for all spatial + dimensions. Default: 3 + padding: ["valid", "same"], optional + The padding to use. NB: If reflect padding has been selected in the user configuration + options, then this argument will be ignored in favor of reflect padding. Default: `"same"` + scale_factor: int, optional + The amount to upscale the image. Default: `2` + use_instance_norm: bool, optional + ``True`` if instance normalization should be applied after the convolutional layer. + Default: ``False`` + res_block_follows: bool, optional + If a residual block will follow this layer, then this should be set to ``True`` to add + a leaky ReLu after the convolutional layer. Default: ``False`` + kwargs: dict + Any additional Keras standard layer keyword arguments to pass to the Convolutional 2D layer + """ - Adds reflection padding if it has been selected by the user, and other post-processing - if requested by the plugin. + def __init__(self, + filters, + kernel_size=3, + padding="same", + scale_factor=2, + use_instance_norm=False, + res_block_follows=False, + **kwargs): + self._name = _get_name("upscale_{}".format(filters)) + logger.debug("name: %s. filters: %s, kernel_size: %s, padding: %s, scale_factor: %s, " + "use_instance_norm: %s, res_block_follows: %s, kwargs: %s)", + self._name, filters, kernel_size, padding, scale_factor, use_instance_norm, + res_block_follows, kwargs) + + self._filters = filters + self._kernel_size = kernel_size + self._padding = padding + self._scale_factor = scale_factor + self._use_instance_norm = use_instance_norm + self._res_block_follows = res_block_follows + self._kwargs = kwargs + + def __call__(self, inputs): + """ Call the Faceswap Convolutional Layer. Parameters ---------- - input_tensor: tensor - The input tensor to the layer - filters: int - The dimensionality of the output space (i.e. the number of output filters in the - convolution) - kernel_size: int, optional - An integer or tuple/list of 2 integers, specifying the height and width of the 2D - convolution window. Can be a single integer to specify the same value for all spatial - dimensions. Default: 3 - padding: ["valid", "same"], optional - The padding to use. Default: `"same"` - interpolation: ["nearest", "bilinear"], optional - Interpolation to use for up-sampling. Default: `"bilinear"` - res_block_follows: bool, optional - If a residual block will follow this layer, then this should be set to `True` to add - a leaky ReLu after the convolutional layer. Default: ``False`` - scale_factor: int, optional - The amount to upscale the image. Default: `2` - sr_ratio: float, optional - The proportion of super resolution (pixel shuffler) filters to use. Non-fast mode only. - Default: `0.5` - kwargs: dict - Any additional Keras standard layer keyword arguments - fast: bool, optional - Use a faster up-scaling method that may appear more rugged. Default: ``False`` + inputs: Tensor + The input to the layer Returns ------- - tensor - The output tensor from the Upscale layer + Tensor + The output tensor from the Upscale Layer """ - name = self._get_name("upscale2x_{}".format("fast" if fast else "hyb")) - var_x = input_tensor - if not fast: - sr_filters = int(filters * sr_ratio) - filters = filters - sr_filters - var_x_sr = self.upscale(var_x, filters, - kernel_size=kernel_size, - padding=padding, - scale_factor=scale_factor, - res_block_follows=res_block_follows, - **kwargs) - - if fast or (not fast and filters > 0): - var_x2 = self.conv2d(var_x, filters, - kernel_size=3, - padding=padding, - name="{}_conv2d".format(name), - **kwargs) - var_x2 = UpSampling2D(size=(scale_factor, scale_factor), - interpolation=interpolation, - name="{}_upsampling2D".format(name))(var_x2) - if fast: - var_x1 = self.upscale(var_x, filters, - kernel_size=kernel_size, - padding=padding, - scale_factor=scale_factor, - res_block_follows=res_block_follows, **kwargs) - var_x = Add()([var_x2, var_x1]) - else: - var_x = Concatenate(name="{}_concatenate".format(name))([var_x_sr, var_x2]) - else: - var_x = var_x_sr + var_x = Conv2DBlock(self._filters * self._scale_factor * self._scale_factor, + self._kernel_size, + strides=(1, 1), + padding=self._padding, + use_instance_norm=self._use_instance_norm, + res_block_follows=self._res_block_follows, + name="{}_conv2d".format(self._name), + check_icnr_init=_CONFIG["icnr_init"], + **self._kwargs)(inputs) + var_x = PixelShuffler(name="{}_pixelshuffler".format(self._name), + size=self._scale_factor)(var_x) return var_x - # <<< DFaker Model Blocks >>> # - def res_block(self, input_tensor, filters, kernel_size=3, padding="same", **kwargs): - """ Residual block. + +class Upscale2xBlock(): # pylint:disable=too-few-public-methods + """ Custom hybrid upscale layer for sub-pixel up-scaling. + + Most of up-scaling is approximating lighting gradients which can be accurately achieved + using linear fitting. This layer attempts to improve memory consumption by splitting + with bilinear and convolutional layers so that the sub-pixel update will get details + whilst the bilinear filter will get lighting. + + Adds reflection padding if it has been selected by the user, and other post-processing + if requested by the plugin. + + Parameters + ---------- + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution) + kernel_size: int, optional + An integer or tuple/list of 2 integers, specifying the height and width of the 2D + convolution window. Can be a single integer to specify the same value for all spatial + dimensions. Default: 3 + padding: ["valid", "same"], optional + The padding to use. Default: `"same"` + interpolation: ["nearest", "bilinear"], optional + Interpolation to use for up-sampling. Default: `"bilinear"` + res_block_follows: bool, optional + If a residual block will follow this layer, then this should be set to ``True`` to add + a leaky ReLu after the convolutional layer. Default: ``False`` + scale_factor: int, optional + The amount to upscale the image. Default: `2` + sr_ratio: float, optional + The proportion of super resolution (pixel shuffler) filters to use. Non-fast mode only. + Default: `0.5` + fast: bool, optional + Use a faster up-scaling method that may appear more rugged. Default: ``False`` + kwargs: dict + Any additional Keras standard layer keyword arguments to pass to the Convolutional 2D layer + """ + def __init__(self, filters, kernel_size=3, padding="same", interpolation="bilinear", + res_block_follows=False, sr_ratio=0.5, scale_factor=2, fast=False, **kwargs): + self._name = _get_name("upscale2x_{}_{}".format(filters, "fast" if fast else "hyb")) + + self._fast = fast + self._filters = filters if self._fast else filters - int(filters * sr_ratio) + self._kernel_size = kernel_size + self._padding = padding + self._interpolation = interpolation + self._res_block_follows = res_block_follows + self._scale_factor = scale_factor + self._kwargs = kwargs + + def __call__(self, inputs): + """ Call the Faceswap Upscale 2x Layer. Parameters ---------- - input_tensor: tensor - The input tensor to the layer - filters: int - The dimensionality of the output space (i.e. the number of output filters in the - convolution) - kernel_size: int, optional - An integer or tuple/list of 2 integers, specifying the height and width of the 2D - convolution window. Can be a single integer to specify the same value for all spatial - dimensions. Default: 3 - padding: ["valid", "same"], optional - The padding to use. Default: `"same"` - kwargs: dict - Any additional Keras standard layer keyword arguments + inputs: Tensor + The input to the layer Returns ------- - tensor - The output tensor from the Upscale layer + Tensor + The output tensor from the Upscale Layer """ - logger.debug("input_tensor: %s, filters: %s, kernel_size: %s, kwargs: %s)", - input_tensor, filters, kernel_size, kwargs) - name = self._get_name("residual_{}".format(input_tensor.shape[1])) - var_x = LeakyReLU(alpha=0.2, name="{}_leakyrelu_0".format(name))(input_tensor) - if self.use_reflect_padding: - var_x = ReflectionPadding2D(stride=1, - kernel_size=kernel_size, - name="{}_reflectionpadding2d_0".format(name))(var_x) - padding = "valid" - var_x = self.conv2d(var_x, filters, - kernel_size=kernel_size, - padding=padding, - name="{}_conv2d_0".format(name), - **kwargs) - var_x = LeakyReLU(alpha=0.2, name="{}_leakyrelu_1".format(name))(var_x) - if self.use_reflect_padding: - var_x = ReflectionPadding2D(stride=1, - kernel_size=kernel_size, - name="{}_reflectionpadding2d_1".format(name))(var_x) - padding = "valid" - if not self.use_convaware_init: - original_init = self._switch_kernel_initializer(kwargs, VarianceScaling( - scale=0.2, - mode="fan_in", - distribution="uniform")) - var_x = self.conv2d(var_x, filters, - kernel_size=kernel_size, - padding=padding, - **kwargs) - if not self.use_convaware_init: - self._switch_kernel_initializer(kwargs, original_init) - var_x = Add()([var_x, input_tensor]) - var_x = LeakyReLU(alpha=0.2, name="{}_leakyrelu_3".format(name))(var_x) + var_x = inputs + if not self._fast: + var_x_sr = UpscaleBlock(self._filters, + kernel_size=self._kernel_size, + padding=self._padding, + scale_factor=self._scale_factor, + res_block_follows=self._res_block_follows, + **self._kwargs)(var_x) + if self._fast or (not self._fast and self._filters > 0): + var_x2 = Conv2D(self._filters, 3, + padding=self._padding, + name="{}_conv2d".format(self._name), + **self._kwargs)(var_x) + var_x2 = UpSampling2D(size=(self._scale_factor, self._scale_factor), + interpolation=self._interpolation, + name="{}_upsampling2D".format(self._name))(var_x2) + if self._fast: + var_x1 = UpscaleBlock(self._filters, + kernel_size=self._kernel_size, + padding=self._padding, + scale_factor=self._scale_factor, + res_block_follows=self._res_block_follows, + **self._kwargs)(var_x) + var_x = Add()([var_x2, var_x1]) + else: + var_x = Concatenate(name="{}_concatenate".format(self._name))([var_x_sr, var_x2]) + else: + var_x = var_x_sr return var_x - # <<< Unbalanced Model Blocks >>> # - def conv_sep(self, input_tensor, filters, kernel_size=5, strides=2, **kwargs): - """ Seperable Convolution Layer. + +# << OTHER BLOCKS >> +class ResidualBlock(): # pylint:disable=too-few-public-methods + """ Residual block from dfaker. + + Parameters + ---------- + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution) + kernel_size: int, optional + An integer or tuple/list of 2 integers, specifying the height and width of the 2D + convolution window. Can be a single integer to specify the same value for all spatial + dimensions. Default: 3 + padding: ["valid", "same"], optional + The padding to use. Default: `"same"` + kwargs: dict + Any additional Keras standard layer keyword arguments to pass to the Convolutional 2D layer + + Returns + ------- + tensor + The output tensor from the Upscale layer + """ + def __init__(self, filters, kernel_size=3, padding="same", **kwargs): + self._name = _get_name("residual_{}".format(filters)) + logger.debug("name: %s, filters: %s, kernel_size: %s, padding: %s, kwargs: %s)", + self._name, filters, kernel_size, padding, kwargs) + self._use_reflect_padding = _CONFIG["reflect_padding"] + + self._filters = filters + self._kernel_size = kernel_size + self._padding = "valid" if self._use_reflect_padding else padding + self._kwargs = kwargs + + def __call__(self, inputs): + """ Call the Faceswap Residual Block. Parameters ---------- - input_tensor: tensor - The input tensor to the layer - filters: int - The dimensionality of the output space (i.e. the number of output filters in the - convolution) - kernel_size: int, optional - An integer or tuple/list of 2 integers, specifying the height and width of the 2D - convolution window. Can be a single integer to specify the same value for all spatial - dimensions. Default: 5 - strides: tuple or int, optional - An integer or tuple/list of 2 integers, specifying the strides of the convolution along - the height and width. Can be a single integer to specify the same value for all spatial - dimensions. Default: `2` - kwargs: dict - Any additional Keras standard layer keyword arguments + inputs: Tensor + The input to the layer Returns ------- - tensor - The output tensor from the Upscale layer + Tensor + The output tensor from the Upscale Layer """ - logger.debug("input_tensor: %s, filters: %s, kernel_size: %s, strides: %s, kwargs: %s)", - input_tensor, filters, kernel_size, strides, kwargs) - name = self._get_name("separableconv2d_{}".format(input_tensor.shape[1])) - kwargs = self._set_default_initializer(kwargs) - var_x = SeparableConv2D(filters, - kernel_size=kernel_size, - strides=strides, - padding="same", - name="{}_seperableconv2d".format(name), - **kwargs)(input_tensor) - var_x = Activation("relu", name="{}_relu".format(name))(var_x) + var_x = LeakyReLU(alpha=0.2, name="{}_leakyrelu_0".format(self._name))(inputs) + if self._use_reflect_padding: + var_x = ReflectionPadding2D(stride=1, + kernel_size=self._kernel_size, + name="{}_reflectionpadding2d_0".format(self._name))(var_x) + var_x = Conv2D(self._filters, + kernel_size=self._kernel_size, + padding=self._padding, + name="{}_conv2d_0".format(self._name), + **self._kwargs)(var_x) + var_x = LeakyReLU(alpha=0.2, name="{}_leakyrelu_1".format(self._name))(var_x) + if self._use_reflect_padding: + var_x = ReflectionPadding2D(stride=1, + kernel_size=self._kernel_size, + name="{}_reflectionpadding2d_1".format(self._name))(var_x) + + kwargs = {key: val for key, val in self._kwargs.items() if key != "kernel_initializer"} + if not _CONFIG["conv_aware_init"]: + kwargs["kernel_initializer"] = VarianceScaling(scale=0.2, + mode="fan_in", + distribution="uniform") + var_x = Conv2D(self._filters, + kernel_size=self._kernel_size, + padding=self._padding, + name="{}_conv2d_1".format(self._name), + **kwargs)(var_x) + + var_x = Add()([var_x, inputs]) + var_x = LeakyReLU(alpha=0.2, name="{}_leakyrelu_3".format(self._name))(var_x) return var_x diff --git a/lib/model/normalization.py b/lib/model/normalization.py index 60036fdd94..270b761924 100644 --- a/lib/model/normalization.py +++ b/lib/model/normalization.py @@ -4,10 +4,10 @@ import sys import inspect -from keras.engine import Layer, InputSpec +from keras.layers import Layer, InputSpec from keras import initializers, regularizers, constraints from keras import backend as K -from keras.utils.generic_utils import get_custom_objects +from keras.utils import get_custom_objects class InstanceNormalization(Layer): @@ -50,9 +50,8 @@ class InstanceNormalization(Layer): ---------- - Layer Normalization - https://arxiv.org/abs/1607.06450 - - Instance Normalization: The Missing Ingredient for Fast Stylization - - https://arxiv.org/abs/1607.08022 - + - Instance Normalization: The Missing Ingredient for Fast Stylization - \ + https://arxiv.org/abs/1607.08022 """ def __init__(self, axis=None, @@ -162,6 +161,20 @@ def call(self, inputs, training=None): # pylint:disable=arguments-differ,unused return normed def get_config(self): + """Returns the config of the layer. + + A layer config is a Python dictionary (serializable) containing the configuration of a + layer. The same layer can be reinstated later (without its trained weights) from this + configuration. + + The configuration of a layer does not include connectivity information, nor the layer + class name. These are handled by `Network` (one layer of abstraction above). + + Returns + -------- + dict + A python dictionary containing the layer configuration + """ config = { "axis": self.axis, "epsilon": self.epsilon, diff --git a/lib/model/optimizers.py b/lib/model/optimizers.py deleted file mode 100644 index e2b50e4040..0000000000 --- a/lib/model/optimizers.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env python3 -""" Optimizers for faceswap.py """ -# Naming convention inherited from Keras so ignore invalid names -# pylint:disable=invalid-name - -import logging - -from keras import backend as K -from keras.optimizers import Adam as KerasAdam - -logger = logging.getLogger(__name__) # pylint: disable=invalid-name - - -class Adam(KerasAdam): - """Adapted Keras Adam Optimizer to allow support of calculations on CPU for Tensorflow. - - Default parameters follow those provided in the original paper. Adapted from - https://github.com/iperov/DeepFaceLab - - Parameters - ---------- - lr: float, optional - >= `0`. Learning rate. Default: `0.001` - beta_1: float, optional - `0` < beta < `1` Generally close to `1`. Default: `0.9` - beta_2: float, optional - `0` < beta < `1`. Generally close to `1`. Default: `0.999` - epsilon: float, optional - >= `0`. Fuzz factor. If ``None``, defaults to `K.epsilon()`. Default: ``None`` - decay: float, optional - >= 0. Learning rate decay over each update. Default: `0` - amsgrad: bool, optional - ``True`` to apply the AMSGrad variant of this algorithm from the paper "On the Convergence - of Adam and Beyond" otherwise ``False``. Default: ``False`` - cpu_mode: bool, optional - Set to ``True`` to perform some of the calculations on CPU for Nvidia backends, otherwise - ``False``. Default: ``False`` - kwargs: dict - Any additional standard Keras optimizer keyword arguments - - References - ---------- - - Adam - A Method for Stochastic Optimization - https://arxiv.org/abs/1412.6980v8 - - - On the Convergence of Adam and Beyond - https://openreview.net/forum?id=ryQu7f-RZ - """ - - def __init__(self, - lr=0.001, - beta_1=0.9, - beta_2=0.999, - epsilon=None, - decay=0., - amsgrad=False, - cpu_mode=False, - **kwargs): - super().__init__(lr, beta_1, beta_2, epsilon, decay, **kwargs) - self.cpu_mode = self._set_cpu_mode(cpu_mode) - - @staticmethod - def _set_cpu_mode(cpu_mode): - """ Sets the CPU mode to False if not using Tensorflow, otherwise the given value. - - Parameters - ---------- - cpu_mode: bool - Set to ``True`` to perform some of the calculations on CPU for Nvidia backends, - otherwise ``False``. - - Returns - ------- - bool - ``True`` if some calculations should be performed on CPU otherwise ``False`` - """ - retval = False if K.backend() != "tensorflow" else cpu_mode - logger.debug("Optimizer CPU Mode set to %s", retval) - return retval - - def get_updates(self, loss, params): - """ Obtain the optimizer loss updates. - - Parameters - ---------- - loss: list - List of tensors - - params: list - List of tensors - - Returns - ------- - list - List of tensors - """ - grads = self.get_gradients(loss, params) - self.updates = [K.update_add(self.iterations, 1)] - - lr = self.lr - if self.initial_decay > 0: - lr = lr * (1. / (1. + self.decay * K.cast(self.iterations, - K.dtype(self.decay)))) - - t = K.cast(self.iterations, K.floatx()) + 1 - lr_t = lr * (K.sqrt(1. - K.pow(self.beta_2, t)) / - (1. - K.pow(self.beta_1, t))) - - # Pass off to CPU if requested - if self.cpu_mode: - with K.tf.device("/cpu:0"): - ms, vs, vhats = self._update_1(params) - else: - ms, vs, vhats = self._update_1(params) - - self.weights = [self.iterations] + ms + vs + vhats - - for p, g, m, v, vhat in zip(params, grads, ms, vs, vhats): - m_t = (self.beta_1 * m) + (1. - self.beta_1) * g - v_t = (self.beta_2 * v) + (1. - self.beta_2) * K.square(g) - if self.amsgrad: - vhat_t = K.maximum(vhat, v_t) - p_t = p - lr_t * m_t / (K.sqrt(vhat_t) + self.epsilon) - self.updates.append(K.update(vhat, vhat_t)) - else: - p_t = p - lr_t * m_t / (K.sqrt(v_t) + self.epsilon) - - self.updates.append(K.update(m, m_t)) - self.updates.append(K.update(v, v_t)) - new_p = p_t - - # Apply constraints. - if getattr(p, 'constraint', None) is not None: - new_p = p.constraint(new_p) - - self.updates.append(K.update(p, new_p)) - return self.updates - - def _update_1(self, params): - """ Perform the first update. Run under CPU context if running on Tensorflow and CPU mode - is enabled, otherwise run on the default device. """ - ms = [K.zeros(K.int_shape(p), dtype=K.dtype(p)) for p in params] - vs = [K.zeros(K.int_shape(p), dtype=K.dtype(p)) for p in params] - if self.amsgrad: - vhats = [K.zeros(K.int_shape(p), dtype=K.dtype(p)) for p in params] - else: - vhats = [K.zeros(1) for _ in params] - return ms, vs, vhats diff --git a/lib/model/session.py b/lib/model/session.py index 35586adc66..09d9c39036 100644 --- a/lib/model/session.py +++ b/lib/model/session.py @@ -3,26 +3,32 @@ import logging +import numpy as np import tensorflow as tf +# pylint:disable=no-name-in-module,import-error from keras.layers import Activation -from tensorflow.python import errors_impl as tf_error # pylint:disable=no-name-in-module from keras.models import load_model as k_load_model, Model -import numpy as np -from lib.utils import get_backend, FaceswapError +from lib.utils import get_backend logger = logging.getLogger(__name__) # pylint:disable=invalid-name class KSession(): - """ Handles the settings of backend sessions. + """ Handles the settings of backend sessions for inference models. This class acts as a wrapper for various :class:`keras.Model()` functions, ensuring that - actions performed on a model are handled consistently within the correct graph. + actions performed on a model are handled consistently and can be performed in parallel in + separate threads. This is an early implementation of this class, and should be expanded out over time with relevant `AMD`, `CPU` and `NVIDIA` backend methods. + Notes + ----- + The documentation refers to :mod:`keras`. This is a pseudonym for either :mod:`keras` or + :mod:`tensorflow.keras` depending on the backend in use. + Parameters ---------- name: str @@ -30,53 +36,55 @@ class KSession(): model_path: str The path to the keras model file model_kwargs: dict, optional - Any kwargs that need to be passed to :func:`keras.models.load_models()`. Default: None + Any kwargs that need to be passed to :func:`keras.models.load_models()`. Default: ``None`` allow_growth: bool, optional - Enable the Tensorflow GPU allow_growth configuration option. This option prevents " - Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation and " - slower performance. Default: False + Enable the Tensorflow GPU allow_growth configuration option. This option prevents + Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation and + slower performance. Default: ``False`` + exclude_gpus: list, optional + A list of indices correlating to connected GPUs that Tensorflow should not use. Pass + ``None`` to not exclude any GPUs. Default: ``None`` + """ - def __init__(self, name, model_path, model_kwargs=None, allow_growth=False): + def __init__(self, name, model_path, model_kwargs=None, allow_growth=False, exclude_gpus=None): logger.trace("Initializing: %s (name: %s, model_path: %s, model_kwargs: %s, " - "allow_growth: %s)", - self.__class__.__name__, name, model_path, model_kwargs, allow_growth) + "allow_growth: %s, exclude_gpus)", self.__class__.__name__, name, model_path, + model_kwargs, allow_growth, exclude_gpus) self._name = name - self._session = self._set_session(allow_growth) + self._backend = get_backend() + self._set_session(allow_growth, exclude_gpus) self._model_path = model_path - self._model_kwargs = model_kwargs + self._model_kwargs = dict() if not model_kwargs else model_kwargs self._model = None logger.trace("Initialized: %s", self.__class__.__name__,) def predict(self, feed, batch_size=None): - """ Get predictions from the model in the correct session. + """ Get predictions from the model. - This method is a wrapper for :func:`keras.predict()` function. + This method is a wrapper for :func:`keras.predict()` function. For Tensorflow backends + this is a straight call to the predict function. For PlaidML backends, this attempts + to optimize the inference batch sizes to reduce the number of kernels that need to be + compiled. Parameters ---------- feed: numpy.ndarray or list - The feed to be provided to the model as input. This should be a ``numpy.ndarray`` - for single inputs or a ``list`` of ``numpy.ndarrays`` for multiple inputs. + The feed to be provided to the model as input. This should be a :class:`numpy.ndarray` + for single inputs or a `list` of :class:`numpy.ndarray` objects for multiple inputs. """ - if self._session is None: - if batch_size is None: - return self._model.predict(feed) + if self._backend == "amd" and batch_size is not None: return self._amd_predict_with_optimized_batchsizes(feed, batch_size) - - with self._session.as_default(): # pylint: disable=not-context-manager - with self._session.graph.as_default(): - return self._model.predict(feed, batch_size=batch_size) + return self._model.predict(feed, batch_size=batch_size) def _amd_predict_with_optimized_batchsizes(self, feed, batch_size): - """ Minimizes the amount of kernels to be compiled when using - the ``Amd`` backend with varying batchsizes while trying to keep - the batchsize as high as possible. + """ Minimizes the amount of kernels to be compiled when using the ``amd`` backend with + varying batch sizes while trying to keep the batchsize as high as possible. Parameters ---------- feed: numpy.ndarray or list The feed to be provided to the model as input. This should be a ``numpy.ndarray`` - for single inputs or a ``list`` of ``numpy.ndarrays`` for multiple inputs. + for single inputs or a ``list`` of ``numpy.ndarray`` objects for multiple inputs. batch_size: int The upper batchsize to use. """ @@ -99,52 +107,64 @@ def _amd_predict_with_optimized_batchsizes(self, feed, batch_size): return np.concatenate(results) return [np.concatenate(x) for x in zip(*results)] - def _set_session(self, allow_growth): - """ Sets the session and graph. + def _set_session(self, allow_growth, exclude_gpus): + """ Sets the backend session options. + + For AMD backend this does nothing. - If the backend is AMD then this does nothing and the global ``Keras`` ``Session`` - is used + For CPU backends, this hides any GPUs from Tensorflow. + + For Nvidia backends, this hides any GPUs that Tensorflow should not use and applies + any allow growth settings + + Parameters + ---------- + allow_growth: bool, optional + Enable the Tensorflow GPU allow_growth configuration option. This option prevents + Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation + and slower performance. Default: False + exclude_gpus: list, optional + A list of indices correlating to connected GPUs that Tensorflow should not use. Pass + ``None`` to not exclude any GPUs. Default: ``None`` """ - if get_backend() == "amd": - return None - - self.graph = tf.Graph() - config = tf.ConfigProto() - if allow_growth and get_backend() == "nvidia": - config.gpu_options.allow_growth = True - try: - session = tf.Session(graph=tf.Graph(), config=config) - except tf_error.InternalError as err: - if "driver version is insufficient" in str(err): - msg = ("Your Nvidia Graphics Driver is insufficient for running Faceswap. " - "Please upgrade to the latest version.") - raise FaceswapError(msg) from err - raise err - logger.debug("Created tf.session: (graph: %s, session: %s, config: %s)", - session.graph, session, config) - return session + if self._backend == "amd": + return + if self._backend == "cpu": + logger.verbose("Hiding GPUs from Tensorflow") + tf.config.set_visible_devices([], "GPU") + return + + gpus = tf.config.list_physical_devices('GPU') + if exclude_gpus: + gpus = [gpu for idx, gpu in enumerate(gpus) if idx not in exclude_gpus] + logger.debug("Filtering devices to: %s", gpus) + tf.config.set_visible_devices(gpus, "GPU") + + if allow_growth: + for gpu in gpus: + logger.info("Setting allow growth for GPU: %s", gpu) + tf.config.experimental.set_memory_growth(gpu, True) def load_model(self): - """ Loads a model within the correct session. + """ Loads a model. This method is a wrapper for :func:`keras.models.load_model()`. Loads a model and its - weights from :attr:`model_path`. Any additional ``kwargs`` to be passed to - :func:`keras.models.load_model()` should also be defined during initialization of the - class. + weights from :attr:`model_path` defined during initialization of this class. Any additional + ``kwargs`` to be passed to :func:`keras.models.load_model()` should also be defined during + initialization of the class. + + For Tensorflow backends, the `make_predict_function` method is called on the model to make + it thread safe. """ logger.verbose("Initializing plugin model: %s", self._name) - if self._session is None: - self._model = k_load_model(self._model_path, **self._model_kwargs) - else: - with self._session.as_default(): # pylint: disable=not-context-manager - with self._session.graph.as_default(): - self._model = k_load_model(self._model_path, **self._model_kwargs) + self._model = k_load_model(self._model_path, compile=False, **self._model_kwargs) + if self._backend != "amd": + self._model.make_predict_function() def define_model(self, function): - """ Defines a given model in the correct session. + """ Defines a model from the given function. - This method acts as a wrapper for :class:`keras.models.Model()` to ensure that the model - is defined within it's own graph. + This method acts as a wrapper for :class:`keras.models.Model()`. Parameters ---------- @@ -153,39 +173,34 @@ def define_model(self, function): ``outputs``. The function that generates these results should be passed in, NOT the results themselves, as the function needs to be executed within the correct context. """ - if self._session is None: - self._model = Model(*function()) - else: - with self._session.as_default(): # pylint: disable=not-context-manager - with self._session.graph.as_default(): - self._model = Model(*function()) + self._model = Model(*function()) def load_model_weights(self): """ Load model weights for a defined model inside the correct session. This method is a wrapper for :class:`keras.load_weights()`. Once a model has been defined - in :func:`define_model()` this method can be called to load its weights in the correct - graph from the :attr:`model_path` defined during initialization of this class. + in :func:`define_model()` this method can be called to load its weights from the + :attr:`model_path` defined during initialization of this class. + + For Tensorflow backends, the `make_predict_function` method is called on the model to make + it thread safe. """ logger.verbose("Initializing plugin model: %s", self._name) - if self._session is None: - self._model.load_weights(self._model_path) - else: - with self._session.as_default(): # pylint: disable=not-context-manager - with self._session.graph.as_default(): - self._model.load_weights(self._model_path) + self._model.load_weights(self._model_path) + if self._backend != "amd": + self._model.make_predict_function() def append_softmax_activation(self, layer_index=-1): """ Append a softmax activation layer to a model Occasionally a softmax activation layer needs to be added to a model's output. - This is a convenience fuction to append this layer to the loaded model. + This is a convenience function to append this layer to the loaded model. Parameters ---------- layer_index: int, optional The layer index of the model to select the output from to use as an input to the - softmax activation layer. Default: -1 (The final layer of the model) + softmax activation layer. Default: `-1` (The final layer of the model) """ logger.debug("Appending Softmax Activation to model: (layer_index: %s)", layer_index) softmax = Activation("softmax", name="softmax")(self._model.layers[layer_index].output) diff --git a/lib/plaidml_tools.py b/lib/plaidml_tools.py index 738388c42c..f5392a17ef 100644 --- a/lib/plaidml_tools.py +++ b/lib/plaidml_tools.py @@ -1,102 +1,136 @@ #!/usr/bin python3 -""" PlaidML tools +""" PlaidML tools. - Must be kept separate from keras as the keras backend needs to be set from this module +Statistics and setup for PlaidML on AMD devices. + +This module must be kept separate from Keras, and be called prior to any Keras import, as the +plaidML Keras backend is set from this module. """ import json import logging import os +import sys import plaidml _INIT = False _LOGGER = None +_EXCLUDE_DEVICES = [] class PlaidMLStats(): - """ Stats for plaidML """ - def __init__(self, loglevel="INFO", log=True): + """ Handles the initialization of PlaidML and the returning of GPU information for connected + cards from the PlaidML library. + + This class is initialized early in Faceswap's Launch process from :func:`setup_plaidml`, with + statistics made available from :class:`~lib.gpu_stats.GPUStats` + + Parameters + --------- + log_level: str, optional + The requested Faceswap log level. Also dictates the level that PlaidML logging is set at. + Default:`"INFO"` + log: bool, optional + Whether this class should output to the logger. If statistics are being accessed during a + crash, then the logger may not be available, so this gives the option to turn logging off + in those kinds of situations. Default:``True`` + """ + def __init__(self, log_level="INFO", log=True): if not _INIT and log: - # Logger is held internally, as we don't want to log - # when obtaining system stats on crash + # Logger held internally, as we don't want to log when obtaining system stats on crash global _LOGGER # pylint:disable=global-statement - _LOGGER = logging.getLogger(__name__) # pylint:disable=invalid-name - _LOGGER.debug("Initializing: %s: (loglevel: %s, log: %s)", - self.__class__.__name__, loglevel, log) - self.initialize(loglevel) - self.ctx = plaidml.Context() - self.supported_devices = self.get_supported_devices() - self.devices = self.get_all_devices() - - self.device_details = [json.loads(device.details.decode()) for device in self.devices] + _LOGGER = logging.getLogger(__name__) + _LOGGER.debug("Initializing: %s: (log_level: %s, log: %s)", + self.__class__.__name__, log_level, log) + self._initialize(log_level) + self._ctx = plaidml.Context() + self._supported_devices = self._get_supported_devices() + self._devices = self._get_all_devices() + + self._device_details = [json.loads(device.details.decode()) + for device in self._devices if device.details] + if self._devices and not self.active_devices: + self._load_active_devices() if _LOGGER: _LOGGER.debug("Initialized: %s", self.__class__.__name__) # PROPERTIES + @property + def devices(self): + """list: The :class:`pladml._DeviceConfig` objects for GPUs that PlaidML has + discovered. """ + return self._devices + @property def active_devices(self): - """ Return the active device IDs """ - return [idx for idx, d_id in enumerate(self.ids) if d_id in plaidml.settings.device_ids] + """ list: List of device indices for active GPU devices. """ + return [idx for idx, d_id in enumerate(self._ids) + if d_id in plaidml.settings.device_ids and idx not in _EXCLUDE_DEVICES] @property def device_count(self): - """ Return count of PlaidML Devices """ - return len(self.devices) + """ int: The total number of GPU Devices discovered. """ + return len(self._devices) @property def drivers(self): - """ Return all PlaidML device drivers """ - return [device.get("driverVersion", "No Driver Found") for device in self.device_details] + """ list: The driver versions for each GPU device that PlaidML has discovered. """ + return [device.get("driverVersion", "No Driver Found") for device in self._device_details] @property def vram(self): - """ Return Total VRAM for all PlaidML Devices """ + """ list: The VRAM of each GPU device that PlaidML has discovered. """ return [int(device.get("globalMemSize", 0)) / (1024 * 1024) - for device in self.device_details] - - @property - def max_alloc(self): - """ Return Maximum allowed VRAM allocation for all PlaidML Devices """ - return [int(device.get("maxMemAllocSize", 0)) / (1024 * 1024) - for device in self.device_details] - - @property - def ids(self): - """ Return all PlaidML Device IDs """ - return [device.id.decode() for device in self.devices] + for device in self._device_details] @property def names(self): - """ Return all PlaidML Device Names """ + """ list: The name of each GPU device that PlaidML has discovered. """ return ["{} - {} ({})".format( device.get("vendor", "unknown"), device.get("name", "unknown"), - "supported" if idx in self.supported_indices else "experimental") - for idx, device in enumerate(self.device_details)] + "supported" if idx in self._supported_indices else "experimental") + for idx, device in enumerate(self._device_details)] @property - def supported_indices(self): - """ Return the indices from self.devices of GPUs categorized as supported """ + def _ids(self): + """ list: The device identification for each GPU device that PlaidML has discovered. """ + return [device.id.decode() for device in self._devices] + + @property + def _experimental_indices(self): + """ list: The indices corresponding to :attr:`_ids` of GPU devices marked as + "experimental". """ retval = [idx for idx, device in enumerate(self.devices) - if device in self.supported_devices] + if device not in self._supported_indices] if _LOGGER: _LOGGER.debug(retval) return retval @property - def experimental_indices(self): - """ Return the indices from self.devices of GPUs categorized as experimental """ - retval = [idx for idx, device in enumerate(self.devices) - if device not in self.supported_devices] + def _supported_indices(self): + """ list: The indices corresponding to :attr:`_ids` of GPU devices marked as + "supported". """ + retval = [idx for idx, device in enumerate(self._devices) + if device in self._supported_devices] if _LOGGER: _LOGGER.debug(retval) return retval # INITIALIZATION - def initialize(self, loglevel): - """ Initialize PlaidML """ + def _initialize(self, log_level): + """ Initialize PlaidML. + + Set PlaidML to use Faceswap's logger, and set the logging level + + Parameters + ---------- + log_level: str, optional + The requested Faceswap log level. Also dictates the level that PlaidML logging is set + at. + """ global _INIT # pylint:disable=global-statement if _INIT: if _LOGGER: @@ -104,15 +138,15 @@ def initialize(self, loglevel): return if _LOGGER: _LOGGER.debug("Initializing PlaidML") - self.set_plaidml_logger() - self.set_verbosity(loglevel) + self._set_plaidml_logger() + self._set_verbosity(log_level) _INIT = True if _LOGGER: _LOGGER.debug("Initialized PlaidML") - @staticmethod - def set_plaidml_logger(): - """ Set PlaidMLs default logger to Faceswap Logger and prevent propagation """ + @classmethod + def _set_plaidml_logger(cls): + """ Set PlaidMLs default logger to Faceswap Logger and prevent propagation. """ if _LOGGER: _LOGGER.debug("Setting PlaidML Default Logger") plaidml.DEFAULT_LOG_HANDLER = logging.getLogger("plaidml_root") @@ -120,15 +154,20 @@ def set_plaidml_logger(): if _LOGGER: _LOGGER.debug("Set PlaidML Default Logger") - @staticmethod - def set_verbosity(loglevel): - """ Set the PlaidML Verbosity """ + @classmethod + def _set_verbosity(cls, log_level): + """ Set the PlaidML logging verbosity + + log_level: str + The requested Faceswap log level. Also dictates the level that PlaidML logging is set + at. + """ if _LOGGER: - _LOGGER.debug("Setting PlaidML Loglevel: %s", loglevel) - if isinstance(loglevel, int): - numeric_level = loglevel + _LOGGER.debug("Setting PlaidML Loglevel: %s", log_level) + if isinstance(log_level, int): + numeric_level = log_level else: - numeric_level = getattr(logging, loglevel.upper(), None) + numeric_level = getattr(logging, log_level.upper(), None) if numeric_level < 10: # DEBUG Logging plaidml._internal_set_vlog(1) # pylint:disable=protected-access @@ -139,55 +178,72 @@ def set_verbosity(loglevel): # WARNING Logging plaidml.quiet() - def get_supported_devices(self): - """ Return a list of supported devices """ + def _get_supported_devices(self): + """ Obtain GPU devices from PlaidML that are marked as "supported". + + Returns + ------- + list + The :class:`pladml._DeviceConfig` objects for GPUs that PlaidML has discovered. + """ experimental_setting = plaidml.settings.experimental plaidml.settings.experimental = False - devices, _ = plaidml.devices(self.ctx, limit=100, return_all=True) + devices = plaidml.devices(self._ctx, limit=100, return_all=True)[0] plaidml.settings.experimental = experimental_setting supported = [device for device in devices - if json.loads(device.details.decode()).get("type", "cpu").lower() == "gpu"] + if device.details + and json.loads(device.details.decode()).get("type", "cpu").lower() == "gpu"] if _LOGGER: _LOGGER.debug(supported) return supported - def get_all_devices(self): - """ Return list of supported and experimental devices """ + def _get_all_devices(self): + """ Obtain all available (experimental and supported) GPU devices from PlaidML. + + Returns + ------- + list + The :class:`pladml._DeviceConfig` objects for GPUs that PlaidML has discovered. + """ experimental_setting = plaidml.settings.experimental plaidml.settings.experimental = True - devices, _ = plaidml.devices(self.ctx, limit=100, return_all=True) + devices, _ = plaidml.devices(self._ctx, limit=100, return_all=True) plaidml.settings.experimental = experimental_setting - experimental = [device for device in devices - if json.loads(device.details.decode()).get("type", "cpu").lower() == "gpu"] + experi = [device for device in devices + if device.details + and json.loads(device.details.decode()).get("type", "cpu").lower() == "gpu"] if _LOGGER: - _LOGGER.debug("Experimental Devices: %s", experimental) - all_devices = experimental + self.supported_devices + _LOGGER.debug("Experimental Devices: %s", experi) + all_devices = experi + self._supported_devices if _LOGGER: _LOGGER.debug(all_devices) return all_devices - def load_active_devices(self): - """ Load settings from PlaidML.settings.usersettings or select biggest gpu """ + def _load_active_devices(self): + """ If the plaidml user configuration settings exist, then set the default GPU from the + settings file, Otherwise set the GPU to be the one with most VRAM. """ if not os.path.exists(plaidml.settings.user_settings): # pylint:disable=no-member if _LOGGER: _LOGGER.debug("Setting largest PlaidML device") - self.set_largest_gpu() + self._set_largest_gpu() else: if _LOGGER: _LOGGER.debug("Setting PlaidML devices from user_settings") - def set_largest_gpu(self): - """ Get a supported GPU with largest VRAM. If no supported, get largest experimental """ - category = "supported" if self.supported_devices else "experimental" + def _set_largest_gpu(self): + """ Set the default GPU to be a supported device with the most available VRAM. If no + supported device is available, then set the GPU to be the an experimental device with the + most VRAM available. """ + category = "supported" if self._supported_devices else "experimental" if _LOGGER: _LOGGER.debug("Obtaining largest %s device", category) - indices = getattr(self, "{}_indices".format(category)) + indices = getattr(self, "_{}_indices".format(category)) if not indices: _LOGGER.error("Failed to automatically detect your GPU.") _LOGGER.error("Please run `plaidml-setup` to set up your GPU.") - exit() + sys.exit(1) max_vram = max([self.vram[idx] for idx in indices]) if _LOGGER: _LOGGER.debug("Max VRAM: %s", max_vram) @@ -196,7 +252,7 @@ def set_largest_gpu(self): if _LOGGER: _LOGGER.debug("GPU IDX: %s", gpu_idx) - selected_gpu = self.ids[gpu_idx] + selected_gpu = self._ids[gpu_idx] if _LOGGER: _LOGGER.info("Setting GPU to largest available %s device. If you want to override " "this selection, run `plaidml-setup` from the command line.", category) @@ -205,13 +261,27 @@ def set_largest_gpu(self): plaidml.settings.device_ids = [selected_gpu] -def setup_plaidml(loglevel): - """ Setup plaidml for AMD Cards """ +def setup_plaidml(log_level, exclude_devices): + """ Setup PlaidML for AMD Cards. + + Sets the Keras backend to PlaidML, loads the plaidML backend and makes GPU Device information + from PlaidML available to :class:`~lib.gpu_stats.GPUStats`. + + + Parameters + ---------- + log_level: str + Faceswap's log level. Used for setting the log level inside PlaidML + exclude_devices: list + A list of integers of device IDs that should not be used by Faceswap + """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name logger.info("Setting up for PlaidML") logger.verbose("Setting Keras Backend to PlaidML") + # Add explicitly excluded devices to list. The contents have already been checked in GPUStats + if exclude_devices: + _EXCLUDE_DEVICES.extend(int(idx) for idx in exclude_devices) os.environ["KERAS_BACKEND"] = "plaidml.keras.backend" - plaid = PlaidMLStats(loglevel) - plaid.load_active_devices() - logger.info("Using GPU: %s", [plaid.ids[i] for i in plaid.active_devices]) + plaid = PlaidMLStats(log_level) + logger.info("Using GPU(s): %s", [plaid.names[i] for i in plaid.active_devices]) logger.info("Successfully set up for PlaidML") diff --git a/lib/training_data.py b/lib/training_data.py index ba6cba79ed..e6bb5af9a6 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -31,56 +31,53 @@ class TrainingDataGenerator(): model_output_shapes: list A list of tuples defining the output shapes from the model, in the order that the outputs are returned. The tuples should be in (`height`, `width`, `channels`) format. - training_opts: dict - This is a dictionary of model training options as defined in - :mod:`plugins.train.model._base`. These options will be defined by the user from the - provided cli options or from the model ``config.ini``. At a minimum this ``dict`` should - contain the following keys: - - * **coverage_ratio** (`float`) - The ratio of the training image to be trained on. \ - Dictates how much of the image will be cropped out. E.G: a coverage ratio of 0.625 \ - will result in cropping a 160px box from a 256px image (256 * 0.625 = 160). - - * **augment_color** (`bool`) - ``True`` if color is to be augmented, otherwise ``False`` \ - - * **no_flip** (`bool`) - ``True`` if the image shouldn't be randomly flipped as part of \ - augmentation, otherwise ``False`` - - * **warp_to_landmarks** (`bool`) - ``True`` if the random warp method should warp to \ - similar landmarks from the other side, ``False`` if the standard random warp method \ - should be used. If ``True`` then the additional key ``landmarks`` must be provided. + coverage_ratio: float + The ratio of the training image to be trained on. Dictates how much of the image will be + cropped out. E.G: a coverage ratio of 0.625 will result in cropping a 160px box from a + 256px image (:math:`256 * 0.625 = 160`). + augment_color: bool + ``True`` if color is to be augmented, otherwise ``False`` + no_flip: bool + ``True`` if the image shouldn't be randomly flipped as part of augmentation, otherwise + ``False`` + warp_to_landmarks: bool + ``True`` if the random warp method should warp to similar landmarks from the other side, + ``False`` if the standard random warp method should be used. If ``True`` then + the key `landmarks` must be provided in the alignments dictionary. + alignments: dict + A dictionary containing landmarks and masks if these are required for training: * **landmarks** (`dict`, `optional`). Required if :attr:`warp_to_landmarks` is \ ``True``. Returning dictionary has a key of **side** (`str`) the value of which is a \ - `dict` of {**filename** (`str`): **68 point landmarks** (`numpy.ndarray`)}. + `dict` of {**filename** (`str`): **68 point landmarks** (:class:`numpy.ndarray`)}. * **masks** (`dict`, `optional`). Required if :attr:`penalized_mask_loss` or \ :attr:`learn_mask` is ``True``. Returning dictionary has a key of **side** (`str`) the \ value of which is a `dict` of {**filename** (`str`): :class:`lib.faces_detect.Mask`}. - config: dict - The configuration ``dict`` generated from :file:`config.train.ini` containing the trainer \ + The configuration `dict` generated from :file:`config.train.ini` containing the trainer \ plugin configuration options. """ - def __init__(self, model_input_size, model_output_shapes, training_opts, config): + def __init__(self, model_input_size, model_output_shapes, coverage_ratio, augment_color, + no_flip, warp_to_landmarks, alignments, config): logger.debug("Initializing %s: (model_input_size: %s, model_output_shapes: %s, " - "training_opts: %s, landmarks: %s, masks: %s, config: %s)", + "coverage_ratio: %s, augment_color: %s, no_flip: %s, warp_to_landmarks: %s, " + "alignments: %s, config: %s)", self.__class__.__name__, model_input_size, model_output_shapes, - {key: val - for key, val in training_opts.items() if key not in ("landmarks", "masks")}, - {key: len(val) - for key, val in training_opts.get("landmarks", dict()).items()}, - {key: len(val) for key, val in training_opts.get("masks", dict()).items()}, - config) + coverage_ratio, augment_color, no_flip, warp_to_landmarks, + list(alignments.keys()), config) self._config = config self._model_input_size = model_input_size self._model_output_shapes = model_output_shapes - self._training_opts = training_opts - self._landmarks = self._training_opts.get("landmarks", None) - self._masks = self._training_opts.get("masks", None) + self._coverage_ratio = coverage_ratio + self._augment_color = augment_color + self._no_flip = no_flip + self._warp_to_landmarks = warp_to_landmarks + self._landmarks = alignments.get("landmarks", None) + self._masks = alignments.get("masks", None) self._nearest_landmarks = {} - # Batchsize and processing class are set when this class is called by a batcher + # Batchsize and processing class are set when this class is called by a feeder # from lib.training_data self._batchsize = 0 self._processing = None @@ -99,8 +96,8 @@ def minibatch_ab(self, images, batchsize, side, images: list A list of image paths that will be used to compile the final augmented data from. batchsize: int - The batchsize for this iterator. Images will be returned in ``numpy.ndarray`` s of - this size from the iterator. + The batchsize for this iterator. Images will be returned in :class:`numpy.ndarray` + objects of this size from the iterator. side: {'a' or 'b'} The side of the model that this iterator is for. do_shuffle: bool, optional @@ -117,27 +114,27 @@ def minibatch_ab(self, images, batchsize, side, Yields ------ dict - The following items are contained in each ``dict`` yielded from this iterator: + The following items are contained in each `dict` yielded from this iterator: - * **feed** (`numpy.ndarray`) - The feed for the model. The array returned is in the \ - format (`batchsize`, `height`, `width`, `channels`). This is the :attr:`x` parameter \ - for :func:`keras.models.model.train_on_batch`. + * **feed** (:class:`numpy.ndarray`) - The feed for the model. The array returned is \ + in the format (`batchsize`, `height`, `width`, `channels`). This is the :attr:`x` \ + parameter for :func:`keras.models.model.train_on_batch`. - * **targets** (`list`) - A list of 4-dimensional ``numpy.ndarray`` s in the order \ - and size of each output of the model as defined in :attr:`model_output_shapes`. the \ - format of these arrays will be (`batchsize`, `height`, `width`, `3`). This is \ - the :attr:`y` parameter for :func:`keras.models.model.train_on_batch` **NB:** \ - masks are not included in the ``targets`` list. If required for feeding into the \ - Keras model, they will need to be added to this list in \ - :mod:`plugins.train.trainer._base` from the ``masks`` key. + * **targets** (`list`) - A list of 4-dimensional :class:`numpy.ndarray` objects in \ + the order and size of each output of the model as defined in \ + :attr:`model_output_shapes`. the format of these arrays will be (`batchsize`, \ + `height`, `width`, `3`). This is the :attr:`y` parameter for \ + :func:`keras.models.model.train_on_batch` **NB:** masks are not included in the \ + `targets` list. If required for feeding into the Keras model, they will need to be \ + added to this list in :mod:`plugins.train.trainer._base` from the `masks` key. - * **masks** (`numpy.ndarray`) - A 4-dimensional array containing the target masks in \ - the format (`batchsize`, `height`, `width`, `1`). + * **masks** (:class:`numpy.ndarray`) - A 4-dimensional array containing the target \ + masks in the format (`batchsize`, `height`, `width`, `1`). - * **samples** (`numpy.ndarray`) - A 4-dimensional array containing the samples for \ - feeding to the model's predict function for generating preview and time-lapse \ + * **samples** (:class:`numpy.ndarray`) - A 4-dimensional array containing the samples \ + for feeding to the model's predict function for generating preview and time-lapse \ samples. The array will be in the format (`batchsize`, `height`, `width`, \ - `channels`). **NB:** This item will only exist in the ``dict`` if :attr:`is_preview` \ + `channels`). **NB:** This item will only exist in the `dict` if :attr:`is_preview` \ or :attr:`is_timelapse` is ``True`` """ logger.debug("Queue batches: (image_count: %s, batchsize: %s, side: '%s', do_shuffle: %s, " @@ -148,7 +145,7 @@ def minibatch_ab(self, images, batchsize, side, is_preview or is_timelapse, self._model_input_size, self._model_output_shapes, - self._training_opts.get("coverage_ratio", 0.625), + self._coverage_ratio, self._config) args = (images, side, do_shuffle, batchsize) batcher = BackgroundGenerator(self._minibatch, thread_count=2, args=args) @@ -203,7 +200,7 @@ def _process_batch(self, filenames, side): self._processing.initialize(batch.shape[1]) # Get Landmarks prior to manipulating the image - if self._training_opts["warp_to_landmarks"]: + if self._warp_to_landmarks: batch_src_pts = self._get_landmarks(filenames, side) batch_dst_pts = self._get_closest_match(filenames, side, batch_src_pts) warp_kwargs = dict(batch_src_points=batch_src_pts, @@ -212,12 +209,12 @@ def _process_batch(self, filenames, side): warp_kwargs = dict() # Color Augmentation of the image only - if self._training_opts["augment_color"]: + if self._augment_color: batch[..., :3] = self._processing.color_adjust(batch[..., :3]) # Random Transform and flip batch = self._processing.transform(batch) - if not self._training_opts["no_flip"]: + if not self._no_flip: batch = self._processing.random_flip(batch) # Add samples to output if this is for display @@ -229,7 +226,7 @@ def _process_batch(self, filenames, side): # Random Warp # TODO change masks to have a input mask and a warped target mask processed["feed"] = [self._processing.warp(batch[..., :3], - self._training_opts["warp_to_landmarks"], + self._warp_to_landmarks, **warp_kwargs)] logger.trace("Processed batch: (filenames: %s, side: '%s', processed: %s)", @@ -276,7 +273,7 @@ def _resize_masks(target_size, masks): def _get_landmarks(self, filenames, side): """ Obtains the 68 Point Landmarks for the images in this batch. This is only called if - config item ``warp_to_landmarks`` is ``True``. If the landmarks for an image cannot be + config :attr:`_warp_to_landmarks` is ``True``. If the landmarks for an image cannot be found, then an error is raised. """ logger.trace("Retrieving landmarks: (filenames: %s, side: '%s')", filenames, side) src_points = [self._landmarks[side].get(filename, None) for filename in filenames] @@ -298,7 +295,7 @@ def _get_landmarks(self, filenames, side): return np.array(src_points) def _get_closest_match(self, filenames, side, batch_src_points): - """ Only called if the config item ``warp_to_landmarks`` is ``True``. Gets the closest + """ Only called if the :attr:`_warp_to_landmarks` is ``True``. Gets the closest matched 68 point landmarks from the opposite training set. """ logger.trace("Retrieving closest matched landmarks: (filenames: '%s', src_points: '%s'", filenames, batch_src_points) @@ -346,10 +343,10 @@ class ImageAugmentation(): are returned. The tuples should be in (`height`, `width`, `channels`) format. coverage_ratio: float The ratio of the training image to be trained on. Dictates how much of the image will be - cropped out. E.G: a coverage ratio of 0.625 will result in cropping a 160px box from a " - "256px image (256 * 0.625 = 160). + cropped out. E.G: a coverage ratio of 0.625 will result in cropping a 160px box from a + 256px image (:math:`256 * 0.625 = 160`) config: dict - The configuration ``dict`` generated from :file:`config.train.ini` containing the trainer \ + The configuration `dict` generated from :file:`config.train.ini` containing the trainer plugin configuration options. Attributes @@ -359,7 +356,7 @@ class ImageAugmentation(): image size in order to cache certain augmentation operations (see :func:`initialize`) is_display: bool Flag to indicate whether these augmentations are for time-lapses/preview images (``True``) - or standard training data (``False)`` + or standard training data (``False``) """ def __init__(self, batchsize, is_display, input_size, output_shapes, coverage_ratio, config): logger.debug("Initializing %s: (batchsize: %s, is_display: %s, input_size: %s, " @@ -390,8 +387,8 @@ def initialize(self, training_size): """ Initializes the caching of constants for use in various image augmentations. The training image size is not known prior to loading the images from disk and commencing - training, so it cannot be set in the ``__init__`` method. When the first training batch is - loaded this function should be called to initialize the class and perform various + training, so it cannot be set in the :func:`__init__` method. When the first training batch + is loaded this function should be called to initialize the class and perform various calculations based on this input size to cache certain constants for image augmentation calculations. @@ -448,7 +445,7 @@ def get_targets(self, batch): Parameters ---------- - batch: numpy.ndarray + batch: :class:`numpy.ndarray` This should be a 4-dimensional array of training images in the format (`batchsize`, `height`, `width`, `channels`). Targets should be requested after performing image transformations but prior to performing warps. @@ -458,16 +455,16 @@ def get_targets(self, batch): dict The following keys will be within the returned dictionary: - * **targets** (`list`) - A list of 4-dimensional ``numpy.ndarray`` s in the order \ - and size of each output of the model as defined in :attr:`output_shapes`. The \ + * **targets** (`list`) - A list of 4-dimensional :class:`numpy.ndarray` s in the \ + order and size of each output of the model as defined in :attr:`output_shapes`. The \ format of these arrays will be (`batchsize`, `height`, `width`, `3`). **NB:** \ - masks are not included in the ``targets`` list. If masks are to be included in the \ - output they will be returned as their own item from the ``masks`` key. + masks are not included in the `targets` list. If masks are to be included in the \ + output they will be returned as their own item from the `masks` key. - * **masks** (`numpy.ndarray`) - A 4-dimensional array containing the target masks in \ - the format (`batchsize`, `height`, `width`, `1`). + * **masks** (:class:`numpy.ndarray`) - A 4-dimensional array containing the target \ + masks in the format (`batchsize`, `height`, `width`, `1`). """ - logger.trace("Compiling targets") + logger.trace("Compiling targets: batch shape: %s", batch.shape) slices = self._constants["tgt_slices"] target_batch = [np.array([cv2.resize(image[slices, slices, :], (size, size), @@ -487,8 +484,8 @@ def get_targets(self, batch): def _separate_target_mask(target_batch): """ Return the batch and the batch of final masks - Returns the targets as a list of 4-dimensional ``numpy.ndarray`` s of shape (`batchsize`, - `height`, `width`, 3). + Returns the targets as a list of 4-dimensional :class:`numpy.ndarray` s of shape + (`batchsize`, `height`, `width`, `3`). The target masks are returned as its own item and is the 4th channel of the final target output. @@ -507,13 +504,13 @@ def color_adjust(self, batch): Parameters ---------- - batch: numpy.ndarray + batch: :class:`numpy.ndarray` The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `3`) and in `BGR` format. Returns ---------- - numpy.ndarray + :class:`numpy.ndarray` A 4-dimensional array of the same shape as :attr:`batch` with color augmentation applied. """ @@ -575,13 +572,13 @@ def transform(self, batch): Parameters ---------- - batch: numpy.ndarray + batch: :class:`numpy.ndarray` The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `channels`) and in `BGR` format. Returns ---------- - numpy.ndarray + :class:`numpy.ndarray` A 4-dimensional array of the same shape as :attr:`batch` with transformation applied. """ if self.is_display: @@ -625,13 +622,13 @@ def random_flip(self, batch): Parameters ---------- - batch: numpy.ndarray + batch: :class:`numpy.ndarray` The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `channels`) and in `BGR` format. Returns ---------- - numpy.ndarray + :class:`numpy.ndarray` A 4-dimensional array of the same shape as :attr:`batch` with transformation applied. """ if not self.is_display: @@ -647,7 +644,7 @@ def warp(self, batch, to_landmarks=False, **kwargs): Parameters ---------- - batch: numpy.ndarray + batch: :class:`numpy.ndarray` The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `3`) and in `BGR` format. to_landmarks: bool, optional @@ -657,15 +654,15 @@ def warp(self, batch, to_landmarks=False, **kwargs): kwargs: dict If :attr:`to_landmarks` is ``True`` the following additional kwargs must be passed in: - * **batch_src_points** (`numpy.ndarray`) - A batch of 68 point landmarks for the \ - source faces. This is a 3-dimensional array in the shape (`batchsize`, `68`, `2`). + * **batch_src_points** (:class:`numpy.ndarray`) - A batch of 68 point landmarks for \ + the source faces. This is a 3-dimensional array in the shape (`batchsize`, `68`, `2`). - * **batch_dst_points** (`numpy.ndarray`) - A batch of randomly chosen closest match \ - destination faces landmarks. This is a 3-dimensional array in the shape (`batchsize`, \ - `68`, `2`). + * **batch_dst_points** (:class:`numpy.ndarray`) - A batch of randomly chosen closest \ + match destination faces landmarks. This is a 3-dimensional array in the shape \ + (`batchsize`, `68`, `2`). Returns ---------- - numpy.ndarray + :class:`numpy.ndarray` A 4-dimensional array of the same shape as :attr:`batch` with warping applied. """ if to_landmarks: @@ -719,10 +716,10 @@ def _random_warp_landmarks(self, batch, batch_src_points, batch_dst_points): grid_z = np.array([griddata(dst, src, (grids[0], grids[1]), method="linear") for src, dst in zip(batch_src, batch_dst)]) - maps = grid_z.reshape(self._batchsize, - self._training_size, - self._training_size, - 2).astype("float32") + maps = grid_z.reshape((self._batchsize, + self._training_size, + self._training_size, + 2)).astype("float32") warped_batch = np.array([cv2.remap(image, map_[..., 1], map_[..., 0], diff --git a/lib/utils.py b/lib/utils.py index b74760a4e6..6ff2ed2d46 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -1,6 +1,7 @@ #!/usr/bin python3 """ Utilities available across all scripts """ +import importlib import json import logging import os @@ -24,26 +25,39 @@ ".ts", ".vob"] -class Backend(): +class _Backend(): # pylint:disable=too-few-public-methods """ Return the backend from config/.faceswap of from the `FACESWAP_BACKEND` Environment Variable. If file doesn't exist and a variable hasn't been set, create the config file. """ def __init__(self): - self.backends = {"1": "amd", "2": "cpu", "3": "nvidia"} - self.config_file = self.get_config_file() - self.backend = self.get_backend() - - @staticmethod - def get_config_file(): - """ Return location of config file """ + self._backends = {"1": "amd", "2": "cpu", "3": "nvidia"} + self._config_file = self._get_config_file() + self.backend = self._get_backend() + + @classmethod + def _get_config_file(cls): + """ Obtain the location of the main Faceswap configuration file. + + Returns + ------- + str + The path to the Faceswap configuration file + """ pypath = os.path.dirname(os.path.realpath(sys.argv[0])) config_file = os.path.join(pypath, "config", ".faceswap") return config_file - def get_backend(self): + def _get_backend(self): """ Return the backend from either the `FACESWAP_BACKEND` Environment Variable or from - the :loc:`config/.faceswap` configuration file. """ + the :file:`config/.faceswap` configuration file. If neither of these exist, prompt the user + to select a backend. + + Returns + ------- + str + The backend configuration in use by Faceswap + """ # Check if environment variable is set, if so use that if "FACESWAP_BACKEND" in os.environ: fs_backend = os.environ["FACESWAP_BACKEND"].lower() @@ -53,26 +67,31 @@ def get_backend(self): # Intercept for sphinx docs build if sys.argv[0].endswith("sphinx-build"): return "nvidia" - if not os.path.isfile(self.config_file): - self.configure_backend() + if not os.path.isfile(self._config_file): + self._configure_backend() while True: try: - with open(self.config_file, "r") as cnf: + with open(self._config_file, "r") as cnf: config = json.load(cnf) break except json.decoder.JSONDecodeError: - self.configure_backend() + self._configure_backend() continue fs_backend = config.get("backend", None) - if fs_backend is None or fs_backend.lower() not in self.backends.values(): - fs_backend = self.configure_backend() + if fs_backend is None or fs_backend.lower() not in self._backends.values(): + fs_backend = self._configure_backend() if current_process().name == "MainProcess": print("Setting Faceswap backend to {}".format(fs_backend.upper())) return fs_backend.lower() - def configure_backend(self): - """ Configure the backend if config file doesn't exist or there is a - problem with the file """ + def _configure_backend(self): + """ Get user input to select the backend that Faceswap should use. + + Returns + ------- + str + The backend configuration in use by Faceswap + """ print("First time configuration. Please select the required backend") while True: selection = input("1: AMD, 2: CPU, 3: NVIDIA: ") @@ -80,24 +99,57 @@ def configure_backend(self): print("'{}' is not a valid selection. Please try again".format(selection)) continue break - fs_backend = self.backends[selection].lower() + fs_backend = self._backends[selection].lower() config = {"backend": fs_backend} - with open(self.config_file, "w") as cnf: + with open(self._config_file, "w") as cnf: json.dump(config, cnf) - print("Faceswap config written to: {}".format(self.config_file)) + print("Faceswap config written to: {}".format(self._config_file)) return fs_backend -_FS_BACKEND = Backend().backend +_FS_BACKEND = _Backend().backend def get_backend(): - """ Return the faceswap backend """ + """ Get the backend that Faceswap is currently configured to use. + + Returns + ------- + str + The backend configuration in use by Faceswap + """ return _FS_BACKEND +def set_backend(backend): + """ Override the configured backend with the given backend. + + Parameters + ---------- + backend: ["amd", "cpu", "nvidia"] + The backend to set faceswap to + """ + global _FS_BACKEND # pylint:disable=global-statement + _FS_BACKEND = backend.lower() + + def get_folder(path, make_folder=True): - """ Return a path to a folder, creating it if it doesn't exist """ + """ Return a path to a folder, creating it if it doesn't exist + + Parameters + ---------- + path: str + The path to the folder to obtain + make_folder: bool, optional + ``True`` if the folder should be created if it does not already exist, ``False`` if the + folder should not be created + + Returns + ------- + :class:`pathlib.Path` or `None` + The path to the requested folder. If `make_folder` is set to ``False`` and the requested + path does not exist, then ``None`` is returned + """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name logger.debug("Requested path: '%s'", path) output_dir = Path(path) @@ -110,7 +162,18 @@ def get_folder(path, make_folder=True): def get_image_paths(directory): - """ Return a list of images that reside in a folder """ + """ Obtain a list of full paths that reside within a folder. + + Parameters + ---------- + directory: str + The folder that contains the images to be returned + + Returns + ------- + list + The list of full paths to the images contained within the given folder + """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name image_extensions = _image_extensions dir_contents = list() @@ -134,8 +197,19 @@ def get_image_paths(directory): def convert_to_secs(*args): - """ converts a time to second. Either convert_to_secs(min, secs) or - convert_to_secs(hours, minutes, secs). """ + """ Convert a time to seconds. + + Parameters + ---------- + args: tuple + 2 or 3 ints. If 2 ints are supplied, then (`minutes`, `seconds`) is implied. If 3 ints are + supplied then (`hours`, `minutes`, `seconds`) is implied. + + Returns + ------- + int + The given time converted to seconds + """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name logger.debug("from time: %s", args) retval = 0.0 @@ -150,7 +224,24 @@ def convert_to_secs(*args): def full_path_split(path): - """ Split a given path into all of it's separate components """ + """ Split a full path to a location into all of it's separate components. + + Parameters + ---------- + path: str + The full path to be split + + Returns + ------- + list + The full path split into a separate item for each part + + Example + ------- + >>> path = "/foo/baz/bar" + >>> full_path_split(path) + >>> ["foo", "baz", "bar"] + """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name allparts = list() while True: @@ -167,47 +258,47 @@ def full_path_split(path): return allparts -def backup_file(directory, filename): - """ Backup a given file by appending .bk to the end """ - logger = logging.getLogger(__name__) # pylint:disable=invalid-name - logger.trace("Backing up: '%s'", filename) - origfile = os.path.join(directory, filename) - backupfile = origfile + '.bk' - if os.path.exists(backupfile): - logger.trace("Removing existing file: '%s'", backup_file) - os.remove(backupfile) - if os.path.exists(origfile): - logger.trace("Renaming: '%s' to '%s'", origfile, backup_file) - os.rename(origfile, backupfile) - - -def set_system_verbosity(loglevel): - """ Set the verbosity level of tensorflow and suppresses - future and deprecation warnings from any modules - From: - https://stackoverflow.com/questions/35911252/disable-tensorflow-debugging-information - Can be set to: - 0 - all logs shown - 1 - filter out INFO logs - 2 - filter out WARNING logs - 3 - filter out ERROR logs """ +def set_system_verbosity(log_level): + """ Set the verbosity level of tensorflow and suppresses future and deprecation warnings from + any modules + + Parameters + ---------- + log_level: str + The requested Faceswap log level + + References + ---------- + https://stackoverflow.com/questions/35911252/disable-tensorflow-debugging-information + Can be set to: + 0: all logs shown. 1: filter out INFO logs. 2: filter out WARNING logs. 3: filter out ERROR + logs. + """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name from lib.logger import get_loglevel # pylint:disable=import-outside-toplevel - numeric_level = get_loglevel(loglevel) - loglevel = "2" if numeric_level > 15 else "0" - logger.debug("System Verbosity level: %s", loglevel) - os.environ['TF_CPP_MIN_LOG_LEVEL'] = loglevel - if loglevel != '0': + numeric_level = get_loglevel(log_level) + log_level = "2" if numeric_level > 15 else "0" + logger.debug("System Verbosity level: %s", log_level) + os.environ['TF_CPP_MIN_LOG_LEVEL'] = log_level + if log_level != '0': for warncat in (FutureWarning, DeprecationWarning, UserWarning): warnings.simplefilter(action='ignore', category=warncat) -def deprecation_warning(func_name, additional_info=None): - """ Log at warning level that a function will be removed in future """ +def deprecation_warning(function, additional_info=None): + """ Log at warning level that a function will be removed in a future update. + + Parameters + ---------- + function: str + The function that will be deprecated. + additional_info: str, optional + Any additional information to display with the deprecation message. Default: ``None`` + """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name - logger.debug("func_name: %s, additional_info: %s", func_name, additional_info) - msg = "{} has been deprecated and will be removed from a future update.".format(func_name) + logger.debug("func_name: %s, additional_info: %s", function, additional_info) + msg = "{} has been deprecated and will be removed from a future update.".format(function) if additional_info is not None: msg += " {}".format(additional_info) logger.warning(msg) @@ -215,7 +306,22 @@ def deprecation_warning(func_name, additional_info=None): def camel_case_split(identifier): """ Split a camel case name - from: https://stackoverflow.com/questions/29916065 """ + + Parameters + ---------- + identifier: str + The camel case text to be split + + Returns + ------- + list + A list of the given identifier split into it's constituent parts + + + References + ---------- + https://stackoverflow.com/questions/29916065 + """ matches = finditer( ".+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)", identifier) @@ -223,7 +329,14 @@ def camel_case_split(identifier): def safe_shutdown(got_error=False): - """ Close queues, threads and processes in event of crash """ + """ Close all tracked queues and threads in event of crash or on shut down. + + Parameters + ---------- + got_error: bool, optional + ``True`` if this function is being called as the result of raised error, otherwise + ``False``. Default: ``False`` + """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name logger.debug("Safely shutting down") from lib.queue_manager import queue_manager # pylint:disable=import-outside-toplevel @@ -233,97 +346,107 @@ def safe_shutdown(got_error=False): class FaceswapError(Exception): - """ Faceswap Error for handling specific errors with useful information """ + """ Faceswap Error for handling specific errors with useful information. + + Raises + ------ + FaceswapError + on a captured error + """ pass # pylint:disable=unnecessary-pass -class GetModel(): - """ Check for models in their cache path - If available, return the path, if not available, get, unzip and install model +class GetModel(): # Pylint:disable=too-few-public-methods + """ Check for models in their cache path. - model_filename: The name of the model to be loaded (see notes below) - cache_dir: The model cache folder of the current plugin calling this class - IE: The folder that holds the model to be loaded. - git_model_id: The second digit in the github tag that identifies this model. - See https://github.com/deepfakes-models/faceswap-models for more - information + If available, return the path, if not available, get, unzip and install model - NB: Models must have a certain naming convention: - IE: _v. - EG: s3fd_v1.pb + Parameters + ---------- + model_filename: str or list + The name of the model to be loaded (see notes below) + cache_dir: str + The model cache folder of the current plugin calling this class. IE: The folder that holds + the model to be loaded. + git_model_id: int + The second digit in the github tag that identifies this model. See + https://github.com/deepfakes-models/faceswap-models for more information - Multiple models can exist within the model_filename. They should be passed as a list - and follow the same naming convention as above. Any differences in filename should - occur AFTER the version number. - IE: [_v.] - EG: [mtcnn_det_v1.1.py, mtcnn_det_v1.2.py, mtcnn_det_v1.3.py] - [resnet_ssd_v1.caffemodel, resnet_ssd_v1.prototext] - """ + Notes + ------ + Models must have a certain naming convention: `_v.` + (eg: `s3fd_v1.pb`). + + Multiple models can exist within the model_filename. They should be passed as a list and follow + the same naming convention as above. Any differences in filename should occur AFTER the version + number: `_v.` (eg: + `["mtcnn_det_v1.1.py", "mtcnn_det_v1.2.py", "mtcnn_det_v1.3.py"]`, `["resnet_ssd_v1.caffemodel" + ,"resnet_ssd_v1.prototext"]` + """ def __init__(self, model_filename, cache_dir, git_model_id): - self.logger = logging.getLogger(__name__) # pylint:disable=invalid-name + self.logger = logging.getLogger(__name__) if not isinstance(model_filename, list): model_filename = [model_filename] - self.model_filename = model_filename - self.cache_dir = cache_dir - self.git_model_id = git_model_id - self.url_base = "https://github.com/deepfakes-models/faceswap-models/releases/download" - self.chunk_size = 1024 # Chunk size for downloading and unzipping - self.retries = 6 - self.get() - self.model_path = self._model_path + self._model_filename = model_filename + self._cache_dir = cache_dir + self._git_model_id = git_model_id + self._url_base = "https://github.com/deepfakes-models/faceswap-models/releases/download" + self._chunk_size = 1024 # Chunk size for downloading and unzipping + self._retries = 6 + self._get() @property def _model_full_name(self): - """ Return the model full name from the filename(s) """ - common_prefix = os.path.commonprefix(self.model_filename) + """ str: The full model name from the filename(s). """ + common_prefix = os.path.commonprefix(self._model_filename) retval = os.path.splitext(common_prefix)[0] self.logger.trace(retval) return retval @property def _model_name(self): - """ Return the model name from the model full name """ + """ str: The model name from the model's full name. """ retval = self._model_full_name[:self._model_full_name.rfind("_")] self.logger.trace(retval) return retval @property def _model_version(self): - """ Return the model version from the model full name """ + """ int: The model's version number from the model full name. """ retval = int(self._model_full_name[self._model_full_name.rfind("_") + 2:]) self.logger.trace(retval) return retval @property - def _model_path(self): - """ Return the model path(s) in the cache folder """ - retval = [os.path.join(self.cache_dir, fname) for fname in self.model_filename] + def model_path(self): + """ str: The model path(s) in the cache folder. """ + retval = [os.path.join(self._cache_dir, fname) for fname in self._model_filename] retval = retval[0] if len(retval) == 1 else retval self.logger.trace(retval) return retval @property def _model_zip_path(self): - """ Full path to downloaded zip file """ - retval = os.path.join(self.cache_dir, "{}.zip".format(self._model_full_name)) + """ str: The full path to downloaded zip file. """ + retval = os.path.join(self._cache_dir, "{}.zip".format(self._model_full_name)) self.logger.trace(retval) return retval @property def _model_exists(self): - """ Check model(s) exist """ - if isinstance(self._model_path, list): - retval = all(os.path.exists(pth) for pth in self._model_path) + """ bool: ``True`` if the model exists in the cache folder otherwise ``False``. """ + if isinstance(self.model_path, list): + retval = all(os.path.exists(pth) for pth in self.model_path) else: - retval = os.path.exists(self._model_path) + retval = os.path.exists(self.model_path) self.logger.trace(retval) return retval @property def _plugin_section(self): - """ Get the plugin section from the config_dir """ - path = os.path.normpath(self.cache_dir) + """ str: The plugin section from the config_dir """ + path = os.path.normpath(self._cache_dir) split = path.split(os.sep) retval = split[split.index("plugins") + 1] self.logger.trace(retval) @@ -331,7 +454,7 @@ def _plugin_section(self): @property def _url_section(self): - """ Return the section ID in github for this plugin type """ + """ int: The section ID in github for this plugin type. """ sections = dict(extract=1, train=2, convert=3) retval = sections[self._plugin_section] self.logger.trace(retval) @@ -339,33 +462,34 @@ def _url_section(self): @property def _url_download(self): - """ Base URL for models """ - tag = "v{}.{}.{}".format(self._url_section, self.git_model_id, self._model_version) - retval = "{}/{}/{}.zip".format(self.url_base, tag, self._model_full_name) + """ strL Base download URL for models. """ + tag = "v{}.{}.{}".format(self._url_section, self._git_model_id, self._model_version) + retval = "{}/{}/{}.zip".format(self._url_base, tag, self._model_full_name) self.logger.trace("Download url: %s", retval) return retval @property def _url_partial_size(self): - """ Return how many bytes have already been downloaded """ + """ float: How many bytes have already been downloaded. """ zip_file = self._model_zip_path retval = os.path.getsize(zip_file) if os.path.exists(zip_file) else 0 self.logger.trace(retval) return retval - def get(self): - """ Check the model exists, if not, download and unzip into location """ + def _get(self): + """ Check the model exists, if not, download the model, unzip it and place it in the + model's cache folder. """ if self._model_exists: - self.logger.debug("Model exists: %s", self._model_path) + self.logger.debug("Model exists: %s", self.model_path) return - self.download_model() - self.unzip_model() + self._download_model() + self._unzip_model() os.remove(self._model_zip_path) - def download_model(self): - """ Download model zip to cache folder """ + def _download_model(self): + """ Download the model zip from github to the cache folder. """ self.logger.info("Downloading model: '%s' from: %s", self._model_name, self._url_download) - for attempt in range(self.retries): + for attempt in range(self._retries): try: downloaded_size = self._url_partial_size req = urllib.request.Request(self._url_download) @@ -374,24 +498,32 @@ def download_model(self): response = urllib.request.urlopen(req, timeout=10) self.logger.debug("header info: {%s}", response.info()) self.logger.debug("Return Code: %s", response.getcode()) - self.write_zipfile(response, downloaded_size) + self._write_zipfile(response, downloaded_size) break except (socket_error, socket_timeout, urllib.error.HTTPError, urllib.error.URLError) as err: - if attempt + 1 < self.retries: + if attempt + 1 < self._retries: self.logger.warning("Error downloading model (%s). Retrying %s of %s...", - str(err), attempt + 2, self.retries) + str(err), attempt + 2, self._retries) else: self.logger.error("Failed to download model. Exiting. (Error: '%s', URL: " "'%s')", str(err), self._url_download) self.logger.info("You can try running again to resume the download.") self.logger.info("Alternatively, you can manually download the model from: %s " "and unzip the contents to: %s", - self._url_download, self.cache_dir) + self._url_download, self._cache_dir) sys.exit(1) - def write_zipfile(self, response, downloaded_size): - """ Write the model zip file to disk """ + def _write_zipfile(self, response, downloaded_size): + """ Write the model zip file to disk. + + Parameters + ---------- + response: :class:`urllib.request.urlopen` + The response from the model download task + downloaded_size: int + The amount of bytes downloaded so far + """ length = int(response.getheader("content-length")) + downloaded_size if length == downloaded_size: self.logger.info("Zip already exists. Skipping download") @@ -406,25 +538,31 @@ def write_zipfile(self, response, downloaded_size): if downloaded_size != 0: pbar.update(downloaded_size) while True: - buffer = response.read(self.chunk_size) + buffer = response.read(self._chunk_size) if not buffer: break pbar.update(len(buffer)) out_file.write(buffer) pbar.close() - def unzip_model(self): + def _unzip_model(self): """ Unzip the model file to the cache folder """ self.logger.info("Extracting: '%s'", self._model_name) try: zip_file = zipfile.ZipFile(self._model_zip_path, "r") - self.write_model(zip_file) + self._write_model(zip_file) except Exception as err: # pylint:disable=broad-except self.logger.error("Unable to extract model file: %s", str(err)) sys.exit(1) - def write_model(self, zip_file): - """ Extract files from zip file and write, with progress bar """ + def _write_model(self, zip_file): + """ Extract files from zip file and write, with progress bar. + + Parameters + ---------- + zip_file: str + The downloaded model zip file + """ length = sum(f.file_size for f in zip_file.infolist()) fnames = zip_file.namelist() self.logger.debug("Zipfile: Filenames: %s, Total Size: %s", fnames, length) @@ -434,15 +572,85 @@ def write_model(self, zip_file): unit_scale=True, unit_divisor=1024) for fname in fnames: - out_fname = os.path.join(self.cache_dir, fname) + out_fname = os.path.join(self._cache_dir, fname) self.logger.debug("Extracting from: '%s' to '%s'", self._model_zip_path, out_fname) zipped = zip_file.open(fname) with open(out_fname, "wb") as out_file: while True: - buffer = zipped.read(self.chunk_size) + buffer = zipped.read(self._chunk_size) if not buffer: break pbar.update(len(buffer)) out_file.write(buffer) zip_file.close() pbar.close() + + +class KerasFinder(importlib.abc.MetaPathFinder): + """ Importlib Abstract Base Class for intercepting the import of Keras and returning either + Keras (AMD backend) or tensorflow.keras (any other backend). + + The Importlib documentation is sparse at best, and real world examples are pretty much + non-existent. Coupled with this, the import ``tensorflow.keras`` does not resolve so we need + to split out to the actual location of Keras within ``tensorflow_core``. This method works, but + it relies on hard coded paths, and is likely to not be the most robust. + + A custom loader is not used, as we can use the standard loader once we have returned the + correct spec. + """ + def __init__(self): + self._logger = logging.getLogger(__name__) + self._backend = get_backend() + self._tf_keras_locations = [["tensorflow_core", "python", "keras", "api", "_v2"], + ["tensorflow", "python", "keras", "api", "_v2"]] + + def find_spec(self, fullname, path, target=None): # pylint:disable=unused-argument + """ Obtain the spec for either keras or tensorflow.keras depending on the backend in use. + + If keras is not passed in as part of the :attr:`fullname` or the path is not ``None`` + (i.e this is a dependency import) then this returns ``None`` to use the standard import + library. + + Parameters + ---------- + fullname: str + The absolute name of the module to be imported + path: str + The search path for the module + target: module object, optional + Inherited from parent but unused + + Returns + ------- + :class:`importlib.ModuleSpec` + The spec for the Keras module to be imported + """ + prefix = fullname.split(".")[0] + suffix = fullname.split(".")[-1] + if prefix != "keras" or path is not None: + return None + self._logger.debug("Importing '%s' as keras for backend: '%s'", + "keras" if self._backend == "amd" else "tf.keras", self._backend) + path = sys.path if path is None else path + for entry in path: + locations = ([os.path.join(entry, *location) + for location in self._tf_keras_locations] + if self._backend != "amd" else [entry]) + for location in locations: + self._logger.debug("Scanning: '%s' for '%s'", location, suffix) + if os.path.isdir(os.path.join(location, suffix)): + filename = os.path.join(location, suffix, "__init__.py") + submodule_locations = [os.path.join(location, suffix)] + else: + filename = os.path.join(location, suffix + ".py") + submodule_locations = None + if not os.path.exists(filename): + continue + retval = importlib.util.spec_from_file_location( + fullname, + filename, + submodule_search_locations=submodule_locations) + self._logger.debug("Found spec: %s", retval) + return retval + self._logger.debug("Spec not found for '%s'. Falling back to default import", fullname) + return None diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 005c12e6c0..3c104a247a 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -60,9 +60,9 @@ class Extractor(): https://github.com/deepfakes-models/faceswap-models for more information model_filename: str The name of the model file to be loaded - - Other Parameters - ---------------- + exclude_gpus: list, optional + A list of indices correlating to connected GPUs that Tensorflow should not use. Pass + ``None`` to not exclude any GPUs. Default: ``None`` configfile: str, optional Path to a custom configuration ``ini`` file. Default: Use system configfile instance: int, optional @@ -101,12 +101,14 @@ class Extractor(): plugins.extract.pipeline : The extract pipeline that configures and calls all plugins """ - def __init__(self, git_model_id=None, model_filename=None, configfile=None, instance=0): - logger.debug("Initializing %s: (git_model_id: %s, model_filename: %s, instance: %s, " - "configfile: %s, )", self.__class__.__name__, git_model_id, model_filename, - instance, configfile) + def __init__(self, git_model_id=None, model_filename=None, exclude_gpus=None, configfile=None, + instance=0): + logger.debug("Initializing %s: (git_model_id: %s, model_filename: %s, exclude_gpus: %s, " + "configfile: %s, instance: %s, )", self.__class__.__name__, git_model_id, + model_filename, exclude_gpus, configfile, instance) self._instance = instance + self._exclude_gpus = exclude_gpus self.config = _get_config(".".join(self.__module__.split(".")[-2:]), configfile=configfile) """ dict: Config for this plugin, loaded from ``extract.ini`` configfile """ diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index e01641872f..d089ab901e 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -50,13 +50,14 @@ class Aligner(Extractor): # pylint:disable=abstract-method """ def __init__(self, git_model_id=None, model_filename=None, - configfile=None, instance=0, normalize_method=None): + configfile=None, instance=0, normalize_method=None, **kwargs): logger.debug("Initializing %s: (normalize_method: %s)", self.__class__.__name__, normalize_method) super().__init__(git_model_id, model_filename, configfile=configfile, - instance=instance) + instance=instance, + **kwargs) self._normalize_method = None self.set_normalize_method(normalize_method) diff --git a/plugins/extract/align/fan.py b/plugins/extract/align/fan.py index 3be1d14511..79739b09cd 100644 --- a/plugins/extract/align/fan.py +++ b/plugins/extract/align/fan.py @@ -5,8 +5,6 @@ """ import cv2 import numpy as np -import keras -from keras import backend as K from lib.model.session import KSession from ._base import Aligner, logger @@ -15,8 +13,8 @@ class Align(Aligner): """ Perform transformation to align and get landmarks """ def __init__(self, **kwargs): - git_model_id = 9 - model_filename = "face-alignment-network_2d4_keras_v1.h5" + git_model_id = 13 + model_filename = "face-alignment-network_2d4_keras_v2.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.name = "FAN" self.input_size = 256 @@ -29,14 +27,13 @@ def __init__(self, **kwargs): def init_model(self): """ Initialize FAN model """ - model_kwargs = dict(custom_objects={'TorchBatchNorm2D': TorchBatchNorm2D}) self.model = KSession(self.name, self.model_path, - model_kwargs=model_kwargs, - allow_growth=self.config["allow_growth"]) + allow_growth=self.config["allow_growth"], + exclude_gpus=self._exclude_gpus) self.model.load_model() # Feed a placeholder so Aligner is primed for Manual tool - placeholder_shape = (self.batchsize, 3, self.input_size, self.input_size) + placeholder_shape = (self.batchsize, self.input_size, self.input_size, 3) placeholder = np.zeros(placeholder_shape, dtype="float32") self.model.predict(placeholder) @@ -47,7 +44,7 @@ def process_input(self, batch): faces = self.crop(batch) logger.trace("Aligned image around center") faces = self._normalize_faces(faces) - batch["feed"] = np.array(faces, dtype="float32")[..., :3].transpose((0, 3, 1, 2)) / 255.0 + batch["feed"] = np.array(faces, dtype="float32")[..., :3] / 255.0 return batch def get_center_scale(self, detected_faces): @@ -122,8 +119,10 @@ def transform(points, center_scales, resolutions): def predict(self, batch): """ Predict the 68 point landmarks """ logger.debug("Predicting Landmarks") - batch["prediction"] = self.model.predict(batch["feed"])[-1] - logger.trace([pred.shape for pred in batch["prediction"]]) + # TODO Remove lazy transpose and change points from predict to use the correct + # order + batch["prediction"] = self.model.predict(batch["feed"])[-1].transpose(0, 3, 1, 2) + logger.trace(batch["prediction"].shape) return batch def process_output(self, batch): @@ -156,77 +155,3 @@ def get_pts_from_predict(self, batch): batch["landmarks"] = self.transform(subpixel_landmarks, batch["center_scale"], resolution) logger.trace("Obtained points from prediction: %s", batch["landmarks"]) - - -class TorchBatchNorm2D(keras.engine.base_layer.Layer): - # pylint:disable=too-many-instance-attributes - """" Required for FAN_keras model """ - def __init__(self, axis=-1, momentum=0.99, epsilon=1e-3, **kwargs): - super(TorchBatchNorm2D, self).__init__(**kwargs) - self.supports_masking = True - self.axis = axis - self.momentum = momentum - self.epsilon = epsilon - self._epsilon_const = K.constant(self.epsilon, dtype='float32') - - self.built = False - self.gamma = None - self.beta = None - self.moving_mean = None - self.moving_variance = None - - def build(self, input_shape): - dim = input_shape[self.axis] - if dim is None: - raise ValueError("Axis {} of input tensor should have a " - "defined dimension but the layer received " - "an input with shape {}." - .format(str(self.axis), str(input_shape))) - shape = (dim,) - self.gamma = self.add_weight(shape=shape, - name='gamma', - initializer='ones', - regularizer=None, - constraint=None) - self.beta = self.add_weight(shape=shape, - name='beta', - initializer='zeros', - regularizer=None, - constraint=None) - self.moving_mean = self.add_weight(shape=shape, - name='moving_mean', - initializer='zeros', - trainable=False) - self.moving_variance = self.add_weight(shape=shape, - name='moving_variance', - initializer='ones', - trainable=False) - self.built = True - - def call(self, inputs, **kwargs): - input_shape = K.int_shape(inputs) - - broadcast_shape = [1] * len(input_shape) - broadcast_shape[self.axis] = input_shape[self.axis] - - broadcast_moving_mean = K.reshape(self.moving_mean, broadcast_shape) - broadcast_moving_variance = K.reshape(self.moving_variance, - broadcast_shape) - broadcast_gamma = K.reshape(self.gamma, broadcast_shape) - broadcast_beta = K.reshape(self.beta, broadcast_shape) - invstd = ( - K.ones(shape=broadcast_shape, dtype='float32') - / K.sqrt(broadcast_moving_variance + self._epsilon_const) - ) - - return((inputs - broadcast_moving_mean) - * invstd - * broadcast_gamma - + broadcast_beta) - - def get_config(self): - config = {'axis': self.axis, - 'momentum': self.momentum, - 'epsilon': self.epsilon} - base_config = super(TorchBatchNorm2D, self).get_config() - return dict(list(base_config.items()) + list(config.items())) diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index 3cf7b3fa5c..3fc281628d 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -58,13 +58,14 @@ class Detector(Extractor): # pylint:disable=abstract-method """ def __init__(self, git_model_id=None, model_filename=None, - configfile=None, instance=0, rotation=None, min_size=0): + configfile=None, instance=0, rotation=None, min_size=0, **kwargs): logger.debug("Initializing %s: (rotation: %s, min_size: %s)", self.__class__.__name__, rotation, min_size) super().__init__(git_model_id, model_filename, configfile=configfile, - instance=instance) + instance=instance, + **kwargs) self.rotation = self._get_rotation_angles(rotation) self.min_size = min_size diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py index 4e623aa628..f2783f9d48 100644 --- a/plugins/extract/detect/mtcnn.py +++ b/plugins/extract/detect/mtcnn.py @@ -4,10 +4,9 @@ from __future__ import absolute_import, division, print_function import cv2 -from keras.layers import Conv2D, Dense, Flatten, Input, MaxPool2D, Permute, PReLU - import numpy as np - +# pylint:disable=import-error +from keras.layers import Conv2D, Dense, Flatten, Input, MaxPool2D, Permute, PReLU from lib.model.session import KSession from ._base import Detector, logger @@ -54,7 +53,10 @@ def validate_kwargs(self): def init_model(self): """ Initialize S3FD Model""" - self.model = MTCNN(self.model_path, self.config["allow_growth"], **self.kwargs) + self.model = MTCNN(self.model_path, + self.config["allow_growth"], + self._exclude_gpus, + **self.kwargs) def process_input(self, batch): """ Compile the detection image(s) for prediction """ @@ -105,15 +107,18 @@ def process_output(self, batch): class PNet(KSession): - """ Keras PNet model for MTCNN """ - def __init__(self, model_path, allow_growth): - super().__init__("MTCNN-PNet", model_path, allow_growth=allow_growth) + """ Keras P-Net model for MTCNN """ + def __init__(self, model_path, allow_growth, exclude_gpus): + super().__init__("MTCNN-PNet", + model_path, + allow_growth=allow_growth, + exclude_gpus=exclude_gpus) self.define_model(self.model_definition) self.load_model_weights() @staticmethod def model_definition(): - """ Keras PNetwork for MTCNN """ + """ Keras P-Network for MTCNN """ input_ = Input(shape=(None, None, 3)) var_x = Conv2D(10, (3, 3), strides=1, padding='valid', name='conv1')(input_) var_x = PReLU(shared_axes=[1, 2], name='PReLU1')(var_x) @@ -128,15 +133,18 @@ def model_definition(): class RNet(KSession): - """ Keras RNet model for MTCNN """ - def __init__(self, model_path, allow_growth): - super().__init__("MTCNN-RNet", model_path, allow_growth=allow_growth) + """ Keras R-Net model for MTCNN """ + def __init__(self, model_path, allow_growth, exclude_gpus): + super().__init__("MTCNN-RNet", + model_path, + allow_growth=allow_growth, + exclude_gpus=exclude_gpus) self.define_model(self.model_definition) self.load_model_weights() @staticmethod def model_definition(): - """ Keras RNetwork for MTCNN """ + """ Keras R-Network for MTCNN """ input_ = Input(shape=(24, 24, 3)) var_x = Conv2D(28, (3, 3), strides=1, padding='valid', name='conv1')(input_) var_x = PReLU(shared_axes=[1, 2], name='prelu1')(var_x) @@ -158,15 +166,18 @@ def model_definition(): class ONet(KSession): - """ Keras ONet model for MTCNN """ - def __init__(self, model_path, allow_growth): - super().__init__("MTCNN-ONet", model_path, allow_growth=allow_growth) + """ Keras O-Net model for MTCNN """ + def __init__(self, model_path, allow_growth, exclude_gpus): + super().__init__("MTCNN-ONet", + model_path, + allow_growth=allow_growth, + exclude_gpus=exclude_gpus) self.define_model(self.model_definition) self.load_model_weights() @staticmethod def model_definition(): - """ Keras ONetwork for MTCNN """ + """ Keras O-Network for MTCNN """ input_ = Input(shape=(48, 48, 3)) var_x = Conv2D(32, (3, 3), strides=1, padding='valid', name='conv1')(input_) var_x = PReLU(shared_axes=[1, 2], name='prelu1')(var_x) @@ -192,26 +203,26 @@ def model_definition(): class MTCNN(): """ MTCNN Detector for face alignment """ - # TODO Batching for rnet and onet + # TODO Batching for r-net and o-net - def __init__(self, model_path, allow_growth, minsize, threshold, factor): + def __init__(self, model_path, allow_growth, exclude_gpus, minsize, threshold, factor): """ minsize: minimum faces' size threshold: threshold=[th1, th2, th3], th1-3 are three steps threshold factor: the factor used to create a scaling pyramid of face sizes to detect in the image. - pnet, rnet, onet: caffemodel + p-net, r-net, o-net: caffemodel """ - logger.debug("Initializing: %s: (model_path: '%s', allow_growth: %s, minsize: %s, " - "threshold: %s, factor: %s)", self.__class__.__name__, model_path, - allow_growth, minsize, threshold, factor) + logger.debug("Initializing: %s: (model_path: '%s', allow_growth: %s, exclude_gpus: %s, " + "minsize: %s, threshold: %s, factor: %s)", self.__class__.__name__, + model_path, allow_growth, exclude_gpus, minsize, threshold, factor) self.minsize = minsize self.threshold = threshold self.factor = factor - self.pnet = PNet(model_path[0], allow_growth) - self.rnet = RNet(model_path[1], allow_growth) - self.onet = ONet(model_path[2], allow_growth) + self.pnet = PNet(model_path[0], allow_growth, exclude_gpus) + self.rnet = RNet(model_path[1], allow_growth, exclude_gpus) + self.onet = ONet(model_path[2], allow_growth, exclude_gpus) self._pnet_scales = None logger.debug("Initialized: %s", self.__class__.__name__) @@ -238,7 +249,7 @@ def detect_faces(self, batch): def detect_pnet(self, images, height, width): # pylint: disable=too-many-locals - """ first stage - fast proposal network (pnet) to obtain face candidates """ + """ first stage - fast proposal network (p-net) to obtain face candidates """ if self._pnet_scales is None: self._pnet_scales = calculate_scales(height, width, self.minsize, self.factor) rectangles = [[] for _ in range(images.shape[0])] @@ -256,7 +267,7 @@ def detect_pnet(self, images, height, width): cls_prob = np.swapaxes(cls_prob, 1, 2) roi = np.swapaxes(roi, 1, 3) for idx in range(batch_items): - # first index 0 = class score, 1 = one hot repr + # first index 0 = class score, 1 = one hot representation rectangle = detect_face_12net(cls_prob[idx, ...], roi[idx, ...], out_side, @@ -268,7 +279,7 @@ def detect_pnet(self, images, height, width): return [nms(x, 0.7, 'iou') for x in rectangles] def detect_rnet(self, images, rectangle_batch, height, width): - """ second stage - refinement of face candidates with rnet """ + """ second stage - refinement of face candidates with r-net """ ret = [] # TODO: batching for idx, rectangles in enumerate(rectangle_batch): @@ -295,7 +306,7 @@ def detect_rnet(self, images, rectangle_batch, height, width): return ret def detect_onet(self, images, rectangle_batch, height, width): - """ third stage - further refinement and facial landmarks positions with onet """ + """ third stage - further refinement and facial landmarks positions with o-net """ ret = list() # TODO: batching for idx, rectangles in enumerate(rectangle_batch): @@ -474,7 +485,7 @@ def filter_face_48net(cls_prob, roi, pts, rectangles, width, height, threshold): def nms(rectangles, threshold, method): # pylint:disable=too-many-locals - """ apply NMS(non-maximum suppression) on ROIs in same scale(matrix version) + """ apply non-maximum suppression on ROIs in same scale(matrix version) Input: rectangles: rectangles[i][0:3] is the position, rectangles[i][4] is score Output: diff --git a/plugins/extract/detect/s3fd.py b/plugins/extract/detect/s3fd.py index cc516fd09a..0b1e7d8daf 100644 --- a/plugins/extract/detect/s3fd.py +++ b/plugins/extract/detect/s3fd.py @@ -8,8 +8,8 @@ from scipy.special import logsumexp import numpy as np -import keras -import keras.backend as K +import keras # pylint:disable=import-error +import keras.backend as K # pylint:disable=import-error from lib.model.session import KSession from ._base import Detector, logger @@ -31,14 +31,18 @@ def __init__(self, **kwargs): def init_model(self): """ Initialize S3FD Model""" confidence = self.config["confidence"] / 100 - model_kwargs = dict(custom_objects=dict(O2K_Add=O2K_Add, - O2K_Slice=O2K_Slice, - O2K_Sum=O2K_Sum, - O2K_Sqrt=O2K_Sqrt, - O2K_Pow=O2K_Pow, - O2K_ConstantLayer=O2K_ConstantLayer, - O2K_Div=O2K_Div)) - self.model = S3fd(self.model_path, model_kwargs, self.config["allow_growth"], confidence) + model_kwargs = dict(custom_objects=dict(O2K_Add=AddO2K, + O2K_Slice=SliceO2K, + O2K_Sum=SumO2K, + O2K_Sqrt=SqrtO2K, + O2K_Pow=PowO2K, + O2K_ConstantLayer=ConstantLayerO2K, + O2K_Div=DivO2K)) + self.model = S3fd(self.model_path, + model_kwargs, + self.config["allow_growth"], + self._exclude_gpus, + confidence) def process_input(self, batch): """ Compile the detection image(s) for prediction """ @@ -61,14 +65,42 @@ def process_output(self, batch): # CUSTOM KERAS LAYERS # generated by onnx2keras ################################################################################ -class O2K_ElementwiseLayer(keras.engine.Layer): - def __init__(self, **kwargs): - super(O2K_ElementwiseLayer, self).__init__(**kwargs) - - def call(self, *args): +class ElementwiseLayerO2K(keras.layers.Layer): + """ Custom Keras Element Wise layer generated by onnx2keras. """ + def call(self, inputs, **kwargs): # pylint:disable=unused-argument + """This is where the layer's logic lives. + + Override for layers that inherit from this class. + + Parameters + ---------- + inputs: Input tensor, or list/tuple of input tensors. + The input to the layer + **kwargs: Additional keyword arguments. + Required for parent class but unused + Returns + ------- + A tensor or list/tuple of tensors. + The layer output + """ raise NotImplementedError() - def compute_output_shape(self, input_shape): + def compute_output_shape(self, input_shape): # pylint:disable=no-self-use + """Computes the output shape of the layer. + + Assumes that the layer will be built to match that input shape provided. + + Parameters + ---------- + input_shape: tuple or list of tuples + Shape tuple (tuple of integers) or list of shape tuples (one per output tensor of the + layer). Shape tuples can include ``None`` for free dimensions, instead of an integer. + + Returns + ------- + tuple + An output shape tuple. + """ # TODO: do this nicer ldims = len(input_shape[0]) rdims = len(input_shape[1]) @@ -81,72 +113,152 @@ def compute_output_shape(self, input_shape): return input_shape[0 if lprod > rprod else 1] -class O2K_Add(O2K_ElementwiseLayer): - def call(self, x, *args): - return x[0] + x[1] +class AddO2K(ElementwiseLayerO2K): + """ Custom Keras Add layer generated by onnx2keras. """ + def call(self, inputs, **kwargs): # pylint:disable=unused-argument + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: Input tensor, or list/tuple of input tensors. + The input to the layer + **kwargs: Additional keyword arguments. + Required for parent class but unused + Returns + ------- + A tensor or list/tuple of tensors. + The layer output + """ + return inputs[0] + inputs[1] -class O2K_Slice(keras.engine.Layer): +class SliceO2K(keras.layers.Layer): + """ Custom Keras Slice layer generated by onnx2keras. """ def __init__(self, starts, ends, axes=None, steps=None, **kwargs): self._starts = starts self._ends = ends self._axes = axes self._steps = steps - super(O2K_Slice, self).__init__(**kwargs) + super().__init__(**kwargs) def get_config(self): - config = super(O2K_Slice, self).get_config() + """ Returns the config of the layer. + + A layer config is a Python dictionary (serializable) containing the configuration of a + layer. The same layer can be re-instantiated later (without its trained weights) from this + configuration. The config of a layer does not include connectivity information, nor the + layer class name. These are handled by `Network` (one layer of abstraction above). + + Returns + ------- + dict + The configuration for the layer + """ + config = super().get_config() config.update({ 'starts': self._starts, 'ends': self._ends, 'axes': self._axes, 'steps': self._steps }) return config - def get_slices(self, ndims): + def _get_slices(self, dimensions): + """ Obtain slices for the given number of dimensions. + + Parameters + ---------- + dimensions: int + The number of dimensions to obtain slices for + + Returns + ------- + list + The slices for the given number of dimensions + """ axes = self._axes steps = self._steps if axes is None: - axes = tuple(range(ndims)) + axes = tuple(range(dimensions)) if steps is None: steps = (1,) * len(axes) assert len(axes) == len(steps) == len(self._starts) == len(self._ends) return list(zip(axes, self._starts, self._ends, steps)) def compute_output_shape(self, input_shape): + """Computes the output shape of the layer. + + Assumes that the layer will be built to match that input shape provided. + + Parameters + ---------- + input_shape: tuple or list of tuples + Shape tuple (tuple of integers) or list of shape tuples (one per output tensor of the + layer). Shape tuples can include ``None`` for free dimensions, instead of an integer. + + Returns + ------- + tuple + An output shape tuple. + """ input_shape = list(input_shape) - for ax, start, end, steps in self.get_slices(len(input_shape)): - size = input_shape[ax] - if ax == 0: + for a_x, start, end, steps in self._get_slices(len(input_shape)): + size = input_shape[a_x] + if a_x == 0: raise AttributeError("Can not slice batch axis.") if size is None: if start < 0 or end < 0: raise AttributeError("Negative slices not supported on symbolic axes") logger.warning("Slicing symbolic axis might lead to problems.") - input_shape[ax] = (end - start) // steps + input_shape[a_x] = (end - start) // steps continue if start < 0: start = size - start if end < 0: end = size - end - input_shape[ax] = (min(size, end) - start) // steps + input_shape[a_x] = (min(size, end) - start) // steps return tuple(input_shape) - def call(self, x, *args): - ax_map = dict((x[0], slice(*x[1:])) for x in self.get_slices(K.ndim(x))) - shape = K.int_shape(x) + def call(self, inputs, **kwargs): # pylint:disable=unused-argument + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: Input tensor, or list/tuple of input tensors. + The input to the layer + **kwargs: Additional keyword arguments. + Required for parent class but unused + Returns + ------- + A tensor or list/tuple of tensors. + The layer output + """ + ax_map = dict((x[0], slice(*x[1:])) for x in self._get_slices(K.ndim(inputs))) + shape = K.int_shape(inputs) slices = [(ax_map[a] if a in ax_map else slice(None)) for a in range(len(shape))] - x = x[tuple(slices)] - return x + retval = inputs[tuple(slices)] + return retval -class O2K_ReduceLayer(keras.engine.Layer): +class ReduceLayerO2K(keras.layers.Layer): + """ Custom Keras Reduce layer generated by onnx2keras. """ def __init__(self, axes=None, keepdims=True, **kwargs): self._axes = [axes] if isinstance(axes, int) else axes self._keepdims = bool(keepdims) - super(O2K_ReduceLayer, self).__init__(**kwargs) + super().__init__(**kwargs) def get_config(self): - config = super(O2K_ReduceLayer, self).get_config() + """ Returns the config of the layer. + + A layer config is a Python dictionary (serializable) containing the configuration of a + layer. The same layer can be re-instantiated later (without its trained weights) from this + configuration. The config of a layer does not include connectivity information, nor the + layer class name. These are handled by `Network` (one layer of abstraction above). + + Returns + ------- + dict + The configuration for the layer + """ + config = super().get_config() config.update({ 'axes': self._axes, 'keepdims': self._keepdims @@ -154,6 +266,21 @@ def get_config(self): return config def compute_output_shape(self, input_shape): + """Computes the output shape of the layer. + + Assumes that the layer will be built to match that input shape provided. + + Parameters + ---------- + input_shape: tuple or list of tuples + Shape tuple (tuple of integers) or list of shape tuples (one per output tensor of the + layer). Shape tuples can include ``None`` for free dimensions, instead of an integer. + + Returns + ------- + tuple + An output shape tuple. + """ if self._axes is None: return (1,)*len(input_shape) if self._keepdims else tuple() ret = list(input_shape) @@ -164,41 +291,139 @@ def compute_output_shape(self, input_shape): ret.pop(i) return tuple(ret) - def call(self, x, *args): + def call(self, inputs, **kwargs): # pylint:disable=unused-argument + """This is where the layer's logic lives. + + Override for layers which inherit from this class + + Parameters + ---------- + inputs: Input tensor, or list/tuple of input tensors. + The input to the layer + **kwargs: Additional keyword arguments. + Required for parent class but unused + Returns + ------- + A tensor or list/tuple of tensors. + The layer output + """ raise NotImplementedError() -class O2K_Sum(O2K_ReduceLayer): - def call(self, x, *args): - return K.sum(x, self._axes, self._keepdims) - - -class O2K_Sqrt(keras.engine.Layer): - def call(self, x, *args): - return K.sqrt(x) - - -class O2K_Pow(keras.engine.Layer): - def call(self, x, *args): - return K.pow(*x) +class SumO2K(ReduceLayerO2K): + """ Custom Keras Sum layer generated by onnx2keras. """ + def call(self, inputs, **kwargs): # pylint:disable=unused-argument + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: Input tensor, or list/tuple of input tensors. + The input to the layer + **kwargs: Additional keyword arguments. + Required for parent class but unused + Returns + ------- + A tensor or list/tuple of tensors. + The layer output + """ + return K.sum(inputs, self._axes, self._keepdims) + + +class SqrtO2K(keras.layers.Layer): # pylint:disable=too-few-public-methods + """ Custom Keras Square Root layer generated by onnx2keras. """ + def call(self, inputs, **kwargs): # pylint:disable=unused-argument,no-self-use + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: Input tensor, or list/tuple of input tensors. + The input to the layer + **kwargs: Additional keyword arguments. + Required for parent class but unused + Returns + ------- + A tensor or list/tuple of tensors. + The layer output + """ + return K.sqrt(inputs) + + +class PowO2K(keras.layers.Layer): # pylint:disable=too-few-public-methods + """ Custom Keras Power layer generated by onnx2keras. """ + def call(self, inputs, **kwargs): # pylint:disable=unused-argument,no-self-use + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: Input tensor, or list/tuple of input tensors. + The input to the layer + **kwargs: Additional keyword arguments. + Required for parent class but unused + Returns + ------- + A tensor or list/tuple of tensors. + The layer output + """ + return K.pow(*inputs) -class O2K_ConstantLayer(keras.engine.Layer): +class ConstantLayerO2K(keras.layers.Layer): + """ Custom Keras Constant layer generated by onnx2keras. """ def __init__(self, constant_obj, dtype, **kwargs): self._dtype = np.dtype(dtype).name self._constant = np.array(constant_obj, dtype=self._dtype) - super(O2K_ConstantLayer, self).__init__(**kwargs) - - def call(self, *args): - # pylint:disable=arguments-differ + super().__init__(**kwargs) + + def call(self, inputs, **kwargs): # pylint:disable=unused-argument + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: Input tensor, or list/tuple of input tensors. + The input to the layer. Required for parent class but unused + **kwargs: Additional keyword arguments. + Required for parent class but unused + Returns + ------- + A tensor or list/tuple of tensors. + The layer output + """ data = K.constant(self._constant, dtype=self._dtype) return data - def compute_output_shape(self, input_shape): + def compute_output_shape(self, input_shape): # pylint:disable=unused-argument + """Computes the output shape of the layer. + + Assumes that the layer will be built to match that input shape provided. + + Parameters + ---------- + input_shape: tuple or list of tuples + Shape tuple (tuple of integers) or list of shape tuples (one per output tensor of the + layer). Shape tuples can include ``None`` for free dimensions, instead of an integer. + This is unused for a constant layer + + Returns + ------- + tuple + An output shape tuple. + """ return self._constant.shape def get_config(self): - config = super(O2K_ConstantLayer, self).get_config() + """ Returns the config of the layer. + + A layer config is a Python dictionary (serializable) containing the configuration of a + layer. The same layer can be re-instantiated later (without its trained weights) from this + configuration. The config of a layer does not include connectivity information, nor the + layer class name. These are handled by `Network` (one layer of abstraction above). + + Returns + ------- + dict + The configuration for the layer + """ + config = super().get_config() config.update({ 'constant_obj': self._constant, 'dtype': self._dtype @@ -206,18 +431,36 @@ def get_config(self): return config -class O2K_Div(O2K_ElementwiseLayer): - # pylint:disable=arguments-differ - def call(self, x, *args): - return x[0] / x[1] +class DivO2K(ElementwiseLayerO2K): + """ Custom Keras Division layer generated by onnx2keras. """ + def call(self, inputs, **kwargs): # pylint:disable=unused-argument + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: Input tensor, or list/tuple of input tensors. + The input to the layer + **kwargs: Additional keyword arguments. + Required for parent class but unused + Returns + ------- + A tensor or list/tuple of tensors. + The layer output + """ + return inputs[0] / inputs[1] class S3fd(KSession): """ Keras Network """ - def __init__(self, model_path, model_kwargs, allow_growth, confidence): - logger.debug("Initializing: %s: (model_path: '%s', allow_growth: %s)", - self.__class__.__name__, model_path, allow_growth) - super().__init__("S3FD", model_path, model_kwargs=model_kwargs, allow_growth=allow_growth) + def __init__(self, model_path, model_kwargs, allow_growth, exclude_gpus, confidence): + logger.debug("Initializing: %s: (model_path: '%s', model_kwargs: %s, allow_growth: %s, " + "exclude_gpus: %s, confidence: %s)", self.__class__.__name__, model_path, + model_kwargs, allow_growth, exclude_gpus, confidence) + super().__init__("S3FD", + model_path, + model_kwargs=model_kwargs, + allow_growth=allow_growth, + exclude_gpus=exclude_gpus) self.load_model() self.confidence = confidence self.average_img = np.array([104.0, 117.0, 123.0]) @@ -269,21 +512,25 @@ def softmax(inp, axis): return np.exp(inp - logsumexp(inp, axis=axis, keepdims=True)) @staticmethod - def decode(loc, priors): - """Decode locations from predictions using priors to undo - the encoding we did for offset regression at train time. - Args: - loc (tensor): location predictions for loc layers, - Shape: [num_priors,4] - priors (tensor): Prior boxes in center-offset form. - Shape: [num_priors,4]. - variances: (list[float]) Variances of prior boxes - Return: + def decode(location, priors): + """Decode locations from predictions using priors to undo the encoding we did for offset + regression at train time. + + Parameters + ---------- + location: tensor + location predictions for location layers, + priors: tensor + Prior boxes in center-offset form. + + Returns + ------- + :class:`numpy.ndarray` decoded bounding box predictions """ variances = [0.1, 0.2] - boxes = np.concatenate((priors[:, :2] + loc[:, :2] * variances[0] * priors[:, 2:], - priors[:, 2:] * np.exp(loc[:, 2:] * variances[1])), axis=1) + boxes = np.concatenate((priors[:, :2] + location[:, :2] * variances[0] * priors[:, 2:], + priors[:, 2:] * np.exp(location[:, 2:] * variances[1])), axis=1) boxes[:, :2] -= boxes[:, 2:] / 2 boxes[:, 2:] += boxes[:, :2] return boxes diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 9c4cfc9ecc..f058b181a7 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -50,12 +50,13 @@ class Masker(Extractor): # pylint:disable=abstract-method """ def __init__(self, git_model_id=None, model_filename=None, configfile=None, - instance=0, image_is_aligned=False): + instance=0, image_is_aligned=False, **kwargs): logger.debug("Initializing %s: (configfile: %s, )", self.__class__.__name__, configfile) super().__init__(git_model_id, model_filename, configfile=configfile, - instance=instance) + instance=instance, + **kwargs) self.input_size = 256 # Override for model specific input_size self.coverage_ratio = 1.0 # Override for model specific coverage_ratio diff --git a/plugins/extract/mask/unet_dfl.py b/plugins/extract/mask/unet_dfl.py index dd60727f31..86aa140035 100644 --- a/plugins/extract/mask/unet_dfl.py +++ b/plugins/extract/mask/unet_dfl.py @@ -32,8 +32,11 @@ def __init__(self, **kwargs): self.batchsize = self.config["batch-size"] def init_model(self): - self.model = KSession(self.name, self.model_path, - model_kwargs=dict(), allow_growth=self.config["allow_growth"]) + self.model = KSession(self.name, + self.model_path, + model_kwargs=dict(), + allow_growth=self.config["allow_growth"], + exclude_gpus=self._exclude_gpus) self.model.load_model() placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), dtype="float32") diff --git a/plugins/extract/mask/vgg_clear.py b/plugins/extract/mask/vgg_clear.py index 3b5cb46682..2b1a775429 100644 --- a/plugins/extract/mask/vgg_clear.py +++ b/plugins/extract/mask/vgg_clear.py @@ -33,8 +33,11 @@ def __init__(self, **kwargs): self.batchsize = self.config["batch-size"] def init_model(self): - self.model = KSession(self.name, self.model_path, - model_kwargs=dict(), allow_growth=self.config["allow_growth"]) + self.model = KSession(self.name, + self.model_path, + model_kwargs=dict(), + allow_growth=self.config["allow_growth"], + exclude_gpus=self._exclude_gpus) self.model.load_model() self.model.append_softmax_activation(layer_index=-1) placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), diff --git a/plugins/extract/mask/vgg_obstructed.py b/plugins/extract/mask/vgg_obstructed.py index 487712f00d..03e8776873 100644 --- a/plugins/extract/mask/vgg_obstructed.py +++ b/plugins/extract/mask/vgg_obstructed.py @@ -33,8 +33,11 @@ def __init__(self, **kwargs): self.batchsize = self.config["batch-size"] def init_model(self): - self.model = KSession(self.name, self.model_path, - model_kwargs=dict(), allow_growth=self.config["allow_growth"]) + self.model = KSession(self.name, + self.model_path, + model_kwargs=dict(), + allow_growth=self.config["allow_growth"], + exclude_gpus=self._exclude_gpus) self.model.load_model() self.model.append_softmax_activation(layer_index=-1) placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index cadf2b5ba3..cba08356a8 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -52,6 +52,9 @@ class Extractor(): multiprocess: bool, optional Whether to attempt processing the plugins in parallel. This may get overridden internally depending on the plugin combination. Default: ``False`` + exclude_gpus: list, optional + A list of indices correlating to connected GPUs that Tensorflow should not use. Pass + ``None`` to not exclude any GPUs. Default: ``None`` rotate_images: str, optional Used to set the :attr:`plugins.extract.detect.rotation` attribute. Pass in a single number to use increments of that size up to 360, or pass in a ``list`` of ``ints`` to enumerate @@ -74,17 +77,18 @@ class Extractor(): The current phase that the pipeline is running. Used in conjunction with :attr:`passes` and :attr:`final_pass` to indicate to the caller which phase is being processed """ - def __init__(self, detector, aligner, masker, configfile=None, - multiprocess=False, rotate_images=None, min_size=20, - normalize_method=None, image_is_aligned=False): - logger.debug("Initializing %s: (detector: %s, aligner: %s, masker: %s, " - "configfile: %s, multiprocess: %s, rotate_images: %s, min_size: %s, " + def __init__(self, detector, aligner, masker, configfile=None, multiprocess=False, + exclude_gpus=None, rotate_images=None, min_size=20, normalize_method=None, + image_is_aligned=False): + logger.debug("Initializing %s: (detector: %s, aligner: %s, masker: %s, configfile: %s, " + "multiprocess: %s, exclude_gpus: %s, rotate_images: %s, min_size: %s, " "normalize_method: %s, image_is_aligned: %s)", - self.__class__.__name__, detector, aligner, masker, configfile, - multiprocess, rotate_images, min_size, normalize_method, image_is_aligned) + self.__class__.__name__, detector, aligner, masker, configfile, multiprocess, + exclude_gpus, rotate_images, min_size, normalize_method, image_is_aligned) self._instance = _get_instance() masker = [masker] if not isinstance(masker, list) else masker self._flow = self._set_flow(detector, aligner, masker) + self._exclude_gpus = exclude_gpus # We only ever need 1 item in each queue. This is 2 items cached (1 in queue 1 waiting # for queue) at each point. Adding more just stacks RAM with no speed benefit. self._queue_size = 1 @@ -505,7 +509,8 @@ def _load_align(self, aligner, configfile, normalize_method): return None aligner_name = aligner.replace("-", "_").lower() logger.debug("Loading Aligner: '%s'", aligner_name) - aligner = PluginLoader.get_aligner(aligner_name)(configfile=configfile, + aligner = PluginLoader.get_aligner(aligner_name)(exclude_gpus=self._exclude_gpus, + configfile=configfile, normalize_method=normalize_method, instance=self._instance) return aligner @@ -517,7 +522,8 @@ def _load_detect(self, detector, rotation, min_size, configfile): return None detector_name = detector.replace("-", "_").lower() logger.debug("Loading Detector: '%s'", detector_name) - detector = PluginLoader.get_detector(detector_name)(rotation=rotation, + detector = PluginLoader.get_detector(detector_name)(exclude_gpus=self._exclude_gpus, + rotation=rotation, min_size=min_size, configfile=configfile, instance=self._instance) @@ -530,7 +536,8 @@ def _load_mask(self, masker, image_is_aligned, configfile): return None masker_name = masker.replace("-", "_").lower() logger.debug("Loading Masker: '%s'", masker_name) - masker = PluginLoader.get_masker(masker_name)(image_is_aligned=image_is_aligned, + masker = PluginLoader.get_masker(masker_name)(exclude_gpus=self._exclude_gpus, + image_is_aligned=image_is_aligned, configfile=configfile, instance=self._instance) return masker diff --git a/plugins/extract/recognition/__init__.py b/plugins/extract/recognition/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/vgg_face2_keras.py b/plugins/extract/recognition/vgg_face2_keras.py similarity index 70% rename from lib/vgg_face2_keras.py rename to plugins/extract/recognition/vgg_face2_keras.py index 5c49c8080d..10ca5d7ce9 100644 --- a/lib/vgg_face2_keras.py +++ b/plugins/extract/recognition/vgg_face2_keras.py @@ -2,30 +2,25 @@ """ VGG_Face2 inference and sorting """ import logging -import sys -import os import psutil import cv2 import numpy as np from fastcluster import linkage, linkage_vector -from lib.utils import GetModel, FaceswapError + +from lib.model.layers import L2_normalize +from lib.model.session import KSession +from lib.utils import FaceswapError +from plugins.extract._base import Extractor logger = logging.getLogger(__name__) # pylint: disable=invalid-name -class VGGFace2(): +class VGGFace2(Extractor): # pylint:disable=abstract-method """ VGG Face feature extraction. Extracts feature vectors from faces in order to compare similarity. - Parameters - ---------- - backend: ['GPU', 'CPU'] - Whether to run inference on a GPU or on the CPU - loglevel: ['INFO', 'VERBODE', 'DEBUG', 'TRACE'] - The system log level - Notes ----- Input images should be in BGR Order @@ -38,79 +33,35 @@ class VGGFace2(): https://creativecommons.org/licenses/by-nc/4.0/ """ - def __init__(self, backend="GPU", allow_growth=False, loglevel="INFO"): - logger.debug("Initializing %s: (backend: %s, allow_growth: %s, loglevel: %s)", - self.__class__.__name__, backend, allow_growth, loglevel) - backend = backend.upper() + def __init__(self, *args, **kwargs): # pylint:disable=unused-argument + logger.debug("Initializing %s", self.__class__.__name__) git_model_id = 10 model_filename = ["vggface2_resnet50_v2.h5"] + super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self._plugin_type = "recognition" + self.name = "VGG_Face2" self.input_size = 224 # Average image provided in https://github.com/ox-vgg/vgg_face2 - self.average_img = np.array([91.4953, 103.8827, 131.0912]) - - self.model = self._get_model(git_model_id, model_filename, backend, allow_growth) + self._average_img = np.array([91.4953, 103.8827, 131.0912]) logger.debug("Initialized %s", self.__class__.__name__) # <<< GET MODEL >>> # - @staticmethod - def _get_model(git_model_id, model_filename, backend, allow_growth): - """ Check if model is available, if not, download and unzip it - - Parameters - ---------- - git_model_id: int - The second digit in the github tag that identifies this model. See - https://github.com/deepfakes-models/faceswap-models for more information - model_filename: str - The name of the model to be loaded (see :class:`lib.utils.GetModel` for more - information) - backend: ['GPU', 'CPU'] - Whether to run inference on a GPU or on the CPU - allow_growth: bool - ``True`` if Tensorflow's allow_growth option should be set, otherwise ``False`` - - See Also - -------- - lib.utils.GetModel: The model downloading and allocation class. - """ - root_path = os.path.abspath(os.path.dirname(sys.argv[0])) - cache_path = os.path.join(root_path, "plugins", "extract", "recognition", ".cache") - model = GetModel(model_filename, cache_path, git_model_id).model_path - if backend == "CPU": - if os.environ.get("KERAS_BACKEND", "") == "plaidml.keras.backend": - logger.info("Switching to tensorflow backend.") - os.environ["KERAS_BACKEND"] = "tensorflow" - - if allow_growth: - # TODO This needs to be centralized. Just a hacky fix to read the allow growth config - # option from the Extraction config file - logger.info("Enabling Tensorflow 'allow_growth' option") - import tensorflow as tf - from keras.backend.tensorflow_backend import set_session - config = tf.ConfigProto() - config.gpu_options.allow_growth = True - config.gpu_options.visible_device_list = "0" - set_session(tf.Session(config=config)) - logger.debug("Set Tensorflow 'allow_growth' option") - - import keras - from lib.model.layers import L2_normalize - if backend == "CPU": - with keras.backend.tf.device("/cpu:0"): - return keras.models.load_model(model, { - "L2_normalize": L2_normalize - }) - else: - return keras.models.load_model(model, { - "L2_normalize": L2_normalize - }) - - def predict(self, face): + def init_model(self): + """ Initialize VGG Face 2 Model. """ + model_kwargs = dict(custom_objects={'L2_normalize': L2_normalize}) + self.model = KSession(self.name, + self.model_path, + model_kwargs=model_kwargs, + allow_growth=self.config["allow_growth"], + exclude_gpus=self._exclude_gpus) + self.model.load_model() + + def predict(self, batch): """ Return encodings for given image from vgg_face2. Parameters ---------- - face: numpy.ndarray + batch: numpy.ndarray The face to be fed through the predictor. Should be in BGR channel order Returns @@ -118,9 +69,10 @@ def predict(self, face): numpy.ndarray The encodings for the face """ + face = batch if face.shape[0] != self.input_size: face = self._resize_face(face) - face = face[None, :, :, :3] - self.average_img + face = face[None, :, :, :3] - self._average_img preds = self.model.predict(face) return preds[0, :] diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 7ebce93dd3..8900b536a8 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -69,7 +69,7 @@ def set_globals(self): "\n\t87.5%% spans from ear to ear." "\n\t100.0%% is a mugshot.") self.add_item( - section=section, title="mask_type", datatype=str, default="none", + section=section, title="mask_type", datatype=str, default="extended", choices=PluginLoader.get_available_extractors("mask", add_none=True), group="mask", gui_radio=True, info="The mask to be used for training. If you have selected 'Learn Mask' or " @@ -143,6 +143,14 @@ def set_globals(self): "sizing. More complex padding schemes can reduce artifacts at the " "border of the image." "\n\t http://www-cs.engr.ccny.cuny.edu/~wolberg/cs470/hw/hw2_pad.txt") + self.add_item( + section=section, title="allow_growth", datatype=bool, default=False, group="network", + fixed=False, + info="[Nvidia Only]. Enable the Tensorflow GPU 'allow_growth' configuration option. " + "This option prevents Tensorflow from allocating all of the GPU VRAM at launch " + "but can lead to higher VRAM fragmentation and slower performance. Should only " + "be enabled if you are receiving errors regarding 'cuDNN fails to initialize' " + "when commencing training.") self.add_item( section=section, title="penalized_mask_loss", datatype=bool, default=True, group="loss", @@ -155,7 +163,8 @@ def set_globals(self): default="mae", choices=["mae", "mse", "logcosh", "smooth_loss", "l_inf_norm", "ssim", "gmsd", "pixel_gradient_diff"], - info="\n\t MAE - Mean absolute error will guide reconstructions of each pixel " + info="The loss function to use." + "\n\t MAE - Mean absolute error will guide reconstructions of each pixel " "towards its median value in the training dataset. Robust to outliers but as " "a median, it can potentially ignore some infrequent image types in the dataset." "\n\t MSE - Mean squared error will guide reconstructions of each pixel " @@ -163,7 +172,8 @@ def set_globals(self): "suspectible to outliers and typically produces slightly blurrier results." "\n\t LogCosh - log(cosh(x)) acts similiar to MSE for small errors and to " "MAE for large errors. Like MSE, it is very stable and prevents overshoots " - "when errors are near zero. Like MAE, it is robust to outliers." + "when errors are near zero. Like MAE, it is robust to outliers. NB: Due to a bug " + "in PlaidML, this loss does not work on AMD cards." "\n\t Smooth_L1 --- Modification of the MAE loss to correct two of its " "disadvantages. This loss has improved stability and guidance for small errors." "\n\t L_inf_norm --- The L_inf norm will reduce the largest individual pixel " @@ -174,7 +184,8 @@ def set_globals(self): "statistics of an image. Potentially delivers more realistic looking images." "\n\t GMSD - Gradient Magnitude Similarity Deviation seeks to match " "the global standard deviation of the pixel to pixel differences between two " - "images. Similiar in approach to SSIM." + "images. Similiar in approach to SSIM. NB: This loss does not currently work on " + "AMD cards." "\n\t Pixel_Gradient_Difference - Instead of minimizing the difference between " "the absolute value of each pixel in two reference images, compute the pixel to " "pixel spatial difference in each image and then minimize that difference " diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 2540448f0f..de4dcd9e1d 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -1,1000 +1,1133 @@ #!/usr/bin/env python3 -""" Base class for Models. ALL Models should at least inherit from this class +""" +Base class for Models. ALL Models should at least inherit from this class. - When inheriting model_data should be a list of NNMeta objects. - See the class for details. +See :mod:`~plugins.train.model.original` for an annotated example for how to create model plugins. """ import logging import os +import platform import sys import time -from concurrent import futures +from collections import OrderedDict +from contextlib import nullcontext + +import numpy as np +import tensorflow as tf -import keras -from keras import losses +from keras import losses as k_losses from keras import backend as K from keras.layers import Input -from keras.models import load_model, Model -from keras.utils import get_custom_objects, multi_gpu_model +from keras.models import load_model, Model as KModel +from keras.optimizers import Adam from lib.serializer import get_serializer from lib.model.backup_restore import Backup -from lib.model.losses import (DSSIMObjective, PenalizedLoss, gradient_loss, mask_loss_wrapper, - generalized_loss, l_inf_norm, gmsd_loss, gaussian_blur) -from lib.model.nn_blocks import NNBlocks -from lib.model.optimizers import Adam -from lib.utils import deprecation_warning, FaceswapError +from lib.model import losses +from lib.model.nn_blocks import set_config as set_nnblock_config +from lib.utils import get_backend, FaceswapError from plugins.train._config import Config logger = logging.getLogger(__name__) # pylint: disable=invalid-name _CONFIG = None +def KerasModel(inputs, outputs, name): # pylint:disable=invalid-name + """ wrapper for :class:`keras.models.Model`. + + There are some minor foibles between Keras 2.2 and the Tensorflow version of Keras, so this + catches potential issues and fixes prior to returning the requested model. + + All models created within plugins should use this method, and should not call keras directly + for a model. + + Parameters + ---------- + inputs: a keras.Input object or list of keras.Input objects. + The input(s) of the model + outputs: keras objects + The output(s) of the model. + name: str + The name of the model. + + Returns + ------- + :class:`keras.models.Model` + A Keras Model + """ + if get_backend() == "amd": + logger.debug("Flattening inputs (%s) and outputs (%s) for AMD", inputs, outputs) + inputs = np.array(inputs).flatten().tolist() + outputs = np.array(outputs).flatten().tolist() + logger.debug("Flattened inputs (%s) and outputs (%s)", inputs, outputs) + return KModel(inputs, outputs, name=name) + + class ModelBase(): - """ Base class that all models should inherit from """ - def __init__(self, - model_dir, - gpus=1, - configfile=None, - snapshot_interval=0, - no_logs=False, - warp_to_landmarks=False, - augment_color=True, - no_flip=False, - training_image_size=256, - alignments_paths=None, - preview_scale=100, - input_shape=None, - encoder_dim=None, - trainer="original", - pingpong=False, - memory_saving_gradients=False, - optimizer_savings=False, - predict=False): - logger.debug("Initializing ModelBase (%s): (model_dir: '%s', gpus: %s, configfile: %s, " - "snapshot_interval: %s, no_logs: %s, warp_to_landmarks: %s, augment_color: " - "%s, no_flip: %s, training_image_size, %s, alignments_paths: %s, " - "preview_scale: %s, input_shape: %s, encoder_dim: %s, trainer: %s, " - "pingpong: %s, memory_saving_gradients: %s, optimizer_savings: %s, " - "predict: %s)", - self.__class__.__name__, model_dir, gpus, configfile, snapshot_interval, - no_logs, warp_to_landmarks, augment_color, no_flip, training_image_size, - alignments_paths, preview_scale, input_shape, encoder_dim, trainer, pingpong, - memory_saving_gradients, optimizer_savings, predict) - - self.predict = predict - self.model_dir = model_dir - self.vram_savings = VRAMSavings(pingpong, optimizer_savings, memory_saving_gradients) - - self.backup = Backup(self.model_dir, self.name) - self.gpus = gpus - self.configfile = configfile - self.input_shape = input_shape - self.encoder_dim = encoder_dim - self.trainer = trainer - - self.load_config() # Load config if plugin has not already referenced it - - self.state = State(self.model_dir, - self.name, - self.config_changeable_items, - no_logs, - self.vram_savings.pingpong, - training_image_size) - - self.blocks = NNBlocks(use_icnr_init=self.config["icnr_init"], - use_convaware_init=self.config["conv_aware_init"], - use_reflect_padding=self.config["reflect_padding"], - first_run=self.state.first_run) - - self.is_legacy = False - self.rename_legacy() - self.load_state_info() - - self.networks = dict() # Networks for the model - self.predictors = dict() # Predictors for model - self.history = dict() # Loss history per save iteration) - - # Training information specific to the model should be placed in this - # dict for reference by the trainer. - self.training_opts = {"alignments": alignments_paths, - "preview_scaling": preview_scale / 100, - "warp_to_landmarks": warp_to_landmarks, - "augment_color": augment_color, - "no_flip": no_flip, - "pingpong": self.vram_savings.pingpong, - "snapshot_interval": snapshot_interval, - "training_size": self.state.training_size, - "no_logs": self.state.current_session["no_logs"], - "coverage_ratio": self.calculate_coverage_ratio(), - "mask_type": self.config["mask_type"], - "mask_blur_kernel": self.config["mask_blur_kernel"], - "mask_threshold": self.config["mask_threshold"], - "learn_mask": (self.config["learn_mask"] and - self.config["mask_type"] is not None), - "penalized_mask_loss": (self.config["penalized_mask_loss"] and - self.config["mask_type"] is not None)} - logger.debug("training_opts: %s", self.training_opts) - - if self.multiple_models_in_folder: - deprecation_warning("Support for multiple model types within the same folder", - additional_info="Please split each model into separate folders to " - "avoid issues in future.") - - self.build() + """ Base class that all model plugins should inherit from. + + Parameters + ---------- + model_dir: str + The full path to the model save location + arguments: :class:`argparse.Namespace` + The arguments that were passed to the train or convert process as generated from + Faceswap's command line arguments + training_image_size: int, optional + The size of the training images in the training folder. Default: `256` + predict: bool, optional + ``True`` if the model is being loaded for inference, ``False`` if the model is being loaded + for training. Default: ``False`` + + Attributes + ---------- + input_shape: tuple or list + A `tuple` of `ints` defining the shape of the faces that the model takes as input. This + should be overridden by model plugins in their :func:`__init__` function. If the input size + is the same for both sides of the model, then this can be a single 3 dimensional `tuple`. + If the inputs have different sizes for `"A"` and `"B"` this should be a `list` of 2 3 + dimensional shape `tuples`, 1 for each side respectively. + trainer: str + Currently there is only one trainer available (`"original"`), so at present this attribute + can be ignored. If/when more trainers are added, then this attribute should be overridden + with the trainer name that a model requires in the model plugin's + :func:`__init__` function. + """ + def __init__(self, model_dir, arguments, training_image_size=256, predict=False): + logger.debug("Initializing ModelBase (%s): (model_dir: '%s', arguments: %s, " + "training_image_size: %s, predict: %s)", + self.__class__.__name__, model_dir, arguments, training_image_size, predict) + + self.input_shape = None # Must be set within the plugin after initializing + self.trainer = "original" # Override for plugin specific trainer + + self._args = arguments + self._is_predict = predict + self._model = None + + self._configfile = arguments.configfile if hasattr(arguments, "configfile") else None + self._load_config() + + if self.config["penalized_mask_loss"] and self.config["mask_type"] is None: + raise FaceswapError("Penalized Mask Loss has been selected but you have not chosen a " + "Mask to use. Please select a mask or disable Penalized Mask " + "Loss.") + + self._io = _IO(self, model_dir, self._is_predict) + self._check_multiple_models() + + self._settings = _Settings(self._args, self.config["allow_growth"], self._is_predict) + self._state = State(model_dir, + self.name, + self._config_changeable_items, + False if self._is_predict else self._args.no_logs, + training_image_size) + logger.debug("Initialized ModelBase (%s)", self.__class__.__name__) @property - def config_section(self): - """ The section name for loading config """ - retval = ".".join(self.__module__.split(".")[-2:]) - logger.debug(retval) + def model(self): + """:class:`Keras.models.Model`: The compiled model for this plugin. """ + return self._model + + @property + def command_line_arguments(self): + """ :class:`argparse.Namespace`: The command line arguments passed to the model plugin from + either the train or convert script """ + return self._args + + @property + def coverage_ratio(self): + """ float: The ratio of the training image to crop out and train on. """ + coverage_ratio = self.config.get("coverage", 62.5) / 100 + logger.debug("Requested coverage_ratio: %s", coverage_ratio) + cropped_size = (self._state.training_size * coverage_ratio) // 2 * 2 + retval = cropped_size / self._state.training_size + logger.debug("Final coverage_ratio: %s", retval) return retval + @property + def model_dir(self): + """str: The full path to the model folder location. """ + return self._io._model_dir # pylint:disable=protected-access + @property def config(self): - """ Return config dict for current plugin """ + """ dict: The configuration dictionary for current plugin, as set by the user's + configuration settings. """ global _CONFIG # pylint: disable=global-statement if not _CONFIG: - model_name = self.config_section + model_name = self._config_section logger.debug("Loading config for: %s", model_name) - _CONFIG = Config(model_name, configfile=self.configfile).config_dict + _CONFIG = Config(model_name, configfile=self._configfile).config_dict return _CONFIG - @property - def config_changeable_items(self): - """ Return the dict of config items that can be updated after the model - has been created """ - return Config(self.config_section, configfile=self.configfile).changeable_items - @property def name(self): - """ Set the model name based on the subclass """ + """ str: The name of this model based on the plugin name. """ basename = os.path.basename(sys.modules[self.__module__].__file__) - retval = os.path.splitext(basename)[0].lower() - logger.debug("model name: '%s'", retval) - return retval - - @property - def models_exist(self): - """ Return if all files exist and clear session """ - retval = all([os.path.isfile(model.filename) for model in self.networks.values()]) - logger.debug("Pre-existing models exist: %s", retval) - return retval - - @property - def multiple_models_in_folder(self): - """ Return true if there are multiple model types in the same folder, else false """ - model_files = [fname for fname in os.listdir(str(self.model_dir)) if fname.endswith(".h5")] - retval = False if not model_files else os.path.commonprefix(model_files) == "" - logger.debug("model_files: %s, retval: %s", model_files, retval) - return retval + return os.path.splitext(basename)[0].lower() @property def output_shapes(self): - """ Return the output shapes from the main AutoEncoder """ - out = list() - for predictor in self.predictors.values(): - out.extend([K.int_shape(output)[-3:] for output in predictor.outputs]) - break # Only get output from one autoencoder. Shapes are the same - return [tuple(shape) for shape in out] + """ list: A list of list of shape tuples for the outputs of the model with the batch + dimension removed. The outer list contains 2 sub-lists (one for each side "a" and "b"). + The inner sub-lists contain the output shapes for that side. """ + shapes = [tuple(K.int_shape(output)[-3:]) for output in self._model.outputs] + return [shapes[:len(shapes) // 2], shapes[len(shapes) // 2:]] @property - def output_shape(self): - """ The output shape of the model (shape of largest face output) """ - return self.output_shapes[self.largest_face_index] + def iterations(self): + """ int: The total number of iterations that the model has trained. """ + return self._state.iterations + # Private properties @property - def largest_face_index(self): - """ Return the index from model.outputs of the largest face - Required for multi-output model prediction. The largest face - is assumed to be the final output - """ - sizes = [shape[1] for shape in self.output_shapes if shape[2] == 3] - if not sizes: - return None - max_face = max(sizes) - retval = [idx for idx, shape in enumerate(self.output_shapes) - if shape[1] == max_face and shape[2] == 3][0] - logger.debug(retval) - return retval + def _config_section(self): + """ str: The section name for the current plugin for loading configuration options from the + config file. """ + return ".".join(self.__module__.split(".")[-2:]) @property - def largest_mask_index(self): - """ Return the index from model.outputs of the largest mask - Required for multi-output model prediction. The largest face - is assumed to be the final output - """ - sizes = [shape[1] for shape in self.output_shapes if shape[2] == 1] - if not sizes: - return None - max_mask = max(sizes) - retval = [idx for idx, shape in enumerate(self.output_shapes) - if shape[1] == max_mask and shape[2] == 1][0] - logger.debug(retval) - return retval + def _config_changeable_items(self): + """ dict: The configuration options that can be updated after the model has already been + created. """ + return Config(self._config_section, configfile=self._configfile).changeable_items @property - def feed_mask(self): - """ bool: ``True`` if the model expects a mask to be fed into input otherwise ``False`` """ - return self.config["mask_type"] is not None and (self.config["learn_mask"] or - self.config["penalized_mask_loss"]) + def state(self): + """:class:`State`: The state settings for the current plugin. """ + return self._state - def load_config(self): - """ Load the global config for reference in self.config """ + def _load_config(self): + """ Load the global config for reference in :attr:`config` and set the faceswap blocks + configuration options in `lib.model.nn_blocks` """ global _CONFIG # pylint: disable=global-statement if not _CONFIG: - model_name = self.config_section + model_name = self._config_section logger.debug("Loading config for: %s", model_name) - _CONFIG = Config(model_name, configfile=self.configfile).config_dict + _CONFIG = Config(model_name, configfile=self._configfile).config_dict - def calculate_coverage_ratio(self): - """ Coverage must be a ratio, leading to a cropped shape divisible by 2 """ - coverage_ratio = self.config.get("coverage", 62.5) / 100 - logger.debug("Requested coverage_ratio: %s", coverage_ratio) - cropped_size = (self.state.training_size * coverage_ratio) // 2 * 2 - coverage_ratio = cropped_size / self.state.training_size - logger.debug("Final coverage_ratio: %s", coverage_ratio) - return coverage_ratio + nn_block_keys = ['icnr_init', 'conv_aware_init', 'reflect_padding'] + set_nnblock_config({key: _CONFIG.pop(key) + for key in nn_block_keys}) + + def _check_multiple_models(self): + """ Check whether multiple models exist in the model folder, and that no models exist that + were trained with a different plugin than the requested plugin. + + Raises + ------ + FaceswapError + If multiple model files, or models for a different plugin from that requested exists + within the model folder + """ + multiple_models = self._io.multiple_models_in_folder + if multiple_models is None: + logger.debug("Contents of model folder are valid") + return + + if len(multiple_models) == 1: + msg = ("You have requested to train with the '{}' plugin, but a model file for the " + "'{}' plugin already exists in the folder '{}'.\nPlease select a different " + "model folder.".format(self.name, multiple_models[0], self.model_dir)) + else: + msg = ("There are multiple plugin types ('{}') stored in the model folder '{}'. This " + "is not supported.\nPlease split the model files into their own folders before " + "proceeding".format("', '".join(multiple_models), self.model_dir)) + raise FaceswapError(msg) def build(self): - """ Build the model. Override for custom build methods """ - self.add_networks() - self.load_models(swapped=False) - inputs = self.get_inputs() - try: - self.build_autoencoders(inputs) - except ValueError as err: - if "must be from the same graph" in str(err).lower(): - msg = ("There was an error loading saved weights. This is most likely due to " - "model corruption during a previous save." - "\nYou should restore weights from a snapshot or from backup files. " - "You can use the 'Restore' Tool to restore from backup.") - raise FaceswapError(msg) from err - if "multi_gpu_model" in str(err).lower(): - raise FaceswapError(str(err)) from err - raise err - self.log_summary() - self.compile_predictors(initialize=True) - - def get_inputs(self): - """ Return the inputs for the model """ + """ Build the model and assign to :attr:`model`. + + Within the defined strategy scope, either builds the model from scratch or loads an + existing model if one exists. + + If running inference, then the model is built only for the required side to perform the + swap function, otherwise the model is then compiled with the optimizer and chosen + loss function(s). + + Finally, a model summary is outputted to the logger at verbose level. + """ + self._update_legacy_models() + with self._settings.strategy_scope(): + if self._io.model_exists: + model = self._io._load() # pylint:disable=protected-access + if self._is_predict: + inference = _Inference(model, self._args.swap_model) + self._model = inference.model + else: + self._model = model + else: + self._validate_input_shape() + inputs = self._get_inputs() + self._model = self.build_model(inputs) + if not self._is_predict: + self._compile_model() + self._output_summary() + + def _update_legacy_models(self): + """ Load weights from legacy split models into new unified model, archiving old model files + to a new folder. """ + if self._legacy_mapping() is None: + return + if not all(os.path.isfile(os.path.join(self.model_dir, fname)) + for fname in self._legacy_mapping()): + return + archive_dir = "{}_TF1_Archived".format(self.model_dir) + if os.path.exists(archive_dir): + raise FaceswapError("We need to update your model files for use with Tensorflow 2.x, " + "but the archive folder already exists. Please remove the " + "following folder to continue: '{}'".format(archive_dir)) + + logger.info("Updating legacy models for Tensorflow 2.x") + logger.info("Your Tensorflow 1.x models will be archived in the following location: '%s'", + archive_dir) + os.rename(self.model_dir, archive_dir) + os.mkdir(self.model_dir) + new_model = self.build_model(self._get_inputs()) + for model_name, layer_name in self._legacy_mapping().items(): + logger.info("Updating legacy weights from '%s'...", model_name) + old_model = load_model(os.path.join(archive_dir, model_name), compile=False) + layer = [layer for layer in new_model.layers if layer.name == layer_name] + if not layer: + continue + layer = layer[0] + layer.set_weights(old_model.get_weights()) + filename = self._io._filename # pylint:disable=protected-access + logger.info("Saving Tensorflow 2.x model to '%s'", filename) + new_model.save(filename) + self._state.save() + + def _validate_input_shape(self): + """ Validate that the input shape is either a single shape tuple of 3 dimensions or + a list of 2 shape tuples of 3 dimensions. """ + assert len(self.input_shape) in (2, 3), "Input shape should either be a single 3 " \ + "dimensional shape tuple for use in both sides of the model, or a list of 2 3 " \ + "dimensional shape tuples for use in the 'A' and 'B' sides of the model" + if len(self.input_shape) == 2: + assert [len(shape) == 3 for shape in self.input_shape], "All input shapes should " \ + "have 3 dimensions" + + def _get_inputs(self): + """ Obtain the standardized inputs for the model. + + The inputs will be returned for the "A" and "B" sides in the shape as defined by + :attr:`input_shape`. + + Returns + ------- + list + A list of :class:`keras.layers.Input` tensors. This will be a list of 2 tensors (one + for each side) each of shapes :attr:`input_shape`. + """ logger.debug("Getting inputs") - inputs = [Input(shape=self.input_shape, name="face_in")] - output_network = [network for network in self.networks.values() if network.is_output][0] - if self.feed_mask: - # TODO penalized mask doesn't have a mask output, so we can't use output shapes - # mask should always be last output..this needs to be a rule - mask_shape = output_network.output_shapes[-1] - inputs.append(Input(shape=(mask_shape[1:-1] + (1,)), name="mask_in")) - logger.debug("Got inputs: %s", inputs) + if len(self.input_shape) == 3: + input_shapes = [self.input_shape, self.input_shape] + else: + input_shapes = self.input_shape + inputs = [Input(shape=shape, name="face_in_{}".format(side)) + for side, shape in zip(("a", "b"), input_shapes)] + logger.debug("inputs: %s", inputs) return inputs - def build_autoencoders(self, inputs): - """ Override for Model Specific autoencoder builds + def build_model(self, inputs): + """ Override for Model Specific autoencoder builds. - Inputs is defined in self.get_inputs() and is standardized for all models - if will generally be in the order: - [face (the input for image), - mask (the input for mask if it is used)] + Parameters + ---------- + inputs: list + A list of :class:`keras.layers.Input` tensors. This will be a list of 2 tensors (one + for each side) each of shapes :attr:`input_shape`. """ raise NotImplementedError - def add_networks(self): - """ Override to add neural networks """ - raise NotImplementedError + def _output_summary(self): + """ Output the summary of the model and all sub-models to the verbose logger. """ + self._model.summary(print_fn=lambda x: logger.verbose("%s", x)) + for layer in self._model.layers: + if isinstance(layer, KModel): + layer.summary(print_fn=lambda x: logger.verbose("%s", x)) - def load_state_info(self): - """ Load the input shape from state file if it exists """ - logger.debug("Loading Input Shape from State file") - if not self.state.inputs: - logger.debug("No input shapes saved. Using model config") - return - if not self.state.face_shapes: - logger.warning("Input shapes stored in State file, but no matches for 'face'." - "Using model config") - return - input_shape = self.state.face_shapes[0] - logger.debug("Setting input shape from state file: %s", input_shape) - self.input_shape = input_shape - - def add_network(self, network_type, side, network, is_output=False): - """ Add a NNMeta object """ - logger.debug("network_type: '%s', side: '%s', network: '%s', is_output: %s", - network_type, side, network, is_output) - filename = "{}_{}".format(self.name, network_type.lower()) - name = network_type.lower() - if side: - side = side.lower() - filename += "_{}".format(side.upper()) - name += "_{}".format(side) - filename += ".h5" - logger.debug("name: '%s', filename: '%s'", name, filename) - self.networks[name] = NNMeta(str(self.model_dir / filename), - network_type, - side, - network, - is_output) - - def add_predictor(self, side, model): - """ Add a predictor to the predictors dictionary """ - logger.debug("Adding predictor: (side: '%s', model: %s)", side, model) - if self.gpus > 1: - logger.debug("Converting to multi-gpu: side %s", side) - model = multi_gpu_model(model, self.gpus) - self.predictors[side] = model - if not self.state.inputs: - self.store_input_shapes(model) - - def store_input_shapes(self, model): - """ Store the input and output shapes to state """ - logger.debug("Adding input shapes to state for model") - inputs = {tensor.name: K.int_shape(tensor)[-3:] for tensor in model.inputs} - if not any(inp for inp in inputs.keys() if inp.startswith("face")): - raise ValueError("No input named 'face' was found. Check your input naming. " - "Current input names: {}".format(inputs)) - # Make sure they are all ints so that it can be json serialized - inputs = {key: tuple(int(i) for i in val) for key, val in inputs.items()} - self.state.inputs = inputs - logger.debug("Added input shapes: %s", self.state.inputs) - - def reset_pingpong(self): - """ Reset the models for pingpong training """ - logger.debug("Resetting models") - - # Clear models and graph - self.predictors = dict() - K.clear_session() - - # Load Models for current training run - for model in self.networks.values(): - model.network = Model.from_config(model.config) - model.network.set_weights(model.weights) - - inputs = self.get_inputs() - self.build_autoencoders(inputs) - self.compile_predictors(initialize=False) - logger.debug("Reset models") - - def compile_predictors(self, initialize=True): - """ Compile the predictors """ - logger.debug("Compiling Predictors") - learning_rate = self.config.get("learning_rate", 5e-5) - optimizer = self.get_optimizer(lr=learning_rate, beta_1=0.5, beta_2=0.999) - - for side, model in self.predictors.items(): - loss = Loss(model.inputs, model.outputs) - model.compile(optimizer=optimizer, loss=loss.funcs) - if initialize: - self.state.add_session_loss_names(side, loss.names) - self.history[side] = list() - logger.debug("Compiled Predictors. Losses: %s", loss.names) - - def get_optimizer(self, lr=5e-5, beta_1=0.5, beta_2=0.999): # pylint: disable=invalid-name - """ Build and return Optimizer """ - opt_kwargs = dict(lr=lr, beta_1=beta_1, beta_2=beta_2) - if (self.config.get("clipnorm", False) and - keras.backend.backend() != "plaidml.keras.backend"): - # NB: Clip-norm is ballooning VRAM usage, which is not expected behavior - # and may be a bug in Keras/Tensorflow. - # PlaidML has a bug regarding the clip-norm parameter - # See: https://github.com/plaidml/plaidml/issues/228 - # Workaround by simply removing it. - # TODO: Remove this as soon it is fixed in PlaidML. - opt_kwargs["clipnorm"] = 1.0 - logger.debug("Optimizer kwargs: %s", opt_kwargs) - return Adam(**opt_kwargs, cpu_mode=self.vram_savings.optimizer_savings) - - def converter(self, swap): - """ Converter for autoencoder models """ - logger.debug("Getting Converter: (swap: %s)", swap) - side = "a" if swap else "b" - model = self.predictors[side] - if self.predict: - # Must compile the model to be thread safe - model._make_predict_function() # pylint: disable=protected-access - retval = model.predict - logger.debug("Got Converter: %s", retval) + def save(self): + """ Save the model to disk. + + Saves the serialized model, with weights, to the folder location specified when + initializing the plugin. If loss has dropped on both sides of the model, then + a backup is taken. + """ + self._io._save() # pylint:disable=protected-access + + def snapshot(self): + """ Creates a snapshot of the model folder to the models parent folder, with the number + of iterations completed appended to the end of the model name. """ + self._io._snapshot() # pylint:disable=protected-access + + def _compile_model(self): + """ Compile the model to include the Optimizer and Loss Function(s). """ + logger.debug("Compiling Model") + optimizer = self._get_optimizer() + loss = _Loss(self._model.inputs, self._model.outputs) + self._model.compile(optimizer=optimizer, loss=loss.functions) + if not self._is_predict: + self._state.add_session_loss_names(loss.names) + logger.debug("Compiled Model: %s", self._model) + + def _get_optimizer(self): + """ Return a Keras Adam Optimizer with user selected parameters. + + Returns + ------- + :class:`keras.optimizers.Adam` + An Adam Optimizer with the given user settings + + Notes + ----- + Clip-norm is ballooning VRAM usage, which is not expected behavior and may be a bug in + Keras/Tensorflow. + + PlaidML has a bug regarding the clip-norm parameter See: + https://github.com/plaidml/plaidml/issues/228. We workaround by simply not adding this + parameter for AMD backend users. + """ + kwargs = dict(beta_1=0.5, beta_2=0.99) + + learning_rate = "lr" if get_backend() == "amd" else "learning_rate" + kwargs[learning_rate] = self.config.get("learning_rate", 5e-5) + + clipnorm = self.config.get("clipnorm", False) + if clipnorm and (self._args.distributed or self._args.mixed_precision): + logger.warning("Clipnorm has been selected, but is unsupported when using distributed " + "or mixed_precision training, so has been disabled. If you wish to " + "enable clipnorm, then you must disable these options.") + clipnorm = False + if clipnorm and get_backend() == "amd": + # TODO add clipnorm in for plaidML when it is fixed upstream. Still not fixed in + # release 0.7.0. + logger.warning("Due to a bug in plaidML, clipnorm cannot be used on AMD backends so " + "has been disabled") + clipnorm = False + if clipnorm: + kwargs["clipnorm"] = 1.0 + + retval = Adam(**kwargs) + if self._settings.use_mixed_precision: + retval = self._settings.LossScaleOptimizer(retval, loss_scale="dynamic") + logger.debug("Optimizer: %s, kwargs: %s", retval, kwargs) return retval + def _legacy_mapping(self): # pylint:disable=no-self-use + """ The mapping of separate model files to single model layers for transferring of legacy + weights. + + Returns + ------- + dict or ``None`` + Dictionary of original H5 filenames for legacy models mapped to new layer names or + ``None`` if the model did not exist in Faceswap prior to Tensorflow 2 + """ + return None + + def add_history(self, loss): + """ Add the current iteration's loss history to :attr:`_io.history`. + + Called from the trainer after each iteration, for tracking loss drop over time between + save iterations. + + Parameters + ---------- + loss: list + The loss values for the A and B side for the current iteration. This should be the + collated loss values for each side. + """ + self._io.history[0].append(loss[0]) + self._io.history[1].append(loss[1]) + + +class _IO(): + """ Model saving and loading functions. + + Handles the loading and saving of the plugin model from disk as well as the model backup and + snapshot functions. + + Parameters + ---------- + plugin: :class:`Model` + The parent plugin class that owns the IO functions. + model_dir: str + The full path to the model save location + is_predict: bool + ``True`` if the model is being loaded for inference. ``False`` if the model is being loaded + for training. + """ + def __init__(self, plugin, model_dir, is_predict): + self._plugin = plugin + self._is_predict = is_predict + self._model_dir = model_dir + self._history = [[], []] # Loss histories per save iteration + self._backup = Backup(self._model_dir, self._plugin.name) + @property - def iterations(self): - "Get current training iteration number" - return self.state.iterations - - def map_models(self, swapped): - """ Map the models for A/B side for swapping """ - logger.debug("Map models: (swapped: %s)", swapped) - models_map = {"a": dict(), "b": dict()} - sides = ("a", "b") if not swapped else ("b", "a") - for network in self.networks.values(): - if network.side == sides[0]: - models_map["a"][network.type] = network.filename - if network.side == sides[1]: - models_map["b"][network.type] = network.filename - logger.debug("Mapped models: (models_map: %s)", models_map) - return models_map - - def log_summary(self): - """ Verbose log the model summaries """ - if self.predict: - return - for side in sorted(list(self.predictors.keys())): - logger.verbose("[%s %s Summary]:", self.name.title(), side.upper()) - self.predictors[side].summary(print_fn=lambda x: logger.verbose("%s", x)) - for name, nnmeta in self.networks.items(): - if nnmeta.side is not None and nnmeta.side != side: - continue - logger.verbose("%s:", name.title()) - nnmeta.network.summary(print_fn=lambda x: logger.verbose("%s", x)) - - def do_snapshot(self): - """ Perform a model snapshot """ - logger.debug("Performing snapshot") - self.backup.snapshot_models(self.iterations) - logger.debug("Performed snapshot") + def _filename(self): + """str: The filename for this model.""" + return os.path.join(self._model_dir, "{}.h5".format(self._plugin.name)) - def load_models(self, swapped): - """ Load models from file """ - logger.debug("Load model: (swapped: %s)", swapped) + @property + def model_exists(self): + """ bool: ``True`` if a model of the type being loaded exists within the model folder + location otherwise ``False``. + """ + return os.path.isfile(self._filename) - if not self.models_exist and not self.predict: - logger.info("Creating new '%s' model in folder: '%s'", self.name, self.model_dir) - return None - if not self.models_exist and self.predict: - logger.error("Model could not be found in folder '%s'. Exiting", self.model_dir) - exit(0) - - if not self.is_legacy or not self.predict: - K.clear_session() - model_mapping = self.map_models(swapped) - for network in self.networks.values(): - if not network.side: - is_loaded = network.load() - else: - is_loaded = network.load(fullpath=model_mapping[network.side][network.type]) - if not is_loaded: - break - if is_loaded: - logger.info("Loaded model from disk: '%s'", self.model_dir) - return is_loaded - - def save_models(self): - """ Backup and save the models """ + @property + def history(self): + """ list: list of loss histories per side for the current save iteration. """ + return self._history + + @property + def multiple_models_in_folder(self): + """ :list: or ``None`` If there are multiple model types in the requested folder, or model + types that don't correspond to the requested plugin type, then returns the list of plugin + names that exist in the folder, otherwise returns ``None`` """ + plugins = [fname.replace(".h5", "") + for fname in os.listdir(self._model_dir) + if fname.endswith(".h5")] + test_names = plugins + [self._plugin.name] + test = False if not test_names else os.path.commonprefix(test_names) == "" + retval = None if not test else plugins + logger.debug("plugin name: %s, plugins: %s, test result: %s, retval: %s", + self._plugin.name, plugins, test, retval) + return retval + + def _load(self): + """ Loads the model from disk + + If the predict function is to be called and the model cannot be found in the model folder + then an error is logged and the process exits. + + When loading the model, the plugin model folder is scanned for custom layers which are + added to Keras' custom objects. + + Returns + ------- + :class:`keras.models.Model` + The saved model loaded from disk + """ + logger.debug("Loading model: %s", self._filename) + if self._is_predict and not self.model_exists: + logger.error("Model could not be found in folder '%s'. Exiting", self._model_dir) + sys.exit(1) + + model = load_model(self._filename, compile=False) + logger.info("Loaded model from disk: '%s'", self._filename) + return model + + def _save(self): + """ Backup and save the model and state file. + + Notes + ----- + The backup function actually backups the model from the previous save iteration rather than + the current save iteration. This is not a bug, but protection against long save times, as + models can get quite large, so renaming the current model file rather than copying it can + save substantial amount of time. + """ logger.debug("Backing up and saving models") - # Insert a new line to avoid spamming the same row as loss output - print("") - save_averages = self.get_save_averages() - backup_func = self.backup.backup_model if self.should_backup(save_averages) else None - if backup_func: - logger.info("Backing up models...") - executor = futures.ThreadPoolExecutor() - save_threads = [executor.submit(network.save, backup_func=backup_func) - for network in self.networks.values()] - save_threads.append(executor.submit(self.state.save, backup_func=backup_func)) - futures.wait(save_threads) - # call result() to capture errors - _ = [thread.result() for thread in save_threads] + print("") # Insert a new line to avoid spamming the same row as loss output + save_averages = self._get_save_averages() + if save_averages and self._should_backup(save_averages): + self._backup.backup_model(self._filename) + # pylint:disable=protected-access + self._backup.backup_model(self._plugin.state._filename) + + self._plugin.model.save(self._filename, include_optimizer=False) + self._plugin.state.save() + msg = "[Saved models]" if save_averages: - lossmsg = ["{}_{}: {:.5f}".format(self.state.loss_names[side][0], - side.capitalize(), - save_averages[side]) - for side in sorted(list(save_averages.keys()))] - msg += " - Average since last save: {}".format(", ".join(lossmsg)) + lossmsg = ["face_{}: {:.5f}".format(side, avg) + for side, avg in zip(("a", "b"), save_averages)] + msg += " - Average loss since last save: {}".format(", ".join(lossmsg)) logger.info(msg) - def get_save_averages(self): + def _get_save_averages(self): """ Return the average loss since the last save iteration and reset historical loss """ logger.debug("Getting save averages") - avgs = dict() - for side, loss in self.history.items(): - if not loss: - logger.debug("No loss in self.history: %s", side) - break - avgs[side] = sum(loss) / len(loss) - self.history[side] = list() # Reset historical loss - logger.debug("Average losses since last save: %s", avgs) - return avgs - - def should_backup(self, save_averages): - """ Check whether the loss averages for all losses is the lowest that has been seen. - - This protects against model corruption by only backing up the model - if any of the loss values have fallen. - TODO This is not a perfect system. If the model corrupts on save_iteration - 1 - then model may still backup + if not all(loss for loss in self._history): + logger.debug("No loss in history") + retval = [] + else: + retval = [sum(loss) / len(loss) for loss in self._history] + self._history = [[], []] # Reset historical loss + logger.debug("Average losses since last save: %s", retval) + return retval + + def _should_backup(self, save_averages): + """ Check whether the loss averages for this save iteration is the lowest that has been + seen. + + This protects against model corruption by only backing up the model if both sides have + seen a total fall in loss. + + Notes + ----- + This is by no means a perfect system. If the model corrupts at an iteration close + to a save iteration, then the averages may still be pushed lower than a previous + save average, resulting in backing up a corrupted model. + + Parameters + ---------- + save_averages: list + The average loss for each side for this save iteration """ backup = True + for side, loss in zip(("a", "b"), save_averages): + if not self._plugin.state.lowest_avg_loss.get(side, None): + logger.debug("Set initial save iteration loss average for '%s': %s", side, loss) + self._plugin.state.lowest_avg_loss[side] = loss + continue + backup = loss < self._plugin.state.lowest_avg_loss[side] if backup else backup - if not save_averages: - logger.debug("No save averages. Not backing up") - return False + if backup: # Update lowest loss values to the state file + # pylint:disable=unnecessary-comprehension + old_avgs = {key: val for key, val in self._plugin.state.lowest_avg_loss.items()} + self._plugin.state.lowest_avg_loss["a"] = save_averages[0] + self._plugin.state.lowest_avg_loss["b"] = save_averages[1] + logger.debug("Updated lowest historical save iteration averages from: %s to: %s", + old_avgs, self._plugin.state.lowest_avg_loss) - for side, loss in save_averages.items(): - if not self.state.lowest_avg_loss.get(side, None): - logger.debug("Setting initial save iteration loss average for '%s': %s", - side, loss) - self.state.lowest_avg_loss[side] = loss - continue - if backup: - # Only run this if backup is true. All losses must have dropped for a valid backup - backup = self.check_loss_drop(side, loss) + logger.debug("Should backup: %s", backup) + return backup - logger.debug("Lowest historical save iteration loss average: %s", - self.state.lowest_avg_loss) + def _snapshot(self): + """ Perform a model snapshot. - if backup: # Update lowest loss values to the state - for side, avg_loss in save_averages.items(): - logger.debug("Updating lowest save iteration average for '%s': %s", side, avg_loss) - self.state.lowest_avg_loss[side] = avg_loss + Notes + ----- + Snapshot function is called 1 iteration after the model was saved, so that it is built from + the latest save, hence iteration being reduced by 1. + """ + logger.debug("Performing snapshot. Iterations: %s", self._plugin.iterations) + self._backup.snapshot_models(self._plugin.iterations - 1) + logger.debug("Performed snapshot") - logger.debug("Backing up: %s", backup) - return backup - def check_loss_drop(self, side, avg): - """ Check whether total loss has dropped since lowest loss """ - if avg < self.state.lowest_avg_loss[side]: - logger.debug("Loss for '%s' has dropped", side) - return True - logger.debug("Loss for '%s' has not dropped", side) - return False - - def rename_legacy(self): - """ Legacy Original, LowMem and IAE models had inconsistent naming conventions - Rename them if they are found and update """ - legacy_mapping = {"iae": [("IAE_decoder.h5", "iae_decoder.h5"), - ("IAE_encoder.h5", "iae_encoder.h5"), - ("IAE_inter_A.h5", "iae_intermediate_A.h5"), - ("IAE_inter_B.h5", "iae_intermediate_B.h5"), - ("IAE_inter_both.h5", "iae_inter.h5")], - "original": [("encoder.h5", "original_encoder.h5"), - ("decoder_A.h5", "original_decoder_A.h5"), - ("decoder_B.h5", "original_decoder_B.h5"), - ("lowmem_encoder.h5", "original_encoder.h5"), - ("lowmem_decoder_A.h5", "original_decoder_A.h5"), - ("lowmem_decoder_B.h5", "original_decoder_B.h5")]} - if self.name not in legacy_mapping.keys(): - return - logger.debug("Renaming legacy files") +class _Settings(): + """ Tensorflow core training settings. - set_lowmem = False - updated = False - for old_name, new_name in legacy_mapping[self.name]: - old_path = os.path.join(str(self.model_dir), old_name) - new_path = os.path.join(str(self.model_dir), new_name) - if os.path.exists(old_path) and not os.path.exists(new_path): - logger.info("Updating legacy model name from: '%s' to '%s'", old_name, new_name) - os.rename(old_path, new_path) - if old_name.startswith("lowmem"): - set_lowmem = True - updated = True + Sets backend tensorflow settings prior to launching the model. - if not updated: - logger.debug("No legacy files to rename") - return + Tensorflow 2 uses distribution strategies for multi-GPU/system training. These are context + managers. To enable the code to be more readable, we handle strategies the same way for Nvidia + and AMD backends. PlaidML does not support strategies, but we need to still create a context + manager so that we don't need branching logic. - self.is_legacy = True - logger.debug("Creating state file for legacy model") - self.state.inputs = {"face:0": [64, 64, 3]} - self.state.training_size = 256 - self.state.config["coverage"] = 62.5 - self.state.config["reflect_padding"] = False - self.state.config["mask_type"] = None - self.state.config["mask_blur_kernel"] = 3 - self.state.config["mask_threshold"] = 4 - self.state.config["learn_mask"] = False - self.state.config["lowmem"] = False - self.encoder_dim = 1024 - - if set_lowmem: - logger.debug("Setting encoder_dim and lowmem flag for legacy lowmem model") - self.encoder_dim = 512 - self.state.config["lowmem"] = True - - self.state.replace_config(self.config_changeable_items) - self.state.save() - - -class VRAMSavings(): - """ VRAM Saving training methods """ - def __init__(self, pingpong, optimizer_savings, memory_saving_gradients): - logger.debug("Initializing %s: (pingpong: %s, optimizer_savings: %s, " - "memory_saving_gradients: %s)", self.__class__.__name__, - pingpong, optimizer_savings, memory_saving_gradients) - self.is_plaidml = keras.backend.backend() == "plaidml.keras.backend" - self.pingpong = self.set_pingpong(pingpong) - self.optimizer_savings = self.set_optimizer_savings(optimizer_savings) - self.memory_saving_gradients = self.set_gradient_type(memory_saving_gradients) - logger.debug("Initialized: %s", self.__class__.__name__) + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The arguments that were passed to the train or convert process as generated from + Faceswap's command line arguments + allow_growth: bool + ``True`` if the Tensorflow allow_growth parameter should be set otherwise ``False`` + is_predict: bool, optional + ``True`` if the model is being loaded for inference, ``False`` if the model is being loaded + for training. Default: ``False`` + """ + def __init__(self, arguments, allow_growth, is_predict): + logger.debug("Initializing %s: (arguments: %s, allow_growth: %s, is_predict: %s)", + self.__class__.__name__, arguments, allow_growth, is_predict) + self._set_tf_settings(allow_growth, arguments.exclude_gpus) + + use_mixed_precision = not is_predict and arguments.mixed_precision + if use_mixed_precision: + self._mixed_precision = tf.keras.mixed_precision.experimental + else: + self._mixed_precision = None - def set_pingpong(self, pingpong): - """ Disable pingpong for plaidML users """ - if pingpong and self.is_plaidml: - logger.warning("Pingpong training not supported on plaidML. Disabling") - pingpong = False - logger.debug("pingpong: %s", pingpong) - if pingpong: - logger.info("Using Pingpong Training") - return pingpong - - def set_optimizer_savings(self, optimizer_savings): - """ Disable optimizer savings for plaidML users """ - if optimizer_savings and self.is_plaidml == "plaidml.keras.backend": - logger.warning("Optimizer Savings not supported on plaidML. Disabling") - optimizer_savings = False - logger.debug("optimizer_savings: %s", optimizer_savings) - if optimizer_savings: - logger.info("Using Optimizer Savings") - return optimizer_savings - - def set_gradient_type(self, memory_saving_gradients): - """ Monkey-patch Memory Saving Gradients if requested """ - if memory_saving_gradients and self.is_plaidml: - logger.warning("Memory Saving Gradients not supported on plaidML. Disabling") - memory_saving_gradients = False - logger.debug("memory_saving_gradients: %s", memory_saving_gradients) - if memory_saving_gradients: - logger.info("Using Memory Saving Gradients") - from lib.model import memory_saving_gradients - K.__dict__["gradients"] = memory_saving_gradients.gradients_memory - return memory_saving_gradients - - -class Loss(): - """ Holds loss names and functions for an Autoencoder """ - def __init__(self, inputs, outputs): - logger.debug("Initializing %s: (inputs: %s, outputs: %s)", - self.__class__.__name__, inputs, outputs) - self.inputs = inputs - self.outputs = outputs - self.names = self.get_loss_names() - self.funcs = self.get_loss_functions() - if len(self.names) > 1: - self.names.insert(0, "total_loss") - logger.debug("Initialized: %s", self.__class__.__name__) + self._use_mixed_precision = self._set_keras_mixed_precision(use_mixed_precision, + bool(arguments.exclude_gpus)) - @property - def loss_dict(self): - """ Return the loss dict """ - loss_dict = dict(mae=losses.mean_absolute_error, - mse=losses.mean_squared_error, - logcosh=losses.logcosh, - smooth_loss=generalized_loss, - l_inf_norm=l_inf_norm, - ssim=DSSIMObjective(), - gmsd=gmsd_loss, - pixel_gradient_diff=gradient_loss) - return loss_dict + distributed = False if not hasattr(arguments, "distributed") else arguments.distributed + self._strategy = self._get_strategy(distributed) + logger.debug("Initialized %s", self.__class__.__name__) @property - def config(self): - """ Return the global _CONFIG variable """ - return _CONFIG + def use_strategy(self): + """ bool: ``True`` if a distribution strategy is to be used otherwise ``False``. """ + return self._strategy is not None @property - def mask_preprocessing_func(self): - """ The selected pre-processing function for the mask """ - retval = None - if self.config.get("mask_blur", False): - retval = gaussian_blur(max(1, self.mask_shape[1] // 32)) - logger.debug(retval) - return retval + def use_mixed_precision(self): + """ bool: ``True`` if mixed precision training has been enabled, otherwise ``False``. """ + return self._use_mixed_precision @property - def selected_loss(self): - """ Return the selected loss function """ - retval = self.loss_dict[self.config.get("loss_function", "mae")] - logger.debug(retval) + def LossScaleOptimizer(self): # pylint:disable=invalid-name + """ :class:`tf.keras.mixed_precision.experimental.LossScaleOptimizer`: Shortcut to the loss + scale optimizer for mixed precision training. """ + return self._mixed_precision.LossScaleOptimizer + + @classmethod + def _set_tf_settings(cls, allow_growth, exclude_devices): + """ Specify Devices to place operations on and Allow TensorFlow to manage VRAM growth. + + Enables the Tensorflow allow_growth option if requested in the command line arguments + + Parameters + ---------- + allow_growth: bool + ``True`` if the Tensorflow allow_growth parameter should be set otherwise ``False`` + exclude_devices: list or ``None`` + List of GPU device indices that should not be made available to Tensorflow. Pass + ``None`` if all devices should be made available + """ + if get_backend() == "amd": + return # No settings for AMD + if get_backend() == "cpu": + logger.verbose("Hiding GPUs from Tensorflow") + tf.config.set_visible_devices([], "GPU") + return + + if not exclude_devices and not allow_growth: + logger.debug("Not setting any specific Tensorflow settings") + return + + gpus = tf.config.list_physical_devices('GPU') + if exclude_devices: + gpus = [gpu for idx, gpu in enumerate(gpus) if idx not in exclude_devices] + logger.debug("Filtering devices to: %s", gpus) + tf.config.set_visible_devices(gpus, "GPU") + + if allow_growth: + logger.debug("Setting Tensorflow 'allow_growth' option") + for gpu in gpus: + logger.info("Setting allow growth for GPU: %s", gpu) + tf.config.experimental.set_memory_growth(gpu, True) + logger.debug("Set Tensorflow 'allow_growth' option") + + def _set_keras_mixed_precision(self, use_mixed_precision, skip_check): + """ Enable the Keras experimental Mixed Precision API. + + Enables the Keras experimental Mixed Precision API if requested in the user configuration + file. + + Parameters + ---------- + use_mixed_precision: bool + ``True`` if experimental mixed precision support should be enabled for Nvidia GPUs + otherwise ``False``. + skip_check: bool + ``True`` if the mixed precision compatibility check should be skipped, otherwise + ``False``. + + There is a bug in Tensorflow that will cause a failure if + "set_visible_devices" has been set and mixed_precision is enabled. Specifically in + :file:`tensorflow.python.keras.mixed_precision.experimental.device_compatibility_check` + + From doc-string: "if list_local_devices() and tf.config.set_visible_devices() are both + called, TensorFlow will crash. However, GPU names and compute capabilities cannot be + checked without list_local_devices(). + + To get around this, we hack in to set a global parameter to indicate the test has + already been performed. This is likely to cause some issues, but not as many as + guaranteed failure when limiting GPU devices + """ + logger.debug("use_mixed_precision: %s, skip_check: %s", use_mixed_precision, skip_check) + if get_backend() != "nvidia" or not use_mixed_precision: + logger.debug("Not enabling 'mixed_precision' (backend: %s, use_mixed_precision: %s)", + get_backend(), use_mixed_precision) + return False + logger.info("Enabling Mixed Precision Training.") + + if skip_check: + # TODO remove this hacky fix to disable mixed precision compatibility testing if/when + # fixed upstream. + # pylint:disable=import-outside-toplevel,protected-access + from tensorflow.python.keras.mixed_precision.experimental import \ + device_compatibility_check + logger.debug("Overriding tensorflow _logged_compatibility_check parameter. Initial " + "value: %s", device_compatibility_check._logged_compatibility_check) + device_compatibility_check._logged_compatibility_check = True + logger.debug("New value: %s", device_compatibility_check._logged_compatibility_check) + + policy = self._mixed_precision.Policy('mixed_float16') + self._mixed_precision.set_policy(policy) + logger.debug("Enabled mixed precision. (Compute dtype: %s, variable_dtype: %s)", + policy.compute_dtype, policy.variable_dtype) + return True + + @classmethod + def _get_strategy(cls, distributed): + """ If we are running on Nvidia backend and the strategy is not `"default"` then return + the correct tensorflow distribution strategy, otherwise return ``None``. + + Notes + ----- + By default Tensorflow defaults mirrored strategy to use the Nvidia NCCL method for + reductions, however this is only available in Linux, so the method used falls back to + `Hierarchical Copy All Reduce` if the OS is not Linux. + + Parameters + ---------- + distributed: bool + ``True`` if Tensorflow mirrored strategy should be used for multiple GPU training. + ``False`` if the default strategy should be used. + + Returns + ------- + :class:`tensorflow.python.distribute.Strategy` or `None` + The request Tensorflow Strategy if the backend is Nvidia and the strategy is not + `"Default"` otherwise ``None`` + """ + if get_backend() != "nvidia": + retval = None + elif distributed: + if platform.system().lower() == "linux": + cross_device_ops = tf.distribute.NcclAllReduce() + else: + cross_device_ops = tf.distribute.HierarchicalCopyAllReduce() + logger.debug("cross_device_ops: %s", cross_device_ops) + retval = tf.distribute.MirroredStrategy(cross_device_ops=cross_device_ops) + else: + retval = tf.distribute.get_strategy() + logger.debug("Using strategy: %s", retval) return retval - @property - def selected_mask_loss(self): - """ Return the selected mask loss function. Currently returns mse - If a processing function has been requested wrap the loss function - in loss wrapper """ - loss_func = self.loss_dict["mse"] - func = self.mask_preprocessing_func - logger.debug("loss_func: %s, func: %s", loss_func, func) - retval = mask_loss_wrapper(loss_func, preprocessing_func=func) + def strategy_scope(self): + """ Return the strategy scope if we have set a strategy, otherwise return a null + context. + + Returns + ------- + :func:`tensorflow.python.distribute.Strategy.scope` or :func:`contextlib.nullcontext` + The tensorflow strategy scope if a strategy is valid in the current scenario. A null + context manager if the strategy is not valid in the current scenario + """ + retval = nullcontext() if self._strategy is None else self._strategy.scope() + logger.debug("Using strategy scope: %s", retval) return retval + +class _Loss(): + """ Holds loss names and functions for an Autoencoder. + + Parameters + ---------- + inputs: list + A list of input tensors to the model in the order ("a", "b") + outputs: list + A list of output tensors to the model in the order ("a", "b") + """ + def __init__(self, inputs, outputs): + logger.debug("Initializing %s: (inputs: %s, outputs: %s)", + self.__class__.__name__, inputs, outputs) + self._loss_dict = dict(mae=k_losses.mean_absolute_error, + mse=k_losses.mean_squared_error, + logcosh=k_losses.logcosh, + smooth_loss=losses.GeneralizedLoss(), + l_inf_norm=losses.LInfNorm(), + ssim=losses.DSSIMObjective(), + gmsd=losses.GMSDLoss(), + pixel_gradient_diff=losses.GradientLoss()) + self._inputs = inputs + self._names = self._get_loss_names(outputs) + self._funcs = self._get_loss_functions() + self._names.insert(0, "total") + logger.debug("Initialized: %s", self.__class__.__name__) + @property - def output_shapes(self): - """ The shapes of the output nodes """ - return [K.int_shape(output)[1:] for output in self.outputs] + def names(self): + """ list: The list of loss names for the model. """ + return self._names @property - def mask_input(self): - """ Return the mask input or None """ - mask_inputs = [inp for inp in self.inputs if inp.name.startswith("mask")] - if not mask_inputs: - return None - return mask_inputs[0] + def functions(self): + """ list: The list of loss functions for the model. """ + return self._funcs @property - def mask_shape(self): - """ Return the mask shape """ - if self.mask_input is None: - return None - return K.int_shape(self.mask_input)[1:] - - def get_loss_names(self): - """ Return the loss names based on model output """ - output_names = [output.name for output in self.outputs] - logger.debug("Model output names: %s", output_names) - loss_names = [name[name.find("/") + 1:name.rfind("/")].replace("_out", "") - for name in output_names] - if not all(name.startswith("face") or name.startswith("mask") for name in loss_names): - # Handle incorrectly named/legacy outputs - logger.debug("Renaming loss names from: %s", loss_names) - loss_names = self.update_loss_names() - loss_names = ["{}_loss".format(name) for name in loss_names] - logger.debug(loss_names) - return loss_names - - def update_loss_names(self): - """ Update loss names if named incorrectly or legacy model """ - output_types = ["mask" if shape[-1] == 1 else "face" for shape in self.output_shapes] - loss_names = ["{}{}".format(name, - "" if output_types.count(name) == 1 else "_{}".format(idx)) - for idx, name in enumerate(output_types)] - logger.debug("Renamed loss names to: %s", loss_names) - return loss_names - - def get_loss_functions(self): - """ Set the loss function """ - loss_funcs = [] - for idx, loss_name in enumerate(self.names): - if loss_name.startswith("mask"): - loss_funcs.append(self.selected_mask_loss) - elif self.config["penalized_mask_loss"] and self.config["mask_type"] is not None: - face_size = self.output_shapes[idx][1] - mask_size = self.mask_shape[1] - scaling = face_size / mask_size - logger.debug("face_size: %s mask_size: %s, mask_scaling: %s", - face_size, mask_size, scaling) - loss_funcs.append(PenalizedLoss(self.mask_input, self.selected_loss, - mask_scaling=scaling, - preprocessing_func=self.mask_preprocessing_func)) - else: - loss_funcs.append(self.selected_loss) - logger.debug("%s: %s", loss_name, loss_funcs[-1]) - logger.debug(loss_funcs) - return loss_funcs + def _config(self): + """ :dict: The configuration options for this plugin """ + return _CONFIG + @property + def _selected_mask_loss(self): + """ :func:`keras.losses.Loss`: The selected mask loss function. Currently returns mean + standard error as the default function. """ + loss_func = self._loss_dict["mse"] + logger.debug("loss_func: %s", loss_func) + return loss_func -class NNMeta(): - """ Class to hold a neural network and it's meta data + @property + def _mask_inputs(self): + """ list: The list of input tensors to the model that contain the mask. Returns ``None`` + if there is no mask input to the model. """ + mask_inputs = [inp for inp in self._inputs if inp.name.startswith("mask")] + return None if not mask_inputs else mask_inputs - filename: The full path and filename of the model file for this network. - type: The type of network. For networks that can be swapped - The type should be identical for the corresponding - A and B networks, and should be unique for every A/B pair. - Otherwise the type should be completely unique. - side: A, B or None. Used to identify which networks can - be swapped. - network: Define network to this. - is_output: Set to True to indicate that this network is an output to the Autoencoder - """ + @property + def _mask_shapes(self): + """ list: The list of shape tuples for the mask input tensors for the model. Returns + ``None`` if there is no mask input. """ + if self._mask_inputs is None: + return None + return [K.int_shape(mask_input) for mask_input in self._mask_inputs] - def __init__(self, filename, network_type, side, network, is_output): - logger.debug("Initializing %s: (filename: '%s', network_type: '%s', side: '%s', " - "network: %s, is_output: %s", self.__class__.__name__, filename, - network_type, side, network, is_output) - self.filename = filename - self.type = network_type.lower() - self.side = side - self.name = self.set_name() - self.network = network - self.is_output = is_output - self.network.name = self.name - self.config = network.get_config() # For pingpong restore - self.weights = network.get_weights() # For pingpong restore - logger.debug("Initialized %s", self.__class__.__name__) + @classmethod + def _get_loss_names(cls, outputs): + """ Name the losses based on model output - @property - def output_shapes(self): - """ Return the output shapes from the stored network """ - return [K.int_shape(output) for output in self.network.outputs] + Notes + ----- + TODO Currently there is an issue in Tensorflow that wraps all outputs in an Identity layer + when running in Eager Execution mode, which means we cannot use the name of the output + layers to name the losses (https://github.com/tensorflow/tensorflow/issues/32180). + With this in mind, losses are named based on their shapes - def set_name(self): - """ Set the network name """ - name = self.type - if self.side: - name += "_{}".format(self.side) - return name + Parameters + ---------- + outputs: list + A list of output tensors from the model plugin - @property - def output_names(self): - """ Return output node names """ - output_names = [output.name for output in self.network.outputs] - if self.is_output and not any(name.startswith("face_out") for name in output_names): - # Saved models break if their layer names are changed, so dummy - # in correct output names for legacy models - output_names = self.get_output_names() - return output_names - - def get_output_names(self): - """ Return the output names based on number of channels and instances """ - output_types = ["mask_out" if K.int_shape(output)[-1] == 1 else "face_out" - for output in self.network.outputs] - output_names = ["{}{}".format(name, - "" if output_types.count(name) == 1 else "_{}".format(idx)) - for idx, name in enumerate(output_types)] - logger.debug("Overridden output_names: %s", output_names) - return output_names - - def load(self, fullpath=None): - """ Load model """ - fullpath = fullpath if fullpath else self.filename - logger.debug("Loading model: '%s'", fullpath) - try: - network = load_model(self.filename, custom_objects=get_custom_objects()) - except ValueError as err: - if str(err).lower().startswith("cannot create group in read only mode"): - self.convert_legacy_weights() - return True - logger.warning("Failed loading existing training data. Generating new models") - logger.debug("Exception: %s", str(err)) - return False - except OSError as err: # pylint: disable=broad-except - logger.warning("Failed loading existing training data. Generating new models") - logger.debug("Exception: %s", str(err)) - return False - self.config = network.get_config() - self.network = network # Update network with saved model - self.network.name = self.name - return True + Returns + ------- + list + A list of names for the losses to be applied to the model + """ + # TODO Use output names if/when these are fixed upstream + split_outputs = [outputs[:len(outputs) // 2], outputs[len(outputs) // 2:]] + retval = [] + for side, side_output in zip(("a", "b"), split_outputs): + output_names = [output.name for output in side_output] + output_shapes = [K.int_shape(output)[1:] for output in side_output] + output_types = ["mask" if shape[-1] == 1 else "face" for shape in output_shapes] + logger.debug("side: %s, output names: %s, output_shapes: %s, output_types: %s", + side, output_names, output_shapes, output_types) + retval.extend(["{}_{}{}".format(name, side, + "" if output_types.count(name) == 1 + else "_{}".format(idx)) + for idx, name in enumerate(output_types)]) + logger.debug(retval) + return retval - def save(self, fullpath=None, backup_func=None): - """ Save model """ - fullpath = fullpath if fullpath else self.filename - if backup_func: - backup_func(fullpath) - logger.debug("Saving model: '%s'", fullpath) - self.weights = self.network.get_weights() - self.network.save(fullpath) + def _get_loss_functions(self): + """ Set the loss functions. - def convert_legacy_weights(self): - """ Convert legacy weights files to hold the model topology """ - logger.info("Adding model topology to legacy weights file: '%s'", self.filename) - self.network.load_weights(self.filename) - self.save(backup_func=None) - self.network.name = self.type + Returns + ------- + list + A list of loss functions to apply to the model + """ + selected_loss = self._loss_dict[self._config.get("loss_function", "mae")] + loss_funcs = [] + for name in self._names: + if name.startswith("mask"): + loss_funcs.append(self._selected_mask_loss) + elif self._config["penalized_mask_loss"]: + loss_funcs.append(losses.PenalizedLoss(selected_loss)) + else: + loss_funcs.append(selected_loss) + logger.debug("%s: %s", name, loss_funcs[-1]) + logger.debug(loss_funcs) + return loss_funcs class State(): - """ Class to hold the model's current state and autoencoder structure """ - def __init__(self, model_dir, model_name, config_changeable_items, - no_logs, pingpong, training_image_size): + """ Holds state information relating to the plugin's saved model. + + Parameters + ---------- + model_dir: str + The full path to the model save location + model_name: str + The name of the model plugin + config_changeable_items: dict + Configuration options that can be altered when resuming a model, and their current values + no_logs: bool + ``True`` if Tensorboard logs should not be generated, otherwise ``False`` + training_image_size: int + The size of the training images in the training folder + """ + def __init__(self, + model_dir, + model_name, + config_changeable_items, + no_logs, + training_image_size): logger.debug("Initializing %s: (model_dir: '%s', model_name: '%s', " - "config_changeable_items: '%s', no_logs: %s, pingpong: %s, " - "training_image_size: '%s'", self.__class__.__name__, model_dir, model_name, - config_changeable_items, no_logs, pingpong, training_image_size) - self.serializer = get_serializer("json") - filename = "{}_state.{}".format(model_name, self.serializer.file_extension) - self.filename = str(model_dir / filename) - self.name = model_name - self.iterations = 0 - self.session_iterations = 0 - self.training_size = training_image_size - self.sessions = dict() - self.lowest_avg_loss = dict() - self.inputs = dict() - self.config = dict() - self.load(config_changeable_items) - self.session_id = self.new_session_id() - self.create_new_session(no_logs, pingpong, config_changeable_items) + "config_changeable_items: '%s', no_logs: %s, training_image_size: '%s'", + self.__class__.__name__, model_dir, model_name, config_changeable_items, + no_logs, training_image_size) + self._serializer = get_serializer("json") + filename = "{}_state.{}".format(model_name, self._serializer.file_extension) + self._filename = os.path.join(model_dir, filename) + self._name = model_name + self._iterations = 0 + self._training_size = training_image_size + self._sessions = dict() + self._lowest_avg_loss = dict() + self._config = dict() + self._load(config_changeable_items) + self._session_id = self._new_session_id() + self._create_new_session(no_logs, config_changeable_items) logger.debug("Initialized %s:", self.__class__.__name__) @property - def face_shapes(self): - """ Return a list of stored face shape inputs """ - return [tuple(val) for key, val in self.inputs.items() if key.startswith("face")] + def loss_names(self): + """ list: The loss names for the current session """ + return self._sessions[self._session_id]["loss_names"] @property - def mask_shapes(self): - """ Return a list of stored mask shape inputs """ - return [tuple(val) for key, val in self.inputs.items() if key.startswith("mask")] + def current_session(self): + """ dict: The state dictionary for the current :attr:`session_id`. """ + return self._sessions[self._session_id] @property - def loss_names(self): - """ Return the loss names for this session """ - return self.sessions[self.session_id]["loss_names"] + def iterations(self): + """ int: The total number of iterations that the model has trained. """ + return self._iterations @property - def current_session(self): - """ Return the current session dict """ - return self.sessions[self.session_id] + def training_size(self): + """ int: The size of the training images in the training folder. """ + return self._training_size @property - def first_run(self): - """ Return True if this is the first run else False """ - return self.session_id == 1 + def lowest_avg_loss(self): + """dict: The lowest average save interval loss seen for each side. """ + return self._lowest_avg_loss - def new_session_id(self): - """ Return new session_id """ - if not self.sessions: + @property + def session_id(self): + """ int: The current training session id. """ + return self._session_id + + def _new_session_id(self): + """ Generate a new session id. Returns 1 if this is a new model, or the last session id + 1 + if it is a pre-existing model. + + Returns + ------- + int + The newly generated session id + """ + if not self._sessions: session_id = 1 else: - session_id = max(int(key) for key in self.sessions.keys()) + 1 + session_id = max(int(key) for key in self._sessions.keys()) + 1 logger.debug(session_id) return session_id - def create_new_session(self, no_logs, pingpong, config_changeable_items): - """ Create a new session """ - logger.debug("Creating new session. id: %s", self.session_id) - self.sessions[self.session_id] = {"timestamp": time.time(), - "no_logs": no_logs, - "pingpong": pingpong, - "loss_names": dict(), - "batchsize": 0, - "iterations": 0, - "config": config_changeable_items} - - def add_session_loss_names(self, side, loss_names): - """ Add the session loss names to the sessions dictionary """ - logger.debug("Adding session loss_names. (side: '%s', loss_names: %s", side, loss_names) - self.sessions[self.session_id]["loss_names"][side] = loss_names - - def add_session_batchsize(self, batchsize): - """ Add the session batchsize to the sessions dictionary """ - logger.debug("Adding session batchsize: %s", batchsize) - self.sessions[self.session_id]["batchsize"] = batchsize + def _create_new_session(self, no_logs, config_changeable_items): + """ Initialize a new session, creating the dictionary entry for the session in + :attr:`_sessions`. + + Parameters + ---------- + no_logs: bool + ``True`` if Tensorboard logs should not be generated, otherwise ``False`` + config_changeable_items: dict + Configuration options that can be altered when resuming a model, and their current + values + """ + logger.debug("Creating new session. id: %s", self._session_id) + self._sessions[self._session_id] = dict(timestamp=time.time(), + no_logs=no_logs, + loss_names=[], + batchsize=0, + iterations=0, + config=config_changeable_items) + + def add_session_loss_names(self, loss_names): + """ Add the session loss names to the sessions dictionary. + + The loss names are used for Tensorboard logging + + Parameters + ---------- + loss_names: list + The list of loss names for this session. + """ + logger.debug("Adding session loss_names: %s", loss_names) + self._sessions[self._session_id]["loss_names"] = loss_names + + def add_session_batchsize(self, batch_size): + """ Add the session batch size to the sessions dictionary. + + Parameters + ---------- + batch_size: int + The batch size for the current training session + """ + logger.debug("Adding session batch size: %s", batch_size) + self._sessions[self._session_id]["batchsize"] = batch_size def increment_iterations(self): - """ Increment total and session iterations """ - self.iterations += 1 - self.sessions[self.session_id]["iterations"] += 1 + """ Increment :attr:`iterations` and session iterations by 1. """ + self._iterations += 1 + self._sessions[self._session_id]["iterations"] += 1 + + def _load(self, config_changeable_items): + """ Load a state file and set the serialized values to the class instance. - def load(self, config_changeable_items): - """ Load state file """ + Updates the model's config with the values stored in the state file. + + Parameters + ---------- + config_changeable_items: dict + Configuration options that can be altered when resuming a model, and their current + values + """ logger.debug("Loading State") - if not os.path.exists(self.filename): + if not os.path.exists(self._filename): logger.info("No existing state file found. Generating.") return - state = self.serializer.load(self.filename) - self.name = state.get("name", self.name) - self.sessions = state.get("sessions", dict()) - self.lowest_avg_loss = state.get("lowest_avg_loss", dict()) - self.iterations = state.get("iterations", 0) - self.training_size = state.get("training_size", 256) - self.inputs = state.get("inputs", dict()) - self.config = state.get("config", dict()) + state = self._serializer.load(self._filename) + self._name = state.get("name", self._name) + self._sessions = state.get("sessions", dict()) + self._lowest_avg_loss = state.get("lowest_avg_loss", dict()) + self._iterations = state.get("iterations", 0) + self._training_size = state.get("training_size", 256) + self._config = state.get("config", dict()) logger.debug("Loaded state: %s", state) - self.replace_config(config_changeable_items) + self._replace_config(config_changeable_items) - def save(self, backup_func=None): - """ Save iteration number to state file """ + def save(self): + """ Save the state values to the serialized state file. """ logger.debug("Saving State") - if backup_func: - backup_func(self.filename) - state = {"name": self.name, - "sessions": self.sessions, - "lowest_avg_loss": self.lowest_avg_loss, - "iterations": self.iterations, - "inputs": self.inputs, - "training_size": self.training_size, + state = {"name": self._name, + "sessions": self._sessions, + "lowest_avg_loss": self._lowest_avg_loss, + "iterations": self._iterations, + "training_size": self._training_size, "config": _CONFIG} - self.serializer.save(self.filename, state) + self._serializer.save(self._filename, state) logger.debug("Saved State") - def replace_config(self, config_changeable_items): - """ Replace the loaded config with the one contained within the state file - Check for any fixed=False parameters changes and log info changes + def _replace_config(self, config_changeable_items): + """ Replace the loaded config with the one contained within the state file. + + Check for any `fixed`=``False`` parameter changes and log info changes. + + Update any legacy config items to their current versions. + + Parameters + ---------- + config_changeable_items: dict + Configuration options that can be altered when resuming a model, and their current + values """ global _CONFIG # pylint: disable=global-statement legacy_update = self._update_legacy_config() # Add any new items to state config for legacy purposes for key, val in _CONFIG.items(): - if key not in self.config.keys(): + if key not in self._config.keys(): logger.info("Adding new config item to state file: '%s': '%s'", key, val) - self.config[key] = val - self.update_changed_config_items(config_changeable_items) + self._config[key] = val + self._update_changed_config_items(config_changeable_items) logger.debug("Replacing config. Old config: %s", _CONFIG) - _CONFIG = self.config + _CONFIG = self._config if legacy_update: self.save() logger.debug("Replaced config. New config: %s", _CONFIG) @@ -1026,47 +1159,250 @@ def _update_legacy_config(self): new_items = ["loss_function", "learn_mask", "mask_type"] updated = False for old, new in zip(priors, new_items): - if old not in self.config: + if old not in self._config: logger.debug("Legacy item '%s' not in config. Skipping update", old) continue # dssim_loss > loss_function if old == "dssim_loss": - self.config[new] = "ssim" if self.config[old] else "mae" - del self.config[old] + self._config[new] = "ssim" if self._config[old] else "mae" + del self._config[old] updated = True logger.info("Updated config from legacy dssim format. New config loss " - "function: '%s'", self.config[new]) + "function: '%s'", self._config[new]) continue # Add learn mask option and set to True if model has "penalized_mask_loss" specified - if old == "mask_type" and new == "learn_mask" and new not in self.config: - self.config[new] = self.config["mask_type"] is not None + if old == "mask_type" and new == "learn_mask" and new not in self._config: + self._config[new] = self._config["mask_type"] is not None updated = True logger.info("Added new 'learn_mask' config item for this model. Value set to: %s", - self.config[new]) + self._config[new]) continue # Replace removed masks with most similar equivalent - if old == "mask_type" and new == "mask_type" and self.config[old] in ("facehull", - "dfl_full"): - old_mask = self.config[old] - self.config[new] = "components" + if old == "mask_type" and new == "mask_type" and self._config[old] in ("facehull", + "dfl_full"): + old_mask = self._config[old] + self._config[new] = "components" updated = True logger.info("Updated 'mask_type' from '%s' to '%s' for this model", - old_mask, self.config[new]) + old_mask, self._config[new]) logger.debug("State file updated for legacy config: %s", updated) return updated - def update_changed_config_items(self, config_changeable_items): - """ Update any parameters which are not fixed and have been changed """ + def _update_changed_config_items(self, config_changeable_items): + """ Update any parameters which are not fixed and have been changed. + + Parameters + ---------- + config_changeable_items: dict + Configuration options that can be altered when resuming a model, and their current + values + """ if not config_changeable_items: logger.debug("No changeable parameters have been updated") return for key, val in config_changeable_items.items(): - old_val = self.config[key] + old_val = self._config[key] if old_val == val: continue - self.config[key] = val + self._config[key] = val logger.info("Config item: '%s' has been updated from '%s' to '%s'", key, old_val, val) + + +class _Inference(): # pylint:disable=too-few-public-methods + """ Calculates required layers and compiles a saved model for inference. + + Parameters + ---------- + saved_model: :class:`keras.models.Model` + The saved trained Faceswap model + switch_sides: bool + ``True`` if the swap should be performed "B" > "A" ``False`` if the swap should be + "A" > "B" + """ + def __init__(self, saved_model, switch_sides): + logger.debug("Initializing: %s (saved_model: %s, switch_sides: %s)", + self.__class__.__name__, saved_model, switch_sides) + self._config = saved_model.get_config() + input_idx = 1 if switch_sides else 0 + self._output_idx = 0 if switch_sides else 1 + self._input_names = set(self._filter_node(self._config["input_layers"][input_idx])) + + self._inputs = self._get_inputs(saved_model.inputs, input_idx) + self._outputs_dropout = self._get_outputs_dropout() + self._model = self._make_inference_model(saved_model) + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def model(self): + """ :class:`keras.models.Model`: The Faceswap model, compiled for inference. """ + return self._model + + @classmethod + def _filter_node(cls, node): + """ Given in input list of nodes from a :attr:`keras.models.Model.get_config` dictionary, + filters the information out and unravels the dictionary into a more usable format + + Parameters + ---------- + node: list + A node entry from the :attr:`keras.models.Model.get_config` dictionary + + Returns + ------- + list + A squeezed list with only the layer name entries remaining + """ + retval = np.array(node)[..., 0].squeeze().tolist() + return retval if isinstance(retval, list) else [retval] + + @classmethod + def _get_inputs(cls, inputs, input_index): + """ Obtain the inputs for the requested swap direction. + + Parameters + ---------- + inputs: list + The full list of input tensors to the saved faceswap training model + input_index: int + The input index for the requested swap direction + + Returns + ------- + list + List of input tensors to feed the model for the requested swap direction + """ + input_split = len(inputs) // 2 + start_idx = input_split * input_index + retval = inputs[start_idx: start_idx + input_split] + logger.debug("model inputs: %s, input_split: %s, start_idx: %s, inference_inputs: %s", + inputs, input_split, start_idx, retval) + return retval + + def _get_outputs_dropout(self): + """ Obtain the output layer names from the full model that will not be used for inference. + + Returns + ------- + set + The output layer names from the saved Faceswap model that are not used for inference + for the requested swap direction + """ + outputs = self._config["output_layers"] + if get_backend() == "amd": + outputs = [outputs[:len(outputs) // 2], outputs[len(outputs) // 2:]] + side_outputs = set(self._filter_node(outputs)[self._output_idx]) + logger.debug("model outputs: %s, side_outputs: %s", outputs, side_outputs) + outputs_all = {layer + for side in self._filter_node(outputs) + for layer in side} + retval = outputs_all.difference(side_outputs) + logger.debug("outputs dropout: %s", retval) + return retval + + def _make_inference_model(self, saved_model): + """ Extract the sub-models from the saved model that are required for inference. + + Parameters + ---------- + saved_model: :class:`keras.models.Model` + The saved trained Faceswap model + + Returns + ------- + :class:`keras.models.Model` + The model compiled for inference + """ + logger.debug("Compiling inference model. saved_model: %s", saved_model) + struct = self._get_filtered_structure() + required_layers = self._get_required_layers(struct) + logger.debug("Compiling model") + layer_dict = {layer.name: layer for layer in saved_model.layers} + compiled_layers = dict() + for name, inbound in struct.items(): + if name not in required_layers: + logger.debug("Skipping unused layer: '%s'", name) + continue + layer = layer_dict[name] + logger.debug("Processing layer '%s': (layer: %s, inbound_nodes: %s)", + name, layer, inbound) + if not inbound: + logger.debug("Adding model inputs %s: %s", self._input_names, self._inputs) + model = layer(self._inputs) + else: + layer_inputs = [compiled_layers[inp] for inp in inbound] + logger.debug("Compiling layer '%s': layer inputs: %s", name, layer_inputs) + model = layer(layer_inputs) + compiled_layers[name] = model + retval = KerasModel(self._inputs, model, name="{}_inference".format(saved_model.name)) + logger.debug("Compiled inference model '%s': %s", retval.name, retval) + return retval + + def _get_filtered_structure(self): + """ Obtain the structure of the full model, filtering out inbound nodes and + layers that are not required for the requested swap destination. + + Input layers to the full model are not returned in the structure. + + Returns + ------- + :class:`collections.OrderedDict` + The layer name as key with the inbound node layer names for each layer as value. + """ + retval = OrderedDict() + for layer in self._config["layers"]: + name = layer["name"] + if not layer["inbound_nodes"]: + logger.debug("Skipping input layer: '%s'", name) + continue + inbound = self._filter_node(layer["inbound_nodes"]) + + if self._input_names.intersection(inbound): + # Strip the input inbound nodes for applying the correct input layer at compile + # time + logger.debug("Stripping inbound nodes for input '%s': %s", name, inbound) + inbound = "" + + if inbound and np.array(layer["inbound_nodes"]).shape[0] == 2: + # if inbound is not populated, then layer is already split at input + logger.debug("Filtering layer with split inbound nodes: '%s': %s", name, inbound) + inbound = inbound[self._output_idx] + inbound = inbound if isinstance(inbound, list) else [inbound] + logger.debug("Filtered inbound nodes for layer '%s': %s", name, inbound) + if name in self._outputs_dropout: + logger.debug("Dropping output layer '%s'", name) + continue + retval[name] = inbound + logger.debug("Model structure: %s", retval) + return retval + + @classmethod + def _get_required_layers(cls, filtered_structure): + """ Parse through the filtered model structure in reverse order to get the required layers + from the faceswap model for creating an inference model. + + Parameters + ---------- + filtered_structure: :class:`OrderedDict` + The full model structure with unused inbound nodes and layers removed + + Returns + ------- + set + The layers from the saved model that are required to build the inference model + """ + retval = set() + for idx, (name, inbound) in enumerate(reversed(filtered_structure.items())): + if idx == 0: + logger.debug("Adding output layer: '%s'", name) + retval.add(name) + if idx != 0 and name not in retval: + logger.debug("Skipping unused layer: '%s'", name) + continue + logger.debug("Adding inbound layers: %s", inbound) + retval.update(inbound) + logger.debug("Required layers: %s", retval) + return retval diff --git a/plugins/train/model/dfaker.py b/plugins/train/model/dfaker.py index d6b19be395..298ede4e03 100644 --- a/plugins/train/model/dfaker.py +++ b/plugins/train/model/dfaker.py @@ -5,51 +5,40 @@ from keras.initializers import RandomNormal from keras.layers import Input -from keras.models import Model as KerasModel -from .original import logger, Model as OriginalModel +from lib.model.nn_blocks import Conv2DOutput, UpscaleBlock, ResidualBlock +from .original import Model as OriginalModel, KerasModel class Model(OriginalModel): - """ Improved Autoeencoder Model """ + """ Dfaker Model """ def __init__(self, *args, **kwargs): - logger.debug("Initializing %s: (args: %s, kwargs: %s", - self.__class__.__name__, args, kwargs) - kwargs["input_shape"] = (64, 64, 3) - kwargs["encoder_dim"] = 1024 - self.kernel_initializer = RandomNormal(0, 0.02) super().__init__(*args, **kwargs) - logger.debug("Initialized %s", self.__class__.__name__) + self.input_shape = (64, 64, 3) + self.encoder_dim = 1024 + self.kernel_initializer = RandomNormal(0, 0.02) - def decoder(self): + def decoder(self, side): """ Decoder Network """ input_ = Input(shape=(8, 8, 512)) var_x = input_ - var_x = self.blocks.upscale(var_x, 512, res_block_follows=True) - var_x = self.blocks.res_block(var_x, 512, kernel_initializer=self.kernel_initializer) - var_x = self.blocks.upscale(var_x, 256, res_block_follows=True) - var_x = self.blocks.res_block(var_x, 256, kernel_initializer=self.kernel_initializer) - var_x = self.blocks.upscale(var_x, 128, res_block_follows=True) - var_x = self.blocks.res_block(var_x, 128, kernel_initializer=self.kernel_initializer) - var_x = self.blocks.upscale(var_x, 64) - var_x = self.blocks.conv2d(var_x, 3, - kernel_size=5, - padding="same", - activation="sigmoid", - name="face_out") + var_x = UpscaleBlock(512, res_block_follows=True)(var_x) + var_x = ResidualBlock(512, kernel_initializer=self.kernel_initializer)(var_x) + var_x = UpscaleBlock(256, res_block_follows=True)(var_x) + var_x = ResidualBlock(256, kernel_initializer=self.kernel_initializer)(var_x) + var_x = UpscaleBlock(128, res_block_follows=True)(var_x) + var_x = ResidualBlock(128, kernel_initializer=self.kernel_initializer)(var_x) + var_x = UpscaleBlock(64)(var_x) + var_x = Conv2DOutput(3, 5, name="face_out_{}".format(side))(var_x) outputs = [var_x] if self.config.get("learn_mask", False): var_y = input_ - var_y = self.blocks.upscale(var_y, 512) - var_y = self.blocks.upscale(var_y, 256) - var_y = self.blocks.upscale(var_y, 128) - var_y = self.blocks.upscale(var_y, 64) - var_y = self.blocks.conv2d(var_y, 1, - kernel_size=5, - padding="same", - activation="sigmoid", - name="mask_out") + var_y = UpscaleBlock(512)(var_y) + var_y = UpscaleBlock(256)(var_y) + var_y = UpscaleBlock(128)(var_y) + var_y = UpscaleBlock(64)(var_y) + var_y = Conv2DOutput(1, 5, name="mask_out_{}".format(side))(var_y) outputs.append(var_y) - return KerasModel([input_], outputs=outputs) + return KerasModel([input_], outputs=outputs, name="decoder_{}".format(side)) diff --git a/plugins/train/model/dfl_h128.py b/plugins/train/model/dfl_h128.py index 887d379937..3afcf408c4 100644 --- a/plugins/train/model/dfl_h128.py +++ b/plugins/train/model/dfl_h128.py @@ -1,65 +1,49 @@ #!/usr/bin/env python3 -""" DeepFakesLab H128 Model +""" DeepFaceLab H128 Model Based on https://github.com/iperov/DeepFaceLab """ from keras.layers import Dense, Flatten, Input, Reshape -from keras.models import Model as KerasModel -from .original import logger, Model as OriginalModel +from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock +from .original import Model as OriginalModel, KerasModel class Model(OriginalModel): - """ Low Memory version of Original Faceswap Model """ + """ H128 Model from DFL """ def __init__(self, *args, **kwargs): - logger.debug("Initializing %s: (args: %s, kwargs: %s", - self.__class__.__name__, args, kwargs) - - self.configfile = kwargs.get("configfile", None) - kwargs["input_shape"] = (128, 128, 3) - kwargs["encoder_dim"] = 256 if self.config["lowmem"] else 512 - super().__init__(*args, **kwargs) - logger.debug("Initialized %s", self.__class__.__name__) + self.input_shape = (128, 128, 3) + self.encoder_dim = 256 if self.config["lowmem"] else 512 def encoder(self): """ DFL H128 Encoder """ input_ = Input(shape=self.input_shape) - var_x = input_ - var_x = self.blocks.conv(var_x, 128) - var_x = self.blocks.conv(var_x, 256) - var_x = self.blocks.conv(var_x, 512) - var_x = self.blocks.conv(var_x, 1024) + var_x = Conv2DBlock(128)(input_) + var_x = Conv2DBlock(256)(var_x) + var_x = Conv2DBlock(512)(var_x) + var_x = Conv2DBlock(1024)(var_x) var_x = Dense(self.encoder_dim)(Flatten()(var_x)) var_x = Dense(8 * 8 * self.encoder_dim)(var_x) var_x = Reshape((8, 8, self.encoder_dim))(var_x) - var_x = self.blocks.upscale(var_x, self.encoder_dim) + var_x = UpscaleBlock(self.encoder_dim)(var_x) return KerasModel(input_, var_x) - def decoder(self): + def decoder(self, side): """ DFL H128 Decoder """ input_ = Input(shape=(16, 16, self.encoder_dim)) - # Face var_x = input_ - var_x = self.blocks.upscale(var_x, self.encoder_dim) - var_x = self.blocks.upscale(var_x, self.encoder_dim // 2) - var_x = self.blocks.upscale(var_x, self.encoder_dim // 4) - var_x = self.blocks.conv2d(var_x, 3, - kernel_size=5, - padding="same", - activation="sigmoid", - name="face_out") + var_x = UpscaleBlock(self.encoder_dim)(var_x) + var_x = UpscaleBlock(self.encoder_dim // 2)(var_x) + var_x = UpscaleBlock(self.encoder_dim // 4)(var_x) + var_x = Conv2DOutput(3, 5, name="face_out_{}".format(side))(var_x) outputs = [var_x] if self.config.get("learn_mask", False): var_y = input_ - var_y = self.blocks.upscale(var_y, self.encoder_dim) - var_y = self.blocks.upscale(var_y, self.encoder_dim // 2) - var_y = self.blocks.upscale(var_y, self.encoder_dim // 4) - var_y = self.blocks.conv2d(var_y, 1, - kernel_size=5, - padding="same", - activation="sigmoid", - name="mask_out") + var_y = UpscaleBlock(self.encoder_dim)(var_y) + var_y = UpscaleBlock(self.encoder_dim // 2)(var_y) + var_y = UpscaleBlock(self.encoder_dim // 4)(var_y) + var_y = Conv2DOutput(1, 5, name="mask_out_{}".format(side))(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs) + return KerasModel(input_, outputs=outputs, name="decoder_{}".format(side)) diff --git a/plugins/train/model/dfl_sae.py b/plugins/train/model/dfl_sae.py index 4d2212125d..ba8a5f761a 100644 --- a/plugins/train/model/dfl_sae.py +++ b/plugins/train/model/dfl_sae.py @@ -1,37 +1,27 @@ #!/usr/bin/env python3 -""" DeepFakesLab SAE Model +""" DeepFaceLab SAE Model Based on https://github.com/iperov/DeepFaceLab """ import numpy as np from keras.layers import Concatenate, Dense, Flatten, Input, Reshape -from keras.models import Model as KerasModel -from ._base import ModelBase, logger +from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock + +from ._base import ModelBase, KerasModel class Model(ModelBase): - """ Low Memory version of Original Faceswap Model """ + """ SAE Model from DFL """ def __init__(self, *args, **kwargs): - logger.debug("Initializing %s: (args: %s, kwargs: %s", - self.__class__.__name__, args, kwargs) - - self.configfile = kwargs.get("configfile", None) - kwargs["input_shape"] = (self.config["input_size"], self.config["input_size"], 3) - super().__init__(*args, **kwargs) - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def architecture(self): - """ Return the architecture used from config """ - return self.config["architecture"].lower() - - @property - def use_mask(self): - """ Return True if a mask has been set else false """ - return self.config.get("learn_mask", False) + self.input_shape = (self.config["input_size"], self.config["input_size"], 3) + self.architecture = self.config["architecture"].lower() + self.use_mask = self.config.get("learn_mask", False) + self.multiscale_count = 3 if self.config["multiscale_decoder"] else 1 + self.encoder_dim = self.config["encoder_dims"] + self.decoder_dim = self.config["decoder_dims"] @property def ae_dims(self): @@ -41,148 +31,110 @@ def ae_dims(self): retval = 256 if self.architecture == "liae" else 512 return retval - @property - def multiscale_count(self): - """ Return 3 if multiscale decoder is set else 1 """ - retval = 3 if self.config["multiscale_decoder"] else 1 - return retval + def build_model(self, inputs): + """ Build the DFL-SAE Model """ + encoder = getattr(self, "encoder_{}".format(self.architecture))() + enc_output_shape = encoder.output_shape[1:] + encoder_a = encoder(inputs[0]) + encoder_b = encoder(inputs[1]) - def add_networks(self): - """ Add the DFL SAE Networks """ - logger.debug("Adding networks") - # Encoder - self.add_network("encoder", None, getattr(self, "encoder_{}".format(self.architecture))()) - - # Intermediate if self.architecture == "liae": - self.add_network("intermediate", "b", self.inter_liae()) - self.add_network("intermediate", None, self.inter_liae()) - - # Decoder - decoder_sides = [None] if self.architecture == "liae" else ["a", "b"] - for side in decoder_sides: - self.add_network("decoder", side, self.decoder(), is_output=True) - logger.debug("Added networks") - - def build_autoencoders(self, inputs): - """ Initialize DFL SAE model """ - logger.debug("Initializing model") - getattr(self, "build_{}_autoencoder".format(self.architecture))(inputs) - logger.debug("Initialized model") - - def build_liae_autoencoder(self, inputs): - """ Build the LIAE Autoencoder """ - for side in ("a", "b"): - encoder = self.networks["encoder"].network(inputs[0]) - if side == "a": - intermediate = Concatenate()([self.networks["intermediate"].network(encoder), - self.networks["intermediate"].network(encoder)]) - else: - intermediate = Concatenate()([self.networks["intermediate_b"].network(encoder), - self.networks["intermediate"].network(encoder)]) - output = self.networks["decoder"].network(intermediate) - autoencoder = KerasModel(inputs, output) - self.add_predictor(side, autoencoder) - - def build_df_autoencoder(self, inputs): - """ Build the DF Autoencoder """ - for side in ("a", "b"): - logger.debug("Adding Autoencoder. Side: %s", side) - decoder = self.networks["decoder_{}".format(side)].network - output = decoder(self.networks["encoder"].network(inputs[0])) - autoencoder = KerasModel(inputs, output) - self.add_predictor(side, autoencoder) + inter_both = self.inter_liae("both", enc_output_shape) + int_output_shape = (np.array(inter_both.output_shape[1:]) * (1, 1, 2)).tolist() + + inter_a = Concatenate()([inter_both(encoder_a), inter_both(encoder_a)]) + inter_b = Concatenate()([self.inter_liae("b", enc_output_shape)(encoder_b), + inter_both(encoder_b)]) + + decoder = self.decoder("both", int_output_shape) + outputs = [decoder(inter_a), decoder(inter_b)] + else: + outputs = [self.decoder("a", enc_output_shape)(encoder_a), + self.decoder("b", enc_output_shape)(encoder_b)] + autoencoder = KerasModel(inputs, + outputs, + name="{}_{}".format(self.name, self.architecture)) + return autoencoder def encoder_df(self): """ DFL SAE DF Encoder Network""" input_ = Input(shape=self.input_shape) - dims = self.input_shape[-1] * self.config["encoder_dims"] + dims = self.input_shape[-1] * self.encoder_dim lowest_dense_res = self.input_shape[0] // 16 - var_x = input_ - var_x = self.blocks.conv(var_x, dims) - var_x = self.blocks.conv(var_x, dims * 2) - var_x = self.blocks.conv(var_x, dims * 4) - var_x = self.blocks.conv(var_x, dims * 8) + var_x = Conv2DBlock(dims)(input_) + var_x = Conv2DBlock(dims * 2)(var_x) + var_x = Conv2DBlock(dims * 4)(var_x) + var_x = Conv2DBlock(dims * 8)(var_x) var_x = Dense(self.ae_dims)(Flatten()(var_x)) var_x = Dense(lowest_dense_res * lowest_dense_res * self.ae_dims)(var_x) var_x = Reshape((lowest_dense_res, lowest_dense_res, self.ae_dims))(var_x) - var_x = self.blocks.upscale(var_x, self.ae_dims) - return KerasModel(input_, var_x) + var_x = UpscaleBlock(self.ae_dims)(var_x) + return KerasModel(input_, var_x, name="encoder_df") def encoder_liae(self): """ DFL SAE LIAE Encoder Network """ input_ = Input(shape=self.input_shape) - dims = self.input_shape[-1] * self.config["encoder_dims"] - var_x = input_ - var_x = self.blocks.conv(var_x, dims) - var_x = self.blocks.conv(var_x, dims * 2) - var_x = self.blocks.conv(var_x, dims * 4) - var_x = self.blocks.conv(var_x, dims * 8) + dims = self.input_shape[-1] * self.encoder_dim + var_x = Conv2DBlock(dims)(input_) + var_x = Conv2DBlock(dims * 2)(var_x) + var_x = Conv2DBlock(dims * 4)(var_x) + var_x = Conv2DBlock(dims * 8)(var_x) var_x = Flatten()(var_x) - return KerasModel(input_, var_x) + return KerasModel(input_, var_x, name="encoder_liae") - def inter_liae(self): + def inter_liae(self, side, input_shape): """ DFL SAE LIAE Intermediate Network """ - input_ = Input(shape=self.networks["encoder"].output_shapes[0][1:]) + input_ = Input(shape=input_shape) lowest_dense_res = self.input_shape[0] // 16 var_x = input_ var_x = Dense(self.ae_dims)(var_x) var_x = Dense(lowest_dense_res * lowest_dense_res * self.ae_dims * 2)(var_x) var_x = Reshape((lowest_dense_res, lowest_dense_res, self.ae_dims * 2))(var_x) - var_x = self.blocks.upscale(var_x, self.ae_dims * 2) - return KerasModel(input_, var_x) + var_x = UpscaleBlock(self.ae_dims * 2)(var_x) + return KerasModel(input_, var_x, name="intermediate_{}".format(side)) - def decoder(self): + def decoder(self, side, input_shape): """ DFL SAE Decoder Network""" - if self.architecture == "liae": - input_shape = np.array(self.networks["intermediate"].output_shapes[0][1:]) * (1, 1, 2) - else: - input_shape = self.networks["encoder"].output_shapes[0][1:] input_ = Input(shape=input_shape) - outputs = list() + outputs = [] - dims = self.input_shape[-1] * self.config["decoder_dims"] + dims = self.input_shape[-1] * self.decoder_dim var_x = input_ - var_x1 = self.blocks.upscale(var_x, dims * 8, res_block_follows=True) - var_x1 = self.blocks.res_block(var_x1, dims * 8) - var_x1 = self.blocks.res_block(var_x1, dims * 8) + var_x1 = UpscaleBlock(dims * 8, res_block_follows=True)(var_x) + var_x1 = ResidualBlock(dims * 8)(var_x1) + var_x1 = ResidualBlock(dims * 8)(var_x1) if self.multiscale_count >= 3: - outputs.append(self.blocks.conv2d(var_x1, 3, - kernel_size=5, - padding="same", - activation="sigmoid", - name="face_out_32")) - - var_x2 = self.blocks.upscale(var_x1, dims * 4, res_block_follows=True) - var_x2 = self.blocks.res_block(var_x2, dims * 4) - var_x2 = self.blocks.res_block(var_x2, dims * 4) + outputs.append(Conv2DOutput(3, 5, name="face_out_32_{}".format(side))(var_x1)) + + var_x2 = UpscaleBlock(dims * 4, res_block_follows=True)(var_x1) + var_x2 = ResidualBlock(dims * 4)(var_x2) + var_x2 = ResidualBlock(dims * 4)(var_x2) if self.multiscale_count >= 2: - outputs.append(self.blocks.conv2d(var_x2, 3, - kernel_size=5, - padding="same", - activation="sigmoid", - name="face_out_64")) - - var_x3 = self.blocks.upscale(var_x2, dims * 2, res_block_follows=True) - var_x3 = self.blocks.res_block(var_x3, dims * 2) - var_x3 = self.blocks.res_block(var_x3, dims * 2) - - outputs.append(self.blocks.conv2d(var_x3, 3, - kernel_size=5, - padding="same", - activation="sigmoid", - name="face_out_128")) + outputs.append(Conv2DOutput(3, 5, name="face_out_64_{}".format(side))(var_x2)) + + var_x3 = UpscaleBlock(dims * 2, res_block_follows=True)(var_x2) + var_x3 = ResidualBlock(dims * 2)(var_x3) + var_x3 = ResidualBlock(dims * 2)(var_x3) + + outputs.append(Conv2DOutput(3, 5, name="face_out_128_{}".format(side))(var_x3)) if self.use_mask: var_y = input_ - var_y = self.blocks.upscale(var_y, self.config["decoder_dims"] * 8) - var_y = self.blocks.upscale(var_y, self.config["decoder_dims"] * 4) - var_y = self.blocks.upscale(var_y, self.config["decoder_dims"] * 2) - var_y = self.blocks.conv2d(var_y, 1, - kernel_size=5, - padding="same", - activation="sigmoid", - name="mask_out") + var_y = UpscaleBlock(self.decoder_dim * 8)(var_y) + var_y = UpscaleBlock(self.decoder_dim * 4)(var_y) + var_y = UpscaleBlock(self.decoder_dim * 2)(var_y) + var_y = Conv2DOutput(1, 5, name="mask_out_{}".format(side))(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs) + return KerasModel(input_, outputs=outputs, name="decoder_{}".format(side)) + + def _legacy_mapping(self): + """ The mapping of legacy separate model names to single model names """ + mappings = dict(df={"{}_encoder.h5".format(self.name): "encoder_df", + "{}_decoder_A.h5".format(self.name): "decoder_a", + "{}_decoder_B.h5".format(self.name): "decoder_b"}, + liae={"{}_encoder.h5".format(self.name): "encoder_liae", + "{}_intermediate_B.h5".format(self.name): "intermediate_both", + "{}_intermediate.h5".format(self.name): "intermediate_b", + "{}_decoder.h5".format(self.name): "decoder_both"}) + return mappings[self.config["architecture"]] diff --git a/plugins/train/model/dlight.py b/plugins/train/model/dlight.py index 3b9fc9b346..b68595c8f2 100644 --- a/plugins/train/model/dlight.py +++ b/plugins/train/model/dlight.py @@ -8,53 +8,31 @@ DeepHomage for lots of testing """ -from keras.layers import Dense, Flatten, Input, Reshape, AveragePooling2D, LeakyReLU -from keras.layers import UpSampling2D -from keras.layers.core import Dropout -from keras.layers.merge import Concatenate -from keras.layers.normalization import BatchNormalization -from keras.models import Model as KerasModel +from keras.layers import (AveragePooling2D, BatchNormalization, Concatenate, Dense, Dropout, + Flatten, Input, Reshape, LeakyReLU, UpSampling2D) +from lib.model.nn_blocks import (Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock, + Upscale2xBlock) from lib.utils import FaceswapError -from ._base import logger -from .original import Model as OriginalModel +from ._base import ModelBase, KerasModel, logger -class Model(OriginalModel): - """ DeLight Autoencoder Model """ +class Model(ModelBase): + """ DLight Autoencoder Model """ def __init__(self, *args, **kwargs): - logger.debug("Initializing %s: (args: %s, kwargs: %s", - self.__class__.__name__, args, kwargs) - - kwargs["input_shape"] = (128, 128, 3) - kwargs["encoder_dim"] = -1 - self.dense_output = None - self.detail_level = None - self.features = None - self.encoder_filters = None - self.encoder_dim = None - self.details = None - self.upscale_ratio = None super().__init__(*args, **kwargs) + self.input_shape = (128, 128, 3) - logger.debug("Initialized %s", self.__class__.__name__) - - def _detail_level_setup(self): - logger.debug('self.config[output_size]: %d', self.config["output_size"]) self.features = dict(lowmem=0, fair=1, best=2)[self.config["features"]] - logger.debug('self.features: %d', self.features) self.encoder_filters = 64 if self.features > 0 else 48 - logger.debug('self.encoder_filters: %d', self.encoder_filters) + bonum_fortunam = 128 self.encoder_dim = {0: 512 + bonum_fortunam, 1: 1024 + bonum_fortunam, 2: 1536 + bonum_fortunam}[self.features] - logger.debug('self.encoder_dim: %d', self.encoder_dim) self.details = dict(fast=0, good=1)[self.config["details"]] - logger.debug('self.details: %d', self.details) - try: self.upscale_ratio = {128: 2, 256: 4, @@ -62,71 +40,50 @@ def _detail_level_setup(self): except KeyError: logger.error("Config error: output_size must be one of: 128, 256, or 384.") raise FaceswapError("Config error: output_size must be one of: 128, 256, or 384.") - logger.debug('output_size: %r', self.config["output_size"]) - logger.debug('self.upscale_ratio: %r', self.upscale_ratio) - - def build(self): - self._detail_level_setup() - super().build() - - def add_networks(self): - """ Add the DeLight model weights """ - logger.debug("Adding networks") - self.add_network("decoder", "a", self.decoder_a(), is_output=True) - self.add_network("decoder", "b", - self.decoder_b() if self.details > 0 else self.decoder_b_fast(), - is_output=True) - self.add_network("encoder", None, self.encoder()) - logger.debug("Added networks") - - def compile_predictors(self, **kwargs): # pylint: disable=arguments-differ - self.set_networks_trainable() - super().compile_predictors(**kwargs) - - def set_networks_trainable(self): - """ Set the network state to trainable """ - train_encoder = True - train_decoder_a = True - train_decoder_b = True - - encoder = self.networks['encoder'].network - for layer in encoder.layers: - layer.trainable = train_encoder - - decoder_a = self.networks['decoder_a'].network - for layer in decoder_a.layers: - layer.trainable = train_decoder_a - - decoder_b = self.networks['decoder_b'].network - for layer in decoder_b.layers: - layer.trainable = train_decoder_b + + logger.debug("output_size: %s, features: %s, encoder_filters: %s, encoder_dim: %s, " + " details: %s, upscale_ratio: %s", self.config["output_size"], self.features, + self.encoder_filters, self.encoder_dim, self.details, self.upscale_ratio) + + def build_model(self, inputs): + """ Build the Dlight Model. """ + encoder = self.encoder() + encoder_a = encoder(inputs[0]) + encoder_b = encoder(inputs[1]) + + decoder_b = self.decoder_b if self.details > 0 else self.decoder_b_fast + + outputs = [self.decoder_a()(encoder_a), decoder_b()(encoder_b)] + + autoencoder = KerasModel(inputs, outputs, name=self.name) + return autoencoder def encoder(self): """ DeLight Encoder Network """ input_ = Input(shape=self.input_shape) var_x = input_ - var_x1 = self.blocks.conv(var_x, self.encoder_filters // 2) + var_x1 = Conv2DBlock(self.encoder_filters // 2)(var_x) var_x2 = AveragePooling2D()(var_x) var_x2 = LeakyReLU(0.1)(var_x2) var_x = Concatenate()([var_x1, var_x2]) - var_x1 = self.blocks.conv(var_x, self.encoder_filters) + var_x1 = Conv2DBlock(self.encoder_filters)(var_x) var_x2 = AveragePooling2D()(var_x) var_x2 = LeakyReLU(0.1)(var_x2) var_x = Concatenate()([var_x1, var_x2]) - var_x1 = self.blocks.conv(var_x, self.encoder_filters * 2) + var_x1 = Conv2DBlock(self.encoder_filters * 2)(var_x) var_x2 = AveragePooling2D()(var_x) var_x2 = LeakyReLU(0.1)(var_x2) var_x = Concatenate()([var_x1, var_x2]) - var_x1 = self.blocks.conv(var_x, self.encoder_filters * 4) + var_x1 = Conv2DBlock(self.encoder_filters * 4)(var_x) var_x2 = AveragePooling2D()(var_x) var_x2 = LeakyReLU(0.1)(var_x2) var_x = Concatenate()([var_x1, var_x2]) - var_x1 = self.blocks.conv(var_x, self.encoder_filters * 8) + var_x1 = Conv2DBlock(self.encoder_filters * 8)(var_x) var_x2 = AveragePooling2D()(var_x) var_x2 = LeakyReLU(0.1)(var_x2) var_x = Concatenate()([var_x1, var_x2]) @@ -137,7 +94,7 @@ def encoder(self): var_x = Dropout(0.05)(var_x) var_x = Reshape((4, 4, 1024))(var_x) - return KerasModel(input_, var_x) + return KerasModel(input_, var_x, name="encoder") def decoder_a(self): """ DeLight Decoder A(old face) Network """ @@ -149,29 +106,27 @@ def decoder_a(self): var_xy = UpSampling2D(self.upscale_ratio, interpolation='bilinear')(var_xy) var_x = var_xy - var_x = self.blocks.upscale2x(var_x, decoder_a_complexity, fast=False) - var_x = self.blocks.upscale2x(var_x, decoder_a_complexity // 2, fast=False) - var_x = self.blocks.upscale2x(var_x, decoder_a_complexity // 4, fast=False) - var_x = self.blocks.upscale2x(var_x, decoder_a_complexity // 8, fast=False) + var_x = Upscale2xBlock(decoder_a_complexity, fast=False)(var_x) + var_x = Upscale2xBlock(decoder_a_complexity // 2, fast=False)(var_x) + var_x = Upscale2xBlock(decoder_a_complexity // 4, fast=False)(var_x) + var_x = Upscale2xBlock(decoder_a_complexity // 8, fast=False)(var_x) - var_x = self.blocks.conv2d(var_x, 3, kernel_size=5, padding="same", - activation="sigmoid", name="face_out") + var_x = Conv2DOutput(3, 5, name="face_out")(var_x) outputs = [var_x] if self.config.get("learn_mask", False): var_y = var_xy # mask decoder - var_y = self.blocks.upscale2x(var_y, mask_complexity, fast=False) - var_y = self.blocks.upscale2x(var_y, mask_complexity // 2, fast=False) - var_y = self.blocks.upscale2x(var_y, mask_complexity // 4, fast=False) - var_y = self.blocks.upscale2x(var_y, mask_complexity // 8, fast=False) + var_y = Upscale2xBlock(mask_complexity, fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity // 2, fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity // 4, fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity // 8, fast=False)(var_y) - var_y = self.blocks.conv2d(var_y, 1, kernel_size=5, padding="same", - activation="sigmoid", name="mask_out") + var_y = Conv2DOutput(1, 5, name="mask_out")(var_y) outputs.append(var_y) - return KerasModel([input_], outputs=outputs) + return KerasModel([input_], outputs=outputs, name="decoder_a") def decoder_b_fast(self): """ DeLight Fast Decoder B(new face) Network """ @@ -182,33 +137,31 @@ def decoder_b_fast(self): var_xy = input_ - var_xy = self.blocks.upscale(var_xy, 512, scale_factor=self.upscale_ratio) + var_xy = UpscaleBlock(512, scale_factor=self.upscale_ratio)(var_xy) var_x = var_xy - var_x = self.blocks.upscale2x(var_x, decoder_b_complexity, fast=True) - var_x = self.blocks.upscale2x(var_x, decoder_b_complexity // 2, fast=True) - var_x = self.blocks.upscale2x(var_x, decoder_b_complexity // 4, fast=True) - var_x = self.blocks.upscale2x(var_x, decoder_b_complexity // 8, fast=True) + var_x = Upscale2xBlock(decoder_b_complexity, fast=True)(var_x) + var_x = Upscale2xBlock(decoder_b_complexity // 2, fast=True)(var_x) + var_x = Upscale2xBlock(decoder_b_complexity // 4, fast=True)(var_x) + var_x = Upscale2xBlock(decoder_b_complexity // 8, fast=True)(var_x) - var_x = self.blocks.conv2d(var_x, 3, kernel_size=5, padding="same", - activation="sigmoid", name="face_out") + var_x = Conv2DOutput(3, 5, name="face_out")(var_x) outputs = [var_x] if self.config.get("learn_mask", False): var_y = var_xy # mask decoder - var_y = self.blocks.upscale2x(var_y, mask_complexity, fast=False) - var_y = self.blocks.upscale2x(var_y, mask_complexity // 2, fast=False) - var_y = self.blocks.upscale2x(var_y, mask_complexity // 4, fast=False) - var_y = self.blocks.upscale2x(var_y, mask_complexity // 8, fast=False) + var_y = Upscale2xBlock(mask_complexity, fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity // 2, fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity // 4, fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity // 8, fast=False)(var_y) - var_y = self.blocks.conv2d(var_y, 1, kernel_size=5, padding="same", - activation="sigmoid", name="mask_out") + var_y = Conv2DOutput(1, 5, name="mask_out")(var_y) outputs.append(var_y) - return KerasModel([input_], outputs=outputs) + return KerasModel([input_], outputs=outputs, name="decoder_b_fast") def decoder_b(self): """ DeLight Decoder B(new face) Network """ @@ -219,40 +172,45 @@ def decoder_b(self): var_xy = input_ - var_xy = self.blocks.upscale2x(var_xy, 512, scale_factor=self.upscale_ratio, fast=False) + var_xy = Upscale2xBlock(512, scale_factor=self.upscale_ratio, fast=False)(var_xy) var_x = var_xy - var_x = self.blocks.res_block(var_x, 512, use_bias=True) - var_x = self.blocks.res_block(var_x, 512, use_bias=False) - var_x = self.blocks.res_block(var_x, 512, use_bias=False) - var_x = self.blocks.upscale2x(var_x, decoder_b_complexity, fast=False) - var_x = self.blocks.res_block(var_x, decoder_b_complexity, use_bias=True) - var_x = self.blocks.res_block(var_x, decoder_b_complexity, use_bias=False) + var_x = ResidualBlock(512, use_bias=True)(var_x) + var_x = ResidualBlock(512, use_bias=False)(var_x) + var_x = ResidualBlock(512, use_bias=False)(var_x) + var_x = Upscale2xBlock(decoder_b_complexity, fast=False)(var_x) + var_x = ResidualBlock(decoder_b_complexity, use_bias=True)(var_x) + var_x = ResidualBlock(decoder_b_complexity, use_bias=False)(var_x) var_x = BatchNormalization()(var_x) - var_x = self.blocks.upscale2x(var_x, decoder_b_complexity // 2, fast=False) - var_x = self.blocks.res_block(var_x, decoder_b_complexity // 2, use_bias=True) - var_x = self.blocks.upscale2x(var_x, decoder_b_complexity // 4, fast=False) - var_x = self.blocks.res_block(var_x, decoder_b_complexity // 4, use_bias=False) + var_x = Upscale2xBlock(decoder_b_complexity // 2, fast=False)(var_x) + var_x = ResidualBlock(decoder_b_complexity // 2, use_bias=True)(var_x) + var_x = Upscale2xBlock(decoder_b_complexity // 4, fast=False)(var_x) + var_x = ResidualBlock(decoder_b_complexity // 4, use_bias=False)(var_x) var_x = BatchNormalization()(var_x) - var_x = self.blocks.upscale2x(var_x, decoder_b_complexity // 8, fast=False) + var_x = Upscale2xBlock(decoder_b_complexity // 8, fast=False)(var_x) - var_x = self.blocks.conv2d(var_x, 3, kernel_size=5, padding="same", - activation="sigmoid", name="face_out") + var_x = Conv2DOutput(3, 5, name="face_out")(var_x) outputs = [var_x] if self.config.get("learn_mask", False): var_y = var_xy # mask decoder - var_y = self.blocks.upscale2x(var_y, mask_complexity, fast=False) - var_y = self.blocks.upscale2x(var_y, mask_complexity // 2, fast=False) - var_y = self.blocks.upscale2x(var_y, mask_complexity // 4, fast=False) - var_y = self.blocks.upscale2x(var_y, mask_complexity // 8, fast=False) + var_y = Upscale2xBlock(mask_complexity, fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity // 2, fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity // 4, fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity // 8, fast=False)(var_y) - var_y = self.blocks.conv2d(var_y, 1, kernel_size=5, padding="same", - activation="sigmoid", name="mask_out") + var_y = Conv2DOutput(1, 5, name="mask_out")(var_y) outputs.append(var_y) - return KerasModel([input_], outputs=outputs) + return KerasModel([input_], outputs=outputs, name="decoder_b") + + def _legacy_mapping(self): + """ The mapping of legacy separate model names to single model names """ + decoder_b = "decoder_b" if self.details > 0 else "decoder_b_fast" + return {"{}_encoder.h5".format(self.name): "encoder", + "{}_decoder_A.h5".format(self.name): "decoder_a", + "{}_decoder_B.h5".format(self.name): decoder_b} diff --git a/plugins/train/model/iae.py b/plugins/train/model/iae.py index 775305e1f7..87559313c5 100644 --- a/plugins/train/model/iae.py +++ b/plugins/train/model/iae.py @@ -2,91 +2,79 @@ """ Improved autoencoder for faceswap """ from keras.layers import Concatenate, Dense, Flatten, Input, Reshape -from keras.models import Model as KerasModel -from ._base import ModelBase, logger +from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock +from ._base import ModelBase, KerasModel class Model(ModelBase): - """ Improved Autoeencoder Model """ + """ Improved Autoencoder Model """ def __init__(self, *args, **kwargs): - logger.debug("Initializing %s: (args: %s, kwargs: %s", - self.__class__.__name__, args, kwargs) - kwargs["input_shape"] = (64, 64, 3) - kwargs["encoder_dim"] = 1024 super().__init__(*args, **kwargs) - logger.debug("Initialized %s", self.__class__.__name__) + self.input_shape = (64, 64, 3) + self.encoder_dim = 1024 - def add_networks(self): - """ Add the IAE model weights """ - logger.debug("Adding networks") - self.add_network("encoder", None, self.encoder()) - self.add_network("decoder", None, self.decoder(), is_output=True) - self.add_network("intermediate", "a", self.intermediate()) - self.add_network("intermediate", "b", self.intermediate()) - self.add_network("inter", None, self.intermediate()) - logger.debug("Added networks") + def build_model(self, inputs): + """ Build the IAE Model """ + encoder = self.encoder() + decoder = self.decoder() + inter_a = self.intermediate("a") + inter_b = self.intermediate("b") + inter_both = self.intermediate("both") - def build_autoencoders(self, inputs): - """ Initialize IAE model """ - logger.debug("Initializing model") - decoder = self.networks["decoder"].network - encoder = self.networks["encoder"].network - inter_both = self.networks["inter"].network - for side in ("a", "b"): - inter_side = self.networks["intermediate_{}".format(side)].network - output = decoder(Concatenate()([inter_side(encoder(inputs[0])), - inter_both(encoder(inputs[0]))])) + encoder_a = encoder(inputs[0]) + encoder_b = encoder(inputs[1]) - autoencoder = KerasModel(inputs, output) - self.add_predictor(side, autoencoder) - logger.debug("Initialized model") + outputs = [decoder(Concatenate()([inter_a(encoder_a), inter_both(encoder_a)])), + decoder(Concatenate()([inter_b(encoder_b), inter_both(encoder_b)]))] + + autoencoder = KerasModel(inputs, outputs, name=self.name) + return autoencoder def encoder(self): """ Encoder Network """ input_ = Input(shape=self.input_shape) var_x = input_ - var_x = self.blocks.conv(var_x, 128) - var_x = self.blocks.conv(var_x, 256) - var_x = self.blocks.conv(var_x, 512) - var_x = self.blocks.conv(var_x, 1024) + var_x = Conv2DBlock(128)(var_x) + var_x = Conv2DBlock(256)(var_x) + var_x = Conv2DBlock(512)(var_x) + var_x = Conv2DBlock(1024)(var_x) var_x = Flatten()(var_x) - return KerasModel(input_, var_x) + return KerasModel(input_, var_x, name="encoder") - def intermediate(self): + def intermediate(self, side): """ Intermediate Network """ - input_ = Input(shape=(None, 4 * 4 * 1024)) - var_x = input_ - var_x = Dense(self.encoder_dim)(var_x) + input_ = Input(shape=(4 * 4 * 1024)) + var_x = Dense(self.encoder_dim)(input_) var_x = Dense(4 * 4 * int(self.encoder_dim/2))(var_x) var_x = Reshape((4, 4, int(self.encoder_dim/2)))(var_x) - return KerasModel(input_, var_x) + return KerasModel(input_, var_x, name="inter_{}".format(side)) def decoder(self): """ Decoder Network """ input_ = Input(shape=(4, 4, self.encoder_dim)) var_x = input_ - var_x = self.blocks.upscale(var_x, 512) - var_x = self.blocks.upscale(var_x, 256) - var_x = self.blocks.upscale(var_x, 128) - var_x = self.blocks.upscale(var_x, 64) - var_x = self.blocks.conv2d(var_x, 3, - kernel_size=5, - padding="same", - activation="sigmoid", - name="face_out") + var_x = UpscaleBlock(512)(var_x) + var_x = UpscaleBlock(256)(var_x) + var_x = UpscaleBlock(128)(var_x) + var_x = UpscaleBlock(64)(var_x) + var_x = Conv2DOutput(3, 5, name="face_out")(var_x) outputs = [var_x] if self.config.get("learn_mask", False): var_y = input_ - var_y = self.blocks.upscale(var_y, 512) - var_y = self.blocks.upscale(var_y, 256) - var_y = self.blocks.upscale(var_y, 128) - var_y = self.blocks.upscale(var_y, 64) - var_y = self.blocks.conv2d(var_y, 1, - kernel_size=5, - padding="same", - activation="sigmoid", - name="mask_out") + var_y = UpscaleBlock(512)(var_y) + var_y = UpscaleBlock(256)(var_y) + var_y = UpscaleBlock(128)(var_y) + var_y = UpscaleBlock(64)(var_y) + var_y = Conv2DOutput(1, 5, name="mask_out")(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs) + return KerasModel(input_, outputs=outputs, name="decoder") + + def _legacy_mapping(self): + """ The mapping of legacy separate model names to single model names """ + return {"{}_encoder.h5".format(self.name): "encoder", + "{}_intermediate_A.h5".format(self.name): "inter_a", + "{}_intermediate_B.h5".format(self.name): "inter_b", + "{}_inter.h5".format(self.name): "inter_both", + "{}_decoder.h5".format(self.name): "decoder"} diff --git a/plugins/train/model/lightweight.py b/plugins/train/model/lightweight.py index 366e2802d2..ae166fccc1 100644 --- a/plugins/train/model/lightweight.py +++ b/plugins/train/model/lightweight.py @@ -1,61 +1,51 @@ #!/usr/bin/env python3 -""" Original Model +""" Lightweight Model by torzdf + An extremely limited model for training on low-end graphics cards Based on the original https://www.reddit.com/r/deepfakes/ - code sample + contribs """ + code sample + contributions """ from keras.layers import Dense, Flatten, Input, Reshape -from keras.models import Model as KerasModel -from .original import logger, Model as OriginalModel +from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock +from .original import Model as OriginalModel, KerasModel class Model(OriginalModel): """ Lightweight Model for ~2GB Graphics Cards """ def __init__(self, *args, **kwargs): - logger.debug("Initializing %s: (args: %s, kwargs: %s", - self.__class__.__name__, args, kwargs) - - kwargs["input_shape"] = (64, 64, 3) - kwargs["encoder_dim"] = 512 super().__init__(*args, **kwargs) - logger.debug("Initialized %s", self.__class__.__name__) + self.encoder_dim = 512 def encoder(self): """ Encoder Network """ input_ = Input(shape=self.input_shape) var_x = input_ - var_x = self.blocks.conv(var_x, 128) - var_x = self.blocks.conv(var_x, 256) - var_x = self.blocks.conv(var_x, 512) + var_x = Conv2DBlock(128)(var_x) + var_x = Conv2DBlock(256)(var_x) + var_x = Conv2DBlock(512)(var_x) var_x = Dense(self.encoder_dim)(Flatten()(var_x)) var_x = Dense(4 * 4 * 512)(var_x) var_x = Reshape((4, 4, 512))(var_x) - var_x = self.blocks.upscale(var_x, 256) - return KerasModel(input_, var_x) + var_x = UpscaleBlock(256)(var_x) + return KerasModel(input_, var_x, name="encoder") - def decoder(self): + def decoder(self, side): """ Decoder Network """ input_ = Input(shape=(8, 8, 256)) var_x = input_ - var_x = self.blocks.upscale(var_x, 512) - var_x = self.blocks.upscale(var_x, 256) - var_x = self.blocks.upscale(var_x, 128) - var_x = self.blocks.conv2d(var_x, 3, - kernel_size=5, - padding="same", - activation="sigmoid", - name="face_out") + var_x = UpscaleBlock(512)(var_x) + var_x = UpscaleBlock(256)(var_x) + var_x = UpscaleBlock(128)(var_x) + var_x = Conv2DOutput(3, 5, activation="sigmoid", name="face_out_{}".format(side))(var_x) outputs = [var_x] if self.config.get("learn_mask", False): var_y = input_ - var_y = self.blocks.upscale(var_y, 512) - var_y = self.blocks.upscale(var_y, 256) - var_y = self.blocks.upscale(var_y, 128) - var_y = self.blocks.conv2d(var_y, 1, - kernel_size=5, - padding="same", - activation="sigmoid", - name="mask_out") + var_y = UpscaleBlock(512)(var_y) + var_y = UpscaleBlock(256)(var_y) + var_y = UpscaleBlock(128)(var_y) + var_y = Conv2DOutput(1, 5, + activation="sigmoid", + name="mask_out_{}".format(side))(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs) + return KerasModel(input_, outputs=outputs, name="decoder_{}".format(side)) diff --git a/plugins/train/model/original.py b/plugins/train/model/original.py index fa79862860..6ee13eef56 100644 --- a/plugins/train/model/original.py +++ b/plugins/train/model/original.py @@ -1,87 +1,163 @@ #!/usr/bin/env python3 """ Original Model - Based on the original https://www.reddit.com/r/deepfakes/ - code sample + contribs """ +Based on the original https://www.reddit.com/r/deepfakes/ code sample + contributions. -from keras.layers import Dense, Flatten, Input, Reshape +This model is heavily documented as it acts as a template that other model plugins can be developed +from. +""" +from keras.layers import Dense, Flatten, Reshape, Input -from keras.models import Model as KerasModel - -from ._base import ModelBase, logger +from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock +from ._base import KerasModel, ModelBase class Model(ModelBase): - """ Original Faceswap Model """ - def __init__(self, *args, **kwargs): - logger.debug("Initializing %s: (args: %s, kwargs: %s", - self.__class__.__name__, args, kwargs) + """ Original Faceswap Model. + + This is the original faceswap model and acts as a template for plugin development. - self.configfile = kwargs.get("configfile", None) - if "input_shape" not in kwargs: - kwargs["input_shape"] = (64, 64, 3) - if "encoder_dim" not in kwargs: - kwargs["encoder_dim"] = 512 if self.config["lowmem"] else 1024 + All plugins must define the following attribute override after calling the parent's + :func:`__init__` method: + * :attr:`input_shape` (`tuple` or `list`): a tuple of ints defining the shape of the \ + faces that the model takes as input. If the input size is the same for both sides, this \ + can be a single 3 dimensional tuple. If the inputs have different sizes for "A" and "B" \ + this should be a list of 2 3 dimensional shape tuples, 1 for each side. + + Any additional attributes used exclusively by this model should be defined here, but make sure + that you are not accidentally overriding any existing + :class:`~plugins.train.model._base.ModelBase` attributes. + + Parameters + ---------- + args: varies + The default command line arguments passed in from :class:`~scripts.train.Train` or + :class:`~scripts.train.Convert` + kwargs: varies + The default keyword arguments passed in from :class:`~scripts.train.Train` or + :class:`~scripts.train.Convert` + """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - logger.debug("Initialized %s", self.__class__.__name__) - - def add_networks(self): - """ Add the original model weights """ - logger.debug("Adding networks") - self.add_network("decoder", "a", self.decoder(), is_output=True) - self.add_network("decoder", "b", self.decoder(), is_output=True) - self.add_network("encoder", None, self.encoder()) - logger.debug("Added networks") - - def build_autoencoders(self, inputs): - """ Initialize original model """ - logger.debug("Initializing model") - for side in ("a", "b"): - logger.debug("Adding Autoencoder. Side: %s", side) - decoder = self.networks["decoder_{}".format(side)].network - output = decoder(self.networks["encoder"].network(inputs[0])) - autoencoder = KerasModel(inputs, output) - self.add_predictor(side, autoencoder) - logger.debug("Initialized model") + self.input_shape = (64, 64, 3) + self.low_mem = self.config.get("lowmem", False) + self.learn_mask = self.config["learn_mask"] + self.encoder_dim = 512 if self.low_mem else 1024 + + def build_model(self, inputs): + """ Create the model's structure. + + This function is automatically called immediately after :func:`__init__` has been called if + a new model is being created. It is ignored if an existing model is being loaded from disk + as the model structure will be defined in the saved model file. + + The model's final structure is defined here. + + For the original model, An encoder instance is defined, then the same instance is + referenced twice, one for each input "A" and "B" so that the same model is used for + both inputs. + + 2 Decoders are then defined (one for each side) with the encoder instances passed in as + input to the corresponding decoders. + + It is important to note that any models and sub-models should not call + :class:`keras.models.Model` directly, but rather call + :class:`plugins.train.model._base.KerasModel`. This acts as a wrapper for Keras' Model + class, but handles some minor differences which need to be handled between Nvidia and AMD + backends. + + The final output of the model should always call :class:`lib.model.nn_blocks.Conv2DOutput` + so that the correct data type is set for the final activation, to support Mixed Precision + Training. Failure to do so is likely to lead to issues when Mixed Precision is enabled. + + Parameters + ---------- + inputs: list + A list of input tensors for the model. This will be a list of 2 tensors of + shape :attr:`input_shape`, the first for side "a", the second for side "b". + + Returns + ------- + :class:`keras.models.Model` + The output of this function must be a keras model generated from + :class:`plugins.train.model._base.KerasModel`. See Keras documentation for the correct + structure, but note that parameter :attr:`name` is a required rather than an optional + argument in Faceswap. You should assign this to the attribute ``self.name`` that is + automatically generated from the plugin's filename. + """ + input_a = inputs[0] + input_b = inputs[1] + + encoder = self.encoder() + encoder_a = [encoder(input_a)] + encoder_b = [encoder(input_b)] + + outputs = [self.decoder("a")(encoder_a), self.decoder("b")(encoder_b)] + + autoencoder = KerasModel(inputs, outputs, name=self.name) + return autoencoder def encoder(self): - """ Encoder Network """ + """ The original Faceswap Encoder Network. + + The encoder for the original model has it's weights shared between both the "A" and "B" + side of the model, so only one instance is created :func:`build_model`. However this same + instance is then used twice (once for A and once for B) meaning that the weights get + shared. + + Returns + ------- + :class:`keras.models.Model` + The Keras encoder model, for sharing between inputs from both sides. + """ input_ = Input(shape=self.input_shape) var_x = input_ - var_x = self.blocks.conv(var_x, 128) - var_x = self.blocks.conv(var_x, 256) - var_x = self.blocks.conv(var_x, 512) - if not self.config.get("lowmem", False): - var_x = self.blocks.conv(var_x, 1024) + var_x = Conv2DBlock(128)(var_x) + var_x = Conv2DBlock(256)(var_x) + var_x = Conv2DBlock(512)(var_x) + if not self.low_mem: + var_x = Conv2DBlock(1024)(var_x) var_x = Dense(self.encoder_dim)(Flatten()(var_x)) var_x = Dense(4 * 4 * 1024)(var_x) var_x = Reshape((4, 4, 1024))(var_x) - var_x = self.blocks.upscale(var_x, 512) - return KerasModel(input_, var_x) + var_x = UpscaleBlock(512)(var_x) + return KerasModel(input_, var_x, name="encoder") + + def decoder(self, side): + """ The original Faceswap Decoder Network. - def decoder(self): - """ Decoder Network """ + The decoders for the original model have separate weights for each side "A" and "B", so two + instances are created in :func:`build_model`, one for each side. + + Parameters + ---------- + side: str + Either `"a` or `"b"`. This is used for naming the decoder model. + + Returns + ------- + :class:`keras.models.Model` + The Keras decoder model. This will be called twice, once for each side. + """ input_ = Input(shape=(8, 8, 512)) var_x = input_ - var_x = self.blocks.upscale(var_x, 256) - var_x = self.blocks.upscale(var_x, 128) - var_x = self.blocks.upscale(var_x, 64) - var_x = self.blocks.conv2d(var_x, 3, - kernel_size=5, - padding="same", - activation="sigmoid", - name="face_out") + var_x = UpscaleBlock(256)(var_x) + var_x = UpscaleBlock(128)(var_x) + var_x = UpscaleBlock(64)(var_x) + var_x = Conv2DOutput(3, 5, name="face_out_{}".format(side))(var_x) outputs = [var_x] - if self.config.get("learn_mask", False): + if self.learn_mask: var_y = input_ - var_y = self.blocks.upscale(var_y, 256) - var_y = self.blocks.upscale(var_y, 128) - var_y = self.blocks.upscale(var_y, 64) - var_y = self.blocks.conv2d(var_y, 1, - kernel_size=5, - padding="same", - activation="sigmoid", - name="mask_out") + var_y = UpscaleBlock(256)(var_y) + var_y = UpscaleBlock(128)(var_y) + var_y = UpscaleBlock(64)(var_y) + var_y = Conv2DOutput(1, 5, name="mask_out_{}".format(side))(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs) + return KerasModel(input_, outputs=outputs, name="decoder_{}".format(side)) + + def _legacy_mapping(self): + """ The mapping of legacy separate model names to single model names """ + return {"{}_encoder.h5".format(self.name): "encoder", + "{}_decoder_A.h5".format(self.name): "decoder_a", + "{}_decoder_B.h5".format(self.name): "decoder_b"} diff --git a/plugins/train/model/realface.py b/plugins/train/model/realface.py index 48df05c59a..948714a6dc 100644 --- a/plugins/train/model/realface.py +++ b/plugins/train/model/realface.py @@ -1,38 +1,34 @@ #!/usr/bin/env python3 """ RealFaceRC1, codenamed 'Pegasus' Based on the original https://www.reddit.com/r/deepfakes/ - code sample + contribs + code sample + contributions Major thanks goes to BryanLyon as it vastly powered by his ideas and insights. Without him it would not be possible to come up with the model. Additional thanks: Birb - source of inspiration, great Encoder ideas - Kvrooman - additional couseling on autoencoders and practical advices + Kvrooman - additional counseling on auto-encoders and practical advice """ +import sys from keras.initializers import RandomNormal from keras.layers import Dense, Flatten, Input, Reshape -from keras.models import Model as KerasModel -from ._base import ModelBase, logger + +from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock +from ._base import ModelBase, KerasModel, logger class Model(ModelBase): """ RealFace(tm) Faceswap Model """ def __init__(self, *args, **kwargs): - logger.debug("Initializing %s: (args: %s, kwargs: %s", - self.__class__.__name__, args, kwargs) - - self.configfile = kwargs.get("configfile", None) + super().__init__(*args, **kwargs) + self.input_shape = (self.config["input_size"], self.config["input_size"], 3) self.check_input_output() self.dense_width, self.upscalers_no = self.get_dense_width_upscalers_numbers() - kwargs["input_shape"] = (self.config["input_size"], self.config["input_size"], 3) self.kernel_initializer = RandomNormal(0, 0.02) - super().__init__(*args, **kwargs) - logger.debug("Initialized %s", self.__class__.__name__) - @property def downscalers_no(self): - """ Number of downscalers. Don't change! """ + """ Number of downscale blocks. Don't change! """ return 4 @property @@ -50,15 +46,15 @@ def check_input_output(self): if not 64 <= self.config["input_size"] <= 128 or self.config["input_size"] % 16 != 0: logger.error("Config error: input_size must be between 64 and 128 and be divisible by " "16.") - exit(1) + sys.exit(1) if not 64 <= self.config["output_size"] <= 256 or self.config["output_size"] % 32 != 0: logger.error("Config error: output_size must be between 64 and 256 and be divisible " "by 32.") - exit(1) + sys.exit(1) logger.debug("Input and output sizes are valid") def get_dense_width_upscalers_numbers(self): - """ Return the dense width and number of upscalers """ + """ Return the dense width and number of upscale blocks """ output_size = self.config["output_size"] sides = [(output_size // 2**n, n) for n in [4, 5] if (output_size // 2**n) < 10] closest = min([x * self._downscale_ratio for x, _ in sides], @@ -68,24 +64,16 @@ def get_dense_width_upscalers_numbers(self): logger.debug("dense_width: %s, upscalers_no: %s", dense_width, upscalers_no) return dense_width, upscalers_no - def add_networks(self): - """ Add the realface model weights """ - logger.debug("Adding networks") - self.add_network("decoder", "a", self.decoder_a(), is_output=True) - self.add_network("decoder", "b", self.decoder_b(), is_output=True) - self.add_network("encoder", None, self.encoder()) - logger.debug("Added networks") - - def build_autoencoders(self, inputs): - """ Initialize realface model """ - logger.debug("Initializing model") - for side in "a", "b": - logger.debug("Adding Autoencoder. Side: %s", side) - decoder = self.networks["decoder_{}".format(side)].network - output = decoder(self.networks["encoder"].network(inputs[0])) - autoencoder = KerasModel(inputs, output) - self.add_predictor(side, autoencoder) - logger.debug("Initialized model") + def build_model(self, inputs): + """ Build the RealFace model. """ + encoder = self.encoder() + encoder_a = encoder(inputs[0]) + encoder_b = encoder(inputs[1]) + + outputs = [self.decoder_a()(encoder_a), self.decoder_b()(encoder_b)] + + autoencoder = KerasModel(inputs, outputs, name=self.name) + return autoencoder def encoder(self): """ RealFace Encoder Network """ @@ -95,13 +83,13 @@ def encoder(self): encoder_complexity = self.config["complexity_encoder"] for idx in range(self.downscalers_no - 1): - var_x = self.blocks.conv(var_x, encoder_complexity * 2**idx) - var_x = self.blocks.res_block(var_x, encoder_complexity * 2**idx, use_bias=True) - var_x = self.blocks.res_block(var_x, encoder_complexity * 2**idx, use_bias=True) + var_x = Conv2DBlock(encoder_complexity * 2**idx)(var_x) + var_x = ResidualBlock(encoder_complexity * 2**idx, use_bias=True)(var_x) + var_x = ResidualBlock(encoder_complexity * 2**idx, use_bias=True)(var_x) - var_x = self.blocks.conv(var_x, encoder_complexity * 2**(idx + 1)) + var_x = Conv2DBlock(encoder_complexity * 2**(idx + 1))(var_x) - return KerasModel(input_, var_x) + return KerasModel(input_, var_x, name="encoder") def decoder_b(self): """ RealFace Decoder Network """ @@ -114,23 +102,19 @@ def decoder_b(self): var_xy = Dense(self.config["dense_nodes"])(Flatten()(var_xy)) var_xy = Dense(self.dense_width * self.dense_width * self.dense_filters)(var_xy) var_xy = Reshape((self.dense_width, self.dense_width, self.dense_filters))(var_xy) - var_xy = self.blocks.upscale(var_xy, self.dense_filters) + var_xy = UpscaleBlock(self.dense_filters)(var_xy) var_x = var_xy - var_x = self.blocks.res_block(var_x, self.dense_filters, use_bias=False) + var_x = ResidualBlock(self.dense_filters, use_bias=False)(var_x) decoder_b_complexity = self.config["complexity_decoder"] for idx in range(self.upscalers_no - 2): - var_x = self.blocks.upscale(var_x, decoder_b_complexity // 2**idx) - var_x = self.blocks.res_block(var_x, decoder_b_complexity // 2**idx, use_bias=False) - var_x = self.blocks.res_block(var_x, decoder_b_complexity // 2**idx, use_bias=True) - var_x = self.blocks.upscale(var_x, decoder_b_complexity // 2**(idx + 1)) + var_x = UpscaleBlock(decoder_b_complexity // 2**idx)(var_x) + var_x = ResidualBlock(decoder_b_complexity // 2**idx, use_bias=False)(var_x) + var_x = ResidualBlock(decoder_b_complexity // 2**idx, use_bias=True)(var_x) + var_x = UpscaleBlock(decoder_b_complexity // 2**(idx + 1))(var_x) - var_x = self.blocks.conv2d(var_x, 3, - kernel_size=5, - padding="same", - activation="sigmoid", - name="face_out") + var_x = Conv2DOutput(3, 5, name="face_out_b")(var_x) outputs = [var_x] @@ -138,18 +122,14 @@ def decoder_b(self): var_y = var_xy mask_b_complexity = 384 for idx in range(self.upscalers_no-2): - var_y = self.blocks.upscale(var_y, mask_b_complexity // 2**idx) - var_y = self.blocks.upscale(var_y, mask_b_complexity // 2**(idx + 1)) + var_y = UpscaleBlock(mask_b_complexity // 2**idx)(var_y) + var_y = UpscaleBlock(mask_b_complexity // 2**(idx + 1))(var_y) - var_y = self.blocks.conv2d(var_y, 1, - kernel_size=5, - padding="same", - activation="sigmoid", - name="mask_out") + var_y = Conv2DOutput(1, 5, name="mask_out_b")(var_y) outputs += [var_y] - return KerasModel(input_, outputs=outputs) + return KerasModel(input_, outputs=outputs, name="decoder_b") def decoder_a(self): """ RealFace Decoder (A) Network """ @@ -166,21 +146,17 @@ def decoder_a(self): var_xy = Dense(self.dense_width * self.dense_width * dense_filters)(var_xy) var_xy = Reshape((self.dense_width, self.dense_width, dense_filters))(var_xy) - var_xy = self.blocks.upscale(var_xy, dense_filters) + var_xy = UpscaleBlock(dense_filters)(var_xy) var_x = var_xy - var_x = self.blocks.res_block(var_x, dense_filters, use_bias=False) + var_x = ResidualBlock(dense_filters, use_bias=False)(var_x) decoder_a_complexity = int(self.config["complexity_decoder"] / 1.5) for idx in range(self.upscalers_no-2): - var_x = self.blocks.upscale(var_x, decoder_a_complexity // 2**idx) - var_x = self.blocks.upscale(var_x, decoder_a_complexity // 2**(idx + 1)) + var_x = UpscaleBlock(decoder_a_complexity // 2**idx)(var_x) + var_x = UpscaleBlock(decoder_a_complexity // 2**(idx + 1))(var_x) - var_x = self.blocks.conv2d(var_x, 3, - kernel_size=5, - padding="same", - activation="sigmoid", - name="face_out") + var_x = Conv2DOutput(3, 5, name="face_out_a")(var_x) outputs = [var_x] @@ -188,15 +164,17 @@ def decoder_a(self): var_y = var_xy mask_a_complexity = 384 for idx in range(self.upscalers_no-2): - var_y = self.blocks.upscale(var_y, mask_a_complexity // 2**idx) - var_y = self.blocks.upscale(var_y, mask_a_complexity // 2**(idx + 1)) + var_y = UpscaleBlock(mask_a_complexity // 2**idx)(var_y) + var_y = UpscaleBlock(mask_a_complexity // 2**(idx + 1))(var_y) - var_y = self.blocks.conv2d(var_y, 1, - kernel_size=5, - padding="same", - activation="sigmoid", - name="mask_out") + var_y = Conv2DOutput(1, 5, name="mask_out_a")(var_y) outputs += [var_y] - return KerasModel(input_, outputs=outputs) + return KerasModel(input_, outputs=outputs, name="decoder_a") + + def _legacy_mapping(self): + """ The mapping of legacy separate model names to single model names """ + return {"{}_encoder.h5".format(self.name): "encoder", + "{}_decoder_A.h5".format(self.name): "decoder_a", + "{}_decoder_B.h5".format(self.name): "decoder_b"} diff --git a/plugins/train/model/unbalanced.py b/plugins/train/model/unbalanced.py index d7e136fdd4..c29b77cd38 100644 --- a/plugins/train/model/unbalanced.py +++ b/plugins/train/model/unbalanced.py @@ -1,147 +1,131 @@ #!/usr/bin/env python3 """ Unbalanced Model Based on the original https://www.reddit.com/r/deepfakes/ - code sample + contribs """ + code sample + contributions """ from keras.initializers import RandomNormal from keras.layers import Dense, Flatten, Input, Reshape, SpatialDropout2D -from keras.models import Model as KerasModel -from .original import logger, Model as OriginalModel +from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock +from ._base import ModelBase, KerasModel -class Model(OriginalModel): +class Model(ModelBase): """ Unbalanced Faceswap Model """ def __init__(self, *args, **kwargs): - logger.debug("Initializing %s: (args: %s, kwargs: %s", - self.__class__.__name__, args, kwargs) - - self.configfile = kwargs.get("configfile", None) - self.lowmem = self.config.get("lowmem", False) - kwargs["input_shape"] = (self.config["input_size"], self.config["input_size"], 3) - kwargs["encoder_dim"] = 512 if self.lowmem else self.config["nodes"] + super().__init__(*args, **kwargs) + self.input_shape = (self.config["input_size"], self.config["input_size"], 3) + self.low_mem = self.config.get("lowmem", False) + self.encoder_dim = 512 if self.low_mem else self.config["nodes"] self.kernel_initializer = RandomNormal(0, 0.02) - super().__init__(*args, **kwargs) - logger.debug("Initialized %s", self.__class__.__name__) + def build_model(self, inputs): + """ build the Unbalanced Model. """ + encoder = self.encoder() + encoder_a = encoder(inputs[0]) + encoder_b = encoder(inputs[1]) - def add_networks(self): - """ Add the original model weights """ - logger.debug("Adding networks") - self.add_network("decoder", "a", self.decoder_a(), is_output=True) - self.add_network("decoder", "b", self.decoder_b(), is_output=True) - self.add_network("encoder", None, self.encoder()) - logger.debug("Added networks") + outputs = [self.decoder_a()(encoder_a), self.decoder_b()(encoder_b)] + + autoencoder = KerasModel(inputs, outputs, name=self.name) + return autoencoder def encoder(self): """ Unbalanced Encoder """ kwargs = dict(kernel_initializer=self.kernel_initializer) - encoder_complexity = 128 if self.lowmem else self.config["complexity_encoder"] - dense_dim = 384 if self.lowmem else 512 + encoder_complexity = 128 if self.low_mem else self.config["complexity_encoder"] + dense_dim = 384 if self.low_mem else 512 dense_shape = self.input_shape[0] // 16 input_ = Input(shape=self.input_shape) var_x = input_ - var_x = self.blocks.conv(var_x, encoder_complexity, use_instance_norm=True, **kwargs) - var_x = self.blocks.conv(var_x, encoder_complexity * 2, use_instance_norm=True, **kwargs) - var_x = self.blocks.conv(var_x, encoder_complexity * 4, **kwargs) - var_x = self.blocks.conv(var_x, encoder_complexity * 6, **kwargs) - var_x = self.blocks.conv(var_x, encoder_complexity * 8, **kwargs) + var_x = Conv2DBlock(encoder_complexity, use_instance_norm=True, **kwargs)(var_x) + var_x = Conv2DBlock(encoder_complexity * 2, use_instance_norm=True, **kwargs)(var_x) + var_x = Conv2DBlock(encoder_complexity * 4, **kwargs)(var_x) + var_x = Conv2DBlock(encoder_complexity * 6, **kwargs)(var_x) + var_x = Conv2DBlock(encoder_complexity * 8, **kwargs)(var_x) var_x = Dense(self.encoder_dim, kernel_initializer=self.kernel_initializer)(Flatten()(var_x)) var_x = Dense(dense_shape * dense_shape * dense_dim, kernel_initializer=self.kernel_initializer)(var_x) var_x = Reshape((dense_shape, dense_shape, dense_dim))(var_x) - return KerasModel(input_, var_x) + return KerasModel(input_, var_x, name="encoder") def decoder_a(self): """ Decoder for side A """ kwargs = dict(kernel_size=5, kernel_initializer=self.kernel_initializer) - decoder_complexity = 320 if self.lowmem else self.config["complexity_decoder_a"] - dense_dim = 384 if self.lowmem else 512 + decoder_complexity = 320 if self.low_mem else self.config["complexity_decoder_a"] + dense_dim = 384 if self.low_mem else 512 decoder_shape = self.input_shape[0] // 16 input_ = Input(shape=(decoder_shape, decoder_shape, dense_dim)) var_x = input_ - var_x = self.blocks.upscale(var_x, decoder_complexity, **kwargs) + var_x = UpscaleBlock(decoder_complexity, **kwargs)(var_x) var_x = SpatialDropout2D(0.25)(var_x) - var_x = self.blocks.upscale(var_x, decoder_complexity, **kwargs) - if self.lowmem: + var_x = UpscaleBlock(decoder_complexity, **kwargs)(var_x) + if self.low_mem: var_x = SpatialDropout2D(0.15)(var_x) else: var_x = SpatialDropout2D(0.25)(var_x) - var_x = self.blocks.upscale(var_x, decoder_complexity // 2, **kwargs) - var_x = self.blocks.upscale(var_x, decoder_complexity // 4, **kwargs) - var_x = self.blocks.conv2d(var_x, 3, - kernel_size=5, - padding="same", - activation="sigmoid", - name="face_out") + var_x = UpscaleBlock(decoder_complexity // 2, **kwargs)(var_x) + var_x = UpscaleBlock(decoder_complexity // 4, **kwargs)(var_x) + var_x = Conv2DOutput(3, 5, name="face_out_a")(var_x) outputs = [var_x] if self.config.get("learn_mask", False): var_y = input_ - var_y = self.blocks.upscale(var_y, decoder_complexity) - var_y = self.blocks.upscale(var_y, decoder_complexity) - var_y = self.blocks.upscale(var_y, decoder_complexity // 2) - var_y = self.blocks.upscale(var_y, decoder_complexity // 4) - var_y = self.blocks.conv2d(var_y, 1, - kernel_size=5, - padding="same", - activation="sigmoid", - name="mask_out") + var_y = UpscaleBlock(decoder_complexity)(var_y) + var_y = UpscaleBlock(decoder_complexity)(var_y) + var_y = UpscaleBlock(decoder_complexity // 2)(var_y) + var_y = UpscaleBlock(decoder_complexity // 4)(var_y) + var_y = Conv2DOutput(1, 5, name="mask_out_a")(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs) + return KerasModel(input_, outputs=outputs, name="decoder_a") def decoder_b(self): """ Decoder for side B """ kwargs = dict(kernel_size=5, kernel_initializer=self.kernel_initializer) - dense_dim = 384 if self.lowmem else self.config["complexity_decoder_b"] - decoder_complexity = 384 if self.lowmem else 512 + dense_dim = 384 if self.low_mem else self.config["complexity_decoder_b"] + decoder_complexity = 384 if self.low_mem else 512 decoder_shape = self.input_shape[0] // 16 input_ = Input(shape=(decoder_shape, decoder_shape, dense_dim)) var_x = input_ - if self.lowmem: - var_x = self.blocks.upscale(var_x, decoder_complexity, **kwargs) - var_x = self.blocks.upscale(var_x, decoder_complexity // 2, **kwargs) - var_x = self.blocks.upscale(var_x, decoder_complexity // 4, **kwargs) - var_x = self.blocks.upscale(var_x, decoder_complexity // 8, **kwargs) + if self.low_mem: + var_x = UpscaleBlock(decoder_complexity, **kwargs)(var_x) + var_x = UpscaleBlock(decoder_complexity // 2, **kwargs)(var_x) + var_x = UpscaleBlock(decoder_complexity // 4, **kwargs)(var_x) + var_x = UpscaleBlock(decoder_complexity // 8, **kwargs)(var_x) else: - var_x = self.blocks.upscale(var_x, decoder_complexity, - res_block_follows=True, **kwargs) - var_x = self.blocks.res_block(var_x, decoder_complexity, - kernel_initializer=self.kernel_initializer) - var_x = self.blocks.upscale(var_x, decoder_complexity, - res_block_follows=True, **kwargs) - var_x = self.blocks.res_block(var_x, decoder_complexity, - kernel_initializer=self.kernel_initializer) - var_x = self.blocks.upscale(var_x, decoder_complexity // 2, - res_block_follows=True, **kwargs) - var_x = self.blocks.res_block(var_x, decoder_complexity // 2, - kernel_initializer=self.kernel_initializer) - var_x = self.blocks.upscale(var_x, decoder_complexity // 4, **kwargs) - var_x = self.blocks.conv2d(var_x, 3, - kernel_size=5, - padding="same", - activation="sigmoid", - name="face_out") + var_x = UpscaleBlock(decoder_complexity, res_block_follows=True, **kwargs)(var_x) + var_x = ResidualBlock(decoder_complexity, + kernel_initializer=self.kernel_initializer)(var_x) + var_x = UpscaleBlock(decoder_complexity, res_block_follows=True, **kwargs)(var_x) + var_x = ResidualBlock(decoder_complexity, + kernel_initializer=self.kernel_initializer)(var_x) + var_x = UpscaleBlock(decoder_complexity // 2, res_block_follows=True, **kwargs)(var_x) + var_x = ResidualBlock(decoder_complexity // 2, + kernel_initializer=self.kernel_initializer)(var_x) + var_x = UpscaleBlock(decoder_complexity // 4, **kwargs)(var_x) + var_x = Conv2DOutput(3, 5, name="face_out_b")(var_x) outputs = [var_x] if self.config.get("learn_mask", False): var_y = input_ - var_y = self.blocks.upscale(var_y, decoder_complexity) - if not self.lowmem: - var_y = self.blocks.upscale(var_y, decoder_complexity) - var_y = self.blocks.upscale(var_y, decoder_complexity // 2) - var_y = self.blocks.upscale(var_y, decoder_complexity // 4) - if self.lowmem: - var_y = self.blocks.upscale(var_y, decoder_complexity // 8) - var_y = self.blocks.conv2d(var_y, 1, - kernel_size=5, - padding="same", - activation="sigmoid", - name="mask_out") + var_y = UpscaleBlock(decoder_complexity)(var_y) + if not self.low_mem: + var_y = UpscaleBlock(decoder_complexity)(var_y) + var_y = UpscaleBlock(decoder_complexity // 2)(var_y) + var_y = UpscaleBlock(decoder_complexity // 4)(var_y) + if self.low_mem: + var_y = UpscaleBlock(decoder_complexity // 8)(var_y) + var_y = Conv2DOutput(1, 5, name="mask_out_b")(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs) + return KerasModel(input_, outputs=outputs, name="decoder_b") + + def _legacy_mapping(self): + """ The mapping of legacy separate model names to single model names """ + return {"{}_encoder.h5".format(self.name): "encoder", + "{}_decoder_A.h5".format(self.name): "decoder_a", + "{}_decoder_B.h5".format(self.name): "decoder_b"} diff --git a/plugins/train/model/villain.py b/plugins/train/model/villain.py index e662f60bd5..f172d5195c 100644 --- a/plugins/train/model/villain.py +++ b/plugins/train/model/villain.py @@ -1,29 +1,24 @@ #!/usr/bin/env python3 """ Original - VillainGuy model - Based on the original https://www.reddit.com/r/deepfakes/ code sample + contribs + Based on the original https://www.reddit.com/r/deepfakes/ code sample + contributions Adapted from a model by VillainGuy (https://github.com/VillainGuy) """ from keras.initializers import RandomNormal from keras.layers import add, Dense, Flatten, Input, Reshape -from keras.models import Model as KerasModel from lib.model.layers import PixelShuffler -from .original import logger, Model as OriginalModel +from lib.model.nn_blocks import (Conv2DOutput, Conv2DBlock, ResidualBlock, SeparableConv2DBlock, + UpscaleBlock) +from .original import Model as OriginalModel, KerasModel class Model(OriginalModel): """ Villain Faceswap Model """ def __init__(self, *args, **kwargs): - logger.debug("Initializing %s: (args: %s, kwargs: %s", - self.__class__.__name__, args, kwargs) - - self.configfile = kwargs.get("configfile", None) - kwargs["input_shape"] = (128, 128, 3) - kwargs["encoder_dim"] = 512 if self.config["lowmem"] else 1024 - self.kernel_initializer = RandomNormal(0, 0.02) - super().__init__(*args, **kwargs) - logger.debug("Initialized %s", self.__class__.__name__) + self.input_shape = (128, 128, 3) + self.encoder_dim = 512 if self.low_mem else 1024 + self.kernel_initializer = RandomNormal(0, 0.02) def encoder(self): """ Encoder Network """ @@ -34,59 +29,51 @@ def encoder(self): in_conv_filters = 128 + (self.input_shape[0] - 128) // 4 dense_shape = self.input_shape[0] // 16 - var_x = self.blocks.conv(input_, in_conv_filters, res_block_follows=True, **kwargs) + var_x = Conv2DBlock(in_conv_filters, res_block_follows=True, **kwargs)(input_) tmp_x = var_x res_cycles = 8 if self.config.get("lowmem", False) else 16 for _ in range(res_cycles): - nn_x = self.blocks.res_block(var_x, in_conv_filters, **kwargs) + nn_x = ResidualBlock(in_conv_filters, **kwargs)(var_x) var_x = nn_x # consider adding scale before this layer to scale the residual chain var_x = add([var_x, tmp_x]) - var_x = self.blocks.conv(var_x, 128, **kwargs) + var_x = Conv2DBlock(128, **kwargs)(var_x) var_x = PixelShuffler()(var_x) - var_x = self.blocks.conv(var_x, 128, **kwargs) + var_x = Conv2DBlock(128, **kwargs)(var_x) var_x = PixelShuffler()(var_x) - var_x = self.blocks.conv(var_x, 128, **kwargs) - var_x = self.blocks.conv_sep(var_x, 256, **kwargs) - var_x = self.blocks.conv(var_x, 512, **kwargs) + var_x = Conv2DBlock(128, **kwargs)(var_x) + var_x = SeparableConv2DBlock(256, **kwargs)(var_x) + var_x = Conv2DBlock(512, **kwargs)(var_x) if not self.config.get("lowmem", False): - var_x = self.blocks.conv_sep(var_x, 1024, **kwargs) + var_x = SeparableConv2DBlock(1024, **kwargs)(var_x) var_x = Dense(self.encoder_dim, **kwargs)(Flatten()(var_x)) var_x = Dense(dense_shape * dense_shape * 1024, **kwargs)(var_x) var_x = Reshape((dense_shape, dense_shape, 1024))(var_x) - var_x = self.blocks.upscale(var_x, 512, **kwargs) - return KerasModel(input_, var_x) + var_x = UpscaleBlock(512, **kwargs)(var_x) + return KerasModel(input_, var_x, name="encoder") - def decoder(self): + def decoder(self, side): """ Decoder Network """ kwargs = dict(kernel_initializer=self.kernel_initializer) decoder_shape = self.input_shape[0] // 8 input_ = Input(shape=(decoder_shape, decoder_shape, 512)) var_x = input_ - var_x = self.blocks.upscale(var_x, 512, res_block_follows=True, **kwargs) - var_x = self.blocks.res_block(var_x, 512, **kwargs) - var_x = self.blocks.upscale(var_x, 256, res_block_follows=True, **kwargs) - var_x = self.blocks.res_block(var_x, 256, **kwargs) - var_x = self.blocks.upscale(var_x, self.input_shape[0], res_block_follows=True, **kwargs) - var_x = self.blocks.res_block(var_x, self.input_shape[0], **kwargs) - var_x = self.blocks.conv2d(var_x, 3, - kernel_size=5, - padding="same", - activation="sigmoid", - name="face_out") + var_x = UpscaleBlock(512, res_block_follows=True, **kwargs)(var_x) + var_x = ResidualBlock(512, **kwargs)(var_x) + var_x = UpscaleBlock(256, res_block_follows=True, **kwargs)(var_x) + var_x = ResidualBlock(256, **kwargs)(var_x) + var_x = UpscaleBlock(self.input_shape[0], res_block_follows=True, **kwargs)(var_x) + var_x = ResidualBlock(self.input_shape[0], **kwargs)(var_x) + var_x = Conv2DOutput(3, 5, name="face_out_{}".format(side))(var_x) outputs = [var_x] if self.config.get("learn_mask", False): var_y = input_ - var_y = self.blocks.upscale(var_y, 512) - var_y = self.blocks.upscale(var_y, 256) - var_y = self.blocks.upscale(var_y, self.input_shape[0]) - var_y = self.blocks.conv2d(var_y, 1, - kernel_size=5, - padding="same", - activation="sigmoid", - name="mask_out") + var_y = UpscaleBlock(512)(var_y) + var_y = UpscaleBlock(256)(var_y) + var_y = UpscaleBlock(self.input_shape[0])(var_y) + var_y = Conv2DOutput(1, 5, name="mask_out_{}".format(side))(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs) + return KerasModel(input_, outputs=outputs, name="decoder_{}".format(side)) diff --git a/plugins/train/model/villain_defaults.py b/plugins/train/model/villain_defaults.py index 68a4fad833..da3af3eecc 100755 --- a/plugins/train/model/villain_defaults.py +++ b/plugins/train/model/villain_defaults.py @@ -43,7 +43,7 @@ _HELPTEXT = ( "A Higher resolution version of the Original Model by VillainGuy.\n" - "Extremely VRAM heavy. Full model requires 9GB+ for batchsize 16\n" + "Extremely VRAM heavy. Don't try to run this if you have a small GPU.\n" ) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 5a61edbade..70f95b6a97 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -3,45 +3,11 @@ this class. At present there is only the :class:`~plugins.train.trainer.original` plugin, so that entirely -inherits from this class. - -This class heavily references the :attr:`plugins.train.model._base.ModelBase.training_opts` -``dict``. The following keys are expected from this ``dict``: - - * **alignments** (`dict`, `optional`) - If training with a mask or the warp to landmarks \ - command line option is selected then this is required, otherwise it can be ``None``. The \ - dictionary should contain 2 keys ("a" and "b") with the values being the path to the \ - alignments file for the corresponding side. - - * **preview_scaling** (`int`) - How much to scale displayed preview image by. - - * **training_size** ('int') - Size of the training images in pixels. - - * **coverage_ratio** ('float') - Ratio of face to be cropped out of the training image. - - * **mask_type** ('str') - The type of mask to select from the alignments file. - - * **mask_blur_kernel** ('int') - The size of the kernel to use for gaussian blurring the mask. - - * **mask_threshold** ('int') - The threshold for min/maxing mask to 0/100. - - * **learn_mask** ('bool') - Whether the mask should be trained in the model. - - * **penalized_mask_loss** ('bool') - Whether the mask should be penalized from loss. - - * **no_logs** ('bool') - Whether Tensorboard logging should be disabled. - - * **snapshot_interval** ('int') - How many iterations between model snapshot saves. - - * **warp_to_landmarks** ('bool') - Whether to use random_warp_landmarks instead of random_warp. - - * **augment_color** ('bool') - Whether to use color augmentation. - - * **no_flip** ('bool') - Whether to turn off random horizontal flipping. - - * **pingpong** ('bool') - Train each side separately per save iteration rather than together. +inherits from this class. If further plugins are developed, then common code should be kept here, +with "original" unique code split out to the original plugin. """ +# pylint:disable=too-many-lines import logging import os import time @@ -57,7 +23,7 @@ from lib.faces_detect import DetectedFace from lib.image import read_image_hash_batch from lib.training_data import TrainingDataGenerator -from lib.utils import FaceswapError, get_folder, get_image_paths +from lib.utils import FaceswapError, get_backend, get_folder, get_image_paths from plugins.train._config import Config logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -83,7 +49,8 @@ def _get_config(plugin_name, configfile=None): class TrainerBase(): - """ Trainer plugin base Object. + """ Handles the feeding of training images to Faceswap models, the generation of Tensorboard + logs and the creation of sample/time-lapse preview images. All Trainer plugins must inherit from this class. @@ -111,69 +78,48 @@ def __init__(self, model, images, batch_size, configfile): self._images = images self._sides = sorted(key for key in self._images.keys()) - self._process_training_opts() - self._pingpong = PingPong(model, self._sides) - - self._batchers = {side: Batcher(side, - images[side], - self._model, - self._use_mask, - batch_size, - self._config) - for side in self._sides} + self._feeder = _Feeder(images, + self._model, + batch_size, + self._config, + self._get_alignments_data()) self._tensorboard = self._set_tensorboard() - self._samples = Samples(self._model, - self._use_mask, - self._model.training_opts["coverage_ratio"], - self._model.training_opts["preview_scaling"]) - self._timelapse = Timelapse(self._model, - self._use_mask, - self._model.training_opts["coverage_ratio"], - self._config.get("preview_images", 14), - self._batchers) + self._samples = _Samples(self._model, + self._model.coverage_ratio, + self._model.command_line_arguments.preview_scale / 100) + self._timelapse = _Timelapse(self._model, + self._model.coverage_ratio, + self._config.get("preview_images", 14), + self._feeder) logger.debug("Initialized %s", self.__class__.__name__) - @property - def pingpong(self): - """ :class:`pingpong`: Ping-pong object for ping-pong memory saving training. """ - return self._pingpong + def _get_alignments_data(self): + """ Extrapolate alignments and masks from the alignments file into a `dict` for the + training data generator. - @property - def _timestamp(self): - """ str: Current time formatted as HOURS:MINUTES:SECONDS """ - return time.strftime("%H:%M:%S") + Returns + ------- + dict: + Includes the key `landmarks` if landmarks are required for training and the key `masks` + if the masks are required for training. """ + retval = dict() - @property - def _landmarks_required(self): - """ bool: ``True`` if Landmarks are required otherwise ``False ``""" - retval = self._model.training_opts["warp_to_landmarks"] - logger.debug(retval) - return retval + get_masks = self._model.config["learn_mask"] or self._model.config["penalized_mask_loss"] + if not self._model.command_line_arguments.warp_to_landmarks and not get_masks: + return retval - @property - def _use_mask(self): - """ bool: ``True`` if a mask is required otherwise ``False`` """ - retval = (self._model.training_opts["learn_mask"] or - self._model.training_opts["penalized_mask_loss"]) - logger.debug(retval) - return retval - - def _process_training_opts(self): - """ Extrapolate alignments and masks from the alignments file into - :attr:`_model.training_opts`.""" - logger.debug(self._model.training_opts) - if not self._landmarks_required and not self._use_mask: - return + alignments = _TrainingAlignments(self._model, self._images) - alignments = TrainingAlignments(self._model.training_opts, self._images) - if self._landmarks_required: + if self._model.command_line_arguments.warp_to_landmarks: logger.debug("Adding landmarks to training opts dict") - self._model.training_opts["landmarks"] = alignments.landmarks + retval["landmarks"] = alignments.landmarks - if self._use_mask: + if get_masks: logger.debug("Adding masks to training opts dict") - self._model.training_opts["masks"] = alignments.masks + retval["masks"] = alignments.masks + logger.debug(retval) + return retval def _set_tensorboard(self): """ Set up Tensorboard callback for logging loss. @@ -182,77 +128,48 @@ def _set_tensorboard(self): Returns ------- - dict: - 2 Dictionary keys of "a" and "b" the values of which are the - :class:`tf.keras.callbacks.TensorBoard` objects for the respective sides. + :class:`tf.keras.callbacks.TensorBoard` + Tensorboard object for the the current training session. """ - if self._model.training_opts["no_logs"]: + if self._model.state.current_session["no_logs"]: logger.verbose("TensorBoard logging disabled") return None - if self._pingpong.active: - # Currently TensorBoard uses the tf.session, meaning that VRAM does not - # get cleared when model switching - # TODO find a fix for this - logger.warning("Currently TensorBoard logging is not supported for Ping-Pong " - "training. Session stats and graphing will not be available for this " - "training session.") - return None - logger.debug("Enabling TensorBoard Logging") - tensorboard = dict() - - for side in self._sides: - logger.debug("Setting up TensorBoard Logging. Side: %s", side) - log_dir = os.path.join(str(self._model.model_dir), - "{}_logs".format(self._model.name), - side, - "session_{}".format(self._model.state.session_id)) - tbs = tf.keras.callbacks.TensorBoard(log_dir=log_dir, **self._tensorboard_kwargs) - tbs.set_model(self._model.predictors[side]) - tensorboard[side] = tbs + + logger.debug("Setting up TensorBoard Logging") + log_dir = os.path.join(str(self._model.model_dir), + "{}_logs".format(self._model.name), + "session_{}".format(self._model.state.session_id)) + tensorboard = tf.keras.callbacks.TensorBoard(log_dir=log_dir, + histogram_freq=0, # Must be 0 or hangs + write_graph=get_backend() != "amd", + write_images=False, + update_freq="batch", + profile_batch=0, + embeddings_freq=0, + embeddings_metadata=None) + tensorboard.set_model(self._model.model) + tensorboard.on_train_begin(0) logger.info("Enabled TensorBoard Logging") return tensorboard - @property - def _tensorboard_kwargs(self): - """ dict: The keyword arguments to be passed to :class:`tf.keras.callbacks.TensorBoard`. - NB: Tensorflow 1.13 + needs an additional keyword argument which is not valid for earlier - versions """ - kwargs = dict(histogram_freq=0, # Must be 0 or hangs - batch_size=64, - write_graph=True, - write_grads=True) - tf_version = [int(ver) for ver in tf.__version__.split(".") if ver.isdigit()] - logger.debug("Tensorflow version: %s", tf_version) - if tf_version[0] > 1 or (tf_version[0] == 1 and tf_version[1] > 12): - kwargs["update_freq"] = "batch" - if tf_version[0] > 1 or (tf_version[0] == 1 and tf_version[1] > 13): - kwargs["profile_batch"] = 0 - logger.debug(kwargs) - return kwargs - - def __print_loss(self, loss): - """ Outputs the loss for the current iteration to the console. - - Parameters - ---------- - loss: dict - The loss for each side. The dictionary should contain 2 keys ("a" and "b") with the - values being a list of loss values for the current iteration corresponding to - each side. - """ - logger.trace(loss) - output = ["Loss {}: {:.5f}".format(side.capitalize(), loss[side][0]) - for side in sorted(loss.keys())] - output = ", ".join(output) - output = "[{}] [#{:05d}] {}".format(self._timestamp, self._model.iterations, output) - print("\r{}".format(output), end="") - def train_one_step(self, viewer, timelapse_kwargs): """ Running training on a batch of images for each side. Triggered from the training cycle in :class:`scripts.train.Train`. + * Runs a training batch through the model. + + * Outputs the iteration's loss values to the console + + * Logs loss to Tensorboard, if logging is requested. + + * If a preview or time-lapse has been requested, then pushes sample images through the \ + model to generate the previews + + * Creates a snapshot if the total iterations trained so far meet the requested snapshot \ + criteria + Notes ----- As every iteration is called explicitly, the Parameters defined should always be ``None`` @@ -267,85 +184,99 @@ def train_one_step(self, viewer, timelapse_kwargs): not required then this should be ``None``. Otherwise all values should be full paths the keys being `input_a`, `input_b`, `output`. """ + self._model.state.increment_iterations() logger.trace("Training one step: (iteration: %s)", self._model.iterations) do_preview = viewer is not None do_timelapse = timelapse_kwargs is not None - snapshot_interval = self._model.training_opts.get("snapshot_interval", 0) + snapshot_interval = self._model.command_line_arguments.snapshot_interval do_snapshot = (snapshot_interval != 0 and - self._model.iterations >= snapshot_interval and - self._model.iterations % snapshot_interval == 0) + self._model.iterations - 1 >= snapshot_interval and + (self._model.iterations - 1) % snapshot_interval == 0) - loss = dict() + model_inputs, model_targets = self._feeder.get_batch() try: - for side, batcher in self._batchers.items(): - if self._pingpong.active and side != self._pingpong.side: - continue - loss[side] = batcher.train_one_batch() - if not do_preview and not do_timelapse: - continue - if do_preview: - batcher.generate_preview(do_preview) - self._samples.images[side] = batcher.compile_sample(None) - if do_timelapse: - self._timelapse.get_sample(side, timelapse_kwargs) + loss = self._model.model.train_on_batch(model_inputs, y=model_targets) + except tf_errors.ResourceExhaustedError as err: + msg = ("You do not have enough GPU memory available to train the selected model at " + "the selected settings. You can try a number of things:" + "\n1) Close any other application that is using your GPU (web browsers are " + "particularly bad for this)." + "\n2) Lower the batchsize (the amount of images fed into the model each " + "iteration)." + "\n3) Try enabling 'Mixed Precision' training." + "\n4) Use a more lightweight model, or select the model's 'LowMem' option " + "(in config) if it has one.") + raise FaceswapError(msg) from err - self._model.state.increment_iterations() + self._log_tensorboard(loss) + loss = self._collate_and_store_loss(loss[1:]) + self._print_loss(loss) - for side, side_loss in loss.items(): - self._store_history(side, side_loss) - self._log_tensorboard(side, side_loss) + if do_snapshot: + self._model.snapshot() - if not self._pingpong.active: - self.__print_loss(loss) - else: - for key, val in loss.items(): - self._pingpong.loss[key] = val - self.__print_loss(self._pingpong.loss) + if do_preview: + self._feeder.generate_preview(do_preview) + self._samples.images = self._feeder.compile_sample(None) + samples = self._samples.show_sample() + if samples is not None: + viewer(samples, + "Training - 'S': Save Now. 'R': Refresh Preview. 'ENTER': Save and Quit") + + if do_timelapse: + self._timelapse.output_timelapse(timelapse_kwargs) - if do_preview: - samples = self._samples.show_sample() - if samples is not None: - viewer(samples, "Training - 'S': Save Now. 'ENTER': Save and Quit") + def _log_tensorboard(self, loss): + """ Log current loss to Tensorboard log files - if do_timelapse: - self._timelapse.output_timelapse() + Parameters + ---------- + loss: list + The list of loss ``floats`` output from the model + """ + if not self._tensorboard: + return + logger.trace("Updating TensorBoard log") + logs = {log[0]: log[1] + for log in zip(self._model.state.loss_names, loss)} + self._tensorboard.on_train_batch_end(self._model.iterations, logs=logs) - if do_snapshot: - self._model.do_snapshot() - except Exception as err: - raise err + def _collate_and_store_loss(self, loss): + """ Collate the loss into totals for each side. - def _store_history(self, side, loss): - """ Store the loss for this step into :attr:`model.history`. + The losses are then into a total for each side. Loss totals are added to + :attr:`model.state._history` to track the loss drop per save iteration for backup purposes. Parameters ---------- - side: {"a", "b"} - The side to store the loss for loss: list - The list of loss ``floats`` for this side + The list of loss ``floats`` for this iteration. + + Returns + ------- + list + List of 2 ``floats`` which is the total loss for each side """ - logger.trace("Updating loss history: '%s'", side) - self._model.history[side].append(loss[0]) # Either only loss or total loss - logger.trace("Updated loss history: '%s'", side) + split = len(loss) // 2 + combined_loss = [sum(loss[:split]), sum(loss[split:])] + self._model.add_history(combined_loss) + logger.trace("original loss: %s, comibed_loss: %s", loss, combined_loss) + return combined_loss - def _log_tensorboard(self, side, loss): - """ Log current loss to Tensorboard log files + def _print_loss(self, loss): + """ Outputs the loss for the current iteration to the console. Parameters ---------- - side: {"a", "b"} - The side to store the loss for loss: list - The list of loss ``floats`` for this side - """ - if not self._tensorboard: - return - logger.trace("Updating TensorBoard log: '%s'", side) - logs = {log[0]: log[1] - for log in zip(self._model.state.loss_names[side], loss)} - self._tensorboard[side].on_batch_end(self._model.state.iterations, logs) - logger.trace("Updated TensorBoard log: '%s'", side) + The loss for each side. List should contain 2 ``floats`` side "a" in position 0 and + side "b" in position `. + """ + output = ", ".join(["Loss {}: {:.5f}".format(side, side_loss) + for side, side_loss in zip(("A", "B"), loss)]) + timestamp = time.strftime("%H:%M:%S") + output = "[{}] [#{:05d}] {}".format(timestamp, self._model.iterations, output) + print("\r{}".format(output), end="") def clear_tensorboard(self): """ Stop Tensorboard logging. @@ -355,91 +286,129 @@ def clear_tensorboard(self): """ if not self._tensorboard: return - for side, tensorboard in self._tensorboard.items(): - logger.debug("Ending Tensorboard. Side: '%s'", side) - tensorboard.on_train_end(None) + logger.debug("Ending Tensorboard Session: %s", self._tensorboard) + self._tensorboard.on_train_end(None) -class Batcher(): - """ Handles the processing of a Batch for a single side. +class _Feeder(): + """ Handles the processing of a Batch for training the model and generating samples. Parameters ---------- - side: {"a" or "b"} - The side that this :class:`Batcher` belongs to - images: list - The list of full paths to the training images for this :class:`Batcher` + images: dict + The list of full paths to the training images for this :class:`_Feeder` for each side model: plugin from :mod:`plugins.train.model` The selected model that will be running this trainer - use_mask: bool - ``True`` if a mask is required for training otherwise ``False`` batch_size: int - The size of the batch to be processed at each iteration + The size of the batch to be processed for each side at each iteration config: :class:`lib.config.FaceswapConfig` The configuration for this trainer + alignments: dict + A dictionary containing landmarks and masks if these are required for training for each + side """ - def __init__(self, side, images, model, use_mask, batch_size, config): - logger.debug("Initializing %s: side: '%s', num_images: %s, use_mask: %s, batch_size: %s, " - "config: %s)", - self.__class__.__name__, side, len(images), use_mask, batch_size, config) + def __init__(self, images, model, batch_size, config, alignments): + logger.debug("Initializing %s: num_images: %s, batch_size: %s, config: %s)", + self.__class__.__name__, len(images), batch_size, config) self._model = model - self._use_mask = use_mask - self._side = side self._images = images self._config = config - self._target = None - self._samples = None - self._masks = None - - generator = self._load_generator() - self._feed = generator.minibatch_ab(images, batch_size, self._side) - - self._preview_feed = None - self._timelapse_feed = None - self._set_preview_feed() - - def _load_generator(self): - """ Load the :class:`lib.training_data.TrainingDataGenerator` for this batcher """ - logger.debug("Loading generator: %s", self._side) - input_size = self._model.input_shape[0] - output_shapes = self._model.output_shapes + self._alignments = alignments + self._target = dict() + self._samples = dict() + self._masks = dict() + + self._feeds = {side: self._load_generator(idx).minibatch_ab(images[side], batch_size, side) + for idx, side in enumerate(("a", "b"))} + + self._display_feeds = dict(preview=self._set_preview_feed(), timelapse=dict()) + logger.debug("Initialized %s:", self.__class__.__name__) + + def _load_generator(self, output_index): + """ Load the :class:`~lib.training_data.TrainingDataGenerator` for this feeder. + + Parameters + ---------- + output_index: int + The output index from the model to get output shapes for + + Returns + ------- + :class:`~lib.training_data.TrainingDataGenerator` + The training data generator + """ + logger.debug("Loading generator") + input_size = self._model.model.input_shape[output_index][1] + output_shapes = self._model.output_shapes[output_index] logger.debug("input_size: %s, output_shapes: %s", input_size, output_shapes) generator = TrainingDataGenerator(input_size, output_shapes, - self._model.training_opts, + self._model.coverage_ratio, + not self._model.command_line_arguments.no_augment_color, + self._model.command_line_arguments.no_flip, + self._model.command_line_arguments.warp_to_landmarks, + self._alignments, self._config) return generator - def train_one_batch(self): - """ Train on a single batch of images for this :class:`Batcher` + def _set_preview_feed(self): + """ Set the preview feed for this feeder. + + Creates a generator from :class:`lib.training_data.TrainingDataGenerator` specifically + for previews for the feeder. Returns ------- - list - The list of loss values (as ``float``) for this batch + dict + The side ("a" or "b") as key, :class:`~lib.training_data.TrainingDataGenerator` as + value. """ - logger.trace("Training one step: (side: %s)", self._side) - model_inputs, model_targets = self._get_next() - try: - loss = self._model.predictors[self._side].train_on_batch(model_inputs, model_targets) - except tf_errors.ResourceExhaustedError as err: - msg = ("You do not have enough GPU memory available to train the selected model at " - "the selected settings. You can try a number of things:" - "\n1) Close any other application that is using your GPU (web browsers are " - "particularly bad for this)." - "\n2) Lower the batchsize (the amount of images fed into the model each " - "iteration)." - "\n3) Try 'Memory Saving Gradients' and/or 'Optimizer Savings' and/or 'Ping " - "Pong Training'." - "\n4) Use a more lightweight model, or select the model's 'LowMem' option " - "(in config) if it has one.") - raise FaceswapError(msg) from err - loss = loss if isinstance(loss, list) else [loss] - return loss + retval = dict() + for idx, side in enumerate(("a", "b")): + logger.debug("Setting preview feed: (side: '%s')", side) + preview_images = self._config.get("preview_images", 14) + preview_images = min(max(preview_images, 2), 16) + batchsize = min(len(self._images[side]), preview_images) + retval[side] = self._load_generator(idx).minibatch_ab(self._images[side], + batchsize, + side, + do_shuffle=True, + is_preview=True) + logger.debug("Set preview feed. Batchsize: %s", batchsize) + return retval + + def get_batch(self): + """ Get the feed data and the targets for each training side for feeding into the model's + train function. + + Returns + ------- + model_inputs: list + The inputs to the model for each side A and B + model_targets: list + The targets for the model for each side A and B + """ + model_inputs = [] + model_targets = [] + for side in ("a", "b"): + side_inputs, side_targets = self._get_next(side) + if self._model.config["penalized_mask_loss"]: + side_targets = self._compile_masks(side_targets) + if not self._model.config["learn_mask"]: # Remove masks from the model targets + side_targets = side_targets[:-1] + logger.trace("side: %s, input_shapes: %s, target_shapes: %s", + side, [i.shape for i in side_inputs], [i.shape for i in side_targets]) + if get_backend() == "amd": + model_inputs.extend(side_inputs) + model_targets.extend(side_targets) + else: + model_inputs.append(side_inputs) + model_targets.append(side_targets) + return model_inputs, model_targets - def _get_next(self): + def _get_next(self, side): """ Return the next batch from the :class:`lib.training_data.TrainingDataGenerator` for - this batcher ready for feeding into the model. + this feeder ready for feeding into the model. Returns ------- @@ -449,11 +418,40 @@ def _get_next(self): A list of :class:`numpy.ndarray` for comparing the output of the model """ logger.trace("Generating targets") - batch = next(self._feed) - targets_use_mask = self._model.training_opts["learn_mask"] - model_inputs = batch["feed"] + batch["masks"] if self._use_mask else batch["feed"] + batch = next(self._feeds[side]) + targets_use_mask = (self._model.config["learn_mask"] + or self._model.config["penalized_mask_loss"]) model_targets = batch["targets"] + batch["masks"] if targets_use_mask else batch["targets"] - return model_inputs, model_targets + return batch["feed"], model_targets + + @classmethod + def _compile_masks(cls, targets): + """ Compile the masks into the targets for penalized loss. + + Penalized loss expects the target mask to be included for all outputs in the 4th channel + of the targets. The final output and final mask are always the last 2 outputs + + Parameters + ---------- + targets: list + The targets for the model, with the mask as the final entry in the list + + Returns + ------- + list + The targets for the model with the mask compiled into the 4th channel. The original + mask is still output as the final item in the list + """ + masks = targets[-1] + for idx, tgt in enumerate(targets[:-1]): + tgt_dim = tgt.shape[1] + if tgt_dim == masks.shape[1]: + add_masks = masks + else: + add_masks = np.array([cv2.resize(mask, (tgt_dim, tgt_dim)) + for mask in masks])[..., None] + targets[idx] = np.concatenate((tgt, add_masks), axis=-1) + return targets def generate_preview(self, do_preview): """ Generate the preview images. @@ -465,32 +463,16 @@ def generate_preview(self, do_preview): should not be generated, in which case currently stored previews should be deleted. """ if not do_preview: - self._samples = None - self._target = None - self._masks = None + self._samples = dict() + self._target = dict() + self._masks = dict() return logger.debug("Generating preview") - batch = next(self._preview_feed) - self._samples = batch["samples"] - self._target = batch["targets"][self._model.largest_face_index] - self._masks = batch["masks"][0] - - def _set_preview_feed(self): - """ Set the preview feed for this batcher. - - Creates a generator from :class:`lib.training_data.TrainingDataGenerator` specifically - for previews for the batcher. - """ - logger.debug("Setting preview feed: (side: '%s')", self._side) - preview_images = self._config.get("preview_images", 14) - preview_images = min(max(preview_images, 2), 16) - batchsize = min(len(self._images), preview_images) - self._preview_feed = self._load_generator().minibatch_ab(self._images, - batchsize, - self._side, - do_shuffle=True, - is_preview=True) - logger.debug("Set preview feed. Batchsize: %s", batchsize) + for side in ("a", "b"): + batch = next(self._display_feeds["preview"][side]) + self._samples[side] = batch["samples"] + self._target[side] = batch["targets"][-1] + self._masks[side] = batch["masks"][0] def compile_sample(self, batch_size, samples=None, images=None, masks=None): """ Compile the preview samples for display. @@ -499,18 +481,18 @@ def compile_sample(self, batch_size, samples=None, images=None, masks=None): ---------- batch_size: int The requested batch size for each training iterations - samples: :class:`numpy.ndarray`, optional - The sample images that should be used for creating the preview. If ``None`` then the - samples will be generated from the internal random image generator. - Default: ``None`` - images: :class:`numpy.ndarray`, optional - The target images that should be used for creating the preview. If ``None`` then the - targets will be generated from the internal random image generator. - Default: ``None`` - masks: :class:`numpy.ndarray`, optional - The masks that should be used for creating the preview. If ``None`` then the - masks will be generated from the internal random image generator. - Default: ``None`` + samples: dict, optional + Dictionary for side "a", "b" of :class:`numpy.ndarray`. The sample images that should + be used for creating the preview. If ``None`` then the samples will be generated from + the internal random image generator. Default: ``None`` + images: dict, optional + Dictionary for side "a", "b" of :class:`numpy.ndarray`. The target images that should + be used for creating the preview. If ``None`` then the targets will be generated from + the internal random image generator. Default: ``None`` + masks: dict, optional + Dictionary for side "a", "b" of :class:`numpy.ndarray`. The masks that should be used + for creating the preview. If ``None`` then the masks will be generated from the + internal random image generator. Default: ``None`` Returns ------- @@ -520,11 +502,15 @@ def compile_sample(self, batch_size, samples=None, images=None, masks=None): """ num_images = self._config.get("preview_images", 14) num_images = min(batch_size, num_images) if batch_size is not None else num_images - logger.debug("Compiling samples: (side: '%s', samples: %s)", self._side, num_images) - images = images if images is not None else self._target - masks = masks if masks is not None else self._masks - samples = samples if samples is not None else self._samples - retval = [samples[0:num_images], images[0:num_images], masks[0:num_images]] + retval = dict() + for side in ("a", "b"): + logger.debug("Compiling samples: (side: '%s', samples: %s)", side, num_images) + side_images = images[side] if images is not None else self._target[side] + side_masks = masks[side] if masks is not None else self._masks[side] + side_samples = samples[side] if samples is not None else self._samples[side] + retval[side] = [side_samples[0:num_images], + side_images[0:num_images], + side_masks[0:num_images]] return retval def compile_timelapse_sample(self): @@ -532,52 +518,57 @@ def compile_timelapse_sample(self): Returns ------- - list - The list of samples, targets and masks as :class:`numpy.ndarrays` for creating a - time-lapse frame + dict + For sides "a" and "b"; The list of samples, targets and masks as + :class:`numpy.ndarrays` for creating a time-lapse frame """ - batch = next(self._timelapse_feed) - batchsize = len(batch["samples"]) - images = batch["targets"][self._model.largest_face_index] - masks = batch["masks"][0] - sample = self.compile_sample(batchsize, - samples=batch["samples"], - images=images, - masks=masks) + batchsizes = [] + samples = dict() + images = dict() + masks = dict() + for side in ("a", "b"): + batch = next(self._display_feeds["timelapse"][side]) + batchsizes.append(len(batch["samples"])) + samples[side] = batch["samples"] + images[side] = batch["targets"][-1] + masks[side] = batch["masks"][0] + batchsize = min(batchsizes) + sample = self.compile_sample(batchsize, samples=samples, images=images, masks=masks) return sample def set_timelapse_feed(self, images, batch_size): - """ Set the time-lapse feed for this batcher. + """ Set the time-lapse feed for this feeder. Creates a generator from :class:`lib.training_data.TrainingDataGenerator` specifically - for generating time-lapse previews for the batcher. + for generating time-lapse previews for the feeder. Parameters ---------- images: list The list of full paths to the images for creating the time-lapse for this - :class:`Batcher` + :class:`_Feeder` batch_size: int The number of images to be used to create the time-lapse preview. """ - logger.debug("Setting time-lapse feed: (side: '%s', input_images: '%s', batch_size: %s)", - self._side, images, batch_size) - self._timelapse_feed = self._load_generator().minibatch_ab(images[:batch_size], - batch_size, self._side, - do_shuffle=False, - is_timelapse=True) - logger.debug("Set time-lapse feed") - - -class Samples(): + logger.debug("Setting time-lapse feed: (input_images: '%s', batch_size: %s)", + images, batch_size) + for idx, side in enumerate(("a", "b")): + self._display_feeds["timelapse"][side] = self._load_generator(idx).minibatch_ab( + images[side][:batch_size], + batch_size, + side, + do_shuffle=False, + is_timelapse=True) + logger.debug("Set time-lapse feed: %s", self._display_feeds["timelapse"]) + + +class _Samples(): # pylint:disable=too-few-public-methods """ Compile samples for display for preview and time-lapse Parameters ---------- model: plugin from :mod:`plugins.train.model` The selected model that will be running this trainer - use_mask: bool - ``True`` if a mask should be displayed otherwise ``False`` coverage_ratio: float Ratio of face to be cropped out of the training image. scaling: float, optional @@ -590,11 +581,11 @@ class Samples(): dictionary should contain 2 keys ("a" and "b") with the values being the training images for generating samples corresponding to each side. """ - def __init__(self, model, use_mask, coverage_ratio, scaling=1.0): - logger.debug("Initializing %s: model: '%s', use_mask: %s, coverage_ratio: %s)", - self.__class__.__name__, model, use_mask, coverage_ratio) + def __init__(self, model, coverage_ratio, scaling=1.0): + logger.debug("Initializing %s: model: '%s', coverage_ratio: %s)", + self.__class__.__name__, model, coverage_ratio) self._model = model - self._use_mask = use_mask + self._display_mask = model.config["learn_mask"] or model.config["penalized_mask_loss"] self.images = dict() self._coverage_ratio = coverage_ratio self._scaling = scaling @@ -608,23 +599,19 @@ def show_sample(self): :class:`numpy.ndarry` A compiled preview image ready for display or saving """ - if len(self.images) != 2: - logger.debug("Ping Pong training - Only one side trained. Aborting preview") - return None logger.debug("Showing sample") feeds = dict() figures = dict() headers = dict() - for side, samples in self.images.items(): + for idx, side in enumerate(("a", "b")): + samples = self.images[side] faces = samples[1] - if self._model.input_shape[0] / faces.shape[1] != 1.0: - feeds[side] = self._resize_sample(side, faces, self._model.input_shape[0]) - feeds[side] = feeds[side].reshape((-1, ) + self._model.input_shape) + input_shape = self._model.model.input_shape[idx][1:] + if input_shape[0] / faces.shape[1] != 1.0: + feeds[side] = self._resize_sample(side, faces, input_shape[0]) + feeds[side] = feeds[side].reshape((-1, ) + input_shape) else: feeds[side] = faces - if self._use_mask: - mask = samples[-1] - feeds[side] = [feeds[side], mask] preds = self._get_predictions(feeds["a"], feeds["b"]) @@ -654,12 +641,14 @@ def show_sample(self): logger.debug("Compiled sample") return np.clip(figure * 255, 0, 255).astype('uint8') - @staticmethod - def _resize_sample(side, sample, target_size): + @classmethod + def _resize_sample(cls, side, sample, target_size): """ Resize a given image to the target size. Parameters ---------- + side: str + The side ("a" or "b") that the samples are being generated for sample: :class:`numpy.ndarray` The sample to be resized target_size: int @@ -692,19 +681,33 @@ def _get_predictions(self, feed_a, feed_b): List of :class:`numpy.ndarray` of feed images for the "b" side Returns + ------- list: List of :class:`numpy.ndarray` of predictions received from the model """ logger.debug("Getting Predictions") preds = dict() - preds["a_a"] = self._model.predictors["a"].predict(feed_a) - preds["b_a"] = self._model.predictors["b"].predict(feed_a) - preds["a_b"] = self._model.predictors["a"].predict(feed_b) - preds["b_b"] = self._model.predictors["b"].predict(feed_b) - # Get the returned largest image from predictors that emit multiple items - if not isinstance(preds["a_a"], np.ndarray): - for key, val in preds.items(): - preds[key] = val[self._model.largest_face_index] + standard = self._model.model.predict([feed_a, feed_b]) + swapped = self._model.model.predict([feed_b, feed_a]) + + if self._model.config["learn_mask"] and get_backend() == "amd": + # Ravel results for plaidml + split = len(standard) // 2 + standard = [standard[:split], standard[split:]] + swapped = [swapped[:split], swapped[split:]] + + if self._model.config["learn_mask"]: # Add mask to 4th channel of final output + standard = [np.concatenate(side[-2:], axis=-1) for side in standard] + swapped = [np.concatenate(side[-2:], axis=-1) for side in swapped] + else: # Retrieve final output + standard = [side[-1] if isinstance(side, list) else side for side in standard] + swapped = [side[-1] if isinstance(side, list) else side for side in swapped] + + preds["a_a"] = standard[0] + preds["b_b"] = standard[1] + preds["a_b"] = swapped[0] + preds["b_a"] = swapped[1] + logger.debug("Returning predictions: %s", {key: val.shape for key, val in preds.items()}) return preds @@ -716,9 +719,14 @@ def _to_full_frame(self, side, samples, predictions): side: {"a" or "b"} The side that these samples are for samples: list - List of :class:`numpy.ndarray` of target images and feed images + List of :class:`numpy.ndarray` of feed images and target images predictions: list List of :class: `numpy.ndarray` of predictions from the model + + Returns + ------- + list + The images resized and collated for display in the preview frame """ logger.debug("side: '%s', number of sample arrays: %s, prediction.shapes: %s)", side, len(samples), [pred.shape for pred in predictions]) @@ -729,7 +737,7 @@ def _to_full_frame(self, side, samples, predictions): if target_size != full_size: frame = self._frame_overlay(full, target_size, (0, 0, 255)) - if self._use_mask: + if self._display_mask: images = self._compile_masked(images, samples[-1]) images = [self._resize_sample(side, image, target_size) for image in images] if target_size != full_size: @@ -739,8 +747,8 @@ def _to_full_frame(self, side, samples, predictions): images = [self._resize_sample(side, image, new_size) for image in images] return images - @staticmethod - def _frame_overlay(images, target_size, color): + @classmethod + def _frame_overlay(cls, images, target_size, color): """ Add a frame overlay to preview images indicating the region of interest. This is the red border that appears in the preview images. @@ -776,16 +784,17 @@ def _frame_overlay(images, target_size, color): logger.debug("Overlayed background. Shape: %s", retval.shape) return retval - @staticmethod - def _compile_masked(faces, masks): + @classmethod + def _compile_masked(cls, faces, masks): """ Add the mask to the faces for masked preview. Places an opaque red layer over areas of the face that are masked out. Parameters ---------- - faces: :class:`numpy.ndarray` - The sample faces that are to have the mask applied + faces: list + The :class:`numpy.ndarray` sample faces and predictions that are to have the mask + applied masks: :class:`numpy.ndarray` The masks that are to be applied to the faces @@ -794,19 +803,26 @@ def _compile_masked(faces, masks): list List of :class:`numpy.ndarray` faces with the opaque mask layer applied """ - retval = list() - masks3 = np.tile(1 - np.rint(masks), 3) - for mask in masks3: - mask[np.where((mask == [1., 1., 1.]).all(axis=2))] = [0., 0., 1.] - for previews in faces: - images = np.array([cv2.addWeighted(img, 1.0, masks3[idx], 0.3, 0) - for idx, img in enumerate(previews)]) - retval.append(images) + orig_masks = np.tile(1 - np.rint(masks), 3) + orig_masks[np.where((orig_masks == [1., 1., 1.]).all(axis=3))] = [0., 0., 1.] + + if faces[-1].shape[-1] == 4: # Mask contained in alpha channel of predictions + pred_masks = [np.tile(1 - np.rint(face[..., -1])[..., None], 3) for face in faces[-2:]] + for swap_masks in pred_masks: + swap_masks[np.where((swap_masks == [1., 1., 1.]).all(axis=3))] = [0., 0., 1.] + faces[-2:] = [face[..., :-1] for face in faces[-2:]] + masks3 = [orig_masks, *pred_masks] + else: + masks3 = np.repeat(np.expand_dims(orig_masks, axis=0), 3, axis=0) + + retval = [np.array([cv2.addWeighted(img, 1.0, mask, 0.3, 0) + for img, mask in zip(previews, compiled_masks)]) + for previews, compiled_masks in zip(faces, masks3)] logger.debug("masked shapes: %s", [faces.shape for faces in retval]) return retval - @staticmethod - def _overlay_foreground(backgrounds, foregrounds): + @classmethod + def _overlay_foreground(cls, backgrounds, foregrounds): """ Overlay the preview images into the center of the background images Parameters @@ -877,8 +893,8 @@ def _get_headers(self, side, width): logger.debug("header_box.shape: %s", header_box.shape) return header_box - @staticmethod - def _duplicate_headers(headers, columns): + @classmethod + def _duplicate_headers(cls, headers, columns): """ Duplicate headers for the number of columns displayed for each side. Parameters @@ -900,53 +916,33 @@ def _duplicate_headers(headers, columns): return headers -class Timelapse(): +class _Timelapse(): # pylint:disable=too-few-public-methods """ Create a time-lapse preview image. Parameters ---------- model: plugin from :mod:`plugins.train.model` The selected model that will be running this trainer - use_mask: bool - ``True`` if a mask should be displayed otherwise ``False`` coverage_ratio: float Ratio of face to be cropped out of the training image. scaling: float, optional The amount to scale the final preview image by. Default: `1.0` image_count: int The number of preview images to be displayed in the time-lapse - batchers: dict - The dictionary should contain 2 keys ("a" and "b") with the values being the - :class:`Batcher` for each side. + feeder: dict + The :class:`_Feeder` for generating the time-lapse images. """ - def __init__(self, model, use_mask, coverage_ratio, image_count, batchers): - logger.debug("Initializing %s: model: %s, use_mask: %s, coverage_ratio: %s, " - "image_count: %s, batchers: '%s')", self.__class__.__name__, model, - use_mask, coverage_ratio, image_count, batchers) + def __init__(self, model, coverage_ratio, image_count, feeder): + logger.debug("Initializing %s: model: %s, coverage_ratio: %s, image_count: %s, " + "feeder: '%s')", self.__class__.__name__, model, coverage_ratio, + image_count, feeder) self._num_images = image_count - self._samples = Samples(model, use_mask, coverage_ratio) + self._samples = _Samples(model, coverage_ratio) self._model = model - self._batchers = batchers + self._feeder = feeder self._output_file = None logger.debug("Initialized %s", self.__class__.__name__) - def get_sample(self, side, timelapse_kwargs): - """ Compile the time-lapse preview - - Parameters - ---------- - side: {"a" or "b"} - The side that the time-lapse is being generated for - timelapse_kwargs: dict - The keyword arguments for setting up the time-lapse. All values should be full paths - the keys being `input_a`, `input_b`, `output` - """ - logger.debug("Getting time-lapse samples: '%s'", side) - if not self._output_file: - self._setup(**timelapse_kwargs) - self._samples.images[side] = self._batchers[side].compile_timelapse_sample() - logger.debug("Got time-lapse samples: '%s' - %s", side, len(self._samples.images[side])) - def _setup(self, input_a=None, input_b=None, output=None): """ Setup the time-lapse folder locations and the time-lapse feed. @@ -971,13 +967,28 @@ def _setup(self, input_a=None, input_b=None, output=None): batchsize = min(len(images["a"]), len(images["b"]), self._num_images) - for side, image_files in images.items(): - self._batchers[side].set_timelapse_feed(image_files, batchsize) + self._feeder.set_timelapse_feed(images, batchsize) logger.debug("Set up time-lapse") - def output_timelapse(self): - """ Write the created time-lapse to the specified output folder. """ + def output_timelapse(self, timelapse_kwargs): + """ Generate the time-lapse samples and output the created time-lapse to the specified + output folder. + + Parameters + ---------- + timelapse_kwargs: dict: + The keyword arguments for setting up the time-lapse. All values should be full paths + the keys being `input_a`, `input_b`, `output` + """ logger.debug("Ouputting time-lapse") + if not self._output_file: + self._setup(**timelapse_kwargs) + + logger.debug("Getting time-lapse samples") + self._samples.images = self._feeder.compile_timelapse_sample() + logger.debug("Got time-lapse samples: %s", + {side: len(images) for side, images in self._samples.images.items()}) + image = self._samples.show_sample() if image is None: return @@ -987,70 +998,24 @@ def output_timelapse(self): logger.debug("Created time-lapse: '%s'", filename) -class PingPong(): - """ Side switcher for ping-pong training (memory saving feature) - - Parameters - ---------- - model: plugin from :mod:`plugins.train.model` - The selected model that will be running this trainer - sides: list - The sorted sides that are to be trained. Generally ["a", "b"] - - Attributes - ---------- - side: str - The side that is currently being trained - loss: dict - The loss for each side for ping pong training for the current ping pong session - """ - def __init__(self, model, sides): - logger.debug("Initializing %s: (model: '%s')", self.__class__.__name__, model) - self._model = model - self._sides = sides - self.side = sorted(sides)[0] - self.loss = {side: [0] for side in sides} - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def active(self): - """ bool: ``True`` if Ping Pong training is active otherwise ``False``. """ - return self._model.training_opts.get("pingpong", False) - - def switch(self): - """ Switch ping-pong training from one side of the model to the other """ - if not self.active: - return - retval = [side for side in self._sides if side != self.side][0] - logger.info("Switching training to side %s", retval.title()) - self.side = retval - self._reload_model() - - def _reload_model(self): - """ Clear out the model from VRAM and reload for the next side to be trained with ping-pong - training """ - logger.verbose("Ping-Pong re-loading model") - self._model.reset_pingpong() - - -class TrainingAlignments(): +class _TrainingAlignments(): """ Obtain Landmarks and required mask from alignments file. Parameters ---------- - training_opts: dict - The dictionary of model training options (see module doc-string for information about - contents) + model: plugin from :mod:`plugins.train.model` + The model that will be running this trainer image_list: dict The file paths for the images to be trained on for each side. The dictionary should contain 2 keys ("a" and "b") with the values being a list of full paths corresponding to each side. """ - def __init__(self, training_opts, image_list): - logger.debug("Initializing %s: (training_opts: '%s', image counts: %s)", - self.__class__.__name__, training_opts, - {k: len(v) for k, v in image_list.items()}) - self._training_opts = training_opts - self._check_alignments_exist() + def __init__(self, model, image_list): + logger.debug("Initializing %s: (model: %s, image counts: %s)", + self.__class__.__name__, model, {k: len(v) for k, v in image_list.items()}) + self._args = model.command_line_arguments + self._config = model.config + self._training_size = model.state.training_size + self._alignments_paths = self._get_alignments_paths() self._hashes = self._get_image_hashes(image_list) self._detected_faces = self._load_alignments() self._check_all_faces() @@ -1065,6 +1030,35 @@ def landmarks(self): logger.trace(retval) return retval + def _get_alignments_paths(self): + """ Obtain the alignments file paths from the command line arguments passed to the model. + + If the argument does not exist or is empty, then scan the input folder for an alignments + file. + + Returns + ------- + dict + The alignments paths for each of the source and destination faces. Key is the + side, value is the path to the alignments file + + Raises + ------ + FaceswapError + If at least one alignments file does not exist + """ + retval = dict() + for side in ("a", "b"): + alignments_path = getattr(self._args, "alignments_path_{}".format(side)) + if not alignments_path: + image_path = getattr(self._args, "input_{}".format(side)) + alignments_path = os.path.join(image_path, "alignments.fsa") + if not os.path.exists(alignments_path): + raise FaceswapError("Alignments file does not exist: `{}`".format(alignments_path)) + retval[side] = alignments_path + logger.debug("Alignments paths: %s", retval) + return retval + def _transform_landmarks(self, side, detected_faces): """ Transform frame landmarks to their aligned face variant. @@ -1082,7 +1076,7 @@ def _transform_landmarks(self, side, detected_faces): """ landmarks = dict() for face in detected_faces.values(): - face.load_aligned(None, size=self._training_opts["training_size"]) + face.load_aligned(None, size=self._training_size) for filename in self._hash_to_filenames(side, face.hash): landmarks[filename] = face.aligned_landmarks return landmarks @@ -1117,29 +1111,16 @@ def _get_masks(self, side, detected_faces): masks = dict() for fhash, face in detected_faces.items(): - mask = face.mask[self._training_opts["mask_type"]] - mask.set_blur_and_threshold(blur_kernel=self._training_opts["mask_blur_kernel"], - threshold=self._training_opts["mask_threshold"]) + mask = face.mask[self._config["mask_type"]] + mask.set_blur_and_threshold(blur_kernel=self._config["mask_blur_kernel"], + threshold=self._config["mask_threshold"]) for filename in self._hash_to_filenames(side, fhash): masks[filename] = mask return masks - # Pre flight checks - def _check_alignments_exist(self): - """ Ensure the alignments files exist prior to running any longer running tasks. - - Raises - ------ - FaceswapError - If at least one alignments file does not exist - """ - for fullpath in self._training_opts["alignments"].values(): - if not os.path.exists(fullpath): - raise FaceswapError("Alignments file does not exist: `{}`".format(fullpath)) - # Hashes for image folders - @staticmethod - def _get_image_hashes(image_list): + @classmethod + def _get_image_hashes(cls, image_list): """ Return the hashes for all images used for training. Parameters @@ -1179,7 +1160,7 @@ def _load_alignments(self): """ logger.debug("Loading alignments") retval = dict() - for side, fullpath in self._training_opts["alignments"].items(): + for side, fullpath in self._alignments_paths.items(): logger.debug("side: '%s', path: '%s'", side, fullpath) path, filename = os.path.split(fullpath) alignments = Alignments(path, filename=filename) @@ -1257,7 +1238,7 @@ def _validate_face(self, face, filename, idx, side, side_hashes): FaceswapError If the current face doesn't pass validation """ - mask_type = self._training_opts["mask_type"] + mask_type = self._config["mask_type"] if mask_type is not None and "mask" not in face: msg = ("You have selected a Mask Type in your training configuration options but at " "least one face has no mask stored for it.\nYou should generate the required " diff --git a/requirements_amd.txt b/requirements_amd.txt index ecd1dd97bd..1bc3307a95 100644 --- a/requirements_amd.txt +++ b/requirements_amd.txt @@ -1,4 +1,3 @@ -r _requirements_base.txt -tensorflow>=1.12.0,<=1.15.3 -plaidml-keras==0.6.4 -plaidml==0.6.4 +tensorflow>=2.2.0,<2.3.0 +plaidml-keras==0.7.0 diff --git a/requirements_cpu.txt b/requirements_cpu.txt index 7c6097cf0d..971f5b50a7 100644 --- a/requirements_cpu.txt +++ b/requirements_cpu.txt @@ -1,2 +1,2 @@ -r _requirements_base.txt -tensorflow>=1.12.0,<=1.15.3 +tensorflow>=2.2.0,<2.3.0 diff --git a/requirements_nvidia.txt b/requirements_nvidia.txt index eff1ede17b..f695054174 100644 --- a/requirements_nvidia.txt +++ b/requirements_nvidia.txt @@ -1,2 +1,2 @@ -r _requirements_base.txt -tensorflow-gpu>=1.12.0,<=1.15.3 +tensorflow-gpu>=2.2.0,<2.3.0 diff --git a/scripts/convert.py b/scripts/convert.py index 659c036b6e..bddfb90b8f 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -11,8 +11,6 @@ import cv2 import numpy as np from tqdm import tqdm -import tensorflow as tf -from keras.backend.tensorflow_backend import set_session from scripts.fsmedia import Alignments, PostProcess, finalize from lib.serializer import get_serializer @@ -655,13 +653,10 @@ def __init__(self, in_queue, queue_size, arguments): self._faces_count = 0 self._verify_output = False - if arguments.allow_growth: - self._set_tf_allow_growth() - self._model = self._load_model() - self._output_indices = {"face": self._model.largest_face_index, - "mask": self._model.largest_mask_index} - self._predictor = self._model.converter(self._args.swap_model) + self._sizes = self._get_io_sizes() + self._coverage_ratio = self._model.coverage_ratio + self._thread = self._launch_predictor() logger.debug("Initialized %s: (out_queue: %s)", self.__class__.__name__, self._out_queue) @@ -694,28 +689,33 @@ def verify_output(self): @property def coverage_ratio(self): """ float: The coverage ratio that the model was trained at. """ - return self._model.training_opts["coverage_ratio"] + return self._coverage_ratio @property def has_predicted_mask(self): """ bool: ``True`` if the model was trained to learn a mask, otherwise ``False``. """ - return bool(self._model.state.config.get("learn_mask", False)) + return bool(self._model.config.get("learn_mask", False)) @property def output_size(self): """ int: The size in pixels of the Faceswap model output. """ - return self._model.output_shape[0] + return self._sizes["output"] - @property - def _input_size(self): - """ int: The size in pixels of the Faceswap model input. """ - return self._model.input_shape[0] + def _get_io_sizes(self): + """ Obtain the input size and output size of the model. - @property - def _input_mask(self): - """ :class:`numpy.ndarray`: A dummy mask for inputting to the model. """ - mask = np.zeros((1, ) + self._model.state.mask_shapes[0], dtype="float32") - return mask + Returns + ------- + dict + input_size in pixels and output_size in pixels + """ + input_shape = self._model.model.input_shape + input_shape = [input_shape] if not isinstance(input_shape, list) else input_shape + output_shape = self._model.model.output_shape + output_shape = [output_shape] if not isinstance(output_shape, list) else output_shape + retval = dict(input=input_shape[0][1], output=output_shape[-1][1]) + logger.debug(retval) + return retval @staticmethod def _get_batchsize(queue_size): @@ -737,20 +737,6 @@ def _get_batchsize(queue_size): logger.debug("Got batchsize: %s", batchsize) return batchsize - @staticmethod - def _set_tf_allow_growth(): - """ Enables the TensorFlow configuration option "allow_growth". - - TODO Move this temporary fix somewhere more appropriate - """ - # pylint: disable=no-member - logger.debug("Setting Tensorflow 'allow_growth' option") - config = tf.ConfigProto() - config.gpu_options.allow_growth = True - config.gpu_options.visible_device_list = "0" - set_session(tf.Session(config=config)) - logger.debug("Set Tensorflow 'allow_growth' option") - def _load_model(self): """ Load the Faceswap model. @@ -764,8 +750,8 @@ def _load_model(self): if not model_dir: raise FaceswapError("{} does not exist.".format(self._args.model_dir)) trainer = self._get_model_name(model_dir) - gpus = 1 if not hasattr(self._args, "gpus") else self._args.gpus - model = PluginLoader.get_model(trainer)(model_dir, gpus, predict=True) + model = PluginLoader.get_model(trainer)(model_dir, self._args, predict=True) + model.build() logger.debug("Loaded Model") return model @@ -901,15 +887,15 @@ def load_aligned(self, item): logger.trace("Loading aligned faces: '%s'", item["filename"]) for detected_face in item["detected_faces"]: detected_face.load_feed_face(item["image"], - size=self._input_size, - coverage_ratio=self.coverage_ratio, + size=self._sizes["input"], + coverage_ratio=self._coverage_ratio, dtype="float32") - if self._input_size == self.output_size: + if self._sizes["input"] == self._sizes["output"]: detected_face.reference = detected_face.feed else: detected_face.load_reference_face(item["image"], - size=self.output_size, - coverage_ratio=self.coverage_ratio, + size=self._sizes["output"], + coverage_ratio=self._coverage_ratio, dtype="float32") logger.trace("Loaded aligned faces: '%s'", item["filename"]) @@ -951,48 +937,21 @@ def _predict(self, feed_faces, batch_size=None): """ logger.trace("Predicting: Batchsize: %s", len(feed_faces)) feed = [feed_faces] - if self._model.feed_mask: - feed.append(np.repeat(self._input_mask, feed_faces.shape[0], axis=0)) logger.trace("Input shape(s): %s", [item.shape for item in feed]) - predicted = self._predictor(feed, batch_size=batch_size) + predicted = self._model.model.predict(feed, batch_size=batch_size) predicted = predicted if isinstance(predicted, list) else [predicted] logger.trace("Output shape(s): %s", [predict.shape for predict in predicted]) - predicted = self._filter_multi_out(predicted) - - # Compile masks into alpha channel or keep raw faces - predicted = np.concatenate(predicted, axis=-1) if len(predicted) == 2 else predicted[0] - predicted = predicted.astype("float32") + # Only take last output(s) + if predicted[-1].shape[-1] == 1: # Merge mask to alpha channel + predicted = np.concatenate(predicted[-2:], axis=-1).astype("float32") + else: + predicted = predicted[-1].astype("float32") logger.trace("Final shape: %s", predicted.shape) return predicted - def _filter_multi_out(self, predicted): - """ Filter the model output to just the required image. - - Some models have multi-scale outputs, so just make sure we take the largest - output. - - Parameters - ---------- - predicted: :class:`numpy.ndarray` - The predictions retrieved from the Faceswap model. - - Returns - ------- - :class:`numpy.ndarray` - The predictions with any superfluous outputs removed. - """ - if not predicted: - return predicted - face = predicted[self._output_indices["face"]] - mask_idx = self._output_indices["mask"] - mask = predicted[mask_idx] if mask_idx is not None else None - predicted = [face, mask] if mask is not None else [face] - logger.trace("Filtered output shape(s): %s", [predict.shape for predict in predicted]) - return predicted - def _queue_out_frames(self, batch, swapped_faces): """ Compile the batch back to original frames and put to the Out Queue. diff --git a/scripts/extract.py b/scripts/extract.py index 69b96fef34..af66043486 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -58,6 +58,7 @@ def __init__(self, arguments): maskers, configfile=configfile, multiprocess=not self._args.singleprocess, + exclude_gpus=self._args.exclude_gpus, rotate_images=self._args.rotate_images, min_size=self._args.min_size, normalize_method=normalization) diff --git a/scripts/gui.py b/scripts/gui.py index a390f11a4b..6675ba1d55 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -8,7 +8,7 @@ from lib.gui import (TaskBar, CliOptions, CommandNotebook, ConsoleOut, Session, DisplayNotebook, get_images, initialize_images, initialize_config, LastSession, - MainMenuBar, ProcessWrapper, StatusBar) + MainMenuBar, preview_trigger, ProcessWrapper, StatusBar) logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -30,6 +30,7 @@ def __init__(self, debug): self.objects = dict() get_images().delete_preview() + preview_trigger().clear() self.protocol("WM_DELETE_WINDOW", self.close_app) self.build_gui() self._last_session = LastSession(self._config) @@ -162,6 +163,7 @@ def close_app(self, *args): # pylint: disable=unused-argument self._last_session.save() get_images().delete_preview() + preview_trigger().clear() self.quit() logger.debug("Closed GUI") sys.exit(0) diff --git a/scripts/train.py b/scripts/train.py index fecc53dfee..4763b4f864 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -9,20 +9,17 @@ from time import sleep import cv2 -import tensorflow as tf -from keras.backend.tensorflow_backend import set_session from lib.image import read_image from lib.keypress import KBHit from lib.multithreading import MultiThread -from lib.utils import (get_folder, get_image_paths, deprecation_warning, FaceswapError, - _image_extensions) +from lib.utils import (get_folder, get_image_paths, FaceswapError, _image_extensions) from plugins.plugin_loader import PluginLoader logger = logging.getLogger(__name__) # pylint: disable=invalid-name -class Train(): +class Train(): # pylint:disable=too-few-public-methods """ The Faceswap Training Process. The training process is responsible for training a model on a set of source faces and a set of @@ -42,8 +39,11 @@ def __init__(self, arguments): self._args = arguments self._timelapse = self._set_timelapse() self._images = self._get_images() + self._gui_preview_trigger = os.path.join(os.path.realpath(os.path.dirname(sys.argv[0])), + "lib", "gui", ".cache", ".preview_trigger") self._stop = False self._save_now = False + self._refresh_preview = False self._preview_buffer = dict() self._lock = Lock() @@ -59,20 +59,6 @@ def _image_size(self): logger.debug("Training image size: %s", size) return size - @property - def _alignments_paths(self): - """ dict: The alignments paths for each of the source and destination faces. Key is the - side, value is the path to the alignments file """ - alignments_paths = dict() - for side in ("a", "b"): - alignments_path = getattr(self._args, "alignments_path_{}".format(side)) - if not alignments_path: - image_path = getattr(self._args, "input_{}".format(side)) - alignments_path = os.path.join(image_path, "alignments.fsa") - alignments_paths[side] = alignments_path - logger.debug("Alignments paths: %s", alignments_paths) - return alignments_paths - def _set_timelapse(self): """ Set time-lapse paths if requested. @@ -143,21 +129,9 @@ def process(self): """ logger.debug("Starting Training Process") logger.info("Training data directory: %s", self._args.model_dir) - - # TODO Move these args to config and remove these deprecation warnings - if hasattr(self._args, "warp_to_landmarks") and self._args.warp_to_landmarks: - deprecation_warning("`-wl`, ``--warp-to-landmarks``", - additional_info="This option will be available within training " - "config settings (/config/train.ini).") - if hasattr(self._args, "no_augment_color") and self._args.no_augment_color: - deprecation_warning("`-nac`, ``--no-augment-color``", - additional_info="This option will be available within training " - "config settings (/config/train.ini).") thread = self._start_thread() # from lib.queue_manager import queue_manager; queue_manager.debug_monitor(1) - err = self._monitor(thread) - self._end_thread(thread, err) logger.debug("Completed Training Process") @@ -208,16 +182,13 @@ def _training(self): sleep(1) # Let preview instructions flush out to logger logger.debug("Commencing Training") logger.info("Loading data, this may take a while...") - - if self._args.allow_growth: - self._set_tf_allow_growth() model = self._load_model() trainer = self._load_trainer(model) self._run_training_cycle(model, trainer) except KeyboardInterrupt: try: logger.debug("Keyboard Interrupt Caught. Saving Weights and exiting") - model.save_models() + model.save() trainer.clear_tensorboard() except KeyboardInterrupt: logger.info("Saving model weights has been cancelled!") @@ -234,25 +205,13 @@ def _load_model(self): The requested model plugin """ logger.debug("Loading Model") - model_dir = get_folder(self._args.model_dir) - configfile = self._args.configfile if hasattr(self._args, "configfile") else None - augment_color = not self._args.no_augment_color + model_dir = str(get_folder(self._args.model_dir)) model = PluginLoader.get_model(self.trainer_name)( model_dir, - gpus=self._args.gpus, - configfile=configfile, - snapshot_interval=self._args.snapshot_interval, - no_logs=self._args.no_logs, - warp_to_landmarks=self._args.warp_to_landmarks, - augment_color=augment_color, - no_flip=self._args.no_flip, + self._args, training_image_size=self._image_size, - alignments_paths=self._alignments_paths, - preview_scale=self._args.preview_scale, - pingpong=self._args.pingpong, - memory_saving_gradients=self._args.memory_saving_gradients, - optimizer_savings=self._args.optimizer_savings, predict=False) + model.build() logger.debug("Loaded Model") return model @@ -297,28 +256,37 @@ def _run_training_cycle(self, model, trainer): else: display_func = None - for iteration in range(0, self._args.iterations): + for iteration in range(1, self._args.iterations + 1): logger.trace("Training iteration: %s", iteration) - save_iteration = iteration % self._args.save_interval == 0 - viewer = display_func if save_iteration or self._save_now else None + save_iteration = iteration % self._args.save_interval == 0 or iteration == 1 + + if save_iteration or self._save_now or self._refresh_preview: + viewer = display_func + else: + viewer = None timelapse = self._timelapse if save_iteration else None trainer.train_one_step(viewer, timelapse) if self._stop: logger.debug("Stop received. Terminating") break + + if self._refresh_preview and viewer is not None: + if self._args.redirect_gui: + print("\n") + logger.info("[Preview Updated]") + logger.debug("Removing gui trigger file: %s", self._gui_preview_trigger) + os.remove(self._gui_preview_trigger) + self._refresh_preview = False + if save_iteration: - logger.trace("Save Iteration: (iteration: %s", iteration) - if self._args.pingpong: - model.save_models() - trainer.pingpong.switch() - else: - model.save_models() + logger.debug("Save Iteration: (iteration: %s", iteration) + model.save() elif self._save_now: - logger.trace("Save Requested: (iteration: %s", iteration) - model.save_models() + logger.debug("Save Requested: (iteration: %s", iteration) + model.save() self._save_now = False logger.debug("Training cycle complete") - model.save_models() + model.save() trainer.clear_tensorboard() self._stop = True @@ -331,6 +299,7 @@ def _monitor(self, thread): ``True`` if there has been an error in the background thread otherwise ``False`` """ is_preview = self._args.preview + preview_trigger_set = False logger.debug("Launching Monitor") logger.info("===================================================") logger.info(" Starting") @@ -367,8 +336,13 @@ def _monitor(self, thread): logger.debug("Exit requested") break if is_preview and cv_key == ord("s"): + print("\n") logger.info("Save requested") self._save_now = True + if is_preview and cv_key == ord("r"): + print("\n") + logger.info("Refresh preview requested") + self._refresh_preview = True # Console Monitor if keypress.kbhit(): @@ -380,6 +354,18 @@ def _monitor(self, thread): logger.info("Save requested") self._save_now = True + # GUI Preview trigger update monitor + if self._args.redirect_gui: + if not preview_trigger_set and os.path.isfile(self._gui_preview_trigger): + print("\n") + logger.info("Refresh preview requested") + self._refresh_preview = True + preview_trigger_set = True + + if preview_trigger_set and not self._refresh_preview: + logger.debug("Resetting GUI preview trigger") + preview_trigger_set = False + sleep(1) except KeyboardInterrupt: logger.debug("Keyboard Interrupt received") @@ -388,20 +374,6 @@ def _monitor(self, thread): logger.debug("Closed Monitor") return err - @staticmethod - def _set_tf_allow_growth(): - """ Allow TensorFlow to manage VRAM growth. - - Enables the Tensorflow allow_growth option if requested in the command line arguments - """ - # pylint: disable=no-member - logger.debug("Setting Tensorflow 'allow_growth' option") - config = tf.ConfigProto() - config.gpu_options.allow_growth = True - config.gpu_options.visible_device_list = "0" - set_session(tf.Session(config=config)) - logger.debug("Set Tensorflow 'allow_growth' option") - def _show(self, image, name=""): """ Generate the preview and write preview file output. @@ -415,28 +387,28 @@ def _show(self, image, name=""): The name of the image for saving or display purposes. If an empty string is passed then it will automatically be names. Default: "" """ - logger.trace("Updating preview: (name: %s)", name) + logger.debug("Updating preview: (name: %s)", name) try: scriptpath = os.path.realpath(os.path.dirname(sys.argv[0])) if self._args.write_image: - logger.trace("Saving preview to disk") + logger.debug("Saving preview to disk") img = "training_preview.jpg" imgfile = os.path.join(scriptpath, img) cv2.imwrite(imgfile, image) # pylint: disable=no-member - logger.trace("Saved preview to: '%s'", img) + logger.debug("Saved preview to: '%s'", img) if self._args.redirect_gui: - logger.trace("Generating preview for GUI") + logger.debug("Generating preview for GUI") img = ".gui_training_preview.jpg" imgfile = os.path.join(scriptpath, "lib", "gui", ".cache", "preview", img) cv2.imwrite(imgfile, image) # pylint: disable=no-member - logger.trace("Generated preview for GUI: '%s'", img) + logger.debug("Generated preview for GUI: '%s'", img) if self._args.preview: - logger.trace("Generating preview for display: '%s'", name) + logger.debug("Generating preview for display: '%s'", name) with self._lock: self._preview_buffer[name] = image - logger.trace("Generated preview for display: '%s'", name) + logger.debug("Generated preview for display: '%s'", name) except Exception as err: logging.error("could not preview sample") raise err - logger.trace("Updated preview: (name: %s)", name) + logger.debug("Updated preview: (name: %s)", name) diff --git a/setup.py b/setup.py index 0a5320ef9f..62f43e80ea 100755 --- a/setup.py +++ b/setup.py @@ -16,8 +16,7 @@ INSTALL_FAILED = False # Revisions of tensorflow-gpu and cuda/cudnn requirements -TENSORFLOW_REQUIREMENTS = {"==1.12.0": ["9.0", "7.2"], - ">=1.13.1,<1.16": ["10.0", "7.4"]} # TF 2.0 Not currently supported +TENSORFLOW_REQUIREMENTS = {">=2.2.0,<2.3.0": ["10.1", "7.6"]} # Mapping of Python packages to their conda names if different from pypi or in non-default channel CONDA_MAPPING = { # "opencv-python": ("opencv", "conda-forge"), # Periodic issues with conda-forge opencv @@ -167,10 +166,10 @@ def check_python(self): self.output.info("Installed Python: {0} {1}".format(self.py_version[0], self.py_version[1])) if not (self.py_version[0].split(".")[0] == "3" - and self.py_version[0].split(".")[1] in ("3", "4", "5", "6", "7") + and self.py_version[0].split(".")[1] in ("6", "7", "8") and self.py_version[1] == "64bit") and not self.updater: - self.output.error("Please run this script with Python version 3.3, 3.4, 3.5, 3.6 or " - "3.7 64bit and try again.") + self.output.error("Please run this script with Python version 3.6, 3.7 or 3.8 " + "64bit and try again.") sys.exit(1) def output_runtime_info(self): @@ -186,7 +185,7 @@ def check_pip(self): if self.updater: return try: - import pip # noqa pylint:disable=unused-import + import pip # noqa pylint:disable=unused-import,import-outside-toplevel except ImportError: self.output.error("Import pip failed. Please Install python3-pip and try again") sys.exit(1) @@ -202,7 +201,7 @@ def upgrade_pip(self): pipexe.append("--user") pipexe.append("pip") run(pipexe) - import pip + import pip # pylint:disable=import-outside-toplevel pip_version = pip.__version__ self.output.info("Installed pip: {}".format(pip_version)) @@ -256,7 +255,7 @@ def update_tf_dep(self): return self.output.warning( - "The minimum Tensorflow requirement is 1.12. \n" + "The minimum Tensorflow requirement is 2.2 \n" "Tensorflow currently has no official prebuild for your CUDA, cuDNN " "combination.\nEither install a combination that Tensorflow supports or " "build and install your own tensorflow-gpu.\r\n" @@ -461,7 +460,7 @@ def cuda_check_linux(self): break if not chk: self.output.error("CUDA not found. Install and try again.\n" - "Recommended version: CUDA 9.0 cuDNN 7.1.3\n" + "Recommended version: CUDA 10.1 cuDNN 7.6\n" "CUDA: https://developer.nvidia.com/cuda-downloads\n" "cuDNN: https://developer.nvidia.com/rdp/cudnn-download") return @@ -644,6 +643,8 @@ def install_python_packages(self): verbose = pkg.startswith("tensorflow") or self.env.updater if self.conda_installer(pkg, verbose=verbose, channel=channel, conda_only=False): continue + if pkg.startswith("tensorflow-gpu"): + self._tensorflow_dependency_install() self.pip_installer(pkg) def install_conda_packages(self): @@ -687,7 +688,7 @@ def pip_installer(self, package): pipexe = [sys.executable, "-m", "pip"] # hide info/warning and fix cache hang pipexe.extend(["install", "--no-cache-dir"]) - if not self.env.updater: + if not self.env.updater and not package.startswith("tensorflow"): pipexe.append("-qq") # install as user to solve perm restriction if not self.env.is_admin and not self.env.is_virtualenv: @@ -701,6 +702,25 @@ def pip_installer(self, package): self.output.warning("Couldn't install {} with pip. " "Please install this package manually".format(package)) + def _tensorflow_dependency_install(self): + """ Install the Cuda/cuDNN dependencies from Conda when tensorflow is not available + in Conda """ + # TODO This will need to be more robust if/when we accept multiple Tensorflow Versions + versions = list(TENSORFLOW_REQUIREMENTS.values())[-1] + condaexe = ["conda", "search"] + pkgs = ["cudatoolkit", "cudnn"] + for pkg in pkgs: + chk = Popen(condaexe + [pkg], shell=True, stdout=PIPE) + available = [line.split() + for line in chk.communicate()[0].decode(self.env.encoding).splitlines() + if line.startswith(pkg)] + compatible = [req for req in available + if (pkg == "cudatoolkit" and req[1].startswith(versions[0])) + or (pkg == "cudnn" and versions[0] in req[2] + and req[1].startswith(versions[1]))] + candidate = "==".join(sorted(compatible, key=lambda x: x[1])[-1][:2]) + self.conda_installer(candidate, verbose=True, conda_only=True) + class Tips(): """ Display installation Tips """ diff --git a/tests/__init__.py b/tests/__init__.py index e69de29bb2..e0783c0b38 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +""" Use custom Importer for importing Keras for tests """ +import sys +from lib.utils import KerasFinder + + +sys.meta_path.insert(0, KerasFinder()) diff --git a/tests/lib/model/initializers_test.py b/tests/lib/model/initializers_test.py index 5d6e85af25..01c44b4b79 100644 --- a/tests/lib/model/initializers_test.py +++ b/tests/lib/model/initializers_test.py @@ -4,15 +4,14 @@ Adapted from Keras tests. """ -from keras import initializers as k_initializers from keras import backend as K +from keras import initializers as k_initializers import pytest import numpy as np from lib.model import initializers from lib.utils import get_backend - CONV_SHAPE = (3, 3, 256, 2048) CONV_ID = get_backend().upper() @@ -41,7 +40,7 @@ def test_icnr(tensor_shape): tensor_shape: tuple The shape of the tensor to feed to the initializer """ - fan_in, _ = k_initializers._compute_fans(tensor_shape) # pylint:disable=protected-access + fan_in, _ = initializers.compute_fans(tensor_shape) std = np.sqrt(2. / fan_in) _runner(initializers.ICNR(initializer=k_initializers.he_uniform(), scale=2), tensor_shape, target_mean=0, target_std=std) @@ -56,7 +55,7 @@ def test_convolution_aware(tensor_shape): tensor_shape: tuple The shape of the tensor to feed to the initializer """ - fan_in, _ = k_initializers._compute_fans(tensor_shape) # pylint:disable=protected-access + fan_in, _ = initializers.compute_fans(tensor_shape) std = np.sqrt(2. / fan_in) - _runner(initializers.ConvolutionAware(seed=123, init=True), tensor_shape, + _runner(initializers.ConvolutionAware(seed=123), tensor_shape, target_mean=0, target_std=std) diff --git a/tests/lib/model/layers_test.py b/tests/lib/model/layers_test.py index 9eb4463f83..53362a0253 100644 --- a/tests/lib/model/layers_test.py +++ b/tests/lib/model/layers_test.py @@ -8,13 +8,12 @@ import pytest import numpy as np from keras import Input, Model, backend as K -from keras.utils.generic_utils import has_arg from numpy.testing import assert_allclose -from lib.model import layers +from lib.model import layers, normalization from lib.utils import get_backend - +from tests.utils import has_arg CONV_SHAPE = (3, 3, 256, 2048) CONV_ID = get_backend().upper() @@ -52,7 +51,7 @@ def layer_test(layer_cls, kwargs={}, input_shape=None, input_dtype=None, weights = layer.get_weights() layer.set_weights(weights) - if isinstance(layer, layers.ReflectionPadding2D): + if isinstance(layer, (layers.ReflectionPadding2D, normalization.InstanceNormalization)): layer.build(input_shape) expected_output_shape = layer.compute_output_shape(input_shape) @@ -110,7 +109,7 @@ def test_pixel_shuffler(dummy): # pylint:disable=unused-argument @pytest.mark.skipif(get_backend() == "amd", reason="amd does not support this layer") @pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) def test_subpixel_upscaling(dummy): # pylint:disable=unused-argument - """ Sub Pixel Upscaling layer test """ + """ Sub Pixel up-scaling layer test """ layer_test(layers.SubPixelUpscaling, input_shape=(2, 4, 4, 1024)) diff --git a/tests/lib/model/losses_test.py b/tests/lib/model/losses_test.py index 0276a1b193..5765713f75 100644 --- a/tests/lib/model/losses_test.py +++ b/tests/lib/model/losses_test.py @@ -9,6 +9,7 @@ from numpy.testing import assert_allclose from keras import backend as K +from keras import losses as k_losses from keras.layers import Conv2D from keras.models import Sequential from keras.optimizers import Adam @@ -17,27 +18,60 @@ from lib.utils import get_backend -_PARAMS = [(losses.gradient_loss, (1, 5, 6, 7), (1, 5, 6)), - (losses.generalized_loss, (5, 6, 7), (5, 6)), +_PARAMS = [(losses.GeneralizedLoss(), (2, 16, 16)), + (losses.GradientLoss(), (2, 16, 16)), # TODO Make sure these output dimensions are correct - (losses.l_inf_norm, (1, 5, 6, 7), (1, 1, 1)), + (losses.GMSDLoss(), (2, 1, 1)), # TODO Make sure these output dimensions are correct - (losses.gmsd_loss, (1, 5, 6, 7), (1, 1, 1))] -_IDS = ["gradient_loss", "generalized_loss", "l_inf_norm", "gmsd_loss"] + (losses.LInfNorm(), (2, 1, 1))] +_IDS = ["GeneralizedLoss", "GradientLoss", "GMSDLoss", "LInfNorm"] _IDS = ["{}[{}]".format(loss, get_backend().upper()) for loss in _IDS] -@pytest.mark.parametrize(["loss_func", "input_shape", "output_shape"], _PARAMS, ids=_IDS) -def test_objective_shapes(loss_func, input_shape, output_shape): +@pytest.mark.parametrize(["loss_func", "output_shape"], _PARAMS, ids=_IDS) +def test_loss_output(loss_func, output_shape): """ Basic shape tests for loss functions. """ - y_a = K.variable(np.random.random(input_shape)) - y_b = K.variable(np.random.random(input_shape)) + if get_backend() == "amd" and isinstance(loss_func, losses.GMSDLoss): + pytest.skip("GMSD Loss is not currently compatible with PlaidML") + y_a = K.variable(np.random.random((2, 16, 16, 3))) + y_b = K.variable(np.random.random((2, 16, 16, 3))) objective_output = loss_func(y_a, y_b) - assert K.eval(objective_output).shape == output_shape + if get_backend() == "amd": + assert K.eval(objective_output).shape == output_shape + else: + output = objective_output.numpy() + assert output.dtype == "float32" and not np.isnan(output) + + +_PLPARAMS = _PARAMS + [(k_losses.mean_absolute_error, (2, 16, 16)), + (k_losses.mean_squared_error, (2, 16, 16)), + (k_losses.logcosh, (2, 16, 16)), + (losses.DSSIMObjective(), ())] +_PLIDS = ["GeneralizedLoss", "GradientLoss", "GMSDLoss", "LInfNorm", "mae", "mse", "logcosh", + "DSSIMObjective"] +_PLIDS = ["{}[{}]".format(loss, get_backend().upper()) for loss in _PLIDS] + + +@pytest.mark.parametrize(["loss_func", "output_shape"], _PLPARAMS, ids=_PLIDS) +def test_penalized_loss(loss_func, output_shape): + """ Test penalized loss wrapper works as expected """ + if get_backend() == "amd": + if isinstance(loss_func, losses.GMSDLoss): + pytest.skip("GMSD Loss is not currently compatible with PlaidML") + if hasattr(loss_func, "__name__") and loss_func.__name__ == "logcosh": + pytest.skip("LogCosh Loss is not currently compatible with PlaidML") + y_a = K.variable(np.random.random((2, 16, 16, 4))) + y_b = K.variable(np.random.random((2, 16, 16, 3))) + p_loss = losses.PenalizedLoss(loss_func) + output = p_loss(y_a, y_b) + if get_backend() == "amd": + assert K.eval(output).shape == output_shape + else: + output = output.numpy() + assert output.dtype == "float32" and not np.isnan(output) @pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) -@pytest.mark.xfail(get_backend() == "amd", reason="plaidML generates NaNs") def test_dssim_channels_last(dummy): # pylint:disable=unused-argument """ Basic test for DSSIM Loss """ prev_data = K.image_data_format() diff --git a/tests/lib/model/nn_blocks_test.py b/tests/lib/model/nn_blocks_test.py index ebe8256f8c..bb9676c63b 100644 --- a/tests/lib/model/nn_blocks_test.py +++ b/tests/lib/model/nn_blocks_test.py @@ -12,14 +12,9 @@ from keras import Input, Model, backend as K from numpy.testing import assert_allclose -from lib.model.nn_blocks import NNBlocks +from lib.model import nn_blocks from lib.utils import get_backend -_PARAMS = ["use_icnr_init", "use_convaware_init", "use_reflect_padding"] -_VALUES = list(product([True, False], repeat=len(_PARAMS))) -_IDS = ["{}[{}]".format("|".join([_PARAMS[idx] for idx, b in enumerate(v) if b]), - get_backend().upper()) for v in _VALUES] - def block_test(layer_func, kwargs={}, input_shape=None): """Test routine for faceswap neural network blocks. @@ -61,16 +56,23 @@ def block_test(layer_func, kwargs={}, input_shape=None): return actual_output +_PARAMS = ["use_icnr_init", "use_convaware_init", "use_reflect_padding"] +_VALUES = list(product([True, False], repeat=len(_PARAMS))) +_IDS = ["{}[{}]".format("|".join([_PARAMS[idx] for idx, b in enumerate(v) if b]), + get_backend().upper()) for v in _VALUES] + + @pytest.mark.parametrize(_PARAMS, _VALUES, ids=_IDS) def test_blocks(use_icnr_init, use_convaware_init, use_reflect_padding): """ Test for all blocks contained within the NNBlocks Class """ - cls_ = NNBlocks(use_icnr_init=use_icnr_init, - use_convaware_init=use_convaware_init, - use_reflect_padding=use_reflect_padding) - block_test(cls_.conv2d, input_shape=(2, 5, 5, 128), kwargs=dict(filters=1024, kernel_size=3)) - block_test(cls_.conv, input_shape=(2, 8, 8, 32), kwargs=dict(filters=64)) - block_test(cls_.conv_sep, input_shape=(2, 8, 8, 32), kwargs=dict(filters=64)) - block_test(cls_.upscale, input_shape=(2, 4, 4, 128), kwargs=dict(filters=64)) - block_test(cls_.res_block, input_shape=(2, 2, 2, 64), kwargs=dict(filters=64)) - block_test(cls_.upscale2x, input_shape=(2, 4, 4, 128), kwargs=dict(filters=64, fast=False)) - block_test(cls_.upscale2x, input_shape=(2, 4, 4, 128), kwargs=dict(filters=64, fast=True)) + config = dict(icnr_init=use_icnr_init, + conv_aware_init=use_convaware_init, + reflect_padding=use_reflect_padding) + nn_blocks.set_config(config) + block_test(nn_blocks.Conv2DOutput(64, 3), input_shape=(2, 8, 8, 32)) + block_test(nn_blocks.Conv2DBlock(64), input_shape=(2, 8, 8, 32)) + block_test(nn_blocks.SeparableConv2DBlock(64), input_shape=(2, 8, 8, 32)) + block_test(nn_blocks.UpscaleBlock(64), input_shape=(2, 4, 4, 128)) + block_test(nn_blocks.Upscale2xBlock(64, fast=True), input_shape=(2, 4, 4, 128)) + block_test(nn_blocks.Upscale2xBlock(64, fast=False), input_shape=(2, 4, 4, 128)) + block_test(nn_blocks.ResidualBlock(64), input_shape=(2, 4, 4, 64)) diff --git a/tests/lib/model/normalization_test.py b/tests/lib/model/normalization_test.py index 49715b0576..53bf34d92d 100644 --- a/tests/lib/model/normalization_test.py +++ b/tests/lib/model/normalization_test.py @@ -10,7 +10,7 @@ from lib.model import normalization from lib.utils import get_backend -from .layers_test import layer_test +from tests.lib.model.layers_test import layer_test @pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) diff --git a/tests/lib/model/optimizers_test.py b/tests/lib/model/optimizers_test.py index 09c446d953..6a9642117a 100644 --- a/tests/lib/model/optimizers_test.py +++ b/tests/lib/model/optimizers_test.py @@ -8,23 +8,22 @@ from keras import optimizers as k_optimizers from keras.layers import Dense, Activation from keras.models import Sequential -from keras.utils import test_utils -from keras.utils.np_utils import to_categorical import numpy as np from numpy.testing import assert_allclose -from lib.model import optimizers from lib.utils import get_backend +from tests.utils import generate_test_data, to_categorical + def get_test_data(): - """ Obtain radomized test data for training """ + """ Obtain randomized test data for training """ np.random.seed(1337) - (x_train, y_train), _ = test_utils.get_test_data(num_train=1000, - num_test=200, - input_shape=(10,), - classification=True, - num_classes=2) + (x_train, y_train), _ = generate_test_data(num_train=1000, + num_test=200, + input_shape=(10,), + classification=True, + num_classes=2) y_train = to_categorical(y_train) return x_train, y_train @@ -34,44 +33,46 @@ def _test_optimizer(optimizer, target=0.75): model = Sequential() model.add(Dense(10, input_shape=(x_train.shape[1],))) - model.add(Activation('relu')) + model.add(Activation("relu")) model.add(Dense(y_train.shape[1])) - model.add(Activation('softmax')) - model.compile(loss='categorical_crossentropy', + model.add(Activation("softmax")) + model.compile(loss="categorical_crossentropy", optimizer=optimizer, - metrics=['accuracy']) + metrics=["accuracy"]) history = model.fit(x_train, y_train, epochs=2, batch_size=16, verbose=0) - # TODO PlaidML fails this test - assert history.history['acc'][-1] >= target + accuracy = "acc" if get_backend() == "amd" else "accuracy" + assert history.history[accuracy][-1] >= target config = k_optimizers.serialize(optimizer) optim = k_optimizers.deserialize(config) new_config = k_optimizers.serialize(optim) - new_config['class_name'] = new_config['class_name'].lower() + new_config["class_name"] = new_config["class_name"].lower() assert config == new_config # Test constraints. + if get_backend() == "amd": + # NB: PlaidML does not support constraints, so this test skipped for AMD backends + return model = Sequential() dense = Dense(10, input_shape=(x_train.shape[1],), kernel_constraint=lambda x: 0. * x + 1., bias_constraint=lambda x: 0. * x + 2.,) model.add(dense) - model.add(Activation('relu')) + model.add(Activation("relu")) model.add(Dense(y_train.shape[1])) - model.add(Activation('softmax')) - model.compile(loss='categorical_crossentropy', + model.add(Activation("softmax")) + model.compile(loss="categorical_crossentropy", optimizer=optimizer, - metrics=['accuracy']) + metrics=["accuracy"]) model.train_on_batch(x_train[:10], y_train[:10]) kernel, bias = dense.get_weights() assert_allclose(kernel, 1.) assert_allclose(bias, 2.) -@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) -@pytest.mark.xfail(get_backend() == "amd", reason="plaidML fails the standard accuracy test") +@pytest.mark.parametrize("dummy", [None], ids=[get_backend().upper()]) def test_adam(dummy): # pylint:disable=unused-argument - """ Test for custom adam optimizer """ - _test_optimizer(optimizers.Adam()) - _test_optimizer(optimizers.Adam(decay=1e-3)) + """ Test for custom Adam optimizer """ + _test_optimizer(k_optimizers.Adam(), target=0.6) + _test_optimizer(k_optimizers.Adam(decay=1e-3), target=0.6) diff --git a/tests/startup_test.py b/tests/startup_test.py index 4389fcacaa..786907f199 100644 --- a/tests/startup_test.py +++ b/tests/startup_test.py @@ -4,15 +4,26 @@ import inspect import pytest + +import keras from keras import backend as K from lib.utils import get_backend +_BACKEND = get_backend() + @pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) def test_backend(dummy): # pylint:disable=unused-argument """ Sanity check to ensure that Keras backend is returning the correct object type. """ - backend = get_backend() test_var = K.variable((1, 1, 4, 4)) lib = inspect.getmodule(test_var).__name__.split(".")[0] - assert (backend == "cpu" and lib == "tensorflow") or (backend == "amd" and lib == "plaidml") + assert (_BACKEND == "cpu" and lib == "tensorflow") or (_BACKEND == "amd" and lib == "plaidml") + + +@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) +def test_keras(dummy): # pylint:disable=unused-argument + """ Sanity check to ensure that tensorflow keras is being used for CPU and standard + keras for AMD. """ + assert ((_BACKEND == "cpu" and keras.__version__.endswith("-tf")) or + (_BACKEND == "amd" and not keras.__version__.endswith("-tf"))) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000000..248ec0a25b --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +""" Utils imported from Keras as their location changes between Tensorflow Keras and standard +Keras. Also ensures testing consistency """ +import inspect +import sys + +import numpy as np + + +def generate_test_data(num_train=1000, num_test=500, input_shape=(10,), + output_shape=(2,), + classification=True, num_classes=2): + """Generates test data to train a model on. classification=True overrides output_shape (i.e. + output_shape is set to (1,)) and the output consists in integers in [0, num_classes-1]. + + Otherwise: float output with shape output_shape. + """ + samples = num_train + num_test + if classification: + var_y = np.random.randint(0, num_classes, size=(samples,)) + var_x = np.zeros((samples,) + input_shape, dtype=np.float32) + for i in range(samples): + var_x[i] = np.random.normal(loc=var_y[i], scale=0.7, size=input_shape) + else: + y_loc = np.random.random((samples,)) + var_x = np.zeros((samples,) + input_shape, dtype=np.float32) + var_y = np.zeros((samples,) + output_shape, dtype=np.float32) + for i in range(samples): + var_x[i] = np.random.normal(loc=y_loc[i], scale=0.7, size=input_shape) + var_y[i] = np.random.normal(loc=y_loc[i], scale=0.7, size=output_shape) + + return (var_x[:num_train], var_y[:num_train]), (var_x[num_train:], var_y[num_train:]) + + +def to_categorical(var_y, num_classes=None, dtype='float32'): + """Converts a class vector (integers) to binary class matrix. + E.g. for use with categorical_crossentropy. + + Parameters + ---------- + var_y: int + Class vector to be converted into a matrix (integers from 0 to num_classes). + num_classes: int + Total number of classes. + dtype: str + The data type expected by the input, as a string (`float32`, `float64`, `int32`...) + + Returns + ------- + tensor + A binary matrix representation of the input. The classes axis is placed last. + + Example + ------- + >>> # Consider an array of 5 labels out of a set of 3 classes {0, 1, 2}: + >>> labels + >>> array([0, 2, 1, 2, 0]) + >>> # `to_categorical` converts this into a matrix with as many columns as there are classes. + >>> # The number of rows stays the same. + >>> to_categorical(labels) + >>> array([[ 1., 0., 0.], + >>> [ 0., 0., 1.], + >>> [ 0., 1., 0.], + >>> [ 0., 0., 1.], + >>> [ 1., 0., 0.]], dtype=float32) + """ + var_y = np.array(var_y, dtype='int') + input_shape = var_y.shape + if input_shape and input_shape[-1] == 1 and len(input_shape) > 1: + input_shape = tuple(input_shape[:-1]) + var_y = var_y.ravel() + if not num_classes: + num_classes = np.max(var_y) + 1 + var_n = var_y.shape[0] + categorical = np.zeros((var_n, num_classes), dtype=dtype) + categorical[np.arange(var_n), var_y] = 1 + output_shape = input_shape + (num_classes,) + categorical = np.reshape(categorical, output_shape) + return categorical + + +def has_arg(func, name, accept_all=False): + """Checks if a callable accepts a given keyword argument. + + For Python 2, checks if there is an argument with the given name. + For Python 3, checks if there is an argument with the given name, and also whether this + argument can be called with a keyword (i.e. if it is not a positional-only argument). + + Parameters + ---------- + func: object + Callable to inspect. + name: str + Check if `func` can be called with `name` as a keyword argument. + accept_all: bool, optional + What to return if there is no parameter called `name` but the function accepts a + `**kwargs` argument. Default: ``False`` + + Returns + ------- + bool + Whether `func` accepts a `name` keyword argument. + """ + if sys.version_info < (3,): + arg_spec = inspect.getargspec(func) + if accept_all and arg_spec.keywords is not None: + return True + return (name in arg_spec.args) + elif sys.version_info < (3, 3): + arg_spec = inspect.getfullargspec(func) + if accept_all and arg_spec.varkw is not None: + return True + return (name in arg_spec.args or + name in arg_spec.kwonlyargs) + else: + signature = inspect.signature(func) + parameter = signature.parameters.get(name) + if parameter is None: + if accept_all: + for param in signature.parameters.values(): + if param.kind == inspect.Parameter.VAR_KEYWORD: + return True + return False + return (parameter.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY)) diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index 8fd277df73..8100c7b20f 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -4,9 +4,8 @@ import logging from .media import AlignmentData -from .jobs import (Check, Dfl, Draw, Extract, Fix, Merge, # noqa pylint: disable=unused-import +from .jobs import (Check, Dfl, Draw, Extract, Merge, # noqa pylint: disable=unused-import Rename, RemoveAlignments, Sort, Spatial, UpdateHashes) -from .jobs_manual import Manual # noqa pylint: disable=unused-import logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -58,9 +57,6 @@ def process(self): Launches the selected alignments job. """ - if self.args.job == "manual": - logger.warning("The 'manual' job is deprecated and will be removed from a future " - "update. Please use the new 'manual' tool.") if self.args.job == "update-hashes": job = UpdateHashes elif self.args.job.startswith("remove-"): diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py index 60dd6f9291..793ce0e991 100644 --- a/tools/alignments/cli.py +++ b/tools/alignments/cli.py @@ -25,138 +25,125 @@ def get_argument_list(self): output_opts = " Use the output option (-o) to process results." align_eyes = " Can optionally use the align-eyes switch (-ae)." argument_list = list() - argument_list.append({ - "opts": ("-j", "--job"), - "action": Radio, - "type": str, - "choices": ("dfl", "draw", "extract", "fix", "manual", "merge", "missing-alignments", - "missing-frames", "leftover-faces", "multi-faces", "no-faces", - "remove-faces", "remove-frames", "rename", "sort", "spatial", - "update-hashes"), - "required": True, - "help": "R|Choose which action you want to perform. " - "NB: All actions require an alignments file (-a) to be passed in." - "\nL|'dfl': Create an alignments file from faces extracted from DeepFaceLab. " - "Specify 'dfl' as the 'alignments file' entry and the folder containing the " - "dfl faces as the 'faces folder' ('-a dfl -fc '" - "\nL|'draw': Draw landmarks on frames in the selected folder/video. A " - "subfolder will be created within the frames folder to hold the output." + - frames_dir + - "\nL|'extract': Re-extract faces from the source frames/video based on " - "alignment data. This is a lot quicker than re-detecting faces. Can pass in " - "the '-een' (--extract-every-n) parameter to only extract every nth frame." + - frames_and_faces_dir + align_eyes + - # TODO - Remove the fix job after a period of time. Implemented 2019/12/07 - "\nL|'fix': There was a bug when extracting from video which would shift all " - "the faces out by 1 frame. This was a shortlived bug, but this job will fix " - "alignments files that have this issue. NB: Only run this on alignments files " - "that you know need fixing." - "\nL|'manual': Manually view and edit landmarks." + frames_dir + - "\nL|'merge': Merge multiple alignment files into one. Specify a space " - "separated list of alignments files with the -a flag. Optionally specify a " - "faces (-fc) folder to filter the final alignments file to only those faces " - "that appear within the provided folder." - "\nL|'missing-alignments': Identify frames that do not exist in the " - "alignments file." + output_opts + frames_dir + - "\nL|'missing-frames': Identify frames in the alignments file that do not " - "appear within the frames folder/video." + output_opts + frames_dir + - "\nL|'leftover-faces': Identify faces in the faces folder that do not exist " - "in the alignments file." + output_opts + faces_dir + - "\nL|'multi-faces': Identify where multiple faces exist within the alignments " - "file." + output_opts + frames_or_faces_dir + - "\nL|'no-faces': Identify frames that exist within the alignment file but no " - "faces were detected." + output_opts + frames_dir + - "\nL|'remove-faces': Remove deleted faces from an alignments file. The " - "original alignments file will be backed up." + faces_dir + - "\nL|'remove-frames': Remove deleted frames from an alignments file. The " - "original alignments file will be backed up." + frames_dir + - "\nL|'rename' - Rename faces to correspond with their parent frame and " - "position index in the alignments file (i.e. how they are named after running " - "extract)." + faces_dir + - "\nL|'sort': Re-index the alignments from left to right. For alignments " - "with multiple faces this will ensure that the left-most face is at index 0 " - "Optionally pass in a faces folder (-fc) to also rename extracted faces." - "\nL|'spatial': Perform spatial and temporal filtering to smooth alignments " - "(EXPERIMENTAL!)" - "\nL|'update-hashes': Recalculate the face hashes. Only use this if you have " - "altered the extracted faces (e.g. colour adjust). The files MUST be " - "named '_face index' (i.e. how they are named after running " - "extract)." + faces_dir}) - argument_list.append({"opts": ("-a", "--alignments_file"), - "action": FilesFullPaths, - "dest": "alignments_file", - "nargs": "+", - "group": "data", - "required": True, - "filetypes": "alignments", - "help": "Full path to the alignments file to be processed. If " - "merging alignments, then multiple files can be selected, " - "space separated"}) - argument_list.append({"opts": ("-fc", "-faces_folder"), - "action": DirFullPaths, - "dest": "faces_dir", - "group": "data", - "help": "Directory containing extracted faces."}) - argument_list.append({"opts": ("-fr", "-frames_folder"), - "action": DirOrFileFullPaths, - "dest": "frames_dir", - "filetypes": "video", - "group": "data", - "help": "Directory containing source frames " - "that faces were extracted from."}) - argument_list.append({ - "opts": ("-o", "--output"), - "action": Radio, - "type": str, - "choices": ("console", "file", "move"), - "group": "processing", - "default": "console", - "help": "R|How to output discovered items ('faces' and 'frames' only):" - "\nL|'console': Print the list of frames to the screen. (DEFAULT)" - "\nL|'file': Output the list of frames to a text file (stored within the " - " source directory)." - "\nL|'move': Move the discovered items to a sub-folder within the source " - "directory."}) - argument_list.append({"opts": ("-een", "--extract-every-n"), - "type": int, - "action": Slider, - "dest": "extract_every_n", - "min_max": (1, 100), - "default": 1, - "rounding": 1, - "group": "extract", - "help": "[Extract only] Extract every 'nth' frame. This option will " - "skip frames when extracting faces. For example a value of " - "1 will extract faces from every frame, a value of 10 will " - "extract faces from every 10th frame."}) - argument_list.append({"opts": ("-sz", "--size"), - "type": int, - "action": Slider, - "min_max": (128, 512), - "default": 256, - "group": "extract", - "rounding": 64, - "help": "[Extract only] The output size of extracted faces."}) - argument_list.append({"opts": ("-ae", "--align-eyes"), - "action": "store_true", - "dest": "align_eyes", - "group": "extract", - "default": False, - "help": "[Extract only] Perform extra alignment to ensure " - "left/right eyes are at the same height."}) - argument_list.append({"opts": ("-l", "--large"), - "action": "store_true", - "group": "extract", - "default": False, - "help": "[Extract only] Only extract faces that have not been " - "upscaled to the required size (`-sz`, `--size). Useful " - "for excluding low-res images from a training set."}) - argument_list.append({"opts": ("-dm", "--disable-monitor"), - "action": "store_true", - "group": "manual tool", - "dest": "disable_monitor", - "default": False, - "help": "Enable this option if manual " - "alignments window is closing " - "instantly. (Manual only)"}) + argument_list.append(dict( + opts=("-j", "--job"), + action=Radio, + type=str, + choices=("dfl", "draw", "extract", "merge", "missing-alignments", "missing-frames", + "leftover-faces", "multi-faces", "no-faces", "remove-faces", "remove-frames", + "rename", "sort", "spatial", "update-hashes"), + required=True, + help="R|Choose which action you want to perform. NB: All actions require an " + "alignments file (-a) to be passed in." + "\nL|'dfl': Create an alignments file from faces extracted from DeepFaceLab. " + "Specify 'dfl' as the 'alignments file' entry and the folder containing the dfl " + "faces as the 'faces folder' ('-a dfl -fc ')" + "\nL|'draw': Draw landmarks on frames in the selected folder/video. A subfolder " + "will be created within the frames folder to hold the output.{0}" + "\nL|'extract': Re-extract faces from the source frames/video based on alignment " + "data. This is a lot quicker than re-detecting faces. Can pass in the '-een' " + "(--extract-every-n) parameter to only extract every nth frame.{1}{2}" + "\nL|'merge': Merge multiple alignment files into one. Specify a space separated " + "list of alignments files with the -a flag. Optionally specify a faces (-fc) " + "folder to filter the final alignments file to only those faces that appear " + "within the provided folder." + "\nL|'missing-alignments': Identify frames that do not exist in the alignments " + "file.{3}{0}" + "\nL|'missing-frames': Identify frames in the alignments file that do not appear " + "within the frames folder/video.{3}{0}" + "\nL|'leftover-faces': Identify faces in the faces folder that do not exist in " + "the alignments file.{3}{4}" + "\nL|'multi-faces': Identify where multiple faces exist within the alignments " + "file.{3}{5}" + "\nL|'no-faces': Identify frames that exist within the alignment file but no " + "faces were detected.{3}{0}" + "\nL|'remove-faces': Remove deleted faces from an alignments file. The original " + "alignments file will be backed up.{4}" + "\nL|'remove-frames': Remove deleted frames from an alignments file. The " + "original alignments file will be backed up.{0}" + "\nL|'rename' - Rename faces to correspond with their parent frame and position " + "index in the alignments file (i.e. how they are named after running extract).{4}" + "\nL|'sort': Re-index the alignments from left to right. For alignments with " + "multiple faces this will ensure that the left-most face is at index 0 " + "Optionally pass in a faces folder (-fc) to also rename extracted faces." + "\nL|'spatial': Perform spatial and temporal filtering to smooth alignments " + "(EXPERIMENTAL!)" + "\nL|'update-hashes': Recalculate the face hashes. Only use this if you have " + "altered the extracted faces (e.g. colour adjust). The files MUST be named " + "'_face index' (i.e. how they are named after running extract)." + "{4}".format(frames_dir, frames_and_faces_dir, align_eyes, output_opts, + faces_dir, frames_or_faces_dir))) + argument_list.append(dict( + opts=("-a", "--alignments_file"), + action=FilesFullPaths, + dest="alignments_file", + nargs="+", + group="data", + required=True, + filetypes="alignments", + help="Full path to the alignments file to be processed. If merging alignments, then " + "multiple files can be selected, space separated")) + argument_list.append(dict( + opts=("-fc", "-faces_folder"), + action=DirFullPaths, + dest="faces_dir", + group="data", + help="Directory containing extracted faces.")) + argument_list.append(dict( + opts=("-fr", "-frames_folder"), + action=DirOrFileFullPaths, + dest="frames_dir", + filetypes="video", + group="data", + help="Directory containing source frames that faces were extracted from.")) + argument_list.append(dict( + opts=("-o", "--output"), + action=Radio, + type=str, + choices=("console", "file", "move"), + group="processing", + default="console", + help="R|How to output discovered items ('faces' and 'frames' only):" + "\nL|'console': Print the list of frames to the screen. (DEFAULT)" + "\nL|'file': Output the list of frames to a text file (stored within the source " + "directory)." + "\nL|'move': Move the discovered items to a sub-folder within the source " + "directory.")) + argument_list.append(dict( + opts=("-een", "--extract-every-n"), + type=int, + action=Slider, + dest="extract_every_n", + min_max=(1, 100), + default=1, + rounding=1, + group="extract", + help="[Extract only] Extract every 'nth' frame. This option will skip frames when " + "extracting faces. For example a value of 1 will extract faces from every frame, " + "a value of 10 will extract faces from every 10th frame.")) + argument_list.append(dict( + opts=("-sz", "--size"), + type=int, + action=Slider, + min_max=(128, 512), + default=256, + group="extract", + rounding=64, + help="[Extract only] The output size of extracted faces.")) + argument_list.append(dict( + opts=("-ae", "--align-eyes"), + action="store_true", + dest="align_eyes", + group="extract", + default=False, + help="[Extract only] Perform extra alignment to ensure left/right eyes are at the " + "same height.")) + argument_list.append(dict( + opts=("-l", "--large"), + action="store_true", + group="extract", + default=False, + help="[Extract only] Only extract faces that have not been upscaled to the required " + "size (`-sz`, `--size). Useful for excluding low-res images from a training " + "set.")) return argument_list diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index 647bb3dd38..b253e03e8d 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -544,55 +544,6 @@ def _select_valid_faces(self, frame, image): return valid_faces -class Fix(): - """ Fix alignments that were impacted by the 'out by one' bug when extracting from video - - TODO This is a temporary job that should be deleted after a period of time. - Implemented 2019/12/07 - """ - def __init__(self, alignments, arguments): - logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - self.alignments = alignments - logger.debug("Initialized %s", self.__class__.__name__) - - def process(self): - """ Run the fix process """ - if not self._check_file_needs_fixing(): - sys.exit(0) - logger.info("[FIXING FRAMES]") - self._fix() - self.alignments.save() - - def _check_file_needs_fixing(self): - """ Check that these alignments are in video format and that the first frame in the " - "alignments file does not already start with 1 """ - retval = True - min_frame = min(key for key in self.alignments.data.keys()) - logger.debug("First frame: '%s'", min_frame) - fname = os.path.splitext(min_frame)[0] - frame_id = fname.split("_")[-1] - if ("_") not in fname or not frame_id.isdigit(): - logger.info("Alignments file not generated from a video. Nothing to do.") - retval = False - elif int(frame_id) == 1: - logger.info("Alignments file does not require fixing. First frame: '%s'", fname) - retval = False - logger.debug(retval) - return retval - - def _fix(self): - """ Renumber frame names, reducing each one by 1 """ - frame_names = sorted(key for key in self.alignments.data.keys()) - for old_name in tqdm(frame_names, desc="Fixing Alignments file"): - fname, ext = os.path.splitext(old_name) - vid_name, new_frame_id = ("_".join(fname.split("_")[:-1]), - int(fname.split("_")[-1]) - 1) - new_name = "{}_{:06d}{}".format(vid_name, new_frame_id, ext) - logger.debug("Re-assigning: '%s' > '%s'", old_name, new_name) - self.alignments.data[new_name] = self.alignments.data[old_name] - del self.alignments.data[old_name] - - class Merge(): """ Merge two alignments files into one """ def __init__(self, alignments, arguments): diff --git a/tools/alignments/jobs_manual.py b/tools/alignments/jobs_manual.py deleted file mode 100644 index 491b7c43d9..0000000000 --- a/tools/alignments/jobs_manual.py +++ /dev/null @@ -1,939 +0,0 @@ -#!/usr/bin/env python3 -""" Manual processing of alignments """ - -import logging -import platform -import sys -import cv2 -import numpy as np - -from lib.faces_detect import DetectedFace -from lib.queue_manager import queue_manager -from plugins.extract.pipeline import Extractor, ExtractMedia -from .annotate import Annotate -from .media import ExtractedFaces, Frames - -logger = logging.getLogger(__name__) # pylint: disable=invalid-name - - -class Interface(): - """ Key controls and interfacing options for OpenCV """ - def __init__(self, alignments, frames): - logger.debug("Initializing %s: (alignments: %s, frames: %s)", - self.__class__.__name__, alignments, frames) - self.alignments = alignments - self.frames = frames - self.controls = self.set_controls() - self.state = self.set_state() - self.skip_mode = {1: "Standard", - 2: "No Faces", - 3: "Multi-Faces", - 4: "Has Faces"} - logger.debug("Initialized %s", self.__class__.__name__) - - def set_controls(self): - """ Set keyboard controls, destination and help text """ - controls = {"z": {"action": self.iterate_frame, - "args": ("navigation", - 1), - "help": "Previous Frame"}, - "x": {"action": self.iterate_frame, - "args": ("navigation", 1), - "help": "Next Frame"}, - "[": {"action": self.iterate_frame, - "args": ("navigation", - 100), - "help": "100 Frames Back"}, - "]": {"action": self.iterate_frame, - "args": ("navigation", 100), - "help": "100 Frames Forward"}, - "{": {"action": self.iterate_frame, - "args": ("navigation", "first"), - "help": "Go to First Frame"}, - "}": {"action": self.iterate_frame, - "args": ("navigation", "last"), - "help": "Go to Last Frame"}, - 27: {"action": "quit", - "key_text": "ESC", - "args": ("navigation", None), - "help": "Exit", - "key_type": ord}, - "/": {"action": self.iterate_state, - "args": ("navigation", "frame-size"), - "help": "Cycle Frame Zoom"}, - "s": {"action": self.iterate_state, - "args": ("navigation", "skip-mode"), - "help": ("Skip Mode (All, No Faces, Multi Faces, Has Faces)")}, - " ": {"action": self.save_alignments, - "key_text": "SPACE", - "args": ("edit", None), - "help": "Save Alignments"}, - "r": {"action": self.reload_alignments, - "args": ("edit", None), - "help": "Reload Alignments (Discard all changes)"}, - "d": {"action": self.delete_alignment, - "args": ("edit", None), - "help": "Delete Selected Alignment"}, - "m": {"action": self.toggle_state, - "args": ("edit", "active"), - "help": "Change Mode (View, Edit)"}, - range(10): {"action": self.set_state_value, - "key_text": "0 to 9", - "args": ["edit", "selected"], - "help": "Select/Deselect Face at this Index", - "key_type": range}, - "c": {"action": self.copy_alignments, - "args": ("edit", -1), - "help": "Copy Alignments from Previous Frame with Alignments"}, - "v": {"action": self.copy_alignments, - "args": ("edit", 1), - "help": "Copy Alignments from Next Frame with Alignments"}, - "y": {"action": self.toggle_state, - "args": ("image", "display"), - "help": "Toggle Image"}, - "u": {"action": self.iterate_state, - "args": ("bounding_box", "color"), - "help": "Cycle Bounding Box Color"}, - "i": {"action": self.iterate_state, - "args": ("extract_box", "color"), - "help": "Cycle Extract Box Color"}, - "o": {"action": self.iterate_state, - "args": ("landmarks", "color"), - "help": "Cycle Landmarks Color"}, - "p": {"action": self.iterate_state, - "args": ("landmarks_mesh", "color"), - "help": "Cycle Landmarks Mesh Color"}, - "h": {"action": self.iterate_state, - "args": ("bounding_box", "size"), - "help": "Cycle Bounding Box thickness"}, - "j": {"action": self.iterate_state, - "args": ("extract_box", "size"), - "help": "Cycle Extract Box thickness"}, - "k": {"action": self.iterate_state, - "args": ("landmarks", "size"), - "help": "Cycle Landmarks - point size"}, - "l": {"action": self.iterate_state, - "args": ("landmarks_mesh", "size"), - "help": "Cycle Landmarks Mesh - thickness"}} - - logger.debug("Controls: %s", controls) - return controls - - @staticmethod - def set_state(): - """ Set the initial display state """ - state = {"bounding_box": dict(), - "extract_box": dict(), - "landmarks": dict(), - "landmarks_mesh": dict(), - "image": dict(), - "navigation": {"skip-mode": 1, - "frame-size": 1, - "frame_idx": 0, - "max_frame": 0, - "last_request": 0, - "frame_name": None}, - "edit": {"updated": False, - "update_faces": False, - "selected": None, - "active": 0, - "redraw": False}} - - # See lib_alignments/annotate.py for color mapping - color = 0 - for key in sorted(state.keys()): - if key not in ("bounding_box", "extract_box", "landmarks", "landmarks_mesh", "image"): - continue - state[key]["display"] = True - if key == "image": - continue - color += 1 - state[key]["size"] = 1 - state[key]["color"] = color - logger.debug("State: %s", state) - return state - - def save_alignments(self, *args): # pylint: disable=unused-argument - """ Save alignments """ - logger.debug("Saving Alignments") - if not self.state["edit"]["updated"]: - logger.debug("Save received, but state not updated. Not saving") - return - self.alignments.save() - self.state["edit"]["updated"] = False - self.set_redraw(True) - - def reload_alignments(self, *args): # pylint: disable=unused-argument - """ Reload alignments """ - logger.debug("Reloading Alignments") - if not self.state["edit"]["updated"]: - logger.debug("Reload received, but state not updated. Not reloading") - return - self.alignments.reload() - self.state["edit"]["updated"] = False - self.state["edit"]["update_faces"] = True - self.set_redraw(True) - - def delete_alignment(self, *args): # pylint: disable=unused-argument - """ Save alignments """ - logger.debug("Deleting Alignments") - selected_face = self.get_selected_face_id() - if self.get_edit_mode() == "View" or selected_face is None: - logger.debug("Delete received, but edit mode is 'View'. Not deleting") - return - frame = self.get_frame_name() - if self.alignments.delete_face_at_index(frame, selected_face): - self.state["edit"]["selected"] = None - self.state["edit"]["updated"] = True - self.state["edit"]["update_faces"] = True - self.set_redraw(True) - - def copy_alignments(self, *args): - """ Copy the alignments from the previous or next frame - to the current frame """ - logger.debug("Copying Alignments") - if self.get_edit_mode() != "Edit": - logger.debug("Copy received, but edit mode is not 'Edit'. Not copying") - return - frame_id = self.get_next_face_idx(args[1]) - if not 0 <= frame_id <= self.state["navigation"]["max_frame"]: - return - current_frame = self.get_frame_name() - get_frame = self.frames.file_list_sorted[frame_id]["frame_fullname"] - alignments = self.alignments.get_faces_in_frame(get_frame) - for alignment in alignments: - self.alignments. add_face(current_frame, alignment) - self.state["edit"]["updated"] = True - self.state["edit"]["update_faces"] = True - self.set_redraw(True) - - def toggle_state(self, item, category): - """ Toggle state of requested item """ - logger.debug("Toggling state: (item: %s, category: %s)", item, category) - self.state[item][category] = not self.state[item][category] - logger.debug("State toggled: (item: %s, category: %s, value: %s)", - item, category, self.state[item][category]) - self.set_redraw(True) - - def iterate_state(self, item, category): - """ Cycle through options (6 possible or 3 currently supported) """ - logger.debug("Cycling state: (item: %s, category: %s)", item, category) - if category == "color": - max_val = 7 - elif category == "frame-size": - max_val = 6 - elif category == "skip-mode": - max_val = 4 - else: - max_val = 3 - val = self.state[item][category] - val = val + 1 if val != max_val else 1 - self.state[item][category] = val - logger.debug("Cycled state: (item: %s, category: %s, value: %s)", - item, category, self.state[item][category]) - self.set_redraw(True) - - def set_state_value(self, item, category, value): - """ Set state of requested item or toggle off """ - logger.debug("Setting state value: (item: %s, category: %s, value: %s)", - item, category, value) - state = self.state[item][category] - value = str(value) if value is not None else value - if state == value: - self.state[item][category] = None - else: - self.state[item][category] = value - logger.debug("Setting state value: (item: %s, category: %s, value: %s)", - item, category, self.state[item][category]) - self.set_redraw(True) - - def iterate_frame(self, *args): - """ Iterate frame up or down, stopping at either end """ - logger.debug("Iterating frame: (args: %s)", args) - iteration = args[1] - max_frame = self.state["navigation"]["max_frame"] - if iteration in ("first", "last"): - next_frame = 0 if iteration == "first" else max_frame - self.state["navigation"]["frame_idx"] = next_frame - self.state["navigation"]["last_request"] = 0 - self.set_redraw(True) - return - - current_frame = self.state["navigation"]["frame_idx"] - next_frame = current_frame + iteration - end = 0 if iteration < 0 else max_frame - if (max_frame == 0 or - (end > 0 and next_frame >= end) or - (end == 0 and next_frame <= end)): - next_frame = end - self.state["navigation"]["frame_idx"] = next_frame - self.state["navigation"]["last_request"] = iteration - self.set_state_value("edit", "selected", None) - - def get_color(self, item): - """ Return color for selected item """ - return self.state[item]["color"] - - def get_size(self, item): - """ Return size for selected item """ - return self.state[item]["size"] - - def get_frame_scaling(self): - """ Return frame scaling factor for requested item """ - factors = (1, 1.25, 1.5, 2, 0.5, 0.75) - idx = self.state["navigation"]["frame-size"] - 1 - return factors[idx] - - def get_edit_mode(self): - """ Return text version and border color for edit mode """ - if self.state["edit"]["active"]: - return "Edit" - return "View" - - def get_skip_mode(self): - """ Return text version of skip mode """ - return self.skip_mode[self.state["navigation"]["skip-mode"]] - - def get_state_color(self): - """ Return a color based on current state - white - View Mode - yellow - Edit Mode - red - Unsaved alignments """ - color = (255, 255, 255) - if self.state["edit"]["updated"]: - color = (0, 0, 255) - elif self.state["edit"]["active"]: - color = (0, 255, 255) - return color - - def get_frame_name(self): - """ Return the current frame number """ - return self.state["navigation"]["frame_name"] - - def get_selected_face_id(self): - """ Return the index of the currently selected face """ - try: - return int(self.state["edit"]["selected"]) - except TypeError: - return None - - def redraw(self): - """ Return whether a redraw is required """ - return self.state["edit"]["redraw"] - - def set_redraw(self, request): - """ Turn redraw requirement on or off """ - self.state["edit"]["redraw"] = request - - def get_next_face_idx(self, increment): - """Get the index of the previous or next frame which has a face""" - navigation = self.state["navigation"] - frame_list = self.frames.file_list_sorted - frame_idx = navigation["frame_idx"] + increment - while True: - if not 0 <= frame_idx <= navigation["max_frame"]: - break - frame = frame_list[frame_idx]["frame_fullname"] - if not self.alignments.frame_has_faces(frame): - frame_idx += increment - else: - break - return frame_idx - - -class Help(): - """ Generate and display help in cli and in window """ - def __init__(self, interface): - logger.debug("Initializing %s: (interface: %s)", self.__class__.__name__, interface) - self.interface = interface - self.helptext = self.generate() - logger.debug("Initialized %s", self.__class__.__name__) - - def generate(self): - """ Generate help output """ - logger.debug("Generating help") - sections = ("navigation", "display", "color", "size", "edit") - helpout = {section: list() for section in sections} - helptext = "" - for key, val in self.interface.controls.items(): - logger.trace("Generating help for:(key: '%s', val: '%s'", key, val) - help_section = val["args"][0] - if help_section not in ("navigation", "edit"): - help_section = val["args"][1] - key_text = val.get("key_text", None) - key_text = key_text if key_text else key - logger.trace("Adding help for:(section: '%s', val: '%s', text: '%s'", - help_section, val["help"], key_text) - helpout[help_section].append((val["help"], key_text)) - - helpout["edit"].append(("Bounding Box - Move", "Left Click")) - helpout["edit"].append(("Bounding Box - Resize", "Middle Click")) - - for section in sections: - spacer = "=" * int((40 - len(section)) / 2) - display = "\n{0} {1} {0}\n".format(spacer, section.upper()) - helpsection = sorted(helpout[section]) - if section == "navigation": - helpsection = sorted(helpout[section], reverse=True) - display += "\n".join(" - '{}': {}".format(item[1], item[0]) - for item in helpsection) - - helptext += display - logger.debug("Added helptext: '%s'", helptext) - return helptext - - def render(self): - """ Render help text to image window """ - # pylint: disable=no-member - logger.trace("Rendering help text") - image = self.background() - display_text = self.helptext + self.compile_status() - self.text_to_image(image, display_text) - cv2.namedWindow("Help") - cv2.imshow("Help", image) - logger.trace("Rendered help text") - - def background(self): - """ Create an image to hold help text """ - # pylint: disable=no-member - logger.trace("Creating help text canvas") - height = 880 - width = 480 - image = np.zeros((height, width, 3), np.uint8) - color = self.interface.get_state_color() - cv2.rectangle(image, (0, 0), (width - 1, height - 1), color, 2) - logger.trace("Created help text canvas") - return image - - def compile_status(self): - """ Render the status text """ - logger.trace("Compiling Status text") - status = "\n=== STATUS\n" - navigation = self.interface.state["navigation"] - frame_scale = int(self.interface.get_frame_scaling() * 100) - status += " File: {}\n".format(self.interface.get_frame_name()) - status += " Frame: {} / {}\n".format( - navigation["frame_idx"] + 1, navigation["max_frame"] + 1) - status += " Frame Size: {}%\n".format(frame_scale) - status += " Skip Mode: {}\n".format(self.interface.get_skip_mode()) - status += " View Mode: {}\n".format(self.interface.get_edit_mode()) - if self.interface.get_selected_face_id() is not None: - status += " Selected Face Index: {}\n".format(self.interface.get_selected_face_id()) - if self.interface.state["edit"]["updated"]: - status += " Warning: There are unsaved changes\n" - - logger.trace("Compiled Status text") - return status - - @staticmethod - def text_to_image(image, display_text): - """ Write out and format help text to image """ - # pylint: disable=no-member - logger.trace("Converting help text to image") - pos_y = 0 - for line in display_text.split("\n"): - if line.startswith("==="): - pos_y += 10 - line = line.replace("=", "").strip() - line = line.replace("- '", "[ ").replace("':", " ]") - cv2.putText(image, line, (20, pos_y), - cv2.FONT_HERSHEY_SIMPLEX, 0.43, (255, 255, 255), 1) - pos_y += 20 - logger.trace("Converted help text to image") - - -class Manual(): - """ Manually adjust or create landmarks data """ - def __init__(self, alignments, arguments): - logger.debug("Initializing %s: (alignments: %s, arguments: %s)", - self.__class__.__name__, alignments, arguments) - self.arguments = arguments - self.alignments = alignments - self.frames = Frames(arguments.frames_dir) - self.extracted_faces = None - self.interface = None - self.help = None - self.mouse_handler = None - logger.debug("Initialized %s", self.__class__.__name__) - - def process(self): - """ Process manual extraction """ - logger.info("[MANUAL PROCESSING]") # Tidy up cli output - self.extracted_faces = ExtractedFaces(self.frames, self.alignments, size=256) - self.interface = Interface(self.alignments, self.frames) - self.help = Help(self.interface) - self.mouse_handler = MouseHandler(self.interface, self.arguments.loglevel) - - print(self.help.helptext) - max_idx = self.frames.count - 1 - self.interface.state["navigation"]["max_frame"] = max_idx - self.display_frames() - - def display_frames(self): - """ Iterate through frames """ - # pylint: disable=no-member - logger.debug("Display frames") - is_windows = platform.system() == "Windows" - is_conda = "conda" in sys.version.lower() - logger.debug("is_windows: %s, is_conda: %s", is_windows, is_conda) - cv2.namedWindow("Frame") - cv2.namedWindow("Faces") - cv2.setMouseCallback('Frame', self.mouse_handler.on_event) - - frame, faces = self.get_frame() - press = self.get_keys() - self.interface.set_redraw(True) - - while True: - if self.interface.redraw(): - self.help.render() - cv2.imshow("Frame", frame) - cv2.imshow("Faces", faces) - self.interface.set_redraw(False) - key = cv2.waitKey(1000) - - if self.window_closed(is_windows, is_conda, key): - queue_manager.terminate_queues() - break - - if key and key != -1: - logger.trace("Keypress received: '%s'", key) - if key in press.keys(): - action = press[key]["action"] - logger.debug("Keypress action: key: ('%s', action: '%s')", key, action) - if action == "quit": - break - - if press[key].get("key_type") == range: - args = press[key]["args"] + [chr(key)] - else: - args = press[key]["args"] - action(*args) - - if not self.interface.redraw(): - continue - - logger.trace("Redraw requested") - frame, faces = self.get_frame() - - cv2.destroyAllWindows() - - def window_closed(self, is_windows, is_conda, key): - """ Check whether the window has been closed - - MS Windows doesn't appear to read the window state property - properly, so we check for a negative key press. - - Conda (tested on Windows) doesn't appear to read the window - state property or negative key press properly, so we arbitrarily - use another property """ - # pylint: disable=no-member - logger.trace("Commencing closed window check") - closed = False - prop_autosize = cv2.getWindowProperty('Frame', cv2.WND_PROP_AUTOSIZE) - prop_visible = cv2.getWindowProperty('Frame', cv2.WND_PROP_VISIBLE) - if self.arguments.disable_monitor: - closed = False - elif is_conda and prop_autosize < 1: - closed = True - elif is_windows and not is_conda and key == -1: - closed = True - elif not is_windows and not is_conda and prop_visible < 1: - closed = True - logger.trace("Completed closed window check. Closed is %s", closed) - if closed: - logger.debug("Window closed detected") - return closed - - def get_keys(self): - """ Convert keys dict into something useful - for OpenCV """ - keys = dict() - for key, val in self.interface.controls.items(): - if val.get("key_type", str) == range: - for range_key in key: - keys[ord(str(range_key))] = val - elif val.get("key_type", str) == ord: - keys[key] = val - else: - keys[ord(key)] = val - - return keys - - def get_frame(self): - """ Compile the frame and get faces """ - image = self.frame_selector() - frame_name = self.interface.get_frame_name() - logger.debug("Frame Name: '%s'", frame_name) - alignments = self.alignments.get_faces_in_frame(frame_name) - faces_updated = self.interface.state["edit"]["update_faces"] - logger.debug("Faces Updated: %s", faces_updated) - self.extracted_faces.get_faces(frame_name) - roi = [face.original_roi for face in self.extracted_faces.faces] - - if faces_updated: - self.interface.state["edit"]["update_faces"] = False - - frame = FrameDisplay(image, alignments, roi, self.interface).image - faces = self.set_faces(frame_name).image - return frame, faces - - def frame_selector(self): - """ Return frame at given index """ - navigation = self.interface.state["navigation"] - frame_list = self.frames.file_list_sorted - frame = frame_list[navigation["frame_idx"]]["frame_fullname"] - skip_mode = self.interface.get_skip_mode().lower() - logger.debug("navigation: %s, frame: '%s', skip_mode: '%s'", navigation, frame, skip_mode) - - while True: - if navigation["last_request"] == 0: - break - if navigation["frame_idx"] in (0, navigation["max_frame"]): - break - if skip_mode == "standard": - break - if skip_mode == "no faces" and not self.alignments.frame_has_faces(frame): - break - if skip_mode == "multi-faces" and self.alignments.frame_has_multiple_faces(frame): - break - if skip_mode == "has faces" and self.alignments.frame_has_faces(frame): - break - self.interface.iterate_frame("navigation", navigation["last_request"]) - frame = frame_list[navigation["frame_idx"]]["frame_fullname"] - - image = self.frames.load_image(frame) - navigation["last_request"] = 0 - navigation["frame_name"] = frame - return image - - def set_faces(self, frame): - """ Pass the current frame faces to faces window """ - faces = self.extracted_faces.get_faces_in_frame(frame) - landmarks = [{"landmarks_xy": face.aligned_landmarks} - for face in self.extracted_faces.faces] - return FacesDisplay(faces, landmarks, self.extracted_faces.size, self.interface) - - -class FrameDisplay(): - """" Window that holds the frame """ - def __init__(self, image, alignments, roi, interface): - logger.trace("Initializing %s: (alignments: %s, roi: %s, interface: %s)", - self.__class__.__name__, alignments, roi, interface) - self.image = image - self.roi = roi - self.alignments = alignments - self.interface = interface - self.annotate_frame() - logger.trace("Initialized %s", self.__class__.__name__) - - def annotate_frame(self): - """ Annotate the frame """ - state = self.interface.state - logger.trace("State: %s", state) - annotate = Annotate(self.image, self.alignments, self.roi) - if not state["image"]["display"]: - annotate.draw_black_image() - - for item in ("bounding_box", "extract_box", "landmarks", "landmarks_mesh"): - color = self.interface.get_color(item) - size = self.interface.get_size(item) - state[item]["display"] = color != 7 - if not state[item]["display"]: - continue - logger.trace("Annotating: '%s'", item) - annotation = getattr(annotate, "draw_{}".format(item)) - annotation(color, size) - - selected_face = self.interface.get_selected_face_id() - if (selected_face is not None and - int(selected_face) < len(self.alignments)): - annotate.draw_grey_out_faces(selected_face) - - self.image = self.resize_frame(annotate.image) - - def resize_frame(self, image): - """ Set the displayed frame size and add state border""" - # pylint: disable=no-member - logger.trace("Resizing frame") - height, width = image.shape[:2] - color = self.interface.get_state_color() - cv2.rectangle(image, (0, 0), (width - 1, height - 1), color, 1) - scaling = self.interface.get_frame_scaling() - image = cv2.resize(image, (0, 0), fx=scaling, fy=scaling) - logger.trace("Resized frame") - return image - - -class FacesDisplay(): - """ Window that holds faces thumbnail """ - def __init__(self, extracted_faces, landmarks, size, interface): - logger.trace("Initializing %s: (extracted_faces: %s, landmarks: %s, size: %s, " - "interface: %s)", self.__class__.__name__, extracted_faces, - landmarks, size, interface) - self.row_length = 4 - self.faces = self.copy_faces(extracted_faces) - self.roi = self.set_full_roi(size) - self.landmarks = landmarks - self.interface = interface - - self.annotate_faces() - - self.image = self.build_faces_image(size) - logger.trace("Initialized %s", self.__class__.__name__) - - @staticmethod - def copy_faces(faces): - """ Copy the extracted faces so as not to save the annotations back """ - return [face.aligned_face.copy() for face in faces] - - @staticmethod - def set_full_roi(size): - """ ROI is the full frame for faces, so set based on size """ - return [np.array([[(0, 0), (0, size - 1), (size - 1, size - 1), (size - 1, 0)]], np.int32)] - - def annotate_faces(self): - """ Annotate each of the faces """ - state = self.interface.state - selected_face = self.interface.get_selected_face_id() - logger.trace("State: %s, Selected Face ID: %s", state, selected_face) - for idx, face in enumerate(self.faces): - annotate = Annotate(face, [self.landmarks[idx]], self.roi) - if not state["image"]["display"]: - annotate.draw_black_image() - - for item in ("landmarks", "landmarks_mesh"): - if not state[item]["display"]: - continue - logger.trace("Annotating: '%s'", item) - color = self.interface.get_color(item) - size = self.interface.get_size(item) - annotation = getattr(annotate, "draw_{}".format(item)) - annotation(color, size) - - if (selected_face is not None - and int(selected_face) < len(self.faces) - and int(selected_face) != idx): - annotate.draw_grey_out_faces(1) - - self.faces[idx] = annotate.image - - def build_faces_image(self, size): - """ Display associated faces """ - total_faces = len(self.faces) - logger.trace("Building faces panel. (total_faces: %s)", total_faces) - if not total_faces: - logger.trace("Returning empty row") - image = self.build_faces_row(list(), size) - return image - total_rows = int(total_faces / self.row_length) + 1 - for idx in range(total_rows): - logger.trace("Building row %s", idx) - face_idx = idx * self.row_length - row_faces = self.faces[face_idx:face_idx + self.row_length] - if not row_faces: - break - row = self.build_faces_row(row_faces, size) - image = row if idx == 0 else np.concatenate((image, row), axis=0) - return image - - def build_faces_row(self, faces, size): - """ Build a row of 4 faces """ - # pylint: disable=no-member - logger.trace("Building row for %s faces", len(faces)) - if len(faces) != 4: - remainder = 4 - (len(faces) % self.row_length) - for _ in range(remainder): - faces.append(np.zeros((size, size, 3), np.uint8)) - for idx, face in enumerate(faces): - color = self.interface.get_state_color() - cv2.rectangle(face, (0, 0), (size - 1, size - 1), - color, 1) - if idx == 0: - row = face - else: - row = np.concatenate((row, face), axis=1) - return row - - -class MouseHandler(): - """ Manual Extraction """ - def __init__(self, interface, loglevel): - logger.debug("Initializing %s: (interface: %s, loglevel: %s)", - self.__class__.__name__, interface, loglevel) - self.interface = interface - self.alignments = interface.alignments - self.frames = interface.frames - - self.queues = dict() - self.extractor = self.init_extractor() - - self.mouse_state = None - self.last_move = None - self.center = None - self.dims = None - self.media = {"frame_id": None, - "image": None, - "bounding_box": list(), - "bounding_last": list(), - "bounding_box_orig": list()} - logger.debug("Initialized %s", self.__class__.__name__) - - def init_extractor(self): - """ Initialize Aligner """ - logger.debug("Initialize Extractor") - extractor = Extractor(None, "fan", None, multiprocess=True, normalize_method="hist") - self.queues["in"] = extractor.input_queue - # Set the batchsize to 1 - extractor.set_batchsize("align", 1) - extractor.launch() - logger.debug("Initialized Extractor") - return extractor - - def on_event(self, event, x, y, flags, param): # pylint: disable=unused-argument,invalid-name - """ Handle the mouse events """ - # pylint: disable=no-member - if self.interface.get_edit_mode() != "Edit": - return - logger.trace("Mouse event: (event: %s, x: %s, y: %s, flags: %s, param: %s", - event, x, y, flags, param) - if not self.mouse_state and event not in (cv2.EVENT_LBUTTONDOWN, cv2.EVENT_MBUTTONDOWN): - return - - self.initialize() - - if event in (cv2.EVENT_LBUTTONUP, cv2.EVENT_MBUTTONUP): - self.mouse_state = None - self.last_move = None - elif event == cv2.EVENT_LBUTTONDOWN: - self.mouse_state = "left" - self.set_bounding_box(x, y) - elif event == cv2.EVENT_MBUTTONDOWN: - self.mouse_state = "middle" - self.set_bounding_box(x, y) - elif event == cv2.EVENT_MOUSEMOVE: - if self.mouse_state == "left": - self.move_bounding_box(x, y) - elif self.mouse_state == "middle": - self.resize_bounding_box(x, y) - - def initialize(self): - """ Update changed parameters """ - frame = self.interface.get_frame_name() - if frame == self.media["frame_id"]: - return - logger.debug("Initialize frame: '%s'", frame) - self.media["frame_id"] = frame - self.media["image"] = self.frames.load_image(frame) - self.dims = None - self.center = None - self.last_move = None - self.mouse_state = None - self.media["bounding_box"] = DetectedFace() - self.media["bounding_box_orig"] = None - - def set_bounding_box(self, pt_x, pt_y): - """ Select or create bounding box """ - if self.interface.get_selected_face_id() is None: - self.check_click_location(pt_x, pt_y) - - if self.interface.get_selected_face_id() is not None: - self.dims_from_alignment() - else: - self.dims_from_image() - - self.move_bounding_box(pt_x, pt_y) - - def check_click_location(self, pt_x, pt_y): - """ Check whether the point clicked is within an existing - bounding box and set face_id """ - frame = self.media["frame_id"] - alignments = self.alignments.get_faces_in_frame(frame) - scale = self.interface.get_frame_scaling() - pt_x = int(pt_x / scale) - pt_y = int(pt_y / scale) - - for idx, alignment in enumerate(alignments): - left = alignment["x"] - right = alignment["x"] + alignment["w"] - top = alignment["y"] - bottom = alignment["y"] + alignment["h"] - - if left <= pt_x <= right and top <= pt_y <= bottom: - self.interface.set_state_value("edit", "selected", idx) - break - - def dims_from_alignment(self): - """ Set the height and width of bounding box from alignment """ - frame = self.media["frame_id"] - face_id = self.interface.get_selected_face_id() - alignment = self.alignments.get_faces_in_frame(frame)[face_id] - self.dims = (alignment["w"], alignment["h"]) - - def dims_from_image(self): - """ Set the height and width of bounding - box at 10% of longest axis """ - size = max(self.media["image"].shape[:2]) - dim = int(size / 10.00) - self.dims = (dim, dim) - - def bounding_from_center(self): - """ Get bounding X Y from center """ - pt_x, pt_y = self.center - width, height = self.dims - scale = self.interface.get_frame_scaling() - self.media["bounding_box"].x = int((pt_x / scale) - width / 2) - self.media["bounding_box"].y = int((pt_y / scale) - height / 2) - self.media["bounding_box"].w = width - self.media["bounding_box"].h = height - - def move_bounding_box(self, pt_x, pt_y): - """ Move the bounding box """ - self.center = (pt_x, pt_y) - self.bounding_from_center() - self.update_landmarks() - - def resize_bounding_box(self, pt_x, pt_y): - """ Resize the bounding box """ - scale = self.interface.get_frame_scaling() - if not self.last_move: - self.last_move = (pt_x, pt_y) - self.media["bounding_box_orig"] = self.media["bounding_box"] - - move_x = int(pt_x - self.last_move[0]) - move_y = int(self.last_move[1] - pt_y) - - original = self.media["bounding_box_orig"] - updated = self.media["bounding_box"] - - minsize = int(20 / scale) - center = (int(self.center[0] / scale), int(self.center[1] / scale)) - updated.x = min(center[0] - (minsize // 2), original.x - move_x) - updated.y = min(center[1] - (minsize // 2), original.y - move_y) - updated.w = max(minsize, original.w + move_x) - updated.h = max(minsize, original.h + move_y) - self.update_landmarks() - self.last_move = (pt_x, pt_y) - - def update_landmarks(self): - """ Update the landmarks """ - feed = ExtractMedia(self.media["frame_id"], - self.media["image"], - detected_faces=[self.media["bounding_box"]]) - self.queues["in"].put(feed) - detected_face = next(self.extractor.detected_faces()).detected_faces[0] - alignment = detected_face.to_alignment() - # Mask will now be incorrect for updated landmarks so delete - alignment["mask"] = dict() - - frame = self.media["frame_id"] - - if self.interface.get_selected_face_id() is None: - idx = self.alignments.add_face(frame, alignment) - self.interface.set_state_value("edit", "selected", idx) - else: - self.alignments.update_face(frame, - self.interface.get_selected_face_id(), - alignment) - self.interface.set_redraw(True) - - self.interface.state["edit"]["updated"] = True - self.interface.state["edit"]["update_faces"] = True diff --git a/tools/manual/manual.py b/tools/manual/manual.py index f55a6a7446..f37f04a93d 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -44,7 +44,7 @@ def __init__(self, arguments): self._initialize_tkinter() self._globals = TkGlobals(arguments.frames) - extractor = Aligner(self._globals) + extractor = Aligner(self._globals, arguments.exclude_gpus) self._detected_faces = DetectedFaces(self._globals, arguments.alignments_path, arguments.frames, @@ -597,12 +597,17 @@ class Aligner(): ---------- tk_globals: :class:`~tools.manual.manual.TkGlobals` The tkinter variables that apply to the whole of the GUI + 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. """ - def __init__(self, tk_globals): - logger.debug("Initializing: %s (tk_globals: %s)", self.__class__.__name__, tk_globals) + def __init__(self, tk_globals, exclude_gpus): + 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 @@ -656,8 +661,13 @@ def _init_aligner(self): for model in ("mask", "cv2-dnn", "FAN"): logger.debug("Initializing aligner: %s", model) plugin = None if model == "mask" else model - aligner = Extractor(None, plugin, ["components", "extended"], - multiprocess=True, normalize_method="hist") + exclude_gpus = self._exclude_gpus if model == "FAN" else None + aligner = Extractor(None, + plugin, + ["components", "extended"], + exclude_gpus=exclude_gpus, + multiprocess=True, + normalize_method="hist") if plugin: aligner.set_batchsize("align", 1) # Set the batchsize to 1 aligner.launch() diff --git a/tools/mask/mask.py b/tools/mask/mask.py index bd31835cb9..5c1335936d 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -51,7 +51,7 @@ def __init__(self, arguments): self._alignments = Alignments(os.path.dirname(arguments.alignments), filename=os.path.basename(arguments.alignments)) - self._extractor = self._get_extractor() + self._extractor = self._get_extractor(arguments.exclude_gpus) self._extractor_input_thread = self._feed_extractor() logger.debug("Initialized %s", self.__class__.__name__) @@ -99,9 +99,15 @@ def _set_saver(self, arguments): logger.debug(saver) return saver - def _get_extractor(self): + def _get_extractor(self, exclude_gpus): """ 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`: @@ -112,6 +118,7 @@ def _get_extractor(self): return None logger.debug("masker: %s", self._mask_type) extractor = Extractor(None, None, self._mask_type, + exclude_gpus=exclude_gpus, image_is_aligned=self._input_is_faces) extractor.launch() logger.debug(extractor) diff --git a/tools/preview/cli.py b/tools/preview/cli.py index 4e767cc149..2394be1533 100644 --- a/tools/preview/cli.py +++ b/tools/preview/cli.py @@ -17,42 +17,34 @@ def get_info(): def get_argument_list(self): argument_list = list() - argument_list.append({"opts": ("-i", "--input-dir"), - "action": DirOrFileFullPaths, - "filetypes": "video", - "dest": "input_dir", - "group": "data", - "required": True, - "help": "Input directory or video. Either a directory containing " - "the image files you wish to process or path to a video " - "file."}) - argument_list.append({"opts": ("-al", "--alignments"), - "action": FileFullPaths, - "filetypes": "alignments", - "type": str, - "group": "data", - "dest": "alignments_path", - "help": "Path to the alignments file for the input, if not at the " - "default location"}) - argument_list.append({"opts": ("-m", "--model-dir"), - "action": DirFullPaths, - "dest": "model_dir", - "group": "data", - "required": True, - "help": "Model directory. A directory containing the trained model " - "you wish to process."}) - argument_list.append({"opts": ("-s", "--swap-model"), - "action": "store_true", - "dest": "swap_model", - "default": False, - "help": "Swap the model. Instead of A -> B, " - "swap B -> A"}) - argument_list.append({"opts": ("-ag", "--allow-growth"), - "action": "store_true", - "dest": "allow_growth", - "default": False, - "backend": "nvidia", - "help": "Sets allow_growth option of Tensorflow to spare memory " - "on some configurations."}) - + argument_list.append(dict( + opts=("-i", "--input-dir"), + action=DirOrFileFullPaths, + filetypes="video", + dest="input_dir", + group="data", + required=True, + help="Input directory or video. Either a directory containing the image files you " + "wish to process or path to a video file.")) + argument_list.append(dict( + opts=("-al", "--alignments"), + action=FileFullPaths, + filetypes="alignments", + type=str, + group="data", + dest="alignments_path", + help="Path to the alignments file for the input, if not at the default location")) + argument_list.append(dict( + opts=("-m", "--model-dir"), + action=DirFullPaths, + dest="model_dir", + group="data", + required=True, + help="Model directory. A directory containing the trained model you wish to process.")) + argument_list.append(dict( + opts=("-s", "--swap-model"), + action="store_true", + dest="swap_model", + default=False, + help="Swap the model. Instead of A -> B, swap B -> A")) return argument_list diff --git a/tools/sort/cli.py b/tools/sort/cli.py index c2f27c1761..2e12facdf6 100644 --- a/tools/sort/cli.py +++ b/tools/sort/cli.py @@ -159,15 +159,6 @@ def get_argument_list(): "the last bin." "Default value: 5"}) - argument_list.append({"opts": ("-be", "--backend"), - "action": Radio, - "type": str.upper, - "choices": ("CPU", "GPU"), - "default": "GPU", - "group": "settings", - "help": "Backend to use for VGG Face inference." - "Only used for sort by 'face'."}) - argument_list.append({"opts": ('-l', '--log-changes'), "action": 'store_true', "group": "settings", diff --git a/tools/sort/sort.py b/tools/sort/sort.py index a7446ca738..804ba08cd4 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -17,10 +17,8 @@ from lib.serializer import get_serializer_from_filename from lib.faces_detect import DetectedFace from lib.image import ImagesLoader, read_image -from lib.utils import get_backend -from lib.vgg_face2_keras import VGGFace2 as VGGFace +from plugins.extract.recognition.vgg_face2_keras import VGGFace2 as VGGFace from plugins.extract.pipeline import Extractor, ExtractMedia -from plugins.extract._config import Config logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -58,13 +56,8 @@ def process(self): # Load VGG Face if sorting by face if self.args.sort_method.lower() == "face": - conf = Config("global", configfile=self.args.configfile) - allow_growth = (conf.config_dict["allow_growth"] and - self.args.backend.lower() == "gpu" and - get_backend() == "nvidia") - self.vgg_face = VGGFace(backend=self.args.backend, - allow_growth=allow_growth, - loglevel=self.args.loglevel) + self.vgg_face = VGGFace(exclude_gpus=self.args.exclude_gpus) + self.vgg_face.init_model() # If logging is enabled, prepare container if self.args.log_changes: @@ -91,10 +84,10 @@ def process(self): self.sort_process() - @staticmethod - def launch_aligner(): + def launch_aligner(self): """ Load the aligner plugin to retrieve landmarks """ - extractor = Extractor(None, "fan", None, normalize_method="hist") + extractor = Extractor(None, "fan", None, + normalize_method="hist", exclude_gpus=self.args.exclude_gpus) extractor.set_batchsize("align", 1) extractor.launch() return extractor From 6b2aac65b14b9683f72ad09bbeae47977308d368 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 12 Aug 2020 11:19:03 +0100 Subject: [PATCH 263/981] Enable MTCNN for CPU extraction --- lib/cli/args.py | 4 ++-- lib/cli/launcher.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index f676a250e4..ca430dc630 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -362,8 +362,8 @@ def get_optional_arguments(): "'/config/extract.ini' or 'Settings > Configure Extract 'Plugins':" "\nL|cv2-dnn: A CPU only extractor which is the least reliable and least " "resource intensive. Use this if not using a GPU and time is important." - "\nL|mtcnn: Good detector. GPU only. Uses fewer resources than other GPU " - "detectors but can often return more false positives." + "\nL|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " + "than other GPU detectors but can often return more false positives." "\nL|s3fd: Best detector. GPU only. Can detect more faces and fewer false " "positives than other GPU detectors, but is a lot more resource intensive.")) argument_list.append(dict( diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 6a4f743a48..0380541776 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -201,7 +201,7 @@ def _configure_backend(self, arguments): set_exclude_devices(arguments.exclude_gpus) if ((get_backend() == "cpu" or GPUStats().exclude_all_devices) and - (self._command == "extract" and arguments.detector in ("mtcnn", "s3fd"))): + (self._command == "extract" and arguments.detector == "s3fd")): logger.error("Extracting on CPU is not currently for detector: '%s'", arguments.detector.upper()) sys.exit(0) From fa4783fa11b19299859e853222234c5ca0aa2c1a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 16 Aug 2020 23:33:09 +0100 Subject: [PATCH 264/981] training config - Add selectable optimizers --- plugins/train/_config.py | 180 ++++++++++++++++++++++----------- plugins/train/model/_base.py | 132 +++++++++++++++--------- plugins/train/trainer/_base.py | 2 +- 3 files changed, 210 insertions(+), 104 deletions(-) diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 8900b536a8..933c3149be 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -57,8 +57,14 @@ def set_globals(self): self.add_section(title=section, info="Options that apply to all models" + ADDITIONAL_INFO) self.add_item( - section=section, title="coverage", datatype=float, default=68.75, - min_max=(62.5, 100.0), rounding=2, fixed=True, group="face", + section=section, + title="coverage", + datatype=float, + default=68.75, + min_max=(62.5, 100.0), + rounding=2, + fixed=True, + group="face", info="How much of the extracted image to train on. A lower coverage will limit the " "model's scope to a zoomed-in central area while higher amounts can include the " "entire face. A trade-off exists between lower amounts given more detail " @@ -69,8 +75,12 @@ def set_globals(self): "\n\t87.5%% spans from ear to ear." "\n\t100.0%% is a mugshot.") self.add_item( - section=section, title="mask_type", datatype=str, default="extended", - choices=PluginLoader.get_available_extractors("mask", add_none=True), group="mask", + section=section, + title="mask_type", + datatype=str, + default="extended", + choices=PluginLoader.get_available_extractors("mask", add_none=True), + group="mask", gui_radio=True, info="The mask to be used for training. If you have selected 'Learn Mask' or " "'Penalized Mask Loss' you must select a value other than 'none'. The required " @@ -96,70 +106,52 @@ def set_globals(self): "testing for further description. Profile faces may result in sub-par " "performance.") self.add_item( - section=section, title="mask_blur_kernel", datatype=int, min_max=(0, 9), - rounding=1, default=3, group="mask", + section=section, + title="mask_blur_kernel", + datatype=int, + min_max=(0, 9), + rounding=1, + default=3, + group="mask", info="Apply gaussian blur to the mask input. This has the effect of smoothing the " "edges of the mask, which can help with poorly calculated masks and give less " "of a hard edge to the predicted mask. The size is in pixels (calculated from " "a 128px mask). Set to 0 to not apply gaussian blur. This value should be odd, " "if an even number is passed in then it will be rounded to the next odd number.") self.add_item( - section=section, title="mask_threshold", datatype=int, default=4, - min_max=(0, 50), rounding=1, group="mask", + section=section, + title="mask_threshold", + datatype=int, + default=4, + min_max=(0, 50), + rounding=1, + group="mask", info="Sets pixels that are near white to white and near black to black. Set to 0 for " "off.") self.add_item( - section=section, title="learn_mask", datatype=bool, default=False, group="mask", + section=section, + title="learn_mask", + datatype=bool, + default=False, + group="mask", info="Dedicate a portion of the model to learning how to duplicate the input " "mask. Increases VRAM usage in exchange for learning a quick ability to try " "to replicate more complex mask models.") self.add_item( - section=section, title="icnr_init", datatype=bool, - default=False, group="initialization", - info="Use ICNR to tile the default initializer in a repeating pattern. " - "This strategy is designed for pairing with sub-pixel / pixel shuffler " - "to reduce the 'checkerboard effect' in image reconstruction. " - "\n\t https://arxiv.org/ftp/arxiv/papers/1707/1707.02937.pdf") - self.add_item( - section=section, title="conv_aware_init", datatype=bool, - default=False, group="initialization", - info="Use Convolution Aware Initialization for convolutional layers. " - "This can help eradicate the vanishing and exploding gradient problem " - "as well as lead to higher accuracy, lower loss and faster convergence.\nNB:" - "\n\t This can use more VRAM when creating a new model so you may want to " - "lower the batch size for the first run. The batch size can be raised " - "again when reloading the model. " - "\n\t Multi-GPU is not supported for this option, so you should start the model " - "on a single GPU. Once training has started, you can stop training, enable " - "multi-GPU and resume." - "\n\t Building the model will likely take several minutes as the calculations " - "for this initialization technique are expensive. This will only impact starting " - "a new model.") - self.add_item( - section=section, title="reflect_padding", datatype=bool, - default=False, group="network", - info="Use reflection padding rather than zero padding with convolutions. " - "Each convolution must pad the image boundaries to maintain the proper " - "sizing. More complex padding schemes can reduce artifacts at the " - "border of the image." - "\n\t http://www-cs.engr.ccny.cuny.edu/~wolberg/cs470/hw/hw2_pad.txt") - self.add_item( - section=section, title="allow_growth", datatype=bool, default=False, group="network", - fixed=False, - info="[Nvidia Only]. Enable the Tensorflow GPU 'allow_growth' configuration option. " - "This option prevents Tensorflow from allocating all of the GPU VRAM at launch " - "but can lead to higher VRAM fragmentation and slower performance. Should only " - "be enabled if you are receiving errors regarding 'cuDNN fails to initialize' " - "when commencing training.") - self.add_item( - section=section, title="penalized_mask_loss", datatype=bool, - default=True, group="loss", + section=section, + title="penalized_mask_loss", + datatype=bool, + default=True, + group="loss", info="Image loss function is weighted by mask presence. For areas of " "the image without the facial mask, reconstuction errors will be " "ignored while the masked face area is prioritized. May increase " "overall quality by focusing attention on the core face area.") self.add_item( - section=section, title="loss_function", datatype=str, group="loss", + section=section, + title="loss_function", + datatype=str, + group="loss", default="mae", choices=["mae", "mse", "logcosh", "smooth_loss", "l_inf_norm", "ssim", "gmsd", "pixel_gradient_diff"], @@ -192,14 +184,86 @@ def set_globals(self): "between two images. Allows for large color shifts,but maintains the structure " "of the image.\n") self.add_item( - section=section, title="learning_rate", datatype=float, default=5e-5, - min_max=(1e-6, 1e-4), rounding=6, fixed=False, group="optimizer", - info="Learning rate - how fast your network will learn (how large are " - "the modifications to the model weights after one batch of training). " - "Values that are too large might result in model crashes and the " - "inability of the model to find the best solution. " - "Values that are too small might be unable to escape from dead-ends " - "and find the best global minimum.") + section=section, + title="icnr_init", + datatype=bool, + default=False, + group="initialization", + info="Use ICNR to tile the default initializer in a repeating pattern. " + "This strategy is designed for pairing with sub-pixel / pixel shuffler " + "to reduce the 'checkerboard effect' in image reconstruction. " + "\n\t https://arxiv.org/ftp/arxiv/papers/1707/1707.02937.pdf") + self.add_item( + section=section, + title="conv_aware_init", + datatype=bool, + default=False, + group="initialization", + info="Use Convolution Aware Initialization for convolutional layers. " + "This can help eradicate the vanishing and exploding gradient problem " + "as well as lead to higher accuracy, lower loss and faster convergence.\nNB:" + "\n\t This can use more VRAM when creating a new model so you may want to " + "lower the batch size for the first run. The batch size can be raised " + "again when reloading the model. " + "\n\t Multi-GPU is not supported for this option, so you should start the model " + "on a single GPU. Once training has started, you can stop training, enable " + "multi-GPU and resume." + "\n\t Building the model will likely take several minutes as the calculations " + "for this initialization technique are expensive. This will only impact starting " + "a new model.") + self.add_item( + section=section, + title="optimizer", + datatype=str, + gui_radio=True, + group="optimizer", + default="adam", + choices=["adam", "nadam", "rms-prop"], + info="The optimizer to use." + "\n\t adam - Adaptive Moment Optimization. A stochastic gradient descent method " + "that is based on adaptive estimation of first-order and second-order moments." + "\n\t nadam - Adaptive Moment Optimization with Nesterov Momentum. Much like " + "Adam but uses a different formula for calculating momentum." + "\n\t rms-prop - Root Mean Square Propogation. Maintains a moving (discounted) " + "average of the square of the gradients. Divides the gradient by the root of " + "this average.") + self.add_item( + section=section, + title="learning_rate", + datatype=float, + default=5e-5, + min_max=(1e-6, 1e-4), + rounding=6, + fixed=False, + group="optimizer", + info="Learning rate - how fast your network will learn (how large are the " + "modifications to the model weights after one batch of training). Values that " + "are too large might result in model crashes and the inability of the model to " + "find the best solution. Values that are too small might be unable to escape " + "from dead-ends and find the best global minimum.") + self.add_item( + section=section, + title="reflect_padding", + datatype=bool, + default=False, + group="network", + info="Use reflection padding rather than zero padding with convolutions. " + "Each convolution must pad the image boundaries to maintain the proper " + "sizing. More complex padding schemes can reduce artifacts at the " + "border of the image." + "\n\t http://www-cs.engr.ccny.cuny.edu/~wolberg/cs470/hw/hw2_pad.txt") + self.add_item( + section=section, + title="allow_growth", + datatype=bool, + default=False, + group="network", + fixed=False, + info="[Nvidia Only]. Enable the Tensorflow GPU 'allow_growth' configuration option. " + "This option prevents Tensorflow from allocating all of the GPU VRAM at launch " + "but can lead to higher VRAM fragmentation and slower performance. Should only " + "be enabled if you are receiving errors regarding 'cuDNN fails to initialize' " + "when commencing training.") def load_module(self, filename, module_path, plugin_type): """ Load the defaults module and add defaults """ diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index de4dcd9e1d..d0c3341484 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -20,7 +20,7 @@ from keras import backend as K from keras.layers import Input from keras.models import load_model, Model as KModel -from keras.optimizers import Adam +from keras.optimizers import Adam, Nadam, RMSprop from lib.serializer import get_serializer from lib.model.backup_restore import Backup @@ -367,56 +367,20 @@ def snapshot(self): def _compile_model(self): """ Compile the model to include the Optimizer and Loss Function(s). """ logger.debug("Compiling Model") - optimizer = self._get_optimizer() + + optimizer = _Optimizer(self.config["optimizer"], + self.config["learning_rate"], + self.config.get("clipnorm", False), + self._args).optimizer + if self._settings.use_mixed_precision: + optimizer = self._settings.LossScaleOptimizer(optimizer, loss_scale="dynamic") + loss = _Loss(self._model.inputs, self._model.outputs) self._model.compile(optimizer=optimizer, loss=loss.functions) if not self._is_predict: self._state.add_session_loss_names(loss.names) logger.debug("Compiled Model: %s", self._model) - def _get_optimizer(self): - """ Return a Keras Adam Optimizer with user selected parameters. - - Returns - ------- - :class:`keras.optimizers.Adam` - An Adam Optimizer with the given user settings - - Notes - ----- - Clip-norm is ballooning VRAM usage, which is not expected behavior and may be a bug in - Keras/Tensorflow. - - PlaidML has a bug regarding the clip-norm parameter See: - https://github.com/plaidml/plaidml/issues/228. We workaround by simply not adding this - parameter for AMD backend users. - """ - kwargs = dict(beta_1=0.5, beta_2=0.99) - - learning_rate = "lr" if get_backend() == "amd" else "learning_rate" - kwargs[learning_rate] = self.config.get("learning_rate", 5e-5) - - clipnorm = self.config.get("clipnorm", False) - if clipnorm and (self._args.distributed or self._args.mixed_precision): - logger.warning("Clipnorm has been selected, but is unsupported when using distributed " - "or mixed_precision training, so has been disabled. If you wish to " - "enable clipnorm, then you must disable these options.") - clipnorm = False - if clipnorm and get_backend() == "amd": - # TODO add clipnorm in for plaidML when it is fixed upstream. Still not fixed in - # release 0.7.0. - logger.warning("Due to a bug in plaidML, clipnorm cannot be used on AMD backends so " - "has been disabled") - clipnorm = False - if clipnorm: - kwargs["clipnorm"] = 1.0 - - retval = Adam(**kwargs) - if self._settings.use_mixed_precision: - retval = self._settings.LossScaleOptimizer(retval, loss_scale="dynamic") - logger.debug("Optimizer: %s, kwargs: %s", retval, kwargs) - return retval - def _legacy_mapping(self): # pylint:disable=no-self-use """ The mapping of separate model files to single model layers for transferring of legacy weights. @@ -809,6 +773,84 @@ def strategy_scope(self): return retval +class _Optimizer(): # pylint:disable=too-few-public-methods + """ Obtain the selected optimizer with the appropriate keyword arguments. + + Parameters + ---------- + optimizer: str + The selected optimizer name for the plugin + learning_rate: float + The selected learning rate to use + clipnorm: bool + Whether to clip gradients to avoid exploding/vanishing gradients + arguments: :class:`argparse.Namespace` + The arguments that were passed to the train or convert process as generated from + Faceswap's command line arguments + """ + def __init__(self, optimizer, learning_rate, clipnorm, arguments): + logger.debug("Initializing %s: (optimizer: %s, learning_rate: %s, clipnorm: %s, " + "arguments: %s", self.__class__.__name__, optimizer, learning_rate, clipnorm, + arguments) + optimizers = {"adam": Adam, "nadam": Nadam, "rms-prop": RMSprop} + self._optimizer = optimizers[optimizer] + + base_kwargs = {"adam": dict(beta_1=0.5, beta_2=0.99), + "nadam": dict(beta_1=0.5, beta_2=0.99), + "rms-prop": dict()} + self._kwargs = base_kwargs[optimizer] + + self._configure(learning_rate, clipnorm, arguments) + logger.verbose("Using %s optimizer", optimizer.title()) + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def optimizer(self): + """ :class:`keras.optimizers.Optimizer`: The requested optimizer. """ + return self._optimizer(**self._kwargs) + + def _configure(self, learning_rate, clipnorm, arguments): + """ Configure the optimizer based on user settings. + + Parameters + ---------- + learning_rate: float + The selected learning rate to use + clipnorm: bool + Whether to clip gradients to avoid exploding/vanishing gradients + arguments: :class:`argparse.Namespace` + The arguments that were passed to the train or convert process as generated from + Faceswap's command line arguments + + Notes + ----- + Clip-norm is ballooning VRAM usage, which is not expected behavior and may be a bug in + Keras/Tensorflow. + + PlaidML has a bug regarding the clip-norm parameter See: + https://github.com/plaidml/plaidml/issues/228. We workaround by simply not adding this + parameter for AMD backend users. + """ + lr_key = "lr" if get_backend() == "amd" else "learning_rate" + self._kwargs[lr_key] = learning_rate + + if clipnorm and (arguments.distributed or arguments.mixed_precision): + logger.warning("Clipnorm has been selected, but is unsupported when using distributed " + "or mixed_precision training, so has been disabled. If you wish to " + "enable clipnorm, then you must disable these options.") + clipnorm = False + if clipnorm and get_backend() == "amd": + # TODO add clipnorm in for plaidML when it is fixed upstream. Still not fixed in + # release 0.7.0. + logger.warning("Due to a bug in plaidML, clipnorm cannot be used on AMD backends so " + "has been disabled") + clipnorm = False + if clipnorm: + self._kwargs["clipnorm"] = 1.0 + + logger.debug("optimizer kwargs: %s", self._kwargs) + + class _Loss(): """ Holds loss names and functions for an Autoencoder. diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 70f95b6a97..795722d833 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -150,7 +150,7 @@ def _set_tensorboard(self): embeddings_metadata=None) tensorboard.set_model(self._model.model) tensorboard.on_train_begin(0) - logger.info("Enabled TensorBoard Logging") + logger.verbose("Enabled TensorBoard Logging") return tensorboard def train_one_step(self, viewer, timelapse_kwargs): From 725d8649752057cfcf7ba485772007c1805406a8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 17 Aug 2020 09:24:37 +0100 Subject: [PATCH 265/981] Training - Move Mixed Precision to model config --- lib/cli/args.py | 16 ---------------- plugins/train/_config.py | 15 +++++++++++++++ plugins/train/model/_base.py | 18 ++++++++++++------ 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index ca430dc630..85f640a27d 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -969,22 +969,6 @@ def get_argument_list(): backend="nvidia", group="training", help="Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs.")) - argument_list.append(dict( - opts=("-mp", "--mixed-precision"), - action="store_true", - dest="mixed_precision", - default=False, - backend="nvidia", - group="training", - help="R|NVIDIA GPUs can run operations in float16 faster than in float32. Mixed " - "precision allows you to use a mix of float16 with float32, to get the " - "performance benefits from float16 and the numeric stability benefits from " - "float32.\nWhile mixed precision will run on most Nvidia models, it will only " - "speed up training on more recent GPUs. Those with compute capability 7.0 or " - "higher will see the greatest performance benefit from mixed precision because " - "they have Tensor Cores. Older GPUs offer no math performance benefit for using " - "mixed precision, however memory and bandwidth savings can enable some speedups. " - "Generally RTX GPUs and later will offer the most benefit.")) argument_list.append(dict( opts=("-s", "--save-interval"), action=Slider, diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 933c3149be..637de3c406 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -264,6 +264,21 @@ def set_globals(self): "but can lead to higher VRAM fragmentation and slower performance. Should only " "be enabled if you are receiving errors regarding 'cuDNN fails to initialize' " "when commencing training.") + self.add_item( + section=section, + title="mixed_precision", + datatype=bool, + default=False, + group="network", + info="R|[Nvidia Only], NVIDIA GPUs can run operations in float16 faster than in " + "float32. Mixed precision allows you to use a mix of float16 with float32, to " + "get the performance benefits from float16 and the numeric stability benefits " + "from float32.\nWhile mixed precision will run on most Nvidia models, it will " + "only speed up training on more recent GPUs. Those with compute capability 7.0 " + "or higher will see the greatest performance benefit from mixed precision " + "because they have Tensor Cores. Older GPUs offer no math performance benefit " + "for using mixed precision, however memory and bandwidth savings can enable some " + "speedups. Generally RTX GPUs and later will offer the most benefit.") def load_module(self, filename, module_path, plugin_type): """ Load the defaults module and add defaults """ diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index d0c3341484..5db4191eff 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -117,12 +117,15 @@ def __init__(self, model_dir, arguments, training_image_size=256, predict=False) self._io = _IO(self, model_dir, self._is_predict) self._check_multiple_models() - self._settings = _Settings(self._args, self.config["allow_growth"], self._is_predict) self._state = State(model_dir, self.name, self._config_changeable_items, False if self._is_predict else self._args.no_logs, training_image_size) + self._settings = _Settings(self._args, + self.config["mixed_precision"], + self.config["allow_growth"], + self._is_predict) logger.debug("Initialized ModelBase (%s)", self.__class__.__name__) @@ -592,18 +595,21 @@ class _Settings(): arguments: :class:`argparse.Namespace` The arguments that were passed to the train or convert process as generated from Faceswap's command line arguments + mixed_precision: bool + ``True`` if Mixed Precision training should be used otherwise ``False`` allow_growth: bool ``True`` if the Tensorflow allow_growth parameter should be set otherwise ``False`` is_predict: bool, optional ``True`` if the model is being loaded for inference, ``False`` if the model is being loaded for training. Default: ``False`` """ - def __init__(self, arguments, allow_growth, is_predict): - logger.debug("Initializing %s: (arguments: %s, allow_growth: %s, is_predict: %s)", - self.__class__.__name__, arguments, allow_growth, is_predict) + def __init__(self, arguments, mixed_precision, allow_growth, is_predict): + logger.debug("Initializing %s: (arguments: %s, mixed_precision: %s, allow_growth: %s, " + "is_predict: %s)", self.__class__.__name__, arguments, mixed_precision, + allow_growth, is_predict) self._set_tf_settings(allow_growth, arguments.exclude_gpus) - use_mixed_precision = not is_predict and arguments.mixed_precision + use_mixed_precision = not is_predict and mixed_precision and get_backend() == "nvidia" if use_mixed_precision: self._mixed_precision = tf.keras.mixed_precision.experimental else: @@ -698,7 +704,7 @@ def _set_keras_mixed_precision(self, use_mixed_precision, skip_check): guaranteed failure when limiting GPU devices """ logger.debug("use_mixed_precision: %s, skip_check: %s", use_mixed_precision, skip_check) - if get_backend() != "nvidia" or not use_mixed_precision: + if not use_mixed_precision: logger.debug("Not enabling 'mixed_precision' (backend: %s, use_mixed_precision: %s)", get_backend(), use_mixed_precision) return False From 030dadfef372ecab16c55102b75397ae83802746 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 17 Aug 2020 23:52:12 +0100 Subject: [PATCH 266/981] Extract - Fix VGG Obstructed Masker for TF2 --- plugins/extract/mask/vgg_obstructed.py | 199 ++++++++++++++++++++++--- 1 file changed, 179 insertions(+), 20 deletions(-) diff --git a/plugins/extract/mask/vgg_obstructed.py b/plugins/extract/mask/vgg_obstructed.py index 03e8776873..e4ddf0bb59 100644 --- a/plugins/extract/mask/vgg_obstructed.py +++ b/plugins/extract/mask/vgg_obstructed.py @@ -1,20 +1,10 @@ #!/usr/bin/env python3 -""" VGG Obstructed face mask plugin - -Architecture and Pre-Trained Model based on... -On Face Segmentation, Face Swapping, and Face Perception -https://arxiv.org/abs/1704.06729 - -Source Implementation... -https://github.com/YuvalNirkin/face_segmentation - -Model file sourced from... -https://github.com/YuvalNirkin/face_segmentation/releases/download/1.0/face_seg_fcn8s.zip - -Caffe model re-implemented in Keras by Kyle Vrooman -""" +""" VGG Obstructed face mask plugin """ import numpy as np +from keras.layers import (Add, Conv2D, Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, + MaxPooling2D, ZeroPadding2D) + from lib.model.session import KSession from ._base import Masker, logger @@ -33,12 +23,9 @@ def __init__(self, **kwargs): self.batchsize = self.config["batch-size"] def init_model(self): - self.model = KSession(self.name, - self.model_path, - model_kwargs=dict(), - allow_growth=self.config["allow_growth"], - exclude_gpus=self._exclude_gpus) - self.model.load_model() + self.model = VGGObstructed(self.model_path, + allow_growth=self.config["allow_growth"], + exclude_gpus=self._exclude_gpus) self.model.append_softmax_activation(layer_index=-1) placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), dtype="float32") @@ -60,3 +47,175 @@ def predict(self, batch): def process_output(self, batch): """ Compile found faces for output """ return batch + + +class VGGObstructed(KSession): + """ VGG Obstructed mask for Faceswap. + + Caffe model re-implemented in Keras by Kyle Vrooman. + Re-implemented for Tensorflow 2 by TorzDF + + Parameters + ---------- + model_path: str + The path to the keras model file + allow_growth: bool + Enable the Tensorflow GPU allow_growth configuration option. This option prevents + Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation and + slower performance + exclude_gpus: list + A list of indices correlating to connected GPUs that Tensorflow should not use. Pass + ``None`` to not exclude any GPUs + + References + ---------- + On Face Segmentation, Face Swapping, and Face Perception (https://arxiv.org/abs/1704.06729) + Source Implementation: https://github.com/YuvalNirkin/face_segmentation + Model file sourced from: + https://github.com/YuvalNirkin/face_segmentation/releases/download/1.0/face_seg_fcn8s.zip + """ + def __init__(self, model_path, allow_growth, exclude_gpus): + super().__init__("VGG Obstructed", + model_path, + allow_growth=allow_growth, + exclude_gpus=exclude_gpus) + self.define_model(self._model_definition) + self.load_model_weights() + + @classmethod + def _model_definition(cls): + """ Definition of the VGG Obstructed Model. + + Returns + ------- + tuple + The tensor input to the model and tensor output to the model for compilation by + :func`define_model` + """ + input_ = Input(shape=(500, 500, 3)) + var_x = ZeroPadding2D(padding=((100, 100), (100, 100)))(input_) + + var_x = _ConvBlock(1, 64, 2)(var_x) + var_x = _ConvBlock(2, 128, 2)(var_x) + var_x = _ConvBlock(3, 256, 3)(var_x) + + score_pool3 = _ScorePool(3, 0.0001, 9)(var_x) + var_x = _ConvBlock(4, 512, 3)(var_x) + score_pool4 = _ScorePool(4, 0.01, 5)(var_x) + var_x = _ConvBlock(5, 512, 3)(var_x) + + var_x = Conv2D(4096, 7, padding="valid", activation="relu", name="fc6")(var_x) + var_x = Dropout(rate=0.5)(var_x) + var_x = Conv2D(4096, 1, padding="valid", activation="relu", name="fc7")(var_x) + var_x = Dropout(rate=0.5)(var_x) + + var_x = Conv2D(21, 1, padding="valid", activation="linear", name="score_fr")(var_x) + var_x = Conv2DTranspose(21, + 4, + strides=2, + activation="linear", + use_bias=False, + name="upscore2")(var_x) + + var_x = Add()([var_x, score_pool4]) + var_x = Conv2DTranspose(21, + 4, + strides=2, + activation="linear", + use_bias=False, + name="upscore_pool4")(var_x) + + var_x = Add()([var_x, score_pool3]) + var_x = Conv2DTranspose(21, + 16, + strides=8, + activation="linear", + use_bias=False, + name="upscore8")(var_x) + var_x = Cropping2D(cropping=((31, 37), (31, 37)), name="score")(var_x) + return input_, var_x + + +class _ConvBlock(): # pylint:disable=too-few-public-methods + """ Convolutional loop with max pooling layer for VGG Obstructed. + + Parameters + ---------- + level: int + For naming. The current level for this convolutional loop + filters: int + The number of filters that should appear in each Conv2D layer + iterations: int + The number of consecutive Conv2D layers to create + """ + def __init__(self, level, filters, iterations): + self._name = "conv{}_".format(level) + self._level = level + self._filters = filters + self._iterator = range(1, iterations + 1) + + def __call__(self, inputs): + """ Call the convolutional loop. + + Parameters + ---------- + inputs: tensor + The input tensor to the block + + Returns + ------- + tensor + The output tensor from the convolutional block + """ + var_x = inputs + for i in self._iterator: + padding = "valid" if self._level == i == 1 else "same" + var_x = Conv2D(self._filters, + 3, + padding=padding, + activation="relu", + name="{}{}".format(self._name, i))(var_x) + var_x = MaxPooling2D(padding="same", + strides=(2, 2), + name="pool{}".format(self._level))(var_x) + return var_x + + +class _ScorePool(): # pylint:disable=too-few-public-methods + """ Cropped scaling of the pooling layer. + + Parameters + ---------- + level: int + For naming. The current level for this score pool + scale: float + The scaling to apply to the pool + crop: int + The amount of 2D cropping to apply + """ + def __init__(self, level, scale, crop): + self._name = "_pool{}".format(level) + self._cropping = ((crop, crop), (crop, crop)) + self._scale = scale + + def __call__(self, inputs): + """ Score pool block. + + Parameters + ---------- + inputs: tensor + The input tensor to the block + + Returns + ------- + tensor + The output tensor from the score pool block + """ + var_x = Lambda(lambda x: x * self._scale, name="scale" + self._name)(inputs) + var_x = Conv2D(21, + 1, + padding="valid", + activation="linear", + name="score" + self._name)(var_x) + var_x = Cropping2D(cropping=self._cropping, name="score" + self._name + "c")(var_x) + return var_x From 956cfdaabbbf1075d485c46beecbff168a43e291 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 18 Aug 2020 08:56:02 +0100 Subject: [PATCH 267/981] Training: Catch too few images in training folders and error out --- scripts/train.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/scripts/train.py b/scripts/train.py index 4763b4f864..32e5d7956c 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -120,8 +120,38 @@ def _get_images(self): logger.info("Model B Directory: %s", self._args.input_b) logger.debug("Got image paths: %s", [(key, str(len(val)) + " images") for key, val in images.items()]) + self._validate_image_counts(images) return images + @classmethod + def _validate_image_counts(cls, images): + """ Validate that there are sufficient images to commence training without raising an + error. + + Confirms that there are at least 24 images in each folder. Whilst this is not enough images + to train a Neural Network to any successful degree, it should allow the process to train + without raising errors when generating previews. + + A warning is raised if there are fewer than 250 images on any side. + + Parameters + ---------- + images: dict + The image paths for each side. The key is the side, the value is the list of paths + for that side. + """ + counts = {side: len(paths) for side, paths in images.items()} + msg = ("You need to provide a significant number of images to successfully train a Neural " + "Network. Aim for between 500 - 5000 images per side.") + if any(count < 25 for count in counts.values()): + logger.error("At least one of your input folders contains fewer than 25 images.") + logger.error(msg) + sys.exit(1) + if any(count < 250 for count in counts.values()): + logger.warning("At least one of your input folders contains fewer than 250 images. " + "Results are likely to be poor.") + logger.warning(msg) + def process(self): """ The entry point for triggering the Training Process. From 7f061f7999b7d83c853a4e295502ffd85446bd72 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 18 Aug 2020 15:08:42 +0100 Subject: [PATCH 268/981] bugfix: Training - Fix clipnorm check for mixed precision --- plugins/train/model/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 5db4191eff..405ccb569e 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -840,7 +840,7 @@ def _configure(self, learning_rate, clipnorm, arguments): lr_key = "lr" if get_backend() == "amd" else "learning_rate" self._kwargs[lr_key] = learning_rate - if clipnorm and (arguments.distributed or arguments.mixed_precision): + if clipnorm and (arguments.distributed or _CONFIG["mixed_precision"]): logger.warning("Clipnorm has been selected, but is unsupported when using distributed " "or mixed_precision training, so has been disabled. If you wish to " "enable clipnorm, then you must disable these options.") From d27b0798d39d57d2bb14d3b7dcd288bb4236ea16 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 18 Aug 2020 15:44:15 +0100 Subject: [PATCH 269/981] Training - Catch AMD OOM Errors --- lib/plaidml_utils.py | 18 ++++++++++++++++-- plugins/train/trainer/_base.py | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/lib/plaidml_utils.py b/lib/plaidml_utils.py index 74d4da8030..706a9f6fbf 100644 --- a/lib/plaidml_utils.py +++ b/lib/plaidml_utils.py @@ -7,12 +7,26 @@ def pad(data, paddings, mode="CONSTANT", name=None, constant_value=0): """ PlaidML Pad """ - # TODO: use / impl other padding method when required + # TODO: use / implement other padding method when required # CONSTANT -> SpatialPadding ? | Doesn't support first and last axis + # no support for constant_value - # SYMMETRIC -> Requires impl ? + # SYMMETRIC -> Requires implement ? if mode.upper() != "REFLECT": raise NotImplementedError("pad only supports mode == 'REFLECT'") if constant_value != 0: raise NotImplementedError("pad does not support constant_value != 0") return plaidml.op.reflection_padding(data, paddings) + + +def is_plaidml_error(error): + """ Test whether the given exception is a plaidml Exception. + + error: :class:`Exception` + The generated error + + Returns + ------- + bool + ``True`` if the given error has been generated from plaidML otherwise ``False`` + """ + return isinstance(error, plaidml.exceptions.PlaidMLError) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 795722d833..c01bc450e5 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -207,7 +207,22 @@ def train_one_step(self, viewer, timelapse_kwargs): "\n4) Use a more lightweight model, or select the model's 'LowMem' option " "(in config) if it has one.") raise FaceswapError(msg) from err - + except Exception as err: + if get_backend() == "amd": + # pylint:disable=import-outside-toplevel + from lib.plaidml_utils import is_plaidml_error + if (is_plaidml_error(err) and + "CL_MEM_OBJECT_ALLOCATION_FAILURE" in str(err).upper()): + msg = ("You do not have enough GPU memory available to train the selected " + "model at the selected settings. You can try a number of things:" + "\n1) Close any other application that is using your GPU (web browsers " + "are particularly bad for this)." + "\n2) Lower the batchsize (the amount of images fed into the model " + "each iteration)." + "\n3) Use a more lightweight model, or select the model's 'LowMem' " + "option (in config) if it has one.") + raise FaceswapError(msg) from err + raise self._log_tensorboard(loss) loss = self._collate_and_store_loss(loss[1:]) self._print_loss(loss) From f897562a09c7dad3f7acb9c4729a39b043bd7f5d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 18 Aug 2020 19:10:56 +0100 Subject: [PATCH 270/981] Set minimum python version to 3.7 --- INSTALL.md | 8 ++++---- faceswap.py | 6 +++--- setup.py | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 9c0cc63286..719024b4ff 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -95,7 +95,7 @@ Reboot your PC, so that everything you have just installed gets registered. - Give it the name: faceswap - **IMPORTANT**: Select python version 3.8 - Hit "Create" (NB: This may take a while as it will need to download Python) -![Anaconda virtual env setup](https://i.imgur.com/59RHnLs.png) +![Anaconda virtual env setup](https://i.imgur.com/CLIDDfa.png) #### Entering your virtual environment To enter the virtual environment: @@ -155,7 +155,7 @@ Obtain git for your distribution from the [git website](https://git-scm.com/down The recommended install method is to use a Conda3 Environment as this will handle the installation of Nvidia's CUDA and cuDNN straight into your Conda Environment. This is by far the easiest and most reliable way to setup the project. - MiniConda3 is recommended: [MiniConda3](https://docs.conda.io/en/latest/miniconda.html) -Alternatively you can install Python (>= 3.6-3.8 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Tensorflow yourself, make sure you install no higher than version 10.0 of CUDA and 7.5.x of CUDNN. +Alternatively you can install Python (>= 3.7-3.8 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Tensorflow yourself, make sure you install no higher than version 10.0 of CUDA and 7.5.x of CUDNN. - Python distributions: - apt/yum install python3 (Linux) - [Installer](https://www.python.org/downloads/release/python-368/) (Windows) @@ -193,7 +193,7 @@ CUDA with Docker in 20 minutes. INFO The tool provides tips for installation and installs required python packages INFO Setup in Linux 4.14.39-1-MANJARO -INFO Installed Python: 3.6.5 64bit +INFO Installed Python: 3.7.5 64bit INFO Installed PIP: 10.0.1 Enable Docker? [Y/n] INFO Docker Enabled @@ -241,7 +241,7 @@ A successful setup log, without docker. INFO The tool provides tips for installation and installs required python packages INFO Setup in Linux 4.14.39-1-MANJARO -INFO Installed Python: 3.6.5 64bit +INFO Installed Python: 3.7.5 64bit INFO Installed PIP: 10.0.1 Enable Docker? [Y/n] n INFO Docker Disabled diff --git a/faceswap.py b/faceswap.py index 9f60adce78..fa755a8307 100755 --- a/faceswap.py +++ b/faceswap.py @@ -6,9 +6,9 @@ from lib.config import generate_configs if sys.version_info[0] < 3: - raise Exception("This program requires at least python3.6") -if sys.version_info[0] == 3 and sys.version_info[1] < 6: - raise Exception("This program requires at least python3.6") + raise Exception("This program requires at least python3.7") +if sys.version_info[0] == 3 and sys.version_info[1] < 7: + raise Exception("This program requires at least python3.7") _PARSER = args.FullHelpArgumentParser() diff --git a/setup.py b/setup.py index 62f43e80ea..60ffdf4d48 100755 --- a/setup.py +++ b/setup.py @@ -166,9 +166,9 @@ def check_python(self): self.output.info("Installed Python: {0} {1}".format(self.py_version[0], self.py_version[1])) if not (self.py_version[0].split(".")[0] == "3" - and self.py_version[0].split(".")[1] in ("6", "7", "8") + and self.py_version[0].split(".")[1] in ("7", "8") and self.py_version[1] == "64bit") and not self.updater: - self.output.error("Please run this script with Python version 3.6, 3.7 or 3.8 " + self.output.error("Please run this script with Python version 3.7 or 3.8 " "64bit and try again.") sys.exit(1) From 9c5568f887f8fadb7228a573174bf0752a7d0def Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 18 Aug 2020 22:26:29 +0100 Subject: [PATCH 271/981] Bugfix - Models.dfl_h128 --- plugins/train/model/dfl_h128.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/train/model/dfl_h128.py b/plugins/train/model/dfl_h128.py index 3afcf408c4..a11ef6d983 100644 --- a/plugins/train/model/dfl_h128.py +++ b/plugins/train/model/dfl_h128.py @@ -27,7 +27,7 @@ def encoder(self): var_x = Dense(8 * 8 * self.encoder_dim)(var_x) var_x = Reshape((8, 8, self.encoder_dim))(var_x) var_x = UpscaleBlock(self.encoder_dim)(var_x) - return KerasModel(input_, var_x) + return KerasModel(input_, var_x, name=self.name) def decoder(self, side): """ DFL H128 Decoder """ From baa28669bc1e911c57d7d6f258a9aea50620e35e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 19 Aug 2020 08:07:53 +0100 Subject: [PATCH 272/981] bugfix - Update Dependencies - Avoid constantly trying to redownload Tensorflow --- setup.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/setup.py b/setup.py index 60ffdf4d48..b68875b422 100755 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Install packages for faceswap.py """ -# >>> ENV +# >>> Environment import ctypes import json import locale @@ -15,9 +15,9 @@ from pkg_resources import parse_requirements INSTALL_FAILED = False -# Revisions of tensorflow-gpu and cuda/cudnn requirements +# Revisions of tensorflow GPU and cuda/cudnn requirements TENSORFLOW_REQUIREMENTS = {">=2.2.0,<2.3.0": ["10.1", "7.6"]} -# Mapping of Python packages to their conda names if different from pypi or in non-default channel +# Mapping of Python packages to their conda names if different from pip or in non-default channel CONDA_MAPPING = { # "opencv-python": ("opencv", "conda-forge"), # Periodic issues with conda-forge opencv "fastcluster": ("fastcluster", "conda-forge"), @@ -64,12 +64,12 @@ def encoding(self): @property def os_version(self): - """ Get OS Verion """ + """ Get OS Version """ return platform.system(), platform.release() @property def py_version(self): - """ Get Python Verion """ + """ Get Python Version """ return platform.python_version(), platform.architecture()[0] @property @@ -104,8 +104,13 @@ def is_virtualenv(self): return retval def process_arguments(self): - """ Process any cli arguments """ - for arg in sys.argv: + """ Process any cli arguments and dummy in cli arguments if calling from updater. """ + args = [arg for arg in sys.argv] # pylint:disable=unnecessary-comprehension + if self.updater: + from lib.utils import get_backend # pylint:disable=import-outside-toplevel + args.append("--{}".format(get_backend())) + + for arg in args: if arg == "--installer": self.is_installer = True if arg == "--nvidia": @@ -173,7 +178,7 @@ def check_python(self): sys.exit(1) def output_runtime_info(self): - """ Output runtime info """ + """ Output run time info """ if self.is_conda: self.output.info("Running in Conda") if self.is_virtualenv: @@ -193,7 +198,7 @@ def check_pip(self): def upgrade_pip(self): """ Upgrade pip to latest version """ if not self.is_conda: - # Don't do this with Conda, as we must use conda's pip + # Don't do this with Conda, as we must use Conda version of pip self.output.info("Upgrading pip...") pipexe = [sys.executable, "-m", "pip"] pipexe.extend(["install", "--no-cache-dir", "-qq", "--upgrade"]) @@ -246,8 +251,8 @@ def update_tf_dep(self): tf_ver = key break if tf_ver: - # Remove the version of tensorflow in requirements.txt and add the correct version that - # corresponds to the installed Cuda/cuDNN versions + # Remove the version of tensorflow in requirements file and add the correct version + # that corresponds to the installed Cuda/cuDNN versions self.required_packages = [pkg for pkg in self.required_packages if not pkg.startswith("tensorflow-gpu")] tf_ver = "tensorflow-gpu{}".format(tf_ver) @@ -382,7 +387,7 @@ def __init__(self, environment): @property def cuda_keys_windows(self): """ Return the OS Environ CUDA Keys for Windows """ - return [key for key in os.environ.keys() if key.lower().startswith("cuda_path_v")] + return [key for key in os.environ if key.lower().startswith("cuda_path_v")] def amd_ask_enable(self): """ Enable or disable Plaidml for AMD""" @@ -407,7 +412,7 @@ def docker_ask_enable(self): self.env.enable_docker = False def docker_confirm(self): - """ Warn if nvidia-docker on non-linux system """ + """ Warn if nvidia-docker on non-Linux system """ self.output.warning("Nvidia-Docker is only supported on Linux.\r\n" "Only CPU is supported in Docker for your system") self.docker_ask_enable() @@ -533,7 +538,7 @@ def cudnn_check(self): @staticmethod def cudnn_checkfiles_linux(): - """ Return the checkfile locations for linux """ + """ Return the check-file locations for Linux """ chk = os.popen("ldconfig -p | grep -P \"libcudnn.so.\\d+\" | head -n 1").read() chk = chk.strip().replace("libcudnn.so.", "") if not chk: @@ -546,7 +551,7 @@ def cudnn_checkfiles_linux(): return cudnn_checkfiles def cudnn_checkfiles_windows(self): - """ Return the checkfile locations for windows """ + """ Return the check-file locations for Windows """ # TODO A more reliable way of getting the windows location if not self.env.cuda_path: return list() From 45d699555e1a585ac104726439098713c527ebf8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 19 Aug 2020 10:39:49 +0100 Subject: [PATCH 273/981] bugfix - Extract - VGG Clear Mask - Fix for TF2 --- plugins/extract/mask/vgg_clear.py | 191 +++++++++++++++++++++++++++--- 1 file changed, 173 insertions(+), 18 deletions(-) diff --git a/plugins/extract/mask/vgg_clear.py b/plugins/extract/mask/vgg_clear.py index 2b1a775429..68cfe9619d 100644 --- a/plugins/extract/mask/vgg_clear.py +++ b/plugins/extract/mask/vgg_clear.py @@ -1,20 +1,10 @@ #!/usr/bin/env python3 -""" VGG Clear face mask plugin - -Architecture and Pre-Trained Model based on... -On Face Segmentation, Face Swapping, and Face Perception -https://arxiv.org/abs/1704.06729 - -Source Implementation... -https://github.com/YuvalNirkin/face_segmentation - -Model file sourced from... -https://github.com/YuvalNirkin/face_segmentation/releases/download/1.1/face_seg_fcn8s_300_no_aug.zip - -Caffe model re-implemented in Keras by Kyle Vrooman -""" +""" VGG Clear face mask plugin. """ import numpy as np +from keras.layers import (Add, Conv2D, Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, + MaxPooling2D, ZeroPadding2D) + from lib.model.session import KSession from ._base import Masker, logger @@ -33,12 +23,9 @@ def __init__(self, **kwargs): self.batchsize = self.config["batch-size"] def init_model(self): - self.model = KSession(self.name, - self.model_path, - model_kwargs=dict(), + self.model = VGGClear(self.model_path, allow_growth=self.config["allow_growth"], exclude_gpus=self._exclude_gpus) - self.model.load_model() self.model.append_softmax_activation(layer_index=-1) placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), dtype="float32") @@ -61,3 +48,171 @@ def predict(self, batch): def process_output(self, batch): """ Compile found faces for output """ return batch + + +class VGGClear(KSession): + """ VGG Clear mask for Faceswap. + + Caffe model re-implemented in Keras by Kyle Vrooman. + Re-implemented for Tensorflow 2 by TorzDF + + Parameters + ---------- + model_path: str + The path to the keras model file + allow_growth: bool + Enable the Tensorflow GPU allow_growth configuration option. This option prevents + Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation and + slower performance + exclude_gpus: list + A list of indices correlating to connected GPUs that Tensorflow should not use. Pass + ``None`` to not exclude any GPUs + + References + ---------- + On Face Segmentation, Face Swapping, and Face Perception (https://arxiv.org/abs/1704.06729) + + Source Implementation: https://github.com/YuvalNirkin/face_segmentation + + Model file sourced from: + https://github.com/YuvalNirkin/face_segmentation/releases/download/1.1/face_seg_fcn8s_300_no_aug.zip + + """ + def __init__(self, model_path, allow_growth, exclude_gpus): + super().__init__("VGG Obstructed", + model_path, + allow_growth=allow_growth, + exclude_gpus=exclude_gpus) + self.define_model(self._model_definition) + self.load_model_weights() + + @classmethod + def _model_definition(cls): + """ Definition of the VGG Obstructed Model. + + Returns + ------- + tuple + The tensor input to the model and tensor output to the model for compilation by + :func`define_model` + """ + input_ = Input(shape=(300, 300, 3)) + var_x = ZeroPadding2D(padding=((100, 100), (100, 100)), name="zero_padding2d_1")(input_) + + var_x = _ConvBlock(1, 64, 2)(var_x) + var_x = _ConvBlock(2, 128, 2)(var_x) + pool3 = _ConvBlock(3, 256, 3)(var_x) + pool4 = _ConvBlock(4, 512, 3)(pool3) + var_x = _ConvBlock(5, 512, 3)(pool4) + + score_pool3 = _ScorePool(3, 0.0001, (9, 8))(pool3) + score_pool4 = _ScorePool(4, 0.01, (5, 5))(pool4) + + var_x = Conv2D(4096, 7, activation="relu", name="fc6")(var_x) + var_x = Dropout(rate=0.5, name="drop6")(var_x) + var_x = Conv2D(4096, 1, activation="relu", name="fc7")(var_x) + var_x = Dropout(rate=0.5, name="drop7")(var_x) + var_x = Conv2D(2, 1, activation="linear", name="score_fr_r")(var_x) + var_x = Conv2DTranspose(2, + 4, + strides=2, + activation="linear", + use_bias=False, name="upscore2_r")(var_x) + + var_x = Add(name="fuse_pool4")([var_x, score_pool4]) + var_x = Conv2DTranspose(2, + 4, + strides=2, + activation="linear", + use_bias=False, + name="upscore_pool4_r")(var_x) + var_x = Add(name="fuse_pool3")([var_x, score_pool3]) + var_x = Conv2DTranspose(2, + 16, + strides=8, + activation="linear", + use_bias=False, + name="upscore8_r")(var_x) + var_x = Cropping2D(cropping=((31, 45), (31, 45)), name="score")(var_x) + return input_, var_x + + +class _ConvBlock(): # pylint:disable=too-few-public-methods + """ Convolutional loop with max pooling layer for VGG Clear. + + Parameters + ---------- + level: int + For naming. The current level for this convolutional loop + filters: int + The number of filters that should appear in each Conv2D layer + iterations: int + The number of consecutive Conv2D layers to create + """ + def __init__(self, level, filters, iterations): + self._name = "conv{}_".format(level) + self._level = level + self._filters = filters + self._iterator = range(1, iterations + 1) + + def __call__(self, inputs): + """ Call the convolutional loop. + + Parameters + ---------- + inputs: tensor + The input tensor to the block + + Returns + ------- + tensor + The output tensor from the convolutional block + """ + var_x = inputs + for i in self._iterator: + padding = "valid" if self._level == i == 1 else "same" + var_x = Conv2D(self._filters, + 3, + padding=padding, + activation="relu", + name="{}{}".format(self._name, i))(var_x) + var_x = MaxPooling2D(padding="same", + strides=(2, 2), + name="pool{}".format(self._level))(var_x) + return var_x + + +class _ScorePool(): # pylint:disable=too-few-public-methods + """ Cropped scaling of the pooling layer. + + Parameters + ---------- + level: int + For naming. The current level for this score pool + scale: float + The scaling to apply to the pool + crop: tuple + The amount of 2D cropping to apply. Tuple of `ints` + """ + def __init__(self, level, scale, crop): + self._name = "_pool{}".format(level) + self._cropping = (crop, crop) + self._scale = scale + + def __call__(self, inputs): + """ Score pool block. + + Parameters + ---------- + inputs: tensor + The input tensor to the block + + Returns + ------- + tensor + The output tensor from the score pool block + """ + var_x = Lambda(lambda x: x * self._scale, name="scale" + self._name)(inputs) + var_x = Conv2D(2, 1, activation="linear", name="score" + self._name + "_r")(var_x) + var_x = Cropping2D(cropping=self._cropping, name="score" + self._name + "c")(var_x) + return var_x From 0a25dff8967dad535f8823d9642c8619ed0a6e9d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 19 Aug 2020 23:51:28 +0100 Subject: [PATCH 274/981] model.config - Make convert batchsize a user configurable option --- plugins/train/_config.py | 12 +++++++++++ scripts/convert.py | 46 ++++++++++++++++++++++------------------ 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 637de3c406..565c6dc576 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -279,6 +279,18 @@ def set_globals(self): "because they have Tensor Cores. Older GPUs offer no math performance benefit " "for using mixed precision, however memory and bandwidth savings can enable some " "speedups. Generally RTX GPUs and later will offer the most benefit.") + self.add_item( + section=section, + title="convert_batchsize", + datatype=int, + default=16, + min_max=(1, 32), + rounding=1, + group="convert", + info="[GPU Only]. The number of faces to feed through the model at once when running " + "the Convert process.\n\nNB: Increasing this figure is unlikely to improve " + "convert speed, however, if you are getting Out of Memory errors, then you may " + "want to reduce the batch size.") def load_module(self, filename, module_path, plugin_type): """ Load the defaults module and add defaults """ diff --git a/scripts/convert.py b/scripts/convert.py index bddfb90b8f..46092f543d 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -645,7 +645,6 @@ class Predict(): def __init__(self, in_queue, queue_size, arguments): logger.debug("Initializing %s: (args: %s, queue_size: %s, in_queue: %s)", self.__class__.__name__, arguments, queue_size, in_queue) - self._batchsize = self._get_batchsize(queue_size) self._args = arguments self._in_queue = in_queue self._out_queue = queue_manager.get_queue("patch") @@ -654,6 +653,7 @@ def __init__(self, in_queue, queue_size, arguments): self._verify_output = False self._model = self._load_model() + self._batchsize = self._get_batchsize(queue_size) self._sizes = self._get_io_sizes() self._coverage_ratio = self._model.coverage_ratio @@ -717,26 +717,6 @@ def _get_io_sizes(self): logger.debug(retval) return retval - @staticmethod - def _get_batchsize(queue_size): - """ Get the batch size for feeding the model. - - Sets the batch size to 1 if inference is being run on CPU, otherwise the minimum of the - :attr:`self._queue_size` and 16. - - Returns - ------- - int - The batch size that the model is to be fed at. - """ - logger.debug("Getting batchsize") - is_cpu = GPUStats().device_count == 0 - batchsize = 1 if is_cpu else 16 - batchsize = min(queue_size, batchsize) - logger.debug("Batchsize: %s", batchsize) - logger.debug("Got batchsize: %s", batchsize) - return batchsize - def _load_model(self): """ Load the Faceswap model. @@ -755,6 +735,30 @@ def _load_model(self): logger.debug("Loaded Model") return model + def _get_batchsize(self, queue_size): + """ Get the batch size for feeding the model. + + Sets the batch size to 1 if inference is being run on CPU, otherwise the minimum of the + input queue size and the model's `convert_batchsize` configuration option. + + Parameters + ---------- + queue_size: int + The queue size that is feeding the predictor + + Returns + ------- + int + The batch size that the model is to be fed at. + """ + logger.debug("Getting batchsize") + is_cpu = GPUStats().device_count == 0 + batchsize = 1 if is_cpu else self._model.config["convert_batchsize"] + batchsize = min(queue_size, batchsize) + logger.debug("Batchsize: %s", batchsize) + logger.debug("Got batchsize: %s", batchsize) + return batchsize + def _get_model_name(self, model_dir): """ Return the name of the Faceswap model used. From ebf84c59e9ef684f3125bc6b5521fb992fdc5ace Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 20 Aug 2020 12:17:03 +0100 Subject: [PATCH 275/981] GUI - Configs - Handle "None" options correctly --- lib/config.py | 14 +++++++------- lib/gui/popup_configure.py | 4 +++- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/config.py b/lib/config.py index 6f81c6f5e5..5cbdc2a6b2 100644 --- a/lib/config.py +++ b/lib/config.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -""" Default configurations for faceswap - Extends out configparser funcionality by checking for default config updates - and returning data in it's correct format """ +""" Default configurations for faceswap. + Extends out :class:`configparser.ConfigParser` functionality by checking for default + configuration updates and returning data in it's correct format """ import logging import os @@ -65,8 +65,8 @@ def set_defaults(self): @property def config_dict(self): - """ Collate global options and requested section into a dictionary - with the correct datatypes """ + """ Collate global options and requested section into a dictionary with the correct + data types """ conf = dict() for sect in ("global", self.section): if sect not in self.config.sections(): @@ -134,10 +134,10 @@ def add_item(self, section=None, title=None, datatype=str, default=None, info=No is_radio is to indicate to the GUI that it should display Radio Buttons rather than combo boxes for multiple choice options. - The 'fixed' parameter is only for training configs. Training configurations + The 'fixed' parameter is only for training configurations. Training configurations are set when the model is created, and then reloaded from the state file. Marking an item as fixed=False indicates that this value can be changed for - existing models, and will overide the value saved in the state file with the + existing models, and will override the value saved in the state file with the updated value in config. The 'Group' parameter allows you to assign the config item to a group in the GUI diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 9cb2b01b72..7ed3bd148c 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -102,12 +102,14 @@ def _get_config(self): if key == "helptext": self._plugin_info[section] = val continue + initial_value = self._config.config_dict[key] + initial_value = "none" if initial_value is None else initial_value conf[category][section][key] = ControlPanelOption( title=key, dtype=val["type"], group=val["group"], default=val["default"], - initial_value=self._config.config_dict.get(key, val["default"]), + initial_value=initial_value, choices=val["choices"], is_radio=val["gui_radio"], rounding=val["rounding"], From 445aa4944c9db67040de92a3f6605e9113759eb7 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 20 Aug 2020 13:21:40 +0100 Subject: [PATCH 276/981] Bugfix - Legacy Models. Set penalized_loss and learn_mask to "False" if mask_type is None --- plugins/train/_config.py | 1 + plugins/train/model/_base.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 565c6dc576..570348cd6f 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -286,6 +286,7 @@ def set_globals(self): default=16, min_max=(1, 32), rounding=1, + fixed=False, group="convert", info="[GPU Only]. The number of faces to feed through the model at once when running " "the Convert process.\n\nNB: Increasing this figure is unlikely to improve " diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 405ccb569e..e282cf7fff 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -301,6 +301,12 @@ def _update_legacy_models(self): filename = self._io._filename # pylint:disable=protected-access logger.info("Saving Tensorflow 2.x model to '%s'", filename) new_model.save(filename) + # Penalized Loss and Learn Mask used to be disabled automatically if a mask wasn't + # selected, so disable it if enabled, but mask_type is None + if self.config["penalized_mask_loss"] and self.config["mask_type"] is None: + self.config["penalized_mask_loss"] = False + if self.config["learn_mask"] and self.config["mask_type"] is None: + self.config["learn_mask"] = False self._state.save() def _validate_input_shape(self): From 619bd415aa2f8aa11d14fd1b74f92a37fa6ad96a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 20 Aug 2020 16:52:51 +0100 Subject: [PATCH 277/981] Catch further AMD OOM errors --- plugins/train/trainer/_base.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index c01bc450e5..6e3ab7a997 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -211,8 +211,9 @@ def train_one_step(self, viewer, timelapse_kwargs): if get_backend() == "amd": # pylint:disable=import-outside-toplevel from lib.plaidml_utils import is_plaidml_error - if (is_plaidml_error(err) and - "CL_MEM_OBJECT_ALLOCATION_FAILURE" in str(err).upper()): + if (is_plaidml_error(err) and ( + "CL_MEM_OBJECT_ALLOCATION_FAILURE" in str(err).upper() or + "enough memory for the current schedule" in str(err).lower())): msg = ("You do not have enough GPU memory available to train the selected " "model at the selected settings. You can try a number of things:" "\n1) Close any other application that is using your GPU (web browsers " From 877b90b60a3aaf797fec84bab453a6ad49a98250 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 21 Aug 2020 15:29:27 +0100 Subject: [PATCH 278/981] bugfix - losses - DSSIM - Don't reduce mean on output --- lib/model/losses_tf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/model/losses_tf.py b/lib/model/losses_tf.py index 3d676e7db5..ff36549cd7 100644 --- a/lib/model/losses_tf.py +++ b/lib/model/losses_tf.py @@ -136,7 +136,7 @@ def call(self, y_true, y_pred): denom = (K.square(u_true) + K.square(u_pred) + self.c_1) * ( var_pred + var_true + self.c_2) ssim /= denom # no need for clipping, c_1 + c_2 make the denorm non-zero - return K.mean((1.0 - ssim) / 2.0) + return (1.0 - ssim) / 2.0 @staticmethod def _preprocess_padding(padding): From cbcd301150a30da76baa29b27026d85def796e3b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 22 Aug 2020 11:02:15 +0000 Subject: [PATCH 279/981] GUI - Fonts - Config - more sensible font selection list - Linux - Attempt to be more consistent on default font --- lib/gui/_config.py | 27 +++++++++++++++++++++------ lib/gui/utils.py | 26 ++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/lib/gui/_config.py b/lib/gui/_config.py index 323ec3e4fd..12f4f7a233 100644 --- a/lib/gui/_config.py +++ b/lib/gui/_config.py @@ -4,7 +4,8 @@ import logging import sys import os -from tkinter import font +from tkinter import font as tk_font +from matplotlib import font_manager from lib.config import FaceswapConfig @@ -91,8 +92,22 @@ def get_commands(): def get_clean_fonts(): - """ Return the font list with any @prefixed or non-Unicode characters stripped - and default prefixed """ - cleaned_fonts = sorted([fnt for fnt in font.families() - if not fnt.startswith("@") and not any([ord(c) > 127 for c in fnt])]) - return ["default"] + cleaned_fonts + """ Return a sane list of fonts for the system that has both regular and bold variants. + + Pre-pend "default" to the beginning of the list. + + Returns + ------- + list: + A list of valid fonts for the system + """ + fmanager = font_manager.FontManager() + fonts = dict() + for font in fmanager.ttflist: + if str(font.weight) in ("400", "normal", "regular"): + fonts.setdefault(font.name, dict())["regular"] = True + if str(font.weight) in ("700", "bold"): + fonts.setdefault(font.name, dict())["bold"] = True + valid_fonts = {key for key, val in fonts.items() if len(val) == 2} + retval = sorted(list(valid_fonts.intersection(tk_font.families()))) + return ["default"] + retval diff --git a/lib/gui/utils.py b/lib/gui/utils.py index a735880d18..ca2a472246 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -2,11 +2,14 @@ """ Utility functions for the GUI """ import logging import os +import platform import sys import tkinter as tk + from tkinter import filedialog from threading import Event, Thread from queue import Queue + import numpy as np from PIL import Image, ImageDraw, ImageTk @@ -767,10 +770,11 @@ class Config(): def __init__(self, root, cli_opts, statusbar, session): logger.debug("Initializing %s: (root %s, cli_opts: %s, statusbar: %s, session: %s)", self.__class__.__name__, root, cli_opts, statusbar, session) + self._default_font = self._set_default_font() self._constants = dict( root=root, scaling_factor=self._get_scaling(root), - default_font=tk.font.nametofont("TkDefaultFont").configure()["family"]) + default_font=self._default_font) self._gui_objects = dict( cli_opts=cli_opts, tk_vars=self._set_tk_vars(), @@ -781,7 +785,6 @@ def __init__(self, root, cli_opts, statusbar, session): command_notebook=None) # set in command.py self._user_config = UserConfig(None) self.session = session - self._default_font = tk.font.nametofont("TkDefaultFont").configure()["family"] logger.debug("Initialized %s", self.__class__.__name__) # Constants @@ -891,6 +894,25 @@ def _get_scaling(root): logger.debug("dpi: %s, scaling: %s'", dpi, scaling) return scaling + @classmethod + def _set_default_font(cls): + """ Set the default font. + + For macOS and Windows, this just pulls back the system default font. + + For Linux, quite often the default is not ideal, so we try to pull a sane default from + installed fonts. + """ + if platform.system() == "Linux": + for family in ("DejaVu Sans", "Noto Sans", "Nimbus Sans"): + if family in tk.font.families(): + logger.debug("Setting default font to: '%s'", family) + tk.font.nametofont("TkDefaultFont").configure(family=family) + tk.font.nametofont("TkHeadingFont").configure(family=family) + tk.font.nametofont("TkMenuFont").configure(family=family) + break + return tk.font.nametofont("TkDefaultFont").configure()["family"] + def set_default_options(self): """ Set the default options for :mod:`lib.gui.projects` From d9809d299741ad9536948bf6cb62a7edca70b19e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 22 Aug 2020 16:07:02 +0100 Subject: [PATCH 280/981] Bugfix - Convert - Swap Model option --- plugins/train/model/_base.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index e282cf7fff..e0a620ac13 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -1348,10 +1348,16 @@ def _get_outputs_dropout(self): outputs = self._config["output_layers"] if get_backend() == "amd": outputs = [outputs[:len(outputs) // 2], outputs[len(outputs) // 2:]] - side_outputs = set(self._filter_node(outputs)[self._output_idx]) - logger.debug("model outputs: %s, side_outputs: %s", outputs, side_outputs) + + output_names = self._filter_node(outputs) + if not all(isinstance(name, list) for name in output_names): + output_names = [[name] for name in output_names] + side_outputs = set(output_names[self._output_idx]) + logger.debug("model outputs: %s, output_names: %s, side_outputs: %s", + outputs, output_names, side_outputs) + outputs_all = {layer - for side in self._filter_node(outputs) + for side in output_names for layer in side} retval = outputs_all.difference(side_outputs) logger.debug("outputs dropout: %s", retval) From e17749440014145ee0f9814d80cae2c612161739 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 22 Aug 2020 16:36:26 +0100 Subject: [PATCH 281/981] requirements - Pin Matplotlib --- _requirements_base.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/_requirements_base.txt b/_requirements_base.txt index af6547d5b7..b2a6159529 100644 --- a/_requirements_base.txt +++ b/_requirements_base.txt @@ -6,7 +6,8 @@ opencv-python>=4.1.2.0 pillow>=7.0.0 scikit-learn>=0.22.0 fastcluster==1.1.26 -matplotlib>=3.0.3 +# matplotlib 3.3.1 breaks custom toolbar in graph popup +matplotlib>=3.0.3,<3.3.0 imageio>=2.8.0 imageio-ffmpeg>=0.4.2 ffmpy==0.2.3 From 8c8e1b2c144571ff44c9de9b823342453e09c8da Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 23 Aug 2020 00:33:25 +0100 Subject: [PATCH 282/981] Training Config - Split Loss to it's own section --- lib/config.py | 4 +- plugins/train/_config.py | 278 +++++++++++++++++++---------------- plugins/train/model/_base.py | 57 ++++--- 3 files changed, 182 insertions(+), 157 deletions(-) diff --git a/lib/config.py b/lib/config.py index 5cbdc2a6b2..031466b258 100644 --- a/lib/config.py +++ b/lib/config.py @@ -68,7 +68,9 @@ def config_dict(self): """ Collate global options and requested section into a dictionary with the correct data types """ conf = dict() - for sect in ("global", self.section): + sections = [sect for sect in self.config.sections() if sect.startswith("global")] + sections.append(self.section) + for sect in sections: if sect not in self.config.sections(): continue for key in self.config[sect]: diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 570348cd6f..baccb86c6f 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -23,7 +23,8 @@ class Config(FaceswapConfig): def set_defaults(self): """ Set the default values for config """ logger.debug("Setting defaults") - self.set_globals() + self._set_globals() + self._set_loss() current_dir = os.path.dirname(__file__) for dirpath, _, filenames in os.walk(current_dir): default_files = [fname for fname in filenames if fname.endswith("_defaults.py")] @@ -35,23 +36,8 @@ def set_defaults(self): for filename in default_files: self.load_module(filename, import_path, plugin_type) - def set_globals(self): - """ - Set the global options for training - - Loss Documentation - MAE https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine - -learners-should-know-4fb140e9d4b0 - MSE https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine - -learners-should-know-4fb140e9d4b0 - LogCosh https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine - -learners-should-know-4fb140e9d4b0 - Smooth L1 https://arxiv.org/pdf/1701.03077.pdf - L_inf_norm https://medium.com/@montjoile/l0-norm-l1-norm-l2-norm-l-infinity - -norm-7a7d18a4f40c - SSIM http://www.cns.nyu.edu/pub/eero/wang03-reprint.pdf - GMSD https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf - """ + def _set_globals(self): + """ Set the global options for training """ logger.debug("Setting global config") section = "global" self.add_section(title=section, @@ -74,115 +60,7 @@ def set_globals(self): "\n\t75.0%% spans from temple to temple." "\n\t87.5%% spans from ear to ear." "\n\t100.0%% is a mugshot.") - self.add_item( - section=section, - title="mask_type", - datatype=str, - default="extended", - choices=PluginLoader.get_available_extractors("mask", add_none=True), - group="mask", - gui_radio=True, - info="The mask to be used for training. If you have selected 'Learn Mask' or " - "'Penalized Mask Loss' you must select a value other than 'none'. The required " - "mask should have been selected as part of the Extract process. If it does not " - "exist in the alignments file then it will be generated prior to training " - "commencing." - "\n\tnone: Don't use a mask." - "\n\tcomponents: 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." - "\n\textended: 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." - "\n\tvgg-clear: Mask designed to provide smart segmentation of mostly frontal " - "faces clear of obstructions. Profile faces and obstructions may result in " - "sub-par performance." - "\n\tvgg-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." - "\n\tunet-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.") - self.add_item( - section=section, - title="mask_blur_kernel", - datatype=int, - min_max=(0, 9), - rounding=1, - default=3, - group="mask", - info="Apply gaussian blur to the mask input. This has the effect of smoothing the " - "edges of the mask, which can help with poorly calculated masks and give less " - "of a hard edge to the predicted mask. The size is in pixels (calculated from " - "a 128px mask). Set to 0 to not apply gaussian blur. This value should be odd, " - "if an even number is passed in then it will be rounded to the next odd number.") - self.add_item( - section=section, - title="mask_threshold", - datatype=int, - default=4, - min_max=(0, 50), - rounding=1, - group="mask", - info="Sets pixels that are near white to white and near black to black. Set to 0 for " - "off.") - self.add_item( - section=section, - title="learn_mask", - datatype=bool, - default=False, - group="mask", - info="Dedicate a portion of the model to learning how to duplicate the input " - "mask. Increases VRAM usage in exchange for learning a quick ability to try " - "to replicate more complex mask models.") - self.add_item( - section=section, - title="penalized_mask_loss", - datatype=bool, - default=True, - group="loss", - info="Image loss function is weighted by mask presence. For areas of " - "the image without the facial mask, reconstuction errors will be " - "ignored while the masked face area is prioritized. May increase " - "overall quality by focusing attention on the core face area.") - self.add_item( - section=section, - title="loss_function", - datatype=str, - group="loss", - default="mae", - choices=["mae", "mse", "logcosh", "smooth_loss", "l_inf_norm", "ssim", "gmsd", - "pixel_gradient_diff"], - info="The loss function to use." - "\n\t MAE - Mean absolute error will guide reconstructions of each pixel " - "towards its median value in the training dataset. Robust to outliers but as " - "a median, it can potentially ignore some infrequent image types in the dataset." - "\n\t MSE - Mean squared error will guide reconstructions of each pixel " - "towards its average value in the training dataset. As an avg, it will be " - "suspectible to outliers and typically produces slightly blurrier results." - "\n\t LogCosh - log(cosh(x)) acts similiar to MSE for small errors and to " - "MAE for large errors. Like MSE, it is very stable and prevents overshoots " - "when errors are near zero. Like MAE, it is robust to outliers. NB: Due to a bug " - "in PlaidML, this loss does not work on AMD cards." - "\n\t Smooth_L1 --- Modification of the MAE loss to correct two of its " - "disadvantages. This loss has improved stability and guidance for small errors." - "\n\t L_inf_norm --- The L_inf norm will reduce the largest individual pixel " - "error in an image. As each largest error is minimized sequentially, the " - "overall error is improved. This loss will be extremely focused on outliers." - "\n\t SSIM - Structural Similarity Index Metric is a perception-based " - "loss that considers changes in texture, luminance, contrast, and local spatial " - "statistics of an image. Potentially delivers more realistic looking images." - "\n\t GMSD - Gradient Magnitude Similarity Deviation seeks to match " - "the global standard deviation of the pixel to pixel differences between two " - "images. Similiar in approach to SSIM. NB: This loss does not currently work on " - "AMD cards." - "\n\t Pixel_Gradient_Difference - Instead of minimizing the difference between " - "the absolute value of each pixel in two reference images, compute the pixel to " - "pixel spatial difference in each image and then minimize that difference " - "between two images. Allows for large color shifts,but maintains the structure " - "of the image.\n") + self.add_item( section=section, title="icnr_init", @@ -293,6 +171,152 @@ def set_globals(self): "convert speed, however, if you are getting Out of Memory errors, then you may " "want to reduce the batch size.") + def _set_loss(self): + """ Set the default loss options. + + Loss Documentation + MAE https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine + -learners-should-know-4fb140e9d4b0 + MSE https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine + -learners-should-know-4fb140e9d4b0 + LogCosh https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine + -learners-should-know-4fb140e9d4b0 + Smooth L1 https://arxiv.org/pdf/1701.03077.pdf + L_inf_norm https://medium.com/@montjoile/l0-norm-l1-norm-l2-norm-l-infinity + -norm-7a7d18a4f40c + SSIM http://www.cns.nyu.edu/pub/eero/wang03-reprint.pdf + GMSD https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf + """ + logger.debug("Setting Loss config") + section = "global.loss" + self.add_section(title=section, + info="Loss configuration options\n" + "Loss is the mechanism by which a Neural Network judges how well it " + "thinks that it is recreating a face." + ADDITIONAL_INFO) + self.add_item( + section=section, + title="loss_function", + datatype=str, + group="loss", + default="ssim", + choices=["mae", "mse", "logcosh", "smooth_loss", "l_inf_norm", "ssim", "gmsd", + "pixel_gradient_diff"], + info="The loss function to use." + "\n\t MAE - Mean absolute error will guide reconstructions of each pixel " + "towards its median value in the training dataset. Robust to outliers but as " + "a median, it can potentially ignore some infrequent image types in the dataset." + "\n\t MSE - Mean squared error will guide reconstructions of each pixel " + "towards its average value in the training dataset. As an avg, it will be " + "suspectible to outliers and typically produces slightly blurrier results." + "\n\t LogCosh - log(cosh(x)) acts similiar to MSE for small errors and to " + "MAE for large errors. Like MSE, it is very stable and prevents overshoots " + "when errors are near zero. Like MAE, it is robust to outliers. NB: Due to a bug " + "in PlaidML, this loss does not work on AMD cards." + "\n\t Smooth_L1 --- Modification of the MAE loss to correct two of its " + "disadvantages. This loss has improved stability and guidance for small errors." + "\n\t L_inf_norm --- The L_inf norm will reduce the largest individual pixel " + "error in an image. As each largest error is minimized sequentially, the " + "overall error is improved. This loss will be extremely focused on outliers." + "\n\t SSIM - Structural Similarity Index Metric is a perception-based " + "loss that considers changes in texture, luminance, contrast, and local spatial " + "statistics of an image. Potentially delivers more realistic looking images." + "\n\t GMSD - Gradient Magnitude Similarity Deviation seeks to match " + "the global standard deviation of the pixel to pixel differences between two " + "images. Similiar in approach to SSIM. NB: This loss does not currently work on " + "AMD cards." + "\n\t Pixel_Gradient_Difference - Instead of minimizing the difference between " + "the absolute value of each pixel in two reference images, compute the pixel to " + "pixel spatial difference in each image and then minimize that difference " + "between two images. Allows for large color shifts,but maintains the structure " + "of the image.\n") + self.add_item( + section=section, + title="mask_loss_function", + datatype=str, + group="loss", + default="mse", + choices=["mae", "mse"], + info="The loss function to use when learning a mask." + "\n\t MAE - Mean absolute error will guide reconstructions of each pixel " + "towards its median value in the training dataset. Robust to outliers but as " + "a median, it can potentially ignore some infrequent image types in the dataset." + "\n\t MSE - Mean squared error will guide reconstructions of each pixel " + "towards its average value in the training dataset. As an avg, it will be " + "suspectible to outliers and typically produces slightly blurrier results.") + self.add_item( + section=section, + title="penalized_mask_loss", + datatype=bool, + default=True, + group="loss", + info="Image loss function is weighted by mask presence. For areas of " + "the image without the facial mask, reconstuction errors will be " + "ignored while the masked face area is prioritized. May increase " + "overall quality by focusing attention on the core face area.") + self.add_item( + section=section, + title="mask_type", + datatype=str, + default="extended", + choices=PluginLoader.get_available_extractors("mask", add_none=True), + group="mask", + gui_radio=True, + info="The mask to be used for training. If you have selected 'Learn Mask' or " + "'Penalized Mask Loss' you must select a value other than 'none'. The required " + "mask should have been selected as part of the Extract process. If it does not " + "exist in the alignments file then it will be generated prior to training " + "commencing." + "\n\tnone: Don't use a mask." + "\n\tcomponents: 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." + "\n\textended: 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." + "\n\tvgg-clear: Mask designed to provide smart segmentation of mostly frontal " + "faces clear of obstructions. Profile faces and obstructions may result in " + "sub-par performance." + "\n\tvgg-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." + "\n\tunet-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.") + self.add_item( + section=section, + title="mask_blur_kernel", + datatype=int, + min_max=(0, 9), + rounding=1, + default=3, + group="mask", + info="Apply gaussian blur to the mask input. This has the effect of smoothing the " + "edges of the mask, which can help with poorly calculated masks and give less " + "of a hard edge to the predicted mask. The size is in pixels (calculated from " + "a 128px mask). Set to 0 to not apply gaussian blur. This value should be odd, " + "if an even number is passed in then it will be rounded to the next odd number.") + self.add_item( + section=section, + title="mask_threshold", + datatype=int, + default=4, + min_max=(0, 50), + rounding=1, + group="mask", + info="Sets pixels that are near white to white and near black to black. Set to 0 for " + "off.") + self.add_item( + section=section, + title="learn_mask", + datatype=bool, + default=False, + group="mask", + info="Dedicate a portion of the model to learning how to duplicate the input " + "mask. Increases VRAM usage in exchange for learning a quick ability to try " + "to replicate more complex mask models.") + def load_module(self, filename, module_path, plugin_type): """ Load the defaults module and add defaults """ logger.debug("Adding defaults: (filename: %s, module_path: %s, plugin_type: %s", diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index e0a620ac13..fac479d164 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -126,6 +126,7 @@ def __init__(self, model_dir, arguments, training_image_size=256, predict=False) self.config["mixed_precision"], self.config["allow_growth"], self._is_predict) + self._loss = _Loss() logger.debug("Initialized ModelBase (%s)", self.__class__.__name__) @@ -384,10 +385,10 @@ def _compile_model(self): if self._settings.use_mixed_precision: optimizer = self._settings.LossScaleOptimizer(optimizer, loss_scale="dynamic") - loss = _Loss(self._model.inputs, self._model.outputs) - self._model.compile(optimizer=optimizer, loss=loss.functions) + self._loss.configure(self._model.inputs, self._model.outputs) + self._model.compile(optimizer=optimizer, loss=self._loss.functions) if not self._is_predict: - self._state.add_session_loss_names(loss.names) + self._state.add_session_loss_names(self._loss.names) logger.debug("Compiled Model: %s", self._model) def _legacy_mapping(self): # pylint:disable=no-self-use @@ -864,18 +865,9 @@ def _configure(self, learning_rate, clipnorm, arguments): class _Loss(): - """ Holds loss names and functions for an Autoencoder. - - Parameters - ---------- - inputs: list - A list of input tensors to the model in the order ("a", "b") - outputs: list - A list of output tensors to the model in the order ("a", "b") - """ - def __init__(self, inputs, outputs): - logger.debug("Initializing %s: (inputs: %s, outputs: %s)", - self.__class__.__name__, inputs, outputs) + """ Holds loss names and functions for an Autoencoder. """ + def __init__(self): + logger.debug("Initializing %s", self.__class__.__name__) self._loss_dict = dict(mae=k_losses.mean_absolute_error, mse=k_losses.mean_squared_error, logcosh=k_losses.logcosh, @@ -884,10 +876,9 @@ def __init__(self, inputs, outputs): ssim=losses.DSSIMObjective(), gmsd=losses.GMSDLoss(), pixel_gradient_diff=losses.GradientLoss()) - self._inputs = inputs - self._names = self._get_loss_names(outputs) - self._funcs = self._get_loss_functions() - self._names.insert(0, "total") + self._inputs = None + self._names = None + self._funcs = None logger.debug("Initialized: %s", self.__class__.__name__) @property @@ -905,14 +896,6 @@ def _config(self): """ :dict: The configuration options for this plugin """ return _CONFIG - @property - def _selected_mask_loss(self): - """ :func:`keras.losses.Loss`: The selected mask loss function. Currently returns mean - standard error as the default function. """ - loss_func = self._loss_dict["mse"] - logger.debug("loss_func: %s", loss_func) - return loss_func - @property def _mask_inputs(self): """ list: The list of input tensors to the model that contain the mask. Returns ``None`` @@ -928,6 +911,21 @@ def _mask_shapes(self): return None return [K.int_shape(mask_input) for mask_input in self._mask_inputs] + def configure(self, inputs, outputs): + """ Configure the loss functions for the given inputs and outputs. + + Parameters + ---------- + inputs: list + A list of input tensors to the model in the order ("a", "b") + outputs: list + A list of output tensors to the model in the order ("a", "b") + """ + self._inputs = inputs + self._names = self._get_loss_names(outputs) + self._funcs = self._get_loss_functions() + self._names.insert(0, "total") + @classmethod def _get_loss_names(cls, outputs): """ Name the losses based on model output @@ -973,11 +971,12 @@ def _get_loss_functions(self): list A list of loss functions to apply to the model """ - selected_loss = self._loss_dict[self._config.get("loss_function", "mae")] + selected_loss = self._loss_dict[self._config["loss_function"]] + mask_loss = self._loss_dict[self._config["mask_loss_function"]] loss_funcs = [] for name in self._names: if name.startswith("mask"): - loss_funcs.append(self._selected_mask_loss) + loss_funcs.append(mask_loss) elif self._config["penalized_mask_loss"]: loss_funcs.append(losses.PenalizedLoss(selected_loss)) else: From d5c62d16a146d499760574047380e0cc0171fd20 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 23 Aug 2020 09:17:09 +0100 Subject: [PATCH 283/981] bugfix - Training - Prevent crash on manual preview update --- scripts/train.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/train.py b/scripts/train.py index 32e5d7956c..54fe893bd1 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -304,8 +304,9 @@ def _run_training_cycle(self, model, trainer): if self._args.redirect_gui: print("\n") logger.info("[Preview Updated]") - logger.debug("Removing gui trigger file: %s", self._gui_preview_trigger) - os.remove(self._gui_preview_trigger) + if os.path.isfile(self._gui_preview_trigger): + logger.debug("Removing gui trigger file: %s", self._gui_preview_trigger) + os.remove(self._gui_preview_trigger) self._refresh_preview = False if save_iteration: From 83bd60ddbc4a423969d555736f387c0f349ac486 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 23 Aug 2020 12:00:45 +0100 Subject: [PATCH 284/981] training - Use explicit loss dictionaries to map to outputs --- plugins/train/model/_base.py | 104 +++++++++++++++++++++-------------- 1 file changed, 62 insertions(+), 42 deletions(-) diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index fac479d164..8324c8ea40 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -384,13 +384,36 @@ def _compile_model(self): self._args).optimizer if self._settings.use_mixed_precision: optimizer = self._settings.LossScaleOptimizer(optimizer, loss_scale="dynamic") - - self._loss.configure(self._model.inputs, self._model.outputs) + if get_backend() == "amd": + self._rewrite_plaid_outputs() + self._loss.configure(self._model) self._model.compile(optimizer=optimizer, loss=self._loss.functions) if not self._is_predict: self._state.add_session_loss_names(self._loss.names) logger.debug("Compiled Model: %s", self._model) + def _rewrite_plaid_outputs(self): + """ Rewrite the output names for models using the PlaidML (Keras 2.2.4) backend + + Keras 2.2.4 duplicates model output names if any of the models have multiple outputs + so we need to rename the outputs so we can successfully map the loss dictionaries. + + This is a bit of a hack, but it does work. + """ + # TODO Remove this rewrite code if PlaidML updates to a version of Keras where this is + # no longer necessary + if len(self._model.output_names) == len(set(self._model.output_names)): + logger.debug("Output names are unique, not rewriting: %s", self._model.output_names) + return + seen = {name: 0 for name in set(self._model.output_names)} + new_names = [] + for name in self._model.output_names: + new_names.append("{}_{}".format(name, seen[name])) + seen[name] += 1 + logger.debug("Output names rewritten: (old: %s, new: %s)", + self._model.output_names, new_names) + self._model.output_names = new_names + def _legacy_mapping(self): # pylint:disable=no-self-use """ The mapping of separate model files to single model layers for transferring of legacy weights. @@ -877,8 +900,8 @@ def __init__(self): gmsd=losses.GMSDLoss(), pixel_gradient_diff=losses.GradientLoss()) self._inputs = None - self._names = None - self._funcs = None + self._names = [] + self._funcs = dict() logger.debug("Initialized: %s", self.__class__.__name__) @property @@ -888,7 +911,7 @@ def names(self): @property def functions(self): - """ list: The list of loss functions for the model. """ + """ dict: The loss functions that apply to each model output. """ return self._funcs @property @@ -911,24 +934,25 @@ def _mask_shapes(self): return None return [K.int_shape(mask_input) for mask_input in self._mask_inputs] - def configure(self, inputs, outputs): + def configure(self, model): """ Configure the loss functions for the given inputs and outputs. Parameters ---------- - inputs: list - A list of input tensors to the model in the order ("a", "b") - outputs: list - A list of output tensors to the model in the order ("a", "b") + model: :class:`keras.models.Model` + The model that is to be trained """ - self._inputs = inputs - self._names = self._get_loss_names(outputs) - self._funcs = self._get_loss_functions() + self._inputs = model.inputs + self._get_loss_names(model.outputs) + self._get_loss_functions(model.output_names) self._names.insert(0, "total") - @classmethod - def _get_loss_names(cls, outputs): - """ Name the losses based on model output + def _get_loss_names(self, outputs): + """ Name the losses based on model output. + + This is used for correct naming in the state file, for display purposes only. + + Adds the loss names to :attr:`names` Notes ----- @@ -941,49 +965,45 @@ def _get_loss_names(cls, outputs): ---------- outputs: list A list of output tensors from the model plugin - - Returns - ------- - list - A list of names for the losses to be applied to the model """ # TODO Use output names if/when these are fixed upstream split_outputs = [outputs[:len(outputs) // 2], outputs[len(outputs) // 2:]] - retval = [] for side, side_output in zip(("a", "b"), split_outputs): output_names = [output.name for output in side_output] output_shapes = [K.int_shape(output)[1:] for output in side_output] output_types = ["mask" if shape[-1] == 1 else "face" for shape in output_shapes] logger.debug("side: %s, output names: %s, output_shapes: %s, output_types: %s", side, output_names, output_shapes, output_types) - retval.extend(["{}_{}{}".format(name, side, - "" if output_types.count(name) == 1 - else "_{}".format(idx)) - for idx, name in enumerate(output_types)]) - logger.debug(retval) - return retval + self._names.extend(["{}_{}{}".format(name, side, + "" if output_types.count(name) == 1 + else "_{}".format(idx)) + for idx, name in enumerate(output_types)]) + logger.debug(self._names) - def _get_loss_functions(self): + def _get_loss_functions(self, output_names): """ Set the loss functions. - Returns - ------- - list - A list of loss functions to apply to the model + Adds the loss functions to the :attr:`functions` dictionary. + + Parameters + ---------- + output_names: list + The output names from the model """ selected_loss = self._loss_dict[self._config["loss_function"]] - mask_loss = self._loss_dict[self._config["mask_loss_function"]] - loss_funcs = [] - for name in self._names: + for name, output_name in zip(self._names, output_names): if name.startswith("mask"): - loss_funcs.append(mask_loss) + loss_func = self._loss_dict[self._config["mask_loss_function"]] elif self._config["penalized_mask_loss"]: - loss_funcs.append(losses.PenalizedLoss(selected_loss)) + loss_func = losses.PenalizedLoss(selected_loss) + else: + loss_func = selected_loss + logger.debug("%s: (output_name: '%s', function: %s)", name, output_name, loss_func) + if get_backend() == "amd": + self._funcs[output_name] = loss_func else: - loss_funcs.append(selected_loss) - logger.debug("%s: %s", name, loss_funcs[-1]) - logger.debug(loss_funcs) - return loss_funcs + self._funcs.setdefault(output_name, []).append(loss_func) + logger.debug(self._funcs) class State(): From 0e31b98d3646256bd9b71ae351dc7d7f4f0f23fd Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 23 Aug 2020 23:08:19 +0100 Subject: [PATCH 285/981] Training - Add L2 Regularization to Structural Losses --- lib/config.py | 1 + lib/model/losses_plaid.py | 72 ++++++++++++++++++++++++++++++++++-- lib/model/losses_tf.py | 69 ++++++++++++++++++++++++++++++++++ plugins/train/_config.py | 25 ++++++++++++- plugins/train/model/_base.py | 34 ++++++++++------- 5 files changed, 183 insertions(+), 18 deletions(-) diff --git a/lib/config.py b/lib/config.py index 031466b258..77a63bcd20 100644 --- a/lib/config.py +++ b/lib/config.py @@ -180,6 +180,7 @@ def add_item(self, section=None, title=None, datatype=str, default=None, info=No @staticmethod def expand_helptext(helptext, choices, default, datatype, min_max, fixed): """ Add extra helptext info from parameters """ + helptext += "\n" if not fixed: helptext += "\nThis option can be updated for existing models." if choices: diff --git a/lib/model/losses_plaid.py b/lib/model/losses_plaid.py index 621589aa02..5704ac38a7 100644 --- a/lib/model/losses_plaid.py +++ b/lib/model/losses_plaid.py @@ -5,13 +5,11 @@ import logging -from keras import backend as K - import numpy as np import tensorflow as tf +from keras import backend as K from plaidml.op import extract_image_patches - from lib.plaidml_utils import pad from lib.utils import FaceswapError @@ -562,3 +560,71 @@ def _scharr_edges(cls, image, magnitude): output = tf.atan(K.squeeze(output[:, :, :, :, 0] / output[:, :, :, :, 1], axis=None)) # magnitude of edges -- unified x & y edges don't work well with Neural Networks return output + + +class LossWrapper(): # pylint:disable=too-few-public-methods + """ A wrapper class for multiple keras losses to enable multiple weighted loss functions on a + single output. + + Parameters + ---------- + loss_functions: list + A list of either a tuple of (:class:`keras.losses.Loss`, scalar weight) or just a + :class:`keras.losses.Loss` function. If just the loss function is passed, then the weight + is assumed to be 1.0 """ + def __init__(self, loss_functions): + logger.debug("Initializing: %s: (loss_functions: %s)", + self.__class__.__name__, loss_functions) + self._loss_functions = [] + self._loss_weights = [] + self._compile_losses(loss_functions) + logger.debug("Initialized: %s", self.__class__.__name__) + + def _compile_losses(self, loss_functions): + """ Splits the given loss_functions into the corresponding :attr:`_loss_functions' and + :attr:`_loss_weights' lists. + + Loss functions are compiled into :class:`keras.compile_utils.LossesContainer` objects + + Parameters + ---------- + loss_functions: list + A list of either a tuple of (:class:`keras.losses.Loss`, scalar weight) or just a + :class:`keras.losses.Loss` function. If just the loss function is passed, then the + weight is assumed to be 1.0 """ + for loss_func in loss_functions: + if isinstance(loss_func, tuple): + assert len(loss_func) == 2, "Tuple loss functions should contain 2 items" + assert isinstance(loss_func[1], float), "weight should be a float" + func, weight = loss_func + else: + func = loss_func + weight = 1.0 + self._loss_functions.append(func) + self._loss_weights.append(weight) + logger.debug("Compiled losses: (functions: %s, weights: %s", + self._loss_functions, self._loss_weights) + + def __call__(self, y_true, y_pred): + """ Call the sub loss functions for the loss wrapper. + + Weights are returned as an average of the weighted sum rather than weighted sum to keep + totals more in a standardized range end users would expect to see. + + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + + Returns + ------- + tensor + The final loss value + """ + loss = 0.0 + for func, weight in zip(self._loss_functions, self._loss_weights): + loss += K.mean(func(y_true, y_pred)) * weight + weighted_average = loss / sum(self._loss_weights) + return weighted_average diff --git a/lib/model/losses_tf.py b/lib/model/losses_tf.py index ff36549cd7..2fe3af2dd9 100644 --- a/lib/model/losses_tf.py +++ b/lib/model/losses_tf.py @@ -554,3 +554,72 @@ def _scharr_edges(cls, image, magnitude): output = tf.atan(K.squeeze(output[:, :, :, :, 0] / output[:, :, :, :, 1], axis=None)) # magnitude of edges -- unified x & y edges don't work well with Neural Networks return output + + +class LossWrapper(tf.keras.losses.Loss): + """ A wrapper class for multiple keras losses to enable multiple weighted loss functions on a + single output. + + Parameters + ---------- + loss_functions: list + A list of either a tuple of (:class:`keras.losses.Loss`, scalar weight) or just a + :class:`keras.losses.Loss` function. If just the loss function is passed, then the weight + is assumed to be 1.0 """ + def __init__(self, loss_functions): + logger.debug("Initializing: %s: (loss_functions: %s)", + self.__class__.__name__, loss_functions) + super().__init__(name="LossWrapper") + self._loss_functions = [] + self._loss_weights = [] + self._compile_losses(loss_functions) + logger.debug("Initialized: %s", self.__class__.__name__) + + def _compile_losses(self, loss_functions): + """ Splits the given loss_functions into the corresponding :attr:`_loss_functions' and + :attr:`_loss_weights' lists. + + Loss functions are compiled into :class:`keras.compile_utils.LossesContainer` objects + + Parameters + ---------- + loss_functions: list + A list of either a tuple of (:class:`keras.losses.Loss`, scalar weight) or just a + :class:`keras.losses.Loss` function. If just the loss function is passed, then the + weight is assumed to be 1.0 """ + for loss_func in loss_functions: + if isinstance(loss_func, tuple): + assert len(loss_func) == 2, "Tuple loss functions should contain 2 items" + assert isinstance(loss_func[1], float), "weight should be a float" + func, weight = loss_func + else: + func = loss_func + weight = 1.0 + self._loss_functions.append(compile_utils.LossesContainer(func)) + self._loss_weights.append(weight) + logger.debug("Compiled losses: (functions: %s, weights: %s", + self._loss_functions, self._loss_weights) + + def call(self, y_true, y_pred): + """ Call the sub loss functions for the loss wrapper. + + Weights are returned as an average of the weighted sum rather than weighted sum to keep + totals more in a standardized range end users would expect to see. + + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + + Returns + ------- + tensor + The final loss value + """ + loss = 0.0 + for func, weight in zip(self._loss_functions, self._loss_weights): + loss += func(y_true, y_pred) * weight + weighted_average = loss / sum(self._loss_weights) + return weighted_average diff --git a/plugins/train/_config.py b/plugins/train/_config.py index baccb86c6f..a8c52b5486 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -151,7 +151,7 @@ def _set_globals(self): info="R|[Nvidia Only], NVIDIA GPUs can run operations in float16 faster than in " "float32. Mixed precision allows you to use a mix of float16 with float32, to " "get the performance benefits from float16 and the numeric stability benefits " - "from float32.\nWhile mixed precision will run on most Nvidia models, it will " + "from float32.\n\nWhile mixed precision will run on most Nvidia models, it will " "only speed up training on more recent GPUs. Those with compute capability 7.0 " "or higher will see the greatest performance benefit from mixed precision " "because they have Tensor Cores. Older GPUs offer no math performance benefit " @@ -228,7 +228,7 @@ def _set_loss(self): "the absolute value of each pixel in two reference images, compute the pixel to " "pixel spatial difference in each image and then minimize that difference " "between two images. Allows for large color shifts,but maintains the structure " - "of the image.\n") + "of the image.") self.add_item( section=section, title="mask_loss_function", @@ -243,6 +243,27 @@ def _set_loss(self): "\n\t MSE - Mean squared error will guide reconstructions of each pixel " "towards its average value in the training dataset. As an avg, it will be " "suspectible to outliers and typically produces slightly blurrier results.") + self.add_item( + section=section, + title="l2_reg_term", + datatype=int, + group="loss", + min_max=(0, 400), + rounding=1, + default=100, + info="The amount of L2 Regularization to apply as a penalty to Structural Similarity " + "loss functions.\n\nNB: You should only adjust this if you know what you are " + "doing!\n\n" + "L2 regularization applies a penalty term to the given Loss function. This " + "penalty will only be applied if SSIM or GMSD is selected for the main loss " + "function, otherwise it is ignored.\n\nThe value given here is as a percentage " + "weight of the main loss function. For example:" + "\n\t 100 - Will give equal weigthing to the main loss and the penalty function. " + "\n\t 25 - Will give the penalty function 1/4 of the weight of the main loss " + "function. " + "\n\t 400 - Will give the penalty function 4x as much importance as the main " + "loss function." + "\n\t 0 - Disables L2 Regularization altogether.") self.add_item( section=section, title="penalized_mask_loss", diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 8324c8ea40..49f87f7819 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -899,6 +899,7 @@ def __init__(self): ssim=losses.DSSIMObjective(), gmsd=losses.GMSDLoss(), pixel_gradient_diff=losses.GradientLoss()) + self._uses_l2_reg = ["ssim", "gmsd"] self._inputs = None self._names = [] self._funcs = dict() @@ -943,11 +944,11 @@ def configure(self, model): The model that is to be trained """ self._inputs = model.inputs - self._get_loss_names(model.outputs) - self._get_loss_functions(model.output_names) + self._set_loss_names(model.outputs) + self._set_loss_functions(model.output_names) self._names.insert(0, "total") - def _get_loss_names(self, outputs): + def _set_loss_names(self, outputs): """ Name the losses based on model output. This is used for correct naming in the state file, for display purposes only. @@ -980,8 +981,8 @@ def _get_loss_names(self, outputs): for idx, name in enumerate(output_types)]) logger.debug(self._names) - def _get_loss_functions(self, output_names): - """ Set the loss functions. + def _set_loss_functions(self, output_names): + """ Set the loss functions and their associated weights. Adds the loss functions to the :attr:`functions` dictionary. @@ -994,16 +995,23 @@ def _get_loss_functions(self, output_names): for name, output_name in zip(self._names, output_names): if name.startswith("mask"): loss_func = self._loss_dict[self._config["mask_loss_function"]] - elif self._config["penalized_mask_loss"]: - loss_func = losses.PenalizedLoss(selected_loss) else: - loss_func = selected_loss + if (self._config["loss_function"] in self._uses_l2_reg + and self._config["l2_reg_term"] != 0): + loss_funcs = [selected_loss, self._loss_dict["mse"]] + loss_weights = [1.0, self._config["l2_reg_term"] / 100.0] + else: + loss_funcs = [selected_loss] + loss_weights = [1.0] + + if self._config["penalized_mask_loss"]: + loss_funcs = [losses.PenalizedLoss(loss) for loss in loss_funcs] + + loss_func = losses.LossWrapper(loss_functions=list(zip(loss_funcs, loss_weights))) + logger.debug("%s: (output_name: '%s', function: %s)", name, output_name, loss_func) - if get_backend() == "amd": - self._funcs[output_name] = loss_func - else: - self._funcs.setdefault(output_name, []).append(loss_func) - logger.debug(self._funcs) + self._funcs[output_name] = loss_func + logger.debug("functions: %s", self._funcs) class State(): From 1f9e834719282245ed245ec2266412e03d13b1bc Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 23 Aug 2020 23:57:45 +0100 Subject: [PATCH 286/981] typofix --- plugins/train/_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/train/_config.py b/plugins/train/_config.py index a8c52b5486..9a4e4aeb9c 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -258,7 +258,7 @@ def _set_loss(self): "penalty will only be applied if SSIM or GMSD is selected for the main loss " "function, otherwise it is ignored.\n\nThe value given here is as a percentage " "weight of the main loss function. For example:" - "\n\t 100 - Will give equal weigthing to the main loss and the penalty function. " + "\n\t 100 - Will give equal weighting to the main loss and the penalty function. " "\n\t 25 - Will give the penalty function 1/4 of the weight of the main loss " "function. " "\n\t 400 - Will give the penalty function 4x as much importance as the main " From bab12efb7f4670620076a9f5a3a7d2891d6e929c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 25 Aug 2020 00:51:46 +0100 Subject: [PATCH 287/981] Remove averaging from loss --- lib/model/losses_plaid.py | 8 +++----- lib/model/losses_tf.py | 8 +++----- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/lib/model/losses_plaid.py b/lib/model/losses_plaid.py index 5704ac38a7..f2ffd4a07d 100644 --- a/lib/model/losses_plaid.py +++ b/lib/model/losses_plaid.py @@ -608,8 +608,7 @@ def _compile_losses(self, loss_functions): def __call__(self, y_true, y_pred): """ Call the sub loss functions for the loss wrapper. - Weights are returned as an average of the weighted sum rather than weighted sum to keep - totals more in a standardized range end users would expect to see. + Weights are returned as the weighted sum of the chosen losses. Parameters ---------- @@ -625,6 +624,5 @@ def __call__(self, y_true, y_pred): """ loss = 0.0 for func, weight in zip(self._loss_functions, self._loss_weights): - loss += K.mean(func(y_true, y_pred)) * weight - weighted_average = loss / sum(self._loss_weights) - return weighted_average + loss += (K.mean(func(y_true, y_pred)) * weight) + return loss diff --git a/lib/model/losses_tf.py b/lib/model/losses_tf.py index 2fe3af2dd9..c4e5ad78ee 100644 --- a/lib/model/losses_tf.py +++ b/lib/model/losses_tf.py @@ -603,8 +603,7 @@ def _compile_losses(self, loss_functions): def call(self, y_true, y_pred): """ Call the sub loss functions for the loss wrapper. - Weights are returned as an average of the weighted sum rather than weighted sum to keep - totals more in a standardized range end users would expect to see. + Weights are returned as the weighted sum of the chosen losses. Parameters ---------- @@ -620,6 +619,5 @@ def call(self, y_true, y_pred): """ loss = 0.0 for func, weight in zip(self._loss_functions, self._loss_weights): - loss += func(y_true, y_pred) * weight - weighted_average = loss / sum(self._loss_weights) - return weighted_average + loss += (func(y_true, y_pred) * weight) + return loss From 1363fa85eb7484ca36bc578ff99400bc4dca4e79 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 25 Aug 2020 10:25:12 +0100 Subject: [PATCH 288/981] lib.image - More information on image read errors --- lib/image.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/image.py b/lib/image.py index 58ed40c6ce..55a315e11f 100644 --- a/lib/image.py +++ b/lib/image.py @@ -268,17 +268,19 @@ def read_image(filename, raise_error=False, with_hash=False): try: image = cv2.imread(filename) if image is None: - raise ValueError - except TypeError: + raise ValueError("Image is None") + except TypeError as err: success = False msg = "Error while reading image (TypeError): '{}'".format(filename) + msg += ". Original error message: {}".format(str(err)) logger.error(msg) if raise_error: raise Exception(msg) - except ValueError: + except ValueError as err: success = False - msg = ("Error while reading image. This is most likely caused by special characters in " - "the filename: '{}'".format(filename)) + msg = ("Error while reading image. This can be caused by special characters in the " + "filename or a corrupt image file: '{}'".format(filename)) + msg += ". Original error message: {}".format(str(err)) logger.error(msg) if raise_error: raise Exception(msg) From 343392813338ae7b10b0a3bbb3b5a9a7da6e588d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 27 Aug 2020 16:49:57 +0100 Subject: [PATCH 289/981] Add Mouth and Eye Priority to Loss options (#1054) * Priority Training for Mouth and Eyes - Tensorflow * Use chosen loss function for area multipliers * loss multipliers for AMD * Fix mask multipliers for plaid and roll PenalizedMaskLoss into LossWrapper * losses_tf: roll PenalizedMaskLoss into LossWrapper --- lib/faces_detect.py | 117 ++++++++++++++++++++++- lib/model/losses_plaid.py | 164 ++++++++++++++------------------- lib/model/losses_tf.py | 154 +++++++++++++------------------ lib/training_data.py | 122 +++++++++++++++++++----- plugins/train/_config.py | 26 ++++++ plugins/train/model/_base.py | 67 +++++++++++--- plugins/train/trainer/_base.py | 134 +++++++++++++++++++-------- 7 files changed, 521 insertions(+), 263 deletions(-) diff --git a/lib/faces_detect.py b/lib/faces_detect.py index 9e3a370177..37eb3a92c7 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -147,6 +147,50 @@ def add_mask(self, name, mask, affine_matrix, interpolator, storage_size=128): fsmask.add(mask, affine_matrix, interpolator) self.mask[name] = fsmask + def get_landmark_mask(self, size, area, aligned=True, dilation=0, blur_kernel=0, as_zip=False): + """ Obtain a single channel mask based on the face's landmark points. + + Parameters + ---------- + size: int or tuple + The size of the aligned mask to retrieve. Should be an `int` if an aligned face is + being requested, or a ('height', 'width') shape tuple if a full frame is being + requested + area: ["mouth", "eyes"] + The type of mask to obtain. `face` is a full face mask the others are masks for those + specific areas + aligned: bool + ``True`` if the returned mask should be for an aligned face. ``False`` if a full frame + mask should be returned + dilation: int, optional + The amount of dilation to apply to the mask. `0` for none. Default: `0` + blur_kernel: int, optional + The kernel size for applying gaussian blur to apply to the mask. `0` for none. + Default: `0` + as_zip: bool, optional + ``True`` if the mask should be returned zipped otherwise ``False`` + + Returns + ------- + :class:`numpy.ndarray` or zipped array + The mask as a single channel image of the given :attr:`size` dimension. If + :attr:`as_zip` is ``True`` then the :class:`numpy.ndarray` will be contained within a + zipped container + """ + # TODO Face mask generation from landmarks + logger.trace("size: %s, area: %s, aligned: %s, dilation: %s, blur_kernel: %s, as_zip: %s", + size, area, aligned, dilation, blur_kernel, as_zip) + areas = dict(mouth=[slice(48, 60)], + eyes=[slice(36, 42), slice(42, 48)]) + if aligned and self.aligned.get("size") != size: + self.load_aligned(None, size=size, force=True) + size = (size, size) if aligned else size + landmarks = self.aligned_landmarks if aligned else self.landmarks_xy + points = [landmarks[zone] for zone in areas[area]] + mask = _LandmarksMask(size, points, dilation=dilation, blur_kernel=blur_kernel) + retval = mask.get(as_zip=as_zip) + return retval + def to_alignment(self): """ Return the detected face formatted for an alignments file @@ -511,6 +555,77 @@ def reference_interpolators(self): return get_matrix_scaling(self.reference_matrix) +class _LandmarksMask(): # pylint:disable=too-few-public-methods + """ Create a single channel mask from aligned landmark points. + + size: tuple + The (height, width) shape tuple that the mask should be returned as + points: list + A list of landmark points that correspond to the given shape tuple to create + the mask. Each item in the list should be a :class:`numpy.ndarray` that a filled + convex polygon will be created from + dilation: int, optional + The amount of dilation to apply to the mask. `0` for none. Default: `0` + blur_kernel: int, optional + The kernel size for applying gaussian blur to apply to the mask. `0` for none. Default: `0` + """ + def __init__(self, size, points, dilation=0, blur_kernel=0): + logger.trace("Initializing: %s: (size: %s, points: %s, dilation: %s, blur_kernel: %s)", + size, points, dilation, blur_kernel) + self._size = size + self._points = points + self._dilation = dilation + self._blur_kernel = blur_kernel + self._mask = None + logger.trace("Initialized: %s", self.__class__.__name__) + + def get(self, as_zip=False): + """ Obtain the mask. + + Parameters + ---------- + as_zip: bool, optional + ``True`` if the mask should be returned zipped otherwise ``False`` + + Returns + ------- + :class:`numpy.ndarray` or zipped array + The mask as a single channel image of the given :attr:`size` dimension. If + :attr:`as_zip` is ``True`` then the :class:`numpy.ndarray` will be contained within a + zipped container + """ + if not np.any(self._mask): + self._generate_mask() + retval = compress(self._mask) if as_zip else self._mask + logger.trace("as_zip: %s, retval type: %s", as_zip, type(retval)) + return retval + + def _generate_mask(self): + """ Generate the mask. + + Creates the mask applying any requested dilation and blurring and assigns to + :attr:`_mask` + + Returns + ------- + :class:`numpy.ndarray` + The mask as a single channel image of the given :attr:`size` dimension. + """ + mask = np.zeros((self._size) + (1, ), dtype="float32") + for landmarks in self._points: + lms = np.rint(landmarks).astype("int") + cv2.fillConvexPoly(mask, cv2.convexHull(lms), 1.0, lineType=cv2.LINE_AA) + if self._dilation != 0: + mask = cv2.dilate(mask, + cv2.getStructuringElement(cv2.MORPH_ELLIPSE, + (self._dilation, self._dilation)), + iterations=1) + if self._blur_kernel != 0: + mask = BlurMask("gaussian", mask, self._blur_kernel).blurred + logger.trace("mask: (shape: %s, dtype: %s)", mask.shape, mask.dtype) + self._mask = (mask * 255.0).astype("uint8") + + class Mask(): """ Face Mask information and convenience methods @@ -741,7 +856,7 @@ def _attr_name(dict_key): return retval -class BlurMask(): +class BlurMask(): # pylint:disable=too-few-public-methods """ Factory class to return the correct blur object for requested blur type. Works for square images only. Currently supports Gaussian and Normalized Box Filters. diff --git a/lib/model/losses_plaid.py b/lib/model/losses_plaid.py index f2ffd4a07d..edbb9673ee 100644 --- a/lib/model/losses_plaid.py +++ b/lib/model/losses_plaid.py @@ -138,7 +138,7 @@ def __call__(self, y_true, y_pred): denom = (K.square(u_true) + K.square(u_pred) + self.c_1) * ( var_pred + var_true + self.c_2) ssim /= denom # no need for clipping, c_1 + c_2 make the denorm non-zero - return K.mean((1.0 - ssim) / 2.0) + return (1.0 - ssim) / 2.0 @staticmethod def _preprocess_padding(padding): @@ -199,66 +199,6 @@ def extract_image_patches(self, input_tensor, k_sizes, s_sizes, return patches -class PenalizedLoss(): # pylint:disable=too-few-public-methods - """ Penalized Loss function. - - Applies the given loss function just to the masked area of the image. - - Parameters - ---------- - loss_func: function - The actual loss function to use - mask_prop: float, optional - The amount of mask propagation. Default: `1.0` - """ - def __init__(self, loss_func, mask_prop=1.0): - self._loss_func = loss_func - self._mask_prop = mask_prop - - def __call__(self, y_true, y_pred): - """ Apply the loss function to the masked area of the image. - - Parameters - ---------- - y_true: tensor or variable - The ground truth value. This should contain the mask in the 4th channel that will be - split off for penalizing. - y_pred: tensor or variable - The predicted value - - Returns - ------- - tensor - The Loss value - """ - mask = self._prepare_mask(K.expand_dims(y_true[..., -1], axis=-1)) - y_true = y_true[..., :-1] - n_true = y_true * mask - n_pred = y_pred * mask - if isinstance(self._loss_func, DSSIMObjective): - # Extract Image Patches in SSIM requires that y_pred be of a known shape, so - # specifically reshape the tensor. - n_pred = K.reshape(n_pred, K.int_shape(y_pred)) - return self._loss_func(n_true, n_pred) - - def _prepare_mask(self, mask): - """ Prepare the masks for calculating loss - - Parameters - ---------- - mask: :class:`numpy.ndarray` - The masks for the current batch - - Returns - ------- - tensor - The prepared mask for applying to loss - """ - mask_as_k_inv_prop = 1 - self._mask_prop - mask = (mask * self._mask_prop) + mask_as_k_inv_prop - return mask - - class GeneralizedLoss(): # pylint:disable=too-few-public-methods """ Generalized function used to return a large variety of mathematical loss functions. @@ -564,46 +504,33 @@ def _scharr_edges(cls, image, magnitude): class LossWrapper(): # pylint:disable=too-few-public-methods """ A wrapper class for multiple keras losses to enable multiple weighted loss functions on a - single output. - - Parameters - ---------- - loss_functions: list - A list of either a tuple of (:class:`keras.losses.Loss`, scalar weight) or just a - :class:`keras.losses.Loss` function. If just the loss function is passed, then the weight - is assumed to be 1.0 """ - def __init__(self, loss_functions): - logger.debug("Initializing: %s: (loss_functions: %s)", - self.__class__.__name__, loss_functions) + single output and masking. + """ + def __init__(self): + logger.debug("Initializing: %s", self.__class__.__name__) self._loss_functions = [] self._loss_weights = [] - self._compile_losses(loss_functions) + self._mask_channels = [] logger.debug("Initialized: %s", self.__class__.__name__) - def _compile_losses(self, loss_functions): - """ Splits the given loss_functions into the corresponding :attr:`_loss_functions' and - :attr:`_loss_weights' lists. - - Loss functions are compiled into :class:`keras.compile_utils.LossesContainer` objects + def add_loss(self, function, weight=1.0, mask_channel=-1): + """ Add the given loss function with the given weight to the loss function chain. Parameters ---------- - loss_functions: list - A list of either a tuple of (:class:`keras.losses.Loss`, scalar weight) or just a - :class:`keras.losses.Loss` function. If just the loss function is passed, then the - weight is assumed to be 1.0 """ - for loss_func in loss_functions: - if isinstance(loss_func, tuple): - assert len(loss_func) == 2, "Tuple loss functions should contain 2 items" - assert isinstance(loss_func[1], float), "weight should be a float" - func, weight = loss_func - else: - func = loss_func - weight = 1.0 - self._loss_functions.append(func) - self._loss_weights.append(weight) - logger.debug("Compiled losses: (functions: %s, weights: %s", - self._loss_functions, self._loss_weights) + function: :class:`keras.losses.Loss` + The loss function to add to the loss chain + weight: float, optional + The weighting to apply to the loss function. Default: `1.0` + mask_channel: int, optional + The channel in the `y_true` image that the mask exists in. Set to `-1` if there is no + mask for the given loss function. Default: `-1` + """ + logger.debug("Adding loss: (function: %s, weight: %s, mask_channel: %s)", + function, weight, mask_channel) + self._loss_functions.append(function) + self._loss_weights.append(weight) + self._mask_channels.append(mask_channel) def __call__(self, y_true, y_pred): """ Call the sub loss functions for the loss wrapper. @@ -623,6 +550,51 @@ def __call__(self, y_true, y_pred): The final loss value """ loss = 0.0 - for func, weight in zip(self._loss_functions, self._loss_weights): - loss += (K.mean(func(y_true, y_pred)) * weight) + for func, weight, mask_channel in zip(self._loss_functions, + self._loss_weights, + self._mask_channels): + logger.debug("Processing loss function: (func: %s, weight: %s, mask_channel: %s)", + func, weight, mask_channel) + n_true, n_pred = self._apply_mask(y_true, y_pred, mask_channel) + if isinstance(func, DSSIMObjective): + # Extract Image Patches in SSIM requires that y_pred be of a known shape, so + # specifically reshape the tensor. + n_pred = K.reshape(n_pred, K.int_shape(y_pred)) + this_loss = func(n_true, n_pred) + loss_dims = K.ndim(this_loss) + loss += (K.mean(this_loss, axis=list(range(1, loss_dims))) * weight) return loss + + @classmethod + def _apply_mask(cls, y_true, y_pred, mask_channel, mask_prop=1.0): + """ Apply the mask to the input y_true and y_pred. If a mask is not required then + return the unmasked inputs. + + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + mask_channel: int + The channel within y_true that the required mask resides in + mask_prop: float, optional + The amount of mask propagation. Default: `1.0` + + Returns + ------- + tuple + (n_true, n_pred): The ground truth and predicted value tensors with the mask applied + """ + if mask_channel == -1: + logger.debug("No mask to apply") + return y_true[..., :3], y_pred[..., :3] + + logger.debug("Applying mask from channel %s", mask_channel) + mask = K.expand_dims(y_true[..., mask_channel], axis=-1) + mask_as_k_inv_prop = 1 - mask_prop + mask = (mask * mask_prop) + mask_as_k_inv_prop + + n_true = y_true[..., :3] * mask + n_pred = y_pred * mask + return n_true, n_pred diff --git a/lib/model/losses_tf.py b/lib/model/losses_tf.py index c4e5ad78ee..cb5640b5b2 100644 --- a/lib/model/losses_tf.py +++ b/lib/model/losses_tf.py @@ -197,63 +197,6 @@ def extract_image_patches(self, input_tensor, k_sizes, s_sizes, return patches -class PenalizedLoss(tf.keras.losses.Loss): - """ Penalized Loss function. - - Applies the given loss function just to the masked area of the image. - - Parameters - ---------- - loss_func: function - The actual loss function to use - mask_prop: float, optional - The amount of mask propagation. Default: `1.0` - """ - def __init__(self, loss_func, mask_prop=1.0): - super().__init__(name="penalized_loss") - self._loss_func = compile_utils.LossesContainer(loss_func) - self._mask_prop = mask_prop - - def call(self, y_true, y_pred): - """ Apply the loss function to the masked area of the image. - - Parameters - ---------- - y_true: tensor or variable - The ground truth value. This should contain the mask in the 4th channel that will be - split off for penalizing. - y_pred: tensor or variable - The predicted value - - Returns - ------- - tensor - The Loss value - """ - mask = self._prepare_mask(K.expand_dims(y_true[..., -1], axis=-1)) - y_true = y_true[..., :-1] - n_true = K.concatenate([y_true[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) - n_pred = K.concatenate([y_pred[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) - return self._loss_func(n_true, n_pred) - - def _prepare_mask(self, mask): - """ Prepare the masks for calculating loss - - Parameters - ---------- - mask: :class:`numpy.ndarray` - The masks for the current batch - - Returns - ------- - tensor - The prepared mask for applying to loss - """ - mask_as_k_inv_prop = 1 - self._mask_prop - mask = (mask * self._mask_prop) + mask_as_k_inv_prop - return mask - - class GeneralizedLoss(tf.keras.losses.Loss): """ Generalized function used to return a large variety of mathematical loss functions. @@ -559,52 +502,42 @@ def _scharr_edges(cls, image, magnitude): class LossWrapper(tf.keras.losses.Loss): """ A wrapper class for multiple keras losses to enable multiple weighted loss functions on a single output. - - Parameters - ---------- - loss_functions: list - A list of either a tuple of (:class:`keras.losses.Loss`, scalar weight) or just a - :class:`keras.losses.Loss` function. If just the loss function is passed, then the weight - is assumed to be 1.0 """ - def __init__(self, loss_functions): - logger.debug("Initializing: %s: (loss_functions: %s)", - self.__class__.__name__, loss_functions) + """ + def __init__(self): + logger.debug("Initializing: %s", self.__class__.__name__) super().__init__(name="LossWrapper") self._loss_functions = [] self._loss_weights = [] - self._compile_losses(loss_functions) + self._mask_channels = [] logger.debug("Initialized: %s", self.__class__.__name__) - def _compile_losses(self, loss_functions): - """ Splits the given loss_functions into the corresponding :attr:`_loss_functions' and - :attr:`_loss_weights' lists. - - Loss functions are compiled into :class:`keras.compile_utils.LossesContainer` objects + def add_loss(self, function, weight=1.0, mask_channel=-1): + """ Add the given loss function with the given weight to the loss function chain. Parameters ---------- - loss_functions: list - A list of either a tuple of (:class:`keras.losses.Loss`, scalar weight) or just a - :class:`keras.losses.Loss` function. If just the loss function is passed, then the - weight is assumed to be 1.0 """ - for loss_func in loss_functions: - if isinstance(loss_func, tuple): - assert len(loss_func) == 2, "Tuple loss functions should contain 2 items" - assert isinstance(loss_func[1], float), "weight should be a float" - func, weight = loss_func - else: - func = loss_func - weight = 1.0 - self._loss_functions.append(compile_utils.LossesContainer(func)) - self._loss_weights.append(weight) - logger.debug("Compiled losses: (functions: %s, weights: %s", - self._loss_functions, self._loss_weights) + function: :class:`keras.losses.Loss` + The loss function to add to the loss chain + weight: float, optional + The weighting to apply to the loss function. Default: `1.0` + mask_channel: int, optional + The channel in the `y_true` image that the mask exists in. Set to `-1` if there is no + mask for the given loss function. Default: `-1` + """ + logger.debug("Adding loss: (function: %s, weight: %s, mask_channel: %s)", + function, weight, mask_channel) + self._loss_functions.append(compile_utils.LossesContainer(function)) + self._loss_weights.append(weight) + self._mask_channels.append(mask_channel) def call(self, y_true, y_pred): """ Call the sub loss functions for the loss wrapper. Weights are returned as the weighted sum of the chosen losses. + If a mask is being applied to the loss, then the appropriate mask is extracted from y_true + and added as the 4th channel being passed to the penalized loss function. + Parameters ---------- y_true: tensor or variable @@ -618,6 +551,45 @@ def call(self, y_true, y_pred): The final loss value """ loss = 0.0 - for func, weight in zip(self._loss_functions, self._loss_weights): - loss += (func(y_true, y_pred) * weight) + for func, weight, mask_channel in zip(self._loss_functions, + self._loss_weights, + self._mask_channels): + logger.debug("Processing loss function: (func: %s, weight: %s, mask_channel: %s)", + func, weight, mask_channel) + n_true, n_pred = self._apply_mask(y_true, y_pred, mask_channel) + loss += (func(n_true, n_pred) * weight) return loss + + @classmethod + def _apply_mask(cls, y_true, y_pred, mask_channel, mask_prop=1.0): + """ Apply the mask to the input y_true and y_pred. If a mask is not required then + return the unmasked inputs. + + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + mask_channel: int + The channel within y_true that the required mask resides in + mask_prop: float, optional + The amount of mask propagation. Default: `1.0` + + Returns + ------- + tuple + (n_true, n_pred): The ground truth and predicted value tensors with the mask applied + """ + if mask_channel == -1: + logger.debug("No mask to apply") + return y_true[..., :3], y_pred[..., :3] + + logger.debug("Applying mask from channel %s", mask_channel) + mask = K.expand_dims(y_true[..., mask_channel], axis=-1) + mask_as_k_inv_prop = 1 - mask_prop + mask = (mask * mask_prop) + mask_as_k_inv_prop + + n_true = K.concatenate([y_true[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) + n_pred = K.concatenate([y_pred[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) + return n_true, n_pred diff --git a/lib/training_data.py b/lib/training_data.py index e6bb5af9a6..0b8aa938c1 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -4,6 +4,7 @@ import logging from random import shuffle, choice +from zlib import decompress import numpy as np import cv2 @@ -16,7 +17,7 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name -class TrainingDataGenerator(): +class TrainingDataGenerator(): # pylint:disable=too-few-public-methods """ A Training Data Generator for compiling data for feeding to a model. This class is called from :mod:`plugins.train.trainer._base` and launches a background @@ -54,6 +55,17 @@ class TrainingDataGenerator(): * **masks** (`dict`, `optional`). Required if :attr:`penalized_mask_loss` or \ :attr:`learn_mask` is ``True``. Returning dictionary has a key of **side** (`str`) the \ value of which is a `dict` of {**filename** (`str`): :class:`lib.faces_detect.Mask`}. + + * **masks_eye** (`dict`, `optional`). Required if config option "eye_multiplier" is \ + a value greater than 1. Returning dictionary has a key of **side** (`str`) the \ + value of which is a `dict` of {**filename** (`str`): :class:`bytes`} which is a zipped \ + eye mask. + + * **masks_mouth** (`dict`, `optional`). Required if config option "mouth_multiplier" is \ + a value greater than 1. Returning dictionary has a key of **side** (`str`) the \ + value of which is a `dict` of {**filename** (`str`): :class:`bytes`} which is a zipped \ + mouth mask. + config: dict The configuration `dict` generated from :file:`config.train.ini` containing the trainer \ plugin configuration options. @@ -74,7 +86,9 @@ def __init__(self, model_input_size, model_output_shapes, coverage_ratio, augmen self._no_flip = no_flip self._warp_to_landmarks = warp_to_landmarks self._landmarks = alignments.get("landmarks", None) - self._masks = alignments.get("masks", None) + self._masks = dict(masks=alignments.get("masks", None), + eyes=alignments.get("masks_eye", None), + mouths=alignments.get("masks_mouth", None)) self._nearest_landmarks = {} # Batchsize and processing class are set when this class is called by a feeder @@ -234,27 +248,71 @@ def _process_batch(self, filenames, side): side, {k: v.shape if isinstance(v, np.ndarray) else[i.shape for i in v] for k, v in processed.items()}) - return processed def _apply_mask(self, filenames, batch, side): """ Applies the mask to the 4th channel of the image. If masks are not being used - applies a dummy all ones mask """ - logger.trace("Input batch shape: %s, side: %s", batch.shape, side) - if self._masks is None: - logger.trace("Creating dummy masks. side: %s", side) - masks = np.ones_like(batch[..., :1], dtype=batch.dtype) - else: - logger.trace("Obtaining masks for batch. side: %s", side) - masks = np.array([self._masks[side][filename].mask - for filename, face in zip(filenames, batch)], dtype=batch.dtype) - masks = self._resize_masks(batch.shape[1], masks) + applies a dummy all ones mask. + + If the configuration options `eye_multiplier` and/or `mouth_multiplier` are greater than 1 + then these masks are applied to the final channels of the batch respectively. + + Parameters + ---------- + filenames: list + The list of filenames that correspond to this batch + batch: :class:`numpy.ndarray` + The batch of faces that have been loaded from disk + side: str + '"a"' or '"b"' the side that is being processed - logger.trace("masks shape: %s", masks.shape) - batch = np.concatenate((batch, masks), axis=-1) + Returns + ------- + :class:`numpy.ndarray` + The batch with masks applied to the final channels + """ + logger.trace("Input batch shape: %s, side: %s", batch.shape, side) + size = batch.shape[1] + for key in ("masks", "eyes", "mouths"): + item = self._masks[key] + if item is None and key != "masks": + continue + if item is None and key == "masks": + logger.trace("Creating dummy masks. side: %s", side) + masks = np.ones_like(batch[..., :1], dtype=batch.dtype) + else: + logger.trace("Obtaining masks for batch. (key: %s side: %s)", key, side) + masks = np.array([self._get_mask(item[side][filename], size) + for filename, face in zip(filenames, batch)], dtype=batch.dtype) + masks = self._resize_masks(size, masks) + + logger.trace("masks: (key: %s, shape: %s)", key, masks.shape) + batch = np.concatenate((batch, masks), axis=-1) logger.trace("Output batch shape: %s, side: %s", batch.shape, side) return batch + @classmethod + def _get_mask(cls, item, size): + """ Decompress zipped eye and mouth masks, or return the stored mask + + Parameters + ---------- + item: :class:`lib.faces_detect.Mask` or `bytes` + Either a stored face mask object or a zipped eye or mouth mask + size: int + The size of the stored eye or mouth mask for reshaping + + Returns + ------- + class:`numpy.ndarray` + The decompressed mask + """ + if isinstance(item, bytes): + retval = np.frombuffer(decompress(item), dtype="uint8").reshape(size, size, 1) + else: + retval = item.mask + return retval + @staticmethod def _resize_masks(target_size, masks): """ Resize the masks to the target size """ @@ -446,10 +504,13 @@ def get_targets(self, batch): Parameters ---------- batch: :class:`numpy.ndarray` - This should be a 4-dimensional array of training images in the format (`batchsize`, + This should be a 4+-dimensional array of training images in the format (`batchsize`, `height`, `width`, `channels`). Targets should be requested after performing image transformations but prior to performing warps. + The 4th channel should be the mask. Any channels above the 4th should be any additional + masks that are requested. + Returns ------- dict @@ -472,7 +533,7 @@ def get_targets(self, batch): for image in batch], dtype='float32') / 255. for size in self._output_sizes] logger.trace("Target image shapes: %s", - [tgt_images.shape[1:] for tgt_images in target_batch]) + [tgt_images.shape for tgt_images in target_batch]) retval = self._separate_target_mask(target_batch) logger.trace("Final targets: %s", @@ -484,16 +545,31 @@ def get_targets(self, batch): def _separate_target_mask(target_batch): """ Return the batch and the batch of final masks - Returns the targets as a list of 4-dimensional :class:`numpy.ndarray` s of shape - (`batchsize`, `height`, `width`, `3`). + Parameters + ---------- + target_batch: list + List of 4 dimension :class:`numpy.ndarray` objects resized the model outputs. + The 4th channel of the array contains the face mask, any additional channels after + this are additional masks (e.g. eye mask and mouth mask) - The target masks are returned as its own item and is the 4th channel of the final target - output. + Returns + ------- + dict: + The targets and the masks separated into their own items. The targets are a list of + 3 channel, 4 dimensional :class:`numpy.ndarray` objects sized for each output from the + model. The masks are a :class:`numpy.ndarray` of the final output size. Any additional + masks(e.g. eye and mouth masks) will be collated together into a :class:`numpy.ndarray` + of the final output size. The number of channels will be the number of additional + masks available """ logger.trace("target_batch shapes: %s", [tgt.shape for tgt in target_batch]) retval = dict(targets=[batch[..., :3] for batch in target_batch], - masks=[target_batch[-1][..., 3:]]) - logger.trace("returning: %s", {k: [tgt.shape for tgt in v] for k, v in retval.items()}) + masks=target_batch[-1][..., 3][..., None]) + if target_batch[-1].shape[-1] > 4: + retval["additional_masks"] = target_batch[-1][..., 4:] + logger.trace("returning: %s", {k: v.shape if isinstance(v, np.ndarray) else [tgt.shape + for tgt in v] + for k, v in retval.items()}) return retval # <<< COLOR AUGMENTATION >>> # diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 9a4e4aeb9c..8468bcd3a0 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -264,6 +264,32 @@ def _set_loss(self): "\n\t 400 - Will give the penalty function 4x as much importance as the main " "loss function." "\n\t 0 - Disables L2 Regularization altogether.") + self.add_item( + section=section, + title="eye_multiplier", + datatype=int, + group="loss", + min_max=(1, 40), + rounding=1, + default=12, + info="The amount of priority to give to the eyes.\n\nThe value given here is as a " + "multiplier of the main loss score. For example:" + "\n\t 1 - The eyes will receive the same priority as the rest of the face. " + "\n\t 10 - The eyes will be given a score 10 times higher than the rest of the " + "face.") + self.add_item( + section=section, + title="mouth_multiplier", + datatype=int, + group="loss", + min_max=(1, 40), + rounding=1, + default=8, + info="The amount of priority to give to the mouth.\n\nThe value given here is as a " + "multiplier of the main loss score. For example:" + "\n\t 1 - The mouth will receive the same priority as the rest of the face. " + "\n\t 10 - The mouth will be given a score 10 times higher than the rest of the " + "face.") self.add_item( section=section, title="penalized_mask_loss", diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 49f87f7819..87d48e4e9d 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -991,28 +991,69 @@ def _set_loss_functions(self, output_names): output_names: list The output names from the model """ - selected_loss = self._loss_dict[self._config["loss_function"]] + mask_channels = self._get_mask_channels() + face_loss = self._loss_dict[self._config["loss_function"]] + for name, output_name in zip(self._names, output_names): if name.startswith("mask"): loss_func = self._loss_dict[self._config["mask_loss_function"]] else: - if (self._config["loss_function"] in self._uses_l2_reg - and self._config["l2_reg_term"] != 0): - loss_funcs = [selected_loss, self._loss_dict["mse"]] - loss_weights = [1.0, self._config["l2_reg_term"] / 100.0] - else: - loss_funcs = [selected_loss] - loss_weights = [1.0] - - if self._config["penalized_mask_loss"]: - loss_funcs = [losses.PenalizedLoss(loss) for loss in loss_funcs] - - loss_func = losses.LossWrapper(loss_functions=list(zip(loss_funcs, loss_weights))) + loss_func = losses.LossWrapper() + loss_func.add_loss(face_loss, mask_channel=mask_channels[0]) + self._add_l2_regularization_term(loss_func, mask_channels[0]) + + mask_channel = 1 + for multiplier in ("eye_multiplier", "mouth_multiplier"): + if self._config[multiplier] > 1: + loss_func.add_loss(face_loss, + weight=self._config[multiplier] * 1.0, + mask_channel=mask_channels[mask_channel]) + self._add_l2_regularization_term(loss_func, mask_channel) + mask_channel += 1 logger.debug("%s: (output_name: '%s', function: %s)", name, output_name, loss_func) self._funcs[output_name] = loss_func logger.debug("functions: %s", self._funcs) + def _add_l2_regularization_term(self, loss_wrapper, mask_channel): + """ Check if an L2 Regularization term should be added and add to the loss function + wrapper. + + Parameters + ---------- + loss_wrapper: :class:`lib.model.losses.LossWrapper` + The wrapper loss function that holds the face losses + mask_channel: int + The channel that holds the mask in `y_true`, if a mask is used for the loss. + `-1` if the input is not masked + """ + if self._config["loss_function"] in self._uses_l2_reg and self._config["l2_reg_term"] > 0: + logger.debug("Adding L2 Regularization for Structural Loss") + loss_wrapper.add_loss(self._loss_dict["mse"], + weight=self._config["l2_reg_term"] / 100.0, + mask_channel=mask_channel) + + def _get_mask_channels(self): + """ Obtain the channels from the face targets that the masks reside in from the training + data generator. + + Returns + ------- + list: + A list of channel indices that contain the mask for the corresponding config item + """ + uses_masks = (self._config["penalized_mask_loss"], + self._config["eye_multiplier"] > 1, + self._config["mouth_multiplier"] > 1) + mask_channels = [-1 for _ in range(len(uses_masks))] + current_channel = 3 + for idx, mask_required in enumerate(uses_masks): + if mask_required: + mask_channels[idx] = current_channel + current_channel += 1 + logger.debug("uses_masks: %s, mask_channels: %s", uses_masks, mask_channels) + return mask_channels + class State(): """ Holds state information relating to the plugin's saved model. diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 6e3ab7a997..92e5d9163a 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -101,12 +101,16 @@ def _get_alignments_data(self): Returns ------- dict: - Includes the key `landmarks` if landmarks are required for training and the key `masks` - if the masks are required for training. """ + Includes the key `landmarks` if landmarks are required for training, `masks` if masks + are required for training, `masks_eye` if eye masks are required and `masks_mouth` if + mouth masks are required. """ retval = dict() - get_masks = self._model.config["learn_mask"] or self._model.config["penalized_mask_loss"] - if not self._model.command_line_arguments.warp_to_landmarks and not get_masks: + if not any([self._model.config["learn_mask"], + self._model.config["penalized_mask_loss"], + self._model.config["eye_multiplier"] > 1, + self._model.config["mouth_multiplier"] > 1, + self._model.command_line_arguments.warp_to_landmarks]): return retval alignments = _TrainingAlignments(self._model, self._images) @@ -115,10 +119,17 @@ def _get_alignments_data(self): logger.debug("Adding landmarks to training opts dict") retval["landmarks"] = alignments.landmarks - if get_masks: + if self._model.config["learn_mask"] or self._model.config["penalized_mask_loss"]: logger.debug("Adding masks to training opts dict") retval["masks"] = alignments.masks - logger.debug(retval) + + if self._model.config["eye_multiplier"] > 1: + retval["masks_eye"] = alignments.masks_eye + + if self._model.config["mouth_multiplier"] > 1: + retval["masks_mouth"] = alignments.masks_mouth + + logger.debug({key: {k: len(v) for k, v in val.items()} for key, val in retval.items()}) return retval def _set_tensorboard(self): @@ -407,11 +418,13 @@ def get_batch(self): model_inputs = [] model_targets = [] for side in ("a", "b"): - side_inputs, side_targets = self._get_next(side) - if self._model.config["penalized_mask_loss"]: - side_targets = self._compile_masks(side_targets) - if not self._model.config["learn_mask"]: # Remove masks from the model targets - side_targets = side_targets[:-1] + batch = next(self._feeds[side]) + side_inputs = batch["feed"] + side_targets = self._compile_mask_targets(batch["targets"], + batch["masks"], + batch.get("additional_masks", None)) + if self._model.config["learn_mask"]: + side_targets = side_targets + [batch["masks"]] logger.trace("side: %s, input_shapes: %s, target_shapes: %s", side, [i.shape for i in side_inputs], [i.shape for i in side_targets]) if get_backend() == "amd": @@ -422,35 +435,21 @@ def get_batch(self): model_targets.append(side_targets) return model_inputs, model_targets - def _get_next(self, side): - """ Return the next batch from the :class:`lib.training_data.TrainingDataGenerator` for - this feeder ready for feeding into the model. - - Returns - ------- - model_inputs: list - A list of :class:`numpy.ndarray` for feeding into the model - model_targets: list - A list of :class:`numpy.ndarray` for comparing the output of the model - """ - logger.trace("Generating targets") - batch = next(self._feeds[side]) - targets_use_mask = (self._model.config["learn_mask"] - or self._model.config["penalized_mask_loss"]) - model_targets = batch["targets"] + batch["masks"] if targets_use_mask else batch["targets"] - return batch["feed"], model_targets - - @classmethod - def _compile_masks(cls, targets): - """ Compile the masks into the targets for penalized loss. + def _compile_mask_targets(self, targets, masks, additional_masks): + """ Compile the masks into the targets for penalized loss and for targeted learning. Penalized loss expects the target mask to be included for all outputs in the 4th channel - of the targets. The final output and final mask are always the last 2 outputs + of the targets. Any additional masks are placed into subsequent channels for extraction + by the relevant loss functions. Parameters ---------- targets: list The targets for the model, with the mask as the final entry in the list + masks: list + The masks for the model + additional_masks: list or ``None`` + Any additional masks for the model, or ``None`` if no additional masks are required Returns ------- @@ -458,15 +457,26 @@ def _compile_masks(cls, targets): The targets for the model with the mask compiled into the 4th channel. The original mask is still output as the final item in the list """ - masks = targets[-1] - for idx, tgt in enumerate(targets[:-1]): + if not self._model.config["penalized_mask_loss"] and additional_masks is None: + logger.trace("No masks to compile. Returning targets") + return targets + + if not self._model.config["penalized_mask_loss"] and additional_masks is not None: + masks = additional_masks + elif additional_masks is not None: + masks = np.concatenate((masks, additional_masks), axis=-1) + + for idx, tgt in enumerate(targets): tgt_dim = tgt.shape[1] if tgt_dim == masks.shape[1]: add_masks = masks else: add_masks = np.array([cv2.resize(mask, (tgt_dim, tgt_dim)) - for mask in masks])[..., None] + for mask in masks]) + if add_masks.ndim == 3: + add_masks = add_masks[..., None] targets[idx] = np.concatenate((tgt, add_masks), axis=-1) + logger.trace("masks added to targets: %s", [tgt.shape for tgt in targets]) return targets def generate_preview(self, do_preview): @@ -488,7 +498,7 @@ def generate_preview(self, do_preview): batch = next(self._display_feeds["preview"][side]) self._samples[side] = batch["samples"] self._target[side] = batch["targets"][-1] - self._masks[side] = batch["masks"][0] + self._masks[side] = batch["masks"] def compile_sample(self, batch_size, samples=None, images=None, masks=None): """ Compile the preview samples for display. @@ -547,7 +557,7 @@ def compile_timelapse_sample(self): batchsizes.append(len(batch["samples"])) samples[side] = batch["samples"] images[side] = batch["targets"][-1] - masks[side] = batch["masks"][0] + masks[side] = batch["masks"] batchsize = min(batchsizes) sample = self.compile_sample(batchsize, samples=samples, images=images, masks=masks) return sample @@ -1124,7 +1134,6 @@ def _get_masks(self, side, detected_faces): dict The face filenames as keys with the :class:`lib.faces_detect.Mask` as value. """ - masks = dict() for fhash, face in detected_faces.items(): mask = face.mask[self._config["mask_type"]] @@ -1134,6 +1143,53 @@ def _get_masks(self, side, detected_faces): masks[filename] = mask return masks + @property + def masks_eye(self): + """ dict: filename mapping to zip compressed eye masks for keys "a" and "b" """ + retval = {side: self._get_landmarks_masks(side, detected_faces, "eyes") + for side, detected_faces in self._detected_faces.items()} + return retval + + @property + def masks_mouth(self): + """ dict: filename mapping to zip compressed mouth masks for keys "a" and "b" """ + retval = {side: self._get_landmarks_masks(side, detected_faces, "mouth") + for side, detected_faces in self._detected_faces.items()} + return retval + + def _get_landmarks_masks(self, side, detected_faces, area): + """ Obtain the area landmarks masks for the given area. + + Parameters + ---------- + side: {"a" or "b"} + The side currently being processed + detected_faces: dict + Key is the hash of the face, value is the corresponding + :class:`lib.faces_detect.DetectedFace` object + area: {"eyes" or "mouth"} + The area of the face to obtain the mask for + + Returns + ------- + dict + The face filenames as keys with the zip compressed mask as value. + """ + logger.trace("side: %s, detected_faces: %s, area: %s", side, detected_faces, area) + masks = dict() + for fhash, face in detected_faces.items(): + mask = face.get_landmark_mask(self._training_size, + area, + aligned=True, + dilation=self._training_size // 32, + blur_kernel=self._training_size // 16, + as_zip=True) + for filename in self._hash_to_filenames(side, fhash): + masks[filename] = mask + logger.trace("side: %s, area: %s, masks: %s", + side, area, {key: type(val) for key, val in masks.items()}) + return masks + # Hashes for image folders @classmethod def _get_image_hashes(cls, image_list): From 24c45f90aa01bff2da820ca1af57330dc136e466 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 28 Aug 2020 02:00:03 +0100 Subject: [PATCH 290/981] Update INSTALL.md --- INSTALL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 719024b4ff..b85f720fa1 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -43,7 +43,7 @@ The type of computations that the process does are well suited for graphics card - Laptop CPUs can often run the software, but will not be fast enough to train at reasonable speeds - **A powerful GPU** - Currently, Nvidia GPUs are fully supported. and AMD graphics cards are partially supported through plaidML. - - If using an Nvidia GPU, then it needs to support at least CUDA Compute Capability 3.0 or higher. + - If using an Nvidia GPU, then it needs to support at least CUDA Compute Capability 3.5. (Release 1.0 will work on Compute Capability 3.0) To see which version your GPU supports, consult this list: https://developer.nvidia.com/cuda-gpus Desktop cards later than the 7xx series are most likely supported. - **A lot of patience** @@ -155,7 +155,7 @@ Obtain git for your distribution from the [git website](https://git-scm.com/down The recommended install method is to use a Conda3 Environment as this will handle the installation of Nvidia's CUDA and cuDNN straight into your Conda Environment. This is by far the easiest and most reliable way to setup the project. - MiniConda3 is recommended: [MiniConda3](https://docs.conda.io/en/latest/miniconda.html) -Alternatively you can install Python (>= 3.7-3.8 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Tensorflow yourself, make sure you install no higher than version 10.0 of CUDA and 7.5.x of CUDNN. +Alternatively you can install Python (>= 3.7-3.8 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Tensorflow yourself, make sure you install the correct Cuda and cuDNN package for the currently installed version of Tensorflow (Current release: Tensorflow 2.2. Release v1.0: Tensorflow 1.15). - Python distributions: - apt/yum install python3 (Linux) - [Installer](https://www.python.org/downloads/release/python-368/) (Windows) From 1e862990a82d02b1281ece576d10c810ed05bcbb Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 28 Aug 2020 02:01:50 +0100 Subject: [PATCH 291/981] Update INSTALL.md --- INSTALL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/INSTALL.md b/INSTALL.md index b85f720fa1..ef0836e05e 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -155,7 +155,7 @@ Obtain git for your distribution from the [git website](https://git-scm.com/down The recommended install method is to use a Conda3 Environment as this will handle the installation of Nvidia's CUDA and cuDNN straight into your Conda Environment. This is by far the easiest and most reliable way to setup the project. - MiniConda3 is recommended: [MiniConda3](https://docs.conda.io/en/latest/miniconda.html) -Alternatively you can install Python (>= 3.7-3.8 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Tensorflow yourself, make sure you install the correct Cuda and cuDNN package for the currently installed version of Tensorflow (Current release: Tensorflow 2.2. Release v1.0: Tensorflow 1.15). +Alternatively you can install Python (>= 3.7-3.8 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Tensorflow yourself, make sure you install the correct Cuda and cuDNN package for the currently installed version of Tensorflow (Current release: Tensorflow 2.2. Release v1.0: Tensorflow 1.15). You can check for the compatible versions here: (https://www.tensorflow.org/install/source#gpu). - Python distributions: - apt/yum install python3 (Linux) - [Installer](https://www.python.org/downloads/release/python-368/) (Windows) From 074f30569e1d399f3cf46bc7d435e0603c1e80d2 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 28 Aug 2020 10:55:16 +0000 Subject: [PATCH 292/981] Update losses unit tests --- tests/lib/model/losses_test.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/lib/model/losses_test.py b/tests/lib/model/losses_test.py index 5765713f75..b5fc01a582 100644 --- a/tests/lib/model/losses_test.py +++ b/tests/lib/model/losses_test.py @@ -43,17 +43,16 @@ def test_loss_output(loss_func, output_shape): assert output.dtype == "float32" and not np.isnan(output) -_PLPARAMS = _PARAMS + [(k_losses.mean_absolute_error, (2, 16, 16)), - (k_losses.mean_squared_error, (2, 16, 16)), - (k_losses.logcosh, (2, 16, 16)), - (losses.DSSIMObjective(), ())] -_PLIDS = ["GeneralizedLoss", "GradientLoss", "GMSDLoss", "LInfNorm", "mae", "mse", "logcosh", +_LWPARAMS = [losses.GeneralizedLoss(), losses.GradientLoss(), losses.GMSDLoss(), + losses.LInfNorm(), k_losses.mean_absolute_error, k_losses.mean_squared_error, + k_losses.logcosh, losses.DSSIMObjective()] +_LWIDS = ["GeneralizedLoss", "GradientLoss", "GMSDLoss", "LInfNorm", "mae", "mse", "logcosh", "DSSIMObjective"] -_PLIDS = ["{}[{}]".format(loss, get_backend().upper()) for loss in _PLIDS] +_LWIDS = ["{}[{}]".format(loss, get_backend().upper()) for loss in _LWIDS] -@pytest.mark.parametrize(["loss_func", "output_shape"], _PLPARAMS, ids=_PLIDS) -def test_penalized_loss(loss_func, output_shape): +@pytest.mark.parametrize("loss_func", _LWPARAMS, ids=_LWIDS) +def test_loss_wrapper(loss_func): """ Test penalized loss wrapper works as expected """ if get_backend() == "amd": if isinstance(loss_func, losses.GMSDLoss): @@ -62,10 +61,12 @@ def test_penalized_loss(loss_func, output_shape): pytest.skip("LogCosh Loss is not currently compatible with PlaidML") y_a = K.variable(np.random.random((2, 16, 16, 4))) y_b = K.variable(np.random.random((2, 16, 16, 3))) - p_loss = losses.PenalizedLoss(loss_func) + p_loss = losses.LossWrapper() + p_loss.add_loss(loss_func, 1.0, -1) + p_loss.add_loss(k_losses.mean_squared_error, 2.0, 3) output = p_loss(y_a, y_b) if get_backend() == "amd": - assert K.eval(output).shape == output_shape + assert K.dtype(output) == "float32" and K.eval(output).shape == (2, ) else: output = output.numpy() assert output.dtype == "float32" and not np.isnan(output) From e6d62b8315db51ec47280030483c578ef0e201fc Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 30 Aug 2020 23:43:08 +0100 Subject: [PATCH 293/981] Bugfixes: - dfl_h128 Legacy Weights update - lib.faces_detect - Logging fix --- lib/faces_detect.py | 2 +- plugins/train/model/_base.py | 8 +++++--- plugins/train/model/dfl_h128.py | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/faces_detect.py b/lib/faces_detect.py index 37eb3a92c7..370cb5b0e1 100644 --- a/lib/faces_detect.py +++ b/lib/faces_detect.py @@ -571,7 +571,7 @@ class _LandmarksMask(): # pylint:disable=too-few-public-methods """ def __init__(self, size, points, dilation=0, blur_kernel=0): logger.trace("Initializing: %s: (size: %s, points: %s, dilation: %s, blur_kernel: %s)", - size, points, dilation, blur_kernel) + self.__class__.__name__, size, points, dilation, blur_kernel) self._size = size self._points = points self._dilation = dilation diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 87d48e4e9d..068d301dee 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -292,22 +292,24 @@ def _update_legacy_models(self): os.mkdir(self.model_dir) new_model = self.build_model(self._get_inputs()) for model_name, layer_name in self._legacy_mapping().items(): - logger.info("Updating legacy weights from '%s'...", model_name) old_model = load_model(os.path.join(archive_dir, model_name), compile=False) layer = [layer for layer in new_model.layers if layer.name == layer_name] if not layer: + logger.warning("Skipping legacy weights from '%s'...", model_name) continue layer = layer[0] + logger.info("Updating legacy weights from '%s'...", model_name) layer.set_weights(old_model.get_weights()) filename = self._io._filename # pylint:disable=protected-access logger.info("Saving Tensorflow 2.x model to '%s'", filename) new_model.save(filename) # Penalized Loss and Learn Mask used to be disabled automatically if a mask wasn't # selected, so disable it if enabled, but mask_type is None - if self.config["penalized_mask_loss"] and self.config["mask_type"] is None: + if self.config["mask_type"] is None: self.config["penalized_mask_loss"] = False - if self.config["learn_mask"] and self.config["mask_type"] is None: self.config["learn_mask"] = False + self.config["eye_multiplier"] = 1 + self.config["mouth_multiplier"] = 1 self._state.save() def _validate_input_shape(self): diff --git a/plugins/train/model/dfl_h128.py b/plugins/train/model/dfl_h128.py index a11ef6d983..b2abb62d44 100644 --- a/plugins/train/model/dfl_h128.py +++ b/plugins/train/model/dfl_h128.py @@ -27,7 +27,7 @@ def encoder(self): var_x = Dense(8 * 8 * self.encoder_dim)(var_x) var_x = Reshape((8, 8, self.encoder_dim))(var_x) var_x = UpscaleBlock(self.encoder_dim)(var_x) - return KerasModel(input_, var_x, name=self.name) + return KerasModel(input_, var_x, name="encoder") def decoder(self, side): """ DFL H128 Decoder """ From 29d25080992b97ce0f931d0194031453ce530973 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 2 Sep 2020 22:51:00 +0100 Subject: [PATCH 294/981] GUI: Redirect read training images progress to stdout --- lib/gui/wrapper.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index 38b3ee9ef1..d6cc408c41 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -226,6 +226,9 @@ def read_stderr(self): if output: if self.command != "train" and self.capture_tqdm(output): continue + if self.command == "train" and output.startswith("Reading training images"): + print(output.strip(), file=sys.stdout) + continue print(output.strip(), file=sys.stderr) logger.debug("Terminated stderr reader") From 10da0c40eb2d18376b6921a9af548a7e4a581aed Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 5 Sep 2020 14:13:33 +0100 Subject: [PATCH 295/981] Loss multipliers: - Lower defaults. - Set to updateable --- plugins/train/_config.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 8468bcd3a0..add862a702 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -271,12 +271,14 @@ def _set_loss(self): group="loss", min_max=(1, 40), rounding=1, - default=12, + default=6, + fixed=False, info="The amount of priority to give to the eyes.\n\nThe value given here is as a " "multiplier of the main loss score. For example:" "\n\t 1 - The eyes will receive the same priority as the rest of the face. " "\n\t 10 - The eyes will be given a score 10 times higher than the rest of the " - "face.") + "face." + "\n\nNB: Penalized Mask Loss must be enable to use this option.") self.add_item( section=section, title="mouth_multiplier", @@ -284,12 +286,14 @@ def _set_loss(self): group="loss", min_max=(1, 40), rounding=1, - default=8, + default=4, + fixed=False, info="The amount of priority to give to the mouth.\n\nThe value given here is as a " - "multiplier of the main loss score. For example:" + "multiplier of the main loss score. For Example:" "\n\t 1 - The mouth will receive the same priority as the rest of the face. " "\n\t 10 - The mouth will be given a score 10 times higher than the rest of the " - "face.") + "face." + "\n\nNB: Penalized Mask Loss must be enable to use this option.") self.add_item( section=section, title="penalized_mask_loss", From a2f1cb46b90f16df58dcf8b84b5b558749b03e07 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 6 Sep 2020 14:34:20 +0100 Subject: [PATCH 296/981] Bugfix: Preview Tool. Fix blank swapped faces issue --- tools/preview/preview.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/preview/preview.py b/tools/preview/preview.py index 0fbe3b7d23..75ccb9bb9d 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -412,6 +412,7 @@ def __init__(self, arguments, available_masks, samples, thread_count=1, name="patch_thread") self._thread.start() + logger.debug("Initializing %s", self.__class__.__name__) @property def trigger(self): @@ -479,6 +480,9 @@ def _process(self, trigger_event, shutdown_event, patch_queue_in, samples, tk_va tk_vars: dict Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` """ + logger.debug("Launching patch process thread: (trigger_event: %s, shutdown_event: %s, " + "patch_queue_in: %s, samples: %s, tk_vars: %s)", trigger_event, + shutdown_event, patch_queue_in, samples, tk_vars) patch_queue_out = queue_manager.get_queue("preview_patch_out") while True: trigger = trigger_event.wait(1) @@ -489,7 +493,6 @@ def _process(self, trigger_event, shutdown_event, patch_queue_in, samples, tk_va continue # Clear trigger so calling process can set it during this run trigger_event.clear() - tk_vars["busy"].set(True) queue_manager.flush_queue("preview_patch_in") self._feed_swapped_faces(patch_queue_in, samples) with self._lock: @@ -500,6 +503,7 @@ def _process(self, trigger_event, shutdown_event, patch_queue_in, samples, tk_va self._display.destination = swapped tk_vars["refresh"].set(True) tk_vars["busy"].set(False) + logger.debug("Closed patch process thread") def _update_converter_arguments(self): """ Update the converter arguments to the currently selected values. """ From cd3fc8179a0340ca47d8c585d58edc8b860cbcf0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 6 Sep 2020 15:31:21 +0100 Subject: [PATCH 297/981] Training - Cache eye and mouth masks during first epoch rather than at startup --- lib/training_data.py | 42 ++++++++++++++++++++++++++++++---- plugins/train/trainer/_base.py | 15 +++++++----- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/lib/training_data.py b/lib/training_data.py index 0b8aa938c1..97be59a8ab 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -3,6 +3,7 @@ import logging +from functools import partial from random import shuffle, choice from zlib import decompress @@ -280,17 +281,48 @@ def _apply_mask(self, filenames, batch, side): if item is None and key == "masks": logger.trace("Creating dummy masks. side: %s", side) masks = np.ones_like(batch[..., :1], dtype=batch.dtype) - else: - logger.trace("Obtaining masks for batch. (key: %s side: %s)", key, side) - masks = np.array([self._get_mask(item[side][filename], size) - for filename, face in zip(filenames, batch)], dtype=batch.dtype) - masks = self._resize_masks(size, masks) + continue + + # Expand out partials for eye and mouth masks on first epoch + if item is not None and key in ("eyes", "mouths"): + self._expand_partials(side, item, filenames) + + logger.trace("Obtaining masks for batch. (key: %s side: %s)", key, side) + masks = np.array([self._get_mask(item[side][filename], size) + for filename in filenames], dtype=batch.dtype) + masks = self._resize_masks(size, masks) logger.trace("masks: (key: %s, shape: %s)", key, masks.shape) batch = np.concatenate((batch, masks), axis=-1) logger.trace("Output batch shape: %s, side: %s", batch.shape, side) return batch + @classmethod + def _expand_partials(cls, side, item, filenames): + """ Expand partials to their compressed byte masks and replace into the main item + dictionary. + + This is run once for each mask on the first epoch, to save on start up time. + + Parameters + ---------- + item: dict + The mask objects with filenames for the current mask type and side + filenames: list + A list of filenames that are being processed this batch + """ + to_process = {filename: item[side][filename] for filename in filenames} + if not any(isinstance(ptl, partial) for ptl in to_process.values()): + return + + for filename, ptl in to_process.items(): + if not isinstance(ptl, partial): + logger.debug("Mask already generated. side: '%s', filename: '%s'", + side, filename) + continue + logger.debug("Generating mask. side: '%s', filename: '%s'", side, filename) + item[side][filename] = ptl() + @classmethod def _get_mask(cls, item, size): """ Decompress zipped eye and mouth masks, or return the stored mask diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 92e5d9163a..94786dc14c 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -12,6 +12,8 @@ import os import time +from functools import partial + import cv2 import numpy as np @@ -1178,12 +1180,13 @@ def _get_landmarks_masks(self, side, detected_faces, area): logger.trace("side: %s, detected_faces: %s, area: %s", side, detected_faces, area) masks = dict() for fhash, face in detected_faces.items(): - mask = face.get_landmark_mask(self._training_size, - area, - aligned=True, - dilation=self._training_size // 32, - blur_kernel=self._training_size // 16, - as_zip=True) + mask = partial(face.get_landmark_mask, + self._training_size, + area, + aligned=True, + dilation=self._training_size // 32, + blur_kernel=self._training_size // 16, + as_zip=True) for filename in self._hash_to_filenames(side, fhash): masks[filename] = mask logger.trace("side: %s, area: %s, masks: %s", From 49f86e13de995423a2d973bab3a230884d6943b5 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 7 Sep 2020 00:03:00 +0100 Subject: [PATCH 298/981] training - Add config item to disable warp --- lib/training_data.py | 11 +- plugins/train/trainer/original_defaults.py | 172 ++++++++++----------- 2 files changed, 94 insertions(+), 89 deletions(-) diff --git a/lib/training_data.py b/lib/training_data.py index 97be59a8ab..d09ed70410 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -240,9 +240,14 @@ def _process_batch(self, filenames, side): processed.update(self._processing.get_targets(batch)) # Random Warp # TODO change masks to have a input mask and a warped target mask - processed["feed"] = [self._processing.warp(batch[..., :3], - self._warp_to_landmarks, - **warp_kwargs)] + if not self._config["disable_warp"]: + processed["feed"] = [self._processing.warp(batch[..., :3], + self._warp_to_landmarks, + **warp_kwargs)] + else: + size = (self._model_input_size, self._model_input_size) + processed["feed"] = [np.array([cv2.resize(img, size) + for img in batch[..., :3]]).astype("float32") / 255.0] logger.trace("Processed batch: (filenames: %s, side: '%s', processed: %s)", filenames, diff --git a/plugins/train/trainer/original_defaults.py b/plugins/train/trainer/original_defaults.py index 2f911cde12..87a49f2eb9 100755 --- a/plugins/train/trainer/original_defaults.py +++ b/plugins/train/trainer/original_defaults.py @@ -46,89 +46,89 @@ "Only change them if you absolutely know what you are doing!") -_DEFAULTS = { - "preview_images": { - "default": 14, - "info": "Number of sample faces to display for each side in the preview when training.", - "datatype": int, - "rounding": 2, - "min_max": (2, 16), - "group": "evaluation" - }, - "zoom_amount": { - "default": 5, - "info": "Percentage amount to randomly zoom each training image in and out.", - "datatype": int, - "rounding": 1, - "min_max": (0, 25), - "group": "image augmentation", - }, - "rotation_range": { - "default": 10, - "info": "Percentage amount to randomly rotate each training image.", - "datatype": int, - "rounding": 1, - "min_max": (0, 25), - "group": "image augmentation", - }, - "shift_range": { - "default": 5, - "info": "Percentage amount to randomly shift each training image horizontally and " - "vertically.", - "datatype": int, - "rounding": 1, - "min_max": (0, 25), - "group": "image augmentation", - }, - "flip_chance": { - "default": 50, - "info": "Percentage chance to randomly flip each training image horizontally.\n" - "NB: This is ignored if the 'no-flip' option is enabled", - "datatype": int, - "rounding": 1, - "min_max": (0, 75), - "group": "image augmentation", - }, - "color_lightness": { - "default": 30, - "info": "Percentage amount to randomly alter the lightness of each training image.\n" - "NB: This is ignored if the 'no-augment-color' option is enabled", - "datatype": int, - "rounding": 1, - "min_max": (0, 75), - "group": "color augmentation", - }, - "color_ab": { - "default": 8, - "info": "Percentage amount to randomly alter the 'a' and 'b' colors of the L*a*b* color " - "space of each training image.\n" - "NB: This is ignored if the 'no-augment-color' option is enabled", - "datatype": int, - "rounding": 1, - "min_max": (0, 50), - "group": "color augmentation", - }, - "color_clahe_chance": { - "default": 50, - "info": "Percentage chance to perform Contrast Limited Adaptive Histogram Equalization on " - "each training image.\n" - "NB: This is ignored if the 'no-augment-color' option is enabled", - "datatype": int, - "rounding": 1, - "min_max": (0, 75), - "fixed": False, - "group": "color augmentation", - }, - "color_clahe_max_size": { - "default": 4, - "info": "The grid size dictates how much Contrast Limited Adaptive Histogram Equalization " - "is performed on any training image selected for clahe. Contrast will be applied " - "randomly with a gridsize of 0 up to the maximum. This value is a multiplier " - "calculated from the training image size.\n" - "NB: This is ignored if the 'no-augment-color' option is enabled", - "datatype": int, - "rounding": 1, - "min_max": (1, 8), - "group": "color augmentation", - }, -} +_DEFAULTS = dict( + preview_images=dict( + default=14, + info="Number of sample faces to display for each side in the preview when training.", + datatype=int, + rounding=2, + min_max=(2, 16), + group="evaluation"), + zoom_amount=dict( + default=5, + info="Percentage amount to randomly zoom each training image in and out.", + datatype=int, + rounding=1, + min_max=(0, 25), + group="image augmentation"), + rotation_range=dict( + default=10, + info="Percentage amount to randomly rotate each training image.", + datatype=int, + rounding=1, + min_max=(0, 25), + group="image augmentation"), + shift_range=dict( + default=5, + info="Percentage amount to randomly shift each training image horizontally and " + "vertically.", + datatype=int, + rounding=1, + min_max=(0, 25), + group="image augmentation"), + flip_chance=dict( + default=50, + info="Percentage chance to randomly flip each training image horizontally.\n" + "NB: This is ignored if the 'no-flip' option is enabled", + datatype=int, + rounding=1, + min_max=(0, 75), + group="image augmentation"), + disable_warp=dict( + default=False, + info="Disable warp augmentation. Warping is integral to the Neural Network training. If " + "you decide to disable warping, you should only do so towards the end of a model's " + "training session.", + datatype=bool, + group="image augmentation", + fixed=False), + + color_lightness=dict( + default=30, + info="Percentage amount to randomly alter the lightness of each training image.\n" + "NB: This is ignored if the 'no-flip' option is enabled", + datatype=int, + rounding=1, + min_max=(0, 75), + group="color augmentation"), + color_ab=dict( + default=8, + info="Percentage amount to randomly alter the 'a' and 'b' colors of the L*a*b* color " + "space of each training image.\nNB: This is ignored if the 'no-flip' option is " + "enabled", + datatype=int, + rounding=1, + min_max=(0, 50), + group="color augmentation"), + color_clahe_chance=dict( + default=50, + info="Percentage chance to perform Contrast Limited Adaptive Histogram Equalization on " + "each training image.\nNB: This is ignored if the 'no-augment-color' option is " + "enabled", + datatype=int, + rounding=1, + min_max=(0, 75), + fixed=False, + group="color augmentation"), + color_clahe_max_size=dict( + default=4, + info="The grid size dictates how much Contrast Limited Adaptive Histogram Equalization is " + "performed on any training image selected for clahe. Contrast will be applied " + "randomly with a gridsize of 0 up to the maximum. This value is a multiplier " + "calculated from the training image size.\nNB: This is ignored if the " + "'no-augment-color' option is enabled", + datatype=int, + rounding=1, + min_max=(1, 8), + group="color augmentation"), +) From 5530346aa0c9c9e1cebd9ca6c0fd7d5f80d36ec2 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 7 Sep 2020 00:09:41 +0100 Subject: [PATCH 299/981] Bugfix - Pre-caching masks when warp-to-landmarks is disabled --- plugins/train/trainer/_base.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 94786dc14c..95a2383a05 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -1047,16 +1047,14 @@ def __init__(self, model, image_list): self._hashes = self._get_image_hashes(image_list) self._detected_faces = self._load_alignments() self._check_all_faces() + self._landmarks = self._get_landmarks() logger.debug("Initialized %s", self.__class__.__name__) # Get landmarks @property def landmarks(self): """ dict: The :class:`numpy.ndarray` aligned landmarks for keys "a" and "b" """ - retval = {side: self._transform_landmarks(side, detected_faces) - for side, detected_faces in self._detected_faces.items()} - logger.trace(retval) - return retval + return self._landmarks def _get_alignments_paths(self): """ Obtain the alignments file paths from the command line arguments passed to the model. @@ -1087,6 +1085,20 @@ def _get_alignments_paths(self): logger.debug("Alignments paths: %s", retval) return retval + def _get_landmarks(self): + """ Pre-generate landmarks as they are needed for both warp to landmarks and eye/mouth + masks. + + Returns + ------- + dict + The :class:`numpy.ndarray` aligned landmarks for keys "a" and "b" + """ + retval = {side: self._transform_landmarks(side, detected_faces) + for side, detected_faces in self._detected_faces.items()} + logger.trace(retval) + return retval + def _transform_landmarks(self, side, detected_faces): """ Transform frame landmarks to their aligned face variant. From 1daa7dc6f6df2cb22af40e641d93e808c830e774 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 24 Sep 2020 00:23:25 +0000 Subject: [PATCH 300/981] GUI - Stats optimization (#1067) * Faster stat loading + caching * Compress data in cache * Optimize some calculations * Vectorize smoothing * stats.Calculations optimized * Load latest training data from live iterator * Add options to training graph --- docs/full/lib/gui.rst | 43 +- lib/gui/__init__.py | 1 - lib/gui/display_analysis.py | 764 ++++++------------ lib/gui/display_command.py | 213 ++++- lib/gui/display_graph.py | 49 +- lib/gui/display_page.py | 7 +- lib/gui/popup_session.py | 500 ++++++++++++ lib/gui/stats.py | 1452 +++++++++++++++++++++++++---------- lib/gui/utils.py | 21 +- lib/gui/wrapper.py | 46 +- plugins/extract/_base.py | 1 + scripts/gui.py | 5 +- 12 files changed, 2108 insertions(+), 994 deletions(-) create mode 100644 lib/gui/popup_session.py diff --git a/docs/full/lib/gui.rst b/docs/full/lib/gui.rst index c0fca80358..900e3a844b 100755 --- a/docs/full/lib/gui.rst +++ b/docs/full/lib/gui.rst @@ -2,7 +2,7 @@ gui package *********** -The GUI Package contains the entire code base for Faceswap's optional GUI. The GUI itself itself +The GUI Package contains the entire code base for Faceswap's optional GUI. The GUI itself is largely self-generated from the command line options specified in :mod:`lib.cli.args`. .. contents:: Contents @@ -40,7 +40,18 @@ display module display\_analysis module ======================== -.. autoclass:: lib.gui.display_analysis.Analysis + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.gui.display_analysis.Analysis + ~lib.gui.display_analysis.StatsData + +.. rubric:: Module + +.. automodule:: lib.gui.display_analysis :members: :undoc-members: :show-inheritance: @@ -52,6 +63,13 @@ popup_configure module :undoc-members: :show-inheritance: +popup_session module +====================== +.. automodule:: lib.gui.popup_session + :members: + :undoc-members: + :show-inheritance: + project module ============== @@ -71,6 +89,27 @@ project module :undoc-members: :show-inheritance: +stats module +============ + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.gui.stats.Calculations + ~lib.gui.stats.ExponentialMovingAverage + ~lib.gui.stats.GlobalSession + ~lib.gui.stats.SessionsSummary + ~lib.gui.stats.TensorBoardLogs + +.. rubric:: Module + +.. automodule:: lib.gui.stats + :members: + :undoc-members: + :show-inheritance: + utils module ============ diff --git a/lib/gui/__init__.py b/lib/gui/__init__.py index 8bf41c9b3d..b66741baf0 100644 --- a/lib/gui/__init__.py +++ b/lib/gui/__init__.py @@ -4,7 +4,6 @@ from lib.gui.options import CliOptions from lib.gui.menu import MainMenuBar, TaskBar from lib.gui.project import LastSession -from lib.gui.stats import Session from lib.gui.utils import (get_config, get_images, initialize_config, initialize_images, preview_trigger) from lib.gui.wrapper import ProcessWrapper diff --git a/lib/gui/display_analysis.py b/lib/gui/display_analysis.py index c4d79297e3..c1cc9445f0 100644 --- a/lib/gui/display_analysis.py +++ b/lib/gui/display_analysis.py @@ -7,11 +7,10 @@ import tkinter as tk from tkinter import ttk -from .control_helper import ControlBuilder, ControlPanelOption -from .display_graph import SessionGraph -from .display_page import DisplayPage -from .stats import Calculations, Session from .custom_widgets import Tooltip +from .display_page import DisplayPage +from .popup_session import SessionPopUp +from .stats import Session from .utils import FileHandler, get_config, get_images, LongRunningTask logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -36,7 +35,6 @@ def __init__(self, parent, tab_name, helptext): self.__class__.__name__, parent, tab_name, helptext) super().__init__(parent, tab_name, helptext) self._summary = None - self._session = None self._reset_session_info() _Options(self) @@ -95,12 +93,9 @@ def _set_callbacks(self): Training graph refresh - Updates the stats for the current training session when the graph has been updated. - When training is commenced - Removes the currently displayed session. - When the analysis folder has been populated - Updates the stats from that folder. """ self.vars["refresh_graph"].trace("w", self._update_current_session) - self.vars["is_training"].trace("w", self._remove_current_session) self.vars["analysis_folder"].trace("w", self._populate_from_folder) def _update_current_session(self, *args): # pylint:disable=unused-argument @@ -113,13 +108,6 @@ def _update_current_session(self, *args): # pylint:disable=unused-argument logger.debug("Analysis update callback received") self._reset_session() - def _remove_current_session(self, *args): # pylint:disable=unused-argument - """ Remove the current session data on a is_training=False callback """ - if self.vars["is_training"].get(): - return - logger.debug("Remove current training Analysis callback received") - self._clear_session() - def _reset_session_info(self): """ Reset the session info status to default """ logger.debug("Resetting session info") @@ -130,6 +118,9 @@ def _populate_from_folder(self, *args): # pylint:disable=unused-argument Triggered when :attr:`vars` ``analysis_folder`` variable is is set. """ + if Session.is_training: + return + folder = self.vars["analysis_folder"].get() if not folder or not os.path.isdir(folder): logger.debug("Not a valid folder") @@ -178,11 +169,17 @@ def _get_model_name(cls, model_dir, state_file): return model_name def _set_session_summary(self, message): - """ Set the summary data and info message """ + """ Set the summary data and info message. + + Parameters + ---------- + message: str + The information message to set + """ if self._thread is None: logger.debug("Setting session summary. (message: '%s')", message) self._thread = LongRunningTask(target=self._summarise_data, - args=(self._session, ), + args=(Session, ), widget=self) self._thread.start() self.after(1000, lambda msg=message: self._set_session_summary(msg)) @@ -199,25 +196,30 @@ def _set_session_summary(self, message): self._summary = result self._thread = None self.set_info("Session: {}".format(message)) - self._stats.session = self._session self._stats.tree_insert_data(self._summary) @classmethod def _summarise_data(cls, session): - """ Summarize data in a LongRunningThread as it can take a while """ + """ Summarize data in a LongRunningThread as it can take a while. + + Parameters + ---------- + session: :class:`lib.gui.stats.Session` + The session object to generate the summary for + """ return session.full_summary def _clear_session(self): """ Clear the currently displayed analysis data from the Tree-View. """ logger.debug("Clearing session") - if self._session is None: + if not Session.is_loaded: logger.trace("No session loaded. Returning") return self._summary = None - self._stats.session = None self._stats.tree_clear() - self._reset_session_info() - self._session = None + if not Session.is_training: + self._reset_session_info() + Session.clear() def _load_session(self, full_path=None): """ Load the session statistics from a model's state file into the Analysis tab of the GUI @@ -243,8 +245,7 @@ def _load_session(self, full_path=None): model_name = self._get_model_name(model_dir, state_file) if not model_name: return - self._session = Session(model_dir=model_dir, model_name=model_name) - self._session.initialize_session(is_training=False) + Session.initialize_session(model_dir, model_name, is_training=False) msg = full_path if len(msg) > 70: msg = "...{}".format(msg[-70:]) @@ -254,19 +255,14 @@ def _reset_session(self): """ Reset currently training sessions. Clears the current session and loads in the latest data. """ logger.debug("Reset current training session") - self._clear_session() - session = get_config().session - if not session.initialized: + if not Session.is_training: logger.debug("Training not running") return - if session.logging_disabled: + if Session.logging_disabled: logger.trace("Logging disabled. Not triggering analysis update") return - msg = "Currently running training session" - self._session = session - # Reload the state file to get approx currently training iterations - self._session.load_state_file() - self._set_session_summary(msg) + self._clear_session() + self._set_session_summary("Currently running training session") def _save_session(self): """ Launch a file dialog pop-up to save the current analysis data to a CSV file. """ @@ -300,11 +296,19 @@ class _Options(): # pylint:disable=too-few-public-methods def __init__(self, parent): logger.debug("Initializing: %s (parent: %s)", self.__class__.__name__, parent) self._parent = parent - self._add_buttons() + self._buttons = self._add_buttons() + self._add_training_callback() logger.debug("Initialized: %s", self.__class__.__name__) def _add_buttons(self): - """ Add the option buttons """ + """ Add the option buttons. + + Returns + ------- + dict + The button names to button objects + """ + buttons = dict() for btntype in ("clear", "save", "load"): logger.debug("Adding button: '%s'", btntype) cmd = getattr(self._parent, "_{}_session".format(btntype)) @@ -314,78 +318,138 @@ def _add_buttons(self): btn.pack(padx=2, side=tk.RIGHT) hlp = self._set_help(btntype) Tooltip(btn, text=hlp, wraplength=200) + buttons[btntype] = btn + logger.debug("buttons: %s", buttons) + return buttons @classmethod - def _set_help(cls, btntype): - """ Set the help text for option buttons """ + def _set_help(cls, button_type): + """ Set the help text for option buttons. + + Parameters + ---------- + button_type: {"reload", "clear", "save", "load"} + The type of button to set the help text for + """ logger.debug("Setting help") hlp = "" - if btntype == "reload": + if button_type == "reload": hlp = "Load/Refresh stats for the currently training session" - elif btntype == "clear": + elif button_type == "clear": hlp = "Clear currently displayed session stats" - elif btntype == "save": + elif button_type == "save": hlp = "Save session stats to csv" - elif btntype == "load": + elif button_type == "load": hlp = "Load saved session stats" return hlp + def _add_training_callback(self): + """ Add a callback to the training tkinter variable to disable save and clear buttons + when a model is training. """ + var = self._parent.vars["is_training"] + var.trace("w", self._set_buttons_state) + + def _set_buttons_state(self, *args): # pylint:disable=unused-argument + """ Callback to enable/disable button when training is commenced and stopped. """ + is_training = self._parent.vars["is_training"].get() + state = "disabled" if is_training else "!disabled" + for name, button in self._buttons.items(): + if name not in ("load", "clear"): + continue + logger.debug("Setting %s button state to %s", name, state) + button.state([state]) + class StatsData(ttk.Frame): # pylint: disable=too-many-ancestors - """ Stats frame of analysis tab """ + """ Stats frame of analysis tab. + + Holds the tree-view containing the summarized session statistics in the Analysis tab. + + Parameters + ---------- + parent: :class:`tkinter.Frame` + The frame within the Analysis Notebook that will hold the statistics + selected_id: :class:`tkinter.IntVar` + The tkinter variable that holds the currently selected session ID + helptext: str + The help text to display for the summary statistics page + """ def __init__(self, parent, selected_id, helptext): logger.debug("Initializing: %s: (parent, %s, selected_id: %s, helptext: '%s')", self.__class__.__name__, parent, selected_id, helptext) super().__init__(parent) + self._selected_id = selected_id + self._popup_positions = list() + + self._canvas = tk.Canvas(self, bd=0, highlightthickness=0) + tree_frame = ttk.Frame(self._canvas) + self._tree_canvas = self._canvas.create_window((0, 0), window=tree_frame, anchor=tk.NW) + self._sub_frame = ttk.Frame(tree_frame) + + self._add_label() + + self._tree = ttk.Treeview(self._sub_frame, height=1, selectmode=tk.BROWSE) + self._scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=self._tree.yview) + + self._columns = self._tree_configure(helptext) + self._canvas.bind("", self._resize_frame) + + self._scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + self._tree.pack(side=tk.TOP, fill=tk.X) + self._sub_frame.pack(side=tk.LEFT, fill=tk.X, anchor=tk.N, expand=True) + self._canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) self.pack(side=tk.TOP, padx=5, pady=5, fill=tk.BOTH, expand=True) - self.session = None # set when loading or clearing from parent - self.thread = None # Thread for loading data popup - self.selected_id = selected_id - self.popup_positions = list() - - self.canvas = tk.Canvas(self, bd=0, highlightthickness=0) - self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - - self.tree_frame = ttk.Frame(self.canvas) - self.tree_canvas = self.canvas.create_window((0, 0), window=self.tree_frame, anchor=tk.NW) - self.sub_frame = ttk.Frame(self.tree_frame) - self.sub_frame.pack(side=tk.LEFT, fill=tk.X, anchor=tk.N, expand=True) - - self.add_label() - self.tree = ttk.Treeview(self.sub_frame, height=1, selectmode=tk.BROWSE) - self.scrollbar = ttk.Scrollbar(self.tree_frame, orient="vertical", command=self.tree.yview) - self.scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - self.columns = self.tree_configure(helptext) - self.canvas.bind("", self.resize_frame) + logger.debug("Initialized: %s", self.__class__.__name__) - def add_label(self): - """ Add tree-view Title """ + def _add_label(self): + """ Add the title above the tree-view. """ logger.debug("Adding Treeview title") - lbl = ttk.Label(self.sub_frame, text="Session Stats", anchor=tk.CENTER) + lbl = ttk.Label(self._sub_frame, text="Session Stats", anchor=tk.CENTER) lbl.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5) - def resize_frame(self, event): - """ Resize the options frame to fit the canvas """ + def _resize_frame(self, event): + """ Resize the options frame to fit the canvas. + + Parameters + ---------- + event: `tkinter.Event` + The tkinter resize event + """ logger.debug("Resize Analysis Frame") canvas_width = event.width canvas_height = event.height - self.canvas.itemconfig(self.tree_canvas, width=canvas_width, height=canvas_height) + self._canvas.itemconfig(self._tree_canvas, width=canvas_width, height=canvas_height) logger.debug("Resized Analysis Frame") - def tree_configure(self, helptext): - """ Build a tree-view widget to hold the sessions stats """ + def _tree_configure(self, helptext): + """ Build a tree-view widget to hold the sessions stats. + + Parameters + ---------- + helptext: str + The helptext to display when the mouse is over the tree-view + + Returns + ------- + list + The list of tree-view columns + """ logger.debug("Configuring Treeview") - self.tree.configure(yscrollcommand=self.scrollbar.set) - self.tree.tag_configure("total", background="black", foreground="white") - self.tree.pack(side=tk.TOP, fill=tk.X) - self.tree.bind("", self.select_item) - Tooltip(self.tree, text=helptext, wraplength=200) - return self.tree_columns() - - def tree_columns(self): - """ Add the columns to the totals tree-view """ + self._tree.configure(yscrollcommand=self._scrollbar.set) + self._tree.tag_configure("total", background="black", foreground="white") + self._tree.bind("", self._select_item) + Tooltip(self._tree, text=helptext, wraplength=200) + return self._tree_columns() + + def _tree_columns(self): + """ Add the columns to the totals tree-view. + + Returns + ------- + list + The list of tree-view columns + """ logger.debug("Adding Treeview columns") columns = (("session", 40, "#"), ("start", 130, None), @@ -394,84 +458,106 @@ def tree_columns(self): ("batch", 50, None), ("iterations", 90, None), ("rate", 60, "EGs/sec")) - self.tree["columns"] = [column[0] for column in columns] + self._tree["columns"] = [column[0] for column in columns] for column in columns: text = column[2] if column[2] else column[0].title() logger.debug("Adding heading: '%s'", text) - self.tree.heading(column[0], text=text) - self.tree.column(column[0], width=column[1], anchor=tk.E, minwidth=40) - self.tree.column("#0", width=40) - self.tree.heading("#0", text="Graphs") + self._tree.heading(column[0], text=text) + self._tree.column(column[0], width=column[1], anchor=tk.E, minwidth=40) + self._tree.column("#0", width=40) + self._tree.heading("#0", text="Graphs") return [column[0] for column in columns] def tree_insert_data(self, sessions_summary): - """ Insert the data into the totals tree-view """ + """ Insert the summary data into the statistics tree-view. + + Parameters + ---------- + sessions_summary: list + List of session summary dicts for populating into the tree-view + """ logger.debug("Inserting treeview data") - self.tree.configure(height=len(sessions_summary)) + self._tree.configure(height=len(sessions_summary)) for item in sessions_summary: - values = [item[column] for column in self.columns] + values = [item[column] for column in self._columns] kwargs = {"values": values} - if self.check_valid_data(values): + if self._check_valid_data(values): # Don't show graph icon for non-existent sessions kwargs["image"] = get_images().icons["graph"] if values[0] == "Total": kwargs["tags"] = "total" - self.tree.insert("", "end", **kwargs) + self._tree.insert("", "end", **kwargs) def tree_clear(self): - """ Clear the totals tree """ + """ Clear all of the summary data from the tree-view. """ logger.debug("Clearing treeview data") try: - self.tree.delete(* self.tree.get_children()) - self.tree.configure(height=1) + self._tree.delete(* self._tree.get_children()) + self._tree.configure(height=1) except tk.TclError: # Catch non-existent tree view when rebuilding the GUI pass - def select_item(self, event): - """ Update the session summary info with - the selected item or launch graph """ - region = self.tree.identify("region", event.x, event.y) - selection = self.tree.focus() - values = self.tree.item(selection, "values") + def _select_item(self, event): + """ Update the session summary info with the selected item or launch graph. + + If the mouse is clicked on the graph icon, then the session summary pop-up graph is + launched. Otherwise the selected ID is stored. + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse button release event + """ + region = self._tree.identify("region", event.x, event.y) + selection = self._tree.focus() + values = self._tree.item(selection, "values") if values: logger.debug("Selected values: %s", values) - self.selected_id.set(values[0]) - if region == "tree" and self.check_valid_data(values): - datapoints = int(values[self.columns.index("iterations")]) - self.data_popup(datapoints) - - def check_valid_data(self, values): - """ Check there is valid data available for popping up a graph """ - col_indices = [self.columns.index("batch"), self.columns.index("iterations")] + self._selected_id.set(values[0]) + if region == "tree" and self._check_valid_data(values): + data_points = int(values[self._columns.index("iterations")]) + self._data_popup(data_points) + + def _check_valid_data(self, values): + """ Check there is valid data available for popping up a graph. + + Parameters + ---------- + values: list + The values that exist for a single session that are to be validated + """ + col_indices = [self._columns.index("batch"), self._columns.index("iterations")] for idx in col_indices: if (isinstance(values[idx], int) or values[idx].isdigit()) and int(values[idx]) == 0: logger.warning("No data to graph for selected session") return False return True - def data_popup(self, datapoints): + def _data_popup(self, data_points): """ Pop up a window and control it's position - The default view is rolling average over 500 points. - If there are fewer data points than this, switch the default - to smoothed + The default view is rolling average over 500 points. If there are fewer data points than + this, switch the default to smoothed, + + Parameters + ---------- + data_points: int + The number of iterations that are to be plotted """ logger.debug("Popping up data window") scaling_factor = get_config().scaling_factor - toplevel = SessionPopUp(self.session.modeldir, - self.session.modelname, - self.selected_id.get(), - datapoints) - toplevel.title(self.data_popup_title()) + toplevel = SessionPopUp(self._selected_id.get(), + data_points) + toplevel.title(self._data_popup_title()) toplevel.tk.call( 'wm', 'iconphoto', toplevel._w, get_images().icons["favicon"]) # pylint:disable=protected-access - position = self.data_popup_get_position() + position = self._data_popup_get_position() height = int(900 * scaling_factor) width = int(480 * scaling_factor) toplevel.geometry("{}x{}+{}+{}".format(str(height), @@ -480,32 +566,59 @@ def data_popup(self, datapoints): str(position[1]))) toplevel.update() - def data_popup_title(self): - """ Set the data popup title """ + def _data_popup_title(self): + """ Get the summary graph popup title. + + Returns + ------- + str + The title to display at the top of the pop-up graph window + """ logger.debug("Setting poup title") - selected_id = self.selected_id.get() + selected_id = self._selected_id.get() + model_dir, model_name = os.path.split(Session.model_filename) title = "All Sessions" if selected_id != "Total": - title = "{} Model: Session #{}".format(self.session.modelname.title(), selected_id) + title = "{} Model: Session #{}".format(model_name.title(), selected_id) logger.debug("Title: '%s'", title) - return "{} - {}".format(title, self.session.modeldir) + return "{} - {}".format(title, model_dir) + + def _data_popup_get_position(self): + """ Get the position of the next window to pop the summary graph to. - def data_popup_get_position(self): - """ Get the position of the next window """ + Returns + ------- + list + The [x, y] co-ordinates that the pop up window should be placed at + """ logger.debug("getting poup position") init_pos = [120, 120] pos = init_pos while True: - if pos not in self.popup_positions: - self.popup_positions.append(pos) + if pos not in self._popup_positions: + self._popup_positions.append(pos) break pos = [item + 200 for item in pos] - init_pos, pos = self.data_popup_check_boundaries(init_pos, pos) + init_pos, pos = self._data_popup_check_boundaries(init_pos, pos) logger.debug("Position: %s", pos) return pos - def data_popup_check_boundaries(self, initial_position, position): - """ Check that the popup remains within the screen boundaries """ + def _data_popup_check_boundaries(self, initial_position, position): + """ Check that the popup remains within the screen boundaries. + + Parameters + ---------- + initial_position: list + The [x, y] position of the last displayed popup window + position: list + The requested [x, y] position for the new popup window + + Returns + ------- + tuple + The original initial_position and position, adjusted if the new window would go out of + bounds + """ logger.debug("Checking poup boundaries: (initial_position: %s, position: %s)", initial_position, position) boundary_x = self.winfo_screenwidth() - 120 @@ -516,396 +629,3 @@ def data_popup_check_boundaries(self, initial_position, position): logger.debug("Returning poup boundaries: (initial_position: %s, position: %s)", initial_position, position) return initial_position, position - - -class SessionPopUp(tk.Toplevel): - """ Pop up for detailed graph/stats for selected session """ - def __init__(self, model_dir, model_name, session_id, datapoints): - logger.debug("Initializing: %s: (model_dir: %s, model_name: %s, session_id: %s, " - "datapoints: %s)", self.__class__.__name__, model_dir, model_name, session_id, - datapoints) - super().__init__() - self.thread = None # Thread for loading data in a background task - self.default_avg = 500 - self.default_view = "avg" if datapoints > self.default_avg * 2 else "smoothed" - self.session_id = session_id - self.session = Session(model_dir=model_dir, model_name=model_name) - self.initialize_session() - - self.graph_frame = None - self.graph = None - self.display_data = None - - self.vars = {"status": tk.StringVar()} - self.graph_initialised = False - self.build() - logger.debug("Initialized: %s", self.__class__.__name__) - - @property - def is_totals(self): - """ Return True if these are totals else False """ - return bool(self.session_id == "Total") - - def initialize_session(self): - """ Initialize the session """ - logger.debug("Initializing session") - kwargs = dict(is_training=False) - if not self.is_totals: - kwargs["session_id"] = int(self.session_id) - logger.debug("Session kwargs: %s", kwargs) - self.session.initialize_session(**kwargs) - - def build(self): - """ Build the popup window """ - logger.debug("Building popup") - optsframe = self.layout_frames() - self.set_callback() - self.opts_build(optsframe) - self.compile_display_data() - logger.debug("Built popup") - - def set_callback(self): - """ Set a tkinter Boolean var to callback when graph is ready to build """ - logger.debug("Setting tk graph build variable") - var = tk.BooleanVar() - var.set(False) - var.trace("w", self.graph_build) - self.vars["buildgraph"] = var - - def layout_frames(self): - """ Top level container frames """ - logger.debug("Layout frames") - leftframe = ttk.Frame(self) - leftframe.pack(side=tk.LEFT, expand=False, fill=tk.BOTH, pady=5) - - sep = ttk.Frame(self, width=2, relief=tk.RIDGE) - sep.pack(fill=tk.Y, side=tk.LEFT) - - self.graph_frame = ttk.Frame(self) - self.graph_frame.pack(side=tk.RIGHT, fill=tk.BOTH, pady=5, expand=True) - logger.debug("Laid out frames") - - return leftframe - - def opts_build(self, frame): - """ Build Options into the options frame """ - logger.debug("Building Options") - self.opts_combobox(frame) - self.opts_checkbuttons(frame) - self.opts_loss_keys(frame) - self.opts_slider(frame) - self.opts_buttons(frame) - sep = ttk.Frame(frame, height=2, relief=tk.RIDGE) - sep.pack(fill=tk.X, pady=(5, 0), side=tk.BOTTOM) - logger.debug("Built Options") - - def opts_combobox(self, frame): - """ Add the options combo boxes """ - logger.debug("Building Combo boxes") - choices = {"Display": ("Loss", "Rate"), - "Scale": ("Linear", "Log")} - - for item in ["Display", "Scale"]: - var = tk.StringVar() - - cmbframe = ttk.Frame(frame) - cmbframe.pack(fill=tk.X, pady=5, padx=5, side=tk.TOP) - lblcmb = ttk.Label(cmbframe, - text="{}:".format(item), - width=7, - anchor=tk.W) - lblcmb.pack(padx=(0, 2), side=tk.LEFT) - - cmb = ttk.Combobox(cmbframe, textvariable=var, width=10) - cmb["values"] = choices[item] - cmb.current(0) - cmb.pack(fill=tk.X, side=tk.RIGHT) - - cmd = self.optbtn_reload if item == "Display" else self.graph_scale - var.trace("w", cmd) - self.vars[item.lower().strip()] = var - - hlp = self.set_help(item) - Tooltip(cmbframe, text=hlp, wraplength=200) - logger.debug("Built Combo boxes") - - @staticmethod - def add_section(frame, title): - """ Add a separator and section title """ - sep = ttk.Frame(frame, height=2, relief=tk.SOLID) - sep.pack(fill=tk.X, pady=(5, 0), side=tk.TOP) - lbl = ttk.Label(frame, text=title) - lbl.pack(side=tk.TOP, padx=5, pady=0, anchor=tk.CENTER) - - def opts_checkbuttons(self, frame): - """ Add the options check buttons """ - logger.debug("Building Check Buttons") - - self.add_section(frame, "Display") - for item in ("raw", "trend", "avg", "smoothed", "outliers"): - if item == "avg": - text = "Show Rolling Average" - elif item == "outliers": - text = "Flatten Outliers" - else: - text = "Show {}".format(item.title()) - var = tk.BooleanVar() - - if item == self.default_view: - var.set(True) - - self.vars[item] = var - - ctl = ttk.Checkbutton(frame, variable=var, text=text) - ctl.pack(side=tk.TOP, padx=5, pady=5, anchor=tk.W) - - hlp = self.set_help(item) - Tooltip(ctl, text=hlp, wraplength=200) - logger.debug("Built Check Buttons") - - def opts_loss_keys(self, frame): - """ Add loss key selections """ - logger.debug("Building Loss Key Check Buttons") - loss_keys = self.session.loss_keys - lk_vars = dict() - section_added = False - for loss_key in sorted(loss_keys): - text = loss_key.replace("_", " ").title() - helptext = "Display {}".format(text) - var = tk.BooleanVar() - if loss_key.startswith("total"): - var.set(True) - lk_vars[loss_key] = var - - if len(loss_keys) == 1: - # Don't display if there's only one item - var.set(True) - break - - if not section_added: - self.add_section(frame, "Keys") - section_added = True - - ctl = ttk.Checkbutton(frame, variable=var, text=text) - ctl.pack(side=tk.TOP, padx=5, pady=5, anchor=tk.W) - Tooltip(ctl, text=helptext, wraplength=200) - - self.vars["loss_keys"] = lk_vars - logger.debug("Built Loss Key Check Buttons") - - def opts_slider(self, frame): - """ Add the options entry boxes """ - - self.add_section(frame, "Parameters") - logger.debug("Building Slider Controls") - for item in ("avgiterations", "smoothamount"): - if item == "avgiterations": - dtype = int - text = "Iterations to Average:" - default = 500 - rounding = 25 - min_max = (25, 2500) - elif item == "smoothamount": - dtype = float - text = "Smoothing Amount:" - default = 0.90 - rounding = 2 - min_max = (0, 0.99) - slider = ControlPanelOption(text, - dtype, - default=default, - rounding=rounding, - min_max=min_max, - helptext=self.set_help(item)) - self.vars[item] = slider.tk_var - ControlBuilder(frame, slider, 1, 19, None, True) - logger.debug("Built Sliders") - - def opts_buttons(self, frame): - """ Add the option buttons """ - logger.debug("Building Buttons") - btnframe = ttk.Frame(frame) - btnframe.pack(fill=tk.X, pady=5, padx=5, side=tk.BOTTOM) - - lblstatus = ttk.Label(btnframe, - width=40, - textvariable=self.vars["status"], - anchor=tk.W) - lblstatus.pack(side=tk.LEFT, anchor=tk.W, fill=tk.X, expand=True) - - for btntype in ("reload", "save"): - cmd = getattr(self, "optbtn_{}".format(btntype)) - btn = ttk.Button(btnframe, - image=get_images().icons[btntype], - command=cmd) - btn.pack(padx=2, side=tk.RIGHT) - hlp = self.set_help(btntype) - Tooltip(btn, text=hlp, wraplength=200) - logger.debug("Built Buttons") - - def optbtn_save(self): - """ Action for save button press """ - logger.debug("Saving File") - savefile = FileHandler("save", "csv").retfile - if not savefile: - logger.debug("Save Cancelled") - return - logger.debug("Saving to: %s", savefile) - save_data = self.display_data.stats - fieldnames = sorted(key for key in save_data.keys()) - - with savefile as outfile: - csvout = csv.writer(outfile, delimiter=",") - csvout.writerow(fieldnames) - csvout.writerows(zip(*[save_data[key] for key in fieldnames])) - - def optbtn_reload(self, *args): # pylint: disable=unused-argument - """ Action for reset button press and checkbox changes""" - logger.debug("Refreshing Graph") - if not self.graph_initialised: - return - valid = self.compile_display_data() - if not valid: - logger.debug("Invalid data") - return - self.graph.refresh(self.display_data, - self.vars["display"].get(), - self.vars["scale"].get()) - logger.debug("Refreshed Graph") - - def graph_scale(self, *args): # pylint: disable=unused-argument - """ Action for changing graph scale """ - if not self.graph_initialised: - return - self.graph.set_yscale_type(self.vars["scale"].get()) - - @staticmethod - def set_help(control): - """ Set the help text for option buttons """ - hlp = "" - control = control.lower() - if control == "reload": - hlp = "Refresh graph" - elif control == "save": - hlp = "Save display data to csv" - elif control == "avgiterations": - hlp = "Number of data points to sample for rolling average" - elif control == "smoothamount": - hlp = "Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing" - elif control == "outliers": - hlp = "Flatten data points that fall more than 1 standard " \ - "deviation from the mean to the mean value." - elif control == "avg": - hlp = "Display rolling average of the data" - elif control == "smoothed": - hlp = "Smooth the data" - elif control == "raw": - hlp = "Display raw data" - elif control == "trend": - hlp = "Display polynormal data trend" - elif control == "display": - hlp = "Set the data to display" - elif control == "scale": - hlp = "Change y-axis scale" - return hlp - - def compile_display_data(self): - """ Compile the data to be displayed """ - if self.thread is None: - logger.debug("Compiling Display Data in background thread") - loss_keys = [key for key, val in self.vars["loss_keys"].items() - if val.get()] - logger.debug("Selected loss_keys: %s", loss_keys) - - selections = self.selections_to_list() - - if not self.check_valid_selection(loss_keys, selections): - logger.warning("No data to display. Not refreshing") - return False - self.vars["status"].set("Loading Data...") - kwargs = dict(session=self.session, - display=self.vars["display"].get(), - loss_keys=loss_keys, - selections=selections, - avg_samples=self.vars["avgiterations"].get(), - smooth_amount=self.vars["smoothamount"].get(), - flatten_outliers=self.vars["outliers"].get(), - is_totals=self.is_totals) - self.thread = LongRunningTask(target=self.get_display_data, kwargs=kwargs, widget=self) - self.thread.start() - self.after(1000, self.compile_display_data) - return True - if not self.thread.complete.is_set(): - logger.debug("Popup Data not yet available") - self.after(1000, self.compile_display_data) - return True - - logger.debug("Getting Popup from background Thread") - self.display_data = self.thread.get_result() - self.thread = None - if not self.check_valid_data(): - logger.warning("No valid data to display. Not refreshing") - self.vars["status"].set("") - return False - logger.debug("Compiled Display Data") - self.vars["buildgraph"].set(True) - return True - - @staticmethod - def get_display_data(**kwargs): - """ Get the display data in a LongRunningTask """ - return Calculations(**kwargs) - - def check_valid_selection(self, loss_keys, selections): - """ Check that there will be data to display """ - display = self.vars["display"].get().lower() - logger.debug("Validating selection. (loss_keys: %s, selections: %s, display: %s)", - loss_keys, selections, display) - if not selections or (display == "loss" and not loss_keys): - return False - return True - - def check_valid_data(self): - """ Check that the selections holds valid data to display - NB: len-as-condition is used as data could be a list or a numpy array - """ - logger.debug("Validating data. %s", - {key: len(val) for key, val in self.display_data.stats.items()}) - if any(len(val) == 0 # pylint:disable=len-as-condition - for val in self.display_data.stats.values()): - return False - return True - - def selections_to_list(self): - """ Compile checkbox selections to list """ - logger.debug("Compiling selections to list") - selections = list() - for key, val in self.vars.items(): - if (isinstance(val, tk.BooleanVar) - and key != "outliers" - and val.get()): - selections.append(key) - logger.debug("Compiling selections to list: %s", selections) - return selections - - def graph_build(self, *args): # pylint:disable=unused-argument - """ Build the graph in the top right paned window """ - if not self.vars["buildgraph"].get(): - return - self.vars["status"].set("Loading Data...") - logger.debug("Building Graph") - if self.graph is None: - self.graph = SessionGraph(self.graph_frame, - self.display_data, - self.vars["display"].get(), - self.vars["scale"].get()) - self.graph.pack(expand=True, fill=tk.BOTH) - self.graph.build() - self.graph_initialised = True - else: - self.graph.refresh(self.display_data, - self.vars["display"].get(), - self.vars["scale"].get()) - self.vars["status"].set("") - self.vars["buildgraph"].set(False) - logger.debug("Built Graph") diff --git a/lib/gui/display_command.py b/lib/gui/display_command.py index 08c347f336..a55cfaa357 100644 --- a/lib/gui/display_command.py +++ b/lib/gui/display_command.py @@ -11,7 +11,7 @@ from .display_graph import TrainingGraph from .display_page import DisplayOptionalPage from .custom_widgets import Tooltip -from .stats import Calculations +from .stats import Calculations, Session from .control_helper import set_slider_rounding from .utils import FileHandler, get_config, get_images, preview_trigger @@ -191,16 +191,60 @@ def save_preview(self, location): class GraphDisplay(DisplayOptionalPage): # pylint: disable=too-many-ancestors """ The Graph Tab of the Display section """ def __init__(self, parent, tab_name, helptext, waittime, command=None): - self.trace_var = None + self._trace_vars = dict() super().__init__(parent, tab_name, helptext, waittime, command) + def set_vars(self): + """ Add graphing specific variables to the default variables. + + Overrides original method. + + Returns + ------- + dict + The variable names with their corresponding tkinter variable + """ + tk_vars = super().set_vars() + + smoothgraph = tk.DoubleVar() + smoothgraph.set(0.900) + tk_vars["smoothgraph"] = smoothgraph + + raw_var = tk.BooleanVar() + raw_var.set(True) + tk_vars["raw_data"] = raw_var + + smooth_var = tk.BooleanVar() + smooth_var.set(True) + tk_vars["smooth_data"] = smooth_var + + iterations_var = tk.IntVar() + iterations_var.set(10000) + tk_vars["display_iterations"] = iterations_var + + logger.debug(tk_vars) + return tk_vars + + def on_tab_select(self): + """ Callback for when the graph tab is selected. + + Pull latest data and run the tab's update code when the tab is selected. + """ + logger.debug("Callback received for '%s' tab", self.tabname) + if self.display_item is not None: + get_config().tk_vars["refreshgraph"].set(True) + self._update_page() + def add_options(self): """ Add the additional options """ - self.add_option_refresh() + self._add_option_refresh() super().add_options() - self.add_option_smoothing() + self._add_option_raw() + self._add_option_smoothed() + self._add_option_smoothing() + self._add_option_iterations() - def add_option_refresh(self): + def _add_option_refresh(self): """ Add refresh button to refresh graph immediately """ logger.debug("Adding refresh option") tk_var = get_config().tk_vars["refreshgraph"] @@ -213,17 +257,41 @@ def add_option_refresh(self): wraplength=200) logger.debug("Added refresh option") - def add_option_smoothing(self): - """ Add refresh button to refresh graph immediately """ + def _add_option_raw(self): + """ Add check-button to hide/display raw data """ + logger.debug("Adding display raw option") + tk_var = self.vars["raw_data"] + chkbtn = ttk.Checkbutton( + self.optsframe, + variable=tk_var, + text="Raw", + command=lambda v=tk_var: self._display_data_callback("raw", v)) + chkbtn.pack(side=tk.RIGHT, padx=5, anchor=tk.W) + Tooltip(chkbtn, text="Display the raw loss data", wraplength=200) + + def _add_option_smoothed(self): + """ Add check-button to hide/display smoothed data """ + logger.debug("Adding display smoothed option") + tk_var = self.vars["smooth_data"] + chkbtn = ttk.Checkbutton( + self.optsframe, + variable=tk_var, + text="Smoothed", + command=lambda v=tk_var: self._display_data_callback("smoothed", v)) + chkbtn.pack(side=tk.RIGHT, padx=5, anchor=tk.W) + Tooltip(chkbtn, text="Display the smoothed loss data", wraplength=200) + + def _add_option_smoothing(self): + """ Add a slider to adjust the smoothing amount """ logger.debug("Adding Smoothing Slider") - tk_var = get_config().tk_vars["smoothgraph"] - min_max = (0, 0.99) + tk_var = self.vars["smoothgraph"] + min_max = (0, 0.999) hlp = "Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing." ctl_frame = ttk.Frame(self.optsframe) ctl_frame.pack(padx=2, side=tk.RIGHT) - lbl = ttk.Label(ctl_frame, text="Smoothing Amount:", anchor=tk.W) + lbl = ttk.Label(ctl_frame, text="Smoothing:", anchor=tk.W) lbl.pack(pady=5, side=tk.LEFT, anchor=tk.N, expand=True) tbox = ttk.Entry(ctl_frame, width=6, textvariable=tk_var, justify=tk.RIGHT) @@ -232,7 +300,7 @@ def add_option_smoothing(self): ctl = ttk.Scale( ctl_frame, variable=tk_var, - command=lambda val, var=tk_var, dt=float, rn=2, mm=(0, 0.99): + command=lambda val, var=tk_var, dt=float, rn=3, mm=min_max: set_slider_rounding(val, var, dt, rn, mm)) ctl["from_"] = min_max[0] ctl["to"] = min_max[1] @@ -243,54 +311,118 @@ def add_option_smoothing(self): wraplength=200) logger.debug("Added Smoothing Slider") + def _add_option_iterations(self): + """ Add a slider to adjust the amount if iterations to display """ + logger.debug("Adding Iterations Slider") + tk_var = self.vars["display_iterations"] + min_max = (0, 100000) + hlp = "Set the number of iterations to display. 0 displays the full session." + + ctl_frame = ttk.Frame(self.optsframe) + ctl_frame.pack(padx=2, side=tk.RIGHT) + + lbl = ttk.Label(ctl_frame, text="Iterations:", anchor=tk.W) + lbl.pack(pady=5, side=tk.LEFT, anchor=tk.N, expand=True) + + tbox = ttk.Entry(ctl_frame, width=6, textvariable=tk_var, justify=tk.RIGHT) + tbox.pack(padx=(0, 5), side=tk.RIGHT) + + ctl = ttk.Scale( + ctl_frame, + variable=tk_var, + command=lambda val, var=tk_var, dt=int, rn=1000, mm=min_max: + set_slider_rounding(val, var, dt, rn, mm)) + ctl["from_"] = min_max[0] + ctl["to"] = min_max[1] + ctl.pack(padx=5, pady=5, fill=tk.X, expand=True) + for item in (tbox, ctl): + Tooltip(item, + text=hlp, + wraplength=200) + logger.debug("Added Iterations Slider") + def display_item_set(self): """ Load the graph(s) if available """ - session = get_config().session - smooth_amount_var = get_config().tk_vars["smoothgraph"] - if session.initialized and session.logging_disabled: + if Session.is_training and Session.logging_disabled: logger.trace("Logs disabled. Hiding graph") self.set_info("Graph is disabled as 'no-logs' has been selected") self.display_item = None - if self.trace_var is not None: - smooth_amount_var.trace_vdelete("w", self.trace_var) - self.trace_var = None - elif session.initialized: + self._clear_trace_variables() + elif Session.is_training and self.display_item is None: logger.trace("Loading graph") - self.display_item = session - if self.trace_var is None: - self.trace_var = smooth_amount_var.trace("w", self.smooth_amount_callback) + self.display_item = Session + self._add_trace_variables() else: + logger.trace("Clearing graph") self.display_item = None - if self.trace_var is not None: - smooth_amount_var.trace_vdelete("w", self.trace_var) - self.trace_var = None + self._clear_trace_variables() def display_item_process(self): """ Add a single graph to the graph window """ + if not Session.is_training: + logger.debug("Waiting for Session Data to become available to graph") + self.after(1000, self.display_item_process) + return + logger.debug("Adding graph") existing = list(self.subnotebook_get_titles_ids().keys()) - loss_keys = [key for key in self.display_item.loss_keys if key != "total"] + loss_keys = [key + for key in self.display_item.get_loss_keys(Session.session_ids[-1]) + if key != "total"] display_tabs = sorted(set(key[:-1].rstrip("_") for key in loss_keys)) + for loss_key in display_tabs: tabname = loss_key.replace("_", " ").title() if tabname in existing: continue display_keys = [key for key in loss_keys if key.startswith(loss_key)] - data = Calculations(session=get_config().session, + data = Calculations(session_id=Session.session_ids[-1], display="loss", loss_keys=display_keys, selections=["raw", "smoothed"], - smooth_amount=get_config().tk_vars["smoothgraph"].get()) + smooth_amount=self.vars["smoothgraph"].get()) self.add_child(tabname, data) - def smooth_amount_callback(self, *args): + def _smooth_amount_callback(self, *args): """ Update each graph's smooth amount on variable change """ - smooth_amount = get_config().tk_vars["smoothgraph"].get() + try: + smooth_amount = self.vars["smoothgraph"].get() + except tk.TclError: + # Don't update when there is no value in the variable + return logger.debug("Updating graph smooth_amount: (new_value: %s, args: %s)", smooth_amount, args) for graph in self.subnotebook.children.values(): - graph.calcs.args["smooth_amount"] = smooth_amount + graph.calcs.set_smooth_amount(smooth_amount) + + def _iteration_limit_callback(self, *args): + """ Limit the amount of data displayed in the live graph on a iteration slider + variable change. """ + try: + limit = self.vars["display_iterations"].get() + except tk.TclError: + # Don't update when there is no value in the variable + return + logger.debug("Updating graph iteration limit: (new_value: %s, args: %s)", + limit, args) + for graph in self.subnotebook.children.values(): + graph.calcs.set_iterations_limit(limit) + + def _display_data_callback(self, line, variable): + """ Update the displayed graph lines based on option check button selection. + + Parameters + ---------- + line: str + The line to hide or display + variable: :class:`tkinter.BooleanVar` + The tkinter variable containing the ``True`` or ``False`` data for this display item + """ + var = variable.get() + logger.debug("Updating display %s to %s", line, var) + for graph in self.subnotebook.children.values(): + graph.calcs.update_selections(line, var) def add_child(self, name, data): """ Add the graph for the selected keys """ @@ -308,14 +440,29 @@ def save_items(self): for graph in self.subnotebook.children.values(): graph.save_fig(graphlocation) + def _add_trace_variables(self): + """ Add tracing for when the option sliders are updated, for updating the graph. """ + for name, action in zip(("smoothgraph", "display_iterations"), + (self._smooth_amount_callback, self._iteration_limit_callback)): + var = self.vars[name] + if name not in self._trace_vars: + self._trace_vars[name] = (var, var.trace("w", action)) + + def _clear_trace_variables(self): + """ Clear all of the trace variables from :attr:`_trace_vars` and reset the dictionary. """ + if self._trace_vars: + for name, (var, trace) in self._trace_vars.items(): + logger.debug("Clearing trace from variable: %s", name) + var.trace_vdelete("w", trace) + self._trace_vars = dict() + def close(self): """ Clear the plots from RAM """ - if self.trace_var is not None: - get_config().tk_vars["smoothgraph"].trace_vdelete("w", self.trace_var) - self.trace_var = None + self._clear_trace_variables() if self.subnotebook is None: logger.debug("No graphs to clear. Returning") return + for name, graph in self.subnotebook.children.items(): logger.debug("Clearing: %s", name) graph.clear() diff --git a/lib/gui/display_graph.py b/lib/gui/display_graph.py index 895b2b6792..efa67a083f 100755 --- a/lib/gui/display_graph.py +++ b/lib/gui/display_graph.py @@ -8,6 +8,7 @@ from tkinter import ttk from math import ceil, floor +import numpy as np import matplotlib # pylint: disable=wrong-import-position matplotlib.use("TkAgg") @@ -121,8 +122,14 @@ def update_plot(self, initiate=True): fulldata = [item for item in self.calcs.stats.values()] self.axes_limits_set(fulldata) - xrng = [x for x in range(self.calcs.iterations)] + if self.calcs.start_iteration > 0: + end_iteration = self.calcs.start_iteration + self.calcs.iterations + xrng = list(range(self.calcs.start_iteration, end_iteration)) + else: + xrng = list(range(self.calcs.iterations)) + keys = list(self.calcs.stats.keys()) + for idx, item in enumerate(self.lines_sort(keys)): if initiate: self.lines.extend(self.ax1.plot(xrng, self.calcs.stats[item[0]], @@ -148,11 +155,17 @@ def axes_limits_set_default(self): def axes_limits_set(self, data): """ Set the axes limits """ - xmax = self.calcs.iterations - 1 if self.calcs.iterations > 1 else 1 + xmin = self.calcs.start_iteration + if self.calcs.start_iteration > 0: + xmax = self.calcs.iterations + self.calcs.start_iteration + else: + xmax = self.calcs.iterations + xmax = max(1, xmax - 1) + if data: ymin, ymax = self.axes_data_get_min_max(data) self.ax1.set_ylim(ymin, ymax) - self.ax1.set_xlim(0, xmax) + self.ax1.set_xlim(xmin, xmax) logger.trace("axes ranges: (y: (%s, %s), x:(0, %s)", ymin, ymax, xmax) else: self.axes_limits_set_default() @@ -161,12 +174,10 @@ def axes_limits_set(self, data): def axes_data_get_min_max(data): """ Return the minimum and maximum values from list of lists """ ymin, ymax = list(), list() - for item in data: - dataset = list(filter(lambda x: x is not None, item)) - if not dataset: - continue - ymin.append(min(dataset) * 1000) - ymax.append(max(dataset) * 1000) + + for item in data: # TODO Handle as array not loop + ymin.append(np.nanmin(item) * 1000) + ymax.append(np.nanmax(item) * 1000) ymin = floor(min(ymin)) / 1000 ymax = ceil(max(ymax)) / 1000 logger.trace("ymin: %s, ymax: %s", ymin, ymax) @@ -215,8 +226,9 @@ def lines_style(self, lines, groupsize): logger.trace("Setting lines style") groups = int(len(lines) / groupsize) colours = self.lines_create_colors(groupsize, groups) + widths = list(range(1, groups + 1)) for idx, item in enumerate(lines): - linewidth = ceil((idx + 1) / groupsize) + linewidth = widths[idx // groupsize] item.extend((linewidth, colours[idx])) return lines @@ -254,8 +266,9 @@ class TrainingGraph(GraphBase): # pylint: disable=too-many-ancestors """ Live graph to be displayed during training. """ def __init__(self, parent, data, ylabel): - GraphBase.__init__(self, parent, data, ylabel) + super().__init__(parent, data, ylabel) self.thread = None # Thread for LongRunningTask + self._displayed_keys = [] self.add_callback() def add_callback(self): @@ -286,7 +299,17 @@ def refresh(self, *args): # pylint: disable=unused-argument logger.debug("Updating plot with data from background thread") self.calcs = self.thread.get_result() # Terminate the LongRunningTask object self.thread = None - self.update_plot(initiate=False) + + dsp_keys = list(sorted(self.calcs.stats)) + if dsp_keys != self._displayed_keys: + logger.debug("Reinitializing graph for keys change. Old keys: %s New keys: %s", + self._displayed_keys, dsp_keys) + initiate = True + self._displayed_keys = dsp_keys + else: + initiate = False + + self.update_plot(initiate=initiate) self.plotcanvas.draw() refresh_var.set(False) @@ -317,7 +340,7 @@ class Event(): # pylint: disable=too-few-public-methods class SessionGraph(GraphBase): # pylint: disable=too-many-ancestors """ Session Graph for session pop-up """ def __init__(self, parent, data, ylabel, scale): - GraphBase.__init__(self, parent, data, ylabel) + super().__init__(parent, data, ylabel) self.scale = scale def build(self): diff --git a/lib/gui/display_page.py b/lib/gui/display_page.py index f8405c3cac..4ae596b858 100644 --- a/lib/gui/display_page.py +++ b/lib/gui/display_page.py @@ -80,9 +80,8 @@ def add_options_info(self): logger.debug("Adding options info") lblinfo = ttk.Label(self.optsframe, textvariable=self.vars["info"], - anchor=tk.W, - width=70) - lblinfo.pack(side=tk.LEFT, padx=5, pady=5, anchor=tk.W) + anchor=tk.W) + lblinfo.pack(side=tk.LEFT, expand=True, padx=5, pady=5, anchor=tk.W) def set_info(self, msg): """ Set the info message """ @@ -264,7 +263,7 @@ def _update_page(self): if not self.runningtask.get() or not self._tab_is_active: return if self.vars["enabled"].get(): - logger.trace("Updating page") + logger.trace("Updating page: %s", self.__class__.__name__) self.display_item_set() self.load_display() self.after(self._waittime, self._update_page) diff --git a/lib/gui/popup_session.py b/lib/gui/popup_session.py new file mode 100644 index 0000000000..8e85027294 --- /dev/null +++ b/lib/gui/popup_session.py @@ -0,0 +1,500 @@ +#!/usr/bin python3 +""" Pop-up Graph launched from the Analysis tab of the Faceswap GUI """ + +import csv +import logging +import tkinter as tk +from tkinter import ttk + +from .control_helper import ControlBuilder, ControlPanelOption +from .custom_widgets import Tooltip +from .display_graph import SessionGraph +from .stats import Calculations, Session +from .utils import FileHandler, get_images, LongRunningTask + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class SessionPopUp(tk.Toplevel): + """ Pop up for detailed graph/stats for selected session. + + session_id: int or `"Total"` + The session id number for the selected session from the Analysis tab. Should be the string + `"Total"` if all sessions are being graphed + data_points: int + The number of iterations in the selected session + """ + def __init__(self, session_id, data_points): + logger.debug("Initializing: %s: (session_id: %s, data_points: %s)", + self.__class__.__name__, session_id, data_points) + super().__init__() + self._thread = None # Thread for loading data in a background task + self._default_view = "avg" if data_points > 1000 else "smoothed" + self._session_id = None if session_id == "Total" else int(session_id) + + self._graph_frame = None + self._graph = None + self._display_data = None + + self._vars = self._set_vars() + self._graph_initialised = False + + optsframe = self._layout_frames() + self._build_options(optsframe) + + self._lbl_loading = ttk.Label(self._graph_frame, text="Loading Data...", anchor=tk.CENTER) + self._lbl_loading.pack(fill=tk.BOTH, expand=True) + self.update_idletasks() + + self._compile_display_data() + + logger.debug("Initialized: %s", self.__class__.__name__) + + def _set_vars(self): + """ Set status tkinter String variable and tkinter Boolean variable to callback when the + graph is ready to build. + + Returns + ------- + dict + The tkinter Variables for the pop up graph + """ + logger.debug("Setting tk graph build variable and internal variables") + + retval = dict(status=tk.StringVar()) + + var = tk.BooleanVar() + var.set(False) + var.trace("w", self._graph_build) + + retval["buildgraph"] = var + return retval + + def _layout_frames(self): + """ Top level container frames """ + logger.debug("Layout frames") + + leftframe = ttk.Frame(self) + sep = ttk.Frame(self, width=2, relief=tk.RIDGE) + self._graph_frame = ttk.Frame(self) + + self._graph_frame.pack(side=tk.RIGHT, fill=tk.BOTH, pady=5, expand=True) + sep.pack(fill=tk.Y, side=tk.LEFT) + leftframe.pack(side=tk.LEFT, expand=False, fill=tk.BOTH, pady=5) + + logger.debug("Laid out frames") + + return leftframe + + def _build_options(self, frame): + """ Build Options into the options frame. + + Parameters + ---------- + frame: `tkinter.ttk.Frame` + The frame that the options reside in + """ + logger.debug("Building Options") + self._opts_combobox(frame) + self._opts_checkbuttons(frame) + self._opts_loss_keys(frame) + self._opts_slider(frame) + self._opts_buttons(frame) + sep = ttk.Frame(frame, height=2, relief=tk.RIDGE) + sep.pack(fill=tk.X, pady=(5, 0), side=tk.BOTTOM) + logger.debug("Built Options") + + def _opts_combobox(self, frame): + """ Add the options combo boxes. + + Parameters + ---------- + frame: `tkinter.ttk.Frame` + The frame that the options reside in + """ + logger.debug("Building Combo boxes") + choices = dict(Display=("Loss", "Rate"), Scale=("Linear", "Log")) + + for item in ["Display", "Scale"]: + var = tk.StringVar() + + cmbframe = ttk.Frame(frame) + lblcmb = ttk.Label(cmbframe, text="{}:".format(item), width=7, anchor=tk.W) + cmb = ttk.Combobox(cmbframe, textvariable=var, width=10) + cmb["values"] = choices[item] + cmb.current(0) + + cmd = self._option_button_reload if item == "Display" else self._graph_scale + var.trace("w", cmd) + self._vars[item.lower().strip()] = var + + hlp = self._set_help(item) + Tooltip(cmbframe, text=hlp, wraplength=200) + + cmb.pack(fill=tk.X, side=tk.RIGHT) + lblcmb.pack(padx=(0, 2), side=tk.LEFT) + cmbframe.pack(fill=tk.X, pady=5, padx=5, side=tk.TOP) + logger.debug("Built Combo boxes") + + def _opts_checkbuttons(self, frame): + """ Add the options check buttons. + + Parameters + ---------- + frame: `tkinter.ttk.Frame` + The frame that the options reside in + """ + logger.debug("Building Check Buttons") + self._add_section(frame, "Display") + for item in ("raw", "trend", "avg", "smoothed", "outliers"): + if item == "avg": + text = "Show Rolling Average" + elif item == "outliers": + text = "Flatten Outliers" + else: + text = "Show {}".format(item.title()) + + var = tk.BooleanVar() + if item == self._default_view: + var.set(True) + self._vars[item] = var + + ctl = ttk.Checkbutton(frame, variable=var, text=text) + hlp = self._set_help(item) + Tooltip(ctl, text=hlp, wraplength=200) + ctl.pack(side=tk.TOP, padx=5, pady=5, anchor=tk.W) + + logger.debug("Built Check Buttons") + + def _opts_loss_keys(self, frame): + """ Add loss key selections. + + Parameters + ---------- + frame: `tkinter.ttk.Frame` + The frame that the options reside in + """ + logger.debug("Building Loss Key Check Buttons") + loss_keys = Session.get_loss_keys(self._session_id) + lk_vars = dict() + section_added = False + for loss_key in sorted(loss_keys): + if loss_key.startswith("total"): + continue + + text = loss_key.replace("_", " ").title() + helptext = "Display {}".format(text) + + var = tk.BooleanVar() + var.set(True) + lk_vars[loss_key] = var + + if len(loss_keys) == 1: + # Don't display if there's only one item + break + + if not section_added: + self._add_section(frame, "Keys") + section_added = True + + ctl = ttk.Checkbutton(frame, variable=var, text=text) + Tooltip(ctl, text=helptext, wraplength=200) + ctl.pack(side=tk.TOP, padx=5, pady=5, anchor=tk.W) + + self._vars["loss_keys"] = lk_vars + logger.debug("Built Loss Key Check Buttons") + + def _opts_slider(self, frame): + """ Add the options entry boxes. + + Parameters + ---------- + frame: `tkinter.ttk.Frame` + The frame that the options reside in + """ + + self._add_section(frame, "Parameters") + logger.debug("Building Slider Controls") + for item in ("avgiterations", "smoothamount"): + if item == "avgiterations": + dtype = int + text = "Iterations to Average:" + default = 500 + rounding = 25 + min_max = (25, 2500) + elif item == "smoothamount": + dtype = float + text = "Smoothing Amount:" + default = 0.90 + rounding = 2 + min_max = (0, 0.99) + slider = ControlPanelOption(text, + dtype, + default=default, + rounding=rounding, + min_max=min_max, + helptext=self._set_help(item)) + self._vars[item] = slider.tk_var + ControlBuilder(frame, slider, 1, 19, None, True) + logger.debug("Built Sliders") + + def _opts_buttons(self, frame): + """ Add the option buttons. + + Parameters + ---------- + frame: `tkinter.ttk.Frame` + The frame that the options reside in + """ + logger.debug("Building Buttons") + btnframe = ttk.Frame(frame) + lblstatus = ttk.Label(btnframe, + width=40, + textvariable=self._vars["status"], + anchor=tk.W) + + for btntype in ("reload", "save"): + cmd = getattr(self, "_option_button_{}".format(btntype)) + btn = ttk.Button(btnframe, + image=get_images().icons[btntype], + command=cmd) + hlp = self._set_help(btntype) + Tooltip(btn, text=hlp, wraplength=200) + btn.pack(padx=2, side=tk.RIGHT) + + lblstatus.pack(side=tk.LEFT, anchor=tk.W, fill=tk.X, expand=True) + btnframe.pack(fill=tk.X, pady=5, padx=5, side=tk.BOTTOM) + logger.debug("Built Buttons") + + @staticmethod + def _add_section(frame, title): + """ Add a separator and section title between options + + Parameters + ---------- + title: str + The section title to display + """ + sep = ttk.Frame(frame, height=2, relief=tk.SOLID) + lbl = ttk.Label(frame, text=title) + + lbl.pack(side=tk.TOP, padx=5, pady=0, anchor=tk.CENTER) + sep.pack(fill=tk.X, pady=(5, 0), side=tk.TOP) + + def _option_button_save(self): + """ Action for save button press. """ + logger.debug("Saving File") + savefile = FileHandler("save", "csv").retfile + if not savefile: + logger.debug("Save Cancelled") + return + logger.debug("Saving to: %s", savefile) + save_data = self._display_data.stats + fieldnames = sorted(key for key in save_data.keys()) + + with savefile as outfile: + csvout = csv.writer(outfile, delimiter=",") + csvout.writerow(fieldnames) + csvout.writerows(zip(*[save_data[key] for key in fieldnames])) + + def _option_button_reload(self, *args): # pylint: disable=unused-argument + """ Action for reset button press and checkbox changes. """ + logger.debug("Refreshing Graph") + if not self._graph_initialised: + return + valid = self._compile_display_data() + if not valid: + logger.debug("Invalid data") + return + self._graph.refresh(self._display_data, + self._vars["display"].get(), + self._vars["scale"].get()) + logger.debug("Refreshed Graph") + + def _graph_scale(self, *args): # pylint: disable=unused-argument + """ Action for changing graph scale. """ + if not self._graph_initialised: + return + self._graph.set_yscale_type(self._vars["scale"].get()) + + @classmethod + def _set_help(cls, action): + """ Set the help text for option buttons. + + Parameters + ---------- + action: string + The action to get the help text for + + Returns + ------- + str + The help text for the given action + """ + hlp = "" + action = action.lower() + if action == "reload": + hlp = "Refresh graph" + elif action == "save": + hlp = "Save display data to csv" + elif action == "avgiterations": + hlp = "Number of data points to sample for rolling average" + elif action == "smoothamount": + hlp = "Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing" + elif action == "outliers": + hlp = "Flatten data points that fall more than 1 standard " \ + "deviation from the mean to the mean value." + elif action == "avg": + hlp = "Display rolling average of the data" + elif action == "smoothed": + hlp = "Smooth the data" + elif action == "raw": + hlp = "Display raw data" + elif action == "trend": + hlp = "Display polynormal data trend" + elif action == "display": + hlp = "Set the data to display" + elif action == "scale": + hlp = "Change y-axis scale" + return hlp + + def _compile_display_data(self): + """ Compile the data to be displayed. """ + if self._thread is None: + logger.debug("Compiling Display Data in background thread") + loss_keys = [key for key, val in self._vars["loss_keys"].items() + if val.get()] + logger.debug("Selected loss_keys: %s", loss_keys) + + selections = self._selections_to_list() + + if not self._check_valid_selection(loss_keys, selections): + logger.warning("No data to display. Not refreshing") + return False + self._vars["status"].set("Loading Data...") + + if self._graph is not None: + self._graph.pack_forget() + self._lbl_loading.pack(fill=tk.BOTH, expand=True) + self.update_idletasks() + + kwargs = dict(session_id=self._session_id, + display=self._vars["display"].get(), + loss_keys=loss_keys, + selections=selections, + avg_samples=self._vars["avgiterations"].get(), + smooth_amount=self._vars["smoothamount"].get(), + flatten_outliers=self._vars["outliers"].get()) + self._thread = LongRunningTask(target=self._get_display_data, + kwargs=kwargs, + widget=self) + self._thread.start() + self.after(1000, self._compile_display_data) + return True + if not self._thread.complete.is_set(): + logger.debug("Popup Data not yet available") + self.after(1000, self._compile_display_data) + return True + + logger.debug("Getting Popup from background Thread") + self._display_data = self._thread.get_result() + self._thread = None + if not self._check_valid_data(): + logger.warning("No valid data to display. Not refreshing") + self._vars["status"].set("") + return False + logger.debug("Compiled Display Data") + self._vars["buildgraph"].set(True) + return True + + @staticmethod + def _get_display_data(**kwargs): + """ Get the display data in a LongRunningTask. + + Parameters + ---------- + kwargs: dict + The keyword arguments to pass to `lib.gui.stats.Calculations` + + Returns + ------- + :class:`lib.gui.stats.Calculations` + The summarized results for the given session + """ + return Calculations(**kwargs) + + def _check_valid_selection(self, loss_keys, selections): + """ Check that there will be data to display. + + Parameters + ---------- + loss_keys: list + The selected loss to display + selections: list + The selected checkbox options + + Returns + ------- + bool + ``True` if there is data to be displayed, otherwise ``False`` + """ + display = self._vars["display"].get().lower() + logger.debug("Validating selection. (loss_keys: %s, selections: %s, display: %s)", + loss_keys, selections, display) + if not selections or (display == "loss" and not loss_keys): + return False + return True + + def _check_valid_data(self): + """ Check that the selections holds valid data to display + NB: len-as-condition is used as data could be a list or a numpy array + """ + logger.debug("Validating data. %s", + {key: len(val) for key, val in self._display_data.stats.items()}) + if any(len(val) == 0 # pylint:disable=len-as-condition + for val in self._display_data.stats.values()): + return False + return True + + def _selections_to_list(self): + """ Compile checkbox selections to a list. + + Returns + ------- + list + The selected options from the check-boxes + """ + logger.debug("Compiling selections to list") + selections = list() + for key, val in self._vars.items(): + if (isinstance(val, tk.BooleanVar) + and key != "outliers" + and val.get()): + selections.append(key) + logger.debug("Compiling selections to list: %s", selections) + return selections + + def _graph_build(self, *args): # pylint:disable=unused-argument + """ Build the graph in the top right paned window """ + if not self._vars["buildgraph"].get(): + return + self._vars["status"].set("Loading Data...") + logger.debug("Building Graph") + self._lbl_loading.pack_forget() + self.update_idletasks() + if self._graph is None: + self._graph = SessionGraph(self._graph_frame, + self._display_data, + self._vars["display"].get(), + self._vars["scale"].get()) + self._graph.pack(expand=True, fill=tk.BOTH) + self._graph.build() + self._graph_initialised = True + else: + self._graph.refresh(self._display_data, + self._vars["display"].get(), + self._vars["scale"].get()) + self._graph.pack(fill=tk.BOTH, expand=True) + self._vars["status"].set("") + self._vars["buildgraph"].set(False) + logger.debug("Built Graph") diff --git a/lib/gui/stats.py b/lib/gui/stats.py index 960368da46..7cbb323526 100644 --- a/lib/gui/stats.py +++ b/lib/gui/stats.py @@ -1,12 +1,19 @@ #!/usr/bin python3 -""" Stats functions for the GUI """ +""" Stats functions for the GUI. + +Holds the globally loaded training session. This will either be a user selected session (loaded in +the analysis tab) or the currently training session. + +""" import logging import time import os import warnings +import zlib -from math import ceil, sqrt +from math import ceil +from threading import Event import numpy as np import tensorflow as tf @@ -17,22 +24,250 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name -def convert_time(timestamp): - """ Convert time stamp to total hours, minutes and seconds """ - hrs = int(timestamp // 3600) - if hrs < 10: - hrs = "{0:02d}".format(hrs) - mins = "{0:02d}".format((int(timestamp % 3600) // 60)) - secs = "{0:02d}".format((int(timestamp % 3600) % 60)) - return hrs, mins, secs +class GlobalSession(): + """ Holds information about a loaded or current training session by accessing a model's state + file and Tensorboard logs. This class should not be accessed directly, rather through + :attr:`lib.stats.Session` + """ + def __init__(self): + logger.debug("Initializing %s", self.__class__.__name__) + self._state = None + self._model_dir = None + self._model_name = None + + self._tb_logs = None + self._summary = None + + self._is_training = False + self._is_querying = Event() + + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def is_loaded(self): + """ bool: ``True`` if session data is loaded otherwise ``False`` """ + return self._model_dir is not None + + @property + def is_training(self): + """ bool: ``True`` if the loaded session is the currently training model, otherwise + ``False`` """ + return self._is_training + + @property + def model_filename(self): + """ str: The full model filename """ + return os.path.join(self._model_dir, self._model_name) + + @property + def batch_sizes(self): + """ dict: The batch sizes for each session_id for the model. """ + return {int(sess_id): sess["batchsize"] + for sess_id, sess in self._state["sessions"].items()} + + @property + def full_summary(self): + """ list: List of dictionaries containing summary statistics for each session id. """ + return self._summary.get_summary_stats() + + @property + def logging_disabled(self): + """ bool: ``True`` if logging is enabled for the currently training session otherwise + ``False``. """ + return self._state["sessions"][str(self.session_ids[-1])]["no_logs"] + + @property + def session_ids(self): + """ list: The sorted list of all existing session ids `int`s in the state file """ + return self._tb_logs.session_ids + + def _load_state_file(self): + """ Load the current state file to :attr:`_state`. """ + state_file = os.path.join(self._model_dir, "{}_state.json".format(self._model_name)) + logger.debug("Loading State: '%s'", state_file) + serializer = get_serializer("json") + self._state = serializer.load(state_file) + logger.debug("Loaded state: %s", self._state) + + def initialize_session(self, model_folder, model_name, is_training=False): + """ Initialize a Session. + + Load's the model's state file, and sets the paths to any underlying Tensorboard logs, ready + for access on request. + + Parameters + ---------- + model_folder: str, optional + If loading a session manually (e.g. for the analysis tab), then the path to the model + folder must be provided. For training sessions, this should be left at ``None`` + model_name: str, optional + If loading a session manually (e.g. for the analysis tab), then the model filename + must be provided. For training sessions, this should be left at ``None`` + is_training: bool, optional + ``True`` if the session is being initialized for a training session, otherwise + ``False``. Default: ``False`` + """ + logger.debug("Initializing session: (is_training: %s)", is_training) + + if self._model_dir == model_folder and self._model_name == model_name: + if is_training: + self._tb_logs.refresh_log_filenames() + self._load_state_file() + self._is_training = True + logger.debug("Requested session is already loaded. Not initializing: (model_folder: " + "%s, model_name: %s)", model_folder, model_name) + return + + self._is_training = is_training + self._model_dir = model_folder + self._model_name = model_name + self._load_state_file() + self._tb_logs = TensorBoardLogs(os.path.join(self._model_dir, + "{}_logs".format(self._model_name))) + + self._summary = SessionsSummary(self) + logger.debug("Initialized session. Session_IDS: %s", self.session_ids) + + def stop_training(self): + """ Clears the internal training flag. To be called when training completes. """ + self._is_training = False + + def clear(self): + """ Clear the currently loaded session. """ + self._state = None + self._model_dir = None + self._model_name = None + + del self._tb_logs + self._tb_logs = None + + del self._summary + self._summary = None + + self._is_training = False + + def get_loss(self, session_id): + """ Obtain the loss values for the given session_id. + + Parameters + ---------- + session_id: int or ``None`` + The session ID to return loss for. Pass ``None`` to return loss for all sessions. + + Returns + ------- + dict + Loss names as key, :class:`numpy.ndarray` as value. If No session ID was provided + all session's losses are collated + """ + self._wait_for_thread() + + if self._is_training: + self._is_querying.set() + + loss_dict = self._tb_logs.get_loss(session_id=session_id, is_training=self._is_training) + if session_id is None: + retval = dict() + for key in sorted(loss_dict): + for loss_key, loss in loss_dict[key].items(): + retval.setdefault(loss_key, []).extend(loss) + retval = {key: np.array(val, dtype="float32") for key, val in retval.items()} + else: + retval = loss_dict[session_id] + + if self._is_training: + self._is_querying.clear() + return retval + + def get_timestamps(self, session_id): + """ Obtain the time stamps keys for the given session_id. + + Parameters + ---------- + session_id: int or ``None`` + The session ID to return the time stamps for. Pass ``None`` to return time stamps for + all sessions. + + Returns + ------- + dict or :class:`numpy.ndarray` + If a session ID has been given then a single :class:`numpy.ndarray` will be returned + with the session's time stamps. Otherwise a 'dict' will be returned with the session + IDs as key with :class:`numpy.ndarray` of timestamps as values + """ + self._wait_for_thread() + + if self._is_training: + self._is_querying.set() + + retval = self._tb_logs.get_timestamps(session_id=session_id, is_training=self._is_training) + if session_id is not None: + retval = retval[session_id] + + if self._is_training: + self._is_querying.clear() + + return retval + + def _wait_for_thread(self): + """ If a thread is querying the log files for live data, then block until task clears. """ + while True: + if self._is_training and self._is_querying.is_set(): + logger.debug("Waiting for available thread") + time.sleep(1) + continue + break + + def get_loss_keys(self, session_id): + """ Obtain the loss keys for the given session_id. + + Parameters + ---------- + session_id: int or ``None`` + The session ID to return the loss keys for. Pass ``None`` to return loss keys for + all sessions. + + Returns + ------- + list + The loss keys for the given session. If ``None`` is passed as session_id then a unique + list of all loss keys for all sessions is returned + """ + if session_id is None: + retval = list(set(loss_key + for session in self._state["sessions"].values() + for loss_key in session["loss_names"])) + else: + retval = self._state["sessions"][str(session_id)]["loss_names"] + return retval + + +Session = GlobalSession() class TensorBoardLogs(): - """ Parse and return data from TensorBoard logs """ + """ Parse data from TensorBoard logs. + + Process the input logs folder and stores the individual filenames per session. + + Caches timestamp and loss data on request and returns this data from the cache. + + Parameters + ---------- + logs_folder: str + The folder that contains the Tensorboard log files + """ def __init__(self, logs_folder): - tf.config.set_visible_devices([], "GPU") # Don't use the GPU for stats - self.folder_base = logs_folder - self.log_filenames = self._get_log_filenames() + self._folder_base = logs_folder + self._log_filenames = self._get_log_filenames() + self._cache = dict() + self._training_iterator = None + self._training_rollover = dict() + + @property + def session_ids(self): + """ list: Sorted list of integers of available session ids. """ + return list(sorted(self._log_filenames)) def _get_log_filenames(self): """ Get the TensorBoard log filenames for all existing sessions. @@ -42,9 +277,9 @@ def _get_log_filenames(self): dict The full path of each log file for each training session that has been run """ - logger.debug("Loading log filenames. base_dir: '%s'", self.folder_base) + logger.debug("Loading log filenames. base_dir: '%s'", self._folder_base) log_filenames = dict() - for dirpath, _, filenames in os.walk(self.folder_base): + for dirpath, _, filenames in os.walk(self._folder_base): if not any(filename.startswith("events.out.tfevents") for filename in filenames): continue logfiles = [filename for filename in filenames @@ -61,40 +296,205 @@ def _get_log_filenames(self): logger.debug("logfiles: %s", log_filenames) return log_filenames - def get_loss(self, session=None): + def _cache_data(self, session_id, is_training=False): + """ Cache TensorBoard logs for the given session ID on first access. + + Populates :attr:`_cache` with timestamps and loss data. + + If this is a training session and the data is being queried for the training session ID + then get the latest available data and append to the cache + + Parameters + ------- + session_id: int + The session ID to cache the data for + is_training: bool, optional + ``True`` if a current training session is running otherwise ``False``. + Default: ``False`` + """ + labels = [] + step = [] + loss = [] + timestamps = [] + last_step = -1 + live_data = is_training and session_id == max(self._log_filenames) + + if live_data: + iterator = self._get_latest_live() + else: + iterator = tf.compat.v1.io.tf_record_iterator(self._log_filenames[session_id]) + + try: + for record in iterator: + event = event_pb2.Event.FromString(record) + if not event.summary.value or not event.summary.value[0].tag.startswith("batch_"): + continue + + if last_step == -1: + last_step = event.step if live_data else 0 + + if event.step != last_step: + loss.append(step) + step = [] + last_step = event.step + + summary = event.summary.value[0] + tag = summary.tag + + if tag == "batch_total": + timestamps.append(event.wall_time) + continue + + lbl = tag.replace("batch_", "") + if lbl not in labels: + labels.append(lbl) + + step.append(summary.simple_value) + + except tf_errors.DataLossError as err: + logger.warning("The logs for Session %s are corrupted and cannot be displayed. " + "The totals do not include this session. Original error message: " + "'%s'", session_id, str(err)) + + if step: + loss.append(step) + + loss = np.array(loss, dtype="float32") + timestamps = np.array(timestamps, dtype="float64") + + logger.debug("Caching session id: %s, labels: %s, loss: %s, timestamps: %s", + session_id, labels, loss.shape, timestamps.shape) + + if live_data and session_id in self._cache: + self._add_latest_data_to_cache(session_id, loss, timestamps) + else: + self._cache[session_id] = dict(labels=labels, + loss=zlib.compress(loss), + loss_shape=loss.shape, + timestamps=zlib.compress(timestamps), + timestamps_shape=timestamps.shape) + + def _get_latest_live(self): + """ Obtain the latest event logs for live training data and add to the cache """ + if self._training_iterator is None: + training_session_id = self.session_ids[-1] + filename = self._log_filenames[training_session_id] + self._training_iterator = tf.compat.v1.io.tf_record_iterator(filename) + logger.debug("Set live training iterator %s for session_id: %s", + self._training_iterator, training_session_id) + + i = 0 + while True: + try: + yield next(self._training_iterator) + i += 1 + except StopIteration: + logger.debug("End of data reached") + break + except tf.errors.DataLossError as err: + # Truncated records are ignored. The iterator holds the offset, so the record will + # be completed at the next call. + logger.debug("Truncated record. Original Error: %s", err) + break + logger.debug("Collected %s records from live log file", i) + + def _add_latest_data_to_cache(self, session_id, loss, timestamps): + """ Append the latest received live training data to the cached data. + + Parameters + ---------- + session_id: int + The training session ID to update the cache for + loss: :class:`numpy.ndarray` + The latest loss values returned from the iterator + timestamps: :class:`numpy.ndarray` + The latest time stamps returned from the iterator + """ + if not np.any(loss) and not np.any(timestamps): + logger.debug("No new live data to cache.") + return + + logger.debug("Adding live data to cache: (loss: %s, timestamps: %s)", + loss.shape, timestamps.shape) + + cache = self._cache[session_id] + + past_loss = np.frombuffer(zlib.decompress(cache["loss"]), + dtype="float32").reshape(cache["loss_shape"]) + new_loss = np.concatenate((past_loss, loss)) + cache["loss_shape"] = new_loss.shape + cache["loss"] = zlib.compress(new_loss) + del past_loss + + past_timestamps = np.frombuffer(zlib.decompress(cache["timestamps"]), + dtype="float64").reshape(cache["timestamps_shape"]) + new_timestamps = np.concatenate((past_timestamps, timestamps)) + cache["timestamps_shape"] = new_timestamps.shape + cache["timestamps"] = zlib.compress(new_timestamps) + del past_timestamps + + def _from_cache(self, session_id=None, is_training=False): + """ Get the session data from the cache. + + If the request data does not exist in the cache, then populate it. + + Parameters + ---------- + session_id: int, optional + The Session ID to return the data for. Set to ``None`` to return all session + data. Default ``None` + is_training: bool, optional + ``True`` if a current training session is running otherwise ``False``. + Default: ``False`` + + Returns + ------- + dict + The session id(s) as key, with the event data as value + """ + if session_id is not None and session_id not in self._cache: + self._cache_data(session_id) + elif is_training and session_id == self.session_ids[-1]: + self._cache_data(session_id, is_training=is_training) + elif session_id is None and not all(idx in self._cache for idx in self._log_filenames): + for sess in self._log_filenames: + if sess not in self._cache: + self._cache_data(sess) + + if session_id is None: + return self._cache + return {session_id: self._cache[session_id]} + + def get_loss(self, session_id=None, is_training=False): """ Read the loss from the TensorBoard event logs Parameters ---------- - session: int, optional + session_id: int, optional The Session ID to return the loss for. Set to ``None`` to return all session losses. Default ``None`` + is_training: bool, optional + ``True`` if a current training session is running otherwise ``False``. + Default: ``False`` Returns ------- dict - A list of loss values for each step for the requested session + The session id(s) as key, with a further dictionary as value containing the loss name + and list of loss values for each step """ - logger.debug("Getting loss: (session: %s)", session) - all_loss = dict() - for sess, logfile in self.log_filenames.items(): - if session is not None and sess != session: - logger.debug("Skipping session: %s", sess) - continue - loss = dict() - events = [event_pb2.Event.FromString(record.numpy()) - for record in tf.data.TFRecordDataset(logfile)] - for event in events: - if not event.summary.value or not event.summary.value[0].tag.startswith("batch_"): - continue - summary = event.summary.value[0] - tag = summary.tag.replace("batch_", "") - loss.setdefault(tag, []).append(summary.simple_value) - all_loss[sess] = loss - logger.debug(all_loss) - return all_loss + logger.debug("Getting loss: (session_id: %s)", session_id) + retval = dict() + for sess, info in self._from_cache(session_id, is_training).items(): + arr = np.frombuffer(zlib.decompress(info["loss"]), + dtype="float32").reshape(info["loss_shape"]) + for idx, title in enumerate(info["labels"]): + retval.setdefault(sess, dict())[title] = arr[:, idx] + logger.debug({key: {k: v.shape for k, v in val.items()} + for key, val in retval.items()}) + return retval - def get_timestamps(self, session=None): + def get_timestamps(self, session_id=None, is_training=False): """ Read the timestamps from the TensorBoard logs. As loss timestamps are slightly different for each loss, we collect the timestamp from the @@ -102,248 +502,192 @@ def get_timestamps(self, session=None): Parameters ---------- - session: int, optional + session_id: int, optional The Session ID to return the timestamps for. Set to ``None`` to return all session timestamps. Default ``None`` + is_training: bool, optional + ``True`` if a current training session is running otherwise ``False``. + Default: ``False`` Returns ------- dict - The timestamps for each event for the requested session + The session id(s) as key with list of timestamps per step as value """ - logger.debug("Getting timestamps") - all_timestamps = dict() - for sess, logfile in self.log_filenames.items(): - if session is not None and sess != session: - logger.debug("Skipping sessions: %s", sess) - continue - try: - events = [event_pb2.Event.FromString(record.numpy()) - for record in tf.data.TFRecordDataset(logfile)] - timestamps = [event.wall_time - for event in events - if event.summary.value - and event.summary.value[0].tag == "batch_total"] - logger.debug("Total timestamps for session %s: %s", sess, len(timestamps)) - all_timestamps[sess] = timestamps - except tf_errors.DataLossError as err: - logger.warning("The logs for Session %s are corrupted and cannot be displayed. " - "The totals do not include this session. Original error message: " - "'%s'", sess, str(err)) - logger.debug(all_timestamps) - return all_timestamps - - -class Session(): - """ The Loaded or current training session """ - def __init__(self, model_dir=None, model_name=None): - logger.debug("Initializing %s: (model_dir: %s, model_name: %s)", - self.__class__.__name__, model_dir, model_name) - self.serializer = get_serializer("json") - self.state = None - self.modeldir = model_dir # Set and reset by wrapper for training sessions - self.modelname = model_name # Set and reset by wrapper for training sessions - self.tb_logs = None - self.initialized = False - self.session_id = None # Set to specific session_id or current training session - self.summary = SessionsSummary(self) - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def batchsize(self): - """ Return the session batchsize """ - return self.session["batchsize"] - - @property - def config(self): - """ Return config and other information """ - retval = self.state["config"].copy() - retval["training_size"] = self.state["training_size"] - retval["input_size"] = [val[0] for key, val in self.state["inputs"].items() - if key.startswith("face")][0] + logger.debug("Getting timestamps: (session_id: %s, is_training: %s)", + session_id, is_training) + retval = {sess: np.frombuffer(zlib.decompress(info["timestamps"]), + dtype="float64").reshape(info["timestamps_shape"]) + for sess, info in self._from_cache(session_id, is_training).items()} + logger.debug({k: v.shape for k, v in retval.items()}) return retval - @property - def full_summary(self): - """ Return all sessions summary data""" - return self.summary.compile_stats() + def refresh_log_filenames(self): + """ Refresh the log file list in :attr:`_log_filenames`. - @property - def iterations(self): - """ Return session iterations """ - return self.session["iterations"] + Called when a training session is loaded, to add the latest log filename to the list. + """ + self._log_filenames = self._get_log_filenames() - @property - def logging_disabled(self): - """ Return whether logging is disabled for this session """ - return self.session["no_logs"] - @property - def loss(self): - """ dict: The loss for the current session id for each loss key """ - loss_dict = self.tb_logs.get_loss(session=self.session_id)[self.session_id] - return loss_dict +class SessionsSummary(): # pylint:disable=too-few-public-methods + """ Performs top level summary calculations for each session ID within the loaded or currently + training Session for display in the Analysis tree view. - @property - def loss_keys(self): - """ list: The loss keys for the current session, or loss keys for all sessions. """ - if self.session_id is None: - retval = self._total_loss_keys - else: - retval = self.session["loss_names"] - return retval + Parameters + ---------- + session: :class:`GlobalSession` + The loaded or currently training session + """ + def __init__(self, session): + logger.debug("Initializing %s: (session: %s)", self.__class__.__name__, session) + self._session = session + self._state = session._state - @property - def lowest_loss(self): - """ Return the lowest average loss per save iteration seen """ - return self.state["lowest_avg_loss"] + self._time_stats = None + self._per_session_stats = None + logger.debug("Initialized %s", self.__class__.__name__) - @property - def session(self): - """ Return current session dictionary """ - return self.state["sessions"].get(str(self.session_id), dict()) + def get_summary_stats(self): + """ Compile the individual session statistics and calculate the total. - @property - def session_ids(self): - """ Return sorted list of all existing session ids in the state file """ - return sorted([int(key) for key in self.state["sessions"].keys()]) + Format the stats for display - @property - def timestamps(self): - """ Return timestamps from logs for current session """ - ts_dict = self.tb_logs.get_timestamps(session=self.session_id) - return ts_dict[self.session_id] + Returns + ------- + list + A list of summary statistics dictionaries containing the Session ID, start time, end + time, elapsed time, rate, batch size and number of iterations for each session id + within the loaded data as well as the totals. + """ + logger.debug("Compiling sessions summary data") + self._get_time_stats() + self._get_per_session_stats() + if not self._per_session_stats: + return self._per_session_stats + + total_stats = self._total_stats() + retval = self._per_session_stats + [total_stats] + retval = self._format_stats(retval) + logger.debug("Final stats: %s", retval) + return retval - @property - def total_batchsize(self): - """ Return all session batch sizes """ - return {int(sess_id): sess["batchsize"] - for sess_id, sess in self.state["sessions"].items()} + def _get_time_stats(self): + """ Populates the attribute :attr:`_time_stats` with the start start time, end time and + data points for each session id within the loaded session if it has not already been + calculated. - @property - def total_iterations(self): - """ Return session iterations """ - return self.state["iterations"] + If the main Session is currently training, then the training session ID is updated with the + latest stats. + """ + if self._time_stats is None: + logger.debug("Collating summary time stamps") - @property - def total_loss(self): - """ dict: The collated loss for all sessions for each loss key """ - loss_dict = dict() - all_loss = self.tb_logs.get_loss() - for key in sorted(all_loss): - for loss_key, loss in all_loss[key].items(): - loss_dict.setdefault(loss_key, []).extend(loss) - return loss_dict + self._time_stats = { + sess_id: dict(start_time=np.min(timestamps) if np.any(timestamps) else 0, + end_time=np.max(timestamps) if np.any(timestamps) else 0, + iterations=timestamps.shape[0] if np.any(timestamps) else 0) + for sess_id, timestamps in self._session.get_timestamps(None).items()} - @property - def _total_loss_keys(self): - """ list: The loss keys for all sessions. """ - loss_keys = set(loss_key - for session in self.state["sessions"].values() - for loss_key in session["loss_names"]) - return list(loss_keys) + elif Session.is_training: + logger.debug("Updating summary time stamps for training session") - @property - def total_timestamps(self): - """ Return timestamps from logs separated per session for all sessions """ - return self.tb_logs.get_timestamps() - - def initialize_session(self, is_training=False, session_id=None): - """ Initialize the training session """ - logger.debug("Initializing session: (is_training: %s, session_id: %s)", - is_training, session_id) - self.load_state_file() - self.tb_logs = TensorBoardLogs(os.path.join(self.modeldir, - "{}_logs".format(self.modelname))) - if is_training: - self.session_id = max(int(key) for key in self.state["sessions"].keys()) - else: - self.session_id = session_id - self.initialized = True - logger.debug("Initialized session. Session_ID: %s", self.session_id) + session_id = Session.session_ids[-1] + latest = self._session.get_timestamps(session_id) - def load_state_file(self): - """ Load the current state file """ - state_file = os.path.join(self.modeldir, "{}_state.json".format(self.modelname)) - logger.debug("Loading State: '%s'", state_file) - self.state = self.serializer.load(state_file) - logger.debug("Loaded state: %s", self.state) + self._time_stats[session_id] = dict( + start_time=np.min(latest) if np.any(latest) else 0, + end_time=np.max(latest) if np.any(latest) else 0, + iterations=latest.shape[0] if np.any(latest) else 0) - def get_iterations_for_session(self, session_id): - """ Return the number of iterations for the given session id """ - session = self.state["sessions"].get(str(session_id), None) - if session is None: - logger.warning("No session data found for session id: %s", session_id) - return 0 - return session["iterations"] + logger.debug("time_stats: %s", self._time_stats) + def _get_per_session_stats(self): + """ Populate the attribute :attr:`_per_session_stats` with a sorted list by session ID + of each ID in the training/loaded session. Stats contain the session ID, start, end and + elapsed times, the training rate, batch size and number of iterations for each session. -class SessionsSummary(): - """ Calculations for analysis summary stats """ + If a training session is running, then updates the training sessions stats only. + """ + if self._per_session_stats is None: + logger.debug("Collating per session stats") + compiled = list() + for session_id, ts_data in self._time_stats.items(): + logger.debug("Compiling session ID: %s", session_id) + if self._state is None: + logger.debug("Session state dict doesn't exist. Most likely task has been " + "terminated during compilation") + return + compiled.append(self._collate_stats(session_id, ts_data)) + + self._per_session_stats = list(sorted(compiled, key=lambda k: k["session"])) + + elif self._session.is_training: + logger.debug("Collating per session stats for latest training data") + session_id = self._session.session_ids[-1] + ts_data = self._time_stats[session_id] + + if session_id > len(self._per_session_stats): + self._per_session_stats.append(self._collate_stats(session_id, ts_data)) + + stats = self._per_session_stats[-1] + + stats["start"] = ts_data["start_time"] + stats["end"] = ts_data["end_time"] + stats["elapsed"] = int(stats["end"] - stats["start"]) + stats["iterations"] = ts_data["iterations"] + stats["rate"] = (((stats["batch"] * 2) * stats["iterations"]) + / stats["elapsed"] if stats["elapsed"] != 0 else 0) + logger.debug("per_session_stats: %s", self._per_session_stats) + + def _collate_stats(self, session_id, timestamps): + """ Collate the session summary statistics for the given session ID. - def __init__(self, session): - logger.debug("Initializing %s: (session: %s)", self.__class__.__name__, session) - self.session = session - logger.debug("Initialized %s", self.__class__.__name__) + Parameters + ---------- + session_id: int + The session id to compile the stats for + timestamps: + The time stamp summary data for the given session id - @property - def time_stats(self): - """ Return session time stats """ - ts_data = self.session.tb_logs.get_timestamps() - time_stats = {sess_id: {"start_time": min(timestamps) if timestamps else 0, - "end_time": max(timestamps) if timestamps else 0, - "datapoints": len(timestamps) if timestamps else 0} - for sess_id, timestamps in ts_data.items()} - return time_stats + Returns + ------- + dict + The collated session summary statistics + """ + timestamps = self._time_stats[session_id] + elapsed = int(timestamps["end_time"] - timestamps["start_time"]) + batchsize = self._session.batch_sizes.get(session_id, 0) + retval = dict( + session=session_id, + start=timestamps["start_time"], + end=timestamps["end_time"], + elapsed=elapsed, + rate=(((batchsize * 2) * timestamps["iterations"]) / elapsed if elapsed != 0 else 0), + batch=batchsize, + iterations=timestamps["iterations"]) + logger.debug(retval) + return retval - @property - def sessions_stats(self): - """ Return compiled stats """ - compiled = list() - for sess_idx, ts_data in self.time_stats.items(): - logger.debug("Compiling session ID: %s", sess_idx) - if self.session.state is None: - logger.debug("Session state dict doesn't exist. Most likely task has been " - "terminated during compilation") - return None - iterations = self.session.get_iterations_for_session(sess_idx) - elapsed = ts_data["end_time"] - ts_data["start_time"] - batchsize = self.session.total_batchsize.get(sess_idx, 0) - compiled.append( - {"session": sess_idx, - "start": ts_data["start_time"], - "end": ts_data["end_time"], - "elapsed": elapsed, - "rate": ((batchsize * 2) * iterations) / elapsed if elapsed != 0 else 0, - "batch": batchsize, - "iterations": iterations}) - compiled = sorted(compiled, key=lambda k: k["session"]) - return compiled - - def compile_stats(self): - """ Compile sessions stats with totals, format and return """ - logger.debug("Compiling sessions summary data") - compiled_stats = self.sessions_stats - if not compiled_stats: - return compiled_stats - logger.debug("sessions_stats: %s", compiled_stats) - total_stats = self.total_stats(compiled_stats) - compiled_stats.append(total_stats) - compiled_stats = self.format_stats(compiled_stats) - logger.debug("Final stats: %s", compiled_stats) - return compiled_stats - - @staticmethod - def total_stats(sessions_stats): - """ Return total stats """ + def _total_stats(self): + """ Compile the Totals stats. + Totals are fully calculated each time as they will change on the basis of the training + session. + + Returns + ------- + dict: + The Session name, start time, end time, elapsed time, rate, batch size and number of + iterations for all session ids within the loaded data. + """ logger.debug("Compiling Totals") elapsed = 0 examples = 0 iterations = 0 batchset = set() - total_summaries = len(sessions_stats) - for idx, summary in enumerate(sessions_stats): + total_summaries = len(self._per_session_stats) + for idx, summary in enumerate(self._per_session_stats): if idx == 0: starttime = summary["start"] if idx == total_summaries - 1: @@ -363,208 +707,544 @@ def total_stats(sessions_stats): logger.debug(totals) return totals - @staticmethod - def format_stats(compiled_stats): - """ Format for display """ + def _format_stats(self, compiled_stats): + """ Format for the incoming list of statistics for display. + + Parameters + ---------- + compiled_stats: list + List of summary statistics dictionaries to be formatted for display + + Returns + ------- + list + The original statistics formatted for display + """ logger.debug("Formatting stats") + retval = [] for summary in compiled_stats: - hrs, mins, secs = convert_time(summary["elapsed"]) - summary["start"] = time.strftime("%x %X", time.localtime(summary["start"])) - summary["end"] = time.strftime("%x %X", time.localtime(summary["end"])) - summary["elapsed"] = "{}:{}:{}".format(hrs, mins, secs) - summary["rate"] = "{0:.1f}".format(summary["rate"]) - return compiled_stats + hrs, mins, secs = self._convert_time(summary["elapsed"]) + stats = dict() + for key in summary: + if key not in ("start", "end", "elapsed", "rate"): + stats[key] = summary[key] + continue + stats["start"] = time.strftime("%x %X", time.localtime(summary["start"])) + stats["end"] = time.strftime("%x %X", time.localtime(summary["end"])) + stats["elapsed"] = "{}:{}:{}".format(hrs, mins, secs) + stats["rate"] = "{0:.1f}".format(summary["rate"]) + retval.append(stats) + return retval + + @classmethod + def _convert_time(cls, timestamp): + """ Convert time stamp to total hours, minutes and seconds. + + Parameters + ---------- + timestamp: float + The Unix timestamp to be converted + + Returns + ------- + tuple + (`hours`, `minutes`, `seconds`) as ints + """ + hrs = int(timestamp // 3600) + if hrs < 10: + hrs = "{0:02d}".format(hrs) + mins = "{0:02d}".format((int(timestamp % 3600) // 60)) + secs = "{0:02d}".format((int(timestamp % 3600) % 60)) + return hrs, mins, secs class Calculations(): - """ Class to pull raw data for given session(s) and perform calculations """ - def __init__(self, session, display="loss", loss_keys=["loss"], selections=["raw"], - avg_samples=500, smooth_amount=0.90, flatten_outliers=False, is_totals=False): - logger.debug("Initializing %s: (session: %s, display: %s, loss_keys: %s, selections: %s, " - "avg_samples: %s, smooth_amount: %s, flatten_outliers: %s, is_totals: %s", - self.__class__.__name__, session, display, loss_keys, selections, avg_samples, - smooth_amount, flatten_outliers, is_totals) + """ Class that performs calculations on the :class:`GlobalSession` raw data for the given + session id. + + Parameters + ---------- + session_id: int or ``None`` + The session id number for the selected session from the Analysis tab. Should be ``None`` + if all sessions are being calculated + display: {"loss", "rate"}, optional + Whether to display a graph for loss or training rate. Default: `"loss"` + loss_keys: list, optional + The list of loss keys to display on the graph. Default: `["loss"]` + selections: list, optional + The selected annotations to display. Default: `["raw"]` + avg_samples: int, optional + The number of samples to use for performing moving average calculation. Default: `500`. + smooth_amount: float, optional + The amount of smoothing to apply for performing smoothing calculation. Default: `0.9`. + flatten_outliers: bool, optional + ``True`` if values significantly away from the average should be excluded, otherwise + ``False``. Default: ``False`` + """ + def __init__(self, session_id, + display="loss", + loss_keys="loss", + selections="raw", + avg_samples=500, + smooth_amount=0.90, + flatten_outliers=False): + logger.debug("Initializing %s: (session_id: %s, display: %s, loss_keys: %s, " + "selections: %s, avg_samples: %s, smooth_amount: %s, flatten_outliers: %s)", + self.__class__.__name__, session_id, display, loss_keys, selections, + avg_samples, smooth_amount, flatten_outliers) warnings.simplefilter("ignore", np.RankWarning) - self.session = session - self.display = display - self.loss_keys = loss_keys - self.selections = selections - self.is_totals = is_totals - self.args = {"avg_samples": avg_samples, - "smooth_amount": smooth_amount, - "flatten_outliers": flatten_outliers} - self.iterations = 0 - self.stats = None + self._session_id = session_id + + self._display = display + self._loss_keys = loss_keys if isinstance(loss_keys, list) else [loss_keys] + self._selections = selections if isinstance(selections, list) else [selections] + self._is_totals = session_id is None + self._args = dict(avg_samples=avg_samples, + smooth_amount=smooth_amount, + flatten_outliers=flatten_outliers) + self._iterations = 0 + self._limit = 0 + self._start_iteration = 0 + self._stats = dict() self.refresh() logger.debug("Initialized %s", self.__class__.__name__) + @property + def iterations(self): + """ int: The number of iterations in the data set. """ + return self._iterations + + @property + def start_iteration(self): + """ int: The starting iteration number of a limit has been set on the amount of data. """ + return self._start_iteration + + @property + def stats(self): + """ dict: The final calculated statistics """ + return self._stats + def refresh(self): """ Refresh the stats """ logger.debug("Refreshing") - if not self.session.initialized: + if not Session.is_loaded: logger.warning("Session data is not initialized. Not refreshing") return None - self.iterations = 0 - self.stats = self._get_raw() - self.get_calculations() - self.remove_raw() + self._iterations = 0 + self._get_raw() + self._get_calculations() + self._remove_raw() logger.debug("Refreshed") return self - def _get_raw(self): - """ Obtain the raw loss values. + def set_smooth_amount(self, amount): + """ Set the amount of smoothing to apply to smoothed graph. - Returns - ------- - dict - The loss name as key with list of loss values as value + Parameters + ---------- + amount: float + The amount of smoothing to apply to smoothed graph + """ + update = max(min(amount, 0.999), 0.001) + logger.debug("Setting smooth amount to: %s (provided value: %s)", update, amount) + self._args["smooth_amount"] = update + + def update_selections(self, selection, option): + """ Update the type of selected data. + + Parameters + ---------- + selection: str + The selection to update (as can exist in :attr:`_selections`) + option: bool + ``True`` if the selection should be included, ``False`` if it should be removed + """ + # TODO Somewhat hacky, to ensure values are inserted in the correct order. Fine for + # now as this is only called from Live Graph and selections can only be "raw" and + # smoothed. + if option: + if selection not in self._selections: + if selection == "raw": + self._selections.insert(0, selection) + else: + self._selections.append(selection) + else: + if selection in self._selections: + self._selections.remove(selection) + + def set_iterations_limit(self, limit): + """ Set the number of iterations to display in the calculations. + + If a value greater than 0 is passed, then the latest iterations up to the given + limit will be calculated. + + Parameters + ---------- + limit: int + The number of iterations to calculate data for. `0` to calculate for all data """ + limit = max(0, limit) + logger.debug("Setting iteration limit to: %s", limit) + self._limit = limit + + def _get_raw(self): + """ Obtain the raw loss values and add them to a new :attr:`stats` dictionary. """ logger.debug("Getting Raw Data") - raw = dict() + self.stats.clear() iterations = set() - if self.display.lower() == "loss": - loss_dict = self.session.total_loss if self.is_totals else self.session.loss + + if self._display.lower() == "loss": + loss_dict = Session.get_loss(self._session_id) for loss_name, loss in loss_dict.items(): - if loss_name not in self.loss_keys: + if loss_name not in self._loss_keys: continue - if self.args["flatten_outliers"]: - loss = self.flatten_outliers(loss) - iterations.add(len(loss)) - raw["raw_{}".format(loss_name)] = loss + iterations.add(loss.shape[0]) + + if self._limit > 0: + loss = loss[-self._limit:] + + if self._args["flatten_outliers"]: + loss = self._flatten_outliers(loss) + + self.stats["raw_{}".format(loss_name)] = loss + + self._iterations = 0 if not iterations else min(iterations) + if self._limit > 1: + self._start_iteration = max(0, self._iterations - self._limit) + self._iterations = min(self._iterations, self._limit) + else: + self._start_iteration = 0 - self.iterations = 0 if not iterations else min(iterations) if len(iterations) > 1: # Crop all losses to the same number of items - if self.iterations == 0: - raw = {lossname: list() for lossname in raw} + if self._iterations == 0: + self.stats = {lossname: np.array(list(), dtype=loss.dtype) + for lossname, loss in self.stats.items()} else: - raw = {lossname: loss[:self.iterations] for lossname, loss in raw.items()} + self.stats = {lossname: loss[:self._iterations] + for lossname, loss in self.stats.items()} else: # Rate calculation - data = self.calc_rate_total() if self.is_totals else self.calc_rate() - if self.args["flatten_outliers"]: - data = self.flatten_outliers(data) - self.iterations = len(data) - raw = {"raw_rate": data} + data = self._calc_rate_total() if self._is_totals else self._calc_rate() + if self._args["flatten_outliers"]: + data = self._flatten_outliers(data) + self._iterations = data.shape[0] + self.stats["raw_rate"] = data logger.debug("Got Raw Data") - return raw - def remove_raw(self): - """ Remove raw values from stats if not requested """ - if "raw" in self.selections: + @classmethod + def _flatten_outliers(cls, data): + """ Remove the outliers from a provided list. + + Removes data more than 1 Standard Deviation from the mean. + + Parameters + ---------- + data: :class:`numpy.ndarray` + The data to remove the outliers from + + Returns + ------- + :class:`numpy.ndarray` + The data with outliers removed + """ + logger.debug("Flattening outliers: %s", data.shape) + mean = np.mean(data) + limit = np.std(data) + logger.debug("mean: %s, limit: %s", mean, limit) + retdata = np.where(abs(data - mean) < limit, data, mean) + logger.debug("Flattened outliers") + return retdata + + def _remove_raw(self): + """ Remove raw values from :attr:`stats` if they are not requested. """ + if "raw" in self._selections: return logger.debug("Removing Raw Data from output") - for key in list(self.stats.keys()): + for key in list(self._stats.keys()): if key.startswith("raw"): - del self.stats[key] + del self._stats[key] logger.debug("Removed Raw Data from output") - def calc_rate(self): - """ Calculate rate per iteration """ + def _calc_rate(self): + """ Calculate rate per iteration. + + Returns + ------- + :class:`numpy.ndarray` + The training rate for each iteration of the selected session + """ logger.debug("Calculating rate") - batchsize = self.session.batchsize - timestamps = self.session.timestamps - iterations = range(len(timestamps) - 1) - rate = [(batchsize * 2) / (timestamps[i + 1] - timestamps[i]) for i in iterations] - logger.debug("Calculated rate: Item_count: %s", len(rate)) - return rate - - def calc_rate_total(self): - """ Calculate rate per iteration - NB: For totals, gaps between sessions can be large - so time difference has to be reset for each session's - rate calculation """ + retval = (Session.batch_sizes[self._session_id] * 2) / np.diff(Session.get_timestamps( + self._session_id)) + logger.debug("Calculated rate: Item_count: %s", len(retval)) + return retval + + @classmethod + def _calc_rate_total(cls): + """ Calculate rate per iteration for all sessions. + + Returns + ------- + :class:`numpy.ndarray` + The training rate for each iteration in all sessions + + Notes + ----- + For totals, gaps between sessions can be large so the time difference has to be reset for + each session's rate calculation. + """ logger.debug("Calculating totals rate") - batchsizes = self.session.total_batchsize - total_timestamps = self.session.total_timestamps + batchsizes = Session.batch_sizes + total_timestamps = Session.get_timestamps(None) rate = list() for sess_id in sorted(total_timestamps.keys()): batchsize = batchsizes[sess_id] timestamps = total_timestamps[sess_id] - iterations = range(len(timestamps) - 1) - rate.extend([(batchsize * 2) / (timestamps[i + 1] - timestamps[i]) - for i in iterations]) - logger.debug("Calculated totals rate: Item_count: %s", len(rate)) - return rate - - @staticmethod - def flatten_outliers(data): - """ Remove the outliers from a provided list """ - logger.debug("Flattening outliers") - retdata = list() - samples = len(data) - mean = (sum(data) / samples) - limit = sqrt(sum([(item - mean)**2 for item in data]) / samples) - logger.debug("samples: %s, mean: %s, limit: %s", samples, mean, limit) - - for idx, item in enumerate(data): - if (mean - limit) <= item <= (mean + limit): - retdata.append(item) - else: - logger.trace("Item idx: %s, value: %s flattened to %s", idx, item, mean) - retdata.append(mean) - logger.debug("Flattened outliers") - return retdata + rate.extend((batchsize * 2) / np.diff(timestamps)) + retval = np.array(rate) + logger.debug("Calculated totals rate: Item_count: %s", len(retval)) + return retval - def get_calculations(self): - """ Perform the required calculations """ - for selection in self.selections: + def _get_calculations(self): + """ Perform the required calculations and populate :attr:`stats`. """ + for selection in self._selections: if selection == "raw": continue logger.debug("Calculating: %s", selection) - method = getattr(self, "calc_{}".format(selection)) - raw_keys = [key for key in self.stats.keys() if key.startswith("raw_")] + method = getattr(self, "_calc_{}".format(selection)) + raw_keys = [key for key in self._stats if key.startswith("raw_")] for key in raw_keys: selected_key = "{}_{}".format(selection, key.replace("raw_", "")) - self.stats[selected_key] = method(self.stats[key]) + self._stats[selected_key] = method(self._stats[key]) + + def _calc_avg(self, data): + """ Calculate moving average. - def calc_avg(self, data): - """ Calculate rolling average """ + Parameters + ---------- + data: :class:`numpy.ndarray` + The data to calculate the moving average for + + Returns + ------- + :class:`numpy.ndarray` + The moving average for the given data + """ logger.debug("Calculating Average") - avgs = list() - presample = ceil(self.args["avg_samples"] / 2) - postsample = self.args["avg_samples"] - presample - datapoints = len(data) + window = self._args["avg_samples"] + pad = ceil(window / 2) + datapoints = data.shape[0] - if datapoints <= (self.args["avg_samples"] * 2): + if datapoints <= (self._args["avg_samples"] * 2): logger.info("Not enough data to compile rolling average") - return avgs + return np.array([], dtype="float64") - for idx in range(0, datapoints): - if idx < presample or idx >= datapoints - postsample: - avgs.append(None) - continue - avg = sum(data[idx - presample:idx + postsample]) / self.args["avg_samples"] - avgs.append(avg) - logger.debug("Calculated Average") + avgs = np.cumsum(data, dtype="float64") + avgs[window:] = avgs[window:] - avgs[:-window] + avgs = avgs[window - 1:] / window + avgs = np.pad(avgs, (pad, datapoints - (avgs.shape[0] + pad)), constant_values=(np.nan,)) + logger.debug("Calculated Average: shape: %s", avgs.shape) return avgs - def calc_smoothed(self, data): - """ Smooth the data """ - last = data[0] # First value in the plot (first time step) - weight = self.args["smooth_amount"] - smoothed = list() - for point in data: - smoothed_val = last * weight + (1 - weight) * point # Calculate smoothed value - smoothed.append(smoothed_val) # Save it - last = smoothed_val # Anchor the last smoothed value - - return smoothed - - @staticmethod - def calc_trend(data): - """ Compile trend data """ + def _calc_smoothed(self, data): + """ Smooth the data. + + Parameters + ---------- + data: :class:`numpy.ndarray` + The data to smoothen + + Returns + ------- + :class:`numpy.ndarray` + The smoothed data + """ + return ExponentialMovingAverage(data, self._args["smooth_amount"])() + + @classmethod + def _calc_trend(cls, data): + """ Calculate polynomial trend of the given data. + + Parameters + ---------- + data: :class:`numpy.ndarray` + The data to calculate the trend for + + Returns + ------- + :class:`numpy.ndarray` + The trend for the given data + """ logger.debug("Calculating Trend") - points = len(data) + points = data.shape[0] if points < 10: - dummy = [None for i in range(points)] + dummy = np.empty((points, ), dtype=data.dtype) + dummy[:] = np.nan return dummy x_range = range(points) - fit = np.polyfit(x_range, data, 3) - poly = np.poly1d(fit) - trend = poly(x_range) + trend = np.poly1d(np.polyfit(x_range, data, 3))(x_range) logger.debug("Calculated Trend") return trend + + +class ExponentialMovingAverage(): # pylint:disable=too-few-public-methods + """ Reshapes data before calculating exponential moving average, then iterates once over the + rows to calculate the offset without precision issues. + + Parameters + ---------- + data: :class:`numpy.ndarray` + A 1 dimensional numpy array to obtain smoothed data for + amount: float + in the range (0.0, 1.0) The alpha parameter (smoothing amount) for the moving average. + + Notes + ----- + Adapted from: https://stackoverflow.com/questions/42869495 + """ + def __init__(self, data, amount): + assert data.ndim == 1 + amount = min(max(amount, 0.001), 0.999) + + self._data = data + self._alpha = 1. - amount + self._dtype = "float32" if data.dtype == np.float32 else "float64" + self._row_size = self._get_max_row_size() + self._out = np.empty_like(data, dtype=self._dtype) + + def __call__(self): + """ Perform the exponential moving average calculation. + + Returns + ------- + :class:`numpy.ndarray` + The smoothed data + """ + if self._data.size <= self._row_size: + self._ewma_vectorized(self._data, self._out) # Normal function can handle this input + else: + self._ewma_vectorized_safe() # Use the safe version + return self._out + + def _get_max_row_size(self): + """ Calculate the maximum row size for the running platform for the given dtype. + + Returns + ------- + int + The maximum row size possible on the running platform for the given :attr:`_dtype` + + Notes + ----- + Might not be the optimal value for speed, which is hard to predict due to numpy + optimizations. + """ + # Use :func:`np.finfo(dtype).eps` if you are worried about accuracy and want to be safe. + epsilon = np.finfo(self._dtype).tiny + # If this produces an OverflowError, make epsilon larger: + retval = int(np.log(epsilon) / np.log(1 - self._alpha)) + 1 + logger.debug("row_size: %s", retval) + return retval + + def _ewma_vectorized_safe(self): + """ Perform the vectorized exponential moving average in a safe way. """ + num_rows = int(self._data.size // self._row_size) # the number of rows to use + leftover = int(self._data.size % self._row_size) # the amount of data leftover + first_offset = self._data[0] + + if leftover > 0: + # set temporary results to slice view of out parameter + out_main_view = np.reshape(self._out[:-leftover], (num_rows, self._row_size)) + data_main_view = np.reshape(self._data[:-leftover], (num_rows, self._row_size)) + else: + out_main_view = self._out.reshape(-1, self._row_size) + data_main_view = self._data.reshape(-1, self._row_size) + + self._ewma_vectorized_2d(data_main_view, out_main_view) # get the scaled cumulative sums + + scaling_factors = (1 - self._alpha) ** np.arange(1, self._row_size + 1) + last_scaling_factor = scaling_factors[-1] + + # create offset array + offsets = np.empty(out_main_view.shape[0], dtype=self._dtype) + offsets[0] = first_offset + # iteratively calculate offset for each row + + for i in range(1, out_main_view.shape[0]): + offsets[i] = offsets[i - 1] * last_scaling_factor + out_main_view[i - 1, -1] + + # add the offsets to the result + out_main_view += offsets[:, np.newaxis] * scaling_factors[np.newaxis, :] + + if leftover > 0: + # process trailing data in the 2nd slice of the out parameter + self._ewma_vectorized(self._data[-leftover:], + self._out[-leftover:], + offset=out_main_view[-1, -1]) + + def _ewma_vectorized(self, data, out, offset=None): + """ Calculates the exponential moving average over a vector. Will fail for large inputs. + + The result is processed in place into the array passed to the `out` parameter + + Parameters + ---------- + data: :class:`numpy.ndarray` + A 1 dimensional numpy array to obtain smoothed data for + out: :class:`numpy.ndarray` + A location into which the result is stored. It must have the same shape and dtype as + the input data + offset: float, optional + The offset for the moving average, scalar. Default: the value held in data[0]. + """ + if data.size < 1: # empty input, return empty array + return + + offset = data[0] if offset is None else offset + + # scaling_factors -> 0 as len(data) gets large. This leads to divide-by-zeros below + scaling_factors = np.power(1. - self._alpha, np.arange(data.size + 1, dtype=self._dtype), + dtype=self._dtype) + # create cumulative sum array + np.multiply(data, (self._alpha * scaling_factors[-2]) / scaling_factors[:-1], + dtype=self._dtype, out=out) + np.cumsum(out, dtype=self._dtype, out=out) + + out /= scaling_factors[-2::-1] # cumulative sums / scaling + + if offset != 0: + offset = np.array(offset, copy=False).astype(self._dtype, copy=False) + out += offset * scaling_factors[1:] + + def _ewma_vectorized_2d(self, data, out): + """ Calculates the exponential moving average over the last axis. + + The result is processed in place into the array passed to the `out` parameter + + Parameters + ---------- + data: :class:`numpy.ndarray` + A 1 or 2 dimensional numpy array to obtain smoothed data for. + out: :class:`numpy.ndarray` + A location into which the result is stored. It must have the same shape and dtype as + the input data + """ + if data.size < 1: # empty input, return empty array + return + + # calculate the moving average + scaling_factors = np.power(1. - self._alpha, np.arange(data.shape[1] + 1, + dtype=self._dtype), + dtype=self._dtype) + # create a scaled cumulative sum array + np.multiply(data, + np.multiply(self._alpha * scaling_factors[-2], + np.ones((data.shape[0], 1), dtype=self._dtype), + dtype=self._dtype) / scaling_factors[np.newaxis, :-1], + dtype=self._dtype, out=out) + np.cumsum(out, axis=1, dtype=self._dtype, out=out) + out /= scaling_factors[np.newaxis, -2::-1] diff --git a/lib/gui/utils.py b/lib/gui/utils.py index ca2a472246..f6a27e9fd9 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -24,7 +24,7 @@ PATHCACHE = os.path.join(os.path.realpath(os.path.dirname(sys.argv[0])), "lib", "gui", ".cache") -def initialize_config(root, cli_opts, statusbar, session): +def initialize_config(root, cli_opts, statusbar): """ Initialize the GUI Master :class:`Config` and add to global constant. This should only be called once on first GUI startup. Future access to :class:`Config` @@ -38,15 +38,13 @@ def initialize_config(root, cli_opts, statusbar, session): The command line options object statusbar: :class:`lib.gui.custom_widgets.StatusBar` The GUI Status bar - session: :class:`lib.gui.stats.Session` - The current training Session """ global _CONFIG # pylint: disable=global-statement if _CONFIG is not None: return None logger.debug("Initializing config: (root: %s, cli_opts: %s, " - "statusbar: %s, session: %s)", root, cli_opts, statusbar, session) - _CONFIG = Config(root, cli_opts, statusbar, session) + "statusbar: %s)", root, cli_opts, statusbar) + _CONFIG = Config(root, cli_opts, statusbar) return _CONFIG @@ -764,12 +762,10 @@ class Config(): The command line options object statusbar: :class:`lib.gui.custom_widgets.StatusBar` The GUI Status bar - session: :class:`lib.gui.stats.Session` - The current training Session """ - def __init__(self, root, cli_opts, statusbar, session): - logger.debug("Initializing %s: (root %s, cli_opts: %s, statusbar: %s, session: %s)", - self.__class__.__name__, root, cli_opts, statusbar, session) + def __init__(self, root, cli_opts, statusbar): + logger.debug("Initializing %s: (root %s, cli_opts: %s, statusbar: %s)", + self.__class__.__name__, root, cli_opts, statusbar) self._default_font = self._set_default_font() self._constants = dict( root=root, @@ -784,7 +780,6 @@ def __init__(self, root, cli_opts, statusbar, session): status_bar=statusbar, command_notebook=None) # set in command.py self._user_config = UserConfig(None) - self.session = session logger.debug("Initialized %s", self.__class__.__name__) # Constants @@ -1037,9 +1032,6 @@ def _set_tk_vars(): refreshgraph = tk.BooleanVar() refreshgraph.set(False) - smoothgraph = tk.DoubleVar() - smoothgraph.set(0.90) - updatepreview = tk.BooleanVar() updatepreview.set(False) @@ -1053,7 +1045,6 @@ def _set_tk_vars(): "generate": generatecommand, "consoleclear": consoleclear, "refreshgraph": refreshgraph, - "smoothgraph": smoothgraph, "updatepreview": updatepreview, "analysis_folder": analysis_folder} logger.debug(tk_vars) diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index d6cc408c41..a438732412 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -11,6 +11,7 @@ import psutil +from .stats import Session from .utils import get_config, get_images, LongRunningTask, preview_trigger if os.name == "nt": @@ -31,6 +32,7 @@ def __init__(self): self.pathscript = os.path.realpath(os.path.dirname(sys.argv[0])) self.command = None self.statusbar = get_config().statusbar + self._training_session_location = dict() self.task = FaceswapControl(self) logger.debug("Initialized %s", self.__class__.__name__) @@ -84,7 +86,11 @@ def prepare(self, category): return args def build_args(self, category, command=None, generate=False): - """ Build the faceswap command and arguments list """ + """ Build the faceswap command and arguments list. + + If training, pass the model folder and name to the training :class:`lib.gui.stats.Session` + for the GUI. + """ logger.debug("Build cli arguments: (category: %s, command: %s, generate: %s)", category, command, generate) command = self.command if not command else command @@ -98,7 +104,8 @@ def build_args(self, category, command=None, generate=False): for cliopt in cli_opts.gen_cli_arguments(command): args.extend(cliopt) if command == "train" and not generate: - self.init_training_session(cliopt) + self._get_training_session_info(cliopt) + if not generate: args.append("-gui") # Indicate to Faceswap that we are running the GUI if generate: @@ -109,16 +116,21 @@ def build_args(self, category, command=None, generate=False): logger.debug("Built cli arguments: (%s)", args) return args - @staticmethod - def init_training_session(cliopt): - """ Set the session stats for disable logging, model folder and model name """ - session = get_config().session - if cliopt[0] == "-t": - session.modelname = cliopt[1].lower().replace("-", "_") - logger.debug("modelname: '%s'", session.modelname) - if cliopt[0] == "-m": - session.modeldir = cliopt[1] - logger.debug("modeldir: '%s'", session.modeldir) + def _get_training_session_info(self, cli_option): + """ Set the model folder and model name to :`attr:_training_session_location` so the global + session picks them up for logging to the graph and analysis tab. + + Parameters + ---------- + cli_option: list + The command line option to be checked for model folder or name + """ + if cli_option[0] == "-t": + self._training_session_location["model_name"] = cli_option[1].lower().replace("-", "_") + logger.debug("model_name: '%s'", self._training_session_location["model_name"]) + if cli_option[0] == "-m": + self._training_session_location["model_folder"] = cli_option[1] + logger.debug("model_folder: '%s'", self._training_session_location["model_folder"]) def terminate(self, message): """ Finalize wrapper when process has exited """ @@ -130,7 +142,7 @@ def terminate(self, message): self.statusbar.message.set(message) self.tk_vars["display"].set(None) get_images().delete_preview() - get_config().session.__init__() + Session.stop_training() preview_trigger().clear() self.command = None logger.debug("Terminated Faceswap processes") @@ -142,6 +154,7 @@ class FaceswapControl(): def __init__(self, wrapper): logger.debug("Initializing %s", self.__class__.__name__) self.wrapper = wrapper + self._session_info = wrapper._training_session_location self.config = get_config() self.statusbar = self.config.statusbar self.command = None @@ -194,11 +207,14 @@ def read_stdout(self): logger.debug("Trigger GUI Training update") logger.trace("tk_vars: %s", {itm: var.get() for itm, var in self.wrapper.tk_vars.items()}) - if not self.config.session.initialized: + if not Session.is_training: # Don't initialize session until after the first save as state # file must exist first logger.debug("Initializing curret training session") - self.config.session.initialize_session(is_training=True) + Session.initialize_session( + self._session_info["model_folder"], + self._session_info["model_name"], + is_training=True) self.wrapper.tk_vars["updatepreview"].set(True) self.wrapper.tk_vars["refreshgraph"].set(True) if "[preview updated]" in output.strip().lower(): diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 3c104a247a..7c984281cb 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -70,6 +70,7 @@ class Extractor(): launched), the instance of the plugin must be passed in for naming convention reasons. Default: 0 + The following attributes should be set in the plugin's :func:`__init__` method after initializing the parent. diff --git a/scripts/gui.py b/scripts/gui.py index 6675ba1d55..26ab64c108 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -6,7 +6,7 @@ import tkinter as tk from tkinter import messagebox, ttk -from lib.gui import (TaskBar, CliOptions, CommandNotebook, ConsoleOut, Session, DisplayNotebook, +from lib.gui import (TaskBar, CliOptions, CommandNotebook, ConsoleOut, DisplayNotebook, get_images, initialize_images, initialize_config, LastSession, MainMenuBar, preview_trigger, ProcessWrapper, StatusBar) @@ -40,8 +40,7 @@ def initialize_globals(self): """ Initialize config and images global constants """ cliopts = CliOptions() statusbar = StatusBar(self) - session = Session() - config = initialize_config(self, cliopts, statusbar, session) + config = initialize_config(self, cliopts, statusbar) initialize_images() return config From dfc118fd6d6212cb2d379459dbb66a718219449c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 24 Sep 2020 10:21:38 +0100 Subject: [PATCH 301/981] bugfix: Training - Make timelapse image extensions case insensitive --- scripts/train.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/train.py b/scripts/train.py index 54fe893bd1..22ec8fa828 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -84,7 +84,7 @@ def _set_timelapse(self): for folder in (self._args.timelapse_input_a, self._args.timelapse_input_b): if folder is not None and not os.path.isdir(folder): raise FaceswapError("The Timelapse path '{}' does not exist".format(folder)) - exts = [os.path.splitext(fname)[-1] for fname in os.listdir(folder)] + exts = [os.path.splitext(fname)[-1].lower() for fname in os.listdir(folder)] if not any(ext in _image_extensions for ext in exts): raise FaceswapError("The Timelapse path '{}' does not contain any valid " "images".format(folder)) From f3227b7b62e0be558710824c7aac5085252657b1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 24 Sep 2020 10:32:50 +0100 Subject: [PATCH 302/981] Bugfix: GUI fullscreen support for macOS --- lib/gui/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/gui/utils.py b/lib/gui/utils.py index f6a27e9fd9..5f0929e3f6 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -1084,7 +1084,7 @@ def set_geometry(self, width, height, fullscreen=False): initial_dimensions = (round(width * self.scaling_factor), round(height * self.scaling_factor)) - if fullscreen and sys.platform == "win32": + if fullscreen and sys.platform in ("win32", "darwin"): self.root.state('zoomed') elif fullscreen: self.root.attributes('-zoomed', True) From 961f8ff28394687d3d70b2c0954612548a2bdba4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 24 Sep 2020 11:31:54 +0100 Subject: [PATCH 303/981] - Bugfix: Training - Disable loss multipliers if penalized loss not selected - Training: Half mouth/eye multiplier defaults - GUI: Remove analysis callback from convert tab --- lib/gui/control_helper.py | 2 +- lib/training_data.py | 16 ++++++++-------- plugins/train/_config.py | 4 ++-- plugins/train/model/_base.py | 12 ++++++++++-- plugins/train/trainer/_base.py | 9 +++++---- 5 files changed, 26 insertions(+), 17 deletions(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 91f43d25ee..478299f9f8 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -293,7 +293,7 @@ def get_tk_var(self, track_modified): var.trace("w", lambda name, index, mode, cmd=self._command: self._modified_callback(cmd)) - if track_modified and self._command in ("train", "convert") and self.title == "Model Dir": + if track_modified and self._command == "train" and self.title == "Model Dir": var.trace("w", lambda name, index, mode, v=var: self._model_callback(v)) return var diff --git a/lib/training_data.py b/lib/training_data.py index d09ed70410..3b3d385e77 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -283,19 +283,19 @@ def _apply_mask(self, filenames, batch, side): item = self._masks[key] if item is None and key != "masks": continue - if item is None and key == "masks": - logger.trace("Creating dummy masks. side: %s", side) - masks = np.ones_like(batch[..., :1], dtype=batch.dtype) - continue # Expand out partials for eye and mouth masks on first epoch if item is not None and key in ("eyes", "mouths"): self._expand_partials(side, item, filenames) - logger.trace("Obtaining masks for batch. (key: %s side: %s)", key, side) - masks = np.array([self._get_mask(item[side][filename], size) - for filename in filenames], dtype=batch.dtype) - masks = self._resize_masks(size, masks) + if item is None and key == "masks": + logger.trace("Creating dummy masks. side: %s", side) + masks = np.ones_like(batch[..., :1], dtype=batch.dtype) + else: + logger.trace("Obtaining masks for batch. (key: %s side: %s)", key, side) + masks = np.array([self._get_mask(item[side][filename], size) + for filename in filenames], dtype=batch.dtype) + masks = self._resize_masks(size, masks) logger.trace("masks: (key: %s, shape: %s)", key, masks.shape) batch = np.concatenate((batch, masks), axis=-1) diff --git a/plugins/train/_config.py b/plugins/train/_config.py index add862a702..895c5405fb 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -271,7 +271,7 @@ def _set_loss(self): group="loss", min_max=(1, 40), rounding=1, - default=6, + default=3, fixed=False, info="The amount of priority to give to the eyes.\n\nThe value given here is as a " "multiplier of the main loss score. For example:" @@ -286,7 +286,7 @@ def _set_loss(self): group="loss", min_max=(1, 40), rounding=1, - default=4, + default=2, fixed=False, info="The amount of priority to give to the mouth.\n\nThe value given here is as a " "multiplier of the main loss score. For Example:" diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 068d301dee..064c576d4f 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -1044,9 +1044,17 @@ def _get_mask_channels(self): list: A list of channel indices that contain the mask for the corresponding config item """ + eye_multiplier = self._config["eye_multiplier"] + mouth_multiplier = self._config["mouth_multiplier"] + if not self._config["penalized_mask_loss"] and (eye_multiplier > 1 or + mouth_multiplier > 1): + logger.warning("You have selected eye/mouth loss multipliers greate than 1x, but " + "Penalized Mask Loss is disabled. Disabling all multipliers.") + eye_multiplier = 1 + mouth_multiplier = 1 uses_masks = (self._config["penalized_mask_loss"], - self._config["eye_multiplier"] > 1, - self._config["mouth_multiplier"] > 1) + eye_multiplier > 1, + mouth_multiplier > 1) mask_channels = [-1 for _ in range(len(uses_masks))] current_channel = 3 for idx, mask_required in enumerate(uses_masks): diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 95a2383a05..2986460742 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -107,9 +107,10 @@ def _get_alignments_data(self): are required for training, `masks_eye` if eye masks are required and `masks_mouth` if mouth masks are required. """ retval = dict() + penalized_loss = self._model.config["penalized_mask_loss"] if not any([self._model.config["learn_mask"], - self._model.config["penalized_mask_loss"], + penalized_loss, self._model.config["eye_multiplier"] > 1, self._model.config["mouth_multiplier"] > 1, self._model.command_line_arguments.warp_to_landmarks]): @@ -121,14 +122,14 @@ def _get_alignments_data(self): logger.debug("Adding landmarks to training opts dict") retval["landmarks"] = alignments.landmarks - if self._model.config["learn_mask"] or self._model.config["penalized_mask_loss"]: + if self._model.config["learn_mask"] or penalized_loss: logger.debug("Adding masks to training opts dict") retval["masks"] = alignments.masks - if self._model.config["eye_multiplier"] > 1: + if penalized_loss and self._model.config["eye_multiplier"] > 1: retval["masks_eye"] = alignments.masks_eye - if self._model.config["mouth_multiplier"] > 1: + if penalized_loss and self._model.config["mouth_multiplier"] > 1: retval["masks_mouth"] = alignments.masks_mouth logger.debug({key: {k: len(v) for k, v in val.items()} for key, val in retval.items()}) From 42495e71bbd06332a44761a8a2c778fa41f0dce1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 24 Sep 2020 12:43:03 +0100 Subject: [PATCH 304/981] Hold error messages open when launching GUI in Windows --- lib/cli/launcher.py | 50 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 0380541776..bb81cd9763 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -47,8 +47,7 @@ def _import_script(self): script = getattr(module, self._command.title()) return script - @staticmethod - def _test_for_tf_version(): + def _test_for_tf_version(self): """ Check that the required Tensorflow version is installed. Raises @@ -64,21 +63,48 @@ def _test_for_tf_version(): os.environ["KMP_AFFINITY"] = "disabled" import tensorflow as tf # pylint:disable=import-outside-toplevel except ImportError as err: - raise FaceswapError("There was an error importing Tensorflow. This is most likely " - "because you do not have TensorFlow installed, or you are trying " - "to run tensorflow-gpu on a system without an Nvidia graphics " - "card. Original import error: {}".format(str(err))) + if "DLL load failed while importing" in str(err): + msg = ( + "A DLL library file failed to load. Make sure that you have Microsoft Visual " + "C++ Redistributable (2015, 2017, 2019) installed for your machine from: " + "https://support.microsoft.com/en-gb/help/2977003") + else: + msg = ( + "There was an error importing Tensorflow. This is most likely because you do " + "not have TensorFlow installed, or you are trying to run tensorflow-gpu on a " + "system without an Nvidia graphics card. Original import " + "error: {}".format(str(err))) + self._handle_import_error(msg) + tf_ver = float(".".join(tf.__version__.split(".")[:2])) # pylint:disable=no-member if tf_ver < min_ver: - raise FaceswapError("The minimum supported Tensorflow is version {} but you have " - "version {} installed. Please upgrade Tensorflow.".format( - min_ver, tf_ver)) + msg = ("The minimum supported Tensorflow is version {} but you have version {} " + "installed. Please upgrade Tensorflow.".format(min_ver, tf_ver)) + self._handle_import_error(msg) if tf_ver > max_ver: - raise FaceswapError("The maximumum supported Tensorflow is version {} but you have " - "version {} installed. Please downgrade Tensorflow.".format( - max_ver, tf_ver)) + msg = ("The maximumum supported Tensorflow is version {} but you have version {} " + "installed. Please downgrade Tensorflow.".format(max_ver, tf_ver)) + self._handle_import_error(msg) logger.debug("Installed Tensorflow Version: %s", tf_ver) + @classmethod + def _handle_import_error(cls, message): + """ Display the error message to the console and wait for user input to dismiss it, if + running GUI under Windows, otherwise use standard error handling. + + Parameters + ---------- + message: str + The error message to display + """ + if "gui" in sys.argv and platform.system() == "Windows": + logger.error(message) + logger.info("Press \"ENTER\" to dismiss the message and close FaceSwap") + input() + sys.exit(1) + else: + raise FaceswapError(message) + def _test_for_gui(self): """ If running the gui, performs check to ensure necessary prerequisites are present. """ if self._command != "gui": From 538a03d5294f39257c46984e3fb00b1de9a6ab74 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 24 Sep 2020 16:30:01 +0100 Subject: [PATCH 305/981] Bugfix: Tools to use updated lib.gui.utils.config --- tools/manual/manual.py | 2 +- tools/preview/preview.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/manual/manual.py b/tools/manual/manual.py index f37f04a93d..164a8e4908 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -133,7 +133,7 @@ def _initialize_tkinter(self): logger.debug("Initializing tkinter") for widget in ("TButton", "TCheckbutton", "TRadiobutton"): self.unbind_class(widget, "") - initialize_config(self, None, None, None) + initialize_config(self, None, None) initialize_images() get_config().set_geometry(940, 600, fullscreen=True) self.title("Faceswap.py - Visual Alignments") diff --git a/tools/preview/preview.py b/tools/preview/preview.py index 75ccb9bb9d..6579ded558 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -87,7 +87,7 @@ def _available_masks(self): def _initialize_tkinter(self): """ Initialize a standalone tkinter instance. """ logger.debug("Initializing tkinter") - initialize_config(self, None, None, None) + initialize_config(self, None, None) initialize_images() get_config().set_geometry(940, 600, fullscreen=False) self.title("Faceswap.py - Convert Settings") From 68bf1e7431d4725304ae74ce82d6eba13f548c43 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 24 Sep 2020 16:49:46 +0100 Subject: [PATCH 306/981] Typofix --- plugins/train/model/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 064c576d4f..1bce2d545f 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -1048,7 +1048,7 @@ def _get_mask_channels(self): mouth_multiplier = self._config["mouth_multiplier"] if not self._config["penalized_mask_loss"] and (eye_multiplier > 1 or mouth_multiplier > 1): - logger.warning("You have selected eye/mouth loss multipliers greate than 1x, but " + logger.warning("You have selected eye/mouth loss multipliers greater than 1x, but " "Penalized Mask Loss is disabled. Disabling all multipliers.") eye_multiplier = 1 mouth_multiplier = 1 From d3f30384f4a53fe69ec384e44944021d32fcff55 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 24 Sep 2020 18:57:39 +0100 Subject: [PATCH 307/981] convert - Don't use buggy cv2.BODER_TRANSPARENT --- lib/convert.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/convert.py b/lib/convert.py index 157fa536ff..b587818a75 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -239,7 +239,7 @@ def _get_new_image(self, predicted, frame_size): frame_size, placeholder, flags=cv2.WARP_INVERSE_MAP | interpolator, - borderMode=cv2.BORDER_TRANSPARENT) + borderMode=cv2.BORDER_CONSTANT) np.clip(placeholder, 0.0, 1.0, out=placeholder) logger.trace("Got filename: '%s'. (placeholders: %s)", From dee13ab43f0e22099757d4a8dd8c74da36441caa Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 24 Sep 2020 23:50:43 +0100 Subject: [PATCH 308/981] bugfix: Correctly crop input image when disabling warp --- lib/training_data.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/lib/training_data.py b/lib/training_data.py index 3b3d385e77..48c811f798 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -245,9 +245,7 @@ def _process_batch(self, filenames, side): self._warp_to_landmarks, **warp_kwargs)] else: - size = (self._model_input_size, self._model_input_size) - processed["feed"] = [np.array([cv2.resize(img, size) - for img in batch[..., :3]]).astype("float32") / 255.0] + processed["feed"] = [self._processing.skip_warp(batch[..., :3])] logger.trace("Processed batch: (filenames: %s, side: '%s', processed: %s)", filenames, @@ -845,3 +843,27 @@ def _random_warp_landmarks(self, batch, batch_src_points, batch_dst_points): for image in warped_batch]) logger.trace("Warped batch shape: %s", warped_batch.shape) return warped_batch + + def skip_warp(self, batch): + """ Returns the images resized and cropped for feeding the model, if warping has been + disabled. + + Parameters + ---------- + batch: :class:`numpy.ndarray` + The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, + `3`) and in `BGR` format. + + Returns + ------- + :class:`numpy.ndarray` + The given batch cropped and resized for feeding the model + """ + logger.trace("Compiling skip warp images: batch shape: %s", batch.shape) + slices = self._constants["tgt_slices"] + retval = np.array([cv2.resize(image[slices, slices, :], + (self._input_size, self._input_size), + cv2.INTER_AREA) + for image in batch], dtype='float32') / 255. + logger.trace("feed batch shape: %s", retval.shape) + return retval From 88611018b32dc7f5ea8b19319457b4d85639b81b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 27 Sep 2020 00:56:39 +0100 Subject: [PATCH 309/981] Setup.py - Minor change to cudnn locator --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index b68875b422..97db789bf6 100755 --- a/setup.py +++ b/setup.py @@ -545,6 +545,7 @@ def cudnn_checkfiles_linux(): return list() cudnn_vers = chk[0] cudnn_path = chk[chk.find("=>") + 3:chk.find("libcudnn") - 1] + cudnn_path = os.path.realpath(cudnn_path) cudnn_path = cudnn_path.replace("lib", "include") cudnn_checkfiles = [os.path.join(cudnn_path, "cudnn_v{}.h".format(cudnn_vers)), os.path.join(cudnn_path, "cudnn.h")] From 9e8ae7584ff4eef5243c1c0b24705c294c6dcc72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfonso=20Rodr=C3=ADguez=20Pereira?= Date: Thu, 8 Oct 2020 23:56:29 +0200 Subject: [PATCH 310/981] Bugfix: Required tensorflow update to 2.2.1 (#1078) There is now no docker image tagged for tensorflow/tensorflow:2.2.0-gpu-py3, bumped it to 2.2.1 --- Dockerfile.gpu | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.gpu b/Dockerfile.gpu index 9087b79ee9..d5768658cd 100755 --- a/Dockerfile.gpu +++ b/Dockerfile.gpu @@ -1,4 +1,4 @@ -FROM tensorflow/tensorflow:2.2.0-gpu-py3 +FROM tensorflow/tensorflow:2.2.1-gpu-py3 ENV DEBIAN_FRONTEND noninteractive From fe664e274814a75b56c2f8aeabcb1a51d8f5d4f0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 9 Oct 2020 23:15:40 +0100 Subject: [PATCH 311/981] Bugfix - Fix Timelapse when alignments files are used --- plugins/train/trainer/_base.py | 20 ++++++-- scripts/train.py | 87 ++++++++++++++++++++-------------- 2 files changed, 66 insertions(+), 41 deletions(-) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 2986460742..b79fffb7fb 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -93,7 +93,8 @@ def __init__(self, model, images, batch_size, configfile): self._timelapse = _Timelapse(self._model, self._model.coverage_ratio, self._config.get("preview_images", 14), - self._feeder) + self._feeder, + self._images) logger.debug("Initialized %s", self.__class__.__name__) def _get_alignments_data(self): @@ -960,15 +961,18 @@ class _Timelapse(): # pylint:disable=too-few-public-methods The number of preview images to be displayed in the time-lapse feeder: dict The :class:`_Feeder` for generating the time-lapse images. + image_paths: dict + The full paths to the training images for each side of the model """ - def __init__(self, model, coverage_ratio, image_count, feeder): + def __init__(self, model, coverage_ratio, image_count, feeder, image_paths): logger.debug("Initializing %s: model: %s, coverage_ratio: %s, image_count: %s, " - "feeder: '%s')", self.__class__.__name__, model, coverage_ratio, - image_count, feeder) + "feeder: '%s', image_paths: %s)", self.__class__.__name__, model, + coverage_ratio, image_count, feeder, len(image_paths)) self._num_images = image_count self._samples = _Samples(model, coverage_ratio) self._model = model self._feeder = feeder + self._image_paths = image_paths self._output_file = None logger.debug("Initialized %s", self.__class__.__name__) @@ -992,7 +996,13 @@ def _setup(self, input_a=None, input_b=None, output=None): self._output_file = str(output) logger.debug("Time-lapse output set to '%s'", self._output_file) - images = {"a": get_image_paths(input_a), "b": get_image_paths(input_b)} + # Rewrite paths to pull from the training images so mask and landmark data can be accessed + images = dict() + for side, input_ in zip(("a", "b"), (input_a, input_b)): + training_path = os.path.dirname(self._image_paths[side][0]) + images[side] = [os.path.join(training_path, os.path.basename(pth)) + for pth in get_image_paths(input_)] + batchsize = min(len(images["a"]), len(images["b"]), self._num_images) diff --git a/scripts/train.py b/scripts/train.py index 22ec8fa828..67cf87308f 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -37,8 +37,8 @@ class Train(): # pylint:disable=too-few-public-methods def __init__(self, arguments): logger.debug("Initializing %s: (args: %s", self.__class__.__name__, arguments) self._args = arguments - self._timelapse = self._set_timelapse() self._images = self._get_images() + self._timelapse = self._set_timelapse() self._gui_preview_trigger = os.path.join(os.path.realpath(os.path.dirname(sys.argv[0])), "lib", "gui", ".cache", ".preview_trigger") self._stop = False @@ -59,41 +59,6 @@ def _image_size(self): logger.debug("Training image size: %s", size) return size - def _set_timelapse(self): - """ Set time-lapse paths if requested. - - Returns - ------- - dict - The time-lapse keyword arguments for passing to the trainer - - """ - if (not self._args.timelapse_input_a and - not self._args.timelapse_input_b and - not self._args.timelapse_output): - return None - if (not self._args.timelapse_input_a or - not self._args.timelapse_input_b or - not self._args.timelapse_output): - raise FaceswapError("To enable the timelapse, you have to supply all the parameters " - "(--timelapse-input-A, --timelapse-input-B and " - "--timelapse-output).") - - timelapse_output = str(get_folder(self._args.timelapse_output)) - - for folder in (self._args.timelapse_input_a, self._args.timelapse_input_b): - if folder is not None and not os.path.isdir(folder): - raise FaceswapError("The Timelapse path '{}' does not exist".format(folder)) - exts = [os.path.splitext(fname)[-1].lower() for fname in os.listdir(folder)] - if not any(ext in _image_extensions for ext in exts): - raise FaceswapError("The Timelapse path '{}' does not contain any valid " - "images".format(folder)) - kwargs = {"input_a": self._args.timelapse_input_a, - "input_b": self._args.timelapse_input_b, - "output": timelapse_output} - logger.debug("Timelapse enabled: %s", kwargs) - return kwargs - def _get_images(self): """ Check the image folders exist and contains images and obtain image paths. @@ -152,6 +117,56 @@ def _validate_image_counts(cls, images): "Results are likely to be poor.") logger.warning(msg) + def _set_timelapse(self): + """ Set time-lapse paths if requested. + + Returns + ------- + dict + The time-lapse keyword arguments for passing to the trainer + + """ + if (not self._args.timelapse_input_a and + not self._args.timelapse_input_b and + not self._args.timelapse_output): + return None + if (not self._args.timelapse_input_a or + not self._args.timelapse_input_b or + not self._args.timelapse_output): + raise FaceswapError("To enable the timelapse, you have to supply all the parameters " + "(--timelapse-input-A, --timelapse-input-B and " + "--timelapse-output).") + + timelapse_output = str(get_folder(self._args.timelapse_output)) + + for side in ("a", "b"): + folder = getattr(self._args, "timelapse_input_{}".format(side)) + if folder is not None and not os.path.isdir(folder): + raise FaceswapError("The Timelapse path '{}' does not exist".format(folder)) + + training_folder = getattr(self._args, "input_{}".format(side)) + if folder == training_folder: + continue # Timelapse folder is training folder + + filenames = [fname for fname in os.listdir(folder) + if os.path.splitext(fname)[-1].lower() in _image_extensions] + if not filenames: + raise FaceswapError("The Timelapse path '{}' does not contain any valid " + "images".format(folder)) + + # Timelapse images must appear in the training set, as we need access to alignment and + # mask info. Check filenames are there to save failing much later in the process. + training_images = [os.path.basename(img) for img in self._images[side]] + if not all(img in training_images for img in filenames): + raise FaceswapError("All images in the Timelapse folder '{}' must exist in the " + "training folder '{}'".format(folder, training_folder)) + + kwargs = {"input_a": self._args.timelapse_input_a, + "input_b": self._args.timelapse_input_b, + "output": timelapse_output} + logger.debug("Timelapse enabled: %s", kwargs) + return kwargs + def process(self): """ The entry point for triggering the Training Process. From a655fbe34350cecd6ac8baf61b9034865a3f9613 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 10 Oct 2020 01:29:50 +0100 Subject: [PATCH 312/981] Revert convert to BORDER_TRANSPARENT --- lib/convert.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/convert.py b/lib/convert.py index b587818a75..272d7d9220 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -239,9 +239,8 @@ def _get_new_image(self, predicted, frame_size): frame_size, placeholder, flags=cv2.WARP_INVERSE_MAP | interpolator, - borderMode=cv2.BORDER_CONSTANT) + borderMode=cv2.BORDER_TRANSPARENT) - np.clip(placeholder, 0.0, 1.0, out=placeholder) logger.trace("Got filename: '%s'. (placeholders: %s)", predicted["filename"], placeholder.shape) From c049fed42effac53dde6699866fa074399900f09 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 10 Oct 2020 11:43:16 +0100 Subject: [PATCH 313/981] Bugfix - IAE Model for AMD users --- plugins/train/model/iae.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/train/model/iae.py b/plugins/train/model/iae.py index 87559313c5..df1b545bbb 100644 --- a/plugins/train/model/iae.py +++ b/plugins/train/model/iae.py @@ -44,7 +44,7 @@ def encoder(self): def intermediate(self, side): """ Intermediate Network """ - input_ = Input(shape=(4 * 4 * 1024)) + input_ = Input(shape=(4 * 4 * 1024, )) var_x = Dense(self.encoder_dim)(input_) var_x = Dense(4 * 4 * int(self.encoder_dim/2))(var_x) var_x = Reshape((4, 4, int(self.encoder_dim/2)))(var_x) From 8a4199e1b0f45118abd035d45923019798256c97 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 14 Oct 2020 12:06:30 +0100 Subject: [PATCH 314/981] lib.model.normalization - Add ADAIN and Group Norm --- lib/model/normalization.py | 296 ++++++++++++++++++++++++++++++++++++- 1 file changed, 294 insertions(+), 2 deletions(-) diff --git a/lib/model/normalization.py b/lib/model/normalization.py index 270b761924..2bcd0c0b98 100644 --- a/lib/model/normalization.py +++ b/lib/model/normalization.py @@ -83,8 +83,6 @@ def __init__(self, def build(self, input_shape): """Creates the layer weights. - Must be implemented on all layers that have weights. - Parameters ---------- input_shape: tensor @@ -191,6 +189,300 @@ class name. These are handled by `Network` (one layer of abstraction above). return dict(list(base_config.items()) + list(config.items())) +class AdaInstanceNormalization(Layer): + """ Adaptive Instance Normalization Layer for Keras. + + Parameters + ---------- + axis: int, optional + The axis that should be normalized (typically the features axis). For instance, after a + `Conv2D` layer with `data_format="channels_first"`, set `axis=1` in + :class:`InstanceNormalization`. Setting `axis=None` will normalize all values in each + instance of the batch. Axis 0 is the batch dimension. `axis` cannot be set to 0 to avoid + errors. Default: ``None`` + momentum: float, optional + Momentum for the moving mean and the moving variance. Default: `0.99` + epsilon: float, optional + Small float added to variance to avoid dividing by zero. Default: `1e-3` + center: bool, optional + If ``True``, add offset of `beta` to normalized tensor. If ``False``, `beta` is ignored. + Default: ``True`` + scale: bool, optional + If ``True``, multiply by `gamma`. If ``False``, `gamma` is not used. When the next layer + is linear (also e.g. `relu`), this can be disabled since the scaling will be done by + the next layer. Default: ``True`` + + References + ---------- + Arbitrary Style Transfer in Real-time with Adaptive Instance Normalization - \ + https://arxiv.org/abs/1703.06868 + """ + def __init__(self, axis=-1, momentum=0.99, epsilon=1e-3, center=True, scale=True, **kwargs): + super().__init__(**kwargs) + self.axis = axis + self.momentum = momentum + self.epsilon = epsilon + self.center = center + self.scale = scale + + def build(self, input_shape): + """Creates the layer weights. + + Parameters + ---------- + input_shape: tensor + Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to + reference for weight shape computations. + """ + dim = input_shape[0][self.axis] + if dim is None: + raise ValueError('Axis ' + str(self.axis) + ' of ' + 'input tensor should have a defined dimension ' + 'but the layer received an input with shape ' + + str(input_shape[0]) + '.') + + super(AdaInstanceNormalization, self).build(input_shape) + + def call(self, inputs, training=None): # pylint:disable=unused-argument + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: tensor + Input tensor, or list/tuple of input tensors + + Returns + ------- + tensor + A tensor or list/tuple of tensors + """ + input_shape = K.int_shape(inputs[0]) + reduction_axes = list(range(0, len(input_shape))) + + beta = inputs[1] + gamma = inputs[2] + + if self.axis is not None: + del reduction_axes[self.axis] + + del reduction_axes[0] + mean = K.mean(inputs[0], reduction_axes, keepdims=True) + stddev = K.std(inputs[0], reduction_axes, keepdims=True) + self.epsilon + normed = (inputs[0] - mean) / stddev + + return normed * gamma + beta + + def get_config(self): + """Returns the config of the layer. + + The Keras configuration for the layer. + + Returns + -------- + dict + A python dictionary containing the layer configuration + """ + config = { + 'axis': self.axis, + 'momentum': self.momentum, + 'epsilon': self.epsilon, + 'center': self.center, + 'scale': self.scale + } + base_config = super(AdaInstanceNormalization, self).get_config() + return dict(list(base_config.items()) + list(config.items())) + + def compute_output_shape(self, input_shape): + """ Calculate the output shape from this layer. + + Parameters + ---------- + input_shape: tuple + The input shape to the layer + + Returns + ------- + int + The output shape to the layer + """ + return input_shape[0] + + +class GroupNormalization(Layer): + """ Group Normalization + + Parameters + ---------- + axis: int, optional + The axis that should be normalized (typically the features axis). For instance, after a + `Conv2D` layer with `data_format="channels_first"`, set `axis=1` in + :class:`InstanceNormalization`. Setting `axis=None` will normalize all values in each + instance of the batch. Axis 0 is the batch dimension. `axis` cannot be set to 0 to avoid + errors. Default: ``None`` + gamma_init: str, optional + Initializer for the gamma weight. Default: `"one"` + beta_init: str, optional + Initializer for the beta weight. Default `"zero"` + gamma_regularizer: varies, optional + Optional regularizer for the gamma weight. Default: ``None`` + beta_regularizer: varies, optional + Optional regularizer for the beta weight. Default ``None`` + epsilon: float, optional + Small float added to variance to avoid dividing by zero. Default: `1e-3` + group: int, optional + The group size. Default: `32` + data_format: ["channels_first", "channels_last"], optional + The required data format. Optional. Default: ``None`` + kwargs: dict + Any additional standard Keras Layer key word arguments + + References + ---------- + Shaoanlu GAN: https://github.com/shaoanlu/faceswap-GAN + """ + def __init__(self, axis=-1, gamma_init='one', beta_init='zero', gamma_regularizer=None, + beta_regularizer=None, epsilon=1e-6, group=32, data_format=None, **kwargs): + self.beta = None + self.gamma = None + super(GroupNormalization, self).__init__(**kwargs) + self.axis = axis if isinstance(axis, (list, tuple)) else [axis] + self.gamma_init = initializers.get(gamma_init) + self.beta_init = initializers.get(beta_init) + self.gamma_regularizer = regularizers.get(gamma_regularizer) + self.beta_regularizer = regularizers.get(beta_regularizer) + self.epsilon = epsilon + self.group = group + self.data_format = K.normalize_data_format(data_format) + + self.supports_masking = True + + def build(self, input_shape): + """Creates the layer weights. + + Parameters + ---------- + input_shape: tensor + Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to + reference for weight shape computations. + """ + self.input_spec = [InputSpec(shape=input_shape)] + shape = [1 for _ in input_shape] + if self.data_format == 'channels_last': + channel_axis = -1 + shape[channel_axis] = input_shape[channel_axis] + elif self.data_format == 'channels_first': + channel_axis = 1 + shape[channel_axis] = input_shape[channel_axis] + # for i in self.axis: + # shape[i] = input_shape[i] + self.gamma = self.add_weight(shape=shape, + initializer=self.gamma_init, + regularizer=self.gamma_regularizer, + name='gamma') + self.beta = self.add_weight(shape=shape, + initializer=self.beta_init, + regularizer=self.beta_regularizer, + name='beta') + self.built = True + + def call(self, inputs, mask=None): # pylint: disable=unused-argument + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: tensor + Input tensor, or list/tuple of input tensors + + Returns + ------- + tensor + A tensor or list/tuple of tensors + """ + input_shape = K.int_shape(inputs) + if len(input_shape) != 4 and len(input_shape) != 2: + raise ValueError('Inputs should have rank ' + + str(4) + " or " + str(2) + + '; Received input shape:', str(input_shape)) + + if len(input_shape) == 4: + if self.data_format == 'channels_last': + batch_size, height, width, channels = input_shape + if batch_size is None: + batch_size = -1 + + if channels < self.group: + raise ValueError('Input channels should be larger than group size' + + '; Received input channels: ' + str(channels) + + '; Group size: ' + str(self.group)) + + var_x = K.reshape(inputs, (batch_size, + height, + width, + self.group, + channels // self.group)) + mean = K.mean(var_x, axis=[1, 2, 4], keepdims=True) + std = K.sqrt(K.var(var_x, axis=[1, 2, 4], keepdims=True) + self.epsilon) + var_x = (var_x - mean) / std + + var_x = K.reshape(var_x, (batch_size, height, width, channels)) + retval = self.gamma * var_x + self.beta + elif self.data_format == 'channels_first': + batch_size, channels, height, width = input_shape + if batch_size is None: + batch_size = -1 + + if channels < self.group: + raise ValueError('Input channels should be larger than group size' + + '; Received input channels: ' + str(channels) + + '; Group size: ' + str(self.group)) + + var_x = K.reshape(inputs, (batch_size, + self.group, + channels // self.group, + height, + width)) + mean = K.mean(var_x, axis=[2, 3, 4], keepdims=True) + std = K.sqrt(K.var(var_x, axis=[2, 3, 4], keepdims=True) + self.epsilon) + var_x = (var_x - mean) / std + + var_x = K.reshape(var_x, (batch_size, channels, height, width)) + retval = self.gamma * var_x + self.beta + + elif len(input_shape) == 2: + reduction_axes = list(range(0, len(input_shape))) + del reduction_axes[0] + batch_size, _ = input_shape + if batch_size is None: + batch_size = -1 + + mean = K.mean(inputs, keepdims=True) + std = K.sqrt(K.var(inputs, keepdims=True) + self.epsilon) + var_x = (inputs - mean) / std + + retval = self.gamma * var_x + self.beta + return retval + + def get_config(self): + """Returns the config of the layer. + + The Keras configuration for the layer. + + Returns + -------- + dict + A python dictionary containing the layer configuration + """ + config = {'epsilon': self.epsilon, + 'axis': self.axis, + 'gamma_init': initializers.serialize(self.gamma_init), + 'beta_init': initializers.serialize(self.beta_init), + 'gamma_regularizer': regularizers.serialize(self.gamma_regularizer), + 'beta_regularizer': regularizers.serialize(self.gamma_regularizer), + 'group': self.group} + base_config = super(GroupNormalization, self).get_config() + return dict(list(base_config.items()) + list(config.items())) + + # Update normalization into Keras custom objects for name, obj in inspect.getmembers(sys.modules[__name__]): if inspect.isclass(obj) and obj.__module__ == __name__: From cbc55814c6f47dbde9e0b2056357abc7b1abaa17 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 14 Oct 2020 16:51:01 +0100 Subject: [PATCH 315/981] lib.model.nn_blocks fix --- lib/model/nn_blocks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index 498a989efb..ed964ff96f 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -80,7 +80,8 @@ class Conv2D(KConv2D): # pylint:disable=too-few-public-methods """ def __init__(self, *args, padding="same", check_icnr_init=False, **kwargs): if kwargs.get("name", None) is None: - kwargs["name"] = _get_name("conv2d_{}".format(args[0])) + filters = kwargs["filters"] if "filters" in kwargs else args[0] + kwargs["name"] = _get_name("conv2d_{}".format(filters)) initializer = self._get_default_initializer(kwargs.pop("kernel_initializer", None)) if check_icnr_init and _CONFIG["icnr_init"]: initializer = ICNR(initializer=initializer) From edc3b96bba5a79afdf1eadc355254056cdbbebdb Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 15 Oct 2020 14:31:29 +0100 Subject: [PATCH 316/981] GUI Bugfix - Convert preview --- lib/gui/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 5f0929e3f6..748d586a77 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -488,7 +488,6 @@ def load_latest_preview(self, thumbnail_size, frame_dims): gui_preview = os.path.join(self._pathoutput, ".gui_preview.jpg") if not image_files or (len(image_files) == 1 and gui_preview not in image_files): logger.debug("No preview to display") - self._previewoutput = None return # Filter to just the gui_preview if it exists in folder output image_files = [gui_preview] if gui_preview in image_files else image_files From b46c02d10494877e991b2c560f8acd957fa104b4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 18 Oct 2020 23:35:50 +0100 Subject: [PATCH 317/981] extract: S3FD - Convert to re-enable CPU --- lib/cli/args.py | 5 +- lib/cli/launcher.py | 6 - plugins/extract/detect/s3fd.py | 450 +++++++++++++-------------------- 3 files changed, 184 insertions(+), 277 deletions(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index 85f640a27d..6d7f40d3a6 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -364,8 +364,9 @@ def get_optional_arguments(): "resource intensive. Use this if not using a GPU and time is important." "\nL|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " "than other GPU detectors but can often return more false positives." - "\nL|s3fd: Best detector. GPU only. Can detect more faces and fewer false " - "positives than other GPU detectors, but is a lot more resource intensive.")) + "\nL|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " + "fewer false positives than other GPU detectors, but is a lot more resource " + "intensive.")) argument_list.append(dict( opts=("-A", "--aligner"), action=Radio, diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index bb81cd9763..d1030fb405 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -226,12 +226,6 @@ def _configure_backend(self, arguments): arguments.exclude_gpus = [int(idx) for idx in arguments.exclude_gpus] set_exclude_devices(arguments.exclude_gpus) - if ((get_backend() == "cpu" or GPUStats().exclude_all_devices) and - (self._command == "extract" and arguments.detector == "s3fd")): - logger.error("Extracting on CPU is not currently for detector: '%s'", - arguments.detector.upper()) - sys.exit(0) - if GPUStats().exclude_all_devices and get_backend() != "cpu": msg = "Switching backend to CPU" if get_backend() == "amd": diff --git a/plugins/extract/detect/s3fd.py b/plugins/extract/detect/s3fd.py index 0b1e7d8daf..62db1ccda0 100644 --- a/plugins/extract/detect/s3fd.py +++ b/plugins/extract/detect/s3fd.py @@ -10,6 +10,7 @@ import numpy as np import keras # pylint:disable=import-error import keras.backend as K # pylint:disable=import-error +from keras.layers import Concatenate, Conv2D, Input, Maximum, MaxPooling2D, ZeroPadding2D from lib.model.session import KSession from ._base import Detector, logger @@ -19,7 +20,7 @@ class Detect(Detector): """ S3FD detector for face recognition """ def __init__(self, **kwargs): git_model_id = 11 - model_filename = "s3fd_keras_v1.h5" + model_filename = "s3fd_keras_v2.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.name = "S3FD" self.input_size = 640 @@ -31,13 +32,7 @@ def __init__(self, **kwargs): def init_model(self): """ Initialize S3FD Model""" confidence = self.config["confidence"] / 100 - model_kwargs = dict(custom_objects=dict(O2K_Add=AddO2K, - O2K_Slice=SliceO2K, - O2K_Sum=SumO2K, - O2K_Sqrt=SqrtO2K, - O2K_Pow=PowO2K, - O2K_ConstantLayer=ConstantLayerO2K, - O2K_Div=DivO2K)) + model_kwargs = dict(custom_objects=dict(L2Norm=L2Norm, SliceO2K=SliceO2K)) self.model = S3fd(self.model_path, model_kwargs, self.config["allow_growth"], @@ -63,73 +58,56 @@ def process_output(self, batch): ################################################################################ # CUSTOM KERAS LAYERS -# generated by onnx2keras ################################################################################ -class ElementwiseLayerO2K(keras.layers.Layer): - """ Custom Keras Element Wise layer generated by onnx2keras. """ - def call(self, inputs, **kwargs): # pylint:disable=unused-argument - """This is where the layer's logic lives. - - Override for layers that inherit from this class. - - Parameters - ---------- - inputs: Input tensor, or list/tuple of input tensors. - The input to the layer - **kwargs: Additional keyword arguments. - Required for parent class but unused - Returns - ------- - A tensor or list/tuple of tensors. - The layer output - """ - raise NotImplementedError() - - def compute_output_shape(self, input_shape): # pylint:disable=no-self-use - """Computes the output shape of the layer. +class L2Norm(keras.layers.Layer): + """ L2 Normalization layer for S3FD. + + Parameters + ---------- + n_channels: int + The number of channels to normalize + scale: float, optional + The scaling for initial weights. Default: `1.0` + """ + def __init__(self, n_channels, scale=1.0, **kwargs): + super().__init__(**kwargs) + self._n_channels = n_channels + self._scale = scale + self.w = self.add_weight("l2norm", # pylint:disable=invalid-name + (self._n_channels, ), + trainable=True, + initializer=keras.initializers.Constant(value=self._scale), + dtype="float32") - Assumes that the layer will be built to match that input shape provided. + def call(self, inputs): + """ Call the L2 Normalization Layer. Parameters ---------- - input_shape: tuple or list of tuples - Shape tuple (tuple of integers) or list of shape tuples (one per output tensor of the - layer). Shape tuples can include ``None`` for free dimensions, instead of an integer. + inputs: tensor + The input to the L2 Normalization Layer Returns ------- - tuple - An output shape tuple. + tensor: + The output from the L2 Normalization Layer """ - # TODO: do this nicer - ldims = len(input_shape[0]) - rdims = len(input_shape[1]) - if ldims > rdims: - return input_shape[0] - if rdims > ldims: - return input_shape[1] - lprod = np.prod(list(filter(bool, input_shape[0]))) - rprod = np.prod(list(filter(bool, input_shape[1]))) - return input_shape[0 if lprod > rprod else 1] - - -class AddO2K(ElementwiseLayerO2K): - """ Custom Keras Add layer generated by onnx2keras. """ - def call(self, inputs, **kwargs): # pylint:disable=unused-argument - """This is where the layer's logic lives. + norm = K.sqrt(K.sum(K.pow(inputs, 2), axis=-1, keepdims=True)) + 1e-10 + var_x = inputs / norm * self.w + return var_x + + def get_config(self): + """ Returns the config of the layer. - Parameters - ---------- - inputs: Input tensor, or list/tuple of input tensors. - The input to the layer - **kwargs: Additional keyword arguments. - Required for parent class but unused Returns ------- - A tensor or list/tuple of tensors. - The layer output + dict + The configuration for the layer """ - return inputs[0] + inputs[1] + config = super().get_config() + config.update({"n_channels": self._n_channels, + "scale": self._scale}) + return config class SliceO2K(keras.layers.Layer): @@ -141,26 +119,6 @@ def __init__(self, starts, ends, axes=None, steps=None, **kwargs): self._steps = steps super().__init__(**kwargs) - def get_config(self): - """ Returns the config of the layer. - - A layer config is a Python dictionary (serializable) containing the configuration of a - layer. The same layer can be re-instantiated later (without its trained weights) from this - configuration. The config of a layer does not include connectivity information, nor the - layer class name. These are handled by `Network` (one layer of abstraction above). - - Returns - ------- - dict - The configuration for the layer - """ - config = super().get_config() - config.update({ - 'starts': self._starts, 'ends': self._ends, - 'axes': self._axes, 'steps': self._steps - }) - return config - def _get_slices(self, dimensions): """ Obtain slices for the given number of dimensions. @@ -237,243 +195,197 @@ def call(self, inputs, **kwargs): # pylint:disable=unused-argument retval = inputs[tuple(slices)] return retval - -class ReduceLayerO2K(keras.layers.Layer): - """ Custom Keras Reduce layer generated by onnx2keras. """ - def __init__(self, axes=None, keepdims=True, **kwargs): - self._axes = [axes] if isinstance(axes, int) else axes - self._keepdims = bool(keepdims) - super().__init__(**kwargs) - def get_config(self): """ Returns the config of the layer. - A layer config is a Python dictionary (serializable) containing the configuration of a - layer. The same layer can be re-instantiated later (without its trained weights) from this - configuration. The config of a layer does not include connectivity information, nor the - layer class name. These are handled by `Network` (one layer of abstraction above). - Returns ------- dict The configuration for the layer """ config = super().get_config() - config.update({ - 'axes': self._axes, - 'keepdims': self._keepdims - }) + config.update({"starts": self._starts, + "ends": self._ends, + "axes": self._axes, + "steps": self._steps}) return config - def compute_output_shape(self, input_shape): - """Computes the output shape of the layer. - Assumes that the layer will be built to match that input shape provided. +class S3fd(KSession): + """ Keras Network """ + def __init__(self, model_path, model_kwargs, allow_growth, exclude_gpus, confidence): + logger.debug("Initializing: %s: (model_path: '%s', model_kwargs: %s, allow_growth: %s, " + "exclude_gpus: %s, confidence: %s)", self.__class__.__name__, model_path, + model_kwargs, allow_growth, exclude_gpus, confidence) + super().__init__("S3FD", + model_path, + model_kwargs=model_kwargs, + allow_growth=allow_growth, + exclude_gpus=exclude_gpus) + self.define_model(self.model_definition) + self.load_model_weights() + self.confidence = confidence + self.average_img = np.array([104.0, 117.0, 123.0]) + logger.debug("Initialized: %s", self.__class__.__name__) - Parameters - ---------- - input_shape: tuple or list of tuples - Shape tuple (tuple of integers) or list of shape tuples (one per output tensor of the - layer). Shape tuples can include ``None`` for free dimensions, instead of an integer. + def model_definition(self): + """ Keras S3FD Model Definition, adapted from FAN pytorch implementation. """ + input_ = Input(shape=(640, 640, 3)) + var_x = self.conv_block(input_, 64, 1, 2) + var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) - Returns - ------- - tuple - An output shape tuple. - """ - if self._axes is None: - return (1,)*len(input_shape) if self._keepdims else tuple() - ret = list(input_shape) - for i in sorted(self._axes, reverse=True): - if self._keepdims: - ret[i] = 1 - else: - ret.pop(i) - return tuple(ret) + var_x = self.conv_block(var_x, 128, 2, 2) + var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) - def call(self, inputs, **kwargs): # pylint:disable=unused-argument - """This is where the layer's logic lives. + var_x = self.conv_block(var_x, 256, 3, 3) + f3_3 = var_x + var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) - Override for layers which inherit from this class + var_x = self.conv_block(var_x, 512, 4, 3) + f4_3 = var_x + var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) - Parameters - ---------- - inputs: Input tensor, or list/tuple of input tensors. - The input to the layer - **kwargs: Additional keyword arguments. - Required for parent class but unused - Returns - ------- - A tensor or list/tuple of tensors. - The layer output - """ - raise NotImplementedError() + var_x = self.conv_block(var_x, 512, 5, 3) + f5_3 = var_x + var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) + var_x = ZeroPadding2D(3)(var_x) + var_x = Conv2D(1024, kernel_size=3, strides=1, activation="relu", name="fc6")(var_x) + var_x = Conv2D(1024, kernel_size=1, strides=1, activation="relu", name="fc7")(var_x) + ffc7 = var_x -class SumO2K(ReduceLayerO2K): - """ Custom Keras Sum layer generated by onnx2keras. """ - def call(self, inputs, **kwargs): # pylint:disable=unused-argument - """This is where the layer's logic lives. + f6_2 = self.conv_up(var_x, 256, 6) + f7_2 = self.conv_up(f6_2, 128, 7) - Parameters - ---------- - inputs: Input tensor, or list/tuple of input tensors. - The input to the layer - **kwargs: Additional keyword arguments. - Required for parent class but unused - Returns - ------- - A tensor or list/tuple of tensors. - The layer output - """ - return K.sum(inputs, self._axes, self._keepdims) + f3_3 = L2Norm(256, scale=10, name="conv3_3_norm")(f3_3) + f4_3 = L2Norm(512, scale=8, name="conv4_3_norm")(f4_3) + f5_3 = L2Norm(512, scale=5, name="conv5_3_norm")(f5_3) + f3_3 = ZeroPadding2D(1)(f3_3) + cls1 = Conv2D(4, kernel_size=3, strides=1, name="conv3_3_norm_mbox_conf")(f3_3) + reg1 = Conv2D(4, kernel_size=3, strides=1, name="conv3_3_norm_mbox_loc")(f3_3) -class SqrtO2K(keras.layers.Layer): # pylint:disable=too-few-public-methods - """ Custom Keras Square Root layer generated by onnx2keras. """ - def call(self, inputs, **kwargs): # pylint:disable=unused-argument,no-self-use - """This is where the layer's logic lives. + f4_3 = ZeroPadding2D(1)(f4_3) + cls2 = Conv2D(2, kernel_size=3, strides=1, name="conv4_3_norm_mbox_conf")(f4_3) + reg2 = Conv2D(4, kernel_size=3, strides=1, name="conv4_3_norm_mbox_loc")(f4_3) - Parameters - ---------- - inputs: Input tensor, or list/tuple of input tensors. - The input to the layer - **kwargs: Additional keyword arguments. - Required for parent class but unused - Returns - ------- - A tensor or list/tuple of tensors. - The layer output - """ - return K.sqrt(inputs) + f5_3 = ZeroPadding2D(1)(f5_3) + cls3 = Conv2D(2, kernel_size=3, strides=1, name="conv5_3_norm_mbox_conf")(f5_3) + reg3 = Conv2D(4, kernel_size=3, strides=1, name="conv5_3_norm_mbox_loc")(f5_3) + ffc7 = ZeroPadding2D(1)(ffc7) + cls4 = Conv2D(2, kernel_size=3, strides=1, name="fc7_mbox_conf")(ffc7) + reg4 = Conv2D(4, kernel_size=3, strides=1, name="fc7_mbox_loc")(ffc7) -class PowO2K(keras.layers.Layer): # pylint:disable=too-few-public-methods - """ Custom Keras Power layer generated by onnx2keras. """ - def call(self, inputs, **kwargs): # pylint:disable=unused-argument,no-self-use - """This is where the layer's logic lives. + f6_2 = ZeroPadding2D(1)(f6_2) + cls5 = Conv2D(2, kernel_size=3, strides=1, name="conv6_2_mbox_conf")(f6_2) + reg5 = Conv2D(4, kernel_size=3, strides=1, name="conv6_2_mbox_loc")(f6_2) - Parameters - ---------- - inputs: Input tensor, or list/tuple of input tensors. - The input to the layer - **kwargs: Additional keyword arguments. - Required for parent class but unused - Returns - ------- - A tensor or list/tuple of tensors. - The layer output - """ - return K.pow(*inputs) - - -class ConstantLayerO2K(keras.layers.Layer): - """ Custom Keras Constant layer generated by onnx2keras. """ - def __init__(self, constant_obj, dtype, **kwargs): - self._dtype = np.dtype(dtype).name - self._constant = np.array(constant_obj, dtype=self._dtype) - super().__init__(**kwargs) + f7_2 = ZeroPadding2D(1)(f7_2) + cls6 = Conv2D(2, kernel_size=3, strides=1, name="conv7_2_mbox_conf")(f7_2) + reg6 = Conv2D(4, kernel_size=3, strides=1, name="conv7_2_mbox_loc")(f7_2) - def call(self, inputs, **kwargs): # pylint:disable=unused-argument - """This is where the layer's logic lives. + # max-out background label + chunks = [SliceO2K(starts=[0], ends=[1], axes=[3], steps=None)(cls1), + SliceO2K(starts=[1], ends=[2], axes=[3], steps=None)(cls1), + SliceO2K(starts=[2], ends=[3], axes=[3], steps=None)(cls1), + SliceO2K(starts=[3], ends=[4], axes=[3], steps=None)(cls1)] - Parameters - ---------- - inputs: Input tensor, or list/tuple of input tensors. - The input to the layer. Required for parent class but unused - **kwargs: Additional keyword arguments. - Required for parent class but unused - Returns - ------- - A tensor or list/tuple of tensors. - The layer output - """ - data = K.constant(self._constant, dtype=self._dtype) - return data + bmax = Maximum()([chunks[0], chunks[1], chunks[2]]) + cls1 = Concatenate()([bmax, chunks[3]]) - def compute_output_shape(self, input_shape): # pylint:disable=unused-argument - """Computes the output shape of the layer. + return [input_], [cls1, reg1, cls2, reg2, cls3, reg3, cls4, reg4, cls5, reg5, cls6, reg6] - Assumes that the layer will be built to match that input shape provided. + @classmethod + def conv_block(cls, inputs, filters, idx, recursions): + """ First round convolutions with zero padding added. Parameters ---------- - input_shape: tuple or list of tuples - Shape tuple (tuple of integers) or list of shape tuples (one per output tensor of the - layer). Shape tuples can include ``None`` for free dimensions, instead of an integer. - This is unused for a constant layer + inputs: tensor + The input tensor to the convolution block + filters: int + The number of filters + idx: int + The layer index for naming + recursions: int + The number of recursions of the block to perform Returns ------- - tuple - An output shape tuple. + tensor + The output tensor from the convolution block """ - return self._constant.shape - - def get_config(self): - """ Returns the config of the layer. + name = "conv{}".format(idx) + var_x = inputs + for i in range(1, recursions + 1): + rec_name = "{}_{}".format(name, i) + var_x = ZeroPadding2D(1, name="{}.zeropad".format(rec_name))(var_x) + var_x = Conv2D(filters, + kernel_size=3, + strides=1, + activation="relu", + name=rec_name)(var_x) + return var_x + + @classmethod + def conv_up(cls, inputs, filters, idx): + """ Convolution up filter blocks with zero padding added. - A layer config is a Python dictionary (serializable) containing the configuration of a - layer. The same layer can be re-instantiated later (without its trained weights) from this - configuration. The config of a layer does not include connectivity information, nor the - layer class name. These are handled by `Network` (one layer of abstraction above). + Parameters + ---------- + inputs: tensor + The input tensor to the convolution block + filters: int + The initial number of filters + idx: int + The layer index for naming Returns ------- - dict - The configuration for the layer + tensor + The output tensor from the convolution block """ - config = super().get_config() - config.update({ - 'constant_obj': self._constant, - 'dtype': self._dtype - }) - return config + name = "conv{}".format(idx) + var_x = inputs + for i in range(1, 3): + rec_name = "{}_{}".format(name, i) + size = 1 if i == 1 else 3 + if i == 2: + var_x = ZeroPadding2D(1, name="{}.zeropad".format(rec_name))(var_x) + var_x = Conv2D(filters * i, + kernel_size=size, + strides=i, + activation="relu", + name=rec_name)(var_x) + return var_x + def prepare_batch(self, batch): + """ Prepare a batch for prediction. -class DivO2K(ElementwiseLayerO2K): - """ Custom Keras Division layer generated by onnx2keras. """ - def call(self, inputs, **kwargs): # pylint:disable=unused-argument - """This is where the layer's logic lives. + Normalizes the feed images. Parameters ---------- - inputs: Input tensor, or list/tuple of input tensors. - The input to the layer - **kwargs: Additional keyword arguments. - Required for parent class but unused + batch: class:`numpy.ndarray` + The batch to be fed to the model + Returns ------- - A tensor or list/tuple of tensors. - The layer output + class:`numpy.ndarray` + The normalized images for feeding to the model """ - return inputs[0] / inputs[1] - - -class S3fd(KSession): - """ Keras Network """ - def __init__(self, model_path, model_kwargs, allow_growth, exclude_gpus, confidence): - logger.debug("Initializing: %s: (model_path: '%s', model_kwargs: %s, allow_growth: %s, " - "exclude_gpus: %s, confidence: %s)", self.__class__.__name__, model_path, - model_kwargs, allow_growth, exclude_gpus, confidence) - super().__init__("S3FD", - model_path, - model_kwargs=model_kwargs, - allow_growth=allow_growth, - exclude_gpus=exclude_gpus) - self.load_model() - self.confidence = confidence - self.average_img = np.array([104.0, 117.0, 123.0]) - logger.debug("Initialized: %s", self.__class__.__name__) - - def prepare_batch(self, batch): - """ Prepare a batch for prediction """ batch = batch - self.average_img - batch = batch.transpose(0, 3, 1, 2) return batch def finalize_predictions(self, bounding_boxes_scales): - """ Detect faces """ + """ Process the output from the model to obtain faces + + Parameters + ---------- + bounding_boxes_scales: list + The output predictions from the S3FD model + """ ret = list() batch_size = range(bounding_boxes_scales[0].shape[0]) for img in batch_size: @@ -489,16 +401,16 @@ def _post_process(self, bboxlist): """ retval = list() for i in range(len(bboxlist) // 2): - bboxlist[i * 2] = self.softmax(bboxlist[i * 2], axis=1) + bboxlist[i * 2] = self.softmax(bboxlist[i * 2], axis=3) for i in range(len(bboxlist) // 2): ocls, oreg = bboxlist[i * 2], bboxlist[i * 2 + 1] stride = 2 ** (i + 2) # 4,8,16,32,64,128 - poss = zip(*np.where(ocls[:, 1, :, :] > 0.05)) + poss = zip(*np.where(ocls[:, :, :, 1] > 0.05)) for _, hindex, windex in poss: axc, ayc = stride / 2 + windex * stride, stride / 2 + hindex * stride - score = ocls[0, 1, hindex, windex] + score = ocls[0, hindex, windex, 1] if score >= self.confidence: - loc = np.ascontiguousarray(oreg[0, :, hindex, windex]).reshape((1, 4)) + loc = np.ascontiguousarray(oreg[0, hindex, windex, :]).reshape((1, 4)) priors = np.array([[axc / 1.0, ayc / 1.0, stride * 4 / 1.0, stride * 4 / 1.0]]) box = self.decode(loc, priors) x_1, y_1, x_2, y_2 = box[0] * 1.0 From 5bf56be57d06c7fee806ac688831a90844cc7284 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 19 Oct 2020 14:17:48 +0100 Subject: [PATCH 318/981] Catch OOM Errors on Extract --- plugins/extract/align/_base.py | 33 ++++++++++++++++++++++++++++++- plugins/extract/detect/_base.py | 35 +++++++++++++++++++++++++++++++-- plugins/extract/mask/_base.py | 33 ++++++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 4 deletions(-) diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index d089ab901e..77920f6055 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -17,6 +17,9 @@ import cv2 import numpy as np +from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module + +from lib.utils import get_backend, FaceswapError from plugins.extract._base import Extractor, logger, ExtractMedia @@ -215,7 +218,35 @@ def finalize(self, batch): # <<< PREDICT WRAPPER >>> # def _predict(self, batch): """ Just return the aligner's predict function """ - return self.predict(batch) + try: + return self.predict(batch) + except tf_errors.ResourceExhaustedError as err: + msg = ("You do not have enough GPU memory available to run detection at the " + "selected batch size. You can try a number of things:" + "\n1) Close any other application that is using your GPU (web browsers are " + "particularly bad for this)." + "\n2) Lower the batchsize (the amount of images fed into the model) by " + "editing the plugin settings (GUI: Settings > Configure extract settings, " + "CLI: Edit the file faceswap/config/extract.ini)." + "\n3) Enable 'Single Process' mode.") + raise FaceswapError(msg) from err + except Exception as err: + if get_backend() == "amd": + # pylint:disable=import-outside-toplevel + from lib.plaidml_utils import is_plaidml_error + if (is_plaidml_error(err) and ( + "CL_MEM_OBJECT_ALLOCATION_FAILURE" in str(err).upper() or + "enough memory for the current schedule" in str(err).lower())): + msg = ("You do not have enough GPU memory available to run detection at " + "the selected batch size. You can try a number of things:" + "\n1) Close any other application that is using your GPU (web " + "browsers are particularly bad for this)." + "\n2) Lower the batchsize (the amount of images fed into the " + "model) by editing the plugin settings (GUI: Settings > Configure " + "extract settings, CLI: Edit the file " + "faceswap/config/extract.ini).") + raise FaceswapError(msg) from err + raise # <<< FACE NORMALIZATION METHODS >>> # def _normalize_faces(self, faces): diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index 3fc281628d..e4fcb89c62 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -14,12 +14,15 @@ To get a :class:`~lib.faces_detect.DetectedFace` object use the function: >>> face = self.to_detected_face(, , , ) - """ import cv2 import numpy as np +from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module + from lib.faces_detect import DetectedFace +from lib.utils import get_backend, FaceswapError + from plugins.extract._base import Extractor, logger @@ -205,7 +208,35 @@ def _predict(self, batch): for angle in self.rotation: # Rotate the batch and insert placeholders for already found faces self._rotate_batch(batch, angle) - batch = self.predict(batch) + try: + batch = self.predict(batch) + except tf_errors.ResourceExhaustedError as err: + msg = ("You do not have enough GPU memory available to run detection at the " + "selected batch size. You can try a number of things:" + "\n1) Close any other application that is using your GPU (web browsers are " + "particularly bad for this)." + "\n2) Lower the batchsize (the amount of images fed into the model) by " + "editing the plugin settings (GUI: Settings > Configure extract settings, " + "CLI: Edit the file faceswap/config/extract.ini)." + "\n3) Enable 'Single Process' mode.") + raise FaceswapError(msg) from err + except Exception as err: + if get_backend() == "amd": + # pylint:disable=import-outside-toplevel + from lib.plaidml_utils import is_plaidml_error + if (is_plaidml_error(err) and ( + "CL_MEM_OBJECT_ALLOCATION_FAILURE" in str(err).upper() or + "enough memory for the current schedule" in str(err).lower())): + msg = ("You do not have enough GPU memory available to run detection at " + "the selected batch size. You can try a number of things:" + "\n1) Close any other application that is using your GPU (web " + "browsers are particularly bad for this)." + "\n2) Lower the batchsize (the amount of images fed into the " + "model) by editing the plugin settings (GUI: Settings > Configure " + "extract settings, CLI: Edit the file " + "faceswap/config/extract.ini).") + raise FaceswapError(msg) from err + raise if angle != 0 and any([face.any() for face in batch["prediction"]]): logger.verbose("found face(s) by rotating image %s degrees", angle) diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index f058b181a7..433b7af148 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -16,6 +16,9 @@ import cv2 import numpy as np +from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module + +from lib.utils import get_backend, FaceswapError from plugins.extract._base import Extractor, ExtractMedia, logger @@ -162,7 +165,35 @@ def _collect_item(self, queue): def _predict(self, batch): """ Just return the masker's predict function """ - return self.predict(batch) + try: + return self.predict(batch) + except tf_errors.ResourceExhaustedError as err: + msg = ("You do not have enough GPU memory available to run detection at the " + "selected batch size. You can try a number of things:" + "\n1) Close any other application that is using your GPU (web browsers are " + "particularly bad for this)." + "\n2) Lower the batchsize (the amount of images fed into the model) by " + "editing the plugin settings (GUI: Settings > Configure extract settings, " + "CLI: Edit the file faceswap/config/extract.ini)." + "\n3) Enable 'Single Process' mode.") + raise FaceswapError(msg) from err + except Exception as err: + if get_backend() == "amd": + # pylint:disable=import-outside-toplevel + from lib.plaidml_utils import is_plaidml_error + if (is_plaidml_error(err) and ( + "CL_MEM_OBJECT_ALLOCATION_FAILURE" in str(err).upper() or + "enough memory for the current schedule" in str(err).lower())): + msg = ("You do not have enough GPU memory available to run detection at " + "the selected batch size. You can try a number of things:" + "\n1) Close any other application that is using your GPU (web " + "browsers are particularly bad for this)." + "\n2) Lower the batchsize (the amount of images fed into the " + "model) by editing the plugin settings (GUI: Settings > Configure " + "extract settings, CLI: Edit the file " + "faceswap/config/extract.ini).") + raise FaceswapError(msg) from err + raise def finalize(self, batch): """ Finalize the output from Masker From 0532c22dbfd4696d06c7269f2ce76f35801f1503 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 19 Oct 2020 18:30:56 +0100 Subject: [PATCH 319/981] Bugfix: Extract - Fix Skip Saving Faces --- lib/cli/args.py | 2 +- scripts/extract.py | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index 6d7f40d3a6..511aebf5a6 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -562,7 +562,7 @@ def get_optional_arguments(): dest="skip_saving_faces", default=False, group="settings", - help="Skip saving out the face images")) + help="Skip saving the detected faces to disk. Just create an alignments file")) return argument_list diff --git a/scripts/extract.py b/scripts/extract.py index af66043486..1876159ac4 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -38,7 +38,8 @@ class Extract(): # pylint:disable=too-few-public-methods def __init__(self, arguments): logger.debug("Initializing %s: (args: %s", self.__class__.__name__, arguments) self._args = arguments - self._output_dir = str(get_folder(self._args.output_dir)) + self._output_dir = None if self._args.skip_saving_faces else str(get_folder( + self._args.output_dir)) logger.info("Output Directory: %s", self._args.output_dir) self._images = ImagesLoader(self._args.input_dir, fast_count=True) @@ -192,7 +193,8 @@ def _run_extraction(self): processing. """ size = self._args.size if hasattr(self._args, "size") else 256 - saver = ImagesSaver(self._output_dir, as_bytes=True) + saver = None if self._args.skip_saving_faces else ImagesSaver(self._output_dir, + as_bytes=True) exception = False for phase in range(self._extractor.passes): @@ -214,8 +216,7 @@ def _run_extraction(self): self._check_thread_error() if is_final: self._output_processing(extract_media, size) - if not self._args.skip_saving_faces: - self._output_faces(saver, extract_media) + self._output_faces(saver, extract_media) if self._save_interval and (idx + 1) % self._save_interval == 0: self._alignments.save() else: @@ -227,7 +228,8 @@ def _run_extraction(self): if not is_final: logger.debug("Reloading images") self._threaded_redirector("reload", detected_faces) - saver.close() + if not self._args.skip_saving_faces: + saver.close() def _check_thread_error(self): """ Check if any errors have occurred in the running threads and their errors """ @@ -282,7 +284,8 @@ def _output_faces(self, saver, extract_media): output_filename = "{}_{}{}".format(filename, str(idx), extension) face.hash, image = encode_image_with_hash(face.aligned_face, extension) - saver.save(output_filename, image) + if not self._args.skip_saving_faces: + saver.save(output_filename, image) final_faces.append(face.to_alignment()) self._alignments.data[os.path.basename(extract_media.filename)] = dict(faces=final_faces) del extract_media From a9d6d4efc8d2ce1b72f4ab8cd533c7a62291247a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 19 Oct 2020 19:00:34 +0100 Subject: [PATCH 320/981] Bugfix: Alignments Tool - Catch incorrect input type for leftover faces --- tools/alignments/jobs.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index b253e03e8d..0b4536eab2 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -74,8 +74,12 @@ def validate(self): "be nothing to move. Defaulting to output: console") self.output = "console" if self.type == "faces" and self.job not in ("multi-faces", "leftover-faces"): - logger.warning("The selected folder is not valid. Faces folder (-fc) is only " - "supported for 'multi-faces' and 'leftover-faces'") + logger.error("The selected folder is not valid. Faces folder (-fc) is only " + "supported for 'multi-faces' and 'leftover-faces'") + sys.exit(1) + if self.type == "frames" and self.job == "leftover-faces": + logger.error("You must provide a faces folder (-fc) NOT a frames folder (-fr) if " + "running the 'leftover-faces' job.") sys.exit(1) def compile_output(self): From 75db1bd41fa1cd44aad9bfb2ced35fab0fb38ecc Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 19 Oct 2020 22:51:51 +0000 Subject: [PATCH 321/981] GUI - Allow uppercase file extensions in Linux --- lib/gui/utils.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 748d586a77..b37bcfe07d 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -171,13 +171,19 @@ def _filetypes(self): ("WebM", "*.webm"), ("Windows Media Video", "*.wmv"), all_files]} - # Add in multi-select options - for key, val in filetypes.items(): - if len(val) < 3: - continue - multi = ["{} Files".format(key.title())] - multi.append(" ".join([ftype[1] for ftype in val if ftype[0] != "All files"])) - val.insert(0, tuple(multi)) + + # Add in multi-select options and upper case extensions for Linux + for key in filetypes: + if platform.system() == "Linux": + filetypes[key] = [item + if item[0] == "All files" + else (item[0], "{} {}".format(item[1], item[1].upper())) + for item in filetypes[key]] + if len(filetypes[key]) > 2: + multi = ["{} Files".format(key.title())] + multi.append(" ".join([ftype[1] + for ftype in filetypes[key] if ftype[0] != "All files"])) + filetypes[key].insert(0, tuple(multi)) return filetypes @property From 3dbbac5a988a4d99e3e04facdb69ed50754d092b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 20 Oct 2020 14:36:43 +0100 Subject: [PATCH 322/981] Convert - Move scaling option exclusively to config --- lib/cli/args.py | 12 ------------ lib/convert.py | 19 ++++++++++--------- plugins/convert/scaling/sharpen_defaults.py | 5 +++-- tools/preview/preview.py | 15 +++++---------- 4 files changed, 18 insertions(+), 33 deletions(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index 511aebf5a6..b0c12bd980 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -669,18 +669,6 @@ def get_optional_arguments(): "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=("-sc", "--scaling"), - action=Radio, - type=str.lower, - default="none", - choices=PluginLoader.get_available_convert_plugins("scaling", True), - group="plugins", - help="R|Performs a scaling process to attempt to get better definition on the " - "final swap. Some of these options have configurable settings in " - "'/config/convert.ini' or 'Settings > Configure Convert Plugins':" - "\nL|sharpen: Perform sharpening on the final face." - "\nL|none: Don't perform any scaling operations.")) argument_list.append(dict( opts=("-w", "--writer"), action=Radio, diff --git a/lib/convert.py b/lib/convert.py index 272d7d9220..7a9cc47e1f 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -49,7 +49,7 @@ def __init__(self, output_size, coverage_ratio, draw_transparent, pre_encode, self._configfile = configfile self._scale = arguments.output_scale / 100 - self._adjustments = dict(box=None, mask=None, color=None, seamless=None, scaling=None) + self._adjustments = dict(box=None, mask=None, color=None, seamless=None, sharpening=None) self._load_plugins() logger.debug("Initialized %s", self.__class__.__name__) @@ -72,7 +72,7 @@ def reinitialize(self, config): Pre-loaded :class:`lib.config.FaceswapConfig`. used over any configuration on disk. """ logger.debug("Reinitializing converter") - self._adjustments = dict(box=None, mask=None, color=None, seamless=None, scaling=None) + self._adjustments = dict(box=None, mask=None, color=None, seamless=None, sharpening=None) self._load_plugins(config=config, disable_logging=True) logger.debug("Reinitialized converter") @@ -114,11 +114,12 @@ def _load_plugins(self, config=None, disable_logging=False): self._args.color_adjustment, disable_logging=disable_logging)(configfile=self._configfile, config=config) - if self._args.scaling != "none" and self._args.scaling is not None: - self._adjustments["scaling"] = PluginLoader.get_converter( - "scaling", - self._args.scaling, - disable_logging=disable_logging)(configfile=self._configfile, config=config) + sharpening = PluginLoader.get_converter( + "scaling", + "sharpen", + disable_logging=disable_logging)(configfile=self._configfile, config=config) + if sharpening.config.get("method", None) is not None: + self._adjustments["sharpening"] = sharpening logger.debug("Loaded plugins: %s", self._adjustments) def process(self, in_queue, out_queue): @@ -328,8 +329,8 @@ def _post_warp_adjustments(self, background, new_image): :class:`numpy.ndarray` The final merged and swapped frame with any requested post-warp adjustments applied """ - if self._adjustments["scaling"] is not None: - new_image = self._adjustments["scaling"].run(new_image) + if self._adjustments["sharpening"] is not None: + new_image = self._adjustments["sharpening"].run(new_image) if self._draw_transparent: frame = new_image diff --git a/plugins/convert/scaling/sharpen_defaults.py b/plugins/convert/scaling/sharpen_defaults.py index 991c0a6f9f..802adabe32 100755 --- a/plugins/convert/scaling/sharpen_defaults.py +++ b/plugins/convert/scaling/sharpen_defaults.py @@ -46,8 +46,9 @@ _DEFAULTS = { "method": { - "default": "unsharp_mask", + "default": "none", "info": "The type of sharpening to use:" + "\n\t none: Don't perform any sharpening." "\n\t box: Fastest, but weakest method. Uses a box filter to assess edges." "\n\t gaussian: Slower, but better than box. Uses a gaussian filter to assess " "edges." @@ -56,7 +57,7 @@ "datatype": str, "rounding": None, "min_max": None, - "choices": ["box", "gaussian", "unsharp_mask"], + "choices": ["none", "box", "gaussian", "unsharp_mask"], "gui_radio": True, "fixed": True, }, diff --git a/tools/preview/preview.py b/tools/preview/preview.py index 6579ded558..5c0c8f82be 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -141,7 +141,6 @@ def _build_ui(self): self._samples.predictor.has_predicted_mask, self._patch.converter.cli_arguments.color_adjustment.replace("-", "_"), self._patch.converter.cli_arguments.mask_type.replace("-", "_"), - self._patch.converter.cli_arguments.scaling.replace("-", "_"), self._config_tools, self._refresh, self._samples.generate, @@ -1010,8 +1009,6 @@ class ActionFrame(ttk.Frame): # pylint: disable=too-many-ancestors The selected color adjustment type selected_mask_type: str The selected mask type - selected_scaling: str - The selected scaling type config_tools: :class:`ConfigTools` Tools for loading and saving configuration files patch_callback: python function @@ -1022,19 +1019,17 @@ class ActionFrame(ttk.Frame): # pylint: disable=too-many-ancestors Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` """ def __init__(self, parent, available_masks, has_predicted_mask, selected_color, - selected_mask_type, selected_scaling, config_tools, patch_callback, - refresh_callback, tk_vars): + selected_mask_type, config_tools, patch_callback, refresh_callback, tk_vars): logger.debug("Initializing %s: (available_masks: %s, has_predicted_mask: %s, " - "selected_color: %s, selected_mask_type: %s, selected_scaling: %s, " - "patch_callback: %s, refresh_callback: %s, tk_vars: %s)", + "selected_color: %s, selected_mask_type: %s, patch_callback: %s, " + "refresh_callback: %s, tk_vars: %s)", self.__class__.__name__, available_masks, has_predicted_mask, selected_color, - selected_mask_type, selected_scaling, patch_callback, refresh_callback, - tk_vars) + selected_mask_type, patch_callback, refresh_callback, tk_vars) self._config_tools = config_tools super().__init__(parent) self.pack(side=tk.LEFT, anchor=tk.N, fill=tk.Y) - self._options = ["color", "mask_type", "scaling"] + self._options = ["color", "mask_type"] self._busy_tkvar = tk_vars["busy"] self._tk_vars = dict() From 15632cabbbe2ad0c67bf46ecf32f040ef3635636 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 23 Oct 2020 10:50:09 +0100 Subject: [PATCH 323/981] Typo Fix --- plugins/extract/align/fan_defaults.py | 31 +++++++------- plugins/extract/detect/s3fd_defaults.py | 57 +++++++++++++------------ 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/plugins/extract/align/fan_defaults.py b/plugins/extract/align/fan_defaults.py index a790c6ed23..8749b0b9bb 100644 --- a/plugins/extract/align/fan_defaults.py +++ b/plugins/extract/align/fan_defaults.py @@ -44,23 +44,24 @@ _HELPTEXT = ( - "FAN Aligner options.Fast on GPU, slow on CPU. Best aligner." + "FAN Aligner options.\n" + "Fast on GPU, slow on CPU. Best aligner." ) _DEFAULTS = { - "batch-size": { - "default": 12, - "info": "The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered." - "\n\tAMD users: A batchsize of 8 requires about 4 GB vram.", - "datatype": int, - "rounding": 1, - "min_max": (1, 64), - "choices": [], - "gui_radio": False, - "fixed": True, - } + "batch-size": dict( + default=12, + info="The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered." + "\n\tAMD users: A batchsize of 8 requires about 4 GB vram.", + datatype=int, + rounding=1, + min_max=(1, 64), + choices=[], + gui_radio=False, + fixed=True, + ) } diff --git a/plugins/extract/detect/s3fd_defaults.py b/plugins/extract/detect/s3fd_defaults.py index 1f7100f462..d8b4f85329 100755 --- a/plugins/extract/detect/s3fd_defaults.py +++ b/plugins/extract/detect/s3fd_defaults.py @@ -44,36 +44,37 @@ _HELPTEXT = ( - "S3FD Detector options.Fast on GPU, slow on CPU. Can detect more faces and fewer false " - "positives than other GPU detectors, but is a lot more resource intensive." + "S3FD Detector options.\n" + "Fast on GPU, slow on CPU. Can detect more faces and fewer false positives than other GPU " + "detectors, but is a lot more resource intensive." ) _DEFAULTS = { - "confidence": { - "default": 70, - "info": "The confidence level at which the detector has succesfully found a face.\n" - "Higher levels will be more discriminating, lower levels will have more false " - "positives.", - "datatype": int, - "rounding": 5, - "min_max": (25, 100), - "choices": [], - "gui_radio": False, - "fixed": True, - }, - "batch-size": { - "default": 4, - "info": "The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered." - "\n\tAMD users: A batchsize of 8 requires about 2 GB vram.", - "datatype": int, - "rounding": 1, - "min_max": (1, 64), - "choices": [], - "gui_radio": False, - "fixed": True, - } + "confidence": dict( + default=70, + info="The confidence level at which the detector has succesfully found a face.\n" + "Higher levels will be more discriminating, lower levels will have more false " + "positives.", + datatype=int, + rounding=5, + min_max=(25, 100), + choices=[], + gui_radio=False, + fixed=True, + ), + "batch-size": dict( + default=4, + info="The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered." + "\n\tAMD users: A batchsize of 8 requires about 2 GB vram.", + datatype=int, + rounding=1, + min_max=(1, 64), + choices=[], + gui_radio=False, + fixed=True, + ) } From b7ecad058d6c193c60f2c70ef7f9a079adb40a22 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 24 Oct 2020 00:06:52 +0100 Subject: [PATCH 324/981] GUI: Consolidate settings to single menu --- lib/gui/control_helper.py | 16 +- lib/gui/menu.py | 60 +--- lib/gui/popup_configure.py | 719 ++++++++++++++++++++++++++++--------- plugins/train/_config.py | 2 +- scripts/gui.py | 11 +- 5 files changed, 568 insertions(+), 240 deletions(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 478299f9f8..9a63a545ed 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -143,7 +143,7 @@ def __init__(self, title, dtype, # pylint:disable=too-many-arguments min_max=min_max, helptext=helptext) self.control = self.get_control() - self.tk_var = self.get_tk_var(track_modified) + self.tk_var = self.get_tk_var(initial_value, track_modified) logger.debug("Initialized %s", self.__class__.__name__) @property @@ -276,7 +276,7 @@ def get_control(self): logger.debug("Setting control '%s' to %s", self.title, control) return control - def get_tk_var(self, track_modified): + def get_tk_var(self, initial_value, track_modified): """ Correct variable type for control """ if self.dtype == bool: var = tk.BooleanVar() @@ -286,8 +286,10 @@ def get_tk_var(self, track_modified): var = tk.DoubleVar() else: var = tk.StringVar() - logger.debug("Setting tk variable: (name: '%s', dtype: %s, tk_var: %s)", - self.name, self.dtype, var) + if initial_value is not None: + var.set(initial_value) + logger.debug("Setting tk variable: (name: '%s', dtype: %s, tk_var: %s, initial_value: %s)", + self.name, self.dtype, var, initial_value) if track_modified and self._command is not None: logger.debug("Tracking variable modification: %s", self.name) var.trace("w", @@ -417,10 +419,10 @@ def add_info(self, frame): """ Plugin information """ gui_style = ttk.Style() gui_style.configure('White.TFrame', background='#FFFFFF') - gui_style.configure('Header.TLabel', + gui_style.configure('InfoHeader.TLabel', background='#FFFFFF', font=get_config().default_font + ("bold", )) - gui_style.configure('Body.TLabel', + gui_style.configure('InfoBody.TLabel', background='#FFFFFF') info_frame = ttk.Frame(frame, style='White.TFrame', relief=tk.SOLID) @@ -430,7 +432,7 @@ def add_info(self, frame): for idx, line in enumerate(self.header_text.splitlines()): if not line: continue - style = "Header.TLabel" if idx == 0 else "Body.TLabel" + style = "InfoHeader.TLabel" if idx == 0 else "InfoBody.TLabel" info = ttk.Label(label_frame, text=line, style=style, anchor=tk.W) info.bind("", self._adjust_wraplength) info.pack(fill=tk.X, padx=0, pady=0, expand=True, side=tk.TOP) diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 218bb41d08..0dd3052ad8 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -9,14 +9,13 @@ from tkinter import ttk import webbrowser -from importlib import import_module from subprocess import Popen, PIPE, STDOUT from lib.multithreading import MultiThread from lib.serializer import get_serializer import update_deps -from .popup_configure import popup_config +from .popup_configure import open_popup from .custom_widgets import Tooltip from .utils import get_config, get_images @@ -25,8 +24,6 @@ ("Discord - The FaceSwap Discord server", "https://discord.gg/VasFUAy"), ("Github - Our Source Code", "https://github.com/deepfakes/faceswap")] -_CONFIG_FILES = [] -_CONFIGS = dict() _WORKING_DIR = os.path.dirname(os.path.realpath(sys.argv[0])) @@ -56,58 +53,16 @@ def __init__(self, parent): logger.debug("Initializing %s", self.__class__.__name__) super().__init__(parent, tearoff=0) self.root = parent.root - self.configs = self.scan_for_plugin_configs() self.build() logger.debug("Initialized %s", self.__class__.__name__) - def scan_for_plugin_configs(self): - """ Scan for config.ini file locations """ - global _CONFIGS, _CONFIG_FILES # pylint:disable=global-statement - root_path = os.path.abspath(os.path.dirname(sys.argv[0])) - plugins_path = os.path.join(root_path, "plugins") - logger.debug("Scanning path: '%s'", plugins_path) - configs = dict() - for dirpath, _, filenames in os.walk(plugins_path): - if "_config.py" in filenames: - plugin_type = os.path.split(dirpath)[-1] - config = self.load_config(plugin_type) - configs[plugin_type] = config - logger.debug("Configs loaded: %s", sorted(list(configs.keys()))) - keys = list(configs.keys()) - for key in ("extract", "train", "convert"): - if key in keys: - _CONFIG_FILES.append(keys.pop(keys.index(key))) - _CONFIG_FILES.extend([key for key in sorted(keys)]) - _CONFIGS = configs - return configs - - @staticmethod - def load_config(plugin_type): - """ Load the config to generate config file if it doesn't exist and get filename """ - # Load config to generate default if doesn't exist - mod = ".".join(("plugins", plugin_type, "_config")) - module = import_module(mod) - config = module.Config(None) - logger.debug("Found '%s' config at '%s'", plugin_type, config.configfile) - return config - def build(self): """ Add the settings menu to the menu bar """ # pylint: disable=cell-var-from-loop logger.debug("Building settings menu") - for name in _CONFIG_FILES: - label = "Configure {} Plugins...".format(name.title()) - config = self.configs[name] - self.add_command( - label=label, - underline=10, - command=lambda n=name, c=config: popup_config(n, c)) - self.add_separator() - conf = get_config().user_config - self.add_command( - label="GUI Settings...", - underline=10, - command=lambda n="GUI", c=conf: popup_config(n, c)) + self.add_command(label="Configure Settings...", + underline=0, + command=open_popup) logger.debug("Built settings menu") @@ -386,7 +341,7 @@ def output_sysinfo(self): self.root.config(cursor="watch") self.clear_console() try: - from lib.sysinfo import sysinfo + from lib.sysinfo import sysinfo # pylint:disable=import-outside-toplevel info = sysinfo except Exception as err: # pylint:disable=broad-except info = "Error obtaining system info: {}".format(str(err)) @@ -546,15 +501,14 @@ def _settings_btns(self): # pylint: disable=cell-var-from-loop frame = ttk.Frame(self._btn_frame) frame.pack(side=tk.LEFT, anchor=tk.W, expand=False, padx=2) - for name in _CONFIG_FILES: - config = _CONFIGS[name] + for name in ("extract", "train", "convert"): btntype = "settings_{}".format(name) btntype = btntype if btntype in get_images().icons else "settings" logger.debug("Adding button: '%s'", btntype) btn = ttk.Button( frame, image=get_images().icons[btntype], - command=lambda n=name, c=config: popup_config(n, c)) + command=lambda n=name: open_popup(name=n)) btn.pack(side=tk.LEFT, anchor=tk.W) hlp = "Configure {} settings...".format(name.title()) Tooltip(btn, text=hlp, wraplength=200) diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 7ed3bd148c..c5bc21485c 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -4,38 +4,96 @@ from collections import OrderedDict from configparser import ConfigParser import logging +import os +import sys import tkinter as tk - from tkinter import ttk +from importlib import import_module from .control_helper import ControlPanel, ControlPanelOption from .custom_widgets import Tooltip from .utils import get_config, get_images logger = logging.getLogger(__name__) # pylint: disable=invalid-name -POPUP = dict() +_POPUP = [] +_CONFIG_FILES = [] +_CONFIGS = dict() -def popup_config(name, configuration): - """ Open the settings for the requested configuration file and close any already active - pop-ups. +class _State(): + """ Holds the existing config files and the current state of the popup window. """ + def __init__(self): + self._popup = None + # The GUI Config cannot be scanned until GUI is launched, so this is populated + # on the first call to load the settings + self._configs = dict() - Parameters - ---------- - name: str - The name of the configuration file. Used for the pop-up title bar. - configuration: :class:`~lib.config.FaceswapConfig` - The configuration options for the requested pop-up window - """ - logger.debug("name: %s, configuration: %s", name, configuration) - if POPUP: - p_key = list(POPUP.keys())[0] - logger.debug("Closing open popup: '%s'", p_key) - POPUP[p_key].destroy() - del POPUP[p_key] - window = _ConfigurePlugins(name, configuration) - POPUP[name] = window - logger.debug("Current pop-up: %s", POPUP) + def open_popup(self, name=None): + """ Launch the popup, ensuring only one instance is ever open + + Parameters + ---------- + name: str, Optional + The name of the configuration file. Used for selecting the correct section if required. + Set to ``None`` if no initial section should be selected. Default: ``None`` + """ + if not self._configs: + self._scan_for_configs() + logger.debug("name: %s", name) + if self._popup is not None: + logger.info("Popup already open. Returning: %s", _POPUP) + return + self._popup = _ConfigurePlugins(name, self._configs) + + def close_popup(self): + """ Destroy the open popup and remove it from tracking. """ + if self._popup is None: + logger.info("No popup to close. Returning") + return + self._popup.destroy() + del self._popup + self._popup = None + + def _scan_for_configs(self): + """ Scan the plugin folders for configuration settings. Add in the GUI configuration also. + + Populates the attribute :attr:`_configs`. + """ + root_path = os.path.abspath(os.path.dirname(sys.argv[0])) + plugins_path = os.path.join(root_path, "plugins") + logger.debug("Scanning path: '%s'", plugins_path) + for dirpath, _, filenames in os.walk(plugins_path): + if "_config.py" in filenames: + plugin_type = os.path.split(dirpath)[-1] + config = self._load_config(plugin_type) + self._configs[plugin_type] = config + self._configs["gui"] = get_config().user_config + logger.debug("Configs loaded: %s", sorted(list(self._configs.keys()))) + + @classmethod + def _load_config(cls, plugin_type): + """ Load the config from disk. If the file doesn't exist, then it will be generated. + + Parameters + ---------- + plugin_type: str + The plugin type (i.e. extract, train convert) that the config should be loaded for + + Returns + ------- + :class:`lib.config.FaceswapConfig` + The Configuration for the selected plugin + """ + # Load config to generate default if doesn't exist + mod = ".".join(("plugins", plugin_type, "_config")) + module = import_module(mod) + config = module.Config(None) + logger.debug("Found '%s' config at '%s'", plugin_type, config.configfile) + return config + + +_STATE = _State() +open_popup = _STATE.open_popup class _ConfigurePlugins(tk.Toplevel): @@ -44,30 +102,43 @@ class _ConfigurePlugins(tk.Toplevel): Parameters ---------- name: str - The name of the configuration file. Used for the pop-up title bar. - configuration: :class:`~lib.config.FaceswapConfig` - The configuration options for the requested pop-up window + The name of the section that is being navigated to. Used for opening on the correct + page in the Tree View. + configurations: dict + Dictionary containing the :class:`~lib.config.FaceswapConfig` object for each + configuration section for the requested pop-up window """ - def __init__(self, name, configuration): - logger.debug("Initializing %s: (name: %s, configuration: %s)", - self.__class__.__name__, name, configuration) + def __init__(self, name, configurations): + logger.debug("Initializing %s: (name: %s, configurations: %s)", + self.__class__.__name__, name, configurations) super().__init__() - self._name = name - self._config = configuration self._root = get_config().root - self._set_geometry() + self._tk_vars = dict(header=tk.StringVar()) - self._page_frame = ttk.Frame(self) - self._plugin_info = dict() + header_frame = self._build_header() + content_frame = ttk.Frame(self) - self._config_cpanel_dict = self._get_config() - self._build() - self.update() + self._tree = _Tree(content_frame, configurations, name).tree + self._tree.bind("", self._select_item) + + self._opts_frame = DisplayArea(content_frame, configurations, self._tree) + self._opts_frame.pack(fill=tk.BOTH, expand=True, side=tk.RIGHT) + footer_frame = self._build_footer() + + header_frame.pack(fill=tk.X, padx=5, pady=5, side=tk.TOP) + content_frame.pack(fill=tk.BOTH, padx=5, pady=(0, 5), expand=True, side=tk.TOP) + footer_frame.pack(fill=tk.X, padx=5, pady=(0, 5), side=tk.BOTTOM) - self._page_frame.pack(fill=tk.BOTH, expand=True) - self.title("{} Plugins".format(self._name.title())) + select = name if name else self._tree.get_children()[0] + self._tree.selection_set(select) + self._tree.focus(select) + self._select_item(0) + + self.title("Congigure Settings") self.tk.call('wm', 'iconphoto', self._w, get_images().icons["favicon"]) + self.protocol("WM_DELETE_WINDOW", _STATE.close_popup) + logger.debug("Initialized %s", self.__class__.__name__) def _set_geometry(self): @@ -76,10 +147,238 @@ def _set_geometry(self): pos_x = self._root.winfo_x() + 80 pos_y = self._root.winfo_y() + 80 width = int(600 * scaling_factor) - height = int(400 * scaling_factor) + height = int(536 * scaling_factor) logger.debug("Pop up Geometry: %sx%s, %s+%s", width, height, pos_x, pos_y) self.geometry("{}x{}+{}+{}".format(width, height, pos_x, pos_y)) + def _build_header(self): + """ Build the main header text and separator. """ + header_frame = ttk.Frame(self) + lbl_frame = ttk.Frame(header_frame) + + self._tk_vars["header"].set("Settings") + lbl_header = ttk.Label(lbl_frame, + textvariable=self._tk_vars["header"], + anchor=tk.W, + style="H1.TLabel") + lbl_header.pack(fill=tk.X, expand=True, side=tk.LEFT) + + sep = ttk.Frame(header_frame, height=2, relief=tk.RIDGE) + + lbl_frame.pack(fill=tk.X, expand=True, side=tk.TOP) + sep.pack(fill=tk.X, pady=(1, 0), side=tk.BOTTOM) + return header_frame + + def _build_footer(self): + """ Build the main footer buttons and separator. """ + logger.debug("Adding action buttons") + frame = ttk.Frame(self) + left_frame = ttk.Frame(frame) + right_frame = ttk.Frame(frame) + + btn_saveall = ttk.Button(left_frame, + text="Save All", + width=10, + command=self._opts_frame.save) + btn_rstall = ttk.Button(left_frame, + text="Reset All", + width=10, + command=self._opts_frame.reset) + + btn_cls = ttk.Button(right_frame, text="Cancel", width=10, command=_STATE.close_popup) + btn_save = ttk.Button(right_frame, + text="Save", + width=10, + command=lambda: self._opts_frame.save(page_only=True)) + btn_rst = ttk.Button(right_frame, + text="Reset", + width=10, + command=lambda: self._opts_frame.reset(page_only=True)) + + Tooltip(btn_cls, text="Close without saving", wraplength=720) + Tooltip(btn_save, text="Save this page's config", wraplength=720) + Tooltip(btn_rst, text="Reset this page's config to default values", wraplength=720) + Tooltip(btn_saveall, + text="Save all settings for the currently selected config", + wraplength=720) + Tooltip(btn_rstall, + text="Reset all settings for the currently selected config to default values", + wraplength=720) + + btn_cls.pack(padx=2, side=tk.RIGHT) + btn_save.pack(padx=2, side=tk.RIGHT) + btn_rst.pack(padx=2, side=tk.RIGHT) + btn_saveall.pack(padx=2, side=tk.RIGHT) + btn_rstall.pack(padx=2, side=tk.RIGHT) + + left_frame.pack(side=tk.LEFT) + right_frame.pack(side=tk.RIGHT) + logger.debug("Added action buttons") + return frame + + def _select_item(self, event): # pylint:disable=unused-argument + """ Update the session summary info with the selected item or launch graph. + + If the mouse is clicked on the graph icon, then the session summary pop-up graph is + launched. Otherwise the selected ID is stored. + + Parameters + ---------- + event: :class:`tkinter.Event` + The tkinter mouse button release event. Unused. + """ + selection = self._tree.focus() + section = selection.split("|")[0] + subsections = selection.split("|")[1:] if "|" in selection else [] + self._tk_vars["header"].set("{} Settings".format(section.title())) + self._opts_frame.select_options(section, subsections) + + +class _Tree(ttk.Frame): # pylint:disable=too-many-ancestors + """ Frame that holds the Tree View Navigator and scroll bar for the configuration pop-up. + + Parameters + ---------- + parent: :class:`tkinter.ttk.Frame` + The parent frame to the Tree View area + configurations: dict + Dictionary containing the :class:`~lib.config.FaceswapConfig` object for each + configuration section for the requested pop-up window + name: str + The name of the section that is being navigated to. Used for opening on the correct + page in the Tree View. ``None`` if no specific area is being navigated to + """ + def __init__(self, parent, configurations, name): + super().__init__(parent) + self._fix_styles() + + frame = ttk.Frame(self, relief=tk.SOLID, borderwidth=1) + self._tree = self._build_tree(frame, configurations, name) + scrollbar = ttk.Scrollbar(frame, orient="vertical", command=self._tree.yview) + + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + self._tree.pack(fill=tk.Y, expand=True) + self._tree.configure(yscrollcommand=scrollbar.set) + frame.pack(expand=True, fill=tk.Y) + self.pack(side=tk.LEFT, fill=tk.Y) + + @property + def tree(self): + """ :class:`tkinter.ttk.TreeView` The Tree View held within the frame """ + return self._tree + + @classmethod + def _fix_styles(cls): + """ Tkinter has a bug when setting the background style on certain OSes. This fixes the + issue so we can set different colored backgrounds. + + We also set some default styles for our tree view. + """ + style = ttk.Style() + fix_map = lambda o: [elm for elm in style.map("Treeview", query_opt=o) # noqa + if elm[:2] != ("!disabled", "!selected")] + style.map("Treeview", foreground=fix_map("foreground"), background=fix_map("background")) + # Remove the Borders + style.configure("ConfigNav.Treeview", bd=0) + style.layout("ConfigNav.Treeview", [('ConfigNav.Treeview.treearea', {'sticky': 'nswe'})]) + + def _build_tree(self, parent, configurations, name): + """ Build the configuration pop-up window. + + Parameters + ---------- + configurations: dict + Dictionary containing the :class:`~lib.config.FaceswapConfig` object for each + configuration section for the requested pop-up window + name: str + The name of the section that is being navigated to. Used for opening on the correct + page in the Tree View. ``None`` if no specific area is being navigated to + + Returns + ------- + :class:`tkinter.ttk.TreeView` + The populated tree view + """ + logger.debug("Building Tree View Navigator") + tree = ttk.Treeview(parent, show="tree", style="ConfigNav.Treeview") + data = {category: [sect.split(".") for sect in sorted(conf.config.sections())] + for category, conf in configurations.items()} + ordered = sorted(list(data.keys())) + categories = ["extract", "train", "convert"] + categories += [x for x in ordered if x not in categories] + + for cat in categories: + img = get_images().icons.get("settings_{}".format(cat), "") + text = cat.replace("_", " ").title() + text = " " + text if img else text + is_open = tk.TRUE if name is None or name == cat else tk.FALSE + tree.insert("", "end", cat, text=text, image=img, open=is_open, tags="category") + self._process_sections(tree, data[cat], cat, name == cat) + + tree.tag_configure('category', background='#DFDFDF') + tree.tag_configure('section', background='#E8E8E8') + tree.tag_configure('option', background='#F0F0F0') + logger.debug("Tree View Navigator") + return tree + + @classmethod + def _process_sections(cls, tree, sections, category, is_open): + """ Process the sections of a category's configuration. + + Creates a category's sections, then the sub options for that category + + Parameters + ---------- + tree: :class:`tkinter.ttk.TreeView` + The tree view to insert sections into + sections: list + The sections to insert into the Tree View + category: str + The category node that these sections sit in + is_open: bool + ``True`` if the node should be created in "open" mode. ``False`` if it should be + closed. + """ + seen = set() + for section in sections: + if section[-1] == "global": # Global categories get escalated to parent + continue + sect = section[0] + section_id = "{}|{}".format(category, sect) + if sect not in seen: + seen.add(sect) + text = sect.replace("_", " ").title() + tree.insert(category, "end", section_id, text=text, open=is_open, tags="section") + if len(section) == 2: + opt = section[-1] + opt_id = "{}|{}".format(section_id, opt) + opt_text = opt.replace("_", " ").title() + tree.insert(section_id, "end", opt_id, text=opt_text, open=is_open, tags="option") + + +class DisplayArea(ttk.Frame): # pylint:disable=too-many-ancestors + """ The option configuration area of the pop up options. + + Parameters + ---------- + parent: :class:`tkinter.ttk.Frame` + The parent frame that holds the Display Area of the pop up configuration window + tree: :class:`tkinter.ttk.TreeView` + The Tree View navigator for the pop up configuration window + configurations: dict + Dictionary containing the :class:`~lib.config.FaceswapConfig` object for each + configuration section for the requested pop-up window + """ + def __init__(self, parent, configurations, tree): + super().__init__(parent) + self._configs = configurations + self._tree = tree + self._vars = dict() + self._cache = dict() + self._config_cpanel_dict = self._get_config() + self._build_header() + self._displayed_frame = None + def _get_config(self): """ Format the configuration options stored in :attr:`_config` into a dict of :class:`~lib.gui.control_helper.ControlPanelOption's for placement into option frames. @@ -91,161 +390,227 @@ def _get_config(self): objects """ logger.debug("Formatting Config for GUI") - conf = dict() - for section in self._config.config.sections(): - self._config.section = section - category = section.split(".")[0] - options = self._config.defaults[section] - section = section.split(".")[-1] - conf.setdefault(category, dict())[section] = OrderedDict() - for key, val in options.items(): - if key == "helptext": - self._plugin_info[section] = val - continue - initial_value = self._config.config_dict[key] - initial_value = "none" if initial_value is None else initial_value - conf[category][section][key] = ControlPanelOption( - title=key, - dtype=val["type"], - group=val["group"], - default=val["default"], - initial_value=initial_value, - choices=val["choices"], - is_radio=val["gui_radio"], - rounding=val["rounding"], - min_max=val["min_max"], - helptext=val["helptext"]) - logger.debug("Formatted Config for GUI: %s", conf) - return conf - - def _build(self): - """ Build the configuration pop-up window""" - logger.debug("Building plugin config popup") - container = ttk.Notebook(self._page_frame) - categories = sorted(list(self._config_cpanel_dict.keys())) - if "global" in categories: # Move global to first item - categories.insert(0, categories.pop(categories.index("global"))) - for category in categories: - page = self._build_page(container, category) - container.add(page, text=category.title()) - - self._add_frame_separator() - self._add_actions() - - container.pack(fill=tk.BOTH, expand=True) - logger.debug("Built plugin config popup") - - def _build_page(self, container, category): - """ Build a single tab within the plugin's configuration pop-up. + retval = dict() + for plugin, conf in self._configs.items(): + for section in conf.config.sections(): + conf.section = section + category = section.split(".")[0] + sect = section.split(".")[-1] + # Elevate global to root + key = plugin if sect == "global" else "{}|{}|{}".format(plugin, category, sect) + retval[key] = dict(helptext=None, options=OrderedDict()) + + for option, params in conf.defaults[section].items(): + if option == "helptext": + retval[key]["helptext"] = params + continue + initial_value = conf.config_dict[option] + initial_value = "none" if initial_value is None else initial_value + retval[key]["options"][option] = ControlPanelOption( + title=option, + dtype=params["type"], + group=params["group"], + default=params["default"], + initial_value=initial_value, + choices=params["choices"], + is_radio=params["gui_radio"], + rounding=params["rounding"], + min_max=params["min_max"], + helptext=params["helptext"]) + logger.debug("Formatted Config for GUI: %s", retval) + return retval + + def _build_header(self): + """ Build the dynamic header text. """ + header_frame = ttk.Frame(self) + var = tk.StringVar() + lbl = ttk.Label(header_frame, textvariable=var, anchor=tk.W, style="H2.TLabel") + lbl.pack(fill=tk.X, expand=True, side=tk.TOP) + header_frame.pack(fill=tk.X, padx=5, pady=(5, 0), side=tk.TOP) + self._vars["header"] = var + + def select_options(self, section, subsections): + """ Display the page for the given section and subsections. Parameters ---------- - container: :class:`ttk.Notebook` - The notebook to place the category options into - category: str - The name of the categories to build options for + section: str + The main section to be navigated to (or root node) + subsections: list + The full list of subsections ending on the required node + """ + labels = ["global"] if not subsections else subsections + self._vars["header"].set(" - ".join(sect.replace("_", " ").title() for sect in labels)) + self._set_display(section, subsections) - Returns - ------- - :class:'~lib.gui.control_helper.ControlPanel` or :class:`ttk.Notebook` - The control panel options in a Control Panel frame (for single plugin configurations) - or a Notebook containing tabs with Control Panel frames (for multi-plugin - configurations) + def _set_display(self, section, subsections): + """ Set the correct display page for the given section and subsections. + + Parameters + ---------- + section: str + The main section to be navigated to (or root node) + subsections: list + The full list of subsections ending on the required node """ - logger.debug("Building plugin config page: '%s'", category) - plugins = sorted(list(key for key in self._config_cpanel_dict[category].keys())) - panel_kwargs = dict(columns=2, max_columns=2, option_columns=2, blank_nones=False) - if any(plugin != category for plugin in plugins): - page = ttk.Notebook(container) - for plugin in plugins: - cp_options = list(self._config_cpanel_dict[category][plugin].values()) - frame = ControlPanel(page, - cp_options, - header_text=self._plugin_info[plugin], - **panel_kwargs) - title = plugin[plugin.rfind(".") + 1:] - title = title.replace("_", " ").title() - page.add(frame, text=title) - page.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + key = "|".join([section] + subsections) + if self._displayed_frame is not None: + self._displayed_frame.pack_forget() + + if key not in self._cache: + self._cache_page(key) + + self._displayed_frame = self._cache[key] + self._displayed_frame.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True) + + def _cache_page(self, key): + """ Create the control panel options for the requested configuration and cache. + + Parameters + ---------- + key: str + The lookup key to the settings cache + """ + panel_kwargs = dict(columns=1, max_columns=1, option_columns=4, blank_nones=False) + info = self._config_cpanel_dict.get(key, None) + if info is None: + logger.debug("key '%s' does not exist in options. Creating links page.", key) + self._cache[key] = self._create_links_page(key) else: - cp_options = list(self._config_cpanel_dict[category][plugins[0]].values()) - page = ControlPanel(container, - cp_options, - header_text=self._plugin_info[plugins[0]], - **panel_kwargs) - - logger.debug("Built plugin config page: '%s'", category) - - return page - - def _add_frame_separator(self): - """ Add a separator between the configuration options and the action buttons. """ - logger.debug("Add frame seperator") - sep = ttk.Frame(self._page_frame, height=2, relief=tk.RIDGE) - sep.pack(fill=tk.X, pady=(5, 0), side=tk.BOTTOM) - logger.debug("Added frame seperator") - - def _add_actions(self): - """ Add Action buttons to the bottom of the pop-up window. """ - logger.debug("Add action buttons") - frame = ttk.Frame(self._page_frame) - btn_cls = ttk.Button(frame, text="Cancel", width=10, command=self.destroy) - btn_ok = ttk.Button(frame, text="OK", width=10, command=self._save) - btn_rst = ttk.Button(frame, text="Reset", width=10, command=self._reset) + self._cache[key] = ControlPanel(self, + list(info["options"].values()), + header_text=info["helptext"], + **panel_kwargs) - Tooltip(btn_cls, text="Close without saving", wraplength=720) - Tooltip(btn_ok, text="Close and save config", wraplength=720) - Tooltip(btn_rst, text="Reset all plugins to default values", wraplength=720) + def _create_links_page(self, key): + """ For headings which don't have settings, build a links page to the subsections. - frame.pack(fill=tk.BOTH, padx=5, pady=5, side=tk.BOTTOM) - btn_cls.pack(padx=2, side=tk.RIGHT) - btn_ok.pack(padx=2, side=tk.RIGHT) - btn_rst.pack(padx=2, side=tk.RIGHT) + Parameters + ---------- + key: str + The lookup key to set the links page for + """ + frame = ttk.Frame(self) + links = {item.replace(key, "")[1:].split("|")[0] + for item in self._config_cpanel_dict + if item.startswith(key)} + + if not links: + return frame + + header_lbl = ttk.Label(frame, text="Select a plugin to configure:") + header_lbl.pack(side=tk.TOP, fill=tk.X, padx=5, pady=(5, 10)) + for link in sorted(links): + lbl = ttk.Label(frame, + text=link.replace("_", " ").title(), + anchor=tk.W, + foreground="blue", + cursor="hand2") + lbl.pack(side=tk.TOP, fill=tk.X, padx=10, pady=(0, 5)) + bind = "{}|{}".format(key, link) + lbl.bind("", lambda e, l=bind: self._link_callback(l)) + + return frame + + def _link_callback(self, identifier): + """ Set the tree view to the selected item and display the requested page on a link click. - logger.debug("Added action buttons") + Parameters + ---------- + identifier: str + The identifier from the tree view for the page to display + """ + parent = "|".join(identifier.split("|")[:-1]) + self._tree.item(parent, open=True) + self._tree.selection_set(identifier) + self._tree.focus(identifier) + split = identifier.split("|") + section = split[0] + subsections = split[1:] if len(split) > 1 else [] + self.select_options(section, subsections) + + def reset(self, page_only=False): + """ Reset all configuration options to their default values. - def _reset(self): - """ Reset all configuration options to their default values. """ - logger.debug("Resetting config") - for section, items in self._config.defaults.items(): - logger.debug("Resetting section: '%s'", section) - lookup = [section.split(".")[0], section.split(".")[-1]] - for item, def_opt in items.items(): - if item == "helptext": - continue - default = def_opt["default"] - logger.debug("Resetting: '%s' to '%s'", item, default) - self._config_cpanel_dict[lookup[0]][lookup[1]][item].set(default) + Parameters + ---------- + page_only: bool, optional + ``True`` resets just the currently selected page's options to default, ``False`` resets + all plugins within the currently selected config to default. Default: ``False`` + """ + logger.debug("Resetting config, page_only: %s", page_only) + selection = self._tree.focus() + if page_only: + if selection not in self._config_cpanel_dict: + logger.info("No configuration options to reset for current page: %s", selection) + return + items = list(self._config_cpanel_dict[selection]["options"].values()) + else: + items = [opt + for key, val in self._config_cpanel_dict.items() + for opt in val["options"].values() + if key.startswith(selection.split("|")[0])] + for item in items: + logger.debug("Resetting item '%s' from '%s' to default '%s'", + item.title, item.get(), item.default) + item.set(item.default) + logger.debug("Reset config") + + def save(self, page_only=False): + """ Save the configuration file to disk. - def _save(self): - """ Save the configuration file to disk. """ + Parameters + ---------- + page_only: bool, optional + ``True`` saves just the currently selected page's options, ``False`` saves all the + plugins options within the currently selected config. Default: ``False`` + """ logger.debug("Saving config") - options = {".".join((key, sect)) if sect != key else key: opts - for key, value in self._config_cpanel_dict.items() - for sect, opts in value.items()} + selection = self._tree.focus() + category = selection.split("|")[0] + config = self._configs[category] + # Create a new config to pull through any defaults change new_config = ConfigParser(allow_no_value=True) - for section, items in self._config.defaults.items(): + + if "|" in selection: + lookup = ".".join(selection.split("|")[1:]) + else: # Expand global out from root node + lookup = "global" + + if page_only and lookup not in config.config.sections(): + logger.info("No settings to save for the current page") + return + + for section, items in config.defaults.items(): logger.debug("Adding section: '%s')", section) - self._config.insert_config_section(section, items["helptext"], config=new_config) - for item, def_opt in items.items(): + config.insert_config_section(section, items["helptext"], config=new_config) + for item, options in items.items(): if item == "helptext": continue - new_opt = options[section][item].get() - logger.debug("Adding option: (item: '%s', default: %s new: '%s'", - item, def_opt, new_opt) - helptext = def_opt["helptext"] - helptext = self._config.format_help(helptext, is_section=False) + if page_only and section != lookup: + # Keep existing values for pages we are not updating + new_opt = config.get(section, item) + logger.debug("Retain existing value '%s' for %s", + new_opt, ".".join([section, item])) + else: + # Get currently selected value + key = category + if section != "global": + key += "|{}".format(section.replace(".", "|")) + new_opt = self._config_cpanel_dict[key]["options"][item].get() + logger.debug("Updating value to '%s' for %s", + new_opt, ".".join([section, item])) + helptext = config.format_help(options["helptext"], is_section=False) new_config.set(section, helptext) new_config.set(section, item, str(new_opt)) - self._config.config = new_config - self._config.save_config() - logger.info("Saved config: '%s'", self._config.configfile) - self.destroy() - - running_task = get_config().tk_vars["runningtask"].get() - if self._name.lower() == "gui" and not running_task: - self._root.rebuild() - elif self._name.lower() == "gui" and running_task: - logger.info("Can't redraw GUI whilst a task is running. GUI Settings will be applied " - "at the next restart.") + config.config = new_config + config.save_config() + logger.info("Saved config: '%s'", config.configfile) + + if category == "gui": + if not get_config().tk_vars["runningtask"].get(): + get_config().root.rebuild() + else: + logger.info("Can't redraw GUI whilst a task is running. GUI Settings will be " + "applied at the next restart.") logger.debug("Saved config") diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 895c5405fb..ee6f944bae 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -148,7 +148,7 @@ def _set_globals(self): datatype=bool, default=False, group="network", - info="R|[Nvidia Only], NVIDIA GPUs can run operations in float16 faster than in " + info="[Nvidia Only], NVIDIA GPUs can run operations in float16 faster than in " "float32. Mixed precision allows you to use a mix of float16 with float32, to " "get the performance benefits from float16 and the numeric stability benefits " "from float32.\n\nWhile mixed precision will run on most Nvidia models, it will " diff --git a/scripts/gui.py b/scripts/gui.py index 26ab64c108..b45b98a31e 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -51,11 +51,18 @@ def set_fonts(self): tk.font.nametofont(font).configure(family=self._config.default_font[0], size=self._config.default_font[1]) - @staticmethod - def set_styles(): + def set_styles(self): """ Set global custom styles """ gui_style = ttk.Style() gui_style.configure('TLabelframe.Label', foreground="#0046D5", relief=tk.SOLID) + gui_style.configure('H1.TLabel', + font=(self._config.default_font[0], + self._config.default_font[1] + 4, + "bold")) + gui_style.configure('H2.TLabel', + font=(self._config.default_font[0], + self._config.default_font[1] + 2, + "bold")) def build_gui(self, rebuild=False): """ Build the GUI """ From 42d10bc991ccf7fdc5508785dfeb74fe3703a287 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 24 Oct 2020 00:31:57 +0100 Subject: [PATCH 325/981] Typo Fix --- lib/gui/popup_configure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index c5bc21485c..cb26676a19 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -135,7 +135,7 @@ def __init__(self, name, configurations): self._tree.focus(select) self._select_item(0) - self.title("Congigure Settings") + self.title("Configure Settings") self.tk.call('wm', 'iconphoto', self._w, get_images().icons["favicon"]) self.protocol("WM_DELETE_WINDOW", _STATE.close_popup) From ef099665f70af5a4865a5b54587c207956654b43 Mon Sep 17 00:00:00 2001 From: Shubham Chaudhary Date: Sat, 24 Oct 2020 06:42:04 -0400 Subject: [PATCH 326/981] Bugfix: Fix tensorflow cpu docker image (#1081) * Bugfix: Update tensorflow docker image to tag: 2.2.1-py3 There is no image tagged 2.2.0-py3. Related ticket for gpu: #1078 * Bugfix: Install software-properties-common to fetch missing add-apt-repository * Bugfix: Refactor missing package installation to a single line * Bugfix: Disable interactive input stopping the docker build --- Dockerfile.cpu | 9 +++++++-- Dockerfile.gpu | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Dockerfile.cpu b/Dockerfile.cpu index bb30b48883..45eeeaf346 100755 --- a/Dockerfile.cpu +++ b/Dockerfile.cpu @@ -1,6 +1,11 @@ -FROM tensorflow/tensorflow:2.2.0-py3 +FROM tensorflow/tensorflow:2.2.1-py3 -RUN add-apt-repository -y ppa:jonathonf/ffmpeg-4 \ +# To disable tzdata and others from asking for input +ENV DEBIAN_FRONTEND noninteractive + +RUN apt-get update -qq -y \ + && apt-get install -y software-properties-common \ + && add-apt-repository -y ppa:jonathonf/ffmpeg-4 \ && apt-get update -qq -y \ && apt-get install -y libsm6 libxrender1 libxext-dev python3-tk ffmpeg git \ && apt-get clean \ diff --git a/Dockerfile.gpu b/Dockerfile.gpu index d5768658cd..07aedd9bf1 100755 --- a/Dockerfile.gpu +++ b/Dockerfile.gpu @@ -1,5 +1,6 @@ FROM tensorflow/tensorflow:2.2.1-gpu-py3 +# To disable tzdata and others from asking for input ENV DEBIAN_FRONTEND noninteractive RUN add-apt-repository -y ppa:jonathonf/ffmpeg-4 \ From c24bf2b4808631bbb59c20d20f73debd3c0621c4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 1 Nov 2020 00:44:12 +0000 Subject: [PATCH 327/981] GUI - Revert Conda default font fix --- lib/gui/_config.py | 6 ++++++ lib/gui/utils.py | 23 ++--------------------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/lib/gui/_config.py b/lib/gui/_config.py index 12f4f7a233..1c26a238f0 100644 --- a/lib/gui/_config.py +++ b/lib/gui/_config.py @@ -110,4 +110,10 @@ def get_clean_fonts(): fonts.setdefault(font.name, dict())["bold"] = True valid_fonts = {key for key, val in fonts.items() if len(val) == 2} retval = sorted(list(valid_fonts.intersection(tk_font.families()))) + if not retval: + # Return the font list with any @prefixed or non-Unicode characters stripped and default + # prefixed + logger.debug("No bold/regular fonts found. Running simple filter") + retval = sorted([fnt for fnt in tk_font.families() + if not fnt.startswith("@") and not any([ord(c) > 127 for c in fnt])]) return ["default"] + retval diff --git a/lib/gui/utils.py b/lib/gui/utils.py index b37bcfe07d..c524ecfb04 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -182,7 +182,7 @@ def _filetypes(self): if len(filetypes[key]) > 2: multi = ["{} Files".format(key.title())] multi.append(" ".join([ftype[1] - for ftype in filetypes[key] if ftype[0] != "All files"])) + for ftype in filetypes[key] if ftype[0] != "All files"])) filetypes[key].insert(0, tuple(multi)) return filetypes @@ -771,7 +771,7 @@ class Config(): def __init__(self, root, cli_opts, statusbar): logger.debug("Initializing %s: (root %s, cli_opts: %s, statusbar: %s)", self.__class__.__name__, root, cli_opts, statusbar) - self._default_font = self._set_default_font() + self._default_font = tk.font.nametofont("TkDefaultFont").configure()["family"] self._constants = dict( root=root, scaling_factor=self._get_scaling(root), @@ -894,25 +894,6 @@ def _get_scaling(root): logger.debug("dpi: %s, scaling: %s'", dpi, scaling) return scaling - @classmethod - def _set_default_font(cls): - """ Set the default font. - - For macOS and Windows, this just pulls back the system default font. - - For Linux, quite often the default is not ideal, so we try to pull a sane default from - installed fonts. - """ - if platform.system() == "Linux": - for family in ("DejaVu Sans", "Noto Sans", "Nimbus Sans"): - if family in tk.font.families(): - logger.debug("Setting default font to: '%s'", family) - tk.font.nametofont("TkDefaultFont").configure(family=family) - tk.font.nametofont("TkHeadingFont").configure(family=family) - tk.font.nametofont("TkMenuFont").configure(family=family) - break - return tk.font.nametofont("TkDefaultFont").configure()["family"] - def set_default_options(self): """ Set the default options for :mod:`lib.gui.projects` From 3359717b85a5433b9dae04a3d036c23526d7b6f6 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 4 Dec 2020 11:39:36 +0000 Subject: [PATCH 328/981] Add Discord button to README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9707391e3b..b147646e66 100755 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

-

+    


Jennifer Lawrence/Steve Buscemi FaceSwap using the Villain model From 05018f6119b4f90b91d18203e6bcd39868b0b662 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 8 Dec 2020 01:31:56 +0000 Subject: [PATCH 329/981] Extract - Increase area and move centering (#1095) * Extract - Implement aligner re-feeding - Add extract type to pipeline.ExtractMedia - Add pose annotation to debug * Convert - implement centering - remove usage of feed and reference face properties - Remove distributed option from convert - Force update of alignments file on legacy receive * Train - Resize preview image to model output size - Force legacy centering if centering does not exist in model's state file - Enable training on legacy face sets * Alignments Tool - Update draw to include head/pose - Remove DFL drop + linting - Remove remove-frames job - remove align-eyes option - Update legacy masks to new extract type - Exit if attempting to merge version 1.0 alignments files with version 2.0 alignments files - Re-generate thumbnails on legacy upgrade * Mask Tool - Update for new extract + bugfix full frame * Manual Tool - Update to new extraction method - Disable legacy alignments, - extract box bugfix - extract faces - size to 512 and center on head * Preview Tool - Display based on model centering * Sort Tool - Use alignments for sort by face * lib.aligner - Add Pose Class - Add AlignedFace Class - center _MEAN_FACE on x - Add meta information with versioning to alignments file - lib.aligner.get_align_matrix to use landmarks not face - Refactor aligned faces in lib.faces_detect * lib.logger - larger file log padding * lib.config - Fix global changeable_items * lib.face_filter - Use new extracted face images * lib.image - bump thumbnail default size to 96px --- docs/full/lib/align.rst | 71 ++ docs/full/lib/alignments.rst | 7 - docs/full/lib/faces_detect.rst | 21 - docs/full/lib/model.rst | 2 +- lib/align/__init__.py | 6 + lib/align/aligned_face.py | 693 ++++++++++++++++++ lib/{ => align}/alignments.py | 44 +- .../detected_face.py} | 363 +++------ lib/aligner.py | 123 ---- lib/cli/args.py | 48 +- lib/config.py | 3 +- lib/convert.py | 53 +- lib/face_filter.py | 46 +- lib/gui/stats.py | 2 +- lib/image.py | 2 +- lib/logger.py | 4 +- lib/model/session.py | 4 +- lib/training_data.py | 142 ++-- lib/umeyama.py | 124 ---- plugins/convert/mask/box_blend.py | 2 +- plugins/convert/mask/box_blend_defaults.py | 130 ++-- plugins/convert/mask/mask_blend.py | 18 +- plugins/convert/mask/mask_blend_defaults.py | 142 ++-- plugins/extract/_base.py | 52 +- plugins/extract/align/_base.py | 111 ++- plugins/extract/align/cv2_dnn.py | 6 +- plugins/extract/align/fan.py | 6 +- plugins/extract/detect/_base.py | 8 +- plugins/extract/mask/_base.py | 31 +- plugins/extract/mask/components.py | 4 +- plugins/extract/mask/extended.py | 4 +- plugins/extract/mask/unet_dfl.py | 4 +- plugins/extract/mask/vgg_clear.py | 7 +- plugins/extract/mask/vgg_obstructed.py | 7 +- plugins/extract/pipeline.py | 23 +- plugins/train/_config.py | 18 + plugins/train/model/_base.py | 13 +- plugins/train/trainer/_base.py | 265 ++++--- scripts/convert.py | 82 ++- scripts/extract.py | 11 +- scripts/fsmedia.py | 27 +- tools/alignments/alignments.py | 14 +- tools/alignments/annotate.py | 106 --- tools/alignments/cli.py | 44 +- tools/alignments/jobs.py | 636 +++++++++------- tools/alignments/media.py | 75 +- tools/manual/detected_faces.py | 132 ++-- tools/manual/faceviewer/frame.py | 8 +- tools/manual/faceviewer/viewport.py | 57 +- tools/manual/frameviewer/control.py | 12 +- .../manual/frameviewer/editor/bounding_box.py | 2 +- .../manual/frameviewer/editor/extract_box.py | 7 +- tools/manual/frameviewer/editor/landmarks.py | 11 +- tools/manual/frameviewer/editor/mask.py | 7 +- tools/manual/manual.py | 2 +- tools/mask/mask.py | 17 +- tools/preview/preview.py | 54 +- tools/sort/cli.py | 306 ++++---- tools/sort/sort.py | 175 +++-- 59 files changed, 2537 insertions(+), 1857 deletions(-) create mode 100644 docs/full/lib/align.rst delete mode 100755 docs/full/lib/alignments.rst delete mode 100755 docs/full/lib/faces_detect.rst create mode 100644 lib/align/__init__.py create mode 100644 lib/align/aligned_face.py rename lib/{ => align}/alignments.py (94%) rename lib/{faces_detect.py => align/detected_face.py} (70%) delete mode 100644 lib/aligner.py delete mode 100644 lib/umeyama.py delete mode 100644 tools/alignments/annotate.py diff --git a/docs/full/lib/align.rst b/docs/full/lib/align.rst new file mode 100644 index 0000000000..addd3bd8e1 --- /dev/null +++ b/docs/full/lib/align.rst @@ -0,0 +1,71 @@ +************* +align package +************* + +The align Package handles detected faces, their alignments and masks. + +.. contents:: Contents + :local: + +aligned\_face module +==================== + +Handles aligned faces and corresponding pose estimates + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.align.aligned_face.AlignedFace + ~lib.align.aligned_face.get_matrix_scaling + ~lib.align.aligned_face.PoseEstimate + ~lib.align.aligned_face.transform_image + +.. rubric:: Module + +.. automodule:: lib.align.aligned_face + :members: + :undoc-members: + :show-inheritance: + +alignments module +================= + +Handles alignments stored in a serialized alignments.fsa file + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.align.alignments.Alignments + ~lib.align.alignments.Thumbnails + +.. rubric:: Module + +.. automodule:: lib.align.alignments + :members: + :undoc-members: + :show-inheritance: + +detected\_face module +===================== + +Handles detected face objects and their associated masks. + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.align.detected_face.BlurMask + ~lib.align.detected_face.DetectedFace + ~lib.align.detected_face.Mask + +.. rubric:: Module + +.. automodule:: lib.align.detected_face + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib/alignments.rst b/docs/full/lib/alignments.rst deleted file mode 100755 index 015ddfa085..0000000000 --- a/docs/full/lib/alignments.rst +++ /dev/null @@ -1,7 +0,0 @@ -alignments module -================= - -.. automodule:: lib.alignments - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/lib/faces_detect.rst b/docs/full/lib/faces_detect.rst deleted file mode 100755 index c5b688a7bc..0000000000 --- a/docs/full/lib/faces_detect.rst +++ /dev/null @@ -1,21 +0,0 @@ -******************** -faces\_detect module -******************** - -Handles detected and aligned faces objects and their associated masks. - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~lib.faces_detect.BlurMask - ~lib.faces_detect.DetectedFace - ~lib.faces_detect.Mask - -.. rubric:: Module - -.. automodule:: lib.faces_detect - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/lib/model.rst b/docs/full/lib/model.rst index 5396f89a4d..b4a19cd510 100755 --- a/docs/full/lib/model.rst +++ b/docs/full/lib/model.rst @@ -64,11 +64,11 @@ be imported as :mod:`lib.model.losses` depending on the backend in use. :nosignatures: ~lib.model.losses_tf.DSSIMObjective - ~lib.model.losses_tf.PenalizedLoss ~lib.model.losses_tf.GeneralizedLoss ~lib.model.losses_tf.GMSDLoss ~lib.model.losses_tf.GradientLoss ~lib.model.losses_tf.LInfNorm + ~lib.model.losses_tf.LossWrapper .. automodule:: lib.model.losses_tf :members: diff --git a/lib/align/__init__.py b/lib/align/__init__.py new file mode 100644 index 0000000000..d34adf3948 --- /dev/null +++ b/lib/align/__init__.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +""" Package for handling alignments files, detected faces and aligned faces along with their +associated objects. """ +from .aligned_face import AlignedFace, _EXTRACT_RATIOS, get_matrix_scaling, PoseEstimate, transform_image # noqa +from .alignments import Alignments # noqa +from .detected_face import BlurMask, DetectedFace, Mask # noqa diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py new file mode 100644 index 0000000000..4dcc7ea884 --- /dev/null +++ b/lib/align/aligned_face.py @@ -0,0 +1,693 @@ +#!/usr/bin/env python3 +""" Aligner for faceswap.py """ + +import logging +from threading import Lock + +import cv2 +import numpy as np + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +_MEAN_FACE = np.array([[0.010086, 0.106454], [0.085135, 0.038915], [0.191003, 0.018748], + [0.300643, 0.034489], [0.403270, 0.077391], [0.596729, 0.077391], + [0.699356, 0.034489], [0.808997, 0.018748], [0.914864, 0.038915], + [0.989913, 0.106454], [0.500000, 0.203352], [0.500000, 0.307009], + [0.500000, 0.409805], [0.500000, 0.515625], [0.376753, 0.587326], + [0.435909, 0.609345], [0.500000, 0.628106], [0.564090, 0.609345], + [0.623246, 0.587326], [0.131610, 0.216423], [0.196995, 0.178758], + [0.275698, 0.179852], [0.344479, 0.231733], [0.270791, 0.245099], + [0.192616, 0.244077], [0.655520, 0.231733], [0.724301, 0.179852], + [0.803005, 0.178758], [0.868389, 0.216423], [0.807383, 0.244077], + [0.729208, 0.245099], [0.264022, 0.780233], [0.350858, 0.745405], + [0.438731, 0.727388], [0.500000, 0.742578], [0.561268, 0.727388], + [0.649141, 0.745405], [0.735977, 0.780233], [0.652032, 0.864805], + [0.566594, 0.902192], [0.500000, 0.909281], [0.433405, 0.902192], + [0.347967, 0.864805], [0.300252, 0.784792], [0.437969, 0.778746], + [0.500000, 0.785343], [0.562030, 0.778746], [0.699747, 0.784792], + [0.563237, 0.824182], [0.500000, 0.831803], [0.436763, 0.824182]]) + +_MEAN_FACE_3D = np.array([[4.056931, -11.432347, 1.636229], # 8 chin LL + [1.833492, -12.542305, 4.061275], # 7 chin L + [0.0, -12.901019, 4.070434], # 6 chin C + [-1.833492, -12.542305, 4.061275], # 5 chin R + [-4.056931, -11.432347, 1.636229], # 4 chin RR + [6.825897, 1.275284, 4.402142], # 33 L eyebrow L + [1.330353, 1.636816, 6.903745], # 29 L eyebrow R + [-1.330353, 1.636816, 6.903745], # 34 R eyebrow L + [-6.825897, 1.275284, 4.402142], # 38 R eyebrow R + [1.930245, -5.060977, 5.914376], # 54 nose LL + [0.746313, -5.136947, 6.263227], # 53 nose L + [0.0, -5.485328, 6.76343], # 52 nose C + [-0.746313, -5.136947, 6.263227], # 51 nose R + [-1.930245, -5.060977, 5.914376], # 50 nose RR + [5.311432, 0.0, 3.987654], # 13 L eye L + [1.78993, -0.091703, 4.413414], # 17 L eye R + [-1.78993, -0.091703, 4.413414], # 25 R eye L + [-5.311432, 0.0, 3.987654], # 21 R eye R + [2.774015, -7.566103, 5.048531], # 43 mouth L + [0.509714, -7.056507, 6.566167], # 42 mouth top L + [0.0, -7.131772, 6.704956], # 41 mouth top C + [-0.509714, -7.056507, 6.566167], # 40 mouth top R + [-2.774015, -7.566103, 5.048531], # 39 mouth R + [-0.589441, -8.443925, 6.109526], # 46 mouth bottom R + [0.0, -8.601736, 6.097667], # 45 mouth bottom C + [0.589441, -8.443925, 6.109526]]) # 44 mouth bottom L + +_EXTRACT_RATIOS = dict(legacy=0.375, face=0.5, head=0.625) + + +def get_matrix_scaling(matrix): + """ Given a matrix, return the cv2 Interpolation method and inverse interpolation method for + applying the matrix on an image. + + Parameters + ---------- + matrix: :class:`numpy.ndarray` + The transform matrix to return the interpolator for + + Returns + ------- + tuple + The interpolator and inverse interpolator for the given matrix. This will be (Cubic, Area) + for an upscale matrix and (Area, Cubic) for a downscale matrix + """ + x_scale = np.sqrt(matrix[0, 0] * matrix[0, 0] + matrix[0, 1] * matrix[0, 1]) + y_scale = (matrix[0, 0] * matrix[1, 1] - matrix[0, 1] * matrix[1, 0]) / x_scale + avg_scale = (x_scale + y_scale) * 0.5 + if avg_scale >= 1.: + interpolators = cv2.INTER_CUBIC, cv2.INTER_AREA + else: + interpolators = cv2.INTER_AREA, cv2.INTER_CUBIC + logger.trace("interpolator: %s, inverse interpolator: %s", interpolators[0], interpolators[1]) + return interpolators + + +def transform_image(image, matrix, size, padding=0): + """ Perform transformation on an image, applying the given size and padding to the matrix. + + Parameters + ---------- + image: :class:`numpy.ndarray` + The image to transform + matrix: :class:`numpy.ndarray` + The transformation matrix to apply to the image + size: int + The final size of the transformed image + padding: int, optional + The amount of padding to apply to the final image. Default: `0` + + Returns + ------- + :class:`numpy.ndarray` + The transformed image + """ + logger.trace("image shape: %s, matrix: %s, size: %s. padding: %s", + image.shape, matrix, size, padding) + # transform the matrix for size and padding + mat = matrix * (size - 2 * padding) + mat[:, 2] += padding + + # transform image + interpolators = get_matrix_scaling(mat) + retval = cv2.warpAffine(image, mat, (size, size), flags=interpolators[0]) + logger.trace("transformed matrix: %s, final image shape: %s", mat, image.shape) + return retval + + +class AlignedFace(): + """ Class to align a face. + + Holds the aligned landmarks and face image, as well as associated matrices and information + about an aligned face. + + Parameters + ---------- + landmarks: :class:`numpy.ndarray` + The original 68 point landmarks that pertain to the given image for this face + image: :class:`numpy.ndarray`, optional + The original frame that contains the face that is to be aligned. Pass `None` if the aligned + face is not to be generated, and just the co-ordinates should be calculated. + centering: ["legacy", "face", "head"], optional + The type of extracted face that should be loaded. "legacy" places the nose in the center of + the image (the original method for aligning). "face" aligns for the nose to be in the + center of the face (top to bottom) but the center of the skull for left to right. "head" + aligns for the center of the skull (in 3D space) being the center of the extracted image, + with the crop holding the full head. Default: `"face"` + size: int, optional + The size in pixels, of each edge of the final aligned face. Default: `64` + coverage_ratio: float, optional + The amount of the aligned image to return. A ratio of 1.0 will return the full contents of + the aligned image. A ratio of 0.5 will return an image of the given size, but will crop to + the central 50%% of the image. + dtype: str, optional + Set a data type for the final face to be returned as. Passing ``None`` will return a face + with the same data type as the original :attr:`image`. Default: ``None`` + is_aligned_face: bool, optional + Indicates that the :attr:`image` is an aligned face rather than a frame. + Default: ``False`` + """ + def __init__(self, landmarks, image=None, centering="face", size=64, coverage_ratio=1.0, + dtype=None, is_aligned=False): + logger.trace("Initializing: %s (image shape: %s, centering: '%s', size: %s, " + "coverage_ratio: %s, dtype: %s, is_aligned: %s)", self.__class__.__name__, + image if image is None else image.shape, centering, size, coverage_ratio, + dtype, is_aligned) + self._frame_landmarks = landmarks + self._centering = centering + self._size = size + self._dtype = dtype + self._is_aligned = is_aligned + self._matrices = dict(legacy=_umeyama(landmarks[17:], _MEAN_FACE, True)[0:2], + face=None, + head=None) + self._padding = self._padding_from_coverage(size, coverage_ratio) + + self._cache = self._set_cache() + + self._face = self._extract_face(image) + logger.trace("Initialized: %s (matrix: %s, padding: %s, face shape: %s)", + self.__class__.__name__, self._matrices["legacy"], self._padding, + self._face if self._face is None else self._face.shape) + + @property + def size(self): + """ int: The size (in pixels) of one side of the square extracted face image. """ + return self._size + + @property + def padding(self): + """ int: The amount of padding (in pixels) that is applied to each side of the + extracted face image for the selected extract type. """ + return self._padding[self._centering] + + @property + def matrix(self): + """ :class:`numpy.ndarray`: The 3x2 transformation matrix for extracting and aligning the + core face area out of the original frame, with no padding or sizing applied. The returned + matrix is offset for the given :attr:`centering`. """ + if self._matrices[self._centering] is None: + matrix = self._matrices["legacy"].copy() + matrix[:, 2] -= self.pose.offset[self._centering] + self._matrices[self._centering] = matrix + logger.trace("original matrix: %s, new matrix: %s", self._matrices["legacy"], matrix) + return self._matrices[self._centering] + + @property + def pose(self): + """ :class:`lib.align.PoseEstimate`: The estimated pose in 3D space. """ + with self._cache["pose"][1]: + if self._cache["pose"][0] is None: + lms = cv2.transform(np.expand_dims(self._frame_landmarks, axis=1), + self._matrices["legacy"]).squeeze() + self._cache["pose"][0] = PoseEstimate(lms) + return self._cache["pose"][0] + + @property + def adjusted_matrix(self): + """ :class:`numpy.ndarray`: The 3x2 transformation matrix for extracting and aligning the + core face area out of the original frame with padding and sizing applied. """ + with self._cache["adjusted_matrix"][1]: + if self._cache["adjusted_matrix"][0] is None: + matrix = self.matrix.copy() + mat = matrix * (self._size - 2 * self.padding) + mat[:, 2] += self.padding + logger.trace("adjusted_matrix: %s", mat) + self._cache["adjusted_matrix"][0] = mat + return self._cache["adjusted_matrix"][0] + + @property + def face(self): + """ :class:`numpy.ndarray`: The aligned face at the given :attr:`size` at the specified + :attr:`coverage` in the given :attr:`dtype`. If an :attr:`image` has not been provided + then an the attribute will return ``None``. """ + return self._face + + @property + def original_roi(self): + """ :class:`numpy.ndarray`: The location of the extracted face box within the original + frame. """ + with self._cache["original_roi"][1]: + if self._cache["original_roi"][0] is None: + roi = np.array([[0, 0], + [0, self._size - 1], + [self._size - 1, self._size - 1], + [self._size - 1, 0]]) + roi = np.rint(self.transform_points(roi, invert=True)).astype("int32") + logger.trace("original roi: %s", roi) + self._cache["original_roi"][0] = roi + return self._cache["original_roi"][0] + + @property + def landmarks(self): + """ :class:`numpy.ndarray`: The 68 point facial landmarks aligned to the extracted face + box. """ + with self._cache["landmarks"][1]: + if self._cache["landmarks"][0] is None: + lms = self.transform_points(self._frame_landmarks) + logger.trace("aligned landmarks: %s", lms) + self._cache["landmarks"][0] = lms + return self._cache["landmarks"][0] + + @property + def interpolators(self): + """ tuple: (`interpolator` and `reverse interpolator`) for the :attr:`adjusted matrix`. """ + with self._cache["interpolators"][1]: + if self._cache["interpolators"][0] is None: + interpolators = get_matrix_scaling(self.adjusted_matrix) + logger.trace("interpolators: %s", interpolators) + self._cache["interpolators"][0] = interpolators + return self._cache["interpolators"][0] + + @classmethod + def _set_cache(cls): + """ Set the cache items. + + Items are cached so that they are only created the first time they are called. + Each item includes a threading lock to make cache creation thread safe. + + Returns + ------- + dict + The Aligned Face cache + """ + return dict(pose=[None, Lock()], + original_roi=[None, Lock()], + landmarks=[None, Lock()], + adjusted_matrix=[None, Lock()], + interpolators=[None, Lock()], + cropped_roi=[dict(), Lock()], + cropped_size=[dict(), Lock()], + cropped_slices=[dict(), Lock()]) + + def transform_points(self, points, invert=False): + """ Perform transformation on a series of (x, y) co-ordinates in world space into + aligned face space. + + Parameters + ---------- + points: :class:`numpy.ndarray` + The points to transform + invert: bool, optional + ``True`` to reverse the transformation (i.e. transform the points into world space from + aligned face space). Default: ``False`` + + Returns + ------- + :class:`numpy.ndarray` + The transformed points + """ + retval = np.expand_dims(points, axis=1) + mat = cv2.invertAffineTransform(self.adjusted_matrix) if invert else self.adjusted_matrix + retval = cv2.transform(retval, mat, retval.shape).squeeze() + logger.trace("invert: %s, Original points: %s, transformed points: %s", + invert, points, retval) + return retval + + def _extract_face(self, image): + """ Extract the face from a source image and populate :attr:`face`. If an image is not + provided then ``None`` is returned. + + Parameters + ---------- + image: :class:`numpy.ndarray` or ``None`` + The original frame to extract the face from. ``None`` if the face should not be + extracted + + Returns + ------- + :class:`numpy.ndarray` or ``None`` + The extracted face at the given size, with the given coverage of the given dtype or + ``None`` if no image has been provided. + """ + if image is None: + logger.debug("_extract_face called without a loaded image. Returning empty face.") + if self._is_aligned: + raise ValueError("An aligned face must be provided if calling with " + "'is_aligned=True'") + return None + if self._is_aligned and self._centering != "head": # Crop out the sub face from full head + image = self._convert_centering(image) + + if self._is_aligned and image.shape[0] != self._size: # Resize the given aligned face + interp = cv2.INTER_CUBIC if image.shape[0] < self._size else cv2.INTER_AREA + retval = cv2.resize(image, (self._size, self._size), interpolation=interp) + elif self._is_aligned: + retval = image + else: + retval = transform_image(image, self.matrix, self._size, self.padding) + retval = retval if self._dtype is None else retval.astype(self._dtype) + return retval + + def _convert_centering(self, image): + """ When the face being loaded is pre-aligned, the loaded image will have 'head' centering + so it needs to be cropped out to the appropriate centering. + + This function temporarily converts this object to a full head aligned face, extracts the + sub-cropped face to the correct centering, revers the sub crop and returns the cropped + face. + + Parameters + ---------- + image: :class:`numpy.ndarray` + The original head-centered aligned image + + Returns + ------- + :class:`numpy.ndarray` + The aligned image with the correct centering + """ + # Input image is sized up because of integer rounding + src_size = self.size - (self._size * _EXTRACT_RATIOS[self._centering]) + head_size = 2 * int(np.rint(src_size / (1 - _EXTRACT_RATIOS["head"]) / 2)) + if head_size != image.shape[0]: + interp = cv2.INTER_CUBIC if image.shape[0] < head_size else cv2.INTER_AREA + image = cv2.resize(image, (head_size, head_size), interpolation=interp) + + # store requested size + centering whilst temporary converting to full head extract + old_centering = self._centering + old_size = self.size + self._centering = "head" + self._size = image.shape[0] + + # crop the requested centering from image + size = self.get_cropped_size(old_centering) + out = np.zeros((size, size, image.shape[-1]), dtype=image.dtype) + slices = self.get_cropped_slices(old_centering) + out[slices["out"][0], slices["out"][1], :] = image[slices["in"][0], slices["in"][1], :] + + # Revert back to the correct centering and size and reset the cache + self._centering = old_centering + self._size = old_size + self._cache = self._set_cache() + logger.trace("Cropped from aligned extract: (centering: %s, in shape: %s, out shape: %s)", + old_centering, image.shape, out.shape) + return out + + @classmethod + def _padding_from_coverage(cls, size, coverage_ratio): + """ Return the image padding for a face from coverage_ratio set against a + pre-padded training image. + + Parameters + ---------- + size: int + The final size of the aligned image in pixels + coverage_ratio: float + The ratio of the final image to pad to + + Returns + ------- + dict + The padding required, in pixels for 'head', 'face' and 'legacy' face types + """ + retval = {_type: round((size * (coverage_ratio - (1 - _EXTRACT_RATIOS[_type]))) / 2) + for _type in ("legacy", "face", "head")} + logger.trace(retval) + return retval + + def get_cropped_roi(self, centering): + """ Obtain the region of interest within an aligned face set to centered coverage for + an alternative centering + + Parameters + ---------- + centering: ["legacy", "face"] + The type of centering to obtain the region of interest for. "legacy" places the nose + in the center of the image (the original method for aligning). "face" aligns for the + nose to be in the center of the face (top to bottom) but the center of the skull for + left to right. + + Returns + ------- + :class:`numpy.ndarray` + The (`left`, `top`, `right`, `bottom` location of the region of interest within an + aligned face centered on the head for the given centering + """ + if self._centering != "head": + raise ValueError("Sub ROI can only be obtained from an aligned face with 'head' " + "centering") + with self._cache["cropped_roi"][1]: + if centering not in self._cache["cropped_roi"][0]: + offset = self.pose.offset.get(centering, np.float32((0, 0))) # legacy = 0,0 + offset -= self.pose.offset["head"] + offset *= ((self._size - self._padding["head"]) / 2) + + center = np.rint(offset + self._size / 2).astype("int32") + padding = self.get_cropped_size(centering) // 2 + roi = np.array([center - padding, center + padding]).ravel() + logger.trace("centering: '%s', center: %s, padding: %s, sub roi: %s", + centering, center, padding, roi) + self._cache["cropped_roi"][0][centering] = roi + return self._cache["cropped_roi"][0][centering] + + def get_cropped_size(self, centering): + """ Obtain the size of a cropped face from a full head centered image. + + Parameters + ---------- + centering: ["legacy", "face"] + The type of centering to obtain the region of interest for. "legacy" places the nose + in the center of the image (the original method for aligning). "face" aligns for the + nose to be in the center of the face (top to bottom) but the center of the skull for + left to right. + + Returns + ------- + int + The pixel size of a sub-crop image from a full head aligned image + + Notes + ----- + The ROI in relation to the source image is calculated by rounding the padding of one side + to the nearest integer then applying this padding to the center of the crop, so the size + is calculated in the same way. + """ + if self._centering != "head": + raise ValueError("Sub ROI can only be obtained from an aligned face with 'head' " + "centering") + with self._cache["cropped_size"][1]: + if not self._cache["cropped_size"][0].get(centering): + src_size = self.size - (self._size * _EXTRACT_RATIOS["head"]) + size = 2 * int(np.rint(src_size / (1 - _EXTRACT_RATIOS[centering]) / 2)) + logger.trace("centering: %s, size: %s, crop_size: %s", centering, self._size, size) + self._cache["cropped_size"][0][centering] = size + return self._cache["cropped_size"][0][centering] + + def get_cropped_slices(self, centering): + """ Obtain the slices to turn a full head extract into an alternatively centered extract. + + Parameters + ---------- + centering: ["legacy", "face"] + The type of centering to obtain the region of interest for. "legacy" places the nose + in the center of the image (the original method for aligning). "face" aligns for the + nose to be in the center of the face (top to bottom) but the center of the skull for + left to right. + + Returns + ------- + dict + The slices for an input full head image and output cropped image + """ + if self._centering != "head": + raise ValueError("Cropped slices can only be obtained from an aligned face with " + "'head' centering") + with self._cache["cropped_slices"][1]: + if not self._cache["cropped_slices"][0].get(centering): + size = self.get_cropped_size(centering) + roi = self.get_cropped_roi(centering) + slice_in = [slice(max(roi[1], 0), roi[3]), slice(max(roi[0], 0), roi[2])] + slice_out = [slice(max(roi[1] * -1, 0), size - max(0, roi[3] - self.size)), + slice(max(roi[0] * -1, 0), size - max(0, roi[2] - self.size))] + self._cache["cropped_slices"][0][centering] = {"in": slice_in, "out": slice_out} + logger.trace("centering: %s, cropped_slices: %s", + centering, self._cache["cropped_slices"][0][centering]) + return self._cache["cropped_slices"][0][centering] + + +class PoseEstimate(): + """ Estimates pose from a generic 3D head model for the given 2D face landmarks. + + Parameters + ---------- + landmarks: :class:`numpy.ndarry` + The original 68 point landmarks aligned to 0.0 - 1.0 range + + References + ---------- + Head Pose Estimation using OpenCV and Dlib - https://www.learnopencv.com/tag/solvepnp/ + 3D Model points - http://aifi.isr.uc.pt/Downloads/OpenGL/glAnthropometric3DModel.cpp + """ + def __init__(self, landmarks): + self._distortion_coefficients = np.zeros((4, 1)) # Assuming no lens distortion + self._xyz_2d = None + + self._camera_matrix = self._get_camera_matrix() + self._rotation, self._translation = self._solve_pnp(landmarks) + self._offset = self._get_offset() + + @property + def xyz_2d(self): + """ :class:`numpy.ndarray` projected (x, y) coordinates for each x, y, z point at a + constant distance from adjusted center of the skull (0.5, 0.5) in the 2D space. """ + if self._xyz_2d is None: + xyz = cv2.projectPoints(np.float32([[6, 0, -2.3], [0, 6, -2.3], [0, 0, 3.7]]), + self._rotation, + self._translation, + self._camera_matrix, + self._distortion_coefficients)[0].squeeze() + self._xyz_2d = xyz - self._offset["head"] + return self._xyz_2d + + @property + def offset(self): + """ dict: The amount to offset a standard 0.0 - 1.0 umeyama transformation matrix for a + from the center of the face (between the eyes) or center of the head (middle of skull) + rather than the nose area. """ + return self._offset + + @classmethod + def _get_camera_matrix(cls): + """ Obtain an estimate of the camera matrix based off the original frame dimensions. + + Returns + ------- + :class:`numpy.ndarray` + An estimated camera matrix + """ + focal_length = 4 + camera_matrix = np.array([[focal_length, 0, 0.5], + [0, focal_length, 0.5], + [0, 0, 1]], dtype="double") + logger.trace("camera_matrix: %s", camera_matrix) + return camera_matrix + + def _solve_pnp(self, landmarks): + """ Solve the Perspective-n-Point for the given landmarks. + + Takes 2D landmarks in world space and estimates the rotation and translation vectors + in 3D space. + + Parameters + ---------- + landmarks: :class:`numpy.ndarry` + The original 68 point landmark co-ordinates relating to the original frame + + Returns + ------- + rotation: :class:`numpy.ndarray` + The solved rotation vector + translation: :class:`numpy.ndarray` + The solved translation vector + """ + points = landmarks[[6, 7, 8, 9, 10, 17, 21, 22, 26, 31, 32, 33, 34, + 35, 36, 39, 42, 45, 48, 50, 51, 52, 54, 56, 57, 58]] + _, rotation, translation = cv2.solvePnP(_MEAN_FACE_3D, + points, + self._camera_matrix, + self._distortion_coefficients, + flags=cv2.SOLVEPNP_ITERATIVE) + logger.trace("points: %s, rotation: %s, translation: %s", points, rotation, translation) + return rotation, translation + + def _get_offset(self): + """ Obtain the offset between the original center of the extracted face to the new center + of the head in 2D space. + + Returns + ------- + :class:`numpy.ndarray` + The x, y offset of the new center from the old center. + """ + points = dict(head=(0, 0, -2.3), face=(0, -1.5, 4.2)) + offset = dict() + for key, pnts in points.items(): + center = cv2.projectPoints(np.float32([pnts]), + self._rotation, + self._translation, + self._camera_matrix, + self._distortion_coefficients)[0].squeeze() + logger.trace("center %s: %s", key, center) + offset[key] = center - (0.5, 0.5) + logger.trace("offset: %s", offset) + return offset + + +def _umeyama(source, destination, estimate_scale): + """Estimate N-D similarity transformation with or without scaling. + + Imported, and slightly adapted, directly from: + https://github.com/scikit-image/scikit-image/blob/master/skimage/transform/_geometric.py + + + Parameters + ---------- + source: :class:`numpy.ndarray` + (M, N) array source coordinates. + destination: :class:`numpy.ndarray` + (M, N) array destination coordinates. + estimate_scale: bool + Whether to estimate scaling factor. + + Returns + ------- + :class:`numpy.ndarray` + (N + 1, N + 1) The homogeneous similarity transformation matrix. The matrix contains + NaN values only if the problem is not well-conditioned. + + References + ---------- + .. [1] "Least-squares estimation of transformation parameters between two + point patterns", Shinji Umeyama, PAMI 1991, :DOI:`10.1109/34.88573` + """ + # pylint:disable=invalid-name,too-many-locals + num = source.shape[0] + dim = source.shape[1] + + # Compute mean of source and destination. + src_mean = source.mean(axis=0) + dst_mean = destination.mean(axis=0) + + # Subtract mean from source and destination. + src_demean = source - src_mean + dst_demean = destination - dst_mean + + # Eq. (38). + A = dst_demean.T @ src_demean / num + + # Eq. (39). + d = np.ones((dim,), dtype=np.double) + if np.linalg.det(A) < 0: + d[dim - 1] = -1 + + T = np.eye(dim + 1, dtype=np.double) + + U, S, V = np.linalg.svd(A) + + # Eq. (40) and (43). + rank = np.linalg.matrix_rank(A) + if rank == 0: + return np.nan * T + if rank == dim - 1: + if np.linalg.det(U) * np.linalg.det(V) > 0: + T[:dim, :dim] = U @ V + else: + s = d[dim - 1] + d[dim - 1] = -1 + T[:dim, :dim] = U @ np.diag(d) @ V + d[dim - 1] = s + else: + T[:dim, :dim] = U @ np.diag(d) @ V + + if estimate_scale: + # Eq. (41) and (42). + scale = 1.0 / src_demean.var(axis=0).sum() * (S @ d) + else: + scale = 1.0 + + T[:dim, dim] = dst_mean - scale * (T[:dim, :dim] @ src_mean.T) + T[:dim, :dim] *= scale + + return T diff --git a/lib/alignments.py b/lib/align/alignments.py similarity index 94% rename from lib/alignments.py rename to lib/align/alignments.py index 41e63409f9..29bf08959b 100644 --- a/lib/alignments.py +++ b/lib/align/alignments.py @@ -12,6 +12,7 @@ from lib.utils import FaceswapError logger = logging.getLogger(__name__) # pylint: disable=invalid-name +_VERSION = 2.0 class Alignments(): @@ -36,11 +37,14 @@ class Alignments(): def __init__(self, folder, filename="alignments"): logger.debug("Initializing %s: (folder: '%s', filename: '%s')", self.__class__.__name__, folder, filename) + self._version = _VERSION self._serializer = get_serializer("compressed") self._file = self._get_location(folder, filename) + self._meta = None self._data = self._load() self._update_legacy() self._hashes_to_frame = dict() + self._hashes_to_alignment = dict() self._thumbnails = Thumbnails(self) logger.debug("Initialized %s", self.__class__.__name__) @@ -97,6 +101,23 @@ def hashes_to_frame(self): self._hashes_to_frame.setdefault(face["hash"], dict())[frame_name] = idx return self._hashes_to_frame + @property + def hashes_to_alignment(self): + """ dict: The SHA1 hash of the face mapped to the alignment for the face that the hash + corresponds to. The structure of the dictionary is: + + Notes + ----- + The first time this property is referenced, the dictionary will be created and cached. + Subsequent references will be made to this cached dictionary. + """ + if not self._hashes_to_alignment: + logger.debug("Generating hashes to alignment") + self._hashes_to_alignment = {face["hash"]: face + for val in self._data.values() + for face in val["faces"]} + return self._hashes_to_alignment + @property def mask_summary(self): """ dict: The mask type names stored in the alignments :attr:`data` as key with the number @@ -129,10 +150,15 @@ def video_meta_data(self): @property def thumbnails(self): - """ :class:`~lib.alignments.Thumbnails`: The low resolution thumbnail images that exist + """ :class:`~lib.align.Thumbnails`: The low resolution thumbnail images that exist within the alignments file """ return self._thumbnails + @property + def version(self): + """ float: The alignments file version number. """ + return self._version + # << INIT FUNCTIONS >> # def _get_location(self, folder, filename): @@ -179,6 +205,9 @@ def _get_location(self, folder, filename): def _load(self): """ Load the alignments data from the serialized alignments :attr:`file`. + Populates :attr:`_meta` with the alignment file's meta information as well as returning + the serialized data. + Returns ------- dict: @@ -191,15 +220,20 @@ def _load(self): logger.info("Reading alignments from: '%s'", self._file) data = self._serializer.load(self._file) + self._meta = data.get("__meta__", dict(version=1.0)) + self._version = self._meta["version"] + data = data.get("__data__", data) logger.debug("Loaded alignments") return data def save(self): - """ Write the contents of :attr:`data` to a serialized ``.fsa`` file at the location - :attr:`file`. """ + """ Write the contents of :attr:`data` and :attr:`_meta` to a serialized ``.fsa`` file at + the location :attr:`file`. """ logger.debug("Saving alignments") logger.info("Writing alignments to: '%s'", self._file) - self._serializer.save(self._file, self._data) + data = dict(__meta__=dict(version=self._version), + __data__=self._data) + self._serializer.save(self._file, data) logger.debug("Saved alignments") def backup(self): @@ -709,7 +743,7 @@ class Thumbnails(): Parameters ---------- - alignments: :class:'~lib.alignments.Alignments` + alignments: :class:'~lib.align.Alignments` The parent alignments class that these thumbs belong to """ def __init__(self, alignments): diff --git a/lib/faces_detect.py b/lib/align/detected_face.py similarity index 70% rename from lib/faces_detect.py rename to lib/align/detected_face.py index 370cb5b0e1..75f7c51376 100644 --- a/lib/faces_detect.py +++ b/lib/align/detected_face.py @@ -7,7 +7,7 @@ import cv2 import numpy as np -from lib.aligner import Extract as AlignerExtract, get_align_mat, get_matrix_scaling +from . import AlignedFace, _EXTRACT_RATIOS logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -70,10 +70,10 @@ class DetectedFace(): The hash of the face. This cannot be set until the file is saved due to image compression, but will be set if loading data from :func:`from_alignment` """ - def __init__(self, image=None, x=None, w=None, y=None, h=None, - landmarks_xy=None, mask=None, filename=None): - logger.trace("Initializing %s: (image: %s, x: %s, w: %s, y: %s, h:%s, " - "landmarks_xy: %s, mask: %s, filename: %s)", + def __init__(self, image=None, x=None, w=None, y=None, h=None, landmarks_xy=None, mask=None, + filename=None): + logger.trace("Initializing %s: (image: %s, x: %s, w: %s, y: %s, h:%s, landmarks_xy: %s, " + "mask: %s, filename: %s)", self.__class__.__name__, image.shape if image is not None and image.any() else image, x, w, y, h, landmarks_xy, @@ -89,9 +89,7 @@ def __init__(self, image=None, x=None, w=None, y=None, h=None, self.mask = dict() if mask is None else mask self.hash = None - self.aligned = dict() - self.feed = dict() - self.reference = dict() + self.aligned = None logger.trace("Initialized %s", self.__class__.__name__) @property @@ -114,11 +112,6 @@ def bottom(self): """int: Bottom point (in pixels) of face detection bounding box within the parent image """ return self.y + self.h - @property - def _extract_ratio(self): - """ float: The ratio of padding to add for training images """ - return 0.375 - def add_mask(self, name, mask, affine_matrix, interpolator, storage_size=128): """ Add a :class:`Mask` to this detected face @@ -147,7 +140,8 @@ def add_mask(self, name, mask, affine_matrix, interpolator, storage_size=128): fsmask.add(mask, affine_matrix, interpolator) self.mask[name] = fsmask - def get_landmark_mask(self, size, area, aligned=True, dilation=0, blur_kernel=0, as_zip=False): + def get_landmark_mask(self, size, area, + aligned=True, centering="head", dilation=0, blur_kernel=0, as_zip=False): """ Obtain a single channel mask based on the face's landmark points. Parameters @@ -159,9 +153,17 @@ def get_landmark_mask(self, size, area, aligned=True, dilation=0, blur_kernel=0, area: ["mouth", "eyes"] The type of mask to obtain. `face` is a full face mask the others are masks for those specific areas - aligned: bool + aligned: bool, optional ``True`` if the returned mask should be for an aligned face. ``False`` if a full frame - mask should be returned + mask should be returned. Default ``True`` + centering: ["legacy", "face", "head"], optional + Only used if `aligned`=``True``. The centering for the landmarks based mask. Should be + the same as the centering used for the extracted face that this mask will be applied + to. "legacy" places the nose in the center of the image (the original method for + aligning). "face" aligns for the nose to be in the center of the face (top to bottom) + but the center of the skull for left to right. "head" aligns for the center of the + skull (in 3D space) being the center of the extracted image, with the crop holding the + full head. Default: `"face"` dilation: int, optional The amount of dilation to apply to the mask. `0` for none. Default: `0` blur_kernel: int, optional @@ -180,13 +182,14 @@ def get_landmark_mask(self, size, area, aligned=True, dilation=0, blur_kernel=0, # TODO Face mask generation from landmarks logger.trace("size: %s, area: %s, aligned: %s, dilation: %s, blur_kernel: %s, as_zip: %s", size, area, aligned, dilation, blur_kernel, as_zip) - areas = dict(mouth=[slice(48, 60)], - eyes=[slice(36, 42), slice(42, 48)]) - if aligned and self.aligned.get("size") != size: - self.load_aligned(None, size=size, force=True) - size = (size, size) if aligned else size - landmarks = self.aligned_landmarks if aligned else self.landmarks_xy - points = [landmarks[zone] for zone in areas[area]] + areas = dict(mouth=[slice(48, 60)], eyes=[slice(36, 42), slice(42, 48)]) + if aligned: + face = AlignedFace(self.landmarks_xy, centering=centering, size=size) + landmarks = face.landmarks + size = (size, size) + else: + landmarks = self.landmarks_xy + points = [landmarks[zone] for zone in areas[area]] # pylint:disable=unsubscriptable-object mask = _LandmarksMask(size, points, dilation=dilation, blur_kernel=blur_kernel) retval = mask.get(as_zip=as_zip) return retval @@ -254,9 +257,7 @@ def from_alignment(self, alignment, image=None, with_thumb=False): # Manual tool does not know the final hash so default to None self.hash = alignment.get("hash", None) # Manual tool and legacy alignments will not have a mask - self.aligned = dict() - self.feed = dict() - self.reference = dict() + self.aligned = None if alignment.get("mask", None) is not None: self.mask = dict() @@ -276,16 +277,16 @@ def _image_to_face(self, image): self.left: self.right] # <<< Aligned Face methods and properties >>> # - def load_aligned(self, image, size=256, dtype=None, force=False): + def load_aligned(self, image, size=256, dtype=None, centering="head", force=False): """ Align a face from a given image. Aligning a face is a relatively expensive task and is not required for all uses of - the :class:`~lib.faces_detect.DetectedFace` object, so call this function explicitly to + the :class:`~lib.align.DetectedFace` object, so call this function explicitly to load an aligned face. - This method plugs into :mod:`lib.aligner` to perform face alignment based on this face's - ``landmarks_xy``. If the face has already been aligned, then this function will return - having performed no action. + This method plugs into :mod:`lib.align.AlignedFace` to perform face alignment based on this + face's ``landmarks_xy``. If the face has already been aligned, then this function will + return having performed no action. Parameters ---------- @@ -295,264 +296,32 @@ def load_aligned(self, image, size=256, dtype=None, force=False): The size of the output face in pixels dtype: str, optional Optionally set a ``dtype`` for the final face to be formatted in. Default: ``None`` + centering: ["legacy", "face", "head"], optional + The type of extracted face that should be loaded. "legacy" places the nose in the + center of the image (the original method for aligning). "face" aligns for the nose to + be in the center of the face (top to bottom) but the center of the skull for left to + right. "head" aligns for the center of the skull (in 3D space) being the center of the + extracted image, with the crop holding the full head. + Default: `"head"` force: bool, optional Force an update of the aligned face, even if it is already loaded. Default: ``False`` Notes ----- - This method must be executed to get access to the following `properties`: - - :func:`original_roi` - - :func:`aligned_landmarks` - - :func:`aligned_face` - - :func:`adjusted_interpolators` + This method must be executed to get access to the following an :class:`AlignedFace` object """ if self.aligned and not force: # Don't reload an already aligned face logger.trace("Skipping alignment calculation for already aligned face") else: logger.trace("Loading aligned face: (size: %s, dtype: %s)", size, dtype) - padding = int(size * self._extract_ratio) // 2 - self.aligned["size"] = size - self.aligned["padding"] = padding - self.aligned["matrix"] = get_align_mat(self) - self.aligned["face"] = None - if image is not None and (self.aligned["face"] is None or force): - logger.trace("Getting aligned face") - face = AlignerExtract().transform(image, self.aligned["matrix"], size, padding) - self.aligned["face"] = face if dtype is None else face.astype(dtype) - - logger.trace("Loaded aligned face: %s", {k: str(v) if isinstance(v, np.ndarray) else v - for k, v in self.aligned.items() - if k != "face"}) - - def _padding_from_coverage(self, size, coverage_ratio): - """ Return the image padding for a face from coverage_ratio set against a - pre-padded training image """ - adjusted_ratio = coverage_ratio - (1 - self._extract_ratio) - padding = round((size * adjusted_ratio) / 2) - logger.trace(padding) - return padding - - def load_feed_face(self, image, size=64, coverage_ratio=0.625, dtype=None, - is_aligned_face=False): - """ Align a face in the correct dimensions for feeding into a model. - - Parameters - ---------- - image: numpy.ndarray - The image that contains the face to be aligned - size: int - The size of the face in pixels to be fed into the model - coverage_ratio: float, optional - the ratio of the extracted image that was used for training. Default: `0.625` - dtype: str, optional - Optionally set a ``dtype`` for the final face to be formatted in. Default: ``None`` - is_aligned_face: bool, optional - Indicates that the :attr:`image` is an aligned face rather than a frame. - Default: ``False`` - - Notes - ----- - This method must be executed to get access to the following `properties`: - - :func:`feed_face` - - :func:`feed_interpolators` - """ - logger.trace("Loading feed face: (size: %s, coverage_ratio: %s, dtype: %s, " - "is_aligned_face: %s)", size, coverage_ratio, dtype, is_aligned_face) - - self.feed["size"] = size - self.feed["padding"] = self._padding_from_coverage(size, coverage_ratio) - self.feed["matrix"] = get_align_mat(self) - if is_aligned_face: - original_size = image.shape[0] - interp = cv2.INTER_CUBIC if original_size < size else cv2.INTER_AREA - face = cv2.resize(image, (size, size), interpolation=interp) - else: - face = AlignerExtract().transform(image, - self.feed["matrix"], - size, - self.feed["padding"]) - self.feed["face"] = face if dtype is None else face.astype(dtype) - - logger.trace("Loaded feed face. (face_shape: %s, matrix: %s)", - self.feed_face.shape, self.feed_matrix) - - def load_reference_face(self, image, size=64, coverage_ratio=0.625, dtype=None): - """ Align a face in the correct dimensions for reference against the output from a model. - - Parameters - ---------- - image: numpy.ndarray - The image that contains the face to be aligned - size: int - The size of the face in pixels to be fed into the model - coverage_ratio: float, optional - the ratio of the extracted image that was used for training. Default: `0.625` - dtype: str, optional - Optionally set a ``dtype`` for the final face to be formatted in. Default: ``None`` - - Notes - ----- - This method must be executed to get access to the following `properties`: - - :func:`reference_face` - - :func:`reference_landmarks` - - :func:`reference_matrix` - - :func:`reference_interpolators` - """ - logger.trace("Loading reference face: (size: %s, coverage_ratio: %s, dtype: %s)", - size, coverage_ratio, dtype) - - self.reference["size"] = size - self.reference["padding"] = self._padding_from_coverage(size, coverage_ratio) - self.reference["matrix"] = get_align_mat(self) - - face = AlignerExtract().transform(image, - self.reference["matrix"], - size, - self.reference["padding"]) - self.reference["face"] = face if dtype is None else face.astype(dtype) - - logger.trace("Loaded reference face. (face_shape: %s, matrix: %s)", - self.reference_face.shape, self.reference_matrix) - - @property - def original_roi(self): - """ numpy.ndarray: The location of the extracted face box within the original frame. - Only available after :func:`load_aligned` has been called, otherwise returns ``None``""" - if not self.aligned: - return None - roi = AlignerExtract().get_original_roi(self.aligned["matrix"], - self.aligned["size"], - self.aligned["padding"]) - logger.trace("Returning: %s", roi) - return roi - - @property - def aligned_landmarks(self): - """ numpy.ndarray: The 68 point landmarks location transposed to the extracted face box. - Only available after :func:`load_aligned` has been called, otherwise returns ``None``""" - if not self.aligned: - return None - landmarks = AlignerExtract().transform_points(self.landmarks_xy, - self.aligned["matrix"], - self.aligned["size"], - self.aligned["padding"]) - logger.trace("Returning: %s", landmarks) - return landmarks - - @property - def aligned_face(self): - """ numpy.ndarray: The aligned detected face. Only available after :func:`load_aligned` - has been called with an image, otherwise returns ``None`` """ - return self.aligned.get("face", None) - - @property - def _adjusted_matrix(self): - """ numpy.ndarray: Adjusted matrix for size/padding combination. Only available after - :func:`load_aligned` has been called, otherwise returns ``None``""" - if not self.aligned: - return None - mat = AlignerExtract().transform_matrix(self.aligned["matrix"], - self.aligned["size"], - self.aligned["padding"]) - logger.trace("Returning: %s", mat) - return mat - - @property - def adjusted_interpolators(self): - """ tuple: Tuple of (`interpolator` and `reverse interpolator`) for the adjusted matrix. - Only available after :func:`load_aligned` has been called, otherwise returns ``None``""" - if not self.aligned: - return None - return get_matrix_scaling(self._adjusted_matrix) - - @property - def feed_face(self): - """ numpy.ndarray: The aligned face sized for feeding into a model. Only available after - :func:`load_feed_face` has been called with an image, otherwise returns ``None`` """ - if not self.feed: - return None - return self.feed["face"] - - @property - def feed_landmarks(self): - """ numpy.ndarray: The 68 point landmarks location transposed to the feed face box. - Only available after :func:`load_reference_face` has been called, otherwise returns - ``None``""" - if not self.feed: - return None - landmarks = AlignerExtract().transform_points(self.landmarks_xy, - self.feed["matrix"], - self.feed["size"], - self.feed["padding"]) - logger.trace("Returning: %s", landmarks) - return landmarks - - @property - def feed_matrix(self): - """ numpy.ndarray: The adjusted matrix face sized for feeding into a model. Only available - after :func:`load_feed_face` has been called with an image, otherwise returns ``None`` """ - if not self.feed: - return None - mat = AlignerExtract().transform_matrix(self.feed["matrix"], - self.feed["size"], - self.feed["padding"]) - logger.trace("Returning: %s", mat) - return mat - - @property - def feed_interpolators(self): - """ tuple: Tuple of (`interpolator` and `reverse interpolator`) for the adjusted feed - matrix. Only available after :func:`load_feed_face` has been called, otherwise returns - ``None``""" - if not self.feed: - return None - return get_matrix_scaling(self.feed_matrix) - - @property - def reference_face(self): - """ numpy.ndarray: The aligned face sized for reference against a face coming out of a - model. Only available after :func:`load_reference_face` has been called, otherwise - returns ``None``""" - if not self.reference: - return None - return self.reference["face"] - - @property - def reference_landmarks(self): - """ numpy.ndarray: The 68 point landmarks location transposed to the reference face box. - Only available after :func:`load_reference_face` has been called, otherwise returns - ``None``""" - if not self.reference: - return None - landmarks = AlignerExtract().transform_points(self.landmarks_xy, - self.reference["matrix"], - self.reference["size"], - self.reference["padding"]) - logger.trace("Returning: %s", landmarks) - return landmarks - - @property - def reference_matrix(self): - """ numpy.ndarray: The adjusted matrix face sized for reference against a face coming out of - a model. Only available after :func:`load_reference_face` has been called, otherwise - returns ``None``""" - if not self.reference: - return None - mat = AlignerExtract().transform_matrix(self.reference["matrix"], - self.reference["size"], - self.reference["padding"]) - logger.trace("Returning: %s", mat) - return mat - - @property - def reference_interpolators(self): - """ tuple: Tuple of (`interpolator` and `reverse interpolator`) for the reference - matrix. Only available after :func:`load_reference_face` has been called, otherwise - returns ``None``""" - if not self.reference: - return None - return get_matrix_scaling(self.reference_matrix) + self.aligned = AlignedFace(self.landmarks_xy, + image=image, + centering=centering, + size=size, + coverage_ratio=1.0, + dtype=dtype, + is_aligned=False) class _LandmarksMask(): # pylint:disable=too-few-public-methods @@ -644,7 +413,6 @@ class Mask(): stored_size: int The size, in pixels, of the stored mask across its height and width. """ - def __init__(self, storage_size=128): self.stored_size = storage_size @@ -655,6 +423,7 @@ def __init__(self, storage_size=128): self._blur = dict() self._blur_kernel = 0 self._threshold = 0.0 + self._sub_crop = dict(size=None, slice_in=[], slice_out=[]) self.set_blur_and_threshold() @property @@ -673,6 +442,11 @@ def mask(self): mask, self._blur["kernel"], passes=self._blur["passes"]).blurred + if self._sub_crop["size"]: # Crop the mask to the given centering + out = np.zeros((self._sub_crop["size"], self._sub_crop["size"], 1), dtype=mask.dtype) + slice_in, slice_out = self._sub_crop["slice_in"], self._sub_crop["slice_out"] + out[slice_out[0], slice_out[1], :] = mask[slice_in[0], slice_in[1], :] + mask = out logger.trace("mask shape: %s", mask.shape) return mask @@ -786,6 +560,37 @@ def set_blur_and_threshold(self, self._blur["passes"] = blur_passes self._threshold = (threshold / 100.0) * 255.0 + def set_sub_crop(self, offset): + """ Set the internal crop area of the mask to be returned. + + This impacts the returned mask from :attr:`mask` if the requested mask is required for + different face centering than what has been stored. + + Parameters + ---------- + offset: :class:`numpy.ndarray` + The (x, y) offset from the center point to return the mask for + + Notes + ----- + All masks are currently stored with `face` centering and all crops are for 'legacy` + centering. This may change in future + """ + src_size = self.stored_size - (self.stored_size * _EXTRACT_RATIOS["face"]) + offset *= ((self.stored_size - (src_size / 2)) / 2) + center = np.rint(offset + self.stored_size / 2).astype("int32") + + crop_size = 2 * int(np.rint(src_size / (1 - _EXTRACT_RATIOS["legacy"]) / 2)) + roi = np.array([center - crop_size // 2, center + crop_size // 2]).ravel() + + self._sub_crop["size"] = crop_size + self._sub_crop["slice_in"] = [slice(max(roi[1], 0), roi[3]), slice(max(roi[0], 0), roi[2])] + self._sub_crop["slice_out"] = [slice(max(roi[1] * -1, 0), + crop_size - max(0, roi[3] - self.stored_size)), + slice(max(roi[0] * -1, 0), + crop_size - max(0, roi[2] - self.stored_size))] + logger.trace("src_size: %s, roi: %s, sub_crop: %s", src_size, roi, self._sub_crop) + def _adjust_affine_matrix(self, mask_size, affine_matrix): """ Adjust the affine matrix for the mask's storage size diff --git a/lib/aligner.py b/lib/aligner.py deleted file mode 100644 index d6abb7a612..0000000000 --- a/lib/aligner.py +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env python3 -""" Aligner for faceswap.py """ - -import logging - -import cv2 -import numpy as np - -from lib.umeyama import umeyama - -logger = logging.getLogger(__name__) # pylint: disable=invalid-name - - -class Extract(): - """ Based on the original https://www.reddit.com/r/deepfakes/ - code sample + contribs """ - - def extract(self, image, face, size): - """ Extract a face from an image """ - logger.trace("size: %s", size) - padding = int(size * 0.1875) - alignment = get_align_mat(face) - extracted = self.transform(image, alignment, size, padding) - logger.trace("Returning face and alignment matrix: (alignment_matrix: %s)", alignment) - return extracted, alignment - - @staticmethod - def transform_matrix(mat, size, padding): - """ Transform the matrix for current size and padding """ - logger.trace("size: %s. padding: %s", size, padding) - matrix = mat * (size - 2 * padding) - matrix[:, 2] += padding - logger.trace("Returning: %s", matrix) - return matrix - - def transform(self, image, mat, size, padding=0): - """ Transform Image """ - logger.trace("matrix: %s, size: %s. padding: %s", mat, size, padding) - matrix = self.transform_matrix(mat, size, padding) - interpolators = get_matrix_scaling(matrix) - retval = cv2.warpAffine(image, matrix, (size, size), flags=interpolators[0]) - return retval - - def transform_points(self, points, mat, size, padding=0): - """ Transform points along matrix """ - logger.trace("points: %s, matrix: %s, size: %s. padding: %s", points, mat, size, padding) - matrix = self.transform_matrix(mat, size, padding) - points = np.expand_dims(points, axis=1) - points = cv2.transform(points, matrix, points.shape) - retval = np.squeeze(points) - logger.trace("Returning: %s", retval) - return retval - - def get_original_roi(self, mat, size, padding=0): - """ Return the square aligned box location on the original image """ - logger.trace("matrix: %s, size: %s. padding: %s", mat, size, padding) - matrix = self.transform_matrix(mat, size, padding) - points = np.array([[0, 0], [0, size - 1], [size - 1, size - 1], [size - 1, 0]], np.int32) - points = points.reshape((-1, 1, 2)) - matrix = cv2.invertAffineTransform(matrix) - logger.trace("Returning: (points: %s, matrix: %s", points, matrix) - return cv2.transform(points, matrix) - - @staticmethod - def get_feature_mask(aligned_landmarks_68, size, padding=0, dilation=30): - """ Return the face feature mask """ - logger.trace("aligned_landmarks_68: %s, size: %s, padding: %s, dilation: %s", - aligned_landmarks_68, size, padding, dilation) - scale = size - 2 * padding - translation = padding - pad_mat = np.matrix([[scale, 0.0, translation], [0.0, scale, translation]]) - aligned_landmarks_68 = np.expand_dims(aligned_landmarks_68, axis=1) - aligned_landmarks_68 = cv2.transform(aligned_landmarks_68, - pad_mat, - aligned_landmarks_68.shape) - aligned_landmarks_68 = np.squeeze(aligned_landmarks_68) - l_eye_points = aligned_landmarks_68[42:48].tolist() - l_brow_points = aligned_landmarks_68[22:27].tolist() - r_eye_points = aligned_landmarks_68[36:42].tolist() - r_brow_points = aligned_landmarks_68[17:22].tolist() - nose_points = aligned_landmarks_68[27:36].tolist() - chin_points = aligned_landmarks_68[8:11].tolist() - mouth_points = aligned_landmarks_68[48:68].tolist() - # TODO remove excessive reshapes and flattens - - l_eye = np.array(l_eye_points + l_brow_points).reshape((-1, 2)).astype('int32').flatten() - r_eye = np.array(r_eye_points + r_brow_points).reshape((-1, 2)).astype('int32').flatten() - mouth = np.array(mouth_points + nose_points + chin_points) - mouth = mouth.reshape((-1, 2)).astype('int32').flatten() - l_eye_hull = cv2.convexHull(l_eye.reshape((-1, 2))) - r_eye_hull = cv2.convexHull(r_eye.reshape((-1, 2))) - mouth_hull = cv2.convexHull(mouth.reshape((-1, 2))) - - mask = np.zeros((size, size, 3), dtype=float) - cv2.fillConvexPoly(mask, l_eye_hull, (1, 1, 1)) - cv2.fillConvexPoly(mask, r_eye_hull, (1, 1, 1)) - cv2.fillConvexPoly(mask, mouth_hull, (1, 1, 1)) - - if dilation > 0: - kernel = np.ones((dilation, dilation), np.uint8) - mask = cv2.dilate(mask, kernel, iterations=1) - - logger.trace("Returning: %s", mask) - return mask - - -def get_matrix_scaling(mat): - """ Get the correct interpolator """ - x_scale = np.sqrt(mat[0, 0] * mat[0, 0] + mat[0, 1] * mat[0, 1]) - y_scale = (mat[0, 0] * mat[1, 1] - mat[0, 1] * mat[1, 0]) / x_scale - avg_scale = (x_scale + y_scale) * 0.5 - if avg_scale >= 1.: - interpolators = cv2.INTER_CUBIC, cv2.INTER_AREA - else: - interpolators = cv2.INTER_AREA, cv2.INTER_CUBIC - logger.trace("interpolator: %s, inverse interpolator: %s", interpolators[0], interpolators[1]) - return interpolators - - -def get_align_mat(face): - """ Return the alignment Matrix """ - mat_umeyama = umeyama(face.landmarks_xy[17:], True)[0:2] - return mat_umeyama diff --git a/lib/cli/args.py b/lib/cli/args.py index b0c12bd980..774fb4e4e7 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -427,6 +427,21 @@ def get_optional_arguments(): "face." "\nL|hist: Equalize the histograms on the RGB channels." "\nL|mean: Normalize the face colors to the mean.")) + argument_list.append(dict( + opts=("-rf", "--re-feed"), + action=Slider, + min_max=(0, 10), + rounding=1, + type=int, + dest="re_feed", + default=0, + group="plugins", + help="The number of times to re-feed the detected face into the aligner. Each time " + "the face is re-fed into the aligner the bounding box is adjusted by a small " + "amount. The final landmarks are then averaged from each iteration. Helps to " + "remove 'micro-jitter' but at the cost of slower extraction speed. The more " + "times the face is re-fed into the aligner, the less micro-jitter should occur " + "but the longer extraction will take.")) argument_list.append(dict( opts=("-r", "--rotate-images"), type=str, @@ -487,6 +502,17 @@ def get_optional_arguments(): "recognition. Lower values are stricter. NB: Using face filter will " "significantly decrease extraction speed and its accuracy cannot be " "guaranteed.")) + argument_list.append(dict( + opts=("-sz", "--size"), + action=Slider, + min_max=(256, 1024), + rounding=64, + type=int, + default=512, + group="output", + help="The output size of extracted faces. Make sure that the model you intend to " + "train supports your required size. This will only need to be changed for " + "hi-res models.")) argument_list.append(dict( opts=("-een", "--extract-every-n"), action=Slider, @@ -499,17 +525,6 @@ def get_optional_arguments(): help="Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " "of 10 will extract faces from every 10th frame.")) - argument_list.append(dict( - opts=("-sz", "--size"), - action=Slider, - min_max=(128, 512), - rounding=64, - type=int, - default=256, - group="output", - help="The output size of extracted faces. Make sure that the model you intend to " - "train supports your required size. This will only need to be changed for " - "hi-res models.")) argument_list.append(dict( opts=("-si", "--save-interval"), action=Slider, @@ -773,13 +788,6 @@ def get_optional_arguments(): "Setting this to 0 will use the maximum available. No matter what you set " "this to, it will never attempt to use more processes than are available on " "your system. If singleprocess is enabled this setting will be ignored.")) - argument_list.append(dict( - opts=("-d", "--distributed"), - action="store_true", - default=False, - backend="nvidia", - group="settings", - help="Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs.")) argument_list.append(dict( opts=("-t", "--trainer"), type=str.lower, @@ -1018,9 +1026,9 @@ def get_argument_list(): rounding=25, type=int, dest="preview_scale", - default=50, + default=100, group="preview", - help="Percentage amount to scale the preview by.")) + help="Percentage amount to scale the preview by. 100%% is the model output size.")) argument_list.append(dict( opts=("-p", "--preview"), action="store_true", diff --git a/lib/config.py b/lib/config.py index 77a63bcd20..096cbc52d5 100644 --- a/lib/config.py +++ b/lib/config.py @@ -34,7 +34,8 @@ def changeable_items(self): Return a dict of config items with their set values for items that can be altered after the model has been created """ retval = dict() - for sect in ("global", self.section): + sections = [sect for sect in self.config.sections() if sect.startswith("global")] + for sect in sections + [self.section]: if sect not in self.defaults: continue for key, val in self.defaults[sect].items(): diff --git a/lib/convert.py b/lib/convert.py index 7a9cc47e1f..e61e680528 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -21,6 +21,8 @@ class Converter(): The size of the face, in pixels, that is output from the Faceswap model coverage_ratio: float The ratio of the training image that was used for training the Faceswap model + centering: str + The extracted face centering that the model was trained on (`"face"` or "`legacy`") draw_transparent: bool Whether the final output should be drawn onto a transparent layer rather than the original frame. Only available with certain writer plugins. @@ -35,14 +37,15 @@ class Converter(): Optional location of custom configuration ``ini`` file. If ``None`` then use the default config location. Default: ``None`` """ - def __init__(self, output_size, coverage_ratio, draw_transparent, pre_encode, + def __init__(self, output_size, coverage_ratio, centering, draw_transparent, pre_encode, arguments, configfile=None): - logger.debug("Initializing %s: (output_size: %s, coverage_ratio: %s, draw_transparent: " - "%s, pre_encode: %s, arguments: %s, configfile: %s)", self.__class__.__name__, - output_size, coverage_ratio, draw_transparent, pre_encode, arguments, - configfile) + logger.debug("Initializing %s: (output_size: %s, coverage_ratio: %s, centering: %s, " + "draw_transparent: %s, pre_encode: %s, arguments: %s, configfile: %s)", + self.__class__.__name__, output_size, coverage_ratio, centering, + draw_transparent, pre_encode, arguments, configfile) self._output_size = output_size self._coverage_ratio = coverage_ratio + self._centering = centering self._draw_transparent = draw_transparent self._writer_pre_encode = pre_encode self._args = arguments @@ -226,17 +229,21 @@ def _get_new_image(self, predicted, frame_size): background = predicted["image"] / np.array(255.0, dtype="float32") placeholder[:, :, :3] = background - for new_face, detected_face in zip(predicted["swapped_faces"], - predicted["detected_faces"]): + for new_face, detected_face, reference_face in zip(predicted["swapped_faces"], + predicted["detected_faces"], + predicted["reference_faces"]): predicted_mask = new_face[:, :, -1] if new_face.shape[2] == 4 else None new_face = new_face[:, :, :3] - interpolator = detected_face.reference_interpolators[1] + interpolator = reference_face.interpolators[1] - new_face = self._pre_warp_adjustments(new_face, detected_face, predicted_mask) + new_face = self._pre_warp_adjustments(new_face, + detected_face, + reference_face, + predicted_mask) # Warp face with the mask cv2.warpAffine(new_face, - detected_face.reference_matrix, + reference_face.adjusted_matrix, frame_size, placeholder, flags=cv2.WARP_INVERSE_MAP | interpolator, @@ -247,7 +254,7 @@ def _get_new_image(self, predicted, frame_size): return placeholder, background - def _pre_warp_adjustments(self, new_face, detected_face, predicted_mask): + def _pre_warp_adjustments(self, new_face, detected_face, reference_face, predicted_mask): """ Run any requested adjustments that can be performed on the raw output from the Faceswap model. @@ -258,8 +265,10 @@ def _pre_warp_adjustments(self, new_face, detected_face, predicted_mask): ---------- new_face: :class:`numpy.ndarray` The swapped face received from the faceswap model. - detected_face: :class:`~lib.faces_detect.DetectedFace` + detected_face: :class:`~lib.align.DetectedFace` The detected_face object as defined in :class:`scripts.convert.Predictor` + reference_face: :class:`~lib.align.AlignedFace` + The aligned face object sized to the model output of the original face for reference predicted_mask: :class:`numpy.ndarray` or ``None`` The predicted mask output from the Faceswap model. ``None`` if the model did not learn a mask @@ -272,9 +281,12 @@ def _pre_warp_adjustments(self, new_face, detected_face, predicted_mask): """ logger.trace("new_face shape: %s, predicted_mask shape: %s", new_face.shape, predicted_mask.shape if predicted_mask is not None else None) - old_face = detected_face.reference_face[..., :3] / 255.0 + old_face = reference_face.face[..., :3] / 255.0 new_face = self._adjustments["box"].run(new_face) - new_face, raw_mask = self._get_image_mask(new_face, detected_face, predicted_mask) + new_face, raw_mask = self._get_image_mask(new_face, + detected_face, + predicted_mask, + reference_face) if self._adjustments["color"] is not None: new_face = self._adjustments["color"].run(old_face, new_face, raw_mask) if self._adjustments["seamless"] is not None: @@ -282,7 +294,7 @@ def _pre_warp_adjustments(self, new_face, detected_face, predicted_mask): logger.trace("returning: new_face shape %s", new_face.shape) return new_face - def _get_image_mask(self, new_face, detected_face, predicted_mask): + def _get_image_mask(self, new_face, detected_face, predicted_mask, reference_face): """ Return any selected image mask and intersect with any box mask. Places the requested mask into the new face's Alpha channel, intersecting with any box @@ -292,18 +304,25 @@ def _get_image_mask(self, new_face, detected_face, predicted_mask): ---------- new_face: :class:`numpy.ndarray` The swapped face received from the faceswap model, with any box mask applied - detected_face: :class:`~lib.faces_detect.DetectedFace` + detected_face: :class:`~lib.DetectedFace` The detected_face object as defined in :class:`scripts.convert.Predictor` predicted_mask: :class:`numpy.ndarray` or ``None`` The predicted mask output from the Faceswap model. ``None`` if the model did not learn a mask + reference_face: :class:`~lib.align.AlignedFace` + The aligned face object sized to the model output of the original face for reference Returns + ------- :class:`numpy.ndarray` The swapped face with the requested mask added to the Alpha channel """ logger.trace("Getting mask. Image shape: %s", new_face.shape) - mask, raw_mask = self._adjustments["mask"].run(detected_face, predicted_mask) + if self._centering == "legacy": + crop_offset = reference_face.pose.offset["face"] * -1 + else: + crop_offset = np.array((0, 0)) + mask, raw_mask = self._adjustments["mask"].run(detected_face, crop_offset, predicted_mask) if new_face.shape[2] == 4: logger.trace("Combining mask with alpha channel box mask") new_face[:, :, -1] = np.minimum(new_face[:, :, -1], mask.squeeze()) diff --git a/lib/face_filter.py b/lib/face_filter.py index 096709d31e..c6589a386f 100644 --- a/lib/face_filter.py +++ b/lib/face_filter.py @@ -3,9 +3,10 @@ import logging +from lib.align import AlignedFace from lib.vgg_face import VGGFace from lib.image import read_image -from plugins.extract.pipeline import Extractor +from plugins.extract.pipeline import Extractor, ExtractMedia logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -32,8 +33,8 @@ def __init__(self, reference_file_paths, nreference_file_paths, detector, aligne # Whilst this vram can still be used, the pipeline for the extraction process can't see # it so thinks there is not enough vram available. # Either the pipeline will need to be changed to be re-usable by face-filter and extraction - # Or another vram measurement technique will need to be implemented to for when TF has - # already performed allocation. For now we force CPU detectors. + # Or another vram measurement technique will need to be implemented to for when tensorflow + # has already performed allocation. For now we force CPU detectors. # self.align_faces(detector, aligner, multiprocess) self.align_faces("cv2-dnn", "cv2-dnn", "none", multiprocess) @@ -69,11 +70,11 @@ def align_faces(self, detector_name, aligner_name, masker_name, multiprocess): def run_extractor(self, extractor): """ Run extractor to get faces """ for _ in range(extractor.passes): - self.queue_images(extractor) extractor.launch() + self.queue_images(extractor) for faces in extractor.detected_faces(): - filename = faces["filename"] - detected_faces = faces["detected_faces"] + filename = faces.filename + detected_faces = faces.detected_faces if len(detected_faces) > 1: logger.warning("Multiple faces found in %s file: '%s'. Using first detected " "face.", self.filters[filename]["type"], filename) @@ -84,11 +85,8 @@ def queue_images(self, extractor): in_queue = extractor.input_queue for fname, img in self.filters.items(): logger.debug("Adding to filter queue: '%s' (%s)", fname, img["type"]) - feed_dict = dict(filename=fname, image=img["image"]) - if img.get("detected_faces", None): - feed_dict["detected_faces"] = img["detected_faces"] - logger.debug("Queueing filename: '%s' items: %s", - fname, list(feed_dict.keys())) + feed_dict = ExtractMedia(fname, img["image"], detected_faces=img.get("detected_faces")) + logger.debug("Queueing filename: '%s' items: %s", fname, feed_dict) in_queue.put(feed_dict) logger.debug("Sending EOF to filter queue") in_queue.put("EOF") @@ -99,8 +97,8 @@ def load_aligned_face(self): logger.debug("Loading aligned face: '%s'", filename) image = face["image"] detected_face = face["detected_face"] - detected_face.load_aligned(image, size=224) - face["face"] = detected_face.aligned_face + detected_face.load_aligned(image, centering="legacy", size=224) + face["face"] = detected_face.aligned.face del face["image"] logger.debug("Loaded aligned face: ('%s', shape: %s)", filename, face["face"].shape) @@ -114,11 +112,25 @@ def get_filter_encodings(self): face["encoding"] = encodings del face["face"] - def check(self, detected_face): - """ Check the extracted Face """ + def check(self, image, detected_face): + """ Check the extracted Face + + Parameters + ---------- + image: :class:`numpy.ndarray` + The original frame that contains the face to be checked + detected_face: :class:`lib.align.DetectedFace` + The detected face object that contains the face to be checked + + Returns + ------- + bool + ``True`` if the face matches a filter otherwise ``False`` + """ logger.trace("Checking face with FaceFilter") distances = {"filter": list(), "nfilter": list()} - encodings = self.vgg_face.predict(detected_face.aligned_face) + feed = AlignedFace(detected_face.landmarks_xy, image=image, size=224, centering="legacy") + encodings = self.vgg_face.predict(feed.face) for filt in self.filters.values(): similarity = self.vgg_face.find_cosine_similiarity(filt["encoding"], encodings) distances[filt["type"]].append(similarity) @@ -144,7 +156,7 @@ def check(self, detected_face): "{}, nfilter: {})".format(round(mins["filter"], 2), round(mins["nfilter"], 2))) retval = False elif distances["filter"] and distances["nfilter"]: - # k-nn classifier + # k-nearest-neighbor classifier var_k = min(5, min(len(distances["filter"]), len(distances["nfilter"])) + 1) var_n = sum(list(map(lambda x: x[0], list(sorted([(1, d) for d in distances["filter"]] + diff --git a/lib/gui/stats.py b/lib/gui/stats.py index 7cbb323526..6827a8d7c0 100644 --- a/lib/gui/stats.py +++ b/lib/gui/stats.py @@ -78,7 +78,7 @@ def logging_disabled(self): @property def session_ids(self): - """ list: The sorted list of all existing session ids `int`s in the state file """ + """ list: The sorted list of all existing session ids in the state file """ return self._tb_logs.session_ids def _load_state_file(self): diff --git a/lib/image.py b/lib/image.py index 55a315e11f..044a9de4d7 100644 --- a/lib/image.py +++ b/lib/image.py @@ -429,7 +429,7 @@ def encode_image_with_hash(image, extension): return image_hash, encoded_image -def generate_thumbnail(image, size=80, quality=60): +def generate_thumbnail(image, size=96, quality=60): """ Generate a jpg thumbnail for the given image. Parameters diff --git a/lib/logger.py b/lib/logger.py index 35edb9c259..bae8c1fe66 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -189,8 +189,8 @@ def log_setup(loglevel, log_file, command, is_gui=False): numeric_loglevel = get_loglevel(loglevel) root_loglevel = min(logging.DEBUG, numeric_loglevel) rootlogger = _set_root_logger(loglevel=root_loglevel) - log_format = FaceswapFormatter("%(asctime)s %(processName)-15s %(threadName)-15s " - "%(module)-15s %(funcName)-25s %(levelname)-8s %(message)s", + log_format = FaceswapFormatter("%(asctime)s %(processName)-15s %(threadName)-30s " + "%(module)-15s %(funcName)-30s %(levelname)-8s %(message)s", datefmt="%m/%d/%Y %H:%M:%S") f_handler = _file_handler(numeric_loglevel, log_file, log_format, command) s_handler = _stream_handler(numeric_loglevel, is_gui) diff --git a/lib/model/session.py b/lib/model/session.py index 09d9c39036..ea644c3ee3 100644 --- a/lib/model/session.py +++ b/lib/model/session.py @@ -48,8 +48,8 @@ class KSession(): """ def __init__(self, name, model_path, model_kwargs=None, allow_growth=False, exclude_gpus=None): logger.trace("Initializing: %s (name: %s, model_path: %s, model_kwargs: %s, " - "allow_growth: %s, exclude_gpus)", self.__class__.__name__, name, model_path, - model_kwargs, allow_growth, exclude_gpus) + "allow_growth: %s, exclude_gpus: %s)", self.__class__.__name__, name, + model_path, model_kwargs, allow_growth, exclude_gpus) self._name = name self._backend = get_backend() self._set_session(allow_growth, exclude_gpus) diff --git a/lib/training_data.py b/lib/training_data.py index 48c811f798..eeede2e076 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -18,6 +18,10 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name +# TODO Face masks appear to be coming out too big? +# TODO Test _get_closest_match for speed and correctness + + class TrainingDataGenerator(): # pylint:disable=too-few-public-methods """ A Training Data Generator for compiling data for feeding to a model. @@ -44,18 +48,22 @@ class TrainingDataGenerator(): # pylint:disable=too-few-public-methods ``False`` warp_to_landmarks: bool ``True`` if the random warp method should warp to similar landmarks from the other side, - ``False`` if the standard random warp method should be used. If ``True`` then - the key `landmarks` must be provided in the alignments dictionary. + ``False`` if the standard random warp method should be used. alignments: dict - A dictionary containing landmarks and masks if these are required for training: + A dictionary containing aligned face information and masks if these are required for + training: - * **landmarks** (`dict`, `optional`). Required if :attr:`warp_to_landmarks` is \ - ``True``. Returning dictionary has a key of **side** (`str`) the value of which is a \ - `dict` of {**filename** (`str`): **68 point landmarks** (:class:`numpy.ndarray`)}. + * **aligned_faces** (`dict`). Contains the aligned face information. Returning dictionary \ + has a key of **side** (`str`) the value of which is a `dict` of {**filename** (`str`): \ + :class:`lib.align.AlignedFace`}. + + * **versions** (`dict`). The Alignments file versions that the extracted faces originated \ + from for each key of **side** (`str`). Version 1.0 will be a legacy extract. Anything \ + above this will be a full-face extract * **masks** (`dict`, `optional`). Required if :attr:`penalized_mask_loss` or \ :attr:`learn_mask` is ``True``. Returning dictionary has a key of **side** (`str`) the \ - value of which is a `dict` of {**filename** (`str`): :class:`lib.faces_detect.Mask`}. + value of which is a `dict` of {**filename** (`str`): :class:`lib.align.Mask`}. * **masks_eye** (`dict`, `optional`). Required if config option "eye_multiplier" is \ a value greater than 1. Returning dictionary has a key of **side** (`str`) the \ @@ -68,7 +76,7 @@ class TrainingDataGenerator(): # pylint:disable=too-few-public-methods mouth mask. config: dict - The configuration `dict` generated from :file:`config.train.ini` containing the trainer \ + The configuration `dict` generated from :file:`config.train.ini` containing the trainer plugin configuration options. """ def __init__(self, model_input_size, model_output_shapes, coverage_ratio, augment_color, @@ -86,11 +94,12 @@ def __init__(self, model_input_size, model_output_shapes, coverage_ratio, augmen self._augment_color = augment_color self._no_flip = no_flip self._warp_to_landmarks = warp_to_landmarks - self._landmarks = alignments.get("landmarks", None) + self._extract_versions = alignments["versions"] + self._aligned_faces = alignments["aligned_faces"] self._masks = dict(masks=alignments.get("masks", None), eyes=alignments.get("masks_eye", None), mouths=alignments.get("masks_mouth", None)) - self._nearest_landmarks = {} + self._cache = dict(nearest_landmarks=dict(), crop_size=0) # Batchsize and processing class are set when this class is called by a feeder # from lib.training_data @@ -207,6 +216,7 @@ def _process_batch(self, filenames, side): :func:`minibatch_ab` for more details on the output. """ logger.trace("Process batch: (filenames: '%s', side: '%s')", filenames, side) batch = read_image_batch(filenames) + batch, landmarks = self._crop_to_center(filenames, batch, side) batch = self._apply_mask(filenames, batch, side) processed = dict() @@ -216,10 +226,8 @@ def _process_batch(self, filenames, side): # Get Landmarks prior to manipulating the image if self._warp_to_landmarks: - batch_src_pts = self._get_landmarks(filenames, side) - batch_dst_pts = self._get_closest_match(filenames, side, batch_src_pts) - warp_kwargs = dict(batch_src_points=batch_src_pts, - batch_dst_points=batch_dst_pts) + batch_dst_pts = self._get_closest_match(filenames, side, landmarks) + warp_kwargs = dict(batch_src_points=landmarks, batch_dst_points=batch_dst_pts) else: warp_kwargs = dict() @@ -254,6 +262,71 @@ def _process_batch(self, filenames, side): for k, v in processed.items()}) return processed + def _crop_to_center(self, filenames, batch, side): + """ Crops the training image out of the full extract image based on the centering used in + the user's configuration settings. + + If legacy extract images are being used then this just returns the extracted batch with + their corresponding landmarks. + + Parameters + ---------- + filenames: list + The list of filenames that correspond to this batch + batch: :class:`numpy.ndarray` + The batch of faces that have been loaded from disk + side: str + '"a"' or '"b"' the side that is being processed + + Returns + ------- + batch: :class:`numpy.ndarray` + The centered faces cropped out of the loaded batch + landmarks: :class:`numpy.ndarray` + The aligned landmarks for this batch. NB: The aligned landmarks do not directly + correspond to the size of the extracted face. They are scaled to the source training + image, not the sub-image. + + Raises + ------ + FaceswapError + If Alignment information is not available for any of the images being loaded in + the batch + """ + logger.trace("Cropping training images info: (filenames: %s, side: '%s')", filenames, side) + aligned = [self._aligned_faces[side].get(filename, None) for filename in filenames] + # Raise error on missing alignments + if any(info is None for info in aligned): + missing = [filenames[idx] for idx, info in enumerate(aligned) if info is None] + msg = ("Files missing alignments for this batch: {}" + "\nAt least one of your images does not have a matching entry in your " + "alignments file." + "\nEvery face you intend to train on must exist within the alignments file." + "\nThe specific files that caused this failure are listed above." + "\nMost likely there will be more than just these files missing from the " + "alignments file. You can use the Alignments Tool to help identify missing " + "alignments".format(missing)) + raise FaceswapError(msg) + + if self._extract_versions[side] == 1.0: + # Legacy extract. Don't crop, just return batch with landmarks + return batch, np.array([face.landmarks for face in aligned]) + + if not self._cache["crop_size"]: + size = aligned[0].get_cropped_size(self._config["centering"]) + logger.debug("caching crop size: (centering: '%s', full size: %s, crop size: %s)", + self._config["centering"], batch.shape[1], size) + self._cache["crop_size"] = size + size = self._cache["crop_size"] + + landmarks = np.array([face.landmarks for face in aligned]) + cropped = np.zeros((batch.shape[0], size, size, batch.shape[3]), dtype=batch.dtype) + + for out, align, img in zip(cropped, aligned, batch): + slices = align.get_cropped_slices(self._config["centering"]) + out[slices["out"][0], slices["out"][1], :] = img[slices["in"][0], slices["in"][1], :] + return cropped, landmarks + def _apply_mask(self, filenames, batch, side): """ Applies the mask to the 4th channel of the image. If masks are not being used applies a dummy all ones mask. @@ -275,7 +348,8 @@ def _apply_mask(self, filenames, batch, side): :class:`numpy.ndarray` The batch with masks applied to the final channels """ - logger.trace("Input batch shape: %s, side: %s", batch.shape, side) + logger.trace("Input filenames: %s, batch shape: %s, side: %s", + filenames, batch.shape, side) size = batch.shape[1] for key in ("masks", "eyes", "mouths"): item = self._masks[key] @@ -294,7 +368,6 @@ def _apply_mask(self, filenames, batch, side): masks = np.array([self._get_mask(item[side][filename], size) for filename in filenames], dtype=batch.dtype) masks = self._resize_masks(size, masks) - logger.trace("masks: (key: %s, shape: %s)", key, masks.shape) batch = np.concatenate((batch, masks), axis=-1) logger.trace("Output batch shape: %s, side: %s", batch.shape, side) @@ -332,7 +405,7 @@ def _get_mask(cls, item, size): Parameters ---------- - item: :class:`lib.faces_detect.Mask` or `bytes` + item: :class:`lib.align.Mask` or `bytes` Either a stored face mask object or a zipped eye or mouth mask size: int The size of the stored eye or mouth mask for reshaping @@ -348,8 +421,8 @@ def _get_mask(cls, item, size): retval = item.mask return retval - @staticmethod - def _resize_masks(target_size, masks): + @classmethod + def _resize_masks(cls, target_size, masks): """ Resize the masks to the target size """ logger.trace("target size: %s, masks shape: %s", target_size, masks.shape) mask_size = masks.shape[1] @@ -364,36 +437,15 @@ def _resize_masks(target_size, masks): logger.trace("Resized masks: %s", masks.shape) return masks - def _get_landmarks(self, filenames, side): - """ Obtains the 68 Point Landmarks for the images in this batch. This is only called if - config :attr:`_warp_to_landmarks` is ``True``. If the landmarks for an image cannot be - found, then an error is raised. """ - logger.trace("Retrieving landmarks: (filenames: %s, side: '%s')", filenames, side) - src_points = [self._landmarks[side].get(filename, None) for filename in filenames] - # Raise error on missing alignments - if not all(isinstance(pts, np.ndarray) for pts in src_points): - missing = [filenames[idx] for idx, pts in enumerate(src_points) if pts is None] - msg = ("Files missing alignments for this batch: {}" - "\nAt least one of your images does not have a matching entry in your " - "alignments file." - "\nIf you are using 'warp to landmarks' then every " - "face you intend to train on must exist within the alignments file." - "\nThe specific files that caused this failure are listed above." - "\nMost likely there will be more than just these files missing from the " - "alignments file. You can use the Alignments Tool to help identify missing " - "alignments".format(missing)) - raise FaceswapError(msg) - - logger.trace("Returning: (src_points: %s)", [str(src) for src in src_points]) - return np.array(src_points) - def _get_closest_match(self, filenames, side, batch_src_points): """ Only called if the :attr:`_warp_to_landmarks` is ``True``. Gets the closest matched 68 point landmarks from the opposite training set. """ logger.trace("Retrieving closest matched landmarks: (filenames: '%s', src_points: '%s'", filenames, batch_src_points) - landmarks = self._landmarks["a"] if side == "b" else self._landmarks["b"] - closest_hashes = [self._nearest_landmarks.get(filename) for filename in filenames] + lm_side = "a" if side == "b" else "b" + landmarks = {key: aligned.landmarks + for key, aligned in self._aligned_faces[lm_side].items()} + closest_hashes = [self._cache["nearest_landmarks"].get(filename) for filename in filenames] if None in closest_hashes: closest_hashes = self._cache_closest_hashes(filenames, batch_src_points, landmarks) @@ -411,7 +463,7 @@ def _cache_closest_hashes(self, filenames, batch_src_points, landmarks): for filename, src_points in zip(filenames, batch_src_points): closest = (np.mean(np.square(src_points - dst_points), axis=(1, 2))).argsort()[:10] closest_hashes = tuple(dst_landmarks[i][0] for i in closest) - self._nearest_landmarks[filename] = closest_hashes + self._cache["nearest_landmarks"][filename] = closest_hashes batch_closest_hashes.append(closest_hashes) logger.trace("Cached closest hashes") return batch_closest_hashes diff --git a/lib/umeyama.py b/lib/umeyama.py deleted file mode 100644 index d767a01144..0000000000 --- a/lib/umeyama.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -""" Umeyama for Faceswap - - License (Modified BSD) - Copyright (C) 2011, the scikit-image team All rights reserved. - - Redistribution and use in source and binary forms, with or without modification, are permitted - provided that the following conditions are met: - - Redistributions of source code must retain the above copyright notice, this list of conditions - and the following disclaimer. - - Redistributions in binary form must reproduce the above copyright notice, this list of - conditions and the following disclaimer in the documentation and/or other materials provided - with the distribution. - - Neither the name of skimage nor the names of its contributors may be used to endorse or promote - products derived from this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE AUTHOR ''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, - INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A - PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, - INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED - TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS - INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT - LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF - THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - umeyama function from scikit-image/skimage/transform/_geometric.py -""" -import numpy as np - -MEAN_FACE_X = np.array([ - 0.000213256, 0.0752622, 0.18113, 0.29077, 0.393397, 0.586856, 0.689483, - 0.799124, 0.904991, 0.98004, 0.490127, 0.490127, 0.490127, 0.490127, - 0.36688, 0.426036, 0.490127, 0.554217, 0.613373, 0.121737, 0.187122, - 0.265825, 0.334606, 0.260918, 0.182743, 0.645647, 0.714428, 0.793132, - 0.858516, 0.79751, 0.719335, 0.254149, 0.340985, 0.428858, 0.490127, - .551395, 0.639268, 0.726104, 0.642159, 0.556721, 0.490127, 0.423532, - 0.338094, 0.290379, 0.428096, 0.490127, 0.552157, 0.689874, 0.553364, - 0.490127, 0.42689]) - -MEAN_FACE_Y = np.array([ - 0.106454, 0.038915, 0.0187482, 0.0344891, 0.0773906, 0.0773906, 0.0344891, - 0.0187482, 0.038915, 0.106454, 0.203352, 0.307009, 0.409805, 0.515625, - 0.587326, 0.609345, 0.628106, 0.609345, 0.587326, 0.216423, 0.178758, - 0.179852, 0.231733, 0.245099, 0.244077, 0.231733, 0.179852, 0.178758, - 0.216423, 0.244077, 0.245099, 0.780233, 0.745405, 0.727388, 0.742578, - 0.727388, 0.745405, 0.780233, 0.864805, 0.902192, 0.909281, 0.902192, - 0.864805, 0.784792, 0.778746, 0.785343, 0.778746, 0.784792, 0.824182, - 0.831803, 0.824182]) - - -def umeyama(src, estimate_scale, dst=None): - """Estimate N-D similarity transformation with or without scaling. - Parameters - ---------- - src : (M, N) array - Source coordinates. - dst : (M, N) array - Destination coordinates. - estimate_scale : bool - Whether to estimate scaling factor. - Returns - ------- - T : (N + 1, N + 1) - The homogeneous similarity transformation matrix. The matrix contains - NaN values only if the problem is not well-conditioned. - References - ---------- - .. [1] "Least-squares estimation of transformation parameters between two - point patterns", Shinji Umeyama, PAMI 1991, DOI: 10.1109/34.88573 - """ - if dst is None: - dst = np.stack([MEAN_FACE_X, MEAN_FACE_Y], axis=1) - - num = src.shape[0] - dim = src.shape[1] - - # Compute mean of src and dst. - src_mean = src.mean(axis=0) - dst_mean = dst.mean(axis=0) - - # Subtract mean from src and dst. - src_demean = src - src_mean - dst_demean = dst - dst_mean - - # Eq. (38). - A = np.dot(dst_demean.T, src_demean) / num - - # Eq. (39). - d = np.ones((dim,), dtype=np.double) - if np.linalg.det(A) < 0: - d[dim - 1] = -1 - - T = np.eye(dim + 1, dtype=np.double) - - U, S, V = np.linalg.svd(A) - - # Eq. (40) and (43). - rank = np.linalg.matrix_rank(A) - if rank == 0: - return np.nan * T - elif rank == dim - 1: - if np.linalg.det(U) * np.linalg.det(V) > 0: - T[:dim, :dim] = np.dot(U, V) - else: - s = d[dim - 1] - d[dim - 1] = -1 - T[:dim, :dim] = np.dot(U, np.dot(np.diag(d), V)) - d[dim - 1] = s - else: - T[:dim, :dim] = np.dot(U, np.dot(np.diag(d), V.T)) - - if estimate_scale: - # Eq. (41) and (42). - scale = 1.0 / src_demean.var(axis=0).sum() * np.dot(S, d) - else: - scale = 1.0 - - T[:dim, dim] = dst_mean - scale * np.dot(T[:dim, :dim], src_mean.T) - T[:dim, :dim] *= scale - - return T diff --git a/plugins/convert/mask/box_blend.py b/plugins/convert/mask/box_blend.py index a7dd4fb2ea..ae03750d2f 100644 --- a/plugins/convert/mask/box_blend.py +++ b/plugins/convert/mask/box_blend.py @@ -4,7 +4,7 @@ import numpy as np -from lib.faces_detect import BlurMask +from lib.align import BlurMask from ._base import Adjustment, logger diff --git a/plugins/convert/mask/box_blend_defaults.py b/plugins/convert/mask/box_blend_defaults.py index dbeca5f8af..5dc98a565d 100755 --- a/plugins/convert/mask/box_blend_defaults.py +++ b/plugins/convert/mask/box_blend_defaults.py @@ -18,7 +18,7 @@ The following keys are expected for the _DEFAULTS dict: datatype: [required] A python type class. This limits the type of data that can be provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , + correct type to faceswap. Valid data types are: , , , . default: [required] The default value for this option. info: [required] A string describing what this option does. @@ -27,10 +27,10 @@ a combobox / radio option in the GUI. gui_radio: [optional] If are defined, this indicates that the GUI should use radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required + min_max: [partial] For and data types this is required otherwise it is ignored. Should be a tuple of min and max accepted values. This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is + rounding: [partial] For and data types this is required otherwise it is ignored. Used for the GUI slider. For floats, this is the number of decimal places to display. For ints this is the step size. fixed: [optional] [train only]. Training configurations are fixed when the model is @@ -44,65 +44,65 @@ _HELPTEXT = "Options for blending the edges of the swapped box with the background image" -_DEFAULTS = { - "type": { - "default": "gaussian", - "info": "The type of blending to use:" - "\n\t gaussian: Blend with Gaussian filter. Slower, but often better than " - "Normalized" - "\n\t normalized: Blend with Normalized box filter. Faster than Gaussian" - "\n\t none: Don't perform blending", - "datatype": str, - "rounding": None, - "min_max": None, - "choices": ["gaussian", "normalized", "none"], - "gui_radio": True, - "fixed": True, - }, - "distance": { - "default": 11.0, - "info": "The distance from the edges of the swap box to start blending.\nThe distance " - "is set as percentage of the swap box size to give the number of pixels from " - "the edge of the box. Eg: For a swap area of 256px and a percentage of 4%, " - "blending would commence 10 pixels from the edge.\nHigher percentages start " - "the blending from closer to the center of the face, so will reveal more of " - "the source face.", - "datatype": float, - "rounding": 1, - "group": "settings", - "min_max": (0.1, 25.0), - "choices": [], - "gui_radio": False, - "fixed": True, - }, - "radius": { - "default": 5.0, - "info": "Radius dictates how much blending should occur, or more specifically, how " - "far the blending will spread away from the 'distance' parameter.\nThis " - "figure is set as a percentage of the swap box size to give the radius in " - "pixels. Eg: For a swap area of 256px and a percentage of 5%, the radius " - "would be 13 pixels\nNB: Higher percentage means more blending, but too high " - "may reveal more of the source face, or lead to hard lines at the border.", - "datatype": float, - "rounding": 1, - "min_max": (0.1, 25.0), - "choices": [], - "gui_radio": False, - "group": "settings", - "fixed": True, - }, - "passes": { - "default": 1, - "info": "The number of passes to perform. Additional passes of the blending algorithm " - "can improve smoothing at a time cost. This is more useful for 'box' type " - "blending.\nAdditional passes have exponentially less effect so it's not " - "worth setting this too high.", - "datatype": int, - "rounding": 1, - "min_max": (1, 8), - "choices": [], - "gui_radio": False, - "group": "settings", - "fixed": True, - }, -} +_DEFAULTS = dict( + type=dict( + default="gaussian", + info="The type of blending to use:" + "\n\t gaussian: Blend with Gaussian filter. Slower, but often better than Normalized" + "\n\t normalized: Blend with Normalized box filter. Faster than Gaussian" + "\n\t none: Don't perform blending", + datatype=str, + rounding=None, + min_max=None, + choices=["gaussian", "normalized", "none"], + gui_radio=True, + fixed=True, + ), + distance=dict( + default=11.0, + info="The distance from the edges of the swap box to start blending.\n" + "The distance is set as percentage of the swap box size to give the number of pixels " + "from the edge of the box. Eg: For a swap area of 256px and a percentage of 4%, " + "blending would commence 10 pixels from the edge.\nHigher percentages start the " + "blending from closer to the center of the face, so will reveal more of the source " + "face.", + datatype=float, + rounding=1, + group="settings", + min_max=(0.1, 25.0), + choices=[], + gui_radio=False, + fixed=True, + ), + radius=dict( + default=5.0, + info="Radius dictates how much blending should occur, or more specifically, how far the " + "blending will spread away from the 'distance' parameter.\n" + "This figure is set as a percentage of the swap box size to give the radius in " + "pixels. Eg: For a swap area of 256px and a percentage of 5%, the radius would be 13 " + "pixels.\n" + "NB: Higher percentage means more blending, but too high may reveal more of the " + "source face, or lead to hard lines at the border.", + datatype=float, + rounding=1, + min_max=(0.1, 25.0), + choices=[], + gui_radio=False, + group="settings", + fixed=True, + ), + passes=dict( + default=1, + info="The number of passes to perform. Additional passes of the blending algorithm can " + "improve smoothing at a time cost. This is more useful for 'box' type blending.\n" + "Additional passes have exponentially less effect so it's not worth setting this too " + "high.", + datatype=int, + rounding=1, + min_max=(1, 8), + choices=[], + gui_radio=False, + group="settings", + fixed=True, + ), +) diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index 778be9ebab..27714d930d 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -27,13 +27,16 @@ def __init__(self, mask_type, output_size, coverage_ratio, **kwargs): self._do_erode = self.config.get("erosion", 0) != 0 self._coverage_ratio = coverage_ratio - def process(self, detected_face, predicted_mask=None): # pylint:disable=arguments-differ + def process(self, detected_face, sub_crop_offset, # pylint:disable=arguments-differ + predicted_mask=None,): """ Obtain the requested mask type and perform any defined mask manipulations. Parameters ---------- - detected_face: :class:`lib.faces_detect.DetectedFace` + detected_face: :class:`lib.align.DetectedFace` The DetectedFace object as returned from :class:`scripts.convert.Predictor`. + sub_crop_offset: :class:`numpy.ndarray`, optional + The (x, y) offset to crop the mask from the center point. predicted_mask: :class:`numpy.ndarray`, optional The predicted mask as output from the Faceswap Model, if the model was trained with a mask, otherwise ``None``. Default: ``None``. @@ -45,23 +48,26 @@ def process(self, detected_face, predicted_mask=None): # pylint:disable=argumen raw_mask: :class:`numpy.ndarray` The mask with no erosion/dilation applied """ - mask = self._get_mask(detected_face, predicted_mask) + mask = self._get_mask(detected_face, predicted_mask, sub_crop_offset) raw_mask = mask.copy() if not self.skip and self._do_erode: mask = self._erode(mask) logger.trace("mask shape: %s, raw_mask shape: %s", mask.shape, raw_mask.shape) return mask, raw_mask - def _get_mask(self, detected_face, predicted_mask): + def _get_mask(self, detected_face, predicted_mask, sub_crop_offset): """ Return the requested mask with any requested blurring applied. Parameters ---------- - detected_face: :class:`lib.faces_detect.DetectedFace` + detected_face: :class:`lib.align.DetectedFace` The DetectedFace object as returned from :class:`scripts.convert.Predictor`. predicted_mask: :class:`numpy.ndarray` The predicted mask as output from the Faceswap Model if the model was trained with a mask, otherwise ``None`` + sub_crop_offset: :class:`numpy.ndarray` + The (x, y) offset to crop the mask from the center point. Set to `None` if the mask + does not need to be offset for alternative centering Returns ------- @@ -79,6 +85,8 @@ def _get_mask(self, detected_face, predicted_mask): blur_type=self.config["type"], blur_passes=self.config["passes"], threshold=self.config["threshold"]) + if np.any(sub_crop_offset): + mask.set_sub_crop(sub_crop_offset) mask = self._crop_to_coverage(mask.mask) mask_size = mask.shape[0] face_size = self.dummy.shape[0] diff --git a/plugins/convert/mask/mask_blend_defaults.py b/plugins/convert/mask/mask_blend_defaults.py index 618060e8ab..e6ed91f03f 100755 --- a/plugins/convert/mask/mask_blend_defaults.py +++ b/plugins/convert/mask/mask_blend_defaults.py @@ -18,7 +18,7 @@ The following keys are expected for the _DEFAULTS dict: datatype: [required] A python type class. This limits the type of data that can be provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , + correct type to faceswap. Valid data types are: , , , . default: [required] The default value for this option. info: [required] A string describing what this option does. @@ -27,10 +27,10 @@ a combobox / radio option in the GUI. gui_radio: [optional] If are defined, this indicates that the GUI should use radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required + min_max: [partial] For and data types this is required otherwise it is ignored. Should be a tuple of min and max accepted values. This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is + rounding: [partial] For and data types this is required otherwise it is ignored. Used for the GUI slider. For floats, this is the number of decimal places to display. For ints this is the step size. fixed: [optional] [train only]. Training configurations are fixed when the model is @@ -44,72 +44,70 @@ _HELPTEXT = "Options for blending the edges between the mask and the background image" -_DEFAULTS = { - "type": { - "default": "normalized", - "info": "The type of blending to use:" - "\n\t gaussian: Blend with Gaussian filter. Slower, but often better than " - "Normalized" - "\n\t normalized: Blend with Normalized box filter. Faster than Gaussian" - "\n\t none: Don't perform blending", - "datatype": str, - "rounding": None, - "min_max": None, - "choices": ["gaussian", "normalized", "none"], - "gui_radio": True, - "fixed": True, - }, - "kernel_size": { - "default": 3, - "info": "The kernel size dictates how much blending should occur.\n" - "The size is the diameter of the kernel in pixels (calculated from a 128px mask). " - " This value should be odd, if an even number is passed in then it will be " - "rounded to the next odd number. Higher sizes means more blending.", - "datatype": int, - "rounding": 1, - "min_max": (1, 9), - "choices": [], - "gui_radio": False, - "group": "settings", - "fixed": True, - }, - "passes": { - "default": 4, - "info": "The number of passes to perform. Additional passes of the blending algorithm " - "can improve smoothing at a time cost. This is more useful for 'box' type " - "blending.\nAdditional passes have exponentially less effect so it's not " - "worth setting this too high.", - "datatype": int, - "rounding": 1, - "min_max": (1, 8), - "choices": [], - "gui_radio": False, - "group": "settings", - "fixed": True, - }, - "threshold": { - "default": 4, - "info": "Sets pixels that are near white to white and near black to black. Set to 0 for " - "off.", - "datatype": int, - "rounding": 1, - "min_max": (0, 50), - "choices": [], - "gui_radio": False, - "group": "settings", - "fixed": True, - }, - "erosion": { - "default": 0.0, - "info": "Erosion kernel size as a percentage of the mask radius area.\nPositive " - "values apply erosion which reduces the size of the swapped area.\nNegative " - "values apply dilation which increases the swapped area.", - "datatype": float, - "rounding": 1, - "min_max": (-100.0, 100.0), - "choices": [], - "gui_radio": False, - "group": "settings", - "fixed": True, - }, -} +_DEFAULTS = dict( + type=dict( + default="normalized", + info="The type of blending to use:" + "\n\t gaussian: Blend with Gaussian filter. Slower, but often better than Normalized" + "\n\t normalized: Blend with Normalized box filter. Faster than Gaussian" + "\n\t none: Don't perform blending", + datatype=str, + rounding=None, + min_max=None, + choices=["gaussian", "normalized", "none"], + gui_radio=True, + fixed=True, + ), + kernel_size=dict( + default=3, + info="The kernel size dictates how much blending should occur.\n" + "The size is the diameter of the kernel in pixels (calculated from a 128px mask). " + "This value should be odd, if an even number is passed in then it will be rounded to " + "the next odd number. Higher sizes means more blending.", + datatype=int, + rounding=1, + min_max=(1, 9), + choices=[], + gui_radio=False, + group="settings", + fixed=True, + ), + passes=dict( + default=4, + info="The number of passes to perform. Additional passes of the blending algorithm can " + "improve smoothing at a time cost. This is more useful for 'box' type blending.\n" + "Additional passes have exponentially less effect so it's not worth setting this too " + "high.", + datatype=int, + rounding=1, + min_max=(1, 8), + choices=[], + gui_radio=False, + group="settings", + fixed=True, + ), + threshold=dict( + default=4, + info="Sets pixels that are near white to white and near black to black. Set to 0 for off.", + datatype=int, + rounding=1, + min_max=(0, 50), + choices=[], + gui_radio=False, + group="settings", + fixed=True, + ), + erosion=dict( + default=0.0, + info="Erosion kernel size as a percentage of the mask radius area.\n" + "Positive values apply erosion which reduces the size of the swapped area.\n" + "Negative values apply dilation which increases the swapped area.", + datatype=float, + rounding=1, + min_max=(-100.0, 100.0), + choices=[], + gui_radio=False, + group="settings", + fixed=True, + ), +) diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 7c984281cb..8b209dcf3d 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -242,6 +242,49 @@ def _predict(self, batch): """ raise NotImplementedError + def _process_input(self, batch): + """ **Override method** (at `` level) + + This method should be overridden at the `` level (IE. + ``plugins.extract.detect._base`` or ``plugins.extract.align._base``) and should not + be overridden within plugins themselves. + + It acts as a wrapper for the plugin's :func:`process_input` method and handles any + input processing that is consistent for all plugins within the `plugin_type`. + + If this method is not overridden then the plugin's :func:`process_input` is just called. + + Parameters + ---------- + batch : dict + Contains the batch that is currently being passed through the plugin process + + Notes + ----- + When preparing an input to the model a key ``feed`` must be added + to the :attr:`batch` ``dict`` which contains this input. + """ + return self.process_input(batch) + + def _process_output(self, batch): + """ **Override method** (at `` level) + + This method should be overridden at the `` level (IE. + ``plugins.extract.detect._base`` or ``plugins.extract.align._base``) and should not + be overridden within plugins themselves. + + It acts as a wrapper for the plugin's :func:`process_output` method and handles any + output processing that is consistent for all plugins within the `plugin_type`. + + If this method is not overridden then the plugin's :func:`process_output` is just called. + + Parameters + ---------- + batch : dict + Contains the batch that is currently being passed through the plugin process + """ + return self.process_output(batch) + def finalize(self, batch): """ **Override method** (at `` level) @@ -258,6 +301,7 @@ def finalize(self, batch): Contains the batch that is currently being passed through the plugin process """ + raise NotImplementedError def get_batch(self, queue): """ **Override method** (at `` level) @@ -375,7 +419,7 @@ def _compile_threads(self): name = self.name.replace(" ", "_").lower() base_name = "{}_{}".format(self._plugin_type, name) self._add_thread("{}_input".format(base_name), - self.process_input, + self._process_input, self._queues["in"], self._queues["predict_{}".format(name)]) self._add_thread("{}_predict".format(base_name), @@ -383,7 +427,7 @@ def _compile_threads(self): self._queues["predict_{}".format(name)], self._queues["post_{}".format(name)]) self._add_thread("{}_output".format(base_name), - self.process_output, + self._process_output, self._queues["post_{}".format(name)], self._queues["out"]) logger.debug("Compiled %s threads: %s", self._plugin_type, self._threads) @@ -404,7 +448,7 @@ def _thread_process(self, function, in_queue, out_queue): func_name = function.__name__ logger.debug("threading: (function: '%s')", func_name) while True: - if func_name == "process_input": + if func_name == "_process_input": # Process input items to batches exhausted, batch = self.get_batch(in_queue) if exhausted: @@ -431,7 +475,7 @@ def _thread_process(self, function, in_queue, out_queue): "`allow_growth option to `True`.") raise FaceswapError(msg) from err raise err - if func_name == "process_output": + if func_name == "_process_output": # Process output items to individual items from batch for item in self.finalize(batch): out_queue.put(item) diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index 77920f6055..a09c0bf9d2 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -37,6 +37,9 @@ class Aligner(Extractor): # pylint:disable=abstract-method The name of the model file to be loaded normalize_method: {`None`, 'clahe', 'hist', 'mean'}, optional Normalize the images fed to the aligner. Default: ``None`` + re_feed: int + The number of times to re-feed a slightly adjusted bounding box into the aligner. + Default: `0` Other Parameters ---------------- @@ -53,21 +56,23 @@ class Aligner(Extractor): # pylint:disable=abstract-method """ def __init__(self, git_model_id=None, model_filename=None, - configfile=None, instance=0, normalize_method=None, **kwargs): - logger.debug("Initializing %s: (normalize_method: %s)", self.__class__.__name__, - normalize_method) + configfile=None, instance=0, normalize_method=None, re_feed=0, **kwargs): + logger.debug("Initializing %s: (normalize_method: %s, re_feed: %s)", + self.__class__.__name__, normalize_method, re_feed) super().__init__(git_model_id, model_filename, configfile=configfile, instance=instance, **kwargs) self._normalize_method = None + self._re_feed = re_feed self.set_normalize_method(normalize_method) self._plugin_type = "align" self._faces_per_filename = dict() # Tracking for recompiling face batches self._rollover = None # Items that are rolled over from the previous batch in get_batch self._output_faces = [] + self._additional_keys = [] logger.debug("Initialized %s", self.__class__.__name__) def set_normalize_method(self, method): @@ -92,7 +97,7 @@ def get_batch(self, queue): to ``dict`` for internal processing. To ensure consistent batch sizes for aligner the items are split into separate items for - each :class:`~lib.faces_detect.DetectedFace` object. + each :class:`~lib.align.DetectedFace` object. Remember to put ``'EOF'`` to the out queue after processing the final batch @@ -102,7 +107,7 @@ def get_batch(self, queue): >>> {'filename': [], >>> 'image': [], - >>> 'detected_faces': [[>> 'detected_faces': [[>> # + + # << PROCESS_INPUT WRAPPER >> + def _process_input(self, batch): + """ Process the input to the aligner model multiple times based on the user selected + `re-feed` command line option. This adjusts the bounding box for the face to be fed + into the model by a random amount within 0.05 pixels of the detected face's shortest axis. + + References + ---------- + https://studios.disneyresearch.com/2020/06/29/high-resolution-neural-face-swapping-for-visual-effects/ + + Parameters + ---------- + batch: dict + Contains the batch that is currently being passed through the plugin process + + Returns + ------- + dict + The batch with input processed + """ + if not self._additional_keys: + existing_keys = list(batch.keys()) + + original_boxes = np.array([(face.x, face.y, face.w, face.h) + for face in batch["detected_faces"]]) + adjusted_boxes = self._get_adjusted_boxes(original_boxes) + retval = dict() + for bounding_boxes in adjusted_boxes: + for face, box in zip(batch["detected_faces"], bounding_boxes): + face.x, face.y, face.w, face.h = box + + result = self.process_input(batch) + if not self._additional_keys: + self._additional_keys = [key for key in result if key not in existing_keys] + for key in self._additional_keys: + retval.setdefault(key, []).append(batch[key]) + del batch[key] + + # Place the original bounding box back to detected face objects + for face, box in zip(batch["detected_faces"], original_boxes): + face.x, face.y, face.w, face.h = box + + batch.update(retval) + return batch + + def _get_adjusted_boxes(self, original_boxes): + """ Obtain an array of adjusted bounding boxes based on the number of re-feed iterations + that have been selected and the minimum dimension of the original bounding box. + + Parameters + ---------- + original_boxes: :class:`numpy.ndarray` + The original ('x', 'y', 'w', 'h') detected face boxes corresponding to the incoming + detected face objects + + Returns + ------- + :class:`numpy.ndarray` + The original boxes (in position 0) and the randomly adjusted bounding boxes + """ + if self._re_feed == 0: + return original_boxes[None, ...] + beta = 0.05 + max_shift = np.min(original_boxes[..., 2:], axis=1) * beta + rands = np.random.rand(self._re_feed, *original_boxes.shape) * 2 - 1 + new_boxes = np.rint(original_boxes + (rands * max_shift[None, :, None])).astype("int32") + retval = np.concatenate((original_boxes[None, ...], new_boxes)) + logger.trace(retval) + return retval + # <<< PREDICT WRAPPER >>> # def _predict(self, batch): """ Just return the aligner's predict function """ try: - return self.predict(batch) + batch["prediction"] = [self.predict(feed) for feed in batch["feed"]] + return batch except tf_errors.ResourceExhaustedError as err: msg = ("You do not have enough GPU memory available to run detection at the " "selected batch size. You can try a number of things:" @@ -248,6 +325,28 @@ def _predict(self, batch): raise FaceswapError(msg) from err raise + def _process_output(self, batch): + """ Process the output from the aligner model multiple times based on the user selected + `re-feed amount` configuration option, then average the results for final prediction. + + Parameters + ---------- + batch : dict + Contains the batch that is currently being passed through the plugin process + """ + landmarks = [] + for idx in range(self._re_feed + 1): + subbatch = {key: val + for key, val in batch.items() + if key not in ["feed", "prediction"] + self._additional_keys} + subbatch["prediction"] = batch["prediction"][idx] + for key in self._additional_keys: + subbatch[key] = batch[key][idx] + self.process_output(subbatch) + landmarks.append(subbatch["landmarks"]) + batch["landmarks"] = np.average(landmarks, axis=0) + return batch + # <<< FACE NORMALIZATION METHODS >>> # def _normalize_faces(self, faces): """ Normalizes the face for feeding into model diff --git a/plugins/extract/align/cv2_dnn.py b/plugins/extract/align/cv2_dnn.py index 1458cefda9..d9d1fae091 100644 --- a/plugins/extract/align/cv2_dnn.py +++ b/plugins/extract/align/cv2_dnn.py @@ -151,9 +151,9 @@ def pad_image(box, image): def predict(self, batch): """ Predict the 68 point landmarks """ logger.trace("Predicting Landmarks") - self.model.setInput(batch["feed"]) - batch["prediction"] = self.model.forward() - return batch + self.model.setInput(batch) + retval = self.model.forward() + return retval def process_output(self, batch): """ Process the output from the model """ diff --git a/plugins/extract/align/fan.py b/plugins/extract/align/fan.py index 79739b09cd..4f9c9ae55d 100644 --- a/plugins/extract/align/fan.py +++ b/plugins/extract/align/fan.py @@ -121,9 +121,9 @@ def predict(self, batch): logger.debug("Predicting Landmarks") # TODO Remove lazy transpose and change points from predict to use the correct # order - batch["prediction"] = self.model.predict(batch["feed"])[-1].transpose(0, 3, 1, 2) - logger.trace(batch["prediction"].shape) - return batch + retval = self.model.predict(batch)[-1].transpose(0, 3, 1, 2) + logger.trace(retval.shape) + return retval def process_output(self, batch): """ Process the output from the model """ diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index e4fcb89c62..dd0790b02a 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -11,7 +11,7 @@ >>> {'filename': , >>> 'detected_faces': >> face = self.to_detected_face(, , , ) """ @@ -20,7 +20,7 @@ from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module -from lib.faces_detect import DetectedFace +from lib.align import DetectedFace from lib.utils import get_backend, FaceswapError from plugins.extract._base import Extractor, logger @@ -96,7 +96,7 @@ def get_batch(self, queue): >>> 'image': , >>> 'scale': [], >>> 'pad': [], - >>> 'detected_faces': [[>> 'detected_faces': [[>> {'filename': [], - >>> 'detected_faces': [[>> 'detected_faces': [[> # - def _load_align(self, aligner, configfile, normalize_method): + def _load_align(self, aligner, configfile, normalize_method, re_feed): """ Set global arguments and load aligner plugin """ if aligner is None or aligner.lower() == "none": logger.debug("No aligner selected. Returning None") @@ -512,6 +516,7 @@ def _load_align(self, aligner, configfile, normalize_method): aligner = PluginLoader.get_aligner(aligner_name)(exclude_gpus=self._exclude_gpus, configfile=configfile, normalize_method=normalize_method, + re_feed=re_feed, instance=self._instance) return aligner @@ -667,8 +672,8 @@ class ExtractMedia(): image: :class:`numpy.ndarray` The original frame detected_faces: list, optional - A list of :class:`~lib.faces_detect.DetectedFace` objects. Detected faces can be added - later with :func:`add_detected_faces`. Default: None + A list of :class:`~lib.align.DetectedFace` objects. Detected faces can be added + later with :func:`add_detected_faces`. Default: ``None`` """ def __init__(self, filename, image, detected_faces=None): @@ -700,7 +705,7 @@ def image_size(self): @property def detected_faces(self): - """list: A list of :class:`~lib.faces_detect.DetectedFace` objects in the + """list: A list of :class:`~lib.align.DetectedFace` objects in the :attr:`image`. """ return self._detected_faces @@ -727,7 +732,7 @@ def add_detected_faces(self, faces): Parameters ---------- faces: list - A list of :class:`~lib.faces_detect.DetectedFace` objects + A list of :class:`~lib.align.DetectedFace` objects """ logger.trace("Adding detected faces for filename: '%s'. (faces: %s, lrtb: %s)", self._filename, faces, diff --git a/plugins/train/_config.py b/plugins/train/_config.py index ee6f944bae..4dd43256ad 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -42,6 +42,24 @@ def _set_globals(self): section = "global" self.add_section(title=section, info="Options that apply to all models" + ADDITIONAL_INFO) + self.add_item( + section=section, + title="centering", + datatype=str, + gui_radio=True, + default="face", + choices=["face", "legacy"], + fixed=True, + group="face", + info="How to center the training image. The extracted images are centered on the " + "middle of the skull based on the face's estimated pose. A subsection of these " + "images are used for training. The centering used dictates how this subsection " + "will be cropped from the aligned images." + "\n\tface: Centers the training image on the center of the face, adjusting for " + "pitch and yaw." + "\n\tlegacy: The 'original' extraction technique. Centers the training image " + "near the tip of the nose with no adjustment. Can result in the edges of the " + "face appearing outside of the training area.") self.add_item( section=section, title="coverage", diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 1bce2d545f..c553e29a63 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -1252,11 +1252,18 @@ def _replace_config(self, config_changeable_items): """ global _CONFIG # pylint: disable=global-statement legacy_update = self._update_legacy_config() - # Add any new items to state config for legacy purposes + # Add any new items to state config for legacy purposes and set sensible defaults for + # any values that may have been changed in the config file which could be detrimental. + legacy_defaults = dict(centering="legacy", + mask_loss_function="mse", + l2_reg_term=100, + optimizer="adam", + mixed_precision=False) for key, val in _CONFIG.items(): if key not in self._config.keys(): - logger.info("Adding new config item to state file: '%s': '%s'", key, val) - self._config[key] = val + setting = legacy_defaults.get(key, val) + logger.info("Adding new config item to state file: '%s': '%s'", key, setting) + self._config[key] = setting self._update_changed_config_items(config_changeable_items) logger.debug("Replacing config. Old config: %s", _CONFIG) _CONFIG = self._config diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index b79fffb7fb..9a70e6e5dc 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -21,8 +21,7 @@ from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module from tqdm import tqdm -from lib.alignments import Alignments -from lib.faces_detect import DetectedFace +from lib.align import Alignments, DetectedFace from lib.image import read_image_hash_batch from lib.training_data import TrainingDataGenerator from lib.utils import FaceswapError, get_backend, get_folder, get_image_paths @@ -73,9 +72,9 @@ class TrainerBase(): def __init__(self, model, images, batch_size, configfile): logger.debug("Initializing %s: (model: '%s', batch_size: %s)", self.__class__.__name__, model, batch_size) - self._config = _get_config(".".join(self.__module__.split(".")[-2:]), - configfile=configfile) self._model = model + self._config = self._get_config(configfile) + self._model.state.add_session_batchsize(batch_size) self._images = images self._sides = sorted(key for key in self._images.keys()) @@ -97,6 +96,31 @@ def __init__(self, model, images, batch_size, configfile): self._images) logger.debug("Initialized %s", self.__class__.__name__) + def _get_config(self, configfile): + """ Get the saved training config options. Override any global settings with the setting + provided from the model's saved config. + + Parameters + ----------- + configfile: str + The path to a custom configuration file. If ``None`` is passed then configuration is + loaded from the default :file:`.config.train.ini` file. + + Returns + ------- + dict + The trainer configuration options + """ + config = _get_config(".".join(self.__module__.split(".")[-2:]), + configfile=configfile) + for key, val in config.items(): + if key in self._model.config and val != self._model.config[key]: + new_val = self._model.config[key] + logger.debug("Updating global training config item for '%s' form '%s' to '%s'", + key, val, new_val) + config[key] = new_val + return config + def _get_alignments_data(self): """ Extrapolate alignments and masks from the alignments file into a `dict` for the training data generator. @@ -104,24 +128,23 @@ def _get_alignments_data(self): Returns ------- dict: - Includes the key `landmarks` if landmarks are required for training, `masks` if masks - are required for training, `masks_eye` if eye masks are required and `masks_mouth` if - mouth masks are required. """ - retval = dict() + Includes the key `aligned_faces` holding aligned face information and the key + `versions` indicating the alignments file versions that the faces have come from. + In addition, the following optional keys are provided: `masks` if masks are required + for training, `masks_eye` if eye masks are required and `masks_mouth` if mouth masks + are required. """ penalized_loss = self._model.config["penalized_mask_loss"] - if not any([self._model.config["learn_mask"], - penalized_loss, - self._model.config["eye_multiplier"] > 1, - self._model.config["mouth_multiplier"] > 1, - self._model.command_line_arguments.warp_to_landmarks]): - return retval - alignments = _TrainingAlignments(self._model, self._images) - - if self._model.command_line_arguments.warp_to_landmarks: - logger.debug("Adding landmarks to training opts dict") - retval["landmarks"] = alignments.landmarks + if (any(version == 1.0 for version in alignments.versions.values()) + and self._config["centering"] != "legacy"): + logger.warning("You are using legacy extracted faces but have selected '%s' " + "centering which is incompatible. Switching centering to 'legacy'", + self._config["centering"]) + self._config["centering"] = "legacy" + self._model.config["centering"] = "legacy" + retval = dict(aligned_faces=alignments.aligned_faces, + versions=alignments.versions) if self._model.config["learn_mask"] or penalized_loss: logger.debug("Adding masks to training opts dict") @@ -133,7 +156,9 @@ def _get_alignments_data(self): if penalized_loss and self._model.config["mouth_multiplier"] > 1: retval["masks_mouth"] = alignments.masks_mouth - logger.debug({key: {k: len(v) for k, v in val.items()} for key, val in retval.items()}) + logger.debug({key: {k: v if isinstance(v, float) else len(v) + for k, v in val.items()} + for key, val in retval.items()}) return retval def _set_tensorboard(self): @@ -335,8 +360,8 @@ class _Feeder(): config: :class:`lib.config.FaceswapConfig` The configuration for this trainer alignments: dict - A dictionary containing landmarks and masks if these are required for training for each - side + A dictionary containing aligned face data, extract version information and masks if these + are required for training for each side """ def __init__(self, images, model, batch_size, config, alignments): logger.debug("Initializing %s: num_images: %s, batch_size: %s, config: %s)", @@ -742,7 +767,7 @@ def _get_predictions(self, feed_a, feed_b): return preds def _to_full_frame(self, side, samples, predictions): - """ Patch targets and prediction images into images of training image size. + """ Patch targets and prediction images into images of model output size. Parameters ---------- @@ -761,58 +786,57 @@ def _to_full_frame(self, side, samples, predictions): logger.debug("side: '%s', number of sample arrays: %s, prediction.shapes: %s)", side, len(samples), [pred.shape for pred in predictions]) full, faces = samples[:2] + full = self._process_full(side, full, predictions[0].shape[1], (0, 0, 255)) images = [faces] + predictions - full_size = full.shape[1] - target_size = int(full_size * self._coverage_ratio) - if target_size != full_size: - frame = self._frame_overlay(full, target_size, (0, 0, 255)) - if self._display_mask: images = self._compile_masked(images, samples[-1]) - images = [self._resize_sample(side, image, target_size) for image in images] - if target_size != full_size: - images = [self._overlay_foreground(frame, image) for image in images] + images = [self._overlay_foreground(full.copy(), image) for image in images] + if self._scaling != 1.0: - new_size = int(full_size * self._scaling) + new_size = int(images[0].shape[1] * self._scaling) images = [self._resize_sample(side, image, new_size) for image in images] return images - @classmethod - def _frame_overlay(cls, images, target_size, color): + def _process_full(self, side, images, prediction_size, color): """ Add a frame overlay to preview images indicating the region of interest. - This is the red border that appears in the preview images. + This applies the red border that appears in the preview images. Parameters ---------- + side: {"a" or "b"} + The side that these samples are for images: :class:`numpy.ndarray` - The samples to apply the frame to - target_size: int - The size of the sample within the full size frame + The input training images to to process + prediction_size: int + The size of the predicted output from the model color: tuple The (Blue, Green, Red) color to use for the frame Returns ------- :class:`numpy,ndarray` - The samples with the frame overlay applied + The input training images, sized for output and annotated for coverage """ - logger.debug("full_size: %s, target_size: %s, color: %s", - images.shape[1], target_size, color) - new_images = list() - full_size = images.shape[1] - padding = (full_size - target_size) // 2 - length = target_size // 4 - t_l, b_r = (padding, full_size - padding) + logger.debug("full_size: %s, prediction_size: %s, color: %s", + images.shape[1], prediction_size, color) + + display_size = int(prediction_size * (1 + (1 - self._coverage_ratio))) + images = self._resize_sample(side, images, display_size) # Resize targets to display size + padding = (display_size - prediction_size) // 2 + if padding == 0: + logger.debug("Resized background. Shape: %s", images.shape) + return images + + length = display_size // 4 + t_l, b_r = (padding, display_size - padding) for img in images: - cv2.rectangle(img, (t_l, t_l), (t_l + length, t_l + length), color, 3) - cv2.rectangle(img, (b_r, t_l), (b_r - length, t_l + length), color, 3) - cv2.rectangle(img, (b_r, b_r), (b_r - length, b_r - length), color, 3) - cv2.rectangle(img, (t_l, b_r), (t_l + length, b_r - length), color, 3) - new_images.append(img) - retval = np.array(new_images) - logger.debug("Overlayed background. Shape: %s", retval.shape) - return retval + cv2.rectangle(img, (t_l, t_l), (t_l + length, t_l + length), color, 2) + cv2.rectangle(img, (b_r, t_l), (b_r - length, t_l + length), color, 2) + cv2.rectangle(img, (b_r, b_r), (b_r - length, b_r - length), color, 2) + cv2.rectangle(img, (t_l, b_r), (t_l + length, b_r - length), color, 2) + logger.debug("Overlayed background. Shape: %s", images.shape) + return images @classmethod def _compile_masked(cls, faces, masks): @@ -868,16 +892,14 @@ def _overlay_foreground(cls, backgrounds, foregrounds): The preview images compiled into the full frame size for each preview """ offset = (backgrounds.shape[1] - foregrounds.shape[1]) // 2 - new_images = list() - for idx, img in enumerate(backgrounds): - img[offset:offset + foregrounds[idx].shape[0], - offset:offset + foregrounds[idx].shape[1], :3] = foregrounds[idx] - new_images.append(img) - retval = np.array(new_images) - logger.debug("Overlayed foreground. Shape: %s", retval.shape) - return retval + for foreground, background in zip(foregrounds, backgrounds): + background[offset:offset + foreground.shape[0], + offset:offset + foreground.shape[1], :3] = foreground + logger.debug("Overlayed foreground. Shape: %s", backgrounds.shape) + return backgrounds - def _get_headers(self, side, width): + @classmethod + def _get_headers(cls, side, width): """ Set header row for the final preview frame Parameters @@ -896,14 +918,15 @@ def _get_headers(self, side, width): side, width) titles = ("Original", "Swap") if side == "a" else ("Swap", "Original") side = side.upper() - height = int(64 * self._scaling) + height = int(width / 4.5) total_width = width * 3 logger.debug("height: %s, total_width: %s", height, total_width) font = cv2.FONT_HERSHEY_SIMPLEX texts = ["{} ({})".format(titles[0], side), "{0} > {0}".format(titles[0]), "{} > {}".format(titles[0], titles[1])] - text_sizes = [cv2.getTextSize(texts[idx], font, self._scaling * 0.8, 1)[0] + scaling = (width / 144) * 0.45 + text_sizes = [cv2.getTextSize(texts[idx], font, scaling, 1)[0] for idx in range(len(texts))] text_y = int((height + text_sizes[0][1]) / 2) text_x = [int((width - text_sizes[idx][0]) / 2) + width * idx @@ -916,7 +939,7 @@ def _get_headers(self, side, width): text, (text_x[idx], text_y), font, - self._scaling * 0.8, + scaling, (0, 0, 0), 1, lineType=cv2.LINE_AA) @@ -996,7 +1019,7 @@ def _setup(self, input_a=None, input_b=None, output=None): self._output_file = str(output) logger.debug("Time-lapse output set to '%s'", self._output_file) - # Rewrite paths to pull from the training images so mask and landmark data can be accessed + # Rewrite paths to pull from the training images so mask and face data can be accessed images = dict() for side, input_ in zip(("a", "b"), (input_a, input_b)): training_path = os.path.dirname(self._image_paths[side][0]) @@ -1056,16 +1079,25 @@ def __init__(self, model, image_list): self._training_size = model.state.training_size self._alignments_paths = self._get_alignments_paths() self._hashes = self._get_image_hashes(image_list) - self._detected_faces = self._load_alignments() + self._alignments_version = dict() + self._detected_faces = dict() + self._load_alignments() self._check_all_faces() - self._landmarks = self._get_landmarks() + self._aligned_faces = self._get_aligned_faces() logger.debug("Initialized %s", self.__class__.__name__) - # Get landmarks @property - def landmarks(self): - """ dict: The :class:`numpy.ndarray` aligned landmarks for keys "a" and "b" """ - return self._landmarks + def aligned_faces(self): + """ dict: The "a", "b" keys for each side, containing a sub-dictionary with the + filename as key and :class:`lib.faces.detected_face.aligned` object as value. """ + return self._aligned_faces + + @property + def versions(self): + """ dict: The "a", "b" keys for each side, with value being the alignment file version + that provided the data. This is used to crop the faces correctly based on whether the + extracted faces are legacy or full-head extracts. """ + return self._alignments_version def _get_alignments_paths(self): """ Obtain the alignments file paths from the command line arguments passed to the model. @@ -1096,46 +1128,31 @@ def _get_alignments_paths(self): logger.debug("Alignments paths: %s", retval) return retval - def _get_landmarks(self): - """ Pre-generate landmarks as they are needed for both warp to landmarks and eye/mouth - masks. + def _get_aligned_faces(self): + """ Pre-generate aligned faces as they are needed for all training functions. Returns ------- dict - The :class:`numpy.ndarray` aligned landmarks for keys "a" and "b" + The "a", "b" keys for each side, containing a sub-dictionary with the + filename as key and :class:`lib.faces.detected_face.AlignedFace` object as value. """ - retval = {side: self._transform_landmarks(side, detected_faces) - for side, detected_faces in self._detected_faces.items()} - logger.trace(retval) + retval = dict() + for side, detected_faces in self._detected_faces.items(): + centering = "legacy" if self._alignments_version[side] == 1.0 else "head" + logger.debug("side: %s, centering: %s", side, centering) + ret_side = dict() + for fhash, face in detected_faces.items(): + face.load_aligned(None, size=self._training_size, centering=centering) + for filename in self._hash_to_filenames(side, fhash): + ret_side[filename] = face.aligned + retval[side] = ret_side return retval - def _transform_landmarks(self, side, detected_faces): - """ Transform frame landmarks to their aligned face variant. - - Parameters - ---------- - side: {"a" or "b"} - The side currently being processed - detected_faces: list - A list of :class:`lib.faces_detect.DetectedFace` objects - - Returns - ------- - dict - The face filenames as keys with the aligned landmarks as value. - """ - landmarks = dict() - for face in detected_faces.values(): - face.load_aligned(None, size=self._training_size) - for filename in self._hash_to_filenames(side, face.hash): - landmarks[filename] = face.aligned_landmarks - return landmarks - # Get masks @property def masks(self): - """ dict: The :class:`lib.faces_detect.Mask` objects of requested mask type for + """ dict: The :class:`lib.align.Mask` objects of requested mask type for keys a" and "b" """ retval = {side: self._get_masks(side, detected_faces) @@ -1152,18 +1169,20 @@ def _get_masks(self, side, detected_faces): The side currently being processed detected_faces: dict Key is the hash of the face, value is the corresponding - :class:`lib.faces_detect.DetectedFace` object + :class:`lib.align.DetectedFace` object Returns ------- dict - The face filenames as keys with the :class:`lib.faces_detect.Mask` as value. + The face filenames as keys with the :class:`lib.align.Mask` as value. """ masks = dict() for fhash, face in detected_faces.items(): mask = face.mask[self._config["mask_type"]] mask.set_blur_and_threshold(blur_kernel=self._config["mask_blur_kernel"], threshold=self._config["mask_threshold"]) + if self._alignments_version[side] > 1.0 and self._config["centering"] == "legacy": + mask.set_sub_crop(face.aligned.pose.offset["face"] * -1) for filename in self._hash_to_filenames(side, fhash): masks[filename] = mask return masks @@ -1191,7 +1210,7 @@ def _get_landmarks_masks(self, side, detected_faces, area): The side currently being processed detected_faces: dict Key is the hash of the face, value is the corresponding - :class:`lib.faces_detect.DetectedFace` object + :class:`lib.align.DetectedFace` object area: {"eyes" or "mouth"} The area of the face to obtain the mask for @@ -1202,13 +1221,20 @@ def _get_landmarks_masks(self, side, detected_faces, area): """ logger.trace("side: %s, detected_faces: %s, area: %s", side, detected_faces, area) masks = dict() + if self._alignments_version[side] == 1.0: + centering = "legacy" + size = self._training_size + else: + centering = self._config["centering"] + size = list(self._aligned_faces[side].values())[0].get_cropped_size(centering) for fhash, face in detected_faces.items(): mask = partial(face.get_landmark_mask, - self._training_size, + size, area, aligned=True, - dilation=self._training_size // 32, - blur_kernel=self._training_size // 16, + centering=centering, + dilation=size // 32, + blur_kernel=size // 16, as_zip=True) for filename in self._hash_to_filenames(side, fhash): masks[filename] = mask @@ -1248,23 +1274,20 @@ def _get_image_hashes(cls, image_list): # Hashes for Detected Faces def _load_alignments(self): - """ Load the alignments and convert to :class:`lib.faces_detect.DetectedFace` objects. + """ Load the alignments and convert to :class:`lib.align.DetectedFace` objects. - Returns - ------- - dict - For keys "a" and "b" values are a dict with the key being the sha1 hash of the face - and the value being the corresponding :class:`lib.faces_detect.DetectedFace` object. + Assign the alignments file version to :attr:`_alignments_version` and the converted + detected faces to :attr:`_detected_faces` for each side """ logger.debug("Loading alignments") - retval = dict() for side, fullpath in self._alignments_paths.items(): logger.debug("side: '%s', path: '%s'", side, fullpath) path, filename = os.path.split(fullpath) alignments = Alignments(path, filename=filename) - retval[side] = self._to_detected_faces(alignments, side) - logger.debug("Returning: %s", {k: len(v) for k, v in retval.items()}) - return retval + self._detected_faces[side] = self._to_detected_faces(alignments, side) + self._alignments_version[side] = alignments.version + logger.debug("alignments_versions: %s, detected_faces: %s, ", self._alignments_version, + {k: len(v) for k, v in self._detected_faces.items()}) def _to_detected_faces(self, alignments, side): """ Convert alignments to DetectedFace objects. @@ -1273,7 +1296,7 @@ def _to_detected_faces(self, alignments, side): Parameters ---------- - alignments: :class:`lib.alignments.Alignments` + alignments: :class:`lib.align.Alignments` The alignments for the current faces side: {"a" or "b"} The side being processed @@ -1282,7 +1305,7 @@ def _to_detected_faces(self, alignments, side): ------- dict key is sha1 hash of face, value is the corresponding - :class:`lib.faces_detect.DetectedFace` object + :class:`lib.align.DetectedFace` object """ skip_count = 0 dupe_count = 0 diff --git a/scripts/convert.py b/scripts/convert.py index 46092f543d..86754cdf5b 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -15,7 +15,7 @@ from scripts.fsmedia import Alignments, PostProcess, finalize from lib.serializer import get_serializer from lib.convert import Converter -from lib.faces_detect import DetectedFace +from lib.align import AlignedFace, DetectedFace from lib.gpu_stats import GPUStats from lib.image import read_image_hash, ImagesLoader from lib.multithreading import MultiThread, total_cpus @@ -52,6 +52,11 @@ def __init__(self, arguments): self._patch_threads = None self._images = ImagesLoader(self._args.input_dir, fast_count=True) self._alignments = Alignments(self._args, False, self._images.is_video) + if self._alignments.version == 1.0: + logger.error("The alignments file format has been updated since the given alignments " + "file was generated. You need to update the file to proceed.") + logger.error("To do this run the 'Alignments Tool' > 'Extract' Job.") + sys.exit(1) self._opts = OptionalActions(self._args, self._images.file_list, self._alignments) @@ -64,6 +69,7 @@ def __init__(self, arguments): configfile = self._args.configfile if hasattr(self._args, "configfile") else None self._converter = Converter(self._predictor.output_size, self._predictor.coverage_ratio, + self._predictor.centering, self._disk_io.draw_transparent, self._disk_io.pre_encode, arguments, @@ -441,7 +447,7 @@ def _load(self, *args): # pylint: disable=unused-argument In a background thread: * Loads frames from disk. * Discards or passes through cli selected skipped frames - * Pairs the frame with its :class:`~lib.faces_detect.DetectedFace` objects + * Pairs the frame with its :class:`~lib.align.DetectedFace` objects * Performs any pre-processing actions * Puts the frame and detected faces to the load queue """ @@ -515,7 +521,7 @@ def _get_detected_faces(self, filename, image): Returns ------- list - List of :class:`lib.faces_detect.DetectedFace` objects + List of :class:`lib.align.DetectedFace` objects """ logger.trace("Getting faces for: '%s'", filename) if not self._extractor: @@ -538,7 +544,7 @@ def _alignments_faces(self, frame_name, image): Returns ------- list - List of :class:`lib.faces_detect.DetectedFace` objects + List of :class:`lib.align.DetectedFace` objects """ if not self._check_alignments(frame_name): return list() @@ -588,7 +594,7 @@ def _detect_faces(self, filename, image): Returns ------- list - List of :class:`lib.faces_detect.DetectedFace` objects + List of :class:`lib.align.DetectedFace` objects """ self._extractor.input_queue.put(ExtractMedia(filename, image)) faces = next(self._extractor.detected_faces()) @@ -656,6 +662,7 @@ def __init__(self, in_queue, queue_size, arguments): self._batchsize = self._get_batchsize(queue_size) self._sizes = self._get_io_sizes() self._coverage_ratio = self._model.coverage_ratio + self._centering = self._model.config["centering"] self._thread = self._launch_predictor() logger.debug("Initialized %s: (out_queue: %s)", self.__class__.__name__, self._out_queue) @@ -691,6 +698,11 @@ def coverage_ratio(self): """ float: The coverage ratio that the model was trained at. """ return self._coverage_ratio + @property + def centering(self): + """ str: The centering that the model was trained on (`"face"` or `"legacy"`) """ + return self._centering + @property def has_predicted_mask(self): """ bool: ``True`` if the model was trained to learn a mask, otherwise ``False``. """ @@ -852,10 +864,10 @@ def _predict_faces(self): if batch: logger.trace("Batching to predictor. Frames: %s, Faces: %s", len(batch), faces_seen) - detected_batch = [detected_face for item in batch - for detected_face in item["detected_faces"]] + feed_batch = [feed_face for item in batch + for feed_face in item["feed_faces"]] if faces_seen != 0: - feed_faces = self._compile_feed_faces(detected_batch) + feed_faces = self._compile_feed_faces(feed_batch) batch_size = None if is_amd and feed_faces.shape[0] != self._batchsize: logger.verbose("Fallback to BS=1") @@ -885,43 +897,53 @@ def load_aligned(self, item): Parameters ---------- item: dict - The incoming image and list of :class:`~lib.faces_detect.DetectedFace` objects + The incoming image, list of :class:`~lib.align.DetectedFace` objects and list of + :class:`~lib.align.AlignedFace` objects for the feed face(s) and list of + :class:`~lib.align.AlignedFace` objects for the reference face(s) """ logger.trace("Loading aligned faces: '%s'", item["filename"]) + feed_faces = [] + reference_faces = [] for detected_face in item["detected_faces"]: - detected_face.load_feed_face(item["image"], - size=self._sizes["input"], - coverage_ratio=self._coverage_ratio, - dtype="float32") + feed_face = AlignedFace(detected_face.landmarks_xy, + image=item["image"], + centering=self._centering, + size=self._sizes["input"], + coverage_ratio=self._coverage_ratio, + dtype="float32") if self._sizes["input"] == self._sizes["output"]: - detected_face.reference = detected_face.feed + reference_faces.append(feed_face) else: - detected_face.load_reference_face(item["image"], - size=self._sizes["output"], - coverage_ratio=self._coverage_ratio, - dtype="float32") + reference_faces.append(AlignedFace(detected_face.landmarks_xy, + image=item["image"], + centering=self._centering, + size=self._sizes["output"], + coverage_ratio=self._coverage_ratio, + dtype="float32")) + feed_faces.append(feed_face) + item["feed_faces"] = feed_faces + item["reference_faces"] = reference_faces logger.trace("Loaded aligned faces: '%s'", item["filename"]) @staticmethod - def _compile_feed_faces(detected_faces): + def _compile_feed_faces(feed_faces): """ Compile a batch of faces for feeding into the Predictor. Parameters ---------- - detected_faces: list - List of `~lib.faces_detect.DetectedFace` objects + feed_faces: list + List of :class:`~lib.align.AlignedFace` objects sized for feeding into the model Returns ------- :class:`numpy.ndarray` A batch of faces ready for feeding into the Faceswap model. """ - logger.trace("Compiling feed face. Batchsize: %s", len(detected_faces)) - feed_faces = np.stack([detected_face.feed_face[..., :3] - for detected_face in detected_faces]) / 255.0 - logger.trace("Compiled Feed faces. Shape: %s", feed_faces.shape) - return feed_faces + logger.trace("Compiling feed face. Batchsize: %s", len(feed_faces)) + retval = np.stack([feed_face.face[..., :3] for feed_face in feed_faces]) / 255.0 + logger.trace("Compiled Feed faces. Shape: %s", retval.shape) + return retval def _predict(self, feed_faces, batch_size=None): """ Run the Faceswap models' prediction function. @@ -978,9 +1000,9 @@ def _queue_out_frames(self, batch, swapped_faces): else: item["swapped_faces"] = swapped_faces[pointer:pointer + num_faces] - logger.trace("Putting to queue. ('%s', detected_faces: %s, swapped_faces: %s)", - item["filename"], len(item["detected_faces"]), - item["swapped_faces"].shape[0]) + logger.trace("Putting to queue. ('%s', detected_faces: %s, reference_faces: %s, " + "swapped_faces: %s)", item["filename"], len(item["detected_faces"]), + len(item["reference_faces"]), item["swapped_faces"].shape[0]) pointer += num_faces self._out_queue.put(batch) logger.trace("Queued out batch. Batchsize: %s", len(batch)) @@ -998,7 +1020,7 @@ class OptionalActions(): # pylint:disable=too-few-public-methods line arguments input_images: list List of input image files - alignments: :class:`lib.alignments.Alignments` + alignments: :class:`lib.align.Alignments` The alignments file for this conversion """ diff --git a/scripts/extract.py b/scripts/extract.py index 1876159ac4..f19104dadf 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -62,7 +62,8 @@ def __init__(self, arguments): exclude_gpus=self._args.exclude_gpus, rotate_images=self._args.rotate_images, min_size=self._args.min_size, - normalize_method=normalization) + normalize_method=normalization, + re_feed=self._args.re_feed) self._threads = list() self._verify_output = False logger.debug("Initialized %s", self.__class__.__name__) @@ -250,8 +251,10 @@ def _output_processing(self, extract_media, size): The size that the aligned face should be created at """ for face in extract_media.detected_faces: - face.load_aligned(extract_media.image, size=size) - face.thumbnail = generate_thumbnail(face.aligned_face, size=80, quality=60) + face.load_aligned(extract_media.image, + size=size, + centering="head") + face.thumbnail = generate_thumbnail(face.aligned.face, size=96, quality=60) self._post_process.do_actions(extract_media) extract_media.remove_image() @@ -282,7 +285,7 @@ def _output_faces(self, saver, extract_media): filename, extension = os.path.splitext(os.path.basename(extract_media.filename)) for idx, face in enumerate(extract_media.detected_faces): output_filename = "{}_{}{}".format(filename, str(idx), extension) - face.hash, image = encode_image_with_hash(face.aligned_face, extension) + face.hash, image = encode_image_with_hash(face.aligned.face, extension) if not self._args.skip_saving_faces: saver.save(output_filename, image) diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 042ec3b2e9..b3d2e0777d 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -12,9 +12,10 @@ from pathlib import Path import cv2 +import numpy as np import imageio -from lib.alignments import Alignments as AlignmentsBase +from lib.align import Alignments as AlignmentsBase from lib.face_filter import FaceFilter as FilterFunc from lib.image import count_frames, read_image from lib.utils import (camel_case_split, get_image_paths, _video_extensions) @@ -49,7 +50,7 @@ def finalize(images_found, num_faces_detected, verify_output): class Alignments(AlignmentsBase): - """ Override :class:`lib.alignments.Alignments` to add custom loading based on command + """ Override :class:`lib.align.Alignments` to add custom loading based on command line arguments. Parameters @@ -107,7 +108,7 @@ def _set_folder_filename(self, input_is_video): return folder, filename def _load(self): - """ Override the parent :func:`~lib.alignments.Alignments._load` to handle skip existing + """ Override the parent :func:`~lib.align.Alignments._load` to handle skip existing frames and faces on extract. If skip existing has been selected, existing alignments are loaded and returned to the @@ -500,9 +501,18 @@ def process(self, extract_media): frame = os.path.splitext(os.path.basename(extract_media.filename))[0] for idx, face in enumerate(extract_media.detected_faces): logger.trace("Drawing Landmarks. Frame: '%s'. Face: %s", frame, idx) - aligned_landmarks = face.aligned_landmarks - for (pos_x, pos_y) in aligned_landmarks: - cv2.circle(face.aligned_face, (pos_x, pos_y), 2, (0, 0, 255), -1) + # Landmarks + for (pos_x, pos_y) in face.aligned.landmarks: + cv2.circle(face.aligned.face, (pos_x, pos_y), 1, (0, 255, 255), -1) + # Pose + center = tuple(np.int32((face.aligned.size / 2, face.aligned.size / 2))) + points = (face.aligned.pose.xyz_2d * face.aligned.size).astype("int32") + cv2.line(face.aligned.face, center, tuple(points[1]), (0, 255, 0), 1) + cv2.line(face.aligned.face, center, tuple(points[0]), (255, 0, 0), 1) + cv2.line(face.aligned.face, center, tuple(points[2]), (0, 0, 255), 1) + # Face centering + roi = face.aligned.get_cropped_roi("face") + cv2.rectangle(face.aligned.face, tuple(roi[:2]), tuple(roi[2:]), (0, 255, 0), 1) class FaceFilter(PostProcessAction): @@ -621,12 +631,11 @@ def process(self, extract_media): ret_faces = list() for idx, detect_face in enumerate(extract_media.detected_faces): check_item = detect_face["face"] if isinstance(detect_face, dict) else detect_face - check_item.load_aligned(extract_media.image) - if not self._filter.check(check_item): + if not self._filter.check(extract_media.image, check_item): logger.verbose("Skipping not recognized face: (Frame: %s Face %s)", extract_media.filename, idx) continue logger.trace("Accepting recognised face. Frame: %s. Face: %s", extract_media.filename, idx) ret_faces.append(detect_face) - extract_media.detected_faces = ret_faces + extract_media.add_detected_faces(ret_faces) diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index 8100c7b20f..6cda3ef217 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -4,8 +4,8 @@ import logging from .media import AlignmentData -from .jobs import (Check, Dfl, Draw, Extract, Merge, # noqa pylint: disable=unused-import - Rename, RemoveAlignments, Sort, Spatial, UpdateHashes) +from .jobs import (Check, Draw, Extract, Merge, Rename, # noqa pylint: disable=unused-import + RemoveFaces, Sort, Spatial, UpdateHashes) logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -57,15 +57,11 @@ def process(self): Launches the selected alignments job. """ - if self.args.job == "update-hashes": - job = UpdateHashes - elif self.args.job.startswith("remove-"): - job = RemoveAlignments - elif self.args.job in ("missing-alignments", "missing-frames", - "multi-faces", "leftover-faces", "no-faces"): + if self.args.job in ("missing-alignments", "missing-frames", + "multi-faces", "leftover-faces", "no-faces"): job = Check else: - job = globals()[self.args.job.title()] + job = globals()[self.args.job.title().replace("-", "")] job = job(self.alignments, self.args) logger.debug(job) job.process() diff --git a/tools/alignments/annotate.py b/tools/alignments/annotate.py deleted file mode 100644 index fec4e8bbfc..0000000000 --- a/tools/alignments/annotate.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -""" Tools for annotating an input image """ - -from collections import OrderedDict - -import logging - -import cv2 -import numpy as np - -logger = logging.getLogger(__name__) # pylint: disable=invalid-name - - -class Annotate(): - """ Annotate an input image """ - - def __init__(self, image, alignments, original_roi=None): - logger.debug("Initializing %s: (alignments: %s, original_roi: %s)", - self.__class__.__name__, alignments, original_roi) - self.image = image - self.alignments = alignments - self.roi = original_roi - self.colors = {1: (255, 0, 0), - 2: (0, 255, 0), - 3: (0, 0, 255), - 4: (255, 255, 0), - 5: (255, 0, 255), - 6: (0, 255, 255)} - logger.debug("Initialized %s", self.__class__.__name__) - - def draw_black_image(self): - """ Change image to black at correct dimensions """ - logger.trace("Drawing black image") - height, width = self.image.shape[:2] - self.image = np.zeros((height, width, 3), dtype="uint8") - - def draw_bounding_box(self, color_id=1, thickness=1): - """ Draw the bounding box around faces """ - color = self.colors[color_id] - for alignment in self.alignments: - top_left = (alignment["x"], alignment["y"]) - bottom_right = (alignment["x"] + alignment["w"], alignment["y"] + alignment["h"]) - logger.trace("Drawing bounding box: (top_left: %s, bottom_right: %s, color: %s, " - "thickness: %s)", top_left, bottom_right, color, thickness) - cv2.rectangle(self.image, top_left, bottom_right, color, thickness) - - def draw_extract_box(self, color_id=2, thickness=1): - """ Draw the extracted face box """ - if not self.roi: - return - color = self.colors[color_id] - for idx, roi in enumerate(self.roi): - logger.trace("Drawing Extract Box: (idx: %s, roi: %s)", idx, roi) - top_left = [point for point in roi.squeeze()[0]] - top_left = (top_left[0], top_left[1] - 10) - cv2.putText(self.image, - str(idx), - top_left, - cv2.FONT_HERSHEY_DUPLEX, - 1.0, - color, - thickness) - cv2.polylines(self.image, [roi], True, color, thickness) - - def draw_landmarks(self, color_id=3, radius=1): - """ Draw the facial landmarks """ - color = self.colors[color_id] - for alignment in self.alignments: - landmarks = alignment["landmarks_xy"].astype("int32") - logger.trace("Drawing Landmarks: (landmarks: %s, color: %s, radius: %s)", - landmarks, color, radius) - for (pos_x, pos_y) in landmarks: - cv2.circle(self.image, (pos_x, pos_y), radius, color, -1) - - def draw_landmarks_mesh(self, color_id=4, thickness=1): - """ Draw the facial landmarks """ - color = self.colors[color_id] - facial_landmarks_idxs = OrderedDict([("mouth", (48, 68)), - ("right_eyebrow", (17, 22)), - ("left_eyebrow", (22, 27)), - ("right_eye", (36, 42)), - ("left_eye", (42, 48)), - ("nose", (27, 36)), - ("jaw", (0, 17)), - ("chin", (8, 11))]) - for alignment in self.alignments: - landmarks = alignment["landmarks_xy"] - logger.trace("Drawing Landmarks Mesh: (landmarks: %s, color: %s, thickness: %s)", - landmarks, color, thickness) - for key, val in facial_landmarks_idxs.items(): - points = np.array([landmarks[val[0]:val[1]]], np.int32) - fill_poly = bool(key in ("right_eye", "left_eye", "mouth")) - cv2.polylines(self.image, points, fill_poly, color, thickness) - - def draw_grey_out_faces(self, live_face): - """ Grey out all faces except target """ - if not self.roi: - return - alpha = 0.6 - overlay = self.image.copy() - for idx, roi in enumerate(self.roi): - if idx != int(live_face): - logger.trace("Greying out face: (idx: %s, roi: %s)", idx, roi) - cv2.fillPoly(overlay, roi, (0, 0, 0)) - - cv2.addWeighted(overlay, alpha, self.image, 1. - alpha, 0., self.image) diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py index 793ce0e991..a0ba51d85b 100644 --- a/tools/alignments/cli.py +++ b/tools/alignments/cli.py @@ -23,46 +23,40 @@ def get_argument_list(self): frames_and_faces_dir = (" Must Pass in a frames folder/source video file AND a faces " "folder (-fr and -fc).") output_opts = " Use the output option (-o) to process results." - align_eyes = " Can optionally use the align-eyes switch (-ae)." argument_list = list() argument_list.append(dict( opts=("-j", "--job"), action=Radio, type=str, - choices=("dfl", "draw", "extract", "merge", "missing-alignments", "missing-frames", - "leftover-faces", "multi-faces", "no-faces", "remove-faces", "remove-frames", - "rename", "sort", "spatial", "update-hashes"), + choices=("draw", "extract", "merge", "missing-alignments", "missing-frames", + "leftover-faces", "multi-faces", "no-faces", "remove-faces", "rename", "sort", + "spatial", "update-hashes"), required=True, help="R|Choose which action you want to perform. NB: All actions require an " "alignments file (-a) to be passed in." - "\nL|'dfl': Create an alignments file from faces extracted from DeepFaceLab. " - "Specify 'dfl' as the 'alignments file' entry and the folder containing the dfl " - "faces as the 'faces folder' ('-a dfl -fc ')" "\nL|'draw': Draw landmarks on frames in the selected folder/video. A subfolder " "will be created within the frames folder to hold the output.{0}" "\nL|'extract': Re-extract faces from the source frames/video based on alignment " "data. This is a lot quicker than re-detecting faces. Can pass in the '-een' " - "(--extract-every-n) parameter to only extract every nth frame.{1}{2}" + "(--extract-every-n) parameter to only extract every nth frame.{1}" "\nL|'merge': Merge multiple alignment files into one. Specify a space separated " "list of alignments files with the -a flag. Optionally specify a faces (-fc) " "folder to filter the final alignments file to only those faces that appear " "within the provided folder." "\nL|'missing-alignments': Identify frames that do not exist in the alignments " - "file.{3}{0}" + "file.{2}{0}" "\nL|'missing-frames': Identify frames in the alignments file that do not appear " - "within the frames folder/video.{3}{0}" + "within the frames folder/video.{2}{0}" "\nL|'leftover-faces': Identify faces in the faces folder that do not exist in " - "the alignments file.{3}{4}" + "the alignments file.{2}{3}" "\nL|'multi-faces': Identify where multiple faces exist within the alignments " - "file.{3}{5}" + "file.{2}{4}" "\nL|'no-faces': Identify frames that exist within the alignment file but no " - "faces were detected.{3}{0}" + "faces were detected.{2}{0}" "\nL|'remove-faces': Remove deleted faces from an alignments file. The original " - "alignments file will be backed up.{4}" - "\nL|'remove-frames': Remove deleted frames from an alignments file. The " - "original alignments file will be backed up.{0}" + "alignments file will be backed up.{3}" "\nL|'rename' - Rename faces to correspond with their parent frame and position " - "index in the alignments file (i.e. how they are named after running extract).{4}" + "index in the alignments file (i.e. how they are named after running extract).{3}" "\nL|'sort': Re-index the alignments from left to right. For alignments with " "multiple faces this will ensure that the left-most face is at index 0 " "Optionally pass in a faces folder (-fc) to also rename extracted faces." @@ -71,8 +65,8 @@ def get_argument_list(self): "\nL|'update-hashes': Recalculate the face hashes. Only use this if you have " "altered the extracted faces (e.g. colour adjust). The files MUST be named " "'_face index' (i.e. how they are named after running extract)." - "{4}".format(frames_dir, frames_and_faces_dir, align_eyes, output_opts, - faces_dir, frames_or_faces_dir))) + "{3}".format(frames_dir, frames_and_faces_dir, output_opts, faces_dir, + frames_or_faces_dir))) argument_list.append(dict( opts=("-a", "--alignments_file"), action=FilesFullPaths, @@ -125,19 +119,11 @@ def get_argument_list(self): opts=("-sz", "--size"), type=int, action=Slider, - min_max=(128, 512), - default=256, + min_max=(256, 1024), + default=512, group="extract", rounding=64, help="[Extract only] The output size of extracted faces.")) - argument_list.append(dict( - opts=("-ae", "--align-eyes"), - action="store_true", - dest="align_eyes", - group="extract", - default=False, - help="[Extract only] Perform extra alignment to ensure left/right eyes are at the " - "same height.")) argument_list.append(dict( opts=("-l", "--large"), action="store_true", diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index 0b4536eab2..b22c0f0609 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -3,18 +3,20 @@ import logging import os -import pickle -import struct import sys from datetime import datetime -from PIL import Image +import cv2 import numpy as np from scipy import signal from sklearn import decomposition from tqdm import tqdm -from .annotate import Annotate +from lib.align import DetectedFace, _EXTRACT_RATIOS +from lib.align.alignments import _VERSION +from lib.image import generate_thumbnail +from plugins.extract.pipeline import Extractor, ExtractMedia + from .media import ExtractedFaces, Faces, Frames logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -24,7 +26,7 @@ class Check(): """ Frames and faces checking tasks """ def __init__(self, alignments, arguments): logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - self.alignments = alignments + self._alignments = alignments self.job = arguments.job self.type = None self.is_video = False # Set when getting items @@ -87,7 +89,7 @@ def compile_output(self): action = self.job.replace("-", "_") processor = getattr(self, "get_{}".format(action)) logger.debug("Processor: %s", processor) - return [item for item in processor()] + return [item for item in processor()] # pylint:disable=unnecessary-comprehension def get_no_faces(self): """ yield each frame that has no face match in alignments file """ @@ -95,7 +97,7 @@ def get_no_faces(self): for frame in tqdm(self.items, desc=self.output_message): logger.trace(frame) frame_name = frame["frame_fullname"] - if not self.alignments.frame_has_faces(frame_name): + if not self._alignments.frame_has_faces(frame_name): logger.debug("Returning: '%s'", frame_name) yield frame_name @@ -111,7 +113,7 @@ def get_multi_faces_frames(self): self.output_message = "Frames with multiple faces" for item in tqdm(self.items, desc=self.output_message): filename = item["frame_fullname"] - if not self.alignments.frame_has_multiple_faces(filename): + if not self._alignments.frame_has_multiple_faces(filename): continue logger.trace("Returning: '%s'", filename) yield filename @@ -123,8 +125,7 @@ def get_multi_faces_faces(self): for item in tqdm(self.items, desc=self.output_message): filename = item["face_fullname"] f_hash = item["face_hash"] - frame_idx = [(frame, idx) - for frame, idx in self.alignments.hashes_to_frame[f_hash].items()] + frame_idx = list(self._alignments.hashes_to_frame[f_hash].items()) if len(frame_idx) > 1: # If the same hash exists in multiple frames, select arbitrary frame @@ -135,7 +136,7 @@ def get_multi_faces_faces(self): frame_idx = [frame_idx] frame_name, idx = frame_idx[0] - if not self.alignments.frame_has_multiple_faces(frame_name): + if not self._alignments.frame_has_multiple_faces(frame_name): continue retval = (filename, idx) logger.trace("Returning: '%s'", retval) @@ -148,7 +149,7 @@ def get_missing_alignments(self): for frame in tqdm(self.items, desc=self.output_message): frame_name = frame["frame_fullname"] if (frame["frame_extension"] not in exclude_filetypes - and not self.alignments.frame_exists(frame_name)): + and not self._alignments.frame_exists(frame_name)): logger.debug("Returning: '%s'", frame_name) yield frame_name @@ -157,7 +158,7 @@ def get_missing_frames(self): not have a matching file """ self.output_message = "Missing frames that are in alignments file" frames = set(item["frame_fullname"] for item in self.items) - for frame in tqdm(self.alignments.data.keys(), desc=self.output_message): + for frame in tqdm(self._alignments.data.keys(), desc=self.output_message): if frame not in frames: logger.debug("Returning: '%s'", frame) yield frame @@ -167,7 +168,7 @@ def get_leftover_faces(self): self.output_message = "Faces missing from the alignments file" for face in tqdm(self.items, desc=self.output_message): f_hash = face["face_hash"] - if f_hash not in self.alignments.hashes_to_frame: + if f_hash not in self._alignments.hashes_to_frame: logger.debug("Returning: '%s'", face["face_fullname"]) yield face["face_fullname"], -1 @@ -261,148 +262,149 @@ def move_faces(self, output_folder, items_output): os.rename(src, dst) -class Dfl(): - """ Reformat Alignment file """ +class Draw(): # pylint:disable=too-few-public-methods + """ Draws annotations onto original frames and saves into a sub-folder next to the original + frames. + + Parameters + --------- + alignments: :class:`tools.alignments.media.AlignmentsData` + The loaded alignments corresponding to the frames to be annotated + arguments: :class:`argparse.Namespace` + The command line arguments that have called this job + """ def __init__(self, alignments, arguments): logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - self.alignments = alignments - if self.alignments.file != "dfl.fsa": - logger.error("Alignments file must be specified as 'dfl' to reformat dfl alignmnets") - sys.exit(1) - logger.debug("Loading DFL faces") - self.faces = Faces(arguments.faces_dir) + self._alignments = alignments + self._frames = Frames(arguments.frames_dir) + self._output_folder = self._set_output() + self._mesh_areas = dict(mouth=(48, 68), + right_eyebrow=(17, 22), + left_eyebrow=(22, 27), + right_eye=(36, 42), + left_eye=(42, 48), + nose=(27, 36), + jaw=(0, 17), + chin=(8, 11)) logger.debug("Initialized %s", self.__class__.__name__) - def process(self): - """ Run reformat """ - logger.info("[REFORMAT DFL ALIGNMENTS]") # Tidy up cli output - self.alignments.data_from_dfl(self.load_dfl(), self.faces.folder) - self.alignments.save() - - def load_dfl(self): - """ Load alignments from DeepFaceLab and format for Faceswap """ - alignments = dict() - for face in tqdm(self.faces.file_list_sorted, desc="Converting DFL Faces"): - if face["face_extension"] not in (".png", ".jpg"): - logger.verbose("'%s' is not a png or jpeg. Skipping", face["face_fullname"]) - continue - f_hash = face["face_hash"] - fullpath = os.path.join(self.faces.folder, face["face_fullname"]) - dfl = self.get_dfl_alignment(fullpath) - - if not dfl: - continue - - self.convert_dfl_alignment(dfl, f_hash, alignments) - return alignments + def _set_output(self): + """ Set the output folder path. - @staticmethod - def get_dfl_alignment(filename): - """ Process the alignment of one face """ - ext = os.path.splitext(filename)[1] - - if ext.lower() in (".jpg", ".jpeg"): - img = Image.open(filename) - try: - dfl_alignments = pickle.loads(img.app["APP15"]) - dfl_alignments["source_rect"] = [n.item() # comes as non-JSONable np.int32 - for n in dfl_alignments["source_rect"]] - return dfl_alignments - except pickle.UnpicklingError: - return None - - with open(filename, "rb") as dfl: - header = dfl.read(8) - if header != b"\x89PNG\r\n\x1a\n": - logger.error("No Valid PNG header: %s", filename) - return None - while True: - chunk_start = dfl.tell() - chunk_hdr = dfl.read(8) - if not chunk_hdr: - break - chunk_length, chunk_name = struct.unpack("!I4s", chunk_hdr) - dfl.seek(chunk_start, os.SEEK_SET) - if chunk_name == b"fcWp": - chunk = dfl.read(chunk_length + 12) - retval = pickle.loads(chunk[8:-4]) - logger.trace("Loaded DFL Alignment: (filename: '%s', alignment: %s", - filename, retval) - return retval - dfl.seek(chunk_length+12, os.SEEK_CUR) - logger.error("Couldn't find DFL alignments: %s", filename) + If annotating a folder of frames, output will be placed in a sub folder within the frames + folder. If annotating a video, output will be a folder next to the original video. - @staticmethod - def convert_dfl_alignment(dfl_alignments, f_hash, alignments): - """ Add Deep Face Lab Alignments to alignments in Faceswap format """ - sourcefile = dfl_alignments["source_filename"] - left, top, right, bottom = dfl_alignments["source_rect"] - alignment = {"x": left, - "w": right - left, - "y": top, - "h": bottom - top, - "hash": f_hash, - "landmarks_xy": np.array(dfl_alignments["source_landmarks"], dtype="float32")} - logger.trace("Adding alignment: (frame: '%s', alignment: %s", sourcefile, alignment) - alignments.setdefault(sourcefile, dict()).setdefault("faces", []).append(alignment) - - -class Draw(): - """ Draw Alignments on passed in images """ - def __init__(self, alignments, arguments): - logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - self.arguments = arguments - self.alignments = alignments - self.frames = Frames(arguments.frames_dir) - self.output_folder = self.set_output() - self.extracted_faces = None - logger.debug("Initialized %s", self.__class__.__name__) + Returns + ------- + str + Full path to the output folder - def set_output(self): - """ Set the output folder path """ + """ now = datetime.now().strftime("%Y%m%d_%H%M%S") folder_name = "drawn_landmarks_{}".format(now) - if self.frames.is_video: - dest_folder = os.path.dirname(self.frames.folder) + if self._frames.is_video: + dest_folder = os.path.dirname(self._frames.folder) else: - dest_folder = self.frames.folder + dest_folder = self._frames.folder output_folder = os.path.join(dest_folder, folder_name) logger.debug("Creating folder: '%s'", output_folder) os.makedirs(output_folder) return output_folder def process(self): - """ Run the draw alignments process """ + """ Runs the process to draw face annotations onto original source frames. """ logger.info("[DRAW LANDMARKS]") # Tidy up cli output - self.extracted_faces = ExtractedFaces(self.frames, self.alignments, size=256) frames_drawn = 0 - for frame in tqdm(self.frames.file_list_sorted, desc="Drawing landmarks"): + for frame in tqdm(self._frames.file_list_sorted, desc="Drawing landmarks"): frame_name = frame["frame_fullname"] - if not self.alignments.frame_exists(frame_name): + if not self._alignments.frame_exists(frame_name): logger.verbose("Skipping '%s' - Alignments not found", frame_name) continue - self.annotate_image(frame_name) + self._annotate_image(frame_name) frames_drawn += 1 logger.info("%s Frame(s) output", frames_drawn) - def annotate_image(self, frame): - """ Draw the alignments """ - logger.trace("Annotating frame: '%s'", frame) - alignments = self.alignments.get_faces_in_frame(frame) - image = self.frames.load_image(frame) - self.extracted_faces.get_faces_in_frame(frame) - original_roi = [face.original_roi - for face in self.extracted_faces.faces] - annotate = Annotate(image, alignments, original_roi) - annotate.draw_bounding_box(1, 1) - annotate.draw_extract_box(2, 1) - annotate.draw_landmarks(3, 1) - annotate.draw_landmarks_mesh(4, 1) + def _annotate_image(self, frame_name): + """ Annotate the frame with each face that appears in the alignments file. + + Parameters + ---------- + frame_name: str + The full path to the original frame + """ + logger.trace("Annotating frame: '%s'", frame_name) + image = self._frames.load_image(frame_name) + + for idx, alignment in enumerate(self._alignments.get_faces_in_frame(frame_name)): + face = DetectedFace() + face.from_alignment(alignment, image=image) + # Bounding Box + cv2.rectangle(image, (face.left, face.top), (face.right, face.bottom), (255, 0, 0), 1) + self._annotate_landmarks(image, np.rint(face.landmarks_xy).astype("int32")) + self._annotate_extract_boxes(image, face, idx) + self._annotate_pose(image, face) # Pose (head is still loaded) - image = annotate.image - self.frames.save_image(self.output_folder, frame, image) + self._frames.save_image(self._output_folder, frame_name, image) + + def _annotate_landmarks(self, image, landmarks): + """ Annotate the extract boxes onto the frame. + + Parameters + ---------- + image: :class:`numpy.ndarray` + The frame that extract boxes are to be annotated on to + landmarks: :class:`numpy.ndarray` + The 68 point landmarks that are to be annotated onto the frame + index: int + The face index for the given face + """ + # Mesh + for area, indices in self._mesh_areas.items(): + fill = area in ("right_eye", "left_eye", "mouth") + cv2.polylines(image, [landmarks[indices[0]:indices[1]]], fill, (255, 255, 0), 1) + # Landmarks + for (pos_x, pos_y) in landmarks: + cv2.circle(image, (pos_x, pos_y), 1, (0, 255, 255), -1) + + @classmethod + def _annotate_extract_boxes(cls, image, face, index): + """ Annotate the mesh and landmarks boxes onto the frame. + + Parameters + ---------- + image: :class:`numpy.ndarray` + The frame that mesh and landmarks are to be annotated on to + face: :class:`lib.align.AlignedFace` + The aligned face + """ + for area in ("face", "head"): + face.load_aligned(image, centering=area, force=True) + color = (0, 255, 0) if area == "face" else (0, 0, 255) + top_left = face.aligned.original_roi[0] # pylint:disable=unsubscriptable-object + top_left = (top_left[0], top_left[1] - 10) + cv2.putText(image, str(index), top_left, cv2.FONT_HERSHEY_DUPLEX, 1.0, color, 1) + cv2.polylines(image, [face.aligned.original_roi], True, color, 1) + + @classmethod + def _annotate_pose(cls, image, face): + """ Annotate the pose onto the frame. + + Parameters + ---------- + image: :class:`numpy.ndarray` + The frame that pose is to be annotated on to + face: :class:`lib.align.AlignedFace` + The aligned face loaded for head centering + """ + center = np.int32((face.aligned.size / 2, face.aligned.size / 2)).reshape(1, 2) + center = np.rint(face.aligned.transform_points(center, invert=True)).astype("int32") + points = face.aligned.pose.xyz_2d * face.aligned.size + points = np.rint(face.aligned.transform_points(points, invert=True)).astype("int32") + cv2.line(image, tuple(center), tuple(points[1]), (0, 255, 0), 2) + cv2.line(image, tuple(center), tuple(points[0]), (255, 0, 0), 2) + cv2.line(image, tuple(center), tuple(points[2]), (0, 0, 255), 2) class Extract(): # pylint:disable=too-few-public-methods @@ -419,18 +421,21 @@ def __init__(self, alignments, arguments): logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self._arguments = arguments self._alignments = alignments + self._is_legacy = self._alignments.version == 1.0 # pylint:disable=protected-access + self._mask_pipeline = None self._faces_dir = arguments.faces_dir self._frames = Frames(arguments.frames_dir) self._extracted_faces = ExtractedFaces(self._frames, self._alignments, - size=arguments.size, - align_eyes=arguments.align_eyes) + size=arguments.size) logger.debug("Initialized %s", self.__class__.__name__) def process(self): """ Run the re-extraction from Alignments file process""" logger.info("[EXTRACT FACES]") # Tidy up cli output self._check_folder() + if self._is_legacy: + self._legacy_check() self._export_faces() def _check_folder(self): @@ -448,6 +453,35 @@ def _check_folder(self): sys.exit(0) logger.verbose("Creating output folder at '%s'", self._faces_dir) + def _legacy_check(self): + """ Check whether the alignments file was created with the legacy extraction method. + + If so, force user to re-extract all faces if any options have been specified, otherwise + raise the appropriate warnings and set the legacy options. + """ + if self._arguments.large or self._arguments.extract_every_n != 1: + logger.warning("This alignments file was generated with the legacy extraction method.") + logger.warning("You should run this extraction job, but with 'large' deselected and " + "'extract-every-n' set to 1 to update the alignments file.") + logger.warning("You can then re-run this extraction job with your chosen options.") + sys.exit(0) + + maskers = ["components", "extended"] + nn_masks = [mask for mask in list(self._alignments.mask_summary) if mask not in maskers] + logtype = logger.warning if nn_masks else logger.info + logtype("This alignments file was created with the legacy extraction method and will be " + "updated.") + logtype("Faces will be extracted using the new method and landmarks based masks will be " + "regenerated.") + if nn_masks: + logtype("However, the NN based masks '%s' will be cropped to the legacy extraction " + "method, so you may want to run the mask tool to regenerate these " + "masks.", "', '".join(nn_masks)) + self._mask_pipeline = Extractor(None, None, maskers, multiprocess=True) + self._mask_pipeline.launch() + # Update alignments versioning + self._alignments._version = _VERSION # pylint:disable=protected-access + def _export_faces(self): """ Export the faces to the output folder and update the alignments file with new hashes. """ @@ -507,16 +541,21 @@ def _output_faces(self, filename, image): face_count = 0 frame_name, extension = os.path.splitext(filename) faces = self._select_valid_faces(filename, image) + if self._is_legacy: + faces = self._process_legacy(filename, image, faces) for idx, face in enumerate(faces): output = "{}_{}{}".format(frame_name, str(idx), extension) if self._arguments.large: - self._frames.save_image(self._faces_dir, output, face.aligned_face) + self._frames.save_image(self._faces_dir, output, face.aligned.face) else: output = os.path.join(self._faces_dir, output) f_hash = self._extracted_faces.save_face_with_hash(output, extension, - face.aligned_face) + face.aligned.face) + if self._is_legacy: # Generate the new thumbnail and store new face data for save + face.thumbnail = generate_thumbnail(face.aligned.face, size=96, quality=60) + self._alignments.data[filename]["faces"][idx] = face.to_alignment() self._alignments.data[filename]["faces"][idx]["hash"] = f_hash face_count += 1 return face_count @@ -534,7 +573,7 @@ def _select_valid_faces(self, frame, image): Returns ------- list: - List of valid :class:`lib,faces_detect.DetectedFace` objects + List of valid :class:`lib,align.DetectedFace` objects """ faces = self._extracted_faces.get_faces_in_frame(frame, image=image) if not self._arguments.large: @@ -547,63 +586,159 @@ def _select_valid_faces(self, frame, image): frame, len(faces), len(valid_faces)) return valid_faces + def _process_legacy(self, filename, image, detected_faces): + """ Process legacy face extractions to new extraction method. + + Updates stored masks to new extract size + + Parameters + ---------- + filename: str + The current frame filename + image: :class:`numpy.ndarray` + The current image the contains the faces + detected_faces: list + list of :class:`lib.align.DetectedFace` objects for the current frame + """ + # Update landmarks based masks for face centering + mask_item = ExtractMedia(filename, image, detected_faces=detected_faces) + self._mask_pipeline.input_queue.put(mask_item) + faces = next(self._mask_pipeline.detected_faces()).detected_faces + + # Pad and shift Neural Network based masks to face centering + for face in faces: + self._pad_legacy_masks(face) + return faces + + @classmethod + def _pad_legacy_masks(cls, detected_face): + """ Recenter legacy Neural Network based masks from legacy centering to face centering + and pad accordingly. + + Update the masks back into the detected face objects. + + Parameters + ---------- + detected_face: :class:`lib.align.DetectedFace` + The detected face to update the masks for + """ + offset = detected_face.aligned.pose.offset["face"] + for name, mask in detected_face.mask.items(): # Re-center mask and pad to face size + if name in ("components", "extended"): + continue + old_mask = mask.mask.astype("float32") / 255.0 + size = old_mask.shape[0] + new_size = int(size + (size * _EXTRACT_RATIOS["face"]) / 2) + + shift = np.rint(offset * (size - (size * _EXTRACT_RATIOS["face"]))).astype("int32") + pos = np.array([(new_size // 2 - size // 2) - shift[1], + (new_size // 2) + (size // 2) - shift[1], + (new_size // 2 - size // 2) - shift[0], + (new_size // 2) + (size // 2) - shift[0]]) + bounds = np.array([max(0, pos[0]), min(new_size, pos[1]), + max(0, pos[2]), min(new_size, pos[3])]) + + slice_in = [slice(0 - (pos[0] - bounds[0]), size - (pos[1] - bounds[1])), + slice(0 - (pos[2] - bounds[2]), size - (pos[3] - bounds[3]))] + slice_out = [slice(bounds[0], bounds[1]), slice(bounds[2], bounds[3])] + + new_mask = np.zeros((new_size, new_size, 1), dtype="float32") + new_mask[slice_out[0], slice_out[1], :] = old_mask[slice_in[0], slice_in[1], :] + + mask.replace_mask(new_mask) + # Get the affine matrix from recently generated components mask + # pylint:disable=protected-access + mask._affine_matrix = detected_face.mask["components"].affine_matrix + -class Merge(): - """ Merge two alignments files into one """ +class Merge(): # pylint:disable=too-few-public-methods + """ Merge multiple alignments files into one. + + Parameters + ---------- + alignments: :class:`tools.lib_alignments.media.AlignmentData` + The alignments data loaded from an alignments file for this rename job + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + """ def __init__(self, alignments, arguments): - self.alignments = alignments - self.faces = self.get_faces(arguments) - self.final_alignments = alignments[0] - self.process_alignments = alignments[1:] + self._alignments = alignments + self._check_versions() + self._faces = self._get_faces(arguments) + self._final_alignments = alignments[0] + self._process_alignments = alignments[1:] self._hashes_to_frame = None + def _check_versions(self): + """ Ensure all alignments files are compatible versions. If not, exit with error. """ + versions = [al.version for al in self._alignments] + logger.debug(versions) + if any(vers < 2.0 for vers in versions) and any(vers >= 2.0 for vers in versions): + logger.error("You have selected incompatible alignments files for merging. You cannot " + "merge alignments files for legacy extracted faces with aligments files " + "for full-head extracted faces.") + logger.info("You can update legacy alignments files by using the Extract job in the " + "Alignments tool to re-extract the faces in full-head format.") + sys.exit(0) + @staticmethod - def get_faces(arguments): - """ If faces argument is specified, load faces_dir - otherwise return None """ + def _get_faces(arguments): + """ If faces argument is specified, load the faces folder otherwise return ``None``. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + + Returns + ------- + :class:`tools.alignments.media.Faces` or ``None`` + The faces object or ``None`` if not faces folder specified + """ if not hasattr(arguments, "faces_dir") or not arguments.faces_dir: return None return Faces(arguments.faces_dir) def process(self): - """Process the alignments file merge """ + """Run the alignments file merging process. """ logger.info("[MERGE ALIGNMENTS]") # Tidy up cli output - if self.faces is not None: - self.remove_faces() - self._hashes_to_frame = self.final_alignments.hashes_to_frame + if self._faces is not None: + self._remove_faces() + self._hashes_to_frame = self._final_alignments.hashes_to_frame skip_count = 0 merge_count = 0 - total_count = sum([alignments.frames_count for alignments in self.process_alignments]) + total_count = sum([alignments.frames_count for alignments in self._process_alignments]) with tqdm(desc="Merging Alignments", total=total_count) as pbar: - for alignments in self.process_alignments: + for alignments in self._process_alignments: for _, src_alignments, _, frame in alignments.yield_faces(): for idx, alignment in enumerate(src_alignments): if not alignment.get("hash", None): logger.warning("Alignment '%s':%s has no Hash! Skipping", frame, idx) skip_count += 1 continue - if self.check_exists(frame, alignment, idx): + if self._check_exists(frame, alignment, idx): skip_count += 1 continue - self.merge_alignment(frame, alignment, idx) + self._merge_alignment(frame, alignment, idx) merge_count += 1 pbar.update(1) logger.info("Alignments Merged: %s", merge_count) logger.info("Alignments Skipped: %s", skip_count) if merge_count != 0: - self.set_destination_filename() - self.final_alignments.save() + self._set_destination_filename() + self._final_alignments.save() - def remove_faces(self): - """ Process to remove faces from an alignments file """ - face_hashes = list(self.faces.items.keys()) + def _remove_faces(self): + """ Removes faces from the alignments file if a faces folder has been provided and the + faces do not exist within each alignments file. """ + face_hashes = list(self._faces.items.keys()) del_faces_count = 0 del_frames_count = 0 if not face_hashes: logger.error("No face hashes. This would remove all faces from your alignments file.") return - for alignments in tqdm(self.alignments, desc="Filtering out faces"): + for alignments in tqdm(self._alignments, desc="Filtering out faces"): pre_face_count = alignments.faces_count pre_frames_count = alignments.frames_count alignments.filter_hashes(face_hashes, filter_out=False) @@ -622,105 +757,106 @@ def remove_faces(self): removed_faces, removed_frames, os.path.basename(alignments.file)) logger.info("Total removed - faces: %s, frames: %s", del_faces_count, del_frames_count) - def check_exists(self, frame, alignment, idx): - """ Check whether this face already exists """ + def _check_exists(self, frame, alignment, index): + """ Remove duplicate faces from the alignments file when an instance of the face already + exists. + + Parameters + ---------- + frame: str + The frame name for the current frame being processed + alignment: dict + The alignment dictionary for the current face in the frame + index: int + The face index for the current face in the frame + + Returns + ------- + bool + ``True`` if the face has been already been seen, otherwise ``False`` + """ existing_frame = self._hashes_to_frame.get(alignment["hash"], None) if not existing_frame: return False if frame in existing_frame.keys(): logger.verbose("Face '%s': %s already exists in destination at position %s. " - "Skipping", frame, idx, existing_frame[frame]) + "Skipping", frame, index, existing_frame[frame]) elif frame not in existing_frame.keys(): logger.verbose("Face '%s': %s exists in destination as: %s. " - "Skipping", frame, idx, existing_frame) + "Skipping", frame, index, existing_frame) return True - def merge_alignment(self, frame, alignment, idx): - """ Merge the source alignment into the destination """ + def _merge_alignment(self, frame, alignment, idx): + """ Merge the source alignment into the destination final alignments dictionary + + Parameters + ---------- + frame: str + The frame name for the current frame being processed + alignment: dict + The alignment dictionary for the current face in the frame + index: int + The face index for the current face in the frame + """ logger.debug("Merging alignment: (frame: %s, src_idx: %s, hash: %s)", frame, idx, alignment["hash"]) self._hashes_to_frame.setdefault(alignment["hash"], dict())[frame] = idx - self.final_alignments.data.setdefault(frame, - dict()).setdefault("faces", []).append(alignment) + self._final_alignments.data.setdefault(frame, + dict()).setdefault("faces", []).append(alignment) - def set_destination_filename(self): + def _set_destination_filename(self): """ Set the destination filename """ - folder = os.path.split(self.final_alignments.file)[0] - ext = os.path.splitext(self.final_alignments.file)[1] + folder = os.path.split(self._final_alignments.file)[0] + ext = os.path.splitext(self._final_alignments.file)[1] now = datetime.now().strftime("%Y%m%d_%H%M%S") filename = os.path.join(folder, "alignments_merged_{}{}".format(now, ext)) logger.debug("Output set to: '%s'", filename) - self.final_alignments.set_filename(filename) + self._final_alignments.set_filename(filename) + +class RemoveFaces(): # pylint:disable=too-few-public-methods + """ Remove items from alignments file. -class RemoveAlignments(): - """ Remove items from alignments file """ + Parameters + --------- + alignments: :class:`tools.alignments.media.AlignmentsData` + The loaded alignments containing faces to be removed + arguments: :class:`argparse.Namespace` + The command line arguments that have called this job + """ def __init__(self, alignments, arguments): logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - self.alignments = alignments - self.type = arguments.job.replace("remove-", "") - self.items = self.get_items(arguments) - self.removed = set() + self._alignments = alignments + self._items = Faces(arguments.faces_dir) logger.debug("Initialized %s", self.__class__.__name__) - def get_items(self, arguments): - """ Set the correct items to process """ - retval = None - if self.type == "frames": - retval = Frames(arguments.frames_dir).items - elif self.type == "faces": - retval = Faces(arguments.faces_dir) - return retval - def process(self): - """ run removal """ - logger.info("[REMOVE ALIGNMENTS DATA]") # Tidy up cli output - del_count = 0 - task = getattr(self, "remove_{}".format(self.type)) - - if self.type == "frames": - logger.debug("Removing Frames") - for frame in tqdm(list(item[3] for item in self.alignments.yield_faces()), - desc="Removing Frames", - total=self.alignments.frames_count): - del_count += task(frame) - else: - logger.debug("Removing Faces") - del_count = task() + """ Run the job to remove faces from an alignments file that do not exist within a faces + folder. """ + logger.info("[REMOVE FACES FROM ALIGNMENTS]") # Tidy up cli output + + face_hashes = self._items.items + if not face_hashes: + logger.error("No matching faces found in your faces folder. This would remove all " + "faces from your alignments file. Process aborted.") + return + + pre_face_count = self._alignments.faces_count + self._alignments.filter_hashes(face_hashes, filter_out=False) + del_count = pre_face_count - self._alignments.faces_count if del_count == 0: logger.info("No changes made to alignments file. Exiting") return logger.info("%s alignment(s) were removed from alignments file", del_count) - self.alignments.save() + self._alignments.save() - if self.type == "faces": - rename = Rename(self.alignments, None, self.items) - rename.process() - - def remove_frames(self, frame): - """ Process to remove frames from an alignments file """ - if frame in self.items: - logger.trace("Not deleting frame: '%s'", frame) - return 0 - logger.debug("Deleting frame: '%s'", frame) - del self.alignments.data[frame] - return 1 - - def remove_faces(self): - """ Process to remove faces from an alignments file """ - face_hashes = self.items.items - if not face_hashes: - logger.error("No face hashes. This would remove all faces from your alignments file.") - return 0 - pre_face_count = self.alignments.faces_count - self.alignments.filter_hashes(face_hashes, filter_out=False) - post_face_count = self.alignments.faces_count - return pre_face_count - post_face_count + rename = Rename(self._alignments, None, self._items) + rename.process() -class Rename(): +class Rename(): # pylint:disable=too-few-public-methods """ Rename faces in a folder to match their filename as stored in an alignments file. Parameters @@ -736,8 +872,8 @@ class Rename(): def __init__(self, alignments, arguments, faces=None): logger.debug("Initializing %s: (arguments: %s, faces: %s)", self.__class__.__name__, arguments, faces) - self.alignments = alignments - self.faces = faces if faces else Faces(arguments.faces_dir) + self._alignments = alignments + self._faces = faces if faces else Faces(arguments.faces_dir) logger.debug("Initialized %s", self.__class__.__name__) def process(self): @@ -762,9 +898,9 @@ def _build_rename_list(self): source_filenames = [] dest_filenames = [] errors = [] - pbar = tqdm(desc="Building Rename Lists", total=self.faces.count) - for disk_hash, disk_faces in self.faces.items.items(): - align_faces = self.alignments.hashes_to_frame.get(disk_hash, None) + pbar = tqdm(desc="Building Rename Lists", total=self._faces.count) + for disk_hash, disk_faces in self._faces.items.items(): + align_faces = self._alignments.hashes_to_frame.get(disk_hash, None) face_error = self._validate_hash_match(disk_faces, align_faces) if face_error is not None: errors.extend(face_error) @@ -837,7 +973,7 @@ def _get_filename_mapping(disk_faces, align_faces): source_filenames = [] dest_filenames = [] # Force deterministic order on alignments dict for multi hash faces - sorted_aligned = sorted([(frame, idx) for frame, idx in align_faces.items()]) + sorted_aligned = sorted(list(align_faces.items())) for disk_face, align_face in zip(disk_faces, sorted_aligned): extension = disk_face[1] src_fname = disk_face[0] + extension @@ -892,8 +1028,8 @@ def _rename_faces(self, filename_mappings): if src == dst: logger.debug("Skipping rename of '%s' as destination name is same as souce", src) continue - old = os.path.join(self.faces.folder, src) - new = os.path.join(self.faces.folder, dst) + old = os.path.join(self._faces.folder, src) + new = os.path.join(self._faces.folder, dst) if os.path.exists(new): # This should never happen, but is a safety measure to prevent deletion of faces # when multiple files have the same hash. @@ -910,7 +1046,7 @@ class Sort(): """ Sort alignments' index by the order they appear in an image """ def __init__(self, alignments, arguments): logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - self.alignments = alignments + self._alignments = alignments self.faces = self.get_faces(arguments) logger.debug("Initialized %s", self.__class__.__name__) @@ -927,26 +1063,26 @@ def process(self): logger.info("[SORT INDEXES]") # Tidy up cli output reindexed = self.reindex_faces() if reindexed: - self.alignments.save() + self._alignments.save() if self.faces: - rename = Rename(self.alignments, None, self.faces) + rename = Rename(self._alignments, None, self.faces) rename.process() def reindex_faces(self): """ Re-Index the faces """ reindexed = 0 - for alignment in tqdm(self.alignments.yield_faces(), - desc="Sort alignment indexes", total=self.alignments.frames_count): + for alignment in tqdm(self._alignments.yield_faces(), + desc="Sort alignment indexes", total=self._alignments.frames_count): frame, alignments, count, key = alignment if count <= 1: logger.trace("0 or 1 face in frame. Not sorting: '%s'", frame) continue - sorted_alignments = sorted([item for item in alignments], key=lambda x: (x["x"])) + sorted_alignments = sorted(alignments, key=lambda x: (x["x"])) if sorted_alignments == alignments: logger.trace("Alignments already in correct order. Not sorting: '%s'", frame) continue logger.trace("Sorting alignments for frame: '%s'", frame) - self.alignments.data[key]["faces"] = sorted_alignments + self._alignments.data[key]["faces"] = sorted_alignments reindexed += 1 logger.info("%s Frames had their faces reindexed", reindexed) return reindexed @@ -960,7 +1096,7 @@ class Spatial(): def __init__(self, alignments, arguments): logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self.arguments = arguments - self.alignments = alignments + self._alignments = alignments self.mappings = dict() self.normalized = dict() self.shapes_model = None @@ -979,7 +1115,7 @@ def process(self): landmarks = self.spatially_filter() landmarks = self.temporally_smooth(landmarks) self.update_alignments(landmarks) - self.alignments.save() + self._alignments.save() logger.info("Done! To re-extract faces run: python tools.py " "alignments -j extract -a %s -fr -fc " @@ -1023,12 +1159,12 @@ def normalized_to_original(shapes_normalized, scale_factors, mean_coords): def normalize(self): """ Compile all original and normalized alignments """ logger.debug("Normalize") - count = sum(1 for val in self.alignments.data.values() if val["faces"]) + count = sum(1 for val in self._alignments.data.values() if val["faces"]) landmarks_all = np.zeros((68, 2, int(count))) end = 0 - for key in tqdm(sorted(self.alignments.data.keys()), desc="Compiling"): - val = self.alignments.data[key]["faces"] + for key in tqdm(sorted(self._alignments.data.keys()), desc="Compiling"): + val = self._alignments.data[key]["faces"] if not val: continue # We should only be normalizing a single face, so just take @@ -1106,7 +1242,7 @@ def update_alignments(self, landmarks): logger.trace("Updating: (frame: %s)", frame) landmarks_update = landmarks[:, :, idx] landmarks_xy = landmarks_update.reshape(68, 2).tolist() - self.alignments.data[frame]["faces"][0]["landmarks_xy"] = landmarks_xy + self._alignments.data[frame]["faces"][0]["landmarks_xy"] = landmarks_xy logger.trace("Updated: (frame: '%s', landmarks: %s)", frame, landmarks_xy) logger.debug("Updated alignments") @@ -1115,7 +1251,7 @@ class UpdateHashes(): """ Update hashes in an alignments file """ def __init__(self, alignments, arguments): logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - self.alignments = alignments + self._alignments = alignments self.faces = Faces(arguments.faces_dir).file_list_sorted self.face_hashes = dict() logger.debug("Initialized %s", self.__class__.__name__) @@ -1128,7 +1264,7 @@ def process(self): if updated == 0: logger.info("No hashes were updated. Exiting") return - self.alignments.save() + self._alignments.save() logger.info("%s frame(s) had their face hashes updated.", updated) def get_hashes(self): @@ -1152,15 +1288,15 @@ def update_hashes(self): logger.info("Updating hashes to alignments...") updated = 0 for frame, hashes in self.face_hashes.items(): - if not self.alignments.frame_exists(frame): + if not self._alignments.frame_exists(frame): logger.warning("Frame not found in alignments file. Skipping: '%s'", frame) continue - if not self.alignments.frame_has_faces(frame): + if not self._alignments.frame_has_faces(frame): logger.warning("Frame does not have faces. Skipping: '%s'", frame) continue existing = [face.get("hash", None) - for face in self.alignments.get_faces_in_frame(frame)] + for face in self._alignments.get_faces_in_frame(frame)] if any(hsh not in existing for hsh in list(hashes.values())): - self.alignments.add_face_hashes(frame, hashes) + self._alignments.add_face_hashes(frame, hashes) updated += 1 return updated diff --git a/tools/alignments/media.py b/tools/alignments/media.py index 2b72339606..9c318eb602 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -7,17 +7,14 @@ import sys import cv2 -import numpy as np from tqdm import tqdm # TODO imageio single frame seek seems slow. Look into this # import imageio -from lib.aligner import Extract as AlignerExtract -from lib.alignments import Alignments, get_serializer -from lib.faces_detect import DetectedFace -from lib.image import (count_frames, encode_image_with_hash, ImagesLoader, read_image, - read_image_hash_batch) +from lib.align import Alignments, DetectedFace +from lib.image import (count_frames, encode_image_with_hash, generate_thumbnail, ImagesLoader, + read_image, read_image_hash_batch) from lib.utils import _image_extensions, _video_extensions logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -31,10 +28,6 @@ def __init__(self, alignments_file): self.__class__.__name__, alignments_file) logger.info("[ALIGNMENT DATA]") # Tidy up cli output folder, filename = self.check_file_exists(alignments_file) - if filename.lower() == "dfl": - self._serializer = get_serializer("compressed") - self._file = "{}.{}".format(filename.lower(), self._serializer.file_extension) - return super().__init__(folder, filename=filename) logger.verbose("%s items loaded", self.frames_count) logger.debug("Initialized %s", self.__class__.__name__) @@ -43,11 +36,7 @@ def __init__(self, alignments_file): def check_file_exists(alignments_file): """ Check the alignments file exists""" folder, filename = os.path.split(alignments_file) - if filename.lower() == "dfl": - folder = None - filename = "dfl" - logger.info("Using extracted DFL faces for alignments") - elif not os.path.isfile(alignments_file): + if not os.path.isfile(alignments_file): logger.error("ERROR: alignments file not found at: '%s'", alignments_file) sys.exit(0) if folder: @@ -78,27 +67,13 @@ def add_face_hashes(self, frame_name, hashes): for idx, i_hash in hashes.items(): faces[idx]["hash"] = i_hash - def data_from_dfl(self, alignments, faces_folder): - """ Set :attr:`data` from alignments extracted from a Deep Face Lab face set. - - Parameters - ---------- - alignments: dict - The extracted alignments from a Deep Face Lab face set - faces_folder: str - The folder that the faces are in, where the newly generated alignments file will - be saved - """ - self._data = alignments - self.set_filename(self._get_location(faces_folder, "alignments")) - def set_filename(self, filename): """ Set the :attr:`_file` to the given filename. Parameters ---------- filename: str - The full path and filename to se the alignments file name to + The full path and filename to set the alignments file name to """ self._file = filename @@ -268,8 +243,7 @@ def load_items(self): def sorted_items(self): """ Return the items sorted by face name """ - items = sorted([item for item in self.process_folder()], - key=lambda x: (x["face_name"])) + items = sorted(self.process_folder(), key=lambda x: (x["face_name"])) logger.trace(items) return items @@ -323,8 +297,7 @@ def load_items(self): def sorted_items(self): """ Return the items sorted by filename """ - items = sorted([item for item in self.process_folder()], - key=lambda x: (x["frame_name"])) + items = sorted(self.process_folder(), key=lambda x: (x["frame_name"])) logger.trace(items) return items @@ -332,11 +305,10 @@ def sorted_items(self): class ExtractedFaces(): """ Holds the extracted faces and matrix for alignments """ - def __init__(self, frames, alignments, size=256, align_eyes=False): + def __init__(self, frames, alignments, size=512): logger.trace("Initializing %s: size: %s", self.__class__.__name__, size) self.size = size self.padding = int(size * 0.1875) - self.align_eyes_bool = align_eyes self.alignments = alignments self.frames = frames self.current_frame = None @@ -363,8 +335,8 @@ def extract_one_face(self, alignment, image): self.current_frame, alignment) face = DetectedFace() face.from_alignment(alignment, image=image) - face.load_aligned(image, size=self.size) - face = self.align_eyes(face, image) if self.align_eyes_bool else face + face.load_aligned(image, size=self.size, centering="head") + face.thumbnail = generate_thumbnail(face.aligned.face, size=80, quality=60) return face def get_faces_in_frame(self, frame, update=False, image=None): @@ -382,7 +354,7 @@ def get_roi_size_for_frame(self, frame): self.get_faces(frame) sizes = list() for face in self.faces: - roi = face.original_roi.squeeze() + roi = face.aligned.original_roi.squeeze() top_left, top_right = roi[0], roi[3] len_x = top_right[0] - top_left[0] len_y = top_right[1] - top_left[1] @@ -402,28 +374,3 @@ def save_face_with_hash(filename, extension, face): with open(filename, "wb") as out_file: out_file.write(img) return f_hash - - @staticmethod - def align_eyes(face, image): - """ Re-extract a face with the pupils forced to be absolutely horizontally aligned """ - umeyama_landmarks = face.aligned_landmarks - left_eye_center = umeyama_landmarks[42:48].mean(axis=0) - right_eye_center = umeyama_landmarks[36:42].mean(axis=0) - d_y = right_eye_center[1] - left_eye_center[1] - d_x = right_eye_center[0] - left_eye_center[0] - theta = np.pi - np.arctan2(d_y, d_x) - rot_cos = np.cos(theta) - rot_sin = np.sin(theta) - rotation_matrix = np.array([[rot_cos, -rot_sin, 0.], - [rot_sin, rot_cos, 0.], - [0., 0., 1.]]) - - mat_umeyama = np.concatenate((face.aligned["matrix"], np.array([[0., 0., 1.]])), axis=0) - corrected_mat = np.dot(rotation_matrix, mat_umeyama) - face.aligned["matrix"] = corrected_mat[:2] - face.aligned["face"] = AlignerExtract().transform(image, - face.aligned["matrix"], - face.aligned["size"], - int(face.aligned["size"] * 0.375) // 2) - logger.trace("Adjusted matrix: %s", face.aligned["matrix"]) - return face diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index 27ddbd31ea..fd96d286f6 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 """ Alignments handling for Faceswap's Manual Adjustments tool. Handles the conversion of -alignments data to :class:`~lib.faces_detect.DetectedFace` objects, and the update of these faces +alignments data to :class:`~lib.align.DetectedFace` objects, and the update of these faces when edits are made in the GUI. """ import logging import os +import sys import tkinter as tk from copy import deepcopy from queue import Queue, Empty @@ -16,12 +17,11 @@ import numpy as np from tqdm import tqdm -from lib.aligner import Extract as AlignerExtract -from lib.alignments import Alignments -from lib.faces_detect import DetectedFace +from lib.align import Alignments, AlignedFace, DetectedFace from lib.gui.custom_widgets import PopupProgress from lib.gui.utils import FileHandler -from lib.image import SingleFrameLoader, ImagesLoader, ImagesSaver, encode_image_with_hash +from lib.image import (SingleFrameLoader, ImagesLoader, ImagesSaver, encode_image_with_hash, + generate_thumbnail) from lib.multithreading import MultiThread from lib.utils import get_folder @@ -30,7 +30,7 @@ class DetectedFaces(): - """ Handles the manipulation of :class:`~lib.faces_detect.DetectedFace` objects stored + """ Handles the manipulation of :class:`~lib.align.DetectedFace` objects stored in the alignments file. Acts as a parent class for the IO operations (saving and loading from an alignments file), the face update operations (when changes are made to alignments in the GUI) and the face filters (when a user changes the filter navigation mode.) @@ -79,7 +79,7 @@ def filter(self): @property def update(self): """ :class:`FaceUpdate`: Handles the adding, removing and updating of - :class:`~lib.faces_detect.DetectedFace` stored within the alignments file. """ + :class:`~lib.align.DetectedFace` stored within the alignments file. """ return self._children["update"] # << TKINTER VARIABLES >> # @@ -110,7 +110,7 @@ def available_masks(self): @property def current_faces(self): - """ list: The most up to date full list of :class:`~lib.faces_detect.DetectedFace` + """ list: The most up to date full list of :class:`~lib.align.DetectedFace` objects. """ return self._frame_faces @@ -135,7 +135,7 @@ def is_frame_updated(self, frame_index): return frame_index in self._updated_frame_indices def load_faces(self): - """ Load the faces as :class:`~lib.faces_detect.DetectedFace` objects from the alignments + """ Load the faces as :class:`~lib.align.DetectedFace` objects from the alignments file. """ self._children["io"].load() @@ -197,7 +197,7 @@ def _set_tk_vars(): return retval def _get_alignments(self, alignments_path, input_location): - """ Get the :class:`~lib.alignments.Alignments` object for the given location. + """ Get the :class:`~lib.align.Alignments` object for the given location. Parameters ---------- @@ -209,7 +209,7 @@ def _get_alignments(self, alignments_path, input_location): Returns ------- - :class:`~lib.alignments.Alignments` + :class:`~lib.align.Alignments` The alignments object for the given input location """ logger.debug("alignments_path: %s, input_location: %s", alignments_path, input_location) @@ -223,12 +223,17 @@ def _get_alignments(self, alignments_path, input_location): else: folder = input_location retval = Alignments(folder, filename) + if retval.version == 1.0: + logger.error("The Manual Tool is not compatible with legacy Alignments files.") + logger.info("You can update legacy Alignments files by using the Extract job in the " + "Alignments tool to re-extract the faces in full-head format.") + sys.exit(0) logger.debug("folder: %s, filename: %s, alignments: %s", folder, filename, retval) return retval class _DiskIO(): # pylint:disable=too-few-public-methods - """ Handles the loading of :class:`~lib.faces_detect.DetectedFaces` from the alignments file + """ Handles the loading of :class:`~lib.align.DetectedFaces` from the alignments file into :class:`DetectedFaces` and the saving of this data (in the opposite direction) to an alignments file. @@ -255,7 +260,7 @@ def __init__(self, detected_faces, input_location): def load(self): """ Load the faces from the alignments file, convert to - :class:`~lib.faces_detect.DetectedFace`. objects and add to :attr:`_frame_faces`. """ + :class:`~lib.align.DetectedFace`. objects and add to :attr:`_frame_faces`. """ for key in sorted(self._alignments.data): this_frame_faces = [] for item in self._alignments.data[key]["faces"]: @@ -265,7 +270,7 @@ def load(self): self._frame_faces.append(this_frame_faces) def save(self): - """ Convert updated :class:`~lib.faces_detect.DetectedFace` objects to alignments format + """ Convert updated :class:`~lib.align.DetectedFace` objects to alignments format and save the alignments file. """ if not self._tk_unsaved.get(): logger.debug("Alignments not updated. Returning") @@ -300,7 +305,7 @@ def revert_to_saved(self, frame_index): reset_grid = self._add_remove_faces(alignments, faces) for detected_face, face in zip(faces, alignments): - detected_face.from_alignment(face) + detected_face.from_alignment(face, with_thumb=True) self._updated_frame_indices.remove(frame_index) if not self._updated_frame_indices: @@ -348,7 +353,7 @@ def extract(self): thread.start() self._monitor_extract(thread, queue, pbar) - def _monitor_extract(self, thread, queue, pbar): + def _monitor_extract(self, thread, queue, progress_bar): """ Monitor the extraction thread, and update the progress bar. On completion, save alignments and clear progress bar. @@ -359,27 +364,27 @@ def _monitor_extract(self, thread, queue, pbar): The thread that is performing the extraction task queue: :class:`queue.Queue` The queue that the worker thread is putting it's incremental counts to - pbar: :class:`lib.gui.custom_widget.PopupProgress` + progress_bar: :class:`lib.gui.custom_widget.PopupProgress` The popped up progress bar """ thread.check_and_raise_error() if not thread.is_alive(): thread.join() # Update hashes in alignments file. - pbar.update_title("Saving Alignments...") + progress_bar.update_title("Saving Alignments...") self._alignments.backup() self._alignments.save() self._updated_frame_indices.clear() self._tk_unsaved.set(False) - pbar.stop() + progress_bar.stop() return while True: try: - pbar.step(queue.get(False, 0)) + progress_bar.step(queue.get(False, 0)) except Empty: break - pbar.after(100, self._monitor_extract, thread, queue, pbar) + progress_bar.after(100, self._monitor_extract, thread, queue, progress_bar) def _background_extract(self, output_folder, progress_queue): """ Perform the background extraction in a thread so GUI doesn't become unresponsive. @@ -389,23 +394,26 @@ def _background_extract(self, output_folder, progress_queue): output_folder: str The location to save the output faces to progress_queue: :class:`queue.Queue` - The queue to place incrememental counts to for updating the GUI's progress bar + The queue to place incremental counts to for updating the GUI's progress bar """ saver = ImagesSaver(str(get_folder(output_folder)), as_bytes=True) loader = ImagesLoader(self._input_location, count=self._alignments.frames_count) for frame_idx, (filename, image) in enumerate(loader.load()): logger.trace("Outputting frame: %s: %s", frame_idx, filename) - frame_name, extension = os.path.splitext(filename) + basename = os.path.basename(filename) + frame_name, extension = os.path.splitext(basename) final_faces = [] progress_queue.put(1) for face_idx, face in enumerate(self._frame_faces[frame_idx]): output = "{}_{}{}".format(frame_name, str(face_idx), extension) - face.load_aligned(image, size=256, force=True) # TODO user selectable size - face.hash, b_image = encode_image_with_hash(face.aligned_face, extension) + aligned = AlignedFace(face.landmarks_xy, + image=image, + centering="head", + size=512) # TODO user selectable size + face.hash, b_image = encode_image_with_hash(aligned.face, extension) saver.save(output, b_image) final_faces.append(face.to_alignment()) - face.aligned = dict() - self._alignments.data[filename]["faces"] = final_faces + self._alignments.data[basename]["faces"] = final_faces saver.close() @@ -476,7 +484,7 @@ def frames_list(self): class FaceUpdate(): - """ Perform updates on :class:`~lib.faces_detect.DetectedFace` objects stored in + """ Perform updates on :class:`~lib.align.DetectedFace` objects stored in :class:`DetectedFaces` when changes are made within the GUI. Parameters @@ -530,7 +538,7 @@ def _faces_at_frame_index(self, frame_index): Returns ------- list - The :class:`~lib.faces_detect.DetectedFace` objects for the requested frame + The :class:`~lib.align.DetectedFace` objects for the requested frame """ if not self._updated_frame_indices and not self._tk_unsaved.get(): self._tk_unsaved.set(True) @@ -538,22 +546,8 @@ def _faces_at_frame_index(self, frame_index): retval = self._frame_faces[frame_index] return retval - def _generate_thumbnail(self, face): - """ Generate the jpg thumbnail from the currently active frame for the detected face and - assign to it's `thumbnail` attribute. - - Parameters - ---------- - face: class:`~lib.faces_detect.DetectedFace` - The detected face object to generate the thumbnail for - """ - face.load_aligned(self._globals.current_frame["image"], 80, force=True) - jpg = cv2.imencode(".jpg", face.aligned_face, [cv2.IMWRITE_JPEG_QUALITY, 60])[1] - face.thumbnail = jpg - face.aligned = dict() - def add(self, frame_index, pnt_x, width, pnt_y, height): - """ Add a :class:`~lib.faces_detect.DetectedFace` object to the current frame with the + """ Add a :class:`~lib.align.DetectedFace` object to the current frame with the given dimensions. Parameters @@ -578,7 +572,7 @@ def add(self, frame_index, pnt_x, width, pnt_y, height): self._tk_face_count_changed.set(True) def delete(self, frame_index, face_index): - """ Delete the :class:`~lib.faces_detect.DetectedFace` object for the given frame and face + """ Delete the :class:`~lib.align.DetectedFace` object for the given frame and face indices. Parameters @@ -595,7 +589,7 @@ def delete(self, frame_index, face_index): self._globals.tk_update.set(True) def bounding_box(self, frame_index, face_index, pnt_x, width, pnt_y, height, aligner="FAN"): - """ Update the bounding box for the :class:`~lib.faces_detect.DetectedFace` object at the + """ Update the bounding box for the :class:`~lib.align.DetectedFace` object at the given frame and face indices, with the given dimensions and update the 68 point landmarks from the :class:`~tools.manual.manual.Aligner` for the updated bounding box. @@ -627,7 +621,7 @@ def bounding_box(self, frame_index, face_index, pnt_x, width, pnt_y, height, ali self._globals.tk_update.set(True) def landmark(self, frame_index, face_index, landmark_index, shift_x, shift_y, is_zoomed): - """ Shift a single landmark point for the :class:`~lib.faces_detect.DetectedFace` object + """ Shift a single landmark point for the :class:`~lib.align.DetectedFace` object at the given frame and face indices by the given x and y values. Parameters @@ -648,13 +642,12 @@ def landmark(self, frame_index, face_index, landmark_index, shift_x, shift_y, is """ face = self._faces_at_frame_index(frame_index)[face_index] if is_zoomed: - if not np.any(face.aligned_landmarks): # This will be None on a resize - face.load_aligned(None, size=min(self._globals.frame_display_dims)) - landmark = face.aligned_landmarks[landmark_index] + aligned = AlignedFace(face.landmarks_xy, + centering="face", + size=min(self._globals.frame_display_dims)) + landmark = aligned.landmarks[landmark_index] landmark += (shift_x, shift_y) - matrix = AlignerExtract.transform_matrix(face.aligned["matrix"], - face.aligned["size"], - face.aligned["padding"]) + matrix = aligned.adjusted_matrix matrix = cv2.invertAffineTransform(matrix) if landmark.ndim == 1: landmark = np.reshape(landmark, (1, 1, 2)) @@ -672,7 +665,7 @@ def landmark(self, frame_index, face_index, landmark_index, shift_x, shift_y, is def landmarks(self, frame_index, face_index, shift_x, shift_y): """ Shift all of the landmarks and bounding box for the - :class:`~lib.faces_detect.DetectedFace` object at the given frame and face indices by the + :class:`~lib.align.DetectedFace` object at the given frame and face indices by the given x and y values and update the masks. Parameters @@ -700,7 +693,7 @@ def landmarks(self, frame_index, face_index, shift_x, shift_y): def landmarks_rotate(self, frame_index, face_index, angle, center): """ Rotate the landmarks on an Extract Box rotate for the - :class:`~lib.faces_detect.DetectedFace` object at the given frame and face indices for the + :class:`~lib.align.DetectedFace` object at the given frame and face indices for the given angle from the given center point. Parameters @@ -723,7 +716,7 @@ def landmarks_rotate(self, frame_index, face_index, angle, center): def landmarks_scale(self, frame_index, face_index, scale, center): """ Scale the landmarks on an Extract Box resize for the - :class:`~lib.faces_detect.DetectedFace` object at the given frame and face indices from the + :class:`~lib.align.DetectedFace` object at the given frame and face indices from the given center point. Parameters @@ -743,7 +736,7 @@ def landmarks_scale(self, frame_index, face_index, scale, center): self._globals.tk_update.set(True) def mask(self, frame_index, face_index, mask, mask_type): - """ Update the mask on an edit for the :class:`~lib.faces_detect.DetectedFace` object at + """ Update the mask on an edit for the :class:`~lib.align.DetectedFace` object at the given frame and face indices, for the given mask and mask type. Parameters @@ -803,10 +796,11 @@ def post_edit_trigger(self, frame_index, face_index): The face index within the frame """ face = self._frame_faces[frame_index][face_index] - face.load_aligned(self._globals.current_frame["image"], 80, force=True) - jpg = cv2.imencode(".jpg", face.aligned_face, [cv2.IMWRITE_JPEG_QUALITY, 60])[1] - face.thumbnail = jpg - face.aligned = dict() + aligned = AlignedFace(face.landmarks_xy, + image=self._globals.current_frame["image"], + centering="head", + size=96) + face.thumbnail = generate_thumbnail(aligned.face, size=96) self._tk_edited.set(True) @@ -817,7 +811,7 @@ class ThumbsCreator(): Parameters ---------- detected_faces: :class:`~tool.manual.faces.DetectedFaces` - The :class:`~lib.faces_detect.DetectedFace` objects for this video + The :class:`~lib.align.DetectedFace` objects for this video input_location: str The location of the input folder of frames or video file """ @@ -826,7 +820,6 @@ def __init__(self, detected_faces, input_location, single_process): "single_process: %s)", self.__class__.__name__, detected_faces, input_location, single_process) self._size = 80 - self._jpeg_quality = 60 self._pbar = dict(pbar=None, lock=Lock()) self._meta = dict(key_frames=detected_faces.video_meta_data.get("keyframes", None), pts_times=detected_faces.video_meta_data.get("pts_time", None)) @@ -1029,12 +1022,11 @@ def _set_thumbail(self, filename, frame, frame_index): The frame index of this frame in the :attr:`_frame_faces` """ for face_idx, face in enumerate(self._frame_faces[frame_index]): - face.load_aligned(frame, size=self._size, force=True) - jpg = cv2.imencode(".jpg", - face.aligned_face, - [cv2.IMWRITE_JPEG_QUALITY, self._jpeg_quality])[1] - face.thumbnail = jpg - self._alignments.thumbnails.add_thumbnail(filename, face_idx, jpg) - face.aligned["face"] = None + aligned = AlignedFace(face.landmarks_xy, + image=frame, + centering="head", + size=96) + face.thumbnail = generate_thumbnail(aligned.face, size=96) + self._alignments.thumbnails.add_thumbnail(filename, face_idx, face.thumbnail) with self._pbar["lock"]: self._pbar["pbar"].update(1) diff --git a/tools/manual/faceviewer/frame.py b/tools/manual/faceviewer/frame.py index 169a87d8bd..5633c218f7 100644 --- a/tools/manual/faceviewer/frame.py +++ b/tools/manual/faceviewer/frame.py @@ -30,7 +30,7 @@ class FacesFrame(ttk.Frame): # pylint:disable=too-many-ancestors tk_globals: :class:`~tools.manual.manual.TkGlobals` The tkinter variables that apply to the whole of the GUI detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` - The :class:`~lib.faces_detect.DetectedFace` objects for this video + The :class:`~lib.align.DetectedFace` objects for this video display_frame: :class:`~tools.manual.frameviewer.frame.DisplayFrame` The section of the Manual Tool that holds the frames viewer """ @@ -228,7 +228,7 @@ class FacesViewer(tk.Canvas): # pylint:disable=too-many-ancestors The :class:`tkinter.BooleanVar` objects for selectable optional annotations as set by the buttons in the :class:`FacesActionsFrame` detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` - The :class:`~lib.faces_detect.DetectedFace` objects for this video + The :class:`~lib.align.DetectedFace` objects for this video display_frame: :class:`~tools.manual.frameviewer.frame.DisplayFrame` The section of the Manual Tool that holds the frames viewer event: :class:`threading.Event` @@ -467,7 +467,7 @@ class Grid(): canvas: :class:`tkinter.Canvas` The :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` - The :class:`~lib.faces_detect.DetectedFace` objects for this video + The :class:`~lib.align.DetectedFace` objects for this video """ def __init__(self, canvas, detected_faces): logger.debug("Initializing %s: (detected_faces: %s)", @@ -652,7 +652,7 @@ def _get_display_faces(self): ------- :class:`numpy.ndarray` Array of dimensions (rows, columns) corresponding to the display grid, containing the - corresponding :class:`lib.faces_detect.DetectFace` object + corresponding :class:`lib.align.DetectFace` object Any remaining placeholders at the end of the grid which are not populated with a face are replaced with ``None`` diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index a467f9671a..10db6cd111 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -8,6 +8,7 @@ import numpy as np from PIL import Image, ImageTk +from lib.align import AlignedFace logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -27,6 +28,7 @@ def __init__(self, canvas, tk_edited_variable): self.__class__.__name__, canvas, tk_edited_variable) self._canvas = canvas self._grid = canvas.grid + self._centering = "face" self._tk_selected_editor = canvas._display_frame.tk_selected_action self._landmark_mapping = dict(mouth_inner=(60, 68), mouth_outer=(48, 60), @@ -176,7 +178,7 @@ def get_tk_face(self, frame_index, face_index, face): The frame index to obtain the face for face_index: int The face index of the face within the requested frame - face: :class:`~lib.faces_detect.DetectedFace` + face: :class:`~lib.align.DetectedFace` The detected face object, containing the thumbnail jpg Returns @@ -190,13 +192,16 @@ def get_tk_face(self, frame_index, face_index, face): if key not in self._tk_faces or is_active: logger.trace("creating new tk_face: (key: %s, is_active: %s)", key, is_active) if is_active: - face.load_aligned(self._active_frame.current_frame, - size=self.face_size, - force=True) - image = face.aligned_face - face.aligned = dict() + image = AlignedFace(face.landmarks_xy, + image=self._active_frame.current_frame, + centering=self._centering, + size=self.face_size).face else: - image = face.thumbnail + image = AlignedFace(face.landmarks_xy, + image=cv2.imdecode(face.thumbnail, cv2.IMREAD_UNCHANGED), + centering=self._centering, + size=self.face_size, + is_aligned=True).face tk_face = self._get_tk_face_object(face, image, is_active) self._tk_faces[key] = tk_face else: @@ -213,7 +218,7 @@ def _get_tk_face_object(self, face, image, is_active): Parameters ---------- - face: :class:`lib.faces_detect.DetectedFace` + face: :class:`lib.align.DetectedFace` A detected face object to create the :class:`TKFace` from image: :class:`numpy.ndarray` The jpg thumbnail or the 3 channel image for the face @@ -247,6 +252,8 @@ def get_landmarks(self, frame_index, face_index, face, top_left, refresh=False): The frame index to obtain the face for face_index: int The face index of the face within the requested frame + face: :class:`lib.align.DetectedFace` + The detected face object to obtain landmarks for top_left: tuple The top left (x, y) points of the face's bounding box within the viewport refresh: bool, optional @@ -263,10 +270,12 @@ def get_landmarks(self, frame_index, face_index, face, top_left, refresh=False): key = "{}_{}".format(frame_index, face_index) landmarks = self._landmarks.get(key, None) if not landmarks or refresh: - face.load_aligned(None, size=self.face_size, force=True) + aligned = AlignedFace(face.landmarks_xy, + centering=self._centering, + size=self.face_size) landmarks = dict(polygon=[], line=[]) for area, val in self._landmark_mapping.items(): - points = face.aligned_landmarks[val[0]:val[1]] + top_left + points = aligned.landmarks[val[0]:val[1]] + top_left shape = "polygon" if area.endswith("eye") or area.startswith("mouth") else "line" landmarks[shape].append(points) self._landmarks[key] = landmarks @@ -359,7 +368,7 @@ def visible_grid(self): @property def visible_faces(self): - """ :class:`numpy.ndarray`: The currently visible :class:`~lib.faces_detect.DetectedFace` + """ :class:`numpy.ndarray`: The currently visible :class:`~lib.align.DetectedFace` objects. A numpy array of shape (`rows`, `columns`) corresponding to the viewable area of the @@ -396,8 +405,8 @@ def _top_left(self): def update(self): """ Load and unload thumbnails in the visible area of the faces viewer. """ self._visible_grid, self._visible_faces = self._grid.visible_area - if (isinstance(self._images, np.ndarray) and - self._visible_grid.shape[-1] != self._images.shape[-1]): + if (isinstance(self._images, np.ndarray) and isinstance(self._visible_grid, np.ndarray) + and self._visible_grid.shape[-1] != self._images.shape[-1]): self._recycle_objects() required_rows = self._visible_grid.shape[1] if self._grid.is_valid else 0 @@ -521,12 +530,12 @@ def _get_mesh(self): else: tags = ["viewport", "viewport_mesh"] mesh = dict(polygon=[self._canvas.create_polygon(0, 0, - width=1, + width=2, tags=tags + ["viewport_polygon"], **kwargs["polygon"]) for _ in range(4)], line=[self._canvas.create_line(0, 0, 0, 0, - width=1, + width=2, tags=tags + ["viewport_line"], **kwargs["line"]) for _ in range(5)]) @@ -881,7 +890,7 @@ def _show_mesh(self, mesh_ids, face_index, detected_face, top_left): the mesh for the given face face_index: int The face index within the frame for the given face - detected_face: :class:`~lib.faces_detect.DetectedFace` + detected_face: :class:`~lib.align.DetectedFace` The detected face object that contains the landmarks for generating the mesh top_left: tuple The (x, y) top left co-ordinates of the mesh's bounding box @@ -893,18 +902,14 @@ def _show_mesh(self, mesh_ids, face_index, detected_face, top_left): edited = (self._tk_vars["edited"].get() and self._tk_vars["selected_editor"].get() not in ("Mask", "View")) - relocate = self._viewport.face_size != self._last_execution["size"] or ( - state == "normal" and not self._optional_annotations["mesh"]) - if relocate or edited: - landmarks = self._viewport.get_landmarks(self.frame_index, - face_index, - detected_face, - top_left, - edited) + landmarks = self._viewport.get_landmarks(self.frame_index, + face_index, + detected_face, + top_left, + edited) for key, kwarg in kwargs.items(): for idx, mesh_id in enumerate(mesh_ids[key]): - if relocate: - self._canvas.coords(mesh_id, *landmarks[key][idx].flatten()) + self._canvas.coords(mesh_id, *landmarks[key][idx].flatten()) self._canvas.itemconfig(mesh_id, state=state, **kwarg) self._canvas.addtag_withtag("active_mesh_{}".format(key), mesh_id) diff --git a/tools/manual/frameviewer/control.py b/tools/manual/frameviewer/control.py index 6d76ea82bf..8c4317eb8c 100644 --- a/tools/manual/frameviewer/control.py +++ b/tools/manual/frameviewer/control.py @@ -9,6 +9,8 @@ import numpy as np from PIL import Image, ImageTk +from lib.align import AlignedFace + logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -148,7 +150,7 @@ def goto_last_frame(self): self._globals.tk_transport_index.set(frame_count - 1) -class BackgroundImage(): +class BackgroundImage(): # pylint:disable=too-few-public-methods """ The background image of the canvas """ def __init__(self, canvas): self._canvas = canvas @@ -233,10 +235,10 @@ def _get_zoomed_face(self): face = np.ones((size, size, 3), dtype="uint8") else: det_face = self._det_faces.current_faces[frame_idx][face_idx] - det_face.load_aligned(self._globals.current_frame["image"], size=size, force=True) - face = det_face.aligned_face.copy() - det_face.aligned["image"] = None - + face = AlignedFace(det_face.landmarks_xy, + image=self._globals.current_frame["image"], + centering="face", + size=size).face logger.trace("face shape: %s", face.shape) return face[..., 2::-1] diff --git a/tools/manual/frameviewer/editor/bounding_box.py b/tools/manual/frameviewer/editor/bounding_box.py index 58a7b30c49..888cdca97b 100644 --- a/tools/manual/frameviewer/editor/bounding_box.py +++ b/tools/manual/frameviewer/editor/bounding_box.py @@ -365,7 +365,7 @@ def _move(self, event): self._drag_data["current_location"] = (event.x, event.y) def _coords_to_bounding_box(self, coords): - """ Converts tkinter coordinates to :class:`lib.faces_detect.DetectedFace` bounding + """ Converts tkinter coordinates to :class:`lib.align.DetectedFace` bounding box format, scaled up and offset for feeding the model. Returns diff --git a/tools/manual/frameviewer/editor/extract_box.py b/tools/manual/frameviewer/editor/extract_box.py index d781221c82..9bc7591643 100644 --- a/tools/manual/frameviewer/editor/extract_box.py +++ b/tools/manual/frameviewer/editor/extract_box.py @@ -5,6 +5,7 @@ import numpy as np +from lib.align import AlignedFace from lib.gui.custom_widgets import RightClickMenu from lib.gui.utils import get_config from ._base import Editor, logger @@ -48,12 +49,12 @@ def update_annotation(self): color = self._control_color roi = self._zoomed_roi for idx, face in enumerate(self._face_iterator): - logger.trace("Drawing Extract Box: (idx: %s, roi: %s)", idx, face.original_roi) + logger.trace("Drawing Extract Box: (idx: %s)", idx) if self._globals.is_zoomed: box = np.array((roi[0], roi[1], roi[2], roi[1], roi[2], roi[3], roi[0], roi[3])) else: - face.load_aligned(None, force=True) - box = self._scale_to_display(face.original_roi).flatten() + aligned = AlignedFace(face.landmarks_xy, centering="face") + box = self._scale_to_display(aligned.original_roi).flatten() top_left = box[:2] - 10 kwargs = dict(fill=color, font=("Default", 20, "bold"), text=str(idx)) self._object_tracker("eb_text", "text", idx, top_left, kwargs) diff --git a/tools/manual/frameviewer/editor/landmarks.py b/tools/manual/frameviewer/editor/landmarks.py index c2d2b3464f..83d944072b 100644 --- a/tools/manual/frameviewer/editor/landmarks.py +++ b/tools/manual/frameviewer/editor/landmarks.py @@ -2,6 +2,7 @@ """ Landmarks Editor and Landmarks Mesh viewer for the manual adjustments tool """ import numpy as np +from lib.align import AlignedFace from ._base import Editor, logger @@ -70,7 +71,10 @@ def update_annotation(self): for face_idx, face in enumerate(self._face_iterator): face_index = self._globals.face_index if self._globals.is_zoomed else face_idx if self._globals.is_zoomed: - landmarks = face.aligned_landmarks + zoomed_offset + aligned = AlignedFace(face.landmarks_xy, + centering="face", + size=min(self._globals.frame_display_dims)) + landmarks = aligned.landmarks + zoomed_offset # Hide all landmarks and only display selected self._canvas.itemconfig("lm_dsp", state="hidden") self._canvas.itemconfig("lm_dsp_face_{}".format(face_index), state="normal") @@ -438,7 +442,10 @@ def update_annotation(self): for face_idx, face in enumerate(self._face_iterator): face_index = self._globals.face_index if self._globals.is_zoomed else face_idx if self._globals.is_zoomed: - landmarks = face.aligned_landmarks + zoomed_offset + aligned = AlignedFace(face.landmarks_xy, + centering="face", + size=min(self._globals.frame_display_dims)) + landmarks = aligned.landmarks + zoomed_offset # Hide all meshes and only display selected self._canvas.itemconfig("Mesh", state="hidden") self._canvas.itemconfig("Mesh_face_{}".format(face_index), state="normal") diff --git a/tools/manual/frameviewer/editor/mask.py b/tools/manual/frameviewer/editor/mask.py index 9a102ae833..945248b2b4 100644 --- a/tools/manual/frameviewer/editor/mask.py +++ b/tools/manual/frameviewer/editor/mask.py @@ -176,7 +176,7 @@ def _set_full_frame_meta(self, mask, mask_scale): Parameters ---------- - mask: :class:`lib.faces_detect.Mask` + mask: :class:`lib.align.Mask` The mask object mask_scale: float The scaling factor from the stored mask size to the internal mask size @@ -325,7 +325,8 @@ def _update_mask_image_full_frame(self, mask, rgb_color, face_index): frame_dims, frame, flags=cv2.WARP_INVERSE_MAP | interpolator, - borderMode=cv2.BORDER_CONSTANT)[slices[0], slices[1]][..., None] + borderMode=cv2.BORDER_CONSTANT)[slices[0], slices[1]] + mask = mask[..., None] if mask.ndim == 2 else mask rgb = np.tile(rgb_color, mask.shape).astype("uint8") rgba = np.concatenate((rgb, np.minimum(mask, self._meta["roi_mask"][face_index])), axis=2) return Image.fromarray(rgba) @@ -333,7 +334,7 @@ def _update_mask_image_full_frame(self, mask, rgb_color, face_index): def _update_roi_box(self, mask, face_index, color): """ Update the region of interest box for the current mask. - mask: :class:`~lib.faces_detect.Mask` + mask: :class:`~lib.align.Mask` The current mask object to create an ROI box for face_index: int The index of the face within the current frame diff --git a/tools/manual/manual.py b/tools/manual/manual.py index 164a8e4908..651810c840 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -685,7 +685,7 @@ def link_faces(self, detected_faces): Parameters ---------- detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` - The class that holds the :class:`~lib.faces_detect.DetectedFace` objects for the + The class that holds the :class:`~lib.align.DetectedFace` objects for the current Manual session """ logger.debug("Linking detected_faces: %s", detected_faces) diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 5c1335936d..d7ed158580 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -8,8 +8,7 @@ import numpy as np from tqdm import tqdm -from lib.alignments import Alignments -from lib.faces_detect import DetectedFace +from lib.align import Alignments, AlignedFace, DetectedFace from lib.image import FacesLoader, ImagesLoader, ImagesSaver from lib.multithreading import MultiThread @@ -349,6 +348,7 @@ def _update_frames(self, extractor_output): 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, idx, detected_face): @@ -395,15 +395,20 @@ def _create_image(self, detected_face): 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 = detected_face.image + face = AlignedFace(detected_face.landmarks_xy, + image=detected_face.image, + centering="face", + size=detected_face.image.shape[0], + is_aligned=True).face else: - detected_face.load_aligned(detected_face.image) - face = detected_face.aligned_face + centering = "legacy" if self._alignments.version == 1.0 else "face" + detected_face.load_aligned(detected_face.image, centering=centering) + face = detected_face.aligned.face mask = cv2.resize(detected_face.mask[self._mask_type].mask, (face.shape[1], face.shape[0]), interpolation=cv2.INTER_CUBIC)[..., None] else: - face = detected_face.image + face = np.array(detected_face.image) # cv2 fails if this comes as imageio.core.Array mask = mask.get_full_frame_mask(face.shape[1], face.shape[0]) mask = np.expand_dims(mask, -1) diff --git a/tools/preview/preview.py b/tools/preview/preview.py index 5c0c8f82be..2af63d9fb4 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -15,13 +15,12 @@ import numpy as np from PIL import Image, ImageTk -from lib.aligner import Extract as AlignerExtract +from lib.align import DetectedFace, transform_image from lib.cli.args import ConvertArgs from lib.gui.utils import get_images, get_config, initialize_config, initialize_images from lib.gui.custom_widgets import Tooltip from lib.gui.control_helper import ControlPanel, ControlPanelOption from lib.convert import Converter -from lib.faces_detect import DetectedFace from lib.multithreading import MultiThread from lib.utils import FaceswapError from lib.queue_manager import queue_manager @@ -190,6 +189,11 @@ def __init__(self, arguments, sample_size, display, lock, trigger_patch): self._alignments = Alignments(arguments, is_extract=False, input_is_video=self._images.is_video) + if self._alignments.version == 1.0: + logger.error("The alignments file format has been updated since the given alignments " + "file was generated. You need to update the file to proceed.") + logger.error("To do this run the 'Alignments Tool' > 'Extract' Job.") + sys.exit(1) if not self._alignments.have_alignments_file: logger.error("Alignments file not found at: '%s'", self._alignments.file) sys.exit(1) @@ -199,6 +203,7 @@ def __init__(self, arguments, sample_size, display, lock, trigger_patch): self._predictor = Predict(queue_manager.get_queue("preview_predict_in"), sample_size, arguments) + self._display.set_centering(self._predictor.centering) self.generate() logger.debug("Initialized %s", self.__class__.__name__) @@ -215,7 +220,7 @@ def predicted_images(self): @property def alignments(self): - """ :class:`~lib.alignments.Alignments`: The alignments for the preview faces """ + """ :class:`~lib.align.Alignments`: The alignments for the preview faces """ return self._alignments @property @@ -301,11 +306,11 @@ def _load_frames(self): * Picks a random face from each indices group. - * Takes the first face from the image (if there) are multiple faces. Adds the images to \ - :attr:`self._input_images`. + * Takes the first face from the image (if there are multiple faces). Adds the images to \ + :attr:`self._input_images`. - * Sets :attr:`_display.source` to the input images and flags that the display should \ - be updated + * Sets :attr:`_display.source` to the input images and flags that the display should be \ + updated """ self._input_images = list() for selection in self._random_choice: @@ -395,6 +400,7 @@ def __init__(self, arguments, available_masks, samples, configfile = arguments.configfile if hasattr(arguments, "configfile") else None self._converter = Converter(output_size=self._samples.predictor.output_size, coverage_ratio=self._samples.predictor.coverage_ratio, + centering=self._samples.predictor.centering, draw_transparent=False, pre_encode=None, arguments=self._generate_converter_arguments(arguments, @@ -596,6 +602,7 @@ def __init__(self, size, padding, tk_vars): self._padding = padding self._faces = dict() + self._centering = None self._faces_source = None self._faces_dest = None self._tk_image = None @@ -619,6 +626,17 @@ def _total_columns(self): """ Return the total number of images that are being displayed """ return len(self.source) + def set_centering(self, centering): + """ The centering that the model uses is not known at initialization time. + Set :attr:`_centering` when the model has been loaded. + + Parameters + ---------- + centering: str + The centering that the model was trained on + """ + self._centering = centering + def set_display_dimensions(self, dimensions): """ Adjust the size of the frame that will hold the preview samples. @@ -699,16 +717,15 @@ def _crop_source_faces(self): for image in self.source: detected_face = image["detected_faces"][0] src_img = image["image"] - detected_face.load_aligned(src_img, self._size) - matrix = detected_face.aligned["matrix"] + detected_face.load_aligned(src_img, size=self._size, centering=self._centering) + matrix = detected_face.aligned.matrix self._faces.setdefault("filenames", list()).append(os.path.splitext(image["filename"])[0]) self._faces.setdefault("matrix", list()).append(matrix) - self._faces.setdefault("src", list()).append(AlignerExtract().transform( - src_img, - matrix, - self._size, - self._padding)) + self._faces.setdefault("src", list()).append(transform_image(src_img, + matrix, + self._size, + self._padding)) self.update_source = False logger.debug("Updated source faces") @@ -720,11 +737,10 @@ def _crop_destination_faces(self): destination = self.destination if self.destination else [np.ones_like(src["image"]) for src in self.source] for idx, image in enumerate(destination): - self._faces["dst"].append(AlignerExtract().transform( - image, - self._faces["matrix"][idx], - self._size, - self._padding)) + self._faces["dst"].append(transform_image(image, + self._faces["matrix"][idx], + self._size, + self._padding)) logger.debug("Updated destination faces") def _header_text(self): diff --git a/tools/sort/cli.py b/tools/sort/cli.py index 2e12facdf6..a47c2cdbbe 100644 --- a/tools/sort/cli.py +++ b/tools/sort/cli.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ from lib.cli.args import FaceSwapArgs -from lib.cli.actions import DirFullPaths, SaveFileFullPaths, Radio, Slider +from lib.cli.actions import DirFullPaths, FileFullPaths, SaveFileFullPaths, Radio, Slider _HELPTEXT = "This command lets you sort images using various methods." @@ -18,171 +18,143 @@ def get_info(): def get_argument_list(): """ Put the arguments in a list so that they are accessible from both argparse and gui """ argument_list = list() - argument_list.append({"opts": ('-i', '--input'), - "action": DirFullPaths, - "dest": "input_dir", - "group": "data", - "help": "Input directory of aligned faces.", - "required": True}) - - argument_list.append({"opts": ('-o', '--output'), - "action": DirFullPaths, - "dest": "output_dir", - "group": "data", - "help": "Output directory for sorted aligned " - "faces."}) - - argument_list.append({"opts": ('-s', '--sort-by'), - "action": Radio, - "type": str, - "choices": ("blur", "face", "face-cnn", "face-cnn-dissim", - "face-yaw", "hist", "hist-dissim", "color-gray", - "color-luma", "color-green", "color-orange"), - "dest": 'sort_method', - "group": "sort settings", - "default": "face", - "help": "R|Sort by method. Choose how images are sorted. " - "\nL|'blur': Sort faces by blurriness." - "\nL|'face': Use VGG Face to sort by face similarity. This " - "uses a pairwise clustering algorithm to check the " - "distances between 4096 features on every face in your set " - "and order them appropriately. WARNING: On very large " - "datasets it is possible to run out of memory performing " - "this calculation." - "\nL|'face-cnn': Sort faces by their landmarks. You can " - "adjust the threshold with the '-t' (--ref_threshold) " - "option." - "\nL|'face-cnn-dissim': Like 'face-cnn' but sorts by " - "dissimilarity." - "\nL|'face-yaw': Sort faces by Yaw (rotation left to right)." - "\nL|'hist': Sort faces by their color histogram. You can " - "adjust the threshold with the '-t' (--ref_threshold) " - "option." - "\nL|'hist-dissim': Like 'hist' but sorts by dissimilarity." - "\nL|'color-gray': Sort images by the average intensity of " - "the converted grayscale color channel." - "\nL|'color-luma': Sort images by the average intensity of " - "the converted Y color channel. Bright lighting and " - "oversaturated images will be ranked first." - "\nL|'color-green': Sort images by the average intensity of " - "the converted Cg color channel. Green images will be " - "ranked first and red images will be last." - "\nL|'color-orange': Sort images by the average intensity " - "of the converted Co color channel. Orange images will be " - "ranked first and blue images will be last." - "\nDefault: hist"}) - argument_list.append({"opts": ('-k', '--keep'), - "action": 'store_true', - "dest": 'keep_original', - "default": False, - "group": "output", - "help": "Keeps the original files in the input " - "directory. Be careful when using this " - "with rename grouping and no specified " - "output directory as this would keep " - "the original and renamed files in the " - "same directory."}) - argument_list.append({"opts": ('-t', '--ref_threshold'), - "action": Slider, - "min_max": (-1.0, 10.0), - "rounding": 2, - "type": float, - "dest": 'min_threshold', - "group": "sort settings", - "default": -1.0, - "help": "Float value. " - "Minimum threshold to use for grouping comparison with " - "'face-cnn' and 'hist' methods. The lower the value the " - "more discriminating the grouping is. Leaving -1.0 will " - "allow the program set the default value automatically. " - "For face-cnn 7.2 should be enough, with 4 being very " - "discriminating. For hist 0.3 should be enough, with 0.2 " - "being very discriminating. Be careful setting a value " - "that's too low in a directory with many images, as this " - "could result in a lot of directories being created. " - "Defaults: face-cnn 7.2, hist 0.3"}) - - argument_list.append({"opts": ('-fp', '--final-process'), - "action": Radio, - "type": str, - "choices": ("folders", "rename"), - "dest": 'final_process', - "default": "rename", - "group": "output", - "help": "R|Default: rename." - "\nL|'folders': files are sorted using " - "the -s/--sort-by method, then they " - "are organized into folders using " - "the -g/--group-by grouping method." - "\nL|'rename': files are sorted using " - "the -s/--sort-by then they are " - "renamed."}) - - argument_list.append({"opts": ('-g', '--group-by'), - "action": Radio, - "type": str, - "choices": ("blur", "face-cnn", "face-yaw", "hist"), - "dest": 'group_method', - "group": "output", - "default": "hist", - "help": "Group by method. " - "When -fp/--final-processing by " - "folders choose the how the images are " - "grouped after sorting. " - "Default: hist"}) - - argument_list.append({"opts": ('-b', '--bins'), - "action": Slider, - "min_max": (1, 100), - "rounding": 1, - "type": int, - "dest": 'num_bins', - "group": "output", - "default": 5, - "help": "Integer value. " - "Number of folders that will be used " - "to group by blur and face-yaw. " - "For blur folder 0 will be the least " - "blurry, while the last folder will be " - "the blurriest. " - "For face-yaw the number of bins is by " - "how much 180 degrees is divided. So " - "if you use 18, then each folder will " - "be a 10 degree increment. Folder 0 " - "will contain faces looking the most " - "to the left whereas the last folder " - "will contain the faces looking the " - "most to the right. " - "If the number of images doesn't " - "divide evenly into the number of " - "bins, the remaining images get put in " - "the last bin." - "Default value: 5"}) - - argument_list.append({"opts": ('-l', '--log-changes'), - "action": 'store_true', - "group": "settings", - "default": False, - "help": "Logs file renaming changes if " - "grouping by renaming, or it logs the " - "file copying/movement if grouping by " - "folders. If no log file is specified " - "with '--log-file', then a " - "'sort_log.json' file will be created " - "in the input directory."}) - - argument_list.append({"opts": ('-lf', '--log-file'), - "action": SaveFileFullPaths, - "filetypes": "alignments", - "group": "settings", - "dest": 'log_file_path', - "default": 'sort_log.json', - "help": "Specify a log file to use for saving " - "the renaming or grouping information. " - "If specified extension isn't 'json' " - "or 'yaml', then json will be used as " - "the serializer, with the supplied " - "filename. " - "Default: sort_log.json"}) + argument_list.append(dict( + opts=('-i', '--input'), + action=DirFullPaths, + dest="input_dir", + group="data", + help="Input directory of aligned faces.", + required=True)) + argument_list.append(dict( + opts=('-o', '--output'), + action=DirFullPaths, + dest="output_dir", + group="data", + help="Output directory for sorted aligned faces.")) + argument_list.append(dict( + opts=('-a', '--alignments'), + action=FileFullPaths, + filetypes="alignments", + type=str, + dest="alignments_path", + group="data", + help="Optional path to an alignments file. This is only used for the 'sort-by face' " + "method. If not provided, the default location will be scanned. If the file " + "still cannot be located, then the sorting process will analyze the full-head " + "extract images, which will lead to vastly inferior results.")) + argument_list.append(dict( + opts=('-s', '--sort-by'), + action=Radio, + type=str, + choices=("blur", "face", "face-cnn", "face-cnn-dissim", "face-yaw", "hist", + "hist-dissim", "color-gray", "color-luma", "color-green", "color-orange"), + dest='sort_method', + group="sort settings", + default="face", + help="R|Sort by method. Choose how images are sorted. " + "\nL|'blur': Sort faces by blurriness." + "\nL|'face': Use VGG Face to sort by face similarity. This uses a pairwise " + "clustering algorithm to check the distances between 512 features on every face " + "in your set and order them appropriately. NB: You should provide an alignments " + "file if using this method. Not doing so will lead to vastly inferior results." + "\nL|'face-cnn': Sort faces by their landmarks. You can adjust the threshold " + "with the '-t' (--ref_threshold) option." + "\nL|'face-cnn-dissim': Like 'face-cnn' but sorts by dissimilarity." + "\nL|'face-yaw': Sort faces by Yaw (rotation left to right)." + "\nL|'hist': Sort faces by their color histogram. You can adjust the threshold " + "with the '-t' (--ref_threshold) option." + "\nL|'hist-dissim': Like 'hist' but sorts by dissimilarity." + "\nL|'color-gray': Sort images by the average intensity of the converted " + "grayscale color channel." + "\nL|'color-luma': Sort images by the average intensity of the converted Y color " + "channel. Bright lighting and oversaturated images will be ranked first." + "\nL|'color-green': Sort images by the average intensity of the converted Cg " + "color channel. Green images will be ranked first and red images will be last." + "\nL|'color-orange': Sort images by the average intensity of the converted Co " + "color channel. Orange images will be ranked first and blue images will be last." + "\nDefault: hist")) + argument_list.append(dict( + opts=('-k', '--keep'), + action='store_true', + dest='keep_original', + default=False, + group="output", + help="Keeps the original files in the input directory. Be careful when using this " + "with rename grouping and no specified output directory as this would keep the " + "original and renamed files in the same directory.")) + argument_list.append(dict( + opts=('-t', '--ref_threshold'), + action=Slider, + min_max=(-1.0, 10.0), + rounding=2, + type=float, + dest='min_threshold', + group="sort settings", + default=-1.0, + help="Float value. Minimum threshold to use for grouping comparison with 'face-cnn' " + "and 'hist' methods. The lower the value the more discriminating the grouping " + "is. Leaving -1.0 will allow the program set the default value automatically. " + "For face-cnn 7.2 should be enough, with 4 being very discriminating. For hist " + "0.3 should be enough, with 0.2 being very discriminating. Be careful setting a " + "value that's too low in a directory with many images, as this could result in a " + "lot of directories being created. Defaults: face-cnn 7.2, hist 0.3")) + argument_list.append(dict( + opts=('-fp', '--final-process'), + action=Radio, + type=str, + choices=("folders", "rename"), + dest='final_process', + default="rename", + group="output", + help="R|Default: rename." + "\nL|'folders': files are sorted using the -s/--sort-by method, then they are " + "organized into folders using the -g/--group-by grouping method." + "\nL|'rename': files are sorted using the -s/--sort-by then they are renamed.")) + argument_list.append(dict( + opts=('-g', '--group-by'), + action=Radio, + type=str, + choices=("blur", "face-cnn", "face-yaw", "hist"), + dest='group_method', + group="output", + default="hist", + help="Group by method. When -fp/--final-processing by folders choose the how the " + "images are grouped after sorting. Default: hist")) + argument_list.append(dict( + opts=('-b', '--bins'), + action=Slider, + min_max=(1, 100), + rounding=1, + type=int, + dest='num_bins', + group="output", + default=5, + help="Integer value. Number of folders that will be used to group by blur and " + "face-yaw. For blur folder 0 will be the least blurry, while the last folder " + "will be the blurriest. For face-yaw the number of bins is by how much 180 " + "degrees is divided. So if you use 18, then each folder will be a 10 degree " + "increment. Folder 0 will contain faces looking the most to the left whereas the " + "last folder will contain the faces looking the most to the right. If the number " + "of images doesn't divide evenly into the number of bins, the remaining images " + "get put in the last bin. Default value: 5")) + argument_list.append(dict( + opts=('-l', '--log-changes'), + action='store_true', + group="settings", + default=False, + help="Logs file renaming changes if grouping by renaming, or it logs the file " + "copying/movement if grouping by folders. If no log file is specified with " + "'--log-file', then a 'sort_log.json' file will be created in the input " + "directory.")) + argument_list.append(dict( + opts=('-lf', '--log-file'), + action=SaveFileFullPaths, + filetypes="alignments", + group="settings", + dest='log_file_path', + default='sort_log.json', + help="Specify a log file to use for saving the renaming or grouping information. If " + "specified extension isn't 'json' or 'yaml', then json will be used as the " + "serializer, with the supplied filename. Default: sort_log.json")) return argument_list diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 804ba08cd4..e747651d9f 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -15,8 +15,8 @@ # faceswap imports from lib.serializer import get_serializer_from_filename -from lib.faces_detect import DetectedFace -from lib.image import ImagesLoader, read_image +from lib.align import Alignments, AlignedFace, DetectedFace, _EXTRACT_RATIOS +from lib.image import FacesLoader, read_image from plugins.extract.recognition.vgg_face2_keras import VGGFace2 as VGGFace from plugins.extract.pipeline import Extractor, ExtractMedia @@ -27,13 +27,40 @@ class Sort(): """ Sorts folders of faces based on input criteria """ # pylint: disable=no-member def __init__(self, arguments): - self.args = arguments + self._args = arguments + self._alignments = self._get_alignments() self.changes = None self.serializer = None - self.vgg_face = None - # TODO set this as ImagesLoader in init. Need to move all processes to use it + self._vgg_face = None + # TODO set this as FacesLoader in init. Need to move all processes to use it self._loader = None + def _get_alignments(self): + """ Obtain the alignments data and validate for methods which require it. + + Returns + ------- + :class:`lib.align.Alignments` + The alignments object pertaining to the data to be sorted. Returns ``None`` if an + alignments file can't be found or is not required. + """ + required_methods = ["face"] + if self._args.sort_method not in required_methods: + return None + if self._args.alignments_path is None: + path = os.path.join(self._args.input_dir, "alignments.fsa") + else: + path = self._args.alignments_path + + if not os.path.isfile(path): + logger.warning("Alignments file not found at '%s'. Not using an alignments file will " + "lead to vastly inferior results.", path) + logger.warning("It is highly recommended that you use an alignments file for sorting " + "by '%s'.", self._args.sort_method) + return None + + return Alignments(*os.path.split(path)) + def process(self): """ Main processing function of the sort tool """ @@ -41,53 +68,53 @@ def process(self): # Set output dir to the same value as input dir # if the user didn't specify it. - if self.args.output_dir is None: + if self._args.output_dir is None: logger.verbose("No output directory provided. Using input dir as output dir.") - self.args.output_dir = self.args.input_dir + self._args.output_dir = self._args.input_dir # Assigning default threshold values based on grouping method - if (self.args.final_process == "folders" - and self.args.min_threshold < 0.0): - method = self.args.group_method.lower() + if (self._args.final_process == "folders" + and self._args.min_threshold < 0.0): + method = self._args.group_method.lower() if method == 'face-cnn': - self.args.min_threshold = 7.2 + self._args.min_threshold = 7.2 elif method == 'hist': - self.args.min_threshold = 0.3 + self._args.min_threshold = 0.3 # Load VGG Face if sorting by face - if self.args.sort_method.lower() == "face": - self.vgg_face = VGGFace(exclude_gpus=self.args.exclude_gpus) - self.vgg_face.init_model() + if self._args.sort_method.lower() == "face": + self._vgg_face = VGGFace(exclude_gpus=self._args.exclude_gpus) + self._vgg_face.init_model() # If logging is enabled, prepare container - if self.args.log_changes: + if self._args.log_changes: self.changes = dict() # Assign default sort_log.json value if user didn't specify one - if self.args.log_file_path == 'sort_log.json': - self.args.log_file_path = os.path.join(self.args.input_dir, - 'sort_log.json') + if self._args.log_file_path == 'sort_log.json': + self._args.log_file_path = os.path.join(self._args.input_dir, + 'sort_log.json') - # Set serializer based on logfile extension - self.serializer = get_serializer_from_filename(self.args.log_file_path) + # Set serializer based on log file extension + self.serializer = get_serializer_from_filename(self._args.log_file_path) # Prepare sort, group and final process method names - _sort = "sort_" + self.args.sort_method.lower() - _group = "group_" + self.args.group_method.lower() - _final = "final_process_" + self.args.final_process.lower() + _sort = "sort_" + self._args.sort_method.lower() + _group = "group_" + self._args.group_method.lower() + _final = "final_process_" + self._args.final_process.lower() if _sort.startswith('sort_color-'): - self.args.color_method = _sort.replace('sort_color-', '') + self._args.color_method = _sort.replace('sort_color-', '') _sort = _sort[:10] - self.args.sort_method = _sort.replace('-', '_') - self.args.group_method = _group.replace('-', '_') - self.args.final_process = _final.replace('-', '_') + self._args.sort_method = _sort.replace('-', '_') + self._args.group_method = _group.replace('-', '_') + self._args.final_process = _final.replace('-', '_') self.sort_process() def launch_aligner(self): """ Load the aligner plugin to retrieve landmarks """ extractor = Extractor(None, "fan", None, - normalize_method="hist", exclude_gpus=self.args.exclude_gpus) + normalize_method="hist", exclude_gpus=self._args.exclude_gpus) extractor.set_batchsize("align", 1) extractor.launch() return extractor @@ -118,7 +145,7 @@ def _get_landmarks(self): def _get_images(self): """ Multi-threaded, parallel and sequentially ordered image loader """ logger.info("Loading images...") - filename_list = self.find_images(self.args.input_dir) + filename_list = self.find_images(self._args.input_dir) with futures.ThreadPoolExecutor() as executor: image_list = list(tqdm(executor.map(read_image, filename_list), desc="Loading Images...", @@ -133,13 +160,13 @@ def sort_process(self): the core process of sorting, optionally grouping, renaming/moving into folders. After the functions are assigned they are executed. """ - sort_method = self.args.sort_method.lower() - group_method = self.args.group_method.lower() - final_method = self.args.final_process.lower() + sort_method = self._args.sort_method.lower() + group_method = self._args.group_method.lower() + final_method = self._args.final_process.lower() img_list = getattr(self, sort_method)() if "folders" in final_method: - # Check if non-dissim sort method and group method are not the same + # Check if non-dissimilarity sort method and group method are not the same if group_method.replace('group_', '') not in sort_method: img_list = self.reload_images(group_method, img_list) img_list = getattr(self, group_method)(img_list) @@ -168,21 +195,45 @@ def sort_face(self): """ Sort by identity similarity """ logger.info("Sorting by identity similarity...") - # TODO This should be set in init - self._loader = ImagesLoader(self.args.input_dir) - + self._loader = FacesLoader(self._args.input_dir) # TODO This should be set in init + ratio = _EXTRACT_RATIOS["legacy"] / _EXTRACT_RATIOS["head"] filenames = [] - preds = np.empty((self._loader.count, 512), dtype="float32") - for idx, (filename, image) in enumerate(tqdm(self._loader.load(), - desc="Classifying Faces...", - total=self._loader.count)): + preds = [] + no_hash = 0 + for filename, image, hsh in tqdm(self._loader.load(), + desc="Classifying Faces...", + total=self._loader.count): + if self._alignments is not None and self._alignments.version != 1.0: + face = self._alignments.hashes_to_alignment.get(hsh) + if face: + image = AlignedFace(face["landmarks_xy"], + image=image, + centering="legacy", + size=self._vgg_face.input_size, + is_aligned=True).face + elif image.shape[0] != image.shape[1]: + logger.warning("Skipping image '%s' as it is not square (probably not a " + "face)", filename) + continue + else: # Center crop the image and add count to warning count + center = image.shape[0] // 2 + crop = slice(center - int(center * ratio), center + int(center * ratio)) + image = image[crop, crop, :] + no_hash += 1 + filenames.append(filename) - preds[idx] = self.vgg_face.predict(image) + preds.append(self._vgg_face.predict(image)) logger.info("Sorting by ward linkage...") - indices = self.vgg_face.sorted_similarity(preds, method="ward") + indices = self._vgg_face.sorted_similarity(np.array(preds), method="ward") img_list = np.array(filenames)[indices] + + if no_hash: + logger.warning("%s image(s) were not found in the alignments file. This will likely " + "result in sub-par sorting results, so you should check the output " + "carefully", no_hash) + return img_list def sort_face_cnn(self): @@ -294,7 +345,7 @@ def sort_color(self): """ Score by channel average intensity """ logger.info("Sorting by channel average intensity...") desired_channel = {'gray': 0, 'luma': 0, 'orange': 1, 'green': 2} - method = self.args.color_method + method = self._args.color_method channel_to_sort = next(v for (k, v) in desired_channel.items() if method.endswith(k)) filename_list, image_list = self._get_images() @@ -319,7 +370,7 @@ def sort_color(self): def group_blur(self, img_list): """ Group into bins by blur """ # Starting the binning process - num_bins = self.args.num_bins + num_bins = self._args.num_bins # The last bin will get all extra images if it's # not possible to distribute them evenly @@ -355,7 +406,7 @@ def group_face_cnn(self, img_list): # faces have to be to be grouped together. # It is multiplied by 1000 here to allow the cli option to use smaller # numbers. - min_threshold = self.args.min_threshold * 1000 + min_threshold = self._args.min_threshold * 1000 img_list_len = len(img_list) @@ -388,7 +439,7 @@ def group_face_cnn(self, img_list): def group_face_yaw(self, img_list): """ Group into bins by yaw of face """ # Starting the binning process - num_bins = self.args.num_bins + num_bins = self._args.num_bins # The last bin will get all extra images if it's # not possible to distribute them evenly @@ -420,7 +471,7 @@ def group_hist(self, img_list): # an array containing the file paths to the images in that group bins = [] - min_threshold = self.args.min_threshold + min_threshold = self._args.min_threshold img_list_len = len(img_list) reference_groups[0] = [img_list[0][1]] @@ -447,17 +498,17 @@ def group_hist(self, img_list): # Final process methods def final_process_rename(self, img_list): """ Rename the files """ - output_dir = self.args.output_dir + output_dir = self._args.output_dir - process_file = self.set_process_file_method(self.args.log_changes, - self.args.keep_original) + process_file = self.set_process_file_method(self._args.log_changes, + self._args.keep_original) # Make sure output directory exists if not os.path.exists(output_dir): os.makedirs(output_dir) description = ( - "Copying and Renaming" if self.args.keep_original + "Copying and Renaming" if self._args.keep_original else "Moving and Renaming" ) @@ -478,7 +529,7 @@ def final_process_rename(self, img_list): for i in tqdm(range(0, len(img_list)), desc=description, file=sys.stdout): - renaming = self.set_renaming_method(self.args.log_changes) + renaming = self.set_renaming_method(self._args.log_changes) fname = img_list[i] if isinstance(img_list[i], str) else img_list[i][0] src, dst = renaming(fname, output_dir, i, self.changes) @@ -488,15 +539,15 @@ def final_process_rename(self, img_list): logger.error(err) logger.error('fail to rename %s', format(src)) - if self.args.log_changes: + if self._args.log_changes: self.write_to_log(self.changes) def final_process_folders(self, bins): """ Move the files to folders """ - output_dir = self.args.output_dir + output_dir = self._args.output_dir - process_file = self.set_process_file_method(self.args.log_changes, - self.args.keep_original) + process_file = self.set_process_file_method(self._args.log_changes, + self._args.keep_original) # First create new directories to avoid checking # for directory existence in the moving loop @@ -507,7 +558,7 @@ def final_process_folders(self, bins): os.makedirs(directory) description = ( - "Copying into Groups" if self.args.keep_original + "Copying into Groups" if self._args.keep_original else "Moving into Groups" ) @@ -524,14 +575,14 @@ def final_process_folders(self, bins): logger.error(err) logger.error("Failed to move '%s' to '%s'", src, dst) - if self.args.log_changes: + if self._args.log_changes: self.write_to_log(self.changes) # Various helper methods def write_to_log(self, changes): """ Write the changes to log file """ - logger.info("Writing sort log to: '%s'", self.args.log_file_path) - self.serializer.save(self.args.log_file_path, changes) + logger.info("Writing sort log to: '%s'", self._args.log_file_path) + self.serializer.save(self._args.log_file_path, changes) def reload_images(self, group_method, img_list): """ @@ -566,7 +617,7 @@ def reload_images(self, group_method, img_list): @staticmethod def _convert_color(imgs, same_size, method): - """ Helper function to convert colorspaces """ + """ Helper function to convert color spaces """ if method.endswith('gray'): conversion = np.array([[0.0722], [0.7152], [0.2126]]) From 6279f8578789eb7699c170debda93b45ba598f84 Mon Sep 17 00:00:00 2001 From: Dominik Miszkiewicz Date: Tue, 8 Dec 2020 13:49:49 +0100 Subject: [PATCH 330/981] Update tensorflow to 2.4.0rc4 (#1086) * Update tensorflow to 2.4.0rc1 * Update requirements_nvidia.txt * Update requirements_nvidia.txt --- lib/cli/launcher.py | 4 ++-- lib/gui/stats.py | 2 +- plugins/extract/_base.py | 2 +- plugins/extract/align/_base.py | 2 +- plugins/extract/detect/_base.py | 2 +- plugins/extract/mask/_base.py | 2 +- plugins/train/model/_base.py | 8 ++++---- plugins/train/trainer/_base.py | 2 +- requirements_nvidia.txt | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index d1030fb405..ad483bf868 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -53,10 +53,10 @@ def _test_for_tf_version(self): Raises ------ FaceswapError - If Tensorflow is not found, or is not between versions 2.2 and 2.2 + If Tensorflow is not found, or is not between versions 2.2 and 2.4 """ min_ver = 2.2 - max_ver = 2.2 + max_ver = 2.4 try: # Ensure tensorflow doesn't pin all threads to one core when using Math Kernel Library os.environ["TF_MIN_GPU_MULTIPROCESSOR_COUNT"] = "4" diff --git a/lib/gui/stats.py b/lib/gui/stats.py index 7cbb323526..41ec71604c 100644 --- a/lib/gui/stats.py +++ b/lib/gui/stats.py @@ -17,7 +17,7 @@ import numpy as np import tensorflow as tf -from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module +from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module from tensorflow.core.util import event_pb2 from lib.serializer import get_serializer diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 7c984281cb..530fc3840e 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -6,7 +6,7 @@ import os import sys -from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module +from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module from lib.multithreading import MultiThread from lib.queue_manager import queue_manager diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index 77920f6055..5f871e94ac 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -17,7 +17,7 @@ import cv2 import numpy as np -from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module +from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module from lib.utils import get_backend, FaceswapError from plugins.extract._base import Extractor, logger, ExtractMedia diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index e4fcb89c62..522e5fab87 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -18,7 +18,7 @@ import cv2 import numpy as np -from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module +from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module from lib.faces_detect import DetectedFace from lib.utils import get_backend, FaceswapError diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 433b7af148..ac092d5fa0 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -16,7 +16,7 @@ import cv2 import numpy as np -from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module +from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module from lib.utils import get_backend, FaceswapError from plugins.extract._base import Extractor, ExtractMedia, logger diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 1bce2d545f..9676897e6a 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -385,7 +385,7 @@ def _compile_model(self): self.config.get("clipnorm", False), self._args).optimizer if self._settings.use_mixed_precision: - optimizer = self._settings.LossScaleOptimizer(optimizer, loss_scale="dynamic") + optimizer = self._settings.LossScaleOptimizer(optimizer, True) if get_backend() == "amd": self._rewrite_plaid_outputs() self._loss.configure(self._model) @@ -643,7 +643,7 @@ def __init__(self, arguments, mixed_precision, allow_growth, is_predict): use_mixed_precision = not is_predict and mixed_precision and get_backend() == "nvidia" if use_mixed_precision: - self._mixed_precision = tf.keras.mixed_precision.experimental + self._mixed_precision = tf.keras.mixed_precision else: self._mixed_precision = None @@ -746,7 +746,7 @@ def _set_keras_mixed_precision(self, use_mixed_precision, skip_check): # TODO remove this hacky fix to disable mixed precision compatibility testing if/when # fixed upstream. # pylint:disable=import-outside-toplevel,protected-access - from tensorflow.python.keras.mixed_precision.experimental import \ + from tensorflow.python.keras.mixed_precision import \ device_compatibility_check logger.debug("Overriding tensorflow _logged_compatibility_check parameter. Initial " "value: %s", device_compatibility_check._logged_compatibility_check) @@ -754,7 +754,7 @@ def _set_keras_mixed_precision(self, use_mixed_precision, skip_check): logger.debug("New value: %s", device_compatibility_check._logged_compatibility_check) policy = self._mixed_precision.Policy('mixed_float16') - self._mixed_precision.set_policy(policy) + self._mixed_precision.set_global_policy(policy) logger.debug("Enabled mixed precision. (Compute dtype: %s, variable_dtype: %s)", policy.compute_dtype, policy.variable_dtype) return True diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index b79fffb7fb..9761236dc5 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -18,7 +18,7 @@ import numpy as np import tensorflow as tf -from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module +from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module from tqdm import tqdm from lib.alignments import Alignments diff --git a/requirements_nvidia.txt b/requirements_nvidia.txt index f695054174..97623dfc31 100644 --- a/requirements_nvidia.txt +++ b/requirements_nvidia.txt @@ -1,2 +1,2 @@ -r _requirements_base.txt -tensorflow-gpu>=2.2.0,<2.3.0 +tensorflow-gpu==2.4.0rc4 From d392dfbdf7df617a26e64b6f57b440654608ba1f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 15 Dec 2020 12:11:26 +0000 Subject: [PATCH 331/981] Bugfixes - Manual Tool: - Hide annotations for faces not meeting criteria - Update landmarks on face add/del - Clearer landmark annotations - Handle non-numerics in frame number box - Training - Fix mis-aligned preview images - Allows mixing legacy + new alignments for A and B - Catch non-training images in training folder - Catch inconsistently sized training images - Standardize coverage ratio calculation - lib.image - Add option to get image shape along with hash Dfaker model: - Add 256px mode --- lib/image.py | 28 +++++++++---- lib/training_data.py | 6 +-- plugins/train/model/_base.py | 50 ++++++++-------------- plugins/train/model/dfaker.py | 16 +++++++- plugins/train/model/dfaker_defaults.py | 57 ++++++++++++++++++++++++++ plugins/train/trainer/_base.py | 47 +++++++++++++-------- scripts/train.py | 15 +------ tools/manual/faceviewer/frame.py | 2 +- tools/manual/faceviewer/viewport.py | 38 ++++++++++++----- tools/manual/frameviewer/control.py | 24 ++++++++++- 10 files changed, 191 insertions(+), 92 deletions(-) create mode 100644 plugins/train/model/dfaker_defaults.py diff --git a/lib/image.py b/lib/image.py index 044a9de4d7..6cfc18537a 100644 --- a/lib/image.py +++ b/lib/image.py @@ -339,13 +339,16 @@ def read_image_batch(filenames): return batch -def read_image_hash(filename): +def read_image_hash(filename, output_shape=False): """ Return the `sha1` hash of an image saved on disk. Parameters ---------- filename: str Full path to the image to be loaded. + output_shape: bool + If ``True`` then a tuple is returned with the shape tuple of the image as the final value. + Default: ``False`` Returns ------- @@ -357,12 +360,14 @@ def read_image_hash(filename): >>> image_hash = read_image_hash(image_file) """ img = read_image(filename, raise_error=True) - image_hash = sha1(img).hexdigest() - logger.trace("filename: '%s', hash: %s", filename, image_hash) - return image_hash + retval = sha1(img).hexdigest() + if output_shape: + retval = (retval, img.shape) + logger.trace("filename: '%s', retval: %s", filename, retval) + return retval -def read_image_hash_batch(filenames): +def read_image_hash_batch(filenames, output_shape=False): """ Return the `sha` hash of a batch of images Leverages multi-threading to load multiple images from disk at the same time @@ -378,10 +383,14 @@ def read_image_hash_batch(filenames): ---------- filenames: list A list of ``str`` full paths to the images to be loaded. + output_shape: bool + If ``True`` then a 3rd item is added to the output tuple containing the shape of the read + image. Default: ``False`` Yields ------- - tuple: (`filename`, :func:`hashlib.hexdigest()` representation of the `sha1` hash of the image) + tuple: (`filename`, :func:`hashlib.hexdigest()` representation of the `sha1` hash of the image, + [optional shape tuple] ) Example ------- >>> image_filenames = ["/path/to/image_1.png", "/path/to/image_2.png", "/path/to/image_3.png"] @@ -392,11 +401,14 @@ def read_image_hash_batch(filenames): executor = futures.ThreadPoolExecutor() with executor: logger.debug("Submitting %s items to executor", len(filenames)) - read_hashes = {executor.submit(read_image_hash, filename): filename + read_hashes = {executor.submit(read_image_hash, + filename, + output_shape=output_shape): filename for filename in filenames} logger.debug("Succesfully submitted %s items to executor", len(filenames)) for future in futures.as_completed(read_hashes): - retval = (read_hashes[future], future.result()) + retval = (read_hashes[future], + *future.result()) if output_shape else (read_hashes[future], future.result()) logger.trace("Yielding: %s", retval) yield retval diff --git a/lib/training_data.py b/lib/training_data.py index eeede2e076..e0d6ccbd17 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -18,10 +18,6 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name -# TODO Face masks appear to be coming out too big? -# TODO Test _get_closest_match for speed and correctness - - class TrainingDataGenerator(): # pylint:disable=too-few-public-methods """ A Training Data Generator for compiling data for feeding to a model. @@ -547,7 +543,7 @@ def initialize(self, training_size): """ logger.debug("Initializing constants. training_size: %s", training_size) self._training_size = training_size - coverage = int(self._training_size * self._coverage_ratio) + coverage = int(self._training_size * self._coverage_ratio // 2) * 2 # Color Aug clahe_base_contrast = training_size // 128 diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index c553e29a63..9936163708 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -74,8 +74,6 @@ class ModelBase(): arguments: :class:`argparse.Namespace` The arguments that were passed to the train or convert process as generated from Faceswap's command line arguments - training_image_size: int, optional - The size of the training images in the training folder. Default: `256` predict: bool, optional ``True`` if the model is being loaded for inference, ``False`` if the model is being loaded for training. Default: ``False`` @@ -94,10 +92,9 @@ class ModelBase(): with the trainer name that a model requires in the model plugin's :func:`__init__` function. """ - def __init__(self, model_dir, arguments, training_image_size=256, predict=False): - logger.debug("Initializing ModelBase (%s): (model_dir: '%s', arguments: %s, " - "training_image_size: %s, predict: %s)", - self.__class__.__name__, model_dir, arguments, training_image_size, predict) + def __init__(self, model_dir, arguments, predict=False): + logger.debug("Initializing ModelBase (%s): (model_dir: '%s', arguments: %s, predict: %s)", + self.__class__.__name__, model_dir, arguments, predict) self.input_shape = None # Must be set within the plugin after initializing self.trainer = "original" # Override for plugin specific trainer @@ -120,8 +117,7 @@ def __init__(self, model_dir, arguments, training_image_size=256, predict=False) self._state = State(model_dir, self.name, self._config_changeable_items, - False if self._is_predict else self._args.no_logs, - training_image_size) + False if self._is_predict else self._args.no_logs) self._settings = _Settings(self._args, self.config["mixed_precision"], self.config["allow_growth"], @@ -143,13 +139,15 @@ def command_line_arguments(self): @property def coverage_ratio(self): - """ float: The ratio of the training image to crop out and train on. """ - coverage_ratio = self.config.get("coverage", 62.5) / 100 - logger.debug("Requested coverage_ratio: %s", coverage_ratio) - cropped_size = (self._state.training_size * coverage_ratio) // 2 * 2 - retval = cropped_size / self._state.training_size - logger.debug("Final coverage_ratio: %s", retval) - return retval + """ float: The ratio of the training image to crop out and train on as defined in user + configuration options. + + NB: The coverage ratio is a raw float, but will be applied to integer pixel images. + + To ensure consistent rounding and guaranteed even image size, the calculation for coverage + should always be: :math:`(original_size * coverage_ratio // 2) * 2` + """ + return self.config.get("coverage", 62.5) / 100 @property def model_dir(self): @@ -1078,25 +1076,16 @@ class State(): Configuration options that can be altered when resuming a model, and their current values no_logs: bool ``True`` if Tensorboard logs should not be generated, otherwise ``False`` - training_image_size: int - The size of the training images in the training folder """ - def __init__(self, - model_dir, - model_name, - config_changeable_items, - no_logs, - training_image_size): + def __init__(self, model_dir, model_name, config_changeable_items, no_logs): logger.debug("Initializing %s: (model_dir: '%s', model_name: '%s', " - "config_changeable_items: '%s', no_logs: %s, training_image_size: '%s'", - self.__class__.__name__, model_dir, model_name, config_changeable_items, - no_logs, training_image_size) + "config_changeable_items: '%s', no_logs: %s", self.__class__.__name__, + model_dir, model_name, config_changeable_items, no_logs) self._serializer = get_serializer("json") filename = "{}_state.{}".format(model_name, self._serializer.file_extension) self._filename = os.path.join(model_dir, filename) self._name = model_name self._iterations = 0 - self._training_size = training_image_size self._sessions = dict() self._lowest_avg_loss = dict() self._config = dict() @@ -1120,11 +1109,6 @@ def iterations(self): """ int: The total number of iterations that the model has trained. """ return self._iterations - @property - def training_size(self): - """ int: The size of the training images in the training folder. """ - return self._training_size - @property def lowest_avg_loss(self): """dict: The lowest average save interval loss seen for each side. """ @@ -1220,7 +1204,6 @@ def _load(self, config_changeable_items): self._sessions = state.get("sessions", dict()) self._lowest_avg_loss = state.get("lowest_avg_loss", dict()) self._iterations = state.get("iterations", 0) - self._training_size = state.get("training_size", 256) self._config = state.get("config", dict()) logger.debug("Loaded state: %s", state) self._replace_config(config_changeable_items) @@ -1232,7 +1215,6 @@ def save(self): "sessions": self._sessions, "lowest_avg_loss": self._lowest_avg_loss, "iterations": self._iterations, - "training_size": self._training_size, "config": _CONFIG} self._serializer.save(self._filename, state) logger.debug("Saved State") diff --git a/plugins/train/model/dfaker.py b/plugins/train/model/dfaker.py index 298ede4e03..6ba249245a 100644 --- a/plugins/train/model/dfaker.py +++ b/plugins/train/model/dfaker.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """ DFaker Model Based on the dfaker model: https://github.com/dfaker """ - +import logging +import sys from keras.initializers import RandomNormal from keras.layers import Input @@ -9,12 +10,18 @@ from lib.model.nn_blocks import Conv2DOutput, UpscaleBlock, ResidualBlock from .original import Model as OriginalModel, KerasModel +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + class Model(OriginalModel): """ Dfaker Model """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.input_shape = (64, 64, 3) + self._output_size = self.config["output_size"] + if self._output_size not in (128, 256): + logger.error("Dfaker output shape should be 128 or 256 px") + sys.exit(1) + self.input_shape = (self._output_size // 2, self._output_size // 2, 3) self.encoder_dim = 1024 self.kernel_initializer = RandomNormal(0, 0.02) @@ -23,6 +30,9 @@ def decoder(self, side): input_ = Input(shape=(8, 8, 512)) var_x = input_ + if self._output_size == 256: + var_x = UpscaleBlock(1024, res_block_follows=True)(var_x) + var_x = ResidualBlock(1024, kernel_initializer=self.kernel_initializer)(var_x) var_x = UpscaleBlock(512, res_block_follows=True)(var_x) var_x = ResidualBlock(512, kernel_initializer=self.kernel_initializer)(var_x) var_x = UpscaleBlock(256, res_block_follows=True)(var_x) @@ -35,6 +45,8 @@ def decoder(self, side): if self.config.get("learn_mask", False): var_y = input_ + if self._output_size == 256: + var_y = UpscaleBlock(1024)(var_y) var_y = UpscaleBlock(512)(var_y) var_y = UpscaleBlock(256)(var_y) var_y = UpscaleBlock(128)(var_y) diff --git a/plugins/train/model/dfaker_defaults.py b/plugins/train/model/dfaker_defaults.py new file mode 100644 index 0000000000..bcbbeaaf9a --- /dev/null +++ b/plugins/train/model/dfaker_defaults.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +""" + The default options for the faceswap Dfl_SAE Model plugin. + + Defaults files should be named _defaults.py + Any items placed into this file will automatically get added to the relevant config .ini files + within the faceswap/config folder. + + The following variables should be defined: + _HELPTEXT: A string describing what this plugin does + _DEFAULTS: A dictionary containing the options, defaults and meta information. The + dictionary should be defined as: + {: {}} + + should always be lower text. + dictionary requirements are listed below. + + The following keys are expected for the _DEFAULTS dict: + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid data types are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and data types this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and data types this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. +""" + + +_HELPTEXT = "Dfaker Model (Adapted from https://github.com/dfaker/df)" + + +_DEFAULTS = dict( + output_size=dict( + default=128, + info="Resolution (in pixels) of the output image to generate on.\n" + "BE AWARE Larger resolution will dramatically increase VRAM requirements.\n" + "Must be 128 or 256.", + datatype=int, + rounding=128, + min_max=(128, 256), + group="size", + fixed=True)) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 9a70e6e5dc..a61fcaf3ed 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -821,7 +821,7 @@ def _process_full(self, side, images, prediction_size, color): logger.debug("full_size: %s, prediction_size: %s, color: %s", images.shape[1], prediction_size, color) - display_size = int(prediction_size * (1 + (1 - self._coverage_ratio))) + display_size = int((prediction_size / self._coverage_ratio // 2) * 2) images = self._resize_sample(side, images, display_size) # Resize targets to display size padding = (display_size - prediction_size) // 2 if padding == 0: @@ -829,12 +829,12 @@ def _process_full(self, side, images, prediction_size, color): return images length = display_size // 4 - t_l, b_r = (padding, display_size - padding) + t_l, b_r = (padding - 1, display_size - padding) for img in images: - cv2.rectangle(img, (t_l, t_l), (t_l + length, t_l + length), color, 2) - cv2.rectangle(img, (b_r, t_l), (b_r - length, t_l + length), color, 2) - cv2.rectangle(img, (b_r, b_r), (b_r - length, b_r - length), color, 2) - cv2.rectangle(img, (t_l, b_r), (t_l + length, b_r - length), color, 2) + cv2.rectangle(img, (t_l, t_l), (t_l + length, t_l + length), color, 1) + cv2.rectangle(img, (b_r, t_l), (b_r - length, t_l + length), color, 1) + cv2.rectangle(img, (b_r, b_r), (b_r - length, b_r - length), color, 1) + cv2.rectangle(img, (t_l, b_r), (t_l + length, b_r - length), color, 1) logger.debug("Overlayed background. Shape: %s", images.shape) return images @@ -1076,9 +1076,8 @@ def __init__(self, model, image_list): self.__class__.__name__, model, {k: len(v) for k, v in image_list.items()}) self._args = model.command_line_arguments self._config = model.config - self._training_size = model.state.training_size self._alignments_paths = self._get_alignments_paths() - self._hashes = self._get_image_hashes(image_list) + self._hashes, self._training_sizes = self._get_image_hashes(image_list) self._alignments_version = dict() self._detected_faces = dict() self._load_alignments() @@ -1139,11 +1138,12 @@ def _get_aligned_faces(self): """ retval = dict() for side, detected_faces in self._detected_faces.items(): + size = self._training_sizes[side] centering = "legacy" if self._alignments_version[side] == 1.0 else "head" logger.debug("side: %s, centering: %s", side, centering) ret_side = dict() for fhash, face in detected_faces.items(): - face.load_aligned(None, size=self._training_size, centering=centering) + face.load_aligned(None, size=size, centering=centering) for filename in self._hash_to_filenames(side, fhash): ret_side[filename] = face.aligned retval[side] = ret_side @@ -1223,7 +1223,7 @@ def _get_landmarks_masks(self, side, detected_faces, area): masks = dict() if self._alignments_version[side] == 1.0: centering = "legacy" - size = self._training_size + size = self._training_sizes[side] else: centering = self._config["centering"] size = list(self._aligned_faces[side].values())[0].get_cropped_size(centering) @@ -1262,15 +1262,30 @@ def _get_image_hashes(cls, image_list): within the training data folder """ hashes = {key: dict() for key in image_list} + sizes = {key: None for key in image_list} for side, filelist in image_list.items(): logger.debug("side: %s, file count: %s", side, len(filelist)) - for filename, hsh in tqdm(read_image_hash_batch(filelist), - desc="Reading training images ({})".format(side.upper()), - total=len(filelist), - leave=False): + for filename, hsh, shape in tqdm( + read_image_hash_batch(filelist, output_shape=True), + desc="Reading training images ({})".format(side.upper()), + total=len(filelist), + leave=False): hashes[side].setdefault(hsh, list()).append(filename) - logger.trace(hashes) - return hashes + if shape[0] != shape[1]: + msg = ("Training images must be created by the extraction process and must be " + "square.\nThe image '{}' has dimensions {}x{} so the process cannot " + "continue.\nThere may be more images with these issues. Please double " + "check your dataset".format(filename, shape[1], shape[0])) + raise FaceswapError(msg) + if not sizes[side]: + sizes[side] = shape[0] + if shape[0] != sizes[side]: + msg = ("All training images for each side must be of the same size.\nImages " + "in side '{}' have mismatched sizes {} and {}.\nPlease double check " + "your dataset".format(side.upper(), sizes[side], shape[0])) + raise FaceswapError(msg) + logger.trace(hashes, sizes) + return hashes, sizes # Hashes for Detected Faces def _load_alignments(self): diff --git a/scripts/train.py b/scripts/train.py index 67cf87308f..725f5dc832 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -10,7 +10,6 @@ import cv2 -from lib.image import read_image from lib.keypress import KBHit from lib.multithreading import MultiThread from lib.utils import (get_folder, get_image_paths, FaceswapError, _image_extensions) @@ -50,15 +49,6 @@ def __init__(self, arguments): self.trainer_name = self._args.trainer logger.debug("Initialized %s", self.__class__.__name__) - @property - def _image_size(self): - """ int: The training image size. Reads the first image in the training folder and returns - the size. """ - image = read_image(self._images["a"][0], raise_error=True) - size = image.shape[0] - logger.debug("Training image size: %s", size) - return size - def _get_images(self): """ Check the image folders exist and contains images and obtain image paths. @@ -146,7 +136,7 @@ def _set_timelapse(self): training_folder = getattr(self._args, "input_{}".format(side)) if folder == training_folder: - continue # Timelapse folder is training folder + continue # Time-lapse folder is training folder filenames = [fname for fname in os.listdir(folder) if os.path.splitext(fname)[-1].lower() in _image_extensions] @@ -154,7 +144,7 @@ def _set_timelapse(self): raise FaceswapError("The Timelapse path '{}' does not contain any valid " "images".format(folder)) - # Timelapse images must appear in the training set, as we need access to alignment and + # Time-lapse images must appear in the training set, as we need access to alignment and # mask info. Check filenames are there to save failing much later in the process. training_images = [os.path.basename(img) for img in self._images[side]] if not all(img in training_images for img in filenames): @@ -254,7 +244,6 @@ def _load_model(self): model = PluginLoader.get_model(self.trainer_name)( model_dir, self._args, - training_image_size=self._image_size, predict=False) model.build() logger.debug("Loaded Model") diff --git a/tools/manual/faceviewer/frame.py b/tools/manual/faceviewer/frame.py index 5633c218f7..74834862b1 100644 --- a/tools/manual/faceviewer/frame.py +++ b/tools/manual/faceviewer/frame.py @@ -338,7 +338,7 @@ def refresh_grid(self, trigger_var, retain_position=False): self.yview_moveto(move_to) if size_change: self._view.reset() - self._view.update() + self._view.update(refresh_annotations=retain_position) if not size_change: trigger_var.set(False) diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index 10db6cd111..6977624e27 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -58,7 +58,7 @@ def mesh_kwargs(self): face's mesh annotation based on the current user selected options. Key is the object type (`polygon` or `line`), value are the keyword arguments for that type. """ state = "normal" if self._canvas.optional_annotations["mesh"] else "hidden" - color = self._canvas.get_muted_color("Mesh") + color = self._canvas.control_colors["Mesh"] kwargs = dict(polygon=dict(fill="", outline=color, state=state), line=dict(fill=color, state=state)) return kwargs @@ -112,18 +112,29 @@ def reset(self): self._landmarks = dict() self._tk_faces = dict() - def update(self): + def update(self, refresh_annotations=False): """ Update the viewport. + Parameters + ---------- + refresh_annotations: bool, optional + ``True`` if mesh annotations should be re-calculated otherwise ``False``. + Default: ``False`` + Obtains the objects that are currently visible. Updates the visible area of the canvas and reloads the active frame's annotations. """ self._objects.update() - self._update_viewport() + self._update_viewport(refresh_annotations) self._active_frame.reload_annotations() - def _update_viewport(self): + def _update_viewport(self, refresh_annotations): """ Update the viewport + Parameters + ---------- + refresh_annotations: bool + ``True`` if mesh annotations should be re-calculated otherwise ``False`` + Clear out cached objects that are not currently in view. Populate the cache for any faces that are now in view. Populate the correct face image and annotations for each object in the viewport based on current location. If optional mesh annotations are @@ -156,7 +167,8 @@ def _update_viewport(self): self._canvas.itemconfig(image_id, image=tk_face.photo) if (self._canvas.optional_annotations["mesh"] or frame_idx == self._active_frame.frame_index): - landmarks = self.get_landmarks(frame_idx, face_idx, face, top_left) + landmarks = self.get_landmarks(frame_idx, face_idx, face, top_left, + refresh=refresh_annotations) self._locate_mesh(mesh_ids, landmarks) def _discard_tk_faces(self): @@ -414,9 +426,13 @@ def update(self): logger.trace("existing_rows: %s. required_rows: %s", existing_rows, required_rows) if existing_rows > required_rows: - for image_id in self._images[required_rows: existing_rows].flatten(): + for image_id, mesh_ids in zip(self._images[required_rows: existing_rows].flatten(), + self._meshes[required_rows: existing_rows].flatten()): logger.trace("Hiding image id: %s", image_id) self._canvas.itemconfig(image_id, image="") + for ids in mesh_ids.values(): + for mesh_id in ids: + self._canvas.itemconfig(mesh_id, state="hidden") if existing_rows < required_rows: self._add_rows(existing_rows, required_rows) @@ -530,12 +546,12 @@ def _get_mesh(self): else: tags = ["viewport", "viewport_mesh"] mesh = dict(polygon=[self._canvas.create_polygon(0, 0, - width=2, + width=1, tags=tags + ["viewport_polygon"], **kwargs["polygon"]) for _ in range(4)], line=[self._canvas.create_line(0, 0, 0, 0, - width=2, + width=1, tags=tags + ["viewport_line"], **kwargs["line"]) for _ in range(5)]) @@ -763,7 +779,7 @@ def _clear_previous(self): for key in ("polygon", "line"): tag = "active_mesh_{}".format(key) - self._canvas.itemconfig(tag, **self._viewport.mesh_kwargs[key]) + self._canvas.itemconfig(tag, **self._viewport.mesh_kwargs[key], width=1) self._canvas.dtag(tag) if self._viewport.selected_editor == "mask" and not self._optional_annotations["mask"]: @@ -897,8 +913,8 @@ def _show_mesh(self, mesh_ids, face_index, detected_face, top_left): """ state = "normal" if (self._tk_vars["selected_editor"].get() != "Mask" or self._optional_annotations["mesh"]) else "hidden" - kwargs = dict(polygon=dict(fill="", outline=self._canvas.control_colors["Mesh"]), - line=dict(fill=self._canvas.control_colors["Mesh"])) + kwargs = dict(polygon=dict(fill="", width=2, outline=self._canvas.control_colors["Mesh"]), + line=dict(fill=self._canvas.control_colors["Mesh"], width=2)) edited = (self._tk_vars["edited"].get() and self._tk_vars["selected_editor"].get() not in ("Mask", "View")) diff --git a/tools/manual/frameviewer/control.py b/tools/manual/frameviewer/control.py index 8c4317eb8c..cac4267d5b 100644 --- a/tools/manual/frameviewer/control.py +++ b/tools/manual/frameviewer/control.py @@ -84,7 +84,7 @@ def increment_frame(self, frame_count=None, is_playing=False): """ Update The frame navigation position to the next frame based on filter. """ if not is_playing: self.stop_playback() - position = self._globals.tk_transport_index.get() + position = self._get_safe_frame_index() face_count_change = self._check_face_count_change() if face_count_change: position -= 1 @@ -98,7 +98,7 @@ def increment_frame(self, frame_count=None, is_playing=False): def decrement_frame(self): """ Update The frame navigation position to the previous frame based on filter. """ self.stop_playback() - position = self._globals.tk_transport_index.get() + position = self._get_safe_frame_index() face_count_change = self._check_face_count_change() if face_count_change: position += 1 @@ -108,6 +108,26 @@ def decrement_frame(self): self._globals.tk_transport_index.set(min(max(0, self._det_faces.filter.count - 1), max(0, position - 1))) + def _get_safe_frame_index(self): + """ Obtain the current frame position from the tk_transport_index variable in + a safe manner (i.e. handle for non-numerics) + + Returns + ------- + int + The current transport frame index + """ + try: + retval = self._globals.tk_transport_index.get() + except tk.TclError as err: + if "expected floating-point" not in str(err): + raise + val = str(err).split(" ")[-1].replace("\"", "") + retval = "".join(ch for ch in val if ch.isdigit()) + retval = 0 if not retval else int(retval) + self._globals.tk_transport_index.set(retval) + return retval + def _check_face_count_change(self): """ Check whether the face count for the current filter has changed, and update the transport scale appropriately. From ce786ec273afe5975779efe32c48c0128482824e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 15 Dec 2020 22:44:01 +0000 Subject: [PATCH 332/981] plugins.train.config - Update coverage helptext --- plugins/train/_config.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 4dd43256ad..f0e1caa8d9 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -72,8 +72,9 @@ def _set_globals(self): info="How much of the extracted image to train on. A lower coverage will limit the " "model's scope to a zoomed-in central area while higher amounts can include the " "entire face. A trade-off exists between lower amounts given more detail " - "versus higher amounts avoiding noticeable swap transitions. Sensible values to " - "use are:" + "versus higher amounts avoiding noticeable swap transitions. For 'Face' " + "centering you will want to leave this above 75%. Sensible values for 'Legacy' " + "centering are:" "\n\t62.5%% spans from eyebrow to eyebrow." "\n\t75.0%% spans from temple to temple." "\n\t87.5%% spans from ear to ear." From 3b7535c7327ca087436f5188f7dd9c1d5dee38e2 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 18 Dec 2020 19:58:30 +0000 Subject: [PATCH 333/981] Update to TF2.3 (#1100) * Minimum requirements to tf2.3. - Handle upgrades for Windows users with tf2.2 installed by Pip - Handle windows upgrade from pip tf2.2 - Explicitly install Cuda for Conda installs * Update tensorflow errors api reference * Suppress AutoGraph warning messages * Update GUI Stats to work with tf2.3 * Fix live graph for tf2.3 * DSSIMObjective - autoGraph bugfix * Update Travis test --- lib/cli/launcher.py | 6 +-- lib/gui/stats.py | 40 +++++++++++++--- lib/logger.py | 25 ++++++---- lib/model/losses_plaid.py | 6 +-- lib/model/losses_tf.py | 6 +-- plugins/extract/_base.py | 2 +- plugins/extract/align/_base.py | 2 +- plugins/extract/detect/_base.py | 2 +- plugins/extract/mask/_base.py | 2 +- plugins/train/trainer/_base.py | 2 +- requirements_amd.txt | 2 +- requirements_cpu.txt | 2 +- requirements_nvidia.txt | 2 +- setup.py | 82 +++++++++++++++++++++++++++++---- tests/startup_test.py | 4 +- 15 files changed, 141 insertions(+), 44 deletions(-) diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index d1030fb405..e867a681db 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -53,10 +53,10 @@ def _test_for_tf_version(self): Raises ------ FaceswapError - If Tensorflow is not found, or is not between versions 2.2 and 2.2 + If Tensorflow is not found, or is not between versions 2.3 and 2.3 """ - min_ver = 2.2 - max_ver = 2.2 + min_ver = 2.3 + max_ver = 2.3 try: # Ensure tensorflow doesn't pin all threads to one core when using Math Kernel Library os.environ["TF_MIN_GPU_MULTIPROCESSOR_COUNT"] = "4" diff --git a/lib/gui/stats.py b/lib/gui/stats.py index 6827a8d7c0..fb02d674b0 100644 --- a/lib/gui/stats.py +++ b/lib/gui/stats.py @@ -17,7 +17,7 @@ import numpy as np import tensorflow as tf -from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module +from tensorflow.python.framework import errors_impl as tf_errors from tensorflow.core.util import event_pb2 from lib.serializer import get_serializer @@ -317,6 +317,7 @@ def _cache_data(self, session_id, is_training=False): loss = [] timestamps = [] last_step = -1 + carry_over = None live_data = is_training and session_id == max(self._log_filenames) if live_data: @@ -333,19 +334,31 @@ def _cache_data(self, session_id, is_training=False): if last_step == -1: last_step = event.step if live_data else 0 + if live_data and self._cache[session_id].get("carry_over"): + step = self._cache[session_id]["carry_over"] + logger.debug("Retrieving carried over data: %s", step) + self._cache[session_id]["carry_over"] = None + if event.step != last_step: - loss.append(step) + if last_step != 0: + loss.append(step) step = [] last_step = event.step summary = event.summary.value[0] tag = summary.tag - if tag == "batch_total": + # Pre tf2.3 totals were "batch_total" + if tag in ("batch_loss", "batch_total"): timestamps.append(event.wall_time) continue - lbl = tag.replace("batch_", "") + # tf2.3 stopped respecting loss names in tensorboard callback so rewrite + lbl_split = tag.replace("batch_", "").replace("_loss", "").split("_") + if lbl_split[-1] in ("a", "b"): + lbl = "face_{}".format(lbl_split[-1]) + else: + lbl = "mask_{}".format(lbl_split[-2]) if lbl not in labels: labels.append(lbl) @@ -359,9 +372,20 @@ def _cache_data(self, session_id, is_training=False): if step: loss.append(step) - loss = np.array(loss, dtype="float32") - timestamps = np.array(timestamps, dtype="float64") + try: + loss = np.array(loss, dtype="float32") + except ValueError as err: + # When collecting live loss, the current batch may not be completely populated + # Carry over the last loss to the next collection + if "setting an array element with a sequence" in str(err): + carry_over = loss[-1] + logger.debug("Carrying over data: (carry_over: %s, new loss: %s)", + carry_over, loss[:-1]) + loss = np.array(loss[:-1], dtype="float32") + else: + raise + timestamps = np.array(timestamps, dtype="float64") logger.debug("Caching session id: %s, labels: %s, loss: %s, timestamps: %s", session_id, labels, loss.shape, timestamps.shape) @@ -373,6 +397,8 @@ def _cache_data(self, session_id, is_training=False): loss_shape=loss.shape, timestamps=zlib.compress(timestamps), timestamps_shape=timestamps.shape) + if carry_over: + self._cache[session_id]["carry_over"] = carry_over def _get_latest_live(self): """ Obtain the latest event logs for live training data and add to the cache """ @@ -498,7 +524,7 @@ def get_timestamps(self, session_id=None, is_training=False): """ Read the timestamps from the TensorBoard logs. As loss timestamps are slightly different for each loss, we collect the timestamp from the - `batch_total` key. + `batch_loss` key. Parameters ---------- diff --git a/lib/logger.py b/lib/logger.py index bae8c1fe66..036657b1d8 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -79,7 +79,7 @@ def format(self, record): record.message = record.getMessage() record = self._rewrite_warnings(record) # strip newlines - if "\n" in record.message or "\r" in record.message: + if record.levelno < 30 and ("\n" in record.message or "\r" in record.message): record.message = record.message.replace("\n", "\\n").replace("\r", "\\r") if self.usesTime(): @@ -110,10 +110,19 @@ def _rewrite_warnings(cls, record): record: :class:`logging.LogRecord` The log record to check for rewriting """ + if record.levelno == 30 and record.funcName == "warn" and record.module == "ag_logging": + # TF 2.3 in Conda is imported with the wrong gast(0.4 when 0.3.3 should be used). This + # causes warnings in autograph. They don't appear to impact performance so de-elevate + # warning to debug + record.levelno = 10 + record.levelname = "DEBUG" + if record.levelno == 30 and (record.funcName == "_tfmw_add_deprecation_warning" or record.module in ("deprecation", "deprecation_wrapper")): + # Keras Deprecations. record.levelno = 10 record.levelname = "DEBUG" + return record @@ -271,7 +280,7 @@ def _stream_handler(loglevel, is_gui): def _crash_handler(log_format): - """ Add a handler that stores the last 100 debug lines to :attr:'_debug_buffer' for use in + """ Add a handler that stores the last 100 debug lines to :attr:'_DEBUG_BUFFER' for use in crash reports. Parameters @@ -284,7 +293,7 @@ def _crash_handler(log_format): :class:`logging.StreamHandler` The crash log handler """ - log_crash = logging.StreamHandler(_debug_buffer) + log_crash = logging.StreamHandler(_DEBUG_BUFFER) log_crash.setFormatter(log_format) log_crash.setLevel(logging.DEBUG) return log_crash @@ -311,7 +320,7 @@ def get_loglevel(loglevel): def crash_log(): - """ On a crash, write out the contents of :func:`_debug_buffer` containing the last 100 lines + """ On a crash, write out the contents of :func:`_DEBUG_BUFFER` containing the last 100 lines of debug messages to a crash report in the root Faceswap folder. Returns @@ -322,7 +331,7 @@ def crash_log(): original_traceback = traceback.format_exc() path = os.path.dirname(os.path.realpath(sys.argv[0])) filename = os.path.join(path, datetime.now().strftime("crash_report.%Y.%m.%d.%H%M%S%f.log")) - freeze_log = list(_debug_buffer) + freeze_log = list(_DEBUG_BUFFER) try: from lib.sysinfo import sysinfo # pylint:disable=import-outside-toplevel except Exception: # pylint:disable=broad-except @@ -335,13 +344,13 @@ def crash_log(): return filename -_old_factory = logging.getLogRecordFactory() +_OLD_FACTORY = logging.getLogRecordFactory() def _faceswap_logrecord(*args, **kwargs): """ Add a flag to :class:`logging.LogRecord` to not strip formatting from particular records. """ - record = _old_factory(*args, **kwargs) + record = _OLD_FACTORY(*args, **kwargs) record.strip_spaces = True return record @@ -352,4 +361,4 @@ def _faceswap_logrecord(*args, **kwargs): logging.setLoggerClass(FaceswapLogger) # Stores the last 100 debug messages -_debug_buffer = RollingBuffer(maxlen=100) +_DEBUG_BUFFER = RollingBuffer(maxlen=100) diff --git a/lib/model/losses_plaid.py b/lib/model/losses_plaid.py index edbb9673ee..a99ce123a0 100644 --- a/lib/model/losses_plaid.py +++ b/lib/model/losses_plaid.py @@ -73,7 +73,7 @@ def __init__(self, k_1=0.01, k_2=0.03, kernel_size=3, max_value=1.0): self.dim_ordering = K.image_data_format() @staticmethod - def __int_shape(input_tensor): + def _int_shape(input_tensor): """ Returns the shape of tensor or variable as a tuple of int or None entries. Parameters @@ -110,8 +110,8 @@ def __call__(self, y_true, y_pred): """ kernel = [self.kernel_size, self.kernel_size] - y_true = K.reshape(y_true, [-1] + list(self.__int_shape(y_pred)[1:])) - y_pred = K.reshape(y_pred, [-1] + list(self.__int_shape(y_pred)[1:])) + y_true = K.reshape(y_true, [-1] + list(self._int_shape(y_pred)[1:])) + y_pred = K.reshape(y_pred, [-1] + list(self._int_shape(y_pred)[1:])) patches_pred = self.extract_image_patches(y_pred, kernel, kernel, diff --git a/lib/model/losses_tf.py b/lib/model/losses_tf.py index cb5640b5b2..d8bd29d52b 100644 --- a/lib/model/losses_tf.py +++ b/lib/model/losses_tf.py @@ -71,7 +71,7 @@ def __init__(self, k_1=0.01, k_2=0.03, kernel_size=3, max_value=1.0): self.dim_ordering = K.image_data_format() @staticmethod - def __int_shape(input_tensor): + def _int_shape(input_tensor): """ Returns the shape of tensor or variable as a tuple of int or None entries. Parameters @@ -108,8 +108,8 @@ def call(self, y_true, y_pred): """ kernel = [self.kernel_size, self.kernel_size] - y_true = K.reshape(y_true, [-1] + list(self.__int_shape(y_pred)[1:])) - y_pred = K.reshape(y_pred, [-1] + list(self.__int_shape(y_pred)[1:])) + y_true = K.reshape(y_true, [-1] + list(self._int_shape(y_pred)[1:])) + y_pred = K.reshape(y_pred, [-1] + list(self._int_shape(y_pred)[1:])) patches_pred = self.extract_image_patches(y_pred, kernel, kernel, diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 8b209dcf3d..51c8463942 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -6,7 +6,7 @@ import os import sys -from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module +from tensorflow.python.framework import errors_impl as tf_errors from lib.multithreading import MultiThread from lib.queue_manager import queue_manager diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index a09c0bf9d2..12e3a33b31 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -17,7 +17,7 @@ import cv2 import numpy as np -from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module +from tensorflow.python.framework import errors_impl as tf_errors from lib.utils import get_backend, FaceswapError from plugins.extract._base import Extractor, logger, ExtractMedia diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index dd0790b02a..97c6effdaa 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -18,7 +18,7 @@ import cv2 import numpy as np -from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module +from tensorflow.python.framework import errors_impl as tf_errors from lib.align import DetectedFace from lib.utils import get_backend, FaceswapError diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 2c58f9050b..b02e3ede2e 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -16,7 +16,7 @@ import cv2 import numpy as np -from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module +from tensorflow.python.framework import errors_impl as tf_errors from lib.align import AlignedFace from lib.utils import get_backend, FaceswapError diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index a61fcaf3ed..0ef5a2db52 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -18,7 +18,7 @@ import numpy as np import tensorflow as tf -from tensorflow.python import errors_impl as tf_errors # pylint:disable=no-name-in-module +from tensorflow.python.framework import errors_impl as tf_errors from tqdm import tqdm from lib.align import Alignments, DetectedFace diff --git a/requirements_amd.txt b/requirements_amd.txt index 1bc3307a95..957ea579ff 100644 --- a/requirements_amd.txt +++ b/requirements_amd.txt @@ -1,3 +1,3 @@ -r _requirements_base.txt -tensorflow>=2.2.0,<2.3.0 +tensorflow>=2.3.0,<2.4.0 plaidml-keras==0.7.0 diff --git a/requirements_cpu.txt b/requirements_cpu.txt index 971f5b50a7..f89f053dfb 100644 --- a/requirements_cpu.txt +++ b/requirements_cpu.txt @@ -1,2 +1,2 @@ -r _requirements_base.txt -tensorflow>=2.2.0,<2.3.0 +tensorflow>=2.3.0,<2.4.0 diff --git a/requirements_nvidia.txt b/requirements_nvidia.txt index f695054174..5323101b27 100644 --- a/requirements_nvidia.txt +++ b/requirements_nvidia.txt @@ -1,2 +1,2 @@ -r _requirements_base.txt -tensorflow-gpu>=2.2.0,<2.3.0 +tensorflow-gpu>=2.3.0,<2.4.0 diff --git a/setup.py b/setup.py index 97db789bf6..438021a73b 100755 --- a/setup.py +++ b/setup.py @@ -12,11 +12,11 @@ import sys from subprocess import CalledProcessError, run, PIPE, Popen -from pkg_resources import parse_requirements +from pkg_resources import parse_requirements, Requirement INSTALL_FAILED = False # Revisions of tensorflow GPU and cuda/cudnn requirements -TENSORFLOW_REQUIREMENTS = {">=2.2.0,<2.3.0": ["10.1", "7.6"]} +TENSORFLOW_REQUIREMENTS = {">=2.3.0,<2.4.0": ["10.1", "7.6"]} # Mapping of Python packages to their conda names if different from pip or in non-default channel CONDA_MAPPING = { # "opencv-python": ("opencv", "conda-forge"), # Periodic issues with conda-forge opencv @@ -55,7 +55,7 @@ def __init__(self, logger=None, updater=False): self.upgrade_pip() self.installed_packages = self.get_installed_packages() - self.get_installed_conda_packages() + self.installed_packages.update(self.get_installed_conda_packages()) @property def encoding(self): @@ -231,9 +231,11 @@ def get_installed_conda_packages(self): chk = os.popen("conda list").read() installed = [re.sub(" +", " ", line.strip()) for line in chk.splitlines() if not line.startswith("#")] + retval = dict() for pkg in installed: item = pkg.split(" ") - self.installed_packages[item[0]] = item[1] + retval[item[0]] = item[1] + return retval def update_tf_dep(self): """ Update Tensorflow Dependency """ @@ -260,7 +262,7 @@ def update_tf_dep(self): return self.output.warning( - "The minimum Tensorflow requirement is 2.2 \n" + "The minimum Tensorflow requirement is 2.3 \n" "Tensorflow currently has no official prebuild for your CUDA, cuDNN " "combination.\nEither install a combination that Tensorflow supports or " "build and install your own tensorflow-gpu.\r\n" @@ -547,7 +549,8 @@ def cudnn_checkfiles_linux(): cudnn_path = chk[chk.find("=>") + 3:chk.find("libcudnn") - 1] cudnn_path = os.path.realpath(cudnn_path) cudnn_path = cudnn_path.replace("lib", "include") - cudnn_checkfiles = [os.path.join(cudnn_path, "cudnn_v{}.h".format(cudnn_vers)), + cudnn_checkfiles = [os.path.join(cudnn_path, "cudnn_version.h"), + os.path.join(cudnn_path, "cudnn_v{}.h".format(cudnn_vers)), os.path.join(cudnn_path, "cudnn.h")] return cudnn_checkfiles @@ -580,6 +583,8 @@ def __init__(self, environment): not self.env.missing_packages and not self.env.conda_missing_packages): self.output.info("All Dependencies are up to date") return + if self.env.updater: + self._remove_unrequired_packages() self.install_missing_dep() if self.env.updater: return @@ -627,6 +632,42 @@ def check_conda_missing_dep(self): self.env.conda_missing_packages.append(pkg) continue + def _remove_unrequired_packages(self): + """ Remove packages that have been installed by Pip that might now be installed by + Conda. + + This specifically relates to tensorflow 2.2 when a Conda version was not available for + Windows, so needed to be installed by Pip, with the Cuda toolkit coming from Conda. + + This method is left here in case it is needed in the future. """ + if not self.env.is_conda or self.env.os_version[0] != "Windows": + return + installed_pip = self.env.get_installed_packages() + if "tensorflow-gpu" not in installed_pip: + return + if not installed_pip["tensorflow-gpu"].startswith("2.2"): + return + # The below are a load of pip installed tf dependencies. They may not need to be all + # removed, but won't hurt to take them out of pip and put in Conda + remove_packages = ["urllib3", "pyasn1", "idna", "chardet", "rsa", "requests", + "pyasn1-modules", "oauthlib", "cachetools", "requests-oauthlib", + "google-auth", "werkzeug", "tensorboard-plugin-wit", "protobuf", + "numpy", "markdown", "grpcio", "google-auth-oauthlib", "absl-py", + "wrapt", "termcolor", "tensorflow-gpu-estimator", "tensorboard", + "opt-einsum", "keras-preprocessing", "h5py", "google-pasta", "gast", + "astunparse", "tensorflow-gpu"] + self.output.info("Uninstalling Pip Tensorflow 2.2") + pipexe = [sys.executable, "-m", "pip", "uninstall", "-y", "-qq"] + if not self.env.is_admin and not self.env.is_virtualenv: + pipexe.append("--user") + pipexe.extend([pkg for pkg in remove_packages if pkg in installed_pip]) + + try: + run(pipexe, check=True) + except CalledProcessError: + self.output.warning("Couldn't remove Tensorflow 2.2 with pip. You should attempt this " + "manually") + def install_missing_dep(self): """ Install missing dependencies """ # Install conda packages first @@ -663,15 +704,31 @@ def install_conda_packages(self): def conda_installer(self, package, channel=None, verbose=False, conda_only=False): """ Install a conda package """ # Packages with special characters need to be enclosed in double quotes - if any(char in package for char in (" ", "<", ">", "*", "|")): - package = "\"{}\"".format(package) + cuda_cudnn = None success = True condaexe = ["conda", "install", "-y"] if not verbose or self.env.updater: condaexe.append("-q") if channel: condaexe.extend(["-c", channel]) + + # Windows TF2.3 doesn't pull in the Cuda toolkit, so we may as well be explicit + # TODO This is not a robust enough check if we have more than 1 tf version + if package.startswith("tensorflow-gpu"): # Add toolkit + specs = Requirement.parse(package).specs + for key, val in TENSORFLOW_REQUIREMENTS.items(): + req_specs = Requirement.parse("foobar" + key).specs + if all(item in req_specs for item in specs): + cuda_cudnn = val + break + + if any(char in package for char in (" ", "<", ">", "*", "|")): + package = "\"{}\"".format(package) condaexe.append(package) + + if cuda_cudnn is not None: + condaexe.extend(["cudatoolkit={}".format(cuda_cudnn[0]), + "cudnn={}".format(cuda_cudnn[1])]) self.output.info("Installing {}".format(package.replace("\"", ""))) shell = self.env.os_version[0] == "Windows" try: @@ -710,13 +767,18 @@ def pip_installer(self, package): def _tensorflow_dependency_install(self): """ Install the Cuda/cuDNN dependencies from Conda when tensorflow is not available - in Conda """ + in Conda. + + This was used whilst Tensorflow 2.2 was not available for Windows in Conda. It is kept + here in case it is required again in the future. + """ # TODO This will need to be more robust if/when we accept multiple Tensorflow Versions versions = list(TENSORFLOW_REQUIREMENTS.values())[-1] condaexe = ["conda", "search"] pkgs = ["cudatoolkit", "cudnn"] + shell = self.env.os_version[0] == "Windows" for pkg in pkgs: - chk = Popen(condaexe + [pkg], shell=True, stdout=PIPE) + chk = Popen(condaexe + [pkg], shell=shell, stdout=PIPE) available = [line.split() for line in chk.communicate()[0].decode(self.env.encoding).splitlines() if line.startswith(pkg)] diff --git a/tests/startup_test.py b/tests/startup_test.py index 786907f199..72de7e22ae 100644 --- a/tests/startup_test.py +++ b/tests/startup_test.py @@ -25,5 +25,5 @@ def test_backend(dummy): # pylint:disable=unused-argument def test_keras(dummy): # pylint:disable=unused-argument """ Sanity check to ensure that tensorflow keras is being used for CPU and standard keras for AMD. """ - assert ((_BACKEND == "cpu" and keras.__version__.endswith("-tf")) or - (_BACKEND == "amd" and not keras.__version__.endswith("-tf"))) + assert ((_BACKEND == "cpu" and keras.__version__ == "2.4.0") or + (_BACKEND == "amd" and keras.__version__ == "2.2.4")) From 399df9860f5b23bd852c321b45635528fce8e8b9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 18 Dec 2020 23:40:06 +0000 Subject: [PATCH 334/981] jobs.alignments - fix memory leak on sparse alignments extract --- tools/alignments/jobs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index b22c0f0609..13acd1f970 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -541,6 +541,8 @@ def _output_faces(self, filename, image): face_count = 0 frame_name, extension = os.path.splitext(filename) faces = self._select_valid_faces(filename, image) + if not faces: + return face_count if self._is_legacy: faces = self._process_legacy(filename, image, faces) From 7c296e6f7a4a1de41e7346fa5760700589fe1c4e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 20 Dec 2020 01:33:54 +0000 Subject: [PATCH 335/981] training - bugfixes - Correctly subcrop training images - type fix zoom_amount in transformations --- lib/align/aligned_face.py | 2 +- lib/training_data.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 4dcc7ea884..16062c9a0c 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -432,7 +432,7 @@ def get_cropped_roi(self, centering): if centering not in self._cache["cropped_roi"][0]: offset = self.pose.offset.get(centering, np.float32((0, 0))) # legacy = 0,0 offset -= self.pose.offset["head"] - offset *= ((self._size - self._padding["head"]) / 2) + offset *= (self.size - (self._size * _EXTRACT_RATIOS["head"])) center = np.rint(offset + self._size / 2).astype("int32") padding = self.get_cropped_size(centering) // 2 diff --git a/lib/training_data.py b/lib/training_data.py index e0d6ccbd17..215b908e3d 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -744,7 +744,7 @@ def transform(self, batch): return batch logger.trace("Randomly transforming image") rotation_range = self._config.get("rotation_range", 10) - zoom_range = self._config.get("zoom_range", 5) / 100 + zoom_range = self._config.get("zoom_amount", 5) / 100 shift_range = self._config.get("shift_range", 5) / 100 rotation = np.random.uniform(-rotation_range, From 9182c56921bbf6d7783e0c694004fd61ca8200c1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 20 Dec 2020 13:37:59 +0000 Subject: [PATCH 336/981] Re-instate tf2.2 minimum version (2.3 not on Conda for Linux) --- lib/cli/launcher.py | 4 ++-- requirements_amd.txt | 2 +- requirements_cpu.txt | 2 +- requirements_nvidia.txt | 2 +- setup.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index e867a681db..03d9e006d0 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -53,9 +53,9 @@ def _test_for_tf_version(self): Raises ------ FaceswapError - If Tensorflow is not found, or is not between versions 2.3 and 2.3 + If Tensorflow is not found, or is not between versions 2.2 and 2.3 """ - min_ver = 2.3 + min_ver = 2.2 max_ver = 2.3 try: # Ensure tensorflow doesn't pin all threads to one core when using Math Kernel Library diff --git a/requirements_amd.txt b/requirements_amd.txt index 957ea579ff..a1ea23f152 100644 --- a/requirements_amd.txt +++ b/requirements_amd.txt @@ -1,3 +1,3 @@ -r _requirements_base.txt -tensorflow>=2.3.0,<2.4.0 +tensorflow>=2.2.0,<2.4.0 plaidml-keras==0.7.0 diff --git a/requirements_cpu.txt b/requirements_cpu.txt index f89f053dfb..ab0d3cb7fa 100644 --- a/requirements_cpu.txt +++ b/requirements_cpu.txt @@ -1,2 +1,2 @@ -r _requirements_base.txt -tensorflow>=2.3.0,<2.4.0 +tensorflow>=2.2.0,<2.4.0 diff --git a/requirements_nvidia.txt b/requirements_nvidia.txt index 5323101b27..4f59f5171c 100644 --- a/requirements_nvidia.txt +++ b/requirements_nvidia.txt @@ -1,2 +1,2 @@ -r _requirements_base.txt -tensorflow-gpu>=2.3.0,<2.4.0 +tensorflow-gpu>=2.2.0,<2.4.0 diff --git a/setup.py b/setup.py index 438021a73b..4efb5b780c 100755 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ INSTALL_FAILED = False # Revisions of tensorflow GPU and cuda/cudnn requirements -TENSORFLOW_REQUIREMENTS = {">=2.3.0,<2.4.0": ["10.1", "7.6"]} +TENSORFLOW_REQUIREMENTS = {">=2.2.0,<2.4.0": ["10.1", "7.6"]} # Mapping of Python packages to their conda names if different from pip or in non-default channel CONDA_MAPPING = { # "opencv-python": ("opencv", "conda-forge"), # Periodic issues with conda-forge opencv From fa616ffea29eea8d52ed617d7969a7b41acbd093 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 20 Dec 2020 14:11:55 +0000 Subject: [PATCH 337/981] GUI: Display paths in tooltips --- lib/gui/control_helper.py | 10 +++++++--- lib/gui/custom_widgets.py | 17 ++++++++++++----- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 9a63a545ed..cdebb2678b 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -21,13 +21,14 @@ _RECREATE_OBJECTS = dict(tooltips=dict(), commands=dict(), contextmenus=dict()) -def _get_tooltip(widget, text, wraplength=600): +def _get_tooltip(widget, text=None, text_variable=None, wraplength=600): """ Store the tooltip layout and widget id in _TOOLTIPS and return a tooltip """ _RECREATE_OBJECTS["tooltips"][str(widget)] = {"text": text, + "text_variable": text_variable, "wraplength": wraplength} logger.debug("Adding to tooltips dict: (widget: %s. text: '%s', wraplength: %s)", widget, text, wraplength) - return Tooltip(widget, text=text, wraplength=wraplength) + return Tooltip(widget, text=text, text_variable=text_variable, wraplength=wraplength) def _get_contextmenu(widget): @@ -832,7 +833,10 @@ def build_one_control(self): if self.option.control != ttk.Checkbutton: ctl.pack(padx=5, pady=5, fill=tk.X, expand=True) if self.option.helptext is not None and not self.helpset: - _get_tooltip(ctl, text=self.option.helptext, wraplength=600) + tooltip_kwargs = dict(text=self.option.helptext, wraplength=600) + if self.option.sysbrowser is not None: + tooltip_kwargs["text_variable"] = self.option.tk_var + _get_tooltip(ctl, **tooltip_kwargs) logger.debug("Built control: '%s'", self.option.name) diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index 873e22139e..c2abd3b01a 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -529,9 +529,8 @@ def progress_update(self, message, position, update_position=True): self._pbar_position.set(position) -class Tooltip: - """ - Create a tooltip for a given widget as the mouse goes on it. +class Tooltip: # pylint:disable=too-few-public-methods + """ Create a tooltip for a given widget as the mouse goes on it. Parameters ---------- @@ -543,6 +542,9 @@ class Tooltip: (left, top, right, bottom) padding for the tool-tip. Default: (5, 3, 5, 3) text: str, optional The text to be displayed in the tool-tip. Default: 'widget info' + text_variable: :class:`tkinter.strVar`, optional + The text variable to use for dynamic help text. Appended after the contents of :attr:`text` + if provided. Default: ``None`` waittime: int, optional The time in milliseconds to wait before showing the tool-tip. Default: 400 wraplength: int, optional @@ -560,12 +562,13 @@ class Tooltip: http://www.daniweb.com/programming/software-development/code/484591/a-tooltip-class-for-tkinter """ def __init__(self, widget, *, background="#FFFFEA", pad=(5, 3, 5, 3), text="widget info", - waittime=400, wraplength=250): + text_variable=None, waittime=400, wraplength=250): self._waittime = waittime # in milliseconds, originally 500 self._wraplength = wraplength # in pixels, originally 180 self._widget = widget self._text = text + self._text_variable = text_variable self._widget.bind("", self._on_enter) self._widget.bind("", self._on_leave) self._widget.bind("", self._on_leave) @@ -658,8 +661,12 @@ def tip_pos_calculator(widget, label, win = tk.Frame(self._topwidget, background=background, borderwidth=0) + + text = self._text + if self._text_variable and self._text_variable.get(): + text += "\n\nCurrent value: '{}'".format(self._text_variable.get()) label = tk.Label(win, - text=self._text, + text=text, justify=tk.LEFT, background=background, relief=tk.SOLID, From 5a79a6f5e9f859bbd80d5f403052df82e1c25f1c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 21 Dec 2020 18:39:50 +0000 Subject: [PATCH 338/981] Bugfix - Warp to landmarks --- lib/align/__init__.py | 2 +- lib/align/aligned_face.py | 166 ++++++++++++++++----------------- lib/align/detected_face.py | 4 +- lib/cli/launcher.py | 2 +- lib/training_data.py | 14 +-- plugins/train/trainer/_base.py | 41 +++++--- 6 files changed, 117 insertions(+), 112 deletions(-) diff --git a/lib/align/__init__.py b/lib/align/__init__.py index d34adf3948..e9a82fa53d 100644 --- a/lib/align/__init__.py +++ b/lib/align/__init__.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ Package for handling alignments files, detected faces and aligned faces along with their associated objects. """ -from .aligned_face import AlignedFace, _EXTRACT_RATIOS, get_matrix_scaling, PoseEstimate, transform_image # noqa +from .aligned_face import AlignedFace, _EXTRACT_RATIOS, get_matrix_scaling, get_centered_size, PoseEstimate, transform_image # noqa from .alignments import Alignments # noqa from .detected_face import BlurMask, DetectedFace, Mask # noqa diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 16062c9a0c..25fe0ce417 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -166,7 +166,7 @@ def __init__(self, landmarks, image=None, centering="face", size=64, coverage_ra self._cache = self._set_cache() - self._face = self._extract_face(image) + self._face = self.extract_face(image) logger.trace("Initialized: %s (matrix: %s, padding: %s, face shape: %s)", self.__class__.__name__, self._matrices["legacy"], self._padding, self._face if self._face is None else self._face.shape) @@ -194,6 +194,17 @@ def matrix(self): logger.trace("original matrix: %s, new matrix: %s", self._matrices["legacy"], matrix) return self._matrices[self._centering] + @property + def _head_size(self): + """ int: The size of the full head extract image calculated from the required + centering. """ + with self._cache["head_size"][1]: + if self._centering not in self._cache["head_size"][0]: + self._cache["head_size"][0][self._centering] = get_centered_size(self._centering, + "head", + self.size) + return self._cache["head_size"][0][self._centering] + @property def pose(self): """ :class:`lib.align.PoseEstimate`: The estimated pose in 3D space. """ @@ -277,6 +288,7 @@ def _set_cache(cls): landmarks=[None, Lock()], adjusted_matrix=[None, Lock()], interpolators=[None, Lock()], + head_size=[dict(), Lock()], cropped_roi=[dict(), Lock()], cropped_size=[dict(), Lock()], cropped_slices=[dict(), Lock()]) @@ -305,7 +317,7 @@ def transform_points(self, points, invert=False): invert, points, retval) return retval - def _extract_face(self, image): + def extract_face(self, image): """ Extract the face from a source image and populate :attr:`face`. If an image is not provided then ``None`` is returned. @@ -323,10 +335,8 @@ def _extract_face(self, image): """ if image is None: logger.debug("_extract_face called without a loaded image. Returning empty face.") - if self._is_aligned: - raise ValueError("An aligned face must be provided if calling with " - "'is_aligned=True'") return None + if self._is_aligned and self._centering != "head": # Crop out the sub face from full head image = self._convert_centering(image) @@ -345,7 +355,7 @@ def _convert_centering(self, image): so it needs to be cropped out to the appropriate centering. This function temporarily converts this object to a full head aligned face, extracts the - sub-cropped face to the correct centering, revers the sub crop and returns the cropped + sub-cropped face to the correct centering, reverse the sub crop and returns the cropped face. Parameters @@ -359,30 +369,17 @@ def _convert_centering(self, image): The aligned image with the correct centering """ # Input image is sized up because of integer rounding - src_size = self.size - (self._size * _EXTRACT_RATIOS[self._centering]) - head_size = 2 * int(np.rint(src_size / (1 - _EXTRACT_RATIOS["head"]) / 2)) - if head_size != image.shape[0]: - interp = cv2.INTER_CUBIC if image.shape[0] < head_size else cv2.INTER_AREA - image = cv2.resize(image, (head_size, head_size), interpolation=interp) - - # store requested size + centering whilst temporary converting to full head extract - old_centering = self._centering - old_size = self.size - self._centering = "head" - self._size = image.shape[0] - - # crop the requested centering from image - size = self.get_cropped_size(old_centering) - out = np.zeros((size, size, image.shape[-1]), dtype=image.dtype) - slices = self.get_cropped_slices(old_centering) + logger.trace("head_size: %s, image_size: %s, target_size: %s", + self._head_size, image.shape[0], self.size) + if self._head_size != image.shape[0]: + interp = cv2.INTER_CUBIC if image.shape[0] < self._head_size else cv2.INTER_AREA + image = cv2.resize(image, (self._head_size, self._head_size), interpolation=interp) + + out = np.zeros((self.size, self.size, image.shape[-1]), dtype=image.dtype) + slices = self._get_cropped_slices() out[slices["out"][0], slices["out"][1], :] = image[slices["in"][0], slices["in"][1], :] - - # Revert back to the correct centering and size and reset the cache - self._centering = old_centering - self._size = old_size - self._cache = self._set_cache() logger.trace("Cropped from aligned extract: (centering: %s, in shape: %s, out shape: %s)", - old_centering, image.shape, out.shape) + self._centering, image.shape, out.shape) return out @classmethod @@ -425,86 +422,40 @@ def get_cropped_roi(self, centering): The (`left`, `top`, `right`, `bottom` location of the region of interest within an aligned face centered on the head for the given centering """ - if self._centering != "head": - raise ValueError("Sub ROI can only be obtained from an aligned face with 'head' " - "centering") with self._cache["cropped_roi"][1]: if centering not in self._cache["cropped_roi"][0]: offset = self.pose.offset.get(centering, np.float32((0, 0))) # legacy = 0,0 offset -= self.pose.offset["head"] - offset *= (self.size - (self._size * _EXTRACT_RATIOS["head"])) + offset *= (self._head_size - (self._head_size * _EXTRACT_RATIOS["head"])) - center = np.rint(offset + self._size / 2).astype("int32") - padding = self.get_cropped_size(centering) // 2 + center = np.rint(offset + self._head_size / 2).astype("int32") + padding = self.size // 2 roi = np.array([center - padding, center + padding]).ravel() logger.trace("centering: '%s', center: %s, padding: %s, sub roi: %s", centering, center, padding, roi) self._cache["cropped_roi"][0][centering] = roi return self._cache["cropped_roi"][0][centering] - def get_cropped_size(self, centering): - """ Obtain the size of a cropped face from a full head centered image. - - Parameters - ---------- - centering: ["legacy", "face"] - The type of centering to obtain the region of interest for. "legacy" places the nose - in the center of the image (the original method for aligning). "face" aligns for the - nose to be in the center of the face (top to bottom) but the center of the skull for - left to right. - - Returns - ------- - int - The pixel size of a sub-crop image from a full head aligned image - - Notes - ----- - The ROI in relation to the source image is calculated by rounding the padding of one side - to the nearest integer then applying this padding to the center of the crop, so the size - is calculated in the same way. - """ - if self._centering != "head": - raise ValueError("Sub ROI can only be obtained from an aligned face with 'head' " - "centering") - with self._cache["cropped_size"][1]: - if not self._cache["cropped_size"][0].get(centering): - src_size = self.size - (self._size * _EXTRACT_RATIOS["head"]) - size = 2 * int(np.rint(src_size / (1 - _EXTRACT_RATIOS[centering]) / 2)) - logger.trace("centering: %s, size: %s, crop_size: %s", centering, self._size, size) - self._cache["cropped_size"][0][centering] = size - return self._cache["cropped_size"][0][centering] - - def get_cropped_slices(self, centering): + def _get_cropped_slices(self): """ Obtain the slices to turn a full head extract into an alternatively centered extract. - Parameters - ---------- - centering: ["legacy", "face"] - The type of centering to obtain the region of interest for. "legacy" places the nose - in the center of the image (the original method for aligning). "face" aligns for the - nose to be in the center of the face (top to bottom) but the center of the skull for - left to right. - Returns ------- dict The slices for an input full head image and output cropped image """ - if self._centering != "head": - raise ValueError("Cropped slices can only be obtained from an aligned face with " - "'head' centering") with self._cache["cropped_slices"][1]: - if not self._cache["cropped_slices"][0].get(centering): - size = self.get_cropped_size(centering) - roi = self.get_cropped_roi(centering) + if not self._cache["cropped_slices"][0].get(self._centering): + roi = self.get_cropped_roi(self._centering) + head_size = self._head_size slice_in = [slice(max(roi[1], 0), roi[3]), slice(max(roi[0], 0), roi[2])] - slice_out = [slice(max(roi[1] * -1, 0), size - max(0, roi[3] - self.size)), - slice(max(roi[0] * -1, 0), size - max(0, roi[2] - self.size))] - self._cache["cropped_slices"][0][centering] = {"in": slice_in, "out": slice_out} + slice_out = [slice(max(roi[1] * -1, 0), self._size - max(0, roi[3] - head_size)), + slice(max(roi[0] * -1, 0), self._size - max(0, roi[2] - head_size))] + self._cache["cropped_slices"][0][self._centering] = {"in": slice_in, + "out": slice_out} logger.trace("centering: %s, cropped_slices: %s", - centering, self._cache["cropped_slices"][0][centering]) - return self._cache["cropped_slices"][0][centering] + self._centering, self._cache["cropped_slices"][0][self._centering]) + return self._cache["cropped_slices"][0][self._centering] class PoseEstimate(): @@ -615,6 +566,47 @@ def _get_offset(self): return offset +def get_centered_size(source_centering, target_centering, size): + """ Obtain the size of a cropped face from an aligned image. + + Given an image of a certain dimensions, returns the dimensions of the sub-crop within that + image for the requested centering. + + Notes + ----- + `"legacy"` places the nose in the center of the image (the original method for aligning). + `"face"` aligns for the nose to be in the center of the face (top to bottom) but the center + of the skull for left to right. `"head"` places the center in the middle of the skull in 3D + space. + + The ROI in relation to the source image is calculated by rounding the padding of one side + to the nearest integer then applying this padding to the center of the crop, to ensure that + any dimensions always have an even number of pixels. + + Parameters + ---------- + source_centering: ["head", "face", "legacy"] + The centering that the original image is aligned at + target_centering: ["head", "face", "legacy"] + The centering that the sub-crop size should be obtained for + size: int + The size of the source image to obtain the cropped size for + + Returns + ------- + int + The pixel size of a sub-crop image from a full head aligned image + """ + if source_centering == target_centering: + retval = size + else: + src_size = size - (size * _EXTRACT_RATIOS[source_centering]) + retval = 2 * int(np.rint(src_size / (1 - _EXTRACT_RATIOS[target_centering]) / 2)) + logger.trace("source_centering: %s, target_centering: %s, size: %s, crop_size: %s", + source_centering, target_centering, size, retval) + return retval + + def _umeyama(source, destination, estimate_scale): """Estimate N-D similarity transformation with or without scaling. diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index 75f7c51376..8785d69fc1 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -7,7 +7,7 @@ import cv2 import numpy as np -from . import AlignedFace, _EXTRACT_RATIOS +from . import AlignedFace, _EXTRACT_RATIOS, get_centered_size logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -580,7 +580,7 @@ def set_sub_crop(self, offset): offset *= ((self.stored_size - (src_size / 2)) / 2) center = np.rint(offset + self.stored_size / 2).astype("int32") - crop_size = 2 * int(np.rint(src_size / (1 - _EXTRACT_RATIOS["legacy"]) / 2)) + crop_size = get_centered_size("face", "legacy", self.stored_size) roi = np.array([center - crop_size // 2, center + crop_size // 2]).ravel() self._sub_crop["size"] = crop_size diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 03d9e006d0..598f8d9ecf 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -82,7 +82,7 @@ def _test_for_tf_version(self): "installed. Please upgrade Tensorflow.".format(min_ver, tf_ver)) self._handle_import_error(msg) if tf_ver > max_ver: - msg = ("The maximumum supported Tensorflow is version {} but you have version {} " + msg = ("The maximum supported Tensorflow is version {} but you have version {} " "installed. Please downgrade Tensorflow.".format(max_ver, tf_ver)) self._handle_import_error(msg) logger.debug("Installed Tensorflow Version: %s", tf_ver) diff --git a/lib/training_data.py b/lib/training_data.py index 215b908e3d..df951f5c51 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -309,18 +309,14 @@ def _crop_to_center(self, filenames, batch, side): return batch, np.array([face.landmarks for face in aligned]) if not self._cache["crop_size"]: - size = aligned[0].get_cropped_size(self._config["centering"]) + size = aligned[0].size logger.debug("caching crop size: (centering: '%s', full size: %s, crop size: %s)", self._config["centering"], batch.shape[1], size) self._cache["crop_size"] = size size = self._cache["crop_size"] landmarks = np.array([face.landmarks for face in aligned]) - cropped = np.zeros((batch.shape[0], size, size, batch.shape[3]), dtype=batch.dtype) - - for out, align, img in zip(cropped, aligned, batch): - slices = align.get_cropped_slices(self._config["centering"]) - out[slices["out"][0], slices["out"][1], :] = img[slices["in"][0], slices["in"][1], :] + cropped = np.array([align.extract_face(img) for align, img in zip(aligned, batch)]) return cropped, landmarks def _apply_mask(self, filenames, batch, side): @@ -443,6 +439,12 @@ def _get_closest_match(self, filenames, side, batch_src_points): for key, aligned in self._aligned_faces[lm_side].items()} closest_hashes = [self._cache["nearest_landmarks"].get(filename) for filename in filenames] if None in closest_hashes: + # Resize mismatched training image size landmarks + sizes = {side: list(self._aligned_faces[side].values())[0].size + for side in self._aligned_faces} + if len(set(sizes.values())) > 1: + scale = sizes[side] / sizes[lm_side] + landmarks = {key: lms * scale for key, lms in landmarks.items()} closest_hashes = self._cache_closest_hashes(filenames, batch_src_points, landmarks) batch_dst_points = np.array([landmarks[choice(hsh)] for hsh in closest_hashes]) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 0ef5a2db52..319b195a8b 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -21,7 +21,7 @@ from tensorflow.python.framework import errors_impl as tf_errors from tqdm import tqdm -from lib.align import Alignments, DetectedFace +from lib.align import Alignments, AlignedFace, DetectedFace, get_centered_size from lib.image import read_image_hash_batch from lib.training_data import TrainingDataGenerator from lib.utils import FaceswapError, get_backend, get_folder, get_image_paths @@ -1077,12 +1077,15 @@ def __init__(self, model, image_list): self._args = model.command_line_arguments self._config = model.config self._alignments_paths = self._get_alignments_paths() - self._hashes, self._training_sizes = self._get_image_hashes(image_list) + self._hashes, sizes = self._get_image_hashes(image_list) + self._alignments_version = dict() self._detected_faces = dict() + self._load_alignments() self._check_all_faces() - self._aligned_faces = self._get_aligned_faces() + + self._aligned_faces = self._get_aligned_faces(sizes) logger.debug("Initialized %s", self.__class__.__name__) @property @@ -1127,9 +1130,14 @@ def _get_alignments_paths(self): logger.debug("Alignments paths: %s", retval) return retval - def _get_aligned_faces(self): + def _get_aligned_faces(self, input_sizes): """ Pre-generate aligned faces as they are needed for all training functions. + Parameters + ---------- + input_sizes: dict + The training image sizes in pixels for side `a` and `b` as they are saved on disk + Returns ------- dict @@ -1138,12 +1146,15 @@ def _get_aligned_faces(self): """ retval = dict() for side, detected_faces in self._detected_faces.items(): - size = self._training_sizes[side] - centering = "legacy" if self._alignments_version[side] == 1.0 else "head" - logger.debug("side: %s, centering: %s", side, centering) ret_side = dict() + size = get_centered_size("legacy" if self._alignments_version[side] == 1.0 else "head", + self._config["centering"], + input_sizes[side]) for fhash, face in detected_faces.items(): - face.load_aligned(None, size=size, centering=centering) + face.aligned = AlignedFace(face.landmarks_xy, + centering=self._config["centering"], + size=size, + is_aligned=True) for filename in self._hash_to_filenames(side, fhash): ret_side[filename] = face.aligned retval[side] = ret_side @@ -1221,18 +1232,13 @@ def _get_landmarks_masks(self, side, detected_faces, area): """ logger.trace("side: %s, detected_faces: %s, area: %s", side, detected_faces, area) masks = dict() - if self._alignments_version[side] == 1.0: - centering = "legacy" - size = self._training_sizes[side] - else: - centering = self._config["centering"] - size = list(self._aligned_faces[side].values())[0].get_cropped_size(centering) + size = list(self._aligned_faces[side].values())[0].size for fhash, face in detected_faces.items(): mask = partial(face.get_landmark_mask, size, area, aligned=True, - centering=centering, + centering=self._config["centering"], dilation=size // 32, blur_kernel=size // 16, as_zip=True) @@ -1301,6 +1307,11 @@ def _load_alignments(self): alignments = Alignments(path, filename=filename) self._detected_faces[side] = self._to_detected_faces(alignments, side) self._alignments_version[side] = alignments.version + + if 1.0 in self._alignments_version.values() and self._config["centering"] != "legacy": + logger.debug("Updating alignments config to legacy for old facesets") + self._config["centering"] = "legacy" + logger.debug("alignments_versions: %s, detected_faces: %s, ", self._alignments_version, {k: len(v) for k, v in self._detected_faces.items()}) From ef1818072462f96276ff3afbb85365352d6ff055 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 28 Dec 2020 14:38:41 +0000 Subject: [PATCH 339/981] bugfix - Pin AMD version to TF2.2 - Tensorboard after v2.2 no longer works with old Keras --- requirements_amd.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements_amd.txt b/requirements_amd.txt index a1ea23f152..6ca508f292 100644 --- a/requirements_amd.txt +++ b/requirements_amd.txt @@ -1,3 +1,4 @@ -r _requirements_base.txt -tensorflow>=2.2.0,<2.4.0 +# tf2.2 is last version that tensorboard logging works with old Keras +tensorflow>=2.2.0,<2.3.0 plaidml-keras==0.7.0 From 199f6caccb2bb66de2bafe09439e058c3a2b6355 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 29 Dec 2020 01:50:48 +0000 Subject: [PATCH 340/981] update travis test --- tests/startup_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/startup_test.py b/tests/startup_test.py index 72de7e22ae..02e48bbfb9 100644 --- a/tests/startup_test.py +++ b/tests/startup_test.py @@ -25,5 +25,5 @@ def test_backend(dummy): # pylint:disable=unused-argument def test_keras(dummy): # pylint:disable=unused-argument """ Sanity check to ensure that tensorflow keras is being used for CPU and standard keras for AMD. """ - assert ((_BACKEND == "cpu" and keras.__version__ == "2.4.0") or + assert ((_BACKEND == "cpu" and keras.__version__ in ("2.3.0-tf", "2.4.0") or (_BACKEND == "amd" and keras.__version__ == "2.2.4")) From b67f91ea0b9d75bd9a2b1c312eedc7ff2e47a2b1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 29 Dec 2020 18:13:56 +0000 Subject: [PATCH 341/981] travis test typo fix --- tests/startup_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/startup_test.py b/tests/startup_test.py index 02e48bbfb9..05fd0dafca 100644 --- a/tests/startup_test.py +++ b/tests/startup_test.py @@ -25,5 +25,5 @@ def test_backend(dummy): # pylint:disable=unused-argument def test_keras(dummy): # pylint:disable=unused-argument """ Sanity check to ensure that tensorflow keras is being used for CPU and standard keras for AMD. """ - assert ((_BACKEND == "cpu" and keras.__version__ in ("2.3.0-tf", "2.4.0") or + assert ((_BACKEND == "cpu" and keras.__version__ in ("2.3.0-tf", "2.4.0")) or (_BACKEND == "amd" and keras.__version__ == "2.2.4")) From 29667b469612666f849a3e234b7fb04c45c1664a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 4 Jan 2021 15:15:48 +0000 Subject: [PATCH 342/981] Expand support for tf2.2-2.4 --- plugins/train/model/_base.py | 61 ++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 824ebb8dd1..b94524fc66 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -383,7 +383,7 @@ def _compile_model(self): self.config.get("clipnorm", False), self._args).optimizer if self._settings.use_mixed_precision: - optimizer = self._settings.LossScaleOptimizer(optimizer, True) + optimizer = self._settings.loss_scale_optimizer(optimizer) if get_backend() == "amd": self._rewrite_plaid_outputs() self._loss.configure(self._model) @@ -637,10 +637,14 @@ def __init__(self, arguments, mixed_precision, allow_growth, is_predict): logger.debug("Initializing %s: (arguments: %s, mixed_precision: %s, allow_growth: %s, " "is_predict: %s)", self.__class__.__name__, arguments, mixed_precision, allow_growth, is_predict) + self._tf_version = [int(i) for i in tf.__version__.split(".")[:2]] self._set_tf_settings(allow_growth, arguments.exclude_gpus) use_mixed_precision = not is_predict and mixed_precision and get_backend() == "nvidia" - if use_mixed_precision: + # Mixed precision moved out of experimental in tf 2.4 + if use_mixed_precision and self._tf_version[0] == 2 and self._tf_version[1] < 4: + self._mixed_precision = tf.keras.mixed_precision.experimental + elif use_mixed_precision: self._mixed_precision = tf.keras.mixed_precision else: self._mixed_precision = None @@ -662,11 +666,24 @@ def use_mixed_precision(self): """ bool: ``True`` if mixed precision training has been enabled, otherwise ``False``. """ return self._use_mixed_precision - @property - def LossScaleOptimizer(self): # pylint:disable=invalid-name - """ :class:`tf.keras.mixed_precision.experimental.LossScaleOptimizer`: Shortcut to the loss - scale optimizer for mixed precision training. """ - return self._mixed_precision.LossScaleOptimizer + def loss_scale_optimizer(self, optimizer): + """ Optimize loss scaling for mixed precision training. + + Parameters + ---------- + optimizer: :class:`tf.keras.optimizers.Optimizer` + The optimizer instance to wrap + + Returns + -------- + :class:`tf.keras.mixed_precision.loss_scale_optimizer.LossScaleOptimizer` + The original optimizer with loss scaling applied + """ + # tf versions < 2.4 had different kwargs where scaling needs to be explicitly defined + vers = self._tf_version + kwargs = dict(loss_scale="dynamic") if vers[0] == 2 and vers[1] < 4 else dict() + logger.debug("tf version: %s, kwargs: %s", vers, kwargs) + return self._mixed_precision.LossScaleOptimizer(optimizer, **kwargs) @classmethod def _set_tf_settings(cls, allow_growth, exclude_devices): @@ -706,7 +723,7 @@ def _set_tf_settings(cls, allow_growth, exclude_devices): tf.config.experimental.set_memory_growth(gpu, True) logger.debug("Set Tensorflow 'allow_growth' option") - def _set_keras_mixed_precision(self, use_mixed_precision, skip_check): + def _set_keras_mixed_precision(self, use_mixed_precision, exclude_gpus): """ Enable the Keras experimental Mixed Precision API. Enables the Keras experimental Mixed Precision API if requested in the user configuration @@ -717,12 +734,12 @@ def _set_keras_mixed_precision(self, use_mixed_precision, skip_check): use_mixed_precision: bool ``True`` if experimental mixed precision support should be enabled for Nvidia GPUs otherwise ``False``. - skip_check: bool - ``True`` if the mixed precision compatibility check should be skipped, otherwise - ``False``. + exclude_gpus: bool + ``True`` If connected GPUs are being excluded otherwise ``False``. - There is a bug in Tensorflow that will cause a failure if - "set_visible_devices" has been set and mixed_precision is enabled. Specifically in + There is a bug in Tensorflow 2.2 that will cause a failure if "set_visible_devices" has + been set and mixed_precision is enabled. This can happen if GPUs have been excluded. + The issue is Specifically in :file:`tensorflow.python.keras.mixed_precision.experimental.device_compatibility_check` From doc-string: "if list_local_devices() and tf.config.set_visible_devices() are both @@ -733,18 +750,19 @@ def _set_keras_mixed_precision(self, use_mixed_precision, skip_check): already been performed. This is likely to cause some issues, but not as many as guaranteed failure when limiting GPU devices """ - logger.debug("use_mixed_precision: %s, skip_check: %s", use_mixed_precision, skip_check) + logger.debug("use_mixed_precision: %s, exclude_gpus: %s", + use_mixed_precision, exclude_gpus) if not use_mixed_precision: logger.debug("Not enabling 'mixed_precision' (backend: %s, use_mixed_precision: %s)", get_backend(), use_mixed_precision) return False logger.info("Enabling Mixed Precision Training.") - if skip_check: - # TODO remove this hacky fix to disable mixed precision compatibility testing if/when - # fixed upstream. - # pylint:disable=import-outside-toplevel,protected-access - from tensorflow.python.keras.mixed_precision import \ + if exclude_gpus and self._tf_version[0] == 2 and self._tf_version[1] == 2: + # TODO remove this hacky fix to disable mixed precision compatibility testing when + # tf 2.2 support dropped + # pylint:disable=import-outside-toplevel,protected-access,import-error + from tensorflow.python.keras.mixed_precision.experimental import \ device_compatibility_check logger.debug("Overriding tensorflow _logged_compatibility_check parameter. Initial " "value: %s", device_compatibility_check._logged_compatibility_check) @@ -752,7 +770,10 @@ def _set_keras_mixed_precision(self, use_mixed_precision, skip_check): logger.debug("New value: %s", device_compatibility_check._logged_compatibility_check) policy = self._mixed_precision.Policy('mixed_float16') - self._mixed_precision.set_global_policy(policy) + if self._tf_version[0] == 2 and self._tf_version[1] < 4: + self._mixed_precision.set_policy(policy) + else: + self._mixed_precision.set_global_policy(policy) logger.debug("Enabled mixed precision. (Compute dtype: %s, variable_dtype: %s)", policy.compute_dtype, policy.variable_dtype) return True From f9a5b722e8039d388a6d31fdb86371cedccfa57e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 6 Jan 2021 01:24:17 +0000 Subject: [PATCH 343/981] Bugfix - setup.py. Explicitly install cudatoolkit in Conda --- setup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.py b/setup.py index 4efb5b780c..d2f9196918 100755 --- a/setup.py +++ b/setup.py @@ -715,6 +715,9 @@ def conda_installer(self, package, channel=None, verbose=False, conda_only=False # Windows TF2.3 doesn't pull in the Cuda toolkit, so we may as well be explicit # TODO This is not a robust enough check if we have more than 1 tf version if package.startswith("tensorflow-gpu"): # Add toolkit + # TODO Remove this hack to lower the max supported TF version when TF2.4 can be + # installed by setup.py + package = package.replace("2.5.0", "2.4.0") specs = Requirement.parse(package).specs for key, val in TENSORFLOW_REQUIREMENTS.items(): req_specs = Requirement.parse("foobar" + key).specs From 163b8fe3745dae928cf573d24c136bffb58869c3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 8 Jan 2021 23:43:22 +0000 Subject: [PATCH 344/981] sysinfo/setup - Standardize cuda/cudnn checks --- lib/sysinfo.py | 203 +++----------------------------------------- setup.py | 225 ++++++++++++++++++++++++++----------------------- 2 files changed, 132 insertions(+), 296 deletions(-) diff --git a/lib/sysinfo.py b/lib/sysinfo.py index 8abe965c01..e0ef06fbfe 100644 --- a/lib/sysinfo.py +++ b/lib/sysinfo.py @@ -5,16 +5,16 @@ import locale import os import platform -import re import sys from subprocess import PIPE, Popen import psutil from lib.gpu_stats import GPUStats +from setup import CudaCheck -class _SysInfo(): +class _SysInfo(): # pylint:disable=too-few-public-methods """ Obtain information about the System, Python and GPU """ def __init__(self): self._state_file = _State().state_file @@ -28,7 +28,7 @@ def __init__(self): self._python = dict(implementation=platform.python_implementation(), version=platform.python_version()) self._gpu = GPUStats(log=False).sys_info - self._cuda_path = self._get_cuda_path() + self._cuda_check = CudaCheck() @property def _encoding(self): @@ -145,203 +145,26 @@ def _git_commits(self): commits = stdout.decode().splitlines() return ". ".join(commits) - @property - def _cuda_keys_windows(self): - """ list: The CUDA Path environment variables stored for Windows users. """ - return [key for key in os.environ.keys() if key.lower().startswith("cuda_path_v")] - @property def _cuda_version(self): """ str: The installed CUDA version. """ # TODO Handle multiple CUDA installs - chk = Popen("nvcc -V", shell=True, stdout=PIPE, stderr=PIPE) - stdout, stderr = chk.communicate() - if not stderr: - version = re.search(r".*release (?P\d+\.\d+)", stdout.decode(self._encoding)) - version = version.groupdict().get("cuda", None) - if version: - return version - # Failed to load nvcc - if self._is_linux: - version = self._cuda_version_linux() - elif self._is_windows: - version = self._cuda_version_windows() - else: - version = "Unsupported OS" + retval = self._cuda_check.cuda_version + if not retval: + retval = "No global version found" if self._is_conda: - version += ". Check Conda packages for Conda Cuda" - return version + retval += ". Check Conda packages for Conda Cuda" + return retval @property def _cudnn_version(self): """ str: The installed cuDNN version. """ - if self._is_linux: - cudnn_checkfiles = self._cudnn_checkfiles_linux() - elif self._is_windows: - cudnn_checkfiles = self._cudnn_checkfiles_windows() - else: - retval = "Unsupported OS" - if self._is_conda: - retval += ". Check Conda packages for Conda cuDNN" - return retval - - cudnn_checkfile = None - for checkfile in cudnn_checkfiles: - if os.path.isfile(checkfile): - cudnn_checkfile = checkfile - break - - if not cudnn_checkfile: - retval = "No global version found" - if self._is_conda: - retval += ". Check Conda packages for Conda cuDNN" - return retval - - found = 0 - with open(cudnn_checkfile, "r") as ofile: - for line in ofile: - if line.lower().startswith("#define cudnn_major"): - major = line[line.rfind(" ") + 1:].strip() - found += 1 - elif line.lower().startswith("#define cudnn_minor"): - minor = line[line.rfind(" ") + 1:].strip() - found += 1 - elif line.lower().startswith("#define cudnn_patchlevel"): - patchlevel = line[line.rfind(" ") + 1:].strip() - found += 1 - if found == 3: - break - if found != 3: + retval = self._cuda_check.cudnn_version + if not retval: retval = "No global version found" if self._is_conda: retval += ". Check Conda packages for Conda cuDNN" - return retval - return "{}.{}.{}".format(major, minor, patchlevel) - - @staticmethod - def _cudnn_checkfiles_linux(): - """ Obtain the location of the files to check for cuDNN location in Linux. - - Returns - str: - The location of the header files for cuDNN - """ - chk = os.popen("ldconfig -p | grep -P \"libcudnn.so.\\d+\" | head -n 1").read() - if "libcudnn.so." not in chk: - return list() - chk = chk.strip().replace("libcudnn.so.", "") - cudnn_vers = chk[0] - cudnn_path = chk[chk.find("=>") + 3:chk.find("libcudnn") - 1] - cudnn_path = cudnn_path.replace("lib", "include") - cudnn_checkfiles = [os.path.join(cudnn_path, "cudnn_v{}.h".format(cudnn_vers)), - os.path.join(cudnn_path, "cudnn.h")] - return cudnn_checkfiles - - def _cudnn_checkfiles_windows(self): - """ Obtain the location of the files to check for cuDNN location in Windows. - - Returns - str: - The location of the header files for cuDNN - """ - # TODO A more reliable way of getting the windows location - if not self._cuda_path and not self._cuda_keys_windows: - return list() - if not self._cuda_path: - self._cuda_path = os.environ[self._cuda_keys_windows[0]] - - cudnn_checkfile = os.path.join(self._cuda_path, "include", "cudnn.h") - return [cudnn_checkfile] - - def _get_cuda_path(self): - """ Obtain the path to Cuda install location. - - Returns - ------- - str - The path to the install location of Cuda on the system - """ - if self._is_linux: - path = self._cuda_path_linux() - elif self._is_windows: - path = self._cuda_path_windows() - else: - path = None - return path - - @staticmethod - def _cuda_path_linux(): - """ Obtain the path to Cuda install location on Linux. - - Returns - ------- - str - The path to the install location of Cuda on a Linux system - """ - ld_library_path = os.environ.get("LD_LIBRARY_PATH", None) - chk = os.popen("ldconfig -p | grep -P \"libcudart.so.\\d+.\\d+\" | head -n 1").read() - if ld_library_path and not chk: - paths = ld_library_path.split(":") - for path in paths: - chk = os.popen("ls {} | grep -P -o \"libcudart.so.\\d+.\\d+\" | " - "head -n 1".format(path)).read() - if chk: - break - if not chk: - return None - return chk[chk.find("=>") + 3:chk.find("targets") - 1] - - @staticmethod - def _cuda_path_windows(): - """ Obtain the path to Cuda install location on Windows. - - Returns - ------- - str - The path to the install location of Cuda on a Windows system - """ - cuda_path = os.environ.get("CUDA_PATH", None) - return cuda_path - - def _cuda_version_linux(self): - """ Obtain the installed version of Cuda on a Linux system. - - Returns - ------- - The installed CUDA version on a Linux system - """ - ld_library_path = os.environ.get("LD_LIBRARY_PATH", None) - chk = os.popen("ldconfig -p | grep -P \"libcudart.so.\\d+.\\d+\" | head -n 1").read() - if ld_library_path and not chk: - paths = ld_library_path.split(":") - for path in paths: - chk = os.popen("ls {} | grep -P -o \"libcudart.so.\\d+.\\d+\" | " - "head -n 1".format(path)).read() - if chk: - break - if not chk: - retval = "No global version found" - if self._is_conda: - retval += ". Check Conda packages for Conda Cuda" - return retval - cudavers = chk.strip().replace("libcudart.so.", "") - return cudavers[:cudavers.find(" ")] - - def _cuda_version_windows(self): - """ Obtain the installed version of Cuda on a Windows system. - - Returns - ------- - The installed CUDA version on a Windows system - """ - cuda_keys = self._cuda_keys_windows - if not cuda_keys: - retval = "No global version found" - if self._is_conda: - retval += ". Check Conda packages for Conda Cuda" - return retval - cudavers = [key.lower().replace("cuda_path_v", "").replace("_", ".") for key in cuda_keys] - return " ".join(cudavers) + return retval def full_info(self): """ Obtain extensive system information stats, formatted into a human readable format. @@ -422,7 +245,7 @@ def get_sysinfo(): return retval -class _Configs(): +class _Configs(): # pylint:disable=too-few-public-methods """ Parses the config files in /faceswap/config and outputs the information stored within them in a human readable format. """ @@ -533,7 +356,7 @@ def _format_text(key, value): return "{0: <25} {1}\n".format(key.strip() + ":", value.strip()) -class _State(): +class _State(): # pylint:disable=too-few-public-methods """ Parses the state file in the current model directory, if the model is training, and formats the content into a human readable format. """ def __init__(self): diff --git a/setup.py b/setup.py index d2f9196918..fe36f55d23 100755 --- a/setup.py +++ b/setup.py @@ -36,7 +36,6 @@ def __init__(self, logger=None, updater=False): self.updater = updater # Flag that setup is being run by installer so steps can be skipped self.is_installer = False - self.cuda_path = "" self.cuda_version = "" self.cudnn_version = "" self.enable_amd = False @@ -78,11 +77,6 @@ def is_conda(self): return ("conda" in sys.version.lower() or os.path.exists(os.path.join(sys.prefix, 'conda-meta'))) - @property - def ld_library_path(self): - """ Get the ld library path """ - return os.environ.get("LD_LIBRARY_PATH", None) - @property def is_admin(self): """ Check whether user is admin """ @@ -227,7 +221,7 @@ def get_installed_packages(self): def get_installed_conda_packages(self): """ Get currently installed conda packages """ if not self.is_conda: - return + return None chk = os.popen("conda list").read() installed = [re.sub(" +", " ", line.strip()) for line in chk.splitlines() if not line.startswith("#")] @@ -375,8 +369,25 @@ def __init__(self, environment): if self.env.enable_cuda and self.env.is_conda: self.output.info("Skipping Cuda/cuDNN checks for Conda install") elif self.env.enable_cuda and self.env.os_version[0] in ("Linux", "Windows"): - self.cuda_check() - self.cudnn_check() + check = CudaCheck() + if check.cuda_version: + self.env.cuda_version = check.cuda_version + self.output.info("CUDA version: " + self.env.cuda_version) + else: + self.output.error("CUDA not found. Install and try again.\n" + "Recommended version: CUDA 10.1 cuDNN 7.6\n" + "CUDA: https://developer.nvidia.com/cuda-downloads\n" + "cuDNN: https://developer.nvidia.com/rdp/cudnn-download") + return + + if check.cudnn_version: + self.env.cudnn_version = ".".join(check.cudnn_version.split(".")[:2]) + self.output.info(f"cuDNN version: {self.env.cudnn_version}") + else: + self.output.error("cuDNN not found. See " + "https://github.com/deepfakes/faceswap/blob/master/INSTALL.md#" + "cudnn for instructions") + return elif self.env.enable_cuda and self.env.os_version[0] not in ("Linux", "Windows"): self.tips.macos() self.output.warning("Cannot find CUDA on macOS") @@ -386,11 +397,6 @@ def __init__(self, environment): if self.env.os_version[0] == "Windows": self.tips.pip() - @property - def cuda_keys_windows(self): - """ Return the OS Environ CUDA Keys for Windows """ - return [key for key in os.environ if key.lower().startswith("cuda_path_v")] - def amd_ask_enable(self): """ Enable or disable Plaidml for AMD""" self.output.info("AMD Support: AMD GPU support is currently limited.\r\n" @@ -439,81 +445,79 @@ def cuda_ask_enable(self): self.output.info("CUDA Disabled") self.env.enable_cuda = False - def cuda_check(self): - """ Check Cuda for Linux or Windows """ + +class CudaCheck(): # pylint:disable=too-few-public-methods + """ Find the location of system installed Cuda and cuDNN on Windows and Linux. """ + + def __init__(self): + self.cuda_path = None + self.cuda_version = None + self.cudnn_version = None + + self._os = platform.system().lower() + self._cuda_keys = [key for key in os.environ if key.lower().startswith("cuda_path_v")] + self._cudnn_header_files = ["cudnn_version.h", "cudnn.h"] + + if self._os in ("windows", "linux"): + self._cuda_check() + self._cudnn_check() + + def _cuda_check(self): + """ Obtain the location and version of Cuda and populate :attr:`cuda_version` and + :attr:`cuda_path` + + Initially just calls `nvcc -V` to get the installed version of Cuda currently in use. + If this fails, drills down to more OS specific checking methods. + """ chk = Popen("nvcc -V", shell=True, stdout=PIPE, stderr=PIPE) stdout, stderr = chk.communicate() if not stderr: - version = re.search(r".*release (?P\d+\.\d+)", stdout.decode(self.env.encoding)) - self.env.cuda_version = version.groupdict().get("cuda", None) - if self.env.cuda_version: - self.output.info("CUDA version: " + self.env.cuda_version) - return - # Failed to load nvcc - if self.env.os_version[0] == "Linux": - self.cuda_check_linux() - elif self.env.os_version[0] == "Windows": - self.cuda_check_windows() - - def cuda_check_linux(self): - """ Check Linux CUDA Version """ + version = re.search(r".*release (?P\d+\.\d+)", + stdout.decode(locale.getpreferredencoding())) + self.cuda_version = version.groupdict().get("cuda", None) + locate = "where" if self._os == "windows" else "which" + path = os.popen(f"{locate} nvcc").read() + if path: + path = path.split("\n")[0] # Split multiple entries and take first found + while True: # Get Cuda root folder + path, split = os.path.split(path) + if split == "bin": + break + self.cuda_path = path + return + + # Failed to load nvcc, manual check + getattr(self, f"_cuda_check_{self._os}")() + + def _cuda_check_linux(self): + """ For Linux check the dynamic link loader for libcudart. If not found with ldconfig then + attempt to find it in LD_LIBRARY_PATH. """ chk = os.popen("ldconfig -p | grep -P \"libcudart.so.\\d+.\\d+\" | head -n 1").read() - if self.env.ld_library_path and not chk: - paths = self.env.ld_library_path.split(":") - for path in paths: - chk = os.popen("ls {} | grep -P -o \"libcudart.so.\\d+.\\d+\" | " - "head -n 1".format(path)).read() + if not chk and os.environ.get("LD_LIBRARY_PATH"): + for path in os.environ["LD_LIBRARY_PATH"].split(":"): + chk = os.popen(f"ls {path} | grep -P -o \"libcudart.so.\\d+.\\d+\" | " + "head -n 1").read() if chk: break - if not chk: - self.output.error("CUDA not found. Install and try again.\n" - "Recommended version: CUDA 10.1 cuDNN 7.6\n" - "CUDA: https://developer.nvidia.com/cuda-downloads\n" - "cuDNN: https://developer.nvidia.com/rdp/cudnn-download") + if not chk: # Cuda not found return + cudavers = chk.strip().replace("libcudart.so.", "") - self.env.cuda_version = cudavers[:cudavers.find(" ")] - if self.env.cuda_version: - self.output.info("CUDA version: " + self.env.cuda_version) - self.env.cuda_path = chk[chk.find("=>") + 3:chk.find("targets") - 1] - - def cuda_check_windows(self): - """ Check Windows CUDA Version """ - cuda_keys = self.cuda_keys_windows - if not cuda_keys: - self.output.error("CUDA not found. See " - "https://github.com/deepfakes/faceswap/blob/master/INSTALL.md#cuda " - "for instructions") + self.cuda_version = cudavers[:cudavers.find(" ")] + self.cuda_path = chk[chk.find("=>") + 3:chk.find("targets") - 1] + + def _cuda_check_windows(self): + """ Check Windows CUDA Version and path from Environment Variables""" + if not self._cuda_keys: # Cuda environment variable not found return + self.cuda_version = self._cuda_keys[0].lower().replace("cuda_path_v", "").replace("_", ".") + self.cuda_path = os.environ[self._cuda_keys[0][0]] - self.env.cuda_version = cuda_keys[0].lower().replace("cuda_path_v", "").replace("_", ".") - self.env.cuda_path = os.environ[cuda_keys[0]] - self.output.info("CUDA version: " + self.env.cuda_version) - - def cudnn_check(self): - """ Check Linux or Windows cuDNN Version from cudnn.h """ - if self.env.os_version[0] == "Linux": - cudnn_checkfiles = self.cudnn_checkfiles_linux() - elif self.env.os_version[0] == "Windows": - if not self.env.cuda_path and not self.cuda_keys_windows: - self.output.error( - "CUDA not found. See " - "https://github.com/deepfakes/faceswap/blob/master/INSTALL.md#cuda " - "for instructions") - return - if not self.env.cuda_path: - self.env.cuda_path = os.environ[self.cuda_keys_windows[0]] - cudnn_checkfiles = self.cudnn_checkfiles_windows() - - cudnn_checkfile = None - for checkfile in cudnn_checkfiles: - if os.path.isfile(checkfile): - cudnn_checkfile = checkfile - break + def _cudnn_check(self): + """ Check Linux or Windows cuDNN Version from cudnn.h and add to :attr:`cudnn_version`. """ + cudnn_checkfiles = getattr(self, f"_get_checkfiles_{self._os}")() + cudnn_checkfile = next((hdr for hdr in cudnn_checkfiles if os.path.isfile(hdr)), None) if not cudnn_checkfile: - self.output.error("cuDNN not found. See " - "https://github.com/deepfakes/faceswap/blob/master/INSTALL.md#cudnn " - "for instructions") return found = 0 with open(cudnn_checkfile, "r") as ofile: @@ -529,38 +533,47 @@ def cudnn_check(self): found += 1 if found == 3: break - if found != 3: - self.output.error("cuDNN version could not be determined. See " - "https://github.com/deepfakes/faceswap/blob/master/INSTALL.md#cudnn " - "for instructions") + if found != 3: # Full version could not be determined return + self.cudnn_version = ".".join([str(major), str(minor), str(patchlevel)]) - self.env.cudnn_version = "{}.{}".format(major, minor) - self.output.info("cuDNN version: {}.{}".format(self.env.cudnn_version, patchlevel)) + def _get_checkfiles_linux(self): + """ Return the the files to check for cuDNN locations for Linux by querying + the dynamic link loader. - @staticmethod - def cudnn_checkfiles_linux(): - """ Return the check-file locations for Linux """ + Returns + ------- + list + List of header file locations to scan for cuDNN versions + """ chk = os.popen("ldconfig -p | grep -P \"libcudnn.so.\\d+\" | head -n 1").read() chk = chk.strip().replace("libcudnn.so.", "") if not chk: return list() + cudnn_vers = chk[0] - cudnn_path = chk[chk.find("=>") + 3:chk.find("libcudnn") - 1] - cudnn_path = os.path.realpath(cudnn_path) + header_files = [f"cudnn_v{cudnn_vers}.h"] + self._cudnn_header_files + + cudnn_path = os.path.realpath(chk[chk.find("=>") + 3:chk.find("libcudnn") - 1]) cudnn_path = cudnn_path.replace("lib", "include") - cudnn_checkfiles = [os.path.join(cudnn_path, "cudnn_version.h"), - os.path.join(cudnn_path, "cudnn_v{}.h".format(cudnn_vers)), - os.path.join(cudnn_path, "cudnn.h")] + cudnn_checkfiles = [os.path.join(cudnn_path, header) for header in header_files] return cudnn_checkfiles - def cudnn_checkfiles_windows(self): - """ Return the check-file locations for Windows """ + def _get_checkfiles_windows(self): + """ Return the check-file locations for Windows. Just looks inside the include folder of + the discovered :attr:`cuda_path` + + Returns + ------- + list + List of header file locations to scan for cuDNN versions + """ # TODO A more reliable way of getting the windows location - if not self.env.cuda_path: + if not self.cuda_path: return list() - cudnn_checkfile = os.path.join(self.env.cuda_path, "include", "cudnn.h") - return [cudnn_checkfile] + scandir = os.path.join(self.cuda_path, "include") + cudnn_checkfiles = [os.path.join(scandir, header) for header in self._cudnn_header_files] + return cudnn_checkfiles class Install(): @@ -726,12 +739,12 @@ def conda_installer(self, package, channel=None, verbose=False, conda_only=False break if any(char in package for char in (" ", "<", ">", "*", "|")): - package = "\"{}\"".format(package) + package = f"\"{package}\"" condaexe.append(package) if cuda_cudnn is not None: - condaexe.extend(["cudatoolkit={}".format(cuda_cudnn[0]), - "cudnn={}".format(cuda_cudnn[1])]) + condaexe.extend([f"cudatoolkit={cuda_cudnn[0]}", + f"cudnn={cuda_cudnn[1]}"]) self.output.info("Installing {}".format(package.replace("\"", ""))) shell = self.env.os_version[0] == "Windows" try: @@ -742,10 +755,10 @@ def conda_installer(self, package, channel=None, verbose=False, conda_only=False run(condaexe, stdout=devnull, stderr=devnull, check=True, shell=shell) except CalledProcessError: if not conda_only: - self.output.info("{} not available in Conda. Installing with pip".format(package)) + self.output.info(f"{package} not available in Conda. Installing with pip") else: - self.output.warning("Couldn't install {} with Conda. " - "Please install this package manually".format(package)) + self.output.warning(f"Couldn't install {package} with Conda. " + "Please install this package manually") success = False return success @@ -759,14 +772,14 @@ def pip_installer(self, package): # install as user to solve perm restriction if not self.env.is_admin and not self.env.is_virtualenv: pipexe.append("--user") - msg = "Installing {}".format(package) + msg = f"Installing {package}" self.output.info(msg) pipexe.append(package) try: run(pipexe, check=True) except CalledProcessError: - self.output.warning("Couldn't install {} with pip. " - "Please install this package manually".format(package)) + self.output.warning(f"Couldn't install {package} with pip. " + "Please install this package manually") def _tensorflow_dependency_install(self): """ Install the Cuda/cuDNN dependencies from Conda when tensorflow is not available From 15bbc959551134dd10c2bc9a84726af59ac278ac Mon Sep 17 00:00:00 2001 From: amitsh1 Date: Sat, 9 Jan 2021 20:52:55 +0200 Subject: [PATCH 345/981] Update Dockerfile (#1112) --- Dockerfile.gpu | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/Dockerfile.gpu b/Dockerfile.gpu index 07aedd9bf1..f940119f79 100755 --- a/Dockerfile.gpu +++ b/Dockerfile.gpu @@ -1,20 +1,27 @@ -FROM tensorflow/tensorflow:2.2.1-gpu-py3 +FROM nvidia/cuda:10.1-cudnn7-devel-ubuntu16.04 -# To disable tzdata and others from asking for input -ENV DEBIAN_FRONTEND noninteractive - -RUN add-apt-repository -y ppa:jonathonf/ffmpeg-4 \ - && apt-get update -qq -y \ - && apt-get install -y libsm6 libxrender1 libxext-dev python3-tk ffmpeg git \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* +#install python3.8 +RUN apt-get update +RUN apt install software-properties-common -y +RUN add-apt-repository ppa:deadsnakes/ppa -y +RUN apt-get update +RUN apt install python3.8 -y +RUN apt install python3.8-distutils -y +RUN apt install curl -y +RUN curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py +RUN python3.8 get-pip.py +RUN rm get-pip.py +# install requirements +RUN apt-get install ffmpeg git -y COPY _requirements_base.txt /opt/ -RUN pip3 install --upgrade pip -RUN pip3 --no-cache-dir install -r /opt/_requirements_base.txt && rm /opt/_requirements_base.txt -RUN pip3 install jupyter matplotlib -RUN pip3 install jupyter_http_over_ws -RUN jupyter serverextension enable --py jupyter_http_over_ws +COPY requirements_nvidia.txt /opt/ +RUN python3.8 -m pip --no-cache-dir install -r /opt/requirements_nvidia.txt && rm /opt/_requirements_base.txt && rm /opt/requirements_nvidia.txt +RUN python3.8 -m pip install jupyter matplotlib +RUN python3.8 -m pip install jupyter_http_over_ws +RUN jupyter serverextension enable --py jupyter_http_over_ws +RUN alias python=python3.8 +RUN echo "alias python=python3.8" >> /root/.bashrc WORKDIR "/notebooks" -CMD ["jupyter-notebook", "--allow-root" ,"--port=8888" ,"--no-browser" ,"--ip=0.0.0.0"] +CMD ["jupyter-notebook", "--allow-root" ,"--port=8888" ,"--no-browser" ,"--ip=0.0.0.0"] \ No newline at end of file From da5f69319dbfb85d800443d59b47fdda6ac69622 Mon Sep 17 00:00:00 2001 From: Mathias Hedberg Date: Sat, 23 Jan 2021 16:55:06 +0100 Subject: [PATCH 346/981] Add python3.8-tk to Dockerfile.gpu and fixed documentation regarding docker launch (#1118) Co-authored-by: Mathias Hedberg --- Dockerfile.gpu | 3 ++- INSTALL.md | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Dockerfile.gpu b/Dockerfile.gpu index f940119f79..b18c58d447 100755 --- a/Dockerfile.gpu +++ b/Dockerfile.gpu @@ -7,6 +7,7 @@ RUN add-apt-repository ppa:deadsnakes/ppa -y RUN apt-get update RUN apt install python3.8 -y RUN apt install python3.8-distutils -y +RUN apt install python3.8-tk -y RUN apt install curl -y RUN curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py RUN python3.8 get-pip.py @@ -24,4 +25,4 @@ RUN jupyter serverextension enable --py jupyter_http_over_ws RUN alias python=python3.8 RUN echo "alias python=python3.8" >> /root/.bashrc WORKDIR "/notebooks" -CMD ["jupyter-notebook", "--allow-root" ,"--port=8888" ,"--no-browser" ,"--ip=0.0.0.0"] \ No newline at end of file +CMD ["jupyter-notebook", "--allow-root" ,"--port=8888" ,"--no-browser" ,"--ip=0.0.0.0"] diff --git a/INSTALL.md b/INSTALL.md index ef0836e05e..2a94d4f246 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -233,7 +233,9 @@ INFO 1. Install Docker deepfakes-gpu 1. Open a new terminal to interact with the project - docker exec faceswap-gpu python /srv/faceswap.py gui + docker exec -it deepfakes-gpu /bin/bash + # Launch deepfakes gui (Answer 3 for NVIDIA at the prompt) + python3.8 /srv/faceswap.py gui ``` A successful setup log, without docker. From 2208eb308bde85d752c0390969572a17d8ec6e38 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 23 Jan 2021 15:55:52 +0000 Subject: [PATCH 347/981] Bugfix: Edge case extracted face sub-cropping error --- lib/align/aligned_face.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 25fe0ce417..1208f67355 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -448,7 +448,8 @@ def _get_cropped_slices(self): if not self._cache["cropped_slices"][0].get(self._centering): roi = self.get_cropped_roi(self._centering) head_size = self._head_size - slice_in = [slice(max(roi[1], 0), roi[3]), slice(max(roi[0], 0), roi[2])] + slice_in = [slice(max(roi[1], 0), max(roi[3], 0)), + slice(max(roi[0], 0), max(roi[2], 0))] slice_out = [slice(max(roi[1] * -1, 0), self._size - max(0, roi[3] - head_size)), slice(max(roi[0] * -1, 0), self._size - max(0, roi[2] - head_size))] self._cache["cropped_slices"][0][self._centering] = {"in": slice_in, From a62fddf78698269b6b30fbc36b2a4e38208a9176 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 29 Jan 2021 11:47:07 +0000 Subject: [PATCH 348/981] lib.model - Maintenance - Add Depthwise option to Conv Block - Add Swish Activation function - remove res_block_follows and add_instance_norm_args - Add explicit normalization and activation args - Add K.resize_images layer plugins.train.model - Inference creation bugfix --- lib/model/layers.py | 116 +++++++++++++++ lib/model/nn_blocks.py | 232 ++++++++++++++++------------- plugins/train/model/_base.py | 6 +- plugins/train/model/dfaker.py | 20 +-- plugins/train/model/dfl_h128.py | 22 +-- plugins/train/model/dfl_sae.py | 32 ++-- plugins/train/model/dlight.py | 80 +++++----- plugins/train/model/iae.py | 24 +-- plugins/train/model/lightweight.py | 20 +-- plugins/train/model/original.py | 22 +-- plugins/train/model/realface.py | 32 ++-- plugins/train/model/unbalanced.py | 58 ++++---- plugins/train/model/villain.py | 24 +-- 13 files changed, 425 insertions(+), 263 deletions(-) diff --git a/lib/model/layers.py b/lib/model/layers.py index 574414d764..7d9cf41fd4 100644 --- a/lib/model/layers.py +++ b/lib/model/layers.py @@ -191,6 +191,76 @@ class name. These are handled by `Network` (one layer of abstraction above). return dict(list(base_config.items()) + list(config.items())) +class KResizeImages(Layer): + """ A custom upscale function that uses :class:`keras.backend.resize_images` to upsample. + + Parameters + ---------- + size: int, optional + The scale to upsample to. Default: `2` + interpolation: ["nearest", "bilinear"], optional + The interpolation to use. Default: `"nearest"` + kwargs: dict + The standard Keras Layer keyword arguments (if any) + """ + def __init__(self, size=2, interpolation="nearest", **kwargs): + super().__init__(**kwargs) + self.size = size + self.interpolation = interpolation + + def call(self, inputs, **kwargs): # pylint:disable=unused-argument + """ Call the upsample layer + + Parameters + ---------- + inputs: tensor + Input tensor, or list/tuple of input tensors + kwargs: dict + Additional keyword arguments. Unused + + Returns + ------- + tensor + A tensor or list/tuple of tensors + """ + return K.resize_images(inputs, + self.size, + self.size, + "channels_last", + interpolation=self.interpolation) + + def compute_output_shape(self, input_shape): + """Computes the output shape of the layer. + + This is the input shape with size dimensions multiplied by :attr:`size` + + Parameters + ---------- + input_shape: tuple or list of tuples + Shape tuple (tuple of integers) or list of shape tuples (one per output tensor of the + layer). Shape tuples can include None for free dimensions, instead of an integer. + + Returns + ------- + tuple + An input shape tuple + """ + batch, height, width, channels = input_shape + return (batch, height * self.size, width * self.size, channels) + + def get_config(self): + """Returns the config of the layer. + + Returns + -------- + dict + A python dictionary containing the layer configuration + """ + config = dict(size=self.size, interpolation=self.interpolation) + base_config = super().get_config() + return dict(list(base_config.items()) + list(config.items())) + + class SubPixelUpscaling(Layer): """ Sub-pixel convolutional up-scaling layer. @@ -662,6 +732,52 @@ class name. These are handled by `Network` (one layer of abstraction above). return config +class Swish(Layer): + """ Swish Activation Layer implementation for Keras. + + Parameters + ---------- + beta: float, optional + The beta value to apply to the activation function. Default: `1.0` + kwargs: dict + The standard Keras Layer keyword arguments (if any) + + References + ----------- + Swish: a Self-Gated Activation Function: https://arxiv.org/abs/1710.05941v1 + """ + def __init__(self, beta=1.0, **kwargs): + super().__init__(**kwargs) + self.beta = beta + + def call(self, inputs): # pylint:disable=arguments-differ + """ Call the Swish Activation function. + + Parameters + ---------- + inputs: tensor + Input tensor, or list/tuple of input tensors + """ + if get_backend() == "amd": + return inputs * K.sigmoid(inputs * self.beta) + # Native TF Implementation has more memory-efficient gradients + return tf.nn.swish(inputs * self.beta) + + def get_config(self): + """Returns the config of the layer. + + Adds the :attr:`beta` to config. + + Returns + -------- + dict + A python dictionary containing the layer configuration + """ + config = super().get_config() + config["beta"] = self.beta + return config + + # Update layers into Keras custom objects for name, obj in inspect.getmembers(sys.modules[__name__]): if inspect.isclass(obj) and obj.__module__ == __name__: diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index ed964ff96f..b73e7c9292 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -3,12 +3,13 @@ import logging -from keras.layers import (Activation, Add, Concatenate, Conv2D as KConv2D, LeakyReLU, - SeparableConv2D, UpSampling2D) +from keras.layers import (Activation, Add, BatchNormalization, Concatenate, Conv2D as KConv2D, + DepthwiseConv2D as KDepthwiseConv2d, LeakyReLU, SeparableConv2D, + UpSampling2D) from keras.initializers import he_uniform, VarianceScaling from .initializers import ICNR, ConvolutionAware -from .layers import PixelShuffler, ReflectionPadding2D +from .layers import PixelShuffler, ReflectionPadding2D, Swish from .normalization import InstanceNormalization logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -59,6 +60,32 @@ def _get_name(name): # << CONVOLUTIONS >> +def _get_default_initializer(initializer): + """ Returns a default initializer of Convolutional Aware or he_uniform for convolutional + layers. + + Parameters + ---------- + initializer: :class:`keras.initializers.Initializer` or None + The initializer that has been passed into the model. If this value is ``None`` then a + default initializer will be returned based on the configuration choices, otherwise + the given initializer will be returned. + + Returns + ------- + :class:`keras.initializers.Initializer` + The kernel initializer to use for this convolutional layer. Either the original given + initializer, he_uniform or convolutional aware (if selected in config options) + """ + if initializer is None: + retval = ConvolutionAware() if _CONFIG["conv_aware_init"] else he_uniform() + logger.debug("Set default kernel_initializer: %s", retval) + else: + retval = initializer + logger.debug("Using model supplied initializer: %s", retval) + return retval + + class Conv2D(KConv2D): # pylint:disable=too-few-public-methods """ A standard Keras Convolution 2D layer with parameters updated to be more appropriate for Faceswap architecture. @@ -82,37 +109,40 @@ def __init__(self, *args, padding="same", check_icnr_init=False, **kwargs): if kwargs.get("name", None) is None: filters = kwargs["filters"] if "filters" in kwargs else args[0] kwargs["name"] = _get_name("conv2d_{}".format(filters)) - initializer = self._get_default_initializer(kwargs.pop("kernel_initializer", None)) + initializer = _get_default_initializer(kwargs.pop("kernel_initializer", None)) if check_icnr_init and _CONFIG["icnr_init"]: initializer = ICNR(initializer=initializer) logger.debug("Using ICNR Initializer: %s", initializer) super().__init__(*args, padding=padding, kernel_initializer=initializer, **kwargs) - @classmethod - def _get_default_initializer(cls, initializer): - """ Returns a default initializer of Convolutional Aware or he_uniform for convolutional - layers. - Parameters - ---------- - initializer: :class:`keras.initializers.Initializer` or None - The initializer that has been passed into the model. If this value is ``None`` then a - default initializer will be returned based on the configuration choices, otherwise - the given initializer will be returned. +class DepthwiseConv2D(KDepthwiseConv2d): # pylint:disable=too-few-public-methods + """ A standard Keras Depthwise Convolution 2D layer with parameters updated to be more + appropriate for Faceswap architecture. - Returns - ------- - :class:`keras.initializers.Initializer` - The kernel initializer to use for this convolutional layer. Either the original given - initializer, he_uniform or convolutional aware (if selected in config options) - """ - if initializer is None: - retval = ConvolutionAware() if _CONFIG["conv_aware_init"] else he_uniform() - logger.debug("Set default kernel_initializer: %s", retval) - else: - retval = initializer - logger.debug("Using model supplied initializer: %s", retval) - return retval + Parameters are the same, with the same defaults, as a standard + :class:`keras.layers.DepthwiseConv2D` except where listed below. The default initializer is + updated to `he_uniform` or `convolutional aware` based on user configuration settings. + + Parameters + ---------- + padding: str, optional + One of `"valid"` or `"same"` (case-insensitive). Default: `"same"`. Note that `"same"` is + slightly inconsistent across backends with `strides` != 1, as described + `here `_. + check_icnr_init: `bool`, optional + ``True`` if the user configuration options should be checked to apply ICNR initialization + to the layer. This should only be passed in from :class:`UpscaleBlock` layers. + Default: ``False`` + """ + def __init__(self, *args, padding="same", check_icnr_init=False, **kwargs): + if kwargs.get("name", None) is None: + kwargs["name"] = _get_name("dwconv2d") + initializer = _get_default_initializer(kwargs.pop("depthwise_initializer", None)) + if check_icnr_init and _CONFIG["icnr_init"]: + initializer = ICNR(initializer=initializer) + logger.debug("Using ICNR Initializer: %s", initializer) + super().__init__(*args, padding=padding, depthwise_initializer=initializer, **kwargs) class Conv2DOutput(): # pylint:disable=too-few-public-methods @@ -191,7 +221,8 @@ class Conv2DBlock(): # pylint:disable=too-few-public-methods kernel_size: int, optional An integer or tuple/list of 2 integers, specifying the height and width of the 2D convolution window. Can be a single integer to specify the same value for all spatial - dimensions. Default: 5 + dimensions. NB: If `use_depthwise` is ``True`` then a value must still be provided here, + but it will be ignored. Default: 5 strides: tuple or int, optional An integer or tuple/list of 2 integers, specifying the strides of the convolution along the height and width. Can be a single integer to specify the same value for all spatial @@ -199,12 +230,16 @@ class Conv2DBlock(): # pylint:disable=too-few-public-methods padding: ["valid", "same"], optional The padding to use. NB: If reflect padding has been selected in the user configuration options, then this argument will be ignored in favor of reflect padding. Default: `"same"` - use_instance_norm: bool, optional - ``True`` if instance normalization should be applied after the convolutional layer. - Default: ``False`` - res_block_follows: bool, optional - If a residual block will follow this layer, then this should be set to ``True`` to add a - leaky ReLu after the convolutional layer. Default: ``False`` + normalization: str or ``None``, optional + Normalization to apply after the Convolution Layer. Select one of "batch" or "instance". + Set to ``None`` to not apply normalization. Default: ``None`` + activation: str or ``None``, optional + The activation function to use. This is applied at the end of the convolution block. Select + one of `"leakyrelu"` or `"swish"`. Set to ``None`` to not apply an activation function. + Default: `"leakyrelu"` + use_depthwise: bool, optional + Set to ``True`` to use a Depthwise Convolution 2D layer rather than a standard Convolution + 2D layer. Default: ``False`` kwargs: dict Any additional Keras standard layer keyword arguments to pass to the Convolutional 2D layer """ @@ -213,24 +248,36 @@ def __init__(self, kernel_size=5, strides=2, padding="same", - use_instance_norm=False, - res_block_follows=False, + normalization=None, + activation="leakyrelu", + use_depthwise=False, **kwargs): self._name = kwargs.pop("name") if "name" in kwargs else _get_name( "conv_{}".format(filters)) + logger.debug("name: %s, filters: %s, kernel_size: %s, strides: %s, padding: %s, " - "use_instance_norm: %s, res_block_follows: %s, kwargs: %s)", - self._name, filters, kernel_size, strides, padding, use_instance_norm, - res_block_follows, kwargs) + "normalization: %s, activation: %s, use_depthwise: %s, kwargs: %s)", + self._name, filters, kernel_size, strides, padding, normalization, + activation, use_depthwise, kwargs) + self._use_reflect_padding = _CONFIG["reflect_padding"] - self._filters = filters - self._kernel_size = kernel_size + self._args = (kernel_size, ) if use_depthwise else (filters, kernel_size) self._strides = strides self._padding = "valid" if self._use_reflect_padding else padding self._kwargs = kwargs - self._use_instance_norm = use_instance_norm - self._res_block_follows = res_block_follows + self._normalization = None if not normalization else normalization.lower() + self._activation = None if not activation else activation.lower() + self._use_depthwise = use_depthwise + + self._assert_arguments() + + def _assert_arguments(self): + """ Validate the given arguments. """ + assert self._normalization in ("batch", "instance", None), ( + "normalization should be 'batch', 'instance' or None") + assert self._activation in ("leakyrelu", "swish", None), ( + "activation should be 'leakyrelu', 'swish' or None") def __call__(self, inputs): """ Call the Faceswap Convolutional Layer. @@ -247,18 +294,25 @@ def __call__(self, inputs): """ if self._use_reflect_padding: inputs = ReflectionPadding2D(stride=self._strides, - kernel_size=self._kernel_size, + kernel_size=self._args[-1], name="{}_reflectionpadding2d".format(self._name))(inputs) - var_x = Conv2D(self._filters, - self._kernel_size, - strides=self._strides, - padding=self._padding, - name="{}_conv2d".format(self._name), - **self._kwargs)(inputs) - if self._use_instance_norm: + conv = DepthwiseConv2D if self._use_depthwise else Conv2D + var_x = conv(*self._args, + strides=self._strides, + padding=self._padding, + name="{}_{}conv2d".format(self._name, "dw" if self._use_depthwise else ""), + **self._kwargs)(inputs) + # normalization + if self._normalization == "instance": var_x = InstanceNormalization(name="{}_instancenorm".format(self._name))(var_x) - if not self._res_block_follows: + if self._normalization == "batch": + var_x = BatchNormalization(axis=3, name="{}_batchnorm".format(self._name))(var_x) + + # activation + if self._activation == "leakyrelu": var_x = LeakyReLU(0.1, name="{}_leakyrelu".format(self._name))(var_x) + if self._activation == "swish": + var_x = Swish(name="{}_swish".format(self._name))(var_x) return var_x @@ -291,36 +345,10 @@ def __init__(self, filters, kernel_size=5, strides=2, **kwargs): self._kernel_size = kernel_size self._strides = strides - initializer = self._get_default_initializer(kwargs.pop("kernel_initializer", None)) + initializer = _get_default_initializer(kwargs.pop("kernel_initializer", None)) kwargs["kernel_initializer"] = initializer self._kwargs = kwargs - @classmethod - def _get_default_initializer(cls, initializer): - """ Returns a default initializer of Convolutional Aware or he_uniform for convolutional - layers. - - Parameters - ---------- - initializer: :class:`keras.initializers.Initializer` or None - The initializer that has been passed into the model. If this value is ``None`` then a - default initializer will be returned based on the configuration choices, otherwise - the given initializer will be returned. - - Returns - ------- - :class:`keras.initializers.Initializer` - The kernel initializer to use for this convolutional layer. Either the original given - initializer, he_uniform or convolutional aware (if selected in config options) - """ - if initializer is None: - retval = ConvolutionAware() if _CONFIG["conv_aware_init"] else he_uniform() - logger.debug("Set default kernel_initializer: %s", retval) - else: - retval = initializer - logger.debug("Using model supplied initializer: %s", retval) - return retval - def __call__(self, inputs): """ Call the Faceswap Separable Convolutional 2D Block. @@ -366,12 +394,13 @@ class UpscaleBlock(): # pylint:disable=too-few-public-methods options, then this argument will be ignored in favor of reflect padding. Default: `"same"` scale_factor: int, optional The amount to upscale the image. Default: `2` - use_instance_norm: bool, optional - ``True`` if instance normalization should be applied after the convolutional layer. - Default: ``False`` - res_block_follows: bool, optional - If a residual block will follow this layer, then this should be set to ``True`` to add - a leaky ReLu after the convolutional layer. Default: ``False`` + normalization: str or ``None``, optional + Normalization to apply after the Convolution Layer. Select one of "batch" or "instance". + Set to ``None`` to not apply normalization. Default: ``None`` + activation: str or ``None``, optional + The activation function to use. This is applied at the end of the convolution block. Select + one of `"leakyrelu"` or `"swish"`. Set to ``None`` to not apply an activation function. + Default: `"leakyrelu"` kwargs: dict Any additional Keras standard layer keyword arguments to pass to the Convolutional 2D layer """ @@ -381,21 +410,21 @@ def __init__(self, kernel_size=3, padding="same", scale_factor=2, - use_instance_norm=False, - res_block_follows=False, + normalization=None, + activation="leakyrelu", **kwargs): self._name = _get_name("upscale_{}".format(filters)) logger.debug("name: %s. filters: %s, kernel_size: %s, padding: %s, scale_factor: %s, " - "use_instance_norm: %s, res_block_follows: %s, kwargs: %s)", - self._name, filters, kernel_size, padding, scale_factor, use_instance_norm, - res_block_follows, kwargs) + "normalization: %s, activation: %s, kwargs: %s)", + self._name, filters, kernel_size, padding, scale_factor, normalization, + activation, kwargs) self._filters = filters self._kernel_size = kernel_size self._padding = padding self._scale_factor = scale_factor - self._use_instance_norm = use_instance_norm - self._res_block_follows = res_block_follows + self._normalization = normalization + self._activation = activation self._kwargs = kwargs def __call__(self, inputs): @@ -415,8 +444,8 @@ def __call__(self, inputs): self._kernel_size, strides=(1, 1), padding=self._padding, - use_instance_norm=self._use_instance_norm, - res_block_follows=self._res_block_follows, + normalization=self._normalization, + activation=self._activation, name="{}_conv2d".format(self._name), check_icnr_init=_CONFIG["icnr_init"], **self._kwargs)(inputs) @@ -449,9 +478,10 @@ class Upscale2xBlock(): # pylint:disable=too-few-public-methods The padding to use. Default: `"same"` interpolation: ["nearest", "bilinear"], optional Interpolation to use for up-sampling. Default: `"bilinear"` - res_block_follows: bool, optional - If a residual block will follow this layer, then this should be set to ``True`` to add - a leaky ReLu after the convolutional layer. Default: ``False`` + activation: str or ``None``, optional + The activation function to use. This is applied at the end of the convolution block. Select + one of `"leakyrelu"` or `"swish"`. Set to ``None`` to not apply an activation function. + Default: `"leakyrelu"` scale_factor: int, optional The amount to upscale the image. Default: `2` sr_ratio: float, optional @@ -463,7 +493,7 @@ class Upscale2xBlock(): # pylint:disable=too-few-public-methods Any additional Keras standard layer keyword arguments to pass to the Convolutional 2D layer """ def __init__(self, filters, kernel_size=3, padding="same", interpolation="bilinear", - res_block_follows=False, sr_ratio=0.5, scale_factor=2, fast=False, **kwargs): + activation="leakyrelu", sr_ratio=0.5, scale_factor=2, fast=False, **kwargs): self._name = _get_name("upscale2x_{}_{}".format(filters, "fast" if fast else "hyb")) self._fast = fast @@ -471,7 +501,7 @@ def __init__(self, filters, kernel_size=3, padding="same", interpolation="biline self._kernel_size = kernel_size self._padding = padding self._interpolation = interpolation - self._res_block_follows = res_block_follows + self._activation = activation self._scale_factor = scale_factor self._kwargs = kwargs @@ -494,7 +524,7 @@ def __call__(self, inputs): kernel_size=self._kernel_size, padding=self._padding, scale_factor=self._scale_factor, - res_block_follows=self._res_block_follows, + activation=self._activation, **self._kwargs)(var_x) if self._fast or (not self._fast and self._filters > 0): var_x2 = Conv2D(self._filters, 3, @@ -509,7 +539,7 @@ def __call__(self, inputs): kernel_size=self._kernel_size, padding=self._padding, scale_factor=self._scale_factor, - res_block_follows=self._res_block_follows, + activation=self._activation, **self._kwargs)(var_x) var_x = Add()([var_x2, var_x1]) else: diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index b94524fc66..d8784eea39 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -1508,7 +1508,11 @@ def _get_filtered_structure(self): continue inbound = self._filter_node(layer["inbound_nodes"]) - if self._input_names.intersection(inbound): + # TODO Currently any models which have a list input will not contain the main model + # input. This may not be true in future (if the main input is injected into a layer + # further down the model chain) so this should be made more robust + if (not any(isinstance(inb, list) for inb in inbound) + and self._input_names.intersection(inbound)): # Strip the input inbound nodes for applying the correct input layer at compile # time logger.debug("Stripping inbound nodes for input '%s': %s", name, inbound) diff --git a/plugins/train/model/dfaker.py b/plugins/train/model/dfaker.py index 6ba249245a..622ff28859 100644 --- a/plugins/train/model/dfaker.py +++ b/plugins/train/model/dfaker.py @@ -31,26 +31,26 @@ def decoder(self, side): var_x = input_ if self._output_size == 256: - var_x = UpscaleBlock(1024, res_block_follows=True)(var_x) + var_x = UpscaleBlock(1024, activation=None)(var_x) var_x = ResidualBlock(1024, kernel_initializer=self.kernel_initializer)(var_x) - var_x = UpscaleBlock(512, res_block_follows=True)(var_x) + var_x = UpscaleBlock(512, activation=None)(var_x) var_x = ResidualBlock(512, kernel_initializer=self.kernel_initializer)(var_x) - var_x = UpscaleBlock(256, res_block_follows=True)(var_x) + var_x = UpscaleBlock(256, activation=None)(var_x) var_x = ResidualBlock(256, kernel_initializer=self.kernel_initializer)(var_x) - var_x = UpscaleBlock(128, res_block_follows=True)(var_x) + var_x = UpscaleBlock(128, activation=None)(var_x) var_x = ResidualBlock(128, kernel_initializer=self.kernel_initializer)(var_x) - var_x = UpscaleBlock(64)(var_x) + var_x = UpscaleBlock(64, activation="leakyrelu")(var_x) var_x = Conv2DOutput(3, 5, name="face_out_{}".format(side))(var_x) outputs = [var_x] if self.config.get("learn_mask", False): var_y = input_ if self._output_size == 256: - var_y = UpscaleBlock(1024)(var_y) - var_y = UpscaleBlock(512)(var_y) - var_y = UpscaleBlock(256)(var_y) - var_y = UpscaleBlock(128)(var_y) - var_y = UpscaleBlock(64)(var_y) + var_y = UpscaleBlock(1024, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(512, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(256, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(128, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(64, activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, name="mask_out_{}".format(side))(var_y) outputs.append(var_y) return KerasModel([input_], outputs=outputs, name="decoder_{}".format(side)) diff --git a/plugins/train/model/dfl_h128.py b/plugins/train/model/dfl_h128.py index b2abb62d44..0677b0111e 100644 --- a/plugins/train/model/dfl_h128.py +++ b/plugins/train/model/dfl_h128.py @@ -19,31 +19,31 @@ def __init__(self, *args, **kwargs): def encoder(self): """ DFL H128 Encoder """ input_ = Input(shape=self.input_shape) - var_x = Conv2DBlock(128)(input_) - var_x = Conv2DBlock(256)(var_x) - var_x = Conv2DBlock(512)(var_x) - var_x = Conv2DBlock(1024)(var_x) + var_x = Conv2DBlock(128, activation="leakyrelu")(input_) + var_x = Conv2DBlock(256, activation="leakyrelu")(var_x) + var_x = Conv2DBlock(512, activation="leakyrelu")(var_x) + var_x = Conv2DBlock(1024, activation="leakyrelu")(var_x) var_x = Dense(self.encoder_dim)(Flatten()(var_x)) var_x = Dense(8 * 8 * self.encoder_dim)(var_x) var_x = Reshape((8, 8, self.encoder_dim))(var_x) - var_x = UpscaleBlock(self.encoder_dim)(var_x) + var_x = UpscaleBlock(self.encoder_dim, activation="leakyrelu")(var_x) return KerasModel(input_, var_x, name="encoder") def decoder(self, side): """ DFL H128 Decoder """ input_ = Input(shape=(16, 16, self.encoder_dim)) var_x = input_ - var_x = UpscaleBlock(self.encoder_dim)(var_x) - var_x = UpscaleBlock(self.encoder_dim // 2)(var_x) - var_x = UpscaleBlock(self.encoder_dim // 4)(var_x) + var_x = UpscaleBlock(self.encoder_dim, activation="leakyrelu")(var_x) + var_x = UpscaleBlock(self.encoder_dim // 2, activation="leakyrelu")(var_x) + var_x = UpscaleBlock(self.encoder_dim // 4, activation="leakyrelu")(var_x) var_x = Conv2DOutput(3, 5, name="face_out_{}".format(side))(var_x) outputs = [var_x] if self.config.get("learn_mask", False): var_y = input_ - var_y = UpscaleBlock(self.encoder_dim)(var_y) - var_y = UpscaleBlock(self.encoder_dim // 2)(var_y) - var_y = UpscaleBlock(self.encoder_dim // 4)(var_y) + var_y = UpscaleBlock(self.encoder_dim, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(self.encoder_dim // 2, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(self.encoder_dim // 4, activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, name="mask_out_{}".format(side))(var_y) outputs.append(var_y) return KerasModel(input_, outputs=outputs, name="decoder_{}".format(side)) diff --git a/plugins/train/model/dfl_sae.py b/plugins/train/model/dfl_sae.py index ba8a5f761a..c1896f3073 100644 --- a/plugins/train/model/dfl_sae.py +++ b/plugins/train/model/dfl_sae.py @@ -61,24 +61,24 @@ def encoder_df(self): input_ = Input(shape=self.input_shape) dims = self.input_shape[-1] * self.encoder_dim lowest_dense_res = self.input_shape[0] // 16 - var_x = Conv2DBlock(dims)(input_) - var_x = Conv2DBlock(dims * 2)(var_x) - var_x = Conv2DBlock(dims * 4)(var_x) - var_x = Conv2DBlock(dims * 8)(var_x) + var_x = Conv2DBlock(dims, activation="leakyrelu")(input_) + var_x = Conv2DBlock(dims * 2, activation="leakyrelu")(var_x) + var_x = Conv2DBlock(dims * 4, activation="leakyrelu")(var_x) + var_x = Conv2DBlock(dims * 8, activation="leakyrelu")(var_x) var_x = Dense(self.ae_dims)(Flatten()(var_x)) var_x = Dense(lowest_dense_res * lowest_dense_res * self.ae_dims)(var_x) var_x = Reshape((lowest_dense_res, lowest_dense_res, self.ae_dims))(var_x) - var_x = UpscaleBlock(self.ae_dims)(var_x) + var_x = UpscaleBlock(self.ae_dims, activation="leakyrelu")(var_x) return KerasModel(input_, var_x, name="encoder_df") def encoder_liae(self): """ DFL SAE LIAE Encoder Network """ input_ = Input(shape=self.input_shape) dims = self.input_shape[-1] * self.encoder_dim - var_x = Conv2DBlock(dims)(input_) - var_x = Conv2DBlock(dims * 2)(var_x) - var_x = Conv2DBlock(dims * 4)(var_x) - var_x = Conv2DBlock(dims * 8)(var_x) + var_x = Conv2DBlock(dims, activation="leakyrelu")(input_) + var_x = Conv2DBlock(dims * 2, activation="leakyrelu")(var_x) + var_x = Conv2DBlock(dims * 4, activation="leakyrelu")(var_x) + var_x = Conv2DBlock(dims * 8, activation="leakyrelu")(var_x) var_x = Flatten()(var_x) return KerasModel(input_, var_x, name="encoder_liae") @@ -90,7 +90,7 @@ def inter_liae(self, side, input_shape): var_x = Dense(self.ae_dims)(var_x) var_x = Dense(lowest_dense_res * lowest_dense_res * self.ae_dims * 2)(var_x) var_x = Reshape((lowest_dense_res, lowest_dense_res, self.ae_dims * 2))(var_x) - var_x = UpscaleBlock(self.ae_dims * 2)(var_x) + var_x = UpscaleBlock(self.ae_dims * 2, activation="leakyrelu")(var_x) return KerasModel(input_, var_x, name="intermediate_{}".format(side)) def decoder(self, side, input_shape): @@ -101,19 +101,19 @@ def decoder(self, side, input_shape): dims = self.input_shape[-1] * self.decoder_dim var_x = input_ - var_x1 = UpscaleBlock(dims * 8, res_block_follows=True)(var_x) + var_x1 = UpscaleBlock(dims * 8, activation=None)(var_x) var_x1 = ResidualBlock(dims * 8)(var_x1) var_x1 = ResidualBlock(dims * 8)(var_x1) if self.multiscale_count >= 3: outputs.append(Conv2DOutput(3, 5, name="face_out_32_{}".format(side))(var_x1)) - var_x2 = UpscaleBlock(dims * 4, res_block_follows=True)(var_x1) + var_x2 = UpscaleBlock(dims * 4, activation=None)(var_x1) var_x2 = ResidualBlock(dims * 4)(var_x2) var_x2 = ResidualBlock(dims * 4)(var_x2) if self.multiscale_count >= 2: outputs.append(Conv2DOutput(3, 5, name="face_out_64_{}".format(side))(var_x2)) - var_x3 = UpscaleBlock(dims * 2, res_block_follows=True)(var_x2) + var_x3 = UpscaleBlock(dims * 2, activation=None)(var_x2) var_x3 = ResidualBlock(dims * 2)(var_x3) var_x3 = ResidualBlock(dims * 2)(var_x3) @@ -121,9 +121,9 @@ def decoder(self, side, input_shape): if self.use_mask: var_y = input_ - var_y = UpscaleBlock(self.decoder_dim * 8)(var_y) - var_y = UpscaleBlock(self.decoder_dim * 4)(var_y) - var_y = UpscaleBlock(self.decoder_dim * 2)(var_y) + var_y = UpscaleBlock(self.decoder_dim * 8, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(self.decoder_dim * 4, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(self.decoder_dim * 2, activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, name="mask_out_{}".format(side))(var_y) outputs.append(var_y) return KerasModel(input_, outputs=outputs, name="decoder_{}".format(side)) diff --git a/plugins/train/model/dlight.py b/plugins/train/model/dlight.py index b68595c8f2..fb7e08cbb4 100644 --- a/plugins/train/model/dlight.py +++ b/plugins/train/model/dlight.py @@ -63,27 +63,27 @@ def encoder(self): input_ = Input(shape=self.input_shape) var_x = input_ - var_x1 = Conv2DBlock(self.encoder_filters // 2)(var_x) + var_x1 = Conv2DBlock(self.encoder_filters // 2, activation="leakyrelu")(var_x) var_x2 = AveragePooling2D()(var_x) var_x2 = LeakyReLU(0.1)(var_x2) var_x = Concatenate()([var_x1, var_x2]) - var_x1 = Conv2DBlock(self.encoder_filters)(var_x) + var_x1 = Conv2DBlock(self.encoder_filters, activation="leakyrelu")(var_x) var_x2 = AveragePooling2D()(var_x) var_x2 = LeakyReLU(0.1)(var_x2) var_x = Concatenate()([var_x1, var_x2]) - var_x1 = Conv2DBlock(self.encoder_filters * 2)(var_x) + var_x1 = Conv2DBlock(self.encoder_filters * 2, activation="leakyrelu")(var_x) var_x2 = AveragePooling2D()(var_x) var_x2 = LeakyReLU(0.1)(var_x2) var_x = Concatenate()([var_x1, var_x2]) - var_x1 = Conv2DBlock(self.encoder_filters * 4)(var_x) + var_x1 = Conv2DBlock(self.encoder_filters * 4, activation="leakyrelu")(var_x) var_x2 = AveragePooling2D()(var_x) var_x2 = LeakyReLU(0.1)(var_x2) var_x = Concatenate()([var_x1, var_x2]) - var_x1 = Conv2DBlock(self.encoder_filters * 8)(var_x) + var_x1 = Conv2DBlock(self.encoder_filters * 8, activation="leakyrelu")(var_x) var_x2 = AveragePooling2D()(var_x) var_x2 = LeakyReLU(0.1)(var_x2) var_x = Concatenate()([var_x1, var_x2]) @@ -99,17 +99,17 @@ def encoder(self): def decoder_a(self): """ DeLight Decoder A(old face) Network """ input_ = Input(shape=(4, 4, 1024)) - decoder_a_complexity = 256 + dec_a_complexity = 256 mask_complexity = 128 var_xy = input_ var_xy = UpSampling2D(self.upscale_ratio, interpolation='bilinear')(var_xy) var_x = var_xy - var_x = Upscale2xBlock(decoder_a_complexity, fast=False)(var_x) - var_x = Upscale2xBlock(decoder_a_complexity // 2, fast=False)(var_x) - var_x = Upscale2xBlock(decoder_a_complexity // 4, fast=False)(var_x) - var_x = Upscale2xBlock(decoder_a_complexity // 8, fast=False)(var_x) + var_x = Upscale2xBlock(dec_a_complexity, activation="leakyrelu", fast=False)(var_x) + var_x = Upscale2xBlock(dec_a_complexity // 2, activation="leakyrelu", fast=False)(var_x) + var_x = Upscale2xBlock(dec_a_complexity // 4, activation="leakyrelu", fast=False)(var_x) + var_x = Upscale2xBlock(dec_a_complexity // 8, activation="leakyrelu", fast=False)(var_x) var_x = Conv2DOutput(3, 5, name="face_out")(var_x) @@ -117,10 +117,10 @@ def decoder_a(self): if self.config.get("learn_mask", False): var_y = var_xy # mask decoder - var_y = Upscale2xBlock(mask_complexity, fast=False)(var_y) - var_y = Upscale2xBlock(mask_complexity // 2, fast=False)(var_y) - var_y = Upscale2xBlock(mask_complexity // 4, fast=False)(var_y) - var_y = Upscale2xBlock(mask_complexity // 8, fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity, activation="leakyrelu", fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity // 2, activation="leakyrelu", fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity // 4, activation="leakyrelu", fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity // 8, activation="leakyrelu", fast=False)(var_y) var_y = Conv2DOutput(1, 5, name="mask_out")(var_y) @@ -132,18 +132,18 @@ def decoder_b_fast(self): """ DeLight Fast Decoder B(new face) Network """ input_ = Input(shape=(4, 4, 1024)) - decoder_b_complexity = 512 + dec_b_complexity = 512 mask_complexity = 128 var_xy = input_ - var_xy = UpscaleBlock(512, scale_factor=self.upscale_ratio)(var_xy) + var_xy = UpscaleBlock(512, scale_factor=self.upscale_ratio, activation="leakyrelu")(var_xy) var_x = var_xy - var_x = Upscale2xBlock(decoder_b_complexity, fast=True)(var_x) - var_x = Upscale2xBlock(decoder_b_complexity // 2, fast=True)(var_x) - var_x = Upscale2xBlock(decoder_b_complexity // 4, fast=True)(var_x) - var_x = Upscale2xBlock(decoder_b_complexity // 8, fast=True)(var_x) + var_x = Upscale2xBlock(dec_b_complexity, activation="leakyrelu", fast=True)(var_x) + var_x = Upscale2xBlock(dec_b_complexity // 2, activation="leakyrelu", fast=True)(var_x) + var_x = Upscale2xBlock(dec_b_complexity // 4, activation="leakyrelu", fast=True)(var_x) + var_x = Upscale2xBlock(dec_b_complexity // 8, activation="leakyrelu", fast=True)(var_x) var_x = Conv2DOutput(3, 5, name="face_out")(var_x) @@ -152,10 +152,10 @@ def decoder_b_fast(self): if self.config.get("learn_mask", False): var_y = var_xy # mask decoder - var_y = Upscale2xBlock(mask_complexity, fast=False)(var_y) - var_y = Upscale2xBlock(mask_complexity // 2, fast=False)(var_y) - var_y = Upscale2xBlock(mask_complexity // 4, fast=False)(var_y) - var_y = Upscale2xBlock(mask_complexity // 8, fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity, activation="leakyrelu", fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity // 2, activation="leakyrelu", fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity // 4, activation="leakyrelu", fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity // 8, activation="leakyrelu", fast=False)(var_y) var_y = Conv2DOutput(1, 5, name="mask_out")(var_y) @@ -167,28 +167,30 @@ def decoder_b(self): """ DeLight Decoder B(new face) Network """ input_ = Input(shape=(4, 4, 1024)) - decoder_b_complexity = 512 + dec_b_complexity = 512 mask_complexity = 128 var_xy = input_ - var_xy = Upscale2xBlock(512, scale_factor=self.upscale_ratio, fast=False)(var_xy) - + var_xy = Upscale2xBlock(512, + scale_factor=self.upscale_ratio, + activation="leakyrelu", + fast=False)(var_xy) var_x = var_xy var_x = ResidualBlock(512, use_bias=True)(var_x) var_x = ResidualBlock(512, use_bias=False)(var_x) var_x = ResidualBlock(512, use_bias=False)(var_x) - var_x = Upscale2xBlock(decoder_b_complexity, fast=False)(var_x) - var_x = ResidualBlock(decoder_b_complexity, use_bias=True)(var_x) - var_x = ResidualBlock(decoder_b_complexity, use_bias=False)(var_x) + var_x = Upscale2xBlock(dec_b_complexity, activation=None, fast=False)(var_x) + var_x = ResidualBlock(dec_b_complexity, use_bias=True)(var_x) + var_x = ResidualBlock(dec_b_complexity, use_bias=False)(var_x) var_x = BatchNormalization()(var_x) - var_x = Upscale2xBlock(decoder_b_complexity // 2, fast=False)(var_x) - var_x = ResidualBlock(decoder_b_complexity // 2, use_bias=True)(var_x) - var_x = Upscale2xBlock(decoder_b_complexity // 4, fast=False)(var_x) - var_x = ResidualBlock(decoder_b_complexity // 4, use_bias=False)(var_x) + var_x = Upscale2xBlock(dec_b_complexity // 2, activation=None, fast=False)(var_x) + var_x = ResidualBlock(dec_b_complexity // 2, use_bias=True)(var_x) + var_x = Upscale2xBlock(dec_b_complexity // 4, activation=None, fast=False)(var_x) + var_x = ResidualBlock(dec_b_complexity // 4, use_bias=False)(var_x) var_x = BatchNormalization()(var_x) - var_x = Upscale2xBlock(decoder_b_complexity // 8, fast=False)(var_x) + var_x = Upscale2xBlock(dec_b_complexity // 8, activation="leakyrelu", fast=False)(var_x) var_x = Conv2DOutput(3, 5, name="face_out")(var_x) @@ -197,10 +199,10 @@ def decoder_b(self): if self.config.get("learn_mask", False): var_y = var_xy # mask decoder - var_y = Upscale2xBlock(mask_complexity, fast=False)(var_y) - var_y = Upscale2xBlock(mask_complexity // 2, fast=False)(var_y) - var_y = Upscale2xBlock(mask_complexity // 4, fast=False)(var_y) - var_y = Upscale2xBlock(mask_complexity // 8, fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity, activation="leakyrelu", fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity // 2, activation="leakyrelu", fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity // 4, activation="leakyrelu", fast=False)(var_y) + var_y = Upscale2xBlock(mask_complexity // 8, activation="leakyrelu", fast=False)(var_y) var_y = Conv2DOutput(1, 5, name="mask_out")(var_y) diff --git a/plugins/train/model/iae.py b/plugins/train/model/iae.py index df1b545bbb..9e7d956ff8 100644 --- a/plugins/train/model/iae.py +++ b/plugins/train/model/iae.py @@ -35,10 +35,10 @@ def encoder(self): """ Encoder Network """ input_ = Input(shape=self.input_shape) var_x = input_ - var_x = Conv2DBlock(128)(var_x) - var_x = Conv2DBlock(256)(var_x) - var_x = Conv2DBlock(512)(var_x) - var_x = Conv2DBlock(1024)(var_x) + var_x = Conv2DBlock(128, activation="leakyrelu")(var_x) + var_x = Conv2DBlock(256, activation="leakyrelu")(var_x) + var_x = Conv2DBlock(512, activation="leakyrelu")(var_x) + var_x = Conv2DBlock(1024, activation="leakyrelu")(var_x) var_x = Flatten()(var_x) return KerasModel(input_, var_x, name="encoder") @@ -54,19 +54,19 @@ def decoder(self): """ Decoder Network """ input_ = Input(shape=(4, 4, self.encoder_dim)) var_x = input_ - var_x = UpscaleBlock(512)(var_x) - var_x = UpscaleBlock(256)(var_x) - var_x = UpscaleBlock(128)(var_x) - var_x = UpscaleBlock(64)(var_x) + var_x = UpscaleBlock(512, activation="leakyrelu")(var_x) + var_x = UpscaleBlock(256, activation="leakyrelu")(var_x) + var_x = UpscaleBlock(128, activation="leakyrelu")(var_x) + var_x = UpscaleBlock(64, activation="leakyrelu")(var_x) var_x = Conv2DOutput(3, 5, name="face_out")(var_x) outputs = [var_x] if self.config.get("learn_mask", False): var_y = input_ - var_y = UpscaleBlock(512)(var_y) - var_y = UpscaleBlock(256)(var_y) - var_y = UpscaleBlock(128)(var_y) - var_y = UpscaleBlock(64)(var_y) + var_y = UpscaleBlock(512, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(256, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(128, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(64, activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, name="mask_out")(var_y) outputs.append(var_y) return KerasModel(input_, outputs=outputs, name="decoder") diff --git a/plugins/train/model/lightweight.py b/plugins/train/model/lightweight.py index ae166fccc1..5618613949 100644 --- a/plugins/train/model/lightweight.py +++ b/plugins/train/model/lightweight.py @@ -20,30 +20,30 @@ def encoder(self): """ Encoder Network """ input_ = Input(shape=self.input_shape) var_x = input_ - var_x = Conv2DBlock(128)(var_x) - var_x = Conv2DBlock(256)(var_x) - var_x = Conv2DBlock(512)(var_x) + var_x = Conv2DBlock(128, activation="leakyrelu")(var_x) + var_x = Conv2DBlock(256, activation="leakyrelu")(var_x) + var_x = Conv2DBlock(512, activation="leakyrelu")(var_x) var_x = Dense(self.encoder_dim)(Flatten()(var_x)) var_x = Dense(4 * 4 * 512)(var_x) var_x = Reshape((4, 4, 512))(var_x) - var_x = UpscaleBlock(256)(var_x) + var_x = UpscaleBlock(256, activation="leakyrelu")(var_x) return KerasModel(input_, var_x, name="encoder") def decoder(self, side): """ Decoder Network """ input_ = Input(shape=(8, 8, 256)) var_x = input_ - var_x = UpscaleBlock(512)(var_x) - var_x = UpscaleBlock(256)(var_x) - var_x = UpscaleBlock(128)(var_x) + var_x = UpscaleBlock(512, activation="leakyrelu")(var_x) + var_x = UpscaleBlock(256, activation="leakyrelu")(var_x) + var_x = UpscaleBlock(128, activation="leakyrelu")(var_x) var_x = Conv2DOutput(3, 5, activation="sigmoid", name="face_out_{}".format(side))(var_x) outputs = [var_x] if self.config.get("learn_mask", False): var_y = input_ - var_y = UpscaleBlock(512)(var_y) - var_y = UpscaleBlock(256)(var_y) - var_y = UpscaleBlock(128)(var_y) + var_y = UpscaleBlock(512, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(256, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(128, activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, activation="sigmoid", name="mask_out_{}".format(side))(var_y) diff --git a/plugins/train/model/original.py b/plugins/train/model/original.py index 6ee13eef56..2380852230 100644 --- a/plugins/train/model/original.py +++ b/plugins/train/model/original.py @@ -112,15 +112,15 @@ def encoder(self): """ input_ = Input(shape=self.input_shape) var_x = input_ - var_x = Conv2DBlock(128)(var_x) - var_x = Conv2DBlock(256)(var_x) - var_x = Conv2DBlock(512)(var_x) + var_x = Conv2DBlock(128, activation="leakyrelu")(var_x) + var_x = Conv2DBlock(256, activation="leakyrelu")(var_x) + var_x = Conv2DBlock(512, activation="leakyrelu")(var_x) if not self.low_mem: - var_x = Conv2DBlock(1024)(var_x) + var_x = Conv2DBlock(1024, activation="leakyrelu")(var_x) var_x = Dense(self.encoder_dim)(Flatten()(var_x)) var_x = Dense(4 * 4 * 1024)(var_x) var_x = Reshape((4, 4, 1024))(var_x) - var_x = UpscaleBlock(512)(var_x) + var_x = UpscaleBlock(512, activation="leakyrelu")(var_x) return KerasModel(input_, var_x, name="encoder") def decoder(self, side): @@ -141,17 +141,17 @@ def decoder(self, side): """ input_ = Input(shape=(8, 8, 512)) var_x = input_ - var_x = UpscaleBlock(256)(var_x) - var_x = UpscaleBlock(128)(var_x) - var_x = UpscaleBlock(64)(var_x) + var_x = UpscaleBlock(256, activation="leakyrelu")(var_x) + var_x = UpscaleBlock(128, activation="leakyrelu")(var_x) + var_x = UpscaleBlock(64, activation="leakyrelu")(var_x) var_x = Conv2DOutput(3, 5, name="face_out_{}".format(side))(var_x) outputs = [var_x] if self.learn_mask: var_y = input_ - var_y = UpscaleBlock(256)(var_y) - var_y = UpscaleBlock(128)(var_y) - var_y = UpscaleBlock(64)(var_y) + var_y = UpscaleBlock(256, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(128, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(64, activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, name="mask_out_{}".format(side))(var_y) outputs.append(var_y) return KerasModel(input_, outputs=outputs, name="decoder_{}".format(side)) diff --git a/plugins/train/model/realface.py b/plugins/train/model/realface.py index 948714a6dc..e332d13e13 100644 --- a/plugins/train/model/realface.py +++ b/plugins/train/model/realface.py @@ -83,11 +83,15 @@ def encoder(self): encoder_complexity = self.config["complexity_encoder"] for idx in range(self.downscalers_no - 1): - var_x = Conv2DBlock(encoder_complexity * 2**idx)(var_x) - var_x = ResidualBlock(encoder_complexity * 2**idx, use_bias=True)(var_x) - var_x = ResidualBlock(encoder_complexity * 2**idx, use_bias=True)(var_x) + var_x = Conv2DBlock(encoder_complexity * 2**idx, activation="leakyrelu")(var_x) + var_x = ResidualBlock(encoder_complexity * 2**idx, + use_bias=True, + activation="leakyrelu")(var_x) + var_x = ResidualBlock(encoder_complexity * 2**idx, + use_bias=True, + activation="leakyrelu")(var_x) - var_x = Conv2DBlock(encoder_complexity * 2**(idx + 1))(var_x) + var_x = Conv2DBlock(encoder_complexity * 2**(idx + 1), activation="leakyrelu")(var_x) return KerasModel(input_, var_x, name="encoder") @@ -102,17 +106,17 @@ def decoder_b(self): var_xy = Dense(self.config["dense_nodes"])(Flatten()(var_xy)) var_xy = Dense(self.dense_width * self.dense_width * self.dense_filters)(var_xy) var_xy = Reshape((self.dense_width, self.dense_width, self.dense_filters))(var_xy) - var_xy = UpscaleBlock(self.dense_filters)(var_xy) + var_xy = UpscaleBlock(self.dense_filters, activation="leakyrelu")(var_xy) var_x = var_xy var_x = ResidualBlock(self.dense_filters, use_bias=False)(var_x) decoder_b_complexity = self.config["complexity_decoder"] for idx in range(self.upscalers_no - 2): - var_x = UpscaleBlock(decoder_b_complexity // 2**idx)(var_x) + var_x = UpscaleBlock(decoder_b_complexity // 2**idx, activation=None)(var_x) var_x = ResidualBlock(decoder_b_complexity // 2**idx, use_bias=False)(var_x) var_x = ResidualBlock(decoder_b_complexity // 2**idx, use_bias=True)(var_x) - var_x = UpscaleBlock(decoder_b_complexity // 2**(idx + 1))(var_x) + var_x = UpscaleBlock(decoder_b_complexity // 2**(idx + 1), activation="leakyrelu")(var_x) var_x = Conv2DOutput(3, 5, name="face_out_b")(var_x) @@ -122,8 +126,8 @@ def decoder_b(self): var_y = var_xy mask_b_complexity = 384 for idx in range(self.upscalers_no-2): - var_y = UpscaleBlock(mask_b_complexity // 2**idx)(var_y) - var_y = UpscaleBlock(mask_b_complexity // 2**(idx + 1))(var_y) + var_y = UpscaleBlock(mask_b_complexity // 2**idx, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(mask_b_complexity // 2**(idx + 1), activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, name="mask_out_b")(var_y) @@ -146,15 +150,15 @@ def decoder_a(self): var_xy = Dense(self.dense_width * self.dense_width * dense_filters)(var_xy) var_xy = Reshape((self.dense_width, self.dense_width, dense_filters))(var_xy) - var_xy = UpscaleBlock(dense_filters)(var_xy) + var_xy = UpscaleBlock(dense_filters, activation="leakyrelu")(var_xy) var_x = var_xy var_x = ResidualBlock(dense_filters, use_bias=False)(var_x) decoder_a_complexity = int(self.config["complexity_decoder"] / 1.5) for idx in range(self.upscalers_no-2): - var_x = UpscaleBlock(decoder_a_complexity // 2**idx)(var_x) - var_x = UpscaleBlock(decoder_a_complexity // 2**(idx + 1))(var_x) + var_x = UpscaleBlock(decoder_a_complexity // 2**idx, activation="leakyrelu")(var_x) + var_x = UpscaleBlock(decoder_a_complexity // 2**(idx + 1), activation="leakyrelu")(var_x) var_x = Conv2DOutput(3, 5, name="face_out_a")(var_x) @@ -164,8 +168,8 @@ def decoder_a(self): var_y = var_xy mask_a_complexity = 384 for idx in range(self.upscalers_no-2): - var_y = UpscaleBlock(mask_a_complexity // 2**idx)(var_y) - var_y = UpscaleBlock(mask_a_complexity // 2**(idx + 1))(var_y) + var_y = UpscaleBlock(mask_a_complexity // 2**idx, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(mask_a_complexity // 2**(idx + 1), activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, name="mask_out_a")(var_y) diff --git a/plugins/train/model/unbalanced.py b/plugins/train/model/unbalanced.py index c29b77cd38..64468d9ba0 100644 --- a/plugins/train/model/unbalanced.py +++ b/plugins/train/model/unbalanced.py @@ -39,11 +39,17 @@ def encoder(self): input_ = Input(shape=self.input_shape) var_x = input_ - var_x = Conv2DBlock(encoder_complexity, use_instance_norm=True, **kwargs)(var_x) - var_x = Conv2DBlock(encoder_complexity * 2, use_instance_norm=True, **kwargs)(var_x) - var_x = Conv2DBlock(encoder_complexity * 4, **kwargs)(var_x) - var_x = Conv2DBlock(encoder_complexity * 6, **kwargs)(var_x) - var_x = Conv2DBlock(encoder_complexity * 8, **kwargs)(var_x) + var_x = Conv2DBlock(encoder_complexity, + normalization="instance", + activation="leakyrelu", + **kwargs)(var_x) + var_x = Conv2DBlock(encoder_complexity * 2, + normalization="instance", + activation="leakyrelu", + **kwargs)(var_x) + var_x = Conv2DBlock(encoder_complexity * 4, **kwargs, activation="leakyrelu")(var_x) + var_x = Conv2DBlock(encoder_complexity * 6, **kwargs, activation="leakyrelu")(var_x) + var_x = Conv2DBlock(encoder_complexity * 8, **kwargs, activation="leakyrelu")(var_x) var_x = Dense(self.encoder_dim, kernel_initializer=self.kernel_initializer)(Flatten()(var_x)) var_x = Dense(dense_shape * dense_shape * dense_dim, @@ -61,24 +67,24 @@ def decoder_a(self): var_x = input_ - var_x = UpscaleBlock(decoder_complexity, **kwargs)(var_x) + var_x = UpscaleBlock(decoder_complexity, activation="leakyrelu", **kwargs)(var_x) var_x = SpatialDropout2D(0.25)(var_x) - var_x = UpscaleBlock(decoder_complexity, **kwargs)(var_x) + var_x = UpscaleBlock(decoder_complexity, activation="leakyrelu", **kwargs)(var_x) if self.low_mem: var_x = SpatialDropout2D(0.15)(var_x) else: var_x = SpatialDropout2D(0.25)(var_x) - var_x = UpscaleBlock(decoder_complexity // 2, **kwargs)(var_x) - var_x = UpscaleBlock(decoder_complexity // 4, **kwargs)(var_x) + var_x = UpscaleBlock(decoder_complexity // 2, activation="leakyrelu", **kwargs)(var_x) + var_x = UpscaleBlock(decoder_complexity // 4, activation="leakyrelu", **kwargs)(var_x) var_x = Conv2DOutput(3, 5, name="face_out_a")(var_x) outputs = [var_x] if self.config.get("learn_mask", False): var_y = input_ - var_y = UpscaleBlock(decoder_complexity)(var_y) - var_y = UpscaleBlock(decoder_complexity)(var_y) - var_y = UpscaleBlock(decoder_complexity // 2)(var_y) - var_y = UpscaleBlock(decoder_complexity // 4)(var_y) + var_y = UpscaleBlock(decoder_complexity, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(decoder_complexity, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(decoder_complexity // 2, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(decoder_complexity // 4, activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, name="mask_out_a")(var_y) outputs.append(var_y) return KerasModel(input_, outputs=outputs, name="decoder_a") @@ -93,33 +99,33 @@ def decoder_b(self): var_x = input_ if self.low_mem: - var_x = UpscaleBlock(decoder_complexity, **kwargs)(var_x) - var_x = UpscaleBlock(decoder_complexity // 2, **kwargs)(var_x) - var_x = UpscaleBlock(decoder_complexity // 4, **kwargs)(var_x) - var_x = UpscaleBlock(decoder_complexity // 8, **kwargs)(var_x) + var_x = UpscaleBlock(decoder_complexity, activation="leakyrelu", **kwargs)(var_x) + var_x = UpscaleBlock(decoder_complexity // 2, activation="leakyrelu", **kwargs)(var_x) + var_x = UpscaleBlock(decoder_complexity // 4, activation="leakyrelu", **kwargs)(var_x) + var_x = UpscaleBlock(decoder_complexity // 8, activation="leakyrelu", **kwargs)(var_x) else: - var_x = UpscaleBlock(decoder_complexity, res_block_follows=True, **kwargs)(var_x) + var_x = UpscaleBlock(decoder_complexity, activation=None, **kwargs)(var_x) var_x = ResidualBlock(decoder_complexity, kernel_initializer=self.kernel_initializer)(var_x) - var_x = UpscaleBlock(decoder_complexity, res_block_follows=True, **kwargs)(var_x) + var_x = UpscaleBlock(decoder_complexity, activation=None, **kwargs)(var_x) var_x = ResidualBlock(decoder_complexity, kernel_initializer=self.kernel_initializer)(var_x) - var_x = UpscaleBlock(decoder_complexity // 2, res_block_follows=True, **kwargs)(var_x) + var_x = UpscaleBlock(decoder_complexity // 2, activation=None, **kwargs)(var_x) var_x = ResidualBlock(decoder_complexity // 2, kernel_initializer=self.kernel_initializer)(var_x) - var_x = UpscaleBlock(decoder_complexity // 4, **kwargs)(var_x) + var_x = UpscaleBlock(decoder_complexity // 4, activation="leakyrelu", **kwargs)(var_x) var_x = Conv2DOutput(3, 5, name="face_out_b")(var_x) outputs = [var_x] if self.config.get("learn_mask", False): var_y = input_ - var_y = UpscaleBlock(decoder_complexity)(var_y) + var_y = UpscaleBlock(decoder_complexity, activation="leakyrelu")(var_y) if not self.low_mem: - var_y = UpscaleBlock(decoder_complexity)(var_y) - var_y = UpscaleBlock(decoder_complexity // 2)(var_y) - var_y = UpscaleBlock(decoder_complexity // 4)(var_y) + var_y = UpscaleBlock(decoder_complexity, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(decoder_complexity // 2, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(decoder_complexity // 4, activation="leakyrelu")(var_y) if self.low_mem: - var_y = UpscaleBlock(decoder_complexity // 8)(var_y) + var_y = UpscaleBlock(decoder_complexity // 8, activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, name="mask_out_b")(var_y) outputs.append(var_y) return KerasModel(input_, outputs=outputs, name="decoder_b") diff --git a/plugins/train/model/villain.py b/plugins/train/model/villain.py index f172d5195c..fcd53cd3a4 100644 --- a/plugins/train/model/villain.py +++ b/plugins/train/model/villain.py @@ -29,7 +29,7 @@ def encoder(self): in_conv_filters = 128 + (self.input_shape[0] - 128) // 4 dense_shape = self.input_shape[0] // 16 - var_x = Conv2DBlock(in_conv_filters, res_block_follows=True, **kwargs)(input_) + var_x = Conv2DBlock(in_conv_filters, activation=None, **kwargs)(input_) tmp_x = var_x res_cycles = 8 if self.config.get("lowmem", False) else 16 for _ in range(res_cycles): @@ -37,20 +37,20 @@ def encoder(self): var_x = nn_x # consider adding scale before this layer to scale the residual chain var_x = add([var_x, tmp_x]) - var_x = Conv2DBlock(128, **kwargs)(var_x) + var_x = Conv2DBlock(128, activation="leakyrelu", **kwargs)(var_x) var_x = PixelShuffler()(var_x) - var_x = Conv2DBlock(128, **kwargs)(var_x) + var_x = Conv2DBlock(128, activation="leakyrelu", **kwargs)(var_x) var_x = PixelShuffler()(var_x) - var_x = Conv2DBlock(128, **kwargs)(var_x) + var_x = Conv2DBlock(128, activation="leakyrelu", **kwargs)(var_x) var_x = SeparableConv2DBlock(256, **kwargs)(var_x) - var_x = Conv2DBlock(512, **kwargs)(var_x) + var_x = Conv2DBlock(512, activation="leakyrelu", **kwargs)(var_x) if not self.config.get("lowmem", False): var_x = SeparableConv2DBlock(1024, **kwargs)(var_x) var_x = Dense(self.encoder_dim, **kwargs)(Flatten()(var_x)) var_x = Dense(dense_shape * dense_shape * 1024, **kwargs)(var_x) var_x = Reshape((dense_shape, dense_shape, 1024))(var_x) - var_x = UpscaleBlock(512, **kwargs)(var_x) + var_x = UpscaleBlock(512, activation="leakyrelu", **kwargs)(var_x) return KerasModel(input_, var_x, name="encoder") def decoder(self, side): @@ -60,20 +60,20 @@ def decoder(self, side): input_ = Input(shape=(decoder_shape, decoder_shape, 512)) var_x = input_ - var_x = UpscaleBlock(512, res_block_follows=True, **kwargs)(var_x) + var_x = UpscaleBlock(512, activation=None, **kwargs)(var_x) var_x = ResidualBlock(512, **kwargs)(var_x) - var_x = UpscaleBlock(256, res_block_follows=True, **kwargs)(var_x) + var_x = UpscaleBlock(256, activation=None, **kwargs)(var_x) var_x = ResidualBlock(256, **kwargs)(var_x) - var_x = UpscaleBlock(self.input_shape[0], res_block_follows=True, **kwargs)(var_x) + var_x = UpscaleBlock(self.input_shape[0], activation=None, **kwargs)(var_x) var_x = ResidualBlock(self.input_shape[0], **kwargs)(var_x) var_x = Conv2DOutput(3, 5, name="face_out_{}".format(side))(var_x) outputs = [var_x] if self.config.get("learn_mask", False): var_y = input_ - var_y = UpscaleBlock(512)(var_y) - var_y = UpscaleBlock(256)(var_y) - var_y = UpscaleBlock(self.input_shape[0])(var_y) + var_y = UpscaleBlock(512, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(256, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(self.input_shape[0], activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, name="mask_out_{}".format(side))(var_y) outputs.append(var_y) return KerasModel(input_, outputs=outputs, name="decoder_{}".format(side)) From bcf38b02cc7209d1baccd1302b5224f5faf2f00a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 31 Jan 2021 16:08:48 +0000 Subject: [PATCH 349/981] plugins.train.model.base - More robust inference model generation --- plugins/train/model/_base.py | 213 ++++++++++++++--------------------- 1 file changed, 86 insertions(+), 127 deletions(-) diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index d8784eea39..b791b3c7b8 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -1369,12 +1369,11 @@ def __init__(self, saved_model, switch_sides): logger.debug("Initializing: %s (saved_model: %s, switch_sides: %s)", self.__class__.__name__, saved_model, switch_sides) self._config = saved_model.get_config() - input_idx = 1 if switch_sides else 0 + + self._input_idx = 1 if switch_sides else 0 self._output_idx = 0 if switch_sides else 1 - self._input_names = set(self._filter_node(self._config["input_layers"][input_idx])) - self._inputs = self._get_inputs(saved_model.inputs, input_idx) - self._outputs_dropout = self._get_outputs_dropout() + self._input_names = [inp[0] for inp in self._config["input_layers"]] self._model = self._make_inference_model(saved_model) logger.debug("Initialized: %s", self.__class__.__name__) @@ -1383,72 +1382,25 @@ def model(self): """ :class:`keras.models.Model`: The Faceswap model, compiled for inference. """ return self._model - @classmethod - def _filter_node(cls, node): + def _get_nodes(self, nodes): """ Given in input list of nodes from a :attr:`keras.models.Model.get_config` dictionary, - filters the information out and unravels the dictionary into a more usable format + filters the layer name(s) and output index of the node, splitting to the correct output + index in the event of multiple inputs. Parameters ---------- - node: list + nodes: list A node entry from the :attr:`keras.models.Model.get_config` dictionary Returns ------- list - A squeezed list with only the layer name entries remaining - """ - retval = np.array(node)[..., 0].squeeze().tolist() - return retval if isinstance(retval, list) else [retval] - - @classmethod - def _get_inputs(cls, inputs, input_index): - """ Obtain the inputs for the requested swap direction. - - Parameters - ---------- - inputs: list - The full list of input tensors to the saved faceswap training model - input_index: int - The input index for the requested swap direction - - Returns - ------- - list - List of input tensors to feed the model for the requested swap direction + The (node name, output index) for each node passed in """ - input_split = len(inputs) // 2 - start_idx = input_split * input_index - retval = inputs[start_idx: start_idx + input_split] - logger.debug("model inputs: %s, input_split: %s, start_idx: %s, inference_inputs: %s", - inputs, input_split, start_idx, retval) - return retval - - def _get_outputs_dropout(self): - """ Obtain the output layer names from the full model that will not be used for inference. - - Returns - ------- - set - The output layer names from the saved Faceswap model that are not used for inference - for the requested swap direction - """ - outputs = self._config["output_layers"] - if get_backend() == "amd": - outputs = [outputs[:len(outputs) // 2], outputs[len(outputs) // 2:]] - - output_names = self._filter_node(outputs) - if not all(isinstance(name, list) for name in output_names): - output_names = [[name] for name in output_names] - side_outputs = set(output_names[self._output_idx]) - logger.debug("model outputs: %s, output_names: %s, side_outputs: %s", - outputs, output_names, side_outputs) - - outputs_all = {layer - for side in output_names - for layer in side} - retval = outputs_all.difference(side_outputs) - logger.debug("outputs dropout: %s", retval) + nodes = np.array(nodes, dtype="object")[..., :3] + num_layers = nodes.shape[0] + nodes = nodes[self._output_idx] if num_layers == 2 else nodes[0] + retval = [(node[0], node[2]) for node in nodes] return retval def _make_inference_model(self, saved_model): @@ -1466,95 +1418,102 @@ def _make_inference_model(self, saved_model): """ logger.debug("Compiling inference model. saved_model: %s", saved_model) struct = self._get_filtered_structure() - required_layers = self._get_required_layers(struct) - logger.debug("Compiling model") - layer_dict = {layer.name: layer for layer in saved_model.layers} + model_inputs = self._get_inputs(saved_model.inputs) compiled_layers = dict() - for name, inbound in struct.items(): - if name not in required_layers: - logger.debug("Skipping unused layer: '%s'", name) + for layer in saved_model.layers: + if layer.name not in struct: + logger.debug("Skipping unused layer: '%s'", layer.name) continue - layer = layer_dict[name] + inbound = struct[layer.name] logger.debug("Processing layer '%s': (layer: %s, inbound_nodes: %s)", - name, layer, inbound) + layer.name, layer, inbound) if not inbound: - logger.debug("Adding model inputs %s: %s", self._input_names, self._inputs) - model = layer(self._inputs) + model = model_inputs + logger.debug("Adding model inputs %s: %s", layer.name, model) else: - layer_inputs = [compiled_layers[inp] for inp in inbound] - logger.debug("Compiling layer '%s': layer inputs: %s", name, layer_inputs) + layer_inputs = [] + for inp in inbound: + inbound_layer = compiled_layers[inp[0]] + if isinstance(inbound_layer, list) and len(inbound_layer) > 1: + # Multi output inputs + inbound_output_idx = inp[1] + logger.debug("Selecting output index %s from multi output inbound " + "layer: %s", inbound_output_idx, inbound_layer) + layer_inputs.append(inbound_layer[inbound_output_idx]) + else: + layer_inputs.append(inbound_layer) + + logger.debug("Compiling layer '%s': layer inputs: %s", layer.name, layer_inputs) model = layer(layer_inputs) - compiled_layers[name] = model - retval = KerasModel(self._inputs, model, name="{}_inference".format(saved_model.name)) + compiled_layers[layer.name] = model + retval = KerasModel(model_inputs, model, name="{}_inference".format(saved_model.name)) logger.debug("Compiled inference model '%s': %s", retval.name, retval) return retval def _get_filtered_structure(self): - """ Obtain the structure of the full model, filtering out inbound nodes and - layers that are not required for the requested swap destination. + """ Obtain the structure of the inference model. - Input layers to the full model are not returned in the structure. + This parses the model config (in reverse) to obtain the required layers for an inference + model. Returns ------- :class:`collections.OrderedDict` - The layer name as key with the inbound node layer names for each layer as value. + The layer name as key with the input name and output index as value. """ - retval = OrderedDict() - for layer in self._config["layers"]: - name = layer["name"] - if not layer["inbound_nodes"]: - logger.debug("Skipping input layer: '%s'", name) - continue - inbound = self._filter_node(layer["inbound_nodes"]) - - # TODO Currently any models which have a list input will not contain the main model - # input. This may not be true in future (if the main input is injected into a layer - # further down the model chain) so this should be made more robust - if (not any(isinstance(inb, list) for inb in inbound) - and self._input_names.intersection(inbound)): - # Strip the input inbound nodes for applying the correct input layer at compile - # time - logger.debug("Stripping inbound nodes for input '%s': %s", name, inbound) - inbound = "" - - if inbound and np.array(layer["inbound_nodes"]).shape[0] == 2: - # if inbound is not populated, then layer is already split at input - logger.debug("Filtering layer with split inbound nodes: '%s': %s", name, inbound) - inbound = inbound[self._output_idx] - inbound = inbound if isinstance(inbound, list) else [inbound] - logger.debug("Filtered inbound nodes for layer '%s': %s", name, inbound) - if name in self._outputs_dropout: - logger.debug("Dropping output layer '%s'", name) - continue - retval[name] = inbound - logger.debug("Model structure: %s", retval) - return retval + # Filter output layer + out = np.array(self._config["output_layers"], dtype="object") + if out.ndim == 2: + out = np.expand_dims(out, axis=1) # Needs to be expanded for _get_nodes + outputs = self._get_nodes(out) + + # Iterate backwards from the required output to get the reversed model structure + current_layers = [outputs[0]] + next_layers = [] + struct = OrderedDict() + drop_input = self._input_names[abs(self._input_idx - 1)] + switch_input = self._input_names[self._input_idx] + while True: + layer_info = current_layers.pop(0) + current_layer = next(lyr for lyr in self._config["layers"] + if lyr["name"] == layer_info[0]) + inbound = current_layer["inbound_nodes"] - @classmethod - def _get_required_layers(cls, filtered_structure): - """ Parse through the filtered model structure in reverse order to get the required layers - from the faceswap model for creating an inference model. + if not inbound: + break + + inbound_info = self._get_nodes(inbound) + + if any(inb[0] == drop_input for inb in inbound_info): # Switch inputs + inbound_info = [(switch_input if inb[0] == drop_input else inb[0], inb[1]) + for inb in inbound_info] + struct[layer_info[0]] = inbound_info + next_layers.extend(inbound_info) + + if not current_layers: + current_layers = next_layers + next_layers = [] + + struct[switch_input] = [] # Add the input layer + logger.debug("Model structure: %s", struct) + return struct + + def _get_inputs(self, inputs): + """ Obtain the inputs for the requested swap direction. Parameters ---------- - filtered_structure: :class:`OrderedDict` - The full model structure with unused inbound nodes and layers removed + inputs: list + The full list of input tensors to the saved faceswap training model Returns ------- - set - The layers from the saved model that are required to build the inference model + list + List of input tensors to feed the model for the requested swap direction """ - retval = set() - for idx, (name, inbound) in enumerate(reversed(filtered_structure.items())): - if idx == 0: - logger.debug("Adding output layer: '%s'", name) - retval.add(name) - if idx != 0 and name not in retval: - logger.debug("Skipping unused layer: '%s'", name) - continue - logger.debug("Adding inbound layers: %s", inbound) - retval.update(inbound) - logger.debug("Required layers: %s", retval) + input_split = len(inputs) // 2 + start_idx = input_split * self._input_idx + retval = inputs[start_idx: start_idx + input_split] + logger.debug("model inputs: %s, input_split: %s, start_idx: %s, inference_inputs: %s", + inputs, input_split, start_idx, retval) return retval From 0941d6d27b988b6c8c137a4ada54dd2c2be6bd44 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 31 Jan 2021 18:54:18 +0000 Subject: [PATCH 350/981] realface model - Bugfix resblock --- plugins/train/model/realface.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/plugins/train/model/realface.py b/plugins/train/model/realface.py index e332d13e13..d21e1db48e 100644 --- a/plugins/train/model/realface.py +++ b/plugins/train/model/realface.py @@ -84,12 +84,8 @@ def encoder(self): for idx in range(self.downscalers_no - 1): var_x = Conv2DBlock(encoder_complexity * 2**idx, activation="leakyrelu")(var_x) - var_x = ResidualBlock(encoder_complexity * 2**idx, - use_bias=True, - activation="leakyrelu")(var_x) - var_x = ResidualBlock(encoder_complexity * 2**idx, - use_bias=True, - activation="leakyrelu")(var_x) + var_x = ResidualBlock(encoder_complexity * 2**idx, use_bias=True)(var_x) + var_x = ResidualBlock(encoder_complexity * 2**idx, use_bias=True)(var_x) var_x = Conv2DBlock(encoder_complexity * 2**(idx + 1), activation="leakyrelu")(var_x) From 27a7adb4c5ec7d92b2a783dc3e70e2d5d9b0d5d9 Mon Sep 17 00:00:00 2001 From: deepfakes <34667098+deepfakes@users.noreply.github.com> Date: Tue, 2 Feb 2021 17:30:44 +0000 Subject: [PATCH 351/981] Update README.md Update Crypto addresses --- README.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b147646e66..0507c8f16c 100755 --- a/README.md +++ b/README.md @@ -128,9 +128,11 @@ Alternatively you can give a one off donation to any of our Devs: ### @torzdf There is very little FaceSwap code that hasn't been touched by torzdf. He is responsible for implementing the GUI, FAN aligner, MTCNN detector and porting the Villain, DFL-H128 and DFaker models to FaceSwap, as well as significantly improving many areas of the code. -**Bitcoin:** 385a1r9tyZpt5LyZcNk1FALTxC8ZHta7yq +**Bitcoin:** bc1qpm22suz59ylzk0j7qk5e4c7cnkjmve2rmtrnc6 -**Ethereum:** 0x18CBbff5fA7C78de7B949A2b0160A0d1bd649f80 +**Ethereum:** 0xd3e954dC241B87C4E8E1A801ada485DC1d530F01 + +**Monero:** 45dLrtQZ2pkHizBpt3P3yyJKkhcFHnhfNYPMSnz3yVEbdWm3Hj6Kr5TgmGAn3Far8LVaQf1th2n3DJVTRkfeB5ZkHxWozSX **Paypal:** [![torzdf](https://www.paypalobjects.com/en_GB/i/btn/btn_donate_SM.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=JZ8PP3YE9J62L) @@ -139,11 +141,6 @@ Creator of the Unbalanced and OHR models, as well as expanding various capabilit **Paypal:** [![andenixa](https://www.paypalobjects.com/en_GB/i/btn/btn_donate_SM.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=NRVLQYGS6NWTU) -### @kvrooman -Responsible for consolidating the converters, adding a lot of code to fix model stability issues, and helping significantly towards making the training process more modular, kvrooman continues to be a very active contributor. - -**Ethereum:** 0x18CBbff5fA7C78de7B949A2b0160A0d1bd649f80 - # How to contribute ## For people interested in the generative models From f32d714ab0418e297bce6dd1a0cefe0ac8371624 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 4 Feb 2021 13:35:30 +0000 Subject: [PATCH 352/981] lib.model.nn_blocks - Maintenance - Add additional activation functions - Add custom upscale block --- lib/model/nn_blocks.py | 109 +++++++++++++++++++++++++++++++++++------ 1 file changed, 94 insertions(+), 15 deletions(-) diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index b73e7c9292..1ef2eb4873 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -4,12 +4,12 @@ import logging from keras.layers import (Activation, Add, BatchNormalization, Concatenate, Conv2D as KConv2D, - DepthwiseConv2D as KDepthwiseConv2d, LeakyReLU, SeparableConv2D, - UpSampling2D) + Conv2DTranspose, DepthwiseConv2D as KDepthwiseConv2d, LeakyReLU, PReLU, + SeparableConv2D, UpSampling2D) from keras.initializers import he_uniform, VarianceScaling from .initializers import ICNR, ConvolutionAware -from .layers import PixelShuffler, ReflectionPadding2D, Swish +from .layers import PixelShuffler, ReflectionPadding2D, Swish, KResizeImages from .normalization import InstanceNormalization logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -235,8 +235,8 @@ class Conv2DBlock(): # pylint:disable=too-few-public-methods Set to ``None`` to not apply normalization. Default: ``None`` activation: str or ``None``, optional The activation function to use. This is applied at the end of the convolution block. Select - one of `"leakyrelu"` or `"swish"`. Set to ``None`` to not apply an activation function. - Default: `"leakyrelu"` + one of `"leakyrelu"`, `"prelu"` or `"swish"`. Set to ``None`` to not apply an activation + function. Default: `"leakyrelu"` use_depthwise: bool, optional Set to ``True`` to use a Depthwise Convolution 2D layer rather than a standard Convolution 2D layer. Default: ``False`` @@ -276,8 +276,8 @@ def _assert_arguments(self): """ Validate the given arguments. """ assert self._normalization in ("batch", "instance", None), ( "normalization should be 'batch', 'instance' or None") - assert self._activation in ("leakyrelu", "swish", None), ( - "activation should be 'leakyrelu', 'swish' or None") + assert self._activation in ("leakyrelu", "swish", "prelu", None), ( + "activation should be 'leakyrelu', 'prelu', 'swish' or None") def __call__(self, inputs): """ Call the Faceswap Convolutional Layer. @@ -313,6 +313,9 @@ def __call__(self, inputs): var_x = LeakyReLU(0.1, name="{}_leakyrelu".format(self._name))(var_x) if self._activation == "swish": var_x = Swish(name="{}_swish".format(self._name))(var_x) + if self._activation == "prelu": + var_x = PReLU(name="{}_prelu".format(self._name))(var_x) + return var_x @@ -399,8 +402,8 @@ class UpscaleBlock(): # pylint:disable=too-few-public-methods Set to ``None`` to not apply normalization. Default: ``None`` activation: str or ``None``, optional The activation function to use. This is applied at the end of the convolution block. Select - one of `"leakyrelu"` or `"swish"`. Set to ``None`` to not apply an activation function. - Default: `"leakyrelu"` + one of `"leakyrelu"`, `"prelu"` or `"swish"`. Set to ``None`` to not apply an activation + function. Default: `"leakyrelu"` kwargs: dict Any additional Keras standard layer keyword arguments to pass to the Convolutional 2D layer """ @@ -476,12 +479,12 @@ class Upscale2xBlock(): # pylint:disable=too-few-public-methods dimensions. Default: 3 padding: ["valid", "same"], optional The padding to use. Default: `"same"` - interpolation: ["nearest", "bilinear"], optional - Interpolation to use for up-sampling. Default: `"bilinear"` activation: str or ``None``, optional The activation function to use. This is applied at the end of the convolution block. Select - one of `"leakyrelu"` or `"swish"`. Set to ``None`` to not apply an activation function. - Default: `"leakyrelu"` + one of `"leakyrelu"`, `"prelu"` or `"swish"`. Set to ``None`` to not apply an activation + function. Default: `"leakyrelu"` + interpolation: ["nearest", "bilinear"], optional + Interpolation to use for up-sampling. Default: `"bilinear"` scale_factor: int, optional The amount to upscale the image. Default: `2` sr_ratio: float, optional @@ -492,8 +495,8 @@ class Upscale2xBlock(): # pylint:disable=too-few-public-methods kwargs: dict Any additional Keras standard layer keyword arguments to pass to the Convolutional 2D layer """ - def __init__(self, filters, kernel_size=3, padding="same", interpolation="bilinear", - activation="leakyrelu", sr_ratio=0.5, scale_factor=2, fast=False, **kwargs): + def __init__(self, filters, kernel_size=3, padding="same", activation="leakyrelu", + interpolation="bilinear", sr_ratio=0.5, scale_factor=2, fast=False, **kwargs): self._name = _get_name("upscale2x_{}_{}".format(filters, "fast" if fast else "hyb")) self._fast = fast @@ -549,6 +552,82 @@ def __call__(self, inputs): return var_x +class UpscaleResizeImagesBlock(): # pylint:disable=too-few-public-methods + """ Upscale block that uses the Keras Backend function resize_images to perform the upscaling + Similar in methodolgy to the :class:`Upscale2xBlock` + + Adds reflection padding if it has been selected by the user, and other post-processing + if requested by the plugin. + + Parameters + ---------- + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution) + kernel_size: int, optional + An integer or tuple/list of 2 integers, specifying the height and width of the 2D + convolution window. Can be a single integer to specify the same value for all spatial + dimensions. Default: 3 + padding: ["valid", "same"], optional + The padding to use. Default: `"same"` + activation: str or ``None``, optional + The activation function to use. This is applied at the end of the convolution block. Select + one of `"leakyrelu"`, `"prelu"` or `"swish"`. Set to ``None`` to not apply an activation + function. Default: `"leakyrelu"` + scale_factor: int, optional + The amount to upscale the image. Default: `2` + interpolation: ["nearest", "bilinear"], optional + Interpolation to use for up-sampling. Default: `"bilinear"` + kwargs: dict + Any additional Keras standard layer keyword arguments to pass to the Convolutional 2D layer + """ + def __init__(self, filters, kernel_size=3, padding="same", activation="leakyrelu", + scale_factor=2, interpolation="bilinear"): + self._name = _get_name("upscale_ri_{}".format(filters)) + self._interpolation = interpolation + self._size = scale_factor + self._filters = filters + self._kernel_size = kernel_size + self._padding = padding + self._activation = activation + + def __call__(self, inputs): + """ Call the Faceswap Resize Images Layer. + + Parameters + ---------- + inputs: Tensor + The input to the layer + + Returns + ------- + Tensor + The output tensor from the Upscale Layer + """ + var_x = inputs + + var_x_sr = KResizeImages(size=self._size, + interpolation=self._interpolation, + name="{}_resize".format(self._name))(var_x) + var_x_sr = Conv2D(self._filters, self._kernel_size, + strides=1, + padding=self._padding, + name="{}_conv".format(self._name))(var_x_sr) + var_x_us = Conv2DTranspose(self._filters, 3, + strides=2, + padding=self._padding, + name="{}_convtrans".format(self._name))(var_x) + var_x = Add()([var_x_sr, var_x_us]) + + if self._activation == "leakyrelu": + var_x = LeakyReLU(0.2, name="{}_leakyrelu".format(self._name))(var_x) + if self._activation == "swish": + var_x = Swish(name="{}_swish".format(self._name))(var_x) + if self._activation == "prelu": + var_x = PReLU(name="{}_prelu".format(self._name))(var_x) + return var_x + + # << OTHER BLOCKS >> class ResidualBlock(): # pylint:disable=too-few-public-methods """ Residual block from dfaker. From cd24f25f9afa682056186586099c3a78250b8de7 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 5 Feb 2021 12:03:06 +0000 Subject: [PATCH 353/981] Extract - Always output faces as .png --- scripts/extract.py | 3 ++- tools/alignments/jobs.py | 3 ++- tools/manual/detected_faces.py | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/extract.py b/scripts/extract.py index f19104dadf..5646a8442c 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -282,7 +282,8 @@ def _output_faces(self, saver, extract_media): """ logger.trace("Outputting faces for %s", extract_media.filename) final_faces = list() - filename, extension = os.path.splitext(os.path.basename(extract_media.filename)) + filename = os.path.splitext(os.path.basename(extract_media.filename))[0] + extension = ".png" for idx, face in enumerate(extract_media.detected_faces): output_filename = "{}_{}{}".format(filename, str(idx), extension) face.hash, image = encode_image_with_hash(face.aligned.face, extension) diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index 13acd1f970..f4666a11e5 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -539,7 +539,8 @@ def _output_faces(self, filename, image): """ logger.trace("Outputting frame: %s", filename) face_count = 0 - frame_name, extension = os.path.splitext(filename) + frame_name = os.path.splitext(filename)[0] + extension = ".png" faces = self._select_valid_faces(filename, image) if not faces: return face_count diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index fd96d286f6..f0ab994141 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -401,7 +401,8 @@ def _background_extract(self, output_folder, progress_queue): for frame_idx, (filename, image) in enumerate(loader.load()): logger.trace("Outputting frame: %s: %s", frame_idx, filename) basename = os.path.basename(filename) - frame_name, extension = os.path.splitext(basename) + frame_name = os.path.splitext(basename)[0] + extension = ".png" final_faces = [] progress_queue.put(1) for face_idx, face in enumerate(self._frame_faces[frame_idx]): From 7d44aab33c20d30c0b71a7e4725ee6b29b7b2f0c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 7 Feb 2021 02:06:31 +0000 Subject: [PATCH 354/981] Minor GUI Updates - Switch paned window class to ttk - Properly pad StatusBar --- scripts/gui.py | 22 ++++++++-------------- tools/manual/faceviewer/frame.py | 2 +- tools/manual/frameviewer/frame.py | 2 +- tools/manual/manual.py | 11 ++++------- tools/preview/preview.py | 7 ++----- 5 files changed, 16 insertions(+), 28 deletions(-) diff --git a/scripts/gui.py b/scripts/gui.py index b45b98a31e..d54993cfaf 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -93,20 +93,14 @@ def add_containers(self): """ Add the paned window containers that hold each main area of the gui """ logger.debug("Adding containers") - maincontainer = tk.PanedWindow(self, - sashrelief=tk.RIDGE, - sashwidth=4, - sashpad=8, - orient=tk.VERTICAL, - name="pw_main") + maincontainer = ttk.PanedWindow(self, + orient=tk.VERTICAL, + name="pw_main") maincontainer.pack(fill=tk.BOTH, expand=True) - topcontainer = tk.PanedWindow(maincontainer, - sashrelief=tk.RIDGE, - sashwidth=4, - sashpad=8, - orient=tk.HORIZONTAL, - name="pw_top") + topcontainer = ttk.PanedWindow(maincontainer, + orient=tk.HORIZONTAL, + name="pw_top") maincontainer.add(topcontainer) bottomcontainer = ttk.Frame(maincontainer, name="frame_bottom") @@ -137,8 +131,8 @@ def set_layout(self): logger.debug("Setting Initial Layout: (root_width: %s, root_height: %s, width_ratio: %s, " "height_ratio: %s, width: %s, height: %s", r_width, r_height, w_ratio, h_ratio, width, height) - self.objects["container_top"].sash_place(0, width, 1) - self.objects["container_main"].sash_place(0, 1, height) + self.objects["container_top"].sashpos(0, width) + self.objects["container_main"].sashpos(0, height) self.update_idletasks() def rebuild(self): diff --git a/tools/manual/faceviewer/frame.py b/tools/manual/faceviewer/frame.py index 74834862b1..425f9ff0e2 100644 --- a/tools/manual/faceviewer/frame.py +++ b/tools/manual/faceviewer/frame.py @@ -25,7 +25,7 @@ class FacesFrame(ttk.Frame): # pylint:disable=too-many-ancestors Parameters ---------- - parent: :class:`tkinter.PanedWindow` + parent: :class:`ttk.PanedWindow` The paned window that the faces frame resides in tk_globals: :class:`~tools.manual.manual.TkGlobals` The tkinter variables that apply to the whole of the GUI diff --git a/tools/manual/frameviewer/frame.py b/tools/manual/frameviewer/frame.py index e42e581a4f..180dc41854 100644 --- a/tools/manual/frameviewer/frame.py +++ b/tools/manual/frameviewer/frame.py @@ -23,7 +23,7 @@ class DisplayFrame(ttk.Frame): # pylint:disable=too-many-ancestors Parameters ---------- - parent: :class:`tkinter.PanedWindow` + parent: :class:`ttk.PanedWindow` The paned window that the display frame resides in tk_globals: :class:`~tools.manual.manual.TkGlobals` The tkinter variables that apply to the whole of the GUI diff --git a/tools/manual/manual.py b/tools/manual/manual.py index 651810c840..c2f637718b 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -148,12 +148,9 @@ def _create_containers(self): The main containers of the manual tool. """ logger.debug("Creating containers") - main = tk.PanedWindow(self, - sashrelief=tk.RIDGE, - sashwidth=2, - sashpad=4, - orient=tk.VERTICAL, - name="pw_main") + main = ttk.PanedWindow(self, + orient=tk.VERTICAL, + name="pw_main") main.pack(fill=tk.BOTH, expand=True) top = ttk.Frame(main, name="frame_top") @@ -231,7 +228,7 @@ def _set_initial_layout(self): "iconphoto", self._w, get_images().icons["favicon"]) # pylint:disable=protected-access location = int(self.winfo_screenheight() // 1.5) - self._containers["main"].sash_place(0, 1, location) + self._containers["main"].sashpos(0, location) self.update_idletasks() def process(self): diff --git a/tools/preview/preview.py b/tools/preview/preview.py index 2af63d9fb4..3b283622a6 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -123,11 +123,8 @@ def _refresh(self, *args): def _build_ui(self): """ Build the elements for displaying preview images and options panels. """ - container = tk.PanedWindow(self, - sashrelief=tk.RIDGE, - sashwidth=4, - sashpad=8, - orient=tk.VERTICAL) + container = ttk.PanedWindow(self, + orient=tk.VERTICAL) container.pack(fill=tk.BOTH, expand=True) container.preview_display = self._display self._image_canvas = ImagesCanvas(container, self._tk_vars) From d5ae59c6bf3c4e5c958af4f4d8ec6e959a15b60c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 7 Feb 2021 02:17:06 +0000 Subject: [PATCH 355/981] GUI - Add correctly pad status bar --- lib/gui/custom_widgets.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index c2abd3b01a..8a67ce8be2 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -425,8 +425,7 @@ class StatusBar(ttk.Frame): # pylint: disable=too-many-ancestors def __init__(self, parent, hide_status=False): super().__init__(parent) - self.pack(side=tk.BOTTOM, padx=10, pady=2, fill=tk.X, expand=False) - + self._frame = ttk.Frame(self) self._message = tk.StringVar() self._pbar_message = tk.StringVar() self._pbar_position = tk.IntVar() @@ -435,6 +434,8 @@ def __init__(self, parent, hide_status=False): self._status(hide_status) self._pbar = self._progress_bar() + self.pack(side=tk.BOTTOM, fill=tk.X, expand=False) + self._frame.pack(padx=10, pady=2, fill=tk.X, expand=False) @property def message(self): @@ -454,7 +455,7 @@ def _status(self, hide_status): if hide_status: return - statusframe = ttk.Frame(self) + statusframe = ttk.Frame(self._frame) statusframe.pack(side=tk.LEFT, anchor=tk.W, fill=tk.X, expand=False) lbltitle = ttk.Label(statusframe, text="Status:", width=6, anchor=tk.W) @@ -468,7 +469,7 @@ def _status(self, hide_status): def _progress_bar(self): """ Place progress bar into right of the status bar. """ - progressframe = ttk.Frame(self) + progressframe = ttk.Frame(self._frame) progressframe.pack(side=tk.RIGHT, anchor=tk.E, fill=tk.X) lblmessage = ttk.Label(progressframe, textvariable=self._pbar_message) From 6de2b3193e67c58efd3e20b48dff474242022ea4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 7 Feb 2021 12:30:22 +0000 Subject: [PATCH 356/981] bugfix: plugins.train - AMD Inference model compilation --- plugins/train/model/_base.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index b791b3c7b8..2f17aec232 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -1437,11 +1437,18 @@ def _make_inference_model(self, saved_model): if isinstance(inbound_layer, list) and len(inbound_layer) > 1: # Multi output inputs inbound_output_idx = inp[1] - logger.debug("Selecting output index %s from multi output inbound " - "layer: %s", inbound_output_idx, inbound_layer) - layer_inputs.append(inbound_layer[inbound_output_idx]) + next_input = inbound_layer[inbound_output_idx] + logger.debug("Selecting output index %s from multi output inbound layer: " + "%s (using: %s)", inbound_output_idx, inbound_layer, + next_input) else: - layer_inputs.append(inbound_layer) + next_input = inbound_layer + + if get_backend() == "amd" and isinstance(next_input, list): + # tf.keras and keras 2.2 behave differently for layer inputs + layer_inputs.extend(next_input) + else: + layer_inputs.append(next_input) logger.debug("Compiling layer '%s': layer inputs: %s", layer.name, layer_inputs) model = layer(layer_inputs) From 57b29b03cd5412e17cbfd1b2d26696fed0e5fb7d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 8 Feb 2021 01:01:54 +0000 Subject: [PATCH 357/981] preview tool: bugfix ttk.PanedWindow --- tools/preview/preview.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/preview/preview.py b/tools/preview/preview.py index 3b283622a6..b927ae4d24 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -128,7 +128,7 @@ def _build_ui(self): container.pack(fill=tk.BOTH, expand=True) container.preview_display = self._display self._image_canvas = ImagesCanvas(container, self._tk_vars) - container.add(self._image_canvas, height=400 * get_config().scaling_factor) + container.add(self._image_canvas, weight=3) options_frame = ttk.Frame(container) self._cli_frame = ActionFrame( @@ -144,7 +144,9 @@ def _build_ui(self): self._opts_book = OptionsBook(options_frame, self._config_tools, self._refresh) - container.add(options_frame) + container.add(options_frame, weight=1) + self.update_idletasks() + container.sashpos(0, int(400 * get_config().scaling_factor)) class Samples(): From ec3fb8b4431c47c546abafd6857e2cef59b07407 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 10 Feb 2021 13:51:30 +0000 Subject: [PATCH 358/981] gui - bugfix: Clear recent files on data corruption --- lib/gui/menu.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 0dd3052ad8..b7f1ee1853 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -13,6 +13,7 @@ from lib.multithreading import MultiThread from lib.serializer import get_serializer +from lib.utils import FaceswapError import update_deps from .popup_configure import open_popup @@ -134,7 +135,16 @@ def build_recent_menu(self): menu_file = os.path.join(self._config.pathcache, ".recent.json") if not os.path.isfile(menu_file) or os.path.getsize(menu_file) == 0: self.clear_recent_files(serializer, menu_file) - recent_files = serializer.load(menu_file) + try: + recent_files = serializer.load(menu_file) + except FaceswapError as err: + if "Error unserializing data for type" in str(err): + # Some reports of corruption breaking menus + logger.warning("There was an error opening the recent files list so it has been " + "reset.") + self.clear_recent_files(serializer, menu_file) + recent_files = [] + logger.debug("Loaded recent files: %s", recent_files) removed_files = [] for recent_item in recent_files: From a84462f4fa2a31c19af5547b424f79996b173d4b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 11 Feb 2021 10:24:03 +0000 Subject: [PATCH 359/981] Training - Move disable warp option out of config --- lib/cli/args.py | 10 ++++++++++ lib/training_data.py | 17 ++++++++++------- plugins/train/trainer/_base.py | 1 + plugins/train/trainer/original_defaults.py | 8 -------- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index 774fb4e4e7..4e9a0d7339 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -1080,6 +1080,16 @@ def get_argument_list(): help="Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " "Enable this option to disable color augmentation.")) + argument_list.append(dict( + opts=("-nw", "--no-warp"), + action="store_true", + dest="no_warp", + default=False, + group="augmentation", + help="Warping is integral to training the Neural Network. This option should only be " + "enabled towards the very end of training to try to bring out more detail. Think " + "of it as 'fine-tuning'. Enabling this option from the beginning is likely to " + "kill a model and lead to terrible results.")) return argument_list diff --git a/lib/training_data.py b/lib/training_data.py index df951f5c51..834355f1d7 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -42,6 +42,8 @@ class TrainingDataGenerator(): # pylint:disable=too-few-public-methods no_flip: bool ``True`` if the image shouldn't be randomly flipped as part of augmentation, otherwise ``False`` + no_warp: bool + ``True`` if the image shouldn't be warped as part of augmentation, otherwise ``False`` warp_to_landmarks: bool ``True`` if the random warp method should warp to similar landmarks from the other side, ``False`` if the standard random warp method should be used. @@ -76,12 +78,12 @@ class TrainingDataGenerator(): # pylint:disable=too-few-public-methods plugin configuration options. """ def __init__(self, model_input_size, model_output_shapes, coverage_ratio, augment_color, - no_flip, warp_to_landmarks, alignments, config): + no_flip, no_warp, warp_to_landmarks, alignments, config): logger.debug("Initializing %s: (model_input_size: %s, model_output_shapes: %s, " - "coverage_ratio: %s, augment_color: %s, no_flip: %s, warp_to_landmarks: %s, " - "alignments: %s, config: %s)", + "coverage_ratio: %s, augment_color: %s, no_flip: %s, no_warp: %s, " + "warp_to_landmarks: %s, alignments: %s, config: %s)", self.__class__.__name__, model_input_size, model_output_shapes, - coverage_ratio, augment_color, no_flip, warp_to_landmarks, + coverage_ratio, augment_color, no_flip, no_warp, warp_to_landmarks, list(alignments.keys()), config) self._config = config self._model_input_size = model_input_size @@ -90,6 +92,7 @@ def __init__(self, model_input_size, model_output_shapes, coverage_ratio, augmen self._augment_color = augment_color self._no_flip = no_flip self._warp_to_landmarks = warp_to_landmarks + self._no_warp = no_warp self._extract_versions = alignments["versions"] self._aligned_faces = alignments["aligned_faces"] self._masks = dict(masks=alignments.get("masks", None), @@ -244,12 +247,12 @@ def _process_batch(self, filenames, side): processed.update(self._processing.get_targets(batch)) # Random Warp # TODO change masks to have a input mask and a warped target mask - if not self._config["disable_warp"]: + if self._no_warp: + processed["feed"] = [self._processing.skip_warp(batch[..., :3])] + else: processed["feed"] = [self._processing.warp(batch[..., :3], self._warp_to_landmarks, **warp_kwargs)] - else: - processed["feed"] = [self._processing.skip_warp(batch[..., :3])] logger.trace("Processed batch: (filenames: %s, side: '%s', processed: %s)", filenames, diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 319b195a8b..26f6ef35a4 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -402,6 +402,7 @@ def _load_generator(self, output_index): self._model.coverage_ratio, not self._model.command_line_arguments.no_augment_color, self._model.command_line_arguments.no_flip, + self._model.command_line_arguments.no_warp, self._model.command_line_arguments.warp_to_landmarks, self._alignments, self._config) diff --git a/plugins/train/trainer/original_defaults.py b/plugins/train/trainer/original_defaults.py index 87a49f2eb9..be760eff44 100755 --- a/plugins/train/trainer/original_defaults.py +++ b/plugins/train/trainer/original_defaults.py @@ -84,14 +84,6 @@ rounding=1, min_max=(0, 75), group="image augmentation"), - disable_warp=dict( - default=False, - info="Disable warp augmentation. Warping is integral to the Neural Network training. If " - "you decide to disable warping, you should only do so towards the end of a model's " - "training session.", - datatype=bool, - group="image augmentation", - fixed=False), color_lightness=dict( default=30, From b1cfbe458c0bf123591348c54973d49297fd55ab Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 14 Feb 2021 16:49:55 +0000 Subject: [PATCH 360/981] Update extracted faces to use PNG EXIF data (#1123) Documentation - Update Usage.md, align.rst and image.rst lib.image.py - read_image - Remove hash return, add metadata return - Remove read_image_hash functions - Add read_image_meta functios - Replace encode_image_with_hash with encode_image (to store metadata) - Add png meta reading and writing functions - Update Image Loaders/Savers to handle metadata rather than hashes lib.training_data - Naming updates to remove references to hashes lib.align.Alignments - Add versioning notes - Increment alignments version to 2.1 - Deprecate hashing lookup functions - Replace filter_hashes with filter_faces lib.align.detected_face - DetectedFace - Remove hash property - Add png header data serializing/deserializing functions - Mask - Add png header data serializing/deserializing functions - add update_legacy_png_header function to update png meta data lib.cli.args - Deprecate alignments files for training - plugins.train.trainer - Update alignments/mask code to read png header data - scripts.convert - Aligned images folder - read data from png headers - scripts.extract - Write png header information and no longer store hash of face - tools.alignments - remove leftover-faces, merge and update-hashes jobs - Update jobs to use png meta data rather than hashes - tools.manual - Update extract code to output png meta data and don't store hashes - Perform check on launch that tool is not pointing at a faces folder tools.mask - Update to use png meta data tools.sort - Update to use png meta data --- USAGE.md | 2 - docs/full/lib/align.rst | 1 + docs/full/lib/image.rst | 8 +- lib/align/__init__.py | 2 +- lib/align/alignments.py | 51 ++- lib/align/detected_face.py | 158 +++++++-- lib/cli/args.py | 15 +- lib/image.py | 214 +++++++---- lib/training_data.py | 25 +- plugins/train/trainer/_base.py | 602 +++++++++++++++---------------- scripts/convert.py | 68 ++-- scripts/extract.py | 11 +- tools/alignments/alignments.py | 35 +- tools/alignments/cli.py | 28 +- tools/alignments/jobs.py | 630 ++++++++------------------------- tools/alignments/media.py | 144 +++++--- tools/manual/detected_faces.py | 28 +- tools/manual/manual.py | 28 +- tools/mask/mask.py | 88 +++-- tools/sort/cli.py | 16 +- tools/sort/sort.py | 78 +--- 21 files changed, 1061 insertions(+), 1171 deletions(-) diff --git a/USAGE.md b/USAGE.md index 1472b35237..49476007ad 100755 --- a/USAGE.md +++ b/USAGE.md @@ -75,8 +75,6 @@ When extracting faces for training, you are looking to gather around 500 to 5000 You do not want to extract every single frame from a video for training as from frame to frame the faces will be very similar. -If you plan to train with a mask or use the Warp to Landmarks option, then you will need to copy the output `alignments.json` file from your source frames folder into your output faces folder for training. If you have extracted from multiple sources, you can use the alignments tool to merge several `alignments.json` files together. - You can see the full list of arguments for extracting by hovering over the options in the GUI or passing the help flag. i.e: ```bash python faceswap.py extract -h diff --git a/docs/full/lib/align.rst b/docs/full/lib/align.rst index addd3bd8e1..bb449f805d 100644 --- a/docs/full/lib/align.rst +++ b/docs/full/lib/align.rst @@ -62,6 +62,7 @@ Handles detected face objects and their associated masks. ~lib.align.detected_face.BlurMask ~lib.align.detected_face.DetectedFace ~lib.align.detected_face.Mask + ~lib.align.detected_face.update_legacy_png_header .. rubric:: Module diff --git a/docs/full/lib/image.rst b/docs/full/lib/image.rst index 089d339cc1..8b0c081d6d 100755 --- a/docs/full/lib/image.rst +++ b/docs/full/lib/image.rst @@ -17,13 +17,15 @@ Handles loading and manipulation of images in Faceswap. ~lib.image.SingleFrameLoader ~lib.image.batch_convert_color ~lib.image.count_frames - ~lib.image.encode_image_with_hash + ~lib.image.encode_image ~lib.image.generate_thumbnail ~lib.image.hex_to_rgb + ~lib.image.png_read_meta + ~lib.image.png_write_meta ~lib.image.read_image ~lib.image.read_image_batch - ~lib.image.read_image_hash - ~lib.image.read_image_hash_batch + ~lib.image.read_image_meta + ~lib.image.read_image_meta_batch ~lib.image.rgb_to_hex .. rubric:: Module diff --git a/lib/align/__init__.py b/lib/align/__init__.py index e9a82fa53d..12b82f283a 100644 --- a/lib/align/__init__.py +++ b/lib/align/__init__.py @@ -3,4 +3,4 @@ associated objects. """ from .aligned_face import AlignedFace, _EXTRACT_RATIOS, get_matrix_scaling, get_centered_size, PoseEstimate, transform_image # noqa from .alignments import Alignments # noqa -from .detected_face import BlurMask, DetectedFace, Mask # noqa +from .detected_face import BlurMask, DetectedFace, Mask, update_legacy_png_header # noqa diff --git a/lib/align/alignments.py b/lib/align/alignments.py index 29bf08959b..2dad99200f 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -12,9 +12,16 @@ from lib.utils import FaceswapError logger = logging.getLogger(__name__) # pylint: disable=invalid-name -_VERSION = 2.0 +_VERSION = 2.1 +# VERSION TRACKING +# 1.0 - Never really existed. Basically any alignments file prior to version 2.0 +# 2.0 - Implementation of full head extract. Any alignments version below this will have used +# legacy extract +# 2.1 - Alignments data to extracted face PNG header. SHA1 hashes of faces no longer calculated +# or stored in alignments file + class Alignments(): """ The alignments file is a custom serialized ``.fsa`` file that holds information for each frame for a video or series of images. @@ -91,6 +98,9 @@ def hashes_to_frame(self): Notes ----- + This method is depractated and exists purely for updating legacy hash based alignments + to new png header storage in :class:`lib.align.update_legacy_png_header`. + The first time this property is referenced, the dictionary will be created and cached. Subsequent references will be made to this cached dictionary. """ @@ -108,6 +118,9 @@ def hashes_to_alignment(self): Notes ----- + This method is depractated and exists purely for updating legacy hash based alignments + to new png header storage in :class:`lib.align.update_legacy_png_header`. + The first time this property is referenced, the dictionary will be created and cached. Subsequent references will be made to this cached dictionary. """ @@ -533,29 +546,33 @@ def update_face(self, frame_name, face_index, face): logger.debug("Updating face %s for frame_name '%s'", face_index, frame_name) self._data[frame_name]["faces"][face_index] = face - def filter_hashes(self, hash_list, filter_out=False): - """ Remove faces from :attr:`data` based on a given hash list. + def filter_faces(self, filter_dict, filter_out=False): + """ Remove faces from :attr:`data` based on a given filter list. Parameters ---------- - hash_list: list - List of SHA1 hashes in `str` format to use as a filter against :attr:`data` + filter_dict: dict + Dictionary of source filenames ask key with a list of face indices to filter as value. filter_out: bool, optional ``True`` if faces should be removed from :attr:`data` when there is a corresponding - match in the given hash_list. ``False`` if faces should be kept in :attr:`data` when - there is a corresponding match in the given hash_list, but removed if there is no + match in the given filter_dict. ``False`` if faces should be kept in :attr:`data` when + there is a corresponding match in the given filter_dict, but removed if there is no match. Default: ``False`` """ - hashset = set(hash_list) - for filename, val in self._data.items(): - for idx, face in reversed(list(enumerate(val["faces"]))): - if ((filter_out and face.get("hash", None) in hashset) or - (not filter_out and face.get("hash", None) not in hashset)): - logger.verbose("Filtering out face: (filename: %s, index: %s)", filename, idx) - del val["faces"][idx] - else: - logger.trace("Not filtering out face: (filename: %s, index: %s)", - filename, idx) + logger.debug("filter_dict: %s, filter_out: %s", filter_dict, filter_out) + for source_frame, frame_data in self._data.items(): + face_indices = filter_dict.get(source_frame, []) + if filter_out: + filter_list = face_indices + else: + filter_list = [idx for idx in range(len(frame_data["faces"])) + if idx not in face_indices] + logger.trace("frame: '%s', filter_list: %s", source_frame, filter_list) + + for face_idx in reversed(sorted(filter_list)): + logger.verbose("Filtering out face: (filename: %s, index: %s)", + source_frame, face_idx) + del frame_data["faces"][face_idx] # << GENERATORS >> # def yield_faces(self): diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index 8785d69fc1..5030dd4a10 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -1,12 +1,16 @@ #!/usr/bin python3 """ Face and landmarks detection for faceswap.py """ import logging +import os +from hashlib import sha1 from zlib import compress, decompress import cv2 import numpy as np +from lib.image import encode_image, read_image +from lib.utils import FaceswapError from . import AlignedFace, _EXTRACT_RATIOS, get_centered_size logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -66,9 +70,6 @@ class DetectedFace(): mask: dict The generated mask(s) for the face as generated in :mod:`plugins.extract.mask`. Is a dict of {**name** (`str`): :class:`Mask`}. - hash: str - The hash of the face. This cannot be set until the file is saved due to image compression, - but will be set if loading data from :func:`from_alignment` """ def __init__(self, image=None, x=None, w=None, y=None, h=None, landmarks_xy=None, mask=None, filename=None): @@ -87,7 +88,6 @@ def __init__(self, image=None, x=None, w=None, y=None, h=None, landmarks_xy=None self.landmarks_xy = landmarks_xy self.thumbnail = None self.mask = dict() if mask is None else mask - self.hash = None self.aligned = None logger.trace("Initialized %s", self.__class__.__name__) @@ -201,17 +201,15 @@ def to_alignment(self): ------- alignment: dict The alignment dict will be returned with the keys ``x``, ``w``, ``y``, ``h``, - ``landmarks_xy``, ``mask``, ``hash``. The additional key ``thumb`` will be provided - if the detected face object contains a thumbnail. + ``landmarks_xy``, ``mask``. The additional key ``thumb`` will be provided if the + detected face object contains a thumbnail. """ - alignment = dict() - alignment["x"] = self.x - alignment["w"] = self.w - alignment["y"] = self.y - alignment["h"] = self.h - alignment["landmarks_xy"] = self.landmarks_xy - alignment["hash"] = self.hash - alignment["mask"] = {name: mask.to_dict() for name, mask in self.mask.items()} + alignment = dict(x=self.x, + w=self.w, + y=self.y, + h=self.h, + landmarks_xy=self.landmarks_xy, + mask={name: mask.to_dict() for name, mask in self.mask.items()}) if self.thumbnail is not None: alignment["thumb"] = self.thumbnail logger.trace("Returning: %s", alignment) @@ -228,8 +226,6 @@ def from_alignment(self, alignment, image=None, with_thumb=False): ``x``, ``w``, ``y``, ``h``, ``landmarks_xy``. Optionally the key ``thumb`` will be provided. This is for use in the manual tool and contains the compressed jpg thumbnail of the face to be allocated to :attr:`thumbnail. - Optionally the key ``hash`` will be provided, but not all use cases will know the - face hash at this time. Optionally the key ``mask`` will be provided, but legacy alignments will not have this key. image: numpy.ndarray, optional @@ -254,8 +250,6 @@ def from_alignment(self, alignment, image=None, with_thumb=False): if with_thumb: # Thumbnails currently only used for manual tool. Default to None self.thumbnail = alignment.get("thumb", None) - # Manual tool does not know the final hash so default to None - self.hash = alignment.get("hash", None) # Manual tool and legacy alignments will not have a mask self.aligned = None @@ -270,6 +264,42 @@ def from_alignment(self, alignment, image=None, with_thumb=False): "landmarks: %s, mask: %s)", self.x, self.w, self.y, self.h, self.landmarks_xy, self.mask) + def to_png_meta(self): + """ Return the detected face formatted for insertion into a png itxt header. + + returns: dict + The alignments dict will be returned with the keys ``x``, ``w``, ``y``, ``h``, + ``landmarks_xy`` and ``mask`` + """ + alignment = dict(x=self.x, + w=self.w, + y=self.y, + h=self.h, + landmarks_xy=self.landmarks_xy.tolist(), + mask={name: mask.to_png_meta() for name, mask in self.mask.items()}) + return alignment + + def from_png_meta(self, alignment): + """ Set the attributes of this class from alignments stored in a png exif header. + + Parameters + ---------- + alignment: dict + A dictionary entry for a face from alignments stored in a png exif header containing + the keys ``x``, ``w``, ``y``, ``h``, ``landmarks_xy`` and ``mask`` + """ + self.x = alignment["x"] + self.w = alignment["w"] + self.y = alignment["y"] + self.h = alignment["h"] + self.landmarks_xy = np.array(alignment["landmarks_xy"], dtype="float32") + self.mask = dict() + for name, mask_dict in alignment["mask"].items(): + self.mask[name] = Mask() + self.mask[name].from_dict(mask_dict) + logger.trace("Created from png exif header: (x: %s, w: %s, y: %s. h: %s, landmarks: %s, " + "mask: %s)", self.x, self.w, self.y, self.h, self.landmarks_xy, self.mask) + def _image_to_face(self, image): """ set self.image to be the cropped face from detected bounding box """ logger.trace("Cropping face from image") @@ -629,6 +659,25 @@ def to_dict(self): logger.trace({k: v if k != "mask" else type(v) for k, v in retval.items()}) return retval + def to_png_meta(self): + """ Convert the mask to a dictionary supported by png itxt headers. + + Returns + ------- + dict: + The :class:`Mask` for saving to an alignments file. Contains the keys ``mask``, + ``affine_matrix``, ``interpolator``, ``stored_size`` + """ + retval = dict() + for key in ("mask", "affine_matrix", "interpolator", "stored_size"): + val = getattr(self, self._attr_name(key)) + if isinstance(val, np.ndarray): + retval[key] = val.tolist() + else: + retval[key] = val + logger.trace({k: v if k != "mask" else type(v) for k, v in retval.items()}) + return retval + def from_dict(self, mask_dict): """ Populates the :class:`Mask` from a dictionary loaded from an alignments file. @@ -639,8 +688,11 @@ def from_dict(self, mask_dict): ``affine_matrix``, ``interpolator``, ``stored_size`` """ for key in ("mask", "affine_matrix", "interpolator", "stored_size"): - setattr(self, self._attr_name(key), mask_dict[key]) - logger.trace("%s - %s", key, mask_dict[key] if key != "mask" else type(mask_dict[key])) + val = mask_dict[key] + if key == "affine_matrix" and not isinstance(val, np.ndarray): + val = np.array(val, dtype="float64") + setattr(self, self._attr_name(key), val) + logger.trace("%s - %s", key, val if key != "mask" else type(val)) @staticmethod def _attr_name(dict_key): @@ -801,3 +853,69 @@ def _get_kwargs(self): for kword in self._kwarg_requirements[self._blur_type]} logger.trace("BlurMask kwargs: %s", retval) return retval + + +_HASHES_SEEN = dict() + + +def update_legacy_png_header(filename, alignments): + """ Update a legacy extracted face from pre v2.1 alignments by placing the alignment data for + the face in the png exif header for the given filename with the given alignment data. + + If the given file is not a .png then a png is created and the original file is removed + + Parameters + ---------- + filename: str + The image file to update + alignments: :class:`lib.align.alignments.Alignments` + The alignments data the contains the information to store in the image header. This must be + a v2.0 or less alignments file as later versions no longer store the face hash (unrequired) + + Returns + ------- + dict + The metadata that has been applied to the given image + """ + if alignments.version > 2.0: + raise FaceswapError("The faces being passed in do not correspond to the given Alignments " + "file. Please double check your sources and try again.") + # Track hashes for multiple files with the same hash. Not the most robust but should be + # effective enough + folder = os.path.dirname(filename) + if folder not in _HASHES_SEEN: + _HASHES_SEEN[folder] = dict() + hashes_seen = _HASHES_SEEN[folder] + + in_image = read_image(filename, raise_error=True) + in_hash = sha1(in_image).hexdigest() + hashes_seen[in_hash] = hashes_seen.get(in_hash, -1) + 1 + + alignment = alignments.hashes_to_alignment.get(in_hash) + if not alignment: + logger.debug("Alignments not found for image: '%s'", filename) + return None + + detected_face = DetectedFace() + detected_face.from_alignment(alignment) + # For dupe hash handling, make sure we get a different filename for repeat hashes + src_fname, face_idx = list(alignments.hashes_to_frame[in_hash].items())[hashes_seen[in_hash]] + orig_filename = "{}_{}.png".format(os.path.splitext(src_fname)[0], face_idx) + meta = dict(alignments=detected_face.to_png_meta(), + source=dict(alignments_version=alignments.version, + original_filename=orig_filename, + face_index=face_idx, + source_filename=src_fname, + source_is_video=False)) # Can't check so set false + + out_filename = f"{os.path.splitext(filename)[0]}.png" # Make sure saved file is png + out_image = encode_image(in_image, ".png", metadata=meta) + + with open(out_filename, "wb") as out_file: + out_file.write(out_image) + + if filename != out_filename: # Remove the old non-png: + logger.debug("Removing replaced face with deprecated extension: '%s'", filename) + os.remove(filename) + + return meta diff --git a/lib/cli/args.py b/lib/cli/args.py index 4e9a0d7339..e13303e4d6 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -873,9 +873,9 @@ def get_argument_list(): dest="alignments_path_a", default=None, group="faces", - help="Path to alignments file for training set A. Only required if you are using a " - "masked model or warp-to-landmarks is enabled. Defaults to " - "/alignments.json if not provided.")) + help="DEPRECATED - This option will be removed in a future update. Path to alignments " + "file for training set A. Defaults to /alignments.json if not " + "provided.")) argument_list.append(dict( opts=("-B", "--input-B"), action=DirFullPaths, @@ -893,9 +893,9 @@ def get_argument_list(): dest="alignments_path_b", default=None, group="faces", - help="Path to alignments file for training set B. Only required if you are using a " - "masked model or warp-to-landmarks is enabled. Defaults to " - "/alignments.json if not provided.")) + help="DEPRECATED - This option will be removed in a future update. Path to alignments " + "file for training set B. Defaults to /alignments.json if not " + "provided.")) argument_list.append(dict( opts=("-m", "--model-dir"), action=DirFullPaths, @@ -1060,8 +1060,7 @@ def get_argument_list(): group="augmentation", help="Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " - "warping. Alignments files for both sets of faces must be provided if using " - "this option.")) + "warping.")) argument_list.append(dict( opts=("-nf", "--no-flip"), action="store_true", diff --git a/lib/image.py b/lib/image.py index 6cfc18537a..3e0180eea4 100644 --- a/lib/image.py +++ b/lib/image.py @@ -5,11 +5,12 @@ import re import subprocess import os +import struct import sys from bisect import bisect from concurrent import futures -from hashlib import sha1 +from zlib import crc32 import cv2 import imageio @@ -230,7 +231,7 @@ def _initialize(self, index=0): imageio.plugins.ffmpeg.FfmpegFormat.Reader = FfmpegReader -def read_image(filename, raise_error=False, with_hash=False): +def read_image(filename, raise_error=False, with_metadata=False): """ Read an image file from a file location. Extends the functionality of :func:`cv2.imread()` by ensuring that an image was actually @@ -245,20 +246,22 @@ def read_image(filename, raise_error=False, with_hash=False): If ``True`` then any failures (including the returned image being ``None``) will be raised. If ``False`` then an error message will be logged, but the error will not be raised. Default: ``False`` - with_hash: bool, optional - If ``True`` then returns the image's sha1 hash with the image. Default: ``False`` + with_metadata: bool, optional + Only returns a value if the images loaded are extracted Faceswap faces. If ``True`` then + returns the Faceswap metadata stored with in a Face images .png exif header. + Default: ``False`` Returns ------- numpy.ndarray or tuple - If :attr:`with_hash` is ``False`` then returns a `numpy.ndarray` of the image in `BGR` - channel order. If :attr:`with_hash` is ``True`` then returns a `tuple` of (`numpy.ndarray`" - of the image in `BGR`, `str` of sha` hash of image) + If :attr:`with_metadata` is ``False`` then returns a `numpy.ndarray` of the image in `BGR` + channel order. If :attr:`with_metadata` is ``True`` then returns a `tuple` of + (`numpy.ndarray`" of the image in `BGR`, `dict` of face's Faceswap metadata) Example ------- >>> image_file = "/path/to/image.png" >>> try: - >>> image = read_image(image_file, raise_error=True, with_hash=False) + >>> image = read_image(image_file, raise_error=True, with_metadata=False) >>> except: >>> raise ValueError("There was an error") """ @@ -266,9 +269,16 @@ def read_image(filename, raise_error=False, with_hash=False): success = True image = None try: - image = cv2.imread(filename) - if image is None: - raise ValueError("Image is None") + if not with_metadata: + retval = cv2.imread(filename) + if retval is None: + raise ValueError("Image is None") + else: + with open(filename, "rb") as infile: + raw_file = infile.read() + metadata = png_read_meta(raw_file) + image = cv2.imdecode(np.frombuffer(raw_file, dtype="uint8"), cv2.IMREAD_UNCHANGED) + retval = (image, metadata) except TypeError as err: success = False msg = "Error while reading image (TypeError): '{}'".format(filename) @@ -291,7 +301,6 @@ def read_image(filename, raise_error=False, with_hash=False): if raise_error: raise Exception(msg) logger.trace("Loaded image: '%s'. Success: %s", filename, success) - retval = (image, sha1(image).hexdigest()) if with_hash else image return retval @@ -339,40 +348,59 @@ def read_image_batch(filenames): return batch -def read_image_hash(filename, output_shape=False): - """ Return the `sha1` hash of an image saved on disk. +def read_image_meta(filename): + """ Read the Faceswap metadata stored in an extracted face's exif header. Parameters ---------- filename: str - Full path to the image to be loaded. - output_shape: bool - If ``True`` then a tuple is returned with the shape tuple of the image as the final value. - Default: ``False`` + Full path to the image to be retrieve the meta information for. Returns ------- - str - The :func:`hashlib.hexdigest()` representation of the `sha1` hash of the given image. + dict + The output dictionary will contain the `width` and `height` of the png image as well as any + `itxt` information. Example ------- >>> image_file = "/path/to/image.png" - >>> image_hash = read_image_hash(image_file) + >>> metadata = read_image_meta(image_file) + >>> width = metadata["width] + >>> height = metadata["height"] + >>> faceswap_info = metadata["itxt"] """ - img = read_image(filename, raise_error=True) - retval = sha1(img).hexdigest() - if output_shape: - retval = (retval, img.shape) - logger.trace("filename: '%s', retval: %s", filename, retval) + retval = dict() + if os.path.splitext(filename)[-1] != ".png": + raise ValueError(f"Only png files are supported for reading exif data. ({filename})") + with open(filename, "rb") as infile: + chunk = infile.read(8) + if chunk != b"\x89PNG\r\n\x1a\n": + raise ValueError(f"Invalid header found in png: {filename}") + while True: + chunk = infile.read(8) + length, field = struct.unpack(">I4s", chunk) + logger.trace("Read chunk: (chunk: %s, length: %s, field: %s", chunk, length, field) + if not chunk or field == b"IDAT": + break + if field == b"IHDR": + # Get dimensions + chunk = infile.read(8) + retval["width"], retval["height"] = struct.unpack(">II", chunk) + length -= 8 + elif field == b"iTXt": + retval["itxt"] = eval(infile.read(length).split(b"\0\0\0\0\0", 1)[-1]) + break + infile.seek(length + 4, 1) + logger.trace("filename: %s, metadata: %s", filename, retval) return retval -def read_image_hash_batch(filenames, output_shape=False): - """ Return the `sha` hash of a batch of images +def read_image_meta_batch(filenames): + """ Read the Faceswap metadata stored in a batch extracted faces' exif headers. Leverages multi-threading to load multiple images from disk at the same time leading to vastly reduced image read times. Creates a generator to retrieve filenames - with their hashes as they are calculated. + with their metadata as they are calculated. Notes ----- @@ -383,38 +411,33 @@ def read_image_hash_batch(filenames, output_shape=False): ---------- filenames: list A list of ``str`` full paths to the images to be loaded. - output_shape: bool - If ``True`` then a 3rd item is added to the output tuple containing the shape of the read - image. Default: ``False`` Yields ------- - tuple: (`filename`, :func:`hashlib.hexdigest()` representation of the `sha1` hash of the image, - [optional shape tuple] ) + tuple + (**filename** (`str`), **metadata** (`dict`) ) + Example ------- >>> image_filenames = ["/path/to/image_1.png", "/path/to/image_2.png", "/path/to/image_3.png"] - >>> for filename, hash in read_image_hash_batch(image_filenames): + >>> for filename, meta in read_image_meta_batch(image_filenames): >>> """ logger.trace("Requested batch: '%s'", filenames) executor = futures.ThreadPoolExecutor() with executor: logger.debug("Submitting %s items to executor", len(filenames)) - read_hashes = {executor.submit(read_image_hash, - filename, - output_shape=output_shape): filename - for filename in filenames} + read_meta = {executor.submit(read_image_meta, filename): filename + for filename in filenames} logger.debug("Succesfully submitted %s items to executor", len(filenames)) - for future in futures.as_completed(read_hashes): - retval = (read_hashes[future], - *future.result()) if output_shape else (read_hashes[future], future.result()) + for future in futures.as_completed(read_meta): + retval = (read_meta[future], future.result()) logger.trace("Yielding: %s", retval) yield retval -def encode_image_with_hash(image, extension): - """ Encode an image, and get the encoded image back with its `sha1` hash. +def encode_image(image, extension, metadata=None): + """ Encode an image. Parameters ---------- @@ -422,11 +445,12 @@ def encode_image_with_hash(image, extension): The image to be encoded in `BGR` channel order. extension: str A compatible `cv2` image file extension that the final image is to be saved to. + metadata: dict, optional + Metadata for the image. If provided, and the extension is png, this information will be + written to the PNG itxt header. Default:``None`` Returns ------- - image_hash: str - The :func:`hashlib.hexdigest()` representation of the `sha1` hash of the encoded image encoded_image: bytes The image encoded into the correct file format @@ -434,11 +458,78 @@ def encode_image_with_hash(image, extension): ------- >>> image_file = "/path/to/image.png" >>> image = read_image(image_file) - >>> image_hash, encoded_image = encode_image_with_hash(image, ".jpg") + >>> encoded_image = encode_image(image, ".jpg") + """ + if metadata and extension.lower() != ".png": + raise ValueError("Metadata is only supported for .png images") + retval = cv2.imencode(extension, image)[1] + if metadata: + retval = np.frombuffer(png_write_meta(retval.tobytes(), metadata), dtype="uint8") + return retval + + +def png_write_meta(png, data): + """ Write Faceswap information to a png's iTXt field. + + Parameters + ---------- + png: bytes + The bytes encoded png file to write header data to + data: dict or bytes + The dictionary to write to the header. Can be pre-encoded as utf-8. + + Notes + ----- + This is a fairly stripped down and non-robust header writer to fit a very specific task. OpenCV + will not write any iTXt headers to the PNG file, so we make the assumption that the only iTXt + header that exists is the one that we created for storing alignments. + + References + ---------- + PNG Specification: https://www.w3.org/TR/2003/REC-PNG-20031110/ + + """ + if not isinstance(data, bytes): + data = str(data).encode("utf-8", "strict") + key = "faceswap".encode("latin-1", "strict") + + split = png.find(b"IDAT") - 4 + header, image = png[:split], png[split:] + + chunk = key + b"\0\0\0\0\0" + data + crc = struct.pack(">I", crc32(chunk, crc32(b"iTXt")) & 0xFFFFFFFF) + length = struct.pack(">I", len(chunk)) + retval = header + length + b"iTXt" + chunk + crc + image + return retval + + +def png_read_meta(png): + """ Read the Faceswap information stored in a png's iTXt field. + + Parameters + ---------- + png: bytes + The bytes encoded png file to read header data from + + Returns + ------- + dict + The Faceswap information stored in the PNG header + + Notes + ----- + This is a very stripped down, non-robust and non-secure header reader to fit a very specific + task. OpenCV will not write any iTXt headers to the PNG file, so we make the assumption that + the only iTXt header that exists is the one that Faceswap created for storing alignments. """ - encoded_image = cv2.imencode(extension, image)[1] - image_hash = sha1(cv2.imdecode(encoded_image, cv2.IMREAD_UNCHANGED)).hexdigest() - return image_hash, encoded_image + pointer = png.find(b"iTXt") - 4 + if pointer < 0: + logger.trace("No metadata in png") + return None + length = struct.unpack(">I", png[pointer:pointer + 4])[0] + pointer += 8 + data = png[pointer:pointer + length].split(b"\0\0\0\0\0", 1)[-1] + return eval(data) def generate_thumbnail(image, size=96, quality=60): @@ -963,7 +1054,7 @@ def _from_folder(self): if idx in self._skip_list: logger.trace("Skipping frame %s due to skip list") continue - image_read = read_image(filename, raise_error=False, with_hash=False) + image_read = read_image(filename, raise_error=False) retval = filename, image_read if retval[1] is None: logger.warning("Frame not loaded: '%s'", filename) @@ -973,8 +1064,8 @@ def _from_folder(self): def load(self): """ Generator for loading images from the given :attr:`location` - If :class:`FacesLoader` is in use then the sha1 hash of the image is added as the final - item in the output `tuple`. + If :class:`FacesLoader` is in use then the Faceswap metadata of the image stored in the + image exif file is added as the final item in the output `tuple`. Yields ------ @@ -982,9 +1073,8 @@ def load(self): The filename of the loaded image. image: numpy.ndarray The loaded image. - sha1_hash: str, (:class:`FacesLoader` only) - The sha1 hash of the loaded image. Only yielded if :class:`FacesLoader` is being - executed. + metadata: dict, (:class:`FacesLoader` only) + The Faceswap metadata associated with the loaded image. """ logger.debug("Initializing Load Generator") self._set_thread() @@ -1005,14 +1095,14 @@ def load(self): class FacesLoader(ImagesLoader): - """ Loads faces from a faces folder along with the face's hash. + """ Loads faces from a faces folder along with the face's Faceswap metadata. Examples -------- - Loading faces with their sha1 hash: + Loading faces with their Faceswap metadata: >>> loader = FacesLoader('/path/to/faces/folder') - >>> for filename, face, sha1_hash in loader.load(): + >>> for filename, face, metadata in loader.load(): >>> """ def __init__(self, path, skip_list=None, count=None): @@ -1031,15 +1121,15 @@ def _from_folder(self): The filename of the loaded image. image: numpy.ndarray The loaded image. - sha1_hash: str - The sha1 hash of the loaded image. + metadata: dict + The Faceswap metadata associated with the loaded image. """ logger.debug("Loading images from folder: '%s'", self.location) for idx, filename in enumerate(self.file_list): if idx in self._skip_list: logger.trace("Skipping face %s due to skip list") continue - image_read = read_image(filename, raise_error=False, with_hash=True) + image_read = read_image(filename, raise_error=False, with_metadata=True) retval = filename, *image_read if retval[1] is None: logger.warning("Face not loaded: '%s'", filename) diff --git a/lib/training_data.py b/lib/training_data.py index 834355f1d7..c4dc5ac528 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -440,34 +440,35 @@ def _get_closest_match(self, filenames, side, batch_src_points): lm_side = "a" if side == "b" else "b" landmarks = {key: aligned.landmarks for key, aligned in self._aligned_faces[lm_side].items()} - closest_hashes = [self._cache["nearest_landmarks"].get(filename) for filename in filenames] - if None in closest_hashes: + closest_matches = [self._cache["nearest_landmarks"].get(filename) + for filename in filenames] + if None in closest_matches: # Resize mismatched training image size landmarks sizes = {side: list(self._aligned_faces[side].values())[0].size for side in self._aligned_faces} if len(set(sizes.values())) > 1: scale = sizes[side] / sizes[lm_side] landmarks = {key: lms * scale for key, lms in landmarks.items()} - closest_hashes = self._cache_closest_hashes(filenames, batch_src_points, landmarks) + closest_matches = self._cache_closest_matches(filenames, batch_src_points, landmarks) - batch_dst_points = np.array([landmarks[choice(hsh)] for hsh in closest_hashes]) + batch_dst_points = np.array([landmarks[choice(fname)] for fname in closest_matches]) logger.trace("Returning: (batch_dst_points: %s)", batch_dst_points.shape) return batch_dst_points - def _cache_closest_hashes(self, filenames, batch_src_points, landmarks): + def _cache_closest_matches(self, filenames, batch_src_points, landmarks): """ Cache the nearest landmarks for this batch """ - logger.trace("Caching closest hashes") + logger.trace("Caching closest matches") dst_landmarks = list(landmarks.items()) dst_points = np.array([lm[1] for lm in dst_landmarks]) - batch_closest_hashes = list() + batch_closest_matches = list() for filename, src_points in zip(filenames, batch_src_points): closest = (np.mean(np.square(src_points - dst_points), axis=(1, 2))).argsort()[:10] - closest_hashes = tuple(dst_landmarks[i][0] for i in closest) - self._cache["nearest_landmarks"][filename] = closest_hashes - batch_closest_hashes.append(closest_hashes) - logger.trace("Cached closest hashes") - return batch_closest_hashes + closest_matches = tuple(dst_landmarks[i][0] for i in closest) + self._cache["nearest_landmarks"][filename] = closest_matches + batch_closest_matches.append(closest_matches) + logger.trace("Cached closest matches") + return batch_closest_matches class ImageAugmentation(): diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 26f6ef35a4..f258900767 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -12,6 +12,7 @@ import os import time +from concurrent import futures from functools import partial import cv2 @@ -21,8 +22,9 @@ from tensorflow.python.framework import errors_impl as tf_errors from tqdm import tqdm -from lib.align import Alignments, AlignedFace, DetectedFace, get_centered_size -from lib.image import read_image_hash_batch +from lib.align import (Alignments, AlignedFace, DetectedFace, get_centered_size, + update_legacy_png_header) +from lib.image import read_image_meta_batch from lib.training_data import TrainingDataGenerator from lib.utils import FaceswapError, get_backend, get_folder, get_image_paths from plugins.train._config import Config @@ -78,12 +80,13 @@ def __init__(self, model, images, batch_size, configfile): self._model.state.add_session_batchsize(batch_size) self._images = images self._sides = sorted(key for key in self._images.keys()) + alignment_data = self._get_alignments_data() self._feeder = _Feeder(images, self._model, batch_size, self._config, - self._get_alignments_data()) + alignment_data) self._tensorboard = self._set_tensorboard() self._samples = _Samples(self._model, @@ -125,6 +128,8 @@ def _get_alignments_data(self): """ Extrapolate alignments and masks from the alignments file into a `dict` for the training data generator. + Removes any images from :attr:`_images` if they do not have alignment data attached. + Returns ------- dict: @@ -136,13 +141,8 @@ def _get_alignments_data(self): penalized_loss = self._model.config["penalized_mask_loss"] alignments = _TrainingAlignments(self._model, self._images) - if (any(version == 1.0 for version in alignments.versions.values()) - and self._config["centering"] != "legacy"): - logger.warning("You are using legacy extracted faces but have selected '%s' " - "centering which is incompatible. Switching centering to 'legacy'", - self._config["centering"]) - self._config["centering"] = "legacy" - self._model.config["centering"] = "legacy" + # Update centering if it has been changed by legacy facesets in TrainingAlignments + self._config["centering"] = self._model.config["centering"] retval = dict(aligned_faces=alignments.aligned_faces, versions=alignments.versions) @@ -159,6 +159,13 @@ def _get_alignments_data(self): logger.debug({key: {k: v if isinstance(v, float) else len(v) for k, v in val.items()} for key, val in retval.items()}) + + # Replace _images with list containing valid alignment data + for side, aligned_faces in alignments.aligned_faces.items(): + if len(aligned_faces) != len(self._images[side]): + logger.info("Updating training images list with images containing valid metadata " + "for side '%s'", side.upper()) + self._images[side] = list(aligned_faces.keys()) return retval def _set_tensorboard(self): @@ -1077,24 +1084,16 @@ def __init__(self, model, image_list): self.__class__.__name__, model, {k: len(v) for k, v in image_list.items()}) self._args = model.command_line_arguments self._config = model.config - self._alignments_paths = self._get_alignments_paths() - self._hashes, sizes = self._get_image_hashes(image_list) self._alignments_version = dict() - self._detected_faces = dict() + self._image_sizes = {key: None for key in image_list} + self._detected_faces = self._load_detected_faces(image_list) + self._update_legacy_facesets() - self._load_alignments() - self._check_all_faces() - - self._aligned_faces = self._get_aligned_faces(sizes) + self._validity_check() + self._aligned_faces = self._get_aligned_faces() logger.debug("Initialized %s", self.__class__.__name__) - @property - def aligned_faces(self): - """ dict: The "a", "b" keys for each side, containing a sub-dictionary with the - filename as key and :class:`lib.faces.detected_face.aligned` object as value. """ - return self._aligned_faces - @property def versions(self): """ dict: The "a", "b" keys for each side, with value being the alignment file version @@ -1102,63 +1101,271 @@ def versions(self): extracted faces are legacy or full-head extracts. """ return self._alignments_version - def _get_alignments_paths(self): - """ Obtain the alignments file paths from the command line arguments passed to the model. + @property + def aligned_faces(self): + """ dict: The "a", "b" keys for each side, containing a sub-dictionary with the + filename as key and :class:`lib.align.AlignedFace` object as value. """ + return self._aligned_faces + + # <<< LOAD DETECTED FACE INFORMATION FROM PNG HEADER >>> + def _load_detected_faces(self, image_list): + """ Obtain the metadata from the png training image headers for all images used for + training. - If the argument does not exist or is empty, then scan the input folder for an alignments - file. + The Faceswap alignments data is returned as a dictionary, whist :attr:`_image_sizes` is + populated from the training image metadata + + Parameters + ---------- + image_list: dict + The file paths for the images to be trained on for each side. The dictionary should + contain 2 keys ("a" and "b") with the values being a list of full paths corresponding + to each side. Returns ------- dict - The alignments paths for each of the source and destination faces. Key is the - side, value is the path to the alignments file + For keys "a" and "b" the values are a ``dict`` with the key being the filename of the + training image and the value being the :class:`lib.align.DetectedFace` object + """ + metadata = dict() + for side, filelist in image_list.items(): + meta_side = dict() + logger.debug("side: %s, file count: %s", side, len(filelist)) + for filename, meta in tqdm(read_image_meta_batch(filelist), + desc="Reading training images ({})".format(side.upper()), + total=len(filelist), + leave=False): + + self._validate_image_size(side, filename, meta["width"], meta["height"]) + + if "itxt" not in meta or "alignments" not in meta["itxt"]: + meta_side[filename] = None + else: + alignments_version = meta["itxt"]["source"]["alignments_version"] + self._alignments_version.setdefault(side, set()).add(alignments_version) + detected_face = DetectedFace() + detected_face.from_png_meta(meta["itxt"]["alignments"]) + meta_side[filename] = detected_face + metadata[side] = meta_side + return metadata + + def _validate_image_size(self, side, filename, width, height): + """ Validate that the images are square and that the sizes for all image in a side are + the same. + + Parameters + ---------- + side: ["a" or "b"] + The training side that is being processed + filename: str + The filename of the image that is being validated + width: int + The width of the image to be validated + height: int + The height of the image to be validated Raises ------ FaceswapError - If at least one alignments file does not exist + If the image to be checked is not square or is of a different size of any other image + for the current side, an error is raised. """ - retval = dict() - for side in ("a", "b"): - alignments_path = getattr(self._args, "alignments_path_{}".format(side)) - if not alignments_path: - image_path = getattr(self._args, "input_{}".format(side)) - alignments_path = os.path.join(image_path, "alignments.fsa") - if not os.path.exists(alignments_path): - raise FaceswapError("Alignments file does not exist: `{}`".format(alignments_path)) - retval[side] = alignments_path - logger.debug("Alignments paths: %s", retval) - return retval + # Add the image size to the sizes dictionary if this is the first image + if not self._image_sizes[side]: + self._image_sizes[side] = width + + # Validate image is square + if width != height: + msg = ("Training images must be created by the extraction process and must be " + "square.\nThe image '{}' has dimensions {}x{} so the process cannot " + "continue.\nThere may be more images with these issues. Please double " + "check your dataset".format(filename, width, height)) + raise FaceswapError(msg) - def _get_aligned_faces(self, input_sizes): - """ Pre-generate aligned faces as they are needed for all training functions. + # Validate image is the same size as the other images for the side + if width != self._image_sizes[side]: + msg = ("All training images for each side must be of the same size.\nImages " + "in side '{}' have mismatched sizes {} and {}.\nPlease double check " + "your dataset".format(side.upper(), self._image_sizes[side], width)) + raise FaceswapError(msg) + + def _update_legacy_facesets(self): + """ Update the png header data for legacy face sets that do not contain the meta data in + the exif header. + """ + if self._validate_metadata(output_warning=False): + logger.debug("All faces contain valid header information") + return + + for side, png_meta in self._detected_faces.items(): + if all(png_meta.values()): + continue + filenames = [filename for filename, meta in png_meta.items() if not meta] + logger.info("Legacy faces discovered for side '%s'. Updating %s images...", + side.upper(), len(filenames)) + alignments = Alignments(*os.path.split(self._get_alignments_path(side))) + self._alignments_version.setdefault(side, set()).add(alignments.version) + + executor = futures.ThreadPoolExecutor() + with executor: + images = {executor.submit(update_legacy_png_header, filename, alignments): filename + for filename in filenames} + + for future in tqdm( + futures.as_completed(images), + desc="Updating legacy training images ({})".format(side.upper()), + total=len(filenames), + leave=False): + detected_face = DetectedFace() + detected_face.from_png_meta(future.result()) + png_meta[images[future]] = detected_face + + def _get_alignments_path(self, side): + """ Obtain the path to an alignments file for the given training side. + + Used for updating legacy facesets to contain the meta information within the image header + + Parameters + ---------- + side: ["a" or "b"] + The training side to obtain the alignments file for. + + Returns + ------- + str + The full path to the training alignments file + + Raises + ------ + FaceswapError + If an alignments file cannot be located + """ + alignments_path = getattr(self._args, "alignments_path_{}".format(side)) + if not alignments_path: + image_path = getattr(self._args, "input_{}".format(side)) + alignments_path = os.path.join(image_path, "alignments.fsa") + if not os.path.exists(alignments_path): + msg = ("You are using a legacy faceset that does not contain embedded " + "meta-information. An alignments file must be provided so that these files can " + "be updated.\n" + f"Alignments file does not exist: '{alignments_path}'") + raise FaceswapError(msg) + return alignments_path + + # <<< VALIDATE LOADED DETECTED FACE INFORMATION >>> + def _validity_check(self): + """ Check the validity of the finally loaded data. + + Ensure that each side has consistent alignments versions. + Ensure that each side has a full compliment of metadata. + """ + invalid = [side.upper() + for side, version in self._alignments_version.items() if len(version) > 1] + if invalid: + raise FaceswapError("Mixing legacy and full head extracted facesets is not supported. " + "The following side(s) contain a mix of extracted face " + "types: {}".format(invalid)) + # Replace check alignments version sets with actual floats + self._alignments_version = {key: val.pop() + for key, val in self._alignments_version.items()} + + if 1.0 in self._alignments_version.values() and self._config["centering"] != "legacy": + logger.warning("You are using legacy extracted faces but have selected '%s' " + "centering which is incompatible. Switching centering to 'legacy'", + self._config["centering"]) + self._config["centering"] = "legacy" + + self._validate_metadata(output_warning=True) + self._validate_masks() + + def _validate_metadata(self, output_warning=True): + """ Validate that all images to be trained on have associated alignments data. If not + generate a warning. Parameters ---------- - input_sizes: dict - The training image sizes in pixels for side `a` and `b` as they are saved on disk + output_warning: bool, optional + If ``True`` outputs a warning that images are missing alignments data. + + Returns + ------- + bool + ``True`` if all images have valid metadata otherwise ``False`` + """ + all_valid = {side: all(val.values()) for side, val in self._detected_faces.items()} + if all(all_valid.values()): + return True + if not output_warning: + return False + + for side, valid in all_valid.items(): + if valid: + continue + invalid = [filename + for filename, meta in self._detected_faces[side].items() if not meta] + + logger.warning("Data for training side '%s' contains %s faces that do not contain " + "valid metadata and will be excluded from training.", + side.upper(), len(invalid)) + logger.warning("Run in VERBOSE mode if you wish to see a list of these files.") + logger.verbose("Side '%s' images missing metadata: %s", side.upper(), + sorted(os.path.basename(fname) for fname in invalid)) + # Remove images without metadata + self._detected_faces[side] = {key: val + for key, val in self._detected_faces[side].items() + if val} + return False + + def _validate_masks(self): + """ Validate the the loaded metadata all contain the masks required for training. + + Raises + ------ + FaceswapError + If at least one face in the training data does not contain the selected mask type + """ + mask_type = self._config["mask_type"] + invalid = {side: [filename for filename, detected_face in faces.items() + if mask_type not in detected_face.mask] + for side, faces in self._detected_faces.items()} + if any(invalid.values()): + msg = ("You have selected the Mask Type '{}' in your training configuration options " + "but at least one face does not have this mask type stored for it.\nYou should " + "select a mask type that exists within your face data, or generate the " + "required masks with the Mask Tool.".format(mask_type)) + for side, filenames in invalid.items(): + available = set(mask + for det_face in self._detected_faces[side].values() + for mask in det_face.mask) + msg += ("\n{} faces in side {} do not contain the mask '{}'. Available " + "masks: {}".format(len(filenames), side.upper(), mask_type, available)) + raise FaceswapError(msg) + + # <<< LOAD REQUIRED DATA FOR TRAINING >>> + def _get_aligned_faces(self): + """ Pre-generate aligned faces as they are needed for all training functions. Returns ------- dict The "a", "b" keys for each side, containing a sub-dictionary with the - filename as key and :class:`lib.faces.detected_face.AlignedFace` object as value. + filename as key and :class:`lib.align.AlignedFace` object as value. """ + logger.debug("Loading aligned faces: %s", + {k: len(v) for k, v in self._detected_faces.items()}) retval = dict() for side, detected_faces in self._detected_faces.items(): - ret_side = dict() + retval[side] = dict() size = get_centered_size("legacy" if self._alignments_version[side] == 1.0 else "head", self._config["centering"], - input_sizes[side]) - for fhash, face in detected_faces.items(): - face.aligned = AlignedFace(face.landmarks_xy, - centering=self._config["centering"], - size=size, - is_aligned=True) - for filename in self._hash_to_filenames(side, fhash): - ret_side[filename] = face.aligned - retval[side] = ret_side + self._image_sizes[side]) + for filename, face in detected_faces.items(): + retval[side][filename] = AlignedFace(face.landmarks_xy, + centering=self._config["centering"], + size=size, + is_aligned=True) + logger.debug("Loaded aligned faces: %s", {k: len(v) for k, v in retval.items()}) return retval # Get masks @@ -1167,38 +1374,19 @@ def masks(self): """ dict: The :class:`lib.align.Mask` objects of requested mask type for keys a" and "b" """ - retval = {side: self._get_masks(side, detected_faces) - for side, detected_faces in self._detected_faces.items()} + retval = dict() + for side, faces in self._detected_faces.items(): + retval[side] = dict() + for filename, detected_face in faces.items(): + mask = detected_face.mask[self._config["mask_type"]] + mask.set_blur_and_threshold(blur_kernel=self._config["mask_blur_kernel"], + threshold=self._config["mask_threshold"]) + if self._alignments_version[side] > 1.0 and self._config["centering"] == "legacy": + mask.set_sub_crop(self._aligned_faces[side][filename].pose.offset["face"] * -1) + retval[side][filename] = mask logger.trace(retval) return retval - def _get_masks(self, side, detected_faces): - """ For each face, obtain the mask and set the requested blurring and threshold level. - - Parameters - ---------- - side: {"a" or "b"} - The side currently being processed - detected_faces: dict - Key is the hash of the face, value is the corresponding - :class:`lib.align.DetectedFace` object - - Returns - ------- - dict - The face filenames as keys with the :class:`lib.align.Mask` as value. - """ - masks = dict() - for fhash, face in detected_faces.items(): - mask = face.mask[self._config["mask_type"]] - mask.set_blur_and_threshold(blur_kernel=self._config["mask_blur_kernel"], - threshold=self._config["mask_threshold"]) - if self._alignments_version[side] > 1.0 and self._config["centering"] == "legacy": - mask.set_sub_crop(face.aligned.pose.offset["face"] * -1) - for filename in self._hash_to_filenames(side, fhash): - masks[filename] = mask - return masks - @property def masks_eye(self): """ dict: filename mapping to zip compressed eye masks for keys "a" and "b" """ @@ -1216,12 +1404,16 @@ def masks_mouth(self): def _get_landmarks_masks(self, side, detected_faces, area): """ Obtain the area landmarks masks for the given area. + A :func:`functools.partial` is returned rather than the full compressed mask to speed up + pre-loading. The partials are expanded the first time they are accessed within the training + loop. + Parameters ---------- side: {"a" or "b"} The side currently being processed detected_faces: dict - Key is the hash of the face, value is the corresponding + Key is the filename of the face, value is the corresponding :class:`lib.align.DetectedFace` object area: {"eyes" or "mouth"} The area of the face to obtain the mask for @@ -1229,242 +1421,24 @@ def _get_landmarks_masks(self, side, detected_faces, area): Returns ------- dict - The face filenames as keys with the zip compressed mask as value. + The face filenames as keys with the :func:`functools.partial` of the mask as value """ logger.trace("side: %s, detected_faces: %s, area: %s", side, detected_faces, area) masks = dict() size = list(self._aligned_faces[side].values())[0].size - for fhash, face in detected_faces.items(): - mask = partial(face.get_landmark_mask, - size, - area, - aligned=True, - centering=self._config["centering"], - dilation=size // 32, - blur_kernel=size // 16, - as_zip=True) - for filename in self._hash_to_filenames(side, fhash): - masks[filename] = mask + for filename, face in detected_faces.items(): + masks[filename] = partial(face.get_landmark_mask, + size, + area, + aligned=True, + centering=self._config["centering"], + dilation=size // 32, + blur_kernel=size // 16, + as_zip=True) logger.trace("side: %s, area: %s, masks: %s", side, area, {key: type(val) for key, val in masks.items()}) return masks - # Hashes for image folders - @classmethod - def _get_image_hashes(cls, image_list): - """ Return the hashes for all images used for training. - - Parameters - ---------- - image_list: dict - The file paths for the images to be trained on for each side. The dictionary should - contain 2 keys ("a" and "b") with the values being a list of full paths corresponding - to each side. - - Returns - ------- - dict - For keys "a" and "b" the values are a ``dict`` with the key being the sha1 hash and - the value being a list of filenames that correspond to the hash for images that exist - within the training data folder - """ - hashes = {key: dict() for key in image_list} - sizes = {key: None for key in image_list} - for side, filelist in image_list.items(): - logger.debug("side: %s, file count: %s", side, len(filelist)) - for filename, hsh, shape in tqdm( - read_image_hash_batch(filelist, output_shape=True), - desc="Reading training images ({})".format(side.upper()), - total=len(filelist), - leave=False): - hashes[side].setdefault(hsh, list()).append(filename) - if shape[0] != shape[1]: - msg = ("Training images must be created by the extraction process and must be " - "square.\nThe image '{}' has dimensions {}x{} so the process cannot " - "continue.\nThere may be more images with these issues. Please double " - "check your dataset".format(filename, shape[1], shape[0])) - raise FaceswapError(msg) - if not sizes[side]: - sizes[side] = shape[0] - if shape[0] != sizes[side]: - msg = ("All training images for each side must be of the same size.\nImages " - "in side '{}' have mismatched sizes {} and {}.\nPlease double check " - "your dataset".format(side.upper(), sizes[side], shape[0])) - raise FaceswapError(msg) - logger.trace(hashes, sizes) - return hashes, sizes - - # Hashes for Detected Faces - def _load_alignments(self): - """ Load the alignments and convert to :class:`lib.align.DetectedFace` objects. - - Assign the alignments file version to :attr:`_alignments_version` and the converted - detected faces to :attr:`_detected_faces` for each side - """ - logger.debug("Loading alignments") - for side, fullpath in self._alignments_paths.items(): - logger.debug("side: '%s', path: '%s'", side, fullpath) - path, filename = os.path.split(fullpath) - alignments = Alignments(path, filename=filename) - self._detected_faces[side] = self._to_detected_faces(alignments, side) - self._alignments_version[side] = alignments.version - - if 1.0 in self._alignments_version.values() and self._config["centering"] != "legacy": - logger.debug("Updating alignments config to legacy for old facesets") - self._config["centering"] = "legacy" - - logger.debug("alignments_versions: %s, detected_faces: %s, ", self._alignments_version, - {k: len(v) for k, v in self._detected_faces.items()}) - - def _to_detected_faces(self, alignments, side): - """ Convert alignments to DetectedFace objects. - - Filter the detected faces to only those that exist in the training folders. - - Parameters - ---------- - alignments: :class:`lib.align.Alignments` - The alignments for the current faces - side: {"a" or "b"} - The side being processed - - Returns - ------- - dict - key is sha1 hash of face, value is the corresponding - :class:`lib.align.DetectedFace` object - """ - skip_count = 0 - dupe_count = 0 - side_hashes = set(self._hashes[side]) - detected_faces = dict() - for _, faces, _, filename in alignments.yield_faces(): - for idx, face in enumerate(faces): - if face["hash"] in detected_faces: - dupe_count += 1 - logger.debug("Face already exists, skipping: '%s'", filename) - if not self._validate_face(face, filename, idx, side, side_hashes): - skip_count += 1 - continue - detected_face = DetectedFace() - detected_face.from_alignment(face) - detected_faces[face["hash"]] = detected_face - logger.debug("Detected Faces count: %s, Skipped faces count: %s, duplicate faces " - "count: %s", len(detected_faces), skip_count, dupe_count) - if skip_count != 0: - logger.warning("%s alignments have been removed as their corresponding faces do not " - "exist in the input folder for side %s. Run in verbose mode if you " - "wish to see which alignments have been excluded.", - skip_count, side.upper()) - return detected_faces - - # Validation - def _validate_face(self, face, filename, idx, side, side_hashes): - """ Validate that the currently processing face has a corresponding hash entry and the - requested mask exists - - Parameters - ---------- - face: dict - A face retrieved from an alignments file - filename: str - The original frame filename that the given face comes from - idx: int - The index of the face in the frame - side: {'A', 'B'} - The side that this face belongs to - side_hashes: set - A set of hashes that exist in the alignments folder for these faces - - Returns - ------- - bool - ``True`` if the face is valid otherwise ``False`` - - Raises - ------ - FaceswapError - If the current face doesn't pass validation - """ - mask_type = self._config["mask_type"] - if mask_type is not None and "mask" not in face: - msg = ("You have selected a Mask Type in your training configuration options but at " - "least one face has no mask stored for it.\nYou should generate the required " - "masks with the Mask Tool or set the Mask Type configuration option to `none`." - "\nThe face that caused this failure was side: `{}`, frame: `{}`, index: {}. " - "However there are probably more faces without masks".format( - side.upper(), filename, idx)) - raise FaceswapError(msg) - - if mask_type is not None and mask_type not in face["mask"]: - msg = ("At least one of your faces does not have the mask `{}` stored for it.\nYou " - "should run the Mask Tool to generate this mask for your faceset or " - "select a different mask in the training configuration options.\n" - "The face that caused this failure was [side: `{}`, frame: `{}`, index: {}]. " - "The masks that exist for this face are: {}.\nBe aware that there are probably " - "more faces without this Mask Type".format( - mask_type, side.upper(), filename, idx, list(face["mask"].keys()))) - raise FaceswapError(msg) - - if face["hash"] not in side_hashes: - logger.verbose("Skipping alignment for non-existant face in frame '%s' index: %s", - filename, idx) - return False - return True - - def _check_all_faces(self): - """ Ensure that all faces in the training folder exist in the alignments file. - If not, output missing filenames - - Raises - ------ - FaceswapError - If there are faces in the training folder which do not exist in the alignments file - """ - logger.debug("Checking faces exist in alignments") - missing_alignments = dict() - for side, train_hashes in self._hashes.items(): - align_hashes = set(self._detected_faces[side]) - if not align_hashes.issuperset(set(train_hashes)): - missing_alignments[side] = [ - os.path.basename(filename) - for hsh, filenames in train_hashes.items() - for filename in filenames - if hsh not in align_hashes] - if missing_alignments: - msg = ("There are faces in your training folder(s) which do not exist in your " - "alignments file. Training cannot continue. See above for a full list of " - "files missing alignments.") - for side, filelist in missing_alignments.items(): - logger.error("Faces missing alignments for side %s: %s", - side.capitalize(), filelist) - raise FaceswapError(msg) - - # Utils - def _hash_to_filenames(self, side, face_hash): - """ For a given hash return all the filenames that match for the given side. - - Notes - ----- - Multiple faces can have the same hash, so this makes sure that all filenames are updated - for all instances of a hash. - - Parameters - ---------- - side: {"a" or "b"} - The side currently being processed - face_hash: str - The sha1 hash of the face to obtain the filename for - - Returns - ------- - list - The filenames that exist for the given hash - """ - retval = self._hashes[side][face_hash] - logger.trace("side: %s, hash: %s, filenames: %s", side, face_hash, retval) - return retval - def _stack_images(images): """ Stack images evenly for preview. diff --git a/scripts/convert.py b/scripts/convert.py index 86754cdf5b..6a5d2c3eae 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -15,9 +15,9 @@ from scripts.fsmedia import Alignments, PostProcess, finalize from lib.serializer import get_serializer from lib.convert import Converter -from lib.align import AlignedFace, DetectedFace +from lib.align import AlignedFace, DetectedFace, update_legacy_png_header from lib.gpu_stats import GPUStats -from lib.image import read_image_hash, ImagesLoader +from lib.image import read_image_meta_batch, ImagesLoader from lib.multithreading import MultiThread, total_cpus from lib.queue_manager import queue_manager from lib.utils import FaceswapError, get_backend, get_folder, get_image_paths @@ -1038,41 +1038,61 @@ def _remove_skipped_faces(self): """ If the user has specified an input aligned directory, remove any non-matching faces from the alignments file. """ logger.debug("Filtering Faces") - face_hashes = self._get_face_hashes() - if not face_hashes: - logger.debug("No face hashes. Not skipping any faces") + accept_dict = self._get_face_metadata() + if not accept_dict: + logger.debug("No aligned face data. Not skipping any faces") return pre_face_count = self._alignments.faces_count - self._alignments.filter_hashes(face_hashes, filter_out=False) + self._alignments.filter_faces(accept_dict, filter_out=False) logger.info("Faces filtered out: %s", pre_face_count - self._alignments.faces_count) - def _get_face_hashes(self): + def _get_face_metadata(self): """ Check for the existence of an aligned directory for identifying which faces in the - target frames should be swapped. + target frames should be swapped. If it exists, scan the folder for face's metadata Returns ------- - list - A list of face hashes that exist in the given input aligned directory. + dict + Dictionary of source frame names with a list of associated face indices to be skipped """ - face_hashes = list() + retval = dict() input_aligned_dir = self._args.input_aligned_dir if input_aligned_dir is None: logger.verbose("Aligned directory not specified. All faces listed in the " "alignments file will be converted") - elif not os.path.isdir(input_aligned_dir): + return retval + if not os.path.isdir(input_aligned_dir): logger.warning("Aligned directory not found. All faces listed in the " "alignments file will be converted") - else: - file_list = get_image_paths(input_aligned_dir) - logger.info("Getting Face Hashes for selected Aligned Images") - for face in tqdm(file_list, desc="Hashing Faces"): - face_hashes.append(read_image_hash(face)) - logger.debug("Face Hashes: %s", (len(face_hashes))) - if not face_hashes: - raise FaceswapError("Aligned directory is empty, no faces will be converted!") - if len(face_hashes) <= len(self._input_images) / 3: - logger.warning("Aligned directory contains far fewer images than the input " - "directory, are you sure this is the right folder?") - return face_hashes + return retval + + log_once = False + filelist = get_image_paths(input_aligned_dir) + for fullpath, metadata in tqdm(read_image_meta_batch(filelist), + total=len(filelist), + desc="Reading Face Data", + leave=False): + if "itxt" not in metadata or "source" not in metadata["itxt"]: + # UPDATE LEGACY FACES FROM ALIGNMENTS FILE + if not log_once: + logger.warning("Legacy faces discovered in '%s'. These faces will be updated", + input_aligned_dir) + log_once = True + data = update_legacy_png_header(fullpath, self._alignments) + if not data: + raise FaceswapError( + "Some of the faces being passed in from '{}' could not be matched to the " + "alignments file '{}'\nPlease double check your sources and try " + "again.".format(input_aligned_dir, self._alignments.file)) + meta = data["source"] + else: + meta = metadata["itxt"]["source"] + retval.setdefault(meta["source_filename"], list()).append(meta["face_index"]) + + if not retval: + raise FaceswapError("Aligned directory is empty, no faces will be converted!") + if len(retval) <= len(self._input_images) / 3: + logger.warning("Aligned directory contains far fewer images than the input " + "directory, are you sure this is the right folder?") + return retval diff --git a/scripts/extract.py b/scripts/extract.py index 5646a8442c..28f7c42307 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -7,7 +7,7 @@ from tqdm import tqdm -from lib.image import encode_image_with_hash, generate_thumbnail, ImagesLoader, ImagesSaver +from lib.image import encode_image, generate_thumbnail, ImagesLoader, ImagesSaver from lib.multithreading import MultiThread from lib.utils import get_folder from plugins.extract.pipeline import Extractor, ExtractMedia @@ -284,9 +284,16 @@ def _output_faces(self, saver, extract_media): final_faces = list() filename = os.path.splitext(os.path.basename(extract_media.filename))[0] extension = ".png" + for idx, face in enumerate(extract_media.detected_faces): output_filename = "{}_{}{}".format(filename, str(idx), extension) - face.hash, image = encode_image_with_hash(face.aligned.face, extension) + meta = dict(alignments=face.to_png_meta(), + source=dict(alignments_version=self._alignments.version, + original_filename=output_filename, + face_index=idx, + source_filename=extract_media.filename, + source_is_video=self._images.is_video)) + image = encode_image(face.aligned.face, extension, metadata=meta) if not self._args.skip_saving_faces: saver.save(output_filename, image) diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index 6cda3ef217..270c3176b5 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -1,11 +1,10 @@ #!/usr/bin/env python3 """ Tools for manipulating the alignments serialized file """ -import sys import logging from .media import AlignmentData -from .jobs import (Check, Draw, Extract, Merge, Rename, # noqa pylint: disable=unused-import - RemoveFaces, Sort, Spatial, UpdateHashes) +from .jobs import (Check, Draw, Extract, Rename, # noqa pylint: disable=unused-import + RemoveFaces, Sort, Spatial) logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -24,41 +23,15 @@ class Alignments(): # pylint:disable=too-few-public-methods def __init__(self, arguments): logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) self.args = arguments - self.alignments = self._load_alignments() + self.alignments = AlignmentData(self.args.alignments_file) logger.debug("Initialized %s", self.__class__.__name__) - def _load_alignments(self): - """ Loads the given alignments file(s) prior to running the selected job. - - Returns - ------- - :class:`~tools.alignments.media.AlignmentData` or list - The alignments data formatted for use by the alignments tool. If multiple alignments - files have been selected, then this will be a list of - :class:`~tools.alignments.media.AlignmentData` objects - """ - logger.debug("Loading alignments") - if len(self.args.alignments_file) > 1 and self.args.job != "merge": - logger.error("Multiple alignments files are only permitted for merging") - sys.exit(0) - if len(self.args.alignments_file) == 1 and self.args.job == "merge": - logger.error("More than one alignments file required for merging") - sys.exit(0) - - if len(self.args.alignments_file) == 1: - retval = AlignmentData(self.args.alignments_file[0]) - else: - retval = [AlignmentData(a_file) for a_file in self.args.alignments_file] - logger.debug("Alignments: %s", retval) - return retval - def process(self): """ The entry point for the Alignments tool from :mod:`lib.tools.alignments.cli`. Launches the selected alignments job. """ - if self.args.job in ("missing-alignments", "missing-frames", - "multi-faces", "leftover-faces", "no-faces"): + if self.args.job in ("missing-alignments", "missing-frames", "multi-faces", "no-faces"): job = Check else: job = globals()[self.args.job.title().replace("-", "")] diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py index a0ba51d85b..d873036555 100644 --- a/tools/alignments/cli.py +++ b/tools/alignments/cli.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ from lib.cli.args import FaceSwapArgs -from lib.cli.actions import DirOrFileFullPaths, DirFullPaths, FilesFullPaths, Radio, Slider +from lib.cli.actions import DirOrFileFullPaths, DirFullPaths, FileFullPaths, Radio, Slider _HELPTEXT = "This command lets you perform various tasks pertaining to an alignments file." @@ -28,9 +28,8 @@ def get_argument_list(self): opts=("-j", "--job"), action=Radio, type=str, - choices=("draw", "extract", "merge", "missing-alignments", "missing-frames", - "leftover-faces", "multi-faces", "no-faces", "remove-faces", "rename", "sort", - "spatial", "update-hashes"), + choices=("draw", "extract", "missing-alignments", "missing-frames", "multi-faces", + "no-faces", "remove-faces", "rename", "sort", "spatial"), required=True, help="R|Choose which action you want to perform. NB: All actions require an " "alignments file (-a) to be passed in." @@ -39,16 +38,10 @@ def get_argument_list(self): "\nL|'extract': Re-extract faces from the source frames/video based on alignment " "data. This is a lot quicker than re-detecting faces. Can pass in the '-een' " "(--extract-every-n) parameter to only extract every nth frame.{1}" - "\nL|'merge': Merge multiple alignment files into one. Specify a space separated " - "list of alignments files with the -a flag. Optionally specify a faces (-fc) " - "folder to filter the final alignments file to only those faces that appear " - "within the provided folder." "\nL|'missing-alignments': Identify frames that do not exist in the alignments " "file.{2}{0}" "\nL|'missing-frames': Identify frames in the alignments file that do not appear " "within the frames folder/video.{2}{0}" - "\nL|'leftover-faces': Identify faces in the faces folder that do not exist in " - "the alignments file.{2}{3}" "\nL|'multi-faces': Identify where multiple faces exist within the alignments " "file.{2}{4}" "\nL|'no-faces': Identify frames that exist within the alignment file but no " @@ -58,20 +51,15 @@ def get_argument_list(self): "\nL|'rename' - Rename faces to correspond with their parent frame and position " "index in the alignments file (i.e. how they are named after running extract).{3}" "\nL|'sort': Re-index the alignments from left to right. For alignments with " - "multiple faces this will ensure that the left-most face is at index 0 " - "Optionally pass in a faces folder (-fc) to also rename extracted faces." + "multiple faces this will ensure that the left-most face is at index 0." "\nL|'spatial': Perform spatial and temporal filtering to smooth alignments " - "(EXPERIMENTAL!)" - "\nL|'update-hashes': Recalculate the face hashes. Only use this if you have " - "altered the extracted faces (e.g. colour adjust). The files MUST be named " - "'_face index' (i.e. how they are named after running extract)." - "{3}".format(frames_dir, frames_and_faces_dir, output_opts, faces_dir, - frames_or_faces_dir))) + "(EXPERIMENTAL!)".format(frames_dir, frames_and_faces_dir, output_opts, faces_dir, + frames_or_faces_dir))) argument_list.append(dict( opts=("-a", "--alignments_file"), - action=FilesFullPaths, + action=FileFullPaths, dest="alignments_file", - nargs="+", + type=str, group="data", required=True, filetypes="alignments", diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index f4666a11e5..92418be2a7 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -14,7 +14,7 @@ from lib.align import DetectedFace, _EXTRACT_RATIOS from lib.align.alignments import _VERSION -from lib.image import generate_thumbnail +from lib.image import encode_image, generate_thumbnail, ImagesSaver from plugins.extract.pipeline import Extractor, ExtractMedia from .media import ExtractedFaces, Faces, Frames @@ -23,169 +23,150 @@ class Check(): - """ Frames and faces checking tasks """ + """ Frames and faces checking tasks. + + Parameters + --------- + alignments: :class:`tools.alignments.media.AlignmentsData` + The loaded alignments corresponding to the frames to be annotated + arguments: :class:`argparse.Namespace` + The command line arguments that have called this job + """ def __init__(self, alignments, arguments): logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self._alignments = alignments - self.job = arguments.job - self.type = None - self.is_video = False # Set when getting items - self.output = arguments.output - self.source_dir = self.get_source_dir(arguments) - self.validate() - self.items = self.get_items() + self._job = arguments.job + self._type = None + self._is_video = False # Set when getting items + self._output = arguments.output + self._source_dir = self._get_source_dir(arguments) + self._validate() + self._items = self._get_items() self.output_message = "" logger.debug("Initialized %s", self.__class__.__name__) - def get_source_dir(self, arguments): + def _get_source_dir(self, arguments): """ Set the correct source folder """ if (hasattr(arguments, "faces_dir") and arguments.faces_dir and hasattr(arguments, "frames_dir") and arguments.frames_dir): logger.error("Only select a source frames (-fr) or source faces (-fc) folder") sys.exit(1) elif hasattr(arguments, "faces_dir") and arguments.faces_dir: - self.type = "faces" + self._type = "faces" source_dir = arguments.faces_dir elif hasattr(arguments, "frames_dir") and arguments.frames_dir: - self.type = "frames" + self._type = "frames" source_dir = arguments.frames_dir else: logger.error("No source folder (-fr or -fc) was provided") sys.exit(1) - logger.debug("type: '%s', source_dir: '%s'", self.type, source_dir) + logger.debug("type: '%s', source_dir: '%s'", self._type, source_dir) return source_dir - def get_items(self): + def _get_items(self): """ Set the correct items to process """ - items = globals()[self.type.title()](self.source_dir) - self.is_video = items.is_video + items = globals()[self._type.title()](self._source_dir) + self._is_video = items.is_video return items.file_list_sorted def process(self): """ Process the frames check against the alignments file """ - logger.info("[CHECK %s]", self.type.upper()) - items_output = self.compile_output() - self.output_results(items_output) + logger.info("[CHECK %s]", self._type.upper()) + items_output = self._compile_output() + self._output_results(items_output) - def validate(self): + def _validate(self): """ Check that the selected type is valid for selected task and job """ - if self.job == "missing-frames" and self.output == "move": + if self._job == "missing-frames" and self._output == "move": logger.warning("Missing_frames was selected with move output, but there will " "be nothing to move. Defaulting to output: console") - self.output = "console" - if self.type == "faces" and self.job not in ("multi-faces", "leftover-faces"): + self._output = "console" + if self._type == "faces" and self._job != "multi-faces": logger.error("The selected folder is not valid. Faces folder (-fc) is only " - "supported for 'multi-faces' and 'leftover-faces'") - sys.exit(1) - if self.type == "frames" and self.job == "leftover-faces": - logger.error("You must provide a faces folder (-fc) NOT a frames folder (-fr) if " - "running the 'leftover-faces' job.") + "supported for 'multi-faces'") sys.exit(1) - def compile_output(self): + def _compile_output(self): """ Compile list of frames that meet criteria """ - action = self.job.replace("-", "_") - processor = getattr(self, "get_{}".format(action)) + action = self._job.replace("-", "_") + processor = getattr(self, "_get_{}".format(action)) logger.debug("Processor: %s", processor) return [item for item in processor()] # pylint:disable=unnecessary-comprehension - def get_no_faces(self): + def _get_no_faces(self): """ yield each frame that has no face match in alignments file """ self.output_message = "Frames with no faces" - for frame in tqdm(self.items, desc=self.output_message): + for frame in tqdm(self._items, desc=self.output_message): logger.trace(frame) frame_name = frame["frame_fullname"] if not self._alignments.frame_has_faces(frame_name): logger.debug("Returning: '%s'", frame_name) yield frame_name - def get_multi_faces(self): + def _get_multi_faces(self): """ yield each frame or face that has multiple faces matched in alignments file """ - process_type = getattr(self, "get_multi_faces_{}".format(self.type)) + process_type = getattr(self, "_get_multi_faces_{}".format(self._type)) for item in process_type(): yield item - def get_multi_faces_frames(self): + def _get_multi_faces_frames(self): """ Return Frames that contain multiple faces """ self.output_message = "Frames with multiple faces" - for item in tqdm(self.items, desc=self.output_message): + for item in tqdm(self._items, desc=self.output_message): filename = item["frame_fullname"] if not self._alignments.frame_has_multiple_faces(filename): continue logger.trace("Returning: '%s'", filename) yield filename - def get_multi_faces_faces(self): + def _get_multi_faces_faces(self): """ Return Faces when there are multiple faces in a frame """ self.output_message = "Multiple faces in frame" - seen_hash_dupes = set() - for item in tqdm(self.items, desc=self.output_message): - filename = item["face_fullname"] - f_hash = item["face_hash"] - frame_idx = list(self._alignments.hashes_to_frame[f_hash].items()) - - if len(frame_idx) > 1: - # If the same hash exists in multiple frames, select arbitrary frame - # and add to seen_hash_dupes so it is not selected again - logger.trace("Dupe hashes: %s", frame_idx) - frame_idx = [f_i for f_i in frame_idx if f_i not in seen_hash_dupes][0] - seen_hash_dupes.add(frame_idx) - frame_idx = [frame_idx] - - frame_name, idx = frame_idx[0] - if not self._alignments.frame_has_multiple_faces(frame_name): + for item in tqdm(self._items, desc=self.output_message): + if not self._alignments.frame_has_multiple_faces(item["source_filename"]): continue - retval = (filename, idx) + retval = (item["current_filename"], item["face_index"]) logger.trace("Returning: '%s'", retval) yield retval - def get_missing_alignments(self): + def _get_missing_alignments(self): """ yield each frame that does not exist in alignments file """ self.output_message = "Frames missing from alignments file" exclude_filetypes = set(["yaml", "yml", "p", "json", "txt"]) - for frame in tqdm(self.items, desc=self.output_message): + for frame in tqdm(self._items, desc=self.output_message): frame_name = frame["frame_fullname"] if (frame["frame_extension"] not in exclude_filetypes and not self._alignments.frame_exists(frame_name)): logger.debug("Returning: '%s'", frame_name) yield frame_name - def get_missing_frames(self): + def _get_missing_frames(self): """ yield each frame in alignments that does not have a matching file """ self.output_message = "Missing frames that are in alignments file" - frames = set(item["frame_fullname"] for item in self.items) + frames = set(item["frame_fullname"] for item in self._items) for frame in tqdm(self._alignments.data.keys(), desc=self.output_message): if frame not in frames: logger.debug("Returning: '%s'", frame) yield frame - def get_leftover_faces(self): - """yield each face that isn't in the alignments file.""" - self.output_message = "Faces missing from the alignments file" - for face in tqdm(self.items, desc=self.output_message): - f_hash = face["face_hash"] - if f_hash not in self._alignments.hashes_to_frame: - logger.debug("Returning: '%s'", face["face_fullname"]) - yield face["face_fullname"], -1 - - def output_results(self, items_output): + def _output_results(self, items_output): """ Output the results in the requested format """ logger.trace("items_output: %s", items_output) - if self.output == "move" and self.is_video and self.type == "frames": + if self._output == "move" and self._is_video and self._type == "frames": logger.warning("Move was selected with an input video. This is not possible so " "falling back to console output") - self.output = "console" + self._output = "console" if not items_output: - logger.info("No %s were found meeting the criteria", self.type) + logger.info("No %s were found meeting the criteria", self._type) return - if self.output == "move": - self.move_file(items_output) + if self._output == "move": + self._move_file(items_output) return - if self.job in ("multi-faces", "leftover-faces") and self.type == "faces": + if self._job == "multi-faces" and self._type == "faces": # Strip the index for printed/file output items_output = [item[0] for item in items_output] output_message = "-----------------------------------------------\r\n" @@ -193,31 +174,31 @@ def output_results(self, items_output): len(items_output)) output_message += "-----------------------------------------------\r\n" output_message += "\r\n".join(items_output) - if self.output == "console": + if self._output == "console": for line in output_message.splitlines(): logger.info(line) - if self.output == "file": + if self._output == "file": self.output_file(output_message, len(items_output)) - def get_output_folder(self): + def _get_output_folder(self): """ Return output folder. Needs to be in the root if input is a video and processing frames """ - if self.is_video and self.type == "frames": - return os.path.dirname(self.source_dir) - return self.source_dir + if self._is_video and self._type == "frames": + return os.path.dirname(self._source_dir) + return self._source_dir - def get_filename_prefix(self): + def _get_filename_prefix(self): """ Video name needs to be prefixed to filename if input is a video and processing frames """ - if self.is_video and self.type == "frames": - return "{}_".format(os.path.basename(self.source_dir)) + if self._is_video and self._type == "frames": + return "{}_".format(os.path.basename(self._source_dir)) return "" def output_file(self, output_message, items_discovered): """ Save the output to a text file in the frames directory """ now = datetime.now().strftime("%Y%m%d_%H%M%S") - dst_dir = self.get_output_folder() - filename = "{}{}_{}.txt".format(self.get_filename_prefix(), + dst_dir = self._get_output_folder() + filename = "{}{}_{}.txt".format(self._get_filename_prefix(), self.output_message.replace(" ", "_").lower(), now) output_file = os.path.join(dst_dir, filename) @@ -225,34 +206,34 @@ def output_file(self, output_message, items_discovered): with open(output_file, "w") as f_output: f_output.write(output_message) - def move_file(self, items_output): + def _move_file(self, items_output): """ Move the identified frames to a new sub folder """ now = datetime.now().strftime("%Y%m%d_%H%M%S") - folder_name = "{}{}_{}".format(self.get_filename_prefix(), + folder_name = "{}{}_{}".format(self._get_filename_prefix(), self.output_message.replace(" ", "_").lower(), now) - dst_dir = self.get_output_folder() + dst_dir = self._get_output_folder() output_folder = os.path.join(dst_dir, folder_name) logger.debug("Creating folder: '%s'", output_folder) os.makedirs(output_folder) - move = getattr(self, "move_{}".format(self.type)) + move = getattr(self, "_move_{}".format(self._type)) logger.debug("Move function: %s", move) move(output_folder, items_output) - def move_frames(self, output_folder, items_output): + def _move_frames(self, output_folder, items_output): """ Move frames into single sub folder """ logger.info("Moving %s frame(s) to '%s'", len(items_output), output_folder) for frame in items_output: - src = os.path.join(self.source_dir, frame) + src = os.path.join(self._source_dir, frame) dst = os.path.join(output_folder, frame) logger.debug("Moving: '%s' to '%s'", src, dst) os.rename(src, dst) - def move_faces(self, output_folder, items_output): + def _move_faces(self, output_folder, items_output): """ Make additional sub folders for each face that appears Enables easier manual sorting """ logger.info("Moving %s faces(s) to '%s'", len(items_output), output_folder) for frame, idx in items_output: - src = os.path.join(self.source_dir, frame) + src = os.path.join(self._source_dir, frame) dst_folder = os.path.join(output_folder, str(idx)) if idx != -1 else output_folder if not os.path.isdir(dst_folder): logger.debug("Creating folder: '%s'", dst_folder) @@ -428,6 +409,7 @@ def __init__(self, alignments, arguments): self._extracted_faces = ExtractedFaces(self._frames, self._alignments, size=arguments.size) + self._saver = None logger.debug("Initialized %s", self.__class__.__name__) def process(self): @@ -436,6 +418,7 @@ def process(self): self._check_folder() if self._is_legacy: self._legacy_check() + self._saver = ImagesSaver(self._faces_dir, as_bytes=True) self._export_faces() def _check_folder(self): @@ -483,8 +466,7 @@ def _legacy_check(self): self._alignments._version = _VERSION # pylint:disable=protected-access def _export_faces(self): - """ Export the faces to the output folder and update the alignments file with - new hashes. """ + """ Export the faces to the output folder. """ extracted_faces = 0 skip_list = self._set_skip_list() count = self._frames.count if skip_list is None else self._frames.count - len(skip_list) @@ -495,7 +477,7 @@ def _export_faces(self): logger.verbose("Skipping '%s' - Alignments not found", frame_name) continue extracted_faces += self._output_faces(frame_name, image) - if extracted_faces != 0 and not self._arguments.large: + if self._is_legacy and extracted_faces != 0 and not self._arguments.large: self._alignments.save() logger.info("%s face(s) extracted", extracted_faces) @@ -523,7 +505,7 @@ def _set_skip_list(self): return skip_list def _output_faces(self, filename, image): - """ For each frame save out the faces and update the face hash back to alignments + """ For each frame save out the faces Parameters ---------- @@ -540,7 +522,6 @@ def _output_faces(self, filename, image): logger.trace("Outputting frame: %s", filename) face_count = 0 frame_name = os.path.splitext(filename)[0] - extension = ".png" faces = self._select_valid_faces(filename, image) if not faces: return face_count @@ -548,19 +529,19 @@ def _output_faces(self, filename, image): faces = self._process_legacy(filename, image, faces) for idx, face in enumerate(faces): - output = "{}_{}{}".format(frame_name, str(idx), extension) - if self._arguments.large: - self._frames.save_image(self._faces_dir, output, face.aligned.face) - else: - output = os.path.join(self._faces_dir, output) - f_hash = self._extracted_faces.save_face_with_hash(output, - extension, - face.aligned.face) - if self._is_legacy: # Generate the new thumbnail and store new face data for save - face.thumbnail = generate_thumbnail(face.aligned.face, size=96, quality=60) - self._alignments.data[filename]["faces"][idx] = face.to_alignment() - self._alignments.data[filename]["faces"][idx]["hash"] = f_hash + output = "{}_{}.png".format(frame_name, str(idx)) + meta = dict(alignments=face.to_png_meta(), + source=dict(alignments_version=self._alignments.version, + original_filename=output, + face_index=idx, + source_filename=filename, + source_is_video=self._frames.is_video)) + self._saver.save(output, encode_image(face.aligned.face, ".png", metadata=meta)) + if not self._arguments.large and self._is_legacy: + face.thumbnail = generate_thumbnail(face.aligned.face, size=96, quality=60) + self._alignments.data[filename]["faces"][idx] = face.to_alignment() face_count += 1 + self._saver.close() return face_count def _select_valid_faces(self, frame, image): @@ -654,169 +635,6 @@ def _pad_legacy_masks(cls, detected_face): mask._affine_matrix = detected_face.mask["components"].affine_matrix -class Merge(): # pylint:disable=too-few-public-methods - """ Merge multiple alignments files into one. - - Parameters - ---------- - alignments: :class:`tools.lib_alignments.media.AlignmentData` - The alignments data loaded from an alignments file for this rename job - arguments: :class:`argparse.Namespace` - The :mod:`argparse` arguments as passed in from :mod:`tools.py` - """ - def __init__(self, alignments, arguments): - self._alignments = alignments - self._check_versions() - self._faces = self._get_faces(arguments) - self._final_alignments = alignments[0] - self._process_alignments = alignments[1:] - self._hashes_to_frame = None - - def _check_versions(self): - """ Ensure all alignments files are compatible versions. If not, exit with error. """ - versions = [al.version for al in self._alignments] - logger.debug(versions) - if any(vers < 2.0 for vers in versions) and any(vers >= 2.0 for vers in versions): - logger.error("You have selected incompatible alignments files for merging. You cannot " - "merge alignments files for legacy extracted faces with aligments files " - "for full-head extracted faces.") - logger.info("You can update legacy alignments files by using the Extract job in the " - "Alignments tool to re-extract the faces in full-head format.") - sys.exit(0) - - @staticmethod - def _get_faces(arguments): - """ If faces argument is specified, load the faces folder otherwise return ``None``. - - Parameters - ---------- - arguments: :class:`argparse.Namespace` - The :mod:`argparse` arguments as passed in from :mod:`tools.py` - - Returns - ------- - :class:`tools.alignments.media.Faces` or ``None`` - The faces object or ``None`` if not faces folder specified - """ - if not hasattr(arguments, "faces_dir") or not arguments.faces_dir: - return None - return Faces(arguments.faces_dir) - - def process(self): - """Run the alignments file merging process. """ - logger.info("[MERGE ALIGNMENTS]") # Tidy up cli output - if self._faces is not None: - self._remove_faces() - self._hashes_to_frame = self._final_alignments.hashes_to_frame - skip_count = 0 - merge_count = 0 - total_count = sum([alignments.frames_count for alignments in self._process_alignments]) - - with tqdm(desc="Merging Alignments", total=total_count) as pbar: - for alignments in self._process_alignments: - for _, src_alignments, _, frame in alignments.yield_faces(): - for idx, alignment in enumerate(src_alignments): - if not alignment.get("hash", None): - logger.warning("Alignment '%s':%s has no Hash! Skipping", frame, idx) - skip_count += 1 - continue - if self._check_exists(frame, alignment, idx): - skip_count += 1 - continue - self._merge_alignment(frame, alignment, idx) - merge_count += 1 - pbar.update(1) - logger.info("Alignments Merged: %s", merge_count) - logger.info("Alignments Skipped: %s", skip_count) - if merge_count != 0: - self._set_destination_filename() - self._final_alignments.save() - - def _remove_faces(self): - """ Removes faces from the alignments file if a faces folder has been provided and the - faces do not exist within each alignments file. """ - face_hashes = list(self._faces.items.keys()) - del_faces_count = 0 - del_frames_count = 0 - if not face_hashes: - logger.error("No face hashes. This would remove all faces from your alignments file.") - return - for alignments in tqdm(self._alignments, desc="Filtering out faces"): - pre_face_count = alignments.faces_count - pre_frames_count = alignments.frames_count - alignments.filter_hashes(face_hashes, filter_out=False) - # Remove frames with no faces - frames = list(alignments.data.keys()) - for frame in frames: - if not alignments.frame_has_faces(frame): - del alignments.data[frame] - post_face_count = alignments.faces_count - post_frames_count = alignments.frames_count - removed_faces = pre_face_count - post_face_count - removed_frames = pre_frames_count - post_frames_count - del_faces_count += removed_faces - del_frames_count += removed_frames - logger.verbose("Removed %s faces and %s frames from %s", - removed_faces, removed_frames, os.path.basename(alignments.file)) - logger.info("Total removed - faces: %s, frames: %s", del_faces_count, del_frames_count) - - def _check_exists(self, frame, alignment, index): - """ Remove duplicate faces from the alignments file when an instance of the face already - exists. - - Parameters - ---------- - frame: str - The frame name for the current frame being processed - alignment: dict - The alignment dictionary for the current face in the frame - index: int - The face index for the current face in the frame - - Returns - ------- - bool - ``True`` if the face has been already been seen, otherwise ``False`` - """ - existing_frame = self._hashes_to_frame.get(alignment["hash"], None) - if not existing_frame: - return False - if frame in existing_frame.keys(): - logger.verbose("Face '%s': %s already exists in destination at position %s. " - "Skipping", frame, index, existing_frame[frame]) - elif frame not in existing_frame.keys(): - logger.verbose("Face '%s': %s exists in destination as: %s. " - "Skipping", frame, index, existing_frame) - return True - - def _merge_alignment(self, frame, alignment, idx): - """ Merge the source alignment into the destination final alignments dictionary - - Parameters - ---------- - frame: str - The frame name for the current frame being processed - alignment: dict - The alignment dictionary for the current face in the frame - index: int - The face index for the current face in the frame - """ - logger.debug("Merging alignment: (frame: %s, src_idx: %s, hash: %s)", - frame, idx, alignment["hash"]) - self._hashes_to_frame.setdefault(alignment["hash"], dict())[frame] = idx - self._final_alignments.data.setdefault(frame, - dict()).setdefault("faces", []).append(alignment) - - def _set_destination_filename(self): - """ Set the destination filename """ - folder = os.path.split(self._final_alignments.file)[0] - ext = os.path.splitext(self._final_alignments.file)[1] - now = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = os.path.join(folder, "alignments_merged_{}{}".format(now, ext)) - logger.debug("Output set to: '%s'", filename) - self._final_alignments.set_filename(filename) - - class RemoveFaces(): # pylint:disable=too-few-public-methods """ Remove items from alignments file. @@ -830,7 +648,12 @@ class RemoveFaces(): # pylint:disable=too-few-public-methods def __init__(self, alignments, arguments): logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self._alignments = alignments - self._items = Faces(arguments.faces_dir) + + kwargs = dict() + if alignments.version < 2.1: + # Update headers of faces generated with hash based alignments + kwargs["alignments"] = alignments + self._items = Faces(arguments.faces_dir, **kwargs) logger.debug("Initialized %s", self.__class__.__name__) def process(self): @@ -838,14 +661,14 @@ def process(self): folder. """ logger.info("[REMOVE FACES FROM ALIGNMENTS]") # Tidy up cli output - face_hashes = self._items.items - if not face_hashes: + frame_face_indices = self._items.items + if not frame_face_indices: logger.error("No matching faces found in your faces folder. This would remove all " "faces from your alignments file. Process aborted.") return pre_face_count = self._alignments.faces_count - self._alignments.filter_hashes(face_hashes, filter_out=False) + self._alignments.filter_faces(frame_face_indices, filter_out=False) del_count = pre_face_count - self._alignments.faces_count if del_count == 0: @@ -876,140 +699,24 @@ def __init__(self, alignments, arguments, faces=None): logger.debug("Initializing %s: (arguments: %s, faces: %s)", self.__class__.__name__, arguments, faces) self._alignments = alignments - self._faces = faces if faces else Faces(arguments.faces_dir) + + kwargs = dict() + if alignments.version < 2.1: + # Update headers of faces generated with hash based alignments + kwargs["alignments"] = alignments + self._faces = faces if faces else Faces(arguments.faces_dir, **kwargs) logger.debug("Initialized %s", self.__class__.__name__) def process(self): """ Process the face renaming """ logger.info("[RENAME FACES]") # Tidy up cli output - rename_mappings = self._build_rename_list() + rename_mappings = sorted([(face["current_filename"], face["original_filename"]) + for face in self._faces.file_list_sorted + if face["current_filename"] != face["original_filename"]], + key=lambda x: x[1]) rename_count = self._rename_faces(rename_mappings) logger.info("%s faces renamed", rename_count) - def _build_rename_list(self): - """ Build a list of source and destination filenames for renaming. - - Validates that all files in the faces folder have a corresponding match in the alignments - file. Orders the rename list by destination filename to avoid potential for filename clash. - - Returns - ------- - list - List of tuples of (`source filename`, `destination filename`) ordered by destination - filename - """ - source_filenames = [] - dest_filenames = [] - errors = [] - pbar = tqdm(desc="Building Rename Lists", total=self._faces.count) - for disk_hash, disk_faces in self._faces.items.items(): - align_faces = self._alignments.hashes_to_frame.get(disk_hash, None) - face_error = self._validate_hash_match(disk_faces, align_faces) - if face_error is not None: - errors.extend(face_error) - pbar.update(len(disk_faces)) - continue - src_faces, dst_faces = self._get_filename_mapping(disk_faces, align_faces) - source_filenames.extend(src_faces) - dest_filenames.extend(dst_faces) - pbar.update(len(src_faces)) - pbar.close() - if errors: - logger.error("There are faces in the given folder that do not correspond to entries " - "in the alignments file. Please check your data, and if neccesarry run " - "the `remove-faces` job. To get a list of faces missing alignments " - "entries, run with VERBOSE logging") - logger.verbose("Files in faces folder not in alignments file: %s", errors) - sys.exit(1) - return self._sort_mappings(source_filenames, dest_filenames) - - @staticmethod - def _validate_hash_match(disk_faces, align_faces): - """ Validate that the hash has returned corresponding faces from disk and alignments file. - - Parameters - ---------- - disk_faces: list - List of tuples of (`file name`, `file extension`) for all faces that exist for the - current hash - align_faces: dict - `frame filename`: `index` for all faces that exist in the alignments file for the - current hash - - Returns - ------- - list - List of disk_faces that do not correspond to a matching entry in the alignments file. - Returns `None` if there is a valid match - """ - if align_faces is None: - logger.debug("No matching hash found for faces: %s", disk_faces) - return [face[0] + face[1] for face in disk_faces] - if len(disk_faces) != len(align_faces): - logger.debug("Number of faces mismatch for hash: (disk_faces: %s, align_faces: %s)", - disk_faces, align_faces) - return [face[0] + face[1] for face in disk_faces[: len(align_faces)]] - return None - - @staticmethod - def _get_filename_mapping(disk_faces, align_faces): - """ Map the source filenames for this hash to the destination filenames. - - Parameters - ---------- - disk_faces: list - List of tuples of (`file name`, `file extension`) for all faces that exist for the - current hash - align_faces: dict - `frame filename`: `index` for all faces that exist in the alignments file for the - current hash - - Returns - ------- - source_filenames: list - List of source filenames to be renamed for this hash - dest_filenames: list - List of destination filenames that faces for this hash are to be renamed to - List of disk_faces that do not correspond to a matching entry in the alignments file. - Returns `None` if there is a valid match - """ - source_filenames = [] - dest_filenames = [] - # Force deterministic order on alignments dict for multi hash faces - sorted_aligned = sorted(list(align_faces.items())) - for disk_face, align_face in zip(disk_faces, sorted_aligned): - extension = disk_face[1] - src_fname = disk_face[0] + extension - - dst_frame = os.path.splitext(align_face[0])[0] - dst_fname = "{}_{}{}".format(dst_frame, align_face[1], extension) - logger.debug("Mapping rename from '%s' to '%s'", src_fname, dst_fname) - source_filenames.append(src_fname) - dest_filenames.append(dst_fname) - return source_filenames, dest_filenames - - @staticmethod - def _sort_mappings(sources, destinations): - """ Sort the mapping lists by destinations to avoid filename clash. - - Parameters - ---------- - sources: list - List of source filenames in the same order as :attr:`destinations` - destinations: dict - List of destination filenames in the same order as :attr:`sources` - - Returns - ------- - list - List of tuples of (`source filename`, `destination filename`) ordered by destination - filename - """ - sorted_indices = [idx for idx, _ in sorted(enumerate(destinations), key=lambda x: x[1])] - mappings = [(sources[idx], destinations[idx]) for idx in sorted_indices] - logger.trace("filename mappings: %s", mappings) - return mappings - def _rename_faces(self, filename_mappings): """ Rename faces back to their original name as exists in the alignments file. @@ -1026,50 +733,62 @@ def _rename_faces(self, filename_mappings): int The number of faces that have been renamed """ + if not filename_mappings: + return 0 + rename_count = 0 + conflicts = [] for src, dst in tqdm(filename_mappings, desc="Renaming Faces"): - if src == dst: - logger.debug("Skipping rename of '%s' as destination name is same as souce", src) - continue old = os.path.join(self._faces.folder, src) new = os.path.join(self._faces.folder, dst) + if os.path.exists(new): - # This should never happen, but is a safety measure to prevent deletion of faces - # when multiple files have the same hash. - logger.debug("Skipping renaming to an existing file: (src: '%s', dst: '%s'", + # Interim add .tmp extension to files that will cause a rename conflict, to + # process afterwards + logger.debug("interim renaming file to avoid conflict: (src: '%s', dst: '%s')", src, dst) - continue + new = new + ".tmp" + conflicts.append(new) + logger.verbose("Renaming '%s' to '%s'", old, new) os.rename(old, new) rename_count += 1 + if conflicts: + for old in tqdm(conflicts, desc="Renaming Faces"): + new = old[:-4] # Remove .tmp extension + if os.path.exists(new): + # This should only be running on faces. If there is still a conflict + # then the user has done something stupid, so we will delete the file and + # replace. They can always re-extract :/ + os.remove(new) + logger.verbose("Renaming '%s' to '%s'", old, new) + os.rename(old, new) return rename_count class Sort(): - """ Sort alignments' index by the order they appear in an image """ + """ Sort alignments' index by the order they appear in an image in left to right order. + + Parameters + ---------- + alignments: :class:`tools.lib_alignments.media.AlignmentData` + The alignments data loaded from an alignments file for this rename job + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + """ def __init__(self, alignments, arguments): logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self._alignments = alignments - self.faces = self.get_faces(arguments) logger.debug("Initialized %s", self.__class__.__name__) - @staticmethod - def get_faces(arguments): - """ If faces argument is specified, load faces_dir otherwise return None """ - if not hasattr(arguments, "faces_dir") or not arguments.faces_dir: - return None - faces = Faces(arguments.faces_dir) - return faces - def process(self): """ Execute the sort process """ logger.info("[SORT INDEXES]") # Tidy up cli output reindexed = self.reindex_faces() if reindexed: self._alignments.save() - if self.faces: - rename = Rename(self._alignments, None, self.faces) - rename.process() + logger.warning("If you have a face-set corresponding to the alignment file you " + "processed then you should run the 'Extract' job to regenerate it.") def reindex_faces(self): """ Re-Index the faces """ @@ -1119,10 +838,8 @@ def process(self): landmarks = self.temporally_smooth(landmarks) self.update_alignments(landmarks) self._alignments.save() - - logger.info("Done! To re-extract faces run: python tools.py " - "alignments -j extract -a %s -fr -fc " - "", self.arguments.alignments_file) + logger.warning("If you have a face-set corresponding to the alignment file you " + "processed then you should run the 'Extract' job to regenerate it.") # Define shape normalization utility functions @staticmethod @@ -1172,7 +889,7 @@ def normalize(self): continue # We should only be normalizing a single face, so just take # the first landmarks found - landmarks = np.array(val[0]["landmarks_xy"]).reshape(68, 2, 1) + landmarks = np.array(val[0]["landmarks_xy"]).reshape((68, 2, 1)) start = end end = start + landmarks.shape[2] # Store in one big array @@ -1248,58 +965,3 @@ def update_alignments(self, landmarks): self._alignments.data[frame]["faces"][0]["landmarks_xy"] = landmarks_xy logger.trace("Updated: (frame: '%s', landmarks: %s)", frame, landmarks_xy) logger.debug("Updated alignments") - - -class UpdateHashes(): - """ Update hashes in an alignments file """ - def __init__(self, alignments, arguments): - logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - self._alignments = alignments - self.faces = Faces(arguments.faces_dir).file_list_sorted - self.face_hashes = dict() - logger.debug("Initialized %s", self.__class__.__name__) - - def process(self): - """ Update Face Hashes to the alignments file """ - logger.info("[UPDATE FACE HASHES]") # Tidy up cli output - self.get_hashes() - updated = self.update_hashes() - if updated == 0: - logger.info("No hashes were updated. Exiting") - return - self._alignments.save() - logger.info("%s frame(s) had their face hashes updated.", updated) - - def get_hashes(self): - """ Read the face hashes from the faces """ - logger.info("Getting original filenames, indexes and hashes...") - for face in self.faces: - filename = face["face_name"] - extension = face["face_extension"] - if "_" not in face["face_name"]: - logger.warning("Unable to determine index of file. Skipping: '%s'", filename) - continue - index = filename[filename.rfind("_") + 1:] - if not index.isdigit(): - logger.warning("Unable to determine index of file. Skipping: '%s'", filename) - continue - orig_frame = filename[:filename.rfind("_")] + extension - self.face_hashes.setdefault(orig_frame, dict())[int(index)] = face["face_hash"] - - def update_hashes(self): - """ Update hashes to alignments """ - logger.info("Updating hashes to alignments...") - updated = 0 - for frame, hashes in self.face_hashes.items(): - if not self._alignments.frame_exists(frame): - logger.warning("Frame not found in alignments file. Skipping: '%s'", frame) - continue - if not self._alignments.frame_has_faces(frame): - logger.warning("Frame does not have faces. Skipping: '%s'", frame) - continue - existing = [face.get("hash", None) - for face in self._alignments.get_faces_in_frame(frame)] - if any(hsh not in existing for hsh in list(hashes.values())): - self._alignments.add_face_hashes(frame, hashes) - updated += 1 - return updated diff --git a/tools/alignments/media.py b/tools/alignments/media.py index 9c318eb602..2494fd01f2 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -12,10 +12,10 @@ # TODO imageio single frame seek seems slow. Look into this # import imageio -from lib.align import Alignments, DetectedFace -from lib.image import (count_frames, encode_image_with_hash, generate_thumbnail, ImagesLoader, - read_image, read_image_hash_batch) -from lib.utils import _image_extensions, _video_extensions +from lib.align import Alignments, DetectedFace, update_legacy_png_header +from lib.image import (count_frames, generate_thumbnail, ImagesLoader, + png_write_meta, read_image, read_image_meta_batch) +from lib.utils import _image_extensions, _video_extensions, FaceswapError logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -54,19 +54,6 @@ def reload(self): self._data = self._load() logger.debug("Re-loaded alignments") - def add_face_hashes(self, frame_name, hashes): - """ Recalculate face hashes """ - logger.trace("Adding face hash: (frame: '%s', hashes: %s)", frame_name, hashes) - faces = self.get_faces_in_frame(frame_name) - count_match = len(faces) - len(hashes) - if count_match != 0: - msg = "more" if count_match > 0 else "fewer" - logger.warning("There are %s %s face(s) in the alignments file than exist in the " - "faces folder. Check your sources for frame '%s'.", - abs(count_match), msg, frame_name) - for idx, i_hash in hashes.items(): - faces[idx]["hash"] = i_hash - def set_filename(self, filename): """ Set the :attr:`_file` to the given filename. @@ -79,7 +66,13 @@ def set_filename(self, filename): class MediaLoader(): - """ Class to load filenames from folder """ + """ Class to load images. + + Parameters + ---------- + folder: str + The folder of images or video file to load images from + """ def __init__(self, folder): logger.debug("Initializing %s: (folder: '%s')", self.__class__.__name__, folder) logger.info("[%s DATA]", self.__class__.__name__.upper()) @@ -202,48 +195,108 @@ def stream(self, skip_list=None): yield filename, image @staticmethod - def save_image(output_folder, filename, image): + def save_image(output_folder, filename, image, metadata=None): """ Save an image """ output_file = os.path.join(output_folder, filename) - output_file = os.path.splitext(output_file)[0]+'.png' + output_file = os.path.splitext(output_file)[0] + ".png" logger.trace("Saving image: '%s'", output_file) - cv2.imwrite(output_file, image) # pylint: disable=no-member + if metadata: + encoded_image = cv2.imencode(".png", image)[1] + encoded_image = png_write_meta(encoded_image.tobytes(), metadata) + with open(output_file, "wb") as out_file: + out_file.write(encoded_image) + else: + cv2.imwrite(output_file, image) # pylint: disable=no-member class Faces(MediaLoader): - """ Object to hold the faces that are to be swapped out """ + """ Object to load Extracted Faces from a folder. + + Parameters + ---------- + folder: str + The folder to load faces from + alignments: :class:`lib.align.Alignments`, optional + The alignments object that contains the faces. Used to update legacy hash based faces + for len(alignment) - 1: self._counts["skip"] += 1 - logger.warning("Skipping face not in alignments file: '%s'", filename) + logger.warning("Skipping Face not found in alignments file. skipping: '%s'", + filename) continue + alignment = alignment[face_index] + self._counts["face"] += 1 - frames = self._alignments.hashes_to_frame[hsh] - if len(frames) > 1: - # Filter the output by filename in case of multiple frames with the same face - logger.debug("Filtering multiple hashes to current filename: (filename: '%s', " - "frames: %s", filename, frames) - lookup = os.path.splitext(os.path.basename(filename))[0] - frames = {k: v - for k, v in frames.items() - if lookup.startswith(os.path.splitext(k)[0])} - logger.debug("Filtered: (filename: '%s', frame: '%s')", filename, frames) - - for frame, idx in frames.items(): - self._counts["face"] += 1 - alignment = self._alignments.get_faces_in_frame(frame)[idx] - if self._check_for_missing(frame, idx, alignment): - continue - detected_face = self._get_detected_face(alignment) - if self._update_type == "output": - detected_face.image = image - self._save(frame, idx, detected_face) - else: - queue.put(ExtractMedia(filename, image, detected_faces=[detected_face])) - self._counts["update"] += 1 + if self._check_for_missing(frame_name, face_index, alignment): + continue + + detected_face = self._get_detected_face(alignment) + if self._update_type == "output": + detected_face.image = image + self._save(frame_name, face_index, detected_face) + else: + media = ExtractMedia(filename, image, detected_faces=[detected_face]) + setattr(media, "mask_tool_face_info", metadata["source"]) # TODO formalize + queue.put(media) + self._counts["update"] += 1 if self._update_type != "output": queue.put("EOF") @@ -295,6 +305,8 @@ def process(self): logger.debug("Starting masker process") updater = getattr(self, "_update_{}".format("faces" if self._input_is_faces else "frames")) if self._update_type != "output": + 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) @@ -302,6 +314,8 @@ def process(self): if self._counts["update"] != 0: self._alignments.backup() self._alignments.save() + if self._input_is_faces: + self._faces_saver.close() else: self._extractor_input_thread.join() self._saver.close() @@ -328,11 +342,19 @@ def _update_faces(self, extractor_output): The output from the :class:`plugins.extract.pipeline.Extractor` object """ for face in extractor_output.detected_faces: - for frame, idx in self._alignments.hashes_to_frame[face.hash].items(): - 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) + frame_name = extractor_output.mask_tool_face_info["source_filename"] + face_index = extractor_output.mask_tool_face_info["face_index"] + logger.trace("Saving face: (frame: %s, face index: %s)", frame_name, face_index) + + self._alignments.update_face(frame_name, face_index, face.to_alignment()) + metadata = dict(alignments=face.to_png_meta(), + source=extractor_output.mask_tool_face_info) + 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): """ Update alignments for the mask if the input type is a frames folder or video diff --git a/tools/sort/cli.py b/tools/sort/cli.py index a47c2cdbbe..39ba298d5e 100644 --- a/tools/sort/cli.py +++ b/tools/sort/cli.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ from lib.cli.args import FaceSwapArgs -from lib.cli.actions import DirFullPaths, FileFullPaths, SaveFileFullPaths, Radio, Slider +from lib.cli.actions import DirFullPaths, SaveFileFullPaths, Radio, Slider _HELPTEXT = "This command lets you sort images using various methods." @@ -31,17 +31,6 @@ def get_argument_list(): dest="output_dir", group="data", help="Output directory for sorted aligned faces.")) - argument_list.append(dict( - opts=('-a', '--alignments'), - action=FileFullPaths, - filetypes="alignments", - type=str, - dest="alignments_path", - group="data", - help="Optional path to an alignments file. This is only used for the 'sort-by face' " - "method. If not provided, the default location will be scanned. If the file " - "still cannot be located, then the sorting process will analyze the full-head " - "extract images, which will lead to vastly inferior results.")) argument_list.append(dict( opts=('-s', '--sort-by'), action=Radio, @@ -55,8 +44,7 @@ def get_argument_list(): "\nL|'blur': Sort faces by blurriness." "\nL|'face': Use VGG Face to sort by face similarity. This uses a pairwise " "clustering algorithm to check the distances between 512 features on every face " - "in your set and order them appropriately. NB: You should provide an alignments " - "file if using this method. Not doing so will lead to vastly inferior results." + "in your set and order them appropriately." "\nL|'face-cnn': Sort faces by their landmarks. You can adjust the threshold " "with the '-t' (--ref_threshold) option." "\nL|'face-cnn-dissim': Like 'face-cnn' but sorts by dissimilarity." diff --git a/tools/sort/sort.py b/tools/sort/sort.py index e747651d9f..85cd76ba27 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -15,8 +15,9 @@ # faceswap imports from lib.serializer import get_serializer_from_filename -from lib.align import Alignments, AlignedFace, DetectedFace, _EXTRACT_RATIOS +from lib.align import AlignedFace, DetectedFace from lib.image import FacesLoader, read_image +from lib.utils import FaceswapError from plugins.extract.recognition.vgg_face2_keras import VGGFace2 as VGGFace from plugins.extract.pipeline import Extractor, ExtractMedia @@ -28,39 +29,12 @@ class Sort(): # pylint: disable=no-member def __init__(self, arguments): self._args = arguments - self._alignments = self._get_alignments() self.changes = None self.serializer = None self._vgg_face = None # TODO set this as FacesLoader in init. Need to move all processes to use it self._loader = None - def _get_alignments(self): - """ Obtain the alignments data and validate for methods which require it. - - Returns - ------- - :class:`lib.align.Alignments` - The alignments object pertaining to the data to be sorted. Returns ``None`` if an - alignments file can't be found or is not required. - """ - required_methods = ["face"] - if self._args.sort_method not in required_methods: - return None - if self._args.alignments_path is None: - path = os.path.join(self._args.input_dir, "alignments.fsa") - else: - path = self._args.alignments_path - - if not os.path.isfile(path): - logger.warning("Alignments file not found at '%s'. Not using an alignments file will " - "lead to vastly inferior results.", path) - logger.warning("It is highly recommended that you use an alignments file for sorting " - "by '%s'.", self._args.sort_method) - return None - - return Alignments(*os.path.split(path)) - def process(self): """ Main processing function of the sort tool """ @@ -194,46 +168,32 @@ def sort_blur(self): def sort_face(self): """ Sort by identity similarity """ logger.info("Sorting by identity similarity...") - self._loader = FacesLoader(self._args.input_dir) # TODO This should be set in init - ratio = _EXTRACT_RATIOS["legacy"] / _EXTRACT_RATIOS["head"] filenames = [] preds = [] - no_hash = 0 - for filename, image, hsh in tqdm(self._loader.load(), - desc="Classifying Faces...", - total=self._loader.count): - if self._alignments is not None and self._alignments.version != 1.0: - face = self._alignments.hashes_to_alignment.get(hsh) - if face: - image = AlignedFace(face["landmarks_xy"], - image=image, - centering="legacy", - size=self._vgg_face.input_size, - is_aligned=True).face - elif image.shape[0] != image.shape[1]: - logger.warning("Skipping image '%s' as it is not square (probably not a " - "face)", filename) - continue - else: # Center crop the image and add count to warning count - center = image.shape[0] // 2 - crop = slice(center - int(center * ratio), center + int(center * ratio)) - image = image[crop, crop, :] - no_hash += 1 - + for filename, image, metadata in tqdm(self._loader.load(), + desc="Classifying Faces...", + total=self._loader.count, + leave=False): + if not metadata: + msg = ("The images to be sorted do not contain alignment data. Images must have " + "been generated by Faceswap's Extract process.\nIf you are sorting an " + "older faceset, then you should re-extract the faces from your source " + "alignments file to generate this data.") + raise FaceswapError(msg) + alignments = metadata["alignments"] + face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), + image=image, + centering="legacy", + size=self._vgg_face.input_size, + is_aligned=True).face filenames.append(filename) - preds.append(self._vgg_face.predict(image)) + preds.append(self._vgg_face.predict(face)) logger.info("Sorting by ward linkage...") indices = self._vgg_face.sorted_similarity(np.array(preds), method="ward") img_list = np.array(filenames)[indices] - - if no_hash: - logger.warning("%s image(s) were not found in the alignments file. This will likely " - "result in sub-par sorting results, so you should check the output " - "carefully", no_hash) - return img_list def sort_face_cnn(self): From fcf6d8238c2b6eadd319c53072887233548f529e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 15 Feb 2021 00:24:59 +0000 Subject: [PATCH 361/981] Bugfixes: - lib.image Don't raise error if legacy non-png is found when reading header data - plugins.train.trainer._base - Correctly pass legacy alignments through to DetectedFace --- lib/image.py | 2 ++ plugins/train/trainer/_base.py | 11 ++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/image.py b/lib/image.py index 3e0180eea4..bee8f17aa3 100644 --- a/lib/image.py +++ b/lib/image.py @@ -371,6 +371,8 @@ def read_image_meta(filename): """ retval = dict() if os.path.splitext(filename)[-1] != ".png": + logger.trace("Non png found. Not scanning metadata: '%s'", filename) + return retval raise ValueError(f"Only png files are supported for reading exif data. ({filename})") with open(filename, "rb") as infile: chunk = infile.read(8) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index f258900767..97d5d28a39 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -1217,9 +1217,11 @@ def _update_legacy_facesets(self): desc="Updating legacy training images ({})".format(side.upper()), total=len(filenames), leave=False): - detected_face = DetectedFace() - detected_face.from_png_meta(future.result()) - png_meta[images[future]] = detected_face + result = future.result() + if result: + detected_face = DetectedFace() + detected_face.from_png_meta(future.result()["alignments"]) + png_meta[images[future]] = detected_face def _get_alignments_path(self, side): """ Obtain the path to an alignments file for the given training side. @@ -1302,6 +1304,9 @@ def _validate_metadata(self, output_warning=True): for side, valid in all_valid.items(): if valid: continue + if all(val is None for val in self._detected_faces[side].values()): + raise FaceswapError("There is no valid training data for side '{}'. Re-check your " + "data and try again.".format(side.upper())) invalid = [filename for filename, meta in self._detected_faces[side].items() if not meta] From 407b71377fd5094f3c664eecf0d221e3ecd93dd5 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 15 Feb 2021 12:27:00 +0000 Subject: [PATCH 362/981] Bugfix: lib.image.read_image_meta - Read file for dimensions if not a png --- lib/image.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/image.py b/lib/image.py index bee8f17aa3..73e1c15a4f 100644 --- a/lib/image.py +++ b/lib/image.py @@ -371,9 +371,11 @@ def read_image_meta(filename): """ retval = dict() if os.path.splitext(filename)[-1] != ".png": - logger.trace("Non png found. Not scanning metadata: '%s'", filename) + # Get the dimensions directly from the image for non-pngs + logger.trace("Non png found. Loading file for dimensions: '%s'", filename) + img = cv2.imread("filename") + retval["height"], retval["width"] = img.shape[:2] return retval - raise ValueError(f"Only png files are supported for reading exif data. ({filename})") with open(filename, "rb") as infile: chunk = infile.read(8) if chunk != b"\x89PNG\r\n\x1a\n": From 14564bbc8549d252795cecd10330c8e854fcb7c8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 15 Feb 2021 12:46:42 +0000 Subject: [PATCH 363/981] typofix --- lib/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/image.py b/lib/image.py index 73e1c15a4f..abb4181d94 100644 --- a/lib/image.py +++ b/lib/image.py @@ -373,7 +373,7 @@ def read_image_meta(filename): if os.path.splitext(filename)[-1] != ".png": # Get the dimensions directly from the image for non-pngs logger.trace("Non png found. Loading file for dimensions: '%s'", filename) - img = cv2.imread("filename") + img = cv2.imread(filename) retval["height"], retval["width"] = img.shape[:2] return retval with open(filename, "rb") as infile: From 02b2ec25c04900a264de10483b33d4da57e13a96 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 15 Feb 2021 12:47:24 +0000 Subject: [PATCH 364/981] minor fix --- lib/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/image.py b/lib/image.py index abb4181d94..d14ab39c5e 100644 --- a/lib/image.py +++ b/lib/image.py @@ -370,7 +370,7 @@ def read_image_meta(filename): >>> faceswap_info = metadata["itxt"] """ retval = dict() - if os.path.splitext(filename)[-1] != ".png": + if os.path.splitext(filename)[-1].lower() != ".png": # Get the dimensions directly from the image for non-pngs logger.trace("Non png found. Loading file for dimensions: '%s'", filename) img = cv2.imread(filename) From b20ad26b8b28dcaed550615f4fea9a1b80a6d111 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 15 Feb 2021 16:46:17 +0000 Subject: [PATCH 365/981] lib.image - png header, handle pre-existing iTXt entries on load --- lib/image.py | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/lib/image.py b/lib/image.py index d14ab39c5e..6cf13c7319 100644 --- a/lib/image.py +++ b/lib/image.py @@ -392,8 +392,13 @@ def read_image_meta(filename): retval["width"], retval["height"] = struct.unpack(">II", chunk) length -= 8 elif field == b"iTXt": - retval["itxt"] = eval(infile.read(length).split(b"\0\0\0\0\0", 1)[-1]) - break + keyword, value = infile.read(length).split(b"\0", 1) + if keyword == b"faceswap": + retval["itxt"] = eval(value[4:]) + break + else: + logger.trace("Skipping iTXt chunk: '%s'", keyword.decode("latin-1", "ignore")) + length = 0 # Reset marker for next chunk infile.seek(length + 4, 1) logger.trace("filename: %s, metadata: %s", filename, retval) return retval @@ -526,14 +531,22 @@ def png_read_meta(png): task. OpenCV will not write any iTXt headers to the PNG file, so we make the assumption that the only iTXt header that exists is the one that Faceswap created for storing alignments. """ - pointer = png.find(b"iTXt") - 4 - if pointer < 0: - logger.trace("No metadata in png") - return None - length = struct.unpack(">I", png[pointer:pointer + 4])[0] - pointer += 8 - data = png[pointer:pointer + length].split(b"\0\0\0\0\0", 1)[-1] - return eval(data) + retval = None + pointer = 0 + while True: + pointer = png.find(b"iTXt", pointer) - 4 + if pointer < 0: + logger.trace("No metadata in png") + break + length = struct.unpack(">I", png[pointer:pointer + 4])[0] + pointer += 8 + keyword, value = png[pointer:pointer + length].split(b"\0", 1) + if keyword == b"faceswap": + retval = eval(value[4:]) + break + logger.trace("Skipping iTXt chunk: '%s'", keyword.decode("latin-1", "ignore")) + pointer += length + 4 + return retval def generate_thumbnail(image, size=96, quality=60): From d4c4c8c2e9e2269c76223d2fb935323b051291a8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 15 Feb 2021 17:24:27 +0000 Subject: [PATCH 366/981] bugfix - plugins.trainer._base - Update image list to point to renamed pngs --- plugins/train/trainer/_base.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 97d5d28a39..23543db7ef 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -1088,7 +1088,7 @@ def __init__(self, model, image_list): self._alignments_version = dict() self._image_sizes = {key: None for key in image_list} self._detected_faces = self._load_detected_faces(image_list) - self._update_legacy_facesets() + self._update_legacy_facesets(image_list) self._validity_check() self._aligned_faces = self._get_aligned_faces() @@ -1190,9 +1190,16 @@ def _validate_image_size(self, side, filename, width, height): "your dataset".format(side.upper(), self._image_sizes[side], width)) raise FaceswapError(msg) - def _update_legacy_facesets(self): + def _update_legacy_facesets(self, image_list): """ Update the png header data for legacy face sets that do not contain the meta data in the exif header. + + Parameters + ---------- + image_list: dict + The file paths for the images to be trained on for each side. The dictionary should + contain 2 keys ("a" and "b") with the values being a list of full paths corresponding + to each side. """ if self._validate_metadata(output_warning=False): logger.debug("All faces contain valid header information") @@ -1219,9 +1226,18 @@ def _update_legacy_facesets(self): leave=False): result = future.result() if result: + filename = images[future] + if os.path.splitext(filename)[-1].lower() != ".png": + # Update the image list to point at newly created png + del png_meta[filename] + image_list[side].remove(filename) + + filename = os.path.splitext(filename)[0] + ".png" + image_list[side].append(filename) + detected_face = DetectedFace() detected_face.from_png_meta(future.result()["alignments"]) - png_meta[images[future]] = detected_face + png_meta[filename] = detected_face def _get_alignments_path(self, side): """ Obtain the path to an alignments file for the given training side. From 02336977dcc084c38472cea060903d34e153960f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 17 Feb 2021 00:21:18 +0000 Subject: [PATCH 367/981] lib.model.normalization: - refactor - add LayerNorm - add RMSNorm --- lib/model/__init__.py | 1 + lib/model/normalization/__init__.py | 10 + .../normalization_common.py} | 27 +- .../normalization/normalization_plaid.py | 381 ++++++++++++++++++ lib/model/normalization/normalization_tf.py | 167 ++++++++ tests/lib/model/layers_test.py | 5 +- tests/lib/model/normalization_test.py | 29 ++ 7 files changed, 604 insertions(+), 16 deletions(-) create mode 100644 lib/model/normalization/__init__.py rename lib/model/{normalization.py => normalization/normalization_common.py} (95%) create mode 100644 lib/model/normalization/normalization_plaid.py create mode 100644 lib/model/normalization/normalization_tf.py diff --git a/lib/model/__init__.py b/lib/model/__init__.py index ef4d5036c8..175e1343bb 100644 --- a/lib/model/__init__.py +++ b/lib/model/__init__.py @@ -3,6 +3,7 @@ from lib.utils import get_backend +from .normalization import * if get_backend() == "amd": from . import losses_plaid as losses else: diff --git a/lib/model/normalization/__init__.py b/lib/model/normalization/__init__.py new file mode 100644 index 0000000000..f79aab54e0 --- /dev/null +++ b/lib/model/normalization/__init__.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +""" Conditional imports depending on whether the AMD version is installed or not """ + +from lib.utils import get_backend +from .normalization_common import * + +if get_backend() == "amd": + from .normalization_plaid import * +else: + from .normalization_tf import * diff --git a/lib/model/normalization.py b/lib/model/normalization/normalization_common.py similarity index 95% rename from lib/model/normalization.py rename to lib/model/normalization/normalization_common.py index 2bcd0c0b98..ae5d891fe9 100644 --- a/lib/model/normalization.py +++ b/lib/model/normalization/normalization_common.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Normalization methods for faceswap.py. """ +""" Normalization methods for faceswap.py common to both Plaid and Tensorflow Backends """ import sys import inspect @@ -96,7 +96,7 @@ def build(self, input_shape): if (self.axis is not None) and (ndim == 2): raise ValueError("Cannot specify axis for rank 1 tensor") - self.input_spec = InputSpec(ndim=ndim) + self.input_spec = InputSpec(ndim=ndim) # pylint:disable=attribute-defined-outside-init if self.axis is None: shape = (1,) @@ -119,7 +119,7 @@ def build(self, input_shape): constraint=self.beta_constraint) else: self.beta = None - self.built = True + self.built = True # pylint:disable=attribute-defined-outside-init def call(self, inputs, training=None): # pylint:disable=arguments-differ,unused-argument """This is where the layer's logic lives. @@ -185,7 +185,7 @@ class name. These are handled by `Network` (one layer of abstraction above). "beta_constraint": constraints.serialize(self.beta_constraint), "gamma_constraint": constraints.serialize(self.gamma_constraint) } - base_config = super(InstanceNormalization, self).get_config() + base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) @@ -241,9 +241,9 @@ def build(self, input_shape): 'but the layer received an input with shape ' + str(input_shape[0]) + '.') - super(AdaInstanceNormalization, self).build(input_shape) + super().build(input_shape) - def call(self, inputs, training=None): # pylint:disable=unused-argument + def call(self, inputs, training=None): # pylint:disable=unused-argument,arguments-differ """This is where the layer's logic lives. Parameters @@ -289,10 +289,10 @@ def get_config(self): 'center': self.center, 'scale': self.scale } - base_config = super(AdaInstanceNormalization, self).get_config() + base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) - def compute_output_shape(self, input_shape): + def compute_output_shape(self, input_shape): # pylint:disable=no-self-use """ Calculate the output shape from this layer. Parameters @@ -344,7 +344,7 @@ def __init__(self, axis=-1, gamma_init='one', beta_init='zero', gamma_regularize beta_regularizer=None, epsilon=1e-6, group=32, data_format=None, **kwargs): self.beta = None self.gamma = None - super(GroupNormalization, self).__init__(**kwargs) + super().__init__(**kwargs) self.axis = axis if isinstance(axis, (list, tuple)) else [axis] self.gamma_init = initializers.get(gamma_init) self.beta_init = initializers.get(beta_init) @@ -365,7 +365,8 @@ def build(self, input_shape): Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to reference for weight shape computations. """ - self.input_spec = [InputSpec(shape=input_shape)] + input_spec = [InputSpec(shape=input_shape)] + self.input_spec = input_spec # pylint:disable=attribute-defined-outside-init shape = [1 for _ in input_shape] if self.data_format == 'channels_last': channel_axis = -1 @@ -383,9 +384,9 @@ def build(self, input_shape): initializer=self.beta_init, regularizer=self.beta_regularizer, name='beta') - self.built = True + self.built = True # pylint:disable=attribute-defined-outside-init - def call(self, inputs, mask=None): # pylint: disable=unused-argument + def call(self, inputs, mask=None): # pylint:disable=unused-argument,arguments-differ """This is where the layer's logic lives. Parameters @@ -479,7 +480,7 @@ def get_config(self): 'gamma_regularizer': regularizers.serialize(self.gamma_regularizer), 'beta_regularizer': regularizers.serialize(self.gamma_regularizer), 'group': self.group} - base_config = super(GroupNormalization, self).get_config() + base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) diff --git a/lib/model/normalization/normalization_plaid.py b/lib/model/normalization/normalization_plaid.py new file mode 100644 index 0000000000..1e72bede58 --- /dev/null +++ b/lib/model/normalization/normalization_plaid.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 +""" Normalization methods for faceswap.py. """ + +import sys +import inspect + +from plaidml.op import slice_tensor +from keras.layers import Layer +from keras import initializers, regularizers, constraints +from keras import backend as K +from keras.utils import get_custom_objects + + +class LayerNormalization(Layer): + """Instance normalization layer (Lei Ba et al, 2016). Implementation adapted from + tensorflow.keras implementation and https://github.com/CyberZHG/keras-layer-normalization + + Normalize the activations of the previous layer for each given example in a batch + independently, rather than across a batch like Batch Normalization. i.e. applies a + transformation that maintains the mean activation within each example close to 0 and the + activation standard deviation close to 1. + + Parameters + ---------- + axis: int or list/tuple + The axis or axes to normalize across. Typically this is the features axis/axes. + The left-out axes are typically the batch axis/axes. This argument defaults to `-1`, the + last dimension in the input. + epsilon: float, optional + Small float added to variance to avoid dividing by zero. Default: `1e-3` + center: bool, optional + If ``True``, add offset of `beta` to normalized tensor. If ``False``, `beta` is ignored. + Default: ``True`` + scale: bool, optional + If ``True``, multiply by `gamma`. If ``False``, `gamma` is not used. When the next layer + is linear (also e.g. `relu`), this can be disabled since the scaling will be done by + the next layer. Default: ``True`` + beta_initializer: str, optional + Initializer for the beta weight. Default: `"zeros"` + gamma_initializer: str, optional + Initializer for the gamma weight. Default: `"ones"` + beta_regularizer: str, optional + Optional regularizer for the beta weight. Default: ``None`` + gamma_regularizer: str, optional + Optional regularizer for the gamma weight. Default: ``None`` + beta_constraint: float, optional + Optional constraint for the beta weight. Default: ``None`` + gamma_constraint: float, optional + Optional constraint for the gamma weight. Default: ``None`` + kwargs: dict + Standard keras layer kwargs + + References + ---------- + - Layer Normalization - https://arxiv.org/abs/1607.06450 + - Keras implementation - https://github.com/CyberZHG/keras-layer-normalization + """ + def __init__(self, + axis=-1, + epsilon=1e-3, + center=True, + scale=True, + beta_initializer="zeros", + gamma_initializer="ones", + beta_regularizer=None, + gamma_regularizer=None, + beta_constraint=None, + gamma_constraint=None, + **kwargs): + + self.gamma = None + self.beta = None + super().__init__(**kwargs) + + if isinstance(axis, (list, tuple)): + self.axis = axis[:] + elif isinstance(axis, int): + self.axis = axis + else: + raise TypeError("Expected an int or a list/tuple of ints for the argument 'axis', " + f"but received: {axis}") + + self.epsilon = epsilon + self.center = center + self.scale = scale + self.beta_initializer = initializers.get(beta_initializer) + self.gamma_initializer = initializers.get(gamma_initializer) + self.beta_regularizer = regularizers.get(beta_regularizer) + self.gamma_regularizer = regularizers.get(gamma_regularizer) + self.beta_constraint = constraints.get(beta_constraint) + self.gamma_constraint = constraints.get(gamma_constraint) + self.supports_masking = True + + def build(self, input_shape): + """Creates the layer weights. + + Parameters + ---------- + input_shape: tensor + Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to + reference for weight shape computations. + """ + ndims = len(input_shape) + if ndims is None: + raise ValueError(f"Input shape {input_shape} has undefined rank.") + + # Convert axis to list and resolve negatives + if isinstance(self.axis, int): + self.axis = [self.axis] + elif isinstance(self.axis, tuple): + self.axis = list(self.axis) + for idx, axs in enumerate(self.axis): + if axs < 0: + self.axis[idx] = ndims + axs + + # Validate axes + for axs in self.axis: + if axs < 0 or axs >= ndims: + raise ValueError(f"Invalid axis: {axs}") + if len(self.axis) != len(set(self.axis)): + raise ValueError("Duplicate axis: {}".format(tuple(self.axis))) + + param_shape = [input_shape[dim] for dim in self.axis] + if self.scale: + self.gamma = self.add_weight( + name="gamma", + shape=param_shape, + initializer=self.gamma_initializer, + regularizer=self.gamma_regularizer, + constraint=self.gamma_constraint) + if self.center: + self.beta = self.add_weight( + name='beta', + shape=param_shape, + initializer=self.beta_initializer, + regularizer=self.beta_regularizer, + constraint=self.beta_constraint) + + self.built = True # pylint:disable=attribute-defined-outside-init + + def call(self, inputs, **kwargs): # pylint:disable=unused-argument + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: tensor + Input tensor, or list/tuple of input tensors + + Returns + ------- + tensor + A tensor or list/tuple of tensors + """ + # Compute the axes along which to reduce the mean / variance + input_shape = K.int_shape(inputs) + ndims = len(input_shape) + + # Broadcasting only necessary for norm when the axis is not just the last dimension + broadcast_shape = [1] * ndims + for dim in self.axis: + broadcast_shape[dim] = input_shape[dim] + + def _broadcast(var): + if (var is not None and len(var.shape) != ndims and self.axis != [ndims - 1]): + return K.reshape(var, broadcast_shape) + return var + + # Calculate the moments on the last axis (layer activations). + mean = K.mean(inputs, self.axis, keepdims=True) + variance = K.mean(K.square(inputs - mean), axis=self.axis, keepdims=True) + std = K.sqrt(variance + self.epsilon) + outputs = (inputs - mean) / std + + scale, offset = _broadcast(self.gamma), _broadcast(self.beta) + if self.scale: + outputs *= scale + if self.center: + outputs *= offset + + return outputs + + def compute_output_shape(self, input_shape): # pylint:disable=no-self-use + """ The output shape of the layer is the same as the input shape. + + Parameters + ---------- + input_shape: tuple + The input shape to the layer + + Returns + ------- + tuple + The output shape to the layer + """ + return input_shape + + def get_config(self): + """Returns the config of the layer. + + A layer config is a Python dictionary (serializable) containing the configuration of a + layer. The same layer can be reinstated later (without its trained weights) from this + configuration. + + The configuration of a layer does not include connectivity information, nor the layer + class name. These are handled by `Network` (one layer of abstraction above). + + Returns + -------- + dict + A python dictionary containing the layer configuration + """ + base_config = super().get_config() + config = dict(axis=self.axis, + epsilon=self.epsilon, + center=self.center, + scale=self.scale, + beta_initializer=initializers.serialize(self.beta_initializer), + gamma_initializer=initializers.serialize(self.gamma_initializer), + beta_regularizer=regularizers.serialize(self.beta_regularizer), + gamma_regularizer=regularizers.serialize(self.gamma_regularizer), + beta_constraint=constraints.serialize(self.beta_constraint), + gamma_constraint=constraints.serialize(self.gamma_constraint)) + return dict(list(base_config.items()) + list(config.items())) + + +class RMSNormalization(Layer): + """ Root Mean Square Layer Normalization (Biao Zhang, Rico Sennrich, 2019) + + RMSNorm is a simplification of the original layer normalization (LayerNorm). LayerNorm is a + regularization technique that might handle the internal covariate shift issue so as to + stabilize the layer activations and improve model convergence. It has been proved quite + successful in NLP-based model. In some cases, LayerNorm has become an essential component + to enable model optimization, such as in the SOTA NMT model Transformer. + + Parameters + ---------- + axis: int + The axis to normalize across. Typically this is the features axis. The left-out axes are + typically the batch axis/axes. This argument defaults to `-1`, the last dimension in the + input. + epsilon: float, optional + Small float added to variance to avoid dividing by zero. Default: `1e-8` + partial: float, optional + Partial multiplier for calculating pRMSNorm. Valid values are between `0.0` and `1.0`. + Setting to `0.0` or `1.0` disables. Default: `0.0` + bias: bool, optional + Whether to use a bias term for RMSNorm. Disabled by default because RMSNorm does not + enforce re-centering invariance. Default ``False`` + kwargs: dict + Standard keras layer kwargs + + References + ---------- + - RMS Normalization - https://arxiv.org/abs/1910.07467 + - Official implementation - https://github.com/bzhangGo/rmsnorm + """ + def __init__(self, axis=-1, epsilon=1e-8, partial=-1.0, bias=False, **kwargs): + self.scale = None + self.offset = 0 + super().__init__(**kwargs) + + # Checks + if not isinstance(axis, int): + raise TypeError(f"Expected an int for the argument 'axis', but received: {axis}") + + if not 0.0 <= partial <= 1.0: + raise ValueError(f"partial must be between 0.0 and 1.0, but received {partial}") + + self.axis = axis + self.epsilon = epsilon + self.partial = partial + self.bias = bias + self.offset = 0. + + def build(self, input_shape): + """ Validate and populate :attr:`axis` + + Parameters + ---------- + input_shape: tensor + Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to + reference for weight shape computations. + """ + ndims = len(input_shape) + if ndims is None: + raise ValueError(f"Input shape {input_shape} has undefined rank.") + + # Resolve negative axis + if self.axis < 0: + self.axis += ndims + + # Validate axes + if self.axis < 0 or self.axis >= ndims: + raise ValueError(f"Invalid axis: {self.axis}") + + param_shape = [input_shape[self.axis]] + self.scale = self.add_weight( + name="scale", + shape=param_shape, + initializer="ones") + if self.bias: + self.offset = self.add_weight( + name="offset", + shape=param_shape, + initializer="zeros") + + self.built = True # pylint:disable=attribute-defined-outside-init + + def call(self, inputs, **kwargs): # pylint:disable=unused-argument + """ Call Root Mean Square Layer Normalization + + Parameters + ---------- + inputs: tensor + Input tensor, or list/tuple of input tensors + + Returns + ------- + tensor + A tensor or list/tuple of tensors + """ + # Compute the axes along which to reduce the mean / variance + input_shape = K.int_shape(inputs) + layer_size = input_shape[self.axis] + + if self.partial in (0.0, 1.0): + mean_square = K.mean(K.square(inputs), axis=self.axis, keepdims=True) + else: + partial_size = int(layer_size * self.partial) + partial_x = slice_tensor(inputs, + axes=[self.axis], + starts=[0], + ends=[partial_size]) + mean_square = K.mean(K.square(partial_x), axis=self.axis, keepdims=True) + + recip_square_root = 1. / K.sqrt(mean_square + self.epsilon) + output = self.scale * inputs * recip_square_root + self.offset + return output + + def compute_output_shape(self, input_shape): # pylint:disable=no-self-use + """ The output shape of the layer is the same as the input shape. + + Parameters + ---------- + input_shape: tuple + The input shape to the layer + + Returns + ------- + tuple + The output shape to the layer + """ + return input_shape + + def get_config(self): + """Returns the config of the layer. + + A layer config is a Python dictionary (serializable) containing the configuration of a + layer. The same layer can be reinstated later (without its trained weights) from this + configuration. + + The configuration of a layer does not include connectivity information, nor the layer + class name. These are handled by `Network` (one layer of abstraction above). + + Returns + -------- + dict + A python dictionary containing the layer configuration + """ + base_config = super().get_config() + config = dict(axis=self.axis, + epsilon=self.epsilon, + partial=self.partial, + bias=self.bias) + return dict(list(base_config.items()) + list(config.items())) + + +# Update normalization into Keras custom objects +for name, obj in inspect.getmembers(sys.modules[__name__]): + if inspect.isclass(obj) and obj.__module__ == __name__: + get_custom_objects().update({name: obj}) diff --git a/lib/model/normalization/normalization_tf.py b/lib/model/normalization/normalization_tf.py new file mode 100644 index 0000000000..1aa30d50f8 --- /dev/null +++ b/lib/model/normalization/normalization_tf.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +""" Normalization methods for faceswap.py specific to Tensorflow backend """ +import inspect +import sys + +import tensorflow as tf +import tensorflow.keras.backend as K +# tf.keras has a LayerNormaliztion implementation +from tensorflow.keras.layers import Layer, LayerNormalization # noqa pylint:disable=unused-import +from tensorflow.keras.utils import get_custom_objects + + +class RMSNormalization(Layer): + """ Root Mean Square Layer Normalization (Biao Zhang, Rico Sennrich, 2019) + + RMSNorm is a simplification of the original layer normalization (LayerNorm). LayerNorm is a + regularization technique that might handle the internal covariate shift issue so as to + stabilize the layer activations and improve model convergence. It has been proved quite + successful in NLP-based model. In some cases, LayerNorm has become an essential component + to enable model optimization, such as in the SOTA NMT model Transformer. + + Parameters + ---------- + axis: int + The axis to normalize across. Typically this is the features axis. The left-out axes are + typically the batch axis/axes. This argument defaults to `-1`, the last dimension in the + input. + epsilon: float, optional + Small float added to variance to avoid dividing by zero. Default: `1e-8` + partial: float, optional + Partial multiplier for calculating pRMSNorm. Valid values are between `0.0` and `1.0`. + Setting to `0.0` or `1.0` disables. Default: `0.0` + bias: bool, optional + Whether to use a bias term for RMSNorm. Disabled by default because RMSNorm does not + enforce re-centering invariance. Default ``False`` + kwargs: dict + Standard keras layer kwargs + + References + ---------- + - RMS Normalization - https://arxiv.org/abs/1910.07467 + - Official implementation - https://github.com/bzhangGo/rmsnorm + """ + def __init__(self, axis=-1, epsilon=1e-8, partial=-1.0, bias=False, **kwargs): + self.scale = None + self.offset = 0 + super().__init__(**kwargs) + + # Checks + if not isinstance(axis, int): + raise TypeError(f"Expected an int for the argument 'axis', but received: {axis}") + + if not 0.0 <= partial <= 1.0: + raise ValueError(f"partial must be between 0.0 and 1.0, but received {partial}") + + self.axis = axis + self.epsilon = epsilon + self.partial = partial + self.bias = bias + self.offset = 0. + + def build(self, input_shape): + """ Validate and populate :attr:`axis` + + Parameters + ---------- + input_shape: tensor + Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to + reference for weight shape computations. + """ + ndims = len(input_shape) + if ndims is None: + raise ValueError(f"Input shape {input_shape} has undefined rank.") + + # Resolve negative axis + if self.axis < 0: + self.axis += ndims + + # Validate axes + if self.axis < 0 or self.axis >= ndims: + raise ValueError(f"Invalid axis: {self.axis}") + + param_shape = [input_shape[self.axis]] + self.scale = self.add_weight( + name="scale", + shape=param_shape, + initializer="ones") + if self.bias: + self.offset = self.add_weight( + name="offset", + shape=param_shape, + initializer="zeros") + + self.built = True # pylint:disable=attribute-defined-outside-init + + def call(self, inputs, **kwargs): # pylint:disable=unused-argument + """ Call Root Mean Square Layer Normalization + + Parameters + ---------- + inputs: tensor + Input tensor, or list/tuple of input tensors + + Returns + ------- + tensor + A tensor or list/tuple of tensors + """ + # Compute the axes along which to reduce the mean / variance + input_shape = K.int_shape(inputs) + layer_size = input_shape[self.axis] + + if self.partial in (0.0, 1.0): + mean_square = K.mean(K.square(inputs), axis=self.axis, keepdims=True) + else: + partial_size = int(layer_size * self.partial) + partial_x, _ = tf.split(inputs, + [partial_size, layer_size - partial_size], + axis=self.axis) + mean_square = K.mean(K.square(partial_x), axis=self.axis, keepdims=True) + + recip_square_root = tf.math.rsqrt(mean_square + self.epsilon) + output = self.scale * inputs * recip_square_root + self.offset + return output + + def compute_output_shape(self, input_shape): # pylint:disable=no-self-use + """ The output shape of the layer is the same as the input shape. + + Parameters + ---------- + input_shape: tuple + The input shape to the layer + + Returns + ------- + tuple + The output shape to the layer + """ + return input_shape + + def get_config(self): + """Returns the config of the layer. + + A layer config is a Python dictionary (serializable) containing the configuration of a + layer. The same layer can be reinstated later (without its trained weights) from this + configuration. + + The configuration of a layer does not include connectivity information, nor the layer + class name. These are handled by `Network` (one layer of abstraction above). + + Returns + -------- + dict + A python dictionary containing the layer configuration + """ + base_config = super().get_config() + config = dict(axis=self.axis, + epsilon=self.epsilon, + partial=self.partial, + bias=self.bias) + return dict(list(base_config.items()) + list(config.items())) + + +# Update normalization into Keras custom objects +for name, obj in inspect.getmembers(sys.modules[__name__]): + if inspect.isclass(obj) and obj.__module__ == __name__: + get_custom_objects().update({name: obj}) diff --git a/tests/lib/model/layers_test.py b/tests/lib/model/layers_test.py index 53362a0253..a650dd4833 100644 --- a/tests/lib/model/layers_test.py +++ b/tests/lib/model/layers_test.py @@ -11,7 +11,7 @@ from numpy.testing import assert_allclose -from lib.model import layers, normalization +from lib.model import layers from lib.utils import get_backend from tests.utils import has_arg @@ -51,8 +51,7 @@ def layer_test(layer_cls, kwargs={}, input_shape=None, input_dtype=None, weights = layer.get_weights() layer.set_weights(weights) - if isinstance(layer, (layers.ReflectionPadding2D, normalization.InstanceNormalization)): - layer.build(input_shape) + layer.build(input_shape) expected_output_shape = layer.compute_output_shape(input_shape) # test in functional API diff --git a/tests/lib/model/normalization_test.py b/tests/lib/model/normalization_test.py index 53bf34d92d..c8f0bb8f7f 100644 --- a/tests/lib/model/normalization_test.py +++ b/tests/lib/model/normalization_test.py @@ -3,6 +3,7 @@ Adapted from Keras tests. """ +from itertools import product from keras import regularizers import pytest @@ -35,3 +36,31 @@ def test_instance_normalization(dummy): # pylint:disable=unused-argument 'scale': False, 'center': False}, input_shape=(3, 4, 2, 4)) + + +_PARAMS = ["center", "scale"] +_VALUES = list(product([True, False], repeat=len(_PARAMS))) +_IDS = ["{}[{}]".format("|".join([_PARAMS[idx] for idx, b in enumerate(v) if b]), + get_backend().upper()) for v in _VALUES] + + +@pytest.mark.parametrize(_PARAMS, _VALUES, ids=_IDS) +def test_layer_normalization(center, scale): # pylint:disable=unused-argument + """ Basic test for layer normalization. """ + layer_test(normalization.LayerNormalization, + kwargs={"center": center, "scale": scale}, + input_shape=(4, 512)) + + +_PARAMS = ["partial", "bias"] +_VALUES = [(0.0, False), (0.25, False), (0.5, True), (0.75, False), (1.0, True)] +_IDS = ["partial={}|bias={}[{}]".format(v[0], v[1], get_backend().upper()) + for v in _VALUES] + + +@pytest.mark.parametrize(_PARAMS, _VALUES, ids=_IDS) +def test_rms_normalization(partial, bias): # pylint:disable=unused-argument + """ Basic test for RMS Layer normalization. """ + layer_test(normalization.RMSNormalization, + kwargs={"partial": partial, "bias": bias}, + input_shape=(4, 512)) From 7e563b275e0830953acb40a0aa186bfbe875a30b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 17 Feb 2021 00:24:51 +0000 Subject: [PATCH 368/981] typofix --- lib/model/normalization/normalization_plaid.py | 5 ++++- lib/model/normalization/normalization_tf.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/model/normalization/normalization_plaid.py b/lib/model/normalization/normalization_plaid.py index 1e72bede58..d9e28a5700 100644 --- a/lib/model/normalization/normalization_plaid.py +++ b/lib/model/normalization/normalization_plaid.py @@ -232,6 +232,9 @@ class RMSNormalization(Layer): successful in NLP-based model. In some cases, LayerNorm has become an essential component to enable model optimization, such as in the SOTA NMT model Transformer. + RMSNorm simplifies LayerNorm by removing the mean-centering operation, or normalizing layer + activations with RMS statistic. + Parameters ---------- axis: int @@ -254,7 +257,7 @@ class RMSNormalization(Layer): - RMS Normalization - https://arxiv.org/abs/1910.07467 - Official implementation - https://github.com/bzhangGo/rmsnorm """ - def __init__(self, axis=-1, epsilon=1e-8, partial=-1.0, bias=False, **kwargs): + def __init__(self, axis=-1, epsilon=1e-8, partial=0.0, bias=False, **kwargs): self.scale = None self.offset = 0 super().__init__(**kwargs) diff --git a/lib/model/normalization/normalization_tf.py b/lib/model/normalization/normalization_tf.py index 1aa30d50f8..f53d7e7a6b 100644 --- a/lib/model/normalization/normalization_tf.py +++ b/lib/model/normalization/normalization_tf.py @@ -19,6 +19,9 @@ class RMSNormalization(Layer): successful in NLP-based model. In some cases, LayerNorm has become an essential component to enable model optimization, such as in the SOTA NMT model Transformer. + RMSNorm simplifies LayerNorm by removing the mean-centering operation, or normalizing layer + activations with RMS statistic. + Parameters ---------- axis: int @@ -41,7 +44,7 @@ class RMSNormalization(Layer): - RMS Normalization - https://arxiv.org/abs/1910.07467 - Official implementation - https://github.com/bzhangGo/rmsnorm """ - def __init__(self, axis=-1, epsilon=1e-8, partial=-1.0, bias=False, **kwargs): + def __init__(self, axis=-1, epsilon=1e-8, partial=0.0, bias=False, **kwargs): self.scale = None self.offset = 0 super().__init__(**kwargs) From 69813de15a040584756c5dbfa61468f20c97f8af Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 17 Feb 2021 12:19:11 +0000 Subject: [PATCH 369/981] Unit tests - lib.model.normalization - __init__.py - Explicit imports (to fix pytests) - Add tests for all existing normalization layers - fix GroupNormalization for tensorflow backend --- .gitignore | 1 + lib/model/normalization/__init__.py | 9 ++-- .../normalization/normalization_common.py | 10 +++- tests/lib/model/normalization_test.py | 51 ++++++++++++++++++- 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 40e94ba27d..19a1af6780 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ !lib/gui !lib/gui/.cache/preview !lib/gui/.cache/icons +!lib/model/* !scripts !plugins/ !plugins/* diff --git a/lib/model/normalization/__init__.py b/lib/model/normalization/__init__.py index f79aab54e0..a7f2bd759d 100644 --- a/lib/model/normalization/__init__.py +++ b/lib/model/normalization/__init__.py @@ -2,9 +2,12 @@ """ Conditional imports depending on whether the AMD version is installed or not """ from lib.utils import get_backend -from .normalization_common import * +from .normalization_common import AdaInstanceNormalization +from .normalization_common import GroupNormalization +from .normalization_common import InstanceNormalization + if get_backend() == "amd": - from .normalization_plaid import * + from .normalization_plaid import LayerNormalization, RMSNormalization else: - from .normalization_tf import * + from .normalization_tf import LayerNormalization, RMSNormalization diff --git a/lib/model/normalization/normalization_common.py b/lib/model/normalization/normalization_common.py index ae5d891fe9..0625368fca 100644 --- a/lib/model/normalization/normalization_common.py +++ b/lib/model/normalization/normalization_common.py @@ -9,6 +9,14 @@ from keras import backend as K from keras.utils import get_custom_objects +from lib.utils import get_backend + + +if get_backend() == "amd": + from keras.backend import normalize_data_format # pylint:disable=ungrouped-imports +else: + from tensorflow.python.keras.utils.conv_utils import normalize_data_format + class InstanceNormalization(Layer): """Instance normalization layer (Lei Ba et al, 2016, Ulyanov et al., 2016). @@ -352,7 +360,7 @@ def __init__(self, axis=-1, gamma_init='one', beta_init='zero', gamma_regularize self.beta_regularizer = regularizers.get(beta_regularizer) self.epsilon = epsilon self.group = group - self.data_format = K.normalize_data_format(data_format) + self.data_format = normalize_data_format(data_format) self.supports_masking = True diff --git a/tests/lib/model/normalization_test.py b/tests/lib/model/normalization_test.py index c8f0bb8f7f..925c2f3521 100644 --- a/tests/lib/model/normalization_test.py +++ b/tests/lib/model/normalization_test.py @@ -5,9 +5,11 @@ """ from itertools import product -from keras import regularizers +import numpy as np import pytest +from keras import regularizers, models, layers + from lib.model import normalization from lib.utils import get_backend @@ -38,6 +40,29 @@ def test_instance_normalization(dummy): # pylint:disable=unused-argument input_shape=(3, 4, 2, 4)) +@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) +def test_group_normalization(dummy): # pylint:disable=unused-argument + """ Basic test for instance normalization. """ + layer_test(normalization.GroupNormalization, + kwargs={'epsilon': 0.1, + 'gamma_regularizer': regularizers.l2(0.01), + 'beta_regularizer': regularizers.l2(0.01)}, + input_shape=(4, 3, 4, 128)) + layer_test(normalization.GroupNormalization, + kwargs={'epsilon': 0.1, + 'axis': 1}, + input_shape=(4, 1, 4, 256)) + layer_test(normalization.GroupNormalization, + kwargs={'gamma_init': 'ones', + 'beta_init': 'ones'}, + input_shape=(4, 64)) + layer_test(normalization.GroupNormalization, + kwargs={'epsilon': 0.1, + 'axis': 1, + 'group': 16}, + input_shape=(3, 64)) + + _PARAMS = ["center", "scale"] _VALUES = list(product([True, False], repeat=len(_PARAMS))) _IDS = ["{}[{}]".format("|".join([_PARAMS[idx] for idx, b in enumerate(v) if b]), @@ -45,7 +70,29 @@ def test_instance_normalization(dummy): # pylint:disable=unused-argument @pytest.mark.parametrize(_PARAMS, _VALUES, ids=_IDS) -def test_layer_normalization(center, scale): # pylint:disable=unused-argument +def test_adain_normalization(center, scale): + """ Basic test for Ada Instance Normalization. """ + norm = normalization.AdaInstanceNormalization(center=center, scale=scale) + shapes = [(4, 8, 8, 1280), (4, 1, 1, 1280), (4, 1, 1, 1280)] + norm.build(shapes) + expected_output_shape = norm.compute_output_shape(shapes) + inputs = [layers.Input(shape=shapes[0][1:]), + layers.Input(shape=shapes[1][1:]), + layers.Input(shape=shapes[2][1:])] + model = models.Model(inputs, norm(inputs)) + data = [10 * np.random.random(shape) for shape in shapes] + + actual_output = model.predict(data) + actual_output_shape = actual_output.shape + + for expected_dim, actual_dim in zip(expected_output_shape, + actual_output_shape): + if expected_dim is not None: + assert expected_dim == actual_dim + + +@pytest.mark.parametrize(_PARAMS, _VALUES, ids=_IDS) +def test_layer_normalization(center, scale): """ Basic test for layer normalization. """ layer_test(normalization.LayerNormalization, kwargs={"center": center, "scale": scale}, From 48ca4d1b0e52ad940067896a5955553fbd785db3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 17 Feb 2021 18:36:30 +0000 Subject: [PATCH 370/981] bugfix - FIx rare bug that fails to load configuration files on some windows installs --- lib/config.py | 45 ++++++++++++++++++++++++++++++++++++++ plugins/convert/_config.py | 29 +----------------------- plugins/extract/_config.py | 27 +---------------------- plugins/train/_config.py | 30 +------------------------ 4 files changed, 48 insertions(+), 83 deletions(-) diff --git a/lib/config.py b/lib/config.py index 096cbc52d5..088f54cbc1 100644 --- a/lib/config.py +++ b/lib/config.py @@ -10,6 +10,8 @@ from configparser import ConfigParser from importlib import import_module +from lib.utils import full_path_split + logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -64,6 +66,49 @@ def set_defaults(self): """ raise NotImplementedError + def _defaults_from_plugin(self, plugin_folder): + """ Scan the given plugins folder for config defaults.py files and update the + default configuration. + + Parameters + ---------- + plugin_folder: str + The folder to scan for plugins + """ + for dirpath, _, filenames in os.walk(plugin_folder): + default_files = [fname for fname in filenames if fname.endswith("_defaults.py")] + if not default_files: + continue + base_path = os.path.dirname(os.path.realpath(sys.argv[0])) + # Can't use replace as there is a bug on some Windows installs that lowers some paths + import_path = ".".join(full_path_split(dirpath[len(base_path):])[1:]) + plugin_type = import_path.split(".")[-1] + for filename in default_files: + self._load_defaults_from_module(filename, import_path, plugin_type) + + def _load_defaults_from_module(self, filename, module_path, plugin_type): + """ Load the plugin's defaults module, extract defaults and add to default configuration. + + Parameters + ---------- + filename: str + The filename to load the defaults from + module_path: str + The path to load the module from + plugin_type: str + The type of plugin that the defaults are being loaded for + """ + logger.debug("Adding defaults: (filename: %s, module_path: %s, plugin_type: %s", + filename, module_path, plugin_type) + module = os.path.splitext(filename)[0] + section = ".".join((plugin_type, module.replace("_defaults", ""))) + logger.debug("Importing defaults module: %s.%s", module_path, module) + mod = import_module("{}.{}".format(module_path, module)) + self.add_section(title=section, info=mod._HELPTEXT) # pylint:disable=protected-access + for key, val in mod._DEFAULTS.items(): # pylint:disable=protected-access + self.add_item(section=section, title=key, **val) + logger.debug("Added defaults: %s", section) + @property def config_dict(self): """ Collate global options and requested section into a dictionary with the correct diff --git a/plugins/convert/_config.py b/plugins/convert/_config.py index 5e916e699d..5f5ad26c0f 100644 --- a/plugins/convert/_config.py +++ b/plugins/convert/_config.py @@ -3,12 +3,8 @@ import logging import os -import sys - -from importlib import import_module from lib.config import FaceswapConfig -from lib.utils import full_path_split logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -18,27 +14,4 @@ class Config(FaceswapConfig): def set_defaults(self): """ Set the default values for config """ - logger.debug("Setting defaults") - current_dir = os.path.dirname(__file__) - for dirpath, _, filenames in os.walk(current_dir): - default_files = [fname for fname in filenames if fname.endswith("_defaults.py")] - if not default_files: - continue - base_path = os.path.dirname(os.path.realpath(sys.argv[0])) - import_path = ".".join(full_path_split(dirpath.replace(base_path, ""))[1:]) - plugin_type = import_path.split(".")[-1] - for filename in default_files: - self.load_module(filename, import_path, plugin_type) - - def load_module(self, filename, module_path, plugin_type): - """ Load the defaults module and add defaults """ - logger.debug("Adding defaults: (filename: %s, module_path: %s, plugin_type: %s", - filename, module_path, plugin_type) - module = os.path.splitext(filename)[0] - section = ".".join((plugin_type, module.replace("_defaults", ""))) - logger.debug("Importing defaults module: %s.%s", module_path, module) - mod = import_module("{}.{}".format(module_path, module)) - self.add_section(title=section, info=mod._HELPTEXT) # pylint:disable=protected-access - for key, val in mod._DEFAULTS.items(): # pylint:disable=protected-access - self.add_item(section=section, title=key, **val) - logger.debug("Added defaults: %s", section) + self._defaults_from_plugin(os.path.dirname(__file__)) diff --git a/plugins/extract/_config.py b/plugins/extract/_config.py index b9768fb03f..0360ffcd56 100644 --- a/plugins/extract/_config.py +++ b/plugins/extract/_config.py @@ -3,11 +3,8 @@ import logging import os -import sys -from importlib import import_module from lib.config import FaceswapConfig -from lib.utils import full_path_split logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -19,29 +16,7 @@ def set_defaults(self): """ Set the default values for config """ logger.debug("Setting defaults") self.set_globals() - current_dir = os.path.dirname(__file__) - for dirpath, _, filenames in os.walk(current_dir): - default_files = [fname for fname in filenames if fname.endswith("_defaults.py")] - if not default_files: - continue - base_path = os.path.dirname(os.path.realpath(sys.argv[0])) - import_path = ".".join(full_path_split(dirpath.replace(base_path, ""))[1:]) - plugin_type = import_path.split(".")[-1] - for filename in default_files: - self.load_module(filename, import_path, plugin_type) - - def load_module(self, filename, module_path, plugin_type): - """ Load the defaults module and add defaults """ - logger.debug("Adding defaults: (filename: %s, module_path: %s, plugin_type: %s", - filename, module_path, plugin_type) - module = os.path.splitext(filename)[0] - section = ".".join((plugin_type, module.replace("_defaults", ""))) - logger.debug("Importing defaults module: %s.%s", module_path, module) - mod = import_module("{}.{}".format(module_path, module)) - self.add_section(title=section, info=mod._HELPTEXT) # pylint:disable=protected-access - for key, val in mod._DEFAULTS.items(): # pylint:disable=protected-access - self.add_item(section=section, title=key, **val) - logger.debug("Added defaults: %s", section) + self._defaults_from_plugin(os.path.dirname(__file__)) def set_globals(self): """ diff --git a/plugins/train/_config.py b/plugins/train/_config.py index f0e1caa8d9..c2a2b7a3d4 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -3,12 +3,8 @@ import logging import os -import sys - -from importlib import import_module from lib.config import FaceswapConfig -from lib.utils import full_path_split from plugins.plugin_loader import PluginLoader logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -25,16 +21,7 @@ def set_defaults(self): logger.debug("Setting defaults") self._set_globals() self._set_loss() - current_dir = os.path.dirname(__file__) - for dirpath, _, filenames in os.walk(current_dir): - default_files = [fname for fname in filenames if fname.endswith("_defaults.py")] - if not default_files: - continue - base_path = os.path.dirname(os.path.realpath(sys.argv[0])) - import_path = ".".join(full_path_split(dirpath.replace(base_path, ""))[1:]) - plugin_type = import_path.split(".")[-1] - for filename in default_files: - self.load_module(filename, import_path, plugin_type) + self._defaults_from_plugin(os.path.dirname(__file__)) def _set_globals(self): """ Set the global options for training """ @@ -386,18 +373,3 @@ def _set_loss(self): info="Dedicate a portion of the model to learning how to duplicate the input " "mask. Increases VRAM usage in exchange for learning a quick ability to try " "to replicate more complex mask models.") - - def load_module(self, filename, module_path, plugin_type): - """ Load the defaults module and add defaults """ - logger.debug("Adding defaults: (filename: %s, module_path: %s, plugin_type: %s", - filename, module_path, plugin_type) - module = os.path.splitext(filename)[0] - section = ".".join((plugin_type, module.replace("_defaults", ""))) - logger.debug("Importing defaults module: %s.%s", module_path, module) - mod = import_module("{}.{}".format(module_path, module)) - helptext = mod._HELPTEXT # pylint:disable=protected-access - helptext += ADDITIONAL_INFO if module_path.endswith("model") else "" - self.add_section(title=section, info=helptext) - for key, val in mod._DEFAULTS.items(): # pylint:disable=protected-access - self.add_item(section=section, title=key, **val) - logger.debug("Added defaults: %s", section) From 770daa31825f8dec61402d2bbcbe6598c9470744 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 18 Feb 2021 10:34:32 +0000 Subject: [PATCH 371/981] Bugfix - scripts.extract - Store the source filename, not the full frame in png meta header --- scripts/extract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/extract.py b/scripts/extract.py index 28f7c42307..34d087061b 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -291,7 +291,7 @@ def _output_faces(self, saver, extract_media): source=dict(alignments_version=self._alignments.version, original_filename=output_filename, face_index=idx, - source_filename=extract_media.filename, + source_filename=os.path.basename(extract_media.filename), source_is_video=self._images.is_video)) image = encode_image(face.aligned.face, extension, metadata=meta) From a5e666fb79bf47a3f61a0d09ca624927241071b7 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 18 Feb 2021 11:18:11 +0000 Subject: [PATCH 372/981] Bugfix - Remove ability to execute arbritary code from png header data --- lib/image.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/image.py b/lib/image.py index 6cf13c7319..8e337603dc 100644 --- a/lib/image.py +++ b/lib/image.py @@ -8,6 +8,7 @@ import struct import sys +from ast import literal_eval from bisect import bisect from concurrent import futures from zlib import crc32 @@ -394,7 +395,7 @@ def read_image_meta(filename): elif field == b"iTXt": keyword, value = infile.read(length).split(b"\0", 1) if keyword == b"faceswap": - retval["itxt"] = eval(value[4:]) + retval["itxt"] = literal_eval(value[4:].decode("utf-8")) break else: logger.trace("Skipping iTXt chunk: '%s'", keyword.decode("latin-1", "ignore")) @@ -542,7 +543,7 @@ def png_read_meta(png): pointer += 8 keyword, value = png[pointer:pointer + length].split(b"\0", 1) if keyword == b"faceswap": - retval = eval(value[4:]) + retval = literal_eval(value[4:].decode("utf-8")) break logger.trace("Skipping iTXt chunk: '%s'", keyword.decode("latin-1", "ignore")) pointer += length + 4 From 1f3e1b0656156b67c870532817293511c67237f3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 18 Feb 2021 23:53:01 +0000 Subject: [PATCH 373/981] Add Locale support for cli arguments --- .gitignore | 6 + faceswap.py | 25 +- lib/cli/args.py | 718 ++++++++++---------- locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 0 -> 37615 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 894 +++++++++++++++++++++++++ locales/faceswap.pot | 33 + locales/lib.cli.args.pot | 404 +++++++++++ locales/tools.alignments.cli.pot | 108 +++ locales/tools.effmpeg.cli.pot | 115 ++++ locales/tools.manual.cli.pot | 49 ++ locales/tools.mask.cli.pot | 97 +++ locales/tools.pot | 21 + locales/tools.preview.cli.pot | 47 ++ locales/tools.restore.cli.pot | 29 + locales/tools.sort.cli.pot | 98 +++ tools.py | 19 +- tools/alignments/cli.py | 125 ++-- tools/effmpeg/cli.py | 328 +++++---- tools/manual/cli.py | 42 +- tools/mask/cli.py | 248 +++---- tools/preview/cli.py | 29 +- tools/restore/cli.py | 24 +- tools/sort/cli.py | 142 ++-- 23 files changed, 2778 insertions(+), 823 deletions(-) create mode 100644 locales/es/LC_MESSAGES/lib.cli.args.mo create mode 100644 locales/es/LC_MESSAGES/lib.cli.args.po create mode 100644 locales/faceswap.pot create mode 100644 locales/lib.cli.args.pot create mode 100644 locales/tools.alignments.cli.pot create mode 100644 locales/tools.effmpeg.cli.pot create mode 100644 locales/tools.manual.cli.pot create mode 100644 locales/tools.mask.cli.pot create mode 100644 locales/tools.pot create mode 100644 locales/tools.preview.cli.pot create mode 100644 locales/tools.restore.cli.pot create mode 100644 locales/tools.sort.cli.pot diff --git a/.gitignore b/.gitignore index 19a1af6780..7d1337fa34 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,11 @@ !*.inf !*.keep !*.md +!*.mo !*.nsi !*.png +!*.po +!*.pot !*.py !*.rst !*.sh @@ -21,6 +24,9 @@ !docs/full !docs/_static !config/ +!locales/ +!locales/* +!locales/*/LC_MESSAGES !lib/ !lib/* !lib/gui diff --git a/faceswap.py b/faceswap.py index fa755a8307..95e53a1e4a 100755 --- a/faceswap.py +++ b/faceswap.py @@ -1,22 +1,29 @@ #!/usr/bin/env python3 """ The master faceswap.py script """ +import gettext import sys -from lib.cli import args +from lib.cli import args as cli_args from lib.config import generate_configs + +# LOCALES +_LANG = gettext.translation("faceswap", localedir="locales", fallback=True) +_ = _LANG.gettext + + if sys.version_info[0] < 3: raise Exception("This program requires at least python3.7") if sys.version_info[0] == 3 and sys.version_info[1] < 7: raise Exception("This program requires at least python3.7") -_PARSER = args.FullHelpArgumentParser() +_PARSER = cli_args.FullHelpArgumentParser() def _bad_args(*args): # pylint:disable=unused-argument """ Print help to console when bad arguments are provided. """ - print(args) + print(cli_args) _PARSER.print_help() sys.exit(0) @@ -33,12 +40,12 @@ def _main(): generate_configs() subparser = _PARSER.add_subparsers() - args.ExtractArgs(subparser, "extract", "Extract the faces from pictures") - args.TrainArgs(subparser, "train", "This command trains the model for the two faces A and B") - args.ConvertArgs(subparser, - "convert", - "Convert a source image to a new one with the face swapped") - args.GuiArgs(subparser, "gui", "Launch the Faceswap Graphical User Interface") + cli_args.ExtractArgs(subparser, "extract", _("Extract the faces from pictures or a video")) + cli_args.TrainArgs(subparser, "train", _("Train a model for the two faces A and B")) + cli_args.ConvertArgs(subparser, + "convert", + _("Convert source pictures or video to a new one with the face swapped")) + cli_args.GuiArgs(subparser, "gui", _("Launch the Faceswap Graphical User Interface")) _PARSER.set_defaults(func=_bad_args) arguments = _PARSER.parse_args() arguments.func(arguments) diff --git a/lib/cli/args.py b/lib/cli/args.py index e13303e4d6..91140bf267 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -3,6 +3,7 @@ # pylint:disable=too-many-lines import argparse +import gettext import logging import re import sys @@ -20,6 +21,10 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name _GPUS = GPUStats().cli_devices +# LOCALES +_LANG = gettext.translation("lib.cli.args", localedir="locales", fallback=True) +_ = _LANG.gettext + class FullHelpArgumentParser(argparse.ArgumentParser): """ Extends :class:`argparse.ArgumentParser` to output full help on bad arguments. """ @@ -169,27 +174,27 @@ def _get_global_arguments(): type=str.lower, nargs="+", choices=[str(idx) for idx in range(len(_GPUS))], - group="Global Options", - help="R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " - "to any GPU(s) that you do not wish to be made available to Faceswap. " - "Selecting all GPUs here will force Faceswap into CPU mode." - "\nL|{}".format(" \nL|".join(_GPUS)))) + group=_("Global Options"), + help=_("R|Exclude GPUs from use by Faceswap. Select the number(s) which " + "correspond to any GPU(s) that you do not wish to be made available to " + "Faceswap. Selecting all GPUs here will force Faceswap into CPU mode." + "\nL|{}").format(" \nL|".join(_GPUS)))) global_args.append(dict( opts=("-C", "--configfile"), action=FileFullPaths, filetypes="ini", type=str, - group="Global Options", - help="Optionally overide the saved config with the path to a custom config file.")) + group=_("Global Options"), + help=_("Optionally overide the saved config with the path to a custom config file."))) global_args.append(dict( opts=("-L", "--loglevel"), type=str.upper, dest="loglevel", default="INFO", choices=("INFO", "VERBOSE", "DEBUG", "TRACE"), - group="Global Options", - help="Log level. Stick with INFO or VERBOSE unless you need to file an error report. " - "Be careful with TRACE as it will generate a lot of data")) + group=_("Global Options"), + help=_("Log level. Stick with INFO or VERBOSE unless you need to file an error " + "report. Be careful with TRACE as it will generate a lot of data"))) global_args.append(dict( opts=("-LF", "--logfile"), action=SaveFileFullPaths, @@ -197,8 +202,8 @@ def _get_global_arguments(): type=str, dest="logfile", default=None, - group="Global Options", - help="Path to store the logfile. Leave blank to store in the faceswap folder")) + group=_("Global Options"), + help=_("Path to store the logfile. Leave blank to store in the faceswap folder"))) # These are hidden arguments to indicate that the GUI/Colab is being used global_args.append(dict( opts=("-gui", "--gui"), @@ -291,26 +296,26 @@ def get_argument_list(): filetypes="video", dest="input_dir", required=True, - group="Data", - help="Input directory or video. Either a directory containing the image files you " - "wish to process or path to a video file. NB: This should be the source video/" - "frames NOT the source faces.")) + group=_("Data"), + help=_("Input directory or video. Either a directory containing the image files you " + "wish to process or path to a video file. NB: This should be the source video/" + "frames NOT the source faces."))) argument_list.append(dict( opts=("-o", "--output-dir"), action=DirFullPaths, dest="output_dir", required=True, - group="Data", - help="Output directory. This is where the converted files will be saved.")) + group=_("Data"), + help=_("Output directory. This is where the converted files will be saved."))) argument_list.append(dict( opts=("-al", "--alignments"), action=FileFullPaths, filetypes="alignments", type=str, dest="alignments_path", - group="Data", - help="Optional path to an alignments file. Leave blank if the alignments file is " - "at the default location.")) + group=_("Data"), + help=_("Optional path to an alignments file. Leave blank if the alignments file is " + "at the default location."))) return argument_list @@ -332,8 +337,8 @@ def get_info(): str The information text for the Extract command. """ - return ("Extract faces from image or video sources.\n" - "Extraction plugins can be configured in the 'Settings' Menu") + return _("Extract faces from image or video sources.\n" + "Extraction plugins can be configured in the 'Settings' Menu") @staticmethod def get_optional_arguments(): @@ -357,27 +362,27 @@ def get_optional_arguments(): type=str.lower, default=default_detector, choices=PluginLoader.get_available_extractors("detect"), - group="Plugins", - help="R|Detector to use. Some of these have configurable settings in " - "'/config/extract.ini' or 'Settings > Configure Extract 'Plugins':" - "\nL|cv2-dnn: A CPU only extractor which is the least reliable and least " - "resource intensive. Use this if not using a GPU and time is important." - "\nL|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " - "than other GPU detectors but can often return more false positives." - "\nL|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " - "fewer false positives than other GPU detectors, but is a lot more resource " - "intensive.")) + group=_("Plugins"), + help=_("R|Detector to use. Some of these have configurable settings in " + "'/config/extract.ini' or 'Settings > Configure Extract 'Plugins':" + "\nL|cv2-dnn: A CPU only extractor which is the least reliable and least " + "resource intensive. Use this if not using a GPU and time is important." + "\nL|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " + "than other GPU detectors but can often return more false positives." + "\nL|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces " + "and fewer false positives than other GPU detectors, but is a lot more " + "resource intensive."))) argument_list.append(dict( opts=("-A", "--aligner"), action=Radio, type=str.lower, default=default_aligner, choices=PluginLoader.get_available_extractors("align"), - group="Plugins", - help="R|Aligner to use." - "\nL|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " - "but less accurate. Only use this if not using a GPU and time is important." - "\nL|fan: Best aligner. Fast on GPU, slow on CPU.")) + group=_("Plugins"), + help=_("R|Aligner to use." + "\nL|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " + "but less accurate. Only use this if not using a GPU and time is important." + "\nL|fan: Best aligner. Fast on GPU, slow on CPU."))) argument_list.append(dict( opts=("-M", "--masker"), action=MultiOption, @@ -385,31 +390,31 @@ def get_optional_arguments(): nargs="+", choices=[mask for mask in PluginLoader.get_available_extractors("mask") if mask not in ("components", "extended")], - group="Plugins", - help="R|Additional Masker(s) to use. The masks generated here will all take up GPU " - "RAM. You can select none, one or multiple masks, but the extraction may take " - "longer the more you select. NB: The Extended and Components (landmark based) " - "masks are automatically generated on extraction." - "\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." - "\nThe auto generated masks are as follows:" - "\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|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." - "\n(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)")) + group=_("Plugins"), + help=_("R|Additional Masker(s) to use. The masks generated here will all take up GPU " + "RAM. You can select none, one or multiple masks, but the extraction may take " + "longer the more you select. NB: The Extended and Components (landmark based) " + "masks are automatically generated on extraction." + "\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." + "\nThe auto generated masks are as follows:" + "\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|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." + "\n(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)"))) argument_list.append(dict( opts=("-nm", "--normalization"), action=Radio, @@ -417,16 +422,16 @@ def get_optional_arguments(): dest="normalization", default="none", choices=["none", "clahe", "hist", "mean"], - group="plugins", - help="R|Performing normalization can help the aligner better align faces with " - "difficult lighting conditions at an extraction speed cost. Different methods " - "will yield different results on different sets. NB: This does not impact the " - "output face, just the input to the aligner." - "\nL|none: Don't perform normalization on the face." - "\nL|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " - "face." - "\nL|hist: Equalize the histograms on the RGB channels." - "\nL|mean: Normalize the face colors to the mean.")) + group=_("Plugins"), + help=_("R|Performing normalization can help the aligner better align faces with " + "difficult lighting conditions at an extraction speed cost. Different methods " + "will yield different results on different sets. NB: This does not impact the " + "output face, just the input to the aligner." + "\nL|none: Don't perform normalization on the face." + "\nL|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " + "face." + "\nL|hist: Equalize the histograms on the RGB channels." + "\nL|mean: Normalize the face colors to the mean."))) argument_list.append(dict( opts=("-rf", "--re-feed"), action=Slider, @@ -435,23 +440,23 @@ def get_optional_arguments(): type=int, dest="re_feed", default=0, - group="plugins", - help="The number of times to re-feed the detected face into the aligner. Each time " - "the face is re-fed into the aligner the bounding box is adjusted by a small " - "amount. The final landmarks are then averaged from each iteration. Helps to " - "remove 'micro-jitter' but at the cost of slower extraction speed. The more " - "times the face is re-fed into the aligner, the less micro-jitter should occur " - "but the longer extraction will take.")) + group=_("Plugins"), + help=_("The number of times to re-feed the detected face into the aligner. Each time " + "the face is re-fed into the aligner the bounding box is adjusted by a small " + "amount. The final landmarks are then averaged from each iteration. Helps to " + "remove 'micro-jitter' but at the cost of slower extraction speed. The more " + "times the face is re-fed into the aligner, the less micro-jitter should occur " + "but the longer extraction will take."))) argument_list.append(dict( opts=("-r", "--rotate-images"), type=str, dest="rotate_images", default=None, - group="plugins", - help="If a face isn't found, rotate the images to try to find a face. Can find " - "more faces at the cost of extraction speed. Pass in a single number to use " - "increments of that size up to 360, or pass in a list of numbers to enumerate " - "exactly what angles to check.")) + group=_("Plugins"), + help=_("If a face isn't found, rotate the images to try to find a face. Can find " + "more faces at the cost of extraction speed. Pass in a single number to use " + "increments of that size up to 360, or pass in a list of numbers to enumerate " + "exactly what angles to check."))) argument_list.append(dict( opts=("-min", "--min-size"), action=Slider, @@ -460,9 +465,9 @@ def get_optional_arguments(): type=int, dest="min_size", default=0, - group="Face Processing", - help="Filters out faces detected below this size. Length, in pixels across the " - "diagonal of the bounding box. Set to 0 for off")) + group=_("Face Processing"), + help=_("Filters out faces detected below this size. Length, in pixels across the " + "diagonal of the bounding box. Set to 0 for off"))) argument_list.append(dict( opts=("-n", "--nfilter"), action=FilesFullPaths, @@ -470,12 +475,12 @@ def get_optional_arguments(): dest="nfilter", default=None, nargs="+", - group="Face Processing", - help="Optionally filter out people who you do not wish to process by passing in an " - "image of that person. Should be a front portrait with a single person in the " - "image. Multiple images can be added space separated. NB: Using face filter " - "will significantly decrease extraction speed and its accuracy cannot be " - "guaranteed.")) + group=_("Face Processing"), + help=_("Optionally filter out people who you do not wish to process by passing in an " + "image of that person. Should be a front portrait with a single person in the " + "image. Multiple images can be added space separated. NB: Using face filter " + "will significantly decrease extraction speed and its accuracy cannot be " + "guaranteed."))) argument_list.append(dict( opts=("-f", "--filter"), action=FilesFullPaths, @@ -483,12 +488,12 @@ def get_optional_arguments(): dest="filter", default=None, nargs="+", - group="Face Processing", - help="Optionally select people you wish to process by passing in an image of that " - "person. Should be a front portrait with a single person in the image. " - "Multiple images can be added space separated. NB: Using face filter will " - "significantly decrease extraction speed and its accuracy cannot be " - "guaranteed.")) + group=_("Face Processing"), + help=_("Optionally select people you wish to process by passing in an image of that " + "person. Should be a front portrait with a single person in the image. " + "Multiple images can be added space separated. NB: Using face filter will " + "significantly decrease extraction speed and its accuracy cannot be " + "guaranteed."))) argument_list.append(dict( opts=("-l", "--ref_threshold"), action=Slider, @@ -497,11 +502,11 @@ def get_optional_arguments(): type=float, dest="ref_threshold", default=0.4, - group="Face Processing", - help="For use with the optional nfilter/filter files. Threshold for positive face " - "recognition. Lower values are stricter. NB: Using face filter will " - "significantly decrease extraction speed and its accuracy cannot be " - "guaranteed.")) + group=_("Face Processing"), + help=_("For use with the optional nfilter/filter files. Threshold for positive face " + "recognition. Lower values are stricter. NB: Using face filter will " + "significantly decrease extraction speed and its accuracy cannot be " + "guaranteed."))) argument_list.append(dict( opts=("-sz", "--size"), action=Slider, @@ -509,10 +514,10 @@ def get_optional_arguments(): rounding=64, type=int, default=512, - group="output", - help="The output size of extracted faces. Make sure that the model you intend to " - "train supports your required size. This will only need to be changed for " - "hi-res models.")) + group=_("output"), + help=_("The output size of extracted faces. Make sure that the model you intend to " + "train supports your required size. This will only need to be changed for " + "hi-res models."))) argument_list.append(dict( opts=("-een", "--extract-every-n"), action=Slider, @@ -521,10 +526,10 @@ def get_optional_arguments(): type=int, dest="extract_every_n", default=1, - group="output", - help="Extract every 'nth' frame. This option will skip frames when extracting " - "faces. For example a value of 1 will extract faces from every frame, a value " - "of 10 will extract faces from every 10th frame.")) + group=_("output"), + help=_("Extract every 'nth' frame. This option will skip frames when extracting " + "faces. For example a value of 1 will extract faces from every frame, a value " + "of 10 will extract faces from every 10th frame."))) argument_list.append(dict( opts=("-si", "--save-interval"), action=Slider, @@ -533,51 +538,51 @@ def get_optional_arguments(): type=int, dest="save_interval", default=0, - group="output", - help="Automatically save the alignments file after a set amount of frames. By " - "default the alignments file is only saved at the end of the extraction " - "process. NB: If extracting in 2 passes then the alignments file will only " - "start to be saved out during the second pass. WARNING: Don't interrupt the " - "script when writing the file because it might get corrupted. Set to 0 to " - "turn off")) + group=_("output"), + help=_("Automatically save the alignments file after a set amount of frames. By " + "default the alignments file is only saved at the end of the extraction " + "process. NB: If extracting in 2 passes then the alignments file will only " + "start to be saved out during the second pass. WARNING: Don't interrupt the " + "script when writing the file because it might get corrupted. Set to 0 to " + "turn off"))) argument_list.append(dict( opts=("-dl", "--debug-landmarks"), action="store_true", dest="debug_landmarks", default=False, - group="output", - help="Draw landmarks on the ouput faces for debugging purposes.")) + group=_("output"), + help=_("Draw landmarks on the ouput faces for debugging purposes."))) argument_list.append(dict( opts=("-sp", "--singleprocess"), action="store_true", default=False, backend="nvidia", - group="settings", - help="Don't run extraction in parallel. Will run each part of the extraction " - "process separately (one after the other) rather than all at the smae time. " - "Useful if VRAM is at a premium.")) + group=_("settings"), + help=_("Don't run extraction in parallel. Will run each part of the extraction " + "process separately (one after the other) rather than all at the smae time. " + "Useful if VRAM is at a premium."))) argument_list.append(dict( opts=("-s", "--skip-existing"), action="store_true", dest="skip_existing", default=False, - group="settings", - help="Skips frames that have already been extracted and exist in the alignments " - "file")) + group=_("settings"), + help=_("Skips frames that have already been extracted and exist in the alignments " + "file"))) argument_list.append(dict( opts=("-sf", "--skip-existing-faces"), action="store_true", dest="skip_faces", default=False, - group="settings", - help="Skip frames that already have detected faces in the alignments file")) + group=_("settings"), + help=_("Skip frames that already have detected faces in the alignments file"))) argument_list.append(dict( opts=("-ssf", "--skip-saving-faces"), action="store_true", dest="skip_saving_faces", default=False, - group="settings", - help="Skip saving the detected faces to disk. Just create an alignments file")) + group=_("settings"), + help=_("Skip saving the detected faces to disk. Just create an alignments file"))) return argument_list @@ -599,8 +604,8 @@ def get_info(): str The information text for the Convert command. """ - return ("Swap the original faces in a source video/images to your final faces.\n" - "Conversion plugins can be configured in the 'Settings' Menu") + return _("Swap the original faces in a source video/images to your final faces.\n" + "Conversion plugins can be configured in the 'Settings' Menu") @staticmethod def get_optional_arguments(): @@ -619,18 +624,18 @@ def get_optional_arguments(): filetypes="video", type=str, dest="reference_video", - group="data", - help="Only required if converting from images to video. Provide The original video " - "that the source frames were extracted from (for extracting the fps and " - "audio).")) + group=_("Data"), + help=_("Only required if converting from images to video. Provide The original video " + "that the source frames were extracted from (for extracting the fps and " + "audio)."))) argument_list.append(dict( opts=("-m", "--model-dir"), action=DirFullPaths, dest="model_dir", required=True, - group="data", - help="Model directory. The directory containing the trained model you wish to use " - "for conversion.")) + group=_("Data"), + help=_("Model directory. The directory containing the trained model you wish to use " + "for conversion."))) argument_list.append(dict( opts=("-c", "--color-adjustment"), action=Radio, @@ -638,24 +643,24 @@ def get_optional_arguments(): dest="color_adjustment", default="avg-color", choices=PluginLoader.get_available_convert_plugins("color", True), - group="plugins", - help="R|Performs color adjustment to the swapped face. Some of these options have " - "configurable settings in '/config/convert.ini' or 'Settings > Configure " - "Convert Plugins':" - "\nL|avg-color: Adjust the mean of each color channel in the swapped " - "reconstruction to equal the mean of the masked area in the original image." - "\nL|color-transfer: Transfers the color distribution from the source to the " - "target image using the mean and standard deviations of the L*a*b* " - "color space." - "\nL|manual-balance: Manually adjust the balance of the image in a variety of " - "color spaces. Best used with the Preview tool to set correct values." - "\nL|match-hist: Adjust the histogram of each color channel in the swapped " - "reconstruction to equal the histogram of the masked area in the original " - "image." - "\nL|seamless-clone: Use cv2's seamless clone function to remove extreme " - "gradients at the mask seam by smoothing colors. Generally does not give " - "very satisfactory results." - "\nL|none: Don't perform color adjustment.")) + group=_("Plugins"), + help=_("R|Performs color adjustment to the swapped face. Some of these options have " + "configurable settings in '/config/convert.ini' or 'Settings > Configure " + "Convert Plugins':" + "\nL|avg-color: Adjust the mean of each color channel in the swapped " + "reconstruction to equal the mean of the masked area in the original image." + "\nL|color-transfer: Transfers the color distribution from the source to the " + "target image using the mean and standard deviations of the L*a*b* " + "color space." + "\nL|manual-balance: Manually adjust the balance of the image in a variety of " + "color spaces. Best used with the Preview tool to set correct values." + "\nL|match-hist: Adjust the histogram of each color channel in the swapped " + "reconstruction to equal the histogram of the masked area in the original " + "image." + "\nL|seamless-clone: Use cv2's seamless clone function to remove extreme " + "gradients at the mask seam by smoothing colors. Generally does not give " + "very satisfactory results." + "\nL|none: Don't perform color adjustment."))) argument_list.append(dict( opts=("-M", "--mask-type"), action=Radio, @@ -663,45 +668,45 @@ def get_optional_arguments(): dest="mask_type", default="extended", choices=PluginLoader.get_available_extractors("mask", add_none=True) + ["predicted"], - group="Plugins", - help="R|Masker to use. NB: The mask you require must exist within the alignments " - "file. You can add additional masks with the Mask Tool." - "\nL|none: Don't use a mask." - "\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|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.")) + group=_("Plugins"), + help=_("R|Masker to use. NB: The mask you require must exist within the alignments " + "file. You can add additional masks with the Mask Tool." + "\nL|none: Don't use a mask." + "\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|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=("-w", "--writer"), action=Radio, type=str, default="opencv", choices=PluginLoader.get_available_convert_plugins("writer", False), - group="plugins", - help="R|The plugin to use to output the converted images. The writers are " - "configurable in '/config/convert.ini' or 'Settings > Configure Convert " - "Plugins:'" - "\nL|ffmpeg: [video] Writes out the convert straight to video. When the input " - "is a series of images then the '-ref' (--reference-video) parameter must be " - "set." - "\nL|gif: [animated image] Create an animated gif." - "\nL|opencv: [images] The fastest image writer, but less options and formats " - "than other plugins." - "\nL|pillow: [images] Slower than opencv, but has more options and supports " - "more formats.")) + group=_("Plugins"), + help=_("R|The plugin to use to output the converted images. The writers are " + "configurable in '/config/convert.ini' or 'Settings > Configure Convert " + "Plugins:'" + "\nL|ffmpeg: [video] Writes out the convert straight to video. When the input " + "is a series of images then the '-ref' (--reference-video) parameter must be " + "set." + "\nL|gif: [animated image] Create an animated gif." + "\nL|opencv: [images] The fastest image writer, but less options and formats " + "than other plugins." + "\nL|pillow: [images] Slower than opencv, but has more options and supports " + "more formats."))) argument_list.append(dict( opts=("-osc", "--output-scale"), action=Slider, @@ -710,30 +715,30 @@ def get_optional_arguments(): type=int, dest="output_scale", default=100, - group="Frame Processing", - help="Scale the final output frames by this amount. 100%% will output the frames " - "at source dimensions. 50%% at half size 200%% at double size")) + group=_("Frame Processing"), + help=_("Scale the final output frames by this amount. 100%% will output the frames " + "at source dimensions. 50%% at half size 200%% at double size"))) argument_list.append(dict( opts=("-fr", "--frame-ranges"), type=str, nargs="+", - group="Frame Processing", - help="Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " - "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " - "will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " - "converting from images, then the filenames must end with the frame-number!")) + group=_("Frame Processing"), + help=_("Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " + "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " + "will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " + "converting from images, then the filenames must end with the frame-number!"))) argument_list.append(dict( opts=("-a", "--input-aligned-dir"), action=DirFullPaths, dest="input_aligned_dir", default=None, - group="Face Processing", - help="If you have not cleansed your alignments file, then you can filter out faces " - "by defining a folder here that contains the faces extracted from your input " - "files/video. If this folder is defined, then only faces that exist within " - "your alignments file and also exist within the specified folder will be " - "converted. Leaving this blank will convert all faces that exist within the " - "alignments file.")) + group=_("Face Processing"), + help=_("If you have not cleansed your alignments file, then you can filter out faces " + "by defining a folder here that contains the faces extracted from your input " + "files/video. If this folder is defined, then only faces that exist within " + "your alignments file and also exist within the specified folder will be " + "converted. Leaving this blank will convert all faces that exist within the " + "alignments file."))) argument_list.append(dict( opts=("-n", "--nfilter"), action=FilesFullPaths, @@ -741,12 +746,12 @@ def get_optional_arguments(): dest="nfilter", default=None, nargs="+", - group="Face Processing", - help="Optionally filter out people who you do not wish to process by passing in an " - "image of that person. Should be a front portrait with a single person in the " - "image. Multiple images can be added space separated. NB: Using face filter " - "will significantly decrease extraction speed and its accuracy cannot be " - "guaranteed.")) + group=_("Face Processing"), + help=_("Optionally filter out people who you do not wish to process by passing in an " + "image of that person. Should be a front portrait with a single person in the " + "image. Multiple images can be added space separated. NB: Using face filter " + "will significantly decrease extraction speed and its accuracy cannot be " + "guaranteed."))) argument_list.append(dict( opts=("-f", "--filter"), action=FilesFullPaths, @@ -754,12 +759,12 @@ def get_optional_arguments(): dest="filter", default=None, nargs="+", - group="Face Processing", - help="Optionally select people you wish to process by passing in an image of that " - "person. Should be a front portrait with a single person in the image. " - "Multiple images can be added space separated. NB: Using face filter will " - "significantly decrease extraction speed and its accuracy cannot be " - "guaranteed.")) + group=_("Face Processing"), + help=_("Optionally select people you wish to process by passing in an image of that " + "person. Should be a front portrait with a single person in the image. " + "Multiple images can be added space separated. NB: Using face filter will " + "significantly decrease extraction speed and its accuracy cannot be " + "guaranteed."))) argument_list.append(dict( opts=("-l", "--ref_threshold"), action=Slider, @@ -768,11 +773,11 @@ def get_optional_arguments(): type=float, dest="ref_threshold", default=0.4, - group="Face Processing", - help="For use with the optional nfilter/filter files. Threshold for positive face " - "recognition. Lower values are stricter. NB: Using face filter will " - "significantly decrease extraction speed and its accuracy cannot be " - "guaranteed.")) + group=_("Face Processing"), + help=_("For use with the optional nfilter/filter files. Threshold for positive face " + "recognition. Lower values are stricter. NB: Using face filter will " + "significantly decrease extraction speed and its accuracy cannot be " + "guaranteed."))) argument_list.append(dict( opts=("-j", "--jobs"), action=Slider, @@ -781,52 +786,52 @@ def get_optional_arguments(): type=int, dest="jobs", default=0, - group="settings", - help="The maximum number of parallel processes for performing conversion. " - "Converting images is system RAM heavy so it is possible to run out of memory " - "if you have a lot of processes and not enough RAM to accommodate them all. " - "Setting this to 0 will use the maximum available. No matter what you set " - "this to, it will never attempt to use more processes than are available on " - "your system. If singleprocess is enabled this setting will be ignored.")) + group=_("settings"), + help=_("The maximum number of parallel processes for performing conversion. " + "Converting images is system RAM heavy so it is possible to run out of memory " + "if you have a lot of processes and not enough RAM to accommodate them all. " + "Setting this to 0 will use the maximum available. No matter what you set " + "this to, it will never attempt to use more processes than are available on " + "your system. If singleprocess is enabled this setting will be ignored."))) argument_list.append(dict( opts=("-t", "--trainer"), type=str.lower, choices=PluginLoader.get_available_models(), - group="settings", - help="[LEGACY] This only needs to be selected if a legacy model is being loaded or " - "if there are multiple models in the model folder")) + group=_("settings"), + help=_("[LEGACY] This only needs to be selected if a legacy model is being loaded or " + "if there are multiple models in the model folder"))) argument_list.append(dict( opts=("-otf", "--on-the-fly"), action="store_true", dest="on_the_fly", default=False, - group="settings", - help="Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " - "alignments file for your destination video. However, if you wish you can " - "generate the alignments on-the-fly by enabling this option. This will use " - "an inferior extraction pipeline and will lead to substandard results. If an " - "alignments file is found, this option will be ignored.")) + group=_("settings"), + help=_("Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " + "alignments file for your destination video. However, if you wish you can " + "generate the alignments on-the-fly by enabling this option. This will use " + "an inferior extraction pipeline and will lead to substandard results. If an " + "alignments file is found, this option will be ignored."))) argument_list.append(dict( opts=("-k", "--keep-unchanged"), action="store_true", dest="keep_unchanged", default=False, - group="Frame Processing", - help="When used with --frame-ranges outputs the unchanged frames that are not " - "processed instead of discarding them.")) + group=_("Frame Processing"), + help=_("When used with --frame-ranges outputs the unchanged frames that are not " + "processed instead of discarding them."))) argument_list.append(dict( opts=("-s", "--swap-model"), action="store_true", dest="swap_model", default=False, - group="settings", - help="Swap the model. Instead converting from of A -> B, converts B -> A")) + group=_("settings"), + help=_("Swap the model. Instead converting from of A -> B, converts B -> A"))) argument_list.append(dict( opts=("-sp", "--singleprocess"), action="store_true", default=False, - group="settings", - help="Disable multiprocessing. Slower but less resource intensive.")) + group=_("settings"), + help=_("Disable multiprocessing. Slower but less resource intensive."))) return argument_list @@ -842,9 +847,9 @@ def get_info(): str The information text for the Train command. """ - return ("Train a model on extracted original (A) and swap (B) faces.\n" - "Training models can take a long time. Anything from 24hrs to over a week\n" - "Model plugins can be configured in the 'Settings' Menu") + return _("Train a model on extracted original (A) and swap (B) faces.\n" + "Training models can take a long time. Anything from 24hrs to over a week\n" + "Model plugins can be configured in the 'Settings' Menu") @staticmethod def get_argument_list(): @@ -861,10 +866,10 @@ def get_argument_list(): action=DirFullPaths, dest="input_a", required=True, - group="faces", - help="Input directory. A directory containing training images for face A. This is " - "the original face, i.e. the face that you want to remove and replace with " - "face B.")) + group=_("faces"), + help=_("Input directory. A directory containing training images for face A. This is " + "the original face, i.e. the face that you want to remove and replace with " + "face B."))) argument_list.append(dict( opts=("-ala", "--alignments-A"), action=FileFullPaths, @@ -872,19 +877,19 @@ def get_argument_list(): type=str, dest="alignments_path_a", default=None, - group="faces", - help="DEPRECATED - This option will be removed in a future update. Path to alignments " - "file for training set A. Defaults to /alignments.json if not " - "provided.")) + group=_("faces"), + help=_("DEPRECATED - This option will be removed in a future update. Path to " + "alignments file for training set A. Defaults to /alignments.json if " + "not provided."))) argument_list.append(dict( opts=("-B", "--input-B"), action=DirFullPaths, dest="input_b", required=True, - group="faces", - help="Input directory. A directory containing training images for face B. This is " - "the swap face, i.e. the face that you want to place onto the head of person " - "A.")) + group=_("faces"), + help=_("Input directory. A directory containing training images for face B. This is " + "the swap face, i.e. the face that you want to place onto the head of person " + "A."))) argument_list.append(dict( opts=("-alb", "--alignments-B"), action=FileFullPaths, @@ -892,47 +897,47 @@ def get_argument_list(): type=str, dest="alignments_path_b", default=None, - group="faces", - help="DEPRECATED - This option will be removed in a future update. Path to alignments " - "file for training set B. Defaults to /alignments.json if not " - "provided.")) + group=_("faces"), + help=_("DEPRECATED - This option will be removed in a future update. Path to " + "alignments file for training set B. Defaults to /alignments.json if " + "not provided."))) argument_list.append(dict( opts=("-m", "--model-dir"), action=DirFullPaths, dest="model_dir", required=True, - group="model", - help="Model directory. This is where the training data will be stored. You should " - "always specify a new folder for new models. If starting a new model, select " - "either an empty folder, or a folder which does not exist (which will be " - "created). If continuing to train an existing model, specify the location of " - "the existing model.")) + group=_("model"), + help=_("Model directory. This is where the training data will be stored. You should " + "always specify a new folder for new models. If starting a new model, select " + "either an empty folder, or a folder which does not exist (which will be " + "created). If continuing to train an existing model, specify the location of " + "the existing model."))) argument_list.append(dict( opts=("-t", "--trainer"), action=Radio, type=str.lower, default=PluginLoader.get_default_model(), choices=PluginLoader.get_available_models(), - group="model", - help="R|Select which trainer to use. Trainers can be configured from the Settings " - "menu or the config folder." - "\nL|original: The original model created by /u/deepfakes." - "\nL|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " - "for full dfaker method." - "\nL|dfl-h128: 128px in/out model from deepfacelab" - "\nL|dfl-sae: Adaptable model from deepfacelab" - "\nL|dlight: A lightweight, high resolution DFaker variant." - "\nL|iae: A model that uses intermediate layers to try to get better details" - "\nL|lightweight: A lightweight model for low-end cards. Don't expect great " - "results. Can train as low as 1.6GB with batch size 8." - "\nL|realface: A high detail, dual density model based on DFaker, with " - "customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " - "won't work so well. By andenixa et al. Very configurable." - "\nL|unbalanced: 128px in/out model from andenixa. The autoencoders are " - "unbalanced so B>A swaps won't work so well. Very configurable." - "\nL|villain: 128px in/out model from villainguy. Very resource hungry (You " - "will require a GPU with a fair amount of VRAM). Good for details, but more " - "susceptible to color differences.")) + group=_("model"), + help=_("R|Select which trainer to use. Trainers can be configured from the Settings " + "menu or the config folder." + "\nL|original: The original model created by /u/deepfakes." + "\nL|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " + "for full dfaker method." + "\nL|dfl-h128: 128px in/out model from deepfacelab" + "\nL|dfl-sae: Adaptable model from deepfacelab" + "\nL|dlight: A lightweight, high resolution DFaker variant." + "\nL|iae: A model that uses intermediate layers to try to get better details" + "\nL|lightweight: A lightweight model for low-end cards. Don't expect great " + "results. Can train as low as 1.6GB with batch size 8." + "\nL|realface: A high detail, dual density model based on DFaker, with " + "customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " + "won't work so well. By andenixa et al. Very configurable." + "\nL|unbalanced: 128px in/out model from andenixa. The autoencoders are " + "unbalanced so B>A swaps won't work so well. Very configurable." + "\nL|villain: 128px in/out model from villainguy. Very resource hungry (You " + "will require a GPU with a fair amount of VRAM). Good for details, but more " + "susceptible to color differences."))) argument_list.append(dict( opts=("-bs", "--batch-size"), action=Slider, @@ -941,11 +946,11 @@ def get_argument_list(): type=int, dest="batch_size", default=16, - group="training", - help="Batch size. This is the number of images processed through the model for each " - "side per iteration. NB: As the model is fed 2 sides at a time, the actual " - "number of images within the model at any one time is double the number that you " - "set here. Larger batches require more GPU RAM.")) + group=_("training"), + help=_("Batch size. This is the number of images processed through the model for each " + "side per iteration. NB: As the model is fed 2 sides at a time, the actual " + "number of images within the model at any one time is double the number that " + "you set here. Larger batches require more GPU RAM."))) argument_list.append(dict( opts=("-it", "--iterations"), action=Slider, @@ -953,19 +958,20 @@ def get_argument_list(): rounding=20000, type=int, default=1000000, - group="training", - help="Length of training in iterations. This is only really used for automation. " - "There is no 'correct' number of iterations a model should be trained for. " - "You should stop training when you are happy with the previews. However, if " - "you want the model to stop automatically at a set number of iterations, you " - "can set that value here.")) + group=_("training"), + help=_("Length of training in iterations. This is only really used for automation. " + "There is no 'correct' number of iterations a model should be trained for. " + "You should stop training when you are happy with the previews. However, if " + "you want the model to stop automatically at a set number of iterations, you " + "can set that value here."))) argument_list.append(dict( opts=("-d", "--distributed"), action="store_true", default=False, backend="nvidia", - group="training", - help="Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs.")) + group=_("training"), + help=_("Use the Tensorflow Mirrored Distrubution Strategy to train on multiple " + "GPUs."))) argument_list.append(dict( opts=("-s", "--save-interval"), action=Slider, @@ -974,8 +980,8 @@ def get_argument_list(): type=int, dest="save_interval", default=250, - group="Saving", - help="Sets the number of iterations between each model save.")) + group=_("Saving"), + help=_("Sets the number of iterations between each model save."))) argument_list.append(dict( opts=("-ss", "--snapshot-interval"), action=Slider, @@ -984,41 +990,41 @@ def get_argument_list(): type=int, dest="snapshot_interval", default=25000, - group="Saving", - help="Sets the number of iterations before saving a backup snapshot of the model " - "in it's current state. Set to 0 for off.")) + group=_("Saving"), + help=_("Sets the number of iterations before saving a backup snapshot of the model " + "in it's current state. Set to 0 for off."))) argument_list.append(dict( opts=("-tia", "--timelapse-input-A"), action=DirFullPaths, dest="timelapse_input_a", default=None, - group="timelapse", - help="Optional for creating a timelapse. Timelapse will save an image of your " - "selected faces into the timelapse-output folder at every save iteration. " - "This should be the input folder of 'A' faces that you would like to use for " - "creating the timelapse. You must also supply a --timelapse-output and a " - "--timelapse-input-B parameter.")) + group=_("timelapse"), + help=_("Optional for creating a timelapse. Timelapse will save an image of your " + "selected faces into the timelapse-output folder at every save iteration. " + "This should be the input folder of 'A' faces that you would like to use for " + "creating the timelapse. You must also supply a --timelapse-output and a " + "--timelapse-input-B parameter."))) argument_list.append(dict( opts=("-tib", "--timelapse-input-B"), action=DirFullPaths, dest="timelapse_input_b", default=None, - group="timelapse", - help="Optional for creating a timelapse. Timelapse will save an image of your " - "selected faces into the timelapse-output folder at every save iteration. " - "This should be the input folder of 'B' faces that you would like to use for " - "creating the timelapse. You must also supply a --timelapse-output and a " - "--timelapse-input-A parameter.")) + group=_("timelapse"), + help=_("Optional for creating a timelapse. Timelapse will save an image of your " + "selected faces into the timelapse-output folder at every save iteration. " + "This should be the input folder of 'B' faces that you would like to use for " + "creating the timelapse. You must also supply a --timelapse-output and a " + "--timelapse-input-A parameter."))) argument_list.append(dict( opts=("-to", "--timelapse-output"), action=DirFullPaths, dest="timelapse_output", default=None, - group="timelapse", - help="Optional for creating a timelapse. Timelapse will save an image of your " - "selected faces into the timelapse-output folder at every save iteration. If " - "the input folders are supplied but no output folder, it will default to your " - "model folder /timelapse/")) + group=_("timelapse"), + help=_("Optional for creating a timelapse. Timelapse will save an image of your " + "selected faces into the timelapse-output folder at every save iteration. If " + "the input folders are supplied but no output folder, it will default to your " + "model folder /timelapse/"))) argument_list.append(dict( opts=("-ps", "--preview-scale"), action=Slider, @@ -1027,68 +1033,68 @@ def get_argument_list(): type=int, dest="preview_scale", default=100, - group="preview", - help="Percentage amount to scale the preview by. 100%% is the model output size.")) + group=_("preview"), + help=_("Percentage amount to scale the preview by. 100%% is the model output size."))) argument_list.append(dict( opts=("-p", "--preview"), action="store_true", dest="preview", default=False, - group="preview", - help="Show training preview output. in a separate window.")) + group=_("preview"), + help=_("Show training preview output. in a separate window."))) argument_list.append(dict( opts=("-w", "--write-image"), action="store_true", dest="write_image", default=False, - group="preview", - help="Writes the training result to a file. The image will be stored in the root " - "of your FaceSwap folder.")) + group=_("preview"), + help=_("Writes the training result to a file. The image will be stored in the root " + "of your FaceSwap folder."))) argument_list.append(dict( opts=("-nl", "--no-logs"), action="store_true", dest="no_logs", default=False, - group="training", - help="Disables TensorBoard logging. NB: Disabling logs means that you will not be " - "able to use the graph or analysis for this session in the GUI.")) + group=_("training"), + help=_("Disables TensorBoard logging. NB: Disabling logs means that you will not be " + "able to use the graph or analysis for this session in the GUI."))) argument_list.append(dict( opts=("-wl", "--warp-to-landmarks"), action="store_true", dest="warp_to_landmarks", default=False, - group="augmentation", - help="Warps training faces to closely matched Landmarks from the opposite face-set " - "rather than randomly warping the face. This is the 'dfaker' way of doing " - "warping.")) + group=_("augmentation"), + help=_("Warps training faces to closely matched Landmarks from the opposite face-set " + "rather than randomly warping the face. This is the 'dfaker' way of doing " + "warping."))) argument_list.append(dict( opts=("-nf", "--no-flip"), action="store_true", dest="no_flip", default=False, - group="augmentation", - help="To effectively learn, a random set of images are flipped horizontally. " - "Sometimes it is desirable for this not to occur. Generally this should be " - "left off except for during 'fit training'.")) + group=_("augmentation"), + help=_("To effectively learn, a random set of images are flipped horizontally. " + "Sometimes it is desirable for this not to occur. Generally this should be " + "left off except for during 'fit training'."))) argument_list.append(dict( opts=("-nac", "--no-augment-color"), action="store_true", dest="no_augment_color", default=False, - group="augmentation", - help="Color augmentation helps make the model less susceptible to color " - "differences between the A and B sets, at an increased training time cost. " - "Enable this option to disable color augmentation.")) + group=_("augmentation"), + help=_("Color augmentation helps make the model less susceptible to color " + "differences between the A and B sets, at an increased training time cost. " + "Enable this option to disable color augmentation."))) argument_list.append(dict( opts=("-nw", "--no-warp"), action="store_true", dest="no_warp", default=False, - group="augmentation", - help="Warping is integral to training the Neural Network. This option should only be " - "enabled towards the very end of training to try to bring out more detail. Think " - "of it as 'fine-tuning'. Enabling this option from the beginning is likely to " - "kill a model and lead to terrible results.")) + group=_("augmentation"), + help=_("Warping is integral to training the Neural Network. This option should only " + "be enabled towards the very end of training to try to bring out more detail. " + "Think of it as 'fine-tuning'. Enabling this option from the beginning is " + "likely to kill a model and lead to terrible results."))) return argument_list @@ -1110,5 +1116,5 @@ def get_argument_list(): action="store_true", dest="debug", default=False, - help="Output to Shell console instead of GUI console")) + help=_("Output to Shell console instead of GUI console"))) return argument_list diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo new file mode 100644 index 0000000000000000000000000000000000000000..3c267f4e66d9200f40310b6f7f7d614a39182496 GIT binary patch literal 37615 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 literal 0 HcmV?d00001 diff --git a/locales/es/LC_MESSAGES/lib.cli.args.po b/locales/es/LC_MESSAGES/lib.cli.args.po new file mode 100644 index 0000000000..8c873107b2 --- /dev/null +++ b/locales/es/LC_MESSAGES/lib.cli.args.po @@ -0,0 +1,894 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2021-02-18 11:58-0000\n" +"PO-Revision-Date: 2021-02-18 18:41+0000\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 2.4.2\n" +"Last-Translator: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: es\n" + +#: lib/cli/args.py:172 lib/cli/args.py:182 lib/cli/args.py:190 +#: lib/cli/args.py:200 +msgid "Global Options" +msgstr "Opciones Globales" + +#: lib/cli/args.py:173 +msgid "" +"R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " +"to any GPU(s) that you do not wish to be made available to Faceswap. " +"Selecting all GPUs here will force Faceswap into CPU mode.\n" +"L|{}" +msgstr "" +"R|Excluir GPUs de su uso por Faceswap. Seleccione el/los número(s) que " +"correpondan a cualquier GPU(s) que no desee que esté disponible para su uso " +"con Faceswap. Marcar todas las GPUs forzará a Faceswap a usar sólo la CPU,\n" +"L|{}" + +#: lib/cli/args.py:183 +msgid "" +"Optionally overide the saved config with the path to a custom config file." +msgstr "Usar un fichero alternativo de configuración, almacenado en esta ruta." + +#: lib/cli/args.py:191 +msgid "" +"Log level. Stick with INFO or VERBOSE unless you need to file an error " +"report. Be careful with TRACE as it will generate a lot of data" +msgstr "" +"Nivel de registro. Dejarlo en INFO o VERBOSE, a menos que necesite informar " +"de un error. Tenga en cuenta que TRACE generará muchísima información" + +#: lib/cli/args.py:201 +msgid "Path to store the logfile. Leave blank to store in the faceswap folder" +msgstr "" +"Ruta para almacenar el fichero de registro. Dejarlo en blanco para " +"almacenarlo en la carpeta pde instalación de faceswap" + +#: lib/cli/args.py:294 lib/cli/args.py:303 lib/cli/args.py:311 +#: lib/cli/args.py:622 lib/cli/args.py:631 +msgid "Data" +msgstr "Datos" + +#: lib/cli/args.py:295 +msgid "" +"Input directory or video. Either a directory containing the image files you " +"wish to process or path to a video file. NB: This should be the source video/" +"frames NOT the source faces." +msgstr "" +"Directorio o vídeo de entrada. Un directorio que contenga los archivos de " +"imagen que desea procesar o la ruta a un archivo de vídeo. NB: Debe ser el " +"vídeo/los fotogramas de origen, NO las caras de origen." + +#: lib/cli/args.py:304 +msgid "Output directory. This is where the converted files will be saved." +msgstr "" +"Directorio de salida. Aquí es donde se guardarán los archivos convertidos." + +#: lib/cli/args.py:312 +msgid "" +"Optional path to an alignments file. Leave blank if the alignments file is " +"at the default location." +msgstr "" +"Ruta opcional a un archivo de alineaciones. Dejar en blanco si el archivo de " +"alineaciones está en la ubicación por defecto." + +#: lib/cli/args.py:360 lib/cli/args.py:376 lib/cli/args.py:388 +#: lib/cli/args.py:420 lib/cli/args.py:438 lib/cli/args.py:450 +#: lib/cli/args.py:641 lib/cli/args.py:666 lib/cli/args.py:693 +msgid "Plugins" +msgstr "Extensiones" + +#: lib/cli/args.py:361 +msgid "" +"R|Detector to use. Some of these have configurable settings in '/config/" +"extract.ini' or 'Settings > Configure Extract 'Plugins':\n" +"L|cv2-dnn: A CPU only extractor which is the least reliable and least " +"resource intensive. Use this if not using a GPU and time is important.\n" +"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " +"than other GPU detectors but can often return more false positives.\n" +"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " +"fewer false positives than other GPU detectors, but is a lot more resource " +"intensive." +msgstr "" +"R|Detector de caras a usar. Algunos tienen ajustes configurables en '/config/" +"extract.ini' o 'Ajustes > Configurar Extensiones de Extracción:\n" +"L|cv2-dnn: Extractor que usa sólo la CPU. Es el menos fiable y el que menos " +"recursos usa. Elegir este si necesita rapidez y no usar la GPU.\n" +"L|mtcnn: Buen detector. Rápido en la CPU y más rápido en la GPU. Usa menos " +"recursos que otros detectores basados en GPU, pero puede devolver más falsos " +"positivos.\n" +"L|s3fd: El mejor detector. Lento en la CPU, y más rápido en la GPU. Puede " +"detectar más caras y tiene menos falsos positivos que otros detectores " +"basados en GPU, pero uso muchos más recursos." + +#: lib/cli/args.py:377 +msgid "" +"R|Aligner to use.\n" +"L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " +"but less accurate. Only use this if not using a GPU and time is important.\n" +"L|fan: Best aligner. Fast on GPU, slow on CPU." +msgstr "" +"R|Alineador a usar.\n" +"L|cv2-dnn: Detector que usa sólo la CPU. Más rápido, usa menos recursos, " +"pero es menos preciso. Elegir este si necesita rapidez y no usar la GPU.\n" +"L|fan: El mejor alineador. Rápido en la GPU, y lento en la CPU." + +#: lib/cli/args.py:389 +msgid "" +"R|Additional Masker(s) to use. The masks generated here will all take up GPU " +"RAM. You can select none, one or multiple masks, but the extraction may take " +"longer the more you select. NB: The Extended and Components (landmark based) " +"masks are automatically generated on extraction.\n" +"L|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.\n" +"L|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.\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.\n" +"The auto generated masks are as follows:\n" +"L|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.\n" +"L|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.\n" +"(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" +msgstr "" +"R|Enmascarador(es) adicional(es) a usar. Las máscaras generadas aquí usarán " +"todas RAM de la GPU. Puede seleccionar una, varias o ninguna máscaras, pero " +"la extracción tardará más cuanto más marque. Las máscaras Extended y " +"Components son siempre generadas durante la extracción.\n" +"L|vgg-clear: Máscara diseñada para proporcionar una segmentación " +"inteligente de rostros principalmente frontales y libres de obstrucciones. " +"Los rostros de perfil y las obstrucciones pueden dar lugar a un rendimiento " +"inferior.\n" +"L|vgg-obstructed: Máscara diseñada para proporcionar una segmentación " +"inteligente de rostros principalmente frontales. El modelo de la máscara ha " +"sido entrenado específicamente para reconocer algunas obstrucciones faciales " +"(manos y gafas). Los rostros de perfil pueden dar lugar a un rendimiento " +"inferior.\n" +"L|unet-dfl: Máscara diseñada para proporcionar una segmentación inteligente " +"de rostros principalmente frontales. El modelo de máscara ha sido entrenado " +"por los miembros de la comunidad y necesitará ser probado para una mayor " +"descripción. Los rostros de perfil pueden dar lugar a un rendimiento " +"inferior.\n" +"Las máscaras que siempre se generan son:\n" +"L|components: Máscara diseñada para proporcionar una segmentación facial " +"basada en el posicionamiento de las ubicaciones de los puntos de referencia. " +"Se construye un casco convexo alrededor del exterior de los puntos de " +"referencia para crear una máscara.\n" +"L|extended: Máscara diseñada para proporcionar una segmentación facial " +"basada en el posicionamiento de las ubicaciones de los puntos de referencia. " +"Se construye un casco convexo alrededor del exterior de los puntos de " +"referencia y la máscara se extiende hacia arriba en la frente.\n" +"(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" + +#: lib/cli/args.py:421 +msgid "" +"R|Performing normalization can help the aligner better align faces with " +"difficult lighting conditions at an extraction speed cost. Different methods " +"will yield different results on different sets. NB: This does not impact the " +"output face, just the input to the aligner.\n" +"L|none: Don't perform normalization on the face.\n" +"L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " +"face.\n" +"L|hist: Equalize the histograms on the RGB channels.\n" +"L|mean: Normalize the face colors to the mean." +msgstr "" +"R|Realizar la normalización puede ayudar al alineador a alinear mejor las " +"caras con condiciones de iluminación difíciles a un coste de velocidad de " +"extracción. Diferentes métodos darán diferentes resultados en diferentes " +"conjuntos. NB: Esto no afecta a la cara de salida, sólo a la entrada del " +"alineador.\nL|none: No realice la normalización en la cara.\nL|clahe: " +"Realice la ecualización adaptativa del histograma con contraste limitado en " +"el rostro.\nL|hist: Iguala los histogramas de los canales RGB.\nL|mean: " +"Normalizar los colores de la cara a la media." + +#: lib/cli/args.py:439 +msgid "" +"The number of times to re-feed the detected face into the aligner. Each time " +"the face is re-fed into the aligner the bounding box is adjusted by a small " +"amount. The final landmarks are then averaged from each iteration. Helps to " +"remove 'micro-jitter' but at the cost of slower extraction speed. The more " +"times the face is re-fed into the aligner, the less micro-jitter should " +"occur but the longer extraction will take." +msgstr "" +"El número de veces que hay que volver a introducir la cara detectada en el " +"alineador. Cada vez que la cara se vuelve a introducir en el alineador, el " +"cuadro delimitador se ajusta en una pequeña cantidad. Los puntos de " +"referencia finales se promedian en cada iteración. Esto ayuda a eliminar el " +"'micro-jitter', pero a costa de una menor velocidad de extracción. Cuantas " +"más veces se vuelva a introducir la cara en el alineador, menos " +"microfluctuaciones se producirán, pero la extracción será más larga." + +#: lib/cli/args.py:451 +msgid "" +"If a face isn't found, rotate the images to try to find a face. Can find " +"more faces at the cost of extraction speed. Pass in a single number to use " +"increments of that size up to 360, or pass in a list of numbers to enumerate " +"exactly what angles to check." +msgstr "" +"Si no se encuentra una cara, gira las imágenes para intentar encontrar una " +"cara. Puede encontrar más caras a costa de la velocidad de extracción. Pase " +"un solo número para usar incrementos de ese tamaño hasta 360, o pase una " +"lista de números para enumerar exactamente qué ángulos comprobar." + +#: lib/cli/args.py:463 lib/cli/args.py:473 lib/cli/args.py:486 +#: lib/cli/args.py:500 lib/cli/args.py:730 lib/cli/args.py:744 +#: lib/cli/args.py:757 lib/cli/args.py:771 +msgid "Face Processing" +msgstr "Proceso de Caras" + +#: lib/cli/args.py:464 +msgid "" +"Filters out faces detected below this size. Length, in pixels across the " +"diagonal of the bounding box. Set to 0 for off" +msgstr "" +"Filtra las caras detectadas por debajo de este tamaño. Longitud, en píxeles " +"a lo largo de la diagonal del cuadro delimitador. Establecer a 0 para " +"desactivar" + +#: lib/cli/args.py:474 lib/cli/args.py:745 +msgid "" +"Optionally filter out people who you do not wish to process by passing in an " +"image of that person. Should be a front portrait with a single person in the " +"image. Multiple images can be added space separated. NB: Using face filter " +"will significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"Opcionalmente, puede filtrar las personas que no desea procesar pasando una " +"imagen de esa persona. Debe ser un retrato frontal con una sola persona en " +"la imagen. Se pueden añadir varias imágenes separadas por espacios. NB: El " +"uso del filtro de caras disminuirá significativamente la velocidad de " +"extracción y no se puede garantizar su precisión." + +#: lib/cli/args.py:487 lib/cli/args.py:758 +msgid "" +"Optionally select people you wish to process by passing in an image of that " +"person. Should be a front portrait with a single person in the image. " +"Multiple images can be added space separated. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"Opcionalmente, seleccione las personas que desea procesar pasando una imagen " +"de esa persona. Debe ser un retrato frontal con una sola persona en la " +"imagen. Se pueden añadir varias imágenes separadas por espacios. NB: El uso " +"del filtro facial disminuirá significativamente la velocidad de extracción y " +"no se puede garantizar su precisión." + +#: lib/cli/args.py:501 lib/cli/args.py:772 +msgid "" +"For use with the optional nfilter/filter files. Threshold for positive face " +"recognition. Lower values are stricter. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"Para usar con los archivos opcionales nfilter/filter. Umbral para el " +"reconocimiento positivo de caras. Los valores más bajos son más estrictos. " +"NB: El uso del filtro facial disminuirá significativamente la velocidad de " +"extracción y no se puede garantizar su precisión." + +#: lib/cli/args.py:512 lib/cli/args.py:524 lib/cli/args.py:536 +#: lib/cli/args.py:548 +msgid "output" +msgstr "salida" + +#: lib/cli/args.py:513 +msgid "" +"The output size of extracted faces. Make sure that the model you intend to " +"train supports your required size. This will only need to be changed for hi-" +"res models." +msgstr "" +"El tamaño de salida de las caras extraídas. Asegúrese de que el modelo que " +"pretende entrenar admite el tamaño deseado. Esto sólo tendrá que ser " +"cambiado para los modelos de alta resolución." + +#: lib/cli/args.py:525 +msgid "" +"Extract every 'nth' frame. This option will skip frames when extracting " +"faces. For example a value of 1 will extract faces from every frame, a value " +"of 10 will extract faces from every 10th frame." +msgstr "" +"Extraer cada 'enésimo' fotograma. Esta opción omitirá los fotogramas al " +"extraer las caras. Por ejemplo, un valor de 1 extraerá las caras de cada " +"fotograma, un valor de 10 extraerá las caras de cada 10 fotogramas." + +#: lib/cli/args.py:537 +msgid "" +"Automatically save the alignments file after a set amount of frames. By " +"default the alignments file is only saved at the end of the extraction " +"process. NB: If extracting in 2 passes then the alignments file will only " +"start to be saved out during the second pass. WARNING: Don't interrupt the " +"script when writing the file because it might get corrupted. Set to 0 to " +"turn off" +msgstr "" +"Guardar automáticamente el archivo de alineaciones después de una cantidad " +"determinada de cuadros. Por defecto, el archivo de alineaciones sólo se " +"guarda al final del proceso de extracción. Nota: Si se extrae en 2 pases, el " +"archivo de alineaciones sólo se empezará a guardar durante el segundo pase. " +"ADVERTENCIA: No interrumpa el script al escribir el archivo porque podría " +"corromperse. Poner a 0 para desactivar" + +#: lib/cli/args.py:549 +msgid "Draw landmarks on the ouput faces for debugging purposes." +msgstr "" +"Dibujar puntos de referencia en las caras de salida para fines de depuración." + +#: lib/cli/args.py:555 lib/cli/args.py:564 lib/cli/args.py:572 +#: lib/cli/args.py:579 lib/cli/args.py:784 lib/cli/args.py:795 +#: lib/cli/args.py:803 lib/cli/args.py:822 lib/cli/args.py:828 +msgid "settings" +msgstr "ajustes" + +#: lib/cli/args.py:556 +msgid "" +"Don't run extraction in parallel. Will run each part of the extraction " +"process separately (one after the other) rather than all at the smae time. " +"Useful if VRAM is at a premium." +msgstr "" +"No ejecute la extracción en paralelo. Ejecutará cada parte del proceso de " +"extracción por separado (una tras otra) en lugar de hacerlo todo al mismo " +"tiempo. Útil si la VRAM es escasa." + +#: lib/cli/args.py:565 +msgid "" +"Skips frames that have already been extracted and exist in the alignments " +"file" +msgstr "" +"Omite los fotogramas que ya han sido extraídos y que existen en el archivo " +"de alineaciones" + +#: lib/cli/args.py:573 +msgid "Skip frames that already have detected faces in the alignments file" +msgstr "" +"Omitir los fotogramas que ya tienen caras detectadas en el archivo de " +"alineaciones" + +#: lib/cli/args.py:580 +msgid "Skip saving the detected faces to disk. Just create an alignments file" +msgstr "" +"No guardar las caras detectadas en el disco. Crear sólo un archivo de " +"alineaciones" + +#: lib/cli/args.py:623 +msgid "" +"Only required if converting from images to video. Provide The original video " +"that the source frames were extracted from (for extracting the fps and " +"audio)." +msgstr "" +"Sólo es necesario si se convierte de imágenes a vídeo. Proporcione el vídeo " +"original del que se extrajeron los fotogramas de origen (para extraer los " +"fps y el audio)." + +#: lib/cli/args.py:632 +msgid "" +"Model directory. The directory containing the trained model you wish to use " +"for conversion." +msgstr "" +"Directorio del modelo. El directorio que contiene el modelo entrenado que " +"desea utilizar para la conversión." + +#: lib/cli/args.py:642 +msgid "" +"R|Performs color adjustment to the swapped face. Some of these options have " +"configurable settings in '/config/convert.ini' or 'Settings > Configure " +"Convert Plugins':\n" +"L|avg-color: Adjust the mean of each color channel in the swapped " +"reconstruction to equal the mean of the masked area in the original image.\n" +"L|color-transfer: Transfers the color distribution from the source to the " +"target image using the mean and standard deviations of the L*a*b* color " +"space.\n" +"L|manual-balance: Manually adjust the balance of the image in a variety of " +"color spaces. Best used with the Preview tool to set correct values.\n" +"L|match-hist: Adjust the histogram of each color channel in the swapped " +"reconstruction to equal the histogram of the masked area in the original " +"image.\n" +"L|seamless-clone: Use cv2's seamless clone function to remove extreme " +"gradients at the mask seam by smoothing colors. Generally does not give very " +"satisfactory results.\n" +"L|none: Don't perform color adjustment." +msgstr "" +"R|Realiza un ajuste de color a la cara intercambiada. Algunas de estas " +"opciones tienen ajustes configurables en '/config/convert.ini' o 'Ajustes > " +"Configurar Extensiones de Conversión':\n" +"L|avg-color: Ajuste la media de cada canal de color en la reconstrucción " +"intercambiada para igualar la media del área enmascarada en la imagen " +"original.\n" +"L|color-transfer: Transfiere la distribución del color de la imagen de " +"origen a la de destino utilizando la media y las desviaciones estándar del " +"espacio de color L*a*b*.\n" +"L|manual-balance: Ajuste manualmente el equilibrio de la imagen en una " +"variedad de espacios de color. Se utiliza mejor con la herramienta de vista " +"previa para establecer los valores correctos.\n" +"L|match-hist: Ajuste el histograma de cada canal de color en la " +"reconstrucción intercambiada para igualar el histograma del área enmascarada " +"en la imagen original.\n" +"L|seamless-clone: Utilice la función de clonación sin costuras de cv2 para " +"eliminar los gradientes extremos en la costura de la máscara, suavizando los " +"colores. Generalmente no da resultados muy satisfactorios.\n" +"L|none: No realice el ajuste de color." + +#: lib/cli/args.py:667 +msgid "" +"R|Masker to use. NB: The mask you require must exist within the alignments " +"file. You can add additional masks with the Mask Tool.\n" +"L|none: Don't use a mask.\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\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." +msgstr "" +"R|Máscara a utilizar. NB: La máscara que necesita debe existir en el archivo " +"de alineaciones. Puede añadir máscaras adicionales con la herramienta de " +"máscaras.\n" +"L|ninguna: No utilizar una máscara.\n" +"L|components: Máscara diseñada para proporcionar una segmentación facial " +"basada en el posicionamiento de las ubicaciones de los puntos de referencia. " +"Se construye un casco convexo alrededor del exterior de los puntos de " +"referencia para crear una máscara.\n" +"L|extended: Máscara diseñada para proporcionar una segmentación facial " +"basada en el posicionamiento de las ubicaciones de los puntos de referencia. " +"Se construye un casco convexo alrededor del exterior de los puntos de " +"referencia y la máscara se extiende hacia arriba en la frente.\n" +"L|vgg-clear: Máscara diseñada para proporcionar una segmentación inteligente " +"de rostros principalmente frontales y libres de obstrucciones. Los rostros " +"de perfil y las obstrucciones pueden dar lugar a un rendimiento inferior.\n" +"L|vgg-obstructed: Máscara diseñada para proporcionar una segmentación " +"inteligente de rostros principalmente frontales. El modelo de la máscara ha " +"sido entrenado específicamente para reconocer algunas obstrucciones faciales " +"(manos y gafas). Los rostros de perfil pueden dar lugar a un rendimiento " +"inferior.\n" +"L|unet-dfl: Máscara diseñada para proporcionar una segmentación inteligente " +"de rostros principalmente frontales. El modelo de máscara ha sido entrenado " +"por los miembros de la comunidad y necesitará ser probado para una mayor " +"descripción. Los rostros de perfil pueden dar lugar a un rendimiento " +"inferior." + +#: lib/cli/args.py:694 +msgid "" +"R|The plugin to use to output the converted images. The writers are " +"configurable in '/config/convert.ini' or 'Settings > Configure Convert " +"Plugins:'\n" +"L|ffmpeg: [video] Writes out the convert straight to video. When the input " +"is a series of images then the '-ref' (--reference-video) parameter must be " +"set.\n" +"L|gif: [animated image] Create an animated gif.\n" +"L|opencv: [images] The fastest image writer, but less options and formats " +"than other plugins.\n" +"L|pillow: [images] Slower than opencv, but has more options and supports " +"more formats." +msgstr "" +"R|El plugin a utilizar para dar salida a las imágenes convertidas. Los " +"escritores son configurables en '/config/convert.ini' o 'Ajustes > " +"Configurar Extensiones de Conversión:'\n" +"L|ffmpeg: [video] Escribe la conversión directamente en vídeo. Cuando la " +"entrada es una serie de imágenes, el parámetro '-ref' (--reference-video) " +"debe ser establecido.\n" +"L|gif: [imagen animada] Crea un gif animado.\n" +"L|opencv: [images] El escritor de imágenes más rápido, pero con menos " +"opciones y formatos que otros plugins.\n" +"L|pillow: [images] Más lento que opencv, pero tiene más opciones y soporta " +"más formatos." + +#: lib/cli/args.py:713 lib/cli/args.py:720 lib/cli/args.py:814 +msgid "Frame Processing" +msgstr "Proceso de fotogramas" + +#: lib/cli/args.py:714 +msgid "" +"Scale the final output frames by this amount. 100%% will output the frames " +"at source dimensions. 50%% at half size 200%% at double size" +msgstr "" +"Escala los fotogramas finales de salida en esta cantidad. 100%% dará salida " +"a los fotogramas a las dimensiones de origen. 50%% a la mitad de tamaño. 200%" +"% al doble de tamaño" + +#: lib/cli/args.py:721 +msgid "" +"Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " +"--frame-ranges 10-50 90-100. Frames falling outside of the selected range " +"will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " +"converting from images, then the filenames must end with the frame-number!" +msgstr "" +"Rangos de fotogramas a los que aplicar la transferencia, por ejemplo, para " +"los fotogramas de 10 a 50 y de 90 a 100 utilice --frame-ranges 10-50 90-100. " +"Los fotogramas que queden fuera del rango seleccionado se descartarán a " +"menos que se seleccione '-k' (--keep-unchanged). Nota: Si está convirtiendo " +"imágenes, ¡los nombres de los archivos deben terminar con el número de " +"fotograma!" + +#: lib/cli/args.py:731 +msgid "" +"If you have not cleansed your alignments file, then you can filter out faces " +"by defining a folder here that contains the faces extracted from your input " +"files/video. If this folder is defined, then only faces that exist within " +"your alignments file and also exist within the specified folder will be " +"converted. Leaving this blank will convert all faces that exist within the " +"alignments file." +msgstr "" +"Si no ha limpiado su archivo de alineaciones, puede filtrar las caras " +"definiendo aquí una carpeta que contenga las caras extraídas de sus archivos/" +"vídeos de entrada. Si se define esta carpeta, sólo se convertirán las caras " +"que existan en el archivo de alineaciones y también en la carpeta " +"especificada. Si se deja en blanco, se convertirán todas las caras que " +"existan en el archivo de alineaciones." + +#: lib/cli/args.py:785 +msgid "" +"The maximum number of parallel processes for performing conversion. " +"Converting images is system RAM heavy so it is possible to run out of memory " +"if you have a lot of processes and not enough RAM to accommodate them all. " +"Setting this to 0 will use the maximum available. No matter what you set " +"this to, it will never attempt to use more processes than are available on " +"your system. If singleprocess is enabled this setting will be ignored." +msgstr "" +"El número máximo de procesos paralelos para realizar la conversión. La " +"conversión de imágenes requiere mucha RAM del sistema, por lo que es posible " +"que se agote la memoria si tiene muchos procesos y no hay suficiente RAM " +"para acomodarlos a todos. Si se ajusta a 0, se utilizará el máximo " +"disponible. No importa lo que establezca, nunca intentará utilizar más " +"procesos que los disponibles en su sistema. Si 'singleprocess' está " +"habilitado, este ajuste será ignorado." + +#: lib/cli/args.py:796 +msgid "" +"[LEGACY] This only needs to be selected if a legacy model is being loaded or " +"if there are multiple models in the model folder" +msgstr "" +"[LEGACY] Sólo es necesario seleccionar esta opción si se está cargando un " +"modelo heredado si hay varios modelos en la carpeta de modelos" + +#: lib/cli/args.py:804 +msgid "" +"Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " +"alignments file for your destination video. However, if you wish you can " +"generate the alignments on-the-fly by enabling this option. This will use an " +"inferior extraction pipeline and will lead to substandard results. If an " +"alignments file is found, this option will be ignored." +msgstr "" +"Activar la conversión sobre la marcha. NO se recomienda. Debe generar un " +"archivo de alineación limpio para su vídeo de destino. Sin embargo, si lo " +"desea, puede generar las alineaciones sobre la marcha activando esta opción. " +"Esto utilizará una tubería de extracción inferior y conducirá a resultados " +"de baja calidad. Si se encuentra un archivo de alineaciones, esta opción " +"será ignorada." + +#: lib/cli/args.py:815 +msgid "" +"When used with --frame-ranges outputs the unchanged frames that are not " +"processed instead of discarding them." +msgstr "" +"Cuando se usa con --frame-ranges, la salida incluye los fotogramas no " +"procesados en vez de descartarlos." + +#: lib/cli/args.py:823 +msgid "Swap the model. Instead converting from of A -> B, converts B -> A" +msgstr "" +"Intercambiar el modelo. En vez de convertir de A a B, convierte de B a A" + +#: lib/cli/args.py:829 +msgid "Disable multiprocessing. Slower but less resource intensive." +msgstr "Desactiva el multiproceso. Es más lento, pero usa menos recursos." + +#: lib/cli/args.py:864 lib/cli/args.py:875 lib/cli/args.py:884 +#: lib/cli/args.py:895 +msgid "faces" +msgstr "caras" + +#: lib/cli/args.py:865 +msgid "" +"Input directory. A directory containing training images for face A. This is " +"the original face, i.e. the face that you want to remove and replace with " +"face B." +msgstr "" +"Directorio de entrada. Un directorio que contiene imágenes de entrenamiento " +"para la cara A. Esta es la cara original, es decir, la cara que se quiere " +"eliminar y sustituir por la cara B." + +#: lib/cli/args.py:876 +msgid "" +"DEPRECATED - This option will be removed in a future update. Path to " +"alignments file for training set A. Defaults to /alignments.json if " +"not provided." +msgstr "" +"DEPRECIADO - Esta opción se eliminará en una futura actualización. Ruta al " +"archivo de alineaciones para el conjunto de entrenamiento A. Por defecto es " +"/alignments.json si no se proporciona." + +#: lib/cli/args.py:885 +msgid "" +"Input directory. A directory containing training images for face B. This is " +"the swap face, i.e. the face that you want to place onto the head of person " +"A." +msgstr "" +"Directorio de entrada. Un directorio que contiene imágenes de entrenamiento " +"para la cara B. Esta es la cara de intercambio, es decir, la cara que se " +"quiere colocar en la cabeza de la persona A." + +#: lib/cli/args.py:896 +msgid "" +"DEPRECATED - This option will be removed in a future update. Path to " +"alignments file for training set B. Defaults to /alignments.json if " +"not provided." +msgstr "" +"DEPRECIADO - Esta opción se eliminará en una futura actualización. Ruta al " +"archivo de alineaciones para el conjunto de entrenamiento B. Por defecto es " +"/alignments.json si no se proporciona." + +#: lib/cli/args.py:904 lib/cli/args.py:916 +msgid "model" +msgstr "modelo" + +#: lib/cli/args.py:905 +msgid "" +"Model directory. This is where the training data will be stored. You should " +"always specify a new folder for new models. If starting a new model, select " +"either an empty folder, or a folder which does not exist (which will be " +"created). If continuing to train an existing model, specify the location of " +"the existing model." +msgstr "" +"Directorio del modelo. Aquí es donde se almacenarán los datos de " +"entrenamiento. Siempre debe especificar una nueva carpeta para los nuevos " +"modelos. Si se inicia un nuevo modelo, seleccione una carpeta vacía o una " +"carpeta que no exista (que se creará). Si continúa entrenando un modelo " +"existente, especifique la ubicación del modelo existente." + +#: lib/cli/args.py:917 +msgid "" +"R|Select which trainer to use. Trainers can be configured from the Settings " +"menu or the config folder.\n" +"L|original: The original model created by /u/deepfakes.\n" +"L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " +"for full dfaker method.\n" +"L|dfl-h128: 128px in/out model from deepfacelab\n" +"L|dfl-sae: Adaptable model from deepfacelab\n" +"L|dlight: A lightweight, high resolution DFaker variant.\n" +"L|iae: A model that uses intermediate layers to try to get better details\n" +"L|lightweight: A lightweight model for low-end cards. Don't expect great " +"results. Can train as low as 1.6GB with batch size 8.\n" +"L|realface: A high detail, dual density model based on DFaker, with " +"customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " +"won't work so well. By andenixa et al. Very configurable.\n" +"L|unbalanced: 128px in/out model from andenixa. The autoencoders are " +"unbalanced so B>A swaps won't work so well. Very configurable.\n" +"L|villain: 128px in/out model from villainguy. Very resource hungry (You " +"will require a GPU with a fair amount of VRAM). Good for details, but more " +"susceptible to color differences." +msgstr "" +"R|Seleccione el entrenador que desea utilizar. Los entrenadores se pueden " +"configurar desde el menú de configuración o la carpeta de configuración.\n" +"L|original: El modelo original creado por /u/deepfakes.\n" +"L|dfaker: Modelo de 64px in/128px out de dfaker. Habilitar 'warp-to-" +"landmarks' para el método completo de dfaker.\n" +"L|dfl-h128: modelo de 128px in/out de deepfacelab\n" +"L|dfl-sae: Modelo adaptable de deepfacelab\n" +"L|dlight: Una variante de DFaker ligera y de alta resolución.\n" +"L|iae: Un modelo que utiliza capas intermedias para tratar de obtener " +"mejores detalles.\n" +"L|lightweight: Un modelo ligero para tarjetas de gama baja. No esperes " +"grandes resultados. Puede entrenar hasta 1,6GB con tamaño de lote 8.\n" +"L|realface: Un modelo de alto detalle y doble densidad basado en DFaker, con " +"resolución de entrada y salida personalizable. Los autocodificadores están " +"desequilibrados, por lo que los intercambios B>A no funcionan tan bien. Por " +"andenixa et al. Muy configurable\n" +"L|Unbalanced: modelo de 128px de entrada/salida de andenixa. Los " +"autocodificadores están desequilibrados por lo que los intercambios B>A no " +"funcionarán tan bien. Muy configurable\n" +"L|villain: Modelo de 128px de entrada/salida de villainguy. Requiere muchos " +"recursos (se necesita una GPU con una buena cantidad de VRAM). Bueno para " +"los detalles, pero más susceptible a las diferencias de color." + +#: lib/cli/args.py:944 lib/cli/args.py:956 lib/cli/args.py:967 +#: lib/cli/args.py:1053 +msgid "training" +msgstr "entrenamiento" + +#: lib/cli/args.py:945 +msgid "" +"Batch size. This is the number of images processed through the model for " +"each side per iteration. NB: As the model is fed 2 sides at a time, the " +"actual number of images within the model at any one time is double the " +"number that you set here. Larger batches require more GPU RAM." +msgstr "" +"Tamaño del lote. Este es el número de imágenes procesadas a través del " +"modelo para cada lado por iteración. Nota: Como el modelo se alimenta de 2 " +"lados a la vez, el número real de imágenes dentro del modelo en cualquier " +"momento es el doble del número que se establece aquí. Los lotes más grandes " +"requieren más RAM de la GPU." + +#: lib/cli/args.py:957 +msgid "" +"Length of training in iterations. This is only really used for automation. " +"There is no 'correct' number of iterations a model should be trained for. " +"You should stop training when you are happy with the previews. However, if " +"you want the model to stop automatically at a set number of iterations, you " +"can set that value here." +msgstr "" +"Duración del entrenamiento en iteraciones. Esto sólo se utiliza realmente " +"para la automatización. No hay un número 'correcto' de iteraciones para las " +"que deba entrenarse un modelo. Debe dejar de entrenar cuando esté satisfecho " +"con las previsiones. Sin embargo, si desea que el modelo se detenga " +"automáticamente en un número determinado de iteraciones, puede establecer " +"ese valor aquí." + +#: lib/cli/args.py:968 +msgid "" +"Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." +msgstr "" +"Utilice la estrategia de distribución en espejo de Tensorflow para entrenar " +"en múltiples GPUs." + +#: lib/cli/args.py:978 lib/cli/args.py:988 +msgid "Saving" +msgstr "Guardar" + +#: lib/cli/args.py:979 +msgid "Sets the number of iterations between each model save." +msgstr "Establece el número de iteraciones entre cada guardado del modelo." + +#: lib/cli/args.py:989 +msgid "" +"Sets the number of iterations before saving a backup snapshot of the model " +"in it's current state. Set to 0 for off." +msgstr "" +"Establece el número de iteraciones antes de guardar una copia de seguridad " +"del modelo en su estado actual. Establece 0 para que esté desactivado." + +#: lib/cli/args.py:996 lib/cli/args.py:1007 lib/cli/args.py:1018 +msgid "timelapse" +msgstr "intervalo" + +#: lib/cli/args.py:997 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. " +"This should be the input folder of 'A' faces that you would like to use for " +"creating the timelapse. You must also supply a --timelapse-output and a --" +"timelapse-input-B parameter." +msgstr "" +"Opcional para crear un timelapse. Timelapse guardará una imagen de las caras " +"seleccionadas en la carpeta timelapse-output en cada iteración de guardado. " +"Esta debe ser la carpeta de entrada de las caras \"A\" que desea utilizar " +"para crear el timelapse. También debe suministrar un parámetro --timelapse-" +"output y un parámetro --timelapse-input-B." + +#: lib/cli/args.py:1008 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. " +"This should be the input folder of 'B' faces that you would like to use for " +"creating the timelapse. You must also supply a --timelapse-output and a --" +"timelapse-input-A parameter." +msgstr "" +"Opcional para crear un timelapse. Timelapse guardará una imagen de las caras " +"seleccionadas en la carpeta timelapse-output en cada iteración de guardado. " +"Esta debe ser la carpeta de entrada de las caras \"B\" que desea utilizar " +"para crear el timelapse. También debe suministrar un parámetro --timelapse-" +"output y un parámetro --timelapse-input-A." + +#: lib/cli/args.py:1019 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. If " +"the input folders are supplied but no output folder, it will default to your " +"model folder /timelapse/" +msgstr "" +"Opcional para crear un timelapse. Timelapse guardará una imagen de las caras " +"seleccionadas en la carpeta timelapse-output en cada iteración de guardado. " +"Si se suministran las carpetas de entrada pero no la carpeta de salida, se " +"guardará por defecto en la carpeta del modelo /timelapse/" + +#: lib/cli/args.py:1031 lib/cli/args.py:1038 lib/cli/args.py:1045 +msgid "preview" +msgstr "previsualización" + +#: lib/cli/args.py:1032 +msgid "" +"Percentage amount to scale the preview by. 100%% is the model output size." +msgstr "" +"Cantidad porcentual para escalar la vista previa. 100%% es el tamaño de " +"salida del modelo." + +#: lib/cli/args.py:1039 +msgid "Show training preview output. in a separate window." +msgstr "" +"Mostrar la salida de la vista previa del entrenamiento. en una ventana " +"separada." + +#: lib/cli/args.py:1046 +msgid "" +"Writes the training result to a file. The image will be stored in the root " +"of your FaceSwap folder." +msgstr "" +"Escribe el resultado del entrenamiento en un archivo. La imagen se " +"almacenará en la raíz de su carpeta FaceSwap." + +#: lib/cli/args.py:1054 +msgid "" +"Disables TensorBoard logging. NB: Disabling logs means that you will not be " +"able to use the graph or analysis for this session in the GUI." +msgstr "" +"Desactiva el registro de TensorBoard. NB: Desactivar los registros significa " +"que no podrá utilizar el gráfico o el análisis de esta sesión en la GUI." + +#: lib/cli/args.py:1061 lib/cli/args.py:1070 lib/cli/args.py:1079 +#: lib/cli/args.py:1088 +msgid "augmentation" +msgstr "aumento" + +#: lib/cli/args.py:1062 +msgid "" +"Warps training faces to closely matched Landmarks from the opposite face-set " +"rather than randomly warping the face. This is the 'dfaker' way of doing " +"warping." +msgstr "" +"Deforma las caras de entrenamiento a puntos de referencia muy parecidos del " +"conjunto de caras opuestas en lugar de deformar la cara al azar. Esta es la " +"forma 'dfaker' de hacer la deformación." + +#: lib/cli/args.py:1071 +msgid "" +"To effectively learn, a random set of images are flipped horizontally. " +"Sometimes it is desirable for this not to occur. Generally this should be " +"left off except for during 'fit training'." +msgstr "" +"Para aprender de forma efectiva, se voltea horizontalmente un conjunto " +"aleatorio de imágenes. A veces es deseable que esto no ocurra. Por lo " +"general, esto debería dejarse sin efecto, excepto durante el 'entrenamiento " +"de ajuste'." + +#: lib/cli/args.py:1080 +msgid "" +"Color augmentation helps make the model less susceptible to color " +"differences between the A and B sets, at an increased training time cost. " +"Enable this option to disable color augmentation." +msgstr "" +"El aumento del color ayuda a que el modelo sea menos susceptible a las " +"diferencias de color entre los conjuntos A y B, con un mayor coste de tiempo " +"de entrenamiento. Activa esta opción para desactivar el aumento de color." + +#: lib/cli/args.py:1089 +msgid "" +"Warping is integral to training the Neural Network. This option should only " +"be enabled towards the very end of training to try to bring out more detail. " +"Think of it as 'fine-tuning'. Enabling this option from the beginning is " +"likely to kill a model and lead to terrible results." +msgstr "" +"La deformación es fundamental para el entrenamiento de la red neuronal. Esta " +"opción sólo debería activarse hacia el final del entrenamiento para tratar " +"de obtener más detalles. Piense en ello como un 'ajuste fino'. Si se activa " +"esta opción desde el principio, es probable que arruine el modelo y se " +"obtengan resultados terribles." + +#: lib/cli/args.py:1114 +msgid "Output to Shell console instead of GUI console" +msgstr "Salida a la consola Shell en lugar de la consola GUI" diff --git a/locales/faceswap.pot b/locales/faceswap.pot new file mode 100644 index 0000000000..8979c87555 --- /dev/null +++ b/locales/faceswap.pot @@ -0,0 +1,33 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2021-02-18 23:48-0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=cp1252\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" + + +#: faceswap.py:43 +msgid "Extract the faces from pictures or a video" +msgstr "" + +#: faceswap.py:44 +msgid "Train a model for the two faces A and B" +msgstr "" + +#: faceswap.py:47 +msgid "Convert source pictures or video to a new one with the face swapped" +msgstr "" + +#: faceswap.py:48 +msgid "Launch the Faceswap Graphical User Interface" +msgstr "" + diff --git a/locales/lib.cli.args.pot b/locales/lib.cli.args.pot new file mode 100644 index 0000000000..3d367190c8 --- /dev/null +++ b/locales/lib.cli.args.pot @@ -0,0 +1,404 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2021-02-18 23:45-0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=cp1252\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" + + +#: lib/cli/args.py:177 lib/cli/args.py:187 lib/cli/args.py:195 +#: lib/cli/args.py:205 +msgid "Global Options" +msgstr "" + +#: lib/cli/args.py:178 +msgid "" +"R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond to any GPU(s) that you do not wish to be made available to Faceswap. Selecting all GPUs here will force Faceswap into CPU mode.\n" +"L|{}" +msgstr "" + +#: lib/cli/args.py:188 +msgid "Optionally overide the saved config with the path to a custom config file." +msgstr "" + +#: lib/cli/args.py:196 +msgid "Log level. Stick with INFO or VERBOSE unless you need to file an error report. Be careful with TRACE as it will generate a lot of data" +msgstr "" + +#: lib/cli/args.py:206 +msgid "Path to store the logfile. Leave blank to store in the faceswap folder" +msgstr "" + +#: lib/cli/args.py:299 lib/cli/args.py:308 lib/cli/args.py:316 +#: lib/cli/args.py:627 lib/cli/args.py:636 +msgid "Data" +msgstr "" + +#: lib/cli/args.py:300 +msgid "Input directory or video. Either a directory containing the image files you wish to process or path to a video file. NB: This should be the source video/frames NOT the source faces." +msgstr "" + +#: lib/cli/args.py:309 +msgid "Output directory. This is where the converted files will be saved." +msgstr "" + +#: lib/cli/args.py:317 +msgid "Optional path to an alignments file. Leave blank if the alignments file is at the default location." +msgstr "" + +#: lib/cli/args.py:340 +msgid "" +"Extract faces from image or video sources.\n" +"Extraction plugins can be configured in the 'Settings' Menu" +msgstr "" + +#: lib/cli/args.py:365 lib/cli/args.py:381 lib/cli/args.py:393 +#: lib/cli/args.py:425 lib/cli/args.py:443 lib/cli/args.py:455 +#: lib/cli/args.py:646 lib/cli/args.py:671 lib/cli/args.py:698 +msgid "Plugins" +msgstr "" + +#: lib/cli/args.py:366 +msgid "" +"R|Detector to use. Some of these have configurable settings in '/config/extract.ini' or 'Settings > Configure Extract 'Plugins':\n" +"L|cv2-dnn: A CPU only extractor which is the least reliable and least resource intensive. Use this if not using a GPU and time is important.\n" +"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources than other GPU detectors but can often return more false positives.\n" +"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and fewer false positives than other GPU detectors, but is a lot more resource intensive." +msgstr "" + +#: lib/cli/args.py:382 +msgid "" +"R|Aligner to use.\n" +"L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, but less accurate. Only use this if not using a GPU and time is important.\n" +"L|fan: Best aligner. Fast on GPU, slow on CPU." +msgstr "" + +#: lib/cli/args.py:394 +msgid "" +"R|Additional Masker(s) to use. The masks generated here will all take up GPU RAM. You can select none, one or multiple masks, but the extraction may take longer the more you select. NB: The Extended and Components (landmark based) masks are automatically generated on extraction.\n" +"L|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.\n" +"L|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.\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.\n" +"The auto generated masks are as follows:\n" +"L|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.\n" +"L|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.\n" +"(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" +msgstr "" + +#: lib/cli/args.py:426 +msgid "" +"R|Performing normalization can help the aligner better align faces with difficult lighting conditions at an extraction speed cost. Different methods will yield different results on different sets. NB: This does not impact the output face, just the input to the aligner.\n" +"L|none: Don't perform normalization on the face.\n" +"L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the face.\n" +"L|hist: Equalize the histograms on the RGB channels.\n" +"L|mean: Normalize the face colors to the mean." +msgstr "" + +#: lib/cli/args.py:444 +msgid "The number of times to re-feed the detected face into the aligner. Each time the face is re-fed into the aligner the bounding box is adjusted by a small amount. The final landmarks are then averaged from each iteration. Helps to remove 'micro-jitter' but at the cost of slower extraction speed. The more times the face is re-fed into the aligner, the less micro-jitter should occur but the longer extraction will take." +msgstr "" + +#: lib/cli/args.py:456 +msgid "If a face isn't found, rotate the images to try to find a face. Can find more faces at the cost of extraction speed. Pass in a single number to use increments of that size up to 360, or pass in a list of numbers to enumerate exactly what angles to check." +msgstr "" + +#: lib/cli/args.py:468 lib/cli/args.py:478 lib/cli/args.py:491 +#: lib/cli/args.py:505 lib/cli/args.py:735 lib/cli/args.py:749 +#: lib/cli/args.py:762 lib/cli/args.py:776 +msgid "Face Processing" +msgstr "" + +#: lib/cli/args.py:469 +msgid "Filters out faces detected below this size. Length, in pixels across the diagonal of the bounding box. Set to 0 for off" +msgstr "" + +#: lib/cli/args.py:479 lib/cli/args.py:750 +msgid "Optionally filter out people who you do not wish to process by passing in an image of that person. Should be a front portrait with a single person in the image. Multiple images can be added space separated. NB: Using face filter will significantly decrease extraction speed and its accuracy cannot be guaranteed." +msgstr "" + +#: lib/cli/args.py:492 lib/cli/args.py:763 +msgid "Optionally select people you wish to process by passing in an image of that person. Should be a front portrait with a single person in the image. Multiple images can be added space separated. NB: Using face filter will significantly decrease extraction speed and its accuracy cannot be guaranteed." +msgstr "" + +#: lib/cli/args.py:506 lib/cli/args.py:777 +msgid "For use with the optional nfilter/filter files. Threshold for positive face recognition. Lower values are stricter. NB: Using face filter will significantly decrease extraction speed and its accuracy cannot be guaranteed." +msgstr "" + +#: lib/cli/args.py:517 lib/cli/args.py:529 lib/cli/args.py:541 +#: lib/cli/args.py:553 +msgid "output" +msgstr "" + +#: lib/cli/args.py:518 +msgid "The output size of extracted faces. Make sure that the model you intend to train supports your required size. This will only need to be changed for hi-res models." +msgstr "" + +#: lib/cli/args.py:530 +msgid "Extract every 'nth' frame. This option will skip frames when extracting faces. For example a value of 1 will extract faces from every frame, a value of 10 will extract faces from every 10th frame." +msgstr "" + +#: lib/cli/args.py:542 +msgid "Automatically save the alignments file after a set amount of frames. By default the alignments file is only saved at the end of the extraction process. NB: If extracting in 2 passes then the alignments file will only start to be saved out during the second pass. WARNING: Don't interrupt the script when writing the file because it might get corrupted. Set to 0 to turn off" +msgstr "" + +#: lib/cli/args.py:554 +msgid "Draw landmarks on the ouput faces for debugging purposes." +msgstr "" + +#: lib/cli/args.py:560 lib/cli/args.py:569 lib/cli/args.py:577 +#: lib/cli/args.py:584 lib/cli/args.py:789 lib/cli/args.py:800 +#: lib/cli/args.py:808 lib/cli/args.py:827 lib/cli/args.py:833 +msgid "settings" +msgstr "" + +#: lib/cli/args.py:561 +msgid "Don't run extraction in parallel. Will run each part of the extraction process separately (one after the other) rather than all at the smae time. Useful if VRAM is at a premium." +msgstr "" + +#: lib/cli/args.py:570 +msgid "Skips frames that have already been extracted and exist in the alignments file" +msgstr "" + +#: lib/cli/args.py:578 +msgid "Skip frames that already have detected faces in the alignments file" +msgstr "" + +#: lib/cli/args.py:585 +msgid "Skip saving the detected faces to disk. Just create an alignments file" +msgstr "" + +#: lib/cli/args.py:607 +msgid "" +"Swap the original faces in a source video/images to your final faces.\n" +"Conversion plugins can be configured in the 'Settings' Menu" +msgstr "" + +#: lib/cli/args.py:628 +msgid "Only required if converting from images to video. Provide The original video that the source frames were extracted from (for extracting the fps and audio)." +msgstr "" + +#: lib/cli/args.py:637 +msgid "Model directory. The directory containing the trained model you wish to use for conversion." +msgstr "" + +#: lib/cli/args.py:647 +msgid "" +"R|Performs color adjustment to the swapped face. Some of these options have configurable settings in '/config/convert.ini' or 'Settings > Configure Convert Plugins':\n" +"L|avg-color: Adjust the mean of each color channel in the swapped reconstruction to equal the mean of the masked area in the original image.\n" +"L|color-transfer: Transfers the color distribution from the source to the target image using the mean and standard deviations of the L*a*b* color space.\n" +"L|manual-balance: Manually adjust the balance of the image in a variety of color spaces. Best used with the Preview tool to set correct values.\n" +"L|match-hist: Adjust the histogram of each color channel in the swapped reconstruction to equal the histogram of the masked area in the original image.\n" +"L|seamless-clone: Use cv2's seamless clone function to remove extreme gradients at the mask seam by smoothing colors. Generally does not give very satisfactory results.\n" +"L|none: Don't perform color adjustment." +msgstr "" + +#: lib/cli/args.py:672 +msgid "" +"R|Masker to use. NB: The mask you require must exist within the alignments file. You can add additional masks with the Mask Tool.\n" +"L|none: Don't use a mask.\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\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." +msgstr "" + +#: lib/cli/args.py:699 +msgid "" +"R|The plugin to use to output the converted images. The writers are configurable in '/config/convert.ini' or 'Settings > Configure Convert Plugins:'\n" +"L|ffmpeg: [video] Writes out the convert straight to video. When the input is a series of images then the '-ref' (--reference-video) parameter must be set.\n" +"L|gif: [animated image] Create an animated gif.\n" +"L|opencv: [images] The fastest image writer, but less options and formats than other plugins.\n" +"L|pillow: [images] Slower than opencv, but has more options and supports more formats." +msgstr "" + +#: lib/cli/args.py:718 lib/cli/args.py:725 lib/cli/args.py:819 +msgid "Frame Processing" +msgstr "" + +#: lib/cli/args.py:719 +msgid "Scale the final output frames by this amount. 100%% will output the frames at source dimensions. 50%% at half size 200%% at double size" +msgstr "" + +#: lib/cli/args.py:726 +msgid "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use --frame-ranges 10-50 90-100. Frames falling outside of the selected range will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are converting from images, then the filenames must end with the frame-number!" +msgstr "" + +#: lib/cli/args.py:736 +msgid "If you have not cleansed your alignments file, then you can filter out faces by defining a folder here that contains the faces extracted from your input files/video. If this folder is defined, then only faces that exist within your alignments file and also exist within the specified folder will be converted. Leaving this blank will convert all faces that exist within the alignments file." +msgstr "" + +#: lib/cli/args.py:790 +msgid "The maximum number of parallel processes for performing conversion. Converting images is system RAM heavy so it is possible to run out of memory if you have a lot of processes and not enough RAM to accommodate them all. Setting this to 0 will use the maximum available. No matter what you set this to, it will never attempt to use more processes than are available on your system. If singleprocess is enabled this setting will be ignored." +msgstr "" + +#: lib/cli/args.py:801 +msgid "[LEGACY] This only needs to be selected if a legacy model is being loaded or if there are multiple models in the model folder" +msgstr "" + +#: lib/cli/args.py:809 +msgid "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean alignments file for your destination video. However, if you wish you can generate the alignments on-the-fly by enabling this option. This will use an inferior extraction pipeline and will lead to substandard results. If an alignments file is found, this option will be ignored." +msgstr "" + +#: lib/cli/args.py:820 +msgid "When used with --frame-ranges outputs the unchanged frames that are not processed instead of discarding them." +msgstr "" + +#: lib/cli/args.py:828 +msgid "Swap the model. Instead converting from of A -> B, converts B -> A" +msgstr "" + +#: lib/cli/args.py:834 +msgid "Disable multiprocessing. Slower but less resource intensive." +msgstr "" + +#: lib/cli/args.py:850 +msgid "" +"Train a model on extracted original (A) and swap (B) faces.\n" +"Training models can take a long time. Anything from 24hrs to over a week\n" +"Model plugins can be configured in the 'Settings' Menu" +msgstr "" + +#: lib/cli/args.py:869 lib/cli/args.py:880 lib/cli/args.py:889 +#: lib/cli/args.py:900 +msgid "faces" +msgstr "" + +#: lib/cli/args.py:870 +msgid "Input directory. A directory containing training images for face A. This is the original face, i.e. the face that you want to remove and replace with face B." +msgstr "" + +#: lib/cli/args.py:881 +msgid "DEPRECATED - This option will be removed in a future update. Path to alignments file for training set A. Defaults to /alignments.json if not provided." +msgstr "" + +#: lib/cli/args.py:890 +msgid "Input directory. A directory containing training images for face B. This is the swap face, i.e. the face that you want to place onto the head of person A." +msgstr "" + +#: lib/cli/args.py:901 +msgid "DEPRECATED - This option will be removed in a future update. Path to alignments file for training set B. Defaults to /alignments.json if not provided." +msgstr "" + +#: lib/cli/args.py:909 lib/cli/args.py:921 +msgid "model" +msgstr "" + +#: lib/cli/args.py:910 +msgid "Model directory. This is where the training data will be stored. You should always specify a new folder for new models. If starting a new model, select either an empty folder, or a folder which does not exist (which will be created). If continuing to train an existing model, specify the location of the existing model." +msgstr "" + +#: lib/cli/args.py:922 +msgid "" +"R|Select which trainer to use. Trainers can be configured from the Settings menu or the config folder.\n" +"L|original: The original model created by /u/deepfakes.\n" +"L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' for full dfaker method.\n" +"L|dfl-h128: 128px in/out model from deepfacelab\n" +"L|dfl-sae: Adaptable model from deepfacelab\n" +"L|dlight: A lightweight, high resolution DFaker variant.\n" +"L|iae: A model that uses intermediate layers to try to get better details\n" +"L|lightweight: A lightweight model for low-end cards. Don't expect great results. Can train as low as 1.6GB with batch size 8.\n" +"L|realface: A high detail, dual density model based on DFaker, with customizable in/out resolution. The autoencoders are unbalanced so B>A swaps won't work so well. By andenixa et al. Very configurable.\n" +"L|unbalanced: 128px in/out model from andenixa. The autoencoders are unbalanced so B>A swaps won't work so well. Very configurable.\n" +"L|villain: 128px in/out model from villainguy. Very resource hungry (You will require a GPU with a fair amount of VRAM). Good for details, but more susceptible to color differences." +msgstr "" + +#: lib/cli/args.py:949 lib/cli/args.py:961 lib/cli/args.py:972 +#: lib/cli/args.py:1058 +msgid "training" +msgstr "" + +#: lib/cli/args.py:950 +msgid "Batch size. This is the number of images processed through the model for each side per iteration. NB: As the model is fed 2 sides at a time, the actual number of images within the model at any one time is double the number that you set here. Larger batches require more GPU RAM." +msgstr "" + +#: lib/cli/args.py:962 +msgid "Length of training in iterations. This is only really used for automation. There is no 'correct' number of iterations a model should be trained for. You should stop training when you are happy with the previews. However, if you want the model to stop automatically at a set number of iterations, you can set that value here." +msgstr "" + +#: lib/cli/args.py:973 +msgid "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." +msgstr "" + +#: lib/cli/args.py:983 lib/cli/args.py:993 +msgid "Saving" +msgstr "" + +#: lib/cli/args.py:984 +msgid "Sets the number of iterations between each model save." +msgstr "" + +#: lib/cli/args.py:994 +msgid "Sets the number of iterations before saving a backup snapshot of the model in it's current state. Set to 0 for off." +msgstr "" + +#: lib/cli/args.py:1001 lib/cli/args.py:1012 lib/cli/args.py:1023 +msgid "timelapse" +msgstr "" + +#: lib/cli/args.py:1002 +msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. This should be the input folder of 'A' faces that you would like to use for creating the timelapse. You must also supply a --timelapse-output and a --timelapse-input-B parameter." +msgstr "" + +#: lib/cli/args.py:1013 +msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. This should be the input folder of 'B' faces that you would like to use for creating the timelapse. You must also supply a --timelapse-output and a --timelapse-input-A parameter." +msgstr "" + +#: lib/cli/args.py:1024 +msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. If the input folders are supplied but no output folder, it will default to your model folder /timelapse/" +msgstr "" + +#: lib/cli/args.py:1036 lib/cli/args.py:1043 lib/cli/args.py:1050 +msgid "preview" +msgstr "" + +#: lib/cli/args.py:1037 +msgid "Percentage amount to scale the preview by. 100%% is the model output size." +msgstr "" + +#: lib/cli/args.py:1044 +msgid "Show training preview output. in a separate window." +msgstr "" + +#: lib/cli/args.py:1051 +msgid "Writes the training result to a file. The image will be stored in the root of your FaceSwap folder." +msgstr "" + +#: lib/cli/args.py:1059 +msgid "Disables TensorBoard logging. NB: Disabling logs means that you will not be able to use the graph or analysis for this session in the GUI." +msgstr "" + +#: lib/cli/args.py:1066 lib/cli/args.py:1075 lib/cli/args.py:1084 +#: lib/cli/args.py:1093 +msgid "augmentation" +msgstr "" + +#: lib/cli/args.py:1067 +msgid "Warps training faces to closely matched Landmarks from the opposite face-set rather than randomly warping the face. This is the 'dfaker' way of doing warping." +msgstr "" + +#: lib/cli/args.py:1076 +msgid "To effectively learn, a random set of images are flipped horizontally. Sometimes it is desirable for this not to occur. Generally this should be left off except for during 'fit training'." +msgstr "" + +#: lib/cli/args.py:1085 +msgid "Color augmentation helps make the model less susceptible to color differences between the A and B sets, at an increased training time cost. Enable this option to disable color augmentation." +msgstr "" + +#: lib/cli/args.py:1094 +msgid "Warping is integral to training the Neural Network. This option should only be enabled towards the very end of training to try to bring out more detail. Think of it as 'fine-tuning'. Enabling this option from the beginning is likely to kill a model and lead to terrible results." +msgstr "" + +#: lib/cli/args.py:1119 +msgid "Output to Shell console instead of GUI console" +msgstr "" + diff --git a/locales/tools.alignments.cli.pot b/locales/tools.alignments.cli.pot new file mode 100644 index 0000000000..34ae7a73b4 --- /dev/null +++ b/locales/tools.alignments.cli.pot @@ -0,0 +1,108 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2021-02-18 23:43-0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=cp1252\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" + + +#: tools/alignments/cli.py:14 +msgid "This command lets you perform various tasks pertaining to an alignments file." +msgstr "" + +#: tools/alignments/cli.py:23 +msgid "" +"Alignments tool\n" +"This tool allows you to perform numerous actions on or using an alignments file against its corresponding faceset/frame source." +msgstr "" + +#: tools/alignments/cli.py:27 +msgid " Must Pass in a frames folder/source video file (-fr)." +msgstr "" + +#: tools/alignments/cli.py:28 +msgid " Must Pass in a faces folder (-fc)." +msgstr "" + +#: tools/alignments/cli.py:29 +msgid " Must Pass in either a frames folder/source video file OR afaces folder (-fr or -fc)." +msgstr "" + +#: tools/alignments/cli.py:31 +msgid " Must Pass in a frames folder/source video file AND a faces folder (-fr and -fc)." +msgstr "" + +#: tools/alignments/cli.py:33 +msgid " Use the output option (-o) to process results." +msgstr "" + +#: tools/alignments/cli.py:42 +msgid "" +"R|Choose which action you want to perform. NB: All actions require an alignments file (-a) to be passed in.\n" +"L|'draw': Draw landmarks on frames in the selected folder/video. A subfolder will be created within the frames folder to hold the output.{0}\n" +"L|'extract': Re-extract faces from the source frames/video based on alignment data. This is a lot quicker than re-detecting faces. Can pass in the '-een' (--extract-every-n) parameter to only extract every nth frame.{1}\n" +"L|'missing-alignments': Identify frames that do not exist in the alignments file.{2}{0}\n" +"L|'missing-frames': Identify frames in the alignments file that do not appear within the frames folder/video.{2}{0}\n" +"L|'multi-faces': Identify where multiple faces exist within the alignments file.{2}{4}\n" +"L|'no-faces': Identify frames that exist within the alignment file but no faces were detected.{2}{0}\n" +"L|'remove-faces': Remove deleted faces from an alignments file. The original alignments file will be backed up.{3}\n" +"L|'rename' - Rename faces to correspond with their parent frame and position index in the alignments file (i.e. how they are named after running extract).{3}\n" +"L|'sort': Re-index the alignments from left to right. For alignments with multiple faces this will ensure that the left-most face is at index 0.\n" +"L|'spatial': Perform spatial and temporal filtering to smooth alignments (EXPERIMENTAL!)" +msgstr "" + +#: tools/alignments/cli.py:72 tools/alignments/cli.py:81 +#: tools/alignments/cli.py:88 +msgid "data" +msgstr "" + +#: tools/alignments/cli.py:75 +msgid "Full path to the alignments file to be processed. If merging alignments, then multiple files can be selected, space separated" +msgstr "" + +#: tools/alignments/cli.py:82 +msgid "Directory containing extracted faces." +msgstr "" + +#: tools/alignments/cli.py:89 +msgid "Directory containing source frames that faces were extracted from." +msgstr "" + +#: tools/alignments/cli.py:95 +msgid "processing" +msgstr "" + +#: tools/alignments/cli.py:97 +msgid "" +"R|How to output discovered items ('faces' and 'frames' only):\n" +"L|'console': Print the list of frames to the screen. (DEFAULT)\n" +"L|'file': Output the list of frames to a text file (stored within the source directory).\n" +"L|'move': Move the discovered items to a sub-folder within the source directory." +msgstr "" + +#: tools/alignments/cli.py:111 tools/alignments/cli.py:121 +#: tools/alignments/cli.py:127 +msgid "extract" +msgstr "" + +#: tools/alignments/cli.py:112 +msgid "[Extract only] Extract every 'nth' frame. This option will skip frames when extracting faces. For example a value of 1 will extract faces from every frame, a value of 10 will extract faces from every 10th frame." +msgstr "" + +#: tools/alignments/cli.py:123 +msgid "[Extract only] The output size of extracted faces." +msgstr "" + +#: tools/alignments/cli.py:129 +msgid "[Extract only] Only extract faces that have not been upscaled to the required size (`-sz`, `--size). Useful for excluding low-res images from a training set." +msgstr "" + diff --git a/locales/tools.effmpeg.cli.pot b/locales/tools.effmpeg.cli.pot new file mode 100644 index 0000000000..83c4ac8f05 --- /dev/null +++ b/locales/tools.effmpeg.cli.pot @@ -0,0 +1,115 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2021-02-18 23:34-0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=cp1252\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" + + +#: tools/effmpeg/cli.py:15 +msgid "This command allows you to easily execute common ffmpeg tasks." +msgstr "" + +#: tools/effmpeg/cli.py:24 +msgid "A wrapper for ffmpeg for performing image <> video converting." +msgstr "" + +#: tools/effmpeg/cli.py:51 +msgid "" +"R|Choose which action you want ffmpeg ffmpeg to do.\n" +"L|'extract': turns videos into images \n" +"L|'gen-vid': turns images into videos \n" +"L|'get-fps' returns the chosen video's fps.\n" +"L|'get-info' returns information about a video.\n" +"L|'mux-audio' add audio from one video to another.\n" +"L|'rescale' resize video.\n" +"L|'rotate' rotate video.\n" +"L|'slice' cuts a portion of the video into a separate video file." +msgstr "" + +#: tools/effmpeg/cli.py:65 +msgid "Input file." +msgstr "" + +#: tools/effmpeg/cli.py:66 tools/effmpeg/cli.py:73 tools/effmpeg/cli.py:87 +msgid "data" +msgstr "" + +#: tools/effmpeg/cli.py:76 +msgid "Output file. If no output is specified then: if the output is meant to be a video then a video called 'out.mkv' will be created in the input directory; if the output is meant to be a directory then a directory called 'out' will be created inside the input directory. Note: the chosen output file extension will determine the file encoding." +msgstr "" + +#: tools/effmpeg/cli.py:89 +msgid "Path to reference video if 'input' was not a video." +msgstr "" + +#: tools/effmpeg/cli.py:95 tools/effmpeg/cli.py:105 tools/effmpeg/cli.py:142 +#: tools/effmpeg/cli.py:171 +msgid "output" +msgstr "" + +#: tools/effmpeg/cli.py:97 +msgid "Provide video fps. Can be an integer, float or fraction. Negative values will will make the program try to get the fps from the input or reference videos." +msgstr "" + +#: tools/effmpeg/cli.py:107 +msgid "Image format that extracted images should be saved as. '.bmp' will offer the fastest extraction speed, but will take the most storage space. '.png' will be slower but will take less storage." +msgstr "" + +#: tools/effmpeg/cli.py:114 tools/effmpeg/cli.py:123 tools/effmpeg/cli.py:132 +msgid "clip" +msgstr "" + +#: tools/effmpeg/cli.py:116 +msgid "Enter the start time from which an action is to be applied. Default: 00:00:00, in HH:MM:SS format. You can also enter the time with or without the colons, e.g. 00:0000 or 026010." +msgstr "" + +#: tools/effmpeg/cli.py:125 +msgid "Enter the end time to which an action is to be applied. If both an end time and duration are set, then the end time will be used and the duration will be ignored. Default: 00:00:00, in HH:MM:SS." +msgstr "" + +#: tools/effmpeg/cli.py:134 +msgid "Enter the duration of the chosen action, for example if you enter 00:00:10 for slice, then the first 10 seconds after and including the start time will be cut out into a new video. Default: 00:00:00, in HH:MM:SS format. You can also enter the time with or without the colons, e.g. 00:0000 or 026010." +msgstr "" + +#: tools/effmpeg/cli.py:144 +msgid "Mux the audio from the reference video into the input video. This option is only used for the 'gen-vid' action. 'mux-audio' action has this turned on implicitly." +msgstr "" + +#: tools/effmpeg/cli.py:155 tools/effmpeg/cli.py:165 +msgid "rotate" +msgstr "" + +#: tools/effmpeg/cli.py:157 +msgid "Transpose the video. If transpose is set, then degrees will be ignored. For cli you can enter either the number or the long command name, e.g. to use (1, 90Clockwise) -tr 1 or -tr 90Clockwise" +msgstr "" + +#: tools/effmpeg/cli.py:166 +msgid "Rotate the video clockwise by the given number of degrees." +msgstr "" + +#: tools/effmpeg/cli.py:173 +msgid "Set the new resolution scale if the chosen action is 'rescale'." +msgstr "" + +#: tools/effmpeg/cli.py:178 tools/effmpeg/cli.py:186 +msgid "settings" +msgstr "" + +#: tools/effmpeg/cli.py:180 +msgid "Reduces output verbosity so that only serious errors are printed. If both quiet and verbose are set, verbose will override quiet." +msgstr "" + +#: tools/effmpeg/cli.py:188 +msgid "Increases output verbosity. If both quiet and verbose are set, verbose will override quiet." +msgstr "" + diff --git a/locales/tools.manual.cli.pot b/locales/tools.manual.cli.pot new file mode 100644 index 0000000000..bf3e0a4729 --- /dev/null +++ b/locales/tools.manual.cli.pot @@ -0,0 +1,49 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2021-02-18 23:17-0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=cp1252\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" + + +#: tools/manual/cli.py:13 +msgid "This command lets you perform various actions on frames, faces and alignments files using visual tools." +msgstr "" + +#: tools/manual/cli.py:23 +msgid "A tool to perform various actions on frames, faces and alignments files using visual tools" +msgstr "" + +#: tools/manual/cli.py:35 tools/manual/cli.py:43 +msgid "data" +msgstr "" + +#: tools/manual/cli.py:37 +msgid "Path to the alignments file for the input, if not at the default location" +msgstr "" + +#: tools/manual/cli.py:44 +msgid "Video file or directory containing source frames that faces were extracted from." +msgstr "" + +#: tools/manual/cli.py:51 tools/manual/cli.py:59 +msgid "options" +msgstr "" + +#: tools/manual/cli.py:52 +msgid "Force regeneration of the low resolution jpg thumbnails in the alignments file." +msgstr "" + +#: tools/manual/cli.py:60 +msgid "The process attempts to speed up generation of thumbnails by extracting from the video in parallel threads. For some videos, this causes the caching process to hang. If this happens, then set this option to generate the thumbnails in a slower, but more stable single thread." +msgstr "" + diff --git a/locales/tools.mask.cli.pot b/locales/tools.mask.cli.pot new file mode 100644 index 0000000000..11a314e6d3 --- /dev/null +++ b/locales/tools.mask.cli.pot @@ -0,0 +1,97 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2021-02-18 23:14-0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=cp1252\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" + + +#: tools/mask/cli.py:15 +msgid "This command lets you generate masks for existing alignments." +msgstr "" + +#: tools/mask/cli.py:24 +msgid "" +"Mask tool\n" +"Generate masks for existing alignments files." +msgstr "" + +#: tools/mask/cli.py:32 tools/mask/cli.py:41 tools/mask/cli.py:51 +msgid "data" +msgstr "" + +#: tools/mask/cli.py:35 +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." +msgstr "" + +#: tools/mask/cli.py:44 +msgid "Directory containing extracted faces, source frames, or a video file." +msgstr "" + +#: tools/mask/cli.py:53 +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:62 tools/mask/cli.py:87 +msgid "process" +msgstr "" + +#: tools/mask/cli.py:63 +msgid "" +"R|Masker to use.\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\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." +msgstr "" + +#: tools/mask/cli.py:88 +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 "" + +#: tools/mask/cli.py:101 tools/mask/cli.py:108 tools/mask/cli.py:121 +#: tools/mask/cli.py:134 tools/mask/cli.py:143 +msgid "output" +msgstr "" + +#: tools/mask/cli.py:102 +msgid "Optional output location. If provided, a preview of the masks created will be output in the given folder." +msgstr "" + +#: tools/mask/cli.py:112 +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 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" +msgstr "" + +#: tools/mask/cli.py:125 +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:135 +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" +"L|masked: Output the face/frame as rgba image with the face masked.\n" +"L|mask: Only output the mask as a single channel image." +msgstr "" + +#: tools/mask/cli.py:144 +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." +msgstr "" + diff --git a/locales/tools.pot b/locales/tools.pot new file mode 100644 index 0000000000..78cf388e1e --- /dev/null +++ b/locales/tools.pot @@ -0,0 +1,21 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2021-02-18 23:49-0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=cp1252\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" + + +#: tools.py:46 +msgid "Please backup your data and/or test the tool you want to use with a smaller data set to make sure you understand how it works." +msgstr "" + diff --git a/locales/tools.preview.cli.pot b/locales/tools.preview.cli.pot new file mode 100644 index 0000000000..e2c6cfaa40 --- /dev/null +++ b/locales/tools.preview.cli.pot @@ -0,0 +1,47 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2021-02-18 23:09-0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=cp1252\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" + + +#: tools/preview/cli.py:14 +msgid "This command allows you to preview swaps to tweak convert settings." +msgstr "" + +#: tools/preview/cli.py:23 +msgid "" +"Preview tool\n" +"Allows you to configure your convert settings with a live preview" +msgstr "" + +#: tools/preview/cli.py:33 tools/preview/cli.py:42 tools/preview/cli.py:49 +msgid "data" +msgstr "" + +#: tools/preview/cli.py:35 +msgid "Input directory or video. Either a directory containing the image files you wish to process or path to a video file." +msgstr "" + +#: tools/preview/cli.py:44 +msgid "Path to the alignments file for the input, if not at the default location" +msgstr "" + +#: tools/preview/cli.py:51 +msgid "Model directory. A directory containing the trained model you wish to process." +msgstr "" + +#: tools/preview/cli.py:58 +msgid "Swap the model. Instead of A -> B, swap B -> A" +msgstr "" + diff --git a/locales/tools.restore.cli.pot b/locales/tools.restore.cli.pot new file mode 100644 index 0000000000..95e331fb3a --- /dev/null +++ b/locales/tools.restore.cli.pot @@ -0,0 +1,29 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2021-02-18 23:06-0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=cp1252\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" + + +#: tools/restore/cli.py:13 +msgid "This command lets you restore models from backup." +msgstr "" + +#: tools/restore/cli.py:22 +msgid "A tool for restoring models from backup (.bk) files" +msgstr "" + +#: tools/restore/cli.py:33 +msgid "Model directory. A directory containing the model you wish to restore from backup." +msgstr "" + diff --git a/locales/tools.sort.cli.pot b/locales/tools.sort.cli.pot new file mode 100644 index 0000000000..1d4c27a6d7 --- /dev/null +++ b/locales/tools.sort.cli.pot @@ -0,0 +1,98 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2021-02-18 23:02-0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=cp1252\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" + + +#: tools/sort/cli.py:14 +msgid "This command lets you sort images using various methods." +msgstr "" + +#: tools/sort/cli.py:23 +msgid "Sort faces using a number of different techniques" +msgstr "" + +#: tools/sort/cli.py:33 tools/sort/cli.py:40 +msgid "data" +msgstr "" + +#: tools/sort/cli.py:34 +msgid "Input directory of aligned faces." +msgstr "" + +#: tools/sort/cli.py:41 +msgid "Output directory for sorted aligned faces." +msgstr "" + +#: tools/sort/cli.py:49 tools/sort/cli.py:89 +msgid "sort settings" +msgstr "" + +#: tools/sort/cli.py:51 +msgid "" +"R|Sort by method. Choose how images are sorted. \n" +"L|'blur': Sort faces by blurriness.\n" +"L|'face': Use VGG Face to sort by face similarity. This uses a pairwise clustering algorithm to check the distances between 512 features on every face in your set and order them appropriately.\n" +"L|'face-cnn': Sort faces by their landmarks. You can adjust the threshold with the '-t' (--ref_threshold) option.\n" +"L|'face-cnn-dissim': Like 'face-cnn' but sorts by dissimilarity.\n" +"L|'face-yaw': Sort faces by Yaw (rotation left to right).\n" +"L|'hist': Sort faces by their color histogram. You can adjust the threshold with the '-t' (--ref_threshold) option.\n" +"L|'hist-dissim': Like 'hist' but sorts by dissimilarity.\n" +"L|'color-gray': Sort images by the average intensity of the converted grayscale color channel.\n" +"L|'color-luma': Sort images by the average intensity of the converted Y color channel. Bright lighting and oversaturated images will be ranked first.\n" +"L|'color-green': Sort images by the average intensity of the converted Cg color channel. Green images will be ranked first and red images will be last.\n" +"L|'color-orange': Sort images by the average intensity of the converted Co color channel. Orange images will be ranked first and blue images will be last.\n" +"Default: hist" +msgstr "" + +#: tools/sort/cli.py:78 tools/sort/cli.py:105 tools/sort/cli.py:117 +#: tools/sort/cli.py:128 +msgid "output" +msgstr "" + +#: tools/sort/cli.py:79 +msgid "Keeps the original files in the input directory. Be careful when using this with rename grouping and no specified output directory as this would keep the original and renamed files in the same directory." +msgstr "" + +#: tools/sort/cli.py:91 +msgid "Float value. Minimum threshold to use for grouping comparison with 'face-cnn' and 'hist' methods. The lower the value the more discriminating the grouping is. Leaving -1.0 will allow the program set the default value automatically. For face-cnn 7.2 should be enough, with 4 being very discriminating. For hist 0.3 should be enough, with 0.2 being very discriminating. Be careful setting a value that's too low in a directory with many images, as this could result in a lot of directories being created. Defaults: face-cnn 7.2, hist 0.3" +msgstr "" + +#: tools/sort/cli.py:106 +msgid "" +"R|Default: rename.\n" +"L|'folders': files are sorted using the -s/--sort-by method, then they are organized into folders using the -g/--group-by grouping method.\n" +"L|'rename': files are sorted using the -s/--sort-by then they are renamed." +msgstr "" + +#: tools/sort/cli.py:119 +msgid "Group by method. When -fp/--final-processing by folders choose the how the images are grouped after sorting. Default: hist" +msgstr "" + +#: tools/sort/cli.py:130 +msgid "Integer value. Number of folders that will be used to group by blur and face-yaw. For blur folder 0 will be the least blurry, while the last folder will be the blurriest. For face-yaw the number of bins is by how much 180 degrees is divided. So if you use 18, then each folder will be a 10 degree increment. Folder 0 will contain faces looking the most to the left whereas the last folder will contain the faces looking the most to the right. If the number of images doesn't divide evenly into the number of bins, the remaining images get put in the last bin. Default value: 5" +msgstr "" + +#: tools/sort/cli.py:141 tools/sort/cli.py:151 +msgid "settings" +msgstr "" + +#: tools/sort/cli.py:143 +msgid "Logs file renaming changes if grouping by renaming, or it logs the file copying/movement if grouping by folders. If no log file is specified with '--log-file', then a 'sort_log.json' file will be created in the input directory." +msgstr "" + +#: tools/sort/cli.py:154 +msgid "Specify a log file to use for saving the renaming or grouping information. If specified extension isn't 'json' or 'yaml', then json will be used as the serializer, with the supplied filename. Default: sort_log.json" +msgstr "" + diff --git a/tools.py b/tools.py index 835d85ecd0..e17dce0d61 100755 --- a/tools.py +++ b/tools.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """ The master tools.py script """ +import gettext import os import sys @@ -8,11 +9,17 @@ # Importing the various tools from lib.cli.args import FullHelpArgumentParser + +# LOCALES +_LANG = gettext.translation("tools", localedir="locales", fallback=True) +_ = _LANG.gettext + + # Python version check if sys.version_info[0] < 3: - raise Exception("This program requires at least python3.2") -if sys.version_info[0] == 3 and sys.version_info[1] < 2: - raise Exception("This program requires at least python3.2") + raise Exception("This program requires at least python3.7") +if sys.version_info[0] == 3 and sys.version_info[1] < 7: + raise Exception("This program requires at least python3.7") def bad_args(*args): # pylint:disable=unused-argument @@ -36,10 +43,8 @@ def _get_cli_opts(): if __name__ == "__main__": - _TOOLS_WARNING = "Please backup your data and/or test the tool you want " - _TOOLS_WARNING += "to use with a smaller data set to make sure you " - _TOOLS_WARNING += "understand how it works." - print(_TOOLS_WARNING) + print(_("Please backup your data and/or test the tool you want to use with a smaller data set " + "to make sure you understand how it works.")) PARSER = FullHelpArgumentParser() SUBPARSER = PARSER.add_subparsers() diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py index d873036555..d4788719ec 100644 --- a/tools/alignments/cli.py +++ b/tools/alignments/cli.py @@ -1,9 +1,17 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ +import gettext + from lib.cli.args import FaceSwapArgs from lib.cli.actions import DirOrFileFullPaths, DirFullPaths, FileFullPaths, Radio, Slider -_HELPTEXT = "This command lets you perform various tasks pertaining to an alignments file." + +# LOCALES +_LANG = gettext.translation("tools.alignments.cli", localedir="locales", fallback=True) +_ = _LANG.gettext + + +_HELPTEXT = _("This command lets you perform various tasks pertaining to an alignments file.") class AlignmentsArgs(FaceSwapArgs): @@ -12,17 +20,17 @@ class AlignmentsArgs(FaceSwapArgs): @staticmethod def get_info(): """ Return command information """ - return ("Alignments tool\nThis tool allows you to perform numerous actions on or using an " - "alignments file against its corresponding faceset/frame source.") + return _("Alignments tool\nThis tool allows you to perform numerous actions on or using " + "an alignments file against its corresponding faceset/frame source.") def get_argument_list(self): - frames_dir = " Must Pass in a frames folder/source video file (-fr)." - faces_dir = " Must Pass in a faces folder (-fc)." - frames_or_faces_dir = (" Must Pass in either a frames folder/source video file OR a" - "faces folder (-fr or -fc).") - frames_and_faces_dir = (" Must Pass in a frames folder/source video file AND a faces " - "folder (-fr and -fc).") - output_opts = " Use the output option (-o) to process results." + frames_dir = _(" Must Pass in a frames folder/source video file (-fr).") + faces_dir = _(" Must Pass in a faces folder (-fc).") + frames_or_faces_dir = _(" Must Pass in either a frames folder/source video file OR a" + "faces folder (-fr or -fc).") + frames_and_faces_dir = _(" Must Pass in a frames folder/source video file AND a faces " + "folder (-fr and -fc).") + output_opts = _(" Use the output option (-o) to process results.") argument_list = list() argument_list.append(dict( opts=("-j", "--job"), @@ -31,66 +39,67 @@ def get_argument_list(self): choices=("draw", "extract", "missing-alignments", "missing-frames", "multi-faces", "no-faces", "remove-faces", "rename", "sort", "spatial"), required=True, - help="R|Choose which action you want to perform. NB: All actions require an " - "alignments file (-a) to be passed in." - "\nL|'draw': Draw landmarks on frames in the selected folder/video. A subfolder " - "will be created within the frames folder to hold the output.{0}" - "\nL|'extract': Re-extract faces from the source frames/video based on alignment " - "data. This is a lot quicker than re-detecting faces. Can pass in the '-een' " - "(--extract-every-n) parameter to only extract every nth frame.{1}" - "\nL|'missing-alignments': Identify frames that do not exist in the alignments " - "file.{2}{0}" - "\nL|'missing-frames': Identify frames in the alignments file that do not appear " - "within the frames folder/video.{2}{0}" - "\nL|'multi-faces': Identify where multiple faces exist within the alignments " - "file.{2}{4}" - "\nL|'no-faces': Identify frames that exist within the alignment file but no " - "faces were detected.{2}{0}" - "\nL|'remove-faces': Remove deleted faces from an alignments file. The original " - "alignments file will be backed up.{3}" - "\nL|'rename' - Rename faces to correspond with their parent frame and position " - "index in the alignments file (i.e. how they are named after running extract).{3}" - "\nL|'sort': Re-index the alignments from left to right. For alignments with " - "multiple faces this will ensure that the left-most face is at index 0." - "\nL|'spatial': Perform spatial and temporal filtering to smooth alignments " - "(EXPERIMENTAL!)".format(frames_dir, frames_and_faces_dir, output_opts, faces_dir, - frames_or_faces_dir))) + help=_("R|Choose which action you want to perform. NB: All actions require an " + "alignments file (-a) to be passed in." + "\nL|'draw': Draw landmarks on frames in the selected folder/video. A " + "subfolder will be created within the frames folder to hold the output.{0}" + "\nL|'extract': Re-extract faces from the source frames/video based on " + "alignment data. This is a lot quicker than re-detecting faces. Can pass in " + "the '-een' (--extract-every-n) parameter to only extract every nth frame.{1}" + "\nL|'missing-alignments': Identify frames that do not exist in the alignments " + "file.{2}{0}" + "\nL|'missing-frames': Identify frames in the alignments file that do not " + "appear within the frames folder/video.{2}{0}" + "\nL|'multi-faces': Identify where multiple faces exist within the alignments " + "file.{2}{4}" + "\nL|'no-faces': Identify frames that exist within the alignment file but no " + "faces were detected.{2}{0}" + "\nL|'remove-faces': Remove deleted faces from an alignments file. The " + "original alignments file will be backed up.{3}" + "\nL|'rename' - Rename faces to correspond with their parent frame and " + "position index in the alignments file (i.e. how they are named after running " + "extract).{3}" + "\nL|'sort': Re-index the alignments from left to right. For alignments with " + "multiple faces this will ensure that the left-most face is at index 0." + "\nL|'spatial': Perform spatial and temporal filtering to smooth alignments " + "(EXPERIMENTAL!)").format(frames_dir, frames_and_faces_dir, output_opts, + faces_dir, frames_or_faces_dir))) argument_list.append(dict( opts=("-a", "--alignments_file"), action=FileFullPaths, dest="alignments_file", type=str, - group="data", + group=_("data"), required=True, filetypes="alignments", - help="Full path to the alignments file to be processed. If merging alignments, then " - "multiple files can be selected, space separated")) + help=_("Full path to the alignments file to be processed. If merging alignments, then " + "multiple files can be selected, space separated"))) argument_list.append(dict( opts=("-fc", "-faces_folder"), action=DirFullPaths, dest="faces_dir", - group="data", - help="Directory containing extracted faces.")) + group=_("data"), + help=_("Directory containing extracted faces."))) argument_list.append(dict( opts=("-fr", "-frames_folder"), action=DirOrFileFullPaths, dest="frames_dir", filetypes="video", - group="data", - help="Directory containing source frames that faces were extracted from.")) + group=_("data"), + help=_("Directory containing source frames that faces were extracted from."))) argument_list.append(dict( opts=("-o", "--output"), action=Radio, type=str, choices=("console", "file", "move"), - group="processing", + group=_("processing"), default="console", - help="R|How to output discovered items ('faces' and 'frames' only):" - "\nL|'console': Print the list of frames to the screen. (DEFAULT)" - "\nL|'file': Output the list of frames to a text file (stored within the source " - "directory)." - "\nL|'move': Move the discovered items to a sub-folder within the source " - "directory.")) + help=_("R|How to output discovered items ('faces' and 'frames' only):" + "\nL|'console': Print the list of frames to the screen. (DEFAULT)" + "\nL|'file': Output the list of frames to a text file (stored within the " + "source directory)." + "\nL|'move': Move the discovered items to a sub-folder within the source " + "directory."))) argument_list.append(dict( opts=("-een", "--extract-every-n"), type=int, @@ -99,25 +108,25 @@ def get_argument_list(self): min_max=(1, 100), default=1, rounding=1, - group="extract", - help="[Extract only] Extract every 'nth' frame. This option will skip frames when " - "extracting faces. For example a value of 1 will extract faces from every frame, " - "a value of 10 will extract faces from every 10th frame.")) + group=_("extract"), + help=_("[Extract only] Extract every 'nth' frame. This option will skip frames when " + "extracting faces. For example a value of 1 will extract faces from every " + "frame, a value of 10 will extract faces from every 10th frame."))) argument_list.append(dict( opts=("-sz", "--size"), type=int, action=Slider, min_max=(256, 1024), default=512, - group="extract", + group=_("extract"), rounding=64, - help="[Extract only] The output size of extracted faces.")) + help=_("[Extract only] The output size of extracted faces."))) argument_list.append(dict( opts=("-l", "--large"), action="store_true", - group="extract", + group=_("extract"), default=False, - help="[Extract only] Only extract faces that have not been upscaled to the required " - "size (`-sz`, `--size). Useful for excluding low-res images from a training " - "set.")) + help=_("[Extract only] Only extract faces that have not been upscaled to the required " + "size (`-sz`, `--size). Useful for excluding low-res images from a training " + "set."))) return argument_list diff --git a/tools/effmpeg/cli.py b/tools/effmpeg/cli.py index 7af0aca93b..ececeeaa39 100644 --- a/tools/effmpeg/cli.py +++ b/tools/effmpeg/cli.py @@ -1,10 +1,18 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ +import gettext + from lib.cli.args import FaceSwapArgs from lib.cli.actions import ContextFullPaths, FileFullPaths, Radio from lib.utils import _image_extensions -_HELPTEXT = "This command allows you to easily execute common ffmpeg tasks." + +# LOCALES +_LANG = gettext.translation("tools.effmpeg.cli", localedir="locales", fallback=True) +_ = _LANG.gettext + + +_HELPTEXT = _("This command allows you to easily execute common ffmpeg tasks.") class EffmpegArgs(FaceSwapArgs): @@ -13,7 +21,7 @@ class EffmpegArgs(FaceSwapArgs): @staticmethod def get_info(): """ Return command information """ - return "A wrapper for ffmpeg for performing image <> video converting." + return _("A wrapper for ffmpeg for performing image <> video converting.") @staticmethod def __parse_transpose(value): @@ -33,174 +41,150 @@ def __parse_transpose(value): def get_argument_list(self): argument_list = list() - argument_list.append({"opts": ('-a', '--action'), - "action": Radio, - "dest": "action", - "choices": ("extract", "gen-vid", "get-fps", - "get-info", "mux-audio", "rescale", - "rotate", "slice"), - "default": "extract", - "help": "R|Choose which action you want ffmpeg " - "ffmpeg to do." - "\nL|'extract': turns videos into images " - "\nL|'gen-vid': turns images into videos " - "\nL|'get-fps' returns the chosen video's fps." - "\nL|'get-info' returns information about a video." - "\nL|'mux-audio' add audio from one video to another." - "\nL|'rescale' resize video." - "\nL|'rotate' rotate video." - "\nL|'slice' cuts a portion of the video into a separate " - "video file."}) - argument_list.append({"opts": ('-i', '--input'), - "action": ContextFullPaths, - "dest": "input", - "default": "input", - "help": "Input file.", - "group": "data", - "required": True, - "action_option": "-a", - "filetypes": "video"}) - argument_list.append({"opts": ('-o', '--output'), - "action": ContextFullPaths, - "group": "data", - "default": "", - "dest": "output", - "help": "Output file. If no output is " - "specified then: if the output is " - "meant to be a video then a video " - "called 'out.mkv' will be created in " - "the input directory; if the output is " - "meant to be a directory then a " - "directory called 'out' will be " - "created inside the input " - "directory." - "Note: the chosen output file " - "extension will determine the file " - "encoding.", - "action_option": "-a", - "filetypes": "video"}) - argument_list.append({"opts": ('-r', '--reference-video'), - "action": FileFullPaths, - "dest": "ref_vid", - "group": "data", - "default": None, - "help": "Path to reference video if 'input' " - "was not a video.", - "filetypes": "video"}) - argument_list.append({"opts": ('-fps', '--fps'), - "type": str, - "dest": "fps", - "group": "output", - "default": "-1.0", - "help": "Provide video fps. Can be an integer, " - "float or fraction. Negative values " - "will make the program try to get the " - "fps from the input or reference " - "videos."}) - argument_list.append({"opts": ("-ef", "--extract-filetype"), - "action": Radio, - "choices": _image_extensions, - "dest": "extract_ext", - "group": "output", - "default": ".png", - "help": "Image format that extracted images " - "should be saved as. '.bmp' will offer " - "the fastest extraction speed, but " - "will take the most storage space. " - "'.png' will be slower but will take " - "less storage."}) - argument_list.append({"opts": ('-s', '--start'), - "type": str, - "dest": "start", - "group": "clip", - "default": "00:00:00", - "help": "Enter the start time from which an " - "action is to be applied. " - "Default: 00:00:00, in HH:MM:SS " - "format. You can also enter the time " - "with or without the colons, e.g. " - "00:0000 or 026010."}) - argument_list.append({"opts": ('-e', '--end'), - "type": str, - "dest": "end", - "group": "clip", - "default": "00:00:00", - "help": "Enter the end time to which an action " - "is to be applied. If both an end time " - "and duration are set, then the end " - "time will be used and the duration " - "will be ignored. " - "Default: 00:00:00, in HH:MM:SS."}) - argument_list.append({"opts": ('-d', '--duration'), - "type": str, - "dest": "duration", - "group": "clip", - "default": "00:00:00", - "help": "Enter the duration of the chosen " - "action, for example if you enter " - "00:00:10 for slice, then the first 10 " - "seconds after and including the start " - "time will be cut out into a new " - "video. " - "Default: 00:00:00, in HH:MM:SS " - "format. You can also enter the time " - "with or without the colons, e.g. " - "00:0000 or 026010."}) - argument_list.append({"opts": ('-m', '--mux-audio'), - "action": "store_true", - "dest": "mux_audio", - "group": "output", - "default": False, - "help": "Mux the audio from the reference " - "video into the input video. This " - "option is only used for the 'gen-vid' " - "action. 'mux-audio' action has this " - "turned on implicitly."}) - argument_list.append( - {"opts": ('-tr', '--transpose'), - "choices": ("(0, 90CounterClockwise&VerticalFlip)", - "(1, 90Clockwise)", - "(2, 90CounterClockwise)", - "(3, 90Clockwise&VerticalFlip)"), - "type": lambda v: self.__parse_transpose(v), - "dest": "transpose", - "group": "rotate", - "default": None, - "help": "Transpose the video. If transpose is " - "set, then degrees will be ignored. For " - "cli you can enter either the number " - "or the long command name, " - "e.g. to use (1, 90Clockwise) " - "-tr 1 or -tr 90Clockwise"}) - argument_list.append({"opts": ('-de', '--degrees'), - "type": str, - "dest": "degrees", - "default": None, - "group": "rotate", - "help": "Rotate the video clockwise by the " - "given number of degrees."}) - argument_list.append({"opts": ('-sc', '--scale'), - "type": str, - "dest": "scale", - "group": "output", - "default": "1920x1080", - "help": "Set the new resolution scale if the " - "chosen action is 'rescale'."}) - argument_list.append({"opts": ('-q', '--quiet'), - "action": "store_true", - "dest": "quiet", - "group": "settings", - "default": False, - "help": "Reduces output verbosity so that only " - "serious errors are printed. If both " - "quiet and verbose are set, verbose " - "will override quiet."}) - argument_list.append({"opts": ('-v', '--verbose'), - "action": "store_true", - "dest": "verbose", - "group": "settings", - "default": False, - "help": "Increases output verbosity. If both " - "quiet and verbose are set, verbose " - "will override quiet."}) + argument_list.append(dict( + opts=('-a', '--action'), + action=Radio, + dest="action", + choices=("extract", "gen-vid", "get-fps", "get-info", "mux-audio", "rescale", "rotate", + "slice"), + default="extract", + help=_("R|Choose which action you want ffmpeg ffmpeg to do." + "\nL|'extract': turns videos into images " + "\nL|'gen-vid': turns images into videos " + "\nL|'get-fps' returns the chosen video's fps." + "\nL|'get-info' returns information about a video." + "\nL|'mux-audio' add audio from one video to another." + "\nL|'rescale' resize video." + "\nL|'rotate' rotate video." + "\nL|'slice' cuts a portion of the video into a separate video file."))) + argument_list.append(dict( + opts=('-i', '--input'), + action=ContextFullPaths, + dest="input", + default="input", + help=_("Input file."), + group=_("data"), + required=True, + action_option="-a", + filetypes="video")) + argument_list.append(dict( + opts=('-o', '--output'), + action=ContextFullPaths, + group=_("data"), + default="", + dest="output", + help=_("Output file. If no output is specified then: if the output is meant to be a " + "video then a video called 'out.mkv' will be created in the input directory; " + "if the output is meant to be a directory then a directory called 'out' will " + "be created inside the input directory. Note: the chosen output file extension " + "will determine the file encoding."), + action_option="-a", + filetypes="video")) + argument_list.append(dict( + opts=('-r', '--reference-video'), + action=FileFullPaths, + dest="ref_vid", + group=_("data"), + default=None, + help=_("Path to reference video if 'input' was not a video."), + filetypes="video")) + argument_list.append(dict( + opts=('-fps', '--fps'), + type=str, + dest="fps", + group=_("output"), + default="-1.0", + help=_("Provide video fps. Can be an integer, float or fraction. Negative values will " + "will make the program try to get the fps from the input or reference " + "videos."))) + argument_list.append(dict( + opts=("-ef", "--extract-filetype"), + action=Radio, + choices=_image_extensions, + dest="extract_ext", + group=_("output"), + default=".png", + help=_("Image format that extracted images should be saved as. '.bmp' will offer the " + "fastest extraction speed, but will take the most storage space. '.png' will " + "be slower but will take less storage."))) + argument_list.append(dict( + opts=('-s', '--start'), + type=str, + dest="start", + group=_("clip"), + default="00:00:00", + help=_("Enter the start time from which an action is to be applied. Default: " + "00:00:00, in HH:MM:SS format. You can also enter the time with or without the " + "colons, e.g. 00:0000 or 026010."))) + argument_list.append(dict( + opts=('-e', '--end'), + type=str, + dest="end", + group=_("clip"), + default="00:00:00", + help=_("Enter the end time to which an action is to be applied. If both an end time " + "and duration are set, then the end time will be used and the duration will be " + "ignored. Default: 00:00:00, in HH:MM:SS."))) + argument_list.append(dict( + opts=('-d', '--duration'), + type=str, + dest="duration", + group=_("clip"), + default="00:00:00", + help=_("Enter the duration of the chosen action, for example if you enter 00:00:10 " + "for slice, then the first 10 seconds after and including the start time will " + "be cut out into a new video. Default: 00:00:00, in HH:MM:SS format. You can " + "also enter the time with or without the colons, e.g. 00:0000 or 026010."))) + argument_list.append(dict( + opts=('-m', '--mux-audio'), + action="store_true", + dest="mux_audio", + group=_("output"), + default=False, + help=_("Mux the audio from the reference video into the input video. This option is " + "only used for the 'gen-vid' action. 'mux-audio' action has this turned on " + "implicitly."))) + argument_list.append(dict( + opts=('-tr', '--transpose'), + choices=("(0, 90CounterClockwise&VerticalFlip)", + "(1, 90Clockwise)", + "(2, 90CounterClockwise)", + "(3, 90Clockwise&VerticalFlip)"), + type=lambda v: self.__parse_transpose(v), # pylint:disable=unnecessary-lambda + dest="transpose", + group=_("rotate"), + default=None, + help=_("Transpose the video. If transpose is set, then degrees will be ignored. For " + "cli you can enter either the number or the long command name, e.g. to use (1, " + "90Clockwise) -tr 1 or -tr 90Clockwise"))) + argument_list.append(dict( + opts=('-de', '--degrees'), + type=str, + dest="degrees", + default=None, + group=_("rotate"), + help=_("Rotate the video clockwise by the given number of degrees."))) + argument_list.append(dict( + opts=('-sc', '--scale'), + type=str, + dest="scale", + group=_("output"), + default="1920x1080", + help=_("Set the new resolution scale if the chosen action is 'rescale'."))) + argument_list.append(dict( + opts=('-q', '--quiet'), + action="store_true", + dest="quiet", + group=_("settings"), + default=False, + help=_("Reduces output verbosity so that only serious errors are printed. If both " + "quiet and verbose are set, verbose will override quiet."))) + argument_list.append(dict( + opts=('-v', '--verbose'), + action="store_true", + dest="verbose", + group=_("settings"), + default=False, + help=_("Increases output verbosity. If both quiet and verbose are set, verbose will " + "override quiet."))) return argument_list diff --git a/tools/manual/cli.py b/tools/manual/cli.py index db9278eb1a..714c6ade33 100644 --- a/tools/manual/cli.py +++ b/tools/manual/cli.py @@ -1,9 +1,17 @@ #!/usr/bin/env python3 """ The Command Line Arguments for the Manual Editor tool. """ +import gettext + from lib.cli.args import FaceSwapArgs, DirOrFileFullPaths, FileFullPaths -_HELPTEXT = ("This command lets you perform various actions on frames, " - "faces and alignments files using visual tools.") + +# LOCALES +_LANG = gettext.translation("tools.manual.cli", localedir="locales", fallback=True) +_ = _LANG.gettext + + +_HELPTEXT = _("This command lets you perform various actions on frames, " + "faces and alignments files using visual tools.") class ManualArgs(FaceSwapArgs): @@ -12,8 +20,8 @@ class ManualArgs(FaceSwapArgs): @staticmethod def get_info(): """ Obtain the information about what the Manual Tool does. """ - return ("A tool to perform various actions on frames, faces and alignments files using " - "visual tools") + return _("A tool to perform various actions on frames, faces and alignments files using " + "visual tools") @staticmethod def get_argument_list(): @@ -24,33 +32,33 @@ def get_argument_list(): action=FileFullPaths, filetypes="alignments", type=str, - group="data", + group=_("data"), dest="alignments_path", - help="Path to the alignments file for the input, if not at the default location")) + help=_("Path to the alignments file for the input, if not at the default location"))) argument_list.append(dict( opts=("-fr", "--frames"), action=DirOrFileFullPaths, filetypes="video", required=True, - group="data", - help="Video file or directory containing source frames that faces were extracted " - "from.")) + group=_("data"), + help=_("Video file or directory containing source frames that faces were extracted " + "from."))) argument_list.append(dict( opts=("-t", "--thumb-regen"), action="store_true", dest="thumb_regen", default=False, - group="options", - help="Force regeneration of the low resolution jpg thumbnails in the alignments " - "file.")) + group=_("options"), + help=_("Force regeneration of the low resolution jpg thumbnails in the alignments " + "file."))) argument_list.append(dict( opts=("-s", "--single-process"), action="store_true", dest="single_process", default=False, - group="options", - help="The process attempts to speed up generation of thumbnails by extracting from " - "the video in parallel threads. For some videos, this causes the caching " - "process to hang. If this happens, then set this option to generate the " - "thumbnails in a slower, but more stable single thread.")) + group=_("options"), + help=_("The process attempts to speed up generation of thumbnails by extracting from " + "the video in parallel threads. For some videos, this causes the caching " + "process to hang. If this happens, then set this option to generate the " + "thumbnails in a slower, but more stable single thread."))) return argument_list diff --git a/tools/mask/cli.py b/tools/mask/cli.py index 5407c4ef83..0c677ef88f 100644 --- a/tools/mask/cli.py +++ b/tools/mask/cli.py @@ -1,10 +1,18 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ +import gettext + from lib.cli.args import FaceSwapArgs from lib.cli.actions import (DirOrFileFullPaths, DirFullPaths, FileFullPaths, Radio, Slider) from plugins.plugin_loader import PluginLoader -_HELPTEXT = "This command lets you generate masks for existing alignments." + +# LOCALES +_LANG = gettext.translation("tools.mask.cli", localedir="locales", fallback=True) +_ = _LANG.gettext + + +_HELPTEXT = _("This command lets you generate masks for existing alignments.") class MaskArgs(FaceSwapArgs): @@ -13,127 +21,127 @@ class MaskArgs(FaceSwapArgs): @staticmethod def get_info(): """ Return command information """ - return "Mask tool\nGenerate masks for existing alignments files." + return _("Mask tool\nGenerate masks for existing alignments files.") def get_argument_list(self): argument_list = list() - argument_list.append({ - "opts": ("-a", "--alignments"), - "action": FileFullPaths, - "type": str, - "group": "data", - "required": True, - "filetypes": "alignments", - "help": "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."}) - 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": ("-M", "--masker"), - "action": Radio, - "type": str.lower, - "choices": PluginLoader.get_available_extractors("mask"), - "default": "extended", - "group": "process", - "help": "R|Masker to use." - "\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|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({ - "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({ - "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": 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({ - "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({ - "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({ - "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(dict( + opts=("-a", "--alignments"), + action=FileFullPaths, + type=str, + group=_("data"), + required=True, + filetypes="alignments", + help=_("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."))) + 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=("-M", "--masker"), + action=Radio, + type=str.lower, + choices=PluginLoader.get_available_extractors("mask"), + default="extended", + group=_("process"), + help=_("R|Masker to use." + "\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|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."))) return argument_list diff --git a/tools/preview/cli.py b/tools/preview/cli.py index 2394be1533..278c195210 100644 --- a/tools/preview/cli.py +++ b/tools/preview/cli.py @@ -1,9 +1,17 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ +import gettext + from lib.cli.args import FaceSwapArgs from lib.cli.actions import DirOrFileFullPaths, DirFullPaths, FileFullPaths -_HELPTEXT = "This command allows you to preview swaps to tweak convert settings." + +# LOCALES +_LANG = gettext.translation("tools.preview.cli", localedir="locales", fallback=True) +_ = _LANG.gettext + + +_HELPTEXT = _("This command allows you to preview swaps to tweak convert settings.") class PreviewArgs(FaceSwapArgs): @@ -12,7 +20,7 @@ class PreviewArgs(FaceSwapArgs): @staticmethod def get_info(): """ Return command information """ - return "Preview tool\nAllows you to configure your convert settings with a live preview" + return _("Preview tool\nAllows you to configure your convert settings with a live preview") def get_argument_list(self): @@ -22,29 +30,30 @@ def get_argument_list(self): action=DirOrFileFullPaths, filetypes="video", dest="input_dir", - group="data", + group=_("data"), required=True, - help="Input directory or video. Either a directory containing the image files you " - "wish to process or path to a video file.")) + help=_("Input directory or video. Either a directory containing the image files you " + "wish to process or path to a video file."))) argument_list.append(dict( opts=("-al", "--alignments"), action=FileFullPaths, filetypes="alignments", type=str, - group="data", + group=_("data"), dest="alignments_path", - help="Path to the alignments file for the input, if not at the default location")) + help=_("Path to the alignments file for the input, if not at the default location"))) argument_list.append(dict( opts=("-m", "--model-dir"), action=DirFullPaths, dest="model_dir", - group="data", + group=_("data"), required=True, - help="Model directory. A directory containing the trained model you wish to process.")) + help=_("Model directory. A directory containing the trained model you wish to " + "process."))) argument_list.append(dict( opts=("-s", "--swap-model"), action="store_true", dest="swap_model", default=False, - help="Swap the model. Instead of A -> B, swap B -> A")) + help=_("Swap the model. Instead of A -> B, swap B -> A"))) return argument_list diff --git a/tools/restore/cli.py b/tools/restore/cli.py index 4376eefb8a..fe3ca9aa77 100644 --- a/tools/restore/cli.py +++ b/tools/restore/cli.py @@ -1,9 +1,16 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ +import gettext + from lib.cli.args import FaceSwapArgs from lib.cli.actions import DirFullPaths -_HELPTEXT = "This command lets you restore models from backup." + +# LOCALES +_LANG = gettext.translation("tools.restore.cli", localedir="locales", fallback=True) +_ = _LANG.gettext + +_HELPTEXT = _("This command lets you restore models from backup.") class RestoreArgs(FaceSwapArgs): @@ -12,16 +19,17 @@ class RestoreArgs(FaceSwapArgs): @staticmethod def get_info(): """ Return command information """ - return "A tool for restoring models from backup (.bk) files" + return _("A tool for restoring models from backup (.bk) files") @staticmethod def get_argument_list(): """ Put the arguments in a list so that they are accessible from both argparse and gui """ argument_list = list() - argument_list.append({"opts": ("-m", "--model-dir"), - "action": DirFullPaths, - "dest": "model_dir", - "required": True, - "help": "Model directory. A directory containing the model " - "you wish to restore from backup."}) + argument_list.append(dict( + opts=("-m", "--model-dir"), + action=DirFullPaths, + dest="model_dir", + required=True, + help=_("Model directory. A directory containing the model you wish to restore from " + "backup."))) return argument_list diff --git a/tools/sort/cli.py b/tools/sort/cli.py index 39ba298d5e..f424c6e6fd 100644 --- a/tools/sort/cli.py +++ b/tools/sort/cli.py @@ -1,9 +1,17 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ +import gettext + from lib.cli.args import FaceSwapArgs from lib.cli.actions import DirFullPaths, SaveFileFullPaths, Radio, Slider -_HELPTEXT = "This command lets you sort images using various methods." + +# LOCALES +_LANG = gettext.translation("tools.sort.cli", localedir="locales", fallback=True) +_ = _LANG.gettext + + +_HELPTEXT = _("This command lets you sort images using various methods.") class SortArgs(FaceSwapArgs): @@ -12,7 +20,7 @@ class SortArgs(FaceSwapArgs): @staticmethod def get_info(): """ Return command information """ - return "Sort faces using a number of different techniques" + return _("Sort faces using a number of different techniques") @staticmethod def get_argument_list(): @@ -22,15 +30,15 @@ def get_argument_list(): opts=('-i', '--input'), action=DirFullPaths, dest="input_dir", - group="data", - help="Input directory of aligned faces.", + group=_("data"), + help=_("Input directory of aligned faces."), required=True)) argument_list.append(dict( opts=('-o', '--output'), action=DirFullPaths, dest="output_dir", - group="data", - help="Output directory for sorted aligned faces.")) + group=_("data"), + help=_("Output directory for sorted aligned faces."))) argument_list.append(dict( opts=('-s', '--sort-by'), action=Radio, @@ -38,38 +46,39 @@ def get_argument_list(): choices=("blur", "face", "face-cnn", "face-cnn-dissim", "face-yaw", "hist", "hist-dissim", "color-gray", "color-luma", "color-green", "color-orange"), dest='sort_method', - group="sort settings", + group=_("sort settings"), default="face", - help="R|Sort by method. Choose how images are sorted. " - "\nL|'blur': Sort faces by blurriness." - "\nL|'face': Use VGG Face to sort by face similarity. This uses a pairwise " - "clustering algorithm to check the distances between 512 features on every face " - "in your set and order them appropriately." - "\nL|'face-cnn': Sort faces by their landmarks. You can adjust the threshold " - "with the '-t' (--ref_threshold) option." - "\nL|'face-cnn-dissim': Like 'face-cnn' but sorts by dissimilarity." - "\nL|'face-yaw': Sort faces by Yaw (rotation left to right)." - "\nL|'hist': Sort faces by their color histogram. You can adjust the threshold " - "with the '-t' (--ref_threshold) option." - "\nL|'hist-dissim': Like 'hist' but sorts by dissimilarity." - "\nL|'color-gray': Sort images by the average intensity of the converted " - "grayscale color channel." - "\nL|'color-luma': Sort images by the average intensity of the converted Y color " - "channel. Bright lighting and oversaturated images will be ranked first." - "\nL|'color-green': Sort images by the average intensity of the converted Cg " - "color channel. Green images will be ranked first and red images will be last." - "\nL|'color-orange': Sort images by the average intensity of the converted Co " - "color channel. Orange images will be ranked first and blue images will be last." - "\nDefault: hist")) + help=_("R|Sort by method. Choose how images are sorted. " + "\nL|'blur': Sort faces by blurriness." + "\nL|'face': Use VGG Face to sort by face similarity. This uses a pairwise " + "clustering algorithm to check the distances between 512 features on every " + "face in your set and order them appropriately." + "\nL|'face-cnn': Sort faces by their landmarks. You can adjust the threshold " + "with the '-t' (--ref_threshold) option." + "\nL|'face-cnn-dissim': Like 'face-cnn' but sorts by dissimilarity." + "\nL|'face-yaw': Sort faces by Yaw (rotation left to right)." + "\nL|'hist': Sort faces by their color histogram. You can adjust the threshold " + "with the '-t' (--ref_threshold) option." + "\nL|'hist-dissim': Like 'hist' but sorts by dissimilarity." + "\nL|'color-gray': Sort images by the average intensity of the converted " + "grayscale color channel." + "\nL|'color-luma': Sort images by the average intensity of the converted Y " + "color channel. Bright lighting and oversaturated images will be ranked first." + "\nL|'color-green': Sort images by the average intensity of the converted Cg " + "color channel. Green images will be ranked first and red images will be last." + "\nL|'color-orange': Sort images by the average intensity of the converted Co " + "color channel. Orange images will be ranked first and blue images will be " + "last." + "\nDefault: hist"))) argument_list.append(dict( opts=('-k', '--keep'), action='store_true', dest='keep_original', default=False, - group="output", - help="Keeps the original files in the input directory. Be careful when using this " - "with rename grouping and no specified output directory as this would keep the " - "original and renamed files in the same directory.")) + group=_("output"), + help=_("Keeps the original files in the input directory. Be careful when using this " + "with rename grouping and no specified output directory as this would keep the " + "original and renamed files in the same directory."))) argument_list.append(dict( opts=('-t', '--ref_threshold'), action=Slider, @@ -77,15 +86,15 @@ def get_argument_list(): rounding=2, type=float, dest='min_threshold', - group="sort settings", + group=_("sort settings"), default=-1.0, - help="Float value. Minimum threshold to use for grouping comparison with 'face-cnn' " - "and 'hist' methods. The lower the value the more discriminating the grouping " - "is. Leaving -1.0 will allow the program set the default value automatically. " - "For face-cnn 7.2 should be enough, with 4 being very discriminating. For hist " - "0.3 should be enough, with 0.2 being very discriminating. Be careful setting a " - "value that's too low in a directory with many images, as this could result in a " - "lot of directories being created. Defaults: face-cnn 7.2, hist 0.3")) + help=_("Float value. Minimum threshold to use for grouping comparison with 'face-cnn' " + "and 'hist' methods. The lower the value the more discriminating the grouping " + "is. Leaving -1.0 will allow the program set the default value automatically. " + "For face-cnn 7.2 should be enough, with 4 being very discriminating. For hist " + "0.3 should be enough, with 0.2 being very discriminating. Be careful setting " + "a value that's too low in a directory with many images, as this could result " + "in a lot of directories being created. Defaults: face-cnn 7.2, hist 0.3"))) argument_list.append(dict( opts=('-fp', '--final-process'), action=Radio, @@ -93,21 +102,22 @@ def get_argument_list(): choices=("folders", "rename"), dest='final_process', default="rename", - group="output", - help="R|Default: rename." - "\nL|'folders': files are sorted using the -s/--sort-by method, then they are " - "organized into folders using the -g/--group-by grouping method." - "\nL|'rename': files are sorted using the -s/--sort-by then they are renamed.")) + group=_("output"), + help=_("R|Default: rename." + "\nL|'folders': files are sorted using the -s/--sort-by method, then they are " + "organized into folders using the -g/--group-by grouping method." + "\nL|'rename': files are sorted using the -s/--sort-by then they are " + "renamed."))) argument_list.append(dict( opts=('-g', '--group-by'), action=Radio, type=str, choices=("blur", "face-cnn", "face-yaw", "hist"), dest='group_method', - group="output", + group=_("output"), default="hist", - help="Group by method. When -fp/--final-processing by folders choose the how the " - "images are grouped after sorting. Default: hist")) + help=_("Group by method. When -fp/--final-processing by folders choose the how the " + "images are grouped after sorting. Default: hist"))) argument_list.append(dict( opts=('-b', '--bins'), action=Slider, @@ -115,34 +125,34 @@ def get_argument_list(): rounding=1, type=int, dest='num_bins', - group="output", + group=_("output"), default=5, - help="Integer value. Number of folders that will be used to group by blur and " - "face-yaw. For blur folder 0 will be the least blurry, while the last folder " - "will be the blurriest. For face-yaw the number of bins is by how much 180 " - "degrees is divided. So if you use 18, then each folder will be a 10 degree " - "increment. Folder 0 will contain faces looking the most to the left whereas the " - "last folder will contain the faces looking the most to the right. If the number " - "of images doesn't divide evenly into the number of bins, the remaining images " - "get put in the last bin. Default value: 5")) + help=_("Integer value. Number of folders that will be used to group by blur and " + "face-yaw. For blur folder 0 will be the least blurry, while the last folder " + "will be the blurriest. For face-yaw the number of bins is by how much 180 " + "degrees is divided. So if you use 18, then each folder will be a 10 degree " + "increment. Folder 0 will contain faces looking the most to the left whereas " + "the last folder will contain the faces looking the most to the right. If the " + "number of images doesn't divide evenly into the number of bins, the remaining " + "images get put in the last bin. Default value: 5"))) argument_list.append(dict( opts=('-l', '--log-changes'), action='store_true', - group="settings", + group=_("settings"), default=False, - help="Logs file renaming changes if grouping by renaming, or it logs the file " - "copying/movement if grouping by folders. If no log file is specified with " - "'--log-file', then a 'sort_log.json' file will be created in the input " - "directory.")) + help=_("Logs file renaming changes if grouping by renaming, or it logs the file " + "copying/movement if grouping by folders. If no log file is specified with " + "'--log-file', then a 'sort_log.json' file will be created in the input " + "directory."))) argument_list.append(dict( opts=('-lf', '--log-file'), action=SaveFileFullPaths, filetypes="alignments", - group="settings", + group=_("settings"), dest='log_file_path', default='sort_log.json', - help="Specify a log file to use for saving the renaming or grouping information. If " - "specified extension isn't 'json' or 'yaml', then json will be used as the " - "serializer, with the supplied filename. Default: sort_log.json")) + help=_("Specify a log file to use for saving the renaming or grouping information. If " + "specified extension isn't 'json' or 'yaml', then json will be used as the " + "serializer, with the supplied filename. Default: sort_log.json"))) return argument_list From bdf84ae307b755486f11c35e96f8d03b389864dc Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 19 Feb 2021 00:12:43 +0000 Subject: [PATCH 374/981] Update spanish translation --- locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 37615 -> 37986 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 220 ++++++++++++++----------- 2 files changed, 120 insertions(+), 100 deletions(-) diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo index 3c267f4e66d9200f40310b6f7f7d614a39182496..7f575184092863329f768f4727cbf820025dc118 100644 GIT binary patch delta 1754 zcmYk6X>3$g7>3_zp`}Hrz)YbVo#N8Xwlk#&ol+PJ(@L#YXdxgCGGJRIP?|0p)956I zWq<~%gFi}DRAL}5iO6Eg63{@5KM28yU?Kz%O$d<2G$E2;^*Q%m2q$^(bH20ObI$jD z=L~%wcYS|cpF7ej>Gmi-lxTv~?UJ^_RQO+_bb|KOBxw))#bH;nWTv>sO0!`a+yqKGb2>7n&;xQtIR6V^h;sDFYq0{6qswC|fJ z9mXzpOCP}-a5sF{Bh9BjE0uBBt0qg!;Y~P~_Rth*0DDuKbPwjFOY>;Y$cWgLS2Ly6 zbnMNNX3>#2RjS1fW=r|l+hG;{z;tX7>pdDQb(nOznGYxgdb!7I$v53yF<2T z{(x^|*RTrV)EUS@^|QlEFai4tG@keqj)8X|X`{Q2ts1F-b&}v-7x6E9UaF_z(gG$k zFz*HFCz!~t49`Qe(7=oKi9Ut>*f-!&_zuT4CoZp-dihTj{)X*YXy?=rGzZcmX#)Lw zAnBq54djiB`2R?S{8H^=JJwC`0|sthV#h3VslCu`_ygniEn^Y-*S;d{!X75mjHjYa z#GCfJ@C)pQ6_OWw1TKf!D{c0KZBJfYC9R0iv3NCZWx}D?xI)1iL%r3lb5IpBB@OWqTGuxNrlZI+e6mpu3L;)iO+vJUHgc%v_dIs#r=+FHBAt<6P^&EfTr*a2^IXY6`tS1ejxc<@wpN!|Ymsp-N1 delta 1377 zcmYk*duYvJ9LMp`h7H>=9E>do(H2MN9HZm7+hN08HyT#P*mlwo@-=vnxW2vg3paO=GRTLM*uVX7yCb`$AvT#^5--Hj#K%*lG~%x~oH(g7 z3&fc?63fx<_XIg4r8rqo6RA5sBfimH8Wb(=je{7N#B^)3NzcCMpBNO z1<=qTU2-sBwNK*o74b`3@Bo%#mjO~g+=qSeA!eXm2YW#Q9Eas-FZeZH=0z!kq-uOR z7|AamDqX{H$6-=756{uAc{fHKCiXLH5x&B?m>HnXcm^q>(nd&ZRQ(zpi1vbqqkR&i zkZYoe$hFY)hPVK8cux@?i=zHFS@w7m!7S+=9WRZN?qenAYMVF4yeO4b+B@ov2XHA~ z#kjF%$9HiD^ElZ4EArLj&7E33!E9(JCeq$(B7JBdJW1LR6_#>&;9luPj;VD$?xSOy z$);vCQ_PE2PL*!aFMXP{mG+-lNxW{lIVr~s?gjacc$$3OOvy=HK8wqUXY-6r^UX;X zSZpp#!$%%688D1-M+(^EBa zSo)3}l16Sd3#vk@ryux+4Gh`FXN$?@=DY8-(=4bC^XYeQmnnJbZq3#W5-avd^RfS4 zsRXN$v2sMr!j2*fRt?7BxdtzzUAkI~#mCqTUtj`$h+Hg-sT$fN*OgHcv_cCO7rO#h zDCl>3-CmE&?R9y4PLIdm*XMHct4PUAPA1{=r+a$ZiRRf>ap5wnFzCt+T19?mMJTf} jG21E)xpGUa;?hM{XmJVI*sNah|9bkJ!P1M~EO+)_=lJ3n diff --git a/locales/es/LC_MESSAGES/lib.cli.args.po b/locales/es/LC_MESSAGES/lib.cli.args.po index 8c873107b2..102ca8df1e 100644 --- a/locales/es/LC_MESSAGES/lib.cli.args.po +++ b/locales/es/LC_MESSAGES/lib.cli.args.po @@ -5,24 +5,24 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2021-02-18 11:58-0000\n" -"PO-Revision-Date: 2021-02-18 18:41+0000\n" +"POT-Creation-Date: 2021-02-18 23:45-0000\n" +"PO-Revision-Date: 2021-02-19 00:11+0000\n" +"Last-Translator: \n" "Language-Team: \n" +"Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: pygettext.py 1.5\n" "X-Generator: Poedit 2.4.2\n" -"Last-Translator: \n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -"Language: es\n" -#: lib/cli/args.py:172 lib/cli/args.py:182 lib/cli/args.py:190 -#: lib/cli/args.py:200 +#: lib/cli/args.py:177 lib/cli/args.py:187 lib/cli/args.py:195 +#: lib/cli/args.py:205 msgid "Global Options" msgstr "Opciones Globales" -#: lib/cli/args.py:173 +#: lib/cli/args.py:178 msgid "" "R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " "to any GPU(s) that you do not wish to be made available to Faceswap. " @@ -34,12 +34,12 @@ msgstr "" "con Faceswap. Marcar todas las GPUs forzará a Faceswap a usar sólo la CPU,\n" "L|{}" -#: lib/cli/args.py:183 +#: lib/cli/args.py:188 msgid "" "Optionally overide the saved config with the path to a custom config file." msgstr "Usar un fichero alternativo de configuración, almacenado en esta ruta." -#: lib/cli/args.py:191 +#: lib/cli/args.py:196 msgid "" "Log level. Stick with INFO or VERBOSE unless you need to file an error " "report. Be careful with TRACE as it will generate a lot of data" @@ -47,18 +47,18 @@ msgstr "" "Nivel de registro. Dejarlo en INFO o VERBOSE, a menos que necesite informar " "de un error. Tenga en cuenta que TRACE generará muchísima información" -#: lib/cli/args.py:201 +#: lib/cli/args.py:206 msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "" "Ruta para almacenar el fichero de registro. Dejarlo en blanco para " "almacenarlo en la carpeta pde instalación de faceswap" -#: lib/cli/args.py:294 lib/cli/args.py:303 lib/cli/args.py:311 -#: lib/cli/args.py:622 lib/cli/args.py:631 +#: lib/cli/args.py:299 lib/cli/args.py:308 lib/cli/args.py:316 +#: lib/cli/args.py:627 lib/cli/args.py:636 msgid "Data" msgstr "Datos" -#: lib/cli/args.py:295 +#: lib/cli/args.py:300 msgid "" "Input directory or video. Either a directory containing the image files you " "wish to process or path to a video file. NB: This should be the source video/" @@ -68,12 +68,12 @@ msgstr "" "imagen que desea procesar o la ruta a un archivo de vídeo. NB: Debe ser el " "vídeo/los fotogramas de origen, NO las caras de origen." -#: lib/cli/args.py:304 +#: lib/cli/args.py:309 msgid "Output directory. This is where the converted files will be saved." msgstr "" "Directorio de salida. Aquí es donde se guardarán los archivos convertidos." -#: lib/cli/args.py:312 +#: lib/cli/args.py:317 msgid "" "Optional path to an alignments file. Leave blank if the alignments file is " "at the default location." @@ -81,13 +81,19 @@ msgstr "" "Ruta opcional a un archivo de alineaciones. Dejar en blanco si el archivo de " "alineaciones está en la ubicación por defecto." -#: lib/cli/args.py:360 lib/cli/args.py:376 lib/cli/args.py:388 -#: lib/cli/args.py:420 lib/cli/args.py:438 lib/cli/args.py:450 -#: lib/cli/args.py:641 lib/cli/args.py:666 lib/cli/args.py:693 +#: lib/cli/args.py:340 +msgid "" +"Extract faces from image or video sources.\n" +"Extraction plugins can be configured in the 'Settings' Menu" +msgstr "" + +#: lib/cli/args.py:365 lib/cli/args.py:381 lib/cli/args.py:393 +#: lib/cli/args.py:425 lib/cli/args.py:443 lib/cli/args.py:455 +#: lib/cli/args.py:646 lib/cli/args.py:671 lib/cli/args.py:698 msgid "Plugins" msgstr "Extensiones" -#: lib/cli/args.py:361 +#: lib/cli/args.py:366 msgid "" "R|Detector to use. Some of these have configurable settings in '/config/" "extract.ini' or 'Settings > Configure Extract 'Plugins':\n" @@ -110,7 +116,7 @@ msgstr "" "detectar más caras y tiene menos falsos positivos que otros detectores " "basados en GPU, pero uso muchos más recursos." -#: lib/cli/args.py:377 +#: lib/cli/args.py:382 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " @@ -122,7 +128,7 @@ msgstr "" "pero es menos preciso. Elegir este si necesita rapidez y no usar la GPU.\n" "L|fan: El mejor alineador. Rápido en la GPU, y lento en la CPU." -#: lib/cli/args.py:389 +#: lib/cli/args.py:394 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -153,10 +159,9 @@ msgstr "" "todas RAM de la GPU. Puede seleccionar una, varias o ninguna máscaras, pero " "la extracción tardará más cuanto más marque. Las máscaras Extended y " "Components son siempre generadas durante la extracción.\n" -"L|vgg-clear: Máscara diseñada para proporcionar una segmentación " -"inteligente de rostros principalmente frontales y libres de obstrucciones. " -"Los rostros de perfil y las obstrucciones pueden dar lugar a un rendimiento " -"inferior.\n" +"L|vgg-clear: Máscara diseñada para proporcionar una segmentación inteligente " +"de rostros principalmente frontales y libres de obstrucciones. Los rostros " +"de perfil y las obstrucciones pueden dar lugar a un rendimiento inferior.\n" "L|vgg-obstructed: Máscara diseñada para proporcionar una segmentación " "inteligente de rostros principalmente frontales. El modelo de la máscara ha " "sido entrenado específicamente para reconocer algunas obstrucciones faciales " @@ -178,7 +183,7 @@ msgstr "" "referencia y la máscara se extiende hacia arriba en la frente.\n" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -#: lib/cli/args.py:421 +#: lib/cli/args.py:426 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -194,12 +199,14 @@ msgstr "" "caras con condiciones de iluminación difíciles a un coste de velocidad de " "extracción. Diferentes métodos darán diferentes resultados en diferentes " "conjuntos. NB: Esto no afecta a la cara de salida, sólo a la entrada del " -"alineador.\nL|none: No realice la normalización en la cara.\nL|clahe: " -"Realice la ecualización adaptativa del histograma con contraste limitado en " -"el rostro.\nL|hist: Iguala los histogramas de los canales RGB.\nL|mean: " -"Normalizar los colores de la cara a la media." +"alineador.\n" +"L|none: No realice la normalización en la cara.\n" +"L|clahe: Realice la ecualización adaptativa del histograma con contraste " +"limitado en el rostro.\n" +"L|hist: Iguala los histogramas de los canales RGB.\n" +"L|mean: Normalizar los colores de la cara a la media." -#: lib/cli/args.py:439 +#: lib/cli/args.py:444 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -216,7 +223,7 @@ msgstr "" "más veces se vuelva a introducir la cara en el alineador, menos " "microfluctuaciones se producirán, pero la extracción será más larga." -#: lib/cli/args.py:451 +#: lib/cli/args.py:456 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -228,13 +235,13 @@ msgstr "" "un solo número para usar incrementos de ese tamaño hasta 360, o pase una " "lista de números para enumerar exactamente qué ángulos comprobar." -#: lib/cli/args.py:463 lib/cli/args.py:473 lib/cli/args.py:486 -#: lib/cli/args.py:500 lib/cli/args.py:730 lib/cli/args.py:744 -#: lib/cli/args.py:757 lib/cli/args.py:771 +#: lib/cli/args.py:468 lib/cli/args.py:478 lib/cli/args.py:491 +#: lib/cli/args.py:505 lib/cli/args.py:735 lib/cli/args.py:749 +#: lib/cli/args.py:762 lib/cli/args.py:776 msgid "Face Processing" msgstr "Proceso de Caras" -#: lib/cli/args.py:464 +#: lib/cli/args.py:469 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -243,7 +250,7 @@ msgstr "" "a lo largo de la diagonal del cuadro delimitador. Establecer a 0 para " "desactivar" -#: lib/cli/args.py:474 lib/cli/args.py:745 +#: lib/cli/args.py:479 lib/cli/args.py:750 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -257,7 +264,7 @@ msgstr "" "uso del filtro de caras disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args.py:487 lib/cli/args.py:758 +#: lib/cli/args.py:492 lib/cli/args.py:763 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -271,7 +278,7 @@ msgstr "" "del filtro facial disminuirá significativamente la velocidad de extracción y " "no se puede garantizar su precisión." -#: lib/cli/args.py:501 lib/cli/args.py:772 +#: lib/cli/args.py:506 lib/cli/args.py:777 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -283,12 +290,12 @@ msgstr "" "NB: El uso del filtro facial disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args.py:512 lib/cli/args.py:524 lib/cli/args.py:536 -#: lib/cli/args.py:548 +#: lib/cli/args.py:517 lib/cli/args.py:529 lib/cli/args.py:541 +#: lib/cli/args.py:553 msgid "output" msgstr "salida" -#: lib/cli/args.py:513 +#: lib/cli/args.py:518 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -298,7 +305,7 @@ msgstr "" "pretende entrenar admite el tamaño deseado. Esto sólo tendrá que ser " "cambiado para los modelos de alta resolución." -#: lib/cli/args.py:525 +#: lib/cli/args.py:530 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -308,7 +315,7 @@ msgstr "" "extraer las caras. Por ejemplo, un valor de 1 extraerá las caras de cada " "fotograma, un valor de 10 extraerá las caras de cada 10 fotogramas." -#: lib/cli/args.py:537 +#: lib/cli/args.py:542 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -324,18 +331,18 @@ msgstr "" "ADVERTENCIA: No interrumpa el script al escribir el archivo porque podría " "corromperse. Poner a 0 para desactivar" -#: lib/cli/args.py:549 +#: lib/cli/args.py:554 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" "Dibujar puntos de referencia en las caras de salida para fines de depuración." -#: lib/cli/args.py:555 lib/cli/args.py:564 lib/cli/args.py:572 -#: lib/cli/args.py:579 lib/cli/args.py:784 lib/cli/args.py:795 -#: lib/cli/args.py:803 lib/cli/args.py:822 lib/cli/args.py:828 +#: lib/cli/args.py:560 lib/cli/args.py:569 lib/cli/args.py:577 +#: lib/cli/args.py:584 lib/cli/args.py:789 lib/cli/args.py:800 +#: lib/cli/args.py:808 lib/cli/args.py:827 lib/cli/args.py:833 msgid "settings" msgstr "ajustes" -#: lib/cli/args.py:556 +#: lib/cli/args.py:561 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " @@ -345,7 +352,7 @@ msgstr "" "extracción por separado (una tras otra) en lugar de hacerlo todo al mismo " "tiempo. Útil si la VRAM es escasa." -#: lib/cli/args.py:565 +#: lib/cli/args.py:570 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -353,19 +360,25 @@ msgstr "" "Omite los fotogramas que ya han sido extraídos y que existen en el archivo " "de alineaciones" -#: lib/cli/args.py:573 +#: lib/cli/args.py:578 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" "Omitir los fotogramas que ya tienen caras detectadas en el archivo de " "alineaciones" -#: lib/cli/args.py:580 +#: lib/cli/args.py:585 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "No guardar las caras detectadas en el disco. Crear sólo un archivo de " "alineaciones" -#: lib/cli/args.py:623 +#: lib/cli/args.py:607 +msgid "" +"Swap the original faces in a source video/images to your final faces.\n" +"Conversion plugins can be configured in the 'Settings' Menu" +msgstr "" + +#: lib/cli/args.py:628 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -375,7 +388,7 @@ msgstr "" "original del que se extrajeron los fotogramas de origen (para extraer los " "fps y el audio)." -#: lib/cli/args.py:632 +#: lib/cli/args.py:637 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -383,7 +396,7 @@ msgstr "" "Directorio del modelo. El directorio que contiene el modelo entrenado que " "desea utilizar para la conversión." -#: lib/cli/args.py:642 +#: lib/cli/args.py:647 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -423,7 +436,7 @@ msgstr "" "colores. Generalmente no da resultados muy satisfactorios.\n" "L|none: No realice el ajuste de color." -#: lib/cli/args.py:667 +#: lib/cli/args.py:672 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -473,7 +486,7 @@ msgstr "" "descripción. Los rostros de perfil pueden dar lugar a un rendimiento " "inferior." -#: lib/cli/args.py:694 +#: lib/cli/args.py:699 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -499,11 +512,11 @@ msgstr "" "L|pillow: [images] Más lento que opencv, pero tiene más opciones y soporta " "más formatos." -#: lib/cli/args.py:713 lib/cli/args.py:720 lib/cli/args.py:814 +#: lib/cli/args.py:718 lib/cli/args.py:725 lib/cli/args.py:819 msgid "Frame Processing" msgstr "Proceso de fotogramas" -#: lib/cli/args.py:714 +#: lib/cli/args.py:719 msgid "" "Scale the final output frames by this amount. 100%% will output the frames " "at source dimensions. 50%% at half size 200%% at double size" @@ -512,7 +525,7 @@ msgstr "" "a los fotogramas a las dimensiones de origen. 50%% a la mitad de tamaño. 200%" "% al doble de tamaño" -#: lib/cli/args.py:721 +#: lib/cli/args.py:726 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -526,7 +539,7 @@ msgstr "" "imágenes, ¡los nombres de los archivos deben terminar con el número de " "fotograma!" -#: lib/cli/args.py:731 +#: lib/cli/args.py:736 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -542,7 +555,7 @@ msgstr "" "especificada. Si se deja en blanco, se convertirán todas las caras que " "existan en el archivo de alineaciones." -#: lib/cli/args.py:785 +#: lib/cli/args.py:790 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -559,7 +572,7 @@ msgstr "" "procesos que los disponibles en su sistema. Si 'singleprocess' está " "habilitado, este ajuste será ignorado." -#: lib/cli/args.py:796 +#: lib/cli/args.py:801 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -567,7 +580,7 @@ msgstr "" "[LEGACY] Sólo es necesario seleccionar esta opción si se está cargando un " "modelo heredado si hay varios modelos en la carpeta de modelos" -#: lib/cli/args.py:804 +#: lib/cli/args.py:809 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -582,7 +595,7 @@ msgstr "" "de baja calidad. Si se encuentra un archivo de alineaciones, esta opción " "será ignorada." -#: lib/cli/args.py:815 +#: lib/cli/args.py:820 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -590,21 +603,28 @@ msgstr "" "Cuando se usa con --frame-ranges, la salida incluye los fotogramas no " "procesados en vez de descartarlos." -#: lib/cli/args.py:823 +#: lib/cli/args.py:828 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Intercambiar el modelo. En vez de convertir de A a B, convierte de B a A" -#: lib/cli/args.py:829 +#: lib/cli/args.py:834 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Desactiva el multiproceso. Es más lento, pero usa menos recursos." -#: lib/cli/args.py:864 lib/cli/args.py:875 lib/cli/args.py:884 -#: lib/cli/args.py:895 +#: lib/cli/args.py:850 +msgid "" +"Train a model on extracted original (A) and swap (B) faces.\n" +"Training models can take a long time. Anything from 24hrs to over a week\n" +"Model plugins can be configured in the 'Settings' Menu" +msgstr "" + +#: lib/cli/args.py:869 lib/cli/args.py:880 lib/cli/args.py:889 +#: lib/cli/args.py:900 msgid "faces" msgstr "caras" -#: lib/cli/args.py:865 +#: lib/cli/args.py:870 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -614,7 +634,7 @@ msgstr "" "para la cara A. Esta es la cara original, es decir, la cara que se quiere " "eliminar y sustituir por la cara B." -#: lib/cli/args.py:876 +#: lib/cli/args.py:881 msgid "" "DEPRECATED - This option will be removed in a future update. Path to " "alignments file for training set A. Defaults to /alignments.json if " @@ -624,7 +644,7 @@ msgstr "" "archivo de alineaciones para el conjunto de entrenamiento A. Por defecto es " "/alignments.json si no se proporciona." -#: lib/cli/args.py:885 +#: lib/cli/args.py:890 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -634,7 +654,7 @@ msgstr "" "para la cara B. Esta es la cara de intercambio, es decir, la cara que se " "quiere colocar en la cabeza de la persona A." -#: lib/cli/args.py:896 +#: lib/cli/args.py:901 msgid "" "DEPRECATED - This option will be removed in a future update. Path to " "alignments file for training set B. Defaults to /alignments.json if " @@ -644,11 +664,11 @@ msgstr "" "archivo de alineaciones para el conjunto de entrenamiento B. Por defecto es " "/alignments.json si no se proporciona." -#: lib/cli/args.py:904 lib/cli/args.py:916 +#: lib/cli/args.py:909 lib/cli/args.py:921 msgid "model" msgstr "modelo" -#: lib/cli/args.py:905 +#: lib/cli/args.py:910 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -662,7 +682,7 @@ msgstr "" "carpeta que no exista (que se creará). Si continúa entrenando un modelo " "existente, especifique la ubicación del modelo existente." -#: lib/cli/args.py:917 +#: lib/cli/args.py:922 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -707,12 +727,12 @@ msgstr "" "recursos (se necesita una GPU con una buena cantidad de VRAM). Bueno para " "los detalles, pero más susceptible a las diferencias de color." -#: lib/cli/args.py:944 lib/cli/args.py:956 lib/cli/args.py:967 -#: lib/cli/args.py:1053 +#: lib/cli/args.py:949 lib/cli/args.py:961 lib/cli/args.py:972 +#: lib/cli/args.py:1058 msgid "training" msgstr "entrenamiento" -#: lib/cli/args.py:945 +#: lib/cli/args.py:950 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -725,7 +745,7 @@ msgstr "" "momento es el doble del número que se establece aquí. Los lotes más grandes " "requieren más RAM de la GPU." -#: lib/cli/args.py:957 +#: lib/cli/args.py:962 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -740,22 +760,22 @@ msgstr "" "automáticamente en un número determinado de iteraciones, puede establecer " "ese valor aquí." -#: lib/cli/args.py:968 +#: lib/cli/args.py:973 msgid "" "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" "Utilice la estrategia de distribución en espejo de Tensorflow para entrenar " "en múltiples GPUs." -#: lib/cli/args.py:978 lib/cli/args.py:988 +#: lib/cli/args.py:983 lib/cli/args.py:993 msgid "Saving" msgstr "Guardar" -#: lib/cli/args.py:979 +#: lib/cli/args.py:984 msgid "Sets the number of iterations between each model save." msgstr "Establece el número de iteraciones entre cada guardado del modelo." -#: lib/cli/args.py:989 +#: lib/cli/args.py:994 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -763,11 +783,11 @@ msgstr "" "Establece el número de iteraciones antes de guardar una copia de seguridad " "del modelo en su estado actual. Establece 0 para que esté desactivado." -#: lib/cli/args.py:996 lib/cli/args.py:1007 lib/cli/args.py:1018 +#: lib/cli/args.py:1001 lib/cli/args.py:1012 lib/cli/args.py:1023 msgid "timelapse" msgstr "intervalo" -#: lib/cli/args.py:997 +#: lib/cli/args.py:1002 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -781,7 +801,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-B." -#: lib/cli/args.py:1008 +#: lib/cli/args.py:1013 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -795,7 +815,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-A." -#: lib/cli/args.py:1019 +#: lib/cli/args.py:1024 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -807,24 +827,24 @@ msgstr "" "Si se suministran las carpetas de entrada pero no la carpeta de salida, se " "guardará por defecto en la carpeta del modelo /timelapse/" -#: lib/cli/args.py:1031 lib/cli/args.py:1038 lib/cli/args.py:1045 +#: lib/cli/args.py:1036 lib/cli/args.py:1043 lib/cli/args.py:1050 msgid "preview" msgstr "previsualización" -#: lib/cli/args.py:1032 +#: lib/cli/args.py:1037 msgid "" "Percentage amount to scale the preview by. 100%% is the model output size." msgstr "" "Cantidad porcentual para escalar la vista previa. 100%% es el tamaño de " "salida del modelo." -#: lib/cli/args.py:1039 +#: lib/cli/args.py:1044 msgid "Show training preview output. in a separate window." msgstr "" "Mostrar la salida de la vista previa del entrenamiento. en una ventana " "separada." -#: lib/cli/args.py:1046 +#: lib/cli/args.py:1051 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -832,7 +852,7 @@ msgstr "" "Escribe el resultado del entrenamiento en un archivo. La imagen se " "almacenará en la raíz de su carpeta FaceSwap." -#: lib/cli/args.py:1054 +#: lib/cli/args.py:1059 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -840,12 +860,12 @@ msgstr "" "Desactiva el registro de TensorBoard. NB: Desactivar los registros significa " "que no podrá utilizar el gráfico o el análisis de esta sesión en la GUI." -#: lib/cli/args.py:1061 lib/cli/args.py:1070 lib/cli/args.py:1079 -#: lib/cli/args.py:1088 +#: lib/cli/args.py:1066 lib/cli/args.py:1075 lib/cli/args.py:1084 +#: lib/cli/args.py:1093 msgid "augmentation" msgstr "aumento" -#: lib/cli/args.py:1062 +#: lib/cli/args.py:1067 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -855,7 +875,7 @@ msgstr "" "conjunto de caras opuestas en lugar de deformar la cara al azar. Esta es la " "forma 'dfaker' de hacer la deformación." -#: lib/cli/args.py:1071 +#: lib/cli/args.py:1076 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -866,7 +886,7 @@ msgstr "" "general, esto debería dejarse sin efecto, excepto durante el 'entrenamiento " "de ajuste'." -#: lib/cli/args.py:1080 +#: lib/cli/args.py:1085 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -876,7 +896,7 @@ msgstr "" "diferencias de color entre los conjuntos A y B, con un mayor coste de tiempo " "de entrenamiento. Activa esta opción para desactivar el aumento de color." -#: lib/cli/args.py:1089 +#: lib/cli/args.py:1094 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -889,6 +909,6 @@ msgstr "" "esta opción desde el principio, es probable que arruine el modelo y se " "obtengan resultados terribles." -#: lib/cli/args.py:1114 +#: lib/cli/args.py:1119 msgid "Output to Shell console instead of GUI console" msgstr "Salida a la consola Shell en lugar de la consola GUI" From d5c30e7ea92aa871f5e6a724f9c4a3c4d532c2b7 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 19 Feb 2021 11:36:03 +0000 Subject: [PATCH 375/981] bugfix - plugin.model.trainer._base - Fix mixed extract face type check --- plugins/train/trainer/_base.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 23543db7ef..a8892f27e3 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -1275,11 +1275,13 @@ def _get_alignments_path(self, side): def _validity_check(self): """ Check the validity of the finally loaded data. - Ensure that each side has consistent alignments versions. + Ensure that each side contains alignments data that was extracted with the same centering. Ensure that each side has a full compliment of metadata. """ invalid = [side.upper() - for side, version in self._alignments_version.items() if len(version) > 1] + for side, vers in self._alignments_version.items().items() + if len(vers) > 1 and any(v < 2 for v in vers) and any(v > 1 for v in vers)] + if invalid: raise FaceswapError("Mixing legacy and full head extracted facesets is not supported. " "The following side(s) contain a mix of extracted face " From 51f9eef0f8715a0575c933f5c0b1e935399a000b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 19 Feb 2021 11:42:52 +0000 Subject: [PATCH 376/981] typofix --- plugins/train/trainer/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index a8892f27e3..47e9cc5e2f 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -1279,7 +1279,7 @@ def _validity_check(self): Ensure that each side has a full compliment of metadata. """ invalid = [side.upper() - for side, vers in self._alignments_version.items().items() + for side, vers in self._alignments_version.items() if len(vers) > 1 and any(v < 2 for v in vers) and any(v > 1 for v in vers)] if invalid: From 6ce09dbac6a3c971345307758576fbae84f7d974 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 20 Feb 2021 02:14:00 +0000 Subject: [PATCH 377/981] Help text - Add "predicted" help tesk to convert mask option --- lib/cli/args.py | 4 +- locales/lib.cli.args.pot | 111 ++++++++++++++++++++------------------- 2 files changed, 59 insertions(+), 56 deletions(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index 91140bf267..8414001628 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -688,7 +688,9 @@ def get_optional_arguments(): "\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."))) + "performance." + "\nL|predicted: If the 'Learn Mask' option was enabled during training, this " + "will use the mask that was created by the trained model."))) argument_list.append(dict( opts=("-w", "--writer"), action=Radio, diff --git a/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index 3d367190c8..76c4c94eaf 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-02-18 23:45-0000\n" +"POT-Creation-Date: 2021-02-20 02:08-0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -63,7 +63,7 @@ msgstr "" #: lib/cli/args.py:365 lib/cli/args.py:381 lib/cli/args.py:393 #: lib/cli/args.py:425 lib/cli/args.py:443 lib/cli/args.py:455 -#: lib/cli/args.py:646 lib/cli/args.py:671 lib/cli/args.py:698 +#: lib/cli/args.py:646 lib/cli/args.py:671 lib/cli/args.py:700 msgid "Plugins" msgstr "" @@ -112,8 +112,8 @@ msgid "If a face isn't found, rotate the images to try to find a face. Can find msgstr "" #: lib/cli/args.py:468 lib/cli/args.py:478 lib/cli/args.py:491 -#: lib/cli/args.py:505 lib/cli/args.py:735 lib/cli/args.py:749 -#: lib/cli/args.py:762 lib/cli/args.py:776 +#: lib/cli/args.py:505 lib/cli/args.py:737 lib/cli/args.py:751 +#: lib/cli/args.py:764 lib/cli/args.py:778 msgid "Face Processing" msgstr "" @@ -121,15 +121,15 @@ msgstr "" msgid "Filters out faces detected below this size. Length, in pixels across the diagonal of the bounding box. Set to 0 for off" msgstr "" -#: lib/cli/args.py:479 lib/cli/args.py:750 +#: lib/cli/args.py:479 lib/cli/args.py:752 msgid "Optionally filter out people who you do not wish to process by passing in an image of that person. Should be a front portrait with a single person in the image. Multiple images can be added space separated. NB: Using face filter will significantly decrease extraction speed and its accuracy cannot be guaranteed." msgstr "" -#: lib/cli/args.py:492 lib/cli/args.py:763 +#: lib/cli/args.py:492 lib/cli/args.py:765 msgid "Optionally select people you wish to process by passing in an image of that person. Should be a front portrait with a single person in the image. Multiple images can be added space separated. NB: Using face filter will significantly decrease extraction speed and its accuracy cannot be guaranteed." msgstr "" -#: lib/cli/args.py:506 lib/cli/args.py:777 +#: lib/cli/args.py:506 lib/cli/args.py:779 msgid "For use with the optional nfilter/filter files. Threshold for positive face recognition. Lower values are stricter. NB: Using face filter will significantly decrease extraction speed and its accuracy cannot be guaranteed." msgstr "" @@ -155,8 +155,8 @@ msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" #: lib/cli/args.py:560 lib/cli/args.py:569 lib/cli/args.py:577 -#: lib/cli/args.py:584 lib/cli/args.py:789 lib/cli/args.py:800 -#: lib/cli/args.py:808 lib/cli/args.py:827 lib/cli/args.py:833 +#: lib/cli/args.py:584 lib/cli/args.py:791 lib/cli/args.py:802 +#: lib/cli/args.py:810 lib/cli/args.py:829 lib/cli/args.py:835 msgid "settings" msgstr "" @@ -209,10 +209,11 @@ msgid "" "L|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.\n" "L|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.\n" "L|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.\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." +"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.\n" +"L|predicted: If the 'Learn Mask' option was enabled during training, this will use the mask that was created by the trained model." msgstr "" -#: lib/cli/args.py:699 +#: lib/cli/args.py:701 msgid "" "R|The plugin to use to output the converted images. The writers are configurable in '/config/convert.ini' or 'Settings > Configure Convert Plugins:'\n" "L|ffmpeg: [video] Writes out the convert straight to video. When the input is a series of images then the '-ref' (--reference-video) parameter must be set.\n" @@ -221,83 +222,83 @@ msgid "" "L|pillow: [images] Slower than opencv, but has more options and supports more formats." msgstr "" -#: lib/cli/args.py:718 lib/cli/args.py:725 lib/cli/args.py:819 +#: lib/cli/args.py:720 lib/cli/args.py:727 lib/cli/args.py:821 msgid "Frame Processing" msgstr "" -#: lib/cli/args.py:719 +#: lib/cli/args.py:721 msgid "Scale the final output frames by this amount. 100%% will output the frames at source dimensions. 50%% at half size 200%% at double size" msgstr "" -#: lib/cli/args.py:726 +#: lib/cli/args.py:728 msgid "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use --frame-ranges 10-50 90-100. Frames falling outside of the selected range will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are converting from images, then the filenames must end with the frame-number!" msgstr "" -#: lib/cli/args.py:736 +#: lib/cli/args.py:738 msgid "If you have not cleansed your alignments file, then you can filter out faces by defining a folder here that contains the faces extracted from your input files/video. If this folder is defined, then only faces that exist within your alignments file and also exist within the specified folder will be converted. Leaving this blank will convert all faces that exist within the alignments file." msgstr "" -#: lib/cli/args.py:790 +#: lib/cli/args.py:792 msgid "The maximum number of parallel processes for performing conversion. Converting images is system RAM heavy so it is possible to run out of memory if you have a lot of processes and not enough RAM to accommodate them all. Setting this to 0 will use the maximum available. No matter what you set this to, it will never attempt to use more processes than are available on your system. If singleprocess is enabled this setting will be ignored." msgstr "" -#: lib/cli/args.py:801 +#: lib/cli/args.py:803 msgid "[LEGACY] This only needs to be selected if a legacy model is being loaded or if there are multiple models in the model folder" msgstr "" -#: lib/cli/args.py:809 +#: lib/cli/args.py:811 msgid "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean alignments file for your destination video. However, if you wish you can generate the alignments on-the-fly by enabling this option. This will use an inferior extraction pipeline and will lead to substandard results. If an alignments file is found, this option will be ignored." msgstr "" -#: lib/cli/args.py:820 +#: lib/cli/args.py:822 msgid "When used with --frame-ranges outputs the unchanged frames that are not processed instead of discarding them." msgstr "" -#: lib/cli/args.py:828 +#: lib/cli/args.py:830 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" -#: lib/cli/args.py:834 +#: lib/cli/args.py:836 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "" -#: lib/cli/args.py:850 +#: lib/cli/args.py:852 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" "Model plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:869 lib/cli/args.py:880 lib/cli/args.py:889 -#: lib/cli/args.py:900 +#: lib/cli/args.py:871 lib/cli/args.py:882 lib/cli/args.py:891 +#: lib/cli/args.py:902 msgid "faces" msgstr "" -#: lib/cli/args.py:870 +#: lib/cli/args.py:872 msgid "Input directory. A directory containing training images for face A. This is the original face, i.e. the face that you want to remove and replace with face B." msgstr "" -#: lib/cli/args.py:881 +#: lib/cli/args.py:883 msgid "DEPRECATED - This option will be removed in a future update. Path to alignments file for training set A. Defaults to /alignments.json if not provided." msgstr "" -#: lib/cli/args.py:890 +#: lib/cli/args.py:892 msgid "Input directory. A directory containing training images for face B. This is the swap face, i.e. the face that you want to place onto the head of person A." msgstr "" -#: lib/cli/args.py:901 +#: lib/cli/args.py:903 msgid "DEPRECATED - This option will be removed in a future update. Path to alignments file for training set B. Defaults to /alignments.json if not provided." msgstr "" -#: lib/cli/args.py:909 lib/cli/args.py:921 +#: lib/cli/args.py:911 lib/cli/args.py:923 msgid "model" msgstr "" -#: lib/cli/args.py:910 +#: lib/cli/args.py:912 msgid "Model directory. This is where the training data will be stored. You should always specify a new folder for new models. If starting a new model, select either an empty folder, or a folder which does not exist (which will be created). If continuing to train an existing model, specify the location of the existing model." msgstr "" -#: lib/cli/args.py:922 +#: lib/cli/args.py:924 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings menu or the config folder.\n" "L|original: The original model created by /u/deepfakes.\n" @@ -312,93 +313,93 @@ msgid "" "L|villain: 128px in/out model from villainguy. Very resource hungry (You will require a GPU with a fair amount of VRAM). Good for details, but more susceptible to color differences." msgstr "" -#: lib/cli/args.py:949 lib/cli/args.py:961 lib/cli/args.py:972 -#: lib/cli/args.py:1058 +#: lib/cli/args.py:951 lib/cli/args.py:963 lib/cli/args.py:974 +#: lib/cli/args.py:1060 msgid "training" msgstr "" -#: lib/cli/args.py:950 +#: lib/cli/args.py:952 msgid "Batch size. This is the number of images processed through the model for each side per iteration. NB: As the model is fed 2 sides at a time, the actual number of images within the model at any one time is double the number that you set here. Larger batches require more GPU RAM." msgstr "" -#: lib/cli/args.py:962 +#: lib/cli/args.py:964 msgid "Length of training in iterations. This is only really used for automation. There is no 'correct' number of iterations a model should be trained for. You should stop training when you are happy with the previews. However, if you want the model to stop automatically at a set number of iterations, you can set that value here." msgstr "" -#: lib/cli/args.py:973 +#: lib/cli/args.py:975 msgid "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" -#: lib/cli/args.py:983 lib/cli/args.py:993 +#: lib/cli/args.py:985 lib/cli/args.py:995 msgid "Saving" msgstr "" -#: lib/cli/args.py:984 +#: lib/cli/args.py:986 msgid "Sets the number of iterations between each model save." msgstr "" -#: lib/cli/args.py:994 +#: lib/cli/args.py:996 msgid "Sets the number of iterations before saving a backup snapshot of the model in it's current state. Set to 0 for off." msgstr "" -#: lib/cli/args.py:1001 lib/cli/args.py:1012 lib/cli/args.py:1023 +#: lib/cli/args.py:1003 lib/cli/args.py:1014 lib/cli/args.py:1025 msgid "timelapse" msgstr "" -#: lib/cli/args.py:1002 +#: lib/cli/args.py:1004 msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. This should be the input folder of 'A' faces that you would like to use for creating the timelapse. You must also supply a --timelapse-output and a --timelapse-input-B parameter." msgstr "" -#: lib/cli/args.py:1013 +#: lib/cli/args.py:1015 msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. This should be the input folder of 'B' faces that you would like to use for creating the timelapse. You must also supply a --timelapse-output and a --timelapse-input-A parameter." msgstr "" -#: lib/cli/args.py:1024 +#: lib/cli/args.py:1026 msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. If the input folders are supplied but no output folder, it will default to your model folder /timelapse/" msgstr "" -#: lib/cli/args.py:1036 lib/cli/args.py:1043 lib/cli/args.py:1050 +#: lib/cli/args.py:1038 lib/cli/args.py:1045 lib/cli/args.py:1052 msgid "preview" msgstr "" -#: lib/cli/args.py:1037 +#: lib/cli/args.py:1039 msgid "Percentage amount to scale the preview by. 100%% is the model output size." msgstr "" -#: lib/cli/args.py:1044 +#: lib/cli/args.py:1046 msgid "Show training preview output. in a separate window." msgstr "" -#: lib/cli/args.py:1051 +#: lib/cli/args.py:1053 msgid "Writes the training result to a file. The image will be stored in the root of your FaceSwap folder." msgstr "" -#: lib/cli/args.py:1059 +#: lib/cli/args.py:1061 msgid "Disables TensorBoard logging. NB: Disabling logs means that you will not be able to use the graph or analysis for this session in the GUI." msgstr "" -#: lib/cli/args.py:1066 lib/cli/args.py:1075 lib/cli/args.py:1084 -#: lib/cli/args.py:1093 +#: lib/cli/args.py:1068 lib/cli/args.py:1077 lib/cli/args.py:1086 +#: lib/cli/args.py:1095 msgid "augmentation" msgstr "" -#: lib/cli/args.py:1067 +#: lib/cli/args.py:1069 msgid "Warps training faces to closely matched Landmarks from the opposite face-set rather than randomly warping the face. This is the 'dfaker' way of doing warping." msgstr "" -#: lib/cli/args.py:1076 +#: lib/cli/args.py:1078 msgid "To effectively learn, a random set of images are flipped horizontally. Sometimes it is desirable for this not to occur. Generally this should be left off except for during 'fit training'." msgstr "" -#: lib/cli/args.py:1085 +#: lib/cli/args.py:1087 msgid "Color augmentation helps make the model less susceptible to color differences between the A and B sets, at an increased training time cost. Enable this option to disable color augmentation." msgstr "" -#: lib/cli/args.py:1094 +#: lib/cli/args.py:1096 msgid "Warping is integral to training the Neural Network. This option should only be enabled towards the very end of training to try to bring out more detail. Think of it as 'fine-tuning'. Enabling this option from the beginning is likely to kill a model and lead to terrible results." msgstr "" -#: lib/cli/args.py:1119 +#: lib/cli/args.py:1121 msgid "Output to Shell console instead of GUI console" msgstr "" From ef22c576f66dafdc355876eacf3f32096489dbd9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 20 Feb 2021 14:55:45 +0000 Subject: [PATCH 378/981] Bugfix - Extract - Fix skip existing faces --- scripts/fsmedia.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index b3d2e0777d..dbae1c4bb5 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -138,12 +138,12 @@ def _load(self): logger.warning("Skip Existing/Skip Faces selected, but no alignments file found!") return data - data = self._serializer.load(self.file) + data = super()._load() if skip_faces: # Remove items from alignments that have no faces so they will # be re-detected - del_keys = [key for key, val in data.items() if not val] + del_keys = [key for key, val in data.items() if not val["faces"]] logger.debug("Frames with no faces selected for redetection: %s", len(del_keys)) for key in del_keys: if key in data: From 78039d43ba21a8b4aa2c1147760daf2ce1dfb75d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 21 Feb 2021 11:42:15 +0000 Subject: [PATCH 379/981] Bugfix - lib.align.detected_face.Mask - Fix broadcast error when sub-cropping --- lib/align/detected_face.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index 5030dd4a10..2849406cb6 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -614,7 +614,8 @@ def set_sub_crop(self, offset): roi = np.array([center - crop_size // 2, center + crop_size // 2]).ravel() self._sub_crop["size"] = crop_size - self._sub_crop["slice_in"] = [slice(max(roi[1], 0), roi[3]), slice(max(roi[0], 0), roi[2])] + self._sub_crop["slice_in"] = [slice(max(roi[1], 0), max(roi[3], 0)), + slice(max(roi[0], 0), max(roi[2], 0))] self._sub_crop["slice_out"] = [slice(max(roi[1] * -1, 0), crop_size - max(0, roi[3] - self.stored_size)), slice(max(roi[0] * -1, 0), From 55de06e74efb35c0ec48b5400b49379d7c999f5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Claudio=20S=C3=A1nchez?= Date: Sun, 21 Feb 2021 16:21:52 +0000 Subject: [PATCH 380/981] Cli Arguments - Spanish Translation (#1127) * New translation files added * New translation files added * New translation files added * New translation templates added * Minor translations corrections --- locales/es/LC_MESSAGES/faceswap.mo | Bin 0 -> 885 bytes locales/es/LC_MESSAGES/faceswap.po | 34 +++ locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 37986 -> 39026 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 19 +- .../es/LC_MESSAGES/tools.alignments.cli.mo | Bin 0 -> 7214 bytes .../es/LC_MESSAGES/tools.alignments.cli.po | 195 +++++++++++++++++ locales/es/LC_MESSAGES/tools.effmpeg.cli.mo | Bin 0 -> 6481 bytes locales/es/LC_MESSAGES/tools.effmpeg.cli.po | 196 +++++++++++++++++ locales/es/LC_MESSAGES/tools.manual.cli.mo | Bin 0 -> 2139 bytes locales/es/LC_MESSAGES/tools.manual.cli.po | 77 +++++++ locales/es/LC_MESSAGES/tools.mask.cli.mo | Bin 0 -> 7532 bytes locales/es/LC_MESSAGES/tools.mask.cli.po | 190 ++++++++++++++++ locales/es/LC_MESSAGES/tools.mo | Bin 0 -> 717 bytes locales/es/LC_MESSAGES/tools.po | 27 +++ locales/es/LC_MESSAGES/tools.preview.cli.mo | Bin 0 -> 1596 bytes locales/es/LC_MESSAGES/tools.preview.cli.po | 64 ++++++ locales/es/LC_MESSAGES/tools.restore.cli.mo | Bin 0 -> 889 bytes locales/es/LC_MESSAGES/tools.restore.cli.po | 36 ++++ locales/es/LC_MESSAGES/tools.sort.cli.mo | Bin 0 -> 8497 bytes locales/es/LC_MESSAGES/tools.sort.cli.po | 202 ++++++++++++++++++ 20 files changed, 1036 insertions(+), 4 deletions(-) create mode 100644 locales/es/LC_MESSAGES/faceswap.mo create mode 100644 locales/es/LC_MESSAGES/faceswap.po create mode 100644 locales/es/LC_MESSAGES/tools.alignments.cli.mo create mode 100644 locales/es/LC_MESSAGES/tools.alignments.cli.po create mode 100644 locales/es/LC_MESSAGES/tools.effmpeg.cli.mo create mode 100644 locales/es/LC_MESSAGES/tools.effmpeg.cli.po create mode 100644 locales/es/LC_MESSAGES/tools.manual.cli.mo create mode 100644 locales/es/LC_MESSAGES/tools.manual.cli.po create mode 100644 locales/es/LC_MESSAGES/tools.mask.cli.mo create mode 100644 locales/es/LC_MESSAGES/tools.mask.cli.po create mode 100644 locales/es/LC_MESSAGES/tools.mo create mode 100644 locales/es/LC_MESSAGES/tools.po create mode 100644 locales/es/LC_MESSAGES/tools.preview.cli.mo create mode 100644 locales/es/LC_MESSAGES/tools.preview.cli.po create mode 100644 locales/es/LC_MESSAGES/tools.restore.cli.mo create mode 100644 locales/es/LC_MESSAGES/tools.restore.cli.po create mode 100644 locales/es/LC_MESSAGES/tools.sort.cli.mo create mode 100644 locales/es/LC_MESSAGES/tools.sort.cli.po diff --git a/locales/es/LC_MESSAGES/faceswap.mo b/locales/es/LC_MESSAGES/faceswap.mo new file mode 100644 index 0000000000000000000000000000000000000000..724deab30e428cc2f5eb6b65a40303971284ec8e GIT binary patch literal 885 zcmY*X&2AGh5MH1NX-|kVhv9%|DGo_0)Ur_y{Yk5pS~Y?caYNJH*~D~Xug2b_;R)c1 z!~^sJc#52P1r9s~N5CD$m4lKh==VZZKKrl21YY67rPry$I3Y26w$Eu<{8eDqD%-;qa6l|yeabd$2 z9b;{>hT>2t0s|Z|Rui3J31^NNQsapOe>7s$MygM3P9+r5QYh``c2(ZNE}+R_FB-D_ zOqo|{u09YuZB5c-2z$#_XtbNe!&CK+W;#UG0eVWH+wOK$yQ?~Hp|hQAZEv<^#K&mH zHI7M*2&ai0{{jnZavPtdCxbiwPOBZu-FY%zjTV*AdAjtkt5RC-K{i2l9OS#%xBv38JZEqyEYXKpJS=O*QXN{Jx&%zMa$UdMgJ#-aH}I?vosHh@F%uy7Y4AS25rSqY(Se>W zug21r2m$2oXUm^K3pt4{w5)@CwXtAonpw!Kkxh^xhu`#EV~&j|, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: faceswap.spanish\n" +"POT-Creation-Date: 2021-02-18 23:48-0000\n" +"PO-Revision-Date: 2021-02-19 17:37+0000\n" +"Language-Team: tokafondo\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 2.3\n" +"Last-Translator: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: es_ES\n" + +#: faceswap.py:43 +msgid "Extract the faces from pictures or a video" +msgstr "Extraer las caras de las fotos o de un vídeo" + +#: faceswap.py:44 +msgid "Train a model for the two faces A and B" +msgstr "Entrenar un modelo para las dos caras A y B" + +#: faceswap.py:47 +msgid "Convert source pictures or video to a new one with the face swapped" +msgstr "Convertir las imágenes o el vídeo de origen en uno nuevo con la cara cambiada" + +#: faceswap.py:48 +msgid "Launch the Faceswap Graphical User Interface" +msgstr "Inicie la interfaz gráfica de usuario (GUI) de Faceswap" diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo index 7f575184092863329f768f4727cbf820025dc118..bec24845247ce5b77a24b0d5cfcc3a2c4900ddf6 100644 GIT binary patch delta 2816 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, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: faceswap.spanish\n" +"POT-Creation-Date: 2021-02-18 23:43-0000\n" +"PO-Revision-Date: 2021-02-19 17:38+0000\n" +"Language-Team: tokafondo\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 2.3\n" +"Last-Translator: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: es_ES\n" + +#: tools/alignments/cli.py:14 +msgid "" +"This command lets you perform various tasks pertaining to an alignments file." +msgstr "" +"Este comando le permite realizar varias tareas relacionadas con un archivo " +"de alineación." + +#: tools/alignments/cli.py:23 +msgid "" +"Alignments tool\n" +"This tool allows you to perform numerous actions on or using an alignments " +"file against its corresponding faceset/frame source." +msgstr "" +"Herramienta de alineación\n" +"Esta herramienta le permite realizar numerosas acciones sobre un conjunto de " +"caras o una fuente de fotogramas, usando opcionalmente su correspondiente " +"archivo de alineación." + +#: tools/alignments/cli.py:27 +msgid " Must Pass in a frames folder/source video file (-fr)." +msgstr "" +" Debe indicar una carpeta de fotogramas o archivo de vídeo de origen (-fr)." + +#: tools/alignments/cli.py:28 +msgid " Must Pass in a faces folder (-fc)." +msgstr " Debe indicar una carpeta de caras (-fc)." + +#: tools/alignments/cli.py:29 +msgid "" +" Must Pass in either a frames folder/source video file OR afaces folder (-fr " +"or -fc)." +msgstr "" +" Debe indicar una carpeta de fotogramas o archivo de vídeo de origen, o una " +"carpeta de caras (-fr o -fc)." + +#: tools/alignments/cli.py:31 +msgid "" +" Must Pass in a frames folder/source video file AND a faces folder (-fr and -" +"fc)." +msgstr "" +" Debe indicar una carpeta de fotogramas o archivo de vídeo de origen, y una " +"carpeta de caras (-fr y -fc)." + +#: tools/alignments/cli.py:33 +msgid " Use the output option (-o) to process results." +msgstr " Usar la opción de salida (-o) para procesar los resultados." + +#: tools/alignments/cli.py:42 +msgid "" +"R|Choose which action you want to perform. NB: All actions require an " +"alignments file (-a) to be passed in.\n" +"L|'draw': Draw landmarks on frames in the selected folder/video. A subfolder " +"will be created within the frames folder to hold the output.{0}\n" +"L|'extract': Re-extract faces from the source frames/video based on " +"alignment data. This is a lot quicker than re-detecting faces. Can pass in " +"the '-een' (--extract-every-n) parameter to only extract every nth frame." +"{1}\n" +"L|'missing-alignments': Identify frames that do not exist in the alignments " +"file.{2}{0}\n" +"L|'missing-frames': Identify frames in the alignments file that do not " +"appear within the frames folder/video.{2}{0}\n" +"L|'multi-faces': Identify where multiple faces exist within the alignments " +"file.{2}{4}\n" +"L|'no-faces': Identify frames that exist within the alignment file but no " +"faces were detected.{2}{0}\n" +"L|'remove-faces': Remove deleted faces from an alignments file. The original " +"alignments file will be backed up.{3}\n" +"L|'rename' - Rename faces to correspond with their parent frame and position " +"index in the alignments file (i.e. how they are named after running extract)." +"{3}\n" +"L|'sort': Re-index the alignments from left to right. For alignments with " +"multiple faces this will ensure that the left-most face is at index 0.\n" +"L|'spatial': Perform spatial and temporal filtering to smooth alignments " +"(EXPERIMENTAL!)" +msgstr "" +"R|Elija la acción que desea realizar. NB: Todas las acciones requieren que " +"se indique un archivo de alineación (-a).\n" +"L|'draw': Dibuja puntos de referencia en los fotogramas de la carpeta o " +"vídeo seleccionado. Se creará una subcarpeta dentro de la carpeta de " +"fotogramas para guardar el resultado.{0}\n" +"L|'extract': Reextrae las caras de los fotogramas o vídeos de origen " +"basándose en los datos de alineación. Esto es mucho más rápido que volver a " +"detectar las caras. Se puede pasar el parámetro '-een' (--extract-every-n) " +"para extraer sólo cada enésimo fotograma.{1}\n" +"L|'missing-alignments': Identifica los fotogramas que no existen en el " +"archivo de alineaciones.{2}{0}\n" +"L|'missing-frames': Identifica los fotogramas del archivo de alineaciones " +"que no aparecen en la carpeta de fotogramas o vídeo.{2}{0}\n" +"L|'multi-faces': Identifica los casos en los que existen múltiples caras " +"dentro de un mismo fotograma, en el archivo de alineaciones.{2}{4}\n" +"L|'no-faces': Identifica los fotogramas que existen en el archivo de " +"alineación pero no se detectan caras.{2}{0}\n" +"L|'remove-faces': Elimina las caras previamente eliminadas de un archivo de " +"alineaciones. Se hará una copia de seguridad del archivo de alineaciones " +"original.{3}\n" +"L|'rename': Cambia el nombre de las caras para que se correspondan con su " +"marco padre y su índice de posición en el archivo de alineaciones (es decir, " +"cómo se nombran después de ejecutar la extracción).{3}\n" +"L|'sort': Reordena las alineaciones de izquierda a derecha. En el caso de " +"alineaciones con múltiples caras, esto asegurará que la cara más a la " +"izquierda esté en el índice 0.\n" +"L|'spatial': Realiza un filtrado espacial y temporal para suavizar las " +"alineaciones (¡EXPERIMENTAL!)" + +#: tools/alignments/cli.py:72 tools/alignments/cli.py:81 +#: tools/alignments/cli.py:88 +msgid "data" +msgstr "datos" + +#: tools/alignments/cli.py:75 +msgid "" +"Full path to the alignments file to be processed. If merging alignments, " +"then multiple files can be selected, space separated" +msgstr "" +"Ruta completa del archivo de alineaciones a procesar. Si se combinan " +"alineaciones, se pueden seleccionar varios archivos, separados por espacios" + +#: tools/alignments/cli.py:82 +msgid "Directory containing extracted faces." +msgstr "Directorio que contiene las caras extraídas." + +#: tools/alignments/cli.py:89 +msgid "Directory containing source frames that faces were extracted from." +msgstr "" +"Directorio que contiene los fotogramas de origen de los que se extrajeron " +"las caras." + +#: tools/alignments/cli.py:95 +msgid "processing" +msgstr "proceso" + +#: tools/alignments/cli.py:97 +msgid "" +"R|How to output discovered items ('faces' and 'frames' only):\n" +"L|'console': Print the list of frames to the screen. (DEFAULT)\n" +"L|'file': Output the list of frames to a text file (stored within the source " +"directory).\n" +"L|'move': Move the discovered items to a sub-folder within the source " +"directory." +msgstr "" +"R|Como procesar los elementos descubiertos (sólo 'caras' y 'cuadros'):\n" +"L|'console': Muestra la lista de fotogramas en la pantalla. (POR DEFECTO)\n" +"L|'file': Redirige la lista de fotogramas a un archivo de texto (almacenado " +"en el directorio de origen).\n" +"L|'move': Mueve los elementos descubiertos a una subcarpeta dentro del " +"directorio de origen." + +#: tools/alignments/cli.py:111 tools/alignments/cli.py:121 +#: tools/alignments/cli.py:127 +msgid "extract" +msgstr "extracción" + +#: tools/alignments/cli.py:112 +msgid "" +"[Extract only] Extract every 'nth' frame. This option will skip frames when " +"extracting faces. For example a value of 1 will extract faces from every " +"frame, a value of 10 will extract faces from every 10th frame." +msgstr "" +"[Sólo extracción] Extraer cada 'enésimo' fotograma. Esta opción omitirá los " +"fotogramas al extraer las caras. Por ejemplo, un valor de 1 extraerá las " +"caras de cada fotograma, un valor de 10 extraerá las caras de cada 10 " +"fotogramas." + +#: tools/alignments/cli.py:123 +msgid "[Extract only] The output size of extracted faces." +msgstr "[Sólo extracción] El tamaño de salida de las caras extraídas." + +#: tools/alignments/cli.py:129 +msgid "" +"[Extract only] Only extract faces that have not been upscaled to the " +"required size (`-sz`, `--size). Useful for excluding low-res images from a " +"training set." +msgstr "" +"[Sólo extracción] Sólo extraer las caras que son de origen iguales como " +"mínimo al tamaño de salida (`-sz`, `--size). Es útil para excluir las " +"imágenes de baja resolución de un conjunto de entrenamiento." diff --git a/locales/es/LC_MESSAGES/tools.effmpeg.cli.mo b/locales/es/LC_MESSAGES/tools.effmpeg.cli.mo new file mode 100644 index 0000000000000000000000000000000000000000..3931988e07596919107eddd9e01bb80e7f3035ae GIT binary patch literal 6481 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 W%24CHim4u^nxMQgmpl1MQ2hf(Zg*M$ literal 0 HcmV?d00001 diff --git a/locales/es/LC_MESSAGES/tools.effmpeg.cli.po b/locales/es/LC_MESSAGES/tools.effmpeg.cli.po new file mode 100644 index 0000000000..ed5953581a --- /dev/null +++ b/locales/es/LC_MESSAGES/tools.effmpeg.cli.po @@ -0,0 +1,196 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: faceswap.spanish\n" +"POT-Creation-Date: 2021-02-19 16:39+0000\n" +"PO-Revision-Date: 2021-02-19 17:35+0000\n" +"Language-Team: tokafondo\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 2.3\n" +"Last-Translator: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: es_ES\n" + +#: tools/effmpeg/cli.py:15 +msgid "This command allows you to easily execute common ffmpeg tasks." +msgstr "Este comando le permite ejecutar fácilmente tareas comunes de ffmpeg." + +#: tools/effmpeg/cli.py:24 +msgid "A wrapper for ffmpeg for performing image <> video converting." +msgstr "Un interfaz de ffmpeg para realizar la conversión de imagen <> vídeo." + +#: tools/effmpeg/cli.py:51 +msgid "" +"R|Choose which action you want ffmpeg ffmpeg to do.\n" +"L|'extract': turns videos into images \n" +"L|'gen-vid': turns images into videos \n" +"L|'get-fps' returns the chosen video's fps.\n" +"L|'get-info' returns information about a video.\n" +"L|'mux-audio' add audio from one video to another.\n" +"L|'rescale' resize video.\n" +"L|'rotate' rotate video.\n" +"L|'slice' cuts a portion of the video into a separate video file." +msgstr "" +"R|Elige qué acción quieres que haga ffmpeg\n" +"L|'extract': convierte los vídeos en imágenes \n" +"L|'gen-vid': convierte las imágenes en vídeos \n" +"L|'get-fps' devuelve los fps del vídeo elegido.\n" +"L|'get-info' devuelve información sobre un vídeo.\n" +"L|'mux-audio' añade audio de un vídeo a otro.\n" +"L|'rescale' cambia el tamaño del vídeo." + +#: tools/effmpeg/cli.py:65 +msgid "Input file." +msgstr "Archivo de entrada." + +#: tools/effmpeg/cli.py:66 tools/effmpeg/cli.py:73 tools/effmpeg/cli.py:87 +msgid "data" +msgstr "datos" + +#: tools/effmpeg/cli.py:76 +msgid "" +"Output file. If no output is specified then: if the output is meant to be a " +"video then a video called 'out.mkv' will be created in the input directory; " +"if the output is meant to be a directory then a directory called 'out' will " +"be created inside the input directory. Note: the chosen output file " +"extension will determine the file encoding." +msgstr "" +"R|Archivo de salida. Si se deja en blanco, entonces:\n" +"L|si la salida es un vídeo, se creará un vídeo llamado 'out.mkv' en el " +"directorio de entrada;\n" +"L|si la salida es un directorio, se creará un directorio llamado 'out' " +"dentro del directorio de entrada.\n" +"Nota: la extensión del archivo de salida elegida determinará la codificación " +"del archivo." + +#: tools/effmpeg/cli.py:89 +msgid "Path to reference video if 'input' was not a video." +msgstr "" +"Ruta de acceso al vídeo de referencia si se dio una carpeta con fotogramas " +"en vez de un vídeo." + +#: tools/effmpeg/cli.py:95 tools/effmpeg/cli.py:105 tools/effmpeg/cli.py:142 +#: tools/effmpeg/cli.py:171 +msgid "output" +msgstr "salida" + +#: tools/effmpeg/cli.py:97 +msgid "" +"Provide video fps. Can be an integer, float or fraction. Negative values " +"will will make the program try to get the fps from the input or reference " +"videos." +msgstr "" +"Introducir los fps del vídeo. Puede ser un número entero, flotante o una " +"fracción. Los valores negativos harán que el programa intente obtener los " +"fps de los vídeos de entrada o de referencia." + +#: tools/effmpeg/cli.py:107 +msgid "" +"Image format that extracted images should be saved as. '.bmp' will offer the " +"fastest extraction speed, but will take the most storage space. '.png' will " +"be slower but will take less storage." +msgstr "" +"Formato de imagen en el que se deben guardar las imágenes extraídas. '.bmp' " +"ofrecerá la mayor velocidad de extracción, pero ocupará el mayor espacio de " +"almacenamiento. '.png' será más lento pero ocupará menos espacio de " +"almacenamiento." + +#: tools/effmpeg/cli.py:114 tools/effmpeg/cli.py:123 tools/effmpeg/cli.py:132 +msgid "clip" +msgstr "recorte" + +#: tools/effmpeg/cli.py:116 +msgid "" +"Enter the start time from which an action is to be applied. Default: " +"00:00:00, in HH:MM:SS format. You can also enter the time with or without " +"the colons, e.g. 00:0000 or 026010." +msgstr "" +"Introduzca el momento a partir de la cual se debe aplicar una acción. Por " +"defecto: 00:00:00, en formato HH:MM:SS. También puede introducir la hora con " +"o sin los dos puntos, por ejemplo, 00:0000 o 026010." + +#: tools/effmpeg/cli.py:125 +msgid "" +"Enter the end time to which an action is to be applied. If both an end time " +"and duration are set, then the end time will be used and the duration will " +"be ignored. Default: 00:00:00, in HH:MM:SS." +msgstr "" +"Introduzca el momento hasta el cual se debe aplicar una acción. Por defecto: " +"00:00:00, en formato HH:MM:SS. También puede introducir la hora con o sin " +"los dos puntos, por ejemplo, 00:0000 o 026010." + +#: tools/effmpeg/cli.py:134 +msgid "" +"Enter the duration of the chosen action, for example if you enter 00:00:10 " +"for slice, then the first 10 seconds after and including the start time will " +"be cut out into a new video. Default: 00:00:00, in HH:MM:SS format. You can " +"also enter the time with or without the colons, e.g. 00:0000 or 026010." +msgstr "" +"Introduzca la duración de la acción seleccionada. Por defecto: 00:00:00, en " +"formato HH:MM:SS. También puede introducir la hora con o sin los dos puntos, " +"por ejemplo, 00:0000 o 026010." + +#: tools/effmpeg/cli.py:144 +msgid "" +"Mux the audio from the reference video into the input video. This option is " +"only used for the 'gen-vid' action. 'mux-audio' action has this turned on " +"implicitly." +msgstr "" +"Copia el audio del vídeo de referencia al vídeo de entrada. Esta opción sólo " +"se utiliza para la acción 'gen-vid'. La acción 'mux-audio' la tiene activada " +"implícitamente." + +#: tools/effmpeg/cli.py:155 tools/effmpeg/cli.py:165 +msgid "rotate" +msgstr "rotación" + +#: tools/effmpeg/cli.py:157 +msgid "" +"Transpose the video. If transpose is set, then degrees will be ignored. For " +"cli you can enter either the number or the long command name, e.g. to use " +"(1, 90Clockwise) -tr 1 or -tr 90Clockwise" +msgstr "" +"Rotar el vídeo. Si la rotación está establecida, los grados serán ignorados. " +"En la línea de comandos puede introducir el número o el nombre largo del " +"comando, por ejemplo, para usar (1, 90Clockwise) son válidas las opciones -" +"tr 1 y -tr 90Clockwise" + +#: tools/effmpeg/cli.py:166 +msgid "Rotate the video clockwise by the given number of degrees." +msgstr "" +"Gira el vídeo en el sentido de las agujas del reloj el número de grados " +"indicado." + +#: tools/effmpeg/cli.py:173 +msgid "Set the new resolution scale if the chosen action is 'rescale'." +msgstr "" +"Establece la nueva escala de resolución si la acción elegida es \"reescalar" +"\"." + +#: tools/effmpeg/cli.py:178 tools/effmpeg/cli.py:186 +msgid "settings" +msgstr "ajustes" + +#: tools/effmpeg/cli.py:180 +msgid "" +"Reduces output verbosity so that only serious errors are printed. If both " +"quiet and verbose are set, verbose will override quiet." +msgstr "" +"Reduce el detalle de la salida del registro para que sólo se impriman los " +"errores graves. Si se establecen tanto 'quiet' como 'verbose', 'verbose' " +"tendrá preferencia y anulará a 'quiet'." + +#: tools/effmpeg/cli.py:188 +msgid "" +"Increases output verbosity. If both quiet and verbose are set, verbose will " +"override quiet." +msgstr "" +"Aumenta el detalle de la información de registro. Si se establecen tanto " +"'quiet' como 'verbose', 'verbose', 'verbose' tendrá preferencia y anulará a " +"'quiet'." diff --git a/locales/es/LC_MESSAGES/tools.manual.cli.mo b/locales/es/LC_MESSAGES/tools.manual.cli.mo new file mode 100644 index 0000000000000000000000000000000000000000..931de6c47675b66a76a557775f4c9a12bc787e9b GIT binary patch literal 2139 zcmb_dO>Z1U5N#k3Scyx{9Et-H2+!bk_y}u!2xNmSCCcJB5kg4NJ5#gM&P?~v-LuK& z4kxbsfNU__ua_=-qwu_d^%7Qz z^&-~ySn~Xa^$FJRShuj=drGN&*sozPVf~D?!uLO)R%!wJ(KAY2fc+NsBJ6jt*WmLb z?3eid7Y;v%efaDU_ZQe#aQ^mlN_~a>Pw`R>`v)wJ)Q#tr!k;?8Ke(kmm=qhYq(|~0 zy+VrAhiu9_>A6bLxd#8JV_!Mn(pr1t`arsf#@Rs5Qss5aVT&qVFb-@La!y7Yl6D&e8J>-#g@I6VB{)_HB;m6V?Vg2PrFCm3fq0VwAwU*j7?7G zQsio(V=aYBgmZ_4%G18FT_3l|RAgNw9n)sXmF}B}v_&dI9U?;K9hyNmF>>3X4@3?f zb4h(i=Sx1RZMh+S5WQf7b}R4Nlwoa3c2an!y>1!?1hr>fhMa%`1=ljLfe%28wFy+{ zJ^(dorO-t!!5$ZcI%;iKIqeHr@U3;%u}yEXrNGh@wq2LNgZo%5r*KaKsVM+#_FJ^< zBel*mg{YSe406H@b>@IXs&Z{Bx7#rA*tq_G=R~gVCZvQj1YkYV_Mj^CdxEp z@*ZlrxitXRA`=CG1ed{0?Lp(JssQ{s5YAx&0zeV{Pu$|2nMg`%d z9+IcgCkj%SmS9C;^fkZyAyVOVGE2=Vhq96?7Sd)01O`ofS9=(GAZ#ZkXB7~fP^Cv@gY=&a_=IL)F7LJSR$gq0L$IDN{qTwNVV zOMq!qBa;vt!51h+oG=UW!-e0w6JiUl&Kq2(e3CP92D<4QkI=D@M8qC6%|)qPfK(0q GSAPS`#>^7{ literal 0 HcmV?d00001 diff --git a/locales/es/LC_MESSAGES/tools.manual.cli.po b/locales/es/LC_MESSAGES/tools.manual.cli.po new file mode 100644 index 0000000000..aad8921b10 --- /dev/null +++ b/locales/es/LC_MESSAGES/tools.manual.cli.po @@ -0,0 +1,77 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: faceswap.spanish\n" +"POT-Creation-Date: 2021-02-18 23:17-0000\n" +"PO-Revision-Date: 2021-02-19 17:43+0000\n" +"Language-Team: tokafondo\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 2.3\n" +"Last-Translator: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: es_ES\n" + +#: tools/manual/cli.py:13 +msgid "" +"This command lets you perform various actions on frames, faces and " +"alignments files using visual tools." +msgstr "" +"Este comando le permite realizar varias acciones en los archivos de " +"fotogramas, caras y alineaciones utilizando herramientas visuales." + +#: tools/manual/cli.py:23 +msgid "" +"A tool to perform various actions on frames, faces and alignments files " +"using visual tools" +msgstr "" +"Una herramienta que permite realizar diversas acciones en archivos de " +"fotogramas, caras y alineaciones mediante herramientas visuales" + +#: tools/manual/cli.py:35 tools/manual/cli.py:43 +msgid "data" +msgstr "datos" + +#: tools/manual/cli.py:37 +msgid "" +"Path to the alignments file for the input, if not at the default location" +msgstr "" +"Ruta del archivo de alineaciones para la entrada, si no está en la ubicación " +"por defecto" + +#: tools/manual/cli.py:44 +msgid "" +"Video file or directory containing source frames that faces were extracted " +"from." +msgstr "" +"Archivo o directorio de vídeo que contiene los fotogramas de origen de los " +"que se extrajeron las caras." + +#: tools/manual/cli.py:51 tools/manual/cli.py:59 +msgid "options" +msgstr "opciones" + +#: tools/manual/cli.py:52 +msgid "" +"Force regeneration of the low resolution jpg thumbnails in the alignments " +"file." +msgstr "" +"Forzar la regeneración de las miniaturas jpg de baja resolución en el " +"archivo de alineaciones." + +#: tools/manual/cli.py:60 +msgid "" +"The process attempts to speed up generation of thumbnails by extracting from " +"the video in parallel threads. For some videos, this causes the caching " +"process to hang. If this happens, then set this option to generate the " +"thumbnails in a slower, but more stable single thread." +msgstr "" +"El proceso intenta acelerar la generación de miniaturas extrayendo del vídeo " +"en hilos paralelos. En algunos vídeos, esto hace que el proceso de " +"extracción se cuelgue. Si esto sucede, entonces configure esta opción para " +"generar las miniaturas en un solo hilo más lento, pero más estable." diff --git a/locales/es/LC_MESSAGES/tools.mask.cli.mo b/locales/es/LC_MESSAGES/tools.mask.cli.mo new file mode 100644 index 0000000000000000000000000000000000000000..ffa39974a7c75bdc1345812cfd9ba88500f58744 GIT binary patch literal 7532 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, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: faceswap.spanish\n" +"POT-Creation-Date: 2021-02-18 23:14-0000\n" +"PO-Revision-Date: 2021-02-19 17:58+0000\n" +"Language-Team: tokafondo\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 2.3\n" +"Last-Translator: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: es_ES\n" + +#: tools/mask/cli.py:15 +msgid "This command lets you generate masks for existing alignments." +msgstr "" +"Este comando permite generar máscaras para las alineaciones existentes." + +#: tools/mask/cli.py:24 +msgid "" +"Mask tool\n" +"Generate masks for existing alignments files." +msgstr "" +"Herramienta de máscara\n" +"Genera máscaras para los archivos de alineación existentes." + +#: tools/mask/cli.py:32 tools/mask/cli.py:41 tools/mask/cli.py:51 +msgid "data" +msgstr "datos" + +#: tools/mask/cli.py:35 +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." +msgstr "" +"Ruta completa del archivo de alineaciones al que se añadirá la máscara. " +"Nota: si la máscara ya existe en el archivo de alineaciones, se " +"sobrescribirá." + +#: tools/mask/cli.py:44 +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:53 +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 "" +"R|Si la entrada es una carpeta de caras o una carpeta frames o vídeo\n" +"L|Caras: La entrada es una carpeta que contiene caras extraídas.\n" +"L|Fotogramas: La entrada es una carpeta que contiene fotogramas o es un vídeo" + +#: tools/mask/cli.py:62 tools/mask/cli.py:87 +msgid "process" +msgstr "proceso" + +#: tools/mask/cli.py:63 +msgid "" +"R|Masker to use.\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\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." +msgstr "" +"R|Máscara a utilizar.\n" +"L|components: Máscara diseñada para proporcionar una segmentación facial " +"basada en la posición de los puntos de referencia. Se construye un casco " +"convexo alrededor del exterior de los puntos de referencia para crear una " +"máscara.\n" +"L|extended: Máscara diseñada para proporcionar una segmentación facial " +"basada en el posicionamiento de las ubicaciones de los puntos de referencia. " +"Se construye un casco convexo alrededor del exterior de los puntos de " +"referencia y la máscara se extiende hacia arriba en la frente.\n" +"L|vgg-clear: Máscara diseñada para proporcionar una segmentación inteligente " +"de rostros principalmente frontales y libres de obstrucciones. Los rostros " +"de perfil y las obstrucciones pueden dar lugar a un rendimiento inferior.\n" +"L|vgg-obstructed: Máscara diseñada para proporcionar una segmentación " +"inteligente de rostros principalmente frontales. El modelo de máscara ha " +"sido entrenado específicamente para reconocer algunas obstrucciones faciales " +"(manos y gafas). Los rostros de perfil pueden dar lugar a un rendimiento " +"inferior.\n" +"L|unet-dfl: Máscara diseñada para proporcionar una segmentación inteligente " +"de rostros principalmente frontales. El modelo de máscara ha sido entrenado " +"por los miembros de la comunidad y necesitará ser probado para una mayor " +"descripción. Los rostros de perfil pueden dar lugar a un rendimiento " +"inferior." + +#: tools/mask/cli.py:88 +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." + +#: tools/mask/cli.py:101 tools/mask/cli.py:108 tools/mask/cli.py:121 +#: tools/mask/cli.py:134 tools/mask/cli.py:143 +msgid "output" +msgstr "salida" + +#: tools/mask/cli.py:102 +msgid "" +"Optional output location. If provided, a preview of the masks created will " +"be output in the given folder." +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:112 +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 " +"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" +msgstr "" +"Aplica el desenfoque gaussiano a la salida de la máscara. Tiene el efecto de " +"suavizar los bordes de la máscara dando menos de un borde duro. el tamaño " +"está en píxeles. Este valor debe ser impar, si se pasa un número par se " +"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:125 +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 "" +"Ayuda a reducir la \"mancha\" en algunas máscaras haciendo que los tonos " +"claros sean blancos y los oscuros negros. Los valores más altos afectarán " +"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:135 +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" +"L|masked: Output the face/frame as rgba image with the face masked.\n" +"L|mask: Only output the mask as a single channel image." +msgstr "" +"R|Cómo formatear la salida cuando el procesamiento se establece en " +"'salida'.\n" +"L|combined: La imagen contiene la cara o fotograma, la máscara facial y la " +"cara enmascarada.\n" +"L|masked: Da salida a la cara o fotograma como imagen rgba con la cara " +"enmascarada.\n" +"L|mask: Sólo emite la máscara como una imagen de un solo canal." + +#: tools/mask/cli.py:144 +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." +msgstr "" +"R|Marcar esta opción dará como salida el fotograma completo, en vez de sólo " +"el cuadro de la cara cuando se utiliza el procesamiento de salida. Sólo " +"tiene efecto cuando se utilizan cuadros como entrada." diff --git a/locales/es/LC_MESSAGES/tools.mo b/locales/es/LC_MESSAGES/tools.mo new file mode 100644 index 0000000000000000000000000000000000000000..afc32e4c192bb5b28842536461c58756b9b97a89 GIT binary patch literal 717 zcmYLH&5qMR3=Rk`IU;f6U~V8f*`x@mr1S#YMJur)KtbG)o4CnrlbPB1*>3j0Tkrrp z0^+~}^aXeVo&_hYsHIPiV}IY+}MN8-i)>v+XfnEm-6?z0z)seQ~$$9V%f!Den(y&9te>4PU?WAu2!FAZS zbVGDBraRmcxL|1{1yzxCo<*V2JCGiB+HPGE6;_{9>BVU!K5>QADu)f07ObI4|Snm$R{;y0-3fHjE~VV850Tij?>>G>B=9O7Nwd)*`Vba79Uq9Vs@yrsN9CESgKN2Qq?)k*3!qXNs;B2 zD)F+#s*pwv, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: faceswap.spanish\n" +"POT-Creation-Date: 2021-02-18 23:49-0000\n" +"PO-Revision-Date: 2021-02-19 18:00+0000\n" +"Language-Team: tokafondo\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 2.3\n" +"Last-Translator: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: es_ES\n" + +#: tools.py:46 +msgid "" +"Please backup your data and/or test the tool you want to use with a smaller " +"data set to make sure you understand how it works." +msgstr "" +"Por favor, haga una copia de seguridad de sus datos, y pruebe la " +"herramienta que quiere utilizar con un conjunto de datos más pequeño para " +"asegurarse de que entiende cómo funciona." diff --git a/locales/es/LC_MESSAGES/tools.preview.cli.mo b/locales/es/LC_MESSAGES/tools.preview.cli.mo new file mode 100644 index 0000000000000000000000000000000000000000..dfb8811cc3aa1aa0260d0345fed69701e7b35d8f GIT binary patch literal 1596 zcmZuxO^@6}5N#lQ$lN$1QCtv+*vw1>+IY2*cC)j|NGyz!T?7aT)!OZu-q_uyyFJ73 z1CVmakpmJ!;s#_GqXS}m#RPOy{hilf4z3&JAv;N*z2$_V6Vb{ zfQ9cjn1}ri`v`XDIU#27PvArUPxuV}t>=Zf3x5uO1O9vXW6b>s|1J7|zaYeC@RykU z9R62$1^>p2LY%-qhL`Yvz~dJmyd(s+$9CX`u6P-SaPdR9?HBOGOt+ne_h ztyC#ZMpNaN(h^^rER6Q7w9*U09I1vEk}6e8M;p`8TDc{8L#;K1bS}hd*~cO7qsGOV zI5nlLcX2a1+;!+bAl?E|mej;O&w$Cqd;kfYxK<0@NbOz3q6#D;*O2E;QWa_AiG3U_ zWyM|XsWt_BWwe-Exl(eCBut$g*0ovpnIS3As??&hGNfulcO|VSC%s2K9Fj+-Oto4` z6d;BX57*pAph!NWnRZ@sNv1-^=^Z+{NiHlo3Y|l7wp0#*n}&6XM6RO`5#w?fYV6mN zAN`L-CQ9~vDC>}Ki*R0k?cZx_ z9N#__OSUN0!TH%qy0=?rS-VQwbgBzeqKrB1T_~U2lUiC(D$}D)PVHuaA|qJVZfKmn zpM07Q3yjTaZe*!EO|n~H>VO?$){M)XQ!=kR%XNBUY~ym$ZS1;($v*YXgSR!kc|hZL z_P1%~B;A+Ohlw~IG@%R`T3!7NQBWxf`B^SGqjSBzv{>?0C&OtV{RJ+8am2P*sudd8 z6NRGVno>H+gF@IMa?NBr&$v3Be!3~_=@GN*G=ga2O@ca~jjm8Q+OCRRrRK7y_iyM- zJP5EM=0G)|LOxn@fja?Bw``FMKTR2zRY5(dkH7i}gJG(>Pz9D>{i3Ob(jchtj)?ox zT5iB5B0&2Njd+JSnM>O!AFCAwg(}2)U3L&hELX2B7o(z$!i48z7b0K}FP500*>jB_?5bs3HC5qEH@edmk0hs^* literal 0 HcmV?d00001 diff --git a/locales/es/LC_MESSAGES/tools.preview.cli.po b/locales/es/LC_MESSAGES/tools.preview.cli.po new file mode 100644 index 0000000000..2e9d26398a --- /dev/null +++ b/locales/es/LC_MESSAGES/tools.preview.cli.po @@ -0,0 +1,64 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: faceswap.spanish\n" +"POT-Creation-Date: 2021-02-18 23:09-0000\n" +"PO-Revision-Date: 2021-02-19 18:03+0000\n" +"Language-Team: tokafondo\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 2.3\n" +"Last-Translator: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: es_ES\n" + +#: tools/preview/cli.py:14 +msgid "This command allows you to preview swaps to tweak convert settings." +msgstr "" +"Este comando permite previsualizar los intercambios para ajustar la " +"configuración de la conversión." + +#: tools/preview/cli.py:23 +msgid "" +"Preview tool\n" +"Allows you to configure your convert settings with a live preview" +msgstr "" +"Herramienta de vista previa\n" +"Permite configurar los ajustes de conversión con una vista previa en directo" + +#: tools/preview/cli.py:33 tools/preview/cli.py:42 tools/preview/cli.py:49 +msgid "data" +msgstr "datos" + +#: tools/preview/cli.py:35 +msgid "" +"Input directory or video. Either a directory containing the image files you " +"wish to process or path to a video file." +msgstr "" +"Directorio o vídeo de entrada. Un directorio que contenga los archivos de " +"imagen que desea procesar o la ruta a un archivo de vídeo." + +#: tools/preview/cli.py:44 +msgid "" +"Path to the alignments file for the input, if not at the default location" +msgstr "" +"Ruta del archivo de alineaciones para la entrada, si no está en la " +"ubicación por defecto" + +#: tools/preview/cli.py:51 +msgid "" +"Model directory. A directory containing the trained model you wish to " +"process." +msgstr "" +"Directorio del modelo. Un directorio que contiene el modelo entrenado que " +"desea procesar." + +#: tools/preview/cli.py:58 +msgid "Swap the model. Instead of A -> B, swap B -> A" +msgstr "" +"Intercambiar el modelo. En lugar de convertir A en B, convierte B en A" diff --git a/locales/es/LC_MESSAGES/tools.restore.cli.mo b/locales/es/LC_MESSAGES/tools.restore.cli.mo new file mode 100644 index 0000000000000000000000000000000000000000..40170fef7634b35b4c5791fd9f408c3f7baf4aac GIT binary patch literal 889 zcmZuv!EVz)5M7{LeB{hwxV4})aZ07&kV7FUszgLo65xP@CiXa9V(+@U>lS|qpTM{9 z7kmdt#&*=Sf|Z_TW@q=knceyQ=-`Lob-;Mcc*S_g_`+yc#W-eMGARKBt}v8dJlyayg6bWau_~D3-Z$e6=i5 zr%;f0-Iczg9m$<~Lb0&s8|%ySRDLAul+iI{n2@{1Ds&ZUmJ1f|b@EDRDts_5KEzjg=f6k(I8Cf~giAnicVi>KJ58jd{%O$1qbrVC?Y{<7{Vbwqs zo&=xe?trl|EDWW}!!SJNN*!yroCWgZ#}F)v+F~Kkj4fRZy6QqV8IGZMCx;rIOdvWM z-?SM6xv#SifmmsToUFxCaZd;pTI@aunbY>4XB>11uPmelXp>xB@h1C;sT8)3qfKp9 zifKP5V!E3RWjI^xctTj|n?d2sW!*Ap&(Z`pr@HNw9NjL2V^%5fKZ>E4Io}esqhp{V UTPlv*(Yf11&rx&7|CSw!zmz%_&;S4c literal 0 HcmV?d00001 diff --git a/locales/es/LC_MESSAGES/tools.restore.cli.po b/locales/es/LC_MESSAGES/tools.restore.cli.po new file mode 100644 index 0000000000..e9a34b92fe --- /dev/null +++ b/locales/es/LC_MESSAGES/tools.restore.cli.po @@ -0,0 +1,36 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: faceswap.spanish\n" +"POT-Creation-Date: 2021-02-18 23:06-0000\n" +"PO-Revision-Date: 2021-02-19 18:05+0000\n" +"Language-Team: tokafondo\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 2.3\n" +"Last-Translator: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: es_ES\n" + +#: tools/restore/cli.py:13 +msgid "This command lets you restore models from backup." +msgstr "Este comando permite restaurar modelos desde una copia de seguridad." + +#: tools/restore/cli.py:22 +msgid "A tool for restoring models from backup (.bk) files" +msgstr "" +"Una herramienta para restaurar modelos a partir de archivos de copia de " +"seguridad (.bk)" + +#: tools/restore/cli.py:33 +msgid "" +"Model directory. A directory containing the model you wish to restore from " +"backup." +msgstr "" +"Directorio del modelo. Un directorio que contiene el modelo que desea " +"restaurar desde la copia de seguridad." diff --git a/locales/es/LC_MESSAGES/tools.sort.cli.mo b/locales/es/LC_MESSAGES/tools.sort.cli.mo new file mode 100644 index 0000000000000000000000000000000000000000..5d8f6556482895850b6e85e744a7d0f81e37a84c GIT binary patch literal 8497 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 S8O39!SIt!-JW^DBng0U@SdWVU literal 0 HcmV?d00001 diff --git a/locales/es/LC_MESSAGES/tools.sort.cli.po b/locales/es/LC_MESSAGES/tools.sort.cli.po new file mode 100644 index 0000000000..0b1c39ce67 --- /dev/null +++ b/locales/es/LC_MESSAGES/tools.sort.cli.po @@ -0,0 +1,202 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: faceswap.spanish\n" +"POT-Creation-Date: 2021-02-18 23:02-0000\n" +"PO-Revision-Date: 2021-02-20 17:18+0000\n" +"Language-Team: tokafondo\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 2.3\n" +"Last-Translator: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: es_ES\n" + +#: tools/sort/cli.py:14 +msgid "This command lets you sort images using various methods." +msgstr "" +"Este comando le permite ordenar las imágenes utilizando varios métodos." + +#: tools/sort/cli.py:23 +msgid "Sort faces using a number of different techniques" +msgstr "Clasificar los rostros mediante diferentes técnicas" + +#: tools/sort/cli.py:33 tools/sort/cli.py:40 +msgid "data" +msgstr "datos" + +#: tools/sort/cli.py:34 +msgid "Input directory of aligned faces." +msgstr "Directorio de entrada de caras alineadas." + +#: tools/sort/cli.py:41 +msgid "Output directory for sorted aligned faces." +msgstr "Directorio de salida para las caras alineadas ordenadas." + +#: tools/sort/cli.py:49 tools/sort/cli.py:89 +msgid "sort settings" +msgstr "ajustes de ordenación" + +#: tools/sort/cli.py:51 +msgid "" +"R|Sort by method. Choose how images are sorted. \n" +"L|'blur': Sort faces by blurriness.\n" +"L|'face': Use VGG Face to sort by face similarity. This uses a pairwise " +"clustering algorithm to check the distances between 512 features on every " +"face in your set and order them appropriately.\n" +"L|'face-cnn': Sort faces by their landmarks. You can adjust the threshold " +"with the '-t' (--ref_threshold) option.\n" +"L|'face-cnn-dissim': Like 'face-cnn' but sorts by dissimilarity.\n" +"L|'face-yaw': Sort faces by Yaw (rotation left to right).\n" +"L|'hist': Sort faces by their color histogram. You can adjust the threshold " +"with the '-t' (--ref_threshold) option.\n" +"L|'hist-dissim': Like 'hist' but sorts by dissimilarity.\n" +"L|'color-gray': Sort images by the average intensity of the converted " +"grayscale color channel.\n" +"L|'color-luma': Sort images by the average intensity of the converted Y " +"color channel. Bright lighting and oversaturated images will be ranked " +"first.\n" +"L|'color-green': Sort images by the average intensity of the converted Cg " +"color channel. Green images will be ranked first and red images will be " +"last.\n" +"L|'color-orange': Sort images by the average intensity of the converted Co " +"color channel. Orange images will be ranked first and blue images will be " +"last.\n" +"Default: hist" +msgstr "" +"R|Método de ordenación. Elige cómo se ordenan las imágenes. \n" +"L|'blur': Ordena las caras por desenfoque.\n" +"L|'face': Utiliza VGG Face para ordenar por similitud de caras. Esto utiliza " +"un algoritmo de agrupación por pares para comprobar las distancias entre 512 " +"características en cada cara en su conjunto y ordenarlos adecuadamente.\n" +"L|'face-cnn': Ordena las caras por sus puntos de referencia. Puedes ajustar " +"el umbral con la opción '-t' (--ref_threshold).\n" +"L|'face-cnn-dissim': Como 'face-cnn' pero ordena por disimilitud.\n" +"L|'face-yaw': Ordena las caras por Yaw (rotación de izquierda a derecha).\n" +"L|'hist': Ordena las caras por su histograma de color. Puedes ajustar el " +"umbral con la opción '-t' (--ref_threshold).\n" +"L|'hist-dissim': Como 'hist' pero ordena por disimilitud.\n" +"L|'color-gray': Ordena las imágenes por la intensidad media del canal de " +"color previa conversión a escala de grises convertido.\n" +"L|'color-luma': Ordena las imágenes por la intensidad media del canal de " +"color Y. Las imágenes muy brillantes y sobresaturadas se clasificarán " +"primero.\n" +"L|'color-green': Ordena las imágenes por la intensidad media del canal de " +"color Cg. Las imágenes verdes serán clasificadas primero y las rojas serán " +"las últimas.\n" +"L|'color-orange': Ordena las imágenes por la intensidad media del canal de " +"color Co. Las imágenes naranjas serán clasificadas primero y las azules " +"serán las últimas.\n" +"Por defecto: hist" + +#: tools/sort/cli.py:78 tools/sort/cli.py:105 tools/sort/cli.py:117 +#: tools/sort/cli.py:128 +msgid "output" +msgstr "salida" + +#: tools/sort/cli.py:79 +msgid "" +"Keeps the original files in the input directory. Be careful when using this " +"with rename grouping and no specified output directory as this would keep " +"the original and renamed files in the same directory." +msgstr "" +"Mantiene los archivos originales en el directorio de entrada. Tenga cuidado " +"al usar esto con la agrupación de renombre y sin especificar el directorio " +"de salida, ya que esto mantendría los archivos originales y renombrados en " +"el mismo directorio." + +#: tools/sort/cli.py:91 +msgid "" +"Float value. Minimum threshold to use for grouping comparison with 'face-" +"cnn' and 'hist' methods. The lower the value the more discriminating the " +"grouping is. Leaving -1.0 will allow the program set the default value " +"automatically. For face-cnn 7.2 should be enough, with 4 being very " +"discriminating. For hist 0.3 should be enough, with 0.2 being very " +"discriminating. Be careful setting a value that's too low in a directory " +"with many images, as this could result in a lot of directories being " +"created. Defaults: face-cnn 7.2, hist 0.3" +msgstr "" +"Valor flotante. Umbral mínimo a utilizar para la comparación de agrupaciones " +"con los métodos 'face-cnn' e 'hist'. Cuanto más bajo sea el valor, más " +"discriminante será la agrupación. Si se deja -1.0, el programa establecerá " +"el valor por defecto automáticamente. Para 'face-cnn' 7.2 debería ser " +"suficiente, siendo 4 muy discriminante. Para 'hist' 0.3 debería ser " +"suficiente, siendo 0,2 muy discriminante. Tenga cuidado al establecer un " +"valor demasiado bajo en un directorio con muchas imágenes, ya que esto " +"podría resultar en la creación de muchos directorios. Por defecto: 'face-" +"cnn' = 7.2, 'hist' = 0.3" + +#: tools/sort/cli.py:106 +msgid "" +"R|Default: rename.\n" +"L|'folders': files are sorted using the -s/--sort-by method, then they are " +"organized into folders using the -g/--group-by grouping method.\n" +"L|'rename': files are sorted using the -s/--sort-by then they are renamed." +msgstr "" +"R|Por defecto: renombrar.\n" +"L|'folders': los archivos se ordenan utilizando el método -s/--sort-by, y " +"luego se organizan en carpetas utilizando el método de agrupación -g/--group-" +"by.\n" +"L|'rename': los archivos se ordenan utilizando el método -s/--sort-by y " +"luego se renombran." + +#: tools/sort/cli.py:119 +msgid "" +"Group by method. When -fp/--final-processing by folders choose the how the " +"images are grouped after sorting. Default: hist" +msgstr "" +"Método de agrupamiento. Elija la forma de agrupar las imágenes, en el caso " +"de hacerlo por carpetas, después de la clasificación. Por defecto: hist" + +#: tools/sort/cli.py:130 +msgid "" +"Integer value. Number of folders that will be used to group by blur and face-" +"yaw. For blur folder 0 will be the least blurry, while the last folder will " +"be the blurriest. For face-yaw the number of bins is by how much 180 degrees " +"is divided. So if you use 18, then each folder will be a 10 degree " +"increment. Folder 0 will contain faces looking the most to the left whereas " +"the last folder will contain the faces looking the most to the right. If the " +"number of images doesn't divide evenly into the number of bins, the " +"remaining images get put in the last bin. Default value: 5" +msgstr "" +"Valor entero. Número de carpetas que se utilizarán al agrupar por 'blur' y " +"'face-yaw'. Para 'blur' la carpeta 0 será la menos borrosa, mientras que la " +"última carpeta será la más borrosa. Para 'face-yaw' el número de carpetas es " +"por cuanto se dividen los 180 grados. Así que si usas 18, entonces cada " +"carpeta será un incremento de 10 grados. La carpeta 0 contendrá las caras " +"que miren más a la izquierda, mientras que la última carpeta contendrá las " +"caras que miren más a la derecha. Si el número de imágenes no se divide " +"uniformemente en el número de carpetas, las imágenes restantes se colocan en " +"la última carpeta. Valor por defecto: 5" + +#: tools/sort/cli.py:141 tools/sort/cli.py:151 +msgid "settings" +msgstr "ajustes" + +#: tools/sort/cli.py:143 +msgid "" +"Logs file renaming changes if grouping by renaming, or it logs the file " +"copying/movement if grouping by folders. If no log file is specified with " +"'--log-file', then a 'sort_log.json' file will be created in the input " +"directory." +msgstr "" +"Registra los cambios en el nombre de los archivos si se agrupa por nombre, o " +"registra la copia o movimiento de archivos si se agrupa por carpetas. Si no " +"se especifica ningún archivo de registro con '--log-file', se creará un " +"archivo 'sort_log.json' en el directorio de entrada." + +#: tools/sort/cli.py:154 +msgid "" +"Specify a log file to use for saving the renaming or grouping information. " +"If specified extension isn't 'json' or 'yaml', then json will be used as the " +"serializer, with the supplied filename. Default: sort_log.json" +msgstr "" +"Especifica un archivo de registro que se utilizará para guardar la " +"información de renombrado o agrupación. Si la extensión especificada no es " +"'json' o 'yaml', se utilizará json como serializador, con el nombre de " +"archivo suministrado. Por defecto: sort_log.json" From c59d39f71e13ff7475a33fafea52737e96b93810 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 21 Feb 2021 16:52:18 +0000 Subject: [PATCH 381/981] Spanish Translations fixes: - Add missing masks for convert - Correctly label input type for mask tool - Add missing effmpeg options --- locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 39026 -> 39299 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 122 ++++++++++---------- locales/es/LC_MESSAGES/tools.effmpeg.cli.mo | Bin 6481 -> 6576 bytes locales/es/LC_MESSAGES/tools.effmpeg.cli.po | 8 +- locales/es/LC_MESSAGES/tools.mask.cli.mo | Bin 7532 -> 7530 bytes locales/es/LC_MESSAGES/tools.mask.cli.po | 8 +- 6 files changed, 72 insertions(+), 66 deletions(-) diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo index bec24845247ce5b77a24b0d5cfcc3a2c4900ddf6..0e8b6aff425ea76c35d19fd169860e7a6aec52b3 100644 GIT binary patch delta 1288 zcmZA0Pi&M$7{~EnyOvVKNC~KzhD^9LYE@cl4>bkSD%gKwDJ76a@ZH_F^p)Lx+jrlE zAjpE4NJ49biqH#68xJ+cHra>?njn;DJOF11e-0WOHJX6L;6?q;zV)Do&1asOcV?b> zX7=uM`Qc*u^l({WO@%c3lys*|s;!qU#iV7cq~rtAttM${xzzf+G*8l&7U><_fjOK- zK2dzN#1tLJHv9(H;es{N%h-dh_z@n$@9`}AxocI>IE@-c#~C=!>@P9#Lgexnr7q&@ z_=ruARZkPIUN2?vCML1-CFxC^!iD&ByTrNFJm*@p2Uv%d*p2hCue~5u(bzuMF@SAs zG=vlQd55$Cx01yzzMJfpzQZFN%erwxv{B6~(Fs@J82v7u!@tp1;9QS1%sN#YrEA0= z^h&#Nu&_y5L}M21$?jt{AD--^^u&{!rMHOBV>NN>7U>D%O?UziY?ZFkza$=Q_yb-f z{%{*fGM;!|-993Q2g)?6wo=~b8N5O?=WR9m0! zlr|IJLwhxyp45n^a4%j)a?twMr3Y~Wf2a0S_>+x?q_@kZkwE&9{k1qReai=bVgmyg z_DlUdzIz9xUM?|yn1@II)RE}!<{y=&S!fZC;sxZ{sBc)J+8RTute=r8t7Rlg=os=m z^eawa)4S4*GVcF6jlVe27w_@<88~~ql$rLi`hDUA^?MEDC!)9T6@E>;@T4?=myyG$ z>6An+nne4^p5r0dN7#a#Pi=E{puNf-tXxVXNuvsP;X{}o8-2XujcS|ha(?z7W)Jx6 z=*Jbe?Re?GWnEP~HgEBhg#}&v2Lm_dB||sWY})%xIN+MvF4qaNrrXKwsx|&#==oW* z$H^Ht>m<@{%B1pvm)&W?!11y)>X_u^%pNbDHu;=eS|Y<-8ctZ6m<(Kp4NPKhX|Obr zFyqHkZn`+W^5|og4fUqJskw2qvHeP{@tKBVtYcYS%+I>T6}v|&{+DL2XVMOdCB2zq z_P^P({eYA3(q1@Iw0Sz&&^2zF)Wd-EGae1U&bYbICs!w!Ib)Z~%$&(3oxm|edDryw cvk9jhGw26)a^_*5JpF$cN%_Uk_Fby^2a4hoWdHyG delta 1013 zcmXZaTS!z<6vpvyv~iM>(kMzwe9$5jQl|{fGSh6DE~ZwBinlCN$S^OE6;l>yrif17 zh4FW)RAKD@D6s7zr%-EbJXm>_Ef2n?_9?j>i+3cHGXvwtRyHrX6~#4{TUc= zoYqqhIU&`O$D%*^NQShT{28|4?@VIq%~|GzdD+q!d0>uI&-*uci~Mx1*|7!8pj^CHE0Z% zVNj{`gWiYX7b7pH3tqJUy!46r)l@0%WI$z=6vc~yi>#K*_kr2d>8-O?%B0?1XP({9 z8;s#Yf6bbp-p1*8g7@^j6ypk=2R!zFr?GG8{x?Q6t!p!8b`SY zZN?d=5%^*j`e9M0JJ4EV3{enIG*2Rr!`*M4^Rp8r^BBLao)({gFwe%whPAfHnBd4A iv3B?1foY4~Q}0|C?o(1$Tvb-!>8ozCdPXmeum1;AJF{5; diff --git a/locales/es/LC_MESSAGES/lib.cli.args.po b/locales/es/LC_MESSAGES/lib.cli.args.po index 5e88713eac..9acb9f0d41 100644 --- a/locales/es/LC_MESSAGES/lib.cli.args.po +++ b/locales/es/LC_MESSAGES/lib.cli.args.po @@ -5,8 +5,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-02-18 23:45-0000\n" -"PO-Revision-Date: 2021-02-19 17:37+0000\n" +"POT-Creation-Date: 2021-02-20 02:08-0000\n" +"PO-Revision-Date: 2021-02-21 16:47+0000\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es\n" @@ -14,7 +14,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 2.3\n" +"X-Generator: Poedit 2.4.2\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: lib/cli/args.py:177 lib/cli/args.py:187 lib/cli/args.py:195 @@ -91,7 +91,7 @@ msgstr "" #: lib/cli/args.py:365 lib/cli/args.py:381 lib/cli/args.py:393 #: lib/cli/args.py:425 lib/cli/args.py:443 lib/cli/args.py:455 -#: lib/cli/args.py:646 lib/cli/args.py:671 lib/cli/args.py:698 +#: lib/cli/args.py:646 lib/cli/args.py:671 lib/cli/args.py:700 msgid "Plugins" msgstr "Extensiones" @@ -238,8 +238,8 @@ msgstr "" "lista de números para enumerar exactamente qué ángulos comprobar." #: lib/cli/args.py:468 lib/cli/args.py:478 lib/cli/args.py:491 -#: lib/cli/args.py:505 lib/cli/args.py:735 lib/cli/args.py:749 -#: lib/cli/args.py:762 lib/cli/args.py:776 +#: lib/cli/args.py:505 lib/cli/args.py:737 lib/cli/args.py:751 +#: lib/cli/args.py:764 lib/cli/args.py:778 msgid "Face Processing" msgstr "Proceso de Caras" @@ -252,7 +252,7 @@ msgstr "" "a lo largo de la diagonal del cuadro delimitador. Establecer a 0 para " "desactivar" -#: lib/cli/args.py:479 lib/cli/args.py:750 +#: lib/cli/args.py:479 lib/cli/args.py:752 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -266,7 +266,7 @@ msgstr "" "uso del filtro de caras disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args.py:492 lib/cli/args.py:763 +#: lib/cli/args.py:492 lib/cli/args.py:765 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -280,7 +280,7 @@ msgstr "" "del filtro facial disminuirá significativamente la velocidad de extracción y " "no se puede garantizar su precisión." -#: lib/cli/args.py:506 lib/cli/args.py:777 +#: lib/cli/args.py:506 lib/cli/args.py:779 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -339,8 +339,8 @@ msgstr "" "Dibujar puntos de referencia en las caras de salida para fines de depuración." #: lib/cli/args.py:560 lib/cli/args.py:569 lib/cli/args.py:577 -#: lib/cli/args.py:584 lib/cli/args.py:789 lib/cli/args.py:800 -#: lib/cli/args.py:808 lib/cli/args.py:827 lib/cli/args.py:833 +#: lib/cli/args.py:584 lib/cli/args.py:791 lib/cli/args.py:802 +#: lib/cli/args.py:810 lib/cli/args.py:829 lib/cli/args.py:835 msgid "settings" msgstr "ajustes" @@ -464,12 +464,14 @@ msgid "" "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." +"performance.\n" +"L|predicted: If the 'Learn Mask' option was enabled during training, this " +"will use the mask that was created by the trained model." msgstr "" "R|Máscara a utilizar. NB: La máscara que necesita debe existir en el archivo " "de alineaciones. Puede añadir máscaras adicionales con la herramienta de " "máscaras.\n" -"L|ninguna: No utilizar una máscara.\n" +"L|none: No utilizar una máscara.\n" "L|components: Máscara diseñada para proporcionar una segmentación facial " "basada en el posicionamiento de las ubicaciones de los puntos de referencia. " "Se construye un casco convexo alrededor del exterior de los puntos de " @@ -490,9 +492,11 @@ msgstr "" "de rostros principalmente frontales. El modelo de máscara ha sido entrenado " "por los miembros de la comunidad y necesitará ser probado para una mayor " "descripción. Los rostros de perfil pueden dar lugar a un rendimiento " -"inferior." +"inferior.\n" +"L|predicted: Si la opción 'Learn Mask' se habilitó durante el entrenamiento, " +"esto usará la máscara que fue creada por el modelo entrenado." -#: lib/cli/args.py:699 +#: lib/cli/args.py:701 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -518,11 +522,11 @@ msgstr "" "L|pillow: [images] Más lento que opencv, pero tiene más opciones y soporta " "más formatos." -#: lib/cli/args.py:718 lib/cli/args.py:725 lib/cli/args.py:819 +#: lib/cli/args.py:720 lib/cli/args.py:727 lib/cli/args.py:821 msgid "Frame Processing" msgstr "Proceso de fotogramas" -#: lib/cli/args.py:719 +#: lib/cli/args.py:721 msgid "" "Scale the final output frames by this amount. 100%% will output the frames " "at source dimensions. 50%% at half size 200%% at double size" @@ -531,7 +535,7 @@ msgstr "" "a los fotogramas a las dimensiones de origen. 50%% a la mitad de tamaño. 200%" "% al doble de tamaño" -#: lib/cli/args.py:726 +#: lib/cli/args.py:728 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -545,7 +549,7 @@ msgstr "" "imágenes, ¡los nombres de los archivos deben terminar con el número de " "fotograma!" -#: lib/cli/args.py:736 +#: lib/cli/args.py:738 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -561,7 +565,7 @@ msgstr "" "especificada. Si se deja en blanco, se convertirán todas las caras que " "existan en el archivo de alineaciones." -#: lib/cli/args.py:790 +#: lib/cli/args.py:792 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -578,7 +582,7 @@ msgstr "" "procesos que los disponibles en su sistema. Si 'singleprocess' está " "habilitado, este ajuste será ignorado." -#: lib/cli/args.py:801 +#: lib/cli/args.py:803 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -586,7 +590,7 @@ msgstr "" "[LEGACY] Sólo es necesario seleccionar esta opción si se está cargando un " "modelo heredado si hay varios modelos en la carpeta de modelos" -#: lib/cli/args.py:809 +#: lib/cli/args.py:811 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -601,7 +605,7 @@ msgstr "" "de baja calidad. Si se encuentra un archivo de alineaciones, esta opción " "será ignorada." -#: lib/cli/args.py:820 +#: lib/cli/args.py:822 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -609,16 +613,16 @@ msgstr "" "Cuando se usa con --frame-ranges, la salida incluye los fotogramas no " "procesados en vez de descartarlos." -#: lib/cli/args.py:828 +#: lib/cli/args.py:830 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Intercambiar el modelo. En vez de convertir de A a B, convierte de B a A" -#: lib/cli/args.py:834 +#: lib/cli/args.py:836 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Desactiva el multiproceso. Es más lento, pero usa menos recursos." -#: lib/cli/args.py:850 +#: lib/cli/args.py:852 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -630,12 +634,12 @@ msgstr "" "hasta más de una semana.\n" "Los plugins de los modelos pueden configurarse en el menú \"Ajustes\"" -#: lib/cli/args.py:869 lib/cli/args.py:880 lib/cli/args.py:889 -#: lib/cli/args.py:900 +#: lib/cli/args.py:871 lib/cli/args.py:882 lib/cli/args.py:891 +#: lib/cli/args.py:902 msgid "faces" msgstr "caras" -#: lib/cli/args.py:870 +#: lib/cli/args.py:872 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -645,7 +649,7 @@ msgstr "" "para la cara A. Esta es la cara original, es decir, la cara que se quiere " "eliminar y sustituir por la cara B." -#: lib/cli/args.py:881 +#: lib/cli/args.py:883 msgid "" "DEPRECATED - This option will be removed in a future update. Path to " "alignments file for training set A. Defaults to /alignments.json if " @@ -655,7 +659,7 @@ msgstr "" "archivo de alineaciones para el conjunto de entrenamiento A. Por defecto es " "/alignments.json si no se proporciona." -#: lib/cli/args.py:890 +#: lib/cli/args.py:892 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -665,7 +669,7 @@ msgstr "" "para la cara B. Esta es la cara de intercambio, es decir, la cara que se " "quiere colocar en la cabeza de la persona A." -#: lib/cli/args.py:901 +#: lib/cli/args.py:903 msgid "" "DEPRECATED - This option will be removed in a future update. Path to " "alignments file for training set B. Defaults to /alignments.json if " @@ -675,11 +679,11 @@ msgstr "" "archivo de alineaciones para el conjunto de entrenamiento B. Por defecto es " "/alignments.json si no se proporciona." -#: lib/cli/args.py:909 lib/cli/args.py:921 +#: lib/cli/args.py:911 lib/cli/args.py:923 msgid "model" msgstr "modelo" -#: lib/cli/args.py:910 +#: lib/cli/args.py:912 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -693,7 +697,7 @@ msgstr "" "carpeta que no exista (que se creará). Si continúa entrenando un modelo " "existente, especifique la ubicación del modelo existente." -#: lib/cli/args.py:922 +#: lib/cli/args.py:924 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -738,12 +742,12 @@ msgstr "" "recursos (se necesita una GPU con una buena cantidad de VRAM). Bueno para " "los detalles, pero más susceptible a las diferencias de color." -#: lib/cli/args.py:949 lib/cli/args.py:961 lib/cli/args.py:972 -#: lib/cli/args.py:1058 +#: lib/cli/args.py:951 lib/cli/args.py:963 lib/cli/args.py:974 +#: lib/cli/args.py:1060 msgid "training" msgstr "entrenamiento" -#: lib/cli/args.py:950 +#: lib/cli/args.py:952 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -756,7 +760,7 @@ msgstr "" "momento es el doble del número que se establece aquí. Los lotes más grandes " "requieren más RAM de la GPU." -#: lib/cli/args.py:962 +#: lib/cli/args.py:964 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -771,22 +775,22 @@ msgstr "" "automáticamente en un número determinado de iteraciones, puede establecer " "ese valor aquí." -#: lib/cli/args.py:973 +#: lib/cli/args.py:975 msgid "" "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" "Utilice la estrategia de distribución en espejo de Tensorflow para entrenar " "en múltiples GPUs." -#: lib/cli/args.py:983 lib/cli/args.py:993 +#: lib/cli/args.py:985 lib/cli/args.py:995 msgid "Saving" msgstr "Guardar" -#: lib/cli/args.py:984 +#: lib/cli/args.py:986 msgid "Sets the number of iterations between each model save." msgstr "Establece el número de iteraciones entre cada guardado del modelo." -#: lib/cli/args.py:994 +#: lib/cli/args.py:996 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -794,11 +798,11 @@ msgstr "" "Establece el número de iteraciones antes de guardar una copia de seguridad " "del modelo en su estado actual. Establece 0 para que esté desactivado." -#: lib/cli/args.py:1001 lib/cli/args.py:1012 lib/cli/args.py:1023 +#: lib/cli/args.py:1003 lib/cli/args.py:1014 lib/cli/args.py:1025 msgid "timelapse" msgstr "intervalo" -#: lib/cli/args.py:1002 +#: lib/cli/args.py:1004 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -812,7 +816,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-B." -#: lib/cli/args.py:1013 +#: lib/cli/args.py:1015 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -826,7 +830,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-A." -#: lib/cli/args.py:1024 +#: lib/cli/args.py:1026 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -838,24 +842,24 @@ msgstr "" "Si se suministran las carpetas de entrada pero no la carpeta de salida, se " "guardará por defecto en la carpeta del modelo /timelapse/" -#: lib/cli/args.py:1036 lib/cli/args.py:1043 lib/cli/args.py:1050 +#: lib/cli/args.py:1038 lib/cli/args.py:1045 lib/cli/args.py:1052 msgid "preview" msgstr "previsualización" -#: lib/cli/args.py:1037 +#: lib/cli/args.py:1039 msgid "" "Percentage amount to scale the preview by. 100%% is the model output size." msgstr "" "Cantidad porcentual para escalar la vista previa. 100%% es el tamaño de " "salida del modelo." -#: lib/cli/args.py:1044 +#: lib/cli/args.py:1046 msgid "Show training preview output. in a separate window." msgstr "" "Mostrar la salida de la vista previa del entrenamiento. en una ventana " "separada." -#: lib/cli/args.py:1051 +#: lib/cli/args.py:1053 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -863,7 +867,7 @@ msgstr "" "Escribe el resultado del entrenamiento en un archivo. La imagen se " "almacenará en la raíz de su carpeta FaceSwap." -#: lib/cli/args.py:1059 +#: lib/cli/args.py:1061 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -871,12 +875,12 @@ msgstr "" "Desactiva el registro de TensorBoard. NB: Desactivar los registros significa " "que no podrá utilizar el gráfico o el análisis de esta sesión en la GUI." -#: lib/cli/args.py:1066 lib/cli/args.py:1075 lib/cli/args.py:1084 -#: lib/cli/args.py:1093 +#: lib/cli/args.py:1068 lib/cli/args.py:1077 lib/cli/args.py:1086 +#: lib/cli/args.py:1095 msgid "augmentation" msgstr "aumento" -#: lib/cli/args.py:1067 +#: lib/cli/args.py:1069 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -886,7 +890,7 @@ msgstr "" "conjunto de caras opuestas en lugar de deformar la cara al azar. Esta es la " "forma 'dfaker' de hacer la deformación." -#: lib/cli/args.py:1076 +#: lib/cli/args.py:1078 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -897,7 +901,7 @@ msgstr "" "general, esto debería dejarse sin efecto, excepto durante el 'entrenamiento " "de ajuste'." -#: lib/cli/args.py:1085 +#: lib/cli/args.py:1087 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -907,7 +911,7 @@ msgstr "" "diferencias de color entre los conjuntos A y B, con un mayor coste de tiempo " "de entrenamiento. Activa esta opción para desactivar el aumento de color." -#: lib/cli/args.py:1094 +#: lib/cli/args.py:1096 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -920,6 +924,6 @@ msgstr "" "esta opción desde el principio, es probable que arruine el modelo y se " "obtengan resultados terribles." -#: lib/cli/args.py:1119 +#: lib/cli/args.py:1121 msgid "Output to Shell console instead of GUI console" msgstr "Salida a la consola Shell en lugar de la consola GUI" diff --git a/locales/es/LC_MESSAGES/tools.effmpeg.cli.mo b/locales/es/LC_MESSAGES/tools.effmpeg.cli.mo index 3931988e07596919107eddd9e01bb80e7f3035ae..e4212f5452d997868f0bb689bda839e29489bba5 100644 GIT binary patch delta 315 zcmXZUy-Nc@5C!meiW+00@iW>@Bk3YJLquC45>Oi}LBZv2!Gn<1%^45`>u94W*omd2 z2!ZPYfq;mWh2Y;|qqgEZwEE4wdCZ&JS-yHdFFU|#3fKV9PXk?=7zbYH6MdzZ6Tm0k z%>WN{YZ92IAGAXUrhq;EiRc0M%na~Mk7~o3_}i%mLfnzp^Ny%wc^V_@Nhc zgf}%6f-E!!ZAk>zYO7o4iD~*`D(Y^PWY6 delta 219 zcmWm6yN&??7{&4b6vmx(9lC9{p%621Z8SneXcQX7zJVyWQtKoey(bVdorF+`N+H=t zQ0k`NII7<{`M#X3bGMg&^R@v$5$FQPDD0&fgR4Btn_R@flj9Fq%YFh<@+r5HBuuok zli#YbG+g9V9%Lm0Bk9S3{<>Khs;?}nbQB&I{L5kv{IZY-pR}bZ>oO=?@_p~Q3!@Yc ZlpCMrTGOfgo+2B=ac9x5kn33o;|02fA`t)p diff --git a/locales/es/LC_MESSAGES/tools.effmpeg.cli.po b/locales/es/LC_MESSAGES/tools.effmpeg.cli.po index ed5953581a..dab3f62f5f 100644 --- a/locales/es/LC_MESSAGES/tools.effmpeg.cli.po +++ b/locales/es/LC_MESSAGES/tools.effmpeg.cli.po @@ -6,13 +6,13 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" "POT-Creation-Date: 2021-02-19 16:39+0000\n" -"PO-Revision-Date: 2021-02-19 17:35+0000\n" +"PO-Revision-Date: 2021-02-21 16:49+0000\n" "Language-Team: tokafondo\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 2.3\n" +"X-Generator: Poedit 2.4.2\n" "Last-Translator: \n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "Language: es_ES\n" @@ -43,7 +43,9 @@ msgstr "" "L|'get-fps' devuelve los fps del vídeo elegido.\n" "L|'get-info' devuelve información sobre un vídeo.\n" "L|'mux-audio' añade audio de un vídeo a otro.\n" -"L|'rescale' cambia el tamaño del vídeo." +"L|'rescale' cambia el tamaño del vídeo.\n" +"L|'rotate' rotar video\n" +"L|'slice' corta una parte del video en un archivo de video separado. " #: tools/effmpeg/cli.py:65 msgid "Input file." diff --git a/locales/es/LC_MESSAGES/tools.mask.cli.mo b/locales/es/LC_MESSAGES/tools.mask.cli.mo index ffa39974a7c75bdc1345812cfd9ba88500f58744..43b9b66e9134fdaf6e1bb2d79694bf9859b2d98c 100644 GIT binary patch delta 185 zcmWN{JqrN=9LMpm<2=N9UXGO6AcS-#3s%DoQtHUVz(QtTg1Y63*I>EHi?EnYMzP2u zi~scL_v`yK9>)H-ZF``nL0@#DFa+K)IEcMCe8iV%iuVNU#6}WY(x1hJ3cnOw<)7kYaliW7`v;$!BQF2| delta 199 zcmaE5^~P$#oO%~V28KXh28Ku=J&linA&!B8;R8Pd!)ze!FTlX?1xUXJ(xE{5fgl4z z1CTZoVqkCts^1Qzb66P|ehM=%Tmy=yNir~;1=6-s3=B+63=G`T3=AGXKA$WD15g=5 zIFM!o(y>6A9Z09hGHjM+Eal-av{W!Ow=%VuJcD-~qw!`>zI1L5=ftAKVk-ro$$65J ZlP^jt^19`h Date: Sun, 21 Feb 2021 17:02:35 +0000 Subject: [PATCH 382/981] bugfix: plugins.train.trainer._base - Don't validate masks if `None` selected --- plugins/train/trainer/_base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 47e9cc5e2f..b35d467fd4 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -1349,6 +1349,9 @@ def _validate_masks(self): If at least one face in the training data does not contain the selected mask type """ mask_type = self._config["mask_type"] + if mask_type is None: + logger.debug("No mask selected. Not validating") + return invalid = {side: [filename for filename, detected_face in faces.items() if mask_type not in detected_face.mask] for side, faces in self._detected_faces.items()} From 7c746fa18eca8557e7dbe963fe826cbb5306d6ce Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 22 Feb 2021 12:27:07 +0000 Subject: [PATCH 383/981] lib.align.aligned_face.pose - Add pitch and yaw attributes --- lib/align/aligned_face.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 1208f67355..7d5d676539 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -479,6 +479,7 @@ def __init__(self, landmarks): self._camera_matrix = self._get_camera_matrix() self._rotation, self._translation = self._solve_pnp(landmarks) self._offset = self._get_offset() + self._pitch_yaw = None @property def xyz_2d(self): @@ -500,6 +501,28 @@ def offset(self): rather than the nose area. """ return self._offset + @property + def pitch(self): + """ float: The pitch of the aligned face in eular angles """ + if not self._pitch_yaw: + self._get_pitch_yaw() + return self._pitch_yaw[0] + + @property + def yaw(self): + """ float: The yaw of the aligned face in eular angles """ + if not self._pitch_yaw: + self._get_pitch_yaw() + return self._pitch_yaw[1] + + def _get_pitch_yaw(self): + """ Obtain the yaw and pitch from the :attr:`_rotation` in eular angles. """ + proj_matrix = np.zeros((3, 4), dtype="float32") + proj_matrix[:3, :3] = cv2.Rodrigues(self._rotation)[0] + euler = cv2.decomposeProjectionMatrix(proj_matrix)[-1] + self._pitch_yaw = (euler[0][0], euler[1][0]) + logger.trace("yaw_pitch: %s", self._pitch_yaw) + @classmethod def _get_camera_matrix(cls): """ Obtain an estimate of the camera matrix based off the original frame dimensions. From 4fe9974da4b5594da5a4f56bd9c07908496d901a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 22 Feb 2021 13:55:29 +0000 Subject: [PATCH 384/981] bugfix: lib.gui.utils - Catch permission error on preview image load --- lib/gui/utils.py | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/lib/gui/utils.py b/lib/gui/utils.py index c524ecfb04..8f950c1727 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -503,7 +503,14 @@ def load_latest_preview(self, thumbnail_size, frame_dims): if not image_files: return - self._load_images_to_cache(image_files, frame_dims, thumbnail_size) + if not self._load_images_to_cache(image_files, frame_dims, thumbnail_size): + logger.debug("Failed to load any preview images") + if gui_preview in image_files: + # Reset last modified for failed loading of a gui preview image so it is picked + # up next time + self._previewcache["modified"] = None + return + if image_files == [gui_preview]: # Delete the preview image so that the main scripts know to output another logger.debug("Deleting preview image") @@ -555,18 +562,30 @@ def _load_images_to_cache(self, image_files, frame_dims, thumbnail_size): The (width (`int`), height (`int`)) of the display panel that will display the preview thumbnail_size: int The size of each thumbnail that should be created + + Returns + ------- + bool + ``True`` if images were succesfully loaded to cache otherwise ``False`` """ logger.debug("Number image_files: %s, frame_dims: %s, thumbnail_size: %s", len(image_files), frame_dims, thumbnail_size) num_images = (frame_dims[0] // thumbnail_size) * (frame_dims[1] // thumbnail_size) logger.debug("num_images: %s", num_images) if num_images == 0: - return + return False samples = list() start_idx = len(image_files) - num_images if len(image_files) > num_images else 0 show_files = sorted(image_files, key=os.path.getctime)[start_idx:] + dropped_files = list() for fname in show_files: - img = Image.open(fname) + try: + img = Image.open(fname) + except PermissionError as err: + logger.debug("Permission error opening preview file: '%s'. Original error: %s", + fname, str(err)) + dropped_files.append(fname) + continue width, height = img.size scaling = thumbnail_size / max(width, height) logger.debug("image width: %s, height: %s, scaling: %s", width, height, scaling) @@ -580,7 +599,16 @@ def _load_images_to_cache(self, image_files, frame_dims, thumbnail_size): draw = ImageDraw.Draw(img) draw.rectangle(((0, 0), (thumbnail_size, thumbnail_size)), outline="#E5E5E5", width=1) samples.append(np.array(img)) + samples = np.array(samples) + if not np.any(samples): + logger.debug("No preview images collected.") + return False + + if dropped_files: + logger.debug("Removing dropped files: %s", dropped_files) + show_files = [fname for fname in show_files if fname not in dropped_files] + self._previewcache["filenames"] = (self._previewcache["filenames"] + show_files)[-num_images:] cache = self._previewcache["images"] @@ -592,6 +620,7 @@ def _load_images_to_cache(self, image_files, frame_dims, thumbnail_size): cache = np.concatenate((cache, samples))[-num_images:] self._previewcache["images"] = cache logger.debug("Cache shape: %s", self._previewcache["images"].shape) + return True def _place_previews(self, frame_dims): """ Format the preview thumbnails stored in the cache into a grid fitting the display From 0401dd15a5a5f242e5bb4751766ed5afb00e3aea Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 23 Feb 2021 19:23:11 +0000 Subject: [PATCH 385/981] tools.alignments - Update png header when removing faces --- lib/align/alignments.py | 2 +- lib/image.py | 82 +++++++++++++++++++++++++++++++++++----- tools/alignments/jobs.py | 36 +++++++++++++++++- 3 files changed, 107 insertions(+), 13 deletions(-) diff --git a/lib/align/alignments.py b/lib/align/alignments.py index 2dad99200f..69ec40233c 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -552,7 +552,7 @@ def filter_faces(self, filter_dict, filter_out=False): Parameters ---------- filter_dict: dict - Dictionary of source filenames ask key with a list of face indices to filter as value. + Dictionary of source filenames as key with a list of face indices to filter as value. filter_out: bool, optional ``True`` if faces should be removed from :attr:`data` when there is a corresponding match in the given filter_dict. ``False`` if faces should be kept in :attr:`data` when diff --git a/lib/image.py b/lib/image.py index 8e337603dc..274144aab0 100644 --- a/lib/image.py +++ b/lib/image.py @@ -7,6 +7,7 @@ import os import struct import sys +import tempfile from ast import literal_eval from bisect import bisect @@ -446,6 +447,76 @@ def read_image_meta_batch(filenames): yield retval +def pack_to_itxt(metadata): + """ Pack the given metadata dictionary to a PNG iTXt header field. + + Parameters + ---------- + metadata: dict or bytes + The dictionary to write to the header. Can be pre-encoded as utf-8. + + Returns + ------- + bytes + A byte encoded PNG iTXt field, including chunk header and CRC + """ + if not isinstance(metadata, bytes): + metadata = str(metadata).encode("utf-8", "strict") + key = "faceswap".encode("latin-1", "strict") + + chunk = key + b"\0\0\0\0\0" + metadata + crc = struct.pack(">I", crc32(chunk, crc32(b"iTXt")) & 0xFFFFFFFF) + length = struct.pack(">I", len(chunk)) + retval = length + b"iTXt" + chunk + crc + return retval + + +def update_existing_metadata(filename, metadata): + """ Update the png header metadata for an existing .png extracted face file on the filesystem. + + Parameters + ---------- + filename: str + The full path to the face to be updated + metadata: dict or bytes + The dictionary to write to the header. Can be pre-encoded as utf-8. + """ + + with tempfile.NamedTemporaryFile(dir=os.path.dirname(filename), + delete=False) as tmp, open(filename, "rb") as png: + chunk = png.read(8) + if chunk != b"\x89PNG\r\n\x1a\n": + raise ValueError(f"Invalid header found in png: {filename}") + tmp.write(chunk) + while True: + chunk = png.read(8) + length, field = struct.unpack(">I4s", chunk) + logger.trace("Read chunk: (chunk: %s, length: %s, field: %s", chunk, length, field) + + if field == b"IEND": # End of PNG + logger.trace("Closing png") + tmp.write(chunk) + break + + if field != b"iTXt": # Write chunk straight out to tmp file + logger.trace("Copying existing chunk") + tmp.write(chunk + png.read(length + 4)) # Header + CRC + continue + + keyword, value = png.read(length).split(b"\0", 1) + if keyword != b"faceswap": + # Write existing non fs-iTXt data + CRC + logger.trace("Copying non-faceswap iTXt chunk: %s", keyword) + tmp.write(keyword + b"\0" + value + png.read(4)) + continue + + logger.trace("Updating faceswap iTXt chunk") + tmp.write(pack_to_itxt(metadata)) + png.seek(4, 1) # Skip old CRC + + os.replace(tmp.name, filename) + + def encode_image(image, extension, metadata=None): """ Encode an image. @@ -499,17 +570,8 @@ def png_write_meta(png, data): PNG Specification: https://www.w3.org/TR/2003/REC-PNG-20031110/ """ - if not isinstance(data, bytes): - data = str(data).encode("utf-8", "strict") - key = "faceswap".encode("latin-1", "strict") - split = png.find(b"IDAT") - 4 - header, image = png[:split], png[split:] - - chunk = key + b"\0\0\0\0\0" + data - crc = struct.pack(">I", crc32(chunk, crc32(b"iTXt")) & 0xFFFFFFFF) - length = struct.pack(">I", len(chunk)) - retval = header + length + b"iTXt" + chunk + crc + image + retval = png[:split] + pack_to_itxt(data) + png[split:] return retval diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index 92418be2a7..a676c14212 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -14,7 +14,7 @@ from lib.align import DetectedFace, _EXTRACT_RATIOS from lib.align.alignments import _VERSION -from lib.image import encode_image, generate_thumbnail, ImagesSaver +from lib.image import encode_image, generate_thumbnail, ImagesSaver, update_existing_metadata from plugins.extract.pipeline import Extractor, ExtractMedia from .media import ExtractedFaces, Faces, Frames @@ -662,6 +662,7 @@ def process(self): logger.info("[REMOVE FACES FROM ALIGNMENTS]") # Tidy up cli output frame_face_indices = self._items.items + if not frame_face_indices: logger.error("No matching faces found in your faces folder. This would remove all " "faces from your alignments file. Process aborted.") @@ -670,12 +671,43 @@ def process(self): pre_face_count = self._alignments.faces_count self._alignments.filter_faces(frame_face_indices, filter_out=False) del_count = pre_face_count - self._alignments.faces_count - if del_count == 0: logger.info("No changes made to alignments file. Exiting") return logger.info("%s alignment(s) were removed from alignments file", del_count) + + # PNG Header Updates + updated_headers = 0 + for file_info in tqdm(self._items.file_list_sorted, desc="Updating PNG Headers"): + frame = file_info["source_filename"] + face_index = file_info["face_index"] + new_index = frame_face_indices[frame].index(face_index) + + if new_index == face_index: # face index has not changed + continue + fullpath = os.path.join(self._items.folder, file_info["current_filename"]) + logger.debug("Updating png header for '%s': face index from %s to %s", + fullpath, face_index, new_index) + + # Update file_list_sorted for rename task + orig_filename = "{}_{}.png".format(os.path.splitext(frame)[0], new_index) + file_info["face_index"] = new_index + file_info["original_filename"] = orig_filename + + face = DetectedFace() + face.from_alignment(self._alignments.get_faces_in_frame(frame)[new_index]) + meta = dict(alignments=face.to_png_meta(), + source=dict(alignments_version=file_info["alignments_version"], + original_filename=orig_filename, + face_index=new_index, + source_filename=frame, + source_is_video=file_info["source_is_video"])) + update_existing_metadata(fullpath, meta) + updated_headers += 1 + + logger.info("%s Extracted face(s) had their header information updated", updated_headers) + self._alignments.save() rename = Rename(self._alignments, None, self._items) From 2a4cfa0262de5c11b5028ceb335eba8e2f2c7c93 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 24 Feb 2021 10:50:28 +0000 Subject: [PATCH 386/981] bugfix - lib.image.update_existing_metadata - Correctly close png file --- lib/image.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/image.py b/lib/image.py index 274144aab0..34da70ceb4 100644 --- a/lib/image.py +++ b/lib/image.py @@ -495,7 +495,8 @@ def update_existing_metadata(filename, metadata): if field == b"IEND": # End of PNG logger.trace("Closing png") - tmp.write(chunk) + crc = struct.pack(">I", crc32(field)) + tmp.write(chunk + crc) break if field != b"iTXt": # Write chunk straight out to tmp file From a2a53c553ba93e7a16bcfd68a8f63f8d37ecda54 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 24 Feb 2021 23:55:32 +0000 Subject: [PATCH 387/981] bugfix - lib.align - Fix edge case aligned face sub crop --- lib/align/aligned_face.py | 9 +++++---- lib/align/detected_face.py | 10 ++++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 7d5d676539..b2ad11277a 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -424,7 +424,7 @@ def get_cropped_roi(self, centering): """ with self._cache["cropped_roi"][1]: if centering not in self._cache["cropped_roi"][0]: - offset = self.pose.offset.get(centering, np.float32((0, 0))) # legacy = 0,0 + offset = self.pose.offset.get(centering, np.float32((0, 0))) # legacy = 0.0 offset -= self.pose.offset["head"] offset *= (self._head_size - (self._head_size * _EXTRACT_RATIOS["head"])) @@ -447,11 +447,12 @@ def _get_cropped_slices(self): with self._cache["cropped_slices"][1]: if not self._cache["cropped_slices"][0].get(self._centering): roi = self.get_cropped_roi(self._centering) - head_size = self._head_size slice_in = [slice(max(roi[1], 0), max(roi[3], 0)), slice(max(roi[0], 0), max(roi[2], 0))] - slice_out = [slice(max(roi[1] * -1, 0), self._size - max(0, roi[3] - head_size)), - slice(max(roi[0] * -1, 0), self._size - max(0, roi[2] - head_size))] + slice_out = [slice(max(roi[1] * -1, 0), + self._size - min(self._size, max(0, roi[3] - self._head_size))), + slice(max(roi[0] * -1, 0), + self._size - min(self._size, max(0, roi[2] - self._head_size)))] self._cache["cropped_slices"][0][self._centering] = {"in": slice_in, "out": slice_out} logger.trace("centering: %s, cropped_slices: %s", diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index 2849406cb6..b19eccab0e 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -616,10 +616,12 @@ def set_sub_crop(self, offset): self._sub_crop["size"] = crop_size self._sub_crop["slice_in"] = [slice(max(roi[1], 0), max(roi[3], 0)), slice(max(roi[0], 0), max(roi[2], 0))] - self._sub_crop["slice_out"] = [slice(max(roi[1] * -1, 0), - crop_size - max(0, roi[3] - self.stored_size)), - slice(max(roi[0] * -1, 0), - crop_size - max(0, roi[2] - self.stored_size))] + self._sub_crop["slice_out"] = [ + slice(max(roi[1] * -1, 0), + crop_size - min(crop_size, max(0, roi[3] - self.stored_size))), + slice(max(roi[0] * -1, 0), + crop_size - min(crop_size, max(0, roi[2] - self.stored_size)))] + logger.trace("src_size: %s, roi: %s, sub_crop: %s", src_size, roi, self._sub_crop) def _adjust_affine_matrix(self, mask_size, affine_matrix): From 790a094d346d89abbd6f3881402e0cf5c7ee8754 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 26 Feb 2021 10:29:11 +0000 Subject: [PATCH 388/981] Code updates: - lib.image.update_existing_meta: optimize - plugins.tran.model._base - Capture corrupt model errors --- lib/image.py | 19 ++++++++--------- plugins/train/model/_base.py | 11 +++++++++- tools/alignments/jobs.py | 41 +++++++++++++++++++++--------------- 3 files changed, 43 insertions(+), 28 deletions(-) diff --git a/lib/image.py b/lib/image.py index 34da70ceb4..061611156f 100644 --- a/lib/image.py +++ b/lib/image.py @@ -7,7 +7,6 @@ import os import struct import sys -import tempfile from ast import literal_eval from bisect import bisect @@ -482,24 +481,24 @@ def update_existing_metadata(filename, metadata): The dictionary to write to the header. Can be pre-encoded as utf-8. """ - with tempfile.NamedTemporaryFile(dir=os.path.dirname(filename), - delete=False) as tmp, open(filename, "rb") as png: + tmp_filename = filename + "~" + with open(filename, "rb") as png, open(tmp_filename, "wb") as tmp: chunk = png.read(8) if chunk != b"\x89PNG\r\n\x1a\n": raise ValueError(f"Invalid header found in png: {filename}") tmp.write(chunk) + while True: chunk = png.read(8) length, field = struct.unpack(">I4s", chunk) - logger.trace("Read chunk: (chunk: %s, length: %s, field: %s", chunk, length, field) + logger.trace("Read chunk: (chunk: %s, length: %s, field: %s)", chunk, length, field) - if field == b"IEND": # End of PNG - logger.trace("Closing png") - crc = struct.pack(">I", crc32(field)) - tmp.write(chunk + crc) + if field == b"IDAT": # Write out all remaining data + logger.trace("Writing image data and closing png") + tmp.write(chunk + png.read()) break - if field != b"iTXt": # Write chunk straight out to tmp file + if field != b"iTXt": # Write non iTXt chunk straight out logger.trace("Copying existing chunk") tmp.write(chunk + png.read(length + 4)) # Header + CRC continue @@ -515,7 +514,7 @@ def update_existing_metadata(filename, metadata): tmp.write(pack_to_itxt(metadata)) png.seek(4, 1) # Skip old CRC - os.replace(tmp.name, filename) + os.replace(tmp_filename, filename) def encode_image(image, extension, metadata=None): diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 2f17aec232..1695e69f20 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -516,7 +516,16 @@ def _load(self): logger.error("Model could not be found in folder '%s'. Exiting", self._model_dir) sys.exit(1) - model = load_model(self._filename, compile=False) + try: + model = load_model(self._filename, compile=False) + except RuntimeError as err: + if "unable to get link info" in str(err).lower(): + msg = (f"Unable to load the model from '{self._filename}'. This may be a " + "temporary error but most likely means that your model has corrupted.\n" + "You can try to load the model again but if the problem persists you " + "should use the Restore Tool to restore your model from backup.") + raise FaceswapError(msg) + raise err logger.info("Loaded model from disk: '%s'", self._filename) return model diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index a676c14212..0121a7b8e3 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -661,15 +661,13 @@ def process(self): folder. """ logger.info("[REMOVE FACES FROM ALIGNMENTS]") # Tidy up cli output - frame_face_indices = self._items.items - - if not frame_face_indices: + if not self._items.items: logger.error("No matching faces found in your faces folder. This would remove all " "faces from your alignments file. Process aborted.") return pre_face_count = self._alignments.faces_count - self._alignments.filter_faces(frame_face_indices, filter_out=False) + self._alignments.filter_faces(self._items.items, filter_out=False) del_count = pre_face_count - self._alignments.faces_count if del_count == 0: logger.info("No changes made to alignments file. Exiting") @@ -677,15 +675,30 @@ def process(self): logger.info("%s alignment(s) were removed from alignments file", del_count) - # PNG Header Updates - updated_headers = 0 - for file_info in tqdm(self._items.file_list_sorted, desc="Updating PNG Headers"): + self._update_png_headers() + self._alignments.save() + + rename = Rename(self._alignments, None, self._items) + rename.process() + + def _update_png_headers(self): + """ Update the EXIF iTXt field of any face PNGs that have had their face index changed. + + Notes + ----- + This could be quicker if parellizing in threads, however, Windows (at least) does not seem + to like this and has a tendency to throw permission errors, so this remains single threaded + for now. + """ + to_update = [ # Items whose face index has changed + x for x in self._items.file_list_sorted + if x["face_index"] != self._items.items[x["source_filename"]].index(x["face_index"])] + + for file_info in tqdm(to_update, desc="Updating PNG Headers", leave=False): frame = file_info["source_filename"] face_index = file_info["face_index"] - new_index = frame_face_indices[frame].index(face_index) + new_index = self._items.items[frame].index(face_index) - if new_index == face_index: # face index has not changed - continue fullpath = os.path.join(self._items.folder, file_info["current_filename"]) logger.debug("Updating png header for '%s': face index from %s to %s", fullpath, face_index, new_index) @@ -704,14 +717,8 @@ def process(self): source_filename=frame, source_is_video=file_info["source_is_video"])) update_existing_metadata(fullpath, meta) - updated_headers += 1 - logger.info("%s Extracted face(s) had their header information updated", updated_headers) - - self._alignments.save() - - rename = Rename(self._alignments, None, self._items) - rename.process() + logger.info("%s Extracted face(s) had their header information updated", len(to_update)) class Rename(): # pylint:disable=too-few-public-methods From 1d01af75a2e3562d5c13d6d1237089ebf68bff4b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 28 Feb 2021 12:32:55 +0000 Subject: [PATCH 389/981] Bugfixes: - plugins.train.trainer._base - Don't output error message for sides which have valid masks - lib.gui.menu - Fix project file saving extension on Linux --- lib/gui/utils.py | 6 +++--- plugins/train/trainer/_base.py | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 8f950c1727..3eee648db9 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -222,7 +222,7 @@ def _set_defaults(self): dict: The default file extension for each file type """ - defaults = {key: val[0][1].replace("*", "") + defaults = {key: next(ext for ext in val[0][1].split(" ")).replace("*", "") for key, val in self._filetypes.items()} defaults["default"] = None defaults["video"] = ".mp4" @@ -254,7 +254,7 @@ def _set_kwargs(self, title, initialdir, filetype, command, action, variable=Non if self._handletype.lower() in ( "open", "save", "filename", "filename_multi", "savefilename"): kwargs["filetypes"] = self._filetypes[filetype] - if self._defaults.get(filetype, None): + if self._defaults.get(filetype): kwargs['defaultextension'] = self._defaults[filetype] if self._handletype.lower() == "save": kwargs["mode"] = "w" @@ -566,7 +566,7 @@ def _load_images_to_cache(self, image_files, frame_dims, thumbnail_size): Returns ------- bool - ``True`` if images were succesfully loaded to cache otherwise ``False`` + ``True`` if images were successfully loaded to cache otherwise ``False`` """ logger.debug("Number image_files: %s, frame_dims: %s, thumbnail_size: %s", len(image_files), frame_dims, thumbnail_size) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index b35d467fd4..07ca298304 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -141,7 +141,7 @@ def _get_alignments_data(self): penalized_loss = self._model.config["penalized_mask_loss"] alignments = _TrainingAlignments(self._model, self._images) - # Update centering if it has been changed by legacy facesets in TrainingAlignments + # Update centering if it has been changed by legacy face sets in TrainingAlignments self._config["centering"] = self._model.config["centering"] retval = dict(aligned_faces=alignments.aligned_faces, versions=alignments.versions) @@ -1242,7 +1242,7 @@ def _update_legacy_facesets(self, image_list): def _get_alignments_path(self, side): """ Obtain the path to an alignments file for the given training side. - Used for updating legacy facesets to contain the meta information within the image header + Used for updating legacy face sets to contain the meta information within the image header Parameters ---------- @@ -1361,6 +1361,8 @@ def _validate_masks(self): "select a mask type that exists within your face data, or generate the " "required masks with the Mask Tool.".format(mask_type)) for side, filenames in invalid.items(): + if not filenames: + continue available = set(mask for det_face in self._detected_faces[side].values() for mask in det_face.mask) From 514f3d60a77d2c9bede509d835e46a02271e839f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 1 Mar 2021 12:30:15 +0000 Subject: [PATCH 390/981] lib.gui - Restore settings window on click when hidden --- lib/gui/popup_configure.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index cb26676a19..c8d412826d 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -41,7 +41,10 @@ def open_popup(self, name=None): self._scan_for_configs() logger.debug("name: %s", name) if self._popup is not None: - logger.info("Popup already open. Returning: %s", _POPUP) + logger.debug("Restoring existing popup") + self._popup.update() + self._popup.deiconify() + self._popup.lift() return self._popup = _ConfigurePlugins(name, self._configs) @@ -93,7 +96,7 @@ def _load_config(cls, plugin_type): _STATE = _State() -open_popup = _STATE.open_popup +open_popup = _STATE.open_popup # pylint:disable=invalid-name class _ConfigurePlugins(tk.Toplevel): From 3c9a0f9e53742ae95b394e2016b1a3a391049d77 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:00:22 +0000 Subject: [PATCH 391/981] lib.model.layers.KResizeImages - Add arbritary resize (tf) --- lib/model/layers.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/lib/model/layers.py b/lib/model/layers.py index 7d9cf41fd4..2557654435 100644 --- a/lib/model/layers.py +++ b/lib/model/layers.py @@ -196,7 +196,7 @@ class KResizeImages(Layer): Parameters ---------- - size: int, optional + size: int or float, optional The scale to upsample to. Default: `2` interpolation: ["nearest", "bilinear"], optional The interpolation to use. Default: `"nearest"` @@ -223,11 +223,20 @@ def call(self, inputs, **kwargs): # pylint:disable=unused-argument tensor A tensor or list/tuple of tensors """ - return K.resize_images(inputs, - self.size, - self.size, - "channels_last", - interpolation=self.interpolation) + if isinstance(self.size, int): + retval = K.resize_images(inputs, + self.size, + self.size, + "channels_last", + interpolation=self.interpolation) + else: + # Arbitrary resizing + size = int(round(K.int_shape(inputs)[1] * self.size)) + if get_backend() != "amd": + retval = tf.image.resize(inputs, (size, size), method=self.interpolation) + else: + raise NotImplementedError + return retval def compute_output_shape(self, input_shape): """Computes the output shape of the layer. From 9d75564d3f61d815ec29eff07af577bf357aaf38 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 1 Mar 2021 18:02:23 +0000 Subject: [PATCH 392/981] typofix: lib.model.layers - remove indent --- lib/model/layers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/model/layers.py b/lib/model/layers.py index 2557654435..9a9986aeef 100644 --- a/lib/model/layers.py +++ b/lib/model/layers.py @@ -236,7 +236,7 @@ def call(self, inputs, **kwargs): # pylint:disable=unused-argument retval = tf.image.resize(inputs, (size, size), method=self.interpolation) else: raise NotImplementedError - return retval + return retval def compute_output_shape(self, input_shape): """Computes the output shape of the layer. From 8f499a44ddb0dc39a5972ca14f784eaf00b4fe20 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 3 Mar 2021 14:32:03 +0000 Subject: [PATCH 393/981] Fixups: - lib.gui.display - Catch error when trying to delete non-existant notebook tabs - lib.model.nn_blocks.ResidualBlock: Remove first LeakyReLU (explicit > implicit) - lib.model.nn_blocks._get_default_initializer - Always use ConvAware if selected - plugins.model.model - Add explicit leakyReLUs for ResBlocks --- lib/gui/display.py | 4 +++- lib/model/nn_blocks.py | 20 ++++++++++++-------- plugins/train/model/dfaker.py | 6 +++++- plugins/train/model/dfl_sae.py | 5 ++++- plugins/train/model/dlight.py | 7 ++++++- plugins/train/model/realface.py | 16 ++++++++++++---- plugins/train/model/unbalanced.py | 5 ++++- plugins/train/model/villain.py | 8 +++++++- 8 files changed, 53 insertions(+), 18 deletions(-) diff --git a/lib/gui/display.py b/lib/gui/display.py index de83e2f951..f1ec6be950 100644 --- a/lib/gui/display.py +++ b/lib/gui/display.py @@ -140,7 +140,9 @@ def _remove_tabs(self): continue logger.debug("removing child: %s", child) child_name = child.split(".")[-1] - child_object = self.children[child_name] # returns the OptionalDisplayPage object + child_object = self.children.get(child_name) # returns the OptionalDisplayPage object + if not child_object: + continue child_object.close() # Call the OptionalDisplayPage close() method self.forget(child) diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index 1ef2eb4873..93ee1296f7 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -68,8 +68,9 @@ def _get_default_initializer(initializer): ---------- initializer: :class:`keras.initializers.Initializer` or None The initializer that has been passed into the model. If this value is ``None`` then a - default initializer will be returned based on the configuration choices, otherwise - the given initializer will be returned. + default initializer will be set to 'he_uniform'. If Convolutional Aware initialization + has been enabled, then any passed through initializer will be replaced with the + Convolutional Aware initializer. Returns ------- @@ -77,12 +78,15 @@ def _get_default_initializer(initializer): The kernel initializer to use for this convolutional layer. Either the original given initializer, he_uniform or convolutional aware (if selected in config options) """ - if initializer is None: - retval = ConvolutionAware() if _CONFIG["conv_aware_init"] else he_uniform() - logger.debug("Set default kernel_initializer: %s", retval) + if _CONFIG["conv_aware_init"]: + retval = ConvolutionAware() + elif initializer is None: + retval = he_uniform() else: retval = initializer logger.debug("Using model supplied initializer: %s", retval) + logger.debug("Set default kernel_initializer: (original: %s current: %s)", initializer, retval) + return retval @@ -553,8 +557,8 @@ def __call__(self, inputs): class UpscaleResizeImagesBlock(): # pylint:disable=too-few-public-methods - """ Upscale block that uses the Keras Backend function resize_images to perform the upscaling - Similar in methodolgy to the :class:`Upscale2xBlock` + """ Upscale block that uses the Keras Backend function resize_images to perform the up scaling + Similar in methodology to the :class:`Upscale2xBlock` Adds reflection padding if it has been selected by the user, and other post-processing if requested by the plugin. @@ -675,7 +679,7 @@ def __call__(self, inputs): Tensor The output tensor from the Upscale Layer """ - var_x = LeakyReLU(alpha=0.2, name="{}_leakyrelu_0".format(self._name))(inputs) + var_x = inputs if self._use_reflect_padding: var_x = ReflectionPadding2D(stride=1, kernel_size=self._kernel_size, diff --git a/plugins/train/model/dfaker.py b/plugins/train/model/dfaker.py index 622ff28859..24ea2d5770 100644 --- a/plugins/train/model/dfaker.py +++ b/plugins/train/model/dfaker.py @@ -5,7 +5,7 @@ import sys from keras.initializers import RandomNormal -from keras.layers import Input +from keras.layers import Input, LeakyReLU from lib.model.nn_blocks import Conv2DOutput, UpscaleBlock, ResidualBlock from .original import Model as OriginalModel, KerasModel @@ -32,12 +32,16 @@ def decoder(self, side): if self._output_size == 256: var_x = UpscaleBlock(1024, activation=None)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(1024, kernel_initializer=self.kernel_initializer)(var_x) var_x = UpscaleBlock(512, activation=None)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(512, kernel_initializer=self.kernel_initializer)(var_x) var_x = UpscaleBlock(256, activation=None)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(256, kernel_initializer=self.kernel_initializer)(var_x) var_x = UpscaleBlock(128, activation=None)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(128, kernel_initializer=self.kernel_initializer)(var_x) var_x = UpscaleBlock(64, activation="leakyrelu")(var_x) var_x = Conv2DOutput(3, 5, name="face_out_{}".format(side))(var_x) diff --git a/plugins/train/model/dfl_sae.py b/plugins/train/model/dfl_sae.py index c1896f3073..2b94aada0d 100644 --- a/plugins/train/model/dfl_sae.py +++ b/plugins/train/model/dfl_sae.py @@ -5,7 +5,7 @@ import numpy as np -from keras.layers import Concatenate, Dense, Flatten, Input, Reshape +from keras.layers import Concatenate, Dense, Flatten, Input, LeakyReLU, Reshape from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock @@ -102,18 +102,21 @@ def decoder(self, side, input_shape): var_x = input_ var_x1 = UpscaleBlock(dims * 8, activation=None)(var_x) + var_x1 = LeakyReLU(alpha=0.2)(var_x1) var_x1 = ResidualBlock(dims * 8)(var_x1) var_x1 = ResidualBlock(dims * 8)(var_x1) if self.multiscale_count >= 3: outputs.append(Conv2DOutput(3, 5, name="face_out_32_{}".format(side))(var_x1)) var_x2 = UpscaleBlock(dims * 4, activation=None)(var_x1) + var_x2 = LeakyReLU(alpha=0.2)(var_x2) var_x2 = ResidualBlock(dims * 4)(var_x2) var_x2 = ResidualBlock(dims * 4)(var_x2) if self.multiscale_count >= 2: outputs.append(Conv2DOutput(3, 5, name="face_out_64_{}".format(side))(var_x2)) var_x3 = UpscaleBlock(dims * 2, activation=None)(var_x2) + var_x3 = LeakyReLU(alpha=0.2)(var_x3) var_x3 = ResidualBlock(dims * 2)(var_x3) var_x3 = ResidualBlock(dims * 2)(var_x3) diff --git a/plugins/train/model/dlight.py b/plugins/train/model/dlight.py index fb7e08cbb4..2672a8be01 100644 --- a/plugins/train/model/dlight.py +++ b/plugins/train/model/dlight.py @@ -174,20 +174,24 @@ def decoder_b(self): var_xy = Upscale2xBlock(512, scale_factor=self.upscale_ratio, - activation="leakyrelu", + activation=None, fast=False)(var_xy) var_x = var_xy + var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(512, use_bias=True)(var_x) var_x = ResidualBlock(512, use_bias=False)(var_x) var_x = ResidualBlock(512, use_bias=False)(var_x) var_x = Upscale2xBlock(dec_b_complexity, activation=None, fast=False)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(dec_b_complexity, use_bias=True)(var_x) var_x = ResidualBlock(dec_b_complexity, use_bias=False)(var_x) var_x = BatchNormalization()(var_x) var_x = Upscale2xBlock(dec_b_complexity // 2, activation=None, fast=False)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(dec_b_complexity // 2, use_bias=True)(var_x) var_x = Upscale2xBlock(dec_b_complexity // 4, activation=None, fast=False)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(dec_b_complexity // 4, use_bias=False)(var_x) var_x = BatchNormalization()(var_x) var_x = Upscale2xBlock(dec_b_complexity // 8, activation="leakyrelu", fast=False)(var_x) @@ -198,6 +202,7 @@ def decoder_b(self): if self.config.get("learn_mask", False): var_y = var_xy # mask decoder + var_y = LeakyReLU(alpha=0.1)(var_y) var_y = Upscale2xBlock(mask_complexity, activation="leakyrelu", fast=False)(var_y) var_y = Upscale2xBlock(mask_complexity // 2, activation="leakyrelu", fast=False)(var_y) diff --git a/plugins/train/model/realface.py b/plugins/train/model/realface.py index d21e1db48e..df95fadf94 100644 --- a/plugins/train/model/realface.py +++ b/plugins/train/model/realface.py @@ -10,7 +10,7 @@ import sys from keras.initializers import RandomNormal -from keras.layers import Dense, Flatten, Input, Reshape +from keras.layers import Dense, Flatten, Input, LeakyReLU, Reshape from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock @@ -83,7 +83,8 @@ def encoder(self): encoder_complexity = self.config["complexity_encoder"] for idx in range(self.downscalers_no - 1): - var_x = Conv2DBlock(encoder_complexity * 2**idx, activation="leakyrelu")(var_x) + var_x = Conv2DBlock(encoder_complexity * 2**idx, activation=None)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(encoder_complexity * 2**idx, use_bias=True)(var_x) var_x = ResidualBlock(encoder_complexity * 2**idx, use_bias=True)(var_x) @@ -102,14 +103,16 @@ def decoder_b(self): var_xy = Dense(self.config["dense_nodes"])(Flatten()(var_xy)) var_xy = Dense(self.dense_width * self.dense_width * self.dense_filters)(var_xy) var_xy = Reshape((self.dense_width, self.dense_width, self.dense_filters))(var_xy) - var_xy = UpscaleBlock(self.dense_filters, activation="leakyrelu")(var_xy) + var_xy = UpscaleBlock(self.dense_filters, activation=None)(var_xy) var_x = var_xy + var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(self.dense_filters, use_bias=False)(var_x) decoder_b_complexity = self.config["complexity_decoder"] for idx in range(self.upscalers_no - 2): var_x = UpscaleBlock(decoder_b_complexity // 2**idx, activation=None)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(decoder_b_complexity // 2**idx, use_bias=False)(var_x) var_x = ResidualBlock(decoder_b_complexity // 2**idx, use_bias=True)(var_x) var_x = UpscaleBlock(decoder_b_complexity // 2**(idx + 1), activation="leakyrelu")(var_x) @@ -120,6 +123,8 @@ def decoder_b(self): if self.config.get("learn_mask", False): var_y = var_xy + var_y = LeakyReLU(alpha=0.1)(var_y) + mask_b_complexity = 384 for idx in range(self.upscalers_no-2): var_y = UpscaleBlock(mask_b_complexity // 2**idx, activation="leakyrelu")(var_y) @@ -146,9 +151,10 @@ def decoder_a(self): var_xy = Dense(self.dense_width * self.dense_width * dense_filters)(var_xy) var_xy = Reshape((self.dense_width, self.dense_width, dense_filters))(var_xy) - var_xy = UpscaleBlock(dense_filters, activation="leakyrelu")(var_xy) + var_xy = UpscaleBlock(dense_filters, activation=None)(var_xy) var_x = var_xy + var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(dense_filters, use_bias=False)(var_x) decoder_a_complexity = int(self.config["complexity_decoder"] / 1.5) @@ -162,6 +168,8 @@ def decoder_a(self): if self.config.get("learn_mask", False): var_y = var_xy + var_y = LeakyReLU(alpha=0.1)(var_y) + mask_a_complexity = 384 for idx in range(self.upscalers_no-2): var_y = UpscaleBlock(mask_a_complexity // 2**idx, activation="leakyrelu")(var_y) diff --git a/plugins/train/model/unbalanced.py b/plugins/train/model/unbalanced.py index 64468d9ba0..b122f3c3ae 100644 --- a/plugins/train/model/unbalanced.py +++ b/plugins/train/model/unbalanced.py @@ -4,7 +4,7 @@ code sample + contributions """ from keras.initializers import RandomNormal -from keras.layers import Dense, Flatten, Input, Reshape, SpatialDropout2D +from keras.layers import Dense, Flatten, Input, LeakyReLU, Reshape, SpatialDropout2D from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock from ._base import ModelBase, KerasModel @@ -105,12 +105,15 @@ def decoder_b(self): var_x = UpscaleBlock(decoder_complexity // 8, activation="leakyrelu", **kwargs)(var_x) else: var_x = UpscaleBlock(decoder_complexity, activation=None, **kwargs)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(decoder_complexity, kernel_initializer=self.kernel_initializer)(var_x) var_x = UpscaleBlock(decoder_complexity, activation=None, **kwargs)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(decoder_complexity, kernel_initializer=self.kernel_initializer)(var_x) var_x = UpscaleBlock(decoder_complexity // 2, activation=None, **kwargs)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(decoder_complexity // 2, kernel_initializer=self.kernel_initializer)(var_x) var_x = UpscaleBlock(decoder_complexity // 4, activation="leakyrelu", **kwargs)(var_x) diff --git a/plugins/train/model/villain.py b/plugins/train/model/villain.py index fcd53cd3a4..f9f241c1a6 100644 --- a/plugins/train/model/villain.py +++ b/plugins/train/model/villain.py @@ -4,7 +4,7 @@ Adapted from a model by VillainGuy (https://github.com/VillainGuy) """ from keras.initializers import RandomNormal -from keras.layers import add, Dense, Flatten, Input, Reshape +from keras.layers import add, Dense, Flatten, Input, LeakyReLU, Reshape from lib.model.layers import PixelShuffler from lib.model.nn_blocks import (Conv2DOutput, Conv2DBlock, ResidualBlock, SeparableConv2DBlock, @@ -31,11 +31,14 @@ def encoder(self): var_x = Conv2DBlock(in_conv_filters, activation=None, **kwargs)(input_) tmp_x = var_x + + var_x = LeakyReLU(alpha=0.2)(var_x) res_cycles = 8 if self.config.get("lowmem", False) else 16 for _ in range(res_cycles): nn_x = ResidualBlock(in_conv_filters, **kwargs)(var_x) var_x = nn_x # consider adding scale before this layer to scale the residual chain + tmp_x = LeakyReLU(alpha=0.1)(tmp_x) var_x = add([var_x, tmp_x]) var_x = Conv2DBlock(128, activation="leakyrelu", **kwargs)(var_x) var_x = PixelShuffler()(var_x) @@ -61,10 +64,13 @@ def decoder(self, side): var_x = input_ var_x = UpscaleBlock(512, activation=None, **kwargs)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(512, **kwargs)(var_x) var_x = UpscaleBlock(256, activation=None, **kwargs)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(256, **kwargs)(var_x) var_x = UpscaleBlock(self.input_shape[0], activation=None, **kwargs)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(self.input_shape[0], **kwargs)(var_x) var_x = Conv2DOutput(3, 5, name="face_out_{}".format(side))(var_x) outputs = [var_x] From e111355183f86c857f881c22b4b49ceb3c337bdf Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 4 Mar 2021 17:44:22 +0000 Subject: [PATCH 394/981] trainer - Add RGB model support --- lib/training_data.py | 19 +++++++++++++------ plugins/train/model/_base.py | 10 ++++++---- plugins/train/trainer/_base.py | 8 ++++++++ 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/lib/training_data.py b/lib/training_data.py index c4dc5ac528..ad37f15ec6 100644 --- a/lib/training_data.py +++ b/lib/training_data.py @@ -37,6 +37,8 @@ class TrainingDataGenerator(): # pylint:disable=too-few-public-methods The ratio of the training image to be trained on. Dictates how much of the image will be cropped out. E.G: a coverage ratio of 0.625 will result in cropping a 160px box from a 256px image (:math:`256 * 0.625 = 160`). + color_order: ["rgb", "bgr"] + The color order that the model expects as input augment_color: bool ``True`` if color is to be augmented, otherwise ``False`` no_flip: bool @@ -77,18 +79,19 @@ class TrainingDataGenerator(): # pylint:disable=too-few-public-methods The configuration `dict` generated from :file:`config.train.ini` containing the trainer plugin configuration options. """ - def __init__(self, model_input_size, model_output_shapes, coverage_ratio, augment_color, - no_flip, no_warp, warp_to_landmarks, alignments, config): + def __init__(self, model_input_size, model_output_shapes, coverage_ratio, color_order, + augment_color, no_flip, no_warp, warp_to_landmarks, alignments, config): logger.debug("Initializing %s: (model_input_size: %s, model_output_shapes: %s, " - "coverage_ratio: %s, augment_color: %s, no_flip: %s, no_warp: %s, " - "warp_to_landmarks: %s, alignments: %s, config: %s)", + "coverage_ratio: %s, color_order: %s, augment_color: %s, no_flip: %s, " + "no_warp: %s, warp_to_landmarks: %s, alignments: %s, config: %s)", self.__class__.__name__, model_input_size, model_output_shapes, - coverage_ratio, augment_color, no_flip, no_warp, warp_to_landmarks, - list(alignments.keys()), config) + coverage_ratio, color_order, augment_color, no_flip, no_warp, + warp_to_landmarks, list(alignments.keys()), config) self._config = config self._model_input_size = model_input_size self._model_output_shapes = model_output_shapes self._coverage_ratio = coverage_ratio + self._color_order = color_order.lower() self._augment_color = augment_color self._no_flip = no_flip self._warp_to_landmarks = warp_to_landmarks @@ -239,6 +242,10 @@ def _process_batch(self, filenames, side): if not self._no_flip: batch = self._processing.random_flip(batch) + # Switch color order for RGB models + if self._color_order == "rgb": + batch = batch[..., [2, 1, 0, 3]] + # Add samples to output if this is for display if self._processing.is_display: processed["samples"] = batch[..., :3].astype("float32") / 255.0 diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 1695e69f20..8d928729b5 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -98,6 +98,7 @@ def __init__(self, model_dir, arguments, predict=False): self.input_shape = None # Must be set within the plugin after initializing self.trainer = "original" # Override for plugin specific trainer + self.color_order = "bgr" # Override for plugin specific image color channel order self._args = arguments self._is_predict = predict @@ -650,7 +651,7 @@ def __init__(self, arguments, mixed_precision, allow_growth, is_predict): self._set_tf_settings(allow_growth, arguments.exclude_gpus) use_mixed_precision = not is_predict and mixed_precision and get_backend() == "nvidia" - # Mixed precision moved out of experimental in tf 2.4 + # Mixed precision moved out of experimental in tensorflow 2.4 if use_mixed_precision and self._tf_version[0] == 2 and self._tf_version[1] < 4: self._mixed_precision = tf.keras.mixed_precision.experimental elif use_mixed_precision: @@ -688,7 +689,8 @@ def loss_scale_optimizer(self, optimizer): :class:`tf.keras.mixed_precision.loss_scale_optimizer.LossScaleOptimizer` The original optimizer with loss scaling applied """ - # tf versions < 2.4 had different kwargs where scaling needs to be explicitly defined + # tensorflow versions < 2.4 had different kwargs where scaling needs to be explicitly + # defined vers = self._tf_version kwargs = dict(loss_scale="dynamic") if vers[0] == 2 and vers[1] < 4 else dict() logger.debug("tf version: %s, kwargs: %s", vers, kwargs) @@ -769,7 +771,7 @@ def _set_keras_mixed_precision(self, use_mixed_precision, exclude_gpus): if exclude_gpus and self._tf_version[0] == 2 and self._tf_version[1] == 2: # TODO remove this hacky fix to disable mixed precision compatibility testing when - # tf 2.2 support dropped + # tensorflow 2.2 support dropped # pylint:disable=import-outside-toplevel,protected-access,import-error from tensorflow.python.keras.mixed_precision.experimental import \ device_compatibility_check @@ -1454,7 +1456,7 @@ def _make_inference_model(self, saved_model): next_input = inbound_layer if get_backend() == "amd" and isinstance(next_input, list): - # tf.keras and keras 2.2 behave differently for layer inputs + # tensorflow.keras and keras 2.2 behave differently for layer inputs layer_inputs.extend(next_input) else: layer_inputs.append(next_input) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 07ca298304..b2b539d48b 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -407,6 +407,7 @@ def _load_generator(self, output_index): generator = TrainingDataGenerator(input_size, output_shapes, self._model.coverage_ratio, + self._model.color_order, not self._model.command_line_arguments.no_augment_color, self._model.command_line_arguments.no_flip, self._model.command_line_arguments.no_warp, @@ -794,6 +795,13 @@ def _to_full_frame(self, side, samples, predictions): logger.debug("side: '%s', number of sample arrays: %s, prediction.shapes: %s)", side, len(samples), [pred.shape for pred in predictions]) full, faces = samples[:2] + + if self._model.color_order.lower() == "rgb": # Switch color order for RGB model display + full = full[..., ::-1] + faces = faces[..., ::-1] + predictions = [pred[..., ::-1] if pred.shape[-1] == 3 else pred + for pred in predictions] + full = self._process_full(side, full, predictions[0].shape[1], (0, 0, 255)) images = [faces] + predictions if self._display_mask: From 06ecccf1b8cedbd0b64e0decf0f3ee6e5064f7b1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 8 Mar 2021 13:54:38 +0000 Subject: [PATCH 395/981] lib.gui.stats - Fix graphing for combined decoders --- lib/gui/stats.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/gui/stats.py b/lib/gui/stats.py index fb02d674b0..3b11e89c2a 100644 --- a/lib/gui/stats.py +++ b/lib/gui/stats.py @@ -356,9 +356,11 @@ def _cache_data(self, session_id, is_training=False): # tf2.3 stopped respecting loss names in tensorboard callback so rewrite lbl_split = tag.replace("batch_", "").replace("_loss", "").split("_") if lbl_split[-1] in ("a", "b"): - lbl = "face_{}".format(lbl_split[-1]) + lbl = f"face_{lbl_split[-1]}" + elif "both" in lbl_split: # Combined decoders don't get face names + lbl = f"face_{'b' if lbl_split[-1] == '1' else 'a'}" else: - lbl = "mask_{}".format(lbl_split[-2]) + lbl = f"mask_{lbl_split[-2]}" # TODO may not work for combined decoders if lbl not in labels: labels.append(lbl) From d8d88cb654f4256733c27b730ee71aa291d51adf Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 10 Mar 2021 12:10:05 +0000 Subject: [PATCH 396/981] scripts.convert -RGB model support --- scripts/convert.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/convert.py b/scripts/convert.py index 6a5d2c3eae..9d272048be 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -962,11 +962,19 @@ def _predict(self, feed_faces, batch_size=None): The swapped faces for the given batch """ logger.trace("Predicting: Batchsize: %s", len(feed_faces)) + + if self._model.color_order.lower() == "rgb": + feed_faces = feed_faces[..., ::-1] + feed = [feed_faces] logger.trace("Input shape(s): %s", [item.shape for item in feed]) predicted = self._model.model.predict(feed, batch_size=batch_size) predicted = predicted if isinstance(predicted, list) else [predicted] + + if self._model.color_order.lower() == "rgb": + predicted[0] = predicted[0][..., ::-1] + logger.trace("Output shape(s): %s", [predict.shape for predict in predicted]) # Only take last output(s) From bcaa5596a7dd0f7151228c584f5cd0fb4851c272 Mon Sep 17 00:00:00 2001 From: Artem Ivanov <37909402+andenixa@users.noreply.github.com> Date: Wed, 10 Mar 2021 16:51:46 +0300 Subject: [PATCH 397/981] Russian translations rc1 (no tools) (#1129) * Russian translations rc1 (no tools) * Add parameter for exclude GPU option Co-authored-by: AnDenixa Co-authored-by: torzdf <36920800+torzdf@users.noreply.github.com> --- locales/ru/LC_MESSAGES/faceswap.mo | Bin 0 -> 1043 bytes locales/ru/LC_MESSAGES/faceswap.po | 34 + locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 0 -> 50370 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 896 +++++++++++++++++++++++++ 4 files changed, 930 insertions(+) create mode 100644 locales/ru/LC_MESSAGES/faceswap.mo create mode 100644 locales/ru/LC_MESSAGES/faceswap.po create mode 100644 locales/ru/LC_MESSAGES/lib.cli.args.mo create mode 100644 locales/ru/LC_MESSAGES/lib.cli.args.po diff --git a/locales/ru/LC_MESSAGES/faceswap.mo b/locales/ru/LC_MESSAGES/faceswap.mo new file mode 100644 index 0000000000000000000000000000000000000000..f02a1b0ff3d143ec0fd570bb15b0e55e19a4c3e6 GIT binary patch literal 1043 zcmZuv%Wl&^6g5zUlqG8xmqiN&9Bj8pC9RA4R-{%$C`D`sd(s#swl$tKjnqY3Cxf6mn_?vP!bbslq@`nkLsgraAtS@>+Wd9v$s1KO%J$;$3s{^{=rdzN? zN$d4uUCiDD3x5BBnKR_32>lf(s?zpE8(|7uEZMB2Fu+izd K_>J)flg3}my`;qe literal 0 HcmV?d00001 diff --git a/locales/ru/LC_MESSAGES/faceswap.po b/locales/ru/LC_MESSAGES/faceswap.po new file mode 100644 index 0000000000..2e0f297baf --- /dev/null +++ b/locales/ru/LC_MESSAGES/faceswap.po @@ -0,0 +1,34 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2021-02-18 23:48-0000\n" +"PO-Revision-Date: 2021-02-19 21:30+0300\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 2.4.2\n" +"Last-Translator: \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" +"Language: ru\n" + +#: faceswap.py:43 +msgid "Extract the faces from pictures or a video" +msgstr "Извлечь лица из фотографий или видео" + +#: faceswap.py:44 +msgid "Train a model for the two faces A and B" +msgstr "Обучить модель при помощи лиц A и B" + +#: faceswap.py:47 +msgid "Convert source pictures or video to a new one with the face swapped" +msgstr "Преобразование исходных изображений или видео в новое с замененным лицом" + +#: faceswap.py:48 +msgid "Launch the Faceswap Graphical User Interface" +msgstr "Запуск графического интерфейса Faceswap" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo new file mode 100644 index 0000000000000000000000000000000000000000..778c3cbedc978d79d22d2e6fa5dee5ae95af1884 GIT binary patch literal 50370 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 HCyM_I$PcxW literal 0 HcmV?d00001 diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.po b/locales/ru/LC_MESSAGES/lib.cli.args.po new file mode 100644 index 0000000000..c0e8a733ce --- /dev/null +++ b/locales/ru/LC_MESSAGES/lib.cli.args.po @@ -0,0 +1,896 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2021-02-18 23:45-0000\n" +"PO-Revision-Date: 2021-03-10 13:49+0000\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 2.4.2\n" +"Last-Translator: \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" +"Language: ru\n" + +#: lib/cli/args.py:177 lib/cli/args.py:187 lib/cli/args.py:195 +#: lib/cli/args.py:205 +msgid "Global Options" +msgstr "Общие настройки" + +#: lib/cli/args.py:178 +msgid "" +"R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " +"to any GPU(s) that you do not wish to be made available to Faceswap. " +"Selecting all GPUs here will force Faceswap into CPU mode.\n" +"L|{}" +msgstr "" +"R|Не использовать GPU для Faceswap. Выберите номер(а), которые соответствуют " +"тем GPU, которые вы не хотите использовать в Faceswap. При отключении всех " +"GPU Faceswap будет работать в режиме CPU.\n" +"L|{}" + +#: lib/cli/args.py:188 +msgid "" +"Optionally overide the saved config with the path to a custom config file." +msgstr "" +"Переназначить путь к файлу конфигурации пользовательским. (Необязательно)" + +#: lib/cli/args.py:196 +msgid "" +"Log level. Stick with INFO or VERBOSE unless you need to file an error " +"report. Be careful with TRACE as it will generate a lot of data" +msgstr "" +"Уровень записи журнала. Придерживайтесь уровней INFO или VERBOSE, кроме " +"случаев когда вам нужно отправить отчёт об ошибке. Будьте осторожнее при " +"указании уровня TRACE, так как будет сгенерировано очень много данных" + +#: lib/cli/args.py:206 +msgid "Path to store the logfile. Leave blank to store in the faceswap folder" +msgstr "" +"Путь для сохранения файла журнала. Оставьте пустым, чтобы сохранить в папке " +"с faceswap" + +#: lib/cli/args.py:299 lib/cli/args.py:308 lib/cli/args.py:316 +#: lib/cli/args.py:627 lib/cli/args.py:636 +msgid "Data" +msgstr "Данные" + +#: lib/cli/args.py:300 +msgid "" +"Input directory or video. Either a directory containing the image files you " +"wish to process or path to a video file. NB: This should be the source video/" +"frames NOT the source faces." +msgstr "" +"Входная папка либо видео файл. Папка с набором фотографий для обработки либо " +"видео файл. Примечание: должно указывать на исходное видео либо набор " +"извлеченных кадров, а НЕ уже извлеченных лица." + +#: lib/cli/args.py:309 +msgid "Output directory. This is where the converted files will be saved." +msgstr "Папка для сохранения преобразованных файлов." + +#: lib/cli/args.py:317 +msgid "" +"Optional path to an alignments file. Leave blank if the alignments file is " +"at the default location." +msgstr "Путь к файлу выравнивания. Оставьте пустым, для пути по умолчанию." + +#: lib/cli/args.py:340 +msgid "" +"Extract faces from image or video sources.\n" +"Extraction plugins can be configured in the 'Settings' Menu" +msgstr "" +"Извлечь лица из изображений или видео источников.\n" +"Плагины извлечения можно настроить в меню 'Настройки'" + +#: lib/cli/args.py:365 lib/cli/args.py:381 lib/cli/args.py:393 +#: lib/cli/args.py:425 lib/cli/args.py:443 lib/cli/args.py:455 +#: lib/cli/args.py:646 lib/cli/args.py:671 lib/cli/args.py:698 +msgid "Plugins" +msgstr "Плагины" + +#: lib/cli/args.py:366 +msgid "" +"R|Detector to use. Some of these have configurable settings in '/config/" +"extract.ini' or 'Settings > Configure Extract 'Plugins':\n" +"L|cv2-dnn: A CPU only extractor which is the least reliable and least " +"resource intensive. Use this if not using a GPU and time is important.\n" +"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " +"than other GPU detectors but can often return more false positives.\n" +"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " +"fewer false positives than other GPU detectors, but is a lot more resource " +"intensive." +msgstr "" +"R|Тип детектора. Некоторые могут быть настроенны через '/config/extract.ini' " +"либо 'Settings > Configure Extract 'Plugins':\n" +"L|cv2-dnn: Работает только на CPU, наименее надежный и наименее требователен " +"к ресурсам. Используйте если для вас очень важна скорость, а также не " +"использовать GPU .\n" +"L|mtcnn: Хороший детектор. Быстрый на CPU, ещё быстрее на GPU. Использует " +"меньше ресурсов, нежели другие GPU детекторы, но может производить больше " +"ложных положительных детектирований.\n" +"L|s3fd: Лучший детектор. Медленный на CPU, быстре на GPU. Может " +"детектировать лицо в большем кол-ве ситуация и меньшим кол-вом ошибок, чем " +"другие GPU, но значительно более требователен к ресурсам." + +#: lib/cli/args.py:382 +msgid "" +"R|Aligner to use.\n" +"L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " +"but less accurate. Only use this if not using a GPU and time is important.\n" +"L|fan: Best aligner. Fast on GPU, slow on CPU." +msgstr "" +"R|Выравнивание лица.\n" +"L|cv2-dnn: Детектор меток лица, только для CPU. Быстрый, не требователен к " +"ресурсам, но менее точный. Используйте только если вам необходимо не " +"использовать GPU.\n" +"L|fan: Лучший выравниватель. Быстрый на GPU, медленный на CPU." + +#: lib/cli/args.py:394 +msgid "" +"R|Additional Masker(s) to use. The masks generated here will all take up GPU " +"RAM. You can select none, one or multiple masks, but the extraction may take " +"longer the more you select. NB: The Extended and Components (landmark based) " +"masks are automatically generated on extraction.\n" +"L|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.\n" +"L|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.\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.\n" +"The auto generated masks are as follows:\n" +"L|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.\n" +"L|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.\n" +"(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" +msgstr "" +"R|Создание доп. масок. Генерация масок требует дополнительной памяти GPU. Вы " +"можете выбрать none, одну, или несколько масок, но процес извлечение может " +"занять больше времени в зависимости от выбора. Прим.: Маски Extended и " +"Components (на основе меток лица) всегда создаются автоматически при " +"извлечении лиц.\n" +"L|vgg-clear: Маска предназначена для умной сегментации преимущественно " +"фронтальных лиц без препятствий. Фотографии в профиль могут быть обработаны " +"посредственно.\n" +"L|vgg-obstructed: Маска предназначена для умной сегментации преимущественно " +"фронтальных лиц. Эта маска была обучена распознавать некоторые препятствия, " +"такие как руки и очки. Фотографии в профиль могут быть обработаны " +"посредственно.\n" +"L|unet-dfl: Маска предназначена для умной сегментации преимущественно " +"фронтальных лиц. Маска была обучена силами участников сообщества и нуждается " +"в тестировании. Фотографии в профиль могут быть обработаны посредственно.\n" +"Следующие маски создаются автоматически:\n" +"L|components: Маска предназначена для сегментации лица на основе ориентиров " +"лица. Маска создается путем построения выпуклого полигона вокруг внешних " +"ориентиров лица.\n" +"L|extended: Маска предназначена для сегментации лица на основе ориентиров " +"лица. Маска создается путем построения выпуклого полигона вокруг внешних " +"ориентиров лица и расширяется вверх на лоб.\n" +"(пример: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" + +#: lib/cli/args.py:426 +msgid "" +"R|Performing normalization can help the aligner better align faces with " +"difficult lighting conditions at an extraction speed cost. Different methods " +"will yield different results on different sets. NB: This does not impact the " +"output face, just the input to the aligner.\n" +"L|none: Don't perform normalization on the face.\n" +"L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " +"face.\n" +"L|hist: Equalize the histograms on the RGB channels.\n" +"L|mean: Normalize the face colors to the mean." +msgstr "" +"R|Нормализация может помочь выравниванию лиц при сложных условиях освещения, " +"ценой снижения скорости. Различные методы дают разные результаты в " +"зависимости от набора лиц. Прим.: Не влияет на вывод лица, только на " +"выравнивание.\n" +"L|none: Не производить нормализацию картинки лица.\n" +"L|clahe: Производить нормализацию методом CLAHE.\n" +"L|hist: Выравнивание гистограммы каналов RGB каналов.\n" +"L|mean: Усреднение цветов лица." + +#: lib/cli/args.py:444 +msgid "" +"The number of times to re-feed the detected face into the aligner. Each time " +"the face is re-fed into the aligner the bounding box is adjusted by a small " +"amount. The final landmarks are then averaged from each iteration. Helps to " +"remove 'micro-jitter' but at the cost of slower extraction speed. The more " +"times the face is re-fed into the aligner, the less micro-jitter should " +"occur but the longer extraction will take." +msgstr "" +"Кол-во проходов выравнивания после обнаружения лица. Каждый раз при " +"повторном выравнивании рамка лица немного корректируется. Окончательные " +"ориентиры затем усредняются. Помогает устранить «микроджиттер», но за счет " +"замедления скорости извлечения. Чем больше проходов выравнивания, тем меньше " +"микродрожание, но тем дольше идет извлечение." + +#: lib/cli/args.py:456 +msgid "" +"If a face isn't found, rotate the images to try to find a face. Can find " +"more faces at the cost of extraction speed. Pass in a single number to use " +"increments of that size up to 360, or pass in a list of numbers to enumerate " +"exactly what angles to check." +msgstr "" +"Если лицо не найдено, поворачивает картинку, чтобы попытаться найти лицо. " +"Может найти больше лиц ценой скорости извлечения. Укажите число, чтобы " +"использовать приращения этого размера до 360, либо передайте список чисел, " +"чтобы точно указать, какие углы проверять." + +#: lib/cli/args.py:468 lib/cli/args.py:478 lib/cli/args.py:491 +#: lib/cli/args.py:505 lib/cli/args.py:735 lib/cli/args.py:749 +#: lib/cli/args.py:762 lib/cli/args.py:776 +msgid "Face Processing" +msgstr "Обработка лиц" + +#: lib/cli/args.py:469 +msgid "" +"Filters out faces detected below this size. Length, in pixels across the " +"diagonal of the bounding box. Set to 0 for off" +msgstr "" +"Отбрасывает лица ниже указанного размера. Длина указывается в пикселях по " +"диагонали. Установите в 0 для отключения" + +#: lib/cli/args.py:479 lib/cli/args.py:750 +msgid "" +"Optionally filter out people who you do not wish to process by passing in an " +"image of that person. Should be a front portrait with a single person in the " +"image. Multiple images can be added space separated. NB: Using face filter " +"will significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"Дополнительно вы можете отфильтровать лица людей, которых вы не хотите " +"обрабатывать указав изображение этого человека. На изображении должен быть " +"фронтальный портрет одного человека . Можно указать несколько файлов через " +"пробел. Прим.: Фильтрация лиц существенно снижает скорость извлечения, при " +"этом точность не гарантируется." + +#: lib/cli/args.py:492 lib/cli/args.py:763 +msgid "" +"Optionally select people you wish to process by passing in an image of that " +"person. Should be a front portrait with a single person in the image. " +"Multiple images can be added space separated. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"Дополнительно вы можете выбрать людей, которых вы хотели бы включить в " +"обработку путем указания изображения этого человека. Должен быть фронтальный " +"портрет с лишь одним человеком на картинке. Можно выбрать несколько " +"изображений через пробел. Прим.: Использование фильтра существенно замедлит " +"скорость извлечения. Также точность не гарантируется." + +#: lib/cli/args.py:506 lib/cli/args.py:777 +msgid "" +"For use with the optional nfilter/filter files. Threshold for positive face " +"recognition. Lower values are stricter. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"Только при использовании файлов nfilter/filter. Порог для распознавания " +"лица. Чем ниже значения, тем строже. Прим.: Использование фильтра лиц " +"существенно замедлит скорость извлечения. Также точность не гарантируется." + +#: lib/cli/args.py:517 lib/cli/args.py:529 lib/cli/args.py:541 +#: lib/cli/args.py:553 +msgid "output" +msgstr "вывод" + +#: lib/cli/args.py:518 +msgid "" +"The output size of extracted faces. Make sure that the model you intend to " +"train supports your required size. This will only need to be changed for hi-" +"res models." +msgstr "" +"Размер извлекаемых лиц в пикселях. Убедитесь, что выбранная Вами модель " +"поддерживает такой входной размер. Стоит изменять только для моделей " +"высокого разрешения." + +#: lib/cli/args.py:530 +msgid "" +"Extract every 'nth' frame. This option will skip frames when extracting " +"faces. For example a value of 1 will extract faces from every frame, a value " +"of 10 will extract faces from every 10th frame." +msgstr "" +"Обрабатывать каждые N кадров. Эта опция будет пропускать лица при " +"извлечении. Например, значение 1 будет искать лица в каждом кадре, а " +"значение 10 в каждом 10том кадре." + +#: lib/cli/args.py:542 +msgid "" +"Automatically save the alignments file after a set amount of frames. By " +"default the alignments file is only saved at the end of the extraction " +"process. NB: If extracting in 2 passes then the alignments file will only " +"start to be saved out during the second pass. WARNING: Don't interrupt the " +"script when writing the file because it might get corrupted. Set to 0 to " +"turn off" +msgstr "" +"Автоматически сохранять файл выравнивания после указанного кол-ва кадров. По " +"умолчанию файл выравнивания сохраняется только в конце процедуры извлечения. " +"Прим.: При извлечении в 2 прохода, файл выравниваний начнёт сохранение " +"только во время второго прохода. ВНИМАНИЕ: Не прерывайте выполнение во время " +"записи, так как это может повлечь порчу файла. Установите в 0 для выключения" + +#: lib/cli/args.py:554 +msgid "Draw landmarks on the ouput faces for debugging purposes." +msgstr "Рисовать ландмарки на выходных лицах для нужд отладки." + +#: lib/cli/args.py:560 lib/cli/args.py:569 lib/cli/args.py:577 +#: lib/cli/args.py:584 lib/cli/args.py:789 lib/cli/args.py:800 +#: lib/cli/args.py:808 lib/cli/args.py:827 lib/cli/args.py:833 +msgid "settings" +msgstr "настройки" + +#: lib/cli/args.py:561 +msgid "" +"Don't run extraction in parallel. Will run each part of the extraction " +"process separately (one after the other) rather than all at the smae time. " +"Useful if VRAM is at a premium." +msgstr "" +"Не проводить параллельное извлечение. Вместо одновременного запуска, каждая " +"стадия извлечения будет запущена отдельно (одна, за другой). Полезно при " +"нехватке VRAM." + +#: lib/cli/args.py:570 +msgid "" +"Skips frames that have already been extracted and exist in the alignments " +"file" +msgstr "" +"Пропускать кадры, которые уже были извлечены и существуют в файле " +"выравнивания" + +#: lib/cli/args.py:578 +msgid "Skip frames that already have detected faces in the alignments file" +msgstr "Пропускать кадры, для которых в файле выравнивания есть найденные лица" + +#: lib/cli/args.py:585 +msgid "Skip saving the detected faces to disk. Just create an alignments file" +msgstr "" +"Не сохранять найденные лица на носитель. Просто создать файл выравнивания" + +#: lib/cli/args.py:607 +msgid "" +"Swap the original faces in a source video/images to your final faces.\n" +"Conversion plugins can be configured in the 'Settings' Menu" +msgstr "" +"Заменить оригиналы лица в исходном видео/фотографиях новыми.\n" +"Плагины конвертации могут быть настроены в меню 'Настройки'" + +#: lib/cli/args.py:628 +msgid "" +"Only required if converting from images to video. Provide The original video " +"that the source frames were extracted from (for extracting the fps and " +"audio)." +msgstr "" +"Нужно указывать лишь при конвертации из набора картинок в видео. " +"Предоставьте исходное видео, из которого были извлечены кадры (для настройки " +"частоты кадров, а также аудио)." + +#: lib/cli/args.py:637 +msgid "" +"Model directory. The directory containing the trained model you wish to use " +"for conversion." +msgstr "" +"Папка с моделью. Папка, содержащая обученную модель, которую вы хотите " +"использовать для преобразования." + +#: lib/cli/args.py:647 +msgid "" +"R|Performs color adjustment to the swapped face. Some of these options have " +"configurable settings in '/config/convert.ini' or 'Settings > Configure " +"Convert Plugins':\n" +"L|avg-color: Adjust the mean of each color channel in the swapped " +"reconstruction to equal the mean of the masked area in the original image.\n" +"L|color-transfer: Transfers the color distribution from the source to the " +"target image using the mean and standard deviations of the L*a*b* color " +"space.\n" +"L|manual-balance: Manually adjust the balance of the image in a variety of " +"color spaces. Best used with the Preview tool to set correct values.\n" +"L|match-hist: Adjust the histogram of each color channel in the swapped " +"reconstruction to equal the histogram of the masked area in the original " +"image.\n" +"L|seamless-clone: Use cv2's seamless clone function to remove extreme " +"gradients at the mask seam by smoothing colors. Generally does not give very " +"satisfactory results.\n" +"L|none: Don't perform color adjustment." +msgstr "" +"R|Производит подгонку цветов в измененном лице. Некоторые из этих опций " +"имеют настройки в файле '/config/convert.ini' либо 'Настройки > Настроить " +"Плагины Конверсии':\n" +"L|avg-color: Подогнать среднее значение каждого цветового канала в " +"замененном лице так, чтобы оно равнялось среднему значению области маски " +"исходного изображения.\n" +"L|color-transfer: Переносит распределение цвета от источника к целевому " +"изображению с использованием среднего и стандартного отклонения цветового " +"пространства L * a * b *.\n" +"L|manual-balance: Ручная настройка баланса изображения в различных цветовых " +"пространствах. Лучше всего использовать с инструментом предварительного " +"просмотра для установки правильных значений.\n" +"L|match-hist: Подгонять гистограмму каждого цветового канала нового лица, " +"гистограммой области маски исходного изображения\n" +"L|seamless-clone: Исп. фунцю cv2's незаметного переноса чтобы убрать " +"экстремальные градиенты на краях маски путём сглаживания цветов. Обычно не " +"дает удовлетворительных результатов.\n" +"L|none: Не производить подгонку цвета." + +#: lib/cli/args.py:672 +msgid "" +"R|Masker to use. NB: The mask you require must exist within the alignments " +"file. You can add additional masks with the Mask Tool.\n" +"L|none: Don't use a mask.\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\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." +msgstr "" +"R|Использовать маску. Прим.: Требуемая маска должна наличествовать в файле " +"выравнивания. Доп. маски можно добавить через Инструмент Создания Масок.\n" +"L|none: Не использовать маску.\n" +"L | компоненты: маска, предназначенная для сегментации лица на основе " +"найденных ориентиров. Маска создается построением выпуклого многоугольника " +"вокруг внешних ориентиров лица.\n" +"L | расширенный: маска, предназначенная для сегментации лица на основе " +"расположения ориентиров. Маска создается построением выпуклого " +"многоугольника вокруг внешних ориентиров лица и продолжается вверх на лоб.\n" +"L | vgg-clear: маска, предназначенная для умной сегментации преимущественно " +"фронтальных лиц без препятствий. Лица в профиль и препятствия могут привести " +"к некачественным результатам.\n" +"L | vgg-obstructed: маска, предназначенная для умной сегментации " +"преимущественно фронтальных лиц. Модель маски специально обучена " +"распознавать некоторые лицевые препятствия (руки и очки). Лица в профиль " +"могут привести к некачественным результатам..\n" +"L | unet-dfl: маска, предназначенная для умной сегментации преимущественно " +"фронтальных лиц. Модель маски была обучена членами сообщества и потребует " +"тестирования для дальнейшего описания. Лица в профиль могут привести к " +"некачественным результатам.." + +#: lib/cli/args.py:699 +msgid "" +"R|The plugin to use to output the converted images. The writers are " +"configurable in '/config/convert.ini' or 'Settings > Configure Convert " +"Plugins:'\n" +"L|ffmpeg: [video] Writes out the convert straight to video. When the input " +"is a series of images then the '-ref' (--reference-video) parameter must be " +"set.\n" +"L|gif: [animated image] Create an animated gif.\n" +"L|opencv: [images] The fastest image writer, but less options and formats " +"than other plugins.\n" +"L|pillow: [images] Slower than opencv, but has more options and supports " +"more formats." +msgstr "" +"R|Тип плагина для вывода конвертированных изображений. Записывающие плагины " +"можно настроить в '/config/convert.ini' либо 'Настройки > Настроить Плагины " +"Конверсии:'\n" +"L|ffmpeg: [видео] Записывает результат конверсии сразу в видео файл. Если " +"входом является серий изображений, то нужно также указать параметр '-ref' (--" +"reference-video).\n" +"L|gif: [анимированное изображение] Создает анимированный gif.\n" +"L|opencv: [изображения] Наибыстрейший способ записи, но с меньшим кол-вом " +"опций и форматов вывода.\n" +"L|pillow: [изображения] Более медленный, чем opencv, но имеет больше опций и " +"поддерживает больше форматов." + +#: lib/cli/args.py:718 lib/cli/args.py:725 lib/cli/args.py:819 +msgid "Frame Processing" +msgstr "Обработка кадров" + +#: lib/cli/args.py:719 +msgid "" +"Scale the final output frames by this amount. 100%% will output the frames " +"at source dimensions. 50%% at half size 200%% at double size" +msgstr "" +"Масштабировать оконечные кадры до указанного процента. 100%% будет выводить " +"кадры в исходном размере. 50%% половина от размера, а 200%% в удвоенном " +"размере" + +#: lib/cli/args.py:726 +msgid "" +"Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " +"--frame-ranges 10-50 90-100. Frames falling outside of the selected range " +"will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " +"converting from images, then the filenames must end with the frame-number!" +msgstr "" +"Диапазон кадров к которым применять перенос, например, для кадров от 10 до " +"50, и 90 до 100 укажите: --frame-ranges 10-50 90-100. Кадры попадающие вне " +"выбранного диапазона будут отброшены если не указано '-k' (--keep-" +"unchanged). Прим.: Если при конверсии используются изображения, то имена " +"файлов должны заканчиваться номером кадра!" + +#: lib/cli/args.py:736 +msgid "" +"If you have not cleansed your alignments file, then you can filter out faces " +"by defining a folder here that contains the faces extracted from your input " +"files/video. If this folder is defined, then only faces that exist within " +"your alignments file and also exist within the specified folder will be " +"converted. Leaving this blank will convert all faces that exist within the " +"alignments file." +msgstr "" +"Если вы не вычистили ваш файл выравниваний, то вы можете отфильтровать лица " +"указав здесь папку, которая содержит лица извлеченные из входных файлов/" +"видео. Если эта папка указана, то, только лица, которые существуют в файле " +"выравниваний и ТАКЖЕ существуют в указанной папке будут сконвертированы. " +"Если оставить это поле пустым, то все лица, которые существуют в файле " +"выравниваний будут сконвертированы." + +#: lib/cli/args.py:790 +msgid "" +"The maximum number of parallel processes for performing conversion. " +"Converting images is system RAM heavy so it is possible to run out of memory " +"if you have a lot of processes and not enough RAM to accommodate them all. " +"Setting this to 0 will use the maximum available. No matter what you set " +"this to, it will never attempt to use more processes than are available on " +"your system. If singleprocess is enabled this setting will be ignored." +msgstr "" +"Максимальное количество параллельных процессов для выполнения " +"преобразования. Преобразование изображений требует большого объема системной " +"памяти, поэтому возможна ее нехватка, если у вас много процессов и не " +"хватает памяти для их всех. Установка этого значения на 0 будет использовать " +"максимально доступное значение. Независимо от ваших установок, никогда не " +"будет использоваться больше процессов, чем доступно в вашей системе. Если " +"включен одиночный процесс, этот параметр будет проигнорирован." + +#: lib/cli/args.py:801 +msgid "" +"[LEGACY] This only needs to be selected if a legacy model is being loaded or " +"if there are multiple models in the model folder" +msgstr "" +"[СОВМЕСТИМОСТЬ] Это нужно выбирать только в том случае, если загружается " +"устаревшая модель или если в папке сохранения есть несколько моделей" + +#: lib/cli/args.py:809 +msgid "" +"Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " +"alignments file for your destination video. However, if you wish you can " +"generate the alignments on-the-fly by enabling this option. This will use an " +"inferior extraction pipeline and will lead to substandard results. If an " +"alignments file is found, this option will be ignored." +msgstr "" +"Включить преобразование на лету. НЕ рекомендуется. Вам стоит создать чистый " +"файл выравнивания для вашего целевого видео. Однако, если вы хотите, вы " +"можете сгенерировать выравнивания на лету, включив эту опцию. Это приведет к " +"использованию улучшенного конвейера экстракции и некачественных результатов. " +"Если файл выравниваний найден, этот параметр будет проигнорирован." + +#: lib/cli/args.py:820 +msgid "" +"When used with --frame-ranges outputs the unchanged frames that are not " +"processed instead of discarding them." +msgstr "" +"При использовании с --frame-range кадры не попавшие в диапазон выводятся " +"неизменными, вместо их пропуска." + +#: lib/cli/args.py:828 +msgid "Swap the model. Instead converting from of A -> B, converts B -> A" +msgstr "" +"Поменять модели местами. Вместо преобразования из A -> B, преобразует B -> A" + +#: lib/cli/args.py:834 +msgid "Disable multiprocessing. Slower but less resource intensive." +msgstr "Отключить многопроцессорность. Медленнее, но менее ресурсоемко." + +#: lib/cli/args.py:850 +msgid "" +"Train a model on extracted original (A) and swap (B) faces.\n" +"Training models can take a long time. Anything from 24hrs to over a week\n" +"Model plugins can be configured in the 'Settings' Menu" +msgstr "" +"Начать обучение модели используя наборы лиц: (A) - исходное лицо и (B) - " +"новое лицо.\n" +"Обучение моделей может занять долгое время: от 24 часов до недели\n" +"Каждую модель можно отдельно настроить в меню «Настройки»" + +#: lib/cli/args.py:869 lib/cli/args.py:880 lib/cli/args.py:889 +#: lib/cli/args.py:900 +msgid "faces" +msgstr "лица" + +#: lib/cli/args.py:870 +msgid "" +"Input directory. A directory containing training images for face A. This is " +"the original face, i.e. the face that you want to remove and replace with " +"face B." +msgstr "" +"Входная папка. Папка содержащая изображения для тренировки лица A. Это " +"исходное лицо т.е. лицо, которое вы хотите убрать, заменив лицом B." + +#: lib/cli/args.py:881 +msgid "" +"DEPRECATED - This option will be removed in a future update. Path to " +"alignments file for training set A. Defaults to /alignments.json if " +"not provided." +msgstr "" +"УСТАРЕЛО - Эта настройка будет удалена в будущих обновлениях. Путь к файлу " +"выравнивания для обучающего набора A. По умолчанию используется /" +"alignments.json, если он не указан." + +#: lib/cli/args.py:890 +msgid "" +"Input directory. A directory containing training images for face B. This is " +"the swap face, i.e. the face that you want to place onto the head of person " +"A." +msgstr "" +"Входная папка. Папка содержащая изображения для тренировки лица B. Это новое " +"лицо т.е. лицо, которое вы хотите поместить на голову человека A." + +#: lib/cli/args.py:901 +msgid "" +"DEPRECATED - This option will be removed in a future update. Path to " +"alignments file for training set B. Defaults to /alignments.json if " +"not provided." +msgstr "" +"УСТАРЕЛО - Эта настройка будет удалена в будущих обновлениях. Путь к файлу " +"выравнивания для обучающего набора B. По умолчанию используется /" +"alignments.json, если он не указан." + +#: lib/cli/args.py:909 lib/cli/args.py:921 +msgid "model" +msgstr "модель" + +#: lib/cli/args.py:910 +msgid "" +"Model directory. This is where the training data will be stored. You should " +"always specify a new folder for new models. If starting a new model, select " +"either an empty folder, or a folder which does not exist (which will be " +"created). If continuing to train an existing model, specify the location of " +"the existing model." +msgstr "" +"Папка сохранений модели. Здесь сохраняется прогресс тренировки. Следует " +"всегда создавать новую папку для новых моделей. При начале тренировки новой " +"модели, выберите пустую либо несуществующую папку (во втором случае она " +"будет создана). Если вы хотите продолжить тренировку, выберите папку с уже " +"существующими сохранениями." + +#: lib/cli/args.py:922 +msgid "" +"R|Select which trainer to use. Trainers can be configured from the Settings " +"menu or the config folder.\n" +"L|original: The original model created by /u/deepfakes.\n" +"L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " +"for full dfaker method.\n" +"L|dfl-h128: 128px in/out model from deepfacelab\n" +"L|dfl-sae: Adaptable model from deepfacelab\n" +"L|dlight: A lightweight, high resolution DFaker variant.\n" +"L|iae: A model that uses intermediate layers to try to get better details\n" +"L|lightweight: A lightweight model for low-end cards. Don't expect great " +"results. Can train as low as 1.6GB with batch size 8.\n" +"L|realface: A high detail, dual density model based on DFaker, with " +"customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " +"won't work so well. By andenixa et al. Very configurable.\n" +"L|unbalanced: 128px in/out model from andenixa. The autoencoders are " +"unbalanced so B>A swaps won't work so well. Very configurable.\n" +"L|villain: 128px in/out model from villainguy. Very resource hungry (You " +"will require a GPU with a fair amount of VRAM). Good for details, but more " +"susceptible to color differences." +msgstr "" +"R|Выберите тренера для использования. Тренеры могут быть настроенны через " +"меню Настройки либо в папке config.\n" +"L|original: Оригинальная модель созданная /u/deepfakes.\n" +"L|dfaker: модель с 64px вход/128px выходом от dfaker. Включите 'warp-to-" +"landmarks' для полного соответствия методу dfaker.\n" +"L|dfl-h128: 128px вход/выход модель от deepfacelab\n" +"L|dfl-sae: Адаптивная модель от deepfacelab\n" +"L|dlight: Легковесная модель высокого разрешения. Один из вариантов DFaker.\n" +"L|iae: Модель использующая промежуточные слои, для достижения лучшей " +"детализции\n" +"L|lightweight: Легковесная модель для младшей линейки видеокарт. Не ожидайте " +"хороших результатов. Может тренировать на картах с 1.6Гб памяти при размере " +"серии 8.\n" +"L|realface: Модель повышенной детализации, с двумя сложносоставными слоями, " +"базированная на DFaker, с настраиваемым разрешением входа/выхода. " +"Автоэнкодеры не сбалансированы, поэтому свапы B>A не дадут хорошего " +"качества. andenixa и другие. Очень настраиваемая.\n" +"L|unbalanced: Модель 128px вход/выход от andenixa. Автоэнкодеры не " +"сбалансированы, поэтому свапы B>A не будут очень хорошими. Очень " +"настраеваемая.\n" +"L|villain: Модель 128px вход/выход от villainguy. Очень требовательна к " +"ресурсам (Вам потребуется GPU с хорошим количеством видеопамяти). Хороша для " +"деталей, но подвержена к неправильной передаче цвета." + +#: lib/cli/args.py:949 lib/cli/args.py:961 lib/cli/args.py:972 +#: lib/cli/args.py:1058 +msgid "training" +msgstr "тренировка" + +#: lib/cli/args.py:950 +msgid "" +"Batch size. This is the number of images processed through the model for " +"each side per iteration. NB: As the model is fed 2 sides at a time, the " +"actual number of images within the model at any one time is double the " +"number that you set here. Larger batches require more GPU RAM." +msgstr "" +"Размер партии. Это количество изображений для каждой стороны, которые " +"обрабатываются моделью за одну итерацию. Примечание: Поскольку в модель " +"передается сразу две стороны за раз, реальное количество загружаемых " +"изображений в два раза больше этого числа. Увеличение размера партии требует " +"больше памяти GPU." + +#: lib/cli/args.py:962 +msgid "" +"Length of training in iterations. This is only really used for automation. " +"There is no 'correct' number of iterations a model should be trained for. " +"You should stop training when you are happy with the previews. However, if " +"you want the model to stop automatically at a set number of iterations, you " +"can set that value here." +msgstr "" +"Кол-во итераций для тренировки. Используется только для автоматизирования. " +"Не существует \"правильного\" кол-ва итераций для любой выбранной модели. " +"Тренировку стоит завершать только когда вы довольны кадрами на превью. " +"Однако, если вы хотите, чтобы тренировка прервалась после указанного кол-ва " +"итерация, вы можете ввести это здесь." + +#: lib/cli/args.py:973 +msgid "" +"Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." +msgstr "" +"Использовать стратегию зеркального распределения Tensorflow для совместной " +"тренировки сразу на нескольких GPU." + +#: lib/cli/args.py:983 lib/cli/args.py:993 +msgid "Saving" +msgstr "Сохранение" + +#: lib/cli/args.py:984 +msgid "Sets the number of iterations between each model save." +msgstr "Установка количества итераций между сохранениями модели." + +#: lib/cli/args.py:994 +msgid "" +"Sets the number of iterations before saving a backup snapshot of the model " +"in it's current state. Set to 0 for off." +msgstr "" +"Устанавливает кол-во итераций перед созданием резервной копии модели. " +"Установите в 0 для отключения." + +#: lib/cli/args.py:1001 lib/cli/args.py:1012 lib/cli/args.py:1023 +msgid "timelapse" +msgstr "таймлапс" + +#: lib/cli/args.py:1002 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. " +"This should be the input folder of 'A' faces that you would like to use for " +"creating the timelapse. You must also supply a --timelapse-output and a --" +"timelapse-input-B parameter." +msgstr "" +"Только при создании таймлапсов. Сохраняет предварительный просмотр выбранных " +"лиц в папку timelapse-output при каждом сохранении. Следует указать входную " +"папку лиц набора 'A' для использования при создании таймлапса. Вам также " +"нужно указать параметры--timelapse-output и --timelapse-input-B." + +#: lib/cli/args.py:1013 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. " +"This should be the input folder of 'B' faces that you would like to use for " +"creating the timelapse. You must also supply a --timelapse-output and a --" +"timelapse-input-A parameter." +msgstr "" +"Только при создании таймлапса. Таймлапс будет сохранять изображения " +"выбранных лиц в папке таймлапсов при каждой итерации сохранения. Это должна " +"быть папка для ввода лиц из набора 'B', для использования в создании " +"таймлапса. Вы также должны указать параметр --timelapse-output и --timelapse-" +"input-A." + +#: lib/cli/args.py:1024 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. If " +"the input folders are supplied but no output folder, it will default to your " +"model folder /timelapse/" +msgstr "" +"Опционально, при создании таймлапса. Создаст картинку текущего таймлапса " +"выбранных лиц в папке timelapse-output при каждом сохранении модели. Если " +"указаны только входные папки, то по умолчанию вывод будет сохранен вместе с " +"моделью в подкаталог /timelapse/" + +#: lib/cli/args.py:1036 lib/cli/args.py:1043 lib/cli/args.py:1050 +msgid "preview" +msgstr "предварительный просмотр" + +#: lib/cli/args.py:1037 +msgid "" +"Percentage amount to scale the preview by. 100%% is the model output size." +msgstr "" +"Величина в процентах, на которую требуется масштабировать предварительный " +"просмотр. 100 %% - размер вывода модели." + +#: lib/cli/args.py:1044 +msgid "Show training preview output. in a separate window." +msgstr "Показывать предварительный просмотр в отдельном окне." + +#: lib/cli/args.py:1051 +msgid "" +"Writes the training result to a file. The image will be stored in the root " +"of your FaceSwap folder." +msgstr "" +"Записывает результат тренировки в файл. Файл будет сохранен в коренной папке " +"FaceSwap." + +#: lib/cli/args.py:1059 +msgid "" +"Disables TensorBoard logging. NB: Disabling logs means that you will not be " +"able to use the graph or analysis for this session in the GUI." +msgstr "" +"Отключает журнал TensorBoard. Примечание: Отключение журналов означает, что " +"вы не сможете использовать графики или анализ сессии внутри GUI." + +#: lib/cli/args.py:1066 lib/cli/args.py:1075 lib/cli/args.py:1084 +#: lib/cli/args.py:1093 +msgid "augmentation" +msgstr "аугментация" + +#: lib/cli/args.py:1067 +msgid "" +"Warps training faces to closely matched Landmarks from the opposite face-set " +"rather than randomly warping the face. This is the 'dfaker' way of doing " +"warping." +msgstr "" +"Вместо случайного искажения лица, деформирует лица в соответствии с " +"Ориентирами/Landmarks противоположного набора лиц. Этот способ используется " +"пакетом \"dfaker\"." + +#: lib/cli/args.py:1076 +msgid "" +"To effectively learn, a random set of images are flipped horizontally. " +"Sometimes it is desirable for this not to occur. Generally this should be " +"left off except for during 'fit training'." +msgstr "" +"Для повышения эффективности обучения, некоторые изображения случайным " +"образом переворачивается по горизонтали. Иногда желательно, чтобы этого не " +"происходило. Как правило, эту настройку не стоит трогать, за исключением " +"периода «финальной шлифовки»." + +#: lib/cli/args.py:1085 +msgid "" +"Color augmentation helps make the model less susceptible to color " +"differences between the A and B sets, at an increased training time cost. " +"Enable this option to disable color augmentation." +msgstr "" +"Цветовая аугментация помогает модели быть менее чувствительной к разнице " +"цвета между наборами A and B ценой некоторого замедления скорости " +"тренировки. Включите эту опцию для отключения цветовой аугментации." + +#: lib/cli/args.py:1094 +msgid "" +"Warping is integral to training the Neural Network. This option should only " +"be enabled towards the very end of training to try to bring out more detail. " +"Think of it as 'fine-tuning'. Enabling this option from the beginning is " +"likely to kill a model and lead to terrible results." +msgstr "" +"Внесение случайных искажение является неотъемлемой частью обучения нейронной " +"сети. Эту опцию следует включать только в самом конце обучения, чтобы " +"попытаться выявить больше деталей. Думайте об этом как о «стадии шлифовки». " +"Включение этой опции с самого начала может убить модель и привести к ужасным " +"результатам." + +#: lib/cli/args.py:1119 +msgid "Output to Shell console instead of GUI console" +msgstr "Вывод в системную консоль вместо GUI" From daac5dff3136d0be25a1c7f86dec742edff79020 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 10 Mar 2021 14:36:49 +0000 Subject: [PATCH 398/981] Bugfix - lib.logger - Force file writing to urf-8 --- lib/logger.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/logger.py b/lib/logger.py index 036657b1d8..66c6af23f4 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -139,7 +139,7 @@ def write(self, buffer): The log messages to write to the rolling buffer """ for line in buffer.rstrip().splitlines(): - self.append(line + "\n") + self.append(f"{line}\n") class TqdmHandler(logging.StreamHandler): @@ -238,7 +238,7 @@ def _file_handler(loglevel, log_file, log_format, command): filename += "_gui.log" if command == "gui" else ".log" should_rotate = os.path.isfile(filename) - log_file = RotatingFileHandler(filename, backupCount=1) + log_file = RotatingFileHandler(filename, backupCount=1, encoding="utf-8") if should_rotate: log_file.doRollover() log_file.setFormatter(log_format) @@ -328,19 +328,19 @@ def crash_log(): str The filename of the file that contains the crash report """ - original_traceback = traceback.format_exc() + original_traceback = traceback.format_exc().encode("utf-8") path = os.path.dirname(os.path.realpath(sys.argv[0])) filename = os.path.join(path, datetime.now().strftime("crash_report.%Y.%m.%d.%H%M%S%f.log")) - freeze_log = list(_DEBUG_BUFFER) + freeze_log = [line.encode("utf-8") for line in _DEBUG_BUFFER] try: from lib.sysinfo import sysinfo # pylint:disable=import-outside-toplevel except Exception: # pylint:disable=broad-except sysinfo = ("\n\nThere was an error importing System Information from lib.sysinfo. This is " "probably a bug which should be fixed:\n{}".format(traceback.format_exc())) - with open(filename, "w") as outfile: + with open(filename, "wb") as outfile: outfile.writelines(freeze_log) outfile.write(original_traceback) - outfile.write(sysinfo) + outfile.write(sysinfo.encode("utf-8")) return filename From 6f4e8865c73b0d696a79f1874049d6cdd97cc012 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 10 Mar 2021 14:55:26 +0000 Subject: [PATCH 399/981] Bugfix - GUI Don't use group as widget name due to translation issues with TCL --- lib/gui/control_helper.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index cdebb2678b..7a128a1e99 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -474,11 +474,9 @@ def get_group_frame(self, group): is_master = group == "_master" opts_frame = self.optsframe.subframe if is_master: - group_frame = ttk.Frame(opts_frame, name=group.lower()) + group_frame = ttk.Frame(opts_frame) else: - group_frame = ttk.LabelFrame(opts_frame, - text="" if is_master else group.title(), - name=group.lower()) + group_frame = ttk.LabelFrame(opts_frame, text="" if is_master else group.title()) group_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5, anchor=tk.NW) @@ -522,7 +520,7 @@ def _get_subgroup_frame(self, parent, subgroup): if subgroup is None: return subgroup if subgroup not in self._sub_group_frames: - sub_frame = ttk.Frame(parent, name="subgroup_{}".format(subgroup)) + sub_frame = ttk.Frame(parent) self._sub_group_frames[subgroup] = AutoFillContainer(sub_frame, self.option_columns, self.option_columns) From bbb7435bf5909c0881802807accad2a2811ad627 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 10 Mar 2021 15:07:32 +0000 Subject: [PATCH 400/981] Russian Translation - Slight fix --- locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 50370 -> 50687 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 893 ++++++++++--------------- 2 files changed, 341 insertions(+), 552 deletions(-) diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo index 778c3cbedc978d79d22d2e6fa5dee5ae95af1884..0c6dbed65b2e310877c1e573dd49f4b4614369d6 100644 GIT binary patch delta 1362 zcmZ9LTWr&H6vw}H8^Q!Li5R?%--{X9CER2WRoMc#QS3AzQzsYc+W%N}ZD-pt(6Ks= z4l|=|Y}#`#Q;+FMVGrExSS5c%QUD(i0Cz-S85@S}6d(gI@U17U>W?0~f;zm&9Ch&a)b32B?8I!aBGR*1KZTB7{BjhGrO{ z(X()r$$#1^t%WD*r021J@d!oX)@?byygk?O+@sQd>deBcaPtmnC2`Jr$%pQJOnR3( zpTe?C{RU|Y`^ngod~SqYJEbq-e)u8*u3gd!^v`!QHHp8$yU}mm%hdSS!XK~~HA)`z zKk0G}|Bfc7Ie0&LF=q$|LVGJlSQ#dpfa0ih5OD=# z?M8nC9znl$fF$%ToD3^Rhg1#g;Q$5wz-QUt;+9FoA zM1WibPc)n84O<=>n8t%yXEqTv5X$peEyIplNS=(R9La{`=NtjXG5?vzKA!Hf zQ}&p#&)QS`q+hT{?TPfO#-6f=(=VmFQ77%OjA1z4Lu4WoIc}%Yz4JZ{Q}%TFm2A+= z`~QBJnN)e)9xbh!uoLtcw-eAyVd!(6NHsKs4tlfDj@zFVZS zQBs3dDu|KRP+vYyifAL%$4PA^y`Cs}@I7YW+IWdBdV{gpWs;PLi8u{CI2T{w6zrEE z9m17(kM%w$$gNU>BBxYJ!$d~cU`wmTQ>EGDOOm8W`jz1Y@)~B}f~nK_q4*T{2Yg`Iky{)E(|rsW-t}Y{nm$O^3(J zr2*tKR!D;xxB>f-d()&3wBN!{)Q_!_R*}0{NR) zb#sFaX$K3xxF-!`VrP}~95+`}$MLmX90pwXvs3!HcyWEH`|fj#sW&}fB0T(1vQh6# z=ScE_4WUam9)p+CiD5Vwqj4E_iy+7)2*(l(?y$Te-(la^l}P=0#_=TbBC|w%agc(C7obY_V};CKy*{q)+P^l@(=Hn{{TbS=2 && n" -"%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\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" "Language: ru\n" -#: lib/cli/args.py:177 lib/cli/args.py:187 lib/cli/args.py:195 -#: lib/cli/args.py:205 +#: lib/cli/args.py:177 lib/cli/args.py:187 lib/cli/args.py:195 lib/cli/args.py:205 msgid "Global Options" msgstr "Общие настройки" #: lib/cli/args.py:178 msgid "" -"R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " -"to any GPU(s) that you do not wish to be made available to Faceswap. " -"Selecting all GPUs here will force Faceswap into CPU mode.\n" +"R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond to any GPU(s) that you do not wish to be made " +"available to Faceswap. Selecting all GPUs here will force Faceswap into CPU mode.\n" "L|{}" msgstr "" -"R|Не использовать GPU для Faceswap. Выберите номер(а), которые соответствуют " -"тем GPU, которые вы не хотите использовать в Faceswap. При отключении всех " -"GPU Faceswap будет работать в режиме CPU.\n" +"R|Не использовать GPU для Faceswap. Выберите номер(а), которые соответствуют тем GPU, которые вы не хотите использовать в " +"Faceswap. При отключении всех GPU Faceswap будет работать в режиме CPU.\n" "L|{}" #: lib/cli/args.py:188 -msgid "" -"Optionally overide the saved config with the path to a custom config file." -msgstr "" -"Переназначить путь к файлу конфигурации пользовательским. (Необязательно)" +msgid "Optionally overide the saved config with the path to a custom config file." +msgstr "Переназначить путь к файлу конфигурации пользовательским. (Необязательно)" #: lib/cli/args.py:196 msgid "" -"Log level. Stick with INFO or VERBOSE unless you need to file an error " -"report. Be careful with TRACE as it will generate a lot of data" +"Log level. Stick with INFO or VERBOSE unless you need to file an error report. Be careful with TRACE as it will generate a lot of " +"data" msgstr "" -"Уровень записи журнала. Придерживайтесь уровней INFO или VERBOSE, кроме " -"случаев когда вам нужно отправить отчёт об ошибке. Будьте осторожнее при " -"указании уровня TRACE, так как будет сгенерировано очень много данных" +"Уровень записи журнала. Придерживайтесь уровней INFO или VERBOSE, кроме случаев когда вам нужно отправить отчёт об ошибке. Будьте " +"осторожнее при указании уровня TRACE, так как будет сгенерировано очень много данных" #: lib/cli/args.py:206 msgid "Path to store the logfile. Leave blank to store in the faceswap folder" -msgstr "" -"Путь для сохранения файла журнала. Оставьте пустым, чтобы сохранить в папке " -"с faceswap" +msgstr "Путь для сохранения файла журнала. Оставьте пустым, чтобы сохранить в папке с faceswap" -#: lib/cli/args.py:299 lib/cli/args.py:308 lib/cli/args.py:316 -#: lib/cli/args.py:627 lib/cli/args.py:636 +#: lib/cli/args.py:299 lib/cli/args.py:308 lib/cli/args.py:316 lib/cli/args.py:627 lib/cli/args.py:636 msgid "Data" msgstr "Данные" #: lib/cli/args.py:300 msgid "" -"Input directory or video. Either a directory containing the image files you " -"wish to process or path to a video file. NB: This should be the source video/" -"frames NOT the source faces." +"Input directory or video. Either a directory containing the image files you wish to process or path to a video file. NB: This " +"should be the source video/frames NOT the source faces." msgstr "" -"Входная папка либо видео файл. Папка с набором фотографий для обработки либо " -"видео файл. Примечание: должно указывать на исходное видео либо набор " -"извлеченных кадров, а НЕ уже извлеченных лица." +"Входная папка либо видео файл. Папка с набором фотографий для обработки либо видео файл. Примечание: должно указывать на исходное " +"видео либо набор извлеченных кадров, а НЕ уже извлеченных лица." #: lib/cli/args.py:309 msgid "Output directory. This is where the converted files will be saved." msgstr "Папка для сохранения преобразованных файлов." #: lib/cli/args.py:317 -msgid "" -"Optional path to an alignments file. Leave blank if the alignments file is " -"at the default location." +msgid "Optional path to an alignments file. Leave blank if the alignments file is at the default location." msgstr "Путь к файлу выравнивания. Оставьте пустым, для пути по умолчанию." #: lib/cli/args.py:340 @@ -89,116 +75,87 @@ msgstr "" "Извлечь лица из изображений или видео источников.\n" "Плагины извлечения можно настроить в меню 'Настройки'" -#: lib/cli/args.py:365 lib/cli/args.py:381 lib/cli/args.py:393 -#: lib/cli/args.py:425 lib/cli/args.py:443 lib/cli/args.py:455 +#: lib/cli/args.py:365 lib/cli/args.py:381 lib/cli/args.py:393 lib/cli/args.py:425 lib/cli/args.py:443 lib/cli/args.py:455 #: lib/cli/args.py:646 lib/cli/args.py:671 lib/cli/args.py:698 msgid "Plugins" msgstr "Плагины" #: lib/cli/args.py:366 msgid "" -"R|Detector to use. Some of these have configurable settings in '/config/" -"extract.ini' or 'Settings > Configure Extract 'Plugins':\n" -"L|cv2-dnn: A CPU only extractor which is the least reliable and least " -"resource intensive. Use this if not using a GPU and time is important.\n" -"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " -"than other GPU detectors but can often return more false positives.\n" -"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " -"fewer false positives than other GPU detectors, but is a lot more resource " -"intensive." -msgstr "" -"R|Тип детектора. Некоторые могут быть настроенны через '/config/extract.ini' " -"либо 'Settings > Configure Extract 'Plugins':\n" -"L|cv2-dnn: Работает только на CPU, наименее надежный и наименее требователен " -"к ресурсам. Используйте если для вас очень важна скорость, а также не " -"использовать GPU .\n" -"L|mtcnn: Хороший детектор. Быстрый на CPU, ещё быстрее на GPU. Использует " -"меньше ресурсов, нежели другие GPU детекторы, но может производить больше " -"ложных положительных детектирований.\n" -"L|s3fd: Лучший детектор. Медленный на CPU, быстре на GPU. Может " -"детектировать лицо в большем кол-ве ситуация и меньшим кол-вом ошибок, чем " -"другие GPU, но значительно более требователен к ресурсам." +"R|Detector to use. Some of these have configurable settings in '/config/extract.ini' or 'Settings > Configure Extract 'Plugins':\n" +"L|cv2-dnn: A CPU only extractor which is the least reliable and least resource intensive. Use this if not using a GPU and time is " +"important.\n" +"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources than other GPU detectors but can often return more false " +"positives.\n" +"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and fewer false positives than other GPU detectors, but " +"is a lot more resource intensive." +msgstr "" +"R|Тип детектора. Некоторые могут быть настроенны через '/config/extract.ini' либо 'Settings > Configure Extract 'Plugins':\n" +"L|cv2-dnn: Работает только на CPU, наименее надежный и наименее требователен к ресурсам. Используйте если для вас очень важна " +"скорость, а также не использовать GPU .\n" +"L|mtcnn: Хороший детектор. Быстрый на CPU, ещё быстрее на GPU. Использует меньше ресурсов, нежели другие GPU детекторы, но может " +"производить больше ложных положительных детектирований.\n" +"L|s3fd: Лучший детектор. Медленный на CPU, быстре на GPU. Может детектировать лицо в большем кол-ве ситуация и меньшим кол-вом " +"ошибок, чем другие GPU, но значительно более требователен к ресурсам." #: lib/cli/args.py:382 msgid "" "R|Aligner to use.\n" -"L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " -"but less accurate. Only use this if not using a GPU and time is important.\n" +"L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, but less accurate. Only use this if not using a GPU and " +"time is important.\n" "L|fan: Best aligner. Fast on GPU, slow on CPU." msgstr "" "R|Выравнивание лица.\n" -"L|cv2-dnn: Детектор меток лица, только для CPU. Быстрый, не требователен к " -"ресурсам, но менее точный. Используйте только если вам необходимо не " -"использовать GPU.\n" +"L|cv2-dnn: Детектор меток лица, только для CPU. Быстрый, не требователен к ресурсам, но менее точный. Используйте только если вам " +"необходимо не использовать GPU.\n" "L|fan: Лучший выравниватель. Быстрый на GPU, медленный на CPU." #: lib/cli/args.py:394 msgid "" -"R|Additional Masker(s) to use. The masks generated here will all take up GPU " -"RAM. You can select none, one or multiple masks, but the extraction may take " -"longer the more you select. NB: The Extended and Components (landmark based) " -"masks are automatically generated on extraction.\n" -"L|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.\n" -"L|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.\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.\n" +"R|Additional Masker(s) to use. The masks generated here will all take up GPU RAM. You can select none, one or multiple masks, but " +"the extraction may take longer the more you select. NB: The Extended and Components (landmark based) masks are automatically " +"generated on extraction.\n" +"L|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.\n" +"L|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.\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.\n" "The auto generated masks are as follows:\n" -"L|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.\n" -"L|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.\n" +"L|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.\n" +"L|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.\n" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" msgstr "" -"R|Создание доп. масок. Генерация масок требует дополнительной памяти GPU. Вы " -"можете выбрать none, одну, или несколько масок, но процес извлечение может " -"занять больше времени в зависимости от выбора. Прим.: Маски Extended и " -"Components (на основе меток лица) всегда создаются автоматически при " -"извлечении лиц.\n" -"L|vgg-clear: Маска предназначена для умной сегментации преимущественно " -"фронтальных лиц без препятствий. Фотографии в профиль могут быть обработаны " -"посредственно.\n" -"L|vgg-obstructed: Маска предназначена для умной сегментации преимущественно " -"фронтальных лиц. Эта маска была обучена распознавать некоторые препятствия, " -"такие как руки и очки. Фотографии в профиль могут быть обработаны " -"посредственно.\n" -"L|unet-dfl: Маска предназначена для умной сегментации преимущественно " -"фронтальных лиц. Маска была обучена силами участников сообщества и нуждается " -"в тестировании. Фотографии в профиль могут быть обработаны посредственно.\n" +"R|Создание доп. масок. Генерация масок требует дополнительной памяти GPU. Вы можете выбрать none, одну, или несколько масок, но " +"процес извлечение может занять больше времени в зависимости от выбора. Прим.: Маски Extended и Components (на основе меток лица) " +"всегда создаются автоматически при извлечении лиц.\n" +"L|vgg-clear: Маска предназначена для умной сегментации преимущественно фронтальных лиц без препятствий. Фотографии в профиль " +"могут быть обработаны посредственно.\n" +"L|vgg-obstructed: Маска предназначена для умной сегментации преимущественно фронтальных лиц. Эта маска была обучена распознавать " +"некоторые препятствия, такие как руки и очки. Фотографии в профиль могут быть обработаны посредственно.\n" +"L|unet-dfl: Маска предназначена для умной сегментации преимущественно фронтальных лиц. Маска была обучена силами участников " +"сообщества и нуждается в тестировании. Фотографии в профиль могут быть обработаны посредственно.\n" "Следующие маски создаются автоматически:\n" -"L|components: Маска предназначена для сегментации лица на основе ориентиров " -"лица. Маска создается путем построения выпуклого полигона вокруг внешних " -"ориентиров лица.\n" -"L|extended: Маска предназначена для сегментации лица на основе ориентиров " -"лица. Маска создается путем построения выпуклого полигона вокруг внешних " -"ориентиров лица и расширяется вверх на лоб.\n" +"L|components: Маска предназначена для сегментации лица на основе ориентиров лица. Маска создается путем построения выпуклого " +"полигона вокруг внешних ориентиров лица.\n" +"L|extended: Маска предназначена для сегментации лица на основе ориентиров лица. Маска создается путем построения выпуклого " +"полигона вокруг внешних ориентиров лица и расширяется вверх на лоб.\n" "(пример: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" #: lib/cli/args.py:426 msgid "" -"R|Performing normalization can help the aligner better align faces with " -"difficult lighting conditions at an extraction speed cost. Different methods " -"will yield different results on different sets. NB: This does not impact the " -"output face, just the input to the aligner.\n" +"R|Performing normalization can help the aligner better align faces with difficult lighting conditions at an extraction speed " +"cost. Different methods will yield different results on different sets. NB: This does not impact the output face, just the input " +"to the aligner.\n" "L|none: Don't perform normalization on the face.\n" -"L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " -"face.\n" +"L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the face.\n" "L|hist: Equalize the histograms on the RGB channels.\n" "L|mean: Normalize the face colors to the mean." msgstr "" -"R|Нормализация может помочь выравниванию лиц при сложных условиях освещения, " -"ценой снижения скорости. Различные методы дают разные результаты в " -"зависимости от набора лиц. Прим.: Не влияет на вывод лица, только на " -"выравнивание.\n" +"R|Нормализация может помочь выравниванию лиц при сложных условиях освещения, ценой снижения скорости. Различные методы дают " +"разные результаты в зависимости от набора лиц. Прим.: Не влияет на вывод лица, только на выравнивание.\n" "L|none: Не производить нормализацию картинки лица.\n" "L|clahe: Производить нормализацию методом CLAHE.\n" "L|hist: Выравнивание гистограммы каналов RGB каналов.\n" @@ -206,151 +163,111 @@ msgstr "" #: lib/cli/args.py:444 msgid "" -"The number of times to re-feed the detected face into the aligner. Each time " -"the face is re-fed into the aligner the bounding box is adjusted by a small " -"amount. The final landmarks are then averaged from each iteration. Helps to " -"remove 'micro-jitter' but at the cost of slower extraction speed. The more " -"times the face is re-fed into the aligner, the less micro-jitter should " -"occur but the longer extraction will take." +"The number of times to re-feed the detected face into the aligner. Each time the face is re-fed into the aligner the bounding box " +"is adjusted by a small amount. The final landmarks are then averaged from each iteration. Helps to remove 'micro-jitter' but at " +"the cost of slower extraction speed. The more times the face is re-fed into the aligner, the less micro-jitter should occur but " +"the longer extraction will take." msgstr "" -"Кол-во проходов выравнивания после обнаружения лица. Каждый раз при " -"повторном выравнивании рамка лица немного корректируется. Окончательные " -"ориентиры затем усредняются. Помогает устранить «микроджиттер», но за счет " -"замедления скорости извлечения. Чем больше проходов выравнивания, тем меньше " -"микродрожание, но тем дольше идет извлечение." +"Кол-во проходов выравнивания после обнаружения лица. Каждый раз при повторном выравнивании рамка лица немного корректируется. " +"Окончательные ориентиры затем усредняются. Помогает устранить «микроджиттер», но за счет замедления скорости извлечения. Чем " +"больше проходов выравнивания, тем меньше микродрожание, но тем дольше идет извлечение." #: lib/cli/args.py:456 msgid "" -"If a face isn't found, rotate the images to try to find a face. Can find " -"more faces at the cost of extraction speed. Pass in a single number to use " -"increments of that size up to 360, or pass in a list of numbers to enumerate " -"exactly what angles to check." +"If a face isn't found, rotate the images to try to find a face. Can find more faces at the cost of extraction speed. Pass in a " +"single number to use increments of that size up to 360, or pass in a list of numbers to enumerate exactly what angles to check." msgstr "" -"Если лицо не найдено, поворачивает картинку, чтобы попытаться найти лицо. " -"Может найти больше лиц ценой скорости извлечения. Укажите число, чтобы " -"использовать приращения этого размера до 360, либо передайте список чисел, " -"чтобы точно указать, какие углы проверять." +"Если лицо не найдено, поворачивает картинку, чтобы попытаться найти лицо. Может найти больше лиц ценой скорости извлечения. " +"Укажите число, чтобы использовать приращения этого размера до 360, либо передайте список чисел, чтобы точно указать, какие углы " +"проверять." -#: lib/cli/args.py:468 lib/cli/args.py:478 lib/cli/args.py:491 -#: lib/cli/args.py:505 lib/cli/args.py:735 lib/cli/args.py:749 +#: lib/cli/args.py:468 lib/cli/args.py:478 lib/cli/args.py:491 lib/cli/args.py:505 lib/cli/args.py:735 lib/cli/args.py:749 #: lib/cli/args.py:762 lib/cli/args.py:776 msgid "Face Processing" msgstr "Обработка лиц" #: lib/cli/args.py:469 -msgid "" -"Filters out faces detected below this size. Length, in pixels across the " -"diagonal of the bounding box. Set to 0 for off" -msgstr "" -"Отбрасывает лица ниже указанного размера. Длина указывается в пикселях по " -"диагонали. Установите в 0 для отключения" +msgid "Filters out faces detected below this size. Length, in pixels across the diagonal of the bounding box. Set to 0 for off" +msgstr "Отбрасывает лица ниже указанного размера. Длина указывается в пикселях по диагонали. Установите в 0 для отключения" #: lib/cli/args.py:479 lib/cli/args.py:750 msgid "" -"Optionally filter out people who you do not wish to process by passing in an " -"image of that person. Should be a front portrait with a single person in the " -"image. Multiple images can be added space separated. NB: Using face filter " -"will significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." +"Optionally filter out people who you do not wish to process by passing in an image of that person. Should be a front portrait " +"with a single person in the image. Multiple images can be added space separated. NB: Using face filter will significantly " +"decrease extraction speed and its accuracy cannot be guaranteed." msgstr "" -"Дополнительно вы можете отфильтровать лица людей, которых вы не хотите " -"обрабатывать указав изображение этого человека. На изображении должен быть " -"фронтальный портрет одного человека . Можно указать несколько файлов через " -"пробел. Прим.: Фильтрация лиц существенно снижает скорость извлечения, при " -"этом точность не гарантируется." +"Дополнительно вы можете отфильтровать лица людей, которых вы не хотите обрабатывать указав изображение этого человека. На " +"изображении должен быть фронтальный портрет одного человека . Можно указать несколько файлов через пробел. Прим.: Фильтрация лиц " +"существенно снижает скорость извлечения, при этом точность не гарантируется." #: lib/cli/args.py:492 lib/cli/args.py:763 msgid "" -"Optionally select people you wish to process by passing in an image of that " -"person. Should be a front portrait with a single person in the image. " -"Multiple images can be added space separated. NB: Using face filter will " -"significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." +"Optionally select people you wish to process by passing in an image of that person. Should be a front portrait with a single " +"person in the image. Multiple images can be added space separated. NB: Using face filter will significantly decrease extraction " +"speed and its accuracy cannot be guaranteed." msgstr "" -"Дополнительно вы можете выбрать людей, которых вы хотели бы включить в " -"обработку путем указания изображения этого человека. Должен быть фронтальный " -"портрет с лишь одним человеком на картинке. Можно выбрать несколько " -"изображений через пробел. Прим.: Использование фильтра существенно замедлит " -"скорость извлечения. Также точность не гарантируется." +"Дополнительно вы можете выбрать людей, которых вы хотели бы включить в обработку путем указания изображения этого человека. " +"Должен быть фронтальный портрет с лишь одним человеком на картинке. Можно выбрать несколько изображений через пробел. Прим.: " +"Использование фильтра существенно замедлит скорость извлечения. Также точность не гарантируется." #: lib/cli/args.py:506 lib/cli/args.py:777 msgid "" -"For use with the optional nfilter/filter files. Threshold for positive face " -"recognition. Lower values are stricter. NB: Using face filter will " -"significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." +"For use with the optional nfilter/filter files. Threshold for positive face recognition. Lower values are stricter. NB: Using " +"face filter will significantly decrease extraction speed and its accuracy cannot be guaranteed." msgstr "" -"Только при использовании файлов nfilter/filter. Порог для распознавания " -"лица. Чем ниже значения, тем строже. Прим.: Использование фильтра лиц " -"существенно замедлит скорость извлечения. Также точность не гарантируется." +"Только при использовании файлов nfilter/filter. Порог для распознавания лица. Чем ниже значения, тем строже. Прим.: " +"Использование фильтра лиц существенно замедлит скорость извлечения. Также точность не гарантируется." -#: lib/cli/args.py:517 lib/cli/args.py:529 lib/cli/args.py:541 -#: lib/cli/args.py:553 +#: lib/cli/args.py:517 lib/cli/args.py:529 lib/cli/args.py:541 lib/cli/args.py:553 msgid "output" msgstr "вывод" #: lib/cli/args.py:518 msgid "" -"The output size of extracted faces. Make sure that the model you intend to " -"train supports your required size. This will only need to be changed for hi-" -"res models." +"The output size of extracted faces. Make sure that the model you intend to train supports your required size. This will only need " +"to be changed for hi-res models." msgstr "" -"Размер извлекаемых лиц в пикселях. Убедитесь, что выбранная Вами модель " -"поддерживает такой входной размер. Стоит изменять только для моделей " -"высокого разрешения." +"Размер извлекаемых лиц в пикселях. Убедитесь, что выбранная Вами модель поддерживает такой входной размер. Стоит изменять только " +"для моделей высокого разрешения." #: lib/cli/args.py:530 msgid "" -"Extract every 'nth' frame. This option will skip frames when extracting " -"faces. For example a value of 1 will extract faces from every frame, a value " -"of 10 will extract faces from every 10th frame." +"Extract every 'nth' frame. This option will skip frames when extracting faces. For example a value of 1 will extract faces from " +"every frame, a value of 10 will extract faces from every 10th frame." msgstr "" -"Обрабатывать каждые N кадров. Эта опция будет пропускать лица при " -"извлечении. Например, значение 1 будет искать лица в каждом кадре, а " -"значение 10 в каждом 10том кадре." +"Обрабатывать каждые N кадров. Эта опция будет пропускать лица при извлечении. Например, значение 1 будет искать лица в каждом " +"кадре, а значение 10 в каждом 10том кадре." #: lib/cli/args.py:542 msgid "" -"Automatically save the alignments file after a set amount of frames. By " -"default the alignments file is only saved at the end of the extraction " -"process. NB: If extracting in 2 passes then the alignments file will only " -"start to be saved out during the second pass. WARNING: Don't interrupt the " -"script when writing the file because it might get corrupted. Set to 0 to " -"turn off" +"Automatically save the alignments file after a set amount of frames. By default the alignments file is only saved at the end of " +"the extraction process. NB: If extracting in 2 passes then the alignments file will only start to be saved out during the second " +"pass. WARNING: Don't interrupt the script when writing the file because it might get corrupted. Set to 0 to turn off" msgstr "" -"Автоматически сохранять файл выравнивания после указанного кол-ва кадров. По " -"умолчанию файл выравнивания сохраняется только в конце процедуры извлечения. " -"Прим.: При извлечении в 2 прохода, файл выравниваний начнёт сохранение " -"только во время второго прохода. ВНИМАНИЕ: Не прерывайте выполнение во время " -"записи, так как это может повлечь порчу файла. Установите в 0 для выключения" +"Автоматически сохранять файл выравнивания после указанного кол-ва кадров. По умолчанию файл выравнивания сохраняется только в " +"конце процедуры извлечения. Прим.: При извлечении в 2 прохода, файл выравниваний начнёт сохранение только во время второго " +"прохода. ВНИМАНИЕ: Не прерывайте выполнение во время записи, так как это может повлечь порчу файла. Установите в 0 для выключения" #: lib/cli/args.py:554 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "Рисовать ландмарки на выходных лицах для нужд отладки." -#: lib/cli/args.py:560 lib/cli/args.py:569 lib/cli/args.py:577 -#: lib/cli/args.py:584 lib/cli/args.py:789 lib/cli/args.py:800 +#: lib/cli/args.py:560 lib/cli/args.py:569 lib/cli/args.py:577 lib/cli/args.py:584 lib/cli/args.py:789 lib/cli/args.py:800 #: lib/cli/args.py:808 lib/cli/args.py:827 lib/cli/args.py:833 msgid "settings" msgstr "настройки" #: lib/cli/args.py:561 msgid "" -"Don't run extraction in parallel. Will run each part of the extraction " -"process separately (one after the other) rather than all at the smae time. " -"Useful if VRAM is at a premium." +"Don't run extraction in parallel. Will run each part of the extraction process separately (one after the other) rather than all " +"at the smae time. Useful if VRAM is at a premium." msgstr "" -"Не проводить параллельное извлечение. Вместо одновременного запуска, каждая " -"стадия извлечения будет запущена отдельно (одна, за другой). Полезно при " -"нехватке VRAM." +"Не проводить параллельное извлечение. Вместо одновременного запуска, каждая стадия извлечения будет запущена отдельно (одна, за " +"другой). Полезно при нехватке VRAM." #: lib/cli/args.py:570 -msgid "" -"Skips frames that have already been extracted and exist in the alignments " -"file" -msgstr "" -"Пропускать кадры, которые уже были извлечены и существуют в файле " -"выравнивания" +msgid "Skips frames that have already been extracted and exist in the alignments file" +msgstr "Пропускать кадры, которые уже были извлечены и существуют в файле выравнивания" #: lib/cli/args.py:578 msgid "Skip frames that already have detected faces in the alignments file" @@ -358,8 +275,7 @@ msgstr "Пропускать кадры, для которых в файле в #: lib/cli/args.py:585 msgid "Skip saving the detected faces to disk. Just create an alignments file" -msgstr "" -"Не сохранять найденные лица на носитель. Просто создать файл выравнивания" +msgstr "Не сохранять найденные лица на носитель. Просто создать файл выравнивания" #: lib/cli/args.py:607 msgid "" @@ -371,131 +287,94 @@ msgstr "" #: lib/cli/args.py:628 msgid "" -"Only required if converting from images to video. Provide The original video " -"that the source frames were extracted from (for extracting the fps and " -"audio)." +"Only required if converting from images to video. Provide The original video that the source frames were extracted from (for " +"extracting the fps and audio)." msgstr "" -"Нужно указывать лишь при конвертации из набора картинок в видео. " -"Предоставьте исходное видео, из которого были извлечены кадры (для настройки " -"частоты кадров, а также аудио)." +"Нужно указывать лишь при конвертации из набора картинок в видео. Предоставьте исходное видео, из которого были извлечены кадры " +"(для настройки частоты кадров, а также аудио)." #: lib/cli/args.py:637 -msgid "" -"Model directory. The directory containing the trained model you wish to use " -"for conversion." -msgstr "" -"Папка с моделью. Папка, содержащая обученную модель, которую вы хотите " -"использовать для преобразования." +msgid "Model directory. The directory containing the trained model you wish to use for conversion." +msgstr "Папка с моделью. Папка, содержащая обученную модель, которую вы хотите использовать для преобразования." #: lib/cli/args.py:647 msgid "" -"R|Performs color adjustment to the swapped face. Some of these options have " -"configurable settings in '/config/convert.ini' or 'Settings > Configure " -"Convert Plugins':\n" -"L|avg-color: Adjust the mean of each color channel in the swapped " -"reconstruction to equal the mean of the masked area in the original image.\n" -"L|color-transfer: Transfers the color distribution from the source to the " -"target image using the mean and standard deviations of the L*a*b* color " -"space.\n" -"L|manual-balance: Manually adjust the balance of the image in a variety of " -"color spaces. Best used with the Preview tool to set correct values.\n" -"L|match-hist: Adjust the histogram of each color channel in the swapped " -"reconstruction to equal the histogram of the masked area in the original " -"image.\n" -"L|seamless-clone: Use cv2's seamless clone function to remove extreme " -"gradients at the mask seam by smoothing colors. Generally does not give very " -"satisfactory results.\n" +"R|Performs color adjustment to the swapped face. Some of these options have configurable settings in '/config/convert.ini' or " +"'Settings > Configure Convert Plugins':\n" +"L|avg-color: Adjust the mean of each color channel in the swapped reconstruction to equal the mean of the masked area in the " +"original image.\n" +"L|color-transfer: Transfers the color distribution from the source to the target image using the mean and standard deviations of " +"the L*a*b* color space.\n" +"L|manual-balance: Manually adjust the balance of the image in a variety of color spaces. Best used with the Preview tool to set " +"correct values.\n" +"L|match-hist: Adjust the histogram of each color channel in the swapped reconstruction to equal the histogram of the masked area " +"in the original image.\n" +"L|seamless-clone: Use cv2's seamless clone function to remove extreme gradients at the mask seam by smoothing colors. Generally " +"does not give very satisfactory results.\n" "L|none: Don't perform color adjustment." msgstr "" -"R|Производит подгонку цветов в измененном лице. Некоторые из этих опций " -"имеют настройки в файле '/config/convert.ini' либо 'Настройки > Настроить " -"Плагины Конверсии':\n" -"L|avg-color: Подогнать среднее значение каждого цветового канала в " -"замененном лице так, чтобы оно равнялось среднему значению области маски " -"исходного изображения.\n" -"L|color-transfer: Переносит распределение цвета от источника к целевому " -"изображению с использованием среднего и стандартного отклонения цветового " -"пространства L * a * b *.\n" -"L|manual-balance: Ручная настройка баланса изображения в различных цветовых " -"пространствах. Лучше всего использовать с инструментом предварительного " -"просмотра для установки правильных значений.\n" -"L|match-hist: Подгонять гистограмму каждого цветового канала нового лица, " -"гистограммой области маски исходного изображения\n" -"L|seamless-clone: Исп. фунцю cv2's незаметного переноса чтобы убрать " -"экстремальные градиенты на краях маски путём сглаживания цветов. Обычно не " -"дает удовлетворительных результатов.\n" +"R|Производит подгонку цветов в измененном лице. Некоторые из этих опций имеют настройки в файле '/config/convert.ini' либо " +"'Настройки > Настроить Плагины Конверсии':\n" +"L|avg-color: Подогнать среднее значение каждого цветового канала в замененном лице так, чтобы оно равнялось среднему значению " +"области маски исходного изображения.\n" +"L|color-transfer: Переносит распределение цвета от источника к целевому изображению с использованием среднего и стандартного " +"отклонения цветового пространства L * a * b *.\n" +"L|manual-balance: Ручная настройка баланса изображения в различных цветовых пространствах. Лучше всего использовать с " +"инструментом предварительного просмотра для установки правильных значений.\n" +"L|match-hist: Подгонять гистограмму каждого цветового канала нового лица, гистограммой области маски исходного изображения\n" +"L|seamless-clone: Исп. фунцю cv2's незаметного переноса чтобы убрать экстремальные градиенты на краях маски путём сглаживания " +"цветов. Обычно не дает удовлетворительных результатов.\n" "L|none: Не производить подгонку цвета." #: lib/cli/args.py:672 msgid "" -"R|Masker to use. NB: The mask you require must exist within the alignments " -"file. You can add additional masks with the Mask Tool.\n" +"R|Masker to use. NB: The mask you require must exist within the alignments file. You can add additional masks with the Mask " +"Tool.\n" "L|none: Don't use a mask.\n" -"L|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.\n" -"L|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.\n" -"L|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.\n" -"L|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.\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." -msgstr "" -"R|Использовать маску. Прим.: Требуемая маска должна наличествовать в файле " -"выравнивания. Доп. маски можно добавить через Инструмент Создания Масок.\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\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.\n" +"L|predicted: If the 'Learn Mask' option was enabled during training, this will use the mask that was created by the trained model." +msgstr "" +"R|Использовать маску. Прим.: Требуемая маска должна наличествовать в файле выравнивания. Доп. маски можно добавить через " +"Инструмент Создания Масок.\n" "L|none: Не использовать маску.\n" -"L | компоненты: маска, предназначенная для сегментации лица на основе " -"найденных ориентиров. Маска создается построением выпуклого многоугольника " -"вокруг внешних ориентиров лица.\n" -"L | расширенный: маска, предназначенная для сегментации лица на основе " -"расположения ориентиров. Маска создается построением выпуклого " +"L| components: маска, предназначенная для сегментации лица на основе найденных ориентиров. Маска создается построением выпуклого " +"многоугольника вокруг внешних ориентиров лица.\n" +"L| extended: маска, предназначенная для сегментации лица на основе расположения ориентиров. Маска создается построением выпуклого " "многоугольника вокруг внешних ориентиров лица и продолжается вверх на лоб.\n" -"L | vgg-clear: маска, предназначенная для умной сегментации преимущественно " -"фронтальных лиц без препятствий. Лица в профиль и препятствия могут привести " -"к некачественным результатам.\n" -"L | vgg-obstructed: маска, предназначенная для умной сегментации " -"преимущественно фронтальных лиц. Модель маски специально обучена " -"распознавать некоторые лицевые препятствия (руки и очки). Лица в профиль " -"могут привести к некачественным результатам..\n" -"L | unet-dfl: маска, предназначенная для умной сегментации преимущественно " -"фронтальных лиц. Модель маски была обучена членами сообщества и потребует " -"тестирования для дальнейшего описания. Лица в профиль могут привести к " -"некачественным результатам.." +"L| vgg-clear: маска, предназначенная для умной сегментации преимущественно фронтальных лиц без препятствий. Лица в профиль и " +"препятствия могут привести к некачественным результатам.\n" +"L| vgg-obstructed: маска, предназначенная для умной сегментации преимущественно фронтальных лиц. Модель маски специально обучена " +"распознавать некоторые лицевые препятствия (руки и очки). Лица в профиль могут привести к некачественным результатам..\n" +"L| unet-dfl: маска, предназначенная для умной сегментации преимущественно фронтальных лиц. Модель маски была обучена членами " +"сообщества и потребует тестирования для дальнейшего описания. Лица в профиль могут привести к некачественным результатам..\n" +"L| predicted: Если во время обучения была включена опция «Learn Mask», будет использоваться маска, созданная обученной моделью." #: lib/cli/args.py:699 msgid "" -"R|The plugin to use to output the converted images. The writers are " -"configurable in '/config/convert.ini' or 'Settings > Configure Convert " -"Plugins:'\n" -"L|ffmpeg: [video] Writes out the convert straight to video. When the input " -"is a series of images then the '-ref' (--reference-video) parameter must be " -"set.\n" +"R|The plugin to use to output the converted images. The writers are configurable in '/config/convert.ini' or 'Settings > " +"Configure Convert Plugins:'\n" +"L|ffmpeg: [video] Writes out the convert straight to video. When the input is a series of images then the '-ref' (--reference-" +"video) parameter must be set.\n" "L|gif: [animated image] Create an animated gif.\n" -"L|opencv: [images] The fastest image writer, but less options and formats " -"than other plugins.\n" -"L|pillow: [images] Slower than opencv, but has more options and supports " -"more formats." -msgstr "" -"R|Тип плагина для вывода конвертированных изображений. Записывающие плагины " -"можно настроить в '/config/convert.ini' либо 'Настройки > Настроить Плагины " -"Конверсии:'\n" -"L|ffmpeg: [видео] Записывает результат конверсии сразу в видео файл. Если " -"входом является серий изображений, то нужно также указать параметр '-ref' (--" -"reference-video).\n" +"L|opencv: [images] The fastest image writer, but less options and formats than other plugins.\n" +"L|pillow: [images] Slower than opencv, but has more options and supports more formats." +msgstr "" +"R|Тип плагина для вывода конвертированных изображений. Записывающие плагины можно настроить в '/config/convert.ini' либо " +"'Настройки > Настроить Плагины Конверсии:'\n" +"L|ffmpeg: [видео] Записывает результат конверсии сразу в видео файл. Если входом является серий изображений, то нужно также " +"указать параметр '-ref' (--reference-video).\n" "L|gif: [анимированное изображение] Создает анимированный gif.\n" -"L|opencv: [изображения] Наибыстрейший способ записи, но с меньшим кол-вом " -"опций и форматов вывода.\n" -"L|pillow: [изображения] Более медленный, чем opencv, но имеет больше опций и " -"поддерживает больше форматов." +"L|opencv: [изображения] Наибыстрейший способ записи, но с меньшим кол-вом опций и форматов вывода.\n" +"L|pillow: [изображения] Более медленный, чем opencv, но имеет больше опций и поддерживает больше форматов." #: lib/cli/args.py:718 lib/cli/args.py:725 lib/cli/args.py:819 msgid "Frame Processing" @@ -503,93 +382,70 @@ msgstr "Обработка кадров" #: lib/cli/args.py:719 msgid "" -"Scale the final output frames by this amount. 100%% will output the frames " -"at source dimensions. 50%% at half size 200%% at double size" +"Scale the final output frames by this amount. 100%% will output the frames at source dimensions. 50%% at half size 200%% at " +"double size" msgstr "" -"Масштабировать оконечные кадры до указанного процента. 100%% будет выводить " -"кадры в исходном размере. 50%% половина от размера, а 200%% в удвоенном " -"размере" +"Масштабировать оконечные кадры до указанного процента. 100%% будет выводить кадры в исходном размере. 50%% половина от размера, а " +"200%% в удвоенном размере" #: lib/cli/args.py:726 msgid "" -"Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " -"--frame-ranges 10-50 90-100. Frames falling outside of the selected range " -"will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " -"converting from images, then the filenames must end with the frame-number!" +"Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use --frame-ranges 10-50 90-100. Frames falling outside " +"of the selected range will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are converting from images, then " +"the filenames must end with the frame-number!" msgstr "" -"Диапазон кадров к которым применять перенос, например, для кадров от 10 до " -"50, и 90 до 100 укажите: --frame-ranges 10-50 90-100. Кадры попадающие вне " -"выбранного диапазона будут отброшены если не указано '-k' (--keep-" -"unchanged). Прим.: Если при конверсии используются изображения, то имена " -"файлов должны заканчиваться номером кадра!" +"Диапазон кадров к которым применять перенос, например, для кадров от 10 до 50, и 90 до 100 укажите: --frame-ranges 10-50 90-100. " +"Кадры попадающие вне выбранного диапазона будут отброшены если не указано '-k' (--keep-unchanged). Прим.: Если при конверсии " +"используются изображения, то имена файлов должны заканчиваться номером кадра!" #: lib/cli/args.py:736 msgid "" -"If you have not cleansed your alignments file, then you can filter out faces " -"by defining a folder here that contains the faces extracted from your input " -"files/video. If this folder is defined, then only faces that exist within " -"your alignments file and also exist within the specified folder will be " -"converted. Leaving this blank will convert all faces that exist within the " -"alignments file." +"If you have not cleansed your alignments file, then you can filter out faces by defining a folder here that contains the faces " +"extracted from your input files/video. If this folder is defined, then only faces that exist within your alignments file and also " +"exist within the specified folder will be converted. Leaving this blank will convert all faces that exist within the alignments " +"file." msgstr "" -"Если вы не вычистили ваш файл выравниваний, то вы можете отфильтровать лица " -"указав здесь папку, которая содержит лица извлеченные из входных файлов/" -"видео. Если эта папка указана, то, только лица, которые существуют в файле " -"выравниваний и ТАКЖЕ существуют в указанной папке будут сконвертированы. " -"Если оставить это поле пустым, то все лица, которые существуют в файле " -"выравниваний будут сконвертированы." +"Если вы не вычистили ваш файл выравниваний, то вы можете отфильтровать лица указав здесь папку, которая содержит лица извлеченные " +"из входных файлов/видео. Если эта папка указана, то, только лица, которые существуют в файле выравниваний и ТАКЖЕ существуют в " +"указанной папке будут сконвертированы. Если оставить это поле пустым, то все лица, которые существуют в файле выравниваний будут " +"сконвертированы." #: lib/cli/args.py:790 msgid "" -"The maximum number of parallel processes for performing conversion. " -"Converting images is system RAM heavy so it is possible to run out of memory " -"if you have a lot of processes and not enough RAM to accommodate them all. " -"Setting this to 0 will use the maximum available. No matter what you set " -"this to, it will never attempt to use more processes than are available on " -"your system. If singleprocess is enabled this setting will be ignored." -msgstr "" -"Максимальное количество параллельных процессов для выполнения " -"преобразования. Преобразование изображений требует большого объема системной " -"памяти, поэтому возможна ее нехватка, если у вас много процессов и не " -"хватает памяти для их всех. Установка этого значения на 0 будет использовать " -"максимально доступное значение. Независимо от ваших установок, никогда не " -"будет использоваться больше процессов, чем доступно в вашей системе. Если " -"включен одиночный процесс, этот параметр будет проигнорирован." +"The maximum number of parallel processes for performing conversion. Converting images is system RAM heavy so it is possible to " +"run out of memory if you have a lot of processes and not enough RAM to accommodate them all. Setting this to 0 will use the " +"maximum available. No matter what you set this to, it will never attempt to use more processes than are available on your system. " +"If singleprocess is enabled this setting will be ignored." +msgstr "" +"Максимальное количество параллельных процессов для выполнения преобразования. Преобразование изображений требует большого объема " +"системной памяти, поэтому возможна ее нехватка, если у вас много процессов и не хватает памяти для их всех. Установка этого " +"значения на 0 будет использовать максимально доступное значение. Независимо от ваших установок, никогда не будет использоваться " +"больше процессов, чем доступно в вашей системе. Если включен одиночный процесс, этот параметр будет проигнорирован." #: lib/cli/args.py:801 msgid "" -"[LEGACY] This only needs to be selected if a legacy model is being loaded or " -"if there are multiple models in the model folder" +"[LEGACY] This only needs to be selected if a legacy model is being loaded or if there are multiple models in the model folder" msgstr "" -"[СОВМЕСТИМОСТЬ] Это нужно выбирать только в том случае, если загружается " -"устаревшая модель или если в папке сохранения есть несколько моделей" +"[СОВМЕСТИМОСТЬ] Это нужно выбирать только в том случае, если загружается устаревшая модель или если в папке сохранения есть " +"несколько моделей" #: lib/cli/args.py:809 msgid "" -"Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " -"alignments file for your destination video. However, if you wish you can " -"generate the alignments on-the-fly by enabling this option. This will use an " -"inferior extraction pipeline and will lead to substandard results. If an " -"alignments file is found, this option will be ignored." +"Enable On-The-Fly Conversion. NOT recommended. You should generate a clean alignments file for your destination video. However, " +"if you wish you can generate the alignments on-the-fly by enabling this option. This will use an inferior extraction pipeline and " +"will lead to substandard results. If an alignments file is found, this option will be ignored." msgstr "" -"Включить преобразование на лету. НЕ рекомендуется. Вам стоит создать чистый " -"файл выравнивания для вашего целевого видео. Однако, если вы хотите, вы " -"можете сгенерировать выравнивания на лету, включив эту опцию. Это приведет к " -"использованию улучшенного конвейера экстракции и некачественных результатов. " -"Если файл выравниваний найден, этот параметр будет проигнорирован." +"Включить преобразование на лету. НЕ рекомендуется. Вам стоит создать чистый файл выравнивания для вашего целевого видео. Однако, " +"если вы хотите, вы можете сгенерировать выравнивания на лету, включив эту опцию. Это приведет к использованию улучшенного " +"конвейера экстракции и некачественных результатов. Если файл выравниваний найден, этот параметр будет проигнорирован." #: lib/cli/args.py:820 -msgid "" -"When used with --frame-ranges outputs the unchanged frames that are not " -"processed instead of discarding them." -msgstr "" -"При использовании с --frame-range кадры не попавшие в диапазон выводятся " -"неизменными, вместо их пропуска." +msgid "When used with --frame-ranges outputs the unchanged frames that are not processed instead of discarding them." +msgstr "При использовании с --frame-range кадры не попавшие в диапазон выводятся неизменными, вместо их пропуска." #: lib/cli/args.py:828 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" -msgstr "" -"Поменять модели местами. Вместо преобразования из A -> B, преобразует B -> A" +msgstr "Поменять модели местами. Вместо преобразования из A -> B, преобразует B -> A" #: lib/cli/args.py:834 msgid "Disable multiprocessing. Slower but less resource intensive." @@ -601,53 +457,45 @@ msgid "" "Training models can take a long time. Anything from 24hrs to over a week\n" "Model plugins can be configured in the 'Settings' Menu" msgstr "" -"Начать обучение модели используя наборы лиц: (A) - исходное лицо и (B) - " -"новое лицо.\n" +"Начать обучение модели используя наборы лиц: (A) - исходное лицо и (B) - новое лицо.\n" "Обучение моделей может занять долгое время: от 24 часов до недели\n" "Каждую модель можно отдельно настроить в меню «Настройки»" -#: lib/cli/args.py:869 lib/cli/args.py:880 lib/cli/args.py:889 -#: lib/cli/args.py:900 +#: lib/cli/args.py:869 lib/cli/args.py:880 lib/cli/args.py:889 lib/cli/args.py:900 msgid "faces" msgstr "лица" #: lib/cli/args.py:870 msgid "" -"Input directory. A directory containing training images for face A. This is " -"the original face, i.e. the face that you want to remove and replace with " -"face B." +"Input directory. A directory containing training images for face A. This is the original face, i.e. the face that you want to " +"remove and replace with face B." msgstr "" -"Входная папка. Папка содержащая изображения для тренировки лица A. Это " -"исходное лицо т.е. лицо, которое вы хотите убрать, заменив лицом B." +"Входная папка. Папка содержащая изображения для тренировки лица A. Это исходное лицо т.е. лицо, которое вы хотите убрать, заменив " +"лицом B." #: lib/cli/args.py:881 msgid "" -"DEPRECATED - This option will be removed in a future update. Path to " -"alignments file for training set A. Defaults to /alignments.json if " -"not provided." +"DEPRECATED - This option will be removed in a future update. Path to alignments file for training set A. Defaults to /" +"alignments.json if not provided." msgstr "" -"УСТАРЕЛО - Эта настройка будет удалена в будущих обновлениях. Путь к файлу " -"выравнивания для обучающего набора A. По умолчанию используется /" -"alignments.json, если он не указан." +"УСТАРЕЛО - Эта настройка будет удалена в будущих обновлениях. Путь к файлу выравнивания для обучающего набора A. По умолчанию " +"используется /alignments.json, если он не указан." #: lib/cli/args.py:890 msgid "" -"Input directory. A directory containing training images for face B. This is " -"the swap face, i.e. the face that you want to place onto the head of person " -"A." +"Input directory. A directory containing training images for face B. This is the swap face, i.e. the face that you want to place " +"onto the head of person A." msgstr "" -"Входная папка. Папка содержащая изображения для тренировки лица B. Это новое " -"лицо т.е. лицо, которое вы хотите поместить на голову человека A." +"Входная папка. Папка содержащая изображения для тренировки лица B. Это новое лицо т.е. лицо, которое вы хотите поместить на " +"голову человека A." #: lib/cli/args.py:901 msgid "" -"DEPRECATED - This option will be removed in a future update. Path to " -"alignments file for training set B. Defaults to /alignments.json if " -"not provided." +"DEPRECATED - This option will be removed in a future update. Path to alignments file for training set B. Defaults to /" +"alignments.json if not provided." msgstr "" -"УСТАРЕЛО - Эта настройка будет удалена в будущих обновлениях. Путь к файлу " -"выравнивания для обучающего набора B. По умолчанию используется /" -"alignments.json, если он не указан." +"УСТАРЕЛО - Эта настройка будет удалена в будущих обновлениях. Путь к файлу выравнивания для обучающего набора B. По умолчанию " +"используется /alignments.json, если он не указан." #: lib/cli/args.py:909 lib/cli/args.py:921 msgid "model" @@ -655,102 +503,75 @@ msgstr "модель" #: lib/cli/args.py:910 msgid "" -"Model directory. This is where the training data will be stored. You should " -"always specify a new folder for new models. If starting a new model, select " -"either an empty folder, or a folder which does not exist (which will be " -"created). If continuing to train an existing model, specify the location of " -"the existing model." +"Model directory. This is where the training data will be stored. You should always specify a new folder for new models. If " +"starting a new model, select either an empty folder, or a folder which does not exist (which will be created). If continuing to " +"train an existing model, specify the location of the existing model." msgstr "" -"Папка сохранений модели. Здесь сохраняется прогресс тренировки. Следует " -"всегда создавать новую папку для новых моделей. При начале тренировки новой " -"модели, выберите пустую либо несуществующую папку (во втором случае она " -"будет создана). Если вы хотите продолжить тренировку, выберите папку с уже " -"существующими сохранениями." +"Папка сохранений модели. Здесь сохраняется прогресс тренировки. Следует всегда создавать новую папку для новых моделей. При " +"начале тренировки новой модели, выберите пустую либо несуществующую папку (во втором случае она будет создана). Если вы хотите " +"продолжить тренировку, выберите папку с уже существующими сохранениями." #: lib/cli/args.py:922 msgid "" -"R|Select which trainer to use. Trainers can be configured from the Settings " -"menu or the config folder.\n" +"R|Select which trainer to use. Trainers can be configured from the Settings menu or the config folder.\n" "L|original: The original model created by /u/deepfakes.\n" -"L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " -"for full dfaker method.\n" +"L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' for full dfaker method.\n" "L|dfl-h128: 128px in/out model from deepfacelab\n" "L|dfl-sae: Adaptable model from deepfacelab\n" "L|dlight: A lightweight, high resolution DFaker variant.\n" "L|iae: A model that uses intermediate layers to try to get better details\n" -"L|lightweight: A lightweight model for low-end cards. Don't expect great " -"results. Can train as low as 1.6GB with batch size 8.\n" -"L|realface: A high detail, dual density model based on DFaker, with " -"customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " -"won't work so well. By andenixa et al. Very configurable.\n" -"L|unbalanced: 128px in/out model from andenixa. The autoencoders are " -"unbalanced so B>A swaps won't work so well. Very configurable.\n" -"L|villain: 128px in/out model from villainguy. Very resource hungry (You " -"will require a GPU with a fair amount of VRAM). Good for details, but more " -"susceptible to color differences." -msgstr "" -"R|Выберите тренера для использования. Тренеры могут быть настроенны через " -"меню Настройки либо в папке config.\n" +"L|lightweight: A lightweight model for low-end cards. Don't expect great results. Can train as low as 1.6GB with batch size 8.\n" +"L|realface: A high detail, dual density model based on DFaker, with customizable in/out resolution. The autoencoders are " +"unbalanced so B>A swaps won't work so well. By andenixa et al. Very configurable.\n" +"L|unbalanced: 128px in/out model from andenixa. The autoencoders are unbalanced so B>A swaps won't work so well. Very " +"configurable.\n" +"L|villain: 128px in/out model from villainguy. Very resource hungry (You will require a GPU with a fair amount of VRAM). Good for " +"details, but more susceptible to color differences." +msgstr "" +"R|Выберите тренера для использования. Тренеры могут быть настроенны через меню Настройки либо в папке config.\n" "L|original: Оригинальная модель созданная /u/deepfakes.\n" -"L|dfaker: модель с 64px вход/128px выходом от dfaker. Включите 'warp-to-" -"landmarks' для полного соответствия методу dfaker.\n" +"L|dfaker: модель с 64px вход/128px выходом от dfaker. Включите 'warp-to-landmarks' для полного соответствия методу dfaker.\n" "L|dfl-h128: 128px вход/выход модель от deepfacelab\n" "L|dfl-sae: Адаптивная модель от deepfacelab\n" "L|dlight: Легковесная модель высокого разрешения. Один из вариантов DFaker.\n" -"L|iae: Модель использующая промежуточные слои, для достижения лучшей " -"детализции\n" -"L|lightweight: Легковесная модель для младшей линейки видеокарт. Не ожидайте " -"хороших результатов. Может тренировать на картах с 1.6Гб памяти при размере " -"серии 8.\n" -"L|realface: Модель повышенной детализации, с двумя сложносоставными слоями, " -"базированная на DFaker, с настраиваемым разрешением входа/выхода. " -"Автоэнкодеры не сбалансированы, поэтому свапы B>A не дадут хорошего " -"качества. andenixa и другие. Очень настраиваемая.\n" -"L|unbalanced: Модель 128px вход/выход от andenixa. Автоэнкодеры не " -"сбалансированы, поэтому свапы B>A не будут очень хорошими. Очень " -"настраеваемая.\n" -"L|villain: Модель 128px вход/выход от villainguy. Очень требовательна к " -"ресурсам (Вам потребуется GPU с хорошим количеством видеопамяти). Хороша для " -"деталей, но подвержена к неправильной передаче цвета." - -#: lib/cli/args.py:949 lib/cli/args.py:961 lib/cli/args.py:972 -#: lib/cli/args.py:1058 +"L|iae: Модель использующая промежуточные слои, для достижения лучшей детализции\n" +"L|lightweight: Легковесная модель для младшей линейки видеокарт. Не ожидайте хороших результатов. Может тренировать на картах с " +"1.6Гб памяти при размере серии 8.\n" +"L|realface: Модель повышенной детализации, с двумя сложносоставными слоями, базированная на DFaker, с настраиваемым разрешением " +"входа/выхода. Автоэнкодеры не сбалансированы, поэтому свапы B>A не дадут хорошего качества. andenixa и другие. Очень " +"настраиваемая.\n" +"L|unbalanced: Модель 128px вход/выход от andenixa. Автоэнкодеры не сбалансированы, поэтому свапы B>A не будут очень хорошими. " +"Очень настраеваемая.\n" +"L|villain: Модель 128px вход/выход от villainguy. Очень требовательна к ресурсам (Вам потребуется GPU с хорошим количеством " +"видеопамяти). Хороша для деталей, но подвержена к неправильной передаче цвета." + +#: lib/cli/args.py:949 lib/cli/args.py:961 lib/cli/args.py:972 lib/cli/args.py:1058 msgid "training" msgstr "тренировка" #: lib/cli/args.py:950 msgid "" -"Batch size. This is the number of images processed through the model for " -"each side per iteration. NB: As the model is fed 2 sides at a time, the " -"actual number of images within the model at any one time is double the " -"number that you set here. Larger batches require more GPU RAM." +"Batch size. This is the number of images processed through the model for each side per iteration. NB: As the model is fed 2 sides " +"at a time, the actual number of images within the model at any one time is double the number that you set here. Larger batches " +"require more GPU RAM." msgstr "" -"Размер партии. Это количество изображений для каждой стороны, которые " -"обрабатываются моделью за одну итерацию. Примечание: Поскольку в модель " -"передается сразу две стороны за раз, реальное количество загружаемых " -"изображений в два раза больше этого числа. Увеличение размера партии требует " -"больше памяти GPU." +"Размер партии. Это количество изображений для каждой стороны, которые обрабатываются моделью за одну итерацию. Примечание: " +"Поскольку в модель передается сразу две стороны за раз, реальное количество загружаемых изображений в два раза больше этого " +"числа. Увеличение размера партии требует больше памяти GPU." #: lib/cli/args.py:962 msgid "" -"Length of training in iterations. This is only really used for automation. " -"There is no 'correct' number of iterations a model should be trained for. " -"You should stop training when you are happy with the previews. However, if " -"you want the model to stop automatically at a set number of iterations, you " -"can set that value here." +"Length of training in iterations. This is only really used for automation. There is no 'correct' number of iterations a model " +"should be trained for. You should stop training when you are happy with the previews. However, if you want the model to stop " +"automatically at a set number of iterations, you can set that value here." msgstr "" -"Кол-во итераций для тренировки. Используется только для автоматизирования. " -"Не существует \"правильного\" кол-ва итераций для любой выбранной модели. " -"Тренировку стоит завершать только когда вы довольны кадрами на превью. " -"Однако, если вы хотите, чтобы тренировка прервалась после указанного кол-ва " -"итерация, вы можете ввести это здесь." +"Кол-во итераций для тренировки. Используется только для автоматизирования. Не существует \"правильного\" кол-ва итераций для " +"любой выбранной модели. Тренировку стоит завершать только когда вы довольны кадрами на превью. Однако, если вы хотите, чтобы " +"тренировка прервалась после указанного кол-ва итерация, вы можете ввести это здесь." #: lib/cli/args.py:973 -msgid "" -"Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." -msgstr "" -"Использовать стратегию зеркального распределения Tensorflow для совместной " -"тренировки сразу на нескольких GPU." +msgid "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." +msgstr "Использовать стратегию зеркального распределения Tensorflow для совместной тренировки сразу на нескольких GPU." #: lib/cli/args.py:983 lib/cli/args.py:993 msgid "Saving" @@ -761,12 +582,8 @@ msgid "Sets the number of iterations between each model save." msgstr "Установка количества итераций между сохранениями модели." #: lib/cli/args.py:994 -msgid "" -"Sets the number of iterations before saving a backup snapshot of the model " -"in it's current state. Set to 0 for off." -msgstr "" -"Устанавливает кол-во итераций перед созданием резервной копии модели. " -"Установите в 0 для отключения." +msgid "Sets the number of iterations before saving a backup snapshot of the model in it's current state. Set to 0 for off." +msgstr "Устанавливает кол-во итераций перед созданием резервной копии модели. Установите в 0 для отключения." #: lib/cli/args.py:1001 lib/cli/args.py:1012 lib/cli/args.py:1023 msgid "timelapse" @@ -774,122 +591,94 @@ msgstr "таймлапс" #: lib/cli/args.py:1002 msgid "" -"Optional for creating a timelapse. Timelapse will save an image of your " -"selected faces into the timelapse-output folder at every save iteration. " -"This should be the input folder of 'A' faces that you would like to use for " -"creating the timelapse. You must also supply a --timelapse-output and a --" -"timelapse-input-B parameter." +"Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every " +"save iteration. This should be the input folder of 'A' faces that you would like to use for creating the timelapse. You must also " +"supply a --timelapse-output and a --timelapse-input-B parameter." msgstr "" -"Только при создании таймлапсов. Сохраняет предварительный просмотр выбранных " -"лиц в папку timelapse-output при каждом сохранении. Следует указать входную " -"папку лиц набора 'A' для использования при создании таймлапса. Вам также " -"нужно указать параметры--timelapse-output и --timelapse-input-B." +"Только при создании таймлапсов. Сохраняет предварительный просмотр выбранных лиц в папку timelapse-output при каждом сохранении. " +"Следует указать входную папку лиц набора 'A' для использования при создании таймлапса. Вам также нужно указать параметры--" +"timelapse-output и --timelapse-input-B." #: lib/cli/args.py:1013 msgid "" -"Optional for creating a timelapse. Timelapse will save an image of your " -"selected faces into the timelapse-output folder at every save iteration. " -"This should be the input folder of 'B' faces that you would like to use for " -"creating the timelapse. You must also supply a --timelapse-output and a --" -"timelapse-input-A parameter." +"Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every " +"save iteration. This should be the input folder of 'B' faces that you would like to use for creating the timelapse. You must also " +"supply a --timelapse-output and a --timelapse-input-A parameter." msgstr "" -"Только при создании таймлапса. Таймлапс будет сохранять изображения " -"выбранных лиц в папке таймлапсов при каждой итерации сохранения. Это должна " -"быть папка для ввода лиц из набора 'B', для использования в создании " -"таймлапса. Вы также должны указать параметр --timelapse-output и --timelapse-" -"input-A." +"Только при создании таймлапса. Таймлапс будет сохранять изображения выбранных лиц в папке таймлапсов при каждой итерации " +"сохранения. Это должна быть папка для ввода лиц из набора 'B', для использования в создании таймлапса. Вы также должны указать " +"параметр --timelapse-output и --timelapse-input-A." #: lib/cli/args.py:1024 msgid "" -"Optional for creating a timelapse. Timelapse will save an image of your " -"selected faces into the timelapse-output folder at every save iteration. If " -"the input folders are supplied but no output folder, it will default to your " -"model folder /timelapse/" +"Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every " +"save iteration. If the input folders are supplied but no output folder, it will default to your model folder /timelapse/" msgstr "" -"Опционально, при создании таймлапса. Создаст картинку текущего таймлапса " -"выбранных лиц в папке timelapse-output при каждом сохранении модели. Если " -"указаны только входные папки, то по умолчанию вывод будет сохранен вместе с " -"моделью в подкаталог /timelapse/" +"Опционально, при создании таймлапса. Создаст картинку текущего таймлапса выбранных лиц в папке timelapse-output при каждом " +"сохранении модели. Если указаны только входные папки, то по умолчанию вывод будет сохранен вместе с моделью в подкаталог /" +"timelapse/" #: lib/cli/args.py:1036 lib/cli/args.py:1043 lib/cli/args.py:1050 msgid "preview" msgstr "предварительный просмотр" #: lib/cli/args.py:1037 -msgid "" -"Percentage amount to scale the preview by. 100%% is the model output size." -msgstr "" -"Величина в процентах, на которую требуется масштабировать предварительный " -"просмотр. 100 %% - размер вывода модели." +msgid "Percentage amount to scale the preview by. 100%% is the model output size." +msgstr "Величина в процентах, на которую требуется масштабировать предварительный просмотр. 100 %% - размер вывода модели." #: lib/cli/args.py:1044 msgid "Show training preview output. in a separate window." msgstr "Показывать предварительный просмотр в отдельном окне." #: lib/cli/args.py:1051 -msgid "" -"Writes the training result to a file. The image will be stored in the root " -"of your FaceSwap folder." -msgstr "" -"Записывает результат тренировки в файл. Файл будет сохранен в коренной папке " -"FaceSwap." +msgid "Writes the training result to a file. The image will be stored in the root of your FaceSwap folder." +msgstr "Записывает результат тренировки в файл. Файл будет сохранен в коренной папке FaceSwap." #: lib/cli/args.py:1059 msgid "" -"Disables TensorBoard logging. NB: Disabling logs means that you will not be " -"able to use the graph or analysis for this session in the GUI." +"Disables TensorBoard logging. NB: Disabling logs means that you will not be able to use the graph or analysis for this session in " +"the GUI." msgstr "" -"Отключает журнал TensorBoard. Примечание: Отключение журналов означает, что " -"вы не сможете использовать графики или анализ сессии внутри GUI." +"Отключает журнал TensorBoard. Примечание: Отключение журналов означает, что вы не сможете использовать графики или анализ сессии " +"внутри GUI." -#: lib/cli/args.py:1066 lib/cli/args.py:1075 lib/cli/args.py:1084 -#: lib/cli/args.py:1093 +#: lib/cli/args.py:1066 lib/cli/args.py:1075 lib/cli/args.py:1084 lib/cli/args.py:1093 msgid "augmentation" msgstr "аугментация" #: lib/cli/args.py:1067 msgid "" -"Warps training faces to closely matched Landmarks from the opposite face-set " -"rather than randomly warping the face. This is the 'dfaker' way of doing " -"warping." +"Warps training faces to closely matched Landmarks from the opposite face-set rather than randomly warping the face. This is the " +"'dfaker' way of doing warping." msgstr "" -"Вместо случайного искажения лица, деформирует лица в соответствии с " -"Ориентирами/Landmarks противоположного набора лиц. Этот способ используется " -"пакетом \"dfaker\"." +"Вместо случайного искажения лица, деформирует лица в соответствии с Ориентирами/Landmarks противоположного набора лиц. Этот " +"способ используется пакетом \"dfaker\"." #: lib/cli/args.py:1076 msgid "" -"To effectively learn, a random set of images are flipped horizontally. " -"Sometimes it is desirable for this not to occur. Generally this should be " -"left off except for during 'fit training'." +"To effectively learn, a random set of images are flipped horizontally. Sometimes it is desirable for this not to occur. Generally " +"this should be left off except for during 'fit training'." msgstr "" -"Для повышения эффективности обучения, некоторые изображения случайным " -"образом переворачивается по горизонтали. Иногда желательно, чтобы этого не " -"происходило. Как правило, эту настройку не стоит трогать, за исключением " -"периода «финальной шлифовки»." +"Для повышения эффективности обучения, некоторые изображения случайным образом переворачивается по горизонтали. Иногда желательно, " +"чтобы этого не происходило. Как правило, эту настройку не стоит трогать, за исключением периода «финальной шлифовки»." #: lib/cli/args.py:1085 msgid "" -"Color augmentation helps make the model less susceptible to color " -"differences between the A and B sets, at an increased training time cost. " -"Enable this option to disable color augmentation." +"Color augmentation helps make the model less susceptible to color differences between the A and B sets, at an increased training " +"time cost. Enable this option to disable color augmentation." msgstr "" -"Цветовая аугментация помогает модели быть менее чувствительной к разнице " -"цвета между наборами A and B ценой некоторого замедления скорости " -"тренировки. Включите эту опцию для отключения цветовой аугментации." +"Цветовая аугментация помогает модели быть менее чувствительной к разнице цвета между наборами A and B ценой некоторого замедления " +"скорости тренировки. Включите эту опцию для отключения цветовой аугментации." #: lib/cli/args.py:1094 msgid "" -"Warping is integral to training the Neural Network. This option should only " -"be enabled towards the very end of training to try to bring out more detail. " -"Think of it as 'fine-tuning'. Enabling this option from the beginning is " -"likely to kill a model and lead to terrible results." +"Warping is integral to training the Neural Network. This option should only be enabled towards the very end of training to try to " +"bring out more detail. Think of it as 'fine-tuning'. Enabling this option from the beginning is likely to kill a model and lead " +"to terrible results." msgstr "" -"Внесение случайных искажение является неотъемлемой частью обучения нейронной " -"сети. Эту опцию следует включать только в самом конце обучения, чтобы " -"попытаться выявить больше деталей. Думайте об этом как о «стадии шлифовки». " -"Включение этой опции с самого начала может убить модель и привести к ужасным " -"результатам." +"Внесение случайных искажение является неотъемлемой частью обучения нейронной сети. Эту опцию следует включать только в самом " +"конце обучения, чтобы попытаться выявить больше деталей. Думайте об этом как о «стадии шлифовки». Включение этой опции с самого " +"начала может убить модель и привести к ужасным результатам." #: lib/cli/args.py:1119 msgid "Output to Shell console instead of GUI console" From ea1f9978500c52b4c15627054af4900687ae1f82 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 10 Mar 2021 17:04:58 +0000 Subject: [PATCH 401/981] Translations - Create .pot files for GUI Tooltips and tools --- lib/gui/command.py | 9 +- lib/gui/display.py | 14 +- lib/gui/display_analysis.py | 13 +- lib/gui/display_command.py | 17 +- lib/gui/display_page.py | 9 +- lib/gui/menu.py | 34 +-- lib/gui/popup_configure.py | 16 +- lib/gui/popup_session.py | 31 +-- locales/es/LC_MESSAGES/tools.manual.cli.po | 77 ------- .../{tools.manual.cli.mo => tools.manual.mo} | Bin 2139 -> 2141 bytes locales/es/LC_MESSAGES/tools.manual.po | 201 +++++++++++++++++ ...{tools.preview.cli.mo => tools.preview.mo} | Bin 1596 -> 1598 bytes ...{tools.preview.cli.po => tools.preview.po} | 24 ++ locales/gui.tooltips.pot | 213 ++++++++++++++++++ locales/tools.manual.cli.pot | 49 ---- locales/tools.manual.pot | 197 ++++++++++++++++ ...ools.preview.cli.pot => tools.preview.pot} | 41 +++- tools/manual/cli.py | 2 +- tools/manual/faceviewer/frame.py | 9 +- .../manual/frameviewer/editor/bounding_box.py | 44 ++-- .../manual/frameviewer/editor/extract_box.py | 19 +- tools/manual/frameviewer/editor/landmarks.py | 14 +- tools/manual/frameviewer/editor/mask.py | 30 ++- tools/manual/frameviewer/frame.py | 35 +-- tools/preview/cli.py | 3 +- tools/preview/preview.py | 17 +- 26 files changed, 864 insertions(+), 254 deletions(-) delete mode 100644 locales/es/LC_MESSAGES/tools.manual.cli.po rename locales/es/LC_MESSAGES/{tools.manual.cli.mo => tools.manual.mo} (90%) create mode 100644 locales/es/LC_MESSAGES/tools.manual.po rename locales/es/LC_MESSAGES/{tools.preview.cli.mo => tools.preview.mo} (90%) rename locales/es/LC_MESSAGES/{tools.preview.cli.po => tools.preview.po} (80%) create mode 100644 locales/gui.tooltips.pot delete mode 100644 locales/tools.manual.cli.pot create mode 100644 locales/tools.manual.pot rename locales/{tools.preview.cli.pot => tools.preview.pot} (56%) diff --git a/lib/gui/command.py b/lib/gui/command.py index 08af864e13..137ce44ca0 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -2,6 +2,7 @@ """ The command frame for Faceswap GUI """ import logging +import gettext import tkinter as tk from tkinter import ttk @@ -11,6 +12,10 @@ logger = logging.getLogger(__name__) # pylint:disable=invalid-name +# LOCALES +_LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) +_ = _LANG.gettext + class CommandNotebook(ttk.Notebook): # pylint:disable=too-many-ancestors """ Frame to hold each individual tab of the command notebook """ @@ -175,7 +180,7 @@ def add_action_button(self, category, actionbtns): command=lambda: tk_vars["generate"].set(var_value)) btngen.pack(side=tk.LEFT, padx=5) Tooltip(btngen, - text="Output command line options to the console", + text=_("Output command line options to the console"), wraplength=200) btnact = ttk.Button(actframe, @@ -186,7 +191,7 @@ def add_action_button(self, category, actionbtns): command=lambda: tk_vars["action"].set(var_value)) btnact.pack(side=tk.LEFT, fill=tk.X, expand=True) Tooltip(btnact, - text="Run the {} script".format(self.title), + text=_("Run the {} script").format(self.title), wraplength=200) actionbtns[self.command] = btnact diff --git a/lib/gui/display.py b/lib/gui/display.py index f1ec6be950..86141296cd 100644 --- a/lib/gui/display.py +++ b/lib/gui/display.py @@ -6,6 +6,7 @@ task. """ import logging +import gettext import tkinter as tk from tkinter import ttk @@ -15,6 +16,10 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name +# LOCALES +_LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) +_ = _LANG.gettext + class DisplayNotebook(ttk.Notebook): # pylint: disable=too-many-ancestors """ The tkinter Notebook that holds the display items. @@ -63,7 +68,7 @@ def _add_static_tabs(self): continue # Not yet implemented if tab == "analysis": helptext = {"stats": - "Summary statistics for each training session"} + _("Summary statistics for each training session")} frame = Analysis(self, tab, helptext) else: frame = self._add_frame() @@ -105,8 +110,7 @@ def _extract_tabs(self, command="extract"): """ logger.debug("Build extract tabs") - helptext = ("Updates preview from output every 5 " - "seconds to limit disk contention") + helptext = _("Preview updates every 5 seconds") PreviewExtract(self, "preview", helptext, 5000, command) logger.debug("Built extract tabs") @@ -115,10 +119,10 @@ def _train_tabs(self): logger.debug("Build train tabs") for tab in ("graph", "preview"): if tab == "graph": - helptext = "Graph showing Loss vs Iterations" + helptext = _("Graph showing Loss vs Iterations") GraphDisplay(self, "graph", helptext, 5000) elif tab == "preview": - helptext = "Training preview. Updated on every save iteration" + helptext = _("Training preview. Updated on every save iteration") PreviewTrain(self, "preview", helptext, 1000) logger.debug("Built train tabs") diff --git a/lib/gui/display_analysis.py b/lib/gui/display_analysis.py index c1cc9445f0..c3985240f4 100644 --- a/lib/gui/display_analysis.py +++ b/lib/gui/display_analysis.py @@ -2,6 +2,7 @@ """ Analysis tab of Display Frame of the Faceswap GUI """ import csv +import gettext import logging import os import tkinter as tk @@ -15,6 +16,10 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name +# LOCALES +_LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) +_ = _LANG.gettext + class Analysis(DisplayPage): # pylint: disable=too-many-ancestors """ Session Analysis Tab. @@ -334,13 +339,13 @@ def _set_help(cls, button_type): logger.debug("Setting help") hlp = "" if button_type == "reload": - hlp = "Load/Refresh stats for the currently training session" + hlp = _("Load/Refresh stats for the currently training session") elif button_type == "clear": - hlp = "Clear currently displayed session stats" + hlp = _("Clear currently displayed session stats") elif button_type == "save": - hlp = "Save session stats to csv" + hlp = _("Save session stats to csv") elif button_type == "load": - hlp = "Load saved session stats" + hlp = _("Load saved session stats") return hlp def _add_training_callback(self): diff --git a/lib/gui/display_command.py b/lib/gui/display_command.py index a55cfaa357..36304a1a76 100644 --- a/lib/gui/display_command.py +++ b/lib/gui/display_command.py @@ -1,6 +1,7 @@ #!/usr/bin python3 """ Command specific tabs of Display Frame of the Faceswap GUI """ import datetime +import gettext import logging import os import tkinter as tk @@ -17,6 +18,10 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name +# LOCALES +_LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) +_ = _LANG.gettext + class PreviewExtract(DisplayOptionalPage): # pylint: disable=too-many-ancestors """ Tab to display output preview images for extract and convert """ @@ -86,7 +91,7 @@ def add_option_refresh(self): command=preview_trigger().set) btnrefresh.pack(padx=2, side=tk.RIGHT) Tooltip(btnrefresh, - text="Preview updates at every model save. Click to refresh now.", + text=_("Preview updates at every model save. Click to refresh now."), wraplength=200) logger.debug("Added refresh option") @@ -253,7 +258,7 @@ def _add_option_refresh(self): command=lambda: tk_var.set(True)) btnrefresh.pack(padx=2, side=tk.RIGHT) Tooltip(btnrefresh, - text="Graph updates at every model save. Click to refresh now.", + text=_("Graph updates at every model save. Click to refresh now."), wraplength=200) logger.debug("Added refresh option") @@ -267,7 +272,7 @@ def _add_option_raw(self): text="Raw", command=lambda v=tk_var: self._display_data_callback("raw", v)) chkbtn.pack(side=tk.RIGHT, padx=5, anchor=tk.W) - Tooltip(chkbtn, text="Display the raw loss data", wraplength=200) + Tooltip(chkbtn, text=_("Display the raw loss data"), wraplength=200) def _add_option_smoothed(self): """ Add check-button to hide/display smoothed data """ @@ -279,14 +284,14 @@ def _add_option_smoothed(self): text="Smoothed", command=lambda v=tk_var: self._display_data_callback("smoothed", v)) chkbtn.pack(side=tk.RIGHT, padx=5, anchor=tk.W) - Tooltip(chkbtn, text="Display the smoothed loss data", wraplength=200) + Tooltip(chkbtn, text=_("Display the smoothed loss data"), wraplength=200) def _add_option_smoothing(self): """ Add a slider to adjust the smoothing amount """ logger.debug("Adding Smoothing Slider") tk_var = self.vars["smoothgraph"] min_max = (0, 0.999) - hlp = "Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing." + hlp = _("Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing.") ctl_frame = ttk.Frame(self.optsframe) ctl_frame.pack(padx=2, side=tk.RIGHT) @@ -316,7 +321,7 @@ def _add_option_iterations(self): logger.debug("Adding Iterations Slider") tk_var = self.vars["display_iterations"] min_max = (0, 100000) - hlp = "Set the number of iterations to display. 0 displays the full session." + hlp = _("Set the number of iterations to display. 0 displays the full session.") ctl_frame = ttk.Frame(self.optsframe) ctl_frame.pack(padx=2, side=tk.RIGHT) diff --git a/lib/gui/display_page.py b/lib/gui/display_page.py index 4ae596b858..354b07f21d 100644 --- a/lib/gui/display_page.py +++ b/lib/gui/display_page.py @@ -1,6 +1,7 @@ #!/usr/bin python3 """ Display Page parent classes for display section of the Faceswap GUI """ +import gettext import logging import tkinter as tk from tkinter import ttk @@ -10,6 +11,10 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name +# LOCALES +_LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) +_ = _LANG.gettext + class DisplayPage(ttk.Frame): # pylint: disable=too-many-ancestors """ Parent frame holder for each tab. @@ -230,7 +235,7 @@ def add_option_save(self): command=self.save_items) btnsave.pack(padx=2, side=tk.RIGHT) Tooltip(btnsave, - text="Save {}(s) to file".format(self.tabname), + text=_("Save {}(s) to file").format(self.tabname), wraplength=200) def add_option_enable(self): @@ -242,7 +247,7 @@ def add_option_enable(self): command=self.on_chkenable_change) chkenable.pack(side=tk.RIGHT, padx=5, anchor=tk.W) Tooltip(chkenable, - text="Enable or disable {} display".format(self.tabname), + text=_("Enable or disable {} display").format(self.tabname), wraplength=200) def save_items(self): diff --git a/lib/gui/menu.py b/lib/gui/menu.py index b7f1ee1853..16f431d8a2 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -1,6 +1,7 @@ #!/usr/bin python3 """ The Menu Bars for faceswap GUI """ +import gettext import locale import logging import os @@ -20,15 +21,18 @@ from .custom_widgets import Tooltip from .utils import get_config, get_images -_RESOURCES = [("faceswap.dev - Guides and Forum", "https://www.faceswap.dev"), - ("Patreon - Support this project", "https://www.patreon.com/faceswap"), - ("Discord - The FaceSwap Discord server", "https://discord.gg/VasFUAy"), - ("Github - Our Source Code", "https://github.com/deepfakes/faceswap")] +logger = logging.getLogger(__name__) # pylint: disable=invalid-name -_WORKING_DIR = os.path.dirname(os.path.realpath(sys.argv[0])) +# LOCALES +_LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) +_ = _LANG.gettext +_WORKING_DIR = os.path.dirname(os.path.realpath(sys.argv[0])) -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +_RESOURCES = [(_("faceswap.dev - Guides and Forum"), "https://www.faceswap.dev"), + (_("Patreon - Support this project"), "https://www.patreon.com/faceswap"), + (_("Discord - The FaceSwap Discord server"), "https://discord.gg/VasFUAy"), + (_("Github - Our Source Code"), "https://github.com/deepfakes/faceswap")] class MainMenuBar(tk.Menu): # pylint:disable=too-many-ancestors @@ -520,7 +524,7 @@ def _settings_btns(self): image=get_images().icons[btntype], command=lambda n=name: open_popup(name=n)) btn.pack(side=tk.LEFT, anchor=tk.W) - hlp = "Configure {} settings...".format(name.title()) + hlp = _("Configure {} settings...").format(name.title()) Tooltip(btn, text=hlp, wraplength=200) @staticmethod @@ -528,22 +532,22 @@ def set_help(btntype): """ Set the helptext for option buttons """ logger.debug("Setting help") hlp = "" - task = "currently selected Task" if btntype[-1] == "2" else "Project" + task = _("currently selected Task") if btntype[-1] == "2" else _("Project") if btntype.startswith("reload"): - hlp = "Reload {} from disk".format(task) + hlp = _("Reload {} from disk").format(task) if btntype == "new": - hlp = "Create a new {}...".format(task) + hlp = _("Create a new {}...").format(task) if btntype.startswith("clear"): - hlp = "Reset {} to default".format(task) + hlp = _("Reset {} to default").format(task) elif btntype.startswith("save") and "_" not in btntype: - hlp = "Save {}".format(task) + hlp = _("Save {}").format(task) elif btntype.startswith("save_as"): - hlp = "Save {} as...".format(task) + hlp = _("Save {} as...").format(task) elif btntype.startswith("load"): msg = task if msg.endswith("Task"): - msg += " from a task or project file" - hlp = "Load {}...".format(msg) + msg += _(" from a task or project file") + hlp = _("Load {}...").format(msg) return hlp def _group_separator(self): diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index c8d412826d..e1f0fe273b 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -3,6 +3,7 @@ from collections import OrderedDict from configparser import ConfigParser +import gettext import logging import os import sys @@ -15,6 +16,11 @@ from .utils import get_config, get_images logger = logging.getLogger(__name__) # pylint: disable=invalid-name + +# LOCALES +_LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) +_ = _LANG.gettext + _POPUP = [] _CONFIG_FILES = [] _CONFIGS = dict() @@ -198,14 +204,14 @@ def _build_footer(self): width=10, command=lambda: self._opts_frame.reset(page_only=True)) - Tooltip(btn_cls, text="Close without saving", wraplength=720) - Tooltip(btn_save, text="Save this page's config", wraplength=720) - Tooltip(btn_rst, text="Reset this page's config to default values", wraplength=720) + Tooltip(btn_cls, text=_("Close without saving"), wraplength=720) + Tooltip(btn_save, text=_("Save this page's config"), wraplength=720) + Tooltip(btn_rst, text=_("Reset this page's config to default values"), wraplength=720) Tooltip(btn_saveall, - text="Save all settings for the currently selected config", + text=_("Save all settings for the currently selected config"), wraplength=720) Tooltip(btn_rstall, - text="Reset all settings for the currently selected config to default values", + text=_("Reset all settings for the currently selected config to default values"), wraplength=720) btn_cls.pack(padx=2, side=tk.RIGHT) diff --git a/lib/gui/popup_session.py b/lib/gui/popup_session.py index 8e85027294..e07df3e696 100644 --- a/lib/gui/popup_session.py +++ b/lib/gui/popup_session.py @@ -2,6 +2,7 @@ """ Pop-up Graph launched from the Analysis tab of the Faceswap GUI """ import csv +import gettext import logging import tkinter as tk from tkinter import ttk @@ -14,6 +15,10 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name +# LOCALES +_LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) +_ = _LANG.gettext + class SessionPopUp(tk.Toplevel): """ Pop up for detailed graph/stats for selected session. @@ -183,7 +188,7 @@ def _opts_loss_keys(self, frame): continue text = loss_key.replace("_", " ").title() - helptext = "Display {}".format(text) + helptext = _("Display {}").format(text) var = tk.BooleanVar() var.set(True) @@ -334,28 +339,28 @@ def _set_help(cls, action): hlp = "" action = action.lower() if action == "reload": - hlp = "Refresh graph" + hlp = _("Refresh graph") elif action == "save": - hlp = "Save display data to csv" + hlp = _("Save display data to csv") elif action == "avgiterations": - hlp = "Number of data points to sample for rolling average" + hlp = _("Number of data points to sample for rolling average") elif action == "smoothamount": - hlp = "Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing" + hlp = _("Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing") elif action == "outliers": - hlp = "Flatten data points that fall more than 1 standard " \ - "deviation from the mean to the mean value." + hlp = _("Flatten data points that fall more than 1 standard deviation from the mean " + "to the mean value.") elif action == "avg": - hlp = "Display rolling average of the data" + hlp = _("Display rolling average of the data") elif action == "smoothed": - hlp = "Smooth the data" + hlp = _("Smooth the data") elif action == "raw": - hlp = "Display raw data" + hlp = _("Display raw data") elif action == "trend": - hlp = "Display polynormal data trend" + hlp = _("Display polynormal data trend") elif action == "display": - hlp = "Set the data to display" + hlp = _("Set the data to display") elif action == "scale": - hlp = "Change y-axis scale" + hlp = _("Change y-axis scale") return hlp def _compile_display_data(self): diff --git a/locales/es/LC_MESSAGES/tools.manual.cli.po b/locales/es/LC_MESSAGES/tools.manual.cli.po deleted file mode 100644 index aad8921b10..0000000000 --- a/locales/es/LC_MESSAGES/tools.manual.cli.po +++ /dev/null @@ -1,77 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR ORGANIZATION -# FIRST AUTHOR , YEAR. -# -msgid "" -msgstr "" -"Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-02-18 23:17-0000\n" -"PO-Revision-Date: 2021-02-19 17:43+0000\n" -"Language-Team: tokafondo\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 2.3\n" -"Last-Translator: \n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"Language: es_ES\n" - -#: tools/manual/cli.py:13 -msgid "" -"This command lets you perform various actions on frames, faces and " -"alignments files using visual tools." -msgstr "" -"Este comando le permite realizar varias acciones en los archivos de " -"fotogramas, caras y alineaciones utilizando herramientas visuales." - -#: tools/manual/cli.py:23 -msgid "" -"A tool to perform various actions on frames, faces and alignments files " -"using visual tools" -msgstr "" -"Una herramienta que permite realizar diversas acciones en archivos de " -"fotogramas, caras y alineaciones mediante herramientas visuales" - -#: tools/manual/cli.py:35 tools/manual/cli.py:43 -msgid "data" -msgstr "datos" - -#: tools/manual/cli.py:37 -msgid "" -"Path to the alignments file for the input, if not at the default location" -msgstr "" -"Ruta del archivo de alineaciones para la entrada, si no está en la ubicación " -"por defecto" - -#: tools/manual/cli.py:44 -msgid "" -"Video file or directory containing source frames that faces were extracted " -"from." -msgstr "" -"Archivo o directorio de vídeo que contiene los fotogramas de origen de los " -"que se extrajeron las caras." - -#: tools/manual/cli.py:51 tools/manual/cli.py:59 -msgid "options" -msgstr "opciones" - -#: tools/manual/cli.py:52 -msgid "" -"Force regeneration of the low resolution jpg thumbnails in the alignments " -"file." -msgstr "" -"Forzar la regeneración de las miniaturas jpg de baja resolución en el " -"archivo de alineaciones." - -#: tools/manual/cli.py:60 -msgid "" -"The process attempts to speed up generation of thumbnails by extracting from " -"the video in parallel threads. For some videos, this causes the caching " -"process to hang. If this happens, then set this option to generate the " -"thumbnails in a slower, but more stable single thread." -msgstr "" -"El proceso intenta acelerar la generación de miniaturas extrayendo del vídeo " -"en hilos paralelos. En algunos vídeos, esto hace que el proceso de " -"extracción se cuelgue. Si esto sucede, entonces configure esta opción para " -"generar las miniaturas en un solo hilo más lento, pero más estable." diff --git a/locales/es/LC_MESSAGES/tools.manual.cli.mo b/locales/es/LC_MESSAGES/tools.manual.mo similarity index 90% rename from locales/es/LC_MESSAGES/tools.manual.cli.mo rename to locales/es/LC_MESSAGES/tools.manual.mo index 931de6c47675b66a76a557775f4c9a12bc787e9b..b56085b4bf0d0a5f1f27d8c712bd02f5e52b9deb 100644 GIT binary patch delta 104 zcmcaDa93bLimMwV1H*b|28LD!28M4e3=9cCx`vg3Aqq&}1=6ZOc?&iMhBhESj-7!a x14usw(m*K&KMn>4AYusFxYC)K%UIXYK*7+=%EWx~H|BNBCVEDjm$GbQ1^~vA6Mg^y delta 102 zcmcaBa9dzPimMAF1H*b|28LD!28J&z3=9cCx{8&7Aqq&}2GXiPc{4T!hBhEShMj>S v14usx(m*K&9}WfvAYusGxYC)K%ShMIQo+#N%EWl`H|BMW#+#S3Y-0uho=6iA diff --git a/locales/es/LC_MESSAGES/tools.manual.po b/locales/es/LC_MESSAGES/tools.manual.po new file mode 100644 index 0000000000..7fb77e72fa --- /dev/null +++ b/locales/es/LC_MESSAGES/tools.manual.po @@ -0,0 +1,201 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: faceswap.spanish\n" +"POT-Creation-Date: 2021-02-18 23:17-0000\n" +"PO-Revision-Date: 2021-03-10 16:47+0000\n" +"Language-Team: tokafondo\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 2.4.2\n" +"Last-Translator: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: es_ES\n" + +#: tools/manual/cli.py:13 +msgid "This command lets you perform various actions on frames, faces and alignments files using visual tools." +msgstr "Este comando le permite realizar varias acciones en los archivos de fotogramas, caras y alineaciones utilizando herramientas visuales." + +#: tools/manual/cli.py:23 +msgid "A tool to perform various actions on frames, faces and alignments files using visual tools" +msgstr "Una herramienta que permite realizar diversas acciones en archivos de fotogramas, caras y alineaciones mediante herramientas visuales" + +#: tools/manual/cli.py:35 tools/manual/cli.py:43 +msgid "data" +msgstr "datos" + +#: tools/manual/cli.py:37 +msgid "Path to the alignments file for the input, if not at the default location" +msgstr "Ruta del archivo de alineaciones para la entrada, si no está en la ubicación por defecto" + +#: tools/manual/cli.py:44 +msgid "Video file or directory containing source frames that faces were extracted from." +msgstr "Archivo o directorio de vídeo que contiene los fotogramas de origen de los que se extrajeron las caras." + +#: tools/manual/cli.py:51 tools/manual/cli.py:59 +msgid "options" +msgstr "opciones" + +#: tools/manual/cli.py:52 +msgid "Force regeneration of the low resolution jpg thumbnails in the alignments file." +msgstr "Forzar la regeneración de las miniaturas jpg de baja resolución en el archivo de alineaciones." + +#: tools/manual/cli.py:60 +msgid "" +"The process attempts to speed up generation of thumbnails by extracting from the video in parallel threads. For some videos, this causes the caching process to hang. If this happens, then set this option to generate the thumbnails " +"in a slower, but more stable single thread." +msgstr "" +"El proceso intenta acelerar la generación de miniaturas extrayendo del vídeo en hilos paralelos. En algunos vídeos, esto hace que el proceso de extracción se cuelgue. Si esto sucede, entonces configure esta opción para generar las " +"miniaturas en un solo hilo más lento, pero más estable." + +#: tools/manual/faceviewer\frame.py:163 +msgid "Display the landmarks mesh" +msgstr "" + +#: tools/manual/faceviewer\frame.py:164 +msgid "Display the mask" +msgstr "" + +#: tools/manual/frameviewer\editor\bounding_box.py:33 tools/manual/frameviewer\editor\extract_box.py:32 +msgid "Delete Face" +msgstr "" + +#: tools/manual/frameviewer\editor\bounding_box.py:36 +msgid "" +"Bounding Box Editor\n" +"Edit the bounding box being fed into the aligner to recalculate the landmarks.\n" +"\n" +" - Grab the corner anchors to resize the bounding box.\n" +" - Click and drag the bounding box to relocate.\n" +" - Click in empty space to create a new bounding box.\n" +" - Right click a bounding box to delete a face." +msgstr "" + +#: tools/manual/frameviewer\editor\bounding_box.py:70 +msgid "Aligner to use. FAN will obtain better alignments, but cv2-dnn can be useful if FAN cannot get decent alignments and you want to set a base to edit from." +msgstr "" + +#: tools/manual/frameviewer\editor\bounding_box.py:83 +msgid "" +"Normalization method to use for feeding faces to the aligner. This can help the aligner better align faces with difficult lighting conditions. Different methods will yield different results on different sets. NB: This does not " +"impact the output face, just the input to the aligner.\n" +"\tnone: Don't perform normalization on the face.\n" +"\tclahe: Perform Contrast Limited Adaptive Histogram Equalization on the face.\n" +"\thist: Equalize the histograms on the RGB channels.\n" +"\tmean: Normalize the face colors to the mean." +msgstr "" + +#: tools/manual/frameviewer\editor\extract_box.py:35 +msgid "" +"Extract Box Editor\n" +"Move the extract box that has been generated by the aligner. Click and drag:\n" +"\n" +" - Inside the bounding box to relocate the landmarks.\n" +" - The corner anchors to resize the landmarks.\n" +" - Outside of the corners to rotate the landmarks." +msgstr "" + +#: tools/manual/frameviewer\editor\landmarks.py:27 +msgid "" +"Landmark Point Editor\n" +"Edit the individual landmark points.\n" +"\n" +" - Click and drag individual points to relocate.\n" +" - Draw a box to select multiple points to relocate." +msgstr "" + +#: tools/manual/frameviewer\editor\landmarks.py:44 tools/manual/frameviewer\editor\mask.py:75 +msgid "Magnify/Demagnify the View" +msgstr "" + +#: tools/manual/frameviewer\editor\mask.py:33 +msgid "" +"Mask Editor\n" +"Edit the mask.\n" +" - NB: For Landmark based masks (e.g. components/extended) it is better to make sure the landmarks are correct rather than editing the mask directly. Any change to the landmarks after editing the mask will override your manual edits." +msgstr "" + +#: tools/manual/frameviewer\editor\mask.py:77 +msgid "Draw Tool" +msgstr "" + +#: tools/manual/frameviewer\editor\mask.py:78 +msgid "Erase Tool" +msgstr "" + +#: tools/manual/frameviewer\editor\mask.py:97 +msgid "Select which mask to edit" +msgstr "" + +#: tools/manual/frameviewer\editor\mask.py:104 +msgid "Set the brush size. ([ - decrease, ] - increase)" +msgstr "" + +#: tools/manual/frameviewer\editor\mask.py:111 +msgid "Select the brush cursor color." +msgstr "" + +#: tools/manual/frameviewer\frame.py:77 +msgid "Play/Pause (SPACE)" +msgstr "" + +#: tools/manual/frameviewer\frame.py:78 +msgid "Go to First Frame (HOME)" +msgstr "" + +#: tools/manual/frameviewer\frame.py:79 +msgid "Go to Previous Frame (Z)" +msgstr "" + +#: tools/manual/frameviewer\frame.py:80 +msgid "Go to Next Frame (X)" +msgstr "" + +#: tools/manual/frameviewer\frame.py:81 +msgid "Go to Last Frame (END)" +msgstr "" + +#: tools/manual/frameviewer\frame.py:82 +msgid "Extract the faces to a folder... (Ctrl+E)" +msgstr "" + +#: tools/manual/frameviewer\frame.py:83 +msgid "Save the Alignments file (Ctrl+S)" +msgstr "" + +#: tools/manual/frameviewer\frame.py:84 +msgid "Filter Frames to only those Containing the Selected Item (F)" +msgstr "" + +#: tools/manual/frameviewer\frame.py:318 +msgid "View alignments" +msgstr "" + +#: tools/manual/frameviewer\frame.py:319 +msgid "Bounding box editor" +msgstr "" + +#: tools/manual/frameviewer\frame.py:320 +msgid "Location editor" +msgstr "" + +#: tools/manual/frameviewer\frame.py:321 +msgid "Mask editor" +msgstr "" + +#: tools/manual/frameviewer\frame.py:322 +msgid "Landmark point editor" +msgstr "" + +#: tools/manual/frameviewer\frame.py:408 +msgid "Revert to saved Alignments ({})" +msgstr "" + +#: tools/manual/frameviewer\frame.py:414 +msgid "Copy {} Alignments ({})" +msgstr "" diff --git a/locales/es/LC_MESSAGES/tools.preview.cli.mo b/locales/es/LC_MESSAGES/tools.preview.mo similarity index 90% rename from locales/es/LC_MESSAGES/tools.preview.cli.mo rename to locales/es/LC_MESSAGES/tools.preview.mo index dfb8811cc3aa1aa0260d0345fed69701e7b35d8f..ca74dc62269b043198e88ef43b2a3d1419b10565 100644 GIT binary patch delta 81 zcmdnPvyW#&jIA3Z1H(Kf28K2U28O513=EM#I){aUAqq$z1Jap5T9K83!3{`v1L;&C ZeF;bd5rf6XnZKBrP4tX5Phnoc1OQ5b4y^zH delta 79 zcmdnTvxjFwjI9eJ1H(Kf28K2U28PGX3=EM#I*WyYAqq$z0n(X3TAr1G!3{`v0_jvB XeE~=V5rf&rnZKAAjW B, swap B -> A" msgstr "" "Intercambiar el modelo. En lugar de convertir A en B, convierte B en A" + +#: ./tools/preview\preview.py:1303 +msgid "Save full config" +msgstr "" + +#: ./tools/preview\preview.py:1306 +msgid "Reset full config to default values" +msgstr "" + +#: ./tools/preview\preview.py:1309 +msgid "Reset full config to saved values" +msgstr "" + +#: ./tools/preview\preview.py:1453 +msgid "Save {} config" +msgstr "" + +#: ./tools/preview\preview.py:1456 +msgid "Reset {} config to default values" +msgstr "" + +#: ./tools/preview\preview.py:1459 +msgid "Reset {} config to saved values" +msgstr "" diff --git a/locales/gui.tooltips.pot b/locales/gui.tooltips.pot new file mode 100644 index 0000000000..a84ee63c8b --- /dev/null +++ b/locales/gui.tooltips.pot @@ -0,0 +1,213 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2021-03-10 15:35-0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=cp1252\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" + + +#: ./lib/gui/command.py:180 +msgid "Output command line options to the console" +msgstr "" + +#: ./lib/gui/command.py:191 +msgid "Run the {} script" +msgstr "" + +#: ./lib/gui/display.py:69 +msgid "Summary statistics for each training session" +msgstr "" + +#: ./lib/gui/display.py:111 +msgid "Preview updates every 5 seconds" +msgstr "" + +#: ./lib/gui/display.py:120 +msgid "Graph showing Loss vs Iterations" +msgstr "" + +#: ./lib/gui/display.py:123 +msgid "Training preview. Updated on every save iteration" +msgstr "" + +#: ./lib/gui/display_analysis.py:340 +msgid "Load/Refresh stats for the currently training session" +msgstr "" + +#: ./lib/gui/display_analysis.py:342 +msgid "Clear currently displayed session stats" +msgstr "" + +#: ./lib/gui/display_analysis.py:344 +msgid "Save session stats to csv" +msgstr "" + +#: ./lib/gui/display_analysis.py:346 +msgid "Load saved session stats" +msgstr "" + +#: ./lib/gui/display_command.py:91 +msgid "Preview updates at every model save. Click to refresh now." +msgstr "" + +#: ./lib/gui/display_command.py:258 +msgid "Graph updates at every model save. Click to refresh now." +msgstr "" + +#: ./lib/gui/display_command.py:272 +msgid "Display the raw loss data" +msgstr "" + +#: ./lib/gui/display_command.py:284 +msgid "Display the smoothed loss data" +msgstr "" + +#: ./lib/gui/display_command.py:291 +msgid "Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing." +msgstr "" + +#: ./lib/gui/display_command.py:321 +msgid "Set the number of iterations to display. 0 displays the full session." +msgstr "" + +#: ./lib/gui/display_page.py:235 +msgid "Save {}(s) to file" +msgstr "" + +#: ./lib/gui/display_page.py:247 +msgid "Enable or disable {} display" +msgstr "" + +#: ./lib/gui/menu.py:30 +msgid "faceswap.dev - Guides and Forum" +msgstr "" + +#: ./lib/gui/menu.py:31 +msgid "Patreon - Support this project" +msgstr "" + +#: ./lib/gui/menu.py:32 +msgid "Discord - The FaceSwap Discord server" +msgstr "" + +#: ./lib/gui/menu.py:33 +msgid "Github - Our Source Code" +msgstr "" + +#: ./lib/gui/menu.py:524 +msgid "Configure {} settings..." +msgstr "" + +#: ./lib/gui/menu.py:532 +msgid "Project" +msgstr "" + +#: ./lib/gui/menu.py:532 +msgid "currently selected Task" +msgstr "" + +#: ./lib/gui/menu.py:534 +msgid "Reload {} from disk" +msgstr "" + +#: ./lib/gui/menu.py:536 +msgid "Create a new {}..." +msgstr "" + +#: ./lib/gui/menu.py:538 +msgid "Reset {} to default" +msgstr "" + +#: ./lib/gui/menu.py:540 +msgid "Save {}" +msgstr "" + +#: ./lib/gui/menu.py:542 +msgid "Save {} as..." +msgstr "" + +#: ./lib/gui/menu.py:546 +msgid " from a task or project file" +msgstr "" + +#: ./lib/gui/menu.py:547 +msgid "Load {}..." +msgstr "" + +#: ./lib/gui/popup_configure.py:205 +msgid "Close without saving" +msgstr "" + +#: ./lib/gui/popup_configure.py:206 +msgid "Save this page's config" +msgstr "" + +#: ./lib/gui/popup_configure.py:207 +msgid "Reset this page's config to default values" +msgstr "" + +#: ./lib/gui/popup_configure.py:209 +msgid "Save all settings for the currently selected config" +msgstr "" + +#: ./lib/gui/popup_configure.py:212 +msgid "Reset all settings for the currently selected config to default values" +msgstr "" + +#: ./lib/gui/popup_session.py:188 +msgid "Display {}" +msgstr "" + +#: ./lib/gui/popup_session.py:339 +msgid "Refresh graph" +msgstr "" + +#: ./lib/gui/popup_session.py:341 +msgid "Save display data to csv" +msgstr "" + +#: ./lib/gui/popup_session.py:343 +msgid "Number of data points to sample for rolling average" +msgstr "" + +#: ./lib/gui/popup_session.py:345 +msgid "Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing" +msgstr "" + +#: ./lib/gui/popup_session.py:347 +msgid "Flatten data points that fall more than 1 standard deviation from the mean to the mean value." +msgstr "" + +#: ./lib/gui/popup_session.py:350 +msgid "Display rolling average of the data" +msgstr "" + +#: ./lib/gui/popup_session.py:352 +msgid "Smooth the data" +msgstr "" + +#: ./lib/gui/popup_session.py:354 +msgid "Display raw data" +msgstr "" + +#: ./lib/gui/popup_session.py:356 +msgid "Display polynormal data trend" +msgstr "" + +#: ./lib/gui/popup_session.py:358 +msgid "Set the data to display" +msgstr "" + +#: ./lib/gui/popup_session.py:360 +msgid "Change y-axis scale" +msgstr "" + diff --git a/locales/tools.manual.cli.pot b/locales/tools.manual.cli.pot deleted file mode 100644 index bf3e0a4729..0000000000 --- a/locales/tools.manual.cli.pot +++ /dev/null @@ -1,49 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR ORGANIZATION -# FIRST AUTHOR , YEAR. -# -msgid "" -msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-02-18 23:17-0000\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=cp1252\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" - - -#: tools/manual/cli.py:13 -msgid "This command lets you perform various actions on frames, faces and alignments files using visual tools." -msgstr "" - -#: tools/manual/cli.py:23 -msgid "A tool to perform various actions on frames, faces and alignments files using visual tools" -msgstr "" - -#: tools/manual/cli.py:35 tools/manual/cli.py:43 -msgid "data" -msgstr "" - -#: tools/manual/cli.py:37 -msgid "Path to the alignments file for the input, if not at the default location" -msgstr "" - -#: tools/manual/cli.py:44 -msgid "Video file or directory containing source frames that faces were extracted from." -msgstr "" - -#: tools/manual/cli.py:51 tools/manual/cli.py:59 -msgid "options" -msgstr "" - -#: tools/manual/cli.py:52 -msgid "Force regeneration of the low resolution jpg thumbnails in the alignments file." -msgstr "" - -#: tools/manual/cli.py:60 -msgid "The process attempts to speed up generation of thumbnails by extracting from the video in parallel threads. For some videos, this causes the caching process to hang. If this happens, then set this option to generate the thumbnails in a slower, but more stable single thread." -msgstr "" - diff --git a/locales/tools.manual.pot b/locales/tools.manual.pot new file mode 100644 index 0000000000..76759253bb --- /dev/null +++ b/locales/tools.manual.pot @@ -0,0 +1,197 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2021-03-10 16:44-0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=cp1252\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" + + +#: ./tools/manual/cli.py:13 +msgid "This command lets you perform various actions on frames, faces and alignments files using visual tools." +msgstr "" + +#: ./tools/manual/cli.py:23 +msgid "A tool to perform various actions on frames, faces and alignments files using visual tools" +msgstr "" + +#: ./tools/manual/cli.py:35 ./tools/manual/cli.py:43 +msgid "data" +msgstr "" + +#: ./tools/manual/cli.py:37 +msgid "Path to the alignments file for the input, if not at the default location" +msgstr "" + +#: ./tools/manual/cli.py:44 +msgid "Video file or directory containing source frames that faces were extracted from." +msgstr "" + +#: ./tools/manual/cli.py:51 ./tools/manual/cli.py:59 +msgid "options" +msgstr "" + +#: ./tools/manual/cli.py:52 +msgid "Force regeneration of the low resolution jpg thumbnails in the alignments file." +msgstr "" + +#: ./tools/manual/cli.py:60 +msgid "The process attempts to speed up generation of thumbnails by extracting from the video in parallel threads. For some videos, this causes the caching process to hang. If this happens, then set this option to generate the thumbnails in a slower, but more stable single thread." +msgstr "" + +#: ./tools/manual/faceviewer\frame.py:163 +msgid "Display the landmarks mesh" +msgstr "" + +#: ./tools/manual/faceviewer\frame.py:164 +msgid "Display the mask" +msgstr "" + +#: ./tools/manual/frameviewer\editor\bounding_box.py:33 +#: ./tools/manual/frameviewer\editor\extract_box.py:32 +msgid "Delete Face" +msgstr "" + +#: ./tools/manual/frameviewer\editor\bounding_box.py:36 +msgid "" +"Bounding Box Editor\n" +"Edit the bounding box being fed into the aligner to recalculate the landmarks.\n" +"\n" +" - Grab the corner anchors to resize the bounding box.\n" +" - Click and drag the bounding box to relocate.\n" +" - Click in empty space to create a new bounding box.\n" +" - Right click a bounding box to delete a face." +msgstr "" + +#: ./tools/manual/frameviewer\editor\bounding_box.py:70 +msgid "Aligner to use. FAN will obtain better alignments, but cv2-dnn can be useful if FAN cannot get decent alignments and you want to set a base to edit from." +msgstr "" + +#: ./tools/manual/frameviewer\editor\bounding_box.py:83 +msgid "" +"Normalization method to use for feeding faces to the aligner. This can help the aligner better align faces with difficult lighting conditions. Different methods will yield different results on different sets. NB: This does not impact the output face, just the input to the aligner.\n" +"\tnone: Don't perform normalization on the face.\n" +"\tclahe: Perform Contrast Limited Adaptive Histogram Equalization on the face.\n" +"\thist: Equalize the histograms on the RGB channels.\n" +"\tmean: Normalize the face colors to the mean." +msgstr "" + +#: ./tools/manual/frameviewer\editor\extract_box.py:35 +msgid "" +"Extract Box Editor\n" +"Move the extract box that has been generated by the aligner. Click and drag:\n" +"\n" +" - Inside the bounding box to relocate the landmarks.\n" +" - The corner anchors to resize the landmarks.\n" +" - Outside of the corners to rotate the landmarks." +msgstr "" + +#: ./tools/manual/frameviewer\editor\landmarks.py:27 +msgid "" +"Landmark Point Editor\n" +"Edit the individual landmark points.\n" +"\n" +" - Click and drag individual points to relocate.\n" +" - Draw a box to select multiple points to relocate." +msgstr "" + +#: ./tools/manual/frameviewer\editor\landmarks.py:44 +#: ./tools/manual/frameviewer\editor\mask.py:75 +msgid "Magnify/Demagnify the View" +msgstr "" + +#: ./tools/manual/frameviewer\editor\mask.py:33 +msgid "" +"Mask Editor\n" +"Edit the mask.\n" +" - NB: For Landmark based masks (e.g. components/extended) it is better to make sure the landmarks are correct rather than editing the mask directly. Any change to the landmarks after editing the mask will override your manual edits." +msgstr "" + +#: ./tools/manual/frameviewer\editor\mask.py:77 +msgid "Draw Tool" +msgstr "" + +#: ./tools/manual/frameviewer\editor\mask.py:78 +msgid "Erase Tool" +msgstr "" + +#: ./tools/manual/frameviewer\editor\mask.py:97 +msgid "Select which mask to edit" +msgstr "" + +#: ./tools/manual/frameviewer\editor\mask.py:104 +msgid "Set the brush size. ([ - decrease, ] - increase)" +msgstr "" + +#: ./tools/manual/frameviewer\editor\mask.py:111 +msgid "Select the brush cursor color." +msgstr "" + +#: ./tools/manual/frameviewer\frame.py:77 +msgid "Play/Pause (SPACE)" +msgstr "" + +#: ./tools/manual/frameviewer\frame.py:78 +msgid "Go to First Frame (HOME)" +msgstr "" + +#: ./tools/manual/frameviewer\frame.py:79 +msgid "Go to Previous Frame (Z)" +msgstr "" + +#: ./tools/manual/frameviewer\frame.py:80 +msgid "Go to Next Frame (X)" +msgstr "" + +#: ./tools/manual/frameviewer\frame.py:81 +msgid "Go to Last Frame (END)" +msgstr "" + +#: ./tools/manual/frameviewer\frame.py:82 +msgid "Extract the faces to a folder... (Ctrl+E)" +msgstr "" + +#: ./tools/manual/frameviewer\frame.py:83 +msgid "Save the Alignments file (Ctrl+S)" +msgstr "" + +#: ./tools/manual/frameviewer\frame.py:84 +msgid "Filter Frames to only those Containing the Selected Item (F)" +msgstr "" + +#: ./tools/manual/frameviewer\frame.py:318 +msgid "View alignments" +msgstr "" + +#: ./tools/manual/frameviewer\frame.py:319 +msgid "Bounding box editor" +msgstr "" + +#: ./tools/manual/frameviewer\frame.py:320 +msgid "Location editor" +msgstr "" + +#: ./tools/manual/frameviewer\frame.py:321 +msgid "Mask editor" +msgstr "" + +#: ./tools/manual/frameviewer\frame.py:322 +msgid "Landmark point editor" +msgstr "" + +#: ./tools/manual/frameviewer\frame.py:408 +msgid "Revert to saved Alignments ({})" +msgstr "" + +#: ./tools/manual/frameviewer\frame.py:414 +msgid "Copy {} Alignments ({})" +msgstr "" + diff --git a/locales/tools.preview.cli.pot b/locales/tools.preview.pot similarity index 56% rename from locales/tools.preview.cli.pot rename to locales/tools.preview.pot index e2c6cfaa40..3d98ad6363 100644 --- a/locales/tools.preview.cli.pot +++ b/locales/tools.preview.pot @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-02-18 23:09-0000\n" +"POT-Creation-Date: 2021-03-10 16:51-0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -15,33 +15,58 @@ msgstr "" "Generated-By: pygettext.py 1.5\n" -#: tools/preview/cli.py:14 +#: ./tools/preview\cli.py:13 msgid "This command allows you to preview swaps to tweak convert settings." msgstr "" -#: tools/preview/cli.py:23 +#: ./tools/preview\cli.py:22 msgid "" "Preview tool\n" "Allows you to configure your convert settings with a live preview" msgstr "" -#: tools/preview/cli.py:33 tools/preview/cli.py:42 tools/preview/cli.py:49 +#: ./tools/preview\cli.py:32 ./tools/preview\cli.py:41 +#: ./tools/preview\cli.py:48 msgid "data" msgstr "" -#: tools/preview/cli.py:35 +#: ./tools/preview\cli.py:34 msgid "Input directory or video. Either a directory containing the image files you wish to process or path to a video file." msgstr "" -#: tools/preview/cli.py:44 +#: ./tools/preview\cli.py:43 msgid "Path to the alignments file for the input, if not at the default location" msgstr "" -#: tools/preview/cli.py:51 +#: ./tools/preview\cli.py:50 msgid "Model directory. A directory containing the trained model you wish to process." msgstr "" -#: tools/preview/cli.py:58 +#: ./tools/preview\cli.py:57 msgid "Swap the model. Instead of A -> B, swap B -> A" msgstr "" +#: ./tools/preview\preview.py:1303 +msgid "Save full config" +msgstr "" + +#: ./tools/preview\preview.py:1306 +msgid "Reset full config to default values" +msgstr "" + +#: ./tools/preview\preview.py:1309 +msgid "Reset full config to saved values" +msgstr "" + +#: ./tools/preview\preview.py:1453 +msgid "Save {} config" +msgstr "" + +#: ./tools/preview\preview.py:1456 +msgid "Reset {} config to default values" +msgstr "" + +#: ./tools/preview\preview.py:1459 +msgid "Reset {} config to saved values" +msgstr "" + diff --git a/tools/manual/cli.py b/tools/manual/cli.py index 714c6ade33..3cb571efc1 100644 --- a/tools/manual/cli.py +++ b/tools/manual/cli.py @@ -6,7 +6,7 @@ # LOCALES -_LANG = gettext.translation("tools.manual.cli", localedir="locales", fallback=True) +_LANG = gettext.translation("tools.manual", localedir="locales", fallback=True) _ = _LANG.gettext diff --git a/tools/manual/faceviewer/frame.py b/tools/manual/faceviewer/frame.py index 425f9ff0e2..7a8fbf7350 100644 --- a/tools/manual/faceviewer/frame.py +++ b/tools/manual/faceviewer/frame.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """ The Faces Viewer Frame and Canvas for Faceswap's Manual Tool. """ import colorsys +import gettext import logging import platform import tkinter as tk @@ -18,6 +19,10 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name +# LOCALES +_LANG = gettext.translation("tools.manual", localedir="locales", fallback=True) +_ = _LANG.gettext + class FacesFrame(ttk.Frame): # pylint:disable=too-many-ancestors """ The faces display frame (bottom section of GUI). This frame holds the faces viewport and @@ -155,8 +160,8 @@ def key_bindings(self): def _helptext(self): """ dict: `button key`: `button helptext`. The help text to display for each button. """ inverse_keybindings = {val: key for key, val in self.key_bindings.items()} - retval = dict(mesh="Display the landmarks mesh", - mask="Display the mask") + retval = dict(mesh=_("Display the landmarks mesh"), + mask=_("Display the mask")) for item in retval: retval[item] += " ({})".format(inverse_keybindings[item]) return retval diff --git a/tools/manual/frameviewer/editor/bounding_box.py b/tools/manual/frameviewer/editor/bounding_box.py index 888cdca97b..9eeef016fe 100644 --- a/tools/manual/frameviewer/editor/bounding_box.py +++ b/tools/manual/frameviewer/editor/bounding_box.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """ Bounding Box Editor for the manual adjustments tool """ +import gettext import platform from functools import partial @@ -10,6 +11,11 @@ from ._base import ControlPanelOption, Editor, logger +# LOCALES +_LANG = gettext.translation("tools.manual", localedir="locales", fallback=True) +_ = _LANG.gettext + + class BoundingBox(Editor): """ The Bounding Box Editor. @@ -24,15 +30,15 @@ class BoundingBox(Editor): """ def __init__(self, canvas, detected_faces): self._tk_aligner = None - self._right_click_menu = RightClickMenu(["Delete Face"], + self._right_click_menu = RightClickMenu([_("Delete Face")], [self._delete_current_face], ["Del"]) - control_text = ("Bounding Box Editor\nEdit the bounding box being fed into the aligner " - "to recalculate the landmarks.\n\n" - " - Grab the corner anchors to resize the bounding box.\n" - " - Click and drag the bounding box to relocate.\n" - " - Click in empty space to create a new bounding box.\n" - " - Right click a bounding box to delete a face.") + control_text = _("Bounding Box Editor\nEdit the bounding box being fed into the aligner " + "to recalculate the landmarks.\n\n" + " - Grab the corner anchors to resize the bounding box.\n" + " - Click and drag the bounding box to relocate.\n" + " - Click in empty space to create a new bounding box.\n" + " - Right click a bounding box to delete a face.") key_bindings = {"": self._delete_current_face} super().__init__(canvas, detected_faces, control_text=control_text, key_bindings=key_bindings) @@ -61,9 +67,9 @@ def _add_controls(self): choices=["cv2-dnn", "FAN"], default="FAN", is_radio=True, - helptext="Aligner to use. FAN will obtain better alignments, but cv2-dnn can be " - "useful if FAN cannot get decent alignments and you want to set a base to " - "edit from.") + helptext=_("Aligner to use. FAN will obtain better alignments, but cv2-dnn can be " + "useful if FAN cannot get decent alignments and you want to set a base to " + "edit from.")) self._tk_aligner = align_ctl.tk_var self._add_control(align_ctl) @@ -74,15 +80,15 @@ def _add_controls(self): choices=["none", "clahe", "hist", "mean"], default="hist", is_radio=True, - helptext="Normalization method to use for feeding faces to the aligner. This can help " - "the aligner better align faces with difficult lighting conditions. " - "Different methods will yield different results on different sets. NB: This " - "does not impact the output face, just the input to the aligner." - "\n\tnone: Don't perform normalization on the face." - "\n\tclahe: Perform Contrast Limited Adaptive Histogram Equalization on the " - "face." - "\n\thist: Equalize the histograms on the RGB channels." - "\n\tmean: Normalize the face colors to the mean.") + helptext=_("Normalization method to use for feeding faces to the aligner. This can " + "help the aligner better align faces with difficult lighting conditions. " + "Different methods will yield different results on different sets. NB: " + "This does not impact the output face, just the input to the aligner." + "\n\tnone: Don't perform normalization on the face." + "\n\tclahe: Perform Contrast Limited Adaptive Histogram Equalization on " + "the face." + "\n\thist: Equalize the histograms on the RGB channels." + "\n\tmean: Normalize the face colors to the mean.")) var = norm_ctl.tk_var var.trace("w", lambda *e, v=var: self._det_faces.extractor.set_normalization_method(v.get())) diff --git a/tools/manual/frameviewer/editor/extract_box.py b/tools/manual/frameviewer/editor/extract_box.py index 9bc7591643..eb739545a2 100644 --- a/tools/manual/frameviewer/editor/extract_box.py +++ b/tools/manual/frameviewer/editor/extract_box.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ Extract Box Editor for the manual adjustments tool """ - +import gettext import platform import numpy as np @@ -11,6 +11,11 @@ from ._base import Editor, logger +# LOCALES +_LANG = gettext.translation("tools.manual", localedir="locales", fallback=True) +_ = _LANG.gettext + + class ExtractBox(Editor): """ The Extract Box Editor. @@ -24,14 +29,14 @@ class ExtractBox(Editor): The _detected_faces data for this manual session """ def __init__(self, canvas, detected_faces): - self._right_click_menu = RightClickMenu(["Delete Face"], + self._right_click_menu = RightClickMenu([_("Delete Face")], [self._delete_current_face], ["Del"]) - control_text = ("Extract Box Editor\nMove the extract box that has been generated by the " - "aligner. Click and drag:\n\n" - " - Inside the bounding box to relocate the landmarks.\n" - " - The corner anchors to resize the landmarks.\n" - " - Outside of the corners to rotate the landmarks.") + control_text = _("Extract Box Editor\nMove the extract box that has been generated by the " + "aligner. Click and drag:\n\n" + " - Inside the bounding box to relocate the landmarks.\n" + " - The corner anchors to resize the landmarks.\n" + " - Outside of the corners to rotate the landmarks.") key_bindings = {"": self._delete_current_face} super().__init__(canvas, detected_faces, control_text=control_text, key_bindings=key_bindings) diff --git a/tools/manual/frameviewer/editor/landmarks.py b/tools/manual/frameviewer/editor/landmarks.py index 83d944072b..bc2896212d 100644 --- a/tools/manual/frameviewer/editor/landmarks.py +++ b/tools/manual/frameviewer/editor/landmarks.py @@ -1,10 +1,15 @@ #!/usr/bin/env python3 """ Landmarks Editor and Landmarks Mesh viewer for the manual adjustments tool """ +import gettext import numpy as np from lib.align import AlignedFace from ._base import Editor, logger +# LOCALES +_LANG = gettext.translation("tools.manual", localedir="locales", fallback=True) +_ = _LANG.gettext + class Landmarks(Editor): """ The Landmarks Editor. @@ -19,9 +24,9 @@ class Landmarks(Editor): The _detected_faces data for this manual session """ def __init__(self, canvas, detected_faces): - control_text = ("Landmark Point Editor\nEdit the individual landmark points.\n\n" - " - Click and drag individual points to relocate.\n" - " - Draw a box to select multiple points to relocate.") + control_text = _("Landmark Point Editor\nEdit the individual landmark points.\n\n" + " - Click and drag individual points to relocate.\n" + " - Draw a box to select multiple points to relocate.") self._selection_box = canvas.create_rectangle(0, 0, 0, 0, dash=(2, 4), state="hidden", @@ -36,7 +41,8 @@ def __init__(self, canvas, detected_faces): def _add_actions(self): """ Add the optional action buttons to the viewer. Current actions are Point, Select and Zoom. """ - self._add_action("magnify", "zoom", "Magnify/Demagnify the View", group=None, hotkey="M") + self._add_action("magnify", "zoom", _("Magnify/Demagnify the View"), + group=None, hotkey="M") self._actions["magnify"]["tk_var"].trace("w", self._toggle_zoom) # CALLBACKS diff --git a/tools/manual/frameviewer/editor/mask.py b/tools/manual/frameviewer/editor/mask.py index 945248b2b4..ee1e4f3e96 100644 --- a/tools/manual/frameviewer/editor/mask.py +++ b/tools/manual/frameviewer/editor/mask.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """ Mask Editor for the manual adjustments tool """ +import gettext import tkinter as tk import numpy as np @@ -8,6 +9,10 @@ from ._base import ControlPanelOption, Editor, logger +# LOCALES +_LANG = gettext.translation("tools.manual", localedir="locales", fallback=True) +_ = _LANG.gettext + class Mask(Editor): """ The mask Editor. @@ -25,11 +30,11 @@ def __init__(self, canvas, detected_faces): self._meta = [] self._tk_faces = [] self._internal_size = 512 - control_text = ("Mask Editor\nEdit the mask." - "\n - NB: For Landmark based masks (e.g. components/extended) it is " - "better to make sure the landmarks are correct rather than editing the " - "mask directly. Any change to the landmarks after editing the mask will " - "override your manual edits.") + control_text = _("Mask Editor\nEdit the mask." + "\n - NB: For Landmark based masks (e.g. components/extended) it is " + "better to make sure the landmarks are correct rather than editing the " + "mask directly. Any change to the landmarks after editing the mask will " + "override your manual edits.") key_bindings = {"[": lambda *e, i=False: self._adjust_brush_radius(increase=i), "]": lambda *e, i=True: self._adjust_brush_radius(increase=i)} super().__init__(canvas, detected_faces, @@ -67,9 +72,10 @@ def _cursor_color(self): def _add_actions(self): """ Add the optional action buttons to the viewer. Current actions are Draw, Erase and Zoom. """ - self._add_action("magnify", "zoom", "Magnify/Demagnify the View", group=None, hotkey="M") - self._add_action("draw", "draw", "Draw Tool", group="paint", hotkey="D") - self._add_action("erase", "erase", "Erase Tool", group="paint", hotkey="E") + self._add_action("magnify", "zoom", _("Magnify/Demagnify the View"), + group=None, hotkey="M") + self._add_action("draw", "draw", _("Draw Tool"), group="paint", hotkey="D") + self._add_action("erase", "erase", _("Erase Tool"), group="paint", hotkey="E") self._actions["magnify"]["tk_var"].trace("w", lambda *e: self._globals.tk_update.set(True)) def _add_controls(self): @@ -88,21 +94,21 @@ def _add_controls(self): choices=masks, default=default, is_radio=True, - helptext="Select which mask to edit")) + helptext=_("Select which mask to edit"))) self._add_control(ControlPanelOption("Brush Size", int, group="Brush", min_max=(1, 100), default=10, rounding=1, - helptext="Set the brush size. ([ - decrease, " - "] - increase)")) + helptext=_("Set the brush size. ([ - decrease, " + "] - increase)"))) self._add_control(ControlPanelOption("Cursor Color", str, group="Brush", choices="colorchooser", default="#ffffff", - helptext="Select the brush cursor color.")) + helptext=_("Select the brush cursor color."))) def _set_tk_mask_change_callback(self): """ Add a trace to change the displayed mask on a mask type change. """ diff --git a/tools/manual/frameviewer/frame.py b/tools/manual/frameviewer/frame.py index 180dc41854..660fd0a3c3 100644 --- a/tools/manual/frameviewer/frame.py +++ b/tools/manual/frameviewer/frame.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """ The frame viewer section of the manual tool GUI """ +import gettext import logging import tkinter as tk from tkinter import ttk, TclError @@ -17,6 +18,10 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name +# LOCALES +_LANG = gettext.translation("tools.manual", localedir="locales", fallback=True) +_ = _LANG.gettext + class DisplayFrame(ttk.Frame): # pylint:disable=too-many-ancestors """ The main video display frame (top left section of GUI). @@ -69,14 +74,14 @@ def __init__(self, parent, tk_globals, detected_faces): def _helptext(self): """ dict: {`name`: `help text`} Helptext lookup for navigation buttons """ return dict( - play="Play/Pause (SPACE)", - beginning="Go to First Frame (HOME)", - prev="Go to Previous Frame (Z)", - next="Go to Next Frame (X)", - end="Go to Last Frame (END)", - extract="Extract the faces to a folder... (Ctrl+E)", - save="Save the Alignments file (Ctrl+S)", - mode="Filter Frames to only those Containing the Selected Item (F)") + play=_("Play/Pause (SPACE)"), + beginning=_("Go to First Frame (HOME)"), + prev=_("Go to Previous Frame (Z)"), + next=_("Go to Next Frame (X)"), + end=_("Go to Last Frame (END)"), + extract=_("Extract the faces to a folder... (Ctrl+E)"), + save=_("Save the Alignments file (Ctrl+S)"), + mode=_("Filter Frames to only those Containing the Selected Item (F)")) @property def _btn_action(self): @@ -310,11 +315,11 @@ def key_bindings(self): def _helptext(self): """ dict: `button key`: `button helptext`. The help text to display for each button. """ inverse_keybindings = {val: key for key, val in self.key_bindings.items()} - retval = dict(View="View alignments", - BoundingBox="Bounding box editor", - ExtractBox="Location editor", - Mask="Mask editor", - Landmarks="Landmark point editor") + retval = dict(View=_("View alignments"), + BoundingBox=_("Bounding box editor"), + ExtractBox=_("Location editor"), + Mask=_("Mask editor"), + Landmarks=_("Landmark point editor")) for item in retval: retval[item] += " ({})".format(inverse_keybindings[item]) return retval @@ -400,13 +405,13 @@ def _add_static_buttons(self): if action == "reload": icon = "reload3" cmd = lambda f=tk_frame_index: self._det_faces.revert_to_saved(f.get()) # noqa - helptext = "Revert to saved Alignments ({})".format(lookup[action][1]) + helptext = _("Revert to saved Alignments ({})").format(lookup[action][1]) else: icon = action direction = action.replace("copy_", "") cmd = lambda f=tk_frame_index, d=direction: self._det_faces.update.copy( # noqa f.get(), d) - helptext = "Copy {} Alignments ({})".format(*lookup[action]) + helptext = _("Copy {} Alignments ({})").format(*lookup[action]) state = ["!disabled"] if action == "copy_next" else ["disabled"] button = ttk.Button(frame, image=get_images().icons[icon], diff --git a/tools/preview/cli.py b/tools/preview/cli.py index 278c195210..3ee985d30d 100644 --- a/tools/preview/cli.py +++ b/tools/preview/cli.py @@ -5,9 +5,8 @@ from lib.cli.args import FaceSwapArgs from lib.cli.actions import DirOrFileFullPaths, DirFullPaths, FileFullPaths - # LOCALES -_LANG = gettext.translation("tools.preview.cli", localedir="locales", fallback=True) +_LANG = gettext.translation("tools.preview", localedir="locales", fallback=True) _ = _LANG.gettext diff --git a/tools/preview/preview.py b/tools/preview/preview.py index b927ae4d24..8fbc53a69b 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """ Tool to preview swaps and tweak configuration prior to running a convert """ +import gettext import logging import random import tkinter as tk @@ -32,6 +33,10 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name +# LOCALES +_LANG = gettext.translation("tools.preview", localedir="locales", fallback=True) +_ = _LANG.gettext + class Preview(tk.Tk): # pylint:disable=too-few-public-methods """ This tool is part of the Faceswap Tools suite and should be called from @@ -1296,13 +1301,13 @@ def _add_actions(self, parent): logger.debug("Adding button: '%s'", utl) img = get_images().icons[utl] if utl == "save": - text = "Save full config" + text = _("Save full config") action = self._config_tools.save_config elif utl == "clear": - text = "Reset full config to default values" + text = _("Reset full config to default values") action = self._config_tools.reset_config_to_default elif utl == "reload": - text = "Reset full config to saved values" + text = _("Reset full config to saved values") action = self._config_tools.reset_config_to_saved btnutl = ttk.Button(frame, @@ -1446,13 +1451,13 @@ def _add_actions(self, parent, config_key): logger.debug("Adding button: '%s'", utl) img = get_images().icons[utl] if utl == "save": - text = "Save {} config".format(title) + text = _("Save {} config").format(title) action = parent.config_tools.save_config elif utl == "clear": - text = "Reset {} config to default values".format(title) + text = _("Reset {} config to default values").format(title) action = parent.config_tools.reset_config_to_default elif utl == "reload": - text = "Reset {} config to saved values".format(title) + text = _("Reset {} config to saved values").format(title) action = parent.config_tools.reset_config_to_saved btnutl = ttk.Button(btn_frame, From 1d07dbaa5dde892e34dc17c6ffd115a83879969b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 11 Mar 2021 01:35:00 +0000 Subject: [PATCH 402/981] train - Add option to output model summary and exit --- lib/cli/args.py | 10 + locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 39299 -> 39799 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 66 +- locales/lib.cli.args.pot | 56 +- locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 50687 -> 51387 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 1007 +++++++++++++++--------- plugins/train/model/_base.py | 11 +- scripts/train.py | 10 +- 8 files changed, 712 insertions(+), 448 deletions(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index 8414001628..df26652346 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -940,6 +940,16 @@ def get_argument_list(): "\nL|villain: 128px in/out model from villainguy. Very resource hungry (You " "will require a GPU with a fair amount of VRAM). Good for details, but more " "susceptible to color differences."))) + argument_list.append(dict( + opts=("-su", "--summary"), + action="store_true", + dest="summary", + default=False, + group=_("model"), + help=_("Output a summary of the model and exit. If a model folder is provided then a " + "summary of the saved model is displayed. Otherwise a summary of the model " + "that would be created by the chosen plugin and configuration settings is " + "displayed."))) argument_list.append(dict( opts=("-bs", "--batch-size"), action=Slider, diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo index 0e8b6aff425ea76c35d19fd169860e7a6aec52b3..2d941ad14cf3dcd8e3e756bc369f7435307f1a3e 100644 GIT binary patch delta 2311 zcma)-S!`5Q7{~vVMRwVjg7g+dX(_dfvI?cN1ypb7QCAS%*8h=xRp(U2I8CO$}nz>7-_M&bh!4f;EC2OrP}Px_zV`Of;C?|kQW zS9jL+Q(37?qxyA-vIb2?Z)Hg>u%JIbl*Z z#5~9pHH?yO!U)_$d(&v?B=*%Y(qY&&R_cW1;WP zv~iL2ENp}c*b8UF&1F(4?0^;UJTwjsTP)SU^)R)BN*9%<;P>zp8#b5YJL8WhxPvbE z6AcH+pl7l;floeQ58y3+M*me(KdxIrjQK0iZlD_tgLXpoI+QHrZm72;cxE=ByBYv_ws zpn=FlV-YGq!;vY~0hE*B%naAS(P$|$Q8kexP5vnhsm(*>K}$;0sZ0qBm1H;ynyj6M zDv-%kQ^o~I|DSk(`fOB_X*17qYNkE|&OvjLg$hwVQhCz%T5R79+0kgbm9X2oFAR7- zJ7rsmWJ`-3Z@0WA%Wrn97BB2XEjt#roX1?h(5h~->`Y^m7Y#dc%S~9Vaj(q{J7F^; zM*nSVusVIgN{kM>iPosy?t}}iS|-GIx(Vmjq<`(~H`~6o(@RFfR-OAb~m^)i~a_g8=ipx delta 1784 zcmYk7TWnNS6o!AMSUR=Ew)DcK^fF#rgd&~7PzvqTDYSORT3cE|GYGZViZ&XNRAUql zh(RJ?5G*&98v0aXtY)Mppg{;xd_W!y4-(rKjg8mDNEBYsZ_PPPT$%rTd#%0CK6|gd z&Y6G4e7h4qXCi0%ps+_#A$r#*^;`Q6F4*N%X<(Z4HOz-^FX%uRRs37n4K zai_EjcELB`5%@7I&5%~WZ(%p{WM)d2BuCHAl=|qXx=Z4x6I@<|r{OpZWJ&y_Y-uq! zW%ynr+VcEb~7 zbqlIzH(;8dnJvEM!Ftn1p{aaakp+PLbwRu>f*A5n?jNM^;LlhSt|guft7n z8=Qm;QM`_9!b7kDehF0z=2+h7E~51m~!exNbN1AFVGQ?QWUQ^e16_c_?J+GWui z>0$iq&}yNKSx@5EHA&CFacG%}JtS?1qmbiPMYF`dRO7K8+6Ee%IsX|nS_q`UPUwdl zJ%02fw9H zBHQ}lNnS<#8_

_LQ^~9)mBzE08s)>1l~KQN!>raz6@x#&?FdO0W5(1J6r85y%;k zzGA>F7$kn-6<$w@@5T;kEr%H2P2mwA-Ro9$+CJ$c?#qIQ;RQ(9sB^#jO*szR@qd9N zS+xh;1-%6+4@bY#7^Wliru02Kxejl`PY)(P5E?t=F6?Dyzsa~B^4AUHgKjN+2EV}1 zILfO9uR!*q&@sLNa0FV7Eu#>u2x}qxQ$v!Rf0TxuWgE1Co1q`}!%R2;Sx+*3AerW3 zxCAXgR!{+CMq*TfY`#jAgEEjAFHzF+J=$WH^L*Xs1?=3+n$Q|Vv092$ghI&D`T#0L za}cMZdFUQgj?8QW|F@N%Qvze?UKBx7=FIY1cYCac_CGLXmMXIXG#jlzc_/alignments.json si no se proporciona." -#: lib/cli/args.py:911 lib/cli/args.py:923 +#: lib/cli/args.py:911 lib/cli/args.py:923 lib/cli/args.py:948 msgid "model" msgstr "modelo" @@ -742,12 +742,24 @@ msgstr "" "recursos (se necesita una GPU con una buena cantidad de VRAM). Bueno para " "los detalles, pero más susceptible a las diferencias de color." -#: lib/cli/args.py:951 lib/cli/args.py:963 lib/cli/args.py:974 -#: lib/cli/args.py:1060 +#: lib/cli/args.py:949 +msgid "" +"Output a summary of the model and exit. If a model folder is provided then a " +"summary of the saved model is displayed. Otherwise a summary of the model " +"that would be created by the chosen plugin and configuration settings is " +"displayed." +msgstr "" +"Genere un resumen del modelo y salga. Si se proporciona una carpeta de " +"modelo, se muestra un resumen del modelo guardado. De lo contrario, se " +"muestra un resumen del modelo que crearía el complemento elegido y los " +"ajustes de configuración." + +#: lib/cli/args.py:961 lib/cli/args.py:973 lib/cli/args.py:984 +#: lib/cli/args.py:1070 msgid "training" msgstr "entrenamiento" -#: lib/cli/args.py:952 +#: lib/cli/args.py:962 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -760,7 +772,7 @@ msgstr "" "momento es el doble del número que se establece aquí. Los lotes más grandes " "requieren más RAM de la GPU." -#: lib/cli/args.py:964 +#: lib/cli/args.py:974 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -775,22 +787,22 @@ msgstr "" "automáticamente en un número determinado de iteraciones, puede establecer " "ese valor aquí." -#: lib/cli/args.py:975 +#: lib/cli/args.py:985 msgid "" "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" "Utilice la estrategia de distribución en espejo de Tensorflow para entrenar " "en múltiples GPUs." -#: lib/cli/args.py:985 lib/cli/args.py:995 +#: lib/cli/args.py:995 lib/cli/args.py:1005 msgid "Saving" msgstr "Guardar" -#: lib/cli/args.py:986 +#: lib/cli/args.py:996 msgid "Sets the number of iterations between each model save." msgstr "Establece el número de iteraciones entre cada guardado del modelo." -#: lib/cli/args.py:996 +#: lib/cli/args.py:1006 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -798,11 +810,11 @@ msgstr "" "Establece el número de iteraciones antes de guardar una copia de seguridad " "del modelo en su estado actual. Establece 0 para que esté desactivado." -#: lib/cli/args.py:1003 lib/cli/args.py:1014 lib/cli/args.py:1025 +#: lib/cli/args.py:1013 lib/cli/args.py:1024 lib/cli/args.py:1035 msgid "timelapse" msgstr "intervalo" -#: lib/cli/args.py:1004 +#: lib/cli/args.py:1014 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -816,7 +828,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-B." -#: lib/cli/args.py:1015 +#: lib/cli/args.py:1025 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -830,7 +842,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-A." -#: lib/cli/args.py:1026 +#: lib/cli/args.py:1036 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -842,24 +854,24 @@ msgstr "" "Si se suministran las carpetas de entrada pero no la carpeta de salida, se " "guardará por defecto en la carpeta del modelo /timelapse/" -#: lib/cli/args.py:1038 lib/cli/args.py:1045 lib/cli/args.py:1052 +#: lib/cli/args.py:1048 lib/cli/args.py:1055 lib/cli/args.py:1062 msgid "preview" msgstr "previsualización" -#: lib/cli/args.py:1039 +#: lib/cli/args.py:1049 msgid "" "Percentage amount to scale the preview by. 100%% is the model output size." msgstr "" "Cantidad porcentual para escalar la vista previa. 100%% es el tamaño de " "salida del modelo." -#: lib/cli/args.py:1046 +#: lib/cli/args.py:1056 msgid "Show training preview output. in a separate window." msgstr "" "Mostrar la salida de la vista previa del entrenamiento. en una ventana " "separada." -#: lib/cli/args.py:1053 +#: lib/cli/args.py:1063 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -867,7 +879,7 @@ msgstr "" "Escribe el resultado del entrenamiento en un archivo. La imagen se " "almacenará en la raíz de su carpeta FaceSwap." -#: lib/cli/args.py:1061 +#: lib/cli/args.py:1071 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -875,12 +887,12 @@ msgstr "" "Desactiva el registro de TensorBoard. NB: Desactivar los registros significa " "que no podrá utilizar el gráfico o el análisis de esta sesión en la GUI." -#: lib/cli/args.py:1068 lib/cli/args.py:1077 lib/cli/args.py:1086 -#: lib/cli/args.py:1095 +#: lib/cli/args.py:1078 lib/cli/args.py:1087 lib/cli/args.py:1096 +#: lib/cli/args.py:1105 msgid "augmentation" msgstr "aumento" -#: lib/cli/args.py:1069 +#: lib/cli/args.py:1079 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -890,7 +902,7 @@ msgstr "" "conjunto de caras opuestas en lugar de deformar la cara al azar. Esta es la " "forma 'dfaker' de hacer la deformación." -#: lib/cli/args.py:1078 +#: lib/cli/args.py:1088 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -901,7 +913,7 @@ msgstr "" "general, esto debería dejarse sin efecto, excepto durante el 'entrenamiento " "de ajuste'." -#: lib/cli/args.py:1087 +#: lib/cli/args.py:1097 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -911,7 +923,7 @@ msgstr "" "diferencias de color entre los conjuntos A y B, con un mayor coste de tiempo " "de entrenamiento. Activa esta opción para desactivar el aumento de color." -#: lib/cli/args.py:1096 +#: lib/cli/args.py:1106 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -924,6 +936,6 @@ msgstr "" "esta opción desde el principio, es probable que arruine el modelo y se " "obtengan resultados terribles." -#: lib/cli/args.py:1121 +#: lib/cli/args.py:1131 msgid "Output to Shell console instead of GUI console" msgstr "Salida a la consola Shell en lugar de la consola GUI" diff --git a/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index 76c4c94eaf..16435edc75 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-02-20 02:08-0000\n" +"POT-Creation-Date: 2021-03-11 01:27-0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -290,7 +290,7 @@ msgstr "" msgid "DEPRECATED - This option will be removed in a future update. Path to alignments file for training set B. Defaults to /alignments.json if not provided." msgstr "" -#: lib/cli/args.py:911 lib/cli/args.py:923 +#: lib/cli/args.py:911 lib/cli/args.py:923 lib/cli/args.py:948 msgid "model" msgstr "" @@ -313,93 +313,97 @@ msgid "" "L|villain: 128px in/out model from villainguy. Very resource hungry (You will require a GPU with a fair amount of VRAM). Good for details, but more susceptible to color differences." msgstr "" -#: lib/cli/args.py:951 lib/cli/args.py:963 lib/cli/args.py:974 -#: lib/cli/args.py:1060 +#: lib/cli/args.py:949 +msgid "Output a summary of the model and exit. If a model folder is provided then a summary of the saved model is displayed. Otherwise a summary of the model that would be created by the chosen plugin and configuration settings is displayed." +msgstr "" + +#: lib/cli/args.py:961 lib/cli/args.py:973 lib/cli/args.py:984 +#: lib/cli/args.py:1070 msgid "training" msgstr "" -#: lib/cli/args.py:952 +#: lib/cli/args.py:962 msgid "Batch size. This is the number of images processed through the model for each side per iteration. NB: As the model is fed 2 sides at a time, the actual number of images within the model at any one time is double the number that you set here. Larger batches require more GPU RAM." msgstr "" -#: lib/cli/args.py:964 +#: lib/cli/args.py:974 msgid "Length of training in iterations. This is only really used for automation. There is no 'correct' number of iterations a model should be trained for. You should stop training when you are happy with the previews. However, if you want the model to stop automatically at a set number of iterations, you can set that value here." msgstr "" -#: lib/cli/args.py:975 +#: lib/cli/args.py:985 msgid "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" -#: lib/cli/args.py:985 lib/cli/args.py:995 +#: lib/cli/args.py:995 lib/cli/args.py:1005 msgid "Saving" msgstr "" -#: lib/cli/args.py:986 +#: lib/cli/args.py:996 msgid "Sets the number of iterations between each model save." msgstr "" -#: lib/cli/args.py:996 +#: lib/cli/args.py:1006 msgid "Sets the number of iterations before saving a backup snapshot of the model in it's current state. Set to 0 for off." msgstr "" -#: lib/cli/args.py:1003 lib/cli/args.py:1014 lib/cli/args.py:1025 +#: lib/cli/args.py:1013 lib/cli/args.py:1024 lib/cli/args.py:1035 msgid "timelapse" msgstr "" -#: lib/cli/args.py:1004 +#: lib/cli/args.py:1014 msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. This should be the input folder of 'A' faces that you would like to use for creating the timelapse. You must also supply a --timelapse-output and a --timelapse-input-B parameter." msgstr "" -#: lib/cli/args.py:1015 +#: lib/cli/args.py:1025 msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. This should be the input folder of 'B' faces that you would like to use for creating the timelapse. You must also supply a --timelapse-output and a --timelapse-input-A parameter." msgstr "" -#: lib/cli/args.py:1026 +#: lib/cli/args.py:1036 msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. If the input folders are supplied but no output folder, it will default to your model folder /timelapse/" msgstr "" -#: lib/cli/args.py:1038 lib/cli/args.py:1045 lib/cli/args.py:1052 +#: lib/cli/args.py:1048 lib/cli/args.py:1055 lib/cli/args.py:1062 msgid "preview" msgstr "" -#: lib/cli/args.py:1039 +#: lib/cli/args.py:1049 msgid "Percentage amount to scale the preview by. 100%% is the model output size." msgstr "" -#: lib/cli/args.py:1046 +#: lib/cli/args.py:1056 msgid "Show training preview output. in a separate window." msgstr "" -#: lib/cli/args.py:1053 +#: lib/cli/args.py:1063 msgid "Writes the training result to a file. The image will be stored in the root of your FaceSwap folder." msgstr "" -#: lib/cli/args.py:1061 +#: lib/cli/args.py:1071 msgid "Disables TensorBoard logging. NB: Disabling logs means that you will not be able to use the graph or analysis for this session in the GUI." msgstr "" -#: lib/cli/args.py:1068 lib/cli/args.py:1077 lib/cli/args.py:1086 -#: lib/cli/args.py:1095 +#: lib/cli/args.py:1078 lib/cli/args.py:1087 lib/cli/args.py:1096 +#: lib/cli/args.py:1105 msgid "augmentation" msgstr "" -#: lib/cli/args.py:1069 +#: lib/cli/args.py:1079 msgid "Warps training faces to closely matched Landmarks from the opposite face-set rather than randomly warping the face. This is the 'dfaker' way of doing warping." msgstr "" -#: lib/cli/args.py:1078 +#: lib/cli/args.py:1088 msgid "To effectively learn, a random set of images are flipped horizontally. Sometimes it is desirable for this not to occur. Generally this should be left off except for during 'fit training'." msgstr "" -#: lib/cli/args.py:1087 +#: lib/cli/args.py:1097 msgid "Color augmentation helps make the model less susceptible to color differences between the A and B sets, at an increased training time cost. Enable this option to disable color augmentation." msgstr "" -#: lib/cli/args.py:1096 +#: lib/cli/args.py:1106 msgid "Warping is integral to training the Neural Network. This option should only be enabled towards the very end of training to try to bring out more detail. Think of it as 'fine-tuning'. Enabling this option from the beginning is likely to kill a model and lead to terrible results." msgstr "" -#: lib/cli/args.py:1121 +#: lib/cli/args.py:1131 msgid "Output to Shell console instead of GUI console" msgstr "" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo index 0c6dbed65b2e310877c1e573dd49f4b4614369d6..0b674ac82220779b3cf275c08af0522849d05546 100644 GIT binary patch delta 2531 zcmZXUX>3$g6vzKiL;(>2Su6^#AhMN0DQlrX*;|$Zq9r2Wqcd$srZX@LSP%yavVKrD zO;Au-41Q3drBEnS6c8k0c%$(P5rxEnVvO+varq$r?t3L+xtTM+d+u`1x#ynyZf{Ae z-

Av0byBqRm2{LiRV6Dq!YA{L!v9m*P#NYp^3Mend(#9%>=I4>MbOya=122U|&_ z;8M65ehLr65sxD9H~2DkdbXC%N(rrhOe$q!Y8#24s`*4zI=7Xw*w_cM zMRVIp|G_f2p7GN5QZ4%LPe{Arz7EnZnBP&F!2AzzJ$goGX&yWX$23V0^^?-O3`|Iu z{()EFcs5+_;<4+~QVHXWx=OIzi5|<4#=(W%2^8*ylVA#(^__c2 z55p3;i|j6j-{W7+`$&0Bi4a~wPtPPW7Tkn?;-K#!QkBN_3}!PqzBWwi!1{^gnOo7k z(b5LE2u9&eI20}zBjv!Ia6G&KO+c;3N;BXbm>5TAADuPuIy{QQ@_eF)-PpC4@zWEe zdg!0%F#{{p(62yK6MZL3d(iWzc(rpCmY~llkXFHJ$hB)AX)tA#*U0gO+`lb*xv&Ma8}-F7ilZ2;<>bFil?K5*9AhQpshQLXoIcy*#^*eTXXZ%Du;aj6aPsp~ zZ`S3#Ao6sC zN;lvLbFs{4#0iFOcb>?6^!MN<^nV#lps!mY)v>-Ac_#@0n#Awcf-<_!D~3QRy8F4>%@8 zaD4s*g@)nWB-aJ~+*1T*e=eP2{r)dFDeKBl^HLc9_$V9pFfAJwwf5bEsK(^cS3Trz3k;pU17^F2a4l&QK7h;+TZnE3+0_@b-&ut{P z={8f)cUn4~BE-~;CL-pWZQ4L&3PR?^D|4S?TR0lYL`>0`R^Y*X4W`?#QJ)3dBYYa% zr%ma&ZyvZUkN*)!zNaSClTKGp(V#|}v=2slAmfo%h-v2DOlyM-L53pcOJ-UJq`TYW zjl*HcaKu8gkiLlW;aM0(ESb?auBoba0@cDgaM~M0RY+#)^&l0PBf#{N;UFr8_ zSw(D!EDuEecQ!rXIaY4RtmWZ&&}S|3TO|>{9mCe5N;kNY@^BP~OM>yT0DcLwBpfOY zl*J>QEF7|;{#Yy!DvO#J8@<-d%_!(SGTR!Motu->Zy>GZG&>sWKPO^`qCq-2!TB$Xt4X7tl zYc1!b+pTr#ojS|>s#$&f&e|-?*~xNu?o?{EYosBy1~Z4SQIDQ<4q47A)Kkt0)4j`F zrsXuSGST21X6qs6C{mlMV*8tS#lR*!G^E~e*Cbt&Nn$#Fk1Yb(h9A}#lXW;ZYfhT2 z^{KU~4aT(R?g4L;?siAGs2E75)?sQj@g7R8Cov2*+~bkxPq@y^DmHC@EtEdjOOJP) yBsR7X0w=G>eT^|{Vqzm{Bqi>FPP#`n$3JO?G3mOlb7~0N7{<9%x9|4K!v6uAO?krr delta 1849 zcmY+EeN5DK9LL{qh;oJk;v)zLJ#c~;4@r9RZ~=nADQX8rpm7=xE5*9wv{rJ{Ewz|U zqp&2U_^X`L3~eo^W|5-@tkvAI4P9%EYW7EM&RQ#beeZYt(P#I**Z1@Je!rjZ^Y83I z+wERk=vq?DtHRczRP;u)^n@u-;SU>)m4Z>yXK)TInauz}{XfA{p(=S4fNt_a=GETF1}^Pfs#HGyfc0mi|N5x)}# z80aZ@hL!)seip!i)zY)XFV~Y4)~>Vo{CaD^j~&Njq1A^ceo;M(J(x zj5da(95Xb>pKZRqLXn5g%3S`$D1xHXV%k?}Z7 z(4=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" -"Language: ru\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" -#: lib/cli/args.py:177 lib/cli/args.py:187 lib/cli/args.py:195 lib/cli/args.py:205 +#: lib/cli/args.py:177 lib/cli/args.py:187 lib/cli/args.py:195 +#: lib/cli/args.py:205 msgid "Global Options" msgstr "Общие настройки" #: lib/cli/args.py:178 msgid "" -"R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond to any GPU(s) that you do not wish to be made " -"available to Faceswap. Selecting all GPUs here will force Faceswap into CPU mode.\n" +"R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " +"to any GPU(s) that you do not wish to be made available to Faceswap. " +"Selecting all GPUs here will force Faceswap into CPU mode.\n" "L|{}" msgstr "" -"R|Не использовать GPU для Faceswap. Выберите номер(а), которые соответствуют тем GPU, которые вы не хотите использовать в " -"Faceswap. При отключении всех GPU Faceswap будет работать в режиме CPU.\n" +"R|Не использовать GPU для Faceswap. Выберите номер(а), которые соответствуют " +"тем GPU, которые вы не хотите использовать в Faceswap. При отключении всех " +"GPU Faceswap будет работать в режиме CPU.\n" "L|{}" #: lib/cli/args.py:188 -msgid "Optionally overide the saved config with the path to a custom config file." -msgstr "Переназначить путь к файлу конфигурации пользовательским. (Необязательно)" +msgid "" +"Optionally overide the saved config with the path to a custom config file." +msgstr "" +"Переназначить путь к файлу конфигурации пользовательским. (Необязательно)" #: lib/cli/args.py:196 msgid "" -"Log level. Stick with INFO or VERBOSE unless you need to file an error report. Be careful with TRACE as it will generate a lot of " -"data" +"Log level. Stick with INFO or VERBOSE unless you need to file an error " +"report. Be careful with TRACE as it will generate a lot of data" msgstr "" -"Уровень записи журнала. Придерживайтесь уровней INFO или VERBOSE, кроме случаев когда вам нужно отправить отчёт об ошибке. Будьте " -"осторожнее при указании уровня TRACE, так как будет сгенерировано очень много данных" +"Уровень записи журнала. Придерживайтесь уровней INFO или VERBOSE, кроме " +"случаев когда вам нужно отправить отчёт об ошибке. Будьте осторожнее при " +"указании уровня TRACE, так как будет сгенерировано очень много данных" #: lib/cli/args.py:206 msgid "Path to store the logfile. Leave blank to store in the faceswap folder" -msgstr "Путь для сохранения файла журнала. Оставьте пустым, чтобы сохранить в папке с faceswap" +msgstr "" +"Путь для сохранения файла журнала. Оставьте пустым, чтобы сохранить в папке " +"с faceswap" -#: lib/cli/args.py:299 lib/cli/args.py:308 lib/cli/args.py:316 lib/cli/args.py:627 lib/cli/args.py:636 +#: lib/cli/args.py:299 lib/cli/args.py:308 lib/cli/args.py:316 +#: lib/cli/args.py:627 lib/cli/args.py:636 msgid "Data" msgstr "Данные" #: lib/cli/args.py:300 msgid "" -"Input directory or video. Either a directory containing the image files you wish to process or path to a video file. NB: This " -"should be the source video/frames NOT the source faces." +"Input directory or video. Either a directory containing the image files you " +"wish to process or path to a video file. NB: This should be the source video/" +"frames NOT the source faces." msgstr "" -"Входная папка либо видео файл. Папка с набором фотографий для обработки либо видео файл. Примечание: должно указывать на исходное " -"видео либо набор извлеченных кадров, а НЕ уже извлеченных лица." +"Входная папка либо видео файл. Папка с набором фотографий для обработки либо " +"видео файл. Примечание: должно указывать на исходное видео либо набор " +"извлеченных кадров, а НЕ уже извлеченных лица." #: lib/cli/args.py:309 msgid "Output directory. This is where the converted files will be saved." msgstr "Папка для сохранения преобразованных файлов." #: lib/cli/args.py:317 -msgid "Optional path to an alignments file. Leave blank if the alignments file is at the default location." +msgid "" +"Optional path to an alignments file. Leave blank if the alignments file is " +"at the default location." msgstr "Путь к файлу выравнивания. Оставьте пустым, для пути по умолчанию." #: lib/cli/args.py:340 @@ -75,87 +89,116 @@ msgstr "" "Извлечь лица из изображений или видео источников.\n" "Плагины извлечения можно настроить в меню 'Настройки'" -#: lib/cli/args.py:365 lib/cli/args.py:381 lib/cli/args.py:393 lib/cli/args.py:425 lib/cli/args.py:443 lib/cli/args.py:455 -#: lib/cli/args.py:646 lib/cli/args.py:671 lib/cli/args.py:698 +#: lib/cli/args.py:365 lib/cli/args.py:381 lib/cli/args.py:393 +#: lib/cli/args.py:425 lib/cli/args.py:443 lib/cli/args.py:455 +#: lib/cli/args.py:646 lib/cli/args.py:671 lib/cli/args.py:700 msgid "Plugins" msgstr "Плагины" #: lib/cli/args.py:366 msgid "" -"R|Detector to use. Some of these have configurable settings in '/config/extract.ini' or 'Settings > Configure Extract 'Plugins':\n" -"L|cv2-dnn: A CPU only extractor which is the least reliable and least resource intensive. Use this if not using a GPU and time is " -"important.\n" -"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources than other GPU detectors but can often return more false " -"positives.\n" -"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and fewer false positives than other GPU detectors, but " -"is a lot more resource intensive." -msgstr "" -"R|Тип детектора. Некоторые могут быть настроенны через '/config/extract.ini' либо 'Settings > Configure Extract 'Plugins':\n" -"L|cv2-dnn: Работает только на CPU, наименее надежный и наименее требователен к ресурсам. Используйте если для вас очень важна " -"скорость, а также не использовать GPU .\n" -"L|mtcnn: Хороший детектор. Быстрый на CPU, ещё быстрее на GPU. Использует меньше ресурсов, нежели другие GPU детекторы, но может " -"производить больше ложных положительных детектирований.\n" -"L|s3fd: Лучший детектор. Медленный на CPU, быстре на GPU. Может детектировать лицо в большем кол-ве ситуация и меньшим кол-вом " -"ошибок, чем другие GPU, но значительно более требователен к ресурсам." +"R|Detector to use. Some of these have configurable settings in '/config/" +"extract.ini' or 'Settings > Configure Extract 'Plugins':\n" +"L|cv2-dnn: A CPU only extractor which is the least reliable and least " +"resource intensive. Use this if not using a GPU and time is important.\n" +"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " +"than other GPU detectors but can often return more false positives.\n" +"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " +"fewer false positives than other GPU detectors, but is a lot more resource " +"intensive." +msgstr "" +"R|Тип детектора. Некоторые могут быть настроенны через '/config/extract.ini' " +"либо 'Settings > Configure Extract 'Plugins':\n" +"L|cv2-dnn: Работает только на CPU, наименее надежный и наименее требователен " +"к ресурсам. Используйте если для вас очень важна скорость, а также не " +"использовать GPU .\n" +"L|mtcnn: Хороший детектор. Быстрый на CPU, ещё быстрее на GPU. Использует " +"меньше ресурсов, нежели другие GPU детекторы, но может производить больше " +"ложных положительных детектирований.\n" +"L|s3fd: Лучший детектор. Медленный на CPU, быстре на GPU. Может " +"детектировать лицо в большем кол-ве ситуация и меньшим кол-вом ошибок, чем " +"другие GPU, но значительно более требователен к ресурсам." #: lib/cli/args.py:382 msgid "" "R|Aligner to use.\n" -"L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, but less accurate. Only use this if not using a GPU and " -"time is important.\n" +"L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " +"but less accurate. Only use this if not using a GPU and time is important.\n" "L|fan: Best aligner. Fast on GPU, slow on CPU." msgstr "" "R|Выравнивание лица.\n" -"L|cv2-dnn: Детектор меток лица, только для CPU. Быстрый, не требователен к ресурсам, но менее точный. Используйте только если вам " -"необходимо не использовать GPU.\n" +"L|cv2-dnn: Детектор меток лица, только для CPU. Быстрый, не требователен к " +"ресурсам, но менее точный. Используйте только если вам необходимо не " +"использовать GPU.\n" "L|fan: Лучший выравниватель. Быстрый на GPU, медленный на CPU." #: lib/cli/args.py:394 msgid "" -"R|Additional Masker(s) to use. The masks generated here will all take up GPU RAM. You can select none, one or multiple masks, but " -"the extraction may take longer the more you select. NB: The Extended and Components (landmark based) masks are automatically " -"generated on extraction.\n" -"L|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.\n" -"L|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.\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.\n" +"R|Additional Masker(s) to use. The masks generated here will all take up GPU " +"RAM. You can select none, one or multiple masks, but the extraction may take " +"longer the more you select. NB: The Extended and Components (landmark based) " +"masks are automatically generated on extraction.\n" +"L|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.\n" +"L|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.\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.\n" "The auto generated masks are as follows:\n" -"L|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.\n" -"L|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.\n" +"L|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.\n" +"L|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.\n" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" msgstr "" -"R|Создание доп. масок. Генерация масок требует дополнительной памяти GPU. Вы можете выбрать none, одну, или несколько масок, но " -"процес извлечение может занять больше времени в зависимости от выбора. Прим.: Маски Extended и Components (на основе меток лица) " -"всегда создаются автоматически при извлечении лиц.\n" -"L|vgg-clear: Маска предназначена для умной сегментации преимущественно фронтальных лиц без препятствий. Фотографии в профиль " -"могут быть обработаны посредственно.\n" -"L|vgg-obstructed: Маска предназначена для умной сегментации преимущественно фронтальных лиц. Эта маска была обучена распознавать " -"некоторые препятствия, такие как руки и очки. Фотографии в профиль могут быть обработаны посредственно.\n" -"L|unet-dfl: Маска предназначена для умной сегментации преимущественно фронтальных лиц. Маска была обучена силами участников " -"сообщества и нуждается в тестировании. Фотографии в профиль могут быть обработаны посредственно.\n" +"R|Создание доп. масок. Генерация масок требует дополнительной памяти GPU. Вы " +"можете выбрать none, одну, или несколько масок, но процес извлечение может " +"занять больше времени в зависимости от выбора. Прим.: Маски Extended и " +"Components (на основе меток лица) всегда создаются автоматически при " +"извлечении лиц.\n" +"L|vgg-clear: Маска предназначена для умной сегментации преимущественно " +"фронтальных лиц без препятствий. Фотографии в профиль могут быть обработаны " +"посредственно.\n" +"L|vgg-obstructed: Маска предназначена для умной сегментации преимущественно " +"фронтальных лиц. Эта маска была обучена распознавать некоторые препятствия, " +"такие как руки и очки. Фотографии в профиль могут быть обработаны " +"посредственно.\n" +"L|unet-dfl: Маска предназначена для умной сегментации преимущественно " +"фронтальных лиц. Маска была обучена силами участников сообщества и нуждается " +"в тестировании. Фотографии в профиль могут быть обработаны посредственно.\n" "Следующие маски создаются автоматически:\n" -"L|components: Маска предназначена для сегментации лица на основе ориентиров лица. Маска создается путем построения выпуклого " -"полигона вокруг внешних ориентиров лица.\n" -"L|extended: Маска предназначена для сегментации лица на основе ориентиров лица. Маска создается путем построения выпуклого " -"полигона вокруг внешних ориентиров лица и расширяется вверх на лоб.\n" +"L|components: Маска предназначена для сегментации лица на основе ориентиров " +"лица. Маска создается путем построения выпуклого полигона вокруг внешних " +"ориентиров лица.\n" +"L|extended: Маска предназначена для сегментации лица на основе ориентиров " +"лица. Маска создается путем построения выпуклого полигона вокруг внешних " +"ориентиров лица и расширяется вверх на лоб.\n" "(пример: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" #: lib/cli/args.py:426 msgid "" -"R|Performing normalization can help the aligner better align faces with difficult lighting conditions at an extraction speed " -"cost. Different methods will yield different results on different sets. NB: This does not impact the output face, just the input " -"to the aligner.\n" +"R|Performing normalization can help the aligner better align faces with " +"difficult lighting conditions at an extraction speed cost. Different methods " +"will yield different results on different sets. NB: This does not impact the " +"output face, just the input to the aligner.\n" "L|none: Don't perform normalization on the face.\n" -"L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the face.\n" +"L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " +"face.\n" "L|hist: Equalize the histograms on the RGB channels.\n" "L|mean: Normalize the face colors to the mean." msgstr "" -"R|Нормализация может помочь выравниванию лиц при сложных условиях освещения, ценой снижения скорости. Различные методы дают " -"разные результаты в зависимости от набора лиц. Прим.: Не влияет на вывод лица, только на выравнивание.\n" +"R|Нормализация может помочь выравниванию лиц при сложных условиях освещения, " +"ценой снижения скорости. Различные методы дают разные результаты в " +"зависимости от набора лиц. Прим.: Не влияет на вывод лица, только на " +"выравнивание.\n" "L|none: Не производить нормализацию картинки лица.\n" "L|clahe: Производить нормализацию методом CLAHE.\n" "L|hist: Выравнивание гистограммы каналов RGB каналов.\n" @@ -163,111 +206,151 @@ msgstr "" #: lib/cli/args.py:444 msgid "" -"The number of times to re-feed the detected face into the aligner. Each time the face is re-fed into the aligner the bounding box " -"is adjusted by a small amount. The final landmarks are then averaged from each iteration. Helps to remove 'micro-jitter' but at " -"the cost of slower extraction speed. The more times the face is re-fed into the aligner, the less micro-jitter should occur but " -"the longer extraction will take." +"The number of times to re-feed the detected face into the aligner. Each time " +"the face is re-fed into the aligner the bounding box is adjusted by a small " +"amount. The final landmarks are then averaged from each iteration. Helps to " +"remove 'micro-jitter' but at the cost of slower extraction speed. The more " +"times the face is re-fed into the aligner, the less micro-jitter should " +"occur but the longer extraction will take." msgstr "" -"Кол-во проходов выравнивания после обнаружения лица. Каждый раз при повторном выравнивании рамка лица немного корректируется. " -"Окончательные ориентиры затем усредняются. Помогает устранить «микроджиттер», но за счет замедления скорости извлечения. Чем " -"больше проходов выравнивания, тем меньше микродрожание, но тем дольше идет извлечение." +"Кол-во проходов выравнивания после обнаружения лица. Каждый раз при " +"повторном выравнивании рамка лица немного корректируется. Окончательные " +"ориентиры затем усредняются. Помогает устранить «микроджиттер», но за счет " +"замедления скорости извлечения. Чем больше проходов выравнивания, тем меньше " +"микродрожание, но тем дольше идет извлечение." #: lib/cli/args.py:456 msgid "" -"If a face isn't found, rotate the images to try to find a face. Can find more faces at the cost of extraction speed. Pass in a " -"single number to use increments of that size up to 360, or pass in a list of numbers to enumerate exactly what angles to check." +"If a face isn't found, rotate the images to try to find a face. Can find " +"more faces at the cost of extraction speed. Pass in a single number to use " +"increments of that size up to 360, or pass in a list of numbers to enumerate " +"exactly what angles to check." msgstr "" -"Если лицо не найдено, поворачивает картинку, чтобы попытаться найти лицо. Может найти больше лиц ценой скорости извлечения. " -"Укажите число, чтобы использовать приращения этого размера до 360, либо передайте список чисел, чтобы точно указать, какие углы " -"проверять." +"Если лицо не найдено, поворачивает картинку, чтобы попытаться найти лицо. " +"Может найти больше лиц ценой скорости извлечения. Укажите число, чтобы " +"использовать приращения этого размера до 360, либо передайте список чисел, " +"чтобы точно указать, какие углы проверять." -#: lib/cli/args.py:468 lib/cli/args.py:478 lib/cli/args.py:491 lib/cli/args.py:505 lib/cli/args.py:735 lib/cli/args.py:749 -#: lib/cli/args.py:762 lib/cli/args.py:776 +#: lib/cli/args.py:468 lib/cli/args.py:478 lib/cli/args.py:491 +#: lib/cli/args.py:505 lib/cli/args.py:737 lib/cli/args.py:751 +#: lib/cli/args.py:764 lib/cli/args.py:778 msgid "Face Processing" msgstr "Обработка лиц" #: lib/cli/args.py:469 -msgid "Filters out faces detected below this size. Length, in pixels across the diagonal of the bounding box. Set to 0 for off" -msgstr "Отбрасывает лица ниже указанного размера. Длина указывается в пикселях по диагонали. Установите в 0 для отключения" +msgid "" +"Filters out faces detected below this size. Length, in pixels across the " +"diagonal of the bounding box. Set to 0 for off" +msgstr "" +"Отбрасывает лица ниже указанного размера. Длина указывается в пикселях по " +"диагонали. Установите в 0 для отключения" -#: lib/cli/args.py:479 lib/cli/args.py:750 +#: lib/cli/args.py:479 lib/cli/args.py:752 msgid "" -"Optionally filter out people who you do not wish to process by passing in an image of that person. Should be a front portrait " -"with a single person in the image. Multiple images can be added space separated. NB: Using face filter will significantly " -"decrease extraction speed and its accuracy cannot be guaranteed." +"Optionally filter out people who you do not wish to process by passing in an " +"image of that person. Should be a front portrait with a single person in the " +"image. Multiple images can be added space separated. NB: Using face filter " +"will significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." msgstr "" -"Дополнительно вы можете отфильтровать лица людей, которых вы не хотите обрабатывать указав изображение этого человека. На " -"изображении должен быть фронтальный портрет одного человека . Можно указать несколько файлов через пробел. Прим.: Фильтрация лиц " -"существенно снижает скорость извлечения, при этом точность не гарантируется." +"Дополнительно вы можете отфильтровать лица людей, которых вы не хотите " +"обрабатывать указав изображение этого человека. На изображении должен быть " +"фронтальный портрет одного человека . Можно указать несколько файлов через " +"пробел. Прим.: Фильтрация лиц существенно снижает скорость извлечения, при " +"этом точность не гарантируется." -#: lib/cli/args.py:492 lib/cli/args.py:763 +#: lib/cli/args.py:492 lib/cli/args.py:765 msgid "" -"Optionally select people you wish to process by passing in an image of that person. Should be a front portrait with a single " -"person in the image. Multiple images can be added space separated. NB: Using face filter will significantly decrease extraction " -"speed and its accuracy cannot be guaranteed." +"Optionally select people you wish to process by passing in an image of that " +"person. Should be a front portrait with a single person in the image. " +"Multiple images can be added space separated. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." msgstr "" -"Дополнительно вы можете выбрать людей, которых вы хотели бы включить в обработку путем указания изображения этого человека. " -"Должен быть фронтальный портрет с лишь одним человеком на картинке. Можно выбрать несколько изображений через пробел. Прим.: " -"Использование фильтра существенно замедлит скорость извлечения. Также точность не гарантируется." +"Дополнительно вы можете выбрать людей, которых вы хотели бы включить в " +"обработку путем указания изображения этого человека. Должен быть фронтальный " +"портрет с лишь одним человеком на картинке. Можно выбрать несколько " +"изображений через пробел. Прим.: Использование фильтра существенно замедлит " +"скорость извлечения. Также точность не гарантируется." -#: lib/cli/args.py:506 lib/cli/args.py:777 +#: lib/cli/args.py:506 lib/cli/args.py:779 msgid "" -"For use with the optional nfilter/filter files. Threshold for positive face recognition. Lower values are stricter. NB: Using " -"face filter will significantly decrease extraction speed and its accuracy cannot be guaranteed." +"For use with the optional nfilter/filter files. Threshold for positive face " +"recognition. Lower values are stricter. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." msgstr "" -"Только при использовании файлов nfilter/filter. Порог для распознавания лица. Чем ниже значения, тем строже. Прим.: " -"Использование фильтра лиц существенно замедлит скорость извлечения. Также точность не гарантируется." +"Только при использовании файлов nfilter/filter. Порог для распознавания " +"лица. Чем ниже значения, тем строже. Прим.: Использование фильтра лиц " +"существенно замедлит скорость извлечения. Также точность не гарантируется." -#: lib/cli/args.py:517 lib/cli/args.py:529 lib/cli/args.py:541 lib/cli/args.py:553 +#: lib/cli/args.py:517 lib/cli/args.py:529 lib/cli/args.py:541 +#: lib/cli/args.py:553 msgid "output" msgstr "вывод" #: lib/cli/args.py:518 msgid "" -"The output size of extracted faces. Make sure that the model you intend to train supports your required size. This will only need " -"to be changed for hi-res models." +"The output size of extracted faces. Make sure that the model you intend to " +"train supports your required size. This will only need to be changed for hi-" +"res models." msgstr "" -"Размер извлекаемых лиц в пикселях. Убедитесь, что выбранная Вами модель поддерживает такой входной размер. Стоит изменять только " -"для моделей высокого разрешения." +"Размер извлекаемых лиц в пикселях. Убедитесь, что выбранная Вами модель " +"поддерживает такой входной размер. Стоит изменять только для моделей " +"высокого разрешения." #: lib/cli/args.py:530 msgid "" -"Extract every 'nth' frame. This option will skip frames when extracting faces. For example a value of 1 will extract faces from " -"every frame, a value of 10 will extract faces from every 10th frame." +"Extract every 'nth' frame. This option will skip frames when extracting " +"faces. For example a value of 1 will extract faces from every frame, a value " +"of 10 will extract faces from every 10th frame." msgstr "" -"Обрабатывать каждые N кадров. Эта опция будет пропускать лица при извлечении. Например, значение 1 будет искать лица в каждом " -"кадре, а значение 10 в каждом 10том кадре." +"Обрабатывать каждые N кадров. Эта опция будет пропускать лица при " +"извлечении. Например, значение 1 будет искать лица в каждом кадре, а " +"значение 10 в каждом 10том кадре." #: lib/cli/args.py:542 msgid "" -"Automatically save the alignments file after a set amount of frames. By default the alignments file is only saved at the end of " -"the extraction process. NB: If extracting in 2 passes then the alignments file will only start to be saved out during the second " -"pass. WARNING: Don't interrupt the script when writing the file because it might get corrupted. Set to 0 to turn off" +"Automatically save the alignments file after a set amount of frames. By " +"default the alignments file is only saved at the end of the extraction " +"process. NB: If extracting in 2 passes then the alignments file will only " +"start to be saved out during the second pass. WARNING: Don't interrupt the " +"script when writing the file because it might get corrupted. Set to 0 to " +"turn off" msgstr "" -"Автоматически сохранять файл выравнивания после указанного кол-ва кадров. По умолчанию файл выравнивания сохраняется только в " -"конце процедуры извлечения. Прим.: При извлечении в 2 прохода, файл выравниваний начнёт сохранение только во время второго " -"прохода. ВНИМАНИЕ: Не прерывайте выполнение во время записи, так как это может повлечь порчу файла. Установите в 0 для выключения" +"Автоматически сохранять файл выравнивания после указанного кол-ва кадров. По " +"умолчанию файл выравнивания сохраняется только в конце процедуры извлечения. " +"Прим.: При извлечении в 2 прохода, файл выравниваний начнёт сохранение " +"только во время второго прохода. ВНИМАНИЕ: Не прерывайте выполнение во время " +"записи, так как это может повлечь порчу файла. Установите в 0 для выключения" #: lib/cli/args.py:554 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "Рисовать ландмарки на выходных лицах для нужд отладки." -#: lib/cli/args.py:560 lib/cli/args.py:569 lib/cli/args.py:577 lib/cli/args.py:584 lib/cli/args.py:789 lib/cli/args.py:800 -#: lib/cli/args.py:808 lib/cli/args.py:827 lib/cli/args.py:833 +#: lib/cli/args.py:560 lib/cli/args.py:569 lib/cli/args.py:577 +#: lib/cli/args.py:584 lib/cli/args.py:791 lib/cli/args.py:802 +#: lib/cli/args.py:810 lib/cli/args.py:829 lib/cli/args.py:835 msgid "settings" msgstr "настройки" #: lib/cli/args.py:561 msgid "" -"Don't run extraction in parallel. Will run each part of the extraction process separately (one after the other) rather than all " -"at the smae time. Useful if VRAM is at a premium." +"Don't run extraction in parallel. Will run each part of the extraction " +"process separately (one after the other) rather than all at the smae time. " +"Useful if VRAM is at a premium." msgstr "" -"Не проводить параллельное извлечение. Вместо одновременного запуска, каждая стадия извлечения будет запущена отдельно (одна, за " -"другой). Полезно при нехватке VRAM." +"Не проводить параллельное извлечение. Вместо одновременного запуска, каждая " +"стадия извлечения будет запущена отдельно (одна, за другой). Полезно при " +"нехватке VRAM." #: lib/cli/args.py:570 -msgid "Skips frames that have already been extracted and exist in the alignments file" -msgstr "Пропускать кадры, которые уже были извлечены и существуют в файле выравнивания" +msgid "" +"Skips frames that have already been extracted and exist in the alignments " +"file" +msgstr "" +"Пропускать кадры, которые уже были извлечены и существуют в файле " +"выравнивания" #: lib/cli/args.py:578 msgid "Skip frames that already have detected faces in the alignments file" @@ -275,7 +358,8 @@ msgstr "Пропускать кадры, для которых в файле в #: lib/cli/args.py:585 msgid "Skip saving the detected faces to disk. Just create an alignments file" -msgstr "Не сохранять найденные лица на носитель. Просто создать файл выравнивания" +msgstr "" +"Не сохранять найденные лица на носитель. Просто создать файл выравнивания" #: lib/cli/args.py:607 msgid "" @@ -287,399 +371,542 @@ msgstr "" #: lib/cli/args.py:628 msgid "" -"Only required if converting from images to video. Provide The original video that the source frames were extracted from (for " -"extracting the fps and audio)." +"Only required if converting from images to video. Provide The original video " +"that the source frames were extracted from (for extracting the fps and " +"audio)." msgstr "" -"Нужно указывать лишь при конвертации из набора картинок в видео. Предоставьте исходное видео, из которого были извлечены кадры " -"(для настройки частоты кадров, а также аудио)." +"Нужно указывать лишь при конвертации из набора картинок в видео. " +"Предоставьте исходное видео, из которого были извлечены кадры (для настройки " +"частоты кадров, а также аудио)." #: lib/cli/args.py:637 -msgid "Model directory. The directory containing the trained model you wish to use for conversion." -msgstr "Папка с моделью. Папка, содержащая обученную модель, которую вы хотите использовать для преобразования." +msgid "" +"Model directory. The directory containing the trained model you wish to use " +"for conversion." +msgstr "" +"Папка с моделью. Папка, содержащая обученную модель, которую вы хотите " +"использовать для преобразования." #: lib/cli/args.py:647 msgid "" -"R|Performs color adjustment to the swapped face. Some of these options have configurable settings in '/config/convert.ini' or " -"'Settings > Configure Convert Plugins':\n" -"L|avg-color: Adjust the mean of each color channel in the swapped reconstruction to equal the mean of the masked area in the " -"original image.\n" -"L|color-transfer: Transfers the color distribution from the source to the target image using the mean and standard deviations of " -"the L*a*b* color space.\n" -"L|manual-balance: Manually adjust the balance of the image in a variety of color spaces. Best used with the Preview tool to set " -"correct values.\n" -"L|match-hist: Adjust the histogram of each color channel in the swapped reconstruction to equal the histogram of the masked area " -"in the original image.\n" -"L|seamless-clone: Use cv2's seamless clone function to remove extreme gradients at the mask seam by smoothing colors. Generally " -"does not give very satisfactory results.\n" +"R|Performs color adjustment to the swapped face. Some of these options have " +"configurable settings in '/config/convert.ini' or 'Settings > Configure " +"Convert Plugins':\n" +"L|avg-color: Adjust the mean of each color channel in the swapped " +"reconstruction to equal the mean of the masked area in the original image.\n" +"L|color-transfer: Transfers the color distribution from the source to the " +"target image using the mean and standard deviations of the L*a*b* color " +"space.\n" +"L|manual-balance: Manually adjust the balance of the image in a variety of " +"color spaces. Best used with the Preview tool to set correct values.\n" +"L|match-hist: Adjust the histogram of each color channel in the swapped " +"reconstruction to equal the histogram of the masked area in the original " +"image.\n" +"L|seamless-clone: Use cv2's seamless clone function to remove extreme " +"gradients at the mask seam by smoothing colors. Generally does not give very " +"satisfactory results.\n" "L|none: Don't perform color adjustment." msgstr "" -"R|Производит подгонку цветов в измененном лице. Некоторые из этих опций имеют настройки в файле '/config/convert.ini' либо " -"'Настройки > Настроить Плагины Конверсии':\n" -"L|avg-color: Подогнать среднее значение каждого цветового канала в замененном лице так, чтобы оно равнялось среднему значению " -"области маски исходного изображения.\n" -"L|color-transfer: Переносит распределение цвета от источника к целевому изображению с использованием среднего и стандартного " -"отклонения цветового пространства L * a * b *.\n" -"L|manual-balance: Ручная настройка баланса изображения в различных цветовых пространствах. Лучше всего использовать с " -"инструментом предварительного просмотра для установки правильных значений.\n" -"L|match-hist: Подгонять гистограмму каждого цветового канала нового лица, гистограммой области маски исходного изображения\n" -"L|seamless-clone: Исп. фунцю cv2's незаметного переноса чтобы убрать экстремальные градиенты на краях маски путём сглаживания " -"цветов. Обычно не дает удовлетворительных результатов.\n" +"R|Производит подгонку цветов в измененном лице. Некоторые из этих опций " +"имеют настройки в файле '/config/convert.ini' либо 'Настройки > Настроить " +"Плагины Конверсии':\n" +"L|avg-color: Подогнать среднее значение каждого цветового канала в " +"замененном лице так, чтобы оно равнялось среднему значению области маски " +"исходного изображения.\n" +"L|color-transfer: Переносит распределение цвета от источника к целевому " +"изображению с использованием среднего и стандартного отклонения цветового " +"пространства L * a * b *.\n" +"L|manual-balance: Ручная настройка баланса изображения в различных цветовых " +"пространствах. Лучше всего использовать с инструментом предварительного " +"просмотра для установки правильных значений.\n" +"L|match-hist: Подгонять гистограмму каждого цветового канала нового лица, " +"гистограммой области маски исходного изображения\n" +"L|seamless-clone: Исп. фунцю cv2's незаметного переноса чтобы убрать " +"экстремальные градиенты на краях маски путём сглаживания цветов. Обычно не " +"дает удовлетворительных результатов.\n" "L|none: Не производить подгонку цвета." #: lib/cli/args.py:672 msgid "" -"R|Masker to use. NB: The mask you require must exist within the alignments file. You can add additional masks with the Mask " -"Tool.\n" +"R|Masker to use. NB: The mask you require must exist within the alignments " +"file. You can add additional masks with the Mask Tool.\n" "L|none: Don't use a mask.\n" -"L|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.\n" -"L|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.\n" -"L|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.\n" -"L|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.\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.\n" -"L|predicted: If the 'Learn Mask' option was enabled during training, this will use the mask that was created by the trained model." -msgstr "" -"R|Использовать маску. Прим.: Требуемая маска должна наличествовать в файле выравнивания. Доп. маски можно добавить через " -"Инструмент Создания Масок.\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\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.\n" +"L|predicted: If the 'Learn Mask' option was enabled during training, this " +"will use the mask that was created by the trained model." +msgstr "" +"R|Использовать маску. Прим.: Требуемая маска должна наличествовать в файле " +"выравнивания. Доп. маски можно добавить через Инструмент Создания Масок.\n" "L|none: Не использовать маску.\n" -"L| components: маска, предназначенная для сегментации лица на основе найденных ориентиров. Маска создается построением выпуклого " -"многоугольника вокруг внешних ориентиров лица.\n" -"L| extended: маска, предназначенная для сегментации лица на основе расположения ориентиров. Маска создается построением выпуклого " +"L| components: маска, предназначенная для сегментации лица на основе " +"найденных ориентиров. Маска создается построением выпуклого многоугольника " +"вокруг внешних ориентиров лица.\n" +"L| extended: маска, предназначенная для сегментации лица на основе " +"расположения ориентиров. Маска создается построением выпуклого " "многоугольника вокруг внешних ориентиров лица и продолжается вверх на лоб.\n" -"L| vgg-clear: маска, предназначенная для умной сегментации преимущественно фронтальных лиц без препятствий. Лица в профиль и " -"препятствия могут привести к некачественным результатам.\n" -"L| vgg-obstructed: маска, предназначенная для умной сегментации преимущественно фронтальных лиц. Модель маски специально обучена " -"распознавать некоторые лицевые препятствия (руки и очки). Лица в профиль могут привести к некачественным результатам..\n" -"L| unet-dfl: маска, предназначенная для умной сегментации преимущественно фронтальных лиц. Модель маски была обучена членами " -"сообщества и потребует тестирования для дальнейшего описания. Лица в профиль могут привести к некачественным результатам..\n" -"L| predicted: Если во время обучения была включена опция «Learn Mask», будет использоваться маска, созданная обученной моделью." - -#: lib/cli/args.py:699 -msgid "" -"R|The plugin to use to output the converted images. The writers are configurable in '/config/convert.ini' or 'Settings > " -"Configure Convert Plugins:'\n" -"L|ffmpeg: [video] Writes out the convert straight to video. When the input is a series of images then the '-ref' (--reference-" -"video) parameter must be set.\n" +"L| vgg-clear: маска, предназначенная для умной сегментации преимущественно " +"фронтальных лиц без препятствий. Лица в профиль и препятствия могут привести " +"к некачественным результатам.\n" +"L| vgg-obstructed: маска, предназначенная для умной сегментации " +"преимущественно фронтальных лиц. Модель маски специально обучена " +"распознавать некоторые лицевые препятствия (руки и очки). Лица в профиль " +"могут привести к некачественным результатам..\n" +"L| unet-dfl: маска, предназначенная для умной сегментации преимущественно " +"фронтальных лиц. Модель маски была обучена членами сообщества и потребует " +"тестирования для дальнейшего описания. Лица в профиль могут привести к " +"некачественным результатам..\n" +"L| predicted: Если во время обучения была включена опция «Learn Mask», будет " +"использоваться маска, созданная обученной моделью." + +#: lib/cli/args.py:701 +msgid "" +"R|The plugin to use to output the converted images. The writers are " +"configurable in '/config/convert.ini' or 'Settings > Configure Convert " +"Plugins:'\n" +"L|ffmpeg: [video] Writes out the convert straight to video. When the input " +"is a series of images then the '-ref' (--reference-video) parameter must be " +"set.\n" "L|gif: [animated image] Create an animated gif.\n" -"L|opencv: [images] The fastest image writer, but less options and formats than other plugins.\n" -"L|pillow: [images] Slower than opencv, but has more options and supports more formats." -msgstr "" -"R|Тип плагина для вывода конвертированных изображений. Записывающие плагины можно настроить в '/config/convert.ini' либо " -"'Настройки > Настроить Плагины Конверсии:'\n" -"L|ffmpeg: [видео] Записывает результат конверсии сразу в видео файл. Если входом является серий изображений, то нужно также " -"указать параметр '-ref' (--reference-video).\n" +"L|opencv: [images] The fastest image writer, but less options and formats " +"than other plugins.\n" +"L|pillow: [images] Slower than opencv, but has more options and supports " +"more formats." +msgstr "" +"R|Тип плагина для вывода конвертированных изображений. Записывающие плагины " +"можно настроить в '/config/convert.ini' либо 'Настройки > Настроить Плагины " +"Конверсии:'\n" +"L|ffmpeg: [видео] Записывает результат конверсии сразу в видео файл. Если " +"входом является серий изображений, то нужно также указать параметр '-ref' (--" +"reference-video).\n" "L|gif: [анимированное изображение] Создает анимированный gif.\n" -"L|opencv: [изображения] Наибыстрейший способ записи, но с меньшим кол-вом опций и форматов вывода.\n" -"L|pillow: [изображения] Более медленный, чем opencv, но имеет больше опций и поддерживает больше форматов." +"L|opencv: [изображения] Наибыстрейший способ записи, но с меньшим кол-вом " +"опций и форматов вывода.\n" +"L|pillow: [изображения] Более медленный, чем opencv, но имеет больше опций и " +"поддерживает больше форматов." -#: lib/cli/args.py:718 lib/cli/args.py:725 lib/cli/args.py:819 +#: lib/cli/args.py:720 lib/cli/args.py:727 lib/cli/args.py:821 msgid "Frame Processing" msgstr "Обработка кадров" -#: lib/cli/args.py:719 +#: lib/cli/args.py:721 msgid "" -"Scale the final output frames by this amount. 100%% will output the frames at source dimensions. 50%% at half size 200%% at " -"double size" +"Scale the final output frames by this amount. 100%% will output the frames " +"at source dimensions. 50%% at half size 200%% at double size" msgstr "" -"Масштабировать оконечные кадры до указанного процента. 100%% будет выводить кадры в исходном размере. 50%% половина от размера, а " -"200%% в удвоенном размере" +"Масштабировать оконечные кадры до указанного процента. 100%% будет выводить " +"кадры в исходном размере. 50%% половина от размера, а 200%% в удвоенном " +"размере" -#: lib/cli/args.py:726 +#: lib/cli/args.py:728 msgid "" -"Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use --frame-ranges 10-50 90-100. Frames falling outside " -"of the selected range will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are converting from images, then " -"the filenames must end with the frame-number!" +"Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " +"--frame-ranges 10-50 90-100. Frames falling outside of the selected range " +"will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " +"converting from images, then the filenames must end with the frame-number!" msgstr "" -"Диапазон кадров к которым применять перенос, например, для кадров от 10 до 50, и 90 до 100 укажите: --frame-ranges 10-50 90-100. " -"Кадры попадающие вне выбранного диапазона будут отброшены если не указано '-k' (--keep-unchanged). Прим.: Если при конверсии " -"используются изображения, то имена файлов должны заканчиваться номером кадра!" +"Диапазон кадров к которым применять перенос, например, для кадров от 10 до " +"50, и 90 до 100 укажите: --frame-ranges 10-50 90-100. Кадры попадающие вне " +"выбранного диапазона будут отброшены если не указано '-k' (--keep-" +"unchanged). Прим.: Если при конверсии используются изображения, то имена " +"файлов должны заканчиваться номером кадра!" -#: lib/cli/args.py:736 +#: lib/cli/args.py:738 msgid "" -"If you have not cleansed your alignments file, then you can filter out faces by defining a folder here that contains the faces " -"extracted from your input files/video. If this folder is defined, then only faces that exist within your alignments file and also " -"exist within the specified folder will be converted. Leaving this blank will convert all faces that exist within the alignments " -"file." +"If you have not cleansed your alignments file, then you can filter out faces " +"by defining a folder here that contains the faces extracted from your input " +"files/video. If this folder is defined, then only faces that exist within " +"your alignments file and also exist within the specified folder will be " +"converted. Leaving this blank will convert all faces that exist within the " +"alignments file." msgstr "" -"Если вы не вычистили ваш файл выравниваний, то вы можете отфильтровать лица указав здесь папку, которая содержит лица извлеченные " -"из входных файлов/видео. Если эта папка указана, то, только лица, которые существуют в файле выравниваний и ТАКЖЕ существуют в " -"указанной папке будут сконвертированы. Если оставить это поле пустым, то все лица, которые существуют в файле выравниваний будут " -"сконвертированы." +"Если вы не вычистили ваш файл выравниваний, то вы можете отфильтровать лица " +"указав здесь папку, которая содержит лица извлеченные из входных файлов/" +"видео. Если эта папка указана, то, только лица, которые существуют в файле " +"выравниваний и ТАКЖЕ существуют в указанной папке будут сконвертированы. " +"Если оставить это поле пустым, то все лица, которые существуют в файле " +"выравниваний будут сконвертированы." -#: lib/cli/args.py:790 +#: lib/cli/args.py:792 msgid "" -"The maximum number of parallel processes for performing conversion. Converting images is system RAM heavy so it is possible to " -"run out of memory if you have a lot of processes and not enough RAM to accommodate them all. Setting this to 0 will use the " -"maximum available. No matter what you set this to, it will never attempt to use more processes than are available on your system. " -"If singleprocess is enabled this setting will be ignored." +"The maximum number of parallel processes for performing conversion. " +"Converting images is system RAM heavy so it is possible to run out of memory " +"if you have a lot of processes and not enough RAM to accommodate them all. " +"Setting this to 0 will use the maximum available. No matter what you set " +"this to, it will never attempt to use more processes than are available on " +"your system. If singleprocess is enabled this setting will be ignored." msgstr "" -"Максимальное количество параллельных процессов для выполнения преобразования. Преобразование изображений требует большого объема " -"системной памяти, поэтому возможна ее нехватка, если у вас много процессов и не хватает памяти для их всех. Установка этого " -"значения на 0 будет использовать максимально доступное значение. Независимо от ваших установок, никогда не будет использоваться " -"больше процессов, чем доступно в вашей системе. Если включен одиночный процесс, этот параметр будет проигнорирован." +"Максимальное количество параллельных процессов для выполнения " +"преобразования. Преобразование изображений требует большого объема системной " +"памяти, поэтому возможна ее нехватка, если у вас много процессов и не " +"хватает памяти для их всех. Установка этого значения на 0 будет использовать " +"максимально доступное значение. Независимо от ваших установок, никогда не " +"будет использоваться больше процессов, чем доступно в вашей системе. Если " +"включен одиночный процесс, этот параметр будет проигнорирован." -#: lib/cli/args.py:801 +#: lib/cli/args.py:803 msgid "" -"[LEGACY] This only needs to be selected if a legacy model is being loaded or if there are multiple models in the model folder" +"[LEGACY] This only needs to be selected if a legacy model is being loaded or " +"if there are multiple models in the model folder" msgstr "" -"[СОВМЕСТИМОСТЬ] Это нужно выбирать только в том случае, если загружается устаревшая модель или если в папке сохранения есть " -"несколько моделей" +"[СОВМЕСТИМОСТЬ] Это нужно выбирать только в том случае, если загружается " +"устаревшая модель или если в папке сохранения есть несколько моделей" -#: lib/cli/args.py:809 +#: lib/cli/args.py:811 msgid "" -"Enable On-The-Fly Conversion. NOT recommended. You should generate a clean alignments file for your destination video. However, " -"if you wish you can generate the alignments on-the-fly by enabling this option. This will use an inferior extraction pipeline and " -"will lead to substandard results. If an alignments file is found, this option will be ignored." +"Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " +"alignments file for your destination video. However, if you wish you can " +"generate the alignments on-the-fly by enabling this option. This will use an " +"inferior extraction pipeline and will lead to substandard results. If an " +"alignments file is found, this option will be ignored." msgstr "" -"Включить преобразование на лету. НЕ рекомендуется. Вам стоит создать чистый файл выравнивания для вашего целевого видео. Однако, " -"если вы хотите, вы можете сгенерировать выравнивания на лету, включив эту опцию. Это приведет к использованию улучшенного " -"конвейера экстракции и некачественных результатов. Если файл выравниваний найден, этот параметр будет проигнорирован." +"Включить преобразование на лету. НЕ рекомендуется. Вам стоит создать чистый " +"файл выравнивания для вашего целевого видео. Однако, если вы хотите, вы " +"можете сгенерировать выравнивания на лету, включив эту опцию. Это приведет к " +"использованию улучшенного конвейера экстракции и некачественных результатов. " +"Если файл выравниваний найден, этот параметр будет проигнорирован." -#: lib/cli/args.py:820 -msgid "When used with --frame-ranges outputs the unchanged frames that are not processed instead of discarding them." -msgstr "При использовании с --frame-range кадры не попавшие в диапазон выводятся неизменными, вместо их пропуска." +#: lib/cli/args.py:822 +msgid "" +"When used with --frame-ranges outputs the unchanged frames that are not " +"processed instead of discarding them." +msgstr "" +"При использовании с --frame-range кадры не попавшие в диапазон выводятся " +"неизменными, вместо их пропуска." -#: lib/cli/args.py:828 +#: lib/cli/args.py:830 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" -msgstr "Поменять модели местами. Вместо преобразования из A -> B, преобразует B -> A" +msgstr "" +"Поменять модели местами. Вместо преобразования из A -> B, преобразует B -> A" -#: lib/cli/args.py:834 +#: lib/cli/args.py:836 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Отключить многопроцессорность. Медленнее, но менее ресурсоемко." -#: lib/cli/args.py:850 +#: lib/cli/args.py:852 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" "Model plugins can be configured in the 'Settings' Menu" msgstr "" -"Начать обучение модели используя наборы лиц: (A) - исходное лицо и (B) - новое лицо.\n" +"Начать обучение модели используя наборы лиц: (A) - исходное лицо и (B) - " +"новое лицо.\n" "Обучение моделей может занять долгое время: от 24 часов до недели\n" "Каждую модель можно отдельно настроить в меню «Настройки»" -#: lib/cli/args.py:869 lib/cli/args.py:880 lib/cli/args.py:889 lib/cli/args.py:900 +#: lib/cli/args.py:871 lib/cli/args.py:882 lib/cli/args.py:891 +#: lib/cli/args.py:902 msgid "faces" msgstr "лица" -#: lib/cli/args.py:870 +#: lib/cli/args.py:872 msgid "" -"Input directory. A directory containing training images for face A. This is the original face, i.e. the face that you want to " -"remove and replace with face B." +"Input directory. A directory containing training images for face A. This is " +"the original face, i.e. the face that you want to remove and replace with " +"face B." msgstr "" -"Входная папка. Папка содержащая изображения для тренировки лица A. Это исходное лицо т.е. лицо, которое вы хотите убрать, заменив " -"лицом B." +"Входная папка. Папка содержащая изображения для тренировки лица A. Это " +"исходное лицо т.е. лицо, которое вы хотите убрать, заменив лицом B." -#: lib/cli/args.py:881 +#: lib/cli/args.py:883 msgid "" -"DEPRECATED - This option will be removed in a future update. Path to alignments file for training set A. Defaults to /" -"alignments.json if not provided." +"DEPRECATED - This option will be removed in a future update. Path to " +"alignments file for training set A. Defaults to /alignments.json if " +"not provided." msgstr "" -"УСТАРЕЛО - Эта настройка будет удалена в будущих обновлениях. Путь к файлу выравнивания для обучающего набора A. По умолчанию " -"используется /alignments.json, если он не указан." +"УСТАРЕЛО - Эта настройка будет удалена в будущих обновлениях. Путь к файлу " +"выравнивания для обучающего набора A. По умолчанию используется /" +"alignments.json, если он не указан." -#: lib/cli/args.py:890 +#: lib/cli/args.py:892 msgid "" -"Input directory. A directory containing training images for face B. This is the swap face, i.e. the face that you want to place " -"onto the head of person A." +"Input directory. A directory containing training images for face B. This is " +"the swap face, i.e. the face that you want to place onto the head of person " +"A." msgstr "" -"Входная папка. Папка содержащая изображения для тренировки лица B. Это новое лицо т.е. лицо, которое вы хотите поместить на " -"голову человека A." +"Входная папка. Папка содержащая изображения для тренировки лица B. Это новое " +"лицо т.е. лицо, которое вы хотите поместить на голову человека A." -#: lib/cli/args.py:901 +#: lib/cli/args.py:903 msgid "" -"DEPRECATED - This option will be removed in a future update. Path to alignments file for training set B. Defaults to /" -"alignments.json if not provided." +"DEPRECATED - This option will be removed in a future update. Path to " +"alignments file for training set B. Defaults to /alignments.json if " +"not provided." msgstr "" -"УСТАРЕЛО - Эта настройка будет удалена в будущих обновлениях. Путь к файлу выравнивания для обучающего набора B. По умолчанию " -"используется /alignments.json, если он не указан." +"УСТАРЕЛО - Эта настройка будет удалена в будущих обновлениях. Путь к файлу " +"выравнивания для обучающего набора B. По умолчанию используется /" +"alignments.json, если он не указан." -#: lib/cli/args.py:909 lib/cli/args.py:921 +#: lib/cli/args.py:911 lib/cli/args.py:923 lib/cli/args.py:948 msgid "model" msgstr "модель" -#: lib/cli/args.py:910 +#: lib/cli/args.py:912 msgid "" -"Model directory. This is where the training data will be stored. You should always specify a new folder for new models. If " -"starting a new model, select either an empty folder, or a folder which does not exist (which will be created). If continuing to " -"train an existing model, specify the location of the existing model." +"Model directory. This is where the training data will be stored. You should " +"always specify a new folder for new models. If starting a new model, select " +"either an empty folder, or a folder which does not exist (which will be " +"created). If continuing to train an existing model, specify the location of " +"the existing model." msgstr "" -"Папка сохранений модели. Здесь сохраняется прогресс тренировки. Следует всегда создавать новую папку для новых моделей. При " -"начале тренировки новой модели, выберите пустую либо несуществующую папку (во втором случае она будет создана). Если вы хотите " -"продолжить тренировку, выберите папку с уже существующими сохранениями." +"Папка сохранений модели. Здесь сохраняется прогресс тренировки. Следует " +"всегда создавать новую папку для новых моделей. При начале тренировки новой " +"модели, выберите пустую либо несуществующую папку (во втором случае она " +"будет создана). Если вы хотите продолжить тренировку, выберите папку с уже " +"существующими сохранениями." -#: lib/cli/args.py:922 +#: lib/cli/args.py:924 msgid "" -"R|Select which trainer to use. Trainers can be configured from the Settings menu or the config folder.\n" +"R|Select which trainer to use. Trainers can be configured from the Settings " +"menu or the config folder.\n" "L|original: The original model created by /u/deepfakes.\n" -"L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' for full dfaker method.\n" +"L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " +"for full dfaker method.\n" "L|dfl-h128: 128px in/out model from deepfacelab\n" "L|dfl-sae: Adaptable model from deepfacelab\n" "L|dlight: A lightweight, high resolution DFaker variant.\n" "L|iae: A model that uses intermediate layers to try to get better details\n" -"L|lightweight: A lightweight model for low-end cards. Don't expect great results. Can train as low as 1.6GB with batch size 8.\n" -"L|realface: A high detail, dual density model based on DFaker, with customizable in/out resolution. The autoencoders are " -"unbalanced so B>A swaps won't work so well. By andenixa et al. Very configurable.\n" -"L|unbalanced: 128px in/out model from andenixa. The autoencoders are unbalanced so B>A swaps won't work so well. Very " -"configurable.\n" -"L|villain: 128px in/out model from villainguy. Very resource hungry (You will require a GPU with a fair amount of VRAM). Good for " -"details, but more susceptible to color differences." -msgstr "" -"R|Выберите тренера для использования. Тренеры могут быть настроенны через меню Настройки либо в папке config.\n" +"L|lightweight: A lightweight model for low-end cards. Don't expect great " +"results. Can train as low as 1.6GB with batch size 8.\n" +"L|realface: A high detail, dual density model based on DFaker, with " +"customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " +"won't work so well. By andenixa et al. Very configurable.\n" +"L|unbalanced: 128px in/out model from andenixa. The autoencoders are " +"unbalanced so B>A swaps won't work so well. Very configurable.\n" +"L|villain: 128px in/out model from villainguy. Very resource hungry (You " +"will require a GPU with a fair amount of VRAM). Good for details, but more " +"susceptible to color differences." +msgstr "" +"R|Выберите тренера для использования. Тренеры могут быть настроенны через " +"меню Настройки либо в папке config.\n" "L|original: Оригинальная модель созданная /u/deepfakes.\n" -"L|dfaker: модель с 64px вход/128px выходом от dfaker. Включите 'warp-to-landmarks' для полного соответствия методу dfaker.\n" +"L|dfaker: модель с 64px вход/128px выходом от dfaker. Включите 'warp-to-" +"landmarks' для полного соответствия методу dfaker.\n" "L|dfl-h128: 128px вход/выход модель от deepfacelab\n" "L|dfl-sae: Адаптивная модель от deepfacelab\n" "L|dlight: Легковесная модель высокого разрешения. Один из вариантов DFaker.\n" -"L|iae: Модель использующая промежуточные слои, для достижения лучшей детализции\n" -"L|lightweight: Легковесная модель для младшей линейки видеокарт. Не ожидайте хороших результатов. Может тренировать на картах с " -"1.6Гб памяти при размере серии 8.\n" -"L|realface: Модель повышенной детализации, с двумя сложносоставными слоями, базированная на DFaker, с настраиваемым разрешением " -"входа/выхода. Автоэнкодеры не сбалансированы, поэтому свапы B>A не дадут хорошего качества. andenixa и другие. Очень " -"настраиваемая.\n" -"L|unbalanced: Модель 128px вход/выход от andenixa. Автоэнкодеры не сбалансированы, поэтому свапы B>A не будут очень хорошими. " -"Очень настраеваемая.\n" -"L|villain: Модель 128px вход/выход от villainguy. Очень требовательна к ресурсам (Вам потребуется GPU с хорошим количеством " -"видеопамяти). Хороша для деталей, но подвержена к неправильной передаче цвета." - -#: lib/cli/args.py:949 lib/cli/args.py:961 lib/cli/args.py:972 lib/cli/args.py:1058 +"L|iae: Модель использующая промежуточные слои, для достижения лучшей " +"детализции\n" +"L|lightweight: Легковесная модель для младшей линейки видеокарт. Не ожидайте " +"хороших результатов. Может тренировать на картах с 1.6Гб памяти при размере " +"серии 8.\n" +"L|realface: Модель повышенной детализации, с двумя сложносоставными слоями, " +"базированная на DFaker, с настраиваемым разрешением входа/выхода. " +"Автоэнкодеры не сбалансированы, поэтому свапы B>A не дадут хорошего " +"качества. andenixa и другие. Очень настраиваемая.\n" +"L|unbalanced: Модель 128px вход/выход от andenixa. Автоэнкодеры не " +"сбалансированы, поэтому свапы B>A не будут очень хорошими. Очень " +"настраеваемая.\n" +"L|villain: Модель 128px вход/выход от villainguy. Очень требовательна к " +"ресурсам (Вам потребуется GPU с хорошим количеством видеопамяти). Хороша для " +"деталей, но подвержена к неправильной передаче цвета." + +#: lib/cli/args.py:949 +msgid "" +"Output a summary of the model and exit. If a model folder is provided then a " +"summary of the saved model is displayed. Otherwise a summary of the model " +"that would be created by the chosen plugin and configuration settings is " +"displayed." +msgstr "" +"Выведите сводку модели и выйдите. Если предоставлена папка модели, " +"отображается сводка сохраненной модели. В противном случае отображается " +"сводная информация о модели, которая будет создана выбранным плагином, и " +"параметрами конфигурации." + +#: lib/cli/args.py:961 lib/cli/args.py:973 lib/cli/args.py:984 +#: lib/cli/args.py:1070 msgid "training" msgstr "тренировка" -#: lib/cli/args.py:950 +#: lib/cli/args.py:962 msgid "" -"Batch size. This is the number of images processed through the model for each side per iteration. NB: As the model is fed 2 sides " -"at a time, the actual number of images within the model at any one time is double the number that you set here. Larger batches " -"require more GPU RAM." +"Batch size. This is the number of images processed through the model for " +"each side per iteration. NB: As the model is fed 2 sides at a time, the " +"actual number of images within the model at any one time is double the " +"number that you set here. Larger batches require more GPU RAM." msgstr "" -"Размер партии. Это количество изображений для каждой стороны, которые обрабатываются моделью за одну итерацию. Примечание: " -"Поскольку в модель передается сразу две стороны за раз, реальное количество загружаемых изображений в два раза больше этого " -"числа. Увеличение размера партии требует больше памяти GPU." +"Размер партии. Это количество изображений для каждой стороны, которые " +"обрабатываются моделью за одну итерацию. Примечание: Поскольку в модель " +"передается сразу две стороны за раз, реальное количество загружаемых " +"изображений в два раза больше этого числа. Увеличение размера партии требует " +"больше памяти GPU." -#: lib/cli/args.py:962 +#: lib/cli/args.py:974 msgid "" -"Length of training in iterations. This is only really used for automation. There is no 'correct' number of iterations a model " -"should be trained for. You should stop training when you are happy with the previews. However, if you want the model to stop " -"automatically at a set number of iterations, you can set that value here." +"Length of training in iterations. This is only really used for automation. " +"There is no 'correct' number of iterations a model should be trained for. " +"You should stop training when you are happy with the previews. However, if " +"you want the model to stop automatically at a set number of iterations, you " +"can set that value here." msgstr "" -"Кол-во итераций для тренировки. Используется только для автоматизирования. Не существует \"правильного\" кол-ва итераций для " -"любой выбранной модели. Тренировку стоит завершать только когда вы довольны кадрами на превью. Однако, если вы хотите, чтобы " -"тренировка прервалась после указанного кол-ва итерация, вы можете ввести это здесь." +"Кол-во итераций для тренировки. Используется только для автоматизирования. " +"Не существует \"правильного\" кол-ва итераций для любой выбранной модели. " +"Тренировку стоит завершать только когда вы довольны кадрами на превью. " +"Однако, если вы хотите, чтобы тренировка прервалась после указанного кол-ва " +"итерация, вы можете ввести это здесь." -#: lib/cli/args.py:973 -msgid "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." -msgstr "Использовать стратегию зеркального распределения Tensorflow для совместной тренировки сразу на нескольких GPU." +#: lib/cli/args.py:985 +msgid "" +"Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." +msgstr "" +"Использовать стратегию зеркального распределения Tensorflow для совместной " +"тренировки сразу на нескольких GPU." -#: lib/cli/args.py:983 lib/cli/args.py:993 +#: lib/cli/args.py:995 lib/cli/args.py:1005 msgid "Saving" msgstr "Сохранение" -#: lib/cli/args.py:984 +#: lib/cli/args.py:996 msgid "Sets the number of iterations between each model save." msgstr "Установка количества итераций между сохранениями модели." -#: lib/cli/args.py:994 -msgid "Sets the number of iterations before saving a backup snapshot of the model in it's current state. Set to 0 for off." -msgstr "Устанавливает кол-во итераций перед созданием резервной копии модели. Установите в 0 для отключения." +#: lib/cli/args.py:1006 +msgid "" +"Sets the number of iterations before saving a backup snapshot of the model " +"in it's current state. Set to 0 for off." +msgstr "" +"Устанавливает кол-во итераций перед созданием резервной копии модели. " +"Установите в 0 для отключения." -#: lib/cli/args.py:1001 lib/cli/args.py:1012 lib/cli/args.py:1023 +#: lib/cli/args.py:1013 lib/cli/args.py:1024 lib/cli/args.py:1035 msgid "timelapse" msgstr "таймлапс" -#: lib/cli/args.py:1002 +#: lib/cli/args.py:1014 msgid "" -"Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every " -"save iteration. This should be the input folder of 'A' faces that you would like to use for creating the timelapse. You must also " -"supply a --timelapse-output and a --timelapse-input-B parameter." +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. " +"This should be the input folder of 'A' faces that you would like to use for " +"creating the timelapse. You must also supply a --timelapse-output and a --" +"timelapse-input-B parameter." msgstr "" -"Только при создании таймлапсов. Сохраняет предварительный просмотр выбранных лиц в папку timelapse-output при каждом сохранении. " -"Следует указать входную папку лиц набора 'A' для использования при создании таймлапса. Вам также нужно указать параметры--" -"timelapse-output и --timelapse-input-B." +"Только при создании таймлапсов. Сохраняет предварительный просмотр выбранных " +"лиц в папку timelapse-output при каждом сохранении. Следует указать входную " +"папку лиц набора 'A' для использования при создании таймлапса. Вам также " +"нужно указать параметры--timelapse-output и --timelapse-input-B." -#: lib/cli/args.py:1013 +#: lib/cli/args.py:1025 msgid "" -"Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every " -"save iteration. This should be the input folder of 'B' faces that you would like to use for creating the timelapse. You must also " -"supply a --timelapse-output and a --timelapse-input-A parameter." +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. " +"This should be the input folder of 'B' faces that you would like to use for " +"creating the timelapse. You must also supply a --timelapse-output and a --" +"timelapse-input-A parameter." msgstr "" -"Только при создании таймлапса. Таймлапс будет сохранять изображения выбранных лиц в папке таймлапсов при каждой итерации " -"сохранения. Это должна быть папка для ввода лиц из набора 'B', для использования в создании таймлапса. Вы также должны указать " -"параметр --timelapse-output и --timelapse-input-A." +"Только при создании таймлапса. Таймлапс будет сохранять изображения " +"выбранных лиц в папке таймлапсов при каждой итерации сохранения. Это должна " +"быть папка для ввода лиц из набора 'B', для использования в создании " +"таймлапса. Вы также должны указать параметр --timelapse-output и --timelapse-" +"input-A." -#: lib/cli/args.py:1024 +#: lib/cli/args.py:1036 msgid "" -"Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every " -"save iteration. If the input folders are supplied but no output folder, it will default to your model folder /timelapse/" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. If " +"the input folders are supplied but no output folder, it will default to your " +"model folder /timelapse/" msgstr "" -"Опционально, при создании таймлапса. Создаст картинку текущего таймлапса выбранных лиц в папке timelapse-output при каждом " -"сохранении модели. Если указаны только входные папки, то по умолчанию вывод будет сохранен вместе с моделью в подкаталог /" -"timelapse/" +"Опционально, при создании таймлапса. Создаст картинку текущего таймлапса " +"выбранных лиц в папке timelapse-output при каждом сохранении модели. Если " +"указаны только входные папки, то по умолчанию вывод будет сохранен вместе с " +"моделью в подкаталог /timelapse/" -#: lib/cli/args.py:1036 lib/cli/args.py:1043 lib/cli/args.py:1050 +#: lib/cli/args.py:1048 lib/cli/args.py:1055 lib/cli/args.py:1062 msgid "preview" msgstr "предварительный просмотр" -#: lib/cli/args.py:1037 -msgid "Percentage amount to scale the preview by. 100%% is the model output size." -msgstr "Величина в процентах, на которую требуется масштабировать предварительный просмотр. 100 %% - размер вывода модели." +#: lib/cli/args.py:1049 +msgid "" +"Percentage amount to scale the preview by. 100%% is the model output size." +msgstr "" +"Величина в процентах, на которую требуется масштабировать предварительный " +"просмотр. 100 %% - размер вывода модели." -#: lib/cli/args.py:1044 +#: lib/cli/args.py:1056 msgid "Show training preview output. in a separate window." msgstr "Показывать предварительный просмотр в отдельном окне." -#: lib/cli/args.py:1051 -msgid "Writes the training result to a file. The image will be stored in the root of your FaceSwap folder." -msgstr "Записывает результат тренировки в файл. Файл будет сохранен в коренной папке FaceSwap." +#: lib/cli/args.py:1063 +msgid "" +"Writes the training result to a file. The image will be stored in the root " +"of your FaceSwap folder." +msgstr "" +"Записывает результат тренировки в файл. Файл будет сохранен в коренной папке " +"FaceSwap." -#: lib/cli/args.py:1059 +#: lib/cli/args.py:1071 msgid "" -"Disables TensorBoard logging. NB: Disabling logs means that you will not be able to use the graph or analysis for this session in " -"the GUI." +"Disables TensorBoard logging. NB: Disabling logs means that you will not be " +"able to use the graph or analysis for this session in the GUI." msgstr "" -"Отключает журнал TensorBoard. Примечание: Отключение журналов означает, что вы не сможете использовать графики или анализ сессии " -"внутри GUI." +"Отключает журнал TensorBoard. Примечание: Отключение журналов означает, что " +"вы не сможете использовать графики или анализ сессии внутри GUI." -#: lib/cli/args.py:1066 lib/cli/args.py:1075 lib/cli/args.py:1084 lib/cli/args.py:1093 +#: lib/cli/args.py:1078 lib/cli/args.py:1087 lib/cli/args.py:1096 +#: lib/cli/args.py:1105 msgid "augmentation" msgstr "аугментация" -#: lib/cli/args.py:1067 +#: lib/cli/args.py:1079 msgid "" -"Warps training faces to closely matched Landmarks from the opposite face-set rather than randomly warping the face. This is the " -"'dfaker' way of doing warping." +"Warps training faces to closely matched Landmarks from the opposite face-set " +"rather than randomly warping the face. This is the 'dfaker' way of doing " +"warping." msgstr "" -"Вместо случайного искажения лица, деформирует лица в соответствии с Ориентирами/Landmarks противоположного набора лиц. Этот " -"способ используется пакетом \"dfaker\"." +"Вместо случайного искажения лица, деформирует лица в соответствии с " +"Ориентирами/Landmarks противоположного набора лиц. Этот способ используется " +"пакетом \"dfaker\"." -#: lib/cli/args.py:1076 +#: lib/cli/args.py:1088 msgid "" -"To effectively learn, a random set of images are flipped horizontally. Sometimes it is desirable for this not to occur. Generally " -"this should be left off except for during 'fit training'." +"To effectively learn, a random set of images are flipped horizontally. " +"Sometimes it is desirable for this not to occur. Generally this should be " +"left off except for during 'fit training'." msgstr "" -"Для повышения эффективности обучения, некоторые изображения случайным образом переворачивается по горизонтали. Иногда желательно, " -"чтобы этого не происходило. Как правило, эту настройку не стоит трогать, за исключением периода «финальной шлифовки»." +"Для повышения эффективности обучения, некоторые изображения случайным " +"образом переворачивается по горизонтали. Иногда желательно, чтобы этого не " +"происходило. Как правило, эту настройку не стоит трогать, за исключением " +"периода «финальной шлифовки»." -#: lib/cli/args.py:1085 +#: lib/cli/args.py:1097 msgid "" -"Color augmentation helps make the model less susceptible to color differences between the A and B sets, at an increased training " -"time cost. Enable this option to disable color augmentation." +"Color augmentation helps make the model less susceptible to color " +"differences between the A and B sets, at an increased training time cost. " +"Enable this option to disable color augmentation." msgstr "" -"Цветовая аугментация помогает модели быть менее чувствительной к разнице цвета между наборами A and B ценой некоторого замедления " -"скорости тренировки. Включите эту опцию для отключения цветовой аугментации." +"Цветовая аугментация помогает модели быть менее чувствительной к разнице " +"цвета между наборами A and B ценой некоторого замедления скорости " +"тренировки. Включите эту опцию для отключения цветовой аугментации." -#: lib/cli/args.py:1094 +#: lib/cli/args.py:1106 msgid "" -"Warping is integral to training the Neural Network. This option should only be enabled towards the very end of training to try to " -"bring out more detail. Think of it as 'fine-tuning'. Enabling this option from the beginning is likely to kill a model and lead " -"to terrible results." +"Warping is integral to training the Neural Network. This option should only " +"be enabled towards the very end of training to try to bring out more detail. " +"Think of it as 'fine-tuning'. Enabling this option from the beginning is " +"likely to kill a model and lead to terrible results." msgstr "" -"Внесение случайных искажение является неотъемлемой частью обучения нейронной сети. Эту опцию следует включать только в самом " -"конце обучения, чтобы попытаться выявить больше деталей. Думайте об этом как о «стадии шлифовки». Включение этой опции с самого " -"начала может убить модель и привести к ужасным результатам." +"Внесение случайных искажение является неотъемлемой частью обучения нейронной " +"сети. Эту опцию следует включать только в самом конце обучения, чтобы " +"попытаться выявить больше деталей. Думайте об этом как о «стадии шлифовки». " +"Включение этой опции с самого начала может убить модель и привести к ужасным " +"результатам." -#: lib/cli/args.py:1119 +#: lib/cli/args.py:1131 msgid "Output to Shell console instead of GUI console" msgstr "Вывод в системную консоль вместо GUI" diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 8d928729b5..33e60f67c7 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -254,6 +254,7 @@ def build(self): Finally, a model summary is outputted to the logger at verbose level. """ self._update_legacy_models() + is_summary = hasattr(self._args, "summary") and self._args.summary with self._settings.strategy_scope(): if self._io.model_exists: model = self._io._load() # pylint:disable=protected-access @@ -266,7 +267,7 @@ def build(self): self._validate_input_shape() inputs = self._get_inputs() self._model = self.build_model(inputs) - if not self._is_predict: + if not is_summary and not self._is_predict: self._compile_model() self._output_summary() @@ -356,10 +357,14 @@ def build_model(self, inputs): def _output_summary(self): """ Output the summary of the model and all sub-models to the verbose logger. """ - self._model.summary(print_fn=lambda x: logger.verbose("%s", x)) + if hasattr(self._args, "summary") and self._args.summary: + print_fn = None # Print straight to stdout + else: + print_fn = lambda x: logger.verbose("%s", x) # print to logger + self._model.summary(print_fn=print_fn) for layer in self._model.layers: if isinstance(layer, KModel): - layer.summary(print_fn=lambda x: logger.verbose("%s", x)) + layer.summary(print_fn=print_fn) def save(self): """ Save the model to disk. diff --git a/scripts/train.py b/scripts/train.py index 725f5dc832..68dfa5c683 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -36,6 +36,10 @@ class Train(): # pylint:disable=too-few-public-methods def __init__(self, arguments): logger.debug("Initializing %s: (args: %s", self.__class__.__name__, arguments) self._args = arguments + if self._args.summary: + # If just outputting summary we don't need to initialize everything + return + self._images = self._get_images() self._timelapse = self._set_timelapse() self._gui_preview_trigger = os.path.join(os.path.realpath(os.path.dirname(sys.argv[0])), @@ -46,7 +50,6 @@ def __init__(self, arguments): self._preview_buffer = dict() self._lock = Lock() - self.trainer_name = self._args.trainer logger.debug("Initialized %s", self.__class__.__name__) def _get_images(self): @@ -162,6 +165,9 @@ def process(self): Should only be called from :class:`lib.cli.launcher.ScriptExecutor` """ + if self._args.summary: + self._load_model() + return logger.debug("Starting Training Process") logger.info("Training data directory: %s", self._args.model_dir) thread = self._start_thread() @@ -241,7 +247,7 @@ def _load_model(self): """ logger.debug("Loading Model") model_dir = str(get_folder(self._args.model_dir)) - model = PluginLoader.get_model(self.trainer_name)( + model = PluginLoader.get_model(self._args.trainer)( model_dir, self._args, predict=False) From 52c10dcc35ed80d9009de793106ce0d73aab2576 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 11 Mar 2021 16:42:36 +0000 Subject: [PATCH 403/981] scripts.train - Add freeze weights option lib.config: - Better formatting for .ini files - Support multi-select options gui - Add support for multi-select config items --- lib/cli/args.py | 11 +++ lib/config.py | 119 +++++++++++++++++++---- lib/gui/custom_widgets.py | 2 +- lib/gui/popup_configure.py | 7 ++ locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 39799 -> 40457 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 67 ++++++++----- locales/lib.cli.args.pot | 55 ++++++----- locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 51387 -> 52268 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 67 ++++++++----- plugins/train/model/_base.py | 23 ++++- plugins/train/model/original_defaults.py | 28 +++--- 11 files changed, 264 insertions(+), 115 deletions(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index df26652346..4cac4ccb6f 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -950,6 +950,17 @@ def get_argument_list(): "summary of the saved model is displayed. Otherwise a summary of the model " "that would be created by the chosen plugin and configuration settings is " "displayed."))) + argument_list.append(dict( + opts=("-f", "--freeze"), + action="store_true", + dest="freeze", + default=False, + group=_("model"), + help=_("Freeze the weights of the model. Freezing weights means that some of the " + "parameters in the model will no longer continue to learn, but those that are " + "not frozen will continue to learn. For most models, this will freeze the " + "encoder, but some models may have configuration options for freezing other " + "layers."))) argument_list.append(dict( opts=("-bs", "--batch-size"), action=Slider, diff --git a/lib/config.py b/lib/config.py index 088f54cbc1..b5981797e6 100644 --- a/lib/config.py +++ b/lib/config.py @@ -6,6 +6,7 @@ import logging import os import sys +import textwrap from collections import OrderedDict from configparser import ConfigParser from importlib import import_module @@ -126,7 +127,20 @@ def config_dict(self): return conf def get(self, section, option): - """ Return a config item in it's correct format """ + """ Return a config item in it's correct format. + + Parameters + ---------- + section: str + The configuration section currently being processed + option: str + The configuration option currently being processed + + Returns + ------- + varies + The selected configuration option in the correct data format + """ logger.debug("Getting config item: (section: '%s', option: '%s')", section, option) datatype = self.defaults[section][option]["type"] if datatype == bool: @@ -135,6 +149,8 @@ def get(self, section, option): func = self.config.getint elif datatype == float: func = self.config.getfloat + elif datatype == list: + func = self._parse_list else: func = self.config.get retval = func(section, option) @@ -143,6 +159,34 @@ def get(self, section, option): logger.debug("Returning item: (type: %s, value: %s)", datatype, retval) return retval + def _parse_list(self, section, option): + """ Parse options that are stored as lists in the config file. These can be space or + comma-separated items in the config file. They will be returned as a list of strings, + regardless of what the final data type should be, so conversion from strings to other + formats should be done explicitly within the retrieving code. + + Parameters + ---------- + section: str + The configuration section currently being processed + option: str + The configuration option currently being processed + + Returns + ------- + list + List of `str` selected items for the config choice. + """ + raw_option = self.config.get(section, option) + if not raw_option: + logger.debug("No options selected, returning empty list") + return [] + delimiter = "," if "," in raw_option else None + retval = [opt.strip().lower() for opt in raw_option.split(delimiter)] + logger.debug("Processed raw option '%s' to list %s for section '%s', option '%s'", + raw_option, retval, section, option) + return retval + def get_config_file(self, configfile): """ Return the config file from the calling folder or the provided file """ if configfile is not None: @@ -179,6 +223,9 @@ def add_item(self, section=None, title=None, datatype=str, default=None, info=No For str values choices can be set to validate input and create a combo box in the GUI + For list values, choices must be provided, and a multi-option select box will + be created + is_radio is to indicate to the GUI that it should display Radio Buttons rather than combo boxes for multiple choice options. @@ -199,16 +246,17 @@ def add_item(self, section=None, title=None, datatype=str, default=None, info=No choices = list() if not choices else choices if None in (section, title, default, info): - raise ValueError("Default config items must have a section, " - "title, defult and " + raise ValueError("Default config items must have a section, title, defult and " "information text") if not self.defaults.get(section, None): raise ValueError("Section does not exist: {}".format(section)) - if datatype not in (str, bool, float, int): + if datatype not in (str, bool, float, int, list): raise ValueError("'datatype' must be one of str, bool, float or " "int: {} - {}".format(section, title)) if datatype in (float, int) and (rounding is None or min_max is None): raise ValueError("'rounding' and 'min_max' must be set for numerical options") + if isinstance(datatype, list) and not choices: + raise ValueError("'choices' must be defined for list based configuration items") if not isinstance(choices, (list, tuple)): raise ValueError("'choices' must be a list or tuple") @@ -228,7 +276,10 @@ def expand_helptext(helptext, choices, default, datatype, min_max, fixed): """ Add extra helptext info from parameters """ helptext += "\n" if not fixed: - helptext += "\nThis option can be updated for existing models." + helptext += "\nThis option can be updated for existing models.\n" + if datatype == list: + helptext += ("\nIf selecting multiple options then each option should be separated " + "by a space or a comma (e.g. item1, item2, item3)\n") if choices: helptext += "\nChoose from: {}".format(choices) elif datatype == bool: @@ -257,7 +308,7 @@ def create_default(self): logger.debug("Adding section: '%s')", section) self.insert_config_section(section, items["helptext"]) for item, opt in items.items(): - logger.debug("Adding option: (item: '%s', opt: '%s'", item, opt) + logger.debug("Adding option: (item: '%s', opt: '%s')", item, opt) if item == "helptext": continue self.insert_config_item(section, @@ -271,6 +322,7 @@ def insert_config_section(self, section, helptext, config=None): logger.debug("Inserting section: (section: '%s', helptext: '%s', config: '%s')", section, helptext, config) config = self.config if config is None else config + config.optionxform = str helptext = self.format_help(helptext, is_section=True) config.add_section(section) config.set(section, helptext) @@ -282,6 +334,7 @@ def insert_config_item(self, section, item, default, option, logger.debug("Inserting item: (section: '%s', item: '%s', default: '%s', helptext: '%s', " "config: '%s')", section, item, default, option["helptext"], config) config = self.config if config is None else config + config.optionxform = str helptext = option["helptext"] helptext = self.format_help(helptext, is_section=False) config.set(section, helptext) @@ -292,7 +345,15 @@ def insert_config_item(self, section, item, default, option, def format_help(helptext, is_section=False): """ Format comments for default ini file """ logger.debug("Formatting help: (helptext: '%s', is_section: '%s')", helptext, is_section) - helptext = '# {}'.format(helptext.replace("\n", "\n# ")) + formatted = "" + for hlp in helptext.split("\n"): + subsequent_indent = "\t\t" if hlp.startswith("\t") else "" + hlp = f"\t- {hlp[1:].strip()}" if hlp.startswith("\t") else hlp + formatted += textwrap.fill(hlp, + 100, + tabsize=4, + subsequent_indent=subsequent_indent) + "\n" + helptext = '# {}'.format(formatted[:-1].replace("\n", "\n# ")) # Strip last newline if is_section: helptext = helptext.upper() else: @@ -308,9 +369,8 @@ def load_config(self): def save_config(self): """ Save a config file """ logger.info("Updating config at: '%s'", self.configfile) - f_cfgfile = open(self.configfile, "w") - self.config.write(f_cfgfile) - f_cfgfile.close() + with open(self.configfile, "w") as f_cfgfile: + self.config.write(f_cfgfile) logger.debug("Updated config at: '%s'", self.configfile) def validate_config(self): @@ -353,15 +413,26 @@ def check_config_choices(self): for item, opt in items.items(): if item == "helptext" or not opt["choices"]: continue - opt_value = self.config.get(section, item) - if opt_value.lower() == "none" and any(choice.lower() == "none" - for choice in opt["choices"]): - continue - if opt_value not in opt["choices"]: - default = str(opt["default"]) - logger.warning("'%s' is not a valid config choice for '%s': '%s'. Defaulting " - "to: '%s'", opt_value, section, item, default) - self.config.set(section, item, default) + if opt["type"] == list: # Multi-select items + opt_value = self._parse_list(section, item) + if not opt_value: # No option selected + continue + if not all(val in opt["choices"] for val in opt_value): + invalid = [val for val in opt_value if val not in opt["choices"]] + valid = ", ".join(val for val in opt_value if val in opt["choices"]) + logger.warning("The option(s) %s are not valid selections for '%s': '%s'. " + "setting to: '%s'", invalid, section, item, valid) + self.config.set(section, item, valid) + else: # Single-select items + opt_value = self.config.get(section, item) + if opt_value.lower() == "none" and any(choice.lower() == "none" + for choice in opt["choices"]): + continue + if opt_value not in opt["choices"]: + default = str(opt["default"]) + logger.warning("'%s' is not a valid config choice for '%s': '%s'. " + "Defaulting to: '%s'", opt_value, section, item, default) + self.config.set(section, item, default) logger.debug("Checked config choices") def check_config_change(self): @@ -382,8 +453,14 @@ def check_config_change(self): return False def handle_config(self): - """ Handle the config """ - logger.debug("Handling config") + """ Handle the config. + + Checks whether a config file exists for this section. If not then a default is created. + + Configuration choices are then loaded and validated + """ + logger.debug("Handling config: (section: %s, configfile: '%s')", + self.section, self.configfile) if not self.check_exists(): self.create_default() self.load_config() diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index 8a67ce8be2..fd0e889bd7 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -709,7 +709,7 @@ class MultiOption(ttk.Checkbutton): # pylint: disable=too-many-ancestors """ def __init__(self, parent, value, variable, **kwargs): self._tk_var = tk.BooleanVar() - self._tk_var.set(value == variable.get()) + self._tk_var.set(value in variable.get().split()) super().__init__(parent, variable=self._tk_var, **kwargs) self._value = value self._master_variable = variable diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index e1f0fe273b..00d7f5632a 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -415,6 +415,10 @@ def _get_config(self): continue initial_value = conf.config_dict[option] initial_value = "none" if initial_value is None else initial_value + if params["type"] == list and isinstance(initial_value, list): + # Split multi-select lists into space separated strings for tk variables + initial_value = " ".join(initial_value) + retval[key]["options"][option] = ControlPanelOption( title=option, dtype=params["type"], @@ -423,6 +427,7 @@ def _get_config(self): initial_value=initial_value, choices=params["choices"], is_radio=params["gui_radio"], + is_multi_option=params["type"] == list, rounding=params["rounding"], min_max=params["min_max"], helptext=params["helptext"]) @@ -611,6 +616,8 @@ def save(self, page_only=False): new_opt, ".".join([section, item])) helptext = config.format_help(options["helptext"], is_section=False) new_config.set(section, helptext) + if options["type"] == list: # Comma seperate multi select options + new_opt = ", ".join(new_opt if isinstance(new_opt, list) else new_opt.split()) new_config.set(section, item, str(new_opt)) config.config = new_config config.save_config() diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo index 2d941ad14cf3dcd8e3e756bc369f7435307f1a3e..3702a088686e242e42ae2f514b947c63460f776c 100644 GIT binary patch delta 2452 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 diff --git a/locales/es/LC_MESSAGES/lib.cli.args.po b/locales/es/LC_MESSAGES/lib.cli.args.po index 3f943175ed..16960902d2 100644 --- a/locales/es/LC_MESSAGES/lib.cli.args.po +++ b/locales/es/LC_MESSAGES/lib.cli.args.po @@ -5,8 +5,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-03-11 01:27-0000\n" -"PO-Revision-Date: 2021-03-11 01:32+0000\n" +"POT-Creation-Date: 2021-03-11 13:22-0000\n" +"PO-Revision-Date: 2021-03-11 13:24+0000\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es\n" @@ -680,6 +680,7 @@ msgstr "" "/alignments.json si no se proporciona." #: lib/cli/args.py:911 lib/cli/args.py:923 lib/cli/args.py:948 +#: lib/cli/args.py:958 msgid "model" msgstr "modelo" @@ -754,12 +755,26 @@ msgstr "" "muestra un resumen del modelo que crearía el complemento elegido y los " "ajustes de configuración." -#: lib/cli/args.py:961 lib/cli/args.py:973 lib/cli/args.py:984 -#: lib/cli/args.py:1070 +#: lib/cli/args.py:959 +msgid "" +"Freeze the weights of the model. Freezing weights means that some of the " +"parameters in the model will no longer continue to learn, but those that are " +"not frozen will continue to learn. For most models, this will freeze the " +"encoder, but some models may have configuration options for freezing other " +"layers." +msgstr "" +"Congele los pesos del modelo. Congelar pesos significa que algunos de los " +"parámetros del modelo ya no seguirán aprendiendo, pero los que no están " +"congelados seguirán aprendiendo. Para la mayoría de los modelos, esto " +"congelará el codificador, pero algunos modelos pueden tener opciones de " +"configuración para congelar otras capas." + +#: lib/cli/args.py:972 lib/cli/args.py:984 lib/cli/args.py:995 +#: lib/cli/args.py:1081 msgid "training" msgstr "entrenamiento" -#: lib/cli/args.py:962 +#: lib/cli/args.py:973 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -772,7 +787,7 @@ msgstr "" "momento es el doble del número que se establece aquí. Los lotes más grandes " "requieren más RAM de la GPU." -#: lib/cli/args.py:974 +#: lib/cli/args.py:985 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -787,22 +802,22 @@ msgstr "" "automáticamente en un número determinado de iteraciones, puede establecer " "ese valor aquí." -#: lib/cli/args.py:985 +#: lib/cli/args.py:996 msgid "" "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" "Utilice la estrategia de distribución en espejo de Tensorflow para entrenar " "en múltiples GPUs." -#: lib/cli/args.py:995 lib/cli/args.py:1005 +#: lib/cli/args.py:1006 lib/cli/args.py:1016 msgid "Saving" msgstr "Guardar" -#: lib/cli/args.py:996 +#: lib/cli/args.py:1007 msgid "Sets the number of iterations between each model save." msgstr "Establece el número de iteraciones entre cada guardado del modelo." -#: lib/cli/args.py:1006 +#: lib/cli/args.py:1017 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -810,11 +825,11 @@ msgstr "" "Establece el número de iteraciones antes de guardar una copia de seguridad " "del modelo en su estado actual. Establece 0 para que esté desactivado." -#: lib/cli/args.py:1013 lib/cli/args.py:1024 lib/cli/args.py:1035 +#: lib/cli/args.py:1024 lib/cli/args.py:1035 lib/cli/args.py:1046 msgid "timelapse" msgstr "intervalo" -#: lib/cli/args.py:1014 +#: lib/cli/args.py:1025 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -828,7 +843,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-B." -#: lib/cli/args.py:1025 +#: lib/cli/args.py:1036 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -842,7 +857,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-A." -#: lib/cli/args.py:1036 +#: lib/cli/args.py:1047 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -854,24 +869,24 @@ msgstr "" "Si se suministran las carpetas de entrada pero no la carpeta de salida, se " "guardará por defecto en la carpeta del modelo /timelapse/" -#: lib/cli/args.py:1048 lib/cli/args.py:1055 lib/cli/args.py:1062 +#: lib/cli/args.py:1059 lib/cli/args.py:1066 lib/cli/args.py:1073 msgid "preview" msgstr "previsualización" -#: lib/cli/args.py:1049 +#: lib/cli/args.py:1060 msgid "" "Percentage amount to scale the preview by. 100%% is the model output size." msgstr "" "Cantidad porcentual para escalar la vista previa. 100%% es el tamaño de " "salida del modelo." -#: lib/cli/args.py:1056 +#: lib/cli/args.py:1067 msgid "Show training preview output. in a separate window." msgstr "" "Mostrar la salida de la vista previa del entrenamiento. en una ventana " "separada." -#: lib/cli/args.py:1063 +#: lib/cli/args.py:1074 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -879,7 +894,7 @@ msgstr "" "Escribe el resultado del entrenamiento en un archivo. La imagen se " "almacenará en la raíz de su carpeta FaceSwap." -#: lib/cli/args.py:1071 +#: lib/cli/args.py:1082 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -887,12 +902,12 @@ msgstr "" "Desactiva el registro de TensorBoard. NB: Desactivar los registros significa " "que no podrá utilizar el gráfico o el análisis de esta sesión en la GUI." -#: lib/cli/args.py:1078 lib/cli/args.py:1087 lib/cli/args.py:1096 -#: lib/cli/args.py:1105 +#: lib/cli/args.py:1089 lib/cli/args.py:1098 lib/cli/args.py:1107 +#: lib/cli/args.py:1116 msgid "augmentation" msgstr "aumento" -#: lib/cli/args.py:1079 +#: lib/cli/args.py:1090 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -902,7 +917,7 @@ msgstr "" "conjunto de caras opuestas en lugar de deformar la cara al azar. Esta es la " "forma 'dfaker' de hacer la deformación." -#: lib/cli/args.py:1088 +#: lib/cli/args.py:1099 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -913,7 +928,7 @@ msgstr "" "general, esto debería dejarse sin efecto, excepto durante el 'entrenamiento " "de ajuste'." -#: lib/cli/args.py:1097 +#: lib/cli/args.py:1108 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -923,7 +938,7 @@ msgstr "" "diferencias de color entre los conjuntos A y B, con un mayor coste de tiempo " "de entrenamiento. Activa esta opción para desactivar el aumento de color." -#: lib/cli/args.py:1106 +#: lib/cli/args.py:1117 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -936,6 +951,6 @@ msgstr "" "esta opción desde el principio, es probable que arruine el modelo y se " "obtengan resultados terribles." -#: lib/cli/args.py:1131 +#: lib/cli/args.py:1142 msgid "Output to Shell console instead of GUI console" msgstr "Salida a la consola Shell en lugar de la consola GUI" diff --git a/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index 16435edc75..56f69c51e9 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-03-11 01:27-0000\n" +"POT-Creation-Date: 2021-03-11 13:22-0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -291,6 +291,7 @@ msgid "DEPRECATED - This option will be removed in a future update. Path to alig msgstr "" #: lib/cli/args.py:911 lib/cli/args.py:923 lib/cli/args.py:948 +#: lib/cli/args.py:958 msgid "model" msgstr "" @@ -317,93 +318,97 @@ msgstr "" msgid "Output a summary of the model and exit. If a model folder is provided then a summary of the saved model is displayed. Otherwise a summary of the model that would be created by the chosen plugin and configuration settings is displayed." msgstr "" -#: lib/cli/args.py:961 lib/cli/args.py:973 lib/cli/args.py:984 -#: lib/cli/args.py:1070 +#: lib/cli/args.py:959 +msgid "Freeze the weights of the model. Freezing weights means that some of the parameters in the model will no longer continue to learn, but those that are not frozen will continue to learn. For most models, this will freeze the encoder, but some models may have configuration options for freezing other layers." +msgstr "" + +#: lib/cli/args.py:972 lib/cli/args.py:984 lib/cli/args.py:995 +#: lib/cli/args.py:1081 msgid "training" msgstr "" -#: lib/cli/args.py:962 +#: lib/cli/args.py:973 msgid "Batch size. This is the number of images processed through the model for each side per iteration. NB: As the model is fed 2 sides at a time, the actual number of images within the model at any one time is double the number that you set here. Larger batches require more GPU RAM." msgstr "" -#: lib/cli/args.py:974 +#: lib/cli/args.py:985 msgid "Length of training in iterations. This is only really used for automation. There is no 'correct' number of iterations a model should be trained for. You should stop training when you are happy with the previews. However, if you want the model to stop automatically at a set number of iterations, you can set that value here." msgstr "" -#: lib/cli/args.py:985 +#: lib/cli/args.py:996 msgid "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" -#: lib/cli/args.py:995 lib/cli/args.py:1005 +#: lib/cli/args.py:1006 lib/cli/args.py:1016 msgid "Saving" msgstr "" -#: lib/cli/args.py:996 +#: lib/cli/args.py:1007 msgid "Sets the number of iterations between each model save." msgstr "" -#: lib/cli/args.py:1006 +#: lib/cli/args.py:1017 msgid "Sets the number of iterations before saving a backup snapshot of the model in it's current state. Set to 0 for off." msgstr "" -#: lib/cli/args.py:1013 lib/cli/args.py:1024 lib/cli/args.py:1035 +#: lib/cli/args.py:1024 lib/cli/args.py:1035 lib/cli/args.py:1046 msgid "timelapse" msgstr "" -#: lib/cli/args.py:1014 +#: lib/cli/args.py:1025 msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. This should be the input folder of 'A' faces that you would like to use for creating the timelapse. You must also supply a --timelapse-output and a --timelapse-input-B parameter." msgstr "" -#: lib/cli/args.py:1025 +#: lib/cli/args.py:1036 msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. This should be the input folder of 'B' faces that you would like to use for creating the timelapse. You must also supply a --timelapse-output and a --timelapse-input-A parameter." msgstr "" -#: lib/cli/args.py:1036 +#: lib/cli/args.py:1047 msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. If the input folders are supplied but no output folder, it will default to your model folder /timelapse/" msgstr "" -#: lib/cli/args.py:1048 lib/cli/args.py:1055 lib/cli/args.py:1062 +#: lib/cli/args.py:1059 lib/cli/args.py:1066 lib/cli/args.py:1073 msgid "preview" msgstr "" -#: lib/cli/args.py:1049 +#: lib/cli/args.py:1060 msgid "Percentage amount to scale the preview by. 100%% is the model output size." msgstr "" -#: lib/cli/args.py:1056 +#: lib/cli/args.py:1067 msgid "Show training preview output. in a separate window." msgstr "" -#: lib/cli/args.py:1063 +#: lib/cli/args.py:1074 msgid "Writes the training result to a file. The image will be stored in the root of your FaceSwap folder." msgstr "" -#: lib/cli/args.py:1071 +#: lib/cli/args.py:1082 msgid "Disables TensorBoard logging. NB: Disabling logs means that you will not be able to use the graph or analysis for this session in the GUI." msgstr "" -#: lib/cli/args.py:1078 lib/cli/args.py:1087 lib/cli/args.py:1096 -#: lib/cli/args.py:1105 +#: lib/cli/args.py:1089 lib/cli/args.py:1098 lib/cli/args.py:1107 +#: lib/cli/args.py:1116 msgid "augmentation" msgstr "" -#: lib/cli/args.py:1079 +#: lib/cli/args.py:1090 msgid "Warps training faces to closely matched Landmarks from the opposite face-set rather than randomly warping the face. This is the 'dfaker' way of doing warping." msgstr "" -#: lib/cli/args.py:1088 +#: lib/cli/args.py:1099 msgid "To effectively learn, a random set of images are flipped horizontally. Sometimes it is desirable for this not to occur. Generally this should be left off except for during 'fit training'." msgstr "" -#: lib/cli/args.py:1097 +#: lib/cli/args.py:1108 msgid "Color augmentation helps make the model less susceptible to color differences between the A and B sets, at an increased training time cost. Enable this option to disable color augmentation." msgstr "" -#: lib/cli/args.py:1106 +#: lib/cli/args.py:1117 msgid "Warping is integral to training the Neural Network. This option should only be enabled towards the very end of training to try to bring out more detail. Think of it as 'fine-tuning'. Enabling this option from the beginning is likely to kill a model and lead to terrible results." msgstr "" -#: lib/cli/args.py:1131 +#: lib/cli/args.py:1142 msgid "Output to Shell console instead of GUI console" msgstr "" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo index 0b674ac82220779b3cf275c08af0522849d05546..3289cf6be3dfa8e7bac97826bab65a260542d498 100644 GIT binary patch delta 2578 zcmZ9N32anV6o!AGMWg~!XhF6<3QB=eEISB70of76g&i5nw2ZVfI5PzSb?Cxo!3sq% zRb&wqDHx!gQd+1enjmO+qfuibDk3q##6XnblBnOES2l0vyzks|?>*<-bMJdoyQ$HE z*BXVsi;sC#v}s5Oc(LA%me%4;B9g^4h`iF5+{i+4*q;LxTHzlAaMpO3=^ z^WjUd4jzW%?~w+>zu|oR^lv78B!#rKxs=7g>=qI~eaK@O{2ZQ#6YiDxDW#>9#=^lc z9WIKOSgNvC(m(KBxCZ;a)>4>$??h=kyadZ&VH;^Qdc=7@1nmKmW3$uL6k2sjO!r{qx_wEO6Ta$=|y6Ur{d)*0do3MvPROp{v;A3b)lXK z%wIc9;&ilqI6B+|12A=jGyv{|EY;U=6pYJo5}E=-k7C%0Vluu2^I-~|ss_@2xEs1g zL+mkZqZYnX&&;vXB>G9?oV$?&-=tqS-nm1`lqZw^EAR#QJLLG~pD3B@6`Dx>jj|ep z*+DThrP&O*XDWl-Grd*cKL`UPR0YEgr5=-|KJbSr(n{>DxWz+Y$>R>sJmCb4eM(x2 zpLlo;u7@ei3r*+z7h>2q1Hn+s-3s(A?5;*c4X7-tZKRhrdDdL2CS|v%@wJ|C)&OK>8u(5_dzA z5%X2eK#b1^ksEE2QHZZckjD^naZEE8&U`pbGYRyGYJ3-P(3-#Xybb)Pra`8C{qzC7X4Zcs~<>*;}I&aaYz%SB{Cf8g)~P@ zo8UlJJyYvB4YoqYmQHTmCiF1c5M(4`+5<>ZRC7L2Mm06=k90*wBXNjnZ5%}ZBnF@l zM0oF`&75DCdc8XwgbYSHBj)USBFf0CxY#%^E;PpPcCT<-!9{LsncI`SC>XGOSNY85d9w5TuAs-~wR}qq0#+98 zqkhcZebmQq<+zr!y|jwy@lyuHruQAvr(Z?+gxtib+w4P;BD>nIiR9bWkyVjG`&gvF z4qNsSJB;a&WgoZe>`FUq*V@%-md$))iL8QEtUCfvz#A({%b!dty;#&bgkLk~IBp8? zTa3p$d|CEk9M?uRMAn(bC(Pq8;VUDBkpc^kM^V<|rGP2fk`7$ zd~@~Do2k{=RUtAy5h-Ts`{ZGEKrk}KpM^>!oHNCw&bGFYNy+UgRkJ#u5bW>vw8Qo> zE3(mqw(Q&E9i@0UCM_aSN4rIxeI&9T?=?>1r5B14I^I@F$7#il%~Gb)3riCkMv6;& nu4q5F(n;r5ggc6DGOdiBRRytDTbxrZfy4HZw2DO6-t7MX7HFG# delta 1790 zcmZ9MdrZ}37{|YQkWwOp6PK{igPCy&LA=1pBOERwprB-785km$B3oXlWwT!~M`Hiv z5^I!JqRqB0gA?j+(nSd49jsKfOEO^Ld~5{yy*X{N8!R zcWR%{`93kGPn5OjR&->lv<2o&o8}A$MY}-`_VXQ z8Qco@!uQ}HEV==~pW#ESGcR5`BRT51QEH>3W~RhXL;O7nN8u$Hyh-Ax)C4J)iJ6co zTAwKW4clQ4?QMQ(82jqY(m^FF3w2#4R?0;r+80_jf(j@kdfK&=MW^hn=7_NX9p^Z=^uxqQNU2q8U?8?Ip7FMAs%c^;QyFnEV=9)E-_|kef z9X3bpPPmL4*(%+W4UK6?_VI)4!?_uV7zjlIAn-7f5(0r5SI~ zzZg!??&tc=*wH_nl5}!)$V{o{mYljESMgX@@(vN>^Z! zJuLl!E)GLAgA{4Rehv0w|3#yNy?dv0lJPP49Vcky8L0#&aM1&>9)1CTf|f=q{=Mb>dHYp!dv-{N`LrBt#>6dPtG9e z%DnyXB=*rE=@Hlz@mzimc4Oy$Anjqe2C1;Ng2P_NdH4f%-Erw<7G7{d+Q!CbKP1pt zI5@&{!B*aB8_fS$I?ebapKw#gb&QfJw7-3ZV5WWSQ`UjI#_$U5NoNUK?6l7;)^I~U z6^qUwoB=E00$2|dVK=l7P#?4#z6Dvw&FM^7fGl|LKmo*OLnX-8v~~VpNG1`l?$_4# z7at`s z<1tkr`(|6oLn{%U7g^?}V**@`a*zd$l`0QzHdr+~YOjTUwBn`4*c2y3tq6sYm3b)L zt&zY{nvWNuxu^`qAuIMsR%W8bXbG~ Date: Fri, 12 Mar 2021 09:48:00 +0000 Subject: [PATCH 404/981] Fixups - Documentation - Update sphinx_requirements.txt - plugins.train.model - Add recursive keras model scanning - Change "freeze" option to "freeze-weights" --- docs/sphinx_requirements.txt | 2 +- lib/cli/args.py | 4 ++-- plugins/train/model/_base.py | 42 +++++++++++++++++++++++++++--------- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index 9d38b6f16f..1a220a47f5 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -9,7 +9,7 @@ opencv-python==4.1.2.30 pillow==7.0.0 scikit-learn==0.22.0 fastcluster==1.1.26 -matplotlib==3.0.3 +matplotlib>3.0.3,<3.3.0 imageio==2.8.0 imageio-ffmpeg==0.4.2 ffmpy==0.2.3 diff --git a/lib/cli/args.py b/lib/cli/args.py index 4cac4ccb6f..ead45a6a1d 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -951,9 +951,9 @@ def get_argument_list(): "that would be created by the chosen plugin and configuration settings is " "displayed."))) argument_list.append(dict( - opts=("-f", "--freeze"), + opts=("-f", "--freeze-weights"), action="store_true", - dest="freeze", + dest="freeze_weights", default=False, group=_("model"), help=_("Freeze the weights of the model. Freezing weights means that some of the " diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 0a1e26f28b..b1fd2692d7 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -271,6 +271,31 @@ def build(self): self._compile_model() self._output_summary() + def _get_all_sub_models(self, model, models=None): + """ For a given model, return all sub-models that occur (recursively) as children. + + Parameters + ---------- + model: :class:`keras.models.Model` + A Keras model to scan for sub models + models: `None` + Do not provide this parameter. It is used for recursion + + Returns + ------- + list + A list of all :class:`keras.models.Model`s found within the given model. The provided + model will always be returned in the first position + """ + if models is None: + models = [model] + else: + models.append(model) + for layer in model.layers: + if isinstance(layer, KModel): + self._get_all_sub_models(layer, models=models) + return models + def _update_legacy_models(self): """ Load weights from legacy split models into new unified model, archiving old model files to a new folder. """ @@ -361,10 +386,8 @@ def _output_summary(self): print_fn = None # Print straight to stdout else: print_fn = lambda x: logger.verbose("%s", x) # print to logger - self._model.summary(print_fn=print_fn) - for layer in self._model.layers: - if isinstance(layer, KModel): - layer.summary(print_fn=print_fn) + for model in self._get_all_sub_models(self._model): + model.summary(print_fn=print_fn) def save(self): """ Save the model to disk. @@ -401,15 +424,14 @@ def _compile_model(self): def _freeze_weights(self): """ If freeze has been selected in the cli arguments, then freeze those models indicated in the plugin's configuration. """ - if not self._args.freeze: + if not self._args.freeze_weights: logger.debug("Freeze weights deselected. Not freezing") return - # Standardized config for freezing weights of other layers in model is `freeze-layers` - to_freeze = self.config.get("freeze_layers") - to_freeze = to_freeze if to_freeze else ["encoder"] - for layer in self._model.layers: - if isinstance(layer, KModel) and layer.name in to_freeze: + to_freeze = self.config.get("freeze_layers") # Standardized config for freezing weights + to_freeze = to_freeze if to_freeze else ["encoder"] # No plugin config + for layer in self._get_all_sub_models(self._model): + if layer.name in to_freeze: logger.info("Freezing weights for '%s' in model '%s'", layer.name, self.name) layer.trainable = False to_freeze.remove(layer.name) From caa6e541c691b9de2be3cbc3acbfa3917359356f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 12 Mar 2021 14:48:59 +0000 Subject: [PATCH 405/981] Training - Load Weights - Enable the loading of previously trained encoders for new models. --- lib/cli/args.py | 16 ++ lib/gui/utils.py | 105 +++++----- locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 40457 -> 41749 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 84 +++++--- locales/lib.cli.args.pot | 64 +++--- locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 52268 -> 54072 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 84 +++++--- plugins/train/model/_base.py | 257 ++++++++++++++++++++----- 8 files changed, 421 insertions(+), 189 deletions(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index ead45a6a1d..93a14839fd 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -914,6 +914,22 @@ def get_argument_list(): "either an empty folder, or a folder which does not exist (which will be " "created). If continuing to train an existing model, specify the location of " "the existing model."))) + argument_list.append(dict( + opts=("-l", "--load-weights"), + action=FileFullPaths, + filetypes="model", + dest="load_weights", + required=False, + group=_("model"), + help=_("R|Load the weights from a pre-existing model into a newly created model. " + "For most models this will load weights from the Encoder of the given model " + "into the encoder of the newly created model. Some plugins may have specific " + "configuration options allowing you to load weights from other layers. Weights " + "will only be loaded when creating a new model. This option will be ignored if " + "you are resuming an existing model. Generally you will also want to 'freeze-" + "weights' whilst the rest of your model catches up with your Encoder.\n" + "NB: Weights can only be loaded from models of the same plugin as you intend " + "to train."))) argument_list.append(dict( opts=("-t", "--trainer"), action=Radio, diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 3eee648db9..c62f0a384c 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -147,30 +147,31 @@ def __init__(self, handle_type, file_type, title=None, initial_folder=None, comm def _filetypes(self): """ dict: The accepted extensions for each file type for opening/saving """ all_files = ("All files", "*.*") - filetypes = {"default": (all_files,), - "alignments": [("Faceswap Alignments", "*.fsa"), - all_files], - "config_project": [("Faceswap Project files", "*.fsw"), all_files], - "config_task": [("Faceswap Task files", "*.fst"), all_files], - "config_all": [("Faceswap Project and Task files", "*.fst *.fsw"), all_files], - "csv": [("Comma separated values", "*.csv"), all_files], - "image": [("Bitmap", "*.bmp"), - ("JPG", "*.jpeg *.jpg"), - ("PNG", "*.png"), - ("TIFF", "*.tif *.tiff"), - all_files], - "ini": [("Faceswap config files", "*.ini"), all_files], - "state": [("State files", "*.json"), all_files], - "log": [("Log files", "*.log"), all_files], - "video": [("Audio Video Interleave", "*.avi"), - ("Flash Video", "*.flv"), - ("Matroska", "*.mkv"), - ("MOV", "*.mov"), - ("MP4", "*.mp4"), - ("MPEG", "*.mpeg *.mpg *.ts *.vob"), - ("WebM", "*.webm"), - ("Windows Media Video", "*.wmv"), - all_files]} + filetypes = dict( + default=(all_files,), + alignments=[("Faceswap Alignments", "*.fsa"), all_files], + config_project=[("Faceswap Project files", "*.fsw"), all_files], + config_task=[("Faceswap Task files", "*.fst"), all_files], + config_all=[("Faceswap Project and Task files", "*.fst *.fsw"), all_files], + csv=[("Comma separated values", "*.csv"), all_files], + image=[("Bitmap", "*.bmp"), + ("JPG", "*.jpeg *.jpg"), + ("PNG", "*.png"), + ("TIFF", "*.tif *.tiff"), + all_files], + ini=[("Faceswap config files", "*.ini"), all_files], + model=[("Keras model files", "*.h5"), all_files], + state=[("State files", "*.json"), all_files], + log=[("Log files", "*.log"), all_files], + video=[("Audio Video Interleave", "*.avi"), + ("Flash Video", "*.flv"), + ("Matroska", "*.mkv"), + ("MOV", "*.mov"), + ("MP4", "*.mp4"), + ("MPEG", "*.mpeg *.mpg *.ts *.vob"), + ("WebM", "*.webm"), + ("Windows Media Video", "*.wmv"), + all_files]) # Add in multi-select options and upper case extensions for Linux for key in filetypes: @@ -190,28 +191,22 @@ def _filetypes(self): def _contexts(self): """dict: Mapping of commands, actions and their corresponding file dialog for context handle types. """ - return { - "effmpeg": { - "input": { - "extract": "filename", - "gen-vid": "dir", - "get-fps": "filename", - "get-info": "filename", - "mux-audio": "filename", - "rescale": "filename", - "rotate": "filename", - "slice": "filename"}, - "output": { - "extract": "dir", - "gen-vid": "savefilename", - "get-fps": "nothing", - "get-info": "nothing", - "mux-audio": "savefilename", - "rescale": "savefilename", - "rotate": "savefilename", - "slice": "savefilename"} - } - } + return dict(effmpeg=dict(input={"extract": "filename", + "gen-vid": "dir", + "get-fps": "filename", + "get-info": "filename", + "mux-audio": "filename", + "rescale": "filename", + "rotate": "filename", + "slice": "filename"}, + output={"extract": "dir", + "gen-vid": "savefilename", + "get-fps": "nothing", + "get-info": "nothing", + "mux-audio": "savefilename", + "rescale": "savefilename", + "rotate": "savefilename", + "slice": "savefilename"})) def _set_defaults(self): """ Set the default file type for the file dialog. Generally the first found file type @@ -1053,15 +1048,15 @@ def _set_tk_vars(): analysis_folder = tk.StringVar() analysis_folder.set(None) - tk_vars = {"display": display, - "runningtask": runningtask, - "istraining": istraining, - "action": actioncommand, - "generate": generatecommand, - "consoleclear": consoleclear, - "refreshgraph": refreshgraph, - "updatepreview": updatepreview, - "analysis_folder": analysis_folder} + tk_vars = dict(display=display, + runningtask=runningtask, + istraining=istraining, + action=actioncommand, + generate=generatecommand, + consoleclear=consoleclear, + refreshgraph=refreshgraph, + updatepreview=updatepreview, + analysis_folder=analysis_folder) logger.debug(tk_vars) return tk_vars diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo index 3702a088686e242e42ae2f514b947c63460f776c..d3da58c1875b6867c519cd0c15c9f7748ebed471 100644 GIT binary patch delta 3004 zcmZvc35*m~8i0QX1e}GDOB4`&FzP77v>b{Y$_;`Jhz_8Ff<^c1?sBWEda9}!XBM}2 z)qpN!6P?9XRCI=D;uSRG6^}$66pS&%O*Gj=O+2Dm6XISbyNPc0`>Wbvh`#h+-}_(v z_1=HIzUTG2eec#)KN~xApD@~xvym6-MA~8NsXQ2?hl^BB5g7v~z}+K6-eCQ}X(Erp zo9a{k0EePK{40^mVGsNR{0u%1-H{^m;p9;wx3JGfcvz$=FExnddGVXmMfk}VJUZY% z;ivG{(KrEDj}gi6;f-(>ynCz&pUSJ_ME(cggS%KiI$q>O^lK)FJOWQWQ)E9p2v_ia z(OKA`-#Jla3+GGyB$36u$is(t@x)}2|H5UYV>#=WPf7W^b40SNA3j%P9_tqxk*m<1 zsUjDlKLfu-|9BeT!nYc67!Ey8WEGqP)xHaoOnC?Huaf2un0&-Sak|JAq<>Mf$T9St zEh5{|TiInXc86w){DXtOyA&seh;{0`T?$QOF3yny8l;@WXQMhLD;g9`oGHL$i_4ScWe^b%SIo< zzp}pd29X_b-i>J!9)cR#n{W(v+cx7QdixfUJJ5GSb)^1g-m|`WYuXzg+{gO;+tSj` zaME*ss4{sQ!)8VJMQ1xQHxjL-&DU% zYLifH9fvpU&H>v_?9zX;`%osja$F{q9;{+a&(ScM1SQTPdwc@J>^;hO!)ABwDgICXH; zBO?F7?%(hhc$~UvY1TbT`OptM#*NeazpY0kWZ@{>0~hwDo*sePl*b?e%11SR0=2eZ zLT%FTp*H7_a1>O(^#`A$Mz&^Dv-!n? z+k%ocWv^0j{m7J@PE&MtSQC|P*3G+FlMVd5Td0Ii>;}FG%8JN1o)>gzV4YioiXp0> zI~m|WXgsIWhEc{`UrSCBQQ#B*cAI=e20Ds(o}@!}O!D(nRyK5ZHB71+H*Y#Q#|f2Um5oovd7)^^#JS~8nB+4V@driy)IL!LNC z3!&x8I&rpWBeSVerV?>6eOF6-rheVB1p{Ga9ddJWR7o2o8%4UWo)I|%sWwj3y=2w+ zQ!;(cnfIp*pKWGcvS7~Kz8|l?E3;y`6Ba6(oye4J#4~42rB>TO`znds`mq*x(3_Bw z^*@=WvU|an<`VljVSmrSE`${1w5m>yZqpDG{{oX^LHE+2f7xR2-nk%SmU@MXk89bW zRQ7BMXS(m*(pC2ZmJftR6Gal_H+Qa+{omi5Gjs2qIp^Hj zmTzKrbjLb36MZeB%}1lqsaR<(3_Qk#cJFcN?HH*KrodJ4(pUK3?=KyLfdQ61(1-nQ z0zTLPcfzmX8Cd; zk8PZTpLwjSl4fIP%&@gl507FuR@)lNB0P22d*OTVFG%|J)-1`Cmotm_o6Z&-W(Q5s zgr*r%XNp4VOzob18k&@J!FKpBZ?A<%*~Lxf-K~`_!z*(w56rVKT26JF4X%QRUMyy0aEop~irMB2`Tzu|ryn_jV@OD1kh@i)Q_{J*S_GO?Rp zrE1}+m6q|VZD?1lp)TuSpBAcffn*{(76L@A zNPm(GKkPL&Vb~nY4%SP*V;|(MZy9$sN=5jq8u)xE#*K|qm|WhX0vF-;@3yJj^P%(w z_qD=(uxhU@!W(cdk5R@)_V9TVk_BCaWcubDRvWg(6cg^;QIN$CGZ?V9~d)6hJWhD;ld^4*>^02XWP5OdE`bx;d7ViIsIq9B3wVp2pW_$fyI2o0q(Wn4TKnisx#s$W8o|*JrT3lgfVeyor QlFm&voAWz&JKM7#0ZvEl(f|Me diff --git a/locales/es/LC_MESSAGES/lib.cli.args.po b/locales/es/LC_MESSAGES/lib.cli.args.po index 16960902d2..cfa2720a50 100644 --- a/locales/es/LC_MESSAGES/lib.cli.args.po +++ b/locales/es/LC_MESSAGES/lib.cli.args.po @@ -5,8 +5,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-03-11 13:22-0000\n" -"PO-Revision-Date: 2021-03-11 13:24+0000\n" +"POT-Creation-Date: 2021-03-12 14:35-0000\n" +"PO-Revision-Date: 2021-03-12 14:36+0000\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es\n" @@ -679,8 +679,8 @@ msgstr "" "archivo de alineaciones para el conjunto de entrenamiento B. Por defecto es " "/alignments.json si no se proporciona." -#: lib/cli/args.py:911 lib/cli/args.py:923 lib/cli/args.py:948 -#: lib/cli/args.py:958 +#: lib/cli/args.py:911 lib/cli/args.py:923 lib/cli/args.py:939 +#: lib/cli/args.py:964 lib/cli/args.py:974 msgid "model" msgstr "modelo" @@ -700,6 +700,30 @@ msgstr "" #: lib/cli/args.py:924 msgid "" +"R|Load the weights from a pre-existing model into a newly created model. For " +"most models this will load weights from the Encoder of the given model into " +"the encoder of the newly created model. Some plugins may have specific " +"configuration options allowing you to load weights from other layers. " +"Weights will only be loaded when creating a new model. This option will be " +"ignored if you are resuming an existing model. Generally you will also want " +"to 'freeze-weights' whilst the rest of your model catches up with your " +"Encoder.\n" +"NB: Weights can only be loaded from models of the same plugin as you intend " +"to train." +msgstr "" +"R|Cargue los pesos de un modelo preexistente en un modelo recién creado. " +"Para la mayoría de los modelos, esto cargará pesos del codificador del " +"modelo dado en el codificador del modelo recién creado. Algunos complementos " +"pueden tener opciones de configuración específicas que le permiten cargar " +"pesos de otras capas. Los pesos solo se cargarán al crear un nuevo modelo. " +"Esta opción se ignorará si está reanudando un modelo existente. En general, " +"también querrá 'congelar pesos' mientras el resto de su modelo se pone al " +"día con su codificador.\n" +"NB: Los pesos solo se pueden cargar desde modelos del mismo complemento que " +"desea entrenar." + +#: lib/cli/args.py:940 +msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" "L|original: The original model created by /u/deepfakes.\n" @@ -743,7 +767,7 @@ msgstr "" "recursos (se necesita una GPU con una buena cantidad de VRAM). Bueno para " "los detalles, pero más susceptible a las diferencias de color." -#: lib/cli/args.py:949 +#: lib/cli/args.py:965 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -755,7 +779,7 @@ msgstr "" "muestra un resumen del modelo que crearía el complemento elegido y los " "ajustes de configuración." -#: lib/cli/args.py:959 +#: lib/cli/args.py:975 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -769,12 +793,12 @@ msgstr "" "congelará el codificador, pero algunos modelos pueden tener opciones de " "configuración para congelar otras capas." -#: lib/cli/args.py:972 lib/cli/args.py:984 lib/cli/args.py:995 -#: lib/cli/args.py:1081 +#: lib/cli/args.py:988 lib/cli/args.py:1000 lib/cli/args.py:1011 +#: lib/cli/args.py:1097 msgid "training" msgstr "entrenamiento" -#: lib/cli/args.py:973 +#: lib/cli/args.py:989 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -787,7 +811,7 @@ msgstr "" "momento es el doble del número que se establece aquí. Los lotes más grandes " "requieren más RAM de la GPU." -#: lib/cli/args.py:985 +#: lib/cli/args.py:1001 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -802,22 +826,22 @@ msgstr "" "automáticamente en un número determinado de iteraciones, puede establecer " "ese valor aquí." -#: lib/cli/args.py:996 +#: lib/cli/args.py:1012 msgid "" "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" "Utilice la estrategia de distribución en espejo de Tensorflow para entrenar " "en múltiples GPUs." -#: lib/cli/args.py:1006 lib/cli/args.py:1016 +#: lib/cli/args.py:1022 lib/cli/args.py:1032 msgid "Saving" msgstr "Guardar" -#: lib/cli/args.py:1007 +#: lib/cli/args.py:1023 msgid "Sets the number of iterations between each model save." msgstr "Establece el número de iteraciones entre cada guardado del modelo." -#: lib/cli/args.py:1017 +#: lib/cli/args.py:1033 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -825,11 +849,11 @@ msgstr "" "Establece el número de iteraciones antes de guardar una copia de seguridad " "del modelo en su estado actual. Establece 0 para que esté desactivado." -#: lib/cli/args.py:1024 lib/cli/args.py:1035 lib/cli/args.py:1046 +#: lib/cli/args.py:1040 lib/cli/args.py:1051 lib/cli/args.py:1062 msgid "timelapse" msgstr "intervalo" -#: lib/cli/args.py:1025 +#: lib/cli/args.py:1041 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -843,7 +867,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-B." -#: lib/cli/args.py:1036 +#: lib/cli/args.py:1052 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -857,7 +881,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-A." -#: lib/cli/args.py:1047 +#: lib/cli/args.py:1063 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -869,24 +893,24 @@ msgstr "" "Si se suministran las carpetas de entrada pero no la carpeta de salida, se " "guardará por defecto en la carpeta del modelo /timelapse/" -#: lib/cli/args.py:1059 lib/cli/args.py:1066 lib/cli/args.py:1073 +#: lib/cli/args.py:1075 lib/cli/args.py:1082 lib/cli/args.py:1089 msgid "preview" msgstr "previsualización" -#: lib/cli/args.py:1060 +#: lib/cli/args.py:1076 msgid "" "Percentage amount to scale the preview by. 100%% is the model output size." msgstr "" "Cantidad porcentual para escalar la vista previa. 100%% es el tamaño de " "salida del modelo." -#: lib/cli/args.py:1067 +#: lib/cli/args.py:1083 msgid "Show training preview output. in a separate window." msgstr "" "Mostrar la salida de la vista previa del entrenamiento. en una ventana " "separada." -#: lib/cli/args.py:1074 +#: lib/cli/args.py:1090 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -894,7 +918,7 @@ msgstr "" "Escribe el resultado del entrenamiento en un archivo. La imagen se " "almacenará en la raíz de su carpeta FaceSwap." -#: lib/cli/args.py:1082 +#: lib/cli/args.py:1098 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -902,12 +926,12 @@ msgstr "" "Desactiva el registro de TensorBoard. NB: Desactivar los registros significa " "que no podrá utilizar el gráfico o el análisis de esta sesión en la GUI." -#: lib/cli/args.py:1089 lib/cli/args.py:1098 lib/cli/args.py:1107 -#: lib/cli/args.py:1116 +#: lib/cli/args.py:1105 lib/cli/args.py:1114 lib/cli/args.py:1123 +#: lib/cli/args.py:1132 msgid "augmentation" msgstr "aumento" -#: lib/cli/args.py:1090 +#: lib/cli/args.py:1106 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -917,7 +941,7 @@ msgstr "" "conjunto de caras opuestas en lugar de deformar la cara al azar. Esta es la " "forma 'dfaker' de hacer la deformación." -#: lib/cli/args.py:1099 +#: lib/cli/args.py:1115 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -928,7 +952,7 @@ msgstr "" "general, esto debería dejarse sin efecto, excepto durante el 'entrenamiento " "de ajuste'." -#: lib/cli/args.py:1108 +#: lib/cli/args.py:1124 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -938,7 +962,7 @@ msgstr "" "diferencias de color entre los conjuntos A y B, con un mayor coste de tiempo " "de entrenamiento. Activa esta opción para desactivar el aumento de color." -#: lib/cli/args.py:1117 +#: lib/cli/args.py:1133 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -951,6 +975,6 @@ msgstr "" "esta opción desde el principio, es probable que arruine el modelo y se " "obtengan resultados terribles." -#: lib/cli/args.py:1142 +#: lib/cli/args.py:1158 msgid "Output to Shell console instead of GUI console" msgstr "Salida a la consola Shell en lugar de la consola GUI" diff --git a/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index 56f69c51e9..d0191d8146 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-03-11 13:22-0000\n" +"POT-Creation-Date: 2021-03-12 14:35-0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -290,8 +290,8 @@ msgstr "" msgid "DEPRECATED - This option will be removed in a future update. Path to alignments file for training set B. Defaults to /alignments.json if not provided." msgstr "" -#: lib/cli/args.py:911 lib/cli/args.py:923 lib/cli/args.py:948 -#: lib/cli/args.py:958 +#: lib/cli/args.py:911 lib/cli/args.py:923 lib/cli/args.py:939 +#: lib/cli/args.py:964 lib/cli/args.py:974 msgid "model" msgstr "" @@ -301,6 +301,12 @@ msgstr "" #: lib/cli/args.py:924 msgid "" +"R|Load the weights from a pre-existing model into a newly created model. For most models this will load weights from the Encoder of the given model into the encoder of the newly created model. Some plugins may have specific configuration options allowing you to load weights from other layers. Weights will only be loaded when creating a new model. This option will be ignored if you are resuming an existing model. Generally you will also want to 'freeze-weights' whilst the rest of your model catches up with your Encoder.\n" +"NB: Weights can only be loaded from models of the same plugin as you intend to train." +msgstr "" + +#: lib/cli/args.py:940 +msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings menu or the config folder.\n" "L|original: The original model created by /u/deepfakes.\n" "L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' for full dfaker method.\n" @@ -314,101 +320,101 @@ msgid "" "L|villain: 128px in/out model from villainguy. Very resource hungry (You will require a GPU with a fair amount of VRAM). Good for details, but more susceptible to color differences." msgstr "" -#: lib/cli/args.py:949 +#: lib/cli/args.py:965 msgid "Output a summary of the model and exit. If a model folder is provided then a summary of the saved model is displayed. Otherwise a summary of the model that would be created by the chosen plugin and configuration settings is displayed." msgstr "" -#: lib/cli/args.py:959 +#: lib/cli/args.py:975 msgid "Freeze the weights of the model. Freezing weights means that some of the parameters in the model will no longer continue to learn, but those that are not frozen will continue to learn. For most models, this will freeze the encoder, but some models may have configuration options for freezing other layers." msgstr "" -#: lib/cli/args.py:972 lib/cli/args.py:984 lib/cli/args.py:995 -#: lib/cli/args.py:1081 +#: lib/cli/args.py:988 lib/cli/args.py:1000 lib/cli/args.py:1011 +#: lib/cli/args.py:1097 msgid "training" msgstr "" -#: lib/cli/args.py:973 +#: lib/cli/args.py:989 msgid "Batch size. This is the number of images processed through the model for each side per iteration. NB: As the model is fed 2 sides at a time, the actual number of images within the model at any one time is double the number that you set here. Larger batches require more GPU RAM." msgstr "" -#: lib/cli/args.py:985 +#: lib/cli/args.py:1001 msgid "Length of training in iterations. This is only really used for automation. There is no 'correct' number of iterations a model should be trained for. You should stop training when you are happy with the previews. However, if you want the model to stop automatically at a set number of iterations, you can set that value here." msgstr "" -#: lib/cli/args.py:996 +#: lib/cli/args.py:1012 msgid "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" -#: lib/cli/args.py:1006 lib/cli/args.py:1016 +#: lib/cli/args.py:1022 lib/cli/args.py:1032 msgid "Saving" msgstr "" -#: lib/cli/args.py:1007 +#: lib/cli/args.py:1023 msgid "Sets the number of iterations between each model save." msgstr "" -#: lib/cli/args.py:1017 +#: lib/cli/args.py:1033 msgid "Sets the number of iterations before saving a backup snapshot of the model in it's current state. Set to 0 for off." msgstr "" -#: lib/cli/args.py:1024 lib/cli/args.py:1035 lib/cli/args.py:1046 +#: lib/cli/args.py:1040 lib/cli/args.py:1051 lib/cli/args.py:1062 msgid "timelapse" msgstr "" -#: lib/cli/args.py:1025 +#: lib/cli/args.py:1041 msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. This should be the input folder of 'A' faces that you would like to use for creating the timelapse. You must also supply a --timelapse-output and a --timelapse-input-B parameter." msgstr "" -#: lib/cli/args.py:1036 +#: lib/cli/args.py:1052 msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. This should be the input folder of 'B' faces that you would like to use for creating the timelapse. You must also supply a --timelapse-output and a --timelapse-input-A parameter." msgstr "" -#: lib/cli/args.py:1047 +#: lib/cli/args.py:1063 msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. If the input folders are supplied but no output folder, it will default to your model folder /timelapse/" msgstr "" -#: lib/cli/args.py:1059 lib/cli/args.py:1066 lib/cli/args.py:1073 +#: lib/cli/args.py:1075 lib/cli/args.py:1082 lib/cli/args.py:1089 msgid "preview" msgstr "" -#: lib/cli/args.py:1060 +#: lib/cli/args.py:1076 msgid "Percentage amount to scale the preview by. 100%% is the model output size." msgstr "" -#: lib/cli/args.py:1067 +#: lib/cli/args.py:1083 msgid "Show training preview output. in a separate window." msgstr "" -#: lib/cli/args.py:1074 +#: lib/cli/args.py:1090 msgid "Writes the training result to a file. The image will be stored in the root of your FaceSwap folder." msgstr "" -#: lib/cli/args.py:1082 +#: lib/cli/args.py:1098 msgid "Disables TensorBoard logging. NB: Disabling logs means that you will not be able to use the graph or analysis for this session in the GUI." msgstr "" -#: lib/cli/args.py:1089 lib/cli/args.py:1098 lib/cli/args.py:1107 -#: lib/cli/args.py:1116 +#: lib/cli/args.py:1105 lib/cli/args.py:1114 lib/cli/args.py:1123 +#: lib/cli/args.py:1132 msgid "augmentation" msgstr "" -#: lib/cli/args.py:1090 +#: lib/cli/args.py:1106 msgid "Warps training faces to closely matched Landmarks from the opposite face-set rather than randomly warping the face. This is the 'dfaker' way of doing warping." msgstr "" -#: lib/cli/args.py:1099 +#: lib/cli/args.py:1115 msgid "To effectively learn, a random set of images are flipped horizontally. Sometimes it is desirable for this not to occur. Generally this should be left off except for during 'fit training'." msgstr "" -#: lib/cli/args.py:1108 +#: lib/cli/args.py:1124 msgid "Color augmentation helps make the model less susceptible to color differences between the A and B sets, at an increased training time cost. Enable this option to disable color augmentation." msgstr "" -#: lib/cli/args.py:1117 +#: lib/cli/args.py:1133 msgid "Warping is integral to training the Neural Network. This option should only be enabled towards the very end of training to try to bring out more detail. Think of it as 'fine-tuning'. Enabling this option from the beginning is likely to kill a model and lead to terrible results." msgstr "" -#: lib/cli/args.py:1142 +#: lib/cli/args.py:1158 msgid "Output to Shell console instead of GUI console" msgstr "" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo index 3289cf6be3dfa8e7bac97826bab65a260542d498..4e74b80f5ca5632ec02461ed68b5d638e22a0027 100644 GIT binary patch delta 3243 zcmZ{kdvH|M8NfeD2sR=nfe<3Z90(;m%*w;^enbKhBuI-^tF-E}xyf$3*^PTQ#GuZG zpe7UssawRcYAcS8Gek+uvk3{6VryF+?yb}Q(N;P-eM~!Jop#2tt&aBh-Mi2P+B5sz z-#zDi=X;&+Zs${3gD+*J&KG1K6~=AIETngW$o;TpA`eF1B$1{oMDpQu*nXwR2$yHZh1txXxk_X$d>(!qo`>BqGFfC5oO!j#ec1UbJS&ot{wX4LY&>|42tRqB zM>G5fd>4K*k09Wde31YL?}AHUM}Y`OWoW9%XYgn6G1lLlCep`z$8?cr;lv`5r{N25 z6Z`9DpvQcFvB)m`OYXHI8`!9W-(%yYnIiv!8!1N>>uXDVwq7R^V*PBX$V%4d8 z2WN{cVBQJ;%KX=JI167dBVd?4SEL#)gQ~w5QcQURKAob>Z}aji7Qzcf)=~cTi$u;b zf2cxaH}e{-Y(VeiVv)b&=)*4&M3%^VD+nl?fY*o=qL*7KlEXQJ_jXtgZFmwchhtvM)pfyZiU60{K&&_AM+EiABPp2MUKNipnRP5_AMgAaN$;;e}Xm4kCUGE z$cOMb<~b}jz!zZ%PTel@Fl>gjgM74|`fHy}z`OEFs2NOwTB1Tozex$C-(+59z8Km# zS_S(ih`h2xWGM{oq>osC@^+Ecu;31#+wb%pc6?1_4|a~ikKp9Hxc~VmPPkj7mW4&T zL|(?w5x7Vl+#@o|x^*wrfphPpT5vsVV?RGALOaNJcT;Z8^}+!B9mZ%IxvHKfMsERp zkM&6r`i1#lz9sT8``c2~=W|}FVj}mVP!;zxc!6(F9hY)}J6S&u*K%+P;g7;tv&f%e zMGH#o7d|MWuhXgTH<%xR2blkj1XIi(XcZYm|DSFCr{4XDNG1EJiH}k)UaH~S@Lf0s zo_LJAqUJ;Jc@C~TLNjKI{N_9U*Rqa79n2p-h9mTHo)LLR9lyvo&fV3%Ooh0+){{8R z63H4A;medyX`DRl<)0!T{r=~S@Qt(aTS(QUnzRX0PQVTrI4y!t8GyRJ^+zJVhi}1S z818&sB!S~iZ_tn!9{8!qFW~!cvJPMUxyX;vpZyjYqxVNRi}iWuXm0)fs~ilW@NbyS z#uF}OWPXf`CINXBYPSx+TzD4V0MEe!_&Lmj1@HJ7mO;#192|uS4_;&;Vv4E0FcbCgjVAjyVW_0p8#3Hb_$5-?s)=guvO zKvJcd#ibcu4{MMjguiZY@RfOEEzj-9I%GC-3-T322Q45=kZX{6NF}lcnU8Els*xGU z0;B}dK|iFVj2A7Zj(miFVVR4RBbN^T7-b={7}<&3gy_)gS%&C!To@BGFM5TviT8h^ zjlB}lPx*_;EU(7A=(y2`6vEB=-t$4uP35VYc(B$a!BULJ2S=cGK$B@ zf4Aiwq5{ofBKOLn7QOmhsLJi>y;H7DnMj>!!A~$@nS^CGHF$b4Grqh5bF&q*5`@;` zX?w1MQ9Ev$gR!IrSzed0ti4u6rkLe;jzlTBmI}K`L!I!W7opb`3MNBg%Qkm3HF6Wl zu)mind?5GMjjJ!F6$(2YIZGsf?;xSGxad9qGSgP(a$1!!8yEHnf z|L^g#BLU+)ORPiAC@GDS-a$?5G{KWCd5yRS{Rn#qwoNlVMbd4gf~nJPD`)*&`@Dp_ z)9E?23s+sQRy0>C;0(KuyNBHaoW}UT#X|NwUB0Ptg^bk}&oj;wT3h3EQ*Ruo>#^tX zX56-P=bp0k?nF^GS-g_|cx1zJyy}(srPt8a`vB7IUqR;Ph`Wm=a(4G~C+W)Yv}p%O zWo$X!L+;`9CufRNT--sNabn2n&m`bJ>J`lIgEJ?**tmD>symjoKweg7hBK8I(wiG~ z_j#6v-9rS_4>F>r)i8Q`({ufPzfdggynan|T(QzW z_(|a|I&@5@c|P>Q)vEEc8gu7P7cq}*3F_h%LA$q`7_@xciPoB?ZS5?|3CPZeoTt*= Q){OKGPv-WRqmx(s2M@;5<^TWy delta 1834 zcmXw)3rv<(7{~v>hmeTLM_iJt9~Pg0C@69X0)!OAOIgbcY9x&=(z4WUR&RomWo1cG zBS|4M5^XtxNSC9i&D7>rtJap?mTR-E>B`wex33Ee&>14dC%oJ&pC|jk2v^p zg!8T6_o66EQ7SqZF5L~wBl)46A17@JlPMXhV>JqQusG)Vw|E_=_AQe*F>p>hQ`Y!{`4U~55Ujh8Mt7Q#Git3Qa&G+!UA}Q zU*c1B$4mdfH(>|<{wt&*>=}tt4?GLIVSAEPOM7}U{ji&_WKQPSIVzPj%$qE|LPJN2 z^d}4ujvD-_X&#%>`3V2M45nuVRA+0H5AwDUKcqY*Qi3*9+ zQBNgxxDRfCc~#OJxEJ!NzJfI{rrKN366h?((M82pd=_qjL5$%r=>Y75x7I@ZKDKcb zz7b;cd}$$eMxB=%t?*Us_IfXe0)(d-`#Jb1{2g-qy01a9^l}=Azg4#3upR7x7Mfj< zJX1F$&-7ZzJ_I+gpkX)^F6Ate3gP!lq|Nx_$>K7&^G1&+Zt^CKyji-Ras2Qid=duf z=Pcv=Z^O~Q95GPr3MtnnXe4v-|F}vbJ=D9JgoH<78|}}oVb|DkO;R@B6~KJBVy%>h ze>;Poq2GS^1OBZoL=n5?PU#Zu$M2H_o=@vSSwt73ryvLh(_j;}*{>FQ`PJH+l z2YL+tvQhdO93p!&| zKBcm<-Mdb~t(+AxFNVE*IP{FP)+a67>D`heolHRcUoS|XhqE&dJcTn`-pinz*~Wh9 z50doK+Y&cX=ilM_GEU?W3&Bntme{;bLxSU|>|H*@u?KF40eU?T2cY>Y-jlwAqwpmL z-g|@uV&W3cCx?LtMx~SR%Mb9wzA=eEC49_I>Gw5E#XtEt$BMuB6UKqRLSGo?-*JLf z;n;D~OS(R2Svmm6!x1$V!q2_y5Km>cxnsz%6#9S)uwgSb|E? w6lBMli&Q=6k1Ed|JUnwGEvg{1U{+b-?7{VmC)IZbP9=Ai^~4P3?VeEl9~5l#6#xJL diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.po b/locales/ru/LC_MESSAGES/lib.cli.args.po index df1b4125d2..b9cd9014be 100644 --- a/locales/ru/LC_MESSAGES/lib.cli.args.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args.po @@ -5,8 +5,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2021-03-11 13:22-0000\n" -"PO-Revision-Date: 2021-03-11 13:24+0000\n" +"POT-Creation-Date: 2021-03-12 14:35-0000\n" +"PO-Revision-Date: 2021-03-12 14:37+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -653,8 +653,8 @@ msgstr "" "выравнивания для обучающего набора B. По умолчанию используется /" "alignments.json, если он не указан." -#: lib/cli/args.py:911 lib/cli/args.py:923 lib/cli/args.py:948 -#: lib/cli/args.py:958 +#: lib/cli/args.py:911 lib/cli/args.py:923 lib/cli/args.py:939 +#: lib/cli/args.py:964 lib/cli/args.py:974 msgid "model" msgstr "модель" @@ -674,6 +674,30 @@ msgstr "" #: lib/cli/args.py:924 msgid "" +"R|Load the weights from a pre-existing model into a newly created model. For " +"most models this will load weights from the Encoder of the given model into " +"the encoder of the newly created model. Some plugins may have specific " +"configuration options allowing you to load weights from other layers. " +"Weights will only be loaded when creating a new model. This option will be " +"ignored if you are resuming an existing model. Generally you will also want " +"to 'freeze-weights' whilst the rest of your model catches up with your " +"Encoder.\n" +"NB: Weights can only be loaded from models of the same plugin as you intend " +"to train." +msgstr "" +"R|Загрузите веса из уже существующей модели во вновь созданную модель. Для " +"большинства моделей это загрузит веса из кодировщика данной модели в " +"кодировщик вновь созданной модели. Некоторые плагины могут иметь " +"определенные параметры конфигурации, позволяющие загружать веса из других " +"слоев. Вес будет загружен только при создании новой модели. Этот параметр " +"будет проигнорирован, если вы возобновите работу с существующей моделью. Как " +"правило, вы также захотите «заморозить вес», пока остальная часть вашей " +"модели догонит ваш кодировщик.\n" +"NB: Вес можно загружать только из моделей того же плагина, который вы " +"собираетесь тренировать." + +#: lib/cli/args.py:940 +msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" "L|original: The original model created by /u/deepfakes.\n" @@ -718,7 +742,7 @@ msgstr "" "ресурсам (Вам потребуется GPU с хорошим количеством видеопамяти). Хороша для " "деталей, но подвержена к неправильной передаче цвета." -#: lib/cli/args.py:949 +#: lib/cli/args.py:965 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -730,7 +754,7 @@ msgstr "" "сводная информация о модели, которая будет создана выбранным плагином, и " "параметрами конфигурации." -#: lib/cli/args.py:959 +#: lib/cli/args.py:975 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -744,12 +768,12 @@ msgstr "" "некоторые модели могут иметь параметры конфигурации для замораживания других " "слоев." -#: lib/cli/args.py:972 lib/cli/args.py:984 lib/cli/args.py:995 -#: lib/cli/args.py:1081 +#: lib/cli/args.py:988 lib/cli/args.py:1000 lib/cli/args.py:1011 +#: lib/cli/args.py:1097 msgid "training" msgstr "тренировка" -#: lib/cli/args.py:973 +#: lib/cli/args.py:989 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -762,7 +786,7 @@ msgstr "" "изображений в два раза больше этого числа. Увеличение размера партии требует " "больше памяти GPU." -#: lib/cli/args.py:985 +#: lib/cli/args.py:1001 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -776,22 +800,22 @@ msgstr "" "Однако, если вы хотите, чтобы тренировка прервалась после указанного кол-ва " "итерация, вы можете ввести это здесь." -#: lib/cli/args.py:996 +#: lib/cli/args.py:1012 msgid "" "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" "Использовать стратегию зеркального распределения Tensorflow для совместной " "тренировки сразу на нескольких GPU." -#: lib/cli/args.py:1006 lib/cli/args.py:1016 +#: lib/cli/args.py:1022 lib/cli/args.py:1032 msgid "Saving" msgstr "Сохранение" -#: lib/cli/args.py:1007 +#: lib/cli/args.py:1023 msgid "Sets the number of iterations between each model save." msgstr "Установка количества итераций между сохранениями модели." -#: lib/cli/args.py:1017 +#: lib/cli/args.py:1033 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -799,11 +823,11 @@ msgstr "" "Устанавливает кол-во итераций перед созданием резервной копии модели. " "Установите в 0 для отключения." -#: lib/cli/args.py:1024 lib/cli/args.py:1035 lib/cli/args.py:1046 +#: lib/cli/args.py:1040 lib/cli/args.py:1051 lib/cli/args.py:1062 msgid "timelapse" msgstr "таймлапс" -#: lib/cli/args.py:1025 +#: lib/cli/args.py:1041 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -816,7 +840,7 @@ msgstr "" "папку лиц набора 'A' для использования при создании таймлапса. Вам также " "нужно указать параметры--timelapse-output и --timelapse-input-B." -#: lib/cli/args.py:1036 +#: lib/cli/args.py:1052 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -830,7 +854,7 @@ msgstr "" "таймлапса. Вы также должны указать параметр --timelapse-output и --timelapse-" "input-A." -#: lib/cli/args.py:1047 +#: lib/cli/args.py:1063 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -842,22 +866,22 @@ msgstr "" "указаны только входные папки, то по умолчанию вывод будет сохранен вместе с " "моделью в подкаталог /timelapse/" -#: lib/cli/args.py:1059 lib/cli/args.py:1066 lib/cli/args.py:1073 +#: lib/cli/args.py:1075 lib/cli/args.py:1082 lib/cli/args.py:1089 msgid "preview" msgstr "предварительный просмотр" -#: lib/cli/args.py:1060 +#: lib/cli/args.py:1076 msgid "" "Percentage amount to scale the preview by. 100%% is the model output size." msgstr "" "Величина в процентах, на которую требуется масштабировать предварительный " "просмотр. 100 %% - размер вывода модели." -#: lib/cli/args.py:1067 +#: lib/cli/args.py:1083 msgid "Show training preview output. in a separate window." msgstr "Показывать предварительный просмотр в отдельном окне." -#: lib/cli/args.py:1074 +#: lib/cli/args.py:1090 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -865,7 +889,7 @@ msgstr "" "Записывает результат тренировки в файл. Файл будет сохранен в коренной папке " "FaceSwap." -#: lib/cli/args.py:1082 +#: lib/cli/args.py:1098 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -873,12 +897,12 @@ msgstr "" "Отключает журнал TensorBoard. Примечание: Отключение журналов означает, что " "вы не сможете использовать графики или анализ сессии внутри GUI." -#: lib/cli/args.py:1089 lib/cli/args.py:1098 lib/cli/args.py:1107 -#: lib/cli/args.py:1116 +#: lib/cli/args.py:1105 lib/cli/args.py:1114 lib/cli/args.py:1123 +#: lib/cli/args.py:1132 msgid "augmentation" msgstr "аугментация" -#: lib/cli/args.py:1090 +#: lib/cli/args.py:1106 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -888,7 +912,7 @@ msgstr "" "Ориентирами/Landmarks противоположного набора лиц. Этот способ используется " "пакетом \"dfaker\"." -#: lib/cli/args.py:1099 +#: lib/cli/args.py:1115 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -899,7 +923,7 @@ msgstr "" "происходило. Как правило, эту настройку не стоит трогать, за исключением " "периода «финальной шлифовки»." -#: lib/cli/args.py:1108 +#: lib/cli/args.py:1124 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -909,7 +933,7 @@ msgstr "" "цвета между наборами A and B ценой некоторого замедления скорости " "тренировки. Включите эту опцию для отключения цветовой аугментации." -#: lib/cli/args.py:1117 +#: lib/cli/args.py:1133 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -922,6 +946,6 @@ msgstr "" "Включение этой опции с самого начала может убить модель и привести к ужасным " "результатам." -#: lib/cli/args.py:1142 +#: lib/cli/args.py:1158 msgid "Output to Shell console instead of GUI console" msgstr "Вывод в системную консоль вместо GUI" diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index b1fd2692d7..64af502afa 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -64,6 +64,32 @@ def KerasModel(inputs, outputs, name): # pylint:disable=invalid-name return KModel(inputs, outputs, name=name) +def _get_all_sub_models(model, models=None): + """ For a given model, return all sub-models that occur (recursively) as children. + + Parameters + ---------- + model: :class:`keras.models.Model` + A Keras model to scan for sub models + models: `None` + Do not provide this parameter. It is used for recursion + + Returns + ------- + list + A list of all :class:`keras.models.Model`s found within the given model. The provided + model will always be returned in the first position + """ + if models is None: + models = [model] + else: + models.append(model) + for layer in model.layers: + if isinstance(layer, KModel): + _get_all_sub_models(layer, models=models) + return models + + class ModelBase(): """ Base class that all model plugins should inherit from. @@ -271,31 +297,6 @@ def build(self): self._compile_model() self._output_summary() - def _get_all_sub_models(self, model, models=None): - """ For a given model, return all sub-models that occur (recursively) as children. - - Parameters - ---------- - model: :class:`keras.models.Model` - A Keras model to scan for sub models - models: `None` - Do not provide this parameter. It is used for recursion - - Returns - ------- - list - A list of all :class:`keras.models.Model`s found within the given model. The provided - model will always be returned in the first position - """ - if models is None: - models = [model] - else: - models.append(model) - for layer in model.layers: - if isinstance(layer, KModel): - self._get_all_sub_models(layer, models=models) - return models - def _update_legacy_models(self): """ Load weights from legacy split models into new unified model, archiving old model files to a new folder. """ @@ -386,7 +387,7 @@ def _output_summary(self): print_fn = None # Print straight to stdout else: print_fn = lambda x: logger.verbose("%s", x) # print to logger - for model in self._get_all_sub_models(self._model): + for model in _get_all_sub_models(self._model): model.summary(print_fn=print_fn) def save(self): @@ -415,30 +416,16 @@ def _compile_model(self): optimizer = self._settings.loss_scale_optimizer(optimizer) if get_backend() == "amd": self._rewrite_plaid_outputs() - self._freeze_weights() + + weights = _Weights(self) + weights.load(self._io.model_exists) + weights.freeze() + self._loss.configure(self._model) self._model.compile(optimizer=optimizer, loss=self._loss.functions) self._state.add_session_loss_names(self._loss.names) logger.debug("Compiled Model: %s", self._model) - def _freeze_weights(self): - """ If freeze has been selected in the cli arguments, then freeze those models indicated - in the plugin's configuration. """ - if not self._args.freeze_weights: - logger.debug("Freeze weights deselected. Not freezing") - return - - to_freeze = self.config.get("freeze_layers") # Standardized config for freezing weights - to_freeze = to_freeze if to_freeze else ["encoder"] # No plugin config - for layer in self._get_all_sub_models(self._model): - if layer.name in to_freeze: - logger.info("Freezing weights for '%s' in model '%s'", layer.name, self.name) - layer.trainable = False - to_freeze.remove(layer.name) - if to_freeze: - logger.warning("The following layers were set to be frozen but do not exist in the " - "model: %s", to_freeze) - def _rewrite_plaid_outputs(self): """ Rewrite the output names for models using the PlaidML (Keras 2.2.4) backend @@ -887,6 +874,186 @@ def strategy_scope(self): return retval +class _Weights(): + """ Handling of freezing and loading model weights + + Parameters + ---------- + plugin: :class:`Model` + The parent plugin class that owns the IO functions. + """ + def __init__(self, plugin): + logger.debug("Initializing %s: (plugin: %s)", self.__class__.__name__, plugin) + self._model = plugin.model + self._name = plugin.name + self._do_freeze = plugin._args.freeze_weights + self._weights_file = self._check_weights_file(plugin._args.load_weights) + + freeze_layers = plugin.config.get("freeze_layers") # Standardized config for freezing + load_layers = plugin.config.get("loading_layers") # Standardized config for loading + self._freeze_layers = freeze_layers if freeze_layers else ["encoder"] # No plugin config + self._load_layers = load_layers if load_layers else ["encoder"] # No plugin config + logger.debug("Initialized %s", self.__class__.__name__) + + @classmethod + def _check_weights_file(cls, weights_file): + """ Validate that we have a valid path to a .h5 file. + + Parameters + ---------- + weights_file: str + The full path to a weights file + + Returns + ------- + str + The full path to a weights file + """ + if not weights_file: + logger.debug("No weights file selected.") + return None + + msg = "" + if not os.path.exists(weights_file): + msg = "Load weights selected, but the path '%s' does not exist." + elif not os.path.splitext(weights_file)[-1].lower() == ".h5": + msg = "Load weights selected, but the path '%s' is not a valid Keras model (.h5) file." + + if msg: + msg += " Please check and try again." + logger.error(msg) + + logger.verbose("Using weights file: %s", weights_file) + return weights_file + + def freeze(self): + """ If freeze has been selected in the cli arguments, then freeze those models indicated + in the plugin's configuration. """ + if not self._do_freeze: + logger.debug("Freeze weights deselected. Not freezing") + return + + for layer in _get_all_sub_models(self._model): + if layer.name in self._freeze_layers: + logger.info("Freezing weights for '%s' in model '%s'", layer.name, self._name) + layer.trainable = False + self._freeze_layers.remove(layer.name) + if self._freeze_layers: + logger.warning("The following layers were set to be frozen but do not exist in the " + "model: %s", self._freeze_layers) + + def load(self, model_exists): + """ Load weights for newly created models, or output warning for pre-existing models. + + Parameters + ---------- + model_exists: bool + ``True`` if a model pre-exists and is being resumed, ``False`` if this is a new model + """ + if not self._weights_file: + logger.debug("No weights file provided. Not loading weights.") + return + if model_exists and self._weights_file: + logger.warning("Ignoring weights file '%s' as this model is resuming.", + self._weights_file) + return + + weights_models = self._get_weights_model() + all_models = _get_all_sub_models(self._model) + + for model_name in self._load_layers: + sub_model = next((lyr for lyr in all_models if lyr.name == model_name), None) + sub_weights = next((lyr for lyr in weights_models if lyr.name == model_name), None) + + if not sub_model or not sub_weights: + msg = f"Skipping layer {model_name} as not in " + msg += "current_model." if not sub_model else f"weights '{self._weights_file}.'" + logger.warning(msg) + continue + + logger.info("Loading weights for layer '%s'", model_name) + skipped_ops = 0 + loaded_ops = 0 + for layer in sub_model.layers: + success = self._load_layer_weights(layer, sub_weights, model_name) + if success == 0: + skipped_ops += 1 + elif success == 1: + loaded_ops += 1 + + del weights_models + + if loaded_ops == 0: + raise FaceswapError(f"No weights were succesfully loaded from your weights file: " + f"'{self._weights_file}'. Please check and try again.") + if skipped_ops > 0: + logger.warning("%s weight(s) were unable to be loaded for your model. This is most " + "likely because the weights you are trying to load were trained with " + "different settings than you have set for your current model.", + skipped_ops) + + def _get_weights_model(self): + """ Obtain a list of all sub-models contained within the weights model. + + Returns + ------- + list + List of all models contained within the .h5 file + + Raises + ------ + FaceswapError + In the event of a failure to load the weights, or the weights belonging to a different + model + """ + retval = _get_all_sub_models(load_model(self._weights_file, compile=False)) + if not retval: + raise FaceswapError(f"Error loading weights file {self._weights_file}.") + + if retval[0].name != self._name: + raise FaceswapError(f"You are attempting to load weights from a '{retval[0].name}' " + f"model into a '{self._name}' model. This is not supported.") + return retval + + def _load_layer_weights(self, layer, sub_weights, model_name): + """ Load the weights for a single layer. + + Parameters + ---------- + layer: :class:`keras.layers.Layer` + The layer to set the weights for + sub_weights: list + The list of layers in the weights model to load weights from + model_name: str + The name of the current sub-model that is having it's weights loaded + + Returns + ------- + int + `-1` if the layer has no weights to load. `0` if weights loading was unsuccessful. `1` + if weights loading was successful + """ + old_weights = layer.get_weights() + if not old_weights: + logger.debug("Skipping layer without weights: %s", layer.name) + return -1 + + layer_weights = next((lyr for lyr in sub_weights.layers if lyr.name == layer.name), None) + if not layer_weights: + logger.warning("The weights file '%s' for layer '%s' does not contain weights for " + "'%s'. Skipping", self._weights_file, model_name, layer.name) + return 0 + + new_weights = layer_weights.get_weights() + if old_weights[0].shape != new_weights[0].shape: + logger.warning("The weights for layer '%s' are of incompatible shapes. Skipping.", + layer.name) + return 0 + logger.verbose("Setting weights for '%s'", layer.name) + layer.set_weights(layer_weights.get_weights()) + return 1 + + class _Optimizer(): # pylint:disable=too-few-public-methods """ Obtain the selected optimizer with the appropriate keyword arguments. From 5eea32833d075553313466462cfc3031f14cb745 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 14 Mar 2021 02:50:06 +0000 Subject: [PATCH 406/981] GUI - Make groups collapsible --- lib/gui/control_helper.py | 176 ++++++++++++++++++++++++++++++-------- lib/gui/custom_widgets.py | 94 ++++++++++++++++++++ scripts/gui.py | 8 +- 3 files changed, 243 insertions(+), 35 deletions(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 7a128a1e99..c390279824 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -10,7 +10,7 @@ from _tkinter import Tcl_Obj, TclError -from .custom_widgets import ContextMenu, MultiOption, Tooltip +from .custom_widgets import ContextMenu, MultiOption, ToggledFrame, Tooltip from .utils import FileHandler, get_config, get_images logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -467,7 +467,22 @@ def build_panel(self, blank_nones, scrollbar): logger.debug("Added Config Frame") def get_group_frame(self, group): - """ Return a new group frame """ + """ Return a group frame. + + If a group frame has already been created for the given group, then it will be returned, + otherwise it will be created and returned. + + Parameters + ---------- + group: str + The name of the group to obtain the group frame for + + Returns + ------- + :class:`ttk.Frame` or :class:`ToggledFrame` + If this is a 'master' group frame then returns a standard frame. If this is any + other group, then will return the ToggledFrame for that group + """ group = group.lower() if self.group_frames.get(group, None) is None: logger.debug("Creating new group frame for: %s", group) @@ -475,13 +490,18 @@ def get_group_frame(self, group): opts_frame = self.optsframe.subframe if is_master: group_frame = ttk.Frame(opts_frame) + retval = group_frame else: - group_frame = ttk.LabelFrame(opts_frame, text="" if is_master else group.title()) + group_frame = ToggledFrame(opts_frame, text=group.title()) + retval = group_frame.sub_frame + retval.config(highlightbackground="#176087", + highlightcolor="#176087", + background="#FFFFFF") group_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5, anchor=tk.NW) - self.group_frames[group] = dict(frame=group_frame, - chkbtns=self.checkbuttons_frame(group_frame)) + self.group_frames[group] = dict(frame=retval, + chkbtns=self.checkbuttons_frame(retval)) group_frame = self.group_frames[group] return group_frame @@ -511,7 +531,7 @@ def checkbuttons_frame(self, frame): if is_master then check buttons will be placed in a LabelFrame otherwise in a standard frame """ logger.debug("Add Options CheckButtons Frame") - chk_frame = ttk.Frame(frame, name="chkbuttons") + chk_frame = ttk.Frame(frame, name="chkbuttons", style="CPanel.TFrame") holder = AutoFillContainer(chk_frame, self.option_columns, self.option_columns) logger.debug("Added Options CheckButtons Frame") return holder @@ -520,7 +540,7 @@ def _get_subgroup_frame(self, parent, subgroup): if subgroup is None: return subgroup if subgroup not in self._sub_group_frames: - sub_frame = ttk.Frame(parent) + sub_frame = ttk.Frame(parent, style="CPanel.TFrame") self._sub_group_frames[subgroup] = AutoFillContainer(sub_frame, self.option_columns, self.option_columns) @@ -620,7 +640,8 @@ def validate(self, width): return True def compile_widget_config(self): - """ Compile all children recursively in correct order if not already compiled """ + """ Compile all children recursively in correct order if not already compiled and add + to :attr:`_widget_config` """ zipped = zip_longest(*(subframe.winfo_children() for subframe in self.subframes)) children = [child for group in zipped for child in group if child is not None] self._widget_config = [{"class": child.__class__, @@ -632,35 +653,85 @@ def compile_widget_config(self): "config": self.config_cleaner(child), "children": self.get_all_children_config(child, []), # Some children have custom kwargs, so keep dicts in sync - "custom_kwargs": dict()} + "custom_kwargs": self._custom_kwargs(child)} for idx, child in enumerate(children)] logger.debug("Compiled AutoFillContainer children: %s", self._widget_config) + @classmethod + def _custom_kwargs(cls, widget): + """ For custom widgets some custom arguments need to be passed from the old widget to the + newly created widget. + + Parameters + ---------- + widget: tkinter widget + The widget to be checked for custom keyword arguments + + Returns + ------- + dict + The custom keyword arguments required for recreating the given widget + """ + retval = dict() + if widget.__class__.__name__ == "MultiOption": + retval = dict(value=widget._value, # pylint:disable=protected-access + variable=widget._master_variable) # pylint:disable=protected-access + elif widget.__class__.__name__ == "ToggledFrame": + # Toggled Frames need to have their variable tracked + retval = dict(text=widget._text, # pylint:disable=protected-access + toggle_var=widget._toggle_var) # pylint:disable=protected-access + return retval + def get_all_children_config(self, widget, child_list): - """ Return all children, recursively, of given widget """ + """ Return all children, recursively, of given widget. + + Parameters + ---------- + widget: tkinter widget + The widget to recursively obtain the configurations of each child + child_list: list + The list of child configurations already collected + + Returns + ------- + list + The list of configurations for all recursive children of the given widget + """ + unpack = set() for child in widget.winfo_children(): - if child.winfo_ismapped(): - id_ = str(child) - if child.__class__.__name__ == "MultiOption": - # MultiOption checkbox groups are a custom object with additional parameter - # requirements. - custom_kwargs = dict( - value=child._value, # pylint:disable=protected-access - variable=child._master_variable) # pylint:disable=protected-access - else: - custom_kwargs = dict() + # Hidden Toggle Frame boxes need to be mapped + if child.winfo_ismapped() or "toggledframe_subframe" in str(child): + not_mapped = not child.winfo_ismapped() + # ToggleFrame is a custom widget that creates it's own children and handles + # bindings on the headers, to auto-hide the contents. To ensure that all child + # information (specifically pack information) can be collected, we need to pack + # any hidden sub-frames. These are then hidden again once collected. + if not_mapped and (child.winfo_name() == "toggledframe_subframe" or + child.winfo_name() == "chkbuttons"): + child.pack(fill=tk.X, expand=True) + child.update_idletasks() # Updates the packing info of children + unpack.add(child) + + if child.winfo_name().startswith("toggledframe_header"): + # Headers should be entirely handled by parent widget + continue child_list.append({ "class": child.__class__, - "id": id_, - "tooltip": _RECREATE_OBJECTS["tooltips"].get(id_, None), - "rc_menu": _RECREATE_OBJECTS["contextmenus"].get(str(id_), None), + "id": str(child), + "tooltip": _RECREATE_OBJECTS["tooltips"].get(str(child), None), + "rc_menu": _RECREATE_OBJECTS["contextmenus"].get(str(child), None), "pack_info": self.pack_config_cleaner(child), "name": child.winfo_name(), "config": self.config_cleaner(child), "parent": child.winfo_parent(), - "custom_kwargs": custom_kwargs}) + "custom_kwargs": self._custom_kwargs(child)}) self.get_all_children_config(child, child_list) + + # Re-hide any toggle frames that were expanded + for hide in unpack: + hide.pack_forget() + hide.update_idletasks() return child_list @staticmethod @@ -678,6 +749,9 @@ def config_cleaner(widget): # so skip them to use default value. if key in ("anchor", "justify", "compound") and val == "": continue + # Following keys cannot be defined after widget is created: + if key in ("colormap", "container", "visual"): + continue val = str(val) if isinstance(val, Tcl_Obj) else val # Return correct command from master command dict val = _RECREATE_OBJECTS["commands"][val] if key == "command" and val != "" else val @@ -708,8 +782,21 @@ def repack_columns(self): subframe.pack_forget() def pack_widget_clones(self, widget_dicts, old_children=None, new_children=None): - """ Widgets cannot be given a new parent so we need to clone - them and then pack the new widget """ + """ Recursively pass through the list of widgets creating clones and packing all + children. + + Widgets cannot be given a new parent so we need to clone them and then pack the + new widgets. + + Parameters + ---------- + widget_dicts: list + List of dictionaries, in appearance order, of widget information for cloning widgets + old_childen: list, optional + Used for recursion. Leave at ``None`` + new_childen: list, optional + Used for recursion. Leave at ``None`` + """ for widget_dict in widget_dicts: logger.debug("Cloning widget: %s", widget_dict) old_children = [] if old_children is None else old_children @@ -733,6 +820,15 @@ def pack_widget_clones(self, widget_dicts, old_children=None, new_children=None) rc_menu.__init__(widget=clone) rc_menu.cm_bind() clone.pack(**widget_dict["pack_info"]) + + # Handle ToggledFrame sub-frames. If the parent is not set to expanded, then we need to + # hide the sub-frame + if clone.winfo_name() == "toggledframe_subframe": + toggle_frame = clone.nametowidget(clone.winfo_parent()) + if not toggle_frame.is_expanded: + logger.debug("Hiding minimized toggle box: %s", clone) + clone.pack_forget() + old_children.append(widget_dict["id"]) new_children.append(clone) if widget_dict.get("children", None) is not None: @@ -784,7 +880,7 @@ def __init__(self, parent, option, option_columns, # pylint: disable=too-many-a def control_frame(self, parent): """ Frame to hold control and it's label """ logger.debug("Build control frame") - frame = ttk.Frame(parent, name="fr_{}".format(self.option.name)) + frame = ttk.Frame(parent, name="fr_{}".format(self.option.name), style="CPanel.TFrame") frame.pack(fill=tk.X) logger.debug("Built control frame") return frame @@ -808,7 +904,11 @@ def build_control(self): def build_control_label(self): """ Label for control """ logger.debug("Build control label: (option: '%s')", self.option.name) - lbl = ttk.Label(self.frame, text=self.option.title, width=self.label_width, anchor=tk.W) + lbl = ttk.Label(self.frame, + text=self.option.title, + width=self.label_width, + anchor=tk.W, + style="CPanel.TLabel") lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) if self.option.helptext is not None: _get_tooltip(lbl, text=self.option.helptext, wraplength=600) @@ -852,20 +952,23 @@ def _multi_option_control(self, option_type): help_intro, help_items = self._get_multi_help_items(self.option.helptext) ctl = ttk.LabelFrame(self.frame, text=self.option.title, - name="{}_labelframe".format(option_type)) + name="{}_labelframe".format(option_type), + style="CPanel.TLabelframe") holder = AutoFillContainer(ctl, self.option_columns, self.option_columns) for choice in self.option.choices: ctl = ttk.Radiobutton if option_type == "radio" else MultiOption + style = f"CPanel.T{'Radiobutton' if option_type == 'radio' else 'Checkbutton'}" ctl = ctl(holder.subframe, text=choice.replace("_", " ").title(), value=choice, - variable=self.option.tk_var) + variable=self.option.tk_var, + style=style) if choice.lower() in help_items: self.helpset = True helptext = help_items[choice.lower()] helptext = "{}\n\n - {}".format(helptext, help_intro) _get_tooltip(ctl, text=helptext, wraplength=600) - ctl.pack(anchor=tk.W) + ctl.pack(anchor=tk.W, fill=tk.X) logger.debug("Added %s option %s", option_type, choice) return holder.parent @@ -990,7 +1093,11 @@ def _color_control(self): height=round(int(12 * get_config().scaling_factor))) ctl.bind("", lambda *e, c=ctl, t=self.option.title: self._ask_color(c, t)) ctl.pack(side=tk.LEFT, anchor=tk.W) - lbl = ttk.Label(frame, text=self.option.title, width=self.label_width, anchor=tk.W) + lbl = ttk.Label(frame, + text=self.option.title, + width=self.label_width, + anchor=tk.W, + style="CPanel.TLabel") lbl.pack(padx=2, pady=5, side=tk.RIGHT, anchor=tk.N) frame.pack(side=tk.LEFT, anchor=tk.W) if self.option.helptext is not None: @@ -1014,9 +1121,10 @@ def control_to_checkframe(self): ctl = self.option.control(chkframe, variable=self.option.tk_var, text=self.option.title, - name=self.option.name) + name=self.option.name, + style="CPanel.TCheckbutton") _get_tooltip(ctl, text=self.option.helptext, wraplength=600) - ctl.pack(side=tk.TOP, anchor=tk.W) + ctl.pack(side=tk.TOP, anchor=tk.W, fill=tk.X) logger.debug("Added control checkframe: '%s'", self.option.name) return ctl diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index fd0e889bd7..4b487c0b9f 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -886,3 +886,97 @@ def update_title(self, title): """ self._lbl_title.config(text=title) self._lbl_title.update_idletasks() + + +class ToggledFrame(ttk.Frame): # pylint:disable=too-many-ancestors + """ A collapsible and expandable frame. + + The frame contains a header given in the text argument, and adds an expand contract button. + Clicking on the header will expand and contract the sub-frame below + + Parameters + ---------- + text: str + The text to appear in the Toggle Frame header + subframe_style: str, optional + The name of the ttk Style to use for the sub frame. Default: ``None`` + toggle_var: :class:`tk.BooleanVar`, optional + If provided, this variable will control the expanded (``True``) and minimized (``False``) + state of the widget. Set to None to create the variable internally. Default: ``None`` + """ + def __init__(self, parent, *args, text="", toggle_var=None, **kwargs): + logger.debug("Initializing %s: (parent: %s, text: %s, toggle_var: %s)", + self.__class__.__name__, parent, text, toggle_var) + super().__init__(parent, *args, **kwargs) + style = ttk.Style() + font = get_config().default_font + style.configure('GroupHeader.TLabel', + background="#176087", + foreground="#FFFFFF", + font=(font[0], font[1], "bold")) + + self._text = text + + if toggle_var: + self._toggle_var = toggle_var + else: + self._toggle_var = tk.BooleanVar() + self._toggle_var.set(1) + self._icon_var = tk.StringVar() + self._icon_var.set("-" if self.is_expanded else "+") + + self._build_header() + + self.sub_frame = tk.Frame(self, name="toggledframe_subframe", highlightthickness=1, bd=0) + if self.is_expanded: + self.sub_frame.pack(fill=tk.X, expand=True) + + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def is_expanded(self): + """ bool: ``True`` if the Toggle Frame is expanded. ``False`` if it is minimized. """ + return self._toggle_var.get() + + def _build_header(self): + """ The Header row. Contains the title text and is made clickable to expand and contract + the sub-frame. """ + + header_frame = ttk.Frame(self, name="toggledframe_header") + + text_label = ttk.Label(header_frame, + name="toggledframe_headerlbl", + text=self._text, + style="GroupHeader.TLabel", + cursor="hand2") + + toggle_button = ttk.Label(header_frame, + name="toggledframe_headerbtn", + textvariable=self._icon_var, + style="GroupHeader.TLabel", + cursor="hand2", + width=2) + text_label.bind("", self._toggle) + toggle_button.bind("", self._toggle) + + text_label.pack(side=tk.LEFT, fill=tk.X, expand=True) + toggle_button.pack(side=tk.RIGHT) + header_frame.pack(fill=tk.X, expand=True) + + def _toggle(self, event): # pylint:disable=unused-argument + """ Toggle the sub-frame between contracted or expanded, and update the toggle icon + appropriately. + + Parameters + ---------- + event: tkinter event + Required but unused + """ + if self.is_expanded: + self.sub_frame.forget() + self._icon_var.set("+") + self._toggle_var.set(0) + else: + self.sub_frame.pack(fill=tk.X, expand=True) + self._icon_var.set("-") + self._toggle_var.set(1) diff --git a/scripts/gui.py b/scripts/gui.py index d54993cfaf..68d56e0c45 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -54,7 +54,6 @@ def set_fonts(self): def set_styles(self): """ Set global custom styles """ gui_style = ttk.Style() - gui_style.configure('TLabelframe.Label', foreground="#0046D5", relief=tk.SOLID) gui_style.configure('H1.TLabel', font=(self._config.default_font[0], self._config.default_font[1] + 4, @@ -64,6 +63,13 @@ def set_styles(self): self._config.default_font[1] + 2, "bold")) + gui_style.configure("CPanel.TLabel", background="#FFFFFF") + gui_style.configure("CPanel.TFrame", background="#FFFFFF") + gui_style.configure("CPanel.TLabelframe", background="#FFFFFF") + gui_style.configure('CPanel.TLabelframe.Label', background="#FFFFFF", foreground="#176087") + gui_style.configure("CPanel.TCheckbutton", background="#FFFFFF") + gui_style.configure("CPanel.TRadiobutton", background="#FFFFFF") + def build_gui(self, rebuild=False): """ Build the GUI """ logger.debug("Building GUI") From 073438fbccf1e3ac40560c8916d6be38fc3fcd5e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 14 Mar 2021 18:18:43 +0000 Subject: [PATCH 407/981] GUI fixup - Add alignments tool job to processing group --- tools/alignments/cli.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py index d4788719ec..be165229c9 100644 --- a/tools/alignments/cli.py +++ b/tools/alignments/cli.py @@ -38,6 +38,7 @@ def get_argument_list(self): type=str, choices=("draw", "extract", "missing-alignments", "missing-frames", "multi-faces", "no-faces", "remove-faces", "rename", "sort", "spatial"), + group=_("processing"), required=True, help=_("R|Choose which action you want to perform. NB: All actions require an " "alignments file (-a) to be passed in." @@ -64,6 +65,19 @@ def get_argument_list(self): "\nL|'spatial': Perform spatial and temporal filtering to smooth alignments " "(EXPERIMENTAL!)").format(frames_dir, frames_and_faces_dir, output_opts, faces_dir, frames_or_faces_dir))) + argument_list.append(dict( + opts=("-o", "--output"), + action=Radio, + type=str, + choices=("console", "file", "move"), + group=_("processing"), + default="console", + help=_("R|How to output discovered items ('faces' and 'frames' only):" + "\nL|'console': Print the list of frames to the screen. (DEFAULT)" + "\nL|'file': Output the list of frames to a text file (stored within the " + "source directory)." + "\nL|'move': Move the discovered items to a sub-folder within the source " + "directory."))) argument_list.append(dict( opts=("-a", "--alignments_file"), action=FileFullPaths, @@ -87,19 +101,6 @@ def get_argument_list(self): filetypes="video", group=_("data"), help=_("Directory containing source frames that faces were extracted from."))) - argument_list.append(dict( - opts=("-o", "--output"), - action=Radio, - type=str, - choices=("console", "file", "move"), - group=_("processing"), - default="console", - help=_("R|How to output discovered items ('faces' and 'frames' only):" - "\nL|'console': Print the list of frames to the screen. (DEFAULT)" - "\nL|'file': Output the list of frames to a text file (stored within the " - "source directory)." - "\nL|'move': Move the discovered items to a sub-folder within the source " - "directory."))) argument_list.append(dict( opts=("-een", "--extract-every-n"), type=int, From 7115ad35508aead697ef9ec82c73337033d34f7a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 14 Mar 2021 20:27:04 +0000 Subject: [PATCH 408/981] GUI - Minor Updates - Add style support to control panel - Theme settings to red - Use standard tk buttons - Fix some graphical glitches --- lib/gui/command.py | 3 +- lib/gui/control_helper.py | 88 ++++++++++++++++++++++++++++---------- lib/gui/custom_widgets.py | 32 +++++++------- lib/gui/popup_configure.py | 9 ++-- lib/gui/popup_session.py | 2 +- scripts/gui.py | 35 ++++++++------- 6 files changed, 110 insertions(+), 59 deletions(-) diff --git a/lib/gui/command.py b/lib/gui/command.py index 137ce44ca0..546df5c6de 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -135,7 +135,8 @@ def build_tab(self): label_width=16, option_columns=3, columns=1, - header_text=options.get("helptext", None)) + header_text=options.get("helptext", None), + style="CPanel") self.add_frame_separator() ActionFrame(self) logger.debug("Built Tab: '%s'", self.command) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index c390279824..2eb7f893d7 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -359,6 +359,10 @@ class ControlPanel(ttk.Frame): # pylint:disable=too-many-ancestors header_text: str, optional If provided, will place an information box at the top of the control panel with these contents. + style: str, optional + The name of the style to use for the control panel. Styles are configured when TkInter + initializes. The style name is the common prefix prior to the widget name. Default: + ``None`` (use the OS style) blank_nones: bool, optional How the control panel should handle None values. If set to True then None values will be converted to empty strings. Default: False @@ -369,12 +373,12 @@ class ControlPanel(ttk.Frame): # pylint:disable=too-many-ancestors def __init__(self, parent, options, # pylint:disable=too-many-arguments label_width=20, columns=1, max_columns=4, option_columns=4, header_text=None, - blank_nones=True, scrollbar=True): + style=None, blank_nones=True, scrollbar=True): logger.debug("Initializing %s: (parent: '%s', options: %s, label_width: %s, columns: %s, " - "max_columns: %s, option_columns: %s, header_text: %s, blank_nones: %s, " - "scrollbar: %s)", + "max_columns: %s, option_columns: %s, header_text: %s, style: %s, " + "blank_nones: %s, scrollbar: %s)", self.__class__.__name__, parent, options, label_width, columns, max_columns, - option_columns, header_text, blank_nones, scrollbar) + option_columns, header_text, style, blank_nones, scrollbar) super().__init__(parent) self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) @@ -387,6 +391,8 @@ def __init__(self, parent, options, # pylint:disable=too-many-arguments self.option_columns = option_columns self.header_text = header_text + self._style = "" if style is None else f"{style}." + self.group_frames = dict() self._sub_group_frames = dict() @@ -455,6 +461,7 @@ def build_panel(self, blank_nones, scrollbar): label_width=self.label_width, checkbuttons_frame=group_frame["chkbtns"], option_columns=self.option_columns, + style=self._style, blank_nones=blank_nones) if group_frame["chkbtns"].items > 0: group_frame["chkbtns"].parent.pack(side=tk.BOTTOM, fill=tk.X, anchor=tk.NW) @@ -492,7 +499,7 @@ def get_group_frame(self, group): group_frame = ttk.Frame(opts_frame) retval = group_frame else: - group_frame = ToggledFrame(opts_frame, text=group.title()) + group_frame = ToggledFrame(opts_frame, text=group.title(), theme=self._style) retval = group_frame.sub_frame retval.config(highlightbackground="#176087", highlightcolor="#176087", @@ -531,8 +538,11 @@ def checkbuttons_frame(self, frame): if is_master then check buttons will be placed in a LabelFrame otherwise in a standard frame """ logger.debug("Add Options CheckButtons Frame") - chk_frame = ttk.Frame(frame, name="chkbuttons", style="CPanel.TFrame") - holder = AutoFillContainer(chk_frame, self.option_columns, self.option_columns) + chk_frame = ttk.Frame(frame, name="chkbuttons", style=f"{self._style}TFrame") + holder = AutoFillContainer(chk_frame, + self.option_columns, + self.option_columns, + style=self._style) logger.debug("Added Options CheckButtons Frame") return holder @@ -540,23 +550,39 @@ def _get_subgroup_frame(self, parent, subgroup): if subgroup is None: return subgroup if subgroup not in self._sub_group_frames: - sub_frame = ttk.Frame(parent, style="CPanel.TFrame") + sub_frame = ttk.Frame(parent, style=f"{self._style}TFrame") self._sub_group_frames[subgroup] = AutoFillContainer(sub_frame, self.option_columns, - self.option_columns) + self.option_columns, + style=self._style) sub_frame.pack(anchor=tk.W, expand=True, fill=tk.X) logger.debug("Added Subgroup Frame: %s", subgroup) return self._sub_group_frames[subgroup] class AutoFillContainer(): - """ A container object that auto-fills columns """ - def __init__(self, parent, initial_columns, max_columns): + """ A container object that auto-fills columns. + + Parameters + ---------- + parent: :class:`ttk.Frame` + The parent widget that holds this container + initial_columns: int + The initial number of columns that this container should display + max_columns: int + The maximum number of column that this container is permitted to display + style: str, optional + The name of the style to use for the control panel. Styles are configured when TkInter + initializes. The style name is the common prefix prior to the widget name. Default: + empty string (use the OS style) + """ + def __init__(self, parent, initial_columns, max_columns, style=""): logger.debug("Initializing: %s: (parent: %s, initial_columns: %s, max_columns: %s)", self.__class__.__name__, parent, initial_columns, max_columns) self.max_columns = max_columns self.columns = initial_columns self.parent = parent + self._style = style # self.columns = min(columns, self.max_columns) self.single_column_width = self.scale_column_width(288, 9) self.max_width = self.max_columns * self.single_column_width @@ -598,7 +624,7 @@ def set_subframes(self): subframes = [] for idx in range(self.max_columns): name = "af_subframe_{}".format(idx) - subframe = ttk.Frame(self.parent, name=name) + subframe = ttk.Frame(self.parent, name=name, style=f"{self._style}TFrame") if idx < self.columns: # Only pack visible columns subframe.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N, expand=True, fill=tk.X) @@ -853,21 +879,26 @@ class ControlBuilder(): checkbuttons_frame: tkinter.frame If a check-button frame is passed in, then check-buttons will be placed in this frame rather than the main options frame + style: str + The name of the style to use for the control panel. Styles are configured when TkInter + initializes. The style name is the common prefix prior to the widget name. Provide an empty + string to use the OS style blank_nones: bool Sets selected values to an empty string rather than None if this is true. """ def __init__(self, parent, option, option_columns, # pylint: disable=too-many-arguments - label_width, checkbuttons_frame, blank_nones): + label_width, checkbuttons_frame, style, blank_nones): logger.debug("Initializing %s: (parent: %s, option: %s, option_columns: %s, " - "label_width: %s, checkbuttons_frame: %s, blank_nones: %s)", + "label_width: %s, checkbuttons_frame: %s, style: %s, blank_nones: %s)", self.__class__.__name__, parent, option, option_columns, label_width, - checkbuttons_frame, blank_nones) + checkbuttons_frame, style, blank_nones) self.option = option self.option_columns = option_columns self.helpset = False self.label_width = label_width self.filebrowser = None + self._style = style self.frame = self.control_frame(parent) self.chkbtns = checkbuttons_frame @@ -880,7 +911,9 @@ def __init__(self, parent, option, option_columns, # pylint: disable=too-many-a def control_frame(self, parent): """ Frame to hold control and it's label """ logger.debug("Build control frame") - frame = ttk.Frame(parent, name="fr_{}".format(self.option.name), style="CPanel.TFrame") + frame = ttk.Frame(parent, + name="fr_{}".format(self.option.name), + style=f"{self._style}TFrame") frame.pack(fill=tk.X) logger.debug("Built control frame") return frame @@ -908,7 +941,7 @@ def build_control_label(self): text=self.option.title, width=self.label_width, anchor=tk.W, - style="CPanel.TLabel") + style=f"{self._style}TLabel") lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) if self.option.helptext is not None: _get_tooltip(lbl, text=self.option.helptext, wraplength=600) @@ -953,11 +986,14 @@ def _multi_option_control(self, option_type): ctl = ttk.LabelFrame(self.frame, text=self.option.title, name="{}_labelframe".format(option_type), - style="CPanel.TLabelframe") - holder = AutoFillContainer(ctl, self.option_columns, self.option_columns) + style=f"{self._style}TLabelframe") + holder = AutoFillContainer(ctl, + self.option_columns, + self.option_columns, + style=self._style) for choice in self.option.choices: ctl = ttk.Radiobutton if option_type == "radio" else MultiOption - style = f"CPanel.T{'Radiobutton' if option_type == 'radio' else 'Checkbutton'}" + style = f"{self._style}T{'Radiobutton' if option_type == 'radio' else 'Checkbutton'}" ctl = ctl(holder.subframe, text=choice.replace("_", " ").title(), value=choice, @@ -1097,7 +1133,7 @@ def _color_control(self): text=self.option.title, width=self.label_width, anchor=tk.W, - style="CPanel.TLabel") + style=f"{self._style}TLabel") lbl.pack(padx=2, pady=5, side=tk.RIGHT, anchor=tk.N) frame.pack(side=tk.LEFT, anchor=tk.W) if self.option.helptext is not None: @@ -1122,7 +1158,7 @@ def control_to_checkframe(self): variable=self.option.tk_var, text=self.option.title, name=self.option.name, - style="CPanel.TCheckbutton") + style=f"{self._style}TCheckbutton") _get_tooltip(ctl, text=self.option.helptext, wraplength=600) ctl.pack(side=tk.TOP, anchor=tk.W, fill=tk.X) logger.debug("Added control checkframe: '%s'", self.option.name) @@ -1193,7 +1229,13 @@ def add_browser_buttons(self): img = get_images().icons[lbl] action = getattr(self, "ask_" + browser) cmd = partial(action, filepath=self.tk_var, filetypes=self.filetypes) - fileopn = ttk.Button(frame, image=img, command=cmd) + fileopn = tk.Button(frame, + image=img, + command=cmd, + relief=tk.SOLID, + bd=1, + bg="#FFFFFF", + cursor="hand2") _add_command(fileopn.cget("command"), cmd) fileopn.pack(padx=0, side=tk.RIGHT) _get_tooltip(fileopn, text=self.helptext[lbl], wraplength=600) diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index 4b487c0b9f..37bfcb5fa4 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -898,23 +898,18 @@ class ToggledFrame(ttk.Frame): # pylint:disable=too-many-ancestors ---------- text: str The text to appear in the Toggle Frame header + theme: str, optional + The theme to use for the panel header. Default: `"CPanel"` subframe_style: str, optional The name of the ttk Style to use for the sub frame. Default: ``None`` toggle_var: :class:`tk.BooleanVar`, optional If provided, this variable will control the expanded (``True``) and minimized (``False``) state of the widget. Set to None to create the variable internally. Default: ``None`` """ - def __init__(self, parent, *args, text="", toggle_var=None, **kwargs): - logger.debug("Initializing %s: (parent: %s, text: %s, toggle_var: %s)", - self.__class__.__name__, parent, text, toggle_var) + def __init__(self, parent, *args, text="", theme="CPanel", toggle_var=None, **kwargs): + logger.debug("Initializing %s: (parent: %s, text: %s, theme: %s, toggle_var: %s)", + self.__class__.__name__, parent, text, theme, toggle_var) super().__init__(parent, *args, **kwargs) - style = ttk.Style() - font = get_config().default_font - style.configure('GroupHeader.TLabel', - background="#176087", - foreground="#FFFFFF", - font=(font[0], font[1], "bold")) - self._text = text if toggle_var: @@ -925,7 +920,9 @@ def __init__(self, parent, *args, text="", toggle_var=None, **kwargs): self._icon_var = tk.StringVar() self._icon_var.set("-" if self.is_expanded else "+") - self._build_header() + theme = "CPanel" if not theme else theme + theme = theme[:-1] if theme[-1] == "." else theme + self._build_header(theme) self.sub_frame = tk.Frame(self, name="toggledframe_subframe", highlightthickness=1, bd=0) if self.is_expanded: @@ -938,22 +935,25 @@ def is_expanded(self): """ bool: ``True`` if the Toggle Frame is expanded. ``False`` if it is minimized. """ return self._toggle_var.get() - def _build_header(self): + def _build_header(self, theme): """ The Header row. Contains the title text and is made clickable to expand and contract - the sub-frame. """ + the sub-frame. + Parameters + theme: str + The theme to use for the panel header + """ header_frame = ttk.Frame(self, name="toggledframe_header") text_label = ttk.Label(header_frame, name="toggledframe_headerlbl", text=self._text, - style="GroupHeader.TLabel", + style=f"{theme}.Groupheader.TLabel", cursor="hand2") - toggle_button = ttk.Label(header_frame, name="toggledframe_headerbtn", textvariable=self._icon_var, - style="GroupHeader.TLabel", + style=f"{theme}.Groupheader.TLabel", cursor="hand2", width=2) text_label.bind("", self._toggle) diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 00d7f5632a..b367de03fc 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -485,7 +485,6 @@ def _cache_page(self, key): key: str The lookup key to the settings cache """ - panel_kwargs = dict(columns=1, max_columns=1, option_columns=4, blank_nones=False) info = self._config_cpanel_dict.get(key, None) if info is None: logger.debug("key '%s' does not exist in options. Creating links page.", key) @@ -494,7 +493,11 @@ def _cache_page(self, key): self._cache[key] = ControlPanel(self, list(info["options"].values()), header_text=info["helptext"], - **panel_kwargs) + columns=1, + max_columns=1, + option_columns=4, + style="SPanel", + blank_nones=False) def _create_links_page(self, key): """ For headings which don't have settings, build a links page to the subsections. @@ -616,7 +619,7 @@ def save(self, page_only=False): new_opt, ".".join([section, item])) helptext = config.format_help(options["helptext"], is_section=False) new_config.set(section, helptext) - if options["type"] == list: # Comma seperate multi select options + if options["type"] == list: # Comma separated multi select options new_opt = ", ".join(new_opt if isinstance(new_opt, list) else new_opt.split()) new_config.set(section, item, str(new_opt)) config.config = new_config diff --git a/lib/gui/popup_session.py b/lib/gui/popup_session.py index e07df3e696..83de2680e8 100644 --- a/lib/gui/popup_session.py +++ b/lib/gui/popup_session.py @@ -240,7 +240,7 @@ def _opts_slider(self, frame): min_max=min_max, helptext=self._set_help(item)) self._vars[item] = slider.tk_var - ControlBuilder(frame, slider, 1, 19, None, True) + ControlBuilder(frame, slider, 1, 19, None, "", True) logger.debug("Built Sliders") def _opts_buttons(self, frame): diff --git a/scripts/gui.py b/scripts/gui.py index 68d56e0c45..c179cf2a5b 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -54,21 +54,26 @@ def set_fonts(self): def set_styles(self): """ Set global custom styles """ gui_style = ttk.Style() - gui_style.configure('H1.TLabel', - font=(self._config.default_font[0], - self._config.default_font[1] + 4, - "bold")) - gui_style.configure('H2.TLabel', - font=(self._config.default_font[0], - self._config.default_font[1] + 2, - "bold")) - - gui_style.configure("CPanel.TLabel", background="#FFFFFF") - gui_style.configure("CPanel.TFrame", background="#FFFFFF") - gui_style.configure("CPanel.TLabelframe", background="#FFFFFF") - gui_style.configure('CPanel.TLabelframe.Label', background="#FFFFFF", foreground="#176087") - gui_style.configure("CPanel.TCheckbutton", background="#FFFFFF") - gui_style.configure("CPanel.TRadiobutton", background="#FFFFFF") + font = self._config.default_font + gui_style.configure('H1.TLabel', font=(font[0], font[1] + 4, "bold")) + gui_style.configure('H2.TLabel', font=(font[0], font[1] + 2, "bold")) + + # Control and settings panel styles + for _type in ("CPanel", "SPanel"): + # Common control panel items + for lbl in ["TLabel", "TFrame", "TLabelframe", "TCheckbutton", "TRadiobutton"]: + gui_style.configure(f"CPanel.{lbl}", background="#FFFFFF") + gui_style.configure(f"SPanel.{lbl}", background="#FFFFFF") + + # Specific items + color = "#176087" if _type == "CPanel" else "#9B1D20" + gui_style.configure(f"{_type}.TLabelframe.Label", + background="#FFFFFF", + foreground=color) + gui_style.configure(f"{_type}.Groupheader.TLabel", + background=color, + foreground="#FFFFFF", + font=(font[0], font[1], "bold")) def build_gui(self, rebuild=False): """ Build the GUI """ From 1c0d1125887661be7fc5a6991b3c651b99cd8800 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 14 Mar 2021 23:16:08 +0000 Subject: [PATCH 409/981] Minor GUI Updates - Fix spacing between buttons on Control Panels - Configurable background on Control Panels - Fix background color of TreeView menu in settings pop up - Change TreeView selected item highlight color - Change console background color to match control panel - Fix unfilled color in control panel background --- lib/gui/control_helper.py | 35 +++++++++++++++++------------------ lib/gui/custom_widgets.py | 2 +- lib/gui/popup_configure.py | 3 ++- scripts/gui.py | 15 ++++++++++++--- 4 files changed, 32 insertions(+), 23 deletions(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 2eb7f893d7..1df7b46dc2 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -396,7 +396,13 @@ def __init__(self, parent, options, # pylint:disable=too-many-arguments self.group_frames = dict() self._sub_group_frames = dict() - self._canvas = tk.Canvas(self, bd=0, highlightthickness=0) + canvas_kwargs = dict(bd=0, highlightthickness=0) + if self._style == "CPanel.": + canvas_kwargs["bg"] = "#CDD3D5" + if self._style == "SPanel.": + canvas_kwargs["bg"] = "#DAD2D8" + + self._canvas = tk.Canvas(self, **canvas_kwargs) self._canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) self.mainframe, self.optsframe = self.get_opts_frame() @@ -413,34 +419,27 @@ def _adjust_wraplength(event): def get_opts_frame(self): """ Return an auto-fill container for the options inside a main frame """ - mainframe = ttk.Frame(self._canvas) + style = f"Holder.{self._style}" + mainframe = ttk.Frame(self._canvas, style=f"{style}TFrame") if self.header_text is not None: self.add_info(mainframe) - optsframe = ttk.Frame(mainframe, name="opts_frame") + optsframe = ttk.Frame(mainframe, name="opts_frame", style=f"{style}TFrame") optsframe.pack(expand=True, fill=tk.BOTH) - holder = AutoFillContainer(optsframe, self.columns, self.max_columns) + holder = AutoFillContainer(optsframe, self.columns, self.max_columns, style=style) logger.debug("Opts frames: '%s'", holder) return mainframe, holder def add_info(self, frame): """ Plugin information """ - gui_style = ttk.Style() - gui_style.configure('White.TFrame', background='#FFFFFF') - gui_style.configure('InfoHeader.TLabel', - background='#FFFFFF', - font=get_config().default_font + ("bold", )) - gui_style.configure('InfoBody.TLabel', - background='#FFFFFF') - - info_frame = ttk.Frame(frame, style='White.TFrame', relief=tk.SOLID) - info_frame.pack(fill=tk.X, side=tk.TOP, expand=True, padx=10, pady=10) - label_frame = ttk.Frame(info_frame, style='White.TFrame') + info_frame = ttk.Frame(frame, style=f"{self._style}TFrame", relief=tk.SOLID) + info_frame.pack(fill=tk.X, side=tk.TOP, expand=True, padx=10, pady=(10, 0)) + label_frame = ttk.Frame(info_frame, style=f"{self._style}TFrame") label_frame.pack(padx=5, pady=5, fill=tk.X, expand=True) for idx, line in enumerate(self.header_text.splitlines()): if not line: continue - style = "InfoHeader.TLabel" if idx == 0 else "InfoBody.TLabel" - info = ttk.Label(label_frame, text=line, style=style, anchor=tk.W) + style = f"InfoHeader.{self._style}" if idx == 0 else f"InfoBody.{self._style}" + info = ttk.Label(label_frame, text=line, style=f"{style}TLabel", anchor=tk.W) info.bind("", self._adjust_wraplength) info.pack(fill=tk.X, padx=0, pady=0, expand=True, side=tk.TOP) @@ -1237,7 +1236,7 @@ def add_browser_buttons(self): bg="#FFFFFF", cursor="hand2") _add_command(fileopn.cget("command"), cmd) - fileopn.pack(padx=0, side=tk.RIGHT) + fileopn.pack(padx=1, side=tk.RIGHT) _get_tooltip(fileopn, text=self.helptext[lbl], wraplength=600) logger.debug("Added browser buttons: (action: %s, filetypes: %s", action, self.filetypes) diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index 37bfcb5fa4..22f75afde3 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -158,7 +158,7 @@ def _set_console_clear_var_trace(self): def _build_console(self): """ Build and place the console and add stdout/stderr redirection """ logger.debug("Build console") - self._console.config(width=100, height=6, bg="gray90", fg="black") + self._console.config(width=100, height=6, bg="#CDD3D5", fg="black") self._console.pack(side=tk.LEFT, anchor=tk.N, fill=tk.BOTH, expand=True) scrollbar = ttk.Scrollbar(self, command=self._console.yview) diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index b367de03fc..42ffb9d3ce 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -287,8 +287,9 @@ def _fix_styles(cls): fix_map = lambda o: [elm for elm in style.map("Treeview", query_opt=o) # noqa if elm[:2] != ("!disabled", "!selected")] style.map("Treeview", foreground=fix_map("foreground"), background=fix_map("background")) + style.map('Treeview', background=[('selected', '#9B1D20')]) # Remove the Borders - style.configure("ConfigNav.Treeview", bd=0) + style.configure("ConfigNav.Treeview", bd=0, background="#F0F0F0") style.layout("ConfigNav.Treeview", [('ConfigNav.Treeview.treearea', {'sticky': 'nswe'})]) def _build_tree(self, parent, configurations, name): diff --git a/scripts/gui.py b/scripts/gui.py index c179cf2a5b..60e994d478 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -62,10 +62,13 @@ def set_styles(self): for _type in ("CPanel", "SPanel"): # Common control panel items for lbl in ["TLabel", "TFrame", "TLabelframe", "TCheckbutton", "TRadiobutton"]: - gui_style.configure(f"CPanel.{lbl}", background="#FFFFFF") - gui_style.configure(f"SPanel.{lbl}", background="#FFFFFF") + gui_style.configure(f"{_type}.{lbl}", background="#FFFFFF") - # Specific items + # Background colors + color = "#CDD3D5" if _type == "CPanel" else "#DAD2D8" + gui_style.configure(f"Holder.{_type}.TFrame", background=color) + + # Highlight Colors color = "#176087" if _type == "CPanel" else "#9B1D20" gui_style.configure(f"{_type}.TLabelframe.Label", background="#FFFFFF", @@ -75,6 +78,12 @@ def set_styles(self): foreground="#FFFFFF", font=(font[0], font[1], "bold")) + # Control Panel Info Box + gui_style.configure(f"InfoHeader.{_type}.TLabel", + background='#FFFFFF', + font=(font[0], font[1], "bold")) + gui_style.configure(f"InfoBody.{_type}.TLabel", background="#FFFFFF") + def build_gui(self, rebuild=False): """ Build the GUI """ logger.debug("Building GUI") From 46edd5e5ae8022b37a447de9d562811505406d22 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 15 Mar 2021 00:38:18 +0000 Subject: [PATCH 410/981] GUI fix - Make grey slider backgrounds go away --- lib/gui/control_helper.py | 5 ++++- scripts/gui.py | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 1df7b46dc2..6da32aa39c 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -1054,7 +1054,10 @@ def slider_control(self): d_type=self.option.dtype, round_to=self.option.rounding, min_max=self.option.min_max) - ctl = ttk.Scale(self.frame, variable=self.option.tk_var, command=cmd) + ctl = ttk.Scale(self.frame, + variable=self.option.tk_var, + command=cmd, + style=f"{self._style}Horizontal.TScale") _add_command(ctl.cget("command"), cmd) rc_menu = _get_contextmenu(tbox) rc_menu.cm_bind() diff --git a/scripts/gui.py b/scripts/gui.py index 60e994d478..6b2c1a113f 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -23,7 +23,7 @@ def __init__(self, debug): self._init_args = dict(debug=debug) self._config = self.initialize_globals() self.set_fonts() - self.set_styles() + self._cache = self.set_styles() self._config.set_geometry(1200, 640, self._config.user_config_dict["fullscreen"]) self.wrapper = ProcessWrapper() @@ -53,6 +53,7 @@ def set_fonts(self): def set_styles(self): """ Set global custom styles """ + cache = [] gui_style = ttk.Style() font = self._config.default_font gui_style.configure('H1.TLabel', font=(font[0], font[1] + 4, "bold")) @@ -84,6 +85,9 @@ def set_styles(self): font=(font[0], font[1], "bold")) gui_style.configure(f"InfoBody.{_type}.TLabel", background="#FFFFFF") + # Scale widgets + gui_style.configure(f"{_type}.Horizontal.TScale", background="#FFFFFF") + def build_gui(self, rebuild=False): """ Build the GUI """ logger.debug("Building GUI") From 8758a29a5ca69fe836e8eb88d83ff26e5ca85ade Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 15 Mar 2021 00:56:33 +0000 Subject: [PATCH 411/981] GUI - Fix slider backgrounds for Linux --- scripts/gui.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/gui.py b/scripts/gui.py index 6b2c1a113f..f048ce257e 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -2,6 +2,7 @@ """ The optional GUI for faceswap """ import logging +import platform import sys import tkinter as tk from tkinter import messagebox, ttk @@ -23,7 +24,7 @@ def __init__(self, debug): self._init_args = dict(debug=debug) self._config = self.initialize_globals() self.set_fonts() - self._cache = self.set_styles() + self.set_styles() self._config.set_geometry(1200, 640, self._config.user_config_dict["fullscreen"]) self.wrapper = ProcessWrapper() @@ -53,7 +54,6 @@ def set_fonts(self): def set_styles(self): """ Set global custom styles """ - cache = [] gui_style = ttk.Style() font = self._config.default_font gui_style.configure('H1.TLabel', font=(font[0], font[1] + 4, "bold")) @@ -86,7 +86,9 @@ def set_styles(self): gui_style.configure(f"InfoBody.{_type}.TLabel", background="#FFFFFF") # Scale widgets - gui_style.configure(f"{_type}.Horizontal.TScale", background="#FFFFFF") + gui_style.configure(f"{_type}.Horizontal.TScale", + background="#FFFFFF" if platform.system() == "Windows" else color, + troughcolor="#FFFFFF") def build_gui(self, rebuild=False): """ Build the GUI """ From cff0f5698b635d890765b07e56bd17d5e2399b67 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 16 Mar 2021 19:46:14 +0000 Subject: [PATCH 412/981] GUI updates - Control panel fixes - Basic themeing support - Fix tools to display more natively - Give all config items a group --- .gitignore | 2 + lib/gui/.cache/themes/default.json | 22 ++ lib/gui/control_helper.py | 127 +++++++---- lib/gui/custom_widgets.py | 6 +- lib/gui/popup_configure.py | 9 +- lib/gui/utils.py | 169 ++++++++++++++- .../convert/color/color_transfer_defaults.py | 64 +++--- plugins/convert/color/match_hist_defaults.py | 28 +-- plugins/convert/mask/box_blend_defaults.py | 1 + plugins/convert/mask/mask_blend_defaults.py | 1 + plugins/convert/scaling/sharpen_defaults.py | 129 ++++++----- plugins/convert/writer/ffmpeg_defaults.py | 2 + plugins/convert/writer/gif_defaults.py | 96 ++++---- plugins/convert/writer/opencv_defaults.py | 109 +++++----- plugins/convert/writer/pillow_defaults.py | 205 +++++++++--------- plugins/extract/_config.py | 2 +- plugins/extract/align/fan_defaults.py | 1 + plugins/extract/detect/cv2_dnn_defaults.py | 28 +-- plugins/extract/detect/mtcnn_defaults.py | 139 ++++++------ plugins/extract/detect/s3fd_defaults.py | 2 + plugins/extract/mask/unet_dfl_defaults.py | 27 +-- plugins/extract/mask/vgg_clear_defaults.py | 27 +-- .../extract/mask/vgg_obstructed_defaults.py | 27 +-- scripts/gui.py | 40 ---- tools/manual/faceviewer/frame.py | 5 +- tools/manual/manual.py | 1 + tools/preview/preview.py | 5 +- 27 files changed, 738 insertions(+), 536 deletions(-) create mode 100644 lib/gui/.cache/themes/default.json diff --git a/.gitignore b/.gitignore index 7d1337fa34..87caff89ef 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,8 @@ !lib/gui !lib/gui/.cache/preview !lib/gui/.cache/icons +!lib/gui/.cache/themes +!lib/gui/.cache/themes/*.json !lib/model/* !scripts !plugins/ diff --git a/lib/gui/.cache/themes/default.json b/lib/gui/.cache/themes/default.json new file mode 100644 index 0000000000..109e138fe7 --- /dev/null +++ b/lib/gui/.cache/themes/default.json @@ -0,0 +1,22 @@ +{ + "group_box": { + "background": "#FFFFFF", + "font_color": "#000000", + "input_color": "#FFFFFF", + "input_font": "#000000" + }, + "control_panel": { + "header_color": "#176087", + "secondary_color": "#CDD3D5", + "tertiary_color": "#75929C" + }, + "settings_popup": { + "header_color": "#9B1D20", + "secondary_color": "#DAD2D8", + "tertiary_color": "#B090A8" + }, + "console": { + "background_color": "#CDD3D5", + "foreground_color": "#000000" + } +} \ No newline at end of file diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 6da32aa39c..c7c89f21e1 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -273,7 +273,7 @@ def get_control(self): elif self.dtype in (int, float): control = "scale" else: - control = ttk.Entry + control = tk.Entry logger.debug("Setting control '%s' to %s", self.title, control) return control @@ -396,11 +396,9 @@ def __init__(self, parent, options, # pylint:disable=too-many-arguments self.group_frames = dict() self._sub_group_frames = dict() - canvas_kwargs = dict(bd=0, highlightthickness=0) - if self._style == "CPanel.": - canvas_kwargs["bg"] = "#CDD3D5" - if self._style == "SPanel.": - canvas_kwargs["bg"] = "#DAD2D8" + lookup = "settings_popup" if self._style.startswith("SPanel") else "control_panel" + theme = get_config().user_theme[lookup] + canvas_kwargs = dict(bd=0, highlightthickness=0, bg=theme["secondary_color"]) self._canvas = tk.Canvas(self, **canvas_kwargs) self._canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) @@ -419,7 +417,7 @@ def _adjust_wraplength(event): def get_opts_frame(self): """ Return an auto-fill container for the options inside a main frame """ - style = f"Holder.{self._style}" + style = f"{self._style}Holder." mainframe = ttk.Frame(self._canvas, style=f"{style}TFrame") if self.header_text is not None: self.add_info(mainframe) @@ -431,15 +429,15 @@ def get_opts_frame(self): def add_info(self, frame): """ Plugin information """ - info_frame = ttk.Frame(frame, style=f"{self._style}TFrame", relief=tk.SOLID) + info_frame = ttk.Frame(frame, style="InfoHeader.TFrame", relief=tk.SOLID) info_frame.pack(fill=tk.X, side=tk.TOP, expand=True, padx=10, pady=(10, 0)) - label_frame = ttk.Frame(info_frame, style=f"{self._style}TFrame") + label_frame = ttk.Frame(info_frame, style="InfoHeader.TFrame") label_frame.pack(padx=5, pady=5, fill=tk.X, expand=True) for idx, line in enumerate(self.header_text.splitlines()): if not line: continue - style = f"InfoHeader.{self._style}" if idx == 0 else f"InfoBody.{self._style}" - info = ttk.Label(label_frame, text=line, style=f"{style}TLabel", anchor=tk.W) + style = "InfoHeader" if idx == 0 else "InfoBody" + info = ttk.Label(label_frame, text=line, style=f"{style}.TLabel", anchor=tk.W) info.bind("", self._adjust_wraplength) info.pack(fill=tk.X, padx=0, pady=0, expand=True, side=tk.TOP) @@ -490,6 +488,9 @@ def get_group_frame(self, group): other group, then will return the ToggledFrame for that group """ group = group.lower() + lookup = "settings_popup" if self._style.startswith("SPanel") else "control_panel" + theme = get_config().user_theme[lookup] + if self.group_frames.get(group, None) is None: logger.debug("Creating new group frame for: %s", group) is_master = group == "_master" @@ -500,9 +501,9 @@ def get_group_frame(self, group): else: group_frame = ToggledFrame(opts_frame, text=group.title(), theme=self._style) retval = group_frame.sub_frame - retval.config(highlightbackground="#176087", - highlightcolor="#176087", - background="#FFFFFF") + retval.config(highlightbackground=theme["header_color"], + highlightcolor=theme["header_color"], + background=theme["secondary_color"]) group_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5, anchor=tk.NW) @@ -537,11 +538,11 @@ def checkbuttons_frame(self, frame): if is_master then check buttons will be placed in a LabelFrame otherwise in a standard frame """ logger.debug("Add Options CheckButtons Frame") - chk_frame = ttk.Frame(frame, name="chkbuttons", style=f"{self._style}TFrame") + chk_frame = ttk.Frame(frame, name="chkbuttons", style="Group.TFrame") holder = AutoFillContainer(chk_frame, self.option_columns, self.option_columns, - style=self._style) + style="Group.") logger.debug("Added Options CheckButtons Frame") return holder @@ -549,11 +550,11 @@ def _get_subgroup_frame(self, parent, subgroup): if subgroup is None: return subgroup if subgroup not in self._sub_group_frames: - sub_frame = ttk.Frame(parent, style=f"{self._style}TFrame") + sub_frame = ttk.Frame(parent, style="Group.TFrame") self._sub_group_frames[subgroup] = AutoFillContainer(sub_frame, self.option_columns, self.option_columns, - style=self._style) + style="Group.") sub_frame.pack(anchor=tk.W, expand=True, fill=tk.X) logger.debug("Added Subgroup Frame: %s", subgroup) return self._sub_group_frames[subgroup] @@ -897,7 +898,8 @@ def __init__(self, parent, option, option_columns, # pylint: disable=too-many-a self.helpset = False self.label_width = label_width self.filebrowser = None - self._style = style + # Default to Control Panel Style + self._style = style = style if style else "CPanel." self.frame = self.control_frame(parent) self.chkbtns = checkbuttons_frame @@ -912,7 +914,7 @@ def control_frame(self, parent): logger.debug("Build control frame") frame = ttk.Frame(parent, name="fr_{}".format(self.option.name), - style=f"{self._style}TFrame") + style="Group.TFrame") frame.pack(fill=tk.X) logger.debug("Built control frame") return frame @@ -940,7 +942,7 @@ def build_control_label(self): text=self.option.title, width=self.label_width, anchor=tk.W, - style=f"{self._style}TLabel") + style="Group.TLabel") lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) if self.option.helptext is not None: _get_tooltip(lbl, text=self.option.helptext, wraplength=600) @@ -985,14 +987,19 @@ def _multi_option_control(self, option_type): ctl = ttk.LabelFrame(self.frame, text=self.option.title, name="{}_labelframe".format(option_type), - style=f"{self._style}TLabelframe") + style=f"{self._style}Group.TLabelframe") holder = AutoFillContainer(ctl, self.option_columns, self.option_columns, - style=self._style) + style="Group.") for choice in self.option.choices: - ctl = ttk.Radiobutton if option_type == "radio" else MultiOption - style = f"{self._style}T{'Radiobutton' if option_type == 'radio' else 'Checkbutton'}" + if option_type == "radio": + ctl = ttk.Radiobutton + style = "Group.TRadiobutton" + else: + ctl = MultiOption + style = "Group.TCheckbutton" + ctl = ctl(holder.subframe, text=choice.replace("_", " ").title(), value=choice, @@ -1041,13 +1048,19 @@ def slider_control(self): self.option.rounding, self.option.min_max) validate = self.slider_check_int if self.option.dtype == int else self.slider_check_float vcmd = (self.frame.register(validate)) - tbox = ttk.Entry(self.frame, - width=8, - textvariable=self.option.tk_var, - justify=tk.RIGHT, - font=get_config().default_font, - validate="all", - validatecommand=(vcmd, "%P")) + theme = get_config().user_theme["group_box"] + tbox = tk.Entry(self.frame, + width=8, + textvariable=self.option.tk_var, + justify=tk.RIGHT, + font=get_config().default_font, + validate="all", + validatecommand=(vcmd, "%P"), + bg=theme["input_color"], + fg=theme["input_font"], + highlightbackground=theme["input_font"], + highlightthickness=1, + bd=0) tbox.pack(padx=(0, 5), side=tk.RIGHT) cmd = partial(set_slider_rounding, var=self.option.tk_var, @@ -1102,11 +1115,35 @@ def control_to_optionsframe(self): self.filebrowser = FileBrowser(self.option.name, self.option.tk_var, self.frame, - self.option.sysbrowser) + self.option.sysbrowser, + self._style) + + theme = get_config().user_theme["group_box"] + if self.option.control == tk.Entry: + ctl = self.option.control(self.frame, + textvariable=self.option.tk_var, + font=get_config().default_font, + bg=theme["input_color"], + fg=theme["input_font"], + highlightbackground=theme["input_font"], + highlightthickness=1, + bd=0) + else: # Combobox + ctl = self.option.control(self.frame, + textvariable=self.option.tk_var, + font=get_config().default_font, + state="readonly", + style="Group.TCombobox") + + # Style for combo list boxes needs to be set directly on widget as no style parameter + key = "settings_popup" if self._style.startswith("SPanel") else "control_panel" + select_key = get_config().user_theme[key] + cmd = f"[ttk::combobox::PopdownWindow {ctl}].f.l configure -" + ctl.tk.eval(f"{cmd}foreground {theme['font_color']}") + ctl.tk.eval(f"{cmd}background {theme['background']}") + ctl.tk.eval(f"{cmd}selectforeground {select_key['header_color']}") + ctl.tk.eval(f"{cmd}selectbackground {select_key['secondary_color']}") - ctl = self.option.control(self.frame, - textvariable=self.option.tk_var, - font=get_config().default_font) rc_menu = _get_contextmenu(ctl) rc_menu.cm_bind() @@ -1121,7 +1158,7 @@ def _color_control(self): """ Clickable label holding the currently selected color """ logger.debug("Add control to Options Frame: (widget: '%s', control: %s, choices: %s)", self.option.name, self.option.control, self.option.choices) - frame = ttk.Frame(self.frame) + frame = ttk.Frame(self.frame, style="Group.TFrame") ctl = tk.Frame(frame, bg=self.option.default, bd=2, @@ -1135,7 +1172,7 @@ def _color_control(self): text=self.option.title, width=self.label_width, anchor=tk.W, - style=f"{self._style}TLabel") + style="Group.TLabel") lbl.pack(padx=2, pady=5, side=tk.RIGHT, anchor=tk.N) frame.pack(side=tk.LEFT, anchor=tk.W) if self.option.helptext is not None: @@ -1160,7 +1197,7 @@ def control_to_checkframe(self): variable=self.option.tk_var, text=self.option.title, name=self.option.name, - style=f"{self._style}TCheckbutton") + style="Group.TCheckbutton") _get_tooltip(ctl, text=self.option.helptext, wraplength=600) ctl.pack(side=tk.TOP, anchor=tk.W, fill=tk.X) logger.debug("Added control checkframe: '%s'", self.option.name) @@ -1169,12 +1206,14 @@ def control_to_checkframe(self): class FileBrowser(): """ Add FileBrowser buttons to control and handle routing """ - def __init__(self, opt_name, tk_var, control_frame, sysbrowser_dict): - logger.debug("Initializing: %s: (tk_var: %s, control_frame: %s, sysbrowser_dict: %s)", - self.__class__.__name__, tk_var, control_frame, sysbrowser_dict) + def __init__(self, opt_name, tk_var, control_frame, sysbrowser_dict, style): + logger.debug("Initializing: %s: (tk_var: %s, control_frame: %s, sysbrowser_dict: %s, " + "style: %s)", self.__class__.__name__, tk_var, control_frame, + sysbrowser_dict, style) self._opt_name = opt_name self.tk_var = tk_var self.frame = control_frame + self._style = style self.browser = sysbrowser_dict["browser"] self.filetypes = sysbrowser_dict["filetypes"] self.action_option = self.format_action_option(sysbrowser_dict.get("action_option", None)) @@ -1211,7 +1250,7 @@ def format_action_option(action_option): def add_browser_buttons(self): """ Add correct file browser button for control """ logger.debug("Adding browser buttons: (sysbrowser: %s", self.browser) - frame = ttk.Frame(self.frame) + frame = ttk.Frame(self.frame, style="Group.TFrame") frame.pack(side=tk.RIGHT, padx=(0, 5)) for browser in self.browser: @@ -1236,7 +1275,7 @@ def add_browser_buttons(self): command=cmd, relief=tk.SOLID, bd=1, - bg="#FFFFFF", + bg=get_config().user_theme["group_box"]["background"], cursor="hand2") _add_command(fileopn.cget("command"), cmd) fileopn.pack(padx=1, side=tk.RIGHT) diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index 22f75afde3..7799cc031e 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -140,6 +140,7 @@ def __init__(self, parent, debug): super().__init__(parent) self.pack(side=tk.TOP, anchor=tk.W, padx=10, pady=(2, 0), fill=tk.BOTH, expand=True) + self._theme = get_config().user_theme["console"] self._console = _ReadOnlyText(self) rc_menu = ContextMenu(self._console) rc_menu.cm_bind() @@ -158,7 +159,10 @@ def _set_console_clear_var_trace(self): def _build_console(self): """ Build and place the console and add stdout/stderr redirection """ logger.debug("Build console") - self._console.config(width=100, height=6, bg="#CDD3D5", fg="black") + self._console.config(width=100, + height=6, + bg=self._theme["background_color"], + fg=self._theme["foreground_color"]) self._console.pack(side=tk.LEFT, anchor=tk.N, fill=tk.BOTH, expand=True) scrollbar = ttk.Scrollbar(self, command=self._console.yview) diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 42ffb9d3ce..5e48d7d317 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -169,7 +169,7 @@ def _build_header(self): lbl_header = ttk.Label(lbl_frame, textvariable=self._tk_vars["header"], anchor=tk.W, - style="H1.TLabel") + style=".SPanel.Header1.TLabel") lbl_header.pack(fill=tk.X, expand=True, side=tk.LEFT) sep = ttk.Frame(header_frame, height=2, relief=tk.RIDGE) @@ -283,11 +283,12 @@ def _fix_styles(cls): We also set some default styles for our tree view. """ + theme = get_config().user_theme["settings_popup"] style = ttk.Style() fix_map = lambda o: [elm for elm in style.map("Treeview", query_opt=o) # noqa if elm[:2] != ("!disabled", "!selected")] style.map("Treeview", foreground=fix_map("foreground"), background=fix_map("background")) - style.map('Treeview', background=[('selected', '#9B1D20')]) + style.map('Treeview', background=[('selected', theme["header_color"])]) # Remove the Borders style.configure("ConfigNav.Treeview", bd=0, background="#F0F0F0") style.layout("ConfigNav.Treeview", [('ConfigNav.Treeview.treearea', {'sticky': 'nswe'})]) @@ -439,7 +440,7 @@ def _build_header(self): """ Build the dynamic header text. """ header_frame = ttk.Frame(self) var = tk.StringVar() - lbl = ttk.Label(header_frame, textvariable=var, anchor=tk.W, style="H2.TLabel") + lbl = ttk.Label(header_frame, textvariable=var, anchor=tk.W, style="SPanel.Header2.TLabel") lbl.pack(fill=tk.X, expand=True, side=tk.TOP) header_frame.pack(fill=tk.X, padx=5, pady=(5, 0), side=tk.TOP) self._vars["header"] = var @@ -522,7 +523,7 @@ def _create_links_page(self, key): lbl = ttk.Label(frame, text=link.replace("_", " ").title(), anchor=tk.W, - foreground="blue", + foreground=get_config().user_theme["settings_popup"]["header_color"], cursor="hand2") lbl.pack(side=tk.TOP, fill=tk.X, padx=10, pady=(0, 5)) bind = "{}|{}".format(key, link) diff --git a/lib/gui/utils.py b/lib/gui/utils.py index c62f0a384c..0e7bc48372 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -6,7 +6,7 @@ import sys import tkinter as tk -from tkinter import filedialog +from tkinter import filedialog, ttk from threading import Event, Thread from queue import Queue @@ -14,6 +14,8 @@ from PIL import Image, ImageDraw, ImageTk +from lib.serializer import get_serializer + from ._config import Config as UserConfig from .project import Project, Tasks @@ -809,6 +811,8 @@ def __init__(self, root, cli_opts, statusbar): status_bar=statusbar, command_notebook=None) # set in command.py self._user_config = UserConfig(None) + self._style = _Style(self.default_font, root) + self._user_theme = self._style.user_theme logger.debug("Initialized %s", self.__class__.__name__) # Constants @@ -896,6 +900,11 @@ def user_config_dict(self): """ dict: The GUI config in dict form. """ return self._user_config.config_dict + @property + def user_theme(self): + """ dict: The GUI theme selection options. """ + return self._user_theme + @property def default_font(self): """ tuple: The selected font as configured in user settings. First item is the font (`str`) @@ -1104,6 +1113,164 @@ def set_geometry(self, width, height, fullscreen=False): logger.debug("Geometry: %sx%s", *initial_dimensions) +class _Style(): # pylint:disable=too-few-public-methods + """ Set the overarching theme and customize widgets""" + def __init__(self, default_font, root): + self._image_cache = [] + self._root = root + self._font = default_font + default = os.path.join(PATHCACHE, "themes", "default.json") + self._user_theme = get_serializer("json").load(default) + self._style = ttk.Style() + self._set_styles() + + @property + def user_theme(self): + """ dict: The currently selected user theme. """ + return self._user_theme + + def _config_settings_group(self): + """ Configures the style of the control panel entry boxes. Used for inputting Faceswap + options or controlling plugin settings. """ + self._config_settings_group_common() + self._config_settings_group_unique() + + def _config_settings_group_common(self): + """ Configures the group items that remain consistent, regardless of section. """ + # Info Box + theme = self._user_theme["group_box"] + self._style.configure("InfoHeader.TFrame", background=theme["background"]) + self._style.configure("InfoHeader.TLabel", + background=theme["background"], + foreground=theme["font_color"], + font=(self._font[0], self._font[1], "bold")) + self._style.configure("InfoBody.TLabel", + background=theme["background"], + foreground=theme["font_color"]) + + # Background and Foreground of widgets and labels + for lbl in ["TLabel", "TFrame", "TLabelframe", "TCheckbutton", "TRadiobutton", + "TLabelframe.Label"]: + self._style.configure(f"Group.{lbl}", + background=theme["background"], + foreground=theme["font_color"]) + # Combobox + self._config_settings_group_common_combobox() + + def _config_settings_group_common_combobox(self): + """ Combo-boxes are fairly complex to style. """ + theme = self._user_theme["group_box"] + # Create a clone from clam theme + self._style.element_create("Group.TCombobox.field", "from", "clam") + # Set a layout so we can access required params + self._style.layout("Group.TCombobox", [ + ("Group.TCombobox.field", { + "children": [ + ("Combobox.downarrow", {"side": "right", "sticky": "ns"}), + ("Combobox.padding", { + "expand": "1", + "sticky": "nswe", + "children": [("Combobox.focus", { + "expand": "1", + "sticky": "nswe", + "children": [("Combobox.textarea", {"sticky": "nswe"})]})]})], + "sticky": "nswe"})]) + + # Foreground + self._style.configure("Group.TCombobox", foreground=theme["font_color"]) + self._style.configure("Group.TCombobox", selectforeground=theme["font_color"]) + # Background + self._style.configure("Group.TCombobox", background=theme["background"]) + self._style.configure("Group.TCombobox", selectbackground=theme["background"]) + self._style.map("Group.TCombobox", fieldbackground=[("readonly", theme["background"])]) + self._style.configure("Group.TCombobox", fieldbackground=theme["background"]) + + def _config_settings_group_unique(self): + """ Configures the group items that change depending on section. These are the section + highlight colors. + + These are the header labels on Label Frames, the Group header boxes and the slider color. + """ + # Control and settings panel styles + for section in ("control_panel", "settings_popup"): + key = "CPanel" if section == "control_panel" else "SPanel" + theme = self._user_theme[section] + + # Background colors + self._style.configure(f"{key}.Holder.TFrame", background=theme["secondary_color"]) + + # Highlight Colors + self._style.configure(f"{key}.Group.TLabelframe.Label", + foreground=theme["header_color"]) + self._style.configure(f"{key}.Groupheader.TLabel", + background=theme["header_color"], + foreground=self._user_theme["group_box"]["background"], + font=(self._font[0], self._font[1], "bold")) + + self._config_settings_group_slider(key, theme) + + @classmethod + def _set_img_color(cls, img, color): + """Change color of PhotoImage image.""" + pixel_line = "{" + " ".join(color for i in range(img.width())) + "}" + pixels = " ".join(pixel_line for i in range(img.height())) + img.put(pixels) + + def _config_settings_group_slider(self, key, theme): + """ Take a copy of the default ttk.Scale widget and replace the slider element with a + version we can control the color and shape of. + + Parameters + ---------- + key: str + The section that the slider will belong to + theme: dict + The user configuration theme options + """ + self._image_cache.extend([tk.PhotoImage(width=10, height=25), + tk.PhotoImage(width=10, height=25)]) + img_slider, img_slider_alt = self._image_cache[-2:] + self._set_img_color(img_slider, theme["tertiary_color"]) + self._set_img_color(img_slider_alt, theme["header_color"]) + + self._style.element_create(f"{key}.Horizontal.Scale.trough", "from", "alt") + self._style.element_create(f"{key}.Horizontal.Scale.slider", + "image", + img_slider, + ("active", img_slider_alt)) + + self._style.layout( + f"{key}.Horizontal.TScale", + [(f"{key}.Scale.focus", { + "expand": "1", + "sticky": "nswe", + "children": [ + (f"{key}.Horizontal.Scale.trough", { + "expand": "1", + "sticky": "nswe", + "children": [ + (f"{key}.Horizontal.Scale.track", {"sticky": "we"}), + (f"{key}.Horizontal.Scale.slider", {"side": "left", "sticky": ""}) + ] + }) + ] + })]) + + self._style.configure(f"{key}.Horizontal.TScale", + background=self._user_theme["group_box"]["background"], + groovewidth=4, + troughcolor=self._user_theme["group_box"]["background"]) + + def _set_styles(self): + """ Configure widget theme and styles """ + self._config_settings_group() + # Settings Popup + self._style.configure("SPanel.Header1.TLabel", + font=(self._font[0], self._font[1] + 4, "bold")) + self._style.configure("SPanel.Header2.TLabel", + font=(self._font[0], self._font[1] + 2, "bold")) + + class LongRunningTask(Thread): """ Runs long running tasks in a background thread to prevent the GUI from becoming unresponsive. diff --git a/plugins/convert/color/color_transfer_defaults.py b/plugins/convert/color/color_transfer_defaults.py index 0944c60f6e..8e1193db0b 100755 --- a/plugins/convert/color/color_transfer_defaults.py +++ b/plugins/convert/color/color_transfer_defaults.py @@ -49,35 +49,35 @@ ) -_DEFAULTS = { - "clip": { - "default": True, - "info": "Should components of L*a*b* image be scaled by np.clip before converting " - "back to BGR color space?\nIf False then components will be min-max scaled " - "appropriately.\nClipping will keep target image brightness truer to the " - "input.\nScaling will adjust image brightness to avoid washed out portions in " - "the resulting color transfer that can be caused by clipping.", - "datatype": bool, - "group": "method", - "rounding": None, - "min_max": None, - "choices": [], - "gui_radio": False, - "fixed": True, - }, - "preserve_paper": { - "default": True, - "info": "Should color transfer strictly follow methodology layed out in original " - "paper?\nThe method does not always produce aesthetically pleasing results.\n" - "If False then L*a*b* components will be scaled using the reciprocal of the " - "scaling factor proposed in the paper. This method seems to produce more " - "consistently aesthetically pleasing results.", - "datatype": bool, - "group": "method", - "rounding": None, - "min_max": None, - "choices": [], - "gui_radio": False, - "fixed": True, - }, -} +_DEFAULTS = dict( + clip=dict( + default=True, + info="Should components of L*a*b* image be scaled by np.clip before converting back to " + "BGR color space?\nIf False then components will be min-max scaled appropriately.\n" + "Clipping will keep target image brightness truer to the input.\nScaling will adjust " + "image brightness to avoid washed out portions in the resulting color transfer that " + "can be caused by clipping.", + datatype=bool, + group="method", + rounding=None, + min_max=None, + choices=[], + gui_radio=False, + fixed=True, + ), + preserve_paper=dict( + default=True, + info="Should color transfer strictly follow methodology layed out in original paper?\nThe " + "method does not always produce aesthetically pleasing results.\nIf False then " + "L*a*b* components will be scaled using the reciprocal of the scaling factor " + "proposed in the paper. This method seems to produce more consistently aesthetically " + "pleasing results.", + datatype=bool, + group="method", + rounding=None, + min_max=None, + choices=[], + gui_radio=False, + fixed=True, + ), +) diff --git a/plugins/convert/color/match_hist_defaults.py b/plugins/convert/color/match_hist_defaults.py index 3cbea17313..fb733e0a44 100755 --- a/plugins/convert/color/match_hist_defaults.py +++ b/plugins/convert/color/match_hist_defaults.py @@ -44,17 +44,17 @@ _HELPTEXT = "Options for matching the histograms between the source and destination faces" -_DEFAULTS = { - "threshold": { - "default": 99.0, - "info": "Adjust the threshold for histogram matching. Can reduce extreme colors " - "leaking in by filtering out colors at the extreme ends of the histogram " - "spectrum.", - "datatype": float, - "rounding": 1, - "min_max": (90.0, 100.0), - "choices": [], - "gui_radio": False, - "fixed": True, - } -} +_DEFAULTS = dict( + threshold=dict( + default=99.0, + info="Adjust the threshold for histogram matching. Can reduce extreme colors leaking in " + "by filtering out colors at the extreme ends of the histogram spectrum.", + datatype=float, + rounding=1, + min_max=(90.0, 100.0), + choices=[], + gui_radio=False, + group="settings", + fixed=True, + ) +) diff --git a/plugins/convert/mask/box_blend_defaults.py b/plugins/convert/mask/box_blend_defaults.py index 5dc98a565d..f19698874e 100755 --- a/plugins/convert/mask/box_blend_defaults.py +++ b/plugins/convert/mask/box_blend_defaults.py @@ -56,6 +56,7 @@ min_max=None, choices=["gaussian", "normalized", "none"], gui_radio=True, + group="Blending type", fixed=True, ), distance=dict( diff --git a/plugins/convert/mask/mask_blend_defaults.py b/plugins/convert/mask/mask_blend_defaults.py index e6ed91f03f..24a9ee26e1 100755 --- a/plugins/convert/mask/mask_blend_defaults.py +++ b/plugins/convert/mask/mask_blend_defaults.py @@ -56,6 +56,7 @@ min_max=None, choices=["gaussian", "normalized", "none"], gui_radio=True, + group="Blending type", fixed=True, ), kernel_size=dict( diff --git a/plugins/convert/scaling/sharpen_defaults.py b/plugins/convert/scaling/sharpen_defaults.py index 802adabe32..22a9d0a840 100755 --- a/plugins/convert/scaling/sharpen_defaults.py +++ b/plugins/convert/scaling/sharpen_defaults.py @@ -44,69 +44,66 @@ _HELPTEXT = "Options for sharpening the face after placement" -_DEFAULTS = { - "method": { - "default": "none", - "info": "The type of sharpening to use:" - "\n\t none: Don't perform any sharpening." - "\n\t box: Fastest, but weakest method. Uses a box filter to assess edges." - "\n\t gaussian: Slower, but better than box. Uses a gaussian filter to assess " - "edges." - "\n\t unsharp-mask: Slowest, but most tweakable. Uses the unsharp-mask method " - "to assess edges.", - "datatype": str, - "rounding": None, - "min_max": None, - "choices": ["none", "box", "gaussian", "unsharp_mask"], - "gui_radio": True, - "fixed": True, - }, - "amount": { - "default": 150, - "info": "Percentage that controls the magnitude of each overshoot (how much darker " - "and how much lighter the edge borders become).\nThis can also be thought of " - "as how much contrast is added at the edges. It does not affect the width of " - "the edge rims.", - "datatype": int, - "rounding": 1, - "min_max": (100, 500), - "choices": [], - "gui_radio": False, - "group": "settings", - "fixed": True, - }, - "radius": { - "default": 0.3, - "info": "Affects the size of the edges to be enhanced or how wide the edge rims " - "become, so a smaller radius enhances smaller-scale detail.\nRadius is set as " - "a percentage of the final frame width and rounded to the nearest pixel. E.g " - "for a 1280 width frame, a 0.6 percenatage will give a radius of 8px.\nHigher " - "radius values can cause halos at the edges, a detectable faint light rim " - "around objects. Fine detail needs a smaller radius. \nRadius and amount " - "interact; reducing one allows more of the other.", - "datatype": float, - "rounding": 1, - "min_max": (0.1, 5.0), - "choices": [], - "gui_radio": False, - "group": "settings", - "fixed": True, - }, - "threshold": { - "default": 5.0, - "info": "[unsharp_mask only] Controls the minimal brightness change that will be " - "sharpened or how far apart adjacent tonal values have to be before the " - "filter does anything.\nThis lack of action is important to prevent smooth " - "areas from becoming speckled. The threshold setting can be used to sharpen " - "more pronounced edges, while leaving subtler edges untouched. \nLow values " - "should sharpen more because fewer areas are excluded. \nHigher threshold " - "values exclude areas of lower contrast.", - "datatype": float, - "rounding": 1, - "min_max": (1.0, 10.0), - "choices": [], - "gui_radio": False, - "group": "settings", - "fixed": True, - }, -} +_DEFAULTS = dict( + method=dict( + default="none", + info="The type of sharpening to use:" + "\n\t none: Don't perform any sharpening." + "\n\t box: Fastest, but weakest method. Uses a box filter to assess edges." + "\n\t gaussian: Slower, but better than box. Uses a gaussian filter to assess edges." + "\n\t unsharp-mask: Slowest, but most tweakable. Uses the unsharp-mask method to " + "assess edges.", + datatype=str, + rounding=None, + min_max=None, + choices=["none", "box", "gaussian", "unsharp_mask"], + gui_radio=True, + group="sharpen type", + fixed=True, + ), + amount=dict( + default=150, + info="Percentage that controls the magnitude of each overshoot (how much darker and how " + "much lighter the edge borders become).\nThis can also be thought of as how much " + "contrast is added at the edges. It does not affect the width of the edge rims.", + datatype=int, + rounding=1, + min_max=(100, 500), + choices=[], + gui_radio=False, + group="settings", + fixed=True, + ), + radius=dict( + default=0.3, + info="Affects the size of the edges to be enhanced or how wide the edge rims become, so a " + "smaller radius enhances smaller-scale detail.\nRadius is set as a percentage of the " + "final frame width and rounded to the nearest pixel. E.g for a 1280 width frame, a " + "0.6 percenatage will give a radius of 8px.\nHigher radius values can cause halos at " + "the edges, a detectable faint light rim around objects. Fine detail needs a smaller " + "radius. \nRadius and amount interact; reducing one allows more of the other.", + datatype=float, + rounding=1, + min_max=(0.1, 5.0), + choices=[], + gui_radio=False, + group="settings", + fixed=True, + ), + threshold=dict( + default=5.0, + info="[unsharp_mask only] Controls the minimal brightness change that will be sharpened " + "or how far apart adjacent tonal values have to be before the filter does anything.\n" + "This lack of action is important to prevent smooth areas from becoming speckled. " + "The threshold setting can be used to sharpen more pronounced edges, while leaving " + "subtler edges untouched. \nLow values should sharpen more because fewer areas are " + "excluded. \nHigher threshold values exclude areas of lower contrast.", + datatype=float, + rounding=1, + min_max=(1.0, 10.0), + choices=[], + gui_radio=False, + group="settings", + fixed=True, + ), +) diff --git a/plugins/convert/writer/ffmpeg_defaults.py b/plugins/convert/writer/ffmpeg_defaults.py index 4063048411..163d1b7a05 100755 --- a/plugins/convert/writer/ffmpeg_defaults.py +++ b/plugins/convert/writer/ffmpeg_defaults.py @@ -52,6 +52,7 @@ rounding=None, min_max=None, choices=["avi", "flv", "mkv", "mov", "mp4", "mpeg", "webm"], + group="codec", gui_radio=True, ), codec=dict( @@ -63,6 +64,7 @@ rounding=None, min_max=None, choices=["libx264", "libx265"], + group="codec", gui_radio=True, ), crf=dict( diff --git a/plugins/convert/writer/gif_defaults.py b/plugins/convert/writer/gif_defaults.py index 800fff10c7..ad342b6bee 100755 --- a/plugins/convert/writer/gif_defaults.py +++ b/plugins/convert/writer/gif_defaults.py @@ -44,51 +44,51 @@ _HELPTEXT = "Options for outputting converted frames to an animated gif." -_DEFAULTS = { - "fps": { - "default": 25, - "info": "Frames per Second.", - "datatype": int, - "rounding": 1, - "min_max": (1, 60), - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - }, - "loop": { - "default": 0, - "info": "The number of iterations. Set to 0 to loop indefinitely.", - "datatype": int, - "rounding": 1, - "min_max": (0, 100), - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - }, - "palettesize": { - "default": "256", - "info": "The number of colors to quantize the image to. Is rounded to the nearest " - "power of two.", - "datatype": str, - "rounding": None, - "min_max": None, - "choices": ["2", "4", "8", "16", "32", "64", "128", "256"], - "group": "settings", - "gui_radio": False, - "fixed": True, - }, - "subrectangles": { - "default": False, - "info": "If True, will try and optimize the GIF by storing only the rectangular parts " - "of each frame that change with respect to the previous.", - "datatype": bool, - "rounding": None, - "min_max": None, - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - }, -} +_DEFAULTS = dict( + fps=dict( + default=25, + info="Frames per Second.", + datatype=int, + rounding=1, + min_max=(1, 60), + choices=[], + group="settings", + gui_radio=False, + fixed=True, + ), + loop=dict( + default=0, + info="The number of iterations. Set to 0 to loop indefinitely.", + datatype=int, + rounding=1, + min_max=(0, 100), + choices=[], + group="settings", + gui_radio=False, + fixed=True, + ), + palettesize=dict( + default="256", + info="The number of colors to quantize the image to. Is rounded to the nearest power of " + "two.", + datatype=str, + rounding=None, + min_max=None, + choices=["2", "4", "8", "16", "32", "64", "128", "256"], + group="settings", + gui_radio=False, + fixed=True, + ), + subrectangles=dict( + default=False, + info="If True, will try and optimize the GIF by storing only the rectangular parts of " + "each frame that change with respect to the previous.", + datatype=bool, + rounding=None, + min_max=None, + choices=[], + group="settings", + gui_radio=False, + fixed=True, + ), +) diff --git a/plugins/convert/writer/opencv_defaults.py b/plugins/convert/writer/opencv_defaults.py index b2f6c9d29c..c1bd96368b 100755 --- a/plugins/convert/writer/opencv_defaults.py +++ b/plugins/convert/writer/opencv_defaults.py @@ -48,57 +48,58 @@ ) -_DEFAULTS = { - "format": { - "default": "png", - "info": "Image format to use:" - "\n\t bmp: Windows bitmap" - "\n\t jpg: JPEG format" - "\n\t jp2: JPEG 2000 format" - "\n\t png: Portable Network Graphics" - "\n\t ppm: Portable Pixmap Format", - "datatype": str, - "rounding": None, - "min_max": None, - "choices": ["bmp", "jpg", "jp2", "png", "ppm"], - "gui_radio": True, - "fixed": True, - }, - "draw_transparent": { - "default": False, - "info": "Place the swapped face on a transparent layer rather than the original frame." - "\nNB: This is only compatible with images saved in png format. If an " - "incompatible format is selected then the image will be saved as a png.", - "datatype": bool, - "rounding": None, - "min_max": None, - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - }, - "jpg_quality": { - "default": 75, - "info": "[jpg only] Set the jpg quality. 1 is worst 95 is best. Higher quality leads " - "to larger file sizes.", - "datatype": int, - "rounding": 1, - "min_max": (1, 95), - "choices": [], - "group": "compression", - "gui_radio": False, - "fixed": True, - }, - "png_compress_level": { - "default": 3, - "info": "[png only] ZLIB compression level, 1 gives best speed, 9 gives best " - "compression, 0 gives no compression at all.", - "datatype": int, - "rounding": 1, - "min_max": (0, 9), - "choices": [], - "group": "compression", - "gui_radio": False, - "fixed": True, - }, -} +_DEFAULTS = dict( + format=dict( + default="png", + info="Image format to use:" + "\n\t bmp: Windows bitmap" + "\n\t jpg: JPEG format" + "\n\t jp2: JPEG 2000 format" + "\n\t png: Portable Network Graphics" + "\n\t ppm: Portable Pixmap Format", + datatype=str, + rounding=None, + min_max=None, + choices=["bmp", "jpg", "jp2", "png", "ppm"], + group="format", + gui_radio=True, + fixed=True, + ), + draw_transparent=dict( + default=False, + info="Place the swapped face on a transparent layer rather than the original frame.\nNB: " + "This is only compatible with images saved in png format. If an incompatible format " + "is selected then the image will be saved as a png.", + datatype=bool, + rounding=None, + min_max=None, + choices=[], + group="format", + gui_radio=False, + fixed=True, + ), + jpg_quality=dict( + default=75, + info="[jpg only] Set the jpg quality. 1 is worst 95 is best. Higher quality leads to " + "larger file sizes.", + datatype=int, + rounding=1, + min_max=(1, 95), + choices=[], + group="compression", + gui_radio=False, + fixed=True, + ), + png_compress_level=dict( + default=3, + info="[png only] ZLIB compression level, 1 gives best speed, 9 gives best compression, 0 " + "gives no compression at all.", + datatype=int, + rounding=1, + min_max=(0, 9), + choices=[], + group="compression", + gui_radio=False, + fixed=True, + ), +) diff --git a/plugins/convert/writer/pillow_defaults.py b/plugins/convert/writer/pillow_defaults.py index 4169b62126..d6f17bf936 100755 --- a/plugins/convert/writer/pillow_defaults.py +++ b/plugins/convert/writer/pillow_defaults.py @@ -8,14 +8,14 @@ The following variables should be defined: _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: + _DEFAULTS: A dict(ionary containing the options, defaults and meta information. The + dict(ionary should be defined as: {: {}} should always be lower text. - dictionary requirements are listed below. + dict(ionary requirements are listed below. - The following keys are expected for the _DEFAULTS dict: + The following keys are expected for the _DEFAULTS dict(: datatype: [required] A python type class. This limits the type of data that can be provided in the .ini file and ensures that the value is returned in the correct type to faceswap. Valid datatypes are: , , @@ -47,106 +47,97 @@ ) -_DEFAULTS = { - "format": { - "default": "png", - "info": "Image format to use:" - "\n\t bmp: Windows bitmap" - "\n\t gif: Graphics Interchange Format (NB: Not animated)" - "\n\t jpg: JPEG format" - "\n\t jp2: JPEG 2000 format" - "\n\t png: Portable Network Graphics" - "\n\t ppm: Portable Pixmap Format" - "\n\t tif: Tag Image File Format", - "datatype": str, - "rounding": None, - "min_max": None, - "choices": ["bmp", "gif", "jpg", "jp2", "png", "ppm", "tif"], - "gui_radio": True, - "fixed": True, - }, - "draw_transparent": { - "default": False, - "info": "Place the swapped face on a transparent layer rather than the original frame." - "\nNB: This is only compatible with images saved in png or tif format. If an " - "incompatible format is selected then the image will be saved as a png.", - "datatype": bool, - "rounding": None, - "min_max": None, - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - }, - "optimize": { - "default": False, - "info": "[gif, jpg and png only] If enabled, indicates that the encoder should make " - "an extra pass over the image in order to select optimal encoder settings.", - "datatype": bool, - "rounding": None, - "min_max": None, - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - }, - "gif_interlace": { - "default": True, - "info": "[gif only] Set whether to save the gif as interlaced or not.", - "datatype": bool, - "rounding": None, - "min_max": None, - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - }, - "jpg_quality": { - "default": 75, - "info": "[jpg only] Set the jpg quality. 1 is worst 95 is best. Higher quality leads " - "to larger file sizes.", - "datatype": int, - "rounding": 1, - "min_max": (1, 95), - "choices": [], - "group": "compression", - "gui_radio": False, - "fixed": True, - }, - "png_compress_level": { - "default": 3, - "info": "[png only] ZLIB compression level, 1 gives best speed, 9 gives best " - "compression, 0 gives no compression at all. When optimize option is set to " - "True this has no effect (it is set to 9 regardless of a value passed).", - "datatype": int, - "rounding": 1, - "min_max": (0, 9), - "choices": [], - "group": "compression", - "gui_radio": False, - "fixed": True, - }, - "tif_compression": { - "default": "tiff_deflate", - "info": "[tif only] The desired compression method for the file.", - "datatype": str, - "rounding": None, - "min_max": None, - "choices": [ - "none", - "tiff_ccitt", - "group3", - "group4", - "tiff_jpeg", - "tiff_adobe_deflate", - "tiff_thunderscan", - "tiff_deflate", - "tiff_sgilog", - "tiff_sgilog24", - "tiff_raw_16", - ], - "group": "compression", - "gui_radio": False, - "fixed": True, - }, -} +_DEFAULTS = dict( + format=dict( + default="png", + info="Image format to use:" + "\n\t bmp: Windows bitmap" + "\n\t gif: Graphics Interchange Format (NB: Not animated)" + "\n\t jpg: JPEG format" + "\n\t jp2: JPEG 2000 format" + "\n\t png: Portable Network Graphics" + "\n\t ppm: Portable Pixmap Format" + "\n\t tif: Tag Image File Format", + datatype=str, + rounding=None, + min_max=None, + choices=["bmp", "gif", "jpg", "jp2", "png", "ppm", "tif"], + group="format", + gui_radio=True, + fixed=True, + ), + draw_transparent=dict( + default=False, + info="Place the swapped face on a transparent layer rather than the original frame.\nNB: " + "This is only compatible with images saved in png or tif format. If an incompatible " + "format is selected then the image will be saved as a png.", + datatype=bool, + rounding=None, + min_max=None, + choices=[], + group="format", + gui_radio=False, + fixed=True, + ), + optimize=dict( + default=False, + info="[gif, jpg and png only] If enabled, indicates that the encoder should make an extra " + "pass over the image in order to select optimal encoder settings.", + datatype=bool, + rounding=None, + min_max=None, + choices=[], + group="settings", + gui_radio=False, + fixed=True, + ), + gif_interlace=dict( + default=True, + info="[gif only] Set whether to save the gif as interlaced or not.", + datatype=bool, + rounding=None, + min_max=None, + choices=[], + group="settings", + gui_radio=False, + fixed=True, + ), + jpg_quality=dict( + default=75, + info="[jpg only] Set the jpg quality. 1 is worst 95 is best. Higher quality leads to " + "larger file sizes.", + datatype=int, + rounding=1, + min_max=(1, 95), + choices=[], + group="compression", + gui_radio=False, + fixed=True, + ), + png_compress_level=dict( + default=3, + info="[png only] ZLIB compression level, 1 gives best speed, 9 gives best compression, 0 " + "gives no compression at all. When optimize option is set to True this has no effect " + "(it is set to 9 regardless of a value passed).", + datatype=int, + rounding=1, + min_max=(0, 9), + choices=[], + group="compression", + gui_radio=False, + fixed=True, + ), + tif_compression=dict( + default="tiff_deflate", + info="[tif only] The desired compression method for the file.", + datatype=str, + rounding=None, + min_max=None, + choices=["none", "tiff_ccitt", "group3", "group4", "tiff_jpeg", "tiff_adobe_deflate", + "tiff_thunderscan", "tiff_deflate", "tiff_sgilog", "tiff_sgilog24", + "tiff_raw_16"], + group="compression", + gui_radio=False, + fixed=True, + ), +) diff --git a/plugins/extract/_config.py b/plugins/extract/_config.py index 0360ffcd56..e2e58643f1 100644 --- a/plugins/extract/_config.py +++ b/plugins/extract/_config.py @@ -26,7 +26,7 @@ def set_globals(self): section = "global" self.add_section(title=section, info="Options that apply to all extraction plugins") self.add_item( - section=section, title="allow_growth", datatype=bool, default=False, + section=section, title="allow_growth", datatype=bool, default=False, group="settings", info="[Nvidia Only]. Enable the Tensorflow GPU `allow_growth` configuration option. " "This option prevents Tensorflow from allocating all of the GPU VRAM at launch " "but can lead to higher VRAM fragmentation and slower performance. Should only " diff --git a/plugins/extract/align/fan_defaults.py b/plugins/extract/align/fan_defaults.py index 8749b0b9bb..7c6425827c 100644 --- a/plugins/extract/align/fan_defaults.py +++ b/plugins/extract/align/fan_defaults.py @@ -61,6 +61,7 @@ rounding=1, min_max=(1, 64), choices=[], + group="settings", gui_radio=False, fixed=True, ) diff --git a/plugins/extract/detect/cv2_dnn_defaults.py b/plugins/extract/detect/cv2_dnn_defaults.py index ad2f995d02..762100a66b 100755 --- a/plugins/extract/detect/cv2_dnn_defaults.py +++ b/plugins/extract/detect/cv2_dnn_defaults.py @@ -50,17 +50,17 @@ ) -_DEFAULTS = { - "confidence": { - "default": 50, - "info": "The confidence level at which the detector has succesfully found a face.\n" - "Higher levels will be more discriminating, lower levels will have more false " - "positives.", - "datatype": int, - "rounding": 5, - "min_max": (25, 100), - "choices": [], - "gui_radio": False, - "fixed": True, - }, -} +_DEFAULTS = dict( + confidence=dict( + default=50, + info="The confidence level at which the detector has succesfully found a face.\nHigher " + "levels will be more discriminating, lower levels will have more false positives.", + datatype=int, + rounding=5, + min_max=(25, 100), + choices=[], + group="settings", + gui_radio=False, + fixed=True, + ), +) diff --git a/plugins/extract/detect/mtcnn_defaults.py b/plugins/extract/detect/mtcnn_defaults.py index 2d7a7251c7..4e73eae737 100755 --- a/plugins/extract/detect/mtcnn_defaults.py +++ b/plugins/extract/detect/mtcnn_defaults.py @@ -51,72 +51,75 @@ _DEFAULTS = { - "minsize": { - "default": 20, - "info": "The minimum size of a face (in pixels) to be accepted as a positive match.\n" - "Lower values use significantly more VRAM and will detect more false " - "positives.", - "datatype": int, - "rounding": 10, - "min_max": (20, 1000), - "choices": [], - "gui_radio": False, - "fixed": True, - }, - "threshold_1": { - "default": 0.6, - "info": "First stage threshold for face detection. This stage obtains face " - "candidates.", - "datatype": float, - "rounding": 2, - "min_max": (0.1, 0.9), - "choices": [], - "gui_radio": False, - "fixed": True, - }, - "threshold_2": { - "default": 0.7, - "info": "Second stage threshold for face detection. This stage refines face " - "candidates.", - "datatype": float, - "rounding": 2, - "min_max": (0.1, 0.9), - "choices": [], - "gui_radio": False, - "fixed": True, - }, - "threshold_3": { - "default": 0.7, - "info": "Third stage threshold for face detection. This stage further refines face " - "candidates.", - "datatype": float, - "rounding": 2, - "min_max": (0.1, 0.9), - "choices": [], - "gui_radio": False, - "fixed": True, - }, - "scalefactor": { - "default": 0.709, - "info": "The scale factor for the image pyramid.", - "datatype": float, - "rounding": 3, - "min_max": (0.1, 0.9), - "choices": [], - "gui_radio": False, - "fixed": True, - }, - "batch-size": { - "default": 8, - "info": "The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered.", - "datatype": int, - "rounding": 1, - "min_max": (1, 64), - "choices": [], - "gui_radio": False, - "fixed": True, - } + "minsize": dict( + default=20, + info="The minimum size of a face (in pixels) to be accepted as a positive match.\nLower " + "values use significantly more VRAM and will detect more false positives.", + datatype=int, + rounding=10, + min_max=(20, 1000), + choices=[], + group="settings", + gui_radio=False, + fixed=True, + ), + "scalefactor": dict( + default=0.709, + info="The scale factor for the image pyramid.", + datatype=float, + rounding=3, + min_max=(0.1, 0.9), + choices=[], + group="settings", + gui_radio=False, + fixed=True, + ), + "batch-size": dict( + default=8, + info="The batch size to use. To a point, higher batch sizes equal better performance, but " + "setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can accomodate " + "then this will automatically be lowered.", + datatype=int, + rounding=1, + min_max=(1, 64), + choices=[], + group="settings", + gui_radio=False, + fixed=True, + ), + "threshold_1": dict( + default=0.6, + info="First stage threshold for face detection. This stage obtains face candidates.", + datatype=float, + rounding=2, + min_max=(0.1, 0.9), + choices=[], + group="threshold", + gui_radio=False, + fixed=True, + ), + "threshold_2": dict( + default=0.7, + info="Second stage threshold for face detection. This stage refines face candidates.", + datatype=float, + rounding=2, + min_max=(0.1, 0.9), + choices=[], + group="threshold", + gui_radio=False, + fixed=True, + ), + "threshold_3": dict( + default=0.7, + info="Third stage threshold for face detection. This stage further refines face " + "candidates.", + datatype=float, + rounding=2, + min_max=(0.1, 0.9), + choices=[], + group="threshold", + gui_radio=False, + fixed=True, + ), } diff --git a/plugins/extract/detect/s3fd_defaults.py b/plugins/extract/detect/s3fd_defaults.py index d8b4f85329..6c17bba95b 100755 --- a/plugins/extract/detect/s3fd_defaults.py +++ b/plugins/extract/detect/s3fd_defaults.py @@ -60,6 +60,7 @@ rounding=5, min_max=(25, 100), choices=[], + group="settings", gui_radio=False, fixed=True, ), @@ -74,6 +75,7 @@ rounding=1, min_max=(1, 64), choices=[], + group="settings", gui_radio=False, fixed=True, ) diff --git a/plugins/extract/mask/unet_dfl_defaults.py b/plugins/extract/mask/unet_dfl_defaults.py index 5956610e75..1a3fb81890 100644 --- a/plugins/extract/mask/unet_dfl_defaults.py +++ b/plugins/extract/mask/unet_dfl_defaults.py @@ -51,17 +51,18 @@ _DEFAULTS = { - "batch-size": { - "default": 8, - "info": "The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered.", - "datatype": int, - "rounding": 1, - "min_max": (1, 64), - "choices": [], - "gui_radio": False, - "fixed": True, - } + "batch-size": dict( + default=8, + info="The batch size to use. To a point, higher batch sizes equal better performance, but " + "setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can accomodate " + "then this will automatically be lowered.", + datatype=int, + rounding=1, + min_max=(1, 64), + choices=[], + group="settings", + gui_radio=False, + fixed=True, + ) } diff --git a/plugins/extract/mask/vgg_clear_defaults.py b/plugins/extract/mask/vgg_clear_defaults.py index ef2c307fb1..b9592c5b12 100644 --- a/plugins/extract/mask/vgg_clear_defaults.py +++ b/plugins/extract/mask/vgg_clear_defaults.py @@ -50,17 +50,18 @@ _DEFAULTS = { - "batch-size": { - "default": 6, - "info": "The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered.", - "datatype": int, - "rounding": 1, - "min_max": (1, 64), - "choices": [], - "gui_radio": False, - "fixed": True, - } + "batch-size": dict( + default=6, + info="The batch size to use. To a point, higher batch sizes equal better performance, but " + "setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can accomodate " + "then this will automatically be lowered.", + datatype=int, + rounding=1, + min_max=(1, 64), + choices=[], + group="settings", + gui_radio=False, + fixed=True, + ) } diff --git a/plugins/extract/mask/vgg_obstructed_defaults.py b/plugins/extract/mask/vgg_obstructed_defaults.py index 0588ee1c66..a4ca3e28af 100644 --- a/plugins/extract/mask/vgg_obstructed_defaults.py +++ b/plugins/extract/mask/vgg_obstructed_defaults.py @@ -51,17 +51,18 @@ _DEFAULTS = { - "batch-size": { - "default": 2, - "info": "The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered.", - "datatype": int, - "rounding": 1, - "min_max": (1, 64), - "choices": [], - "gui_radio": False, - "fixed": True, - } + "batch-size": dict( + default=2, + info="The batch size to use. To a point, higher batch sizes equal better performance, but " + "setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can accomodate " + "then this will automatically be lowered.", + datatype=int, + rounding=1, + min_max=(1, 64), + choices=[], + group="settings", + gui_radio=False, + fixed=True, + ) } diff --git a/scripts/gui.py b/scripts/gui.py index f048ce257e..4f7199c0cb 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -2,7 +2,6 @@ """ The optional GUI for faceswap """ import logging -import platform import sys import tkinter as tk from tkinter import messagebox, ttk @@ -24,7 +23,6 @@ def __init__(self, debug): self._init_args = dict(debug=debug) self._config = self.initialize_globals() self.set_fonts() - self.set_styles() self._config.set_geometry(1200, 640, self._config.user_config_dict["fullscreen"]) self.wrapper = ProcessWrapper() @@ -52,44 +50,6 @@ def set_fonts(self): tk.font.nametofont(font).configure(family=self._config.default_font[0], size=self._config.default_font[1]) - def set_styles(self): - """ Set global custom styles """ - gui_style = ttk.Style() - font = self._config.default_font - gui_style.configure('H1.TLabel', font=(font[0], font[1] + 4, "bold")) - gui_style.configure('H2.TLabel', font=(font[0], font[1] + 2, "bold")) - - # Control and settings panel styles - for _type in ("CPanel", "SPanel"): - # Common control panel items - for lbl in ["TLabel", "TFrame", "TLabelframe", "TCheckbutton", "TRadiobutton"]: - gui_style.configure(f"{_type}.{lbl}", background="#FFFFFF") - - # Background colors - color = "#CDD3D5" if _type == "CPanel" else "#DAD2D8" - gui_style.configure(f"Holder.{_type}.TFrame", background=color) - - # Highlight Colors - color = "#176087" if _type == "CPanel" else "#9B1D20" - gui_style.configure(f"{_type}.TLabelframe.Label", - background="#FFFFFF", - foreground=color) - gui_style.configure(f"{_type}.Groupheader.TLabel", - background=color, - foreground="#FFFFFF", - font=(font[0], font[1], "bold")) - - # Control Panel Info Box - gui_style.configure(f"InfoHeader.{_type}.TLabel", - background='#FFFFFF', - font=(font[0], font[1], "bold")) - gui_style.configure(f"InfoBody.{_type}.TLabel", background="#FFFFFF") - - # Scale widgets - gui_style.configure(f"{_type}.Horizontal.TScale", - background="#FFFFFF" if platform.system() == "Windows" else color, - troughcolor="#FFFFFF") - def build_gui(self, rebuild=False): """ Build the GUI """ logger.debug("Building GUI") diff --git a/tools/manual/faceviewer/frame.py b/tools/manual/faceviewer/frame.py index 7a8fbf7350..8d9c3a7b34 100644 --- a/tools/manual/faceviewer/frame.py +++ b/tools/manual/faceviewer/frame.py @@ -243,7 +243,10 @@ def __init__(self, parent, tk_globals, tk_action_vars, detected_faces, display_f logger.debug("Initializing %s: (parent: %s, tk_globals: %s, tk_action_vars: %s, " "detected_faces: %s, display_frame: %s, event: %s)", self.__class__.__name__, parent, tk_globals, tk_action_vars, detected_faces, display_frame, event) - super().__init__(parent, bd=0, highlightthickness=0, bg="#bcbcbc") + super().__init__(parent, + bd=0, + highlightthickness=0, + bg=get_config().user_theme["control_panel"]["secondary_color"]) self.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.E) self._sizes = dict(tiny=32, small=64, medium=96, large=128, extralarge=192) diff --git a/tools/manual/manual.py b/tools/manual/manual.py index 8b5a963f66..c7e43d9f56 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -318,6 +318,7 @@ def _initialize(self): header_text=controls["header"], blank_nones=False, label_width=18, + style="CPanel", scrollbar=False) panel.pack_forget() panels[name] = panel diff --git a/tools/preview/preview.py b/tools/preview/preview.py index 8fbc53a69b..f3c8a51286 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -1152,7 +1152,7 @@ def _add_cli_choices(self, parent, defaults, available_masks, has_predicted_mask Whether the model was trained with a mask """ cp_options = self._get_control_panel_options(defaults, available_masks, has_predicted_mask) - panel_kwargs = dict(blank_nones=False, label_width=10) + panel_kwargs = dict(blank_nones=False, label_width=10, style="CPanel") ControlPanel(parent, cp_options, header_text=None, **panel_kwargs) def _get_control_panel_options(self, defaults, available_masks, has_predicted_mask): @@ -1182,6 +1182,7 @@ def _get_control_panel_options(self, defaults, available_masks, has_predicted_ma default=defaults[opt], initial_value=defaults[opt], choices=choices, + group="Command Line Choices", is_radio=False) self._tk_vars[opt] = cp_option.tk_var cp_options.append(cp_option) @@ -1417,7 +1418,7 @@ def _build_frame(self, parent, config_key): The section/plugin key for these configuration options """ logger.debug("Add Config Frame") - panel_kwargs = dict(columns=2, option_columns=2, blank_nones=False) + panel_kwargs = dict(columns=2, option_columns=2, blank_nones=False, style="CPanel") frame = ttk.Frame(self) frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) cp_options = [opt for key, opt in self._options.items() if key != "helptext"] From 11009bf2375e973d9ce67248b3eeab7d4372fd3f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 16 Mar 2021 23:20:19 +0000 Subject: [PATCH 413/981] GUI Bugfix - Stop settings colors leaking into analysis. --- lib/gui/popup_configure.py | 13 +++-- plugins/train/model/dlight_defaults.py | 67 +++++++++++++------------- 2 files changed, 44 insertions(+), 36 deletions(-) diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 5e48d7d317..470c4d7f52 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -169,7 +169,7 @@ def _build_header(self): lbl_header = ttk.Label(lbl_frame, textvariable=self._tk_vars["header"], anchor=tk.W, - style=".SPanel.Header1.TLabel") + style="SPanel.Header1.TLabel") lbl_header.pack(fill=tk.X, expand=True, side=tk.LEFT) sep = ttk.Frame(header_frame, height=2, relief=tk.RIDGE) @@ -285,14 +285,21 @@ def _fix_styles(cls): """ theme = get_config().user_theme["settings_popup"] style = ttk.Style() + + # Fix a bug in Tree-view that doesn't show alternate foreground on selection fix_map = lambda o: [elm for elm in style.map("Treeview", query_opt=o) # noqa if elm[:2] != ("!disabled", "!selected")] - style.map("Treeview", foreground=fix_map("foreground"), background=fix_map("background")) - style.map('Treeview', background=[('selected', theme["header_color"])]) + # Remove the Borders style.configure("ConfigNav.Treeview", bd=0, background="#F0F0F0") style.layout("ConfigNav.Treeview", [('ConfigNav.Treeview.treearea', {'sticky': 'nswe'})]) + # Set colors + style.map("ConfigNav.Treeview", + foreground=fix_map("foreground"), + background=fix_map("background")) + style.map('ConfigNav.Treeview', background=[('selected', theme["header_color"])]) + def _build_tree(self, parent, configurations, name): """ Build the configuration pop-up window. diff --git a/plugins/train/model/dlight_defaults.py b/plugins/train/model/dlight_defaults.py index ef7514f3bb..b291b813d5 100644 --- a/plugins/train/model/dlight_defaults.py +++ b/plugins/train/model/dlight_defaults.py @@ -45,36 +45,37 @@ "(Adapted from https://github.com/dfaker/df)") -_DEFAULTS = { - "features": { - "default": "best", - "info": "Higher settings will allow learning more features such as tatoos, piercing," - "\nand wrinkles." - "\nStrongly affects VRAM usage.", - "datatype": str, - "choices": ["lowmem", "fair", "best"], - "gui_radio": True, - "fixed": True, - }, - "details": { - "default": "good", - "info": "Defines detail fidelity. Lower setting can appear 'rugged' while 'good' " - "might take onger time to train." - "\nAffects VRAM usage.", - "datatype": str, - "choices": ["fast", "good"], - "gui_radio": True, - "fixed": True, - }, - "output_size": { - "default": 256, - "info": "Output image resolution (in pixels).\nBe aware that larger resolution will " - "increase VRAM requirements.\nNB: Must be either 128, 256, or 384.", - "datatype": int, - "rounding": 128, - "min_max": (128, 384), - "choices": [], - "gui_radio": False, - "fixed": True, - }, -} +_DEFAULTS = dict( + features=dict( + default="best", + info="Higher settings will allow learning more features such as tatoos, piercing and " + "wrinkles.\nStrongly affects VRAM usage.", + datatype=str, + choices=["lowmem", "fair", "best"], + group="settings", + gui_radio=True, + fixed=True, + ), + details=dict( + default="good", + info="Defines detail fidelity. Lower setting can appear 'rugged' while 'good' might take " + "a longer time to train.\nAffects VRAM usage.", + datatype=str, + choices=["fast", "good"], + group="settings", + gui_radio=True, + fixed=True, + ), + output_size=dict( + default=256, + info="Output image resolution (in pixels).\nBe aware that larger resolution will increase " + "VRAM requirements.\nNB: Must be either 128, 256, or 384.", + datatype=int, + rounding=128, + min_max=(128, 384), + choices=[], + group="settings", + gui_radio=False, + fixed=True, + ), +) From d0e99908701bb1de4f78b71cea1fd20637ded07b Mon Sep 17 00:00:00 2001 From: mark-gargan Date: Thu, 18 Mar 2021 12:13:12 +0000 Subject: [PATCH 414/981] =?UTF-8?q?Adding=20some=20defensive=20code=20to?= =?UTF-8?q?=20better=20identify=20which=20image=20is=20causing=20=E2=80=A6?= =?UTF-8?q?=20(#1133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adding some defensive code to better identify which image is causing issue when permission error is encountered. --- lib/image.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/image.py b/lib/image.py index 061611156f..70996635cb 100644 --- a/lib/image.py +++ b/lib/image.py @@ -378,9 +378,14 @@ def read_image_meta(filename): retval["height"], retval["width"] = img.shape[:2] return retval with open(filename, "rb") as infile: - chunk = infile.read(8) + try: + chunk = infile.read(8) + except PermissionError: + raise PermissionError(f"PermissionError while reading: {filename}") + if chunk != b"\x89PNG\r\n\x1a\n": raise ValueError(f"Invalid header found in png: {filename}") + while True: chunk = infile.read(8) length, field = struct.unpack(">I4s", chunk) From a65655220414a0de8ed0fd363d51ebdf482c731b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 18 Mar 2021 17:43:56 +0000 Subject: [PATCH 415/981] Minor Updates - GUI - Tweaks - Fix Analysis Slider background color - More granular theming controls - Standardize comboboxes and scrollbars - plugins.train.model._base - Catch more model corruption errors --- lib/gui/.cache/themes/default.json | 85 +++++- lib/gui/control_helper.py | 85 +++--- lib/gui/custom_widgets.py | 13 +- lib/gui/popup_configure.py | 29 +- lib/gui/popup_session.py | 2 +- lib/gui/utils.py | 441 +++++++++++++++++++++++------ plugins/train/model/_base.py | 13 +- tools/manual/faceviewer/frame.py | 2 +- 8 files changed, 500 insertions(+), 170 deletions(-) diff --git a/lib/gui/.cache/themes/default.json b/lib/gui/.cache/themes/default.json index 109e138fe7..54a9adaa8b 100644 --- a/lib/gui/.cache/themes/default.json +++ b/lib/gui/.cache/themes/default.json @@ -1,22 +1,79 @@ -{ - "group_box": { - "background": "#FFFFFF", - "font_color": "#000000", - "input_color": "#FFFFFF", - "input_font": "#000000" - }, - "control_panel": { +{ + "info": "Initial default theme configuration whilst migrating from default ttk OS widgets", + "group_panel": { + "info": { + "info1": "The 'group_panel' section are any section which contains items for user input, such as the left hand options panel in the main GUI or the Settings pop-up", + "info2": "Anything which uses a 'group_panel' will use the theme specified here as default. Panels can be overriden (see below).", + + "panel_background": "The background color of the main panel that holds all of the group options.", + + "info_color": "The background color of the information header box at the top of each control panel", + "info_font": "The color of the font inside the information header box at the top of each control panel", + "info_border": "The color of the border around the outside of the information header box at the top of each control panel", + + "header_color": "The color to use for the option group boxes header backgrounds, the group box border and for labels on options groups.", + "header_font": "The color to use for the option group boxes header font.", + "group_background": "This is the color used for the background of each group of options, as well as the background color used for any label which resides inside a group box", + "group_font": "The font color used inside each group box for labels", + + "control_color": "The color of controls (e.g. Slider knob, combo pull-down arrow, scrollbar slider + arrows etc.)", + "control_active": "Selected/hovered over color of controls (e.g. Slider knob, combo pull-down arrow, scrollbar slider + arrows etc.)", + "control_disabled": "The color of controls when they are disabled (specifically scrollbars when there is no page to scroll).", + + "input_color": "The background color of input boxes (e.g. text entry)", + "input_font": "The font color of input boxes (e.g. text entry)", + "button_background": "The background color of buttons", + + "scrollbar_border": "Border color of scrollbar", + "scrollbar_trough": "Trough color of scrollbar" + }, + "panel_background": "#CDD3D5", + + "info_color": "#FFFFFF", + "info_font": "#000000", + "info_border": "#000000", + "header_color": "#176087", - "secondary_color": "#CDD3D5", - "tertiary_color": "#75929C" + "header_font": "#FFFFFF", + "group_background": "#FFFFFF", + "group_border": "#176087", + "group_font": "#000000", + + "control_color": "#75929C", + "control_active": "#176087", + "control_disabled": "#CDD3D5", + + "input_color": "#FFFFFF", + "input_font": "#000000", + "button_background": "#FFFFFF", + + "scrollbar_border": "#176087", + "scrollbar_trough": "#CDD3D5" }, - "settings_popup": { + "group_settings": { + "info": {"info1": "Override default colors for the settings pop-up. See 'group_panel' for allowable options", + "info2": "Options same as 'group_panel' with the following additions:", + + "tree_select": "The color of the selected item in the left hand nav frame", + "link_color": "The color of links on pages where there are no configuration options" + }, + "panel_background": "#DAD2D8", + "header_color": "#9B1D20", - "secondary_color": "#DAD2D8", - "tertiary_color": "#B090A8" + "group_border": "#9B1D20", + + "control_color": "#B090A8", + "control_active": "#9B1D20", + "control_disabled": "#DAD2D8", + + "scrollbar_border": "#9B1D20", + "scrollbar_trough": "#DAD2D8", + + "tree_select": "#9B1D20", + "link_color": "#9B1D20" }, "console": { "background_color": "#CDD3D5", "foreground_color": "#000000" - } + } } \ No newline at end of file diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index c7c89f21e1..28db7111b3 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -379,7 +379,8 @@ def __init__(self, parent, options, # pylint:disable=too-many-arguments "blank_nones: %s, scrollbar: %s)", self.__class__.__name__, parent, options, label_width, columns, max_columns, option_columns, header_text, style, blank_nones, scrollbar) - super().__init__(parent) + self._style = "" if style is None else f"{style}." + super().__init__(parent, style=f"{self._style}.Group.TFrame") self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) @@ -391,14 +392,14 @@ def __init__(self, parent, options, # pylint:disable=too-many-arguments self.option_columns = option_columns self.header_text = header_text - self._style = "" if style is None else f"{style}." + self._theme = get_config().user_theme["group_panel"] + if self._style.startswith("SPanel"): + self._theme = {**self._theme, **get_config().user_theme["group_settings"]} self.group_frames = dict() self._sub_group_frames = dict() - lookup = "settings_popup" if self._style.startswith("SPanel") else "control_panel" - theme = get_config().user_theme[lookup] - canvas_kwargs = dict(bd=0, highlightthickness=0, bg=theme["secondary_color"]) + canvas_kwargs = dict(bd=0, highlightthickness=0, bg=self._theme["panel_background"]) self._canvas = tk.Canvas(self, **canvas_kwargs) self._canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) @@ -429,14 +430,14 @@ def get_opts_frame(self): def add_info(self, frame): """ Plugin information """ - info_frame = ttk.Frame(frame, style="InfoHeader.TFrame", relief=tk.SOLID) + info_frame = ttk.Frame(frame, style=f"{self._style}InfoHeader.TFrame") info_frame.pack(fill=tk.X, side=tk.TOP, expand=True, padx=10, pady=(10, 0)) - label_frame = ttk.Frame(info_frame, style="InfoHeader.TFrame") + label_frame = ttk.Frame(info_frame, style=f"{self._style}InfoHeader.TFrame") label_frame.pack(padx=5, pady=5, fill=tk.X, expand=True) for idx, line in enumerate(self.header_text.splitlines()): if not line: continue - style = "InfoHeader" if idx == 0 else "InfoBody" + style = f"{self._style}InfoHeader" if idx == 0 else f"{self._style}InfoBody" info = ttk.Label(label_frame, text=line, style=f"{style}.TLabel", anchor=tk.W) info.bind("", self._adjust_wraplength) info.pack(fill=tk.X, padx=0, pady=0, expand=True, side=tk.TOP) @@ -488,22 +489,17 @@ def get_group_frame(self, group): other group, then will return the ToggledFrame for that group """ group = group.lower() - lookup = "settings_popup" if self._style.startswith("SPanel") else "control_panel" - theme = get_config().user_theme[lookup] if self.group_frames.get(group, None) is None: logger.debug("Creating new group frame for: %s", group) is_master = group == "_master" opts_frame = self.optsframe.subframe if is_master: - group_frame = ttk.Frame(opts_frame) + group_frame = ttk.Frame(opts_frame, style=f"{self._style}.Group.TFrame") retval = group_frame else: group_frame = ToggledFrame(opts_frame, text=group.title(), theme=self._style) retval = group_frame.sub_frame - retval.config(highlightbackground=theme["header_color"], - highlightcolor=theme["header_color"], - background=theme["secondary_color"]) group_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5, anchor=tk.NW) @@ -515,7 +511,9 @@ def get_group_frame(self, group): def add_scrollbar(self): """ Add a scrollbar to the options frame """ logger.debug("Add Config Scrollbar") - scrollbar = ttk.Scrollbar(self, command=self._canvas.yview) + scrollbar = ttk.Scrollbar(self, + command=self._canvas.yview, + style=f"{self._style}Vertical.TScrollbar") scrollbar.pack(side=tk.RIGHT, fill=tk.Y) self._canvas.config(yscrollcommand=scrollbar.set) self.mainframe.bind("", self.update_scrollbar) @@ -538,11 +536,11 @@ def checkbuttons_frame(self, frame): if is_master then check buttons will be placed in a LabelFrame otherwise in a standard frame """ logger.debug("Add Options CheckButtons Frame") - chk_frame = ttk.Frame(frame, name="chkbuttons", style="Group.TFrame") + chk_frame = ttk.Frame(frame, name="chkbuttons", style=f"{self._style}Group.TFrame") holder = AutoFillContainer(chk_frame, self.option_columns, self.option_columns, - style="Group.") + style=f"{self._style}Group.") logger.debug("Added Options CheckButtons Frame") return holder @@ -550,11 +548,11 @@ def _get_subgroup_frame(self, parent, subgroup): if subgroup is None: return subgroup if subgroup not in self._sub_group_frames: - sub_frame = ttk.Frame(parent, style="Group.TFrame") + sub_frame = ttk.Frame(parent, style=f"{self._style}Group.TFrame") self._sub_group_frames[subgroup] = AutoFillContainer(sub_frame, self.option_columns, self.option_columns, - style="Group.") + style=f"{self._style}Group.") sub_frame.pack(anchor=tk.W, expand=True, fill=tk.X) logger.debug("Added Subgroup Frame: %s", subgroup) return self._sub_group_frames[subgroup] @@ -900,6 +898,9 @@ def __init__(self, parent, option, option_columns, # pylint: disable=too-many-a self.filebrowser = None # Default to Control Panel Style self._style = style = style if style else "CPanel." + self._theme = get_config().user_theme["group_panel"] + if self._style.startswith("SPanel"): + self._theme = {**self._theme, **get_config().user_theme["group_settings"]} self.frame = self.control_frame(parent) self.chkbtns = checkbuttons_frame @@ -914,7 +915,7 @@ def control_frame(self, parent): logger.debug("Build control frame") frame = ttk.Frame(parent, name="fr_{}".format(self.option.name), - style="Group.TFrame") + style=f"{self._style}Group.TFrame") frame.pack(fill=tk.X) logger.debug("Built control frame") return frame @@ -942,7 +943,7 @@ def build_control_label(self): text=self.option.title, width=self.label_width, anchor=tk.W, - style="Group.TLabel") + style=f"{self._style}Group.TLabel") lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) if self.option.helptext is not None: _get_tooltip(lbl, text=self.option.helptext, wraplength=600) @@ -991,14 +992,14 @@ def _multi_option_control(self, option_type): holder = AutoFillContainer(ctl, self.option_columns, self.option_columns, - style="Group.") + style=f"{self._style}Group.") for choice in self.option.choices: if option_type == "radio": ctl = ttk.Radiobutton - style = "Group.TRadiobutton" + style = f"{self._style}Group.TRadiobutton" else: ctl = MultiOption - style = "Group.TCheckbutton" + style = f"{self._style}Group.TCheckbutton" ctl = ctl(holder.subframe, text=choice.replace("_", " ").title(), @@ -1048,7 +1049,6 @@ def slider_control(self): self.option.rounding, self.option.min_max) validate = self.slider_check_int if self.option.dtype == int else self.slider_check_float vcmd = (self.frame.register(validate)) - theme = get_config().user_theme["group_box"] tbox = tk.Entry(self.frame, width=8, textvariable=self.option.tk_var, @@ -1056,9 +1056,9 @@ def slider_control(self): font=get_config().default_font, validate="all", validatecommand=(vcmd, "%P"), - bg=theme["input_color"], - fg=theme["input_font"], - highlightbackground=theme["input_font"], + bg=self._theme["input_color"], + fg=self._theme["input_font"], + highlightbackground=self._theme["input_font"], highlightthickness=1, bd=0) tbox.pack(padx=(0, 5), side=tk.RIGHT) @@ -1118,14 +1118,13 @@ def control_to_optionsframe(self): self.option.sysbrowser, self._style) - theme = get_config().user_theme["group_box"] if self.option.control == tk.Entry: ctl = self.option.control(self.frame, textvariable=self.option.tk_var, font=get_config().default_font, - bg=theme["input_color"], - fg=theme["input_font"], - highlightbackground=theme["input_font"], + bg=self._theme["input_color"], + fg=self._theme["input_font"], + highlightbackground=self._theme["input_font"], highlightthickness=1, bd=0) else: # Combobox @@ -1133,16 +1132,14 @@ def control_to_optionsframe(self): textvariable=self.option.tk_var, font=get_config().default_font, state="readonly", - style="Group.TCombobox") + style=f"{self._style}TCombobox") # Style for combo list boxes needs to be set directly on widget as no style parameter - key = "settings_popup" if self._style.startswith("SPanel") else "control_panel" - select_key = get_config().user_theme[key] cmd = f"[ttk::combobox::PopdownWindow {ctl}].f.l configure -" - ctl.tk.eval(f"{cmd}foreground {theme['font_color']}") - ctl.tk.eval(f"{cmd}background {theme['background']}") - ctl.tk.eval(f"{cmd}selectforeground {select_key['header_color']}") - ctl.tk.eval(f"{cmd}selectbackground {select_key['secondary_color']}") + ctl.tk.eval(f"{cmd}foreground {self._theme['input_font']}") + ctl.tk.eval(f"{cmd}background {self._theme['input_color']}") + ctl.tk.eval(f"{cmd}selectforeground {self._theme['control_active']}") + ctl.tk.eval(f"{cmd}selectbackground {self._theme['control_disabled']}") rc_menu = _get_contextmenu(ctl) rc_menu.cm_bind() @@ -1158,7 +1155,7 @@ def _color_control(self): """ Clickable label holding the currently selected color """ logger.debug("Add control to Options Frame: (widget: '%s', control: %s, choices: %s)", self.option.name, self.option.control, self.option.choices) - frame = ttk.Frame(self.frame, style="Group.TFrame") + frame = ttk.Frame(self.frame, style=f"{self._style}Group.TFrame") ctl = tk.Frame(frame, bg=self.option.default, bd=2, @@ -1172,7 +1169,7 @@ def _color_control(self): text=self.option.title, width=self.label_width, anchor=tk.W, - style="Group.TLabel") + style=f"{self._style}Group.TLabel") lbl.pack(padx=2, pady=5, side=tk.RIGHT, anchor=tk.N) frame.pack(side=tk.LEFT, anchor=tk.W) if self.option.helptext is not None: @@ -1197,7 +1194,7 @@ def control_to_checkframe(self): variable=self.option.tk_var, text=self.option.title, name=self.option.name, - style="Group.TCheckbutton") + style=f"{self._style}Group.TCheckbutton") _get_tooltip(ctl, text=self.option.helptext, wraplength=600) ctl.pack(side=tk.TOP, anchor=tk.W, fill=tk.X) logger.debug("Added control checkframe: '%s'", self.option.name) @@ -1250,7 +1247,7 @@ def format_action_option(action_option): def add_browser_buttons(self): """ Add correct file browser button for control """ logger.debug("Adding browser buttons: (sysbrowser: %s", self.browser) - frame = ttk.Frame(self.frame, style="Group.TFrame") + frame = ttk.Frame(self.frame, style=f"{self._style}Group.TFrame") frame.pack(side=tk.RIGHT, padx=(0, 5)) for browser in self.browser: @@ -1275,7 +1272,7 @@ def add_browser_buttons(self): command=cmd, relief=tk.SOLID, bd=1, - bg=get_config().user_theme["group_box"]["background"], + bg=get_config().user_theme["group_panel"]["button_background"], cursor="hand2") _add_command(fileopn.cget("command"), cmd) fileopn.pack(padx=1, side=tk.RIGHT) diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index 7799cc031e..0642211fe1 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -131,7 +131,6 @@ class ConsoleOut(ttk.Frame): # pylint: disable=too-many-ancestors The Console's parent widget debug: bool ``True`` if console output should not be directed to this widget otherwise ``False`` - """ def __init__(self, parent, debug): @@ -152,7 +151,7 @@ def __init__(self, parent, debug): logger.debug("Initialized %s", self.__class__.__name__) def _set_console_clear_var_trace(self): - """ Set a trace on the consoleclear tkinter variable to trigger :func:`_clear` """ + """ Set a trace on the console clear tkinter variable to trigger :func:`_clear` """ logger.debug("Set clear trace") self._console_clear.trace("w", self._clear) @@ -913,7 +912,10 @@ class ToggledFrame(ttk.Frame): # pylint:disable=too-many-ancestors def __init__(self, parent, *args, text="", theme="CPanel", toggle_var=None, **kwargs): logger.debug("Initializing %s: (parent: %s, text: %s, theme: %s, toggle_var: %s)", self.__class__.__name__, parent, text, theme, toggle_var) - super().__init__(parent, *args, **kwargs) + + theme = "CPanel" if not theme else theme + theme = theme[:-1] if theme[-1] == "." else theme + super().__init__(parent, *args, style=f"{theme}.Group.TFrame", **kwargs) self._text = text if toggle_var: @@ -924,11 +926,10 @@ def __init__(self, parent, *args, text="", theme="CPanel", toggle_var=None, **kw self._icon_var = tk.StringVar() self._icon_var.set("-" if self.is_expanded else "+") - theme = "CPanel" if not theme else theme - theme = theme[:-1] if theme[-1] == "." else theme self._build_header(theme) - self.sub_frame = tk.Frame(self, name="toggledframe_subframe", highlightthickness=1, bd=0) + self.sub_frame = ttk.Frame(self, style=f"{theme}.Subframe.Group.TFrame", padding=1) + if self.is_expanded: self.sub_frame.pack(fill=tk.X, expand=True) diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 470c4d7f52..49709af042 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -125,13 +125,15 @@ def __init__(self, name, configurations): self._set_geometry() self._tk_vars = dict(header=tk.StringVar()) + theme = {**get_config().user_theme["group_panel"], + **get_config().user_theme["group_settings"]} header_frame = self._build_header() content_frame = ttk.Frame(self) - self._tree = _Tree(content_frame, configurations, name).tree + self._tree = _Tree(content_frame, configurations, name, theme).tree self._tree.bind("", self._select_item) - self._opts_frame = DisplayArea(content_frame, configurations, self._tree) + self._opts_frame = DisplayArea(content_frame, configurations, self._tree, theme) self._opts_frame.pack(fill=tk.BOTH, expand=True, side=tk.RIGHT) footer_frame = self._build_footer() @@ -256,10 +258,12 @@ class _Tree(ttk.Frame): # pylint:disable=too-many-ancestors name: str The name of the section that is being navigated to. Used for opening on the correct page in the Tree View. ``None`` if no specific area is being navigated to + theme: dict + The color mapping for the settings pop-up theme """ - def __init__(self, parent, configurations, name): + def __init__(self, parent, configurations, name, theme): super().__init__(parent) - self._fix_styles() + self._fix_styles(theme) frame = ttk.Frame(self, relief=tk.SOLID, borderwidth=1) self._tree = self._build_tree(frame, configurations, name) @@ -277,13 +281,17 @@ def tree(self): return self._tree @classmethod - def _fix_styles(cls): + def _fix_styles(cls, theme): """ Tkinter has a bug when setting the background style on certain OSes. This fixes the issue so we can set different colored backgrounds. We also set some default styles for our tree view. + + Parameters + ---------- + theme: dict + The color mapping for the settings pop-up theme """ - theme = get_config().user_theme["settings_popup"] style = ttk.Style() # Fix a bug in Tree-view that doesn't show alternate foreground on selection @@ -298,7 +306,7 @@ def _fix_styles(cls): style.map("ConfigNav.Treeview", foreground=fix_map("foreground"), background=fix_map("background")) - style.map('ConfigNav.Treeview', background=[('selected', theme["header_color"])]) + style.map('ConfigNav.Treeview', background=[('selected', theme["tree_select"])]) def _build_tree(self, parent, configurations, name): """ Build the configuration pop-up window. @@ -386,10 +394,13 @@ class DisplayArea(ttk.Frame): # pylint:disable=too-many-ancestors configurations: dict Dictionary containing the :class:`~lib.config.FaceswapConfig` object for each configuration section for the requested pop-up window + theme: dict + The color mapping for the settings pop-up theme """ - def __init__(self, parent, configurations, tree): + def __init__(self, parent, configurations, tree, theme): super().__init__(parent) self._configs = configurations + self._theme = theme self._tree = tree self._vars = dict() self._cache = dict() @@ -530,7 +541,7 @@ def _create_links_page(self, key): lbl = ttk.Label(frame, text=link.replace("_", " ").title(), anchor=tk.W, - foreground=get_config().user_theme["settings_popup"]["header_color"], + foreground=self._theme["link_color"], cursor="hand2") lbl.pack(side=tk.TOP, fill=tk.X, padx=10, pady=(0, 5)) bind = "{}|{}".format(key, link) diff --git a/lib/gui/popup_session.py b/lib/gui/popup_session.py index 83de2680e8..81af9ee498 100644 --- a/lib/gui/popup_session.py +++ b/lib/gui/popup_session.py @@ -240,7 +240,7 @@ def _opts_slider(self, frame): min_max=min_max, helptext=self._set_help(item)) self._vars[item] = slider.tk_var - ControlBuilder(frame, slider, 1, 19, None, "", True) + ControlBuilder(frame, slider, 1, 19, None, "Analysis.", True) logger.debug("Built Sliders") def _opts_buttons(self, frame): diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 0e7bc48372..e1152bead1 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -15,6 +15,7 @@ from PIL import Image, ImageDraw, ImageTk from lib.serializer import get_serializer +from lib.utils import FaceswapError from ._config import Config as UserConfig from .project import Project, Tasks @@ -1114,9 +1115,9 @@ def set_geometry(self, width, height, fullscreen=False): class _Style(): # pylint:disable=too-few-public-methods - """ Set the overarching theme and customize widgets""" + """ Set the overarching theme and customize widgets. """ def __init__(self, default_font, root): - self._image_cache = [] + self._images = _TkImage() self._root = root self._font = default_font default = os.path.join(PATHCACHE, "themes", "default.json") @@ -1129,92 +1130,92 @@ def user_theme(self): """ dict: The currently selected user theme. """ return self._user_theme + def _set_styles(self): + """ Configure widget theme and styles """ + self._config_settings_group() + # Settings Popup + self._style.configure("SPanel.Header1.TLabel", + font=(self._font[0], self._font[1] + 4, "bold")) + self._style.configure("SPanel.Header2.TLabel", + font=(self._font[0], self._font[1] + 2, "bold")) + def _config_settings_group(self): """ Configures the style of the control panel entry boxes. Used for inputting Faceswap options or controlling plugin settings. """ - self._config_settings_group_common() - self._config_settings_group_unique() - - def _config_settings_group_common(self): - """ Configures the group items that remain consistent, regardless of section. """ - # Info Box - theme = self._user_theme["group_box"] - self._style.configure("InfoHeader.TFrame", background=theme["background"]) - self._style.configure("InfoHeader.TLabel", - background=theme["background"], - foreground=theme["font_color"], - font=(self._font[0], self._font[1], "bold")) - self._style.configure("InfoBody.TLabel", - background=theme["background"], - foreground=theme["font_color"]) - - # Background and Foreground of widgets and labels - for lbl in ["TLabel", "TFrame", "TLabelframe", "TCheckbutton", "TRadiobutton", - "TLabelframe.Label"]: - self._style.configure(f"Group.{lbl}", - background=theme["background"], - foreground=theme["font_color"]) - # Combobox - self._config_settings_group_common_combobox() - - def _config_settings_group_common_combobox(self): - """ Combo-boxes are fairly complex to style. """ - theme = self._user_theme["group_box"] - # Create a clone from clam theme - self._style.element_create("Group.TCombobox.field", "from", "clam") - # Set a layout so we can access required params - self._style.layout("Group.TCombobox", [ - ("Group.TCombobox.field", { - "children": [ - ("Combobox.downarrow", {"side": "right", "sticky": "ns"}), - ("Combobox.padding", { - "expand": "1", - "sticky": "nswe", - "children": [("Combobox.focus", { - "expand": "1", - "sticky": "nswe", - "children": [("Combobox.textarea", {"sticky": "nswe"})]})]})], - "sticky": "nswe"})]) - - # Foreground - self._style.configure("Group.TCombobox", foreground=theme["font_color"]) - self._style.configure("Group.TCombobox", selectforeground=theme["font_color"]) - # Background - self._style.configure("Group.TCombobox", background=theme["background"]) - self._style.configure("Group.TCombobox", selectbackground=theme["background"]) - self._style.map("Group.TCombobox", fieldbackground=[("readonly", theme["background"])]) - self._style.configure("Group.TCombobox", fieldbackground=theme["background"]) + theme = self._user_theme["group_panel"] + for panel_type in ("CPanel", "SPanel"): + if panel_type == "SPanel": # Merge in Settings Panel overrides + theme = {**theme, **self._user_theme["group_settings"]} + self._style.configure(f"{panel_type}.Holder.TFrame", + background=theme["panel_background"]) + # Header Colors on option/group controls + self._style.configure(f"{panel_type}.Group.TLabelframe.Label", + foreground=theme["header_color"]) + self._style.configure(f"{panel_type}.Groupheader.TLabel", + background=theme["header_color"], + foreground=theme["header_font"], + font=(self._font[0], self._font[1], "bold")) + # Widgets and specific areas + self._group_panel_widgets(panel_type, theme) + self._group_panel_infoheader(panel_type, theme) + self._config_settings_group_slider(panel_type, theme) + self._config_settings_group_scrollbar(panel_type, theme) + self._config_settings_group_combobox(panel_type, theme) - def _config_settings_group_unique(self): - """ Configures the group items that change depending on section. These are the section - highlight colors. + def _group_panel_infoheader(self, key, theme): + """ Set the theme for the information header box that appears at the top of each group + panel - These are the header labels on Label Frames, the Group header boxes and the slider color. + Parameters + ---------- + key: str + The section that the slider will belong to + theme: dict + The user configuration theme options """ - # Control and settings panel styles - for section in ("control_panel", "settings_popup"): - key = "CPanel" if section == "control_panel" else "SPanel" - theme = self._user_theme[section] - - # Background colors - self._style.configure(f"{key}.Holder.TFrame", background=theme["secondary_color"]) + self._style.element_create(f"{key}.InfoHeader.Frame.border", "from", "alt") + self._style.layout(f"{key}.InfoHeader.TFrame", + [(f"{key}.InfoHeader.Frame.border", {"sticky": "nswe"})]) + self._style.configure(f"{key}.InfoHeader.TFrame", + background=theme["info_color"], + relief=tk.SOLID, + borderwidth=1, + bordercolor=theme["info_border"]) + + self._style.configure(f"{key}.InfoHeader.TLabel", + background=theme["info_color"], + foreground=theme["info_font"], + font=(self._font[0], self._font[1], "bold")) + self._style.configure(f"{key}.InfoBody.TLabel", + background=theme["info_color"], + foreground=theme["info_font"]) - # Highlight Colors - self._style.configure(f"{key}.Group.TLabelframe.Label", - foreground=theme["header_color"]) - self._style.configure(f"{key}.Groupheader.TLabel", - background=theme["header_color"], - foreground=self._user_theme["group_box"]["background"], - font=(self._font[0], self._font[1], "bold")) + def _group_panel_widgets(self, key, theme): + """ Configure the foreground and background colors of common widgets. - self._config_settings_group_slider(key, theme) + Parameters + ---------- + key: str + The section that the slider will belong to + theme: dict + The user configuration theme options + """ + # Put a border on a group's sub-frame + self._style.element_create(f"{key}.Subframe.Group.Frame.border", "from", "alt") + self._style.layout(f"{key}.Subframe.Group.TFrame", + [(f"{key}.Subframe.Group.Frame.border", {"sticky": "nswe"})]) + self._style.configure(f"{key}.Subframe.Group.TFrame", + background=theme["group_background"], + relief=tk.SOLID, + borderwidth=1, + bordercolor=theme["group_border"]) - @classmethod - def _set_img_color(cls, img, color): - """Change color of PhotoImage image.""" - pixel_line = "{" + " ".join(color for i in range(img.width())) + "}" - pixels = " ".join(pixel_line for i in range(img.height())) - img.put(pixels) + # Background and Foreground of widgets and labels + for lbl in ["TLabel", "TFrame", "TLabelframe", "TCheckbutton", "TRadiobutton", + "TLabelframe.Label"]: + self._style.configure(f"{key}.Group.{lbl}", + background=theme["group_background"], + foreground=theme["group_font"]) def _config_settings_group_slider(self, key, theme): """ Take a copy of the default ttk.Scale widget and replace the slider element with a @@ -1227,11 +1228,8 @@ def _config_settings_group_slider(self, key, theme): theme: dict The user configuration theme options """ - self._image_cache.extend([tk.PhotoImage(width=10, height=25), - tk.PhotoImage(width=10, height=25)]) - img_slider, img_slider_alt = self._image_cache[-2:] - self._set_img_color(img_slider, theme["tertiary_color"]) - self._set_img_color(img_slider_alt, theme["header_color"]) + img_slider = self._images.get_image((10, 25), theme["control_color"]) + img_slider_alt = self._images.get_image((10, 25), theme["control_active"]) self._style.element_create(f"{key}.Horizontal.Scale.trough", "from", "alt") self._style.element_create(f"{key}.Horizontal.Scale.slider", @@ -1257,18 +1255,273 @@ def _config_settings_group_slider(self, key, theme): })]) self._style.configure(f"{key}.Horizontal.TScale", - background=self._user_theme["group_box"]["background"], + background=self._user_theme["group_panel"]["group_background"], groovewidth=4, - troughcolor=self._user_theme["group_box"]["background"]) + troughcolor=self._user_theme["group_panel"]["group_background"]) - def _set_styles(self): - """ Configure widget theme and styles """ - self._config_settings_group() - # Settings Popup - self._style.configure("SPanel.Header1.TLabel", - font=(self._font[0], self._font[1] + 4, "bold")) - self._style.configure("SPanel.Header2.TLabel", - font=(self._font[0], self._font[1] + 2, "bold")) + def _config_settings_group_scrollbar(self, key, theme): + """ Create a custom scroll bar widget so we can control the colors. + + Parameters + ---------- + key: str + The section that the slider will belong to + theme: dict + The user configuration theme options + """ + images = dict() + backgrounds = dict(normal=theme["control_color"], + disabled=theme["control_disabled"], + active=theme["control_active"]) + foregrounds = dict(normal=theme["control_disabled"], + disabled=theme["control_color"], + active=theme["control_disabled"]) + borders = dict(normal=theme["header_color"], + disabled=theme["control_color"], + active=theme["header_color"]) + + for state in ("normal", "disabled", "active"): + # Create arrow and slider widgets for each state + img_args = ((16, 16), backgrounds[state]) + for dir_ in ("up", "down"): + images[f"img_{dir_}_{state}"] = self._images.get_image( + *img_args, + foreground=foregrounds[state], + pattern="arrow", + direction=dir_, + thickness=4, + border_width=1, + border_color=borders[state]) + images[f"img_thumb_{state}"] = self._images.get_image(*img_args, + border_width=1, + border_color=borders[state]) + + for element in ("thumb", "uparrow", "downarrow"): + # Create the elements with the new images + lookup = element.replace("arrow", "") + args = (f"{key}.Vertical.Scrollbar.{element}", + "image", + images[f"img_{lookup}_normal"], + ("disabled", images[f"img_{lookup}_disabled"]), + ("pressed !disabled", images[f"img_{lookup}_active"]), + ("active !disabled", images[f"img_{lookup}_active"])) + kwargs = dict(border=1, sticky="ns") if element == "thumb" else dict() + self._style.element_create(*args, **kwargs) + + # Get a configurable trough + self._style.element_create(f"{key}.Vertical.Scrollbar.trough", "from", "clam") + + self._style.layout( + f"{key}.Vertical.TScrollbar", + [(f"{key}.Vertical.Scrollbar.trough", { + "sticky": "ns", + "children": [ + (f"{key}.Vertical.Scrollbar.uparrow", {"side": "top", "sticky": ""}), + (f"{key}.Vertical.Scrollbar.downarrow", {"side": "bottom", "sticky": ""}), + (f"{key}.Vertical.Scrollbar.thumb", {"expand": "1", "sticky": "nswe"}) + ] + })]) + self._style.configure(f"{key}.Vertical.TScrollbar", + troughcolor=theme["scrollbar_trough"], + bordercolor=theme["scrollbar_border"], + troughrelief=tk.SOLID, + troughborderwidth=1) + + def _config_settings_group_combobox(self, key, theme): + """ Combo-boxes are fairly complex to style. + + Parameters + ---------- + key: str + The section that the slider will belong to + theme: dict + The user configuration theme options + """ + # All the stock down arrow images are bad + images = dict() + for state in ("active", "normal"): + images[f"arrow_{state}"] = self._images.get_image( + (20, 20), + theme["control_color"] if state == "normal" else theme["control_active"], + foreground=theme["control_disabled"], + pattern="arrow", + thickness=2, + border_width=1, + border_color=theme["header_color"]) + + self._style.element_create(f"{key}.Combobox.downarrow", + "image", + images["arrow_normal"], + ("active", images["arrow_active"]), + ("pressed", images["arrow_active"]), + sticky="e", + width=20) + + # None of the themes give us the border control we need, so create an image + box = self._images.get_image((16, 16), + theme["group_background"], + border_width=1, + border_color=theme["group_font"]) + self._style.element_create(f"{key}.Combobox.field", + "image", + box, + border=1, + padding=(6, 0, 0, 0)) + + # Set a layout so we can access required params + self._style.layout(f"{key}.TCombobox", [ + (f"{key}.Combobox.field", { + "children": [ + (f"{key}.Combobox.downarrow", {"side": "right", "sticky": "ns"}), + (f"{key}.Combobox.padding", { + "expand": "1", + "sticky": "nswe", + "children": [(f"{key}.Combobox.focus", { + "expand": "1", + "sticky": "nswe", + "children": [(f"{key}.Combobox.textarea", {"sticky": "nswe"})]})]})], + "sticky": "nswe"})]) + + +class _TkImage(): # pylint:disable=too-few-public-methods + """ Create a tk image for a given pattern and shape. + """ + def __init__(self): + self._cache = [] # We need to keep a reference to every image created + + # Numpy array patterns + @classmethod + def _get_solid(cls, dimensions): + """ Return a solid background color pattern. + + Parameters + ---------- + dimensions: tuple + The (`width`, `height`) of the desired tk image + + Returns + ------- + :class:`numpy.ndarray` + A 2D, UINT8 array of shape (height, width) of all zeros + """ + return np.zeros((dimensions[1], dimensions[0]), dtype="uint8") + + @classmethod + def _get_arrow(cls, dimensions, thickness, direction): + """ Return a background color with a "v" arrow in foreground color + + Parameters + ---------- + dimensions: tuple + The (`width`, `height`) of the desired tk image + thickness: int + The thickness of the pattern to be drawn + direction: ["left", "up", "right", "down"] + The direction that the pattern should be facing + + Returns + ------- + :class:`numpy.ndarray` + A 2D, UINT8 array of shape (height, width) of all zeros + """ + square_size = min(dimensions[1], dimensions[0]) + if square_size < 16 or any(dim % 2 != 0 for dim in dimensions): + raise FaceswapError("For arrow image, the minimum size across any axis must be 8 and " + "dimensions must all be divisible by 2") + crop_size = (square_size // 16) * 16 + draw_rows = int(6 * crop_size / 16) + start_row = dimensions[1] // 2 - draw_rows // 2 + initial_indent = (2 * (crop_size // 16) + (dimensions[0] - crop_size) // 2) + + retval = np.zeros((dimensions[1], dimensions[0]), dtype="uint8") + for i in range(start_row, start_row + draw_rows): + indent = initial_indent + i - start_row + join = (min(indent + thickness, dimensions[0] // 2), + max(dimensions[0] - indent - thickness, dimensions[0] // 2)) + retval[i, np.r_[indent:join[0], join[1]:dimensions[0] - indent]] = 1 + if direction in ("right", "left"): + retval = np.rot90(retval) + if direction in ("up", "left"): + retval = np.flip(retval) + return retval + + def get_image(self, + dimensions, + background, + foreground=None, + pattern="solid", + border_width=0, + border_color=None, + thickness=2, + direction="down"): + """ Obtain a tk image. + + Generates the requested image and stores in cache. + + Parameters + ---------- + dimensions: tuple + The (`width`, `height`) of the desired tk image + background: str + The hex code for the background (main) color + foreground: str, optional + The hex code for the background (secondary) color. If ``None`` is provided then a + solid background color image will be returned. Default: ``None`` + pattern: ["solid", "arrow"], optional + The pattern to generate for the tk image. Default: `"solid"` + border_width: int, optional + The thickness of foreground border to apply. Default: 0 + border_color: int, optional + The color of the border, if one is to be created. Default: ``None`` (use foreground + color) + thickness: int, optional + The thickness of the pattern to be drawn. Default: `2` + direction: ["left", "up", "right", "down"], optional + The direction that the pattern should be facing. Default: `"down"` + """ + foreground = foreground if foreground else background + border_color = border_color if border_color else foreground + + args = [dimensions] + if pattern.lower() == "arrow": + args.extend([thickness, direction]) + if pattern.lower() == "border": + args.extend([thickness]) + pattern = getattr(self, f"_get_{pattern.lower()}")(*args) + + if border_width > 0: + border = np.ones_like(pattern) + 1 + border[border_width:-border_width, + border_width:-border_width] = pattern[border_width:-border_width, + border_width:-border_width] + pattern = border + + return self._create_photoimage(background, foreground, border_color, pattern) + + def _create_photoimage(self, background, foreground, border, pattern): + """ Create a tkinter PhotoImage and populate it with the requested color pattern. + + Parameters + ---------- + background: str + The hex code for the background (main) color + foreground: str + The hex code for the foreground (secondary) color + border: str + The hex code for the border color + pattern: class:`numpy.ndarray` + The pattern for the final image with background colors marked as 0 and foreground + colors marked as 1 + """ + image = tk.PhotoImage(width=pattern.shape[1], height=pattern.shape[0]) + self._cache.append(image) + + pixels = "} {".join(" ".join(foreground + if pxl == 1 else border if pxl == 2 else background + for pxl in row) + for row in pattern) + image.put("{" + pixels + "}") + return image class LongRunningTask(Thread): diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 64af502afa..c1d8acc49c 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -557,9 +557,20 @@ def _load(self): msg = (f"Unable to load the model from '{self._filename}'. This may be a " "temporary error but most likely means that your model has corrupted.\n" "You can try to load the model again but if the problem persists you " - "should use the Restore Tool to restore your model from backup.") + "should use the Restore Tool to restore your model from backup.\n" + f"Original error: {str(err)}") raise FaceswapError(msg) raise err + except KeyError as err: + if "unable to open object" in str(err).lower(): + msg = (f"Unable to load the model from '{self._filename}'. This may be a " + "temporary error but most likely means that your model has corrupted.\n" + "You can try to load the model again but if the problem persists you " + "should use the Restore Tool to restore your model from backup.\n" + f"Original error: {str(err)}") + raise FaceswapError(msg) + raise err + logger.info("Loaded model from disk: '%s'", self._filename) return model diff --git a/tools/manual/faceviewer/frame.py b/tools/manual/faceviewer/frame.py index 8d9c3a7b34..4439cb763a 100644 --- a/tools/manual/faceviewer/frame.py +++ b/tools/manual/faceviewer/frame.py @@ -246,7 +246,7 @@ def __init__(self, parent, tk_globals, tk_action_vars, detected_faces, display_f super().__init__(parent, bd=0, highlightthickness=0, - bg=get_config().user_theme["control_panel"]["secondary_color"]) + bg=get_config().user_theme["group_panel"]["panel_background"]) self.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.E) self._sizes = dict(tiny=32, small=64, medium=96, large=128, extralarge=192) From 86ac59678486f4584b02dd561db826b903346e19 Mon Sep 17 00:00:00 2001 From: Dominik Miszkiewicz Date: Fri, 19 Mar 2021 02:06:26 +0100 Subject: [PATCH 416/981] Update sorting by yaws (#1131) --- tools/sort/sort.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 85cd76ba27..02accc1943 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -243,13 +243,29 @@ def sort_face_cnn_dissim(self): def sort_face_yaw(self): """ Sort by estimated face yaw angle """ logger.info("Sorting by estimated face yaw angle..") - filename_list, _, landmarks = self._get_landmarks() - - logger.info("Estimating yaw...") - yaws = [self.calc_landmarks_face_yaw(mark) for mark in landmarks] + self._loader = FacesLoader(self._args.input_dir) # TODO This should be set in init + filenames = [] + yaws = [] + for filename, image, metadata in tqdm(self._loader.load(), + desc="Classifying Faces...", + total=self._loader.count, + leave=False): + if not metadata: + msg = ("The images to be sorted do not contain alignment data. Images must have " + "been generated by Faceswap's Extract process.\nIf you are sorting an " + "older faceset, then you should re-extract the faces from your source " + "alignments file to generate this data.") + raise FaceswapError(msg) + alignments = metadata["alignments"] + alignedFace = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), + image=image, + centering="legacy", + is_aligned=True) + filenames.append(filename) + yaws.append(alignedFace.pose.yaw) logger.info("Sorting...") - matched_list = list(zip(filename_list, yaws)) + matched_list = list(zip(filenames, yaws)) img_list = sorted(matched_list, key=operator.itemgetter(1), reverse=True) return img_list From 544a95bc4fc2d87e5a83b938ff5ccd328f02d4e4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 19 Mar 2021 01:09:52 +0000 Subject: [PATCH 417/981] linting --- tools/sort/sort.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 02accc1943..4d053144ef 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -257,12 +257,12 @@ def sort_face_yaw(self): "alignments file to generate this data.") raise FaceswapError(msg) alignments = metadata["alignments"] - alignedFace = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), - image=image, - centering="legacy", - is_aligned=True) + aligned_face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), + image=image, + centering="legacy", + is_aligned=True) filenames.append(filename) - yaws.append(alignedFace.pose.yaw) + yaws.append(aligned_face.pose.yaw) logger.info("Sorting...") matched_list = list(zip(filenames, yaws)) From 46cb53e6f0266885239663258c57966cfe475c61 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 19 Mar 2021 18:22:35 +0000 Subject: [PATCH 418/981] Training updates: - Expose optimizer epsilon param - Add NaN protection GUI - slider - add support for discreet values --- lib/gui/control_helper.py | 14 +++++--- plugins/train/_config.py | 45 +++++++++++++++++++----- plugins/train/model/_base.py | 18 ++++++---- plugins/train/model/original_defaults.py | 11 +++--- plugins/train/trainer/_base.py | 15 +++++++- 5 files changed, 78 insertions(+), 25 deletions(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 28db7111b3..c6b9274797 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -57,14 +57,18 @@ def set_slider_rounding(value, var, d_type, round_to, min_max): The variable to set the value for d_type: [:class:`int`, :class:`float`] The type of value that is stored in :attr:`var` - round_to: int - If :attr:`dtype` is :class:`float` then this is the decimal place rounding for :attr:`var`. - If :attr:`dtype` is :class:`int` then this is the number of steps between each increment - for :attr:`var` + round_to: int or list + If :attr:`d_type` is :class:`float` then this is the decimal place rounding for + :attr:`var`. If :attr:`d_type` is :class:`int` then this is the number of steps between + each increment for :attr:`var`. If a list is provided, then this must be a list of + discreet values that are of the correct :attr:`d_type`. min_max: tuple (`int`, `int`) The (``min``, ``max``) values that this slider accepts """ - if d_type == float: + if isinstance(round_to, list): + # Lock to nearest item + var.set(min(round_to, key=lambda x: abs(x-float(value)))) + elif d_type == float: var.set(round(float(value), round_to)) else: steps = range(min_max[0], min_max[1] + round_to, round_to) diff --git a/plugins/train/_config.py b/plugins/train/_config.py index c2a2b7a3d4..e717f84948 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -108,7 +108,7 @@ def _set_globals(self): "that is based on adaptive estimation of first-order and second-order moments." "\n\t nadam - Adaptive Moment Optimization with Nesterov Momentum. Much like " "Adam but uses a different formula for calculating momentum." - "\n\t rms-prop - Root Mean Square Propogation. Maintains a moving (discounted) " + "\n\t rms-prop - Root Mean Square Propagation. Maintains a moving (discounted) " "average of the square of the gradients. Divides the gradient by the root of " "this average.") self.add_item( @@ -125,6 +125,24 @@ def _set_globals(self): "are too large might result in model crashes and the inability of the model to " "find the best solution. Values that are too small might be unable to escape " "from dead-ends and find the best global minimum.") + self.add_item( + section=section, + title="epsilon_exponent", + datatype=int, + default=-7, + min_max=(-10, 0), + rounding=1, + fixed=False, + group="optimizer", + info="The epsilon adds a small constant to weight updates to attempt to avoid 'divide " + "by zero' errors. Generally this option should be left at default value, however " + "if you are getting 'NaN' loss values, and have been unable to resolve the issue " + "any other way (for example, increasing batch size, or lowering learning rate, " + "then raising the epsilon can lead to a more stable model. It may, however, come " + "at the cost of slower training and a less accurate final result.\n" + "NB: The value given here is the 'exponent' to the epsilon. For example, " + "choosing '-7' will set the epsilon to 1e-7. Choosing '-3' will set the epsilon " + "to 0.001 (1e-3).") self.add_item( section=section, title="reflect_padding", @@ -163,6 +181,17 @@ def _set_globals(self): "because they have Tensor Cores. Older GPUs offer no math performance benefit " "for using mixed precision, however memory and bandwidth savings can enable some " "speedups. Generally RTX GPUs and later will offer the most benefit.") + self.add_item( + section=section, + title="nan_protection", + datatype=bool, + default=True, + group="network", + info="If a 'NaN' is generated in the model, this means that the model has corrupted " + "and the model is likely to start deteriorating from this point on. Enabling NaN " + "protection will stop training immediately in the event of a NaN. The last save " + "will not contain the NaN, so you may still be able to rescue your model.", + fixed=False) self.add_item( section=section, title="convert_batchsize", @@ -213,8 +242,8 @@ def _set_loss(self): "a median, it can potentially ignore some infrequent image types in the dataset." "\n\t MSE - Mean squared error will guide reconstructions of each pixel " "towards its average value in the training dataset. As an avg, it will be " - "suspectible to outliers and typically produces slightly blurrier results." - "\n\t LogCosh - log(cosh(x)) acts similiar to MSE for small errors and to " + "susceptible to outliers and typically produces slightly blurrier results." + "\n\t LogCosh - log(cosh(x)) acts similar to MSE for small errors and to " "MAE for large errors. Like MSE, it is very stable and prevents overshoots " "when errors are near zero. Like MAE, it is robust to outliers. NB: Due to a bug " "in PlaidML, this loss does not work on AMD cards." @@ -228,12 +257,12 @@ def _set_loss(self): "statistics of an image. Potentially delivers more realistic looking images." "\n\t GMSD - Gradient Magnitude Similarity Deviation seeks to match " "the global standard deviation of the pixel to pixel differences between two " - "images. Similiar in approach to SSIM. NB: This loss does not currently work on " + "images. Similar in approach to SSIM. NB: This loss does not currently work on " "AMD cards." "\n\t Pixel_Gradient_Difference - Instead of minimizing the difference between " "the absolute value of each pixel in two reference images, compute the pixel to " "pixel spatial difference in each image and then minimize that difference " - "between two images. Allows for large color shifts,but maintains the structure " + "between two images. Allows for large color shifts, but maintains the structure " "of the image.") self.add_item( section=section, @@ -247,8 +276,8 @@ def _set_loss(self): "towards its median value in the training dataset. Robust to outliers but as " "a median, it can potentially ignore some infrequent image types in the dataset." "\n\t MSE - Mean squared error will guide reconstructions of each pixel " - "towards its average value in the training dataset. As an avg, it will be " - "suspectible to outliers and typically produces slightly blurrier results.") + "towards its average value in the training dataset. As an average, it will be " + "susceptible to outliers and typically produces slightly blurrier results.") self.add_item( section=section, title="l2_reg_term", @@ -307,7 +336,7 @@ def _set_loss(self): default=True, group="loss", info="Image loss function is weighted by mask presence. For areas of " - "the image without the facial mask, reconstuction errors will be " + "the image without the facial mask, reconstruction errors will be " "ignored while the masked face area is prioritized. May increase " "overall quality by focusing attention on the core face area.") self.add_item( diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index c1d8acc49c..927e61a2c5 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -386,7 +386,8 @@ def _output_summary(self): if hasattr(self._args, "summary") and self._args.summary: print_fn = None # Print straight to stdout else: - print_fn = lambda x: logger.verbose("%s", x) # print to logger + # print to logger + print_fn = lambda x: logger.verbose("%s", x) # noqa for model in _get_all_sub_models(self._model): model.summary(print_fn=print_fn) @@ -411,6 +412,7 @@ def _compile_model(self): optimizer = _Optimizer(self.config["optimizer"], self.config["learning_rate"], self.config.get("clipnorm", False), + 10 ** int(self.config["epsilon_exponent"]), self._args).optimizer if self._settings.use_mixed_precision: optimizer = self._settings.loss_scale_optimizer(optimizer) @@ -1076,20 +1078,22 @@ class _Optimizer(): # pylint:disable=too-few-public-methods The selected learning rate to use clipnorm: bool Whether to clip gradients to avoid exploding/vanishing gradients + epsilon: float + The value to use for the epsilon of the optimizer arguments: :class:`argparse.Namespace` The arguments that were passed to the train or convert process as generated from Faceswap's command line arguments """ - def __init__(self, optimizer, learning_rate, clipnorm, arguments): + def __init__(self, optimizer, learning_rate, clipnorm, epsilon, arguments): logger.debug("Initializing %s: (optimizer: %s, learning_rate: %s, clipnorm: %s, " - "arguments: %s", self.__class__.__name__, optimizer, learning_rate, clipnorm, - arguments) + "epsilon: %s, arguments: %s)", self.__class__.__name__, + optimizer, learning_rate, clipnorm, epsilon, arguments) optimizers = {"adam": Adam, "nadam": Nadam, "rms-prop": RMSprop} self._optimizer = optimizers[optimizer] - base_kwargs = {"adam": dict(beta_1=0.5, beta_2=0.99), - "nadam": dict(beta_1=0.5, beta_2=0.99), - "rms-prop": dict()} + base_kwargs = {"adam": dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon), + "nadam": dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon), + "rms-prop": dict(epsilon=epsilon)} self._kwargs = base_kwargs[optimizer] self._configure(learning_rate, clipnorm, arguments) diff --git a/plugins/train/model/original_defaults.py b/plugins/train/model/original_defaults.py index d039df6a4e..76b8775e1d 100755 --- a/plugins/train/model/original_defaults.py +++ b/plugins/train/model/original_defaults.py @@ -18,7 +18,7 @@ The following keys are expected for the _DEFAULTS dict: datatype: [required] A python type class. This limits the type of data that can be provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , + correct type to faceswap. Valid data types are: , , , . default: [required] The default value for this option. info: [required] A string describing what this option does. @@ -27,12 +27,15 @@ a combobox / radio option in the GUI. gui_radio: [optional] If are defined, this indicates that the GUI should use radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required + min_max: [partial] For and data types this is required otherwise it is ignored. Should be a tuple of min and max accepted values. This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is + rounding: [partial] For and data types this is required otherwise it is ignored. Used for the GUI slider. For floats, this is the number of decimal places to display. For ints this is the step size. + You can also pass in a list of discreet values for this item, which should be + of the same data type as the given 'datatype'. This will lock the scale to + only those values displayed in the list. fixed: [optional] [train only]. Training configurations are fixed when the model is created, and then reloaded from the state file. Marking an item as fixed=False indicates that this value can be changed for existing models, and will override @@ -57,4 +60,4 @@ fixed=True, group="settings", ), -) \ No newline at end of file +) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index b2b539d48b..40fda06684 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -307,9 +307,11 @@ def _log_tensorboard(self, loss): def _collate_and_store_loss(self, loss): """ Collate the loss into totals for each side. - The losses are then into a total for each side. Loss totals are added to + The losses are summed into a total for each side. Loss totals are added to :attr:`model.state._history` to track the loss drop per save iteration for backup purposes. + If NaN protection is enabled, Checks for NaNs and raises an error if detected. + Parameters ---------- loss: list @@ -319,7 +321,18 @@ def _collate_and_store_loss(self, loss): ------- list List of 2 ``floats`` which is the total loss for each side + + Raises + ------ + FaceswapError + If a NaN is detected, a :class:`FaceswapError` will be raised """ + # NaN protection + if self._config["nan_protection"] and not all(np.isfinite(val) for val in loss): + logger.critical("NaN Detected. Loss: %s", loss) + raise FaceswapError("A NaN was detected and you have NaN protection enabled. Training " + "has been terminated.") + split = len(loss) // 2 combined_loss = [sum(loss[:split]), sum(loss[split:])] self._model.add_history(combined_loss) From 784155fe27909dfcedd9bc89ca56b542d2b488b8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 20 Mar 2021 13:47:29 +0000 Subject: [PATCH 419/981] locales - Add in missing manual tool source translations --- locales/es/LC_MESSAGES/tools.manual.mo | Bin 2141 -> 2141 bytes locales/es/LC_MESSAGES/tools.manual.po | 95 ++++++++++++++++++------- locales/tools.manual.pot | 10 ++- 3 files changed, 78 insertions(+), 27 deletions(-) diff --git a/locales/es/LC_MESSAGES/tools.manual.mo b/locales/es/LC_MESSAGES/tools.manual.mo index b56085b4bf0d0a5f1f27d8c712bd02f5e52b9deb..50853fc1b3ffc5b9b996ce46cef558915714db5d 100644 GIT binary patch delta 72 zcmcaBa93c%J7#$!0|i54D-$zq10dk?Nh~hW4Jk^@E6zzQ$uF`}nEabrir*(OFTFG| bJ=IDfwK(22c(Mq~l*zkTv^Rfc$zTQmT}>D` delta 72 zcmcaBa93c%J7!)(0|i4fD-&~V10a~p%OW+|oMp10PhxS2Zb(sLUU5!hNq&))!sL@I bI{ZF~dFiEz>8Vx\n" "Language-Team: LANGUAGE \n" @@ -187,6 +187,14 @@ msgstr "" msgid "Landmark point editor" msgstr "" +#: ./tools/manual/frameviewer\frame.py:397 +msgid "Next" +msgstr "" + +#: ./tools/manual/frameviewer\frame.py:397 +msgid "Previous" +msgstr "" + #: ./tools/manual/frameviewer\frame.py:408 msgid "Revert to saved Alignments ({})" msgstr "" From a49f810e7c8643348aeea7a545d799e6ea507611 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 20 Mar 2021 14:08:07 +0000 Subject: [PATCH 420/981] GUI - Make tooltips themeable --- lib/gui/.cache/themes/default.json | 5 +++++ tools/manual/frameviewer/frame.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/gui/.cache/themes/default.json b/lib/gui/.cache/themes/default.json index 54a9adaa8b..54c7b74a7f 100644 --- a/lib/gui/.cache/themes/default.json +++ b/lib/gui/.cache/themes/default.json @@ -75,5 +75,10 @@ "console": { "background_color": "#CDD3D5", "foreground_color": "#000000" + }, + "tooltip": { + "background_color": "#FFFFEA", + "border_color": "#FFFFEA", + "font_color": "#000000" } } \ No newline at end of file diff --git a/tools/manual/frameviewer/frame.py b/tools/manual/frameviewer/frame.py index 660fd0a3c3..4fc028ca2d 100644 --- a/tools/manual/frameviewer/frame.py +++ b/tools/manual/frameviewer/frame.py @@ -394,7 +394,7 @@ def _set_selected_action_tkvar(self): def _add_static_buttons(self): """ Add the buttons to copy alignments from previous and next frames """ - lookup = dict(copy_prev=("Previous", "C"), copy_next=("Next", "V"), reload=("", "R")) + lookup = dict(copy_prev=(_("Previous"), "C"), copy_next=(_("Next"), "V"), reload=("", "R")) frame = ttk.Frame(self) frame.pack(side=tk.TOP, fill=tk.Y) sep = ttk.Frame(frame, height=2, relief=tk.RIDGE) From 6872173d7e86d92f9cd796f1a29e1ae71ad13a12 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 20 Mar 2021 14:08:53 +0000 Subject: [PATCH 421/981] GUI - Add missing file --- lib/gui/custom_widgets.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index 0642211fe1..d90b72b48b 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -540,8 +540,6 @@ class Tooltip: # pylint:disable=too-few-public-methods ---------- widget: tkinter object The widget to apply the tool-tip to - background: str, optional - The hex code for the background color. Default:'#FFFFEA' pad: tuple, optional (left, top, right, bottom) padding for the tool-tip. Default: (5, 3, 5, 3) text: str, optional @@ -565,7 +563,7 @@ class Tooltip: # pylint:disable=too-few-public-methods Adapted from StackOverflow: http://stackoverflow.com/questions/3221956 and http://www.daniweb.com/programming/software-development/code/484591/a-tooltip-class-for-tkinter """ - def __init__(self, widget, *, background="#FFFFEA", pad=(5, 3, 5, 3), text="widget info", + def __init__(self, widget, *, pad=(5, 3, 5, 3), text="widget info", text_variable=None, waittime=400, wraplength=250): self._waittime = waittime # in milliseconds, originally 500 @@ -576,7 +574,7 @@ def __init__(self, widget, *, background="#FFFFEA", pad=(5, 3, 5, 3), text="widg self._widget.bind("", self._on_enter) self._widget.bind("", self._on_leave) self._widget.bind("", self._on_leave) - self._background = background + self._theme = get_config().user_theme["tooltip"] self._pad = pad self._ident = None self._topwidget = None @@ -647,7 +645,6 @@ def tip_pos_calculator(widget, label, return x_1, y_1 - background = self._background pad = self._pad widget = self._widget @@ -663,7 +660,10 @@ def tip_pos_calculator(widget, label, self._topwidget.wm_overrideredirect(True) win = tk.Frame(self._topwidget, - background=background, + background=self._theme["background_color"], + highlightbackground=self._theme["border_color"], + highlightcolor=self._theme["border_color"], + highlightthickness=1, borderwidth=0) text = self._text @@ -672,7 +672,8 @@ def tip_pos_calculator(widget, label, label = tk.Label(win, text=text, justify=tk.LEFT, - background=background, + background=self._theme["background_color"], + foreground=self._theme["font_color"], relief=tk.SOLID, borderwidth=0, wraplength=self._wraplength) From 51376bee6e30a509567b3f6b0fb83abe6e21d409 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 20 Mar 2021 14:18:08 +0000 Subject: [PATCH 422/981] Update translations --- locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 41749 -> 40977 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 109 ++++++++++++------------- locales/lib.cli.args.pot | 77 ++++++++--------- locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 54072 -> 53074 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 109 ++++++++++++------------- 5 files changed, 140 insertions(+), 155 deletions(-) diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo index d3da58c1875b6867c519cd0c15c9f7748ebed471..4f07f7ea1954f845ee8fa83b65f26198b4be23fe 100644 GIT binary patch delta 1806 zcmYk6eN5F=7{|X{P>>fzR4`3I?FAH-3kZthRo+S>Uh*|DMN}e7w=AjF%da%W%FRh$ z%WF`J+A7zUyJf9%W5H_8)alk{%hcv{!@qQG4g16TJoo%+o$Y%+=XuUK_jk_oJm-YJ z4;<(Zco#Dg!lE^!JJAOLX)7!k!4K{7NNGo$^gGOitCFM>__vLc5@12Hv=9d2>*j~Y z;4L^G9!-&2S$7oXLL3ThhPv+94j%Tx(w+y zd>HP*zj~bX1@@)!QV$GIkRq@+Q>teCG~9zdZlcrzBQS*jPk4myt8@~(W#AmFV&JFA zj_FgRjrf;mOY`u51?#YH=1BS23v;FG*zHrLO4vM&4a0-577jq0pFCYkgezc#s6GOJ z#vaa-D!c$Y%$LsKNG*`surI(66GLd!`>T1G+z1W91uGP-m2VCU(;EULaOiqQnp_Ns)&$sJ|zn!2LhlS`vNPX#3NPX$J zZ}&q>&5y7Trm_7-c&t&n$hM zqU)u-jCZzDAJ~6yaFIIbwMlCk2yB!-VPO3xDHl6=GarXD+a0?eb)me{A+5u|;W3G9 zsDBHaz)pHxx?$^|aH(k`50s61;B)XCWRI%f#?^x#!9NJS_c@&*bL@~_4R8X&aSc0* zReP~-!ylN~^#b|EZrnx1Q-nKrOUsD)&}&>l{1y9M3J)EiAX(oF--M0buK2FNChU80 zk_|lz$%3bobZloi#D%)j^RN#N!V8?NBg`gX&0FpPsXpvBI4VNnGVca#g+oVNYUqyKbWcaD?6elXN(s&iXtLEw`7T73EE6#dr&*!T9$mIB0h} z4;3RTzPnK_nuHdkWHcN7+qev|R?V+PVzOBt%0dNZ(2`Iv)^pGCgY*}m=_rIOca?~X zpnH(D(I_L<Cmco8mdATert9Wmm*8UTvUo0 ykhSq>YOF_*!7?-t<)B$8Kbn@<6d&zL`L!~ss4zEJQXVXezT9v&A$oOrVZ%R9AM8T_ delta 2505 zcmbW1d2AI`6o-Gd1!^r&cFH0HMV3Na+5)meH>A=+p@pys46joaqbxos&oA;aVo-=pmo;l~- z_x#C(mQNC5SNbNkh*61#p|=yHO|YyR7e-nSDSD682M&Zy$C$TUSIQ7+ zE;nl6E8KW}sPr=|BppTgXASrH0xYpq%JfD zCXJTn!+dDgZ-pdNAH&udY5t7K1st`R(rnT{XM%JNds~*Y0lSR1%w^rtNz!+0bZZJh zBuZaCNI*#hJX0FLx|DfRcb>D@y$OzmVR#fyg&9Qz0arnOdX7uXMD{t8MKHbCkGvK> zh5aTx&W2efQailL;&%K^3#9j9W~t9>unfDM&oe!83m(Moj_fX9?_Xm<-Jq^nxZu10emT;gEjQm`-~VG^Lpi55w(?sW%+Cgob0?rls@)tf=%k zq{@H)RY)>)1HKHimQnu`OwKIxGq7X1w1*d+hhN}dyF%Iwr>*px@F+B2b`ths-MUo- ziM?sHv;(^dnm|$>;XeLVYyIA+hb{Q`uJcPf(f0Sf6Jzob3*X&9N3&ond3q5$(eYEa z53a%Ay+(Qie_$i^#qL#0_re_4=k7W`wbvgdX#7sS^bqS$lmF$|sh;$$c|Pcus;W_% zj2&xZ@;H-=klgLsya&HA_G8jB348&IuVD|}M!?t!6!Z-1c9Y3G?8=>- zB${&PF6Pwq<{oJU{(=|$lD6`7Z}EIPY&Pe=vc>Pl>#%|w6ZT4!Pun2n(>Z7Y%Xo?Z z02qUZ;aPZzFRS0j`G=*g@jnzT*zX6}^MLdd>wbjm;Z5phO0(z?<-!56ks19>{WF;4#7#e~Lzs_7V>^9+#RQB@Gzpm=DL`hVqI}fX7@fnE&WvP~h)k(^ zp=l@`J%EPAbIimHa~#Yt=efj8IxcswNGvL;SXxq4P*qZFWm#3VZrBPoM%FUgFek^ZcQ<+sju#1M*M-@|t+Bjd#A*x$H@nqNb+(G{GM?0b z22}F5fC{?|sPI1n>c~vFQka~ZXXQ?wkw3K~t^9n_!gz$G1;vZWSV=fyld*t%s>9>U zEvMdXa6LP8s?BmdE9!No%(m=6B+AEcwc|^(t))>Ow40ALr^l>DJ7n{u6$pBDQIC98 z^CB-2ay+}iWfC;`tO$ni*Ej)8C;Trd4ZEbnUVgP{P$w13ZQS5fEPUD$#D5jI)x RI_xeI-&NQhSC-^2`VHM})!G06 diff --git a/locales/es/LC_MESSAGES/lib.cli.args.po b/locales/es/LC_MESSAGES/lib.cli.args.po index cfa2720a50..6aaff22cef 100644 --- a/locales/es/LC_MESSAGES/lib.cli.args.po +++ b/locales/es/LC_MESSAGES/lib.cli.args.po @@ -5,8 +5,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-03-12 14:35-0000\n" -"PO-Revision-Date: 2021-03-12 14:36+0000\n" +"POT-Creation-Date: 2021-03-20 14:15+0000\n" +"PO-Revision-Date: 2021-03-20 14:17+0000\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es\n" @@ -634,8 +634,7 @@ msgstr "" "hasta más de una semana.\n" "Los plugins de los modelos pueden configurarse en el menú \"Ajustes\"" -#: lib/cli/args.py:871 lib/cli/args.py:882 lib/cli/args.py:891 -#: lib/cli/args.py:902 +#: lib/cli/args.py:871 lib/cli/args.py:880 msgid "faces" msgstr "caras" @@ -649,17 +648,7 @@ msgstr "" "para la cara A. Esta es la cara original, es decir, la cara que se quiere " "eliminar y sustituir por la cara B." -#: lib/cli/args.py:883 -msgid "" -"DEPRECATED - This option will be removed in a future update. Path to " -"alignments file for training set A. Defaults to /alignments.json if " -"not provided." -msgstr "" -"DEPRECIADO - Esta opción se eliminará en una futura actualización. Ruta al " -"archivo de alineaciones para el conjunto de entrenamiento A. Por defecto es " -"/alignments.json si no se proporciona." - -#: lib/cli/args.py:892 +#: lib/cli/args.py:881 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -669,22 +658,12 @@ msgstr "" "para la cara B. Esta es la cara de intercambio, es decir, la cara que se " "quiere colocar en la cabeza de la persona A." -#: lib/cli/args.py:903 -msgid "" -"DEPRECATED - This option will be removed in a future update. Path to " -"alignments file for training set B. Defaults to /alignments.json if " -"not provided." -msgstr "" -"DEPRECIADO - Esta opción se eliminará en una futura actualización. Ruta al " -"archivo de alineaciones para el conjunto de entrenamiento B. Por defecto es " -"/alignments.json si no se proporciona." - -#: lib/cli/args.py:911 lib/cli/args.py:923 lib/cli/args.py:939 -#: lib/cli/args.py:964 lib/cli/args.py:974 +#: lib/cli/args.py:889 lib/cli/args.py:901 lib/cli/args.py:917 +#: lib/cli/args.py:942 lib/cli/args.py:952 msgid "model" msgstr "modelo" -#: lib/cli/args.py:912 +#: lib/cli/args.py:890 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -698,7 +677,7 @@ msgstr "" "carpeta que no exista (que se creará). Si continúa entrenando un modelo " "existente, especifique la ubicación del modelo existente." -#: lib/cli/args.py:924 +#: lib/cli/args.py:902 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -722,7 +701,7 @@ msgstr "" "NB: Los pesos solo se pueden cargar desde modelos del mismo complemento que " "desea entrenar." -#: lib/cli/args.py:940 +#: lib/cli/args.py:918 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -767,7 +746,7 @@ msgstr "" "recursos (se necesita una GPU con una buena cantidad de VRAM). Bueno para " "los detalles, pero más susceptible a las diferencias de color." -#: lib/cli/args.py:965 +#: lib/cli/args.py:943 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -779,7 +758,7 @@ msgstr "" "muestra un resumen del modelo que crearía el complemento elegido y los " "ajustes de configuración." -#: lib/cli/args.py:975 +#: lib/cli/args.py:953 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -793,12 +772,12 @@ msgstr "" "congelará el codificador, pero algunos modelos pueden tener opciones de " "configuración para congelar otras capas." -#: lib/cli/args.py:988 lib/cli/args.py:1000 lib/cli/args.py:1011 -#: lib/cli/args.py:1097 +#: lib/cli/args.py:966 lib/cli/args.py:978 lib/cli/args.py:989 +#: lib/cli/args.py:1075 msgid "training" msgstr "entrenamiento" -#: lib/cli/args.py:989 +#: lib/cli/args.py:967 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -811,7 +790,7 @@ msgstr "" "momento es el doble del número que se establece aquí. Los lotes más grandes " "requieren más RAM de la GPU." -#: lib/cli/args.py:1001 +#: lib/cli/args.py:979 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -826,22 +805,22 @@ msgstr "" "automáticamente en un número determinado de iteraciones, puede establecer " "ese valor aquí." -#: lib/cli/args.py:1012 +#: lib/cli/args.py:990 msgid "" "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" "Utilice la estrategia de distribución en espejo de Tensorflow para entrenar " "en múltiples GPUs." -#: lib/cli/args.py:1022 lib/cli/args.py:1032 +#: lib/cli/args.py:1000 lib/cli/args.py:1010 msgid "Saving" msgstr "Guardar" -#: lib/cli/args.py:1023 +#: lib/cli/args.py:1001 msgid "Sets the number of iterations between each model save." msgstr "Establece el número de iteraciones entre cada guardado del modelo." -#: lib/cli/args.py:1033 +#: lib/cli/args.py:1011 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -849,11 +828,11 @@ msgstr "" "Establece el número de iteraciones antes de guardar una copia de seguridad " "del modelo en su estado actual. Establece 0 para que esté desactivado." -#: lib/cli/args.py:1040 lib/cli/args.py:1051 lib/cli/args.py:1062 +#: lib/cli/args.py:1018 lib/cli/args.py:1029 lib/cli/args.py:1040 msgid "timelapse" msgstr "intervalo" -#: lib/cli/args.py:1041 +#: lib/cli/args.py:1019 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -867,7 +846,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-B." -#: lib/cli/args.py:1052 +#: lib/cli/args.py:1030 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -881,7 +860,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-A." -#: lib/cli/args.py:1063 +#: lib/cli/args.py:1041 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -893,24 +872,24 @@ msgstr "" "Si se suministran las carpetas de entrada pero no la carpeta de salida, se " "guardará por defecto en la carpeta del modelo /timelapse/" -#: lib/cli/args.py:1075 lib/cli/args.py:1082 lib/cli/args.py:1089 +#: lib/cli/args.py:1053 lib/cli/args.py:1060 lib/cli/args.py:1067 msgid "preview" msgstr "previsualización" -#: lib/cli/args.py:1076 +#: lib/cli/args.py:1054 msgid "" "Percentage amount to scale the preview by. 100%% is the model output size." msgstr "" "Cantidad porcentual para escalar la vista previa. 100%% es el tamaño de " "salida del modelo." -#: lib/cli/args.py:1083 +#: lib/cli/args.py:1061 msgid "Show training preview output. in a separate window." msgstr "" "Mostrar la salida de la vista previa del entrenamiento. en una ventana " "separada." -#: lib/cli/args.py:1090 +#: lib/cli/args.py:1068 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -918,7 +897,7 @@ msgstr "" "Escribe el resultado del entrenamiento en un archivo. La imagen se " "almacenará en la raíz de su carpeta FaceSwap." -#: lib/cli/args.py:1098 +#: lib/cli/args.py:1076 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -926,12 +905,12 @@ msgstr "" "Desactiva el registro de TensorBoard. NB: Desactivar los registros significa " "que no podrá utilizar el gráfico o el análisis de esta sesión en la GUI." -#: lib/cli/args.py:1105 lib/cli/args.py:1114 lib/cli/args.py:1123 -#: lib/cli/args.py:1132 +#: lib/cli/args.py:1083 lib/cli/args.py:1092 lib/cli/args.py:1101 +#: lib/cli/args.py:1110 msgid "augmentation" msgstr "aumento" -#: lib/cli/args.py:1106 +#: lib/cli/args.py:1084 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -941,7 +920,7 @@ msgstr "" "conjunto de caras opuestas en lugar de deformar la cara al azar. Esta es la " "forma 'dfaker' de hacer la deformación." -#: lib/cli/args.py:1115 +#: lib/cli/args.py:1093 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -952,7 +931,7 @@ msgstr "" "general, esto debería dejarse sin efecto, excepto durante el 'entrenamiento " "de ajuste'." -#: lib/cli/args.py:1124 +#: lib/cli/args.py:1102 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -962,7 +941,7 @@ msgstr "" "diferencias de color entre los conjuntos A y B, con un mayor coste de tiempo " "de entrenamiento. Activa esta opción para desactivar el aumento de color." -#: lib/cli/args.py:1133 +#: lib/cli/args.py:1111 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -975,6 +954,24 @@ msgstr "" "esta opción desde el principio, es probable que arruine el modelo y se " "obtengan resultados terribles." -#: lib/cli/args.py:1158 +#: lib/cli/args.py:1136 msgid "Output to Shell console instead of GUI console" msgstr "Salida a la consola Shell en lugar de la consola GUI" + +#~ msgid "" +#~ "DEPRECATED - This option will be removed in a future update. Path to " +#~ "alignments file for training set A. Defaults to /alignments.json " +#~ "if not provided." +#~ msgstr "" +#~ "DEPRECIADO - Esta opción se eliminará en una futura actualización. Ruta " +#~ "al archivo de alineaciones para el conjunto de entrenamiento A. Por " +#~ "defecto es /alignments.json si no se proporciona." + +#~ msgid "" +#~ "DEPRECATED - This option will be removed in a future update. Path to " +#~ "alignments file for training set B. Defaults to /alignments.json " +#~ "if not provided." +#~ msgstr "" +#~ "DEPRECIADO - Esta opción se eliminará en una futura actualización. Ruta " +#~ "al archivo de alineaciones para el conjunto de entrenamiento B. Por " +#~ "defecto es /alignments.json si no se proporciona." diff --git a/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index d0191d8146..b868cfc95b 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-03-12 14:35-0000\n" +"POT-Creation-Date: 2021-03-20 14:15+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -269,8 +269,7 @@ msgid "" "Model plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:871 lib/cli/args.py:882 lib/cli/args.py:891 -#: lib/cli/args.py:902 +#: lib/cli/args.py:871 lib/cli/args.py:880 msgid "faces" msgstr "" @@ -278,34 +277,26 @@ msgstr "" msgid "Input directory. A directory containing training images for face A. This is the original face, i.e. the face that you want to remove and replace with face B." msgstr "" -#: lib/cli/args.py:883 -msgid "DEPRECATED - This option will be removed in a future update. Path to alignments file for training set A. Defaults to /alignments.json if not provided." -msgstr "" - -#: lib/cli/args.py:892 +#: lib/cli/args.py:881 msgid "Input directory. A directory containing training images for face B. This is the swap face, i.e. the face that you want to place onto the head of person A." msgstr "" -#: lib/cli/args.py:903 -msgid "DEPRECATED - This option will be removed in a future update. Path to alignments file for training set B. Defaults to /alignments.json if not provided." -msgstr "" - -#: lib/cli/args.py:911 lib/cli/args.py:923 lib/cli/args.py:939 -#: lib/cli/args.py:964 lib/cli/args.py:974 +#: lib/cli/args.py:889 lib/cli/args.py:901 lib/cli/args.py:917 +#: lib/cli/args.py:942 lib/cli/args.py:952 msgid "model" msgstr "" -#: lib/cli/args.py:912 +#: lib/cli/args.py:890 msgid "Model directory. This is where the training data will be stored. You should always specify a new folder for new models. If starting a new model, select either an empty folder, or a folder which does not exist (which will be created). If continuing to train an existing model, specify the location of the existing model." msgstr "" -#: lib/cli/args.py:924 +#: lib/cli/args.py:902 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For most models this will load weights from the Encoder of the given model into the encoder of the newly created model. Some plugins may have specific configuration options allowing you to load weights from other layers. Weights will only be loaded when creating a new model. This option will be ignored if you are resuming an existing model. Generally you will also want to 'freeze-weights' whilst the rest of your model catches up with your Encoder.\n" "NB: Weights can only be loaded from models of the same plugin as you intend to train." msgstr "" -#: lib/cli/args.py:940 +#: lib/cli/args.py:918 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings menu or the config folder.\n" "L|original: The original model created by /u/deepfakes.\n" @@ -320,101 +311,101 @@ msgid "" "L|villain: 128px in/out model from villainguy. Very resource hungry (You will require a GPU with a fair amount of VRAM). Good for details, but more susceptible to color differences." msgstr "" -#: lib/cli/args.py:965 +#: lib/cli/args.py:943 msgid "Output a summary of the model and exit. If a model folder is provided then a summary of the saved model is displayed. Otherwise a summary of the model that would be created by the chosen plugin and configuration settings is displayed." msgstr "" -#: lib/cli/args.py:975 +#: lib/cli/args.py:953 msgid "Freeze the weights of the model. Freezing weights means that some of the parameters in the model will no longer continue to learn, but those that are not frozen will continue to learn. For most models, this will freeze the encoder, but some models may have configuration options for freezing other layers." msgstr "" -#: lib/cli/args.py:988 lib/cli/args.py:1000 lib/cli/args.py:1011 -#: lib/cli/args.py:1097 +#: lib/cli/args.py:966 lib/cli/args.py:978 lib/cli/args.py:989 +#: lib/cli/args.py:1075 msgid "training" msgstr "" -#: lib/cli/args.py:989 +#: lib/cli/args.py:967 msgid "Batch size. This is the number of images processed through the model for each side per iteration. NB: As the model is fed 2 sides at a time, the actual number of images within the model at any one time is double the number that you set here. Larger batches require more GPU RAM." msgstr "" -#: lib/cli/args.py:1001 +#: lib/cli/args.py:979 msgid "Length of training in iterations. This is only really used for automation. There is no 'correct' number of iterations a model should be trained for. You should stop training when you are happy with the previews. However, if you want the model to stop automatically at a set number of iterations, you can set that value here." msgstr "" -#: lib/cli/args.py:1012 +#: lib/cli/args.py:990 msgid "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" -#: lib/cli/args.py:1022 lib/cli/args.py:1032 +#: lib/cli/args.py:1000 lib/cli/args.py:1010 msgid "Saving" msgstr "" -#: lib/cli/args.py:1023 +#: lib/cli/args.py:1001 msgid "Sets the number of iterations between each model save." msgstr "" -#: lib/cli/args.py:1033 +#: lib/cli/args.py:1011 msgid "Sets the number of iterations before saving a backup snapshot of the model in it's current state. Set to 0 for off." msgstr "" -#: lib/cli/args.py:1040 lib/cli/args.py:1051 lib/cli/args.py:1062 +#: lib/cli/args.py:1018 lib/cli/args.py:1029 lib/cli/args.py:1040 msgid "timelapse" msgstr "" -#: lib/cli/args.py:1041 +#: lib/cli/args.py:1019 msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. This should be the input folder of 'A' faces that you would like to use for creating the timelapse. You must also supply a --timelapse-output and a --timelapse-input-B parameter." msgstr "" -#: lib/cli/args.py:1052 +#: lib/cli/args.py:1030 msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. This should be the input folder of 'B' faces that you would like to use for creating the timelapse. You must also supply a --timelapse-output and a --timelapse-input-A parameter." msgstr "" -#: lib/cli/args.py:1063 +#: lib/cli/args.py:1041 msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. If the input folders are supplied but no output folder, it will default to your model folder /timelapse/" msgstr "" -#: lib/cli/args.py:1075 lib/cli/args.py:1082 lib/cli/args.py:1089 +#: lib/cli/args.py:1053 lib/cli/args.py:1060 lib/cli/args.py:1067 msgid "preview" msgstr "" -#: lib/cli/args.py:1076 +#: lib/cli/args.py:1054 msgid "Percentage amount to scale the preview by. 100%% is the model output size." msgstr "" -#: lib/cli/args.py:1083 +#: lib/cli/args.py:1061 msgid "Show training preview output. in a separate window." msgstr "" -#: lib/cli/args.py:1090 +#: lib/cli/args.py:1068 msgid "Writes the training result to a file. The image will be stored in the root of your FaceSwap folder." msgstr "" -#: lib/cli/args.py:1098 +#: lib/cli/args.py:1076 msgid "Disables TensorBoard logging. NB: Disabling logs means that you will not be able to use the graph or analysis for this session in the GUI." msgstr "" -#: lib/cli/args.py:1105 lib/cli/args.py:1114 lib/cli/args.py:1123 -#: lib/cli/args.py:1132 +#: lib/cli/args.py:1083 lib/cli/args.py:1092 lib/cli/args.py:1101 +#: lib/cli/args.py:1110 msgid "augmentation" msgstr "" -#: lib/cli/args.py:1106 +#: lib/cli/args.py:1084 msgid "Warps training faces to closely matched Landmarks from the opposite face-set rather than randomly warping the face. This is the 'dfaker' way of doing warping." msgstr "" -#: lib/cli/args.py:1115 +#: lib/cli/args.py:1093 msgid "To effectively learn, a random set of images are flipped horizontally. Sometimes it is desirable for this not to occur. Generally this should be left off except for during 'fit training'." msgstr "" -#: lib/cli/args.py:1124 +#: lib/cli/args.py:1102 msgid "Color augmentation helps make the model less susceptible to color differences between the A and B sets, at an increased training time cost. Enable this option to disable color augmentation." msgstr "" -#: lib/cli/args.py:1133 +#: lib/cli/args.py:1111 msgid "Warping is integral to training the Neural Network. This option should only be enabled towards the very end of training to try to bring out more detail. Think of it as 'fine-tuning'. Enabling this option from the beginning is likely to kill a model and lead to terrible results." msgstr "" -#: lib/cli/args.py:1158 +#: lib/cli/args.py:1136 msgid "Output to Shell console instead of GUI console" msgstr "" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo index 4e74b80f5ca5632ec02461ed68b5d638e22a0027..e146ad4d831f3d8ec09b235c6b43f29098faf3b5 100644 GIT binary patch delta 1805 zcmYk6YfzL`7{~vz)DjmpZbA?jO#<0~1tgJ$MG!?L5-&m48c8~8qmGz1UEgFz)22R% zna(;g1ENz{nX_qR(bRYu91I7wnLd=458CLrCNJ1D>F>PnHq$fv-`{zjbIyBi&pG`E zUFW-8)}{E!UBa4BI_iy()49U;Bs@2|dx04|?GTCJv9l zd$1fHiji9BcMYDzpA{#qfH7mGagwEW3a=CBfWO1P;duBfwFPv%0lhHgQHd(ejhFs~ z&%>?6TN0$B_?HuF6zK&Rgnr_8;9ka8*<;L>iu16RinD1B z$3HH$5nq%cO(Wh9=i>jFDdpk&vZR~%E3%~;*ffy|!;j%CcmkUC(UT-MTnvNkY5@L% zKbk95S`o}JPdY~+)*~&${|)+S=r3e22C_=oI+wJ*j0v&lWz(f3+6Aj5PDNi;OYg&P z;0EZQK^qu?CGaM!g&8%@#FoKE{Li3;G|Jy*SV5~}V>n;f17E9y#3#&_zJtfZ+*B_$ z;1|zva>9aL_@A&`lRH!AIXkx>zJu?kaV*>dOXaw8F^o!wN^QE_l=PYyv=vgFfqTgY78|EyQa;Z;$PI?8uu37q&etj^X z_`)Te{|{7HWJW5idXYSUEwFR9ONHx_C{J!U&s}C zgJKbX0sKC=9Y5-ACXe3<`)MD~g4hfVy(d+}%*`ANY=u9=yU^UCQym-)TWnpV&`rk$ zACi0|*R7A7>(SiFB=C3dWFQ)5?U61pP+zx{##z1F%MNi?!9z^m#cq5pCGz3qey%Uy z%ZL2r`&MG{x(&?{v>vA)JO#HgzUs-W9Rx<8sc8G2y9}?x-F*1;fYi>wOMjB4@ZpgY ztPcKjk~kbZ%~hj)#ULxD-F=u&yn2ZIAs#r(cVHsRk0ky%OvXPlY;uk58l(_QU<5|P zOOTtV8!#TGo^u`~4>T*RgnY-o+qtj=nXI0SvJlURs*(9t0s7Cl47SbWpS)F?@fcqd zkjFS!6!O|7=Q)0cas`@%{3r(1paztKjEzNRqQ(-CS)DPS3ELvWObOE<6+Meg?x+s= zkg+0EkJx!}-R{$V+X%fF@+F_5ZCBryR|2f~)le1#^{* z6+5us|5C~mQ7t0>Y)j&Q4vIrlQ5kAP#>~-V+a<0ld>Tzdndm8$7m9PwcZK%F4Ar=c Vi?Y0>)4ic}jlJ&B_LjqMJ^)H}@k{^! delta 2659 zcmbu9X>8O*7>9q-Qm|ZYvE0XS$WiE)0&E<0y(o{NPUuJ@>3Nvjm_JHP6RGSzDHgVYl})8n*tgs-ZG}%qhj&4$nw_9c?KU(y^qK#821xTL^!ISK!5zU?HEUUd3$LZ{uSLpN~5C? zZlYsXN9h-sK{_(Ar*sanyo)po`&$DH>oe}z3@BQpLAy~Jl%tUVPsD! z2M&UE{9;Hlbrx>)ljaLlKE^O7K^jH+Q~OC5X}_2#&7_^nDrt;6FhKeNM>ifKhzRN0 zPy&i1;E_^1G?w}>KDT;^OH+6>GH}wj)2f$Js4Tp7&q+!G;A zcrxU0^;6OU)>#j4!sb&r|2_Lp&LZgFv9STFV@0onK8{SnYI?IJ*;bZSfAHwVBu*3ZyNbfSf+j(}( zxUXO*?7c2>x$XPUbXbT1zrsj5UJZ~&+8a1%HlY2`-qvF<8lHhk@FI+Zw_yy7yBykK z56CiiN*c8h$d0jPVhCythvQ#k!iFKR3$ic32G9`=MX4wYB^L1&R!`I$-Cg)FDgg~ZlTbRc#U9Tf zWRK&I(l}hPN60k^eoySh9)|2weh_sE=9r2tgF{d}%(nH~D^Yn7nb~=hvNO{sXJ?s2 zGkK1u)D+M6d5XPep{J}BSQn$~fyG)k5Fu$zGS4zK0@p$K#`4ZDplk$r^v%T}&USFweZYf?o zg~nU#GxJM|7kCQX1ukX%$9S6G9Z>dP0?PQ?fHM9ypxT7!k20Dh4>ZYxM+~aHo%?1J z{|;xnQ{z-Q+nn{zCT9z2bM^%)oP)+WbTv4K!-MJr>jEoX#@Wh{ionapIc@?goa(eiCd;ts ziNLzx9C|Bpyq0C^oLZ+L7zax=5XV7#63$kPxIf9-C+t|f)Cbm9C!Fu#zjHF??xX2% z2zwc!)LSp7?5cHk6}IIZvun8I{O?pG-D57Q2P{guhs|^9|FHRy;mv2H-uvcjw@p60 F_;+zVmT&+7 diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.po b/locales/ru/LC_MESSAGES/lib.cli.args.po index b9cd9014be..36bbafbc3b 100644 --- a/locales/ru/LC_MESSAGES/lib.cli.args.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args.po @@ -5,8 +5,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2021-03-12 14:35-0000\n" -"PO-Revision-Date: 2021-03-12 14:37+0000\n" +"POT-Creation-Date: 2021-03-20 14:15+0000\n" +"PO-Revision-Date: 2021-03-20 14:17+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -610,8 +610,7 @@ msgstr "" "Обучение моделей может занять долгое время: от 24 часов до недели\n" "Каждую модель можно отдельно настроить в меню «Настройки»" -#: lib/cli/args.py:871 lib/cli/args.py:882 lib/cli/args.py:891 -#: lib/cli/args.py:902 +#: lib/cli/args.py:871 lib/cli/args.py:880 msgid "faces" msgstr "лица" @@ -624,17 +623,7 @@ msgstr "" "Входная папка. Папка содержащая изображения для тренировки лица A. Это " "исходное лицо т.е. лицо, которое вы хотите убрать, заменив лицом B." -#: lib/cli/args.py:883 -msgid "" -"DEPRECATED - This option will be removed in a future update. Path to " -"alignments file for training set A. Defaults to /alignments.json if " -"not provided." -msgstr "" -"УСТАРЕЛО - Эта настройка будет удалена в будущих обновлениях. Путь к файлу " -"выравнивания для обучающего набора A. По умолчанию используется /" -"alignments.json, если он не указан." - -#: lib/cli/args.py:892 +#: lib/cli/args.py:881 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -643,22 +632,12 @@ msgstr "" "Входная папка. Папка содержащая изображения для тренировки лица B. Это новое " "лицо т.е. лицо, которое вы хотите поместить на голову человека A." -#: lib/cli/args.py:903 -msgid "" -"DEPRECATED - This option will be removed in a future update. Path to " -"alignments file for training set B. Defaults to /alignments.json if " -"not provided." -msgstr "" -"УСТАРЕЛО - Эта настройка будет удалена в будущих обновлениях. Путь к файлу " -"выравнивания для обучающего набора B. По умолчанию используется /" -"alignments.json, если он не указан." - -#: lib/cli/args.py:911 lib/cli/args.py:923 lib/cli/args.py:939 -#: lib/cli/args.py:964 lib/cli/args.py:974 +#: lib/cli/args.py:889 lib/cli/args.py:901 lib/cli/args.py:917 +#: lib/cli/args.py:942 lib/cli/args.py:952 msgid "model" msgstr "модель" -#: lib/cli/args.py:912 +#: lib/cli/args.py:890 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -672,7 +651,7 @@ msgstr "" "будет создана). Если вы хотите продолжить тренировку, выберите папку с уже " "существующими сохранениями." -#: lib/cli/args.py:924 +#: lib/cli/args.py:902 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -696,7 +675,7 @@ msgstr "" "NB: Вес можно загружать только из моделей того же плагина, который вы " "собираетесь тренировать." -#: lib/cli/args.py:940 +#: lib/cli/args.py:918 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -742,7 +721,7 @@ msgstr "" "ресурсам (Вам потребуется GPU с хорошим количеством видеопамяти). Хороша для " "деталей, но подвержена к неправильной передаче цвета." -#: lib/cli/args.py:965 +#: lib/cli/args.py:943 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -754,7 +733,7 @@ msgstr "" "сводная информация о модели, которая будет создана выбранным плагином, и " "параметрами конфигурации." -#: lib/cli/args.py:975 +#: lib/cli/args.py:953 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -768,12 +747,12 @@ msgstr "" "некоторые модели могут иметь параметры конфигурации для замораживания других " "слоев." -#: lib/cli/args.py:988 lib/cli/args.py:1000 lib/cli/args.py:1011 -#: lib/cli/args.py:1097 +#: lib/cli/args.py:966 lib/cli/args.py:978 lib/cli/args.py:989 +#: lib/cli/args.py:1075 msgid "training" msgstr "тренировка" -#: lib/cli/args.py:989 +#: lib/cli/args.py:967 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -786,7 +765,7 @@ msgstr "" "изображений в два раза больше этого числа. Увеличение размера партии требует " "больше памяти GPU." -#: lib/cli/args.py:1001 +#: lib/cli/args.py:979 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -800,22 +779,22 @@ msgstr "" "Однако, если вы хотите, чтобы тренировка прервалась после указанного кол-ва " "итерация, вы можете ввести это здесь." -#: lib/cli/args.py:1012 +#: lib/cli/args.py:990 msgid "" "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" "Использовать стратегию зеркального распределения Tensorflow для совместной " "тренировки сразу на нескольких GPU." -#: lib/cli/args.py:1022 lib/cli/args.py:1032 +#: lib/cli/args.py:1000 lib/cli/args.py:1010 msgid "Saving" msgstr "Сохранение" -#: lib/cli/args.py:1023 +#: lib/cli/args.py:1001 msgid "Sets the number of iterations between each model save." msgstr "Установка количества итераций между сохранениями модели." -#: lib/cli/args.py:1033 +#: lib/cli/args.py:1011 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -823,11 +802,11 @@ msgstr "" "Устанавливает кол-во итераций перед созданием резервной копии модели. " "Установите в 0 для отключения." -#: lib/cli/args.py:1040 lib/cli/args.py:1051 lib/cli/args.py:1062 +#: lib/cli/args.py:1018 lib/cli/args.py:1029 lib/cli/args.py:1040 msgid "timelapse" msgstr "таймлапс" -#: lib/cli/args.py:1041 +#: lib/cli/args.py:1019 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -840,7 +819,7 @@ msgstr "" "папку лиц набора 'A' для использования при создании таймлапса. Вам также " "нужно указать параметры--timelapse-output и --timelapse-input-B." -#: lib/cli/args.py:1052 +#: lib/cli/args.py:1030 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -854,7 +833,7 @@ msgstr "" "таймлапса. Вы также должны указать параметр --timelapse-output и --timelapse-" "input-A." -#: lib/cli/args.py:1063 +#: lib/cli/args.py:1041 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -866,22 +845,22 @@ msgstr "" "указаны только входные папки, то по умолчанию вывод будет сохранен вместе с " "моделью в подкаталог /timelapse/" -#: lib/cli/args.py:1075 lib/cli/args.py:1082 lib/cli/args.py:1089 +#: lib/cli/args.py:1053 lib/cli/args.py:1060 lib/cli/args.py:1067 msgid "preview" msgstr "предварительный просмотр" -#: lib/cli/args.py:1076 +#: lib/cli/args.py:1054 msgid "" "Percentage amount to scale the preview by. 100%% is the model output size." msgstr "" "Величина в процентах, на которую требуется масштабировать предварительный " "просмотр. 100 %% - размер вывода модели." -#: lib/cli/args.py:1083 +#: lib/cli/args.py:1061 msgid "Show training preview output. in a separate window." msgstr "Показывать предварительный просмотр в отдельном окне." -#: lib/cli/args.py:1090 +#: lib/cli/args.py:1068 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -889,7 +868,7 @@ msgstr "" "Записывает результат тренировки в файл. Файл будет сохранен в коренной папке " "FaceSwap." -#: lib/cli/args.py:1098 +#: lib/cli/args.py:1076 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -897,12 +876,12 @@ msgstr "" "Отключает журнал TensorBoard. Примечание: Отключение журналов означает, что " "вы не сможете использовать графики или анализ сессии внутри GUI." -#: lib/cli/args.py:1105 lib/cli/args.py:1114 lib/cli/args.py:1123 -#: lib/cli/args.py:1132 +#: lib/cli/args.py:1083 lib/cli/args.py:1092 lib/cli/args.py:1101 +#: lib/cli/args.py:1110 msgid "augmentation" msgstr "аугментация" -#: lib/cli/args.py:1106 +#: lib/cli/args.py:1084 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -912,7 +891,7 @@ msgstr "" "Ориентирами/Landmarks противоположного набора лиц. Этот способ используется " "пакетом \"dfaker\"." -#: lib/cli/args.py:1115 +#: lib/cli/args.py:1093 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -923,7 +902,7 @@ msgstr "" "происходило. Как правило, эту настройку не стоит трогать, за исключением " "периода «финальной шлифовки»." -#: lib/cli/args.py:1124 +#: lib/cli/args.py:1102 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -933,7 +912,7 @@ msgstr "" "цвета между наборами A and B ценой некоторого замедления скорости " "тренировки. Включите эту опцию для отключения цветовой аугментации." -#: lib/cli/args.py:1133 +#: lib/cli/args.py:1111 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -946,6 +925,24 @@ msgstr "" "Включение этой опции с самого начала может убить модель и привести к ужасным " "результатам." -#: lib/cli/args.py:1158 +#: lib/cli/args.py:1136 msgid "Output to Shell console instead of GUI console" msgstr "Вывод в системную консоль вместо GUI" + +#~ msgid "" +#~ "DEPRECATED - This option will be removed in a future update. Path to " +#~ "alignments file for training set A. Defaults to /alignments.json " +#~ "if not provided." +#~ msgstr "" +#~ "УСТАРЕЛО - Эта настройка будет удалена в будущих обновлениях. Путь к " +#~ "файлу выравнивания для обучающего набора A. По умолчанию используется " +#~ " /alignments.json, если он не указан." + +#~ msgid "" +#~ "DEPRECATED - This option will be removed in a future update. Path to " +#~ "alignments file for training set B. Defaults to /alignments.json " +#~ "if not provided." +#~ msgstr "" +#~ "УСТАРЕЛО - Эта настройка будет удалена в будущих обновлениях. Путь к " +#~ "файлу выравнивания для обучающего набора B. По умолчанию используется " +#~ " /alignments.json, если он не указан." From 29cfdaad460b155e44b093b11afeaaeac9dd645f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 21 Mar 2021 23:29:31 +0000 Subject: [PATCH 423/981] Training startup updates - Remove alignments path option from cli - Restructure training_data.py - Load alignment data from PNG header during first epoch - lib.image.read_image_batch - Add option to return metadata - lib.utils.get_image_paths - Add option for explicit extension - plugins.train.trainer._base - remove pre-cache alignments code - scripts.train - Check first image in training folders for metadata - Documentation --- docs/full/lib/training.rst | 24 + docs/full/lib/training_data.rst | 18 - lib/cli/args.py | 22 - lib/image.py | 24 +- lib/training/__init__.py | 6 + lib/training/augmentation.py | 463 ++++++++++++++++ lib/training/generator.py | 822 ++++++++++++++++++++++++++++ lib/training_data.py | 930 -------------------------------- lib/utils.py | 6 +- plugins/train/trainer/_base.py | 467 +--------------- scripts/train.py | 19 +- 11 files changed, 1356 insertions(+), 1445 deletions(-) create mode 100644 docs/full/lib/training.rst delete mode 100755 docs/full/lib/training_data.rst create mode 100644 lib/training/__init__.py create mode 100644 lib/training/augmentation.py create mode 100644 lib/training/generator.py delete mode 100644 lib/training_data.py diff --git a/docs/full/lib/training.rst b/docs/full/lib/training.rst new file mode 100644 index 0000000000..ff8c3cde04 --- /dev/null +++ b/docs/full/lib/training.rst @@ -0,0 +1,24 @@ +**************** +training package +**************** + +The training Package handles the processing of faces for feeding into a Faceswap model. + +.. contents:: Contents + :local: + +augmentation module +=================== + +.. automodule:: lib.training.augmentation + :members: + :undoc-members: + :show-inheritance: + +generator module +================ + +.. automodule:: lib.training.generator + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib/training_data.rst b/docs/full/lib/training_data.rst deleted file mode 100755 index 5aa266e863..0000000000 --- a/docs/full/lib/training_data.rst +++ /dev/null @@ -1,18 +0,0 @@ -********************* -training\_data module -********************* - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~lib.training_data.ImageAugmentation - ~lib.training_data.TrainingDataGenerator - -.. rubric:: Module - -.. automodule:: lib.training_data - :members: - :undoc-members: - :show-inheritance: diff --git a/lib/cli/args.py b/lib/cli/args.py index 93a14839fd..f8e9a49310 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -872,17 +872,6 @@ def get_argument_list(): help=_("Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " "face B."))) - argument_list.append(dict( - opts=("-ala", "--alignments-A"), - action=FileFullPaths, - filetypes='alignments', - type=str, - dest="alignments_path_a", - default=None, - group=_("faces"), - help=_("DEPRECATED - This option will be removed in a future update. Path to " - "alignments file for training set A. Defaults to /alignments.json if " - "not provided."))) argument_list.append(dict( opts=("-B", "--input-B"), action=DirFullPaths, @@ -892,17 +881,6 @@ def get_argument_list(): help=_("Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " "A."))) - argument_list.append(dict( - opts=("-alb", "--alignments-B"), - action=FileFullPaths, - filetypes='alignments', - type=str, - dest="alignments_path_b", - default=None, - group=_("faces"), - help=_("DEPRECATED - This option will be removed in a future update. Path to " - "alignments file for training set B. Defaults to /alignments.json if " - "not provided."))) argument_list.append(dict( opts=("-m", "--model-dir"), action=DirFullPaths, diff --git a/lib/image.py b/lib/image.py index 70996635cb..c2679cd0d3 100644 --- a/lib/image.py +++ b/lib/image.py @@ -305,7 +305,7 @@ def read_image(filename, raise_error=False, with_metadata=False): return retval -def read_image_batch(filenames): +def read_image_batch(filenames, with_metadata=False): """ Load a batch of images from the given file locations. Leverages multi-threading to load multiple images from disk at the same time leading to vastly @@ -315,6 +315,10 @@ def read_image_batch(filenames): ---------- filenames: list A list of ``str`` full paths to the images to be loaded. + with_metadata: bool, optional + Only returns a value if the images loaded are extracted Faceswap faces. If ``True`` then + returns the Faceswap metadata stored with in a Face images .png exif header. + Default: ``False`` Returns ------- @@ -333,9 +337,12 @@ def read_image_batch(filenames): logger.trace("Requested batch: '%s'", filenames) executor = futures.ThreadPoolExecutor() with executor: - images = {executor.submit(read_image, filename, raise_error=True): filename + images = {executor.submit(read_image, filename, + raise_error=True, with_metadata=with_metadata): filename for filename in filenames} batch = [None for _ in range(len(filenames))] + if with_metadata: + meta = [None for _ in range(len(filenames))] # There is no guarantee that the same filename will not be passed through multiple times # (and when shuffle is true this can definitely happen), so we can't just call # filenames.index(). @@ -343,10 +350,17 @@ def read_image_batch(filenames): if fname == filename] for filename in set(filenames)} for future in futures.as_completed(images): - batch[return_indices[images[future]].pop()] = future.result() + return_idx = return_indices[images[future]].pop() + if with_metadata: + batch[return_idx], meta[return_idx] = future.result() + else: + batch[return_idx] = future.result() + batch = np.array(batch) - logger.trace("Returning images: (filenames: %s, batch shape: %s)", filenames, batch.shape) - return batch + retval = (batch, meta) if with_metadata else batch + logger.trace("Returning images: (filenames: %s, batch shape: %s, with_metadata: %s)", + filenames, batch.shape, with_metadata) + return retval def read_image_meta(filename): diff --git a/lib/training/__init__.py b/lib/training/__init__.py new file mode 100644 index 0000000000..a478362969 --- /dev/null +++ b/lib/training/__init__.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +""" Package for handling alignments files, detected faces and aligned faces along with their +associated objects. """ + +from .augmentation import ImageAugmentation +from .generator import TrainingDataGenerator diff --git a/lib/training/augmentation.py b/lib/training/augmentation.py new file mode 100644 index 0000000000..9463de9c34 --- /dev/null +++ b/lib/training/augmentation.py @@ -0,0 +1,463 @@ +#!/usr/bin/env python3 +""" Processes the augmentation of images for feeding into a Faceswap model. """ +import logging + +import cv2 +import numpy as np +from scipy.interpolate import griddata + +from lib.image import batch_convert_color + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class ImageAugmentation(): + """ Performs augmentation on batches of training images. + + Parameters + ---------- + batchsize: int + The number of images that will be fed through the augmentation functions at once. + is_display: bool + Whether the images being fed through will be used for Preview or Time-lapse. Disables + the "warp" augmentation for these images. + input_size: int + The expected input size for the model. It is assumed that the input to the model is always + a square image. This is the size, in pixels, of the `width` and the `height` of the input + to the model. + output_shapes: list + A list of tuples defining the output shapes from the model, in the order that the outputs + are returned. The tuples should be in (`height`, `width`, `channels`) format. + coverage_ratio: float + The ratio of the training image to be trained on. Dictates how much of the image will be + cropped out. E.G: a coverage ratio of 0.625 will result in cropping a 160px box from a + 256px image (:math:`256 * 0.625 = 160`) + config: dict + The configuration `dict` generated from :file:`config.train.ini` containing the trainer + plugin configuration options. + + Attributes + ---------- + initialized: bool + Flag to indicate whether :class:`ImageAugmentation` has been initialized with the training + image size in order to cache certain augmentation operations (see :func:`initialize`) + is_display: bool + Flag to indicate whether these augmentations are for time-lapses/preview images (``True``) + or standard training data (``False``) + """ + def __init__(self, batchsize, is_display, input_size, output_shapes, coverage_ratio, config): + logger.debug("Initializing %s: (batchsize: %s, is_display: %s, input_size: %s, " + "output_shapes: %s, coverage_ratio: %s, config: %s)", + self.__class__.__name__, batchsize, is_display, input_size, output_shapes, + coverage_ratio, config) + + self.initialized = False + self.is_display = is_display + + # Set on first image load from initialize + self._training_size = 0 + self._constants = None + + self._batchsize = batchsize + self._config = config + # Transform and Warp args + self._input_size = input_size + self._output_sizes = [shape[1] for shape in output_shapes if shape[2] == 3] + logger.debug("Output sizes: %s", self._output_sizes) + # Warp args + self._coverage_ratio = coverage_ratio + self._scale = 5 # Normal random variable scale + + logger.debug("Initialized %s", self.__class__.__name__) + + def initialize(self, training_size): + """ Initializes the caching of constants for use in various image augmentations. + + The training image size is not known prior to loading the images from disk and commencing + training, so it cannot be set in the :func:`__init__` method. When the first training batch + is loaded this function should be called to initialize the class and perform various + calculations based on this input size to cache certain constants for image augmentation + calculations. + + Parameters + ---------- + training_size: int + The size of the training images stored on disk that are to be fed into + :class:`ImageAugmentation`. The training images should always be square and of the + same size. This is the size, in pixels, of the `width` and the `height` of the + training images. + """ + logger.debug("Initializing constants. training_size: %s", training_size) + self._training_size = training_size + coverage = int(self._training_size * self._coverage_ratio // 2) * 2 + + # Color Aug + clahe_base_contrast = training_size // 128 + # Target Images + tgt_slices = slice(self._training_size // 2 - coverage // 2, + self._training_size // 2 + coverage // 2) + + # Random Warp + warp_range_ = np.linspace(self._training_size // 2 - coverage // 2, + self._training_size // 2 + coverage // 2, 5, dtype='float32') + warp_mapx = np.broadcast_to(warp_range_, (self._batchsize, 5, 5)).astype("float32") + warp_mapy = np.broadcast_to(warp_mapx[0].T, (self._batchsize, 5, 5)).astype("float32") + + warp_pad = int(1.25 * self._input_size) + warp_slices = slice(warp_pad // 10, -warp_pad // 10) + + # Random Warp Landmarks + p_mx = self._training_size - 1 + p_hf = (self._training_size // 2) - 1 + edge_anchors = np.array([(0, 0), (0, p_mx), (p_mx, p_mx), (p_mx, 0), + (p_hf, 0), (p_hf, p_mx), (p_mx, p_hf), (0, p_hf)]).astype("int32") + edge_anchors = np.broadcast_to(edge_anchors, (self._batchsize, 8, 2)) + grids = np.mgrid[0:p_mx:complex(self._training_size), 0:p_mx:complex(self._training_size)] + + self._constants = dict(clahe_base_contrast=clahe_base_contrast, + tgt_slices=tgt_slices, + warp_mapx=warp_mapx, + warp_mapy=warp_mapy, + warp_pad=warp_pad, + warp_slices=warp_slices, + warp_lm_edge_anchors=edge_anchors, + warp_lm_grids=grids) + self.initialized = True + logger.debug("Initialized constants: %s", {k: str(v) if isinstance(v, np.ndarray) else v + for k, v in self._constants.items()}) + + # <<< TARGET IMAGES >>> # + def get_targets(self, batch): + """ Returns the target images, and masks, if required. + + Parameters + ---------- + batch: :class:`numpy.ndarray` + This should be a 4+-dimensional array of training images in the format (`batchsize`, + `height`, `width`, `channels`). Targets should be requested after performing image + transformations but prior to performing warps. + + The 4th channel should be the mask. Any channels above the 4th should be any additional + masks that are requested. + + Returns + ------- + dict + The following keys will be within the returned dictionary: + + * **targets** (`list`) - A list of 4-dimensional :class:`numpy.ndarray` s in the \ + order and size of each output of the model as defined in :attr:`output_shapes`. The \ + format of these arrays will be (`batchsize`, `height`, `width`, `3`). **NB:** \ + masks are not included in the `targets` list. If masks are to be included in the \ + output they will be returned as their own item from the `masks` key. + + * **masks** (:class:`numpy.ndarray`) - A 4-dimensional array containing the target \ + masks in the format (`batchsize`, `height`, `width`, `1`). + """ + logger.trace("Compiling targets: batch shape: %s", batch.shape) + slices = self._constants["tgt_slices"] + target_batch = [np.array([cv2.resize(image[slices, slices, :], + (size, size), + cv2.INTER_AREA) + for image in batch], dtype='float32') / 255. + for size in self._output_sizes] + logger.trace("Target image shapes: %s", + [tgt_images.shape for tgt_images in target_batch]) + + retval = self._separate_target_mask(target_batch) + logger.trace("Final targets: %s", + {k: v.shape if isinstance(v, np.ndarray) else [img.shape for img in v] + for k, v in retval.items()}) + return retval + + @staticmethod + def _separate_target_mask(target_batch): + """ Return the batch and the batch of final masks + + Parameters + ---------- + target_batch: list + List of 4 dimension :class:`numpy.ndarray` objects resized the model outputs. + The 4th channel of the array contains the face mask, any additional channels after + this are additional masks (e.g. eye mask and mouth mask) + + Returns + ------- + dict: + The targets and the masks separated into their own items. The targets are a list of + 3 channel, 4 dimensional :class:`numpy.ndarray` objects sized for each output from the + model. The masks are a :class:`numpy.ndarray` of the final output size. Any additional + masks(e.g. eye and mouth masks) will be collated together into a :class:`numpy.ndarray` + of the final output size. The number of channels will be the number of additional + masks available + """ + logger.trace("target_batch shapes: %s", [tgt.shape for tgt in target_batch]) + retval = dict(targets=[batch[..., :3] for batch in target_batch], + masks=target_batch[-1][..., 3][..., None]) + if target_batch[-1].shape[-1] > 4: + retval["additional_masks"] = target_batch[-1][..., 4:] + logger.trace("returning: %s", {k: v.shape if isinstance(v, np.ndarray) else [tgt.shape + for tgt in v] + for k, v in retval.items()}) + return retval + + # <<< COLOR AUGMENTATION >>> # + def color_adjust(self, batch): + """ Perform color augmentation on the passed in batch. + + The color adjustment parameters are set in :file:`config.train.ini` + + Parameters + ---------- + batch: :class:`numpy.ndarray` + The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, + `3`) and in `BGR` format. + + Returns + ---------- + :class:`numpy.ndarray` + A 4-dimensional array of the same shape as :attr:`batch` with color augmentation + applied. + """ + if not self.is_display: + logger.trace("Augmenting color") + batch = batch_convert_color(batch, "BGR2LAB") + batch = self._random_clahe(batch) + batch = self._random_lab(batch) + batch = batch_convert_color(batch, "LAB2BGR") + return batch + + def _random_clahe(self, batch): + """ Randomly perform Contrast Limited Adaptive Histogram Equalization on + a batch of images """ + base_contrast = self._constants["clahe_base_contrast"] + + batch_random = np.random.rand(self._batchsize) + indices = np.where(batch_random > self._config.get("color_clahe_chance", 50) / 100)[0] + + grid_bases = np.rint(np.random.uniform(0, + self._config.get("color_clahe_max_size", 4), + size=indices.shape[0])).astype("uint8") + contrast_adjustment = (grid_bases * (base_contrast // 2)) + grid_sizes = contrast_adjustment + base_contrast + logger.trace("Adjusting Contrast. Grid Sizes: %s", grid_sizes) + + clahes = [cv2.createCLAHE(clipLimit=2.0, # pylint: disable=no-member + tileGridSize=(grid_size, grid_size)) + for grid_size in grid_sizes] + + for idx, clahe in zip(indices, clahes): + batch[idx, :, :, 0] = clahe.apply(batch[idx, :, :, 0]) + return batch + + def _random_lab(self, batch): + """ Perform random color/lightness adjustment in L*a*b* color space on a batch of + images """ + amount_l = self._config.get("color_lightness", 30) / 100 + amount_ab = self._config.get("color_ab", 8) / 100 + adjust = np.array([amount_l, amount_ab, amount_ab], dtype="float32") + randoms = ( + (np.random.rand(self._batchsize, 1, 1, 3).astype("float32") * (adjust * 2)) - adjust) + logger.trace("Random LAB adjustments: %s", randoms) + + for image, rand in zip(batch, randoms): + for idx in range(rand.shape[-1]): + adjustment = rand[:, :, idx] + if adjustment >= 0: + image[:, :, idx] = ((255 - image[:, :, idx]) * adjustment) + image[:, :, idx] + else: + image[:, :, idx] = image[:, :, idx] * (1 + adjustment) + return batch + + # <<< IMAGE AUGMENTATION >>> # + def transform(self, batch): + """ Perform random transformation on the passed in batch. + + The transformation parameters are set in :file:`config.train.ini` + + Parameters + ---------- + batch: :class:`numpy.ndarray` + The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, + `channels`) and in `BGR` format. + + Returns + ---------- + :class:`numpy.ndarray` + A 4-dimensional array of the same shape as :attr:`batch` with transformation applied. + """ + if self.is_display: + return batch + logger.trace("Randomly transforming image") + rotation_range = self._config.get("rotation_range", 10) + zoom_range = self._config.get("zoom_amount", 5) / 100 + shift_range = self._config.get("shift_range", 5) / 100 + + rotation = np.random.uniform(-rotation_range, + rotation_range, + size=self._batchsize).astype("float32") + scale = np.random.uniform(1 - zoom_range, + 1 + zoom_range, + size=self._batchsize).astype("float32") + tform = np.random.uniform( + -shift_range, + shift_range, + size=(self._batchsize, 2)).astype("float32") * self._training_size + + mats = np.array( + [cv2.getRotationMatrix2D((self._training_size // 2, self._training_size // 2), + rot, + scl) + for rot, scl in zip(rotation, scale)]).astype("float32") + mats[..., 2] += tform + + batch = np.array([cv2.warpAffine(image, + mat, + (self._training_size, self._training_size), + borderMode=cv2.BORDER_REPLICATE) + for image, mat in zip(batch, mats)]) + + logger.trace("Randomly transformed image") + return batch + + def random_flip(self, batch): + """ Perform random horizontal flipping on the passed in batch. + + The probability of flipping an image is set in :file:`config.train.ini` + + Parameters + ---------- + batch: :class:`numpy.ndarray` + The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, + `channels`) and in `BGR` format. + + Returns + ---------- + :class:`numpy.ndarray` + A 4-dimensional array of the same shape as :attr:`batch` with transformation applied. + """ + if not self.is_display: + logger.trace("Randomly flipping image") + randoms = np.random.rand(self._batchsize) + indices = np.where(randoms > self._config.get("random_flip", 50) / 100)[0] + batch[indices] = batch[indices, :, ::-1] + logger.trace("Randomly flipped %s images of %s", len(indices), self._batchsize) + return batch + + def warp(self, batch, to_landmarks=False, **kwargs): + """ Perform random warping on the passed in batch by one of two methods. + + Parameters + ---------- + batch: :class:`numpy.ndarray` + The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, + `3`) and in `BGR` format. + to_landmarks: bool, optional + If ``False`` perform standard random warping of the input image. If ``True`` perform + warping to semi-random similar corresponding landmarks from the other side. Default: + ``False`` + kwargs: dict + If :attr:`to_landmarks` is ``True`` the following additional kwargs must be passed in: + + * **batch_src_points** (:class:`numpy.ndarray`) - A batch of 68 point landmarks for \ + the source faces. This is a 3-dimensional array in the shape (`batchsize`, `68`, `2`). + + * **batch_dst_points** (:class:`numpy.ndarray`) - A batch of randomly chosen closest \ + match destination faces landmarks. This is a 3-dimensional array in the shape \ + (`batchsize`, `68`, `2`). + Returns + ---------- + :class:`numpy.ndarray` + A 4-dimensional array of the same shape as :attr:`batch` with warping applied. + """ + if to_landmarks: + return self._random_warp_landmarks(batch, **kwargs).astype("float32") / 255.0 + return self._random_warp(batch).astype("float32") / 255.0 + + def _random_warp(self, batch): + """ Randomly warp the input batch """ + logger.trace("Randomly warping batch") + mapx = self._constants["warp_mapx"] + mapy = self._constants["warp_mapy"] + pad = self._constants["warp_pad"] + slices = self._constants["warp_slices"] + + rands = np.random.normal(size=(self._batchsize, 2, 5, 5), + scale=self._scale).astype("float32") + batch_maps = np.stack((mapx, mapy), axis=1) + rands + batch_interp = np.array([[cv2.resize(map_, (pad, pad))[slices, slices] for map_ in maps] + for maps in batch_maps]) + warped_batch = np.array([cv2.remap(image, interp[0], interp[1], cv2.INTER_LINEAR) + for image, interp in zip(batch, batch_interp)]) + + logger.trace("Warped image shape: %s", warped_batch.shape) + return warped_batch + + def _random_warp_landmarks(self, batch, batch_src_points, batch_dst_points): + """ From dfaker. Warp the image to a similar set of landmarks from the opposite side """ + logger.trace("Randomly warping landmarks") + edge_anchors = self._constants["warp_lm_edge_anchors"] + grids = self._constants["warp_lm_grids"] + slices = self._constants["tgt_slices"] + + batch_dst = (batch_dst_points + np.random.normal(size=batch_dst_points.shape, + scale=2.0)) + + face_cores = [cv2.convexHull(np.concatenate([src[17:], dst[17:]], axis=0)) + for src, dst in zip(batch_src_points.astype("int32"), + batch_dst.astype("int32"))] + + batch_src = np.append(batch_src_points, edge_anchors, axis=1) + batch_dst = np.append(batch_dst, edge_anchors, axis=1) + + rem_indices = [list(set(idx for fpl in (src, dst) + for idx, (pty, ptx) in enumerate(fpl) + if cv2.pointPolygonTest(face_core, (pty, ptx), False) >= 0)) + for src, dst, face_core in zip(batch_src[:, :18, :], + batch_dst[:, :18, :], + face_cores)] + batch_src = [np.delete(src, idxs, axis=0) for idxs, src in zip(rem_indices, batch_src)] + batch_dst = [np.delete(dst, idxs, axis=0) for idxs, dst in zip(rem_indices, batch_dst)] + + grid_z = np.array([griddata(dst, src, (grids[0], grids[1]), method="linear") + for src, dst in zip(batch_src, batch_dst)]) + maps = grid_z.reshape((self._batchsize, + self._training_size, + self._training_size, + 2)).astype("float32") + warped_batch = np.array([cv2.remap(image, + map_[..., 1], + map_[..., 0], + cv2.INTER_LINEAR, + cv2.BORDER_TRANSPARENT) + for image, map_ in zip(batch, maps)]) + warped_batch = np.array([cv2.resize(image[slices, slices, :], + (self._input_size, self._input_size), + cv2.INTER_AREA) + for image in warped_batch]) + logger.trace("Warped batch shape: %s", warped_batch.shape) + return warped_batch + + def skip_warp(self, batch): + """ Returns the images resized and cropped for feeding the model, if warping has been + disabled. + + Parameters + ---------- + batch: :class:`numpy.ndarray` + The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, + `3`) and in `BGR` format. + + Returns + ------- + :class:`numpy.ndarray` + The given batch cropped and resized for feeding the model + """ + logger.trace("Compiling skip warp images: batch shape: %s", batch.shape) + slices = self._constants["tgt_slices"] + retval = np.array([cv2.resize(image[slices, slices, :], + (self._input_size, self._input_size), + cv2.INTER_AREA) + for image in batch], dtype='float32') / 255. + logger.trace("feed batch shape: %s", retval.shape) + return retval diff --git a/lib/training/generator.py b/lib/training/generator.py new file mode 100644 index 0000000000..7537d83c40 --- /dev/null +++ b/lib/training/generator.py @@ -0,0 +1,822 @@ +#!/usr/bin/env python3 +""" Handles Data Augmentation for feeding Faceswap Models """ + +import logging +import os + +from random import shuffle, choice +from threading import Lock +from zlib import decompress + +import numpy as np +import cv2 +from tqdm import tqdm +from lib.align import AlignedFace, DetectedFace, get_centered_size +from lib.image import read_image_batch, read_image_meta_batch +from lib.multithreading import BackgroundGenerator +from lib.utils import FaceswapError + +from . import ImageAugmentation + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + +_FACE_CACHES = dict() + + +def _get_cache(side, filenames, config): + """ Obtain a :class:`_Cache` object for the given side. If the object does not pre-exist then + create it. + + Parameters + ---------- + side: str + `"a"` or `"b"`. The side of the model to obtain the cache for + filenames: list + The filenames of all the images. This can either be the full path or the base name. If the + full paths are passed in, they are stripped to base name for use as the cache key. + config: dict + The user selected training configuration options + + Returns + ------- + :class:`_Cache` + The face meta information cache for the requested side + """ + if not _FACE_CACHES.get(side): + logger.debug("Creating cache. Side: %s", side) + _FACE_CACHES[side] = _Cache(filenames, config) + return _FACE_CACHES[side] + + +def _check_reset(face_cache): + """ Check whether a given cache needs to be reset because a face centering change has been + detected in the other cache. + + Parameters + ---------- + face_cache: :class:`_Cache` + The cache object that is checking whether it should reset + + Returns + ------- + bool + ``True`` if the given object should reset the cache, otherwise ``False`` + """ + check_cache = next((cache for cache in _FACE_CACHES.values() if cache != face_cache), None) + retval = check_cache if check_cache is None else check_cache.check_reset() + return retval + + +class _Cache(): + """ A thread safe mechanism for collecting and holding face meta information (masks, " + "alignments data etc.) for multiple :class:`TrainingDataGenerator`s. + + Each side may have up to 3 generators (training, preview and time-lapse). To conserve VRAM + these need to share access to the same face information for the images they are processing. + + As the cache is populated at run-time, thread safe writes are required for the first epoch. + Following that, the cache is only used for reads, which is thread safe intrinsically. + + It would probably be quicker to set locks on each individual face, but for code complexity + reasons, and the fact that the lock is only taken up during cache population, and it should + only be being read multiple times on save iterations, we lock the whole cache during writes. + + Parameters + ---------- + filenames: list + The filenames of all the images. This can either be the full path or the base name. If the + full paths are passed in, they are stripped to base name for use as the cache key. + config: dict + The user selected training configuration options + """ + def __init__(self, filenames, config): + self._lock = Lock() + self._cache = {os.path.basename(filename): dict(cached=False) for filename in filenames} + self._aligned_landmarks = None + self._partial_load = False + self._cache_full = False + self._extract_version = None + self._has_reset = False + self._size = None + + self._centering = config["centering"] + self._config = config + + @property + def cache_full(self): + """bool: ``True`` if the cache has been fully populated. ``False`` if there are items still + to be cached. """ + if self._cache_full: + return self._cache_full + with self._lock: + return self._cache_full + + @property + def partially_loaded(self): + """ bool: ``True`` if the cache has been partially loaded for Warp To Landmarks otherwise + ``False`` """ + if self._partial_load: + return self._partial_load + with self._lock: + return self._partial_load + + @property + def extract_version(self): + """ float: The alignments file version used to extract the faces. """ + return self._extract_version + + @property + def aligned_landmarks(self): + """ dict: The filename as key, aligned landmarks as value """ + if self._aligned_landmarks is None: + self._aligned_landmarks = {key: val["aligned_face"].landmarks + for key, val in self._cache.items()} + return self._aligned_landmarks + + @property + def crop_size(self): + """ int: The pixel size of the cropped aligned face """ + return self._size + + def check_reset(self): + """ Check whether this cache has been reset due to a face centering change, and reset the + flag if it has. + + Returns + ------- + bool + ``True`` if the cache has been reset because of a face centering change due to + legacy alignments, otherwise ``False``. """ + retval = self._has_reset + if retval: + logger.debug("Resetting 'has_reset' flag") + self._has_reset = False + return retval + + def get_items(self, filenames): + """ Obtain the cached items for a list of filenames. The returned list is in the same order + as the provided filenames. + + Parameters + ---------- + filenames: list + A list of image filenames to obtain the cached data for + + Returns + ------- + list + List of dictionaries containing the cached metadata. The list returns in the same order + as the filenames received + """ + return [self._cache[os.path.basename(filename)] for filename in filenames] + + def cache_metadata(self, filenames): + """ Obtain the batch with metadata for items that need caching and cache them to + :attr:`_cache`. + + Parameters + ---------- + filenames: list + List of full paths to image file names + + Returns + ------- + :class:`numpy.ndarray` + The batch of face images loaded from disk + """ + keys = [os.path.basename(filename) for filename in filenames] + with self._lock: + if _check_reset(self): + self._reset_cache(False) + + needs_cache = [filename + for filename, key in zip(filenames, keys) + if not self._cache[key]["cached"]] + logger.trace("Needs cache: %s", needs_cache) + + if not needs_cache: + # Don't bother reading the metadata if no images in this batch need caching + logger.debug("All metadata already cached for: %s", keys) + return read_image_batch(filenames) + + batch, metadata = read_image_batch(filenames, with_metadata=True) + + # Populate items into cache + for filename in needs_cache: + key = os.path.basename(filename) + meta = metadata[filenames.index(filename)] + + # Version Check + self._validate_version(meta, filename) + if self._partial_load: # Faces already loaded for Warp-to-landmarks + detected_face = self._cache[key]["detected_face"] + else: + detected_face = self._add_aligned_face(filename, + meta["alignments"], + batch.shape[1]) + + self._add_mask(filename, detected_face) + for area in ("eye", "mouth"): + self._add_localized_mask(filename, detected_face, area) + + self._cache[key]["cached"] = True + # Update the :attr:`cache_full` attribute + cache_full = all(item["cached"] for item in self._cache.values()) + if cache_full: + logger.verbose("Cache filled: '%s'", os.path.dirname(filenames[0])) + self._cache_full = cache_full + + return batch + + def pre_fill(self, filenames, side): + """ When warp to landmarks is enabled, the cache must be pre-filled, as each side needs + access to the other side's alignments. + + Parameters + ---------- + filenames: list + The list of full paths to the images to load the metadata from + side: str + `"a"` or `"b"`. The side of the model being cached. Used for info output + """ + with self._lock: + for filename, meta in tqdm(read_image_meta_batch(filenames), + desc="WTL: Caching Landmarks ({})".format(side.upper()), + total=len(filenames), + leave=False): + if "itxt" not in meta or "alignments" not in meta["itxt"]: + raise FaceswapError(f"Invalid face image found. Aborting: '{filename}'") + + size = meta["width"] + meta = meta["itxt"] + # Version Check + self._validate_version(meta, filename) + detected_face = self._add_aligned_face(filename, meta["alignments"], size) + self._cache[os.path.basename(filename)]["detected_face"] = detected_face + self._partial_load = True + + def _validate_version(self, png_meta, filename): + """ Validate that there are not a mix of v1.0 extracted faces and v2.x faces. + + Parameters + ---------- + png_meta: dict + The information held within the Faceswap PNG Header + filename: str + The full path to the file being validated + + Raises + ------ + FaceswapError + If a version 1.0 face appears in a 2.x set or vice versa + """ + alignment_version = png_meta["source"]["alignments_version"] + + if not self._extract_version: + logger.debug("Setting initial extract version: %s", alignment_version) + self._extract_version = alignment_version + if alignment_version == 1.0 and self._centering != "legacy": + self._reset_cache(True) + return + + if (self._extract_version == 1.0 and alignment_version > 1.0) or ( + alignment_version == 1.0 and self._extract_version > 1.0): + raise FaceswapError("Mixing legacy and full head extracted facesets is not supported. " + "The following folder contains a mix of extracted face types: " + "{}".format(os.path.dirname(filename))) + + self._extract_version = min(alignment_version, self._extract_version) + + def _reset_cache(self, set_flag): + """ In the event that a legacy extracted face has been seen, and centering is not legacy + the cache will need to be reset for legacy centering. + + Parameters + ---------- + set_flag: bool + ``True`` if the flag should be set to indicate that the cache is being reset because of + a legacy face set/centering mismatch. ``False`` if the cache is being reset because it + has detected a reset flag from the opposite cache. + """ + logger.warning("You are using legacy extracted faces but have selected '%s' centering " + "which is incompatible. Switching centering to 'legacy'", self._centering) + self._config["centering"] = "legacy" + self._centering = "legacy" + self._cache = {key: dict(cached=False) for key in self._cache} + self._cache_full = False + self._size = None + if set_flag: + self._has_reset = True + + def _add_aligned_face(self, filename, alignments, image_size): + """ Add a :class:`lib.align.AlignedFace` object to the cache. + + Parameters + ---------- + filename: str + The file path for the current image + alignments: dict + The alignments for a single face, extracted from a PNG header + image_size: int + The pixel size of the image loaded from disk + + Returns + ------- + :class:`lib.align.DetectedFace` + The Detected Face object that was used to create the Aligned Face + """ + if self._size is None: + self._size = get_centered_size("legacy" if self._extract_version == 1.0 else "head", + self._centering, + image_size) + + detected_face = DetectedFace() + detected_face.from_png_meta(alignments) + + aligned_face = AlignedFace(detected_face.landmarks_xy, + centering=self._centering, + size=self._size, + is_aligned=True) + logger.trace("Caching aligned face for: %s", filename) + self._cache[os.path.basename(filename)]["aligned_face"] = aligned_face + return detected_face + + def _add_mask(self, filename, detected_face): + """ Load the mask to the cache if a mask is required for training. + + Parameters + ---------- + filename: str + The file path for the current image + detected_face: :class:`lib.align.DetectedFace` + The detected face object that holds the masks + + Raises + ------ + FaceswapError + If the requested mask type is not available an error is returned along with a list + of available masks + """ + if not self._config["penalized_mask_loss"] and not self._config["learn_mask"]: + return + + if not self._config["mask_type"]: + logger.debug("No mask selected. Not validating") + return + + if self._config["mask_type"] not in detected_face.mask: + raise FaceswapError( + "You have selected the mask type '{}' but at least one face does not contain the " + "selected mask.\nThe face that failed was: '{}'\nThe masks that exist for this " + "face are: {}".format( + self._config["mask_type"], filename, list(detected_face.mask.keys))) + + key = os.path.basename(filename) + mask = detected_face.mask[self._config["mask_type"]] + mask.set_blur_and_threshold(blur_kernel=self._config["mask_blur_kernel"], + threshold=self._config["mask_threshold"]) + + if self._extract_version > 1.0 and self._centering == "legacy": + mask.set_sub_crop(self._cache[key]["aligned_face"].pose.offset["face"] * -1) + + logger.trace("Caching mask for: %s", filename) + self._cache[key]["mask"] = mask + + def _add_localized_mask(self, filename, detected_face, area): + """ Load a localized mask to the cache for the given area if it is required for training. + + Parameters + ---------- + filename: str + The file path for the current image + detected_face: :class:`lib.align.DetectedFace` + The detected face object that holds the masks + area: str + `"eye"` or `"mouth"`. The area of the face to obtain the mask for + """ + if not self._config["penalized_mask_loss"] or self._config[f"{area}_multiplier"] <= 1: + return + key = "eyes" if area == "eye" else area + + logger.trace("Caching localized '%s' mask for: %s", key, filename) + self._cache[os.path.basename(filename)][f"mask_{key}"] = detected_face.get_landmark_mask( + self._size, + key, + aligned=True, + centering=self._centering, + dilation=self._size // 32, + blur_kernel=self._size // 16, + as_zip=True) + + +class TrainingDataGenerator(): # pylint:disable=too-few-public-methods + """ A Training Data Generator for compiling data for feeding to a model. + + This class is called from :mod:`plugins.train.trainer._base` and launches a background + iterator that compiles augmented data, target data and sample data. + + Parameters + ---------- + model_input_size: int + The expected input size for the model. It is assumed that the input to the model is always + a square image. This is the size, in pixels, of the `width` and the `height` of the input + to the model. + model_output_shapes: list + A list of tuples defining the output shapes from the model, in the order that the outputs + are returned. The tuples should be in (`height`, `width`, `channels`) format. + coverage_ratio: float + The ratio of the training image to be trained on. Dictates how much of the image will be + cropped out. E.G: a coverage ratio of 0.625 will result in cropping a 160px box from a + 256px image (:math:`256 * 0.625 = 160`). + color_order: ["rgb", "bgr"] + The color order that the model expects as input + augment_color: bool + ``True`` if color is to be augmented, otherwise ``False`` + no_flip: bool + ``True`` if the image shouldn't be randomly flipped as part of augmentation, otherwise + ``False`` + no_warp: bool + ``True`` if the image shouldn't be warped as part of augmentation, otherwise ``False`` + warp_to_landmarks: bool + ``True`` if the random warp method should warp to similar landmarks from the other side, + ``False`` if the standard random warp method should be used. + face_cache: dict + A thread safe dictionary containing a cache of information relating to all faces being + trained on + config: dict + The configuration `dict` generated from :file:`config.train.ini` containing the trainer + plugin configuration options. + """ + def __init__(self, model_input_size, model_output_shapes, coverage_ratio, color_order, + augment_color, no_flip, no_warp, warp_to_landmarks, config): + logger.debug("Initializing %s: (model_input_size: %s, model_output_shapes: %s, " + "coverage_ratio: %s, color_order: %s, augment_color: %s, no_flip: %s, " + "no_warp: %s, warp_to_landmarks: %s, config: %s)", + self.__class__.__name__, model_input_size, model_output_shapes, + coverage_ratio, color_order, augment_color, no_flip, no_warp, + warp_to_landmarks, config) + self._config = config + self._model_input_size = model_input_size + self._model_output_shapes = model_output_shapes + self._coverage_ratio = coverage_ratio + self._color_order = color_order.lower() + self._augment_color = augment_color + self._no_flip = no_flip + self._warp_to_landmarks = warp_to_landmarks + self._no_warp = no_warp + + # Batchsize and processing class are set when this class is called by a feeder + # from lib.training_data + self._batchsize = 0 + self._face_cache = None + self._nearest_landmarks = dict() + self._processing = None + logger.debug("Initialized %s", self.__class__.__name__) + + def minibatch_ab(self, images, batchsize, side, + do_shuffle=True, is_preview=False, is_timelapse=False): + """ A Background iterator to return augmented images, samples and targets. + + The exit point from this class and the sole attribute that should be referenced. Called + from :mod:`plugins.train.trainer._base`. Returns an iterator that yields images for + training, preview and time-lapses. + + Parameters + ---------- + images: list + A list of image paths that will be used to compile the final augmented data from. + batchsize: int + The batchsize for this iterator. Images will be returned in :class:`numpy.ndarray` + objects of this size from the iterator. + side: {'a' or 'b'} + The side of the model that this iterator is for. + do_shuffle: bool, optional + Whether data should be shuffled prior to loading from disk. If true, each time the full + list of filenames are processed, the data will be reshuffled to make sure they are not + returned in the same order. Default: ``True`` + is_preview: bool, optional + Indicates whether this iterator is generating preview images. If ``True`` then certain + augmentations will not be performed. Default: ``False`` + is_timelapse: bool optional + Indicates whether this iterator is generating time-lapse images. If ``True``, then + certain augmentations will not be performed. Default: ``False`` + + Yields + ------ + dict + The following items are contained in each `dict` yielded from this iterator: + + * **feed** (:class:`numpy.ndarray`) - The feed for the model. The array returned is \ + in the format (`batchsize`, `height`, `width`, `channels`). This is the :attr:`x` \ + parameter for :func:`keras.models.model.train_on_batch`. + + * **targets** (`list`) - A list of 4-dimensional :class:`numpy.ndarray` objects in \ + the order and size of each output of the model as defined in \ + :attr:`model_output_shapes`. the format of these arrays will be (`batchsize`, \ + `height`, `width`, `3`). This is the :attr:`y` parameter for \ + :func:`keras.models.model.train_on_batch` **NB:** masks are not included in the \ + `targets` list. If required for feeding into the Keras model, they will need to be \ + added to this list in :mod:`plugins.train.trainer._base` from the `masks` key. + + * **masks** (:class:`numpy.ndarray`) - A 4-dimensional array containing the target \ + masks in the format (`batchsize`, `height`, `width`, `1`). + + * **samples** (:class:`numpy.ndarray`) - A 4-dimensional array containing the samples \ + for feeding to the model's predict function for generating preview and time-lapse \ + samples. The array will be in the format (`batchsize`, `height`, `width`, \ + `channels`). **NB:** This item will only exist in the `dict` if :attr:`is_preview` \ + or :attr:`is_timelapse` is ``True`` + """ + logger.debug("Queue batches: (image_count: %s, batchsize: %s, side: '%s', do_shuffle: %s, " + "is_preview, %s, is_timelapse: %s)", len(images), batchsize, side, do_shuffle, + is_preview, is_timelapse) + self._batchsize = batchsize + self._face_cache = _get_cache(side, images, self._config) + self._processing = ImageAugmentation(batchsize, + is_preview or is_timelapse, + self._model_input_size, + self._model_output_shapes, + self._coverage_ratio, + self._config) + + if self._warp_to_landmarks and not self._face_cache.partially_loaded: + self._face_cache.pre_fill(images, side) + + args = (images, side, do_shuffle, batchsize) + batcher = BackgroundGenerator(self._minibatch, thread_count=2, args=args) + return batcher.iterator() + + # << INTERNAL METHODS >> # + def _validate_samples(self, data): + """ Ensures that the total number of images within :attr:`images` is greater or equal to + the selected :attr:`batchsize`. Raises an exception if this is not the case. """ + length = len(data) + msg = ("Number of images is lower than batch-size (Note that too few " + "images may lead to bad training). # images: {}, " + "batch-size: {}".format(length, self._batchsize)) + try: + assert length >= self._batchsize, msg + except AssertionError as err: + msg += ("\nYou should increase the number of images in your training set or lower " + "your batch-size.") + raise FaceswapError(msg) from err + + def _minibatch(self, images, side, do_shuffle, batchsize): + """ A generator function that yields the augmented, target and sample images. + see :func:`minibatch_ab` for more details on the output. """ + logger.debug("Loading minibatch generator: (image_count: %s, side: '%s', do_shuffle: %s)", + len(images), side, do_shuffle) + self._validate_samples(images) + + def _img_iter(imgs): + while True: + if do_shuffle: + shuffle(imgs) + for img in imgs: + yield img + + img_iter = _img_iter(images) + while True: + img_paths = [next(img_iter) for _ in range(batchsize)] + yield self._process_batch(img_paths, side) + + logger.debug("Finished minibatch generator: (side: '%s')", side) + + def _process_batch(self, filenames, side): + """ Performs the augmentation and compiles target images and samples. + + If this is the first time a face has been loaded, then it's meta data is extracted from the + png header and added to :attr:`_face_cache` + + See + :func:`minibatch_ab` for more details on the output. + + Parameters + ---------- + filenames: list + List of full paths to image file names + side: str + The side of the model being trained on (`a` or `b`) + """ + logger.trace("Process batch: (filenames: '%s', side: '%s')", filenames, side) + + if not self._face_cache.cache_full: + batch = self._face_cache.cache_metadata(filenames) + else: + batch = read_image_batch(filenames) + + cache = self._face_cache.get_items(filenames) + batch, landmarks = self._crop_to_center(filenames, cache, batch, side) + batch = self._apply_mask(filenames, cache, batch, side) + processed = dict() + + # Initialize processing training size on first image + if not self._processing.initialized: + self._processing.initialize(batch.shape[1]) + + # Get Landmarks prior to manipulating the image + if self._warp_to_landmarks: + batch_dst_pts = self._get_closest_match(filenames, side, landmarks) + warp_kwargs = dict(batch_src_points=landmarks, batch_dst_points=batch_dst_pts) + else: + warp_kwargs = dict() + + # Color Augmentation of the image only + if self._augment_color: + batch[..., :3] = self._processing.color_adjust(batch[..., :3]) + + # Random Transform and flip + batch = self._processing.transform(batch) + if not self._no_flip: + batch = self._processing.random_flip(batch) + + # Switch color order for RGB models + if self._color_order == "rgb": + batch = batch[..., [2, 1, 0, 3]] + + # Add samples to output if this is for display + if self._processing.is_display: + processed["samples"] = batch[..., :3].astype("float32") / 255.0 + + # Get Targets + processed.update(self._processing.get_targets(batch)) + + # Random Warp # TODO change masks to have a input mask and a warped target mask + if self._no_warp: + processed["feed"] = [self._processing.skip_warp(batch[..., :3])] + else: + processed["feed"] = [self._processing.warp(batch[..., :3], + self._warp_to_landmarks, + **warp_kwargs)] + + logger.trace("Processed batch: (filenames: %s, side: '%s', processed: %s)", + filenames, + side, + {k: v.shape if isinstance(v, np.ndarray) else[i.shape for i in v] + for k, v in processed.items()}) + return processed + + def _crop_to_center(self, filenames, cache, batch, side): + """ Crops the training image out of the full extract image based on the centering used in + the user's configuration settings. + + If legacy extract images are being used then this just returns the extracted batch with + their corresponding landmarks. + + Parameters + ---------- + filenames: list + The list of filenames that correspond to this batch + cache: list + The list of cached items (aligned faces, masks etc.) corresponding to the batch + batch: :class:`numpy.ndarray` + The batch of faces that have been loaded from disk + side: str + '"a"' or '"b"' the side that is being processed + + Returns + ------- + batch: :class:`numpy.ndarray` + The centered faces cropped out of the loaded batch + landmarks: :class:`numpy.ndarray` + The aligned landmarks for this batch. NB: The aligned landmarks do not directly + correspond to the size of the extracted face. They are scaled to the source training + image, not the sub-image. + + Raises + ------ + FaceswapError + If Alignment information is not available for any of the images being loaded in + the batch + """ + logger.trace("Cropping training images info: (filenames: %s, side: '%s')", filenames, side) + aligned = [item["aligned_face"] for item in cache] + + if self._face_cache.extract_version == 1.0: + # Legacy extract. Don't crop, just return batch with landmarks + return batch, np.array([face.landmarks for face in aligned]) + + landmarks = np.array([face.landmarks for face in aligned]) + cropped = np.array([align.extract_face(img) for align, img in zip(aligned, batch)]) + return cropped, landmarks + + def _apply_mask(self, filenames, cache, batch, side): + """ Applies the mask to the 4th channel of the image. If masks are not being used + applies a dummy all ones mask. + + If the configuration options `eye_multiplier` and/or `mouth_multiplier` are greater than 1 + then these masks are applied to the final channels of the batch respectively. + + Parameters + ---------- + filenames: list + The list of filenames that correspond to this batch + cache: list + The list of cached items (aligned faces, masks etc.) corresponding to the batch + batch: :class:`numpy.ndarray` + The batch of faces that have been loaded from disk + side: str + '"a"' or '"b"' the side that is being processed + + Returns + ------- + :class:`numpy.ndarray` + The batch with masks applied to the final channels + """ + logger.trace("Input filenames: %s, batch shape: %s, side: %s", + filenames, batch.shape, side) + size = batch.shape[1] + + for key in ("mask", "mask_eyes", "mask_mouth"): + lookup = cache[0].get(key) + if lookup is None and key != "mask": + continue + + if lookup is None and key == "mask": + logger.trace("Creating dummy masks. side: %s", side) + masks = np.ones_like(batch[..., :1], dtype=batch.dtype) + else: + logger.trace("Obtaining masks for batch. (key: %s side: %s)", key, side) + + masks = np.array([self._get_mask(item[key], size) + for item in cache], dtype=batch.dtype) + masks = self._resize_masks(size, masks) + logger.trace("masks: (key: %s, shape: %s)", key, masks.shape) + batch = np.concatenate((batch, masks), axis=-1) + logger.trace("Output batch shape: %s, side: %s", batch.shape, side) + return batch + + @classmethod + def _get_mask(cls, item, size): + """ Decompress zipped eye and mouth masks, or return the stored mask + + Parameters + ---------- + item: :class:`lib.align.Mask` or `bytes` + Either a stored face mask object or a zipped eye or mouth mask + size: int + The size of the stored eye or mouth mask for reshaping + + Returns + ------- + class:`numpy.ndarray` + The decompressed mask + """ + if isinstance(item, bytes): + retval = np.frombuffer(decompress(item), dtype="uint8").reshape(size, size, 1) + else: + retval = item.mask + return retval + + @classmethod + def _resize_masks(cls, target_size, masks): + """ Resize the masks to the target size """ + logger.trace("target size: %s, masks shape: %s", target_size, masks.shape) + mask_size = masks.shape[1] + if target_size == mask_size: + logger.trace("Mask and targets the same size. Not resizing") + return masks + interpolator = cv2.INTER_CUBIC if mask_size < target_size else cv2.INTER_AREA + masks = np.array([cv2.resize(mask, + (target_size, target_size), + interpolation=interpolator)[..., None] + for mask in masks]) + logger.trace("Resized masks: %s", masks.shape) + return masks + + def _get_closest_match(self, filenames, side, batch_src_points): + """ Only called if the :attr:`_warp_to_landmarks` is ``True``. Gets the closest + matched 68 point landmarks from the opposite training set. """ + logger.trace("Retrieving closest matched landmarks: (filenames: '%s', src_points: '%s'", + filenames, batch_src_points) + lm_side = "a" if side == "b" else "b" + landmarks = _FACE_CACHES[lm_side].aligned_landmarks + + closest_matches = [self._nearest_landmarks.get(os.path.basename(filename)) + for filename in filenames] + if None in closest_matches: + # Resize mismatched training image size landmarks + sizes = {side: cache.crop_size for side, cache in _FACE_CACHES.items()} + if len(set(sizes.values())) > 1: + scale = sizes[side] / sizes[lm_side] + landmarks = {key: lms * scale for key, lms in landmarks.items()} + closest_matches = self._cache_closest_matches(filenames, batch_src_points, landmarks) + + batch_dst_points = np.array([landmarks[choice(fname)] for fname in closest_matches]) + logger.trace("Returning: (batch_dst_points: %s)", batch_dst_points.shape) + return batch_dst_points + + def _cache_closest_matches(self, filenames, batch_src_points, landmarks): + """ Cache the nearest landmarks for this batch """ + logger.trace("Caching closest matches") + dst_landmarks = list(landmarks.items()) + dst_points = np.array([lm[1] for lm in dst_landmarks]) + batch_closest_matches = list() + + for filename, src_points in zip(filenames, batch_src_points): + closest = (np.mean(np.square(src_points - dst_points), axis=(1, 2))).argsort()[:10] + closest_matches = tuple(dst_landmarks[i][0] for i in closest) + self._nearest_landmarks[os.path.basename(filename)] = closest_matches + batch_closest_matches.append(closest_matches) + logger.trace("Cached closest matches") + return batch_closest_matches diff --git a/lib/training_data.py b/lib/training_data.py deleted file mode 100644 index ad37f15ec6..0000000000 --- a/lib/training_data.py +++ /dev/null @@ -1,930 +0,0 @@ -#!/usr/bin/env python3 -""" Handles Data Augmentation for feeding Faceswap Models """ - -import logging - -from functools import partial -from random import shuffle, choice -from zlib import decompress - -import numpy as np -import cv2 -from scipy.interpolate import griddata - -from lib.image import batch_convert_color, read_image_batch -from lib.multithreading import BackgroundGenerator -from lib.utils import FaceswapError - -logger = logging.getLogger(__name__) # pylint: disable=invalid-name - - -class TrainingDataGenerator(): # pylint:disable=too-few-public-methods - """ A Training Data Generator for compiling data for feeding to a model. - - This class is called from :mod:`plugins.train.trainer._base` and launches a background - iterator that compiles augmented data, target data and sample data. - - Parameters - ---------- - model_input_size: int - The expected input size for the model. It is assumed that the input to the model is always - a square image. This is the size, in pixels, of the `width` and the `height` of the input - to the model. - model_output_shapes: list - A list of tuples defining the output shapes from the model, in the order that the outputs - are returned. The tuples should be in (`height`, `width`, `channels`) format. - coverage_ratio: float - The ratio of the training image to be trained on. Dictates how much of the image will be - cropped out. E.G: a coverage ratio of 0.625 will result in cropping a 160px box from a - 256px image (:math:`256 * 0.625 = 160`). - color_order: ["rgb", "bgr"] - The color order that the model expects as input - augment_color: bool - ``True`` if color is to be augmented, otherwise ``False`` - no_flip: bool - ``True`` if the image shouldn't be randomly flipped as part of augmentation, otherwise - ``False`` - no_warp: bool - ``True`` if the image shouldn't be warped as part of augmentation, otherwise ``False`` - warp_to_landmarks: bool - ``True`` if the random warp method should warp to similar landmarks from the other side, - ``False`` if the standard random warp method should be used. - alignments: dict - A dictionary containing aligned face information and masks if these are required for - training: - - * **aligned_faces** (`dict`). Contains the aligned face information. Returning dictionary \ - has a key of **side** (`str`) the value of which is a `dict` of {**filename** (`str`): \ - :class:`lib.align.AlignedFace`}. - - * **versions** (`dict`). The Alignments file versions that the extracted faces originated \ - from for each key of **side** (`str`). Version 1.0 will be a legacy extract. Anything \ - above this will be a full-face extract - - * **masks** (`dict`, `optional`). Required if :attr:`penalized_mask_loss` or \ - :attr:`learn_mask` is ``True``. Returning dictionary has a key of **side** (`str`) the \ - value of which is a `dict` of {**filename** (`str`): :class:`lib.align.Mask`}. - - * **masks_eye** (`dict`, `optional`). Required if config option "eye_multiplier" is \ - a value greater than 1. Returning dictionary has a key of **side** (`str`) the \ - value of which is a `dict` of {**filename** (`str`): :class:`bytes`} which is a zipped \ - eye mask. - - * **masks_mouth** (`dict`, `optional`). Required if config option "mouth_multiplier" is \ - a value greater than 1. Returning dictionary has a key of **side** (`str`) the \ - value of which is a `dict` of {**filename** (`str`): :class:`bytes`} which is a zipped \ - mouth mask. - - config: dict - The configuration `dict` generated from :file:`config.train.ini` containing the trainer - plugin configuration options. - """ - def __init__(self, model_input_size, model_output_shapes, coverage_ratio, color_order, - augment_color, no_flip, no_warp, warp_to_landmarks, alignments, config): - logger.debug("Initializing %s: (model_input_size: %s, model_output_shapes: %s, " - "coverage_ratio: %s, color_order: %s, augment_color: %s, no_flip: %s, " - "no_warp: %s, warp_to_landmarks: %s, alignments: %s, config: %s)", - self.__class__.__name__, model_input_size, model_output_shapes, - coverage_ratio, color_order, augment_color, no_flip, no_warp, - warp_to_landmarks, list(alignments.keys()), config) - self._config = config - self._model_input_size = model_input_size - self._model_output_shapes = model_output_shapes - self._coverage_ratio = coverage_ratio - self._color_order = color_order.lower() - self._augment_color = augment_color - self._no_flip = no_flip - self._warp_to_landmarks = warp_to_landmarks - self._no_warp = no_warp - self._extract_versions = alignments["versions"] - self._aligned_faces = alignments["aligned_faces"] - self._masks = dict(masks=alignments.get("masks", None), - eyes=alignments.get("masks_eye", None), - mouths=alignments.get("masks_mouth", None)) - self._cache = dict(nearest_landmarks=dict(), crop_size=0) - - # Batchsize and processing class are set when this class is called by a feeder - # from lib.training_data - self._batchsize = 0 - self._processing = None - logger.debug("Initialized %s", self.__class__.__name__) - - def minibatch_ab(self, images, batchsize, side, - do_shuffle=True, is_preview=False, is_timelapse=False): - """ A Background iterator to return augmented images, samples and targets. - - The exit point from this class and the sole attribute that should be referenced. Called - from :mod:`plugins.train.trainer._base`. Returns an iterator that yields images for - training, preview and time-lapses. - - Parameters - ---------- - images: list - A list of image paths that will be used to compile the final augmented data from. - batchsize: int - The batchsize for this iterator. Images will be returned in :class:`numpy.ndarray` - objects of this size from the iterator. - side: {'a' or 'b'} - The side of the model that this iterator is for. - do_shuffle: bool, optional - Whether data should be shuffled prior to loading from disk. If true, each time the full - list of filenames are processed, the data will be reshuffled to make sure they are not - returned in the same order. Default: ``True`` - is_preview: bool, optional - Indicates whether this iterator is generating preview images. If ``True`` then certain - augmentations will not be performed. Default: ``False`` - is_timelapse: bool optional - Indicates whether this iterator is generating time-lapse images. If ``True``, then - certain augmentations will not be performed. Default: ``False`` - - Yields - ------ - dict - The following items are contained in each `dict` yielded from this iterator: - - * **feed** (:class:`numpy.ndarray`) - The feed for the model. The array returned is \ - in the format (`batchsize`, `height`, `width`, `channels`). This is the :attr:`x` \ - parameter for :func:`keras.models.model.train_on_batch`. - - * **targets** (`list`) - A list of 4-dimensional :class:`numpy.ndarray` objects in \ - the order and size of each output of the model as defined in \ - :attr:`model_output_shapes`. the format of these arrays will be (`batchsize`, \ - `height`, `width`, `3`). This is the :attr:`y` parameter for \ - :func:`keras.models.model.train_on_batch` **NB:** masks are not included in the \ - `targets` list. If required for feeding into the Keras model, they will need to be \ - added to this list in :mod:`plugins.train.trainer._base` from the `masks` key. - - * **masks** (:class:`numpy.ndarray`) - A 4-dimensional array containing the target \ - masks in the format (`batchsize`, `height`, `width`, `1`). - - * **samples** (:class:`numpy.ndarray`) - A 4-dimensional array containing the samples \ - for feeding to the model's predict function for generating preview and time-lapse \ - samples. The array will be in the format (`batchsize`, `height`, `width`, \ - `channels`). **NB:** This item will only exist in the `dict` if :attr:`is_preview` \ - or :attr:`is_timelapse` is ``True`` - """ - logger.debug("Queue batches: (image_count: %s, batchsize: %s, side: '%s', do_shuffle: %s, " - "is_preview, %s, is_timelapse: %s)", len(images), batchsize, side, do_shuffle, - is_preview, is_timelapse) - self._batchsize = batchsize - self._processing = ImageAugmentation(batchsize, - is_preview or is_timelapse, - self._model_input_size, - self._model_output_shapes, - self._coverage_ratio, - self._config) - args = (images, side, do_shuffle, batchsize) - batcher = BackgroundGenerator(self._minibatch, thread_count=2, args=args) - return batcher.iterator() - - # << INTERNAL METHODS >> # - def _validate_samples(self, data): - """ Ensures that the total number of images within :attr:`images` is greater or equal to - the selected :attr:`batchsize`. Raises an exception if this is not the case. """ - length = len(data) - msg = ("Number of images is lower than batch-size (Note that too few " - "images may lead to bad training). # images: {}, " - "batch-size: {}".format(length, self._batchsize)) - try: - assert length >= self._batchsize, msg - except AssertionError as err: - msg += ("\nYou should increase the number of images in your training set or lower " - "your batch-size.") - raise FaceswapError(msg) from err - - def _minibatch(self, images, side, do_shuffle, batchsize): - """ A generator function that yields the augmented, target and sample images. - see :func:`minibatch_ab` for more details on the output. """ - logger.debug("Loading minibatch generator: (image_count: %s, side: '%s', do_shuffle: %s)", - len(images), side, do_shuffle) - self._validate_samples(images) - - def _img_iter(imgs): - while True: - if do_shuffle: - shuffle(imgs) - for img in imgs: - yield img - - img_iter = _img_iter(images) - while True: - img_paths = [next(img_iter) for _ in range(batchsize)] - yield self._process_batch(img_paths, side) - - logger.debug("Finished minibatch generator: (side: '%s')", side) - - def _process_batch(self, filenames, side): - """ Performs the augmentation and compiles target images and samples. See - :func:`minibatch_ab` for more details on the output. """ - logger.trace("Process batch: (filenames: '%s', side: '%s')", filenames, side) - batch = read_image_batch(filenames) - batch, landmarks = self._crop_to_center(filenames, batch, side) - batch = self._apply_mask(filenames, batch, side) - processed = dict() - - # Initialize processing training size on first image - if not self._processing.initialized: - self._processing.initialize(batch.shape[1]) - - # Get Landmarks prior to manipulating the image - if self._warp_to_landmarks: - batch_dst_pts = self._get_closest_match(filenames, side, landmarks) - warp_kwargs = dict(batch_src_points=landmarks, batch_dst_points=batch_dst_pts) - else: - warp_kwargs = dict() - - # Color Augmentation of the image only - if self._augment_color: - batch[..., :3] = self._processing.color_adjust(batch[..., :3]) - - # Random Transform and flip - batch = self._processing.transform(batch) - if not self._no_flip: - batch = self._processing.random_flip(batch) - - # Switch color order for RGB models - if self._color_order == "rgb": - batch = batch[..., [2, 1, 0, 3]] - - # Add samples to output if this is for display - if self._processing.is_display: - processed["samples"] = batch[..., :3].astype("float32") / 255.0 - - # Get Targets - processed.update(self._processing.get_targets(batch)) - - # Random Warp # TODO change masks to have a input mask and a warped target mask - if self._no_warp: - processed["feed"] = [self._processing.skip_warp(batch[..., :3])] - else: - processed["feed"] = [self._processing.warp(batch[..., :3], - self._warp_to_landmarks, - **warp_kwargs)] - - logger.trace("Processed batch: (filenames: %s, side: '%s', processed: %s)", - filenames, - side, - {k: v.shape if isinstance(v, np.ndarray) else[i.shape for i in v] - for k, v in processed.items()}) - return processed - - def _crop_to_center(self, filenames, batch, side): - """ Crops the training image out of the full extract image based on the centering used in - the user's configuration settings. - - If legacy extract images are being used then this just returns the extracted batch with - their corresponding landmarks. - - Parameters - ---------- - filenames: list - The list of filenames that correspond to this batch - batch: :class:`numpy.ndarray` - The batch of faces that have been loaded from disk - side: str - '"a"' or '"b"' the side that is being processed - - Returns - ------- - batch: :class:`numpy.ndarray` - The centered faces cropped out of the loaded batch - landmarks: :class:`numpy.ndarray` - The aligned landmarks for this batch. NB: The aligned landmarks do not directly - correspond to the size of the extracted face. They are scaled to the source training - image, not the sub-image. - - Raises - ------ - FaceswapError - If Alignment information is not available for any of the images being loaded in - the batch - """ - logger.trace("Cropping training images info: (filenames: %s, side: '%s')", filenames, side) - aligned = [self._aligned_faces[side].get(filename, None) for filename in filenames] - # Raise error on missing alignments - if any(info is None for info in aligned): - missing = [filenames[idx] for idx, info in enumerate(aligned) if info is None] - msg = ("Files missing alignments for this batch: {}" - "\nAt least one of your images does not have a matching entry in your " - "alignments file." - "\nEvery face you intend to train on must exist within the alignments file." - "\nThe specific files that caused this failure are listed above." - "\nMost likely there will be more than just these files missing from the " - "alignments file. You can use the Alignments Tool to help identify missing " - "alignments".format(missing)) - raise FaceswapError(msg) - - if self._extract_versions[side] == 1.0: - # Legacy extract. Don't crop, just return batch with landmarks - return batch, np.array([face.landmarks for face in aligned]) - - if not self._cache["crop_size"]: - size = aligned[0].size - logger.debug("caching crop size: (centering: '%s', full size: %s, crop size: %s)", - self._config["centering"], batch.shape[1], size) - self._cache["crop_size"] = size - size = self._cache["crop_size"] - - landmarks = np.array([face.landmarks for face in aligned]) - cropped = np.array([align.extract_face(img) for align, img in zip(aligned, batch)]) - return cropped, landmarks - - def _apply_mask(self, filenames, batch, side): - """ Applies the mask to the 4th channel of the image. If masks are not being used - applies a dummy all ones mask. - - If the configuration options `eye_multiplier` and/or `mouth_multiplier` are greater than 1 - then these masks are applied to the final channels of the batch respectively. - - Parameters - ---------- - filenames: list - The list of filenames that correspond to this batch - batch: :class:`numpy.ndarray` - The batch of faces that have been loaded from disk - side: str - '"a"' or '"b"' the side that is being processed - - Returns - ------- - :class:`numpy.ndarray` - The batch with masks applied to the final channels - """ - logger.trace("Input filenames: %s, batch shape: %s, side: %s", - filenames, batch.shape, side) - size = batch.shape[1] - for key in ("masks", "eyes", "mouths"): - item = self._masks[key] - if item is None and key != "masks": - continue - - # Expand out partials for eye and mouth masks on first epoch - if item is not None and key in ("eyes", "mouths"): - self._expand_partials(side, item, filenames) - - if item is None and key == "masks": - logger.trace("Creating dummy masks. side: %s", side) - masks = np.ones_like(batch[..., :1], dtype=batch.dtype) - else: - logger.trace("Obtaining masks for batch. (key: %s side: %s)", key, side) - masks = np.array([self._get_mask(item[side][filename], size) - for filename in filenames], dtype=batch.dtype) - masks = self._resize_masks(size, masks) - logger.trace("masks: (key: %s, shape: %s)", key, masks.shape) - batch = np.concatenate((batch, masks), axis=-1) - logger.trace("Output batch shape: %s, side: %s", batch.shape, side) - return batch - - @classmethod - def _expand_partials(cls, side, item, filenames): - """ Expand partials to their compressed byte masks and replace into the main item - dictionary. - - This is run once for each mask on the first epoch, to save on start up time. - - Parameters - ---------- - item: dict - The mask objects with filenames for the current mask type and side - filenames: list - A list of filenames that are being processed this batch - """ - to_process = {filename: item[side][filename] for filename in filenames} - if not any(isinstance(ptl, partial) for ptl in to_process.values()): - return - - for filename, ptl in to_process.items(): - if not isinstance(ptl, partial): - logger.debug("Mask already generated. side: '%s', filename: '%s'", - side, filename) - continue - logger.debug("Generating mask. side: '%s', filename: '%s'", side, filename) - item[side][filename] = ptl() - - @classmethod - def _get_mask(cls, item, size): - """ Decompress zipped eye and mouth masks, or return the stored mask - - Parameters - ---------- - item: :class:`lib.align.Mask` or `bytes` - Either a stored face mask object or a zipped eye or mouth mask - size: int - The size of the stored eye or mouth mask for reshaping - - Returns - ------- - class:`numpy.ndarray` - The decompressed mask - """ - if isinstance(item, bytes): - retval = np.frombuffer(decompress(item), dtype="uint8").reshape(size, size, 1) - else: - retval = item.mask - return retval - - @classmethod - def _resize_masks(cls, target_size, masks): - """ Resize the masks to the target size """ - logger.trace("target size: %s, masks shape: %s", target_size, masks.shape) - mask_size = masks.shape[1] - if target_size == mask_size: - logger.trace("Mask and targets the same size. Not resizing") - return masks - interpolator = cv2.INTER_CUBIC if mask_size < target_size else cv2.INTER_AREA - masks = np.array([cv2.resize(mask, - (target_size, target_size), - interpolation=interpolator)[..., None] - for mask in masks]) - logger.trace("Resized masks: %s", masks.shape) - return masks - - def _get_closest_match(self, filenames, side, batch_src_points): - """ Only called if the :attr:`_warp_to_landmarks` is ``True``. Gets the closest - matched 68 point landmarks from the opposite training set. """ - logger.trace("Retrieving closest matched landmarks: (filenames: '%s', src_points: '%s'", - filenames, batch_src_points) - lm_side = "a" if side == "b" else "b" - landmarks = {key: aligned.landmarks - for key, aligned in self._aligned_faces[lm_side].items()} - closest_matches = [self._cache["nearest_landmarks"].get(filename) - for filename in filenames] - if None in closest_matches: - # Resize mismatched training image size landmarks - sizes = {side: list(self._aligned_faces[side].values())[0].size - for side in self._aligned_faces} - if len(set(sizes.values())) > 1: - scale = sizes[side] / sizes[lm_side] - landmarks = {key: lms * scale for key, lms in landmarks.items()} - closest_matches = self._cache_closest_matches(filenames, batch_src_points, landmarks) - - batch_dst_points = np.array([landmarks[choice(fname)] for fname in closest_matches]) - logger.trace("Returning: (batch_dst_points: %s)", batch_dst_points.shape) - return batch_dst_points - - def _cache_closest_matches(self, filenames, batch_src_points, landmarks): - """ Cache the nearest landmarks for this batch """ - logger.trace("Caching closest matches") - dst_landmarks = list(landmarks.items()) - dst_points = np.array([lm[1] for lm in dst_landmarks]) - batch_closest_matches = list() - - for filename, src_points in zip(filenames, batch_src_points): - closest = (np.mean(np.square(src_points - dst_points), axis=(1, 2))).argsort()[:10] - closest_matches = tuple(dst_landmarks[i][0] for i in closest) - self._cache["nearest_landmarks"][filename] = closest_matches - batch_closest_matches.append(closest_matches) - logger.trace("Cached closest matches") - return batch_closest_matches - - -class ImageAugmentation(): - """ Performs augmentation on batches of training images. - - Parameters - ---------- - batchsize: int - The number of images that will be fed through the augmentation functions at once. - is_display: bool - Whether the images being fed through will be used for Preview or Time-lapse. Disables - the "warp" augmentation for these images. - input_size: int - The expected input size for the model. It is assumed that the input to the model is always - a square image. This is the size, in pixels, of the `width` and the `height` of the input - to the model. - output_shapes: list - A list of tuples defining the output shapes from the model, in the order that the outputs - are returned. The tuples should be in (`height`, `width`, `channels`) format. - coverage_ratio: float - The ratio of the training image to be trained on. Dictates how much of the image will be - cropped out. E.G: a coverage ratio of 0.625 will result in cropping a 160px box from a - 256px image (:math:`256 * 0.625 = 160`) - config: dict - The configuration `dict` generated from :file:`config.train.ini` containing the trainer - plugin configuration options. - - Attributes - ---------- - initialized: bool - Flag to indicate whether :class:`ImageAugmentation` has been initialized with the training - image size in order to cache certain augmentation operations (see :func:`initialize`) - is_display: bool - Flag to indicate whether these augmentations are for time-lapses/preview images (``True``) - or standard training data (``False``) - """ - def __init__(self, batchsize, is_display, input_size, output_shapes, coverage_ratio, config): - logger.debug("Initializing %s: (batchsize: %s, is_display: %s, input_size: %s, " - "output_shapes: %s, coverage_ratio: %s, config: %s)", - self.__class__.__name__, batchsize, is_display, input_size, output_shapes, - coverage_ratio, config) - - self.initialized = False - self.is_display = is_display - - # Set on first image load from initialize - self._training_size = 0 - self._constants = None - - self._batchsize = batchsize - self._config = config - # Transform and Warp args - self._input_size = input_size - self._output_sizes = [shape[1] for shape in output_shapes if shape[2] == 3] - logger.debug("Output sizes: %s", self._output_sizes) - # Warp args - self._coverage_ratio = coverage_ratio - self._scale = 5 # Normal random variable scale - - logger.debug("Initialized %s", self.__class__.__name__) - - def initialize(self, training_size): - """ Initializes the caching of constants for use in various image augmentations. - - The training image size is not known prior to loading the images from disk and commencing - training, so it cannot be set in the :func:`__init__` method. When the first training batch - is loaded this function should be called to initialize the class and perform various - calculations based on this input size to cache certain constants for image augmentation - calculations. - - Parameters - ---------- - training_size: int - The size of the training images stored on disk that are to be fed into - :class:`ImageAugmentation`. The training images should always be square and of the - same size. This is the size, in pixels, of the `width` and the `height` of the - training images. - """ - logger.debug("Initializing constants. training_size: %s", training_size) - self._training_size = training_size - coverage = int(self._training_size * self._coverage_ratio // 2) * 2 - - # Color Aug - clahe_base_contrast = training_size // 128 - # Target Images - tgt_slices = slice(self._training_size // 2 - coverage // 2, - self._training_size // 2 + coverage // 2) - - # Random Warp - warp_range_ = np.linspace(self._training_size // 2 - coverage // 2, - self._training_size // 2 + coverage // 2, 5, dtype='float32') - warp_mapx = np.broadcast_to(warp_range_, (self._batchsize, 5, 5)).astype("float32") - warp_mapy = np.broadcast_to(warp_mapx[0].T, (self._batchsize, 5, 5)).astype("float32") - - warp_pad = int(1.25 * self._input_size) - warp_slices = slice(warp_pad // 10, -warp_pad // 10) - - # Random Warp Landmarks - p_mx = self._training_size - 1 - p_hf = (self._training_size // 2) - 1 - edge_anchors = np.array([(0, 0), (0, p_mx), (p_mx, p_mx), (p_mx, 0), - (p_hf, 0), (p_hf, p_mx), (p_mx, p_hf), (0, p_hf)]).astype("int32") - edge_anchors = np.broadcast_to(edge_anchors, (self._batchsize, 8, 2)) - grids = np.mgrid[0:p_mx:complex(self._training_size), 0:p_mx:complex(self._training_size)] - - self._constants = dict(clahe_base_contrast=clahe_base_contrast, - tgt_slices=tgt_slices, - warp_mapx=warp_mapx, - warp_mapy=warp_mapy, - warp_pad=warp_pad, - warp_slices=warp_slices, - warp_lm_edge_anchors=edge_anchors, - warp_lm_grids=grids) - self.initialized = True - logger.debug("Initialized constants: %s", {k: str(v) if isinstance(v, np.ndarray) else v - for k, v in self._constants.items()}) - - # <<< TARGET IMAGES >>> # - def get_targets(self, batch): - """ Returns the target images, and masks, if required. - - Parameters - ---------- - batch: :class:`numpy.ndarray` - This should be a 4+-dimensional array of training images in the format (`batchsize`, - `height`, `width`, `channels`). Targets should be requested after performing image - transformations but prior to performing warps. - - The 4th channel should be the mask. Any channels above the 4th should be any additional - masks that are requested. - - Returns - ------- - dict - The following keys will be within the returned dictionary: - - * **targets** (`list`) - A list of 4-dimensional :class:`numpy.ndarray` s in the \ - order and size of each output of the model as defined in :attr:`output_shapes`. The \ - format of these arrays will be (`batchsize`, `height`, `width`, `3`). **NB:** \ - masks are not included in the `targets` list. If masks are to be included in the \ - output they will be returned as their own item from the `masks` key. - - * **masks** (:class:`numpy.ndarray`) - A 4-dimensional array containing the target \ - masks in the format (`batchsize`, `height`, `width`, `1`). - """ - logger.trace("Compiling targets: batch shape: %s", batch.shape) - slices = self._constants["tgt_slices"] - target_batch = [np.array([cv2.resize(image[slices, slices, :], - (size, size), - cv2.INTER_AREA) - for image in batch], dtype='float32') / 255. - for size in self._output_sizes] - logger.trace("Target image shapes: %s", - [tgt_images.shape for tgt_images in target_batch]) - - retval = self._separate_target_mask(target_batch) - logger.trace("Final targets: %s", - {k: v.shape if isinstance(v, np.ndarray) else [img.shape for img in v] - for k, v in retval.items()}) - return retval - - @staticmethod - def _separate_target_mask(target_batch): - """ Return the batch and the batch of final masks - - Parameters - ---------- - target_batch: list - List of 4 dimension :class:`numpy.ndarray` objects resized the model outputs. - The 4th channel of the array contains the face mask, any additional channels after - this are additional masks (e.g. eye mask and mouth mask) - - Returns - ------- - dict: - The targets and the masks separated into their own items. The targets are a list of - 3 channel, 4 dimensional :class:`numpy.ndarray` objects sized for each output from the - model. The masks are a :class:`numpy.ndarray` of the final output size. Any additional - masks(e.g. eye and mouth masks) will be collated together into a :class:`numpy.ndarray` - of the final output size. The number of channels will be the number of additional - masks available - """ - logger.trace("target_batch shapes: %s", [tgt.shape for tgt in target_batch]) - retval = dict(targets=[batch[..., :3] for batch in target_batch], - masks=target_batch[-1][..., 3][..., None]) - if target_batch[-1].shape[-1] > 4: - retval["additional_masks"] = target_batch[-1][..., 4:] - logger.trace("returning: %s", {k: v.shape if isinstance(v, np.ndarray) else [tgt.shape - for tgt in v] - for k, v in retval.items()}) - return retval - - # <<< COLOR AUGMENTATION >>> # - def color_adjust(self, batch): - """ Perform color augmentation on the passed in batch. - - The color adjustment parameters are set in :file:`config.train.ini` - - Parameters - ---------- - batch: :class:`numpy.ndarray` - The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, - `3`) and in `BGR` format. - - Returns - ---------- - :class:`numpy.ndarray` - A 4-dimensional array of the same shape as :attr:`batch` with color augmentation - applied. - """ - if not self.is_display: - logger.trace("Augmenting color") - batch = batch_convert_color(batch, "BGR2LAB") - batch = self._random_clahe(batch) - batch = self._random_lab(batch) - batch = batch_convert_color(batch, "LAB2BGR") - return batch - - def _random_clahe(self, batch): - """ Randomly perform Contrast Limited Adaptive Histogram Equalization on - a batch of images """ - base_contrast = self._constants["clahe_base_contrast"] - - batch_random = np.random.rand(self._batchsize) - indices = np.where(batch_random > self._config.get("color_clahe_chance", 50) / 100)[0] - - grid_bases = np.rint(np.random.uniform(0, - self._config.get("color_clahe_max_size", 4), - size=indices.shape[0])).astype("uint8") - contrast_adjustment = (grid_bases * (base_contrast // 2)) - grid_sizes = contrast_adjustment + base_contrast - logger.trace("Adjusting Contrast. Grid Sizes: %s", grid_sizes) - - clahes = [cv2.createCLAHE(clipLimit=2.0, # pylint: disable=no-member - tileGridSize=(grid_size, grid_size)) - for grid_size in grid_sizes] - - for idx, clahe in zip(indices, clahes): - batch[idx, :, :, 0] = clahe.apply(batch[idx, :, :, 0]) - return batch - - def _random_lab(self, batch): - """ Perform random color/lightness adjustment in L*a*b* color space on a batch of - images """ - amount_l = self._config.get("color_lightness", 30) / 100 - amount_ab = self._config.get("color_ab", 8) / 100 - adjust = np.array([amount_l, amount_ab, amount_ab], dtype="float32") - randoms = ( - (np.random.rand(self._batchsize, 1, 1, 3).astype("float32") * (adjust * 2)) - adjust) - logger.trace("Random LAB adjustments: %s", randoms) - - for image, rand in zip(batch, randoms): - for idx in range(rand.shape[-1]): - adjustment = rand[:, :, idx] - if adjustment >= 0: - image[:, :, idx] = ((255 - image[:, :, idx]) * adjustment) + image[:, :, idx] - else: - image[:, :, idx] = image[:, :, idx] * (1 + adjustment) - return batch - - # <<< IMAGE AUGMENTATION >>> # - def transform(self, batch): - """ Perform random transformation on the passed in batch. - - The transformation parameters are set in :file:`config.train.ini` - - Parameters - ---------- - batch: :class:`numpy.ndarray` - The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, - `channels`) and in `BGR` format. - - Returns - ---------- - :class:`numpy.ndarray` - A 4-dimensional array of the same shape as :attr:`batch` with transformation applied. - """ - if self.is_display: - return batch - logger.trace("Randomly transforming image") - rotation_range = self._config.get("rotation_range", 10) - zoom_range = self._config.get("zoom_amount", 5) / 100 - shift_range = self._config.get("shift_range", 5) / 100 - - rotation = np.random.uniform(-rotation_range, - rotation_range, - size=self._batchsize).astype("float32") - scale = np.random.uniform(1 - zoom_range, - 1 + zoom_range, - size=self._batchsize).astype("float32") - tform = np.random.uniform( - -shift_range, - shift_range, - size=(self._batchsize, 2)).astype("float32") * self._training_size - - mats = np.array( - [cv2.getRotationMatrix2D((self._training_size // 2, self._training_size // 2), - rot, - scl) - for rot, scl in zip(rotation, scale)]).astype("float32") - mats[..., 2] += tform - - batch = np.array([cv2.warpAffine(image, - mat, - (self._training_size, self._training_size), - borderMode=cv2.BORDER_REPLICATE) - for image, mat in zip(batch, mats)]) - - logger.trace("Randomly transformed image") - return batch - - def random_flip(self, batch): - """ Perform random horizontal flipping on the passed in batch. - - The probability of flipping an image is set in :file:`config.train.ini` - - Parameters - ---------- - batch: :class:`numpy.ndarray` - The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, - `channels`) and in `BGR` format. - - Returns - ---------- - :class:`numpy.ndarray` - A 4-dimensional array of the same shape as :attr:`batch` with transformation applied. - """ - if not self.is_display: - logger.trace("Randomly flipping image") - randoms = np.random.rand(self._batchsize) - indices = np.where(randoms > self._config.get("random_flip", 50) / 100)[0] - batch[indices] = batch[indices, :, ::-1] - logger.trace("Randomly flipped %s images of %s", len(indices), self._batchsize) - return batch - - def warp(self, batch, to_landmarks=False, **kwargs): - """ Perform random warping on the passed in batch by one of two methods. - - Parameters - ---------- - batch: :class:`numpy.ndarray` - The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, - `3`) and in `BGR` format. - to_landmarks: bool, optional - If ``False`` perform standard random warping of the input image. If ``True`` perform - warping to semi-random similar corresponding landmarks from the other side. Default: - ``False`` - kwargs: dict - If :attr:`to_landmarks` is ``True`` the following additional kwargs must be passed in: - - * **batch_src_points** (:class:`numpy.ndarray`) - A batch of 68 point landmarks for \ - the source faces. This is a 3-dimensional array in the shape (`batchsize`, `68`, `2`). - - * **batch_dst_points** (:class:`numpy.ndarray`) - A batch of randomly chosen closest \ - match destination faces landmarks. This is a 3-dimensional array in the shape \ - (`batchsize`, `68`, `2`). - Returns - ---------- - :class:`numpy.ndarray` - A 4-dimensional array of the same shape as :attr:`batch` with warping applied. - """ - if to_landmarks: - return self._random_warp_landmarks(batch, **kwargs).astype("float32") / 255.0 - return self._random_warp(batch).astype("float32") / 255.0 - - def _random_warp(self, batch): - """ Randomly warp the input batch """ - logger.trace("Randomly warping batch") - mapx = self._constants["warp_mapx"] - mapy = self._constants["warp_mapy"] - pad = self._constants["warp_pad"] - slices = self._constants["warp_slices"] - - rands = np.random.normal(size=(self._batchsize, 2, 5, 5), - scale=self._scale).astype("float32") - batch_maps = np.stack((mapx, mapy), axis=1) + rands - batch_interp = np.array([[cv2.resize(map_, (pad, pad))[slices, slices] for map_ in maps] - for maps in batch_maps]) - warped_batch = np.array([cv2.remap(image, interp[0], interp[1], cv2.INTER_LINEAR) - for image, interp in zip(batch, batch_interp)]) - - logger.trace("Warped image shape: %s", warped_batch.shape) - return warped_batch - - def _random_warp_landmarks(self, batch, batch_src_points, batch_dst_points): - """ From dfaker. Warp the image to a similar set of landmarks from the opposite side """ - logger.trace("Randomly warping landmarks") - edge_anchors = self._constants["warp_lm_edge_anchors"] - grids = self._constants["warp_lm_grids"] - slices = self._constants["tgt_slices"] - - batch_dst = (batch_dst_points + np.random.normal(size=batch_dst_points.shape, - scale=2.0)) - - face_cores = [cv2.convexHull(np.concatenate([src[17:], dst[17:]], axis=0)) - for src, dst in zip(batch_src_points.astype("int32"), - batch_dst.astype("int32"))] - - batch_src = np.append(batch_src_points, edge_anchors, axis=1) - batch_dst = np.append(batch_dst, edge_anchors, axis=1) - - rem_indices = [list(set(idx for fpl in (src, dst) - for idx, (pty, ptx) in enumerate(fpl) - if cv2.pointPolygonTest(face_core, (pty, ptx), False) >= 0)) - for src, dst, face_core in zip(batch_src[:, :18, :], - batch_dst[:, :18, :], - face_cores)] - batch_src = [np.delete(src, idxs, axis=0) for idxs, src in zip(rem_indices, batch_src)] - batch_dst = [np.delete(dst, idxs, axis=0) for idxs, dst in zip(rem_indices, batch_dst)] - - grid_z = np.array([griddata(dst, src, (grids[0], grids[1]), method="linear") - for src, dst in zip(batch_src, batch_dst)]) - maps = grid_z.reshape((self._batchsize, - self._training_size, - self._training_size, - 2)).astype("float32") - warped_batch = np.array([cv2.remap(image, - map_[..., 1], - map_[..., 0], - cv2.INTER_LINEAR, - cv2.BORDER_TRANSPARENT) - for image, map_ in zip(batch, maps)]) - warped_batch = np.array([cv2.resize(image[slices, slices, :], - (self._input_size, self._input_size), - cv2.INTER_AREA) - for image in warped_batch]) - logger.trace("Warped batch shape: %s", warped_batch.shape) - return warped_batch - - def skip_warp(self, batch): - """ Returns the images resized and cropped for feeding the model, if warping has been - disabled. - - Parameters - ---------- - batch: :class:`numpy.ndarray` - The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, - `3`) and in `BGR` format. - - Returns - ------- - :class:`numpy.ndarray` - The given batch cropped and resized for feeding the model - """ - logger.trace("Compiling skip warp images: batch shape: %s", batch.shape) - slices = self._constants["tgt_slices"] - retval = np.array([cv2.resize(image[slices, slices, :], - (self._input_size, self._input_size), - cv2.INTER_AREA) - for image in batch], dtype='float32') / 255. - logger.trace("feed batch shape: %s", retval.shape) - return retval diff --git a/lib/utils.py b/lib/utils.py index 6ff2ed2d46..dd5ee2b17d 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -161,13 +161,15 @@ def get_folder(path, make_folder=True): return output_dir -def get_image_paths(directory): +def get_image_paths(directory, extension=None): """ Obtain a list of full paths that reside within a folder. Parameters ---------- directory: str The folder that contains the images to be returned + extension: str + The specific image extensions that should be returned Returns ------- @@ -175,7 +177,7 @@ def get_image_paths(directory): The list of full paths to the images contained within the given folder """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name - image_extensions = _image_extensions + image_extensions = _image_extensions if extension is None else [extension] dir_contents = list() if not os.path.exists(directory): diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 40fda06684..47fec505ca 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -12,20 +12,13 @@ import os import time -from concurrent import futures -from functools import partial - import cv2 import numpy as np import tensorflow as tf from tensorflow.python.framework import errors_impl as tf_errors -from tqdm import tqdm -from lib.align import (Alignments, AlignedFace, DetectedFace, get_centered_size, - update_legacy_png_header) -from lib.image import read_image_meta_batch -from lib.training_data import TrainingDataGenerator +from lib.training import TrainingDataGenerator from lib.utils import FaceswapError, get_backend, get_folder, get_image_paths from plugins.train._config import Config @@ -80,13 +73,8 @@ def __init__(self, model, images, batch_size, configfile): self._model.state.add_session_batchsize(batch_size) self._images = images self._sides = sorted(key for key in self._images.keys()) - alignment_data = self._get_alignments_data() - self._feeder = _Feeder(images, - self._model, - batch_size, - self._config, - alignment_data) + self._feeder = _Feeder(images, self._model, batch_size, self._config) self._tensorboard = self._set_tensorboard() self._samples = _Samples(self._model, @@ -124,50 +112,6 @@ def _get_config(self, configfile): config[key] = new_val return config - def _get_alignments_data(self): - """ Extrapolate alignments and masks from the alignments file into a `dict` for the - training data generator. - - Removes any images from :attr:`_images` if they do not have alignment data attached. - - Returns - ------- - dict: - Includes the key `aligned_faces` holding aligned face information and the key - `versions` indicating the alignments file versions that the faces have come from. - In addition, the following optional keys are provided: `masks` if masks are required - for training, `masks_eye` if eye masks are required and `masks_mouth` if mouth masks - are required. """ - penalized_loss = self._model.config["penalized_mask_loss"] - - alignments = _TrainingAlignments(self._model, self._images) - # Update centering if it has been changed by legacy face sets in TrainingAlignments - self._config["centering"] = self._model.config["centering"] - retval = dict(aligned_faces=alignments.aligned_faces, - versions=alignments.versions) - - if self._model.config["learn_mask"] or penalized_loss: - logger.debug("Adding masks to training opts dict") - retval["masks"] = alignments.masks - - if penalized_loss and self._model.config["eye_multiplier"] > 1: - retval["masks_eye"] = alignments.masks_eye - - if penalized_loss and self._model.config["mouth_multiplier"] > 1: - retval["masks_mouth"] = alignments.masks_mouth - - logger.debug({key: {k: v if isinstance(v, float) else len(v) - for k, v in val.items()} - for key, val in retval.items()}) - - # Replace _images with list containing valid alignment data - for side, aligned_faces in alignments.aligned_faces.items(): - if len(aligned_faces) != len(self._images[side]): - logger.info("Updating training images list with images containing valid metadata " - "for side '%s'", side.upper()) - self._images[side] = list(aligned_faces.keys()) - return retval - def _set_tensorboard(self): """ Set up Tensorboard callback for logging loss. @@ -379,17 +323,13 @@ class _Feeder(): The size of the batch to be processed for each side at each iteration config: :class:`lib.config.FaceswapConfig` The configuration for this trainer - alignments: dict - A dictionary containing aligned face data, extract version information and masks if these - are required for training for each side """ - def __init__(self, images, model, batch_size, config, alignments): + def __init__(self, images, model, batch_size, config): logger.debug("Initializing %s: num_images: %s, batch_size: %s, config: %s)", self.__class__.__name__, len(images), batch_size, config) self._model = model self._images = images self._config = config - self._alignments = alignments self._target = dict() self._samples = dict() self._masks = dict() @@ -425,7 +365,6 @@ def _load_generator(self, output_index): self._model.command_line_arguments.no_flip, self._model.command_line_arguments.no_warp, self._model.command_line_arguments.warp_to_landmarks, - self._alignments, self._config) return generator @@ -1089,406 +1028,6 @@ def output_timelapse(self, timelapse_kwargs): logger.debug("Created time-lapse: '%s'", filename) -class _TrainingAlignments(): - """ Obtain Landmarks and required mask from alignments file. - - Parameters - ---------- - model: plugin from :mod:`plugins.train.model` - The model that will be running this trainer - image_list: dict - The file paths for the images to be trained on for each side. The dictionary should contain - 2 keys ("a" and "b") with the values being a list of full paths corresponding to each side. - """ - def __init__(self, model, image_list): - logger.debug("Initializing %s: (model: %s, image counts: %s)", - self.__class__.__name__, model, {k: len(v) for k, v in image_list.items()}) - self._args = model.command_line_arguments - self._config = model.config - - self._alignments_version = dict() - self._image_sizes = {key: None for key in image_list} - self._detected_faces = self._load_detected_faces(image_list) - self._update_legacy_facesets(image_list) - - self._validity_check() - self._aligned_faces = self._get_aligned_faces() - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def versions(self): - """ dict: The "a", "b" keys for each side, with value being the alignment file version - that provided the data. This is used to crop the faces correctly based on whether the - extracted faces are legacy or full-head extracts. """ - return self._alignments_version - - @property - def aligned_faces(self): - """ dict: The "a", "b" keys for each side, containing a sub-dictionary with the - filename as key and :class:`lib.align.AlignedFace` object as value. """ - return self._aligned_faces - - # <<< LOAD DETECTED FACE INFORMATION FROM PNG HEADER >>> - def _load_detected_faces(self, image_list): - """ Obtain the metadata from the png training image headers for all images used for - training. - - The Faceswap alignments data is returned as a dictionary, whist :attr:`_image_sizes` is - populated from the training image metadata - - Parameters - ---------- - image_list: dict - The file paths for the images to be trained on for each side. The dictionary should - contain 2 keys ("a" and "b") with the values being a list of full paths corresponding - to each side. - - Returns - ------- - dict - For keys "a" and "b" the values are a ``dict`` with the key being the filename of the - training image and the value being the :class:`lib.align.DetectedFace` object - """ - metadata = dict() - for side, filelist in image_list.items(): - meta_side = dict() - logger.debug("side: %s, file count: %s", side, len(filelist)) - for filename, meta in tqdm(read_image_meta_batch(filelist), - desc="Reading training images ({})".format(side.upper()), - total=len(filelist), - leave=False): - - self._validate_image_size(side, filename, meta["width"], meta["height"]) - - if "itxt" not in meta or "alignments" not in meta["itxt"]: - meta_side[filename] = None - else: - alignments_version = meta["itxt"]["source"]["alignments_version"] - self._alignments_version.setdefault(side, set()).add(alignments_version) - detected_face = DetectedFace() - detected_face.from_png_meta(meta["itxt"]["alignments"]) - meta_side[filename] = detected_face - metadata[side] = meta_side - return metadata - - def _validate_image_size(self, side, filename, width, height): - """ Validate that the images are square and that the sizes for all image in a side are - the same. - - Parameters - ---------- - side: ["a" or "b"] - The training side that is being processed - filename: str - The filename of the image that is being validated - width: int - The width of the image to be validated - height: int - The height of the image to be validated - - Raises - ------ - FaceswapError - If the image to be checked is not square or is of a different size of any other image - for the current side, an error is raised. - """ - # Add the image size to the sizes dictionary if this is the first image - if not self._image_sizes[side]: - self._image_sizes[side] = width - - # Validate image is square - if width != height: - msg = ("Training images must be created by the extraction process and must be " - "square.\nThe image '{}' has dimensions {}x{} so the process cannot " - "continue.\nThere may be more images with these issues. Please double " - "check your dataset".format(filename, width, height)) - raise FaceswapError(msg) - - # Validate image is the same size as the other images for the side - if width != self._image_sizes[side]: - msg = ("All training images for each side must be of the same size.\nImages " - "in side '{}' have mismatched sizes {} and {}.\nPlease double check " - "your dataset".format(side.upper(), self._image_sizes[side], width)) - raise FaceswapError(msg) - - def _update_legacy_facesets(self, image_list): - """ Update the png header data for legacy face sets that do not contain the meta data in - the exif header. - - Parameters - ---------- - image_list: dict - The file paths for the images to be trained on for each side. The dictionary should - contain 2 keys ("a" and "b") with the values being a list of full paths corresponding - to each side. - """ - if self._validate_metadata(output_warning=False): - logger.debug("All faces contain valid header information") - return - - for side, png_meta in self._detected_faces.items(): - if all(png_meta.values()): - continue - filenames = [filename for filename, meta in png_meta.items() if not meta] - logger.info("Legacy faces discovered for side '%s'. Updating %s images...", - side.upper(), len(filenames)) - alignments = Alignments(*os.path.split(self._get_alignments_path(side))) - self._alignments_version.setdefault(side, set()).add(alignments.version) - - executor = futures.ThreadPoolExecutor() - with executor: - images = {executor.submit(update_legacy_png_header, filename, alignments): filename - for filename in filenames} - - for future in tqdm( - futures.as_completed(images), - desc="Updating legacy training images ({})".format(side.upper()), - total=len(filenames), - leave=False): - result = future.result() - if result: - filename = images[future] - if os.path.splitext(filename)[-1].lower() != ".png": - # Update the image list to point at newly created png - del png_meta[filename] - image_list[side].remove(filename) - - filename = os.path.splitext(filename)[0] + ".png" - image_list[side].append(filename) - - detected_face = DetectedFace() - detected_face.from_png_meta(future.result()["alignments"]) - png_meta[filename] = detected_face - - def _get_alignments_path(self, side): - """ Obtain the path to an alignments file for the given training side. - - Used for updating legacy face sets to contain the meta information within the image header - - Parameters - ---------- - side: ["a" or "b"] - The training side to obtain the alignments file for. - - Returns - ------- - str - The full path to the training alignments file - - Raises - ------ - FaceswapError - If an alignments file cannot be located - """ - alignments_path = getattr(self._args, "alignments_path_{}".format(side)) - if not alignments_path: - image_path = getattr(self._args, "input_{}".format(side)) - alignments_path = os.path.join(image_path, "alignments.fsa") - if not os.path.exists(alignments_path): - msg = ("You are using a legacy faceset that does not contain embedded " - "meta-information. An alignments file must be provided so that these files can " - "be updated.\n" - f"Alignments file does not exist: '{alignments_path}'") - raise FaceswapError(msg) - return alignments_path - - # <<< VALIDATE LOADED DETECTED FACE INFORMATION >>> - def _validity_check(self): - """ Check the validity of the finally loaded data. - - Ensure that each side contains alignments data that was extracted with the same centering. - Ensure that each side has a full compliment of metadata. - """ - invalid = [side.upper() - for side, vers in self._alignments_version.items() - if len(vers) > 1 and any(v < 2 for v in vers) and any(v > 1 for v in vers)] - - if invalid: - raise FaceswapError("Mixing legacy and full head extracted facesets is not supported. " - "The following side(s) contain a mix of extracted face " - "types: {}".format(invalid)) - # Replace check alignments version sets with actual floats - self._alignments_version = {key: val.pop() - for key, val in self._alignments_version.items()} - - if 1.0 in self._alignments_version.values() and self._config["centering"] != "legacy": - logger.warning("You are using legacy extracted faces but have selected '%s' " - "centering which is incompatible. Switching centering to 'legacy'", - self._config["centering"]) - self._config["centering"] = "legacy" - - self._validate_metadata(output_warning=True) - self._validate_masks() - - def _validate_metadata(self, output_warning=True): - """ Validate that all images to be trained on have associated alignments data. If not - generate a warning. - - Parameters - ---------- - output_warning: bool, optional - If ``True`` outputs a warning that images are missing alignments data. - - Returns - ------- - bool - ``True`` if all images have valid metadata otherwise ``False`` - """ - all_valid = {side: all(val.values()) for side, val in self._detected_faces.items()} - if all(all_valid.values()): - return True - if not output_warning: - return False - - for side, valid in all_valid.items(): - if valid: - continue - if all(val is None for val in self._detected_faces[side].values()): - raise FaceswapError("There is no valid training data for side '{}'. Re-check your " - "data and try again.".format(side.upper())) - invalid = [filename - for filename, meta in self._detected_faces[side].items() if not meta] - - logger.warning("Data for training side '%s' contains %s faces that do not contain " - "valid metadata and will be excluded from training.", - side.upper(), len(invalid)) - logger.warning("Run in VERBOSE mode if you wish to see a list of these files.") - logger.verbose("Side '%s' images missing metadata: %s", side.upper(), - sorted(os.path.basename(fname) for fname in invalid)) - # Remove images without metadata - self._detected_faces[side] = {key: val - for key, val in self._detected_faces[side].items() - if val} - return False - - def _validate_masks(self): - """ Validate the the loaded metadata all contain the masks required for training. - - Raises - ------ - FaceswapError - If at least one face in the training data does not contain the selected mask type - """ - mask_type = self._config["mask_type"] - if mask_type is None: - logger.debug("No mask selected. Not validating") - return - invalid = {side: [filename for filename, detected_face in faces.items() - if mask_type not in detected_face.mask] - for side, faces in self._detected_faces.items()} - if any(invalid.values()): - msg = ("You have selected the Mask Type '{}' in your training configuration options " - "but at least one face does not have this mask type stored for it.\nYou should " - "select a mask type that exists within your face data, or generate the " - "required masks with the Mask Tool.".format(mask_type)) - for side, filenames in invalid.items(): - if not filenames: - continue - available = set(mask - for det_face in self._detected_faces[side].values() - for mask in det_face.mask) - msg += ("\n{} faces in side {} do not contain the mask '{}'. Available " - "masks: {}".format(len(filenames), side.upper(), mask_type, available)) - raise FaceswapError(msg) - - # <<< LOAD REQUIRED DATA FOR TRAINING >>> - def _get_aligned_faces(self): - """ Pre-generate aligned faces as they are needed for all training functions. - - Returns - ------- - dict - The "a", "b" keys for each side, containing a sub-dictionary with the - filename as key and :class:`lib.align.AlignedFace` object as value. - """ - logger.debug("Loading aligned faces: %s", - {k: len(v) for k, v in self._detected_faces.items()}) - retval = dict() - for side, detected_faces in self._detected_faces.items(): - retval[side] = dict() - size = get_centered_size("legacy" if self._alignments_version[side] == 1.0 else "head", - self._config["centering"], - self._image_sizes[side]) - for filename, face in detected_faces.items(): - retval[side][filename] = AlignedFace(face.landmarks_xy, - centering=self._config["centering"], - size=size, - is_aligned=True) - logger.debug("Loaded aligned faces: %s", {k: len(v) for k, v in retval.items()}) - return retval - - # Get masks - @property - def masks(self): - """ dict: The :class:`lib.align.Mask` objects of requested mask type for - keys a" and "b" - """ - retval = dict() - for side, faces in self._detected_faces.items(): - retval[side] = dict() - for filename, detected_face in faces.items(): - mask = detected_face.mask[self._config["mask_type"]] - mask.set_blur_and_threshold(blur_kernel=self._config["mask_blur_kernel"], - threshold=self._config["mask_threshold"]) - if self._alignments_version[side] > 1.0 and self._config["centering"] == "legacy": - mask.set_sub_crop(self._aligned_faces[side][filename].pose.offset["face"] * -1) - retval[side][filename] = mask - logger.trace(retval) - return retval - - @property - def masks_eye(self): - """ dict: filename mapping to zip compressed eye masks for keys "a" and "b" """ - retval = {side: self._get_landmarks_masks(side, detected_faces, "eyes") - for side, detected_faces in self._detected_faces.items()} - return retval - - @property - def masks_mouth(self): - """ dict: filename mapping to zip compressed mouth masks for keys "a" and "b" """ - retval = {side: self._get_landmarks_masks(side, detected_faces, "mouth") - for side, detected_faces in self._detected_faces.items()} - return retval - - def _get_landmarks_masks(self, side, detected_faces, area): - """ Obtain the area landmarks masks for the given area. - - A :func:`functools.partial` is returned rather than the full compressed mask to speed up - pre-loading. The partials are expanded the first time they are accessed within the training - loop. - - Parameters - ---------- - side: {"a" or "b"} - The side currently being processed - detected_faces: dict - Key is the filename of the face, value is the corresponding - :class:`lib.align.DetectedFace` object - area: {"eyes" or "mouth"} - The area of the face to obtain the mask for - - Returns - ------- - dict - The face filenames as keys with the :func:`functools.partial` of the mask as value - """ - logger.trace("side: %s, detected_faces: %s, area: %s", side, detected_faces, area) - masks = dict() - size = list(self._aligned_faces[side].values())[0].size - for filename, face in detected_faces.items(): - masks[filename] = partial(face.get_landmark_mask, - size, - area, - aligned=True, - centering=self._config["centering"], - dilation=size // 32, - blur_kernel=size // 16, - as_zip=True) - logger.trace("side: %s, area: %s, masks: %s", - side, area, {key: type(val) for key, val in masks.items()}) - return masks - - def _stack_images(images): """ Stack images evenly for preview. diff --git a/scripts/train.py b/scripts/train.py index 68dfa5c683..722f7869a4 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -10,6 +10,7 @@ import cv2 +from lib.image import read_image_meta from lib.keypress import KBHit from lib.multithreading import MultiThread from lib.utils import (get_folder, get_image_paths, FaceswapError, _image_extensions) @@ -53,7 +54,7 @@ def __init__(self, arguments): logger.debug("Initialized %s", self.__class__.__name__) def _get_images(self): - """ Check the image folders exist and contains images and obtain image paths. + """ Check the image folders exist and contains valid extracted faces. Obtain image paths. Returns ------- @@ -69,13 +70,23 @@ def _get_images(self): logger.error("Error: '%s' does not exist", image_dir) sys.exit(1) - images[side] = get_image_paths(image_dir) + images[side] = get_image_paths(image_dir, ".png") if not images[side]: logger.error("Error: '%s' contains no images", image_dir) sys.exit(1) + # Validate the first image is a detected face + test_image = next(img for img in images[side]) + meta = read_image_meta(test_image) + logger.debug("Test file: (filename: %s, metadata: %s)", test_image, meta) + if "itxt" not in meta or "alignments" not in meta["itxt"]: + logger.error("The input folder '%s' contains images that are not extracted faces.", + image_dir) + logger.error("You can only train a model on faces generated from Faceswap's " + "extract process. Please check your sources and try again.") + sys.exit(1) - logger.info("Model A Directory: %s", self._args.input_a) - logger.info("Model B Directory: %s", self._args.input_b) + logger.info("Model %s Directory: '%s' (%s images)", + side.upper(), image_dir, len(images[side])) logger.debug("Got image paths: %s", [(key, str(len(val)) + " images") for key, val in images.items()]) self._validate_image_counts(images) From 002534df0c3467d15bb4b98037be27e828fd3a20 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 21 Mar 2021 23:40:35 +0000 Subject: [PATCH 424/981] CI - Update simple_tests.py --- _travis/simple_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_travis/simple_tests.py b/_travis/simple_tests.py index 3a3e490894..877a9c3b71 100644 --- a/_travis/simple_tests.py +++ b/_travis/simple_tests.py @@ -95,7 +95,7 @@ def extract_args(detector, aligner, in_path, out_path, args=None): def train_args(model, model_path, faces, alignments, iterations=5, batchsize=8, extra_args=""): """ Train command """ py_exe = sys.executable - args = "%s faceswap.py train -A %s -ala %s -B %s -alb %s -m %s -t %s -bs %i -it %s %s" % ( + args = "%s faceswap.py train -A %s -B %s -m %s -t %s -bs %i -it %s %s" % ( py_exe, faces, alignments, faces, alignments, model_path, model, batchsize, iterations, extra_args ) From c830aa1a674fe07614e6431d8196981f6c9a8544 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 21 Mar 2021 23:46:39 +0000 Subject: [PATCH 425/981] CI - Update simple_tests.py --- _travis/simple_tests.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/_travis/simple_tests.py b/_travis/simple_tests.py index 877a9c3b71..f908675e03 100644 --- a/_travis/simple_tests.py +++ b/_travis/simple_tests.py @@ -96,8 +96,7 @@ def train_args(model, model_path, faces, alignments, iterations=5, batchsize=8, """ Train command """ py_exe = sys.executable args = "%s faceswap.py train -A %s -B %s -m %s -t %s -bs %i -it %s %s" % ( - py_exe, faces, alignments, faces, - alignments, model_path, model, batchsize, iterations, extra_args + py_exe, faces, faces, model_path, model, batchsize, iterations, extra_args ) return args.split() From a49831fb2685097feebd8343779a867bfd44fdb9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 22 Mar 2021 14:45:18 +0000 Subject: [PATCH 426/981] GUI - Add theme support to console --- lib/gui/.cache/themes/default.json | 211 +++++++++++++++++------------ lib/gui/custom_widgets.py | 24 ++-- lib/gui/utils.py | 64 ++++++--- 3 files changed, 183 insertions(+), 116 deletions(-) diff --git a/lib/gui/.cache/themes/default.json b/lib/gui/.cache/themes/default.json index 54c7b74a7f..67543a3810 100644 --- a/lib/gui/.cache/themes/default.json +++ b/lib/gui/.cache/themes/default.json @@ -1,84 +1,129 @@ { - "info": "Initial default theme configuration whilst migrating from default ttk OS widgets", - "group_panel": { - "info": { - "info1": "The 'group_panel' section are any section which contains items for user input, such as the left hand options panel in the main GUI or the Settings pop-up", - "info2": "Anything which uses a 'group_panel' will use the theme specified here as default. Panels can be overriden (see below).", - - "panel_background": "The background color of the main panel that holds all of the group options.", - - "info_color": "The background color of the information header box at the top of each control panel", - "info_font": "The color of the font inside the information header box at the top of each control panel", - "info_border": "The color of the border around the outside of the information header box at the top of each control panel", - - "header_color": "The color to use for the option group boxes header backgrounds, the group box border and for labels on options groups.", - "header_font": "The color to use for the option group boxes header font.", - "group_background": "This is the color used for the background of each group of options, as well as the background color used for any label which resides inside a group box", - "group_font": "The font color used inside each group box for labels", - - "control_color": "The color of controls (e.g. Slider knob, combo pull-down arrow, scrollbar slider + arrows etc.)", - "control_active": "Selected/hovered over color of controls (e.g. Slider knob, combo pull-down arrow, scrollbar slider + arrows etc.)", - "control_disabled": "The color of controls when they are disabled (specifically scrollbars when there is no page to scroll).", - - "input_color": "The background color of input boxes (e.g. text entry)", - "input_font": "The font color of input boxes (e.g. text entry)", - "button_background": "The background color of buttons", - - "scrollbar_border": "Border color of scrollbar", - "scrollbar_trough": "Trough color of scrollbar" - }, - "panel_background": "#CDD3D5", - - "info_color": "#FFFFFF", - "info_font": "#000000", - "info_border": "#000000", - - "header_color": "#176087", - "header_font": "#FFFFFF", - "group_background": "#FFFFFF", - "group_border": "#176087", - "group_font": "#000000", - - "control_color": "#75929C", - "control_active": "#176087", - "control_disabled": "#CDD3D5", - - "input_color": "#FFFFFF", - "input_font": "#000000", - "button_background": "#FFFFFF", - - "scrollbar_border": "#176087", - "scrollbar_trough": "#CDD3D5" - }, - "group_settings": { - "info": {"info1": "Override default colors for the settings pop-up. See 'group_panel' for allowable options", - "info2": "Options same as 'group_panel' with the following additions:", - - "tree_select": "The color of the selected item in the left hand nav frame", - "link_color": "The color of links on pages where there are no configuration options" - }, - "panel_background": "#DAD2D8", - - "header_color": "#9B1D20", - "group_border": "#9B1D20", - - "control_color": "#B090A8", - "control_active": "#9B1D20", - "control_disabled": "#DAD2D8", - - "scrollbar_border": "#9B1D20", - "scrollbar_trough": "#DAD2D8", - - "tree_select": "#9B1D20", - "link_color": "#9B1D20" - }, - "console": { - "background_color": "#CDD3D5", - "foreground_color": "#000000" - }, - "tooltip": { - "background_color": "#FFFFEA", - "border_color": "#FFFFEA", - "font_color": "#000000" - } -} \ No newline at end of file + "info": "Initial default theme configuration whilst migrating from default ttk OS widgets", + "group_panel": { + "info": { + "info1": "The 'group_panel' section are any section which contains items for user input, such as the left hand options panel in the main GUI or the Settings pop-up", + "info2": "Anything which uses a 'group_panel' will use the theme specified here as default. Panels can be overriden (see below).", + + "panel_background": "The background color of the main panel that holds all of the group options.", + + "info_color": "The background color of the information header box at the top of each control panel", + "info_font": "The color of the font inside the information header box at the top of each control panel", + "info_border": "The color of the border around the outside of the information header box at the top of each control panel", + + "header_color": "The color to use for the option group boxes header backgrounds, the group box border and for labels on options groups.", + "header_font": "The color to use for the option group boxes header font.", + "group_background": "This is the color used for the background of each group of options, as well as the background color used for any label which resides inside a group box", + "group_font": "The font color used inside each group box for labels", + + "control_color": "The color of controls (e.g. Slider knob, combo pull-down arrow, scrollbar slider + arrows etc.)", + "control_active": "Selected/hovered over color of controls (e.g. Slider knob, combo pull-down arrow, scrollbar slider + arrows etc.)", + "control_disabled": "The color of controls when they are disabled (specifically scrollbars when there is no page to scroll).", + + "input_color": "The background color of input boxes (e.g. text entry)", + "input_font": "The font color of input boxes (e.g. text entry)", + "button_background": "The background color of buttons", + + "scrollbar_border": "Border color of scrollbar", + "scrollbar_trough": "Trough color of scrollbar" + }, + "panel_background": "#CDD3D5", + + "info_color": "#FFFFFF", + "info_font": "#000000", + "info_border": "#000000", + + "header_color": "#176087", + "header_font": "#FFFFFF", + "group_background": "#FFFFFF", + "group_border": "#176087", + "group_font": "#000000", + + "control_color": "#75929C", + "control_active": "#176087", + "control_disabled": "#CDD3D5", + + "input_color": "#FFFFFF", + "input_font": "#000000", + "button_background": "#FFFFFF", + + "scrollbar_border": "#176087", + "scrollbar_trough": "#CDD3D5" + }, + "group_settings": { + "info": { + "info1": "Override default colors for the settings pop-up. See 'group_panel' for allowable options", + "info2": "Options same as 'group_panel' with the following additions:", + + "tree_select": "The color of the selected item in the left hand nav frame", + "link_color": "The color of links on pages where there are no configuration options" + }, + "panel_background": "#DAD2D8", + + "header_color": "#9B1D20", + "group_border": "#9B1D20", + + "control_color": "#B090A8", + "control_active": "#9B1D20", + "control_disabled": "#DAD2D8", + + "scrollbar_border": "#9B1D20", + "scrollbar_trough": "#DAD2D8", + + "tree_select": "#9B1D20", + "link_color": "#9B1D20" + }, + "console": { + "info": { + "info1": "The colors of the console output box", + + "background_color": "The background color of the console output", + "stdout_color": "The text color for standard print message output (non Faceswap Logging messages)", + "stderr_color": "The text color for messages that are printed to sterr (non Faceswap Logging messages)", + "info_color": "The text color for Faceswap INFO log messages", + "verbose_color": "The text color for Faceswap VERBOSE log messages", + "warning_color": "The text color for Faceswap WARNING log messages", + "critical_color": "The text color for Faceswap CRITICAL log messages", + "error_color": "The text color for Faceswap ERROR log messages", + + "scrollbar_border": "The color of the overall scrollbar border", + "scrollbar_trough": "The color of the scrollbar trough", + + "scrollbar_background_": "The main color of the up/down buttons and the slider of the scrollbar, for active (pressed/hovered), normal and disabled (no scrollbar required)", + "scrollbar_foreground_": "The foreground color for the up/down buttons of the scrollbar, for active (pressed/hovered), normal and disabled (no scrollbar required)", + "scrollbar_border_": "The border color of the up/down buttons and the slider of the scrollbar, for active (pressed/hovered), normal and disabled (no scrollbar required)" + }, + "background_color": "#CDD3D5", + "stdout_color": "#172c87", + "stderr_color": "#78162f", + "info_color": "#176087", + "verbose_color": "#1D9B32", + "warning_color": "#9B701D", + "critical_color": "#9B381D", + "error_color": "#9B381D", + + "scrollbar_border": "#176087", + "scrollbar_trough": "#CDD3D5", + "scrollbar_background_normal": "#75929C", + "scrollbar_background_disabled": "#CDD3D5", + "scrollbar_background_active": "#176087", + "scrollbar_foreground_normal": "#CDD3D5", + "scrollbar_foreground_disabled": "#75929C", + "scrollbar_foreground_active": "#CDD3D5", + "scrollbar_border_normal": "#176087", + "scrollbar_border_disabled": "#75929C", + "scrollbar_border_active": "#176087" + }, + "tooltip": { + "info": { + "info1": "The colors of the tool-tip pop ups", + + "background_color": "Tool-tip background color", + "border_color": "Tool-tip border color", + "font_color": "Tool-tip font color" + }, + "background_color": "#FFFFEA", + "border_color": "#FFFFEA", + "font_color": "#000000" + } +} diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index d90b72b48b..d4f087c5a0 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -161,26 +161,28 @@ def _build_console(self): self._console.config(width=100, height=6, bg=self._theme["background_color"], - fg=self._theme["foreground_color"]) - self._console.pack(side=tk.LEFT, anchor=tk.N, fill=tk.BOTH, expand=True) + fg=self._theme["stdout_color"]) - scrollbar = ttk.Scrollbar(self, command=self._console.yview) - scrollbar.pack(side=tk.LEFT, fill="y") + scrollbar = ttk.Scrollbar(self, + command=self._console.yview, + style="Console.Vertical.TScrollbar") self._console.configure(yscrollcommand=scrollbar.set) + scrollbar.pack(side=tk.RIGHT, fill="y") + self._console.pack(side=tk.LEFT, anchor=tk.N, fill=tk.BOTH, expand=True) self._redirect_console() logger.debug("Built console") def _add_tags(self): """ Add tags to text widget to color based on output """ logger.debug("Adding text color tags") - self._console.tag_config("default", foreground="#1E1E1E") - self._console.tag_config("stderr", foreground="#E25056") - self._console.tag_config("info", foreground="#2B445E") - self._console.tag_config("verbose", foreground="#008140") - self._console.tag_config("warning", foreground="#F77B00") - self._console.tag_config("critical", foreground="red") - self._console.tag_config("error", foreground="red") + self._console.tag_config("default", foreground=self._theme["stdout_color"]) + self._console.tag_config("stderr", foreground=self._theme["stderr_color"]) + self._console.tag_config("info", foreground=self._theme["info_color"]) + self._console.tag_config("verbose", foreground=self._theme["verbose_color"]) + self._console.tag_config("warning", foreground=self._theme["warning_color"]) + self._console.tag_config("critical", foreground=self._theme["critical_color"]) + self._console.tag_config("error", foreground=self._theme["error_color"]) def _redirect_console(self): """ Redirect stdout/stderr to console Text Box """ diff --git a/lib/gui/utils.py b/lib/gui/utils.py index e1152bead1..96bdf0e974 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -1138,6 +1138,15 @@ def _set_styles(self): font=(self._font[0], self._font[1] + 4, "bold")) self._style.configure("SPanel.Header2.TLabel", font=(self._font[0], self._font[1] + 2, "bold")) + # Console + theme = self._user_theme["console"] + console_sbar = {area: tuple(theme[f"scrollbar_{area}_{state}"] + for state in ("normal", "disabled", "active")) + for area in ("background", "foreground", "border")} + self._get_custom_scrollbar("Console", + console_sbar, + theme["scrollbar_trough"], + theme["scrollbar_border"]) def _config_settings_group(self): """ Configures the style of the control panel entry boxes. Used for inputting Faceswap @@ -1159,7 +1168,19 @@ def _config_settings_group(self): self._group_panel_widgets(panel_type, theme) self._group_panel_infoheader(panel_type, theme) self._config_settings_group_slider(panel_type, theme) - self._config_settings_group_scrollbar(panel_type, theme) + sbar_theme = dict(background=(theme["control_color"], + theme["control_disabled"], + theme["control_active"]), + foreground=(theme["control_disabled"], + theme["control_color"], + theme["control_disabled"]), + border=(theme["header_color"], + theme["control_color"], + theme["header_color"])) + self._get_custom_scrollbar(panel_type, + sbar_theme, + theme["scrollbar_trough"], + theme["scrollbar_border"]) self._config_settings_group_combobox(panel_type, theme) def _group_panel_infoheader(self, key, theme): @@ -1259,7 +1280,7 @@ def _config_settings_group_slider(self, key, theme): groovewidth=4, troughcolor=self._user_theme["group_panel"]["group_background"]) - def _config_settings_group_scrollbar(self, key, theme): + def _get_custom_scrollbar(self, key, theme, trough, border): """ Create a custom scroll bar widget so we can control the colors. Parameters @@ -1267,34 +1288,33 @@ def _config_settings_group_scrollbar(self, key, theme): key: str The section that the slider will belong to theme: dict - The user configuration theme options + The theme options for a scroll bar. The dict should contain the keys: `background`, + `foreground`, `border`, with each item containing a tuple of the colors for the states + `normal`, `disabled` and `active` respectively + trough: str + The hex code for the scrollbar trough color + border: str + The hex code for the scrollbar border color """ + logger.debug("Creating scrollbar: (key: %s, theme: %s, trough: %s, border: %s)", + key, theme, trough, border) images = dict() - backgrounds = dict(normal=theme["control_color"], - disabled=theme["control_disabled"], - active=theme["control_active"]) - foregrounds = dict(normal=theme["control_disabled"], - disabled=theme["control_color"], - active=theme["control_disabled"]) - borders = dict(normal=theme["header_color"], - disabled=theme["control_color"], - active=theme["header_color"]) - - for state in ("normal", "disabled", "active"): + for idx, state in enumerate(("normal", "disabled", "active")): # Create arrow and slider widgets for each state - img_args = ((16, 16), backgrounds[state]) + img_args = ((16, 16), theme["background"][idx]) for dir_ in ("up", "down"): images[f"img_{dir_}_{state}"] = self._images.get_image( *img_args, - foreground=foregrounds[state], + foreground=theme["foreground"][idx], pattern="arrow", direction=dir_, thickness=4, border_width=1, - border_color=borders[state]) - images[f"img_thumb_{state}"] = self._images.get_image(*img_args, - border_width=1, - border_color=borders[state]) + border_color=theme["border"][idx]) + images[f"img_thumb_{state}"] = self._images.get_image( + *img_args, + border_width=1, + border_color=theme["border"][idx]) for element in ("thumb", "uparrow", "downarrow"): # Create the elements with the new images @@ -1322,8 +1342,8 @@ def _config_settings_group_scrollbar(self, key, theme): ] })]) self._style.configure(f"{key}.Vertical.TScrollbar", - troughcolor=theme["scrollbar_trough"], - bordercolor=theme["scrollbar_border"], + troughcolor=trough, + bordercolor=border, troughrelief=tk.SOLID, troughborderwidth=1) From 79b89610b1d8ddb2eb4449c28b4cd70035a6860e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Claudio=20S=C3=A1nchez?= Date: Mon, 22 Mar 2021 18:26:39 +0000 Subject: [PATCH 427/981] Spanish translations for GUI tooltips. (#1134) * Spanish translations for GUI tooltips. * Added/updated spanish translations for preview and manual tooltips --- locales/es/LC_MESSAGES/gui.tooltips.mo | Bin 0 -> 5280 bytes locales/es/LC_MESSAGES/gui.tooltips.po | 230 ++++++++++++++++++++++++ locales/es/LC_MESSAGES/tools.manual.mo | Bin 2141 -> 7910 bytes locales/es/LC_MESSAGES/tools.manual.po | 105 +++++++---- locales/es/LC_MESSAGES/tools.preview.mo | Bin 1598 -> 2227 bytes locales/es/LC_MESSAGES/tools.preview.po | 26 +-- 6 files changed, 316 insertions(+), 45 deletions(-) create mode 100644 locales/es/LC_MESSAGES/gui.tooltips.mo create mode 100644 locales/es/LC_MESSAGES/gui.tooltips.po diff --git a/locales/es/LC_MESSAGES/gui.tooltips.mo b/locales/es/LC_MESSAGES/gui.tooltips.mo new file mode 100644 index 0000000000000000000000000000000000000000..1704aa60eae8db4e8e2ba7c6bcd12cc6db67776d GIT binary patch literal 5280 zcmb`K&yO5O6~~+409k&AkeCpl0tDN!XS}S&hC0K@{g5w%@5@Vs_m}XX}h~? zQq?`1aTE#3QV>GoL_}_24iF@Ki4RDifM8GDd;(5f!XW~QKLPQ1)!j3*>jjjE*7kn8 zyQ*Hj_kBO=&0p_7@M^?0&;1DZFW(bIOW;@D%RjEK9;m;c13$rg4?F~32Oj``3LXT1 z1>O(d0Urb30zU)ZcW)GZ3_JvW0bBq-1wIRY794^z;H#kQ{}b>z@J&$Ue)@e;q`)V@ z1@HDC@opirzm4m+{T-z|Zr3;6rc&9s-YpOQ7uML7D#wDE|2wcntgv z*ahDPh4+Ur{)^y~;N##k;8E}ukS(G&z{kKhK?A-8irjM_spX{L72aP1W!*nPL`M%} z%rbZgJOySAz79$}{0)2tyoaC=y`Bf5F8V$we)u8yN$_>>8{lt2;q5`3@FnnBQ0%$^ zo(5k7kAQyyzYGcq^1c9y9h;!=lJoCD@H?RJ_9O6X;B7Dg{{=n|#wa2C{{a;J{{mhD zA7E4AEdeD?Z-BTX!hO*Pxy2_E-|aQegYbd+Azy?e;ZtmuxRL8Hx1^cq#rES%c#^Bj z-QkvaBb}mI?)HMlV4u7b@*!3p1?RYNRp3(eeVAMPCOV6sB}RpB+!c6Kpj^U9dqHK0 zse3_*k@mW$c@Tc(5}xD|zeK9%Y_7B_wZEpUQv+wOnz&TGG&9jsUl&_OjXL_p)GHrr z`IZ^&R9rb{iZUCiB=v(#k4&Px@jkVM@}(|4^Q<>&J1zURDwWs6wAhN4Y|%@%DreM7 zFEhU^`RKddZnWf#E)9AVW}7kjw4D0bxvEl`M3>luddYay>Fw}&^1)^qdn=6$jfu9WN_LT@<2mv{7Gj9Md&K9~G9%e%_@$Sl zl|pZ3h;_nGc*I~HMc3$TrpwY4wW0%?7Nuu*4e2_|RBoY%*Fwz+l|`ZLm%?uF zm#CH-rj$159iE3et4udqh4N|>+AdX2t=r1QMlIRIM5|5@`pWn1wpe~%tQvZCu7m-R z?3)SI02Yi_=wo1KghGkQ#Dk`*ma;UyCc8S*bH?{oVYj={d8=_h6ZQr{7^vMTkH;@J zt3rsV9`uMMlVB>HP7BeliKXabm2Vm+?g?IP$+GO04tE>-1#kc(}e!=A#= zIARb?Z25{Y*AmQYnmB>fj#{q<1M5okA<~;9h}Imm6OiwMlD$i7DozA0g=teBX_G^uOQQc8fc|M z9ckVU`y;YG6tQ*K6V2S}WX1h;4C0}#uXSBv)geqDkhC18{?bB%uL8k@POjQ{q^`bctr zVDazO%FD^Yy1M4esqRF_qB3Z>_gr6*lIDhKBFCgr%guq$V|&_17lJau034T?B2LAI zupJ4_rX2ifeefGUna)5xEb2lBp@qIv(sG=lRlak3n9?y!35+gO@FC2MWiUM0`Grf; z1U7cb`>#`-*{U+_((5|06V)hLXW}v)LX7y#>*fWf^F|!|$t)tXXqX*PQAC|k#dvt3 zGEhhLHCJf@@|~MuY)<6SC&c0}@#@Y^=^lc4NDmg%A8{Z+xV+k_ZsB6soSM6HJ4v^w zofU~9O~cO3UK-mbEJ!Q0)^fpFBSeLA5R;`iARK$X(iv9QYM}F0BZDJ{xN4#YK@5f7 z!gkeKmEj;Y(8z<4T7Xl!Z=I$))NV^6csfj3+(f1tjb&>3PMPc40F;-e>xlPC2%Aj; zyxCW@!gAV2ou@q5=;F?=bK`=#d;QS*Ov1bsHyv5gvKdw;qh?B;*+I=xN=emm+P&F6E}Dt?)akLLgSv9g!sMYF!!kv8xS_ zx`lvGQV&+x zgr8T}NojGkE=o$X{7B+wNn3LRy&?(kO3~gkLNj)ZW{g3qC*7FdJ7HSibSR*3mjSl|%T1bZ)DjOxabZrn9!Ky&U$_bGnMNr!KD4U-k(S o8z;!2fzbMJPi$0o6Lm>JCyLLmU>)~sZI8EeiyAkQ{?bPO21i9f1ONa4 literal 0 HcmV?d00001 diff --git a/locales/es/LC_MESSAGES/gui.tooltips.po b/locales/es/LC_MESSAGES/gui.tooltips.po new file mode 100644 index 0000000000..2990b5d925 --- /dev/null +++ b/locales/es/LC_MESSAGES/gui.tooltips.po @@ -0,0 +1,230 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: faceswap.spanish\n" +"POT-Creation-Date: 2021-03-10 15:35-0000\n" +"PO-Revision-Date: 2021-03-16 18:40+0000\n" +"Language-Team: tokafondo\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 2.3\n" +"Last-Translator: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: es_ES\n" + +#: lib/gui/command.py:180 +msgid "Output command line options to the console" +msgstr "Devuelve las opciones de la línea de comandos a la consola" + +#: lib/gui/command.py:191 +msgid "Run the {} script" +msgstr "Ejecuta el script {}" + +#: lib/gui/display.py:69 +msgid "Summary statistics for each training session" +msgstr "Resumen de estadísticas para cada sesión de entrenamiento" + +#: lib/gui/display.py:111 +msgid "Preview updates every 5 seconds" +msgstr "Previsualiza actualizaciones cada 5 segundos" + +#: lib/gui/display.py:120 +msgid "Graph showing Loss vs Iterations" +msgstr "Gráfico mostrando Pérdida contra iteraciones" + +#: lib/gui/display.py:123 +msgid "Training preview. Updated on every save iteration" +msgstr "" +"Previsualización del entrenamiento. Actualizado en cada iteración de guardado" + +#: lib/gui/display_analysis.py:340 +msgid "Load/Refresh stats for the currently training session" +msgstr "Carga/Refresca estadísticas para la sesión actual de entrenamiento" + +#: lib/gui/display_analysis.py:342 +msgid "Clear currently displayed session stats" +msgstr "Borra las estadísticas mostradas de la sesión" + +#: lib/gui/display_analysis.py:344 +msgid "Save session stats to csv" +msgstr "Guarda las estadísticas de la sesión a un archivo csv" + +#: lib/gui/display_analysis.py:346 +msgid "Load saved session stats" +msgstr "Carga estadísticas de sesión ya guardadas" + +#: lib/gui/display_command.py:91 +msgid "Preview updates at every model save. Click to refresh now." +msgstr "" +"Previsualización de actualizaciones cada guardado de modelo. Pulsar para " +"actualizar ahora." + +#: lib/gui/display_command.py:258 +msgid "Graph updates at every model save. Click to refresh now." +msgstr "" +"Previsualización de gráficos cada guardado de modelo. Pulsar para actualizar " +"ahora." + +#: lib/gui/display_command.py:272 +msgid "Display the raw loss data" +msgstr "Muestra los datos de pérdida sin procesar" + +#: lib/gui/display_command.py:284 +msgid "Display the smoothed loss data" +msgstr "Muestra los datos de pérdida regularizados" + +#: lib/gui/display_command.py:291 +msgid "Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing." +msgstr "" +"Ajusta el nivel de regularización. 0 es sin regularización, 0.99 es máxima " +"regularización." + +#: lib/gui/display_command.py:321 +msgid "Set the number of iterations to display. 0 displays the full session." +msgstr "" +"Ajusta el número de iteraciones a mostrar. 0 muestra la sesión completa." + +#: lib/gui/display_page.py:235 +msgid "Save {}(s) to file" +msgstr "Grabar {} a un fichero" + +#: lib/gui/display_page.py:247 +msgid "Enable or disable {} display" +msgstr "Activar o desactivar la muestra de {}" + +#: lib/gui/menu.py:30 +msgid "faceswap.dev - Guides and Forum" +msgstr "faceswap.dev - Guías y foro" + +#: lib/gui/menu.py:31 +msgid "Patreon - Support this project" +msgstr "Patreon - Apoya este proyecto" + +#: lib/gui/menu.py:32 +msgid "Discord - The FaceSwap Discord server" +msgstr "Discord - El servidor de Discord de FaceSwap" + +#: lib/gui/menu.py:33 +msgid "Github - Our Source Code" +msgstr "Github - Nuestro código fuente" + +#: lib/gui/menu.py:524 +msgid "Configure {} settings..." +msgstr "Configurar los ajustes de {}..." + +#: lib/gui/menu.py:532 +msgid "Project" +msgstr "Proyecto" + +#: lib/gui/menu.py:532 +msgid "currently selected Task" +msgstr "tarea actualmente seleccionada" + +#: lib/gui/menu.py:534 +msgid "Reload {} from disk" +msgstr "Recargar {} del disco" + +#: lib/gui/menu.py:536 +msgid "Create a new {}..." +msgstr "Crear un nuevo {}..." + +#: lib/gui/menu.py:538 +msgid "Reset {} to default" +msgstr "Reiniciar {} a los ajustes por defecto" + +#: lib/gui/menu.py:540 +msgid "Save {}" +msgstr "Guardar {}" + +#: lib/gui/menu.py:542 +msgid "Save {} as..." +msgstr "Guardar {} como..." + +#: lib/gui/menu.py:546 +msgid " from a task or project file" +msgstr " de un archivo de tarea o proyecto" + +#: lib/gui/menu.py:547 +msgid "Load {}..." +msgstr "Cargar {}..." + +#: lib/gui/popup_configure.py:205 +msgid "Close without saving" +msgstr "Cerrar sin guardar" + +#: lib/gui/popup_configure.py:206 +msgid "Save this page's config" +msgstr "Guardar la configuración de esta página" + +#: lib/gui/popup_configure.py:207 +msgid "Reset this page's config to default values" +msgstr "Reiniciar la configuración de esta página a sus valores por defecto" + +#: lib/gui/popup_configure.py:209 +msgid "Save all settings for the currently selected config" +msgstr "Guardar todos los ajustes para la configuración seleccionada" + +#: lib/gui/popup_configure.py:212 +msgid "Reset all settings for the currently selected config to default values" +msgstr "" +"Reiniciar todos los ajustes de la configuración seleccionada a sus ajustes " +"por defecto" + +#: lib/gui/popup_session.py:188 +msgid "Display {}" +msgstr "Mostrar {}" + +#: lib/gui/popup_session.py:339 +msgid "Refresh graph" +msgstr "Resfrescar gráfico" + +#: lib/gui/popup_session.py:341 +msgid "Save display data to csv" +msgstr "Guardar datos de muestra a un archivo csv" + +#: lib/gui/popup_session.py:343 +msgid "Number of data points to sample for rolling average" +msgstr "Número de puntos de datos a muestrear para la media móvil" + +#: lib/gui/popup_session.py:345 +msgid "Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing" +msgstr "" +"Establezca la cantidad de regularización. 0 es sin regularización, 0,99 es " +"máxima regularización" + +#: lib/gui/popup_session.py:347 +msgid "" +"Flatten data points that fall more than 1 standard deviation from the mean " +"to the mean value." +msgstr "" +"Aplanar los puntos de datos que se alejan más de 1 desviación estándar de la " +"media al valor medio." + +#: lib/gui/popup_session.py:350 +msgid "Display rolling average of the data" +msgstr "Mostrar la media móvil de los datos" + +#: lib/gui/popup_session.py:352 +msgid "Smooth the data" +msgstr "Regularizar los datos" + +#: lib/gui/popup_session.py:354 +msgid "Display raw data" +msgstr "Mostrar los datos sin procesar" + +#: lib/gui/popup_session.py:356 +msgid "Display polynormal data trend" +msgstr "Mostrar la tendencia de los datos polinormales" + +#: lib/gui/popup_session.py:358 +msgid "Set the data to display" +msgstr "Ajustar los datos a mostrar" + +#: lib/gui/popup_session.py:360 +msgid "Change y-axis scale" +msgstr "Cambiar la escala del eje Y" diff --git a/locales/es/LC_MESSAGES/tools.manual.mo b/locales/es/LC_MESSAGES/tools.manual.mo index 50853fc1b3ffc5b9b996ce46cef558915714db5d..b00858d9d5dddad60c7270290566dc0a717c9ee3 100644 GIT binary patch literal 7910 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 delta 437 zcmXYs&r1S96vtoo!xBXi3?dIx5EZeqt`@cCCHg@>sFgZILAJP)iMAuV8zcmsiVod| zAfi*3E`bOCjn3VSIu!H|bnffcV?XnmnfKm2_AB3xUA)D6ZV52}lE4BH@C-Wf4)(wY z*aGuiM62)(90#wUjPrMZ$bbvoL_P2$9E6|XWz;m`OPqg@JcQ4C{dW(jjm;PeX8VY) zkT^isG5iT5MVtLZ_-F?U%KSUXe;h4-bOvHRVTS)Boy$%S$=mLtybBs~F1*%SkGyor z@#tbgQ?<0BW)w|j>5QJ8nNkt58XX<-MAbYJwk1Lvrd>WY%UoyNEiP@#`{;gYh1=XQ zJ#Hxr4V~2+W$tjfEDX-MBjm!s>?XCI}&bP66=VX=OFi>k>HG~*MUSdKEvWuvy;=#i+Z6X zE>Au0N&w^8eA^J3a3cQRsAleF4PkS}nPFOTLNg~UmNj$oT|%=>`JkqZ@gFiWd7jg` rZn}R!)BQx_zAhLB{BNI$U-Q1YC1qcy%ikBSCs6&UUoDoLL*LaWl+w3V delta 239 zcmdlixR0m)o)F7a1|Z-7Vi_Qg0b*_-o&&@nZ~}-+fcPX3`vI{XBLjmcknRQ2ApUhA ztp}vlm>3wGfpjR4W&zSOfwUV?ZXS?s1M;6TGcZH~=^UVB6p%g!q%(m^7!+ASBm+Y? zkWK~Cmw+@7F<7uMFaY&~*&xROxeV+;3^Wf6fabvg%Vt-`M8?T}%v-sPbPX*P3@xk- Wj3@K5tYbFOGupg| B, swap B -> A" msgstr "" "Intercambiar el modelo. En lugar de convertir A en B, convierte B en A" -#: ./tools/preview\preview.py:1303 +#: tools/preview\preview.py:1303 msgid "Save full config" -msgstr "" +msgstr "Guardar la configuración completa" -#: ./tools/preview\preview.py:1306 +#: tools/preview\preview.py:1306 msgid "Reset full config to default values" -msgstr "" +msgstr "Restablecer la configuración completa a los valores por defecto" -#: ./tools/preview\preview.py:1309 +#: tools/preview\preview.py:1309 msgid "Reset full config to saved values" -msgstr "" +msgstr "Restablecer la configuración completa a los valores guardados" -#: ./tools/preview\preview.py:1453 +#: tools/preview\preview.py:1453 msgid "Save {} config" -msgstr "" +msgstr "Guardar la configuración de {}" -#: ./tools/preview\preview.py:1456 +#: tools/preview\preview.py:1456 msgid "Reset {} config to default values" -msgstr "" +msgstr "Restablecer la configuración completa de {} a los valores por defecto" -#: ./tools/preview\preview.py:1459 +#: tools/preview\preview.py:1459 msgid "Reset {} config to saved values" -msgstr "" +msgstr "Restablecer la configuración completa de {} a los valores guardados" From 5f598ca8252e52ec304ad20e7a3ead690eab966e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 22 Mar 2021 18:41:55 +0000 Subject: [PATCH 428/981] Locales Update --- lib/gui/control_helper.py | 23 ++-- lib/gui/popup_configure.py | 2 +- locales/es/LC_MESSAGES/gui.tooltips.mo | Bin 5280 -> 5282 bytes locales/es/LC_MESSAGES/gui.tooltips.po | 144 ++++++++++++++--------- locales/es/LC_MESSAGES/tools.manual.mo | Bin 7910 -> 7912 bytes locales/es/LC_MESSAGES/tools.manual.po | 19 +-- locales/gui.tooltips.pot | 136 +++++++++++++-------- locales/tools.manual.pot | 13 +- tools/manual/frameviewer/editor/_base.py | 8 +- 9 files changed, 215 insertions(+), 130 deletions(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index c6b9274797..b2335c1f8f 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """ Helper functions and classes for GUI controls """ +import gettext import logging import re @@ -15,6 +16,10 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name +# LOCALES +_LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) +_ = _LANG.gettext + # We store Tooltips, ContextMenus and Commands globally when they are created # Because we need to add them back to newly cloned widgets (they are not easily accessible from # original config or are prone to getting destroyed when the original widget is destroyed) @@ -1226,15 +1231,15 @@ def __init__(self, opt_name, tk_var, control_frame, sysbrowser_dict, style): @property def helptext(self): """ Dict containing tooltip text for buttons """ - retval = dict(folder="Select a folder...", - load="Select a file...", - load2="Select a file...", - picture="Select a folder of images...", - video="Select a video...", - model="Select a model folder...", - multi_load="Select one or more files...", - context="Select a file or folder...", - save_as="Select a save location...") + retval = dict(folder=_("Select a folder..."), + load=_("Select a file..."), + load2=_("Select a file..."), + picture=_("Select a folder of images..."), + video=_("Select a video..."), + model=_("Select a model folder..."), + multi_load=_("Select one or more files..."), + context=_("Select a file or folder..."), + save_as=_("Select a save location...")) return retval @staticmethod diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 49709af042..74c3c454e8 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -535,7 +535,7 @@ def _create_links_page(self, key): if not links: return frame - header_lbl = ttk.Label(frame, text="Select a plugin to configure:") + header_lbl = ttk.Label(frame, text=_("Select a plugin to configure:")) header_lbl.pack(side=tk.TOP, fill=tk.X, padx=5, pady=(5, 10)) for link in sorted(links): lbl = ttk.Label(frame, diff --git a/locales/es/LC_MESSAGES/gui.tooltips.mo b/locales/es/LC_MESSAGES/gui.tooltips.mo index 1704aa60eae8db4e8e2ba7c6bcd12cc6db67776d..c1fdf1a8083c5e1bbe4bc09e40aff7e4721d900c 100644 GIT binary patch delta 482 zcmXZYy-Pw-7{~F)j1sS=_9DGlNmy{xtSm^4wrnV&p`szOE_ib&6nvS5KyV2HAxH?K zDWoB=#;~F2Ux@Y^n)(y^p6m|ia~=-&Ip_IZ>o@xSou#~H5oy^)CPbvmAu@tG&SC<4 zv4rDT#eTfS5I$iTzi|wQoFV~?;xz7K5Ua=)@_>`rLK8ooQvPea(bH~pj5{3EQT_OX z^cJ5>WEFK>#_Ych9HsukJ?wP1A9jc~>ND)a3mm`(?&32haL`j0nPQXjv?r_J3UwWI ze8(m1AO=;(QB9IYl}wJsi>IiPyTo;T#5AhgQ(&TczzeDYK5!GgZt_&o9ia~^&6xGI zdpr{H&qw16vEUq^J!uq*GuwG1TR1U_xqRGT)3jk%(#U2?M#hZ$&B8%qs}|7Ct&8DE M?M(|ct&S_(FEkQ8m;e9( delta 480 zcmXZYKTASk6vy#n35kC)(+n(2q7u_eDa?$flqiITiUz~DrAS}~UYiXBmxcy|oFW>+ zp&@dN3WDB3)LcVTFCqFq*&UwGJv`iV&-q;iE(5nWhDyR9@?;hn5fP(RWB~m*jZ4^z zIu2tEU3iTlyvHg0!XfOpiHu?tCoqjctRcUUI~>CY%;ASkDu0bvy4#KRag&P%st>=B z-r})~B+!owSopVzUg}RwV@FSWund~0Ptb{H*oRHr!beP^+ffl2=VjZ`-mHp?)C~;a zE6$;cLDex-o9v=W=77VE$EcD!$5m`$7Jo2<6ip3;i>BQ2kc`*QkD La=m4=UYLFXx+gvp diff --git a/locales/es/LC_MESSAGES/gui.tooltips.po b/locales/es/LC_MESSAGES/gui.tooltips.po index 2990b5d925..82b9c0c53e 100644 --- a/locales/es/LC_MESSAGES/gui.tooltips.po +++ b/locales/es/LC_MESSAGES/gui.tooltips.po @@ -5,199 +5,235 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-03-10 15:35-0000\n" -"PO-Revision-Date: 2021-03-16 18:40+0000\n" +"POT-Creation-Date: 2021-03-22 18:37+0000\n" +"PO-Revision-Date: 2021-03-22 18:39+0000\n" +"Last-Translator: \n" "Language-Team: tokafondo\n" +"Language: es_ES\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 2.3\n" -"Last-Translator: \n" +"X-Generator: Poedit 2.4.2\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -"Language: es_ES\n" -#: lib/gui/command.py:180 +#: lib/gui/command.py:184 msgid "Output command line options to the console" msgstr "Devuelve las opciones de la línea de comandos a la consola" -#: lib/gui/command.py:191 +#: lib/gui/command.py:195 msgid "Run the {} script" msgstr "Ejecuta el script {}" -#: lib/gui/display.py:69 +#: lib/gui/control_helper.py:1234 +msgid "Select a folder..." +msgstr "" + +#: lib/gui/control_helper.py:1235 lib/gui/control_helper.py:1236 +msgid "Select a file..." +msgstr "" + +#: lib/gui/control_helper.py:1237 +msgid "Select a folder of images..." +msgstr "" + +#: lib/gui/control_helper.py:1238 +msgid "Select a video..." +msgstr "" + +#: lib/gui/control_helper.py:1239 +msgid "Select a model folder..." +msgstr "" + +#: lib/gui/control_helper.py:1240 +msgid "Select one or more files..." +msgstr "" + +#: lib/gui/control_helper.py:1241 +msgid "Select a file or folder..." +msgstr "" + +#: lib/gui/control_helper.py:1242 +msgid "Select a save location..." +msgstr "" + +#: lib/gui/display.py:71 msgid "Summary statistics for each training session" msgstr "Resumen de estadísticas para cada sesión de entrenamiento" -#: lib/gui/display.py:111 +#: lib/gui/display.py:113 msgid "Preview updates every 5 seconds" msgstr "Previsualiza actualizaciones cada 5 segundos" -#: lib/gui/display.py:120 +#: lib/gui/display.py:122 msgid "Graph showing Loss vs Iterations" msgstr "Gráfico mostrando Pérdida contra iteraciones" -#: lib/gui/display.py:123 +#: lib/gui/display.py:125 msgid "Training preview. Updated on every save iteration" msgstr "" "Previsualización del entrenamiento. Actualizado en cada iteración de guardado" -#: lib/gui/display_analysis.py:340 +#: lib/gui/display_analysis.py:342 msgid "Load/Refresh stats for the currently training session" msgstr "Carga/Refresca estadísticas para la sesión actual de entrenamiento" -#: lib/gui/display_analysis.py:342 +#: lib/gui/display_analysis.py:344 msgid "Clear currently displayed session stats" msgstr "Borra las estadísticas mostradas de la sesión" -#: lib/gui/display_analysis.py:344 +#: lib/gui/display_analysis.py:346 msgid "Save session stats to csv" msgstr "Guarda las estadísticas de la sesión a un archivo csv" -#: lib/gui/display_analysis.py:346 +#: lib/gui/display_analysis.py:348 msgid "Load saved session stats" msgstr "Carga estadísticas de sesión ya guardadas" -#: lib/gui/display_command.py:91 +#: lib/gui/display_command.py:94 msgid "Preview updates at every model save. Click to refresh now." msgstr "" "Previsualización de actualizaciones cada guardado de modelo. Pulsar para " "actualizar ahora." -#: lib/gui/display_command.py:258 +#: lib/gui/display_command.py:261 msgid "Graph updates at every model save. Click to refresh now." msgstr "" "Previsualización de gráficos cada guardado de modelo. Pulsar para actualizar " "ahora." -#: lib/gui/display_command.py:272 +#: lib/gui/display_command.py:275 msgid "Display the raw loss data" msgstr "Muestra los datos de pérdida sin procesar" -#: lib/gui/display_command.py:284 +#: lib/gui/display_command.py:287 msgid "Display the smoothed loss data" msgstr "Muestra los datos de pérdida regularizados" -#: lib/gui/display_command.py:291 +#: lib/gui/display_command.py:294 msgid "Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing." msgstr "" "Ajusta el nivel de regularización. 0 es sin regularización, 0.99 es máxima " "regularización." -#: lib/gui/display_command.py:321 +#: lib/gui/display_command.py:324 msgid "Set the number of iterations to display. 0 displays the full session." msgstr "" "Ajusta el número de iteraciones a mostrar. 0 muestra la sesión completa." -#: lib/gui/display_page.py:235 +#: lib/gui/display_page.py:238 msgid "Save {}(s) to file" msgstr "Grabar {} a un fichero" -#: lib/gui/display_page.py:247 +#: lib/gui/display_page.py:250 msgid "Enable or disable {} display" msgstr "Activar o desactivar la muestra de {}" -#: lib/gui/menu.py:30 +#: lib/gui/menu.py:32 msgid "faceswap.dev - Guides and Forum" msgstr "faceswap.dev - Guías y foro" -#: lib/gui/menu.py:31 +#: lib/gui/menu.py:33 msgid "Patreon - Support this project" msgstr "Patreon - Apoya este proyecto" -#: lib/gui/menu.py:32 +#: lib/gui/menu.py:34 msgid "Discord - The FaceSwap Discord server" msgstr "Discord - El servidor de Discord de FaceSwap" -#: lib/gui/menu.py:33 +#: lib/gui/menu.py:35 msgid "Github - Our Source Code" msgstr "Github - Nuestro código fuente" -#: lib/gui/menu.py:524 +#: lib/gui/menu.py:527 msgid "Configure {} settings..." msgstr "Configurar los ajustes de {}..." -#: lib/gui/menu.py:532 +#: lib/gui/menu.py:535 msgid "Project" msgstr "Proyecto" -#: lib/gui/menu.py:532 +#: lib/gui/menu.py:535 msgid "currently selected Task" msgstr "tarea actualmente seleccionada" -#: lib/gui/menu.py:534 +#: lib/gui/menu.py:537 msgid "Reload {} from disk" msgstr "Recargar {} del disco" -#: lib/gui/menu.py:536 +#: lib/gui/menu.py:539 msgid "Create a new {}..." msgstr "Crear un nuevo {}..." -#: lib/gui/menu.py:538 +#: lib/gui/menu.py:541 msgid "Reset {} to default" msgstr "Reiniciar {} a los ajustes por defecto" -#: lib/gui/menu.py:540 +#: lib/gui/menu.py:543 msgid "Save {}" msgstr "Guardar {}" -#: lib/gui/menu.py:542 +#: lib/gui/menu.py:545 msgid "Save {} as..." msgstr "Guardar {} como..." -#: lib/gui/menu.py:546 +#: lib/gui/menu.py:549 msgid " from a task or project file" msgstr " de un archivo de tarea o proyecto" -#: lib/gui/menu.py:547 +#: lib/gui/menu.py:550 msgid "Load {}..." msgstr "Cargar {}..." -#: lib/gui/popup_configure.py:205 +#: lib/gui/popup_configure.py:209 msgid "Close without saving" msgstr "Cerrar sin guardar" -#: lib/gui/popup_configure.py:206 +#: lib/gui/popup_configure.py:210 msgid "Save this page's config" msgstr "Guardar la configuración de esta página" -#: lib/gui/popup_configure.py:207 +#: lib/gui/popup_configure.py:211 msgid "Reset this page's config to default values" msgstr "Reiniciar la configuración de esta página a sus valores por defecto" -#: lib/gui/popup_configure.py:209 +#: lib/gui/popup_configure.py:213 msgid "Save all settings for the currently selected config" msgstr "Guardar todos los ajustes para la configuración seleccionada" -#: lib/gui/popup_configure.py:212 +#: lib/gui/popup_configure.py:216 msgid "Reset all settings for the currently selected config to default values" msgstr "" "Reiniciar todos los ajustes de la configuración seleccionada a sus ajustes " "por defecto" -#: lib/gui/popup_session.py:188 +#: lib/gui/popup_configure.py:538 +msgid "Select a plugin to configure:" +msgstr "" + +#: lib/gui/popup_session.py:191 msgid "Display {}" msgstr "Mostrar {}" -#: lib/gui/popup_session.py:339 +#: lib/gui/popup_session.py:342 msgid "Refresh graph" msgstr "Resfrescar gráfico" -#: lib/gui/popup_session.py:341 +#: lib/gui/popup_session.py:344 msgid "Save display data to csv" msgstr "Guardar datos de muestra a un archivo csv" -#: lib/gui/popup_session.py:343 +#: lib/gui/popup_session.py:346 msgid "Number of data points to sample for rolling average" msgstr "Número de puntos de datos a muestrear para la media móvil" -#: lib/gui/popup_session.py:345 +#: lib/gui/popup_session.py:348 msgid "Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing" msgstr "" "Establezca la cantidad de regularización. 0 es sin regularización, 0,99 es " "máxima regularización" -#: lib/gui/popup_session.py:347 +#: lib/gui/popup_session.py:350 msgid "" "Flatten data points that fall more than 1 standard deviation from the mean " "to the mean value." @@ -205,26 +241,26 @@ msgstr "" "Aplanar los puntos de datos que se alejan más de 1 desviación estándar de la " "media al valor medio." -#: lib/gui/popup_session.py:350 +#: lib/gui/popup_session.py:353 msgid "Display rolling average of the data" msgstr "Mostrar la media móvil de los datos" -#: lib/gui/popup_session.py:352 +#: lib/gui/popup_session.py:355 msgid "Smooth the data" msgstr "Regularizar los datos" -#: lib/gui/popup_session.py:354 +#: lib/gui/popup_session.py:357 msgid "Display raw data" msgstr "Mostrar los datos sin procesar" -#: lib/gui/popup_session.py:356 +#: lib/gui/popup_session.py:359 msgid "Display polynormal data trend" msgstr "Mostrar la tendencia de los datos polinormales" -#: lib/gui/popup_session.py:358 +#: lib/gui/popup_session.py:361 msgid "Set the data to display" msgstr "Ajustar los datos a mostrar" -#: lib/gui/popup_session.py:360 +#: lib/gui/popup_session.py:363 msgid "Change y-axis scale" msgstr "Cambiar la escala del eje Y" diff --git a/locales/es/LC_MESSAGES/tools.manual.mo b/locales/es/LC_MESSAGES/tools.manual.mo index b00858d9d5dddad60c7270290566dc0a717c9ee3..6ea23eace64db40326eccf1da08e5088626a6aaa 100644 GIT binary patch delta 362 zcmWmAze_?<7{>AEUJ;VAu0L*O-b4sNQ5-}F0;vXZ?+%GUi9|T$Ah8yIgE&L~;Xm)iY=;AtQhBsxg$&mVCqGQ%%==2&fi=*rE6@M{ delta 360 zcmWmAKTCp96vy%3eMCqqAIl!iKSdBhrGua@fi*>|4oaj#g+l}p)C+LZCEOfa#3>L8 zo*~)&b tgI8=5f0Mky8q@G88lxYl=so=7zF60*Cfg5&0_M@RV\n" "Language-Team: LANGUAGE \n" @@ -15,199 +15,235 @@ msgstr "" "Generated-By: pygettext.py 1.5\n" -#: ./lib/gui/command.py:180 +#: ./lib/gui/command.py:184 msgid "Output command line options to the console" msgstr "" -#: ./lib/gui/command.py:191 +#: ./lib/gui/command.py:195 msgid "Run the {} script" msgstr "" -#: ./lib/gui/display.py:69 +#: ./lib/gui/control_helper.py:1234 +msgid "Select a folder..." +msgstr "" + +#: ./lib/gui/control_helper.py:1235 ./lib/gui/control_helper.py:1236 +msgid "Select a file..." +msgstr "" + +#: ./lib/gui/control_helper.py:1237 +msgid "Select a folder of images..." +msgstr "" + +#: ./lib/gui/control_helper.py:1238 +msgid "Select a video..." +msgstr "" + +#: ./lib/gui/control_helper.py:1239 +msgid "Select a model folder..." +msgstr "" + +#: ./lib/gui/control_helper.py:1240 +msgid "Select one or more files..." +msgstr "" + +#: ./lib/gui/control_helper.py:1241 +msgid "Select a file or folder..." +msgstr "" + +#: ./lib/gui/control_helper.py:1242 +msgid "Select a save location..." +msgstr "" + +#: ./lib/gui/display.py:71 msgid "Summary statistics for each training session" msgstr "" -#: ./lib/gui/display.py:111 +#: ./lib/gui/display.py:113 msgid "Preview updates every 5 seconds" msgstr "" -#: ./lib/gui/display.py:120 +#: ./lib/gui/display.py:122 msgid "Graph showing Loss vs Iterations" msgstr "" -#: ./lib/gui/display.py:123 +#: ./lib/gui/display.py:125 msgid "Training preview. Updated on every save iteration" msgstr "" -#: ./lib/gui/display_analysis.py:340 +#: ./lib/gui/display_analysis.py:342 msgid "Load/Refresh stats for the currently training session" msgstr "" -#: ./lib/gui/display_analysis.py:342 +#: ./lib/gui/display_analysis.py:344 msgid "Clear currently displayed session stats" msgstr "" -#: ./lib/gui/display_analysis.py:344 +#: ./lib/gui/display_analysis.py:346 msgid "Save session stats to csv" msgstr "" -#: ./lib/gui/display_analysis.py:346 +#: ./lib/gui/display_analysis.py:348 msgid "Load saved session stats" msgstr "" -#: ./lib/gui/display_command.py:91 +#: ./lib/gui/display_command.py:94 msgid "Preview updates at every model save. Click to refresh now." msgstr "" -#: ./lib/gui/display_command.py:258 +#: ./lib/gui/display_command.py:261 msgid "Graph updates at every model save. Click to refresh now." msgstr "" -#: ./lib/gui/display_command.py:272 +#: ./lib/gui/display_command.py:275 msgid "Display the raw loss data" msgstr "" -#: ./lib/gui/display_command.py:284 +#: ./lib/gui/display_command.py:287 msgid "Display the smoothed loss data" msgstr "" -#: ./lib/gui/display_command.py:291 +#: ./lib/gui/display_command.py:294 msgid "Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing." msgstr "" -#: ./lib/gui/display_command.py:321 +#: ./lib/gui/display_command.py:324 msgid "Set the number of iterations to display. 0 displays the full session." msgstr "" -#: ./lib/gui/display_page.py:235 +#: ./lib/gui/display_page.py:238 msgid "Save {}(s) to file" msgstr "" -#: ./lib/gui/display_page.py:247 +#: ./lib/gui/display_page.py:250 msgid "Enable or disable {} display" msgstr "" -#: ./lib/gui/menu.py:30 +#: ./lib/gui/menu.py:32 msgid "faceswap.dev - Guides and Forum" msgstr "" -#: ./lib/gui/menu.py:31 +#: ./lib/gui/menu.py:33 msgid "Patreon - Support this project" msgstr "" -#: ./lib/gui/menu.py:32 +#: ./lib/gui/menu.py:34 msgid "Discord - The FaceSwap Discord server" msgstr "" -#: ./lib/gui/menu.py:33 +#: ./lib/gui/menu.py:35 msgid "Github - Our Source Code" msgstr "" -#: ./lib/gui/menu.py:524 +#: ./lib/gui/menu.py:527 msgid "Configure {} settings..." msgstr "" -#: ./lib/gui/menu.py:532 +#: ./lib/gui/menu.py:535 msgid "Project" msgstr "" -#: ./lib/gui/menu.py:532 +#: ./lib/gui/menu.py:535 msgid "currently selected Task" msgstr "" -#: ./lib/gui/menu.py:534 +#: ./lib/gui/menu.py:537 msgid "Reload {} from disk" msgstr "" -#: ./lib/gui/menu.py:536 +#: ./lib/gui/menu.py:539 msgid "Create a new {}..." msgstr "" -#: ./lib/gui/menu.py:538 +#: ./lib/gui/menu.py:541 msgid "Reset {} to default" msgstr "" -#: ./lib/gui/menu.py:540 +#: ./lib/gui/menu.py:543 msgid "Save {}" msgstr "" -#: ./lib/gui/menu.py:542 +#: ./lib/gui/menu.py:545 msgid "Save {} as..." msgstr "" -#: ./lib/gui/menu.py:546 +#: ./lib/gui/menu.py:549 msgid " from a task or project file" msgstr "" -#: ./lib/gui/menu.py:547 +#: ./lib/gui/menu.py:550 msgid "Load {}..." msgstr "" -#: ./lib/gui/popup_configure.py:205 +#: ./lib/gui/popup_configure.py:209 msgid "Close without saving" msgstr "" -#: ./lib/gui/popup_configure.py:206 +#: ./lib/gui/popup_configure.py:210 msgid "Save this page's config" msgstr "" -#: ./lib/gui/popup_configure.py:207 +#: ./lib/gui/popup_configure.py:211 msgid "Reset this page's config to default values" msgstr "" -#: ./lib/gui/popup_configure.py:209 +#: ./lib/gui/popup_configure.py:213 msgid "Save all settings for the currently selected config" msgstr "" -#: ./lib/gui/popup_configure.py:212 +#: ./lib/gui/popup_configure.py:216 msgid "Reset all settings for the currently selected config to default values" msgstr "" -#: ./lib/gui/popup_session.py:188 +#: ./lib/gui/popup_configure.py:538 +msgid "Select a plugin to configure:" +msgstr "" + +#: ./lib/gui/popup_session.py:191 msgid "Display {}" msgstr "" -#: ./lib/gui/popup_session.py:339 +#: ./lib/gui/popup_session.py:342 msgid "Refresh graph" msgstr "" -#: ./lib/gui/popup_session.py:341 +#: ./lib/gui/popup_session.py:344 msgid "Save display data to csv" msgstr "" -#: ./lib/gui/popup_session.py:343 +#: ./lib/gui/popup_session.py:346 msgid "Number of data points to sample for rolling average" msgstr "" -#: ./lib/gui/popup_session.py:345 +#: ./lib/gui/popup_session.py:348 msgid "Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing" msgstr "" -#: ./lib/gui/popup_session.py:347 +#: ./lib/gui/popup_session.py:350 msgid "Flatten data points that fall more than 1 standard deviation from the mean to the mean value." msgstr "" -#: ./lib/gui/popup_session.py:350 +#: ./lib/gui/popup_session.py:353 msgid "Display rolling average of the data" msgstr "" -#: ./lib/gui/popup_session.py:352 +#: ./lib/gui/popup_session.py:355 msgid "Smooth the data" msgstr "" -#: ./lib/gui/popup_session.py:354 +#: ./lib/gui/popup_session.py:357 msgid "Display raw data" msgstr "" -#: ./lib/gui/popup_session.py:356 +#: ./lib/gui/popup_session.py:359 msgid "Display polynormal data trend" msgstr "" -#: ./lib/gui/popup_session.py:358 +#: ./lib/gui/popup_session.py:361 msgid "Set the data to display" msgstr "" -#: ./lib/gui/popup_session.py:360 +#: ./lib/gui/popup_session.py:363 msgid "Change y-axis scale" msgstr "" diff --git a/locales/tools.manual.pot b/locales/tools.manual.pot index 9067706231..04fca258db 100644 --- a/locales/tools.manual.pot +++ b/locales/tools.manual.pot @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-03-20 13:45+0000\n" +"POT-Creation-Date: 2021-03-22 18:31+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -55,6 +55,12 @@ msgstr "" msgid "Display the mask" msgstr "" +#: ./tools/manual/frameviewer\editor\_base.py:627 +#: ./tools/manual/frameviewer\editor\landmarks.py:44 +#: ./tools/manual/frameviewer\editor\mask.py:75 +msgid "Magnify/Demagnify the View" +msgstr "" + #: ./tools/manual/frameviewer\editor\bounding_box.py:33 #: ./tools/manual/frameviewer\editor\extract_box.py:32 msgid "Delete Face" @@ -103,11 +109,6 @@ msgid "" " - Draw a box to select multiple points to relocate." msgstr "" -#: ./tools/manual/frameviewer\editor\landmarks.py:44 -#: ./tools/manual/frameviewer\editor\mask.py:75 -msgid "Magnify/Demagnify the View" -msgstr "" - #: ./tools/manual/frameviewer\editor\mask.py:33 msgid "" "Mask Editor\n" diff --git a/tools/manual/frameviewer/editor/_base.py b/tools/manual/frameviewer/editor/_base.py index 0aa117453e..143ca971c7 100644 --- a/tools/manual/frameviewer/editor/_base.py +++ b/tools/manual/frameviewer/editor/_base.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """ Editor objects for the manual adjustments tool """ +import gettext import logging import tkinter as tk @@ -12,6 +13,10 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name +# LOCALES +_LANG = gettext.translation("tools.manual", localedir="locales", fallback=True) +_ = _LANG.gettext + class Editor(): """ Parent Class for Object Editors. @@ -619,5 +624,6 @@ def __init__(self, canvas, detected_faces): def _add_actions(self): """ Add the optional action buttons to the viewer. Current actions are Zoom. """ - self._add_action("magnify", "zoom", "Magnify/Demagnify the View", group=None, hotkey="M") + self._add_action("magnify", "zoom", _("Magnify/Demagnify the View"), + group=None, hotkey="M") self._actions["magnify"]["tk_var"].trace("w", lambda *e: self._globals.tk_update.set(True)) From bc9482aa70759149758308efd0cf6ee5682ccafa Mon Sep 17 00:00:00 2001 From: DenDen047 Date: Tue, 23 Mar 2021 03:46:05 +0900 Subject: [PATCH 429/981] apt -> apt-get (#1132) apt should be used on GUI systems. On CLI, apt-get is more suitable. --- Dockerfile.gpu | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile.gpu b/Dockerfile.gpu index b18c58d447..951e753920 100755 --- a/Dockerfile.gpu +++ b/Dockerfile.gpu @@ -2,13 +2,13 @@ FROM nvidia/cuda:10.1-cudnn7-devel-ubuntu16.04 #install python3.8 RUN apt-get update -RUN apt install software-properties-common -y +RUN apt-get install software-properties-common -y RUN add-apt-repository ppa:deadsnakes/ppa -y RUN apt-get update -RUN apt install python3.8 -y -RUN apt install python3.8-distutils -y -RUN apt install python3.8-tk -y -RUN apt install curl -y +RUN apt-get install python3.8 -y +RUN apt-get install python3.8-distutils -y +RUN apt-get install python3.8-tk -y +RUN apt-get install curl -y RUN curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py RUN python3.8 get-pip.py RUN rm get-pip.py From beb0c4337bf94e84a40882adec8ca696e72b5e1c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 23 Mar 2021 13:52:49 +0000 Subject: [PATCH 430/981] Update gitignore --- .gitignore | 84 +++++++++++++++++++++++------------------------------- 1 file changed, 36 insertions(+), 48 deletions(-) diff --git a/.gitignore b/.gitignore index 87caff89ef..1f62f3051b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,57 +1,45 @@ +# Global (Exclude all + retain files that are unlikely to pollute local installs) * -!setup.cfg -!*.css -!*.ico -!*.inf !*.keep !*.md -!*.mo -!*.nsi -!*.png -!*.po -!*.pot -!*.py -!*.rst -!*.sh -!*.txt -!.cache + +# Root files !Dockerfile* -!requirements* +!.pylintrc +!*requirements*.txt +!setup.cfg +!.travis.yml +!/faceswap.py +!/setup.py +!/tools.py +!/update_deps.py + +# Support files +!_travis/ +!_travis/*.py !.install/ -!.install/linux -!.install/windows -!docs -!docs/full -!docs/_static +!.install/** !config/ +!docs/ +!docs/full** +!docs/_static** !locales/ -!locales/* -!locales/*/LC_MESSAGES +!locales/** +!tests/ +!tests/**/ +!tests/**/*.py + +# Core files !lib/ -!lib/* -!lib/gui -!lib/gui/.cache/preview -!lib/gui/.cache/icons -!lib/gui/.cache/themes -!lib/gui/.cache/themes/*.json -!lib/model/* -!scripts +!lib/**/ +!lib/**/*.py +!lib/gui/**/icons/*.png +!lib/gui/**/themes/default.json !plugins/ -!plugins/* -!plugins/extract/* -!plugins/train/* -!plugins/convert/* -!.pylintrc -!tests -!tests/* -!tests/*/* -!tools -!tools/* -!tools/*/* -!tools/*/*/* -!_travis -!_travis/* -!.travis.yml -*.ini -*.pyc -__pycache__/ +!plugins/**/ +!plugins/**/*.py +!scripts/ +!scripts/*.py +!tools/ +!tools/**/ +!tools/**/*.py From 2bcb7d572ac8fdacceb56d5cbd5b05fad8c71e5e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 23 Mar 2021 16:50:37 +0000 Subject: [PATCH 431/981] tools.sort - Add sort by size - Centralize some of the image loading routines --- locales/es/LC_MESSAGES/tools.sort.cli.mo | Bin 8497 -> 9017 bytes locales/es/LC_MESSAGES/tools.sort.cli.po | 48 +++++++----- locales/tools.sort.cli.pot | 29 +++---- tools/sort/cli.py | 8 +- tools/sort/sort.py | 94 ++++++++++++++--------- 5 files changed, 108 insertions(+), 71 deletions(-) diff --git a/locales/es/LC_MESSAGES/tools.sort.cli.mo b/locales/es/LC_MESSAGES/tools.sort.cli.mo index 5d8f6556482895850b6e85e744a7d0f81e37a84c..a3a74d4caab6b1122bfcb6273910fd9ead22dda4 100644 GIT binary patch delta 817 zcma)(&1(}u7{=fAqt)0p5nF2mwr|sew5Gv+D3YVp79oP5;6;&fGf75fcXVc!&{D|3 zvrt&5=itGklpOrHl?6{81i?%H1g{=E`lL}o556@Bw%V48c)QgX;m&7T7vQw11Xp7yX5CqIqy1+(G{v z_yhI!B+(@F$%TRciz(WmaWp2m5I2enl0>F>5NXiGBG=o*#5b;(wZMj)YeO=m36+%?UvHc zbE9wlkQ<4`U^}qtU*TVSZQB3ku7&fJ+?s5NMEmNYeQ$p3RWQ4cu zt3kMU&x+V-;f<|wdXO*XYa(tYqA9B^-J_d#)9?A65)8ycjRewx16 zaS3}7iEPiXj9F@qgbmG~@G4w0j;(PXFGpDy2}&D^7?ofxILx{@A$XJP5&_8ABP=W< zv1xt>8LF&@B>TUlE@NS{Zp=@uLiDCn=1Ad?6^@@JlJR%`o+h+>BI@YE^Qdt$K5`ts fE&HIoiVW+dDQqQo2l~1^(~zMzzark6c^mu$EE4ul delta 355 zcmXxey-EW?6b0ZjV*H6FaZNDMCbL!=4dh2KEZ9g?1O>sAf(&Mph?^|#Za}aQtt^Br ztY|Cv04ga&EPRO|_yD%nR^ox^G#~feJ9p+~{b|*&o_!7i`Fj}&kf z0=}k!B@UjZfiUw&y2<)g0&F9s_dha5hgnZj?!=59WW=mMur1Oe+--`7@krUMskY@A zvT^8o-9+qO%oVbQoXjs9`O-p;C%mOv^|q>;*@~%}hV<+sRkK@FI}n;5FS6}=*@~lD quBANNF=TM9Eyn&Y8Pat3*LJ&a+HF+Zu^m}8YvzGx|2=r2KZ{>!t4GTK diff --git a/locales/es/LC_MESSAGES/tools.sort.cli.po b/locales/es/LC_MESSAGES/tools.sort.cli.po index 0b1c39ce67..765347e394 100644 --- a/locales/es/LC_MESSAGES/tools.sort.cli.po +++ b/locales/es/LC_MESSAGES/tools.sort.cli.po @@ -5,17 +5,17 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-02-18 23:02-0000\n" -"PO-Revision-Date: 2021-02-20 17:18+0000\n" +"POT-Creation-Date: 2021-03-23 15:33+0000\n" +"PO-Revision-Date: 2021-03-23 15:36+0000\n" +"Last-Translator: \n" "Language-Team: tokafondo\n" +"Language: es_ES\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 2.3\n" -"Last-Translator: \n" +"X-Generator: Poedit 2.4.2\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -"Language: es_ES\n" #: tools/sort/cli.py:14 msgid "This command lets you sort images using various methods." @@ -38,11 +38,11 @@ msgstr "Directorio de entrada de caras alineadas." msgid "Output directory for sorted aligned faces." msgstr "Directorio de salida para las caras alineadas ordenadas." -#: tools/sort/cli.py:49 tools/sort/cli.py:89 +#: tools/sort/cli.py:50 tools/sort/cli.py:93 msgid "sort settings" msgstr "ajustes de ordenación" -#: tools/sort/cli.py:51 +#: tools/sort/cli.py:52 msgid "" "R|Sort by method. Choose how images are sorted. \n" "L|'blur': Sort faces by blurriness.\n" @@ -67,7 +67,11 @@ msgid "" "L|'color-orange': Sort images by the average intensity of the converted Co " "color channel. Orange images will be ranked first and blue images will be " "last.\n" -"Default: hist" +"L|'size': Sort images by their size in the original frame. Faces closer to " +"the camera and from higher resolution sources will be sorted first, whilst " +"faces further from the camera and from lower resolution sources will be " +"sorted last.\n" +"Default: face" msgstr "" "R|Método de ordenación. Elige cómo se ordenan las imágenes. \n" "L|'blur': Ordena las caras por desenfoque.\n" @@ -92,14 +96,18 @@ msgstr "" "L|'color-orange': Ordena las imágenes por la intensidad media del canal de " "color Co. Las imágenes naranjas serán clasificadas primero y las azules " "serán las últimas.\n" -"Por defecto: hist" - -#: tools/sort/cli.py:78 tools/sort/cli.py:105 tools/sort/cli.py:117 -#: tools/sort/cli.py:128 +"L|'size': Ordena las imágenes por su tamaño en el marco original. Los " +"rostros más cercanos a la cámara y de fuentes de mayor resolución se " +"ordenarán primero, mientras que los rostros más alejados de la cámara y de " +"fuentes de menor resolución se ordenarán en último lugar.\n" +"Por defecto: face" + +#: tools/sort/cli.py:82 tools/sort/cli.py:109 tools/sort/cli.py:121 +#: tools/sort/cli.py:132 msgid "output" msgstr "salida" -#: tools/sort/cli.py:79 +#: tools/sort/cli.py:83 msgid "" "Keeps the original files in the input directory. Be careful when using this " "with rename grouping and no specified output directory as this would keep " @@ -110,7 +118,7 @@ msgstr "" "de salida, ya que esto mantendría los archivos originales y renombrados en " "el mismo directorio." -#: tools/sort/cli.py:91 +#: tools/sort/cli.py:95 msgid "" "Float value. Minimum threshold to use for grouping comparison with 'face-" "cnn' and 'hist' methods. The lower the value the more discriminating the " @@ -131,7 +139,7 @@ msgstr "" "podría resultar en la creación de muchos directorios. Por defecto: 'face-" "cnn' = 7.2, 'hist' = 0.3" -#: tools/sort/cli.py:106 +#: tools/sort/cli.py:110 msgid "" "R|Default: rename.\n" "L|'folders': files are sorted using the -s/--sort-by method, then they are " @@ -145,7 +153,7 @@ msgstr "" "L|'rename': los archivos se ordenan utilizando el método -s/--sort-by y " "luego se renombran." -#: tools/sort/cli.py:119 +#: tools/sort/cli.py:123 msgid "" "Group by method. When -fp/--final-processing by folders choose the how the " "images are grouped after sorting. Default: hist" @@ -153,7 +161,7 @@ msgstr "" "Método de agrupamiento. Elija la forma de agrupar las imágenes, en el caso " "de hacerlo por carpetas, después de la clasificación. Por defecto: hist" -#: tools/sort/cli.py:130 +#: tools/sort/cli.py:134 msgid "" "Integer value. Number of folders that will be used to group by blur and face-" "yaw. For blur folder 0 will be the least blurry, while the last folder will " @@ -174,11 +182,11 @@ msgstr "" "uniformemente en el número de carpetas, las imágenes restantes se colocan en " "la última carpeta. Valor por defecto: 5" -#: tools/sort/cli.py:141 tools/sort/cli.py:151 +#: tools/sort/cli.py:145 tools/sort/cli.py:155 msgid "settings" msgstr "ajustes" -#: tools/sort/cli.py:143 +#: tools/sort/cli.py:147 msgid "" "Logs file renaming changes if grouping by renaming, or it logs the file " "copying/movement if grouping by folders. If no log file is specified with " @@ -190,7 +198,7 @@ msgstr "" "se especifica ningún archivo de registro con '--log-file', se creará un " "archivo 'sort_log.json' en el directorio de entrada." -#: tools/sort/cli.py:154 +#: tools/sort/cli.py:158 msgid "" "Specify a log file to use for saving the renaming or grouping information. " "If specified extension isn't 'json' or 'yaml', then json will be used as the " diff --git a/locales/tools.sort.cli.pot b/locales/tools.sort.cli.pot index 1d4c27a6d7..e4ed8f0370 100644 --- a/locales/tools.sort.cli.pot +++ b/locales/tools.sort.cli.pot @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-02-18 23:02-0000\n" +"POT-Creation-Date: 2021-03-23 15:33+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -35,11 +35,11 @@ msgstr "" msgid "Output directory for sorted aligned faces." msgstr "" -#: tools/sort/cli.py:49 tools/sort/cli.py:89 +#: tools/sort/cli.py:50 tools/sort/cli.py:93 msgid "sort settings" msgstr "" -#: tools/sort/cli.py:51 +#: tools/sort/cli.py:52 msgid "" "R|Sort by method. Choose how images are sorted. \n" "L|'blur': Sort faces by blurriness.\n" @@ -53,46 +53,47 @@ msgid "" "L|'color-luma': Sort images by the average intensity of the converted Y color channel. Bright lighting and oversaturated images will be ranked first.\n" "L|'color-green': Sort images by the average intensity of the converted Cg color channel. Green images will be ranked first and red images will be last.\n" "L|'color-orange': Sort images by the average intensity of the converted Co color channel. Orange images will be ranked first and blue images will be last.\n" -"Default: hist" +"L|'size': Sort images by their size in the original frame. Faces closer to the camera and from higher resolution sources will be sorted first, whilst faces further from the camera and from lower resolution sources will be sorted last.\n" +"Default: face" msgstr "" -#: tools/sort/cli.py:78 tools/sort/cli.py:105 tools/sort/cli.py:117 -#: tools/sort/cli.py:128 +#: tools/sort/cli.py:82 tools/sort/cli.py:109 tools/sort/cli.py:121 +#: tools/sort/cli.py:132 msgid "output" msgstr "" -#: tools/sort/cli.py:79 +#: tools/sort/cli.py:83 msgid "Keeps the original files in the input directory. Be careful when using this with rename grouping and no specified output directory as this would keep the original and renamed files in the same directory." msgstr "" -#: tools/sort/cli.py:91 +#: tools/sort/cli.py:95 msgid "Float value. Minimum threshold to use for grouping comparison with 'face-cnn' and 'hist' methods. The lower the value the more discriminating the grouping is. Leaving -1.0 will allow the program set the default value automatically. For face-cnn 7.2 should be enough, with 4 being very discriminating. For hist 0.3 should be enough, with 0.2 being very discriminating. Be careful setting a value that's too low in a directory with many images, as this could result in a lot of directories being created. Defaults: face-cnn 7.2, hist 0.3" msgstr "" -#: tools/sort/cli.py:106 +#: tools/sort/cli.py:110 msgid "" "R|Default: rename.\n" "L|'folders': files are sorted using the -s/--sort-by method, then they are organized into folders using the -g/--group-by grouping method.\n" "L|'rename': files are sorted using the -s/--sort-by then they are renamed." msgstr "" -#: tools/sort/cli.py:119 +#: tools/sort/cli.py:123 msgid "Group by method. When -fp/--final-processing by folders choose the how the images are grouped after sorting. Default: hist" msgstr "" -#: tools/sort/cli.py:130 +#: tools/sort/cli.py:134 msgid "Integer value. Number of folders that will be used to group by blur and face-yaw. For blur folder 0 will be the least blurry, while the last folder will be the blurriest. For face-yaw the number of bins is by how much 180 degrees is divided. So if you use 18, then each folder will be a 10 degree increment. Folder 0 will contain faces looking the most to the left whereas the last folder will contain the faces looking the most to the right. If the number of images doesn't divide evenly into the number of bins, the remaining images get put in the last bin. Default value: 5" msgstr "" -#: tools/sort/cli.py:141 tools/sort/cli.py:151 +#: tools/sort/cli.py:145 tools/sort/cli.py:155 msgid "settings" msgstr "" -#: tools/sort/cli.py:143 +#: tools/sort/cli.py:147 msgid "Logs file renaming changes if grouping by renaming, or it logs the file copying/movement if grouping by folders. If no log file is specified with '--log-file', then a 'sort_log.json' file will be created in the input directory." msgstr "" -#: tools/sort/cli.py:154 +#: tools/sort/cli.py:158 msgid "Specify a log file to use for saving the renaming or grouping information. If specified extension isn't 'json' or 'yaml', then json will be used as the serializer, with the supplied filename. Default: sort_log.json" msgstr "" diff --git a/tools/sort/cli.py b/tools/sort/cli.py index f424c6e6fd..a984d58afe 100644 --- a/tools/sort/cli.py +++ b/tools/sort/cli.py @@ -44,7 +44,8 @@ def get_argument_list(): action=Radio, type=str, choices=("blur", "face", "face-cnn", "face-cnn-dissim", "face-yaw", "hist", - "hist-dissim", "color-gray", "color-luma", "color-green", "color-orange"), + "hist-dissim", "color-gray", "color-luma", "color-green", "color-orange", + "size"), dest='sort_method', group=_("sort settings"), default="face", @@ -69,7 +70,10 @@ def get_argument_list(): "\nL|'color-orange': Sort images by the average intensity of the converted Co " "color channel. Orange images will be ranked first and blue images will be " "last." - "\nDefault: hist"))) + "\nL|'size': Sort images by their size in the original frame. Faces closer to " + "the camera and from higher resolution sources will be sorted first, whilst " + "faces further from the camera and from lower resolution sources will be " + "sorted last.\nDefault: face"))) argument_list.append(dict( opts=('-k', '--keep'), action='store_true', diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 4d053144ef..5193f7286f 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -32,8 +32,7 @@ def __init__(self, arguments): self.changes = None self.serializer = None self._vgg_face = None - # TODO set this as FacesLoader in init. Need to move all processes to use it - self._loader = None + self._loader = FacesLoader(self._args.input_dir) def process(self): """ Main processing function of the sort tool """ @@ -110,7 +109,7 @@ def _get_landmarks(self): logger.info("Finding landmarks in images...") # TODO thread the put to queue so we don't have to put and get at the same time # Or even better, set up a proper background loader from disk (i.e. use lib.image.ImageIO) - for idx, feed in enumerate(tqdm(feed_list, desc="Aligning...", file=sys.stdout)): + for idx, feed in enumerate(tqdm(feed_list, desc="Aligning", file=sys.stdout)): extractor.input_queue.put(feed) landmarks[idx] = next(extractor.detected_faces()).detected_faces[0].landmarks_xy @@ -122,7 +121,7 @@ def _get_images(self): filename_list = self.find_images(self._args.input_dir) with futures.ThreadPoolExecutor() as executor: image_list = list(tqdm(executor.map(read_image, filename_list), - desc="Loading Images...", + desc="Loading Images", file=sys.stdout, total=len(filename_list))) @@ -155,24 +154,23 @@ def sort_process(self): def sort_blur(self): """ Sort by blur amount """ logger.info("Sorting by estimated image blur...") - filename_list, image_list = self._get_images() - - logger.info("Estimating blur...") - blurs = [self.estimate_blur(img) for img in image_list] + # TODO We have metadata here, so we can mask the face for blur estimate + blurs = [(filename, self.estimate_blur(image)) + for filename, image, _ in tqdm(self._loader.load(), + desc="Estimating blur", + total=self._loader.count, + leave=False)] logger.info("Sorting...") - matched_list = list(zip(filename_list, blurs)) - img_list = sorted(matched_list, key=operator.itemgetter(1), reverse=True) - return img_list + return sorted(blurs, key=lambda x: x[1], reverse=True) def sort_face(self): """ Sort by identity similarity """ logger.info("Sorting by identity similarity...") - self._loader = FacesLoader(self._args.input_dir) # TODO This should be set in init filenames = [] preds = [] for filename, image, metadata in tqdm(self._loader.load(), - desc="Classifying Faces...", + desc="Classifying Faces", total=self._loader.count, leave=False): if not metadata: @@ -204,7 +202,7 @@ def sort_face_cnn(self): logger.info("Comparing landmarks and sorting...") img_list_len = len(img_list) - for i in tqdm(range(0, img_list_len - 1), desc="Comparing...", file=sys.stdout): + for i in tqdm(range(0, img_list_len - 1), desc="Comparing", file=sys.stdout): min_score = float("inf") j_min_score = i + 1 for j in range(i + 1, img_list_len): @@ -226,7 +224,7 @@ def sort_face_cnn_dissim(self): logger.info("Comparing landmarks...") img_list_len = len(img_list) - for i in tqdm(range(0, img_list_len - 1), desc="Comparing...", file=sys.stdout): + for i in tqdm(range(0, img_list_len - 1), desc="Comparing", file=sys.stdout): score_total = 0 for j in range(i + 1, img_list_len): if i == j: @@ -243,11 +241,10 @@ def sort_face_cnn_dissim(self): def sort_face_yaw(self): """ Sort by estimated face yaw angle """ logger.info("Sorting by estimated face yaw angle..") - self._loader = FacesLoader(self._args.input_dir) # TODO This should be set in init filenames = [] yaws = [] for filename, image, metadata in tqdm(self._loader.load(), - desc="Classifying Faces...", + desc="Classifying Faces", total=self._loader.count, leave=False): if not metadata: @@ -272,20 +269,21 @@ def sort_face_yaw(self): def sort_hist(self): """ Sort by image histogram similarity """ logger.info("Sorting by histogram similarity...") - filename_list, image_list = self._get_images() - distance = cv2.HISTCMP_BHATTACHARYYA - logger.info("Calculating histograms...") - histograms = [cv2.calcHist([img], [0], None, [256], [0, 256]) for img in image_list] - img_list = list(zip(filename_list, histograms)) + # TODO We have metadata here, so we can mask the face for hist sorting + img_list = [(filename, cv2.calcHist([image], [0], None, [256], [0, 256])) + for filename, image, _ in tqdm(self._loader.load(), + desc="Calculating histograms", + total=self._loader.count, + leave=False)] logger.info("Comparing histograms and sorting...") img_list_len = len(img_list) - for i in tqdm(range(0, img_list_len - 1), desc="Comparing", file=sys.stdout): + for i in tqdm(range(0, img_list_len - 1), desc="Comparing histograms", file=sys.stdout): min_score = float("inf") j_min_score = i + 1 for j in range(i + 1, img_list_len): - score = cv2.compareHist(img_list[i][1], img_list[j][1], distance) + score = cv2.compareHist(img_list[i][1], img_list[j][1], cv2.HISTCMP_BHATTACHARYYA) if score < min_score: min_score = score j_min_score = j @@ -295,27 +293,27 @@ def sort_hist(self): def sort_hist_dissim(self): """ Sort by image histogram dissimilarity """ logger.info("Sorting by histogram dissimilarity...") - filename_list, image_list = self._get_images() - scores = np.zeros(len(filename_list), dtype='float32') - distance = cv2.HISTCMP_BHATTACHARYYA - logger.info("Calculating histograms...") - histograms = [cv2.calcHist([img], [0], None, [256], [0, 256]) for img in image_list] - img_list = list(list(items) for items in zip(filename_list, histograms, scores)) + # TODO We have metadata here, so we can mask the face for hist sorting + img_list = [[filename, cv2.calcHist([image], [0], None, [256], [0, 256]), 0.0] + for filename, image, _ in tqdm(self._loader.load(), + desc="Calculating histograms", + total=self._loader.count, + leave=False)] - logger.info("Comparing histograms...") img_list_len = len(img_list) - for i in tqdm(range(0, img_list_len), desc="Comparing", file=sys.stdout): + for i in tqdm(range(0, img_list_len), desc="Comparing histograms", file=sys.stdout): score_total = 0 for j in range(0, img_list_len): if i == j: continue - score_total += cv2.compareHist(img_list[i][1], img_list[j][1], distance) + score_total += cv2.compareHist(img_list[i][1], + img_list[j][1], + cv2.HISTCMP_BHATTACHARYYA) img_list[i][2] = score_total logger.info("Sorting...") - img_list = sorted(img_list, key=operator.itemgetter(2), reverse=True) - return img_list + return sorted(img_list, key=lambda x: x[2], reverse=True) def sort_color(self): """ Score by channel average intensity """ @@ -342,6 +340,32 @@ def sort_color(self): sorted_file_img_list = sorted(matched_list, key=operator.itemgetter(1), reverse=True) return sorted_file_img_list + def sort_size(self): + """ Sort the faces by largest face (in original frame) to smallest """ + logger.info("Sorting by original face size...") + img_list = [] + for filename, image, metadata in tqdm(self._loader.load(), + desc="Calculating face sizes", + total=self._loader.count, + leave=False): + if not metadata: + msg = ("The images to be sorted do not contain alignment data. Images must have " + "been generated by Faceswap's Extract process.\nIf you are sorting an " + "older faceset, then you should re-extract the faces from your source " + "alignments file to generate this data.") + raise FaceswapError(msg) + alignments = metadata["alignments"] + aligned_face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), + image=image, + centering="legacy", + is_aligned=True) + roi = aligned_face.original_roi + size = ((roi[1][0] - roi[0][0]) ** 2 + (roi[1][1] - roi[0][1]) ** 2) ** 0.5 + img_list.append((filename, size)) + + logger.info("Sorting...") + return sorted(img_list, key=lambda x: x[1], reverse=True) + # Methods for grouping def group_blur(self, img_list): """ Group into bins by blur """ From 616bd7c50021c45be22da6eeec4611a7e643d568 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 25 Mar 2021 19:29:29 +0000 Subject: [PATCH 432/981] GUI fixes - Split themes and styles to own module - Fix border on console window - variable name updates to make pep8 happy --- docs/full/lib/gui.rst | 10 + lib/gui/.cache/themes/default.json | 10 + lib/gui/__init__.py | 3 + lib/gui/command.py | 6 +- lib/gui/control_helper.py | 32 +- lib/gui/custom_widgets.py | 50 +-- lib/gui/display_analysis.py | 8 +- lib/gui/display_command.py | 28 +- lib/gui/display_graph.py | 2 +- lib/gui/display_page.py | 12 +- lib/gui/menu.py | 8 +- lib/gui/popup_configure.py | 10 +- lib/gui/popup_session.py | 10 +- lib/gui/project.py | 8 +- lib/gui/theme.py | 580 +++++++++++++++++++++++++++++ lib/gui/utils.py | 466 +---------------------- lib/gui/wrapper.py | 4 +- tools/manual/detected_faces.py | 2 +- tools/preview/preview.py | 4 +- 19 files changed, 712 insertions(+), 541 deletions(-) create mode 100644 lib/gui/theme.py diff --git a/docs/full/lib/gui.rst b/docs/full/lib/gui.rst index 900e3a844b..800a05c6f4 100755 --- a/docs/full/lib/gui.rst +++ b/docs/full/lib/gui.rst @@ -110,6 +110,16 @@ stats module :undoc-members: :show-inheritance: +theme module +============ + +.. rubric:: Module + +.. automodule:: lib.gui.theme + :members: + :undoc-members: + :show-inheritance: + utils module ============ diff --git a/lib/gui/.cache/themes/default.json b/lib/gui/.cache/themes/default.json index 67543a3810..1f41610268 100644 --- a/lib/gui/.cache/themes/default.json +++ b/lib/gui/.cache/themes/default.json @@ -73,11 +73,19 @@ "tree_select": "#9B1D20", "link_color": "#9B1D20" }, + "command_tabs": { + "frame_border": "#176087", + "tab_color": "#CDD3D5", + "tab_selected": "#75929C", + "tab_hover": "#176087" + }, "console": { "info": { "info1": "The colors of the console output box", "background_color": "The background color of the console output", + "border_color": "The color of the border around the console box and scrollbar", + "stdout_color": "The text color for standard print message output (non Faceswap Logging messages)", "stderr_color": "The text color for messages that are printed to sterr (non Faceswap Logging messages)", "info_color": "The text color for Faceswap INFO log messages", @@ -94,6 +102,8 @@ "scrollbar_border_": "The border color of the up/down buttons and the slider of the scrollbar, for active (pressed/hovered), normal and disabled (no scrollbar required)" }, "background_color": "#CDD3D5", + "border_color": "#176087", + "stdout_color": "#172c87", "stderr_color": "#78162f", "info_color": "#176087", diff --git a/lib/gui/__init__.py b/lib/gui/__init__.py index b66741baf0..22697f72e6 100644 --- a/lib/gui/__init__.py +++ b/lib/gui/__init__.py @@ -1,3 +1,6 @@ +#!/usr/bin python3 +""" The Faceswap GUI """ + from lib.gui.command import CommandNotebook from lib.gui.custom_widgets import ConsoleOut, StatusBar from lib.gui.display import DisplayNotebook diff --git a/lib/gui/command.py b/lib/gui/command.py index 546df5c6de..bac9e8c109 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -83,7 +83,7 @@ def change_action_button(self, *args): hlp = "Run the {} script".format(cmd.title()) logger.debug("Updated Action Button: '%s'", ttl) btnact.config(text=ttl, image=img) - Tooltip(btnact, text=hlp, wraplength=200) + Tooltip(btnact, text=hlp, wrap_length=200) def _set_modified_vars(self): """ Set the tkinter variable for each tab to indicate whether contents @@ -182,7 +182,7 @@ def add_action_button(self, category, actionbtns): btngen.pack(side=tk.LEFT, padx=5) Tooltip(btngen, text=_("Output command line options to the console"), - wraplength=200) + wrap_length=200) btnact = ttk.Button(actframe, image=get_images().icons["start"], @@ -193,7 +193,7 @@ def add_action_button(self, category, actionbtns): btnact.pack(side=tk.LEFT, fill=tk.X, expand=True) Tooltip(btnact, text=_("Run the {} script").format(self.title), - wraplength=200) + wrap_length=200) actionbtns[self.command] = btnact logger.debug("Added action buttons: '%s'", self.title) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index b2335c1f8f..619c721c05 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -26,14 +26,14 @@ _RECREATE_OBJECTS = dict(tooltips=dict(), commands=dict(), contextmenus=dict()) -def _get_tooltip(widget, text=None, text_variable=None, wraplength=600): +def _get_tooltip(widget, text=None, text_variable=None, wrap_length=600): """ Store the tooltip layout and widget id in _TOOLTIPS and return a tooltip """ _RECREATE_OBJECTS["tooltips"][str(widget)] = {"text": text, "text_variable": text_variable, - "wraplength": wraplength} - logger.debug("Adding to tooltips dict: (widget: %s. text: '%s', wraplength: %s)", - widget, text, wraplength) - return Tooltip(widget, text=text, text_variable=text_variable, wraplength=wraplength) + "wrap_length": wrap_length} + logger.debug("Adding to tooltips dict: (widget: %s. text: '%s', wrap_length: %s)", + widget, text, wrap_length) + return Tooltip(widget, text=text, text_variable=text_variable, wrap_length=wrap_length) def _get_contextmenu(widget): @@ -955,7 +955,7 @@ def build_control_label(self): style=f"{self._style}Group.TLabel") lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) if self.option.helptext is not None: - _get_tooltip(lbl, text=self.option.helptext, wraplength=600) + _get_tooltip(lbl, text=self.option.helptext, wrap_length=600) logger.debug("Built control label: (widget: '%s', title: '%s'", self.option.name, self.option.title) @@ -975,7 +975,7 @@ def build_one_control(self): if self.option.control != ttk.Checkbutton: ctl.pack(padx=5, pady=5, fill=tk.X, expand=True) if self.option.helptext is not None and not self.helpset: - tooltip_kwargs = dict(text=self.option.helptext, wraplength=600) + tooltip_kwargs = dict(text=self.option.helptext, wrap_length=600) if self.option.sysbrowser is not None: tooltip_kwargs["text_variable"] = self.option.tk_var _get_tooltip(ctl, **tooltip_kwargs) @@ -1019,7 +1019,7 @@ def _multi_option_control(self, option_type): self.helpset = True helptext = help_items[choice.lower()] helptext = "{}\n\n - {}".format(helptext, help_intro) - _get_tooltip(ctl, text=helptext, wraplength=600) + _get_tooltip(ctl, text=helptext, wrap_length=600) ctl.pack(anchor=tk.W, fill=tk.X) logger.debug("Added %s option %s", option_type, choice) return holder.parent @@ -1182,7 +1182,7 @@ def _color_control(self): lbl.pack(padx=2, pady=5, side=tk.RIGHT, anchor=tk.N) frame.pack(side=tk.LEFT, anchor=tk.W) if self.option.helptext is not None: - _get_tooltip(lbl, text=self.option.helptext, wraplength=600) + _get_tooltip(lbl, text=self.option.helptext, wrap_length=600) logger.debug("Added control to Options Frame: %s", self.option.name) return ctl @@ -1204,7 +1204,7 @@ def control_to_checkframe(self): text=self.option.title, name=self.option.name, style=f"{self._style}Group.TCheckbutton") - _get_tooltip(ctl, text=self.option.helptext, wraplength=600) + _get_tooltip(ctl, text=self.option.helptext, wrap_length=600) ctl.pack(side=tk.TOP, anchor=tk.W, fill=tk.X) logger.debug("Added control checkframe: '%s'", self.option.name) return ctl @@ -1285,7 +1285,7 @@ def add_browser_buttons(self): cursor="hand2") _add_command(fileopn.cget("command"), cmd) fileopn.pack(padx=1, side=tk.RIGHT) - _get_tooltip(fileopn, text=self.helptext[lbl], wraplength=600) + _get_tooltip(fileopn, text=self.helptext[lbl], wrap_length=600) logger.debug("Added browser buttons: (action: %s, filetypes: %s", action, self.filetypes) @@ -1305,7 +1305,7 @@ def ask_folder(filepath, filetypes=None): that will store the path to a directory. :param filetypes: Unused argument to allow filetypes to be given in ask_load(). """ - dirname = FileHandler("dir", filetypes).retfile + dirname = FileHandler("dir", filetypes).return_file if dirname: logger.debug(dirname) filepath.set(dirname) @@ -1313,7 +1313,7 @@ def ask_folder(filepath, filetypes=None): @staticmethod def ask_load(filepath, filetypes): """ Pop-up to get path to a file """ - filename = FileHandler("filename", filetypes).retfile + filename = FileHandler("filename", filetypes).return_file if filename: logger.debug(filename) filepath.set(filename) @@ -1321,7 +1321,7 @@ def ask_load(filepath, filetypes): @staticmethod def ask_multi_load(filepath, filetypes): """ Pop-up to get path to a file """ - filenames = FileHandler("filename_multi", filetypes).retfile + filenames = FileHandler("filename_multi", filetypes).return_file if filenames: final_names = " ".join("\"{}\"".format(fname) for fname in filenames) logger.debug(final_names) @@ -1330,7 +1330,7 @@ def ask_multi_load(filepath, filetypes): @staticmethod def ask_save(filepath, filetypes=None): """ Pop-up to get path to save a new file """ - filename = FileHandler("savefilename", filetypes).retfile + filename = FileHandler("save_filename", filetypes).return_file if filename: logger.debug(filename) filepath.set(filename) @@ -1349,7 +1349,7 @@ def ask_context(self, filepath, filetypes): filetypes, command=self.command, action=selected_action, - variable=selected_variable).retfile + variable=selected_variable).return_file if filename: logger.debug(filename) filepath.set(filename) diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index d4f087c5a0..01e19c09ed 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -123,7 +123,7 @@ class ConsoleOut(ttk.Frame): # pylint: disable=too-many-ancestors A Read only text box for displaying the output from stdout/stderr. All handling is internal to this method. To clear the console, the stored tkinter variable in - :attr:`~lib.gui.Config.tk_vars` ``consoleclear`` should be triggered. + :attr:`~lib.gui.Config.tk_vars` ``console_clear`` should be triggered. Parameters ---------- @@ -136,18 +136,18 @@ class ConsoleOut(ttk.Frame): # pylint: disable=too-many-ancestors def __init__(self, parent, debug): logger.debug("Initializing %s: (parent: %s, debug: %s)", self.__class__.__name__, parent, debug) - super().__init__(parent) - self.pack(side=tk.TOP, anchor=tk.W, padx=10, pady=(2, 0), - fill=tk.BOTH, expand=True) + super().__init__(parent, relief=tk.SOLID, padding=1, style="Console.TFrame") self._theme = get_config().user_theme["console"] - self._console = _ReadOnlyText(self) + self._console = _ReadOnlyText(self, relief=tk.FLAT) rc_menu = ContextMenu(self._console) rc_menu.cm_bind() - self._console_clear = get_config().tk_vars['consoleclear'] + self._console_clear = get_config().tk_vars['console_clear'] self._set_console_clear_var_trace() self._debug = debug self._build_console() self._add_tags() + self.pack(side=tk.TOP, anchor=tk.W, padx=10, pady=(2, 0), + fill=tk.BOTH, expand=True) logger.debug("Initialized %s", self.__class__.__name__) def _set_console_clear_var_trace(self): @@ -320,7 +320,7 @@ def __repr__(self): self.widget._w) # pylint:disable=protected-access def close(self): - "Unregister operations and revert redirection created by .__init__." + "de-register operations and revert redirection created by .__init__." for operation in list(self._operations): self.unregister(operation) widget = self.widget @@ -371,7 +371,7 @@ def dispatch(self, operation, *args): Note that if a registered function is called, the operation is not passed through to Tk. Apply the function returned by self.register() - to *args to accomplish that. For an example, see colorizer.py. + to *args to accomplish that. """ op_ = self._operations.get(operation) @@ -387,30 +387,30 @@ class _OriginalCommand: """Callable for original tk command that has been redirected. Returned by .register; can be used in the function registered. - redir = WidgetRedirector(text) + redirect = WidgetRedirector(text) def my_insert(*args): print("insert", args) original_insert(*args) - original_insert = redir.register("insert", my_insert) + original_insert = redirect.register("insert", my_insert) """ - def __init__(self, redir, operation): + def __init__(self, redirect, operation): """Create .tk_call and .orig_and_operation for .__call__ method. - .redir and .operation store the input args for __repr__. - .tk and .orig copy attributes of .redir (probably not needed). + .redirect and .operation store the input args for __repr__. + .tk and .orig copy attributes of .redirect (probably not needed). """ - self.redir = redir + self.redirect = redirect self.operation = operation - self.tk_ = redir.tk_ # redundant with self.redir - self.orig = redir.orig # redundant with self.redir + self.tk_ = redirect.tk_ # redundant with self.redirect + self.orig = redirect.orig # redundant with self.redirect # These two could be deleted after checking recipient code. - self.tk_call = redir.tk_.call - self.orig_and_operation = (redir.orig, operation) + self.tk_call = redirect.tk_.call + self.orig_and_operation = (redirect.orig, operation) def __repr__(self): return "%s(%r, %r)" % (self.__class__.__name__, - self.redir, self.operation) + self.redirect, self.operation) def __call__(self, *args): return self.tk_call(self.orig_and_operation + args) @@ -549,9 +549,9 @@ class Tooltip: # pylint:disable=too-few-public-methods text_variable: :class:`tkinter.strVar`, optional The text variable to use for dynamic help text. Appended after the contents of :attr:`text` if provided. Default: ``None`` - waittime: int, optional + wait_time: int, optional The time in milliseconds to wait before showing the tool-tip. Default: 400 - wraplength: int, optional + wrap_length: int, optional The text length for each line before wrapping. Default: 250 Example @@ -566,10 +566,10 @@ class Tooltip: # pylint:disable=too-few-public-methods http://www.daniweb.com/programming/software-development/code/484591/a-tooltip-class-for-tkinter """ def __init__(self, widget, *, pad=(5, 3, 5, 3), text="widget info", - text_variable=None, waittime=400, wraplength=250): + text_variable=None, wait_time=400, wrap_length=250): - self._waittime = waittime # in milliseconds, originally 500 - self._wraplength = wraplength # in pixels, originally 180 + self._waittime = wait_time # in milliseconds, originally 500 + self.wrap_length = wrap_length # in pixels, originally 180 self._widget = widget self._text = text self._text_variable = text_variable @@ -678,7 +678,7 @@ def tip_pos_calculator(widget, label, foreground=self._theme["font_color"], relief=tk.SOLID, borderwidth=0, - wraplength=self._wraplength) + wraplength=self.wrap_length) label.grid(padx=(pad[0], pad[2]), pady=(pad[1], pad[3]), diff --git a/lib/gui/display_analysis.py b/lib/gui/display_analysis.py index c3985240f4..e9f9ea5f19 100644 --- a/lib/gui/display_analysis.py +++ b/lib/gui/display_analysis.py @@ -240,7 +240,7 @@ def _load_session(self, full_path=None): """ logger.debug("Loading session") if full_path is None: - full_path = FileHandler("filename", "state").retfile + full_path = FileHandler("filename", "state").return_file if not full_path: return self._clear_session() @@ -276,7 +276,7 @@ def _save_session(self): logger.debug("No summary data loaded. Nothing to save") print("No summary data loaded. Nothing to save") return - savefile = FileHandler("save", "csv").retfile + savefile = FileHandler("save", "csv").return_file if not savefile: logger.debug("No save file. Returning") return @@ -322,7 +322,7 @@ def _add_buttons(self): command=cmd) btn.pack(padx=2, side=tk.RIGHT) hlp = self._set_help(btntype) - Tooltip(btn, text=hlp, wraplength=200) + Tooltip(btn, text=hlp, wrap_length=200) buttons[btntype] = btn logger.debug("buttons: %s", buttons) return buttons @@ -444,7 +444,7 @@ def _tree_configure(self, helptext): self._tree.configure(yscrollcommand=self._scrollbar.set) self._tree.tag_configure("total", background="black", foreground="white") self._tree.bind("", self._select_item) - Tooltip(self._tree, text=helptext, wraplength=200) + Tooltip(self._tree, text=helptext, wrap_length=200) return self._tree_columns() def _tree_columns(self): diff --git a/lib/gui/display_command.py b/lib/gui/display_command.py index 36304a1a76..6fc6dae286 100644 --- a/lib/gui/display_command.py +++ b/lib/gui/display_command.py @@ -48,7 +48,7 @@ def add_child(self): preview = self.subnotebook_add_page(self.tabname, widget=None) lblpreview = ttk.Label(preview, image=get_images().previewoutput[1]) lblpreview.pack(side=tk.TOP, anchor=tk.NW) - Tooltip(lblpreview, text=self.helptext, wraplength=200) + Tooltip(lblpreview, text=self.helptext, wrap_length=200) def update_child(self): """ Update the preview image on the label """ @@ -58,7 +58,7 @@ def update_child(self): def save_items(self): """ Open save dialogue and save preview """ - location = FileHandler("dir", None).retfile + location = FileHandler("dir", None).return_file if not location: return filename = "extract_convert_preview" @@ -92,7 +92,7 @@ def add_option_refresh(self): btnrefresh.pack(padx=2, side=tk.RIGHT) Tooltip(btnrefresh, text=_("Preview updates at every model save. Click to refresh now."), - wraplength=200) + wrap_length=200) logger.debug("Added refresh option") def display_item_set(self): @@ -126,7 +126,7 @@ def add_child(self, name): logger.debug("Adding child") preview = PreviewTrainCanvas(self.subnotebook, name) preview = self.subnotebook_add_page(name, widget=preview) - Tooltip(preview, text=self.helptext, wraplength=200) + Tooltip(preview, text=self.helptext, wrap_length=200) self.vars["modified"].set(get_images().previewtrain[name][2]) def update_child(self, tab_id, name): @@ -139,7 +139,7 @@ def update_child(self, tab_id, name): def save_items(self): """ Open save dialogue and save preview """ - location = FileHandler("dir", None).retfile + location = FileHandler("dir", None).return_file if not location: return for preview in self.subnotebook.children.values(): @@ -195,9 +195,9 @@ def save_preview(self, location): class GraphDisplay(DisplayOptionalPage): # pylint: disable=too-many-ancestors """ The Graph Tab of the Display section """ - def __init__(self, parent, tab_name, helptext, waittime, command=None): + def __init__(self, parent, tab_name, helptext, wait_time, command=None): self._trace_vars = dict() - super().__init__(parent, tab_name, helptext, waittime, command) + super().__init__(parent, tab_name, helptext, wait_time, command) def set_vars(self): """ Add graphing specific variables to the default variables. @@ -259,7 +259,7 @@ def _add_option_refresh(self): btnrefresh.pack(padx=2, side=tk.RIGHT) Tooltip(btnrefresh, text=_("Graph updates at every model save. Click to refresh now."), - wraplength=200) + wrap_length=200) logger.debug("Added refresh option") def _add_option_raw(self): @@ -272,7 +272,7 @@ def _add_option_raw(self): text="Raw", command=lambda v=tk_var: self._display_data_callback("raw", v)) chkbtn.pack(side=tk.RIGHT, padx=5, anchor=tk.W) - Tooltip(chkbtn, text=_("Display the raw loss data"), wraplength=200) + Tooltip(chkbtn, text=_("Display the raw loss data"), wrap_length=200) def _add_option_smoothed(self): """ Add check-button to hide/display smoothed data """ @@ -284,7 +284,7 @@ def _add_option_smoothed(self): text="Smoothed", command=lambda v=tk_var: self._display_data_callback("smoothed", v)) chkbtn.pack(side=tk.RIGHT, padx=5, anchor=tk.W) - Tooltip(chkbtn, text=_("Display the smoothed loss data"), wraplength=200) + Tooltip(chkbtn, text=_("Display the smoothed loss data"), wrap_length=200) def _add_option_smoothing(self): """ Add a slider to adjust the smoothing amount """ @@ -313,7 +313,7 @@ def _add_option_smoothing(self): for item in (tbox, ctl): Tooltip(item, text=hlp, - wraplength=200) + wrap_length=200) logger.debug("Added Smoothing Slider") def _add_option_iterations(self): @@ -343,7 +343,7 @@ def _add_option_iterations(self): for item in (tbox, ctl): Tooltip(item, text=hlp, - wraplength=200) + wrap_length=200) logger.debug("Added Iterations Slider") def display_item_set(self): @@ -435,11 +435,11 @@ def add_child(self, name, data): graph = TrainingGraph(self.subnotebook, data, "Loss") graph.build() graph = self.subnotebook_add_page(name, widget=graph) - Tooltip(graph, text=self.helptext, wraplength=200) + Tooltip(graph, text=self.helptext, wrap_length=200) def save_items(self): """ Open save dialogue and save graphs """ - graphlocation = FileHandler("dir", None).retfile + graphlocation = FileHandler("dir", None).return_file if not graphlocation: return for graph in self.subnotebook.children.values(): diff --git a/lib/gui/display_graph.py b/lib/gui/display_graph.py index efa67a083f..bfca7979ac 100755 --- a/lib/gui/display_graph.py +++ b/lib/gui/display_graph.py @@ -67,7 +67,7 @@ def _init_toolbar(self): button = self._Button(btnframe, text=text, file=image_file, command=getattr(self, callback)) if tooltip_text is not None: - Tooltip(button, text=tooltip_text, wraplength=200) + Tooltip(button, text=tooltip_text, wrap_length=200) self.message = tk.StringVar(master=self) self._message_label = ttk.Label(master=self, textvariable=self.message) diff --git a/lib/gui/display_page.py b/lib/gui/display_page.py index 354b07f21d..a1e0c96809 100644 --- a/lib/gui/display_page.py +++ b/lib/gui/display_page.py @@ -167,12 +167,12 @@ def subnotebook_page_from_id(self, tab_id): class DisplayOptionalPage(DisplayPage): # pylint: disable=too-many-ancestors """ Parent Context Sensitive Display Tab """ - def __init__(self, parent, tab_name, helptext, waittime, command=None): - logger.debug("%s: OptionalPage args: (waittime: %s, command: %s)", - self.__class__.__name__, waittime, command) + def __init__(self, parent, tab_name, helptext, wait_time, command=None): + logger.debug("%s: OptionalPage args: (wait_time: %s, command: %s)", + self.__class__.__name__, wait_time, command) DisplayPage.__init__(self, parent, tab_name, helptext) - self._waittime = waittime + self._waittime = wait_time self.command = command self.display_item = None @@ -236,7 +236,7 @@ def add_option_save(self): btnsave.pack(padx=2, side=tk.RIGHT) Tooltip(btnsave, text=_("Save {}(s) to file").format(self.tabname), - wraplength=200) + wrap_length=200) def add_option_enable(self): """ Add check-button to enable/disable page """ @@ -248,7 +248,7 @@ def add_option_enable(self): chkenable.pack(side=tk.RIGHT, padx=5, anchor=tk.W) Tooltip(chkenable, text=_("Enable or disable {} display").format(self.tabname), - wraplength=200) + wrap_length=200) def save_items(self): """ Save items. Override for display specific saving """ diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 16f431d8a2..f5f6aa16f1 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -347,7 +347,7 @@ def in_thread(self, action): @staticmethod def clear_console(): """ Clear the console window """ - get_config().tk_vars["consoleclear"].set(True) + get_config().tk_vars["console_clear"].set(True) def output_sysinfo(self): """ Output system information to console """ @@ -475,7 +475,7 @@ def _project_btns(self): command=lambda fn=cmd, kw=kwargs: fn(**kw)) btn.pack(side=tk.LEFT, anchor=tk.W) hlp = self.set_help(btntype) - Tooltip(btn, text=hlp, wraplength=200) + Tooltip(btn, text=hlp, wrap_length=200) def _task_btns(self): frame = ttk.Frame(self._btn_frame) @@ -495,7 +495,7 @@ def _task_btns(self): command=lambda fn=cmd, kw=kwargs: fn(**kw)) btn.pack(side=tk.LEFT, anchor=tk.W) hlp = self.set_help(btntype) - Tooltip(btn, text=hlp, wraplength=200) + Tooltip(btn, text=hlp, wrap_length=200) @staticmethod def _loader_and_kwargs(btntype): @@ -525,7 +525,7 @@ def _settings_btns(self): command=lambda n=name: open_popup(name=n)) btn.pack(side=tk.LEFT, anchor=tk.W) hlp = _("Configure {} settings...").format(name.title()) - Tooltip(btn, text=hlp, wraplength=200) + Tooltip(btn, text=hlp, wrap_length=200) @staticmethod def set_help(btntype): diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 74c3c454e8..9d695fdfcb 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -206,15 +206,15 @@ def _build_footer(self): width=10, command=lambda: self._opts_frame.reset(page_only=True)) - Tooltip(btn_cls, text=_("Close without saving"), wraplength=720) - Tooltip(btn_save, text=_("Save this page's config"), wraplength=720) - Tooltip(btn_rst, text=_("Reset this page's config to default values"), wraplength=720) + Tooltip(btn_cls, text=_("Close without saving"), wrap_length=720) + Tooltip(btn_save, text=_("Save this page's config"), wrap_length=720) + Tooltip(btn_rst, text=_("Reset this page's config to default values"), wrap_length=720) Tooltip(btn_saveall, text=_("Save all settings for the currently selected config"), - wraplength=720) + wrap_length=720) Tooltip(btn_rstall, text=_("Reset all settings for the currently selected config to default values"), - wraplength=720) + wrap_length=720) btn_cls.pack(padx=2, side=tk.RIGHT) btn_save.pack(padx=2, side=tk.RIGHT) diff --git a/lib/gui/popup_session.py b/lib/gui/popup_session.py index 81af9ee498..70c61b149e 100644 --- a/lib/gui/popup_session.py +++ b/lib/gui/popup_session.py @@ -134,7 +134,7 @@ def _opts_combobox(self, frame): self._vars[item.lower().strip()] = var hlp = self._set_help(item) - Tooltip(cmbframe, text=hlp, wraplength=200) + Tooltip(cmbframe, text=hlp, wrap_length=200) cmb.pack(fill=tk.X, side=tk.RIGHT) lblcmb.pack(padx=(0, 2), side=tk.LEFT) @@ -166,7 +166,7 @@ def _opts_checkbuttons(self, frame): ctl = ttk.Checkbutton(frame, variable=var, text=text) hlp = self._set_help(item) - Tooltip(ctl, text=hlp, wraplength=200) + Tooltip(ctl, text=hlp, wrap_length=200) ctl.pack(side=tk.TOP, padx=5, pady=5, anchor=tk.W) logger.debug("Built Check Buttons") @@ -203,7 +203,7 @@ def _opts_loss_keys(self, frame): section_added = True ctl = ttk.Checkbutton(frame, variable=var, text=text) - Tooltip(ctl, text=helptext, wraplength=200) + Tooltip(ctl, text=helptext, wrap_length=200) ctl.pack(side=tk.TOP, padx=5, pady=5, anchor=tk.W) self._vars["loss_keys"] = lk_vars @@ -264,7 +264,7 @@ def _opts_buttons(self, frame): image=get_images().icons[btntype], command=cmd) hlp = self._set_help(btntype) - Tooltip(btn, text=hlp, wraplength=200) + Tooltip(btn, text=hlp, wrap_length=200) btn.pack(padx=2, side=tk.RIGHT) lblstatus.pack(side=tk.LEFT, anchor=tk.W, fill=tk.X, expand=True) @@ -289,7 +289,7 @@ def _add_section(frame, title): def _option_button_save(self): """ Action for save button press. """ logger.debug("Saving File") - savefile = FileHandler("save", "csv").retfile + savefile = FileHandler("save", "csv").return_file if not savefile: logger.debug("Save Cancelled") return diff --git a/lib/gui/project.py b/lib/gui/project.py index 20ba01c2f8..0979b64745 100644 --- a/lib/gui/project.py +++ b/lib/gui/project.py @@ -148,7 +148,7 @@ def _set_filename(self, filename=None, sess_type="project"): if filename is None: logger.debug("Popping file handler") - cfgfile = self._file_handler("open", handler).retfile + cfgfile = self._file_handler("open", handler).return_file if not cfgfile: logger.debug("No filename given") return False @@ -209,7 +209,7 @@ def _get_options_for_command(self, command): opts = self._options.get(command, None) retval = {command: opts} if not opts: - self._config.tk_vars["consoleclear"].set(True) + self._config.tk_vars["console_clear"].set(True) logger.info("No %s section found in file", command) retval = None logger.debug(retval) @@ -385,7 +385,7 @@ def _save_as_to_filename(self, session_type): cfgfile = self._file_handler("save", "config_{}".format(session_type), title=title, - initial_folder=self._dirname).retfile + initial_folder=self._dirname).return_file if not cfgfile: logger.debug("No filename provided. session_type: '%s'", session_type) return False @@ -851,7 +851,7 @@ def new(self, *args): # pylint:disable=unused-argument cfgfile = self._file_handler("save", "config_project", title="New Project...", - initial_folder=self._basename).retfile + initial_folder=self._basename).return_file if not cfgfile: logger.debug("No filename selected") return diff --git a/lib/gui/theme.py b/lib/gui/theme.py new file mode 100644 index 0000000000..2e29abbe62 --- /dev/null +++ b/lib/gui/theme.py @@ -0,0 +1,580 @@ +#!/usr/bin/env python3 +""" functions for implementing themes in Faceswap's GUI """ +import logging +import os +import tkinter as tk +from tkinter import ttk + +import numpy as np + +from lib.serializer import get_serializer +from lib.utils import FaceswapError + + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class Style(): # pylint:disable=too-few-public-methods + """ Set the overarching theme and customize widgets. + + Parameters + ---------- + default_font: tuple + The name and size of the default font + root: :class:`tkinter.Tk` + The root tkinter object + path_cache: str + The path to the GUI's cache + """ + def __init__(self, default_font, root, path_cache): + self._root = root + self._font = default_font + default = os.path.join(path_cache, "themes", "default.json") + self._user_theme = get_serializer("json").load(default) + self._style = ttk.Style() + self._widgets = _Widgets(self._style) + self._set_styles() + + @property + def user_theme(self): + """ dict: The currently selected user theme. """ + return self._user_theme + + def _set_styles(self): + """ Configure widget theme and styles """ + self._config_settings_group() + # Command page + theme = self._user_theme["command_tabs"] + self._widgets.notebook("CPanel", + theme["frame_border"], + theme["tab_color"], + theme["tab_selected"], + theme["tab_hover"]) + + # Settings Popup + self._style.configure("SPanel.Header1.TLabel", + font=(self._font[0], self._font[1] + 4, "bold")) + self._style.configure("SPanel.Header2.TLabel", + font=(self._font[0], self._font[1] + 2, "bold")) + # Console + theme = self._user_theme["console"] + console_sbar = tuple(tuple(theme[f"scrollbar_{area}_{state}"] + for state in ("normal", "disabled", "active")) + for area in ("background", "foreground", "border")) + self._widgets.scrollbar("Console", + theme["scrollbar_trough"], + theme["scrollbar_border"], + *console_sbar) + self._widgets.frame("Console", + theme["background_color"], + theme["border_color"], + borderwidth=1) + + def _config_settings_group(self): + """ Configures the style of the control panel entry boxes. Used for inputting Faceswap + options or controlling plugin settings. """ + theme = self._user_theme["group_panel"] + for panel_type in ("CPanel", "SPanel"): + if panel_type == "SPanel": # Merge in Settings Panel overrides + theme = {**theme, **self._user_theme["group_settings"]} + self._style.configure(f"{panel_type}.Holder.TFrame", + background=theme["panel_background"]) + # Header Colors on option/group controls + self._style.configure(f"{panel_type}.Group.TLabelframe.Label", + foreground=theme["header_color"]) + self._style.configure(f"{panel_type}.Groupheader.TLabel", + background=theme["header_color"], + foreground=theme["header_font"], + font=(self._font[0], self._font[1], "bold")) + # Widgets and specific areas + self._group_panel_widgets(panel_type, theme) + self._group_panel_infoheader(panel_type, theme) + self._widgets.slider(panel_type, + theme["control_color"], + theme["control_active"], + self._user_theme["group_panel"]["group_background"]) + backgrounds = (theme["control_color"], + theme["control_disabled"], + theme["control_active"]) + foregrounds = (theme["control_disabled"], + theme["control_color"], + theme["control_disabled"]) + borders = (theme["header_color"], theme["control_color"], theme["header_color"]) + self._widgets.scrollbar(panel_type, + theme["scrollbar_trough"], + theme["scrollbar_border"], + backgrounds, + foregrounds, + borders) + self._widgets.combobox(panel_type, + theme["control_color"], + theme["control_active"], + theme["control_disabled"], + theme["header_color"], + theme["group_background"], + theme["group_font"]) + + def _group_panel_infoheader(self, key, theme): + """ Set the theme for the information header box that appears at the top of each group + panel + + Parameters + ---------- + key: str + The section that the slider will belong to + theme: dict + The user configuration theme options + """ + self._widgets.frame(f"{key}.InfoHeader", + theme["info_color"], + theme["info_border"], + borderwidth=1) + + self._style.configure(f"{key}.InfoHeader.TLabel", + background=theme["info_color"], + foreground=theme["info_font"], + font=(self._font[0], self._font[1], "bold")) + self._style.configure(f"{key}.InfoBody.TLabel", + background=theme["info_color"], + foreground=theme["info_font"]) + + def _group_panel_widgets(self, key, theme): + """ Configure the foreground and background colors of common widgets. + + Parameters + ---------- + key: str + The section that the slider will belong to + theme: dict + The user configuration theme options + """ + # Put a border on a group's sub-frame + self._widgets.frame(f"{key}.Subframe.Group", + theme["group_background"], + theme["group_border"], + borderwidth=1) + + # Background and Foreground of widgets and labels + for lbl in ["TLabel", "TFrame", "TLabelframe", "TCheckbutton", "TRadiobutton", + "TLabelframe.Label"]: + self._style.configure(f"{key}.Group.{lbl}", + background=theme["group_background"], + foreground=theme["group_font"]) + + +class _Widgets(): + """ Create custom ttk widget layouts for themed widgets. + + Parameters + ---------- + style: :class:`ttk.Style` + The master style object + """ + def __init__(self, style): + self._images = _TkImage() + self._style = style + + def combobox(self, key, control_color, active_color, arrow_color, control_border, field_color, + field_border): + """ Combo-boxes are fairly complex to style. + + Parameters + ---------- + key: str + The section that the slider will belong to + control_color: str + The color of inactive combo pull down button + active_color: str + The color of combo pull down button when it is hovered or pressed + arrow_color: str + The color of the combo pull down arrow + control_border: str + The color of the combo pull down button border + field_color: str + The color of the input field's background + field_border: str + The color of the input field's border + """ + # All the stock down arrow images are bad + images = dict() + for state in ("active", "normal"): + images[f"arrow_{state}"] = self._images.get_image( + (20, 20), + control_color if state == "normal" else active_color, + foreground=arrow_color, + pattern="arrow", + thickness=2, + border_width=1, + border_color=control_border) + + self._style.element_create(f"{key}.Combobox.downarrow", + "image", + images["arrow_normal"], + ("active", images["arrow_active"]), + ("pressed", images["arrow_active"]), + sticky="e", + width=20) + + # None of the themes give us the border control we need, so create an image + box = self._images.get_image((16, 16), + field_color, + border_width=1, + border_color=field_border) + self._style.element_create(f"{key}.Combobox.field", + "image", + box, + border=1, + padding=(6, 0, 0, 0)) + + # Set a layout so we can access required params + self._style.layout(f"{key}.TCombobox", [ + (f"{key}.Combobox.field", { + "children": [ + (f"{key}.Combobox.downarrow", {"side": "right", "sticky": "ns"}), + (f"{key}.Combobox.padding", { + "expand": "1", + "sticky": "nswe", + "children": [(f"{key}.Combobox.focus", { + "expand": "1", + "sticky": "nswe", + "children": [(f"{key}.Combobox.textarea", {"sticky": "nswe"})]})]})], + "sticky": "nswe"})]) + + def frame(self, key, background, border, borderwidth=1): + """ Create a custom frame widget for controlling background and border colors. + + Parameters + ---------- + key: str + The section that the Frame will belong to + background: str + The hex code for the background of the frame + border: str + The hex code for the border of the frame + """ + self._style.element_create(f"{key}.Frame.border", "from", "alt") + self._style.layout(f"{key}.TFrame", + [(f"{key}.Frame.border", {"sticky": "nswe"})]) + self._style.configure(f"{key}.TFrame", + background=background, + relief=tk.SOLID, + borderwidth=borderwidth, + bordercolor=border) + + def notebook(self, key, frame_border, tab_color, tab_selected, tab_hover): + """ Create a custom notebook widget so we can control the colors. + + Parameters + ---------- + key: str + The section that the scrollbar will belong to + frame_border: str + The border color around the tab's contents + tab_color: str + The color of non selected tabs + tab_selected: str + The color of selected tabs + tab_hover: str + The color of hovered tabs + """ + # TODO This lags out the GUI, so need to test where this is failing prior to implementing + client = self._images.get_image((8, 8), frame_border) + self._style.element_create(f"{key}.Notebook.client", "image", client, border=1) + + tabs = [self._images.get_image((8, 8), color) + for color in (tab_color, tab_selected, tab_hover)] + + self._style.element_create(f"{key}.Notebook.tab", + "image", + tabs[0], + ("selected", tabs[1]), + ("active", tabs[2]), + padding=(0, 2, 0, 0), + border=3) + + self._style.layout(f"{key}.TNotebook", [(f"{key}.Notebook.client", {"sticky": "nswe"})]) + self._style.layout(f"{key}.TNotebook.Tab", [ + (f"{key}.Notebook.tab", { + "sticky": "nswe", + "children": [ + ("Notebook.padding", { + "side": "top", + "sticky": "nswe", + "children": [ + ("Notebook.focus", { + "side": "top", + "sticky": "nswe", + "children": [("Notebook.label", {"side": "top", "sticky": ""})] + })] + })] + })]) + + self._style.configure(f"{key}.TNotebook", tabmargins=(0, 2, 0, 0)) + self._style.configure(f"{key}.TNotebook.Tab", padding=(6, 2, 6, 2), expand=(0, 0, 2)) + self._style.configure(f"{key}.TNotebook.Tab", expand=("selected", (1, 2, 4, 2))) + + def scrollbar(self, key, trough_color, border_color, control_backgrounds, control_foregrounds, + control_borders): + """ Create a custom scroll bar widget so we can control the colors. + + Parameters + ---------- + key: str + The section that the scrollbar will belong to + theme: dict + The theme options for a scroll bar. The dict should contain the keys: `background`, + `foreground`, `border`, with each item containing a tuple of the colors for the states + `normal`, `disabled` and `active` respectively + trough_color: str + The hex code for the scrollbar trough color + border_color: str + The hex code for the scrollbar border color + control_backgrounds: tuple + Tuple of length 3 for the button and slider colors for the states `normal`, + `disabled`, `active` + control_foregrounds: tuple + Tuple of length 3 for the button arrow colors for the states `normal`, + `disabled`, `active` + control_borders: tuple + Tuple of length 3 for the borders of the buttons and slider for the states `normal`, + `disabled`, `active` + """ + logger.debug("Creating scrollbar: (key: %s, trough_color: %s, border_color: %s, " + "control_backgrounds: %s, control_foregrounds: %s, control_borders: %s)", + key, trough_color, border_color, control_backgrounds, control_foregrounds, + control_borders) + images = dict() + for idx, state in enumerate(("normal", "disabled", "active")): + # Create arrow and slider widgets for each state + img_args = ((16, 16), control_backgrounds[idx]) + for dir_ in ("up", "down"): + images[f"img_{dir_}_{state}"] = self._images.get_image( + *img_args, + foreground=control_foregrounds[idx], + pattern="arrow", + direction=dir_, + thickness=4, + border_width=1, + border_color=control_borders[idx]) + images[f"img_thumb_{state}"] = self._images.get_image( + *img_args, + border_width=1, + border_color=control_borders[idx]) + + for element in ("thumb", "uparrow", "downarrow"): + # Create the elements with the new images + lookup = element.replace("arrow", "") + args = (f"{key}.Vertical.Scrollbar.{element}", + "image", + images[f"img_{lookup}_normal"], + ("disabled", images[f"img_{lookup}_disabled"]), + ("pressed !disabled", images[f"img_{lookup}_active"]), + ("active !disabled", images[f"img_{lookup}_active"])) + kwargs = dict(border=1, sticky="ns") if element == "thumb" else dict() + self._style.element_create(*args, **kwargs) + + # Get a configurable trough + self._style.element_create(f"{key}.Vertical.Scrollbar.trough", "from", "clam") + + self._style.layout( + f"{key}.Vertical.TScrollbar", + [(f"{key}.Vertical.Scrollbar.trough", { + "sticky": "ns", + "children": [ + (f"{key}.Vertical.Scrollbar.uparrow", {"side": "top", "sticky": ""}), + (f"{key}.Vertical.Scrollbar.downarrow", {"side": "bottom", "sticky": ""}), + (f"{key}.Vertical.Scrollbar.thumb", {"expand": "1", "sticky": "nswe"}) + ] + })]) + self._style.configure(f"{key}.Vertical.TScrollbar", + troughcolor=trough_color, + bordercolor=border_color, + troughrelief=tk.SOLID, + troughborderwidth=1) + + def slider(self, key, control_color, active_color, trough_color): + """ Take a copy of the default ttk.Scale widget and replace the slider element with a + version we can control the color and shape of. + + Parameters + ---------- + key: str + The section that the slider will belong to + control_color: str + The color of inactive slider and up down buttons + active_color: str + The color of slider and up down buttons when they are hovered or pressed + trough_color: str + The color of the scroll bar's trough + """ + img_slider = self._images.get_image((10, 25), control_color) + img_slider_alt = self._images.get_image((10, 25), active_color) + + self._style.element_create(f"{key}.Horizontal.Scale.trough", "from", "alt") + self._style.element_create(f"{key}.Horizontal.Scale.slider", + "image", + img_slider, + ("active", img_slider_alt)) + + self._style.layout( + f"{key}.Horizontal.TScale", + [(f"{key}.Scale.focus", { + "expand": "1", + "sticky": "nswe", + "children": [ + (f"{key}.Horizontal.Scale.trough", { + "expand": "1", + "sticky": "nswe", + "children": [ + (f"{key}.Horizontal.Scale.track", {"sticky": "we"}), + (f"{key}.Horizontal.Scale.slider", {"side": "left", "sticky": ""}) + ] + }) + ] + })]) + + self._style.configure(f"{key}.Horizontal.TScale", + background=trough_color, + groovewidth=4, + troughcolor=trough_color) + + +class _TkImage(): # pylint:disable=too-few-public-methods + """ Create a tk image for a given pattern and shape. + """ + def __init__(self): + self._cache = [] # We need to keep a reference to every image created + + # Numpy array patterns + @classmethod + def _get_solid(cls, dimensions): + """ Return a solid background color pattern. + + Parameters + ---------- + dimensions: tuple + The (`width`, `height`) of the desired tk image + + Returns + ------- + :class:`numpy.ndarray` + A 2D, UINT8 array of shape (height, width) of all zeros + """ + return np.zeros((dimensions[1], dimensions[0]), dtype="uint8") + + @classmethod + def _get_arrow(cls, dimensions, thickness, direction): + """ Return a background color with a "v" arrow in foreground color + + Parameters + ---------- + dimensions: tuple + The (`width`, `height`) of the desired tk image + thickness: int + The thickness of the pattern to be drawn + direction: ["left", "up", "right", "down"] + The direction that the pattern should be facing + + Returns + ------- + :class:`numpy.ndarray` + A 2D, UINT8 array of shape (height, width) of all zeros + """ + square_size = min(dimensions[1], dimensions[0]) + if square_size < 16 or any(dim % 2 != 0 for dim in dimensions): + raise FaceswapError("For arrow image, the minimum size across any axis must be 8 and " + "dimensions must all be divisible by 2") + crop_size = (square_size // 16) * 16 + draw_rows = int(6 * crop_size / 16) + start_row = dimensions[1] // 2 - draw_rows // 2 + initial_indent = (2 * (crop_size // 16) + (dimensions[0] - crop_size) // 2) + + retval = np.zeros((dimensions[1], dimensions[0]), dtype="uint8") + for i in range(start_row, start_row + draw_rows): + indent = initial_indent + i - start_row + join = (min(indent + thickness, dimensions[0] // 2), + max(dimensions[0] - indent - thickness, dimensions[0] // 2)) + retval[i, np.r_[indent:join[0], join[1]:dimensions[0] - indent]] = 1 + if direction in ("right", "left"): + retval = np.rot90(retval) + if direction in ("up", "left"): + retval = np.flip(retval) + return retval + + def get_image(self, + dimensions, + background, + foreground=None, + pattern="solid", + border_width=0, + border_color=None, + thickness=2, + direction="down"): + """ Obtain a tk image. + + Generates the requested image and stores in cache. + + Parameters + ---------- + dimensions: tuple + The (`width`, `height`) of the desired tk image + background: str + The hex code for the background (main) color + foreground: str, optional + The hex code for the background (secondary) color. If ``None`` is provided then a + solid background color image will be returned. Default: ``None`` + pattern: ["solid", "arrow"], optional + The pattern to generate for the tk image. Default: `"solid"` + border_width: int, optional + The thickness of foreground border to apply. Default: 0 + border_color: int, optional + The color of the border, if one is to be created. Default: ``None`` (use foreground + color) + thickness: int, optional + The thickness of the pattern to be drawn. Default: `2` + direction: ["left", "up", "right", "down"], optional + The direction that the pattern should be facing. Default: `"down"` + """ + foreground = foreground if foreground else background + border_color = border_color if border_color else foreground + + args = [dimensions] + if pattern.lower() == "arrow": + args.extend([thickness, direction]) + if pattern.lower() == "border": + args.extend([thickness]) + pattern = getattr(self, f"_get_{pattern.lower()}")(*args) + + if border_width > 0: + border = np.ones_like(pattern) + 1 + border[border_width:-border_width, + border_width:-border_width] = pattern[border_width:-border_width, + border_width:-border_width] + pattern = border + + return self._create_photoimage(background, foreground, border_color, pattern) + + def _create_photoimage(self, background, foreground, border, pattern): + """ Create a tkinter PhotoImage and populate it with the requested color pattern. + + Parameters + ---------- + background: str + The hex code for the background (main) color + foreground: str + The hex code for the foreground (secondary) color + border: str + The hex code for the border color + pattern: class:`numpy.ndarray` + The pattern for the final image with background colors marked as 0 and foreground + colors marked as 1 + """ + image = tk.PhotoImage(width=pattern.shape[1], height=pattern.shape[0]) + self._cache.append(image) + + pixels = "} {".join(" ".join(foreground + if pxl == 1 else border if pxl == 2 else background + for pxl in row) + for row in pattern) + image.put("{" + pixels + "}") + return image diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 96bdf0e974..cdaeaf89f6 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -6,7 +6,7 @@ import sys import tkinter as tk -from tkinter import filedialog, ttk +from tkinter import filedialog from threading import Event, Thread from queue import Queue @@ -14,11 +14,9 @@ from PIL import Image, ImageDraw, ImageTk -from lib.serializer import get_serializer -from lib.utils import FaceswapError - from ._config import Config as UserConfig from .project import Project, Tasks +from .theme import Style logger = logging.getLogger(__name__) # pylint: disable=invalid-name _CONFIG = None @@ -91,11 +89,11 @@ class FileHandler(): # pylint:disable=too-few-public-methods Parameters ---------- - handle_type: ['open', 'save', 'filename', 'filename_multi', 'savefilename', 'context', `dir`] + handle_type: ['open', 'save', 'filename', 'filename_multi', 'save_filename', 'context', `dir`] The type of file dialog to return. `open` and `save` will perform the open and save actions and return the file. `filename` returns the filename from an `open` dialog. `filename_multi` allows for multi-selection of files and returns a list of files selected. - `savefilename` returns the filename from a `save as` dialog. `context` is a context + `save_filename` returns the filename from a `save as` dialog. `context` is a context sensitive parameter that returns a certain dialog based on the current options. `dir` asks for a folder location. file_type: ['default', 'alignments', 'config_project', 'config_task', 'config_all', 'csv', \ @@ -118,13 +116,13 @@ class FileHandler(): # pylint:disable=too-few-public-methods Attributes ---------- - retfile: str or object + return_file: str or object The return value from the file dialog Example ------- >>> handler = FileHandler('filename', 'video', title='Select a video...') - >>> video_file = handler.retfile + >>> video_file = handler.return_file >>> print(video_file) '/path/to/selected/video.mp4' """ @@ -143,7 +141,7 @@ def __init__(self, handle_type, file_type, title=None, initial_folder=None, comm command, action, variable) - self.retfile = getattr(self, "_{}".format(self._handletype.lower()))() + self.return_file = getattr(self, "_{}".format(self._handletype.lower()))() logger.debug("Initialized %s", self.__class__.__name__) @property @@ -203,13 +201,13 @@ def _contexts(self): "rotate": "filename", "slice": "filename"}, output={"extract": "dir", - "gen-vid": "savefilename", + "gen-vid": "save_filename", "get-fps": "nothing", "get-info": "nothing", - "mux-audio": "savefilename", - "rescale": "savefilename", - "rotate": "savefilename", - "slice": "savefilename"})) + "mux-audio": "save_filename", + "rescale": "save_filename", + "rotate": "save_filename", + "slice": "save_filename"})) def _set_defaults(self): """ Set the default file type for the file dialog. Generally the first found file type @@ -250,7 +248,7 @@ def _set_kwargs(self, title, initialdir, filetype, command, action, variable=Non kwargs["initialdir"] = initialdir if self._handletype.lower() in ( - "open", "save", "filename", "filename_multi", "savefilename"): + "open", "save", "filename", "filename_multi", "save_filename"): kwargs["filetypes"] = self._filetypes[filetype] if self._defaults.get(filetype): kwargs['defaultextension'] = self._defaults[filetype] @@ -812,7 +810,7 @@ def __init__(self, root, cli_opts, statusbar): status_bar=statusbar, command_notebook=None) # set in command.py self._user_config = UserConfig(None) - self._style = _Style(self.default_font, root) + self._style = Style(self.default_font, root, PATHCACHE) self._user_theme = self._style.user_theme logger.debug("Initialized %s", self.__class__.__name__) @@ -1046,8 +1044,8 @@ def _set_tk_vars(): generatecommand = tk.StringVar() generatecommand.set(None) - consoleclear = tk.BooleanVar() - consoleclear.set(False) + console_clear = tk.BooleanVar() + console_clear.set(False) refreshgraph = tk.BooleanVar() refreshgraph.set(False) @@ -1063,7 +1061,7 @@ def _set_tk_vars(): istraining=istraining, action=actioncommand, generate=generatecommand, - consoleclear=consoleclear, + console_clear=console_clear, refreshgraph=refreshgraph, updatepreview=updatepreview, analysis_folder=analysis_folder) @@ -1114,436 +1112,6 @@ def set_geometry(self, width, height, fullscreen=False): logger.debug("Geometry: %sx%s", *initial_dimensions) -class _Style(): # pylint:disable=too-few-public-methods - """ Set the overarching theme and customize widgets. """ - def __init__(self, default_font, root): - self._images = _TkImage() - self._root = root - self._font = default_font - default = os.path.join(PATHCACHE, "themes", "default.json") - self._user_theme = get_serializer("json").load(default) - self._style = ttk.Style() - self._set_styles() - - @property - def user_theme(self): - """ dict: The currently selected user theme. """ - return self._user_theme - - def _set_styles(self): - """ Configure widget theme and styles """ - self._config_settings_group() - # Settings Popup - self._style.configure("SPanel.Header1.TLabel", - font=(self._font[0], self._font[1] + 4, "bold")) - self._style.configure("SPanel.Header2.TLabel", - font=(self._font[0], self._font[1] + 2, "bold")) - # Console - theme = self._user_theme["console"] - console_sbar = {area: tuple(theme[f"scrollbar_{area}_{state}"] - for state in ("normal", "disabled", "active")) - for area in ("background", "foreground", "border")} - self._get_custom_scrollbar("Console", - console_sbar, - theme["scrollbar_trough"], - theme["scrollbar_border"]) - - def _config_settings_group(self): - """ Configures the style of the control panel entry boxes. Used for inputting Faceswap - options or controlling plugin settings. """ - theme = self._user_theme["group_panel"] - for panel_type in ("CPanel", "SPanel"): - if panel_type == "SPanel": # Merge in Settings Panel overrides - theme = {**theme, **self._user_theme["group_settings"]} - self._style.configure(f"{panel_type}.Holder.TFrame", - background=theme["panel_background"]) - # Header Colors on option/group controls - self._style.configure(f"{panel_type}.Group.TLabelframe.Label", - foreground=theme["header_color"]) - self._style.configure(f"{panel_type}.Groupheader.TLabel", - background=theme["header_color"], - foreground=theme["header_font"], - font=(self._font[0], self._font[1], "bold")) - # Widgets and specific areas - self._group_panel_widgets(panel_type, theme) - self._group_panel_infoheader(panel_type, theme) - self._config_settings_group_slider(panel_type, theme) - sbar_theme = dict(background=(theme["control_color"], - theme["control_disabled"], - theme["control_active"]), - foreground=(theme["control_disabled"], - theme["control_color"], - theme["control_disabled"]), - border=(theme["header_color"], - theme["control_color"], - theme["header_color"])) - self._get_custom_scrollbar(panel_type, - sbar_theme, - theme["scrollbar_trough"], - theme["scrollbar_border"]) - self._config_settings_group_combobox(panel_type, theme) - - def _group_panel_infoheader(self, key, theme): - """ Set the theme for the information header box that appears at the top of each group - panel - - Parameters - ---------- - key: str - The section that the slider will belong to - theme: dict - The user configuration theme options - """ - self._style.element_create(f"{key}.InfoHeader.Frame.border", "from", "alt") - self._style.layout(f"{key}.InfoHeader.TFrame", - [(f"{key}.InfoHeader.Frame.border", {"sticky": "nswe"})]) - self._style.configure(f"{key}.InfoHeader.TFrame", - background=theme["info_color"], - relief=tk.SOLID, - borderwidth=1, - bordercolor=theme["info_border"]) - - self._style.configure(f"{key}.InfoHeader.TLabel", - background=theme["info_color"], - foreground=theme["info_font"], - font=(self._font[0], self._font[1], "bold")) - self._style.configure(f"{key}.InfoBody.TLabel", - background=theme["info_color"], - foreground=theme["info_font"]) - - def _group_panel_widgets(self, key, theme): - """ Configure the foreground and background colors of common widgets. - - Parameters - ---------- - key: str - The section that the slider will belong to - theme: dict - The user configuration theme options - """ - # Put a border on a group's sub-frame - self._style.element_create(f"{key}.Subframe.Group.Frame.border", "from", "alt") - self._style.layout(f"{key}.Subframe.Group.TFrame", - [(f"{key}.Subframe.Group.Frame.border", {"sticky": "nswe"})]) - self._style.configure(f"{key}.Subframe.Group.TFrame", - background=theme["group_background"], - relief=tk.SOLID, - borderwidth=1, - bordercolor=theme["group_border"]) - - # Background and Foreground of widgets and labels - for lbl in ["TLabel", "TFrame", "TLabelframe", "TCheckbutton", "TRadiobutton", - "TLabelframe.Label"]: - self._style.configure(f"{key}.Group.{lbl}", - background=theme["group_background"], - foreground=theme["group_font"]) - - def _config_settings_group_slider(self, key, theme): - """ Take a copy of the default ttk.Scale widget and replace the slider element with a - version we can control the color and shape of. - - Parameters - ---------- - key: str - The section that the slider will belong to - theme: dict - The user configuration theme options - """ - img_slider = self._images.get_image((10, 25), theme["control_color"]) - img_slider_alt = self._images.get_image((10, 25), theme["control_active"]) - - self._style.element_create(f"{key}.Horizontal.Scale.trough", "from", "alt") - self._style.element_create(f"{key}.Horizontal.Scale.slider", - "image", - img_slider, - ("active", img_slider_alt)) - - self._style.layout( - f"{key}.Horizontal.TScale", - [(f"{key}.Scale.focus", { - "expand": "1", - "sticky": "nswe", - "children": [ - (f"{key}.Horizontal.Scale.trough", { - "expand": "1", - "sticky": "nswe", - "children": [ - (f"{key}.Horizontal.Scale.track", {"sticky": "we"}), - (f"{key}.Horizontal.Scale.slider", {"side": "left", "sticky": ""}) - ] - }) - ] - })]) - - self._style.configure(f"{key}.Horizontal.TScale", - background=self._user_theme["group_panel"]["group_background"], - groovewidth=4, - troughcolor=self._user_theme["group_panel"]["group_background"]) - - def _get_custom_scrollbar(self, key, theme, trough, border): - """ Create a custom scroll bar widget so we can control the colors. - - Parameters - ---------- - key: str - The section that the slider will belong to - theme: dict - The theme options for a scroll bar. The dict should contain the keys: `background`, - `foreground`, `border`, with each item containing a tuple of the colors for the states - `normal`, `disabled` and `active` respectively - trough: str - The hex code for the scrollbar trough color - border: str - The hex code for the scrollbar border color - """ - logger.debug("Creating scrollbar: (key: %s, theme: %s, trough: %s, border: %s)", - key, theme, trough, border) - images = dict() - for idx, state in enumerate(("normal", "disabled", "active")): - # Create arrow and slider widgets for each state - img_args = ((16, 16), theme["background"][idx]) - for dir_ in ("up", "down"): - images[f"img_{dir_}_{state}"] = self._images.get_image( - *img_args, - foreground=theme["foreground"][idx], - pattern="arrow", - direction=dir_, - thickness=4, - border_width=1, - border_color=theme["border"][idx]) - images[f"img_thumb_{state}"] = self._images.get_image( - *img_args, - border_width=1, - border_color=theme["border"][idx]) - - for element in ("thumb", "uparrow", "downarrow"): - # Create the elements with the new images - lookup = element.replace("arrow", "") - args = (f"{key}.Vertical.Scrollbar.{element}", - "image", - images[f"img_{lookup}_normal"], - ("disabled", images[f"img_{lookup}_disabled"]), - ("pressed !disabled", images[f"img_{lookup}_active"]), - ("active !disabled", images[f"img_{lookup}_active"])) - kwargs = dict(border=1, sticky="ns") if element == "thumb" else dict() - self._style.element_create(*args, **kwargs) - - # Get a configurable trough - self._style.element_create(f"{key}.Vertical.Scrollbar.trough", "from", "clam") - - self._style.layout( - f"{key}.Vertical.TScrollbar", - [(f"{key}.Vertical.Scrollbar.trough", { - "sticky": "ns", - "children": [ - (f"{key}.Vertical.Scrollbar.uparrow", {"side": "top", "sticky": ""}), - (f"{key}.Vertical.Scrollbar.downarrow", {"side": "bottom", "sticky": ""}), - (f"{key}.Vertical.Scrollbar.thumb", {"expand": "1", "sticky": "nswe"}) - ] - })]) - self._style.configure(f"{key}.Vertical.TScrollbar", - troughcolor=trough, - bordercolor=border, - troughrelief=tk.SOLID, - troughborderwidth=1) - - def _config_settings_group_combobox(self, key, theme): - """ Combo-boxes are fairly complex to style. - - Parameters - ---------- - key: str - The section that the slider will belong to - theme: dict - The user configuration theme options - """ - # All the stock down arrow images are bad - images = dict() - for state in ("active", "normal"): - images[f"arrow_{state}"] = self._images.get_image( - (20, 20), - theme["control_color"] if state == "normal" else theme["control_active"], - foreground=theme["control_disabled"], - pattern="arrow", - thickness=2, - border_width=1, - border_color=theme["header_color"]) - - self._style.element_create(f"{key}.Combobox.downarrow", - "image", - images["arrow_normal"], - ("active", images["arrow_active"]), - ("pressed", images["arrow_active"]), - sticky="e", - width=20) - - # None of the themes give us the border control we need, so create an image - box = self._images.get_image((16, 16), - theme["group_background"], - border_width=1, - border_color=theme["group_font"]) - self._style.element_create(f"{key}.Combobox.field", - "image", - box, - border=1, - padding=(6, 0, 0, 0)) - - # Set a layout so we can access required params - self._style.layout(f"{key}.TCombobox", [ - (f"{key}.Combobox.field", { - "children": [ - (f"{key}.Combobox.downarrow", {"side": "right", "sticky": "ns"}), - (f"{key}.Combobox.padding", { - "expand": "1", - "sticky": "nswe", - "children": [(f"{key}.Combobox.focus", { - "expand": "1", - "sticky": "nswe", - "children": [(f"{key}.Combobox.textarea", {"sticky": "nswe"})]})]})], - "sticky": "nswe"})]) - - -class _TkImage(): # pylint:disable=too-few-public-methods - """ Create a tk image for a given pattern and shape. - """ - def __init__(self): - self._cache = [] # We need to keep a reference to every image created - - # Numpy array patterns - @classmethod - def _get_solid(cls, dimensions): - """ Return a solid background color pattern. - - Parameters - ---------- - dimensions: tuple - The (`width`, `height`) of the desired tk image - - Returns - ------- - :class:`numpy.ndarray` - A 2D, UINT8 array of shape (height, width) of all zeros - """ - return np.zeros((dimensions[1], dimensions[0]), dtype="uint8") - - @classmethod - def _get_arrow(cls, dimensions, thickness, direction): - """ Return a background color with a "v" arrow in foreground color - - Parameters - ---------- - dimensions: tuple - The (`width`, `height`) of the desired tk image - thickness: int - The thickness of the pattern to be drawn - direction: ["left", "up", "right", "down"] - The direction that the pattern should be facing - - Returns - ------- - :class:`numpy.ndarray` - A 2D, UINT8 array of shape (height, width) of all zeros - """ - square_size = min(dimensions[1], dimensions[0]) - if square_size < 16 or any(dim % 2 != 0 for dim in dimensions): - raise FaceswapError("For arrow image, the minimum size across any axis must be 8 and " - "dimensions must all be divisible by 2") - crop_size = (square_size // 16) * 16 - draw_rows = int(6 * crop_size / 16) - start_row = dimensions[1] // 2 - draw_rows // 2 - initial_indent = (2 * (crop_size // 16) + (dimensions[0] - crop_size) // 2) - - retval = np.zeros((dimensions[1], dimensions[0]), dtype="uint8") - for i in range(start_row, start_row + draw_rows): - indent = initial_indent + i - start_row - join = (min(indent + thickness, dimensions[0] // 2), - max(dimensions[0] - indent - thickness, dimensions[0] // 2)) - retval[i, np.r_[indent:join[0], join[1]:dimensions[0] - indent]] = 1 - if direction in ("right", "left"): - retval = np.rot90(retval) - if direction in ("up", "left"): - retval = np.flip(retval) - return retval - - def get_image(self, - dimensions, - background, - foreground=None, - pattern="solid", - border_width=0, - border_color=None, - thickness=2, - direction="down"): - """ Obtain a tk image. - - Generates the requested image and stores in cache. - - Parameters - ---------- - dimensions: tuple - The (`width`, `height`) of the desired tk image - background: str - The hex code for the background (main) color - foreground: str, optional - The hex code for the background (secondary) color. If ``None`` is provided then a - solid background color image will be returned. Default: ``None`` - pattern: ["solid", "arrow"], optional - The pattern to generate for the tk image. Default: `"solid"` - border_width: int, optional - The thickness of foreground border to apply. Default: 0 - border_color: int, optional - The color of the border, if one is to be created. Default: ``None`` (use foreground - color) - thickness: int, optional - The thickness of the pattern to be drawn. Default: `2` - direction: ["left", "up", "right", "down"], optional - The direction that the pattern should be facing. Default: `"down"` - """ - foreground = foreground if foreground else background - border_color = border_color if border_color else foreground - - args = [dimensions] - if pattern.lower() == "arrow": - args.extend([thickness, direction]) - if pattern.lower() == "border": - args.extend([thickness]) - pattern = getattr(self, f"_get_{pattern.lower()}")(*args) - - if border_width > 0: - border = np.ones_like(pattern) + 1 - border[border_width:-border_width, - border_width:-border_width] = pattern[border_width:-border_width, - border_width:-border_width] - pattern = border - - return self._create_photoimage(background, foreground, border_color, pattern) - - def _create_photoimage(self, background, foreground, border, pattern): - """ Create a tkinter PhotoImage and populate it with the requested color pattern. - - Parameters - ---------- - background: str - The hex code for the background (main) color - foreground: str - The hex code for the foreground (secondary) color - border: str - The hex code for the border color - pattern: class:`numpy.ndarray` - The pattern for the final image with background colors marked as 0 and foreground - colors marked as 1 - """ - image = tk.PhotoImage(width=pattern.shape[1], height=pattern.shape[0]) - self._cache.append(image) - - pixels = "} {".join(" ".join(foreground - if pxl == 1 else border if pxl == 2 else background - for pxl in row) - for row in pattern) - image.put("{" + pixels + "}") - return image - - class LongRunningTask(Thread): """ Runs long running tasks in a background thread to prevent the GUI from becoming unresponsive. diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index a438732412..e9bc8dcb54 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -62,7 +62,7 @@ def generate_command(self, *args): return category, command = self.tk_vars["generate"].get().split(",") args = self.build_args(category, command=command, generate=True) - self.tk_vars["consoleclear"].set(True) + self.tk_vars["console_clear"].set(True) logger.debug(" ".join(args)) print(" ".join(args)) self.tk_vars["generate"].set(None) @@ -71,7 +71,7 @@ def prepare(self, category): """ Prepare the environment for execution """ logger.debug("Preparing for execution") self.tk_vars["runningtask"].set(True) - self.tk_vars["consoleclear"].set(True) + self.tk_vars["console_clear"].set(True) if self.command == "train": self.tk_vars["istraining"].set(True) print("Loading...") diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index 349f00ed06..539f865388 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -342,7 +342,7 @@ def extract(self): """ dirname = FileHandler("dir", None, initial_folder=os.path.dirname(self._input_location), - title="Select output folder...").retfile + title="Select output folder...").return_file if not dirname: return logger.debug(dirname) diff --git a/tools/preview/preview.py b/tools/preview/preview.py index f3c8a51286..527a21f41d 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -1315,7 +1315,7 @@ def _add_actions(self, parent): image=img, command=action) btnutl.pack(padx=2, side=tk.RIGHT) - Tooltip(btnutl, text=text, wraplength=200) + Tooltip(btnutl, text=text, wrap_length=200) logger.debug("Added util buttons") @@ -1465,5 +1465,5 @@ def _add_actions(self, parent, config_key): image=img, command=lambda cmd=action: cmd(config_key)) btnutl.pack(padx=2, side=tk.RIGHT) - Tooltip(btnutl, text=text, wraplength=200) + Tooltip(btnutl, text=text, wrap_length=200) logger.debug("Added util buttons") From f60eaee9557b9f54bd3ec3446563aa820ce421ed Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 31 Mar 2021 19:17:37 +0100 Subject: [PATCH 433/981] Pin numpy to <1.20 (Loss breaks on version 1.20) --- _requirements_base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_requirements_base.txt b/_requirements_base.txt index b2a6159529..d356106e43 100644 --- a/_requirements_base.txt +++ b/_requirements_base.txt @@ -1,7 +1,7 @@ tqdm>=4.42 psutil>=5.7.0 pathlib==1.0.1 -numpy>=1.18.0 +numpy>=1.18.0,<1.20.0 opencv-python>=4.1.2.0 pillow>=7.0.0 scikit-learn>=0.22.0 From 26e9e20922247736d632dc66549f4b48bedddae8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 2 Apr 2021 22:05:52 +0100 Subject: [PATCH 434/981] lib.training.generator - Add debug error code --- lib/training/generator.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/training/generator.py b/lib/training/generator.py index 7537d83c40..7b9790a8f6 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -211,9 +211,20 @@ def cache_metadata(self, filenames): if self._partial_load: # Faces already loaded for Warp-to-landmarks detected_face = self._cache[key]["detected_face"] else: - detected_face = self._add_aligned_face(filename, - meta["alignments"], - batch.shape[1]) + try: + detected_face = self._add_aligned_face(filename, + meta["alignments"], + batch.shape[1]) + except IndexError: + logger.error("You have hit a bug being actively tracked by the devs") + logger.error("Please provide the crash report so that they can diagnose.") + logger.debug("Error: filename: %s, needs_cache: %s, meta: %s", + filename, needs_cache, meta) + logger.debug("Error: Batch shape: %s", batch.shape) + if len(batch.shape) == 1: + logger.debug("Mismatch batch shapes? Shapes: %s", + [b.shape for b in batch]) + raise self._add_mask(filename, detected_face) for area in ("eye", "mouth"): From 51b316ee6c49a7153686ec5f727d6a7d299b4edb Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 3 Apr 2021 11:31:58 +0100 Subject: [PATCH 435/981] lib.training.generator - Catch mismatched image sizes in training folder --- lib/align/aligned_face.py | 2 +- lib/training/generator.py | 25 +++++++++++-------------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index b2ad11277a..2afe485451 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -334,7 +334,7 @@ def extract_face(self, image): ``None`` if no image has been provided. """ if image is None: - logger.debug("_extract_face called without a loaded image. Returning empty face.") + logger.trace("_extract_face called without a loaded image. Returning empty face.") return None if self._is_aligned and self._centering != "head": # Crop out the sub face from full head diff --git a/lib/training/generator.py b/lib/training/generator.py index 7b9790a8f6..4473ef2089 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -201,6 +201,14 @@ def cache_metadata(self, filenames): batch, metadata = read_image_batch(filenames, with_metadata=True) + if len(batch.shape) == 1: + folder = os.path.dirname(filenames[0]) + details = [f"{key} ({img.shape[1]}px)" for key, img in zip(keys, batch)] + msg = (f"There are mismatched image sizes in the folder '{folder}'. All training " + "images for each side must have the same dimensions.\nThe batch that " + f"failed contains the following files:\n{details}.") + raise FaceswapError(msg) + # Populate items into cache for filename in needs_cache: key = os.path.basename(filename) @@ -211,20 +219,9 @@ def cache_metadata(self, filenames): if self._partial_load: # Faces already loaded for Warp-to-landmarks detected_face = self._cache[key]["detected_face"] else: - try: - detected_face = self._add_aligned_face(filename, - meta["alignments"], - batch.shape[1]) - except IndexError: - logger.error("You have hit a bug being actively tracked by the devs") - logger.error("Please provide the crash report so that they can diagnose.") - logger.debug("Error: filename: %s, needs_cache: %s, meta: %s", - filename, needs_cache, meta) - logger.debug("Error: Batch shape: %s", batch.shape) - if len(batch.shape) == 1: - logger.debug("Mismatch batch shapes? Shapes: %s", - [b.shape for b in batch]) - raise + detected_face = self._add_aligned_face(filename, + meta["alignments"], + batch.shape[1]) self._add_mask(filename, detected_face) for area in ("eye", "mouth"): From 5ac15f68d22c6570c39f2576ba5c74d7c36fa58c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 3 Apr 2021 12:53:47 +0100 Subject: [PATCH 436/981] gui - Console - Only strip trailing new lines from output --- lib/gui/wrapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index e9bc8dcb54..a91fc1aa86 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -220,7 +220,7 @@ def read_stdout(self): if "[preview updated]" in output.strip().lower(): self.wrapper.tk_vars["updatepreview"].set(True) continue - print(output.strip()) + print(output.rstrip()) returncode = self.process.poll() message = self.set_final_status(returncode) self.wrapper.terminate(message) From e682d0c467a85ec54a4e0da1356eb5d9a7269102 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 8 Apr 2021 14:36:39 +0100 Subject: [PATCH 437/981] lib.gui.stats - Add debug code to catch stats graphing bug --- lib/gui/stats.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/gui/stats.py b/lib/gui/stats.py index 3b11e89c2a..9e424390ee 100644 --- a/lib/gui/stats.py +++ b/lib/gui/stats.py @@ -379,11 +379,26 @@ def _cache_data(self, session_id, is_training=False): except ValueError as err: # When collecting live loss, the current batch may not be completely populated # Carry over the last loss to the next collection + if "setting an array element with a sequence" in str(err): - carry_over = loss[-1] - logger.debug("Carrying over data: (carry_over: %s, new loss: %s)", - carry_over, loss[:-1]) - loss = np.array(loss[:-1], dtype="float32") + # TODO Remove this debug code when this bug has been tracked and fixed + debug = dict(step=step, + type=type(loss), + len=len(loss), + loss=loss, + carry_over=carry_over, + labels=labels) + try: + carry_over = loss[-1] + logger.debug("Carrying over data: (carry_over: %s, new loss: %s)", + carry_over, loss[:-1]) + loss = np.array(loss[:-1], dtype="float32") + except: + msg = ("You have hit a bug that is being actively tracked by the developers.\n" + "Graphing will no longer work for the current training session.\n" + "Please provide the following information to the developers so that " + f"they can look to fix this bug: {debug}") + raise ValueError(msg) else: raise From 25d1f1b15918ecf1461d0445c405ce08973c1129 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 13 Apr 2021 00:10:29 +0100 Subject: [PATCH 438/981] gui.analysis - Fixups - Refactor stats and analysis code - Fix rollover data bug on live training - Update documentation --- docs/full/lib/gui.rst | 53 +-- lib/gui/analysis/__init__.py | 4 + lib/gui/analysis/event_reader.py | 624 +++++++++++++++++++++++++++++++ lib/gui/{ => analysis}/stats.py | 370 ++---------------- lib/gui/display_analysis.py | 4 +- lib/gui/display_command.py | 2 +- lib/gui/popup_session.py | 6 +- lib/gui/wrapper.py | 6 +- 8 files changed, 690 insertions(+), 379 deletions(-) create mode 100644 lib/gui/analysis/__init__.py create mode 100644 lib/gui/analysis/event_reader.py rename lib/gui/{ => analysis}/stats.py (70%) diff --git a/docs/full/lib/gui.rst b/docs/full/lib/gui.rst index 800a05c6f4..522cb981d4 100755 --- a/docs/full/lib/gui.rst +++ b/docs/full/lib/gui.rst @@ -8,6 +8,38 @@ is largely self-generated from the command line options specified in :mod:`lib.c .. contents:: Contents :local: +analysis package +================ + + +stats module +============ + +.. rubric:: Package Summary + +.. autosummary:: + :nosignatures: + + ~lib.gui.analysis.stats.Calculations + ~lib.gui.analysis.stats.GlobalSession + ~lib.gui.analysis.stats.SessionsSummary + ~lib.gui.analysis.event_reader.TensorBoardLogs + +.. rubric:: stats Module + +.. automodule:: lib.gui.analysis.stats + :members: + :undoc-members: + :show-inheritance: + +.. rubric:: event_reader Module + +.. automodule:: lib.gui.analysis.event_reader + :members: + :undoc-members: + :show-inheritance: + + custom\_widgets module ====================== @@ -89,27 +121,6 @@ project module :undoc-members: :show-inheritance: -stats module -============ - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~lib.gui.stats.Calculations - ~lib.gui.stats.ExponentialMovingAverage - ~lib.gui.stats.GlobalSession - ~lib.gui.stats.SessionsSummary - ~lib.gui.stats.TensorBoardLogs - -.. rubric:: Module - -.. automodule:: lib.gui.stats - :members: - :undoc-members: - :show-inheritance: - theme module ============ diff --git a/lib/gui/analysis/__init__.py b/lib/gui/analysis/__init__.py new file mode 100644 index 0000000000..ed1b38c142 --- /dev/null +++ b/lib/gui/analysis/__init__.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 +""" Methods for querying and compiling statistical data for the Faceswap GUI Analysis tab. """ + +from .stats import Calculations, _SESSION as Session # noqa diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py new file mode 100644 index 0000000000..80c3cdb38d --- /dev/null +++ b/lib/gui/analysis/event_reader.py @@ -0,0 +1,624 @@ +#!/usr/bin/env python3 +""" Handles the loading and collation of events from Tensorflow event log files. """ + +import logging +import os +import zlib + +import numpy as np +import tensorflow as tf +from tensorflow.core.util import event_pb2 +from tensorflow.python.framework import errors_impl as tf_errors + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class _LogFiles(): + """ Holds the filenames of the Tensorflow Event logs that require parsing. + + Parameters + ---------- + logs_folder: str + The folder that contains the Tensorboard log files + """ + def __init__(self, logs_folder): + logger.debug("Initializing: %s: (logs_folder: '%s')", self.__class__.__name__, logs_folder) + self._logs_folder = logs_folder + self._filenames = self._get_log_filenames() + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def session_ids(self): + """ list: Sorted list of `ints` of available session ids. """ + return list(sorted(self._filenames)) + + def _get_log_filenames(self): + """ Get the Tensorflow event filenames for all existing sessions. + + Returns + ------- + dict + The full path of each log file for each training session id that has been run + """ + logger.debug("Loading log filenames. base_dir: '%s'", self._logs_folder) + retval = dict() + for dirpath, _, filenames in os.walk(self._logs_folder): + if not any(filename.startswith("events.out.tfevents") for filename in filenames): + continue + session_id = self._get_session_id(dirpath) + if session_id is None: + logger.warning("Unable to load session data for model") + return retval + retval[session_id] = self._get_log_filename(dirpath, filenames) + logger.debug("logfiles: %s", retval) + return retval + + @classmethod + def _get_session_id(cls, folder): + """ Obtain the session id for the given folder. + + Parameters + ---------- + folder: str + The full path to the folder that contains the session's Tensorflow Event Log + + Returns + ------- + int + The session ID for the given folder. If no session id can be determined, return + ``None`` + """ + session = os.path.split(os.path.split(folder)[0])[1] + session_id = session[session.rfind("_") + 1:] + retval = None if not session_id.isdigit() else int(session_id) + logger.debug("folder: '%s', session_id: %s", folder, retval) + return retval + + @classmethod + def _get_log_filename(cls, folder, filenames): + """ Obtain the session log file for the given folder. If multiple log files exist for the + given folder, then the most recent log file is used, as earlier files are assumed to be + obsolete. + + Parameters + ---------- + folder: str + The full path to the folder that contains the session's Tensorflow Event Log + filenames: list + List of filenames that exist within the given folder + + Returns + ------- + str + The full path the the selected log file + """ + logfiles = [fname for fname in filenames if fname.startswith("events.out.tfevents")] + retval = os.path.join(folder, sorted(logfiles)[-1]) # Take last item if multi matches + logger.debug("logfiles: %s, selected: '%s'", logfiles, retval) + return retval + + def refresh(self): + """ Refresh the list of log filenames. """ + logger.debug("Refreshing log filenames") + self._filenames = self._get_log_filenames() + + def get(self, session_id): + """ Obtain the log filename for the given session id. + + Parameters + ---------- + session_id: int + The session id to obtain the log filename for + + Returns + ------- + str + The full path to the log file for the requested session id + """ + retval = self._filenames.get(session_id) + logger.debug("session_id: %s, log_filename: '%s'", session_id, retval) + return retval + + +class _Cache(): + """ Holds parsed Tensorflow log event data in a compressed cache in memory. + + Parameters + ---------- + session_ids: list + List of `ints` pertaining to the session ids that exist in the Tensorflow events folder + """ + def __init__(self, session_ids): + logger.debug("Initialising: %s: (session_ids: %s)", self.__class__.__name__, session_ids) + self._data = {idx: None for idx in session_ids} + self._carry_over = dict() + logger.debug("Initialized: %s", self.__class__.__name__) + + def is_cached(self, session_id): + """ bool: ``True`` if the data already exists in the cache otherwise ``False``. """ + return self._data.get(session_id) is not None + + def cache_data(self, session_id, data, labels, is_live=False): + """ Add a full session's worth of event data to :attr:`_data`. + + Parameters + ---------- + session_id: int + The session id to add the data for + data: dict + The extracted event data dictionary generated from :class:`_EventParser` + labels: list + List of `str` for the labels of each loss value output + is_live: bool, optional + ``True`` if the data to be cached is from a live training session otherwise ``False``. + Default: ``False`` + """ + logger.debug("Caching event data: (session_id: %s, labels: %s, data points: %s, " + "is_live: %s)", session_id, labels, len(data), is_live) + if not data: + logger.debug("No data to cache") + return + + timestamps, loss = self._to_numpy(data, len(labels), is_live) + + if not is_live or (is_live and session_id not in self._data): + self._data[session_id] = dict(labels=labels, + loss=zlib.compress(loss), + loss_shape=loss.shape, + timestamps=zlib.compress(timestamps), + timestamps_shape=timestamps.shape) + else: + self._add_latest_live(session_id, loss, timestamps) + + def _to_numpy(self, data, length_loss, is_live): + """ Extract each individual step data into separate numpy arrays for loss and timestamps. + + Timestamps are stored float64 as the extra accuracy is needed for correct timings. Arrays + are returned at the length of the shortest available data (i.e. truncated records are + dropped) + + Parameters + ---------- + data: dict + The incoming tensorflow event data in dictionary form per step + length_loss: int + The number of loss items that should appear for each step + is_live: bool, optional + ``True`` if the data to be cached is from a live training session otherwise ``False``. + Default: ``False`` + + Returns + ------- + timestamps: :class:`numpy.ndarray` + float64 array of all iteration's timestamps + loss: :class:`numpy.ndarray` + float32 array of all iteration's loss + """ + if is_live and self._carry_over: + logger.debug("Processing carry over: %s", self._carry_over) + self._collect_carry_over(data) + + times, loss = zip(*[(data[idx].get("timestamp"), data[idx].get("loss", [])) + for idx in sorted(data)]) + times, loss = self._process_data(data, times, loss, length_loss, is_live) + + times, loss = (np.array(times, dtype="float64"), np.array(loss, dtype="float32")) + + logger.debug("Converted to numpy: (data points: %s, timestamps shape: %s, loss shape: %s)", + len(data), times.shape, loss.shape) + return times, loss + + def _collect_carry_over(self, data): + """ For live data, collect carried over data from the previous update and merge into the + current data dictionary. + + Parameters + ---------- + data: dict + The latest raw data dictionary + """ + for key in list(self._carry_over): + carry_over = self._carry_over.pop(key) + update = data[key] + logger.debug("Merging carry over data: %s in to %s", carry_over, update) + timestamp = update.get("timestamp") + update["timestamp"] = carry_over["timestamp"] if timestamp is None else timestamp + update.setdefault("loss", []).extend(carry_over.get("loss", [])) + logger.debug("Merged carry over data: %s", update) + + def _process_data(self, data, timestamps, loss, length_loss, is_live): + """ Process live update data. + + Live data requires different processing as often we will only have partial data for the + current step, so we need to cache carried over partial data to be picked up at the next + query. In addition to this, if training is unexpectedly interrupted, there may also be + partial data which needs to be cleansed prior to creating a numpy array + + Parameters + ---------- + data: dict + The incoming tensorflow event data in dictionary form per step + timestamps: tuple + The raw timestamps for for the latest live query, including any partial reads + loss: tuple + The raw loss for for the latest live query, including any partial reads + length_loss: int + The number of loss items that should appear for each step + is_live: bool + ``True`` if the data to be cached is from a live training session otherwise ``False``. + + Returns + ------- + timestamps: tuple + Cleaned list of complete timestamps for the latest live query + loss: list + Cleaned list of complete loss for the latest live query + """ + loss = list(loss) + timestamps = list(timestamps) + ids = sorted(data) + + if not all(len(step) == length_loss for step in loss): + indices = [loss.index(step) for step in loss if len(step) != length_loss] + logger.debug("Truncated loss found. loss count: %s, truncated indices: %s", + len(loss), indices) + for idx in reversed(indices): # Backwards so we can delete indices and still traverse + if is_live: + self._set_carry_over(ids[idx], data[ids[idx]]) + logger.debug("Removing truncated loss: (timestamp: %s, loss: %s)", + timestamps[idx], loss[idx]) + del loss[idx] + del timestamps[idx] + + return timestamps, loss + + def _set_carry_over(self, index, data): + """ For live data, set carried over data from a partial Tensorflow Event read to + :attr:`_carry_over` + + Parameters + ------- + index: int + The step index to carry over data for + data: dict + The raw partially read Tensorflow event log data + """ + logger.debug("Setting carried over data: %s", data) + self._carry_over[index] = data + + def _add_latest_live(self, session_id, loss, timestamps): + """ Append the latest received live training data to the cached data. + + Parameters + ---------- + session_id: int + The training session ID to update the cache for + loss: :class:`numpy.ndarray` + The latest loss values returned from the iterator + timestamps: :class:`numpy.ndarray` + The latest time stamps returned from the iterator + """ + logger.debug("Adding live data to cache: (session_id: %s, loss: %s, timestamps: %s)", + session_id, loss.shape, timestamps.shape) + if not np.any(loss) and not np.any(timestamps): + return + + cache = self._data[session_id] + for metric in ("loss", "timestamps"): + data = locals()[metric] + old_shape = cache[f"{metric}_shape"] + dtype = "float32" if metric == "loss" else "float64" + old = np.frombuffer(zlib.decompress(cache[metric]), dtype=dtype).reshape(old_shape) + new = np.concatenate((old, data)) + logger.debug("'%s' old_shape: %s new_shape: %s", metric, old_shape, new.shape) + cache[f"{metric}_shape"] = new.shape + cache[metric] = zlib.compress(new) + del old + + def get_data(self, session_id, metric): + """ Retrieve the decompressed cached data from the cache for the given session id. + + Parameters + ---------- + session_id: int or ``None`` + If session_id is provided, then the cached data for that session is returned. If + session_id is ``None`` then the cached data for all sessions is returned + metric: ['loss', 'timestamps'] + The metric to return the data for. + + Returns + ------- + dict + The `session_id` (s) as key, the values are a dictionary containing the requested + metric information for each session returned + """ + if session_id is None: + raw = self._data + else: + data = self._data.get(session_id) + if not data: + return None + raw = {session_id: data} + + dtype = "float32" if metric == "loss" else "float64" + + retval = dict() + for idx, data in raw.items(): + val = {metric: np.frombuffer(zlib.decompress(data[metric]), + dtype=dtype).reshape(data[f"{metric}_shape"])} + if metric == "loss": + val["labels"] = data["labels"] + retval[idx] = val + + logger.debug("Obtained cached data: %s", + {session_id: {k: v.shape if isinstance(v, np.ndarray) else v + for k, v in data.items()} + for session_id, data in retval.items()}) + return retval + + +class TensorBoardLogs(): + """ Parse data from TensorBoard logs. + + Process the input logs folder and stores the individual filenames per session. + + Caches timestamp and loss data on request and returns this data from the cache. + + Parameters + ---------- + logs_folder: str + The folder that contains the Tensorboard log files + is_training: bool + ``True`` if the events are being read whilst Faceswap is training otherwise ``False`` + """ + def __init__(self, logs_folder, is_training): + self._is_training = is_training + self._log_files = _LogFiles(logs_folder) + self._cache = _Cache(self.session_ids) + self._training_iterator = None + + @property + def session_ids(self): + """ list: Sorted list of integers of available session ids. """ + return self._log_files.session_ids + + def set_training(self, is_training): + """ Set the internal training flag to the given `is_training` value. + + If a new training session is being instigated, refresh the log filenames + + Parameters + ---------- + is_training: bool + ``True`` to indicate that the logs to be read are from the currently training + session otherwise ``False`` + """ + if self._is_training == is_training: + return + + logger.debug("Setting is_training to %s", is_training) + self._is_training = is_training + if is_training: + self._log_files.refresh() + log_file = self._log_files.get(self.session_ids[-1]) + logger.debug("Setting training iterator for log file: '%s'", log_file) + self._training_iterator = tf.compat.v1.io.tf_record_iterator(log_file) + else: + logger.debug("Removing training iterator") + del self._training_iterator + self._training_iterator = None + + def _cache_data(self, session_id): + """ Cache TensorBoard logs for the given session ID on first access. + + Populates :attr:`_cache` with timestamps and loss data. + + If this is a training session and the data is being queried for the training session ID + then get the latest available data and append to the cache + + Parameters + ------- + session_id: int + The session ID to cache the data for + """ + live_data = self._is_training and session_id == max(self.session_ids) + iterator = self._training_iterator if live_data else tf.compat.v1.io.tf_record_iterator( + self._log_files.get(session_id)) + parser = _EventParser(iterator, self._cache, live_data) + parser.cache_events(session_id) + + def _check_cache(self, session_id=None): + """ Check if the given session_id has been cached and if not, cache it. + + Parameters + ---------- + session_id: int, optional + The Session ID to return the data for. Set to ``None`` to return all session + data. Default ``None` + """ + if session_id is not None and not self._cache.is_cached(session_id): + self._cache_data(session_id) + elif self._is_training and session_id == self.session_ids[-1]: + self._cache_data(session_id) + elif session_id is None: + for idx in self.session_ids: + if not self._cache.is_cached(idx): + self._cache_data(idx) + + def get_loss(self, session_id=None): + """ Read the loss from the TensorBoard event logs + + Parameters + ---------- + session_id: int, optional + The Session ID to return the loss for. Set to ``None`` to return all session + losses. Default ``None`` + + Returns + ------- + dict + The session id(s) as key, with a further dictionary as value containing the loss name + and list of loss values for each step + """ + logger.debug("Getting loss: (session_id: %s)", session_id) + retval = dict() + for idx in [session_id] if session_id else self.session_ids: + self._check_cache(idx) + data = self._cache.get_data(idx, "loss") + if not data: + continue + data = data[idx] + retval[idx] = {title: data["loss"][:, idx] for idx, title in enumerate(data["labels"])} + logger.debug({key: {k: v.shape for k, v in val.items()} + for key, val in retval.items()}) + return retval + + def get_timestamps(self, session_id=None): + """ Read the timestamps from the TensorBoard logs. + + As loss timestamps are slightly different for each loss, we collect the timestamp from the + `batch_loss` key. + + Parameters + ---------- + session_id: int, optional + The Session ID to return the timestamps for. Set to ``None`` to return all session + timestamps. Default ``None`` + + Returns + ------- + dict + The session id(s) as key with list of timestamps per step as value + """ + + logger.debug("Getting timestamps: (session_id: %s, is_training: %s)", + session_id, self._is_training) + retval = dict() + for idx in [session_id] if session_id else self.session_ids: + self._check_cache(idx) + retval[idx] = self._cache.get_data(idx, "timestamps")[idx]["timestamps"] + logger.debug({k: v.shape for k, v in retval.items()}) + return retval + + +class _EventParser(): # pylint:disable=too-few-public-methods + """ Parses Tensorflow event and populates data to :class:`_Cache`. + + Parameters + ---------- + iterator: :func:`tf.compat.v1.io.tf_record_iterator` + The iterator to use for reading Tensorflow event logs + cache: :class:`_Cache` + The cache object to store the collected parsed events to + live_data: bool + ``True`` if the iterator to be loaded is a training iterator for reading live data + otherwise ``False`` + """ + def __init__(self, iterator, cache, live_data): + logger.debug("Initializing: %s: (iterator: %s, cache: %s, live_data: %s)", + self.__class__.__name__, iterator, cache, live_data) + self._live_data = live_data + self._cache = cache + self._iterator = self._get_latest_live(iterator) if live_data else iterator + self._loss_labels = [] + logger.debug("Initialized: %s", self.__class__.__name__) + + @classmethod + def _get_latest_live(cls, iterator): + """ Obtain the latest event logs for live training data. + + The live data iterator remains open so that it can be re-queried + + Parameters + ---------- + iterator: :func:`tf.compat.v1.io.tf_record_iterator` + The live training iterator to use for reading Tensorflow event logs + + Yields + ------ + dict + A Tensorflow event in dictionary form for a single step + """ + i = 0 + while True: + try: + yield next(iterator) + i += 1 + except StopIteration: + logger.debug("End of data reached") + break + except tf.errors.DataLossError as err: + # Truncated records are ignored. The iterator holds the offset, so the record will + # be completed at the next call. + logger.debug("Truncated record. Original Error: %s", err) + break + logger.debug("Collected %s records from live log file", i) + + def cache_events(self, session_id): + """ Parse the Tensorflow events logs and add to :attr:`_cache`. + + Parameters + ---------- + session_id: int + The session id that the data is being cached for + """ + data = dict() + try: + for record in self._iterator: + event = event_pb2.Event.FromString(record) # pylint:disable=no-member + if not event.summary.value or not event.summary.value[0].tag.startswith("batch_"): + continue + data[event.step] = self._process_event(event, data.get(event.step, dict())) + + except tf_errors.DataLossError as err: + logger.warning("The logs for Session %s are corrupted and cannot be displayed. " + "The totals do not include this session. Original error message: " + "'%s'", session_id, str(err)) + + self._cache.cache_data(session_id, data, self._loss_labels, is_live=self._live_data) + + def _process_event(self, event, step): + """ Process a single Tensorflow event. + + Adds timestamp to the step `dict` if a total loss value is received, process the labels for + any new loss entries and adds the side loss value to the step `dict`. + + Parameters + ---------- + event: :class:`tensorflow.core.util.event_pb2` + The event data to be processed + step: dict + The dictionary to populated with the extracted data from the tensorflow event + + Returns + ------- + dict + The given step `dict` with the given event data added to it. + """ + summary = event.summary.value[0] + if summary.tag in ("batch_loss", "batch_total"): # Pre tf2.3 totals were "batch_total" + step["timestamp"] = event.wall_time + return step + self._process_label(summary.tag) + step.setdefault("loss", list()).append(summary.simple_value) + return step + + def _process_label(self, label): + """ Check if the incoming loss label exists in :attr:`_loss_labels` and if not, add it. + + Parameters + ---------- + label: str + The label for the currently processing loss event + """ + # tf2.3 stopped respecting loss names in tensorboard callback so rewrite + lbl_split = label.replace("batch_", "").replace("_loss", "").split("_") + if lbl_split[-1] in ("a", "b"): + lbl = f"face_{lbl_split[-1]}" + elif "both" in lbl_split: # Combined decoders don't get face names + lbl = f"face_{'b' if lbl_split[-1] == '1' else 'a'}" + else: + lbl = f"mask_{lbl_split[-2]}" # TODO may not work for combined decoders + if lbl not in self._loss_labels: + logger.debug("Adding loss label: (original: '%s', rewritten: '%s')", label, lbl) + self._loss_labels.append(lbl) diff --git a/lib/gui/stats.py b/lib/gui/analysis/stats.py similarity index 70% rename from lib/gui/stats.py rename to lib/gui/analysis/stats.py index 9e424390ee..4f63c6e232 100644 --- a/lib/gui/stats.py +++ b/lib/gui/analysis/stats.py @@ -10,24 +10,23 @@ import time import os import warnings -import zlib from math import ceil from threading import Event import numpy as np -import tensorflow as tf -from tensorflow.python.framework import errors_impl as tf_errors -from tensorflow.core.util import event_pb2 + from lib.serializer import get_serializer +from .event_reader import TensorBoardLogs + logger = logging.getLogger(__name__) # pylint: disable=invalid-name class GlobalSession(): """ Holds information about a loaded or current training session by accessing a model's state file and Tensorboard logs. This class should not be accessed directly, rather through - :attr:`lib.stats.Session` + :attr:`lib.gui.analysis.Session` """ def __init__(self): logger.debug("Initializing %s", self.__class__.__name__) @@ -111,7 +110,7 @@ def initialize_session(self, model_folder, model_name, is_training=False): if self._model_dir == model_folder and self._model_name == model_name: if is_training: - self._tb_logs.refresh_log_filenames() + self._tb_logs.set_training(is_training) self._load_state_file() self._is_training = True logger.debug("Requested session is already loaded. Not initializing: (model_folder: " @@ -123,7 +122,8 @@ def initialize_session(self, model_folder, model_name, is_training=False): self._model_name = model_name self._load_state_file() self._tb_logs = TensorBoardLogs(os.path.join(self._model_dir, - "{}_logs".format(self._model_name))) + "{}_logs".format(self._model_name)), + is_training) self._summary = SessionsSummary(self) logger.debug("Initialized session. Session_IDS: %s", self.session_ids) @@ -131,6 +131,7 @@ def initialize_session(self, model_folder, model_name, is_training=False): def stop_training(self): """ Clears the internal training flag. To be called when training completes. """ self._is_training = False + self._tb_logs.set_training(False) def clear(self): """ Clear the currently loaded session. """ @@ -165,7 +166,7 @@ def get_loss(self, session_id): if self._is_training: self._is_querying.set() - loss_dict = self._tb_logs.get_loss(session_id=session_id, is_training=self._is_training) + loss_dict = self._tb_logs.get_loss(session_id=session_id) if session_id is None: retval = dict() for key in sorted(loss_dict): @@ -173,7 +174,7 @@ def get_loss(self, session_id): retval.setdefault(loss_key, []).extend(loss) retval = {key: np.array(val, dtype="float32") for key, val in retval.items()} else: - retval = loss_dict[session_id] + retval = loss_dict.get(session_id, dict()) if self._is_training: self._is_querying.clear() @@ -200,7 +201,7 @@ def get_timestamps(self, session_id): if self._is_training: self._is_querying.set() - retval = self._tb_logs.get_timestamps(session_id=session_id, is_training=self._is_training) + retval = self._tb_logs.get_timestamps(session_id=session_id) if session_id is not None: retval = retval[session_id] @@ -242,336 +243,7 @@ def get_loss_keys(self, session_id): return retval -Session = GlobalSession() - - -class TensorBoardLogs(): - """ Parse data from TensorBoard logs. - - Process the input logs folder and stores the individual filenames per session. - - Caches timestamp and loss data on request and returns this data from the cache. - - Parameters - ---------- - logs_folder: str - The folder that contains the Tensorboard log files - """ - def __init__(self, logs_folder): - self._folder_base = logs_folder - self._log_filenames = self._get_log_filenames() - self._cache = dict() - self._training_iterator = None - self._training_rollover = dict() - - @property - def session_ids(self): - """ list: Sorted list of integers of available session ids. """ - return list(sorted(self._log_filenames)) - - def _get_log_filenames(self): - """ Get the TensorBoard log filenames for all existing sessions. - - Returns - ------- - dict - The full path of each log file for each training session that has been run - """ - logger.debug("Loading log filenames. base_dir: '%s'", self._folder_base) - log_filenames = dict() - for dirpath, _, filenames in os.walk(self._folder_base): - if not any(filename.startswith("events.out.tfevents") for filename in filenames): - continue - logfiles = [filename for filename in filenames - if filename.startswith("events.out.tfevents")] - # Take the last log file, in case of previous crash - logfile = os.path.join(dirpath, sorted(logfiles)[-1]) - session = os.path.split(os.path.split(dirpath)[0])[1] - session = session[session.rfind("_") + 1:] - if not session.isdigit(): - logger.warning("Unable to load session data for model") - return log_filenames - session = int(session) - log_filenames[session] = logfile - logger.debug("logfiles: %s", log_filenames) - return log_filenames - - def _cache_data(self, session_id, is_training=False): - """ Cache TensorBoard logs for the given session ID on first access. - - Populates :attr:`_cache` with timestamps and loss data. - - If this is a training session and the data is being queried for the training session ID - then get the latest available data and append to the cache - - Parameters - ------- - session_id: int - The session ID to cache the data for - is_training: bool, optional - ``True`` if a current training session is running otherwise ``False``. - Default: ``False`` - """ - labels = [] - step = [] - loss = [] - timestamps = [] - last_step = -1 - carry_over = None - live_data = is_training and session_id == max(self._log_filenames) - - if live_data: - iterator = self._get_latest_live() - else: - iterator = tf.compat.v1.io.tf_record_iterator(self._log_filenames[session_id]) - - try: - for record in iterator: - event = event_pb2.Event.FromString(record) - if not event.summary.value or not event.summary.value[0].tag.startswith("batch_"): - continue - - if last_step == -1: - last_step = event.step if live_data else 0 - - if live_data and self._cache[session_id].get("carry_over"): - step = self._cache[session_id]["carry_over"] - logger.debug("Retrieving carried over data: %s", step) - self._cache[session_id]["carry_over"] = None - - if event.step != last_step: - if last_step != 0: - loss.append(step) - step = [] - last_step = event.step - - summary = event.summary.value[0] - tag = summary.tag - - # Pre tf2.3 totals were "batch_total" - if tag in ("batch_loss", "batch_total"): - timestamps.append(event.wall_time) - continue - - # tf2.3 stopped respecting loss names in tensorboard callback so rewrite - lbl_split = tag.replace("batch_", "").replace("_loss", "").split("_") - if lbl_split[-1] in ("a", "b"): - lbl = f"face_{lbl_split[-1]}" - elif "both" in lbl_split: # Combined decoders don't get face names - lbl = f"face_{'b' if lbl_split[-1] == '1' else 'a'}" - else: - lbl = f"mask_{lbl_split[-2]}" # TODO may not work for combined decoders - if lbl not in labels: - labels.append(lbl) - - step.append(summary.simple_value) - - except tf_errors.DataLossError as err: - logger.warning("The logs for Session %s are corrupted and cannot be displayed. " - "The totals do not include this session. Original error message: " - "'%s'", session_id, str(err)) - - if step: - loss.append(step) - - try: - loss = np.array(loss, dtype="float32") - except ValueError as err: - # When collecting live loss, the current batch may not be completely populated - # Carry over the last loss to the next collection - - if "setting an array element with a sequence" in str(err): - # TODO Remove this debug code when this bug has been tracked and fixed - debug = dict(step=step, - type=type(loss), - len=len(loss), - loss=loss, - carry_over=carry_over, - labels=labels) - try: - carry_over = loss[-1] - logger.debug("Carrying over data: (carry_over: %s, new loss: %s)", - carry_over, loss[:-1]) - loss = np.array(loss[:-1], dtype="float32") - except: - msg = ("You have hit a bug that is being actively tracked by the developers.\n" - "Graphing will no longer work for the current training session.\n" - "Please provide the following information to the developers so that " - f"they can look to fix this bug: {debug}") - raise ValueError(msg) - else: - raise - - timestamps = np.array(timestamps, dtype="float64") - logger.debug("Caching session id: %s, labels: %s, loss: %s, timestamps: %s", - session_id, labels, loss.shape, timestamps.shape) - - if live_data and session_id in self._cache: - self._add_latest_data_to_cache(session_id, loss, timestamps) - else: - self._cache[session_id] = dict(labels=labels, - loss=zlib.compress(loss), - loss_shape=loss.shape, - timestamps=zlib.compress(timestamps), - timestamps_shape=timestamps.shape) - if carry_over: - self._cache[session_id]["carry_over"] = carry_over - - def _get_latest_live(self): - """ Obtain the latest event logs for live training data and add to the cache """ - if self._training_iterator is None: - training_session_id = self.session_ids[-1] - filename = self._log_filenames[training_session_id] - self._training_iterator = tf.compat.v1.io.tf_record_iterator(filename) - logger.debug("Set live training iterator %s for session_id: %s", - self._training_iterator, training_session_id) - - i = 0 - while True: - try: - yield next(self._training_iterator) - i += 1 - except StopIteration: - logger.debug("End of data reached") - break - except tf.errors.DataLossError as err: - # Truncated records are ignored. The iterator holds the offset, so the record will - # be completed at the next call. - logger.debug("Truncated record. Original Error: %s", err) - break - logger.debug("Collected %s records from live log file", i) - - def _add_latest_data_to_cache(self, session_id, loss, timestamps): - """ Append the latest received live training data to the cached data. - - Parameters - ---------- - session_id: int - The training session ID to update the cache for - loss: :class:`numpy.ndarray` - The latest loss values returned from the iterator - timestamps: :class:`numpy.ndarray` - The latest time stamps returned from the iterator - """ - if not np.any(loss) and not np.any(timestamps): - logger.debug("No new live data to cache.") - return - - logger.debug("Adding live data to cache: (loss: %s, timestamps: %s)", - loss.shape, timestamps.shape) - - cache = self._cache[session_id] - - past_loss = np.frombuffer(zlib.decompress(cache["loss"]), - dtype="float32").reshape(cache["loss_shape"]) - new_loss = np.concatenate((past_loss, loss)) - cache["loss_shape"] = new_loss.shape - cache["loss"] = zlib.compress(new_loss) - del past_loss - - past_timestamps = np.frombuffer(zlib.decompress(cache["timestamps"]), - dtype="float64").reshape(cache["timestamps_shape"]) - new_timestamps = np.concatenate((past_timestamps, timestamps)) - cache["timestamps_shape"] = new_timestamps.shape - cache["timestamps"] = zlib.compress(new_timestamps) - del past_timestamps - - def _from_cache(self, session_id=None, is_training=False): - """ Get the session data from the cache. - - If the request data does not exist in the cache, then populate it. - - Parameters - ---------- - session_id: int, optional - The Session ID to return the data for. Set to ``None`` to return all session - data. Default ``None` - is_training: bool, optional - ``True`` if a current training session is running otherwise ``False``. - Default: ``False`` - - Returns - ------- - dict - The session id(s) as key, with the event data as value - """ - if session_id is not None and session_id not in self._cache: - self._cache_data(session_id) - elif is_training and session_id == self.session_ids[-1]: - self._cache_data(session_id, is_training=is_training) - elif session_id is None and not all(idx in self._cache for idx in self._log_filenames): - for sess in self._log_filenames: - if sess not in self._cache: - self._cache_data(sess) - - if session_id is None: - return self._cache - return {session_id: self._cache[session_id]} - - def get_loss(self, session_id=None, is_training=False): - """ Read the loss from the TensorBoard event logs - - Parameters - ---------- - session_id: int, optional - The Session ID to return the loss for. Set to ``None`` to return all session - losses. Default ``None`` - is_training: bool, optional - ``True`` if a current training session is running otherwise ``False``. - Default: ``False`` - - Returns - ------- - dict - The session id(s) as key, with a further dictionary as value containing the loss name - and list of loss values for each step - """ - logger.debug("Getting loss: (session_id: %s)", session_id) - retval = dict() - for sess, info in self._from_cache(session_id, is_training).items(): - arr = np.frombuffer(zlib.decompress(info["loss"]), - dtype="float32").reshape(info["loss_shape"]) - for idx, title in enumerate(info["labels"]): - retval.setdefault(sess, dict())[title] = arr[:, idx] - logger.debug({key: {k: v.shape for k, v in val.items()} - for key, val in retval.items()}) - return retval - - def get_timestamps(self, session_id=None, is_training=False): - """ Read the timestamps from the TensorBoard logs. - - As loss timestamps are slightly different for each loss, we collect the timestamp from the - `batch_loss` key. - - Parameters - ---------- - session_id: int, optional - The Session ID to return the timestamps for. Set to ``None`` to return all session - timestamps. Default ``None`` - is_training: bool, optional - ``True`` if a current training session is running otherwise ``False``. - Default: ``False`` - - Returns - ------- - dict - The session id(s) as key with list of timestamps per step as value - """ - - logger.debug("Getting timestamps: (session_id: %s, is_training: %s)", - session_id, is_training) - retval = {sess: np.frombuffer(zlib.decompress(info["timestamps"]), - dtype="float64").reshape(info["timestamps_shape"]) - for sess, info in self._from_cache(session_id, is_training).items()} - logger.debug({k: v.shape for k, v in retval.items()}) - return retval - - def refresh_log_filenames(self): - """ Refresh the log file list in :attr:`_log_filenames`. - - Called when a training session is loaded, to add the latest log filename to the list. - """ - self._log_filenames = self._get_log_filenames() +_SESSION = GlobalSession() class SessionsSummary(): # pylint:disable=too-few-public-methods @@ -633,10 +305,10 @@ def _get_time_stats(self): iterations=timestamps.shape[0] if np.any(timestamps) else 0) for sess_id, timestamps in self._session.get_timestamps(None).items()} - elif Session.is_training: + elif _SESSION.is_training: logger.debug("Updating summary time stamps for training session") - session_id = Session.session_ids[-1] + session_id = _SESSION.session_ids[-1] latest = self._session.get_timestamps(session_id) self._time_stats[session_id] = dict( @@ -872,7 +544,7 @@ def stats(self): def refresh(self): """ Refresh the stats """ logger.debug("Refreshing") - if not Session.is_loaded: + if not _SESSION.is_loaded: logger.warning("Session data is not initialized. Not refreshing") return None self._iterations = 0 @@ -939,7 +611,7 @@ def _get_raw(self): iterations = set() if self._display.lower() == "loss": - loss_dict = Session.get_loss(self._session_id) + loss_dict = _SESSION.get_loss(self._session_id) for loss_name, loss in loss_dict.items(): if loss_name not in self._loss_keys: continue @@ -1021,7 +693,7 @@ def _calc_rate(self): The training rate for each iteration of the selected session """ logger.debug("Calculating rate") - retval = (Session.batch_sizes[self._session_id] * 2) / np.diff(Session.get_timestamps( + retval = (_SESSION.batch_sizes[self._session_id] * 2) / np.diff(_SESSION.get_timestamps( self._session_id)) logger.debug("Calculated rate: Item_count: %s", len(retval)) return retval @@ -1041,8 +713,8 @@ def _calc_rate_total(cls): each session's rate calculation. """ logger.debug("Calculating totals rate") - batchsizes = Session.batch_sizes - total_timestamps = Session.get_timestamps(None) + batchsizes = _SESSION.batch_sizes + total_timestamps = _SESSION.get_timestamps(None) rate = list() for sess_id in sorted(total_timestamps.keys()): batchsize = batchsizes[sess_id] @@ -1106,7 +778,7 @@ def _calc_smoothed(self, data): :class:`numpy.ndarray` The smoothed data """ - return ExponentialMovingAverage(data, self._args["smooth_amount"])() + return _ExponentialMovingAverage(data, self._args["smooth_amount"])() @classmethod def _calc_trend(cls, data): @@ -1134,7 +806,7 @@ def _calc_trend(cls, data): return trend -class ExponentialMovingAverage(): # pylint:disable=too-few-public-methods +class _ExponentialMovingAverage(): # pylint:disable=too-few-public-methods """ Reshapes data before calculating exponential moving average, then iterates once over the rows to calculate the offset without precision issues. diff --git a/lib/gui/display_analysis.py b/lib/gui/display_analysis.py index e9f9ea5f19..14fa29969a 100644 --- a/lib/gui/display_analysis.py +++ b/lib/gui/display_analysis.py @@ -11,7 +11,7 @@ from .custom_widgets import Tooltip from .display_page import DisplayPage from .popup_session import SessionPopUp -from .stats import Session +from .analysis import Session from .utils import FileHandler, get_config, get_images, LongRunningTask logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -209,7 +209,7 @@ def _summarise_data(cls, session): Parameters ---------- - session: :class:`lib.gui.stats.Session` + session: :class:`lib.gui.analysis.Session` The session object to generate the summary for """ return session.full_summary diff --git a/lib/gui/display_command.py b/lib/gui/display_command.py index 6fc6dae286..a9be1556c7 100644 --- a/lib/gui/display_command.py +++ b/lib/gui/display_command.py @@ -12,7 +12,7 @@ from .display_graph import TrainingGraph from .display_page import DisplayOptionalPage from .custom_widgets import Tooltip -from .stats import Calculations, Session +from .analysis import Calculations, Session from .control_helper import set_slider_rounding from .utils import FileHandler, get_config, get_images, preview_trigger diff --git a/lib/gui/popup_session.py b/lib/gui/popup_session.py index 70c61b149e..f59999c77e 100644 --- a/lib/gui/popup_session.py +++ b/lib/gui/popup_session.py @@ -10,7 +10,7 @@ from .control_helper import ControlBuilder, ControlPanelOption from .custom_widgets import Tooltip from .display_graph import SessionGraph -from .stats import Calculations, Session +from .analysis import Calculations, Session from .utils import FileHandler, get_images, LongRunningTask logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -419,11 +419,11 @@ def _get_display_data(**kwargs): Parameters ---------- kwargs: dict - The keyword arguments to pass to `lib.gui.stats.Calculations` + The keyword arguments to pass to `lib.gui.analysis.Calculations` Returns ------- - :class:`lib.gui.stats.Calculations` + :class:`lib.gui.analysis.Calculations` The summarized results for the given session """ return Calculations(**kwargs) diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index a91fc1aa86..824921ec1e 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -11,7 +11,7 @@ import psutil -from .stats import Session +from .analysis import Session from .utils import get_config, get_images, LongRunningTask, preview_trigger if os.name == "nt": @@ -88,8 +88,8 @@ def prepare(self, category): def build_args(self, category, command=None, generate=False): """ Build the faceswap command and arguments list. - If training, pass the model folder and name to the training :class:`lib.gui.stats.Session` - for the GUI. + If training, pass the model folder and name to the training + :class:`lib.gui.analysis.Session` for the GUI. """ logger.debug("Build cli arguments: (category: %s, command: %s, generate: %s)", category, command, generate) From 510b8ba051740c0d9c5d0942f3881eab4c86579b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 13 Apr 2021 01:00:37 +0100 Subject: [PATCH 439/981] gui.analysis.event_reader - bugfix - return empty when no timestamps in cache --- lib/gui/analysis/event_reader.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index 80c3cdb38d..bce0ab9f81 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -496,7 +496,10 @@ def get_timestamps(self, session_id=None): retval = dict() for idx in [session_id] if session_id else self.session_ids: self._check_cache(idx) - retval[idx] = self._cache.get_data(idx, "timestamps")[idx]["timestamps"] + data = self._cache.get_data(idx, "timestamps") + if not data: + continue + retval[idx] = data[idx]["timestamps"] logger.debug({k: v.shape for k, v in retval.items()}) return retval From a9bc9fb11462fddaec3b235d70db276496ed37a6 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 13 Apr 2021 11:57:18 +0100 Subject: [PATCH 440/981] GUI - Wrapper - bugfix - Only reset session data at the end of a training session --- lib/gui/wrapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index 824921ec1e..c3bdc653e6 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -138,11 +138,11 @@ def terminate(self, message): self.tk_vars["runningtask"].set(False) if self.task.command == "train": self.tk_vars["istraining"].set(False) + Session.stop_training() self.statusbar.stop() self.statusbar.message.set(message) self.tk_vars["display"].set(None) get_images().delete_preview() - Session.stop_training() preview_trigger().clear() self.command = None logger.debug("Terminated Faceswap processes") From ed8a38677a3e3b0a234f456fbc3e2bec93df479a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 13 Apr 2021 12:05:02 +0000 Subject: [PATCH 441/981] tools.sort - Mask out background for sort by blur --- tools/sort/sort.py | 51 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 5193f7286f..dfa740e6f5 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -39,10 +39,10 @@ def process(self): # Setting default argument values that cannot be set by argparse - # Set output dir to the same value as input dir + # Set output folder to the same value as input folder # if the user didn't specify it. if self._args.output_dir is None: - logger.verbose("No output directory provided. Using input dir as output dir.") + logger.verbose("No output directory provided. Using input folder as output folder.") self._args.output_dir = self._args.input_dir # Assigning default threshold values based on grouping method @@ -155,12 +155,11 @@ def sort_blur(self): """ Sort by blur amount """ logger.info("Sorting by estimated image blur...") - # TODO We have metadata here, so we can mask the face for blur estimate - blurs = [(filename, self.estimate_blur(image)) - for filename, image, _ in tqdm(self._loader.load(), - desc="Estimating blur", - total=self._loader.count, - leave=False)] + blurs = [(filename, self.estimate_blur(image, metadata)) + for filename, image, metadata in tqdm(self._loader.load(), + desc="Estimating blur", + total=self._loader.count, + leave=False)] logger.info("Sorting...") return sorted(blurs, key=lambda x: x[1], reverse=True) @@ -674,12 +673,38 @@ def find_images(input_dir): break return result - @staticmethod - def estimate_blur(image): - """ - Estimate the amount of blur an image has with the variance of the Laplacian. - Normalize by pixel number to offset the effect of image size on pixel gradients & variance + @classmethod + def estimate_blur(cls, image, metadata=None): + """ Estimate the amount of blur an image has with the variance of the Laplacian. + Normalize by pixel number to offset the effect of image size on pixel gradients & variance. + + Parameters + ---------- + image: :class:`numpy.ndarray` + The face image to calculate blur for + metadata: dict, optional + The metadata for the face image or ``None`` if no metadata is available. If metadata is + provided the face will be masked by the "components" mask prior to calculating blur. + Default:``None`` + + Returns + ------- + float + The estimated blur score for the face """ + if metadata is not None: + alignments = metadata["alignments"] + det_face = DetectedFace() + det_face.from_png_meta(alignments) + aln_face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), + image=image, + centering="legacy", + size=256, + is_aligned=True) + mask = det_face.mask["components"] + mask.set_sub_crop(aln_face.pose.offset["face"] * -1) + mask = cv2.resize(mask.mask, (256, 256), interpolation=cv2.INTER_CUBIC)[..., None] + image = np.minimum(aln_face.face, mask) if image.ndim == 3: image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blur_map = cv2.Laplacian(image, cv2.CV_32F) From 094ea338f162ae0b9c74f2baf33c7fe970f87e0a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 14 Apr 2021 15:28:57 +0100 Subject: [PATCH 442/981] GUI - Bugfixes - Swallow OSErrors when failing to load preview image - Fix event_reader mapping for model output to loss names - stats - Ensure that _tb_logs exists prior to calling stop training --- lib/gui/analysis/event_reader.py | 101 +++++++++++++++---------------- lib/gui/analysis/stats.py | 3 +- lib/gui/utils.py | 11 +++- 3 files changed, 62 insertions(+), 53 deletions(-) diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index bce0ab9f81..3a6a6a1331 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -10,6 +10,8 @@ from tensorflow.core.util import event_pb2 from tensorflow.python.framework import errors_impl as tf_errors +from lib.serializer import get_serializer + logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -256,35 +258,19 @@ def _process_data(self, data, timestamps, loss, length_loss, is_live): """ loss = list(loss) timestamps = list(timestamps) - ids = sorted(data) - - if not all(len(step) == length_loss for step in loss): - indices = [loss.index(step) for step in loss if len(step) != length_loss] - logger.debug("Truncated loss found. loss count: %s, truncated indices: %s", - len(loss), indices) - for idx in reversed(indices): # Backwards so we can delete indices and still traverse - if is_live: - self._set_carry_over(ids[idx], data[ids[idx]]) - logger.debug("Removing truncated loss: (timestamp: %s, loss: %s)", - timestamps[idx], loss[idx]) - del loss[idx] - del timestamps[idx] - - return timestamps, loss - def _set_carry_over(self, index, data): - """ For live data, set carried over data from a partial Tensorflow Event read to - :attr:`_carry_over` + if len(loss[-1]) != length_loss: + logger.debug("Truncated loss found. loss count: %s", len(loss)) + idx = sorted(data)[-1] + if is_live: + logger.debug("Setting carried over data: %s", data) + self._carry_over[idx] = data[idx] + logger.debug("Removing truncated loss: (timestamp: %s, loss: %s)", + timestamps[-1], loss[-1]) + del loss[-1] + del timestamps[-1] - Parameters - ------- - index: int - The step index to carry over data for - data: dict - The raw partially read Tensorflow event log data - """ - logger.debug("Setting carried over data: %s", data) - self._carry_over[index] = data + return timestamps, loss def _add_latest_live(self, session_id, loss, timestamps): """ Append the latest received live training data to the cached data. @@ -569,9 +555,12 @@ def cache_events(self, session_id): try: for record in self._iterator: event = event_pb2.Event.FromString(record) # pylint:disable=no-member - if not event.summary.value or not event.summary.value[0].tag.startswith("batch_"): + if not event.summary.value: continue - data[event.step] = self._process_event(event, data.get(event.step, dict())) + if event.summary.value[0].tag == "keras": + self._parse_outputs(event) + if event.summary.value[0].tag.startswith("batch_"): + data[event.step] = self._process_event(event, data.get(event.step, dict())) except tf_errors.DataLossError as err: logger.warning("The logs for Session %s are corrupted and cannot be displayed. " @@ -580,7 +569,38 @@ def cache_events(self, session_id): self._cache.cache_data(session_id, data, self._loss_labels, is_live=self._live_data) - def _process_event(self, event, step): + def _parse_outputs(self, event): + """ Parse the outputs from the stored model structure for mapping loss names to + model outputs. + + Loss names are added to :attr:`_loss_labels` + + Parameters + ---------- + event: :class:`tensorflow.core.util.event_pb2` + The event data containing the keras model structure to be parsed + """ + serializer = get_serializer("json") + struct = event.summary.value[0].tensor.string_val[0] + outputs = np.array(serializer.unmarshal(struct)["config"]["output_layers"]) + logger.debug("Obtained model outputs: %s, shape: %s", outputs, outputs.shape) + if outputs.ndim == 2: # Insert extra dimension for non learn mask models + outputs = np.expand_dims(outputs, axis=1) + logger.debug("Expanded dimensions for non-learn_mask model. outputs: %s, shape: %s", + outputs, outputs.shape) + for side_outputs, side in zip(outputs, ("a", "b")): + logger.debug("side: '%s', outputs: '%s'", side, side_outputs) + for idx in range(len(side_outputs)): + # First output is always face. Subsequent outputs are masks + loss_name = f"face_{side}" if idx == 0 else f"mask_{side}" + loss_name = loss_name if idx < 2 else f"{loss_name}_{idx}" + if loss_name not in self._loss_labels: + logger.debug("Adding loss name: '%s'", loss_name) + self._loss_labels.append(loss_name) + logger.debug("Collated loss labels: %s", self._loss_labels) + + @classmethod + def _process_event(cls, event, step): """ Process a single Tensorflow event. Adds timestamp to the step `dict` if a total loss value is received, process the labels for @@ -602,26 +622,5 @@ def _process_event(self, event, step): if summary.tag in ("batch_loss", "batch_total"): # Pre tf2.3 totals were "batch_total" step["timestamp"] = event.wall_time return step - self._process_label(summary.tag) step.setdefault("loss", list()).append(summary.simple_value) return step - - def _process_label(self, label): - """ Check if the incoming loss label exists in :attr:`_loss_labels` and if not, add it. - - Parameters - ---------- - label: str - The label for the currently processing loss event - """ - # tf2.3 stopped respecting loss names in tensorboard callback so rewrite - lbl_split = label.replace("batch_", "").replace("_loss", "").split("_") - if lbl_split[-1] in ("a", "b"): - lbl = f"face_{lbl_split[-1]}" - elif "both" in lbl_split: # Combined decoders don't get face names - lbl = f"face_{'b' if lbl_split[-1] == '1' else 'a'}" - else: - lbl = f"mask_{lbl_split[-2]}" # TODO may not work for combined decoders - if lbl not in self._loss_labels: - logger.debug("Adding loss label: (original: '%s', rewritten: '%s')", label, lbl) - self._loss_labels.append(lbl) diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index 4f63c6e232..1017ecfa6e 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -131,7 +131,8 @@ def initialize_session(self, model_folder, model_name, is_training=False): def stop_training(self): """ Clears the internal training flag. To be called when training completes. """ self._is_training = False - self._tb_logs.set_training(False) + if self._tb_logs is not None: + self._tb_logs.set_training(False) def clear(self): """ Clear the currently loaded session. """ diff --git a/lib/gui/utils.py b/lib/gui/utils.py index cdaeaf89f6..68da171a63 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -585,7 +585,16 @@ def _load_images_to_cache(self, image_files, frame_dims, thumbnail_size): width, height = img.size scaling = thumbnail_size / max(width, height) logger.debug("image width: %s, height: %s, scaling: %s", width, height, scaling) - img = img.resize((int(width * scaling), int(height * scaling))) + + try: + img = img.resize((int(width * scaling), int(height * scaling))) + except OSError as err: + # Image only gets loaded when we call a method, so may error on partial loads + logger.debug("OS Error resizing preview image: '%s'. Original error: %s", + fname, err) + dropped_files.append(fname) + continue + if img.size[0] != img.size[1]: # Pad to square new_img = Image.new("RGB", (thumbnail_size, thumbnail_size)) From c900036a4ecbb77ee4ef7e0fa12abe77d117a5b0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 15 Apr 2021 18:17:05 +0100 Subject: [PATCH 443/981] GUI - Analysis Bugfix - Get correct length of loss labels when carrying over raw data --- lib/gui/analysis/event_reader.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index 3a6a6a1331..12fb9e1e27 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -134,6 +134,7 @@ def __init__(self, session_ids): logger.debug("Initialising: %s: (session_ids: %s)", self.__class__.__name__, session_ids) self._data = {idx: None for idx in session_ids} self._carry_over = dict() + self._loss_labels = [] logger.debug("Initialized: %s", self.__class__.__name__) def is_cached(self, session_id): @@ -161,7 +162,11 @@ def cache_data(self, session_id, data, labels, is_live=False): logger.debug("No data to cache") return - timestamps, loss = self._to_numpy(data, len(labels), is_live) + if labels: + logger.debug("Setting loss labels: %s", labels) + self._loss_labels = labels + + timestamps, loss = self._to_numpy(data, is_live) if not is_live or (is_live and session_id not in self._data): self._data[session_id] = dict(labels=labels, @@ -172,7 +177,7 @@ def cache_data(self, session_id, data, labels, is_live=False): else: self._add_latest_live(session_id, loss, timestamps) - def _to_numpy(self, data, length_loss, is_live): + def _to_numpy(self, data, is_live): """ Extract each individual step data into separate numpy arrays for loss and timestamps. Timestamps are stored float64 as the extra accuracy is needed for correct timings. Arrays @@ -183,8 +188,6 @@ def _to_numpy(self, data, length_loss, is_live): ---------- data: dict The incoming tensorflow event data in dictionary form per step - length_loss: int - The number of loss items that should appear for each step is_live: bool, optional ``True`` if the data to be cached is from a live training session otherwise ``False``. Default: ``False`` @@ -202,7 +205,7 @@ def _to_numpy(self, data, length_loss, is_live): times, loss = zip(*[(data[idx].get("timestamp"), data[idx].get("loss", [])) for idx in sorted(data)]) - times, loss = self._process_data(data, times, loss, length_loss, is_live) + times, loss = self._process_data(data, times, loss, is_live) times, loss = (np.array(times, dtype="float64"), np.array(loss, dtype="float32")) @@ -228,7 +231,7 @@ def _collect_carry_over(self, data): update.setdefault("loss", []).extend(carry_over.get("loss", [])) logger.debug("Merged carry over data: %s", update) - def _process_data(self, data, timestamps, loss, length_loss, is_live): + def _process_data(self, data, timestamps, loss, is_live): """ Process live update data. Live data requires different processing as often we will only have partial data for the @@ -244,8 +247,6 @@ def _process_data(self, data, timestamps, loss, length_loss, is_live): The raw timestamps for for the latest live query, including any partial reads loss: tuple The raw loss for for the latest live query, including any partial reads - length_loss: int - The number of loss items that should appear for each step is_live: bool ``True`` if the data to be cached is from a live training session otherwise ``False``. @@ -259,11 +260,11 @@ def _process_data(self, data, timestamps, loss, length_loss, is_live): loss = list(loss) timestamps = list(timestamps) - if len(loss[-1]) != length_loss: + if len(loss[-1]) != len(self._loss_labels): logger.debug("Truncated loss found. loss count: %s", len(loss)) idx = sorted(data)[-1] if is_live: - logger.debug("Setting carried over data: %s", data) + logger.debug("Setting carried over data: %s", data[idx]) self._carry_over[idx] = data[idx] logger.debug("Removing truncated loss: (timestamp: %s, loss: %s)", timestamps[-1], loss[-1]) From a68a9eda4fb8fc47def264faa6137d785781cd28 Mon Sep 17 00:00:00 2001 From: pfakanator <59480490+pfakanator@users.noreply.github.com> Date: Mon, 19 Apr 2021 05:48:46 -0400 Subject: [PATCH 444/981] Introduce sorting by FFT filtered blur detection. (#1147) * Update cli.py * Update sort.py --- tools/sort/cli.py | 5 +-- tools/sort/sort.py | 89 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/tools/sort/cli.py b/tools/sort/cli.py index a984d58afe..79ab4ad9d0 100644 --- a/tools/sort/cli.py +++ b/tools/sort/cli.py @@ -43,7 +43,7 @@ def get_argument_list(): opts=('-s', '--sort-by'), action=Radio, type=str, - choices=("blur", "face", "face-cnn", "face-cnn-dissim", "face-yaw", "hist", + choices=("blur", "blur-fft", "face", "face-cnn", "face-cnn-dissim", "face-yaw", "hist", "hist-dissim", "color-gray", "color-luma", "color-green", "color-orange", "size"), dest='sort_method', @@ -51,6 +51,7 @@ def get_argument_list(): default="face", help=_("R|Sort by method. Choose how images are sorted. " "\nL|'blur': Sort faces by blurriness." + "\nL|'blur-fft': Sort faces by fft filtered blurriness." "\nL|'face': Use VGG Face to sort by face similarity. This uses a pairwise " "clustering algorithm to check the distances between 512 features on every " "face in your set and order them appropriately." @@ -116,7 +117,7 @@ def get_argument_list(): opts=('-g', '--group-by'), action=Radio, type=str, - choices=("blur", "face-cnn", "face-yaw", "hist"), + choices=("blur", "blur-fft", "face-cnn", "face-yaw", "hist"), dest='group_method', group=_("output"), default="hist", diff --git a/tools/sort/sort.py b/tools/sort/sort.py index dfa740e6f5..b4cc4140f8 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -27,6 +27,7 @@ class Sort(): """ Sorts folders of faces based on input criteria """ # pylint: disable=no-member + def __init__(self, arguments): self._args = arguments self.changes = None @@ -163,6 +164,18 @@ def sort_blur(self): logger.info("Sorting...") return sorted(blurs, key=lambda x: x[1], reverse=True) + def sort_blur_fft(self): + """ Sort by fft filtered blur amount with fft""" + logger.info("Sorting by estimated fft filtered image blur...") + + fft_blurs = [(filename, self.estimate_blur_fft(image, metadata)) + for filename, image, metadata in tqdm(self._loader.load(), + desc="Estimating fft blur score", + total=self._loader.count, + leave=False)] + logger.info("Sorting...") + return sorted(fft_blurs, key=lambda x: x[1], reverse=True) + def sort_face(self): """ Sort by identity similarity """ logger.info("Sorting by identity similarity...") @@ -390,6 +403,30 @@ def group_blur(self, img_list): return bins + def group_blur_fft(self, img_list): + """ Group into bins by fft blur score""" + # Starting the binning process + num_bins = self._args.num_bins + + # The last bin will get all extra images if it's + # not possible to distribute them evenly + num_per_bin = len(img_list) // num_bins + remainder = len(img_list) % num_bins + + logger.info("Grouping by fft blur score...") + bins = [[] for _ in range(num_bins)] + idx = 0 + for i in range(num_bins): + for _ in range(num_per_bin): + bins[i].append(img_list[idx][0]) + idx += 1 + + # If remainder is 0, nothing gets added to the last bin. + for i in range(1, remainder + 1): + bins[-1].append(img_list[-i][0]) + + return bins + def group_face_cnn(self, img_list): """ Group into bins by CNN face similarity """ logger.info("Grouping by face-cnn similarity...") @@ -598,6 +635,10 @@ def reload_images(self, group_method, img_list): filename_list, image_list = self._get_images() blurs = [self.estimate_blur(img) for img in image_list] temp_list = list(zip(filename_list, blurs)) + if group_method == 'group_blur_fft': + filename_list, image_list = self._get_images() + fft_blurs = [self.estimate_blur_fft(img) for img in image_list] + temp_list = list(zip(filename_list, fft_blurs)) elif group_method == 'group_face_cnn': filename_list, image_list, landmarks = self._get_landmarks() temp_list = list(zip(filename_list, landmarks)) @@ -711,6 +752,54 @@ def estimate_blur(cls, image, metadata=None): score = np.var(blur_map) / np.sqrt(image.shape[0] * image.shape[1]) return score + @classmethod + def estimate_blur_fft(cls, image, metadata=None): + """ Estimate the amount of blur a fft filtered image has. + + Parameters + ---------- + image: :class:`numpy.ndarray` + Use Fourier Transform to analyze the frequency characteristics of the masked + face using 2D Discrete Fourier Transform (DFT) filter to find the frequency domain. + A mean value is assigned to the magnitude spectrum and returns a blur score. + Adapted from https://www.pyimagesearch.com/2020/06/15/ + opencv-fast-fourier-transform-fft-for-blur-detection-in-images-and-video-streams/ + metadata: dict, optional + The metadata for the face image or ``None`` if no metadata is available. If metadata is + provided the face will be masked by the "components" mask prior to calculating blur. + Default:``None`` + + Returns + ------- + float + The estimated fft blur score for the face + """ + if metadata is not None: + alignments = metadata["alignments"] + det_face = DetectedFace() + det_face.from_png_meta(alignments) + aln_face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), + image=image, + centering="legacy", + size=256, + is_aligned=True) + mask = det_face.mask["components"] + mask.set_sub_crop(aln_face.pose.offset["face"] * -1) + mask = cv2.resize(mask.mask, (256, 256), interpolation=cv2.INTER_CUBIC)[..., None] + image = np.minimum(aln_face.face, mask) + if image.ndim == 3: + image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + height, width = image.shape + c_height, c_width = (int(height / 2.0), int(width / 2.0)) + fft = np.fft.fft2(image) + fft_shift = np.fft.fftshift(fft) + fft_shift[c_height - 75:c_height + 75, c_width - 75:c_width + 75] = 0 + ifft_shift = np.fft.ifftshift(fft_shift) + shift_back = np.fft.ifft2(ifft_shift) + magnitude = np.log(np.abs(shift_back)) + score = np.mean(magnitude) + return score + @staticmethod def calc_landmarks_face_pitch(flm): """ UNUSED - Calculate the amount of pitch in a face """ From e0a98e9b6914cf8f5ca911ab60a02be36d7df8b5 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 20 Apr 2021 13:56:03 +0100 Subject: [PATCH 445/981] Training bugfixes: - lib.training.generator - Fix duplicate "legacy faceset" warning - Fix missing mask error message - gui: Fix bug in live stats when resuming an old session --- lib/gui/analysis/event_reader.py | 21 +++++++++++++++++---- lib/training/generator.py | 8 +++++--- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index 12fb9e1e27..3c95014cdd 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -131,7 +131,7 @@ class _Cache(): List of `ints` pertaining to the session ids that exist in the Tensorflow events folder """ def __init__(self, session_ids): - logger.debug("Initialising: %s: (session_ids: %s)", self.__class__.__name__, session_ids) + logger.debug("Initializing: %s: (session_ids: %s)", self.__class__.__name__, session_ids) self._data = {idx: None for idx in session_ids} self._carry_over = dict() self._loss_labels = [] @@ -168,7 +168,7 @@ def cache_data(self, session_id, data, labels, is_live=False): timestamps, loss = self._to_numpy(data, is_live) - if not is_live or (is_live and session_id not in self._data): + if not is_live or (is_live and not self._data.get(session_id, None)): self._data[session_id] = dict(labels=labels, loss=zlib.compress(loss), loss_shape=loss.shape, @@ -222,7 +222,12 @@ def _collect_carry_over(self, data): data: dict The latest raw data dictionary """ + logger.debug("Carry over keys: %s, data keys: %s", list(self._carry_over), list(data)) for key in list(self._carry_over): + if key not in data: + logger.debug("Carry over found for item %s which does not exist in current " + "data: %s. Skipping.", key, list(data)) + continue carry_over = self._carry_over.pop(key) update = data[key] logger.debug("Merging carry over data: %s in to %s", carry_over, update) @@ -359,10 +364,17 @@ class TensorBoardLogs(): ``True`` if the events are being read whilst Faceswap is training otherwise ``False`` """ def __init__(self, logs_folder, is_training): - self._is_training = is_training + logger.debug("Initializing: %s: (logs_folder: %s, is_training: %s)", + self.__class__.__name__, logs_folder, is_training) + self._is_training = False + self._training_iterator = None + self._log_files = _LogFiles(logs_folder) + self.set_training(is_training) + self._cache = _Cache(self.session_ids) - self._training_iterator = None + + logger.debug("Initialized: %s", self.__class__.__name__) @property def session_ids(self): @@ -381,6 +393,7 @@ def set_training(self, is_training): session otherwise ``False`` """ if self._is_training == is_training: + logger.debug("Training flag already set to %s. Returning", is_training) return logger.debug("Setting is_training to %s", is_training) diff --git a/lib/training/generator.py b/lib/training/generator.py index 4473ef2089..f19f16db2d 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -306,8 +306,10 @@ def _reset_cache(self, set_flag): a legacy face set/centering mismatch. ``False`` if the cache is being reset because it has detected a reset flag from the opposite cache. """ - logger.warning("You are using legacy extracted faces but have selected '%s' centering " - "which is incompatible. Switching centering to 'legacy'", self._centering) + if set_flag: + logger.warning("You are using legacy extracted faces but have selected '%s' centering " + "which is incompatible. Switching centering to 'legacy'", + self._centering) self._config["centering"] = "legacy" self._centering = "legacy" self._cache = {key: dict(cached=False) for key in self._cache} @@ -377,7 +379,7 @@ def _add_mask(self, filename, detected_face): "You have selected the mask type '{}' but at least one face does not contain the " "selected mask.\nThe face that failed was: '{}'\nThe masks that exist for this " "face are: {}".format( - self._config["mask_type"], filename, list(detected_face.mask.keys))) + self._config["mask_type"], filename, list(detected_face.mask))) key = os.path.basename(filename) mask = detected_face.mask[self._config["mask_type"]] From 91a6a502555723e114a2b982b26f410b4e930903 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 24 Apr 2021 12:49:38 +0100 Subject: [PATCH 446/981] Training Bugfix - Unfreeze weights when loading a previously frozen model --- plugins/train/model/_base.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 927e61a2c5..13c9ca03cb 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -942,6 +942,13 @@ def _check_weights_file(cls, weights_file): def freeze(self): """ If freeze has been selected in the cli arguments, then freeze those models indicated in the plugin's configuration. """ + # Blanket unfreeze layers, as checking the value of :attr:`layer.trainable` appears to + # return ``True`` even when the weights have been frozen + # TODO this may cause some issues with some keras-app models that are meant to have some + # frozen layers + for layer in _get_all_sub_models(self._model): + layer.trainable = True + if not self._do_freeze: logger.debug("Freeze weights deselected. Not freezing") return From 3092d1421c38585132241e15cb735a67dcc6c58d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 1 May 2021 11:09:14 +0100 Subject: [PATCH 447/981] GUI Bugfix - Handle underscores in config options correctly --- lib/gui/control_helper.py | 7 ++++--- plugins/train/model/_base.py | 2 -- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 619c721c05..09524be57b 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -1045,9 +1045,10 @@ def _get_multi_help_items(helptext): intro = "" if any(line.startswith(" - ") for line in all_help): intro = all_help[0] - retval = (intro, {re.sub(r'[^A-Za-z0-9\-]+', '', - line.split()[1].lower()): " ".join(line.split()[1:]) - for line in all_help if line.startswith(" - ")}) + retval = (intro, + {re.sub(r"[^A-Za-z0-9\-\_]+", "", + line.split()[1].lower()): " ".join(line.replace("_", " ").split()[1:]) + for line in all_help if line.startswith(" - ")}) logger.debug("help items: %s", retval) return retval diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 13c9ca03cb..407d30cfc6 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -944,8 +944,6 @@ def freeze(self): in the plugin's configuration. """ # Blanket unfreeze layers, as checking the value of :attr:`layer.trainable` appears to # return ``True`` even when the weights have been frozen - # TODO this may cause some issues with some keras-app models that are meant to have some - # frozen layers for layer in _get_all_sub_models(self._model): layer.trainable = True From 8ec4b4fb82734c7b943e8f624c32f194119175ca Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 4 May 2021 10:07:41 +0000 Subject: [PATCH 448/981] bugfix: GUI - Correctly handle shutdown when closing during a running task --- scripts/gui.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/gui.py b/scripts/gui.py index 4f7199c0cb..46e77c31a9 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -168,9 +168,9 @@ def _confirm_close_on_running_task(self): confirmtxt = "Processes are still running.\n\nAre you sure you want to exit?" if not messagebox.askokcancel("Close", confirmtxt, default="cancel", icon="warning"): logger.debug("Close Cancelled") - return True + return False logger.debug("Close confirmed") - return False + return True class Gui(): # pylint: disable=too-few-public-methods From 51705fadb06c6376e35b42c00c48586fc97b9299 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 5 May 2021 16:47:26 +0100 Subject: [PATCH 449/981] lib.model.losses_tf - Add multiplier bug catching code --- lib/model/losses_tf.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/lib/model/losses_tf.py b/lib/model/losses_tf.py index d8bd29d52b..cbd0345ac0 100644 --- a/lib/model/losses_tf.py +++ b/lib/model/losses_tf.py @@ -581,15 +581,26 @@ def _apply_mask(cls, y_true, y_pred, mask_channel, mask_prop=1.0): tuple (n_true, n_pred): The ground truth and predicted value tensors with the mask applied """ - if mask_channel == -1: - logger.debug("No mask to apply") - return y_true[..., :3], y_pred[..., :3] + try: + if mask_channel == -1: + logger.debug("No mask to apply") + return y_true[..., :3], y_pred[..., :3] + + logger.debug("Applying mask from channel %s", mask_channel) + mask = K.expand_dims(y_true[..., mask_channel], axis=-1) + mask_as_k_inv_prop = 1 - mask_prop + mask = (mask * mask_prop) + mask_as_k_inv_prop + + n_true = K.concatenate([y_true[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) + n_pred = K.concatenate([y_pred[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) + + except: + logger.error("You have hit a bug which is being actively tracked by the developer.") + logger.error("Please provide the following information so it can be fixed.") + logger.error("y_true: %s, %s, y_pred: %s, %s, mask_channel: %s, mask_prop: %s", + K.int_shape(y_true), K.dtype(y_true), K.int_shape(y_pred), + K.dtype(y_pred), mask_channel, mask_prop) + raise - logger.debug("Applying mask from channel %s", mask_channel) - mask = K.expand_dims(y_true[..., mask_channel], axis=-1) - mask_as_k_inv_prop = 1 - mask_prop - mask = (mask * mask_prop) + mask_as_k_inv_prop - n_true = K.concatenate([y_true[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) - n_pred = K.concatenate([y_pred[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) return n_true, n_pred From 29beb7a23dbd467c59a3fdb49c3cd34e66d63561 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 5 May 2021 16:40:20 +0000 Subject: [PATCH 450/981] bugfix - Fix race condition when WTL is selected --- lib/training/generator.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/training/generator.py b/lib/training/generator.py index f19f16db2d..c5c590e870 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -129,8 +129,11 @@ def extract_version(self): def aligned_landmarks(self): """ dict: The filename as key, aligned landmarks as value """ if self._aligned_landmarks is None: - self._aligned_landmarks = {key: val["aligned_face"].landmarks - for key, val in self._cache.items()} + with self._lock: + # For Warp-To-Landmarks a race condition can occur where this is referenced from + # the opposite side prior to it being populated, so block on a lock. + self._aligned_landmarks = {key: val["aligned_face"].landmarks + for key, val in self._cache.items()} return self._aligned_landmarks @property From 3301c1ee29069bc92a44642d68586742ca38cfae Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 6 May 2021 11:02:15 +0000 Subject: [PATCH 451/981] Bugfix - Training - Fix eye/mouth multiplier for some models --- lib/model/losses_tf.py | 30 ++++++++++-------------------- lib/training/generator.py | 2 +- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/lib/model/losses_tf.py b/lib/model/losses_tf.py index cbd0345ac0..ebc85faeb5 100644 --- a/lib/model/losses_tf.py +++ b/lib/model/losses_tf.py @@ -581,26 +581,16 @@ def _apply_mask(cls, y_true, y_pred, mask_channel, mask_prop=1.0): tuple (n_true, n_pred): The ground truth and predicted value tensors with the mask applied """ - try: - if mask_channel == -1: - logger.debug("No mask to apply") - return y_true[..., :3], y_pred[..., :3] - - logger.debug("Applying mask from channel %s", mask_channel) - mask = K.expand_dims(y_true[..., mask_channel], axis=-1) - mask_as_k_inv_prop = 1 - mask_prop - mask = (mask * mask_prop) + mask_as_k_inv_prop - - n_true = K.concatenate([y_true[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) - n_pred = K.concatenate([y_pred[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) - - except: - logger.error("You have hit a bug which is being actively tracked by the developer.") - logger.error("Please provide the following information so it can be fixed.") - logger.error("y_true: %s, %s, y_pred: %s, %s, mask_channel: %s, mask_prop: %s", - K.int_shape(y_true), K.dtype(y_true), K.int_shape(y_pred), - K.dtype(y_pred), mask_channel, mask_prop) - raise + if mask_channel == -1: + logger.debug("No mask to apply") + return y_true[..., :3], y_pred[..., :3] + logger.debug("Applying mask from channel %s", mask_channel) + mask = K.expand_dims(y_true[..., mask_channel], axis=-1) + mask_as_k_inv_prop = 1 - mask_prop + mask = (mask * mask_prop) + mask_as_k_inv_prop + + n_true = K.concatenate([y_true[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) + n_pred = K.concatenate([y_pred[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) return n_true, n_pred diff --git a/lib/training/generator.py b/lib/training/generator.py index c5c590e870..8a1c77ece0 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -645,7 +645,7 @@ def _process_batch(self, filenames, side): # Switch color order for RGB models if self._color_order == "rgb": - batch = batch[..., [2, 1, 0, 3]] + batch[..., :3] = batch[..., [2, 1, 0]] # Add samples to output if this is for display if self._processing.is_display: From eb0cc9c271bf89049182fc052e4734c5f793abc8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 8 May 2021 11:39:39 +0100 Subject: [PATCH 452/981] alignments tool - Update alignments file tooltip --- .../es/LC_MESSAGES/tools.alignments.cli.mo | Bin 7214 -> 7049 bytes .../es/LC_MESSAGES/tools.alignments.cli.po | 62 ++++++++---------- locales/tools.alignments.cli.pot | 36 +++++----- tools/alignments/cli.py | 3 +- 4 files changed, 48 insertions(+), 53 deletions(-) diff --git a/locales/es/LC_MESSAGES/tools.alignments.cli.mo b/locales/es/LC_MESSAGES/tools.alignments.cli.mo index 0ad8f39c1dfed1c5934acf36bb7c80362695f534..3b57d5a3c2ddf1e1d176d233bc5bc2b452bbbe76 100644 GIT binary patch delta 368 zcmXZXJxjw-6b9fo{qQ52#Nr@rK;t6#5lO72CD5Tm9VAW`0vVKmB~VIBn-&KLaTF9P z0XI9iIVeIVogC|0|As>UfZ`kRF3&xOa}V!*DqR)_C+8-BuO#54ft3`nCIF|izybS1 z5%{2=^p3vG0Xy_)9^fc$=rp~h9Kn$8@%;l`XFZVso91MoLH&Qnw9LA!z{fi;X9}Rx z>H<)s0WHuGeVqb+sKdoCOMuIMCd<&YNypU90TrsN0RMqDWmfcQ9PmO7_l1pYoH0Ie zYw_;}a61rQ3u>C7AVrTtClNJLqnXu$Zj?0BwDQGG!!!)h_JW>X>w2xA>Gj%OOA9Mf oPO-g~-}n5wrPYJu>S5?ggXHdZKKz!p!e@Coa^y)Oy3bsRzkx43fB*mh delta 561 zcmXw!KWG#|6vn@ciJCKaxg-)ya)eg}qj!)$Mb3p3f)>hIE*gn*@`=(mTy<~Q$~H#5I^yz#o#_l^z${5%6}j{$q5 zz)}e~9tZ9)KP>}a$<_tnIXN)_tdTECj^Z16j{Ht?6sP13p8rKIvv;aMoAk(sw{FI3fR#*U6Iw zfd9b7IzVU4lZD_?J-=PLGFRcUs3M}uuy{LW@~5SRvEm09yejRFwB=S;LY?@=`l15k z+fdsqZAiAOt%SWGGjX7$YXY5$tE}v3Np+yzOnVJUW91fotP;gwusHhZ{N=Ug>NQ#2 zaMoK_S6Jn?vVKqby4lexbR>)RR5!9-l&@6!Gwa+=vt}nzHVsr3C646Bl}7FVAxCuj z;O162|59DdKUBvDv(+CX_hifPRdJ(m$5={H+-l{T$ZD!{C+=yF{zKCuR*4*_#6+p6 b#N9Oq(QvHL_L#*{!e{sw4h(+Ro|OLrlmvx- diff --git a/locales/es/LC_MESSAGES/tools.alignments.cli.po b/locales/es/LC_MESSAGES/tools.alignments.cli.po index 81eb49064e..7d421a266c 100644 --- a/locales/es/LC_MESSAGES/tools.alignments.cli.po +++ b/locales/es/LC_MESSAGES/tools.alignments.cli.po @@ -5,17 +5,17 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-02-18 23:43-0000\n" -"PO-Revision-Date: 2021-02-19 17:38+0000\n" +"POT-Creation-Date: 2021-05-08 11:34+0100\n" +"PO-Revision-Date: 2021-05-08 11:37+0100\n" +"Last-Translator: \n" "Language-Team: tokafondo\n" +"Language: es_ES\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 2.3\n" -"Last-Translator: \n" +"X-Generator: Poedit 2.4.3\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -"Language: es_ES\n" #: tools/alignments/cli.py:14 msgid "" @@ -64,7 +64,11 @@ msgstr "" msgid " Use the output option (-o) to process results." msgstr " Usar la opción de salida (-o) para procesar los resultados." -#: tools/alignments/cli.py:42 +#: tools/alignments/cli.py:41 tools/alignments/cli.py:73 +msgid "processing" +msgstr "proceso" + +#: tools/alignments/cli.py:43 msgid "" "R|Choose which action you want to perform. NB: All actions require an " "alignments file (-a) to be passed in.\n" @@ -121,35 +125,8 @@ msgstr "" "L|'spatial': Realiza un filtrado espacial y temporal para suavizar las " "alineaciones (¡EXPERIMENTAL!)" -#: tools/alignments/cli.py:72 tools/alignments/cli.py:81 -#: tools/alignments/cli.py:88 -msgid "data" -msgstr "datos" - #: tools/alignments/cli.py:75 msgid "" -"Full path to the alignments file to be processed. If merging alignments, " -"then multiple files can be selected, space separated" -msgstr "" -"Ruta completa del archivo de alineaciones a procesar. Si se combinan " -"alineaciones, se pueden seleccionar varios archivos, separados por espacios" - -#: tools/alignments/cli.py:82 -msgid "Directory containing extracted faces." -msgstr "Directorio que contiene las caras extraídas." - -#: tools/alignments/cli.py:89 -msgid "Directory containing source frames that faces were extracted from." -msgstr "" -"Directorio que contiene los fotogramas de origen de los que se extrajeron " -"las caras." - -#: tools/alignments/cli.py:95 -msgid "processing" -msgstr "proceso" - -#: tools/alignments/cli.py:97 -msgid "" "R|How to output discovered items ('faces' and 'frames' only):\n" "L|'console': Print the list of frames to the screen. (DEFAULT)\n" "L|'file': Output the list of frames to a text file (stored within the source " @@ -164,6 +141,25 @@ msgstr "" "L|'move': Mueve los elementos descubiertos a una subcarpeta dentro del " "directorio de origen." +#: tools/alignments/cli.py:86 tools/alignments/cli.py:94 +#: tools/alignments/cli.py:101 +msgid "data" +msgstr "datos" + +#: tools/alignments/cli.py:89 +msgid "Full path to the alignments file to be processed." +msgstr "Ruta completa del archivo de alineaciones a procesar." + +#: tools/alignments/cli.py:95 +msgid "Directory containing extracted faces." +msgstr "Directorio que contiene las caras extraídas." + +#: tools/alignments/cli.py:102 +msgid "Directory containing source frames that faces were extracted from." +msgstr "" +"Directorio que contiene los fotogramas de origen de los que se extrajeron " +"las caras." + #: tools/alignments/cli.py:111 tools/alignments/cli.py:121 #: tools/alignments/cli.py:127 msgid "extract" diff --git a/locales/tools.alignments.cli.pot b/locales/tools.alignments.cli.pot index 34ae7a73b4..9c82411ec9 100644 --- a/locales/tools.alignments.cli.pot +++ b/locales/tools.alignments.cli.pot @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-02-18 23:43-0000\n" +"POT-Creation-Date: 2021-05-08 11:34+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -45,7 +45,11 @@ msgstr "" msgid " Use the output option (-o) to process results." msgstr "" -#: tools/alignments/cli.py:42 +#: tools/alignments/cli.py:41 tools/alignments/cli.py:73 +msgid "processing" +msgstr "" + +#: tools/alignments/cli.py:43 msgid "" "R|Choose which action you want to perform. NB: All actions require an alignments file (-a) to be passed in.\n" "L|'draw': Draw landmarks on frames in the selected folder/video. A subfolder will be created within the frames folder to hold the output.{0}\n" @@ -60,33 +64,29 @@ msgid "" "L|'spatial': Perform spatial and temporal filtering to smooth alignments (EXPERIMENTAL!)" msgstr "" -#: tools/alignments/cli.py:72 tools/alignments/cli.py:81 -#: tools/alignments/cli.py:88 -msgid "data" -msgstr "" - #: tools/alignments/cli.py:75 -msgid "Full path to the alignments file to be processed. If merging alignments, then multiple files can be selected, space separated" +msgid "" +"R|How to output discovered items ('faces' and 'frames' only):\n" +"L|'console': Print the list of frames to the screen. (DEFAULT)\n" +"L|'file': Output the list of frames to a text file (stored within the source directory).\n" +"L|'move': Move the discovered items to a sub-folder within the source directory." msgstr "" -#: tools/alignments/cli.py:82 -msgid "Directory containing extracted faces." +#: tools/alignments/cli.py:86 tools/alignments/cli.py:94 +#: tools/alignments/cli.py:101 +msgid "data" msgstr "" #: tools/alignments/cli.py:89 -msgid "Directory containing source frames that faces were extracted from." +msgid "Full path to the alignments file to be processed." msgstr "" #: tools/alignments/cli.py:95 -msgid "processing" +msgid "Directory containing extracted faces." msgstr "" -#: tools/alignments/cli.py:97 -msgid "" -"R|How to output discovered items ('faces' and 'frames' only):\n" -"L|'console': Print the list of frames to the screen. (DEFAULT)\n" -"L|'file': Output the list of frames to a text file (stored within the source directory).\n" -"L|'move': Move the discovered items to a sub-folder within the source directory." +#: tools/alignments/cli.py:102 +msgid "Directory containing source frames that faces were extracted from." msgstr "" #: tools/alignments/cli.py:111 tools/alignments/cli.py:121 diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py index be165229c9..c1d3708f51 100644 --- a/tools/alignments/cli.py +++ b/tools/alignments/cli.py @@ -86,8 +86,7 @@ def get_argument_list(self): group=_("data"), required=True, filetypes="alignments", - help=_("Full path to the alignments file to be processed. If merging alignments, then " - "multiple files can be selected, space separated"))) + help=_("Full path to the alignments file to be processed."))) argument_list.append(dict( opts=("-fc", "-faces_folder"), action=DirFullPaths, From cc5bc8743fd55d563dfcc0f22f9eb72aabc78884 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 8 May 2021 13:31:49 +0100 Subject: [PATCH 453/981] GUI - Plugin preset support - Add ability to load and save presets for all existing faceswap plugins - Add phaze-a presets --- .gitignore | 12 ++ lib/gui/.cache/presets/convert/.keep | 0 lib/gui/.cache/presets/extract/.keep | 0 lib/gui/.cache/presets/gui/.keep | 0 .../train/model_phaze_a_dfaker_preset.json | 48 +++++ .../train/model_phaze_a_dfl-h128_preset.json | 48 +++++ .../model_phaze_a_dfl-sae-df_preset.json | 48 +++++ .../model_phaze_a_dfl-sae-liae_preset.json | 48 +++++ .../model_phaze_a_dfl-saehd-df_preset.json | 48 +++++ .../model_phaze_a_dfl-saehd-liae_preset.json | 48 +++++ .../train/model_phaze_a_iae_preset.json | 48 +++++ .../model_phaze_a_lightweight_preset.json | 48 +++++ .../train/model_phaze_a_original_preset.json | 48 +++++ .../train/model_phaze_a_stojo_preset.json | 48 +++++ lib/gui/popup_configure.py | 189 +++++++++++++++++- lib/gui/utils.py | 62 ++++-- 16 files changed, 721 insertions(+), 22 deletions(-) create mode 100644 lib/gui/.cache/presets/convert/.keep create mode 100644 lib/gui/.cache/presets/extract/.keep create mode 100644 lib/gui/.cache/presets/gui/.keep create mode 100644 lib/gui/.cache/presets/train/model_phaze_a_dfaker_preset.json create mode 100644 lib/gui/.cache/presets/train/model_phaze_a_dfl-h128_preset.json create mode 100644 lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-df_preset.json create mode 100644 lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-liae_preset.json create mode 100644 lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-df_preset.json create mode 100644 lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-liae_preset.json create mode 100644 lib/gui/.cache/presets/train/model_phaze_a_iae_preset.json create mode 100644 lib/gui/.cache/presets/train/model_phaze_a_lightweight_preset.json create mode 100644 lib/gui/.cache/presets/train/model_phaze_a_original_preset.json create mode 100644 lib/gui/.cache/presets/train/model_phaze_a_stojo_preset.json diff --git a/.gitignore b/.gitignore index 1f62f3051b..e0d427e086 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,15 @@ !tools/ !tools/**/ !tools/**/*.py + +# GUI Plugin Presets +!lib/gui/**/presets/train/model_phaze_a_dfaker_preset.json +!lib/gui/**/presets/train/model_phaze_a_dfl-h128_preset.json +!lib/gui/**/presets/train/model_phaze_a_dfl-sae-df_preset.json +!lib/gui/**/presets/train/model_phaze_a_dfl-sae-liae_preset.json +!lib/gui/**/presets/train/model_phaze_a_dfl-saehd-df_preset.json +!lib/gui/**/presets/train/model_phaze_a_dfl-saehd-liae_preset.json +!lib/gui/**/presets/train/model_phaze_a_iae_preset.json +!lib/gui/**/presets/train/model_phaze_a_lightweight_preset.json +!lib/gui/**/presets/train/model_phaze_a_original_preset.json +!lib/gui/**/presets/train/model_phaze_a_stojo_preset.json diff --git a/lib/gui/.cache/presets/convert/.keep b/lib/gui/.cache/presets/convert/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/gui/.cache/presets/extract/.keep b/lib/gui/.cache/presets/extract/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/gui/.cache/presets/gui/.keep b/lib/gui/.cache/presets/gui/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfaker_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfaker_preset.json new file mode 100644 index 0000000000..008581930a --- /dev/null +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfaker_preset.json @@ -0,0 +1,48 @@ +{ + "output_size": 128, + "shared_fc": "none", + "enable_gblock": false, + "split_fc": false, + "split_gblock": false, + "split_decoders": true, + "enc_architecture": "fs_original", + "enc_scaling": 40, + "enc_load_weights": false, + "bottleneck_type": "dense", + "bottleneck_norm": "none", + "bottleneck_size": 1024, + "bottleneck_in_encoder": true, + "fc_depth": 1, + "fc_min_filters": 1024, + "fc_max_filters": 1024, + "fc_dimensions": 4, + "fc_filter_slope": -0.5, + "fc_dropout": 0.0, + "fc_upsampler": "subpixel", + "fc_upsamples": 1, + "fc_upsample_filters": 512, + "fc_gblock_depth": 3, + "fc_gblock_min_nodes": 512, + "fc_gblock_max_nodes": 512, + "fc_gblock_filter_slope": -0.5, + "fc_gblock_dropout": 0.0, + "dec_upscale_method": "subpixel", + "dec_norm": "none", + "dec_min_filters": 64, + "dec_max_filters": 512, + "dec_filter_slope": -0.45, + "dec_res_blocks": 1, + "dec_output_kernel": 5, + "dec_gaussian": false, + "dec_skip_last_residual": true, + "freeze_layers": "encoder", + "load_layers": "encoder", + "fs_original_depth": 4, + "fs_original_min_filters": 128, + "fs_original_max_filters": 1024, + "mobilenet_width": 1.0, + "mobilenet_depth": 1, + "mobilenet_dropout": 0.001, + "__filetype": "faceswap_preset", + "__section": "train|model|phaze_a" +} \ No newline at end of file diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-h128_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-h128_preset.json new file mode 100644 index 0000000000..359cf3d803 --- /dev/null +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-h128_preset.json @@ -0,0 +1,48 @@ +{ + "output_size": 128, + "shared_fc": "none", + "enable_gblock": false, + "split_fc": false, + "split_gblock": false, + "split_decoders": true, + "enc_architecture": "fs_original", + "enc_scaling": 80, + "enc_load_weights": false, + "bottleneck_type": "dense", + "bottleneck_norm": "none", + "bottleneck_size": 512, + "bottleneck_in_encoder": true, + "fc_depth": 1, + "fc_min_filters": 512, + "fc_max_filters": 512, + "fc_dimensions": 8, + "fc_filter_slope": -0.5, + "fc_dropout": 0.0, + "fc_upsampler": "subpixel", + "fc_upsamples": 1, + "fc_upsample_filters": 512, + "fc_gblock_depth": 3, + "fc_gblock_min_nodes": 512, + "fc_gblock_max_nodes": 512, + "fc_gblock_filter_slope": -0.5, + "fc_gblock_dropout": 0.0, + "dec_upscale_method": "subpixel", + "dec_norm": "none", + "dec_min_filters": 128, + "dec_max_filters": 512, + "dec_filter_slope": -0.33, + "dec_res_blocks": 0, + "dec_output_kernel": 5, + "dec_gaussian": false, + "dec_skip_last_residual": false, + "freeze_layers": "encoder", + "load_layers": "encoder", + "fs_original_depth": 4, + "fs_original_min_filters": 128, + "fs_original_max_filters": 1024, + "mobilenet_width": 1.0, + "mobilenet_depth": 1, + "mobilenet_dropout": 0.001, + "__filetype": "faceswap_preset", + "__section": "train|model|phaze_a" +} \ No newline at end of file diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-df_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-df_preset.json new file mode 100644 index 0000000000..b828facafa --- /dev/null +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-df_preset.json @@ -0,0 +1,48 @@ +{ + "output_size": 128, + "shared_fc": "none", + "enable_gblock": false, + "split_fc": false, + "split_gblock": false, + "split_decoders": true, + "enc_architecture": "fs_original", + "enc_scaling": 80, + "enc_load_weights": false, + "bottleneck_type": "dense", + "bottleneck_norm": "none", + "bottleneck_size": 512, + "bottleneck_in_encoder": true, + "fc_depth": 1, + "fc_min_filters": 512, + "fc_max_filters": 512, + "fc_dimensions": 8, + "fc_filter_slope": -0.5, + "fc_dropout": 0.0, + "fc_upsampler": "subpixel", + "fc_upsamples": 1, + "fc_upsample_filters": 512, + "fc_gblock_depth": 3, + "fc_gblock_min_nodes": 512, + "fc_gblock_max_nodes": 512, + "fc_gblock_filter_slope": -0.5, + "fc_gblock_dropout": 0.0, + "dec_upscale_method": "subpixel", + "dec_norm": "none", + "dec_min_filters": 128, + "dec_max_filters": 504, + "dec_filter_slope": -0.33, + "dec_res_blocks": 2, + "dec_output_kernel": 5, + "dec_gaussian": false, + "dec_skip_last_residual": false, + "freeze_layers": "encoder", + "load_layers": "encoder", + "fs_original_depth": 4, + "fs_original_min_filters": 126, + "fs_original_max_filters": 1008, + "mobilenet_width": 1.0, + "mobilenet_depth": 1, + "mobilenet_dropout": 0.001, + "__filetype": "faceswap_preset", + "__section": "train|model|phaze_a" +} \ No newline at end of file diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-liae_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-liae_preset.json new file mode 100644 index 0000000000..f76389a012 --- /dev/null +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-liae_preset.json @@ -0,0 +1,48 @@ +{ + "output_size": 128, + "shared_fc": "half", + "enable_gblock": false, + "split_fc": true, + "split_gblock": false, + "split_decoders": false, + "enc_architecture": "fs_original", + "enc_scaling": 80, + "enc_load_weights": false, + "bottleneck_type": "dense", + "bottleneck_norm": "none", + "bottleneck_size": 256, + "bottleneck_in_encoder": false, + "fc_depth": 1, + "fc_min_filters": 512, + "fc_max_filters": 512, + "fc_dimensions": 8, + "fc_filter_slope": -0.5, + "fc_dropout": 0.0, + "fc_upsampler": "subpixel", + "fc_upsamples": 1, + "fc_upsample_filters": 512, + "fc_gblock_depth": 3, + "fc_gblock_min_nodes": 512, + "fc_gblock_max_nodes": 512, + "fc_gblock_filter_slope": -0.5, + "fc_gblock_dropout": 0.0, + "dec_upscale_method": "subpixel", + "dec_norm": "none", + "dec_min_filters": 128, + "dec_max_filters": 504, + "dec_filter_slope": -0.33, + "dec_res_blocks": 2, + "dec_output_kernel": 5, + "dec_gaussian": false, + "dec_skip_last_residual": false, + "freeze_layers": "encoder", + "load_layers": "encoder", + "fs_original_depth": 4, + "fs_original_min_filters": 126, + "fs_original_max_filters": 1008, + "mobilenet_width": 1.0, + "mobilenet_depth": 1, + "mobilenet_dropout": 0.001, + "__filetype": "faceswap_preset", + "__section": "train|model|phaze_a" +} \ No newline at end of file diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-df_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-df_preset.json new file mode 100644 index 0000000000..9bce4e970f --- /dev/null +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-df_preset.json @@ -0,0 +1,48 @@ +{ + "output_size": 128, + "shared_fc": "none", + "enable_gblock": false, + "split_fc": false, + "split_gblock": false, + "split_decoders": true, + "enc_architecture": "fs_original", + "enc_scaling": 80, + "enc_load_weights": false, + "bottleneck_type": "dense", + "bottleneck_norm": "none", + "bottleneck_size": 256, + "bottleneck_in_encoder": false, + "fc_depth": 1, + "fc_min_filters": 256, + "fc_max_filters": 256, + "fc_dimensions": 8, + "fc_filter_slope": -0.5, + "fc_dropout": 0.0, + "fc_upsampler": "subpixel", + "fc_upsamples": 1, + "fc_upsample_filters": 256, + "fc_gblock_depth": 3, + "fc_gblock_min_nodes": 512, + "fc_gblock_max_nodes": 512, + "fc_gblock_filter_slope": -0.5, + "fc_gblock_dropout": 0.0, + "dec_upscale_method": "subpixel", + "dec_norm": "none", + "dec_min_filters": 128, + "dec_max_filters": 512, + "dec_filter_slope": -0.33, + "dec_res_blocks": 1, + "dec_output_kernel": 1, + "dec_gaussian": false, + "dec_skip_last_residual": false, + "freeze_layers": "encoder", + "load_layers": "encoder", + "fs_original_depth": 4, + "fs_original_min_filters": 64, + "fs_original_max_filters": 5124, + "mobilenet_width": 1.0, + "mobilenet_depth": 1, + "mobilenet_dropout": 0.001, + "__filetype": "faceswap_preset", + "__section": "train|model|phaze_a" +} \ No newline at end of file diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-liae_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-liae_preset.json new file mode 100644 index 0000000000..49e34bc459 --- /dev/null +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-liae_preset.json @@ -0,0 +1,48 @@ +{ + "output_size": 128, + "shared_fc": "half", + "enable_gblock": false, + "split_fc": true, + "split_gblock": false, + "split_decoders": false, + "enc_architecture": "fs_original", + "enc_scaling": 80, + "enc_load_weights": false, + "bottleneck_type": "dense", + "bottleneck_norm": "none", + "bottleneck_size": 256, + "bottleneck_in_encoder": false, + "fc_depth": 1, + "fc_min_filters": 512, + "fc_max_filters": 512, + "fc_dimensions": 8, + "fc_filter_slope": -0.5, + "fc_dropout": 0.0, + "fc_upsampler": "subpixel", + "fc_upsamples": 1, + "fc_upsample_filters": 512, + "fc_gblock_depth": 3, + "fc_gblock_min_nodes": 512, + "fc_gblock_max_nodes": 512, + "fc_gblock_filter_slope": -0.5, + "fc_gblock_dropout": 0.0, + "dec_upscale_method": "subpixel", + "dec_norm": "none", + "dec_min_filters": 128, + "dec_max_filters": 512, + "dec_filter_slope": -0.33, + "dec_res_blocks": 1, + "dec_output_kernel": 1, + "dec_gaussian": false, + "dec_skip_last_residual": false, + "freeze_layers": "encoder", + "load_layers": "encoder", + "fs_original_depth": 4, + "fs_original_min_filters": 64, + "fs_original_max_filters": 512, + "mobilenet_width": 1.0, + "mobilenet_depth": 1, + "mobilenet_dropout": 0.001, + "__filetype": "faceswap_preset", + "__section": "train|model|phaze_a" +} \ No newline at end of file diff --git a/lib/gui/.cache/presets/train/model_phaze_a_iae_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_iae_preset.json new file mode 100644 index 0000000000..50c0f1c848 --- /dev/null +++ b/lib/gui/.cache/presets/train/model_phaze_a_iae_preset.json @@ -0,0 +1,48 @@ +{ + "output_size": 64, + "shared_fc": "full", + "enable_gblock": false, + "split_fc": true, + "split_gblock": false, + "split_decoders": false, + "enc_architecture": "fs_original", + "enc_scaling": 40, + "enc_load_weights": false, + "bottleneck_type": "dense", + "bottleneck_norm": "none", + "bottleneck_size": 1024, + "bottleneck_in_encoder": false, + "fc_depth": 1, + "fc_min_filters": 512, + "fc_max_filters": 512, + "fc_dimensions": 4, + "fc_filter_slope": -0.5, + "fc_dropout": 0.0, + "fc_upsampler": "subpixel", + "fc_upsamples": 0, + "fc_upsample_filters": 512, + "fc_gblock_depth": 3, + "fc_gblock_min_nodes": 512, + "fc_gblock_max_nodes": 512, + "fc_gblock_filter_slope": -0.5, + "fc_gblock_dropout": 0.0, + "dec_upscale_method": "subpixel", + "dec_norm": "none", + "dec_min_filters": 64, + "dec_max_filters": 512, + "dec_filter_slope": -0.45, + "dec_res_blocks": 0, + "dec_output_kernel": 5, + "dec_gaussian": false, + "dec_skip_last_residual": false, + "freeze_layers": "encoder", + "load_layers": "encoder", + "fs_original_depth": 4, + "fs_original_min_filters": 128, + "fs_original_max_filters": 1024, + "mobilenet_width": 1.0, + "mobilenet_depth": 1, + "mobilenet_dropout": 0.001, + "__filetype": "faceswap_preset", + "__section": "train|model|phaze_a" +} \ No newline at end of file diff --git a/lib/gui/.cache/presets/train/model_phaze_a_lightweight_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_lightweight_preset.json new file mode 100644 index 0000000000..f0c0b8530d --- /dev/null +++ b/lib/gui/.cache/presets/train/model_phaze_a_lightweight_preset.json @@ -0,0 +1,48 @@ +{ + "output_size": 64, + "shared_fc": "none", + "enable_gblock": false, + "split_fc": false, + "split_gblock": false, + "split_decoders": true, + "enc_architecture": "fs_original", + "enc_scaling": 40, + "enc_load_weights": false, + "bottleneck_type": "dense", + "bottleneck_norm": "none", + "bottleneck_size": 512, + "bottleneck_in_encoder": true, + "fc_depth": 1, + "fc_min_filters": 512, + "fc_max_filters": 512, + "fc_dimensions": 4, + "fc_filter_slope": -0.5, + "fc_dropout": 0.0, + "fc_upsampler": "subpixel", + "fc_upsamples": 1, + "fc_upsample_filters": 256, + "fc_gblock_depth": 3, + "fc_gblock_min_nodes": 512, + "fc_gblock_max_nodes": 512, + "fc_gblock_filter_slope": -0.5, + "fc_gblock_dropout": 0.0, + "dec_upscale_method": "subpixel", + "dec_norm": "none", + "dec_min_filters": 128, + "dec_max_filters": 512, + "dec_filter_slope": -0.33, + "dec_res_blocks": 0, + "dec_output_kernel": 5, + "dec_gaussian": false, + "dec_skip_last_residual": false, + "freeze_layers": "encoder", + "load_layers": "encoder", + "fs_original_depth": 3, + "fs_original_min_filters": 128, + "fs_original_max_filters": 512, + "mobilenet_width": 1.0, + "mobilenet_depth": 1, + "mobilenet_dropout": 0.001, + "__filetype": "faceswap_preset", + "__section": "train|model|phaze_a" +} \ No newline at end of file diff --git a/lib/gui/.cache/presets/train/model_phaze_a_original_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_original_preset.json new file mode 100644 index 0000000000..8ec28bbdcc --- /dev/null +++ b/lib/gui/.cache/presets/train/model_phaze_a_original_preset.json @@ -0,0 +1,48 @@ +{ + "output_size": 64, + "shared_fc": "none", + "enable_gblock": false, + "split_fc": false, + "split_gblock": false, + "split_decoders": true, + "enc_architecture": "fs_original", + "enc_scaling": 40, + "enc_load_weights": false, + "bottleneck_type": "dense", + "bottleneck_norm": "none", + "bottleneck_size": 1024, + "bottleneck_in_encoder": true, + "fc_depth": 1, + "fc_min_filters": 1024, + "fc_max_filters": 1024, + "fc_dimensions": 4, + "fc_filter_slope": -0.5, + "fc_dropout": 0.0, + "fc_upsampler": "subpixel", + "fc_upsamples": 1, + "fc_upsample_filters": 512, + "fc_gblock_depth": 3, + "fc_gblock_min_nodes": 512, + "fc_gblock_max_nodes": 512, + "fc_gblock_filter_slope": -0.5, + "fc_gblock_dropout": 0.0, + "dec_upscale_method": "subpixel", + "dec_norm": "none", + "dec_min_filters": 64, + "dec_max_filters": 256, + "dec_filter_slope": -0.33, + "dec_res_blocks": 0, + "dec_output_kernel": 5, + "dec_gaussian": false, + "dec_skip_last_residual": false, + "freeze_layers": "encoder", + "load_layers": "encoder", + "fs_original_depth": 4, + "fs_original_min_filters": 128, + "fs_original_max_filters": 1024, + "mobilenet_width": 1.0, + "mobilenet_depth": 1, + "mobilenet_dropout": 0.001, + "__filetype": "faceswap_preset", + "__section": "train|model|phaze_a" +} \ No newline at end of file diff --git a/lib/gui/.cache/presets/train/model_phaze_a_stojo_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_stojo_preset.json new file mode 100644 index 0000000000..d08eb1d587 --- /dev/null +++ b/lib/gui/.cache/presets/train/model_phaze_a_stojo_preset.json @@ -0,0 +1,48 @@ +{ + "output_size": 256, + "shared_fc": "none", + "enable_gblock": true, + "split_fc": true, + "split_gblock": false, + "split_decoders": false, + "enc_architecture": "efficientnet_b4", + "enc_scaling": 60, + "enc_load_weights": true, + "bottleneck_type": "dense", + "bottleneck_norm": "none", + "bottleneck_size": 512, + "bottleneck_in_encoder": true, + "fc_depth": 1, + "fc_min_filters": 1280, + "fc_max_filters": 1280, + "fc_dimensions": 8, + "fc_filter_slope": -0.5, + "fc_dropout": 0.0, + "fc_upsampler": "upsample2d", + "fc_upsamples": 1, + "fc_upsample_filters": 1280, + "fc_gblock_depth": 3, + "fc_gblock_min_nodes": 512, + "fc_gblock_max_nodes": 512, + "fc_gblock_filter_slope": -0.5, + "fc_gblock_dropout": 0.0, + "dec_upscale_method": "resize_images", + "dec_norm": "none", + "dec_min_filters": 160, + "dec_max_filters": 640, + "dec_filter_slope": -0.33, + "dec_res_blocks": 1, + "dec_output_kernel": 3, + "dec_gaussian": true, + "dec_skip_last_residual": false, + "freeze_layers": "keras_encoder", + "load_layers": "encoder", + "fs_original_depth": 4, + "fs_original_min_filters": 128, + "fs_original_max_filters": 1024, + "mobilenet_width": 1.0, + "mobilenet_depth": 1, + "mobilenet_dropout": 0.001, + "__filetype": "faceswap_preset", + "__section": "train|model|phaze_a" +} \ No newline at end of file diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 9d695fdfcb..269eb07c6d 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -11,9 +11,11 @@ from tkinter import ttk from importlib import import_module +from lib.serializer import get_serializer + from .control_helper import ControlPanel, ControlPanelOption from .custom_widgets import Tooltip -from .utils import get_config, get_images +from .utils import FileHandler, get_config, get_images, PATHCACHE logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -133,7 +135,7 @@ def __init__(self, name, configurations): self._tree = _Tree(content_frame, configurations, name, theme).tree self._tree.bind("", self._select_item) - self._opts_frame = DisplayArea(content_frame, configurations, self._tree, theme) + self._opts_frame = DisplayArea(self, content_frame, configurations, self._tree, theme) self._opts_frame.pack(fill=tk.BOTH, expand=True, side=tk.RIGHT) footer_frame = self._build_footer() @@ -387,6 +389,8 @@ class DisplayArea(ttk.Frame): # pylint:disable=too-many-ancestors Parameters ---------- + top_level: :class:``tk.Toplevel`` + The tkinter Top Level widget parent: :class:`tkinter.ttk.Frame` The parent frame that holds the Display Area of the pop up configuration window tree: :class:`tkinter.ttk.TreeView` @@ -397,7 +401,7 @@ class DisplayArea(ttk.Frame): # pylint:disable=too-many-ancestors theme: dict The color mapping for the settings pop-up theme """ - def __init__(self, parent, configurations, tree, theme): + def __init__(self, top_level, parent, configurations, tree, theme): super().__init__(parent) self._configs = configurations self._theme = theme @@ -405,8 +409,21 @@ def __init__(self, parent, configurations, tree, theme): self._vars = dict() self._cache = dict() self._config_cpanel_dict = self._get_config() - self._build_header() self._displayed_frame = None + self._displayed_key = None + + self._presets = _Presets(self, top_level) + self._build_header() + + @property + def displayed_key(self): + """ str: The current display page's lookup key for configuration options. """ + return self._displayed_key + + @property + def config_dict(self): + """ dict: The configuration dictionary for all display pages. """ + return self._config_cpanel_dict def _get_config(self): """ Format the configuration options stored in :attr:`_config` into a dict of @@ -457,12 +474,34 @@ def _get_config(self): def _build_header(self): """ Build the dynamic header text. """ header_frame = ttk.Frame(self) + lbl_frame = ttk.Frame(header_frame) + var = tk.StringVar() - lbl = ttk.Label(header_frame, textvariable=var, anchor=tk.W, style="SPanel.Header2.TLabel") + lbl = ttk.Label(lbl_frame, textvariable=var, anchor=tk.W, style="SPanel.Header2.TLabel") lbl.pack(fill=tk.X, expand=True, side=tk.TOP) - header_frame.pack(fill=tk.X, padx=5, pady=(5, 0), side=tk.TOP) + + self._build_presets_buttons(header_frame) + lbl_frame.pack(fill=tk.X, side=tk.LEFT, expand=True) + header_frame.pack(fill=tk.X, padx=5, pady=5, side=tk.TOP) self._vars["header"] = var + def _build_presets_buttons(self, frame): + """ Build the section that holds the preset load and save buttons. + + Parameters + ---------- + frame: :class:`ttk.Frame` + The frame that holds the preset buttons + """ + presets_frame = ttk.Frame(frame) + for lbl in ("load", "save"): + btn = ttk.Button(presets_frame, + image=get_images().icons[lbl], + command=getattr(self._presets, lbl)) + Tooltip(btn, text=_(f"{lbl.title()} preset for this plugin"), wrap_length=720) + btn.pack(padx=2, side=tk.LEFT) + presets_frame.pack(side=tk.RIGHT) + def select_options(self, section, subsections): """ Display the page for the given section and subsections. @@ -495,6 +534,7 @@ def _set_display(self, section, subsections): self._cache_page(key) self._displayed_frame = self._cache[key] + self._displayed_key = key self._displayed_frame.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True) def _cache_page(self, key): @@ -653,3 +693,140 @@ def save(self, page_only=False): logger.info("Can't redraw GUI whilst a task is running. GUI Settings will be " "applied at the next restart.") logger.debug("Saved config") + + +class _Presets(): + """ Handles the file dialog and loading and saving of plugin preset files. + + Parameters + ---------- + parent: :class:`ttk.Frame` + The parent display area frame + top_level: :class:`tkinter.Toplevel` + The top level pop up window + """ + def __init__(self, parent, top_level): + logger.debug("Initializing: %s (top_level: %s)", self.__class__.__name__, top_level) + self._parent = parent + self._popup = top_level + self._base_path = os.path.join(PATHCACHE, "presets") + self._serializer = get_serializer("json") + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def _preset_path(self): + """ str: The path to the default preset folder for the currently displayed plugin. """ + return os.path.join(self._base_path, self._parent.displayed_key.split("|")[0]) + + @property + def _full_key(self): + """ str: The full extrapolated lookup key for the currently displayed page. """ + full_key = self._parent.displayed_key + return full_key if "|" in full_key else f"{full_key}|global" + + def load(self): + """ Action to perform when load preset button is pressed. + + Loads parameters from a saved json file and updates the displayed page. + """ + filename = self._get_filename("load") + if not filename: + return + + opts = self._serializer.load(filename) + if opts.get("__filetype") != "faceswap_preset": + logger.warning("'%s' is not a valid plugin preset file", filename) + return + if opts.get("__section") != self._full_key: + logger.warning("You are attempting to load a preset for '%s' into '%s'. Aborted.", + opts.get("__section", "no section"), self._full_key) + return + + logger.debug("Loaded preset: %s", opts) + + exist = self._parent.config_dict[self._parent.displayed_key]["options"] + for key, val in opts.items(): + if key.startswith("__") or key not in exist: + logger.debug("Skipping non-existent item: '%s'", key) + continue + logger.debug("Setting '%s' to '%s'", key, val) + exist[key].set(val) + logger.info("Preset loaded from: '%s'", os.path.basename(filename)) + + def save(self): + """ Action to perform when save preset button is pressed. + + Compiles currently displayed configuration options into a json file and saves into selected + location. + """ + filename = self._get_filename("save") + if not filename: + return + + opts = self._parent.config_dict[self._parent.displayed_key]["options"] + preset = {opt: val.get() for opt, val in opts.items()} + preset["__filetype"] = "faceswap_preset" + preset["__section"] = self._full_key + self._serializer.save(filename, preset) + logger.info("Preset '%s' saved to: '%s'", self._full_key, filename) + + def _get_filename(self, action): + """ Obtain the filename for load and save preset actions. + + Parameters + ---------- + action: ["load", "save"] + The preset action that is being performed + + Returns + ------- + str: The requested preset filename + """ + if not self._parent.config_dict.get(self._parent.displayed_key): + logger.info("No settings to %s for the current page.", action) + return None + + args = ("save_filename", "json") if action == "save" else ("filename", "json") + kwargs = dict(title=f"{action.title()} Preset...", + initial_folder=self._preset_path) + if action == "save": + kwargs["initial_file"] = self._get_initial_filename() + + filename = FileHandler(*args, **kwargs).return_file + if not filename: + logger.debug("%s cancelled", action.title()) + + self._raise_toplevel() + return filename + + def _get_initial_filename(self): + """ Obtain the initial filename for saving a preset. + + The name is based on the plugin's display key. A scan of the default presets folder is done + to ensure no filename clash. If a filename does clash, then an integer is added to the end. + + Returns + ------- + str + The initial preset filename + """ + _, key = self._full_key.split("|", 1) + base_filename = f"{key.replace('|', '_')}_preset" + + i = 0 + filename = f"{base_filename}.json" + while True: + if not os.path.exists(os.path.join(self._preset_path, filename)): + break + logger.debug("File pre-exists: %s", filename) + filename = f"{base_filename}_{i}.json" + i += 1 + logger.debug("Initial filename: %s", filename) + return filename + + def _raise_toplevel(self): + """ Opening a file dialog tends to hide the top level pop up, so bring back to the + fore. """ + self._popup.update() + self._popup.deiconify() + self._popup.lift() diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 68da171a63..3f7895ad72 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -106,6 +106,9 @@ class FileHandler(): # pylint:disable=too-few-public-methods initial_folder: str, optional The folder to initially open with the file dialog. If `None` then tkinter will decide. Default: ``None`` + initial_file: str, optional + The filename to set with the file dialog. If `None` then tkinter no initial filename is. + specified. Default: ``None`` command: str, optional Required for context handling file dialog, otherwise unused. Default: ``None`` action: str, optional @@ -127,16 +130,17 @@ class FileHandler(): # pylint:disable=too-few-public-methods '/path/to/selected/video.mp4' """ - def __init__(self, handle_type, file_type, title=None, initial_folder=None, command=None, - action=None, variable=None): + def __init__(self, handle_type, file_type, title=None, initial_folder=None, initial_file=None, + command=None, action=None, variable=None): logger.debug("Initializing %s: (handle_type: '%s', file_type: '%s', title: '%s', " - "initial_folder: '%s, 'command: '%s', action: '%s', variable: %s)", - self.__class__.__name__, handle_type, file_type, title, initial_folder, - command, action, variable) + "initial_folder: '%s', initial_file: '%s', command: '%s', action: '%s', " + "variable: %s)", self.__class__.__name__, handle_type, file_type, title, + initial_folder, initial_file, command, action, variable) self._handletype = handle_type self._defaults = self._set_defaults() self._kwargs = self._set_kwargs(title, initial_folder, + initial_file, file_type, command, action, @@ -161,6 +165,7 @@ def _filetypes(self): ("TIFF", "*.tif *.tiff"), all_files], ini=[("Faceswap config files", "*.ini"), all_files], + json=[("JSON file", "*.json"), all_files], model=[("Keras model files", "*.h5"), all_files], state=[("State files", "*.json"), all_files], log=[("Log files", "*.log"), all_files], @@ -226,17 +231,39 @@ def _set_defaults(self): logger.debug(defaults) return defaults - def _set_kwargs(self, title, initialdir, filetype, command, action, variable=None): + def _set_kwargs(self, title, initial_folder, initial_file, file_type, command, action, + variable=None): """ Generate the required kwargs for the requested file dialog browser. + Parameters + ---------- + title: str + The title to display on the file dialog. If `None` then the default title will be used. + initial_folder: str + The folder to initially open with the file dialog. If `None` then tkinter will decide. + initial_file: str + The filename to set with the file dialog. If `None` then tkinter no initial filename + is. + file_type: ['default', 'alignments', 'config_project', 'config_task', 'config_all', \ + 'csv', 'image', 'ini', 'state', 'log', 'video'] + The type of file that this dialog is for. `default` allows selection of any files. + Other options limit the file type selection + command: str + Required for context handling file dialog, otherwise unused. + action: str + Required for context handling file dialog, otherwise unused. + variable: :class:`tkinter.StringVar`, optional + Required for context handling file dialog, otherwise unused. The variable to associate + with this file dialog. Default: ``None`` + Returns ------- dict: The key word arguments for the file dialog to be launched """ - logger.debug("Setting Kwargs: (title: %s, initialdir: %s, filetype: '%s', " - "command: '%s': action: '%s', variable: '%s')", - title, initialdir, filetype, command, action, variable) + logger.debug("Setting Kwargs: (title: %s, initial_folder: %s, initial_file: '%s', " + "file_type: '%s', command: '%s': action: '%s', variable: '%s')", + title, initial_folder, initial_file, file_type, command, action, variable) kwargs = dict() if self._handletype.lower() == "context": self._set_context_handletype(command, action, variable) @@ -244,14 +271,17 @@ def _set_kwargs(self, title, initialdir, filetype, command, action, variable=Non if title is not None: kwargs["title"] = title - if initialdir is not None: - kwargs["initialdir"] = initialdir + if initial_folder is not None: + kwargs["initialdir"] = initial_folder + + if initial_file is not None: + kwargs["initialfile"] = initial_file if self._handletype.lower() in ( "open", "save", "filename", "filename_multi", "save_filename"): - kwargs["filetypes"] = self._filetypes[filetype] - if self._defaults.get(filetype): - kwargs['defaultextension'] = self._defaults[filetype] + kwargs["filetypes"] = self._filetypes[file_type] + if self._defaults.get(file_type): + kwargs['defaultextension'] = self._defaults[file_type] if self._handletype.lower() == "save": kwargs["mode"] = "w" if self._handletype.lower() == "open": @@ -308,9 +338,9 @@ def _filename_multi(self): logger.debug("Popping Filename browser") return filedialog.askopenfilenames(**self._kwargs) - def _savefilename(self): + def _save_filename(self): """ Get a save file location. """ - logger.debug("Popping SaveFilename browser") + logger.debug("Popping Save Filename browser") return filedialog.asksaveasfilename(**self._kwargs) @staticmethod From 82f365acc7c96b662a44f66228dbafa4a60043df Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 12 May 2021 01:36:10 +0100 Subject: [PATCH 454/981] initial commit --- plugins/extract/mask/bisenet_fp.py | 306 ++++++++++++++++++++ plugins/extract/mask/bisenet_fp_defaults.py | 67 +++++ 2 files changed, 373 insertions(+) create mode 100644 plugins/extract/mask/bisenet_fp.py create mode 100644 plugins/extract/mask/bisenet_fp_defaults.py diff --git a/plugins/extract/mask/bisenet_fp.py b/plugins/extract/mask/bisenet_fp.py new file mode 100644 index 0000000000..cdf6e490d3 --- /dev/null +++ b/plugins/extract/mask/bisenet_fp.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +""" BiSeNet Face-Parsing mask plugin + +Architecture and Pre-Trained Model ported from PyTorch to Keras by TorzDF from +https://github.com/zllrunning/face-parsing.PyTorch +""" + +from keras import backend as K +from keras.layers import (Activation, Add, AveragePooling2D, BatchNormalization, Concatenate, + Conv2D, Input, MaxPooling2D, Multiply, UpSampling2D, ZeroPadding2D) + +import numpy as np +from lib.model.session import KSession +from ._base import Masker, logger + +_NAME_TRACKER = set() +_DIM_IDX = 2 if K.image_data_format() == "channels_first" else 1 + + +class Mask(Masker): + """ Neural network to process face image into a segmentation mask of the face """ + def __init__(self, **kwargs): + git_model_id = 14 + model_filename = "bisnet_face_parsing_v1.h5" + super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.name = "BiSeNet - Face Parsing" + self.input_size = 512 + self.color_format = "RGB" + self.vram = 3424 # TODO + self.vram_warnings = 256 # TODO + self.vram_per_batch = 80 # TODO + self.batchsize = self.config["batch-size"] + + def init_model(self): + self.model = BiSeNet(self.model_path, + self.config["allow_growth"], + self._exclude_gpus, + self.input_size, + 19) + placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), + dtype="float32") + self.model.predict(placeholder) + + def process_input(self, batch): + """ Compile the detected faces for prediction """ + batch["feed"] = np.array([feed.face[..., :3] + for feed in batch["feed_faces"]], dtype="float32") / 255.0 + logger.trace("feed shape: %s", batch["feed"].shape) + return batch + + def predict(self, batch): + """ Run model to get predictions """ + batch["prediction"] = self.model.predict(batch["feed"]) + return batch + + def process_output(self, batch): + """ Compile found faces for output """ + return batch + +# BiSeNet Face-Parsing Model + +# MIT License + +# Copyright (c) 2019 zll + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +def _get_name(name, start_idx=1): + i = start_idx + while True: + retval = f"{name}{i}" if i != -1 else name + if retval not in _NAME_TRACKER: + break + i += 1 + _NAME_TRACKER.add(retval) + return retval + +class ConvBn(): + def __init__(self, filters, kernel_size=3, strides=1, padding=1, activation=True, prefix="", start_idx=1): + self._filters = filters + self._kernel_size = kernel_size + self._strides = strides + self._padding = padding + self._activation = activation + self._prefix = f"{prefix}." if prefix else prefix + self._start_idx = start_idx + + def __call__(self, inputs): + var_x = inputs + if self._padding > 0 and self._kernel_size != 1: + var_x = ZeroPadding2D(self._padding, + name=_get_name(f"{self._prefix}zeropad", + start_idx=self._start_idx))(var_x) + var_x = Conv2D(self._filters, + self._kernel_size, + strides=self._strides, + use_bias=False, + name=_get_name(f"{self._prefix}conv", start_idx=self._start_idx))(var_x) + var_x = BatchNormalization(epsilon=1e-5, + name=_get_name(f"{self._prefix}bn", + start_idx=self._start_idx))(var_x) + if self._activation: + var_x = Activation("relu", + name=_get_name(f"{self._prefix}relu", + start_idx=self._start_idx))(var_x) + return var_x + +class ResNet18(): + def __init__(self): + self._feature_index = 1 if K.image_data_format() == "channels_first" else -1 + + def _basic_block(self, inputs, prefix, filters, strides=1): + res = ConvBn(filters, strides=strides, padding=1, prefix=prefix)(inputs) + res = ConvBn(filters, strides=1, padding=1, activation=False, prefix=prefix)(res) + + shortcut = inputs + filts = (K.int_shape(shortcut)[self._feature_index], K.int_shape(res)[self._feature_index]) + if strides != 1 or filts[0] != filts[1]: # Downsample + name = f"{prefix}.downsample." + shortcut = Conv2D(filters, 1, + strides=strides, + use_bias=False, + name=_get_name(f"{name}", start_idx=0))(shortcut) + shortcut = BatchNormalization(epsilon=1e-5, + name=_get_name(f"{name}", + start_idx=0))(shortcut) + + var_x = Add(name=f"{prefix}.add")([res, shortcut]) + var_x = Activation("relu", name=f"{prefix}.relu")(var_x) + return var_x + + def _basic_layer(self, inputs, prefix, filters, bnum, strides=1): + var_x = self._basic_block(inputs, f"{prefix}.0", filters, strides=strides) + for i in range(bnum - 1): + var_x = self._basic_block(var_x, f"{prefix}.{i + 1}", filters, strides=1) + return var_x + + def __call__(self, inputs): + var_x = ConvBn(64, kernel_size=7, strides=2, padding=3, prefix="cp.resnet")(inputs) + var_x = ZeroPadding2D(1, name="cp.resnet.zeropad")(var_x) + var_x = MaxPooling2D(pool_size=3, strides=2, name="cp.resnet.maxpool")(var_x) + + var_x = self._basic_layer(var_x, "cp.resnet.layer1", 64, 2) + feat8 = self._basic_layer(var_x, "cp.resnet.layer2", 128, 2, strides=2) + feat16 = self._basic_layer(feat8, "cp.resnet.layer3", 256, 2, strides=2) + feat32 = self._basic_layer(feat16, "cp.resnet.layer4", 512, 2, strides=2) + + return feat8, feat16, feat32 + +class AttentionRefinementModule(): + def __init__(self, filters): + self._filters = filters + + def __call__(self, inputs): + prefix = f"cp.arm{K.int_shape(inputs)[2]}" + feat = ConvBn(self._filters, prefix=f"{prefix}.conv", start_idx=-1)(inputs) + atten = AveragePooling2D(pool_size=K.int_shape(feat)[_DIM_IDX:_DIM_IDX + 2], + name=f"{prefix}.avgpool")(feat) + atten = Conv2D(self._filters, 1, use_bias=False, name=f"{prefix}.conv_atten")(atten) + atten = BatchNormalization(epsilon=1e-5, name=f"{prefix}.bn_atten")(atten) + atten = Activation("sigmoid", name=f"{prefix}.sigmoid")(atten) + var_x = Multiply(name=f"{prefix}.mul")([feat, atten]) + return var_x + +class ContextPath(): + def __init__(self): + self._resnet = ResNet18() + + def __call__(self, inputs): + feat8, feat16, feat32 = self._resnet(inputs) + + avg = AveragePooling2D(pool_size=K.int_shape(feat32)[_DIM_IDX:_DIM_IDX + 2], + name="cp.avgpool")(feat32) + avg = ConvBn(128, kernel_size=1, padding=0, prefix="cp.conv_avg", start_idx=-1)(avg) + avg_up = UpSampling2D(size=K.int_shape(feat32)[_DIM_IDX:_DIM_IDX + 2], + name="cp.upsample")(avg) + + feat32 = AttentionRefinementModule(128)(feat32) + feat32 = Add(name="cp.add")([feat32, avg_up]) + feat32 = UpSampling2D(name="cp.upsample1")(feat32) + feat32 = ConvBn(128, kernel_size=1, prefix="cp.conv_head32", start_idx=-1)(feat32) + + feat16 = AttentionRefinementModule(128)(feat16) + feat16 = Add(name="cp.add2")([feat16, feat32]) + feat16 = UpSampling2D(name="cp.upsample2")(feat16) + feat16 = ConvBn(128, kernel_size=1, prefix="cp.conv_head16", start_idx=-1)(feat16) + + return feat8, feat16, feat32 + +class FeatureFusionModule(): + def __init__(self, filters): + self._filters = filters + + def __call__(self, inputs): + feat = Concatenate(name="ffm.concat")(inputs) + feat = ConvBn(self._filters, + kernel_size=1, + padding=0, + prefix="ffm.convblk", + start_idx=-1)(feat) + + atten = AveragePooling2D(pool_size=K.int_shape(feat)[_DIM_IDX:_DIM_IDX + 2], + name="ffm.avgpool")(feat) + atten = Conv2D(self._filters // 4, 1, use_bias=False, name="ffm.conv1")(atten) + atten = Activation("relu", name="ffm.relu")(atten) + atten = Conv2D(self._filters, 1, use_bias=False, name="ffm.conv2")(atten) + atten = Activation("sigmoid", name="ffm.sigmoid")(atten) + + var_x = Multiply(name="ffm.mul")([feat, atten]) + var_x = Add(name="ffm.add")([var_x, feat]) + return var_x + + +class BiSeNetOutput(): + def __init__(self, mid_chan, num_classes, label=""): + self._mid_chan = mid_chan + self._num_classes = num_classes + self._label = label + + def __call__(self, inputs): + var_x = ConvBn(self._mid_chan, prefix=f"conv_out{self._label}.conv", start_idx=-1)(inputs) + var_x = Conv2D(self._num_classes, 1, + use_bias=False, name=f"conv_out{self._label}.conv_out")(var_x) + return var_x + + +class BiSeNet(KSession): + """ BiSeNet Face-Parsing Mask from https://github.com/zllrunning/face-parsing.PyTorch + + PyTorch model reimplemented in Keras by TorzDF + + Parameters + ---------- + model_path: str + The path to the keras model file + allow_growth: bool + Enable the Tensorflow GPU allow_growth configuration option. This option prevents + Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation and + slower performance + exclude_gpus: list + A list of indices correlating to connected GPUs that Tensorflow should not use. Pass + ``None`` to not exclude any GPUs + input_size: int + The input size to the model + num_classes: int + The number of segmentation classes to create + """ + def __init__(self, model_path, allow_growth, exclude_gpus, input_size, num_classes): + super().__init__("BiSeNet Face Parsing", + model_path, + allow_growth=allow_growth, + exclude_gpus=exclude_gpus) + self._input_size = input_size + self._num_classes = num_classes + self._cp = ContextPath() + self.define_model(self._model_definition) + self.load_model_weights() + + def _model_definition(self): + """ Definition of the VGG Obstructed Model. + + Returns + ------- + tuple + The tensor input to the model and tensor output to the model for compilation by + :func`define_model` + """ + input_ = Input((self._input_size, self._input_size, 3)) + + feat_res8, feat_cp8, feat_cp16 = self._cp(input_) + feat_fuse = FeatureFusionModule(256)([feat_res8, feat_cp8]) + + feat_out = BiSeNetOutput(256, self._num_classes)(feat_fuse) + feat_out16 = BiSeNetOutput(64, self._num_classes, label="16")(feat_cp8) + feat_out32 = BiSeNetOutput(64, self._num_classes, label="32")(feat_cp16) + + height, width = K.int_shape(input_)[_DIM_IDX:_DIM_IDX + 2] + f_h, f_w = K.int_shape(feat_out)[_DIM_IDX:_DIM_IDX + 2] + f_h16, f_w16 = K.int_shape(feat_out16)[_DIM_IDX:_DIM_IDX + 2] + f_h32, f_w32 = K.int_shape(feat_out32)[_DIM_IDX:_DIM_IDX + 2] + + feat_out = UpSampling2D(size=(height // f_h, width // f_w), + interpolation="bilinear")(feat_out) + feat_out16 = UpSampling2D(size=(height // f_h16, width // f_w16), + interpolation="bilinear")(feat_out16) + feat_out32 = UpSampling2D(size=(height // f_h32, width // f_w32), + interpolation="bilinear")(feat_out32) + + return input_, [feat_out, feat_out16, feat_out32] diff --git a/plugins/extract/mask/bisenet_fp_defaults.py b/plugins/extract/mask/bisenet_fp_defaults.py new file mode 100644 index 0000000000..92b23e5439 --- /dev/null +++ b/plugins/extract/mask/bisenet_fp_defaults.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +""" + The default options for the faceswap BiSeNet Face Parsing plugin. + + Defaults files should be named _defaults.py + Any items placed into this file will automatically get added to the relevant config .ini files + within the faceswap/config folder. + + The following variables should be defined: + _HELPTEXT: A string describing what this plugin does + _DEFAULTS: A dictionary containing the options, defaults and meta information. The + dictionary should be defined as: + {: {}} + + should always be lower text. + dictionary requirements are listed below. + + The following keys are expected for the _DEFAULTS dict: + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid data types are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + group: [optional]. A group for grouping options together in the GUI. If not + provided this will not group this option with any others. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and data types this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and data types this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. +""" + + +_HELPTEXT = ( + "BiSeNet Face Parsing options.\n" + "Mask ported from https://github.com/zllrunning/face-parsing.PyTorch." + ) + + +_DEFAULTS = { + "batch-size": dict( + default=8, + info="The batch size to use. To a point, higher batch sizes equal better performance, but " + "setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can accomodate " + "then this will automatically be lowered.", + datatype=int, + rounding=1, + min_max=(1, 64), + choices=[], + group="settings", + gui_radio=False, + fixed=True, + ) +} From bd8b6b674db0fd0674274810f9078ca26104e02a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 13 May 2021 13:05:20 +0100 Subject: [PATCH 455/981] Working implementation --- plugins/extract/mask/bisenet_fp.py | 111 ++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 33 deletions(-) diff --git a/plugins/extract/mask/bisenet_fp.py b/plugins/extract/mask/bisenet_fp.py index cdf6e490d3..55a7d5b057 100644 --- a/plugins/extract/mask/bisenet_fp.py +++ b/plugins/extract/mask/bisenet_fp.py @@ -4,18 +4,17 @@ Architecture and Pre-Trained Model ported from PyTorch to Keras by TorzDF from https://github.com/zllrunning/face-parsing.PyTorch """ - +import cv2 from keras import backend as K -from keras.layers import (Activation, Add, AveragePooling2D, BatchNormalization, Concatenate, - Conv2D, Input, MaxPooling2D, Multiply, UpSampling2D, ZeroPadding2D) +from keras.layers import (Activation, Add, BatchNormalization, Concatenate, Conv2D, + GlobalAveragePooling2D, Input, MaxPooling2D, Multiply, Reshape, + UpSampling2D, ZeroPadding2D) import numpy as np + from lib.model.session import KSession from ._base import Masker, logger -_NAME_TRACKER = set() -_DIM_IDX = 2 if K.image_data_format() == "channels_first" else 1 - class Mask(Masker): """ Neural network to process face image into a segmentation mask of the face """ @@ -43,14 +42,50 @@ def init_model(self): def process_input(self, batch): """ Compile the detected faces for prediction """ - batch["feed"] = np.array([feed.face[..., :3] - for feed in batch["feed_faces"]], dtype="float32") / 255.0 + mean = (0.485, 0.456, 0.406) + std = (0.229, 0.224, 0.225) + + batch["feed"] = ((np.array([feed.face[..., :3] + for feed in batch["feed_faces"]], + dtype="float32") / 255.0) - mean) / std logger.trace("feed shape: %s", batch["feed"].shape) return batch def predict(self, batch): """ Run model to get predictions """ - batch["prediction"] = self.model.predict(batch["feed"]) + # batch["prediction"] = self.model.predict(batch["feed"])[0] + # pred = self.model.predict(batch["feed"])[0].argmax(-1).astype("uint8") + pred = self.model.predict(batch["feed"])[0] + pred = pred.argmax(-1).astype("uint8") + part_colors = [[255, 0, 0], [255, 85, 0], [255, 170, 0], + [255, 0, 85], [255, 0, 170], + [0, 255, 0], [85, 255, 0], [170, 255, 0], + [0, 255, 85], [0, 255, 170], + [0, 0, 255], [85, 0, 255], [170, 0, 255], + [0, 85, 255], [0, 170, 255], + [255, 255, 0], [255, 255, 85], [255, 255, 170], + [255, 0, 255], [255, 85, 255], [255, 170, 255], + [0, 255, 255], [85, 255, 255], [170, 255, 255]] + + test = np.array([feed.face[..., :3].copy() + for feed in batch["feed_faces"]]).copy().astype("uint8")[..., 2::-1] + pred_col = np.zeros((*pred.shape, 3)) + 255 + + num_of_class = np.max(pred, axis=(1, 2)) + for idx, (img, _cls) in enumerate(zip(pred, num_of_class)): + for pi in range(1, _cls + 1): + index = np.where(pred[idx] == pi) + pred_col[idx, index[0], index[1], :] = part_colors[pi] + + pred_col = pred_col.astype("uint8") + for idx, (img, col) in enumerate(zip(test, pred_col)): + test[idx] = cv2.addWeighted(img, 0.4, col, 0.6, 0) + + for idx, img in enumerate(test): + cv2.imshow(f"img{idx}", img) + cv2.waitKey() + + exit(0) return batch def process_output(self, batch): @@ -82,6 +117,9 @@ def process_output(self, batch): # SOFTWARE. +_NAME_TRACKER = set() + + def _get_name(name, start_idx=1): i = start_idx while True: @@ -92,8 +130,10 @@ def _get_name(name, start_idx=1): _NAME_TRACKER.add(retval) return retval + class ConvBn(): - def __init__(self, filters, kernel_size=3, strides=1, padding=1, activation=True, prefix="", start_idx=1): + def __init__(self, filters, + kernel_size=3, strides=1, padding=1, activation=True, prefix="", start_idx=1): self._filters = filters self._kernel_size = kernel_size self._strides = strides @@ -108,9 +148,11 @@ def __call__(self, inputs): var_x = ZeroPadding2D(self._padding, name=_get_name(f"{self._prefix}zeropad", start_idx=self._start_idx))(var_x) + padding = "valid" if self._padding != -1 else "same" var_x = Conv2D(self._filters, self._kernel_size, strides=self._strides, + padding=padding, use_bias=False, name=_get_name(f"{self._prefix}conv", start_idx=self._start_idx))(var_x) var_x = BatchNormalization(epsilon=1e-5, @@ -122,6 +164,7 @@ def __call__(self, inputs): start_idx=self._start_idx))(var_x) return var_x + class ResNet18(): def __init__(self): self._feature_index = 1 if K.image_data_format() == "channels_first" else -1 @@ -139,8 +182,7 @@ def _basic_block(self, inputs, prefix, filters, strides=1): use_bias=False, name=_get_name(f"{name}", start_idx=0))(shortcut) shortcut = BatchNormalization(epsilon=1e-5, - name=_get_name(f"{name}", - start_idx=0))(shortcut) + name=_get_name(f"{name}", start_idx=0))(shortcut) var_x = Add(name=f"{prefix}.add")([res, shortcut]) var_x = Activation("relu", name=f"{prefix}.relu")(var_x) @@ -164,21 +206,23 @@ def __call__(self, inputs): return feat8, feat16, feat32 + class AttentionRefinementModule(): def __init__(self, filters): self._filters = filters - def __call__(self, inputs): - prefix = f"cp.arm{K.int_shape(inputs)[2]}" - feat = ConvBn(self._filters, prefix=f"{prefix}.conv", start_idx=-1)(inputs) - atten = AveragePooling2D(pool_size=K.int_shape(feat)[_DIM_IDX:_DIM_IDX + 2], - name=f"{prefix}.avgpool")(feat) + def __call__(self, inputs, feats): + prefix = f"cp.arm{feats}" + feat = ConvBn(self._filters, prefix=f"{prefix}.conv", start_idx=-1, padding=-1)(inputs) + atten = GlobalAveragePooling2D(name=f"{prefix}.avgpool")(feat) + atten = Reshape((1, 1, K.int_shape(atten)[-1]))(atten) atten = Conv2D(self._filters, 1, use_bias=False, name=f"{prefix}.conv_atten")(atten) atten = BatchNormalization(epsilon=1e-5, name=f"{prefix}.bn_atten")(atten) atten = Activation("sigmoid", name=f"{prefix}.sigmoid")(atten) var_x = Multiply(name=f"{prefix}.mul")([feat, atten]) return var_x + class ContextPath(): def __init__(self): self._resnet = ResNet18() @@ -186,24 +230,25 @@ def __init__(self): def __call__(self, inputs): feat8, feat16, feat32 = self._resnet(inputs) - avg = AveragePooling2D(pool_size=K.int_shape(feat32)[_DIM_IDX:_DIM_IDX + 2], - name="cp.avgpool")(feat32) + avg = GlobalAveragePooling2D(name="cp.avgpool")(feat32) + avg = Reshape((1, 1, K.int_shape(avg)[-1]))(avg) avg = ConvBn(128, kernel_size=1, padding=0, prefix="cp.conv_avg", start_idx=-1)(avg) - avg_up = UpSampling2D(size=K.int_shape(feat32)[_DIM_IDX:_DIM_IDX + 2], - name="cp.upsample")(avg) - feat32 = AttentionRefinementModule(128)(feat32) + avg_up = UpSampling2D(size=K.int_shape(feat32)[1:3], name="cp.upsample")(avg) + + feat32 = AttentionRefinementModule(128)(feat32, 32) feat32 = Add(name="cp.add")([feat32, avg_up]) feat32 = UpSampling2D(name="cp.upsample1")(feat32) - feat32 = ConvBn(128, kernel_size=1, prefix="cp.conv_head32", start_idx=-1)(feat32) + feat32 = ConvBn(128, kernel_size=3, prefix="cp.conv_head32", start_idx=-1)(feat32) - feat16 = AttentionRefinementModule(128)(feat16) + feat16 = AttentionRefinementModule(128)(feat16, 16) feat16 = Add(name="cp.add2")([feat16, feat32]) feat16 = UpSampling2D(name="cp.upsample2")(feat16) - feat16 = ConvBn(128, kernel_size=1, prefix="cp.conv_head16", start_idx=-1)(feat16) + feat16 = ConvBn(128, kernel_size=3, prefix="cp.conv_head16", start_idx=-1)(feat16) return feat8, feat16, feat32 + class FeatureFusionModule(): def __init__(self, filters): self._filters = filters @@ -216,8 +261,8 @@ def __call__(self, inputs): prefix="ffm.convblk", start_idx=-1)(feat) - atten = AveragePooling2D(pool_size=K.int_shape(feat)[_DIM_IDX:_DIM_IDX + 2], - name="ffm.avgpool")(feat) + atten = GlobalAveragePooling2D(name="ffm.avgpool")(feat) + atten = Reshape((1, 1, K.int_shape(atten)[-1]))(atten) atten = Conv2D(self._filters // 4, 1, use_bias=False, name="ffm.conv1")(atten) atten = Activation("relu", name="ffm.relu")(atten) atten = Conv2D(self._filters, 1, use_bias=False, name="ffm.conv2")(atten) @@ -266,12 +311,12 @@ def __init__(self, model_path, allow_growth, exclude_gpus, input_size, num_class super().__init__("BiSeNet Face Parsing", model_path, allow_growth=allow_growth, - exclude_gpus=exclude_gpus) + exclude_gpus=exclude_gpus) self._input_size = input_size self._num_classes = num_classes self._cp = ContextPath() self.define_model(self._model_definition) - self.load_model_weights() + self.load_model_weights() def _model_definition(self): """ Definition of the VGG Obstructed Model. @@ -291,10 +336,10 @@ def _model_definition(self): feat_out16 = BiSeNetOutput(64, self._num_classes, label="16")(feat_cp8) feat_out32 = BiSeNetOutput(64, self._num_classes, label="32")(feat_cp16) - height, width = K.int_shape(input_)[_DIM_IDX:_DIM_IDX + 2] - f_h, f_w = K.int_shape(feat_out)[_DIM_IDX:_DIM_IDX + 2] - f_h16, f_w16 = K.int_shape(feat_out16)[_DIM_IDX:_DIM_IDX + 2] - f_h32, f_w32 = K.int_shape(feat_out32)[_DIM_IDX:_DIM_IDX + 2] + height, width = K.int_shape(input_)[1:3] + f_h, f_w = K.int_shape(feat_out)[1:3] + f_h16, f_w16 = K.int_shape(feat_out16)[1:3] + f_h32, f_w32 = K.int_shape(feat_out32)[1:3] feat_out = UpSampling2D(size=(height // f_h, width // f_w), interpolation="bilinear")(feat_out) From 38d77f55832ec042e288342dcc3655ee089e1a0e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 14 May 2021 00:29:27 +0100 Subject: [PATCH 456/981] bugfix: Training Generator - Catch Nonetypes when loading images --- lib/training/generator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/training/generator.py b/lib/training/generator.py index 8a1c77ece0..3149990646 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -206,7 +206,9 @@ def cache_metadata(self, filenames): if len(batch.shape) == 1: folder = os.path.dirname(filenames[0]) - details = [f"{key} ({img.shape[1]}px)" for key, img in zip(keys, batch)] + details = [ + f"{key} ({img.shape[1]}px)" if isinstance(img, np.ndarray) else type(img) + for key, img in zip(keys, batch)] msg = (f"There are mismatched image sizes in the folder '{folder}'. All training " "images for each side must have the same dimensions.\nThe batch that " f"failed contains the following files:\n{details}.") From add2d103159aa6c9bfa0301f1ab93b5fc37af473 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 14 May 2021 01:46:57 +0100 Subject: [PATCH 457/981] Bugfix: Augmentation - Correctly calculate Clahe chance --- lib/training/augmentation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/training/augmentation.py b/lib/training/augmentation.py index 9463de9c34..bc40024bad 100644 --- a/lib/training/augmentation.py +++ b/lib/training/augmentation.py @@ -233,7 +233,9 @@ def _random_clahe(self, batch): base_contrast = self._constants["clahe_base_contrast"] batch_random = np.random.rand(self._batchsize) - indices = np.where(batch_random > self._config.get("color_clahe_chance", 50) / 100)[0] + indices = np.where(batch_random < self._config.get("color_clahe_chance", 50) / 100)[0] + if not indices: + return batch grid_bases = np.rint(np.random.uniform(0, self._config.get("color_clahe_max_size", 4), From 3835f3959deff6294f8277631d453df86253e778 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 14 May 2021 01:54:26 +0100 Subject: [PATCH 458/981] bugfix: augmentation: Use np.any for truth value --- lib/training/augmentation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/training/augmentation.py b/lib/training/augmentation.py index bc40024bad..5b0eff2309 100644 --- a/lib/training/augmentation.py +++ b/lib/training/augmentation.py @@ -234,7 +234,7 @@ def _random_clahe(self, batch): batch_random = np.random.rand(self._batchsize) indices = np.where(batch_random < self._config.get("color_clahe_chance", 50) / 100)[0] - if not indices: + if not np.any(indices): return batch grid_bases = np.rint(np.random.uniform(0, From ecd17d4ba7a700867a3566914b2c2ab0338b1b87 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 14 May 2021 11:59:00 +0100 Subject: [PATCH 459/981] Bugfix: Training Generator - Output image name with NoneType errors --- lib/training/generator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/training/generator.py b/lib/training/generator.py index 3149990646..3ae18a1be1 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -207,7 +207,8 @@ def cache_metadata(self, filenames): if len(batch.shape) == 1: folder = os.path.dirname(filenames[0]) details = [ - f"{key} ({img.shape[1]}px)" if isinstance(img, np.ndarray) else type(img) + "{0} ({1})".format( + key, f"{img.shape[1]}px" if isinstance(img, np.ndarray) else type(img)) for key, img in zip(keys, batch)] msg = (f"There are mismatched image sizes in the folder '{folder}'. All training " "images for each side must have the same dimensions.\nThe batch that " From 84ab423cb7caa88bc8256ed0a66dc599fde7aa45 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 15 May 2021 11:42:32 +0100 Subject: [PATCH 460/981] Implemented working as masker --- plugins/extract/mask/bisenet_fp.py | 69 ++++++++++----------- plugins/extract/mask/bisenet_fp_defaults.py | 22 ++++++- 2 files changed, 53 insertions(+), 38 deletions(-) diff --git a/plugins/extract/mask/bisenet_fp.py b/plugins/extract/mask/bisenet_fp.py index 55a7d5b057..691d962f85 100644 --- a/plugins/extract/mask/bisenet_fp.py +++ b/plugins/extract/mask/bisenet_fp.py @@ -4,14 +4,14 @@ Architecture and Pre-Trained Model ported from PyTorch to Keras by TorzDF from https://github.com/zllrunning/face-parsing.PyTorch """ -import cv2 + +import numpy as np + from keras import backend as K from keras.layers import (Activation, Add, BatchNormalization, Concatenate, Conv2D, GlobalAveragePooling2D, Input, MaxPooling2D, Multiply, Reshape, UpSampling2D, ZeroPadding2D) -import numpy as np - from lib.model.session import KSession from ._base import Masker, logger @@ -29,6 +29,33 @@ def __init__(self, **kwargs): self.vram_warnings = 256 # TODO self.vram_per_batch = 80 # TODO self.batchsize = self.config["batch-size"] + self._segment_indices = self._get_segment_indices() + + def _get_segment_indices(self): + """ Obtain the segment indices to include within the face mask area based on user + configuration settings. + + Returns + ------- + list + The segment indices to include within the face mask area + + Notes + ----- + Model segment indices: + 0: background, 1: skin, 2: left brow, 3: right brow, 4: left eye, 5: right eye, 6: glasses + 7: left ear, 8: right ear, 9: earings, 10: nose, 11: mouth, 12: upper lip, 13: lower_lip, + 14: neck, 15: neck ?, 16: cloth, 17: hair, 18: hat + """ + retval = [1, 2, 3, 4, 5, 10, 11, 12, 13] + if self.config["include_glasses"]: + retval.append(6) + if self.config["include_ears"]: + retval.extend([7, 8, 9]) + if self.config["include_hair"]: + retval.append(17) + logger.debug("Selected segment indices: %s", retval) + return retval def init_model(self): self.model = BiSeNet(self.model_path, @@ -53,43 +80,13 @@ def process_input(self, batch): def predict(self, batch): """ Run model to get predictions """ - # batch["prediction"] = self.model.predict(batch["feed"])[0] - # pred = self.model.predict(batch["feed"])[0].argmax(-1).astype("uint8") - pred = self.model.predict(batch["feed"])[0] - pred = pred.argmax(-1).astype("uint8") - part_colors = [[255, 0, 0], [255, 85, 0], [255, 170, 0], - [255, 0, 85], [255, 0, 170], - [0, 255, 0], [85, 255, 0], [170, 255, 0], - [0, 255, 85], [0, 255, 170], - [0, 0, 255], [85, 0, 255], [170, 0, 255], - [0, 85, 255], [0, 170, 255], - [255, 255, 0], [255, 255, 85], [255, 255, 170], - [255, 0, 255], [255, 85, 255], [255, 170, 255], - [0, 255, 255], [85, 255, 255], [170, 255, 255]] - - test = np.array([feed.face[..., :3].copy() - for feed in batch["feed_faces"]]).copy().astype("uint8")[..., 2::-1] - pred_col = np.zeros((*pred.shape, 3)) + 255 - - num_of_class = np.max(pred, axis=(1, 2)) - for idx, (img, _cls) in enumerate(zip(pred, num_of_class)): - for pi in range(1, _cls + 1): - index = np.where(pred[idx] == pi) - pred_col[idx, index[0], index[1], :] = part_colors[pi] - - pred_col = pred_col.astype("uint8") - for idx, (img, col) in enumerate(zip(test, pred_col)): - test[idx] = cv2.addWeighted(img, 0.4, col, 0.6, 0) - - for idx, img in enumerate(test): - cv2.imshow(f"img{idx}", img) - cv2.waitKey() - - exit(0) + batch["prediction"] = self.model.predict(batch["feed"])[0] return batch def process_output(self, batch): """ Compile found faces for output """ + pred = batch["prediction"].argmax(-1).astype("uint8") + batch["prediction"] = np.isin(pred, self._segment_indices).astype("float32") return batch # BiSeNet Face-Parsing Model diff --git a/plugins/extract/mask/bisenet_fp_defaults.py b/plugins/extract/mask/bisenet_fp_defaults.py index 92b23e5439..313928473e 100644 --- a/plugins/extract/mask/bisenet_fp_defaults.py +++ b/plugins/extract/mask/bisenet_fp_defaults.py @@ -62,6 +62,24 @@ choices=[], group="settings", gui_radio=False, - fixed=True, - ) + fixed=True), + "include_ears": dict( + default=False, + info="Whether to include ears within the face mask.", + datatype=bool, + group="settings" + ), + "include_hair": dict( + default=False, + info="Whether to include hair within the face mask.", + datatype=bool, + group="settings" + ), + "include_glasses": dict( + default=True, + info="Whether to include glasses within the face mask. NB: excluding glasses will mask " + "out the lenses as well as the frames.", + datatype=bool, + group="settings" + ), } From 108fbc497bbc4a41aef560a65e0e4404f74ed326 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 15 May 2021 13:22:09 +0100 Subject: [PATCH 461/981] Implement VRAM sizes --- plugins/extract/mask/bisenet_fp.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/plugins/extract/mask/bisenet_fp.py b/plugins/extract/mask/bisenet_fp.py index 691d962f85..8aadd368cd 100644 --- a/plugins/extract/mask/bisenet_fp.py +++ b/plugins/extract/mask/bisenet_fp.py @@ -25,9 +25,9 @@ def __init__(self, **kwargs): self.name = "BiSeNet - Face Parsing" self.input_size = 512 self.color_format = "RGB" - self.vram = 3424 # TODO - self.vram_warnings = 256 # TODO - self.vram_per_batch = 80 # TODO + self.vram = 2304 + self.vram_warnings = 256 + self.vram_per_batch = 64 self.batchsize = self.config["batch-size"] self._segment_indices = self._get_segment_indices() @@ -63,9 +63,13 @@ def init_model(self): self._exclude_gpus, self.input_size, 19) - placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), - dtype="float32") - self.model.predict(placeholder) + + for i in range(10): + print(i, end="\r") + placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), + dtype="float32") + self.model.predict(placeholder) + exit(0) def process_input(self, batch): """ Compile the detected faces for prediction """ From 1b3530f1042e9d788ab6e87dc2235fd661597a1f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 16 May 2021 00:21:19 +0100 Subject: [PATCH 462/981] Add support for different mask centering types --- lib/align/detected_face.py | 54 ++++++++++++++++++++---------- lib/convert.py | 5 +-- lib/training/generator.py | 4 +-- plugins/convert/mask/mask_blend.py | 12 ++++--- plugins/extract/mask/_base.py | 6 ++-- tools/mask/mask.py | 10 +++--- tools/sort/sort.py | 4 +-- 7 files changed, 59 insertions(+), 36 deletions(-) diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index b19eccab0e..66bc3caa4a 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -112,7 +112,8 @@ def bottom(self): """int: Bottom point (in pixels) of face detection bounding box within the parent image """ return self.y + self.h - def add_mask(self, name, mask, affine_matrix, interpolator, storage_size=128): + def add_mask(self, name, mask, affine_matrix, interpolator, + storage_size=128, storage_centering="face"): """ Add a :class:`Mask` to this detected face The mask should be the original output from :mod:`plugins.extract.mask` @@ -133,15 +134,19 @@ def add_mask(self, name, mask, affine_matrix, interpolator, storage_size=128): The CV2 interpolator required to transform this mask to it's original frame. storage_size, int (optional): The size the mask is to be stored at. Default: 128 + storage_centering, str (optional): + The centering to store the mask at. One of `"legacy"`, `"face"`, `"head"`. + Default: `"face"` """ - logger.trace("name: '%s', mask shape: %s, affine_matrix: %s, interpolator: %s)", - name, mask.shape, affine_matrix, interpolator) - fsmask = Mask(storage_size=storage_size) + logger.trace("name: '%s', mask shape: %s, affine_matrix: %s, interpolator: %s, " + "storage_size: %s, storage_centering: %s)", name, mask.shape, affine_matrix, + interpolator, storage_size, storage_centering) + fsmask = Mask(storage_size=storage_size, storage_centering=storage_centering) fsmask.add(mask, affine_matrix, interpolator) self.mask[name] = fsmask def get_landmark_mask(self, size, area, - aligned=True, centering="head", dilation=0, blur_kernel=0, as_zip=False): + aligned=True, centering="face", dilation=0, blur_kernel=0, as_zip=False): """ Obtain a single channel mask based on the face's landmark points. Parameters @@ -437,14 +442,22 @@ class Mask(): ---------- storage_size: int, optional The size (in pixels) that the mask should be stored at. Default: 128. + storage_centering, str (optional): + The centering to store the mask at. One of `"legacy"`, `"face"`, `"head"`. + Default: `"face"` Attributes ---------- stored_size: int The size, in pixels, of the stored mask across its height and width. + stored_centering: str + The centering that the mask is stored at. One of `"legacy"`, `"face"`, `"head"` """ - def __init__(self, storage_size=128): + def __init__(self, storage_size=128, storage_centering="face"): + logger.trace("Initializing: %s (storage_size: %s, storage_centering: %s)", + self.__class__.__name__, storage_size, storage_centering) self.stored_size = storage_size + self.stored_centering = storage_centering self._mask = None self._affine_matrix = None @@ -455,6 +468,7 @@ def __init__(self, storage_size=128): self._threshold = 0.0 self._sub_crop = dict(size=None, slice_in=[], slice_out=[]) self.set_blur_and_threshold() + logger.trace("Initialized: %s", self.__class__.__name__) @property def mask(self): @@ -590,7 +604,7 @@ def set_blur_and_threshold(self, self._blur["passes"] = blur_passes self._threshold = (threshold / 100.0) * 255.0 - def set_sub_crop(self, offset): + def set_sub_crop(self, offset, centering): """ Set the internal crop area of the mask to be returned. This impacts the returned mask from :attr:`mask` if the requested mask is required for @@ -600,17 +614,21 @@ def set_sub_crop(self, offset): ---------- offset: :class:`numpy.ndarray` The (x, y) offset from the center point to return the mask for + centering: str + The centering to set the sub crop area for. One of `"legacy"`, `"face"`. `"head"` Notes ----- - All masks are currently stored with `face` centering and all crops are for 'legacy` - centering. This may change in future + All crops are for 'legacy` centering. This may change in future """ - src_size = self.stored_size - (self.stored_size * _EXTRACT_RATIOS["face"]) + if centering == self.stored_centering: + return + + src_size = self.stored_size - (self.stored_size * _EXTRACT_RATIOS[self.stored_centering]) offset *= ((self.stored_size - (src_size / 2)) / 2) center = np.rint(offset + self.stored_size / 2).astype("int32") - crop_size = get_centered_size("face", "legacy", self.stored_size) + crop_size = get_centered_size(self.stored_centering, centering, self.stored_size) roi = np.array([center - crop_size // 2, center + crop_size // 2]).ravel() self._sub_crop["size"] = crop_size @@ -654,10 +672,10 @@ def to_dict(self): ------- dict: The :class:`Mask` for saving to an alignments file. Contains the keys ``mask``, - ``affine_matrix``, ``interpolator``, ``stored_size`` + ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` """ retval = dict() - for key in ("mask", "affine_matrix", "interpolator", "stored_size"): + for key in ("mask", "affine_matrix", "interpolator", "stored_size", "stored_centering"): retval[key] = getattr(self, self._attr_name(key)) logger.trace({k: v if k != "mask" else type(v) for k, v in retval.items()}) return retval @@ -669,10 +687,10 @@ def to_png_meta(self): ------- dict: The :class:`Mask` for saving to an alignments file. Contains the keys ``mask``, - ``affine_matrix``, ``interpolator``, ``stored_size`` + ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` """ retval = dict() - for key in ("mask", "affine_matrix", "interpolator", "stored_size"): + for key in ("mask", "affine_matrix", "interpolator", "stored_size", "stored_centering"): val = getattr(self, self._attr_name(key)) if isinstance(val, np.ndarray): retval[key] = val.tolist() @@ -688,9 +706,9 @@ def from_dict(self, mask_dict): ---------- mask_dict: dict A dictionary stored in an alignments file containing the keys ``mask``, - ``affine_matrix``, ``interpolator``, ``stored_size`` + ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` """ - for key in ("mask", "affine_matrix", "interpolator", "stored_size"): + for key in ("mask", "affine_matrix", "interpolator", "stored_size", "stored_centering"): val = mask_dict[key] if key == "affine_matrix" and not isinstance(val, np.ndarray): val = np.array(val, dtype="float64") @@ -711,7 +729,7 @@ def _attr_name(dict_key): attribute_name: str The attribute name for the given key for :class:`Mask` """ - retval = "_{}".format(dict_key) if dict_key != "stored_size" else dict_key + retval = "_{}".format(dict_key) if not dict_key.startswith("stored") else dict_key logger.trace("dict_key: %s, attribute_name: %s", dict_key, retval) return retval diff --git a/lib/convert.py b/lib/convert.py index e61e680528..d9454eb4cc 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -318,8 +318,9 @@ def _get_image_mask(self, new_face, detected_face, predicted_mask, reference_fac The swapped face with the requested mask added to the Alpha channel """ logger.trace("Getting mask. Image shape: %s", new_face.shape) - if self._centering == "legacy": - crop_offset = reference_face.pose.offset["face"] * -1 + mask_centering = detected_face.mask[self._args.mask_type].stored_centering + if self._centering != mask_centering: + crop_offset = reference_face.pose.offset[mask_centering] * -1 else: crop_offset = np.array((0, 0)) mask, raw_mask = self._adjustments["mask"].run(detected_face, crop_offset, predicted_mask) diff --git a/lib/training/generator.py b/lib/training/generator.py index 3ae18a1be1..50b7dde51c 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -392,8 +392,8 @@ def _add_mask(self, filename, detected_face): mask.set_blur_and_threshold(blur_kernel=self._config["mask_blur_kernel"], threshold=self._config["mask_threshold"]) - if self._extract_version > 1.0 and self._centering == "legacy": - mask.set_sub_crop(self._cache[key]["aligned_face"].pose.offset["face"] * -1) + mask.set_sub_crop(self._cache[key]["aligned_face"].pose.offset[mask.stored_centering] * -1, + self._centering) logger.trace("Caching mask for: %s", filename) self._cache[key]["mask"] = mask diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index 27714d930d..2cc8dd0273 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -28,7 +28,7 @@ def __init__(self, mask_type, output_size, coverage_ratio, **kwargs): self._coverage_ratio = coverage_ratio def process(self, detected_face, sub_crop_offset, # pylint:disable=arguments-differ - predicted_mask=None,): + centering, predicted_mask=None,): """ Obtain the requested mask type and perform any defined mask manipulations. Parameters @@ -37,6 +37,8 @@ def process(self, detected_face, sub_crop_offset, # pylint:disable=arguments- The DetectedFace object as returned from :class:`scripts.convert.Predictor`. sub_crop_offset: :class:`numpy.ndarray`, optional The (x, y) offset to crop the mask from the center point. + centering: [`"legacy"`, `"face"`, `"head"`] + The centering to obtain the mask for predicted_mask: :class:`numpy.ndarray`, optional The predicted mask as output from the Faceswap Model, if the model was trained with a mask, otherwise ``None``. Default: ``None``. @@ -48,14 +50,14 @@ def process(self, detected_face, sub_crop_offset, # pylint:disable=arguments- raw_mask: :class:`numpy.ndarray` The mask with no erosion/dilation applied """ - mask = self._get_mask(detected_face, predicted_mask, sub_crop_offset) + mask = self._get_mask(detected_face, predicted_mask, centering, sub_crop_offset) raw_mask = mask.copy() if not self.skip and self._do_erode: mask = self._erode(mask) logger.trace("mask shape: %s, raw_mask shape: %s", mask.shape, raw_mask.shape) return mask, raw_mask - def _get_mask(self, detected_face, predicted_mask, sub_crop_offset): + def _get_mask(self, detected_face, predicted_mask, centering, sub_crop_offset): """ Return the requested mask with any requested blurring applied. Parameters @@ -65,6 +67,8 @@ def _get_mask(self, detected_face, predicted_mask, sub_crop_offset): predicted_mask: :class:`numpy.ndarray` The predicted mask as output from the Faceswap Model if the model was trained with a mask, otherwise ``None`` + centering: [`"legacy"`, `"face"`, `"head"`] + The centering to obtain the mask for sub_crop_offset: :class:`numpy.ndarray` The (x, y) offset to crop the mask from the center point. Set to `None` if the mask does not need to be offset for alternative centering @@ -86,7 +90,7 @@ def _get_mask(self, detected_face, predicted_mask, sub_crop_offset): blur_passes=self.config["passes"], threshold=self.config["threshold"]) if np.any(sub_crop_offset): - mask.set_sub_crop(sub_crop_offset) + mask.set_sub_crop(sub_crop_offset, centering) mask = self._crop_to_coverage(mask.mask) mask_size = mask.shape[0] face_size = self.dummy.shape[0] diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index b02e3ede2e..6467b97f62 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -67,6 +67,7 @@ def __init__(self, git_model_id=None, model_filename=None, configfile=None, self._plugin_type = "mask" self._image_is_aligned = image_is_aligned self._storage_name = self.__module__.split(".")[-1].replace("_", "-") + self._storage_centering = "face" # Centering to store the mask at self._storage_size = 128 # Size to store masks at. Leave this at default self._faces_per_filename = dict() # Tracking for recompiling face batches self._rollover = None # Items that are rolled over from the previous batch in get_batch @@ -122,7 +123,7 @@ def get_batch(self, queue): for f_idx, face in enumerate(item.detected_faces): feed_face = AlignedFace(face.landmarks_xy, image=item.get_image_copy(self.color_format), - centering="face", + centering=self._storage_centering, size=self.input_size, coverage_ratio=self.coverage_ratio, dtype="float32", @@ -224,7 +225,8 @@ def finalize(self, batch): mask, feed_face.adjusted_matrix, feed_face.interpolators[1], - storage_size=self._storage_size) + storage_size=self._storage_size, + storage_centering=self._storage_centering) del batch["feed_faces"] logger.trace("Item out: %s", {key: val.shape if isinstance(val, np.ndarray) else val diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 1099a1f46d..106ac9f562 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -163,8 +163,7 @@ def _input_faces(self, *args): 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 + 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) @@ -175,8 +174,7 @@ def _input_faces(self, *args): alignment = self._alignments.get_faces_in_frame(frame_name) if not alignment or face_index > len(alignment) - 1: self._counts["skip"] += 1 - logger.warning("Skipping Face not found in alignments file. skipping: '%s'", - filename) + logger.warning("Skipping Face not found in alignments file: '%s'", filename) continue alignment = alignment[face_index] self._counts["face"] += 1 @@ -419,11 +417,11 @@ def _create_image(self, detected_face): if self._input_is_faces: face = AlignedFace(detected_face.landmarks_xy, image=detected_face.image, - centering="face", + centering=mask.stored_centering, size=detected_face.image.shape[0], is_aligned=True).face else: - centering = "legacy" if self._alignments.version == 1.0 else "face" + centering = "legacy" if self._alignments.version == 1.0 else mask.stored_centering detected_face.load_aligned(detected_face.image, centering=centering) face = detected_face.aligned.face mask = cv2.resize(detected_face.mask[self._mask_type].mask, diff --git a/tools/sort/sort.py b/tools/sort/sort.py index b4cc4140f8..67e2eba8b6 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -743,7 +743,7 @@ def estimate_blur(cls, image, metadata=None): size=256, is_aligned=True) mask = det_face.mask["components"] - mask.set_sub_crop(aln_face.pose.offset["face"] * -1) + mask.set_sub_crop(aln_face.pose.offset[mask.stored_centering] * -1, centering="legacy") mask = cv2.resize(mask.mask, (256, 256), interpolation=cv2.INTER_CUBIC)[..., None] image = np.minimum(aln_face.face, mask) if image.ndim == 3: @@ -784,7 +784,7 @@ def estimate_blur_fft(cls, image, metadata=None): size=256, is_aligned=True) mask = det_face.mask["components"] - mask.set_sub_crop(aln_face.pose.offset["face"] * -1) + mask.set_sub_crop(aln_face.pose.offset[mask.stored_centering] * -1, centering="legacy") mask = cv2.resize(mask.mask, (256, 256), interpolation=cv2.INTER_CUBIC)[..., None] image = np.minimum(aln_face.face, mask) if image.ndim == 3: From c34709d97ead26d13b19829da2ed1597964f6a74 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 16 May 2021 00:25:24 +0100 Subject: [PATCH 463/981] typo fix --- plugins/extract/mask/bisenet_fp.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/plugins/extract/mask/bisenet_fp.py b/plugins/extract/mask/bisenet_fp.py index 8aadd368cd..090510ab52 100644 --- a/plugins/extract/mask/bisenet_fp.py +++ b/plugins/extract/mask/bisenet_fp.py @@ -30,6 +30,7 @@ def __init__(self, **kwargs): self.vram_per_batch = 64 self.batchsize = self.config["batch-size"] self._segment_indices = self._get_segment_indices() + self._storage_centering = "head" if self.config["include_hair"] else "face" def _get_segment_indices(self): """ Obtain the segment indices to include within the face mask area based on user @@ -64,12 +65,9 @@ def init_model(self): self.input_size, 19) - for i in range(10): - print(i, end="\r") - placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), - dtype="float32") - self.model.predict(placeholder) - exit(0) + placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), + dtype="float32") + self.model.predict(placeholder) def process_input(self, batch): """ Compile the detected faces for prediction """ From b50b021e200e06499e99ce8a7e3f51975c3665da Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 16 May 2021 00:38:51 +0100 Subject: [PATCH 464/981] Update mask centering parameter into legacy alignments --- lib/align/alignments.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/align/alignments.py b/lib/align/alignments.py index 69ec40233c..4f5d8eb4b2 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -12,7 +12,7 @@ from lib.utils import FaceswapError logger = logging.getLogger(__name__) # pylint: disable=invalid-name -_VERSION = 2.1 +_VERSION = 2.2 # VERSION TRACKING @@ -21,6 +21,7 @@ # legacy extract # 2.1 - Alignments data to extracted face PNG header. SHA1 hashes of faces no longer calculated # or stored in alignments file +# 2.2 - Add support for differently centered masks (i.e. not all masks stored as face centering) class Alignments(): """ The alignments file is a custom serialized ``.fsa`` file that holds information for each @@ -619,6 +620,10 @@ def _update_legacy(self): logger.info("Updating legacy landmarks from list to numpy array") self._update_legacy_landmarks_list() updated = True + if self._version < 2.2: + logger.info("Updating legacy mask centering") + self._update_mask_centering() + updated = True if updated: self.save() @@ -751,6 +756,16 @@ def _update_legacy_landmarks_list(self): update_count += 1 logger.debug("Updated landmarks_xy: %s", update_count) + # Masks not containing the stored_centering parameters. Prior to this implementation all masks + # were stored with face centering + def _update_mask_centering(self): + update_count = 0 + for val in self._data.values(): + for alignment in val["faces"]: + for mask in alignment["mask"].values(): + mask["stored_centering"] = "face" + logger.debug("Updated legacy mask centering: %s", update_count) + class Thumbnails(): """ Thumbnail images stored in the alignments file. From 5766316cd7f2765fee8d0350f562f7322ed0dd94 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 16 May 2021 01:04:33 +0100 Subject: [PATCH 465/981] Update alignments version on legacy update --- lib/align/alignments.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/align/alignments.py b/lib/align/alignments.py index 4f5d8eb4b2..a0fd1ce3bd 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -625,6 +625,7 @@ def _update_legacy(self): self._update_mask_centering() updated = True if updated: + self._version = _VERSION self.save() # # From e7fbcc1eebf4e81184e782ea7fbcc8d9284f72d1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 16 May 2021 13:08:12 +0100 Subject: [PATCH 466/981] bugfix: lib.image ImageIO. Ensure unique queues are created --- lib/image.py | 5 ++++- lib/queue_manager.py | 42 +++++++++++++++++++++++++++++++++--------- tools/mask/mask.py | 6 +++--- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/lib/image.py b/lib/image.py index c2679cd0d3..d5752e86d7 100644 --- a/lib/image.py +++ b/lib/image.py @@ -846,7 +846,10 @@ def __init__(self, path, queue_size, args=None): self._location = path self._check_location_exists() - self._queue = queue_manager.get_queue(name=self.__class__.__name__, maxsize=queue_size) + queue_name = queue_manager.add_queue(name=self.__class__.__name__, + maxsize=queue_size, + create_new=True) + self._queue = queue_manager.get_queue(queue_name) self._thread = None @property diff --git a/lib/queue_manager.py b/lib/queue_manager.py index 47b842e34c..f9fb83e63b 100644 --- a/lib/queue_manager.py +++ b/lib/queue_manager.py @@ -24,21 +24,45 @@ def __init__(self): self.queues = dict() logger.debug("Initialized %s", self.__class__.__name__) - def add_queue(self, name, maxsize=0): - """ Add a queue to the manager - - Adds an event "shutdown" to the queue that can be used to indicate - to a process that any activity on the queue should cease """ - - logger.debug("QueueManager adding: (name: '%s', maxsize: %s)", name, maxsize) - if name in self.queues.keys(): + def add_queue(self, name, maxsize=0, create_new=False): + """ Add a queue to the manager. + + Adds an event "shutdown" to the queue that can be used to indicate to a process that any + activity on the queue should cease. + + Parameters + ---------- + name: str + The name of the queue to create + maxsize: int, optional + The maximum queue size. Set to `0` for unlimited. Default: `0` + create_new: bool, optional + If a queue of the given name exists, and this value is ``False``, then an error is + raised preventing the creation of duplicate queues. If this value is ``True`` and + the given name exists then an integer is appended to the end of the queue name and + incremented until the given name is unique. Default: ``False`` + + Returns + ------- + str + The final generated name for the queue + """ + logger.debug("QueueManager adding: (name: '%s', maxsize: %s, create_new: %s)", + name, maxsize, create_new) + if not create_new and name in self.queues: raise ValueError("Queue '{}' already exists.".format(name)) + if create_new and name in self.queues: + i = 0 + while name in self.queues: + name = f"{name}{i}" + logger.debug("Duplicate queue name. Updated to: '%s'", name) queue = Queue(maxsize=maxsize) setattr(queue, "shutdown", self.shutdown) self.queues[name] = queue logger.debug("QueueManager added: (name: '%s')", name) + return name def del_queue(self, name): """ remove a queue from the manager """ @@ -70,7 +94,7 @@ def terminate_queues(self): def flush_queues(self): """ Empty out all queues """ - for q_name in self.queues.keys(): + for q_name in self.queues: self.flush_queue(q_name) logger.debug("QueueManager flushed all queues") diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 106ac9f562..c312155121 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -308,14 +308,14 @@ def process(self): for extractor_output in self._extractor.detected_faces(): self._extractor_input_thread.check_and_raise_error() updater(extractor_output) - self._extractor_input_thread.join() if self._counts["update"] != 0: self._alignments.backup() self._alignments.save() if self._input_is_faces: self._faces_saver.close() - else: - self._extractor_input_thread.join() + + self._extractor_input_thread.join() + if self._saver is not None: self._saver.close() if self._counts["skip"] != 0: From 40798fd27f466e67b620919038b31cef0b2eb767 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 16 May 2021 13:24:00 +0100 Subject: [PATCH 467/981] core mask updates: - Add support for different mask centering - Update legacy alignments to store mask centering - Bugfix: lib.image ImageIO. Ensure unique queues are created (fixes mask tool when Face is input and an output folder is provided) --- lib/align/alignments.py | 18 +++++++++- lib/align/detected_face.py | 54 ++++++++++++++++++++---------- lib/convert.py | 5 +-- lib/image.py | 5 ++- lib/queue_manager.py | 42 ++++++++++++++++++----- lib/training/generator.py | 4 +-- plugins/convert/mask/mask_blend.py | 12 ++++--- plugins/extract/mask/_base.py | 6 ++-- tools/mask/mask.py | 16 ++++----- tools/sort/sort.py | 4 +-- 10 files changed, 116 insertions(+), 50 deletions(-) diff --git a/lib/align/alignments.py b/lib/align/alignments.py index 69ec40233c..a0fd1ce3bd 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -12,7 +12,7 @@ from lib.utils import FaceswapError logger = logging.getLogger(__name__) # pylint: disable=invalid-name -_VERSION = 2.1 +_VERSION = 2.2 # VERSION TRACKING @@ -21,6 +21,7 @@ # legacy extract # 2.1 - Alignments data to extracted face PNG header. SHA1 hashes of faces no longer calculated # or stored in alignments file +# 2.2 - Add support for differently centered masks (i.e. not all masks stored as face centering) class Alignments(): """ The alignments file is a custom serialized ``.fsa`` file that holds information for each @@ -619,7 +620,12 @@ def _update_legacy(self): logger.info("Updating legacy landmarks from list to numpy array") self._update_legacy_landmarks_list() updated = True + if self._version < 2.2: + logger.info("Updating legacy mask centering") + self._update_mask_centering() + updated = True if updated: + self._version = _VERSION self.save() # # @@ -751,6 +757,16 @@ def _update_legacy_landmarks_list(self): update_count += 1 logger.debug("Updated landmarks_xy: %s", update_count) + # Masks not containing the stored_centering parameters. Prior to this implementation all masks + # were stored with face centering + def _update_mask_centering(self): + update_count = 0 + for val in self._data.values(): + for alignment in val["faces"]: + for mask in alignment["mask"].values(): + mask["stored_centering"] = "face" + logger.debug("Updated legacy mask centering: %s", update_count) + class Thumbnails(): """ Thumbnail images stored in the alignments file. diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index b19eccab0e..66bc3caa4a 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -112,7 +112,8 @@ def bottom(self): """int: Bottom point (in pixels) of face detection bounding box within the parent image """ return self.y + self.h - def add_mask(self, name, mask, affine_matrix, interpolator, storage_size=128): + def add_mask(self, name, mask, affine_matrix, interpolator, + storage_size=128, storage_centering="face"): """ Add a :class:`Mask` to this detected face The mask should be the original output from :mod:`plugins.extract.mask` @@ -133,15 +134,19 @@ def add_mask(self, name, mask, affine_matrix, interpolator, storage_size=128): The CV2 interpolator required to transform this mask to it's original frame. storage_size, int (optional): The size the mask is to be stored at. Default: 128 + storage_centering, str (optional): + The centering to store the mask at. One of `"legacy"`, `"face"`, `"head"`. + Default: `"face"` """ - logger.trace("name: '%s', mask shape: %s, affine_matrix: %s, interpolator: %s)", - name, mask.shape, affine_matrix, interpolator) - fsmask = Mask(storage_size=storage_size) + logger.trace("name: '%s', mask shape: %s, affine_matrix: %s, interpolator: %s, " + "storage_size: %s, storage_centering: %s)", name, mask.shape, affine_matrix, + interpolator, storage_size, storage_centering) + fsmask = Mask(storage_size=storage_size, storage_centering=storage_centering) fsmask.add(mask, affine_matrix, interpolator) self.mask[name] = fsmask def get_landmark_mask(self, size, area, - aligned=True, centering="head", dilation=0, blur_kernel=0, as_zip=False): + aligned=True, centering="face", dilation=0, blur_kernel=0, as_zip=False): """ Obtain a single channel mask based on the face's landmark points. Parameters @@ -437,14 +442,22 @@ class Mask(): ---------- storage_size: int, optional The size (in pixels) that the mask should be stored at. Default: 128. + storage_centering, str (optional): + The centering to store the mask at. One of `"legacy"`, `"face"`, `"head"`. + Default: `"face"` Attributes ---------- stored_size: int The size, in pixels, of the stored mask across its height and width. + stored_centering: str + The centering that the mask is stored at. One of `"legacy"`, `"face"`, `"head"` """ - def __init__(self, storage_size=128): + def __init__(self, storage_size=128, storage_centering="face"): + logger.trace("Initializing: %s (storage_size: %s, storage_centering: %s)", + self.__class__.__name__, storage_size, storage_centering) self.stored_size = storage_size + self.stored_centering = storage_centering self._mask = None self._affine_matrix = None @@ -455,6 +468,7 @@ def __init__(self, storage_size=128): self._threshold = 0.0 self._sub_crop = dict(size=None, slice_in=[], slice_out=[]) self.set_blur_and_threshold() + logger.trace("Initialized: %s", self.__class__.__name__) @property def mask(self): @@ -590,7 +604,7 @@ def set_blur_and_threshold(self, self._blur["passes"] = blur_passes self._threshold = (threshold / 100.0) * 255.0 - def set_sub_crop(self, offset): + def set_sub_crop(self, offset, centering): """ Set the internal crop area of the mask to be returned. This impacts the returned mask from :attr:`mask` if the requested mask is required for @@ -600,17 +614,21 @@ def set_sub_crop(self, offset): ---------- offset: :class:`numpy.ndarray` The (x, y) offset from the center point to return the mask for + centering: str + The centering to set the sub crop area for. One of `"legacy"`, `"face"`. `"head"` Notes ----- - All masks are currently stored with `face` centering and all crops are for 'legacy` - centering. This may change in future + All crops are for 'legacy` centering. This may change in future """ - src_size = self.stored_size - (self.stored_size * _EXTRACT_RATIOS["face"]) + if centering == self.stored_centering: + return + + src_size = self.stored_size - (self.stored_size * _EXTRACT_RATIOS[self.stored_centering]) offset *= ((self.stored_size - (src_size / 2)) / 2) center = np.rint(offset + self.stored_size / 2).astype("int32") - crop_size = get_centered_size("face", "legacy", self.stored_size) + crop_size = get_centered_size(self.stored_centering, centering, self.stored_size) roi = np.array([center - crop_size // 2, center + crop_size // 2]).ravel() self._sub_crop["size"] = crop_size @@ -654,10 +672,10 @@ def to_dict(self): ------- dict: The :class:`Mask` for saving to an alignments file. Contains the keys ``mask``, - ``affine_matrix``, ``interpolator``, ``stored_size`` + ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` """ retval = dict() - for key in ("mask", "affine_matrix", "interpolator", "stored_size"): + for key in ("mask", "affine_matrix", "interpolator", "stored_size", "stored_centering"): retval[key] = getattr(self, self._attr_name(key)) logger.trace({k: v if k != "mask" else type(v) for k, v in retval.items()}) return retval @@ -669,10 +687,10 @@ def to_png_meta(self): ------- dict: The :class:`Mask` for saving to an alignments file. Contains the keys ``mask``, - ``affine_matrix``, ``interpolator``, ``stored_size`` + ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` """ retval = dict() - for key in ("mask", "affine_matrix", "interpolator", "stored_size"): + for key in ("mask", "affine_matrix", "interpolator", "stored_size", "stored_centering"): val = getattr(self, self._attr_name(key)) if isinstance(val, np.ndarray): retval[key] = val.tolist() @@ -688,9 +706,9 @@ def from_dict(self, mask_dict): ---------- mask_dict: dict A dictionary stored in an alignments file containing the keys ``mask``, - ``affine_matrix``, ``interpolator``, ``stored_size`` + ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` """ - for key in ("mask", "affine_matrix", "interpolator", "stored_size"): + for key in ("mask", "affine_matrix", "interpolator", "stored_size", "stored_centering"): val = mask_dict[key] if key == "affine_matrix" and not isinstance(val, np.ndarray): val = np.array(val, dtype="float64") @@ -711,7 +729,7 @@ def _attr_name(dict_key): attribute_name: str The attribute name for the given key for :class:`Mask` """ - retval = "_{}".format(dict_key) if dict_key != "stored_size" else dict_key + retval = "_{}".format(dict_key) if not dict_key.startswith("stored") else dict_key logger.trace("dict_key: %s, attribute_name: %s", dict_key, retval) return retval diff --git a/lib/convert.py b/lib/convert.py index e61e680528..d9454eb4cc 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -318,8 +318,9 @@ def _get_image_mask(self, new_face, detected_face, predicted_mask, reference_fac The swapped face with the requested mask added to the Alpha channel """ logger.trace("Getting mask. Image shape: %s", new_face.shape) - if self._centering == "legacy": - crop_offset = reference_face.pose.offset["face"] * -1 + mask_centering = detected_face.mask[self._args.mask_type].stored_centering + if self._centering != mask_centering: + crop_offset = reference_face.pose.offset[mask_centering] * -1 else: crop_offset = np.array((0, 0)) mask, raw_mask = self._adjustments["mask"].run(detected_face, crop_offset, predicted_mask) diff --git a/lib/image.py b/lib/image.py index c2679cd0d3..d5752e86d7 100644 --- a/lib/image.py +++ b/lib/image.py @@ -846,7 +846,10 @@ def __init__(self, path, queue_size, args=None): self._location = path self._check_location_exists() - self._queue = queue_manager.get_queue(name=self.__class__.__name__, maxsize=queue_size) + queue_name = queue_manager.add_queue(name=self.__class__.__name__, + maxsize=queue_size, + create_new=True) + self._queue = queue_manager.get_queue(queue_name) self._thread = None @property diff --git a/lib/queue_manager.py b/lib/queue_manager.py index 47b842e34c..f9fb83e63b 100644 --- a/lib/queue_manager.py +++ b/lib/queue_manager.py @@ -24,21 +24,45 @@ def __init__(self): self.queues = dict() logger.debug("Initialized %s", self.__class__.__name__) - def add_queue(self, name, maxsize=0): - """ Add a queue to the manager - - Adds an event "shutdown" to the queue that can be used to indicate - to a process that any activity on the queue should cease """ - - logger.debug("QueueManager adding: (name: '%s', maxsize: %s)", name, maxsize) - if name in self.queues.keys(): + def add_queue(self, name, maxsize=0, create_new=False): + """ Add a queue to the manager. + + Adds an event "shutdown" to the queue that can be used to indicate to a process that any + activity on the queue should cease. + + Parameters + ---------- + name: str + The name of the queue to create + maxsize: int, optional + The maximum queue size. Set to `0` for unlimited. Default: `0` + create_new: bool, optional + If a queue of the given name exists, and this value is ``False``, then an error is + raised preventing the creation of duplicate queues. If this value is ``True`` and + the given name exists then an integer is appended to the end of the queue name and + incremented until the given name is unique. Default: ``False`` + + Returns + ------- + str + The final generated name for the queue + """ + logger.debug("QueueManager adding: (name: '%s', maxsize: %s, create_new: %s)", + name, maxsize, create_new) + if not create_new and name in self.queues: raise ValueError("Queue '{}' already exists.".format(name)) + if create_new and name in self.queues: + i = 0 + while name in self.queues: + name = f"{name}{i}" + logger.debug("Duplicate queue name. Updated to: '%s'", name) queue = Queue(maxsize=maxsize) setattr(queue, "shutdown", self.shutdown) self.queues[name] = queue logger.debug("QueueManager added: (name: '%s')", name) + return name def del_queue(self, name): """ remove a queue from the manager """ @@ -70,7 +94,7 @@ def terminate_queues(self): def flush_queues(self): """ Empty out all queues """ - for q_name in self.queues.keys(): + for q_name in self.queues: self.flush_queue(q_name) logger.debug("QueueManager flushed all queues") diff --git a/lib/training/generator.py b/lib/training/generator.py index 3ae18a1be1..50b7dde51c 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -392,8 +392,8 @@ def _add_mask(self, filename, detected_face): mask.set_blur_and_threshold(blur_kernel=self._config["mask_blur_kernel"], threshold=self._config["mask_threshold"]) - if self._extract_version > 1.0 and self._centering == "legacy": - mask.set_sub_crop(self._cache[key]["aligned_face"].pose.offset["face"] * -1) + mask.set_sub_crop(self._cache[key]["aligned_face"].pose.offset[mask.stored_centering] * -1, + self._centering) logger.trace("Caching mask for: %s", filename) self._cache[key]["mask"] = mask diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index 27714d930d..2cc8dd0273 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -28,7 +28,7 @@ def __init__(self, mask_type, output_size, coverage_ratio, **kwargs): self._coverage_ratio = coverage_ratio def process(self, detected_face, sub_crop_offset, # pylint:disable=arguments-differ - predicted_mask=None,): + centering, predicted_mask=None,): """ Obtain the requested mask type and perform any defined mask manipulations. Parameters @@ -37,6 +37,8 @@ def process(self, detected_face, sub_crop_offset, # pylint:disable=arguments- The DetectedFace object as returned from :class:`scripts.convert.Predictor`. sub_crop_offset: :class:`numpy.ndarray`, optional The (x, y) offset to crop the mask from the center point. + centering: [`"legacy"`, `"face"`, `"head"`] + The centering to obtain the mask for predicted_mask: :class:`numpy.ndarray`, optional The predicted mask as output from the Faceswap Model, if the model was trained with a mask, otherwise ``None``. Default: ``None``. @@ -48,14 +50,14 @@ def process(self, detected_face, sub_crop_offset, # pylint:disable=arguments- raw_mask: :class:`numpy.ndarray` The mask with no erosion/dilation applied """ - mask = self._get_mask(detected_face, predicted_mask, sub_crop_offset) + mask = self._get_mask(detected_face, predicted_mask, centering, sub_crop_offset) raw_mask = mask.copy() if not self.skip and self._do_erode: mask = self._erode(mask) logger.trace("mask shape: %s, raw_mask shape: %s", mask.shape, raw_mask.shape) return mask, raw_mask - def _get_mask(self, detected_face, predicted_mask, sub_crop_offset): + def _get_mask(self, detected_face, predicted_mask, centering, sub_crop_offset): """ Return the requested mask with any requested blurring applied. Parameters @@ -65,6 +67,8 @@ def _get_mask(self, detected_face, predicted_mask, sub_crop_offset): predicted_mask: :class:`numpy.ndarray` The predicted mask as output from the Faceswap Model if the model was trained with a mask, otherwise ``None`` + centering: [`"legacy"`, `"face"`, `"head"`] + The centering to obtain the mask for sub_crop_offset: :class:`numpy.ndarray` The (x, y) offset to crop the mask from the center point. Set to `None` if the mask does not need to be offset for alternative centering @@ -86,7 +90,7 @@ def _get_mask(self, detected_face, predicted_mask, sub_crop_offset): blur_passes=self.config["passes"], threshold=self.config["threshold"]) if np.any(sub_crop_offset): - mask.set_sub_crop(sub_crop_offset) + mask.set_sub_crop(sub_crop_offset, centering) mask = self._crop_to_coverage(mask.mask) mask_size = mask.shape[0] face_size = self.dummy.shape[0] diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index b02e3ede2e..6467b97f62 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -67,6 +67,7 @@ def __init__(self, git_model_id=None, model_filename=None, configfile=None, self._plugin_type = "mask" self._image_is_aligned = image_is_aligned self._storage_name = self.__module__.split(".")[-1].replace("_", "-") + self._storage_centering = "face" # Centering to store the mask at self._storage_size = 128 # Size to store masks at. Leave this at default self._faces_per_filename = dict() # Tracking for recompiling face batches self._rollover = None # Items that are rolled over from the previous batch in get_batch @@ -122,7 +123,7 @@ def get_batch(self, queue): for f_idx, face in enumerate(item.detected_faces): feed_face = AlignedFace(face.landmarks_xy, image=item.get_image_copy(self.color_format), - centering="face", + centering=self._storage_centering, size=self.input_size, coverage_ratio=self.coverage_ratio, dtype="float32", @@ -224,7 +225,8 @@ def finalize(self, batch): mask, feed_face.adjusted_matrix, feed_face.interpolators[1], - storage_size=self._storage_size) + storage_size=self._storage_size, + storage_centering=self._storage_centering) del batch["feed_faces"] logger.trace("Item out: %s", {key: val.shape if isinstance(val, np.ndarray) else val diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 1099a1f46d..c312155121 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -163,8 +163,7 @@ def _input_faces(self, *args): 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 + 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) @@ -175,8 +174,7 @@ def _input_faces(self, *args): alignment = self._alignments.get_faces_in_frame(frame_name) if not alignment or face_index > len(alignment) - 1: self._counts["skip"] += 1 - logger.warning("Skipping Face not found in alignments file. skipping: '%s'", - filename) + logger.warning("Skipping Face not found in alignments file: '%s'", filename) continue alignment = alignment[face_index] self._counts["face"] += 1 @@ -310,14 +308,14 @@ def process(self): for extractor_output in self._extractor.detected_faces(): self._extractor_input_thread.check_and_raise_error() updater(extractor_output) - self._extractor_input_thread.join() if self._counts["update"] != 0: self._alignments.backup() self._alignments.save() if self._input_is_faces: self._faces_saver.close() - else: - self._extractor_input_thread.join() + + self._extractor_input_thread.join() + if self._saver is not None: self._saver.close() if self._counts["skip"] != 0: @@ -419,11 +417,11 @@ def _create_image(self, detected_face): if self._input_is_faces: face = AlignedFace(detected_face.landmarks_xy, image=detected_face.image, - centering="face", + centering=mask.stored_centering, size=detected_face.image.shape[0], is_aligned=True).face else: - centering = "legacy" if self._alignments.version == 1.0 else "face" + centering = "legacy" if self._alignments.version == 1.0 else mask.stored_centering detected_face.load_aligned(detected_face.image, centering=centering) face = detected_face.aligned.face mask = cv2.resize(detected_face.mask[self._mask_type].mask, diff --git a/tools/sort/sort.py b/tools/sort/sort.py index b4cc4140f8..67e2eba8b6 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -743,7 +743,7 @@ def estimate_blur(cls, image, metadata=None): size=256, is_aligned=True) mask = det_face.mask["components"] - mask.set_sub_crop(aln_face.pose.offset["face"] * -1) + mask.set_sub_crop(aln_face.pose.offset[mask.stored_centering] * -1, centering="legacy") mask = cv2.resize(mask.mask, (256, 256), interpolation=cv2.INTER_CUBIC)[..., None] image = np.minimum(aln_face.face, mask) if image.ndim == 3: @@ -784,7 +784,7 @@ def estimate_blur_fft(cls, image, metadata=None): size=256, is_aligned=True) mask = det_face.mask["components"] - mask.set_sub_crop(aln_face.pose.offset["face"] * -1) + mask.set_sub_crop(aln_face.pose.offset[mask.stored_centering] * -1, centering="legacy") mask = cv2.resize(mask.mask, (256, 256), interpolation=cv2.INTER_CUBIC)[..., None] image = np.minimum(aln_face.face, mask) if image.ndim == 3: From 4f48e1c25b59848505d630463aa9b323cf666d40 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 16 May 2021 16:11:02 +0100 Subject: [PATCH 468/981] bugfix: Training, don't error on loading extracted faces without stored_centering --- lib/align/detected_face.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index 66bc3caa4a..59b4d788c5 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -709,7 +709,8 @@ def from_dict(self, mask_dict): ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` """ for key in ("mask", "affine_matrix", "interpolator", "stored_size", "stored_centering"): - val = mask_dict[key] + val = mask_dict.get(key) + val = "face" if key == "stored_centering" and val is None else val if key == "affine_matrix" and not isinstance(val, np.ndarray): val = np.array(val, dtype="float64") setattr(self, self._attr_name(key), val) From 58cedef281f36c1dab864a4e6b755ec0adeaff15 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 17 May 2021 12:06:18 +0100 Subject: [PATCH 469/981] bugfix: lib.convert - Correctly pass mask centering to mask plugin --- lib/convert.py | 4 +++- plugins/convert/mask/mask_blend.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/convert.py b/lib/convert.py index d9454eb4cc..daba314834 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -163,6 +163,7 @@ def process(self, in_queue, out_queue): logger.error("Failed to convert image: '%s'. Reason: %s", item["filename"], str(err)) image = item["image"] + logger.trace("Convert error traceback:", exc_info=True) # UNCOMMENT THIS CODE BLOCK TO PRINT TRACEBACK ERRORS # import sys ; import traceback # exc_info = sys.exc_info() ; traceback.print_exception(*exc_info) @@ -323,7 +324,8 @@ def _get_image_mask(self, new_face, detected_face, predicted_mask, reference_fac crop_offset = reference_face.pose.offset[mask_centering] * -1 else: crop_offset = np.array((0, 0)) - mask, raw_mask = self._adjustments["mask"].run(detected_face, crop_offset, predicted_mask) + mask, raw_mask = self._adjustments["mask"].run(detected_face, crop_offset, mask_centering, + predicted_mask=predicted_mask) if new_face.shape[2] == 4: logger.trace("Combining mask with alpha channel box mask") new_face[:, :, -1] = np.minimum(new_face[:, :, -1], mask.squeeze()) diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index 2cc8dd0273..020866b99f 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -50,6 +50,8 @@ def process(self, detected_face, sub_crop_offset, # pylint:disable=arguments- raw_mask: :class:`numpy.ndarray` The mask with no erosion/dilation applied """ + logger.trace("detected_face: %s, sub_crop_offset: %s, centering: '%s', predicted_mask: %s", + detected_face, sub_crop_offset, centering, predicted_mask is not None) mask = self._get_mask(detected_face, predicted_mask, centering, sub_crop_offset) raw_mask = mask.copy() if not self.skip and self._do_erode: From 63ab6f9bc5afee8bae2c8777daa90faea402a68f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 17 May 2021 17:18:08 +0100 Subject: [PATCH 470/981] split head and face mask naming --- plugins/extract/mask/bisenet_fp.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/extract/mask/bisenet_fp.py b/plugins/extract/mask/bisenet_fp.py index 090510ab52..20e9f15fdd 100644 --- a/plugins/extract/mask/bisenet_fp.py +++ b/plugins/extract/mask/bisenet_fp.py @@ -30,7 +30,10 @@ def __init__(self, **kwargs): self.vram_per_batch = 64 self.batchsize = self.config["batch-size"] self._segment_indices = self._get_segment_indices() + self._storage_centering = "head" if self.config["include_hair"] else "face" + # Separate storage for face and head masks + self._storage_name = f"{self._storage_name}_{self._storage_centering}" def _get_segment_indices(self): """ Obtain the segment indices to include within the face mask area based on user From 6c439944cdeba0aa9e4ab8347fb6780995f423c9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 17 May 2021 18:20:08 +0100 Subject: [PATCH 471/981] Masks updates - Add head centering support to training - Update helptext/tooltips --- lib/cli/args.py | 14 +- locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 40977 -> 42406 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 182 +++++++++++++---------- locales/es/LC_MESSAGES/tools.mask.cli.mo | Bin 7530 -> 7933 bytes locales/es/LC_MESSAGES/tools.mask.cli.po | 34 +++-- locales/lib.cli.args.pot | 159 ++++++++++---------- locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 53074 -> 54962 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 182 +++++++++++++---------- locales/tools.mask.cli.pot | 21 +-- plugins/plugin_loader.py | 25 +++- plugins/train/_config.py | 28 +++- tools/mask/cli.py | 3 + 12 files changed, 372 insertions(+), 276 deletions(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index f8e9a49310..3f9e62fa5c 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -395,6 +395,9 @@ def get_optional_arguments(): "RAM. You can select none, one or multiple masks, but the extraction may take " "longer the more you select. NB: The Extended and Components (landmark based) " "masks are automatically generated on extraction." + "\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|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." @@ -667,11 +670,20 @@ def get_optional_arguments(): type=str.lower, dest="mask_type", default="extended", - choices=PluginLoader.get_available_extractors("mask", add_none=True) + ["predicted"], + choices=PluginLoader.get_available_extractors("mask", + add_none=True, + extend_plugin=True) + ["predicted"], group=_("Plugins"), help=_("R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool." "\nL|none: Don't use a mask." + "\nL|bisenet-fp-face: Relatively lightweight NN based mask that provides more " + "refined control over the area to be masked (configurable in mask settings). " + "Use this version of bisenet-fp if your model is trained with 'face' or " + "'legacy' centering." + "\nL|bisenet-fp-head: Relatively lightweight NN based mask that provides more " + "refined control over the area to be masked (configurable in mask settings). " + "Use this version of bisenet-fp if your model is trained with 'head' centering." "\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." diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo index 4f07f7ea1954f845ee8fa83b65f26198b4be23fe..78be3fdb0134e33cfa68ef19fe8f01f6a7b97117 100644 GIT binary patch delta 2533 zcmchWU2GIp6vxjNT0v~Y5R{0ROE9jrw6IqBsGtZ~KBR?!U=dJeckgybW_QZWY_W=+ zNJNNys9B9*1gszrJ}735F~9>_eUijTq7j0M3Hb4#0gaIuF#hgNfrh9L8vC&OyXV|9 z_nwdceg06z-j6HxIk9kdmB@-QBA8)oJ^P@vH?zoKKuwyfRks6G{AOSyH{j7?1E3i_u;!-ID??q;P0~_{vC5f4!~)1Bc6lvu;05+0ZgNs&Bm3bw7BApf zpN?8}y%t%{#zy!C{!)Uynqy5>{bndYOY}wgEJ8PeUWdt*{zygXBYbiOP|&xy9znMX_nc z?#er3wZ$(h3o%M>zz-z<;a8D#vci25YqWS1m0nY56*>a+beJ1z9Ul?Vj6Vr?szguBVl!Gj^W+l$#Gc*HLbV z_K4HU^0cJ_S0#1%TSDx7$|}$%leMSAg?dFx6QJ4Cw?2PJk3m!d7 zYex|lcviV@YwRGa;--+eay=DybeomxjH{H+2il|d`YY$Hy>w7rHl%C)U31@(TQ7{{ zzt)_kkiK=6RD4&g)S1{avigp?#6*>t)R>r5J3dhy)_geDP&(24>*zkSZ&b}a-G0jQ zEF@T#u9Y(_GwRhiQLUA>l+GIouP$gK+jiGWF&ax1@)z}*vCSM-O()4q+qF(mDG<}m#La$%+>{=r|h0mzBk^a zVXmnued>nQed_Ghc>fj~djBGG^bpvNouYq!U`6ksx4=vKRlcqKLi7yiqaXBi)5^fp zdDDJcQx<;c8SBEUxcDe8e+uQ7@PBkB{0ANMM`vpPzBT2}ps{$Quy}o`#UEW!%7?qv EpTkSo^#A|> delta 1095 zcmXZaSx8iI6vy%39UF6{dMF>l(n8cM(#aH*9MfzuYc#1uO-;+JG-#8;YDg1}g3z(W zDQ%IWg}}IB&;pi1ge?>V6}EsB*pm{0y;R@J73R)o&bj~p{FifX3_05R9lf=N*YA|J z%$MFvnuwE@Ii%O|(i`Hig;FK)FuED%UM!6pk~diz43PGwNL5p$lr(83tCEA5HLe9Mc)qXGl-*8Xm|&@)4_~NBD5k z`qfe%v1^Sqi*a7;CO(%d)#1{$Y!rL&C{AT?7&hm5B@U)`5|$UxIzcy1#p@V^cQF|I zCgXn0W}z3@gW>GngZDhr7seR{_WylD>p(xS6XQ3qX&gZ7z{k9W(rG5X!jB}1H%j%G zyh)mlchEY?W1PXblFeK-ab1zrNZf?h!hyxSC%>tLMTl$gEVh^0rHm`H7oNapj5Eun zR^EH-E2It*|F+s!)ln&x^1{J=-6mhYO^PB8+0NpaP;J|?!@kOq8mWx@)}0d7Q2#DA zK^(MO8nfot+NH^-4z!KB@G!nZ_UKjK9x0T>P5i~(-^WoaJ}8}Va00I52XO?mT8aPS zbH=qCq29!vCaIbxY;2Yasp05JsfhftGj<8jwb78w??xYb+U@2W!F<+NELqB-Lr4`g zgzQXnxfc3L4cLQ&ILygv&aw&2IZuxKicWij!G0Q-cnm9W^rBsw+)LaFvA>JCSVBGb z;UJ!(f0TOJ-su2ZO*n)cP;V#wfL3k4qSch+Xf@*>499>gG#uvolAN=ROy4?Zq2cyj zb*3BgeRjr1Ci2cYNC1yjBJ01W@nrBM_@Vipl)rn9*qz-+5u5Mq9Bd^+zxE9=6} diff --git a/locales/es/LC_MESSAGES/lib.cli.args.po b/locales/es/LC_MESSAGES/lib.cli.args.po index 6aaff22cef..6662ea056e 100644 --- a/locales/es/LC_MESSAGES/lib.cli.args.po +++ b/locales/es/LC_MESSAGES/lib.cli.args.po @@ -5,8 +5,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-03-20 14:15+0000\n" -"PO-Revision-Date: 2021-03-20 14:17+0000\n" +"POT-Creation-Date: 2021-05-17 18:04+0100\n" +"PO-Revision-Date: 2021-05-17 18:18+0100\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es\n" @@ -14,7 +14,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 2.4.2\n" +"X-Generator: Poedit 2.4.3\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: lib/cli/args.py:177 lib/cli/args.py:187 lib/cli/args.py:195 @@ -54,7 +54,7 @@ msgstr "" "almacenarlo en la carpeta pde instalación de faceswap" #: lib/cli/args.py:299 lib/cli/args.py:308 lib/cli/args.py:316 -#: lib/cli/args.py:627 lib/cli/args.py:636 +#: lib/cli/args.py:630 lib/cli/args.py:639 msgid "Data" msgstr "Datos" @@ -90,8 +90,8 @@ msgstr "" "Los plugins de extracción pueden ser configuradas en el menú de 'Ajustes'" #: lib/cli/args.py:365 lib/cli/args.py:381 lib/cli/args.py:393 -#: lib/cli/args.py:425 lib/cli/args.py:443 lib/cli/args.py:455 -#: lib/cli/args.py:646 lib/cli/args.py:671 lib/cli/args.py:700 +#: lib/cli/args.py:428 lib/cli/args.py:446 lib/cli/args.py:458 +#: lib/cli/args.py:649 lib/cli/args.py:676 lib/cli/args.py:712 msgid "Plugins" msgstr "Extensiones" @@ -136,6 +136,9 @@ msgid "" "RAM. You can select none, one or multiple masks, but the extraction may take " "longer the more you select. NB: The Extended and Components (landmark based) " "masks are automatically generated on extraction.\n" +"L|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).\n" "L|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.\n" @@ -161,6 +164,9 @@ msgstr "" "todas RAM de la GPU. Puede seleccionar una, varias o ninguna máscaras, pero " "la extracción tardará más cuanto más marque. Las máscaras Extended y " "Components son siempre generadas durante la extracción.\n" +"L|bisenet-fp: Máscara relativamente ligera basada en NN que proporciona un " +"control más refinado sobre el área a enmascarar, incluido el enmascaramiento " +"completo de la cabeza (configurable en la configuración de la máscara).\n" "L|vgg-clear: Máscara diseñada para proporcionar una segmentación inteligente " "de rostros principalmente frontales y libres de obstrucciones. Los rostros " "de perfil y las obstrucciones pueden dar lugar a un rendimiento inferior.\n" @@ -185,7 +191,7 @@ msgstr "" "referencia y la máscara se extiende hacia arriba en la frente.\n" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -#: lib/cli/args.py:426 +#: lib/cli/args.py:429 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -208,7 +214,7 @@ msgstr "" "L|hist: Iguala los histogramas de los canales RGB.\n" "L|mean: Normalizar los colores de la cara a la media." -#: lib/cli/args.py:444 +#: lib/cli/args.py:447 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -225,7 +231,7 @@ msgstr "" "más veces se vuelva a introducir la cara en el alineador, menos " "microfluctuaciones se producirán, pero la extracción será más larga." -#: lib/cli/args.py:456 +#: lib/cli/args.py:459 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -237,13 +243,13 @@ msgstr "" "un solo número para usar incrementos de ese tamaño hasta 360, o pase una " "lista de números para enumerar exactamente qué ángulos comprobar." -#: lib/cli/args.py:468 lib/cli/args.py:478 lib/cli/args.py:491 -#: lib/cli/args.py:505 lib/cli/args.py:737 lib/cli/args.py:751 -#: lib/cli/args.py:764 lib/cli/args.py:778 +#: lib/cli/args.py:471 lib/cli/args.py:481 lib/cli/args.py:494 +#: lib/cli/args.py:508 lib/cli/args.py:749 lib/cli/args.py:763 +#: lib/cli/args.py:776 lib/cli/args.py:790 msgid "Face Processing" msgstr "Proceso de Caras" -#: lib/cli/args.py:469 +#: lib/cli/args.py:472 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -252,7 +258,7 @@ msgstr "" "a lo largo de la diagonal del cuadro delimitador. Establecer a 0 para " "desactivar" -#: lib/cli/args.py:479 lib/cli/args.py:752 +#: lib/cli/args.py:482 lib/cli/args.py:764 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -266,7 +272,7 @@ msgstr "" "uso del filtro de caras disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args.py:492 lib/cli/args.py:765 +#: lib/cli/args.py:495 lib/cli/args.py:777 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -280,7 +286,7 @@ msgstr "" "del filtro facial disminuirá significativamente la velocidad de extracción y " "no se puede garantizar su precisión." -#: lib/cli/args.py:506 lib/cli/args.py:779 +#: lib/cli/args.py:509 lib/cli/args.py:791 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -292,12 +298,12 @@ msgstr "" "NB: El uso del filtro facial disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args.py:517 lib/cli/args.py:529 lib/cli/args.py:541 -#: lib/cli/args.py:553 +#: lib/cli/args.py:520 lib/cli/args.py:532 lib/cli/args.py:544 +#: lib/cli/args.py:556 msgid "output" msgstr "salida" -#: lib/cli/args.py:518 +#: lib/cli/args.py:521 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -307,7 +313,7 @@ msgstr "" "pretende entrenar admite el tamaño deseado. Esto sólo tendrá que ser " "cambiado para los modelos de alta resolución." -#: lib/cli/args.py:530 +#: lib/cli/args.py:533 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -317,7 +323,7 @@ msgstr "" "extraer las caras. Por ejemplo, un valor de 1 extraerá las caras de cada " "fotograma, un valor de 10 extraerá las caras de cada 10 fotogramas." -#: lib/cli/args.py:542 +#: lib/cli/args.py:545 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -333,18 +339,18 @@ msgstr "" "ADVERTENCIA: No interrumpa el script al escribir el archivo porque podría " "corromperse. Poner a 0 para desactivar" -#: lib/cli/args.py:554 +#: lib/cli/args.py:557 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" "Dibujar puntos de referencia en las caras de salida para fines de depuración." -#: lib/cli/args.py:560 lib/cli/args.py:569 lib/cli/args.py:577 -#: lib/cli/args.py:584 lib/cli/args.py:791 lib/cli/args.py:802 -#: lib/cli/args.py:810 lib/cli/args.py:829 lib/cli/args.py:835 +#: lib/cli/args.py:563 lib/cli/args.py:572 lib/cli/args.py:580 +#: lib/cli/args.py:587 lib/cli/args.py:803 lib/cli/args.py:814 +#: lib/cli/args.py:822 lib/cli/args.py:841 lib/cli/args.py:847 msgid "settings" msgstr "ajustes" -#: lib/cli/args.py:561 +#: lib/cli/args.py:564 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " @@ -354,7 +360,7 @@ msgstr "" "extracción por separado (una tras otra) en lugar de hacerlo todo al mismo " "tiempo. Útil si la VRAM es escasa." -#: lib/cli/args.py:570 +#: lib/cli/args.py:573 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -362,19 +368,19 @@ msgstr "" "Omite los fotogramas que ya han sido extraídos y que existen en el archivo " "de alineaciones" -#: lib/cli/args.py:578 +#: lib/cli/args.py:581 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" "Omitir los fotogramas que ya tienen caras detectadas en el archivo de " "alineaciones" -#: lib/cli/args.py:585 +#: lib/cli/args.py:588 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "No guardar las caras detectadas en el disco. Crear sólo un archivo de " "alineaciones" -#: lib/cli/args.py:607 +#: lib/cli/args.py:610 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -384,7 +390,7 @@ msgstr "" "Los plugins de conversión pueden ser configurados en el menú \"Configuración" "\"" -#: lib/cli/args.py:628 +#: lib/cli/args.py:631 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -394,7 +400,7 @@ msgstr "" "original del que se extrajeron los fotogramas de origen (para extraer los " "fps y el audio)." -#: lib/cli/args.py:637 +#: lib/cli/args.py:640 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -402,7 +408,7 @@ msgstr "" "Directorio del modelo. El directorio que contiene el modelo entrenado que " "desea utilizar para la conversión." -#: lib/cli/args.py:647 +#: lib/cli/args.py:650 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -442,11 +448,19 @@ msgstr "" "colores. Generalmente no da resultados muy satisfactorios.\n" "L|none: No realice el ajuste de color." -#: lib/cli/args.py:672 +#: lib/cli/args.py:677 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" "L|none: Don't use a mask.\n" +"L|bisenet-fp-face: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'face' or " +"'legacy' centering.\n" +"L|bisenet-fp-head: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'head' " +"centering.\n" "L|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.\n" @@ -472,6 +486,14 @@ msgstr "" "de alineaciones. Puede añadir máscaras adicionales con la herramienta de " "máscaras.\n" "L|none: No utilizar una máscara.\n" +"L|bisenet-fp-face: Máscara relativamente ligera basada en NN que proporciona " +"un control más refinado sobre el área a enmascarar (configurable en la " +"configuración de la máscara). Utilice esta versión de bisenet-fp si su " +"modelo está entrenado con centrado 'face' o 'legacy'.\n" +"L|bisenet-fp-head: Máscara relativamente ligera basada en NN que proporciona " +"un control más refinado sobre el área a enmascarar (configurable en la " +"configuración de la máscara). Utilice esta versión de bisenet-fp si su " +"modelo está entrenado con centrado de 'cabeza'.\n" "L|components: Máscara diseñada para proporcionar una segmentación facial " "basada en el posicionamiento de las ubicaciones de los puntos de referencia. " "Se construye un casco convexo alrededor del exterior de los puntos de " @@ -496,7 +518,7 @@ msgstr "" "L|predicted: Si la opción 'Learn Mask' se habilitó durante el entrenamiento, " "esto usará la máscara que fue creada por el modelo entrenado." -#: lib/cli/args.py:701 +#: lib/cli/args.py:713 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -522,11 +544,11 @@ msgstr "" "L|pillow: [images] Más lento que opencv, pero tiene más opciones y soporta " "más formatos." -#: lib/cli/args.py:720 lib/cli/args.py:727 lib/cli/args.py:821 +#: lib/cli/args.py:732 lib/cli/args.py:739 lib/cli/args.py:833 msgid "Frame Processing" msgstr "Proceso de fotogramas" -#: lib/cli/args.py:721 +#: lib/cli/args.py:733 msgid "" "Scale the final output frames by this amount. 100%% will output the frames " "at source dimensions. 50%% at half size 200%% at double size" @@ -535,7 +557,7 @@ msgstr "" "a los fotogramas a las dimensiones de origen. 50%% a la mitad de tamaño. 200%" "% al doble de tamaño" -#: lib/cli/args.py:728 +#: lib/cli/args.py:740 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -549,7 +571,7 @@ msgstr "" "imágenes, ¡los nombres de los archivos deben terminar con el número de " "fotograma!" -#: lib/cli/args.py:738 +#: lib/cli/args.py:750 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -565,7 +587,7 @@ msgstr "" "especificada. Si se deja en blanco, se convertirán todas las caras que " "existan en el archivo de alineaciones." -#: lib/cli/args.py:792 +#: lib/cli/args.py:804 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -582,7 +604,7 @@ msgstr "" "procesos que los disponibles en su sistema. Si 'singleprocess' está " "habilitado, este ajuste será ignorado." -#: lib/cli/args.py:803 +#: lib/cli/args.py:815 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -590,7 +612,7 @@ msgstr "" "[LEGACY] Sólo es necesario seleccionar esta opción si se está cargando un " "modelo heredado si hay varios modelos en la carpeta de modelos" -#: lib/cli/args.py:811 +#: lib/cli/args.py:823 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -605,7 +627,7 @@ msgstr "" "de baja calidad. Si se encuentra un archivo de alineaciones, esta opción " "será ignorada." -#: lib/cli/args.py:822 +#: lib/cli/args.py:834 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -613,16 +635,16 @@ msgstr "" "Cuando se usa con --frame-ranges, la salida incluye los fotogramas no " "procesados en vez de descartarlos." -#: lib/cli/args.py:830 +#: lib/cli/args.py:842 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Intercambiar el modelo. En vez de convertir de A a B, convierte de B a A" -#: lib/cli/args.py:836 +#: lib/cli/args.py:848 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Desactiva el multiproceso. Es más lento, pero usa menos recursos." -#: lib/cli/args.py:852 +#: lib/cli/args.py:864 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -634,11 +656,11 @@ msgstr "" "hasta más de una semana.\n" "Los plugins de los modelos pueden configurarse en el menú \"Ajustes\"" -#: lib/cli/args.py:871 lib/cli/args.py:880 +#: lib/cli/args.py:883 lib/cli/args.py:892 msgid "faces" msgstr "caras" -#: lib/cli/args.py:872 +#: lib/cli/args.py:884 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -648,7 +670,7 @@ msgstr "" "para la cara A. Esta es la cara original, es decir, la cara que se quiere " "eliminar y sustituir por la cara B." -#: lib/cli/args.py:881 +#: lib/cli/args.py:893 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -658,12 +680,12 @@ msgstr "" "para la cara B. Esta es la cara de intercambio, es decir, la cara que se " "quiere colocar en la cabeza de la persona A." -#: lib/cli/args.py:889 lib/cli/args.py:901 lib/cli/args.py:917 -#: lib/cli/args.py:942 lib/cli/args.py:952 +#: lib/cli/args.py:901 lib/cli/args.py:913 lib/cli/args.py:929 +#: lib/cli/args.py:954 lib/cli/args.py:964 msgid "model" msgstr "modelo" -#: lib/cli/args.py:890 +#: lib/cli/args.py:902 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -677,7 +699,7 @@ msgstr "" "carpeta que no exista (que se creará). Si continúa entrenando un modelo " "existente, especifique la ubicación del modelo existente." -#: lib/cli/args.py:902 +#: lib/cli/args.py:914 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -701,7 +723,7 @@ msgstr "" "NB: Los pesos solo se pueden cargar desde modelos del mismo complemento que " "desea entrenar." -#: lib/cli/args.py:918 +#: lib/cli/args.py:930 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -746,7 +768,7 @@ msgstr "" "recursos (se necesita una GPU con una buena cantidad de VRAM). Bueno para " "los detalles, pero más susceptible a las diferencias de color." -#: lib/cli/args.py:943 +#: lib/cli/args.py:955 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -758,7 +780,7 @@ msgstr "" "muestra un resumen del modelo que crearía el complemento elegido y los " "ajustes de configuración." -#: lib/cli/args.py:953 +#: lib/cli/args.py:965 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -772,12 +794,12 @@ msgstr "" "congelará el codificador, pero algunos modelos pueden tener opciones de " "configuración para congelar otras capas." -#: lib/cli/args.py:966 lib/cli/args.py:978 lib/cli/args.py:989 -#: lib/cli/args.py:1075 +#: lib/cli/args.py:978 lib/cli/args.py:990 lib/cli/args.py:1001 +#: lib/cli/args.py:1087 msgid "training" msgstr "entrenamiento" -#: lib/cli/args.py:967 +#: lib/cli/args.py:979 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -790,7 +812,7 @@ msgstr "" "momento es el doble del número que se establece aquí. Los lotes más grandes " "requieren más RAM de la GPU." -#: lib/cli/args.py:979 +#: lib/cli/args.py:991 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -805,22 +827,22 @@ msgstr "" "automáticamente en un número determinado de iteraciones, puede establecer " "ese valor aquí." -#: lib/cli/args.py:990 +#: lib/cli/args.py:1002 msgid "" "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" "Utilice la estrategia de distribución en espejo de Tensorflow para entrenar " "en múltiples GPUs." -#: lib/cli/args.py:1000 lib/cli/args.py:1010 +#: lib/cli/args.py:1012 lib/cli/args.py:1022 msgid "Saving" msgstr "Guardar" -#: lib/cli/args.py:1001 +#: lib/cli/args.py:1013 msgid "Sets the number of iterations between each model save." msgstr "Establece el número de iteraciones entre cada guardado del modelo." -#: lib/cli/args.py:1011 +#: lib/cli/args.py:1023 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -828,11 +850,11 @@ msgstr "" "Establece el número de iteraciones antes de guardar una copia de seguridad " "del modelo en su estado actual. Establece 0 para que esté desactivado." -#: lib/cli/args.py:1018 lib/cli/args.py:1029 lib/cli/args.py:1040 +#: lib/cli/args.py:1030 lib/cli/args.py:1041 lib/cli/args.py:1052 msgid "timelapse" msgstr "intervalo" -#: lib/cli/args.py:1019 +#: lib/cli/args.py:1031 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -846,7 +868,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-B." -#: lib/cli/args.py:1030 +#: lib/cli/args.py:1042 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -860,7 +882,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-A." -#: lib/cli/args.py:1041 +#: lib/cli/args.py:1053 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -872,24 +894,24 @@ msgstr "" "Si se suministran las carpetas de entrada pero no la carpeta de salida, se " "guardará por defecto en la carpeta del modelo /timelapse/" -#: lib/cli/args.py:1053 lib/cli/args.py:1060 lib/cli/args.py:1067 +#: lib/cli/args.py:1065 lib/cli/args.py:1072 lib/cli/args.py:1079 msgid "preview" msgstr "previsualización" -#: lib/cli/args.py:1054 +#: lib/cli/args.py:1066 msgid "" "Percentage amount to scale the preview by. 100%% is the model output size." msgstr "" "Cantidad porcentual para escalar la vista previa. 100%% es el tamaño de " "salida del modelo." -#: lib/cli/args.py:1061 +#: lib/cli/args.py:1073 msgid "Show training preview output. in a separate window." msgstr "" "Mostrar la salida de la vista previa del entrenamiento. en una ventana " "separada." -#: lib/cli/args.py:1068 +#: lib/cli/args.py:1080 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -897,7 +919,7 @@ msgstr "" "Escribe el resultado del entrenamiento en un archivo. La imagen se " "almacenará en la raíz de su carpeta FaceSwap." -#: lib/cli/args.py:1076 +#: lib/cli/args.py:1088 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -905,12 +927,12 @@ msgstr "" "Desactiva el registro de TensorBoard. NB: Desactivar los registros significa " "que no podrá utilizar el gráfico o el análisis de esta sesión en la GUI." -#: lib/cli/args.py:1083 lib/cli/args.py:1092 lib/cli/args.py:1101 -#: lib/cli/args.py:1110 +#: lib/cli/args.py:1095 lib/cli/args.py:1104 lib/cli/args.py:1113 +#: lib/cli/args.py:1122 msgid "augmentation" msgstr "aumento" -#: lib/cli/args.py:1084 +#: lib/cli/args.py:1096 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -920,7 +942,7 @@ msgstr "" "conjunto de caras opuestas en lugar de deformar la cara al azar. Esta es la " "forma 'dfaker' de hacer la deformación." -#: lib/cli/args.py:1093 +#: lib/cli/args.py:1105 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -931,7 +953,7 @@ msgstr "" "general, esto debería dejarse sin efecto, excepto durante el 'entrenamiento " "de ajuste'." -#: lib/cli/args.py:1102 +#: lib/cli/args.py:1114 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -941,7 +963,7 @@ msgstr "" "diferencias de color entre los conjuntos A y B, con un mayor coste de tiempo " "de entrenamiento. Activa esta opción para desactivar el aumento de color." -#: lib/cli/args.py:1111 +#: lib/cli/args.py:1123 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -954,7 +976,7 @@ msgstr "" "esta opción desde el principio, es probable que arruine el modelo y se " "obtengan resultados terribles." -#: lib/cli/args.py:1136 +#: lib/cli/args.py:1148 msgid "Output to Shell console instead of GUI console" msgstr "Salida a la consola Shell en lugar de la consola GUI" diff --git a/locales/es/LC_MESSAGES/tools.mask.cli.mo b/locales/es/LC_MESSAGES/tools.mask.cli.mo index 43b9b66e9134fdaf6e1bb2d79694bf9859b2d98c..efae7fa7c6833c442306c4172ad0f66353c4b996 100644 GIT binary patch delta 707 zcmZ9IJ!lj`7>2(KIYXj%IW;z?gt0RuCpkn62SF?h7BS#o;Xkvt-|Y?V&YUy5Ckhf9 z8>`?I7Ev^%h}DXv&DC0ny@ep6AQp;^aGs7`Duv=GWrB=0l$DF;CFBVx^?g@xG+w13HOgGM62LB_!V5; zPILxb*+Fy*d=DOhKH5o#=>#%9o*;USz|ShtOYqfhqM;$8`+JCv;rwut2tCpV&^PcI z^bLHQ>{Sc-M7k>?ZOc@2tm&$dok)yjVY#I=;!UGdYhq!#Y@uTzElZIa5%9)aNR)2IS*Ua* zT3H;6_B6{uUVpU@30f-3tPEm?bWqT7nj-3^@^{52BYO|l>W4&qu2G+xo~_T$jx9=; z)=pcg9hx_`A$ku=)$v8CqfAEJ5bQ1=J=J?&x?4EVJ71p3@0D*B|C#!Df6XgW28-nZe)Oy!k{bECfj DjP25B delta 308 zcmW;GKTAS!6vpxI75|{wy`?yKL%4exDj0D?qNpfxkur#~xPjOp25M`yM9@@Rf}j`R z3%FBDTVqS~3L1l!Xp5*v&+_5?&cpAV-^z!3vwvO;!&?NNfM^0P<+BV7*c^k3`oAP7 zMrB+Xxh!LHL#`=Tle_ZXgaf^QS@0lh6YwoRWnI2c!iAhq!IJWiDHdsK`A-^NH8{?~ zi(IfF6oU8^tgBDvKr!lac)*?<9&nWNZDTg0@6qZ}>D19E9yE;WaBvqL#H>=jRCJ1S zZn-dBQ27nh_OHy_bSY8wdj0%%*X#7!UjL-)I)2ugv8rCDb?UX6uG8!tu5JY@)>$MN HWOm~J60I~! diff --git a/locales/es/LC_MESSAGES/tools.mask.cli.po b/locales/es/LC_MESSAGES/tools.mask.cli.po index eba9ff9e43..f09fe4cd72 100644 --- a/locales/es/LC_MESSAGES/tools.mask.cli.po +++ b/locales/es/LC_MESSAGES/tools.mask.cli.po @@ -5,17 +5,17 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-02-18 23:14-0000\n" -"PO-Revision-Date: 2021-02-21 16:50+0000\n" +"POT-Creation-Date: 2021-05-17 18:17+0100\n" +"PO-Revision-Date: 2021-05-17 18:18+0100\n" +"Last-Translator: \n" "Language-Team: tokafondo\n" +"Language: es_ES\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 2.4.2\n" -"Last-Translator: \n" +"X-Generator: Poedit 2.4.3\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -"Language: es_ES\n" #: tools/mask/cli.py:15 msgid "This command lets you generate masks for existing alignments." @@ -59,13 +59,16 @@ 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:62 tools/mask/cli.py:87 +#: tools/mask/cli.py:62 tools/mask/cli.py:90 msgid "process" msgstr "proceso" #: tools/mask/cli.py:63 msgid "" "R|Masker to use.\n" +"L|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).\n" "L|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.\n" @@ -86,6 +89,9 @@ msgid "" "performance." msgstr "" "R|Máscara a utilizar.\n" +"L|bisenet-fp: Máscara relativamente ligera basada en NN que proporciona un " +"control más refinado sobre el área a enmascarar, incluido el enmascaramiento " +"completo de la cabeza (configurable en la configuración de la máscara).\n" "L|components: Máscara diseñada para proporcionar una segmentación facial " "basada en la posición de los puntos de referencia. Se construye un casco " "convexo alrededor del exterior de los puntos de referencia para crear una " @@ -108,7 +114,7 @@ msgstr "" "descripción. Los rostros de perfil pueden dar lugar a un rendimiento " "inferior." -#: tools/mask/cli.py:88 +#: tools/mask/cli.py:91 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 " @@ -128,12 +134,12 @@ msgstr "" "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:101 tools/mask/cli.py:108 tools/mask/cli.py:121 -#: tools/mask/cli.py:134 tools/mask/cli.py:143 +#: tools/mask/cli.py:104 tools/mask/cli.py:111 tools/mask/cli.py:124 +#: tools/mask/cli.py:137 tools/mask/cli.py:146 msgid "output" msgstr "salida" -#: tools/mask/cli.py:102 +#: tools/mask/cli.py:105 msgid "" "Optional output location. If provided, a preview of the masks created will " "be output in the given folder." @@ -141,7 +147,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:112 +#: tools/mask/cli.py:115 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 " @@ -154,7 +160,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:125 +#: tools/mask/cli.py:128 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 " @@ -165,7 +171,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:135 +#: tools/mask/cli.py:138 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" @@ -180,7 +186,7 @@ msgstr "" "enmascarada.\n" "L|mask: Sólo emite la máscara como una imagen de un solo canal." -#: tools/mask/cli.py:144 +#: tools/mask/cli.py:147 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/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index b868cfc95b..f788ab79c6 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-03-20 14:15+0000\n" +"POT-Creation-Date: 2021-05-17 18:04+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -39,7 +39,7 @@ msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "" #: lib/cli/args.py:299 lib/cli/args.py:308 lib/cli/args.py:316 -#: lib/cli/args.py:627 lib/cli/args.py:636 +#: lib/cli/args.py:630 lib/cli/args.py:639 msgid "Data" msgstr "" @@ -62,8 +62,8 @@ msgid "" msgstr "" #: lib/cli/args.py:365 lib/cli/args.py:381 lib/cli/args.py:393 -#: lib/cli/args.py:425 lib/cli/args.py:443 lib/cli/args.py:455 -#: lib/cli/args.py:646 lib/cli/args.py:671 lib/cli/args.py:700 +#: lib/cli/args.py:428 lib/cli/args.py:446 lib/cli/args.py:458 +#: lib/cli/args.py:649 lib/cli/args.py:676 lib/cli/args.py:712 msgid "Plugins" msgstr "" @@ -85,6 +85,7 @@ msgstr "" #: lib/cli/args.py:394 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU RAM. You can select none, one or multiple masks, but the extraction may take longer the more you select. NB: The Extended and Components (landmark based) masks are automatically generated on extraction.\n" +"L|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).\n" "L|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.\n" "L|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.\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.\n" @@ -94,7 +95,7 @@ msgid "" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" msgstr "" -#: lib/cli/args.py:426 +#: lib/cli/args.py:429 msgid "" "R|Performing normalization can help the aligner better align faces with difficult lighting conditions at an extraction speed cost. Different methods will yield different results on different sets. NB: This does not impact the output face, just the input to the aligner.\n" "L|none: Don't perform normalization on the face.\n" @@ -103,94 +104,94 @@ msgid "" "L|mean: Normalize the face colors to the mean." msgstr "" -#: lib/cli/args.py:444 +#: lib/cli/args.py:447 msgid "The number of times to re-feed the detected face into the aligner. Each time the face is re-fed into the aligner the bounding box is adjusted by a small amount. The final landmarks are then averaged from each iteration. Helps to remove 'micro-jitter' but at the cost of slower extraction speed. The more times the face is re-fed into the aligner, the less micro-jitter should occur but the longer extraction will take." msgstr "" -#: lib/cli/args.py:456 +#: lib/cli/args.py:459 msgid "If a face isn't found, rotate the images to try to find a face. Can find more faces at the cost of extraction speed. Pass in a single number to use increments of that size up to 360, or pass in a list of numbers to enumerate exactly what angles to check." msgstr "" -#: lib/cli/args.py:468 lib/cli/args.py:478 lib/cli/args.py:491 -#: lib/cli/args.py:505 lib/cli/args.py:737 lib/cli/args.py:751 -#: lib/cli/args.py:764 lib/cli/args.py:778 +#: lib/cli/args.py:471 lib/cli/args.py:481 lib/cli/args.py:494 +#: lib/cli/args.py:508 lib/cli/args.py:749 lib/cli/args.py:763 +#: lib/cli/args.py:776 lib/cli/args.py:790 msgid "Face Processing" msgstr "" -#: lib/cli/args.py:469 +#: lib/cli/args.py:472 msgid "Filters out faces detected below this size. Length, in pixels across the diagonal of the bounding box. Set to 0 for off" msgstr "" -#: lib/cli/args.py:479 lib/cli/args.py:752 +#: lib/cli/args.py:482 lib/cli/args.py:764 msgid "Optionally filter out people who you do not wish to process by passing in an image of that person. Should be a front portrait with a single person in the image. Multiple images can be added space separated. NB: Using face filter will significantly decrease extraction speed and its accuracy cannot be guaranteed." msgstr "" -#: lib/cli/args.py:492 lib/cli/args.py:765 +#: lib/cli/args.py:495 lib/cli/args.py:777 msgid "Optionally select people you wish to process by passing in an image of that person. Should be a front portrait with a single person in the image. Multiple images can be added space separated. NB: Using face filter will significantly decrease extraction speed and its accuracy cannot be guaranteed." msgstr "" -#: lib/cli/args.py:506 lib/cli/args.py:779 +#: lib/cli/args.py:509 lib/cli/args.py:791 msgid "For use with the optional nfilter/filter files. Threshold for positive face recognition. Lower values are stricter. NB: Using face filter will significantly decrease extraction speed and its accuracy cannot be guaranteed." msgstr "" -#: lib/cli/args.py:517 lib/cli/args.py:529 lib/cli/args.py:541 -#: lib/cli/args.py:553 +#: lib/cli/args.py:520 lib/cli/args.py:532 lib/cli/args.py:544 +#: lib/cli/args.py:556 msgid "output" msgstr "" -#: lib/cli/args.py:518 +#: lib/cli/args.py:521 msgid "The output size of extracted faces. Make sure that the model you intend to train supports your required size. This will only need to be changed for hi-res models." msgstr "" -#: lib/cli/args.py:530 +#: lib/cli/args.py:533 msgid "Extract every 'nth' frame. This option will skip frames when extracting faces. For example a value of 1 will extract faces from every frame, a value of 10 will extract faces from every 10th frame." msgstr "" -#: lib/cli/args.py:542 +#: lib/cli/args.py:545 msgid "Automatically save the alignments file after a set amount of frames. By default the alignments file is only saved at the end of the extraction process. NB: If extracting in 2 passes then the alignments file will only start to be saved out during the second pass. WARNING: Don't interrupt the script when writing the file because it might get corrupted. Set to 0 to turn off" msgstr "" -#: lib/cli/args.py:554 +#: lib/cli/args.py:557 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" -#: lib/cli/args.py:560 lib/cli/args.py:569 lib/cli/args.py:577 -#: lib/cli/args.py:584 lib/cli/args.py:791 lib/cli/args.py:802 -#: lib/cli/args.py:810 lib/cli/args.py:829 lib/cli/args.py:835 +#: lib/cli/args.py:563 lib/cli/args.py:572 lib/cli/args.py:580 +#: lib/cli/args.py:587 lib/cli/args.py:803 lib/cli/args.py:814 +#: lib/cli/args.py:822 lib/cli/args.py:841 lib/cli/args.py:847 msgid "settings" msgstr "" -#: lib/cli/args.py:561 +#: lib/cli/args.py:564 msgid "Don't run extraction in parallel. Will run each part of the extraction process separately (one after the other) rather than all at the smae time. Useful if VRAM is at a premium." msgstr "" -#: lib/cli/args.py:570 +#: lib/cli/args.py:573 msgid "Skips frames that have already been extracted and exist in the alignments file" msgstr "" -#: lib/cli/args.py:578 +#: lib/cli/args.py:581 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" -#: lib/cli/args.py:585 +#: lib/cli/args.py:588 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" -#: lib/cli/args.py:607 +#: lib/cli/args.py:610 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:628 +#: lib/cli/args.py:631 msgid "Only required if converting from images to video. Provide The original video that the source frames were extracted from (for extracting the fps and audio)." msgstr "" -#: lib/cli/args.py:637 +#: lib/cli/args.py:640 msgid "Model directory. The directory containing the trained model you wish to use for conversion." msgstr "" -#: lib/cli/args.py:647 +#: lib/cli/args.py:650 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have configurable settings in '/config/convert.ini' or 'Settings > Configure Convert Plugins':\n" "L|avg-color: Adjust the mean of each color channel in the swapped reconstruction to equal the mean of the masked area in the original image.\n" @@ -201,10 +202,12 @@ msgid "" "L|none: Don't perform color adjustment." msgstr "" -#: lib/cli/args.py:672 +#: lib/cli/args.py:677 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments file. You can add additional masks with the Mask Tool.\n" "L|none: Don't use a mask.\n" +"L|bisenet-fp-face: Relatively lightweight NN based mask that provides more refined control over the area to be masked (configurable in mask settings). Use this version of bisenet-fp if your model is trained with 'face' or 'legacy' centering.\n" +"L|bisenet-fp-head: Relatively lightweight NN based mask that provides more refined control over the area to be masked (configurable in mask settings). Use this version of bisenet-fp if your model is trained with 'head' centering.\n" "L|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.\n" "L|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.\n" "L|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.\n" @@ -213,7 +216,7 @@ msgid "" "L|predicted: If the 'Learn Mask' option was enabled during training, this will use the mask that was created by the trained model." msgstr "" -#: lib/cli/args.py:701 +#: lib/cli/args.py:713 msgid "" "R|The plugin to use to output the converted images. The writers are configurable in '/config/convert.ini' or 'Settings > Configure Convert Plugins:'\n" "L|ffmpeg: [video] Writes out the convert straight to video. When the input is a series of images then the '-ref' (--reference-video) parameter must be set.\n" @@ -222,81 +225,81 @@ msgid "" "L|pillow: [images] Slower than opencv, but has more options and supports more formats." msgstr "" -#: lib/cli/args.py:720 lib/cli/args.py:727 lib/cli/args.py:821 +#: lib/cli/args.py:732 lib/cli/args.py:739 lib/cli/args.py:833 msgid "Frame Processing" msgstr "" -#: lib/cli/args.py:721 +#: lib/cli/args.py:733 msgid "Scale the final output frames by this amount. 100%% will output the frames at source dimensions. 50%% at half size 200%% at double size" msgstr "" -#: lib/cli/args.py:728 +#: lib/cli/args.py:740 msgid "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use --frame-ranges 10-50 90-100. Frames falling outside of the selected range will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are converting from images, then the filenames must end with the frame-number!" msgstr "" -#: lib/cli/args.py:738 +#: lib/cli/args.py:750 msgid "If you have not cleansed your alignments file, then you can filter out faces by defining a folder here that contains the faces extracted from your input files/video. If this folder is defined, then only faces that exist within your alignments file and also exist within the specified folder will be converted. Leaving this blank will convert all faces that exist within the alignments file." msgstr "" -#: lib/cli/args.py:792 +#: lib/cli/args.py:804 msgid "The maximum number of parallel processes for performing conversion. Converting images is system RAM heavy so it is possible to run out of memory if you have a lot of processes and not enough RAM to accommodate them all. Setting this to 0 will use the maximum available. No matter what you set this to, it will never attempt to use more processes than are available on your system. If singleprocess is enabled this setting will be ignored." msgstr "" -#: lib/cli/args.py:803 +#: lib/cli/args.py:815 msgid "[LEGACY] This only needs to be selected if a legacy model is being loaded or if there are multiple models in the model folder" msgstr "" -#: lib/cli/args.py:811 +#: lib/cli/args.py:823 msgid "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean alignments file for your destination video. However, if you wish you can generate the alignments on-the-fly by enabling this option. This will use an inferior extraction pipeline and will lead to substandard results. If an alignments file is found, this option will be ignored." msgstr "" -#: lib/cli/args.py:822 +#: lib/cli/args.py:834 msgid "When used with --frame-ranges outputs the unchanged frames that are not processed instead of discarding them." msgstr "" -#: lib/cli/args.py:830 +#: lib/cli/args.py:842 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" -#: lib/cli/args.py:836 +#: lib/cli/args.py:848 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "" -#: lib/cli/args.py:852 +#: lib/cli/args.py:864 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" "Model plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:871 lib/cli/args.py:880 +#: lib/cli/args.py:883 lib/cli/args.py:892 msgid "faces" msgstr "" -#: lib/cli/args.py:872 +#: lib/cli/args.py:884 msgid "Input directory. A directory containing training images for face A. This is the original face, i.e. the face that you want to remove and replace with face B." msgstr "" -#: lib/cli/args.py:881 +#: lib/cli/args.py:893 msgid "Input directory. A directory containing training images for face B. This is the swap face, i.e. the face that you want to place onto the head of person A." msgstr "" -#: lib/cli/args.py:889 lib/cli/args.py:901 lib/cli/args.py:917 -#: lib/cli/args.py:942 lib/cli/args.py:952 +#: lib/cli/args.py:901 lib/cli/args.py:913 lib/cli/args.py:929 +#: lib/cli/args.py:954 lib/cli/args.py:964 msgid "model" msgstr "" -#: lib/cli/args.py:890 +#: lib/cli/args.py:902 msgid "Model directory. This is where the training data will be stored. You should always specify a new folder for new models. If starting a new model, select either an empty folder, or a folder which does not exist (which will be created). If continuing to train an existing model, specify the location of the existing model." msgstr "" -#: lib/cli/args.py:902 +#: lib/cli/args.py:914 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For most models this will load weights from the Encoder of the given model into the encoder of the newly created model. Some plugins may have specific configuration options allowing you to load weights from other layers. Weights will only be loaded when creating a new model. This option will be ignored if you are resuming an existing model. Generally you will also want to 'freeze-weights' whilst the rest of your model catches up with your Encoder.\n" "NB: Weights can only be loaded from models of the same plugin as you intend to train." msgstr "" -#: lib/cli/args.py:918 +#: lib/cli/args.py:930 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings menu or the config folder.\n" "L|original: The original model created by /u/deepfakes.\n" @@ -311,101 +314,101 @@ msgid "" "L|villain: 128px in/out model from villainguy. Very resource hungry (You will require a GPU with a fair amount of VRAM). Good for details, but more susceptible to color differences." msgstr "" -#: lib/cli/args.py:943 +#: lib/cli/args.py:955 msgid "Output a summary of the model and exit. If a model folder is provided then a summary of the saved model is displayed. Otherwise a summary of the model that would be created by the chosen plugin and configuration settings is displayed." msgstr "" -#: lib/cli/args.py:953 +#: lib/cli/args.py:965 msgid "Freeze the weights of the model. Freezing weights means that some of the parameters in the model will no longer continue to learn, but those that are not frozen will continue to learn. For most models, this will freeze the encoder, but some models may have configuration options for freezing other layers." msgstr "" -#: lib/cli/args.py:966 lib/cli/args.py:978 lib/cli/args.py:989 -#: lib/cli/args.py:1075 +#: lib/cli/args.py:978 lib/cli/args.py:990 lib/cli/args.py:1001 +#: lib/cli/args.py:1087 msgid "training" msgstr "" -#: lib/cli/args.py:967 +#: lib/cli/args.py:979 msgid "Batch size. This is the number of images processed through the model for each side per iteration. NB: As the model is fed 2 sides at a time, the actual number of images within the model at any one time is double the number that you set here. Larger batches require more GPU RAM." msgstr "" -#: lib/cli/args.py:979 +#: lib/cli/args.py:991 msgid "Length of training in iterations. This is only really used for automation. There is no 'correct' number of iterations a model should be trained for. You should stop training when you are happy with the previews. However, if you want the model to stop automatically at a set number of iterations, you can set that value here." msgstr "" -#: lib/cli/args.py:990 +#: lib/cli/args.py:1002 msgid "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" -#: lib/cli/args.py:1000 lib/cli/args.py:1010 +#: lib/cli/args.py:1012 lib/cli/args.py:1022 msgid "Saving" msgstr "" -#: lib/cli/args.py:1001 +#: lib/cli/args.py:1013 msgid "Sets the number of iterations between each model save." msgstr "" -#: lib/cli/args.py:1011 +#: lib/cli/args.py:1023 msgid "Sets the number of iterations before saving a backup snapshot of the model in it's current state. Set to 0 for off." msgstr "" -#: lib/cli/args.py:1018 lib/cli/args.py:1029 lib/cli/args.py:1040 +#: lib/cli/args.py:1030 lib/cli/args.py:1041 lib/cli/args.py:1052 msgid "timelapse" msgstr "" -#: lib/cli/args.py:1019 +#: lib/cli/args.py:1031 msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. This should be the input folder of 'A' faces that you would like to use for creating the timelapse. You must also supply a --timelapse-output and a --timelapse-input-B parameter." msgstr "" -#: lib/cli/args.py:1030 +#: lib/cli/args.py:1042 msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. This should be the input folder of 'B' faces that you would like to use for creating the timelapse. You must also supply a --timelapse-output and a --timelapse-input-A parameter." msgstr "" -#: lib/cli/args.py:1041 +#: lib/cli/args.py:1053 msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. If the input folders are supplied but no output folder, it will default to your model folder /timelapse/" msgstr "" -#: lib/cli/args.py:1053 lib/cli/args.py:1060 lib/cli/args.py:1067 +#: lib/cli/args.py:1065 lib/cli/args.py:1072 lib/cli/args.py:1079 msgid "preview" msgstr "" -#: lib/cli/args.py:1054 +#: lib/cli/args.py:1066 msgid "Percentage amount to scale the preview by. 100%% is the model output size." msgstr "" -#: lib/cli/args.py:1061 +#: lib/cli/args.py:1073 msgid "Show training preview output. in a separate window." msgstr "" -#: lib/cli/args.py:1068 +#: lib/cli/args.py:1080 msgid "Writes the training result to a file. The image will be stored in the root of your FaceSwap folder." msgstr "" -#: lib/cli/args.py:1076 +#: lib/cli/args.py:1088 msgid "Disables TensorBoard logging. NB: Disabling logs means that you will not be able to use the graph or analysis for this session in the GUI." msgstr "" -#: lib/cli/args.py:1083 lib/cli/args.py:1092 lib/cli/args.py:1101 -#: lib/cli/args.py:1110 +#: lib/cli/args.py:1095 lib/cli/args.py:1104 lib/cli/args.py:1113 +#: lib/cli/args.py:1122 msgid "augmentation" msgstr "" -#: lib/cli/args.py:1084 +#: lib/cli/args.py:1096 msgid "Warps training faces to closely matched Landmarks from the opposite face-set rather than randomly warping the face. This is the 'dfaker' way of doing warping." msgstr "" -#: lib/cli/args.py:1093 +#: lib/cli/args.py:1105 msgid "To effectively learn, a random set of images are flipped horizontally. Sometimes it is desirable for this not to occur. Generally this should be left off except for during 'fit training'." msgstr "" -#: lib/cli/args.py:1102 +#: lib/cli/args.py:1114 msgid "Color augmentation helps make the model less susceptible to color differences between the A and B sets, at an increased training time cost. Enable this option to disable color augmentation." msgstr "" -#: lib/cli/args.py:1111 +#: lib/cli/args.py:1123 msgid "Warping is integral to training the Neural Network. This option should only be enabled towards the very end of training to try to bring out more detail. Think of it as 'fine-tuning'. Enabling this option from the beginning is likely to kill a model and lead to terrible results." msgstr "" -#: lib/cli/args.py:1136 +#: lib/cli/args.py:1148 msgid "Output to Shell console instead of GUI console" msgstr "" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo index e146ad4d831f3d8ec09b235c6b43f29098faf3b5..509595df4b2932691375bcfd8cfb12c7ea3ad5d8 100644 GIT binary patch delta 2956 zcmd6oYiv_x7=Yh0ia0^hAcBZ~Mxh&Hu!>wnZVDo|fr11iDCxShwbHG#?Y2|~Wt#{J zOidHfASlEOW|)kXl1aA?Hi#NT&H*LS1dJgn(fGq3MnX*V`A#vb1`~`z{INdgy?pQY z-p<*H9XYM<=d=VIvDtk@p14coa}jwbUt~%zk-Cv0$B_?=5-CA`p-|+OYeaq=Bl43& z#5qCaaE{0?6GgneMcOBe+>D=kK!k8Ieul^fI0c5`5jY;+H&bLRtb(ZIt(g*&Sv;IW zu>g*FNaR7d7S_WraOPp~!VGVRx`MkQ1xo?!3&+B1;pB_*EEwjZg)m9w-H(Xe1EY&Y zR-@muBy&ODQjr?Yje|eKJunaZu4N*n$VX$3i|poL(Q+=s@Wl#|GzRCBB17O|cmke* z4cPat6uATWUXO^2bC1E%u#WPEp>H-sPUH76{2KkcqSoGiY$jaIp}_gb(;;Scp;hF34s&Kf1`@ZXQ)B?=H^DaK*AI%&3|VqW zkAN%>^3Z@{co!GI-H^EM9TwS2;1NeeLR_5wNMs~|-A9=tT=5BdnD;6B zfd97RRE*zBlSM@TCUe}6zT+fuVB|BA-t#f^J^%Wy^zlJKd0m$W%B6&Ve_< zC>#JcLtWu^NEng0+d0%RH(u;q?3fAc@j5I^m74V#8(Hh<(Uyg>GX-vFobC-FP` ztacP7w)gpRSggb!HY$xsVOiBAwa5r~BK{g95LE$xxi3;{=wG#9fhzHYjZ#(N39nKS zpC_WKLctn;sS#Ec!H}UsMw!17yEj-F2?YZxSYw3nGn6M}cvK{)N(}ofF8)eypt{sw zS+2^e0|DhLFg%&KT9rqDGJkn>$Wsz9aLrs8HX;$M;rv9cdws9O`4Qjr&C0s4%;Pn# zO6pnJ#V(cJXtgA4P_{p;s6XrvR;pl`>gliYm#Jv5Iz-P(jex=}67tx6tMx~G%B2f& zsbEOC0!F#V8+9qKQ5i8p)IK_QeqB$XLSKxoUG18)OJ4TJNd9fjJ_^aMGof5>I1*9! znxXy26}l%V_rytVcR`Umo>ScI7@PR8c=&{@*-ujD4y(cJFw<7O*~U}SOj(*IS&gLBjvaS>K}sjZke}C|=p9=d+@gEH__D|lED=@_AmpR4%)e!Y3t}CU-!vDvLs$jzR+V|sr2O%5H AJOBUy delta 1090 zcmXZbe@x7A9LMq3IaeV+){?b4)$IH_bX~uuUzaR}{8;%l9I2R}Geu^8eAQ-Z;}6}Y z)}|?JM1CDfWGI$Jt?-YD`G@frBlU+Z&y(%$9^d!-^Zxw&e(rXYxv$QAbGON;wMg!1 z(pyPGGo{o~Qui#Whd6MKR6zV5UDUavr5`5Ah?6?Z(!K<#aI}<=Bu%ESHcj%Q-_3OC zB;LakbSw%a3N03kfB996l6%t@e1dseklvZ3wRX$DGtOFIflf>R7JvP4f=LahyHj81F#9lVDta@ zF_tjV3%omuQ)Bf`{JBy(On!Ra$OH|mq*D6bzyX}TTAE3F_!_B*IMY}wU8Z9r+G)sJ zFFm5c*dT>s+D54l*I_yBcDEEke0`HNgMQsO4}a%NR`NEs*-za<{6s!#tF(pKyj>ck zed`Ws7~MOi&9octT_Y=QPgT3g>7e16e;%BU8;OA{pF%xfkBF$#t-gb6}e>%ux=W~ga zk~`Tkb;B4=KD~?kL*D(8aWI(ekItdt8V?~PZgr1bwoVKr?!~eA0r}_YD+Xa`4?Ds+ z=-Xi`GK`jZVk}{%bk8zNp2_8@w9@?VI%v!0%i6^s=HCUMh-rN7o+v*qtoNMX yJFC}b+cwTy=nS=vkDcpuM8?D=#U$Dt4o~HZc9YY)Y^5#H+f`8-?0tIvd;DLJ7{){Z diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.po b/locales/ru/LC_MESSAGES/lib.cli.args.po index 36bbafbc3b..666d12ff23 100644 --- a/locales/ru/LC_MESSAGES/lib.cli.args.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args.po @@ -5,8 +5,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2021-03-20 14:15+0000\n" -"PO-Revision-Date: 2021-03-20 14:17+0000\n" +"POT-Creation-Date: 2021-05-17 18:04+0100\n" +"PO-Revision-Date: 2021-05-17 18:11+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -14,7 +14,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 2.4.2\n" +"X-Generator: Poedit 2.4.3\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" @@ -57,7 +57,7 @@ msgstr "" "с faceswap" #: lib/cli/args.py:299 lib/cli/args.py:308 lib/cli/args.py:316 -#: lib/cli/args.py:627 lib/cli/args.py:636 +#: lib/cli/args.py:630 lib/cli/args.py:639 msgid "Data" msgstr "Данные" @@ -90,8 +90,8 @@ msgstr "" "Плагины извлечения можно настроить в меню 'Настройки'" #: lib/cli/args.py:365 lib/cli/args.py:381 lib/cli/args.py:393 -#: lib/cli/args.py:425 lib/cli/args.py:443 lib/cli/args.py:455 -#: lib/cli/args.py:646 lib/cli/args.py:671 lib/cli/args.py:700 +#: lib/cli/args.py:428 lib/cli/args.py:446 lib/cli/args.py:458 +#: lib/cli/args.py:649 lib/cli/args.py:676 lib/cli/args.py:712 msgid "Plugins" msgstr "Плагины" @@ -138,6 +138,9 @@ msgid "" "RAM. You can select none, one or multiple masks, but the extraction may take " "longer the more you select. NB: The Extended and Components (landmark based) " "masks are automatically generated on extraction.\n" +"L|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).\n" "L|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.\n" @@ -164,6 +167,9 @@ msgstr "" "занять больше времени в зависимости от выбора. Прим.: Маски Extended и " "Components (на основе меток лица) всегда создаются автоматически при " "извлечении лиц.\n" +"L|bisenet-fp: Относительно легкая маска на основе NN, которая обеспечивает " +"более точный контроль над маскируемой областью, включая полное маскирование " +"головы (настраивается в настройках маски).\n" "L|vgg-clear: Маска предназначена для умной сегментации преимущественно " "фронтальных лиц без препятствий. Фотографии в профиль могут быть обработаны " "посредственно.\n" @@ -183,7 +189,7 @@ msgstr "" "ориентиров лица и расширяется вверх на лоб.\n" "(пример: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -#: lib/cli/args.py:426 +#: lib/cli/args.py:429 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -204,7 +210,7 @@ msgstr "" "L|hist: Выравнивание гистограммы каналов RGB каналов.\n" "L|mean: Усреднение цветов лица." -#: lib/cli/args.py:444 +#: lib/cli/args.py:447 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -219,7 +225,7 @@ msgstr "" "замедления скорости извлечения. Чем больше проходов выравнивания, тем меньше " "микродрожание, но тем дольше идет извлечение." -#: lib/cli/args.py:456 +#: lib/cli/args.py:459 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -231,13 +237,13 @@ msgstr "" "использовать приращения этого размера до 360, либо передайте список чисел, " "чтобы точно указать, какие углы проверять." -#: lib/cli/args.py:468 lib/cli/args.py:478 lib/cli/args.py:491 -#: lib/cli/args.py:505 lib/cli/args.py:737 lib/cli/args.py:751 -#: lib/cli/args.py:764 lib/cli/args.py:778 +#: lib/cli/args.py:471 lib/cli/args.py:481 lib/cli/args.py:494 +#: lib/cli/args.py:508 lib/cli/args.py:749 lib/cli/args.py:763 +#: lib/cli/args.py:776 lib/cli/args.py:790 msgid "Face Processing" msgstr "Обработка лиц" -#: lib/cli/args.py:469 +#: lib/cli/args.py:472 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -245,7 +251,7 @@ msgstr "" "Отбрасывает лица ниже указанного размера. Длина указывается в пикселях по " "диагонали. Установите в 0 для отключения" -#: lib/cli/args.py:479 lib/cli/args.py:752 +#: lib/cli/args.py:482 lib/cli/args.py:764 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -259,7 +265,7 @@ msgstr "" "пробел. Прим.: Фильтрация лиц существенно снижает скорость извлечения, при " "этом точность не гарантируется." -#: lib/cli/args.py:492 lib/cli/args.py:765 +#: lib/cli/args.py:495 lib/cli/args.py:777 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -273,7 +279,7 @@ msgstr "" "изображений через пробел. Прим.: Использование фильтра существенно замедлит " "скорость извлечения. Также точность не гарантируется." -#: lib/cli/args.py:506 lib/cli/args.py:779 +#: lib/cli/args.py:509 lib/cli/args.py:791 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -284,12 +290,12 @@ msgstr "" "лица. Чем ниже значения, тем строже. Прим.: Использование фильтра лиц " "существенно замедлит скорость извлечения. Также точность не гарантируется." -#: lib/cli/args.py:517 lib/cli/args.py:529 lib/cli/args.py:541 -#: lib/cli/args.py:553 +#: lib/cli/args.py:520 lib/cli/args.py:532 lib/cli/args.py:544 +#: lib/cli/args.py:556 msgid "output" msgstr "вывод" -#: lib/cli/args.py:518 +#: lib/cli/args.py:521 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -299,7 +305,7 @@ msgstr "" "поддерживает такой входной размер. Стоит изменять только для моделей " "высокого разрешения." -#: lib/cli/args.py:530 +#: lib/cli/args.py:533 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -309,7 +315,7 @@ msgstr "" "извлечении. Например, значение 1 будет искать лица в каждом кадре, а " "значение 10 в каждом 10том кадре." -#: lib/cli/args.py:542 +#: lib/cli/args.py:545 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -324,17 +330,17 @@ msgstr "" "только во время второго прохода. ВНИМАНИЕ: Не прерывайте выполнение во время " "записи, так как это может повлечь порчу файла. Установите в 0 для выключения" -#: lib/cli/args.py:554 +#: lib/cli/args.py:557 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "Рисовать ландмарки на выходных лицах для нужд отладки." -#: lib/cli/args.py:560 lib/cli/args.py:569 lib/cli/args.py:577 -#: lib/cli/args.py:584 lib/cli/args.py:791 lib/cli/args.py:802 -#: lib/cli/args.py:810 lib/cli/args.py:829 lib/cli/args.py:835 +#: lib/cli/args.py:563 lib/cli/args.py:572 lib/cli/args.py:580 +#: lib/cli/args.py:587 lib/cli/args.py:803 lib/cli/args.py:814 +#: lib/cli/args.py:822 lib/cli/args.py:841 lib/cli/args.py:847 msgid "settings" msgstr "настройки" -#: lib/cli/args.py:561 +#: lib/cli/args.py:564 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " @@ -344,7 +350,7 @@ msgstr "" "стадия извлечения будет запущена отдельно (одна, за другой). Полезно при " "нехватке VRAM." -#: lib/cli/args.py:570 +#: lib/cli/args.py:573 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -352,16 +358,16 @@ msgstr "" "Пропускать кадры, которые уже были извлечены и существуют в файле " "выравнивания" -#: lib/cli/args.py:578 +#: lib/cli/args.py:581 msgid "Skip frames that already have detected faces in the alignments file" msgstr "Пропускать кадры, для которых в файле выравнивания есть найденные лица" -#: lib/cli/args.py:585 +#: lib/cli/args.py:588 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "Не сохранять найденные лица на носитель. Просто создать файл выравнивания" -#: lib/cli/args.py:607 +#: lib/cli/args.py:610 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -369,7 +375,7 @@ msgstr "" "Заменить оригиналы лица в исходном видео/фотографиях новыми.\n" "Плагины конвертации могут быть настроены в меню 'Настройки'" -#: lib/cli/args.py:628 +#: lib/cli/args.py:631 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -379,7 +385,7 @@ msgstr "" "Предоставьте исходное видео, из которого были извлечены кадры (для настройки " "частоты кадров, а также аудио)." -#: lib/cli/args.py:637 +#: lib/cli/args.py:640 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -387,7 +393,7 @@ msgstr "" "Папка с моделью. Папка, содержащая обученную модель, которую вы хотите " "использовать для преобразования." -#: lib/cli/args.py:647 +#: lib/cli/args.py:650 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -426,11 +432,19 @@ msgstr "" "дает удовлетворительных результатов.\n" "L|none: Не производить подгонку цвета." -#: lib/cli/args.py:672 +#: lib/cli/args.py:677 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" "L|none: Don't use a mask.\n" +"L|bisenet-fp-face: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'face' or " +"'legacy' centering.\n" +"L|bisenet-fp-head: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'head' " +"centering.\n" "L|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.\n" @@ -455,6 +469,14 @@ msgstr "" "R|Использовать маску. Прим.: Требуемая маска должна наличествовать в файле " "выравнивания. Доп. маски можно добавить через Инструмент Создания Масок.\n" "L|none: Не использовать маску.\n" +"L|bisenet-fp-face: Относительно легкая маска на основе NN, которая " +"обеспечивает более точный контроль над маскируемой областью (настраивается в " +"настройках маски). Используйте эту версию bisenet-fp, если ваша модель " +"обучена с центрированием «face» или «legacy» центрирование.\n" +"L|bisenet-fp-head: Относительно легкая маска на основе NN, которая " +"обеспечивает более точный контроль над маскируемой областью (настраивается в " +"настройках маски). Используйте эту версию bisenet-fp, если ваша модель " +"обучена с центрированием «head».\n" "L| components: маска, предназначенная для сегментации лица на основе " "найденных ориентиров. Маска создается построением выпуклого многоугольника " "вокруг внешних ориентиров лица.\n" @@ -475,7 +497,7 @@ msgstr "" "L| predicted: Если во время обучения была включена опция «Learn Mask», будет " "использоваться маска, созданная обученной моделью." -#: lib/cli/args.py:701 +#: lib/cli/args.py:713 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -501,11 +523,11 @@ msgstr "" "L|pillow: [изображения] Более медленный, чем opencv, но имеет больше опций и " "поддерживает больше форматов." -#: lib/cli/args.py:720 lib/cli/args.py:727 lib/cli/args.py:821 +#: lib/cli/args.py:732 lib/cli/args.py:739 lib/cli/args.py:833 msgid "Frame Processing" msgstr "Обработка кадров" -#: lib/cli/args.py:721 +#: lib/cli/args.py:733 msgid "" "Scale the final output frames by this amount. 100%% will output the frames " "at source dimensions. 50%% at half size 200%% at double size" @@ -514,7 +536,7 @@ msgstr "" "кадры в исходном размере. 50%% половина от размера, а 200%% в удвоенном " "размере" -#: lib/cli/args.py:728 +#: lib/cli/args.py:740 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -527,7 +549,7 @@ msgstr "" "unchanged). Прим.: Если при конверсии используются изображения, то имена " "файлов должны заканчиваться номером кадра!" -#: lib/cli/args.py:738 +#: lib/cli/args.py:750 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -543,7 +565,7 @@ msgstr "" "Если оставить это поле пустым, то все лица, которые существуют в файле " "выравниваний будут сконвертированы." -#: lib/cli/args.py:792 +#: lib/cli/args.py:804 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -560,7 +582,7 @@ msgstr "" "будет использоваться больше процессов, чем доступно в вашей системе. Если " "включен одиночный процесс, этот параметр будет проигнорирован." -#: lib/cli/args.py:803 +#: lib/cli/args.py:815 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -568,7 +590,7 @@ msgstr "" "[СОВМЕСТИМОСТЬ] Это нужно выбирать только в том случае, если загружается " "устаревшая модель или если в папке сохранения есть несколько моделей" -#: lib/cli/args.py:811 +#: lib/cli/args.py:823 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -582,7 +604,7 @@ msgstr "" "использованию улучшенного конвейера экстракции и некачественных результатов. " "Если файл выравниваний найден, этот параметр будет проигнорирован." -#: lib/cli/args.py:822 +#: lib/cli/args.py:834 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -590,16 +612,16 @@ msgstr "" "При использовании с --frame-range кадры не попавшие в диапазон выводятся " "неизменными, вместо их пропуска." -#: lib/cli/args.py:830 +#: lib/cli/args.py:842 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Поменять модели местами. Вместо преобразования из A -> B, преобразует B -> A" -#: lib/cli/args.py:836 +#: lib/cli/args.py:848 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Отключить многопроцессорность. Медленнее, но менее ресурсоемко." -#: lib/cli/args.py:852 +#: lib/cli/args.py:864 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -610,11 +632,11 @@ msgstr "" "Обучение моделей может занять долгое время: от 24 часов до недели\n" "Каждую модель можно отдельно настроить в меню «Настройки»" -#: lib/cli/args.py:871 lib/cli/args.py:880 +#: lib/cli/args.py:883 lib/cli/args.py:892 msgid "faces" msgstr "лица" -#: lib/cli/args.py:872 +#: lib/cli/args.py:884 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -623,7 +645,7 @@ msgstr "" "Входная папка. Папка содержащая изображения для тренировки лица A. Это " "исходное лицо т.е. лицо, которое вы хотите убрать, заменив лицом B." -#: lib/cli/args.py:881 +#: lib/cli/args.py:893 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -632,12 +654,12 @@ msgstr "" "Входная папка. Папка содержащая изображения для тренировки лица B. Это новое " "лицо т.е. лицо, которое вы хотите поместить на голову человека A." -#: lib/cli/args.py:889 lib/cli/args.py:901 lib/cli/args.py:917 -#: lib/cli/args.py:942 lib/cli/args.py:952 +#: lib/cli/args.py:901 lib/cli/args.py:913 lib/cli/args.py:929 +#: lib/cli/args.py:954 lib/cli/args.py:964 msgid "model" msgstr "модель" -#: lib/cli/args.py:890 +#: lib/cli/args.py:902 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -651,7 +673,7 @@ msgstr "" "будет создана). Если вы хотите продолжить тренировку, выберите папку с уже " "существующими сохранениями." -#: lib/cli/args.py:902 +#: lib/cli/args.py:914 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -675,7 +697,7 @@ msgstr "" "NB: Вес можно загружать только из моделей того же плагина, который вы " "собираетесь тренировать." -#: lib/cli/args.py:918 +#: lib/cli/args.py:930 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -721,7 +743,7 @@ msgstr "" "ресурсам (Вам потребуется GPU с хорошим количеством видеопамяти). Хороша для " "деталей, но подвержена к неправильной передаче цвета." -#: lib/cli/args.py:943 +#: lib/cli/args.py:955 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -733,7 +755,7 @@ msgstr "" "сводная информация о модели, которая будет создана выбранным плагином, и " "параметрами конфигурации." -#: lib/cli/args.py:953 +#: lib/cli/args.py:965 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -747,12 +769,12 @@ msgstr "" "некоторые модели могут иметь параметры конфигурации для замораживания других " "слоев." -#: lib/cli/args.py:966 lib/cli/args.py:978 lib/cli/args.py:989 -#: lib/cli/args.py:1075 +#: lib/cli/args.py:978 lib/cli/args.py:990 lib/cli/args.py:1001 +#: lib/cli/args.py:1087 msgid "training" msgstr "тренировка" -#: lib/cli/args.py:967 +#: lib/cli/args.py:979 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -765,7 +787,7 @@ msgstr "" "изображений в два раза больше этого числа. Увеличение размера партии требует " "больше памяти GPU." -#: lib/cli/args.py:979 +#: lib/cli/args.py:991 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -779,22 +801,22 @@ msgstr "" "Однако, если вы хотите, чтобы тренировка прервалась после указанного кол-ва " "итерация, вы можете ввести это здесь." -#: lib/cli/args.py:990 +#: lib/cli/args.py:1002 msgid "" "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" "Использовать стратегию зеркального распределения Tensorflow для совместной " "тренировки сразу на нескольких GPU." -#: lib/cli/args.py:1000 lib/cli/args.py:1010 +#: lib/cli/args.py:1012 lib/cli/args.py:1022 msgid "Saving" msgstr "Сохранение" -#: lib/cli/args.py:1001 +#: lib/cli/args.py:1013 msgid "Sets the number of iterations between each model save." msgstr "Установка количества итераций между сохранениями модели." -#: lib/cli/args.py:1011 +#: lib/cli/args.py:1023 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -802,11 +824,11 @@ msgstr "" "Устанавливает кол-во итераций перед созданием резервной копии модели. " "Установите в 0 для отключения." -#: lib/cli/args.py:1018 lib/cli/args.py:1029 lib/cli/args.py:1040 +#: lib/cli/args.py:1030 lib/cli/args.py:1041 lib/cli/args.py:1052 msgid "timelapse" msgstr "таймлапс" -#: lib/cli/args.py:1019 +#: lib/cli/args.py:1031 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -819,7 +841,7 @@ msgstr "" "папку лиц набора 'A' для использования при создании таймлапса. Вам также " "нужно указать параметры--timelapse-output и --timelapse-input-B." -#: lib/cli/args.py:1030 +#: lib/cli/args.py:1042 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -833,7 +855,7 @@ msgstr "" "таймлапса. Вы также должны указать параметр --timelapse-output и --timelapse-" "input-A." -#: lib/cli/args.py:1041 +#: lib/cli/args.py:1053 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -845,22 +867,22 @@ msgstr "" "указаны только входные папки, то по умолчанию вывод будет сохранен вместе с " "моделью в подкаталог /timelapse/" -#: lib/cli/args.py:1053 lib/cli/args.py:1060 lib/cli/args.py:1067 +#: lib/cli/args.py:1065 lib/cli/args.py:1072 lib/cli/args.py:1079 msgid "preview" msgstr "предварительный просмотр" -#: lib/cli/args.py:1054 +#: lib/cli/args.py:1066 msgid "" "Percentage amount to scale the preview by. 100%% is the model output size." msgstr "" "Величина в процентах, на которую требуется масштабировать предварительный " "просмотр. 100 %% - размер вывода модели." -#: lib/cli/args.py:1061 +#: lib/cli/args.py:1073 msgid "Show training preview output. in a separate window." msgstr "Показывать предварительный просмотр в отдельном окне." -#: lib/cli/args.py:1068 +#: lib/cli/args.py:1080 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -868,7 +890,7 @@ msgstr "" "Записывает результат тренировки в файл. Файл будет сохранен в коренной папке " "FaceSwap." -#: lib/cli/args.py:1076 +#: lib/cli/args.py:1088 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -876,12 +898,12 @@ msgstr "" "Отключает журнал TensorBoard. Примечание: Отключение журналов означает, что " "вы не сможете использовать графики или анализ сессии внутри GUI." -#: lib/cli/args.py:1083 lib/cli/args.py:1092 lib/cli/args.py:1101 -#: lib/cli/args.py:1110 +#: lib/cli/args.py:1095 lib/cli/args.py:1104 lib/cli/args.py:1113 +#: lib/cli/args.py:1122 msgid "augmentation" msgstr "аугментация" -#: lib/cli/args.py:1084 +#: lib/cli/args.py:1096 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -891,7 +913,7 @@ msgstr "" "Ориентирами/Landmarks противоположного набора лиц. Этот способ используется " "пакетом \"dfaker\"." -#: lib/cli/args.py:1093 +#: lib/cli/args.py:1105 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -902,7 +924,7 @@ msgstr "" "происходило. Как правило, эту настройку не стоит трогать, за исключением " "периода «финальной шлифовки»." -#: lib/cli/args.py:1102 +#: lib/cli/args.py:1114 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -912,7 +934,7 @@ msgstr "" "цвета между наборами A and B ценой некоторого замедления скорости " "тренировки. Включите эту опцию для отключения цветовой аугментации." -#: lib/cli/args.py:1111 +#: lib/cli/args.py:1123 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -925,7 +947,7 @@ msgstr "" "Включение этой опции с самого начала может убить модель и привести к ужасным " "результатам." -#: lib/cli/args.py:1136 +#: lib/cli/args.py:1148 msgid "Output to Shell console instead of GUI console" msgstr "Вывод в системную консоль вместо GUI" diff --git a/locales/tools.mask.cli.pot b/locales/tools.mask.cli.pot index 11a314e6d3..0e3d2c1c09 100644 --- a/locales/tools.mask.cli.pot +++ b/locales/tools.mask.cli.pot @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-02-18 23:14-0000\n" +"POT-Creation-Date: 2021-05-17 18:17+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -44,13 +44,14 @@ msgid "" "L|frames: The input is a folder containing frames or is a video" msgstr "" -#: tools/mask/cli.py:62 tools/mask/cli.py:87 +#: tools/mask/cli.py:62 tools/mask/cli.py:90 msgid "process" msgstr "" #: tools/mask/cli.py:63 msgid "" "R|Masker to use.\n" +"L|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).\n" "L|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.\n" "L|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.\n" "L|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.\n" @@ -58,7 +59,7 @@ msgid "" "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." msgstr "" -#: tools/mask/cli.py:88 +#: tools/mask/cli.py:91 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" @@ -66,24 +67,24 @@ msgid "" "L|output: Don't update the masks, just output them for review in the given output folder." msgstr "" -#: tools/mask/cli.py:101 tools/mask/cli.py:108 tools/mask/cli.py:121 -#: tools/mask/cli.py:134 tools/mask/cli.py:143 +#: tools/mask/cli.py:104 tools/mask/cli.py:111 tools/mask/cli.py:124 +#: tools/mask/cli.py:137 tools/mask/cli.py:146 msgid "output" msgstr "" -#: tools/mask/cli.py:102 +#: tools/mask/cli.py:105 msgid "Optional output location. If provided, a preview of the masks created will be output in the given folder." msgstr "" -#: tools/mask/cli.py:112 +#: tools/mask/cli.py:115 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 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" msgstr "" -#: tools/mask/cli.py:125 +#: tools/mask/cli.py:128 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:135 +#: tools/mask/cli.py:138 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" @@ -91,7 +92,7 @@ msgid "" "L|mask: Only output the mask as a single channel image." msgstr "" -#: tools/mask/cli.py:144 +#: tools/mask/cli.py:147 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." msgstr "" diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 525fc76b7c..4dacc924f9 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -164,7 +164,7 @@ def _import(attr, name, disable_logging): return getattr(module, ttl) @staticmethod - def get_available_extractors(extractor_type, add_none=False): + def get_available_extractors(extractor_type, add_none=False, extend_plugin=False): """ Return a list of available extractors of the given type Parameters @@ -173,6 +173,14 @@ def get_available_extractors(extractor_type, add_none=False): The type of extractor to return the plugins for add_none: bool, optional Append "none" to the list of returned plugins. Default: False + extend_plugin: bool, optional + Some plugins have configuration options that mean that multiple 'pseudo-plugins' + can be generated based on their settings. An example of this is the bisenet-fp mask + which, whilst selected as 'bisenet-fp' can be stored as 'bisenet-fp-face' and + 'bisenet-fp-head' depending on whether hair has been included in the mask or not. + ``True`` will generate each pseudo-plugin, ``False`` will generate the original + plugin name. Default: ``False`` + Returns ------- list: @@ -181,11 +189,16 @@ def get_available_extractors(extractor_type, add_none=False): extractpath = os.path.join(os.path.dirname(__file__), "extract", extractor_type) - extractors = sorted(item.name.replace(".py", "").replace("_", "-") - for item in os.scandir(extractpath) - if not item.name.startswith("_") - and not item.name.endswith("defaults.py") - and item.name.endswith(".py")) + extractors = [item.name.replace(".py", "").replace("_", "-") + for item in os.scandir(extractpath) + if not item.name.startswith("_") + and not item.name.endswith("defaults.py") + and item.name.endswith(".py")] + if extend_plugin and extractor_type == "mask" and "bisenet-fp" in extractors: + extractors.remove("bisenet-fp") + extractors.extend(["bisenet-fp-face", "bisenet-fp-head"]) + + extractors = sorted(extractors) if add_none: extractors.insert(0, "none") return extractors diff --git a/plugins/train/_config.py b/plugins/train/_config.py index e717f84948..7f6dfc5b32 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -35,7 +35,7 @@ def _set_globals(self): datatype=str, gui_radio=True, default="face", - choices=["face", "legacy"], + choices=["face", "head", "legacy"], fixed=True, group="face", info="How to center the training image. The extracted images are centered on the " @@ -44,6 +44,11 @@ def _set_globals(self): "will be cropped from the aligned images." "\n\tface: Centers the training image on the center of the face, adjusting for " "pitch and yaw." + "\n\thead: Centers the training image on the center of the head, adjusting for " + "pitch and yaw. NB: You should only select head centering if you intend to " + "include the full head (including hair) in the final swap. This may give mixed " + "results. Additionally, it is only worth choosing head centering if you are " + "training with a mask that includes the hair (e.g. BiSeNet-FP-Head)." "\n\tlegacy: The 'original' extraction technique. Centers the training image " "near the tip of the nose with no adjustment. Can result in the edges of the " "face appearing outside of the training area.") @@ -60,12 +65,13 @@ def _set_globals(self): "model's scope to a zoomed-in central area while higher amounts can include the " "entire face. A trade-off exists between lower amounts given more detail " "versus higher amounts avoiding noticeable swap transitions. For 'Face' " - "centering you will want to leave this above 75%. Sensible values for 'Legacy' " + "centering you will want to leave this above 75%. For Head centering you will " + "most likely want to set this to 100%. Sensible values for 'Legacy' " "centering are:" - "\n\t62.5%% spans from eyebrow to eyebrow." - "\n\t75.0%% spans from temple to temple." - "\n\t87.5%% spans from ear to ear." - "\n\t100.0%% is a mugshot.") + "\n\t62.5% spans from eyebrow to eyebrow." + "\n\t75.0% spans from temple to temple." + "\n\t87.5% spans from ear to ear." + "\n\t100.0% is a mugshot.") self.add_item( section=section, @@ -344,7 +350,8 @@ def _set_loss(self): title="mask_type", datatype=str, default="extended", - choices=PluginLoader.get_available_extractors("mask", add_none=True), + choices=PluginLoader.get_available_extractors("mask", + add_none=True, extend_plugin=True), group="mask", gui_radio=True, info="The mask to be used for training. If you have selected 'Learn Mask' or " @@ -353,6 +360,13 @@ def _set_loss(self): "exist in the alignments file then it will be generated prior to training " "commencing." "\n\tnone: Don't use a mask." + "\n\tbisenet-fp-face: Relatively lightweight NN based mask that provides more " + "refined control over the area to be masked (configurable in mask settings). " + "Use this version of bisenet-fp if your model is trained with 'face' or " + "'legacy' centering." + "\n\tbisenet-fp-head: Relatively lightweight NN based mask that provides more " + "refined control over the area to be masked (configurable in mask settings). " + "Use this version of bisenet-fp if your model is trained with 'head' centering." "\n\tcomponents: 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." diff --git a/tools/mask/cli.py b/tools/mask/cli.py index 0c677ef88f..ac0d8be662 100644 --- a/tools/mask/cli.py +++ b/tools/mask/cli.py @@ -61,6 +61,9 @@ def get_argument_list(self): 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." From 2b39b63e0a07407f67988ad276c8b7e320832f0d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 18 May 2021 18:55:16 +0100 Subject: [PATCH 472/981] Update docstrings --- plugins/extract/mask/bisenet_fp.py | 217 +++++++++++++++++++++++++++-- 1 file changed, 202 insertions(+), 15 deletions(-) diff --git a/plugins/extract/mask/bisenet_fp.py b/plugins/extract/mask/bisenet_fp.py index 20e9f15fdd..0ada9fe68d 100644 --- a/plugins/extract/mask/bisenet_fp.py +++ b/plugins/extract/mask/bisenet_fp.py @@ -48,7 +48,7 @@ def _get_segment_indices(self): ----- Model segment indices: 0: background, 1: skin, 2: left brow, 3: right brow, 4: left eye, 5: right eye, 6: glasses - 7: left ear, 8: right ear, 9: earings, 10: nose, 11: mouth, 12: upper lip, 13: lower_lip, + 7: left ear, 8: right ear, 9: earing, 10: nose, 11: mouth, 12: upper lip, 13: lower_lip, 14: neck, 15: neck ?, 16: cloth, 17: hair, 18: hat """ retval = [1, 2, 3, 4, 5, 10, 11, 12, 13] @@ -62,14 +62,15 @@ def _get_segment_indices(self): return retval def init_model(self): + """ Initialize the BiSeNet Face Parsing model. """ self.model = BiSeNet(self.model_path, self.config["allow_growth"], self._exclude_gpus, self.input_size, 19) - + placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), - dtype="float32") + dtype="float32") self.model.predict(placeholder) def process_input(self, batch): @@ -123,6 +124,26 @@ def process_output(self, batch): def _get_name(name, start_idx=1): + """ Auto numbering to keep track of layer names. + + Names are kept the same as the PyTorch original model, to enable easier porting of weights. + + Names are tracked and auto-appended with an integer to ensure they are unique. + + Parameters + ---------- + name: str + The name of the layer to get auto named. + start_idx + The first index number to start auto naming layers with the same name. Usually 0 or 1. + Pass -1 if the name should not be auto-named (i.e. should not have an integer appended + to the end) + + Returns + ------- + str + A unique version of the original name + """ i = start_idx while True: retval = f"{name}{i}" if i != -1 else name @@ -133,7 +154,29 @@ def _get_name(name, start_idx=1): return retval -class ConvBn(): +class ConvBn(): # pylint:disable=too-few-public-methods + """ Convolutional 3D with Batch Normalization block. + + Parameters + ---------- + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution). + kernel_size: int, optional + The height and width of the 2D convolution window. Default: `3` + strides: int, optional + The strides of the convolution along the height and width. Default: `1` + padding: int, optional + The amount of padding to apply prior to the first Convolutional Layer. Default: `1` + activation: bool + Whether to include ReLu Activation at the end of the block. Default: ``True`` + prefix: str, optional + The prefix to name the layers within the block. Default: ``""`` (empty string, i.e. no + prefix) + start_idx: int, optional + The starting index for naming the layers within the block. See :func:`_get_name` for + more information. Default: `1` + """ def __init__(self, filters, kernel_size=3, strides=1, padding=1, activation=True, prefix="", start_idx=1): self._filters = filters @@ -145,6 +188,18 @@ def __init__(self, filters, self._start_idx = start_idx def __call__(self, inputs): + """ Call the Convolutional Batch Normalization block. + + Parameters + ---------- + inputs: tensor + The input to the block + + Returns + ------- + tensor + The output from the block + """ var_x = inputs if self._padding > 0 and self._kernel_size != 1: var_x = ZeroPadding2D(self._padding, @@ -167,11 +222,31 @@ def __call__(self, inputs): return var_x -class ResNet18(): +class ResNet18(): # pylint:disable=too-few-public-methods + """ ResNet 18 block. Used at the start of BiSeNet Face Parsing. """ def __init__(self): self._feature_index = 1 if K.image_data_format() == "channels_first" else -1 def _basic_block(self, inputs, prefix, filters, strides=1): + """ The basic building block for ResNet 18. + + Parameters + ---------- + inputs: tensor + The input to the block + prefix: str + The prefix to name the layers within the block + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution). + strides: int, optional + The strides of the convolution along the height and width. Default: `1` + + Returns + ------- + tensor + The output from the block + """ res = ConvBn(filters, strides=strides, padding=1, prefix=prefix)(inputs) res = ConvBn(filters, strides=1, padding=1, activation=False, prefix=prefix)(res) @@ -190,13 +265,46 @@ def _basic_block(self, inputs, prefix, filters, strides=1): var_x = Activation("relu", name=f"{prefix}.relu")(var_x) return var_x - def _basic_layer(self, inputs, prefix, filters, bnum, strides=1): + def _basic_layer(self, inputs, prefix, filters, num_blocks, strides=1): + """ The basic layer for ResNet 18. Recursively builds from :func:`_basic_block`. + + Parameters + ---------- + inputs: tensor + The input to the block + prefix: str + The prefix to name the layers within the block + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution). + num_blocks: int + The number of basic blocks to recursively build + strides: int, optional + The strides of the convolution along the height and width. Default: `1` + + Returns + ------- + tensor + The output from the block + """ var_x = self._basic_block(inputs, f"{prefix}.0", filters, strides=strides) - for i in range(bnum - 1): + for i in range(num_blocks - 1): var_x = self._basic_block(var_x, f"{prefix}.{i + 1}", filters, strides=1) return var_x def __call__(self, inputs): + """ Call the ResNet 18 block. + + Parameters + ---------- + inputs: tensor + The input to the block + + Returns + ------- + tensor + The output from the block + """ var_x = ConvBn(64, kernel_size=7, strides=2, padding=3, prefix="cp.resnet")(inputs) var_x = ZeroPadding2D(1, name="cp.resnet.zeropad")(var_x) var_x = MaxPooling2D(pool_size=3, strides=2, name="cp.resnet.maxpool")(var_x) @@ -209,11 +317,33 @@ def __call__(self, inputs): return feat8, feat16, feat32 -class AttentionRefinementModule(): +class AttentionRefinementModule(): # pylint:disable=too-few-public-methods + """ The Attention Refinement block for BiSeNet Face Parsing + + Parameters + ---------- + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution). + """ def __init__(self, filters): self._filters = filters def __call__(self, inputs, feats): + """ Call the Attention Refinement block. + + Parameters + ---------- + inputs: tensor + The input to the block + feats: int + The number of features. Used for naming. + + Returns + ------- + tensor + The output from the block + """ prefix = f"cp.arm{feats}" feat = ConvBn(self._filters, prefix=f"{prefix}.conv", start_idx=-1, padding=-1)(inputs) atten = GlobalAveragePooling2D(name=f"{prefix}.avgpool")(feat) @@ -225,11 +355,24 @@ def __call__(self, inputs, feats): return var_x -class ContextPath(): +class ContextPath(): # pylint:disable=too-few-public-methods + """ The Context Path block for BiSeNet Face Parsing. """ def __init__(self): self._resnet = ResNet18() def __call__(self, inputs): + """ Call the Context Path block. + + Parameters + ---------- + inputs: tensor + The input to the block + + Returns + ------- + tensor + The output from the block + """ feat8, feat16, feat32 = self._resnet(inputs) avg = GlobalAveragePooling2D(name="cp.avgpool")(feat32) @@ -251,11 +394,31 @@ def __call__(self, inputs): return feat8, feat16, feat32 -class FeatureFusionModule(): +class FeatureFusionModule(): # pylint:disable=too-few-public-methods + """ The Feature Fusion block for BiSeNet Face Parsing + + Parameters + ---------- + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution). + """ def __init__(self, filters): self._filters = filters def __call__(self, inputs): + """ Call the Feature Fusion block. + + Parameters + ---------- + inputs: tensor + The input to the block + + Returns + ------- + tensor + The output from the block + """ feat = Concatenate(name="ffm.concat")(inputs) feat = ConvBn(self._filters, kernel_size=1, @@ -275,14 +438,38 @@ def __call__(self, inputs): return var_x -class BiSeNetOutput(): - def __init__(self, mid_chan, num_classes, label=""): - self._mid_chan = mid_chan +class BiSeNetOutput(): # pylint:disable=too-few-public-methods + """ The BiSeNet Output block for Face Parsing + + Parameters + ---------- + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution). + num_class: int + The number of classes to generate + label, str, optional + The label for this output (for naming). Default: `""` (i.e. empty string, or no label) + """ + def __init__(self, filters, num_classes, label=""): + self._filters = filters self._num_classes = num_classes self._label = label def __call__(self, inputs): - var_x = ConvBn(self._mid_chan, prefix=f"conv_out{self._label}.conv", start_idx=-1)(inputs) + """ Call the BiSeNet Output block. + + Parameters + ---------- + inputs: tensor + The input to the block + + Returns + ------- + tensor + The output from the block + """ + var_x = ConvBn(self._filters, prefix=f"conv_out{self._label}.conv", start_idx=-1)(inputs) var_x = Conv2D(self._num_classes, 1, use_bias=False, name=f"conv_out{self._label}.conv_out")(var_x) return var_x @@ -291,7 +478,7 @@ def __call__(self, inputs): class BiSeNet(KSession): """ BiSeNet Face-Parsing Mask from https://github.com/zllrunning/face-parsing.PyTorch - PyTorch model reimplemented in Keras by TorzDF + PyTorch model implemented in Keras by TorzDF Parameters ---------- From 2b896df4983688750dc6627fa7545bfe723470f2 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 18 May 2021 19:36:01 +0100 Subject: [PATCH 473/981] mask tool - Update to support masks that support multi-centering --- tools/mask/mask.py | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/tools/mask/mask.py b/tools/mask/mask.py index c312155121..36d43da66a 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -275,7 +275,7 @@ def _get_output_suffix(self, arguments): str: The suffix to be appended to the output filename """ - sfx = "{}_mask_preview_".format(self._mask_type) + sfx = "mask_preview_" sfx += "face_" if not arguments.full_frame or self._input_is_faces else "frame_" sfx += "{}.png".format(arguments.output_type) return sfx @@ -383,35 +383,45 @@ def _save(self, frame, idx, detected_face): detected_face: `lib.FacesDetect.detected_face` A detected_face object for a face """ - filename = os.path.join(self._saver.location, "{}_{}_{}".format( - os.path.splitext(frame)[0], - idx, - self._output["suffix"])) + if self._update_type == "output" and 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 detected_face.mask.get(self._mask_type, None) is None: + 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 - image = self._create_image(detected_face) - logger.trace("filename: '%s', image_shape: %s", filename, image.shape) - self._saver.save(filename, image) - def _create_image(self, detected_face): + for mask_type in mask_types: + filename = os.path.join(self._saver.location, "{}_{}_{}".format( + os.path.splitext(frame)[0], + idx, + f"{mask_type}_{self._output['suffix']}")) + image = self._create_image(detected_face, mask_type) + logger.trace("filename: '%s', image_shape: %s", filename, image.shape) + self._saver.save(filename, image) + + def _create_image(self, detected_face, mask_type): """ Create a mask preview image for saving out to disk Parameters ---------- detected_face: `lib.FacesDetect.detected_face` A detected_face object for a face + mask_type: str + The stored mask type name to create the image for Returns - numpy.ndarray: + ------- + :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[self._mask_type] + mask = detected_face.mask[mask_type] mask.set_blur_and_threshold(**self._output["opts"]) if not self._output["full_frame"] or self._input_is_faces: if self._input_is_faces: @@ -422,9 +432,9 @@ def _create_image(self, detected_face): is_aligned=True).face else: centering = "legacy" if self._alignments.version == 1.0 else mask.stored_centering - detected_face.load_aligned(detected_face.image, centering=centering) + detected_face.load_aligned(detected_face.image, centering=centering, force=True) face = detected_face.aligned.face - mask = cv2.resize(detected_face.mask[self._mask_type].mask, + mask = cv2.resize(detected_face.mask[mask_type].mask, (face.shape[1], face.shape[0]), interpolation=cv2.INTER_CUBIC)[..., None] else: From 903da479401a9de6185c18110b147fc1b105ced1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 19 May 2021 11:11:53 +0100 Subject: [PATCH 474/981] bugfix: unet-dfl mask - Store + extract with legacy centering --- plugins/extract/mask/unet_dfl.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/extract/mask/unet_dfl.py b/plugins/extract/mask/unet_dfl.py index bf8bf0eaaa..9dd45613ad 100644 --- a/plugins/extract/mask/unet_dfl.py +++ b/plugins/extract/mask/unet_dfl.py @@ -30,6 +30,7 @@ def __init__(self, **kwargs): self.vram_warnings = 256 self.vram_per_batch = 80 self.batchsize = self.config["batch-size"] + self._storage_centering = "legacy" def init_model(self): self.model = KSession(self.name, From c0a1b7f536e5ae0ecf41915333536a6d3059152d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 19 May 2021 22:34:22 +0100 Subject: [PATCH 475/981] bugfix: Switch `-` for `_` in bisenet mask --- plugins/plugin_loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 4dacc924f9..5345216c4c 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -196,7 +196,7 @@ def get_available_extractors(extractor_type, add_none=False, extend_plugin=False and item.name.endswith(".py")] if extend_plugin and extractor_type == "mask" and "bisenet-fp" in extractors: extractors.remove("bisenet-fp") - extractors.extend(["bisenet-fp-face", "bisenet-fp-head"]) + extractors.extend(["bisenet-fp_face", "bisenet-fp_head"]) extractors = sorted(extractors) if add_none: From 0526da38a0766d91604c03642f14b4b648596a32 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 20 May 2021 11:19:50 +0100 Subject: [PATCH 476/981] lib.align.pose.offset - Add legacy parameter to dict --- lib/align/aligned_face.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 2afe485451..e8da3dce54 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -577,8 +577,9 @@ def _get_offset(self): :class:`numpy.ndarray` The x, y offset of the new center from the old center. """ + offset = dict(legacy=np.array([0.0, 0.0])) points = dict(head=(0, 0, -2.3), face=(0, -1.5, 4.2)) - offset = dict() + for key, pnts in points.items(): center = cv2.projectPoints(np.float32([pnts]), self._rotation, From d20b04ef84633185b96434f1ccc1f39080d6e4f4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 20 May 2021 11:52:24 +0100 Subject: [PATCH 477/981] tools.mask - Correctly output masks when generating masks with multi-config options --- tools/mask/mask.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 36d43da66a..b0a4d8df21 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -383,7 +383,7 @@ def _save(self, frame, idx, detected_face): detected_face: `lib.FacesDetect.detected_face` A detected_face object for a face """ - if self._update_type == "output" and self._mask_type == "bisenet-fp": + if self._mask_type == "bisenet-fp": mask_types = [f"{self._mask_type}_{area}" for area in ("face", "head")] else: mask_types = [self._mask_type] @@ -395,6 +395,9 @@ def _save(self, frame, idx, detected_face): return for mask_type in mask_types: + if mask_type not in detected_face.mask: + # If extracting bisenet-fp mask, then skip versions which don't exist + continue filename = os.path.join(self._saver.location, "{}_{}_{}".format( os.path.splitext(frame)[0], idx, From 00544f432a789060f729759b6df7e05aa6f89cfb Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 20 May 2021 13:39:16 +0100 Subject: [PATCH 478/981] Bugfix: Collect mask correctly in training and convert --- lib/align/detected_face.py | 4 ---- lib/convert.py | 8 +++----- lib/training/generator.py | 3 ++- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index 59b4d788c5..f6ce1b962e 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -616,10 +616,6 @@ def set_sub_crop(self, offset, centering): The (x, y) offset from the center point to return the mask for centering: str The centering to set the sub crop area for. One of `"legacy"`, `"face"`. `"head"` - - Notes - ----- - All crops are for 'legacy` centering. This may change in future """ if centering == self.stored_centering: return diff --git a/lib/convert.py b/lib/convert.py index daba314834..a6696941ed 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -320,11 +320,9 @@ def _get_image_mask(self, new_face, detected_face, predicted_mask, reference_fac """ logger.trace("Getting mask. Image shape: %s", new_face.shape) mask_centering = detected_face.mask[self._args.mask_type].stored_centering - if self._centering != mask_centering: - crop_offset = reference_face.pose.offset[mask_centering] * -1 - else: - crop_offset = np.array((0, 0)) - mask, raw_mask = self._adjustments["mask"].run(detected_face, crop_offset, mask_centering, + crop_offset = (reference_face.pose.offset[self._centering] - + reference_face.pose.offset[mask_centering]) + mask, raw_mask = self._adjustments["mask"].run(detected_face, crop_offset, self._centering, predicted_mask=predicted_mask) if new_face.shape[2] == 4: logger.trace("Combining mask with alpha channel box mask") diff --git a/lib/training/generator.py b/lib/training/generator.py index 50b7dde51c..c8bbf2f56c 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -392,7 +392,8 @@ def _add_mask(self, filename, detected_face): mask.set_blur_and_threshold(blur_kernel=self._config["mask_blur_kernel"], threshold=self._config["mask_threshold"]) - mask.set_sub_crop(self._cache[key]["aligned_face"].pose.offset[mask.stored_centering] * -1, + pose = self._cache[key]["aligned_face"].pose + mask.set_sub_crop(pose.offset[self._centering] - pose.offset[mask.stored_centering], self._centering) logger.trace("Caching mask for: %s", filename) From 3d914ee382022a74199b0bb6674affff50aa8dbc Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 21 May 2021 10:52:39 +0100 Subject: [PATCH 479/981] bugfix: Convert - don't error if no mask is selected --- lib/convert.py | 11 +++++++++-- scripts/convert.py | 27 +++++++++++++++++++-------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/lib/convert.py b/lib/convert.py index a6696941ed..28cd7b4bba 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -142,6 +142,7 @@ def process(self, in_queue, out_queue): """ logger.debug("Starting convert process. (in_queue: %s, out_queue: %s)", in_queue, out_queue) + log_once = False while True: items = in_queue.get() if items == "EOF": @@ -163,7 +164,10 @@ def process(self, in_queue, out_queue): logger.error("Failed to convert image: '%s'. Reason: %s", item["filename"], str(err)) image = item["image"] - logger.trace("Convert error traceback:", exc_info=True) + + loglevel = logger.trace if log_once else logger.warning + loglevel("Convert error traceback:", exc_info=True) + log_once = True # UNCOMMENT THIS CODE BLOCK TO PRINT TRACEBACK ERRORS # import sys ; import traceback # exc_info = sys.exc_info() ; traceback.print_exception(*exc_info) @@ -319,7 +323,10 @@ def _get_image_mask(self, new_face, detected_face, predicted_mask, reference_fac The swapped face with the requested mask added to the Alpha channel """ logger.trace("Getting mask. Image shape: %s", new_face.shape) - mask_centering = detected_face.mask[self._args.mask_type].stored_centering + if self._args.mask_type != "none": + mask_centering = detected_face.mask[self._args.mask_type].stored_centering + else: + mask_centering = "face" # Unused but requires a valid value crop_offset = (reference_face.pose.offset[self._centering] - reference_face.pose.offset[mask_centering]) mask, raw_mask = self._adjustments["mask"].run(detected_face, crop_offset, self._centering, diff --git a/scripts/convert.py b/scripts/convert.py index 9d272048be..ebd4f757cc 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -52,18 +52,13 @@ def __init__(self, arguments): self._patch_threads = None self._images = ImagesLoader(self._args.input_dir, fast_count=True) self._alignments = Alignments(self._args, False, self._images.is_video) - if self._alignments.version == 1.0: - logger.error("The alignments file format has been updated since the given alignments " - "file was generated. You need to update the file to proceed.") - logger.error("To do this run the 'Alignments Tool' > 'Extract' Job.") - sys.exit(1) + self._validate() self._opts = OptionalActions(self._args, self._images.file_list, self._alignments) self._add_queues() self._disk_io = DiskIO(self._alignments, self._images, arguments) self._predictor = Predict(self._disk_io.load_queue, self._queue_size, arguments) - self._validate() get_folder(self._args.output_dir) configfile = self._args.configfile if hasattr(self._args, "configfile") else None @@ -107,6 +102,7 @@ def _validate(self): Ensure that certain cli selections are valid and won't result in an error. Checks: * If frames have been passed in with video output, ensure user supplies reference video. + * If "on-the-fly" and an NN mask is selected, output warning and switch to 'extended' * If a mask-type is selected, ensure it exists in the alignments file. * If a predicted mask-type is selected, ensure model has been trained with a mask otherwise attempt to select first available masks, otherwise raise error. @@ -117,12 +113,26 @@ def _validate(self): If an invalid selection has been found. """ + if self._alignments.version == 1.0: + logger.error("The alignments file format has been updated since the given alignments " + "file was generated. You need to update the file to proceed.") + logger.error("To do this run the 'Alignments Tool' > 'Extract' Job.") + sys.exit(1) + if (self._args.writer == "ffmpeg" and not self._images.is_video and self._args.reference_video is None): raise FaceswapError("Output as video selected, but using frames as input. You must " "provide a reference video ('-ref', '--reference-video').") - if (self._args.mask_type not in ("none", "predicted") and + + if (self._args.on_the_fly and + self._args.mask_type not in ("none", "extended", "components")): + logger.warning("You have selected an incompatible mask type ('%s') for On-The-Fly " + "conversion. Switching to 'extended'", self._args.mask_type) + self._args.mask_type = "extended" + + if (not self._args.on_the_fly and + self._args.mask_type not in ("none", "predicted") and not self._alignments.mask_is_valid(self._args.mask_type)): msg = ("You have selected the Mask Type `{}` but at least one face does not have this " "mask stored in the Alignments File.\nYou should generate the required masks " @@ -131,6 +141,7 @@ def _validate(self): "{}".format(self._args.mask_type, self._alignments.faces_count, self._alignments.mask_summary)) raise FaceswapError(msg) + if self._args.mask_type == "predicted" and not self._predictor.has_predicted_mask: available_masks = [k for k, v in self._alignments.mask_summary.items() if k != "none" and v == self._alignments.faces_count] @@ -387,7 +398,7 @@ def _load_extractor(self): "video with Extract first for superior results.") extractor = Extractor(detector="cv2-dnn", aligner="cv2-dnn", - masker="none", + masker=self._args.mask_type, multiprocess=True, rotate_images=None, min_size=20) From 6ee896d175145297ecae001d1a6f4628b5b4e6ef Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 23 May 2021 16:10:42 +0100 Subject: [PATCH 480/981] lib.gui.stats - Read loss names from model config output rather than state file --- lib/gui/analysis/event_reader.py | 56 +++++++++++++++++++++++++------- lib/gui/analysis/stats.py | 8 +++-- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index 3c95014cdd..cba88fe2e6 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -589,6 +589,11 @@ def _parse_outputs(self, event): Loss names are added to :attr:`_loss_labels` + Notes + ----- + The master model does not actually contain the specified output name, so we dig into the + sub-model to obtain the name of the output layers + Parameters ---------- event: :class:`tensorflow.core.util.event_pb2` @@ -596,23 +601,52 @@ def _parse_outputs(self, event): """ serializer = get_serializer("json") struct = event.summary.value[0].tensor.string_val[0] - outputs = np.array(serializer.unmarshal(struct)["config"]["output_layers"]) - logger.debug("Obtained model outputs: %s, shape: %s", outputs, outputs.shape) - if outputs.ndim == 2: # Insert extra dimension for non learn mask models - outputs = np.expand_dims(outputs, axis=1) - logger.debug("Expanded dimensions for non-learn_mask model. outputs: %s, shape: %s", - outputs, outputs.shape) - for side_outputs, side in zip(outputs, ("a", "b")): + + config = serializer.unmarshal(struct)["config"] + model_outputs = self._get_outputs(config) + split_output = len(np.unique(model_outputs[..., 1])) == 1 + + for side_outputs, side in zip(model_outputs, ("a", "b")): logger.debug("side: '%s', outputs: '%s'", side, side_outputs) - for idx in range(len(side_outputs)): - # First output is always face. Subsequent outputs are masks - loss_name = f"face_{side}" if idx == 0 else f"mask_{side}" - loss_name = loss_name if idx < 2 else f"{loss_name}_{idx}" + layer_name = side_outputs[0][0] + + output_config = next(layer for layer in config["layers"] + if layer["name"] == layer_name)["config"] + layer_outputs = self._get_outputs(output_config) + for output in layer_outputs: # Drill into sub-model to get the actual output names + loss_name = output[0][0] + if not split_output: # Rename losses to reflect the side's output + loss_name = f"{loss_name.replace('_both', '')}_{side}" if loss_name not in self._loss_labels: logger.debug("Adding loss name: '%s'", loss_name) self._loss_labels.append(loss_name) logger.debug("Collated loss labels: %s", self._loss_labels) + @classmethod + def _get_outputs(cls, model_config): + """ Obtain the output names, instance index and output index for the given model. + + If there is only a single output, the shape of the array is expanded to remain consistent + with multi model outputs + + Parameters + ---------- + model_config: dict + The saved Keras model configuration dictionary + + Returns + ------- + :class:`numpy.ndarray` + The layer output names, their instance index and their output index + """ + outputs = np.array(model_config["output_layers"]) + logger.debug("Obtained model outputs: %s, shape: %s", outputs, outputs.shape) + if outputs.ndim == 2: # Insert extra dimension for non learn mask models + outputs = np.expand_dims(outputs, axis=1) + logger.debug("Expanded dimensions for single output model. outputs: %s, shape: %s", + outputs, outputs.shape) + return outputs + @classmethod def _process_event(cls, event, step): """ Process a single Tensorflow event. diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index 1017ecfa6e..f4dcc2255d 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -235,12 +235,14 @@ def get_loss_keys(self, session_id): The loss keys for the given session. If ``None`` is passed as session_id then a unique list of all loss keys for all sessions is returned """ + loss_keys = {sess_id: list(logs.keys()) + for sess_id, logs in self._tb_logs.get_loss(session_id=session_id).items()} if session_id is None: retval = list(set(loss_key - for session in self._state["sessions"].values() - for loss_key in session["loss_names"])) + for session in loss_keys.values() + for loss_key in session)) else: - retval = self._state["sessions"][str(session_id)]["loss_names"] + retval = loss_keys[session_id] return retval From 4c1631b58ef5b53c9d4e29465e87279906e75491 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 25 May 2021 13:11:35 +0100 Subject: [PATCH 481/981] lib.model - Add AdaBelief Optimizer --- docs/full/lib/model.rst | 19 ++ lib/model/__init__.py | 2 + lib/model/optimizers_plaid.py | 147 +++++++++++ lib/model/optimizers_tf.py | 387 +++++++++++++++++++++++++++++ plugins/train/_config.py | 22 +- plugins/train/model/_base.py | 15 +- tests/lib/model/optimizers_test.py | 11 +- 7 files changed, 586 insertions(+), 17 deletions(-) create mode 100644 lib/model/optimizers_plaid.py create mode 100644 lib/model/optimizers_tf.py diff --git a/docs/full/lib/model.rst b/docs/full/lib/model.rst index b4a19cd510..c253d9a8b3 100755 --- a/docs/full/lib/model.rst +++ b/docs/full/lib/model.rst @@ -112,6 +112,25 @@ model.normalization module :undoc-members: :show-inheritance: +model.optimizers module +----------------------- + +The optimizers listed here are generated from the docstrings in :mod:`lib.model.optimizers_tf`, however +the functions are excactly the same for :mod:`lib.model.optimizers_plaid`. The correct optimizers module will +be imported as :mod:`lib.model.optimizers` depending on the backend in use. + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.model.optimizers_tf.AdaBelief + +.. automodule:: lib.model.optimizers_tf + :members: + :undoc-members: + :show-inheritance: + model.session module --------------------- diff --git a/lib/model/__init__.py b/lib/model/__init__.py index 175e1343bb..9ac90e31cb 100644 --- a/lib/model/__init__.py +++ b/lib/model/__init__.py @@ -6,5 +6,7 @@ from .normalization import * if get_backend() == "amd": from . import losses_plaid as losses + from . import optimizers_plaid as optimizers else: from . import losses_tf as losses + from . import optimizers_tf as optimizers diff --git a/lib/model/optimizers_plaid.py b/lib/model/optimizers_plaid.py new file mode 100644 index 0000000000..5179e8df78 --- /dev/null +++ b/lib/model/optimizers_plaid.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +""" Custom Optimizers for PlaidML/Keras 2.2. """ + +from keras import backend as K +from keras.optimizers import Optimizer + + +class AdaBelief(Optimizer): + """AdaBelief optimizer. + + Default parameters follow those provided in the original paper. + + Parameters + ---------- + learning_rate: float + The learning rate. + beta_1: float + The exponential decay rate for the 1st moment estimates. + beta_2: float + The exponential decay rate for the 2nd moment estimates. + epsilon: float, optional + A small constant for numerical stability. Default: `K.epsilon()`. + amsgrad: bool + Whether to apply AMSGrad variant of this algorithm from the paper "On the Convergence + of Adam and beyond". + + References + ---------- + AdaBelief - A Method for Stochastic Optimization - https://arxiv.org/abs/1412.6980v8 + On the Convergence of AdaBelief and Beyond - https://openreview.net/forum?id=ryQu7f-RZ + + Adapted from https://github.com/liaoxuanzhi/adabelief + + BSD 2-Clause License + + Copyright (c) 2021, Juntang Zhuang + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + """ + + def __init__(self, lr=0.001, beta_1=0.9, beta_2=0.999, + epsilon=None, decay=0., weight_decay=0.0, **kwargs): + super().__init__(**kwargs) + with K.name_scope(self.__class__.__name__): + self.iterations = K.variable(0, dtype='int64', name='iterations') + self.lr = K.variable(lr, name='lr') + self.beta_1 = K.variable(beta_1, name='beta_1') + self.beta_2 = K.variable(beta_2, name='beta_2') + self.decay = K.variable(decay, name='decay') + if epsilon is None: + epsilon = K.epsilon() + self.epsilon = float(epsilon) + self.initial_decay = decay + self.weight_decay = float(weight_decay) + + def get_updates(self, loss, params): # pylint:disable=too-many-locals + """ Get the weight updates + + Parameters + ---------- + loss: list + The loss to update + parans: list + The variables + """ + grads = self.get_gradients(loss, params) + self.updates = [K.update_add(self.iterations, 1)] + + l_r = self.lr + if self.initial_decay > 0: + l_r = l_r * (1. / (1. + self.decay * K.cast(self.iterations, + K.dtype(self.decay)))) + + var_t = K.cast(self.iterations, K.floatx()) + 1 + # bias correction + bias_correction1 = 1. - K.pow(self.beta_1, var_t) + bias_correction2 = 1. - K.pow(self.beta_2, var_t) + + m_s = [K.zeros(K.int_shape(p), dtype=K.dtype(p)) for p in params] + v_s = [K.zeros(K.int_shape(p), dtype=K.dtype(p)) for p in params] + + self.weights = [self.iterations] + m_s + v_s + + for param, grad, var_m, var_v in zip(params, grads, m_s, v_s): + if self.weight_decay != 0.: + grad += self.weight_decay * K.stop_gradient(param) + + m_t = (self.beta_1 * var_m) + (1. - self.beta_1) * grad + m_corr_t = m_t / bias_correction1 + + v_t = (self.beta_2 * var_v) + (1. - self.beta_2) * K.square(grad - m_t) + self.epsilon + v_corr_t = K.sqrt(v_t / bias_correction2) + + p_t = param - l_r * m_corr_t / (v_corr_t + self.epsilon) + + self.updates.append(K.update(var_m, m_t)) + self.updates.append(K.update(var_v, v_t)) + new_param = p_t + + # Apply constraints. + if getattr(param, 'constraint', None) is not None: + new_param = param.constraint(new_param) + + self.updates.append(K.update(param, new_param)) + return self.updates + + def get_config(self): + """ Returns the config of the optimizer. + + An optimizer config is a Python dictionary (serializable) containing the configuration of + an optimizer. The same optimizer can be reinstantiated later (without any saved state) from + this configuration. + + Returns + ------- + dict + The optimizer configuration. + """ + config = dict(lr=float(K.get_value(self.lr)), + beta_1=float(K.get_value(self.beta_1)), + beta_2=float(K.get_value(self.beta_2)), + decay=float(K.get_value(self.decay)), + epsilon=self.epsilon, + weight_decay=self.weight_decay) + base_config = super().get_config() + return dict(list(base_config.items()) + list(config.items())) diff --git a/lib/model/optimizers_tf.py b/lib/model/optimizers_tf.py new file mode 100644 index 0000000000..74c289fdbb --- /dev/null +++ b/lib/model/optimizers_tf.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +""" Custom Optimizers for TensorFlow 2.x/tf.keras """ + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import tensorflow as tf + + +class AdaBelief(tf.keras.optimizers.Optimizer): + """ Implementation of the AdaBelief Optimizer + + Inherits from: tf.keras.optimizers.Optimizer. + + AdaBelief Optimizer is not a placement of the heuristic warmup, the settings should be kept if + warmup has already been employed and tuned in the baseline method. You can enable warmup by + setting `total_steps` and `warmup_proportion` (see examples) + + Lookahead (see references) can be integrated with AdaBelief Optimizer, which is announced by + Less Wright and the new combined optimizer can also be called "Ranger". The mechanism can be + enabled by using the lookahead wrapper. (See examples) + + Parameters + ---------- + learning_rate: `Tensor`, float or :class: `tf.keras.optimizers.schedules.LearningRateSchedule` + The learning rate. + beta_1: float + The exponential decay rate for the 1st moment estimates. + beta_2: float + The exponential decay rate for the 2nd moment estimates. + epsilon: float + A small constant for numerical stability. + weight_decay: `Tensor`, float or :class: `tf.keras.optimizers.schedules.LearningRateSchedule` + Weight decay for each parameter. + rectify: bool + Whether to enable rectification as in RectifiedAdam + amsgrad: bool + Whether to apply AMSGrad variant of this algorithm from the paper "On the Convergence + of Adam and beyond". + sma_threshold. float + The threshold for simple mean average. + total_steps: int + Total number of training steps. Enable warmup by setting a positive value. + warmup_proportion: float + The proportion of increasing steps. + min_lr: float + Minimum learning rate after warmup. + name: str, optional + Name for the operations created when applying gradients. Default: ``"AdaBeliefOptimizer"``. + **kwargs: dict + Standard Keras Optimizer keyword arguments. Allowed to be {`clipnorm`, `clipvalue`, `lr`, + `decay`}. `clipnorm` is clip gradients by norm; `clipvalue` is clip gradients by value, + `decay` is included for backward compatibility to allow time inverse decay of learning + rate. `lr` is included for backward compatibility, recommended to use `learning_rate` + instead. + + Examples + -------- + >>> from adabelief_tf import AdaBelief + >>> opt = AdaBelief(lr=1e-3) + + Example of serialization: + + >>> optimizer = AdaBelief(learning_rate=lr_scheduler, weight_decay=wd_scheduler) + >>> config = tf.keras.optimizers.serialize(optimizer) + >>> new_optimizer = tf.keras.optimizers.deserialize(config, + ... custom_objects=dict(AdaBelief=AdaBelief)) + + Example of warmup: + + >>> opt = AdaBelief(lr=1e-3, total_steps=10000, warmup_proportion=0.1, min_lr=1e-5) + + In the above example, the learning rate will increase linearly from 0 to `lr` in 1000 steps, + then decrease linearly from `lr` to `min_lr` in 9000 steps. + + Example of enabling Lookahead: + + >>> adabelief = AdaBelief() + >>> ranger = tfa.optimizers.Lookahead(adabelief, sync_period=6, slow_step_size=0.5) + + Notes + ----- + `amsgrad` is not described in the original paper. Use it with caution. + + References + ---------- + Juntang Zhuang et al. - AdaBelief Optimizer: Adapting stepsizes by the belief in observed + gradients - https://arxiv.org/abs/2010.07468. + + Original implementation - https://github.com/juntang-zhuang/Adabelief-Optimizer + + Michael R. Zhang et.al - Lookahead Optimizer: k steps forward, 1 step back - + https://arxiv.org/abs/1907.08610v1 + + Adapted from https://github.com/juntang-zhuang/Adabelief-Optimizer + + BSD 2-Clause License + + Copyright (c) 2021, Juntang Zhuang + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + """ + + def __init__(self, learning_rate=0.001, beta_1=0.9, beta_2=0.999, epsilon=1e-14, + weight_decay=0.0, rectify=True, amsgrad=False, sma_threshold=5.0, total_steps=0, + warmup_proportion=0.1, min_lr=0.0, name="AdaBeliefOptimizer", **kwargs): + super().__init__(name, **kwargs) + self._set_hyper("learning_rate", kwargs.get("lr", learning_rate)) + self._set_hyper("beta_1", beta_1) + self._set_hyper("beta_2", beta_2) + self._set_hyper("decay", self._initial_decay) + self._set_hyper("weight_decay", weight_decay) + self._set_hyper("sma_threshold", sma_threshold) + self._set_hyper("total_steps", int(total_steps)) + self._set_hyper("warmup_proportion", warmup_proportion) + self._set_hyper("min_lr", min_lr) + self.epsilon = epsilon or tf.keras.backend.epsilon() + self.amsgrad = amsgrad + self.rectify = rectify + self._has_weight_decay = weight_decay != 0.0 + self._initial_total_steps = total_steps + + def _create_slots(self, var_list): + """ Create slots for the first and second moments + + Parameters + ---------- + var_list: list + List of tf variables to create slots for + """ + for var in var_list: + self.add_slot(var, "m") + self.add_slot(var, "v") + if self.amsgrad: + self.add_slot(var, "vhat") + + def set_weights(self, weights): + """ Set the weights of the optimizer. + + The weights of an optimizer are its state (ie, variables). This function takes the weight + values associated with this optimizer as a list of Numpy arrays. The first value is always + the iterations count of the optimizer, followed by the optimizer's state variables in the + order they are created. The passed values are used to set the new state of the optimizer. + + Parameters + ---------- + weights: list + weight values as a list of numpy arrays. + """ + params = self.weights + num_vars = int((len(params) - 1) / 2) + if len(weights) == 3 * num_vars + 1: + weights = weights[: len(params)] + super().set_weights(weights) + + def _decayed_wd(self, var_dtype): + """ Set the weight decay + + Parameters + ---------- + var_dtype: str + The data type to to set up weight decau for + + Returns + ------- + Tensor + The weight decay variable + """ + wd_t = self._get_hyper("weight_decay", var_dtype) + if isinstance(wd_t, tf.keras.optimizers.schedules.LearningRateSchedule): + wd_t = tf.cast(wd_t(self.iterations), var_dtype) + return wd_t + + def _resource_apply_dense(self, grad, handle, apply_state=None): + # pylint:disable=too-many-locals + """ Add ops to apply dense gradients to the variable handle. + + Parameters + ---------- + grad: Tensor + A tensor representing the gradient. + handle: Tensor + a Tensor of dtype resource which points to the variable to be updated. + apply_state: dict + A dict which is used across multiple apply calls. + + Returns + ------- + An Operation which updates the value of the variable. + """ + var_dtype = handle.dtype.base_dtype + lr_t = self._decayed_lr(var_dtype) + wd_t = self._decayed_wd(var_dtype) + var_m = self.get_slot(handle, "m") + var_v = self.get_slot(handle, "v") + beta_1_t = self._get_hyper("beta_1", var_dtype) + beta_2_t = self._get_hyper("beta_2", var_dtype) + epsilon_t = tf.convert_to_tensor(self.epsilon, var_dtype) + local_step = tf.cast(self.iterations + 1, var_dtype) + beta_1_power = tf.math.pow(beta_1_t, local_step) + beta_2_power = tf.math.pow(beta_2_t, local_step) + + if self._initial_total_steps > 0: + total_steps = self._get_hyper("total_steps", var_dtype) + warmup_steps = total_steps * self._get_hyper("warmup_proportion", var_dtype) + min_lr = self._get_hyper("min_lr", var_dtype) + decay_steps = tf.maximum(total_steps - warmup_steps, 1) + decay_rate = (min_lr - lr_t) / decay_steps + lr_t = tf.where(local_step <= warmup_steps, + lr_t * (local_step / warmup_steps), + lr_t + decay_rate * tf.minimum(local_step - warmup_steps, decay_steps)) + + m_t = var_m.assign(beta_1_t * var_m + (1.0 - beta_1_t) * grad, + use_locking=self._use_locking) + m_corr_t = m_t / (1.0 - beta_1_power) + + v_t = var_v.assign( + beta_2_t * var_v + (1.0 - beta_2_t) * tf.math.square(grad - m_t) + epsilon_t, + use_locking=self._use_locking) + + if self.amsgrad: + vhat = self.get_slot(handle, "vhat") + vhat_t = vhat.assign(tf.maximum(vhat, v_t), use_locking=self._use_locking) + v_corr_t = tf.math.sqrt(vhat_t / (1.0 - beta_2_power)) + else: + vhat_t = None + v_corr_t = tf.math.sqrt(v_t / (1.0 - beta_2_power)) + + if self.rectify: + sma_inf = 2.0 / (1.0 - beta_2_t) - 1.0 + sma_t = sma_inf - 2.0 * local_step * beta_2_power / (1.0 - beta_2_power) + r_t = tf.math.sqrt((sma_t - 4.0) / (sma_inf - 4.0) * + (sma_t - 2.0) / (sma_inf - 2.0) * + sma_inf / sma_t) + sma_threshold = self._get_hyper("sma_threshold", var_dtype) + var_t = tf.where(sma_t >= sma_threshold, + r_t * m_corr_t / (v_corr_t + epsilon_t), + m_corr_t) + else: + var_t = m_corr_t / (v_corr_t + epsilon_t) + + if self._has_weight_decay: + var_t += wd_t * handle + + var_update = handle.assign_sub(lr_t * var_t, use_locking=self._use_locking) + updates = [var_update, m_t, v_t] + + if self.amsgrad: + updates.append(vhat_t) + return tf.group(*updates) + + def _resource_apply_sparse(self, grad, handle, indices, apply_state=None): + # pylint:disable=too-many-locals + """ Add ops to apply sparse gradients to the variable handle. + + Similar to _apply_sparse, the indices argument to this method has been de-duplicated. + Optimizers which deal correctly with non-unique indices may instead override + :func:`_resource_apply_sparse_duplicate_indices` to avoid this overhead. + + Parameters + ---------- + grad: Tensor + a Tensor representing the gradient for the affected indices. + handle: Tensor + a Tensor of dtype resource which points to the variable to be updated. + indices: Tensor + a Tensor of integral type representing the indices for which the gradient is nonzero. + Indices are unique. + apply_state: dict + A dict which is used across multiple apply calls. + + Returns + ------- + An Operation which updates the value of the variable. + """ + var_dtype = handle.dtype.base_dtype + lr_t = self._decayed_lr(var_dtype) + wd_t = self._decayed_wd(var_dtype) + beta_1_t = self._get_hyper("beta_1", var_dtype) + beta_2_t = self._get_hyper("beta_2", var_dtype) + epsilon_t = tf.convert_to_tensor(self.epsilon, var_dtype) + local_step = tf.cast(self.iterations + 1, var_dtype) + beta_1_power = tf.math.pow(beta_1_t, local_step) + beta_2_power = tf.math.pow(beta_2_t, local_step) + + if self._initial_total_steps > 0: + total_steps = self._get_hyper("total_steps", var_dtype) + warmup_steps = total_steps * self._get_hyper("warmup_proportion", var_dtype) + min_lr = self._get_hyper("min_lr", var_dtype) + decay_steps = tf.maximum(total_steps - warmup_steps, 1) + decay_rate = (min_lr - lr_t) / decay_steps + lr_t = tf.where(local_step <= warmup_steps, + lr_t * (local_step / warmup_steps), + lr_t + decay_rate * tf.minimum(local_step - warmup_steps, decay_steps)) + + var_m = self.get_slot(handle, "m") + m_scaled_g_values = grad * (1 - beta_1_t) + m_t = var_m.assign(var_m * beta_1_t, use_locking=self._use_locking) + m_t = self._resource_scatter_add(var_m, indices, m_scaled_g_values) + m_corr_t = m_t / (1.0 - beta_1_power) + + var_v = self.get_slot(handle, "v") + m_t_indices = tf.gather(m_t, indices) + v_scaled_g_values = tf.math.square(grad - m_t_indices) * (1 - beta_2_t) + v_t = var_v.assign(var_v * beta_2_t + epsilon_t, use_locking=self._use_locking) + v_t = self._resource_scatter_add(var_v, indices, v_scaled_g_values) + + if self.amsgrad: + vhat = self.get_slot(handle, "vhat") + vhat_t = vhat.assign(tf.maximum(vhat, v_t), use_locking=self._use_locking) + v_corr_t = tf.math.sqrt(vhat_t / (1.0 - beta_2_power)) + else: + vhat_t = None + v_corr_t = tf.math.sqrt(v_t / (1.0 - beta_2_power)) + + if self.rectify: + sma_inf = 2.0 / (1.0 - beta_2_t) - 1.0 + sma_t = sma_inf - 2.0 * local_step * beta_2_power / (1.0 - beta_2_power) + r_t = tf.math.sqrt((sma_t - 4.0) / (sma_inf - 4.0) * + (sma_t - 2.0) / (sma_inf - 2.0) * + sma_inf / sma_t) + sma_threshold = self._get_hyper("sma_threshold", var_dtype) + var_t = tf.where(sma_t >= sma_threshold, + r_t * m_corr_t / (v_corr_t + epsilon_t), + m_corr_t) + else: + var_t = m_corr_t / (v_corr_t + epsilon_t) + + if self._has_weight_decay: + var_t += wd_t * handle + + var_update = self._resource_scatter_add(handle, + indices, + tf.gather(tf.math.negative(lr_t) * var_t, indices)) + + updates = [var_update, m_t, v_t] + if self.amsgrad: + updates.append(vhat_t) + return tf.group(*updates) + + def get_config(self): + """ Returns the config of the optimizer. + + An optimizer config is a Python dictionary (serializable) containing the configuration of + an optimizer. The same optimizer can be reinstantiated later (without any saved state) from + this configuration. + + Returns + ------- + dict + The optimizer configuration. + """ + config = super().get_config() + config.update(dict(learning_rate=self._serialize_hyperparameter("learning_rate"), + beta_1=self._serialize_hyperparameter("beta_1"), + beta_2=self._serialize_hyperparameter("beta_2"), + decay=self._serialize_hyperparameter("decay"), + weight_decay=self._serialize_hyperparameter("weight_decay"), + sma_threshold=self._serialize_hyperparameter("sma_threshold"), + epsilon=self.epsilon, + amsgrad=self.amsgrad, + rectify=self.rectify, + total_steps=self._serialize_hyperparameter("total_steps"), + warmup_proportion=self._serialize_hyperparameter("warmup_proportion"), + min_lr=self._serialize_hyperparameter("min_lr"))) + return config diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 7f6dfc5b32..210a360130 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -108,8 +108,13 @@ def _set_globals(self): gui_radio=True, group="optimizer", default="adam", - choices=["adam", "nadam", "rms-prop"], + choices=["adabelief", "adam", "nadam", "rms-prop"], info="The optimizer to use." + "\n\t adabelief - Adapting Stepsizes by the Belief in Observed Gradients. An " + "optimizer with the aim to converge faster, generalize better and remain more " + "stable. (https://arxiv.org/abs/2010.07468). NB: Epsilon for AdaBelief needs to " + "be set to a smaller value than other Optimizers. Generally setting the 'Epsilon " + "Exponent' to around '-16' should work." "\n\t adam - Adaptive Moment Optimization. A stochastic gradient descent method " "that is based on adaptive estimation of first-order and second-order moments." "\n\t nadam - Adaptive Moment Optimization with Nesterov Momentum. Much like " @@ -136,16 +141,19 @@ def _set_globals(self): title="epsilon_exponent", datatype=int, default=-7, - min_max=(-10, 0), + min_max=(-20, 0), rounding=1, fixed=False, group="optimizer", info="The epsilon adds a small constant to weight updates to attempt to avoid 'divide " - "by zero' errors. Generally this option should be left at default value, however " - "if you are getting 'NaN' loss values, and have been unable to resolve the issue " - "any other way (for example, increasing batch size, or lowering learning rate, " - "then raising the epsilon can lead to a more stable model. It may, however, come " - "at the cost of slower training and a less accurate final result.\n" + "by zero' errors. Unless you are using the AdaBelief Optimizer, then Generally " + "this option should be left at default value, For AdaBelief, setting this to " + "around '-16' should work.\n" + "In all instances if you are getting 'NaN' loss values, and have been unable to " + "resolve the issue any other way (for example, increasing batch size, or " + "lowering learning rate), then raising the epsilon can lead to a more stable " + "model. It may, however, come at the cost of slower training and a less accurate " + "final result.\n" "NB: The value given here is the 'exponent' to the epsilon. For example, " "choosing '-7' will set the epsilon to 1e-7. Choosing '-3' will set the epsilon " "to 0.001 (1e-3).") diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 407d30cfc6..e4fe304f2c 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -24,7 +24,7 @@ from lib.serializer import get_serializer from lib.model.backup_restore import Backup -from lib.model import losses +from lib.model import losses, optimizers from lib.model.nn_blocks import set_config as set_nnblock_config from lib.utils import get_backend, FaceswapError from plugins.train._config import Config @@ -1093,13 +1093,12 @@ def __init__(self, optimizer, learning_rate, clipnorm, epsilon, arguments): logger.debug("Initializing %s: (optimizer: %s, learning_rate: %s, clipnorm: %s, " "epsilon: %s, arguments: %s)", self.__class__.__name__, optimizer, learning_rate, clipnorm, epsilon, arguments) - optimizers = {"adam": Adam, "nadam": Nadam, "rms-prop": RMSprop} - self._optimizer = optimizers[optimizer] - - base_kwargs = {"adam": dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon), - "nadam": dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon), - "rms-prop": dict(epsilon=epsilon)} - self._kwargs = base_kwargs[optimizer] + valid_optimizers = {"adabelief": (optimizers.AdaBelief, + dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), + "adam": (Adam, dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), + "nadam": (Nadam, dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), + "rms-prop": (RMSprop, dict(epsilon=epsilon))} + self._optimizer, self._kwargs = valid_optimizers[optimizer] self._configure(learning_rate, clipnorm, arguments) logger.verbose("Using %s optimizer", optimizer.title()) diff --git a/tests/lib/model/optimizers_test.py b/tests/lib/model/optimizers_test.py index 6a9642117a..1fc0679623 100644 --- a/tests/lib/model/optimizers_test.py +++ b/tests/lib/model/optimizers_test.py @@ -11,6 +11,7 @@ import numpy as np from numpy.testing import assert_allclose +from lib.model import optimizers from lib.utils import get_backend from tests.utils import generate_test_data, to_categorical @@ -74,5 +75,11 @@ def _test_optimizer(optimizer, target=0.75): @pytest.mark.parametrize("dummy", [None], ids=[get_backend().upper()]) def test_adam(dummy): # pylint:disable=unused-argument """ Test for custom Adam optimizer """ - _test_optimizer(k_optimizers.Adam(), target=0.6) - _test_optimizer(k_optimizers.Adam(decay=1e-3), target=0.6) + _test_optimizer(k_optimizers.Adam(), target=0.5) + _test_optimizer(k_optimizers.Adam(decay=1e-3), target=0.5) + + +@pytest.mark.parametrize("dummy", [None], ids=[get_backend().upper()]) +def test_adabelief(dummy): # pylint:disable=unused-argument + """ Test for custom Adam optimizer """ + _test_optimizer(optimizers.AdaBelief(), target=0.6) From 8c87ac52b45e03edeca5d1113774d7cb356a383c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 25 May 2021 13:12:18 +0000 Subject: [PATCH 482/981] Update Travis Tests for optimizers --- lib/model/optimizers_plaid.py | 15 ++++++++++++--- lib/model/optimizers_tf.py | 25 +++++++++++++++++-------- tests/lib/model/optimizers_test.py | 3 ++- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/lib/model/optimizers_plaid.py b/lib/model/optimizers_plaid.py index 5179e8df78..2fc52836ec 100644 --- a/lib/model/optimizers_plaid.py +++ b/lib/model/optimizers_plaid.py @@ -1,8 +1,11 @@ #!/usr/bin/env python3 """ Custom Optimizers for PlaidML/Keras 2.2. """ +import inspect +import sys from keras import backend as K from keras.optimizers import Optimizer +from keras.utils import get_custom_objects class AdaBelief(Optimizer): @@ -81,7 +84,7 @@ def get_updates(self, loss, params): # pylint:disable=too-many-locals ---------- loss: list The loss to update - parans: list + params: list The variables """ grads = self.get_gradients(loss, params) @@ -129,8 +132,8 @@ def get_config(self): """ Returns the config of the optimizer. An optimizer config is a Python dictionary (serializable) containing the configuration of - an optimizer. The same optimizer can be reinstantiated later (without any saved state) from - this configuration. + an optimizer. The same optimizer can be re-instantiated later (without any saved state) + from this configuration. Returns ------- @@ -145,3 +148,9 @@ def get_config(self): weight_decay=self.weight_decay) base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) + + +# Update layers into Keras custom objects +for name, obj in inspect.getmembers(sys.modules[__name__]): + if inspect.isclass(obj) and obj.__module__ == __name__: + get_custom_objects().update({name: obj}) diff --git a/lib/model/optimizers_tf.py b/lib/model/optimizers_tf.py index 74c289fdbb..2b1afdaedc 100644 --- a/lib/model/optimizers_tf.py +++ b/lib/model/optimizers_tf.py @@ -1,11 +1,14 @@ #!/usr/bin/env python3 """ Custom Optimizers for TensorFlow 2.x/tf.keras """ - from __future__ import absolute_import from __future__ import division from __future__ import print_function +import inspect +import sys + import tensorflow as tf +from keras.utils import get_custom_objects class AdaBelief(tf.keras.optimizers.Optimizer): @@ -67,7 +70,7 @@ class AdaBelief(tf.keras.optimizers.Optimizer): >>> new_optimizer = tf.keras.optimizers.deserialize(config, ... custom_objects=dict(AdaBelief=AdaBelief)) - Example of warmup: + Example of warm up: >>> opt = AdaBelief(lr=1e-3, total_steps=10000, warmup_proportion=0.1, min_lr=1e-5) @@ -147,7 +150,7 @@ def _create_slots(self, var_list): Parameters ---------- var_list: list - List of tf variables to create slots for + List of tensorflow variables to create slots for """ for var in var_list: self.add_slot(var, "m") @@ -158,9 +161,9 @@ def _create_slots(self, var_list): def set_weights(self, weights): """ Set the weights of the optimizer. - The weights of an optimizer are its state (ie, variables). This function takes the weight + The weights of an optimizer are its state (IE, variables). This function takes the weight values associated with this optimizer as a list of Numpy arrays. The first value is always - the iterations count of the optimizer, followed by the optimizer's state variables in the + the iterations count of the optimizer, followed by the optimizers state variables in the order they are created. The passed values are used to set the new state of the optimizer. Parameters @@ -180,7 +183,7 @@ def _decayed_wd(self, var_dtype): Parameters ---------- var_dtype: str - The data type to to set up weight decau for + The data type to to set up weight decay for Returns ------- @@ -363,8 +366,8 @@ def get_config(self): """ Returns the config of the optimizer. An optimizer config is a Python dictionary (serializable) containing the configuration of - an optimizer. The same optimizer can be reinstantiated later (without any saved state) from - this configuration. + an optimizer. The same optimizer can be re-instantiated later (without any saved state) + from this configuration. Returns ------- @@ -385,3 +388,9 @@ def get_config(self): warmup_proportion=self._serialize_hyperparameter("warmup_proportion"), min_lr=self._serialize_hyperparameter("min_lr"))) return config + + +# Update layers into Keras custom objects +for name, obj in inspect.getmembers(sys.modules[__name__]): + if inspect.isclass(obj) and obj.__module__ == __name__: + get_custom_objects().update({name: obj}) diff --git a/tests/lib/model/optimizers_test.py b/tests/lib/model/optimizers_test.py index 1fc0679623..92875af570 100644 --- a/tests/lib/model/optimizers_test.py +++ b/tests/lib/model/optimizers_test.py @@ -47,6 +47,7 @@ def _test_optimizer(optimizer, target=0.75): config = k_optimizers.serialize(optimizer) optim = k_optimizers.deserialize(config) new_config = k_optimizers.serialize(optim) + config["class_name"] = config["class_name"].lower() new_config["class_name"] = new_config["class_name"].lower() assert config == new_config @@ -82,4 +83,4 @@ def test_adam(dummy): # pylint:disable=unused-argument @pytest.mark.parametrize("dummy", [None], ids=[get_backend().upper()]) def test_adabelief(dummy): # pylint:disable=unused-argument """ Test for custom Adam optimizer """ - _test_optimizer(optimizers.AdaBelief(), target=0.6) + _test_optimizer(optimizers.AdaBelief(), target=0.5) From 486375ec991355547656111bf5eeaa1c233f57d0 Mon Sep 17 00:00:00 2001 From: AnDenixa Date: Thu, 27 May 2021 02:26:11 +0300 Subject: [PATCH 483/981] - added push t to disable mask display at preview window trsprmvd - added extra checks for mask presence in the set --- plugins/train/trainer/_base.py | 9 +++++++++ scripts/train.py | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 47fec505ca..bcafe69e20 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -144,6 +144,10 @@ def _set_tensorboard(self): logger.verbose("Enabled TensorBoard Logging") return tensorboard + def toggle_mask(self): + logger.info("Flipping mask display") + self._samples.toggle_mask_display() + def train_one_step(self, viewer, timelapse_kwargs): """ Running training on a batch of images for each side. @@ -607,6 +611,11 @@ def __init__(self, model, coverage_ratio, scaling=1.0): self._scaling = scaling logger.debug("Initialized %s", self.__class__.__name__) + def toggle_mask_display(self): + if not (self._model.config["learn_mask"] or self._model.config["penalized_mask_loss"]): + return + self._display_mask = not self._display_mask + def show_sample(self): """ Compile a preview image. diff --git a/scripts/train.py b/scripts/train.py index 722f7869a4..6e3bc92af1 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -47,6 +47,7 @@ def __init__(self, arguments): "lib", "gui", ".cache", ".preview_trigger") self._stop = False self._save_now = False + self._toggle_preview_mask = False self._refresh_preview = False self._preview_buffer = dict() self._lock = Lock() @@ -321,6 +322,10 @@ def _run_training_cycle(self, model, trainer): logger.debug("Stop received. Terminating") break + if self._toggle_preview_mask: + trainer.toggle_mask() + self._toggle_preview_mask = False + if self._refresh_preview and viewer is not None: if self._args.redirect_gui: print("\n") @@ -395,6 +400,11 @@ def _monitor(self, thread): print("\n") logger.info("Refresh preview requested") self._refresh_preview = True + if is_preview and cv_key == ord("t"): + print("\n") + logger.info("Toggle mask display requested") + self._toggle_preview_mask = True + self._refresh_preview = True # Console Monitor if keypress.kbhit(): From 18e31392753433b6384efb2c5d2aa791360bfc8b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 28 May 2021 19:41:11 +0100 Subject: [PATCH 484/981] Updates - Change shortcut key to M - Slight optimizations - GUI support for mask toggling --- lib/gui/.cache/icons/mask2.png | Bin 0 -> 5783 bytes lib/gui/display_command.py | 19 ++++++++++-- lib/gui/utils.py | 53 +++++++++++++++++++++++---------- lib/gui/wrapper.py | 2 +- plugins/train/trainer/_base.py | 11 +++++-- scripts/gui.py | 4 +-- scripts/train.py | 35 +++++++++++++--------- 7 files changed, 85 insertions(+), 39 deletions(-) create mode 100644 lib/gui/.cache/icons/mask2.png diff --git a/lib/gui/.cache/icons/mask2.png b/lib/gui/.cache/icons/mask2.png new file mode 100644 index 0000000000000000000000000000000000000000..ca6440b66e1ad68a1778bdf9b371a6543844b923 GIT binary patch literal 5783 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 Var;3U7~B8=002ovPDHLkV1g&O=o

Date: Sun, 30 May 2021 11:45:03 +0100 Subject: [PATCH 485/981] Bugfixes - extract - debug landmarks - fix non integer errror - training (gui) - fix mask toggling --- scripts/fsmedia.py | 2 +- scripts/train.py | 96 +++++++++++++++++++++++++++++----------------- 2 files changed, 61 insertions(+), 37 deletions(-) diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index dbae1c4bb5..bf9b3bfbf5 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -502,7 +502,7 @@ def process(self, extract_media): for idx, face in enumerate(extract_media.detected_faces): logger.trace("Drawing Landmarks. Frame: '%s'. Face: %s", frame, idx) # Landmarks - for (pos_x, pos_y) in face.aligned.landmarks: + for (pos_x, pos_y) in face.aligned.landmarks.astype("int32"): cv2.circle(face.aligned.face, (pos_x, pos_y), 1, (0, 255, 255), -1) # Pose center = tuple(np.int32((face.aligned.size / 2, face.aligned.size / 2))) diff --git a/scripts/train.py b/scripts/train.py index 7a6c04a05d..226befcdf5 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -333,11 +333,6 @@ def _run_training_cycle(self, model, trainer): if self._args.redirect_gui: # Remove any gui trigger files following an update print("\n") logger.info("[Preview Updated]") - for filename in self._gui_triggers.values(): - if os.path.isfile(filename): - logger.debug("Removing gui trigger file: %s", filename) - os.remove(filename) - self._refresh_preview = False if save_iteration: @@ -360,12 +355,10 @@ def _monitor(self, thread): bool ``True`` if there has been an error in the background thread otherwise ``False`` """ - is_preview = self._args.preview - preview_trigger_set = False logger.debug("Launching Monitor") logger.info("===================================================") logger.info(" Starting") - if is_preview: + if self._args.preview: logger.info(" Using live preview") logger.info(" Press '%s' to save and quit", "Stop" if self._args.redirect_gui or self._args.colab else "ENTER") @@ -377,7 +370,7 @@ def _monitor(self, thread): err = False while True: try: - if is_preview: + if self._args.preview: with self._lock: for name, image in self._preview_buffer.items(): cv2.imshow(name, image) # pylint: disable=no-member @@ -394,21 +387,8 @@ def _monitor(self, thread): break # Preview Monitor - if is_preview and (cv_key == ord("\n") or cv_key == ord("\r")): - logger.debug("Exit requested") + if not self._preview_monitor(cv_key): break - if is_preview and cv_key == ord("s"): - print("\n") - logger.info("Save requested") - self._save_now = True - if is_preview and cv_key == ord("r"): - print("\n") - logger.info("Refresh preview requested") - self._refresh_preview = True - if is_preview and cv_key == ord("m"): - print("\n") - logger.verbose("Toggle mask display requested") - self._toggle_preview_mask = True # Console Monitor if keypress.kbhit(): @@ -421,19 +401,7 @@ def _monitor(self, thread): self._save_now = True # GUI Preview trigger update monitor - if self._args.redirect_gui: - if os.path.isfile(self._gui_triggers["mask_toggle"]): - self._toggle_preview_mask = True - - if not preview_trigger_set and os.path.isfile(self._gui_triggers["update"]): - print("\n") - logger.info("Refresh preview requested") - self._refresh_preview = True - preview_trigger_set = True - - if preview_trigger_set and not self._refresh_preview: - logger.debug("Resetting GUI preview trigger") - preview_trigger_set = False + self._process_gui_triggers() sleep(1) except KeyboardInterrupt: @@ -443,6 +411,62 @@ def _monitor(self, thread): logger.debug("Closed Monitor") return err + def _preview_monitor(self, key_press): + """ Monitors keyboard presses on the pop-up OpenCV Preview Window. + + Parameters + ---------- + key_press: str + The key press received from OpenCV or ``None`` if no press received + + Returns + ------- + bool + ``True`` if the process should continue training. ``False`` if an exit has been + requested and process should terminate + """ + if not self._args.preview: + return True + + if key_press == ord("\n") or key_press == ord("\r"): + logger.debug("Exit requested") + return False + + if key_press == ord("s"): + print("\n") + logger.info("Save requested") + self._save_now = True + if key_press == ord("r"): + print("\n") + logger.info("Refresh preview requested") + self._refresh_preview = True + if key_press == ord("m"): + print("\n") + logger.verbose("Toggle mask display requested") + self._toggle_preview_mask = True + + return True + + def _process_gui_triggers(self): + """ Check whether a file drop has occurred from the GUI to manually update the preview. """ + if not self._args.redirect_gui: + return + + parent_flags = dict(mask_toggle="_toggle_preview_mask", update="_refresh_preview") + for trigger in ("mask_toggle", "update"): + filename = self._gui_triggers[trigger] + if os.path.isfile(filename): + logger.debug("GUI Trigger received for: '%s'", trigger) + + logger.debug("Removing gui trigger file: %s", filename) + os.remove(filename) + + if trigger == "update": + print("\n") + logger.info("Refresh preview requested") + + setattr(self, parent_flags[trigger], True) + def _show(self, image, name=""): """ Generate the preview and write preview file output. From a26bc5089103ab79aa93dac4d906ad459f1b42f8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 31 May 2021 18:58:34 +0100 Subject: [PATCH 486/981] Manual Tool - Update to support masks with different centering --- lib/align/detected_face.py | 19 +++++++++---- tools/manual/faceviewer/viewport.py | 34 ++++++++++++++++++++---- tools/manual/frameviewer/control.py | 9 ++++--- tools/manual/frameviewer/editor/_base.py | 1 + tools/manual/frameviewer/editor/mask.py | 6 +++-- tools/manual/frameviewer/frame.py | 4 +++ 6 files changed, 58 insertions(+), 15 deletions(-) diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index f6ce1b962e..d82155f161 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -472,10 +472,9 @@ def __init__(self, storage_size=128, storage_centering="face"): @property def mask(self): - """ numpy.ndarray: The mask at the size of :attr:`stored_size` with any requested blurring - and threshold amount applied.""" - dims = (self.stored_size, self.stored_size, 1) - mask = np.frombuffer(decompress(self._mask), dtype="uint8").reshape(dims) + """ numpy.ndarray: The mask at the size of :attr:`stored_size` with any requested blurring, + threshold amount and centering applied.""" + mask = self.stored_mask if self._threshold != 0.0 or self._blur["kernel"] != 0: mask = mask.copy() if self._threshold != 0.0: @@ -494,6 +493,15 @@ def mask(self): logger.trace("mask shape: %s", mask.shape) return mask + @property + def stored_mask(self): + """ :class:`numpy.ndarray`: The mask at the size of :attr:`stored_size` as it is stored + (i.e. with no blurring/centering applied). """ + dims = (self.stored_size, self.stored_size, 1) + mask = np.frombuffer(decompress(self._mask), dtype="uint8").reshape(dims) + logger.trace("stored mask shape: %s", mask.shape) + return mask + @property def original_roi(self): """ :class: `numpy.ndarray`: The original region of interest of the mask in the @@ -888,7 +896,8 @@ def update_legacy_png_header(filename, alignments): The image file to update alignments: :class:`lib.align.alignments.Alignments` The alignments data the contains the information to store in the image header. This must be - a v2.0 or less alignments file as later versions no longer store the face hash (unrequired) + a v2.0 or less alignments file as later versions no longer store the face hash (not + required) Returns ------- diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index 6977624e27..e85575daaa 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -96,17 +96,42 @@ def toggle_mask(self, state, mask_type): The type of mask to overlay onto the face """ logger.debug("Toggling mask annotations to: %s. mask_type: %s", state, mask_type) - for (frame_idx, face_idx), det_faces in zip( + for (frame_idx, face_idx), det_face in zip( self._objects.visible_grid[:2].transpose(1, 2, 0).reshape(-1, 2), self._objects.visible_faces.flatten()): if frame_idx == -1: continue + key = "_".join([str(frame_idx), str(face_idx)]) - mask = None if state == "hidden" else det_faces.mask.get(mask_type, None) - mask = mask if mask is None else mask.mask.squeeze() + mask = None if state == "hidden" else self._obtain_mask(det_face, mask_type) self._tk_faces[key].update_mask(mask) self.update() + @classmethod + def _obtain_mask(cls, detected_face, mask_type): + """ Obtain the mask for the correct "face" centering that is used in the thumbnail display. + + Parameters + ----------- + detected_face: :class:`lib.align.DetectedFace` + The Detected Face object to obtain the mask for + mask_type: str + The type of mask to obtain + + Returns + ------- + :class:`numpy.ndarray` or ``None`` + The single channel mask of requested mask type, if it exists, otherwise ``None`` + """ + mask = detected_face.mask.get(mask_type) + if not mask: + return None + if mask.stored_centering != "face": + face = AlignedFace(detected_face.landmarks_xy) + mask.set_sub_crop(face.pose.offset["face"] - face.pose.offset[mask.stored_centering], + centering="face") + return mask.mask.squeeze() + def reset(self): """ Reset all the cached objects on a face size change. """ self._landmarks = dict() @@ -245,8 +270,7 @@ def _get_tk_face_object(self, face, image, is_active): """ get_mask = (self._canvas.optional_annotations["mask"] or (is_active and self.selected_editor == "mask")) - mask = face.mask.get(self._canvas.selected_mask, None) if get_mask else None - mask = mask if mask is None else mask.mask.squeeze() + mask = self._obtain_mask(face, self._canvas.selected_mask) if get_mask else None tk_face = TKFace(image, size=self.face_size, mask=mask) logger.trace("face: %s, tk_face: %s", face, tk_face) return tk_face diff --git a/tools/manual/frameviewer/control.py b/tools/manual/frameviewer/control.py index cac4267d5b..d343f6481a 100644 --- a/tools/manual/frameviewer/control.py +++ b/tools/manual/frameviewer/control.py @@ -110,7 +110,7 @@ def decrement_frame(self): def _get_safe_frame_index(self): """ Obtain the current frame position from the tk_transport_index variable in - a safe manner (i.e. handle for non-numerics) + a safe manner (i.e. handle for non-numeric) Returns ------- @@ -184,6 +184,7 @@ def __init__(self, canvas): image=self._tk_frame, anchor=tk.CENTER, tags="main_image") + self._zoomed_centering = "face" @property def _current_view_mode(self): @@ -212,8 +213,10 @@ def _switch_image(self, view_mode): view_mode: ["frame", "face"] The currently active editor's selected view mode. """ - if view_mode == self._current_view_mode: + if view_mode == self._current_view_mode and ( + self._canvas.active_editor.zoomed_centering == self._zoomed_centering): return + self._zoomed_centering = self._canvas.active_editor.zoomed_centering logger.trace("Switching background image from '%s' to '%s'", self._current_view_mode, view_mode) img = getattr(self, "_tk_{}".format(view_mode)) @@ -257,7 +260,7 @@ def _get_zoomed_face(self): det_face = self._det_faces.current_faces[frame_idx][face_idx] face = AlignedFace(det_face.landmarks_xy, image=self._globals.current_frame["image"], - centering="face", + centering=self._zoomed_centering, size=size).face logger.trace("face shape: %s", face.shape) return face[..., 2::-1] diff --git a/tools/manual/frameviewer/editor/_base.py b/tools/manual/frameviewer/editor/_base.py index 143ca971c7..46c19b5bd2 100644 --- a/tools/manual/frameviewer/editor/_base.py +++ b/tools/manual/frameviewer/editor/_base.py @@ -36,6 +36,7 @@ class Editor(): def __init__(self, canvas, detected_faces, control_text="", key_bindings=None): logger.debug("Initializing %s: (canvas: '%s', detected_faces: %s, control_text: %s)", self.__class__.__name__, canvas, detected_faces, control_text) + self.zoomed_centering = "face" # Override for different zoomed centering per editor self._canvas = canvas self._globals = canvas._globals self._det_faces = detected_faces diff --git a/tools/manual/frameviewer/editor/mask.py b/tools/manual/frameviewer/editor/mask.py index ee1e4f3e96..edec6a8d02 100644 --- a/tools/manual/frameviewer/editor/mask.py +++ b/tools/manual/frameviewer/editor/mask.py @@ -170,12 +170,14 @@ def _set_face_meta_data(self, mask, face_index): return logger.debug("Defining meta information for face: %s", face_index) - scale = self._internal_size / mask.mask.shape[0] + scale = self._internal_size / mask.stored_size self._set_full_frame_meta(mask, scale) dims = (self._internal_size, self._internal_size) - self._meta.setdefault("mask", []).append(cv2.resize(mask.mask, + self._meta.setdefault("mask", []).append(cv2.resize(mask.stored_mask, dims, interpolation=cv2.INTER_CUBIC)) + if self.zoomed_centering != mask.stored_centering: + self.zoomed_centering = mask.stored_centering def _set_full_frame_meta(self, mask, mask_scale): """ Sets the meta information for displaying the mask in full frame mode. diff --git a/tools/manual/frameviewer/frame.py b/tools/manual/frameviewer/frame.py index 4fc028ca2d..0cd7588df3 100644 --- a/tools/manual/frameviewer/frame.py +++ b/tools/manual/frameviewer/frame.py @@ -694,12 +694,16 @@ def _update_display(self, *args): # pylint:disable=unused-argument """ if not self._globals.tk_update.get(): return + zoomed_centering = self.active_editor.zoomed_centering self._image.refresh(self.active_editor.view_mode) to_display = sorted([self.selected_action] + self.editor_display[self.selected_action]) self._hide_additional_faces() for editor in to_display: self._editors[editor].update_annotation() self._bind_unbind_keys() + if zoomed_centering != self.active_editor.zoomed_centering: + # Refresh the image if editor annotation has changed the zoom centering of the image + self._image.refresh(self.active_editor.view_mode) self._globals.tk_update.set(False) self.update_idletasks() From 6321ca64e43daede733c7255751439a3c6c4f0ad Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 1 Jun 2021 12:30:31 +0100 Subject: [PATCH 487/981] Bug fix - Manual Tool - Fix a first time indexing bug when EEN > 1 --- tools/manual/faceviewer/frame.py | 3 ++- tools/manual/faceviewer/viewport.py | 9 ++++++++- tools/manual/frameviewer/control.py | 2 +- tools/manual/manual.py | 20 ++++++++++++++------ 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/tools/manual/faceviewer/frame.py b/tools/manual/faceviewer/frame.py index 4439cb763a..5f6545fe34 100644 --- a/tools/manual/faceviewer/frame.py +++ b/tools/manual/faceviewer/frame.py @@ -650,7 +650,8 @@ def _get_labels(self): labels = np.array((self._raw_indices["frame"] + padding, self._raw_indices["face"] + padding), dtype="int").reshape((2, rows, columns)) - logger.debug(labels.shape) + logger.debug("face-count: %s, columns: %s, rows: %s, remainder: %s, padding: %s, labels " + "shape: %s", face_count, columns, rows, remainder, padding, labels.shape) return labels def _get_display_faces(self): diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index e85575daaa..4a3f163692 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -658,8 +658,11 @@ def on_hover(self, event): coords = (int(self._canvas.canvasx(pnts[0])), int(self._canvas.canvasy(pnts[1]))) face = self._viewport.face_from_point(*coords) frame_idx, face_idx = face[:2] - is_zoomed = self._globals.is_zoomed + if frame_idx == self._current_frame_index and face_idx == self._current_face_index: + return + + is_zoomed = self._globals.is_zoomed if (-1 in face or (frame_idx == self._globals.frame_index and (not is_zoomed or (is_zoomed and face_idx == self._globals.tk_face_index.get())))): @@ -669,6 +672,8 @@ def on_hover(self, event): self._current_face_index = None return + logger.debug("Viewport hover: frame_idx: %s, face_idx: %s", frame_idx, face_idx) + self._canvas.config(cursor="hand2") self._highlight(face[2:]) self._current_frame_index = frame_idx @@ -698,6 +703,8 @@ def _select_frame(self): """ frame_id = self._current_frame_index is_zoomed = self._globals.is_zoomed + logger.debug("Face clicked. Global frame index: %s, Current frame_id: %s, is_zoomed: %s", + self._globals.frame_index, frame_id, is_zoomed) if frame_id is None or (frame_id == self._globals.frame_index and not is_zoomed): return face_idx = self._current_face_index if is_zoomed else 0 diff --git a/tools/manual/frameviewer/control.py b/tools/manual/frameviewer/control.py index d343f6481a..670be538b1 100644 --- a/tools/manual/frameviewer/control.py +++ b/tools/manual/frameviewer/control.py @@ -202,8 +202,8 @@ def refresh(self, view_mode): The currently active editor's selected view mode. """ self._switch_image(view_mode) - getattr(self, "_update_tk_{}".format(self._current_view_mode))() logger.trace("Updating background frame") + getattr(self, "_update_tk_{}".format(self._current_view_mode))() def _switch_image(self, view_mode): """ Switch the image between the full frame image and the zoomed face image. diff --git a/tools/manual/manual.py b/tools/manual/manual.py index c7e43d9f56..de046f51a8 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -53,11 +53,18 @@ def __init__(self, arguments): extractor) video_meta_data = self._detected_faces.video_meta_data + valid_meta = all(val is not None for val in video_meta_data.values()) + loader = FrameLoader(self._globals, arguments.frames, video_meta_data) + if valid_meta: # Load the faces whilst other threads complete if we have valid meta data + self._detected_faces.load_faces() - self._detected_faces.load_faces() self._containers = self._create_containers() - self._wait_for_threads(extractor, loader, video_meta_data) + self._wait_for_threads(extractor, loader, valid_meta) + if not valid_meta: + # Load the faces after other threads complete if meta data required updating + self._detected_faces.load_faces() + self._generate_thumbs(arguments.frames, arguments.thumb_regen, arguments.single_process) self._display = DisplayFrame(self._containers["top"], @@ -99,7 +106,7 @@ def _validate_non_faces(cls, frames_folder): sys.exit(1) logger.debug("Test input file '%s' does not contain Faceswap header data", test_file) - def _wait_for_threads(self, extractor, loader, video_meta_data): + def _wait_for_threads(self, extractor, loader, valid_meta): """ The :class:`Aligner` and :class:`FramesLoader` are launched in background threads. Wait for them to be initialized prior to proceeding. @@ -109,8 +116,9 @@ def _wait_for_threads(self, extractor, loader, video_meta_data): The extraction pipeline for the Manual Tool loader: :class:`FramesLoader` The frames loader for the Manual Tool - video_meta_data: dict - The video meta data that exists within the alignments file + valid_meta: bool + Whether the input video had valid meta-data on import, or if it had to be created. + ``True`` if valid meta data existed previously, ``False`` if it needed to be created Notes ----- @@ -129,7 +137,7 @@ def _wait_for_threads(self, extractor, loader, video_meta_data): sleep(1) extractor.link_faces(self._detected_faces) - if any(val is None for val in video_meta_data.values()): + if not valid_meta: logger.debug("Saving video meta data to alignments file") self._detected_faces.save_video_meta_data(**loader.video_meta_data) From 5010466f2eebc79a56da9403a1ef365f3c258a3b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 2 Jun 2021 13:25:28 +0100 Subject: [PATCH 488/981] bugfix: Manual tool. Fix misaligned landmarks in face viewer after adding/removing a face --- tools/manual/faceviewer/viewport.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index 4a3f163692..c8432dfe84 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -190,8 +190,10 @@ def _update_viewport(self, refresh_annotations): tk_face = self.get_tk_face(frame_idx, face_idx, face) self._canvas.itemconfig(image_id, image=tk_face.photo) + if (self._canvas.optional_annotations["mesh"] - or frame_idx == self._active_frame.frame_index): + or frame_idx == self._active_frame.frame_index + or refresh_annotations): landmarks = self.get_landmarks(frame_idx, face_idx, face, top_left, refresh=refresh_annotations) self._locate_mesh(mesh_ids, landmarks) From fbdd91ef0ef3c45a3d2f9f611c23886a6a08827e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 3 Jun 2021 11:19:23 +0100 Subject: [PATCH 489/981] Bugfix - Manual Tool - Correctly update mesh annotations in frame viewer when there are multiple faces in frame --- tools/manual/faceviewer/viewport.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index c8432dfe84..c2a9e0a9d7 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -177,11 +177,11 @@ def _update_viewport(self, refresh_annotations): self._objects.visible_faces): for (frame_idx, face_idx, pnt_x, pnt_y), image_id, mesh_ids, face in zip(*collection): top_left = np.array((pnt_x, pnt_y)) - if frame_idx == self._active_frame.frame_index: + if frame_idx == self._active_frame.frame_index and not refresh_annotations: logger.trace("Skipping active frame: %s", frame_idx) continue if frame_idx == -1: - logger.debug("Blanking non-existant face") + logger.trace("Blanking non-existant face") self._canvas.itemconfig(image_id, image="") for area in mesh_ids.values(): for mesh_id in area: From b4087cc06cd5f57726ce947f0f0e85addc59513a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 6 Jun 2021 12:17:39 +0100 Subject: [PATCH 490/981] Manual Tool - Bugfixes - Fix extract box rotation - Fix thumbnail view size - Fix issue of deleted landmark meshes still displaying --- tools/manual/detected_faces.py | 2 +- tools/manual/faceviewer/frame.py | 3 ++- tools/manual/faceviewer/viewport.py | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index 539f865388..5bdc26e5bf 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -709,7 +709,7 @@ def landmarks_rotate(self, frame_index, face_index, angle, center): The center point of the Landmark's Extract Box """ face = self._faces_at_frame_index(frame_index)[face_index] - rot_mat = cv2.getRotationMatrix2D(tuple(center), angle, 1.) + rot_mat = cv2.getRotationMatrix2D(tuple(center.astype("float32")), angle, 1.) face.landmarks_xy = cv2.transform(np.expand_dims(face.landmarks_xy, axis=0), rot_mat).squeeze() face.mask = self._extractor.get_masks(frame_index, face_index) diff --git a/tools/manual/faceviewer/frame.py b/tools/manual/faceviewer/frame.py index 5f6545fe34..c2a6438359 100644 --- a/tools/manual/faceviewer/frame.py +++ b/tools/manual/faceviewer/frame.py @@ -269,7 +269,8 @@ def face_size(self): """ int: The currently selected thumbnail size in pixels """ scaling = get_config().scaling_factor size = self._sizes[self._globals.tk_faces_size.get().lower().replace(" ", "")] - return int(round(size * scaling)) + scaled = size * scaling + return int(round(scaled / 2) * 2) @property def viewport(self): diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index c2a9e0a9d7..b81d5d6a1e 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -168,9 +168,6 @@ def _update_viewport(self, refresh_annotations): return self._discard_tk_faces() - if self._canvas.optional_annotations["mesh"]: # Display any hidden end of row meshes - self._canvas.itemconfig("viewport_mesh", state="normal") - for collection in zip(self._objects.visible_grid.transpose(1, 2, 0), self._objects.images, self._objects.meshes, @@ -442,6 +439,9 @@ def _top_left(self): def update(self): """ Load and unload thumbnails in the visible area of the faces viewer. """ + if self._canvas.optional_annotations["mesh"]: # Display any hidden end of row meshes + self._canvas.itemconfig("viewport_mesh", state="normal") + self._visible_grid, self._visible_faces = self._grid.visible_area if (isinstance(self._images, np.ndarray) and isinstance(self._visible_grid, np.ndarray) and self._visible_grid.shape[-1] != self._images.shape[-1]): From cfb856101e666b77477ebbd2b5efb8416b048781 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 8 Jun 2021 13:51:24 +0100 Subject: [PATCH 491/981] Bugfix - Manual Tool. Prevent Frame Navigation from showing non integer frame numbers --- tools/manual/frameviewer/frame.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/manual/frameviewer/frame.py b/tools/manual/frameviewer/frame.py index 0cd7588df3..b16ce42eaf 100644 --- a/tools/manual/frameviewer/frame.py +++ b/tools/manual/frameviewer/frame.py @@ -135,7 +135,6 @@ def _filter_modes(self): def _add_nav(self): """ Add the slider to navigate through frames """ - self._globals.tk_transport_index.trace("w", self._set_frame_index) max_frame = self._globals.frame_count - 1 frame = ttk.Frame(self._transport_frame) @@ -163,6 +162,7 @@ def _add_nav(self): to=max_frame, command=cmd) nav.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + self._globals.tk_transport_index.trace("w", self._set_frame_index) return dict(entry=tbox, scale=nav, label=lbl) def _set_frame_index(self, *args): # pylint:disable=unused-argument From eb96da03463fe80514b54c1f7483af9509db9e78 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 8 Jun 2021 19:30:28 +0100 Subject: [PATCH 492/981] Add Misalignment Detection - lib.align.AlignedFace - Add average_distance property (distance from mean_face) - tools.manual - Add misaligned Faces filter - tools.sort - Add sort by distance (misaligned sort)Add "Misaligned Faces" filter to manual tool --- lib/align/aligned_face.py | 26 ++++++ locales/es/LC_MESSAGES/tools.manual.mo | Bin 7912 -> 8168 bytes locales/es/LC_MESSAGES/tools.manual.po | 98 ++++++++++++----------- locales/es/LC_MESSAGES/tools.sort.cli.mo | Bin 9017 -> 9438 bytes locales/es/LC_MESSAGES/tools.sort.cli.po | 35 ++++---- locales/tools.manual.pot | 92 +++++++++++---------- locales/tools.sort.cli.pot | 26 +++--- tools/manual/detected_faces.py | 80 ++++++++++++------ tools/manual/faceviewer/viewport.py | 3 + tools/manual/frameviewer/control.py | 7 +- tools/manual/frameviewer/frame.py | 89 ++++++++++++++++++-- tools/manual/manual.py | 10 ++- tools/sort/cli.py | 8 +- tools/sort/sort.py | 83 ++++++++++++------- 14 files changed, 378 insertions(+), 179 deletions(-) diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index e8da3dce54..96dc6c78fd 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -261,6 +261,18 @@ def landmarks(self): self._cache["landmarks"][0] = lms return self._cache["landmarks"][0] + @property + def normalized_landmarks(self): + """ :class:`numpy.ndarray`: The 68 point facial landmarks normalized to 0.0 - 1.0 as + aligned by Umeyama. """ + with self._cache["landmarks_normalized"][1]: + if self._cache["landmarks_normalized"][0] is None: + lms = np.expand_dims(self._frame_landmarks, axis=1) + lms = cv2.transform(lms, self._matrices["legacy"], lms.shape).squeeze() + logger.trace("normalized landmarks: %s", lms) + self._cache["landmarks_normalized"][0] = lms + return self._cache["landmarks_normalized"][0] + @property def interpolators(self): """ tuple: (`interpolator` and `reverse interpolator`) for the :attr:`adjusted matrix`. """ @@ -271,6 +283,18 @@ def interpolators(self): self._cache["interpolators"][0] = interpolators return self._cache["interpolators"][0] + @property + def average_distance(self): + """ float: The average distance of the core landmarks (18-67) from the mean face that was + used for aligning the image. """ + with self._cache["average_distance"][1]: + if self._cache["average_distance"][0] is None: + # pylint:disable=unsubscriptable-object + average_distance = np.mean(np.abs(self.normalized_landmarks[17:] - _MEAN_FACE)) + logger.trace("average_distance: %s", average_distance) + self._cache["average_distance"][0] = average_distance + return self._cache["average_distance"][0] + @classmethod def _set_cache(cls): """ Set the cache items. @@ -286,6 +310,8 @@ def _set_cache(cls): return dict(pose=[None, Lock()], original_roi=[None, Lock()], landmarks=[None, Lock()], + landmarks_normalized=[None, Lock()], + average_distance=[None, Lock()], adjusted_matrix=[None, Lock()], interpolators=[None, Lock()], head_size=[dict(), Lock()], diff --git a/locales/es/LC_MESSAGES/tools.manual.mo b/locales/es/LC_MESSAGES/tools.manual.mo index 6ea23eace64db40326eccf1da08e5088626a6aaa..a9c32b1e4970e6c5e7042a8ced3093cc6255dd63 100644 GIT binary patch delta 1043 zcmYk)OGs2v7{Kw*nK2)!In&cJJw0q36A27@nn(oN1B1dvLUo-vjko5`*uB?TxS19e zL~Uw_XcHKb8kE(ndMtvVRw*PA78KGV7eTZ$+Vnq_)t#C9JLk-~=ljlg&urUld+tLd zbXB;T_|@`D6^ZP?rvYwUqd}1)IEL3T6cX9QoEx~9{$#Ppa{Pn~@dvKNk_95wxE9N> z70a;==dcrFB01?O5!uPWDeT5kT!C}A48x@&ORy2mcpDC54=%+yT!d9bF@ftaM1tGU zB(#gD;Btm@2(_js`jrBN=Yj6_l@Eg`(btOB;COnC~7-7K|n4~{l zC2|lu$i^geg_{X{6WNQ5;Cmd$b9j;RJR`n@!y=DZ=rx*GaEZEZ#4$8Gc!P~NgM6i& za+sahp{dYj?B)4=Xr3G4&pkMW$FYVoS{;=AB@D@p@Vfh0DRmWw}GzSAM@%3ET6nbX=R$NDwpHY2U z#WQKoPH0yrlw*5V%1)+rqD6Ju$$strUBgqBtCf@C=4#J(?YM7ewHzkQfSZYHj|sl7 zodMq~9ILz+C~7TKRo|+}kwITd55+B&vgQ}FX=yK^)nM9E(YWPWW(`Lt>`YV*nC|I8 vt-SwIGxNzTt*wOBqV`+fzok6oJRc@_%4gKeq)yqyA`n@X=}65SoX}%{5^XX~hGBjutkH z1aJ$t@C~c^&zeOfmGN1ONG9H5BEDcYzM~zTu_9J1!UU|u4{SnW2BjuWq>YIY?87j+ zFp3#yiWf;k7wUW^?qWBlV-!>H8#S&c{Bu9K%5U7$($Ur(aA==IK_C>Cen|WsEK6KX}~h%4C2KHtivU=Q8*(M1HbPrzhS+t;`9Iu>Kl#otKAQIE>@CjrvM|P%GnRk=lrQ zpaBftax=q?p0tA(sihu6E%iLk5?~dbjK7$qs$yex3*Uze1B?fAc)xgvTFD#q-~)Ow zhAaxO7ISeB{W#~ADk&kr5fjTy-0^f8N6Chw$QAM($vx`EPkEtJ!)MjKNlD5>uSTb- uBvfy1bd)jXllp6V$kNb)IbYXGab(wgYK>ebUl^v^$clYGDRP|KVEh8uEm6?` diff --git a/locales/es/LC_MESSAGES/tools.manual.po b/locales/es/LC_MESSAGES/tools.manual.po index 6a75aa7906..71b2b71d7a 100644 --- a/locales/es/LC_MESSAGES/tools.manual.po +++ b/locales/es/LC_MESSAGES/tools.manual.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-03-22 18:31+0000\n" +"POT-Creation-Date: 2021-06-08 19:24+0100\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: \n" @@ -13,9 +13,9 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Poedit 2.4.2\n" +"X-Generator: Poedit 2.4.3\n" -#: tools/manual/cli.py:13 +#: tools/manual\cli.py:13 msgid "" "This command lets you perform various actions on frames, faces and " "alignments files using visual tools." @@ -23,7 +23,7 @@ msgstr "" "Este comando le permite realizar varias acciones en los archivos de " "fotogramas, caras y alineaciones utilizando herramientas visuales." -#: tools/manual/cli.py:23 +#: tools/manual\cli.py:23 msgid "" "A tool to perform various actions on frames, faces and alignments files " "using visual tools" @@ -31,18 +31,18 @@ msgstr "" "Una herramienta que permite realizar diversas acciones en archivos de " "fotogramas, caras y alineaciones mediante herramientas visuales" -#: tools/manual/cli.py:35 tools/manual/cli.py:43 +#: tools/manual\cli.py:35 tools/manual\cli.py:43 msgid "data" msgstr "datos" -#: tools/manual/cli.py:37 +#: tools/manual\cli.py:37 msgid "" "Path to the alignments file for the input, if not at the default location" msgstr "" "Ruta del archivo de alineaciones para la entrada, si no está en la ubicación " "por defecto" -#: tools/manual/cli.py:44 +#: tools/manual\cli.py:44 msgid "" "Video file or directory containing source frames that faces were extracted " "from." @@ -50,11 +50,11 @@ msgstr "" "Archivo o directorio de vídeo que contiene los fotogramas de origen de los " "que se extrajeron las caras." -#: tools/manual/cli.py:51 tools/manual/cli.py:59 +#: tools/manual\cli.py:51 tools/manual\cli.py:59 msgid "options" msgstr "opciones" -#: tools/manual/cli.py:52 +#: tools/manual\cli.py:52 msgid "" "Force regeneration of the low resolution jpg thumbnails in the alignments " "file." @@ -62,7 +62,7 @@ msgstr "" "Forzar la regeneración de las miniaturas jpg de baja resolución en el " "archivo de alineaciones." -#: tools/manual/cli.py:60 +#: tools/manual\cli.py:60 msgid "" "The process attempts to speed up generation of thumbnails by extracting from " "the video in parallel threads. For some videos, this causes the caching " @@ -74,26 +74,26 @@ msgstr "" "extracción se cuelgue. Si esto sucede, entonces configure esta opción para " "generar las miniaturas en un solo hilo más lento, pero más estable." -#: tools/manual/faceviewer\frame.py:163 +#: tools/manual\faceviewer\frame.py:163 msgid "Display the landmarks mesh" msgstr "Mostrar la malla de puntos de referencia" -#: tools/manual/faceviewer\frame.py:164 +#: tools/manual\faceviewer\frame.py:164 msgid "Display the mask" msgstr "Mostrar la máscara" -#: tools/manual/frameviewer\editor\_base.py:627 -#: tools/manual/frameviewer\editor\landmarks.py:44 -#: tools/manual/frameviewer\editor\mask.py:75 +#: tools/manual\frameviewer\editor\_base.py:628 +#: tools/manual\frameviewer\editor\landmarks.py:44 +#: tools/manual\frameviewer\editor\mask.py:75 msgid "Magnify/Demagnify the View" msgstr "Ampliar/Reducir la vista" -#: tools/manual/frameviewer\editor\bounding_box.py:33 -#: tools/manual/frameviewer\editor\extract_box.py:32 +#: tools/manual\frameviewer\editor\bounding_box.py:33 +#: tools/manual\frameviewer\editor\extract_box.py:32 msgid "Delete Face" msgstr "Borrar cara" -#: tools/manual/frameviewer\editor\bounding_box.py:36 +#: tools/manual\frameviewer\editor\bounding_box.py:36 msgid "" "Bounding Box Editor\n" "Edit the bounding box being fed into the aligner to recalculate the " @@ -115,7 +115,7 @@ msgstr "" " - Haga clic con el botón derecho del ratón en un cuadro delimitador para " "eliminar una cara." -#: tools/manual/frameviewer\editor\bounding_box.py:70 +#: tools/manual\frameviewer\editor\bounding_box.py:70 msgid "" "Aligner to use. FAN will obtain better alignments, but cv2-dnn can be useful " "if FAN cannot get decent alignments and you want to set a base to edit from." @@ -124,7 +124,7 @@ msgstr "" "ser útil si FAN no puede obtener alineaciones decentes y quiere tener una " "base inicial que luego se vaya a editar." -#: tools/manual/frameviewer\editor\bounding_box.py:83 +#: tools/manual\frameviewer\editor\bounding_box.py:83 msgid "" "Normalization method to use for feeding faces to the aligner. This can help " "the aligner better align faces with difficult lighting conditions. Different " @@ -147,7 +147,7 @@ msgstr "" "\thist: Iguala los histogramas en los canales RGB.\n" "\tmean: Normaliza los colores de la cara a la media." -#: tools/manual/frameviewer\editor\extract_box.py:35 +#: tools/manual\frameviewer\editor\extract_box.py:35 msgid "" "Extract Box Editor\n" "Move the extract box that has been generated by the aligner. Click and " @@ -166,7 +166,7 @@ msgstr "" "referencia.\n" " - Fuera de las esquinas para girar los puntos de referencia." -#: tools/manual/frameviewer\editor\landmarks.py:27 +#: tools/manual\frameviewer\editor\landmarks.py:27 msgid "" "Landmark Point Editor\n" "Edit the individual landmark points.\n" @@ -180,7 +180,7 @@ msgstr "" " - Haga clic y arrastre los puntos individuales para reubicarlos.\n" " - Dibuje un cuadro para seleccionar varios puntos para reubicarlos." -#: tools/manual/frameviewer\editor\mask.py:33 +#: tools/manual\frameviewer\editor\mask.py:33 msgid "" "Mask Editor\n" "Edit the mask.\n" @@ -197,90 +197,98 @@ msgstr "" "Cualquier cambio en los puntos de referencia después de editar la máscara " "anulará sus ediciones manuales." -#: tools/manual/frameviewer\editor\mask.py:77 +#: tools/manual\frameviewer\editor\mask.py:77 msgid "Draw Tool" msgstr "Herramienta de dibujo" -#: tools/manual/frameviewer\editor\mask.py:78 +#: tools/manual\frameviewer\editor\mask.py:78 msgid "Erase Tool" msgstr "Herramienta de borrado" -#: tools/manual/frameviewer\editor\mask.py:97 +#: tools/manual\frameviewer\editor\mask.py:97 msgid "Select which mask to edit" msgstr "Seleccionar máscara a editar" -#: tools/manual/frameviewer\editor\mask.py:104 +#: tools/manual\frameviewer\editor\mask.py:104 msgid "Set the brush size. ([ - decrease, ] - increase)" msgstr "Seleccionar el tamaño del pincel ([ - disminuir, ] - aumentar)" -#: tools/manual/frameviewer\editor\mask.py:111 +#: tools/manual\frameviewer\editor\mask.py:111 msgid "Select the brush cursor color." msgstr "Seleccionar el color del pincel." -#: tools/manual/frameviewer\frame.py:77 +#: tools/manual\frameviewer\frame.py:78 msgid "Play/Pause (SPACE)" msgstr "Reproducir/Pausa (BARRA DE ESPACIO)" -#: tools/manual/frameviewer\frame.py:78 +#: tools/manual\frameviewer\frame.py:79 msgid "Go to First Frame (HOME)" msgstr "Ir al primer cuadro (INICIO)" -#: tools/manual/frameviewer\frame.py:79 +#: tools/manual\frameviewer\frame.py:80 msgid "Go to Previous Frame (Z)" msgstr "Ir al cuadro anterior (Z)" -#: tools/manual/frameviewer\frame.py:80 +#: tools/manual\frameviewer\frame.py:81 msgid "Go to Next Frame (X)" msgstr "Ir al siguiente cuadro (X)" -#: tools/manual/frameviewer\frame.py:81 +#: tools/manual\frameviewer\frame.py:82 msgid "Go to Last Frame (END)" msgstr "Ir al último cuadro (FIN)" -#: tools/manual/frameviewer\frame.py:82 +#: tools/manual\frameviewer\frame.py:83 msgid "Extract the faces to a folder... (Ctrl+E)" msgstr "Extraer las caras a una carpeta... (Ctrl+E)" -#: tools/manual/frameviewer\frame.py:83 +#: tools/manual\frameviewer\frame.py:84 msgid "Save the Alignments file (Ctrl+S)" msgstr "Guardar el fichero de alineamientos (Ctrl+S)" -#: tools/manual/frameviewer\frame.py:84 +#: tools/manual\frameviewer\frame.py:85 msgid "Filter Frames to only those Containing the Selected Item (F)" msgstr "Mostrar cuadros que contenga únicamente el elemento seleccionado (F)" -#: tools/manual/frameviewer\frame.py:318 +#: tools/manual\frameviewer\frame.py:86 +msgid "" +"Set the distance from an 'average face' to be considered misaligned. Higher " +"distances are more restrictive" +msgstr "" +"Establezca la distancia desde una 'cara promedio' para que se considere " +"desalineada. Las distancias más altas son más restrictivas" + +#: tools/manual\frameviewer\frame.py:391 msgid "View alignments" msgstr "Ver alineamientos" -#: tools/manual/frameviewer\frame.py:319 +#: tools/manual\frameviewer\frame.py:392 msgid "Bounding box editor" msgstr "Editor de cuadro delimitador" -#: tools/manual/frameviewer\frame.py:320 +#: tools/manual\frameviewer\frame.py:393 msgid "Location editor" msgstr "Editor de ubicación" -#: tools/manual/frameviewer\frame.py:321 +#: tools/manual\frameviewer\frame.py:394 msgid "Mask editor" msgstr "Editor de máscara" -#: tools/manual/frameviewer\frame.py:322 +#: tools/manual\frameviewer\frame.py:395 msgid "Landmark point editor" msgstr "Editor de puntos de referencia" -#: tools/manual/frameviewer\frame.py:397 +#: tools/manual\frameviewer\frame.py:470 msgid "Next" msgstr "Siguiente" -#: tools/manual/frameviewer\frame.py:397 +#: tools/manual\frameviewer\frame.py:470 msgid "Previous" msgstr "Anterior" -#: tools/manual/frameviewer\frame.py:408 +#: tools/manual\frameviewer\frame.py:481 msgid "Revert to saved Alignments ({})" msgstr "Volver a los alineamientos guardados ({})" -#: tools/manual/frameviewer\frame.py:414 +#: tools/manual\frameviewer\frame.py:487 msgid "Copy {} Alignments ({})" msgstr "Copiar los alineamientos del cuadro {} ({})" diff --git a/locales/es/LC_MESSAGES/tools.sort.cli.mo b/locales/es/LC_MESSAGES/tools.sort.cli.mo index a3a74d4caab6b1122bfcb6273910fd9ead22dda4..e438b4cd863e4e717c65f2fa707442ab2d55d405 100644 GIT binary patch delta 671 zcmZ9I&1(};6vgjIHBBIxNd0I-;TnZlq@ig=OS4h2g@PZ8x^?lAxsyDYc@y7zBM3rv zcH^QD$=*Lf1EOwRD7bXzMsVdH;KGGV&os%V12aF~dFOtd`MCFM|0o=e&k)@!619lv z=UJj3V7WrH4gLlfz||@dY*Ysq!8Nc9Zi0IO(QUAQise zzI%!2BK+;;Y5#*2dPqwUUab(lED*h^5#50C+7+S_^e5mx&i$)Ir@?n%zQ_j8Bx1bd$fgM^!;L%YnON0B zr+jdeANRH6E^a!)nUg4sIZ7={tP^dN*QUpbcE_DCA+MQ?inwS9e~ zHTqil9&AlsSKk*4vwWlW>hf?N$*M5rP@I@3eVWOMNRMnNMmSdaA{O5P7Kqh6j#jgX zA*87d>ljg8o#F@OeIaK?9anRGfT$AcwBBs|^%yedCQLs?GcNcpc^YVKtqqy5go`u<^-Tha z&oZOd1sdb0~;~OWsF}|x#|L5-mUfeOqcHDA&%MB~F@F=KX XhUcf%YBca>rWW7tyd~qs;lf%0(ZnU& diff --git a/locales/es/LC_MESSAGES/tools.sort.cli.po b/locales/es/LC_MESSAGES/tools.sort.cli.po index 765347e394..5dd351b54c 100644 --- a/locales/es/LC_MESSAGES/tools.sort.cli.po +++ b/locales/es/LC_MESSAGES/tools.sort.cli.po @@ -5,8 +5,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-03-23 15:33+0000\n" -"PO-Revision-Date: 2021-03-23 15:36+0000\n" +"POT-Creation-Date: 2021-06-07 12:34+0100\n" +"PO-Revision-Date: 2021-06-07 12:38+0100\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es_ES\n" @@ -14,7 +14,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 2.4.2\n" +"X-Generator: Poedit 2.4.3\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: tools/sort/cli.py:14 @@ -38,7 +38,7 @@ msgstr "Directorio de entrada de caras alineadas." msgid "Output directory for sorted aligned faces." msgstr "Directorio de salida para las caras alineadas ordenadas." -#: tools/sort/cli.py:50 tools/sort/cli.py:93 +#: tools/sort/cli.py:50 tools/sort/cli.py:96 msgid "sort settings" msgstr "ajustes de ordenación" @@ -46,6 +46,9 @@ msgstr "ajustes de ordenación" msgid "" "R|Sort by method. Choose how images are sorted. \n" "L|'blur': Sort faces by blurriness.\n" +"L|'blur-fft': Sort faces by fft filtered blurriness.\n" +"L|'distance' Sort faces by the estimated distance of the alignments from an " +"'average' face. This can be useful for eliminating misaligned faces.\n" "L|'face': Use VGG Face to sort by face similarity. This uses a pairwise " "clustering algorithm to check the distances between 512 features on every " "face in your set and order them appropriately.\n" @@ -75,6 +78,10 @@ msgid "" msgstr "" "R|Método de ordenación. Elige cómo se ordenan las imágenes. \n" "L|'blur': Ordena las caras por desenfoque.\n" +"L|'blur-fft': Ordena las caras por fft filtrado desenfoque.\n" +"L|'distance' Ordene las caras por la distancia estimada de las alineaciones " +"desde una cara \"promedio\". Esto puede resultar útil para eliminar caras " +"desalineadas.\n" "L|'face': Utiliza VGG Face para ordenar por similitud de caras. Esto utiliza " "un algoritmo de agrupación por pares para comprobar las distancias entre 512 " "características en cada cara en su conjunto y ordenarlos adecuadamente.\n" @@ -102,12 +109,12 @@ msgstr "" "fuentes de menor resolución se ordenarán en último lugar.\n" "Por defecto: face" -#: tools/sort/cli.py:82 tools/sort/cli.py:109 tools/sort/cli.py:121 -#: tools/sort/cli.py:132 +#: tools/sort/cli.py:85 tools/sort/cli.py:112 tools/sort/cli.py:124 +#: tools/sort/cli.py:135 msgid "output" msgstr "salida" -#: tools/sort/cli.py:83 +#: tools/sort/cli.py:86 msgid "" "Keeps the original files in the input directory. Be careful when using this " "with rename grouping and no specified output directory as this would keep " @@ -118,7 +125,7 @@ msgstr "" "de salida, ya que esto mantendría los archivos originales y renombrados en " "el mismo directorio." -#: tools/sort/cli.py:95 +#: tools/sort/cli.py:98 msgid "" "Float value. Minimum threshold to use for grouping comparison with 'face-" "cnn' and 'hist' methods. The lower the value the more discriminating the " @@ -139,7 +146,7 @@ msgstr "" "podría resultar en la creación de muchos directorios. Por defecto: 'face-" "cnn' = 7.2, 'hist' = 0.3" -#: tools/sort/cli.py:110 +#: tools/sort/cli.py:113 msgid "" "R|Default: rename.\n" "L|'folders': files are sorted using the -s/--sort-by method, then they are " @@ -153,7 +160,7 @@ msgstr "" "L|'rename': los archivos se ordenan utilizando el método -s/--sort-by y " "luego se renombran." -#: tools/sort/cli.py:123 +#: tools/sort/cli.py:126 msgid "" "Group by method. When -fp/--final-processing by folders choose the how the " "images are grouped after sorting. Default: hist" @@ -161,7 +168,7 @@ msgstr "" "Método de agrupamiento. Elija la forma de agrupar las imágenes, en el caso " "de hacerlo por carpetas, después de la clasificación. Por defecto: hist" -#: tools/sort/cli.py:134 +#: tools/sort/cli.py:137 msgid "" "Integer value. Number of folders that will be used to group by blur and face-" "yaw. For blur folder 0 will be the least blurry, while the last folder will " @@ -182,11 +189,11 @@ msgstr "" "uniformemente en el número de carpetas, las imágenes restantes se colocan en " "la última carpeta. Valor por defecto: 5" -#: tools/sort/cli.py:145 tools/sort/cli.py:155 +#: tools/sort/cli.py:148 tools/sort/cli.py:158 msgid "settings" msgstr "ajustes" -#: tools/sort/cli.py:147 +#: tools/sort/cli.py:150 msgid "" "Logs file renaming changes if grouping by renaming, or it logs the file " "copying/movement if grouping by folders. If no log file is specified with " @@ -198,7 +205,7 @@ msgstr "" "se especifica ningún archivo de registro con '--log-file', se creará un " "archivo 'sort_log.json' en el directorio de entrada." -#: tools/sort/cli.py:158 +#: tools/sort/cli.py:161 msgid "" "Specify a log file to use for saving the renaming or grouping information. " "If specified extension isn't 'json' or 'yaml', then json will be used as the " diff --git a/locales/tools.manual.pot b/locales/tools.manual.pot index 04fca258db..a6cff24f13 100644 --- a/locales/tools.manual.pot +++ b/locales/tools.manual.pot @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-03-22 18:31+0000\n" +"POT-Creation-Date: 2021-06-08 19:24+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -15,58 +15,58 @@ msgstr "" "Generated-By: pygettext.py 1.5\n" -#: ./tools/manual/cli.py:13 +#: tools/manual\cli.py:13 msgid "This command lets you perform various actions on frames, faces and alignments files using visual tools." msgstr "" -#: ./tools/manual/cli.py:23 +#: tools/manual\cli.py:23 msgid "A tool to perform various actions on frames, faces and alignments files using visual tools" msgstr "" -#: ./tools/manual/cli.py:35 ./tools/manual/cli.py:43 +#: tools/manual\cli.py:35 tools/manual\cli.py:43 msgid "data" msgstr "" -#: ./tools/manual/cli.py:37 +#: tools/manual\cli.py:37 msgid "Path to the alignments file for the input, if not at the default location" msgstr "" -#: ./tools/manual/cli.py:44 +#: tools/manual\cli.py:44 msgid "Video file or directory containing source frames that faces were extracted from." msgstr "" -#: ./tools/manual/cli.py:51 ./tools/manual/cli.py:59 +#: tools/manual\cli.py:51 tools/manual\cli.py:59 msgid "options" msgstr "" -#: ./tools/manual/cli.py:52 +#: tools/manual\cli.py:52 msgid "Force regeneration of the low resolution jpg thumbnails in the alignments file." msgstr "" -#: ./tools/manual/cli.py:60 +#: tools/manual\cli.py:60 msgid "The process attempts to speed up generation of thumbnails by extracting from the video in parallel threads. For some videos, this causes the caching process to hang. If this happens, then set this option to generate the thumbnails in a slower, but more stable single thread." msgstr "" -#: ./tools/manual/faceviewer\frame.py:163 +#: tools/manual\faceviewer\frame.py:163 msgid "Display the landmarks mesh" msgstr "" -#: ./tools/manual/faceviewer\frame.py:164 +#: tools/manual\faceviewer\frame.py:164 msgid "Display the mask" msgstr "" -#: ./tools/manual/frameviewer\editor\_base.py:627 -#: ./tools/manual/frameviewer\editor\landmarks.py:44 -#: ./tools/manual/frameviewer\editor\mask.py:75 +#: tools/manual\frameviewer\editor\_base.py:628 +#: tools/manual\frameviewer\editor\landmarks.py:44 +#: tools/manual\frameviewer\editor\mask.py:75 msgid "Magnify/Demagnify the View" msgstr "" -#: ./tools/manual/frameviewer\editor\bounding_box.py:33 -#: ./tools/manual/frameviewer\editor\extract_box.py:32 +#: tools/manual\frameviewer\editor\bounding_box.py:33 +#: tools/manual\frameviewer\editor\extract_box.py:32 msgid "Delete Face" msgstr "" -#: ./tools/manual/frameviewer\editor\bounding_box.py:36 +#: tools/manual\frameviewer\editor\bounding_box.py:36 msgid "" "Bounding Box Editor\n" "Edit the bounding box being fed into the aligner to recalculate the landmarks.\n" @@ -77,11 +77,11 @@ msgid "" " - Right click a bounding box to delete a face." msgstr "" -#: ./tools/manual/frameviewer\editor\bounding_box.py:70 +#: tools/manual\frameviewer\editor\bounding_box.py:70 msgid "Aligner to use. FAN will obtain better alignments, but cv2-dnn can be useful if FAN cannot get decent alignments and you want to set a base to edit from." msgstr "" -#: ./tools/manual/frameviewer\editor\bounding_box.py:83 +#: tools/manual\frameviewer\editor\bounding_box.py:83 msgid "" "Normalization method to use for feeding faces to the aligner. This can help the aligner better align faces with difficult lighting conditions. Different methods will yield different results on different sets. NB: This does not impact the output face, just the input to the aligner.\n" "\tnone: Don't perform normalization on the face.\n" @@ -90,7 +90,7 @@ msgid "" "\tmean: Normalize the face colors to the mean." msgstr "" -#: ./tools/manual/frameviewer\editor\extract_box.py:35 +#: tools/manual\frameviewer\editor\extract_box.py:35 msgid "" "Extract Box Editor\n" "Move the extract box that has been generated by the aligner. Click and drag:\n" @@ -100,7 +100,7 @@ msgid "" " - Outside of the corners to rotate the landmarks." msgstr "" -#: ./tools/manual/frameviewer\editor\landmarks.py:27 +#: tools/manual\frameviewer\editor\landmarks.py:27 msgid "" "Landmark Point Editor\n" "Edit the individual landmark points.\n" @@ -109,98 +109,102 @@ msgid "" " - Draw a box to select multiple points to relocate." msgstr "" -#: ./tools/manual/frameviewer\editor\mask.py:33 +#: tools/manual\frameviewer\editor\mask.py:33 msgid "" "Mask Editor\n" "Edit the mask.\n" " - NB: For Landmark based masks (e.g. components/extended) it is better to make sure the landmarks are correct rather than editing the mask directly. Any change to the landmarks after editing the mask will override your manual edits." msgstr "" -#: ./tools/manual/frameviewer\editor\mask.py:77 +#: tools/manual\frameviewer\editor\mask.py:77 msgid "Draw Tool" msgstr "" -#: ./tools/manual/frameviewer\editor\mask.py:78 +#: tools/manual\frameviewer\editor\mask.py:78 msgid "Erase Tool" msgstr "" -#: ./tools/manual/frameviewer\editor\mask.py:97 +#: tools/manual\frameviewer\editor\mask.py:97 msgid "Select which mask to edit" msgstr "" -#: ./tools/manual/frameviewer\editor\mask.py:104 +#: tools/manual\frameviewer\editor\mask.py:104 msgid "Set the brush size. ([ - decrease, ] - increase)" msgstr "" -#: ./tools/manual/frameviewer\editor\mask.py:111 +#: tools/manual\frameviewer\editor\mask.py:111 msgid "Select the brush cursor color." msgstr "" -#: ./tools/manual/frameviewer\frame.py:77 +#: tools/manual\frameviewer\frame.py:78 msgid "Play/Pause (SPACE)" msgstr "" -#: ./tools/manual/frameviewer\frame.py:78 +#: tools/manual\frameviewer\frame.py:79 msgid "Go to First Frame (HOME)" msgstr "" -#: ./tools/manual/frameviewer\frame.py:79 +#: tools/manual\frameviewer\frame.py:80 msgid "Go to Previous Frame (Z)" msgstr "" -#: ./tools/manual/frameviewer\frame.py:80 +#: tools/manual\frameviewer\frame.py:81 msgid "Go to Next Frame (X)" msgstr "" -#: ./tools/manual/frameviewer\frame.py:81 +#: tools/manual\frameviewer\frame.py:82 msgid "Go to Last Frame (END)" msgstr "" -#: ./tools/manual/frameviewer\frame.py:82 +#: tools/manual\frameviewer\frame.py:83 msgid "Extract the faces to a folder... (Ctrl+E)" msgstr "" -#: ./tools/manual/frameviewer\frame.py:83 +#: tools/manual\frameviewer\frame.py:84 msgid "Save the Alignments file (Ctrl+S)" msgstr "" -#: ./tools/manual/frameviewer\frame.py:84 +#: tools/manual\frameviewer\frame.py:85 msgid "Filter Frames to only those Containing the Selected Item (F)" msgstr "" -#: ./tools/manual/frameviewer\frame.py:318 +#: tools/manual\frameviewer\frame.py:86 +msgid "Set the distance from an 'average face' to be considered misaligned. Higher distances are more restrictive" +msgstr "" + +#: tools/manual\frameviewer\frame.py:391 msgid "View alignments" msgstr "" -#: ./tools/manual/frameviewer\frame.py:319 +#: tools/manual\frameviewer\frame.py:392 msgid "Bounding box editor" msgstr "" -#: ./tools/manual/frameviewer\frame.py:320 +#: tools/manual\frameviewer\frame.py:393 msgid "Location editor" msgstr "" -#: ./tools/manual/frameviewer\frame.py:321 +#: tools/manual\frameviewer\frame.py:394 msgid "Mask editor" msgstr "" -#: ./tools/manual/frameviewer\frame.py:322 +#: tools/manual\frameviewer\frame.py:395 msgid "Landmark point editor" msgstr "" -#: ./tools/manual/frameviewer\frame.py:397 +#: tools/manual\frameviewer\frame.py:470 msgid "Next" msgstr "" -#: ./tools/manual/frameviewer\frame.py:397 +#: tools/manual\frameviewer\frame.py:470 msgid "Previous" msgstr "" -#: ./tools/manual/frameviewer\frame.py:408 +#: tools/manual\frameviewer\frame.py:481 msgid "Revert to saved Alignments ({})" msgstr "" -#: ./tools/manual/frameviewer\frame.py:414 +#: tools/manual\frameviewer\frame.py:487 msgid "Copy {} Alignments ({})" msgstr "" diff --git a/locales/tools.sort.cli.pot b/locales/tools.sort.cli.pot index e4ed8f0370..4658d74d62 100644 --- a/locales/tools.sort.cli.pot +++ b/locales/tools.sort.cli.pot @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-03-23 15:33+0000\n" +"POT-Creation-Date: 2021-06-07 12:34+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -35,7 +35,7 @@ msgstr "" msgid "Output directory for sorted aligned faces." msgstr "" -#: tools/sort/cli.py:50 tools/sort/cli.py:93 +#: tools/sort/cli.py:50 tools/sort/cli.py:96 msgid "sort settings" msgstr "" @@ -43,6 +43,8 @@ msgstr "" msgid "" "R|Sort by method. Choose how images are sorted. \n" "L|'blur': Sort faces by blurriness.\n" +"L|'blur-fft': Sort faces by fft filtered blurriness.\n" +"L|'distance' Sort faces by the estimated distance of the alignments from an 'average' face. This can be useful for eliminating misaligned faces.\n" "L|'face': Use VGG Face to sort by face similarity. This uses a pairwise clustering algorithm to check the distances between 512 features on every face in your set and order them appropriately.\n" "L|'face-cnn': Sort faces by their landmarks. You can adjust the threshold with the '-t' (--ref_threshold) option.\n" "L|'face-cnn-dissim': Like 'face-cnn' but sorts by dissimilarity.\n" @@ -57,43 +59,43 @@ msgid "" "Default: face" msgstr "" -#: tools/sort/cli.py:82 tools/sort/cli.py:109 tools/sort/cli.py:121 -#: tools/sort/cli.py:132 +#: tools/sort/cli.py:85 tools/sort/cli.py:112 tools/sort/cli.py:124 +#: tools/sort/cli.py:135 msgid "output" msgstr "" -#: tools/sort/cli.py:83 +#: tools/sort/cli.py:86 msgid "Keeps the original files in the input directory. Be careful when using this with rename grouping and no specified output directory as this would keep the original and renamed files in the same directory." msgstr "" -#: tools/sort/cli.py:95 +#: tools/sort/cli.py:98 msgid "Float value. Minimum threshold to use for grouping comparison with 'face-cnn' and 'hist' methods. The lower the value the more discriminating the grouping is. Leaving -1.0 will allow the program set the default value automatically. For face-cnn 7.2 should be enough, with 4 being very discriminating. For hist 0.3 should be enough, with 0.2 being very discriminating. Be careful setting a value that's too low in a directory with many images, as this could result in a lot of directories being created. Defaults: face-cnn 7.2, hist 0.3" msgstr "" -#: tools/sort/cli.py:110 +#: tools/sort/cli.py:113 msgid "" "R|Default: rename.\n" "L|'folders': files are sorted using the -s/--sort-by method, then they are organized into folders using the -g/--group-by grouping method.\n" "L|'rename': files are sorted using the -s/--sort-by then they are renamed." msgstr "" -#: tools/sort/cli.py:123 +#: tools/sort/cli.py:126 msgid "Group by method. When -fp/--final-processing by folders choose the how the images are grouped after sorting. Default: hist" msgstr "" -#: tools/sort/cli.py:134 +#: tools/sort/cli.py:137 msgid "Integer value. Number of folders that will be used to group by blur and face-yaw. For blur folder 0 will be the least blurry, while the last folder will be the blurriest. For face-yaw the number of bins is by how much 180 degrees is divided. So if you use 18, then each folder will be a 10 degree increment. Folder 0 will contain faces looking the most to the left whereas the last folder will contain the faces looking the most to the right. If the number of images doesn't divide evenly into the number of bins, the remaining images get put in the last bin. Default value: 5" msgstr "" -#: tools/sort/cli.py:145 tools/sort/cli.py:155 +#: tools/sort/cli.py:148 tools/sort/cli.py:158 msgid "settings" msgstr "" -#: tools/sort/cli.py:147 +#: tools/sort/cli.py:150 msgid "Logs file renaming changes if grouping by renaming, or it logs the file copying/movement if grouping by folders. If no log file is specified with '--log-file', then a 'sort_log.json' file will be created in the input directory." msgstr "" -#: tools/sort/cli.py:158 +#: tools/sort/cli.py:161 msgid "Specify a log file to use for saving the renaming or grouping information. If specified extension isn't 'json' or 'yaml', then json will be used as the serializer, with the supplied filename. Default: sort_log.json" msgstr "" diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index 5bdc26e5bf..d716d93695 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -266,6 +266,8 @@ def load(self): for item in self._alignments.data[key]["faces"]: face = DetectedFace() face.from_alignment(item, with_thumb=True) + face.load_aligned(None) + _ = face.aligned.average_distance # cache the distances this_frame_faces.append(face) self._frame_faces.append(this_frame_faces) @@ -306,6 +308,8 @@ def revert_to_saved(self, frame_index): for detected_face, face in zip(faces, alignments): detected_face.from_alignment(face, with_thumb=True) + detected_face.load_aligned(None, force=True) + _ = detected_face.aligned.average_distance # cache the distances self._updated_frame_indices.remove(frame_index) if not self._updated_frame_indices: @@ -390,18 +394,17 @@ def _background_extract(self, output_folder, progress_queue): progress_queue: :class:`queue.Queue` The queue to place incremental counts to for updating the GUI's progress bar """ - saver = ImagesSaver(str(get_folder(output_folder)), as_bytes=True) - loader = ImagesLoader(self._input_location, count=self._alignments.frames_count) - extension = ".png" + _io = dict(saver=ImagesSaver(str(get_folder(output_folder)), as_bytes=True), + loader=ImagesLoader(self._input_location, count=self._alignments.frames_count)) - for frame_idx, (filename, image) in enumerate(loader.load()): + for frame_idx, (filename, image) in enumerate(_io["loader"].load()): logger.trace("Outputting frame: %s: %s", frame_idx, filename) src_filename = os.path.basename(filename) frame_name = os.path.splitext(src_filename)[0] progress_queue.put(1) for face_idx, face in enumerate(self._frame_faces[frame_idx]): - output = "{}_{}{}".format(frame_name, str(face_idx), extension) + output = "{}_{}{}".format(frame_name, str(face_idx), ".png") aligned = AlignedFace(face.landmarks_xy, image=image, centering="head", @@ -413,9 +416,9 @@ def _background_extract(self, output_folder, progress_queue): source_filename=src_filename, source_is_video=self._globals.is_video)) - b_image = encode_image(aligned.face, extension, metadata=meta) - saver.save(output, b_image) - saver.close() + b_image = encode_image(aligned.face, ".png", metadata=meta) + _io["saver"].save(output, b_image) + _io["saver"].close() class Filter(): @@ -434,6 +437,16 @@ def __init__(self, detected_faces): self._detected_faces = detected_faces logger.debug("Initialized %s", self.__class__.__name__) + @property + def _filter_distance(self): + """ float: The currently selected distance when Misaligned Faces filter is selected. """ + try: + retval = self._globals.tk_filter_distance.get() + except tk.TclError: + # Suppress error when distance box is empty + retval = 0 + return retval / 100. + @property def count(self): """ int: The number of frames that meet the filter criteria returned by @@ -445,6 +458,10 @@ def count(self): retval = sum(1 for fcount in face_count_per_index if fcount != 0) elif self._globals.filter_mode == "Multiple Faces": retval = sum(1 for fcount in face_count_per_index if fcount > 1) + elif self._globals.filter_mode == "Misaligned Faces": + distance = self._filter_distance + retval = sum(1 for frame in self._detected_faces.current_faces + if any(face.aligned.average_distance > distance for face in frame)) else: retval = len(face_count_per_index) logger.trace("filter mode: %s, frame count: %s", self._globals.filter_mode, retval) @@ -456,15 +473,15 @@ def raw_indices(self): displayed face. """ frame_indices = [] face_indices = [] - if self._globals.filter_mode != "No Faces": - for frame_idx, face_count in enumerate(self._detected_faces.face_count_per_index): - if face_count <= 1 and self._globals.filter_mode == "Multiple Faces": - continue - for face_idx in range(face_count): - frame_indices.append(frame_idx) - face_indices.append(face_idx) - logger.trace("frame_indices: %s, face_indices: %s", frame_indices, face_indices) + face_counts = self._detected_faces.face_count_per_index # Copy to avoid recalculations + + for frame_idx in self.frames_list: + for face_idx in range(face_counts[frame_idx]): + frame_indices.append(frame_idx) + face_indices.append(face_idx) + retval = dict(frame=frame_indices, face=face_indices) + logger.trace("frame_indices: %s, face_indices: %s", frame_indices, face_indices) return retval @property @@ -478,6 +495,10 @@ def frames_list(self): retval = [idx for idx, count in enumerate(face_count_per_index) if count > 1] elif self._globals.filter_mode == "Has Face(s)": retval = [idx for idx, count in enumerate(face_count_per_index) if count != 0] + elif self._globals.filter_mode == "Misaligned Faces": + distance = self._filter_distance + retval = [idx for idx, frame in enumerate(self._detected_faces.current_faces) + if any(face.aligned.average_distance > distance for face in frame)] else: retval = range(len(face_count_per_index)) logger.trace("filter mode: %s, number_frames: %s", self._globals.filter_mode, len(retval)) @@ -646,7 +667,7 @@ def landmark(self, frame_index, face_index, landmark_index, shift_x, shift_y, is aligned = AlignedFace(face.landmarks_xy, centering="face", size=min(self._globals.frame_display_dims)) - landmark = aligned.landmarks[landmark_index] + landmark = aligned.landmarks[landmark_index] # pylint:disable=unsubscriptable-object landmark += (shift_x, shift_y) matrix = aligned.adjusted_matrix matrix = cv2.invertAffineTransform(matrix) @@ -661,7 +682,6 @@ def landmark(self, frame_index, face_index, landmark_index, shift_x, shift_y, is face.landmarks_xy[idx] = lmk else: face.landmarks_xy[landmark_index] += (shift_x, shift_y) - face.mask = self._extractor.get_masks(frame_index, face_index) self._globals.tk_update.set(True) def landmarks(self, frame_index, face_index, shift_x, shift_y): @@ -689,7 +709,6 @@ def landmarks(self, frame_index, face_index, shift_x, shift_y): face.x += shift_x face.y += shift_y face.landmarks_xy += (shift_x, shift_y) - face.mask = self._extractor.get_masks(frame_index, face_index) self._globals.tk_update.set(True) def landmarks_rotate(self, frame_index, face_index, angle, center): @@ -712,7 +731,6 @@ def landmarks_rotate(self, frame_index, face_index, angle, center): rot_mat = cv2.getRotationMatrix2D(tuple(center.astype("float32")), angle, 1.) face.landmarks_xy = cv2.transform(np.expand_dims(face.landmarks_xy, axis=0), rot_mat).squeeze() - face.mask = self._extractor.get_masks(frame_index, face_index) self._globals.tk_update.set(True) def landmarks_scale(self, frame_index, face_index, scale, center): @@ -733,7 +751,6 @@ def landmarks_scale(self, frame_index, face_index, scale, center): """ face = self._faces_at_frame_index(frame_index)[face_index] face.landmarks_xy = ((face.landmarks_xy - center) * scale) + center - face.mask = self._extractor.get_masks(frame_index, face_index) self._globals.tk_update.set(True) def mask(self, frame_index, face_index, mask, mask_type): @@ -782,12 +799,24 @@ def copy(self, frame_index, direction): # No previous/next frame available return logger.debug("Copying alignments from frame %s to frame: %s", idx, frame_index) - faces.extend(deepcopy(self._faces_at_frame_index(idx))) + + # aligned_face cannot be deep copied, so remove and recreate + to_copy = self._faces_at_frame_index(idx) + for face in to_copy: + face.aligned = None + copied = deepcopy(to_copy) + + for old_face, new_face in zip(to_copy, copied): + old_face.load_aligned(None) + new_face.load_aligned(None) + + faces.extend(copied) self._tk_face_count_changed.set(True) self._globals.tk_update.set(True) def post_edit_trigger(self, frame_index, face_index): - """ Update the jpg thumbnail and the viewport thumbnail on a face edit. + """ Update the jpg thumbnail, the viewport thumbnail, the landmark masks and the aligned + face on a face edit. Parameters ---------- @@ -797,11 +826,16 @@ def post_edit_trigger(self, frame_index, face_index): The face index within the frame """ face = self._frame_faces[frame_index][face_index] + face.load_aligned(None, force=True) # Update average distance + face.mask = self._extractor.get_masks(frame_index, face_index) + aligned = AlignedFace(face.landmarks_xy, image=self._globals.current_frame["image"], centering="head", size=96) face.thumbnail = generate_thumbnail(aligned.face, size=96) + if self._globals.filter_mode == "Misaligned Faces": + self._detected_faces.tk_face_count_changed.set(True) self._tk_edited.set(True) diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index b81d5d6a1e..5018dc36f6 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -310,6 +310,7 @@ def get_landmarks(self, frame_index, face_index, face, top_left, refresh=False): size=self.face_size) landmarks = dict(polygon=[], line=[]) for area, val in self._landmark_mapping.items(): + # pylint:disable=unsubscriptable-object points = aligned.landmarks[val[0]:val[1]] + top_left shape = "polygon" if area.endswith("eye") or area.startswith("mouth") else "line" landmarks[shape].append(points) @@ -908,6 +909,8 @@ def _update_face(self): self._assets["meshes"], self._assets["boxes"], self._assets["faces"])): + if det_face is None: + continue top_left = np.array(self._canvas.coords(image_id)) coords = (*top_left, *top_left + self._size) tk_face = self._viewport.get_tk_face(self.frame_index, face_idx, det_face) diff --git a/tools/manual/frameviewer/control.py b/tools/manual/frameviewer/control.py index 670be538b1..afca5f9a0c 100644 --- a/tools/manual/frameviewer/control.py +++ b/tools/manual/frameviewer/control.py @@ -24,6 +24,7 @@ class Navigation(): """ def __init__(self, display_frame): logger.debug("Initializing %s", self.__class__.__name__) + self._display_frame = display_frame self._globals = display_frame._globals self._det_faces = display_frame._det_faces self._nav = display_frame._nav @@ -37,19 +38,23 @@ def _current_nav_frame_count(self): return self._nav["scale"].cget("to") + 1 def nav_scale_callback(self, *args, reset_progress=True): # pylint:disable=unused-argument - """ Adjust transport slider scale for different filters. + """ Adjust transport slider scale for different filters. Hide or display optional filter + controls. Returns ------- bool ``True`` if the navigation scale has been updated otherwise ``False`` """ + self._display_frame.pack_threshold_slider() if reset_progress: self.stop_playback() frame_count = self._det_faces.filter.count if self._current_nav_frame_count == frame_count: logger.trace("Filtered count has not changed. Returning") return False + if self._globals.tk_filter_mode.get() == "Misaligned Faces": + self._det_faces.tk_face_count_changed.set(True) max_frame = max(0, frame_count - 1) logger.debug("Filtered frame count has changed. Updating from %s to %s", self._current_nav_frame_count, frame_count) diff --git a/tools/manual/frameviewer/frame.py b/tools/manual/frameviewer/frame.py index 0cd7588df3..b4495ef111 100644 --- a/tools/manual/frameviewer/frame.py +++ b/tools/manual/frameviewer/frame.py @@ -42,6 +42,7 @@ def __init__(self, parent, tk_globals, detected_faces): self._globals = tk_globals self._det_faces = detected_faces + self._optional_widgets = dict() self._actions_frame = ActionsFrame(self) main_frame = ttk.Frame(self) @@ -81,7 +82,9 @@ def _helptext(self): end=_("Go to Last Frame (END)"), extract=_("Extract the faces to a folder... (Ctrl+E)"), save=_("Save the Alignments file (Ctrl+S)"), - mode=_("Filter Frames to only those Containing the Selected Item (F)")) + mode=_("Filter Frames to only those Containing the Selected Item (F)"), + distance=_("Set the distance from an 'average face' to be considered misaligned. " + "Higher distances are more restrictive")) @property def _btn_action(self): @@ -131,13 +134,11 @@ def tk_selected_mask(self): @property def _filter_modes(self): """ list: The filter modes combo box values """ - return ["All Frames", "Has Face(s)", "No Faces", "Multiple Faces"] + return ["All Frames", "Has Face(s)", "No Faces", "Multiple Faces", "Misaligned Faces"] def _add_nav(self): """ Add the slider to navigate through frames """ - self._globals.tk_transport_index.trace("w", self._set_frame_index) max_frame = self._globals.frame_count - 1 - frame = ttk.Frame(self._transport_frame) frame.pack(side=tk.TOP, fill=tk.X, pady=(0, 5)) @@ -163,6 +164,7 @@ def _add_nav(self): to=max_frame, command=cmd) nav.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + self._globals.tk_transport_index.trace("w", self._set_frame_index) return dict(entry=tbox, scale=nav, label=lbl) def _set_frame_index(self, *args): # pylint:disable=unused-argument @@ -195,9 +197,10 @@ def _add_transport(self): wgt = ttk.Button(frame, image=icons[icon], command=self._btn_action[action]) wgt.state(state) else: - wgt = self._add_filter_mode_combo(frame) + wgt = self._add_filter_section(frame) wgt.pack(side=side, padx=padx) - Tooltip(wgt, text=self._helptext[action]) + if action != "mode": + Tooltip(wgt, text=self._helptext[action]) buttons[action] = wgt logger.debug("Transport buttons: %s", buttons) return buttons @@ -207,8 +210,33 @@ def _add_transport_tk_trace(self): self._navigation.tk_is_playing.trace("w", self._play) self._det_faces.tk_unsaved.trace("w", self._toggle_save_state) + def _add_filter_section(self, frame): + """ Add the section that holds the filter mode combo and any optional filter widgets + + Parameters + ---------- + frame: :class:`tkinter.ttk.Frame` + The Frame that holds the filter section + + Returns + ------- + :class:`tkinter.ttk.Frame` + The filter section frame + """ + filter_frame = ttk.Frame(frame) + self._add_filter_mode_combo(filter_frame) + self._add_filter_threshold_slider(filter_frame) + filter_frame.pack(side=tk.RIGHT) + return filter_frame + def _add_filter_mode_combo(self, frame): - """ Add the navigation mode combo box to the transport frame """ + """ Add the navigation mode combo box to the filter frame. + + Parameters + ---------- + frame: :class:`tkinter.ttk.Frame` + The Filter Frame that holds the filter combo box + """ self._globals.tk_filter_mode.set("All Frames") self._globals.tk_filter_mode.trace("w", self._navigation.nav_scale_callback) nav_frame = ttk.Frame(frame) @@ -220,7 +248,52 @@ def _add_filter_mode_combo(self, frame): state="readonly", values=self._filter_modes) combo.pack(side=tk.RIGHT) - return nav_frame + Tooltip(nav_frame, text=self._helptext["mode"]) + nav_frame.pack(side=tk.RIGHT) + + def _add_filter_threshold_slider(self, frame): + """ Add the optional filter threshold slider for misaligned filter to the filter frame. + + Parameters + ---------- + frame: :class:`tkinter.ttk.Frame` + The Filter Frame that holds the filter threshold slider + """ + slider_frame = ttk.Frame(frame) + tk_var = self._globals.tk_filter_distance + + min_max = (5, 20) + ctl_frame = ttk.Frame(slider_frame) + ctl_frame.pack(padx=2, side=tk.RIGHT) + + lbl = ttk.Label(ctl_frame, text="Distance:", anchor=tk.W) + lbl.pack(side=tk.LEFT, anchor=tk.N, expand=True) + + tbox = ttk.Entry(ctl_frame, width=6, textvariable=tk_var, justify=tk.RIGHT) + tbox.pack(padx=(0, 5), side=tk.RIGHT) + + ctl = ttk.Scale( + ctl_frame, + variable=tk_var, + command=lambda val, var=tk_var, dt=int, rn=1, mm=min_max: + set_slider_rounding(val, var, dt, rn, mm)) + ctl["from_"] = min_max[0] + ctl["to"] = min_max[1] + ctl.pack(padx=5, fill=tk.X, expand=True) + for item in (tbox, ctl): + Tooltip(item, + text=self._helptext["distance"], + wrap_length=200) + tk_var.trace("w", self._navigation.nav_scale_callback) + self._optional_widgets["distance_slider"] = slider_frame + + def pack_threshold_slider(self): + """ Display or hide the threshold slider depending on the current filter mode. For + misaligned faces filter, display the slider. Hide for all other filters. """ + if self._globals.tk_filter_mode.get() == "Misaligned Faces": + self._optional_widgets["distance_slider"].pack(side=tk.LEFT) + else: + self._optional_widgets["distance_slider"].pack_forget() def cycle_filter_mode(self): """ Cycle the navigation mode combo entry """ diff --git a/tools/manual/manual.py b/tools/manual/manual.py index de046f51a8..360f67c9bf 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -422,9 +422,9 @@ def _get_tk_vars(cls): The variable name as key, the variable as value """ retval = dict() - for name in ("frame_index", "transport_index", "face_index"): + for name in ("frame_index", "transport_index", "face_index", "filter_distance"): var = tk.IntVar() - var.set(0) + var.set(10 if name == "filter_distance" else 0) retval[name] = var for name in ("update", "update_active_viewport", "is_zoomed"): var = tk.BooleanVar() @@ -503,6 +503,12 @@ def tk_filter_mode(self): filter mode. """ return self._tk_vars["filter_mode"] + @property + def tk_filter_distance(self): + """ :class:`tkinter.DoubleVar`: The variable holding the currently selected threshold + distance for misaligned filter mode. """ + return self._tk_vars["filter_distance"] + @property def tk_faces_size(self): """ :class:`tkinter.StringVar`: The variable holding the currently selected Faces Viewer diff --git a/tools/sort/cli.py b/tools/sort/cli.py index 79ab4ad9d0..d18c24c5b6 100644 --- a/tools/sort/cli.py +++ b/tools/sort/cli.py @@ -43,15 +43,17 @@ def get_argument_list(): opts=('-s', '--sort-by'), action=Radio, type=str, - choices=("blur", "blur-fft", "face", "face-cnn", "face-cnn-dissim", "face-yaw", "hist", - "hist-dissim", "color-gray", "color-luma", "color-green", "color-orange", - "size"), + choices=("blur", "blur-fft", "distance", "face", "face-cnn", "face-cnn-dissim", + "face-yaw", "hist", "hist-dissim", "color-gray", "color-luma", "color-green", + "color-orange", "size"), dest='sort_method', group=_("sort settings"), default="face", help=_("R|Sort by method. Choose how images are sorted. " "\nL|'blur': Sort faces by blurriness." "\nL|'blur-fft': Sort faces by fft filtered blurriness." + "\nL|'distance' Sort faces by the estimated distance of the alignments from an " + "'average' face. This can be useful for eliminating misaligned faces." "\nL|'face': Use VGG Face to sort by face similarity. This uses a pairwise " "clustering algorithm to check the distances between 512 features on every " "face in your set and order them appropriately." diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 67e2eba8b6..3ef5e51c8a 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -16,7 +16,7 @@ # faceswap imports from lib.serializer import get_serializer_from_filename from lib.align import AlignedFace, DetectedFace -from lib.image import FacesLoader, read_image +from lib.image import FacesLoader, read_image, read_image_meta_batch from lib.utils import FaceswapError from plugins.extract.recognition.vgg_face2_keras import VGGFace2 as VGGFace from plugins.extract.pipeline import Extractor, ExtractMedia @@ -152,6 +152,34 @@ def sort_process(self): logger.info("Done.") # Methods for sorting + def sort_distance(self): + """ Sort by comparison of face landmark points to mean face by average distance of core + landmarks. """ + logger.info("Sorting by average distance of landmarks...") + filenames = [] + distances = [] + filelist = [os.path.join(self._loader.location, fname) + for fname in os.listdir(self._loader.location) + if os.path.splitext(fname)[-1] == ".png"] + for filename, metadata in tqdm(read_image_meta_batch(filelist), + total=len(filelist), + desc="Calculating Distances"): + if not metadata: + msg = ("The images to be sorted do not contain alignment data. Images must have " + "been generated by Faceswap's Extract process.\nIf you are sorting an " + "older faceset, then you should re-extract the faces from your source " + "alignments file to generate this data.") + raise FaceswapError(msg) + alignments = metadata["itxt"]["alignments"] + aligned_face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32")) + filenames.append(filename) + distances.append(aligned_face.average_distance) + + logger.info("Sorting...") + matched_list = list(zip(filenames, distances)) + img_list = sorted(matched_list, key=operator.itemgetter(1)) + return img_list + def sort_blur(self): """ Sort by blur amount """ logger.info("Sorting by estimated image blur...") @@ -176,6 +204,31 @@ def sort_blur_fft(self): logger.info("Sorting...") return sorted(fft_blurs, key=lambda x: x[1], reverse=True) + def sort_color(self): + """ Score by channel average intensity """ + logger.info("Sorting by channel average intensity...") + desired_channel = {'gray': 0, 'luma': 0, 'orange': 1, 'green': 2} + method = self._args.color_method + channel_to_sort = next(v for (k, v) in desired_channel.items() if method.endswith(k)) + filename_list, image_list = self._get_images() + + logger.info("Converting to appropriate colorspace...") + same_size = all(img.size == image_list[0].size for img in image_list) + images = np.array(image_list, dtype='float32')[None, ...] if same_size else image_list + converted_images = self._convert_color(images, same_size, method) + + logger.info("Scoring each image...") + if same_size: + scores = np.average(converted_images[0], axis=(1, 2)) + else: + progress_bar = tqdm(converted_images, desc="Scoring", file=sys.stdout) + scores = np.array([np.average(image, axis=(0, 1)) for image in progress_bar]) + + logger.info("Sorting...") + matched_list = list(zip(filename_list, scores[:, channel_to_sort])) + sorted_file_img_list = sorted(matched_list, key=operator.itemgetter(1), reverse=True) + return sorted_file_img_list + def sort_face(self): """ Sort by identity similarity """ logger.info("Sorting by identity similarity...") @@ -327,31 +380,6 @@ def sort_hist_dissim(self): logger.info("Sorting...") return sorted(img_list, key=lambda x: x[2], reverse=True) - def sort_color(self): - """ Score by channel average intensity """ - logger.info("Sorting by channel average intensity...") - desired_channel = {'gray': 0, 'luma': 0, 'orange': 1, 'green': 2} - method = self._args.color_method - channel_to_sort = next(v for (k, v) in desired_channel.items() if method.endswith(k)) - filename_list, image_list = self._get_images() - - logger.info("Converting to appropriate colorspace...") - same_size = all(img.size == image_list[0].size for img in image_list) - images = np.array(image_list, dtype='float32')[None, ...] if same_size else image_list - converted_images = self._convert_color(images, same_size, method) - - logger.info("Scoring each image...") - if same_size: - scores = np.average(converted_images[0], axis=(1, 2)) - else: - progress_bar = tqdm(converted_images, desc="Scoring", file=sys.stdout) - scores = np.array([np.average(image, axis=(0, 1)) for image in progress_bar]) - - logger.info("Sorting...") - matched_list = list(zip(filename_list, scores[:, channel_to_sort])) - sorted_file_img_list = sorted(matched_list, key=operator.itemgetter(1), reverse=True) - return sorted_file_img_list - def sort_size(self): """ Sort the faces by largest face (in original frame) to smallest """ logger.info("Sorting by original face size...") @@ -372,7 +400,8 @@ def sort_size(self): centering="legacy", is_aligned=True) roi = aligned_face.original_roi - size = ((roi[1][0] - roi[0][0]) ** 2 + (roi[1][1] - roi[0][1]) ** 2) ** 0.5 + size = ((roi[1][0] - roi[0][0]) ** 2 + # pylint:disable=unsubscriptable-object + (roi[1][1] - roi[0][1]) ** 2) ** 0.5 # pylint:disable=unsubscriptable-object img_list.append((filename, size)) logger.info("Sorting...") From c9a9361793232fe8bfbf05fd69ea1bce65778ccc Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 9 Jun 2021 10:51:09 +0100 Subject: [PATCH 493/981] bugfix: sort tool - fix group by --- tools/sort/sort.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 3ef5e51c8a..3559d3f82c 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -664,7 +664,7 @@ def reload_images(self, group_method, img_list): filename_list, image_list = self._get_images() blurs = [self.estimate_blur(img) for img in image_list] temp_list = list(zip(filename_list, blurs)) - if group_method == 'group_blur_fft': + elif group_method == 'group_blur_fft': filename_list, image_list = self._get_images() fft_blurs = [self.estimate_blur_fft(img) for img in image_list] temp_list = list(zip(filename_list, fft_blurs)) From 1763c4438370756b6bf780fb3d8336d95b36f358 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 10 Jun 2021 10:20:17 +0100 Subject: [PATCH 494/981] Bugfix - Extract - Set fallback scaling amount for parallel plugins --- plugins/extract/pipeline.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 50498e3f1b..60684cccc0 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -96,6 +96,8 @@ def __init__(self, detector, aligner, masker, configfile=None, multiprocess=Fals # We only ever need 1 item in each queue. This is 2 items cached (1 in queue 1 waiting # for queue) at each point. Adding more just stacks RAM with no speed benefit. self._queue_size = 1 + # TODO Calculate scaling for more plugins than currently exist in _parallel_scaling + self._scaling_fallback = 0.4 self._vram_stats = self._get_vram_stats() self._detect = self._load_detect(detector, rotate_images, min_size, configfile) self._align = self._load_align(aligner, configfile, normalize_method, re_feed) @@ -298,7 +300,7 @@ def _total_vram_required(self): logger.debug("VRAM requirements: %s. Plugins requiring VRAM: %s", vrams, vram_required_count) retval = (sum(vrams.values()) * - self._parallel_scaling[vram_required_count]) + self._parallel_scaling.get(vram_required_count, self._scaling_fallback)) logger.debug("Total VRAM required: %s", retval) return retval @@ -478,7 +480,7 @@ def _set_phases(self, multiprocess): for phase in self._flow: num_plugins = len([p for p in current_phase if self._vram_per_phase[p] > 0]) num_plugins += 1 if self._vram_per_phase[phase] > 0 else 0 - scaling = self._parallel_scaling[num_plugins] + scaling = self._parallel_scaling.get(num_plugins, self._scaling_fallback) required = sum(self._vram_per_phase[p] for p in current_phase + [phase]) * scaling logger.debug("Num plugins for phase: %s, scaling: %s, vram required: %s", num_plugins, scaling, required) @@ -583,8 +585,8 @@ def _set_extractor_batchsize(self): batch_required = sum([plugin.vram_per_batch * plugin.batchsize for plugin in self._active_plugins]) gpu_plugins = [p for p in self._current_phase if self._vram_per_phase[p] > 0] - plugins_required = sum([self._vram_per_phase[p] - for p in gpu_plugins]) * self._parallel_scaling[len(gpu_plugins)] + scaling = self._parallel_scaling.get(len(gpu_plugins), self._scaling_fallback) + plugins_required = sum([self._vram_per_phase[p] for p in gpu_plugins]) * scaling if plugins_required + batch_required <= self._vram_stats["vram_free"]: logger.debug("Plugin requirements within threshold: (plugins_required: %sMB, " "vram_free: %sMB)", plugins_required, self._vram_stats["vram_free"]) From 44aaac09d7d4306b0e478d47358e31e6604a6c7d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 10 Jun 2021 16:22:10 +0100 Subject: [PATCH 495/981] Bugfixes - Manual Tool - Update total frames count on a face count change - Correct navigation position when decrementing frame after edit changes the filter criteria --- tools/manual/detected_faces.py | 17 +++++++++ tools/manual/frameviewer/control.py | 58 ++++++++++------------------- 2 files changed, 37 insertions(+), 38 deletions(-) diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index d716d93695..d4b08fe2f3 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -437,6 +437,23 @@ def __init__(self, detected_faces): self._detected_faces = detected_faces logger.debug("Initialized %s", self.__class__.__name__) + @property + def frame_meets_criteria(self): + """ bool: ``True`` if the current frame meets the selected filter criteria otherwise + ``False`` """ + filter_mode = self._globals.filter_mode + frame_faces = self._detected_faces.current_faces[self._globals.frame_index] + distance = self._filter_distance + retval = ( + filter_mode == "All Frames" or + (filter_mode == "No Faces" and not frame_faces) or + (filter_mode == "Has Faces" and frame_faces) or + (filter_mode == "Multiple Faces" and len(frame_faces) > 1) or + (filter_mode == "Misaligned Faces" and any(face.aligned.average_distance > distance + for face in frame_faces))) + logger.trace("filter_mode: %s, frame meets criteria: %s", filter_mode, retval) + return retval + @property def _filter_distance(self): """ float: The currently selected distance when Misaligned Faces filter is selected. """ diff --git a/tools/manual/frameviewer/control.py b/tools/manual/frameviewer/control.py index afca5f9a0c..54737783b3 100644 --- a/tools/manual/frameviewer/control.py +++ b/tools/manual/frameviewer/control.py @@ -30,6 +30,7 @@ def __init__(self, display_frame): self._nav = display_frame._nav self._tk_is_playing = tk.BooleanVar() self._tk_is_playing.set(False) + self._det_faces.tk_face_count_changed.trace("w", self._update_total_frame_count) logger.debug("Initialized %s", self.__class__.__name__) @property @@ -40,11 +41,6 @@ def _current_nav_frame_count(self): def nav_scale_callback(self, *args, reset_progress=True): # pylint:disable=unused-argument """ Adjust transport slider scale for different filters. Hide or display optional filter controls. - - Returns - ------- - bool - ``True`` if the navigation scale has been updated otherwise ``False`` """ self._display_frame.pack_threshold_slider() if reset_progress: @@ -52,9 +48,24 @@ def nav_scale_callback(self, *args, reset_progress=True): # pylint:disable=unus frame_count = self._det_faces.filter.count if self._current_nav_frame_count == frame_count: logger.trace("Filtered count has not changed. Returning") - return False if self._globals.tk_filter_mode.get() == "Misaligned Faces": self._det_faces.tk_face_count_changed.set(True) + self._update_total_frame_count() + if reset_progress: + self._globals.tk_transport_index.set(0) + + def _update_total_frame_count(self, *args): # pylint:disable=unused-argument + """ Update the displayed number of total frames that meet the current filter criteria. + + Parameters + ---------- + args: tuple + Required for tkinter trace callback but unused + """ + frame_count = self._det_faces.filter.count + if self._current_nav_frame_count == frame_count: + logger.trace("Filtered count has not changed. Returning") + return max_frame = max(0, frame_count - 1) logger.debug("Filtered frame count has changed. Updating from %s to %s", self._current_nav_frame_count, frame_count) @@ -62,9 +73,6 @@ def nav_scale_callback(self, *args, reset_progress=True): # pylint:disable=unus self._nav["label"].config(text="/{}".format(max_frame)) state = "disabled" if max_frame == 0 else "normal" self._nav["entry"].config(state=state) - if reset_progress: - self._globals.tk_transport_index.set(0) - return True @property def tk_is_playing(self): @@ -90,7 +98,7 @@ def increment_frame(self, frame_count=None, is_playing=False): if not is_playing: self.stop_playback() position = self._get_safe_frame_index() - face_count_change = self._check_face_count_change() + face_count_change = not self._det_faces.filter.frame_meets_criteria if face_count_change: position -= 1 frame_count = self._det_faces.filter.count if frame_count is None else frame_count @@ -104,11 +112,9 @@ def decrement_frame(self): """ Update The frame navigation position to the previous frame based on filter. """ self.stop_playback() position = self._get_safe_frame_index() - face_count_change = self._check_face_count_change() - if face_count_change: - position += 1 + face_count_change = not self._det_faces.filter.frame_meets_criteria if not face_count_change and (self._det_faces.filter.count == 0 or position == 0): - logger.debug("End of Stream. Not incrementing") + logger.debug("End of Stream. Not decrementing") return self._globals.tk_transport_index.set(min(max(0, self._det_faces.filter.count - 1), max(0, position - 1))) @@ -133,30 +139,6 @@ def _get_safe_frame_index(self): self._globals.tk_transport_index.set(retval) return retval - def _check_face_count_change(self): - """ Check whether the face count for the current filter has changed, and update the - transport scale appropriately. - - Perform additional check on whether the current frame still meets the selected navigation - mode filter criteria. - - Returns - ------- - bool - ``True`` if the currently active frame no longer meets the filter criteria otherwise - ``False`` - """ - filter_mode = self._globals.filter_mode - if filter_mode not in ("No Faces", "Multiple Faces"): - return False - if not self.nav_scale_callback(reset_progress=False): - return False - face_count = len(self._det_faces.current_faces[self._globals.frame_index]) - if (filter_mode == "No Faces" and face_count != 0) or (filter_mode == "Multiple Faces" - and face_count < 2): - return True - return False - def goto_first_frame(self): """ Go to the first frame that meets the filter criteria. """ self.stop_playback() From cbf64decc991cc6eeadac885a867c8e585d11e31 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 10 Jun 2021 19:39:12 +0100 Subject: [PATCH 496/981] Typofix --- tools/manual/detected_faces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index d4b08fe2f3..d549386897 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -447,7 +447,7 @@ def frame_meets_criteria(self): retval = ( filter_mode == "All Frames" or (filter_mode == "No Faces" and not frame_faces) or - (filter_mode == "Has Faces" and frame_faces) or + (filter_mode == "Has Face(s)" and frame_faces) or (filter_mode == "Multiple Faces" and len(frame_faces) > 1) or (filter_mode == "Misaligned Faces" and any(face.aligned.average_distance > distance for face in frame_faces))) From fb6f576e6daa53a1cc69ec6decb456f9104d77a4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 11 Jun 2021 15:50:27 +0100 Subject: [PATCH 497/981] Bugfix - Manual Tool - Fix non-appearing landmark annotations in face viewer --- tools/manual/faceviewer/frame.py | 3 +- tools/manual/faceviewer/viewport.py | 69 +++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/tools/manual/faceviewer/frame.py b/tools/manual/faceviewer/frame.py index c2a6438359..3c07b364b0 100644 --- a/tools/manual/faceviewer/frame.py +++ b/tools/manual/faceviewer/frame.py @@ -530,7 +530,8 @@ def _visible_row_indices(self): height = self.dimensions[1] visible = (max(0, floor(height * self._canvas.yview()[0]) - self._face_size), ceil(height * self._canvas.yview()[1])) - logger.trace("visible: %s", visible) + logger.trace("height: %s, yview: %s, face_size: %s, visible: %s", + height, self._canvas.yview(), self._face_size, visible) y_points = self._grid[3, :, 1] top = np.searchsorted(y_points, visible[0], side="left") bottom = np.searchsorted(y_points, visible[1], side="right") diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index 5018dc36f6..010d8aa42b 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -192,7 +192,7 @@ def _update_viewport(self, refresh_annotations): or frame_idx == self._active_frame.frame_index or refresh_annotations): landmarks = self.get_landmarks(frame_idx, face_idx, face, top_left, - refresh=refresh_annotations) + refresh=True) self._locate_mesh(mesh_ids, landmarks) def _discard_tk_faces(self): @@ -351,7 +351,7 @@ def face_from_point(self, point_x, point_y): If the given coordinates are not over a face, then the frame and face indices will be -1 """ - if point_x > self._grid.dimensions[0]: + if not self._grid.is_valid or point_x > self._grid.dimensions[0]: retval = np.array((-1, -1, -1, -1)) else: x_idx = np.searchsorted(self._objects.visible_grid[2, 0, :], point_x, side="left") - 1 @@ -436,7 +436,11 @@ def meshes(self): def _top_left(self): """ :class:`numpy.ndarray`: The canvas (`x`, `y`) position of the face currently in the viewable area's top left position. """ - return np.array(self._canvas.coords(self._images[0][0]), dtype="int") + if self._images is None or not np.any(self._images): + retval = [0, 0] + else: + retval = self._canvas.coords(self._images[0][0]) + return np.array(retval, dtype="int") def update(self): """ Load and unload thumbnails in the visible area of the faces viewer. """ @@ -446,33 +450,42 @@ def update(self): self._visible_grid, self._visible_faces = self._grid.visible_area if (isinstance(self._images, np.ndarray) and isinstance(self._visible_grid, np.ndarray) and self._visible_grid.shape[-1] != self._images.shape[-1]): - self._recycle_objects() + self._reset_viewport() required_rows = self._visible_grid.shape[1] if self._grid.is_valid else 0 existing_rows = len(self._images) logger.trace("existing_rows: %s. required_rows: %s", existing_rows, required_rows) if existing_rows > required_rows: - for image_id, mesh_ids in zip(self._images[required_rows: existing_rows].flatten(), - self._meshes[required_rows: existing_rows].flatten()): - logger.trace("Hiding image id: %s", image_id) - self._canvas.itemconfig(image_id, image="") - for ids in mesh_ids.values(): - for mesh_id in ids: - self._canvas.itemconfig(mesh_id, state="hidden") - + self._remove_rows(existing_rows, required_rows) if existing_rows < required_rows: self._add_rows(existing_rows, required_rows) self._shift() - def _recycle_objects(self): - """ On a column count change, place all existing objects into the recycle bin so that - they can be used for the new grid shape and reset the objects size to the new size. """ + def _reset_viewport(self): + """ Reset all objects in the viewport on a column count change. Reset the viewport size + to the newly specified face size. """ + logger.debug("Resetting Viewport") self._size = self._viewport.face_size images = self._images.flatten().tolist() meshes = self._meshes.flatten().tolist() + self._recycle_objects(images, meshes) + self._images = [] + self._meshes = [] + + def _recycle_objects(self, images, meshes): + """ Reset the visible property and position of the given objects and add to the recycle + bin. + Parameters + --------- + images: list + List of image_ids to be recycled + meshes: list + List of dictionaries containing the mesh annotation ids to be recycled + """ + logger.debug("Recycling objects: (images: %s, meshes: %s)", len(images), len(meshes)) for image_id in images: self._canvas.itemconfig(image_id, image="") self._canvas.coords(image_id, 0, 0) @@ -486,8 +499,24 @@ def _recycle_objects(self): self._recycled["meshes"].extend(meshes) logger.trace("Recycled objects: %s", self._recycled) - self._images = [] - self._meshes = [] + def _remove_rows(self, existing_rows, required_rows): + """ Remove and recycle rows from the viewport that are not in the view area. + + Parameters + ---------- + existing_rows: int + The number of existing rows within the viewport + required_rows: int + The number of rows required by the viewport + """ + logger.debug("Removing rows from viewport: (existing_rows: %s, required_rows: %s)", + existing_rows, required_rows) + self._recycle_objects(self._images[required_rows: existing_rows].flatten().tolist(), + self._meshes[required_rows: existing_rows].flatten().tolist()) + self._images = self._images[:required_rows] + self._meshes = self._meshes[:required_rows] + logger.trace("self._images: %s, self._meshes: %s", + self._images.shape, self._meshes.shape) def _add_rows(self, existing_rows, required_rows): """ Add rows to the viewport. @@ -499,12 +528,14 @@ def _add_rows(self, existing_rows, required_rows): required_rows: int The number of rows required by the viewport """ + logger.debug("Adding rows to viewport: (existing_rows: %s, required_rows: %s)", + existing_rows, required_rows) columns = self._grid.columns_rows[0] if not isinstance(self._images, np.ndarray): base_coords = [(col * self._size, 0) for col in range(columns)] else: base_coords = [self._canvas.coords(item_id) for item_id in self._images[0]] - logger.debug("existing rows: %s, required_rows: %s, base_coords: %s", + logger.trace("existing rows: %s, required_rows: %s, base_coords: %s", existing_rows, required_rows, base_coords) images = [] meshes = [] @@ -526,7 +557,7 @@ def _add_rows(self, existing_rows, required_rows): images.shape, meshes.shape) self._images = np.concatenate((self._images, images)) self._meshes = np.concatenate((self._meshes, meshes)) - logger.debug("self._images: %s, self._meshes: %s", self._images.shape, self._meshes.shape) + logger.trace("self._images: %s, self._meshes: %s", self._images.shape, self._meshes.shape) def _get_image(self, coordinates): """ Create or recycle a tkinter canvas image object with the given coordinates. From 104a549abb36bb702c997a9aa023964f4cc149e4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 11 Jun 2021 19:26:13 +0100 Subject: [PATCH 498/981] Bugfix - Manual Tool - Fix bug when changing filter modes from a filter with no matches --- tools/manual/faceviewer/viewport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index 010d8aa42b..8c566f9a97 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -449,7 +449,7 @@ def update(self): self._visible_grid, self._visible_faces = self._grid.visible_area if (isinstance(self._images, np.ndarray) and isinstance(self._visible_grid, np.ndarray) - and self._visible_grid.shape[-1] != self._images.shape[-1]): + and self._visible_grid.shape[1:] != self._images.shape): self._reset_viewport() required_rows = self._visible_grid.shape[1] if self._grid.is_valid else 0 From 0775245d2959f0b107fa07cba86d26aff7327f72 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 12 Jun 2021 17:01:02 +0100 Subject: [PATCH 499/981] Bugfix - Manual Tool - Fix bug when adding new face with "misaligned" filter applied --- tools/manual/detected_faces.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index d549386897..bc763ae5ce 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -608,6 +608,7 @@ def add(self, frame_index, pnt_x, width, pnt_y, height): face_index = len(faces) - 1 self.bounding_box(frame_index, face_index, pnt_x, width, pnt_y, height, aligner="cv2-dnn") + face.load_aligned(None) self._tk_face_count_changed.set(True) def delete(self, frame_index, face_index): From 55bb7236fac65b06f8300cfac2dff3e709ca2467 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 15 Jun 2021 21:59:31 +0100 Subject: [PATCH 500/981] New Model: Phaze-A --- plugins/train/model/phaze_a.py | 1027 +++++++++++++++++++++++ plugins/train/model/phaze_a_defaults.py | 604 +++++++++++++ 2 files changed, 1631 insertions(+) create mode 100644 plugins/train/model/phaze_a.py create mode 100644 plugins/train/model/phaze_a_defaults.py diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py new file mode 100644 index 0000000000..49d11225b5 --- /dev/null +++ b/plugins/train/model/phaze_a.py @@ -0,0 +1,1027 @@ +#!/usr/bin/env python3 +""" Phaze-A Model by TorzDF with thanks to BirbFakes and the myriad of testers. """ + +import numpy as np +import tensorflow as tf + +import keras.backend as K +from keras import applications as kapp +from keras.layers import ( + Add, BatchNormalization, Concatenate, Dense, Dropout, Flatten, GaussianNoise, + GlobalAveragePooling2D, GlobalMaxPooling2D, Input, LeakyReLU, Reshape, UpSampling2D, + Conv2D as KConv2D) +from keras.models import clone_model + +from lib.model.nn_blocks import ( + Conv2D, Conv2DBlock, Conv2DOutput, ResidualBlock, UpscaleBlock, Upscale2xBlock, + UpscaleResizeImagesBlock) +from lib.model.normalization import ( + AdaInstanceNormalization, GroupNormalization, InstanceNormalization, LayerNormalization, + RMSNormalization) + +from lib.utils import get_backend, FaceswapError + +from ._base import KerasModel, ModelBase, logger, _get_all_sub_models + + +_MODEL_MAPPING = dict( + densenet121=dict( + keras_name="DenseNet121", default_size=224), + densenet169=dict( + keras_name="DenseNet169", default_size=224), + densenet201=dict( + keras_name="DenseNet201", default_size=224), + efficientnet_b0=dict( + keras_name="EfficientNetB0", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=224), + efficientnet_b1=dict( + keras_name="EfficientNetB1", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=240), + efficientnet_b2=dict( + keras_name="EfficientNetB2", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=260), + efficientnet_b3=dict( + keras_name="EfficientNetB3", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=300), + efficientnet_b4=dict( + keras_name="EfficientNetB4", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=380), + efficientnet_b5=dict( + keras_name="EfficientNetB5", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=456), + efficientnet_b6=dict( + keras_name="EfficientNetB6", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=528), + efficientnet_b7=dict( + keras_name="EfficientNetB7", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=600), + inception_resnet_v2=dict( + keras_name="InceptionResNetV2", scaling=(-1, 1), min_size=75, default_size=299), + inception_v3=dict( + keras_name="InceptionV3", scaling=(-1, 1), min_size=75, default_size=299), + mobilenet=dict( + keras_name="MobileNet", scaling=(-1, 1), default_size=224), + mobilenet_v2=dict( + keras_name="MobileNetV2", scaling=(-1, 1), default_size=224), + nasnet_large=dict( + keras_name="NASNetLarge", scaling=(-1, 1), default_size=331, enforce_for_weights=True), + nasnet_mobile=dict( + keras_name="NASNetMobile", scaling=(-1, 1), default_size=224, enforce_for_weights=True), + resnet50=dict( + keras_name="ResNet50", scaling=(-1, 1), min_size=32, default_size=224), + resnet50_v2=dict( + keras_name="ResNet50V2", no_amd=True, scaling=(-1, 1), default_size=224), + resnet101=dict( + keras_name="ResNet101", no_amd=True, scaling=(-1, 1), default_size=224), + resnet101_v2=dict( + keras_name="ResNet101V2", no_amd=True, scaling=(-1, 1), default_size=224), + resnet152=dict( + keras_name="ResNet152", no_amd=True, scaling=(-1, 1), default_size=224), + resnet152_v2=dict( + keras_name="ResNet152V2", no_amd=True, scaling=(-1, 1), default_size=224), + vgg16=dict( + keras_name="VGG16", color_order="bgr", scaling=(0, 255), default_size=224), + vgg19=dict( + keras_name="VGG19", color_order="bgr", scaling=(0, 255), default_size=224), + xception=dict( + keras_name="Xception", scaling=(-1, 1), min_size=71, default_size=299), + fs_original=dict( + color_order="bgr", min_size=32, default_size=160)) + + +class Model(ModelBase): + """ Phaze-A Faceswap Model. + + An highly adaptable and configurable model by torzDF + + Parameters + ---------- + args: varies + The default command line arguments passed in from :class:`~scripts.train.Train` or + :class:`~scripts.train.Convert` + kwargs: varies + The default keyword arguments passed in from :class:`~scripts.train.Train` or + :class:`~scripts.train.Convert` + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.config["output_size"] % 64 != 0: + raise FaceswapError("Phaze-A output shape must be a multiple of 64") + + self._validate_encoder_architecture() + self.config["freeze_layers"] = self._select_freeze_layers() + + self.input_shape = self._get_input_shape() + self.color_order = _MODEL_MAPPING[self.config["enc_architecture"]].get("color_order", + "rgb") + + def build(self): + """ Build the model and assign to :attr:`model`. + + Override's the default build function for allowing the setting of dropout rate for pre- + existing models. + """ + is_summary = hasattr(self._args, "summary") and self._args.summary + if not self._io.model_exists or self._is_predict or is_summary: + logger.debug("New model, inference or summary. Falling back to default build: " + "(exists: %s, inference: %s, is_summary: %s)", + self._io.model_exists, self._is_predict, is_summary) + super().build() + return + with self._settings.strategy_scope(): + model = self._io._load() # pylint:disable=protected-access + model = self._update_dropouts(model) + self._model = model + self._compile_model() + self._output_summary() + + def _update_dropouts(self, model): + """ Update the saved model with new dropout rates. + + Keras, annoyingly, does not actually change the dropout of the underlying layer, so we need + to update the rate, then clone the model into a new model and reload weights. + + Parameters + ---------- + model: :class:`keras.models.Model` + The loaded saved Keras Model to update the dropout rates for + + Returns + ------- + :class:`keras.models.Model` + The loaded Keras Model with the dropout rates updated + """ + dropouts = dict(fc=self.config["fc_dropout"], + gblock=self.config["fc_gblock_dropout"]) + logger.debug("Config dropouts: %s", dropouts) + updated = False + for mod in _get_all_sub_models(model): + if not mod.name.startswith("fc_"): + continue + key = "gblock" if "gblock" in mod.name else mod.name.split("_")[0] + rate = dropouts[key] + log_once = False + for layer in mod.layers: + if not isinstance(layer, Dropout): + continue + if layer.rate != rate: + logger.debug("Updating dropout rate for %s from %s to %s", + f"{mod.name} - {layer.name}", layer.rate, rate) + if not log_once: + logger.info("Updating Dropout Rate for '%s' from %s to %s", + mod.name, layer.rate, rate) + log_once = True + layer.rate = rate + updated = True + if updated: + logger.debug("Dropout rate updated. Cloning model") + new_model = clone_model(model) + new_model.set_weights(model.get_weights()) + del model + model = new_model + return model + + def _select_freeze_layers(self): + """ Process the selected frozen layers and replace the `keras_encoder` option with the + actual keras model name + + Returns + ------- + list + The selected layers for weight freezing + """ + layers = self.config["freeze_layers"] + keras_name = _MODEL_MAPPING[self.config["enc_architecture"]].get("keras_name") + + if "keras_encoder" not in self.config["freeze_layers"]: + retval = layers + elif keras_name: + retval = [layer.replace("keras_encoder", keras_name.lower()) for layer in layers] + logger.debug("Substituting 'keras_encoder' for '%s'", self.config["enc_architecture"]) + else: + retval = [layer for layer in layers if layer != "keras_encoder"] + logger.debug("Removing 'keras_encoder' for '%s'", self.config["enc_architecture"]) + return retval + + def _get_input_shape(self): + """ Obtain the input shape for the model. + + Input shape is calculated from the selected Encoder's input size, scaled to the user + selected Input Scaling, rounded down to the nearest 16 pixels. + + Returns + ------- + tuple + The shape tuple for the input size to the Phaze-A model + """ + size = _MODEL_MAPPING[self.config["enc_architecture"]]["default_size"] + min_size = _MODEL_MAPPING[self.config["enc_architecture"]].get("min_size", 32) + scaling = self.config["enc_scaling"] / 100 + size = int(max(min_size, min(size, ((size * scaling) // 16) * 16))) + retval = (size, size, 3) + logger.debug("Encoder input set to: %s", retval) + return retval + + def _validate_encoder_architecture(self): + """ Validate that the requested architecture is a valid choice for the running system + configuration. + + If the selection is not valid, an error is logged and system exits. + """ + arch = self.config["enc_architecture"].lower() + model = _MODEL_MAPPING.get(arch) + if not model: + raise FaceswapError(f"'{arch}' is not a valid choice for encoder architecture. Choose " + f"one of {list(_MODEL_MAPPING.keys())}.") + + if get_backend() == "amd" and model.get("no_amd"): + valid = [x for x in _MODEL_MAPPING if not _MODEL_MAPPING[x].get('no_amd')] + raise FaceswapError(f"'{arch}' is not compatible with the AMD backend. Choose one of " + f"{valid}.") + + tf_ver = float(".".join(tf.__version__.split(".")[:2])) # pylint:disable=no-member + tf_min = model.get("tf_min", 2.0) + if get_backend() != "amd" and tf_ver < tf_min: + raise FaceswapError(f"{arch}' is not compatible with your version of Tensorflow. The " + f"minimum version required is {tf_min} whilst you have version " + f"{tf_ver} installed.") + + def build_model(self, inputs): + """ Create the model's structure. + + Parameters + ---------- + inputs: list + A list of input tensors for the model. This will be a list of 2 tensors of + shape :attr:`input_shape`, the first for side "a", the second for side "b". + + Returns + ------- + :class:`keras.models.Model` + The output of this function must be a keras model generated from + :class:`plugins.train.model._base.KerasModel`. See Keras documentation for the correct + structure, but note that parameter :attr:`name` is a required rather than an optional + argument in Faceswap. You should assign this to the attribute ``self.name`` that is + automatically generated from the plugin's filename. + """ + # Create sub-Models + encoders = self._build_encoders(inputs) + inters = self._build_fully_connected(encoders) + g_blocks = self._build_g_blocks(inters) + decoders = self._build_decoders(g_blocks) + + # Create Autoencoder + outputs = [decoders["a"], decoders["b"]] + autoencoder = KerasModel(inputs, outputs, name=self.name) + return autoencoder + + def _build_encoders(self, inputs): + """ Build the encoders for Phaze-A + + Parameters + ---------- + inputs: list + A list of input tensors for the model. This will be a list of 2 tensors of + shape :attr:`input_shape`, the first for side "a", the second for side "b". + + Returns + ------- + dict + side as key ('a' or 'b'), encoder for side as value + """ + encoder = Encoder(self.input_shape, self.config)() + retval = dict(a=encoder(inputs[0]), b=encoder(inputs[1])) + logger.debug("Encoders: %s", retval) + return retval + + def _build_fully_connected(self, inputs): + """ Build the fully connected layers for Phaze-A + + Parameters + ---------- + inputs: dict + The compiled encoder models that act as inputs to the fully connected layers + + Returns + ------- + dict + side as key ('a' or 'b'), fully connected model for side as value + """ + input_shapes = K.int_shape(inputs["a"])[1:] + + if self.config["split_fc"]: + fc_a = FullyConnected("a", input_shapes, self.config)() + inter_a = [fc_a(inputs["a"])] + inter_b = [FullyConnected("b", input_shapes, self.config)()(inputs["b"])] + else: + fc_both = FullyConnected("both", input_shapes, self.config)() + inter_a = [fc_both(inputs["a"])] + inter_b = [fc_both(inputs["b"])] + + if self.config["shared_fc"]: + if self.config["shared_fc"] == "full": + fc_shared = FullyConnected("shared", input_shapes, self.config)() + elif self.config["split_fc"]: + fc_shared = fc_a + else: + fc_shared = fc_both + inter_a = [Concatenate(name="inter_a")([inter_a[0], fc_shared(inputs["a"])])] + inter_b = [Concatenate(name="inter_b")([inter_b[0], fc_shared(inputs["b"])])] + + if self.config["enable_gblock"]: + fc_gblock = FullyConnected("gblock", input_shapes, self.config)() + inter_a.append(fc_gblock(inputs["a"])) + inter_b.append(fc_gblock(inputs["b"])) + + retval = dict(a=inter_a, b=inter_b) + logger.debug("Fully Connected: %s", retval) + return retval + + def _build_g_blocks(self, inputs): + """ Build the g-block layers for Phaze-A. + + If a g-block has not been selected for this model, then the original `inters` models are + returned for passing straight to the decoder + + Parameters + ---------- + inputs: dict + The compiled inter models that act as inputs to the g_blocks + + Returns + ------- + dict + side as key ('a' or 'b'), g-block model for side as value. If g-block has been disabled + then the values will be the fully connected layers + """ + if not self.config["enable_gblock"]: + logger.debug("No G-Block selected, returning Inters: %s", inputs) + return inputs + + input_shapes = [K.int_shape(inter)[1:] for inter in inputs["a"]] + if self.config["split_gblock"]: + retval = dict(a=GBlock("a", input_shapes, self.config)()(inputs["a"]), + b=GBlock("b", input_shapes, self.config)()(inputs["b"])) + else: + g_block = GBlock("both", input_shapes, self.config)() + retval = dict(a=g_block((inputs["a"])), b=g_block((inputs["b"]))) + + logger.debug("G-Blocks: %s", retval) + return retval + + def _build_decoders(self, inputs): + """ Build the encoders for Phaze-A + + Parameters + ---------- + inputs: dict + A dict of inputs to the decoder. This will either be g-block output (if g-block is + enabled) or fully connected layers output (if g-block is disabled). + + Returns + ------- + dict + side as key ('a' or 'b'), decoder for side as value + """ + input_ = inputs["a"] + # If input is inters, shapes will be a list. + # There will only ever be 1 input. For inters: either inter out, or concatenate of inters + # For g-block, this only ever has one output + input_ = input_[0] if isinstance(input_, list) else input_ + input_shape = K.int_shape(input_)[1:] + + if self.config["split_decoders"]: + retval = dict(a=Decoder("a", input_shape, self.config)()(inputs["a"]), + b=Decoder("b", input_shape, self.config)()(inputs["b"])) + else: + decoder = Decoder("both", input_shape, self.config)() + retval = dict(a=decoder(inputs["a"]), b=decoder(inputs["b"])) + + logger.debug("Decoders: %s", retval) + return retval + + +def _bottleneck(inputs, bottleneck, size, normalization): + """ The bottleneck fully connected layer. Can be called from Encoder or FullyConnected layers. + + Parameters + ---------- + inputs: tensor + The input to the bottleneck layer + bottleneck: str + The type of layer to use for the bottleneck + size: int + The number of nodes for the dense layer (if selected) + normalization: str + The normalization method to use prior to the bottleneck layer + + Returns + ------- + tensor + The output from the bottleneck + """ + norms = dict(layer=LayerNormalization, + rms=RMSNormalization, + instance=InstanceNormalization) + bottlenecks = dict(average_pooling=GlobalAveragePooling2D(), + dense=Dense(size), + max_pooling=GlobalMaxPooling2D()) + var_x = inputs + if normalization: + var_x = norms[normalization]()(var_x) + if bottleneck == "dense" and len(K.int_shape(var_x)[1:]) > 1: + # Flatten non-1D inputs for dense bottleneck + var_x = Flatten()(var_x) + var_x = bottlenecks[bottleneck](var_x) + if len(K.int_shape(var_x)[1:]) > 1: + # Flatten prior to fc layers + var_x = Flatten()(var_x) + return var_x + + +def _get_upscale_layer(method, filters, activation=None): + """ Obtain an instance of the requested upscale method. + + Parameters + ---------- + method: str + The user selected upscale method to use + filters: int + The number of filters to use in the upscale layer + activation: str, optional + The activation function to use in the upscale layer. ``None`` to use no activation. + Default: ``None`` + + Returns + ------- + :class:`keras.layers.Layer` + The selected configured upscale layer + """ + if method == "upsample2d": + return UpSampling2D() + if method == "subpixel": + return UpscaleBlock(filters, activation=activation) + if method == "upscale_fast": + return Upscale2xBlock(filters, activation=activation, fast=True) + if method == "upscale_hybrid": + return Upscale2xBlock(filters, activation=activation, fast=False) + return UpscaleResizeImagesBlock(filters, activation=activation) + + +def _get_curve(start_y, end_y, num_points, scale): + """ Obtain a curve. + + For the given start and end y values, return the y co-ordinates of a curve for the given + number of points. The points are rounded down to the nearest 8. + + Parameters + ---------- + start_y: int + The y co-ordinate for the starting point of the curve + end_y: int + The y co-ordinate for the end point of the curve + num_points: int + The number of data points to plot on the x-axis + scale: float + The scale of the curve (from -.99 to 0.99) + + Returns + ------- + list + List of ints of points for the given curve + """ + scale = min(.99, max(-.99, scale)) + logger.debug("Obtaining curve: (start_y: %s, end_y: %s, num_points: %s, scale: %s)", + start_y, end_y, num_points, scale) + x_axis = np.linspace(0., 1., num=num_points) + y_axis = (x_axis - x_axis * scale) / (scale - abs(x_axis) * 2 * scale + 1) + y_axis = y_axis * (end_y - start_y) + start_y + retval = [int((y // 8) * 8) for y in y_axis] + logger.debug("Returning curve: %s", retval) + return retval + + +def _scale_dim(target_resolution, original_dim): + """ Scale a given `original_dim` so that it is a factor of the target resolution. + + Parameters + ---------- + target_resolution: int + The output resolution that is being targetted + original_dim: int + The dimension that needs to be checked for compatibility for upscaling to the + target resolution + + Returns + ------- + int + The highest dimension below or equal to `original_dim` that is a factor of the + target resolution. + """ + new_dim = target_resolution + while new_dim > original_dim: + next_dim = new_dim / 2 + if not next_dim.is_integer(): + break + new_dim = int(next_dim) + logger.debug("target_resolution: %s, original_dim: %s, new_dim: %s", + target_resolution, original_dim, new_dim) + return new_dim + + +class Encoder(): # pylint:disable=too-few-public-methods + """ Encoder. Uses one of pre-existing Keras/Faceswap models or custom encoder. + + Parameters + ---------- + input_shape: tuple + The shape tuple for the input tensor + config: dict + The model configuration options + """ + def __init__(self, input_shape, config): + self.input_shape = input_shape + self._config = config + self._input_shape = input_shape + + @property + def _model_kwargs(self): + """ dict: Configuration option for architecture mapped to optional kwargs. """ + return dict(mobilenet=dict(alpha=self._config["mobilenet_width"], + depth_multiplier=self._config["mobilenet_depth"], + dropout=self._config["mobilenet_dropout"]), + mobilenet_v2=dict(alpha=self._config["mobilenet_width"])) + + @property + def _selected_model(self): + """ dict: The selected encoder model options dictionary """ + arch = self._config["enc_architecture"] + model = _MODEL_MAPPING.get(arch) + model["kwargs"] = self._model_kwargs.get(arch, dict()) + return model + + @property + def _model_input_shape(self): + """ tuple: The required input shape for the encoder model. + + Notes + ----- + NasNet does not allow custom input sizes when loading pre-trained weights, so we need to + resize the input for this model + """ + default_size = self._selected_model.get("default_size") + if self._config["enc_load_weights"] and self._selected_model.get("enforce_for_weights"): + retval = (default_size, default_size, 3) + else: + retval = self._input_shape + return retval + + def __call__(self): + """ Create the Phaze-A Encoder Model. + + Returns + ------- + :class:`keras.models.Model` + The selected Encoder Model + """ + input_ = Input(shape=self._model_input_shape) + var_x = input_ + + if self._input_shape != self._model_input_shape: + var_x = self._resize_inputs(var_x) + + scaling = self._selected_model.get("scaling") + if scaling: + # Some models expect different scaling. + logger.debug("Scaling to %s for '%s'", scaling, self._config["enc_architecture"]) + if scaling == (0, 255): + # models expecting inputs from 0-255. + var_x = var_x * 255. + if scaling == (-1, 1): + # models expecting inputs from -1-1. + var_x = var_x * (1. / 2.) + var_x = var_x - 1.0 + + var_x = self._get_encoder_model()(var_x) + + if self._config["bottleneck_in_encoder"]: + var_x = _bottleneck(var_x, + self._config["bottleneck_type"], + self._config["bottleneck_size"], + self._config["bottleneck_norm"]) + + return KerasModel(input_, var_x, name="encoder") + + def _resize_inputs(self, inputs): + """ Some models (specifically NasNet) need a specific input size when loading trained + weights. This is slightly hacky, but arbitrarily resize the input for these instances. + + Parameters + ---------- + inputs: tensor + The input tensor to be resized + + Returns + ------- + tensor + The resized input tensor + """ + input_size = self._input_shape[0] + new_size = self._model_input_shape[0] + logger.debug("Resizing input for encoder: '%s' from %s to %s due to trained weights usage", + self._config["enc_architecture"], input_size, new_size) + scale = new_size / input_size + interp = "bilinear" if scale > 1 else "nearest" + return K.resize_images(size=scale, interpolation=interp)(inputs) + + def _get_encoder_model(self): + """ Return the model defined by the selected architecture. + + Parameters + ---------- + input_shape: tuple + The input shape for the model + + Returns + ------- + :class:`keras.Model` + The selected keras model for the chosen encoder architecture + """ + if self._selected_model.get("keras_name"): + kwargs = self._selected_model["kwargs"] + kwargs["input_shape"] = self._model_input_shape + kwargs["include_top"] = False + kwargs["weights"] = "imagenet" if self._config["enc_load_weights"] else None + retval = getattr(kapp, self._selected_model["keras_name"])(**kwargs) + else: + retval = _EncoderFaceswap(self._config) + return retval + + +class _EncoderFaceswap(): # pylint:disable=too-few-public-methods + """ A configurable standard Faceswap encoder based off Original model. + + Parameters + ---------- + config: dict + The model configuration options + """ + def __init__(self, config): + self._config = config + self._type = self._config["enc_architecture"] + self._depth = config[f"{self._type}_depth"] + self._min_filters = config["fs_original_min_filters"] + self._max_filters = config["fs_original_max_filters"] + + def __call__(self, inputs): + """ Call the original Faceswap Encoder + + Parameters + ---------- + inputs: tensor + The input tensor to the Faceswap Encoder + + Returns + ------- + tensor + The output tensor from the Faceswap Encoder + """ + var_x = inputs + filters = self._config["fs_original_min_filters"] + for i in range(self._depth): + var_x = Conv2DBlock(filters, activation="leakyrelu", name=f"fs_enc_convblk_{i}")(var_x) + filters = min(self._config["fs_original_max_filters"], filters * 2) + return var_x + + +class FullyConnected(): # pylint:disable=too-few-public-methods + """ Intermediate Fully Connected layers for Phaze-A Model. + + Parameters + ---------- + side: ["a", "b", "both", "gblock", "shared"] + The side of the model that the fully connected layers belong to. Used for naming + input_shape: tuple + The input shape for the fully connected layers + config: dict + The user configuration dictionary + """ + def __init__(self, side, input_shape, config): + logger.debug("Initializing: %s (side: %s, input_shape: %s)", + self.__class__.__name__, side, input_shape) + self._side = side + self._input_shape = input_shape + self._config = config + self._final_dims = self._config["fc_dimensions"] * (self._config["fc_upsamples"] + 1) + self._prefix = "fc_gblock" if self._side == "gblock" else "fc" + + logger.debug("Initialized: %s (side: %s, min_nodes: %s, max_nodes: %s)", + self.__class__.__name__, self._side, self._min_nodes, self._max_nodes) + + @property + def _min_nodes(self): + """ int: The number of nodes for the first Dense. For non g-block layers this will be the + given minimum filters multiplied by the dimensions squared. For g-block layers, this is the + given value """ + if self._side == "gblock": + return self._config["fc_gblock_min_nodes"] + retval = self._scale_filters(self._config["fc_min_filters"]) + retval = int(retval * self._config["fc_dimensions"] ** 2) + return retval + + @property + def _max_nodes(self): + """ int: The number of nodes for the final Dense. For non g-block layers this will be the + given maximum filters multiplied by the dimensions squared. This number will be scaled down + if the final shape can not be mapped to the requested output size. + + For g-block layers, this is the given config value. + """ + if self._side == "gblock": + return self._config["fc_gblock_max_nodes"] + retval = self._scale_filters(self._config["fc_max_filters"]) + retval = int(retval * self._config["fc_dimensions"] ** 2) + return retval + + def _scale_filters(self, original_filters): + """ Scale the filters to be compatible with the model's selected output size. + + Parameters + ---------- + original_filters: int + The original user selected number of filters + + Returns + ------- + int + The number of filters scaled down for output size + """ + scaled_dim = _scale_dim(self._config["output_size"], self._final_dims) + if scaled_dim == self._final_dims: + logger.debug("filters don't require scaling. Returning: %s", original_filters) + return original_filters + + flat = self._final_dims ** 2 * original_filters + modifier = self._final_dims ** 2 * scaled_dim ** 2 + retval = int((flat // modifier) * modifier) + retval = int(retval / self._final_dims ** 2) + logger.debug("original_filters: %s, scaled_filters: %s", original_filters, retval) + return retval + + def __call__(self): + """ Call the intermediate layer. + + Returns + ------- + :class:`keras.models.Model` + The Fully connected model + """ + input_ = Input(shape=self._input_shape) + var_x = input_ + + node_curve = _get_curve(self._min_nodes, + self._max_nodes, + self._config[f"{self._prefix}_depth"], + self._config[f"{self._prefix}_filter_slope"]) + + if not self._config["bottleneck_in_encoder"]: + var_x = _bottleneck(var_x, + self._config["bottleneck_type"], + self._config["bottleneck_size"], + self._config["bottleneck_norm"]) + + dropout = f"{self._prefix}_dropout" + for idx, nodes in enumerate(node_curve): + var_x = Dropout(self._config[dropout], name=f"{dropout}_{idx + 1}")(var_x) + var_x = Dense(nodes)(var_x) + + if self._side != "gblock": + dim = self._config["fc_dimensions"] + upsample_filts = self._scale_filters(self._config["fc_upsample_filters"]) + + var_x = Reshape((dim, dim, int(self._max_nodes / (dim ** 2))))(var_x) + for _ in range(self._config["fc_upsamples"]): + upscaler = _get_upscale_layer(self._config["fc_upsampler"].lower(), + upsample_filts, + activation="leakyrelu") + var_x = upscaler(var_x) + if self._config["fc_upsampler"].lower() == "upsample2d": + var_x = LeakyReLU(alpha=0.1)(var_x) + + return KerasModel(input_, var_x, name="fc_{}".format(self._side)) + + +class GBlock(): # pylint:disable=too-few-public-methods + """ G-Block model, borrowing from Adain StyleGAN. + + Parameters + ---------- + side: ["a", "b", "both"] + The side of the model that the fully connected layers belong to. Used for naming + input_shapes: list or tuple + The shape tuples for the input to the decoder. The first item is the input from each side's + fully connected model, the second item is the input shape from the combined fully connected + model. + config: dict + The user configuration dictionary + """ + def __init__(self, side, input_shapes, config): + logger.debug("Initializing: %s (side: %s, input_shapes: %s)", + self.__class__.__name__, side, input_shapes) + self._side = side + self._config = config + self._inputs = [Input(shape=shape) for shape in input_shapes] + self._dense_nodes = 512 + self._dense_recursions = 3 + logger.debug("Initialized: %s", self.__class__.__name__) + + @classmethod + def _g_block(cls, inputs, style, filters, recursions=2): + """ G_block adapted from ADAIN StyleGAN. + + Parameters + ---------- + inputs: tensor + The input tensor to the G-Block model + style: tensor + The input combined 'style' tensor to the G-Block model + filters: int + The number of filters to use for the G-Block Convolutional layers + recursions: int, optional + The number of recursive Convolutions to process. Default: `2` + + Returns + ------- + tensor + The output tensor from the G-Block model + """ + var_x = inputs + for i in range(recursions): + styles = [Reshape([1, 1, filters])(Dense(filters)(style)) for _ in range(2)] + noise = KConv2D(filters, 1, padding="same")(GaussianNoise(1.0)(var_x)) + + if i == recursions - 1: + var_x = KConv2D(filters, 3, padding="same")(var_x) + + var_x = AdaInstanceNormalization()([var_x, *styles]) + var_x = Add()([var_x, noise]) + var_x = LeakyReLU(0.2)(var_x) + + return var_x + + def __call__(self): + """ G-Block Network. + + Returns + ------- + :class:`keras.models.Model` + The G-Block model + """ + var_x, style = self._inputs + for i in range(self._dense_recursions): + style = Dense(self._dense_nodes, kernel_initializer="he_normal")(style) + if i != self._dense_recursions - 1: # Don't add leakyReLu to final output + style = LeakyReLU(0.1)(style) + + # Scale g_block filters to side dense + g_filts = K.int_shape(var_x)[-1] + var_x = Conv2D(g_filts, 3, strides=1, padding="same")(var_x) + var_x = GaussianNoise(1.0)(var_x) + var_x = self._g_block(var_x, style, g_filts) + return KerasModel(self._inputs, var_x, name=f"g_block_{self._side}") + + +class Decoder(): # pylint:disable=too-few-public-methods + """ Decoder Network. + + Parameters + ---------- + side: ["a", "b", "both"] + The side of the model that the fully connected layers belong to. Used for naming + input_shape: tuple + The shape tuple for the input to the decoder. + config: dict + The user configuration dictionary + """ + def __init__(self, side, input_shape, config): + logger.debug("Initializing: %s (side: %s, input_shape: %s)", + self.__class__.__name__, side, input_shape) + self._side = side + self._input_shape = input_shape + self._config = config + logger.debug("Initialized: %s", self.__class__.__name__,) + + def _reshape_for_output(self, inputs): + """ Reshape the input for arbitrary output sizes. + + The number of filters in the input will have been scaled to the model output size allowing + us to scale the dimensions to the requested output size. + + Parameters + ---------- + inputs: tensor + The tensor that is to be reshaped + + Returns + ------- + tensor + The tensor shaped correctly to upscale to output size + """ + var_x = inputs + old_dim = K.int_shape(inputs)[1] + new_dim = _scale_dim(self._config["output_size"], old_dim) + if new_dim != old_dim: + old_shape = K.int_shape(inputs)[1:] + new_shape = (new_dim, new_dim, np.prod(old_shape) // new_dim ** 2) + logger.debug("Reshaping tensor from %s to %s for output size %s", + K.int_shape(inputs)[1:], new_shape, self._config["output_size"]) + var_x = Reshape(new_shape)(var_x) + return var_x + + def _upscale_block(self, inputs, filters, skip_residual=False, is_mask=False): + """ Upscale block for Phaze-A Decoder. + + Uses requested upscale method, adds requested regularization and activation function. + + Parameters + ---------- + inputs: tensor + The input tensor for the upscale block + filters: int + The number of filters to use for the upscale + skip_residual: bool, optional + ``True`` if a residual block should not be placed in the upscale block, otherwise + ``False``. Default ``False`` + is_mask: bool, optional + ``True`` if the input is a mask. ``False`` if the input is a face. Default: ``False`` + + Returns + ------- + tensor + The output tensor from the upscale block + """ + upscaler = _get_upscale_layer(self._config["dec_upscale_method"].lower(), filters) + + var_x = upscaler(inputs) + if not is_mask and self._config["dec_gaussian"]: + var_x = GaussianNoise(1.0)(var_x) + if not is_mask and self._config["dec_res_blocks"] and not skip_residual: + var_x = self._normalization(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) + for _ in range(self._config["dec_res_blocks"]): + var_x = ResidualBlock(filters)(var_x) + else: + var_x = self._normalization(var_x) + var_x = LeakyReLU(alpha=0.1)(var_x) + return var_x + + def _normalization(self, inputs): + """ Add a normalization layer if requested. + + Parameters + ---------- + inputs: tensor + The input tensor to apply normalization to. + + Returns + -------- + tensor + The tensor with any normalization applied + """ + if not self._config["dec_norm"]: + return inputs + norms = dict(batch=BatchNormalization, + group=GroupNormalization, + instance=InstanceNormalization, + layer=LayerNormalization, + rms=RMSNormalization) + return norms[self._config["dec_norm"]]()(inputs) + + def __call__(self): + """ Decoder Network. + + Returns + ------- + :class:`keras.models.Model` + The Decoder model + """ + inputs = Input(shape=self._input_shape) + var_x = inputs + var_x = self._reshape_for_output(var_x) + + if self._config["learn_mask"]: + var_y = inputs + var_y = self._reshape_for_output(var_y) + + # De-convolve + upscales = int(np.log2(self._config["output_size"] / K.int_shape(var_x)[1])) + filters = _get_curve(self._config["dec_max_filters"], + self._config["dec_min_filters"], + upscales, + self._config["dec_filter_slope"]) + + for idx, filts in enumerate(filters): + skip_res = idx == len(filters) - 1 and self._config["dec_skip_last_residual"] + var_x = self._upscale_block(var_x, filts, skip_residual=skip_res) + if self._config["learn_mask"]: + var_y = self._upscale_block(var_y, filts, is_mask=True) + + outputs = [Conv2DOutput(3, self._config["dec_output_kernel"], name="face_out")(var_x)] + if self._config["learn_mask"]: + outputs.append(Conv2DOutput(1, + self._config["dec_output_kernel"], + name="mask_out")(var_y)) + + return KerasModel(inputs, outputs=outputs, name="decoder_{}".format(self._side)) diff --git a/plugins/train/model/phaze_a_defaults.py b/plugins/train/model/phaze_a_defaults.py new file mode 100644 index 0000000000..5d632f76a1 --- /dev/null +++ b/plugins/train/model/phaze_a_defaults.py @@ -0,0 +1,604 @@ +#!/usr/bin/env python3 +""" + The default options for the faceswap Phaze-A Model plugin. + + Defaults files should be named _defaults.py + Any items placed into this file will automatically get added to the relevant config .ini files + within the faceswap/config folder. + + The following variables should be defined: + _HELPTEXT: A string describing what this plugin does + _DEFAULTS: A dictionary containing the options, defaults and meta information. The + dictionary should be defined as: + {: {}} + + should always be lower text. + dictionary requirements are listed below. + + The following keys are expected for the _DEFAULTS dict: + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid data types are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and data types this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and data types this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. +""" +from lib.utils import get_backend + +_HELPTEXT = ("Phaze-A Model by TorzDF, with thanks to BirbFakes.\n" + "Allows for the experimentation of various standard Networks as the encoder and " + "takes inspiration from Nvidia's StyleGAN for the Decoder. It is highly recommended " + "to research to understand the parameters better.") + +_ENCODERS = ["densenet121", "densenet169", "densenet201", "inception_resnet_v2", "inception_v3", + "mobilenet", "mobilenet_v2", "nasnet_large", "nasnet_mobile", "resnet50", "vgg16", + "vgg19", "xception", "fs_original"] +if get_backend() != "amd": + _ENCODERS.extend(["efficientnet_b0", "efficientnet_b1", "efficientnet_b2", "efficientnet_b3", + "efficientnet_b4", "efficientnet_b5", "efficientnet_b6", "efficientnet_b7", + "resnet50_v2", "resnet101", "resnet101_v2", "resnet152", "resnet152_v2"]) +_ENCODERS = sorted(_ENCODERS) + + +_DEFAULTS = dict( + # General + output_size=dict( + default=128, + info="Resolution (in pixels) of the output image to generate.\n" + "BE AWARE Larger resolution will dramatically increase VRAM requirements.", + datatype=int, + rounding=64, + min_max=(64, 1024), + group="general", + fixed=True), + shared_fc=dict( + default="none", + info="Whether to create a shared fully connected layer. This layer will have the same " + "structure as the fully connected layers used for each side of the model. A shared " + "fully connected layer looks for patterns that are common to both sides. NB: " + "Enabling this option only makes sense if 'split fc' is selected." + "\n\tnone - Do not create a Fully Connected layer for shared data. (Original method)" + "\n\tfull - Create an exclusive Fully Connected layer for shared data. (IAE method)" + "\n\thalf - Use the 'fc_a' layer for shared data. This saves VRAM by re-using the " + "'A' side's fully connected model for the shared data. However, this will lead to " + "an 'unbalanced' model and can lead to more identity bleed (DFL method)", + datatype=str, + choices=["none", "full", "half"], + gui_radio=True, + group="general", + fixed=True), + enable_gblock=dict( + default=True, + info="Whether to enable the G-Block. If enabled, this will create a shared fully " + "connected layer (configurable in the 'G-Block hidden layers' section) to look for " + "patterns in the combined data, before feeding a block prior to the decoder for " + "merging this shared and combined data." + "\n\tTrue - Use the G-Block in the Decoder. A combined fully connected layer will be " + "created to feed this block which can be configured below." + "\n\tFalse - Don't use the G-Block in the decoder. No combined fully connected layer " + "will be created.", + datatype=bool, + group="general", + fixed=True), + split_fc=dict( + default=True, + info="Whether to use a single shared Fully Connected layer or separate Fully Connected " + "layers for each side." + "\n\tTrue - Use separate Fully Connected layers for Face A and Face B. This is more " + "similar to the 'IAE' style of model." + "\n\tFalse - Use combined Fully Connected layers for both sides. This is more " + "similar to the original Faceswap architecture.", + datatype=bool, + group="general", + fixed=True), + split_gblock=dict( + default=False, + info="If the G-Block is enabled, Whether to use a single G-Block shared between both " + "sides, or whether to have a separate G-Block (one for each side). NB: The Fully " + "Connected layer that feeds the G-Block will always be shared." + "\n\tTrue - Use separate G-Blocks for Face A and Face B." + "\n\tFalse - Use a combined G-Block layers for both sides.", + datatype=bool, + group="general", + fixed=True), + split_decoders=dict( + default=False, + info="Whether to use a single decoder or split decoders." + "\n\tTrue - Use a separate decoder for Face A and Face B. This is more similar to " + "the original Faceswap architecture." + "\n\tFalse - Use a combined Decoder. This is more similar to 'IAE' style " + "architecture.", + datatype=bool, + group="general", + fixed=True), + + # Encoder + enc_architecture=dict( + default="fs_original", + info="The encoder architecture to use. See the relevant config sections for specific " + "architecture tweaking.\nNB: For keras based pre-built models, the global " + "initializers and padding options will be ignored for the selected encoder." + "\n\tdensenet: (32px -224px). Ref: Densely Connected Convolutional Networks (2016): " + "https://arxiv.org/abs/1608.06993?source=post_page" + "\n\tefficientnet: [Tensorflow 2.3+ only] EfficientNet has numerous variants (B0 - " + "B8) that increases the model width, depth and dimensional space at each step. The " + "minimum input resolution is 32px for all variants. The maximum input resolution for " + "each variant is: b0: 224px, b1: 240px, b2: 260px, b3: 300px, b4: 380px, b5: 456px, " + "b6: 528px, b7 600px. Ref: Rethinking Model Scaling for Convolutional Neural " + "Networks (2020): https://arxiv.org/abs/1905.11946" + "\n\tfs_original: (32px - 320px). A configurable variant of the original facewap " + "encoder. ImageNet weights cannot be loaded for this model. Additional parameters " + "can be configured with the 'fs_enc' options. A version of this encoder is used in " + "the following models: Original, Original (lowmem), Dfaker, DFL-H128, DFL-SAE, IAE, " + "Lightweight." + "\n\tinception_resnet_v2: (75px - 299px). Ref: Inception-ResNet and the Impact of " + "Residual Connections on Learning (2016): https://arxiv.org/abs/1602.07261" + "\n\tinceptionV3: (75px - 299px). Ref: Rethinking the Inception Architecture for " + "Computer Vision (2015): https://arxiv.org/abs/1512.00567" + "\n\tmobilenet: (32px - 224px). Additional MobileNet parameters can be set with the " + "'mobilenet' options. Ref: MobileNets: Efficient Convolutional Neural Networks for " + "Mobile Vision Applications (2017): https://arxiv.org/abs/1704.04861" + "\n\tmobilenet_v2: (32px - 224px). Additional MobileNet parameters can be set with " + "the 'mobilenet' options. Ref: MobileNetV2: Inverted Residuals and Linear " + "Bottlenecks (2018): https://arxiv.org/abs/1801.04381" + "\n\tnasnet: (32px - 331px (large) or 224px (mobile)). Ref: Learning Transferable " + "Architectures for Scalable Image Recognition (2017): " + "https://arxiv.org/abs/1707.07012" + "\n\tresnet: (32px - 224px). Deep Residual Learning for Image Recognition (2015): " + "https://arxiv.org/abs/1512.03385" + "\n\tvgg: (32px - 224px). Very Deep Convolutional Networks for Large-Scale Image " + "Recognition (2014): https://arxiv.org/abs/1409.1556" + "\n\txception: (71px - 229px). Ref: Deep Learning with Depthwise Separable " + "Convolutions (2017): https://arxiv.org/abs/1409.1556.\n", + datatype=str, + choices=_ENCODERS, + gui_radio=False, + group="encoder", + fixed=True), + enc_scaling=dict( + default=40, + info="Input scaling for the encoder. Some of the encoders have large input sizes, which " + "often are not helpful for Faceswap. This setting scales the dimensional space that " + "the encoder works in. For example an encoder with a maximum input size of 224px " + "will be input an image of 112px at 50%% scaling. See the Architecture tooltip for " + "the minimum and maximum sizes for each encoder.", + datatype=int, + min_max=(0, 100), + rounding=1, + group="encoder", + fixed=True), + enc_load_weights=dict( + default=True, + info="Load pre-trained weights trained on ImageNet data. Only available for non-Faceswap " + "encoders (i.e. those not beginning with 'fs'). NB: If you use the global 'load " + "weights' option and have selected to load weights from a previous model's 'encoder' " + "or 'keras_encoder' then the weights loaded here will be replaced by the weights " + "loaded from your saved model.", + datatype=bool, + group="encoder", + fixed=True), + + # Bottleneck + bottleneck_type=dict( + default="dense", + info="The type of layer to use for the bottleneck." + "\n\taverage_pooling: Use a Global Average Pooling 2D layer for the bottleneck." + "\n\tdense: Use a Dense layer for the bottleneck (the traditional Faceswap method). " + "You can set the size of the Dense layer with the 'bottleneck_size' parameter." + "\n\tmax_pooling: Use a Global Max Pooling 2D layer for the bottleneck.", + datatype=str, + group="bottleneck", + gui_radio=True, + choices=["average_pooling", "dense", "max_pooling"], + fixed=True), + bottleneck_norm=dict( + default="none", + info="Apply a normalization layer after encoder output and prior to the bottleneck." + "\n\tnone - Do not apply a normalization layer" + "\n\tinstance - Apply Instance Normalization" + "\n\tlayer - Apply Layer Normalization (Ba et al., 2016)" + "\n\trms - Apply Root Mean Squared Layer Normalization (Zhang et al., 2019). A " + "simplified version of Layer Normalization with reduced overhead.", + datatype=str, + gui_radio=True, + choices=["none", "instance", "layer", "rms"], + group="bottleneck", + fixed=True), + bottleneck_size=dict( + default=1024, + info="If using a Dense layer for the bottleneck, then this is the number of nodes to use.", + datatype=int, + rounding=128, + min_max=(128, 4096), + group="bottleneck", + fixed=True), + bottleneck_in_encoder=dict( + default=True, + info="Whether to place the bottleneck in the Encoder or to place it with the other hidden " + "layers. Placing the bottleneck in the encoder means that both sides will share the " + "same bottleneck. Placing it with the other fully connected layers means that each " + "fully connected layer will each get their own bottleneck. This may be combined or " + "split depending on your overall architecture configuration settings.", + datatype=bool, + group="bottleneck", + fixed=True), + + # Intermediate Layers + fc_depth=dict( + default=1, + info="The number of consecutive Dense (fully connected) layers to include in each side's " + "intermediate layer.", + datatype=int, + rounding=1, + min_max=(1, 16), + group="hidden layers", + fixed=True), + fc_min_filters=dict( + default=1024, + info="The number of filters to use for the initial fully connected layer. The number of " + "nodes actually used is: fc_min_filters x fc_dimensions x fc_dimensions.\nNB: This " + "value may be scaled down, depending on output resolution.", + datatype=int, + rounding=16, + min_max=(16, 5120), + group="hidden layers", + fixed=True), + fc_max_filters=dict( + default=1024, + info="This is the number of filters to be used in the final reshape layer at the end of " + "the fully connected layers. The actual number of nodes used for the final fully " + "connected layer is: fc_min_filters x fc_dimensions x fc_dimensions.\nNB: This value " + "may be scaled down, depending on output resolution.", + datatype=int, + rounding=128, + min_max=(128, 5120), + group="hidden layers", + fixed=True), + fc_dimensions=dict( + default=4, + info="The height and width dimension for the final reshape layer at the end of the fully " + "connected layers.\nNB: The total number of nodes within the final fully connected " + "layer will be: fc_dimensions x fc_dimensions x fc_max_filters.", + datatype=int, + rounding=1, + min_max=(3, 16), + group="hidden layers", + fixed=True), + fc_filter_slope=dict( + default=-0.5, + info="The rate that the filters move from the minimum number of filters to the maximum " + "number of filters. EG:\n" + "Negative numbers will change the number of filters quicker at first and slow down " + "each layer.\n" + "Positive numbers will change the number of filters slower at first but then speed " + "up each layer.\n" + "0.0 - This will change at a linear rate (i.e. the same number of filters will be " + "changed at each layer).", + datatype=float, + min_max=(-.99, .99), + rounding=2, + group="hidden layers", + fixed=True), + fc_dropout=dict( + default=0.0, + info="Dropout is a form of regularization that can prevent a model from over-fitting and " + "help to keep neurons 'alive'. 0.5 will dropout half the connections between each " + "fully connected layer, 0.25 will dropout a quarter of the connections etc. Set to " + "0.0 to disable.", + datatype=float, + rounding=2, + min_max=(0.0, 0.99), + group="hidden layers", + fixed=False), + fc_upsampler=dict( + default="upsample2d", + info="The type of dimensional upsampling to perform at the end of the fully connected " + "layers, if upsamples > 0. The number of filters used for the upscale layers will be " + "the value given in 'fc_upsample_filters'." + "\n\tupsample2d - A lightweight and VRAM friendly method. 'quick and dirty' but does " + "not learn any parameters" + "\n\tsubpixel - Sub-pixel upscaler using depth-to-space which may require more " + "VRAM." + "\n\tresize_images - Uses the Keras resize_image function to save about half as much " + "vram as the heaviest methods." + "\n\tupscale_fast - Developed by Andenixa. Focusses on speed to upscale, but " + "requires more VRAM." + "\n\tupscale_hybrid - Developed by Andenixa. Uses a combination of PixelShuffler and " + "Upsampling2D to upscale, saving about 1/3rd of VRAM of the heaviest methods.", + datatype=str, + choices=["resize_images", "subpixel", "upscale_fast", "upscale_hybrid", "upsample2d"], + group="hidden layers", + gui_radio=False, + fixed=True), + fc_upsamples=dict( + default=1, + info="Some upsampling can occur within the Fully Connected layers rather than in the " + "Decoder to increase the dimensional space. Set how many upscale layers should occur " + "within the Fully Connected layers.", + datatype=int, + min_max=(0, 4), + rounding=1, + group="hidden layers", + fixed=True), + fc_upsample_filters=dict( + default=512, + info="If you have selected an upsampler which requires filters (i.e. any upsampler with " + "the exception of Upsampling2D), then this is the number of filters to be used for " + "the upsamplers within the fully connected layers, NB: This value may be scaled " + "down, depending on output resolution. Also note, that this figure will dictate the " + "number of filters used for the G-Block, if selected.", + datatype=int, + rounding=128, + min_max=(128, 5120), + group="hidden layers", + fixed=True), + + # G-Block + fc_gblock_depth=dict( + default=3, + info="The number of consecutive Dense (fully connected) layers to include in the G-Block " + "shared layer.", + datatype=int, + rounding=1, + min_max=(1, 16), + group="g-block hidden layers", + fixed=True), + fc_gblock_min_nodes=dict( + default=512, + info="The number of nodes to use for the initial G-Block shared fully connected layer.", + datatype=int, + rounding=128, + min_max=(128, 5120), + group="g-block hidden layers", + fixed=True), + fc_gblock_max_nodes=dict( + default=512, + info="The number of nodes to use for the final G-Block shared fully connected layer.", + datatype=int, + rounding=128, + min_max=(128, 5120), + group="g-block hidden layers", + fixed=True), + fc_gblock_filter_slope=dict( + default=-0.5, + info="The rate that the filters move from the minimum number of filters to the maximum " + "number of filters for the G-Block shared layers. EG:\n" + "Negative numbers will change the number of filters quicker at first and slow down " + "each layer.\n" + "Positive numbers will change the number of filters slower at first but then speed " + "up each layer.\n" + "0.0 - This will change at a linear rate (i.e. the same number of filters will be " + "changed at each layer).", + datatype=float, + min_max=(-.99, .99), + rounding=2, + group="g-block hidden layers", + fixed=True), + fc_gblock_dropout=dict( + default=0.0, + info="Dropout is a regularization technique that can prevent a model from over-fitting " + "and help to keep neurons 'alive'. 0.5 will dropout half the connections between " + "each fully connected layer, 0.25 will dropout a quarter of the connections etc. Set " + "to 0.0 to disable.", + datatype=float, + rounding=2, + min_max=(0.0, 0.99), + group="g-block hidden layers", + fixed=False), + + # Decoder + dec_upscale_method=dict( + default="subpixel", + info="The method to use for the upscales within the decoder. Images are upscaled multiple " + "times within the decoder as the network learns to reconstruct the face." + "\n\tsubpixel - Sub-pixel upscaler using depth-to-space which requires more " + "VRAM." + "\n\tresize_images - Uses the Keras resize_image function to save about half as much " + "vram as the heaviest methods." + "\n\tupscale_fast - Developed by Andenixa. Focusses on speed to upscale, but " + "requires more VRAM." + "\n\tupscale_hybrid - Developed by Andenixa. Uses a combination of PixelShuffler and " + "Upsampling2D to upscale, saving about 1/3rd of VRAM of the heaviest methods.", + datatype=str, + choices=["subpixel", "resize_images", "upscale_fast", "upscale_hybrid"], + gui_radio=True, + group="decoder", + fixed=True), + dec_norm=dict( + default="none", + info="Normalization to apply to apply after each upscale." + "\n\tnone - Do not apply a normalization layer" + "\n\tbatch - Apply Batch Normalization" + "\n\tgroup - Apply Group Normalization" + "\n\tinstance - Apply Instance Normalization" + "\n\tlayer - Apply Layer Normalization (Ba et al., 2016)" + "\n\trms - Apply Root Mean Squared Layer Normalization (Zhang et al., 2019). A " + "simplified version of Layer Normalization with reduced overhead.", + datatype=str, + gui_radio=True, + choices=["none", "batch", "group", "instance", "layer", "rms"], + group="decoder", + fixed=True), + dec_min_filters=dict( + default=64, + info="The minimum number of filters to use in decoder upscalers (i.e. the number of " + "filters to use for the final upscale layer).", + datatype=int, + min_max=(64, 512), + rounding=64, + group="decoder", + fixed=True), + dec_max_filters=dict( + default=512, + info="The maximum number of filters to use in decoder upscalers (i.e. the number of " + "filters to use for the first upscale layer).", + datatype=int, + min_max=(256, 5120), + rounding=128, + group="decoder", + fixed=True), + dec_filter_slope=dict( + default=-0.45, + info="The rate that the filters reduce at each upscale layer. EG:\n" + "Negative numbers will drop the number of filters quicker at first and slow down " + "each upscale.\n" + "Positive numbers will drop the number of filters slower at first but then speed " + "up each upscale.\n" + "0.0 - This will reduce at a linear rate (i.e. the same number of filters will be " + "reduced at each upscale).", + datatype=float, + min_max=(-.99, .99), + rounding=2, + group="decoder", + fixed=True), + dec_res_blocks=dict( + default=1, + info="The number of Residual Blocks to apply to each upscale layer. Set to 0 to disable " + "residual blocks entirely.", + datatype=int, + rounding=1, + min_max=(0, 8), + group="decoder", + fixed=True), + dec_output_kernel=dict( + default=5, + info="The kernel size to apply to the final Convolution layer.", + datatype=int, + rounding=2, + min_max=(1, 9), + group="decoder", + fixed=True), + dec_gaussian=dict( + default=True, + info="Gaussian Noise acts as a regularization technique for preventing overfitting of " + "data." + "\n\tTrue - Apply a Gaussian Noise layer to each upscale." + "\n\tFalse - Don't apply a Gaussian Noise layer to each upscale.", + datatype=bool, + group="decoder", + fixed=True), + dec_skip_last_residual=dict( + default=True, + info="If Residual blocks have been enabled, enabling this option will not apply a " + "Residual block to the final upscaler." + "\n\tTrue - Don't apply a Residual block to the final upscale." + "\n\tFalse - Apply a Residual block to all upscale layers.", + datatype=bool, + group="decoder", + fixed=True), + + # Weight management + freeze_layers=dict( + default="keras_encoder", + info="If the command line option 'freeze-weights' is enabled, then the layers indicated " + "here will be frozen the next time the model starts up. NB: Not all architectures " + "contain all of the layers listed here, so any layers marked for freezing that are " + "not within your chosen architecture will be ignored. EG:\n If 'split fc' has " + "been selected, then 'fc_a' and 'fc_b' are available for freezing. If it has " + "not been selected then 'fc_both' is available for freezing.", + datatype=list, + choices=["encoder", "keras_encoder", "fc_a", "fc_b", "fc_both", "fc_shared", "fc_gblock", + "g_block_a", "g_block_b", "g_block_both", "decoder_a", "decoder_b", + "decoder_both"], + group="weights", + fixed=False), + load_layers=dict( + default="encoder", + info="If the command line option 'load-weights' is populated, then the layers indicated " + "here will be loaded from the given weights file if starting a new model. NB Not all " + "architectures contain all of the layers listed here, so any layers marked for " + "loading that are not within your chosen architecture will be ignored. EG:\n If " + "'split fc' has been selected, then 'fc_a' and 'fc_b' are available for loading. If " + "it has not been selected then 'fc_both' is available for loading.", + datatype=list, + choices=["encoder", "fc_a", "fc_b", "fc_both", "fc_shared", "fc_gblock", "g_block_a", + "g_block_b", "g_block_both", "decoder_a", "decoder_b", "decoder_both"], + group="weights", + fixed=True), + + # # SPECIFIC ENCODER SETTINGS # # + # Faceswap Original + fs_original_depth=dict( + default=4, + info="Faceswap Encoder only: The number of convolutions to perform within the encoder.", + datatype=int, + min_max=(2, 10), + rounding=1, + group="faceswap encoder configuration", + fixed=True), + fs_original_min_filters=dict( + default=128, + info="Faceswap Encoder only: The minumum number of filters to use for encoder " + "convolutions. (i.e. the number of filters to use for the first encoder layer).", + datatype=int, + min_max=(64, 2048), + rounding=64, + group="faceswap encoder configuration", + fixed=True), + fs_original_max_filters=dict( + default=1024, + info="Faceswap Encoder only: The maximum number of filters to use for encoder " + "convolutions. (i.e. the number of filters to use for the final encoder layer).", + datatype=int, + min_max=(256, 8192), + rounding=128, + group="faceswap encoder configuration", + fixed=True), + + # MobileNet + mobilenet_width=dict( + default=1.0, + info="The width multiplier for mobilenet encoders. Controls the width of the " + "network. Values less than 1.0 proportionally decrease the number of filters within " + "each layer. Values greater than 1.0 proportionally increase the number of filters " + "within each layer. 1.0 is the default number of layers used within the paper.\n" + "NB: This option is ignored for any non-mobilenet encoders.\n" + "NB: If loading ImageNet weights, then for mobilenet v1 only values of '0.25', " + "'0.5', '0.75' or '1.0 can be selected. For mobilenet v2 only values of '0.35', " + "'0.50', '0.75', '1.0', '1.3' or '1.4' can be selected", + datatype=float, + min_max=(0.1, 2.0), + rounding=2, + group="mobilenet encoder configuration", + fixed=True), + mobilenet_depth=dict( + default=1, + info="The depth multiplier for mobilenet v1 encoder. This is the depth multiplier " + "for depthwise convolution (known as the resolution multiplier within the original " + "paper).\n" + "NB: This option is only used for mobilenet v1 and is ignored for all other " + "encoders.\n" + "NB: If loading ImageNet weights, this must be set to 1.", + datatype=int, + min_max=(1, 10), + rounding=1, + group="mobilenet encoder configuration", + fixed=True), + mobilenet_dropout=dict( + default=0.001, + info="The dropout rate for for mobilenet v1 encoder.\n" + "NB: This option is only used for mobilenet v1 and is ignored for all other " + "encoders.\n" + "NB: If loading ImageNet weights, this must be set to 1.0.", + datatype=float, + min_max=(0.1, 2.0), + rounding=2, + group="mobilenet encoder configuration", + fixed=True), + ) From ac22d40a91e1afc4b68b7a13b71199a4dea49971 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 22 Jul 2021 13:04:29 +0100 Subject: [PATCH 501/981] extract: mask - Delete any mask from outside of frame boundaries --- lib/align/aligned_face.py | 6 ++-- plugins/extract/mask/_base.py | 55 +++++++++++++++++++++++++++++----- plugins/extract/pipeline.py | 5 ++-- scripts/extract.py | 3 +- tools/alignments/jobs.py | 6 ++-- tools/manual/detected_faces.py | 3 +- tools/mask/mask.py | 10 ++++++- 7 files changed, 70 insertions(+), 18 deletions(-) diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 96dc6c78fd..b5dd48687d 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -451,10 +451,10 @@ def get_cropped_roi(self, centering): with self._cache["cropped_roi"][1]: if centering not in self._cache["cropped_roi"][0]: offset = self.pose.offset.get(centering, np.float32((0, 0))) # legacy = 0.0 - offset -= self.pose.offset["head"] - offset *= (self._head_size - (self._head_size * _EXTRACT_RATIOS["head"])) + adjusted = offset - self.pose.offset["head"] + adjusted *= (self._head_size - (self._head_size * _EXTRACT_RATIOS["head"])) - center = np.rint(offset + self._head_size / 2).astype("int32") + center = np.rint(adjusted + self._head_size / 2).astype("int32") padding = self.size // 2 roi = np.array([center - padding, center + padding]).ravel() logger.trace("centering: '%s', center: %s, padding: %s, sub roi: %s", diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 6467b97f62..6b982bb882 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -18,7 +18,7 @@ from tensorflow.python.framework import errors_impl as tf_errors -from lib.align import AlignedFace +from lib.align import AlignedFace, transform_image from lib.utils import get_backend, FaceswapError from plugins.extract._base import Extractor, ExtractMedia, logger @@ -121,13 +121,34 @@ def get_batch(self, queue): self._queues["out"].put(item) continue for f_idx, face in enumerate(item.detected_faces): + + image = item.get_image_copy(self.color_format) + roi = np.ones((*item.image_size[:2], 1), dtype="float32") + + if not self._image_is_aligned: + # Add the ROI mask to image so we can get the ROI mask with a single warp + image = np.concatenate([image, roi], axis=-1) + feed_face = AlignedFace(face.landmarks_xy, - image=item.get_image_copy(self.color_format), + image=image, centering=self._storage_centering, size=self.input_size, coverage_ratio=self.coverage_ratio, dtype="float32", is_aligned=self._image_is_aligned) + + if not self._image_is_aligned: + # Split roi mask from feed face alpha channel + roi_mask = feed_face.face[..., 3] + feed_face._face = feed_face.face[..., :3] # pylint:disable=protected-access + else: + # We have to do the warp here as AlignedFace did not perform it + roi_mask = transform_image(roi, + feed_face.matrix, + feed_face.size, + padding=feed_face.padding) + + batch.setdefault("roi_masks", []).append(roi_mask) batch.setdefault("detected_faces", []).append(face) batch.setdefault("feed_faces", []).append(feed_face) batch.setdefault("filename", []).append(item.filename) @@ -210,7 +231,7 @@ def finalize(self, batch): ---------- batch : dict The final ``dict`` from the `plugin` process. It must contain the `keys`: - ``detected_faces``, ``filename``, ``feed_faces`` + ``detected_faces``, ``filename``, ``feed_faces``, ``roi_masks`` Yields ------ @@ -218,9 +239,11 @@ def finalize(self, batch): The :attr:`DetectedFaces` list will be populated for this class with the bounding boxes, landmarks and masks for the detected faces found in the frame. """ - for mask, face, feed_face in zip(batch["prediction"], - batch["detected_faces"], - batch["feed_faces"]): + for mask, face, feed_face, roi_mask in zip(batch["prediction"], + batch["detected_faces"], + batch["feed_faces"], + batch["roi_masks"]): + self._crop_out_of_bounds(mask, roi_mask) face.add_mask(self._storage_name, mask, feed_face.adjusted_matrix, @@ -244,8 +267,8 @@ def finalize(self, batch): yield output # <<< PROTECTED ACCESS METHODS >>> # - @staticmethod - def _resize(image, target_size): + @classmethod + def _resize(cls, image, target_size): """ resize input and output of mask models appropriately """ height, width, channels = image.shape image_size = max(height, width) @@ -256,3 +279,19 @@ def _resize(image, target_size): resized = cv2.resize(image, (0, 0), fx=scale, fy=scale, interpolation=method) resized = resized if channels > 1 else resized[..., None] return resized + + @classmethod + def _crop_out_of_bounds(cls, mask, roi_mask): + """ Un-mask any area of the predicted mask that falls outside of the original frame. + + Parameters + ---------- + masks: :class:`numpy.ndarray` + The predicted masks from the plugin + roi_mask: :class:`numpy.ndarray` + The roi mask. In frame is white, out of frame is black + """ + if np.all(roi_mask): + return # The whole of the face is within the frame + roi_mask = roi_mask[..., None] if mask.ndim == 3 else roi_mask + mask *= roi_mask diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 60684cccc0..a03cc1945c 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -683,6 +683,7 @@ def __init__(self, filename, image, detected_faces=None): self.__class__.__name__, filename, image.shape, detected_faces) self._filename = filename self._image = image + self._image_shape = image.shape self._detected_faces = detected_faces @property @@ -698,12 +699,12 @@ def image(self): @property def image_shape(self): """ tuple: The shape of the stored :attr:`image`. """ - return self._image.shape + return self._image_shape @property def image_size(self): """ tuple: The (`height`, `width`) of the stored :attr:`image`. """ - return self._image.shape[:2] + return self._image_shape[:2] @property def detected_faces(self): diff --git a/scripts/extract.py b/scripts/extract.py index 34d087061b..968937f539 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -292,7 +292,8 @@ def _output_faces(self, saver, extract_media): original_filename=output_filename, face_index=idx, source_filename=os.path.basename(extract_media.filename), - source_is_video=self._images.is_video)) + source_is_video=self._images.is_video, + source_frame_dims=extract_media.image_size)) image = encode_image(face.aligned.face, extension, metadata=meta) if not self._args.skip_saving_faces: diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index 0121a7b8e3..af07e8dd86 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -535,7 +535,8 @@ def _output_faces(self, filename, image): original_filename=output, face_index=idx, source_filename=filename, - source_is_video=self._frames.is_video)) + source_is_video=self._frames.is_video, + source_frame_dims=image.shape[:2])) self._saver.save(output, encode_image(face.aligned.face, ".png", metadata=meta)) if not self._arguments.large and self._is_legacy: face.thumbnail = generate_thumbnail(face.aligned.face, size=96, quality=60) @@ -715,7 +716,8 @@ def _update_png_headers(self): original_filename=orig_filename, face_index=new_index, source_filename=frame, - source_is_video=file_info["source_is_video"])) + source_is_video=file_info["source_is_video"], + source_frame_dims=file_info.get("source_frame_dims"))) update_existing_metadata(fullpath, meta) logger.info("%s Extracted face(s) had their header information updated", len(to_update)) diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index bc763ae5ce..c6fb1d9763 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -414,7 +414,8 @@ def _background_extract(self, output_folder, progress_queue): original_filename=output, face_index=face_idx, source_filename=src_filename, - source_is_video=self._globals.is_video)) + source_is_video=self._globals.is_video, + source_frame_dims=image.shape[:2])) b_image = encode_image(aligned.face, ".png", metadata=meta) _io["saver"].save(output, b_image) diff --git a/tools/mask/mask.py b/tools/mask/mask.py index b0a4d8df21..a4d406984d 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -168,7 +168,12 @@ def _input_faces(self, *args): 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["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 frame_name = metadata["source"]["source_filename"] face_index = metadata["source"]["face_index"] alignment = self._alignments.get_faces_in_frame(frame_name) @@ -188,6 +193,9 @@ def _input_faces(self, *args): self._save(frame_name, face_index, detected_face) else: media = ExtractMedia(filename, image, detected_faces=[detected_face]) + # Hacky overload of ExtractMedia's shape parameter to apply the actual original + # frame dimension + media._image_shape = (*metadata["source"]["source_frame_dims"], 3) setattr(media, "mask_tool_face_info", metadata["source"]) # TODO formalize queue.put(media) self._counts["update"] += 1 From bfc8517c5ef3dc2e56a850d535acc306d2533bcf Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 27 Jul 2021 21:49:24 +0100 Subject: [PATCH 502/981] Bugfix - Correctly parse 'load_layers' configuration parameter --- plugins/train/model/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index e4fe304f2c..26692af369 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -903,7 +903,7 @@ def __init__(self, plugin): self._weights_file = self._check_weights_file(plugin._args.load_weights) freeze_layers = plugin.config.get("freeze_layers") # Standardized config for freezing - load_layers = plugin.config.get("loading_layers") # Standardized config for loading + load_layers = plugin.config.get("load_layers") # Standardized config for loading self._freeze_layers = freeze_layers if freeze_layers else ["encoder"] # No plugin config self._load_layers = load_layers if load_layers else ["encoder"] # No plugin config logger.debug("Initialized %s", self.__class__.__name__) From 1b7809879996c02bc9f12d052623a18dd8e4526e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 4 Aug 2021 12:55:21 +0100 Subject: [PATCH 503/981] Phaze-A - Force AdaInstanceNorm to float32 - Reduce NaN for Mixed Precision training --- plugins/train/model/phaze_a.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 49d11225b5..42f9cf08fe 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -855,7 +855,7 @@ def _g_block(cls, inputs, style, filters, recursions=2): if i == recursions - 1: var_x = KConv2D(filters, 3, padding="same")(var_x) - var_x = AdaInstanceNormalization()([var_x, *styles]) + var_x = AdaInstanceNormalization(dtype="float32")([var_x, *styles]) var_x = Add()([var_x, noise]) var_x = LeakyReLU(0.2)(var_x) From b5b0da42c872530b8b051fdb33620be08cec7e7d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 7 Aug 2021 12:15:56 +0100 Subject: [PATCH 504/981] convert - bugfix - Race condition when checking for predicted mask existence --- scripts/convert.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/scripts/convert.py b/scripts/convert.py index ebd4f757cc..5da15aa6c6 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -52,13 +52,18 @@ def __init__(self, arguments): self._patch_threads = None self._images = ImagesLoader(self._args.input_dir, fast_count=True) self._alignments = Alignments(self._args, False, self._images.is_video) - self._validate() + if self._alignments.version == 1.0: + logger.error("The alignments file format has been updated since the given alignments " + "file was generated. You need to update the file to proceed.") + logger.error("To do this run the 'Alignments Tool' > 'Extract' Job.") + sys.exit(1) self._opts = OptionalActions(self._args, self._images.file_list, self._alignments) self._add_queues() self._disk_io = DiskIO(self._alignments, self._images, arguments) self._predictor = Predict(self._disk_io.load_queue, self._queue_size, arguments) + self._validate() get_folder(self._args.output_dir) configfile = self._args.configfile if hasattr(self._args, "configfile") else None @@ -113,12 +118,6 @@ def _validate(self): If an invalid selection has been found. """ - if self._alignments.version == 1.0: - logger.error("The alignments file format has been updated since the given alignments " - "file was generated. You need to update the file to proceed.") - logger.error("To do this run the 'Alignments Tool' > 'Extract' Job.") - sys.exit(1) - if (self._args.writer == "ffmpeg" and not self._images.is_video and self._args.reference_video is None): From acf1fc561246e4eccc45728bb9a6e6f0ff7e13b3 Mon Sep 17 00:00:00 2001 From: Olivier Gagnon Date: Sat, 7 Aug 2021 07:24:41 -0400 Subject: [PATCH 505/981] Add a new sorting/grouping feature based on black pixels in images (#1169) * Add a new sorting/grouping feature based on the percentage of black pixels in images Context: - Faces are quite often moving and can get near borders of the image. - Because of that, the face image has the out of bound part replaced by black pixels. - Some are still good for training but others not. Also, one might want to keep an alignment file with the near borders faces but excluded (some of) them for training. - Having to manually move/delete those images in a dataset of thousands of them is painful and prone to mistakes. - Thus, having a way to sort and group those images would be helpful. At least, it is for me :-) Added features: - A new option in Tools/Sort for both Sort and Group By called Black-Pixels. - Sort feature: calculates the percentage of black pixels in each images and sort them from 0 to 100%. - Group By feature: Uses the Bins slider number selected and creates 100/Bins folders with the images percentage of black pixels. ie. a Bins of 5 will create 20 folders with the first one containing images with 0-5% of black pixels, the second one with 6-10%, etc. A static method as been added for the rounding error. * PEP8 fixes Co-authored-by: Olivier Gagnon --- tools/sort/cli.py | 28 ++++++++++++++---------- tools/sort/sort.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/tools/sort/cli.py b/tools/sort/cli.py index d18c24c5b6..ccc56699ac 100644 --- a/tools/sort/cli.py +++ b/tools/sort/cli.py @@ -45,7 +45,7 @@ def get_argument_list(): type=str, choices=("blur", "blur-fft", "distance", "face", "face-cnn", "face-cnn-dissim", "face-yaw", "hist", "hist-dissim", "color-gray", "color-luma", "color-green", - "color-orange", "size"), + "color-orange", "size", "black-pixels"), dest='sort_method', group=_("sort settings"), default="face", @@ -76,7 +76,10 @@ def get_argument_list(): "\nL|'size': Sort images by their size in the original frame. Faces closer to " "the camera and from higher resolution sources will be sorted first, whilst " "faces further from the camera and from lower resolution sources will be " - "sorted last.\nDefault: face"))) + "sorted last." + "\nL|'black-pixels': Sort images by their number of black pixels. Useful when " + "faces are near borders and a large part of the image is black." + "\nDefault: face"))) argument_list.append(dict( opts=('-k', '--keep'), action='store_true', @@ -119,7 +122,7 @@ def get_argument_list(): opts=('-g', '--group-by'), action=Radio, type=str, - choices=("blur", "blur-fft", "face-cnn", "face-yaw", "hist"), + choices=("blur", "blur-fft", "face-cnn", "face-yaw", "hist", "black-pixels"), dest='group_method', group=_("output"), default="hist", @@ -134,14 +137,17 @@ def get_argument_list(): dest='num_bins', group=_("output"), default=5, - help=_("Integer value. Number of folders that will be used to group by blur and " - "face-yaw. For blur folder 0 will be the least blurry, while the last folder " - "will be the blurriest. For face-yaw the number of bins is by how much 180 " - "degrees is divided. So if you use 18, then each folder will be a 10 degree " - "increment. Folder 0 will contain faces looking the most to the left whereas " - "the last folder will contain the faces looking the most to the right. If the " - "number of images doesn't divide evenly into the number of bins, the remaining " - "images get put in the last bin. Default value: 5"))) + help=_("Integer value. Number of folders that will be used to group by blur, " + "face-yaw and black-pixels. For blur folder 0 will be the least blurry, while " + "the last folder will be the blurriest. For face-yaw the number of bins is by " + "how much 180 degrees is divided. So if you use 18, then each folder will be " + "a 10 degree increment. Folder 0 will contain faces looking the most to the " + "left whereas the last folder will contain the faces looking the most to the " + "right. If the number of images doesn't divide evenly into the number of " + "bins, the remaining images get put in the last bin. For black-pixels it " + "represents the divider of the percentage of black pixels. For 10, first " + "folder will have the faces with 0 to 10% black pixels, second 11 to 20%, " + "etc. Default value: 5"))) argument_list.append(dict( opts=('-l', '--log-changes'), action='store_true', diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 3559d3f82c..3482b3fd6c 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -407,6 +407,25 @@ def sort_size(self): logger.info("Sorting...") return sorted(img_list, key=lambda x: x[1], reverse=True) + def sort_black_pixels(self): + """ Sort by percentage of black pixels """ + logger.info("Sorting by percentage of black pixels...") + + """ Calculate the sum of black pixels, get the percentage X 3 channels """ + img_list = [(filename, np.ndarray.all(image == [0, 0, 0], axis=2).sum()/image.size*100*3) + for filename, image, _ in tqdm(self._loader.load(), + desc="Calculating black pixels", + total=self._loader.count, + leave=False)] + img_list_len = len(img_list) + for i in tqdm(range(0, img_list_len - 1), desc="Comparing black pixels", file=sys.stdout): + for j in range(0, img_list_len-i-1): + if img_list[j][1] > img_list[j+1][1]: + temp = img_list[j] + img_list[j] = img_list[j+1] + img_list[j+1] = temp + return img_list + # Methods for grouping def group_blur(self, img_list): """ Group into bins by blur """ @@ -525,6 +544,25 @@ def group_face_yaw(self, img_list): return bins + def group_black_pixels(self, img_list): + """ Group into bins by percentage of black pixels + :type img_list: (str, float) + """ + logger.info("Grouping by percentage of black pixels...") + + # Starting the binning process + bins = [[] for _ in range(self._args.num_bins)] + # Get edges of bins from 0 to 100 + bins_edges = self._near_split(100, self._args.num_bins) + # Get the proper bin number for each img order + img_bins = np.digitize([x[1] for x in img_list], bins_edges, right=True) + + # Place imgs in bins + for i, b in enumerate(img_bins): + bins[b].append(img_list[i][0]) + + return bins + def group_hist(self, img_list): """ Group into bins by histogram """ logger.info("Grouping by histogram...") @@ -679,11 +717,27 @@ def reload_images(self, group_method, img_list): filename_list, image_list = self._get_images() histograms = [cv2.calcHist([img], [0], None, [256], [0, 256]) for img in image_list] temp_list = list(zip(filename_list, histograms)) + elif group_method == 'group_black_pixels': + filename_list, image_list = self._get_images() + black_pixels = [np.ndarray.all(img == [0, 0, 0], axis=2).sum()/img.size*100*3 + for img in image_list] + temp_list = list(zip(filename_list, black_pixels)) else: raise ValueError("{} group_method not found.".format(group_method)) return self.splice_lists(img_list, temp_list) + @staticmethod + def _near_split(x, num_bins): + quotient, remainder = divmod(x, num_bins) + seps = [quotient + 1] * remainder + [quotient] * (num_bins - remainder) + uplimit = 0 + bins = [0] + for n in seps: + bins.append(uplimit+n) + uplimit += n + return bins + @staticmethod def _convert_color(imgs, same_size, method): """ Helper function to convert color spaces """ From ee92cff6fc068f2daeb1014a969ca2578a1e8d1f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 7 Aug 2021 12:39:09 +0100 Subject: [PATCH 506/981] tools.sort - Linting and languages --- locales/es/LC_MESSAGES/tools.sort.cli.mo | Bin 9438 -> 10114 bytes locales/es/LC_MESSAGES/tools.sort.cli.po | 54 ++++++++++++++--------- locales/tools.sort.cli.pot | 27 ++++++------ tools/sort/sort.py | 35 ++++++++++----- 4 files changed, 71 insertions(+), 45 deletions(-) diff --git a/locales/es/LC_MESSAGES/tools.sort.cli.mo b/locales/es/LC_MESSAGES/tools.sort.cli.mo index e438b4cd863e4e717c65f2fa707442ab2d55d405..40dec2bf0ae384211e4e17a5ecbb8f53c3438580 100644 GIT binary patch delta 1025 zcmZva!An#@9LIkz&2ml549iUVMW|To;!2=J2wemtSRv{Z#Q1i0_vLfuF*BVh`T?&irP6-{0@|;eB7)D!sYWI5QM02NzenPuW6J3YyL-(OWy+n+&KhPrfV}nHBpe5)$bmANlEa(Ze2U>d3rhQgXwp|~C%f3MG;d zsRM^=KiApAb*}j$>h2oNb4A2*dQ)t$P-R|8MZ7wlCC_APo3FcnM2$slI-;C7HZn8P zO64rOn&fh_o|L6wT?yMvniyxIDt#n4a@(=M-mN%;qKUDND?0Ug8%dgSO{~k1?)$Nb zxf;)~o6@*H-r`rNZT#3r>Z>e3^jv;n<->FDIztAvX60 zK1I=d^X1^jp2o5;g0t2KU_}>7R*km#BaDJ&8Yo)eDPx`KHo~WXiz=Oo1rBx|V+vQ3 ztVy`X!er73c2bp1YX?Wx$NX2{Qa~vJ$m4dw#mZ`3m4&Ycs^SRhjf=l>BYnrImHFA0*zc81poj5 delta 359 zcmXBPyGsK>5XbTF>X~>a$C(gc2||4034)4 ziLUrGZg4MFOkpUVHIBH|OIEFa>3c~8_QW{tez8*9FCJF*4yKMPNA}A3GIl(@>)If5 UYs|cT&fcVKnE&zY\n" "Language-Team: LANGUAGE \n" @@ -35,7 +35,7 @@ msgstr "" msgid "Output directory for sorted aligned faces." msgstr "" -#: tools/sort/cli.py:50 tools/sort/cli.py:96 +#: tools/sort/cli.py:50 tools/sort/cli.py:99 msgid "sort settings" msgstr "" @@ -56,46 +56,47 @@ msgid "" "L|'color-green': Sort images by the average intensity of the converted Cg color channel. Green images will be ranked first and red images will be last.\n" "L|'color-orange': Sort images by the average intensity of the converted Co color channel. Orange images will be ranked first and blue images will be last.\n" "L|'size': Sort images by their size in the original frame. Faces closer to the camera and from higher resolution sources will be sorted first, whilst faces further from the camera and from lower resolution sources will be sorted last.\n" +"L|'black-pixels': Sort images by their number of black pixels. Useful when faces are near borders and a large part of the image is black.\n" "Default: face" msgstr "" -#: tools/sort/cli.py:85 tools/sort/cli.py:112 tools/sort/cli.py:124 -#: tools/sort/cli.py:135 +#: tools/sort/cli.py:88 tools/sort/cli.py:115 tools/sort/cli.py:127 +#: tools/sort/cli.py:138 msgid "output" msgstr "" -#: tools/sort/cli.py:86 +#: tools/sort/cli.py:89 msgid "Keeps the original files in the input directory. Be careful when using this with rename grouping and no specified output directory as this would keep the original and renamed files in the same directory." msgstr "" -#: tools/sort/cli.py:98 +#: tools/sort/cli.py:101 msgid "Float value. Minimum threshold to use for grouping comparison with 'face-cnn' and 'hist' methods. The lower the value the more discriminating the grouping is. Leaving -1.0 will allow the program set the default value automatically. For face-cnn 7.2 should be enough, with 4 being very discriminating. For hist 0.3 should be enough, with 0.2 being very discriminating. Be careful setting a value that's too low in a directory with many images, as this could result in a lot of directories being created. Defaults: face-cnn 7.2, hist 0.3" msgstr "" -#: tools/sort/cli.py:113 +#: tools/sort/cli.py:116 msgid "" "R|Default: rename.\n" "L|'folders': files are sorted using the -s/--sort-by method, then they are organized into folders using the -g/--group-by grouping method.\n" "L|'rename': files are sorted using the -s/--sort-by then they are renamed." msgstr "" -#: tools/sort/cli.py:126 +#: tools/sort/cli.py:129 msgid "Group by method. When -fp/--final-processing by folders choose the how the images are grouped after sorting. Default: hist" msgstr "" -#: tools/sort/cli.py:137 -msgid "Integer value. Number of folders that will be used to group by blur and face-yaw. For blur folder 0 will be the least blurry, while the last folder will be the blurriest. For face-yaw the number of bins is by how much 180 degrees is divided. So if you use 18, then each folder will be a 10 degree increment. Folder 0 will contain faces looking the most to the left whereas the last folder will contain the faces looking the most to the right. If the number of images doesn't divide evenly into the number of bins, the remaining images get put in the last bin. Default value: 5" +#: tools/sort/cli.py:140 +msgid "Integer value. Number of folders that will be used to group by blur, face-yaw and black-pixels. For blur folder 0 will be the least blurry, while the last folder will be the blurriest. For face-yaw the number of bins is by how much 180 degrees is divided. So if you use 18, then each folder will be a 10 degree increment. Folder 0 will contain faces looking the most to the left whereas the last folder will contain the faces looking the most to the right. If the number of images doesn't divide evenly into the number of bins, the remaining images get put in the last bin. For black-pixels it represents the divider of the percentage of black pixels. For 10, first folder will have the faces with 0 to 10% black pixels, second 11 to 20%, etc. Default value: 5" msgstr "" -#: tools/sort/cli.py:148 tools/sort/cli.py:158 +#: tools/sort/cli.py:154 tools/sort/cli.py:164 msgid "settings" msgstr "" -#: tools/sort/cli.py:150 +#: tools/sort/cli.py:156 msgid "Logs file renaming changes if grouping by renaming, or it logs the file copying/movement if grouping by folders. If no log file is specified with '--log-file', then a 'sort_log.json' file will be created in the input directory." msgstr "" -#: tools/sort/cli.py:161 +#: tools/sort/cli.py:167 msgid "Specify a log file to use for saving the renaming or grouping information. If specified extension isn't 'json' or 'yaml', then json will be used as the serializer, with the supplied filename. Default: sort_log.json" msgstr "" diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 3482b3fd6c..47b7ea2155 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -408,10 +408,11 @@ def sort_size(self): return sorted(img_list, key=lambda x: x[1], reverse=True) def sort_black_pixels(self): - """ Sort by percentage of black pixels """ - logger.info("Sorting by percentage of black pixels...") + """ Sort by percentage of black pixels - """ Calculate the sum of black pixels, get the percentage X 3 channels """ + Calculates the sum of black pixels, get the percentage X 3 channels + """ + logger.info("Sorting by percentage of black pixels...") img_list = [(filename, np.ndarray.all(image == [0, 0, 0], axis=2).sum()/image.size*100*3) for filename, image, _ in tqdm(self._loader.load(), desc="Calculating black pixels", @@ -558,8 +559,8 @@ def group_black_pixels(self, img_list): img_bins = np.digitize([x[1] for x in img_list], bins_edges, right=True) # Place imgs in bins - for i, b in enumerate(img_bins): - bins[b].append(img_list[i][0]) + for idx, _bin in enumerate(img_bins): + bins[_bin].append(img_list[idx][0]) return bins @@ -728,14 +729,28 @@ def reload_images(self, group_method, img_list): return self.splice_lists(img_list, temp_list) @staticmethod - def _near_split(x, num_bins): - quotient, remainder = divmod(x, num_bins) + def _near_split(bin_range, num_bins): + """ Obtain the split for the given number of bins for the given range + + Parameters + ---------- + bin_range: int + The range of data to separate into bins + num_bins: int + The number of bins to create + + Returns + ------- + list + The split dividers for the given number of bins for the given range + """ + quotient, remainder = divmod(bin_range, num_bins) seps = [quotient + 1] * remainder + [quotient] * (num_bins - remainder) uplimit = 0 bins = [0] - for n in seps: - bins.append(uplimit+n) - uplimit += n + for sep in seps: + bins.append(uplimit + sep) + uplimit += sep return bins @staticmethod From 7e7640e557a79b43fb115d43dc36b7c5b8265971 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 11 Aug 2021 23:58:34 +0100 Subject: [PATCH 507/981] Bugfix: Convert - Fix predicted mask --- lib/convert.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/convert.py b/lib/convert.py index 28cd7b4bba..d9c783a050 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -323,7 +323,7 @@ def _get_image_mask(self, new_face, detected_face, predicted_mask, reference_fac The swapped face with the requested mask added to the Alpha channel """ logger.trace("Getting mask. Image shape: %s", new_face.shape) - if self._args.mask_type != "none": + if self._args.mask_type not in ("none", "predicted"): mask_centering = detected_face.mask[self._args.mask_type].stored_centering else: mask_centering = "face" # Unused but requires a valid value From 6f1e6743df5dfc1da12638778028109d0dbe9d97 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 28 Aug 2021 10:40:01 +0000 Subject: [PATCH 508/981] Bugfixes: - Gui - Stats - Return empty dict on state file look up error - Gui - Last Session - Don't load saved project information when loading project from last session - Train - Set default coverage to 87.5% --- lib/gui/analysis/stats.py | 10 +++++++--- lib/gui/project.py | 35 ++++++++++++++--------------------- plugins/train/_config.py | 2 +- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index f4dcc2255d..e5358b94e3 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -61,8 +61,10 @@ def model_filename(self): @property def batch_sizes(self): """ dict: The batch sizes for each session_id for the model. """ + if self._state is None: + return dict() return {int(sess_id): sess["batchsize"] - for sess_id, sess in self._state["sessions"].items()} + for sess_id, sess in self._state.get("sessions", dict()).items()} @property def full_summary(self): @@ -73,6 +75,8 @@ def full_summary(self): def logging_disabled(self): """ bool: ``True`` if logging is enabled for the currently training session otherwise ``False``. """ + if self._state is None: + return True return self._state["sessions"][str(self.session_ids[-1])]["no_logs"] @property @@ -136,7 +140,7 @@ def stop_training(self): def clear(self): """ Clear the currently loaded session. """ - self._state = None + self._state = dict() self._model_dir = None self._model_name = None @@ -774,7 +778,7 @@ def _calc_smoothed(self, data): Parameters ---------- data: :class:`numpy.ndarray` - The data to smoothen + The data to smooth Returns ------- diff --git a/lib/gui/project.py b/lib/gui/project.py index 0979b64745..7967a70129 100644 --- a/lib/gui/project.py +++ b/lib/gui/project.py @@ -249,7 +249,7 @@ def _reset_modified_var(self, command=None): """ for key, tk_var in self._modified_vars.items(): if (command is None or command == key) and tk_var.get(): - logger.debug("Reset modified state for: %s", command) + logger.debug("Reset modified state for: (command: %s key: %s)", command, key) tk_var.set(False) # RECENT FILE HANDLING @@ -727,7 +727,8 @@ def _modified_callback(self, *args): # pylint:disable=unused-argument self._modified = self._project_modified self._update_root_title() - def load(self, *args, filename=None): # pylint:disable=unused-argument + def load(self, *args, # pylint:disable=unused-argument + filename=None, last_session=False): """ Load a project from a saved ``.fsw`` project file. Parameters @@ -737,8 +738,12 @@ def load(self, *args, filename=None): # pylint:disable=unused-argument filename: str, optional If a filename is passed in, This will be used, otherwise a file handler will be launched to select the relevant file. + last_session: bool, optional + ``True`` if the project is being loaded from the last opened session ``False`` if the + project is being loaded directly from disk. Default: ``False`` """ - logger.debug("Loading project config: (filename: '%s')", filename) + logger.debug("Loading project config: (filename: '%s', last_session: %s)", + filename, last_session) filename_set = self._set_filename(filename, sess_type="project") if not filename_set: @@ -757,7 +762,8 @@ def load(self, *args, filename=None): # pylint:disable=unused-argument self._handoff_legacy_task() return - self._set_options() + if not last_session: + self._set_options() # Options will be set by last session. Don't set now self._update_tasks() self._add_to_recent() self._reset_modified_var() @@ -994,29 +1000,16 @@ def load(self): loaded = self._load() if not loaded: return - needs_update = self._set_project() - if needs_update: - self._set_options() + self._set_project() + self._set_options() def _set_project(self): - """ Set the :class:`Project` if session is resuming from one. - - Returns - ------- - bool: - ``True`` If the GUI still needs to be updated from the last session, ``False`` if - the returned GUI state is the last session - """ + """ Set the :class:`Project` if session is resuming from one. """ if self._options.get("project", None) is None: logger.debug("No project stored") - retval = True else: logger.debug("Loading stored project") - self._config.project.load(filename=self._options["project"]) - retval = self._cli_options != self._config.project.cli_options - - logger.debug("Needs update: %s", retval) - return retval + self._config.project.load(filename=self._options["project"], last_session=True) def save(self): """ Save a snapshot of currently set GUI config options. diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 210a360130..56fbb6a5e9 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -56,7 +56,7 @@ def _set_globals(self): section=section, title="coverage", datatype=float, - default=68.75, + default=87.5, min_max=(62.5, 100.0), rounding=2, fixed=True, From f1819ad5926eb1493767cc4eb431e57fdbfcfd39 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 1 Sep 2021 09:41:21 +0100 Subject: [PATCH 509/981] bugfix: Phaze-A - Fix -1 to 1 scaling --- plugins/train/model/phaze_a.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 42f9cf08fe..e2cc8e5ea3 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -587,11 +587,11 @@ def __call__(self): # Some models expect different scaling. logger.debug("Scaling to %s for '%s'", scaling, self._config["enc_architecture"]) if scaling == (0, 255): - # models expecting inputs from 0-255. + # models expecting inputs from 0 to 255. var_x = var_x * 255. if scaling == (-1, 1): - # models expecting inputs from -1-1. - var_x = var_x * (1. / 2.) + # models expecting inputs from -1 to 1. + var_x = var_x * 2. var_x = var_x - 1.0 var_x = self._get_encoder_model()(var_x) From c7d85f89e69c74e97bf7485b064c07487d31faae Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 1 Sep 2021 11:16:15 +0100 Subject: [PATCH 510/981] Bugfix: Phaze-A Correct documentation for original encoder input size --- plugins/train/model/phaze_a_defaults.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/train/model/phaze_a_defaults.py b/plugins/train/model/phaze_a_defaults.py index 5d632f76a1..15d33123b8 100644 --- a/plugins/train/model/phaze_a_defaults.py +++ b/plugins/train/model/phaze_a_defaults.py @@ -142,7 +142,7 @@ "each variant is: b0: 224px, b1: 240px, b2: 260px, b3: 300px, b4: 380px, b5: 456px, " "b6: 528px, b7 600px. Ref: Rethinking Model Scaling for Convolutional Neural " "Networks (2020): https://arxiv.org/abs/1905.11946" - "\n\tfs_original: (32px - 320px). A configurable variant of the original facewap " + "\n\tfs_original: (32px - 160px). A configurable variant of the original facewap " "encoder. ImageNet weights cannot be loaded for this model. Additional parameters " "can be configured with the 'fs_enc' options. A version of this encoder is used in " "the following models: Original, Original (lowmem), Dfaker, DFL-H128, DFL-SAE, IAE, " From cf4b567cc68267855767025bab9e10a3e2af64b3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 5 Sep 2021 01:25:12 +0000 Subject: [PATCH 511/981] Tensorflow 2.6 Support (#1182) * lib.cli.launcher - Bump max tf version to 2.6 * Remove pathlib requirement * Update requirements files * Update setup.py * bugfix - GUI: Supress errors when attempting to load previews in extract * GUI: Suppress ptxas error messages for Windows --- INSTALL.md | 1 - _requirements_base.txt | 25 +++++++--------- docs/sphinx_requirements.txt | 19 ++++++------ lib/cli/launcher.py | 18 +++++------ lib/gui/utils.py | 8 +++++ lib/gui/wrapper.py | 3 ++ lib/model/backup_restore.py | 2 +- lib/utils.py | 15 ++++------ plugins/train/trainer/_base.py | 6 ++-- requirements_cpu.txt | 2 +- requirements_nvidia.txt | 2 +- scripts/convert.py | 4 +-- scripts/extract.py | 4 +-- scripts/fsmedia.py | 3 +- scripts/train.py | 18 +++++------ setup.py | 55 +++++++++++++++++++++------------- tools/manual/detected_faces.py | 2 +- tools/mask/mask.py | 4 +-- 18 files changed, 104 insertions(+), 87 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 2a94d4f246..02fe3068cf 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -257,7 +257,6 @@ WARNING Tensorflow has no official prebuild for CUDA 9.1 currently. Are System Dependencies met? [y/N] y INFO Installing Missing Python Packages... INFO Installing tensorflow-gpu -INFO Installing pathlib==1.0.1 ...... INFO Installing tqdm INFO Installing matplotlib diff --git a/_requirements_base.txt b/_requirements_base.txt index d356106e43..d4662e8ec7 100644 --- a/_requirements_base.txt +++ b/_requirements_base.txt @@ -1,18 +1,15 @@ -tqdm>=4.42 -psutil>=5.7.0 -pathlib==1.0.1 +tqdm>=4.62 +psutil>=5.8.0 numpy>=1.18.0,<1.20.0 -opencv-python>=4.1.2.0 -pillow>=7.0.0 -scikit-learn>=0.22.0 -fastcluster==1.1.26 +opencv-python>=4.5.3.0 +pillow>=8.3.1 +scikit-learn>=0.24.2 +fastcluster>=1.1.26 # matplotlib 3.3.1 breaks custom toolbar in graph popup -matplotlib>=3.0.3,<3.3.0 -imageio>=2.8.0 -imageio-ffmpeg>=0.4.2 +matplotlib>=3.2.0,<3.3.0 +imageio>=2.9.0 +imageio-ffmpeg>=0.4.5 ffmpy==0.2.3 -# Revert back to nvidia-ml-py3 when windows/system32 patch is implemented -git+https://github.com/deepfakes/nvidia-ml-py3.git -#nvidia-ml-py3 -pywin32>=227 ; sys_platform == "win32" +nvidia-ml-py>=11.470.66 +pywin32>=228 ; sys_platform == "win32" pynvx==1.0.0 ; sys_platform == "darwin" diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index 1a220a47f5..2c076eeb7f 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -1,20 +1,19 @@ # NB Do not install from this requirements file # It is for documentation purposes only -tqdm==4.42 -psutil==5.7.0 -pathlib==1.0.1 +tqdm==4.62 +psutil==5.8.0 numpy==1.18.0 -opencv-python==4.1.2.30 -pillow==7.0.0 -scikit-learn==0.22.0 +opencv-python==4.5.3.0 +pillow==8.3.1 +scikit-learn==0.24.2 fastcluster==1.1.26 -matplotlib>3.0.3,<3.3.0 -imageio==2.8.0 -imageio-ffmpeg==0.4.2 +matplotlib>3.2.0,<3.3.0 +imageio==2.9.0 +imageio-ffmpeg==0.4.5 ffmpy==0.2.3 nvidia-ml-py3 -pywin32==227 ; sys_platform == "win32" +pywin32==228 ; sys_platform == "win32" pynvx==1.0.0 ; sys_platform == "darwin" plaidml-keras==0.7.0 tensorflow==2.2.0 diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 70aa1a2ed1..09e6f63bc5 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -53,10 +53,10 @@ def _test_for_tf_version(self): Raises ------ FaceswapError - If Tensorflow is not found, or is not between versions 2.2 and 2.4 + If Tensorflow is not found, or is not between versions 2.2 and 2.6 """ min_ver = 2.2 - max_ver = 2.4 + max_ver = 2.6 try: # Ensure tensorflow doesn't pin all threads to one core when using Math Kernel Library os.environ["TF_MIN_GPU_MULTIPROCESSOR_COUNT"] = "4" @@ -65,15 +65,16 @@ def _test_for_tf_version(self): except ImportError as err: if "DLL load failed while importing" in str(err): msg = ( - "A DLL library file failed to load. Make sure that you have Microsoft Visual " + f"A DLL library file failed to load. Make sure that you have Microsoft Visual " "C++ Redistributable (2015, 2017, 2019) installed for your machine from: " - "https://support.microsoft.com/en-gb/help/2977003") + "https://support.microsoft.com/en-gb/help/2977003. Original error: " + f"{str(err)}") else: msg = ( - "There was an error importing Tensorflow. This is most likely because you do " + f"There was an error importing Tensorflow. This is most likely because you do " "not have TensorFlow installed, or you are trying to run tensorflow-gpu on a " "system without an Nvidia graphics card. Original import " - "error: {}".format(str(err))) + f"error: {str(err)}") self._handle_import_error(msg) tf_ver = float(".".join(tf.__version__.split(".")[:2])) # pylint:disable=no-member @@ -126,9 +127,8 @@ def _test_tkinter(): If tkinter cannot be imported """ try: - # pylint: disable=unused-variable import tkinter # noqa pylint: disable=unused-import,import-outside-toplevel - except ImportError: + except ImportError as err: logger.error("It looks like TkInter isn't installed for your OS, so the GUI has been " "disabled. To enable the GUI please install the TkInter application. You " "can try:") @@ -139,7 +139,7 @@ def _test_tkinter(): logger.info("Arch: sudo pacman -S tk") logger.info("CentOS/Redhat: sudo yum install tkinter") logger.info("Fedora: sudo dnf install python3-tkinter") - raise FaceswapError("TkInter not found") + raise FaceswapError("TkInter not found") from err @staticmethod def _check_display(): diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 3107fa084d..868ba22854 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -612,6 +612,14 @@ def _load_images_to_cache(self, image_files, frame_dims, thumbnail_size): fname, str(err)) dropped_files.append(fname) continue + except Exception as err: # pylint:disable=broad-except + # Swallow any issues with opening an image rather than spamming console + # Can happen when trying to read partially saved images + logger.debug("Error opening preview file: '%s'. Original error: %s", + fname, str(err)) + dropped_files.append(fname) + continue + width, height = img.size scaling = thumbnail_size / max(width, height) logger.debug("image width: %s, height: %s, scaling: %s", width, height, scaling) diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index 8ecea281c3..d258685c47 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -245,6 +245,9 @@ def read_stderr(self): if self.command == "train" and output.startswith("Reading training images"): print(output.strip(), file=sys.stdout) continue + if os.name == "nt" and "Call to CreateProcess failed. Error code: 2" in output: + # Suppress ptxas errors on Tensorflow for Windows + logger.debug("Suppressed call to subprocess error: '%s'", output) print(output.strip(), file=sys.stderr) logger.debug("Terminated stderr reader") diff --git a/lib/model/backup_restore.py b/lib/model/backup_restore.py index 6026228663..0408040b85 100644 --- a/lib/model/backup_restore.py +++ b/lib/model/backup_restore.py @@ -105,7 +105,7 @@ def snapshot_models(self, iterations): logger.debug("Removing previously existing snapshot folder: '%s'", snapshot_dir) rmtree(snapshot_dir) - dst = str(get_folder(snapshot_dir)) + dst = get_folder(snapshot_dir) for filename in os.listdir(self.model_dir): if not self._check_valid(filename, for_restore=False): logger.debug("Not snapshotting file: '%s'", filename) diff --git a/lib/utils.py b/lib/utils.py index dd5ee2b17d..3e81cbad7b 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -10,7 +10,6 @@ import warnings import zipfile -from pathlib import Path from re import finditer from multiprocessing import current_process from socket import timeout as socket_timeout, error as socket_error @@ -146,19 +145,18 @@ def get_folder(path, make_folder=True): Returns ------- - :class:`pathlib.Path` or `None` + str or `None` The path to the requested folder. If `make_folder` is set to ``False`` and the requested path does not exist, then ``None`` is returned """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name logger.debug("Requested path: '%s'", path) - output_dir = Path(path) - if not make_folder and not output_dir.exists(): + if not make_folder and not os.path.isdir(path): logger.debug("%s does not exist", path) return None - output_dir.mkdir(parents=True, exist_ok=True) - logger.debug("Returning: '%s'", output_dir) - return output_dir + os.makedirs(path, exist_ok=True) + logger.debug("Returning: '%s'", path) + return path def get_image_paths(directory, extension=None): @@ -189,8 +187,7 @@ def get_image_paths(directory, extension=None): logger.trace("Scanned Folder Contents: %s", dir_scanned) for chkfile in dir_scanned: - if any([chkfile.name.lower().endswith(ext) - for ext in image_extensions]): + if any(chkfile.name.lower().endswith(ext) for ext in image_extensions): logger.trace("Adding '%s' to image list", chkfile.path) dir_contents.append(chkfile.path) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index f664803b4f..ef7e6049ff 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -945,7 +945,7 @@ def _duplicate_headers(cls, headers, columns): The original headers duplicated by the number of columns """ for side, header in headers.items(): - duped = tuple([header for _ in range(columns)]) + duped = tuple(header for _ in range(columns)) headers[side] = np.concatenate(duped, axis=1) logger.debug("side: %s header.shape: %s", side, header.shape) return headers @@ -996,8 +996,8 @@ def _setup(self, input_a=None, input_b=None, output=None): """ logger.debug("Setting up time-lapse") if output is None: - output = str(get_folder(os.path.join(str(self._model.model_dir), - "{}_timelapse".format(self._model.name)))) + output = get_folder(os.path.join(str(self._model.model_dir), + f"{self._model.name}_timelapse")) self._output_file = str(output) logger.debug("Time-lapse output set to '%s'", self._output_file) diff --git a/requirements_cpu.txt b/requirements_cpu.txt index 29096926b7..0de8d8c2c6 100644 --- a/requirements_cpu.txt +++ b/requirements_cpu.txt @@ -1,2 +1,2 @@ -r _requirements_base.txt -tensorflow>=2.2.0,<2.5.0 +tensorflow>=2.2.0,<2.7.0 diff --git a/requirements_nvidia.txt b/requirements_nvidia.txt index ec8b87f880..eaeb261a3f 100644 --- a/requirements_nvidia.txt +++ b/requirements_nvidia.txt @@ -1,2 +1,2 @@ -r _requirements_base.txt -tensorflow-gpu>=2.2.0,<2.5.0 +tensorflow-gpu>=2.2.0,<2.7.0 diff --git a/scripts/convert.py b/scripts/convert.py index 5da15aa6c6..243a5d1124 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -107,7 +107,7 @@ def _validate(self): Ensure that certain cli selections are valid and won't result in an error. Checks: * If frames have been passed in with video output, ensure user supplies reference video. - * If "on-the-fly" and an NN mask is selected, output warning and switch to 'extended' + * If "on-the-fly" and a Neural Network mask is selected, warn and switch to 'extended' * If a mask-type is selected, ensure it exists in the alignments file. * If a predicted mask-type is selected, ensure model has been trained with a mask otherwise attempt to select first available masks, otherwise raise error. @@ -750,7 +750,7 @@ def _load_model(self): logger.debug("Loading Model") model_dir = get_folder(self._args.model_dir, make_folder=False) if not model_dir: - raise FaceswapError("{} does not exist.".format(self._args.model_dir)) + raise FaceswapError(f"{self._args.model_dir} does not exist.") trainer = self._get_model_name(model_dir) model = PluginLoader.get_model(trainer)(model_dir, self._args, predict=True) model.build() diff --git a/scripts/extract.py b/scripts/extract.py index 968937f539..80dffaf601 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -38,8 +38,8 @@ class Extract(): # pylint:disable=too-few-public-methods def __init__(self, arguments): logger.debug("Initializing %s: (args: %s", self.__class__.__name__, arguments) self._args = arguments - self._output_dir = None if self._args.skip_saving_faces else str(get_folder( - self._args.output_dir)) + self._output_dir = None if self._args.skip_saving_faces else get_folder( + self._args.output_dir) logger.info("Output Directory: %s", self._args.output_dir) self._images = ImagesLoader(self._args.input_dir, fast_count=True) diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index bf9b3bfbf5..e721f47fd5 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -9,7 +9,6 @@ import logging import os import sys -from pathlib import Path import cv2 import numpy as np @@ -604,7 +603,7 @@ def _set_face_filter(f_type, f_args): logger.info("%s: %s", f_type.title(), f_args) filter_files = f_args if isinstance(f_args, list) else [f_args] - filter_files = list(filter(lambda fpath: Path(fpath).exists(), filter_files)) + filter_files = list(filter(lambda fpath: os.path.exists(fpath), filter_files)) if not filter_files: logger.warning("Face %s files were requested, but no files could be found. This " "filter will not be applied.", f_type) diff --git a/scripts/train.py b/scripts/train.py index 226befcdf5..1747f07d37 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -144,29 +144,29 @@ def _set_timelapse(self): "(--timelapse-input-A, --timelapse-input-B and " "--timelapse-output).") - timelapse_output = str(get_folder(self._args.timelapse_output)) + timelapse_output = get_folder(self._args.timelapse_output) for side in ("a", "b"): - folder = getattr(self._args, "timelapse_input_{}".format(side)) + folder = getattr(self._args, f"timelapse_input_{side}") if folder is not None and not os.path.isdir(folder): - raise FaceswapError("The Timelapse path '{}' does not exist".format(folder)) + raise FaceswapError(f"The Timelapse path '{folder}' does not exist") - training_folder = getattr(self._args, "input_{}".format(side)) + training_folder = getattr(self._args, f"input_{side}") if folder == training_folder: continue # Time-lapse folder is training folder filenames = [fname for fname in os.listdir(folder) if os.path.splitext(fname)[-1].lower() in _image_extensions] if not filenames: - raise FaceswapError("The Timelapse path '{}' does not contain any valid " - "images".format(folder)) + raise FaceswapError(f"The Timelapse path '{folder}' does not contain any valid " + "images") # Time-lapse images must appear in the training set, as we need access to alignment and # mask info. Check filenames are there to save failing much later in the process. training_images = [os.path.basename(img) for img in self._images[side]] if not all(img in training_images for img in filenames): - raise FaceswapError("All images in the Timelapse folder '{}' must exist in the " - "training folder '{}'".format(folder, training_folder)) + raise FaceswapError(f"All images in the Timelapse folder '{folder}' must exist in " + f"the training folder '{training_folder}'") kwargs = {"input_a": self._args.timelapse_input_a, "input_b": self._args.timelapse_input_b, @@ -260,7 +260,7 @@ def _load_model(self): The requested model plugin """ logger.debug("Loading Model") - model_dir = str(get_folder(self._args.model_dir)) + model_dir = get_folder(self._args.model_dir) model = PluginLoader.get_model(self._args.trainer)( model_dir, self._args, diff --git a/setup.py b/setup.py index fe36f55d23..6c88bc63d0 100755 --- a/setup.py +++ b/setup.py @@ -15,13 +15,15 @@ from pkg_resources import parse_requirements, Requirement INSTALL_FAILED = False -# Revisions of tensorflow GPU and cuda/cudnn requirements -TENSORFLOW_REQUIREMENTS = {">=2.2.0,<2.4.0": ["10.1", "7.6"]} +# Revisions of tensorflow GPU and cuda/cudnn requirements. These relate specifically to the +# Tensorflow builds available from pypi +TENSORFLOW_REQUIREMENTS = {">=2.2.0,<2.4.0": ["10.1", "7.6"], + ">=2.4.0,<2.5.0": ["11.0", "8.0"], + ">=2.5.0,<2.7.0": ["11.2", "8.1"]} # Mapping of Python packages to their conda names if different from pip or in non-default channel CONDA_MAPPING = { # "opencv-python": ("opencv", "conda-forge"), # Periodic issues with conda-forge opencv "fastcluster": ("fastcluster", "conda-forge"), - "toposort": ("toposort", "conda-forge"), "imageio-ffmpeg": ("imageio-ffmpeg", "conda-forge")} @@ -692,6 +694,7 @@ def install_missing_dep(self): def install_python_packages(self): """ Install required pip packages """ self.output.info("Installing Required Python Packages. This may take some time...") + conda_only = False for pkg, version in self.env.missing_packages: if self.env.is_conda: pkg = CONDA_MAPPING.get(pkg, (pkg, None)) @@ -700,11 +703,26 @@ def install_python_packages(self): if version: pkg = "{}{}".format(pkg, ",".join("".join(spec) for spec in version)) if self.env.is_conda and not pkg.startswith("git"): + if pkg.startswith("tensorflow-gpu"): + # From TF 2.4 onwards, Anaconda Tensorflow becomes a mess. The version of 2.5 + # installed by Anaconda is compiled against an incorrect numpy version which + # breaks Tensorflow. Coupled with this the versions of cudatoolkit and cudnn + # available in the default Anaconda channel are not compatible with the + # official PyPi versions of Tensorflow. With this in mind we will pull in the + # required Cuda/cuDNN from conda-forge, and install Tensorflow with pip + # TODO Revert to Conda if they get their act together + + # Rewrite tensorflow requirement to versions from highest available cuda/cudnn + highest_cuda = sorted(TENSORFLOW_REQUIREMENTS.values())[-1] + compat_tf = next(k for k, v in TENSORFLOW_REQUIREMENTS.items() + if v == highest_cuda) + pkg = f"tensorflow-gpu{compat_tf}" + conda_only = True + verbose = pkg.startswith("tensorflow") or self.env.updater - if self.conda_installer(pkg, verbose=verbose, channel=channel, conda_only=False): + if self.conda_installer(pkg, + verbose=verbose, channel=channel, conda_only=conda_only): continue - if pkg.startswith("tensorflow-gpu"): - self._tensorflow_dependency_install() self.pip_installer(pkg) def install_conda_packages(self): @@ -717,7 +735,6 @@ def install_conda_packages(self): def conda_installer(self, package, channel=None, verbose=False, conda_only=False): """ Install a conda package """ # Packages with special characters need to be enclosed in double quotes - cuda_cudnn = None success = True condaexe = ["conda", "install", "-y"] if not verbose or self.env.updater: @@ -725,26 +742,24 @@ def conda_installer(self, package, channel=None, verbose=False, conda_only=False if channel: condaexe.extend(["-c", channel]) - # Windows TF2.3 doesn't pull in the Cuda toolkit, so we may as well be explicit - # TODO This is not a robust enough check if we have more than 1 tf version - if package.startswith("tensorflow-gpu"): # Add toolkit - # TODO Remove this hack to lower the max supported TF version when TF2.4 can be - # installed by setup.py - package = package.replace("2.5.0", "2.4.0") + if package.startswith("tensorflow-gpu"): + # Here we will install the cuda/cudnn toolkits, currently only available from + # conda-forge, but fail tensorflow itself so that it can be handled by pip. specs = Requirement.parse(package).specs for key, val in TENSORFLOW_REQUIREMENTS.items(): req_specs = Requirement.parse("foobar" + key).specs if all(item in req_specs for item in specs): - cuda_cudnn = val + cuda, cudnn = val break + condaexe.extend(["-c", "conda-forge", f"cudatoolkit={cuda}", f"cudnn={cudnn}"]) + package = "Cuda Toolkit" + success = False - if any(char in package for char in (" ", "<", ">", "*", "|")): - package = f"\"{package}\"" - condaexe.append(package) + if package != "Cuda Toolkit": + if any(char in package for char in (" ", "<", ">", "*", "|")): + package = f"\"{package}\"" + condaexe.append(package) - if cuda_cudnn is not None: - condaexe.extend([f"cudatoolkit={cuda_cudnn[0]}", - f"cudnn={cuda_cudnn[1]}"]) self.output.info("Installing {}".format(package.replace("\"", ""))) shell = self.env.os_version[0] == "Windows" try: diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index c6fb1d9763..5b31f70248 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -394,7 +394,7 @@ def _background_extract(self, output_folder, progress_queue): progress_queue: :class:`queue.Queue` The queue to place incremental counts to for updating the GUI's progress bar """ - _io = dict(saver=ImagesSaver(str(get_folder(output_folder)), as_bytes=True), + _io = dict(saver=ImagesSaver(get_folder(output_folder), as_bytes=True), loader=ImagesLoader(self._input_location, count=self._alignments.frames_count)) for frame_idx, (filename, image) in enumerate(_io["loader"].load()): diff --git a/tools/mask/mask.py b/tools/mask/mask.py index a4d406984d..6dbb2ac197 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -94,7 +94,7 @@ def _set_saver(self, arguments): sys.exit(0) logger.debug("No output provided. Not creating saver") return None - output_dir = str(get_folder(arguments.output, make_folder=True)) + 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) @@ -404,7 +404,7 @@ def _save(self, frame, idx, detected_face): for mask_type in mask_types: if mask_type not in detected_face.mask: - # If extracting bisenet-fp mask, then skip versions which don't exist + # If extracting bisenet mask, then skip versions which don't exist continue filename = os.path.join(self._saver.location, "{}_{}_{}".format( os.path.splitext(frame)[0], From 7798a19c0ddc509a4ef5057fda8b87062b5067ff Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 7 Sep 2021 00:33:48 +0100 Subject: [PATCH 512/981] bugfix: _requirements_base.txt - Exclude badly versioned nvml --- _requirements_base.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/_requirements_base.txt b/_requirements_base.txt index d4662e8ec7..ac830076cc 100644 --- a/_requirements_base.txt +++ b/_requirements_base.txt @@ -10,6 +10,7 @@ matplotlib>=3.2.0,<3.3.0 imageio>=2.9.0 imageio-ffmpeg>=0.4.5 ffmpy==0.2.3 -nvidia-ml-py>=11.470.66 +# Exclude badly numbered Python2 version of nvidia-ml-py +nvidia-ml-py">=11.450,<300" pywin32>=228 ; sys_platform == "win32" pynvx==1.0.0 ; sys_platform == "darwin" From 15501b1e9e9959fa4d15534cbf869fbf71f90867 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 7 Sep 2021 00:35:31 +0100 Subject: [PATCH 513/981] typofix --- _requirements_base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_requirements_base.txt b/_requirements_base.txt index ac830076cc..b394346ec6 100644 --- a/_requirements_base.txt +++ b/_requirements_base.txt @@ -11,6 +11,6 @@ imageio>=2.9.0 imageio-ffmpeg>=0.4.5 ffmpy==0.2.3 # Exclude badly numbered Python2 version of nvidia-ml-py -nvidia-ml-py">=11.450,<300" +nvidia-ml-py>=11.450,<300 pywin32>=228 ; sys_platform == "win32" pynvx==1.0.0 ; sys_platform == "darwin" From 24888f61c3b24ac6662df6d5d07f6dc5e3149799 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 8 Sep 2021 00:18:33 +0100 Subject: [PATCH 514/981] Suppress ptxas error in GUI on Windows --- lib/gui/wrapper.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index d258685c47..276e34b773 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -248,6 +248,7 @@ def read_stderr(self): if os.name == "nt" and "Call to CreateProcess failed. Error code: 2" in output: # Suppress ptxas errors on Tensorflow for Windows logger.debug("Suppressed call to subprocess error: '%s'", output) + continue print(output.strip(), file=sys.stderr) logger.debug("Terminated stderr reader") From 860ccb91acc9934a42264dbf283d86e142059e47 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 8 Sep 2021 00:23:01 +0100 Subject: [PATCH 515/981] Suppress Tensorflow stderr custom error messages --- lib/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/utils.py b/lib/utils.py index 3e81cbad7b..28f2c6ad05 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -277,7 +277,7 @@ def set_system_verbosity(log_level): logger = logging.getLogger(__name__) # pylint:disable=invalid-name from lib.logger import get_loglevel # pylint:disable=import-outside-toplevel numeric_level = get_loglevel(log_level) - log_level = "2" if numeric_level > 15 else "0" + log_level = "3" if numeric_level > 15 else "0" logger.debug("System Verbosity level: %s", log_level) os.environ['TF_CPP_MIN_LOG_LEVEL'] = log_level if log_level != '0': From 086933d97dbd50bb9291026c1cf90fe94f463ce1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 9 Nov 2021 12:32:23 +0000 Subject: [PATCH 516/981] Bugfix -Weights freezing/loading for dfl-sae --- plugins/train/model/_base.py | 9 +++--- plugins/train/model/dfl_sae.py | 52 +++++++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 26692af369..270a90db0e 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -893,7 +893,7 @@ class _Weights(): Parameters ---------- plugin: :class:`Model` - The parent plugin class that owns the IO functions. + The parent plugin class that owns the weights functions. """ def __init__(self, plugin): logger.debug("Initializing %s: (plugin: %s)", self.__class__.__name__, plugin) @@ -928,13 +928,14 @@ def _check_weights_file(cls, weights_file): msg = "" if not os.path.exists(weights_file): - msg = "Load weights selected, but the path '%s' does not exist." + msg = f"Load weights selected, but the path '{weights_file}' does not exist." elif not os.path.splitext(weights_file)[-1].lower() == ".h5": - msg = "Load weights selected, but the path '%s' is not a valid Keras model (.h5) file." + msg = (f"Load weights selected, but the path '{weights_file}' is not a valid Keras " + f"model (.h5) file.") if msg: msg += " Please check and try again." - logger.error(msg) + raise FaceswapError(msg) logger.verbose("Using weights file: %s", weights_file) return weights_file diff --git a/plugins/train/model/dfl_sae.py b/plugins/train/model/dfl_sae.py index 2b94aada0d..ad79d57c5e 100644 --- a/plugins/train/model/dfl_sae.py +++ b/plugins/train/model/dfl_sae.py @@ -9,20 +9,31 @@ from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock -from ._base import ModelBase, KerasModel +from ._base import ModelBase, KerasModel, logger class Model(ModelBase): """ SAE Model from DFL """ def __init__(self, *args, **kwargs): + + self._patch_weights_management(args[1]) + super().__init__(*args, **kwargs) + self.input_shape = (self.config["input_size"], self.config["input_size"], 3) - self.architecture = self.config["architecture"].lower() self.use_mask = self.config.get("learn_mask", False) self.multiscale_count = 3 if self.config["multiscale_decoder"] else 1 self.encoder_dim = self.config["encoder_dims"] self.decoder_dim = self.config["decoder_dims"] + @property + def name(self): + """ str: The name of this model based on the plugin name. Overridden as DFL-SAE is + named differently depending on the architecture selected. """ + basename = super().name + name = f"{basename}_{self.architecture}" + return name + @property def ae_dims(self): """ Set the Autoencoder Dimensions or set to default """ @@ -31,6 +42,26 @@ def ae_dims(self): retval = 256 if self.architecture == "liae" else 512 return retval + def _patch_weights_management(self, arguments): + """ Patch in the correct encoder name into the config dictionary for freezing and loading + weights based on architecture. + + Because of variable model name based on architecture, configfile needs to be loaded + prior to initializing parent + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The arguments that were passed to the train or convert process as generated from + Faceswap's command line arguments + + """ + self._configfile = arguments.configfile if hasattr(arguments, "configfile") else None + self.architecture = self.config["architecture"].lower() + self.config["freeze_layers"] = [f"encoder_{self.architecture}"] + self.config["load_layers"] = [f"encoder_{self.architecture}"] + logger.debug("Patched encoder layers to config: %s", self.config) + def build_model(self, inputs): """ Build the DFL-SAE Model """ encoder = getattr(self, "encoder_{}".format(self.architecture))() @@ -53,7 +84,7 @@ def build_model(self, inputs): self.decoder("b", enc_output_shape)(encoder_b)] autoencoder = KerasModel(inputs, outputs, - name="{}_{}".format(self.name, self.architecture)) + name=self.name) return autoencoder def encoder_df(self): @@ -133,11 +164,12 @@ def decoder(self, side, input_shape): def _legacy_mapping(self): """ The mapping of legacy separate model names to single model names """ - mappings = dict(df={"{}_encoder.h5".format(self.name): "encoder_df", - "{}_decoder_A.h5".format(self.name): "decoder_a", - "{}_decoder_B.h5".format(self.name): "decoder_b"}, - liae={"{}_encoder.h5".format(self.name): "encoder_liae", - "{}_intermediate_B.h5".format(self.name): "intermediate_both", - "{}_intermediate.h5".format(self.name): "intermediate_b", - "{}_decoder.h5".format(self.name): "decoder_both"}) + name = "dfl_sae" + mappings = dict(df={"{}_encoder.h5".format(name): "encoder_df", + "{}_decoder_A.h5".format(name): "decoder_a", + "{}_decoder_B.h5".format(name): "decoder_b"}, + liae={"{}_encoder.h5".format(name): "encoder_liae", + "{}_intermediate_B.h5".format(name): "intermediate_both", + "{}_intermediate.h5".format(name): "intermediate_b", + "{}_decoder.h5".format(name): "decoder_both"}) return mappings[self.config["architecture"]] From 8b7b125edf983416e5ce275d45ffc0da303c7a14 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 10 Nov 2021 11:34:36 +0000 Subject: [PATCH 517/981] Revert "Bugfix -Weights freezing/loading for dfl-sae" This reverts commit 086933d97dbd50bb9291026c1cf90fe94f463ce1. --- plugins/train/model/_base.py | 9 +++--- plugins/train/model/dfl_sae.py | 52 +++++++--------------------------- 2 files changed, 14 insertions(+), 47 deletions(-) diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 270a90db0e..26692af369 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -893,7 +893,7 @@ class _Weights(): Parameters ---------- plugin: :class:`Model` - The parent plugin class that owns the weights functions. + The parent plugin class that owns the IO functions. """ def __init__(self, plugin): logger.debug("Initializing %s: (plugin: %s)", self.__class__.__name__, plugin) @@ -928,14 +928,13 @@ def _check_weights_file(cls, weights_file): msg = "" if not os.path.exists(weights_file): - msg = f"Load weights selected, but the path '{weights_file}' does not exist." + msg = "Load weights selected, but the path '%s' does not exist." elif not os.path.splitext(weights_file)[-1].lower() == ".h5": - msg = (f"Load weights selected, but the path '{weights_file}' is not a valid Keras " - f"model (.h5) file.") + msg = "Load weights selected, but the path '%s' is not a valid Keras model (.h5) file." if msg: msg += " Please check and try again." - raise FaceswapError(msg) + logger.error(msg) logger.verbose("Using weights file: %s", weights_file) return weights_file diff --git a/plugins/train/model/dfl_sae.py b/plugins/train/model/dfl_sae.py index ad79d57c5e..2b94aada0d 100644 --- a/plugins/train/model/dfl_sae.py +++ b/plugins/train/model/dfl_sae.py @@ -9,31 +9,20 @@ from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock -from ._base import ModelBase, KerasModel, logger +from ._base import ModelBase, KerasModel class Model(ModelBase): """ SAE Model from DFL """ def __init__(self, *args, **kwargs): - - self._patch_weights_management(args[1]) - super().__init__(*args, **kwargs) - self.input_shape = (self.config["input_size"], self.config["input_size"], 3) + self.architecture = self.config["architecture"].lower() self.use_mask = self.config.get("learn_mask", False) self.multiscale_count = 3 if self.config["multiscale_decoder"] else 1 self.encoder_dim = self.config["encoder_dims"] self.decoder_dim = self.config["decoder_dims"] - @property - def name(self): - """ str: The name of this model based on the plugin name. Overridden as DFL-SAE is - named differently depending on the architecture selected. """ - basename = super().name - name = f"{basename}_{self.architecture}" - return name - @property def ae_dims(self): """ Set the Autoencoder Dimensions or set to default """ @@ -42,26 +31,6 @@ def ae_dims(self): retval = 256 if self.architecture == "liae" else 512 return retval - def _patch_weights_management(self, arguments): - """ Patch in the correct encoder name into the config dictionary for freezing and loading - weights based on architecture. - - Because of variable model name based on architecture, configfile needs to be loaded - prior to initializing parent - - Parameters - ---------- - arguments: :class:`argparse.Namespace` - The arguments that were passed to the train or convert process as generated from - Faceswap's command line arguments - - """ - self._configfile = arguments.configfile if hasattr(arguments, "configfile") else None - self.architecture = self.config["architecture"].lower() - self.config["freeze_layers"] = [f"encoder_{self.architecture}"] - self.config["load_layers"] = [f"encoder_{self.architecture}"] - logger.debug("Patched encoder layers to config: %s", self.config) - def build_model(self, inputs): """ Build the DFL-SAE Model """ encoder = getattr(self, "encoder_{}".format(self.architecture))() @@ -84,7 +53,7 @@ def build_model(self, inputs): self.decoder("b", enc_output_shape)(encoder_b)] autoencoder = KerasModel(inputs, outputs, - name=self.name) + name="{}_{}".format(self.name, self.architecture)) return autoencoder def encoder_df(self): @@ -164,12 +133,11 @@ def decoder(self, side, input_shape): def _legacy_mapping(self): """ The mapping of legacy separate model names to single model names """ - name = "dfl_sae" - mappings = dict(df={"{}_encoder.h5".format(name): "encoder_df", - "{}_decoder_A.h5".format(name): "decoder_a", - "{}_decoder_B.h5".format(name): "decoder_b"}, - liae={"{}_encoder.h5".format(name): "encoder_liae", - "{}_intermediate_B.h5".format(name): "intermediate_both", - "{}_intermediate.h5".format(name): "intermediate_b", - "{}_decoder.h5".format(name): "decoder_both"}) + mappings = dict(df={"{}_encoder.h5".format(self.name): "encoder_df", + "{}_decoder_A.h5".format(self.name): "decoder_a", + "{}_decoder_B.h5".format(self.name): "decoder_b"}, + liae={"{}_encoder.h5".format(self.name): "encoder_liae", + "{}_intermediate_B.h5".format(self.name): "intermediate_both", + "{}_intermediate.h5".format(self.name): "intermediate_b", + "{}_decoder.h5".format(self.name): "decoder_both"}) return mappings[self.config["architecture"]] From 808e00895d06e804ff64f8fa0570404447c2cb9d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 14 Nov 2021 11:38:02 +0000 Subject: [PATCH 518/981] Bugfix -Weights freezing/loading for dfl-sae --- plugins/train/model/_base.py | 15 +++++++++++---- plugins/train/model/dfl_sae.py | 21 +++++++++++++++++++-- plugins/train/model/dlight.py | 2 +- plugins/train/model/iae.py | 2 +- plugins/train/model/original.py | 2 +- plugins/train/model/phaze_a.py | 2 +- plugins/train/model/realface.py | 2 +- plugins/train/model/unbalanced.py | 2 +- 8 files changed, 36 insertions(+), 12 deletions(-) diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 26692af369..49769d4738 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -198,6 +198,12 @@ def name(self): basename = os.path.basename(sys.modules[self.__module__].__file__) return os.path.splitext(basename)[0].lower() + @property + def model_name(self): + """ str: The name of the keras model. Generally this will be the same as :attr:`name` + but some plugins will override this when they contain multiple architectures """ + return self.name + @property def output_shapes(self): """ list: A list of list of shape tuples for the outputs of the model with the batch @@ -898,7 +904,7 @@ class _Weights(): def __init__(self, plugin): logger.debug("Initializing %s: (plugin: %s)", self.__class__.__name__, plugin) self._model = plugin.model - self._name = plugin.name + self._name = plugin.model_name self._do_freeze = plugin._args.freeze_weights self._weights_file = self._check_weights_file(plugin._args.load_weights) @@ -928,13 +934,14 @@ def _check_weights_file(cls, weights_file): msg = "" if not os.path.exists(weights_file): - msg = "Load weights selected, but the path '%s' does not exist." + msg = f"Load weights selected, but the path '{weights_file}' does not exist." elif not os.path.splitext(weights_file)[-1].lower() == ".h5": - msg = "Load weights selected, but the path '%s' is not a valid Keras model (.h5) file." + msg = (f"Load weights selected, but the path '{weights_file}' is not a valid Keras " + f"model (.h5) file.") if msg: msg += " Please check and try again." - logger.error(msg) + raise FaceswapError(msg) logger.verbose("Using weights file: %s", weights_file) return weights_file diff --git a/plugins/train/model/dfl_sae.py b/plugins/train/model/dfl_sae.py index 2b94aada0d..f6c7fafcd6 100644 --- a/plugins/train/model/dfl_sae.py +++ b/plugins/train/model/dfl_sae.py @@ -9,7 +9,7 @@ from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock -from ._base import ModelBase, KerasModel +from ._base import ModelBase, KerasModel, logger class Model(ModelBase): @@ -23,6 +23,13 @@ def __init__(self, *args, **kwargs): self.encoder_dim = self.config["encoder_dims"] self.decoder_dim = self.config["decoder_dims"] + self._patch_weights_management() + + @property + def model_name(self): + """ str: The name of the keras model. Varies depending on selected architecture. """ + return f"{self.name}_{self.architecture}" + @property def ae_dims(self): """ Set the Autoencoder Dimensions or set to default """ @@ -31,6 +38,16 @@ def ae_dims(self): retval = 256 if self.architecture == "liae" else 512 return retval + def _patch_weights_management(self): + """ Patch in the correct encoder name into the config dictionary for freezing and loading + weights based on architecture. + """ + self.config["freeze_layers"] = [f"encoder_{self.architecture}"] + self.config["load_layers"] = [f"encoder_{self.architecture}"] + logger.debug("Patched encoder layers to config: %s", + {k: v for k, v in self.config.items() + if k in ("freeze_layers", "load_layers")}) + def build_model(self, inputs): """ Build the DFL-SAE Model """ encoder = getattr(self, "encoder_{}".format(self.architecture))() @@ -53,7 +70,7 @@ def build_model(self, inputs): self.decoder("b", enc_output_shape)(encoder_b)] autoencoder = KerasModel(inputs, outputs, - name="{}_{}".format(self.name, self.architecture)) + name=self.model_name) return autoencoder def encoder_df(self): diff --git a/plugins/train/model/dlight.py b/plugins/train/model/dlight.py index 2672a8be01..66c7fbd217 100644 --- a/plugins/train/model/dlight.py +++ b/plugins/train/model/dlight.py @@ -55,7 +55,7 @@ def build_model(self, inputs): outputs = [self.decoder_a()(encoder_a), decoder_b()(encoder_b)] - autoencoder = KerasModel(inputs, outputs, name=self.name) + autoencoder = KerasModel(inputs, outputs, name=self.model_name) return autoencoder def encoder(self): diff --git a/plugins/train/model/iae.py b/plugins/train/model/iae.py index 9e7d956ff8..63ed593d3d 100644 --- a/plugins/train/model/iae.py +++ b/plugins/train/model/iae.py @@ -28,7 +28,7 @@ def build_model(self, inputs): outputs = [decoder(Concatenate()([inter_a(encoder_a), inter_both(encoder_a)])), decoder(Concatenate()([inter_b(encoder_b), inter_both(encoder_b)]))] - autoencoder = KerasModel(inputs, outputs, name=self.name) + autoencoder = KerasModel(inputs, outputs, name=self.model_name) return autoencoder def encoder(self): diff --git a/plugins/train/model/original.py b/plugins/train/model/original.py index 2380852230..41c193e6b3 100644 --- a/plugins/train/model/original.py +++ b/plugins/train/model/original.py @@ -94,7 +94,7 @@ def build_model(self, inputs): outputs = [self.decoder("a")(encoder_a), self.decoder("b")(encoder_b)] - autoencoder = KerasModel(inputs, outputs, name=self.name) + autoencoder = KerasModel(inputs, outputs, name=self.model_name) return autoencoder def encoder(self): diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index e2cc8e5ea3..70afcb397c 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -264,7 +264,7 @@ def build_model(self, inputs): # Create Autoencoder outputs = [decoders["a"], decoders["b"]] - autoencoder = KerasModel(inputs, outputs, name=self.name) + autoencoder = KerasModel(inputs, outputs, name=self.model_name) return autoencoder def _build_encoders(self, inputs): diff --git a/plugins/train/model/realface.py b/plugins/train/model/realface.py index df95fadf94..3a135eac81 100644 --- a/plugins/train/model/realface.py +++ b/plugins/train/model/realface.py @@ -72,7 +72,7 @@ def build_model(self, inputs): outputs = [self.decoder_a()(encoder_a), self.decoder_b()(encoder_b)] - autoencoder = KerasModel(inputs, outputs, name=self.name) + autoencoder = KerasModel(inputs, outputs, name=self.model_name) return autoencoder def encoder(self): diff --git a/plugins/train/model/unbalanced.py b/plugins/train/model/unbalanced.py index b122f3c3ae..9146755db2 100644 --- a/plugins/train/model/unbalanced.py +++ b/plugins/train/model/unbalanced.py @@ -27,7 +27,7 @@ def build_model(self, inputs): outputs = [self.decoder_a()(encoder_a), self.decoder_b()(encoder_b)] - autoencoder = KerasModel(inputs, outputs, name=self.name) + autoencoder = KerasModel(inputs, outputs, name=self.model_name) return autoencoder def encoder(self): From abb7b004cbd8585a59abf87f56864a93493e0c19 Mon Sep 17 00:00:00 2001 From: Markus Karileet Date: Sun, 5 Dec 2021 16:35:17 +0200 Subject: [PATCH 519/981] feat: add support for rectangle cursor shape for masking (#1189) * feat: add support for rectangle cursor shape for masking * formatting update * fix bug with initial rectangular point Co-authored-by: markus --- tools/manual/frameviewer/editor/mask.py | 70 +++++++++++++++++++++---- 1 file changed, 61 insertions(+), 9 deletions(-) diff --git a/tools/manual/frameviewer/editor/mask.py b/tools/manual/frameviewer/editor/mask.py index edec6a8d02..99d3f6b09d 100644 --- a/tools/manual/frameviewer/editor/mask.py +++ b/tools/manual/frameviewer/editor/mask.py @@ -42,8 +42,9 @@ def __init__(self, canvas, detected_faces): # Bind control click for reverse painting self._canvas.bind("", self._control_click) self._mask_type = self._set_tk_mask_change_callback() + self._cursor_shape = self._set_tk_cursor_shape_change_callback() self._mouse_location = [ - self._canvas.create_oval(0, 0, 0, 0, outline="black", state="hidden"), False] + self._get_cursor_shape(), False] @property def _opacity(self): @@ -69,6 +70,11 @@ def _cursor_color(self): """ str: The hex code for the selected cursor color """ return self._control_vars["brush"]["CursorColor"].get() + @property + def _cursor_shape_name(self): + """ str: The selected cursor shape """ + return self._control_vars["display"]["CursorShape"].get() + def _add_actions(self): """ Add the optional action buttons to the viewer. Current actions are Draw, Erase and Zoom. """ @@ -109,13 +115,28 @@ def _add_controls(self): choices="colorchooser", default="#ffffff", helptext=_("Select the brush cursor color."))) - + self._add_control(ControlPanelOption("Cursor Shape", + str, + group="Display", + choices=["Circle", "Rectangle"], + default="Circle", + is_radio=True, + helptext=_("Select a shape for masking cursor."))) def _set_tk_mask_change_callback(self): """ Add a trace to change the displayed mask on a mask type change. """ var = self._control_vars["display"]["MaskType"] var.trace("w", lambda *e: self._on_mask_type_change()) return var.get() + def _set_tk_cursor_shape_change_callback(self): + """ Add a trace to change the displayed cursor on a cursor shape type change. """ + var = self._control_vars["display"]["CursorShape"] + var.trace("w", lambda *e: self._on_cursor_shape_change()) + return var.get() + + def _on_cursor_shape_change(self): + self._mouse_location[0] = self._get_cursor_shape() + def _on_mask_type_change(self): """ Update the displayed mask on a mask type change """ mask_type = self._control_vars["display"]["MaskType"].get() @@ -436,6 +457,10 @@ def _drag_start(self, event, control_click=False): # pylint:disable=arguments-d self._drag_data["color"] = np.array(tuple(int(self._control_color[1:][i:i + 2], 16) for i in (0, 2, 4))) self._drag_data["opacity"] = self._opacity + self._get_cursor_shape_mark( + self._meta["mask"][face_idx], + np.array(((event.x, event.y), )), + face_idx) self._drag_callback = self._paint def _paint(self, event): @@ -503,18 +528,45 @@ def _drag_stop(self, event): return face_idx = self._mouse_location[1] location = np.array(((event.x, event.y), )) - color = 0 if self._edit_mode == "erase" else 255 - # Reverse action on control click - color = abs(color - 255) if self._drag_data["control_click"] else color if np.array_equal(self._drag_data["starting_location"], location[0]): - points, scale = self._transform_points(face_idx, location) - brush_radius = int(round(self._brush_radius * scale)) - cv2.circle(self._meta["mask"][face_idx], tuple(points), brush_radius, color, - thickness=-1) + self._get_cursor_shape_mark(self._meta["mask"][face_idx], location, face_idx) self._mask_to_alignments(face_idx) self._drag_data = dict() self._update_cursor(event) + def _get_cursor_shape_mark(self, img, location, face_idx): + """ Draw object depending on the cursor shape selection. Defaults to circle. + + Parameters + ---------- + img: Image to draw on (mask) + location: Cursor location coordinates that will be transformed to correct + coordinates + face_index: int + The index of the face within the current frame + """ + points, scale = self._transform_points(face_idx, location) + radius = int(round(self._brush_radius * scale)) + color = 0 if self._edit_mode == "erase" else 255 + # Reverse action on control click + color = abs(color - 255) if self._drag_data["control_click"] else color + + if self._cursor_shape_name == "Rectangle": + point2 = points.copy() + points[0] = points[0] - radius + points[1] = points[1] - radius + point2[0] = point2[0] + radius + point2[1] = point2[1] + radius + cv2.rectangle(img, tuple(points), tuple(point2), color, -1) + else: + cv2.circle(img, tuple(points), radius, color, thickness=-1) + + def _get_cursor_shape(self, x1=0, y1=0, x2=0, y2=0, outline="black", state="hidden"): + if self._cursor_shape_name == "Rectangle": + return self._canvas.create_rectangle(x1, y1, x2, y2, outline=outline, state=state) + else: + return self._canvas.create_oval(x1, y1, x2, y2, outline=outline, state=state) + def _mask_to_alignments(self, face_index): """ Update the annotated mask to alignments. From 3852b2b2d1e19cd2f0d72d111e9d6733bbf5ae26 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 5 Dec 2021 19:25:23 +0000 Subject: [PATCH 520/981] bugfix: Training - Select correct channel for loss multiplier L2Reg --- plugins/train/model/_base.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 26692af369..06f9a95b61 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -1266,14 +1266,15 @@ def _set_loss_functions(self, output_names): loss_func.add_loss(face_loss, mask_channel=mask_channels[0]) self._add_l2_regularization_term(loss_func, mask_channels[0]) - mask_channel = 1 + channel_idx = 1 for multiplier in ("eye_multiplier", "mouth_multiplier"): + mask_channel = mask_channels[channel_idx] if self._config[multiplier] > 1: loss_func.add_loss(face_loss, weight=self._config[multiplier] * 1.0, - mask_channel=mask_channels[mask_channel]) + mask_channel=mask_channel) self._add_l2_regularization_term(loss_func, mask_channel) - mask_channel += 1 + channel_idx += 1 logger.debug("%s: (output_name: '%s', function: %s)", name, output_name, loss_func) self._funcs[output_name] = loss_func From 183aee37e93708c0ae73845face5b4469319ebd3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 15 Jan 2022 02:16:59 +0000 Subject: [PATCH 521/981] bugfix: Pin pynvml to <11.515 --- _requirements_base.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/_requirements_base.txt b/_requirements_base.txt index b394346ec6..c20a985861 100644 --- a/_requirements_base.txt +++ b/_requirements_base.txt @@ -11,6 +11,9 @@ imageio>=2.9.0 imageio-ffmpeg>=0.4.5 ffmpy==0.2.3 # Exclude badly numbered Python2 version of nvidia-ml-py -nvidia-ml-py>=11.450,<300 +# nvidia-ml-py>=11.450,<300 +# v11.515.0 changes dtype of output items. Pinned for now +# TODO update code to use latest version +nvidia-ml-py>=11.450,<11.515 pywin32>=228 ; sys_platform == "win32" pynvx==1.0.0 ; sys_platform == "darwin" From 444762114c1b1ad2e72c871e825373bd74880aba Mon Sep 17 00:00:00 2001 From: Daniel Livingston Date: Sat, 19 Mar 2022 15:11:13 -0400 Subject: [PATCH 522/981] Initial somewhat working version --- lib/cli/launcher.py | 2 +- lib/gpu_stats.py | 16 +++++------ lib/metal/__init__.py | 65 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 lib/metal/__init__.py diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 09e6f63bc5..ae65525aaa 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -56,7 +56,7 @@ def _test_for_tf_version(self): If Tensorflow is not found, or is not between versions 2.2 and 2.6 """ min_ver = 2.2 - max_ver = 2.6 + max_ver = 2.8 #2.6 try: # Ensure tensorflow doesn't pin all threads to one core when using Math Kernel Library os.environ["TF_MIN_GPU_MULTIPROCESSOR_COUNT"] = "4" diff --git a/lib/gpu_stats.py b/lib/gpu_stats.py index 3509893a5a..e17a6023db 100644 --- a/lib/gpu_stats.py +++ b/lib/gpu_stats.py @@ -14,7 +14,7 @@ from lib.utils import get_backend if platform.system() == 'Darwin': - import pynvx # pylint: disable=import-error + import lib.metal as metal # pylint: disable=import-error IS_MACOS = True else: import pynvml @@ -165,7 +165,7 @@ def _initialize(self, log=False): elif IS_MACOS: self._log("debug", "macOS Detected. Using pynvx") try: - pynvx.cudaInit() + metal.init() except RuntimeError: self._initialized = True return @@ -218,7 +218,7 @@ def _get_device_count(self): if self._is_plaidml: self._device_count = self._plaid.device_count elif IS_MACOS: - self._device_count = pynvx.cudaDeviceGetCount(ignore=True) + self._device_count = metal.get_device_count() else: try: self._device_count = pynvml.nvmlDeviceGetCount() @@ -250,7 +250,7 @@ def _get_handles(self): if self._is_plaidml: self._handles = self._plaid.devices elif IS_MACOS: - self._handles = pynvx.cudaDeviceGetHandles(ignore=True) + self._handles = metal.get_handles() else: self._handles = [pynvml.nvmlDeviceGetHandleByIndex(i) for i in range(self._device_count)] @@ -267,7 +267,7 @@ def _get_driver(self): if self._is_plaidml: driver = self._plaid.drivers elif IS_MACOS: - driver = pynvx.cudaSystemGetDriverVersion(ignore=True) + driver = metal.get_driver_version() else: try: driver = pynvml.nvmlSystemGetDriverVersion().decode("utf-8") @@ -292,8 +292,7 @@ def _get_devices(self): if self._is_plaidml: names = self._plaid.names elif IS_MACOS: - names = [pynvx.cudaGetName(handle, ignore=True) - for handle in self._handles] + names = metal.get_device_names() else: names = [pynvml.nvmlDeviceGetName(handle).decode("utf-8") for handle in self._handles] @@ -315,8 +314,7 @@ def _get_vram(self): elif self._is_plaidml: vram = self._plaid.vram elif IS_MACOS: - vram = [pynvx.cudaGetMemTotal(handle, ignore=True) / (1024 * 1024) - for handle in self._handles] + vram = [metal.get_memory_info(i) / (1024 * 1024) for i in range(self._device_count)] else: vram = [pynvml.nvmlDeviceGetMemoryInfo(handle).total / (1024 * 1024) diff --git a/lib/metal/__init__.py b/lib/metal/__init__.py new file mode 100644 index 0000000000..fbeebb6c2f --- /dev/null +++ b/lib/metal/__init__.py @@ -0,0 +1,65 @@ +from typing import List +import os +import psutil # used for getting GPU memory +import tensorflow as tf + +class Constants: + class System: + ARCH = 'arm64' + DEVICE_TYPE = 'GPU' + SET_MEMORY_GROWTH = True + class CUDA: + DRIVER_VERSION_UNSUPPORTED = 0 + +def _dbg_check_mem(): + print("==========================================") + print(tf.config.experimental.get_memory_info('GPU:0')) + print(tf.config.list_logical_devices()) + print("==========================================") + +def _validate_metal(): + # Validate a GPU exists + assert(len(tf.config.experimental.list_physical_devices('GPU')) > 0) + + # Validate Metal device is working + with tf.device('GPU:0'): + assert(tf.math.add(1.0, 2.0) == 3.0) + +def init(device_type: str = 'GPU') -> None: + _validate_metal() + + os.environ['DISPLAY'] = ':0' + try: + os.system('open -a XQuartz') + except Exception: + pass + Constants.System.DEVICE_TYPE = device_type + + for device in get_devices(): + tf.config.experimental.set_memory_growth(device, Constants.System.SET_MEMORY_GROWTH) + + _dbg_check_mem() + +def get_devices() -> List[tf.config.PhysicalDevice]: + return tf.config.list_physical_devices(device_type=Constants.System.DEVICE_TYPE) + +def get_device_count() -> int: + return len(get_devices()) + +def get_handles() -> list: + return list(range(get_device_count())) + +def get_driver_version() -> int: + # https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART____VERSION.html + return Constants.CUDA.DRIVER_VERSION_UNSUPPORTED + +def get_device_names() -> List[str]: + return [d.name for d in get_devices()] + +def get_memory_info(handle: int) -> int: + # Does not work: + # tf.config.experimental.get_memory_info('GPU:0') + # So, using psutil instead. + # We can just grab the total memory, as it's shared between + # the CPU and the GPU. There is no dedicated VRAM. + return psutil.virtual_memory().total / get_device_count() \ No newline at end of file From 2714964f432584e3175022e6fb7c4b34ea9ad0b6 Mon Sep 17 00:00:00 2001 From: Daniel Livingston Date: Sat, 19 Mar 2022 15:16:38 -0400 Subject: [PATCH 523/981] conda env --- conda-environment-apple-silicon.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 conda-environment-apple-silicon.yml diff --git a/conda-environment-apple-silicon.yml b/conda-environment-apple-silicon.yml new file mode 100644 index 0000000000..aca2972274 --- /dev/null +++ b/conda-environment-apple-silicon.yml @@ -0,0 +1,24 @@ +name: faceswap +channels: + - conda-forge + - apple +dependencies: + - python>=3.8,<3.10 + - pip + - tensorflow-deps==2.6.0 + - tk + - libblas + - pip: + - tensorflow-macos + - tensorflow-metal + - tqdm>=4.62 + - psutil>=5.8.0 + - numpy>=1.18.0,<1.22.0 + - opencv-python>=4.5.3.0 + - pillow>=8.3.1 + - scikit-learn>=0.24.2 + - fastcluster>=1.1.26 + - matplotlib>=3.2.0,<3.3.0 + - imageio>=2.9.0 + - imageio-ffmpeg>=0.4.5 + - ffmpy==0.2.3 \ No newline at end of file From a7ef098083e834eb663a36cc6301dda3fd3221d2 Mon Sep 17 00:00:00 2001 From: Daniel Livingston Date: Sat, 19 Mar 2022 15:31:42 -0400 Subject: [PATCH 524/981] keras import errors fix --- lib/model/initializers.py | 6 +++++- lib/model/layers.py | 5 ++++- lib/model/normalization/normalization_common.py | 7 ++++++- lib/model/optimizers_tf.py | 6 +++++- lib/utils.py | 8 ++++---- plugins/train/model/_base.py | 6 +++++- 6 files changed, 29 insertions(+), 9 deletions(-) diff --git a/lib/model/initializers.py b/lib/model/initializers.py index c436342284..2287223118 100644 --- a/lib/model/initializers.py +++ b/lib/model/initializers.py @@ -9,7 +9,11 @@ import tensorflow as tf from keras import backend as K from keras import initializers -from keras.utils import get_custom_objects + +try: + from keras.utils import get_custom_objects +except ImportError: + from tensorflow.keras.utils import get_custom_objects from lib.utils import get_backend diff --git a/lib/model/layers.py b/lib/model/layers.py index 9a9986aeef..e6b87f7661 100644 --- a/lib/model/layers.py +++ b/lib/model/layers.py @@ -10,7 +10,10 @@ import keras.backend as K from keras.layers import InputSpec, Layer -from keras.utils import get_custom_objects +try: + from keras.utils import get_custom_objects +except ImportError: + from tensorflow.keras.utils import get_custom_objects from lib.utils import get_backend diff --git a/lib/model/normalization/normalization_common.py b/lib/model/normalization/normalization_common.py index 0625368fca..d779ba5707 100644 --- a/lib/model/normalization/normalization_common.py +++ b/lib/model/normalization/normalization_common.py @@ -1,13 +1,18 @@ #!/usr/bin/env python3 """ Normalization methods for faceswap.py common to both Plaid and Tensorflow Backends """ +from ast import Import import sys import inspect from keras.layers import Layer, InputSpec from keras import initializers, regularizers, constraints from keras import backend as K -from keras.utils import get_custom_objects + +try: + from keras.utils import get_custom_objects +except ImportError: + from tensorflow.keras.utils import get_custom_objects from lib.utils import get_backend diff --git a/lib/model/optimizers_tf.py b/lib/model/optimizers_tf.py index 2b1afdaedc..bef6524308 100644 --- a/lib/model/optimizers_tf.py +++ b/lib/model/optimizers_tf.py @@ -8,7 +8,11 @@ import sys import tensorflow as tf -from keras.utils import get_custom_objects + +try: + from keras.utils import get_custom_objects +except ImportError: + from tensorflow.keras.utils import get_custom_objects class AdaBelief(tf.keras.optimizers.Optimizer): diff --git a/lib/utils.py b/lib/utils.py index 28f2c6ad05..ed72f1d6b0 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -30,7 +30,7 @@ class _Backend(): # pylint:disable=too-few-public-methods If file doesn't exist and a variable hasn't been set, create the config file. """ def __init__(self): - self._backends = {"1": "amd", "2": "cpu", "3": "nvidia"} + self._backends = {"1": "amd", "2": "cpu", "3": "nvidia", "4": "apple"} self._config_file = self._get_config_file() self.backend = self._get_backend() @@ -93,8 +93,8 @@ def _configure_backend(self): """ print("First time configuration. Please select the required backend") while True: - selection = input("1: AMD, 2: CPU, 3: NVIDIA: ") - if selection not in ("1", "2", "3"): + selection = input("1: AMD, 2: CPU, 3: NVIDIA, 4: Apple: ") + if selection not in ("1", "2", "3", "4"): print("'{}' is not a valid selection. Please try again".format(selection)) continue break @@ -125,7 +125,7 @@ def set_backend(backend): Parameters ---------- - backend: ["amd", "cpu", "nvidia"] + backend: ["amd", "cpu", "nvidia", "apple"] The backend to set faceswap to """ global _FS_BACKEND # pylint:disable=global-statement diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 2efbdd59a0..fc49b8d235 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -20,7 +20,11 @@ from keras import backend as K from keras.layers import Input from keras.models import load_model, Model as KModel -from keras.optimizers import Adam, Nadam, RMSprop + +try: + from keras.optimizers import Adam, Nadam, RMSprop +except ImportError: + from tensorflow.keras.optimizers import Adam, Nadam, RMSprop from lib.serializer import get_serializer from lib.model.backup_restore import Backup From d6eedb09ff1bd56503453986444dffa3cd245d74 Mon Sep 17 00:00:00 2001 From: Daniel Livingston Date: Sat, 19 Mar 2022 15:43:21 -0400 Subject: [PATCH 525/981] added apple silicon backend --- lib/gpu_stats.py | 2 +- lib/metal/__init__.py | 6 +++--- setup.py | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/gpu_stats.py b/lib/gpu_stats.py index e17a6023db..331165789a 100644 --- a/lib/gpu_stats.py +++ b/lib/gpu_stats.py @@ -163,7 +163,7 @@ def _initialize(self, log=False): loglevel = "INFO" if self._logger is None else self._logger.getEffectiveLevel() self._plaid = plaidlib(log_level=loglevel, log=log) elif IS_MACOS: - self._log("debug", "macOS Detected. Using pynvx") + self._log("debug", "macOS Detected.") try: metal.init() except RuntimeError: diff --git a/lib/metal/__init__.py b/lib/metal/__init__.py index fbeebb6c2f..7fa2af07f7 100644 --- a/lib/metal/__init__.py +++ b/lib/metal/__init__.py @@ -26,7 +26,7 @@ def _validate_metal(): assert(tf.math.add(1.0, 2.0) == 3.0) def init(device_type: str = 'GPU') -> None: - _validate_metal() + #_validate_metal() os.environ['DISPLAY'] = ':0' try: @@ -35,8 +35,8 @@ def init(device_type: str = 'GPU') -> None: pass Constants.System.DEVICE_TYPE = device_type - for device in get_devices(): - tf.config.experimental.set_memory_growth(device, Constants.System.SET_MEMORY_GROWTH) + #for device in get_devices(): + # tf.config.experimental.set_memory_growth(device, Constants.System.SET_MEMORY_GROWTH) _dbg_check_mem() diff --git a/setup.py b/setup.py index 6c88bc63d0..ad98a91059 100755 --- a/setup.py +++ b/setup.py @@ -289,6 +289,8 @@ def set_config(self): backend = "amd" elif self.enable_cuda: backend = "nvidia" + elif self.enable_apple: + backend = "apple" else: backend = "cpu" config = {"backend": backend} From b76879af13bba54afbbe797d8ac5d12398820e27 Mon Sep 17 00:00:00 2001 From: Daniel Livingston Date: Sat, 19 Mar 2022 16:03:40 -0400 Subject: [PATCH 526/981] update readme --- README.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0507c8f16c..3aaf4c8cf4 100755 --- a/README.md +++ b/README.md @@ -17,9 +17,25 @@ [![Build Status](https://travis-ci.org/deepfakes/faceswap.svg?branch=master)](https://travis-ci.org/deepfakes/faceswap) [![Documentation Status](https://readthedocs.org/projects/faceswap/badge/?version=latest)](https://faceswap.readthedocs.io/en/latest/?badge=latest) +============================ +# Apple Silicon port +## WIP port for GPU-accelerated, native Apple Silicon processing. + +```sh +$ conda env create -f conda-environment-apple-silicon.yml +``` + +Ensure that the backend is set to "apple" and **not** to "cpu". +You may have to modify the config file in `./config/.faceswap`. + +============================ + + Make sure you check out [INSTALL.md](INSTALL.md) before getting started. -- [deepfakes_faceswap](#deepfakesfaceswap) +- [deepfakes_faceswap](#deepfakes_faceswap) +- [Apple Silicon port](#apple-silicon-port) + - [WIP port for GPU-accelerated, native Apple Silicon processing.](#wip-port-for-gpu-accelerated-native-apple-silicon-processing) - [Manifesto](#manifesto) - [FaceSwap has ethical uses.](#faceswap-has-ethical-uses) - [How To setup and run the project](#how-to-setup-and-run-the-project) @@ -37,7 +53,6 @@ Make sure you check out [INSTALL.md](INSTALL.md) before getting started. - [One time Donations](#one-time-donations) - [@torzdf](#torzdf) - [@andenixa](#andenixa) - - [@kvrooman](#kvrooman) - [How to contribute](#how-to-contribute) - [For people interested in the generative models](#for-people-interested-in-the-generative-models) - [For devs](#for-devs) From 7f3e6bc3cfc09a7abfac8070bf9ca7f5ffc40604 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 28 Mar 2022 12:58:43 +0100 Subject: [PATCH 527/981] Bisenet-FP - Improved weights for masker (#1210) * Add option to load faceswap trained weights * Switch to original weights if fs weights not present * typofix --- plugins/extract/mask/bisenet_fp.py | 61 +++++++++++++++++---- plugins/extract/mask/bisenet_fp_defaults.py | 18 +++++- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/plugins/extract/mask/bisenet_fp.py b/plugins/extract/mask/bisenet_fp.py index 0ada9fe68d..8341e42be7 100644 --- a/plugins/extract/mask/bisenet_fp.py +++ b/plugins/extract/mask/bisenet_fp.py @@ -4,6 +4,7 @@ Architecture and Pre-Trained Model ported from PyTorch to Keras by TorzDF from https://github.com/zllrunning/face-parsing.PyTorch """ +import os import numpy as np @@ -13,15 +14,19 @@ UpSampling2D, ZeroPadding2D) from lib.model.session import KSession +from plugins.extract._base import _get_config from ._base import Masker, logger class Mask(Masker): """ Neural network to process face image into a segmentation mask of the face """ def __init__(self, **kwargs): + self._is_faceswap = self._check_weights_selection(kwargs.get("configfile")) + git_model_id = 14 - model_filename = "bisnet_face_parsing_v1.h5" + model_filename = f"bisnet_face_parsing_v{'2' if self._is_faceswap else '1'}.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.name = "BiSeNet - Face Parsing" self.input_size = 512 self.color_format = "RGB" @@ -29,12 +34,43 @@ def __init__(self, **kwargs): self.vram_warnings = 256 self.vram_per_batch = 64 self.batchsize = self.config["batch-size"] - self._segment_indices = self._get_segment_indices() + self._segment_indices = self._get_segment_indices() self._storage_centering = "head" if self.config["include_hair"] else "face" # Separate storage for face and head masks self._storage_name = f"{self._storage_name}_{self._storage_centering}" + def _check_weights_selection(self, configfile): + """ Check which weights have been selected. + + This is required for passing along the correct file name for the corresponding weights + selection, so config needs to be loaded and scanned prior to parent loading it. + + Parameters + ---------- + configfile: str + Path to a custom configuration ``ini`` file. ``None`` to use system configfile + + Returns + ------- + bool + ``True`` if `faceswap` trained weights have been selected. ``False`` if `original` + weights have been selected + """ + config = _get_config(".".join(self.__module__.split(".")[-2:]), configfile=configfile) + retval = config.get("weights", "faceswap").lower() == "faceswap" + + # TODO Remove this check when weights moved to main code. + if retval: + _chk_dir = os.listdir(os.path.join(os.path.dirname(__file__), ".cache")) + if 'bisnet_face_parsing_v2.h5' not in _chk_dir: + logger.warning("'Faceswap' trained weights are currently Patreon timed exclusive. " + "They will be coming to the main code soon.") + logger.warning("Switching to 'Original' weights.") + retval = False + + return retval + def _get_segment_indices(self): """ Obtain the segment indices to include within the face mask area based on user configuration settings. @@ -46,28 +82,33 @@ def _get_segment_indices(self): Notes ----- - Model segment indices: + 'original' Model segment indices: 0: background, 1: skin, 2: left brow, 3: right brow, 4: left eye, 5: right eye, 6: glasses 7: left ear, 8: right ear, 9: earing, 10: nose, 11: mouth, 12: upper lip, 13: lower_lip, 14: neck, 15: neck ?, 16: cloth, 17: hair, 18: hat + + 'faceswap' Model segment indices: + 0: background, 1: skin, 2: ears, 3: hair, 4: glasses """ - retval = [1, 2, 3, 4, 5, 10, 11, 12, 13] + retval = [1] if self._is_faceswap else [1, 2, 3, 4, 5, 10, 11, 12, 13] + if self.config["include_glasses"]: - retval.append(6) + retval.append(4 if self._is_faceswap else 6) if self.config["include_ears"]: - retval.extend([7, 8, 9]) + retval.extend([2] if self._is_faceswap else [7, 8, 9]) if self.config["include_hair"]: - retval.append(17) + retval.append(3 if self._is_faceswap else 17) logger.debug("Selected segment indices: %s", retval) return retval def init_model(self): """ Initialize the BiSeNet Face Parsing model. """ + lbls = 5 if self._is_faceswap else 19 self.model = BiSeNet(self.model_path, self.config["allow_growth"], self._exclude_gpus, self.input_size, - 19) + lbls) placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), dtype="float32") @@ -75,8 +116,8 @@ def init_model(self): def process_input(self, batch): """ Compile the detected faces for prediction """ - mean = (0.485, 0.456, 0.406) - std = (0.229, 0.224, 0.225) + mean = (0.384, 0.314, 0.279) if self._is_faceswap else (0.485, 0.456, 0.406) + std = (0.324, 0.286, 0.275) if self._is_faceswap else (0.229, 0.224, 0.225) batch["feed"] = ((np.array([feed.face[..., :3] for feed in batch["feed_faces"]], diff --git a/plugins/extract/mask/bisenet_fp_defaults.py b/plugins/extract/mask/bisenet_fp_defaults.py index 313928473e..ab556299e5 100644 --- a/plugins/extract/mask/bisenet_fp_defaults.py +++ b/plugins/extract/mask/bisenet_fp_defaults.py @@ -63,6 +63,18 @@ group="settings", gui_radio=False, fixed=True), + "weights": dict( + default="faceswap", + info="The trained weights to use.\n" + "\n\tfaceswap - Weights trained on wildly varied Faceswap extracted data to better " + "handle varying conditions, obstructions, glasses and multiple targets within a " + "single extracted image." + "\n\toriginal - The original weights trained on the CelebAMask-HQ dataset.", + choices=["faceswap", "original"], + datatype=str, + group="settings", + gui_radio=True, + ), "include_ears": dict( default=False, info="Whether to include ears within the face mask.", @@ -77,8 +89,10 @@ ), "include_glasses": dict( default=True, - info="Whether to include glasses within the face mask. NB: excluding glasses will mask " - "out the lenses as well as the frames.", + info="Whether to include glasses within the face mask.\n\tFor 'original' weights " + "excluding glasses will mask out the lenses as well as the frames.\n\tFor 'faceswap' " + "weights, the model has been trained to mask out lenses if eyes cannot be seen (i.e. " + "dark sunglasses) or just the frames if the eyes can be seen. ", datatype=bool, group="settings" ), From 7dd1122c1ca7f2b2726185a97e5d5b254d3bfd6f Mon Sep 17 00:00:00 2001 From: geewiz94 <94993977+geewiz94@users.noreply.github.com> Date: Tue, 29 Mar 2022 17:48:51 +0200 Subject: [PATCH 528/981] Get free VRAM from Metal --- lib/gpu_stats.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/gpu_stats.py b/lib/gpu_stats.py index 331165789a..001899995d 100644 --- a/lib/gpu_stats.py +++ b/lib/gpu_stats.py @@ -342,8 +342,7 @@ def _get_free_vram(self): if self._is_plaidml: vram = self._plaid.vram elif IS_MACOS: - vram = [pynvx.cudaGetMemFree(handle, ignore=True) / (1024 * 1024) - for handle in self._handles] + vram = [metal.get_memory_info(i) / (1024 * 1024) for i in range(self._device_count)] else: vram = [pynvml.nvmlDeviceGetMemoryInfo(handle).free / (1024 * 1024) for handle in self._handles] From 23d92c1f0d83ce1cdcc51480cfe37af074a981b3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 30 Mar 2022 02:54:29 +0100 Subject: [PATCH 529/981] Bugfixes - Sort - Fix rare help-text parsing bug - Manual - Fix issue where frame count is incorrect when een > 1 used on extract --- locales/es/LC_MESSAGES/tools.sort.cli.mo | Bin 10114 -> 10116 bytes locales/es/LC_MESSAGES/tools.sort.cli.po | 6 +++--- locales/tools.sort.cli.pot | 2 +- tools/manual/detected_faces.py | 7 ++++++- tools/sort/cli.py | 2 +- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/locales/es/LC_MESSAGES/tools.sort.cli.mo b/locales/es/LC_MESSAGES/tools.sort.cli.mo index 40dec2bf0ae384211e4e17a5ecbb8f53c3438580..504ee6da2476b52fda7eaf41365180b285682369 100644 GIT binary patch delta 303 zcmX}nyGjE=6oBD>U3X*D2pWwTZ?T9sBzP+fhzQyULP#eFLJGwutzZ?9R94D@mEc2c z=@W=x=@VFptsr8d{y}h>Z_b%BXU<&I+O@0A)>jv7b;Bc2&A^LBJ+P>6`l=&YD0%j+ zE5_4<@T_&+(D@;-1r3e$M7>H=b9$z`0krkh_a5?aV}7;3^?kOrAHsV9K22>GVbs#< z_+RV#1PmKrYQ>&+ldx{xQyF^AhqJI}JTT|jdZ3*Vj?{@cR$J3l%l}uOJAcy|FWCte if+%eUNtFD=rF1HD-XE@2mebq(VVr!7Eu}NXM(!8EdnauG delta 305 zcmXBPu}T9$5P;!-jma5J3O$J!4Jv3vg@7>-3rQmg+FWO&prC~i>@4g8!NO8Gu+pcv z(kBqDeFBU0HYqIpqYKN&%+Aiv-X;CyVXrqFgU&d-1MLXDG&cd8I?#c}MOgOiN4Je@ z)9|T@?rMDoY{8ip^isV_SIc^%2Lbwe<$G^2JehA+cziAPj&kr_fuX5R73M5$FZ{Po z7Gc)-UK{p&sX@!Sk2RPyAL?*uTv~^`9_wg?Q*~l4)Yf#>^1n5zn{xY`(Rk^8*a&3T gA Date: Wed, 30 Mar 2022 02:56:17 +0100 Subject: [PATCH 530/981] Remove debug code --- tools/manual/detected_faces.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index 7e2f8aba67..43138f51da 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -304,8 +304,6 @@ def revert_to_saved(self, frame_index): logger.debug("Alignments not amended. Returning") return logger.verbose("Reverting alignments for frame_index %s", frame_index) - print(frame_index) - print(len(self._sorted_frame_names)) alignments = self._alignments.data[self._sorted_frame_names[frame_index]]["faces"] faces = self._frame_faces[frame_index] From 30872ef265c0fc29465f4c3a0778d0049f8c3897 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 30 Mar 2022 15:08:41 +0100 Subject: [PATCH 531/981] alignments tool - Don't re-analyze video if metadata in alignments --- tools/alignments/jobs.py | 19 ++++++++++++++++++- tools/alignments/media.py | 9 ++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index af07e8dd86..6b424be79e 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -405,13 +405,30 @@ def __init__(self, alignments, arguments): self._is_legacy = self._alignments.version == 1.0 # pylint:disable=protected-access self._mask_pipeline = None self._faces_dir = arguments.faces_dir - self._frames = Frames(arguments.frames_dir) + + self._frames = Frames(arguments.frames_dir, self._get_count()) self._extracted_faces = ExtractedFaces(self._frames, self._alignments, size=arguments.size) self._saver = None logger.debug("Initialized %s", self.__class__.__name__) + def _get_count(self): + """ If the alignments file has been run through the manual tool, then it will hold video + meta information, meaning that the count of frames in the alignment file can be relied + on to be accurate. + + Returns + ------- + int or ``None`` + For video input which contain video meta-data in the alignments file then the count of + frames is returned. In all other cases ``None`` is returned + """ + has_meta = all(val is not None for val in self._alignments.video_meta_data.values()) + retval = len(self._alignments.video_meta_data["pts_time"]) if has_meta else None + logger.debug("Frame count from alignments file: (has_meta: %s, %s", has_meta, retval) + return retval + def process(self): """ Run the re-extraction from Alignments file process""" logger.info("[EXTRACT FACES]") # Tidy up cli output diff --git a/tools/alignments/media.py b/tools/alignments/media.py index 2494fd01f2..196601a551 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -72,11 +72,14 @@ class MediaLoader(): ---------- folder: str The folder of images or video file to load images from + count: int or ``None``, optional + If the total frame count is known it can be passed in here which will skip + analyzing a video file. If the count is not passed in, it will be calculated. """ - def __init__(self, folder): + def __init__(self, folder, count=None): logger.debug("Initializing %s: (folder: '%s')", self.__class__.__name__, folder) logger.info("[%s DATA]", self.__class__.__name__.upper()) - self._count = None + self._count = count self.folder = folder self.vid_reader = self.check_input_folder() self.file_list_sorted = self.sorted_items() @@ -188,7 +191,7 @@ def stream(self, skip_list=None): numpy.ndarray The image that has been loaded from disk """ - loader = ImagesLoader(self.folder, queue_size=32) + loader = ImagesLoader(self.folder, queue_size=32, count=self._count) if skip_list is not None: loader.add_skip_list(skip_list) for filename, image in loader.load(): From c789e7f3d810760b31eab7a05c0f6e2689f9157c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 11 Apr 2022 18:09:56 +0100 Subject: [PATCH 532/981] Release bisenet-fp obstructed trained weights. --- plugins/extract/mask/bisenet_fp.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/plugins/extract/mask/bisenet_fp.py b/plugins/extract/mask/bisenet_fp.py index 8341e42be7..152eefc1bd 100644 --- a/plugins/extract/mask/bisenet_fp.py +++ b/plugins/extract/mask/bisenet_fp.py @@ -4,8 +4,6 @@ Architecture and Pre-Trained Model ported from PyTorch to Keras by TorzDF from https://github.com/zllrunning/face-parsing.PyTorch """ -import os - import numpy as np from keras import backend as K @@ -59,16 +57,6 @@ def _check_weights_selection(self, configfile): """ config = _get_config(".".join(self.__module__.split(".")[-2:]), configfile=configfile) retval = config.get("weights", "faceswap").lower() == "faceswap" - - # TODO Remove this check when weights moved to main code. - if retval: - _chk_dir = os.listdir(os.path.join(os.path.dirname(__file__), ".cache")) - if 'bisnet_face_parsing_v2.h5' not in _chk_dir: - logger.warning("'Faceswap' trained weights are currently Patreon timed exclusive. " - "They will be coming to the main code soon.") - logger.warning("Switching to 'Original' weights.") - retval = False - return retval def _get_segment_indices(self): From cda49b3c3cdaef18ad839c97785f157c3a3026a9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 16 Apr 2022 15:13:46 +0100 Subject: [PATCH 533/981] Bugfix - Fix graphing not always showing loss for both sides --- lib/gui/analysis/event_reader.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index cba88fe2e6..bc87744f58 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -604,8 +604,11 @@ def _parse_outputs(self, event): config = serializer.unmarshal(struct)["config"] model_outputs = self._get_outputs(config) - split_output = len(np.unique(model_outputs[..., 1])) == 1 + # loss length of unique should be 3: + # - decoder_both, 1, 2 + # - docoder_a, decoder_b, 1 + split_output = len(np.unique(model_outputs[..., :2])) != 3 for side_outputs, side in zip(model_outputs, ("a", "b")): logger.debug("side: '%s', outputs: '%s'", side, side_outputs) layer_name = side_outputs[0][0] From c1512fd41d86ef47a5d1ce618d6d755ef7cbacdf Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 2 May 2022 14:30:43 +0100 Subject: [PATCH 534/981] Update code to support Tensorflow versions up to 2.8 (#1213) * Update maximum tf version in setup + requirements * - bump max version of tf version in launcher - standardise tf version check * update keras get_custom_objects for tf>2.6 * bugfix: force black text in GUI file dialogs (linux) * dssim loss - Move to stock tf.ssim function * Update optimizer imports for compatibility * fix logging for tf2.8 * Fix GUI graphing for TF2.8 * update tests * bump requirements.txt versions * Remove limit on nvidia-ml-py * Graphing bugfixes - Prevent live graph from displaying if data not yet available * bugfix: Live graph. Collect loss labels correctly * fix: live graph - swallow inconsistent loss errors * Bugfix: Prevent live graph from clearing during training * Fix graphing for AMD --- _requirements_base.txt | 19 +- lib/cli/launcher.py | 22 +-- lib/gui/analysis/event_reader.py | 99 ++++++++-- lib/gui/analysis/stats.py | 57 +++--- lib/gui/display_command.py | 34 ++-- lib/gui/display_page.py | 16 +- lib/gui/utils.py | 67 +++++-- lib/model/initializers.py | 13 +- lib/model/layers.py | 63 +++--- lib/model/losses_tf.py | 184 +++--------------- .../normalization/normalization_common.py | 13 +- lib/model/normalization/normalization_tf.py | 17 +- lib/model/optimizers_plaid.py | 2 +- lib/model/optimizers_tf.py | 20 +- lib/utils.py | 70 ++++--- plugins/train/model/_base.py | 79 ++++---- plugins/train/trainer/_base.py | 70 ++++--- requirements_cpu.txt | 2 +- requirements_nvidia.txt | 2 +- setup.py | 163 ++++++++++------ tests/lib/model/losses_test.py | 38 ---- tests/lib/model/optimizers_test.py | 6 +- tests/startup_test.py | 3 +- 23 files changed, 553 insertions(+), 506 deletions(-) diff --git a/_requirements_base.txt b/_requirements_base.txt index c20a985861..923a0c134d 100644 --- a/_requirements_base.txt +++ b/_requirements_base.txt @@ -1,19 +1,16 @@ -tqdm>=4.62 +tqdm>=4.64 psutil>=5.8.0 -numpy>=1.18.0,<1.20.0 -opencv-python>=4.5.3.0 -pillow>=8.3.1 -scikit-learn>=0.24.2 -fastcluster>=1.1.26 +numpy>=1.18.0 +opencv-python>=4.5.5.0 +pillow>=9.0.1 +scikit-learn>=1.0.2 +fastcluster>=1.2.4 # matplotlib 3.3.1 breaks custom toolbar in graph popup matplotlib>=3.2.0,<3.3.0 imageio>=2.9.0 -imageio-ffmpeg>=0.4.5 +imageio-ffmpeg>=0.4.7 ffmpy==0.2.3 # Exclude badly numbered Python2 version of nvidia-ml-py -# nvidia-ml-py>=11.450,<300 -# v11.515.0 changes dtype of output items. Pinned for now -# TODO update code to use latest version -nvidia-ml-py>=11.450,<11.515 +nvidia-ml-py>=11.510,<300 pywin32>=228 ; sys_platform == "win32" pynvx==1.0.0 ; sys_platform == "darwin" diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 09e6f63bc5..b43e274c06 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -9,8 +9,8 @@ from lib.gpu_stats import set_exclude_devices, GPUStats from lib.logger import crash_log, log_setup -from lib.utils import (FaceswapError, get_backend, KerasFinder, safe_shutdown, set_backend, - set_system_verbosity) +from lib.utils import (FaceswapError, get_backend, get_tf_version, KerasFinder, safe_shutdown, + set_backend, set_system_verbosity) logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -41,7 +41,7 @@ def _import_script(self): self._test_for_tf_version() self._test_for_gui() cmd = os.path.basename(sys.argv[0]) - src = "tools.{}".format(self._command.lower()) if cmd == "tools.py" else "scripts" + src = f"tools.{self._command.lower()}" if cmd == "tools.py" else "scripts" mod = ".".join((src, self._command.lower())) module = import_module(mod) script = getattr(module, self._command.title()) @@ -53,15 +53,15 @@ def _test_for_tf_version(self): Raises ------ FaceswapError - If Tensorflow is not found, or is not between versions 2.2 and 2.6 + If Tensorflow is not found, or is not between versions 2.2 and 2.8 """ min_ver = 2.2 - max_ver = 2.6 + max_ver = 2.8 try: # Ensure tensorflow doesn't pin all threads to one core when using Math Kernel Library os.environ["TF_MIN_GPU_MULTIPROCESSOR_COUNT"] = "4" os.environ["KMP_AFFINITY"] = "disabled" - import tensorflow as tf # pylint:disable=import-outside-toplevel + import tensorflow as tf # noqa pylint:disable=import-outside-toplevel,unused-import except ImportError as err: if "DLL load failed while importing" in str(err): msg = ( @@ -77,14 +77,14 @@ def _test_for_tf_version(self): f"error: {str(err)}") self._handle_import_error(msg) - tf_ver = float(".".join(tf.__version__.split(".")[:2])) # pylint:disable=no-member + tf_ver = get_tf_version() if tf_ver < min_ver: - msg = ("The minimum supported Tensorflow is version {} but you have version {} " - "installed. Please upgrade Tensorflow.".format(min_ver, tf_ver)) + msg = (f"The minimum supported Tensorflow is version {min_ver} but you have version " + f"{tf_ver} installed. Please upgrade Tensorflow.") self._handle_import_error(msg) if tf_ver > max_ver: - msg = ("The maximum supported Tensorflow is version {} but you have version {} " - "installed. Please downgrade Tensorflow.".format(max_ver, tf_ver)) + msg = (f"The maximum supported Tensorflow is version {max_ver} but you have version " + f"{tf_ver} installed. Please downgrade Tensorflow.") self._handle_import_error(msg) logger.debug("Installed Tensorflow Version: %s", tf_ver) diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index bc87744f58..820a7ea24c 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -7,10 +7,12 @@ import numpy as np import tensorflow as tf -from tensorflow.core.util import event_pb2 -from tensorflow.python.framework import errors_impl as tf_errors +from tensorflow.core.util import event_pb2 # pylint:disable=no-name-in-module +from tensorflow.python.framework import ( # pylint:disable=no-name-in-module + errors_impl as tf_errors) from lib.serializer import get_serializer +from lib.utils import get_backend logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -43,7 +45,7 @@ def _get_log_filenames(self): The full path of each log file for each training session id that has been run """ logger.debug("Loading log filenames. base_dir: '%s'", self._logs_folder) - retval = dict() + retval = {} for dirpath, _, filenames in os.walk(self._logs_folder): if not any(filename.startswith("events.out.tfevents") for filename in filenames): continue @@ -133,7 +135,7 @@ class _Cache(): def __init__(self, session_ids): logger.debug("Initializing: %s: (session_ids: %s)", self.__class__.__name__, session_ids) self._data = {idx: None for idx in session_ids} - self._carry_over = dict() + self._carry_over = {} self._loss_labels = [] logger.debug("Initialized: %s", self.__class__.__name__) @@ -158,18 +160,19 @@ def cache_data(self, session_id, data, labels, is_live=False): """ logger.debug("Caching event data: (session_id: %s, labels: %s, data points: %s, " "is_live: %s)", session_id, labels, len(data), is_live) - if not data: - logger.debug("No data to cache") - return if labels: logger.debug("Setting loss labels: %s", labels) self._loss_labels = labels + if not data: + logger.debug("No data to cache") + return + timestamps, loss = self._to_numpy(data, is_live) if not is_live or (is_live and not self._data.get(session_id, None)): - self._data[session_id] = dict(labels=labels, + self._data[session_id] = dict(labels=self._loss_labels, loss=zlib.compress(loss), loss_shape=loss.shape, timestamps=zlib.compress(timestamps), @@ -207,10 +210,30 @@ def _to_numpy(self, data, is_live): for idx in sorted(data)]) times, loss = self._process_data(data, times, loss, is_live) - times, loss = (np.array(times, dtype="float64"), np.array(loss, dtype="float32")) + if is_live and not all(len(val) == len(self._loss_labels) for val in loss): + # TODO Many attempts have been made to fix this for live graph logging, and the issue + # of non-consistent loss record sizes keeps coming up. In the meantime we shall swallow + # any loss values that are of incorrect length so graph remains functional. This will, + # most likely, lead to a mismatch on iteration count so a proper fix should be + # implemented. + + # Timestamps and loss appears to remain consistent with each other, but sometimes loss + # appears non-consistent. eg (lengths): + # [2, 2, 2, 2, 2, 2, 2, 0] - last loss collection has zero length + # [1, 2, 2, 2, 2, 2, 2, 2] - 1st loss collection has 1 length + # [2, 2, 2, 3, 2, 2, 2] - 4th loss collection has 3 length + + logger.debug("Inconsistent loss found in collection: %s", loss) + for idx in reversed(range(len(loss))): + if len(loss[idx]) != len(self._loss_labels): + logger.debug("Removing loss/timestamps at position %s", idx) + del loss[idx] + del times[idx] + times, loss = (np.array(times, dtype="float64"), np.array(loss, dtype="float32")) logger.debug("Converted to numpy: (data points: %s, timestamps shape: %s, loss shape: %s)", len(data), times.shape, loss.shape) + return times, loss def _collect_carry_over(self, data): @@ -334,7 +357,7 @@ def get_data(self, session_id, metric): dtype = "float32" if metric == "loss" else "float64" - retval = dict() + retval = {} for idx, data in raw.items(): val = {metric: np.frombuffer(zlib.decompress(data[metric]), dtype=dtype).reshape(data[f"{metric}_shape"])} @@ -461,7 +484,7 @@ def get_loss(self, session_id=None): and list of loss values for each step """ logger.debug("Getting loss: (session_id: %s)", session_id) - retval = dict() + retval = {} for idx in [session_id] if session_id else self.session_ids: self._check_cache(idx) data = self._cache.get_data(idx, "loss") @@ -493,7 +516,7 @@ def get_timestamps(self, session_id=None): logger.debug("Getting timestamps: (session_id: %s, is_training: %s)", session_id, self._is_training) - retval = dict() + retval = {} for idx in [session_id] if session_id else self.session_ids: self._check_cache(idx) data = self._cache.get_data(idx, "timestamps") @@ -565,7 +588,7 @@ def cache_events(self, session_id): session_id: int The session id that the data is being cached for """ - data = dict() + data = {} try: for record in self._iterator: event = event_pb2.Event.FromString(record) # pylint:disable=no-member @@ -573,8 +596,11 @@ def cache_events(self, session_id): continue if event.summary.value[0].tag == "keras": self._parse_outputs(event) + if get_backend() == "amd": + # No model is logged for AMD so need to get loss labels from state file + self._add_amd_loss_labels(session_id) if event.summary.value[0].tag.startswith("batch_"): - data[event.step] = self._process_event(event, data.get(event.step, dict())) + data[event.step] = self._process_event(event, data.get(event.step, {})) except tf_errors.DataLossError as err: logger.warning("The logs for Session %s are corrupted and cannot be displayed. " @@ -605,10 +631,6 @@ def _parse_outputs(self, event): config = serializer.unmarshal(struct)["config"] model_outputs = self._get_outputs(config) - # loss length of unique should be 3: - # - decoder_both, 1, 2 - # - docoder_a, decoder_b, 1 - split_output = len(np.unique(model_outputs[..., :2])) != 3 for side_outputs, side in zip(model_outputs, ("a", "b")): logger.debug("side: '%s', outputs: '%s'", side, side_outputs) layer_name = side_outputs[0][0] @@ -618,8 +640,10 @@ def _parse_outputs(self, event): layer_outputs = self._get_outputs(output_config) for output in layer_outputs: # Drill into sub-model to get the actual output names loss_name = output[0][0] - if not split_output: # Rename losses to reflect the side's output - loss_name = f"{loss_name.replace('_both', '')}_{side}" + if loss_name[-2:] not in ("_a", "_b"): # Rename losses to reflect the side output + new_name = f"{loss_name.replace('_both', '')}_{side}" + logger.debug("Renaming loss output from '%s' to '%s'", loss_name, new_name) + loss_name = new_name if loss_name not in self._loss_labels: logger.debug("Adding loss name: '%s'", loss_name) self._loss_labels.append(loss_name) @@ -650,6 +674,28 @@ def _get_outputs(cls, model_config): outputs, outputs.shape) return outputs + def _add_amd_loss_labels(self, session_id): + """ It is not possible to store the model config in the Tensorboard logs for AMD so we + need to obtain the loss labels from the model's state file. This is called now so we know + event data is being written, and therefore the most current loss label data is available + in the state file. + + Loss names are added to :attr:`_loss_labels` + + Parameters + ---------- + session_id: int + The session id that the data is being cached for + + """ + if self._cache._loss_labels: # pylint:disable=protected-access + return + # Import global session here to prevent circular import + from . import Session # pylint:disable=import-outside-toplevel + loss_labels = sorted(Session.get_loss_keys(session_id=session_id)) + self._loss_labels = loss_labels + logger.debug("Collated loss labels: %s", self._loss_labels) + @classmethod def _process_event(cls, event, step): """ Process a single Tensorflow event. @@ -670,8 +716,19 @@ def _process_event(cls, event, step): The given step `dict` with the given event data added to it. """ summary = event.summary.value[0] + if summary.tag in ("batch_loss", "batch_total"): # Pre tf2.3 totals were "batch_total" step["timestamp"] = event.wall_time return step - step.setdefault("loss", list()).append(summary.simple_value) + + loss = summary.simple_value + if not loss: + # Need to convert a tensor to a float for TF2.8 logged data. This maybe due to change + # in logging or may be due to work around put in place in FS training function for the + # following bug in TF 2.8 when writing records: + # https://github.com/keras-team/keras/issues/16173 + loss = float(tf.make_ndarray(summary.tensor)) + + step.setdefault("loss", []).append(loss) + return step diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index e5358b94e3..97446ee6ef 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -17,6 +17,7 @@ import numpy as np from lib.serializer import get_serializer +from lib.utils import get_backend from .event_reader import TensorBoardLogs @@ -62,9 +63,9 @@ def model_filename(self): def batch_sizes(self): """ dict: The batch sizes for each session_id for the model. """ if self._state is None: - return dict() + return {} return {int(sess_id): sess["batchsize"] - for sess_id, sess in self._state.get("sessions", dict()).items()} + for sess_id, sess in self._state.get("sessions", {}).items()} @property def full_summary(self): @@ -86,7 +87,7 @@ def session_ids(self): def _load_state_file(self): """ Load the current state file to :attr:`_state`. """ - state_file = os.path.join(self._model_dir, "{}_state.json".format(self._model_name)) + state_file = os.path.join(self._model_dir, f"{self._model_name}_state.json") logger.debug("Loading State: '%s'", state_file) serializer = get_serializer("json") self._state = serializer.load(state_file) @@ -125,8 +126,7 @@ def initialize_session(self, model_folder, model_name, is_training=False): self._model_dir = model_folder self._model_name = model_name self._load_state_file() - self._tb_logs = TensorBoardLogs(os.path.join(self._model_dir, - "{}_logs".format(self._model_name)), + self._tb_logs = TensorBoardLogs(os.path.join(self._model_dir, f"{self._model_name}_logs"), is_training) self._summary = SessionsSummary(self) @@ -140,7 +140,7 @@ def stop_training(self): def clear(self): """ Clear the currently loaded session. """ - self._state = dict() + self._state = {} self._model_dir = None self._model_name = None @@ -173,13 +173,13 @@ def get_loss(self, session_id): loss_dict = self._tb_logs.get_loss(session_id=session_id) if session_id is None: - retval = dict() + retval = {} for key in sorted(loss_dict): for loss_key, loss in loss_dict[key].items(): retval.setdefault(loss_key, []).extend(loss) retval = {key: np.array(val, dtype="float32") for key, val in retval.items()} else: - retval = loss_dict.get(session_id, dict()) + retval = loss_dict.get(session_id, {}) if self._is_training: self._is_querying.clear() @@ -239,14 +239,21 @@ def get_loss_keys(self, session_id): The loss keys for the given session. If ``None`` is passed as session_id then a unique list of all loss keys for all sessions is returned """ - loss_keys = {sess_id: list(logs.keys()) - for sess_id, logs in self._tb_logs.get_loss(session_id=session_id).items()} + if get_backend() == "amd": + # We can't log the graph in Tensorboard logs for AMD so need to obtain from state file + loss_keys = {int(sess_id): [name for name in session["loss_names"] if name != "total"] + for sess_id, session in self._state["sessions"].items()} + else: + loss_keys = {sess_id: list(logs.keys()) + for sess_id, logs + in self._tb_logs.get_loss(session_id=session_id).items()} + if session_id is None: retval = list(set(loss_key for session in loss_keys.values() for loss_key in session)) else: - retval = loss_keys[session_id] + retval = loss_keys.get(session_id) return retval @@ -334,7 +341,7 @@ def _get_per_session_stats(self): """ if self._per_session_stats is None: logger.debug("Collating per session stats") - compiled = list() + compiled = [] for session_id, ts_data in self._time_stats.items(): logger.debug("Compiling session ID: %s", session_id) if self._state is None: @@ -446,15 +453,15 @@ def _format_stats(self, compiled_stats): retval = [] for summary in compiled_stats: hrs, mins, secs = self._convert_time(summary["elapsed"]) - stats = dict() + stats = {} for key in summary: if key not in ("start", "end", "elapsed", "rate"): stats[key] = summary[key] continue stats["start"] = time.strftime("%x %X", time.localtime(summary["start"])) stats["end"] = time.strftime("%x %X", time.localtime(summary["end"])) - stats["elapsed"] = "{}:{}:{}".format(hrs, mins, secs) - stats["rate"] = "{0:.1f}".format(summary["rate"]) + stats["elapsed"] = f"{hrs}:{mins}:{secs}" + stats["rate"] = f"{summary['rate']:.1f}" retval.append(stats) return retval @@ -474,9 +481,9 @@ def _convert_time(cls, timestamp): """ hrs = int(timestamp // 3600) if hrs < 10: - hrs = "{0:02d}".format(hrs) - mins = "{0:02d}".format((int(timestamp % 3600) // 60)) - secs = "{0:02d}".format((int(timestamp % 3600) % 60)) + hrs = f"{hrs:02d}" + mins = f"{(int(timestamp % 3600) // 60):02d}" + secs = f"{(int(timestamp % 3600) % 60):02d}" return hrs, mins, secs @@ -529,7 +536,7 @@ def __init__(self, session_id, self._iterations = 0 self._limit = 0 self._start_iteration = 0 - self._stats = dict() + self._stats = {} self.refresh() logger.debug("Initialized %s", self.__class__.__name__) @@ -630,7 +637,7 @@ def _get_raw(self): if self._args["flatten_outliers"]: loss = self._flatten_outliers(loss) - self.stats["raw_{}".format(loss_name)] = loss + self.stats[f"raw_{loss_name}"] = loss self._iterations = 0 if not iterations else min(iterations) if self._limit > 1: @@ -642,7 +649,7 @@ def _get_raw(self): if len(iterations) > 1: # Crop all losses to the same number of items if self._iterations == 0: - self.stats = {lossname: np.array(list(), dtype=loss.dtype) + self.stats = {lossname: np.array([], dtype=loss.dtype) for lossname, loss in self.stats.items()} else: self.stats = {lossname: loss[:self._iterations] @@ -722,7 +729,7 @@ def _calc_rate_total(cls): logger.debug("Calculating totals rate") batchsizes = _SESSION.batch_sizes total_timestamps = _SESSION.get_timestamps(None) - rate = list() + rate = [] for sess_id in sorted(total_timestamps.keys()): batchsize = batchsizes[sess_id] timestamps = total_timestamps[sess_id] @@ -737,10 +744,10 @@ def _get_calculations(self): if selection == "raw": continue logger.debug("Calculating: %s", selection) - method = getattr(self, "_calc_{}".format(selection)) + method = getattr(self, f"_calc_{selection}") raw_keys = [key for key in self._stats if key.startswith("raw_")] for key in raw_keys: - selected_key = "{}_{}".format(selection, key.replace("raw_", "")) + selected_key = f"{selection}_{key.replace('raw_', '')}" self._stats[selected_key] = method(self._stats[key]) def _calc_avg(self, data): @@ -866,7 +873,7 @@ def _get_max_row_size(self): optimizations. """ # Use :func:`np.finfo(dtype).eps` if you are worried about accuracy and want to be safe. - epsilon = np.finfo(self._dtype).tiny + epsilon = np.finfo(self._dtype).tiny # pylint:disable=no-member # If this produces an OverflowError, make epsilon larger: retval = int(np.log(epsilon) / np.log(1 - self._alpha)) + 1 logger.debug("row_size: %s", retval) diff --git a/lib/gui/display_command.py b/lib/gui/display_command.py index af550f7c83..a43b54acab 100644 --- a/lib/gui/display_command.py +++ b/lib/gui/display_command.py @@ -63,13 +63,10 @@ def save_items(self): return filename = "extract_convert_preview" now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - filename = os.path.join(location, - "{}_{}.{}".format(filename, - now, - "png")) + filename = os.path.join(location, f"{filename}_{now}.png") get_images().previewoutput[0].save(filename) logger.debug("Saved preview to %s", filename) - print("Saved preview to {}".format(filename)) + print(f"Saved preview to {filename}") class PreviewTrain(DisplayOptionalPage): # pylint: disable=too-many-ancestors @@ -125,7 +122,7 @@ def display_item_process(self): should_update = self.update_preview.get() for name in sortednames: - if name not in existing.keys(): + if name not in existing: self.add_child(name) elif should_update: tab_id = existing[name] @@ -197,19 +194,16 @@ def save_preview(self, location): """ Save the figure to file """ filename = self.name now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - filename = os.path.join(location, - "{}_{}.{}".format(filename, - now, - "png")) + filename = os.path.join(location, f"{filename}_{now}.png") get_images().previewtrain[self.name][0].save(filename) logger.debug("Saved preview to %s", filename) - print("Saved preview to {}".format(filename)) + print(f"Saved preview to {filename}") class GraphDisplay(DisplayOptionalPage): # pylint: disable=too-many-ancestors """ The Graph Tab of the Display section """ def __init__(self, parent, tab_name, helptext, wait_time, command=None): - self._trace_vars = dict() + self._trace_vars = {} super().__init__(parent, tab_name, helptext, wait_time, command) def set_vars(self): @@ -370,6 +364,8 @@ def display_item_set(self): logger.trace("Loading graph") self.display_item = Session self._add_trace_variables() + elif Session.is_training and self.display_item is not None: + logger.trace("Graph already displayed. Nothing to do.") else: logger.trace("Clearing graph") self.display_item = None @@ -384,9 +380,15 @@ def display_item_process(self): logger.debug("Adding graph") existing = list(self.subnotebook_get_titles_ids().keys()) - loss_keys = [key - for key in self.display_item.get_loss_keys(Session.session_ids[-1]) - if key != "total"] + + loss_keys = self.display_item.get_loss_keys(Session.session_ids[-1]) + if not loss_keys: + # Reload if we attempt to get loss keys before data is written + logger.debug("Waiting for Session Data to become available to graph") + self.after(1000, self.display_item_process) + return + + loss_keys = [key for key in loss_keys if key != "total"] display_tabs = sorted(set(key[:-1].rstrip("_") for key in loss_keys)) for loss_key in display_tabs: @@ -472,7 +474,7 @@ def _clear_trace_variables(self): for name, (var, trace) in self._trace_vars.items(): logger.debug("Clearing trace from variable: %s", name) var.trace_vdelete("w", trace) - self._trace_vars = dict() + self._trace_vars = {} def close(self): """ Clear the plots from RAM """ diff --git a/lib/gui/display_page.py b/lib/gui/display_page.py index a1e0c96809..32ebcb1bb2 100644 --- a/lib/gui/display_page.py +++ b/lib/gui/display_page.py @@ -59,7 +59,7 @@ def add_optional_vars(self, varsdict): @staticmethod def set_vars(): """ Override to return a dict of page specific variables """ - return dict() + return {} def on_tab_select(self): # pylint:disable=no-self-use """ Override for specific actions when the current tab is selected """ @@ -151,7 +151,7 @@ def subnotebook_get_widgets(self): def subnotebook_get_titles_ids(self): """ Return tabs ids and titles """ - tabs = dict() + tabs = {} for tab_id in range(0, self.subnotebook.index("end")): tabs[self.subnotebook.tab(tab_id, "text")] = tab_id logger.debug(tabs) @@ -213,11 +213,11 @@ def on_tab_select(self): def set_info_text(self): """ Set waiting for display text """ if not self.vars["enabled"].get(): - msg = "{} disabled".format(self.tabname.title()) + msg = f"{self.tabname.title()} disabled" elif self.vars["enabled"].get() and not self.vars["ready"].get(): - msg = "Waiting for {}...".format(self.tabname) + msg = f"Waiting for {self.tabname}..." else: - msg = "Displaying {}".format(self.tabname) + msg = f"Displaying {self.tabname}" logger.debug(msg) self.set_info(msg) @@ -235,7 +235,7 @@ def add_option_save(self): command=self.save_items) btnsave.pack(padx=2, side=tk.RIGHT) Tooltip(btnsave, - text=_("Save {}(s) to file").format(self.tabname), + text=_(f"Save {self.tabname}(s) to file"), wrap_length=200) def add_option_enable(self): @@ -243,11 +243,11 @@ def add_option_enable(self): logger.debug("Adding enable option") chkenable = ttk.Checkbutton(self.optsframe, variable=self.vars["enabled"], - text="Enable {}".format(self.tabname), + text=f"Enable {self.tabname}", command=self.on_chkenable_change) chkenable.pack(side=tk.RIGHT, padx=5, anchor=tk.W) Tooltip(chkenable, - text=_("Enable or disable {} display").format(self.tabname), + text=_(f"Enable or disable {self.tabname} display"), wrap_length=200) def save_items(self): diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 868ba22854..8189fb7c08 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -137,6 +137,7 @@ def __init__(self, handle_type, file_type, title=None, initial_folder=None, init "variable: %s)", self.__class__.__name__, handle_type, file_type, title, initial_folder, initial_file, command, action, variable) self._handletype = handle_type + self._dummy_master = self._set_dummy_master() self._defaults = self._set_defaults() self._kwargs = self._set_kwargs(title, initial_folder, @@ -145,7 +146,9 @@ def __init__(self, handle_type, file_type, title=None, initial_folder=None, init command, action, variable) - self.return_file = getattr(self, "_{}".format(self._handletype.lower()))() + self.return_file = getattr(self, f"_{self._handletype.lower()}")() + self._remove_dummy_master() + logger.debug("Initialized %s", self.__class__.__name__) @property @@ -184,10 +187,10 @@ def _filetypes(self): if platform.system() == "Linux": filetypes[key] = [item if item[0] == "All files" - else (item[0], "{} {}".format(item[1], item[1].upper())) + else (item[0], f"{item[1]} {item[1].upper()}") for item in filetypes[key]] if len(filetypes[key]) > 2: - multi = ["{} Files".format(key.title())] + multi = [f"{key.title()} Files"] multi.append(" ".join([ftype[1] for ftype in filetypes[key] if ftype[0] != "All files"])) filetypes[key].insert(0, tuple(multi)) @@ -214,6 +217,35 @@ def _contexts(self): "rotate": "save_filename", "slice": "save_filename"})) + @classmethod + def _set_dummy_master(cls): + """ Add an option to force black font on Linux file dialogs KDE issue that displays light + font on white background). + + This is a pretty hacky solution, but tkinter does not allow direct editing of file dialogs, + so we create a dummy frame and add the foreground option there, so that the file dialog can + inherit the foreground. + + Returns + ------- + tkinter.Frame or ``None`` + The dummy master frame for Linux systems, otherwise ``None`` + """ + if platform.system().lower() == "linux": + retval = tk.Frame() + retval.option_add("*foreground", "black") + else: + retval = None + return retval + + def _remove_dummy_master(self): + """ Destroy the dummy master widget on Linux systems. """ + if platform.system().lower() != "linux": + return + self._dummy_master.destroy() + del self._dummy_master + self._dummy_master = None + def _set_defaults(self): """ Set the default file type for the file dialog. Generally the first found file type will be used, but this is overridden if it is not appropriate. @@ -264,7 +296,9 @@ def _set_kwargs(self, title, initial_folder, initial_file, file_type, command, a logger.debug("Setting Kwargs: (title: %s, initial_folder: %s, initial_file: '%s', " "file_type: '%s', command: '%s': action: '%s', variable: '%s')", title, initial_folder, initial_file, file_type, command, action, variable) - kwargs = dict() + + kwargs = dict(master=self._dummy_master) + if self._handletype.lower() == "context": self._set_context_handletype(command, action, variable) @@ -361,10 +395,10 @@ def __init__(self): self._pathpreview = os.path.join(PATHCACHE, "preview") self._pathoutput = None self._previewoutput = None - self._previewtrain = dict() + self._previewtrain = {} self._previewcache = dict(modified=None, # cache for extract and convert images=None, - filenames=list(), + filenames=[], placeholder=None) self._errcount = 0 self._icons = self._load_icons() @@ -420,7 +454,7 @@ def _load_icons(): """ size = get_config().user_config_dict.get("icon_size", 16) size = int(round(size * get_config().scaling_factor)) - icons = dict() + icons = {} pathicons = os.path.join(PATHCACHE, "icons") for fname in os.listdir(pathicons): name, ext = os.path.splitext(fname) @@ -470,10 +504,10 @@ def _clear_image_cache(self): logger.debug("Clearing image cache") self._pathoutput = None self._previewoutput = None - self._previewtrain = dict() + self._previewtrain = {} self._previewcache = dict(modified=None, # cache for extract and convert images=None, - filenames=list(), + filenames=[], placeholder=None) @staticmethod @@ -600,10 +634,10 @@ def _load_images_to_cache(self, image_files, frame_dims, thumbnail_size): logger.debug("num_images: %s", num_images) if num_images == 0: return False - samples = list() + samples = [] start_idx = len(image_files) - num_images if len(image_files) > num_images else 0 show_files = sorted(image_files, key=os.path.getctime)[start_idx:] - dropped_files = list() + dropped_files = [] for fname in show_files: try: img = Image.open(fname) @@ -732,7 +766,7 @@ def load_training_preview(self): modified = None if not image_files: logger.debug("No preview to display") - self._previewtrain = dict() + self._previewtrain = {} return for img in image_files: modified = os.path.getmtime(img) if modified is None else modified @@ -755,7 +789,7 @@ def load_training_preview(self): self._errcount += 1 else: logger.error("Error reading the preview file for '%s'", img) - print("Error reading the preview file for {}".format(name)) + print(f"Error reading the preview file for {name}") self._previewtrain[name] = None def _get_current_size(self, name): @@ -1126,7 +1160,7 @@ def set_root_title(self, text=None): Additional text to be appended to the GUI title bar. Default: ``None`` """ title = "Faceswap.py" - title += " - {}".format(text) if text is not None and text else "" + title += f" - {text}" if text is not None and text else "" self.root.title(title) def set_geometry(self, width, height, fullscreen=False): @@ -1154,8 +1188,7 @@ def set_geometry(self, width, height, fullscreen=False): elif fullscreen: self.root.attributes('-zoomed', True) else: - self.root.geometry("{}x{}+80+80".format(str(initial_dimensions[0]), - str(initial_dimensions[1]))) + self.root.geometry(f"{str(initial_dimensions[0])}x{str(initial_dimensions[1])}+80+80") logger.debug("Geometry: %sx%s", *initial_dimensions) @@ -1260,7 +1293,7 @@ def set(self, trigger_type): """ trigger = self._trigger_files[trigger_type] if not os.path.isfile(trigger): - with open(trigger, "w"): + with open(trigger, "w", encoding="utf8"): pass logger.debug("Set preview trigger: %s", trigger) diff --git a/lib/model/initializers.py b/lib/model/initializers.py index c436342284..026efab66d 100644 --- a/lib/model/initializers.py +++ b/lib/model/initializers.py @@ -9,9 +9,8 @@ import tensorflow as tf from keras import backend as K from keras import initializers -from keras.utils import get_custom_objects -from lib.utils import get_backend +from lib.utils import get_backend, get_keras_custom_objects as get_custom_objects logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -64,7 +63,7 @@ def compute_fans(shape, data_format='channels_last'): return fan_in, fan_out -class ICNR(initializers.Initializer): # pylint: disable=invalid-name +class ICNR(initializers.Initializer): # pylint: disable=invalid-name,no-member """ ICNR initializer for checkerboard artifact free sub pixel convolution Parameters @@ -167,11 +166,11 @@ def get_config(self): config = {"scale": self.scale, "initializer": self.initializer } - base_config = super(ICNR, self).get_config() + base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) -class ConvolutionAware(initializers.Initializer): +class ConvolutionAware(initializers.Initializer): # pylint: disable=no-member """ Initializer that generates orthogonal convolution filters in the Fourier space. If this initializer is passed a shape that is not 3D or 4D, orthogonal initialization will be used. @@ -204,8 +203,8 @@ class ConvolutionAware(initializers.Initializer): def __init__(self, eps_std=0.05, seed=None, initialized=False): self.eps_std = eps_std self.seed = seed - self.orthogonal = initializers.Orthogonal() - self.he_uniform = initializers.he_uniform() + self.orthogonal = initializers.Orthogonal() # pylint:disable=no-member + self.he_uniform = initializers.he_uniform() # pylint:disable=no-member self.initialized = initialized def __call__(self, shape, dtype=None): diff --git a/lib/model/layers.py b/lib/model/layers.py index 9a9986aeef..d732152246 100644 --- a/lib/model/layers.py +++ b/lib/model/layers.py @@ -10,16 +10,15 @@ import keras.backend as K from keras.layers import InputSpec, Layer -from keras.utils import get_custom_objects -from lib.utils import get_backend +from lib.utils import get_backend, get_keras_custom_objects as get_custom_objects if get_backend() == "amd": from lib.plaidml_utils import pad from keras.utils import conv_utils # pylint:disable=ungrouped-imports else: from tensorflow import pad - from tensorflow.python.keras.utils import conv_utils + from tensorflow.python.keras.utils import conv_utils # pylint:disable=no-name-in-module class PixelShuffler(Layer): @@ -64,20 +63,22 @@ class PixelShuffler(Layer): def __init__(self, size=(2, 2), data_format=None, **kwargs): super().__init__(**kwargs) if get_backend() == "amd": - self.data_format = K.normalize_data_format(data_format) + self.data_format = K.normalize_data_format(data_format) # pylint:disable=no-member else: self.data_format = conv_utils.normalize_data_format(data_format) self.size = conv_utils.normalize_tuple(size, 2, 'size') - def call(self, inputs, **kwargs): # pylint:disable=unused-argument + def call(self, inputs, *args, **kwargs): """This is where the layer's logic lives. Parameters ---------- inputs: tensor Input tensor, or list/tuple of input tensors + args: tuple + Additional standard keras Layer arguments kwargs: dict - Additional keyword arguments. Unused + Additional standard keras Layer keyword arguments Returns ------- @@ -186,7 +187,7 @@ class name. These are handled by `Network` (one layer of abstraction above). """ config = {'size': self.size, 'data_format': self.data_format} - base_config = super(PixelShuffler, self).get_config() + base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) @@ -208,15 +209,17 @@ def __init__(self, size=2, interpolation="nearest", **kwargs): self.size = size self.interpolation = interpolation - def call(self, inputs, **kwargs): # pylint:disable=unused-argument + def call(self, inputs, *args, **kwargs): """ Call the upsample layer Parameters ---------- inputs: tensor Input tensor, or list/tuple of input tensors + args: tuple + Additional standard keras Layer arguments kwargs: dict - Additional keyword arguments. Unused + Additional standard keras Layer keyword arguments Returns ------- @@ -316,11 +319,11 @@ class SubPixelUpscaling(Layer): """ def __init__(self, scale_factor=2, data_format=None, **kwargs): - super(SubPixelUpscaling, self).__init__(**kwargs) + super().__init__(**kwargs) self.scale_factor = scale_factor if get_backend() == "amd": - self.data_format = K.normalize_data_format(data_format) + self.data_format = K.normalize_data_format(data_format) # pylint:disable=no-member else: self.data_format = conv_utils.normalize_data_format(data_format) @@ -337,15 +340,17 @@ def build(self, input_shape): """ pass # pylint: disable=unnecessary-pass - def call(self, inputs, **kwargs): # pylint:disable=unused-argument + def call(self, inputs, *args, **kwargs): """This is where the layer's logic lives. Parameters ---------- inputs: tensor Input tensor, or list/tuple of input tensors + args: tuple + Additional standard keras Layer arguments kwargs: dict - Additional keyword arguments. Unused + Additional standard keras Layer keyword arguments Returns ------- @@ -460,7 +465,7 @@ class name. These are handled by `Network` (one layer of abstraction above). """ config = {"scale_factor": self.scale_factor, "data_format": self.data_format} - base_config = super(SubPixelUpscaling, self).get_config() + base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) @@ -592,7 +597,7 @@ class name. These are handled by `Network` (one layer of abstraction above). """ config = {'stride': self.stride, 'kernel_size': self.kernel_size} - base_config = super(ReflectionPadding2D, self).get_config() + base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) @@ -602,9 +607,9 @@ class _GlobalPooling2D(Layer): From keras as access to pooling is trickier in tensorflow.keras """ def __init__(self, data_format=None, **kwargs): - super(_GlobalPooling2D, self).__init__(**kwargs) + super().__init__(**kwargs) if get_backend() == "amd": - self.data_format = K.normalize_data_format(data_format) + self.data_format = K.normalize_data_format(data_format) # pylint:disable=no-member else: self.data_format = conv_utils.normalize_data_format(data_format) self.input_spec = InputSpec(ndim=4) @@ -621,37 +626,41 @@ def compute_output_shape(self, input_shape): return (input_shape[0], input_shape[3]) return (input_shape[0], input_shape[1]) - def call(self, inputs, **kwargs): + def call(self, inputs, *args, **kwargs): """ Override to call the layer. Parameters ---------- inputs: Tensor The input to the layer + args: tuple + Additional standard keras Layer arguments kwargs: dict - Additional keyword arguments + Additional standard keras Layer keyword arguments """ raise NotImplementedError def get_config(self): """ Set the Keras config """ config = {'data_format': self.data_format} - base_config = super(_GlobalPooling2D, self).get_config() + base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) class GlobalMinPooling2D(_GlobalPooling2D): """Global minimum pooling operation for spatial data. """ - def call(self, inputs, **kwargs): + def call(self, inputs, *args, **kwargs): """This is where the layer's logic lives. Parameters ---------- inputs: tensor Input tensor, or list/tuple of input tensors + args: tuple + Additional standard keras Layer arguments kwargs: dict - Additional keyword arguments + Additional standard keras Layer keyword arguments Returns ------- @@ -668,15 +677,17 @@ def call(self, inputs, **kwargs): class GlobalStdDevPooling2D(_GlobalPooling2D): """Global standard deviation pooling operation for spatial data. """ - def call(self, inputs, **kwargs): + def call(self, inputs, *args, **kwargs): """This is where the layer's logic lives. Parameters ---------- inputs: tensor Input tensor, or list/tuple of input tensors + args: tuple + Additional standard keras Layer arguments kwargs: dict - Additional keyword arguments + Additional standard keras Layer keyword arguments Returns ------- @@ -702,7 +713,7 @@ class L2_normalize(Layer): # pylint:disable=invalid-name """ def __init__(self, axis, **kwargs): self.axis = axis - super(L2_normalize, self).__init__(**kwargs) + super().__init__(**kwargs) def call(self, inputs): # pylint:disable=arguments-differ """This is where the layer's logic lives. @@ -736,7 +747,7 @@ class name. These are handled by `Network` (one layer of abstraction above). dict A python dictionary containing the layer configuration """ - config = super(L2_normalize, self).get_config() + config = super().get_config() config["axis"] = self.axis return config diff --git a/lib/model/losses_tf.py b/lib/model/losses_tf.py index ebc85faeb5..c9810585c7 100644 --- a/lib/model/losses_tf.py +++ b/lib/model/losses_tf.py @@ -7,17 +7,17 @@ import numpy as np import tensorflow as tf -from tensorflow.python.keras.engine import compile_utils +from tensorflow.python.keras.engine import compile_utils # pylint:disable=no-name-in-module from keras import backend as K logger = logging.getLogger(__name__) # pylint:disable=invalid-name -class DSSIMObjective(tf.keras.losses.Loss): +class DSSIMObjective(tf.keras.losses.Loss): # pylint:disable=too-few-public-methods """ DSSIM Loss Function - Difference of Structural Similarity (DSSIM loss function). Clipped between 0 and 0.5 + Difference of Structural Similarity (DSSIM loss function). Parameters ---------- @@ -25,66 +25,24 @@ class DSSIMObjective(tf.keras.losses.Loss): Parameter of the SSIM. Default: `0.01` k_2: float, optional Parameter of the SSIM. Default: `0.03` - kernel_size: int, optional - Size of the sliding window Default: `3` + filter_size: int, optional + size of gaussian filter Default: `11` + filter_sigma: float, optional + Width of gaussian filter Default: `1.5` max_value: float, optional Max value of the output. Default: `1.0` Notes ------ You should add a regularization term like a l2 loss in addition to this one. - - References - ---------- - https://github.com/keras-team/keras-contrib/blob/master/keras_contrib/losses/dssim.py - - MIT License - - Copyright (c) 2017 Fariz Rahman - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. """ - def __init__(self, k_1=0.01, k_2=0.03, kernel_size=3, max_value=1.0): + def __init__(self, k_1=0.01, k_2=0.03, filter_size=11, filter_sigma=1.5, max_value=1.0): super().__init__(name="DSSIMObjective") - self.kernel_size = kernel_size + self.filter_size = filter_size + self.filter_sigma = filter_sigma self.k_1 = k_1 self.k_2 = k_2 self.max_value = max_value - self.c_1 = (self.k_1 * self.max_value) ** 2 - self.c_2 = (self.k_2 * self.max_value) ** 2 - self.dim_ordering = K.image_data_format() - - @staticmethod - def _int_shape(input_tensor): - """ Returns the shape of tensor or variable as a tuple of int or None entries. - - Parameters - ---------- - input_tensor: tensor or variable - The input to return the shape for - - Returns - ------- - tuple - A tuple of integers (or None entries) - """ - return K.int_shape(input_tensor) def call(self, y_true, y_pred): """ Call the DSSIM Loss Function. @@ -100,104 +58,19 @@ def call(self, y_true, y_pred): ------- tensor The DSSIM Loss value - - Notes - ----- - There are additional parameters for this function. some of the 'modes' for edge behavior - do not yet have a gradient definition in the Theano tree and cannot be used for learning - """ - - kernel = [self.kernel_size, self.kernel_size] - y_true = K.reshape(y_true, [-1] + list(self._int_shape(y_pred)[1:])) - y_pred = K.reshape(y_pred, [-1] + list(self._int_shape(y_pred)[1:])) - patches_pred = self.extract_image_patches(y_pred, - kernel, - kernel, - 'valid', - self.dim_ordering) - patches_true = self.extract_image_patches(y_true, - kernel, - kernel, - 'valid', - self.dim_ordering) - - # Get mean - u_true = K.mean(patches_true, axis=-1) - u_pred = K.mean(patches_pred, axis=-1) - # Get variance - var_true = K.var(patches_true, axis=-1) - var_pred = K.var(patches_pred, axis=-1) - # Get standard deviation - covar_true_pred = K.mean( - patches_true * patches_pred, axis=-1) - u_true * u_pred - - ssim = (2 * u_true * u_pred + self.c_1) * ( - 2 * covar_true_pred + self.c_2) - denom = (K.square(u_true) + K.square(u_pred) + self.c_1) * ( - var_pred + var_true + self.c_2) - ssim /= denom # no need for clipping, c_1 + c_2 make the denorm non-zero - return (1.0 - ssim) / 2.0 - - @staticmethod - def _preprocess_padding(padding): - """Convert keras padding to tensorflow padding. - - Parameters - ---------- - padding: string, - `"same"` or `"valid"`. - - Returns - ------- - str - `"SAME"` or `"VALID"`. - - Raises - ------ - ValueError - If `padding` is invalid. """ - if padding == 'same': - padding = 'SAME' - elif padding == 'valid': - padding = 'VALID' - else: - raise ValueError('Invalid padding:', padding) - return padding - - def extract_image_patches(self, input_tensor, k_sizes, s_sizes, - padding='same', data_format='channels_last'): - """ Extract the patches from an image. - - Parameters - ---------- - input_tensor: tensor - The input image - k_sizes: tuple - 2-d tuple with the kernel size - s_sizes: tuple - 2-d tuple with the strides size - padding: str, optional - `"same"` or `"valid"`. Default: `"same"` - data_format: str, optional. - `"channels_last"` or `"channels_first"`. Default: `"channels_last"` - - Returns - ------- - The (k_w, k_h) patches extracted - Tensorflow ==> (batch_size, w, h, k_w, k_h, c) - Theano ==> (batch_size, w, h, c, k_w, k_h) - """ - kernel = [1, k_sizes[0], k_sizes[1], 1] - strides = [1, s_sizes[0], s_sizes[1], 1] - padding = self._preprocess_padding(padding) - if data_format == 'channels_first': - input_tensor = K.permute_dimensions(input_tensor, (0, 2, 3, 1)) - patches = tf.image.extract_patches(input_tensor, kernel, strides, [1, 1, 1, 1], padding) - return patches - - -class GeneralizedLoss(tf.keras.losses.Loss): + ssim = tf.image.ssim(y_true, + y_pred, + self.max_value, + filter_size=self.filter_size, + filter_sigma=self.filter_sigma, + k1=self.k_1, + k2=self.k_2) + dssim_loss = 1. - ssim + return dssim_loss + + +class GeneralizedLoss(tf.keras.losses.Loss): # pylint:disable=too-few-public-methods """ Generalized function used to return a large variety of mathematical loss functions. The primary benefit is a smooth, differentiable version of L1 loss. @@ -247,10 +120,11 @@ def call(self, y_true, y_pred): return loss -class LInfNorm(tf.keras.losses.Loss): +class LInfNorm(tf.keras.losses.Loss): # pylint:disable=too-few-public-methods """ Calculate the L-inf norm as a loss function. """ - def call(self, y_true, y_pred): + @classmethod + def call(cls, y_true, y_pred): """ Call the L-inf norm loss function. Parameters @@ -271,7 +145,7 @@ def call(self, y_true, y_pred): return loss -class GradientLoss(tf.keras.losses.Loss): +class GradientLoss(tf.keras.losses.Loss): # pylint:disable=too-few-public-methods """ Gradient Loss Function. Calculates the first and second order gradient difference between pixels of an image in the x @@ -392,7 +266,7 @@ def _diff_xy(cls, img): return (xy_out1 - xy_out2) * 0.25 -class GMSDLoss(tf.keras.losses.Loss): +class GMSDLoss(tf.keras.losses.Loss): # pylint:disable=too-few-public-methods """ Gradient Magnitude Similarity Deviation Loss. Improved image quality metric over MS-SSIM with easier calculations @@ -486,7 +360,9 @@ def _scharr_edges(cls, image, magnitude): # Use depth-wise convolution to calculate edge maps per channel. # Output tensor has shape [batch_size, h, w, d * num_kernels]. pad_sizes = [[0, 0], [2, 2], [2, 2], [0, 0]] - padded = tf.pad(image, pad_sizes, mode='REFLECT') + padded = tf.pad(image, # pylint:disable=unexpected-keyword-arg,no-value-for-parameter + pad_sizes, + mode='REFLECT') output = K.depthwise_conv2d(padded, kernels) if not magnitude: # direction of edges diff --git a/lib/model/normalization/normalization_common.py b/lib/model/normalization/normalization_common.py index 0625368fca..6ef7c94f79 100644 --- a/lib/model/normalization/normalization_common.py +++ b/lib/model/normalization/normalization_common.py @@ -7,15 +7,16 @@ from keras.layers import Layer, InputSpec from keras import initializers, regularizers, constraints from keras import backend as K -from keras.utils import get_custom_objects - -from lib.utils import get_backend +from lib.utils import get_backend, get_keras_custom_objects as get_custom_objects if get_backend() == "amd": - from keras.backend import normalize_data_format # pylint:disable=ungrouped-imports + from keras.backend \ + import normalize_data_format # pylint:disable=ungrouped-imports,no-name-in-module else: - from tensorflow.python.keras.utils.conv_utils import normalize_data_format + # pylint:disable=no-name-in-module + from tensorflow.python.keras.utils.conv_utils \ + import normalize_data_format # pylint:disable=no-name-in-module class InstanceNormalization(Layer): @@ -61,6 +62,7 @@ class InstanceNormalization(Layer): - Instance Normalization: The Missing Ingredient for Fast Stylization - \ https://arxiv.org/abs/1607.08022 """ + # pylint:disable=too-many-instance-attributes,too-many-arguments def __init__(self, axis=None, epsilon=1e-3, @@ -348,6 +350,7 @@ class GroupNormalization(Layer): ---------- Shaoanlu GAN: https://github.com/shaoanlu/faceswap-GAN """ + # pylint:disable=too-many-instance-attributes def __init__(self, axis=-1, gamma_init='one', beta_init='zero', gamma_regularizer=None, beta_regularizer=None, epsilon=1e-6, group=32, data_format=None, **kwargs): self.beta = None diff --git a/lib/model/normalization/normalization_tf.py b/lib/model/normalization/normalization_tf.py index f53d7e7a6b..244507cd07 100644 --- a/lib/model/normalization/normalization_tf.py +++ b/lib/model/normalization/normalization_tf.py @@ -4,10 +4,14 @@ import sys import tensorflow as tf -import tensorflow.keras.backend as K +import tensorflow.keras.backend as K # pylint:disable=no-name-in-module,import-error # tf.keras has a LayerNormaliztion implementation -from tensorflow.keras.layers import Layer, LayerNormalization # noqa pylint:disable=unused-import -from tensorflow.keras.utils import get_custom_objects +# pylint:disable=unused-import +from tensorflow.keras.layers import ( # noqa pylint:disable=no-name-in-module,import-error + Layer, + LayerNormalization) + +from lib.utils import get_keras_custom_objects as get_custom_objects class RMSNormalization(Layer): @@ -117,9 +121,10 @@ def call(self, inputs, **kwargs): # pylint:disable=unused-argument mean_square = K.mean(K.square(inputs), axis=self.axis, keepdims=True) else: partial_size = int(layer_size * self.partial) - partial_x, _ = tf.split(inputs, - [partial_size, layer_size - partial_size], - axis=self.axis) + partial_x, _ = tf.split( # pylint:disable=redundant-keyword-arg,no-value-for-parameter + inputs, + [partial_size, layer_size - partial_size], + axis=self.axis) mean_square = K.mean(K.square(partial_x), axis=self.axis, keepdims=True) recip_square_root = tf.math.rsqrt(mean_square + self.epsilon) diff --git a/lib/model/optimizers_plaid.py b/lib/model/optimizers_plaid.py index 2fc52836ec..848cabff3b 100644 --- a/lib/model/optimizers_plaid.py +++ b/lib/model/optimizers_plaid.py @@ -4,7 +4,7 @@ import sys from keras import backend as K -from keras.optimizers import Optimizer +from keras.optimizers import Optimizer, Adam, Nadam, RMSprop # noqa pylint:disable=unused-import from keras.utils import get_custom_objects diff --git a/lib/model/optimizers_tf.py b/lib/model/optimizers_tf.py index 2b1afdaedc..e5d05c13ed 100644 --- a/lib/model/optimizers_tf.py +++ b/lib/model/optimizers_tf.py @@ -8,7 +8,10 @@ import sys import tensorflow as tf -from keras.utils import get_custom_objects +from tensorflow.keras.optimizers import ( # noqa pylint:disable=no-name-in-module,unused-import,import-error + Adam, Nadam, RMSprop) + +from lib.utils import get_keras_custom_objects as get_custom_objects class AdaBelief(tf.keras.optimizers.Optimizer): @@ -128,6 +131,7 @@ class AdaBelief(tf.keras.optimizers.Optimizer): def __init__(self, learning_rate=0.001, beta_1=0.9, beta_2=0.999, epsilon=1e-14, weight_decay=0.0, rectify=True, amsgrad=False, sma_threshold=5.0, total_steps=0, warmup_proportion=0.1, min_lr=0.0, name="AdaBeliefOptimizer", **kwargs): + # pylint:disable=too-many-arguments super().__init__(name, **kwargs) self._set_hyper("learning_rate", kwargs.get("lr", learning_rate)) self._set_hyper("beta_1", beta_1) @@ -196,7 +200,7 @@ def _decayed_wd(self, var_dtype): return wd_t def _resource_apply_dense(self, grad, handle, apply_state=None): - # pylint:disable=too-many-locals + # pylint:disable=too-many-locals,unused-argument """ Add ops to apply dense gradients to the variable handle. Parameters @@ -274,7 +278,7 @@ def _resource_apply_dense(self, grad, handle, apply_state=None): return tf.group(*updates) def _resource_apply_sparse(self, grad, handle, indices, apply_state=None): - # pylint:disable=too-many-locals + # pylint:disable=too-many-locals, unused-argument """ Add ops to apply sparse gradients to the variable handle. Similar to _apply_sparse, the indices argument to this method has been de-duplicated. @@ -324,7 +328,7 @@ def _resource_apply_sparse(self, grad, handle, indices, apply_state=None): m_corr_t = m_t / (1.0 - beta_1_power) var_v = self.get_slot(handle, "v") - m_t_indices = tf.gather(m_t, indices) + m_t_indices = tf.gather(m_t, indices) # pylint:disable=no-value-for-parameter v_scaled_g_values = tf.math.square(grad - m_t_indices) * (1 - beta_2_t) v_t = var_v.assign(var_v * beta_2_t + epsilon_t, use_locking=self._use_locking) v_t = self._resource_scatter_add(var_v, indices, v_scaled_g_values) @@ -355,7 +359,9 @@ def _resource_apply_sparse(self, grad, handle, indices, apply_state=None): var_update = self._resource_scatter_add(handle, indices, - tf.gather(tf.math.negative(lr_t) * var_t, indices)) + tf.gather( # pylint:disable=no-value-for-parameter + tf.math.negative(lr_t) * var_t, + indices)) updates = [var_update, m_t, v_t] if self.amsgrad: @@ -391,6 +397,6 @@ def get_config(self): # Update layers into Keras custom objects -for name, obj in inspect.getmembers(sys.modules[__name__]): +for _name, obj in inspect.getmembers(sys.modules[__name__]): if inspect.isclass(obj) and obj.__module__ == __name__: - get_custom_objects().update({name: obj}) + get_custom_objects().update({_name: obj}) diff --git a/lib/utils.py b/lib/utils.py index 28f2c6ad05..b6f1166da3 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -22,6 +22,7 @@ _video_extensions = [ # pylint:disable=invalid-name ".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv", ".ts", ".vob"] +_TF_VERS = None class _Backend(): # pylint:disable=too-few-public-methods @@ -60,8 +61,7 @@ def _get_backend(self): # Check if environment variable is set, if so use that if "FACESWAP_BACKEND" in os.environ: fs_backend = os.environ["FACESWAP_BACKEND"].lower() - print("Setting Faceswap backend from environment variable to " - "{}".format(fs_backend.upper())) + print(f"Setting Faceswap backend from environment variable to {fs_backend.upper()}") return fs_backend # Intercept for sphinx docs build if sys.argv[0].endswith("sphinx-build"): @@ -70,7 +70,7 @@ def _get_backend(self): self._configure_backend() while True: try: - with open(self._config_file, "r") as cnf: + with open(self._config_file, "r", encoding="utf8") as cnf: config = json.load(cnf) break except json.decoder.JSONDecodeError: @@ -80,7 +80,7 @@ def _get_backend(self): if fs_backend is None or fs_backend.lower() not in self._backends.values(): fs_backend = self._configure_backend() if current_process().name == "MainProcess": - print("Setting Faceswap backend to {}".format(fs_backend.upper())) + print(f"Setting Faceswap backend to {fs_backend.upper()}") return fs_backend.lower() def _configure_backend(self): @@ -95,14 +95,14 @@ def _configure_backend(self): while True: selection = input("1: AMD, 2: CPU, 3: NVIDIA: ") if selection not in ("1", "2", "3"): - print("'{}' is not a valid selection. Please try again".format(selection)) + print(f"'{selection}' is not a valid selection. Please try again") continue break fs_backend = self._backends[selection].lower() config = {"backend": fs_backend} - with open(self._config_file, "w") as cnf: + with open(self._config_file, "w", encoding="utf8") as cnf: json.dump(config, cnf) - print("Faceswap config written to: {}".format(self._config_file)) + print(f"Faceswap config written to: {self._config_file}") return fs_backend @@ -132,6 +132,32 @@ def set_backend(backend): _FS_BACKEND = backend.lower() +def get_tf_version(): + """ Obtain the major.minor version of currently installed Tensorflow. + + Returns + ------- + float + The currently installed tensorflow version + """ + global _TF_VERS # pylint:disable=global-statement + if _TF_VERS is None: + import tensorflow as tf # pylint:disable=import-outside-toplevel + _TF_VERS = float(".".join(tf.__version__.split(".")[:2])) # pylint:disable=no-member + return _TF_VERS + + +def get_keras_custom_objects(): + """ Wrapper to obtain keras.utils.get_custom_objects from correct location depending on + backend used and tensorflow version. """ + # pylint:disable=no-name-in-module,import-outside-toplevel + if get_backend() == "amd" or get_tf_version() < 2.8: + from keras.utils import get_custom_objects + else: + from keras.utils.generic_utils import get_custom_objects + return get_custom_objects() + + def get_folder(path, make_folder=True): """ Return a path to a folder, creating it if it doesn't exist @@ -176,7 +202,7 @@ def get_image_paths(directory, extension=None): """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name image_extensions = _image_extensions if extension is None else [extension] - dir_contents = list() + dir_contents = [] if not os.path.exists(directory): logger.debug("Creating folder: '%s'", directory) @@ -242,7 +268,7 @@ def full_path_split(path): >>> ["foo", "baz", "bar"] """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name - allparts = list() + allparts = [] while True: parts = os.path.split(path) if parts[0] == path: # sentinel for absolute paths @@ -297,9 +323,9 @@ def deprecation_warning(function, additional_info=None): """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name logger.debug("func_name: %s, additional_info: %s", function, additional_info) - msg = "{} has been deprecated and will be removed from a future update.".format(function) + msg = f"{function} has been deprecated and will be removed from a future update." if additional_info is not None: - msg += " {}".format(additional_info) + msg += f" {additional_info}" logger.warning(msg) @@ -355,7 +381,7 @@ class FaceswapError(Exception): pass # pylint:disable=unnecessary-pass -class GetModel(): # Pylint:disable=too-few-public-methods +class GetModel(): # pylint:disable=too-few-public-methods """ Check for models in their cache path. If available, return the path, if not available, get, unzip and install model @@ -428,7 +454,7 @@ def model_path(self): @property def _model_zip_path(self): """ str: The full path to downloaded zip file. """ - retval = os.path.join(self._cache_dir, "{}.zip".format(self._model_full_name)) + retval = os.path.join(self._cache_dir, f"{self._model_full_name}.zip") self.logger.trace(retval) return retval @@ -462,8 +488,8 @@ def _url_section(self): @property def _url_download(self): """ strL Base download URL for models. """ - tag = "v{}.{}.{}".format(self._url_section, self._git_model_id, self._model_version) - retval = "{}/{}/{}.zip".format(self._url_base, tag, self._model_full_name) + tag = f"v{self._url_section}.{self._git_model_id}.{self._model_version}" + retval = f"{self._url_base}/{tag}/{self._model_full_name}.zip" self.logger.trace("Download url: %s", retval) return retval @@ -493,11 +519,11 @@ def _download_model(self): downloaded_size = self._url_partial_size req = urllib.request.Request(self._url_download) if downloaded_size != 0: - req.add_header("Range", "bytes={}-".format(downloaded_size)) - response = urllib.request.urlopen(req, timeout=10) - self.logger.debug("header info: {%s}", response.info()) - self.logger.debug("Return Code: %s", response.getcode()) - self._write_zipfile(response, downloaded_size) + req.add_header("Range", f"bytes={downloaded_size}-") + with urllib.request.urlopen(req, timeout=10) as response: + self.logger.debug("header info: {%s}", response.info()) + self.logger.debug("Return Code: %s", response.getcode()) + self._write_zipfile(response, downloaded_size) break except (socket_error, socket_timeout, urllib.error.HTTPError, urllib.error.URLError) as err: @@ -548,8 +574,8 @@ def _unzip_model(self): """ Unzip the model file to the cache folder """ self.logger.info("Extracting: '%s'", self._model_name) try: - zip_file = zipfile.ZipFile(self._model_zip_path, "r") - self._write_model(zip_file) + with zipfile.ZipFile(self._model_zip_path, "r") as zip_file: + self._write_model(zip_file) except Exception as err: # pylint:disable=broad-except self.logger.error("Unable to extract model file: %s", str(err)) sys.exit(1) diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 2efbdd59a0..5db843a29c 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -20,13 +20,12 @@ from keras import backend as K from keras.layers import Input from keras.models import load_model, Model as KModel -from keras.optimizers import Adam, Nadam, RMSprop from lib.serializer import get_serializer from lib.model.backup_restore import Backup from lib.model import losses, optimizers from lib.model.nn_blocks import set_config as set_nnblock_config -from lib.utils import get_backend, FaceswapError +from lib.utils import get_backend, get_tf_version, FaceswapError from plugins.train._config import Config logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -264,13 +263,14 @@ def _check_multiple_models(self): return if len(multiple_models) == 1: - msg = ("You have requested to train with the '{}' plugin, but a model file for the " - "'{}' plugin already exists in the folder '{}'.\nPlease select a different " - "model folder.".format(self.name, multiple_models[0], self.model_dir)) + msg = (f"You have requested to train with the '{self.name}' plugin, but a model file " + f"for the '{multiple_models[0]}' plugin already exists in the folder " + f"'{self.model_dir}'.\nPlease select a different model folder.") else: - msg = ("There are multiple plugin types ('{}') stored in the model folder '{}'. This " - "is not supported.\nPlease split the model files into their own folders before " - "proceeding".format("', '".join(multiple_models), self.model_dir)) + ptypes = "', '".join(multiple_models) + msg = (f"There are multiple plugin types ('{ptypes}') stored in the model folder '" + f"{self.model_dir}'. This is not supported.\nPlease split the model files into " + "their own folders before proceeding") raise FaceswapError(msg) def build(self): @@ -311,11 +311,11 @@ def _update_legacy_models(self): if not all(os.path.isfile(os.path.join(self.model_dir, fname)) for fname in self._legacy_mapping()): return - archive_dir = "{}_TF1_Archived".format(self.model_dir) + archive_dir = f"{self.model_dir}_TF1_Archived" if os.path.exists(archive_dir): raise FaceswapError("We need to update your model files for use with Tensorflow 2.x, " "but the archive folder already exists. Please remove the " - "following folder to continue: '{}'".format(archive_dir)) + f"following folder to continue: '{archive_dir}'") logger.info("Updating legacy models for Tensorflow 2.x") logger.info("Your Tensorflow 1.x models will be archived in the following location: '%s'", @@ -371,7 +371,7 @@ def _get_inputs(self): input_shapes = [self.input_shape, self.input_shape] else: input_shapes = self.input_shape - inputs = [Input(shape=shape, name="face_in_{}".format(side)) + inputs = [Input(shape=shape, name=f"face_in_{side}") for side, shape in zip(("a", "b"), input_shapes)] logger.debug("inputs: %s", inputs) return inputs @@ -450,7 +450,7 @@ def _rewrite_plaid_outputs(self): seen = {name: 0 for name in set(self._model.output_names)} new_names = [] for name in self._model.output_names: - new_names.append("{}_{}".format(name, seen[name])) + new_names.append(f"{name}_{seen[name]}") seen[name] += 1 logger.debug("Output names rewritten: (old: %s, new: %s)", self._model.output_names, new_names) @@ -510,7 +510,7 @@ def __init__(self, plugin, model_dir, is_predict): @property def _filename(self): """str: The filename for this model.""" - return os.path.join(self._model_dir, "{}.h5".format(self._plugin.name)) + return os.path.join(self._model_dir, f"{self._plugin.name}.h5") @property def model_exists(self): @@ -567,7 +567,7 @@ def _load(self): "You can try to load the model again but if the problem persists you " "should use the Restore Tool to restore your model from backup.\n" f"Original error: {str(err)}") - raise FaceswapError(msg) + raise FaceswapError(msg) from err raise err except KeyError as err: if "unable to open object" in str(err).lower(): @@ -576,7 +576,7 @@ def _load(self): "You can try to load the model again but if the problem persists you " "should use the Restore Tool to restore your model from backup.\n" f"Original error: {str(err)}") - raise FaceswapError(msg) + raise FaceswapError(msg) from err raise err logger.info("Loaded model from disk: '%s'", self._filename) @@ -605,9 +605,9 @@ def _save(self): msg = "[Saved models]" if save_averages: - lossmsg = ["face_{}: {:.5f}".format(side, avg) + lossmsg = [f"face_{side}: {avg:.5f}" for side, avg in zip(("a", "b"), save_averages)] - msg += " - Average loss since last save: {}".format(", ".join(lossmsg)) + msg += f" - Average loss since last save: {', '.join(lossmsg)}" logger.info(msg) def _get_save_averages(self): @@ -699,12 +699,11 @@ def __init__(self, arguments, mixed_precision, allow_growth, is_predict): logger.debug("Initializing %s: (arguments: %s, mixed_precision: %s, allow_growth: %s, " "is_predict: %s)", self.__class__.__name__, arguments, mixed_precision, allow_growth, is_predict) - self._tf_version = [int(i) for i in tf.__version__.split(".")[:2]] self._set_tf_settings(allow_growth, arguments.exclude_gpus) use_mixed_precision = not is_predict and mixed_precision and get_backend() == "nvidia" # Mixed precision moved out of experimental in tensorflow 2.4 - if use_mixed_precision and self._tf_version[0] == 2 and self._tf_version[1] < 4: + if use_mixed_precision and get_tf_version() < 2.4: self._mixed_precision = tf.keras.mixed_precision.experimental elif use_mixed_precision: self._mixed_precision = tf.keras.mixed_precision @@ -743,9 +742,8 @@ def loss_scale_optimizer(self, optimizer): """ # tensorflow versions < 2.4 had different kwargs where scaling needs to be explicitly # defined - vers = self._tf_version - kwargs = dict(loss_scale="dynamic") if vers[0] == 2 and vers[1] < 4 else dict() - logger.debug("tf version: %s, kwargs: %s", vers, kwargs) + kwargs = dict(loss_scale="dynamic") if get_tf_version() < 2.4 else {} + logger.debug("tf version: %s, kwargs: %s", get_tf_version(), kwargs) return self._mixed_precision.LossScaleOptimizer(optimizer, **kwargs) @classmethod @@ -821,10 +819,11 @@ def _set_keras_mixed_precision(self, use_mixed_precision, exclude_gpus): return False logger.info("Enabling Mixed Precision Training.") - if exclude_gpus and self._tf_version[0] == 2 and self._tf_version[1] == 2: + if exclude_gpus and get_tf_version() == 2.2: # TODO remove this hacky fix to disable mixed precision compatibility testing when # tensorflow 2.2 support dropped - # pylint:disable=import-outside-toplevel,protected-access,import-error + # pylint:disable=import-outside-toplevel,protected-access + # pylint:disable=import-error,no-name-in-module from tensorflow.python.keras.mixed_precision.experimental import \ device_compatibility_check logger.debug("Overriding tensorflow _logged_compatibility_check parameter. Initial " @@ -833,7 +832,7 @@ def _set_keras_mixed_precision(self, use_mixed_precision, exclude_gpus): logger.debug("New value: %s", device_compatibility_check._logged_compatibility_check) policy = self._mixed_precision.Policy('mixed_float16') - if self._tf_version[0] == 2 and self._tf_version[1] < 4: + if get_tf_version() < 2.4: self._mixed_precision.set_policy(policy) else: self._mixed_precision.set_global_policy(policy) @@ -1102,9 +1101,11 @@ def __init__(self, optimizer, learning_rate, clipnorm, epsilon, arguments): optimizer, learning_rate, clipnorm, epsilon, arguments) valid_optimizers = {"adabelief": (optimizers.AdaBelief, dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), - "adam": (Adam, dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), - "nadam": (Nadam, dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), - "rms-prop": (RMSprop, dict(epsilon=epsilon))} + "adam": (optimizers.Adam, + dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), + "nadam": (optimizers.Nadam, + dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), + "rms-prop": (optimizers.RMSprop, dict(epsilon=epsilon))} self._optimizer, self._kwargs = valid_optimizers[optimizer] self._configure(learning_rate, clipnorm, arguments) @@ -1173,7 +1174,7 @@ def __init__(self): self._uses_l2_reg = ["ssim", "gmsd"] self._inputs = None self._names = [] - self._funcs = dict() + self._funcs = {} logger.debug("Initialized: %s", self.__class__.__name__) @property @@ -1248,7 +1249,7 @@ def _set_loss_names(self, outputs): side, output_names, output_shapes, output_types) self._names.extend(["{}_{}{}".format(name, side, "" if output_types.count(name) == 1 - else "_{}".format(idx)) + else f"_{idx}") for idx, name in enumerate(output_types)]) logger.debug(self._names) @@ -1354,13 +1355,13 @@ def __init__(self, model_dir, model_name, config_changeable_items, no_logs): "config_changeable_items: '%s', no_logs: %s", self.__class__.__name__, model_dir, model_name, config_changeable_items, no_logs) self._serializer = get_serializer("json") - filename = "{}_state.{}".format(model_name, self._serializer.file_extension) + filename = f"{model_name}_state.{self._serializer.file_extension}" self._filename = os.path.join(model_dir, filename) self._name = model_name self._iterations = 0 - self._sessions = dict() - self._lowest_avg_loss = dict() - self._config = dict() + self._sessions = {} + self._lowest_avg_loss = {} + self._config = {} self._load(config_changeable_items) self._session_id = self._new_session_id() self._create_new_session(no_logs, config_changeable_items) @@ -1473,10 +1474,10 @@ def _load(self, config_changeable_items): return state = self._serializer.load(self._filename) self._name = state.get("name", self._name) - self._sessions = state.get("sessions", dict()) - self._lowest_avg_loss = state.get("lowest_avg_loss", dict()) + self._sessions = state.get("sessions", {}) + self._lowest_avg_loss = state.get("lowest_avg_loss", {}) self._iterations = state.get("iterations", 0) - self._config = state.get("config", dict()) + self._config = state.get("config", {}) logger.debug("Loaded state: %s", state) self._replace_config(config_changeable_items) @@ -1670,7 +1671,7 @@ def _make_inference_model(self, saved_model): logger.debug("Compiling inference model. saved_model: %s", saved_model) struct = self._get_filtered_structure() model_inputs = self._get_inputs(saved_model.inputs) - compiled_layers = dict() + compiled_layers = {} for layer in saved_model.layers: if layer.name not in struct: logger.debug("Skipping unused layer: '%s'", layer.name) @@ -1704,7 +1705,7 @@ def _make_inference_model(self, saved_model): logger.debug("Compiling layer '%s': layer inputs: %s", layer.name, layer_inputs) model = layer(layer_inputs) compiled_layers[layer.name] = model - retval = KerasModel(model_inputs, model, name="{}_inference".format(saved_model.name)) + retval = KerasModel(model_inputs, model, name=f"{saved_model.name}_inference") logger.debug("Compiled inference model '%s': %s", retval.name, retval) return retval diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index ef7e6049ff..5028a52548 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -16,10 +16,11 @@ import numpy as np import tensorflow as tf -from tensorflow.python.framework import errors_impl as tf_errors +from tensorflow.python.framework import ( # pylint:disable=no-name-in-module + errors_impl as tf_errors) from lib.training import TrainingDataGenerator -from lib.utils import FaceswapError, get_backend, get_folder, get_image_paths +from lib.utils import FaceswapError, get_backend, get_folder, get_image_paths, get_tf_version from plugins.train._config import Config logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -129,8 +130,8 @@ def _set_tensorboard(self): logger.debug("Setting up TensorBoard Logging") log_dir = os.path.join(str(self._model.model_dir), - "{}_logs".format(self._model.name), - "session_{}".format(self._model.state.session_id)) + f"{self._model.name}_logs", + f"session_{self._model.state.session_id}") tensorboard = tf.keras.callbacks.TensorBoard(log_dir=log_dir, histogram_freq=0, # Must be 0 or hangs write_graph=get_backend() != "amd", @@ -251,7 +252,16 @@ def _log_tensorboard(self, loss): logger.trace("Updating TensorBoard log") logs = {log[0]: log[1] for log in zip(self._model.state.loss_names, loss)} + self._tensorboard.on_train_batch_end(self._model.iterations, logs=logs) + if get_tf_version() == 2.8: + # Bug in TF 2.8 where batch recording got deleted. + # ref: https://github.com/keras-team/keras/issues/16173 + for name, value in logs.items(): + tf.summary.scalar( + "batch_" + name, + value, + step=self._model._model._train_counter) # pylint:disable=protected-access def _collate_and_store_loss(self, loss): """ Collate the loss into totals for each side. @@ -297,11 +307,11 @@ def _print_loss(self, loss): The loss for each side. List should contain 2 ``floats`` side "a" in position 0 and side "b" in position `. """ - output = ", ".join(["Loss {}: {:.5f}".format(side, side_loss) + output = ", ".join([f"Loss {side}: {side_loss:.5f}" for side, side_loss in zip(("A", "B"), loss)]) timestamp = time.strftime("%H:%M:%S") - output = "[{}] [#{:05d}] {}".format(timestamp, self._model.iterations, output) - print("\r{}".format(output), end="") + output = f"[{timestamp}] [#{self._model.iterations:05d}] {output}" + print(f"\r{output}", end="") def clear_tensorboard(self): """ Stop Tensorboard logging. @@ -335,14 +345,14 @@ def __init__(self, images, model, batch_size, config): self._model = model self._images = images self._config = config - self._target = dict() - self._samples = dict() - self._masks = dict() + self._target = {} + self._samples = {} + self._masks = {} self._feeds = {side: self._load_generator(idx).minibatch_ab(images[side], batch_size, side) for idx, side in enumerate(("a", "b"))} - self._display_feeds = dict(preview=self._set_preview_feed(), timelapse=dict()) + self._display_feeds = dict(preview=self._set_preview_feed(), timelapse={}) logger.debug("Initialized %s:", self.__class__.__name__) def _load_generator(self, output_index): @@ -385,7 +395,7 @@ def _set_preview_feed(self): The side ("a" or "b") as key, :class:`~lib.training_data.TrainingDataGenerator` as value. """ - retval = dict() + retval = {} for idx, side in enumerate(("a", "b")): logger.debug("Setting preview feed: (side: '%s')", side) preview_images = self._config.get("preview_images", 14) @@ -484,9 +494,9 @@ def generate_preview(self, do_preview): should not be generated, in which case currently stored previews should be deleted. """ if not do_preview: - self._samples = dict() - self._target = dict() - self._masks = dict() + self._samples = {} + self._target = {} + self._masks = {} return logger.debug("Generating preview") for side in ("a", "b"): @@ -523,7 +533,7 @@ def compile_sample(self, batch_size, samples=None, images=None, masks=None): """ num_images = self._config.get("preview_images", 14) num_images = min(batch_size, num_images) if batch_size is not None else num_images - retval = dict() + retval = {} for side in ("a", "b"): logger.debug("Compiling samples: (side: '%s', samples: %s)", side, num_images) side_images = images[side] if images is not None else self._target[side] @@ -544,9 +554,9 @@ def compile_timelapse_sample(self): :class:`numpy.ndarrays` for creating a time-lapse frame """ batchsizes = [] - samples = dict() - images = dict() - masks = dict() + samples = {} + images = {} + masks = {} for side in ("a", "b"): batch = next(self._display_feeds["timelapse"][side]) batchsizes.append(len(batch["samples"])) @@ -607,7 +617,7 @@ def __init__(self, model, coverage_ratio, scaling=1.0): self.__class__.__name__, model, coverage_ratio) self._model = model self._display_mask = model.config["learn_mask"] or model.config["penalized_mask_loss"] - self.images = dict() + self.images = {} self._coverage_ratio = coverage_ratio self._scaling = scaling logger.debug("Initialized %s", self.__class__.__name__) @@ -630,9 +640,9 @@ def show_sample(self): A compiled preview image ready for display or saving """ logger.debug("Showing sample") - feeds = dict() - figures = dict() - headers = dict() + feeds = {} + figures = {} + headers = {} for idx, side in enumerate(("a", "b")): samples = self.images[side] faces = samples[1] @@ -647,8 +657,8 @@ def show_sample(self): for side, samples in self.images.items(): other_side = "a" if side == "b" else "b" - predictions = [preds["{0}_{0}".format(side)], - preds["{}_{}".format(other_side, side)]] + predictions = [preds[f"{side}_{side}"], + preds[f"{other_side}_{side}"]] display = self._to_full_frame(side, samples, predictions) headers[side] = self._get_headers(side, display[0].shape[1]) figures[side] = np.stack([display[0], display[1], display[2], ], axis=1) @@ -716,7 +726,7 @@ def _get_predictions(self, feed_a, feed_b): List of :class:`numpy.ndarray` of predictions received from the model """ logger.debug("Getting Predictions") - preds = dict() + preds = {} standard = self._model.model.predict([feed_a, feed_b]) swapped = self._model.model.predict([feed_b, feed_a]) @@ -904,9 +914,9 @@ def _get_headers(cls, side, width): total_width = width * 3 logger.debug("height: %s, total_width: %s", height, total_width) font = cv2.FONT_HERSHEY_SIMPLEX - texts = ["{} ({})".format(titles[0], side), - "{0} > {0}".format(titles[0]), - "{} > {}".format(titles[0], titles[1])] + texts = [f"{titles[0]} ({side})", + f"{titles[0]} > {titles[0]}", + f"{titles[0]} > {titles[1]}"] scaling = (width / 144) * 0.45 text_sizes = [cv2.getTextSize(texts[idx], font, scaling, 1)[0] for idx in range(len(texts))] @@ -1002,7 +1012,7 @@ def _setup(self, input_a=None, input_b=None, output=None): logger.debug("Time-lapse output set to '%s'", self._output_file) # Rewrite paths to pull from the training images so mask and face data can be accessed - images = dict() + images = {} for side, input_ in zip(("a", "b"), (input_a, input_b)): training_path = os.path.dirname(self._image_paths[side][0]) images[side] = [os.path.join(training_path, os.path.basename(pth)) diff --git a/requirements_cpu.txt b/requirements_cpu.txt index 0de8d8c2c6..b403c949aa 100644 --- a/requirements_cpu.txt +++ b/requirements_cpu.txt @@ -1,2 +1,2 @@ -r _requirements_base.txt -tensorflow>=2.2.0,<2.7.0 +tensorflow>=2.2.0,<2.9.0 diff --git a/requirements_nvidia.txt b/requirements_nvidia.txt index eaeb261a3f..a34dd2a6be 100644 --- a/requirements_nvidia.txt +++ b/requirements_nvidia.txt @@ -1,2 +1,2 @@ -r _requirements_base.txt -tensorflow-gpu>=2.2.0,<2.7.0 +tensorflow-gpu>=2.2.0,<2.9.0 diff --git a/setup.py b/setup.py index 6c88bc63d0..e97d983125 100755 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ # Tensorflow builds available from pypi TENSORFLOW_REQUIREMENTS = {">=2.2.0,<2.4.0": ["10.1", "7.6"], ">=2.4.0,<2.5.0": ["11.0", "8.0"], - ">=2.5.0,<2.7.0": ["11.2", "8.1"]} + ">=2.5.0,<2.9.0": ["11.2", "8.1"]} # Mapping of Python packages to their conda names if different from pip or in non-default channel CONDA_MAPPING = { # "opencv-python": ("opencv", "conda-forge"), # Periodic issues with conda-forge opencv @@ -43,9 +43,9 @@ def __init__(self, logger=None, updater=False): self.enable_amd = False self.enable_docker = False self.enable_cuda = False - self.required_packages = list() - self.missing_packages = list() - self.conda_missing_packages = list() + self.required_packages = [] + self.missing_packages = [] + self.conda_missing_packages = [] self.process_arguments() self.check_permission() @@ -54,6 +54,7 @@ def __init__(self, logger=None, updater=False): self.output_runtime_info() self.check_pip() self.upgrade_pip() + self.set_ld_library_path() self.installed_packages = self.get_installed_packages() self.installed_packages.update(self.get_installed_conda_packages()) @@ -104,7 +105,7 @@ def process_arguments(self): args = [arg for arg in sys.argv] # pylint:disable=unnecessary-comprehension if self.updater: from lib.utils import get_backend # pylint:disable=import-outside-toplevel - args.append("--{}".format(get_backend())) + args.append(f"--{get_backend()}") for arg in args: if arg == "--installer": @@ -124,11 +125,11 @@ def get_required_packages(self): suffix = "cpu.txt" req_files = ["_requirements_base.txt", f"requirements_{suffix}"] pypath = os.path.dirname(os.path.realpath(__file__)) - requirements = list() - git_requirements = list() + requirements = [] + git_requirements = [] for req_file in req_files: requirements_file = os.path.join(pypath, req_file) - with open(requirements_file) as req: + with open(requirements_file, encoding="utf8") as req: for package in req.readlines(): package = package.strip() # parse_requirements can't handle git dependencies, so extract and then @@ -157,15 +158,14 @@ def check_system(self): if not self.updater: self.output.info("The tool provides tips for installation\n" "and installs required python packages") - self.output.info("Setup in %s %s" % (self.os_version[0], self.os_version[1])) + self.output.info(f"Setup in {self.os_version[0]} {self.os_version[1]}") if not self.updater and not self.os_version[0] in ["Windows", "Linux", "Darwin"]: - self.output.error("Your system %s is not supported!" % self.os_version[0]) + self.output.error(f"Your system {self.os_version[0]} is not supported!") sys.exit(1) def check_python(self): """ Check python and virtual environment status """ - self.output.info("Installed Python: {0} {1}".format(self.py_version[0], - self.py_version[1])) + self.output.info(f"Installed Python: {self.py_version[0]} {self.py_version[1]}") if not (self.py_version[0].split(".")[0] == "3" and self.py_version[0].split(".")[1] in ("7", "8") and self.py_version[1] == "64bit") and not self.updater: @@ -179,7 +179,7 @@ def output_runtime_info(self): self.output.info("Running in Conda") if self.is_virtualenv: self.output.info("Running in a Virtual Environment") - self.output.info("Encoding: {}".format(self.encoding)) + self.output.info(f"Encoding: {self.encoding}") def check_pip(self): """ Check installed pip version """ @@ -201,17 +201,16 @@ def upgrade_pip(self): if not self.is_admin and not self.is_virtualenv: pipexe.append("--user") pipexe.append("pip") - run(pipexe) + run(pipexe, check=True) import pip # pylint:disable=import-outside-toplevel pip_version = pip.__version__ - self.output.info("Installed pip: {}".format(pip_version)) + self.output.info(f"Installed pip: {pip_version}") def get_installed_packages(self): """ Get currently installed packages """ - installed_packages = dict() - chk = Popen("\"{}\" -m pip freeze".format(sys.executable), - shell=True, stdout=PIPE) - installed = chk.communicate()[0].decode(self.encoding).splitlines() + installed_packages = {} + with Popen(f"\"{sys.executable}\" -m pip freeze", shell=True, stdout=PIPE) as chk: + installed = chk.communicate()[0].decode(self.encoding).splitlines() for pkg in installed: if "==" not in pkg: @@ -227,7 +226,7 @@ def get_installed_conda_packages(self): chk = os.popen("conda list").read() installed = [re.sub(" +", " ", line.strip()) for line in chk.splitlines() if not line.startswith("#")] - retval = dict() + retval = {} for pkg in installed: item = pkg.split(" ") retval[item[0]] = item[1] @@ -253,7 +252,7 @@ def update_tf_dep(self): # that corresponds to the installed Cuda/cuDNN versions self.required_packages = [pkg for pkg in self.required_packages if not pkg.startswith("tensorflow-gpu")] - tf_ver = "tensorflow-gpu{}".format(tf_ver) + tf_ver = f"tensorflow-gpu{tf_ver}" self.required_packages.append(tf_ver) return @@ -262,13 +261,12 @@ def update_tf_dep(self): "Tensorflow currently has no official prebuild for your CUDA, cuDNN " "combination.\nEither install a combination that Tensorflow supports or " "build and install your own tensorflow-gpu.\r\n" - "CUDA Version: {}\r\n" - "cuDNN Version: {}\r\n" + f"CUDA Version: {self.cuda_version}\r\n" + f"cuDNN Version: {self.cudnn_version}\r\n" "Help:\n" "Building Tensorflow: https://www.tensorflow.org/install/install_sources\r\n" "Tensorflow supported versions: " - "https://www.tensorflow.org/install/source#tested_build_configurations".format( - self.cuda_version, self.cudnn_version)) + "https://www.tensorflow.org/install/source#tested_build_configurations") custom_tf = input("Location of custom tensorflow-gpu wheel (leave " "blank to manually install): ") @@ -277,9 +275,9 @@ def update_tf_dep(self): custom_tf = os.path.realpath(os.path.expanduser(custom_tf)) if not os.path.isfile(custom_tf): - self.output.error("{} not found".format(custom_tf)) + self.output.error(f"{custom_tf} not found") elif os.path.splitext(custom_tf)[1] != ".whl": - self.output.error("{} is not a valid pip wheel".format(custom_tf)) + self.output.error(f"{custom_tf} is not a valid pip wheel") elif custom_tf: self.required_packages.append(custom_tf) @@ -294,9 +292,57 @@ def set_config(self): config = {"backend": backend} pypath = os.path.dirname(os.path.realpath(__file__)) config_file = os.path.join(pypath, "config", ".faceswap") - with open(config_file, "w") as cnf: + with open(config_file, "w", encoding="utf8") as cnf: json.dump(config, cnf) - self.output.info("Faceswap config written to: {}".format(config_file)) + self.output.info(f"Faceswap config written to: {config_file}") + + def set_ld_library_path(self): + """ Update the LD_LIBRARY_PATH environment variable when activating a conda environment + and revert it when deactivating. + + Notes + ----- + From Tensorflow 2.7, installing Cuda Toolkit from conda-forge and tensorflow from pip + causes tensorflow to not be able to locate shared libs and hence not use the GPU. + We update the environment variable for all instances using Conda as it shouldn't hurt + anything and may help avoid conflicts with globally installed Cuda + """ + if not self.is_conda or not self.enable_cuda: + return + + if self.os_version[0] == "Windows": + return + + conda_prefix = os.environ["CONDA_PREFIX"] + activate_folder = os.path.join(conda_prefix, "etc", "conda", "activate.d") + deactivate_folder = os.path.join(conda_prefix, "etc", "conda", "deactivate.d") + + os.makedirs(activate_folder, exist_ok=True) + os.makedirs(deactivate_folder, exist_ok=True) + + activate_script = os.path.join(conda_prefix, activate_folder, f"env_vars.sh") + deactivate_script = os.path.join(conda_prefix, deactivate_folder, f"env_vars.sh") + + if os.path.isfile(activate_script): + # Only create file if it does not already exist. There may be instances where people + # have created their own scripts, but these should be few and far between and those + # people should already know what they are doing. + return + + conda_libs = os.path.join(conda_prefix, "lib") + shebang = "#!/bin/sh\n\n" + + with open(activate_script, "w", encoding="utf8") as afile: + afile.write(f"{shebang}") + afile.write("export OLD_LD_LIBRARY_PATH=${LD_LIBRARY_PATH}\n") + afile.write(f"export LD_LIBRARY_PATH='{conda_libs}':${{LD_LIBRARY_PATH}}\n") + + with open(deactivate_script, "w", encoding="utf8") as afile: + afile.write(f"{shebang}") + afile.write("export LD_LIBRARY_PATH=${OLD_LD_LIBRARY_PATH}\n") + afile.write("unset OLD_LD_LIBRARY_PATH\n") + + self.output.info(f"Cuda search path set to '{conda_libs}'") class Output(): @@ -324,14 +370,14 @@ def info(self, text): """ Format INFO Text """ trm = "INFO " if self.term_support_color: - trm = "{}INFO {} ".format(self.green, self.default_color) + trm = f"{self.green}INFO {self.default_color} " print(trm + self.__indent_text_block(text)) def warning(self, text): """ Format WARNING Text """ trm = "WARNING " if self.term_support_color: - trm = "{}WARNING{} ".format(self.yellow, self.default_color) + trm = f"{self.yellow}WARNING{self.default_color} " print(trm + self.__indent_text_block(text)) def error(self, text): @@ -339,7 +385,7 @@ def error(self, text): global INSTALL_FAILED # pylint:disable=global-statement trm = "ERROR " if self.term_support_color: - trm = "{}ERROR {} ".format(self.red, self.default_color) + trm = f"{self.red}ERROR {self.default_color} " print(trm + self.__indent_text_block(text)) INSTALL_FAILED = True @@ -471,8 +517,8 @@ def _cuda_check(self): Initially just calls `nvcc -V` to get the installed version of Cuda currently in use. If this fails, drills down to more OS specific checking methods. """ - chk = Popen("nvcc -V", shell=True, stdout=PIPE, stderr=PIPE) - stdout, stderr = chk.communicate() + with Popen("nvcc -V", shell=True, stdout=PIPE, stderr=PIPE) as chk: + stdout, stderr = chk.communicate() if not stderr: version = re.search(r".*release (?P\d+\.\d+)", stdout.decode(locale.getpreferredencoding())) @@ -522,7 +568,7 @@ def _cudnn_check(self): if not cudnn_checkfile: return found = 0 - with open(cudnn_checkfile, "r") as ofile: + with open(cudnn_checkfile, "r", encoding="utf8") as ofile: for line in ofile: if line.lower().startswith("#define cudnn_major"): major = line[line.rfind(" ") + 1:].strip() @@ -551,7 +597,7 @@ def _get_checkfiles_linux(self): chk = os.popen("ldconfig -p | grep -P \"libcudnn.so.\\d+\" | head -n 1").read() chk = chk.strip().replace("libcudnn.so.", "") if not chk: - return list() + return [] cudnn_vers = chk[0] header_files = [f"cudnn_v{cudnn_vers}.h"] + self._cudnn_header_files @@ -572,7 +618,7 @@ def _get_checkfiles_windows(self): """ # TODO A more reliable way of getting the windows location if not self.cuda_path: - return list() + return [] scandir = os.path.join(self.cuda_path, "include") cudnn_checkfiles = [os.path.join(scandir, header) for header in self._cudnn_header_files] return cudnn_checkfiles @@ -701,7 +747,7 @@ def install_python_packages(self): channel = None if len(pkg) != 2 else pkg[1] pkg = pkg[0] if version: - pkg = "{}{}".format(pkg, ",".join("".join(spec) for spec in version)) + pkg = f"{pkg}{','.join(''.join(spec) for spec in version)}" if self.env.is_conda and not pkg.startswith("git"): if pkg.startswith("tensorflow-gpu"): # From TF 2.4 onwards, Anaconda Tensorflow becomes a mess. The version of 2.5 @@ -760,13 +806,14 @@ def conda_installer(self, package, channel=None, verbose=False, conda_only=False package = f"\"{package}\"" condaexe.append(package) - self.output.info("Installing {}".format(package.replace("\"", ""))) + clean_pkg = package.replace("\"", "") + self.output.info(f"Installing {clean_pkg}") shell = self.env.os_version[0] == "Windows" try: if verbose: run(condaexe, check=True, shell=shell) else: - with open(os.devnull, "w") as devnull: + with open(os.devnull, "w", encoding="utf8") as devnull: run(condaexe, stdout=devnull, stderr=devnull, check=True, shell=shell) except CalledProcessError: if not conda_only: @@ -809,14 +856,16 @@ def _tensorflow_dependency_install(self): pkgs = ["cudatoolkit", "cudnn"] shell = self.env.os_version[0] == "Windows" for pkg in pkgs: - chk = Popen(condaexe + [pkg], shell=shell, stdout=PIPE) - available = [line.split() - for line in chk.communicate()[0].decode(self.env.encoding).splitlines() - if line.startswith(pkg)] - compatible = [req for req in available - if (pkg == "cudatoolkit" and req[1].startswith(versions[0])) - or (pkg == "cudnn" and versions[0] in req[2] - and req[1].startswith(versions[1]))] + with Popen(condaexe + [pkg], shell=shell, stdout=PIPE) as chk: + available = [line.split() + for line + in chk.communicate()[0].decode(self.env.encoding).splitlines() + if line.startswith(pkg)] + compatible = [req for req in available + if (pkg == "cudatoolkit" and req[1].startswith(versions[0])) + or (pkg == "cudnn" and versions[0] in req[2] + and req[1].startswith(versions[1]))] + candidate = "==".join(sorted(compatible, key=lambda x: x[1])[-1][:2]) self.conda_installer(candidate, verbose=True, conda_only=True) @@ -828,6 +877,8 @@ def __init__(self): def docker_no_cuda(self): """ Output Tips for Docker without Cuda """ + + path = os.path.dirname(os.path.realpath(__file__)) self.output.info( "1. Install Docker\n" "https://www.docker.com/community-edition\n\n" @@ -837,7 +888,7 @@ def docker_no_cuda(self): "# without GUI\n" "docker run -tid -p 8888:8888 \\ \n" "\t--hostname deepfakes-cpu --name deepfakes-cpu \\ \n" - "\t-v {path}:/srv \\ \n" + f"\t-v {path}:/srv \\ \n" "\tdeepfakes-cpu\n\n" "# with gui. tools.py gui working.\n" "## enable local access to X11 server\n" @@ -845,7 +896,7 @@ def docker_no_cuda(self): "## create container\n" "nvidia-docker run -tid -p 8888:8888 \\ \n" "\t--hostname deepfakes-cpu --name deepfakes-cpu \\ \n" - "\t-v {path}:/srv \\ \n" + f"\t-v {path}:/srv \\ \n" "\t-v /tmp/.X11-unix:/tmp/.X11-unix \\ \n" "\t-e DISPLAY=unix$DISPLAY \\ \n" "\t-e AUDIO_GID=`getent group audio | cut -d: -f3` \\ \n" @@ -854,12 +905,13 @@ def docker_no_cuda(self): "\t-e UID=`id -u` \\ \n" "\tdeepfakes-cpu \n\n" "4. Open a new terminal to run faceswap.py in /srv\n" - "docker exec -it deepfakes-cpu bash".format( - path=os.path.dirname(os.path.realpath(__file__)))) + "docker exec -it deepfakes-cpu bash") self.output.info("That's all you need to do with a docker. Have fun.") def docker_cuda(self): """ Output Tips for Docker wit Cuda""" + + path = os.path.dirname(os.path.realpath(__file__)) self.output.info( "1. Install Docker\n" "https://www.docker.com/community-edition\n\n" @@ -873,7 +925,7 @@ def docker_cuda(self): "# without gui \n" "docker run -tid -p 8888:8888 \\ \n" "\t--hostname deepfakes-gpu --name deepfakes-gpu \\ \n" - "\t-v {path}:/srv \\ \n" + f"\t-v {path}:/srv \\ \n" "\tdeepfakes-gpu\n\n" "# with gui.\n" "## enable local access to X11 server\n" @@ -883,7 +935,7 @@ def docker_cuda(self): "## create container\n" "nvidia-docker run -tid -p 8888:8888 \\ \n" "\t--hostname deepfakes-gpu --name deepfakes-gpu \\ \n" - "\t-v {path}:/srv \\ \n" + f"\t-v {path}:/srv \\ \n" "\t-v /tmp/.X11-unix:/tmp/.X11-unix \\ \n" "\t-e DISPLAY=unix$DISPLAY \\ \n" "\t-e AUDIO_GID=`getent group audio | cut -d: -f3` \\ \n" @@ -892,8 +944,7 @@ def docker_cuda(self): "\t-e UID=`id -u` \\ \n" "\tdeepfakes-gpu\n\n" "6. Open a new terminal to interact with the project\n" - "docker exec deepfakes-gpu python /srv/faceswap.py gui\n".format( - path=os.path.dirname(os.path.realpath(__file__)))) + "docker exec deepfakes-gpu python /srv/faceswap.py gui\n") def macos(self): """ Output Tips for macOS""" diff --git a/tests/lib/model/losses_test.py b/tests/lib/model/losses_test.py index b5fc01a582..213333c454 100644 --- a/tests/lib/model/losses_test.py +++ b/tests/lib/model/losses_test.py @@ -70,41 +70,3 @@ def test_loss_wrapper(loss_func): else: output = output.numpy() assert output.dtype == "float32" and not np.isnan(output) - - -@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) -def test_dssim_channels_last(dummy): # pylint:disable=unused-argument - """ Basic test for DSSIM Loss """ - prev_data = K.image_data_format() - K.set_image_data_format('channels_last') - for input_dim, kernel_size in zip([32, 33], [2, 3]): - input_shape = [input_dim, input_dim, 3] - var_x = np.random.random_sample(4 * input_dim * input_dim * 3) - var_x = var_x.reshape([4] + input_shape) - var_y = np.random.random_sample(4 * input_dim * input_dim * 3) - var_y = var_y.reshape([4] + input_shape) - - model = Sequential() - model.add(Conv2D(32, (3, 3), padding='same', input_shape=input_shape, - activation='relu')) - model.add(Conv2D(3, (3, 3), padding='same', input_shape=input_shape, - activation='relu')) - adam = Adam(lr=0.001, beta_1=0.9, beta_2=0.999, epsilon=1e-8) - model.compile(loss=losses.DSSIMObjective(kernel_size=kernel_size), - metrics=['mse'], - optimizer=adam) - model.fit(var_x, var_y, batch_size=2, epochs=1, shuffle='batch') - - # Test same - x_1 = K.constant(var_x, 'float32') - x_2 = K.constant(var_x, 'float32') - dssim = losses.DSSIMObjective(kernel_size=kernel_size) - assert_allclose(0.0, K.eval(dssim(x_1, x_2)), atol=1e-4) - - # Test opposite - x_1 = K.zeros([4] + input_shape) - x_2 = K.ones([4] + input_shape) - dssim = losses.DSSIMObjective(kernel_size=kernel_size) - assert_allclose(0.5, K.eval(dssim(x_1, x_2)), atol=1e-4) - - K.set_image_data_format(prev_data) diff --git a/tests/lib/model/optimizers_test.py b/tests/lib/model/optimizers_test.py index 92875af570..adc82945be 100644 --- a/tests/lib/model/optimizers_test.py +++ b/tests/lib/model/optimizers_test.py @@ -76,11 +76,11 @@ def _test_optimizer(optimizer, target=0.75): @pytest.mark.parametrize("dummy", [None], ids=[get_backend().upper()]) def test_adam(dummy): # pylint:disable=unused-argument """ Test for custom Adam optimizer """ - _test_optimizer(k_optimizers.Adam(), target=0.5) - _test_optimizer(k_optimizers.Adam(decay=1e-3), target=0.5) + _test_optimizer(k_optimizers.Adam(), target=0.45) + _test_optimizer(k_optimizers.Adam(decay=1e-3), target=0.45) @pytest.mark.parametrize("dummy", [None], ids=[get_backend().upper()]) def test_adabelief(dummy): # pylint:disable=unused-argument """ Test for custom Adam optimizer """ - _test_optimizer(optimizers.AdaBelief(), target=0.5) + _test_optimizer(optimizers.AdaBelief(), target=0.45) diff --git a/tests/startup_test.py b/tests/startup_test.py index 05fd0dafca..6fb92e85e2 100644 --- a/tests/startup_test.py +++ b/tests/startup_test.py @@ -25,5 +25,6 @@ def test_backend(dummy): # pylint:disable=unused-argument def test_keras(dummy): # pylint:disable=unused-argument """ Sanity check to ensure that tensorflow keras is being used for CPU and standard keras for AMD. """ - assert ((_BACKEND == "cpu" and keras.__version__ in ("2.3.0-tf", "2.4.0")) or + assert ((_BACKEND == "cpu" and keras.__version__ in ("2.3.0-tf", "2.4.0", + "2.6.0", "2.7.0", "2.8.0")) or (_BACKEND == "amd" and keras.__version__ == "2.2.4")) From 5adc5c536ba69b891b5a8c127bfccc8effe532c5 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 2 May 2022 18:19:00 +0100 Subject: [PATCH 535/981] ssim loss - Scale back --- lib/model/losses_tf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/model/losses_tf.py b/lib/model/losses_tf.py index c9810585c7..ec6cc2b74b 100644 --- a/lib/model/losses_tf.py +++ b/lib/model/losses_tf.py @@ -66,7 +66,7 @@ def call(self, y_true, y_pred): filter_sigma=self.filter_sigma, k1=self.k_1, k2=self.k_2) - dssim_loss = 1. - ssim + dssim_loss = (1. - ssim) / 2.0 return dssim_loss From d264724b11fff754bf0107fafe7033018208e782 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 2 May 2022 22:32:45 +0100 Subject: [PATCH 536/981] revert min pillow requirement to 8.3.1 --- _requirements_base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_requirements_base.txt b/_requirements_base.txt index 923a0c134d..ad7e90a31d 100644 --- a/_requirements_base.txt +++ b/_requirements_base.txt @@ -2,7 +2,7 @@ tqdm>=4.64 psutil>=5.8.0 numpy>=1.18.0 opencv-python>=4.5.5.0 -pillow>=9.0.1 +pillow>=8.3.1 scikit-learn>=1.0.2 fastcluster>=1.2.4 # matplotlib 3.3.1 breaks custom toolbar in graph popup From b1e0e6ea82e9f63836eed724a24fd02540876ac9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 3 May 2022 13:50:10 +0100 Subject: [PATCH 537/981] bugfix: Temporary fix for Phaze-A whilst keras imports resolved --- plugins/train/model/phaze_a.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 70afcb397c..2e7e0ca638 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -5,7 +5,6 @@ import tensorflow as tf import keras.backend as K -from keras import applications as kapp from keras.layers import ( Add, BatchNormalization, Concatenate, Dense, Dropout, Flatten, GaussianNoise, GlobalAveragePooling2D, GlobalMaxPooling2D, Input, LeakyReLU, Reshape, UpSampling2D, @@ -21,6 +20,11 @@ from lib.utils import get_backend, FaceswapError +if get_backend() == "amd": + from keras import applications as kapp +else: + from tensorflow.keras import applications as kapp + from ._base import KerasModel, ModelBase, logger, _get_all_sub_models From aa39234538a8f83e6aa2b60b8275a570e8876ac2 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 3 May 2022 20:18:39 +0100 Subject: [PATCH 538/981] Update all Keras Imports to be conditional (#1214) * Remove custom keras importer * first round keras imports fix * launcher.py: Remove KerasFinder references * 2nd round keras imports update (lib and extract) * 3rd round keras imports update (train) * remove KerasFinder from tests * 4th round keras imports update (tests) --- lib/cli/launcher.py | 7 +- lib/model/initializers.py | 13 ++- lib/model/layers.py | 13 ++- lib/model/losses_tf.py | 7 +- lib/model/nn_blocks.py | 98 ++++++++++--------- .../normalization/normalization_common.py | 20 ++-- lib/model/normalization/normalization_tf.py | 12 +-- lib/model/optimizers_tf.py | 6 +- lib/model/session.py | 15 ++- lib/utils.py | 82 ---------------- plugins/extract/detect/mtcnn.py | 20 ++-- plugins/extract/detect/s3fd.py | 33 ++++--- plugins/extract/mask/bisenet_fp.py | 18 +++- plugins/extract/mask/vgg_clear.py | 22 +++-- plugins/extract/mask/vgg_obstructed.py | 23 +++-- plugins/train/model/_base.py | 18 +++- plugins/train/model/dfaker.py | 19 ++-- plugins/train/model/dfl_h128.py | 15 ++- plugins/train/model/dfl_sae.py | 37 ++++--- plugins/train/model/dlight.py | 21 ++-- plugins/train/model/iae.py | 23 +++-- plugins/train/model/lightweight.py | 10 +- plugins/train/model/original.py | 20 ++-- plugins/train/model/phaze_a.py | 35 ++++--- plugins/train/model/realface.py | 19 ++-- plugins/train/model/unbalanced.py | 18 ++-- plugins/train/model/villain.py | 19 ++-- tests/__init__.py | 7 -- tests/lib/model/initializers_test.py | 11 ++- tests/lib/model/layers_test.py | 8 +- tests/lib/model/losses_test.py | 16 ++- tests/lib/model/nn_blocks_test.py | 8 +- tests/lib/model/normalization_test.py | 11 ++- tests/lib/model/optimizers_test.py | 13 ++- tests/startup_test.py | 12 ++- 35 files changed, 397 insertions(+), 332 deletions(-) diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index b43e274c06..b338c8736e 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -9,7 +9,7 @@ from lib.gpu_stats import set_exclude_devices, GPUStats from lib.logger import crash_log, log_setup -from lib.utils import (FaceswapError, get_backend, get_tf_version, KerasFinder, safe_shutdown, +from lib.utils import (FaceswapError, get_backend, get_tf_version, safe_shutdown, set_backend, set_system_verbosity) logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -206,8 +206,6 @@ def _configure_backend(self, arguments): Set Faceswap backend to CPU if all GPUs have been deselected. - Add the Keras import interception code. - Parameters ---------- arguments: :class:`argparse.Namespace` @@ -234,9 +232,6 @@ def _configure_backend(self, arguments): set_backend("cpu") logger.info(msg) - # Add Keras finder to the meta_path list as the first item - sys.meta_path.insert(0, KerasFinder()) - logger.debug("Executing: %s. PID: %s", self._command, os.getpid()) if get_backend() == "amd": diff --git a/lib/model/initializers.py b/lib/model/initializers.py index 026efab66d..c1d0204710 100644 --- a/lib/model/initializers.py +++ b/lib/model/initializers.py @@ -7,10 +7,17 @@ import numpy as np import tensorflow as tf -from keras import backend as K -from keras import initializers -from lib.utils import get_backend, get_keras_custom_objects as get_custom_objects +from lib.utils import get_backend + +if get_backend() == "amd": + from keras.utils import get_custom_objects # pylint:disable=no-name-in-module + from keras import backend as K + from keras import initializers +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.utils import get_custom_objects # noqa pylint:disable=no-name-in-module,import-error + from tensorflow.keras import initializers, backend as K # noqa pylint:disable=no-name-in-module,import-error logger = logging.getLogger(__name__) # pylint: disable=invalid-name diff --git a/lib/model/layers.py b/lib/model/layers.py index d732152246..9fccc66aa5 100644 --- a/lib/model/layers.py +++ b/lib/model/layers.py @@ -7,16 +7,19 @@ import inspect import tensorflow as tf -import keras.backend as K -from keras.layers import InputSpec, Layer - -from lib.utils import get_backend, get_keras_custom_objects as get_custom_objects +from lib.utils import get_backend if get_backend() == "amd": from lib.plaidml_utils import pad - from keras.utils import conv_utils # pylint:disable=ungrouped-imports + from keras.utils import get_custom_objects, conv_utils # pylint:disable=no-name-in-module + import keras.backend as K + from keras.layers import InputSpec, Layer else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.utils import get_custom_objects # noqa pylint:disable=no-name-in-module,import-error + from tensorflow.keras import backend as K # pylint:disable=import-error + from tensorflow.keras.layers import InputSpec, Layer # noqa pylint:disable=no-name-in-module,import-error from tensorflow import pad from tensorflow.python.keras.utils import conv_utils # pylint:disable=no-name-in-module diff --git a/lib/model/losses_tf.py b/lib/model/losses_tf.py index ec6cc2b74b..a1f6481d0d 100644 --- a/lib/model/losses_tf.py +++ b/lib/model/losses_tf.py @@ -7,11 +7,12 @@ import numpy as np import tensorflow as tf -from tensorflow.python.keras.engine import compile_utils # pylint:disable=no-name-in-module -from keras import backend as K +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.python.keras.engine import compile_utils # noqa pylint:disable=no-name-in-module,import-error +from tensorflow.keras import backend as K # pylint:disable=import-error -logger = logging.getLogger(__name__) # pylint:disable=invalid-name +logger = logging.getLogger(__name__) class DSSIMObjective(tf.keras.losses.Loss): # pylint:disable=too-few-public-methods diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index 93ee1296f7..e5e090da03 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -3,20 +3,31 @@ import logging -from keras.layers import (Activation, Add, BatchNormalization, Concatenate, Conv2D as KConv2D, - Conv2DTranspose, DepthwiseConv2D as KDepthwiseConv2d, LeakyReLU, PReLU, - SeparableConv2D, UpSampling2D) -from keras.initializers import he_uniform, VarianceScaling +from lib.utils import get_backend + from .initializers import ICNR, ConvolutionAware from .layers import PixelShuffler, ReflectionPadding2D, Swish, KResizeImages from .normalization import InstanceNormalization +if get_backend() == "amd": + from keras.layers import ( + Activation, Add, BatchNormalization, Concatenate, Conv2D as KConv2D, Conv2DTranspose, + DepthwiseConv2D as KDepthwiseConv2d, LeakyReLU, PReLU, SeparableConv2D, UpSampling2D) + from keras.initializers import he_uniform, VarianceScaling # pylint:disable=no-name-in-module +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.layers import ( # noqa pylint:disable=no-name-in-module,import-error + Activation, Add, BatchNormalization, Concatenate, Conv2D as KConv2D, Conv2DTranspose, + DepthwiseConv2D as KDepthwiseConv2d, LeakyReLU, PReLU, SeparableConv2D, UpSampling2D) + from tensorflow.keras.initializers import he_uniform, VarianceScaling # noqa pylint:disable=no-name-in-module,import-error + + logger = logging.getLogger(__name__) # pylint: disable=invalid-name -_CONFIG = dict() -_NAMES = dict() +_CONFIG = {} +_NAMES = {} def set_config(configuration): @@ -52,9 +63,9 @@ def _get_name(name): str The unique name for this layer """ - global _NAMES # pylint:disable=global-statement + global _NAMES # pylint:disable=global-statement,global-variable-not-assigned _NAMES[name] = _NAMES.setdefault(name, -1) + 1 - name = "{}_{}".format(name, _NAMES[name]) + name = f"{name}_{_NAMES[name]}" logger.debug("Generating block name: %s", name) return name @@ -112,7 +123,7 @@ class Conv2D(KConv2D): # pylint:disable=too-few-public-methods def __init__(self, *args, padding="same", check_icnr_init=False, **kwargs): if kwargs.get("name", None) is None: filters = kwargs["filters"] if "filters" in kwargs else args[0] - kwargs["name"] = _get_name("conv2d_{}".format(filters)) + kwargs["name"] = _get_name(f"conv2d_{filters}") initializer = _get_default_initializer(kwargs.pop("kernel_initializer", None)) if check_icnr_init and _CONFIG["icnr_init"]: initializer = ICNR(initializer=initializer) @@ -179,7 +190,7 @@ class Conv2DOutput(): # pylint:disable=too-few-public-methods """ def __init__(self, filters, kernel_size, activation="sigmoid", padding="same", **kwargs): self._name = kwargs.pop("name") if "name" in kwargs else _get_name( - "conv_output_{}".format(filters)) + f"conv_output_{filters}") self._filters = filters self._kernel_size = kernel_size self._activation = activation @@ -202,7 +213,7 @@ def __call__(self, inputs): var_x = Conv2D(self._filters, self._kernel_size, padding=self._padding, - name="{}_conv2d".format(self._name), + name=f"{self._name}_conv2d", **self._kwargs)(inputs) var_x = Activation(self._activation, dtype="float32", name=self._name)(var_x) return var_x @@ -256,8 +267,7 @@ def __init__(self, activation="leakyrelu", use_depthwise=False, **kwargs): - self._name = kwargs.pop("name") if "name" in kwargs else _get_name( - "conv_{}".format(filters)) + self._name = kwargs.pop("name") if "name" in kwargs else _get_name(f"conv_{filters}") logger.debug("name: %s, filters: %s, kernel_size: %s, strides: %s, padding: %s, " "normalization: %s, activation: %s, use_depthwise: %s, kwargs: %s)", @@ -299,26 +309,26 @@ def __call__(self, inputs): if self._use_reflect_padding: inputs = ReflectionPadding2D(stride=self._strides, kernel_size=self._args[-1], - name="{}_reflectionpadding2d".format(self._name))(inputs) + name=f"{self._name}_reflectionpadding2d")(inputs) conv = DepthwiseConv2D if self._use_depthwise else Conv2D var_x = conv(*self._args, strides=self._strides, padding=self._padding, - name="{}_{}conv2d".format(self._name, "dw" if self._use_depthwise else ""), + name=f"{self._name}_{'dw' if self._use_depthwise else ''}conv2d", **self._kwargs)(inputs) # normalization if self._normalization == "instance": - var_x = InstanceNormalization(name="{}_instancenorm".format(self._name))(var_x) + var_x = InstanceNormalization(name=f"{self._name}_instancenorm")(var_x) if self._normalization == "batch": - var_x = BatchNormalization(axis=3, name="{}_batchnorm".format(self._name))(var_x) + var_x = BatchNormalization(axis=3, name=f"{self._name}_batchnorm")(var_x) # activation if self._activation == "leakyrelu": - var_x = LeakyReLU(0.1, name="{}_leakyrelu".format(self._name))(var_x) + var_x = LeakyReLU(0.1, name=f"{self._name}_leakyrelu")(var_x) if self._activation == "swish": - var_x = Swish(name="{}_swish".format(self._name))(var_x) + var_x = Swish(name=f"{self._name}_swish")(var_x) if self._activation == "prelu": - var_x = PReLU(name="{}_prelu".format(self._name))(var_x) + var_x = PReLU(name=f"{self._name}_prelu")(var_x) return var_x @@ -344,7 +354,7 @@ class SeparableConv2DBlock(): # pylint:disable=too-few-public-methods Convolutional 2D layer """ def __init__(self, filters, kernel_size=5, strides=2, **kwargs): - self._name = _get_name("separableconv2d_{}".format(filters)) + self._name = _get_name(f"separableconv2d_{filters}") logger.debug("name: %s, filters: %s, kernel_size: %s, strides: %s, kwargs: %s)", self._name, filters, kernel_size, strides, kwargs) @@ -373,9 +383,9 @@ def __call__(self, inputs): kernel_size=self._kernel_size, strides=self._strides, padding="same", - name="{}_seperableconv2d".format(self._name), + name=f"{self._name}_seperableconv2d", **self._kwargs)(inputs) - var_x = Activation("relu", name="{}_relu".format(self._name))(var_x) + var_x = Activation("relu", name=f"{self._name}_relu")(var_x) return var_x @@ -420,7 +430,7 @@ def __init__(self, normalization=None, activation="leakyrelu", **kwargs): - self._name = _get_name("upscale_{}".format(filters)) + self._name = _get_name(f"upscale_{filters}") logger.debug("name: %s. filters: %s, kernel_size: %s, padding: %s, scale_factor: %s, " "normalization: %s, activation: %s, kwargs: %s)", self._name, filters, kernel_size, padding, scale_factor, normalization, @@ -453,10 +463,10 @@ def __call__(self, inputs): padding=self._padding, normalization=self._normalization, activation=self._activation, - name="{}_conv2d".format(self._name), + name=f"{self._name}_conv2d", check_icnr_init=_CONFIG["icnr_init"], **self._kwargs)(inputs) - var_x = PixelShuffler(name="{}_pixelshuffler".format(self._name), + var_x = PixelShuffler(name=f"{self._name}_pixelshuffler", size=self._scale_factor)(var_x) return var_x @@ -501,7 +511,7 @@ class Upscale2xBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters, kernel_size=3, padding="same", activation="leakyrelu", interpolation="bilinear", sr_ratio=0.5, scale_factor=2, fast=False, **kwargs): - self._name = _get_name("upscale2x_{}_{}".format(filters, "fast" if fast else "hyb")) + self._name = _get_name(f"upscale2x_{filters}_{'fast' if fast else 'hyb'}") self._fast = fast self._filters = filters if self._fast else filters - int(filters * sr_ratio) @@ -536,11 +546,11 @@ def __call__(self, inputs): if self._fast or (not self._fast and self._filters > 0): var_x2 = Conv2D(self._filters, 3, padding=self._padding, - name="{}_conv2d".format(self._name), + name=f"{self._name}_conv2d", **self._kwargs)(var_x) var_x2 = UpSampling2D(size=(self._scale_factor, self._scale_factor), interpolation=self._interpolation, - name="{}_upsampling2D".format(self._name))(var_x2) + name=f"{self._name}_upsampling2D")(var_x2) if self._fast: var_x1 = UpscaleBlock(self._filters, kernel_size=self._kernel_size, @@ -550,7 +560,7 @@ def __call__(self, inputs): **self._kwargs)(var_x) var_x = Add()([var_x2, var_x1]) else: - var_x = Concatenate(name="{}_concatenate".format(self._name))([var_x_sr, var_x2]) + var_x = Concatenate(name=f"{self._name}_concatenate")([var_x_sr, var_x2]) else: var_x = var_x_sr return var_x @@ -587,7 +597,7 @@ class UpscaleResizeImagesBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters, kernel_size=3, padding="same", activation="leakyrelu", scale_factor=2, interpolation="bilinear"): - self._name = _get_name("upscale_ri_{}".format(filters)) + self._name = _get_name(f"upscale_ri_{filters}") self._interpolation = interpolation self._size = scale_factor self._filters = filters @@ -612,23 +622,23 @@ def __call__(self, inputs): var_x_sr = KResizeImages(size=self._size, interpolation=self._interpolation, - name="{}_resize".format(self._name))(var_x) + name=f"{self._name}_resize")(var_x) var_x_sr = Conv2D(self._filters, self._kernel_size, strides=1, padding=self._padding, - name="{}_conv".format(self._name))(var_x_sr) + name=f"{self._name}_conv")(var_x_sr) var_x_us = Conv2DTranspose(self._filters, 3, strides=2, padding=self._padding, - name="{}_convtrans".format(self._name))(var_x) + name=f"{self._name}_convtrans")(var_x) var_x = Add()([var_x_sr, var_x_us]) if self._activation == "leakyrelu": - var_x = LeakyReLU(0.2, name="{}_leakyrelu".format(self._name))(var_x) + var_x = LeakyReLU(0.2, name=f"{self._name}_leakyrelu")(var_x) if self._activation == "swish": - var_x = Swish(name="{}_swish".format(self._name))(var_x) + var_x = Swish(name=f"{self._name}_swish")(var_x) if self._activation == "prelu": - var_x = PReLU(name="{}_prelu".format(self._name))(var_x) + var_x = PReLU(name=f"{self._name}_prelu")(var_x) return var_x @@ -656,7 +666,7 @@ class ResidualBlock(): # pylint:disable=too-few-public-methods The output tensor from the Upscale layer """ def __init__(self, filters, kernel_size=3, padding="same", **kwargs): - self._name = _get_name("residual_{}".format(filters)) + self._name = _get_name(f"residual_{filters}") logger.debug("name: %s, filters: %s, kernel_size: %s, padding: %s, kwargs: %s)", self._name, filters, kernel_size, padding, kwargs) self._use_reflect_padding = _CONFIG["reflect_padding"] @@ -683,17 +693,17 @@ def __call__(self, inputs): if self._use_reflect_padding: var_x = ReflectionPadding2D(stride=1, kernel_size=self._kernel_size, - name="{}_reflectionpadding2d_0".format(self._name))(var_x) + name=f"{self._name}_reflectionpadding2d_0")(var_x) var_x = Conv2D(self._filters, kernel_size=self._kernel_size, padding=self._padding, - name="{}_conv2d_0".format(self._name), + name=f"{self._name}_conv2d_0", **self._kwargs)(var_x) - var_x = LeakyReLU(alpha=0.2, name="{}_leakyrelu_1".format(self._name))(var_x) + var_x = LeakyReLU(alpha=0.2, name=f"{self._name}_leakyrelu_1")(var_x) if self._use_reflect_padding: var_x = ReflectionPadding2D(stride=1, kernel_size=self._kernel_size, - name="{}_reflectionpadding2d_1".format(self._name))(var_x) + name=f"{self._name}_reflectionpadding2d_1")(var_x) kwargs = {key: val for key, val in self._kwargs.items() if key != "kernel_initializer"} if not _CONFIG["conv_aware_init"]: @@ -703,9 +713,9 @@ def __call__(self, inputs): var_x = Conv2D(self._filters, kernel_size=self._kernel_size, padding=self._padding, - name="{}_conv2d_1".format(self._name), + name=f"{self._name}_conv2d_1", **kwargs)(var_x) var_x = Add()([var_x, inputs]) - var_x = LeakyReLU(alpha=0.2, name="{}_leakyrelu_3".format(self._name))(var_x) + var_x = LeakyReLU(alpha=0.2, name=f"{self._name}_leakyrelu_3")(var_x) return var_x diff --git a/lib/model/normalization/normalization_common.py b/lib/model/normalization/normalization_common.py index 6ef7c94f79..22e8419d5b 100644 --- a/lib/model/normalization/normalization_common.py +++ b/lib/model/normalization/normalization_common.py @@ -4,19 +4,19 @@ import sys import inspect -from keras.layers import Layer, InputSpec -from keras import initializers, regularizers, constraints -from keras import backend as K - -from lib.utils import get_backend, get_keras_custom_objects as get_custom_objects +from lib.utils import get_backend if get_backend() == "amd": - from keras.backend \ - import normalize_data_format # pylint:disable=ungrouped-imports,no-name-in-module + from keras.utils import get_custom_objects # pylint:disable=no-name-in-module + from keras.layers import Layer, InputSpec + from keras import initializers, regularizers, constraints, backend as K + from keras.backend import normalize_data_format # pylint:disable=no-name-in-module else: - # pylint:disable=no-name-in-module - from tensorflow.python.keras.utils.conv_utils \ - import normalize_data_format # pylint:disable=no-name-in-module + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.utils import get_custom_objects # noqa pylint:disable=no-name-in-module,import-error + from tensorflow.keras.layers import Layer, InputSpec # noqa pylint:disable=no-name-in-module,import-error + from tensorflow.keras import initializers, regularizers, constraints, backend as K # noqa pylint:disable=no-name-in-module,import-error + from tensorflow.python.keras.utils.conv_utils import normalize_data_format # noqa pylint:disable=no-name-in-module class InstanceNormalization(Layer): diff --git a/lib/model/normalization/normalization_tf.py b/lib/model/normalization/normalization_tf.py index 244507cd07..b7a4abd028 100644 --- a/lib/model/normalization/normalization_tf.py +++ b/lib/model/normalization/normalization_tf.py @@ -4,14 +4,10 @@ import sys import tensorflow as tf -import tensorflow.keras.backend as K # pylint:disable=no-name-in-module,import-error -# tf.keras has a LayerNormaliztion implementation -# pylint:disable=unused-import -from tensorflow.keras.layers import ( # noqa pylint:disable=no-name-in-module,import-error - Layer, - LayerNormalization) - -from lib.utils import get_keras_custom_objects as get_custom_objects +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras import backend as K # pylint:disable=import-error +from tensorflow.keras.layers import Layer, LayerNormalization # noqa pylint:disable=no-name-in-module,unused-import,import-error +from tensorflow.keras.utils import get_custom_objects # noqa pylint:disable=no-name-in-module,import-error class RMSNormalization(Layer): diff --git a/lib/model/optimizers_tf.py b/lib/model/optimizers_tf.py index e5d05c13ed..9b028a55fa 100644 --- a/lib/model/optimizers_tf.py +++ b/lib/model/optimizers_tf.py @@ -8,10 +8,10 @@ import sys import tensorflow as tf -from tensorflow.keras.optimizers import ( # noqa pylint:disable=no-name-in-module,unused-import,import-error - Adam, Nadam, RMSprop) -from lib.utils import get_keras_custom_objects as get_custom_objects +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras.optimizers import (Adam, Nadam, RMSprop) # noqa pylint:disable=no-name-in-module,unused-import,import-error +from tensorflow.keras.utils import get_custom_objects # noqa pylint:disable=no-name-in-module,import-error class AdaBelief(tf.keras.optimizers.Optimizer): diff --git a/lib/model/session.py b/lib/model/session.py index ea644c3ee3..ac84048a1e 100644 --- a/lib/model/session.py +++ b/lib/model/session.py @@ -5,12 +5,17 @@ import numpy as np import tensorflow as tf -# pylint:disable=no-name-in-module,import-error -from keras.layers import Activation -from keras.models import load_model as k_load_model, Model from lib.utils import get_backend +if get_backend() == "amd": + from keras.layers import Activation + from keras.models import load_model as k_load_model, Model +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.layers import Activation # noqa pylint:disable=no-name-in-module,import-error + from tensorflow.keras.models import load_model as k_load_model, Model # noqa pylint:disable=no-name-in-module,import-error + logger = logging.getLogger(__name__) # pylint:disable=invalid-name @@ -54,7 +59,7 @@ def __init__(self, name, model_path, model_kwargs=None, allow_growth=False, excl self._backend = get_backend() self._set_session(allow_growth, exclude_gpus) self._model_path = model_path - self._model_kwargs = dict() if not model_kwargs else model_kwargs + self._model_kwargs = {} if not model_kwargs else model_kwargs self._model = None logger.trace("Initialized: %s", self.__class__.__name__,) @@ -92,7 +97,7 @@ def _amd_predict_with_optimized_batchsizes(self, feed, batch_size): feed = [feed] items = feed[0].shape[0] done_items = 0 - results = list() + results = [] while done_items < items: if batch_size < 4: # Not much difference in BS < 4 batch_size = 1 diff --git a/lib/utils.py b/lib/utils.py index b6f1166da3..a71e5ef71d 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -1,7 +1,6 @@ #!/usr/bin python3 """ Utilities available across all scripts """ -import importlib import json import logging import os @@ -147,17 +146,6 @@ def get_tf_version(): return _TF_VERS -def get_keras_custom_objects(): - """ Wrapper to obtain keras.utils.get_custom_objects from correct location depending on - backend used and tensorflow version. """ - # pylint:disable=no-name-in-module,import-outside-toplevel - if get_backend() == "amd" or get_tf_version() < 2.8: - from keras.utils import get_custom_objects - else: - from keras.utils.generic_utils import get_custom_objects - return get_custom_objects() - - def get_folder(path, make_folder=True): """ Return a path to a folder, creating it if it doesn't exist @@ -609,73 +597,3 @@ def _write_model(self, zip_file): out_file.write(buffer) zip_file.close() pbar.close() - - -class KerasFinder(importlib.abc.MetaPathFinder): - """ Importlib Abstract Base Class for intercepting the import of Keras and returning either - Keras (AMD backend) or tensorflow.keras (any other backend). - - The Importlib documentation is sparse at best, and real world examples are pretty much - non-existent. Coupled with this, the import ``tensorflow.keras`` does not resolve so we need - to split out to the actual location of Keras within ``tensorflow_core``. This method works, but - it relies on hard coded paths, and is likely to not be the most robust. - - A custom loader is not used, as we can use the standard loader once we have returned the - correct spec. - """ - def __init__(self): - self._logger = logging.getLogger(__name__) - self._backend = get_backend() - self._tf_keras_locations = [["tensorflow_core", "python", "keras", "api", "_v2"], - ["tensorflow", "python", "keras", "api", "_v2"]] - - def find_spec(self, fullname, path, target=None): # pylint:disable=unused-argument - """ Obtain the spec for either keras or tensorflow.keras depending on the backend in use. - - If keras is not passed in as part of the :attr:`fullname` or the path is not ``None`` - (i.e this is a dependency import) then this returns ``None`` to use the standard import - library. - - Parameters - ---------- - fullname: str - The absolute name of the module to be imported - path: str - The search path for the module - target: module object, optional - Inherited from parent but unused - - Returns - ------- - :class:`importlib.ModuleSpec` - The spec for the Keras module to be imported - """ - prefix = fullname.split(".")[0] - suffix = fullname.split(".")[-1] - if prefix != "keras" or path is not None: - return None - self._logger.debug("Importing '%s' as keras for backend: '%s'", - "keras" if self._backend == "amd" else "tf.keras", self._backend) - path = sys.path if path is None else path - for entry in path: - locations = ([os.path.join(entry, *location) - for location in self._tf_keras_locations] - if self._backend != "amd" else [entry]) - for location in locations: - self._logger.debug("Scanning: '%s' for '%s'", location, suffix) - if os.path.isdir(os.path.join(location, suffix)): - filename = os.path.join(location, suffix, "__init__.py") - submodule_locations = [os.path.join(location, suffix)] - else: - filename = os.path.join(location, suffix + ".py") - submodule_locations = None - if not os.path.exists(filename): - continue - retval = importlib.util.spec_from_file_location( - fullname, - filename, - submodule_search_locations=submodule_locations) - self._logger.debug("Found spec: %s", retval) - return retval - self._logger.debug("Spec not found for '%s'. Falling back to default import", fullname) - return None diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py index f2783f9d48..ab82c443d5 100644 --- a/plugins/extract/detect/mtcnn.py +++ b/plugins/extract/detect/mtcnn.py @@ -5,11 +5,17 @@ import cv2 import numpy as np -# pylint:disable=import-error -from keras.layers import Conv2D, Dense, Flatten, Input, MaxPool2D, Permute, PReLU + from lib.model.session import KSession +from lib.utils import get_backend from ._base import Detector, logger +if get_backend() == "amd": + from keras.layers import Conv2D, Dense, Flatten, Input, MaxPool2D, Permute, PReLU +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.layers import Conv2D, Dense, Flatten, Input, MaxPool2D, Permute, PReLU # noqa pylint:disable=no-name-in-module,import-error + class Detect(Detector): """ MTCNN detector for face recognition """ @@ -234,8 +240,8 @@ def detect_faces(self, batch): rectangles = self.detect_pnet(batch, origin_h, origin_w) rectangles = self.detect_rnet(batch, rectangles, origin_h, origin_w) rectangles = self.detect_onet(batch, rectangles, origin_h, origin_w) - ret_boxes = list() - ret_points = list() + ret_boxes = [] + ret_points = [] for rects in rectangles: if rects: total_boxes = np.array([result[:5] for result in rects]) @@ -284,7 +290,7 @@ def detect_rnet(self, images, rectangle_batch, height, width): # TODO: batching for idx, rectangles in enumerate(rectangle_batch): if not rectangles: - ret.append(list()) + ret.append([]) continue image = images[idx] crop_number = 0 @@ -307,11 +313,11 @@ def detect_rnet(self, images, rectangle_batch, height, width): def detect_onet(self, images, rectangle_batch, height, width): """ third stage - further refinement and facial landmarks positions with o-net """ - ret = list() + ret = [] # TODO: batching for idx, rectangles in enumerate(rectangle_batch): if not rectangles: - ret.append(list()) + ret.append([]) continue image = images[idx] crop_number = 0 diff --git a/plugins/extract/detect/s3fd.py b/plugins/extract/detect/s3fd.py index 62db1ccda0..59205d6c91 100644 --- a/plugins/extract/detect/s3fd.py +++ b/plugins/extract/detect/s3fd.py @@ -8,13 +8,22 @@ from scipy.special import logsumexp import numpy as np -import keras # pylint:disable=import-error -import keras.backend as K # pylint:disable=import-error -from keras.layers import Concatenate, Conv2D, Input, Maximum, MaxPooling2D, ZeroPadding2D from lib.model.session import KSession +from lib.utils import get_backend from ._base import Detector, logger +if get_backend() == "amd": + import keras + from keras import backend as K + from keras.layers import Concatenate, Conv2D, Input, Maximum, MaxPooling2D, ZeroPadding2D +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow import keras + from tensorflow.keras import backend as K # pylint:disable=import-error + from tensorflow.keras.layers import ( # pylint:disable=no-name-in-module,import-error + Concatenate, Conv2D, Input, Maximum, MaxPooling2D, ZeroPadding2D) + class Detect(Detector): """ S3FD detector for face recognition """ @@ -316,11 +325,11 @@ def conv_block(cls, inputs, filters, idx, recursions): tensor The output tensor from the convolution block """ - name = "conv{}".format(idx) + name = f"conv{idx}" var_x = inputs for i in range(1, recursions + 1): - rec_name = "{}_{}".format(name, i) - var_x = ZeroPadding2D(1, name="{}.zeropad".format(rec_name))(var_x) + rec_name = f"{name}_{i}" + var_x = ZeroPadding2D(1, name=f"{rec_name}.zeropad")(var_x) var_x = Conv2D(filters, kernel_size=3, strides=1, @@ -346,13 +355,13 @@ def conv_up(cls, inputs, filters, idx): tensor The output tensor from the convolution block """ - name = "conv{}".format(idx) + name = f"conv{idx}" var_x = inputs for i in range(1, 3): - rec_name = "{}_{}".format(name, i) + rec_name = f"{name}_{i}" size = 1 if i == 1 else 3 if i == 2: - var_x = ZeroPadding2D(1, name="{}.zeropad".format(rec_name))(var_x) + var_x = ZeroPadding2D(1, name=f"{rec_name}.zeropad")(var_x) var_x = Conv2D(filters * i, kernel_size=size, strides=i, @@ -386,7 +395,7 @@ def finalize_predictions(self, bounding_boxes_scales): bounding_boxes_scales: list The output predictions from the S3FD model """ - ret = list() + ret = [] batch_size = range(bounding_boxes_scales[0].shape[0]) for img in batch_size: bboxlist = [scale[img:img+1] for scale in bounding_boxes_scales] @@ -399,7 +408,7 @@ def _post_process(self, bboxlist): """ Perform post processing on output TODO: do this on the batch. """ - retval = list() + retval = [] for i in range(len(bboxlist) // 2): bboxlist[i * 2] = self.softmax(bboxlist[i * 2], axis=3) for i in range(len(bboxlist) // 2): @@ -450,7 +459,7 @@ def decode(location, priors): @staticmethod def _nms(boxes, threshold): """ Perform Non-Maximum Suppression """ - retained_box_indices = list() + retained_box_indices = [] areas = (boxes[:, 2] - boxes[:, 0] + 1) * (boxes[:, 3] - boxes[:, 1] + 1) ranked_indices = boxes[:, 4].argsort()[::-1] diff --git a/plugins/extract/mask/bisenet_fp.py b/plugins/extract/mask/bisenet_fp.py index 152eefc1bd..448a401ac4 100644 --- a/plugins/extract/mask/bisenet_fp.py +++ b/plugins/extract/mask/bisenet_fp.py @@ -6,15 +6,23 @@ """ import numpy as np -from keras import backend as K -from keras.layers import (Activation, Add, BatchNormalization, Concatenate, Conv2D, - GlobalAveragePooling2D, Input, MaxPooling2D, Multiply, Reshape, - UpSampling2D, ZeroPadding2D) - from lib.model.session import KSession +from lib.utils import get_backend from plugins.extract._base import _get_config from ._base import Masker, logger +if get_backend() == "amd": + from keras import backend as K + from keras.layers import ( + Activation, Add, BatchNormalization, Concatenate, Conv2D, GlobalAveragePooling2D, Input, + MaxPooling2D, Multiply, Reshape, UpSampling2D, ZeroPadding2D) +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras import backend as K # pylint:disable=import-error + from tensorflow.keras.layers import ( # pylint:disable=no-name-in-module,import-error + Activation, Add, BatchNormalization, Concatenate, Conv2D, GlobalAveragePooling2D, Input, + MaxPooling2D, Multiply, Reshape, UpSampling2D, ZeroPadding2D) + class Mask(Masker): """ Neural network to process face image into a segmentation mask of the face """ diff --git a/plugins/extract/mask/vgg_clear.py b/plugins/extract/mask/vgg_clear.py index 456b68e211..2b37f9b118 100644 --- a/plugins/extract/mask/vgg_clear.py +++ b/plugins/extract/mask/vgg_clear.py @@ -2,13 +2,21 @@ """ VGG Clear face mask plugin. """ import numpy as np -from keras.layers import (Add, Conv2D, # pylint:disable=no-name-in-module,import-error - Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, - MaxPooling2D, ZeroPadding2D) from lib.model.session import KSession +from lib.utils import get_backend from ._base import Masker, logger +if get_backend() == "amd": + from keras.layers import ( + Add, Conv2D, Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, MaxPooling2D, + ZeroPadding2D) +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.layers import ( # pylint:disable=no-name-in-module,import-error + Add, Conv2D, Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, MaxPooling2D, + ZeroPadding2D) + class Mask(Masker): """ Neural network to process face image into a segmentation mask of the face """ @@ -151,7 +159,7 @@ class _ConvBlock(): # pylint:disable=too-few-public-methods The number of consecutive Conv2D layers to create """ def __init__(self, level, filters, iterations): - self._name = "conv{}_".format(level) + self._name = f"conv{level}_" self._level = level self._filters = filters self._iterator = range(1, iterations + 1) @@ -176,10 +184,10 @@ def __call__(self, inputs): 3, padding=padding, activation="relu", - name="{}{}".format(self._name, i))(var_x) + name=f"{self._name}{i}")(var_x) var_x = MaxPooling2D(padding="same", strides=(2, 2), - name="pool{}".format(self._level))(var_x) + name=f"pool{self._level}")(var_x) return var_x @@ -196,7 +204,7 @@ class _ScorePool(): # pylint:disable=too-few-public-methods The amount of 2D cropping to apply. Tuple of `ints` """ def __init__(self, level, scale, crop): - self._name = "_pool{}".format(level) + self._name = f"_pool{level}" self._cropping = (crop, crop) self._scale = scale diff --git a/plugins/extract/mask/vgg_obstructed.py b/plugins/extract/mask/vgg_obstructed.py index 78406f8253..ba8a596e84 100644 --- a/plugins/extract/mask/vgg_obstructed.py +++ b/plugins/extract/mask/vgg_obstructed.py @@ -2,13 +2,22 @@ """ VGG Obstructed face mask plugin """ import numpy as np -from keras.layers import (Add, Conv2D, # pylint:disable=no-name-in-module,import-error - Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, MaxPooling2D, - ZeroPadding2D) + from lib.model.session import KSession +from lib.utils import get_backend from ._base import Masker, logger +if get_backend() == "amd": + from keras.layers import ( + Add, Conv2D, Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, MaxPooling2D, + ZeroPadding2D) +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.layers import ( # pylint:disable=no-name-in-module,import-error + Add, Conv2D, Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, MaxPooling2D, + ZeroPadding2D) + class Mask(Masker): """ Neural network to process face image into a segmentation mask of the face """ @@ -150,7 +159,7 @@ class _ConvBlock(): # pylint:disable=too-few-public-methods The number of consecutive Conv2D layers to create """ def __init__(self, level, filters, iterations): - self._name = "conv{}_".format(level) + self._name = f"conv{level}_" self._level = level self._filters = filters self._iterator = range(1, iterations + 1) @@ -175,10 +184,10 @@ def __call__(self, inputs): 3, padding=padding, activation="relu", - name="{}{}".format(self._name, i))(var_x) + name=f"{self._name}{i}")(var_x) var_x = MaxPooling2D(padding="same", strides=(2, 2), - name="pool{}".format(self._level))(var_x) + name=f"pool{self._level}")(var_x) return var_x @@ -195,7 +204,7 @@ class _ScorePool(): # pylint:disable=too-few-public-methods The amount of 2D cropping to apply """ def __init__(self, level, scale, crop): - self._name = "_pool{}".format(level) + self._name = f"_pool{level}" self._cropping = ((crop, crop), (crop, crop)) self._scale = scale diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 5db843a29c..d957d0b8cc 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -16,11 +16,6 @@ import numpy as np import tensorflow as tf -from keras import losses as k_losses -from keras import backend as K -from keras.layers import Input -from keras.models import load_model, Model as KModel - from lib.serializer import get_serializer from lib.model.backup_restore import Backup from lib.model import losses, optimizers @@ -28,6 +23,19 @@ from lib.utils import get_backend, get_tf_version, FaceswapError from plugins.train._config import Config +if get_backend() == "amd": + from keras import losses as k_losses + from keras import backend as K + from keras.layers import Input + from keras.models import load_model, Model as KModel +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras import losses as k_losses # pylint:disable=import-error + from tensorflow.keras import backend as K # pylint:disable=import-error + from tensorflow.keras.layers import Input # pylint:disable=import-error,no-name-in-module + from tensorflow.keras.models import load_model, Model as KModel # noqa pylint:disable=import-error,no-name-in-module + + logger = logging.getLogger(__name__) # pylint: disable=invalid-name _CONFIG = None diff --git a/plugins/train/model/dfaker.py b/plugins/train/model/dfaker.py index 24ea2d5770..221ff179b5 100644 --- a/plugins/train/model/dfaker.py +++ b/plugins/train/model/dfaker.py @@ -4,12 +4,19 @@ import logging import sys -from keras.initializers import RandomNormal -from keras.layers import Input, LeakyReLU - from lib.model.nn_blocks import Conv2DOutput, UpscaleBlock, ResidualBlock +from lib.utils import get_backend from .original import Model as OriginalModel, KerasModel +if get_backend() == "amd": + from keras.initializers import RandomNormal + from keras.layers import Input, LeakyReLU +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.initializers import RandomNormal # noqa pylint:disable=import-error,no-name-in-module + from tensorflow.keras.layers import Input, LeakyReLU # noqa pylint:disable=import-error,no-name-in-module + + logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -44,7 +51,7 @@ def decoder(self, side): var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(128, kernel_initializer=self.kernel_initializer)(var_x) var_x = UpscaleBlock(64, activation="leakyrelu")(var_x) - var_x = Conv2DOutput(3, 5, name="face_out_{}".format(side))(var_x) + var_x = Conv2DOutput(3, 5, name=f"face_out_{side}")(var_x) outputs = [var_x] if self.config.get("learn_mask", False): @@ -55,6 +62,6 @@ def decoder(self, side): var_y = UpscaleBlock(256, activation="leakyrelu")(var_y) var_y = UpscaleBlock(128, activation="leakyrelu")(var_y) var_y = UpscaleBlock(64, activation="leakyrelu")(var_y) - var_y = Conv2DOutput(1, 5, name="mask_out_{}".format(side))(var_y) + var_y = Conv2DOutput(1, 5, name=f"mask_out_{side}")(var_y) outputs.append(var_y) - return KerasModel([input_], outputs=outputs, name="decoder_{}".format(side)) + return KerasModel([input_], outputs=outputs, name=f"decoder_{side}") diff --git a/plugins/train/model/dfl_h128.py b/plugins/train/model/dfl_h128.py index 0677b0111e..7d159c6e77 100644 --- a/plugins/train/model/dfl_h128.py +++ b/plugins/train/model/dfl_h128.py @@ -3,11 +3,16 @@ Based on https://github.com/iperov/DeepFaceLab """ -from keras.layers import Dense, Flatten, Input, Reshape - from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock +from lib.utils import get_backend from .original import Model as OriginalModel, KerasModel +if get_backend() == "amd": + from keras.layers import Dense, Flatten, Input, Reshape +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.layers import Dense, Flatten, Input, Reshape # noqa pylint:disable=import-error,no-name-in-module + class Model(OriginalModel): """ H128 Model from DFL """ @@ -36,7 +41,7 @@ def decoder(self, side): var_x = UpscaleBlock(self.encoder_dim, activation="leakyrelu")(var_x) var_x = UpscaleBlock(self.encoder_dim // 2, activation="leakyrelu")(var_x) var_x = UpscaleBlock(self.encoder_dim // 4, activation="leakyrelu")(var_x) - var_x = Conv2DOutput(3, 5, name="face_out_{}".format(side))(var_x) + var_x = Conv2DOutput(3, 5, name=f"face_out_{side}")(var_x) outputs = [var_x] if self.config.get("learn_mask", False): @@ -44,6 +49,6 @@ def decoder(self, side): var_y = UpscaleBlock(self.encoder_dim, activation="leakyrelu")(var_y) var_y = UpscaleBlock(self.encoder_dim // 2, activation="leakyrelu")(var_y) var_y = UpscaleBlock(self.encoder_dim // 4, activation="leakyrelu")(var_y) - var_y = Conv2DOutput(1, 5, name="mask_out_{}".format(side))(var_y) + var_y = Conv2DOutput(1, 5, name=f"mask_out_{side}")(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs, name="decoder_{}".format(side)) + return KerasModel(input_, outputs=outputs, name=f"decoder_{side}") diff --git a/plugins/train/model/dfl_sae.py b/plugins/train/model/dfl_sae.py index f6c7fafcd6..6f00a96b8f 100644 --- a/plugins/train/model/dfl_sae.py +++ b/plugins/train/model/dfl_sae.py @@ -5,12 +5,17 @@ import numpy as np -from keras.layers import Concatenate, Dense, Flatten, Input, LeakyReLU, Reshape - from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock +from lib.utils import get_backend from ._base import ModelBase, KerasModel, logger +if get_backend() == "amd": + from keras.layers import Concatenate, Dense, Flatten, Input, LeakyReLU, Reshape +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.layers import Concatenate, Dense, Flatten, Input, LeakyReLU, Reshape # noqa pylint:disable=import-error,no-name-in-module + class Model(ModelBase): """ SAE Model from DFL """ @@ -50,7 +55,7 @@ def _patch_weights_management(self): def build_model(self, inputs): """ Build the DFL-SAE Model """ - encoder = getattr(self, "encoder_{}".format(self.architecture))() + encoder = getattr(self, f"encoder_{self.architecture}")() enc_output_shape = encoder.output_shape[1:] encoder_a = encoder(inputs[0]) encoder_b = encoder(inputs[1]) @@ -108,7 +113,7 @@ def inter_liae(self, side, input_shape): var_x = Dense(lowest_dense_res * lowest_dense_res * self.ae_dims * 2)(var_x) var_x = Reshape((lowest_dense_res, lowest_dense_res, self.ae_dims * 2))(var_x) var_x = UpscaleBlock(self.ae_dims * 2, activation="leakyrelu")(var_x) - return KerasModel(input_, var_x, name="intermediate_{}".format(side)) + return KerasModel(input_, var_x, name=f"intermediate_{side}") def decoder(self, side, input_shape): """ DFL SAE Decoder Network""" @@ -123,38 +128,38 @@ def decoder(self, side, input_shape): var_x1 = ResidualBlock(dims * 8)(var_x1) var_x1 = ResidualBlock(dims * 8)(var_x1) if self.multiscale_count >= 3: - outputs.append(Conv2DOutput(3, 5, name="face_out_32_{}".format(side))(var_x1)) + outputs.append(Conv2DOutput(3, 5, name=f"face_out_32_{side}")(var_x1)) var_x2 = UpscaleBlock(dims * 4, activation=None)(var_x1) var_x2 = LeakyReLU(alpha=0.2)(var_x2) var_x2 = ResidualBlock(dims * 4)(var_x2) var_x2 = ResidualBlock(dims * 4)(var_x2) if self.multiscale_count >= 2: - outputs.append(Conv2DOutput(3, 5, name="face_out_64_{}".format(side))(var_x2)) + outputs.append(Conv2DOutput(3, 5, name=f"face_out_64_{side}")(var_x2)) var_x3 = UpscaleBlock(dims * 2, activation=None)(var_x2) var_x3 = LeakyReLU(alpha=0.2)(var_x3) var_x3 = ResidualBlock(dims * 2)(var_x3) var_x3 = ResidualBlock(dims * 2)(var_x3) - outputs.append(Conv2DOutput(3, 5, name="face_out_128_{}".format(side))(var_x3)) + outputs.append(Conv2DOutput(3, 5, name=f"face_out_128_{side}")(var_x3)) if self.use_mask: var_y = input_ var_y = UpscaleBlock(self.decoder_dim * 8, activation="leakyrelu")(var_y) var_y = UpscaleBlock(self.decoder_dim * 4, activation="leakyrelu")(var_y) var_y = UpscaleBlock(self.decoder_dim * 2, activation="leakyrelu")(var_y) - var_y = Conv2DOutput(1, 5, name="mask_out_{}".format(side))(var_y) + var_y = Conv2DOutput(1, 5, name=f"mask_out_{side}")(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs, name="decoder_{}".format(side)) + return KerasModel(input_, outputs=outputs, name=f"decoder_{side}") def _legacy_mapping(self): """ The mapping of legacy separate model names to single model names """ - mappings = dict(df={"{}_encoder.h5".format(self.name): "encoder_df", - "{}_decoder_A.h5".format(self.name): "decoder_a", - "{}_decoder_B.h5".format(self.name): "decoder_b"}, - liae={"{}_encoder.h5".format(self.name): "encoder_liae", - "{}_intermediate_B.h5".format(self.name): "intermediate_both", - "{}_intermediate.h5".format(self.name): "intermediate_b", - "{}_decoder.h5".format(self.name): "decoder_both"}) + mappings = dict(df={f"{self.name}_encoder.h5": "encoder_df", + f"{self.name}_decoder_A.h5": "decoder_a", + f"{self.name}_decoder_B.h5": "decoder_b"}, + liae={f"{self.name}_encoder.h5": "encoder_liae", + f"{self.name}_intermediate_B.h5": "intermediate_both", + f"{self.name}_intermediate.h5": "intermediate_b", + f"{self.name}_decoder.h5": "decoder_both"}) return mappings[self.config["architecture"]] diff --git a/plugins/train/model/dlight.py b/plugins/train/model/dlight.py index 66c7fbd217..18808122ff 100644 --- a/plugins/train/model/dlight.py +++ b/plugins/train/model/dlight.py @@ -8,15 +8,22 @@ DeepHomage for lots of testing """ -from keras.layers import (AveragePooling2D, BatchNormalization, Concatenate, Dense, Dropout, - Flatten, Input, Reshape, LeakyReLU, UpSampling2D) - from lib.model.nn_blocks import (Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock, Upscale2xBlock) -from lib.utils import FaceswapError +from lib.utils import FaceswapError, get_backend from ._base import ModelBase, KerasModel, logger +if get_backend() == "amd": + from keras.layers import ( + AveragePooling2D, BatchNormalization, Concatenate, Dense, Dropout, Flatten, Input, Reshape, + LeakyReLU, UpSampling2D) +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.layers import ( # pylint:disable=import-error,no-name-in-module + AveragePooling2D, BatchNormalization, Concatenate, Dense, Dropout, Flatten, Input, Reshape, + LeakyReLU, UpSampling2D) + class Model(ModelBase): """ DLight Autoencoder Model """ @@ -218,6 +225,6 @@ def decoder_b(self): def _legacy_mapping(self): """ The mapping of legacy separate model names to single model names """ decoder_b = "decoder_b" if self.details > 0 else "decoder_b_fast" - return {"{}_encoder.h5".format(self.name): "encoder", - "{}_decoder_A.h5".format(self.name): "decoder_a", - "{}_decoder_B.h5".format(self.name): decoder_b} + return {f"{self.name}_encoder.h5": "encoder", + f"{self.name}_decoder_A.h5": "decoder_a", + f"{self.name}_decoder_B.h5": decoder_b} diff --git a/plugins/train/model/iae.py b/plugins/train/model/iae.py index 63ed593d3d..dbbb982e30 100644 --- a/plugins/train/model/iae.py +++ b/plugins/train/model/iae.py @@ -1,11 +1,18 @@ #!/usr/bin/env python3 """ Improved autoencoder for faceswap """ -from keras.layers import Concatenate, Dense, Flatten, Input, Reshape - from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock +from lib.utils import get_backend + from ._base import ModelBase, KerasModel +if get_backend() == "amd": + from keras.layers import Concatenate, Dense, Flatten, Input, Reshape + +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.layers import Concatenate, Dense, Flatten, Input, Reshape # noqa pylint:disable=import-error,no-name-in-module + class Model(ModelBase): """ Improved Autoencoder Model """ @@ -48,7 +55,7 @@ def intermediate(self, side): var_x = Dense(self.encoder_dim)(input_) var_x = Dense(4 * 4 * int(self.encoder_dim/2))(var_x) var_x = Reshape((4, 4, int(self.encoder_dim/2)))(var_x) - return KerasModel(input_, var_x, name="inter_{}".format(side)) + return KerasModel(input_, var_x, name=f"inter_{side}") def decoder(self): """ Decoder Network """ @@ -73,8 +80,8 @@ def decoder(self): def _legacy_mapping(self): """ The mapping of legacy separate model names to single model names """ - return {"{}_encoder.h5".format(self.name): "encoder", - "{}_intermediate_A.h5".format(self.name): "inter_a", - "{}_intermediate_B.h5".format(self.name): "inter_b", - "{}_inter.h5".format(self.name): "inter_both", - "{}_decoder.h5".format(self.name): "decoder"} + return {f"{self.name}_encoder.h5": "encoder", + f"{self.name}_intermediate_A.h5": "inter_a", + f"{self.name}_intermediate_B.h5": "inter_b", + f"{self.name}_inter.h5": "inter_both", + f"{self.name}_decoder.h5": "decoder"} diff --git a/plugins/train/model/lightweight.py b/plugins/train/model/lightweight.py index 5618613949..7dc0c69880 100644 --- a/plugins/train/model/lightweight.py +++ b/plugins/train/model/lightweight.py @@ -4,10 +4,8 @@ Based on the original https://www.reddit.com/r/deepfakes/ code sample + contributions """ -from keras.layers import Dense, Flatten, Input, Reshape - from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock -from .original import Model as OriginalModel, KerasModel +from .original import Model as OriginalModel, KerasModel, Dense, Flatten, Input, Reshape class Model(OriginalModel): @@ -36,7 +34,7 @@ def decoder(self, side): var_x = UpscaleBlock(512, activation="leakyrelu")(var_x) var_x = UpscaleBlock(256, activation="leakyrelu")(var_x) var_x = UpscaleBlock(128, activation="leakyrelu")(var_x) - var_x = Conv2DOutput(3, 5, activation="sigmoid", name="face_out_{}".format(side))(var_x) + var_x = Conv2DOutput(3, 5, activation="sigmoid", name=f"face_out_{side}")(var_x) outputs = [var_x] if self.config.get("learn_mask", False): @@ -46,6 +44,6 @@ def decoder(self, side): var_y = UpscaleBlock(128, activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, activation="sigmoid", - name="mask_out_{}".format(side))(var_y) + name=f"mask_out_{side}")(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs, name="decoder_{}".format(side)) + return KerasModel(input_, outputs=outputs, name=f"decoder_{side}") diff --git a/plugins/train/model/original.py b/plugins/train/model/original.py index 41c193e6b3..d23b59c532 100644 --- a/plugins/train/model/original.py +++ b/plugins/train/model/original.py @@ -5,11 +5,17 @@ This model is heavily documented as it acts as a template that other model plugins can be developed from. """ -from keras.layers import Dense, Flatten, Reshape, Input from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock +from lib.utils import get_backend from ._base import KerasModel, ModelBase +if get_backend() == "amd": + from keras.layers import Dense, Flatten, Reshape, Input +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.layers import Dense, Flatten, Reshape, Input # noqa pylint:disable=import-error,no-name-in-module + class Model(ModelBase): """ Original Faceswap Model. @@ -144,7 +150,7 @@ def decoder(self, side): var_x = UpscaleBlock(256, activation="leakyrelu")(var_x) var_x = UpscaleBlock(128, activation="leakyrelu")(var_x) var_x = UpscaleBlock(64, activation="leakyrelu")(var_x) - var_x = Conv2DOutput(3, 5, name="face_out_{}".format(side))(var_x) + var_x = Conv2DOutput(3, 5, name=f"face_out_{side}")(var_x) outputs = [var_x] if self.learn_mask: @@ -152,12 +158,12 @@ def decoder(self, side): var_y = UpscaleBlock(256, activation="leakyrelu")(var_y) var_y = UpscaleBlock(128, activation="leakyrelu")(var_y) var_y = UpscaleBlock(64, activation="leakyrelu")(var_y) - var_y = Conv2DOutput(1, 5, name="mask_out_{}".format(side))(var_y) + var_y = Conv2DOutput(1, 5, name=f"mask_out_{side}")(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs, name="decoder_{}".format(side)) + return KerasModel(input_, outputs=outputs, name=f"decoder_{side}") def _legacy_mapping(self): """ The mapping of legacy separate model names to single model names """ - return {"{}_encoder.h5".format(self.name): "encoder", - "{}_decoder_A.h5".format(self.name): "decoder_a", - "{}_decoder_B.h5".format(self.name): "decoder_b"} + return {f"{self.name}_encoder.h5": "encoder", + f"{self.name}_decoder_A.h5": "decoder_a", + f"{self.name}_decoder_B.h5": "decoder_b"} diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 2e7e0ca638..4acca28f13 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -4,28 +4,31 @@ import numpy as np import tensorflow as tf -import keras.backend as K -from keras.layers import ( - Add, BatchNormalization, Concatenate, Dense, Dropout, Flatten, GaussianNoise, - GlobalAveragePooling2D, GlobalMaxPooling2D, Input, LeakyReLU, Reshape, UpSampling2D, - Conv2D as KConv2D) -from keras.models import clone_model - from lib.model.nn_blocks import ( Conv2D, Conv2DBlock, Conv2DOutput, ResidualBlock, UpscaleBlock, Upscale2xBlock, UpscaleResizeImagesBlock) from lib.model.normalization import ( AdaInstanceNormalization, GroupNormalization, InstanceNormalization, LayerNormalization, RMSNormalization) - from lib.utils import get_backend, FaceswapError +from ._base import KerasModel, ModelBase, logger, _get_all_sub_models + if get_backend() == "amd": - from keras import applications as kapp + from keras import applications as kapp, backend as K + from keras.layers import ( + Add, BatchNormalization, Concatenate, Dense, Dropout, Flatten, GaussianNoise, + GlobalAveragePooling2D, GlobalMaxPooling2D, Input, LeakyReLU, Reshape, UpSampling2D, + Conv2D as KConv2D) + from keras.models import clone_model else: - from tensorflow.keras import applications as kapp - -from ._base import KerasModel, ModelBase, logger, _get_all_sub_models + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras import applications as kapp, backend as K # pylint:disable=import-error + from tensorflow.keras.layers import ( # pylint:disable=import-error,no-name-in-module + Add, BatchNormalization, Concatenate, Dense, Dropout, Flatten, GaussianNoise, + GlobalAveragePooling2D, GlobalMaxPooling2D, Input, LeakyReLU, Reshape, UpSampling2D, + Conv2D as KConv2D) + from tensorflow.keras.models import clone_model # noqa pylint:disable=import-error,no-name-in-module _MODEL_MAPPING = dict( @@ -231,7 +234,7 @@ def _validate_encoder_architecture(self): f"one of {list(_MODEL_MAPPING.keys())}.") if get_backend() == "amd" and model.get("no_amd"): - valid = [x for x in _MODEL_MAPPING if not _MODEL_MAPPING[x].get('no_amd')] + valid = [k for k, v in _MODEL_MAPPING.items() if not v.get('no_amd')] raise FaceswapError(f"'{arch}' is not compatible with the AMD backend. Choose one of " f"{valid}.") @@ -553,7 +556,7 @@ def _selected_model(self): """ dict: The selected encoder model options dictionary """ arch = self._config["enc_architecture"] model = _MODEL_MAPPING.get(arch) - model["kwargs"] = self._model_kwargs.get(arch, dict()) + model["kwargs"] = self._model_kwargs.get(arch, {}) return model @property @@ -804,7 +807,7 @@ def __call__(self): if self._config["fc_upsampler"].lower() == "upsample2d": var_x = LeakyReLU(alpha=0.1)(var_x) - return KerasModel(input_, var_x, name="fc_{}".format(self._side)) + return KerasModel(input_, var_x, name=f"fc_{self._side}") class GBlock(): # pylint:disable=too-few-public-methods @@ -1028,4 +1031,4 @@ def __call__(self): self._config["dec_output_kernel"], name="mask_out")(var_y)) - return KerasModel(inputs, outputs=outputs, name="decoder_{}".format(self._side)) + return KerasModel(inputs, outputs=outputs, name=f"decoder_{self._side}") diff --git a/plugins/train/model/realface.py b/plugins/train/model/realface.py index 3a135eac81..fd1aa73569 100644 --- a/plugins/train/model/realface.py +++ b/plugins/train/model/realface.py @@ -9,13 +9,18 @@ """ import sys -from keras.initializers import RandomNormal -from keras.layers import Dense, Flatten, Input, LeakyReLU, Reshape - - from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock +from lib.utils import get_backend from ._base import ModelBase, KerasModel, logger +if get_backend() == "amd": + from keras.initializers import RandomNormal + from keras.layers import Dense, Flatten, Input, LeakyReLU, Reshape +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.initializers import RandomNormal # noqa pylint:disable=import-error,no-name-in-module + from tensorflow.keras.layers import Dense, Flatten, Input, LeakyReLU, Reshape # noqa pylint:disable=import-error,no-name-in-module + class Model(ModelBase): """ RealFace(tm) Faceswap Model """ @@ -183,6 +188,6 @@ def decoder_a(self): def _legacy_mapping(self): """ The mapping of legacy separate model names to single model names """ - return {"{}_encoder.h5".format(self.name): "encoder", - "{}_decoder_A.h5".format(self.name): "decoder_a", - "{}_decoder_B.h5".format(self.name): "decoder_b"} + return {f"{self.name}_encoder.h5": "encoder", + f"{self.name}_decoder_A.h5": "decoder_a", + f"{self.name}_decoder_B.h5": "decoder_b"} diff --git a/plugins/train/model/unbalanced.py b/plugins/train/model/unbalanced.py index 9146755db2..9330f0f4c0 100644 --- a/plugins/train/model/unbalanced.py +++ b/plugins/train/model/unbalanced.py @@ -3,12 +3,18 @@ Based on the original https://www.reddit.com/r/deepfakes/ code sample + contributions """ -from keras.initializers import RandomNormal -from keras.layers import Dense, Flatten, Input, LeakyReLU, Reshape, SpatialDropout2D - from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock +from lib.utils import get_backend from ._base import ModelBase, KerasModel +if get_backend() == "amd": + from keras.initializers import RandomNormal + from keras.layers import Dense, Flatten, Input, LeakyReLU, Reshape, SpatialDropout2D +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.initializers import RandomNormal # noqa pylint:disable=import-error,no-name-in-module + from tensorflow.keras.layers import Dense, Flatten, Input, LeakyReLU, Reshape, SpatialDropout2D # noqa pylint:disable=import-error,no-name-in-module + class Model(ModelBase): """ Unbalanced Faceswap Model """ @@ -135,6 +141,6 @@ def decoder_b(self): def _legacy_mapping(self): """ The mapping of legacy separate model names to single model names """ - return {"{}_encoder.h5".format(self.name): "encoder", - "{}_decoder_A.h5".format(self.name): "decoder_a", - "{}_decoder_B.h5".format(self.name): "decoder_b"} + return {f"{self.name}_encoder.h5": "encoder", + f"{self.name}_decoder_A.h5": "decoder_a", + f"{self.name}_decoder_B.h5": "decoder_b"} diff --git a/plugins/train/model/villain.py b/plugins/train/model/villain.py index f9f241c1a6..16efdc6c4d 100644 --- a/plugins/train/model/villain.py +++ b/plugins/train/model/villain.py @@ -3,14 +3,21 @@ Based on the original https://www.reddit.com/r/deepfakes/ code sample + contributions Adapted from a model by VillainGuy (https://github.com/VillainGuy) """ -from keras.initializers import RandomNormal -from keras.layers import add, Dense, Flatten, Input, LeakyReLU, Reshape - from lib.model.layers import PixelShuffler from lib.model.nn_blocks import (Conv2DOutput, Conv2DBlock, ResidualBlock, SeparableConv2DBlock, UpscaleBlock) +from lib.utils import get_backend + from .original import Model as OriginalModel, KerasModel +if get_backend() == "amd": + from keras.initializers import RandomNormal + from keras.layers import add, Dense, Flatten, Input, LeakyReLU, Reshape +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.initializers import RandomNormal # noqa pylint:disable=import-error,no-name-in-module + from tensorflow.keras.layers import add, Dense, Flatten, Input, LeakyReLU, Reshape # noqa pylint:disable=import-error,no-name-in-module + class Model(OriginalModel): """ Villain Faceswap Model """ @@ -72,7 +79,7 @@ def decoder(self, side): var_x = UpscaleBlock(self.input_shape[0], activation=None, **kwargs)(var_x) var_x = LeakyReLU(alpha=0.2)(var_x) var_x = ResidualBlock(self.input_shape[0], **kwargs)(var_x) - var_x = Conv2DOutput(3, 5, name="face_out_{}".format(side))(var_x) + var_x = Conv2DOutput(3, 5, name=f"face_out_{side}")(var_x) outputs = [var_x] if self.config.get("learn_mask", False): @@ -80,6 +87,6 @@ def decoder(self, side): var_y = UpscaleBlock(512, activation="leakyrelu")(var_y) var_y = UpscaleBlock(256, activation="leakyrelu")(var_y) var_y = UpscaleBlock(self.input_shape[0], activation="leakyrelu")(var_y) - var_y = Conv2DOutput(1, 5, name="mask_out_{}".format(side))(var_y) + var_y = Conv2DOutput(1, 5, name=f"mask_out_{side}")(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs, name="decoder_{}".format(side)) + return KerasModel(input_, outputs=outputs, name=f"decoder_{side}") diff --git a/tests/__init__.py b/tests/__init__.py index e0783c0b38..e69de29bb2 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,7 +0,0 @@ -#!/usr/bin/env python3 -""" Use custom Importer for importing Keras for tests """ -import sys -from lib.utils import KerasFinder - - -sys.meta_path.insert(0, KerasFinder()) diff --git a/tests/lib/model/initializers_test.py b/tests/lib/model/initializers_test.py index 01c44b4b79..2e4921586c 100644 --- a/tests/lib/model/initializers_test.py +++ b/tests/lib/model/initializers_test.py @@ -4,14 +4,21 @@ Adapted from Keras tests. """ -from keras import backend as K -from keras import initializers as k_initializers import pytest import numpy as np from lib.model import initializers from lib.utils import get_backend +if get_backend() == "amd": + from keras import backend as K + from keras import initializers as k_initializers +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras import backend as K # pylint:disable=import-error + from tensorflow.keras import initializers as k_initializers # pylint:disable=import-error + + CONV_SHAPE = (3, 3, 256, 2048) CONV_ID = get_backend().upper() diff --git a/tests/lib/model/layers_test.py b/tests/lib/model/layers_test.py index a650dd4833..b6c8fb9286 100644 --- a/tests/lib/model/layers_test.py +++ b/tests/lib/model/layers_test.py @@ -7,7 +7,6 @@ import pytest import numpy as np -from keras import Input, Model, backend as K from numpy.testing import assert_allclose @@ -15,6 +14,13 @@ from lib.utils import get_backend from tests.utils import has_arg +if get_backend() == "amd": + from keras import Input, Model, backend as K +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras import Input, Model, backend as K # pylint:disable=import-error + + CONV_SHAPE = (3, 3, 256, 2048) CONV_ID = get_backend().upper() diff --git a/tests/lib/model/losses_test.py b/tests/lib/model/losses_test.py index 213333c454..1ff2fde597 100644 --- a/tests/lib/model/losses_test.py +++ b/tests/lib/model/losses_test.py @@ -6,17 +6,15 @@ import pytest import numpy as np -from numpy.testing import assert_allclose - -from keras import backend as K -from keras import losses as k_losses -from keras.layers import Conv2D -from keras.models import Sequential -from keras.optimizers import Adam from lib.model import losses from lib.utils import get_backend +if get_backend() == "amd": + from keras import backend as K, losses as k_losses +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras import backend as K, losses as k_losses # pylint:disable=import-error _PARAMS = [(losses.GeneralizedLoss(), (2, 16, 16)), (losses.GradientLoss(), (2, 16, 16)), @@ -25,7 +23,7 @@ # TODO Make sure these output dimensions are correct (losses.LInfNorm(), (2, 1, 1))] _IDS = ["GeneralizedLoss", "GradientLoss", "GMSDLoss", "LInfNorm"] -_IDS = ["{}[{}]".format(loss, get_backend().upper()) for loss in _IDS] +_IDS = [f"{loss}[{get_backend().upper()}]" for loss in _IDS] @pytest.mark.parametrize(["loss_func", "output_shape"], _PARAMS, ids=_IDS) @@ -48,7 +46,7 @@ def test_loss_output(loss_func, output_shape): k_losses.logcosh, losses.DSSIMObjective()] _LWIDS = ["GeneralizedLoss", "GradientLoss", "GMSDLoss", "LInfNorm", "mae", "mse", "logcosh", "DSSIMObjective"] -_LWIDS = ["{}[{}]".format(loss, get_backend().upper()) for loss in _LWIDS] +_LWIDS = [f"{loss}[{get_backend().upper()}]" for loss in _LWIDS] @pytest.mark.parametrize("loss_func", _LWPARAMS, ids=_LWIDS) diff --git a/tests/lib/model/nn_blocks_test.py b/tests/lib/model/nn_blocks_test.py index bb9676c63b..d775be8b0e 100644 --- a/tests/lib/model/nn_blocks_test.py +++ b/tests/lib/model/nn_blocks_test.py @@ -9,12 +9,18 @@ import pytest import numpy as np -from keras import Input, Model, backend as K + from numpy.testing import assert_allclose from lib.model import nn_blocks from lib.utils import get_backend +if get_backend() == "amd": + from keras import Input, Model, backend as K +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras import Input, Model, backend as K # pylint:disable=import-error + def block_test(layer_func, kwargs={}, input_shape=None): """Test routine for faceswap neural network blocks. diff --git a/tests/lib/model/normalization_test.py b/tests/lib/model/normalization_test.py index 925c2f3521..7f3b1fb7f6 100644 --- a/tests/lib/model/normalization_test.py +++ b/tests/lib/model/normalization_test.py @@ -8,13 +8,17 @@ import numpy as np import pytest -from keras import regularizers, models, layers - from lib.model import normalization from lib.utils import get_backend from tests.lib.model.layers_test import layer_test +if get_backend() == "amd": + from keras import regularizers, models, layers +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras import regularizers, models, layers # pylint:disable=import-error + @pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) def test_instance_normalization(dummy): # pylint:disable=unused-argument @@ -101,8 +105,7 @@ def test_layer_normalization(center, scale): _PARAMS = ["partial", "bias"] _VALUES = [(0.0, False), (0.25, False), (0.5, True), (0.75, False), (1.0, True)] -_IDS = ["partial={}|bias={}[{}]".format(v[0], v[1], get_backend().upper()) - for v in _VALUES] +_IDS = [f"partial={v[0]}|bias={v[1]}[{get_backend().upper()}]" for v in _VALUES] @pytest.mark.parametrize(_PARAMS, _VALUES, ids=_IDS) diff --git a/tests/lib/model/optimizers_test.py b/tests/lib/model/optimizers_test.py index adc82945be..b85581c43e 100644 --- a/tests/lib/model/optimizers_test.py +++ b/tests/lib/model/optimizers_test.py @@ -5,9 +5,6 @@ """ import pytest -from keras import optimizers as k_optimizers -from keras.layers import Dense, Activation -from keras.models import Sequential import numpy as np from numpy.testing import assert_allclose @@ -16,6 +13,16 @@ from tests.utils import generate_test_data, to_categorical +if get_backend() == "amd": + from keras import optimizers as k_optimizers + from keras.layers import Dense, Activation + from keras.models import Sequential +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras import optimizers as k_optimizers # pylint:disable=import-error + from tensorflow.keras.layers import Dense, Activation # noqa pylint:disable=import-error,no-name-in-module + from tensorflow.keras.models import Sequential # pylint:disable=import-error,no-name-in-module + def get_test_data(): """ Obtain randomized test data for training """ diff --git a/tests/startup_test.py b/tests/startup_test.py index 6fb92e85e2..d48e0cc10e 100644 --- a/tests/startup_test.py +++ b/tests/startup_test.py @@ -5,11 +5,17 @@ import pytest -import keras -from keras import backend as K - from lib.utils import get_backend +if get_backend() == "amd": + import keras + from keras import backend as K +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow import keras + from tensorflow.keras import backend as K # pylint:disable=import-error + + _BACKEND = get_backend() From 5569abbb63dd4992309059ae1c463f4c37ac4a4b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 4 May 2022 01:58:00 +0100 Subject: [PATCH 539/981] training - Add MS-SSIM loss function --- lib/model/losses_plaid.py | 59 ++++++++++++++++++++++ lib/model/losses_tf.py | 94 ++++++++++++++++++++++++++++++++++++ plugins/train/_config.py | 15 ++++-- plugins/train/model/_base.py | 3 +- 4 files changed, 165 insertions(+), 6 deletions(-) diff --git a/lib/model/losses_plaid.py b/lib/model/losses_plaid.py index a99ce123a0..1aba162fc8 100644 --- a/lib/model/losses_plaid.py +++ b/lib/model/losses_plaid.py @@ -199,6 +199,65 @@ def extract_image_patches(self, input_tensor, k_sizes, s_sizes, return patches +class MSSSIMLoss(): # pylint:disable=too-few-public-methods + """ Multiscale Structural Similarity Loss Function + + Parameters + ---------- + k_1: float, optional + Parameter of the SSIM. Default: `0.01` + k_2: float, optional + Parameter of the SSIM. Default: `0.03` + filter_size: int, optional + size of gaussian filter Default: `11` + filter_sigma: float, optional + Width of gaussian filter Default: `1.5` + max_value: float, optional + Max value of the output. Default: `1.0` + power_factors: tuple, optional + Iterable of weights for each of the scales. The number of scales used is the length of the + list. Index 0 is the unscaled resolution's weight and each increasing scale corresponds to + the image being downsampled by 2. Defaults to the values obtained in the original paper. + Default: (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) + + Notes + ------ + You should add a regularization term like a l2 loss in addition to this one. + """ + def __init__(self, + k_1=0.01, + k_2=0.03, + filter_size=4, + filter_sigma=1.5, + max_value=1.0, + power_factors=(0.0448, 0.2856, 0.3001, 0.2363, 0.1333)): + super().__init__(name="SSIM_Multiscale_Loss") + self.filter_size = filter_size + self.filter_sigma = filter_sigma + self.k_1 = k_1 + self.k_2 = k_2 + self.max_value = max_value + self.power_factors = power_factors + + def __call__(self, y_true, y_pred): + """ Call the MS-SSIM Loss Function. + + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + + Returns + ------- + tensor + The MS-SSIM Loss value + """ + raise FaceswapError("MS-SSIM Loss is not currently compatible with PlaidML. Please select " + "a different Loss method.") + + class GeneralizedLoss(): # pylint:disable=too-few-public-methods """ Generalized function used to return a large variety of mathematical loss functions. diff --git a/lib/model/losses_tf.py b/lib/model/losses_tf.py index a1f6481d0d..fac4f6d00e 100644 --- a/lib/model/losses_tf.py +++ b/lib/model/losses_tf.py @@ -71,6 +71,100 @@ def call(self, y_true, y_pred): return dssim_loss +class MSSSIMLoss(tf.keras.losses.Loss): # pylint:disable=too-few-public-methods + """ Multiscale Structural Similarity Loss Function + + Parameters + ---------- + k_1: float, optional + Parameter of the SSIM. Default: `0.01` + k_2: float, optional + Parameter of the SSIM. Default: `0.03` + filter_size: int, optional + size of gaussian filter Default: `11` + filter_sigma: float, optional + Width of gaussian filter Default: `1.5` + max_value: float, optional + Max value of the output. Default: `1.0` + power_factors: tuple, optional + Iterable of weights for each of the scales. The number of scales used is the length of the + list. Index 0 is the unscaled resolution's weight and each increasing scale corresponds to + the image being downsampled by 2. Defaults to the values obtained in the original paper. + Default: (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) + + Notes + ------ + You should add a regularization term like a l2 loss in addition to this one. + """ + def __init__(self, + k_1=0.01, + k_2=0.03, + filter_size=4, + filter_sigma=1.5, + max_value=1.0, + power_factors=(0.0448, 0.2856, 0.3001, 0.2363, 0.1333)): + super().__init__(name="SSIM_Multiscale_Loss") + self.filter_size = filter_size + self.filter_sigma = filter_sigma + self.k_1 = k_1 + self.k_2 = k_2 + self.max_value = max_value + self.power_factors = power_factors + + def call(self, y_true, y_pred): + """ Call the MS-SSIM Loss Function. + + Parameters + ---------- + y_true: tensor or variable + The ground truth value + y_pred: tensor or variable + The predicted value + + Returns + ------- + tensor + The MS-SSIM Loss value + """ + im_size = K.int_shape(y_true)[1] + # filter size cannot be larger than the smallest scale + smallest_scale = self._get_smallest_size(im_size, len(self.power_factors) - 1) + filter_size = min(self.filter_size, smallest_scale) + + ms_ssim = tf.image.ssim_multiscale(y_true, + y_pred, + self.max_value, + power_factors=self.power_factors, + filter_size=filter_size, + filter_sigma=self.filter_sigma, + k1=self.k_1, + k2=self.k_2) + ms_ssim_loss = 1. - ms_ssim + return ms_ssim_loss + + def _get_smallest_size(self, size, idx): + """ Recursive function to obtain the smallest size that the image will be scaled to. + + Parameters + ---------- + size: int + The current scaled size to iterate through + idx: int + The current iteration to be performed. When iteration hits zero the value will + be returned + + Returns + ------- + int + The smallest size the image will be scaled to based on the original image size and + the amount of scaling factors that will occur + """ + logger.debug("scale id: %s, size: %s", idx, size) + if idx > 0: + size = self._get_smallest_size(size // 2, idx - 1) + return size + + class GeneralizedLoss(tf.keras.losses.Loss): # pylint:disable=too-few-public-methods """ Generalized function used to return a large variety of mathematical loss functions. diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 56fbb6a5e9..0fd195a1bb 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -234,6 +234,7 @@ def _set_loss(self): L_inf_norm https://medium.com/@montjoile/l0-norm-l1-norm-l2-norm-l-infinity -norm-7a7d18a4f40c SSIM http://www.cns.nyu.edu/pub/eero/wang03-reprint.pdf + MSSIM https://www.cns.nyu.edu/pub/eero/wang03b.pdf GMSD https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf """ logger.debug("Setting Loss config") @@ -248,8 +249,8 @@ def _set_loss(self): datatype=str, group="loss", default="ssim", - choices=["mae", "mse", "logcosh", "smooth_loss", "l_inf_norm", "ssim", "gmsd", - "pixel_gradient_diff"], + choices=["mae", "mse", "logcosh", "smooth_loss", "l_inf_norm", "ssim", "ms_ssim", + "gmsd", "pixel_gradient_diff"], info="The loss function to use." "\n\t MAE - Mean absolute error will guide reconstructions of each pixel " "towards its median value in the training dataset. Robust to outliers but as " @@ -269,6 +270,9 @@ def _set_loss(self): "\n\t SSIM - Structural Similarity Index Metric is a perception-based " "loss that considers changes in texture, luminance, contrast, and local spatial " "statistics of an image. Potentially delivers more realistic looking images." + "\n\t MS_SSIM - Multiscale Structural Similarity Index Metric is similar to SSIM " + "except that it performs the calculations along multiple scales of the input " + "image. NB: This loss currently does not work on AMD Cards." "\n\t GMSD - Gradient Magnitude Similarity Deviation seeks to match " "the global standard deviation of the pixel to pixel differences between two " "images. Similar in approach to SSIM. NB: This loss does not currently work on " @@ -304,9 +308,10 @@ def _set_loss(self): "loss functions.\n\nNB: You should only adjust this if you know what you are " "doing!\n\n" "L2 regularization applies a penalty term to the given Loss function. This " - "penalty will only be applied if SSIM or GMSD is selected for the main loss " - "function, otherwise it is ignored.\n\nThe value given here is as a percentage " - "weight of the main loss function. For example:" + "penalty will only be applied if SSIM, MS-SSIM or GMSD is selected for the main " + "loss function, otherwise it is ignored." + "\n\nThe value given here is as a percentage weight of the main loss function. " + "For example:" "\n\t 100 - Will give equal weighting to the main loss and the penalty function. " "\n\t 25 - Will give the penalty function 1/4 of the weight of the main loss " "function. " diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index d957d0b8cc..66a345500d 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -1177,9 +1177,10 @@ def __init__(self): smooth_loss=losses.GeneralizedLoss(), l_inf_norm=losses.LInfNorm(), ssim=losses.DSSIMObjective(), + ms_ssim=losses.MSSSIMLoss(), gmsd=losses.GMSDLoss(), pixel_gradient_diff=losses.GradientLoss()) - self._uses_l2_reg = ["ssim", "gmsd"] + self._uses_l2_reg = ["ssim", "ms_ssim", "gmsd"] self._inputs = None self._names = [] self._funcs = {} From 1f5561795aae8ca9eb4fd03de25e5f31b3e7e42e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 4 May 2022 09:56:48 +0100 Subject: [PATCH 540/981] update losses test --- tests/lib/model/losses_test.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/lib/model/losses_test.py b/tests/lib/model/losses_test.py index 1ff2fde597..7c83b8755e 100644 --- a/tests/lib/model/losses_test.py +++ b/tests/lib/model/losses_test.py @@ -43,9 +43,9 @@ def test_loss_output(loss_func, output_shape): _LWPARAMS = [losses.GeneralizedLoss(), losses.GradientLoss(), losses.GMSDLoss(), losses.LInfNorm(), k_losses.mean_absolute_error, k_losses.mean_squared_error, - k_losses.logcosh, losses.DSSIMObjective()] + k_losses.logcosh, losses.DSSIMObjective(), losses.MSSSIMLoss()] _LWIDS = ["GeneralizedLoss", "GradientLoss", "GMSDLoss", "LInfNorm", "mae", "mse", "logcosh", - "DSSIMObjective"] + "DSSIMObjective", "MS-SSIM"] _LWIDS = [f"{loss}[{get_backend().upper()}]" for loss in _LWIDS] @@ -55,6 +55,8 @@ def test_loss_wrapper(loss_func): if get_backend() == "amd": if isinstance(loss_func, losses.GMSDLoss): pytest.skip("GMSD Loss is not currently compatible with PlaidML") + if isinstance(loss_func, losses.MSSSIMLoss): + pytest.skip("MS-SSIM Loss is not currently compatible with PlaidML") if hasattr(loss_func, "__name__") and loss_func.__name__ == "logcosh": pytest.skip("LogCosh Loss is not currently compatible with PlaidML") y_a = K.variable(np.random.random((2, 16, 16, 4))) From 332394edbf65c5503f7e1370e2a290a68a685e69 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 4 May 2022 10:03:14 +0100 Subject: [PATCH 541/981] bugfix: correct init for ms-ssim amd --- lib/model/losses_plaid.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/model/losses_plaid.py b/lib/model/losses_plaid.py index 1aba162fc8..1d5cf5442b 100644 --- a/lib/model/losses_plaid.py +++ b/lib/model/losses_plaid.py @@ -231,7 +231,6 @@ def __init__(self, filter_sigma=1.5, max_value=1.0, power_factors=(0.0448, 0.2856, 0.3001, 0.2363, 0.1333)): - super().__init__(name="SSIM_Multiscale_Loss") self.filter_size = filter_size self.filter_sigma = filter_sigma self.k_1 = k_1 From 0189029dbaad486e623353ee4a8451af8c85f4e4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 4 May 2022 11:16:30 +0100 Subject: [PATCH 542/981] Phaze-A: Add MobileNetV3 encoder --- plugins/train/model/phaze_a.py | 83 ++++++++++--------------- plugins/train/model/phaze_a_defaults.py | 41 ++++++++---- 2 files changed, 61 insertions(+), 63 deletions(-) diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 4acca28f13..6fe3231795 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -2,7 +2,6 @@ """ Phaze-A Model by TorzDF with thanks to BirbFakes and the myriad of testers. """ import numpy as np -import tensorflow as tf from lib.model.nn_blocks import ( Conv2D, Conv2DBlock, Conv2DOutput, ResidualBlock, UpscaleBlock, Upscale2xBlock, @@ -10,7 +9,7 @@ from lib.model.normalization import ( AdaInstanceNormalization, GroupNormalization, InstanceNormalization, LayerNormalization, RMSNormalization) -from lib.utils import get_backend, FaceswapError +from lib.utils import get_backend, get_tf_version, FaceswapError from ._base import KerasModel, ModelBase, logger, _get_all_sub_models @@ -62,6 +61,10 @@ keras_name="MobileNet", scaling=(-1, 1), default_size=224), mobilenet_v2=dict( keras_name="MobileNetV2", scaling=(-1, 1), default_size=224), + mobilenet_v3_large=dict( + keras_name="MobileNetV3Large", no_amd=True, tf_min=2.4, scaling=(-1, 1), default_size=224), + mobilenet_v3_small=dict( + keras_name="MobileNetV3Small", no_amd=True, tf_min=2.4, scaling=(-1, 1), default_size=224), nasnet_large=dict( keras_name="NASNetLarge", scaling=(-1, 1), default_size=331, enforce_for_weights=True), nasnet_mobile=dict( @@ -208,16 +211,32 @@ def _get_input_shape(self): Input shape is calculated from the selected Encoder's input size, scaled to the user selected Input Scaling, rounded down to the nearest 16 pixels. + Notes + ----- + Some models (NasNet) require the input size to be of a certain dimension if loading + imagenet weights. In these instances resize inputs and raise warning message + Returns ------- tuple The shape tuple for the input size to the Phaze-A model """ - size = _MODEL_MAPPING[self.config["enc_architecture"]]["default_size"] - min_size = _MODEL_MAPPING[self.config["enc_architecture"]].get("min_size", 32) + arch = self.config["enc_architecture"] + enforce_size = _MODEL_MAPPING[arch].get("enforce_for_weights", False) + default_size = _MODEL_MAPPING[arch]["default_size"] scaling = self.config["enc_scaling"] / 100 - size = int(max(min_size, min(size, ((size * scaling) // 16) * 16))) - retval = (size, size, 3) + + min_size = _MODEL_MAPPING[arch].get("min_size", 32) + size = int(max(min_size, min(default_size, ((default_size * scaling) // 16) * 16))) + + if self.config["enc_load_weights"] and enforce_size and scaling != 1.0: + logger.warning("%s requires input size to be %spx when loading imagenet weights. " + "Adjusting input size from %spx to %spx", + arch, default_size, size, default_size) + retval = (default_size, default_size, 3) + else: + retval = (size, size, 3) + logger.debug("Encoder input set to: %s", retval) return retval @@ -238,7 +257,7 @@ def _validate_encoder_architecture(self): raise FaceswapError(f"'{arch}' is not compatible with the AMD backend. Choose one of " f"{valid}.") - tf_ver = float(".".join(tf.__version__.split(".")[:2])) # pylint:disable=no-member + tf_ver = get_tf_version() tf_min = model.get("tf_min", 2.0) if get_backend() != "amd" and tf_ver < tf_min: raise FaceswapError(f"{arch}' is not compatible with your version of Tensorflow. The " @@ -549,7 +568,10 @@ def _model_kwargs(self): return dict(mobilenet=dict(alpha=self._config["mobilenet_width"], depth_multiplier=self._config["mobilenet_depth"], dropout=self._config["mobilenet_dropout"]), - mobilenet_v2=dict(alpha=self._config["mobilenet_width"])) + mobilenet_v2=dict(alpha=self._config["mobilenet_width"]), + mobilenet_v3=dict(alpha=self._config["mobilenet_width"], + minimalist=self._config["mobilenet_minimalistic"], + include_preprocessing=False)) @property def _selected_model(self): @@ -559,22 +581,6 @@ def _selected_model(self): model["kwargs"] = self._model_kwargs.get(arch, {}) return model - @property - def _model_input_shape(self): - """ tuple: The required input shape for the encoder model. - - Notes - ----- - NasNet does not allow custom input sizes when loading pre-trained weights, so we need to - resize the input for this model - """ - default_size = self._selected_model.get("default_size") - if self._config["enc_load_weights"] and self._selected_model.get("enforce_for_weights"): - retval = (default_size, default_size, 3) - else: - retval = self._input_shape - return retval - def __call__(self): """ Create the Phaze-A Encoder Model. @@ -583,12 +589,9 @@ def __call__(self): :class:`keras.models.Model` The selected Encoder Model """ - input_ = Input(shape=self._model_input_shape) + input_ = Input(shape=self._input_shape) var_x = input_ - if self._input_shape != self._model_input_shape: - var_x = self._resize_inputs(var_x) - scaling = self._selected_model.get("scaling") if scaling: # Some models expect different scaling. @@ -611,28 +614,6 @@ def __call__(self): return KerasModel(input_, var_x, name="encoder") - def _resize_inputs(self, inputs): - """ Some models (specifically NasNet) need a specific input size when loading trained - weights. This is slightly hacky, but arbitrarily resize the input for these instances. - - Parameters - ---------- - inputs: tensor - The input tensor to be resized - - Returns - ------- - tensor - The resized input tensor - """ - input_size = self._input_shape[0] - new_size = self._model_input_shape[0] - logger.debug("Resizing input for encoder: '%s' from %s to %s due to trained weights usage", - self._config["enc_architecture"], input_size, new_size) - scale = new_size / input_size - interp = "bilinear" if scale > 1 else "nearest" - return K.resize_images(size=scale, interpolation=interp)(inputs) - def _get_encoder_model(self): """ Return the model defined by the selected architecture. @@ -648,7 +629,7 @@ def _get_encoder_model(self): """ if self._selected_model.get("keras_name"): kwargs = self._selected_model["kwargs"] - kwargs["input_shape"] = self._model_input_shape + kwargs["input_shape"] = self._input_shape kwargs["include_top"] = False kwargs["weights"] = "imagenet" if self._config["enc_load_weights"] else None retval = getattr(kapp, self._selected_model["keras_name"])(**kwargs) diff --git a/plugins/train/model/phaze_a_defaults.py b/plugins/train/model/phaze_a_defaults.py index 15d33123b8..69273c471a 100644 --- a/plugins/train/model/phaze_a_defaults.py +++ b/plugins/train/model/phaze_a_defaults.py @@ -52,7 +52,8 @@ if get_backend() != "amd": _ENCODERS.extend(["efficientnet_b0", "efficientnet_b1", "efficientnet_b2", "efficientnet_b3", "efficientnet_b4", "efficientnet_b5", "efficientnet_b6", "efficientnet_b7", - "resnet50_v2", "resnet101", "resnet101_v2", "resnet152", "resnet152_v2"]) + "mobilenet_v3_large", "mobilenet_v3_small", "resnet50_v2", "resnet101", + "resnet101_v2", "resnet152", "resnet152_v2"]) _ENCODERS = sorted(_ENCODERS) @@ -157,6 +158,9 @@ "\n\tmobilenet_v2: (32px - 224px). Additional MobileNet parameters can be set with " "the 'mobilenet' options. Ref: MobileNetV2: Inverted Residuals and Linear " "Bottlenecks (2018): https://arxiv.org/abs/1801.04381" + "\n\tmobilenet_v3: (32px - 224px). Additional MobileNet parameters can be set with " + "the 'mobilenet' options. Ref: Searching for MobileNetV3 (2019): " + "https://arxiv.org/pdf/1905.02244.pdf" "\n\tnasnet: (32px - 331px (large) or 224px (mobile)). Ref: Learning Transferable " "Architectures for Scalable Image Recognition (2017): " "https://arxiv.org/abs/1707.07012" @@ -569,9 +573,10 @@ "each layer. Values greater than 1.0 proportionally increase the number of filters " "within each layer. 1.0 is the default number of layers used within the paper.\n" "NB: This option is ignored for any non-mobilenet encoders.\n" - "NB: If loading ImageNet weights, then for mobilenet v1 only values of '0.25', " - "'0.5', '0.75' or '1.0 can be selected. For mobilenet v2 only values of '0.35', " - "'0.50', '0.75', '1.0', '1.3' or '1.4' can be selected", + "NB: If loading ImageNet weights, then for MobilenetV1 only values of '0.25', " + "'0.5', '0.75' or '1.0 can be selected. For MobilenetV2 only values of '0.35', " + "'0.50', '0.75', '1.0', '1.3' or '1.4' can be selected. For mobilenet_v3 only values " + "of '0.75' or '1.0' can be selected", datatype=float, min_max=(0.1, 2.0), rounding=2, @@ -579,10 +584,10 @@ fixed=True), mobilenet_depth=dict( default=1, - info="The depth multiplier for mobilenet v1 encoder. This is the depth multiplier " + info="The depth multiplier for MobilenetV1 encoder. This is the depth multiplier " "for depthwise convolution (known as the resolution multiplier within the original " "paper).\n" - "NB: This option is only used for mobilenet v1 and is ignored for all other " + "NB: This option is only used for MobilenetV1 and is ignored for all other " "encoders.\n" "NB: If loading ImageNet weights, this must be set to 1.", datatype=int, @@ -592,13 +597,25 @@ fixed=True), mobilenet_dropout=dict( default=0.001, - info="The dropout rate for for mobilenet v1 encoder.\n" - "NB: This option is only used for mobilenet v1 and is ignored for all other " - "encoders.\n" - "NB: If loading ImageNet weights, this must be set to 1.0.", + info="The dropout rate for MobilenetV1 encoder.\n" + "NB: This option is only used for MobilenetV1 and is ignored for all other " + "encoders.", datatype=float, - min_max=(0.1, 2.0), - rounding=2, + min_max=(0.001, 2.0), + rounding=3, + group="mobilenet encoder configuration", + fixed=True), + mobilenet_minimalistic=dict( + default=False, + info="Use a minimilist version of MobilenetV3.\n" + "In addition to large and small models MobilenetV3 also contains so-called " + "minimalistic models, these models have the same per-layer dimensions characteristic " + "as MobilenetV3 however, they don't utilize any of the advanced blocks " + "(squeeze-and-excite units, hard-swish, and 5x5 convolutions). While these models " + "are less efficient on CPU, they are much more performant on GPU/DSP.\n" + "NB: This option is only used for MobilenetV3 and is ignored for all other " + "encoders.\n", + datatype=bool, group="mobilenet encoder configuration", fixed=True), ) From 198c682ae20f7f73ece21039cea3629cdbd66c80 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 4 May 2022 18:18:20 +0100 Subject: [PATCH 543/981] Phaze-A: Add EfficientNetV2 Encoder --- plugins/train/model/phaze_a.py | 16 ++++++++++++++++ plugins/train/model/phaze_a_defaults.py | 13 +++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 6fe3231795..5e7c6671c7 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -53,6 +53,20 @@ keras_name="EfficientNetB6", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=528), efficientnet_b7=dict( keras_name="EfficientNetB7", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=600), + efficientnet_v2_b0=dict( + keras_name="EfficientNetV2B0", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=224), + efficientnet_v2_b1=dict( + keras_name="EfficientNetV2B1", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=240), + efficientnet_v2_b2=dict( + keras_name="EfficientNetV2B2", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=260), + efficientnet_v2_b3=dict( + keras_name="EfficientNetV2B3", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=300), + efficientnet_v2_s=dict( + keras_name="EfficientNetV2S", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=384), + efficientnet_v2_m=dict( + keras_name="EfficientNetV2M", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=480), + efficientnet_v2_l=dict( + keras_name="EfficientNetV2L", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=480), inception_resnet_v2=dict( keras_name="InceptionResNetV2", scaling=(-1, 1), min_size=75, default_size=299), inception_v3=dict( @@ -579,6 +593,8 @@ def _selected_model(self): arch = self._config["enc_architecture"] model = _MODEL_MAPPING.get(arch) model["kwargs"] = self._model_kwargs.get(arch, {}) + if arch.startswith("efficientnet_v2"): + model["kwargs"]["include_preprocessing"] = False return model def __call__(self): diff --git a/plugins/train/model/phaze_a_defaults.py b/plugins/train/model/phaze_a_defaults.py index 69273c471a..888c7a1a6a 100644 --- a/plugins/train/model/phaze_a_defaults.py +++ b/plugins/train/model/phaze_a_defaults.py @@ -52,8 +52,10 @@ if get_backend() != "amd": _ENCODERS.extend(["efficientnet_b0", "efficientnet_b1", "efficientnet_b2", "efficientnet_b3", "efficientnet_b4", "efficientnet_b5", "efficientnet_b6", "efficientnet_b7", - "mobilenet_v3_large", "mobilenet_v3_small", "resnet50_v2", "resnet101", - "resnet101_v2", "resnet152", "resnet152_v2"]) + "efficientnet_v2_b0", "efficientnet_v2_b1", "efficientnet_v2_b2", + "efficientnet_v2_b3", "efficientnet_v2_l", "efficientnet_v2_m", + "efficientnet_v2_s", "mobilenet_v3_large", "mobilenet_v3_small", + "resnet50_v2", "resnet101", "resnet101_v2", "resnet152", "resnet152_v2"]) _ENCODERS = sorted(_ENCODERS) @@ -143,6 +145,13 @@ "each variant is: b0: 224px, b1: 240px, b2: 260px, b3: 300px, b4: 380px, b5: 456px, " "b6: 528px, b7 600px. Ref: Rethinking Model Scaling for Convolutional Neural " "Networks (2020): https://arxiv.org/abs/1905.11946" + "\n\tefficientnet_v2: [Tensorflow 2.8+ only] EfficientNetV2 is the follow up to " + "efficientnet. It has numerous variants (B0 - B3 and Small, Medium and Large) that " + "increases the model width, depth and dimensional space at each step. The minimum " + "input resolution is 32px for all variants. The maximum input resolution for each " + "variant is: b0: 224px, b1: 240px, b2: 260px, b3: 300px, s: 384px, m: 480px, l: " + "480px. Ref: EfficientNetV2: Smaller Models and Faster Training (2021): " + "https://arxiv.org/abs/2104.00298" "\n\tfs_original: (32px - 160px). A configurable variant of the original facewap " "encoder. ImageNet weights cannot be loaded for this model. Additional parameters " "can be configured with the 'fs_enc' options. A version of this encoder is used in " From feac8a010835ed045521432bbcd5b21e2d73d5f5 Mon Sep 17 00:00:00 2001 From: Dhyey Patel Date: Wed, 4 May 2022 15:07:31 -0700 Subject: [PATCH 544/981] trainer: add support for non-interactive jobs (#1193) Add support for training in non-interactive shell environment such as Sun Grid Engine, Univa Grid Engine and others. Tested on Univa Grid Engine. Reference: https://stackoverflow.com/questions/967369/python-find-out-if-running-in-shell-or-not-e-g-sun-grid-engine-queue. --- lib/keypress.py | 10 +++++----- scripts/train.py | 5 +++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/keypress.py b/lib/keypress.py index 726935cbfb..628a43f856 100644 --- a/lib/keypress.py +++ b/lib/keypress.py @@ -34,7 +34,7 @@ class KBHit: """ Creates a KBHit object that you can call to do various keyboard things. """ def __init__(self, is_gui=False): self.is_gui = is_gui - if os.name == "nt" or self.is_gui: + if os.name == "nt" or self.is_gui or not sys.stdout.isatty(): pass else: # Save the terminal settings @@ -51,7 +51,7 @@ def __init__(self, is_gui=False): def set_normal_term(self): """ Resets to normal terminal. On Windows this is a no-op. """ - if os.name == "nt" or self.is_gui: + if os.name == "nt" or self.is_gui or not sys.stdout.isatty(): pass else: termios.tcsetattr(self.file_desc, termios.TCSAFLUSH, self.old_term) @@ -59,7 +59,7 @@ def set_normal_term(self): def getch(self): """ Returns a keyboard character after kbhit() has been called. Should not be called in the same program as getarrow(). """ - if self.is_gui and os.name != "nt": + if self.is_gui and os.name != "nt" or not sys.stdout.isatty(): return None if os.name == "nt": return msvcrt.getch().decode("utf-8") @@ -73,7 +73,7 @@ def getarrow(self): 3 : left Should not be called in the same program as getch(). """ - if self.is_gui: + if self.is_gui or not sys.stdout.isatty(): return None if os.name == "nt": msvcrt.getch() # skip 0xE0 @@ -87,7 +87,7 @@ def getarrow(self): def kbhit(self): """ Returns True if keyboard character was hit, False otherwise. """ - if self.is_gui and os.name != "nt": + if self.is_gui and os.name != "nt" or not sys.stdout.isatty(): return None if os.name == "nt": return msvcrt.kbhit() diff --git a/scripts/train.py b/scripts/train.py index 1747f07d37..70d5f05cd5 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -360,9 +360,10 @@ def _monitor(self, thread): logger.info(" Starting") if self._args.preview: logger.info(" Using live preview") - logger.info(" Press '%s' to save and quit", + if sys.stdout.isatty(): + logger.info(" Press '%s' to save and quit", "Stop" if self._args.redirect_gui or self._args.colab else "ENTER") - if not self._args.redirect_gui and not self._args.colab: + if not self._args.redirect_gui and not self._args.colab and sys.stdout.isatty(): logger.info(" Press 'S' to save model weights immediately") logger.info("===================================================") From 60f95bba1cd7fe48d3858d1aad0028cbbdebd409 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 6 May 2022 14:19:47 +0100 Subject: [PATCH 545/981] fix: PhazeA - Use correct name for EffNetV2 freezing --- plugins/train/model/phaze_a.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 5e7c6671c7..f3feafb6b6 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -206,17 +206,20 @@ def _select_freeze_layers(self): list The selected layers for weight freezing """ + arch = self.config["enc_architecture"] layers = self.config["freeze_layers"] - keras_name = _MODEL_MAPPING[self.config["enc_architecture"]].get("keras_name") + # EfficientNetV2 is inconsistent with other model's naming conventions + keras_name = _MODEL_MAPPING[arch].get("keras_name").replace("EfficientNetV2", + "EfficientNetV2-") if "keras_encoder" not in self.config["freeze_layers"]: retval = layers elif keras_name: retval = [layer.replace("keras_encoder", keras_name.lower()) for layer in layers] - logger.debug("Substituting 'keras_encoder' for '%s'", self.config["enc_architecture"]) + logger.debug("Substituting 'keras_encoder' for '%s'", arch) else: retval = [layer for layer in layers if layer != "keras_encoder"] - logger.debug("Removing 'keras_encoder' for '%s'", self.config["enc_architecture"]) + logger.debug("Removing 'keras_encoder' for '%s'", arch) return retval def _get_input_shape(self): From a046248389b96a5a8dcd7fcf19b9668031f83f51 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 6 May 2022 17:15:18 +0100 Subject: [PATCH 546/981] BugFix - lib.keypress --- lib/keypress.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/keypress.py b/lib/keypress.py index 628a43f856..6922b9c426 100644 --- a/lib/keypress.py +++ b/lib/keypress.py @@ -17,6 +17,7 @@ """ import os +import sys # Windows if os.name == "nt": @@ -24,7 +25,6 @@ # Posix (Linux, OS X) else: - import sys import termios import atexit from select import select From eaf7b4c1563f5df25c3222abc91abd0d5d25e4c1 Mon Sep 17 00:00:00 2001 From: geewiz94 <94993977+geewiz94@users.noreply.github.com> Date: Sat, 7 May 2022 13:12:51 +0200 Subject: [PATCH 547/981] Update dependencies --- conda-environment-apple-silicon.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/conda-environment-apple-silicon.yml b/conda-environment-apple-silicon.yml index aca2972274..8cc3653a3b 100644 --- a/conda-environment-apple-silicon.yml +++ b/conda-environment-apple-silicon.yml @@ -1,24 +1,24 @@ -name: faceswap +name: faceswap2 channels: - conda-forge - apple dependencies: - python>=3.8,<3.10 - pip - - tensorflow-deps==2.6.0 + - tensorflow-deps==2.8.0 - tk - libblas - pip: - tensorflow-macos - tensorflow-metal - - tqdm>=4.62 + - tqdm>=4.64 - psutil>=5.8.0 - - numpy>=1.18.0,<1.22.0 - - opencv-python>=4.5.3.0 + - numpy>=1.18.0 + - opencv-python>=4.5.5.0 - pillow>=8.3.1 - - scikit-learn>=0.24.2 - - fastcluster>=1.1.26 + - scikit-learn>=1.0.2 + - fastcluster>=1.2.4 - matplotlib>=3.2.0,<3.3.0 - imageio>=2.9.0 - - imageio-ffmpeg>=0.4.5 + - imageio-ffmpeg>=0.4.7 - ffmpy==0.2.3 \ No newline at end of file From faef5a683e2b848baaf05b40e446a4156ace8bcb Mon Sep 17 00:00:00 2001 From: geewiz94 <94993977+geewiz94@users.noreply.github.com> Date: Sat, 7 May 2022 13:50:10 +0200 Subject: [PATCH 548/981] Cleanup --- conda-environment-apple-silicon.yml | 2 +- lib/gpu_stats.py | 10 +++++++--- lib/model/normalization/normalization_common.py | 1 - 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/conda-environment-apple-silicon.yml b/conda-environment-apple-silicon.yml index 8cc3653a3b..0bd9818ac4 100644 --- a/conda-environment-apple-silicon.yml +++ b/conda-environment-apple-silicon.yml @@ -1,4 +1,4 @@ -name: faceswap2 +name: faceswap channels: - conda-forge - apple diff --git a/lib/gpu_stats.py b/lib/gpu_stats.py index 001899995d..363a8a5b9e 100644 --- a/lib/gpu_stats.py +++ b/lib/gpu_stats.py @@ -163,7 +163,7 @@ def _initialize(self, log=False): loglevel = "INFO" if self._logger is None else self._logger.getEffectiveLevel() self._plaid = plaidlib(log_level=loglevel, log=log) elif IS_MACOS: - self._log("debug", "macOS Detected.") + self._log("debug", "macOS Detected. Using Metal") try: metal.init() except RuntimeError: @@ -314,7 +314,9 @@ def _get_vram(self): elif self._is_plaidml: vram = self._plaid.vram elif IS_MACOS: - vram = [metal.get_memory_info(i) / (1024 * 1024) for i in range(self._device_count)] + vram = [metal.get_memory_info(i) / + (1024 * 1024) + for i in range(self._device_count)] else: vram = [pynvml.nvmlDeviceGetMemoryInfo(handle).total / (1024 * 1024) @@ -342,7 +344,9 @@ def _get_free_vram(self): if self._is_plaidml: vram = self._plaid.vram elif IS_MACOS: - vram = [metal.get_memory_info(i) / (1024 * 1024) for i in range(self._device_count)] + vram = [metal.get_memory_info(i) / + (1024 * 1024) + for i in range(self._device_count)] else: vram = [pynvml.nvmlDeviceGetMemoryInfo(handle).free / (1024 * 1024) for handle in self._handles] diff --git a/lib/model/normalization/normalization_common.py b/lib/model/normalization/normalization_common.py index bdeb71c4d9..22e8419d5b 100644 --- a/lib/model/normalization/normalization_common.py +++ b/lib/model/normalization/normalization_common.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 """ Normalization methods for faceswap.py common to both Plaid and Tensorflow Backends """ -from ast import Import import sys import inspect From a0d38c2d687b2826fac13150a2e344065378effe Mon Sep 17 00:00:00 2001 From: geewiz94 <94993977+geewiz94@users.noreply.github.com> Date: Sat, 7 May 2022 13:51:01 +0200 Subject: [PATCH 549/981] Add install guide --- INSTALL.md | 39 ++++++++++++++++++++++++++++++++++++++- README.md | 16 ---------------- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 02fe3068cf..074ffdc7c8 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -20,6 +20,7 @@ - [Running faceswap](#running-faceswap) - [Create a desktop shortcut](#create-a-desktop-shortcut) - [Updating faceswap](#updating-faceswap) +- [macOS (Apple Silicon) Install Guide](#macos-apple-silicon-install-guide) - [General Install Guide](#general-install-guide) - [Installing dependencies](#installing-dependencies) - [Git](#git-1) @@ -54,7 +55,7 @@ The type of computations that the process does are well suited for graphics card - **Linux** Most Ubuntu/Debian or CentOS based Linux distributions will work. - **macOS** - GPU support on macOS is limited due to lack of drivers/libraries from Nvidia. + WIP port for GPU-accelerated, native Apple Silicon processing. - All operating systems must be 64-bit for Tensorflow to run. Alternatively, there is a docker image that is based on Debian. @@ -145,6 +146,42 @@ It's good to keep faceswap up to date as new features are added and bugs are fix - Enter the following `git pull --all` - Once the latest version has downloaded, make sure your dependencies are up to date. There is a script to help with this: `python update_deps.py` +# macOS (Apple Silicon) Install Guide + +## Prerequisites + +### OS +macOS 12.0+ + +### XCode Tools +`xcode-select --install` + +### XQuartz +Download and install from: https://www.xquartz.org/ + +### Conda +Download and install the latest Conda env from: https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-MacOSX-arm64.sh +```sh +$ chmod +x ~/Downloads/Miniforge3-MacOSX-arm64.sh +$ sh ~/Downloads/Miniforge3-MacOSX-arm64.sh +$ source ~/miniforge3/bin/activate +``` +## Setup + +### faceswap +- Get the faceswap repo by typing: `git clone --depth 1 https://github.com/deepfakes/faceswap.git` +- Enter the faceswap folder: `cd faceswap` + +#### Easy install +```sh +$ conda deactivate +$ conda env create -f conda-environment-apple-silicon.yml +$ conda activate faceswap +``` +- Enter the command `python faceswap.py gui` and follow the prompts: +- Choose '4' for Apple Silicon +- If you have issues/errors follow the Manual install steps below. + # General Install Guide ## Installing dependencies ### Git diff --git a/README.md b/README.md index 3aaf4c8cf4..c4cf43bbda 100755 --- a/README.md +++ b/README.md @@ -17,25 +17,9 @@ [![Build Status](https://travis-ci.org/deepfakes/faceswap.svg?branch=master)](https://travis-ci.org/deepfakes/faceswap) [![Documentation Status](https://readthedocs.org/projects/faceswap/badge/?version=latest)](https://faceswap.readthedocs.io/en/latest/?badge=latest) -============================ -# Apple Silicon port -## WIP port for GPU-accelerated, native Apple Silicon processing. - -```sh -$ conda env create -f conda-environment-apple-silicon.yml -``` - -Ensure that the backend is set to "apple" and **not** to "cpu". -You may have to modify the config file in `./config/.faceswap`. - -============================ - - Make sure you check out [INSTALL.md](INSTALL.md) before getting started. - [deepfakes_faceswap](#deepfakes_faceswap) -- [Apple Silicon port](#apple-silicon-port) - - [WIP port for GPU-accelerated, native Apple Silicon processing.](#wip-port-for-gpu-accelerated-native-apple-silicon-processing) - [Manifesto](#manifesto) - [FaceSwap has ethical uses.](#faceswap-has-ethical-uses) - [How To setup and run the project](#how-to-setup-and-run-the-project) From 66ad0f8cfaed09f52046ab910b0697e2aa7f8d8c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 7 May 2022 13:18:11 +0100 Subject: [PATCH 550/981] Bugfix: Phaze-A - Fix NoneType error --- plugins/train/model/phaze_a.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index f3feafb6b6..2bd23d2cc5 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -209,8 +209,8 @@ def _select_freeze_layers(self): arch = self.config["enc_architecture"] layers = self.config["freeze_layers"] # EfficientNetV2 is inconsistent with other model's naming conventions - keras_name = _MODEL_MAPPING[arch].get("keras_name").replace("EfficientNetV2", - "EfficientNetV2-") + keras_name = _MODEL_MAPPING[arch].get("keras_name", "").replace("EfficientNetV2", + "EfficientNetV2-") if "keras_encoder" not in self.config["freeze_layers"]: retval = layers From d9339658bc701bbef49d36e1db1bdd50dea34397 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 7 May 2022 18:36:23 +0100 Subject: [PATCH 551/981] bisenet-fp - Add face-centered trained weights --- plugins/extract/mask/bisenet_fp.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/plugins/extract/mask/bisenet_fp.py b/plugins/extract/mask/bisenet_fp.py index 448a401ac4..8136c3078c 100644 --- a/plugins/extract/mask/bisenet_fp.py +++ b/plugins/extract/mask/bisenet_fp.py @@ -27,10 +27,10 @@ class Mask(Masker): """ Neural network to process face image into a segmentation mask of the face """ def __init__(self, **kwargs): - self._is_faceswap = self._check_weights_selection(kwargs.get("configfile")) + self._is_faceswap, version = self._check_weights_selection(kwargs.get("configfile")) git_model_id = 14 - model_filename = f"bisnet_face_parsing_v{'2' if self._is_faceswap else '1'}.h5" + model_filename = f"bisnet_face_parsing_v{version}.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.name = "BiSeNet - Face Parsing" @@ -59,13 +59,16 @@ def _check_weights_selection(self, configfile): Returns ------- - bool - ``True`` if `faceswap` trained weights have been selected. ``False`` if `original` - weights have been selected + tuple (bool, int) + First position is ``True`` if `faceswap` trained weights have been selected. + ``False`` if `original` weights have been selected. + Second position is the version of the model to use (``1`` for non-faceswap, ``1`` if + faceswap and full-head model is required. ``3`` if faceswap and full-face is required) """ config = _get_config(".".join(self.__module__.split(".")[-2:]), configfile=configfile) - retval = config.get("weights", "faceswap").lower() == "faceswap" - return retval + is_faceswap = config.get("weights", "faceswap").lower() == "faceswap" + version = 1 if not is_faceswap else 2 if config.get("include_hair") else 3 + return is_faceswap, version def _get_segment_indices(self): """ Obtain the segment indices to include within the face mask area based on user From adb5975c94f0fb10296ef7f0c8d087d03a436e3c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 8 May 2022 10:22:27 +0100 Subject: [PATCH 552/981] Graph popup - Always open in same position --- lib/gui/display_analysis.py | 72 +++++++------------------------------ 1 file changed, 12 insertions(+), 60 deletions(-) diff --git a/lib/gui/display_analysis.py b/lib/gui/display_analysis.py index 14fa29969a..a682227622 100644 --- a/lib/gui/display_analysis.py +++ b/lib/gui/display_analysis.py @@ -167,7 +167,7 @@ def _get_model_name(cls, model_dir, state_file): logger.debug("Getting model name") model_name = state_file.replace("_state.json", "") logger.debug("model_name: %s", model_name) - logs_dir = os.path.join(model_dir, "{}_logs".format(model_name)) + logs_dir = os.path.join(model_dir, f"{model_name}_logs") if not os.path.isdir(logs_dir): logger.warning("No logs folder found in folder: '%s'", logs_dir) return None @@ -200,7 +200,7 @@ def _set_session_summary(self, message): return self._summary = result self._thread = None - self.set_info("Session: {}".format(message)) + self.set_info(f"Session: {message}") self._stats.tree_insert_data(self._summary) @classmethod @@ -253,7 +253,7 @@ def _load_session(self, full_path=None): Session.initialize_session(model_dir, model_name, is_training=False) msg = full_path if len(msg) > 70: - msg = "...{}".format(msg[-70:]) + msg = f"...{msg[-70:]}" self._set_session_summary(msg) def _reset_session(self): @@ -313,10 +313,10 @@ def _add_buttons(self): dict The button names to button objects """ - buttons = dict() + buttons = {} for btntype in ("clear", "save", "load"): logger.debug("Adding button: '%s'", btntype) - cmd = getattr(self._parent, "_{}_session".format(btntype)) + cmd = getattr(self._parent, f"_{btntype}_session") btn = ttk.Button(self._parent.optsframe, image=get_images().icons[btntype], command=cmd) @@ -384,7 +384,6 @@ def __init__(self, parent, selected_id, helptext): self.__class__.__name__, parent, selected_id, helptext) super().__init__(parent) self._selected_id = selected_id - self._popup_positions = list() self._canvas = tk.Canvas(self, bd=0, highlightthickness=0) tree_frame = ttk.Frame(self._canvas) @@ -562,13 +561,13 @@ def _data_popup(self, data_points): 'wm', 'iconphoto', toplevel._w, get_images().icons["favicon"]) # pylint:disable=protected-access - position = self._data_popup_get_position() + + root = get_config().root + offset = (root.winfo_x() + 20, root.winfo_y() + 20) height = int(900 * scaling_factor) width = int(480 * scaling_factor) - toplevel.geometry("{}x{}+{}+{}".format(str(height), - str(width), - str(position[0]), - str(position[1]))) + toplevel.geometry(f"{height}x{width}+{offset[0]}+{offset[1]}") + toplevel.update() def _data_popup_title(self): @@ -584,53 +583,6 @@ def _data_popup_title(self): model_dir, model_name = os.path.split(Session.model_filename) title = "All Sessions" if selected_id != "Total": - title = "{} Model: Session #{}".format(model_name.title(), selected_id) + title = f"{model_name.title()} Model: Session #{selected_id}" logger.debug("Title: '%s'", title) - return "{} - {}".format(title, model_dir) - - def _data_popup_get_position(self): - """ Get the position of the next window to pop the summary graph to. - - Returns - ------- - list - The [x, y] co-ordinates that the pop up window should be placed at - """ - logger.debug("getting poup position") - init_pos = [120, 120] - pos = init_pos - while True: - if pos not in self._popup_positions: - self._popup_positions.append(pos) - break - pos = [item + 200 for item in pos] - init_pos, pos = self._data_popup_check_boundaries(init_pos, pos) - logger.debug("Position: %s", pos) - return pos - - def _data_popup_check_boundaries(self, initial_position, position): - """ Check that the popup remains within the screen boundaries. - - Parameters - ---------- - initial_position: list - The [x, y] position of the last displayed popup window - position: list - The requested [x, y] position for the new popup window - - Returns - ------- - tuple - The original initial_position and position, adjusted if the new window would go out of - bounds - """ - logger.debug("Checking poup boundaries: (initial_position: %s, position: %s)", - initial_position, position) - boundary_x = self.winfo_screenwidth() - 120 - boundary_y = self.winfo_screenheight() - 120 - if position[0] >= boundary_x or position[1] >= boundary_y: - initial_position = [initial_position[0] + 50, initial_position[1]] - position = initial_position - logger.debug("Returning poup boundaries: (initial_position: %s, position: %s)", - initial_position, position) - return initial_position, position + return f"{title} - {model_dir}" From 71c20252c2e747f692289cdefe80ad0d5a456ea6 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 8 May 2022 14:18:30 +0100 Subject: [PATCH 553/981] bugfix: Preview Tool, ensure all config items are written --- tools/preview/preview.py | 84 ++++++++++++++++++++++------------------ 1 file changed, 46 insertions(+), 38 deletions(-) diff --git a/tools/preview/preview.py b/tools/preview/preview.py index 527a21f41d..68859432bc 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -186,8 +186,8 @@ def __init__(self, arguments, sample_size, display, lock, trigger_patch): self._display = display self._lock = lock self._trigger_patch = trigger_patch - self._input_images = list() - self._predicted_images = list() + self._input_images = [] + self._predicted_images = [] self._images = Images(arguments) self._alignments = Alignments(arguments, @@ -250,8 +250,7 @@ def _get_filelist(self): """ logger.debug("Filtering file list to frames with faces") if self._images.is_video: - filelist = ["{}_{:06d}.png".format(os.path.splitext(self._images.input_images)[0], - frame_no) + filelist = [f"{os.path.splitext(self._images.input_images)[0]}_{frame_no:06d}.png" for frame_no in range(1, self._images.images_found + 1)] else: filelist = self._images.input_images @@ -288,10 +287,8 @@ def _get_indices(self): size = len(top_tail) retval = [top_tail[start:start + size // self._sample_size] for start in range(0, size, size // self._sample_size)] - logger.debug("Indices pools: %s", ["{}: (start: {}, end: {}, size: {})".format(idx, - min(pool), - max(pool), - len(pool)) + logger.debug("Indices pools: %s", [f"{idx}: (start: {min(pool)}, " + f"end: {max(pool)}, size: {len(pool)})" for idx, pool in enumerate(retval)]) return retval @@ -316,7 +313,7 @@ def _load_frames(self): * Sets :attr:`_display.source` to the input images and flags that the display should be \ updated """ - self._input_images = list() + self._input_images = [] for selection in self._random_choice: filename = os.path.basename(self._filelist[selection]) image = self._images.load_one_image(self._filelist[selection]) @@ -338,7 +335,7 @@ def _predict(self): model predict function and add the output to :attr:`predicted` """ with self._lock: - self._predicted_images = list() + self._predicted_images = [] for frame in self._input_images: self._predictor.in_queue.put(frame) idx = 0 @@ -562,7 +559,7 @@ def _patch_faces(self, queue_in, queue_out, sample_size): """ logger.trace("Patching faces") self._converter.process(queue_in, queue_out) - swapped = list() + swapped = [] idx = 0 while idx < sample_size: logger.trace("Patching image %s of %s", idx + 1, sample_size) @@ -605,7 +602,7 @@ def __init__(self, size, padding, tk_vars): self._tk_vars = tk_vars self._padding = padding - self._faces = dict() + self._faces = {} self._centering = None self._faces_source = None self._faces_dest = None @@ -613,9 +610,9 @@ def __init__(self, size, padding, tk_vars): # Set from Samples self.update_source = False - self.source = list() # Source images, filenames + detected faces + self.source = [] # Source images, filenames + detected faces # Set from Patch - self.destination = list() # Swapped + patched images + self.destination = [] # Swapped + patched images logger.trace("Initialized %s", self.__class__.__name__) @@ -717,19 +714,19 @@ def _crop_source_faces(self): """ Extract the source faces from the source frames, along with their filenames and the transformation matrix used to extract the faces. """ logger.debug("Updating source faces") - self._faces = dict() + self._faces = {} for image in self.source: detected_face = image["detected_faces"][0] src_img = image["image"] detected_face.load_aligned(src_img, size=self._size, centering=self._centering) matrix = detected_face.aligned.matrix self._faces.setdefault("filenames", - list()).append(os.path.splitext(image["filename"])[0]) - self._faces.setdefault("matrix", list()).append(matrix) - self._faces.setdefault("src", list()).append(transform_image(src_img, - matrix, - self._size, - self._padding)) + []).append(os.path.splitext(image["filename"])[0]) + self._faces.setdefault("matrix", []).append(matrix) + self._faces.setdefault("src", []).append(transform_image(src_img, + matrix, + self._size, + self._padding)) self.update_source = False logger.debug("Updated source faces") @@ -737,7 +734,7 @@ def _crop_destination_faces(self): """ Extract the swapped faces from the swapped frames using the source face destination matrices. """ logger.debug("Updating destination faces") - self._faces["dst"] = list() + self._faces["dst"] = [] destination = self.destination if self.destination else [np.ones_like(src["image"]) for src in self.source] for idx, image in enumerate(destination): @@ -810,7 +807,7 @@ class ConfigTools(): """ def __init__(self): self._config = Config(None) - self.tk_vars = dict() + self.tk_vars = {} self._config_dicts = self._get_config_dicts() # Holds currently saved config @property @@ -864,13 +861,13 @@ def _get_config_dicts(self): Each configuration section as keys, with the values as a dict of option: :class:`lib.gui.control_helper.ControlOption` pairs. """ logger.debug("Formatting Config for GUI") - config_dicts = dict() + config_dicts = {} for section in self._config.config.sections(): if section.startswith("writer."): continue for key, val in self._config.defaults[section].items(): if key == "helptext": - config_dicts.setdefault(section, dict())[key] = val + config_dicts.setdefault(section, {})[key] = val continue cp_option = ControlPanelOption(title=key, dtype=val["type"], @@ -882,8 +879,8 @@ def _get_config_dicts(self): rounding=val["rounding"], min_max=val["min_max"], helptext=val["helptext"]) - self.tk_vars.setdefault(section, dict())[key] = cp_option.tk_var - config_dicts.setdefault(section, dict())[key] = cp_option + self.tk_vars.setdefault(section, {})[key] = cp_option.tk_var + config_dicts.setdefault(section, {})[key] = cp_option logger.debug("Formatted Config for GUI: %s", config_dicts) return config_dicts @@ -933,6 +930,11 @@ def reset_config_to_default(self, section=None): def save_config(self, section=None): """ Save the configuration ``.ini`` file with the currently stored values. + Notes + ----- + We cannot edit the existing saved config as comments tend to get removed, so we create + a new config and populate that. + Parameters ---------- section: str, optional @@ -940,27 +942,33 @@ def save_config(self, section=None): Default: ``None`` """ logger.debug("Saving %s config", section) + new_config = ConfigParser(allow_no_value=True) - for config_section, items in self._config_dicts.items(): + + for config_section, items in self._config.defaults.items(): logger.debug("Adding section: '%s')", config_section) self._config.insert_config_section(config_section, items["helptext"], config=new_config) for item, options in items.items(): if item == "helptext": - continue + continue # helptext already written at top if ((section is not None and config_section != section) or config_section not in self.tk_vars): - new_opt = options.value # Keep saved item for other sections + # retain saved values that have not been updated + new_opt = self._config.get(config_section, item) logger.debug("Retaining option: (item: '%s', value: '%s')", item, new_opt) else: new_opt = self.tk_vars[config_section][item].get() logger.debug("Setting option: (item: '%s', value: '%s')", item, new_opt) + # Set config_dicts value to new saved value - options.set_initial_value(new_opt) - helptext = self._config.format_help(options.helptext, is_section=False) + self._config_dicts[config_section][item].set_initial_value(new_opt) + + helptext = self._config.format_help(options["helptext"], is_section=False) new_config.set(config_section, helptext) new_config.set(config_section, item, str(new_opt)) + self._config.config = new_config self._config.save_config() logger.info("Saved config: '%s'", self._config.configfile) @@ -1051,10 +1059,10 @@ def __init__(self, parent, available_masks, has_predicted_mask, selected_color, self.pack(side=tk.LEFT, anchor=tk.N, fill=tk.Y) self._options = ["color", "mask_type"] self._busy_tkvar = tk_vars["busy"] - self._tk_vars = dict() + self._tk_vars = {} d_locals = locals() - defaults = {opt: self._format_to_display(d_locals["selected_{}".format(opt)]) + defaults = {opt: self._format_to_display(d_locals[f"selected_{opt}"]) for opt in self._options} self._busy_indicator = self._build_frame(defaults, refresh_callback, @@ -1343,7 +1351,7 @@ def __init__(self, parent, config_tools, patch_callback): self.pack(side=tk.RIGHT, anchor=tk.N, fill=tk.BOTH, expand=True) self.config_tools = config_tools - self._tabs = dict() + self._tabs = {} self._build_tabs() self._build_sub_tabs() self._add_patch_callback(patch_callback) @@ -1452,13 +1460,13 @@ def _add_actions(self, parent, config_key): logger.debug("Adding button: '%s'", utl) img = get_images().icons[utl] if utl == "save": - text = _("Save {} config").format(title) + text = _(f"Save {title} config") action = parent.config_tools.save_config elif utl == "clear": - text = _("Reset {} config to default values").format(title) + text = _(f"Reset {title} config to default values") action = parent.config_tools.reset_config_to_default elif utl == "reload": - text = _("Reset {} config to saved values").format(title) + text = _(f"Reset {title} config to saved values") action = parent.config_tools.reset_config_to_saved btnutl = ttk.Button(btn_frame, From 8ab085fae0193bb507fd5ad582668d19d56bea3d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 8 May 2022 14:18:50 +0100 Subject: [PATCH 554/981] bugfix: gui - settings popup. Always reload config --- lib/gui/popup_configure.py | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 269eb07c6d..1119990026 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -23,10 +23,6 @@ _LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) _ = _LANG.gettext -_POPUP = [] -_CONFIG_FILES = [] -_CONFIGS = dict() - class _State(): """ Holds the existing config files and the current state of the popup window. """ @@ -34,7 +30,7 @@ def __init__(self): self._popup = None # The GUI Config cannot be scanned until GUI is launched, so this is populated # on the first call to load the settings - self._configs = dict() + self._configs = {} def open_popup(self, name=None): """ Launch the popup, ensuring only one instance is ever open @@ -45,8 +41,7 @@ def open_popup(self, name=None): The name of the configuration file. Used for selecting the correct section if required. Set to ``None`` if no initial section should be selected. Default: ``None`` """ - if not self._configs: - self._scan_for_configs() + self._scan_for_configs() logger.debug("name: %s", name) if self._popup is not None: logger.debug("Restoring existing popup") @@ -162,7 +157,7 @@ def _set_geometry(self): width = int(600 * scaling_factor) height = int(536 * scaling_factor) logger.debug("Pop up Geometry: %sx%s, %s+%s", width, height, pos_x, pos_y) - self.geometry("{}x{}+{}+{}".format(width, height, pos_x, pos_y)) + self.geometry(f"{width}x{height}+{pos_x}+{pos_y}") def _build_header(self): """ Build the main header text and separator. """ @@ -243,7 +238,7 @@ def _select_item(self, event): # pylint:disable=unused-argument selection = self._tree.focus() section = selection.split("|")[0] subsections = selection.split("|")[1:] if "|" in selection else [] - self._tk_vars["header"].set("{} Settings".format(section.title())) + self._tk_vars["header"].set(f"{section.title()} Settings") self._opts_frame.select_options(section, subsections) @@ -336,7 +331,7 @@ def _build_tree(self, parent, configurations, name): categories += [x for x in ordered if x not in categories] for cat in categories: - img = get_images().icons.get("settings_{}".format(cat), "") + img = get_images().icons.get(f"settings_{cat}", "") text = cat.replace("_", " ").title() text = " " + text if img else text is_open = tk.TRUE if name is None or name == cat else tk.FALSE @@ -372,14 +367,14 @@ def _process_sections(cls, tree, sections, category, is_open): if section[-1] == "global": # Global categories get escalated to parent continue sect = section[0] - section_id = "{}|{}".format(category, sect) + section_id = f"{category}|{sect}" if sect not in seen: seen.add(sect) text = sect.replace("_", " ").title() tree.insert(category, "end", section_id, text=text, open=is_open, tags="section") if len(section) == 2: opt = section[-1] - opt_id = "{}|{}".format(section_id, opt) + opt_id = f"{section_id}|{opt}" opt_text = opt.replace("_", " ").title() tree.insert(section_id, "end", opt_id, text=opt_text, open=is_open, tags="option") @@ -406,8 +401,8 @@ def __init__(self, top_level, parent, configurations, tree, theme): self._configs = configurations self._theme = theme self._tree = tree - self._vars = dict() - self._cache = dict() + self._vars = {} + self._cache = {} self._config_cpanel_dict = self._get_config() self._displayed_frame = None self._displayed_key = None @@ -436,14 +431,14 @@ def _get_config(self): objects """ logger.debug("Formatting Config for GUI") - retval = dict() + retval = {} for plugin, conf in self._configs.items(): for section in conf.config.sections(): conf.section = section category = section.split(".")[0] sect = section.split(".")[-1] # Elevate global to root - key = plugin if sect == "global" else "{}|{}|{}".format(plugin, category, sect) + key = plugin if sect == "global" else f"{plugin}|{category}|{sect}" retval[key] = dict(helptext=None, options=OrderedDict()) for option, params in conf.defaults[section].items(): @@ -584,7 +579,7 @@ def _create_links_page(self, key): foreground=self._theme["link_color"], cursor="hand2") lbl.pack(side=tk.TOP, fill=tk.X, padx=10, pady=(0, 5)) - bind = "{}|{}".format(key, link) + bind = f"{key}|{link}" lbl.bind("", lambda e, l=bind: self._link_callback(l)) return frame @@ -673,7 +668,7 @@ def save(self, page_only=False): # Get currently selected value key = category if section != "global": - key += "|{}".format(section.replace(".", "|")) + key += f"|{section.replace('.', '|')}" new_opt = self._config_cpanel_dict[key]["options"][item].get() logger.debug("Updating value to '%s' for %s", new_opt, ".".join([section, item])) From b2cd8eb867daeb55d057e4ecafb24f07f80d8220 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 9 May 2022 10:29:47 +0100 Subject: [PATCH 555/981] bugfix: Windows - Stop training when terminate button pressed --- lib/keypress.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/keypress.py b/lib/keypress.py index 6922b9c426..55c4450a5b 100644 --- a/lib/keypress.py +++ b/lib/keypress.py @@ -59,7 +59,7 @@ def set_normal_term(self): def getch(self): """ Returns a keyboard character after kbhit() has been called. Should not be called in the same program as getarrow(). """ - if self.is_gui and os.name != "nt" or not sys.stdout.isatty(): + if (self.is_gui or not sys.stdout.isatty()) and os.name != "nt": return None if os.name == "nt": return msvcrt.getch().decode("utf-8") @@ -73,7 +73,7 @@ def getarrow(self): 3 : left Should not be called in the same program as getch(). """ - if self.is_gui or not sys.stdout.isatty(): + if (self.is_gui or not sys.stdout.isatty()) and os.name != "nt": return None if os.name == "nt": msvcrt.getch() # skip 0xE0 @@ -87,7 +87,7 @@ def getarrow(self): def kbhit(self): """ Returns True if keyboard character was hit, False otherwise. """ - if self.is_gui and os.name != "nt" or not sys.stdout.isatty(): + if (self.is_gui or not sys.stdout.isatty()) and os.name != "nt": return None if os.name == "nt": return msvcrt.kbhit() From bdbbad4d310fb606b6f412aa81e9f57ccd994e97 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 11 May 2022 17:36:07 +0100 Subject: [PATCH 556/981] Refactor lib.gpu_stats (#1218) * inital gpu_stats refactor * Add dummy CPU Backend * Update Sphinx documentation --- docs/full/lib/gpu_stats.rst | 48 +++- docs/full/lib/plaidml_stats.rst | 7 - docs/sphinx_requirements.txt | 2 +- lib/cli/launcher.py | 14 +- lib/gpu_stats.py | 388 -------------------------------- lib/gpu_stats/__init__.py | 20 ++ lib/gpu_stats/_base.py | 255 +++++++++++++++++++++ lib/gpu_stats/amd.py | 362 +++++++++++++++++++++++++++++ lib/gpu_stats/cpu.py | 102 +++++++++ lib/gpu_stats/nvidia.py | 155 +++++++++++++ lib/gpu_stats/nvidia_apple.py | 137 +++++++++++ lib/plaidml_tools.py | 287 ----------------------- 12 files changed, 1083 insertions(+), 694 deletions(-) delete mode 100755 docs/full/lib/plaidml_stats.rst delete mode 100644 lib/gpu_stats.py create mode 100644 lib/gpu_stats/__init__.py create mode 100644 lib/gpu_stats/_base.py create mode 100644 lib/gpu_stats/amd.py create mode 100644 lib/gpu_stats/cpu.py create mode 100644 lib/gpu_stats/nvidia.py create mode 100644 lib/gpu_stats/nvidia_apple.py delete mode 100644 lib/plaidml_tools.py diff --git a/docs/full/lib/gpu_stats.rst b/docs/full/lib/gpu_stats.rst index 6535ee23a6..3fd97dc5fb 100755 --- a/docs/full/lib/gpu_stats.rst +++ b/docs/full/lib/gpu_stats.rst @@ -1,7 +1,49 @@ -gpu\_stats module -================= +****************** +gpu\_stats package +****************** -.. automodule:: lib.gpu_stats +The GPU Stats Package handles collection of information from connected GPUs + + +.. contents:: Contents + :local: + +gpu_stats._base module +---------------------- + +.. automodule:: lib.gpu_stats._base + :members: + :undoc-members: + :show-inheritance: + +gpu_stats.amd module +-------------------- + +.. automodule:: lib.gpu_stats.amd + :members: + :undoc-members: + :show-inheritance: + +gpu_stats.cpu module +-------------------- + +.. automodule:: lib.gpu_stats.cpu + :members: + :undoc-members: + :show-inheritance: + +gpu_stats.nvidia_apple module +----------------------------- + +.. automodule:: lib.gpu_stats.nvidia_apple + :members: + :undoc-members: + :show-inheritance: + +gpu_stats.nvidia module +----------------------- + +.. automodule:: lib.gpu_stats.nvidia :members: :undoc-members: :show-inheritance: diff --git a/docs/full/lib/plaidml_stats.rst b/docs/full/lib/plaidml_stats.rst deleted file mode 100755 index 72c19bd8c5..0000000000 --- a/docs/full/lib/plaidml_stats.rst +++ /dev/null @@ -1,7 +0,0 @@ -plaidml\_tools module -===================== - -.. automodule:: lib.plaidml_tools - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index 2c076eeb7f..3d5d21b2a2 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -4,7 +4,7 @@ tqdm==4.62 psutil==5.8.0 numpy==1.18.0 -opencv-python==4.5.3.0 +opencv-python>4.5.3.0,<4.5.4.0 pillow==8.3.1 scikit-learn==0.24.2 fastcluster==1.1.26 diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index b338c8736e..419c855f15 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -211,10 +211,11 @@ def _configure_backend(self, arguments): arguments: :class:`argparse.Namespace` The command line arguments passed to Faceswap. """ - if not hasattr(arguments, "exclude_gpus"): + if get_backend() == "cpu": # Cpu backends will not have this attribute logger.debug("Adding missing exclude gpus argument to namespace") setattr(arguments, "exclude_gpus", None) + return if arguments.exclude_gpus: if not all(idx.isdigit() for idx in arguments.exclude_gpus): @@ -224,7 +225,7 @@ def _configure_backend(self, arguments): arguments.exclude_gpus = [int(idx) for idx in arguments.exclude_gpus] set_exclude_devices(arguments.exclude_gpus) - if GPUStats().exclude_all_devices and get_backend() != "cpu": + if GPUStats().exclude_all_devices: msg = "Switching backend to CPU" if get_backend() == "amd": msg += (". Using Tensorflow for CPU operations.") @@ -234,11 +235,8 @@ def _configure_backend(self, arguments): logger.debug("Executing: %s. PID: %s", self._command, os.getpid()) - if get_backend() == "amd": - plaidml_found = self._setup_amd(arguments) - if not plaidml_found: - safe_shutdown(got_error=True) - sys.exit(1) + if get_backend() == "amd" and not self._setup_amd(arguments): + safe_shutdown(got_error=True) @classmethod def _setup_amd(cls, arguments): @@ -255,7 +253,7 @@ def _setup_amd(cls, arguments): except ImportError: logger.error("PlaidML not found. Run `pip install plaidml-keras` for AMD support") return False - from lib.plaidml_tools import setup_plaidml # pylint:disable=import-outside-toplevel + from lib.gpu_stats import setup_plaidml # pylint:disable=import-outside-toplevel setup_plaidml(arguments.loglevel, arguments.exclude_gpus) logger.debug("setup up for PlaidML") return True diff --git a/lib/gpu_stats.py b/lib/gpu_stats.py deleted file mode 100644 index 3509893a5a..0000000000 --- a/lib/gpu_stats.py +++ /dev/null @@ -1,388 +0,0 @@ -#!/usr/bin python3 -""" Collects and returns Information on available GPUs. - -The information returned from this module provides information for both Nvidia and AMD GPUs. -However, the information available for Nvidia is far more thorough than what is available for -AMD, where we need to plug into plaidML to pull stats. The quality of this data will vary -depending on the OS' particular OpenCL implementation. -""" - -import logging -import os -import platform - -from lib.utils import get_backend - -if platform.system() == 'Darwin': - import pynvx # pylint: disable=import-error - IS_MACOS = True -else: - import pynvml - IS_MACOS = False - -# Limited PlaidML/AMD Stats -try: - from lib.plaidml_tools import PlaidMLStats as plaidlib # pylint:disable=ungrouped-imports -except ImportError: - plaidlib = None - - -_EXCLUDE_DEVICES = [] - - -def set_exclude_devices(devices): - """ Add any explicitly selected GPU devices to the global list of devices to be excluded - from use by Faceswap. - - Parameters - ---------- - devices: list - list of indices corresponding to the GPU devices connected to the computer - """ - logger = logging.getLogger(__name__) - logger.debug("Excluding GPU indicies: %s", devices) - if not devices: - return - _EXCLUDE_DEVICES.extend(devices) - - -class GPUStats(): - """ Holds information and statistics about the GPU(s) available on the currently - running system. - - Parameters - ---------- - log: bool, optional - Whether the class should output information to the logger. There may be occasions where the - logger has not yet been set up when this class is queried. Attempting to log in these - instances will raise an error. If GPU stats are being queried prior to the logger being - available then this parameter should be set to ``False``. Otherwise set to ``True``. - Default: ``True`` - """ - def __init__(self, log=True): - # Logger is held internally, as we don't want to log when obtaining system stats on crash - self._logger = logging.getLogger(__name__) if log else None - self._log("debug", "Initializing {}".format(self.__class__.__name__)) - - self._plaid = None - self._initialized = False - self._device_count = 0 - self._active_devices = list() - self._handles = list() - self._driver = None - self._devices = list() - self._vram = None - - self._initialize(log) - - self._driver = self._get_driver() - self._devices = self._get_devices() - self._vram = self._get_vram() - if not self._active_devices: - self._log("warning", "No GPU detected. Switching to CPU mode") - return - - self._shutdown() - self._log("debug", "Initialized {}".format(self.__class__.__name__)) - - @property - def device_count(self): - """int: The number of GPU devices discovered on the system. """ - return self._device_count - - @property - def cli_devices(self): - """ list: List of available devices for use in faceswap's command line arguments """ - return ["{}: {}".format(idx, device) for idx, device in enumerate(self._devices)] - - @property - def exclude_all_devices(self): - """ bool: ``True`` if all GPU devices have been explicitly disabled otherwise ``False`` """ - return all(idx in _EXCLUDE_DEVICES for idx in range(len(self._devices))) - - @property - def _is_plaidml(self): - """ bool: ``True`` if the backend is plaidML otherwise ``False``. """ - return self._plaid is not None - - @property - def sys_info(self): - """ dict: GPU Stats that are required for system information logging. - - The dictionary contains the following data: - - **vram** (`list`): the total amount of VRAM in Megabytes for each GPU as pertaining to - :attr:`_handles` - - **driver** (`str`): The GPU driver version that is installed on the OS - - **devices** (`list`): The device name of each GPU on the system as pertaining - to :attr:`_handles` - - **devices_active** (`list`): The device name of each active GPU on the system as - pertaining to :attr:`_handles` - """ - return dict(vram=self._vram, - driver=self._driver, - devices=self._devices, - devices_active=self._active_devices) - - def _log(self, level, message): - """ If the class has been initialized with :attr:`log` as `True` then log the message - otherwise skip logging. - - Parameters - ---------- - level: str - The log level to log at - message: str - The message to log - """ - if self._logger is None: - return - logger = getattr(self._logger, level.lower()) - logger(message) - - def _initialize(self, log=False): - """ Initialize the library that will be returning stats for the system's GPU(s). - For Nvidia (on Linux and Windows) the library is `pynvml`. For Nvidia (on macOS) the - library is `pynvx`. For AMD `plaidML` is used. - - Parameters - ---------- - log: bool, optional - Whether the class should output information to the logger. There may be occasions where - the logger has not yet been set up when this class is queried. Attempting to log in - these instances will raise an error. If GPU stats are being queried prior to the - logger being available then this parameter should be set to ``False``. Otherwise set - to ``True``. Default: ``False`` - """ - if not self._initialized: - if get_backend() == "amd": - self._log("debug", "AMD Detected. Using plaidMLStats") - loglevel = "INFO" if self._logger is None else self._logger.getEffectiveLevel() - self._plaid = plaidlib(log_level=loglevel, log=log) - elif IS_MACOS: - self._log("debug", "macOS Detected. Using pynvx") - try: - pynvx.cudaInit() - except RuntimeError: - self._initialized = True - return - else: - try: - self._log("debug", "OS is not macOS. Trying pynvml") - pynvml.nvmlInit() - except (pynvml.NVMLError_LibraryNotFound, # pylint: disable=no-member - pynvml.NVMLError_DriverNotLoaded, # pylint: disable=no-member - pynvml.NVMLError_NoPermission) as err: # pylint: disable=no-member - if plaidlib is not None: - self._log("debug", "pynvml errored. Trying plaidML") - self._plaid = plaidlib(log=log) - else: - msg = ("There was an error reading from the Nvidia Machine Learning " - "Library. Either you do not have an Nvidia GPU (in which case " - "this warning can be ignored) or the most likely cause is " - "incorrectly installed drivers. If this is the case, Please remove " - "and reinstall your Nvidia drivers before reporting." - "Original Error: {}".format(str(err))) - self._log("warning", msg) - self._initialized = True - return - except Exception as err: # pylint: disable=broad-except - msg = ("An unhandled exception occured loading pynvml. " - "Original error: {}".format(str(err))) - if self._logger: - self._logger.error(msg) - else: - print(msg) - self._initialized = True - return - self._initialized = True - self._get_device_count() - self._get_active_devices() - self._get_handles() - - def _shutdown(self): - """ Shutdown pynvml if it was the library used for obtaining stats and set - :attr:`_initialized` back to ``False``. """ - if self._initialized: - self._handles = list() - if not IS_MACOS and not self._is_plaidml: - pynvml.nvmlShutdown() - self._initialized = False - - def _get_device_count(self): - """ Detect the number of GPUs attached to the system and allocate to - :attr:`_device_count`. """ - if self._is_plaidml: - self._device_count = self._plaid.device_count - elif IS_MACOS: - self._device_count = pynvx.cudaDeviceGetCount(ignore=True) - else: - try: - self._device_count = pynvml.nvmlDeviceGetCount() - except pynvml.NVMLError: - self._device_count = 0 - self._log("debug", "GPU Device count: {}".format(self._device_count)) - - def _get_active_devices(self): - """ Obtain the indices of active GPUs (those that have not been explicitly excluded by - CUDA_VISIBLE_DEVICES, plaidML or command line arguments) and allocate to - :attr:`_active_devices`. """ - if self._is_plaidml: - self._active_devices = self._plaid.active_devices - else: - if self._device_count == 0: - self._active_devices = [] - else: - devices = [idx for idx in range(self._device_count) if idx not in _EXCLUDE_DEVICES] - env_devices = os.environ.get("CUDA_VISIBLE_DEVICES", "") - if env_devices: - env_devices = [int(i) for i in env_devices.split(",")] - devices = [idx for idx in devices if idx in env_devices] - self._active_devices = devices - self._log("debug", "Active GPU Devices: {}".format(self._active_devices)) - - def _get_handles(self): - """ Obtain the internal handle identifiers for the system GPUs and allocate to - :attr:`_handles`. """ - if self._is_plaidml: - self._handles = self._plaid.devices - elif IS_MACOS: - self._handles = pynvx.cudaDeviceGetHandles(ignore=True) - else: - self._handles = [pynvml.nvmlDeviceGetHandleByIndex(i) - for i in range(self._device_count)] - self._log("debug", "GPU Handles found: {}".format(len(self._handles))) - - def _get_driver(self): - """ Obtain and return the installed driver version for the system's GPUs. - - Returns - ------- - str - The currently installed GPU driver version - """ - if self._is_plaidml: - driver = self._plaid.drivers - elif IS_MACOS: - driver = pynvx.cudaSystemGetDriverVersion(ignore=True) - else: - try: - driver = pynvml.nvmlSystemGetDriverVersion().decode("utf-8") - except pynvml.NVMLError: - driver = "No Nvidia driver found" - self._log("debug", "GPU Driver: {}".format(driver)) - return driver - - def _get_devices(self): - """ Obtain the name of the installed devices. The quality of this information depends on - the backend and OS being used, but it should be sufficient for identifying cards. - - Returns - ------- - list - List of device names for connected GPUs as corresponding to the values in - :attr:`_handles` - """ - self._initialize() - if self._device_count == 0: - names = list() - if self._is_plaidml: - names = self._plaid.names - elif IS_MACOS: - names = [pynvx.cudaGetName(handle, ignore=True) - for handle in self._handles] - else: - names = [pynvml.nvmlDeviceGetName(handle).decode("utf-8") - for handle in self._handles] - self._log("debug", "GPU Devices: {}".format(names)) - return names - - def _get_vram(self): - """ Obtain the total VRAM in Megabytes for each connected GPU. - - Returns - ------- - list - List of floats containing the total amount of VRAM in Megabytes for each connected GPU - as corresponding to the values in :attr:`_handles - """ - self._initialize() - if self._device_count == 0: - vram = list() - elif self._is_plaidml: - vram = self._plaid.vram - elif IS_MACOS: - vram = [pynvx.cudaGetMemTotal(handle, ignore=True) / (1024 * 1024) - for handle in self._handles] - else: - vram = [pynvml.nvmlDeviceGetMemoryInfo(handle).total / - (1024 * 1024) - for handle in self._handles] - self._log("debug", "GPU VRAM: {}".format(vram)) - return vram - - def _get_free_vram(self): - """ Obtain the amount of VRAM that is available, in Megabytes, for each connected GPU. - - Returns - ------- - list - List of floats containing the amount of VRAM available, in Megabytes, for each - connected GPU as corresponding to the values in :attr:`_handles - - Notes - ----- - There is no useful way to get free VRAM on PlaidML. OpenCL loads and unloads VRAM as - required, so this returns the total memory available per card for AMD cards, which us - not particularly useful. - - """ - self._initialize() - if self._is_plaidml: - vram = self._plaid.vram - elif IS_MACOS: - vram = [pynvx.cudaGetMemFree(handle, ignore=True) / (1024 * 1024) - for handle in self._handles] - else: - vram = [pynvml.nvmlDeviceGetMemoryInfo(handle).free / (1024 * 1024) - for handle in self._handles] - self._shutdown() - self._log("debug", "GPU VRAM free: {}".format(vram)) - return vram - - def get_card_most_free(self): - """ Obtain statistics for the GPU with the most available free VRAM. - - Returns - ------- - dict - The dictionary contains the following data: - - **card_id** (`int`): The index of the card as pertaining to :attr:`_handles` - - **device** (`str`): The name of the device - - **free** (`float`): The amount of available VRAM on the GPU - - **total** (`float`): the total amount of VRAM on the GPU - - If a GPU is not detected then the **card_id** is returned as ``-1`` and the amount - of free and total RAM available is fixed to 2048 Megabytes. - """ - if len(self._active_devices) == 0: - return {"card_id": -1, - "device": "No GPU devices found", - "free": 2048, - "total": 2048} - free_vram = [self._get_free_vram()[i] for i in self._active_devices] - vram_free = max(free_vram) - card_id = self._active_devices[free_vram.index(vram_free)] - retval = {"card_id": card_id, - "device": self._devices[card_id], - "free": vram_free, - "total": self._vram[card_id]} - self._log("debug", "Active GPU Card with most free VRAM: {}".format(retval)) - return retval diff --git a/lib/gpu_stats/__init__.py b/lib/gpu_stats/__init__.py new file mode 100644 index 0000000000..8ea89f26bb --- /dev/null +++ b/lib/gpu_stats/__init__.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +""" Dynamically import the correct GPU Stats library based on the faceswap backend and the machine +being used. """ + +import platform + +from lib.utils import get_backend + +from ._base import set_exclude_devices # noqa + +backend = get_backend() + +if backend == "nvidia" and platform.system().lower() == "darwin": + from .nvidia_apple import NvidiaAppleStats as GPUStats # noqa +elif backend == "nvidia": + from .nvidia import NvidiaStats as GPUStats # noqa +elif backend == "amd": + from .amd import AMDStats as GPUStats, setup_plaidml # noqa +elif backend == "cpu": + from .cpu import CPUStats as GPUStats # noqa diff --git a/lib/gpu_stats/_base.py b/lib/gpu_stats/_base.py new file mode 100644 index 0000000000..b1b9e18da7 --- /dev/null +++ b/lib/gpu_stats/_base.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +""" Parent class for obtaining Stats for various GPU/TPU backends. All GPU Stats should inherit +from the :class:`GPUStats` class contained here. """ + +import logging +import os + +from typing import List, Optional, TypedDict + +from lib.utils import get_backend + + +_EXCLUDE_DEVICES: List[int] = [] + + +class GPUInfo(TypedDict): + """ Typed Dictionary for returning Full GPU Information. """ + vram: List[int] + driver: str + devices: List[str] + active_devices: List[str] + + +class BiggestGPUInfo(TypedDict): + """ Typed Dictionary for returning GPU Information about the card with most available VRAM. """ + card_id: int + device: str + free: float + total: float + + +def set_exclude_devices(devices: List[int]) -> None: + """ Add any explicitly selected GPU devices to the global list of devices to be excluded + from use by Faceswap. + + Parameters + ---------- + devices: list + list of indices corresponding to the GPU devices connected to the computer + """ + logger = logging.getLogger(__name__) + logger.debug("Excluding GPU indicies: %s", devices) + if not devices: + return + _EXCLUDE_DEVICES.extend(devices) + + +class GPUStats(): + """ Parent class for returning information of GPUs used. """ + + def __init__(self, log: bool = True) -> None: + # Logger is held internally, as we don't want to log when obtaining system stats on crash + # or when querying the backend for command line options + self._logger: Optional[logging.Logger] = logging.getLogger(__name__) if log else None + self._log("debug", f"Initializing {self.__class__.__name__}") + + self._is_initialized = False + self._initialize() + + self._device_count: int = self._get_device_count() + self._active_devices: List[int] = self._get_active_devices() + self._handles: list = self._get_handles() + self._driver: str = self._get_driver() + self._device_names: List[str] = self._get_device_names() + self._vram: List[float] = self._get_vram() + self._vram_free: List[float] = self._get_free_vram() + + if get_backend() != "cpu" and not self._active_devices: + self._log("warning", "No GPU detected") + + self._shutdown() + self._log("debug", f"Initialized {self.__class__.__name__}") + + @property + def device_count(self) -> int: + """int: The number of GPU devices discovered on the system. """ + return self._device_count + + @property + def cli_devices(self) -> List[str]: + """ list: List of available devices for use in faceswap's command line arguments. """ + return [f"{idx}: {device}" for idx, device in enumerate(self._device_names)] + + @property + def exclude_all_devices(self) -> bool: + """ bool: ``True`` if all GPU devices have been explicitly disabled otherwise ``False`` """ + return all(idx in _EXCLUDE_DEVICES for idx in range(self._device_count)) + + @property + def sys_info(self) -> GPUInfo: + """ dict: GPU Stats that are required for system information logging. + + The dictionary contains the following data: + + **vram** (`list`): the total amount of VRAM in Megabytes for each GPU as pertaining to + :attr:`_handles` + + **driver** (`str`): The GPU driver version that is installed on the OS + + **devices** (`list`): The device name of each GPU on the system as pertaining + to :attr:`_handles` + + **devices_active** (`list`): The device name of each active GPU on the system as + pertaining to :attr:`_handles` + """ + return GPUInfo(vram=self._vram, + driver=self._driver, + devices=self._device_names, + devices_active=self._active_devices) + + def _log(self, level: str, message: str) -> None: + """ If the class has been initialized with :attr:`log` as `True` then log the message + otherwise skip logging. + + Parameters + ---------- + level: str + The log level to log at + message: str + The message to log + """ + if self._logger is None: + return + logger = getattr(self._logger, level.lower()) + logger(message) + + def _initialize(self): + """ Override for GPU specific initialization code. """ + self._is_initialized = True + + def _shutdown(self): + """ Override for GPU specific shutdown code. """ + self._is_initialized = False + + def _get_device_count(self) -> int: + """ Override to obtain GPU specific device count + + Returns + ------- + int + The total number of GPUs connected to the PC + """ + raise NotImplementedError() + + def _get_active_devices(self) -> List[int]: + """ Obtain the indices of active GPUs (those that have not been explicitly excluded by + CUDA_VISIBLE_DEVICES environment variable or explicitly excluded in the command line + arguments). + + Notes + ----- + Override for GPUs that do not use CUDA + + Returns + ------- + list + The list of device indices that are available for Faceswap to use + """ + devices = [idx for idx in range(self._device_count) if idx not in _EXCLUDE_DEVICES] + env_devices = os.environ.get("CUDA_VISIBLE_DEVICES") + if env_devices: + env_devices = [int(i) for i in env_devices.split(",")] + devices = [idx for idx in devices if idx in env_devices] + self._log("debug", f"Active GPU Devices: {devices}") + return devices + + def _get_handles(self) -> list: + """ Override to obtain GPU specific device handles for all connected devices. + + Returns + ------- + list + The device handle for each connected GPU + """ + raise NotImplementedError() + + def _get_driver(self) -> str: + """ Override to obtain the GPU specific driver version. + + Returns + ------- + str + The GPU driver currently in use + """ + raise NotImplementedError() + + def _get_device_names(self) -> List[str]: + """ Override to obtain the names of all connected GPUs. The quality of this information + depends on the backend and OS being used, but it should be sufficient for identifying + cards. + + Returns + ------- + list + List of device names for connected GPUs as corresponding to the values in + :attr:`_handles` + """ + raise NotImplementedError() + + def _get_vram(self) -> List[float]: + """ Override to obtain the total VRAM in Megabytes for each connected GPU. + + Returns + ------- + list + List of `float`s containing the total amount of VRAM in Megabytes for each + connected GPU as corresponding to the values in :attr:`_handles` + """ + + def _get_free_vram(self) -> List[float]: + """ Override to obrain the amount of VRAM that is available, in Megabytes, for each + connected GPU. + + Returns + ------- + list + List of `float`s containing the amount of VRAM available, in Megabytes, for each + connected GPU as corresponding to the values in :attr:`_handles + """ + raise NotImplementedError() + + def get_card_most_free(self) -> BiggestGPUInfo: + """ Obtain statistics for the GPU with the most available free VRAM. + + Returns + ------- + dict + The dictionary contains the following data: + + **card_id** (`int`): The index of the card as pertaining to :attr:`_handles` + + **device** (`str`): The name of the device + + **free** (`float`): The amount of available VRAM on the GPU + + **total** (`float`): the total amount of VRAM on the GPU + + If a GPU is not detected then the **card_id** is returned as ``-1`` and the amount + of free and total RAM available is fixed to 2048 Megabytes. + """ + if len(self._active_devices) == 0: + retval = BiggestGPUInfo(card_id=-1, + device="No GPU devices found", + free=2048, + total=2048) + else: + free_vram = [self._vram_free[i] for i in self._active_devices] + vram_free = max(free_vram) + card_id = self._active_devices[free_vram.index(vram_free)] + retval = BiggestGPUInfo(card_id=card_id, + device=self._device_names[card_id], + free=vram_free, + total=self._vram[card_id]) + self._log("debug", f"Active GPU Card with most free VRAM: {retval}") + return retval diff --git a/lib/gpu_stats/amd.py b/lib/gpu_stats/amd.py new file mode 100644 index 0000000000..31cecccc3d --- /dev/null +++ b/lib/gpu_stats/amd.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +""" Collects and returns Information on available AMD GPUs. """ +import json +import logging +import os +import sys + +from typing import List, Optional + +import plaidml + +from ._base import GPUStats, _EXCLUDE_DEVICES + + +_PLAIDML_INITIALIZED: bool = False + + +def setup_plaidml(log_level: str, exclude_devices: List[int]) -> None: + """ Setup PlaidML for AMD Cards. + + Sets the Keras backend to PlaidML, loads the plaidML backend and makes GPU Device information + from PlaidML available to :class:`AMDStats`. + + Parameters + ---------- + log_level: str + Faceswap's log level. Used for setting the log level inside PlaidML + exclude_devices: list + A list of integers of device IDs that should not be used by Faceswap + """ + logger = logging.getLogger(__name__) # pylint:disable=invalid-name + logger.info("Setting up for PlaidML") + logger.verbose("Setting Keras Backend to PlaidML") + # Add explicitly excluded devices to list. The contents are checked in AMDstats + if exclude_devices: + _EXCLUDE_DEVICES.extend(int(idx) for idx in exclude_devices) + os.environ["KERAS_BACKEND"] = "plaidml.keras.backend" + stats = AMDStats(log_level) + logger.info("Using GPU(s): %s", [stats.names[i] for i in stats.active_devices]) + logger.info("Successfully set up for PlaidML") + + +class AMDStats(GPUStats): + """ Holds information and statistics about AMD GPU(s) available on the currently + running system. + + Notes + ----- + The quality of data that returns is very much dependent on the OpenCL implementation used + for a particular OS. Some data is just not available at all, so assumptions and substitutions + are made where required. PlaidML is used as an interface into OpenCL to obtain the required + information. + + PlaidML is explicitly initialized inside this class, as it can be called from the command line + arguments to list available GPUs. PlaidML needs to be set up and configured to obtain reliable + information. As the function :func:`setup_plaidml` is called very early within the Faceswap + and launch process and it references this class, initial PlaidML setup can all be handled here. + + Parameters + ---------- + log: bool, optional + Whether the class should output information to the logger. There may be occasions where the + logger has not yet been set up when this class is queried. Attempting to log in these + instances will raise an error. If GPU stats are being queried prior to the logger being + available then this parameter should be set to ``False``. Otherwise set to ``True``. + Default: ``True`` + """ + def __init__(self, log: bool = True, log_level: str = "INFO") -> None: + + self._log_level: str = log_level.upper() + + # Following attributes are set in :func:``_initialize`` + self._ctx: Optional(plaidml.Context) = None + self._supported_devices: Optional(List[plaidml._DeviceConfig]) = None + self._all_devices: Optional(List[plaidml._DeviceConfig]) = None + self._device_details: Optional(List[dict]) = None + + super().__init__(log=log) + + @property + def active_devices(self) -> List[int]: + """ list: The active device ids in use. """ + return self._active_devices + + @property + def _plaid_ids(self) -> List[str]: + """ list: The device identification for each GPU device that PlaidML has discovered. """ + return [device.id.decode("utf-8") for device in self._all_devices] + + @property + def _experimental_indices(self) -> List[int]: + """ list: The indices corresponding to :attr:`_ids` of GPU devices marked as + "experimental". """ + retval = [idx for idx, device in enumerate(self._all_devices) + if device not in self._supported_indices] + return retval + + @property + def _supported_indices(self) -> List[int]: + """ list: The indices corresponding to :attr:`_ids` of GPU devices marked as + "supported". """ + retval = [idx for idx, device in enumerate(self._all_devices) + if device in self._supported_devices] + return retval + + @property + def _all_vram(self) -> List[float]: + """ list: The VRAM of each GPU device that PlaidML has discovered. """ + return [int(device.get("globalMemSize", 0)) / (1024 * 1024) + for device in self._device_details] + + @property + def names(self) -> List[str]: + """ list: The name of each GPU device that PlaidML has discovered. """ + return [f"{device.get('vendor', 'unknown')} - {device.get('name', 'unknown')} " + f"({ 'supported' if idx in self._supported_indices else 'experimental'})" + for idx, device in enumerate(self._device_details)] + + def _initialize(self) -> None: + """ Initialize PlaidML for AMD GPUs. + + If :attr:`_is_initialized` is ``True`` then this function just returns performing no + action. + + if ``False`` then PlaidML is setup, if not already, and GPU information is extracted + from the PlaidML context. + """ + if self._is_initialized: + return + self._log("debug", "Initializing PlaidML for AMD GPU.") + + self._initialize_plaidml() + + self._ctx = plaidml.Context() + self._supported_devices = self._get_supported_devices() + self._all_devices = self._get_all_devices() + self._device_details = self._get_device_details() + self._select_device() + + def _initialize_plaidml(self) -> None: + """ Initialize PlaidML on first call to this class and set global + :attr:``_PLAIDML_INITIALIZED`` to ``True``. If PlaidML has already been initialized then + return performing no action. """ + global _PLAIDML_INITIALIZED # pylint:disable=global-statement + + if _PLAIDML_INITIALIZED: + return + + self._log("debug", "Performing first time PlaidML setup.") + self._set_plaidml_logger() + + _PLAIDML_INITIALIZED = True + + def _set_plaidml_logger(self) -> None: + """ Set PlaidMLs default logger to Faceswap Logger, prevent propagation and set the correct + log level. """ + self._log("debug", "Setting PlaidML Default Logger") + + plaidml.DEFAULT_LOG_HANDLER = logging.getLogger("plaidml_root") + plaidml.DEFAULT_LOG_HANDLER.propagate = 0 + + numeric_level = getattr(logging, self._log_level, None) + if numeric_level < 10: # DEBUG Logging + plaidml._internal_set_vlog(1) # pylint:disable=protected-access + elif numeric_level < 20: # INFO Logging + plaidml._internal_set_vlog(0) # pylint:disable=protected-access + else: # WARNING LOGGING + plaidml.quiet() + + def _get_supported_devices(self) -> List[plaidml._DeviceConfig]: + """ Obtain GPU devices from PlaidML that are marked as "supported". + + Returns + ------- + list_LOGGER. + The :class:`plaidml._DeviceConfig` objects for all supported GPUs that PlaidML has + discovered. + """ + experimental_setting = plaidml.settings.experimental + + plaidml.settings.experimental = False + devices = plaidml.devices(self._ctx, limit=100, return_all=True)[0] + + plaidml.settings.experimental = experimental_setting + + supported = [d for d in devices + if d.details + and json.loads(d.details.decode("utf-8")).get("type", "cpu").lower() == "gpu"] + + self._log("debug", f"Obtained supported devices: {supported}") + return supported + + def _get_all_devices(self) -> List[plaidml._DeviceConfig]: + """ Obtain all available (experimental and supported) GPU devices from PlaidML. + + Returns + ------- + list + The :class:`pladml._DeviceConfig` objects for GPUs that PlaidML has discovered. + """ + experimental_setting = plaidml.settings.experimental + + plaidml.settings.experimental = True + devices = plaidml.devices(self._ctx, limit=100, return_all=True)[0] + + plaidml.settings.experimental = experimental_setting + + experi = [d for d in devices + if d.details + and json.loads(d.details.decode("utf-8")).get("type", "cpu").lower() == "gpu"] + + self._log("debug", f"Obtained experimental Devices: {experi}") + + all_devices = experi + self._supported_devices + + self._log("debug", f"Obtained all Devices: {all_devices}") + return all_devices + + def _get_device_details(self) -> List[dict]: + """ Obtain the device details for all connected AMD GPUS. + + Returns + ------- + list + The `dict` device detail for all GPUs that PlaidML has discovered. + """ + details = [json.loads(d.details.decode("utf-8")) + for d in self._all_devices if d.details] + self._log("debug", f"Obtained Device details: {details}") + return details + + def _select_device(self) -> None: + """ + If the plaidml user configuration settings exist, then set the default GPU from the + settings file, Otherwise set the GPU to be the one with most VRAM. """ + if os.path.exists(plaidml.settings.user_settings): # pylint:disable=no-member + self._log("debug", "Setting PlaidML devices from user_settings") + else: + self._select_largest_gpu() + + def _select_largest_gpu(self) -> None: + """ Set the default GPU to be a supported device with the most available VRAM. If no + supported device is available, then set the GPU to be an experimental device with the + most VRAM available. """ + category = "supported" if self._supported_devices else "experimental" + self._log("debug", f"Obtaining largest {category} device") + + indices = getattr(self, f"_{category}_indices") + if not indices: + self._log("error", "Failed to automatically detect your GPU.") + self._log("error", "Please run `plaidml-setup` to set up your GPU.") + sys.exit(1) + + max_vram = max([self._all_vram[idx] for idx in indices]) + self._log("debug", f"Max VRAM: {max_vram}") + + gpu_idx = min([idx for idx, vram in enumerate(self._all_vram) + if vram == max_vram and idx in indices]) + self._log("debug", f"GPU IDX: {gpu_idx}") + + selected_gpu = self._plaid_ids[gpu_idx] + self._log("info", f"Setting GPU to largest available {category} device. If you want to " + "override this selection, run `plaidml-setup` from the command line.") + + plaidml.settings.experimental = category == "experimental" + plaidml.settings.device_ids = [selected_gpu] + + def _get_device_count(self) -> int: + """ Detect the number of AMD GPUs available from PlaidML. + + Returns + ------- + int + The total number of AMD GPUs available + """ + retval = len(self._all_devices) + self._log("debug", f"GPU Device count: {retval}") + return retval + + def _get_active_devices(self) -> List[int]: + """ Obtain the indices of active GPUs (those that have not been explicitly excluded by + PlaidML or explicitly excluded in the command line arguments). + + Returns + ------- + list + The list of device indices that are available for Faceswap to use + """ + devices = [idx for idx, d_id in enumerate(self._plaid_ids) + if d_id in plaidml.settings.device_ids and idx not in _EXCLUDE_DEVICES] + self._log("debug", f"Active GPU Devices: {devices}") + return devices + + def _get_handles(self) -> list: + """ AMD Doesn't really use device handles, so we just return the all devices list + + Returns + ------- + list + The list of all AMD discovered GPUs + """ + handles = self._all_devices + self._log("debug", f"AMD GPU Handles found: {handles}") + return handles + + def _get_driver(self) -> str: + """ Obtain the AMD driver version currently in use. + + Returns + ------- + str + The current AMD GPU driver versions + """ + drivers = [device.get("driverVersion", "No Driver Found") + for device in self._device_details] + self._log("debug", f"GPU Drivers: {drivers}") + return drivers + + def _get_device_names(self) -> List[str]: + """ Obtain the list of names of connected AMD GPUs as identified in :attr:`_handles`. + + Returns + ------- + list + The list of connected Nvidia GPU names + """ + names = self.names + self._log("debug", f"GPU Devices: {names}") + return names + + def _get_vram(self) -> List[float]: + """ Obtain the VRAM in Megabytes for each connected AMD GPU as identified in + :attr:`_handles`. + + Returns + ------- + list + The VRAM in Megabytes for each connected Nvidia GPU + """ + vram = self._all_vram + self._log("debug", f"GPU VRAM: {vram}") + return vram + + def _get_free_vram(self) -> List[float]: + """ Obtain the amount of VRAM that is available, in Megabytes, for each connected AMD + GPU. + + Notes + ----- + There is no useful way to get free VRAM on PlaidML. OpenCL loads and unloads VRAM as + required, so this returns the total memory available per card for AMD GPUs, which is + not particularly useful. + + Returns + ------- + list + List of `float`s containing the amount of VRAM available, in Megabytes, for each + connected GPU as corresponding to the values in :attr:`_handles + """ + vram = self._all_vram + self._log("debug", f"GPU VRAM free: {vram}") + return vram diff --git a/lib/gpu_stats/cpu.py b/lib/gpu_stats/cpu.py new file mode 100644 index 0000000000..bbc9ef4d45 --- /dev/null +++ b/lib/gpu_stats/cpu.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +""" Dummy functions for running faceswap on CPU. """ + + +from typing import List + +from ._base import GPUStats + + +class CPUStats(GPUStats): + """ Holds information and statistics about the CPU on the currently running system. + + Notes + ----- + The information held here is not useful, but GPUStats is dynamically imported depending on the + backend used, so we need to make sure this class is available for Faceswap run on the CPU + Backend. + + The base :class:`GPUStats` handles the dummying in of information when no GPU is detected. + + Parameters + ---------- + log: bool, optional + Whether the class should output information to the logger. There may be occasions where the + logger has not yet been set up when this class is queried. Attempting to log in these + instances will raise an error. If GPU stats are being queried prior to the logger being + available then this parameter should be set to ``False``. Otherwise set to ``True``. + Default: ``True`` + """ + + def _get_device_count(self) -> int: + """ Detect the number of GPUs attached to the system. Always returns zero for CPU + backends. + + Returns + ------- + int + The total number of GPUs connected to the PC + """ + retval = 0 + self._log("debug", f"GPU Device count: {retval}") + return retval + + def _get_handles(self) -> list: + """ Obtain the device handles for all connected GPUs. + + Returns + ------- + list + An empty list for CPU Backends + """ + handles = [] + self._log("debug", f"GPU Handles found: {len(handles)}") + return handles + + def _get_driver(self) -> str: + """ Obtain the driver version currently in use. + + Returns + ------- + str + An empty string for CPU backends + """ + driver = "" + self._log("debug", f"GPU Driver: {driver}") + return driver + + def _get_device_names(self) -> List[str]: + """ Obtain the list of names of connected GPUs as identified in :attr:`_handles`. + + Returns + ------- + list + An empty list for CPU backends + """ + names = [] + self._log("debug", f"GPU Devices: {names}") + return names + + def _get_vram(self) -> List[float]: + """ Obtain the RAM in Megabytes for the running system. + + Returns + ------- + list + An empty list for CPU backends + """ + vram = [] + self._log("debug", f"GPU VRAM: {vram}") + return vram + + def _get_free_vram(self) -> List[float]: + """ Obtain the amount of RAM that is available, in Megabytes, for the running system. + + Returns + ------- + list + An empty list for CPU backends + """ + vram = [] + self._log("debug", f"GPU VRAM free: {vram}") + return vram diff --git a/lib/gpu_stats/nvidia.py b/lib/gpu_stats/nvidia.py new file mode 100644 index 0000000000..a1d9d328ef --- /dev/null +++ b/lib/gpu_stats/nvidia.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +""" Collects and returns Information on available Nvidia GPUs. """ + +from typing import List + +import pynvml + +from lib.utils import FaceswapError + +from ._base import GPUStats + + +class NvidiaStats(GPUStats): + """ Holds information and statistics about Nvidia GPU(s) available on the currently + running system. + + Notes + ----- + PyNVML is used for hooking in to Nvidia's Machine Learning Library and allows for pulling + fairly extensive statistics for Nvidia GPUs + + Parameters + ---------- + log: bool, optional + Whether the class should output information to the logger. There may be occasions where the + logger has not yet been set up when this class is queried. Attempting to log in these + instances will raise an error. If GPU stats are being queried prior to the logger being + available then this parameter should be set to ``False``. Otherwise set to ``True``. + Default: ``True`` + """ + + def _initialize(self) -> None: + """ Initialize PyNVML for Nvidia GPUs. + + If :attr:`_is_initialized` is ``True`` then this function just returns performing no + action. Otherwise :attr:`is_initialized` is set to ``True`` after successfully + initializing NVML. + + Raises + ------ + FaceswapError + If the NVML library could not be successfully loaded + """ + if self._is_initialized: + return + try: + self._log("debug", "Initializing PyNVML for Nvidia GPU.") + pynvml.nvmlInit() + except (pynvml.NVMLError_LibraryNotFound, # pylint:disable=no-member + pynvml.NVMLError_DriverNotLoaded, # pylint:disable=no-member + pynvml.NVMLError_NoPermission) as err: # pylint:disable=no-member + msg = ("There was an error reading from the Nvidia Machine Learning Library. The most " + "likely cause is incorrectly installed drivers. If this is the case, Please " + "remove and reinstall your Nvidia drivers before reporting. Original " + f"Error: {str(err)}") + raise FaceswapError(msg) from err + except Exception as err: # pylint: disable=broad-except + msg = ("An unhandled exception occured reading from the Nvidia Machine Learning " + f"Library. Original error: {str(err)}") + raise FaceswapError(msg) from err + super()._initialize() + + def _shutdown(self) -> None: + """ Cleanly close access to NVML and set :attr:`_is_initialized` back to ``False``. """ + self._log("debug", "Shutting down NVML") + pynvml.nvmlShutdown() + super()._shutdown() + + def _get_device_count(self) -> int: + """ Detect the number of GPUs attached to the system. + + Returns + ------- + int + The total number of GPUs connected to the PC + """ + try: + retval = pynvml.nvmlDeviceGetCount() + except pynvml.NVMLError as err: + self._log("debug", "Error obtaining device count. Setting to 0. " + f"Original error: {str(err)}") + retval = 0 + self._log("debug", f"GPU Device count: {retval}") + return retval + + def _get_handles(self) -> list: + """ Obtain the device handles for all connected Nvidia GPUs. + + Returns + ------- + list + The list of pointers for connected Nvidia GPUs + """ + handles = [pynvml.nvmlDeviceGetHandleByIndex(i) + for i in range(self._device_count)] + self._log("debug", f"GPU Handles found: {len(handles)}") + return handles + + def _get_driver(self) -> str: + """ Obtain the Nvidia driver version currently in use. + + Returns + ------- + str + The current GPU driver version + """ + try: + driver = pynvml.nvmlSystemGetDriverVersion().decode("utf-8") + except pynvml.NVMLError as err: + self._log("debug", f"Unable to obtain driver. Original error: {str(err)}") + driver = "No Nvidia driver found" + self._log("debug", f"GPU Driver: {driver}") + return driver + + def _get_device_names(self) -> List[str]: + """ Obtain the list of names of connected Nvidia GPUs as identified in :attr:`_handles`. + + Returns + ------- + list + The list of connected Nvidia GPU names + """ + names = [pynvml.nvmlDeviceGetName(handle).decode("utf-8") + for handle in self._handles] + self._log("debug", f"GPU Devices: {names}") + return names + + def _get_vram(self) -> List[float]: + """ Obtain the VRAM in Megabytes for each connected Nvidia GPU as identified in + :attr:`_handles`. + + Returns + ------- + list + The VRAM in Megabytes for each connected Nvidia GPU + """ + vram = [pynvml.nvmlDeviceGetMemoryInfo(handle).total / (1024 * 1024) + for handle in self._handles] + self._log("debug", f"GPU VRAM: {vram}") + return vram + + def _get_free_vram(self) -> List[float]: + """ Obtain the amount of VRAM that is available, in Megabytes, for each connected Nvidia + GPU. + + Returns + ------- + list + List of `float`s containing the amount of VRAM available, in Megabytes, for each + connected GPU as corresponding to the values in :attr:`_handles + """ + vram = [pynvml.nvmlDeviceGetMemoryInfo(handle).free / (1024 * 1024) + for handle in self._handles] + self._log("debug", f"GPU VRAM free: {vram}") + return vram diff --git a/lib/gpu_stats/nvidia_apple.py b/lib/gpu_stats/nvidia_apple.py new file mode 100644 index 0000000000..9c5c6c616e --- /dev/null +++ b/lib/gpu_stats/nvidia_apple.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" Collects and returns Information on available Nvidia GPUs connected to Apple Macs. """ +from typing import List + +import pynvx + +from lib.utils import FaceswapError + +from ._base import GPUStats + + +class NvidiaAppleStats(GPUStats): + """ Holds information and statistics about Nvidia GPU(s) available on the currently + running Apple system. + + Notes + ----- + PyNvx is used for hooking in to Nvidia's Machine Learning Library and allows for pulling fairly + extensive statistics for Apple based Nvidia GPUs + + Parameters + ---------- + log: bool, optional + Whether the class should output information to the logger. There may be occasions where the + logger has not yet been set up when this class is queried. Attempting to log in these + instances will raise an error. If GPU stats are being queried prior to the logger being + available then this parameter should be set to ``False``. Otherwise set to ``True``. + Default: ``True`` + """ + + def _initialize(self) -> None: + """ Initialize PyNvx for Nvidia GPUs on Apple. + + If :attr:`_is_initialized` is ``True`` then this function just returns performing no + action. Otherwise :attr:`is_initialized` is set to ``True`` after successfully + initializing NVML. + + Raises + ------ + FaceswapError + If the NVML library could not be successfully loaded + """ + if self._is_initialized: + return + self._log("debug", "Initializing Pynvx for Apple Nvidia GPU.") + try: + pynvx.cudaInit() # pylint:disable=no-member + except RuntimeError as err: + msg = ("An unhandled exception occured reading from the Nvidia Machine Learning " + f"Library. Original error: {str(err)}") + raise FaceswapError(msg) from err + super()._initialize() + + def _shutdown(self) -> None: + """ Set :attr:`_is_initialized` back to ``False``. """ + self._log("debug", "Shutting down NVML") + super()._shutdown() + + def _get_device_count(self) -> int: + """ Detect the number of GPUs attached to the system. + + Returns + ------- + int + The total number of GPUs connected to the PC + """ + retval = pynvx.cudaDeviceGetCount(ignore=True) # pylint:disable=no-member + self._log("debug", f"GPU Device count: {retval}") + return retval + + def _get_handles(self) -> list: + """ Obtain the device handles for all Apple connected Nvidia GPUs. + + Returns + ------- + list + The list of pointers for connected Nvidia GPUs + """ + handles = pynvx.cudaDeviceGetHandles(ignore=True) # pylint:disable=no-member + self._log("debug", f"GPU Handles found: {len(handles)}") + return handles + + def _get_driver(self) -> str: + """ Obtain the Nvidia driver version currently in use. + + Returns + ------- + str + The current GPU driver version + """ + driver = pynvx.cudaSystemGetDriverVersion(ignore=True) # pylint:disable=no-member + self._log("debug", f"GPU Driver: {driver}") + return driver + + def _get_device_names(self) -> List[str]: + """ Obtain the list of names of connected Nvidia GPUs as identified in :attr:`_handles`. + + Returns + ------- + list + The list of connected Nvidia GPU names + """ + names = [pynvx.cudaGetName(handle, ignore=True) # pylint:disable=no-member + for handle in self._handles] + self._log("debug", f"GPU Devices: {names}") + return names + + def _get_vram(self) -> List[float]: + """ Obtain the VRAM in Megabytes for each connected Nvidia GPU as identified in + :attr:`_handles`. + + Returns + ------- + list + The VRAM in Megabytes for each connected Nvidia GPU + """ + vram = [ + pynvx.cudaGetMemTotal(handle, ignore=True) / (1024 * 1024) # pylint:disable=no-member + for handle in self._handles] + self._log("debug", f"GPU VRAM: {vram}") + return vram + + def _get_free_vram(self) -> List[float]: + """ Obtain the amount of VRAM that is available, in Megabytes, for each connected Nvidia + GPU. + + Returns + ------- + list + List of `float`s containing the amount of VRAM available, in Megabytes, for each + connected GPU as corresponding to the values in :attr:`_handles + """ + vram = [ + pynvx.cudaGetMemFree(handle, ignore=True) / (1024 * 1024) # pylint:disable=no-member + for handle in self._handles] + self._log("debug", f"GPU VRAM free: {vram}") + return vram diff --git a/lib/plaidml_tools.py b/lib/plaidml_tools.py deleted file mode 100644 index f5392a17ef..0000000000 --- a/lib/plaidml_tools.py +++ /dev/null @@ -1,287 +0,0 @@ -#!/usr/bin python3 - -""" PlaidML tools. - -Statistics and setup for PlaidML on AMD devices. - -This module must be kept separate from Keras, and be called prior to any Keras import, as the -plaidML Keras backend is set from this module. -""" - -import json -import logging -import os -import sys - -import plaidml - -_INIT = False -_LOGGER = None -_EXCLUDE_DEVICES = [] - - -class PlaidMLStats(): - """ Handles the initialization of PlaidML and the returning of GPU information for connected - cards from the PlaidML library. - - This class is initialized early in Faceswap's Launch process from :func:`setup_plaidml`, with - statistics made available from :class:`~lib.gpu_stats.GPUStats` - - Parameters - --------- - log_level: str, optional - The requested Faceswap log level. Also dictates the level that PlaidML logging is set at. - Default:`"INFO"` - log: bool, optional - Whether this class should output to the logger. If statistics are being accessed during a - crash, then the logger may not be available, so this gives the option to turn logging off - in those kinds of situations. Default:``True`` - """ - def __init__(self, log_level="INFO", log=True): - if not _INIT and log: - # Logger held internally, as we don't want to log when obtaining system stats on crash - global _LOGGER # pylint:disable=global-statement - _LOGGER = logging.getLogger(__name__) - _LOGGER.debug("Initializing: %s: (log_level: %s, log: %s)", - self.__class__.__name__, log_level, log) - self._initialize(log_level) - self._ctx = plaidml.Context() - self._supported_devices = self._get_supported_devices() - self._devices = self._get_all_devices() - - self._device_details = [json.loads(device.details.decode()) - for device in self._devices if device.details] - if self._devices and not self.active_devices: - self._load_active_devices() - if _LOGGER: - _LOGGER.debug("Initialized: %s", self.__class__.__name__) - - # PROPERTIES - @property - def devices(self): - """list: The :class:`pladml._DeviceConfig` objects for GPUs that PlaidML has - discovered. """ - return self._devices - - @property - def active_devices(self): - """ list: List of device indices for active GPU devices. """ - return [idx for idx, d_id in enumerate(self._ids) - if d_id in plaidml.settings.device_ids and idx not in _EXCLUDE_DEVICES] - - @property - def device_count(self): - """ int: The total number of GPU Devices discovered. """ - return len(self._devices) - - @property - def drivers(self): - """ list: The driver versions for each GPU device that PlaidML has discovered. """ - return [device.get("driverVersion", "No Driver Found") for device in self._device_details] - - @property - def vram(self): - """ list: The VRAM of each GPU device that PlaidML has discovered. """ - return [int(device.get("globalMemSize", 0)) / (1024 * 1024) - for device in self._device_details] - - @property - def names(self): - """ list: The name of each GPU device that PlaidML has discovered. """ - return ["{} - {} ({})".format( - device.get("vendor", "unknown"), - device.get("name", "unknown"), - "supported" if idx in self._supported_indices else "experimental") - for idx, device in enumerate(self._device_details)] - - @property - def _ids(self): - """ list: The device identification for each GPU device that PlaidML has discovered. """ - return [device.id.decode() for device in self._devices] - - @property - def _experimental_indices(self): - """ list: The indices corresponding to :attr:`_ids` of GPU devices marked as - "experimental". """ - retval = [idx for idx, device in enumerate(self.devices) - if device not in self._supported_indices] - if _LOGGER: - _LOGGER.debug(retval) - return retval - - @property - def _supported_indices(self): - """ list: The indices corresponding to :attr:`_ids` of GPU devices marked as - "supported". """ - retval = [idx for idx, device in enumerate(self._devices) - if device in self._supported_devices] - if _LOGGER: - _LOGGER.debug(retval) - return retval - - # INITIALIZATION - def _initialize(self, log_level): - """ Initialize PlaidML. - - Set PlaidML to use Faceswap's logger, and set the logging level - - Parameters - ---------- - log_level: str, optional - The requested Faceswap log level. Also dictates the level that PlaidML logging is set - at. - """ - global _INIT # pylint:disable=global-statement - if _INIT: - if _LOGGER: - _LOGGER.debug("PlaidML already initialized") - return - if _LOGGER: - _LOGGER.debug("Initializing PlaidML") - self._set_plaidml_logger() - self._set_verbosity(log_level) - _INIT = True - if _LOGGER: - _LOGGER.debug("Initialized PlaidML") - - @classmethod - def _set_plaidml_logger(cls): - """ Set PlaidMLs default logger to Faceswap Logger and prevent propagation. """ - if _LOGGER: - _LOGGER.debug("Setting PlaidML Default Logger") - plaidml.DEFAULT_LOG_HANDLER = logging.getLogger("plaidml_root") - plaidml.DEFAULT_LOG_HANDLER.propagate = 0 - if _LOGGER: - _LOGGER.debug("Set PlaidML Default Logger") - - @classmethod - def _set_verbosity(cls, log_level): - """ Set the PlaidML logging verbosity - - log_level: str - The requested Faceswap log level. Also dictates the level that PlaidML logging is set - at. - """ - if _LOGGER: - _LOGGER.debug("Setting PlaidML Loglevel: %s", log_level) - if isinstance(log_level, int): - numeric_level = log_level - else: - numeric_level = getattr(logging, log_level.upper(), None) - if numeric_level < 10: - # DEBUG Logging - plaidml._internal_set_vlog(1) # pylint:disable=protected-access - elif numeric_level < 20: - # INFO Logging - plaidml._internal_set_vlog(0) # pylint:disable=protected-access - else: - # WARNING Logging - plaidml.quiet() - - def _get_supported_devices(self): - """ Obtain GPU devices from PlaidML that are marked as "supported". - - Returns - ------- - list - The :class:`pladml._DeviceConfig` objects for GPUs that PlaidML has discovered. - """ - experimental_setting = plaidml.settings.experimental - plaidml.settings.experimental = False - devices = plaidml.devices(self._ctx, limit=100, return_all=True)[0] - plaidml.settings.experimental = experimental_setting - - supported = [device for device in devices - if device.details - and json.loads(device.details.decode()).get("type", "cpu").lower() == "gpu"] - if _LOGGER: - _LOGGER.debug(supported) - return supported - - def _get_all_devices(self): - """ Obtain all available (experimental and supported) GPU devices from PlaidML. - - Returns - ------- - list - The :class:`pladml._DeviceConfig` objects for GPUs that PlaidML has discovered. - """ - experimental_setting = plaidml.settings.experimental - plaidml.settings.experimental = True - devices, _ = plaidml.devices(self._ctx, limit=100, return_all=True) - plaidml.settings.experimental = experimental_setting - - experi = [device for device in devices - if device.details - and json.loads(device.details.decode()).get("type", "cpu").lower() == "gpu"] - if _LOGGER: - _LOGGER.debug("Experimental Devices: %s", experi) - all_devices = experi + self._supported_devices - if _LOGGER: - _LOGGER.debug(all_devices) - return all_devices - - def _load_active_devices(self): - """ If the plaidml user configuration settings exist, then set the default GPU from the - settings file, Otherwise set the GPU to be the one with most VRAM. """ - if not os.path.exists(plaidml.settings.user_settings): # pylint:disable=no-member - if _LOGGER: - _LOGGER.debug("Setting largest PlaidML device") - self._set_largest_gpu() - else: - if _LOGGER: - _LOGGER.debug("Setting PlaidML devices from user_settings") - - def _set_largest_gpu(self): - """ Set the default GPU to be a supported device with the most available VRAM. If no - supported device is available, then set the GPU to be the an experimental device with the - most VRAM available. """ - category = "supported" if self._supported_devices else "experimental" - if _LOGGER: - _LOGGER.debug("Obtaining largest %s device", category) - indices = getattr(self, "_{}_indices".format(category)) - if not indices: - _LOGGER.error("Failed to automatically detect your GPU.") - _LOGGER.error("Please run `plaidml-setup` to set up your GPU.") - sys.exit(1) - max_vram = max([self.vram[idx] for idx in indices]) - if _LOGGER: - _LOGGER.debug("Max VRAM: %s", max_vram) - gpu_idx = min([idx for idx, vram in enumerate(self.vram) - if vram == max_vram and idx in indices]) - if _LOGGER: - _LOGGER.debug("GPU IDX: %s", gpu_idx) - - selected_gpu = self._ids[gpu_idx] - if _LOGGER: - _LOGGER.info("Setting GPU to largest available %s device. If you want to override " - "this selection, run `plaidml-setup` from the command line.", category) - - plaidml.settings.experimental = category == "experimental" - plaidml.settings.device_ids = [selected_gpu] - - -def setup_plaidml(log_level, exclude_devices): - """ Setup PlaidML for AMD Cards. - - Sets the Keras backend to PlaidML, loads the plaidML backend and makes GPU Device information - from PlaidML available to :class:`~lib.gpu_stats.GPUStats`. - - - Parameters - ---------- - log_level: str - Faceswap's log level. Used for setting the log level inside PlaidML - exclude_devices: list - A list of integers of device IDs that should not be used by Faceswap - """ - logger = logging.getLogger(__name__) # pylint:disable=invalid-name - logger.info("Setting up for PlaidML") - logger.verbose("Setting Keras Backend to PlaidML") - # Add explicitly excluded devices to list. The contents have already been checked in GPUStats - if exclude_devices: - _EXCLUDE_DEVICES.extend(int(idx) for idx in exclude_devices) - os.environ["KERAS_BACKEND"] = "plaidml.keras.backend" - plaid = PlaidMLStats(log_level) - logger.info("Using GPU(s): %s", [plaid.names[i] for i in plaid.active_devices]) - logger.info("Successfully set up for PlaidML") From c8643e6a3efe94fd2da7e262499aee89c93b8418 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 11 May 2022 17:47:30 +0100 Subject: [PATCH 557/981] typofix --- docs/full/lib/gpu_stats.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/full/lib/gpu_stats.rst b/docs/full/lib/gpu_stats.rst index 3fd97dc5fb..8dbef800e3 100755 --- a/docs/full/lib/gpu_stats.rst +++ b/docs/full/lib/gpu_stats.rst @@ -1,10 +1,8 @@ -****************** gpu\_stats package -****************** +================== The GPU Stats Package handles collection of information from connected GPUs - .. contents:: Contents :local: From cf2cf090ee7500bb1e80efca3dcadbf8c6c2fb1f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 11 May 2022 18:01:54 +0100 Subject: [PATCH 558/981] Add .readthedocs.yml --- .gitignore | 1 + .readthedocs.yml | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 .readthedocs.yml diff --git a/.gitignore b/.gitignore index e0d427e086..0f3faf7f1a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ !.install/ !.install/** !config/ +!.readthedocs.yml !docs/ !docs/full** !docs/_static** diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000000..4cceaad664 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,25 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-20.04 + tools: + python: "3.8" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +# If using Sphinx, optionally build your docs in additional formats such as PDF +# formats: +# - pdf + +# Optionally declare the Python requirements required to build your docs +python: + install: + - requirements: docs/spinx_requirements.txt From 981117dca653835eb22d8e0815db41c50f2734ae Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 11 May 2022 18:04:47 +0100 Subject: [PATCH 559/981] typofix --- .readthedocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 4cceaad664..2aa3c9934b 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -22,4 +22,4 @@ sphinx: # Optionally declare the Python requirements required to build your docs python: install: - - requirements: docs/spinx_requirements.txt + - requirements: docs/sphinx_requirements.txt From 6d6903d2ce990117b3915e0e2ad89496d50e9065 Mon Sep 17 00:00:00 2001 From: geewiz94 <94993977+geewiz94@users.noreply.github.com> Date: Thu, 12 May 2022 01:24:39 +0200 Subject: [PATCH 560/981] Linting --- lib/metal/__init__.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/metal/__init__.py b/lib/metal/__init__.py index 7fa2af07f7..738353c39b 100644 --- a/lib/metal/__init__.py +++ b/lib/metal/__init__.py @@ -1,22 +1,26 @@ from typing import List import os -import psutil # used for getting GPU memory +import psutil # used for getting GPU memory import tensorflow as tf + class Constants: class System: ARCH = 'arm64' DEVICE_TYPE = 'GPU' SET_MEMORY_GROWTH = True + class CUDA: DRIVER_VERSION_UNSUPPORTED = 0 + def _dbg_check_mem(): print("==========================================") print(tf.config.experimental.get_memory_info('GPU:0')) print(tf.config.list_logical_devices()) print("==========================================") + def _validate_metal(): # Validate a GPU exists assert(len(tf.config.experimental.list_physical_devices('GPU')) > 0) @@ -25,8 +29,9 @@ def _validate_metal(): with tf.device('GPU:0'): assert(tf.math.add(1.0, 2.0) == 3.0) + def init(device_type: str = 'GPU') -> None: - #_validate_metal() + # _validate_metal() os.environ['DISPLAY'] = ':0' try: @@ -35,31 +40,37 @@ def init(device_type: str = 'GPU') -> None: pass Constants.System.DEVICE_TYPE = device_type - #for device in get_devices(): + # for device in get_devices(): # tf.config.experimental.set_memory_growth(device, Constants.System.SET_MEMORY_GROWTH) - + _dbg_check_mem() + def get_devices() -> List[tf.config.PhysicalDevice]: return tf.config.list_physical_devices(device_type=Constants.System.DEVICE_TYPE) + def get_device_count() -> int: return len(get_devices()) + def get_handles() -> list: return list(range(get_device_count())) + def get_driver_version() -> int: # https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART____VERSION.html return Constants.CUDA.DRIVER_VERSION_UNSUPPORTED + def get_device_names() -> List[str]: return [d.name for d in get_devices()] + def get_memory_info(handle: int) -> int: # Does not work: # tf.config.experimental.get_memory_info('GPU:0') # So, using psutil instead. # We can just grab the total memory, as it's shared between # the CPU and the GPU. There is no dedicated VRAM. - return psutil.virtual_memory().total / get_device_count() \ No newline at end of file + return psutil.virtual_memory().total / get_device_count() From 948f0fb82e4f6b32dd065d0b3e07090cf207470b Mon Sep 17 00:00:00 2001 From: geewiz94 <94993977+geewiz94@users.noreply.github.com> Date: Thu, 12 May 2022 01:33:56 +0200 Subject: [PATCH 561/981] Rename Apple Silicon backend to apple_silicon --- lib/gpu_stats/__init__.py | 2 +- lib/utils.py | 6 +++--- setup.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/gpu_stats/__init__.py b/lib/gpu_stats/__init__.py index c68f4d9a72..e10cac5c3e 100644 --- a/lib/gpu_stats/__init__.py +++ b/lib/gpu_stats/__init__.py @@ -16,7 +16,7 @@ from .nvidia import NvidiaStats as GPUStats # noqa elif backend == "amd": from .amd import AMDStats as GPUStats, setup_plaidml # noqa -elif backend == "apple": +elif backend == "apple_silicon": from .apple_silicon import AppleSiliconStats as GPUStats # noqa elif backend == "cpu": from .cpu import CPUStats as GPUStats # noqa diff --git a/lib/utils.py b/lib/utils.py index 5ca48b3b30..45c48ab4f6 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -30,7 +30,7 @@ class _Backend(): # pylint:disable=too-few-public-methods If file doesn't exist and a variable hasn't been set, create the config file. """ def __init__(self): - self._backends = {"1": "amd", "2": "cpu", "3": "nvidia", "4": "apple"} + self._backends = {"1": "amd", "2": "cpu", "3": "nvidia", "4": "apple_silicon"} self._config_file = self._get_config_file() self.backend = self._get_backend() @@ -92,7 +92,7 @@ def _configure_backend(self): """ print("First time configuration. Please select the required backend") while True: - selection = input("1: AMD, 2: CPU, 3: NVIDIA, 4: Apple: ") + selection = input("1: AMD, 2: CPU, 3: NVIDIA, 4: Apple Silicon: ") if selection not in ("1", "2", "3", "4"): print(f"'{selection}' is not a valid selection. Please try again") continue @@ -124,7 +124,7 @@ def set_backend(backend): Parameters ---------- - backend: ["amd", "cpu", "nvidia", "apple"] + backend: ["amd", "cpu", "nvidia", "apple_silicon"] The backend to set faceswap to """ global _FS_BACKEND # pylint:disable=global-statement diff --git a/setup.py b/setup.py index 6d8f6d2f71..0dacf7f360 100755 --- a/setup.py +++ b/setup.py @@ -288,7 +288,7 @@ def set_config(self): elif self.enable_cuda: backend = "nvidia" elif self.enable_apple: - backend = "apple" + backend = "apple_silicon" else: backend = "cpu" config = {"backend": backend} From b057b719ce5665590beb3ba1782721bc6257963a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 12 May 2022 08:53:13 +0100 Subject: [PATCH 562/981] bugfix: prevent error on python 3.7 --- _requirements_base.txt | 1 + lib/gpu_stats/_base.py | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/_requirements_base.txt b/_requirements_base.txt index ad7e90a31d..3b3d7db001 100644 --- a/_requirements_base.txt +++ b/_requirements_base.txt @@ -14,3 +14,4 @@ ffmpy==0.2.3 nvidia-ml-py>=11.510,<300 pywin32>=228 ; sys_platform == "win32" pynvx==1.0.0 ; sys_platform == "darwin" +typing-extensions ; python_version < "3.8" diff --git a/lib/gpu_stats/_base.py b/lib/gpu_stats/_base.py index b1b9e18da7..bf821f5e0a 100644 --- a/lib/gpu_stats/_base.py +++ b/lib/gpu_stats/_base.py @@ -4,11 +4,17 @@ import logging import os +import sys -from typing import List, Optional, TypedDict +from typing import List, Optional from lib.utils import get_backend +if sys.version_info < (3, 8): + from typing import TypedDict +else: + from typing_extensions import TypedDict + _EXCLUDE_DEVICES: List[int] = [] From bcadc13c6d80a9b4002ed6286d5d06466e297df5 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 12 May 2022 11:49:40 +0100 Subject: [PATCH 563/981] typofix - lib.gpu_stats._base --- lib/gpu_stats/_base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/gpu_stats/_base.py b/lib/gpu_stats/_base.py index bf821f5e0a..8df7c90e58 100644 --- a/lib/gpu_stats/_base.py +++ b/lib/gpu_stats/_base.py @@ -212,6 +212,7 @@ def _get_vram(self) -> List[float]: List of `float`s containing the total amount of VRAM in Megabytes for each connected GPU as corresponding to the values in :attr:`_handles` """ + raise NotImplementedError() def _get_free_vram(self) -> List[float]: """ Override to obrain the amount of VRAM that is available, in Megabytes, for each From fdb0a33c8deddf46d29c73ddea32e48090362600 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 12 May 2022 12:01:14 +0100 Subject: [PATCH 564/981] typofix - lib.gpu_stats.amd --- lib/gpu_stats/amd.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/gpu_stats/amd.py b/lib/gpu_stats/amd.py index 31cecccc3d..6d81d17852 100644 --- a/lib/gpu_stats/amd.py +++ b/lib/gpu_stats/amd.py @@ -137,6 +137,8 @@ def _initialize(self) -> None: self._device_details = self._get_device_details() self._select_device() + super()._initialize() + def _initialize_plaidml(self) -> None: """ Initialize PlaidML on first call to this class and set global :attr:``_PLAIDML_INITIALIZED`` to ``True``. If PlaidML has already been initialized then From f8c1bf26dfe6cf04e3d328273a48ee4ea71d694e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 12 May 2022 12:12:00 +0100 Subject: [PATCH 565/981] Refactor apple-silicon - Merge metal/__init__ into gpu_stats.apple_silicon.py - Use psutil.virtual_memory().available to get free memory --- lib/gpu_stats/apple_silicon.py | 121 +++++++++++++++++++++++++-------- lib/metal/__init__.py | 76 --------------------- 2 files changed, 93 insertions(+), 104 deletions(-) delete mode 100644 lib/metal/__init__.py diff --git a/lib/gpu_stats/apple_silicon.py b/lib/gpu_stats/apple_silicon.py index 7dc39b106c..787eb7bc6b 100644 --- a/lib/gpu_stats/apple_silicon.py +++ b/lib/gpu_stats/apple_silicon.py @@ -1,18 +1,29 @@ #!/usr/bin/env python3 """ Collects and returns Information on available Apple Silicon SoCs in Apple Macs. """ -from typing import List +from typing import List, Optional -import lib.metal as metal +import os +import psutil +import tensorflow as tf from lib.utils import FaceswapError from ._base import GPUStats +_METAL_INITIALIZED: bool = False + + class AppleSiliconStats(GPUStats): """ Holds information and statistics about Apple Silicon SoC(s) available on the currently running Apple system. + Notes + ----- + Apple Silicon is a bit different from other backends, as it does not have a dedicated GPU with + it's own dedicated VRAM, rather the RAM is shared with the CPU and GPU. A combination of psutil + and Tensorflow are used to pull as much useful information as possible. + Parameters ---------- log: bool, optional @@ -22,6 +33,11 @@ class AppleSiliconStats(GPUStats): available then this parameter should be set to ``False``. Otherwise set to ``True``. Default: ``True`` """ + def __init__(self, log: bool = True) -> None: + # Following attribute set in :func:``_initialize`` + self._tf_devices: Optional(List[str]) = None + + super().__init__(log=log) def _initialize(self) -> None: """ Initialize Metal for Apple Silicon SoC(s). @@ -29,22 +45,56 @@ def _initialize(self) -> None: If :attr:`_is_initialized` is ``True`` then this function just returns performing no action. Otherwise :attr:`is_initialized` is set to ``True`` after successfully initializing Metal. + """ + if self._is_initialized: + return + self._log("debug", "Initializing Metal for Apple Silicon SoC.") + self._initialize_metal() + + self._tf_devices = tf.config.list_physical_devices(device_type="GPU") + + super()._initialize() + + def _initialize_metal(self) -> None: + """ Initialize Metal on first call to this class and set global + :attr:``_METAL_INITIALIZED`` to ``True``. If Metal has already been initialized then return + performing no action. + """ + global _METAL_INITIALIZED # pylint:disable=global-statement + + if _METAL_INITIALIZED: + return + + self._log("debug", "Performing first time Apple SoC setup.") + + os.environ["DISPLAY"] = ":0" + + try: + os.system("open -a XQuartz") + except Exception as err: # pylint:disable=broad-except + self._log("debug", f"Swallowing error opening XQuartz: {str(err)}") + + self._test_tensorflow() + + _METAL_INITIALIZED = True + + def _test_tensorflow(self) -> None: + """ Test that tensorflow can execute correctly. Raises ------ FaceswapError - If the Metal library could not be successfully loaded + If the Tensorflow library could not be successfully initialized """ - if self._is_initialized: - return - self._log("debug", "Initializing Metal for Apple Silicon SoC.") try: - metal.init() # pylint:disable=no-member + meminfo = tf.config.experimental.get_memory_info('GPU:0') + devices = tf.config.list_logical_devices() + self._log("debug", + f"Tensorflow initialization test: (mem_info: {meminfo}, devices: {devices}") except RuntimeError as err: - msg = ("An unhandled exception occured initializing the device via Metal" + msg = ("An unhandled exception occured initializing the device via Tensorflow " f"Library. Original error: {str(err)}") raise FaceswapError(msg) from err - super()._initialize() def _get_device_count(self) -> int: """ Detect the number of SoCs attached to the system. @@ -54,43 +104,54 @@ def _get_device_count(self) -> int: int The total number of SoCs available """ - retval = metal.get_device_count() # pylint:disable=no-member + retval = len(self._tf_devices) self._log("debug", f"GPU Device count: {retval}") return retval def _get_handles(self) -> list: """ Obtain the device handles for all available Apple Silicon SoCs. + Notes + ----- + Apple SoC does not use handles, so return a list of indices corresponding to found + GPU devices + Returns ------- list - The list of pointers for available Apple Silicon SoCs + The list of indices for available Apple Silicon SoCs """ - handles = metal.get_handles() # pylint:disable=no-member - self._log("debug", f"GPU Handles found: {len(handles)}") + handles = list(range(self._device_count)) + self._log("debug", f"GPU Handles found: {handles}") return handles def _get_driver(self) -> str: """ Obtain the Apple Silicon driver version currently in use. + Notes + ----- + As the SoC is not a discreet GPU it does not technically have a driver version, so just + return `'Not Applicable'` as a string + Returns ------- str The current SoC driver version """ - driver = metal.get_driver_version() # pylint:disable=no-member + driver = "Not Applicable" self._log("debug", f"GPU Driver: {driver}") return driver def _get_device_names(self) -> List[str]: - """ Obtain the list of names of available Apple Silicon SoC(s) as identified in :attr:`_handles`. + """ Obtain the list of names of available Apple Silicon SoC(s) as identified in + :attr:`_handles`. Returns ------- list The list of available Apple Silicon SoC names """ - names = metal.get_device_names() + names = [d.name for d in self._tf_devices] self._log("debug", f"GPU Devices: {names}") return names @@ -98,29 +159,33 @@ def _get_vram(self) -> List[float]: """ Obtain the VRAM in Megabytes for each available Apple Silicon SoC(s) as identified in :attr:`_handles`. + Notes + ----- + `tf.config.experimental.get_memory_info('GPU:0')` does not work, so uses psutil instead. + The total memory on the system is returned as it is shared between the CPU and the GPU. + There is no dedicated VRAM. + Returns ------- list - The VRAM in Megabytes for each available Apple Silicon SoC + The RAM in Megabytes for each available Apple Silicon SoC """ - vram = [ - metal.get_memory_info(i) / (1024 * 1024) - for i in range(self._device_count)] - self._log("debug", f"GPU VRAM: {vram}") + vram = [(psutil.virtual_memory().total / self._device_count) / (1024 * 1024) + for _ in range(self._device_count)] + self._log("debug", f"SoC RAM: {vram}") return vram def _get_free_vram(self) -> List[float]: - """ Obtain the amount of VRAM that is available, in Megabytes, for each available Apple Silicon - SoC. + """ Obtain the amount of VRAM that is available, in Megabytes, for each available Apple + Silicon SoC. Returns ------- list - List of `float`s containing the amount of VRAM available, in Megabytes, for each + List of `float`s containing the amount of RAM available, in Megabytes, for each available SoC as corresponding to the values in :attr:`_handles """ - vram = [ - metal.get_memory_info(i) / (1024 * 1024) - for i in range(self._device_count)] - self._log("debug", f"GPU VRAM free: {vram}") + vram = [(psutil.virtual_memory().available / self._device_count) / (1024 * 1024) + for _ in range(self._device_count)] + self._log("debug", f"SoC RAM free: {vram}") return vram diff --git a/lib/metal/__init__.py b/lib/metal/__init__.py deleted file mode 100644 index 738353c39b..0000000000 --- a/lib/metal/__init__.py +++ /dev/null @@ -1,76 +0,0 @@ -from typing import List -import os -import psutil # used for getting GPU memory -import tensorflow as tf - - -class Constants: - class System: - ARCH = 'arm64' - DEVICE_TYPE = 'GPU' - SET_MEMORY_GROWTH = True - - class CUDA: - DRIVER_VERSION_UNSUPPORTED = 0 - - -def _dbg_check_mem(): - print("==========================================") - print(tf.config.experimental.get_memory_info('GPU:0')) - print(tf.config.list_logical_devices()) - print("==========================================") - - -def _validate_metal(): - # Validate a GPU exists - assert(len(tf.config.experimental.list_physical_devices('GPU')) > 0) - - # Validate Metal device is working - with tf.device('GPU:0'): - assert(tf.math.add(1.0, 2.0) == 3.0) - - -def init(device_type: str = 'GPU') -> None: - # _validate_metal() - - os.environ['DISPLAY'] = ':0' - try: - os.system('open -a XQuartz') - except Exception: - pass - Constants.System.DEVICE_TYPE = device_type - - # for device in get_devices(): - # tf.config.experimental.set_memory_growth(device, Constants.System.SET_MEMORY_GROWTH) - - _dbg_check_mem() - - -def get_devices() -> List[tf.config.PhysicalDevice]: - return tf.config.list_physical_devices(device_type=Constants.System.DEVICE_TYPE) - - -def get_device_count() -> int: - return len(get_devices()) - - -def get_handles() -> list: - return list(range(get_device_count())) - - -def get_driver_version() -> int: - # https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART____VERSION.html - return Constants.CUDA.DRIVER_VERSION_UNSUPPORTED - - -def get_device_names() -> List[str]: - return [d.name for d in get_devices()] - - -def get_memory_info(handle: int) -> int: - # Does not work: - # tf.config.experimental.get_memory_info('GPU:0') - # So, using psutil instead. - # We can just grab the total memory, as it's shared between - # the CPU and the GPU. There is no dedicated VRAM. - return psutil.virtual_memory().total / get_device_count() From fe5c6058273f03488d5b5c160ce8034779da5032 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 12 May 2022 12:29:29 +0100 Subject: [PATCH 566/981] Update docs --- INSTALL.md | 31 ++++++++++++++++--------------- docs/full/lib/gpu_stats.rst | 8 ++++++++ 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 074ffdc7c8..3857340051 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -33,7 +33,7 @@ - [Notes](#notes) # Prerequisites -Machine learning essentially involves a ton of trial and error. You're letting a program try millions of different settings to land on an algorithm that sort of does what you want it to do. This process is really really slow unless you have the hardware required to speed this up. +Machine learning essentially involves a ton of trial and error. You're letting a program try millions of different settings to land on an algorithm that sort of does what you want it to do. This process is really really slow unless you have the hardware required to speed this up. The type of computations that the process does are well suited for graphics cards, rather than regular processors. **It is pretty much required that you run the training process on a desktop or server capable GPU.** Running this on your CPU means it can take weeks to train your model, compared to several hours on a GPU. @@ -53,15 +53,16 @@ The type of computations that the process does are well suited for graphics card - **Windows 10** Windows 7 and 8 might work. Your mileage may vary. Windows has an installer which will set up everything you need. See: https://github.com/deepfakes/faceswap/releases - **Linux** - Most Ubuntu/Debian or CentOS based Linux distributions will work. + Most Ubuntu/Debian or CentOS based Linux distributions will work. There is a Linux install script that will install and set up everything you need. See: https://github.com/deepfakes/faceswap/releases - **macOS** - WIP port for GPU-accelerated, native Apple Silicon processing. + Experimental support for GPU-accelerated, native Apple Silicon processing (e.g. Apple M1 chips). Installation instructions can be found [further down this page](#macos-apple-silicon-install-guide). + Intel based macOS systems should work, but you will need to follow the [Manual Install](#manual-install) instructions. - All operating systems must be 64-bit for Tensorflow to run. Alternatively, there is a docker image that is based on Debian. # Important before you proceed -**In its current iteration, the project relies heavily on the use of the command line, although a gui is available. if you are unfamiliar with command line tools, you may have difficulty setting up the environment and should perhaps not attempt any of the steps described in this guide.** This guide assumes you have intermediate knowledge of the command line. +**In its current iteration, the project relies heavily on the use of the command line, although a gui is available. if you are unfamiliar with command line tools, you may have difficulty setting up the environment and should perhaps not attempt any of the steps described in this guide.** This guide assumes you have intermediate knowledge of the command line. The developers are also not responsible for any damage you might cause to your own computer. @@ -191,7 +192,7 @@ Obtain git for your distribution from the [git website](https://git-scm.com/down ### Python The recommended install method is to use a Conda3 Environment as this will handle the installation of Nvidia's CUDA and cuDNN straight into your Conda Environment. This is by far the easiest and most reliable way to setup the project. - MiniConda3 is recommended: [MiniConda3](https://docs.conda.io/en/latest/miniconda.html) - + Alternatively you can install Python (>= 3.7-3.8 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Tensorflow yourself, make sure you install the correct Cuda and cuDNN package for the currently installed version of Tensorflow (Current release: Tensorflow 2.2. Release v1.0: Tensorflow 1.15). You can check for the compatible versions here: (https://www.tensorflow.org/install/source#gpu). - Python distributions: - apt/yum install python3 (Linux) @@ -232,32 +233,32 @@ INFO The tool provides tips for installation INFO Setup in Linux 4.14.39-1-MANJARO INFO Installed Python: 3.7.5 64bit INFO Installed PIP: 10.0.1 -Enable Docker? [Y/n] +Enable Docker? [Y/n] INFO Docker Enabled -Enable CUDA? [Y/n] +Enable CUDA? [Y/n] INFO CUDA Enabled INFO 1. Install Docker https://www.docker.com/community-edition - + 1. Install Nvidia-Docker & Restart Docker Service https://github.com/NVIDIA/nvidia-docker - + 1. Build Docker Image For faceswap docker build -t deepfakes-gpu -f Dockerfile.gpu . - + 1. Mount faceswap volume and Run it # without gui. tools.py gui not working. nvidia-docker run --rm -it -p 8888:8888 \ --hostname faceswap-gpu --name faceswap-gpu \ -v /opt/faceswap:/srv \ deepfakes-gpu - + # with gui. tools.py gui working. ## enable local access to X11 server xhost +local: ## enable nvidia device if working under bumblebee echo ON > /proc/acpi/bbswitch - ## create container + ## create container nvidia-docker run -p 8888:8888 \ --hostname faceswap-gpu --name faceswap-gpu \ -v /opt/faceswap:/srv \ @@ -268,7 +269,7 @@ INFO 1. Install Docker -e GID=`id -g` \ -e UID=`id -u` \ deepfakes-gpu - + 1. Open a new terminal to interact with the project docker exec -it deepfakes-gpu /bin/bash # Launch deepfakes gui (Answer 3 for NVIDIA at the prompt) @@ -284,7 +285,7 @@ INFO Installed Python: 3.7.5 64bit INFO Installed PIP: 10.0.1 Enable Docker? [Y/n] n INFO Docker Disabled -Enable CUDA? [Y/n] +Enable CUDA? [Y/n] INFO CUDA Enabled INFO CUDA version: 9.1 INFO cuDNN version: 7 @@ -317,6 +318,6 @@ python faceswap.py gui Proceed to [../blob/master/USAGE.md](USAGE.md) ## Notes -This guide is far from complete. Functionality may change over time, and new dependencies are added and removed as time goes on. +This guide is far from complete. Functionality may change over time, and new dependencies are added and removed as time goes on. If you are experiencing issues, please raise them in the [faceswap Forum](https://faceswap.dev/forum) instead of the main repo. Usage questions raised in the issues within this repo are liable to be closed without response. diff --git a/docs/full/lib/gpu_stats.rst b/docs/full/lib/gpu_stats.rst index 8dbef800e3..6f6aaa309a 100755 --- a/docs/full/lib/gpu_stats.rst +++ b/docs/full/lib/gpu_stats.rst @@ -14,6 +14,14 @@ gpu_stats._base module :undoc-members: :show-inheritance: +gpu_stats.apple_silicon module +------------------------------ + +.. automodule:: lib.gpu_stats.apple_silicon + :members: + :undoc-members: + :show-inheritance: + gpu_stats.amd module -------------------- From d83a39716ba5d663d1e9ad73dac01b4f755d8846 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 12 May 2022 12:40:53 +0100 Subject: [PATCH 567/981] minor cleanup - Consistent naming for backend selection - Remove apple-silicon from setup.py (not yet implemented) --- conda-environment-apple-silicon.yml | 2 +- lib/utils.py | 2 +- setup.py | 6 ++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/conda-environment-apple-silicon.yml b/conda-environment-apple-silicon.yml index 0bd9818ac4..16ee99fc0d 100644 --- a/conda-environment-apple-silicon.yml +++ b/conda-environment-apple-silicon.yml @@ -21,4 +21,4 @@ dependencies: - matplotlib>=3.2.0,<3.3.0 - imageio>=2.9.0 - imageio-ffmpeg>=0.4.7 - - ffmpy==0.2.3 \ No newline at end of file + - ffmpy==0.2.3 diff --git a/lib/utils.py b/lib/utils.py index 45c48ab4f6..7f1f47458a 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -92,7 +92,7 @@ def _configure_backend(self): """ print("First time configuration. Please select the required backend") while True: - selection = input("1: AMD, 2: CPU, 3: NVIDIA, 4: Apple Silicon: ") + selection = input("1: AMD, 2: CPU, 3: NVIDIA, 4: APPLE SILICON: ") if selection not in ("1", "2", "3", "4"): print(f"'{selection}' is not a valid selection. Please try again") continue diff --git a/setup.py b/setup.py index 0dacf7f360..79c66975bb 100755 --- a/setup.py +++ b/setup.py @@ -287,8 +287,6 @@ def set_config(self): backend = "amd" elif self.enable_cuda: backend = "nvidia" - elif self.enable_apple: - backend = "apple_silicon" else: backend = "cpu" config = {"backend": backend} @@ -322,8 +320,8 @@ def set_ld_library_path(self): os.makedirs(activate_folder, exist_ok=True) os.makedirs(deactivate_folder, exist_ok=True) - activate_script = os.path.join(conda_prefix, activate_folder, f"env_vars.sh") - deactivate_script = os.path.join(conda_prefix, deactivate_folder, f"env_vars.sh") + activate_script = os.path.join(conda_prefix, activate_folder, "env_vars.sh") + deactivate_script = os.path.join(conda_prefix, deactivate_folder, "env_vars.sh") if os.path.isfile(activate_script): # Only create file if it does not already exist. There may be instances where people From 5dfc9c03c90d899aa10a6d85aef005c379c59bf1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 12 May 2022 15:57:07 +0100 Subject: [PATCH 568/981] bugfix: fix import order --- lib/gpu_stats/_base.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/gpu_stats/_base.py b/lib/gpu_stats/_base.py index 8df7c90e58..286de83160 100644 --- a/lib/gpu_stats/_base.py +++ b/lib/gpu_stats/_base.py @@ -11,9 +11,10 @@ from lib.utils import get_backend if sys.version_info < (3, 8): - from typing import TypedDict -else: from typing_extensions import TypedDict +else: + from typing import TypedDict + _EXCLUDE_DEVICES: List[int] = [] From 0f7ee1603f093e70496da1585f137f268c0c5f87 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 13 May 2022 12:08:25 +0100 Subject: [PATCH 569/981] training - Enable resize in popup preview image --- scripts/train.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/scripts/train.py b/scripts/train.py index 70d5f05cd5..2fd9e7d246 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -51,7 +51,7 @@ def __init__(self, arguments): self._save_now = False self._toggle_preview_mask = False self._refresh_preview = False - self._preview_buffer = dict() + self._preview_buffer = {} self._lock = Lock() logger.debug("Initialized %s", self.__class__.__name__) @@ -66,9 +66,9 @@ def _get_images(self): for that side. """ logger.debug("Getting image paths") - images = dict() + images = {} for side in ("a", "b"): - image_dir = getattr(self._args, "input_{}".format(side)) + image_dir = getattr(self._args, f"input_{side}") if not os.path.isdir(image_dir): logger.error("Error: '%s' does not exist", image_dir) sys.exit(1) @@ -362,19 +362,24 @@ def _monitor(self, thread): logger.info(" Using live preview") if sys.stdout.isatty(): logger.info(" Press '%s' to save and quit", - "Stop" if self._args.redirect_gui or self._args.colab else "ENTER") + "Stop" if self._args.redirect_gui or self._args.colab else "ENTER") if not self._args.redirect_gui and not self._args.colab and sys.stdout.isatty(): logger.info(" Press 'S' to save model weights immediately") logger.info("===================================================") keypress = KBHit(is_gui=self._args.redirect_gui) + window_created = False err = False while True: try: if self._args.preview: with self._lock: for name, image in self._preview_buffer.items(): + if not window_created: + self._create_resizable_window(name, image.shape) cv2.imshow(name, image) # pylint: disable=no-member + if not window_created: + window_created = bool(self._preview_buffer) cv_key = cv2.waitKey(1000) # pylint: disable=no-member else: cv_key = None @@ -412,6 +417,20 @@ def _monitor(self, thread): logger.debug("Closed Monitor") return err + @classmethod + def _create_resizable_window(cls, name: str, image_shape: tuple) -> None: + """ Create a resizable OpenCV window to hold the preview image. + + name: str + The name to display in the window header and for window identification + shape: tuple + The (`rows`, `columns`, `channels`) of the image to be displayed + """ + logger.debug("Creating named window '%s' for image shape %s", name, image_shape) + height, width = image_shape[:2] + cv2.namedWindow(name, cv2.WINDOW_GUI_EXPANDED) + cv2.resizeWindow(name, width, height) + def _preview_monitor(self, key_press): """ Monitors keyboard presses on the pop-up OpenCV Preview Window. From b11ed6ead09cab432660ec5d2e72525857f803d3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 13 May 2022 12:10:51 +0100 Subject: [PATCH 570/981] typofix --- scripts/train.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/train.py b/scripts/train.py index 2fd9e7d246..0ddc594808 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -421,6 +421,8 @@ def _monitor(self, thread): def _create_resizable_window(cls, name: str, image_shape: tuple) -> None: """ Create a resizable OpenCV window to hold the preview image. + Parameters + ---------- name: str The name to display in the window header and for window identification shape: tuple From 60291d49c4da1cd260fbc0b04aa6a312eedfefbb Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 14 May 2022 02:12:46 +0100 Subject: [PATCH 571/981] ffmpeg writer: Create new filename if output pre-exists --- docs/full/plugins/convert.rst | 19 +++ plugins/convert/writer/_base.py | 128 ++++++++++++++---- plugins/convert/writer/ffmpeg.py | 224 ++++++++++++++++++++----------- 3 files changed, 265 insertions(+), 106 deletions(-) diff --git a/docs/full/plugins/convert.rst b/docs/full/plugins/convert.rst index a6a4719558..6a5bd32aa8 100755 --- a/docs/full/plugins/convert.rst +++ b/docs/full/plugins/convert.rst @@ -33,3 +33,22 @@ mask.mask_blend module :members: :undoc-members: :show-inheritance: + +writer package +============== + +writer._base module +------------------- + +.. automodule:: plugins.convert.writer._base + :members: + :undoc-members: + :show-inheritance: + +writer.ffmpeg module +-------------------- + +.. automodule:: plugins.convert.writer.ffmpeg + :members: + :undoc-members: + :show-inheritance: diff --git a/plugins/convert/writer/_base.py b/plugins/convert/writer/_base.py index 5e2438434d..053bf92ad5 100644 --- a/plugins/convert/writer/_base.py +++ b/plugins/convert/writer/_base.py @@ -5,65 +5,139 @@ import os import re +from typing import Optional + from plugins.convert._config import Config logger = logging.getLogger(__name__) # pylint: disable=invalid-name -def get_config(plugin_name, configfile=None): - """ Return the config for the requested model """ +def get_config(plugin_name: str, configfile: Optional[str] = None) -> dict: + """ Obtain the configuration settings for the writer plugin. + + Parameters + ---------- + plugin_name: str + The name of the convert plugin to return configuration settings for + configfile: str, optional + The full path to a custom configuration ini file. If ``None`` is passed + then the file is loaded from the default location. Default: ``None``. + + Returns + ------- + dict + The requested configuration dictionary + """ return Config(plugin_name, configfile=configfile).config_dict class Output(): - """ Parent class for scaling adjustments """ - def __init__(self, output_folder, configfile=None): + """ Parent class for writer plugins. + + Parameters + ---------- + output_folder: str + The full path to the output folder where the converted media should be saved + configfile: str, optional + The full path to a custom configuration ini file. If ``None`` is passed + then the file is loaded from the default location. Default: ``None``. + """ + def __init__(self, output_folder: str, configfile: Optional[str] = None) -> None: logger.debug("Initializing %s: (output_folder: '%s')", self.__class__.__name__, output_folder) - self.config = get_config(".".join(self.__module__.split(".")[-2:]), configfile=configfile) + self.config: dict = get_config(".".join(self.__module__.split(".")[-2:]), + configfile=configfile) logger.debug("config: %s", self.config) - self.output_folder = output_folder - self.output_dimensions = None + self.output_folder: str = output_folder # Methods for making sure frames are written out in frame order - self.re_search = re.compile(r"(\d+)(?=\.\w+$)") # Identify frame numbers - self.cache = dict() # Cache for when frames must be written in correct order + self.re_search: re.Pattern = re.compile(r"(\d+)(?=\.\w+$)") # Identify frame numbers + self.cache: dict = {} # Cache for when frames must be written in correct order logger.debug("Initialized %s", self.__class__.__name__) @property - def is_stream(self): - """ Return whether the writer is a stream or images - Writers that write to a stream have a frame_order paramater to dictate - the order in which frames should be written out (eg. gif/ffmpeg) """ + def is_stream(self) -> bool: + """ bool: Whether the writer outputs a stream or a series images. + + Writers that write to a stream have a frame_order paramater to dictate + the order in which frames should be written out (eg. gif/ffmpeg) """ retval = hasattr(self, "frame_order") return retval - def output_filename(self, filename): - """ Return the output filename with the correct folder and extension - NB: The plugin must have a config item 'format' that contains the - file extension to use this method """ + def output_filename(self, filename: str) -> str: + """ Obtain the full path for the output file, including the correct extension, for the + given input filename. + + NB: The plugin must have a config item 'format' that contains the file extension to use + this method. + + Parameters + ---------- + filename: str + The input frame filename to generate the output file name for + + Returns + ------- + str + The full path for the output converted frame to be saved to. + """ filename = os.path.splitext(os.path.basename(filename))[0] - out_filename = "{}.{}".format(filename, self.config["format"]) + out_filename = f"{filename}.{self.config['format']}" out_filename = os.path.join(self.output_folder, out_filename) logger.trace("in filename: '%s', out filename: '%s'", filename, out_filename) return out_filename - def cache_frame(self, filename, image): - """ Add the incoming frame to the cache """ + def cache_frame(self, filename, image) -> None: + """ Add the incoming converted frame to the cache ready for writing out. + + Used for ffmpeg and gif writers to ensure that the frames are written out in the correct + order. + + Parameters + ---------- + filename: str + The filename of the incoming frame, where the frame index can be extracted from + image: class:`numpy.ndarray` + The converted frame corresponding to the given filename + """ frame_no = int(re.search(self.re_search, filename).group()) self.cache[frame_no] = image logger.trace("Added to cache. Frame no: %s", frame_no) logger.trace("Current cache: %s", sorted(self.cache.keys())) - def write(self, filename, image): - """ Override for specific frame writing method """ + def write(self, filename: str, image) -> None: + """ Override for specific frame writing method. + + Parameters + ---------- + filename: str + The incoming frame filename. + image: :class:`numpy.ndarray` + The converted image to be written + """ raise NotImplementedError - def pre_encode(self, image): # pylint: disable=unused-argument,no-self-use - """ If the writer supports pre-encoding then override this to pre-encode - the image in lib/convert.py to speed up saving """ + def pre_encode(self, image) -> None: # pylint: disable=unused-argument,no-self-use + """ Some writer plugins support the pre-encoding of images prior to saving out. As + patching is done in multiple threads, but writing is done in a single thread, it can + speed up the process to do any pre-encoding as part of the converter process. + + If the writer supports pre-encoding then override this to pre-encode the image in + :module:`lib.convert` to speed up saving. + + Parameters + ---------- + image: :class:`numpy.ndarray` + The converted image that is to be run through the pre-encoding function + + Returns + ------- + python function or ``None`` + If ``None`` then the writer does not support pre-encoding, otherwise return the python + function that will pre-encode the image + """ return None - def close(self): - """ Override for specific frame writing close methods """ + def close(self) -> None: + """ Override for specific converted frame writing close methods """ raise NotImplementedError diff --git a/plugins/convert/writer/ffmpeg.py b/plugins/convert/writer/ffmpeg.py index 4031f00de8..a1b802924b 100644 --- a/plugins/convert/writer/ffmpeg.py +++ b/plugins/convert/writer/ffmpeg.py @@ -3,6 +3,7 @@ import os from collections import OrderedDict from math import ceil +from typing import Optional, List import imageio import imageio_ffmpeg as im_ffm @@ -12,64 +13,77 @@ class Writer(Output): - """ Video output writer using imageio """ - def __init__(self, output_folder, total_count, frame_ranges, source_video, **kwargs): + """ Video output writer using imageio-ffmpeg. + + Parameters + ---------- + output_folder: str + The folder to save the output video to + total_count: int + The total number of frames to be converted + frame_ranges: list or ``None`` + List of integers for any explicit frame ranges to be converted or ``None`` if all frames + are to be converted + source_video: str + The full path to the source video for obtaining fps and audio + kwargs: dict + Any additional standard :class:`plugins.convert.writer._base.Output` key word arguments. + """ + def __init__(self, + output_folder: str, + total_count: int, + frame_ranges: Optional[List[int]], + source_video: str, + **kwargs) -> None: super().__init__(output_folder, **kwargs) logger.debug("total_count: %s, frame_ranges: %s, source_video: '%s'", total_count, frame_ranges, source_video) - self.source_video = source_video - self.frame_ranges = frame_ranges - self.frame_order = self.set_frame_order(total_count) - self.output_dimensions = None # Fix dims of 1st frame in case of different sized images - self.writer = None # Need to know dimensions of first frame, so set writer then - - @property - def video_file(self): - """ Return full path to video output """ - filename = os.path.basename(self.source_video) - filename = os.path.splitext(filename)[0] - filename = "{}_converted.{}".format(filename, self.config["container"]) - retval = os.path.join(self.output_folder, filename) - logger.debug(retval) - return retval + self._source_video: str = source_video + self._output_filename: str = self._get_output_filename() + self._frame_ranges: Optional[List[int]] = frame_ranges + self._frame_order: List[int] = self._set_frame_order(total_count) + self._output_dimensions: Optional[str] = None # Fix dims on 1st received frame + # Need to know dimensions of first frame, so set writer then + self._writer: Optional[imageio.plugins.ffmpeg.FfmpegFormat.Writer] = None @property - def video_tmp_file(self): - """ Temporary video file, prior to muxing final audio """ - path, filename = os.path.split(self.video_file) - retval = os.path.join(path, "__tmp_{}".format(filename)) + def _video_tmp_file(self) -> str: + """ str: Full path to the temporary video file that is generated prior to muxing final + audio. """ + path, filename = os.path.split(self._output_filename) + retval = os.path.join(path, f"__tmp_{filename}") logger.debug(retval) return retval @property - def valid_tune(self): - """ Return whether selected tune is valid for selected codec """ + def _valid_tunes(self) -> dict: + """ dict: Valid tune selections for libx264 and libx265 codecs. """ return {"libx264": ["film", "animation", "grain", "stillimage", "fastdecode", "zerolatency"], "libx265": ["grain", "fastdecode", "zerolatency"]} @property - def video_fps(self): - """ Return the fps of source video """ - reader = imageio.get_reader(self.source_video, "ffmpeg") + def _video_fps(self) -> float: + """ float: The fps of the source video. """ + reader = imageio.get_reader(self._source_video, "ffmpeg") retval = reader.get_meta_data()["fps"] reader.close() logger.debug(retval) return retval @property - def output_params(self): - """ FFMPEG Output parameters """ + def _output_params(self) -> List[str]: + """ list: The FFMPEG Output parameters """ codec = self.config["codec"] tune = self.config["tune"] # Force all frames to the same size - output_args = ["-vf", "scale={}".format(self.output_dimensions)] + output_args = ["-vf", f"scale={self._output_dimensions}"] output_args.extend(["-c:v", codec]) output_args.extend(["-crf", str(self.config["crf"])]) output_args.extend(["-preset", self.config["preset"]]) - if tune is not None and tune in self.valid_tune[codec]: + if tune is not None and tune in self._valid_tunes[codec]: output_args.extend(["-tune", tune]) if codec == "libx264" and self.config["profile"] != "auto": @@ -81,77 +95,129 @@ def output_params(self): logger.debug(output_args) return output_args - def set_frame_order(self, total_count): - """ Return the full list of frames to be converted in order """ - if self.frame_ranges is None: + def _get_output_filename(self) -> str: + """ Return full path to video output file. + + The filename is the same as the input video with `"_converted"` appended to the end. The + file extension is as selected in the plugin settings. If a file already exists with the + given filename, then `"_1"` is appended to the end of the filename. This number iterates + until a valid filename that does not exist is found. + + Returns + ------- + str + The full path to the output video filename + """ + filename = os.path.basename(self._source_video) + filename = os.path.splitext(filename)[0] + ext = self.config["container"] + idx = 0 + while True: + out_file = f"{filename}_converted{'' if idx == 0 else f'_{idx}'}.{ext}" + retval = os.path.join(self.output_folder, out_file) + if not os.path.exists(retval): + break + idx += 1 + logger.info("Outputting to: '%s'", retval) + return retval + + def _set_frame_order(self, total_count: int) -> List[int]: + """ Obtain the full list of frames to be converted in order. + + Parameters + ---------- + total_count: int + The total number of frames to be converted + + Returns + ------- + list + Full list of all frame indices to be converted + """ + if self._frame_ranges is None: retval = list(range(1, total_count + 1)) else: - retval = list() - for rng in self.frame_ranges: + retval = [] + for rng in self._frame_ranges: retval.extend(list(range(rng[0], rng[1] + 1))) logger.debug("frame_order: %s", retval) return retval - def get_writer(self): - """ Add the requested encoding options and return the writer """ + def _get_writer(self) -> imageio.plugins.ffmpeg.FfmpegFormat.Writer: + """ Add the requested encoding options and return the writer. + + Returns + ------- + :class:`imageio.plugins.ffmpeg.FfmpegFormat.Writer` + The imageio ffmpeg writer + """ logger.debug("writer config: %s", self.config) - return imageio.get_writer(self.video_tmp_file, - fps=self.video_fps, + return imageio.get_writer(self._video_tmp_file, + fps=self._video_fps, ffmpeg_log_level="error", quality=None, macro_block_size=8, - output_params=self.output_params) + output_params=self._output_params) - def write(self, filename, image): - """ Frames come from the pool in arbitrary order, so cache frames - for writing out in correct order """ + def write(self, filename: str, image) -> None: + """ Frames come from the pool in arbitrary order, so frames are cached for writing out + in the correct order. + + Parameters + ---------- + filename: str + The incoming frame filename. + image: :class:`numpy.ndarray` + The converted image to be written + """ logger.trace("Received frame: (filename: '%s', shape: %s", filename, image.shape) - if not self.output_dimensions: - logger.info("Outputting to: '%s'", self.video_file) - self.set_dimensions(image.shape[:2]) - self.writer = self.get_writer() + if not self._output_dimensions: + self._set_dimensions(image.shape[:2]) + self._writer = self._get_writer() self.cache_frame(filename, image) - self.save_from_cache() + self._save_from_cache() - def set_dimensions(self, frame_dims): - """ Set the dimensions based on a given frame frame. This protects against different - sized images coming in and ensure all images go out at the same size for writers - that require it and mapped to a macro block size 16""" + def _set_dimensions(self, frame_dims) -> None: + """ Set the attribute :attr:`_output_dimensions` based on the first frame received. + This protects against different sized images coming in and ensures all images are written + to ffmpeg at the same size. Dimensions are mapped to a macro block size 16. """ logger.debug("input dimensions: %s", frame_dims) - self.output_dimensions = "{}:{}".format( - int(ceil(frame_dims[1] / 16) * 16), - int(ceil(frame_dims[0] / 16) * 16)) - logger.debug("Set dimensions: %s", self.output_dimensions) - - def save_from_cache(self): - """ Save all the frames that are ready to be output from cache """ - while self.frame_order: - if self.frame_order[0] not in self.cache: + self._output_dimensions = (f"{int(ceil(frame_dims[1] / 16) * 16)}:" + f"{int(ceil(frame_dims[0] / 16) * 16)}") + logger.debug("Set dimensions: %s", self._output_dimensions) + + def _save_from_cache(self) -> None: + """ Writes any consecutive frames to the video container that are ready to be output + from the cache. """ + while self._frame_order: + if self._frame_order[0] not in self.cache: logger.trace("Next frame not ready. Continuing") break - save_no = self.frame_order.pop(0) + save_no = self._frame_order.pop(0) save_image = self.cache.pop(save_no) logger.trace("Rendering from cache. Frame no: %s", save_no) - self.writer.append_data(save_image[:, :, ::-1]) + self._writer.append_data(save_image[:, :, ::-1]) logger.trace("Current cache size: %s", len(self.cache)) - def close(self): + def close(self) -> None: """ Close the ffmpeg writer and mux the audio """ - self.writer.close() - self.mux_audio() + self._writer.close() + self._mux_audio() + + def _mux_audio(self) -> None: + """ Mux audio the audio to the generated video temp file. - def mux_audio(self): - """ Mux audio ImageIO is a useful lib for frames > video as it also packages the ffmpeg binary however muxing audio is non-trivial, so this is done afterwards with ffmpy. - A future fix could be implemented to mux audio with the frames """ + + # TODO A future fix could be implemented to mux audio with the frames """ if self.config["skip_mux"]: logger.info("Skipping audio muxing due to configuration settings.") self._rename_tmp_file() return logger.info("Muxing Audio...") - if self.frame_ranges is not None: + if self._frame_ranges is not None: logger.warning("Muxing audio is not currently supported for limited frame ranges." "The output video has been created but you will need to mux audio " "yourself") @@ -159,8 +225,8 @@ def mux_audio(self): return exe = im_ffm.get_ffmpeg_exe() - inputs = OrderedDict([(self.video_tmp_file, None), (self.source_video, None)]) - outputs = {self.video_file: "-map 0:v:0 -map 1:a:0 -c: copy"} + inputs = OrderedDict([(self._video_tmp_file, None), (self._source_video, None)]) + outputs = {self._output_filename: "-map 0:v:0 -map 1:a:0 -c: copy"} ffm = FFmpeg(executable=exe, global_options="-hide_banner -nostats -v 0 -y", inputs=inputs, @@ -180,15 +246,15 @@ def mux_audio(self): logger.error("There was a problem muxing audio. The output video has been " "created but you will need to mux audio yourself either with the " "EFFMpeg tool or an external application.") - os.rename(self.video_tmp_file, self.video_file) + os.rename(self._video_tmp_file, self._output_filename) break logger.debug("Removing temp file") - if os.path.isfile(self.video_tmp_file): - os.remove(self.video_tmp_file) + if os.path.isfile(self._video_tmp_file): + os.remove(self._video_tmp_file) - def _rename_tmp_file(self): + def _rename_tmp_file(self) -> None: """ Rename the temporary video file if not muxing audio. """ - os.rename(self.video_tmp_file, self.video_file) + os.rename(self._video_tmp_file, self._output_filename) logger.debug("Removing temp file") - if os.path.isfile(self.video_tmp_file): - os.remove(self.video_tmp_file) + if os.path.isfile(self._video_tmp_file): + os.remove(self._video_tmp_file) From e0e2779bd3ab59b7bab2ee7ebaaa567d543173c2 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 14 May 2022 12:15:50 +0100 Subject: [PATCH 572/981] gif writer: Create new filename if output pre-exists --- docs/full/plugins/convert.rst | 8 ++ plugins/convert/writer/_base.py | 2 +- plugins/convert/writer/ffmpeg.py | 18 ++-- plugins/convert/writer/gif.py | 160 ++++++++++++++++++++++--------- 4 files changed, 134 insertions(+), 54 deletions(-) diff --git a/docs/full/plugins/convert.rst b/docs/full/plugins/convert.rst index 6a5bd32aa8..a67ee74ad1 100755 --- a/docs/full/plugins/convert.rst +++ b/docs/full/plugins/convert.rst @@ -52,3 +52,11 @@ writer.ffmpeg module :members: :undoc-members: :show-inheritance: + +writer.gif module +----------------- + +.. automodule:: plugins.convert.writer.gif + :members: + :undoc-members: + :show-inheritance: diff --git a/plugins/convert/writer/_base.py b/plugins/convert/writer/_base.py index 053bf92ad5..1be1e9c04f 100644 --- a/plugins/convert/writer/_base.py +++ b/plugins/convert/writer/_base.py @@ -123,7 +123,7 @@ def pre_encode(self, image) -> None: # pylint: disable=unused-argument,no-self- speed up the process to do any pre-encoding as part of the converter process. If the writer supports pre-encoding then override this to pre-encode the image in - :module:`lib.convert` to speed up saving. + :mod:`lib.convert` to speed up saving. Parameters ---------- diff --git a/plugins/convert/writer/ffmpeg.py b/plugins/convert/writer/ffmpeg.py index a1b802924b..cc70c88c71 100644 --- a/plugins/convert/writer/ffmpeg.py +++ b/plugins/convert/writer/ffmpeg.py @@ -3,7 +3,7 @@ import os from collections import OrderedDict from math import ceil -from typing import Optional, List +from typing import Optional, List, Tuple import imageio import imageio_ffmpeg as im_ffm @@ -22,8 +22,8 @@ class Writer(Output): total_count: int The total number of frames to be converted frame_ranges: list or ``None`` - List of integers for any explicit frame ranges to be converted or ``None`` if all frames - are to be converted + List of tuples for starting and end values of each frame range to be converted or ``None`` + if all frames are to be converted source_video: str The full path to the source video for obtaining fps and audio kwargs: dict @@ -32,7 +32,7 @@ class Writer(Output): def __init__(self, output_folder: str, total_count: int, - frame_ranges: Optional[List[int]], + frame_ranges: Optional[List[Tuple[int]]], source_video: str, **kwargs) -> None: super().__init__(output_folder, **kwargs) @@ -40,8 +40,8 @@ def __init__(self, total_count, frame_ranges, source_video) self._source_video: str = source_video self._output_filename: str = self._get_output_filename() - self._frame_ranges: Optional[List[int]] = frame_ranges - self._frame_order: List[int] = self._set_frame_order(total_count) + self._frame_ranges: Optional[List[Tuple[int]]] = frame_ranges + self.frame_order: List[int] = self._set_frame_order(total_count) self._output_dimensions: Optional[str] = None # Fix dims on 1st received frame # Need to know dimensions of first frame, so set writer then self._writer: Optional[imageio.plugins.ffmpeg.FfmpegFormat.Writer] = None @@ -189,11 +189,11 @@ def _set_dimensions(self, frame_dims) -> None: def _save_from_cache(self) -> None: """ Writes any consecutive frames to the video container that are ready to be output from the cache. """ - while self._frame_order: - if self._frame_order[0] not in self.cache: + while self.frame_order: + if self.frame_order[0] not in self.cache: logger.trace("Next frame not ready. Continuing") break - save_no = self._frame_order.pop(0) + save_no = self.frame_order.pop(0) save_image = self.cache.pop(save_no) logger.trace("Rendering from cache. Frame no: %s", save_no) self._writer.append_data(save_image[:, :, ::-1]) diff --git a/plugins/convert/writer/gif.py b/plugins/convert/writer/gif.py index b6f44fdf29..fa9bfe0c52 100644 --- a/plugins/convert/writer/gif.py +++ b/plugins/convert/writer/gif.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """ Animated GIF writer for faceswap.py converter """ import os +from typing import Optional, List, Tuple import cv2 import imageio @@ -9,76 +10,147 @@ class Writer(Output): - """ Video output writer using imageio """ - def __init__(self, output_folder, total_count, frame_ranges, **kwargs): + """ GIF output writer using imageio. + + + Parameters + ---------- + output_folder: str + The folder to save the output gif to + total_count: int + The total number of frames to be converted + frame_ranges: list or ``None`` + List of tuples for starting and end values of each frame range to be converted or ``None`` + if all frames are to be converted + kwargs: dict + Any additional standard :class:`plugins.convert.writer._base.Output` key word arguments. + """ + def __init__(self, + output_folder: str, + total_count: int, + frame_ranges: Optional[List[Tuple[int]]], + **kwargs) -> None: logger.debug("total_count: %s, frame_ranges: %s", total_count, frame_ranges) super().__init__(output_folder, **kwargs) - self.frame_order = self.set_frame_order(total_count, frame_ranges) - self.output_dimensions = None # Fix dims of 1st frame in case of different sized images - self.writer = None # Need to know dimensions of first frame, so set writer then - self.gif_file = None # Set filename based on first file seen + self.frame_order: List[int] = self._set_frame_order(total_count, frame_ranges) + self._output_dimensions: Optional[str] = None # Fix dims on 1st received frame + # Need to know dimensions of first frame, so set writer then + self._writer: Optional[imageio.plugins.pillowmulti.GIFFormat.Writer] = None + self._gif_file: Optional[str] = None # Set filename based on first file seen @property - def gif_params(self): - """ Format the gif params """ + def _gif_params(self) -> dict: + """ dict: The selected gif plugin configuration options. """ kwargs = {key: int(val) for key, val in self.config.items()} logger.debug(kwargs) return kwargs @staticmethod - def set_frame_order(total_count, frame_ranges): - """ Return the full list of frames to be converted in order """ + def _set_frame_order(total_count: int, frame_ranges: Optional[List[Tuple[int]]]) -> List[int]: + """ Obtain the full list of frames to be converted in order. + + Parameters + ---------- + total_count: int + The total number of frames to be converted + frame_ranges: list or ``None`` + List of tuples for starting and end values of each frame range to be converted or + ``None`` if all frames are to be converted + + Returns + ------- + list + Full list of all frame indices to be converted + """ if frame_ranges is None: retval = list(range(1, total_count + 1)) else: - retval = list() + retval = [] for rng in frame_ranges: retval.extend(list(range(rng[0], rng[1] + 1))) logger.debug("frame_order: %s", retval) return retval - def get_writer(self): - """ Add the requested encoding options and return the writer """ + def _get_writer(self) -> imageio.plugins.pillowmulti.GIFFormat.Writer: + """ Obtain the GIF writer with the requested GIF encoding options. + + Returns + ------- + :class:`imageio.plugins.pillowmulti.GIFFormat.Writer` + The imageio GIF writer + """ logger.debug("writer config: %s", self.config) - return imageio.get_writer(self.gif_file, + + return imageio.get_writer(self._gif_file, mode="i", - **self.config) + **self._gif_params) + + def write(self, filename: str, image) -> None: + """ Frames come from the pool in arbitrary order, so frames are cached for writing out + in the correct order. - def write(self, filename, image): - """ Frames come from the pool in arbitrary order, so cache frames - for writing out in correct order """ + Parameters + ---------- + filename: str + The incoming frame filename. + image: :class:`numpy.ndarray` + The converted image to be written + """ logger.trace("Received frame: (filename: '%s', shape: %s", filename, image.shape) - if not self.gif_file: - self.set_gif_filename(filename) - self.set_dimensions(image.shape[:2]) - self.writer = self.get_writer() - if (image.shape[1], image.shape[0]) != self.output_dimensions: - image = cv2.resize(image, self.output_dimensions) # pylint: disable=no-member + if not self._gif_file: + self._set_gif_filename(filename) + self._set_dimensions(image.shape[:2]) + self._writer = self._get_writer() + if (image.shape[1], image.shape[0]) != self._output_dimensions: + image = cv2.resize(image, self._output_dimensions) # pylint: disable=no-member self.cache_frame(filename, image) - self.save_from_cache() + self._save_from_cache() + + def _set_gif_filename(self, filename: str) -> None: + """ Set the full path to GIF output file to :attr:`_gif_file` + + The filename is the created from the source filename of the first input image received with + `"_converted"` appended to the end and a .gif extension. If a file already exists with the + given filename, then `"_1"` is appended to the end of the filename. This number iterates + until a valid filename that does not exist is found. + + Parameters + ---------- + filename: str + The incoming frame filename. + """ - def set_gif_filename(self, filename): - """ Set the gif output filename """ logger.debug("sample filename: '%s'", filename) filename = os.path.splitext(os.path.basename(filename))[0] - idx = len(filename) + snip = len(filename) for char in list(filename[::-1]): if not char.isdigit() and char not in ("_", "-"): break - idx -= 1 - self.gif_file = os.path.join(self.output_folder, "{}_converted.gif".format(filename[:idx])) - logger.info("Outputting to: '%s'", self.gif_file) - - def set_dimensions(self, frame_dims): - """ Set the dimensions based on a given frame frame. This protects against different - sized images coming in and ensure all images go out at the same size for writers - that require it """ + snip -= 1 + filename = filename[:snip] + + idx = 0 + while True: + out_file = f"{filename}_converted{'' if idx == 0 else f'_{idx}'}.gif" + retval = os.path.join(self.output_folder, out_file) + if not os.path.exists(retval): + break + idx += 1 + + self._gif_file = retval + logger.info("Outputting to: '%s'", self._gif_file) + + def _set_dimensions(self, frame_dims: str) -> None: + """ Set the attribute :attr:`_output_dimensions` based on the first frame received. This + protects against different sized images coming in and ensure all images get written to the + Gif at the sema dimensions. """ logger.debug("input dimensions: %s", frame_dims) - self.output_dimensions = (frame_dims[1], frame_dims[0]) - logger.debug("Set dimensions: %s", self.output_dimensions) + self._output_dimensions = (frame_dims[1], frame_dims[0]) + logger.debug("Set dimensions: %s", self._output_dimensions) - def save_from_cache(self): - """ Save all the frames that are ready to be output from cache """ + def _save_from_cache(self) -> None: + """ Writes any consecutive frames to the GIF container that are ready to be output + from the cache. """ while self.frame_order: if self.frame_order[0] not in self.cache: logger.trace("Next frame not ready. Continuing") @@ -86,9 +158,9 @@ def save_from_cache(self): save_no = self.frame_order.pop(0) save_image = self.cache.pop(save_no) logger.trace("Rendering from cache. Frame no: %s", save_no) - self.writer.append_data(save_image[:, :, ::-1]) + self._writer.append_data(save_image[:, :, ::-1]) logger.trace("Current cache size: %s", len(self.cache)) - def close(self): - """ Close the ffmpeg writer and mux the audio """ - self.writer.close() + def close(self) -> None: + """ Close the GIF writer on completion. """ + self._writer.close() From d6a3372eb01b9b7bf3969eb34b253f5ea8175afa Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 15 May 2022 16:48:58 +0100 Subject: [PATCH 573/981] Update matplotlib requirement - bump min requirement to 3.5.1 - Fix custom toolbar for v3.5.1 - Documentation + linting --- _requirements_base.txt | 3 +- conda-environment-apple-silicon.yml | 2 +- docs/full/lib/gui.rst | 8 + docs/sphinx_requirements.txt | 2 +- lib/gui/display_graph.py | 607 +++++++++++++++++++--------- 5 files changed, 417 insertions(+), 205 deletions(-) diff --git a/_requirements_base.txt b/_requirements_base.txt index 3b3d7db001..b71e0b0a4b 100644 --- a/_requirements_base.txt +++ b/_requirements_base.txt @@ -5,8 +5,7 @@ opencv-python>=4.5.5.0 pillow>=8.3.1 scikit-learn>=1.0.2 fastcluster>=1.2.4 -# matplotlib 3.3.1 breaks custom toolbar in graph popup -matplotlib>=3.2.0,<3.3.0 +matplotlib>=3.5.1 imageio>=2.9.0 imageio-ffmpeg>=0.4.7 ffmpy==0.2.3 diff --git a/conda-environment-apple-silicon.yml b/conda-environment-apple-silicon.yml index 16ee99fc0d..152be90d20 100644 --- a/conda-environment-apple-silicon.yml +++ b/conda-environment-apple-silicon.yml @@ -18,7 +18,7 @@ dependencies: - pillow>=8.3.1 - scikit-learn>=1.0.2 - fastcluster>=1.2.4 - - matplotlib>=3.2.0,<3.3.0 + - matplotlib>=3.5.1 - imageio>=2.9.0 - imageio-ffmpeg>=0.4.7 - ffmpy==0.2.3 diff --git a/docs/full/lib/gui.rst b/docs/full/lib/gui.rst index 522cb981d4..aa8f54d18c 100755 --- a/docs/full/lib/gui.rst +++ b/docs/full/lib/gui.rst @@ -88,6 +88,14 @@ display\_analysis module :undoc-members: :show-inheritance: +display\_graph module +===================== + +.. automodule:: lib.gui.display_graph + :members: + :undoc-members: + :show-inheritance: + popup_configure module ====================== .. automodule:: lib.gui.popup_configure diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index 3d5d21b2a2..83f2db1e56 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -8,7 +8,7 @@ opencv-python>4.5.3.0,<4.5.4.0 pillow==8.3.1 scikit-learn==0.24.2 fastcluster==1.1.26 -matplotlib>3.2.0,<3.3.0 +matplotlib==3.5.1 imageio==2.9.0 imageio-ffmpeg==0.4.5 ffmpy==0.2.3 diff --git a/lib/gui/display_graph.py b/lib/gui/display_graph.py index bfca7979ac..f24d9b9b34 100755 --- a/lib/gui/display_graph.py +++ b/lib/gui/display_graph.py @@ -1,179 +1,169 @@ #!/usr/bin python3 -""" Graph functions for Display Frame of the Faceswap GUI """ +""" Graph functions for Display Frame area of the Faceswap GUI """ import datetime import logging import os import tkinter as tk from tkinter import ttk +from typing import Union, List, Tuple from math import ceil, floor import numpy as np import matplotlib -# pylint: disable=wrong-import-position -matplotlib.use("TkAgg") - -from matplotlib import style # noqa -from matplotlib.figure import Figure # noqa -from matplotlib.backends.backend_tkagg import (FigureCanvasTkAgg, # noqa +from matplotlib import style +from matplotlib.figure import Figure +from matplotlib.backends.backend_tkagg import (FigureCanvasTkAgg, NavigationToolbar2Tk) +from matplotlib.backend_bases import NavigationToolbar2 -from .custom_widgets import Tooltip # noqa -from .utils import get_config, get_images, LongRunningTask # noqa - -logger = logging.getLogger(__name__) # pylint: disable=invalid-name - - -class NavigationToolbar(NavigationToolbar2Tk): # pylint: disable=too-many-ancestors - """ Same as default, but only including buttons we need - with custom icons and layout - From: https://stackoverflow.com/questions/12695678 """ - toolitems = [t for t in NavigationToolbar2Tk.toolitems if - t[0] in ("Home", "Pan", "Zoom", "Save")] - - @staticmethod - def _Button(frame, text, file, command, extension=".gif"): # pylint: disable=arguments-differ - """ Map Buttons to their own frame. - Use custom button icons, Use ttk buttons pack to the right """ - iconmapping = {"home": "reload", - "filesave": "save", - "zoom_to_rect": "zoom"} - icon = iconmapping[file] if iconmapping.get(file, None) else file - img = get_images().icons[icon] - btn = ttk.Button(frame, text=text, image=img, command=command) - btn.pack(side=tk.RIGHT, padx=2) - return btn - - def _init_toolbar(self): - """ Same as original but ttk widgets and standard tool-tips used. Separator added and - message label packed to the left """ - xmin, xmax = self.canvas.figure.bbox.intervalx - height, width = 50, xmax-xmin - ttk.Frame.__init__(self, master=self.window, width=int(width), height=int(height)) - - sep = ttk.Frame(self, height=2, relief=tk.RIDGE) - sep.pack(fill=tk.X, pady=(5, 0), side=tk.TOP) - - self.update() # Make axes menu - - btnframe = ttk.Frame(self) - btnframe.pack(fill=tk.X, padx=5, pady=5, side=tk.RIGHT) +from .custom_widgets import Tooltip +from .utils import get_config, get_images, LongRunningTask - for text, tooltip_text, image_file, callback in self.toolitems: - if text is None: - # Add a spacer; return value is unused. - self._Spacer() - else: - button = self._Button(btnframe, text=text, file=image_file, - command=getattr(self, callback)) - if tooltip_text is not None: - Tooltip(button, text=tooltip_text, wrap_length=200) +matplotlib.use("TkAgg") - self.message = tk.StringVar(master=self) - self._message_label = ttk.Label(master=self, textvariable=self.message) - self._message_label.pack(side=tk.LEFT, padx=5) - self.pack(side=tk.BOTTOM, fill=tk.X) +logger: logging.Logger = logging.getLogger(__name__) class GraphBase(ttk.Frame): # pylint: disable=too-many-ancestors - """ Base class for matplotlib line graphs """ - def __init__(self, parent, data, ylabel): + """ Base class for matplotlib line graphs. + + Parameters + ---------- + parent: :class:`tkinter.ttk.Frame` + The parent frame that holds the graph + data: :class:`lib.gui.analysis.stats.Calculations` + The statistics class that holds the data to be displayed + ylabel: str + The data label for the y-axis + """ + def __init__(self, parent: ttk.Frame, data, ylabel: str) -> None: logger.debug("Initializing %s", self.__class__.__name__) super().__init__(parent) style.use("ggplot") - self.calcs = data - self.ylabel = ylabel - self.colourmaps = ["Reds", "Blues", "Greens", "Purples", "Oranges", "Greys", "copper", - "summer", "bone", "hot", "cool", "pink", "Wistia", "spring", "winter"] - self.lines = list() - self.toolbar = None - self.fig = Figure(figsize=(4, 4), dpi=75) + self._calcs = data + self._ylabel = ylabel + self._colourmaps = ["Reds", "Blues", "Greens", "Purples", "Oranges", "Greys", "copper", + "summer", "bone", "hot", "cool", "pink", "Wistia", "spring", "winter"] + self._lines = [] + self._toolbar = None + self._fig = Figure(figsize=(4, 4), dpi=75) - self.ax1 = self.fig.add_subplot(1, 1, 1) - self.plotcanvas = FigureCanvasTkAgg(self.fig, self) + self._ax1 = self._fig.add_subplot(1, 1, 1) + self._plotcanvas = FigureCanvasTkAgg(self._fig, self) - self.initiate_graph() - self.update_plot(initiate=True) + self._initiate_graph() + self._update_plot(initiate=True) logger.debug("Initialized %s", self.__class__.__name__) - def initiate_graph(self): + @property + def calcs(self): + """ :class:`lib.gui.analysis.stats.Calculations`. The calculated statistics associated with + this graph. """ + return self._calcs + + def _initiate_graph(self) -> None: """ Place the graph canvas """ logger.debug("Setting plotcanvas") - self.plotcanvas.get_tk_widget().pack(side=tk.TOP, padx=5, fill=tk.BOTH, expand=True) - self.fig.subplots_adjust(left=0.100, - bottom=0.100, - right=0.95, - top=0.95, - wspace=0.2, - hspace=0.2) + self._plotcanvas.get_tk_widget().pack(side=tk.TOP, padx=5, fill=tk.BOTH, expand=True) + self._fig.subplots_adjust(left=0.100, + bottom=0.100, + right=0.95, + top=0.95, + wspace=0.2, + hspace=0.2) logger.debug("Set plotcanvas") - def update_plot(self, initiate=True): - """ Update the plot with incoming data """ + def _update_plot(self, initiate: bool = True) -> None: + """ Update the plot with incoming data + + Parameters + ---------- + initiate: bool, Optional + Whether the graph should be initialized for the first time (``True``) or data is being + updated for an existing graph (``False``). Default: ``True`` + """ logger.trace("Updating plot") if initiate: logger.debug("Initializing plot") - self.lines = list() - self.ax1.clear() - self.axes_labels_set() + self._lines = [] + self._ax1.clear() + self._axes_labels_set() logger.debug("Initialized plot") - fulldata = [item for item in self.calcs.stats.values()] - self.axes_limits_set(fulldata) + fulldata = list(self._calcs.stats.values()) + self._axes_limits_set(fulldata) - if self.calcs.start_iteration > 0: - end_iteration = self.calcs.start_iteration + self.calcs.iterations - xrng = list(range(self.calcs.start_iteration, end_iteration)) + if self._calcs.start_iteration > 0: + end_iteration = self._calcs.start_iteration + self._calcs.iterations + xrng = list(range(self._calcs.start_iteration, end_iteration)) else: - xrng = list(range(self.calcs.iterations)) + xrng = list(range(self._calcs.iterations)) - keys = list(self.calcs.stats.keys()) + keys = list(self._calcs.stats.keys()) - for idx, item in enumerate(self.lines_sort(keys)): + for idx, item in enumerate(self._lines_sort(keys)): if initiate: - self.lines.extend(self.ax1.plot(xrng, self.calcs.stats[item[0]], - label=item[1], linewidth=item[2], color=item[3])) + self._lines.extend(self._ax1.plot(xrng, self._calcs.stats[item[0]], + label=item[1], linewidth=item[2], color=item[3])) else: - self.lines[idx].set_data(xrng, self.calcs.stats[item[0]]) + self._lines[idx].set_data(xrng, self._calcs.stats[item[0]]) if initiate: - self.legend_place() + self._legend_place() logger.trace("Updated plot") - def axes_labels_set(self): - """ Set the axes label and range """ - logger.debug("Setting axes labels. y-label: '%s'", self.ylabel) - self.ax1.set_xlabel("Iterations") - self.ax1.set_ylabel(self.ylabel) + def _axes_labels_set(self) -> None: + """ Set the X and Y axes labels. """ + logger.debug("Setting axes labels. y-label: '%s'", self._ylabel) + self._ax1.set_xlabel("Iterations") + self._ax1.set_ylabel(self._ylabel) - def axes_limits_set_default(self): - """ Set default axes limits """ + def _axes_limits_set_default(self) -> None: + """ Set the default axes limits for the X and Y axes. """ logger.debug("Setting default axes ranges") - self.ax1.set_ylim(0.00, 100.0) - self.ax1.set_xlim(0, 1) - - def axes_limits_set(self, data): - """ Set the axes limits """ - xmin = self.calcs.start_iteration - if self.calcs.start_iteration > 0: - xmax = self.calcs.iterations + self.calcs.start_iteration + self._ax1.set_ylim(0.00, 100.0) + self._ax1.set_xlim(0, 1) + + def _axes_limits_set(self, data: List[float]) -> None: + """ Set the axes limits. + + Parameters + ---------- + data: list + The data points for the Y Axis + """ + xmin = self._calcs.start_iteration + if self._calcs.start_iteration > 0: + xmax = self._calcs.iterations + self._calcs.start_iteration else: - xmax = self.calcs.iterations + xmax = self._calcs.iterations xmax = max(1, xmax - 1) if data: - ymin, ymax = self.axes_data_get_min_max(data) - self.ax1.set_ylim(ymin, ymax) - self.ax1.set_xlim(xmin, xmax) + ymin, ymax = self._axes_data_get_min_max(data) + self._ax1.set_ylim(ymin, ymax) + self._ax1.set_xlim(xmin, xmax) logger.trace("axes ranges: (y: (%s, %s), x:(0, %s)", ymin, ymax, xmax) else: - self.axes_limits_set_default() + self._axes_limits_set_default() @staticmethod - def axes_data_get_min_max(data): - """ Return the minimum and maximum values from list of lists """ - ymin, ymax = list(), list() + def _axes_data_get_min_max(data: List[float]) -> Tuple[float]: + """ Obtain the minimum and maximum values for the y-axis from the given data points. + + Parameters + ---------- + data: list + The data points for the Y Axis + + Returns + ------- + tuple + The minimum and maximum values for the y axis + """ + ymin, ymax = [], [] for item in data: # TODO Handle as array not loop ymin.append(np.nanmin(item) * 1000) @@ -183,17 +173,33 @@ def axes_data_get_min_max(data): logger.trace("ymin: %s, ymax: %s", ymin, ymax) return ymin, ymax - def axes_set_yscale(self, scale): - """ Set the Y-Scale to log or linear """ + def _axes_set_yscale(self, scale: str) -> None: + """ Set the Y-Scale to log or linear + + Parameters + ---------- + scale: str + Should be one of ``"log"`` or ``"linear"`` + """ logger.debug("yscale: '%s'", scale) - self.ax1.set_yscale(scale) + self._ax1.set_yscale(scale) + + def _lines_sort(self, keys: List[str]) -> List[List[Union[str, int, Tuple[float]]]]: + """ Sort the data keys into consistent order and set line color map and line width. - def lines_sort(self, keys): - """ Sort the data keys into consistent order - and set line color map and line width """ + Parameters + ---------- + keys: list + The list of data point keys + + Returns + ------- + list + A list of loss keys with their corresponding line formatting and color information + """ logger.trace("Sorting lines") - raw_lines = list() - sorted_lines = list() + raw_lines = [] + sorted_lines = [] for key in sorted(keys): title = key.replace("_", " ").title() if key.startswith("raw"): @@ -201,16 +207,30 @@ def lines_sort(self, keys): else: sorted_lines.append([key, title]) - groupsize = self.lines_groupsize(raw_lines, sorted_lines) + groupsize = self._lines_groupsize(raw_lines, sorted_lines) sorted_lines = raw_lines + sorted_lines - lines = self.lines_style(sorted_lines, groupsize) + lines = self._lines_style(sorted_lines, groupsize) return lines @staticmethod - def lines_groupsize(raw_lines, sorted_lines): + def _lines_groupsize(raw_lines: List[str], sorted_lines: List[str]) -> int: """ Get the number of items in each group. - If raw data isn't selected, then check the length of - remaining groups until something is found """ + + If raw data isn't selected, then check the length of remaining groups until something is + found. + + Parameters + ---------- + raw_lines: list + The list of keys for the raw data points + sorted_lines: + The list of sorted line keys to display on the graph + + Returns + ------- + int + The size of each group that exist within the data set. + """ groupsize = 1 if raw_lines: groupsize = len(raw_lines) @@ -221,86 +241,130 @@ def lines_groupsize(raw_lines, sorted_lines): logger.trace(groupsize) return groupsize - def lines_style(self, lines, groupsize): - """ Set the color map and line width for each group """ + def _lines_style(self, + lines: List[str], + groupsize: int) -> List[List[Union[str, int, Tuple[float]]]]: + """ Obtain the color map and line width for each group. + + Parameters + ---------- + lines: list + The list of sorted line keys to display on the graph + groupsize: int + The size of each group to display in the graph + + Returns + ------- + list + A list of loss keys with their corresponding line formatting and color information + """ logger.trace("Setting lines style") groups = int(len(lines) / groupsize) - colours = self.lines_create_colors(groupsize, groups) + colours = self._lines_create_colors(groupsize, groups) widths = list(range(1, groups + 1)) for idx, item in enumerate(lines): linewidth = widths[idx // groupsize] item.extend((linewidth, colours[idx])) return lines - def lines_create_colors(self, groupsize, groups): - """ Create the colors """ - colours = list() + def _lines_create_colors(self, groupsize: int, groups: int) -> List[Tuple[float]]: + """ Create the color maps. + + Parameters + ---------- + groupsize: int + The size of each group to display in the graph + groups: int + The total number of groups to graph + + Returns + ------- + list + The colour map for each group + """ + colours = [] for i in range(1, groups + 1): - for colour in self.colourmaps[0:groupsize]: + for colour in self._colourmaps[0:groupsize]: cmap = matplotlib.cm.get_cmap(colour) cpoint = 1 - (i / 5) colours.append(cmap(cpoint)) logger.trace(colours) return colours - def legend_place(self): - """ Place and format legend """ + def _legend_place(self) -> None: + """ Place and format the graph legend """ logger.debug("Placing legend") - self.ax1.legend(loc="upper right", ncol=2) + self._ax1.legend(loc="upper right", ncol=2) - def toolbar_place(self, parent): - """ Add Graph Navigation toolbar """ + def _toolbar_place(self, parent: ttk.Frame) -> None: + """ Add Graph Navigation toolbar. + + Parameters + ---------- + parent: ttk.Frame + The parent graph frame to place the toolbar onto + """ logger.debug("Placing toolbar") - self.toolbar = NavigationToolbar(self.plotcanvas, parent) - self.toolbar.pack(side=tk.BOTTOM) - self.toolbar.update() + self._toolbar = NavigationToolbar(self._plotcanvas, parent) + self._toolbar.pack(side=tk.BOTTOM) + self._toolbar.update() - def clear(self): - """ Clear the plots from RAM """ + def clear(self) -> None: + """ Clear the graph plots from RAM """ logger.debug("Clearing graph from RAM: %s", self) - self.fig.clf() - del self.fig + self._fig.clf() + del self._fig class TrainingGraph(GraphBase): # pylint: disable=too-many-ancestors - """ Live graph to be displayed during training. """ - - def __init__(self, parent, data, ylabel): + """ Live graph to be displayed during training. + + Parameters + ---------- + parent: :class:`tkinter.ttk.Frame` + The parent frame that holds the graph + data: :class:`lib.gui.analysis.stats.Calculations` + The statistics class that holds the data to be displayed + ylabel: str + The data label for the y-axis + """ + + def __init__(self, parent: ttk.Frame, data, ylabel: str) -> None: super().__init__(parent, data, ylabel) - self.thread = None # Thread for LongRunningTask + self._thread = None # Thread for LongRunningTask self._displayed_keys = [] - self.add_callback() + self._add_callback() - def add_callback(self): - """ Add the variable trace to update graph on recent button or save iteration """ + def _add_callback(self) -> None: + """ Add the variable trace to update graph on refresh button press or save iteration. """ get_config().tk_vars["refreshgraph"].trace("w", self.refresh) - def build(self): - """ Update the plot area with loss values """ + def build(self) -> None: + """ Build the Training graph. """ logger.debug("Building training graph") - self.plotcanvas.draw() + self._plotcanvas.draw() logger.debug("Built training graph") - def refresh(self, *args): # pylint: disable=unused-argument - """ Read loss data and apply to graph """ + def refresh(self, *args) -> None: # pylint: disable=unused-argument + """ Read the latest loss data and apply to current graph """ refresh_var = get_config().tk_vars["refreshgraph"] - if not refresh_var.get() and self.thread is None: + if not refresh_var.get() and self._thread is None: return - if self.thread is None: + if self._thread is None: logger.debug("Updating plot data") - self.thread = LongRunningTask(target=self.calcs.refresh) - self.thread.start() + self._thread = LongRunningTask(target=self._calcs.refresh) + self._thread.start() self.after(1000, self.refresh) - elif not self.thread.complete.is_set(): + elif not self._thread.complete.is_set(): logger.debug("Graph Data not yet available") self.after(1000, self.refresh) else: logger.debug("Updating plot with data from background thread") - self.calcs = self.thread.get_result() # Terminate the LongRunningTask object - self.thread = None + self._calcs = self._thread.get_result() # Terminate the LongRunningTask object + self._thread = None - dsp_keys = list(sorted(self.calcs.stats)) + dsp_keys = list(sorted(self._calcs.stats)) if dsp_keys != self._displayed_keys: logger.debug("Reinitializing graph for keys change. Old keys: %s New keys: %s", self._displayed_keys, dsp_keys) @@ -309,60 +373,201 @@ def refresh(self, *args): # pylint: disable=unused-argument else: initiate = False - self.update_plot(initiate=initiate) - self.plotcanvas.draw() + self._update_plot(initiate=initiate) + self._plotcanvas.draw() refresh_var.set(False) - def save_fig(self, location): - """ Save the figure to file """ + def save_fig(self, location: str) -> None: + """ Save the current graph to file + + Parameters + ---------- + location: str + The full path to the folder where the current graph should be saved + """ logger.debug("Saving graph: '%s'", location) - keys = sorted([key.replace("raw_", "") for key in self.calcs.stats.keys() + keys = sorted([key.replace("raw_", "") for key in self._calcs.stats.keys() if key.startswith("raw_")]) filename = " - ".join(keys) now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - filename = os.path.join(location, "{}_{}.{}".format(filename, now, "png")) - self.fig.set_size_inches(16, 9) - self.fig.savefig(filename, bbox_inches="tight", dpi=120) - print("Saved graph to {}".format(filename)) + filename = os.path.join(location, f"{filename}_{now}.png") + self._fig.set_size_inches(16, 9) + self._fig.savefig(filename, bbox_inches="tight", dpi=120) + print(f"Saved graph to {filename}") logger.debug("Saved graph: '%s'", filename) - self.resize_fig() + self._resize_fig() - def resize_fig(self): - """ Resize the figure back to the canvas """ + def _resize_fig(self) -> None: + """ Resize the figure to the current canvas size. """ class Event(): # pylint: disable=too-few-public-methods """ Event class that needs to be passed to plotcanvas.resize """ pass # pylint: disable=unnecessary-pass Event.width = self.winfo_width() Event.height = self.winfo_height() - self.plotcanvas.resize(Event) # pylint: disable=no-value-for-parameter + self._plotcanvas.resize(Event) # pylint: disable=no-value-for-parameter class SessionGraph(GraphBase): # pylint: disable=too-many-ancestors - """ Session Graph for session pop-up """ - def __init__(self, parent, data, ylabel, scale): + """ Session Graph for session pop-up. + + Parameters + ---------- + parent: :class:`tkinter.ttk.Frame` + The parent frame that holds the graph + data: :class:`lib.gui.analysis.stats.Calculations` + The statistics class that holds the data to be displayed + ylabel: str + The data label for the y-axis + scale: str + Should be one of ``"log"`` or ``"linear"`` + """ + def __init__(self, parent: ttk.Frame, data, ylabel: str, scale: str) -> None: super().__init__(parent, data, ylabel) - self.scale = scale + self._scale = scale def build(self): """ Build the session graph """ logger.debug("Building session graph") - self.toolbar_place(self) - self.plotcanvas.draw() + self._toolbar_place(self) + self._plotcanvas.draw() logger.debug("Built session graph") - def refresh(self, data, ylabel, scale): - """ Refresh graph data """ + def refresh(self, data, ylabel: str, scale: str) -> None: + """ Refresh the Session Graph's data. + + Parameters + ---------- + data: :class:`lib.gui.analysis.stats.Calculations` + The statistics class that holds the data to be displayed + ylabel: str + The data label for the y-axis + scale: str + Should be one of ``"log"`` or ``"linear"`` + """ logger.debug("Refreshing session graph: (ylabel: '%s', scale: '%s')", ylabel, scale) - self.calcs = data - self.ylabel = ylabel + self._calcs = data + self._ylabel = ylabel self.set_yscale_type(scale) logger.debug("Refreshed session graph") - def set_yscale_type(self, scale): - """ switch the y-scale and redraw """ + def set_yscale_type(self, scale: str) -> None: + """ Set the scale type for the y-axis and redraw. + + Parameters + ---------- + scale: str + Should be one of ``"log"`` or ``"linear"`` + """ logger.debug("Updating scale type: '%s'", scale) - self.scale = scale - self.update_plot(initiate=True) - self.axes_set_yscale(self.scale) - self.plotcanvas.draw() + self._scale = scale + self._update_plot(initiate=True) + self._axes_set_yscale(self._scale) + self._plotcanvas.draw() logger.debug("Updated scale type") + + +class NavigationToolbar(NavigationToolbar2Tk): # pylint: disable=too-many-ancestors + """ Overrides the default Navigation Toolbar to provide only the buttons we require + and to layout the items in a consistent manner with the rest of the GUI for the Analysis + Session Graph pop up Window. + + Parameters + ---------- + canvas: :class:`matplotlib.backends.backend_tkagg.FigureCanvasTkAgg` + The canvas that holds the displayed graph and will hold the toolbar + window: :class:`~lib.gui.display_graph.SessionGraph` + The Session Graph canvas + pack_toolbar: bool, Optional + Whether to pack the Tool bar or not. Default: ``True`` + """ + toolitems = [t for t in NavigationToolbar2Tk.toolitems if + t[0] in ("Home", "Pan", "Zoom", "Save")] + + def __init__(self, # pylint: disable=super-init-not-called + canvas: FigureCanvasTkAgg, + window: SessionGraph, + *, + pack_toolbar: bool = True) -> None: + + # Avoid using self.window (prefer self.canvas.get_tk_widget().master), + # so that Tool implementations can reuse the methods. + + ttk.Frame.__init__(self, # pylint:disable=non-parent-init-called + master=window, + width=int(canvas.figure.bbox.width), + height=50) + + sep = ttk.Frame(self, height=2, relief=tk.RIDGE) + sep.pack(fill=tk.X, pady=(5, 0), side=tk.TOP) + + btnframe = ttk.Frame(self) # Add a button frame to consistently line up GUI + btnframe.pack(fill=tk.X, padx=5, pady=5, side=tk.RIGHT) + + self._buttons = {} + for text, tooltip_text, image_file, callback in self.toolitems: + self._buttons[text] = button = self._Button( + btnframe, + text, + image_file, + toggle=callback in ["zoom", "pan"], + command=getattr(self, callback), + ) + if tooltip_text is not None: + Tooltip(button, text=tooltip_text, wrap_length=200) + + self.message = tk.StringVar(master=self) + self._message_label = ttk.Label(master=self, textvariable=self.message) + self._message_label.pack(side=tk.LEFT, padx=5) # Additional left padding + + NavigationToolbar2.__init__(self, canvas) # pylint:disable=non-parent-init-called + if pack_toolbar: + self.pack(side=tk.BOTTOM, fill=tk.X) + + @staticmethod + def _Button(frame: ttk.Frame, # pylint:disable=arguments-differ + text: str, + image_file: str, + toggle: bool, + command) -> Union[ttk.Button, ttk.Checkbutton]: + """ Override the default button method to use our icons and ttk widgets for + consistent GUI layout. + + Parameters + ---------- + frame: :class:`tkinter.ttk.Frame` + The frame that holds the buttons + text: str + The display text for the button + image_file: str + The name of the image file to use + toggle: bool + Whether to use a checkbutton (``True``) or a regular button (``False``) + command: method + The Navigation Toolbar callback method + + Returns + ------- + :class:`tkinter.ttk.Button` or :class:`tkinter.ttk.Checkbutton` + The widger to use. A button if the option is not toggleable, a checkbutton if the + option is toggleable. + """ + iconmapping = {"home": "reload", + "filesave": "save", + "zoom_to_rect": "zoom"} + icon = iconmapping[image_file] if iconmapping.get(image_file, None) else image_file + img = get_images().icons[icon] + + if not toggle: + btn = ttk.Button(frame, text=text, image=img, command=command) + else: + var = tk.IntVar(master=frame) + btn = ttk.Checkbutton(frame, text=text, image=img, command=command, variable=var) + + # Original implementation uses tk Checkbuttons which have a select and deselect + # method. These aren't available in ttk Checkbuttons, so we monkey patch the methods + # to update the underlying variable. + setattr(btn, "select", lambda i=1: var.set(i)) + setattr(btn, "deselect", lambda i=0: var.set(i)) + + btn.pack(side=tk.RIGHT, padx=2) + return btn From b7e680c5a0f67d13eaa7e17725549f2cc705ef65 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 15 May 2022 16:59:11 +0100 Subject: [PATCH 574/981] linting --- lib/gui/display_graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/gui/display_graph.py b/lib/gui/display_graph.py index f24d9b9b34..c2187450e4 100755 --- a/lib/gui/display_graph.py +++ b/lib/gui/display_graph.py @@ -425,7 +425,7 @@ def __init__(self, parent: ttk.Frame, data, ylabel: str, scale: str) -> None: super().__init__(parent, data, ylabel) self._scale = scale - def build(self): + def build(self) -> None: """ Build the session graph """ logger.debug("Building session graph") self._toolbar_place(self) From e0d9c6e7ee4a99929ab7122c75d2dbb47d83ca46 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 16 May 2022 01:24:27 +0100 Subject: [PATCH 575/981] ffmpeg writer updates - Mux audio at the same time as writing frames - Fix bug where video codec was defined twice --- plugins/convert/writer/ffmpeg.py | 148 +++++++++++++------------------ 1 file changed, 61 insertions(+), 87 deletions(-) diff --git a/plugins/convert/writer/ffmpeg.py b/plugins/convert/writer/ffmpeg.py index cc70c88c71..43cc09b0a8 100644 --- a/plugins/convert/writer/ffmpeg.py +++ b/plugins/convert/writer/ffmpeg.py @@ -1,13 +1,12 @@ #!/usr/bin/env python3 """ Video output writer for faceswap.py converter """ import os -from collections import OrderedDict from math import ceil -from typing import Optional, List, Tuple +from typing import Optional, List, Tuple, Generator import imageio import imageio_ffmpeg as im_ffm -from ffmpy import FFmpeg, FFRuntimeError +import numpy as np from ._base import Output, logger @@ -44,16 +43,7 @@ def __init__(self, self.frame_order: List[int] = self._set_frame_order(total_count) self._output_dimensions: Optional[str] = None # Fix dims on 1st received frame # Need to know dimensions of first frame, so set writer then - self._writer: Optional[imageio.plugins.ffmpeg.FfmpegFormat.Writer] = None - - @property - def _video_tmp_file(self) -> str: - """ str: Full path to the temporary video file that is generated prior to muxing final - audio. """ - path, filename = os.path.split(self._output_filename) - retval = os.path.join(path, f"__tmp_{filename}") - logger.debug(retval) - return retval + self._writer: Optional[Generator[None, np.ndarray, None]] = None @property def _valid_tunes(self) -> dict: @@ -79,7 +69,6 @@ def _output_params(self) -> List[str]: # Force all frames to the same size output_args = ["-vf", f"scale={self._output_dimensions}"] - output_args.extend(["-c:v", codec]) output_args.extend(["-crf", str(self.config["crf"])]) output_args.extend(["-preset", self.config["preset"]]) @@ -95,6 +84,23 @@ def _output_params(self) -> List[str]: logger.debug(output_args) return output_args + @property + def _audio_codec(self) -> Optional[str]: + """ str or ``None``: The audio codec to use. This will either be ``"copy"`` (the default) or + ``None`` if skip muxing has been selected in configuration options, or if frame ranges have + been passed in the command line arguments. """ + retval = "copy" + if self.config["skip_mux"]: + logger.info("Skipping audio muxing due to configuration settings.") + retval = None + elif self._frame_ranges is not None: + logger.warning("Muxing audio is not supported for limited frame ranges." + "The output video will be created but you will need to mux audio " + "manually.") + retval = None + logger.debug("Audio codec: %s", retval) + return retval + def _get_output_filename(self) -> str: """ Return full path to video output file. @@ -143,23 +149,40 @@ def _set_frame_order(self, total_count: int) -> List[int]: logger.debug("frame_order: %s", retval) return retval - def _get_writer(self) -> imageio.plugins.ffmpeg.FfmpegFormat.Writer: + def _get_writer(self, frame_dims: Tuple[int]) -> Generator[None, np.ndarray, None]: """ Add the requested encoding options and return the writer. + Parameters + ---------- + frame_dims: tuple + The (rows, colums) shape of the input image + Returns ------- - :class:`imageio.plugins.ffmpeg.FfmpegFormat.Writer` + generator The imageio ffmpeg writer """ - logger.debug("writer config: %s", self.config) - return imageio.get_writer(self._video_tmp_file, - fps=self._video_fps, - ffmpeg_log_level="error", - quality=None, - macro_block_size=8, - output_params=self._output_params) + audio_codec = self._audio_codec + audio_path = None if audio_codec is None else self._source_video + logger.debug("writer config: %s, audio_path: '%s'", self.config, audio_path) + + retval = im_ffm.write_frames(self._output_filename, + size=(frame_dims[1], frame_dims[0]), + fps=self._video_fps, + quality=None, + codec=self.config["codec"], + macro_block_size=8, + ffmpeg_log_level="error", + ffmpeg_timeout=10, + output_params=self._output_params, + audio_path=audio_path, + audio_codec=audio_codec) + logger.debug("FFMPEG Writer created: %s", retval) + retval.send(None) + + return retval - def write(self, filename: str, image) -> None: + def write(self, filename: str, image: np.ndarray) -> None: """ Frames come from the pool in arbitrary order, so frames are cached for writing out in the correct order. @@ -172,18 +195,25 @@ def write(self, filename: str, image) -> None: """ logger.trace("Received frame: (filename: '%s', shape: %s", filename, image.shape) if not self._output_dimensions: - self._set_dimensions(image.shape[:2]) - self._writer = self._get_writer() + input_dims = image.shape[:2] + self._set_dimensions(input_dims) + self._writer = self._get_writer(input_dims) self.cache_frame(filename, image) self._save_from_cache() - def _set_dimensions(self, frame_dims) -> None: + def _set_dimensions(self, frame_dims: Tuple[int]) -> None: """ Set the attribute :attr:`_output_dimensions` based on the first frame received. This protects against different sized images coming in and ensures all images are written - to ffmpeg at the same size. Dimensions are mapped to a macro block size 16. """ + to ffmpeg at the same size. Dimensions are mapped to a macro block size 8. + + Parameters + ---------- + frame_dims: tuple + The (rows, colums) shape of the input image + """ logger.debug("input dimensions: %s", frame_dims) - self._output_dimensions = (f"{int(ceil(frame_dims[1] / 16) * 16)}:" - f"{int(ceil(frame_dims[0] / 16) * 16)}") + self._output_dimensions = (f"{int(ceil(frame_dims[1] / 8) * 8)}:" + f"{int(ceil(frame_dims[0] / 8) * 8)}") logger.debug("Set dimensions: %s", self._output_dimensions) def _save_from_cache(self) -> None: @@ -196,65 +226,9 @@ def _save_from_cache(self) -> None: save_no = self.frame_order.pop(0) save_image = self.cache.pop(save_no) logger.trace("Rendering from cache. Frame no: %s", save_no) - self._writer.append_data(save_image[:, :, ::-1]) + self._writer.send(np.ascontiguousarray(save_image[:, :, ::-1])) logger.trace("Current cache size: %s", len(self.cache)) def close(self) -> None: """ Close the ffmpeg writer and mux the audio """ self._writer.close() - self._mux_audio() - - def _mux_audio(self) -> None: - """ Mux audio the audio to the generated video temp file. - - ImageIO is a useful lib for frames > video as it also packages the ffmpeg binary - however muxing audio is non-trivial, so this is done afterwards with ffmpy. - - # TODO A future fix could be implemented to mux audio with the frames """ - if self.config["skip_mux"]: - logger.info("Skipping audio muxing due to configuration settings.") - self._rename_tmp_file() - return - - logger.info("Muxing Audio...") - if self._frame_ranges is not None: - logger.warning("Muxing audio is not currently supported for limited frame ranges." - "The output video has been created but you will need to mux audio " - "yourself") - self._rename_tmp_file() - return - - exe = im_ffm.get_ffmpeg_exe() - inputs = OrderedDict([(self._video_tmp_file, None), (self._source_video, None)]) - outputs = {self._output_filename: "-map 0:v:0 -map 1:a:0 -c: copy"} - ffm = FFmpeg(executable=exe, - global_options="-hide_banner -nostats -v 0 -y", - inputs=inputs, - outputs=outputs) - logger.debug("Executing: %s", ffm.cmd) - # Sometimes ffmpy exits for no discernible reason, but then works on a later attempt, - # so take 5 shots at this - attempts = 5 - for attempt in range(attempts): - logger.debug("Muxing attempt: %s", attempt + 1) - try: - ffm.run() - except FFRuntimeError as err: - logger.debug("ffmpy runtime error: %s", str(err)) - if attempt != attempts - 1: - continue - logger.error("There was a problem muxing audio. The output video has been " - "created but you will need to mux audio yourself either with the " - "EFFMpeg tool or an external application.") - os.rename(self._video_tmp_file, self._output_filename) - break - logger.debug("Removing temp file") - if os.path.isfile(self._video_tmp_file): - os.remove(self._video_tmp_file) - - def _rename_tmp_file(self) -> None: - """ Rename the temporary video file if not muxing audio. """ - os.rename(self._video_tmp_file, self._output_filename) - logger.debug("Removing temp file") - if os.path.isfile(self._video_tmp_file): - os.remove(self._video_tmp_file) From 17d35ea423fcbfab2caf87dc4a8323d3b7d231fa Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 16 May 2022 11:28:09 +0100 Subject: [PATCH 576/981] setup.py - python 3.9 support - move requirements files to dedicated folder - update all references to requirements files locations - setup.py: remove git requirements references - setup.py: allow running in python 3.9 - windows/linux installers: default to python 3.9 environment - faceswap.py: cleaner python version check --- .gitignore | 6 +++- .install/linux/faceswap_setup_x64.sh | 3 +- .install/windows/install.nsi | 2 +- Dockerfile.cpu | 2 +- Dockerfile.gpu | 4 +-- INSTALL.md | 10 +++--- faceswap.py | 4 +-- .../_requirements_base.txt | 0 .../conda-environment-apple-silicon.yml | 0 .../requirements_amd.txt | 0 .../requirements_cpu.txt | 0 .../requirements_nvidia.txt | 0 setup.py | 34 ++++++++----------- 13 files changed, 31 insertions(+), 34 deletions(-) rename _requirements_base.txt => requirements/_requirements_base.txt (100%) rename conda-environment-apple-silicon.yml => requirements/conda-environment-apple-silicon.yml (100%) rename requirements_amd.txt => requirements/requirements_amd.txt (100%) rename requirements_cpu.txt => requirements/requirements_cpu.txt (100%) rename requirements_nvidia.txt => requirements/requirements_nvidia.txt (100%) diff --git a/.gitignore b/.gitignore index 0f3faf7f1a..580d05d5fc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,14 @@ !*.keep !*.md +# Requirements files +!/requirements/ +!/requirements/*requirements*.txt +!/requirements/*conda*.yml + # Root files !Dockerfile* !.pylintrc -!*requirements*.txt !setup.cfg !.travis.yml !/faceswap.py diff --git a/.install/linux/faceswap_setup_x64.sh b/.install/linux/faceswap_setup_x64.sh index 3d861c64d7..4d6a1da0dd 100644 --- a/.install/linux/faceswap_setup_x64.sh +++ b/.install/linux/faceswap_setup_x64.sh @@ -12,6 +12,7 @@ DIR_CONDA="$HOME/miniconda3" CONDA_EXECUTABLE="${DIR_CONDA}/bin/conda" CONDA_TO_PATH=false ENV_NAME="faceswap" +PYENV_VERSION="3.9" DIR_FACESWAP="$HOME/faceswap" VERSION="nvidia" @@ -348,7 +349,7 @@ create_env() { # Create Python 3.8 env for faceswap delete_env info "Creating Conda Virtual Environment..." - yellow ; "$CONDA_EXECUTABLE" create -n "$ENV_NAME" -q python=3.8 -y + yellow ; "$CONDA_EXECUTABLE" create -n "$ENV_NAME" -q python="$PYENV_VERSION" -y } diff --git a/.install/windows/install.nsi b/.install/windows/install.nsi index e2d2bddbcf..7e9a0d1f64 100644 --- a/.install/windows/install.nsi +++ b/.install/windows/install.nsi @@ -22,7 +22,7 @@ InstallDir $PROFILE\faceswap # Install cli flags !define flagsConda "/S /RegisterPython=0 /AddToPath=0 /D=$PROFILE\MiniConda3" !define flagsRepo "--depth 1 --no-single-branch ${wwwRepo}" -!define flagsEnv "-y python=3.8" +!define flagsEnv "-y python=3.9" # Folders Var ProgramData diff --git a/Dockerfile.cpu b/Dockerfile.cpu index 45eeeaf346..ce7a0b40df 100755 --- a/Dockerfile.cpu +++ b/Dockerfile.cpu @@ -11,7 +11,7 @@ RUN apt-get update -qq -y \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -COPY _requirements_base.txt /opt/ +COPY ./requirements/_requirements_base.txt /opt/ RUN pip3 install --upgrade pip RUN pip3 --no-cache-dir install -r /opt/_requirements_base.txt && rm /opt/_requirements_base.txt diff --git a/Dockerfile.gpu b/Dockerfile.gpu index 951e753920..c7dd1f6549 100755 --- a/Dockerfile.gpu +++ b/Dockerfile.gpu @@ -15,8 +15,8 @@ RUN rm get-pip.py # install requirements RUN apt-get install ffmpeg git -y -COPY _requirements_base.txt /opt/ -COPY requirements_nvidia.txt /opt/ +COPY ./requirements/_requirements_base.txt /opt/ +COPY ./requirements/requirements_nvidia.txt /opt/ RUN python3.8 -m pip --no-cache-dir install -r /opt/requirements_nvidia.txt && rm /opt/_requirements_base.txt && rm /opt/requirements_nvidia.txt RUN python3.8 -m pip install jupyter matplotlib diff --git a/INSTALL.md b/INSTALL.md index 3857340051..5c82cba761 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -120,9 +120,9 @@ Do not follow these steps if the Easy Install above completed succesfully. If you are using an Nvidia card make sure you have the correct versions of Cuda/cuDNN installed for the required version of Tensorflow - Install tkinter (required for the GUI) by typing: `conda install tk` - Install requirements: - - For Nvidia GPU users: `pip install -r requirements_nvidia.txt` - - For AMD GPU users: `pip install -r requirements_amd.txt` - - For CPU users: `pip install -r requirements_cpu.txt` + - For Nvidia GPU users: `pip install -r ./requirements/requirements_nvidia.txt` + - For AMD GPU users: `pip install -r ./requirements/requirements_amd.txt` + - For CPU users: `pip install -r ./requirements/requirements_cpu.txt` ## Running faceswap - If you are not already in your virtual environment follow [these steps](#entering-your-virtual-environment) @@ -176,7 +176,7 @@ $ source ~/miniforge3/bin/activate #### Easy install ```sh $ conda deactivate -$ conda env create -f conda-environment-apple-silicon.yml +$ conda env create -f ./requirements/conda-environment-apple-silicon.yml $ conda activate faceswap ``` - Enter the command `python faceswap.py gui` and follow the prompts: @@ -219,7 +219,7 @@ Enter your virtual environment and then enter the folder that faceswap has been ```bash python setup.py ``` -If setup fails for any reason you can still manually install the packages listed within requirements.txt +If setup fails for any reason you can still manually install the packages listed within the files in the requirements folder. ### About some of the options - CUDA: For acceleration. Requires a good nVidia Graphics Card (which supports CUDA inside) diff --git a/faceswap.py b/faceswap.py index 95e53a1e4a..4e010b95db 100755 --- a/faceswap.py +++ b/faceswap.py @@ -12,9 +12,7 @@ _ = _LANG.gettext -if sys.version_info[0] < 3: - raise Exception("This program requires at least python3.7") -if sys.version_info[0] == 3 and sys.version_info[1] < 7: +if sys.version_info < (3, 7): raise Exception("This program requires at least python3.7") diff --git a/_requirements_base.txt b/requirements/_requirements_base.txt similarity index 100% rename from _requirements_base.txt rename to requirements/_requirements_base.txt diff --git a/conda-environment-apple-silicon.yml b/requirements/conda-environment-apple-silicon.yml similarity index 100% rename from conda-environment-apple-silicon.yml rename to requirements/conda-environment-apple-silicon.yml diff --git a/requirements_amd.txt b/requirements/requirements_amd.txt similarity index 100% rename from requirements_amd.txt rename to requirements/requirements_amd.txt diff --git a/requirements_cpu.txt b/requirements/requirements_cpu.txt similarity index 100% rename from requirements_cpu.txt rename to requirements/requirements_cpu.txt diff --git a/requirements_nvidia.txt b/requirements/requirements_nvidia.txt similarity index 100% rename from requirements_nvidia.txt rename to requirements/requirements_nvidia.txt diff --git a/setup.py b/setup.py index 79c66975bb..8e8a16918f 100755 --- a/setup.py +++ b/setup.py @@ -126,23 +126,16 @@ def get_required_packages(self): req_files = ["_requirements_base.txt", f"requirements_{suffix}"] pypath = os.path.dirname(os.path.realpath(__file__)) requirements = [] - git_requirements = [] for req_file in req_files: - requirements_file = os.path.join(pypath, req_file) + requirements_file = os.path.join(pypath, "requirements", req_file) with open(requirements_file, encoding="utf8") as req: for package in req.readlines(): package = package.strip() - # parse_requirements can't handle git dependencies, so extract and then - # manually add to final list - if package and package.startswith("git+"): - git_requirements.append((package, [])) - continue if package and (not package.startswith(("#", "-r"))): requirements.append(package) self.required_packages = [(pkg.name, pkg.specs) for pkg in parse_requirements(requirements) if pkg.marker is None or pkg.marker.evaluate()] - self.required_packages.extend(git_requirements) def check_permission(self): """ Check for Admin permissions """ @@ -166,10 +159,12 @@ def check_system(self): def check_python(self): """ Check python and virtual environment status """ self.output.info(f"Installed Python: {self.py_version[0]} {self.py_version[1]}") - if not (self.py_version[0].split(".")[0] == "3" - and self.py_version[0].split(".")[1] in ("7", "8") - and self.py_version[1] == "64bit") and not self.updater: - self.output.error("Please run this script with Python version 3.7 or 3.8 " + + if self.updater: + return + + if not ((3, 7) <= sys.version_info < (3, 10) and self.py_version[1] == "64bit"): + self.output.error("Please run this script with Python version 3.7 to 3.9 " "64bit and try again.") sys.exit(1) @@ -209,7 +204,7 @@ def upgrade_pip(self): def get_installed_packages(self): """ Get currently installed packages """ installed_packages = {} - with Popen(f"\"{sys.executable}\" -m pip freeze", shell=True, stdout=PIPE) as chk: + with Popen(f"\"{sys.executable}\" -m pip freeze --local", shell=True, stdout=PIPE) as chk: installed = chk.communicate()[0].decode(self.encoding).splitlines() for pkg in installed: @@ -663,18 +658,17 @@ def ask_continue(self): def check_missing_dep(self): """ Check for missing dependencies """ for key, specs in self.env.required_packages: - if self.env.is_conda: - # Get Conda alias for Key + + if self.env.is_conda: # Get Conda alias for Key key = CONDA_MAPPING.get(key, (key, None))[0] - if (key == "git+https://github.com/deepfakes/nvidia-ml-py3.git" and - self.env.installed_packages.get("nvidia-ml-py3", "") == "7.352.1"): - # Annoying explicit hack to get around our custom version of nvidia-ml=py3 being - # constantly re-downloaded - continue + if key not in self.env.installed_packages: + # Add not installed packages to missing packages list self.env.missing_packages.append((key, specs)) continue + installed_vers = self.env.installed_packages.get(key, "") + if specs and not all(self._operators[spec[0]](installed_vers, spec[1]) for spec in specs): self.env.missing_packages.append((key, specs)) From ea3dd93688a8eb4123eeaef98c307f42c57bb5b8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 16 May 2022 11:36:33 +0100 Subject: [PATCH 577/981] windows installer: Remove stale conda environment files --- .install/windows/install.nsi | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.install/windows/install.nsi b/.install/windows/install.nsi index 7e9a0d1f64..82de95cf45 100644 --- a/.install/windows/install.nsi +++ b/.install/windows/install.nsi @@ -370,6 +370,7 @@ Function SetEnvironment IfFileExists "$dirConda\envs\$envName" DeleteEnv CreateEnv DeleteEnv: + DetailPrint "Removing existing Conda Virtual Environment..." SetDetailsPrint listonly ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda env remove -y -n $\"$envName$\" && conda deactivate" pop $0 @@ -381,6 +382,19 @@ Function SetEnvironment Call Abort ${EndIf} + # Often Conda won't actually remove the folder and some of it's contents which leads to permission problems later + IfFileExists "$dirConda\envs\$envName" DeleteFolder CreateEnv + DeleteFolder: + DetailPrint "Deleting stale Conda Virtual Environment files..." + SetDetailsPrint listonly + RMDir /r "$dirConda\envs\$envName" + pop $0 + SetDetailsPrint both + ${If} $0 != 0 + DetailPrint "Error deleting Conda Virtual Environment Folder" + Call Abort + ${EndIf} + CreateEnv: SetDetailsPrint listonly ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda create ${flagsEnv} -n $\"$envName$\" && conda deactivate" From 0d23714875f81ddabdbe8f4e40bef6e5f29eeb19 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 16 May 2022 23:26:42 +0100 Subject: [PATCH 578/981] bugfix: extract - stop progress bar from going over max value --- scripts/extract.py | 57 ++++++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/scripts/extract.py b/scripts/extract.py index 80dffaf601..91f4fa1219 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -1,9 +1,12 @@ #!/usr/bin python3 """ Main entry point to the extract process of FaceSwap """ +from __future__ import annotations + import logging import os import sys +from typing import TYPE_CHECKING, Optional from tqdm import tqdm @@ -13,6 +16,10 @@ from plugins.extract.pipeline import Extractor, ExtractMedia from scripts.fsmedia import Alignments, PostProcess, finalize +if TYPE_CHECKING: + import argparse + + tqdm.monitor_interval = 0 # workaround for TqdmSynchronisationWarning logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -31,11 +38,11 @@ class Extract(): # pylint:disable=too-few-public-methods Parameters ---------- - arguments: argparse.Namespace + arguments: :class:`argparse.Namespace` The arguments to be passed to the extraction process as generated from Faceswap's command line arguments """ - def __init__(self, arguments): + def __init__(self, arguments: argparse.Namespace) -> None: logger.debug("Initializing %s: (args: %s", self.__class__.__name__, arguments) self._args = arguments self._output_dir = None if self._args.skip_saving_faces else get_folder( @@ -64,12 +71,12 @@ def __init__(self, arguments): min_size=self._args.min_size, normalize_method=normalization, re_feed=self._args.re_feed) - self._threads = list() + self._threads = [] self._verify_output = False logger.debug("Initialized %s", self.__class__.__name__) @property - def _save_interval(self): + def _save_interval(self) -> Optional[int]: """ int: The number of frames to be processed between each saving of the alignments file if it has been provided, otherwise ``None`` """ if hasattr(self._args, "save_interval"): @@ -77,11 +84,11 @@ def _save_interval(self): return None @property - def _skip_num(self): + def _skip_num(self) -> int: """ int: Number of frames to skip if extract_every_n has been provided """ return self._args.extract_every_n if hasattr(self._args, "extract_every_n") else 1 - def _set_skip_list(self): + def _set_skip_list(self) -> None: """ Add the skip list to the image loader Checks against `extract_every_n` and the existence of alignments data (can exist if @@ -108,7 +115,7 @@ def _set_skip_list(self): logger.debug("Adding skip list: %s", skip_list) self._images.add_skip_list(skip_list) - def process(self): + def process(self) -> None: """ The entry point for triggering the Extraction Process. Should only be called from :class:`lib.cli.launcher.ScriptExecutor` @@ -124,7 +131,7 @@ def process(self): self._alignments.faces_count, self._verify_output) - def _threaded_redirector(self, task, io_args=None): + def _threaded_redirector(self, task: str, io_args: Optional[tuple] = None) -> None: """ Redirect image input/output tasks to relevant queues in background thread Parameters @@ -136,12 +143,12 @@ def _threaded_redirector(self, task, io_args=None): """ logger.debug("Threading task: (Task: '%s')", task) io_args = tuple() if io_args is None else (io_args, ) - func = getattr(self, "_{}".format(task)) + func = getattr(self, f"_{task}") io_thread = MultiThread(func, *io_args, thread_count=1) io_thread.start() self._threads.append(io_thread) - def _load(self): + def _load(self) -> None: """ Load the images Loads images from :class:`lib.image.ImagesLoader`, formats them into a dict compatible @@ -158,7 +165,7 @@ def _load(self): load_queue.put("EOF") logger.debug("Load Images: Complete") - def _reload(self, detected_faces): + def _reload(self, detected_faces: dict[str, ExtractMedia]) -> None: """ Reload the images and pair to detected face When the extraction pipeline is running in serial mode, images are reloaded from disk, @@ -186,7 +193,7 @@ def _reload(self, detected_faces): load_queue.put("EOF") logger.debug("Reload Images: Complete") - def _run_extraction(self): + def _run_extraction(self) -> None: """ The main Faceswap Extraction process Receives items from :class:`plugins.extract.Pipeline.Extractor` and either saves out the @@ -202,18 +209,15 @@ def _run_extraction(self): if exception: break is_final = self._extractor.final_pass - detected_faces = dict() + detected_faces = {} self._extractor.launch() self._check_thread_error() ph_desc = "Extraction" if self._extractor.passes == 1 else self._extractor.phase_text - desc = "Running pass {} of {}: {}".format(phase + 1, - self._extractor.passes, - ph_desc) - status_bar = tqdm(self._extractor.detected_faces(), - total=self._images.process_count, - file=sys.stdout, - desc=desc) - for idx, extract_media in enumerate(status_bar): + desc = f"Running pass {phase + 1} of {self._extractor.passes}: {ph_desc}" + for idx, extract_media in enumerate(tqdm(self._extractor.detected_faces(), + total=self._images.process_count, + file=sys.stdout, + desc=desc)): self._check_thread_error() if is_final: self._output_processing(extract_media, size) @@ -224,7 +228,6 @@ def _run_extraction(self): extract_media.remove_image() # cache extract_media for next run detected_faces[extract_media.filename] = extract_media - status_bar.update(1) if not is_final: logger.debug("Reloading images") @@ -232,12 +235,12 @@ def _run_extraction(self): if not self._args.skip_saving_faces: saver.close() - def _check_thread_error(self): + def _check_thread_error(self) -> None: """ Check if any errors have occurred in the running threads and their errors """ for thread in self._threads: thread.check_and_raise_error() - def _output_processing(self, extract_media, size): + def _output_processing(self, extract_media: ExtractMedia, size: int) -> None: """ Prepare faces for output Loads the aligned face, generate the thumbnail, perform any processing actions and verify @@ -266,7 +269,7 @@ def _output_processing(self, extract_media, size): if not self._verify_output and faces_count > 1: self._verify_output = True - def _output_faces(self, saver, extract_media): + def _output_faces(self, saver: ImagesSaver, extract_media: ExtractMedia) -> None: """ Output faces to save thread Set the face filename based on the frame name and put the face to the @@ -281,12 +284,12 @@ def _output_faces(self, saver, extract_media): The output from :class:`~plugins.extract.Pipeline.Extractor` """ logger.trace("Outputting faces for %s", extract_media.filename) - final_faces = list() + final_faces = [] filename = os.path.splitext(os.path.basename(extract_media.filename))[0] extension = ".png" for idx, face in enumerate(extract_media.detected_faces): - output_filename = "{}_{}{}".format(filename, str(idx), extension) + output_filename = f"{filename}_{idx}{extension}" meta = dict(alignments=face.to_png_meta(), source=dict(alignments_version=self._alignments.version, original_filename=output_filename, From a5a598539c2e3cd07c1192cc68d7167db534a40b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 18 May 2022 00:30:56 +0100 Subject: [PATCH 579/981] Manual tool - More robust handling of videos with duped frames --- lib/image.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/image.py b/lib/image.py index d5752e86d7..c7dc31b244 100644 --- a/lib/image.py +++ b/lib/image.py @@ -62,11 +62,32 @@ def get_frame_info(self, frame_pts=None, keyframes=None): return len(frame_pts), dict(pts_time=self._frame_pts, keyframes=self._keyframes) assert isinstance(self._filename, str), "Video path must be a string" + + # NB: The below video filter applies the detected frame rate prior to showinfo. This + # appears to help prevent an issue where the number of timestamp entries generated by + # showinfo does not correspond to the number of frames that the video file generates. + # This is because the demuxer will duplicate frames to meet the required frame rate. + # This **may** cause issues so be aware. + + # Also, drop frame rates (i.e 23.98, 29.97 and 59.94) will introduce rounding errors which + # means sync will drift on generated pts. These **should** be the only 'drop-frame rates' + # that appear in video files, but this is video files, and nothing is guaranteed. + # (The actual values for these should be 24000/1001, 30000/1001 and 60000/1001 + # respectively). The solutions to round these values is hacky at best, so: + # TODO find a more robust method for extracting/handling drop-frame rates. + + fps = self._meta["fps"] + rounded_fps = round(fps, 0) + if 0.01 < rounded_fps - fps < 0.10: # 0.90 - 0.99 + new_fps = f"{int(rounded_fps * 1000)}/1001" + logger.debug("Adjusting drop-frame fps: %s to %s", fps, new_fps) + fps = new_fps + cmd = [im_ffm.get_ffmpeg_exe(), "-hide_banner", "-copyts", "-i", self._filename, - "-vf", "showinfo", + "-vf", f"fps=fps={fps},showinfo", "-start_number", "0", "-an", "-f", "null", From dbcd507d01cfa27f8c72f2beff4d50f0c31e824e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 19 May 2022 10:11:18 +0100 Subject: [PATCH 580/981] pin nvidia-ml-py for breaking change --- requirements/_requirements_base.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index b71e0b0a4b..393afc2f23 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -10,7 +10,9 @@ imageio>=2.9.0 imageio-ffmpeg>=0.4.7 ffmpy==0.2.3 # Exclude badly numbered Python2 version of nvidia-ml-py -nvidia-ml-py>=11.510,<300 +#nvidia-ml-py>=11.510,<300 +# Pin nvidida-ml-py to <11.515 until we know if bytes->str is an error or permanent change +nvidia-ml-py<11.515 pywin32>=228 ; sys_platform == "win32" pynvx==1.0.0 ; sys_platform == "darwin" typing-extensions ; python_version < "3.8" From c2595c46d41fbc342e8619866db0145effd13e9b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 20 May 2022 17:06:25 +0000 Subject: [PATCH 581/981] bugfix - add missing mask key to alignments on legacy update --- lib/align/alignments.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/align/alignments.py b/lib/align/alignments.py index a0fd1ce3bd..9e0f1aee9a 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -763,8 +763,11 @@ def _update_mask_centering(self): update_count = 0 for val in self._data.values(): for alignment in val["faces"]: + if "mask" not in alignment: + alignment["mask"] = {} for mask in alignment["mask"].values(): mask["stored_centering"] = "face" + update_count += 1 logger.debug("Updated legacy mask centering: %s", update_count) From a9908b46f77dc66ac7efe7100ea0eed4b1f2b460 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 21 May 2022 17:06:12 +0100 Subject: [PATCH 582/981] Alignments tool - Replace 'extract-large' with 'min-size' --- .../es/LC_MESSAGES/tools.alignments.cli.mo | Bin 7049 -> 7832 bytes .../es/LC_MESSAGES/tools.alignments.cli.po | 37 ++++-- locales/tools.alignments.cli.pot | 4 +- tools/alignments/cli.py | 24 ++-- tools/alignments/jobs.py | 115 ++++++++++++------ 5 files changed, 124 insertions(+), 56 deletions(-) diff --git a/locales/es/LC_MESSAGES/tools.alignments.cli.mo b/locales/es/LC_MESSAGES/tools.alignments.cli.mo index 3b57d5a3c2ddf1e1d176d233bc5bc2b452bbbe76..d8c186093e5d1d848c84d9be072cceb4cdc03681 100644 GIT binary patch delta 1183 zcmZuvO>0v@6uqfm(56&_wmW6vujV9G(hxW zh$u%yABTx{0)GMb0Dl7qfr-6DXA?w8;3UqMMu;u|?*W&9pE@|Tk7yR>7r@WJAHX#5 z<$j{8!0*63aB`IB?QWtwqclg?K|FSV=q4__F``Z27vKX3dpAxrkMo%cqFuoIKxn0h zKqGK%B08VAoEV9oCk`cO<9*`z;Eke`E_>2ftFpw!1_z7E=bH3v=?1Jlu4+$mpt&k# zfJ@1KP1>rg@LJXbPuKyQT0UAx`>TZ%7t(2SE<}fur<;7m6lu21r~6zu4wr>3{R}Tu zX80VM2)B0|lj$<&v$y(}|ERZIy{S*yz#eH%J>s;{_H8|&z+-6ANrrTR!*UD7MAZwm(o z#m%!;?nGbr=vMMkGMBdU>0Fkx(=++#XY%Wgsf@MJ8h)K@YNunAst9(3&$jUJy&FnQ z*MLv9s1}tdX~yV*sEXDb6u>?|kX+MoY+^|=0&A@!NK~^OR%?#(*w%RG^r}G6j>J~2 z4a0z7(@54f?El6&nc>#6KsjwjS%>$^i#EsRn!Ml#qUf|SQamX@>=U1zwEG~ zdz8kwExutNK-%#L>!7e?#zBlBZTxc0TBJB;HaBL^CCJ^sQ^bkjZm}7s;>nV#O4nD~ qZD-^6wD;Ov+ja!ALUE&Bmu;FLcXSAC)z00lRCj=Zbw4^be)|tAp>@*$ delta 426 zcmZ9`OG^TA6b0}*kOk%|2qF!;ixx?Z`N)byB*+%gCJ6$eaMC|EG0x~bAS{SBLC~TY zh&HwlYUL^+5Zu%%@OuP(g6IgX+T0(93+LX4-1X}1%|)AtdgDYXc<3V5W-a{{Z zgj*8P6O18$`-yg7I6!m)Ykv-44mlSj`haIphJ_H(9<*QvzQgV)(NBo-w2$J~DWVb< z6VpUrP=fztD~ zcjki6zI?x<87xk1QxPU>ip3Sh#B4f(Ekoz3V8c{vIIY$kT*lt08(i None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self._arguments = arguments self._alignments = alignments self._is_legacy = self._alignments.version == 1.0 # pylint:disable=protected-access self._mask_pipeline = None self._faces_dir = arguments.faces_dir + self._min_size = self._get_min_size(arguments.size, arguments.min_size) self._frames = Frames(arguments.frames_dir, self._get_count()) self._extracted_faces = ExtractedFaces(self._frames, @@ -413,7 +417,30 @@ def __init__(self, alignments, arguments): self._saver = None logger.debug("Initialized %s", self.__class__.__name__) - def _get_count(self): + @classmethod + def _get_min_size(cls, extract_size: int, min_size: int) -> int: + """ Obtain the minimum size that a face has been resized from to be included as a valid + extract. + + Parameters + ---------- + extract_size: int + The requested size of the extracted images + min_size: int + The percentage amount that has been supplied for valid faces (as a percentage of + extract size) + + Returns + ------- + int + The minimum size, in pixels, that a face is resized from to be considered valid + """ + retval = 0 if min_size == 0 else max(4, int(extract_size * (min_size / 100.))) + logger.debug("Extract size: %s, min percentage size: %s, min_size: %s", + extract_size, min_size, retval) + return retval + + def _get_count(self) -> Optional[int]: """ If the alignments file has been run through the manual tool, then it will hold video meta information, meaning that the count of frames in the alignment file can be relied on to be accurate. @@ -429,16 +456,21 @@ def _get_count(self): logger.debug("Frame count from alignments file: (has_meta: %s, %s", has_meta, retval) return retval - def process(self): + def process(self) -> None: """ Run the re-extraction from Alignments file process""" logger.info("[EXTRACT FACES]") # Tidy up cli output self._check_folder() if self._is_legacy: self._legacy_check() self._saver = ImagesSaver(self._faces_dir, as_bytes=True) + + if self._min_size > 0: + logger.info("Only selecting faces that have been resized from a minimum resolution " + "of %spx", self._min_size) + self._export_faces() - def _check_folder(self): + def _check_folder(self) -> None: """ Check that the faces folder doesn't pre-exist and create. """ err = None if not self._faces_dir: @@ -447,21 +479,21 @@ def _check_folder(self): logger.debug("Creating folder: '%s'", self._faces_dir) os.makedirs(self._faces_dir) elif os.listdir(self._faces_dir): - err = "ERROR: Output faces folder should be empty: '{}'".format(self._faces_dir) + err = f"ERROR: Output faces folder should be empty: '{self._faces_dir}'" if err: logger.error(err) sys.exit(0) logger.verbose("Creating output folder at '%s'", self._faces_dir) - def _legacy_check(self): + def _legacy_check(self) -> None: """ Check whether the alignments file was created with the legacy extraction method. If so, force user to re-extract all faces if any options have been specified, otherwise raise the appropriate warnings and set the legacy options. """ - if self._arguments.large or self._arguments.extract_every_n != 1: + if self._min_size > 0 or self._arguments.extract_every_n != 1: logger.warning("This alignments file was generated with the legacy extraction method.") - logger.warning("You should run this extraction job, but with 'large' deselected and " + logger.warning("You should run this extraction job, but with 'min_size' set to 0 and " "'extract-every-n' set to 1 to update the alignments file.") logger.warning("You can then re-run this extraction job with your chosen options.") sys.exit(0) @@ -482,11 +514,12 @@ def _legacy_check(self): # Update alignments versioning self._alignments._version = _VERSION # pylint:disable=protected-access - def _export_faces(self): + def _export_faces(self) -> None: """ Export the faces to the output folder. """ extracted_faces = 0 skip_list = self._set_skip_list() count = self._frames.count if skip_list is None else self._frames.count - len(skip_list) + for filename, image in tqdm(self._frames.stream(skip_list=skip_list), total=count, desc="Saving extracted faces"): frame_name = os.path.basename(filename) @@ -494,11 +527,11 @@ def _export_faces(self): logger.verbose("Skipping '%s' - Alignments not found", frame_name) continue extracted_faces += self._output_faces(frame_name, image) - if self._is_legacy and extracted_faces != 0 and not self._arguments.large: + if self._is_legacy and extracted_faces != 0 and self._min_size == 0: self._alignments.save() logger.info("%s face(s) extracted", extracted_faces) - def _set_skip_list(self): + def _set_skip_list(self) -> Optional[List[int]]: """ Set the indices for frames that should be skipped based on the `extract_every_n` command line option. @@ -521,7 +554,7 @@ def _set_skip_list(self): logger.debug("Adding skip list: %s", skip_list) return skip_list - def _output_faces(self, filename, image): + def _output_faces(self, filename: str, image: np.ndarray) -> int: """ For each frame save out the faces Parameters @@ -546,7 +579,7 @@ def _output_faces(self, filename, image): faces = self._process_legacy(filename, image, faces) for idx, face in enumerate(faces): - output = "{}_{}.png".format(frame_name, str(idx)) + output = f"{frame_name}_{idx}.png" meta = dict(alignments=face.to_png_meta(), source=dict(alignments_version=self._alignments.version, original_filename=output, @@ -555,14 +588,14 @@ def _output_faces(self, filename, image): source_is_video=self._frames.is_video, source_frame_dims=image.shape[:2])) self._saver.save(output, encode_image(face.aligned.face, ".png", metadata=meta)) - if not self._arguments.large and self._is_legacy: + if self._min_size == 0 and self._is_legacy: face.thumbnail = generate_thumbnail(face.aligned.face, size=96, quality=60) self._alignments.data[filename]["faces"][idx] = face.to_alignment() face_count += 1 self._saver.close() return face_count - def _select_valid_faces(self, frame, image): + def _select_valid_faces(self, frame: str, image: np.ndarray) -> List[DetectedFace]: """ Return the aligned faces from a frame that meet the selection criteria, Parameters @@ -578,17 +611,20 @@ def _select_valid_faces(self, frame, image): List of valid :class:`lib,align.DetectedFace` objects """ faces = self._extracted_faces.get_faces_in_frame(frame, image=image) - if not self._arguments.large: + if self._min_size == 0: valid_faces = faces else: sizes = self._extracted_faces.get_roi_size_for_frame(frame) valid_faces = [faces[idx] for idx, size in enumerate(sizes) - if size >= self._extracted_faces.size] + if size >= self._min_size] logger.trace("frame: '%s', total_faces: %s, valid_faces: %s", frame, len(faces), len(valid_faces)) return valid_faces - def _process_legacy(self, filename, image, detected_faces): + def _process_legacy(self, + filename: str, + image: np.ndarray, + detected_faces: List[DetectedFace]) -> List[DetectedFace]: """ Process legacy face extractions to new extraction method. Updates stored masks to new extract size @@ -601,6 +637,11 @@ def _process_legacy(self, filename, image, detected_faces): The current image the contains the faces detected_faces: list list of :class:`lib.align.DetectedFace` objects for the current frame + + Returns + ------- + list + The updated list of :class:`lib.align.DetectedFace` objects for the current frame """ # Update landmarks based masks for face centering mask_item = ExtractMedia(filename, image, detected_faces=detected_faces) @@ -613,7 +654,7 @@ def _process_legacy(self, filename, image, detected_faces): return faces @classmethod - def _pad_legacy_masks(cls, detected_face): + def _pad_legacy_masks(cls, detected_face: DetectedFace) -> None: """ Recenter legacy Neural Network based masks from legacy centering to face centering and pad accordingly. @@ -667,7 +708,7 @@ def __init__(self, alignments, arguments): logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self._alignments = alignments - kwargs = dict() + kwargs = {} if alignments.version < 2.1: # Update headers of faces generated with hash based alignments kwargs["alignments"] = alignments @@ -722,7 +763,7 @@ def _update_png_headers(self): fullpath, face_index, new_index) # Update file_list_sorted for rename task - orig_filename = "{}_{}.png".format(os.path.splitext(frame)[0], new_index) + orig_filename = f"{os.path.splitext(frame)[0]}_{new_index}.png" file_info["face_index"] = new_index file_info["original_filename"] = orig_filename @@ -758,7 +799,7 @@ def __init__(self, alignments, arguments, faces=None): self.__class__.__name__, arguments, faces) self._alignments = alignments - kwargs = dict() + kwargs = {} if alignments.version < 2.1: # Update headers of faces generated with hash based alignments kwargs["alignments"] = alignments @@ -877,8 +918,8 @@ def __init__(self, alignments, arguments): logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self.arguments = arguments self._alignments = alignments - self.mappings = dict() - self.normalized = dict() + self.mappings = {} + self.normalized = {} self.shapes_model = None logger.debug("Initialized %s", self.__class__.__name__) From 6437cd7ab0d6f18cdca0172ba281fd71967b86ac Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 24 May 2022 12:42:52 +0100 Subject: [PATCH 583/981] alignments tool - Add from-faces job - Allows user to regenerate alignments file(s) from a folder of extracted faces --- .../es/LC_MESSAGES/tools.alignments.cli.mo | Bin 7832 -> 9131 bytes .../es/LC_MESSAGES/tools.alignments.cli.po | 74 +++-- locales/tools.alignments.cli.pot | 140 +++++---- tools/alignments/alignments.py | 14 +- tools/alignments/cli.py | 38 ++- tools/alignments/jobs.py | 266 +++++++++++++++++- 6 files changed, 437 insertions(+), 95 deletions(-) diff --git a/locales/es/LC_MESSAGES/tools.alignments.cli.mo b/locales/es/LC_MESSAGES/tools.alignments.cli.mo index d8c186093e5d1d848c84d9be072cceb4cdc03681..178318469501d692d03cd8cb0add718ddd8cc94f 100644 GIT binary patch delta 1497 zcmZ`(O=w(I6h4#2r1mFH^D_!oJt2^^FcWPJ2m=MJl&Z89H7F{AH}Bnf@5VRxPVRj# zV{kEU+*=-^n-Ypk=|W~FxSEZEbkki|?p(QY>BjH8H|ZZ3GWp&+_nz~8=bUfy!=<0D zejAT|CZfNO5w+)u4rhth8$^GcB)X3HKc|T90pB={KQlxt3q%+3*lH5tD!mOv8r=Y% z0DcJk6!{8x9`C=NA-V?q3%Cp1TCDL9cnR+(mMEd$@$l9XQ3trVOtcMr9e5Y`9q_l~ zMDYsIM=0ERk?0e|zj+Cwfqw$OhOoN~k$8W1jp!-hO(1%p?|~xl$F=gE#`>yglvd9E1WRF`{mt(S)eH+M8A+G?+Iq#Mh5syPgFWO_#GNd_upvG(H^X74vfj}+?i)oCU; zO=A1ng3V<5TsY5ikJU7lT@Pwxlxlvt}m35-Y>G;3^E9Eg@=$f z=c82Hhw%rrNC0o(qn0ACrJ+r}ak*O!psqbkO_Xw~_BHxOp+_`TEe4TMldm;qykn!y7XGsWE~IHL8V8=Jn#-p;oR{T)(ttur?0GB zka<^PY85<`3eKBk-B|4RfQumAee4R39qQ(6H71KnC<=+?=d&x%4WL{?zL2$oAFkNs zuU;*0Ew7gC`GxW7T=RJAeLZkK?_3Xwi945zBy_f2m&;cc&dzV~g^S&-3*!$KPR?HA zD*?4IW2>QBc>^xx6$oM%IKv0~-BG)EsEs#0--vK4aI8bDH#jPkja_;8?AbHn$a#Kp zo3V_nRL?A((o^$ADpupq7O%{__L$>pud?z{YW6`DR~xWeMP@RV1wm78JmO`u*=kk` zp4^F5*u=tWicvKpI9HPQ(FoM_3V2tx4N>A9NV3%#DE^15_Et553>A*LWO>;u;8_i$ zO)UlmPHqfzuELwbYxhllR2muIP_p$^K=ki z<)_4f2OnltJ{CY*`IdS4E3++T7#cc1B&+H-Mqo`I$$k0s?_2cn}-c8aCCz3nYvn{viCKo}JkEi1K#oB_s5B!|nDK;G2 H4S&2pXO%Gb diff --git a/locales/es/LC_MESSAGES/tools.alignments.cli.po b/locales/es/LC_MESSAGES/tools.alignments.cli.po index 4e615e54d7..0287766d44 100644 --- a/locales/es/LC_MESSAGES/tools.alignments.cli.po +++ b/locales/es/LC_MESSAGES/tools.alignments.cli.po @@ -5,8 +5,9 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-05-08 11:34+0100\n" -"PO-Revision-Date: 2022-05-21 16:57+0100\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-05-24 12:38+0100\n" +"PO-Revision-Date: 2022-05-24 12:41+0100\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es_ES\n" @@ -17,14 +18,14 @@ msgstr "" "X-Generator: Poedit 3.0\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: tools/alignments/cli.py:14 +#: tools/alignments/cli.py:15 msgid "" "This command lets you perform various tasks pertaining to an alignments file." msgstr "" "Este comando le permite realizar varias tareas relacionadas con un archivo " "de alineación." -#: tools/alignments/cli.py:23 +#: tools/alignments/cli.py:30 msgid "" "Alignments tool\n" "This tool allows you to perform numerous actions on or using an alignments " @@ -35,16 +36,16 @@ msgstr "" "caras o una fuente de fotogramas, usando opcionalmente su correspondiente " "archivo de alineación." -#: tools/alignments/cli.py:27 +#: tools/alignments/cli.py:41 msgid " Must Pass in a frames folder/source video file (-fr)." msgstr "" " Debe indicar una carpeta de fotogramas o archivo de vídeo de origen (-fr)." -#: tools/alignments/cli.py:28 +#: tools/alignments/cli.py:42 msgid " Must Pass in a faces folder (-fc)." msgstr " Debe indicar una carpeta de caras (-fc)." -#: tools/alignments/cli.py:29 +#: tools/alignments/cli.py:43 msgid "" " Must Pass in either a frames folder/source video file OR afaces folder (-fr " "or -fc)." @@ -52,7 +53,7 @@ msgstr "" " Debe indicar una carpeta de fotogramas o archivo de vídeo de origen, o una " "carpeta de caras (-fr o -fc)." -#: tools/alignments/cli.py:31 +#: tools/alignments/cli.py:45 msgid "" " Must Pass in a frames folder/source video file AND a faces folder (-fr and -" "fc)." @@ -60,15 +61,16 @@ msgstr "" " Debe indicar una carpeta de fotogramas o archivo de vídeo de origen, y una " "carpeta de caras (-fr y -fc)." -#: tools/alignments/cli.py:33 +#: tools/alignments/cli.py:47 msgid " Use the output option (-o) to process results." msgstr " Usar la opción de salida (-o) para procesar los resultados." -#: tools/alignments/cli.py:41 tools/alignments/cli.py:73 +#: tools/alignments/cli.py:55 tools/alignments/cli.py:94 msgid "processing" msgstr "proceso" -#: tools/alignments/cli.py:43 +#: tools/alignments/cli.py:57 +#, python-brace-format msgid "" "R|Choose which action you want to perform. NB: All actions require an " "alignments file (-a) to be passed in.\n" @@ -78,6 +80,13 @@ msgid "" "alignment data. This is a lot quicker than re-detecting faces. Can pass in " "the '-een' (--extract-every-n) parameter to only extract every nth frame." "{1}\n" +"L|'from-faces': Generate alignment file(s) from a folder of extracted faces. " +"if the folder of faces comes from multiple sources, then multiple alignments " +"files will be created. NB: for faces which have been extracted folders of " +"source images, rather than a video, a single alignments file will be created " +"as there is no way for the process to know how many folders of images were " +"originally used. You do not need to provide an alignments file path to run " +"this job. {3}\n" "L|'missing-alignments': Identify frames that do not exist in the alignments " "file.{2}{0}\n" "L|'missing-frames': Identify frames in the alignments file that do not " @@ -105,6 +114,14 @@ msgstr "" "basándose en los datos de alineación. Esto es mucho más rápido que volver a " "detectar las caras. Se puede pasar el parámetro '-een' (--extract-every-n) " "para extraer sólo cada enésimo fotograma.{1}\n" +"L|'from-faces': genera archivos de alineación a partir de una carpeta de " +"caras extraídas. si la carpeta de caras proviene de varias fuentes, se " +"crearán varios archivos de alineación. NB: para las caras de las que se han " +"extraído carpetas de imágenes de origen, en lugar de un video, se creará un " +"único archivo de alineaciones, ya que el proceso no tiene forma de saber " +"cuántas carpetas de imágenes se usaron originalmente. No necesita " +"proporcionar una ruta de archivo de alineaciones para ejecutar este trabajo. " +"{3}\n" "L|'missing-alignments': Identifica los fotogramas que no existen en el " "archivo de alineaciones.{2}{0}\n" "L|'missing-frames': Identifica los fotogramas del archivo de alineaciones " @@ -125,7 +142,7 @@ msgstr "" "L|'spatial': Realiza un filtrado espacial y temporal para suavizar las " "alineaciones (¡EXPERIMENTAL!)" -#: tools/alignments/cli.py:75 +#: tools/alignments/cli.py:96 msgid "" "R|How to output discovered items ('faces' and 'frames' only):\n" "L|'console': Print the list of frames to the screen. (DEFAULT)\n" @@ -141,31 +158,37 @@ msgstr "" "L|'move': Mueve los elementos descubiertos a una subcarpeta dentro del " "directorio de origen." -#: tools/alignments/cli.py:86 tools/alignments/cli.py:94 -#: tools/alignments/cli.py:101 +#: tools/alignments/cli.py:107 tools/alignments/cli.py:118 +#: tools/alignments/cli.py:125 msgid "data" msgstr "datos" -#: tools/alignments/cli.py:89 -msgid "Full path to the alignments file to be processed." -msgstr "Ruta completa del archivo de alineaciones a procesar." +#: tools/alignments/cli.py:111 +msgid "" +"Full path to the alignments file to be processed. This is required for all " +"jobs except for 'from-faces' when the alignments file will be generated in " +"the specified faces folder." +msgstr "" +"Ruta completa del archivo de alineaciones a procesar. Esto es necesario para " +"todos los trabajos excepto para 'caras desde' cuando el archivo de " +"alineaciones se generará en la carpeta de caras especificada." -#: tools/alignments/cli.py:95 +#: tools/alignments/cli.py:119 msgid "Directory containing extracted faces." msgstr "Directorio que contiene las caras extraídas." -#: tools/alignments/cli.py:102 +#: tools/alignments/cli.py:126 msgid "Directory containing source frames that faces were extracted from." msgstr "" "Directorio que contiene los fotogramas de origen de los que se extrajeron " "las caras." -#: tools/alignments/cli.py:111 tools/alignments/cli.py:121 -#: tools/alignments/cli.py:127 +#: tools/alignments/cli.py:135 tools/alignments/cli.py:146 +#: tools/alignments/cli.py:156 msgid "extract" msgstr "extracción" -#: tools/alignments/cli.py:112 +#: tools/alignments/cli.py:136 msgid "" "[Extract only] Extract every 'nth' frame. This option will skip frames when " "extracting faces. For example a value of 1 will extract faces from every " @@ -176,11 +199,11 @@ msgstr "" "caras de cada fotograma, un valor de 10 extraerá las caras de cada 10 " "fotogramas." -#: tools/alignments/cli.py:123 +#: tools/alignments/cli.py:147 msgid "[Extract only] The output size of extracted faces." msgstr "[Sólo extracción] El tamaño de salida de las caras extraídas." -#: tools/alignments/cli.py:133 +#: tools/alignments/cli.py:157 msgid "" "[Extract only] Only extract faces that have been resized by this percent or " "more to meet the specified extract size (`-sz`, `--size`). Useful for " @@ -200,6 +223,9 @@ msgstr "" "desde 512 px o más. Una configuración de 200 solo extraerá las caras que se " "han reducido de 1024 px o más." +#~ msgid "Full path to the alignments file to be processed." +#~ msgstr "Ruta completa del archivo de alineaciones a procesar." + #~ msgid "" #~ "[Extract only] Only extract faces that have not been upscaled to the " #~ "required size (`-sz`, `--size). Useful for excluding low-res images from " diff --git a/locales/tools.alignments.cli.pot b/locales/tools.alignments.cli.pot index 490efc7a77..985132cecd 100644 --- a/locales/tools.alignments.cli.pot +++ b/locales/tools.alignments.cli.pot @@ -1,108 +1,152 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR ORGANIZATION +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # +#, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-05-08 11:34+0100\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-05-24 12:38+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" +"Language: \n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=cp1252\n" +"Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" - -#: tools/alignments/cli.py:14 -msgid "This command lets you perform various tasks pertaining to an alignments file." +#: tools/alignments/cli.py:15 +msgid "" +"This command lets you perform various tasks pertaining to an alignments file." msgstr "" -#: tools/alignments/cli.py:23 +#: tools/alignments/cli.py:30 msgid "" "Alignments tool\n" -"This tool allows you to perform numerous actions on or using an alignments file against its corresponding faceset/frame source." +"This tool allows you to perform numerous actions on or using an alignments " +"file against its corresponding faceset/frame source." msgstr "" -#: tools/alignments/cli.py:27 +#: tools/alignments/cli.py:41 msgid " Must Pass in a frames folder/source video file (-fr)." msgstr "" -#: tools/alignments/cli.py:28 +#: tools/alignments/cli.py:42 msgid " Must Pass in a faces folder (-fc)." msgstr "" -#: tools/alignments/cli.py:29 -msgid " Must Pass in either a frames folder/source video file OR afaces folder (-fr or -fc)." +#: tools/alignments/cli.py:43 +msgid "" +" Must Pass in either a frames folder/source video file OR afaces folder (-fr " +"or -fc)." msgstr "" -#: tools/alignments/cli.py:31 -msgid " Must Pass in a frames folder/source video file AND a faces folder (-fr and -fc)." +#: tools/alignments/cli.py:45 +msgid "" +" Must Pass in a frames folder/source video file AND a faces folder (-fr and -" +"fc)." msgstr "" -#: tools/alignments/cli.py:33 +#: tools/alignments/cli.py:47 msgid " Use the output option (-o) to process results." msgstr "" -#: tools/alignments/cli.py:41 tools/alignments/cli.py:73 +#: tools/alignments/cli.py:55 tools/alignments/cli.py:94 msgid "processing" msgstr "" -#: tools/alignments/cli.py:43 +#: tools/alignments/cli.py:57 +#, python-brace-format msgid "" -"R|Choose which action you want to perform. NB: All actions require an alignments file (-a) to be passed in.\n" -"L|'draw': Draw landmarks on frames in the selected folder/video. A subfolder will be created within the frames folder to hold the output.{0}\n" -"L|'extract': Re-extract faces from the source frames/video based on alignment data. This is a lot quicker than re-detecting faces. Can pass in the '-een' (--extract-every-n) parameter to only extract every nth frame.{1}\n" -"L|'missing-alignments': Identify frames that do not exist in the alignments file.{2}{0}\n" -"L|'missing-frames': Identify frames in the alignments file that do not appear within the frames folder/video.{2}{0}\n" -"L|'multi-faces': Identify where multiple faces exist within the alignments file.{2}{4}\n" -"L|'no-faces': Identify frames that exist within the alignment file but no faces were detected.{2}{0}\n" -"L|'remove-faces': Remove deleted faces from an alignments file. The original alignments file will be backed up.{3}\n" -"L|'rename' - Rename faces to correspond with their parent frame and position index in the alignments file (i.e. how they are named after running extract).{3}\n" -"L|'sort': Re-index the alignments from left to right. For alignments with multiple faces this will ensure that the left-most face is at index 0.\n" -"L|'spatial': Perform spatial and temporal filtering to smooth alignments (EXPERIMENTAL!)" -msgstr "" - -#: tools/alignments/cli.py:75 +"R|Choose which action you want to perform. NB: All actions require an " +"alignments file (-a) to be passed in.\n" +"L|'draw': Draw landmarks on frames in the selected folder/video. A subfolder " +"will be created within the frames folder to hold the output.{0}\n" +"L|'extract': Re-extract faces from the source frames/video based on " +"alignment data. This is a lot quicker than re-detecting faces. Can pass in " +"the '-een' (--extract-every-n) parameter to only extract every nth frame." +"{1}\n" +"L|'from-faces': Generate alignment file(s) from a folder of extracted faces. " +"if the folder of faces comes from multiple sources, then multiple alignments " +"files will be created. NB: for faces which have been extracted folders of " +"source images, rather than a video, a single alignments file will be created " +"as there is no way for the process to know how many folders of images were " +"originally used. You do not need to provide an alignments file path to run " +"this job. {3}\n" +"L|'missing-alignments': Identify frames that do not exist in the alignments " +"file.{2}{0}\n" +"L|'missing-frames': Identify frames in the alignments file that do not " +"appear within the frames folder/video.{2}{0}\n" +"L|'multi-faces': Identify where multiple faces exist within the alignments " +"file.{2}{4}\n" +"L|'no-faces': Identify frames that exist within the alignment file but no " +"faces were detected.{2}{0}\n" +"L|'remove-faces': Remove deleted faces from an alignments file. The original " +"alignments file will be backed up.{3}\n" +"L|'rename' - Rename faces to correspond with their parent frame and position " +"index in the alignments file (i.e. how they are named after running extract)." +"{3}\n" +"L|'sort': Re-index the alignments from left to right. For alignments with " +"multiple faces this will ensure that the left-most face is at index 0.\n" +"L|'spatial': Perform spatial and temporal filtering to smooth alignments " +"(EXPERIMENTAL!)" +msgstr "" + +#: tools/alignments/cli.py:96 msgid "" "R|How to output discovered items ('faces' and 'frames' only):\n" "L|'console': Print the list of frames to the screen. (DEFAULT)\n" -"L|'file': Output the list of frames to a text file (stored within the source directory).\n" -"L|'move': Move the discovered items to a sub-folder within the source directory." +"L|'file': Output the list of frames to a text file (stored within the source " +"directory).\n" +"L|'move': Move the discovered items to a sub-folder within the source " +"directory." msgstr "" -#: tools/alignments/cli.py:86 tools/alignments/cli.py:94 -#: tools/alignments/cli.py:101 +#: tools/alignments/cli.py:107 tools/alignments/cli.py:118 +#: tools/alignments/cli.py:125 msgid "data" msgstr "" -#: tools/alignments/cli.py:89 -msgid "Full path to the alignments file to be processed." +#: tools/alignments/cli.py:111 +msgid "" +"Full path to the alignments file to be processed. This is required for all " +"jobs except for 'from-faces' when the alignments file will be generated in " +"the specified faces folder." msgstr "" -#: tools/alignments/cli.py:95 +#: tools/alignments/cli.py:119 msgid "Directory containing extracted faces." msgstr "" -#: tools/alignments/cli.py:102 +#: tools/alignments/cli.py:126 msgid "Directory containing source frames that faces were extracted from." msgstr "" -#: tools/alignments/cli.py:111 tools/alignments/cli.py:121 -#: tools/alignments/cli.py:127 +#: tools/alignments/cli.py:135 tools/alignments/cli.py:146 +#: tools/alignments/cli.py:156 msgid "extract" msgstr "" -#: tools/alignments/cli.py:112 -msgid "[Extract only] Extract every 'nth' frame. This option will skip frames when extracting faces. For example a value of 1 will extract faces from every frame, a value of 10 will extract faces from every 10th frame." +#: tools/alignments/cli.py:136 +msgid "" +"[Extract only] Extract every 'nth' frame. This option will skip frames when " +"extracting faces. For example a value of 1 will extract faces from every " +"frame, a value of 10 will extract faces from every 10th frame." msgstr "" -#: tools/alignments/cli.py:123 +#: tools/alignments/cli.py:147 msgid "[Extract only] The output size of extracted faces." msgstr "" -#: tools/alignments/cli.py:133 -msgid "[Extract only] Only extract faces that have been resized by this percent or more to meet the specified extract size (`-sz`, `--size`). Useful for excluding low-res images from a training set. Set to 0 to extract all faces. Eg: For an extract size of 512px, A setting of 50 will only include faces that have been resized from 256px or above. Setting to 100 will only extract faces that have been resized from 512px or above. A setting of 200 will only extract faces that have been downscaled from 1024px or above." +#: tools/alignments/cli.py:157 +msgid "" +"[Extract only] Only extract faces that have been resized by this percent or " +"more to meet the specified extract size (`-sz`, `--size`). Useful for " +"excluding low-res images from a training set. Set to 0 to extract all faces. " +"Eg: For an extract size of 512px, A setting of 50 will only include faces " +"that have been resized from 256px or above. Setting to 100 will only extract " +"faces that have been resized from 512px or above. A setting of 200 will only " +"extract faces that have been downscaled from 1024px or above." msgstr "" - diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index 270c3176b5..010b20260e 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -2,10 +2,15 @@ """ Tools for manipulating the alignments serialized file """ import logging +from typing import TYPE_CHECKING + from .media import AlignmentData -from .jobs import (Check, Draw, Extract, Rename, # noqa pylint: disable=unused-import +from .jobs import (Check, Draw, Extract, FromFaces, Rename, # noqa pylint: disable=unused-import RemoveFaces, Sort, Spatial) +if TYPE_CHECKING: + from argparse import Namespace + logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -20,13 +25,14 @@ class Alignments(): # pylint:disable=too-few-public-methods arguments: :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in from :mod:`tools.py` """ - def __init__(self, arguments): + def __init__(self, arguments: "Namespace") -> None: logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) self.args = arguments - self.alignments = AlignmentData(self.args.alignments_file) + job = self.args.job + self.alignments = None if job == "from-faces" else AlignmentData(self.args.alignments_file) logger.debug("Initialized %s", self.__class__.__name__) - def process(self): + def process(self) -> None: """ The entry point for the Alignments tool from :mod:`lib.tools.alignments.cli`. Launches the selected alignments job. diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py index 8e8e502bef..4ff3a5dfdf 100644 --- a/tools/alignments/cli.py +++ b/tools/alignments/cli.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ +import sys import gettext from lib.cli.args import FaceSwapArgs @@ -18,12 +19,25 @@ class AlignmentsArgs(FaceSwapArgs): """ Class to parse the command line arguments for Alignments tool """ @staticmethod - def get_info(): - """ Return command information """ + def get_info() -> str: + """ Obtain command information. + + Returns + ------- + str + The help text for displaying in argparses help output + """ return _("Alignments tool\nThis tool allows you to perform numerous actions on or using " "an alignments file against its corresponding faceset/frame source.") - def get_argument_list(self): + def get_argument_list(self) -> dict: + """ Collect the argparse argument options. + + Returns + ------- + dict + The argparse command line options for processing by argparse + """ frames_dir = _(" Must Pass in a frames folder/source video file (-fr).") faces_dir = _(" Must Pass in a faces folder (-fc).") frames_or_faces_dir = _(" Must Pass in either a frames folder/source video file OR a" @@ -36,8 +50,8 @@ def get_argument_list(self): opts=("-j", "--job"), action=Radio, type=str, - choices=("draw", "extract", "missing-alignments", "missing-frames", "multi-faces", - "no-faces", "remove-faces", "rename", "sort", "spatial"), + choices=("draw", "extract", "from-faces", "missing-alignments", "missing-frames", + "multi-faces", "no-faces", "remove-faces", "rename", "sort", "spatial"), group=_("processing"), required=True, help=_("R|Choose which action you want to perform. NB: All actions require an " @@ -47,6 +61,13 @@ def get_argument_list(self): "\nL|'extract': Re-extract faces from the source frames/video based on " "alignment data. This is a lot quicker than re-detecting faces. Can pass in " "the '-een' (--extract-every-n) parameter to only extract every nth frame.{1}" + "\nL|'from-faces': Generate alignment file(s) from a folder of extracted " + "faces. if the folder of faces comes from multiple sources, then multiple " + "alignments files will be created. NB: for faces which have been extracted " + "folders of source images, rather than a video, a single alignments file will " + "be created as there is no way for the process to know how many folders of " + "images were originally used. You do not need to provide an alignments file " + "path to run this job. {3}" "\nL|'missing-alignments': Identify frames that do not exist in the alignments " "file.{2}{0}" "\nL|'missing-frames': Identify frames in the alignments file that do not " @@ -84,9 +105,12 @@ def get_argument_list(self): dest="alignments_file", type=str, group=_("data"), - required=True, + # hacky solution to not require alignments file if creating alignments from faces: + required="from-faces" not in sys.argv, filetypes="alignments", - help=_("Full path to the alignments file to be processed."))) + help=_("Full path to the alignments file to be processed. This is required for all " + "jobs except for 'from-faces' when the alignments file will be generated in " + "the specified faces folder."))) argument_list.append(dict( opts=("-fc", "-faces_folder"), action=DirFullPaths, diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index 6a65b19bf5..c66833e5a9 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -7,6 +7,8 @@ from datetime import datetime from typing import TYPE_CHECKING, Optional, List +from argparse import Namespace + import cv2 import numpy as np from scipy import signal @@ -15,13 +17,14 @@ from lib.align import DetectedFace, _EXTRACT_RATIOS from lib.align.alignments import _VERSION -from lib.image import encode_image, generate_thumbnail, ImagesSaver, update_existing_metadata +from lib.image import (encode_image, generate_thumbnail, ImagesSaver, + read_image_meta_batch, update_existing_metadata) from plugins.extract.pipeline import Extractor, ExtractMedia +from scripts.fsmedia import Alignments from .media import ExtractedFaces, Faces, Frames if TYPE_CHECKING: - import argparse from .media import AlignmentData logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -401,7 +404,7 @@ class Extract(): # pylint:disable=too-few-public-methods arguments: :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in from :mod:`tools.py` """ - def __init__(self, alignments: "AlignmentData", arguments: "argparse.Namespace") -> None: + def __init__(self, alignments: "AlignmentData", arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self._arguments = arguments self._alignments = alignments @@ -694,6 +697,242 @@ def _pad_legacy_masks(cls, detected_face: DetectedFace) -> None: mask._affine_matrix = detected_face.mask["components"].affine_matrix +class FromFaces(): # pylint:disable=too-few-public-methods + """ Scan a folder of Faceswap Extracted Faces and re-create the associated alignments file(s) + + Parameters + ---------- + alignments: NoneType + Parameter included for standard job naming convention, but not used for this process. + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + """ + def __init__(self, alignments: None, arguments: Namespace) -> None: + logger.debug("Initializing %s: (alignments: %s, arguments: %s)", + self.__class__.__name__, alignments, arguments) + self._faces_dir = arguments.faces_dir + self._filelist = self._get_filenames() + logger.debug("Initialized %s", self.__class__.__name__) + + def _get_filenames(self) -> list[str]: + """ Obtain the full path to all filenames in the specified faces folder. + + Only png files will be returned, any other files will be ignored. An error is output if + the returned filelist is not valid + + Returns + ------- + list + Full path list to face png files + """ + err = None + if not self._faces_dir: + err = "A faces folder must be provided." + elif not os.path.isdir(self._faces_dir): + err = f"The Faces location '{self._faces_dir}' does not exit" + else: + filelist = [os.path.join(self._faces_dir, fname) + for fname in os.listdir(self._faces_dir) + if os.path.splitext(fname.lower())[1] == ".png"] + if not err and not filelist: + err = "Faces folder should contain Faceswap extracted PNG files" + if err: + logger.error(err) + sys.exit(0) + logger.debug("Collected %s png images from folder '%s'", len(filelist), self._faces_dir) + return filelist + + def process(self) -> None: + """ Run the job to read faces from a folder to create alignments file(s). """ + logger.info("[CREATE ALIGNMENTS FROM FACES]") # Tidy up cli output + skip_count = 0 + d_align = {} + for filename, meta in tqdm(read_image_meta_batch(self._filelist), + desc="Generating Alignments", + total=len(self._filelist), + leave=False): + + if "itxt" not in meta or "alignments" not in meta["itxt"]: + logger.verbose("skipping invalid file: '%s'", filename) + skip_count += 1 + continue + + align_fname = self._get_alignments_filename(meta["itxt"]["source"]) + source_name, f_idx, alignment = self._extract_alignment(meta) + full_info = (f_idx, alignment, filename, meta["itxt"]["source"]) + + d_align.setdefault(align_fname, {}).setdefault(source_name, []).append(full_info) + + alignments = self._sort_alignments(d_align) + self._save_alignments(alignments) + if skip_count > 1: + logger.warning("%s of %s files skipped that do not contain valid alignment data", + skip_count, len(self._filelist)) + logger.warning("Run the process in verbose mode to see which files were skipped") + + @classmethod + def _get_alignments_filename(cls, source_data: dict) -> str: + """ Obtain the name of the alignments file from the source information contained within the + PNG metadata. + + Parameters + ---------- + source_data: dict + The source information contained within a Faceswap extracted PNG + + Returns + ------- + str: + If the face was generated from a video file, the filename will be + `'_alignments.fsa'`. If it was extracted from an image file it will be + `'alignments.fsa'` + """ + is_video = source_data["source_is_video"] + src_name = source_data["source_filename"] + prefix = f"{src_name.rpartition('_')[0]}_" if is_video else "" + retval = f"{prefix}alignments.fsa" + logger.trace("Extracted alignments file filename: '%s'", retval) + return retval + + def _extract_alignment(self, metadata: dict) -> tuple[str, int, dict]: + """ Extract alignment data from a PNG image's itxt header. + + Formats the landmarks into a numpy array and adds in mask centering information if it is + from an older extract. + + Parameters + ---------- + metadata: dict + An extracted faces PNG Header data + + Returns + ------- + tuple + The alignment's source frame name in position 0. The index of the face within the + alignment file in position 1. The alignment data correctly formatted for writing to an + alignments file in positin 2 + """ + alignment = metadata["itxt"]["alignments"] + alignment["landmarks_xy"] = np.array(alignment["landmarks_xy"], dtype="float32") + + src = metadata["itxt"]["source"] + frame_name = src["source_filename"] + face_index = int(src["face_index"]) + version = src["alignments_version"] + + if version < 2.2: + logger.trace("Updating mask centering for frame '%s', face index: %s, version: %s", + frame_name, face_index, version) + self._update_mask_centering(alignment) + + logger.trace("Extracted alignment for frame: '%s', face index: %s", frame_name, face_index) + return frame_name, face_index, alignment + + @classmethod + def _update_mask_centering(cls, alignment: dict) -> None: + """ Prior to alignment version 2.2 all masks were stored with face centering. + + Update the existing masks with correct centering parameter. + + Parameters + ---------- + alignment: dict + The alignment for the face to have the mask centering parameter updated + """ + if "mask" not in alignment: + alignment["mask"] = {} + for mask in alignment["mask"].values(): + mask["stored_centering"] = "face" + + def _sort_alignments(self, alignments: dict) -> dict: + """ Sort the faces into face index order as they appeared in the original alignments file. + + If the face index stored in the png header does not match it's position in the alignments + file (i.e. A face has been removed from a frame) then update the header of the + corresponding png to the correct index as exists in the newly created alignments file. + + Parameters + ---------- + alignments: dict + The unsorted alignments file(s) as generated from the face PNG headers, including the + face index of the face within it's respective frame, the original face filename and + the orignal face header source information + + Returns + ------- + dict + The alignments file dictionaries sorted into the correct face order, ready for savind + """ + logger.info("Sorting and checking faces...") + aln_sorted = {} + for fname, frames in alignments.items(): + this_file = {} + for frame in tqdm(sorted(frames), desc=f"Sorting {fname}", leave=False): + this_file[frame] = [] + for real_idx, (f_id, alignment, f_path, f_src) in enumerate(sorted(frames[frame])): + if real_idx != f_id: + self._update_png_header(f_path, real_idx, alignment, f_src) + this_file[frame].append(alignment) + aln_sorted[fname] = this_file + return aln_sorted + + @classmethod + def _update_png_header(cls, + face_path: str, + new_index: int, + alignment: dict, + source_info: dict) -> None: + """ Update the PNG header for faces where the stored index does not correspond with the + alignments file. This can occur when frames with multiple faces have had some faces deleted + from the faces folder. + + Updates the original filename and index in the png header. + + Parameters + ---------- + face_path: str + Full path to the saved face image that requires updating + new_index: int + The new index as it appears in the newly generated alignments file + alignment: dict + The alignment information to store in the png header + source_info: dict + The face source information as extracted from the original face png file + """ + face = DetectedFace() + face.from_alignment(alignment) + new_filename = f"{os.path.splitext(source_info['source_filename'])[0]}_{new_index}.png" + + logger.trace("Updating png header for '%s': (face index from %s to %s, original filename " + "from '%s' to '%s'", face_path, source_info["face_index"], new_index, + source_info["original_filename"], new_filename) + + source_info["face_index"] = new_index + source_info["original_filename"] = new_filename + meta = dict(alignments=face.to_png_meta(), source=source_info) + update_existing_metadata(face_path, meta) + + def _save_alignments(self, all_alignments: dict) -> None: + """ Save the newely generated alignments file(s). + + If an alignments file already exists in the source faces folder, back it up rather than + overwriting + + Parameters + ---------- + all_alignments: dict + The alignment(s) dictionaries found in the faces folder. Alignment filename as key, + corresponding alignments as value. + """ + for fname, alignments in all_alignments.items(): + 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.backup() + aln.save() + + class RemoveFaces(): # pylint:disable=too-few-public-methods """ Remove items from alignments file. @@ -704,7 +943,7 @@ class RemoveFaces(): # pylint:disable=too-few-public-methods arguments: :class:`argparse.Namespace` The command line arguments that have called this job """ - def __init__(self, alignments, arguments): + def __init__(self, alignments: "AlignmentData", arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self._alignments = alignments @@ -715,7 +954,7 @@ def __init__(self, alignments, arguments): self._items = Faces(arguments.faces_dir, **kwargs) logger.debug("Initialized %s", self.__class__.__name__) - def process(self): + def process(self) -> None: """ Run the job to remove faces from an alignments file that do not exist within a faces folder. """ logger.info("[REMOVE FACES FROM ALIGNMENTS]") # Tidy up cli output @@ -740,7 +979,7 @@ def process(self): rename = Rename(self._alignments, None, self._items) rename.process() - def _update_png_headers(self): + def _update_png_headers(self) -> None: """ Update the EXIF iTXt field of any face PNGs that have had their face index changed. Notes @@ -794,7 +1033,10 @@ class Rename(): # pylint:disable=too-few-public-methods An optional faces object, if the rename task is being called by another job. Default: ``None`` """ - def __init__(self, alignments, arguments, faces=None): + def __init__(self, + alignments: "AlignmentData", + arguments: Namespace, + faces: Optional[Faces] = None) -> None: logger.debug("Initializing %s: (arguments: %s, faces: %s)", self.__class__.__name__, arguments, faces) self._alignments = alignments @@ -806,7 +1048,7 @@ def __init__(self, alignments, arguments, faces=None): self._faces = faces if faces else Faces(arguments.faces_dir, **kwargs) logger.debug("Initialized %s", self.__class__.__name__) - def process(self): + def process(self) -> None: """ Process the face renaming """ logger.info("[RENAME FACES]") # Tidy up cli output rename_mappings = sorted([(face["current_filename"], face["original_filename"]) @@ -816,7 +1058,7 @@ def process(self): rename_count = self._rename_faces(rename_mappings) logger.info("%s faces renamed", rename_count) - def _rename_faces(self, filename_mappings): + def _rename_faces(self, filename_mappings: list[tuple[str, str]]) -> int: """ Rename faces back to their original name as exists in the alignments file. If the source and destination filename are the same then skip that file. @@ -875,12 +1117,12 @@ class Sort(): arguments: :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in from :mod:`tools.py` """ - def __init__(self, alignments, arguments): + def __init__(self, alignments: "AlignmentData", arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self._alignments = alignments logger.debug("Initialized %s", self.__class__.__name__) - def process(self): + def process(self) -> None: """ Execute the sort process """ logger.info("[SORT INDEXES]") # Tidy up cli output reindexed = self.reindex_faces() @@ -889,7 +1131,7 @@ def process(self): logger.warning("If you have a face-set corresponding to the alignment file you " "processed then you should run the 'Extract' job to regenerate it.") - def reindex_faces(self): + def reindex_faces(self) -> None: """ Re-Index the faces """ reindexed = 0 for alignment in tqdm(self._alignments.yield_faces(), From f848e8ad0dff8435c199dd17ce2aac7cbf476854 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 25 May 2022 12:50:09 +0100 Subject: [PATCH 584/981] bugfix - typing for python < 3.9 --- tools/alignments/jobs.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index c66833e5a9..f536e3a04a 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -5,7 +5,7 @@ import os import sys from datetime import datetime -from typing import TYPE_CHECKING, Optional, List +from typing import List, Tuple, TYPE_CHECKING, Optional from argparse import Namespace @@ -714,7 +714,7 @@ def __init__(self, alignments: None, arguments: Namespace) -> None: self._filelist = self._get_filenames() logger.debug("Initialized %s", self.__class__.__name__) - def _get_filenames(self) -> list[str]: + def _get_filenames(self) -> List[str]: """ Obtain the full path to all filenames in the specified faces folder. Only png files will be returned, any other files will be ignored. An error is output if @@ -794,7 +794,7 @@ def _get_alignments_filename(cls, source_data: dict) -> str: logger.trace("Extracted alignments file filename: '%s'", retval) return retval - def _extract_alignment(self, metadata: dict) -> tuple[str, int, dict]: + def _extract_alignment(self, metadata: dict) -> Tuple[str, int, dict]: """ Extract alignment data from a PNG image's itxt header. Formats the landmarks into a numpy array and adds in mask centering information if it is @@ -1058,7 +1058,7 @@ def process(self) -> None: rename_count = self._rename_faces(rename_mappings) logger.info("%s faces renamed", rename_count) - def _rename_faces(self, filename_mappings: list[tuple[str, str]]) -> int: + def _rename_faces(self, filename_mappings: List[Tuple[str, str]]) -> int: """ Rename faces back to their original name as exists in the alignments file. If the source and destination filename are the same then skip that file. From a2b8e324aee52184e9b718a8ecd74c4398c28917 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 28 May 2022 01:46:46 +0100 Subject: [PATCH 585/981] bugfix: distibuted training with dssim --- lib/model/losses_tf.py | 93 +++++++++++++++++++++++------------- plugins/train/model/_base.py | 9 ++-- 2 files changed, 64 insertions(+), 38 deletions(-) diff --git a/lib/model/losses_tf.py b/lib/model/losses_tf.py index fac4f6d00e..c7c63918bf 100644 --- a/lib/model/losses_tf.py +++ b/lib/model/losses_tf.py @@ -4,6 +4,7 @@ from __future__ import absolute_import import logging +from typing import Tuple import numpy as np import tensorflow as tf @@ -38,12 +39,12 @@ class DSSIMObjective(tf.keras.losses.Loss): # pylint:disable=too-few-public-met You should add a regularization term like a l2 loss in addition to this one. """ def __init__(self, k_1=0.01, k_2=0.03, filter_size=11, filter_sigma=1.5, max_value=1.0): - super().__init__(name="DSSIMObjective") - self.filter_size = filter_size - self.filter_sigma = filter_sigma - self.k_1 = k_1 - self.k_2 = k_2 - self.max_value = max_value + super().__init__(name="DSSIMObjective", reduction=tf.keras.losses.Reduction.NONE) + self._filter_size = filter_size + self._filter_sigma = filter_sigma + self._k_1 = k_1 + self._k_2 = k_2 + self._max_value = max_value def call(self, y_true, y_pred): """ Call the DSSIM Loss Function. @@ -62,11 +63,11 @@ def call(self, y_true, y_pred): """ ssim = tf.image.ssim(y_true, y_pred, - self.max_value, - filter_size=self.filter_size, - filter_sigma=self.filter_sigma, - k1=self.k_1, - k2=self.k_2) + self._max_value, + filter_size=self._filter_size, + filter_sigma=self._filter_sigma, + k1=self._k_1, + k2=self._k_2) dssim_loss = (1. - ssim) / 2.0 return dssim_loss @@ -470,24 +471,42 @@ def _scharr_edges(cls, image, magnitude): return output -class LossWrapper(tf.keras.losses.Loss): - """ A wrapper class for multiple keras losses to enable multiple weighted loss functions on a - single output. +class LossWrapper(): + """ A wrapper class for multiple keras losses to enable multiple masked weighted loss + functions on a single output. + + Notes + ----- + Whilst Keras does allow for applying multiple weighted loss functions, it does not allow + for an easy mechanism to add additional data (in our case masks) that are batch specific + but are not fed in to the model. + + This wrapper receives this additional mask data for the batch stacked onto the end of the + color channels of the received :param:`y_true` batch of images. These masks are then split + off the batch of images and applied to both the :param:`y_true` and :param:`y_pred` tensors + prior to feeding into the loss functions. + + For example, for an image of shape (4, 128, 128, 3) 3 additional masks may be stacked onto + the end of y_true, meaning we receive an input of shape (4, 128, 128, 6). This wrapper then + splits off (4, 128, 128, 3:6) from the end of the tensor, leaving the original y_true of + shape (4, 128, 128, 3) ready for masking and feeding through the loss functions. """ - def __init__(self): + def __init__(self) -> None: logger.debug("Initializing: %s", self.__class__.__name__) - super().__init__(name="LossWrapper") self._loss_functions = [] self._loss_weights = [] self._mask_channels = [] logger.debug("Initialized: %s", self.__class__.__name__) - def add_loss(self, function, weight=1.0, mask_channel=-1): + def add_loss(self, + function: tf.keras.losses.Loss, + weight: float = 1.0, + mask_channel: int = -1) -> None: """ Add the given loss function with the given weight to the loss function chain. Parameters ---------- - function: :class:`keras.losses.Loss` + function: :class:`tf.keras.losses.Loss` The loss function to add to the loss chain weight: float, optional The weighting to apply to the loss function. Default: `1.0` @@ -497,29 +516,31 @@ def add_loss(self, function, weight=1.0, mask_channel=-1): """ logger.debug("Adding loss: (function: %s, weight: %s, mask_channel: %s)", function, weight, mask_channel) + # Loss must be compiled inside LossContainer for keras to handle distibuted strategies self._loss_functions.append(compile_utils.LossesContainer(function)) self._loss_weights.append(weight) self._mask_channels.append(mask_channel) - def call(self, y_true, y_pred): + def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: """ Call the sub loss functions for the loss wrapper. - Weights are returned as the weighted sum of the chosen losses. + Loss is returned as the weighted sum of the chosen losses. - If a mask is being applied to the loss, then the appropriate mask is extracted from y_true - and added as the 4th channel being passed to the penalized loss function. + If masks are being applied to the loss function inputs, then they should be included as + additional channels at the end of :param:`y_true`, so that they can be split off and + applied to the actual inputs to the selected loss function(s). Parameters ---------- - y_true: tensor or variable - The ground truth value - y_pred: tensor or variable - The predicted value + y_true: :class:`tensorflow.Tensor` + The ground truth batch of images, with any required masks stacked on the end + y_pred: :class:`tensorflow.Tensor` + The batch of model predictions Returns ------- - tensor - The final loss value + :class:`tensorflow.Tensor` + The final weighted loss """ loss = 0.0 for func, weight, mask_channel in zip(self._loss_functions, @@ -532,7 +553,11 @@ def call(self, y_true, y_pred): return loss @classmethod - def _apply_mask(cls, y_true, y_pred, mask_channel, mask_prop=1.0): + def _apply_mask(cls, + y_true: tf.Tensor, + y_pred: tf.Tensor, + mask_channel: int, + mask_prop: float = 1.0) -> Tuple[tf.Tensor, tf.Tensor]: """ Apply the mask to the input y_true and y_pred. If a mask is not required then return the unmasked inputs. @@ -549,8 +574,10 @@ def _apply_mask(cls, y_true, y_pred, mask_channel, mask_prop=1.0): Returns ------- - tuple - (n_true, n_pred): The ground truth and predicted value tensors with the mask applied + tf.Tensor + The ground truth batch of images, with the required mask applied + tf.Tensor + The predicted batch of images with the required mask applied """ if mask_channel == -1: logger.debug("No mask to apply") @@ -561,7 +588,7 @@ def _apply_mask(cls, y_true, y_pred, mask_channel, mask_prop=1.0): mask_as_k_inv_prop = 1 - mask_prop mask = (mask * mask_prop) + mask_as_k_inv_prop - n_true = K.concatenate([y_true[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) - n_pred = K.concatenate([y_pred[:, :, :, i:i+1] * mask for i in range(3)], axis=-1) + n_true = K.concatenate([y_true[..., i:i + 1] * mask for i in range(3)], axis=-1) + n_pred = K.concatenate([y_pred[..., i:i + 1] * mask for i in range(3)], axis=-1) return n_true, n_pred diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 66a345500d..4500b02f63 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -438,7 +438,7 @@ def _compile_model(self): weights.freeze() self._loss.configure(self._model) - self._model.compile(optimizer=optimizer, loss=self._loss.functions) + self._model.compile(optimizer=optimizer, loss=self._loss.functions, run_eagerly=False) self._state.add_session_loss_names(self._loss.names) logger.debug("Compiled Model: %s", self._model) @@ -1256,10 +1256,9 @@ def _set_loss_names(self, outputs): output_types = ["mask" if shape[-1] == 1 else "face" for shape in output_shapes] logger.debug("side: %s, output names: %s, output_shapes: %s, output_types: %s", side, output_names, output_shapes, output_types) - self._names.extend(["{}_{}{}".format(name, side, - "" if output_types.count(name) == 1 - else f"_{idx}") - for idx, name in enumerate(output_types)]) + for idx, name in enumerate(output_types): + suffix = "" if output_types.count(name) == 1 else f"_{idx}" + self._names.append(f"{name}_{side}{suffix}") logger.debug(self._names) def _set_loss_functions(self, output_names): From c5fec272006ae2886eff2299c71ca3f803e4490f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 28 May 2022 23:20:29 +0100 Subject: [PATCH 586/981] bugfix: remove AMD incompatible 'run_eagerly' kwarg --- plugins/train/model/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index 4500b02f63..a35bfccf73 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -438,7 +438,7 @@ def _compile_model(self): weights.freeze() self._loss.configure(self._model) - self._model.compile(optimizer=optimizer, loss=self._loss.functions, run_eagerly=False) + self._model.compile(optimizer=optimizer, loss=self._loss.functions) self._state.add_session_loss_names(self._loss.names) logger.debug("Compiled Model: %s", self._model) From 5a8b5d7b3c6b0b413fe2b4d9247b9dd0cd692fa0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 29 May 2022 00:31:27 +0100 Subject: [PATCH 587/981] bugfix: ffmpeg writer - prevent crash if no audio in source --- plugins/convert/writer/ffmpeg.py | 42 ++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/plugins/convert/writer/ffmpeg.py b/plugins/convert/writer/ffmpeg.py index 43cc09b0a8..509289df58 100644 --- a/plugins/convert/writer/ffmpeg.py +++ b/plugins/convert/writer/ffmpeg.py @@ -2,6 +2,7 @@ """ Video output writer for faceswap.py converter """ import os from math import ceil +from subprocess import CalledProcessError, check_output, STDOUT from typing import Optional, List, Tuple, Generator import imageio @@ -98,9 +99,50 @@ def _audio_codec(self) -> Optional[str]: "The output video will be created but you will need to mux audio " "manually.") retval = None + elif not self._test_for_audio_stream(): + logger.warning("No audio stream could be found in the source video '%s'. Muxing audio " + "will be disabled.", self._source_video) + retval = None logger.debug("Audio codec: %s", retval) return retval + def _test_for_audio_stream(self) -> bool: + """ Check whether the source video file contains an audio stream. + + If we attempt to mux audio from a source video that does not contain an audio stream + ffmpeg will crash faceswap in a fairly ugly manner. + + Returns + ------- + bool + ``True if an audio stream is found in the source video file, otherwise ``False`` + + Raises + ------ + RuntimeError + If a subprocess error is raised scanning the input video file + """ + exe = im_ffm.get_ffmpeg_exe() + cmd = [exe, "-hide_banner", "-i", self._source_video, "-f", "ffmetadata", "-"] + + try: + out = check_output(cmd, stderr=STDOUT) + except CalledProcessError as err: + out = err.output.decode(errors="ignore") + raise ValueError("Error checking audio stream. Status: " + f"{err.returncode}\n{out}") from err + + retval = False + for line in out.splitlines(): + if not line.strip().startswith(b"Stream #"): + continue + logger.debug("scanning Stream line: %s", line.decode(errors="ignore").strip()) + if b"Audio" in line: + retval = True + break + logger.debug("Audio found: %s", retval) + return retval + def _get_output_filename(self) -> str: """ Return full path to video output file. From 5792e6a088a0e773dd48d566f9ccf410fb2c3397 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 29 May 2022 00:32:29 +0100 Subject: [PATCH 588/981] typofix --- plugins/convert/writer/ffmpeg.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/convert/writer/ffmpeg.py b/plugins/convert/writer/ffmpeg.py index 509289df58..ca1f33bf02 100644 --- a/plugins/convert/writer/ffmpeg.py +++ b/plugins/convert/writer/ffmpeg.py @@ -115,11 +115,11 @@ def _test_for_audio_stream(self) -> bool: Returns ------- bool - ``True if an audio stream is found in the source video file, otherwise ``False`` + ``True`` if an audio stream is found in the source video file, otherwise ``False`` Raises ------ - RuntimeError + ValueError If a subprocess error is raised scanning the input video file """ exe = im_ffm.get_ffmpeg_exe() From afec52309326304f4323029039e49bfcf928ef43 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 29 May 2022 13:13:45 +0100 Subject: [PATCH 589/981] Bugfixes: - Stats graph - Handle NaNs in data - logger - de-elevate matplotlib font messages --- lib/gui/analysis/stats.py | 158 +++++++++++++++++++++----------------- lib/gui/popup_session.py | 108 +++++++++++++++++--------- lib/logger.py | 36 ++++++++- 3 files changed, 192 insertions(+), 110 deletions(-) diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index 97446ee6ef..9ab8347d1e 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -13,6 +13,8 @@ from math import ceil from threading import Event +from typing import List, Optional, Tuple, Union +from typing_extensions import Self import numpy as np @@ -29,7 +31,7 @@ class GlobalSession(): file and Tensorboard logs. This class should not be accessed directly, rather through :attr:`lib.gui.analysis.Session` """ - def __init__(self): + def __init__(self) -> None: logger.debug("Initializing %s", self.__class__.__name__) self._state = None self._model_dir = None @@ -44,23 +46,23 @@ def __init__(self): logger.debug("Initialized %s", self.__class__.__name__) @property - def is_loaded(self): + def is_loaded(self) -> bool: """ bool: ``True`` if session data is loaded otherwise ``False`` """ return self._model_dir is not None @property - def is_training(self): + def is_training(self) -> bool: """ bool: ``True`` if the loaded session is the currently training model, otherwise ``False`` """ return self._is_training @property - def model_filename(self): + def model_filename(self) -> str: """ str: The full model filename """ return os.path.join(self._model_dir, self._model_name) @property - def batch_sizes(self): + def batch_sizes(self) -> dict: """ dict: The batch sizes for each session_id for the model. """ if self._state is None: return {} @@ -68,12 +70,12 @@ def batch_sizes(self): for sess_id, sess in self._state.get("sessions", {}).items()} @property - def full_summary(self): + def full_summary(self) -> List[dict]: """ list: List of dictionaries containing summary statistics for each session id. """ return self._summary.get_summary_stats() @property - def logging_disabled(self): + def logging_disabled(self) -> bool: """ bool: ``True`` if logging is enabled for the currently training session otherwise ``False``. """ if self._state is None: @@ -81,11 +83,11 @@ def logging_disabled(self): return self._state["sessions"][str(self.session_ids[-1])]["no_logs"] @property - def session_ids(self): + def session_ids(self) -> List[int]: """ list: The sorted list of all existing session ids in the state file """ return self._tb_logs.session_ids - def _load_state_file(self): + def _load_state_file(self) -> None: """ Load the current state file to :attr:`_state`. """ state_file = os.path.join(self._model_dir, f"{self._model_name}_state.json") logger.debug("Loading State: '%s'", state_file) @@ -93,7 +95,10 @@ def _load_state_file(self): self._state = serializer.load(state_file) logger.debug("Loaded state: %s", self._state) - def initialize_session(self, model_folder, model_name, is_training=False): + def initialize_session(self, + model_folder: Optional[str], + model_name: Optional[str], + is_training: bool = False) -> None: """ Initialize a Session. Load's the model's state file, and sets the paths to any underlying Tensorboard logs, ready @@ -132,13 +137,13 @@ def initialize_session(self, model_folder, model_name, is_training=False): self._summary = SessionsSummary(self) logger.debug("Initialized session. Session_IDS: %s", self.session_ids) - def stop_training(self): + def stop_training(self) -> None: """ Clears the internal training flag. To be called when training completes. """ self._is_training = False if self._tb_logs is not None: self._tb_logs.set_training(False) - def clear(self): + def clear(self) -> None: """ Clear the currently loaded session. """ self._state = {} self._model_dir = None @@ -152,7 +157,7 @@ def clear(self): self._is_training = False - def get_loss(self, session_id): + def get_loss(self, session_id: Optional[int]) -> dict: """ Obtain the loss values for the given session_id. Parameters @@ -185,7 +190,7 @@ def get_loss(self, session_id): self._is_querying.clear() return retval - def get_timestamps(self, session_id): + def get_timestamps(self, session_id: Optional[int]) -> Union[dict, np.ndarray]: """ Obtain the time stamps keys for the given session_id. Parameters @@ -215,7 +220,7 @@ def get_timestamps(self, session_id): return retval - def _wait_for_thread(self): + def _wait_for_thread(self) -> None: """ If a thread is querying the log files for live data, then block until task clears. """ while True: if self._is_training and self._is_querying.is_set(): @@ -224,7 +229,7 @@ def _wait_for_thread(self): continue break - def get_loss_keys(self, session_id): + def get_loss_keys(self, session_id: Optional[int]) -> List[str]: """ Obtain the loss keys for the given session_id. Parameters @@ -269,7 +274,7 @@ class SessionsSummary(): # pylint:disable=too-few-public-methods session: :class:`GlobalSession` The loaded or currently training session """ - def __init__(self, session): + def __init__(self, session: GlobalSession) -> None: logger.debug("Initializing %s: (session: %s)", self.__class__.__name__, session) self._session = session self._state = session._state @@ -278,7 +283,7 @@ def __init__(self, session): self._per_session_stats = None logger.debug("Initialized %s", self.__class__.__name__) - def get_summary_stats(self): + def get_summary_stats(self) -> List[dict]: """ Compile the individual session statistics and calculate the total. Format the stats for display @@ -302,7 +307,7 @@ def get_summary_stats(self): logger.debug("Final stats: %s", retval) return retval - def _get_time_stats(self): + def _get_time_stats(self) -> None: """ Populates the attribute :attr:`_time_stats` with the start start time, end time and data points for each session id within the loaded session if it has not already been calculated. @@ -332,7 +337,7 @@ def _get_time_stats(self): logger.debug("time_stats: %s", self._time_stats) - def _get_per_session_stats(self): + def _get_per_session_stats(self) -> None: """ Populate the attribute :attr:`_per_session_stats` with a sorted list by session ID of each ID in the training/loaded session. Stats contain the session ID, start, end and elapsed times, the training rate, batch size and number of iterations for each session. @@ -342,13 +347,13 @@ def _get_per_session_stats(self): if self._per_session_stats is None: logger.debug("Collating per session stats") compiled = [] - for session_id, ts_data in self._time_stats.items(): + for session_id in self._time_stats: logger.debug("Compiling session ID: %s", session_id) if self._state is None: logger.debug("Session state dict doesn't exist. Most likely task has been " "terminated during compilation") return - compiled.append(self._collate_stats(session_id, ts_data)) + compiled.append(self._collate_stats(session_id)) self._per_session_stats = list(sorted(compiled, key=lambda k: k["session"])) @@ -358,7 +363,7 @@ def _get_per_session_stats(self): ts_data = self._time_stats[session_id] if session_id > len(self._per_session_stats): - self._per_session_stats.append(self._collate_stats(session_id, ts_data)) + self._per_session_stats.append(self._collate_stats(session_id)) stats = self._per_session_stats[-1] @@ -370,15 +375,13 @@ def _get_per_session_stats(self): / stats["elapsed"] if stats["elapsed"] != 0 else 0) logger.debug("per_session_stats: %s", self._per_session_stats) - def _collate_stats(self, session_id, timestamps): + def _collate_stats(self, session_id: int) -> dict: """ Collate the session summary statistics for the given session ID. Parameters ---------- session_id: int The session id to compile the stats for - timestamps: - The time stamp summary data for the given session id Returns ------- @@ -399,7 +402,7 @@ def _collate_stats(self, session_id, timestamps): logger.debug(retval) return retval - def _total_stats(self): + def _total_stats(self) -> dict: """ Compile the Totals stats. Totals are fully calculated each time as they will change on the basis of the training session. @@ -436,7 +439,7 @@ def _total_stats(self): logger.debug(totals) return totals - def _format_stats(self, compiled_stats): + def _format_stats(self, compiled_stats: List[dict]) -> List[dict]: """ Format for the incoming list of statistics for display. Parameters @@ -466,7 +469,7 @@ def _format_stats(self, compiled_stats): return retval @classmethod - def _convert_time(cls, timestamp): + def _convert_time(cls, timestamp: float) -> Tuple[str, str, str]: """ Convert time stamp to total hours, minutes and seconds. Parameters @@ -477,11 +480,10 @@ def _convert_time(cls, timestamp): Returns ------- tuple - (`hours`, `minutes`, `seconds`) as ints + (`hours`, `minutes`, `seconds`) as strings """ hrs = int(timestamp // 3600) - if hrs < 10: - hrs = f"{hrs:02d}" + hrs = f"{hrs:02d}" if hrs < 10 else str(hrs) mins = f"{(int(timestamp % 3600) // 60):02d}" secs = f"{(int(timestamp % 3600) % 60):02d}" return hrs, mins, secs @@ -511,12 +513,12 @@ class Calculations(): ``False``. Default: ``False`` """ def __init__(self, session_id, - display="loss", - loss_keys="loss", - selections="raw", - avg_samples=500, - smooth_amount=0.90, - flatten_outliers=False): + display: str = "loss", + loss_keys: str = "loss", + selections: str = "raw", + avg_samples: int = 500, + smooth_amount: float = 0.90, + flatten_outliers: bool = False) -> None: logger.debug("Initializing %s: (session_id: %s, display: %s, loss_keys: %s, " "selections: %s, avg_samples: %s, smooth_amount: %s, flatten_outliers: %s)", self.__class__.__name__, session_id, display, loss_keys, selections, @@ -541,21 +543,21 @@ def __init__(self, session_id, logger.debug("Initialized %s", self.__class__.__name__) @property - def iterations(self): + def iterations(self) -> int: """ int: The number of iterations in the data set. """ return self._iterations @property - def start_iteration(self): + def start_iteration(self) -> int: """ int: The starting iteration number of a limit has been set on the amount of data. """ return self._start_iteration @property - def stats(self): + def stats(self) -> dict: """ dict: The final calculated statistics """ return self._stats - def refresh(self): + def refresh(self) -> Optional[Self]: """ Refresh the stats """ logger.debug("Refreshing") if not _SESSION.is_loaded: @@ -565,10 +567,13 @@ def refresh(self): self._get_raw() self._get_calculations() self._remove_raw() - logger.debug("Refreshed") + logger.debug("Refreshed: %s", {k: f"Total: {len(v)}, Min: {np.nanmin(v)}, " + f"Max: {np.nanmax(v)}, " + f"nans: {np.count_nonzero(np.isnan(v))}" + for k, v in self.stats.items()}) return self - def set_smooth_amount(self, amount): + def set_smooth_amount(self, amount: float) -> None: """ Set the amount of smoothing to apply to smoothed graph. Parameters @@ -580,7 +585,7 @@ def set_smooth_amount(self, amount): logger.debug("Setting smooth amount to: %s (provided value: %s)", update, amount) self._args["smooth_amount"] = update - def update_selections(self, selection, option): + def update_selections(self, selection: str, option: bool) -> None: """ Update the type of selected data. Parameters @@ -603,7 +608,7 @@ def update_selections(self, selection, option): if selection in self._selections: self._selections.remove(selection) - def set_iterations_limit(self, limit): + def set_iterations_limit(self, limit: int) -> None: """ Set the number of iterations to display in the calculations. If a value greater than 0 is passed, then the latest iterations up to the given @@ -618,7 +623,7 @@ def set_iterations_limit(self, limit): logger.debug("Setting iteration limit to: %s", limit) self._limit = limit - def _get_raw(self): + def _get_raw(self) -> None: """ Obtain the raw loss values and add them to a new :attr:`stats` dictionary. """ logger.debug("Getting Raw Data") self.stats.clear() @@ -662,10 +667,13 @@ def _get_raw(self): self._iterations = data.shape[0] self.stats["raw_rate"] = data - logger.debug("Got Raw Data") + logger.debug("Got Raw Data: %s", {k: f"Total: {len(v)}, Min: {np.nanmin(v)}, " + f"Max: {np.nanmax(v)}, " + f"nans: {np.count_nonzero(np.isnan(v))}" + for k, v in self.stats.items()}) @classmethod - def _flatten_outliers(cls, data): + def _flatten_outliers(cls, data: np.ndarray) -> np.ndarray: """ Remove the outliers from a provided list. Removes data more than 1 Standard Deviation from the mean. @@ -681,14 +689,14 @@ def _flatten_outliers(cls, data): The data with outliers removed """ logger.debug("Flattening outliers: %s", data.shape) - mean = np.mean(data) - limit = np.std(data) + mean = np.mean(np.nan_to_num(data)) + limit = np.std(np.nan_to_num(data)) logger.debug("mean: %s, limit: %s", mean, limit) retdata = np.where(abs(data - mean) < limit, data, mean) logger.debug("Flattened outliers") return retdata - def _remove_raw(self): + def _remove_raw(self) -> None: """ Remove raw values from :attr:`stats` if they are not requested. """ if "raw" in self._selections: return @@ -698,7 +706,7 @@ def _remove_raw(self): del self._stats[key] logger.debug("Removed Raw Data from output") - def _calc_rate(self): + def _calc_rate(self) -> np.ndarray: """ Calculate rate per iteration. Returns @@ -713,7 +721,7 @@ def _calc_rate(self): return retval @classmethod - def _calc_rate_total(cls): + def _calc_rate_total(cls) -> np.ndarray: """ Calculate rate per iteration for all sessions. Returns @@ -738,7 +746,7 @@ def _calc_rate_total(cls): logger.debug("Calculated totals rate: Item_count: %s", len(retval)) return retval - def _get_calculations(self): + def _get_calculations(self) -> None: """ Perform the required calculations and populate :attr:`stats`. """ for selection in self._selections: if selection == "raw": @@ -749,8 +757,13 @@ def _get_calculations(self): for key in raw_keys: selected_key = f"{selection}_{key.replace('raw_', '')}" self._stats[selected_key] = method(self._stats[key]) + logger.debug("Got calculations: %s", {k: f"Total: {len(v)}, Min: {np.nanmin(v)}, " + f"Max: {np.nanmax(v)}, " + f"nans: {np.count_nonzero(np.isnan(v))}" + for k, v in self.stats.items() + if not k.startswith("raw")}) - def _calc_avg(self, data): + def _calc_avg(self, data: np.ndarray) -> np.ndarray: """ Calculate moving average. Parameters @@ -763,7 +776,7 @@ def _calc_avg(self, data): :class:`numpy.ndarray` The moving average for the given data """ - logger.debug("Calculating Average") + logger.debug("Calculating Average. Data points: %s", len(data)) window = self._args["avg_samples"] pad = ceil(window / 2) datapoints = data.shape[0] @@ -772,14 +785,14 @@ def _calc_avg(self, data): logger.info("Not enough data to compile rolling average") return np.array([], dtype="float64") - avgs = np.cumsum(data, dtype="float64") + avgs = np.cumsum(np.nan_to_num(data), dtype="float64") avgs[window:] = avgs[window:] - avgs[:-window] avgs = avgs[window - 1:] / window avgs = np.pad(avgs, (pad, datapoints - (avgs.shape[0] + pad)), constant_values=(np.nan,)) logger.debug("Calculated Average: shape: %s", avgs.shape) return avgs - def _calc_smoothed(self, data): + def _calc_smoothed(self, data: np.ndarray) -> np.ndarray: """ Smooth the data. Parameters @@ -792,10 +805,12 @@ def _calc_smoothed(self, data): :class:`numpy.ndarray` The smoothed data """ - return _ExponentialMovingAverage(data, self._args["smooth_amount"])() + retval = _ExponentialMovingAverage(data, self._args["smooth_amount"])() + logger.debug("Calculated Smoothed data: shape: %s", retval.shape) + return retval @classmethod - def _calc_trend(cls, data): + def _calc_trend(cls, data: np.ndarray) -> np.ndarray: """ Calculate polynomial trend of the given data. Parameters @@ -815,8 +830,8 @@ def _calc_trend(cls, data): dummy[:] = np.nan return dummy x_range = range(points) - trend = np.poly1d(np.polyfit(x_range, data, 3))(x_range) - logger.debug("Calculated Trend") + trend = np.poly1d(np.polyfit(x_range, np.nan_to_num(data), 3))(x_range) + logger.debug("Calculated Trend: shape: %s", trend.shape) return trend @@ -835,17 +850,17 @@ class _ExponentialMovingAverage(): # pylint:disable=too-few-public-methods ----- Adapted from: https://stackoverflow.com/questions/42869495 """ - def __init__(self, data, amount): + def __init__(self, data: np.ndarray, amount: float) -> None: assert data.ndim == 1 amount = min(max(amount, 0.001), 0.999) - self._data = data + self._data = np.nan_to_num(data) self._alpha = 1. - amount self._dtype = "float32" if data.dtype == np.float32 else "float64" self._row_size = self._get_max_row_size() self._out = np.empty_like(data, dtype=self._dtype) - def __call__(self): + def __call__(self) -> np.ndarray: """ Perform the exponential moving average calculation. Returns @@ -859,7 +874,7 @@ def __call__(self): self._ewma_vectorized_safe() # Use the safe version return self._out - def _get_max_row_size(self): + def _get_max_row_size(self) -> int: """ Calculate the maximum row size for the running platform for the given dtype. Returns @@ -879,7 +894,7 @@ def _get_max_row_size(self): logger.debug("row_size: %s", retval) return retval - def _ewma_vectorized_safe(self): + def _ewma_vectorized_safe(self) -> None: """ Perform the vectorized exponential moving average in a safe way. """ num_rows = int(self._data.size // self._row_size) # the number of rows to use leftover = int(self._data.size % self._row_size) # the amount of data leftover @@ -915,7 +930,10 @@ def _ewma_vectorized_safe(self): self._out[-leftover:], offset=out_main_view[-1, -1]) - def _ewma_vectorized(self, data, out, offset=None): + def _ewma_vectorized(self, + data: np.ndarray, + out: np.ndarray, + offset: Optional[float] = None) -> None: """ Calculates the exponential moving average over a vector. Will fail for large inputs. The result is processed in place into the array passed to the `out` parameter @@ -949,7 +967,7 @@ def _ewma_vectorized(self, data, out, offset=None): offset = np.array(offset, copy=False).astype(self._dtype, copy=False) out += offset * scaling_factors[1:] - def _ewma_vectorized_2d(self, data, out): + def _ewma_vectorized_2d(self, data: np.ndarray, out: np.ndarray) -> None: """ Calculates the exponential moving average over the last axis. The result is processed in place into the array passed to the `out` parameter diff --git a/lib/gui/popup_session.py b/lib/gui/popup_session.py index f59999c77e..cbf0e2361a 100644 --- a/lib/gui/popup_session.py +++ b/lib/gui/popup_session.py @@ -6,6 +6,7 @@ import logging import tkinter as tk from tkinter import ttk +from typing import List from .control_helper import ControlBuilder, ControlPanelOption from .custom_widgets import Tooltip @@ -29,7 +30,7 @@ class SessionPopUp(tk.Toplevel): data_points: int The number of iterations in the selected session """ - def __init__(self, session_id, data_points): + def __init__(self, session_id: int, data_points: int) -> None: logger.debug("Initializing: %s: (session_id: %s, data_points: %s)", self.__class__.__name__, session_id, data_points) super().__init__() @@ -55,7 +56,7 @@ def __init__(self, session_id, data_points): logger.debug("Initialized: %s", self.__class__.__name__) - def _set_vars(self): + def _set_vars(self) -> dict: """ Set status tkinter String variable and tkinter Boolean variable to callback when the graph is ready to build. @@ -75,7 +76,7 @@ def _set_vars(self): retval["buildgraph"] = var return retval - def _layout_frames(self): + def _layout_frames(self) -> ttk.Frame: """ Top level container frames """ logger.debug("Layout frames") @@ -91,12 +92,12 @@ def _layout_frames(self): return leftframe - def _build_options(self, frame): + def _build_options(self, frame: ttk.Frame) -> None: """ Build Options into the options frame. Parameters ---------- - frame: `tkinter.ttk.Frame` + frame: :class:`tkinter.ttk.Frame` The frame that the options reside in """ logger.debug("Building Options") @@ -109,12 +110,12 @@ def _build_options(self, frame): sep.pack(fill=tk.X, pady=(5, 0), side=tk.BOTTOM) logger.debug("Built Options") - def _opts_combobox(self, frame): + def _opts_combobox(self, frame: ttk.Frame) -> None: """ Add the options combo boxes. Parameters ---------- - frame: `tkinter.ttk.Frame` + frame: :class:`tkinter.ttk.Frame` The frame that the options reside in """ logger.debug("Building Combo boxes") @@ -124,7 +125,7 @@ def _opts_combobox(self, frame): var = tk.StringVar() cmbframe = ttk.Frame(frame) - lblcmb = ttk.Label(cmbframe, text="{}:".format(item), width=7, anchor=tk.W) + lblcmb = ttk.Label(cmbframe, text=f"{item}:", width=7, anchor=tk.W) cmb = ttk.Combobox(cmbframe, textvariable=var, width=10) cmb["values"] = choices[item] cmb.current(0) @@ -141,12 +142,12 @@ def _opts_combobox(self, frame): cmbframe.pack(fill=tk.X, pady=5, padx=5, side=tk.TOP) logger.debug("Built Combo boxes") - def _opts_checkbuttons(self, frame): + def _opts_checkbuttons(self, frame: ttk.Frame) -> None: """ Add the options check buttons. Parameters ---------- - frame: `tkinter.ttk.Frame` + frame: :class:`tkinter.ttk.Frame` The frame that the options reside in """ logger.debug("Building Check Buttons") @@ -157,7 +158,7 @@ def _opts_checkbuttons(self, frame): elif item == "outliers": text = "Flatten Outliers" else: - text = "Show {}".format(item.title()) + text = f"Show {item.title()}" var = tk.BooleanVar() if item == self._default_view: @@ -171,17 +172,17 @@ def _opts_checkbuttons(self, frame): logger.debug("Built Check Buttons") - def _opts_loss_keys(self, frame): + def _opts_loss_keys(self, frame: ttk.Frame) -> None: """ Add loss key selections. Parameters ---------- - frame: `tkinter.ttk.Frame` + frame: :class:`tkinter.ttk.Frame` The frame that the options reside in """ logger.debug("Building Loss Key Check Buttons") loss_keys = Session.get_loss_keys(self._session_id) - lk_vars = dict() + lk_vars = {} section_added = False for loss_key in sorted(loss_keys): if loss_key.startswith("total"): @@ -209,12 +210,12 @@ def _opts_loss_keys(self, frame): self._vars["loss_keys"] = lk_vars logger.debug("Built Loss Key Check Buttons") - def _opts_slider(self, frame): + def _opts_slider(self, frame: ttk.Frame) -> None: """ Add the options entry boxes. Parameters ---------- - frame: `tkinter.ttk.Frame` + frame: :class:`tkinter.ttk.Frame` The frame that the options reside in """ @@ -243,12 +244,12 @@ def _opts_slider(self, frame): ControlBuilder(frame, slider, 1, 19, None, "Analysis.", True) logger.debug("Built Sliders") - def _opts_buttons(self, frame): + def _opts_buttons(self, frame: ttk.Frame) -> None: """ Add the option buttons. Parameters ---------- - frame: `tkinter.ttk.Frame` + frame: :class:`tkinter.ttk.Frame` The frame that the options reside in """ logger.debug("Building Buttons") @@ -259,7 +260,7 @@ def _opts_buttons(self, frame): anchor=tk.W) for btntype in ("reload", "save"): - cmd = getattr(self, "_option_button_{}".format(btntype)) + cmd = getattr(self, f"_option_button_{btntype}") btn = ttk.Button(btnframe, image=get_images().icons[btntype], command=cmd) @@ -271,12 +272,14 @@ def _opts_buttons(self, frame): btnframe.pack(fill=tk.X, pady=5, padx=5, side=tk.BOTTOM) logger.debug("Built Buttons") - @staticmethod - def _add_section(frame, title): + @classmethod + def _add_section(cls, frame: ttk.Frame, title: str) -> None: """ Add a separator and section title between options Parameters ---------- + frame: :class:`tkinter.ttk.Frame` + The frame that the options reside in title: str The section title to display """ @@ -286,7 +289,7 @@ def _add_section(frame, title): lbl.pack(side=tk.TOP, padx=5, pady=0, anchor=tk.CENTER) sep.pack(fill=tk.X, pady=(5, 0), side=tk.TOP) - def _option_button_save(self): + def _option_button_save(self) -> None: """ Action for save button press. """ logger.debug("Saving File") savefile = FileHandler("save", "csv").return_file @@ -302,8 +305,14 @@ def _option_button_save(self): csvout.writerow(fieldnames) csvout.writerows(zip(*[save_data[key] for key in fieldnames])) - def _option_button_reload(self, *args): # pylint: disable=unused-argument - """ Action for reset button press and checkbox changes. """ + def _option_button_reload(self, *args) -> None: # pylint: disable=unused-argument + """ Action for reset button press and checkbox changes. + + Parameters + ---------- + args: tuple + Required for TK Callback but unused + """ logger.debug("Refreshing Graph") if not self._graph_initialised: return @@ -316,19 +325,25 @@ def _option_button_reload(self, *args): # pylint: disable=unused-argument self._vars["scale"].get()) logger.debug("Refreshed Graph") - def _graph_scale(self, *args): # pylint: disable=unused-argument - """ Action for changing graph scale. """ + def _graph_scale(self, *args) -> None: # pylint: disable=unused-argument + """ Action for changing graph scale. + + Parameters + ---------- + args: tuple + Required for TK Callback but unused + """ if not self._graph_initialised: return self._graph.set_yscale_type(self._vars["scale"].get()) @classmethod - def _set_help(cls, action): + def _set_help(cls, action: str) -> str: """ Set the help text for option buttons. Parameters ---------- - action: string + action: str The action to get the help text for Returns @@ -363,8 +378,14 @@ def _set_help(cls, action): hlp = _("Change y-axis scale") return hlp - def _compile_display_data(self): - """ Compile the data to be displayed. """ + def _compile_display_data(self) -> bool: + """ Compile the data to be displayed. + + Returns + ------- + bool + ``True`` if there is valid data to display, ``False`` if not + """ if self._thread is None: logger.debug("Compiling Display Data in background thread") loss_keys = [key for key, val in self._vars["loss_keys"].items() @@ -412,8 +433,8 @@ def _compile_display_data(self): self._vars["buildgraph"].set(True) return True - @staticmethod - def _get_display_data(**kwargs): + @classmethod + def _get_display_data(cls, **kwargs) -> None: """ Get the display data in a LongRunningTask. Parameters @@ -428,7 +449,7 @@ def _get_display_data(**kwargs): """ return Calculations(**kwargs) - def _check_valid_selection(self, loss_keys, selections): + def _check_valid_selection(self, loss_keys: List[str], selections: List[str]) -> bool: """ Check that there will be data to display. Parameters @@ -450,9 +471,14 @@ def _check_valid_selection(self, loss_keys, selections): return False return True - def _check_valid_data(self): + def _check_valid_data(self) -> bool: """ Check that the selections holds valid data to display NB: len-as-condition is used as data could be a list or a numpy array + + Returns + ------- + bool + ``True` if there is data to be displayed, otherwise ``False`` """ logger.debug("Validating data. %s", {key: len(val) for key, val in self._display_data.stats.items()}) @@ -461,7 +487,7 @@ def _check_valid_data(self): return False return True - def _selections_to_list(self): + def _selections_to_list(self) -> List[str]: """ Compile checkbox selections to a list. Returns @@ -470,7 +496,7 @@ def _selections_to_list(self): The selected options from the check-boxes """ logger.debug("Compiling selections to list") - selections = list() + selections = [] for key, val in self._vars.items(): if (isinstance(val, tk.BooleanVar) and key != "outliers" @@ -479,8 +505,14 @@ def _selections_to_list(self): logger.debug("Compiling selections to list: %s", selections) return selections - def _graph_build(self, *args): # pylint:disable=unused-argument - """ Build the graph in the top right paned window """ + def _graph_build(self, *args) -> None: # pylint:disable=unused-argument + """ Build the graph in the top right paned window + + Parameters + ---------- + args: tuple + Required for TK Callback but unused + """ if not self._vars["buildgraph"].get(): return self._vars["status"].set("Loading Data...") diff --git a/lib/logger.py b/lib/logger.py index 66c6af23f4..452080c3b1 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -78,6 +78,7 @@ def format(self, record): """ record.message = record.getMessage() record = self._rewrite_warnings(record) + record = self._lower_external(record) # strip newlines if record.levelno < 30 and ("\n" in record.message or "\r" in record.message): record.message = record.message.replace("\n", "\\n").replace("\r", "\\r") @@ -109,6 +110,12 @@ def _rewrite_warnings(cls, record): ---------- record: :class:`logging.LogRecord` The log record to check for rewriting + + Returns + ------- + :class:`logging.LogRecord` + The log rewritten or untouched record + """ if record.levelno == 30 and record.funcName == "warn" and record.module == "ag_logging": # TF 2.3 in Conda is imported with the wrong gast(0.4 when 0.3.3 should be used). This @@ -125,6 +132,31 @@ def _rewrite_warnings(cls, record): return record + @classmethod + def _lower_external(cls, record): + """ Some external libs log at a higher level than we would really like, so lower their + log level. + + Specifically: Matplotlib font properties + + Parameters + ---------- + record: :class:`logging.LogRecord` + The log record to check for rewriting + + Returns + ---------- + :class:`logging.LogRecord` + The log rewritten or untouched record + """ + if (record.levelno == 20 and record.funcName == "__init__" + and record.module == "font_manager"): + # Matplotlib font manager + record.levelno = 10 + record.levelname = "DEBUG" + + return record + class RollingBuffer(collections.deque): """File-like that keeps a certain number of lines of text in memory for writing out to the @@ -315,7 +347,7 @@ def get_loglevel(loglevel): """ numeric_level = getattr(logging, loglevel.upper(), None) if not isinstance(numeric_level, int): - raise ValueError("Invalid log level: %s" % loglevel) + raise ValueError(f"Invalid log level: {loglevel}") return numeric_level @@ -336,7 +368,7 @@ def crash_log(): from lib.sysinfo import sysinfo # pylint:disable=import-outside-toplevel except Exception: # pylint:disable=broad-except sysinfo = ("\n\nThere was an error importing System Information from lib.sysinfo. This is " - "probably a bug which should be fixed:\n{}".format(traceback.format_exc())) + f"probably a bug which should be fixed:\n{traceback.format_exc()}") with open(filename, "wb") as outfile: outfile.writelines(freeze_log) outfile.write(original_traceback) From 13019599076cf134dff22c6ff361ec68de79578b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 29 May 2022 18:16:06 +0100 Subject: [PATCH 590/981] Bugfix: 2 tf loss functions --- lib/model/losses_tf.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/model/losses_tf.py b/lib/model/losses_tf.py index c7c63918bf..97844f766d 100644 --- a/lib/model/losses_tf.py +++ b/lib/model/losses_tf.py @@ -218,6 +218,8 @@ def call(self, y_true, y_pred): class LInfNorm(tf.keras.losses.Loss): # pylint:disable=too-few-public-methods """ Calculate the L-inf norm as a loss function. """ + def __init__(self): + super().__init__(name="l_inf_norm_loss") @classmethod def call(cls, y_true, y_pred): @@ -372,6 +374,8 @@ class GMSDLoss(tf.keras.losses.Loss): # pylint:disable=too-few-public-methods http://www4.comp.polyu.edu.hk/~cslzhang/IQA/GMSD/GMSD.htm https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf """ + def __init__(self): + super().__init__(name="gmsd_loss", reduction=tf.keras.losses.Reduction.NONE) def call(self, y_true, y_pred): """ Return the Gradient Magnitude Similarity Deviation Loss. From 8d084cf4267659c89a9e8750b045e16d3e265d8d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 5 Jun 2022 11:41:46 +0100 Subject: [PATCH 591/981] AMD Updates - Linux Installer - Default to Python3.8 - Setup + Faceswap - Don't launch with python versions > 3.8 --- .install/linux/faceswap_setup_x64.sh | 2 +- faceswap.py | 7 +++++-- setup.py | 4 ++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.install/linux/faceswap_setup_x64.sh b/.install/linux/faceswap_setup_x64.sh index 4d6a1da0dd..980540fb98 100644 --- a/.install/linux/faceswap_setup_x64.sh +++ b/.install/linux/faceswap_setup_x64.sh @@ -131,7 +131,7 @@ ask_version() { vers="${vers:-${default}}" case $vers in 1) VERSION="nvidia" ; break ;; - 2) VERSION="amd" ; break ;; + 2) VERSION="amd" ; PYENV_VERSION="3.8" ; break ;; 3) VERSION="cpu" ; break ;; * ) echo "Invalid selection." ;; esac diff --git a/faceswap.py b/faceswap.py index 4e010b95db..b8857eb697 100755 --- a/faceswap.py +++ b/faceswap.py @@ -5,6 +5,7 @@ from lib.cli import args as cli_args from lib.config import generate_configs +from lib.utils import get_backend # LOCALES @@ -14,19 +15,21 @@ if sys.version_info < (3, 7): raise Exception("This program requires at least python3.7") +if get_backend() == "amd" and sys.version_info >= (3, 9): + raise Exception("The AMD version of Faceswap cannot run on versions of Python higher than 3.8") _PARSER = cli_args.FullHelpArgumentParser() -def _bad_args(*args): # pylint:disable=unused-argument +def _bad_args(*args) -> None: # pylint:disable=unused-argument """ Print help to console when bad arguments are provided. """ print(cli_args) _PARSER.print_help() sys.exit(0) -def _main(): +def _main() -> None: """ The main entry point into Faceswap. - Generates the config files, if they don't pre-exist. diff --git a/setup.py b/setup.py index 8e8a16918f..dda4c0af77 100755 --- a/setup.py +++ b/setup.py @@ -167,6 +167,10 @@ def check_python(self): self.output.error("Please run this script with Python version 3.7 to 3.9 " "64bit and try again.") sys.exit(1) + if self.enable_amd and sys.version_info >= (3, 9): + self.output.error("The AMD version of Faceswap cannot be installed on versions of " + "Python higher than 3.8") + sys.exit(1) def output_runtime_info(self): """ Output run time info """ From 9168721e42972f64d67479fd288695b24f4e89c4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 5 Jun 2022 12:25:03 +0100 Subject: [PATCH 592/981] AMD Updates - Windows Installer - Default to Python3.8 - Requirements - Pin protobuf to 3.19 --- .install/windows/install.nsi | 9 +++++++-- requirements/requirements_amd.txt | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.install/windows/install.nsi b/.install/windows/install.nsi index 82de95cf45..277a0228ec 100644 --- a/.install/windows/install.nsi +++ b/.install/windows/install.nsi @@ -22,7 +22,7 @@ InstallDir $PROFILE\faceswap # Install cli flags !define flagsConda "/S /RegisterPython=0 /AddToPath=0 /D=$PROFILE\MiniConda3" !define flagsRepo "--depth 1 --no-single-branch ${wwwRepo}" -!define flagsEnv "-y python=3.9" +!define flagsEnv "-y python=3." # Folders Var ProgramData @@ -397,7 +397,12 @@ Function SetEnvironment CreateEnv: SetDetailsPrint listonly - ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda create ${flagsEnv} -n $\"$envName$\" && conda deactivate" + ${If} $setupType == "amd" + StrCpy $0 "${flagsEnv}8" + ${else} + StrCpy $0 "${flagsEnv}9" + ${EndIf} + ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda create $0 -n $\"$envName$\" && conda deactivate" pop $0 ExecDos::wait $0 pop $0 diff --git a/requirements/requirements_amd.txt b/requirements/requirements_amd.txt index 6ca508f292..18a4dd6245 100644 --- a/requirements/requirements_amd.txt +++ b/requirements/requirements_amd.txt @@ -1,4 +1,5 @@ -r _requirements_base.txt # tf2.2 is last version that tensorboard logging works with old Keras +protobuf>= 3.19.0,<3.20.0 # TF has started pulling in incompatible protobuf tensorflow>=2.2.0,<2.3.0 plaidml-keras==0.7.0 From c6ac90dea96788d0738e7f28c457792bd4be53f8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 5 Jun 2022 23:01:15 +0100 Subject: [PATCH 593/981] require typing-extensions --- requirements/_requirements_base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index 393afc2f23..5f8d7c1640 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -13,6 +13,6 @@ ffmpy==0.2.3 #nvidia-ml-py>=11.510,<300 # Pin nvidida-ml-py to <11.515 until we know if bytes->str is an error or permanent change nvidia-ml-py<11.515 +typing-extensions pywin32>=228 ; sys_platform == "win32" pynvx==1.0.0 ; sys_platform == "darwin" -typing-extensions ; python_version < "3.8" From 3a9764da9eb5606b450f13cb4b9403afbfc7e4c4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 6 Jun 2022 09:29:36 +0100 Subject: [PATCH 594/981] lib.model.nnblocks - typing + cleanup upscales --- lib/model/nn_blocks.py | 204 +++++++++++++++++++++++++++++++---------- 1 file changed, 154 insertions(+), 50 deletions(-) diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index e5e090da03..3a35d1fc17 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -2,10 +2,10 @@ """ Neural Network Blocks for faceswap.py. """ import logging +from typing import Optional, Tuple, Union from lib.utils import get_backend - from .initializers import ICNR, ConvolutionAware from .layers import PixelShuffler, ReflectionPadding2D, Swish, KResizeImages from .normalization import InstanceNormalization @@ -15,12 +15,18 @@ Activation, Add, BatchNormalization, Concatenate, Conv2D as KConv2D, Conv2DTranspose, DepthwiseConv2D as KDepthwiseConv2d, LeakyReLU, PReLU, SeparableConv2D, UpSampling2D) from keras.initializers import he_uniform, VarianceScaling # pylint:disable=no-name-in-module + # type checking: + import keras + from plaidml.tile import Value as Tensor else: # Ignore linting errors from Tensorflow's thoroughly broken import system from tensorflow.keras.layers import ( # noqa pylint:disable=no-name-in-module,import-error Activation, Add, BatchNormalization, Concatenate, Conv2D as KConv2D, Conv2DTranspose, DepthwiseConv2D as KDepthwiseConv2d, LeakyReLU, PReLU, SeparableConv2D, UpSampling2D) from tensorflow.keras.initializers import he_uniform, VarianceScaling # noqa pylint:disable=no-name-in-module,import-error + # type checking: + from tensorflow import keras + from tensorflow import Tensor logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -30,7 +36,7 @@ _NAMES = {} -def set_config(configuration): +def set_config(configuration: dict) -> None: """ Set the global configuration parameters from the user's config file. These options are used when creating layers for new models. @@ -47,7 +53,7 @@ def set_config(configuration): logger.debug("Set NNBlock configuration to: %s", _CONFIG) -def _get_name(name): +def _get_name(name: str) -> str: """ Return unique layer name for requested block. As blocks can be used multiple times, auto appends an integer to the end of the requested @@ -71,7 +77,8 @@ def _get_name(name): # << CONVOLUTIONS >> -def _get_default_initializer(initializer): +def _get_default_initializer( + initializer: keras.initializers.Initializer) -> keras.initializers.Initializer: """ Returns a default initializer of Convolutional Aware or he_uniform for convolutional layers. @@ -101,7 +108,7 @@ def _get_default_initializer(initializer): return retval -class Conv2D(KConv2D): # pylint:disable=too-few-public-methods +class Conv2D(KConv2D): # pylint:disable=too-few-public-methods, too-many-ancestors """ A standard Keras Convolution 2D layer with parameters updated to be more appropriate for Faceswap architecture. @@ -115,23 +122,24 @@ class Conv2D(KConv2D): # pylint:disable=too-few-public-methods One of `"valid"` or `"same"` (case-insensitive). Default: `"same"`. Note that `"same"` is slightly inconsistent across backends with `strides` != 1, as described `here `_. - check_icnr_init: `bool`, optional - ``True`` if the user configuration options should be checked to apply ICNR initialization - to the layer. This should only be passed in from :class:`UpscaleBlock` layers. - Default: ``False`` + is_upscale: `bool`, optional + ``True`` if the convolution is being called from an upscale layer. This causes the instance + to check the user configuration options to see if ICNR initialization has been selected and + should be applied. This should only be passed in as ``True`` from :class:`UpscaleBlock` + layers. Default: ``False`` """ - def __init__(self, *args, padding="same", check_icnr_init=False, **kwargs): + def __init__(self, *args, padding: str = "same", is_upscale: bool = False, **kwargs) -> None: if kwargs.get("name", None) is None: filters = kwargs["filters"] if "filters" in kwargs else args[0] kwargs["name"] = _get_name(f"conv2d_{filters}") initializer = _get_default_initializer(kwargs.pop("kernel_initializer", None)) - if check_icnr_init and _CONFIG["icnr_init"]: + if is_upscale and _CONFIG["icnr_init"]: initializer = ICNR(initializer=initializer) logger.debug("Using ICNR Initializer: %s", initializer) super().__init__(*args, padding=padding, kernel_initializer=initializer, **kwargs) -class DepthwiseConv2D(KDepthwiseConv2d): # pylint:disable=too-few-public-methods +class DepthwiseConv2D(KDepthwiseConv2d): # noqa,pylint:disable=too-few-public-methods, too-many-ancestors """ A standard Keras Depthwise Convolution 2D layer with parameters updated to be more appropriate for Faceswap architecture. @@ -145,16 +153,17 @@ class DepthwiseConv2D(KDepthwiseConv2d): # pylint:disable=too-few-public-method One of `"valid"` or `"same"` (case-insensitive). Default: `"same"`. Note that `"same"` is slightly inconsistent across backends with `strides` != 1, as described `here `_. - check_icnr_init: `bool`, optional - ``True`` if the user configuration options should be checked to apply ICNR initialization - to the layer. This should only be passed in from :class:`UpscaleBlock` layers. - Default: ``False`` + is_upscale: `bool`, optional + ``True`` if the convolution is being called from an upscale layer. This causes the instance + to check the user configuration options to see if ICNR initialization has been selected and + should be applied. This should only be passed in as ``True`` from :class:`UpscaleBlock` + layers. Default: ``False`` """ - def __init__(self, *args, padding="same", check_icnr_init=False, **kwargs): + def __init__(self, *args, padding: str = "same", is_upscale: bool = False, **kwargs) -> None: if kwargs.get("name", None) is None: kwargs["name"] = _get_name("dwconv2d") initializer = _get_default_initializer(kwargs.pop("depthwise_initializer", None)) - if check_icnr_init and _CONFIG["icnr_init"]: + if is_upscale and _CONFIG["icnr_init"]: initializer = ICNR(initializer=initializer) logger.debug("Using ICNR Initializer: %s", initializer) super().__init__(*args, padding=padding, depthwise_initializer=initializer, **kwargs) @@ -188,7 +197,11 @@ class Conv2DOutput(): # pylint:disable=too-few-public-methods kwargs: dict Any additional Keras standard layer keyword arguments to pass to the Convolutional 2D layer """ - def __init__(self, filters, kernel_size, activation="sigmoid", padding="same", **kwargs): + def __init__(self, + filters: int, + kernel_size: Union[int, Tuple[int]], + activation: str = "sigmoid", + padding: str = "same", **kwargs) -> None: self._name = kwargs.pop("name") if "name" in kwargs else _get_name( f"conv_output_{filters}") self._filters = filters @@ -197,7 +210,7 @@ def __init__(self, filters, kernel_size, activation="sigmoid", padding="same", * self._padding = padding self._kwargs = kwargs - def __call__(self, inputs): + def __call__(self, inputs: Tensor) -> Tensor: """ Call the Faceswap Convolutional Output Layer. Parameters @@ -255,18 +268,21 @@ class Conv2DBlock(): # pylint:disable=too-few-public-methods use_depthwise: bool, optional Set to ``True`` to use a Depthwise Convolution 2D layer rather than a standard Convolution 2D layer. Default: ``False`` + relu_alpha: float + The alpha to use for LeakyRelu Activation. Default=`0.1` kwargs: dict Any additional Keras standard layer keyword arguments to pass to the Convolutional 2D layer """ def __init__(self, - filters, - kernel_size=5, - strides=2, - padding="same", - normalization=None, - activation="leakyrelu", - use_depthwise=False, - **kwargs): + filters: int, + kernel_size: Union[int, Tuple[int]] = 5, + strides: Union[int, Tuple[int]] = 2, + padding: str = "same", + normalization: Optional[str] = None, + activation: Optional[str] = "leakyrelu", + use_depthwise: bool = False, + relu_alpha: float = 0.1, + **kwargs) -> None: self._name = kwargs.pop("name") if "name" in kwargs else _get_name(f"conv_{filters}") logger.debug("name: %s, filters: %s, kernel_size: %s, strides: %s, padding: %s, " @@ -283,17 +299,18 @@ def __init__(self, self._normalization = None if not normalization else normalization.lower() self._activation = None if not activation else activation.lower() self._use_depthwise = use_depthwise + self._relu_alpha = relu_alpha self._assert_arguments() - def _assert_arguments(self): + def _assert_arguments(self) -> None: """ Validate the given arguments. """ assert self._normalization in ("batch", "instance", None), ( "normalization should be 'batch', 'instance' or None") assert self._activation in ("leakyrelu", "swish", "prelu", None), ( "activation should be 'leakyrelu', 'prelu', 'swish' or None") - def __call__(self, inputs): + def __call__(self, inputs: Tensor) -> Tensor: """ Call the Faceswap Convolutional Layer. Parameters @@ -324,7 +341,7 @@ def __call__(self, inputs): # activation if self._activation == "leakyrelu": - var_x = LeakyReLU(0.1, name=f"{self._name}_leakyrelu")(var_x) + var_x = LeakyReLU(self._relu_alpha, name=f"{self._name}_leakyrelu")(var_x) if self._activation == "swish": var_x = Swish(name=f"{self._name}_swish")(var_x) if self._activation == "prelu": @@ -353,7 +370,10 @@ class SeparableConv2DBlock(): # pylint:disable=too-few-public-methods Any additional Keras standard layer keyword arguments to pass to the Separable Convolutional 2D layer """ - def __init__(self, filters, kernel_size=5, strides=2, **kwargs): + def __init__(self, + filters: int, + kernel_size: Union[int, Tuple[int]] = 5, + strides: Union[int, Tuple[int]] = 2, **kwargs) -> None: self._name = _get_name(f"separableconv2d_{filters}") logger.debug("name: %s, filters: %s, kernel_size: %s, strides: %s, kwargs: %s)", self._name, filters, kernel_size, strides, kwargs) @@ -366,7 +386,7 @@ def __init__(self, filters, kernel_size=5, strides=2, **kwargs): kwargs["kernel_initializer"] = initializer self._kwargs = kwargs - def __call__(self, inputs): + def __call__(self, inputs: Tensor) -> Tensor: """ Call the Faceswap Separable Convolutional 2D Block. Parameters @@ -423,13 +443,13 @@ class UpscaleBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, - filters, - kernel_size=3, - padding="same", - scale_factor=2, - normalization=None, - activation="leakyrelu", - **kwargs): + filters: int, + kernel_size: Union[int, Tuple[int]] = 3, + padding: str = "same", + scale_factor: int = 2, + normalization: Optional[str] = None, + activation: Optional[str] = "leakyrelu", + **kwargs) -> None: self._name = _get_name(f"upscale_{filters}") logger.debug("name: %s. filters: %s, kernel_size: %s, padding: %s, scale_factor: %s, " "normalization: %s, activation: %s, kwargs: %s)", @@ -444,7 +464,7 @@ def __init__(self, self._activation = activation self._kwargs = kwargs - def __call__(self, inputs): + def __call__(self, inputs: Tensor) -> Tensor: """ Call the Faceswap Convolutional Layer. Parameters @@ -464,7 +484,7 @@ def __call__(self, inputs): normalization=self._normalization, activation=self._activation, name=f"{self._name}_conv2d", - check_icnr_init=_CONFIG["icnr_init"], + is_upscale=True, **self._kwargs)(inputs) var_x = PixelShuffler(name=f"{self._name}_pixelshuffler", size=self._scale_factor)(var_x) @@ -509,8 +529,15 @@ class Upscale2xBlock(): # pylint:disable=too-few-public-methods kwargs: dict Any additional Keras standard layer keyword arguments to pass to the Convolutional 2D layer """ - def __init__(self, filters, kernel_size=3, padding="same", activation="leakyrelu", - interpolation="bilinear", sr_ratio=0.5, scale_factor=2, fast=False, **kwargs): + def __init__(self, + filters: int, + kernel_size: Union[int, Tuple[int]] = 3, + padding: str = "same", + activation: Optional[str] = "leakyrelu", + interpolation: str = "bilinear", + sr_ratio: float = 0.5, + scale_factor: int = 2, + fast: bool = False, **kwargs) -> None: self._name = _get_name(f"upscale2x_{filters}_{'fast' if fast else 'hyb'}") self._fast = fast @@ -522,7 +549,7 @@ def __init__(self, filters, kernel_size=3, padding="same", activation="leakyrelu self._scale_factor = scale_factor self._kwargs = kwargs - def __call__(self, inputs): + def __call__(self, inputs: Tensor) -> Tensor: """ Call the Faceswap Upscale 2x Layer. Parameters @@ -546,6 +573,7 @@ def __call__(self, inputs): if self._fast or (not self._fast and self._filters > 0): var_x2 = Conv2D(self._filters, 3, padding=self._padding, + is_upscale=True, name=f"{self._name}_conv2d", **self._kwargs)(var_x) var_x2 = UpSampling2D(size=(self._scale_factor, self._scale_factor), @@ -595,8 +623,13 @@ class UpscaleResizeImagesBlock(): # pylint:disable=too-few-public-methods kwargs: dict Any additional Keras standard layer keyword arguments to pass to the Convolutional 2D layer """ - def __init__(self, filters, kernel_size=3, padding="same", activation="leakyrelu", - scale_factor=2, interpolation="bilinear"): + def __init__(self, + filters: int, + kernel_size: Union[int, Tuple[int]] = 3, + padding: str = "same", + activation: str = "leakyrelu", + scale_factor: int = 2, + interpolation: str = "bilinear") -> None: self._name = _get_name(f"upscale_ri_{filters}") self._interpolation = interpolation self._size = scale_factor @@ -605,7 +638,7 @@ def __init__(self, filters, kernel_size=3, padding="same", activation="leakyrelu self._padding = padding self._activation = activation - def __call__(self, inputs): + def __call__(self, inputs: Tensor) -> Tensor: """ Call the Faceswap Resize Images Layer. Parameters @@ -626,6 +659,7 @@ def __call__(self, inputs): var_x_sr = Conv2D(self._filters, self._kernel_size, strides=1, padding=self._padding, + is_upscale=True, name=f"{self._name}_conv")(var_x_sr) var_x_us = Conv2DTranspose(self._filters, 3, strides=2, @@ -642,6 +676,72 @@ def __call__(self, inputs): return var_x +class UpscaleDNYBlock(): # pylint:disable=too-few-public-methods + """ Upscale block that implements methodology similar to the Disney Research Paper using an + upsampling2D block and 2 x convolutions + + Adds reflection padding if it has been selected by the user, and other post-processing + if requested by the plugin. + + References + ---------- + https://studios.disneyresearch.com/2020/06/29/high-resolution-neural-face-swapping-for-visual-effects/ + + Parameters + ---------- + filters: int + The dimensionality of the output space (i.e. the number of output filters in the + convolution) + kernel_size: int, optional + An integer or tuple/list of 2 integers, specifying the height and width of the 2D + convolution window. Can be a single integer to specify the same value for all spatial + dimensions. Default: 3 + activation: str or ``None``, optional + The activation function to use. This is applied at the end of the convolution block. Select + one of `"leakyrelu"`, `"prelu"` or `"swish"`. Set to ``None`` to not apply an activation + function. Default: `"leakyrelu"` + size: int, optional + The amount to upscale the image. Default: `2` + interpolation: ["nearest", "bilinear"], optional + Interpolation to use for up-sampling. Default: `"bilinear"` + kwargs: dict + Any additional Keras standard layer keyword arguments to pass to the Convolutional 2D + layers + """ + def __init__(self, + filters: int, + kernel_size: Union[int, Tuple[int]] = 3, + padding: str = "same", + activation: str = "leakyrelu", + size: int = 2, + interpolation: str = "bilinear", + **kwargs) -> None: + self._name = _get_name(f"upscale_dny_{filters}") + self._interpolation = interpolation + self._size = size + self._filters = filters + self._kernel_size = kernel_size + self._padding = padding + self._activation = activation + self._kwargs = kwargs + + def __call__(self, inputs: Tensor) -> Tensor: + var_x = UpSampling2D(size=self._size, + interpolation=self._interpolation, + name=f"{self._name}_upsample2d")(inputs) + for idx in range(2): + var_x = Conv2DBlock(self._filters, + self._kernel_size, + strides=1, + padding=self._padding, + activation=self._activation, + relu_alpha=0.2, + name=f"{self._name}_conv2d_{idx + 1}", + is_upscale=True, + **self._kwargs)(var_x) + return var_x + + # << OTHER BLOCKS >> class ResidualBlock(): # pylint:disable=too-few-public-methods """ Residual block from dfaker. @@ -665,7 +765,11 @@ class ResidualBlock(): # pylint:disable=too-few-public-methods tensor The output tensor from the Upscale layer """ - def __init__(self, filters, kernel_size=3, padding="same", **kwargs): + def __init__(self, + filters: int, + kernel_size: Union[int, Tuple[int]] = 3, + padding: str = "same", + **kwargs) -> None: self._name = _get_name(f"residual_{filters}") logger.debug("name: %s, filters: %s, kernel_size: %s, padding: %s, kwargs: %s)", self._name, filters, kernel_size, padding, kwargs) @@ -676,7 +780,7 @@ def __init__(self, filters, kernel_size=3, padding="same", **kwargs): self._padding = "valid" if self._use_reflect_padding else padding self._kwargs = kwargs - def __call__(self, inputs): + def __call__(self, inputs: Tensor) -> Tensor: """ Call the Faceswap Residual Block. Parameters From 8acf3228add1ba570b6fb5f60190c508934d0b82 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 6 Jun 2022 09:50:44 +0100 Subject: [PATCH 595/981] nn_blocks - linting --- lib/model/nn_blocks.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index 3a35d1fc17..ffc6803b85 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -2,7 +2,7 @@ """ Neural Network Blocks for faceswap.py. """ import logging -from typing import Optional, Tuple, Union +from typing import Dict, Optional, Tuple, Union from lib.utils import get_backend @@ -17,7 +17,7 @@ from keras.initializers import he_uniform, VarianceScaling # pylint:disable=no-name-in-module # type checking: import keras - from plaidml.tile import Value as Tensor + from plaidml.tile import Value as Tensor # pylint:disable=import-error else: # Ignore linting errors from Tensorflow's thoroughly broken import system from tensorflow.keras.layers import ( # noqa pylint:disable=no-name-in-module,import-error @@ -32,8 +32,8 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name -_CONFIG = {} -_NAMES = {} +_CONFIG: dict = {} +_NAMES: Dict[str, int] = {} def set_config(configuration: dict) -> None: @@ -275,8 +275,8 @@ class Conv2DBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: Union[int, Tuple[int]] = 5, - strides: Union[int, Tuple[int]] = 2, + kernel_size: Union[int, Tuple[int, int]] = 5, + strides: Union[int, Tuple[int, int]] = 2, padding: str = "same", normalization: Optional[str] = None, activation: Optional[str] = "leakyrelu", @@ -372,8 +372,8 @@ class SeparableConv2DBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: Union[int, Tuple[int]] = 5, - strides: Union[int, Tuple[int]] = 2, **kwargs) -> None: + kernel_size: Union[int, Tuple[int, int]] = 5, + strides: Union[int, Tuple[int, int]] = 2, **kwargs) -> None: self._name = _get_name(f"separableconv2d_{filters}") logger.debug("name: %s, filters: %s, kernel_size: %s, strides: %s, kwargs: %s)", self._name, filters, kernel_size, strides, kwargs) @@ -444,7 +444,7 @@ class UpscaleBlock(): # pylint:disable=too-few-public-methods def __init__(self, filters: int, - kernel_size: Union[int, Tuple[int]] = 3, + kernel_size: Union[int, Tuple[int, int]] = 3, padding: str = "same", scale_factor: int = 2, normalization: Optional[str] = None, @@ -531,7 +531,7 @@ class Upscale2xBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: Union[int, Tuple[int]] = 3, + kernel_size: Union[int, Tuple[int, int]] = 3, padding: str = "same", activation: Optional[str] = "leakyrelu", interpolation: str = "bilinear", @@ -625,7 +625,7 @@ class UpscaleResizeImagesBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: Union[int, Tuple[int]] = 3, + kernel_size: Union[int, Tuple[int, int]] = 3, padding: str = "same", activation: str = "leakyrelu", scale_factor: int = 2, @@ -710,7 +710,7 @@ class UpscaleDNYBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: Union[int, Tuple[int]] = 3, + kernel_size: Union[int, Tuple[int, int]] = 3, padding: str = "same", activation: str = "leakyrelu", size: int = 2, @@ -767,7 +767,7 @@ class ResidualBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: Union[int, Tuple[int]] = 3, + kernel_size: Union[int, Tuple[int, int]] = 3, padding: str = "same", **kwargs) -> None: self._name = _get_name(f"residual_{filters}") From a99049711f289b435e710d5b15f9c0e45c4251c3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 6 Jun 2022 11:50:05 +0100 Subject: [PATCH 596/981] Model updates - Increase model summary width - Phaze A updates - Update some min/max values - Add Decoder Filter Slope Mode - Add additional arguments for Upsampling2D - Adjust upsampling method for multiple upsamples in FC layers - Typing --- .../train/model_phaze_a_dfaker_preset.json | 1 + .../train/model_phaze_a_dfl-h128_preset.json | 1 + .../model_phaze_a_dfl-sae-df_preset.json | 1 + .../model_phaze_a_dfl-sae-liae_preset.json | 1 + .../model_phaze_a_dfl-saehd-df_preset.json | 1 + .../model_phaze_a_dfl-saehd-liae_preset.json | 1 + .../train/model_phaze_a_iae_preset.json | 1 + .../model_phaze_a_lightweight_preset.json | 1 + .../train/model_phaze_a_original_preset.json | 1 + .../train/model_phaze_a_stojo_preset.json | 1 + lib/model/nn_blocks.py | 2 +- plugins/train/model/_base.py | 2 +- plugins/train/model/phaze_a.py | 354 ++++++++++++------ plugins/train/model/phaze_a_defaults.py | 78 ++-- 14 files changed, 304 insertions(+), 142 deletions(-) diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfaker_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfaker_preset.json index 008581930a..91932f7a10 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_dfaker_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfaker_preset.json @@ -30,6 +30,7 @@ "dec_norm": "none", "dec_min_filters": 64, "dec_max_filters": 512, + "dec_slope_mode": "full", "dec_filter_slope": -0.45, "dec_res_blocks": 1, "dec_output_kernel": 5, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-h128_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-h128_preset.json index 359cf3d803..dc4934b369 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_dfl-h128_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-h128_preset.json @@ -30,6 +30,7 @@ "dec_norm": "none", "dec_min_filters": 128, "dec_max_filters": 512, + "dec_slope_mode": "full", "dec_filter_slope": -0.33, "dec_res_blocks": 0, "dec_output_kernel": 5, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-df_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-df_preset.json index b828facafa..25d38a5d9a 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-df_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-df_preset.json @@ -30,6 +30,7 @@ "dec_norm": "none", "dec_min_filters": 128, "dec_max_filters": 504, + "dec_slope_mode": "full", "dec_filter_slope": -0.33, "dec_res_blocks": 2, "dec_output_kernel": 5, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-liae_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-liae_preset.json index f76389a012..ffc7e043d0 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-liae_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-liae_preset.json @@ -30,6 +30,7 @@ "dec_norm": "none", "dec_min_filters": 128, "dec_max_filters": 504, + "dec_slope_mode": "full", "dec_filter_slope": -0.33, "dec_res_blocks": 2, "dec_output_kernel": 5, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-df_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-df_preset.json index 9bce4e970f..ea8ca0e583 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-df_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-df_preset.json @@ -30,6 +30,7 @@ "dec_norm": "none", "dec_min_filters": 128, "dec_max_filters": 512, + "dec_slope_mode": "full", "dec_filter_slope": -0.33, "dec_res_blocks": 1, "dec_output_kernel": 1, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-liae_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-liae_preset.json index 49e34bc459..1600b03145 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-liae_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-liae_preset.json @@ -30,6 +30,7 @@ "dec_norm": "none", "dec_min_filters": 128, "dec_max_filters": 512, + "dec_slope_mode": "full", "dec_filter_slope": -0.33, "dec_res_blocks": 1, "dec_output_kernel": 1, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_iae_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_iae_preset.json index 50c0f1c848..4f32ceb528 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_iae_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_iae_preset.json @@ -30,6 +30,7 @@ "dec_norm": "none", "dec_min_filters": 64, "dec_max_filters": 512, + "dec_slope_mode": "full", "dec_filter_slope": -0.45, "dec_res_blocks": 0, "dec_output_kernel": 5, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_lightweight_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_lightweight_preset.json index f0c0b8530d..1ec88f5cbe 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_lightweight_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_lightweight_preset.json @@ -30,6 +30,7 @@ "dec_norm": "none", "dec_min_filters": 128, "dec_max_filters": 512, + "dec_slope_mode": "full", "dec_filter_slope": -0.33, "dec_res_blocks": 0, "dec_output_kernel": 5, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_original_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_original_preset.json index 8ec28bbdcc..c696db8360 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_original_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_original_preset.json @@ -30,6 +30,7 @@ "dec_norm": "none", "dec_min_filters": 64, "dec_max_filters": 256, + "dec_slope_mode": "full", "dec_filter_slope": -0.33, "dec_res_blocks": 0, "dec_output_kernel": 5, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_stojo_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_stojo_preset.json index d08eb1d587..009f3e9538 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_stojo_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_stojo_preset.json @@ -30,6 +30,7 @@ "dec_norm": "none", "dec_min_filters": 160, "dec_max_filters": 640, + "dec_slope_mode": "full", "dec_filter_slope": -0.33, "dec_res_blocks": 1, "dec_output_kernel": 3, diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index ffc6803b85..1d35c20ebc 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -627,7 +627,7 @@ def __init__(self, filters: int, kernel_size: Union[int, Tuple[int, int]] = 3, padding: str = "same", - activation: str = "leakyrelu", + activation: Optional[str] = "leakyrelu", scale_factor: int = 2, interpolation: str = "bilinear") -> None: self._name = _get_name(f"upscale_ri_{filters}") diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index a35bfccf73..b2be51c9cd 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -403,7 +403,7 @@ def _output_summary(self): # print to logger print_fn = lambda x: logger.verbose("%s", x) # noqa for model in _get_all_sub_models(self._model): - model.summary(print_fn=print_fn) + model.summary(line_length=100, print_fn=print_fn) def save(self): """ Save the model to disk. diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 2bd23d2cc5..7d294b390f 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -1,6 +1,10 @@ #!/usr/bin/env python3 """ Phaze-A Model by TorzDF with thanks to BirbFakes and the myriad of testers. """ +# pylint: disable=too-many-lines +from dataclasses import dataclass +from typing import Dict, List, Literal, Optional, Tuple, Union + import numpy as np from lib.model.nn_blocks import ( @@ -20,6 +24,9 @@ GlobalAveragePooling2D, GlobalMaxPooling2D, Input, LeakyReLU, Reshape, UpSampling2D, Conv2D as KConv2D) from keras.models import clone_model + # typing checks + import keras + from plaidml.tile import Value as Tensor # pylint:disable=import-error else: # Ignore linting errors from Tensorflow's thoroughly broken import system from tensorflow.keras import applications as kapp, backend as K # pylint:disable=import-error @@ -28,81 +35,120 @@ GlobalAveragePooling2D, GlobalMaxPooling2D, Input, LeakyReLU, Reshape, UpSampling2D, Conv2D as KConv2D) from tensorflow.keras.models import clone_model # noqa pylint:disable=import-error,no-name-in-module + # typing checks + from tensorflow import keras + from tensorflow import Tensor + +@dataclass +class _EncoderInfo: + """ Contains model configuration options for various Phaze-A Encoders. -_MODEL_MAPPING = dict( - densenet121=dict( + Parameters + ---------- + keras_name: str + The name of the encoder in Keras Applications. Empty string `""` if the encoder does not + exist in Keras Applications + default_size: int + The default input size of the encoder + no_amd: bool, optional + ``True`` if the encoder is not compatible with the PlaidML backend otherwise ``False``. + Default: ``False`` + tf_min: float, optional + The lowest version of Tensorflow that the encoder can be used for. Default: `2.0` + scaling: tuple, optional + The float scaling that the encoder expects. Default: `(0, 1)` + min_size: int, optional + The minimum input size that the encoder will allow. Default: 32 + enforce_for_weights: bool, optional + ``True`` if the input size for the model must be forced to the default size when loading + imagenet weights, otherwise ``False``. Default: ``False`` + color_order: str, optional + The color order that the model expects (`"bgr"` or `"rgb"`). Default: `"rgb"` + """ + keras_name: str + default_size: int + no_amd: bool = False + tf_min: float = 2.0 + scaling: Tuple[int, int] = (0, 1) + min_size: int = 32 + enforce_for_weights: bool = False + color_order: Literal["bgr", "rgb"] = "rgb" + + +_MODEL_MAPPING: Dict[str, _EncoderInfo] = dict( + densenet121=_EncoderInfo( keras_name="DenseNet121", default_size=224), - densenet169=dict( + densenet169=_EncoderInfo( keras_name="DenseNet169", default_size=224), - densenet201=dict( + densenet201=_EncoderInfo( keras_name="DenseNet201", default_size=224), - efficientnet_b0=dict( + efficientnet_b0=_EncoderInfo( keras_name="EfficientNetB0", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=224), - efficientnet_b1=dict( + efficientnet_b1=_EncoderInfo( keras_name="EfficientNetB1", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=240), - efficientnet_b2=dict( + efficientnet_b2=_EncoderInfo( keras_name="EfficientNetB2", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=260), - efficientnet_b3=dict( + efficientnet_b3=_EncoderInfo( keras_name="EfficientNetB3", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=300), - efficientnet_b4=dict( + efficientnet_b4=_EncoderInfo( keras_name="EfficientNetB4", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=380), - efficientnet_b5=dict( + efficientnet_b5=_EncoderInfo( keras_name="EfficientNetB5", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=456), - efficientnet_b6=dict( + efficientnet_b6=_EncoderInfo( keras_name="EfficientNetB6", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=528), - efficientnet_b7=dict( + efficientnet_b7=_EncoderInfo( keras_name="EfficientNetB7", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=600), - efficientnet_v2_b0=dict( + efficientnet_v2_b0=_EncoderInfo( keras_name="EfficientNetV2B0", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=224), - efficientnet_v2_b1=dict( + efficientnet_v2_b1=_EncoderInfo( keras_name="EfficientNetV2B1", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=240), - efficientnet_v2_b2=dict( + efficientnet_v2_b2=_EncoderInfo( keras_name="EfficientNetV2B2", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=260), - efficientnet_v2_b3=dict( + efficientnet_v2_b3=_EncoderInfo( keras_name="EfficientNetV2B3", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=300), - efficientnet_v2_s=dict( + efficientnet_v2_s=_EncoderInfo( keras_name="EfficientNetV2S", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=384), - efficientnet_v2_m=dict( + efficientnet_v2_m=_EncoderInfo( keras_name="EfficientNetV2M", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=480), - efficientnet_v2_l=dict( + efficientnet_v2_l=_EncoderInfo( keras_name="EfficientNetV2L", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=480), - inception_resnet_v2=dict( + inception_resnet_v2=_EncoderInfo( keras_name="InceptionResNetV2", scaling=(-1, 1), min_size=75, default_size=299), - inception_v3=dict( + inception_v3=_EncoderInfo( keras_name="InceptionV3", scaling=(-1, 1), min_size=75, default_size=299), - mobilenet=dict( + mobilenet=_EncoderInfo( keras_name="MobileNet", scaling=(-1, 1), default_size=224), - mobilenet_v2=dict( + mobilenet_v2=_EncoderInfo( keras_name="MobileNetV2", scaling=(-1, 1), default_size=224), - mobilenet_v3_large=dict( + mobilenet_v3_large=_EncoderInfo( keras_name="MobileNetV3Large", no_amd=True, tf_min=2.4, scaling=(-1, 1), default_size=224), - mobilenet_v3_small=dict( + mobilenet_v3_small=_EncoderInfo( keras_name="MobileNetV3Small", no_amd=True, tf_min=2.4, scaling=(-1, 1), default_size=224), - nasnet_large=dict( + nasnet_large=_EncoderInfo( keras_name="NASNetLarge", scaling=(-1, 1), default_size=331, enforce_for_weights=True), - nasnet_mobile=dict( + nasnet_mobile=_EncoderInfo( keras_name="NASNetMobile", scaling=(-1, 1), default_size=224, enforce_for_weights=True), - resnet50=dict( + resnet50=_EncoderInfo( keras_name="ResNet50", scaling=(-1, 1), min_size=32, default_size=224), - resnet50_v2=dict( + resnet50_v2=_EncoderInfo( keras_name="ResNet50V2", no_amd=True, scaling=(-1, 1), default_size=224), - resnet101=dict( + resnet101=_EncoderInfo( keras_name="ResNet101", no_amd=True, scaling=(-1, 1), default_size=224), - resnet101_v2=dict( + resnet101_v2=_EncoderInfo( keras_name="ResNet101V2", no_amd=True, scaling=(-1, 1), default_size=224), - resnet152=dict( + resnet152=_EncoderInfo( keras_name="ResNet152", no_amd=True, scaling=(-1, 1), default_size=224), - resnet152_v2=dict( + resnet152_v2=_EncoderInfo( keras_name="ResNet152V2", no_amd=True, scaling=(-1, 1), default_size=224), - vgg16=dict( + vgg16=_EncoderInfo( keras_name="VGG16", color_order="bgr", scaling=(0, 255), default_size=224), - vgg19=dict( + vgg19=_EncoderInfo( keras_name="VGG19", color_order="bgr", scaling=(0, 255), default_size=224), - xception=dict( + xception=_EncoderInfo( keras_name="Xception", scaling=(-1, 1), min_size=71, default_size=299), - fs_original=dict( - color_order="bgr", min_size=32, default_size=160)) + fs_original=_EncoderInfo( + keras_name="", color_order="bgr", min_size=32, default_size=160)) class Model(ModelBase): @@ -111,7 +157,7 @@ class Model(ModelBase): An highly adaptable and configurable model by torzDF Parameters - ---------- + ----------513 args: varies The default command line arguments passed in from :class:`~scripts.train.Train` or :class:`~scripts.train.Convert` @@ -119,7 +165,7 @@ class Model(ModelBase): The default keyword arguments passed in from :class:`~scripts.train.Train` or :class:`~scripts.train.Convert` """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) if self.config["output_size"] % 64 != 0: raise FaceswapError("Phaze-A output shape must be a multiple of 64") @@ -128,10 +174,9 @@ def __init__(self, *args, **kwargs): self.config["freeze_layers"] = self._select_freeze_layers() self.input_shape = self._get_input_shape() - self.color_order = _MODEL_MAPPING[self.config["enc_architecture"]].get("color_order", - "rgb") + self.color_order = _MODEL_MAPPING[self.config["enc_architecture"]].color_order - def build(self): + def build(self) -> None: """ Build the model and assign to :attr:`model`. Override's the default build function for allowing the setting of dropout rate for pre- @@ -151,7 +196,7 @@ def build(self): self._compile_model() self._output_summary() - def _update_dropouts(self, model): + def _update_dropouts(self, model: keras.models.Model) -> keras.models.Model: """ Update the saved model with new dropout rates. Keras, annoyingly, does not actually change the dropout of the underlying layer, so we need @@ -197,7 +242,7 @@ def _update_dropouts(self, model): model = new_model return model - def _select_freeze_layers(self): + def _select_freeze_layers(self) -> List[str]: """ Process the selected frozen layers and replace the `keras_encoder` option with the actual keras model name @@ -209,8 +254,7 @@ def _select_freeze_layers(self): arch = self.config["enc_architecture"] layers = self.config["freeze_layers"] # EfficientNetV2 is inconsistent with other model's naming conventions - keras_name = _MODEL_MAPPING[arch].get("keras_name", "").replace("EfficientNetV2", - "EfficientNetV2-") + keras_name = _MODEL_MAPPING[arch].keras_name.replace("EfficientNetV2", "EfficientNetV2-") if "keras_encoder" not in self.config["freeze_layers"]: retval = layers @@ -222,7 +266,7 @@ def _select_freeze_layers(self): logger.debug("Removing 'keras_encoder' for '%s'", arch) return retval - def _get_input_shape(self): + def _get_input_shape(self) -> Tuple[int, int, int]: """ Obtain the input shape for the model. Input shape is calculated from the selected Encoder's input size, scaled to the user @@ -239,11 +283,11 @@ def _get_input_shape(self): The shape tuple for the input size to the Phaze-A model """ arch = self.config["enc_architecture"] - enforce_size = _MODEL_MAPPING[arch].get("enforce_for_weights", False) - default_size = _MODEL_MAPPING[arch]["default_size"] + enforce_size = _MODEL_MAPPING[arch].enforce_for_weights + default_size = _MODEL_MAPPING[arch].default_size scaling = self.config["enc_scaling"] / 100 - min_size = _MODEL_MAPPING[arch].get("min_size", 32) + min_size = _MODEL_MAPPING[arch].min_size size = int(max(min_size, min(default_size, ((default_size * scaling) // 16) * 16))) if self.config["enc_load_weights"] and enforce_size and scaling != 1.0: @@ -257,7 +301,7 @@ def _get_input_shape(self): logger.debug("Encoder input set to: %s", retval) return retval - def _validate_encoder_architecture(self): + def _validate_encoder_architecture(self) -> None: """ Validate that the requested architecture is a valid choice for the running system configuration. @@ -269,19 +313,19 @@ def _validate_encoder_architecture(self): raise FaceswapError(f"'{arch}' is not a valid choice for encoder architecture. Choose " f"one of {list(_MODEL_MAPPING.keys())}.") - if get_backend() == "amd" and model.get("no_amd"): - valid = [k for k, v in _MODEL_MAPPING.items() if not v.get('no_amd')] + if get_backend() == "amd" and model.no_amd: + valid = [k for k, v in _MODEL_MAPPING.items() if not v.no_amd] raise FaceswapError(f"'{arch}' is not compatible with the AMD backend. Choose one of " f"{valid}.") tf_ver = get_tf_version() - tf_min = model.get("tf_min", 2.0) + tf_min = model.tf_min if get_backend() != "amd" and tf_ver < tf_min: raise FaceswapError(f"{arch}' is not compatible with your version of Tensorflow. The " f"minimum version required is {tf_min} whilst you have version " f"{tf_ver} installed.") - def build_model(self, inputs): + def build_model(self, inputs: List[Tensor]) -> keras.models.Model: """ Create the model's structure. Parameters @@ -310,7 +354,7 @@ def build_model(self, inputs): autoencoder = KerasModel(inputs, outputs, name=self.model_name) return autoencoder - def _build_encoders(self, inputs): + def _build_encoders(self, inputs: List[Tensor]) -> Dict[str, keras.models.Model]: """ Build the encoders for Phaze-A Parameters @@ -329,7 +373,9 @@ def _build_encoders(self, inputs): logger.debug("Encoders: %s", retval) return retval - def _build_fully_connected(self, inputs): + def _build_fully_connected( + self, + inputs: Dict[str, keras.models.Model]) -> Dict[str, List[keras.models.Model]]: """ Build the fully connected layers for Phaze-A Parameters @@ -372,7 +418,10 @@ def _build_fully_connected(self, inputs): logger.debug("Fully Connected: %s", retval) return retval - def _build_g_blocks(self, inputs): + def _build_g_blocks( + self, + inputs: Dict[str, List[keras.models.Model]] + ) -> Dict[str, Union[List[keras.models.Model], keras.models.Model]]: """ Build the g-block layers for Phaze-A. If a g-block has not been selected for this model, then the original `inters` models are @@ -404,7 +453,10 @@ def _build_g_blocks(self, inputs): logger.debug("G-Blocks: %s", retval) return retval - def _build_decoders(self, inputs): + def _build_decoders( + self, + inputs: Dict[str, Union[List[keras.models.Model], keras.models.Model]] + ) -> Dict[str, keras.models.Model]: """ Build the encoders for Phaze-A Parameters @@ -436,7 +488,7 @@ def _build_decoders(self, inputs): return retval -def _bottleneck(inputs, bottleneck, size, normalization): +def _bottleneck(inputs: Tensor, bottleneck: str, size: int, normalization: str) -> Tensor: """ The bottleneck fully connected layer. Can be called from Encoder or FullyConnected layers. Parameters @@ -474,7 +526,11 @@ def _bottleneck(inputs, bottleneck, size, normalization): return var_x -def _get_upscale_layer(method, filters, activation=None): +def _get_upscale_layer(method: str, + filters: int, + activation: Optional[str] = None, + upsamples: Optional[int] = None, + interpolation: Optional[str] = None) -> keras.layers.Layer: """ Obtain an instance of the requested upscale method. Parameters @@ -486,6 +542,12 @@ def _get_upscale_layer(method, filters, activation=None): activation: str, optional The activation function to use in the upscale layer. ``None`` to use no activation. Default: ``None`` + upsamples: int, optional + Only used for UpSampling2D. If provided, then this is passed to the layer as the ``size`` + parameter. Default: ``None`` + interpolation: str, optional + Only used for UpSampling2D. If provided, then this is passed to the layer as the + ``interpolation`` parameter. Default: ``None`` Returns ------- @@ -493,7 +555,12 @@ def _get_upscale_layer(method, filters, activation=None): The selected configured upscale layer """ if method == "upsample2d": - return UpSampling2D() + kwargs: Dict[str, Union[str, int]] = {} + if upsamples: + kwargs["size"] = upsamples + if interpolation: + kwargs["interpolation"] = interpolation + return UpSampling2D(**kwargs) if method == "subpixel": return UpscaleBlock(filters, activation=activation) if method == "upscale_fast": @@ -503,7 +570,11 @@ def _get_upscale_layer(method, filters, activation=None): return UpscaleResizeImagesBlock(filters, activation=activation) -def _get_curve(start_y, end_y, num_points, scale): +def _get_curve(start_y: int, + end_y: int, + num_points: int, + scale: float, + mode: Literal["full", "cap_max", "cap_min"] = "full") -> List[int]: """ Obtain a curve. For the given start and end y values, return the y co-ordinates of a curve for the given @@ -519,6 +590,13 @@ def _get_curve(start_y, end_y, num_points, scale): The number of data points to plot on the x-axis scale: float The scale of the curve (from -.99 to 0.99) + slope_mode: str, optional + The method to generate the curve. One of `"full"`, `"cap_max"` or `"cap_min"`. `"full"` + mode generates a curve from the `"start_y"` to the `"end_y"` values. `"cap_max"` pads the + earlier points with the `"start_y"` value before filling out the remaining points at a + fixed divider to the `"end_y"` value. `"cap_min"` starts at the `"start_y" filling points + at a fixed divider until the `"end_y"` value is reached and pads the remaining points with + the `"end_y"` value. Default: `"full"` Returns ------- @@ -526,17 +604,28 @@ def _get_curve(start_y, end_y, num_points, scale): List of ints of points for the given curve """ scale = min(.99, max(-.99, scale)) - logger.debug("Obtaining curve: (start_y: %s, end_y: %s, num_points: %s, scale: %s)", - start_y, end_y, num_points, scale) - x_axis = np.linspace(0., 1., num=num_points) - y_axis = (x_axis - x_axis * scale) / (scale - abs(x_axis) * 2 * scale + 1) - y_axis = y_axis * (end_y - start_y) + start_y - retval = [int((y // 8) * 8) for y in y_axis] + logger.debug("Obtaining curve: (start_y: %s, end_y: %s, num_points: %s, scale: %s, mode: %s)", + start_y, end_y, num_points, scale, mode) + if mode == "full": + x_axis = np.linspace(0., 1., num=num_points) + y_axis = (x_axis - x_axis * scale) / (scale - abs(x_axis) * 2 * scale + 1) + y_axis = y_axis * (end_y - start_y) + start_y + retval = [int((y // 8) * 8) for y in y_axis] + else: + y_axis = [start_y] + scale = 1. - abs(scale) + for _ in range(num_points): + current_value = max(end_y, int(((y_axis[-1] * scale) // 8) * 8)) + y_axis.append(current_value) + if current_value == end_y: + break + pad = [start_y if mode == "cap_max" else end_y for _ in range(num_points - len(y_axis))] + retval = pad + y_axis if mode == "cap_max" else y_axis + pad logger.debug("Returning curve: %s", retval) return retval -def _scale_dim(target_resolution, original_dim): +def _scale_dim(target_resolution: int, original_dim: int) -> int: """ Scale a given `original_dim` so that it is a factor of the target resolution. Parameters @@ -574,13 +663,13 @@ class Encoder(): # pylint:disable=too-few-public-methods config: dict The model configuration options """ - def __init__(self, input_shape, config): + def __init__(self, input_shape: Tuple[int, int, int], config: dict) -> None: self.input_shape = input_shape self._config = config self._input_shape = input_shape @property - def _model_kwargs(self): + def _model_kwargs(self) -> Dict[str, Dict[str, Union[str, bool]]]: """ dict: Configuration option for architecture mapped to optional kwargs. """ return dict(mobilenet=dict(alpha=self._config["mobilenet_width"], depth_multiplier=self._config["mobilenet_depth"], @@ -591,16 +680,17 @@ def _model_kwargs(self): include_preprocessing=False)) @property - def _selected_model(self): - """ dict: The selected encoder model options dictionary """ + def _selected_model(self) -> Tuple[_EncoderInfo, dict]: + """ tuple(dict, :class:`_EncoderInfo`): The selected encoder model and it's associated + keyword arguments """ arch = self._config["enc_architecture"] - model = _MODEL_MAPPING.get(arch) - model["kwargs"] = self._model_kwargs.get(arch, {}) + model = _MODEL_MAPPING[arch] + kwargs = self._model_kwargs.get(arch, {}) if arch.startswith("efficientnet_v2"): - model["kwargs"]["include_preprocessing"] = False - return model + kwargs["include_preprocessing"] = False + return model, kwargs - def __call__(self): + def __call__(self) -> keras.models.Model: """ Create the Phaze-A Encoder Model. Returns @@ -611,7 +701,7 @@ def __call__(self): input_ = Input(shape=self._input_shape) var_x = input_ - scaling = self._selected_model.get("scaling") + scaling = self._selected_model[0].scaling if scaling: # Some models expect different scaling. logger.debug("Scaling to %s for '%s'", scaling, self._config["enc_architecture"]) @@ -633,25 +723,20 @@ def __call__(self): return KerasModel(input_, var_x, name="encoder") - def _get_encoder_model(self): + def _get_encoder_model(self) -> keras.models.Model: """ Return the model defined by the selected architecture. - Parameters - ---------- - input_shape: tuple - The input shape for the model - Returns ------- :class:`keras.Model` The selected keras model for the chosen encoder architecture """ - if self._selected_model.get("keras_name"): - kwargs = self._selected_model["kwargs"] + model, kwargs = self._selected_model + if model.keras_name: kwargs["input_shape"] = self._input_shape kwargs["include_top"] = False kwargs["weights"] = "imagenet" if self._config["enc_load_weights"] else None - retval = getattr(kapp, self._selected_model["keras_name"])(**kwargs) + retval = getattr(kapp, model.keras_name)(**kwargs) else: retval = _EncoderFaceswap(self._config) return retval @@ -665,14 +750,14 @@ class _EncoderFaceswap(): # pylint:disable=too-few-public-methods config: dict The model configuration options """ - def __init__(self, config): + def __init__(self, config: dict) -> None: self._config = config self._type = self._config["enc_architecture"] self._depth = config[f"{self._type}_depth"] self._min_filters = config["fs_original_min_filters"] self._max_filters = config["fs_original_max_filters"] - def __call__(self, inputs): + def __call__(self, inputs: Tensor) -> Tensor: """ Call the original Faceswap Encoder Parameters @@ -705,7 +790,10 @@ class FullyConnected(): # pylint:disable=too-few-public-methods config: dict The user configuration dictionary """ - def __init__(self, side, input_shape, config): + def __init__(self, + side: Literal["a", "b", "both", "gblock", "shared"], + input_shape: tuple, + config: dict) -> None: logger.debug("Initializing: %s (side: %s, input_shape: %s)", self.__class__.__name__, side, input_shape) self._side = side @@ -718,7 +806,7 @@ def __init__(self, side, input_shape, config): self.__class__.__name__, self._side, self._min_nodes, self._max_nodes) @property - def _min_nodes(self): + def _min_nodes(self) -> int: """ int: The number of nodes for the first Dense. For non g-block layers this will be the given minimum filters multiplied by the dimensions squared. For g-block layers, this is the given value """ @@ -729,7 +817,7 @@ def _min_nodes(self): return retval @property - def _max_nodes(self): + def _max_nodes(self) -> int: """ int: The number of nodes for the final Dense. For non g-block layers this will be the given maximum filters multiplied by the dimensions squared. This number will be scaled down if the final shape can not be mapped to the requested output size. @@ -742,7 +830,7 @@ def _max_nodes(self): retval = int(retval * self._config["fc_dimensions"] ** 2) return retval - def _scale_filters(self, original_filters): + def _scale_filters(self, original_filters: int) -> int: """ Scale the filters to be compatible with the model's selected output size. Parameters @@ -767,7 +855,40 @@ def _scale_filters(self, original_filters): logger.debug("original_filters: %s, scaled_filters: %s", original_filters, retval) return retval - def __call__(self): + def _do_upsampling(self, inputs: Tensor) -> Tensor: + """ Perform the upsampling at the end of the fully connected layers. + + Parameters + ---------- + inputs: Tensor + The input to the upsample layers + + Returns + ------- + Tensor + The output from the upsample layers + """ + upsample_filts = self._scale_filters(self._config["fc_upsample_filters"]) + upsampler = self._config["fc_upsampler"].lower() + num_upsamples = self._config["fc_upsamples"] + var_x = inputs + if upsampler == "upsample2d" and num_upsamples > 1: + upscaler = _get_upscale_layer(upsampler, + upsample_filts, # Not used but required + upsamples=2 ** num_upsamples, + interpolation="bilinear") + var_x = upscaler(var_x) + else: + for _ in range(num_upsamples): + upscaler = _get_upscale_layer(upsampler, + upsample_filts, + activation="leakyrelu") + var_x = upscaler(var_x) + if upsampler == "upsample2d": + var_x = LeakyReLU(alpha=0.1)(var_x) + return var_x + + def __call__(self) -> keras.models.Model: """ Call the intermediate layer. Returns @@ -796,16 +917,8 @@ def __call__(self): if self._side != "gblock": dim = self._config["fc_dimensions"] - upsample_filts = self._scale_filters(self._config["fc_upsample_filters"]) - var_x = Reshape((dim, dim, int(self._max_nodes / (dim ** 2))))(var_x) - for _ in range(self._config["fc_upsamples"]): - upscaler = _get_upscale_layer(self._config["fc_upsampler"].lower(), - upsample_filts, - activation="leakyrelu") - var_x = upscaler(var_x) - if self._config["fc_upsampler"].lower() == "upsample2d": - var_x = LeakyReLU(alpha=0.1)(var_x) + var_x = self._do_upsampling(var_x) return KerasModel(input_, var_x, name=f"fc_{self._side}") @@ -824,7 +937,10 @@ class GBlock(): # pylint:disable=too-few-public-methods config: dict The user configuration dictionary """ - def __init__(self, side, input_shapes, config): + def __init__(self, + side: Literal["a", "b", "both"], + input_shapes: Union[list, tuple], + config: dict) -> None: logger.debug("Initializing: %s (side: %s, input_shapes: %s)", self.__class__.__name__, side, input_shapes) self._side = side @@ -835,7 +951,7 @@ def __init__(self, side, input_shapes, config): logger.debug("Initialized: %s", self.__class__.__name__) @classmethod - def _g_block(cls, inputs, style, filters, recursions=2): + def _g_block(cls, inputs: Tensor, style: Tensor, filters: int, recursions: int = 2) -> Tensor: """ G_block adapted from ADAIN StyleGAN. Parameters @@ -868,7 +984,7 @@ def _g_block(cls, inputs, style, filters, recursions=2): return var_x - def __call__(self): + def __call__(self) -> keras.models.Model: """ G-Block Network. Returns @@ -902,7 +1018,10 @@ class Decoder(): # pylint:disable=too-few-public-methods config: dict The user configuration dictionary """ - def __init__(self, side, input_shape, config): + def __init__(self, + side: Literal["a", "b", "both"], + input_shape: Tuple[int, int, int], + config: dict) -> None: logger.debug("Initializing: %s (side: %s, input_shape: %s)", self.__class__.__name__, side, input_shape) self._side = side @@ -910,7 +1029,7 @@ def __init__(self, side, input_shape, config): self._config = config logger.debug("Initialized: %s", self.__class__.__name__,) - def _reshape_for_output(self, inputs): + def _reshape_for_output(self, inputs: Tensor) -> Tensor: """ Reshape the input for arbitrary output sizes. The number of filters in the input will have been scaled to the model output size allowing @@ -937,7 +1056,11 @@ def _reshape_for_output(self, inputs): var_x = Reshape(new_shape)(var_x) return var_x - def _upscale_block(self, inputs, filters, skip_residual=False, is_mask=False): + def _upscale_block(self, + inputs: Tensor, + filters: int, + skip_residual: bool = False, + is_mask: bool = False) -> Tensor: """ Upscale block for Phaze-A Decoder. Uses requested upscale method, adds requested regularization and activation function. @@ -974,7 +1097,7 @@ def _upscale_block(self, inputs, filters, skip_residual=False, is_mask=False): var_x = LeakyReLU(alpha=0.1)(var_x) return var_x - def _normalization(self, inputs): + def _normalization(self, inputs: Tensor) -> Tensor: """ Add a normalization layer if requested. Parameters @@ -996,7 +1119,7 @@ def _normalization(self, inputs): rms=RMSNormalization) return norms[self._config["dec_norm"]]()(inputs) - def __call__(self): + def __call__(self) -> keras.models.Model: """ Decoder Network. Returns @@ -1017,7 +1140,8 @@ def __call__(self): filters = _get_curve(self._config["dec_max_filters"], self._config["dec_min_filters"], upscales, - self._config["dec_filter_slope"]) + self._config["dec_filter_slope"], + mode=self._config["dec_slope_mode"]) for idx, filts in enumerate(filters): skip_res = idx == len(filters) - 1 and self._config["dec_skip_last_residual"] diff --git a/plugins/train/model/phaze_a_defaults.py b/plugins/train/model/phaze_a_defaults.py index 888c7a1a6a..db8a7c5226 100644 --- a/plugins/train/model/phaze_a_defaults.py +++ b/plugins/train/model/phaze_a_defaults.py @@ -39,16 +39,21 @@ the value saved in the state file with the updated value in config. If not provided this will default to True. """ +from typing import List + from lib.utils import get_backend -_HELPTEXT = ("Phaze-A Model by TorzDF, with thanks to BirbFakes.\n" - "Allows for the experimentation of various standard Networks as the encoder and " - "takes inspiration from Nvidia's StyleGAN for the Decoder. It is highly recommended " - "to research to understand the parameters better.") +_HELPTEXT: str = ( + "Phaze-A Model by TorzDF, with thanks to BirbFakes.\n" + "Allows for the experimentation of various standard Networks as the encoder and takes " + "inspiration from Nvidia's StyleGAN for the Decoder. It is highly recommended to research to " + "understand the parameters better.") + +_ENCODERS: List[str] = [ + "densenet121", "densenet169", "densenet201", "inception_resnet_v2", "inception_v3", + "mobilenet", "mobilenet_v2", "nasnet_large", "nasnet_mobile", "resnet50", "vgg16", "vgg19", + "xception", "fs_original"] -_ENCODERS = ["densenet121", "densenet169", "densenet201", "inception_resnet_v2", "inception_v3", - "mobilenet", "mobilenet_v2", "nasnet_large", "nasnet_mobile", "resnet50", "vgg16", - "vgg19", "xception", "fs_original"] if get_backend() != "amd": _ENCODERS.extend(["efficientnet_b0", "efficientnet_b1", "efficientnet_b2", "efficientnet_b3", "efficientnet_b4", "efficientnet_b5", "efficientnet_b6", "efficientnet_b7", @@ -67,7 +72,7 @@ "BE AWARE Larger resolution will dramatically increase VRAM requirements.", datatype=int, rounding=64, - min_max=(64, 1024), + min_max=(64, 2048), group="general", fixed=True), shared_fc=dict( @@ -259,7 +264,7 @@ "intermediate layer.", datatype=int, rounding=1, - min_max=(1, 16), + min_max=(0, 16), group="hidden layers", fixed=True), fc_min_filters=dict( @@ -279,7 +284,7 @@ "connected layer is: fc_min_filters x fc_dimensions x fc_dimensions.\nNB: This value " "may be scaled down, depending on output resolution.", datatype=int, - rounding=128, + rounding=64, min_max=(128, 5120), group="hidden layers", fixed=True), @@ -290,7 +295,7 @@ "layer will be: fc_dimensions x fc_dimensions x fc_max_filters.", datatype=int, rounding=1, - min_max=(3, 16), + min_max=(1, 16), group="hidden layers", fixed=True), fc_filter_slope=dict( @@ -357,7 +362,7 @@ "down, depending on output resolution. Also note, that this figure will dictate the " "number of filters used for the G-Block, if selected.", datatype=int, - rounding=128, + rounding=64, min_max=(128, 5120), group="hidden layers", fixed=True), @@ -376,7 +381,7 @@ default=512, info="The number of nodes to use for the initial G-Block shared fully connected layer.", datatype=int, - rounding=128, + rounding=64, min_max=(128, 5120), group="g-block hidden layers", fixed=True), @@ -384,7 +389,7 @@ default=512, info="The number of nodes to use for the final G-Block shared fully connected layer.", datatype=int, - rounding=128, + rounding=64, min_max=(128, 5120), group="g-block hidden layers", fixed=True), @@ -453,8 +458,8 @@ info="The minimum number of filters to use in decoder upscalers (i.e. the number of " "filters to use for the final upscale layer).", datatype=int, - min_max=(64, 512), - rounding=64, + min_max=(16, 512), + rounding=16, group="decoder", fixed=True), dec_max_filters=dict( @@ -463,18 +468,41 @@ "filters to use for the first upscale layer).", datatype=int, min_max=(256, 5120), - rounding=128, + rounding=64, group="decoder", fixed=True), + dec_slope_mode=dict( + default="full", + info="Alters the action of the filter slope.\n" + "\n\tfull: The number of filters at each upscale layer will reduce from the chosen " + "max_filters at the first layer to the chosen min_filters at the last layer as " + "dictated by the dec_filter_slope." + "\n\tcap_max: The filters will decline at a fixed rate from each upscale to the next " + "based on the filter_slope setting. If there are more upscales than filters, " + "then the earliest upscales will be capped at the max_filter value until the filters " + "can reduce to the min_filters value at the final upscale. (EG: 512 -> 512 -> 512 -> " + "256 -> 128 -> 64)." + "\n\tcap_min: The filters will decline at a fixed rate from each upscale to the next " + "based on the filter_slope setting. If there are more upscales than filters, then " + "the earliest upscales will drop their filters until the min_filter value is met and " + "repeat the min_filter value for the remaining upscales. (EG: 512 -> 256 -> 128 -> " + "64 -> 64 -> 64).", + choices=["full", "cap_max", "cap_min"], + group="decoder", + fixed=True, + gui_radio=True), dec_filter_slope=dict( default=-0.45, - info="The rate that the filters reduce at each upscale layer. EG:\n" - "Negative numbers will drop the number of filters quicker at first and slow down " - "each upscale.\n" - "Positive numbers will drop the number of filters slower at first but then speed " - "up each upscale.\n" - "0.0 - This will reduce at a linear rate (i.e. the same number of filters will be " - "reduced at each upscale).", + info="The rate that the filters reduce at each upscale layer.\n" + "\n\tFull Slope Mode: Negative numbers will drop the number of filters quicker at " + "first and slow down each upscale. Positive numbers will drop the number of filters " + "slower at first but then speed up each upscale. A value of 0.0 will reduce at a " + "linear rate (i.e. the same number of filters will be reduced at each upscale).\n" + "\n\tCap Min/Max Slope Mode: Only positive values will work here. Negative values " + "will automatically be converted to their positive counterpart. A value of 0.5 will " + "halve the number of filters at each upscale until the minimum value is reached. A " + "value of 0.33 will be reduce the number of filters by a third until the minimum " + "value is reached etc.", datatype=float, min_max=(-.99, .99), rounding=2, @@ -560,7 +588,7 @@ info="Faceswap Encoder only: The minumum number of filters to use for encoder " "convolutions. (i.e. the number of filters to use for the first encoder layer).", datatype=int, - min_max=(64, 2048), + min_max=(16, 2048), rounding=64, group="faceswap encoder configuration", fixed=True), From c7e577e88732381d3e14c1f25c91212bd6a17997 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 6 Jun 2022 13:15:21 +0100 Subject: [PATCH 597/981] Phaze-A - Updates for future implementations --- .../train/model_phaze_a_dfaker_preset.json | 3 +- .../train/model_phaze_a_dfl-h128_preset.json | 3 +- .../model_phaze_a_dfl-sae-df_preset.json | 3 +- .../model_phaze_a_dfl-sae-liae_preset.json | 3 +- .../model_phaze_a_dfl-saehd-df_preset.json | 5 +- .../model_phaze_a_dfl-saehd-liae_preset.json | 3 +- .../train/model_phaze_a_iae_preset.json | 3 +- .../model_phaze_a_lightweight_preset.json | 3 +- .../train/model_phaze_a_original_preset.json | 3 +- .../train/model_phaze_a_stojo_preset.json | 1 + lib/model/nn_blocks.py | 2 +- plugins/train/model/phaze_a.py | 82 +++++++++++++++++-- plugins/train/model/phaze_a_defaults.py | 21 +++-- 13 files changed, 111 insertions(+), 24 deletions(-) diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfaker_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfaker_preset.json index 91932f7a10..369bf7912b 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_dfaker_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfaker_preset.json @@ -6,7 +6,7 @@ "split_gblock": false, "split_decoders": true, "enc_architecture": "fs_original", - "enc_scaling": 40, + "enc_scaling": 7, "enc_load_weights": false, "bottleneck_type": "dense", "bottleneck_norm": "none", @@ -41,6 +41,7 @@ "fs_original_depth": 4, "fs_original_min_filters": 128, "fs_original_max_filters": 1024, + "fs_original_use_alt": false, "mobilenet_width": 1.0, "mobilenet_depth": 1, "mobilenet_dropout": 0.001, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-h128_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-h128_preset.json index dc4934b369..426d1f78ed 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_dfl-h128_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-h128_preset.json @@ -6,7 +6,7 @@ "split_gblock": false, "split_decoders": true, "enc_architecture": "fs_original", - "enc_scaling": 80, + "enc_scaling": 13, "enc_load_weights": false, "bottleneck_type": "dense", "bottleneck_norm": "none", @@ -41,6 +41,7 @@ "fs_original_depth": 4, "fs_original_min_filters": 128, "fs_original_max_filters": 1024, + "fs_original_use_alt": false, "mobilenet_width": 1.0, "mobilenet_depth": 1, "mobilenet_dropout": 0.001, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-df_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-df_preset.json index 25d38a5d9a..8ba7e714ce 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-df_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-df_preset.json @@ -6,7 +6,7 @@ "split_gblock": false, "split_decoders": true, "enc_architecture": "fs_original", - "enc_scaling": 80, + "enc_scaling": 13, "enc_load_weights": false, "bottleneck_type": "dense", "bottleneck_norm": "none", @@ -41,6 +41,7 @@ "fs_original_depth": 4, "fs_original_min_filters": 126, "fs_original_max_filters": 1008, + "fs_original_use_alt": false, "mobilenet_width": 1.0, "mobilenet_depth": 1, "mobilenet_dropout": 0.001, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-liae_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-liae_preset.json index ffc7e043d0..74b89aeb8b 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-liae_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-liae_preset.json @@ -6,7 +6,7 @@ "split_gblock": false, "split_decoders": false, "enc_architecture": "fs_original", - "enc_scaling": 80, + "enc_scaling": 13, "enc_load_weights": false, "bottleneck_type": "dense", "bottleneck_norm": "none", @@ -41,6 +41,7 @@ "fs_original_depth": 4, "fs_original_min_filters": 126, "fs_original_max_filters": 1008, + "fs_original_use_alt": false, "mobilenet_width": 1.0, "mobilenet_depth": 1, "mobilenet_dropout": 0.001, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-df_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-df_preset.json index ea8ca0e583..651f08547a 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-df_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-df_preset.json @@ -6,7 +6,7 @@ "split_gblock": false, "split_decoders": true, "enc_architecture": "fs_original", - "enc_scaling": 80, + "enc_scaling": 13, "enc_load_weights": false, "bottleneck_type": "dense", "bottleneck_norm": "none", @@ -40,7 +40,8 @@ "load_layers": "encoder", "fs_original_depth": 4, "fs_original_min_filters": 64, - "fs_original_max_filters": 5124, + "fs_original_max_filters": 512, + "fs_original_use_alt": false, "mobilenet_width": 1.0, "mobilenet_depth": 1, "mobilenet_dropout": 0.001, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-liae_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-liae_preset.json index 1600b03145..6e2a6d1f2b 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-liae_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-liae_preset.json @@ -6,7 +6,7 @@ "split_gblock": false, "split_decoders": false, "enc_architecture": "fs_original", - "enc_scaling": 80, + "enc_scaling": 13, "enc_load_weights": false, "bottleneck_type": "dense", "bottleneck_norm": "none", @@ -41,6 +41,7 @@ "fs_original_depth": 4, "fs_original_min_filters": 64, "fs_original_max_filters": 512, + "fs_original_use_alt": false, "mobilenet_width": 1.0, "mobilenet_depth": 1, "mobilenet_dropout": 0.001, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_iae_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_iae_preset.json index 4f32ceb528..ab6635b717 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_iae_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_iae_preset.json @@ -6,7 +6,7 @@ "split_gblock": false, "split_decoders": false, "enc_architecture": "fs_original", - "enc_scaling": 40, + "enc_scaling": 7, "enc_load_weights": false, "bottleneck_type": "dense", "bottleneck_norm": "none", @@ -41,6 +41,7 @@ "fs_original_depth": 4, "fs_original_min_filters": 128, "fs_original_max_filters": 1024, + "fs_original_use_alt": false, "mobilenet_width": 1.0, "mobilenet_depth": 1, "mobilenet_dropout": 0.001, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_lightweight_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_lightweight_preset.json index 1ec88f5cbe..35739831eb 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_lightweight_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_lightweight_preset.json @@ -6,7 +6,7 @@ "split_gblock": false, "split_decoders": true, "enc_architecture": "fs_original", - "enc_scaling": 40, + "enc_scaling": 7, "enc_load_weights": false, "bottleneck_type": "dense", "bottleneck_norm": "none", @@ -41,6 +41,7 @@ "fs_original_depth": 3, "fs_original_min_filters": 128, "fs_original_max_filters": 512, + "fs_original_use_alt": false, "mobilenet_width": 1.0, "mobilenet_depth": 1, "mobilenet_dropout": 0.001, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_original_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_original_preset.json index c696db8360..f8440ab34a 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_original_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_original_preset.json @@ -6,7 +6,7 @@ "split_gblock": false, "split_decoders": true, "enc_architecture": "fs_original", - "enc_scaling": 40, + "enc_scaling": 7, "enc_load_weights": false, "bottleneck_type": "dense", "bottleneck_norm": "none", @@ -41,6 +41,7 @@ "fs_original_depth": 4, "fs_original_min_filters": 128, "fs_original_max_filters": 1024, + "fs_original_use_alt": false, "mobilenet_width": 1.0, "mobilenet_depth": 1, "mobilenet_dropout": 0.001, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_stojo_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_stojo_preset.json index 009f3e9538..8e0a71cce7 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_stojo_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_stojo_preset.json @@ -41,6 +41,7 @@ "fs_original_depth": 4, "fs_original_min_filters": 128, "fs_original_max_filters": 1024, + "fs_original_use_alt": false, "mobilenet_width": 1.0, "mobilenet_depth": 1, "mobilenet_dropout": 0.001, diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index 1d35c20ebc..341ae17ed5 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -712,7 +712,7 @@ def __init__(self, filters: int, kernel_size: Union[int, Tuple[int, int]] = 3, padding: str = "same", - activation: str = "leakyrelu", + activation: Optional[str] = "leakyrelu", size: int = 2, interpolation: str = "bilinear", **kwargs) -> None: diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 7d294b390f..c49f0dac58 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -9,7 +9,7 @@ from lib.model.nn_blocks import ( Conv2D, Conv2DBlock, Conv2DOutput, ResidualBlock, UpscaleBlock, Upscale2xBlock, - UpscaleResizeImagesBlock) + UpscaleResizeImagesBlock, UpscaleDNYBlock) from lib.model.normalization import ( AdaInstanceNormalization, GroupNormalization, InstanceNormalization, LayerNormalization, RMSNormalization) @@ -20,7 +20,7 @@ if get_backend() == "amd": from keras import applications as kapp, backend as K from keras.layers import ( - Add, BatchNormalization, Concatenate, Dense, Dropout, Flatten, GaussianNoise, + Add, BatchNormalization, Concatenate, Dense, Dropout, Flatten, GaussianNoise, MaxPool2D, GlobalAveragePooling2D, GlobalMaxPooling2D, Input, LeakyReLU, Reshape, UpSampling2D, Conv2D as KConv2D) from keras.models import clone_model @@ -31,7 +31,7 @@ # Ignore linting errors from Tensorflow's thoroughly broken import system from tensorflow.keras import applications as kapp, backend as K # pylint:disable=import-error from tensorflow.keras.layers import ( # pylint:disable=import-error,no-name-in-module - Add, BatchNormalization, Concatenate, Dense, Dropout, Flatten, GaussianNoise, + Add, BatchNormalization, Concatenate, Dense, Dropout, Flatten, GaussianNoise, MaxPool2D, GlobalAveragePooling2D, GlobalMaxPooling2D, Input, LeakyReLU, Reshape, UpSampling2D, Conv2D as KConv2D) from tensorflow.keras.models import clone_model # noqa pylint:disable=import-error,no-name-in-module @@ -148,7 +148,7 @@ class _EncoderInfo: xception=_EncoderInfo( keras_name="Xception", scaling=(-1, 1), min_size=71, default_size=299), fs_original=_EncoderInfo( - keras_name="", color_order="bgr", min_size=32, default_size=160)) + keras_name="", color_order="bgr", min_size=32, default_size=1024)) class Model(ModelBase): @@ -526,7 +526,8 @@ def _bottleneck(inputs: Tensor, bottleneck: str, size: int, normalization: str) return var_x -def _get_upscale_layer(method: str, +def _get_upscale_layer(method: Literal["resize_images", "subpixel", "upscale_dny", "upscale_fast", + "upscale_hybrid", "upsample2d"], filters: int, activation: Optional[str] = None, upsamples: Optional[int] = None, @@ -536,7 +537,8 @@ def _get_upscale_layer(method: str, Parameters ---------- method: str - The user selected upscale method to use + The user selected upscale method to use. One of `"resize_images"`, `"subpixel"`, + `"upscale_dny"`, `"upscale_fast"`, `"upscale_hybrid"`, `"upsample2d"` filters: int The number of filters to use in the upscale layer activation: str, optional @@ -567,6 +569,8 @@ def _get_upscale_layer(method: str, return Upscale2xBlock(filters, activation=activation, fast=True) if method == "upscale_hybrid": return Upscale2xBlock(filters, activation=activation, fast=False) + if method == "upscale_dny": + return UpscaleDNYBlock(filters, activation=activation) return UpscaleResizeImagesBlock(filters, activation=activation) @@ -756,6 +760,10 @@ def __init__(self, config: dict) -> None: self._depth = config[f"{self._type}_depth"] self._min_filters = config["fs_original_min_filters"] self._max_filters = config["fs_original_max_filters"] + self._is_alt = config["fs_original_use_alt"] + self._relu_alpha = 0.2 if self._is_alt else 0.1 + self._kernel_size = 3 if self._is_alt else 5 + self._strides = 1 if self._is_alt else 2 def __call__(self, inputs: Tensor) -> Tensor: """ Call the original Faceswap Encoder @@ -772,9 +780,35 @@ def __call__(self, inputs: Tensor) -> Tensor: """ var_x = inputs filters = self._config["fs_original_min_filters"] + + if self._is_alt: + var_x = Conv2DBlock(filters, + kernel_size=1, + strides=self._strides, + relu_alpha=self._relu_alpha)(var_x) + for i in range(self._depth): - var_x = Conv2DBlock(filters, activation="leakyrelu", name=f"fs_enc_convblk_{i}")(var_x) + name = f"fs_{'dny_' if self._is_alt else ''}enc" + var_x = Conv2DBlock(filters, + kernel_size=self._kernel_size, + strides=self._strides, + relu_alpha=self._relu_alpha, + name=f"{name}_convblk_{i}")(var_x) filters = min(self._config["fs_original_max_filters"], filters * 2) + if self._is_alt and i == self._depth - 1: + var_x = Conv2DBlock(filters, + kernel_size=4, + strides=self._strides, + padding="valid", + relu_alpha=self._relu_alpha, + name=f"{name}_convblk_{i}_1")(var_x) + elif self._is_alt: + var_x = Conv2DBlock(filters, + kernel_size=self._kernel_size, + strides=self._strides, + relu_alpha=self._relu_alpha, + name=f"{name}_convblk_{i}_1")(var_x) + var_x = MaxPool2D(2, name=f"{name}_pool_{i}")(var_x) return var_x @@ -1027,6 +1061,7 @@ def __init__(self, self._side = side self._input_shape = input_shape self._config = config + self._is_dny = self._config["dec_upscale_method"].lower() == "upscale_dny" logger.debug("Initialized: %s", self.__class__.__name__,) def _reshape_for_output(self, inputs: Tensor) -> Tensor: @@ -1094,7 +1129,8 @@ def _upscale_block(self, var_x = ResidualBlock(filters)(var_x) else: var_x = self._normalization(var_x) - var_x = LeakyReLU(alpha=0.1)(var_x) + if not self._is_dny: + var_x = LeakyReLU(alpha=0.1)(var_x) return var_x def _normalization(self, inputs: Tensor) -> Tensor: @@ -1119,6 +1155,31 @@ def _normalization(self, inputs: Tensor) -> Tensor: rms=RMSNormalization) return norms[self._config["dec_norm"]]()(inputs) + def _dny_entry(self, inputs: Tensor) -> Tensor: + """ Entry convolutions for using the upscale_dny method. + + Parameters + ---------- + inputs: Tensor + The inputs to the dny entry block + + Returns + ------- + Tensor + The output from the dny entry block + """ + var_x = Conv2DBlock(self._config["dec_max_filters"], + kernel_size=4, + strides=1, + padding="same", + relu_alpha=0.2)(inputs) + var_x = Conv2DBlock(self._config["dec_max_filters"], + kernel_size=3, + strides=1, + padding="same", + relu_alpha=0.2)(var_x) + return var_x + def __call__(self) -> keras.models.Model: """ Decoder Network. @@ -1135,6 +1196,11 @@ def __call__(self) -> keras.models.Model: var_y = inputs var_y = self._reshape_for_output(var_y) + if self._is_dny: + var_x = self._dny_entry(var_x) + if self._is_dny and self._config["learn_mask"]: + var_y = self._dny_entry(var_y) + # De-convolve upscales = int(np.log2(self._config["output_size"] / K.int_shape(var_x)[1])) filters = _get_curve(self._config["dec_max_filters"], diff --git a/plugins/train/model/phaze_a_defaults.py b/plugins/train/model/phaze_a_defaults.py index db8a7c5226..063cfb59d9 100644 --- a/plugins/train/model/phaze_a_defaults.py +++ b/plugins/train/model/phaze_a_defaults.py @@ -157,7 +157,7 @@ "variant is: b0: 224px, b1: 240px, b2: 260px, b3: 300px, s: 384px, m: 480px, l: " "480px. Ref: EfficientNetV2: Smaller Models and Faster Training (2021): " "https://arxiv.org/abs/2104.00298" - "\n\tfs_original: (32px - 160px). A configurable variant of the original facewap " + "\n\tfs_original: (32px - 1024px). A configurable variant of the original facewap " "encoder. ImageNet weights cannot be loaded for this model. Additional parameters " "can be configured with the 'fs_enc' options. A version of this encoder is used in " "the following models: Original, Original (lowmem), Dfaker, DFL-H128, DFL-SAE, IAE, " @@ -190,12 +190,13 @@ group="encoder", fixed=True), enc_scaling=dict( - default=40, + default=7, info="Input scaling for the encoder. Some of the encoders have large input sizes, which " "often are not helpful for Faceswap. This setting scales the dimensional space that " "the encoder works in. For example an encoder with a maximum input size of 224px " "will be input an image of 112px at 50%% scaling. See the Architecture tooltip for " - "the minimum and maximum sizes for each encoder.", + "the minimum and maximum sizes for each encoder. NB: The input size will be rounded " + "down to the nearest 16 pixels.", datatype=int, min_max=(0, 100), rounding=1, @@ -432,9 +433,11 @@ "\n\tupscale_fast - Developed by Andenixa. Focusses on speed to upscale, but " "requires more VRAM." "\n\tupscale_hybrid - Developed by Andenixa. Uses a combination of PixelShuffler and " - "Upsampling2D to upscale, saving about 1/3rd of VRAM of the heaviest methods.", + "Upsampling2D to upscale, saving about 1/3rd of VRAM of the heaviest methods." + "\n\tupscale_dny - An alternative upscale implementation using Upsampling2D to " + "upsale.", datatype=str, - choices=["subpixel", "resize_images", "upscale_fast", "upscale_hybrid"], + choices=["subpixel", "resize_images", "upscale_fast", "upscale_hybrid", "upscale_dny"], gui_radio=True, group="decoder", fixed=True), @@ -601,6 +604,14 @@ rounding=128, group="faceswap encoder configuration", fixed=True), + fs_original_use_alt=dict( + default=False, + info="Use a slightly alternate version of the Faceswap Encoder." + "\n\tTrue - Use the alternate variation of the Faceswap Encoder." + "\n\tFalse - Use the original Faceswap Encoder.", + datatype=bool, + group="faceswap encoder configuration", + fixed=True), # MobileNet mobilenet_width=dict( From 5c9fa1aa034b4faf8c89a5ad9a62e55b67f5f63f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 7 Jun 2022 01:11:26 +0100 Subject: [PATCH 598/981] Train updates - Output full model summary last - Fix activations in Phaze-A upscales - Phaze-A Add option to place some upscales in fc model --- .../train/model_phaze_a_dfaker_preset.json | 1 + .../train/model_phaze_a_dfl-h128_preset.json | 1 + .../model_phaze_a_dfl-sae-df_preset.json | 1 + .../model_phaze_a_dfl-sae-liae_preset.json | 1 + .../model_phaze_a_dfl-saehd-df_preset.json | 1 + .../model_phaze_a_dfl-saehd-liae_preset.json | 1 + .../train/model_phaze_a_iae_preset.json | 1 + .../model_phaze_a_lightweight_preset.json | 1 + .../train/model_phaze_a_original_preset.json | 1 + .../train/model_phaze_a_stojo_preset.json | 1 + plugins/train/model/_base.py | 6 +- plugins/train/model/phaze_a.py | 301 ++++++++++++------ plugins/train/model/phaze_a_defaults.py | 13 + 13 files changed, 223 insertions(+), 107 deletions(-) diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfaker_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfaker_preset.json index 369bf7912b..2d9803c3dd 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_dfaker_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfaker_preset.json @@ -27,6 +27,7 @@ "fc_gblock_filter_slope": -0.5, "fc_gblock_dropout": 0.0, "dec_upscale_method": "subpixel", + "dec_upscales_in_fc": 0, "dec_norm": "none", "dec_min_filters": 64, "dec_max_filters": 512, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-h128_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-h128_preset.json index 426d1f78ed..69c879a501 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_dfl-h128_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-h128_preset.json @@ -27,6 +27,7 @@ "fc_gblock_filter_slope": -0.5, "fc_gblock_dropout": 0.0, "dec_upscale_method": "subpixel", + "dec_upscales_in_fc": 0, "dec_norm": "none", "dec_min_filters": 128, "dec_max_filters": 512, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-df_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-df_preset.json index 8ba7e714ce..af9ec50b5f 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-df_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-df_preset.json @@ -27,6 +27,7 @@ "fc_gblock_filter_slope": -0.5, "fc_gblock_dropout": 0.0, "dec_upscale_method": "subpixel", + "dec_upscales_in_fc": 0, "dec_norm": "none", "dec_min_filters": 128, "dec_max_filters": 504, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-liae_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-liae_preset.json index 74b89aeb8b..c1d9901201 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-liae_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-sae-liae_preset.json @@ -27,6 +27,7 @@ "fc_gblock_filter_slope": -0.5, "fc_gblock_dropout": 0.0, "dec_upscale_method": "subpixel", + "dec_upscales_in_fc": 0, "dec_norm": "none", "dec_min_filters": 128, "dec_max_filters": 504, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-df_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-df_preset.json index 651f08547a..df94b9180f 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-df_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-df_preset.json @@ -27,6 +27,7 @@ "fc_gblock_filter_slope": -0.5, "fc_gblock_dropout": 0.0, "dec_upscale_method": "subpixel", + "dec_upscales_in_fc": 0, "dec_norm": "none", "dec_min_filters": 128, "dec_max_filters": 512, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-liae_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-liae_preset.json index 6e2a6d1f2b..b43a33e431 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-liae_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_dfl-saehd-liae_preset.json @@ -27,6 +27,7 @@ "fc_gblock_filter_slope": -0.5, "fc_gblock_dropout": 0.0, "dec_upscale_method": "subpixel", + "dec_upscales_in_fc": 0, "dec_norm": "none", "dec_min_filters": 128, "dec_max_filters": 512, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_iae_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_iae_preset.json index ab6635b717..304fc70eeb 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_iae_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_iae_preset.json @@ -27,6 +27,7 @@ "fc_gblock_filter_slope": -0.5, "fc_gblock_dropout": 0.0, "dec_upscale_method": "subpixel", + "dec_upscales_in_fc": 0, "dec_norm": "none", "dec_min_filters": 64, "dec_max_filters": 512, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_lightweight_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_lightweight_preset.json index 35739831eb..f47590f5c5 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_lightweight_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_lightweight_preset.json @@ -27,6 +27,7 @@ "fc_gblock_filter_slope": -0.5, "fc_gblock_dropout": 0.0, "dec_upscale_method": "subpixel", + "dec_upscales_in_fc": 0, "dec_norm": "none", "dec_min_filters": 128, "dec_max_filters": 512, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_original_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_original_preset.json index f8440ab34a..a4efe963dc 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_original_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_original_preset.json @@ -27,6 +27,7 @@ "fc_gblock_filter_slope": -0.5, "fc_gblock_dropout": 0.0, "dec_upscale_method": "subpixel", + "dec_upscales_in_fc": 0, "dec_norm": "none", "dec_min_filters": 64, "dec_max_filters": 256, diff --git a/lib/gui/.cache/presets/train/model_phaze_a_stojo_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_stojo_preset.json index 8e0a71cce7..479e322568 100644 --- a/lib/gui/.cache/presets/train/model_phaze_a_stojo_preset.json +++ b/lib/gui/.cache/presets/train/model_phaze_a_stojo_preset.json @@ -27,6 +27,7 @@ "fc_gblock_filter_slope": -0.5, "fc_gblock_dropout": 0.0, "dec_upscale_method": "resize_images", + "dec_upscales_in_fc": 0, "dec_norm": "none", "dec_min_filters": 160, "dec_max_filters": 640, diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index b2be51c9cd..aa94b708c0 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -402,8 +402,12 @@ def _output_summary(self): else: # print to logger print_fn = lambda x: logger.verbose("%s", x) # noqa - for model in _get_all_sub_models(self._model): + for idx, model in enumerate(_get_all_sub_models(self._model)): + if idx == 0: + parent = model + continue model.summary(line_length=100, print_fn=print_fn) + parent.summary(line_length=100, print_fn=print_fn) def save(self): """ Save the model to disk. diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index c49f0dac58..8c34e53521 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -954,114 +954,54 @@ def __call__(self) -> keras.models.Model: var_x = Reshape((dim, dim, int(self._max_nodes / (dim ** 2))))(var_x) var_x = self._do_upsampling(var_x) - return KerasModel(input_, var_x, name=f"fc_{self._side}") - - -class GBlock(): # pylint:disable=too-few-public-methods - """ G-Block model, borrowing from Adain StyleGAN. - - Parameters - ---------- - side: ["a", "b", "both"] - The side of the model that the fully connected layers belong to. Used for naming - input_shapes: list or tuple - The shape tuples for the input to the decoder. The first item is the input from each side's - fully connected model, the second item is the input shape from the combined fully connected - model. - config: dict - The user configuration dictionary - """ - def __init__(self, - side: Literal["a", "b", "both"], - input_shapes: Union[list, tuple], - config: dict) -> None: - logger.debug("Initializing: %s (side: %s, input_shapes: %s)", - self.__class__.__name__, side, input_shapes) - self._side = side - self._config = config - self._inputs = [Input(shape=shape) for shape in input_shapes] - self._dense_nodes = 512 - self._dense_recursions = 3 - logger.debug("Initialized: %s", self.__class__.__name__) - - @classmethod - def _g_block(cls, inputs: Tensor, style: Tensor, filters: int, recursions: int = 2) -> Tensor: - """ G_block adapted from ADAIN StyleGAN. - - Parameters - ---------- - inputs: tensor - The input tensor to the G-Block model - style: tensor - The input combined 'style' tensor to the G-Block model - filters: int - The number of filters to use for the G-Block Convolutional layers - recursions: int, optional - The number of recursive Convolutions to process. Default: `2` - - Returns - ------- - tensor - The output tensor from the G-Block model - """ - var_x = inputs - for i in range(recursions): - styles = [Reshape([1, 1, filters])(Dense(filters)(style)) for _ in range(2)] - noise = KConv2D(filters, 1, padding="same")(GaussianNoise(1.0)(var_x)) - - if i == recursions - 1: - var_x = KConv2D(filters, 3, padding="same")(var_x) - - var_x = AdaInstanceNormalization(dtype="float32")([var_x, *styles]) - var_x = Add()([var_x, noise]) - var_x = LeakyReLU(0.2)(var_x) - - return var_x + num_upscales = self._config["dec_upscales_in_fc"] + if num_upscales: + var_x = UpscaleBlocks(self._side, + K.int_shape(var_x)[1:], + self._config, + layer_indicies=(0, num_upscales))(var_x) - def __call__(self) -> keras.models.Model: - """ G-Block Network. + return KerasModel(input_, var_x, name=f"fc_{self._side}") - Returns - ------- - :class:`keras.models.Model` - The G-Block model - """ - var_x, style = self._inputs - for i in range(self._dense_recursions): - style = Dense(self._dense_nodes, kernel_initializer="he_normal")(style) - if i != self._dense_recursions - 1: # Don't add leakyReLu to final output - style = LeakyReLU(0.1)(style) - # Scale g_block filters to side dense - g_filts = K.int_shape(var_x)[-1] - var_x = Conv2D(g_filts, 3, strides=1, padding="same")(var_x) - var_x = GaussianNoise(1.0)(var_x) - var_x = self._g_block(var_x, style, g_filts) - return KerasModel(self._inputs, var_x, name=f"g_block_{self._side}") +class UpscaleBlocks(): # pylint: disable=too-few-public-methods + """ Obtain a block of upscalers. + This class exists outside of the :class:`Decoder` model, as it is possible to place some of + the upscalers at the end of the Fully Connected Layers, so the upscale chain needs to be able + to be calculated by both the Fully Connected Layers and by the Decoder if required. -class Decoder(): # pylint:disable=too-few-public-methods - """ Decoder Network. + For this reason, the Upscale Filter list is created as a class attribute of the + :class:`UpscaleBlocks` layers for reference by either the Decoder or Fully Connected models Parameters ---------- - side: ["a", "b", "both"] - The side of the model that the fully connected layers belong to. Used for naming + side: ["a", "b", "both", "shared"] + The side of the model that the Decoder belongs to. Used for naming input_shape: tuple The shape tuple for the input to the decoder. config: dict The user configuration dictionary + layer_indices: tuple, optional + The tuple indicies indicating the starting layer index and the ending layer index to + generate upscales for. Used for when splitting upscales between the Fully Connected Layers + and the Decoder. ``None`` will generate the full Upscale chain. An end index of -1 will + generate the layers from the starting index to the final upscale. Default: ``None`` """ + _filters: List[int] = [] + def __init__(self, - side: Literal["a", "b", "both"], + side: Literal["a", "b", "both", "shared"], input_shape: Tuple[int, int, int], - config: dict) -> None: - logger.debug("Initializing: %s (side: %s, input_shape: %s)", - self.__class__.__name__, side, input_shape) + config: dict, + layer_indicies: Optional[Tuple[int, int]] = None) -> None: + logger.debug("Initializing: %s (side: %s, input_shape: %s, layer_indicies: %s)", + self.__class__.__name__, side, input_shape, layer_indicies) self._side = side self._input_shape = input_shape self._config = config self._is_dny = self._config["dec_upscale_method"].lower() == "upscale_dny" + self._layer_indicies = layer_indicies logger.debug("Initialized: %s", self.__class__.__name__,) def _reshape_for_output(self, inputs: Tensor) -> Tensor: @@ -1117,7 +1057,11 @@ def _upscale_block(self, tensor The output tensor from the upscale block """ - upscaler = _get_upscale_layer(self._config["dec_upscale_method"].lower(), filters) + upscaler = _get_upscale_layer(self._config["dec_upscale_method"].lower(), + filters, + activation="leakyrelu", + upsamples=2, + interpolation="bilinear") var_x = upscaler(inputs) if not is_mask and self._config["dec_gaussian"]: @@ -1180,40 +1124,185 @@ def _dny_entry(self, inputs: Tensor) -> Tensor: relu_alpha=0.2)(var_x) return var_x - def __call__(self) -> keras.models.Model: + def __call__(self, inputs: Optional[Tensor] = None) -> Tensor: """ Decoder Network. + Parameters + inputs: Tensor, optional + If the input is an output from another model (such as the Fully Connected Model) this + should be ``None`` otherwise it should be a Tensor + Returns ------- :class:`keras.models.Model` The Decoder model """ - inputs = Input(shape=self._input_shape) + inputs = Input(shape=self._input_shape) if inputs is None else inputs var_x = inputs - var_x = self._reshape_for_output(var_x) + start_idx, end_idx = (0, None) if self._layer_indicies is None else self._layer_indicies + end_idx = None if end_idx == -1 else end_idx - if self._config["learn_mask"]: - var_y = inputs - var_y = self._reshape_for_output(var_y) + if start_idx == 0: + var_x = self._reshape_for_output(var_x) + + if self._config["learn_mask"]: + var_y = inputs + var_y = self._reshape_for_output(var_y) - if self._is_dny: - var_x = self._dny_entry(var_x) - if self._is_dny and self._config["learn_mask"]: - var_y = self._dny_entry(var_y) + if self._is_dny: + var_x = self._dny_entry(var_x) + if self._is_dny and self._config["learn_mask"]: + var_y = self._dny_entry(var_y) # De-convolve - upscales = int(np.log2(self._config["output_size"] / K.int_shape(var_x)[1])) - filters = _get_curve(self._config["dec_max_filters"], - self._config["dec_min_filters"], - upscales, - self._config["dec_filter_slope"], - mode=self._config["dec_slope_mode"]) + if not self._filters: + upscales = int(np.log2(self._config["output_size"] / K.int_shape(var_x)[1])) + self._filters.extend(_get_curve(self._config["dec_max_filters"], + self._config["dec_min_filters"], + upscales, + self._config["dec_filter_slope"], + mode=self._config["dec_slope_mode"])) + logger.debug("Generated class filters: %s", self._filters) + + filters = self._filters[start_idx: end_idx] for idx, filts in enumerate(filters): skip_res = idx == len(filters) - 1 and self._config["dec_skip_last_residual"] var_x = self._upscale_block(var_x, filts, skip_residual=skip_res) if self._config["learn_mask"]: var_y = self._upscale_block(var_y, filts, is_mask=True) + retval = [var_x, var_y] if self._config["learn_mask"] else var_x + return retval + + +class GBlock(): # pylint:disable=too-few-public-methods + """ G-Block model, borrowing from Adain StyleGAN. + + Parameters + ---------- + side: ["a", "b", "both"] + The side of the model that the fully connected layers belong to. Used for naming + input_shapes: list or tuple + The shape tuples for the input to the G-Block. The first item is the input from each side's + fully connected model, the second item is the input shape from the combined fully connected + model. + config: dict + The user configuration dictionary + """ + def __init__(self, + side: Literal["a", "b", "both"], + input_shapes: Union[list, tuple], + config: dict) -> None: + logger.debug("Initializing: %s (side: %s, input_shapes: %s)", + self.__class__.__name__, side, input_shapes) + self._side = side + self._config = config + self._inputs = [Input(shape=shape) for shape in input_shapes] + self._dense_nodes = 512 + self._dense_recursions = 3 + logger.debug("Initialized: %s", self.__class__.__name__) + + @classmethod + def _g_block(cls, inputs: Tensor, style: Tensor, filters: int, recursions: int = 2) -> Tensor: + """ G_block adapted from ADAIN StyleGAN. + + Parameters + ---------- + inputs: tensor + The input tensor to the G-Block model + style: tensor + The input combined 'style' tensor to the G-Block model + filters: int + The number of filters to use for the G-Block Convolutional layers + recursions: int, optional + The number of recursive Convolutions to process. Default: `2` + + Returns + ------- + tensor + The output tensor from the G-Block model + """ + var_x = inputs + for i in range(recursions): + styles = [Reshape([1, 1, filters])(Dense(filters)(style)) for _ in range(2)] + noise = KConv2D(filters, 1, padding="same")(GaussianNoise(1.0)(var_x)) + + if i == recursions - 1: + var_x = KConv2D(filters, 3, padding="same")(var_x) + + var_x = AdaInstanceNormalization(dtype="float32")([var_x, *styles]) + var_x = Add()([var_x, noise]) + var_x = LeakyReLU(0.2)(var_x) + + return var_x + + def __call__(self) -> keras.models.Model: + """ G-Block Network. + + Returns + ------- + :class:`keras.models.Model` + The G-Block model + """ + var_x, style = self._inputs + for i in range(self._dense_recursions): + style = Dense(self._dense_nodes, kernel_initializer="he_normal")(style) + if i != self._dense_recursions - 1: # Don't add leakyReLu to final output + style = LeakyReLU(0.1)(style) + + # Scale g_block filters to side dense + g_filts = K.int_shape(var_x)[-1] + var_x = Conv2D(g_filts, 3, strides=1, padding="same")(var_x) + var_x = GaussianNoise(1.0)(var_x) + var_x = self._g_block(var_x, style, g_filts) + return KerasModel(self._inputs, var_x, name=f"g_block_{self._side}") + + +class Decoder(): # pylint:disable=too-few-public-methods + """ Decoder Network. + + Parameters + ---------- + side: ["a", "b", "both"] + The side of the model that the Decoder belongs to. Used for naming + input_shape: tuple + The shape tuple for the input to the decoder. + config: dict + The user configuration dictionary + """ + def __init__(self, + side: Literal["a", "b", "both"], + input_shape: Tuple[int, int, int], + config: dict) -> None: + logger.debug("Initializing: %s (side: %s, input_shape: %s)", + self.__class__.__name__, side, input_shape) + self._side = side + self._input_shape = input_shape + self._config = config + logger.debug("Initialized: %s", self.__class__.__name__,) + + def __call__(self) -> keras.models.Model: + """ Decoder Network. + + Returns + ------- + :class:`keras.models.Model` + The Decoder model + """ + inputs = Input(shape=self._input_shape) + var_x = inputs + num_ups_in_fc = self._config["dec_upscales_in_fc"] + indicies = None if not num_ups_in_fc else (num_ups_in_fc, -1) + + upscales = UpscaleBlocks(self._side, + self._input_shape, + self._config, + layer_indicies=indicies)(var_x) + + if self._config["learn_mask"]: + var_x, var_y = upscales + else: + var_x = upscales outputs = [Conv2DOutput(3, self._config["dec_output_kernel"], name="face_out")(var_x)] if self._config["learn_mask"]: diff --git a/plugins/train/model/phaze_a_defaults.py b/plugins/train/model/phaze_a_defaults.py index 063cfb59d9..03b1ae7c40 100644 --- a/plugins/train/model/phaze_a_defaults.py +++ b/plugins/train/model/phaze_a_defaults.py @@ -441,6 +441,19 @@ gui_radio=True, group="decoder", fixed=True), + dec_upscales_in_fc=dict( + default=0, + min_max=(0, 6), + rounding=1, + info="It is possible to place some of the upscales at the end of the fully connected " + "model. For models with split decoders, but a shared fully connected layer, this would " + "have the effect of saving some VRAM but possibly at the cost of introducing artefacts. " + "For models with a shared decoder but split fully connected layers, this would have the " + "effect of increasing VRAM usage by processing some of the upscales for each side rather " + "than together.", + datatype=int, + group="decoder", + fixed=True), dec_norm=dict( default="none", info="Normalization to apply to apply after each upscale." From 20a657d6cf14c7dcbdecd79de04543b7ab8e3a43 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 7 Jun 2022 10:54:51 +0100 Subject: [PATCH 599/981] Bump minimum TF Version --- Dockerfile.cpu | 2 +- INSTALL.md | 12 ++++- lib/cli/launcher.py | 14 ++++-- plugins/train/model/_base.py | 67 +++------------------------ requirements/requirements_cpu.txt | 2 +- requirements/requirements_nvidia.txt | 2 +- setup.py | 69 +--------------------------- tests/startup_test.py | 3 +- 8 files changed, 33 insertions(+), 138 deletions(-) diff --git a/Dockerfile.cpu b/Dockerfile.cpu index ce7a0b40df..8b9d297737 100755 --- a/Dockerfile.cpu +++ b/Dockerfile.cpu @@ -1,4 +1,4 @@ -FROM tensorflow/tensorflow:2.2.1-py3 +FROM tensorflow/tensorflow:2.8.2 # To disable tzdata and others from asking for input ENV DEBIAN_FRONTEND noninteractive diff --git a/INSTALL.md b/INSTALL.md index 5c82cba761..4fdc9fa016 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -21,13 +21,21 @@ - [Create a desktop shortcut](#create-a-desktop-shortcut) - [Updating faceswap](#updating-faceswap) - [macOS (Apple Silicon) Install Guide](#macos-apple-silicon-install-guide) + - [Prerequisites](#prerequisites-2) + - [OS](#os) + - [XCode Tools](#xcode-tools) + - [XQuartz](#xquartz) + - [Conda](#conda) + - [Setup](#setup-1) + - [faceswap](#faceswap-1) + - [Easy install](#easy-install-1) - [General Install Guide](#general-install-guide) - [Installing dependencies](#installing-dependencies) - [Git](#git-1) - [Python](#python) - [Virtual Environment](#virtual-environment) - [Getting the faceswap code](#getting-the-faceswap-code) - - [Setup](#setup-1) + - [Setup](#setup-2) - [About some of the options](#about-some-of-the-options) - [Run the project](#run-the-project) - [Notes](#notes) @@ -193,7 +201,7 @@ Obtain git for your distribution from the [git website](https://git-scm.com/down The recommended install method is to use a Conda3 Environment as this will handle the installation of Nvidia's CUDA and cuDNN straight into your Conda Environment. This is by far the easiest and most reliable way to setup the project. - MiniConda3 is recommended: [MiniConda3](https://docs.conda.io/en/latest/miniconda.html) -Alternatively you can install Python (>= 3.7-3.8 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Tensorflow yourself, make sure you install the correct Cuda and cuDNN package for the currently installed version of Tensorflow (Current release: Tensorflow 2.2. Release v1.0: Tensorflow 1.15). You can check for the compatible versions here: (https://www.tensorflow.org/install/source#gpu). +Alternatively you can install Python (>= 3.7-3.9 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Tensorflow yourself, make sure you install the correct Cuda and cuDNN package for the currently installed version of Tensorflow (Current release: Tensorflow 2.8. Release v1.0: Tensorflow 1.15). You can check for the compatible versions here: (https://www.tensorflow.org/install/source#gpu). - Python distributions: - apt/yum install python3 (Linux) - [Installer](https://www.python.org/downloads/release/python-368/) (Windows) diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 419c855f15..feb352435b 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -53,9 +53,10 @@ def _test_for_tf_version(self): Raises ------ FaceswapError - If Tensorflow is not found, or is not between versions 2.2 and 2.8 + If Tensorflow is not found, or is not between versions 2.4 and 2.8 """ - min_ver = 2.2 + amd_ver = 2.2 + min_ver = 2.4 max_ver = 2.8 try: # Ensure tensorflow doesn't pin all threads to one core when using Math Kernel Library @@ -78,14 +79,19 @@ def _test_for_tf_version(self): self._handle_import_error(msg) tf_ver = get_tf_version() - if tf_ver < min_ver: + backend = get_backend() + if backend != "amd" and tf_ver < min_ver: msg = (f"The minimum supported Tensorflow is version {min_ver} but you have version " f"{tf_ver} installed. Please upgrade Tensorflow.") self._handle_import_error(msg) - if tf_ver > max_ver: + if backend != "amd" and tf_ver > max_ver: msg = (f"The maximum supported Tensorflow is version {max_ver} but you have version " f"{tf_ver} installed. Please downgrade Tensorflow.") self._handle_import_error(msg) + if backend == "amd" and tf_ver != amd_ver: + msg = (f"The supported Tensorflow version for AMD cards is {amd_ver} but you have " + "version {tf_ver} installed. Please install the correct version.") + self._handle_import_error(msg) logger.debug("Installed Tensorflow Version: %s", tf_ver) @classmethod diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py index aa94b708c0..5c327d80bc 100644 --- a/plugins/train/model/_base.py +++ b/plugins/train/model/_base.py @@ -20,7 +20,7 @@ from lib.model.backup_restore import Backup from lib.model import losses, optimizers from lib.model.nn_blocks import set_config as set_nnblock_config -from lib.utils import get_backend, get_tf_version, FaceswapError +from lib.utils import get_backend, FaceswapError from plugins.train._config import Config if get_backend() == "amd": @@ -433,7 +433,8 @@ def _compile_model(self): 10 ** int(self.config["epsilon_exponent"]), self._args).optimizer if self._settings.use_mixed_precision: - optimizer = self._settings.loss_scale_optimizer(optimizer) + optimizer = tf.keras.mixed_precision.LossScaleOptimizer(optimizer) + if get_backend() == "amd": self._rewrite_plaid_outputs() @@ -714,14 +715,6 @@ def __init__(self, arguments, mixed_precision, allow_growth, is_predict): self._set_tf_settings(allow_growth, arguments.exclude_gpus) use_mixed_precision = not is_predict and mixed_precision and get_backend() == "nvidia" - # Mixed precision moved out of experimental in tensorflow 2.4 - if use_mixed_precision and get_tf_version() < 2.4: - self._mixed_precision = tf.keras.mixed_precision.experimental - elif use_mixed_precision: - self._mixed_precision = tf.keras.mixed_precision - else: - self._mixed_precision = None - self._use_mixed_precision = self._set_keras_mixed_precision(use_mixed_precision, bool(arguments.exclude_gpus)) @@ -739,25 +732,6 @@ def use_mixed_precision(self): """ bool: ``True`` if mixed precision training has been enabled, otherwise ``False``. """ return self._use_mixed_precision - def loss_scale_optimizer(self, optimizer): - """ Optimize loss scaling for mixed precision training. - - Parameters - ---------- - optimizer: :class:`tf.keras.optimizers.Optimizer` - The optimizer instance to wrap - - Returns - -------- - :class:`tf.keras.mixed_precision.loss_scale_optimizer.LossScaleOptimizer` - The original optimizer with loss scaling applied - """ - # tensorflow versions < 2.4 had different kwargs where scaling needs to be explicitly - # defined - kwargs = dict(loss_scale="dynamic") if get_tf_version() < 2.4 else {} - logger.debug("tf version: %s, kwargs: %s", get_tf_version(), kwargs) - return self._mixed_precision.LossScaleOptimizer(optimizer, **kwargs) - @classmethod def _set_tf_settings(cls, allow_growth, exclude_devices): """ Specify Devices to place operations on and Allow TensorFlow to manage VRAM growth. @@ -796,7 +770,8 @@ def _set_tf_settings(cls, allow_growth, exclude_devices): tf.config.experimental.set_memory_growth(gpu, True) logger.debug("Set Tensorflow 'allow_growth' option") - def _set_keras_mixed_precision(self, use_mixed_precision, exclude_gpus): + @classmethod + def _set_keras_mixed_precision(cls, use_mixed_precision, exclude_gpus): """ Enable the Keras experimental Mixed Precision API. Enables the Keras experimental Mixed Precision API if requested in the user configuration @@ -809,19 +784,6 @@ def _set_keras_mixed_precision(self, use_mixed_precision, exclude_gpus): otherwise ``False``. exclude_gpus: bool ``True`` If connected GPUs are being excluded otherwise ``False``. - - There is a bug in Tensorflow 2.2 that will cause a failure if "set_visible_devices" has - been set and mixed_precision is enabled. This can happen if GPUs have been excluded. - The issue is Specifically in - :file:`tensorflow.python.keras.mixed_precision.experimental.device_compatibility_check` - - From doc-string: "if list_local_devices() and tf.config.set_visible_devices() are both - called, TensorFlow will crash. However, GPU names and compute capabilities cannot be - checked without list_local_devices(). - - To get around this, we hack in to set a global parameter to indicate the test has - already been performed. This is likely to cause some issues, but not as many as - guaranteed failure when limiting GPU devices """ logger.debug("use_mixed_precision: %s, exclude_gpus: %s", use_mixed_precision, exclude_gpus) @@ -831,23 +793,8 @@ def _set_keras_mixed_precision(self, use_mixed_precision, exclude_gpus): return False logger.info("Enabling Mixed Precision Training.") - if exclude_gpus and get_tf_version() == 2.2: - # TODO remove this hacky fix to disable mixed precision compatibility testing when - # tensorflow 2.2 support dropped - # pylint:disable=import-outside-toplevel,protected-access - # pylint:disable=import-error,no-name-in-module - from tensorflow.python.keras.mixed_precision.experimental import \ - device_compatibility_check - logger.debug("Overriding tensorflow _logged_compatibility_check parameter. Initial " - "value: %s", device_compatibility_check._logged_compatibility_check) - device_compatibility_check._logged_compatibility_check = True - logger.debug("New value: %s", device_compatibility_check._logged_compatibility_check) - - policy = self._mixed_precision.Policy('mixed_float16') - if get_tf_version() < 2.4: - self._mixed_precision.set_policy(policy) - else: - self._mixed_precision.set_global_policy(policy) + policy = tf.keras.mixed_precision.Policy('mixed_float16') + tf.keras.mixed_precision.set_global_policy(policy) logger.debug("Enabled mixed precision. (Compute dtype: %s, variable_dtype: %s)", policy.compute_dtype, policy.variable_dtype) return True diff --git a/requirements/requirements_cpu.txt b/requirements/requirements_cpu.txt index b403c949aa..2f03386aa7 100644 --- a/requirements/requirements_cpu.txt +++ b/requirements/requirements_cpu.txt @@ -1,2 +1,2 @@ -r _requirements_base.txt -tensorflow>=2.2.0,<2.9.0 +tensorflow>=2.4.0,<2.9.0 diff --git a/requirements/requirements_nvidia.txt b/requirements/requirements_nvidia.txt index a34dd2a6be..a608dc2fef 100644 --- a/requirements/requirements_nvidia.txt +++ b/requirements/requirements_nvidia.txt @@ -1,2 +1,2 @@ -r _requirements_base.txt -tensorflow-gpu>=2.2.0,<2.9.0 +tensorflow-gpu>=2.4.0,<2.9.0 diff --git a/setup.py b/setup.py index dda4c0af77..e3d2ac6a06 100755 --- a/setup.py +++ b/setup.py @@ -17,8 +17,7 @@ INSTALL_FAILED = False # Revisions of tensorflow GPU and cuda/cudnn requirements. These relate specifically to the # Tensorflow builds available from pypi -TENSORFLOW_REQUIREMENTS = {">=2.2.0,<2.4.0": ["10.1", "7.6"], - ">=2.4.0,<2.5.0": ["11.0", "8.0"], +TENSORFLOW_REQUIREMENTS = {">=2.4.0,<2.5.0": ["11.0", "8.0"], ">=2.5.0,<2.9.0": ["11.2", "8.1"]} # Mapping of Python packages to their conda names if different from pip or in non-default channel CONDA_MAPPING = { @@ -256,7 +255,7 @@ def update_tf_dep(self): return self.output.warning( - "The minimum Tensorflow requirement is 2.3 \n" + "The minimum Tensorflow requirement is 2.4 \n" "Tensorflow currently has no official prebuild for your CUDA, cuDNN " "combination.\nEither install a combination that Tensorflow supports or " "build and install your own tensorflow-gpu.\r\n" @@ -643,8 +642,6 @@ def __init__(self, environment): not self.env.missing_packages and not self.env.conda_missing_packages): self.output.info("All Dependencies are up to date") return - if self.env.updater: - self._remove_unrequired_packages() self.install_missing_dep() if self.env.updater: return @@ -691,42 +688,6 @@ def check_conda_missing_dep(self): self.env.conda_missing_packages.append(pkg) continue - def _remove_unrequired_packages(self): - """ Remove packages that have been installed by Pip that might now be installed by - Conda. - - This specifically relates to tensorflow 2.2 when a Conda version was not available for - Windows, so needed to be installed by Pip, with the Cuda toolkit coming from Conda. - - This method is left here in case it is needed in the future. """ - if not self.env.is_conda or self.env.os_version[0] != "Windows": - return - installed_pip = self.env.get_installed_packages() - if "tensorflow-gpu" not in installed_pip: - return - if not installed_pip["tensorflow-gpu"].startswith("2.2"): - return - # The below are a load of pip installed tf dependencies. They may not need to be all - # removed, but won't hurt to take them out of pip and put in Conda - remove_packages = ["urllib3", "pyasn1", "idna", "chardet", "rsa", "requests", - "pyasn1-modules", "oauthlib", "cachetools", "requests-oauthlib", - "google-auth", "werkzeug", "tensorboard-plugin-wit", "protobuf", - "numpy", "markdown", "grpcio", "google-auth-oauthlib", "absl-py", - "wrapt", "termcolor", "tensorflow-gpu-estimator", "tensorboard", - "opt-einsum", "keras-preprocessing", "h5py", "google-pasta", "gast", - "astunparse", "tensorflow-gpu"] - self.output.info("Uninstalling Pip Tensorflow 2.2") - pipexe = [sys.executable, "-m", "pip", "uninstall", "-y", "-qq"] - if not self.env.is_admin and not self.env.is_virtualenv: - pipexe.append("--user") - pipexe.extend([pkg for pkg in remove_packages if pkg in installed_pip]) - - try: - run(pipexe, check=True) - except CalledProcessError: - self.output.warning("Couldn't remove Tensorflow 2.2 with pip. You should attempt this " - "manually") - def install_missing_dep(self): """ Install missing dependencies """ # Install conda packages first @@ -841,32 +802,6 @@ def pip_installer(self, package): self.output.warning(f"Couldn't install {package} with pip. " "Please install this package manually") - def _tensorflow_dependency_install(self): - """ Install the Cuda/cuDNN dependencies from Conda when tensorflow is not available - in Conda. - - This was used whilst Tensorflow 2.2 was not available for Windows in Conda. It is kept - here in case it is required again in the future. - """ - # TODO This will need to be more robust if/when we accept multiple Tensorflow Versions - versions = list(TENSORFLOW_REQUIREMENTS.values())[-1] - condaexe = ["conda", "search"] - pkgs = ["cudatoolkit", "cudnn"] - shell = self.env.os_version[0] == "Windows" - for pkg in pkgs: - with Popen(condaexe + [pkg], shell=shell, stdout=PIPE) as chk: - available = [line.split() - for line - in chk.communicate()[0].decode(self.env.encoding).splitlines() - if line.startswith(pkg)] - compatible = [req for req in available - if (pkg == "cudatoolkit" and req[1].startswith(versions[0])) - or (pkg == "cudnn" and versions[0] in req[2] - and req[1].startswith(versions[1]))] - - candidate = "==".join(sorted(compatible, key=lambda x: x[1])[-1][:2]) - self.conda_installer(candidate, verbose=True, conda_only=True) - class Tips(): """ Display installation Tips """ diff --git a/tests/startup_test.py b/tests/startup_test.py index d48e0cc10e..d126358fae 100644 --- a/tests/startup_test.py +++ b/tests/startup_test.py @@ -31,6 +31,5 @@ def test_backend(dummy): # pylint:disable=unused-argument def test_keras(dummy): # pylint:disable=unused-argument """ Sanity check to ensure that tensorflow keras is being used for CPU and standard keras for AMD. """ - assert ((_BACKEND == "cpu" and keras.__version__ in ("2.3.0-tf", "2.4.0", - "2.6.0", "2.7.0", "2.8.0")) or + assert ((_BACKEND == "cpu" and keras.__version__ in ("2.4.0", "2.6.0", "2.7.0", "2.8.0")) or (_BACKEND == "amd" and keras.__version__ == "2.2.4")) From df092470ecbf117a3b0f82eed53fe4c1e3871068 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 7 Jun 2022 19:24:15 +0100 Subject: [PATCH 600/981] Convert - Add ability to directionally erode mask --- plugins/convert/mask/mask_blend.py | 95 ++++++++++++++------- plugins/convert/mask/mask_blend_defaults.py | 55 +++++++++++- 2 files changed, 119 insertions(+), 31 deletions(-) diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index 020866b99f..b0da1ee777 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -1,11 +1,15 @@ #!/usr/bin/env python3 """ Plugin to blend the edges of the face between the swap and the original face. """ +from typing import List, Literal, Optional, Tuple, TYPE_CHECKING import cv2 import numpy as np from ._base import Adjustment, logger +if TYPE_CHECKING: + from lib.align import DetectedFace + class Mask(Adjustment): """ Manipulations to perform to the mask that is to be applied to the output of the Faceswap @@ -22,13 +26,20 @@ class Mask(Adjustment): **kwargs: dict, optional See the parent :class:`~plugins.convert.mask._base` for additional keyword arguments. """ - def __init__(self, mask_type, output_size, coverage_ratio, **kwargs): + def __init__(self, mask_type: str, output_size: int, coverage_ratio: float, **kwargs): super().__init__(mask_type, output_size, **kwargs) - self._do_erode = self.config.get("erosion", 0) != 0 + + erode_types = [f"erosion{f}" for f in ["", "_left", "_top", "_right", "_bottom"]] + self._erodes = [self.config.get(erode, 0) / 100 for erode in erode_types] + self._do_erode = any(amount != 0 for amount in self._erodes) + self._coverage_ratio = coverage_ratio - def process(self, detected_face, sub_crop_offset, # pylint:disable=arguments-differ - centering, predicted_mask=None,): + def process(self, # type:ignore # pylint:disable=arguments-differ + detected_face: "DetectedFace", + sub_crop_offset: Optional[np.ndarray], + centering: Literal["legacy", "face", "head"], + predicted_mask: Optional[np.ndarray] = None) -> Tuple[np.ndarray, np.ndarray]: """ Obtain the requested mask type and perform any defined mask manipulations. Parameters @@ -50,16 +61,22 @@ def process(self, detected_face, sub_crop_offset, # pylint:disable=arguments- raw_mask: :class:`numpy.ndarray` The mask with no erosion/dilation applied """ - logger.trace("detected_face: %s, sub_crop_offset: %s, centering: '%s', predicted_mask: %s", - detected_face, sub_crop_offset, centering, predicted_mask is not None) + logger.trace( # type: ignore + "detected_face: %s, sub_crop_offset: %s, centering: '%s', predicted_mask: %s", + detected_face, sub_crop_offset, centering, predicted_mask is not None) mask = self._get_mask(detected_face, predicted_mask, centering, sub_crop_offset) raw_mask = mask.copy() if not self.skip and self._do_erode: mask = self._erode(mask) - logger.trace("mask shape: %s, raw_mask shape: %s", mask.shape, raw_mask.shape) + logger.trace( # type: ignore + "mask shape: %s, raw_mask shape: %s", mask.shape, raw_mask.shape) return mask, raw_mask - def _get_mask(self, detected_face, predicted_mask, centering, sub_crop_offset): + def _get_mask(self, + detected_face: "DetectedFace", + predicted_mask: Optional[np.ndarray], + centering: Literal["legacy", "face", "head"], + sub_crop_offset: Optional[np.ndarray]) -> np.ndarray: """ Return the requested mask with any requested blurring applied. Parameters @@ -83,7 +100,7 @@ def _get_mask(self, detected_face, predicted_mask, centering, sub_crop_offset): if self.mask_type == "none": # Return a dummy mask if not using a mask mask = np.ones_like(self.dummy[:, :, 1], dtype="float32")[..., None] - elif self.mask_type == "predicted": + elif self.mask_type == "predicted" and predicted_mask is not None: mask = predicted_mask[..., None] else: mask = detected_face.mask[self.mask_type] @@ -91,7 +108,7 @@ def _get_mask(self, detected_face, predicted_mask, centering, sub_crop_offset): blur_type=self.config["type"], blur_passes=self.config["passes"], threshold=self.config["threshold"]) - if np.any(sub_crop_offset): + if sub_crop_offset is not None and np.any(sub_crop_offset): mask.set_sub_crop(sub_crop_offset, centering) mask = self._crop_to_coverage(mask.mask) mask_size = mask.shape[0] @@ -102,10 +119,10 @@ def _get_mask(self, detected_face, predicted_mask, centering, sub_crop_offset): self.dummy.shape[:2], interpolation=interp)[..., None] mask = mask.astype("float32") / 255.0 - logger.trace(mask.shape) + logger.trace(mask.shape) # type: ignore return mask - def _crop_to_coverage(self, mask): + def _crop_to_coverage(self, mask: np.ndarray) -> np.ndarray: """ Crop the mask to the correct dimensions based on coverage ratio. Parameters @@ -124,12 +141,12 @@ def _crop_to_coverage(self, mask): padding = round((mask_size * (1 - self._coverage_ratio)) / 2) mask_slice = slice(padding, mask_size - padding) mask = mask[mask_slice, mask_slice, :] - logger.trace("mask_size: %s, coverage: %s, padding: %s, final shape: %s", + logger.trace("mask_size: %s, coverage: %s, padding: %s, final shape: %s", # type: ignore mask_size, self._coverage_ratio, padding, mask.shape) return mask # MASK MANIPULATIONS - def _erode(self, mask): + def _erode(self, mask: np.ndarray) -> np.ndarray: """ Erode or dilate mask the mask based on configuration options. Parameters @@ -142,17 +159,29 @@ def _erode(self, mask): :class:`numpy.ndarray` The mask with erosion/dilation applied """ - kernel = self._get_erosion_kernel(mask) - if self.config["erosion"] > 0: - logger.trace("Eroding mask") - mask = cv2.erode(mask, kernel, iterations=1) - else: - logger.trace("Dilating mask") - mask = cv2.dilate(mask, kernel, iterations=1) + kernels = self._get_erosion_kernels(mask) + if not any(k.any() for k in kernels): + return mask # No kernels could be created from selected input res + eroded = [] + for idx, (kernel, ratio) in enumerate(zip(kernels, self._erodes)): + if not kernel.any(): + continue + anchor = [-1, -1] + if idx > 0: + pos = 1 if idx % 2 == 0 else 0 + val = max(kernel.shape) - 1 if idx < 3 else 0 + anchor[pos] = val + func = cv2.erode if ratio > 0 else cv2.dilate + eroded.append(func(mask, kernel, iterations=1, anchor=anchor)) + + mask = np.min(np.array(eroded), axis=0) if len(eroded) > 1 else eroded[0] return mask - def _get_erosion_kernel(self, mask): - """ Get the erosion kernel. + def _get_erosion_kernels(self, mask: np.ndarray) -> List[np.ndarray]: + """ Get the erosion kernels for each of the center, left, top right and bottom erosions. + + An approximation is made based on the number of positive pixels within the mask to create + an ellipse to act as kernel. Parameters ---------- @@ -161,12 +190,18 @@ def _get_erosion_kernel(self, mask): Returns ------- - :class:`numpy.ndarray` - The erosion kernel to be used for erosion/dilation + list + The erosion kernels to be used for erosion/dilation """ - erosion_ratio = self.config["erosion"] / 100 mask_radius = np.sqrt(np.sum(mask)) / 2 - kernel_size = max(1, int(abs(erosion_ratio * mask_radius))) - erosion_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) - logger.trace("erosion_kernel shape: %s", erosion_kernel.shape) - return erosion_kernel + kernel_sizes = [max(0, int(abs(ratio * mask_radius))) for ratio in self._erodes] + kernels = [] + for idx, size in enumerate(kernel_sizes): + kernel = [size, size] + shape = cv2.MORPH_ELLIPSE if idx == 0 else cv2.MORPH_RECT + if idx > 1: + pos = 0 if idx % 2 == 0 else 1 + kernel[pos] = 1 # Set x/y to 1px based on whether eroding top/bottom, left/right + kernels.append(cv2.getStructuringElement(shape, kernel) if size else np.array(0)) + logger.trace("Erosion kernels: %s", [k.shape for k in kernels]) # type: ignore + return kernels diff --git a/plugins/convert/mask/mask_blend_defaults.py b/plugins/convert/mask/mask_blend_defaults.py index 24a9ee26e1..f864fb1270 100755 --- a/plugins/convert/mask/mask_blend_defaults.py +++ b/plugins/convert/mask/mask_blend_defaults.py @@ -100,7 +100,8 @@ ), erosion=dict( default=0.0, - info="Erosion kernel size as a percentage of the mask radius area.\n" + info="Apply erosion to the whole of the face mask.\n" + "Erosion kernel size as a percentage of the mask radius area.\n" "Positive values apply erosion which reduces the size of the swapped area.\n" "Negative values apply dilation which increases the swapped area.", datatype=float, @@ -111,4 +112,56 @@ group="settings", fixed=True, ), + erosion_top=dict( + default=0.0, + info="Apply erosion to the top part of the mask only.\n" + "Positive values apply erosion which pulls the mask into the center.\n" + "Negative values apply dilation which pushes the mask away from the center.", + datatype=float, + rounding=1, + min_max=(-100.0, 100.0), + choices=[], + gui_radio=False, + group="settings", + fixed=True, + ), + erosion_bottom=dict( + default=0.0, + info="Apply erosion to the bottom part of the mask only.\n" + "Positive values apply erosion which pulls the mask into the center.\n" + "Negative values apply dilation which pushes the mask away from the center.", + datatype=float, + rounding=1, + min_max=(-100.0, 100.0), + choices=[], + gui_radio=False, + group="settings", + fixed=True, + ), + erosion_left=dict( + default=0.0, + info="Apply erosion to the left part of the mask only.\n" + "Positive values apply erosion which pulls the mask into the center.\n" + "Negative values apply dilation which pushes the mask away from the center.", + datatype=float, + rounding=1, + min_max=(-100.0, 100.0), + choices=[], + gui_radio=False, + group="settings", + fixed=True, + ), + erosion_right=dict( + default=0.0, + info="Apply erosion to the right part of the mask only.\n" + "Positive values apply erosion which pulls the mask into the center.\n" + "Negative values apply dilation which pushes the mask away from the center.", + datatype=float, + rounding=1, + min_max=(-100.0, 100.0), + choices=[], + gui_radio=False, + group="settings", + fixed=True, + ), ) From 73442b520c40d58e07d208f3cf98807291ec8edd Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 8 Jun 2022 00:54:47 +0100 Subject: [PATCH 601/981] convert: remove box blend plugin --- docs/full/plugins/convert.rst | 16 -- lib/convert.py | 27 +-- plugins/convert/mask/_base.py | 124 ------------- plugins/convert/mask/box_blend.py | 77 -------- plugins/convert/mask/box_blend_defaults.py | 109 ------------ plugins/convert/mask/mask_blend.py | 197 ++++++++++++++++----- 6 files changed, 159 insertions(+), 391 deletions(-) delete mode 100644 plugins/convert/mask/_base.py delete mode 100644 plugins/convert/mask/box_blend.py delete mode 100755 plugins/convert/mask/box_blend_defaults.py diff --git a/docs/full/plugins/convert.rst b/docs/full/plugins/convert.rst index a67ee74ad1..05f3a110ef 100755 --- a/docs/full/plugins/convert.rst +++ b/docs/full/plugins/convert.rst @@ -10,22 +10,6 @@ The Convert Package handles the various plugins available for performing convers mask package ============ -mask._base module ------------------ - -.. automodule:: plugins.convert.mask._base - :members: - :undoc-members: - :show-inheritance: - -mask.box_blend module ---------------------- - -.. automodule:: plugins.convert.mask.box_blend - :members: - :undoc-members: - :show-inheritance: - mask.mask_blend module ---------------------- diff --git a/lib/convert.py b/lib/convert.py index d9c783a050..86f4c64b2e 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -52,7 +52,7 @@ def __init__(self, output_size, coverage_ratio, centering, draw_transparent, pre self._configfile = configfile self._scale = arguments.output_scale / 100 - self._adjustments = dict(box=None, mask=None, color=None, seamless=None, sharpening=None) + self._adjustments = dict(mask=None, color=None, seamless=None, sharpening=None) self._load_plugins() logger.debug("Initialized %s", self.__class__.__name__) @@ -75,7 +75,7 @@ def reinitialize(self, config): Pre-loaded :class:`lib.config.FaceswapConfig`. used over any configuration on disk. """ logger.debug("Reinitializing converter") - self._adjustments = dict(box=None, mask=None, color=None, seamless=None, sharpening=None) + self._adjustments = dict(mask=None, color=None, seamless=None, sharpening=None) self._load_plugins(config=config, disable_logging=True) logger.debug("Reinitialized converter") @@ -95,13 +95,6 @@ def _load_plugins(self, config=None, disable_logging=False): suppress these messages otherwise ``False``. Default: ``False`` """ logger.debug("Loading plugins. config: %s", config) - self._adjustments["box"] = PluginLoader.get_converter( - "mask", - "box_blend", - disable_logging=disable_logging)(self._output_size, - configfile=self._configfile, - config=config) - self._adjustments["mask"] = PluginLoader.get_converter( "mask", "mask_blend", @@ -287,7 +280,6 @@ def _pre_warp_adjustments(self, new_face, detected_face, reference_face, predict logger.trace("new_face shape: %s, predicted_mask shape: %s", new_face.shape, predicted_mask.shape if predicted_mask is not None else None) old_face = reference_face.face[..., :3] / 255.0 - new_face = self._adjustments["box"].run(new_face) new_face, raw_mask = self._get_image_mask(new_face, detected_face, predicted_mask, @@ -300,15 +292,14 @@ def _pre_warp_adjustments(self, new_face, detected_face, reference_face, predict return new_face def _get_image_mask(self, new_face, detected_face, predicted_mask, reference_face): - """ Return any selected image mask and intersect with any box mask. + """ Return any selected image mask - Places the requested mask into the new face's Alpha channel, intersecting with any box - mask that has already been applied. + Places the requested mask into the new face's Alpha channel. Parameters ---------- new_face: :class:`numpy.ndarray` - The swapped face received from the faceswap model, with any box mask applied + The swapped face received from the faceswap model. detected_face: :class:`~lib.DetectedFace` The detected_face object as defined in :class:`scripts.convert.Predictor` predicted_mask: :class:`numpy.ndarray` or ``None`` @@ -331,12 +322,8 @@ def _get_image_mask(self, new_face, detected_face, predicted_mask, reference_fac reference_face.pose.offset[mask_centering]) mask, raw_mask = self._adjustments["mask"].run(detected_face, crop_offset, self._centering, predicted_mask=predicted_mask) - if new_face.shape[2] == 4: - logger.trace("Combining mask with alpha channel box mask") - new_face[:, :, -1] = np.minimum(new_face[:, :, -1], mask.squeeze()) - else: - logger.trace("Adding mask to alpha channel") - new_face = np.concatenate((new_face, mask), -1) + logger.trace("Adding mask to alpha channel") + new_face = np.concatenate((new_face, mask), -1) logger.trace("Got mask. Image shape: %s", new_face.shape) return new_face, raw_mask diff --git a/plugins/convert/mask/_base.py b/plugins/convert/mask/_base.py deleted file mode 100644 index 0008dd35e2..0000000000 --- a/plugins/convert/mask/_base.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -""" Base class for Faceswap :mod:`~plugins.convert.mask` Plugins """ - -import logging - -import numpy as np - -from plugins.convert._config import Config - -logger = logging.getLogger(__name__) # pylint: disable=invalid-name - - -def _get_config(plugin_name, configfile=None): - """ Return the :attr:`lib.config.FaceswapConfig.config_dict` for the requested plugin. - - Parameters - ---------- - plugin_name: str - The name of the plugin to retrieve the config for - configfile: str, optional - Optional location of custom configuration ``ini`` file. If ``None`` then use the default - config location. Default: ``None`` - - Returns - ------- - dict - The configuration in dictionary form for the given plugin_name from - :attr:`lib.config.FaceswapConfig.config_dict` - """ - return Config(plugin_name, configfile=configfile).config_dict - - -class Adjustment(): - """ Parent class for Mask Adjustment Plugins. - - All mask plugins must inherit from this class. - - Parameters - ---------- - mask_type: str - The type of mask that this plugin is being used for - output_size: int - The size, in pixels, of the output from the Faceswap model. - configfile: str, Optional - Optional location of custom configuration ``ini`` file. If ``None`` then use the default - config location. Default: ``None`` - config: :class:`lib.config.FaceswapConfig`, Optional - Optional pre-loaded :class:`lib.config.FaceswapConfig`. If passed, then this will be used - over any configuration on disk. If ``None`` then it is ignored. Default: ``None`` - - - Attributes - ---------- - config: dict - The configuration dictionary for this plugin. - mask_type: str - The type of mask that this plugin is being used for. - """ - def __init__(self, mask_type, output_size, configfile=None, config=None): - logger.debug("Initializing %s: (arguments: '%s', output_size: %s, " - "configfile: %s, config: %s)", self.__class__.__name__, mask_type, - output_size, configfile, config) - self.config = self._set_config(configfile, config) - logger.debug("config: %s", self.config) - self.mask_type = mask_type - self._dummy = np.zeros((output_size, output_size, 3), dtype='float32') - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def dummy(self): - """:class:`numpy.ndarray`: A dummy mask of all zeros of the shape: - (:attr:`output_size`, :attr:`output_size`, `3`) - """ - return self._dummy - - @property - def skip(self): - """bool: ``True`` if the blur type config attribute is ``None`` otherwise ``False`` """ - return self.config.get("type", None) is None - - def _set_config(self, configfile, config): - """ Set the correct configuration for the plugin based on whether a config file - or pre-loaded config has been passed in. - - Parameters - ---------- - configfile: str - Location of custom configuration ``ini`` file. If ``None`` then use the - default config location - config: :class:`lib.config.FaceswapConfig` - Pre-loaded :class:`lib.config.FaceswapConfig`. If passed, then this will be - used over any configuration on disk. If ``None`` then it is ignored. - - Returns - ------- - dict - The configuration in dictionary form for the given from - :attr:`lib.config.FaceswapConfig.config_dict` - """ - section = ".".join(self.__module__.split(".")[-2:]) - if config is None: - retval = _get_config(section, configfile=configfile) - else: - config.section = section - retval = config.config_dict - config.section = None - logger.debug("Config: %s", retval) - return retval - - def process(self, *args, **kwargs): - """ Override for specific mask adjustment plugin processes. - - Input parameters will vary from plugin to plugin. - - Should return a :class:`numpy.ndarray` mask with the plugin's actions applied - """ - raise NotImplementedError - - def run(self, *args, **kwargs): - """ Perform selected adjustment on face """ - logger.trace("Performing mask adjustment: (plugin: %s, args: %s, kwargs: %s", - self.__module__, args, kwargs) - retval = self.process(*args, **kwargs) - return retval diff --git a/plugins/convert/mask/box_blend.py b/plugins/convert/mask/box_blend.py deleted file mode 100644 index ae03750d2f..0000000000 --- a/plugins/convert/mask/box_blend.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python3 -""" Plugin to blend the edges of the face box that comes out of the Faceswap Model into the final -frame. """ - -import numpy as np - -from lib.align import BlurMask -from ._base import Adjustment, logger - - -class Mask(Adjustment): - """ Manipulations to perform on the edges of the box that is received from the Faceswap model. - - As the size of the box coming out of the model is identical for every face, the mask to be - applied is just calculated once (at launch). - - Parameters - ---------- - output_size: int - The size of the output from the Faceswap model. - **kwargs: dict, optional - See the parent :class:`~plugins.convert.mask._base` for additional keyword arguments. - """ - def __init__(self, output_size, **kwargs): - super().__init__("none", output_size, **kwargs) - self.mask = self._get_mask() if not self.skip else None - - def _get_mask(self): - """ Create a mask to be used at the edges of the face box. - - The box for every face will be identical, so the mask is set just once on initialization. - As gaussian blur technically blurs both sides of the mask, the mask ratio is reduced by - half to give a more expected box. - - Returns - ------- - :class:`numpy.ndarray` - The mask to be used at the edges of the box output from the Faceswap model - """ - logger.debug("Building box mask") - mask_ratio = self.config["distance"] / 200 - facesize = self.dummy.shape[0] - erode = slice(round(facesize * mask_ratio), -round(facesize * mask_ratio)) - mask = self.dummy[:, :, -1] - mask[erode, erode] = 1.0 - - mask = BlurMask(self.config["type"], - mask, - self.config["radius"], - is_ratio=True, - passes=self.config["passes"]).blurred - logger.debug("Built box mask. Shape: %s", mask.shape) - return mask - - def process(self, new_face): # pylint:disable=arguments-differ - """ Apply the box mask to the swapped face. - - Parameters - ---------- - new_face: :class:`numpy.ndarray` - The swapped face that has been output from the Faceswap model - - Returns - ------- - :class:`numpy.ndarray` - The input face is returned with the box mask added to the alpha channel if a blur type - has been specified in the plugin configuration. If this configuration is set to - ``None`` then the input face is returned with no mask applied. - """ - if self.skip: - logger.trace("Skipping blend box") - return new_face - - logger.trace("Blending box") - new_face = np.concatenate((new_face, self.mask), axis=-1) - logger.trace("Blended box") - return new_face diff --git a/plugins/convert/mask/box_blend_defaults.py b/plugins/convert/mask/box_blend_defaults.py deleted file mode 100755 index f19698874e..0000000000 --- a/plugins/convert/mask/box_blend_defaults.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env python3 -""" - The default options for the faceswap Box_Blend Mask plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} - - should always be lower text. - dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid data types are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and data types this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and data types this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" - - -_HELPTEXT = "Options for blending the edges of the swapped box with the background image" - - -_DEFAULTS = dict( - type=dict( - default="gaussian", - info="The type of blending to use:" - "\n\t gaussian: Blend with Gaussian filter. Slower, but often better than Normalized" - "\n\t normalized: Blend with Normalized box filter. Faster than Gaussian" - "\n\t none: Don't perform blending", - datatype=str, - rounding=None, - min_max=None, - choices=["gaussian", "normalized", "none"], - gui_radio=True, - group="Blending type", - fixed=True, - ), - distance=dict( - default=11.0, - info="The distance from the edges of the swap box to start blending.\n" - "The distance is set as percentage of the swap box size to give the number of pixels " - "from the edge of the box. Eg: For a swap area of 256px and a percentage of 4%, " - "blending would commence 10 pixels from the edge.\nHigher percentages start the " - "blending from closer to the center of the face, so will reveal more of the source " - "face.", - datatype=float, - rounding=1, - group="settings", - min_max=(0.1, 25.0), - choices=[], - gui_radio=False, - fixed=True, - ), - radius=dict( - default=5.0, - info="Radius dictates how much blending should occur, or more specifically, how far the " - "blending will spread away from the 'distance' parameter.\n" - "This figure is set as a percentage of the swap box size to give the radius in " - "pixels. Eg: For a swap area of 256px and a percentage of 5%, the radius would be 13 " - "pixels.\n" - "NB: Higher percentage means more blending, but too high may reveal more of the " - "source face, or lead to hard lines at the border.", - datatype=float, - rounding=1, - min_max=(0.1, 25.0), - choices=[], - gui_radio=False, - group="settings", - fixed=True, - ), - passes=dict( - default=1, - info="The number of passes to perform. Additional passes of the blending algorithm can " - "improve smoothing at a time cost. This is more useful for 'box' type blending.\n" - "Additional passes have exponentially less effect so it's not worth setting this too " - "high.", - datatype=int, - rounding=1, - min_max=(1, 8), - choices=[], - gui_radio=False, - group="settings", - fixed=True, - ), -) diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index b0da1ee777..d3ad962269 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -1,17 +1,19 @@ #!/usr/bin/env python3 """ Plugin to blend the edges of the face between the swap and the original face. """ -from typing import List, Literal, Optional, Tuple, TYPE_CHECKING +import logging +from typing import List, Literal, Optional, Tuple import cv2 import numpy as np -from ._base import Adjustment, logger +from lib.align import BlurMask, DetectedFace +from lib.config import FaceswapConfig +from plugins.convert._config import Config -if TYPE_CHECKING: - from lib.align import DetectedFace +logger = logging.getLogger(__name__) -class Mask(Adjustment): +class Mask(): # pylint:disable=too-few-public-methods """ Manipulations to perform to the mask that is to be applied to the output of the Faceswap model. @@ -23,23 +25,96 @@ class Mask(Adjustment): The size of the output from the Faceswap model. coverage_ratio: float The coverage ratio that the Faceswap model was trained at. - **kwargs: dict, optional - See the parent :class:`~plugins.convert.mask._base` for additional keyword arguments. + configfile: str, Optional + Optional location of custom configuration ``ini`` file. If ``None`` then use the default + config location. Default: ``None`` + config: :class:`lib.config.FaceswapConfig`, Optional + Optional pre-loaded :class:`lib.config.FaceswapConfig`. If passed, then this will be used + over any configuration on disk. If ``None`` then it is ignored. Default: ``None`` + """ - def __init__(self, mask_type: str, output_size: int, coverage_ratio: float, **kwargs): - super().__init__(mask_type, output_size, **kwargs) + def __init__(self, + mask_type: str, + output_size: int, + coverage_ratio: float, + configfile: Optional[str] = None, + config: Optional[FaceswapConfig] = None) -> None: + logger.debug("Initializing %s: (mask_type: '%s', output_size: %s, coverage_ratio: %s, " + "configfile: %s, config: %s)", self.__class__.__name__, mask_type, + coverage_ratio, output_size, configfile, config) + self._mask_type = mask_type + self._config = self._set_config(configfile, config) + logger.debug("config: %s", self._config) + + self._coverage_ratio = coverage_ratio + self._box = self._get_box(output_size) erode_types = [f"erosion{f}" for f in ["", "_left", "_top", "_right", "_bottom"]] - self._erodes = [self.config.get(erode, 0) / 100 for erode in erode_types] + self._erodes = [self._config.get(erode, 0) / 100 for erode in erode_types] self._do_erode = any(amount != 0 for amount in self._erodes) - self._coverage_ratio = coverage_ratio + def _set_config(self, + configfile: Optional[str], + config: Optional[FaceswapConfig]) -> dict: + """ Set the correct configuration for the plugin based on whether a config file + or pre-loaded config has been passed in. + + Parameters + ---------- + configfile: str + Location of custom configuration ``ini`` file. If ``None`` then use the + default config location + config: :class:`lib.config.FaceswapConfig` + Pre-loaded :class:`lib.config.FaceswapConfig`. If passed, then this will be + used over any configuration on disk. If ``None`` then it is ignored. + + Returns + ------- + dict + The configuration in dictionary form for the given from + :attr:`lib.config.FaceswapConfig.config_dict` + """ + section = ".".join(self.__module__.split(".")[-2:]) + if config is None: + retval = Config(section, configfile=configfile).config_dict + else: + config.section = section + retval = config.config_dict + config.section = None + logger.debug("Config: %s", retval) + return retval + + def _get_box(self, output_size: int) -> np.ndarray: + """ Apply a gradient overlay to the edge of the swap box to smooth out any hard areas + that where the face intersects with the edge of the swap area. - def process(self, # type:ignore # pylint:disable=arguments-differ - detected_face: "DetectedFace", - sub_crop_offset: Optional[np.ndarray], - centering: Literal["legacy", "face", "head"], - predicted_mask: Optional[np.ndarray] = None) -> Tuple[np.ndarray, np.ndarray]: + Gradient is created from 1/16th distance from the edge of the face box and uses the + parameters as provided for mask blend settings + + Parameters + ---------- + output_size: int + The size of the box that contains the swapped face + + Returns + ------- + :class:`numpy.ndarray` + The box mask + """ + box = np.zeros((output_size, output_size, 1), dtype="float32") + edge = output_size // 32 + box[edge:-edge, edge:-edge] = 1.0 + + box = BlurMask(self._config["type"], + box, self._config["kernel_size"], + self._config["passes"]).blurred + return box + + def run(self, + detected_face: DetectedFace, + sub_crop_offset: Optional[np.ndarray], + centering: Literal["legacy", "face", "head"], + predicted_mask: Optional[np.ndarray] = None) -> Tuple[np.ndarray, np.ndarray]: """ Obtain the requested mask type and perform any defined mask manipulations. Parameters @@ -61,19 +136,24 @@ def process(self, # type:ignore # pylint:disable=arguments-differ raw_mask: :class:`numpy.ndarray` The mask with no erosion/dilation applied """ - logger.trace( # type: ignore - "detected_face: %s, sub_crop_offset: %s, centering: '%s', predicted_mask: %s", - detected_face, sub_crop_offset, centering, predicted_mask is not None) + logger.trace("Performing mask adjustment: (detected_face: %s, " # type: ignore + "sub_crop_offset: %s, centering: '%s', predicted_mask: %s", + detected_face, sub_crop_offset, centering, predicted_mask is not None) mask = self._get_mask(detected_face, predicted_mask, centering, sub_crop_offset) raw_mask = mask.copy() - if not self.skip and self._do_erode: + + if self._config.get("type") is not None and self._do_erode: mask = self._erode(mask) logger.trace( # type: ignore "mask shape: %s, raw_mask shape: %s", mask.shape, raw_mask.shape) + + if self._mask_type != "none": + mask *= self._box + return mask, raw_mask def _get_mask(self, - detected_face: "DetectedFace", + detected_face: DetectedFace, predicted_mask: Optional[np.ndarray], centering: Literal["legacy", "face", "head"], sub_crop_offset: Optional[np.ndarray]) -> np.ndarray: @@ -95,33 +175,56 @@ def _get_mask(self, Returns ------- :class:`numpy.ndarray` - The mask sized to Faceswap model output with any requested blurring applied. + The requested mask. """ - if self.mask_type == "none": - # Return a dummy mask if not using a mask - mask = np.ones_like(self.dummy[:, :, 1], dtype="float32")[..., None] - elif self.mask_type == "predicted" and predicted_mask is not None: - mask = predicted_mask[..., None] + if self._mask_type == "none": + mask = np.ones_like(self._box) # Return a dummy mask if not using a mask + elif self._mask_type == "predicted" and predicted_mask is not None: + mask = predicted_mask else: - mask = detected_face.mask[self.mask_type] - mask.set_blur_and_threshold(blur_kernel=self.config["kernel_size"], - blur_type=self.config["type"], - blur_passes=self.config["passes"], - threshold=self.config["threshold"]) - if sub_crop_offset is not None and np.any(sub_crop_offset): - mask.set_sub_crop(sub_crop_offset, centering) - mask = self._crop_to_coverage(mask.mask) - mask_size = mask.shape[0] - face_size = self.dummy.shape[0] - if mask_size != face_size: - interp = cv2.INTER_CUBIC if mask_size < face_size else cv2.INTER_AREA - mask = cv2.resize(mask, - self.dummy.shape[:2], - interpolation=interp)[..., None] - mask = mask.astype("float32") / 255.0 + mask = self._get_stored_mask(detected_face, centering, sub_crop_offset) + logger.trace(mask.shape) # type: ignore return mask + def _get_stored_mask(self, + detected_face: DetectedFace, + centering: Literal["legacy", "face", "head"], + sub_crop_offset: Optional[np.ndarray]) -> np.ndarray: + """ get the requested stored mask from the detected face object. + + Parameters + ---------- + detected_face: :class:`lib.align.DetectedFace` + The DetectedFace object as returned from :class:`scripts.convert.Predictor`. + centering: [`"legacy"`, `"face"`, `"head"`] + The centering to obtain the mask for + sub_crop_offset: :class:`numpy.ndarray` + The (x, y) offset to crop the mask from the center point. Set to `None` if the mask + does not need to be offset for alternative centering + + Returns + ------- + :class:`numpy.ndarray` + The mask sized to Faceswap model output with any requested blurring applied. + """ + mask = detected_face.mask[self._mask_type] + mask.set_blur_and_threshold(blur_kernel=self._config["kernel_size"], + blur_type=self._config["type"], + blur_passes=self._config["passes"], + threshold=self._config["threshold"]) + if sub_crop_offset is not None and np.any(sub_crop_offset): + mask.set_sub_crop(sub_crop_offset, centering) + mask = self._crop_to_coverage(mask.mask) + mask_size = mask.shape[0] + face_size = self._box.shape[0] + if mask_size != face_size: + interp = cv2.INTER_CUBIC if mask_size < face_size else cv2.INTER_AREA + mask = cv2.resize(mask, + self._box.shape[:2], + interpolation=interp)[..., None].astype("float32") / 255. + return mask + def _crop_to_coverage(self, mask: np.ndarray) -> np.ndarray: """ Crop the mask to the correct dimensions based on coverage ratio. @@ -169,13 +272,17 @@ def _erode(self, mask: np.ndarray) -> np.ndarray: anchor = [-1, -1] if idx > 0: pos = 1 if idx % 2 == 0 else 0 - val = max(kernel.shape) - 1 if idx < 3 else 0 + if ratio > 0: + val = max(kernel.shape) - 1 if idx < 3 else 0 + else: + val = 0 if idx < 3 else max(kernel.shape) - 1 anchor[pos] = val + func = cv2.erode if ratio > 0 else cv2.dilate eroded.append(func(mask, kernel, iterations=1, anchor=anchor)) mask = np.min(np.array(eroded), axis=0) if len(eroded) > 1 else eroded[0] - return mask + return mask[..., None] def _get_erosion_kernels(self, mask: np.ndarray) -> List[np.ndarray]: """ Get the erosion kernels for each of the center, left, top right and bottom erosions. From 2298d51237abfa55dc89787543413cb677508bd8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 8 Jun 2022 12:08:18 +0100 Subject: [PATCH 602/981] Preview Tool fixes - Prevent errors from getting swallowed - Correctly handle None mask type - Consistent box blending --- plugins/convert/mask/mask_blend.py | 40 +++++++++++++++++++++++------- tools/preview/preview.py | 20 +++++++-------- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index d3ad962269..f430f48de3 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -105,9 +105,11 @@ def _get_box(self, output_size: int) -> np.ndarray: edge = output_size // 32 box[edge:-edge, edge:-edge] = 1.0 - box = BlurMask(self._config["type"], - box, self._config["kernel_size"], - self._config["passes"]).blurred + if self._config["type"] is not None: + box = BlurMask("gaussian", + box, + 6, + is_ratio=True).blurred return box def run(self, @@ -142,14 +144,13 @@ def run(self, mask = self._get_mask(detected_face, predicted_mask, centering, sub_crop_offset) raw_mask = mask.copy() - if self._config.get("type") is not None and self._do_erode: - mask = self._erode(mask) - logger.trace( # type: ignore - "mask shape: %s, raw_mask shape: %s", mask.shape, raw_mask.shape) - if self._mask_type != "none": + + mask = self._erode(mask) if self._do_erode else mask mask *= self._box + logger.trace( # type: ignore + "mask shape: %s, raw_mask shape: %s", mask.shape, raw_mask.shape) return mask, raw_mask def _get_mask(self, @@ -180,13 +181,34 @@ def _get_mask(self, if self._mask_type == "none": mask = np.ones_like(self._box) # Return a dummy mask if not using a mask elif self._mask_type == "predicted" and predicted_mask is not None: - mask = predicted_mask + mask = self._process_predicted_mask(predicted_mask) else: mask = self._get_stored_mask(detected_face, centering, sub_crop_offset) logger.trace(mask.shape) # type: ignore return mask + def _process_predicted_mask(self, mask: np.ndarray) -> np.ndarray: + """ Process blurring of the predicted mask + + Parameters + ---------- + mask: :class:`numpy.ndarray` + The predicted mask as output from the Faceswap Model + + Returns + ------ + :class:`numpy.ndarray` + The processed predicted mask + """ + blur_type = self._config["type"] + if blur_type is not None: + mask = BlurMask(blur_type, + mask, + self._config["kernel_size"], + passes=self._config["passes"]).blurred + return mask + def _get_stored_mask(self, detected_face: DetectedFace, centering: Literal["legacy", "face", "head"], diff --git a/tools/preview/preview.py b/tools/preview/preview.py index 68859432bc..1efda36b87 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -10,7 +10,7 @@ import sys from configparser import ConfigParser -from threading import Event, Lock +from threading import Event, Lock, Thread import cv2 import numpy as np @@ -22,7 +22,6 @@ from lib.gui.custom_widgets import Tooltip from lib.gui.control_helper import ControlPanel, ControlPanelOption from lib.convert import Converter -from lib.multithreading import MultiThread from lib.utils import FaceswapError from lib.queue_manager import queue_manager from scripts.fsmedia import Alignments, Images @@ -409,14 +408,14 @@ def __init__(self, arguments, available_masks, samples, configfile=configfile) self._shutdown = Event() - self._thread = MultiThread(self._process, - self._trigger, - self._shutdown, - self._queue_patch_in, - self._samples, - tk_vars, - thread_count=1, - name="patch_thread") + self._thread = Thread(target=self._process, + name="patch_thread", + args=(self._trigger, + self._shutdown, + self._queue_patch_in, + self._samples, + tk_vars), + daemon=True) self._thread.start() logger.debug("Initializing %s", self.__class__.__name__) @@ -509,6 +508,7 @@ def _process(self, trigger_event, shutdown_event, patch_queue_in, samples, tk_va self._display.destination = swapped tk_vars["refresh"].set(True) tk_vars["busy"].set(False) + logger.debug("Closed patch process thread") def _update_converter_arguments(self): From 3d8e674adc88b8f4cc206ebad6fb5b600e38fe14 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 8 Jun 2022 12:44:05 +0100 Subject: [PATCH 603/981] convert - Fix affine borders --- lib/convert.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/convert.py b/lib/convert.py index 86f4c64b2e..224d6daca2 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -162,8 +162,8 @@ def process(self, in_queue, out_queue): loglevel("Convert error traceback:", exc_info=True) log_once = True # UNCOMMENT THIS CODE BLOCK TO PRINT TRACEBACK ERRORS - # import sys ; import traceback - # exc_info = sys.exc_info() ; traceback.print_exception(*exc_info) + import sys ; import traceback + exc_info = sys.exc_info() ; traceback.print_exception(*exc_info) logger.trace("Out queue put: %s", item["filename"]) out_queue.put((item["filename"], image)) logger.debug("Completed convert process") @@ -245,7 +245,7 @@ def _get_new_image(self, predicted, frame_size): frame_size, placeholder, flags=cv2.WARP_INVERSE_MAP | interpolator, - borderMode=cv2.BORDER_TRANSPARENT) + borderMode=cv2.BORDER_CONSTANT) logger.trace("Got filename: '%s'. (placeholders: %s)", predicted["filename"], placeholder.shape) From 828c96611beff31429482531a7b2a1465e74829c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 8 Jun 2022 21:12:17 +0100 Subject: [PATCH 604/981] convert: fix mask casting bug --- plugins/convert/mask/mask_blend.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index f430f48de3..ce12a8686e 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -245,6 +245,8 @@ def _get_stored_mask(self, mask = cv2.resize(mask, self._box.shape[:2], interpolation=interp)[..., None].astype("float32") / 255. + else: + mask = np.float32(mask) / 255. return mask def _crop_to_coverage(self, mask: np.ndarray) -> np.ndarray: From d933b6dee347999cffad3c6c162360a0420c6823 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 9 Jun 2022 20:16:20 +0100 Subject: [PATCH 605/981] bugfix: Literal import for python<3.7 --- plugins/train/model/phaze_a.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 8c34e53521..62468c53e0 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -2,8 +2,15 @@ """ Phaze-A Model by TorzDF with thanks to BirbFakes and the myriad of testers. """ # pylint: disable=too-many-lines +import sys from dataclasses import dataclass -from typing import Dict, List, Literal, Optional, Tuple, Union + +from typing import Dict, List, Optional, Tuple, Union + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal import numpy as np From 917acaa4524e0195c52a636fccf6a0de4eedd37b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 12 Jun 2022 02:04:11 +0100 Subject: [PATCH 606/981] bugfixes - Fix MS-SSIM on multi-gpu - Swallow print bug on multi-gpu --- lib/model/losses_tf.py | 2 +- plugins/train/trainer/_base.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/model/losses_tf.py b/lib/model/losses_tf.py index 97844f766d..cd5aa132d9 100644 --- a/lib/model/losses_tf.py +++ b/lib/model/losses_tf.py @@ -104,7 +104,7 @@ def __init__(self, filter_sigma=1.5, max_value=1.0, power_factors=(0.0448, 0.2856, 0.3001, 0.2363, 0.1333)): - super().__init__(name="SSIM_Multiscale_Loss") + super().__init__(name="SSIM_Multiscale_Loss", reduction=tf.keras.losses.Reduction.NONE) self.filter_size = filter_size self.filter_sigma = filter_sigma self.k_1 = k_1 diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 5028a52548..cd0640867d 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -311,7 +311,11 @@ def _print_loss(self, loss): for side, side_loss in zip(("A", "B"), loss)]) timestamp = time.strftime("%H:%M:%S") output = f"[{timestamp}] [#{self._model.iterations:05d}] {output}" - print(f"\r{output}", end="") + try: + print(f"\r{output}", end="") + except OSError as err: + logger.warning("Swallowed OS Error caused by Tensorflow distributed training. output " + "line: %s, error: %s", output, str(err)) def clear_tensorboard(self): """ Stop Tensorboard logging. From 199911ed2e8bcb9c76559f5be7bc60f40f659d30 Mon Sep 17 00:00:00 2001 From: Rushi Chaudhari Date: Sun, 12 Jun 2022 16:47:21 -0400 Subject: [PATCH 607/981] upgrade Docker Ubuntu to 18.04 Update INSTALL.md --- Dockerfile.gpu | 5 +- INSTALL.md | 144 +++++++++++++++++++++++++++++++++++++------------ 2 files changed, 112 insertions(+), 37 deletions(-) diff --git a/Dockerfile.gpu b/Dockerfile.gpu index c7dd1f6549..078875f5ed 100755 --- a/Dockerfile.gpu +++ b/Dockerfile.gpu @@ -1,4 +1,5 @@ -FROM nvidia/cuda:10.1-cudnn7-devel-ubuntu16.04 +FROM nvidia/cuda:11.7.0-runtime-ubuntu18.04 +ARG DEBIAN_FRONTEND=noninteractive #install python3.8 RUN apt-get update @@ -19,7 +20,7 @@ COPY ./requirements/_requirements_base.txt /opt/ COPY ./requirements/requirements_nvidia.txt /opt/ RUN python3.8 -m pip --no-cache-dir install -r /opt/requirements_nvidia.txt && rm /opt/_requirements_base.txt && rm /opt/requirements_nvidia.txt -RUN python3.8 -m pip install jupyter matplotlib +RUN python3.8 -m pip install jupyter matplotlib tqdm RUN python3.8 -m pip install jupyter_http_over_ws RUN jupyter serverextension enable --py jupyter_http_over_ws RUN alias python=python3.8 diff --git a/INSTALL.md b/INSTALL.md index 4fdc9fa016..cf4dae407a 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -37,6 +37,8 @@ - [Getting the faceswap code](#getting-the-faceswap-code) - [Setup](#setup-2) - [About some of the options](#about-some-of-the-options) +- [Docker Install Guide](#docker-install-guide) + - [Docker General](#docker-general) - [Run the project](#run-the-project) - [Notes](#notes) @@ -234,42 +236,97 @@ If setup fails for any reason you can still manually install the packages listed - Docker: Provide a ready-made image. Hide trivial details. Get you straight to the project. - nVidia-Docker: Access to the nVidia GPU on host machine from inside container. -CUDA with Docker in 20 minutes. +# Docker Install Guide + +## Docker General +
+ Click to expand! + + ### CUDA with Docker in 20 minutes. + + 1. Install Docker + https://www.docker.com/community-edition + + 2. Install Nvidia-Docker & Restart Docker Service + https://github.com/NVIDIA/nvidia-docker + + 3. Build Docker Image For faceswap + + ```bash + docker build -t deepfakes-gpu -f Dockerfile.gpu . + ``` + + 4. Mount faceswap volume and Run it + a). without `gui.tools.py` gui not working. + + ```bash + nvidia-docker run --rm -it -p 8888:8888 \ + --hostname faceswap-gpu --name faceswap-gpu \ + -v /opt/faceswap:/srv \ + deepfakes-gpu + ``` + + b). with gui. tools.py gui working. + +Enable local access to X11 server + +```bash +xhost +local: +``` + +Enable nvidia device if working under bumblebee + +```bash +echo ON > /proc/acpi/bbswitch ``` -INFO The tool provides tips for installation - and installs required python packages -INFO Setup in Linux 4.14.39-1-MANJARO -INFO Installed Python: 3.7.5 64bit -INFO Installed PIP: 10.0.1 -Enable Docker? [Y/n] -INFO Docker Enabled -Enable CUDA? [Y/n] -INFO CUDA Enabled -INFO 1. Install Docker - https://www.docker.com/community-edition - 1. Install Nvidia-Docker & Restart Docker Service - https://github.com/NVIDIA/nvidia-docker +Create container +```bash +nvidia-docker run -p 8888:8888 \ + --hostname faceswap-gpu --name faceswap-gpu \ + -v /opt/faceswap:/srv \ + -v /tmp/.X11-unix:/tmp/.X11-unix \ + -e DISPLAY=unix$DISPLAY \ + -e AUDIO_GID=`getent group audio | cut -d: -f3` \ + -e VIDEO_GID=`getent group video | cut -d: -f3` \ + -e GID=`id -g` \ + -e UID=`id -u` \ + deepfakes-gpu - 1. Build Docker Image For faceswap - docker build -t deepfakes-gpu -f Dockerfile.gpu . +``` - 1. Mount faceswap volume and Run it - # without gui. tools.py gui not working. - nvidia-docker run --rm -it -p 8888:8888 \ - --hostname faceswap-gpu --name faceswap-gpu \ - -v /opt/faceswap:/srv \ - deepfakes-gpu +Open a new terminal to interact with the project - # with gui. tools.py gui working. - ## enable local access to X11 server - xhost +local: - ## enable nvidia device if working under bumblebee - echo ON > /proc/acpi/bbswitch - ## create container - nvidia-docker run -p 8888:8888 \ +```bash +docker exec -it deepfakes-gpu /bin/bash +``` + +Launch deepfakes gui (Answer 3 for NVIDIA at the prompt) + +```bash +python3.8 /srv/faceswap.py gui +``` +
+ +## CUDA with Docker on Arch Linux + +
+ Click to expand! + +### Install docker + +```bash +sudo pacman -S docker +``` + +The steps are same but Arch linux doesn't use nvidia-docker + +create container + +```bash +docker run -p 8888:8888 --gpus all --privileged -v /dev:/dev \ --hostname faceswap-gpu --name faceswap-gpu \ - -v /opt/faceswap:/srv \ + -v /mnt/hdd2/faceswap:/srv \ -v /tmp/.X11-unix:/tmp/.X11-unix \ -e DISPLAY=unix$DISPLAY \ -e AUDIO_GID=`getent group audio | cut -d: -f3` \ @@ -277,14 +334,31 @@ INFO 1. Install Docker -e GID=`id -g` \ -e UID=`id -u` \ deepfakes-gpu +``` + +Open a new terminal to interact with the project - 1. Open a new terminal to interact with the project - docker exec -it deepfakes-gpu /bin/bash - # Launch deepfakes gui (Answer 3 for NVIDIA at the prompt) - python3.8 /srv/faceswap.py gui +```bash +docker exec -it deepfakes-gpu /bin/bash +``` + +Launch deepfakes gui (Answer 3 for NVIDIA at the prompt) + +**With `gui.tools.py` gui working.** + Enable local access to X11 server + + ```bash +xhost +local: ``` + + ```bash + python3.8 /srv/faceswap.py gui + ``` + +
-A successful setup log, without docker. +--- +## A successful setup log, without docker. ``` INFO The tool provides tips for installation and installs required python packages From db778bae8933d99b9f381cbdb00e5b1f334fa997 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 16 Jun 2022 02:19:38 +0100 Subject: [PATCH 608/981] bugfix: Mask blend. Apply masks in sequence --- plugins/convert/mask/mask_blend.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index ce12a8686e..ef4b446e65 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -289,7 +289,7 @@ def _erode(self, mask: np.ndarray) -> np.ndarray: kernels = self._get_erosion_kernels(mask) if not any(k.any() for k in kernels): return mask # No kernels could be created from selected input res - eroded = [] + eroded = mask for idx, (kernel, ratio) in enumerate(zip(kernels, self._erodes)): if not kernel.any(): continue @@ -303,10 +303,9 @@ def _erode(self, mask: np.ndarray) -> np.ndarray: anchor[pos] = val func = cv2.erode if ratio > 0 else cv2.dilate - eroded.append(func(mask, kernel, iterations=1, anchor=anchor)) + eroded = func(eroded, kernel, iterations=1, anchor=anchor) - mask = np.min(np.array(eroded), axis=0) if len(eroded) > 1 else eroded[0] - return mask[..., None] + return eroded[..., None] def _get_erosion_kernels(self, mask: np.ndarray) -> List[np.ndarray]: """ Get the erosion kernels for each of the center, left, top right and bottom erosions. From 5559c54b76cdf45c8a48329effab9776d227a0d6 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 16 Jun 2022 02:27:07 +0100 Subject: [PATCH 609/981] bugfix --- plugins/convert/mask/mask_blend.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index ef4b446e65..e00b37a983 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -146,12 +146,12 @@ def run(self, if self._mask_type != "none": - mask = self._erode(mask) if self._do_erode else mask - mask *= self._box + out = self._erode(mask) if self._do_erode else mask + out = np.minimum(out, self._box) logger.trace( # type: ignore "mask shape: %s, raw_mask shape: %s", mask.shape, raw_mask.shape) - return mask, raw_mask + return out, raw_mask def _get_mask(self, detected_face: DetectedFace, From 493030f4b0375a444c1d18e9ab6195906c0e060d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 16 Jun 2022 02:50:07 +0100 Subject: [PATCH 610/981] bugfix --- plugins/convert/mask/mask_blend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index e00b37a983..958782467f 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -102,7 +102,7 @@ def _get_box(self, output_size: int) -> np.ndarray: The box mask """ box = np.zeros((output_size, output_size, 1), dtype="float32") - edge = output_size // 32 + edge = (output_size // 32) + 1 box[edge:-edge, edge:-edge] = 1.0 if self._config["type"] is not None: From 9563283502e0e1438bd2d6485782dea4524b2459 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 16 Jun 2022 10:02:12 +0100 Subject: [PATCH 611/981] typing bugfix --- plugins/convert/mask/mask_blend.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index 958782467f..83477884e8 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """ Plugin to blend the edges of the face between the swap and the original face. """ import logging -from typing import List, Literal, Optional, Tuple +import sys +from typing import List, Optional, Tuple import cv2 import numpy as np @@ -10,6 +11,12 @@ from lib.config import FaceswapConfig from plugins.convert._config import Config +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + + logger = logging.getLogger(__name__) From a586ef6bf3db26752fc1164835e46b6e375576ca Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 16 Jun 2022 01:25:49 +0100 Subject: [PATCH 612/981] Add Apple M1 to setup.py add libblas to requirements --- INSTALL.md | 38 +++-- .../conda-environment-apple-silicon.yml | 24 --- requirements/requirements_apple_silicon.txt | 3 + setup.py | 150 ++++++++++-------- 4 files changed, 117 insertions(+), 98 deletions(-) delete mode 100644 requirements/conda-environment-apple-silicon.yml create mode 100644 requirements/requirements_apple_silicon.txt diff --git a/INSTALL.md b/INSTALL.md index cf4dae407a..16db5fb64d 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -27,6 +27,7 @@ - [XQuartz](#xquartz) - [Conda](#conda) - [Setup](#setup-1) + - [Create and Activate the Environment](#create-and-activate-the-environment) - [faceswap](#faceswap-1) - [Easy install](#easy-install-1) - [General Install Guide](#general-install-guide) @@ -39,6 +40,10 @@ - [About some of the options](#about-some-of-the-options) - [Docker Install Guide](#docker-install-guide) - [Docker General](#docker-general) + - [CUDA with Docker in 20 minutes.](#cuda-with-docker-in-20-minutes) + - [CUDA with Docker on Arch Linux](#cuda-with-docker-on-arch-linux) + - [Install docker](#install-docker) + - [A successful setup log, without docker.](#a-successful-setup-log-without-docker) - [Run the project](#run-the-project) - [Notes](#notes) @@ -165,35 +170,48 @@ It's good to keep faceswap up to date as new features are added and bugs are fix macOS 12.0+ ### XCode Tools -`xcode-select --install` +```sh +xcode-select --install +``` ### XQuartz -Download and install from: https://www.xquartz.org/ +Download and install from: +- https://www.xquartz.org/ ### Conda -Download and install the latest Conda env from: https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-MacOSX-arm64.sh +Download and install the latest Conda env from: +- https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-MacOSX-arm64.sh + +Install Conda: ```sh $ chmod +x ~/Downloads/Miniforge3-MacOSX-arm64.sh $ sh ~/Downloads/Miniforge3-MacOSX-arm64.sh $ source ~/miniforge3/bin/activate ``` ## Setup +### Create and Activate the Environment +```sh +$ conda env create -n faceswap python=3.9 +$ conda activate faceswap +``` ### faceswap -- Get the faceswap repo by typing: `git clone --depth 1 https://github.com/deepfakes/faceswap.git` -- Enter the faceswap folder: `cd faceswap` +- Download the faceswap repo and enter thr faceswap folder: +```sh +$ git clone --depth 1 https://github.com/deepfakes/faceswap.git +$ cd faceswap +``` #### Easy install ```sh -$ conda deactivate -$ conda env create -f ./requirements/conda-environment-apple-silicon.yml -$ conda activate faceswap +$ python setup.py ``` -- Enter the command `python faceswap.py gui` and follow the prompts: -- Choose '4' for Apple Silicon + - If you have issues/errors follow the Manual install steps below. + # General Install Guide + ## Installing dependencies ### Git Git is required for obtaining the code and keeping your codebase up to date. diff --git a/requirements/conda-environment-apple-silicon.yml b/requirements/conda-environment-apple-silicon.yml deleted file mode 100644 index 152be90d20..0000000000 --- a/requirements/conda-environment-apple-silicon.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: faceswap -channels: - - conda-forge - - apple -dependencies: - - python>=3.8,<3.10 - - pip - - tensorflow-deps==2.8.0 - - tk - - libblas - - pip: - - tensorflow-macos - - tensorflow-metal - - tqdm>=4.64 - - psutil>=5.8.0 - - numpy>=1.18.0 - - opencv-python>=4.5.5.0 - - pillow>=8.3.1 - - scikit-learn>=1.0.2 - - fastcluster>=1.2.4 - - matplotlib>=3.5.1 - - imageio>=2.9.0 - - imageio-ffmpeg>=0.4.7 - - ffmpy==0.2.3 diff --git a/requirements/requirements_apple_silicon.txt b/requirements/requirements_apple_silicon.txt new file mode 100644 index 0000000000..9658e42f8d --- /dev/null +++ b/requirements/requirements_apple_silicon.txt @@ -0,0 +1,3 @@ +tensorflow-macos>=2.8.0,<2.9.0 +tensorflow-metal>=2.8.0,<2.9.0 +libblas # Conda only diff --git a/setup.py b/setup.py index e3d2ac6a06..c7d39d8600 100755 --- a/setup.py +++ b/setup.py @@ -11,9 +11,14 @@ import re import sys from subprocess import CalledProcessError, run, PIPE, Popen +from typing import Dict, List, Optional, Tuple, TYPE_CHECKING, Union from pkg_resources import parse_requirements, Requirement +if TYPE_CHECKING: + from logging import Logger + + INSTALL_FAILED = False # Revisions of tensorflow GPU and cuda/cudnn requirements. These relate specifically to the # Tensorflow builds available from pypi @@ -23,28 +28,31 @@ CONDA_MAPPING = { # "opencv-python": ("opencv", "conda-forge"), # Periodic issues with conda-forge opencv "fastcluster": ("fastcluster", "conda-forge"), - "imageio-ffmpeg": ("imageio-ffmpeg", "conda-forge")} + "imageio-ffmpeg": ("imageio-ffmpeg", "conda-forge"), + "tensorflow-deps": ("tensorflow-deps", "apple"), + "libblas": ("libblas", "conda-forge")} class Environment(): """ The current install environment """ - def __init__(self, logger=None, updater=False): + def __init__(self, logger: Optional["Logger"] = None, updater: bool = False) -> None: """ logger will override built in Output() function if passed in updater indicates that this is being run from update_deps.py so certain steps can be skipped/output limited """ - self.conda_required_packages = [("tk", )] - self.output = logger if logger else Output() + self.conda_required_packages: List[Tuple[str, ...]] = [("tk", )] + self.output: Union["Logger", "Output"] = logger if logger else Output() self.updater = updater # Flag that setup is being run by installer so steps can be skipped - self.is_installer = False - self.cuda_version = "" - self.cudnn_version = "" - self.enable_amd = False - self.enable_docker = False - self.enable_cuda = False - self.required_packages = [] - self.missing_packages = [] - self.conda_missing_packages = [] + self.is_installer: bool = False + self.cuda_version: str = "" + self.cudnn_version: str = "" + self.enable_amd: bool = False + self.enable_apple_silicon: bool = False + self.enable_docker: bool = False + self.enable_cuda: bool = False + self.required_packages: List[Tuple[str, Tuple[str, str]]] = [] + self.missing_packages: List[str] = [] + self.conda_missing_packages: List[str] = [] self.process_arguments() self.check_permission() @@ -59,37 +67,37 @@ def __init__(self, logger=None, updater=False): self.installed_packages.update(self.get_installed_conda_packages()) @property - def encoding(self): + def encoding(self) -> str: """ Get system encoding """ return locale.getpreferredencoding() @property - def os_version(self): + def os_version(self) -> Tuple[str, str]: """ Get OS Version """ return platform.system(), platform.release() @property - def py_version(self): + def py_version(self) -> Tuple[str, str]: """ Get Python Version """ return platform.python_version(), platform.architecture()[0] @property - def is_conda(self): + def is_conda(self) -> bool: """ Check whether using Conda """ return ("conda" in sys.version.lower() or os.path.exists(os.path.join(sys.prefix, 'conda-meta'))) @property - def is_admin(self): + def is_admin(self) -> bool: """ Check whether user is admin """ try: retval = os.getuid() == 0 except AttributeError: - retval = ctypes.windll.shell32.IsUserAnAdmin() != 0 + retval = ctypes.windll.shell32.IsUserAnAdmin() != 0 # type: ignore return retval @property - def is_virtualenv(self): + def is_virtualenv(self) -> bool: """ Check whether this is a virtual environment """ if not self.is_conda: retval = (hasattr(sys, "real_prefix") or @@ -99,7 +107,7 @@ def is_virtualenv(self): retval = (os.path.basename(prefix) == "envs") return retval - def process_arguments(self): + def process_arguments(self) -> None: """ Process any cli arguments and dummy in cli arguments if calling from updater. """ args = [arg for arg in sys.argv] # pylint:disable=unnecessary-comprehension if self.updater: @@ -113,13 +121,17 @@ def process_arguments(self): self.enable_cuda = True if arg == "--amd": self.enable_amd = True + if arg == "--apple-silicon": + self.enable_apple_silicon = True - def get_required_packages(self): + def get_required_packages(self) -> None: """ Load requirements list """ if self.enable_amd: suffix = "amd.txt" elif self.enable_cuda: suffix = "nvidia.txt" + elif self.enable_apple_silicon: + suffix = "apple_silicon.txt" else: suffix = "cpu.txt" req_files = ["_requirements_base.txt", f"requirements_{suffix}"] @@ -136,7 +148,7 @@ def get_required_packages(self): for pkg in parse_requirements(requirements) if pkg.marker is None or pkg.marker.evaluate()] - def check_permission(self): + def check_permission(self) -> None: """ Check for Admin permissions """ if self.updater: return @@ -145,7 +157,7 @@ def check_permission(self): else: self.output.info("Running without root/admin privileges") - def check_system(self): + def check_system(self) -> None: """ Check the system """ if not self.updater: self.output.info("The tool provides tips for installation\n" @@ -154,8 +166,14 @@ def check_system(self): if not self.updater and not self.os_version[0] in ["Windows", "Linux", "Darwin"]: self.output.error(f"Your system {self.os_version[0]} is not supported!") sys.exit(1) + if (not self.updater and + self.os_version[0].lower() == "darwin" and + platform.machine() == "arm64" and not self.is_conda): + self.output.error("Setting up Faceswap for Apple Silicon outside of a Conda " + "environment is unsupported") + sys.exit(1) - def check_python(self): + def check_python(self) -> None: """ Check python and virtual environment status """ self.output.info(f"Installed Python: {self.py_version[0]} {self.py_version[1]}") @@ -171,7 +189,7 @@ def check_python(self): "Python higher than 3.8") sys.exit(1) - def output_runtime_info(self): + def output_runtime_info(self) -> None: """ Output run time info """ if self.is_conda: self.output.info("Running in Conda") @@ -179,7 +197,7 @@ def output_runtime_info(self): self.output.info("Running in a Virtual Environment") self.output.info(f"Encoding: {self.encoding}") - def check_pip(self): + def check_pip(self) -> None: """ Check installed pip version """ if self.updater: return @@ -189,7 +207,7 @@ def check_pip(self): self.output.error("Import pip failed. Please Install python3-pip and try again") sys.exit(1) - def upgrade_pip(self): + def upgrade_pip(self) -> None: """ Upgrade pip to latest version """ if not self.is_conda: # Don't do this with Conda, as we must use Conda version of pip @@ -204,7 +222,7 @@ def upgrade_pip(self): pip_version = pip.__version__ self.output.info(f"Installed pip: {pip_version}") - def get_installed_packages(self): + def get_installed_packages(self) -> Dict[str, str]: """ Get currently installed packages """ installed_packages = {} with Popen(f"\"{sys.executable}\" -m pip freeze --local", shell=True, stdout=PIPE) as chk: @@ -217,10 +235,10 @@ def get_installed_packages(self): installed_packages[item[0]] = item[1] return installed_packages - def get_installed_conda_packages(self): + def get_installed_conda_packages(self) -> Dict[str, str]: """ Get currently installed conda packages """ if not self.is_conda: - return None + return {} chk = os.popen("conda list").read() installed = [re.sub(" +", " ", line.strip()) for line in chk.splitlines() if not line.startswith("#")] @@ -230,7 +248,7 @@ def get_installed_conda_packages(self): retval[item[0]] = item[1] return retval - def update_tf_dep(self): + def update_tf_dep(self) -> None: """ Update Tensorflow Dependency """ if self.is_conda or not self.enable_cuda: # CPU/AMD doesn't need Cuda and Conda handles Cuda and cuDNN so nothing to do here @@ -249,9 +267,12 @@ def update_tf_dep(self): # Remove the version of tensorflow in requirements file and add the correct version # that corresponds to the installed Cuda/cuDNN versions self.required_packages = [pkg for pkg in self.required_packages - if not pkg.startswith("tensorflow-gpu")] + if not pkg[0].startswith("tensorflow-gpu")] tf_ver = f"tensorflow-gpu{tf_ver}" - self.required_packages.append(tf_ver) + + tf_ver = f"tensorflow-gpu{tf_ver}" + self.required_packages.append(("tensorflow-gpu", + next(parse_requirements(tf_ver)).specs)) return self.output.warning( @@ -277,14 +298,16 @@ def update_tf_dep(self): elif os.path.splitext(custom_tf)[1] != ".whl": self.output.error(f"{custom_tf} is not a valid pip wheel") elif custom_tf: - self.required_packages.append(custom_tf) + self.required_packages.append((custom_tf, (custom_tf, ""))) - def set_config(self): + def set_config(self) -> None: """ Set the backend in the faceswap config file """ if self.enable_amd: backend = "amd" elif self.enable_cuda: backend = "nvidia" + elif self.enable_apple_silicon: + backend = "apple_silicon" else: backend = "cpu" config = {"backend": backend} @@ -294,9 +317,9 @@ def set_config(self): json.dump(config, cnf) self.output.info(f"Faceswap config written to: {config_file}") - def set_ld_library_path(self): + def set_ld_library_path(self) -> None: """ Update the LD_LIBRARY_PATH environment variable when activating a conda environment - and revert it when deactivating. + and revert it when deactivating. Linux/conda only Notes ----- @@ -305,10 +328,7 @@ def set_ld_library_path(self): We update the environment variable for all instances using Conda as it shouldn't hurt anything and may help avoid conflicts with globally installed Cuda """ - if not self.is_conda or not self.enable_cuda: - return - - if self.os_version[0] == "Windows": + if not self.is_conda or not self.enable_cuda or self.os_version[0].lower() != "linux": return conda_prefix = os.environ["CONDA_PREFIX"] @@ -345,15 +365,15 @@ def set_ld_library_path(self): class Output(): """ Format and display output """ - def __init__(self): - self.red = "\033[31m" - self.green = "\033[32m" - self.yellow = "\033[33m" - self.default_color = "\033[0m" - self.term_support_color = platform.system() in ("Linux", "Darwin") + def __init__(self) -> None: + self.red: str = "\033[31m" + self.green: str = "\033[32m" + self.yellow: str = "\033[33m" + self.default_color: str = "\033[0m" + self.term_support_color: bool = platform.system().lower() in ("linux", "darwin") @staticmethod - def __indent_text_block(text): + def __indent_text_block(text: str) -> str: """ Indent a text block """ lines = text.splitlines() if len(lines) > 1: @@ -364,21 +384,21 @@ def __indent_text_block(text): return out return text - def info(self, text): + def info(self, text: str) -> None: """ Format INFO Text """ trm = "INFO " if self.term_support_color: trm = f"{self.green}INFO {self.default_color} " print(trm + self.__indent_text_block(text)) - def warning(self, text): + def warning(self, text: str) -> None: """ Format WARNING Text """ trm = "WARNING " if self.term_support_color: trm = f"{self.yellow}WARNING{self.default_color} " print(trm + self.__indent_text_block(text)) - def error(self, text): + def error(self, text: str) -> None: """ Format ERROR Text """ global INSTALL_FAILED # pylint:disable=global-statement trm = "ERROR " @@ -390,14 +410,16 @@ def error(self, text): class Checks(): """ Pre-installation checks """ - def __init__(self, environment): - self.env = environment - self.output = Output() - self.tips = Tips() - + def __init__(self, environment: Environment) -> None: + self.env: Environment = environment + self.output: Output = Output() + self.tips: Tips = Tips() # Checks not required for installer if self.env.is_installer: return + # Checks not required for Apple Silicon + if self.env.enable_apple_silicon: + return # Ask AMD/Docker/Cuda self.amd_ask_enable() @@ -443,7 +465,7 @@ def __init__(self, environment): if self.env.os_version[0] == "Windows": self.tips.pip() - def amd_ask_enable(self): + def amd_ask_enable(self) -> None: """ Enable or disable Plaidml for AMD""" self.output.info("AMD Support: AMD GPU support is currently limited.\r\n" "Nvidia Users MUST answer 'no' to this option.") @@ -455,7 +477,7 @@ def amd_ask_enable(self): self.output.info("AMD Support Disabled") self.env.enable_amd = False - def docker_ask_enable(self): + def docker_ask_enable(self) -> None: """ Enable or disable Docker """ i = input("Enable Docker? [y/N] ") if i in ("Y", "y"): @@ -465,7 +487,7 @@ def docker_ask_enable(self): self.output.info("Docker Disabled") self.env.enable_docker = False - def docker_confirm(self): + def docker_confirm(self) -> None: """ Warn if nvidia-docker on non-Linux system """ self.output.warning("Nvidia-Docker is only supported on Linux.\r\n" "Only CPU is supported in Docker for your system") @@ -474,14 +496,14 @@ def docker_confirm(self): self.output.warning("CUDA Disabled") self.env.enable_cuda = False - def docker_tips(self): + def docker_tips(self) -> None: """ Provide tips for Docker use """ if not self.env.enable_cuda: self.tips.docker_no_cuda() else: self.tips.docker_cuda() - def cuda_ask_enable(self): + def cuda_ask_enable(self) -> None: """ Enable or disable CUDA """ i = input("Enable CUDA? [Y/n] ") if i in ("", "Y", "y"): @@ -624,7 +646,7 @@ def _get_checkfiles_windows(self): class Install(): """ Install the requirements """ - def __init__(self, environment): + def __init__(self, environment: Environment): self._operators = {"==": operator.eq, ">=": operator.ge, "<=": operator.le, @@ -707,7 +729,7 @@ def install_python_packages(self): pkg = pkg[0] if version: pkg = f"{pkg}{','.join(''.join(spec) for spec in version)}" - if self.env.is_conda and not pkg.startswith("git"): + if self.env.is_conda and not self.env.enable_apple_silicon: if pkg.startswith("tensorflow-gpu"): # From TF 2.4 onwards, Anaconda Tensorflow becomes a mess. The version of 2.5 # installed by Anaconda is compiled against an incorrect numpy version which From 0f677900718717648015839d1859a151ddb39525 Mon Sep 17 00:00:00 2001 From: geewiz94 <94993977+geewiz94@users.noreply.github.com> Date: Thu, 16 Jun 2022 12:16:27 +0200 Subject: [PATCH 613/981] Some Apple Silicion setup fixes (#1238) * Move pynvx to Nvidia requirements * Add missing tensorflow-deps and fix tensorflow-metal version * Fix setup not using Conda * Update INSTALL.md --- INSTALL.md | 2 +- requirements/_requirements_base.txt | 1 - requirements/requirements_apple_silicon.txt | 3 ++- requirements/requirements_nvidia.txt | 1 + setup.py | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 16db5fb64d..19474fde6d 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -191,7 +191,7 @@ $ source ~/miniforge3/bin/activate ## Setup ### Create and Activate the Environment ```sh -$ conda env create -n faceswap python=3.9 +$ conda create --name faceswap python=3.9 $ conda activate faceswap ``` diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index 5f8d7c1640..f3f9ff28f6 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -15,4 +15,3 @@ ffmpy==0.2.3 nvidia-ml-py<11.515 typing-extensions pywin32>=228 ; sys_platform == "win32" -pynvx==1.0.0 ; sys_platform == "darwin" diff --git a/requirements/requirements_apple_silicon.txt b/requirements/requirements_apple_silicon.txt index 9658e42f8d..97f7616465 100644 --- a/requirements/requirements_apple_silicon.txt +++ b/requirements/requirements_apple_silicon.txt @@ -1,3 +1,4 @@ tensorflow-macos>=2.8.0,<2.9.0 -tensorflow-metal>=2.8.0,<2.9.0 +tensorflow-deps>=2.8.0,<2.9.0 +tensorflow-metal>=0.4.0,<0.5.0 libblas # Conda only diff --git a/requirements/requirements_nvidia.txt b/requirements/requirements_nvidia.txt index a608dc2fef..fa0364f6d9 100644 --- a/requirements/requirements_nvidia.txt +++ b/requirements/requirements_nvidia.txt @@ -1,2 +1,3 @@ -r _requirements_base.txt tensorflow-gpu>=2.4.0,<2.9.0 +pynvx==1.0.0 ; sys_platform == "darwin" diff --git a/setup.py b/setup.py index c7d39d8600..eafcd0dbd8 100755 --- a/setup.py +++ b/setup.py @@ -729,7 +729,7 @@ def install_python_packages(self): pkg = pkg[0] if version: pkg = f"{pkg}{','.join(''.join(spec) for spec in version)}" - if self.env.is_conda and not self.env.enable_apple_silicon: + if self.env.is_conda: if pkg.startswith("tensorflow-gpu"): # From TF 2.4 onwards, Anaconda Tensorflow becomes a mess. The version of 2.5 # installed by Anaconda is compiled against an incorrect numpy version which From 4f49de52e7b9156bb86cdb9916c042ea7b17c286 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 16 Jun 2022 11:18:55 +0100 Subject: [PATCH 614/981] pin protobuf Setup: automatically detect for apple-silicon --- requirements/requirements_apple_silicon.txt | 1 + setup.py | 42 +++++++++++---------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/requirements/requirements_apple_silicon.txt b/requirements/requirements_apple_silicon.txt index 97f7616465..216b9ce522 100644 --- a/requirements/requirements_apple_silicon.txt +++ b/requirements/requirements_apple_silicon.txt @@ -1,3 +1,4 @@ +protobuf>= 3.19.0,<3.20.0 # TF has started pulling in incompatible protobuf tensorflow-macos>=2.8.0,<2.9.0 tensorflow-deps>=2.8.0,<2.9.0 tensorflow-metal>=0.4.0,<0.5.0 diff --git a/setup.py b/setup.py index eafcd0dbd8..1dc2cd5b83 100755 --- a/setup.py +++ b/setup.py @@ -166,12 +166,13 @@ def check_system(self) -> None: if not self.updater and not self.os_version[0] in ["Windows", "Linux", "Darwin"]: self.output.error(f"Your system {self.os_version[0]} is not supported!") sys.exit(1) - if (not self.updater and - self.os_version[0].lower() == "darwin" and - platform.machine() == "arm64" and not self.is_conda): - self.output.error("Setting up Faceswap for Apple Silicon outside of a Conda " - "environment is unsupported") - sys.exit(1) + if self.os_version[0].lower() == "darwin" and platform.machine() == "arm64": + self.enable_apple_silicon = True + + if not self.updater and not self.is_conda: + self.output.error("Setting up Faceswap for Apple Silicon outside of a Conda " + "environment is unsupported") + sys.exit(1) def check_python(self) -> None: """ Check python and virtual environment status """ @@ -517,20 +518,22 @@ def cuda_ask_enable(self) -> None: class CudaCheck(): # pylint:disable=too-few-public-methods """ Find the location of system installed Cuda and cuDNN on Windows and Linux. """ - def __init__(self): - self.cuda_path = None - self.cuda_version = None - self.cudnn_version = None + def __init__(self) -> None: + self.cuda_path: Optional[str] = None + self.cuda_version: Optional[str] = None + self.cudnn_version: Optional[str] = None - self._os = platform.system().lower() - self._cuda_keys = [key for key in os.environ if key.lower().startswith("cuda_path_v")] - self._cudnn_header_files = ["cudnn_version.h", "cudnn.h"] + self._os: str = platform.system().lower() + self._cuda_keys: List[str] = [key + for key in os.environ + if key.lower().startswith("cuda_path_v")] + self._cudnn_header_files: List[str] = ["cudnn_version.h", "cudnn.h"] if self._os in ("windows", "linux"): self._cuda_check() self._cudnn_check() - def _cuda_check(self): + def _cuda_check(self) -> None: """ Obtain the location and version of Cuda and populate :attr:`cuda_version` and :attr:`cuda_path` @@ -542,7 +545,8 @@ def _cuda_check(self): if not stderr: version = re.search(r".*release (?P\d+\.\d+)", stdout.decode(locale.getpreferredencoding())) - self.cuda_version = version.groupdict().get("cuda", None) + if version is not None: + self.cuda_version = version.groupdict().get("cuda", None) locate = "where" if self._os == "windows" else "which" path = os.popen(f"{locate} nvcc").read() if path: @@ -557,7 +561,7 @@ def _cuda_check(self): # Failed to load nvcc, manual check getattr(self, f"_cuda_check_{self._os}")() - def _cuda_check_linux(self): + def _cuda_check_linux(self) -> None: """ For Linux check the dynamic link loader for libcudart. If not found with ldconfig then attempt to find it in LD_LIBRARY_PATH. """ chk = os.popen("ldconfig -p | grep -P \"libcudart.so.\\d+.\\d+\" | head -n 1").read() @@ -574,7 +578,7 @@ def _cuda_check_linux(self): self.cuda_version = cudavers[:cudavers.find(" ")] self.cuda_path = chk[chk.find("=>") + 3:chk.find("targets") - 1] - def _cuda_check_windows(self): + def _cuda_check_windows(self) -> None: """ Check Windows CUDA Version and path from Environment Variables""" if not self._cuda_keys: # Cuda environment variable not found return @@ -605,7 +609,7 @@ def _cudnn_check(self): return self.cudnn_version = ".".join([str(major), str(minor), str(patchlevel)]) - def _get_checkfiles_linux(self): + def _get_checkfiles_linux(self) -> List[str]: """ Return the the files to check for cuDNN locations for Linux by querying the dynamic link loader. @@ -627,7 +631,7 @@ def _get_checkfiles_linux(self): cudnn_checkfiles = [os.path.join(cudnn_path, header) for header in header_files] return cudnn_checkfiles - def _get_checkfiles_windows(self): + def _get_checkfiles_windows(self) -> List[str]: """ Return the check-file locations for Windows. Just looks inside the include folder of the discovered :attr:`cuda_path` From cb7cb14e254c21be7087531ef9ddec2b9fa2b9f2 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 16 Jun 2022 02:19:38 +0100 Subject: [PATCH 615/981] bugfix: Mask blend. Apply masks in sequence --- plugins/convert/mask/mask_blend.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index ce12a8686e..ef4b446e65 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -289,7 +289,7 @@ def _erode(self, mask: np.ndarray) -> np.ndarray: kernels = self._get_erosion_kernels(mask) if not any(k.any() for k in kernels): return mask # No kernels could be created from selected input res - eroded = [] + eroded = mask for idx, (kernel, ratio) in enumerate(zip(kernels, self._erodes)): if not kernel.any(): continue @@ -303,10 +303,9 @@ def _erode(self, mask: np.ndarray) -> np.ndarray: anchor[pos] = val func = cv2.erode if ratio > 0 else cv2.dilate - eroded.append(func(mask, kernel, iterations=1, anchor=anchor)) + eroded = func(eroded, kernel, iterations=1, anchor=anchor) - mask = np.min(np.array(eroded), axis=0) if len(eroded) > 1 else eroded[0] - return mask[..., None] + return eroded[..., None] def _get_erosion_kernels(self, mask: np.ndarray) -> List[np.ndarray]: """ Get the erosion kernels for each of the center, left, top right and bottom erosions. From b039cbae6975e987ed6a11019bf875f8478a7d14 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 16 Jun 2022 02:27:07 +0100 Subject: [PATCH 616/981] bugfix --- plugins/convert/mask/mask_blend.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index ef4b446e65..e00b37a983 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -146,12 +146,12 @@ def run(self, if self._mask_type != "none": - mask = self._erode(mask) if self._do_erode else mask - mask *= self._box + out = self._erode(mask) if self._do_erode else mask + out = np.minimum(out, self._box) logger.trace( # type: ignore "mask shape: %s, raw_mask shape: %s", mask.shape, raw_mask.shape) - return mask, raw_mask + return out, raw_mask def _get_mask(self, detected_face: DetectedFace, From 9818f6ee700e3497e2e26e4a6de8272c8b602a9f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 16 Jun 2022 02:50:07 +0100 Subject: [PATCH 617/981] bugfix --- plugins/convert/mask/mask_blend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index e00b37a983..958782467f 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -102,7 +102,7 @@ def _get_box(self, output_size: int) -> np.ndarray: The box mask """ box = np.zeros((output_size, output_size, 1), dtype="float32") - edge = output_size // 32 + edge = (output_size // 32) + 1 box[edge:-edge, edge:-edge] = 1.0 if self._config["type"] is not None: From 5e489f3b8470a37878bab276761e2d5b4d2a3825 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 16 Jun 2022 10:02:12 +0100 Subject: [PATCH 618/981] typing bugfix --- plugins/convert/mask/mask_blend.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index 958782467f..83477884e8 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """ Plugin to blend the edges of the face between the swap and the original face. """ import logging -from typing import List, Literal, Optional, Tuple +import sys +from typing import List, Optional, Tuple import cv2 import numpy as np @@ -10,6 +11,12 @@ from lib.config import FaceswapConfig from plugins.convert._config import Config +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + + logger = logging.getLogger(__name__) From 7e4bcbe36a73b1f4d0a59d886f942a8d54048414 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 16 Jun 2022 11:43:30 +0100 Subject: [PATCH 619/981] typofix --- INSTALL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/INSTALL.md b/INSTALL.md index 19474fde6d..2adb73ec15 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -196,7 +196,7 @@ $ conda activate faceswap ``` ### faceswap -- Download the faceswap repo and enter thr faceswap folder: +- Download the faceswap repo and enter the faceswap folder: ```sh $ git clone --depth 1 https://github.com/deepfakes/faceswap.git $ cd faceswap From ff6b0209dd5ad57b81b0aca570df7f39a7119bfb Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 17 Jun 2022 03:24:16 +0100 Subject: [PATCH 620/981] Refactoring and TravisCI to Github Actions (#1239) * refactor training * travis to actions --- .github/workflows/pytest.yml | 37 + .gitignore | 5 +- .travis.yml | 102 --- plugins/train/model/_base/__init__.py | 4 + plugins/train/model/_base/io.py | 443 ++++++++++++ plugins/train/model/_base/model.py | 935 ++++++++++++++++++++++++++ plugins/train/model/_base/settings.py | 509 ++++++++++++++ plugins/train/model/dfaker.py | 2 +- plugins/train/model/dfl_sae.py | 6 +- plugins/train/model/dlight.py | 5 +- plugins/train/model/phaze_a.py | 8 +- plugins/train/model/realface.py | 7 +- plugins/train/model/unbalanced.py | 2 +- plugins/train/model/villain.py | 2 +- tests/lib/model/losses_test.py | 4 +- tests/lib/model/optimizers_test.py | 2 +- {_travis => tests}/simple_tests.py | 0 17 files changed, 1956 insertions(+), 117 deletions(-) create mode 100644 .github/workflows/pytest.yml delete mode 100644 .travis.yml create mode 100644 plugins/train/model/_base/__init__.py create mode 100644 plugins/train/model/_base/io.py create mode 100644 plugins/train/model/_base/model.py create mode 100644 plugins/train/model/_base/settings.py rename {_travis => tests}/simple_tests.py (100%) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000000..7f2e289027 --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,37 @@ +name: Python package + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.7", "3.8", "3.9"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pylint mypy pytest wheel + pip install -r ./requirements/requirements_cpu.txt + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Simple Tests + run: | + FACESWAP_BACKEND="cpu" KERAS_BACKEND="tensorflow" py.test -v tests/; + - name: End to End Tests + run: | + FACESWAP_BACKEND="cpu" KERAS_BACKEND="tensorflow" python tests/simple_tests.py; + \ No newline at end of file diff --git a/.gitignore b/.gitignore index 580d05d5fc..fbf21eba22 100644 --- a/.gitignore +++ b/.gitignore @@ -19,8 +19,9 @@ !/update_deps.py # Support files -!_travis/ -!_travis/*.py +!/.github/ +!/.github/workflows/ +!/.github/workflows/*.yml !.install/ !.install/** !config/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9075e4af8e..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,102 +0,0 @@ -# Adapted from https://github.com/kangwonlee/travis-yml-conda-posix-nt/blob/master/.travis.yml - -language: shell - -env: - global: - - CONDA_PYTHON=3.8 - - CONDA_BLD_PATH=${HOME}/conda-bld - -os: - - linux - # - windows - # - osx - - -cache: - # More time is needed for caching due to the sheer size of the conda env. - timeout: 1000 - directories: - - ${HOME}/cache - -before_cache: - # adapted from https://github.com/theochem/cgrid/blob/master/.travis.yml - - echo "Cleaning stuff in miniconda path. (${MINICONDA_PATH})" - - ls -la ${MINICONDA_PATH} - - rm -rf ${MINICONDA_PATH}/conda-bld - - rm -rf ${MINICONDA_PATH}/locks - - rm -rf ${MINICONDA_PATH}/pkgs - - rm -rf ${MINICONDA_PATH}/var - - rm -rf ${MINICONDA_PATH}/envs/*/conda-bld - - rm -rf ${MINICONDA_PATH}/envs/*/locks - - rm -rf ${MINICONDA_PATH}/envs/*/pkgs - - rm -rf ${MINICONDA_PATH}/envs/*/var - - ls -la ${MINICONDA_PATH} - - echo "Cleaning test results" - - rm -rf ${HOME}/cache/tests/*/faces - - rm -rf ${HOME}/cache/tests/*/conv - - rm -rf ${HOME}/cache/tests/*/*.json - - rm -rf ${HOME}/cache/tests/vid/faces_sorted - - rm -rf ${HOME}/cache/tests/vid/model - -before_install: - # set conda path info - - | - if [[ "$TRAVIS_OS_NAME" != "windows" ]]; then - MINICONDA_PATH=${HOME}/cache/miniconda; - MINICONDA_SUB_PATH=$MINICONDA_PATH/bin; - elif [[ "$TRAVIS_OS_NAME" == "windows" ]]; then - MINICONDA_PATH=${HOME}/cache/miniconda3/; - MINICONDA_PATH_WIN=`cygpath --windows $MINICONDA_PATH`; - MINICONDA_SUB_PATH=$MINICONDA_PATH/Scripts; - fi; - # obtain miniconda installer - - | - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then - wget https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh; - # Not used at the moment, but in case we want to also run test on osx, we can. - elif [[ "$TRAVIS_OS_NAME" == "osx" ]]; then - wget https://repo.continuum.io/miniconda/Miniconda3-latest-MacOSX-x86_64.sh -O miniconda.sh; - fi; - -install: - # install miniconda - # pip and conda will also need OpenSSL for Windows - - | - if test -e "$MINICONDA_PATH"; then - echo "Conda already installed"; - else - echo "Installing conda"; - if [[ "$TRAVIS_OS_NAME" != "windows" ]]; then - bash miniconda.sh -b -p $MINICONDA_PATH; - elif [[ "$TRAVIS_OS_NAME" == "windows" ]]; then - choco install openssl.light; - choco install miniconda3 --params="'/AddToPath:1 /D:$MINICONDA_PATH_WIN'"; - fi; - fi; - - export PATH="$MINICONDA_PATH:$MINICONDA_SUB_PATH:$PATH"; - # for conda version 4.4 or later - - source $MINICONDA_PATH/etc/profile.d/conda.sh; - - hash -r; - - conda config --set always_yes yes --set changeps1 no; - - conda update -q conda; - # Useful for debugging any issues with conda - - conda info -a - - echo "Python $CONDA_PYTHON running on $TRAVIS_OS_NAME"; - # Only create the environment if we don't have it already - - conda env list | grep faceswap || conda create -q --name faceswap python=$CONDA_PYTHON; - - conda activate faceswap; - - conda --version ; python --version ; pip --version; - # We set up for plaidML as we can then use both the plaidML and Tensorflow backends for testing - - python setup.py --installer --amd; - - conda install pytest; - # For debugging purposes - - df -h - -script: - - rm -f ~/.plaidml; - - echo "{\"PLAIDML_DEVICE_IDS\":[\"llvm_cpu.0\"],\"PLAIDML_EXPERIMENTAL\":true}" > ~/.plaidml; - - FACESWAP_BACKEND="amd" KERAS_BACKEND="plaidml.keras.backend" PYTHONPATH=$PWD:$PYTHONPATH py.test -v tests/; - - rm -f ~/.plaidml; - - FACESWAP_BACKEND="cpu" KERAS_BACKEND="tensorflow" PYTHONPATH=$PWD:$PYTHONPATH py.test -v tests/; - - FACESWAP_BACKEND="cpu" KERAS_BACKEND="tensorflow" python _travis/simple_tests.py; diff --git a/plugins/train/model/_base/__init__.py b/plugins/train/model/_base/__init__.py new file mode 100644 index 0000000000..84c77ef96c --- /dev/null +++ b/plugins/train/model/_base/__init__.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 +""" Base class for Models plugins ALL Models should at least inherit from this class. """ + +from .model import get_all_sub_models, KerasModel, ModelBase # noqa diff --git a/plugins/train/model/_base/io.py b/plugins/train/model/_base/io.py new file mode 100644 index 0000000000..20d63ad66d --- /dev/null +++ b/plugins/train/model/_base/io.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +""" +IO handling for the model base plugin. + +The objects in this module should not be called directly, but are called from +:class:`~plugins.train.model._base.ModelBase` + +This module handles: + - The loading, saving and backing up of keras models to and from disk. + - The loading and freezing of weights for model plugins. +""" +import logging +import os +import sys + +from typing import List, Optional, TYPE_CHECKING + +from lib.model.backup_restore import Backup +from lib.utils import FaceswapError, get_backend + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + +if get_backend() == "amd": + import keras + from keras.models import load_model, Model as KModel +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow import keras # pylint:disable=import-error,no-name-in-module + from tensorflow.keras.models import load_model, Model as KModel # noqa pylint:disable=import-error,no-name-in-module + +if TYPE_CHECKING: + from .._base import ModelBase + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +def get_all_sub_models( + model: keras.models.Model, + models: Optional[List[keras.models.Model]] = None) -> List[keras.models.Model]: + """ For a given model, return all sub-models that occur (recursively) as children. + + Parameters + ---------- + model: :class:`keras.models.Model` + A Keras model to scan for sub models + models: `None` + Do not provide this parameter. It is used for recursion + + Returns + ------- + list + A list of all :class:`keras.models.Model`s found within the given model. The provided + model will always be returned in the first position + """ + if models is None: + models = [model] + else: + models.append(model) + for layer in model.layers: + if isinstance(layer, KModel): + get_all_sub_models(layer, models=models) + return models + + +class IO(): + """ Model saving and loading functions. + + Handles the loading and saving of the plugin model from disk as well as the model backup and + snapshot functions. + + Parameters + ---------- + plugin: :class:`Model` + The parent plugin class that owns the IO functions. + model_dir: str + The full path to the model save location + is_predict: bool + ``True`` if the model is being loaded for inference. ``False`` if the model is being loaded + for training. + """ + def __init__(self, plugin: "ModelBase", model_dir: str, is_predict: bool) -> None: + self._plugin = plugin + self._is_predict = is_predict + self._model_dir = model_dir + self._history: List[List[float]] = [[], []] # Loss histories per save iteration + self._backup = Backup(self._model_dir, self._plugin.name) + + @property + def _filename(self) -> str: + """str: The filename for this model.""" + return os.path.join(self._model_dir, f"{self._plugin.name}.h5") + + @property + def model_exists(self) -> bool: + """ bool: ``True`` if a model of the type being loaded exists within the model folder + location otherwise ``False``. + """ + return os.path.isfile(self._filename) + + @property + def history(self) -> List[List[float]]: + """ list: list of loss histories per side for the current save iteration. """ + return self._history + + @property + def multiple_models_in_folder(self) -> Optional[List[str]]: + """ :list: or ``None`` If there are multiple model types in the requested folder, or model + types that don't correspond to the requested plugin type, then returns the list of plugin + names that exist in the folder, otherwise returns ``None`` """ + plugins = [fname.replace(".h5", "") + for fname in os.listdir(self._model_dir) + if fname.endswith(".h5")] + test_names = plugins + [self._plugin.name] + test = False if not test_names else os.path.commonprefix(test_names) == "" + retval = None if not test else plugins + logger.debug("plugin name: %s, plugins: %s, test result: %s, retval: %s", + self._plugin.name, plugins, test, retval) + return retval + + def _load(self) -> keras.models.Model: + """ Loads the model from disk + + If the predict function is to be called and the model cannot be found in the model folder + then an error is logged and the process exits. + + When loading the model, the plugin model folder is scanned for custom layers which are + added to Keras' custom objects. + + Returns + ------- + :class:`keras.models.Model` + The saved model loaded from disk + """ + logger.debug("Loading model: %s", self._filename) + if self._is_predict and not self.model_exists: + logger.error("Model could not be found in folder '%s'. Exiting", self._model_dir) + sys.exit(1) + + try: + model = load_model(self._filename, compile=False) + except RuntimeError as err: + if "unable to get link info" in str(err).lower(): + msg = (f"Unable to load the model from '{self._filename}'. This may be a " + "temporary error but most likely means that your model has corrupted.\n" + "You can try to load the model again but if the problem persists you " + "should use the Restore Tool to restore your model from backup.\n" + f"Original error: {str(err)}") + raise FaceswapError(msg) from err + raise err + except KeyError as err: + if "unable to open object" in str(err).lower(): + msg = (f"Unable to load the model from '{self._filename}'. This may be a " + "temporary error but most likely means that your model has corrupted.\n" + "You can try to load the model again but if the problem persists you " + "should use the Restore Tool to restore your model from backup.\n" + f"Original error: {str(err)}") + raise FaceswapError(msg) from err + raise err + + logger.info("Loaded model from disk: '%s'", self._filename) + return model + + def save(self) -> None: + """ Backup and save the model and state file. + + Notes + ----- + The backup function actually backups the model from the previous save iteration rather than + the current save iteration. This is not a bug, but protection against long save times, as + models can get quite large, so renaming the current model file rather than copying it can + save substantial amount of time. + """ + logger.debug("Backing up and saving models") + print("") # Insert a new line to avoid spamming the same row as loss output + save_averages = self._get_save_averages() + if save_averages and self._should_backup(save_averages): + self._backup.backup_model(self._filename) + # pylint:disable=protected-access + self._backup.backup_model(self._plugin.state._filename) + + self._plugin.model.save(self._filename, include_optimizer=False) + self._plugin.state.save() + + msg = "[Saved models]" + if save_averages: + lossmsg = [f"face_{side}: {avg:.5f}" + for side, avg in zip(("a", "b"), save_averages)] + msg += f" - Average loss since last save: {', '.join(lossmsg)}" + logger.info(msg) + + def _get_save_averages(self) -> List[float]: + """ Return the average loss since the last save iteration and reset historical loss """ + logger.debug("Getting save averages") + if not all(loss for loss in self._history): + logger.debug("No loss in history") + retval = [] + else: + retval = [sum(loss) / len(loss) for loss in self._history] + self._history = [[], []] # Reset historical loss + logger.debug("Average losses since last save: %s", retval) + return retval + + def _should_backup(self, save_averages: List[float]) -> bool: + """ Check whether the loss averages for this save iteration is the lowest that has been + seen. + + This protects against model corruption by only backing up the model if both sides have + seen a total fall in loss. + + Notes + ----- + This is by no means a perfect system. If the model corrupts at an iteration close + to a save iteration, then the averages may still be pushed lower than a previous + save average, resulting in backing up a corrupted model. + + Parameters + ---------- + save_averages: list + The average loss for each side for this save iteration + """ + backup = True + for side, loss in zip(("a", "b"), save_averages): + if not self._plugin.state.lowest_avg_loss.get(side, None): + logger.debug("Set initial save iteration loss average for '%s': %s", side, loss) + self._plugin.state.lowest_avg_loss[side] = loss + continue + backup = loss < self._plugin.state.lowest_avg_loss[side] if backup else backup + + if backup: # Update lowest loss values to the state file + # pylint:disable=unnecessary-comprehension + old_avgs = {key: val for key, val in self._plugin.state.lowest_avg_loss.items()} + self._plugin.state.lowest_avg_loss["a"] = save_averages[0] + self._plugin.state.lowest_avg_loss["b"] = save_averages[1] + logger.debug("Updated lowest historical save iteration averages from: %s to: %s", + old_avgs, self._plugin.state.lowest_avg_loss) + + logger.debug("Should backup: %s", backup) + return backup + + def snapshot(self) -> None: + """ Perform a model snapshot. + + Notes + ----- + Snapshot function is called 1 iteration after the model was saved, so that it is built from + the latest save, hence iteration being reduced by 1. + """ + logger.debug("Performing snapshot. Iterations: %s", self._plugin.iterations) + self._backup.snapshot_models(self._plugin.iterations - 1) + logger.debug("Performed snapshot") + + +class Weights(): + """ Handling of freezing and loading model weights + + Parameters + ---------- + plugin: :class:`Model` + The parent plugin class that owns the IO functions. + """ + def __init__(self, plugin: "ModelBase") -> None: + logger.debug("Initializing %s: (plugin: %s)", self.__class__.__name__, plugin) + self._model = plugin.model + self._name = plugin.model_name + self._do_freeze = plugin._args.freeze_weights + self._weights_file = self._check_weights_file(plugin._args.load_weights) + + freeze_layers = plugin.config.get("freeze_layers") # Standardized config for freezing + load_layers = plugin.config.get("load_layers") # Standardized config for loading + self._freeze_layers = freeze_layers if freeze_layers else ["encoder"] # No plugin config + self._load_layers = load_layers if load_layers else ["encoder"] # No plugin config + logger.debug("Initialized %s", self.__class__.__name__) + + @classmethod + def _check_weights_file(cls, weights_file: str) -> Optional[str]: + """ Validate that we have a valid path to a .h5 file. + + Parameters + ---------- + weights_file: str + The full path to a weights file + + Returns + ------- + str + The full path to a weights file + """ + if not weights_file: + logger.debug("No weights file selected.") + return None + + msg = "" + if not os.path.exists(weights_file): + msg = f"Load weights selected, but the path '{weights_file}' does not exist." + elif not os.path.splitext(weights_file)[-1].lower() == ".h5": + msg = (f"Load weights selected, but the path '{weights_file}' is not a valid Keras " + f"model (.h5) file.") + + if msg: + msg += " Please check and try again." + raise FaceswapError(msg) + + logger.verbose("Using weights file: %s", weights_file) # type:ignore + return weights_file + + def freeze(self) -> None: + """ If freeze has been selected in the cli arguments, then freeze those models indicated + in the plugin's configuration. """ + # Blanket unfreeze layers, as checking the value of :attr:`layer.trainable` appears to + # return ``True`` even when the weights have been frozen + for layer in get_all_sub_models(self._model): + layer.trainable = True + + if not self._do_freeze: + logger.debug("Freeze weights deselected. Not freezing") + return + + for layer in get_all_sub_models(self._model): + if layer.name in self._freeze_layers: + logger.info("Freezing weights for '%s' in model '%s'", layer.name, self._name) + layer.trainable = False + self._freeze_layers.remove(layer.name) + if self._freeze_layers: + logger.warning("The following layers were set to be frozen but do not exist in the " + "model: %s", self._freeze_layers) + + def load(self, model_exists: bool) -> None: + """ Load weights for newly created models, or output warning for pre-existing models. + + Parameters + ---------- + model_exists: bool + ``True`` if a model pre-exists and is being resumed, ``False`` if this is a new model + """ + if not self._weights_file: + logger.debug("No weights file provided. Not loading weights.") + return + if model_exists and self._weights_file: + logger.warning("Ignoring weights file '%s' as this model is resuming.", + self._weights_file) + return + + weights_models = self._get_weights_model() + all_models = get_all_sub_models(self._model) + + for model_name in self._load_layers: + sub_model = next((lyr for lyr in all_models if lyr.name == model_name), None) + sub_weights = next((lyr for lyr in weights_models if lyr.name == model_name), None) + + if not sub_model or not sub_weights: + msg = f"Skipping layer {model_name} as not in " + msg += "current_model." if not sub_model else f"weights '{self._weights_file}.'" + logger.warning(msg) + continue + + logger.info("Loading weights for layer '%s'", model_name) + skipped_ops = 0 + loaded_ops = 0 + for layer in sub_model.layers: + success = self._load_layer_weights(layer, sub_weights, model_name) + if success == 0: + skipped_ops += 1 + elif success == 1: + loaded_ops += 1 + + del weights_models + + if loaded_ops == 0: + raise FaceswapError(f"No weights were succesfully loaded from your weights file: " + f"'{self._weights_file}'. Please check and try again.") + if skipped_ops > 0: + logger.warning("%s weight(s) were unable to be loaded for your model. This is most " + "likely because the weights you are trying to load were trained with " + "different settings than you have set for your current model.", + skipped_ops) + + def _get_weights_model(self) -> List[keras.models.Model]: + """ Obtain a list of all sub-models contained within the weights model. + + Returns + ------- + list + List of all models contained within the .h5 file + + Raises + ------ + FaceswapError + In the event of a failure to load the weights, or the weights belonging to a different + model + """ + retval = get_all_sub_models(load_model(self._weights_file, compile=False)) + if not retval: + raise FaceswapError(f"Error loading weights file {self._weights_file}.") + + if retval[0].name != self._name: + raise FaceswapError(f"You are attempting to load weights from a '{retval[0].name}' " + f"model into a '{self._name}' model. This is not supported.") + return retval + + def _load_layer_weights(self, + layer: keras.layers.Layer, + sub_weights: keras.layers.Layer, + model_name: str) -> Literal[-1, 0, 1]: + """ Load the weights for a single layer. + + Parameters + ---------- + layer: :class:`keras.layers.Layer` + The layer to set the weights for + sub_weights: list + The list of layers in the weights model to load weights from + model_name: str + The name of the current sub-model that is having it's weights loaded + + Returns + ------- + int + `-1` if the layer has no weights to load. `0` if weights loading was unsuccessful. `1` + if weights loading was successful + """ + old_weights = layer.get_weights() + if not old_weights: + logger.debug("Skipping layer without weights: %s", layer.name) + return -1 + + layer_weights = next((lyr for lyr in sub_weights.layers + if lyr.name == layer.name), None) + if not layer_weights: + logger.warning("The weights file '%s' for layer '%s' does not contain weights for " + "'%s'. Skipping", self._weights_file, model_name, layer.name) + return 0 + + new_weights = layer_weights.get_weights() + if old_weights[0].shape != new_weights[0].shape: + logger.warning("The weights for layer '%s' are of incompatible shapes. Skipping.", + layer.name) + return 0 + logger.verbose("Setting weights for '%s'", layer.name) # type:ignore + layer.set_weights(layer_weights.get_weights()) + return 1 diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py new file mode 100644 index 0000000000..7a01a472b5 --- /dev/null +++ b/plugins/train/model/_base/model.py @@ -0,0 +1,935 @@ +#!/usr/bin/env python3 +""" +Base class for Models. ALL Models should at least inherit from this class. + +See :mod:`~plugins.train.model.original` for an annotated example for how to create model plugins. +""" +import logging +import os +import sys +import time + +from collections import OrderedDict +from typing import Dict, List, Optional, Tuple, TYPE_CHECKING, Union + +import numpy as np + +from lib.serializer import get_serializer +from lib.model.nn_blocks import set_config as set_nnblock_config +from lib.utils import get_backend, FaceswapError +from plugins.train._config import Config + +from .io import IO, get_all_sub_models, Weights +from .settings import Loss, Optimizer, Settings + +if get_backend() == "amd": + import keras + from keras import backend as K + from keras.layers import Input + from keras.models import load_model, Model as KModel +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow import keras # pylint:disable=import-error + from tensorflow.keras import backend as K # pylint:disable=import-error + from tensorflow.keras.layers import Input # pylint:disable=import-error,no-name-in-module + from tensorflow.keras.models import load_model, Model as KModel # noqa pylint:disable=import-error,no-name-in-module + +if TYPE_CHECKING: + import argparse + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name +_CONFIG = None + + +def KerasModel(inputs: list, outputs: list, name: str) -> keras.models.Model: # noqa, pylint:disable=invalid-name + """ wrapper for :class:`keras.models.Model`. + + There are some minor foibles between Keras 2.2 and the Tensorflow version of Keras, so this + catches potential issues and fixes prior to returning the requested model. + + All models created within plugins should use this method, and should not call keras directly + for a model. + + Parameters + ---------- + inputs: a keras.Input object or list of keras.Input objects. + The input(s) of the model + outputs: keras objects + The output(s) of the model. + name: str + The name of the model. + + Returns + ------- + :class:`keras.models.Model` + A Keras Model + """ + if get_backend() == "amd": + logger.debug("Flattening inputs (%s) and outputs (%s) for AMD", inputs, outputs) + inputs = np.array(inputs).flatten().tolist() + outputs = np.array(outputs).flatten().tolist() + logger.debug("Flattened inputs (%s) and outputs (%s)", inputs, outputs) + return KModel(inputs, outputs, name=name) + + +class ModelBase(): + """ Base class that all model plugins should inherit from. + + Parameters + ---------- + model_dir: str + The full path to the model save location + arguments: :class:`argparse.Namespace` + The arguments that were passed to the train or convert process as generated from + Faceswap's command line arguments + predict: bool, optional + ``True`` if the model is being loaded for inference, ``False`` if the model is being loaded + for training. Default: ``False`` + + Attributes + ---------- + input_shape: tuple or list + A `tuple` of `ints` defining the shape of the faces that the model takes as input. This + should be overridden by model plugins in their :func:`__init__` function. If the input size + is the same for both sides of the model, then this can be a single 3 dimensional `tuple`. + If the inputs have different sizes for `"A"` and `"B"` this should be a `list` of 2 3 + dimensional shape `tuples`, 1 for each side respectively. + trainer: str + Currently there is only one trainer available (`"original"`), so at present this attribute + can be ignored. If/when more trainers are added, then this attribute should be overridden + with the trainer name that a model requires in the model plugin's + :func:`__init__` function. + """ + def __init__(self, + model_dir: str, + arguments: "argparse.Namespace", + predict: bool = False) -> None: + logger.debug("Initializing ModelBase (%s): (model_dir: '%s', arguments: %s, predict: %s)", + self.__class__.__name__, model_dir, arguments, predict) + + # Input shape must be set within the plugin after initializing + self.input_shape: Union[List[Tuple[int, ...]], Tuple[int, ...]] = () + self.trainer = "original" # Override for plugin specific trainer + self.color_order = "bgr" # Override for plugin specific image color channel order + + self._args = arguments + self._is_predict = predict + self._model: Optional[keras.models.Model] = None + + self._configfile = arguments.configfile if hasattr(arguments, "configfile") else None + self._load_config() + + if self.config["penalized_mask_loss"] and self.config["mask_type"] is None: + raise FaceswapError("Penalized Mask Loss has been selected but you have not chosen a " + "Mask to use. Please select a mask or disable Penalized Mask " + "Loss.") + + self._io = IO(self, model_dir, self._is_predict) + self._check_multiple_models() + + self._state = State(model_dir, + self.name, + self._config_changeable_items, + False if self._is_predict else self._args.no_logs) + self._settings = Settings(self._args, + self.config["mixed_precision"], + self.config["allow_growth"], + self._is_predict) + self._loss = Loss(self.config) + + logger.debug("Initialized ModelBase (%s)", self.__class__.__name__) + + @property + def model(self) -> keras.models.Model: + """:class:`Keras.models.Model`: The compiled model for this plugin. """ + return self._model + + @property + def command_line_arguments(self) -> "argparse.Namespace": + """ :class:`argparse.Namespace`: The command line arguments passed to the model plugin from + either the train or convert script """ + return self._args + + @property + def coverage_ratio(self) -> float: + """ float: The ratio of the training image to crop out and train on as defined in user + configuration options. + + NB: The coverage ratio is a raw float, but will be applied to integer pixel images. + + To ensure consistent rounding and guaranteed even image size, the calculation for coverage + should always be: :math:`(original_size * coverage_ratio // 2) * 2` + """ + return self.config.get("coverage", 62.5) / 100 + + @property + def model_dir(self) -> str: + """str: The full path to the model folder location. """ + return self._io._model_dir # pylint:disable=protected-access + + @property + def config(self) -> dict: + """ dict: The configuration dictionary for current plugin, as set by the user's + configuration settings. """ + global _CONFIG # pylint: disable=global-statement + if not _CONFIG: + model_name = self._config_section + logger.debug("Loading config for: %s", model_name) + _CONFIG = Config(model_name, configfile=self._configfile).config_dict + return _CONFIG + + @property + def name(self) -> str: + """ str: The name of this model based on the plugin name. """ + basename = os.path.basename(sys.modules[self.__module__].__file__) + return os.path.splitext(basename)[0].lower() + + @property + def model_name(self) -> str: + """ str: The name of the keras model. Generally this will be the same as :attr:`name` + but some plugins will override this when they contain multiple architectures """ + return self.name + + @property + def output_shapes(self) -> List[List[Tuple]]: + """ list: A list of list of shape tuples for the outputs of the model with the batch + dimension removed. The outer list contains 2 sub-lists (one for each side "a" and "b"). + The inner sub-lists contain the output shapes for that side. """ + shapes = [tuple(K.int_shape(output)[-3:]) for output in self._model.outputs] + return [shapes[:len(shapes) // 2], shapes[len(shapes) // 2:]] + + @property + def iterations(self) -> int: + """ int: The total number of iterations that the model has trained. """ + return self._state.iterations + + # Private properties + @property + def _config_section(self) -> str: + """ str: The section name for the current plugin for loading configuration options from the + config file. """ + return ".".join(self.__module__.split(".")[-2:]) + + @property + def _config_changeable_items(self) -> dict: + """ dict: The configuration options that can be updated after the model has already been + created. """ + return Config(self._config_section, configfile=self._configfile).changeable_items + + @property + def state(self) -> "State": + """:class:`State`: The state settings for the current plugin. """ + return self._state + + def _load_config(self) -> None: + """ Load the global config for reference in :attr:`config` and set the faceswap blocks + configuration options in `lib.model.nn_blocks` """ + global _CONFIG # pylint: disable=global-statement + if not _CONFIG: + model_name = self._config_section + logger.debug("Loading config for: %s", model_name) + _CONFIG = Config(model_name, configfile=self._configfile).config_dict + + nn_block_keys = ['icnr_init', 'conv_aware_init', 'reflect_padding'] + set_nnblock_config({key: _CONFIG.pop(key) + for key in nn_block_keys}) + + def _check_multiple_models(self) -> None: + """ Check whether multiple models exist in the model folder, and that no models exist that + were trained with a different plugin than the requested plugin. + + Raises + ------ + FaceswapError + If multiple model files, or models for a different plugin from that requested exists + within the model folder + """ + multiple_models = self._io.multiple_models_in_folder + if multiple_models is None: + logger.debug("Contents of model folder are valid") + return + + if len(multiple_models) == 1: + msg = (f"You have requested to train with the '{self.name}' plugin, but a model file " + f"for the '{multiple_models[0]}' plugin already exists in the folder " + f"'{self.model_dir}'.\nPlease select a different model folder.") + else: + ptypes = "', '".join(multiple_models) + msg = (f"There are multiple plugin types ('{ptypes}') stored in the model folder '" + f"{self.model_dir}'. This is not supported.\nPlease split the model files into " + "their own folders before proceeding") + raise FaceswapError(msg) + + def build(self) -> None: + """ Build the model and assign to :attr:`model`. + + Within the defined strategy scope, either builds the model from scratch or loads an + existing model if one exists. + + If running inference, then the model is built only for the required side to perform the + swap function, otherwise the model is then compiled with the optimizer and chosen + loss function(s). + + Finally, a model summary is outputted to the logger at verbose level. + """ + self._update_legacy_models() + is_summary = hasattr(self._args, "summary") and self._args.summary + with self._settings.strategy_scope(): + if self._io.model_exists: + model = self._io._load() # pylint:disable=protected-access + if self._is_predict: + inference = _Inference(model, self._args.swap_model) + self._model = inference.model + else: + self._model = model + else: + self._validate_input_shape() + inputs = self._get_inputs() + self._model = self.build_model(inputs) + if not is_summary and not self._is_predict: + self._compile_model() + self._output_summary() + + def _update_legacy_models(self) -> None: + """ Load weights from legacy split models into new unified model, archiving old model files + to a new folder. """ + legacy_mapping = self._legacy_mapping() # pylint:disable=assignment-from-none + if legacy_mapping is None: + return + + if not all(os.path.isfile(os.path.join(self.model_dir, fname)) + for fname in legacy_mapping): + return + archive_dir = f"{self.model_dir}_TF1_Archived" + if os.path.exists(archive_dir): + raise FaceswapError("We need to update your model files for use with Tensorflow 2.x, " + "but the archive folder already exists. Please remove the " + f"following folder to continue: '{archive_dir}'") + + logger.info("Updating legacy models for Tensorflow 2.x") + logger.info("Your Tensorflow 1.x models will be archived in the following location: '%s'", + archive_dir) + os.rename(self.model_dir, archive_dir) + os.mkdir(self.model_dir) + new_model = self.build_model(self._get_inputs()) + for model_name, layer_name in legacy_mapping.items(): + old_model = load_model(os.path.join(archive_dir, model_name), compile=False) + layer = [layer for layer in new_model.layers if layer.name == layer_name] + if not layer: + logger.warning("Skipping legacy weights from '%s'...", model_name) + continue + layer = layer[0] + logger.info("Updating legacy weights from '%s'...", model_name) + layer.set_weights(old_model.get_weights()) + filename = self._io._filename # pylint:disable=protected-access + logger.info("Saving Tensorflow 2.x model to '%s'", filename) + new_model.save(filename) + # Penalized Loss and Learn Mask used to be disabled automatically if a mask wasn't + # selected, so disable it if enabled, but mask_type is None + if self.config["mask_type"] is None: + self.config["penalized_mask_loss"] = False + self.config["learn_mask"] = False + self.config["eye_multiplier"] = 1 + self.config["mouth_multiplier"] = 1 + self._state.save() + + def _validate_input_shape(self) -> None: + """ Validate that the input shape is either a single shape tuple of 3 dimensions or + a list of 2 shape tuples of 3 dimensions. """ + assert len(self.input_shape) in (2, 3), "Input shape should either be a single 3 " \ + "dimensional shape tuple for use in both sides of the model, or a list of 2 3 " \ + "dimensional shape tuples for use in the 'A' and 'B' sides of the model" + if len(self.input_shape) == 2: + assert [len(shape) == 3 for shape in self.input_shape], "All input shapes should " \ + "have 3 dimensions" + + def _get_inputs(self) -> List[keras.layers.Input]: + """ Obtain the standardized inputs for the model. + + The inputs will be returned for the "A" and "B" sides in the shape as defined by + :attr:`input_shape`. + + Returns + ------- + list + A list of :class:`keras.layers.Input` tensors. This will be a list of 2 tensors (one + for each side) each of shapes :attr:`input_shape`. + """ + logger.debug("Getting inputs") + if len(self.input_shape) == 3: + input_shapes = [self.input_shape, self.input_shape] + else: + input_shapes = self.input_shape + inputs = [Input(shape=shape, name=f"face_in_{side}") + for side, shape in zip(("a", "b"), input_shapes)] + logger.debug("inputs: %s", inputs) + return inputs + + def build_model(self, inputs: List[keras.layers.Input]) -> keras.models.Model: + """ Override for Model Specific autoencoder builds. + + Parameters + ---------- + inputs: list + A list of :class:`keras.layers.Input` tensors. This will be a list of 2 tensors (one + for each side) each of shapes :attr:`input_shape`. + + Returns + ------- + :class:`keras.models.Model` + The output of this function must be a keras model generated from + :class:`plugins.train.model._base.KerasModel`. See Keras documentation for the correct + structure, but note that parameter :attr:`name` is a required rather than an optional + argument in Faceswap. You should assign this to the attribute ``self.name`` that is + automatically generated from the plugin's filename. + """ + raise NotImplementedError + + def _output_summary(self) -> None: + """ Output the summary of the model and all sub-models to the verbose logger. """ + if hasattr(self._args, "summary") and self._args.summary: + print_fn = None # Print straight to stdout + else: + # print to logger + print_fn = lambda x: logger.verbose("%s", x) # type: ignore # noqa + for idx, model in enumerate(get_all_sub_models(self._model)): + if idx == 0: + parent = model + continue + model.summary(line_length=100, print_fn=print_fn) + parent.summary(line_length=100, print_fn=print_fn) + + def save(self) -> None: + """ Save the model to disk. + + Saves the serialized model, with weights, to the folder location specified when + initializing the plugin. If loss has dropped on both sides of the model, then + a backup is taken. + """ + self._io.save() # pylint:disable=protected-access + + def snapshot(self) -> None: + """ Creates a snapshot of the model folder to the models parent folder, with the number + of iterations completed appended to the end of the model name. """ + self._io.snapshot() + + def _compile_model(self) -> None: + """ Compile the model to include the Optimizer and Loss Function(s). """ + logger.debug("Compiling Model") + + optimizer = Optimizer(self.config["optimizer"], + self.config["learning_rate"], + self.config.get("clipnorm", False), + 10 ** int(self.config["epsilon_exponent"]), + self.config.get("mixed_precision", False), + self._args).optimizer + if self._settings.use_mixed_precision: + optimizer = self._settings.loss_scale_optimizer(optimizer) + if get_backend() == "amd": + self._rewrite_plaid_outputs() + + weights = Weights(self) + weights.load(self._io.model_exists) + weights.freeze() + + self._loss.configure(self._model) + self._model.compile(optimizer=optimizer, loss=self._loss.functions) + self._state.add_session_loss_names(self._loss.names) + logger.debug("Compiled Model: %s", self._model) + + def _rewrite_plaid_outputs(self) -> None: + """ Rewrite the output names for models using the PlaidML (Keras 2.2.4) backend + + Keras 2.2.4 duplicates model output names if any of the models have multiple outputs + so we need to rename the outputs so we can successfully map the loss dictionaries. + + This is a bit of a hack, but it does work. + """ + # TODO Remove this rewrite code if PlaidML updates to a version of Keras where this is + # no longer necessary + if len(self._model.output_names) == len(set(self._model.output_names)): + logger.debug("Output names are unique, not rewriting: %s", self._model.output_names) + return + seen = {name: 0 for name in set(self._model.output_names)} + new_names = [] + for name in self._model.output_names: + new_names.append(f"{name}_{seen[name]}") + seen[name] += 1 + logger.debug("Output names rewritten: (old: %s, new: %s)", + self._model.output_names, new_names) + self._model.output_names = new_names + + def _legacy_mapping(self) -> Optional[dict]: # pylint:disable=no-self-use + """ The mapping of separate model files to single model layers for transferring of legacy + weights. + + Returns + ------- + dict or ``None`` + Dictionary of original H5 filenames for legacy models mapped to new layer names or + ``None`` if the model did not exist in Faceswap prior to Tensorflow 2 + """ + return None + + def add_history(self, loss: List[float]) -> None: + """ Add the current iteration's loss history to :attr:`_io.history`. + + Called from the trainer after each iteration, for tracking loss drop over time between + save iterations. + + Parameters + ---------- + loss: list + The loss values for the A and B side for the current iteration. This should be the + collated loss values for each side. + """ + self._io.history[0].append(loss[0]) + self._io.history[1].append(loss[1]) + + +class State(): + """ Holds state information relating to the plugin's saved model. + + Parameters + ---------- + model_dir: str + The full path to the model save location + model_name: str + The name of the model plugin + config_changeable_items: dict + Configuration options that can be altered when resuming a model, and their current values + no_logs: bool + ``True`` if Tensorboard logs should not be generated, otherwise ``False`` + """ + def __init__(self, + model_dir: str, + model_name: str, + config_changeable_items: dict, + no_logs: bool) -> None: + logger.debug("Initializing %s: (model_dir: '%s', model_name: '%s', " + "config_changeable_items: '%s', no_logs: %s", self.__class__.__name__, + model_dir, model_name, config_changeable_items, no_logs) + self._serializer = get_serializer("json") + filename = f"{model_name}_state.{self._serializer.file_extension}" + self._filename = os.path.join(model_dir, filename) + self._name = model_name + self._iterations = 0 + self._sessions: Dict[int, dict] = {} + self._lowest_avg_loss: Dict[str, float] = {} + self._config = {} + self._load(config_changeable_items) + self._session_id = self._new_session_id() + self._create_new_session(no_logs, config_changeable_items) + logger.debug("Initialized %s:", self.__class__.__name__) + + @property + def loss_names(self) -> List[str]: + """ list: The loss names for the current session """ + return self._sessions[self._session_id]["loss_names"] + + @property + def current_session(self) -> dict: + """ dict: The state dictionary for the current :attr:`session_id`. """ + return self._sessions[self._session_id] + + @property + def iterations(self) -> int: + """ int: The total number of iterations that the model has trained. """ + return self._iterations + + @property + def lowest_avg_loss(self) -> dict: + """dict: The lowest average save interval loss seen for each side. """ + return self._lowest_avg_loss + + @property + def session_id(self) -> int: + """ int: The current training session id. """ + return self._session_id + + def _new_session_id(self) -> int: + """ Generate a new session id. Returns 1 if this is a new model, or the last session id + 1 + if it is a pre-existing model. + + Returns + ------- + int + The newly generated session id + """ + if not self._sessions: + session_id = 1 + else: + session_id = max(int(key) for key in self._sessions.keys()) + 1 + logger.debug(session_id) + return session_id + + def _create_new_session(self, no_logs: bool, config_changeable_items: dict) -> None: + """ Initialize a new session, creating the dictionary entry for the session in + :attr:`_sessions`. + + Parameters + ---------- + no_logs: bool + ``True`` if Tensorboard logs should not be generated, otherwise ``False`` + config_changeable_items: dict + Configuration options that can be altered when resuming a model, and their current + values + """ + logger.debug("Creating new session. id: %s", self._session_id) + self._sessions[self._session_id] = dict(timestamp=time.time(), + no_logs=no_logs, + loss_names=[], + batchsize=0, + iterations=0, + config=config_changeable_items) + + def add_session_loss_names(self, loss_names: List[str]) -> None: + """ Add the session loss names to the sessions dictionary. + + The loss names are used for Tensorboard logging + + Parameters + ---------- + loss_names: list + The list of loss names for this session. + """ + logger.debug("Adding session loss_names: %s", loss_names) + self._sessions[self._session_id]["loss_names"] = loss_names + + def add_session_batchsize(self, batch_size: int) -> None: + """ Add the session batch size to the sessions dictionary. + + Parameters + ---------- + batch_size: int + The batch size for the current training session + """ + logger.debug("Adding session batch size: %s", batch_size) + self._sessions[self._session_id]["batchsize"] = batch_size + + def increment_iterations(self) -> None: + """ Increment :attr:`iterations` and session iterations by 1. """ + self._iterations += 1 + self._sessions[self._session_id]["iterations"] += 1 + + def _load(self, config_changeable_items: dict) -> None: + """ Load a state file and set the serialized values to the class instance. + + Updates the model's config with the values stored in the state file. + + Parameters + ---------- + config_changeable_items: dict + Configuration options that can be altered when resuming a model, and their current + values + """ + logger.debug("Loading State") + if not os.path.exists(self._filename): + logger.info("No existing state file found. Generating.") + return + state = self._serializer.load(self._filename) + self._name = state.get("name", self._name) + self._sessions = state.get("sessions", {}) + self._lowest_avg_loss = state.get("lowest_avg_loss", {}) + self._iterations = state.get("iterations", 0) + self._config = state.get("config", {}) + logger.debug("Loaded state: %s", state) + self._replace_config(config_changeable_items) + + def save(self) -> None: + """ Save the state values to the serialized state file. """ + logger.debug("Saving State") + state = {"name": self._name, + "sessions": self._sessions, + "lowest_avg_loss": self._lowest_avg_loss, + "iterations": self._iterations, + "config": _CONFIG} + self._serializer.save(self._filename, state) + logger.debug("Saved State") + + def _replace_config(self, config_changeable_items) -> None: + """ Replace the loaded config with the one contained within the state file. + + Check for any `fixed`=``False`` parameter changes and log info changes. + + Update any legacy config items to their current versions. + + Parameters + ---------- + config_changeable_items: dict + Configuration options that can be altered when resuming a model, and their current + values + """ + global _CONFIG # pylint: disable=global-statement + if _CONFIG is None: + return + legacy_update = self._update_legacy_config() + # Add any new items to state config for legacy purposes where the new default may be + # detrimental to an existing model. + legacy_defaults = dict(centering="legacy", + mask_loss_function="mse", + l2_reg_term=100, + optimizer="adam", + mixed_precision=False) + for key, val in _CONFIG.items(): + if key not in self._config.keys(): + setting = legacy_defaults.get(key, val) + logger.info("Adding new config item to state file: '%s': '%s'", key, setting) + self._config[key] = setting + self._update_changed_config_items(config_changeable_items) + logger.debug("Replacing config. Old config: %s", _CONFIG) + _CONFIG = self._config + if legacy_update: + self.save() + logger.debug("Replaced config. New config: %s", _CONFIG) + logger.info("Using configuration saved in state file") + + def _update_legacy_config(self) -> bool: + """ Legacy updates for new config additions. + + When new config items are added to the Faceswap code, existing model state files need to be + updated to handle these new items. + + Current existing legacy update items: + + * loss - If old `dssim_loss` is ``true`` set new `loss_function` to `ssim` otherwise + set it to `mae`. Remove old `dssim_loss` item + + * masks - If `learn_mask` does not exist then it is set to ``True`` if `mask_type` is + not ``None`` otherwise it is set to ``False``. + + * masks type - Replace removed masks 'dfl_full' and 'facehull' with `components` mask + + Returns + ------- + bool + ``True`` if legacy items exist and state file has been updated, otherwise ``False`` + """ + logger.debug("Checking for legacy state file update") + priors = ["dssim_loss", "mask_type", "mask_type"] + new_items = ["loss_function", "learn_mask", "mask_type"] + updated = False + for old, new in zip(priors, new_items): + if old not in self._config: + logger.debug("Legacy item '%s' not in config. Skipping update", old) + continue + + # dssim_loss > loss_function + if old == "dssim_loss": + self._config[new] = "ssim" if self._config[old] else "mae" + del self._config[old] + updated = True + logger.info("Updated config from legacy dssim format. New config loss " + "function: '%s'", self._config[new]) + continue + + # Add learn mask option and set to True if model has "penalized_mask_loss" specified + if old == "mask_type" and new == "learn_mask" and new not in self._config: + self._config[new] = self._config["mask_type"] is not None + updated = True + logger.info("Added new 'learn_mask' config item for this model. Value set to: %s", + self._config[new]) + continue + + # Replace removed masks with most similar equivalent + if old == "mask_type" and new == "mask_type" and self._config[old] in ("facehull", + "dfl_full"): + old_mask = self._config[old] + self._config[new] = "components" + updated = True + logger.info("Updated 'mask_type' from '%s' to '%s' for this model", + old_mask, self._config[new]) + + logger.debug("State file updated for legacy config: %s", updated) + return updated + + def _update_changed_config_items(self, config_changeable_items: dict) -> None: + """ Update any parameters which are not fixed and have been changed. + + Parameters + ---------- + config_changeable_items: dict + Configuration options that can be altered when resuming a model, and their current + values + """ + if not config_changeable_items: + logger.debug("No changeable parameters have been updated") + return + for key, val in config_changeable_items.items(): + old_val = self._config[key] + if old_val == val: + continue + self._config[key] = val + logger.info("Config item: '%s' has been updated from '%s' to '%s'", key, old_val, val) + + +class _Inference(): # pylint:disable=too-few-public-methods + """ Calculates required layers and compiles a saved model for inference. + + Parameters + ---------- + saved_model: :class:`keras.models.Model` + The saved trained Faceswap model + switch_sides: bool + ``True`` if the swap should be performed "B" > "A" ``False`` if the swap should be + "A" > "B" + """ + def __init__(self, saved_model: keras.models.Model, switch_sides: bool) -> None: + logger.debug("Initializing: %s (saved_model: %s, switch_sides: %s)", + self.__class__.__name__, saved_model, switch_sides) + self._config = saved_model.get_config() + + self._input_idx = 1 if switch_sides else 0 + self._output_idx = 0 if switch_sides else 1 + + self._input_names = [inp[0] for inp in self._config["input_layers"]] + self._model = self._make_inference_model(saved_model) + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def model(self) -> keras.models.Model: + """ :class:`keras.models.Model`: The Faceswap model, compiled for inference. """ + return self._model + + def _get_nodes(self, nodes: list) -> List[Tuple[str, int]]: + """ Given in input list of nodes from a :attr:`keras.models.Model.get_config` dictionary, + filters the layer name(s) and output index of the node, splitting to the correct output + index in the event of multiple inputs. + + Parameters + ---------- + nodes: list + A node entry from the :attr:`keras.models.Model.get_config` dictionary + + Returns + ------- + list + The (node name, output index) for each node passed in + """ + anodes = np.array(nodes, dtype="object")[..., :3] + num_layers = anodes.shape[0] + anodes = anodes[self._output_idx] if num_layers == 2 else nodes[0] + retval = [(node[0], node[2]) for node in anodes] + return retval + + def _make_inference_model(self, saved_model: keras.models.Model) -> keras.models.Model: + """ Extract the sub-models from the saved model that are required for inference. + + Parameters + ---------- + saved_model: :class:`keras.models.Model` + The saved trained Faceswap model + + Returns + ------- + :class:`keras.models.Model` + The model compiled for inference + """ + logger.debug("Compiling inference model. saved_model: %s", saved_model) + struct = self._get_filtered_structure() + model_inputs = self._get_inputs(saved_model.inputs) + compiled_layers = {} + for layer in saved_model.layers: + if layer.name not in struct: + logger.debug("Skipping unused layer: '%s'", layer.name) + continue + inbound = struct[layer.name] + logger.debug("Processing layer '%s': (layer: %s, inbound_nodes: %s)", + layer.name, layer, inbound) + if not inbound: + model = model_inputs + logger.debug("Adding model inputs %s: %s", layer.name, model) + else: + layer_inputs = [] + for inp in inbound: + inbound_layer = compiled_layers[inp[0]] + if isinstance(inbound_layer, list) and len(inbound_layer) > 1: + # Multi output inputs + inbound_output_idx = inp[1] + next_input = inbound_layer[inbound_output_idx] + logger.debug("Selecting output index %s from multi output inbound layer: " + "%s (using: %s)", inbound_output_idx, inbound_layer, + next_input) + else: + next_input = inbound_layer + + if get_backend() == "amd" and isinstance(next_input, list): + # tensorflow.keras and keras 2.2 behave differently for layer inputs + layer_inputs.extend(next_input) + else: + layer_inputs.append(next_input) + + logger.debug("Compiling layer '%s': layer inputs: %s", layer.name, layer_inputs) + model = layer(layer_inputs) + compiled_layers[layer.name] = model + retval = KerasModel(model_inputs, model, name=f"{saved_model.name}_inference") + logger.debug("Compiled inference model '%s': %s", retval.name, retval) + return retval + + def _get_filtered_structure(self) -> OrderedDict: + """ Obtain the structure of the inference model. + + This parses the model config (in reverse) to obtain the required layers for an inference + model. + + Returns + ------- + :class:`collections.OrderedDict` + The layer name as key with the input name and output index as value. + """ + # Filter output layer + out = np.array(self._config["output_layers"], dtype="object") + if out.ndim == 2: + out = np.expand_dims(out, axis=1) # Needs to be expanded for _get_nodes + outputs = self._get_nodes(out) + + # Iterate backwards from the required output to get the reversed model structure + current_layers = [outputs[0]] + next_layers = [] + struct = OrderedDict() + drop_input = self._input_names[abs(self._input_idx - 1)] + switch_input = self._input_names[self._input_idx] + while True: + layer_info = current_layers.pop(0) + current_layer = next(lyr for lyr in self._config["layers"] + if lyr["name"] == layer_info[0]) + inbound = current_layer["inbound_nodes"] + + if not inbound: + break + + inbound_info = self._get_nodes(inbound) + + if any(inb[0] == drop_input for inb in inbound_info): # Switch inputs + inbound_info = [(switch_input if inb[0] == drop_input else inb[0], inb[1]) + for inb in inbound_info] + struct[layer_info[0]] = inbound_info + next_layers.extend(inbound_info) + + if not current_layers: + current_layers = next_layers + next_layers = [] + + struct[switch_input] = [] # Add the input layer + logger.debug("Model structure: %s", struct) + return struct + + def _get_inputs(self, inputs: list) -> list: + """ Obtain the inputs for the requested swap direction. + + Parameters + ---------- + inputs: list + The full list of input tensors to the saved faceswap training model + + Returns + ------- + list + List of input tensors to feed the model for the requested swap direction + """ + input_split = len(inputs) // 2 + start_idx = input_split * self._input_idx + retval = inputs[start_idx: start_idx + input_split] + logger.debug("model inputs: %s, input_split: %s, start_idx: %s, inference_inputs: %s", + inputs, input_split, start_idx, retval) + return retval diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py new file mode 100644 index 0000000000..59caadf2ce --- /dev/null +++ b/plugins/train/model/_base/settings.py @@ -0,0 +1,509 @@ +#!/usr/bin/env python3 +""" +Settings for the model base plugins. + +The objects in this module should not be called directly, but are called from +:class:`~plugins.train.model._base.ModelBase` + +Handles configuration of model plugins for: + - Loss configuration + - Optimizer settings + - General global model configuration settings +""" +import logging +import platform + +from contextlib import nullcontext +from typing import Callable, ContextManager, Dict, List, Optional, TYPE_CHECKING + +import tensorflow as tf + +from lib.model import losses, optimizers +from lib.utils import get_backend, get_tf_version + +if get_backend() == "amd": + import keras + from keras import losses as k_losses + from keras import backend as K +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow import keras + from tensorflow.keras import losses as k_losses # pylint:disable=import-error + from tensorflow.keras import backend as K # pylint:disable=import-error + +if get_tf_version() < 2.4: + import tensorflow.keras.mixed_precision.experimental as mixedprecision # noqa pylint:disable=import-error,no-name-in-module +else: + import tensorflow.keras.mixed_precision as mixedprecision # noqa pylint:disable=import-error,no-name-in-module + +if TYPE_CHECKING: + from argparse import Namespace + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class Loss(): + """ Holds loss names and functions for an Autoencoder. + + Parameters + ---------- + config: dict + The configuration options for the current model plugin + """ + def __init__(self, config: dict) -> None: + logger.debug("Initializing %s", self.__class__.__name__) + self._config = config + self._loss_dict = dict(mae=k_losses.mean_absolute_error, + mse=k_losses.mean_squared_error, + logcosh=k_losses.logcosh, + smooth_loss=losses.GeneralizedLoss(), + l_inf_norm=losses.LInfNorm(), + ssim=losses.DSSIMObjective(), + ms_ssim=losses.MSSSIMLoss(), + gmsd=losses.GMSDLoss(), + pixel_gradient_diff=losses.GradientLoss()) + self._uses_l2_reg = ["ssim", "ms_ssim", "gmsd"] + self._mask_channels = self._get_mask_channels() + self._inputs: List[keras.layers.Layer] = [] + self._names: List[str] = [] + self._funcs: Dict[str, Callable] = {} + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def names(self) -> List[str]: + """ list: The list of loss names for the model. """ + return self._names + + @property + def functions(self) -> dict: + """ dict: The loss functions that apply to each model output. """ + return self._funcs + + @property + def _mask_inputs(self) -> Optional[list]: + """ list: The list of input tensors to the model that contain the mask. Returns ``None`` + if there is no mask input to the model. """ + mask_inputs = [inp for inp in self._inputs if inp.name.startswith("mask")] + return None if not mask_inputs else mask_inputs + + @property + def _mask_shapes(self) -> Optional[List[tuple]]: + """ list: The list of shape tuples for the mask input tensors for the model. Returns + ``None`` if there is no mask input. """ + if self._mask_inputs is None: + return None + return [K.int_shape(mask_input) for mask_input in self._mask_inputs] + + def configure(self, model: keras.models.Model) -> None: + """ Configure the loss functions for the given inputs and outputs. + + Parameters + ---------- + model: :class:`keras.models.Model` + The model that is to be trained + """ + self._inputs = model.inputs + self._set_loss_names(model.outputs) + self._set_loss_functions(model.output_names) + self._names.insert(0, "total") + + def _set_loss_names(self, outputs: List[tf.Tensor]) -> None: + """ Name the losses based on model output. + + This is used for correct naming in the state file, for display purposes only. + + Adds the loss names to :attr:`names` + + Notes + ----- + TODO Currently there is an issue in Tensorflow that wraps all outputs in an Identity layer + when running in Eager Execution mode, which means we cannot use the name of the output + layers to name the losses (https://github.com/tensorflow/tensorflow/issues/32180). + With this in mind, losses are named based on their shapes + + Parameters + ---------- + outputs: list + A list of output tensors from the model plugin + """ + # TODO Use output names if/when these are fixed upstream + split_outputs = [outputs[:len(outputs) // 2], outputs[len(outputs) // 2:]] + for side, side_output in zip(("a", "b"), split_outputs): + output_names = [output.name for output in side_output] + output_shapes = [K.int_shape(output)[1:] for output in side_output] + output_types = ["mask" if shape[-1] == 1 else "face" for shape in output_shapes] + logger.debug("side: %s, output names: %s, output_shapes: %s, output_types: %s", + side, output_names, output_shapes, output_types) + for idx, name in enumerate(output_types): + suffix = "" if output_types.count(name) == 1 else f"_{idx}" + self._names.append(f"{name}_{side}{suffix}") + logger.debug(self._names) + + def _set_loss_functions(self, output_names: List[str]): + """ Set the loss functions and their associated weights. + + Adds the loss functions to the :attr:`functions` dictionary. + + Parameters + ---------- + output_names: list + The output names from the model + """ + face_loss = self._loss_dict[self._config["loss_function"]] + + for name, output_name in zip(self._names, output_names): + if name.startswith("mask"): + loss_func = self._loss_dict[self._config["mask_loss_function"]] + else: + loss_func = losses.LossWrapper() + loss_func.add_loss(face_loss, mask_channel=self._mask_channels[0]) + self._add_l2_regularization_term(loss_func, self._mask_channels[0]) + + channel_idx = 1 + for multiplier in ("eye_multiplier", "mouth_multiplier"): + mask_channel = self._mask_channels[channel_idx] + if self._config[multiplier] > 1: + loss_func.add_loss(face_loss, + weight=self._config[multiplier] * 1.0, + mask_channel=mask_channel) + self._add_l2_regularization_term(loss_func, mask_channel) + channel_idx += 1 + + logger.debug("%s: (output_name: '%s', function: %s)", name, output_name, loss_func) + self._funcs[output_name] = loss_func + logger.debug("functions: %s", self._funcs) + + def _add_l2_regularization_term(self, loss_wrapper, mask_channel): + """ Check if an L2 Regularization term should be added and add to the loss function + wrapper. + + Parameters + ---------- + loss_wrapper: :class:`lib.model.losses.LossWrapper` + The wrapper loss function that holds the face losses + mask_channel: int + The channel that holds the mask in `y_true`, if a mask is used for the loss. + `-1` if the input is not masked + """ + if self._config["loss_function"] in self._uses_l2_reg and self._config["l2_reg_term"] > 0: + logger.debug("Adding L2 Regularization for Structural Loss") + loss_wrapper.add_loss(self._loss_dict["mse"], + weight=self._config["l2_reg_term"] / 100.0, + mask_channel=mask_channel) + + def _get_mask_channels(self) -> List[int]: + """ Obtain the channels from the face targets that the masks reside in from the training + data generator. + + Returns + ------- + list: + A list of channel indices that contain the mask for the corresponding config item + """ + eye_multiplier = self._config["eye_multiplier"] + mouth_multiplier = self._config["mouth_multiplier"] + if not self._config["penalized_mask_loss"] and (eye_multiplier > 1 or + mouth_multiplier > 1): + logger.warning("You have selected eye/mouth loss multipliers greater than 1x, but " + "Penalized Mask Loss is disabled. Disabling all multipliers.") + eye_multiplier = 1 + mouth_multiplier = 1 + uses_masks = (self._config["penalized_mask_loss"], + eye_multiplier > 1, + mouth_multiplier > 1) + mask_channels = [-1 for _ in range(len(uses_masks))] + current_channel = 3 + for idx, mask_required in enumerate(uses_masks): + if mask_required: + mask_channels[idx] = current_channel + current_channel += 1 + logger.debug("uses_masks: %s, mask_channels: %s", uses_masks, mask_channels) + return mask_channels + + +class Optimizer(): # pylint:disable=too-few-public-methods + """ Obtain the selected optimizer with the appropriate keyword arguments. + + Parameters + ---------- + optimizer: str + The selected optimizer name for the plugin + learning_rate: float + The selected learning rate to use + clipnorm: bool + Whether to clip gradients to avoid exploding/vanishing gradients + epsilon: float + The value to use for the epsilon of the optimizer + mixed_precision: bool + ``True`` if mixed precision training is to be enabled otherwise ``False`` + arguments: :class:`argparse.Namespace` + The arguments that were passed to the train or convert process as generated from + Faceswap's command line arguments + """ + def __init__(self, + optimizer: str, + learning_rate: float, + clipnorm: bool, + epsilon: float, + mixed_precision: bool, + arguments: "Namespace") -> None: + logger.debug("Initializing %s: (optimizer: %s, learning_rate: %s, clipnorm: %s, " + "epsilon: %s, mixed_precision: %s, arguments: %s)", self.__class__.__name__, + optimizer, learning_rate, clipnorm, epsilon, mixed_precision, arguments) + valid_optimizers = {"adabelief": (optimizers.AdaBelief, + dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), + "adam": (optimizers.Adam, + dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), + "nadam": (optimizers.Nadam, + dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), + "rms-prop": (optimizers.RMSprop, dict(epsilon=epsilon))} + self._optimizer, self._kwargs = valid_optimizers[optimizer] + + self._configure(learning_rate, clipnorm, mixed_precision, arguments) + logger.verbose("Using %s optimizer", optimizer.title()) # type:ignore + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def optimizer(self) -> keras.optimizers.Optimizer: + """ :class:`keras.optimizers.Optimizer`: The requested optimizer. """ + return self._optimizer(**self._kwargs) + + def _configure(self, + learning_rate: float, + clipnorm: bool, + mixed_precision: bool, + arguments: "Namespace") -> None: + """ Configure the optimizer based on user settings. + + Parameters + ---------- + learning_rate: float + The selected learning rate to use + clipnorm: bool + Whether to clip gradients to avoid exploding/vanishing gradients + mixed_precision: bool + ``True`` if mixed precision training is to be enabled otherwise ``False`` + arguments: :class:`argparse.Namespace` + The arguments that were passed to the train or convert process as generated from + Faceswap's command line arguments + + Notes + ----- + Clip-norm is ballooning VRAM usage, which is not expected behavior and may be a bug in + Keras/Tensorflow. + + PlaidML has a bug regarding the clip-norm parameter See: + https://github.com/plaidml/plaidml/issues/228. We workaround by simply not adding this + parameter for AMD backend users. + """ + lr_key = "lr" if get_backend() == "amd" else "learning_rate" + self._kwargs[lr_key] = learning_rate + + if clipnorm and (arguments.distributed or mixed_precision): + logger.warning("Clipnorm has been selected, but is unsupported when using distributed " + "or mixed_precision training, so has been disabled. If you wish to " + "enable clipnorm, then you must disable these other options.") + clipnorm = False + if clipnorm and get_backend() == "amd": + # TODO add clipnorm in for plaidML when it is fixed upstream. Still not fixed in + # release 0.7.0. + logger.warning("Due to a bug in plaidML, clipnorm cannot be used on AMD backends so " + "has been disabled") + clipnorm = False + if clipnorm: + self._kwargs["clipnorm"] = 1.0 + + logger.debug("optimizer kwargs: %s", self._kwargs) + + +class Settings(): + """ Tensorflow core training settings. + + Sets backend tensorflow settings prior to launching the model. + + Tensorflow 2 uses distribution strategies for multi-GPU/system training. These are context + managers. To enable the code to be more readable, we handle strategies the same way for Nvidia + and AMD backends. PlaidML does not support strategies, but we need to still create a context + manager so that we don't need branching logic. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The arguments that were passed to the train or convert process as generated from + Faceswap's command line arguments + mixed_precision: bool + ``True`` if Mixed Precision training should be used otherwise ``False`` + allow_growth: bool + ``True`` if the Tensorflow allow_growth parameter should be set otherwise ``False`` + is_predict: bool, optional + ``True`` if the model is being loaded for inference, ``False`` if the model is being loaded + for training. Default: ``False`` + """ + def __init__(self, + arguments: "Namespace", + mixed_precision: bool, + allow_growth: bool, + is_predict: bool) -> None: + logger.debug("Initializing %s: (arguments: %s, mixed_precision: %s, allow_growth: %s, " + "is_predict: %s)", self.__class__.__name__, arguments, mixed_precision, + allow_growth, is_predict) + self._set_tf_settings(allow_growth, arguments.exclude_gpus) + + use_mixed_precision = not is_predict and mixed_precision and get_backend() == "nvidia" + self._use_mixed_precision = self._set_keras_mixed_precision(use_mixed_precision, + bool(arguments.exclude_gpus)) + + distributed = False if not hasattr(arguments, "distributed") else arguments.distributed + self._strategy = self._get_strategy(distributed) + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def use_strategy(self) -> bool: + """ bool: ``True`` if a distribution strategy is to be used otherwise ``False``. """ + return self._strategy is not None + + @property + def use_mixed_precision(self) -> bool: + """ bool: ``True`` if mixed precision training has been enabled, otherwise ``False``. """ + return self._use_mixed_precision + + @classmethod + def loss_scale_optimizer( + cls, + optimizer: keras.optimizers.Optimizer) -> mixedprecision.LossScaleOptimizer: + """ Optimize loss scaling for mixed precision training. + + Parameters + ---------- + optimizer: :class:`tf.keras.optimizers.Optimizer` + The optimizer instance to wrap + + Returns + -------- + :class:`tf.keras.mixed_precision.loss_scale_optimizer.LossScaleOptimizer` + The original optimizer with loss scaling applied + """ + return mixedprecision.LossScaleOptimizer(optimizer) + + @classmethod + def _set_tf_settings(cls, allow_growth: bool, exclude_devices: List[int]) -> None: + """ Specify Devices to place operations on and Allow TensorFlow to manage VRAM growth. + + Enables the Tensorflow allow_growth option if requested in the command line arguments + + Parameters + ---------- + allow_growth: bool + ``True`` if the Tensorflow allow_growth parameter should be set otherwise ``False`` + exclude_devices: list or ``None`` + List of GPU device indices that should not be made available to Tensorflow. Pass + ``None`` if all devices should be made available + """ + if get_backend() == "amd": + return # No settings for AMD + if get_backend() == "cpu": + logger.verbose("Hiding GPUs from Tensorflow") # type:ignore + tf.config.set_visible_devices([], "GPU") + return + + if not exclude_devices and not allow_growth: + logger.debug("Not setting any specific Tensorflow settings") + return + + gpus = tf.config.list_physical_devices('GPU') + if exclude_devices: + gpus = [gpu for idx, gpu in enumerate(gpus) if idx not in exclude_devices] + logger.debug("Filtering devices to: %s", gpus) + tf.config.set_visible_devices(gpus, "GPU") + + if allow_growth: + logger.debug("Setting Tensorflow 'allow_growth' option") + for gpu in gpus: + logger.info("Setting allow growth for GPU: %s", gpu) + tf.config.experimental.set_memory_growth(gpu, True) + logger.debug("Set Tensorflow 'allow_growth' option") + + @classmethod + def _set_keras_mixed_precision(cls, use_mixed_precision: bool, exclude_gpus: bool) -> bool: + """ Enable the Keras experimental Mixed Precision API. + + Enables the Keras experimental Mixed Precision API if requested in the user configuration + file. + + Parameters + ---------- + use_mixed_precision: bool + ``True`` if experimental mixed precision support should be enabled for Nvidia GPUs + otherwise ``False``. + exclude_gpus: bool + ``True`` If connected GPUs are being excluded otherwise ``False``. + + Returns + ------- + bool + ``True`` if mixed precision has been enabled otherwise ``False`` + """ + logger.debug("use_mixed_precision: %s, exclude_gpus: %s", + use_mixed_precision, exclude_gpus) + if not use_mixed_precision: + logger.debug("Not enabling 'mixed_precision' (backend: %s, use_mixed_precision: %s)", + get_backend(), use_mixed_precision) + return False + logger.info("Enabling Mixed Precision Training.") + + policy = mixedprecision.Policy('mixed_float16') + mixedprecision.set_global_policy(policy) + logger.debug("Enabled mixed precision. (Compute dtype: %s, variable_dtype: %s)", + policy.compute_dtype, policy.variable_dtype) + return True + + @classmethod + def _get_strategy(cls, distributed: bool) -> Optional[tf.distribute.Strategy]: + """ If we are running on Nvidia backend and the strategy is not `"default"` then return + the correct tensorflow distribution strategy, otherwise return ``None``. + + Notes + ----- + By default Tensorflow defaults mirrored strategy to use the Nvidia NCCL method for + reductions, however this is only available in Linux, so the method used falls back to + `Hierarchical Copy All Reduce` if the OS is not Linux. + + Parameters + ---------- + distributed: bool + ``True`` if Tensorflow mirrored strategy should be used for multiple GPU training. + ``False`` if the default strategy should be used. + + Returns + ------- + :class:`tensorflow.distribute.Strategy` or `None` + The request Tensorflow Strategy if the backend is Nvidia and the strategy is not + `"Default"` otherwise ``None`` + """ + if get_backend() != "nvidia": + retval = None + elif distributed: + if platform.system().lower() == "linux": + cross_device_ops = tf.distribute.NcclAllReduce() + else: + cross_device_ops = tf.distribute.HierarchicalCopyAllReduce() + logger.debug("cross_device_ops: %s", cross_device_ops) + retval = tf.distribute.MirroredStrategy(cross_device_ops=cross_device_ops) + else: + retval = tf.distribute.get_strategy() + logger.debug("Using strategy: %s", retval) + return retval + + def strategy_scope(self) -> ContextManager: + """ Return the strategy scope if we have set a strategy, otherwise return a null + context. + + Returns + ------- + :func:`tensorflow.python.distribute.Strategy.scope` or :func:`contextlib.nullcontext` + The tensorflow strategy scope if a strategy is valid in the current scenario. A null + context manager if the strategy is not valid in the current scenario + """ + retval = nullcontext() if self._strategy is None else self._strategy.scope() + logger.debug("Using strategy scope: %s", retval) + return retval diff --git a/plugins/train/model/dfaker.py b/plugins/train/model/dfaker.py index 221ff179b5..3621227b99 100644 --- a/plugins/train/model/dfaker.py +++ b/plugins/train/model/dfaker.py @@ -9,7 +9,7 @@ from .original import Model as OriginalModel, KerasModel if get_backend() == "amd": - from keras.initializers import RandomNormal + from keras.initializers import RandomNormal # pylint:disable=no-name-in-module from keras.layers import Input, LeakyReLU else: # Ignore linting errors from Tensorflow's thoroughly broken import system diff --git a/plugins/train/model/dfl_sae.py b/plugins/train/model/dfl_sae.py index 6f00a96b8f..093dd393e9 100644 --- a/plugins/train/model/dfl_sae.py +++ b/plugins/train/model/dfl_sae.py @@ -2,13 +2,13 @@ """ DeepFaceLab SAE Model Based on https://github.com/iperov/DeepFaceLab """ - +import logging import numpy as np from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock from lib.utils import get_backend -from ._base import ModelBase, KerasModel, logger +from ._base import ModelBase, KerasModel if get_backend() == "amd": from keras.layers import Concatenate, Dense, Flatten, Input, LeakyReLU, Reshape @@ -16,6 +16,8 @@ # Ignore linting errors from Tensorflow's thoroughly broken import system from tensorflow.keras.layers import Concatenate, Dense, Flatten, Input, LeakyReLU, Reshape # noqa pylint:disable=import-error,no-name-in-module +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + class Model(ModelBase): """ SAE Model from DFL """ diff --git a/plugins/train/model/dlight.py b/plugins/train/model/dlight.py index 18808122ff..421edc0cf5 100644 --- a/plugins/train/model/dlight.py +++ b/plugins/train/model/dlight.py @@ -7,12 +7,13 @@ kvrooman for numerous insights and invaluable aid DeepHomage for lots of testing """ +import logging from lib.model.nn_blocks import (Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock, Upscale2xBlock) from lib.utils import FaceswapError, get_backend -from ._base import ModelBase, KerasModel, logger +from ._base import ModelBase, KerasModel if get_backend() == "amd": from keras.layers import ( @@ -24,6 +25,8 @@ AveragePooling2D, BatchNormalization, Concatenate, Dense, Dropout, Flatten, Input, Reshape, LeakyReLU, UpSampling2D) +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + class Model(ModelBase): """ DLight Autoencoder Model """ diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 62468c53e0..d9a2632abf 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -2,6 +2,7 @@ """ Phaze-A Model by TorzDF with thanks to BirbFakes and the myriad of testers. """ # pylint: disable=too-many-lines +import logging import sys from dataclasses import dataclass @@ -22,7 +23,10 @@ RMSNormalization) from lib.utils import get_backend, get_tf_version, FaceswapError -from ._base import KerasModel, ModelBase, logger, _get_all_sub_models +from ._base import KerasModel, ModelBase, get_all_sub_models + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + if get_backend() == "amd": from keras import applications as kapp, backend as K @@ -223,7 +227,7 @@ def _update_dropouts(self, model: keras.models.Model) -> keras.models.Model: gblock=self.config["fc_gblock_dropout"]) logger.debug("Config dropouts: %s", dropouts) updated = False - for mod in _get_all_sub_models(model): + for mod in get_all_sub_models(model): if not mod.name.startswith("fc_"): continue key = "gblock" if "gblock" in mod.name else mod.name.split("_")[0] diff --git a/plugins/train/model/realface.py b/plugins/train/model/realface.py index fd1aa73569..25e7ccd7a8 100644 --- a/plugins/train/model/realface.py +++ b/plugins/train/model/realface.py @@ -7,20 +7,23 @@ Additional thanks: Birb - source of inspiration, great Encoder ideas Kvrooman - additional counseling on auto-encoders and practical advice """ +import logging import sys from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock from lib.utils import get_backend -from ._base import ModelBase, KerasModel, logger +from ._base import ModelBase, KerasModel if get_backend() == "amd": - from keras.initializers import RandomNormal + from keras.initializers import RandomNormal # pylint:disable=no-name-in-module from keras.layers import Dense, Flatten, Input, LeakyReLU, Reshape else: # Ignore linting errors from Tensorflow's thoroughly broken import system from tensorflow.keras.initializers import RandomNormal # noqa pylint:disable=import-error,no-name-in-module from tensorflow.keras.layers import Dense, Flatten, Input, LeakyReLU, Reshape # noqa pylint:disable=import-error,no-name-in-module +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + class Model(ModelBase): """ RealFace(tm) Faceswap Model """ diff --git a/plugins/train/model/unbalanced.py b/plugins/train/model/unbalanced.py index 9330f0f4c0..b68535d2e6 100644 --- a/plugins/train/model/unbalanced.py +++ b/plugins/train/model/unbalanced.py @@ -8,7 +8,7 @@ from ._base import ModelBase, KerasModel if get_backend() == "amd": - from keras.initializers import RandomNormal + from keras.initializers import RandomNormal # pylint:disable=no-name-in-module from keras.layers import Dense, Flatten, Input, LeakyReLU, Reshape, SpatialDropout2D else: # Ignore linting errors from Tensorflow's thoroughly broken import system diff --git a/plugins/train/model/villain.py b/plugins/train/model/villain.py index 16efdc6c4d..863a782ad7 100644 --- a/plugins/train/model/villain.py +++ b/plugins/train/model/villain.py @@ -11,7 +11,7 @@ from .original import Model as OriginalModel, KerasModel if get_backend() == "amd": - from keras.initializers import RandomNormal + from keras.initializers import RandomNormal # pylint:disable=no-name-in-module from keras.layers import add, Dense, Flatten, Input, LeakyReLU, Reshape else: # Ignore linting errors from Tensorflow's thoroughly broken import system diff --git a/tests/lib/model/losses_test.py b/tests/lib/model/losses_test.py index 7c83b8755e..9a2b89eedb 100644 --- a/tests/lib/model/losses_test.py +++ b/tests/lib/model/losses_test.py @@ -38,7 +38,7 @@ def test_loss_output(loss_func, output_shape): assert K.eval(objective_output).shape == output_shape else: output = objective_output.numpy() - assert output.dtype == "float32" and not np.isnan(output) + assert output.dtype == "float32" and not np.any(np.isnan(output)) _LWPARAMS = [losses.GeneralizedLoss(), losses.GradientLoss(), losses.GMSDLoss(), @@ -69,4 +69,4 @@ def test_loss_wrapper(loss_func): assert K.dtype(output) == "float32" and K.eval(output).shape == (2, ) else: output = output.numpy() - assert output.dtype == "float32" and not np.isnan(output) + assert output.dtype == "float32" and not np.any(np.isnan(output)) diff --git a/tests/lib/model/optimizers_test.py b/tests/lib/model/optimizers_test.py index b85581c43e..c86aae5918 100644 --- a/tests/lib/model/optimizers_test.py +++ b/tests/lib/model/optimizers_test.py @@ -90,4 +90,4 @@ def test_adam(dummy): # pylint:disable=unused-argument @pytest.mark.parametrize("dummy", [None], ids=[get_backend().upper()]) def test_adabelief(dummy): # pylint:disable=unused-argument """ Test for custom Adam optimizer """ - _test_optimizer(optimizers.AdaBelief(), target=0.45) + _test_optimizer(optimizers.AdaBelief(), target=0.20) diff --git a/_travis/simple_tests.py b/tests/simple_tests.py similarity index 100% rename from _travis/simple_tests.py rename to tests/simple_tests.py From 98a65277d8c55cfcbdbfa629f790a8f8731621a8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 17 Jun 2022 17:59:55 +0100 Subject: [PATCH 621/981] Fix AMD Tests + docs --- .github/workflows/pytest.yml | 23 +++++++++++---- docs/full/plugins/train.rst | 27 ++++++++++++++---- docs/sphinx_requirements.txt | 1 + lib/gpu_stats/_base.py | 4 +-- lib/gpu_stats/amd.py | 53 ++++++++++++++++++++++++++-------- setup.cfg | 8 ++++++ tests/simple_tests.py | 55 ++++++++++++++++-------------------- 7 files changed, 115 insertions(+), 56 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 7f2e289027..6b57b473a6 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -10,7 +10,15 @@ jobs: fail-fast: false matrix: python-version: ["3.7", "3.8", "3.9"] - + backend: ["amd", "cpu"] + include: + - kbackend: "plaidml.keras.backend" + backend: "amd" + - kbackend: "tensorflow" + backend: "cpu" + exclude: + - python-version: 3.9 + backend: amd steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} @@ -21,17 +29,20 @@ jobs: run: | python -m pip install --upgrade pip pip install flake8 pylint mypy pytest wheel - pip install -r ./requirements/requirements_cpu.txt + pip install -r ./requirements/requirements_${{ matrix.backend }}.txt - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + # exit-zero treats all errors as warnings. + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=100 --statistics - name: Simple Tests run: | - FACESWAP_BACKEND="cpu" KERAS_BACKEND="tensorflow" py.test -v tests/; + if [ "${{ matrix.backend }}" == "amd" ] ; then echo "{\"PLAIDML_DEVICE_IDS\":[\"llvm_cpu.0\"],\"PLAIDML_EXPERIMENTAL\":true}" > ~/.plaidml; fi ; + echo "{\"PLAIDML_DEVICE_IDS\":[\"llvm_cpu.0\"],\"PLAIDML_EXPERIMENTAL\":true}" > ~/.plaidml; + FACESWAP_BACKEND="${{ matrix.backend }}" KERAS_BACKEND="${{ matrix.kbackend }}" py.test -v tests/; - name: End to End Tests run: | - FACESWAP_BACKEND="cpu" KERAS_BACKEND="tensorflow" python tests/simple_tests.py; + FACESWAP_BACKEND="${{ matrix.backend }}" KERAS_BACKEND="${{ matrix.kbackend }}" python tests/simple_tests.py; + if [ "${{ matrix.backend }}" == "amd" ] ; then rm -f ~/.plaidml; fi ; \ No newline at end of file diff --git a/docs/full/plugins/train.rst b/docs/full/plugins/train.rst index 4f0675d23b..a449168fa1 100755 --- a/docs/full/plugins/train.rst +++ b/docs/full/plugins/train.rst @@ -16,13 +16,30 @@ model._base module .. autosummary:: :nosignatures: - ~plugins.train.model._base.KerasModel - ~plugins.train.model._base.ModelBase - ~plugins.train.model._base.State + ~plugins.train.model._base.model + ~plugins.train.model._base.settings + ~plugins.train.model._base.io -.. rubric:: Module +model._base.model module +======================== -.. automodule:: plugins.train.model._base +.. automodule:: plugins.train.model._base.model + :members: + :undoc-members: + :show-inheritance: + +model._base.settings module +=========================== + +.. automodule:: plugins.train.model._base.settings + :members: + :undoc-members: + :show-inheritance: + +model._base.io module +===================== + +.. automodule:: plugins.train.model._base.io :members: :undoc-members: :show-inheritance: diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index 83f2db1e56..2f84ab7cca 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -17,3 +17,4 @@ pywin32==228 ; sys_platform == "win32" pynvx==1.0.0 ; sys_platform == "darwin" plaidml-keras==0.7.0 tensorflow==2.2.0 +typing-extensions diff --git a/lib/gpu_stats/_base.py b/lib/gpu_stats/_base.py index 286de83160..9fb0680768 100644 --- a/lib/gpu_stats/_base.py +++ b/lib/gpu_stats/_base.py @@ -14,8 +14,6 @@ from typing_extensions import TypedDict else: from typing import TypedDict - - _EXCLUDE_DEVICES: List[int] = [] @@ -25,7 +23,7 @@ class GPUInfo(TypedDict): vram: List[int] driver: str devices: List[str] - active_devices: List[str] + devices_active: List[str] class BiggestGPUInfo(TypedDict): diff --git a/lib/gpu_stats/amd.py b/lib/gpu_stats/amd.py index 6d81d17852..48a6157e04 100644 --- a/lib/gpu_stats/amd.py +++ b/lib/gpu_stats/amd.py @@ -30,12 +30,12 @@ def setup_plaidml(log_level: str, exclude_devices: List[int]) -> None: """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name logger.info("Setting up for PlaidML") - logger.verbose("Setting Keras Backend to PlaidML") + logger.verbose("Setting Keras Backend to PlaidML") # type:ignore # Add explicitly excluded devices to list. The contents are checked in AMDstats if exclude_devices: _EXCLUDE_DEVICES.extend(int(idx) for idx in exclude_devices) os.environ["KERAS_BACKEND"] = "plaidml.keras.backend" - stats = AMDStats(log_level) + stats = AMDStats(log_level=log_level) logger.info("Using GPU(s): %s", [stats.names[i] for i in stats.active_devices]) logger.info("Successfully set up for PlaidML") @@ -70,10 +70,10 @@ def __init__(self, log: bool = True, log_level: str = "INFO") -> None: self._log_level: str = log_level.upper() # Following attributes are set in :func:``_initialize`` - self._ctx: Optional(plaidml.Context) = None - self._supported_devices: Optional(List[plaidml._DeviceConfig]) = None - self._all_devices: Optional(List[plaidml._DeviceConfig]) = None - self._device_details: Optional(List[dict]) = None + self._ctx: Optional[plaidml.Context] = None + self._supported_devices: List[plaidml._DeviceConfig] = [] + self._all_devices: List[plaidml._DeviceConfig] = [] + self._device_details: List[dict] = [] super().__init__(log=log) @@ -182,7 +182,6 @@ def _get_supported_devices(self) -> List[plaidml._DeviceConfig]: plaidml.settings.experimental = False devices = plaidml.devices(self._ctx, limit=100, return_all=True)[0] - plaidml.settings.experimental = experimental_setting supported = [d for d in devices @@ -201,10 +200,8 @@ def _get_all_devices(self) -> List[plaidml._DeviceConfig]: The :class:`pladml._DeviceConfig` objects for GPUs that PlaidML has discovered. """ experimental_setting = plaidml.settings.experimental - plaidml.settings.experimental = True devices = plaidml.devices(self._ctx, limit=100, return_all=True)[0] - plaidml.settings.experimental = experimental_setting experi = [d for d in devices @@ -214,10 +211,38 @@ def _get_all_devices(self) -> List[plaidml._DeviceConfig]: self._log("debug", f"Obtained experimental Devices: {experi}") all_devices = experi + self._supported_devices + all_devices = all_devices if all_devices else self._get_fallback_devices() # Use CPU self._log("debug", f"Obtained all Devices: {all_devices}") return all_devices + def _get_fallback_devices(self) -> List[plaidml._DeviceConfig]: + """ Called if a GPU has not been discovered. Return any devices we can run on. + + Returns + ------- + list: + The :class:`pladml._DeviceConfig` fallaback objects that PlaidML has discovered. + """ + # Try get a supported device + experimental_setting = plaidml.settings.experimental + plaidml.settings.experimental = False + devices = plaidml.devices(self._ctx, limit=100, return_all=True)[0] + + # Try get any device + if not devices: + plaidml.settings.experimental = True + devices = plaidml.devices(self._ctx, limit=100, return_all=True)[0] + + plaidml.settings.experimental = experimental_setting + + if not devices: + raise RuntimeError("No valid devices could be found for plaidML.") + + self._log("warning", f"PlaidML could not find a GPU. Falling back to: " + f"{[d.id.decode('utf-8') for d in devices]}") + return devices + def _get_device_details(self) -> List[dict]: """ Obtain the device details for all connected AMD GPUS. @@ -226,8 +251,14 @@ def _get_device_details(self) -> List[dict]: list The `dict` device detail for all GPUs that PlaidML has discovered. """ - details = [json.loads(d.details.decode("utf-8")) - for d in self._all_devices if d.details] + details = [] + for dev in self._all_devices: + if dev.details: + details.append(json.loads(dev.details.decode("utf-8"))) + else: + details.append(dict(vendor=dev.id.decode("utf-8"), + name=dev.description.decode("utf-8"), + globalMemSize=4 * 1024 * 1024 * 1024)) # 4GB dummy ram self._log("debug", f"Obtained Device details: {details}") return details diff --git a/setup.cfg b/setup.cfg index 13459d1465..3526b979e1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,11 @@ [flake8] max-line-length = 99 exclude = .git, __pycache__ + +[mypy] +[mypy-tensorflow.*] +ignore_missing_imports = True +[mypy-keras.*] +ignore_missing_imports = True +[mypy-plaidml.*] +ignore_missing_imports = True diff --git a/tests/simple_tests.py b/tests/simple_tests.py index f908675e03..7e90aedd1b 100644 --- a/tests/simple_tests.py +++ b/tests/simple_tests.py @@ -31,9 +31,8 @@ def print_colored(text, color="OK", bold=False): although travis runs windows stuff in git bash, so it might ? """ color = _COLORS.get(color, color) - print("%s%s%s%s" % ( - color, "" if not bold else _COLORS["BOLD"], text, _COLORS["ENDC"] - )) + fmt = '' if not bold else _COLORS['BOLD'] + print(f"{color}{fmt}{text}{_COLORS['ENDC']}") def print_ok(text): @@ -54,15 +53,15 @@ def print_status(text): def run_test(name, cmd): """ run a test """ global FAIL_COUNT, TEST_COUNT # pylint:disable=global-statement - print_status("[?] running %s" % name) - print("Cmd: %s" % " ".join(cmd)) + print_status(f"[?] running {name}") + print(f"Cmd: {''.join(cmd)}") TEST_COUNT += 1 try: check_call(cmd) print_ok("[+] Test success") return True except CalledProcessError as err: - print_fail("[-] Test failed with %s" % err) + print_fail(f"[-] Test failed with {err}") FAIL_COUNT += 1 return False @@ -70,54 +69,50 @@ def run_test(name, cmd): def download_file(url, filename): # TODO: retry """ Download a file from given url """ if os.path.isfile(filename): - print_status("[?] '%s' already cached as '%s'" % (url, filename)) + print_status(f"[?] '{url}' already cached as '{filename}'") return filename try: - print_status("[?] Downloading '%s' to '%s'" % (url, filename)) + print_status(f"[?] Downloading '{url}' to '{filename}'") video, _ = urlretrieve(url, filename) return video except urllib.error.URLError as err: - print_fail("[-] Failed downloading: %s" % err) + print_fail(f"[-] Failed downloading: {err}") return None def extract_args(detector, aligner, in_path, out_path, args=None): """ Extraction command """ py_exe = sys.executable - _extract_args = "%s faceswap.py extract -i %s -o %s -D %s -A %s" % ( - py_exe, in_path, out_path, detector, aligner - ) + _extract_args = (f"{py_exe} faceswap.py extract -i {in_path} -o {out_path} -D {detector} " + f"-A {aligner}") if args: - _extract_args += " %s" % args + _extract_args += f" {args}" return _extract_args.split() -def train_args(model, model_path, faces, alignments, iterations=5, batchsize=8, extra_args=""): +def train_args(model, model_path, faces, iterations=1, batchsize=4, extra_args=""): """ Train command """ py_exe = sys.executable - args = "%s faceswap.py train -A %s -B %s -m %s -t %s -bs %i -it %s %s" % ( - py_exe, faces, faces, model_path, model, batchsize, iterations, extra_args - ) + args = (f"{py_exe} faceswap.py train -A {faces} -B {faces} -m {model_path} -t {model} " + f"-bs {batchsize} -it {iterations} {extra_args}") return args.split() def convert_args(in_path, out_path, model_path, writer, args=None): """ Convert command """ py_exe = sys.executable - conv_args = "%s faceswap.py convert -i %s -o %s -m %s -w %s" % ( - py_exe, in_path, out_path, model_path, writer - ) + conv_args = (f"{py_exe} faceswap.py convert -i {in_path} -o {out_path} -m {model_path} " + f"-w {writer}") if args: - conv_args += " %s" % args + conv_args += f" {args}" return conv_args.split() # Don't use pathes with spaces ;) def sort_args(in_path, out_path, sortby="face", groupby="hist", method="rename"): """ Sort command """ py_exe = sys.executable - _sort_args = "%s tools.py sort -i %s -o %s -s %s -fp %s -g %s -k" % ( - py_exe, in_path, out_path, sortby, method, groupby - ) + _sort_args = (f"{py_exe} tools.py sort -i {in_path} -o {out_path} -s {sortby} -fp {method} " + f"-g {groupby} -k") return _sort_args.split() @@ -174,16 +169,14 @@ def main(): "Train lightweight model for 1 iteration with WTL.", train_args( "lightweight", pathjoin(vid_base, "model"), - pathjoin(vid_base, "faces"), pathjoin(vid_base, "test_alignments.fsa"), - iterations=1, extra_args="-wl" + pathjoin(vid_base, "faces"), extra_args="-wl" ) ) was_trained = run_test( - "Train lightweight model for 5 iterations WITHOUT WTL.", + "Train lightweight model for 1 iterations WITHOUT WTL.", train_args( - "lightweight", pathjoin(vid_base, "model"), - pathjoin(vid_base, "faces"), pathjoin(vid_base, "test_alignments.fsa") + "lightweight", pathjoin(vid_base, "model"), pathjoin(vid_base, "faces") ) ) @@ -205,10 +198,10 @@ def main(): ) if FAIL_COUNT == 0: - print_ok("[+] Failed %i/%i tests." % (FAIL_COUNT, TEST_COUNT)) + print_ok(f"[+] Failed {FAIL_COUNT}/{TEST_COUNT} tests.") sys.exit(0) else: - print_fail("[-] Failed %i/%i tests." % (FAIL_COUNT, TEST_COUNT)) + print_fail(f"[-] Failed {FAIL_COUNT}/{TEST_COUNT} tests.") sys.exit(1) From 94c3dcff7ebd02a5a5758f33a3eb2bfc66282117 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 17 Jun 2022 20:24:26 +0100 Subject: [PATCH 622/981] Training updates - Add multiple selected loss functions - Unlock loss as a model configuration - Phaze-A remove encoder scaling max xap --- .github/workflows/pytest.yml | 4 +- lib/model/losses_plaid.py | 10 +- lib/model/losses_tf.py | 9 +- plugins/train/_config.py | 220 ++++++++++++++++++-------- plugins/train/model/_base/model.py | 16 +- plugins/train/model/_base/settings.py | 57 ++++--- plugins/train/model/phaze_a.py | 12 +- setup.cfg | 3 + 8 files changed, 221 insertions(+), 110 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 6b57b473a6..b456d37492 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -33,9 +33,9 @@ jobs: - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 . --select=E9,F63,F7,F82 --show-source # exit-zero treats all errors as warnings. - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=100 --statistics + flake8 . --exit-zero - name: Simple Tests run: | if [ "${{ matrix.backend }}" == "amd" ] ; then echo "{\"PLAIDML_DEVICE_IDS\":[\"llvm_cpu.0\"],\"PLAIDML_EXPERIMENTAL\":true}" > ~/.plaidml; fi ; diff --git a/lib/model/losses_plaid.py b/lib/model/losses_plaid.py index 1d5cf5442b..816718ef10 100644 --- a/lib/model/losses_plaid.py +++ b/lib/model/losses_plaid.py @@ -649,10 +649,12 @@ def _apply_mask(cls, y_true, y_pred, mask_channel, mask_prop=1.0): return y_true[..., :3], y_pred[..., :3] logger.debug("Applying mask from channel %s", mask_channel) - mask = K.expand_dims(y_true[..., mask_channel], axis=-1) + + mask = K.tile(K.expand_dims(y_true[..., mask_channel], axis=-1), (1, 1, 1, 3)) mask_as_k_inv_prop = 1 - mask_prop mask = (mask * mask_prop) + mask_as_k_inv_prop - n_true = y_true[..., :3] * mask - n_pred = y_pred * mask - return n_true, n_pred + m_true = y_true[..., :3] * mask + m_pred = y_pred[..., :3] * mask + + return m_true, m_pred diff --git a/lib/model/losses_tf.py b/lib/model/losses_tf.py index cd5aa132d9..5afe5ee4fc 100644 --- a/lib/model/losses_tf.py +++ b/lib/model/losses_tf.py @@ -588,11 +588,12 @@ def _apply_mask(cls, return y_true[..., :3], y_pred[..., :3] logger.debug("Applying mask from channel %s", mask_channel) - mask = K.expand_dims(y_true[..., mask_channel], axis=-1) + + mask = K.tile(K.expand_dims(y_true[..., mask_channel], axis=-1), (1, 1, 1, 3)) mask_as_k_inv_prop = 1 - mask_prop mask = (mask * mask_prop) + mask_as_k_inv_prop - n_true = K.concatenate([y_true[..., i:i + 1] * mask for i in range(3)], axis=-1) - n_pred = K.concatenate([y_pred[..., i:i + 1] * mask for i in range(3)], axis=-1) + m_true = y_true[..., :3] * mask + m_pred = y_pred[..., :3] * mask - return n_true, n_pred + return m_true, m_pred diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 0fd195a1bb..182347c689 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -12,18 +12,60 @@ ADDITIONAL_INFO = ("\nNB: Unless specifically stated, values changed here will only take effect " "when creating a new model.") +_LOSS_HELP = dict( + gmsd=( + "Gradient Magnitude Similarity Deviation seeks to match the global standard deviation of " + "the pixel to pixel differences between two images. Similar in approach to SSIM. NB: This " + "loss does not currently work on AMD cards."), + l_inf_norm=( + "The L_inf norm will reduce the largest individual pixel error in an image. As " + "each largest error is minimized sequentially, the overall error is improved. This loss " + "will be extremely focused on outliers."), + logcosh=( + "log(cosh(x)) acts similar to MSE for small errors and to MAE for large errors. Like " + "MSE, it is very stable and prevents overshoots when errors are near zero. Like MAE, it " + "is robust to outliers. NB: Due to a bug in PlaidML, this loss does not work on AMD " + "cards."), + mae=( + "Mean absolute error will guide reconstructions of each pixel towards its median value in " + "the training dataset. Robust to outliers but as a median, it can potentially ignore some " + "infrequent image types in the dataset."), + mse=( + "Mean squared error will guide reconstructions of each pixel towards its average value in " + "the training dataset. As an avg, it will be susceptible to outliers and typically " + "produces slightly blurrier results."), + ms_ssim=( + "Multiscale Structural Similarity Index Metric is similar to SSIM except that it " + "performs the calculations along multiple scales of the input image. NB: This loss " + "currently does not work on AMD Cards."), + smooth_loss=( + "Smooth_L1 is a modification of the MAE loss to correct two of its disadvantages. " + "This loss has improved stability and guidance for small errors."), + ssim=( + "Structural Similarity Index Metric is a perception-based loss that considers changes in " + "texture, luminance, contrast, and local spatial statistics of an image. Potentially " + "delivers more realistic looking images."), + pixel_gradient_diff=( + "Instead of minimizing the difference between the absolute value of each " + "pixel in two reference images, compute the pixel to pixel spatial difference in each " + "image and then minimize that difference between two images. Allows for large color " + "shifts, but maintains the structure of the image."), + none="Do not use an additional loss function.") + +_NON_PRIMARY_LOSS = ["none"] + class Config(FaceswapConfig): """ Config File for Models """ # pylint: disable=too-many-statements - def set_defaults(self): + def set_defaults(self) -> None: """ Set the default values for config """ logger.debug("Setting defaults") self._set_globals() self._set_loss() self._defaults_from_plugin(os.path.dirname(__file__)) - def _set_globals(self): + def _set_globals(self) -> None: """ Set the global options for training """ logger.debug("Setting global config") section = "global" @@ -220,23 +262,21 @@ def _set_globals(self): "convert speed, however, if you are getting Out of Memory errors, then you may " "want to reduce the batch size.") - def _set_loss(self): + def _set_loss(self) -> None: + # pylint:disable=line-too-long """ Set the default loss options. Loss Documentation - MAE https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine - -learners-should-know-4fb140e9d4b0 - MSE https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine - -learners-should-know-4fb140e9d4b0 - LogCosh https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine - -learners-should-know-4fb140e9d4b0 + MAE https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine-learners-should-know-4fb140e9d4b0 + MSE https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine-learners-should-know-4fb140e9d4b0 + LogCosh https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine-learners-should-know-4fb140e9d4b0 Smooth L1 https://arxiv.org/pdf/1701.03077.pdf - L_inf_norm https://medium.com/@montjoile/l0-norm-l1-norm-l2-norm-l-infinity - -norm-7a7d18a4f40c + L_inf_norm https://medium.com/@montjoile/l0-norm-l1-norm-l2-norm-l-infinity-norm-7a7d18a4f40c SSIM http://www.cns.nyu.edu/pub/eero/wang03-reprint.pdf MSSIM https://www.cns.nyu.edu/pub/eero/wang03b.pdf GMSD https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf - """ + """ # noqa + # pylint:enable=line-too-long logger.debug("Setting Loss config") section = "global.loss" self.add_section(title=section, @@ -249,45 +289,113 @@ def _set_loss(self): datatype=str, group="loss", default="ssim", - choices=["mae", "mse", "logcosh", "smooth_loss", "l_inf_norm", "ssim", "ms_ssim", - "gmsd", "pixel_gradient_diff"], - info="The loss function to use." - "\n\t MAE - Mean absolute error will guide reconstructions of each pixel " - "towards its median value in the training dataset. Robust to outliers but as " - "a median, it can potentially ignore some infrequent image types in the dataset." - "\n\t MSE - Mean squared error will guide reconstructions of each pixel " - "towards its average value in the training dataset. As an avg, it will be " - "susceptible to outliers and typically produces slightly blurrier results." - "\n\t LogCosh - log(cosh(x)) acts similar to MSE for small errors and to " - "MAE for large errors. Like MSE, it is very stable and prevents overshoots " - "when errors are near zero. Like MAE, it is robust to outliers. NB: Due to a bug " - "in PlaidML, this loss does not work on AMD cards." - "\n\t Smooth_L1 --- Modification of the MAE loss to correct two of its " - "disadvantages. This loss has improved stability and guidance for small errors." - "\n\t L_inf_norm --- The L_inf norm will reduce the largest individual pixel " - "error in an image. As each largest error is minimized sequentially, the " - "overall error is improved. This loss will be extremely focused on outliers." - "\n\t SSIM - Structural Similarity Index Metric is a perception-based " - "loss that considers changes in texture, luminance, contrast, and local spatial " - "statistics of an image. Potentially delivers more realistic looking images." - "\n\t MS_SSIM - Multiscale Structural Similarity Index Metric is similar to SSIM " - "except that it performs the calculations along multiple scales of the input " - "image. NB: This loss currently does not work on AMD Cards." - "\n\t GMSD - Gradient Magnitude Similarity Deviation seeks to match " - "the global standard deviation of the pixel to pixel differences between two " - "images. Similar in approach to SSIM. NB: This loss does not currently work on " - "AMD cards." - "\n\t Pixel_Gradient_Difference - Instead of minimizing the difference between " - "the absolute value of each pixel in two reference images, compute the pixel to " - "pixel spatial difference in each image and then minimize that difference " - "between two images. Allows for large color shifts, but maintains the structure " - "of the image.") + fixed=False, + choices=[x for x in sorted(_LOSS_HELP) if x not in _NON_PRIMARY_LOSS], + info="The loss function to use.\n\n\t" + + "\n\t".join(f"{k}: {v}" + for k, v in sorted(_LOSS_HELP.items()) if k not in _NON_PRIMARY_LOSS)) + self.add_item( + section=section, + title="loss_function_2", + datatype=str, + group="loss", + default="mse", + fixed=False, + choices=list(sorted(_LOSS_HELP)), + info="The second loss function to use. If using a structural based loss (such as " + "SSIM, MS-SSIM or GMSD) it is common to add an L1 regularization(MAE) or L2 " + "regularization (MSE) function. You can adjust the weighting of this loss " + "function with the loss_weight_2 option.\n\n\t" + + "\n\t".join(f"{k}: {v}" + for k, v in sorted(_LOSS_HELP.items()))) + self.add_item( + section=section, + title="loss_weight_2", + datatype=int, + group="loss", + min_max=(0, 400), + rounding=1, + default=100, + fixed=False, + info="The amount of weight to apply to the second loss function.\n\n" + "\n\nThe value given here is as a percentage denoting how much the selected " + "function should contribute to the overall loss cost of the model. For example:" + "\n\t 100 - The loss calculated for the second loss function will be applied at " + "its full amount towards the overall loss score. " + "\n\t 25 - The loss calculated for the second loss function will be reduced by a " + "quarter prior to adding to the overall loss score. " + "\n\t 400 - The loss calculated for the second loss function will be mulitplied " + "4 times prior to adding to the overall loss score. " + "\n\t 0 - Disables the second loss function altogether.") + self.add_item( + section=section, + title="loss_function_3", + datatype=str, + group="loss", + default="none", + fixed=False, + choices=list(sorted(_LOSS_HELP)), + info="The third loss function to use. You can adjust the weighting of this loss " + "function with the loss_weight_3 option.\n\n\t" + + "\n\t".join(f"{k}: {v}" + for k, v in sorted(_LOSS_HELP.items()))) + self.add_item( + section=section, + title="loss_weight_3", + datatype=int, + group="loss", + min_max=(0, 400), + rounding=1, + default=0, + fixed=False, + info="The amount of weight to apply to the third loss function.\n\n" + "\n\nThe value given here is as a percentage denoting how much the selected " + "function should contribute to the overall loss cost of the model. For example:" + "\n\t 100 - The loss calculated for the third loss function will be applied at " + "its full amount towards the overall loss score. " + "\n\t 25 - The loss calculated for the third loss function will be reduced by a " + "quarter prior to adding to the overall loss score. " + "\n\t 400 - The loss calculated for the third loss function will be mulitplied 4 " + "times prior to adding to the overall loss score. " + "\n\t 0 - Disables the third loss function altogether.") + self.add_item( + section=section, + title="loss_function_4", + datatype=str, + group="loss", + default="none", + fixed=False, + choices=list(sorted(_LOSS_HELP)), + info="The fourth loss function to use. You can adjust the weighting of this loss " + "function with the loss_weight_3 option.\n\n\t" + + "\n\t".join(f"{k}: {v}" + for k, v in sorted(_LOSS_HELP.items()))) + self.add_item( + section=section, + title="loss_weight_4", + datatype=int, + group="loss", + min_max=(0, 400), + rounding=1, + default=0, + fixed=False, + info="The amount of weight to apply to the fourth loss function.\n\n" + "\n\nThe value given here is as a percentage denoting how much the selected " + "function should contribute to the overall loss cost of the model. For example:" + "\n\t 100 - The loss calculated for the fourth loss function will be applied at " + "its full amount towards the overall loss score. " + "\n\t 25 - The loss calculated for the fourth loss function will be reduced by a " + "quarter prior to adding to the overall loss score. " + "\n\t 400 - The loss calculated for the fourth loss function will be mulitplied " + "4 times prior to adding to the overall loss score. " + "\n\t 0 - Disables the fourth loss function altogether.") self.add_item( section=section, title="mask_loss_function", datatype=str, group="loss", default="mse", + fixed=False, choices=["mae", "mse"], info="The loss function to use when learning a mask." "\n\t MAE - Mean absolute error will guide reconstructions of each pixel " @@ -296,28 +404,6 @@ def _set_loss(self): "\n\t MSE - Mean squared error will guide reconstructions of each pixel " "towards its average value in the training dataset. As an average, it will be " "susceptible to outliers and typically produces slightly blurrier results.") - self.add_item( - section=section, - title="l2_reg_term", - datatype=int, - group="loss", - min_max=(0, 400), - rounding=1, - default=100, - info="The amount of L2 Regularization to apply as a penalty to Structural Similarity " - "loss functions.\n\nNB: You should only adjust this if you know what you are " - "doing!\n\n" - "L2 regularization applies a penalty term to the given Loss function. This " - "penalty will only be applied if SSIM, MS-SSIM or GMSD is selected for the main " - "loss function, otherwise it is ignored." - "\n\nThe value given here is as a percentage weight of the main loss function. " - "For example:" - "\n\t 100 - Will give equal weighting to the main loss and the penalty function. " - "\n\t 25 - Will give the penalty function 1/4 of the weight of the main loss " - "function. " - "\n\t 400 - Will give the penalty function 4x as much importance as the main " - "loss function." - "\n\t 0 - Disables L2 Regularization altogether.") self.add_item( section=section, title="eye_multiplier", diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 7a01a472b5..e3f993dd40 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -695,6 +695,9 @@ def _update_legacy_config(self) -> bool: * loss - If old `dssim_loss` is ``true`` set new `loss_function` to `ssim` otherwise set it to `mae`. Remove old `dssim_loss` item + * l2_reg_term - If this exists, set loss_function_2 to ``mse`` and loss_weight_2 to + the value held in the old ``l2_reg_term`` item + * masks - If `learn_mask` does not exist then it is set to ``True`` if `mask_type` is not ``None`` otherwise it is set to ``False``. @@ -706,8 +709,8 @@ def _update_legacy_config(self) -> bool: ``True`` if legacy items exist and state file has been updated, otherwise ``False`` """ logger.debug("Checking for legacy state file update") - priors = ["dssim_loss", "mask_type", "mask_type"] - new_items = ["loss_function", "learn_mask", "mask_type"] + priors = ["dssim_loss", "mask_type", "mask_type", "l2_reg_term"] + new_items = ["loss_function", "learn_mask", "mask_type", "loss_function_2"] updated = False for old, new in zip(priors, new_items): if old not in self._config: @@ -740,6 +743,15 @@ def _update_legacy_config(self) -> bool: logger.info("Updated 'mask_type' from '%s' to '%s' for this model", old_mask, self._config[new]) + # Replace l2_reg_term with the correct loss_2_function and update the value of + # loss_2_weight + if old == "l2_reg_term": + self._config[new] = "mse" + self._config["loss_weight_2"] = self._config[old] + del self._config[old] + updated = True + logger.info("Updated config from legacy 'l2_reg_term' to 'loss_function_2'") + logger.debug("State file updated for legacy config: %s", updated) return updated diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 59caadf2ce..17461e2db7 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -62,7 +62,6 @@ def __init__(self, config: dict) -> None: ms_ssim=losses.MSSSIMLoss(), gmsd=losses.GMSDLoss(), pixel_gradient_diff=losses.GradientLoss()) - self._uses_l2_reg = ["ssim", "ms_ssim", "gmsd"] self._mask_channels = self._get_mask_channels() self._inputs: List[keras.layers.Layer] = [] self._names: List[str] = [] @@ -149,47 +148,55 @@ def _set_loss_functions(self, output_names: List[str]): output_names: list The output names from the model """ - face_loss = self._loss_dict[self._config["loss_function"]] + face_losses = [(self._loss_dict[v], self._config.get(f"loss_weight_{k[-1]}", 100)) + for k, v in sorted(self._config.items()) + if k.startswith("loss_function") + and self._config.get(f"loss_weight_{k[-1]}", 100) != 0 + and v is not None] for name, output_name in zip(self._names, output_names): if name.startswith("mask"): loss_func = self._loss_dict[self._config["mask_loss_function"]] else: loss_func = losses.LossWrapper() - loss_func.add_loss(face_loss, mask_channel=self._mask_channels[0]) - self._add_l2_regularization_term(loss_func, self._mask_channels[0]) - - channel_idx = 1 - for multiplier in ("eye_multiplier", "mouth_multiplier"): - mask_channel = self._mask_channels[channel_idx] - if self._config[multiplier] > 1: - loss_func.add_loss(face_loss, - weight=self._config[multiplier] * 1.0, - mask_channel=mask_channel) - self._add_l2_regularization_term(loss_func, mask_channel) - channel_idx += 1 + for func, weight in face_losses: + self._add_face_loss_function(loss_func, func, weight / 100.) logger.debug("%s: (output_name: '%s', function: %s)", name, output_name, loss_func) self._funcs[output_name] = loss_func logger.debug("functions: %s", self._funcs) - def _add_l2_regularization_term(self, loss_wrapper, mask_channel): - """ Check if an L2 Regularization term should be added and add to the loss function - wrapper. + def _add_face_loss_function(self, + loss_wrapper: losses.LossWrapper, + loss_function: Callable, + weight: float) -> None: + """ Add the given face loss function at the given weight and apply any mouth and eye + multipliers Parameters ---------- loss_wrapper: :class:`lib.model.losses.LossWrapper` The wrapper loss function that holds the face losses - mask_channel: int - The channel that holds the mask in `y_true`, if a mask is used for the loss. - `-1` if the input is not masked + loss_func: :class:`keras.losses.Loss` + The loss function to add to the loss wrapper + weight: float + The amount of weight to apply to the given loss function """ - if self._config["loss_function"] in self._uses_l2_reg and self._config["l2_reg_term"] > 0: - logger.debug("Adding L2 Regularization for Structural Loss") - loss_wrapper.add_loss(self._loss_dict["mse"], - weight=self._config["l2_reg_term"] / 100.0, - mask_channel=mask_channel) + logger.debug("Adding loss function: %s, weight: %s", loss_function, weight) + loss_wrapper.add_loss(loss_function, + weight=weight, + mask_channel=self._mask_channels[0]) + + channel_idx = 1 + for section in ("eye_multiplier", "mouth_multiplier"): + mask_channel = self._mask_channels[channel_idx] + multiplier = self._config[section] * 1. + if multiplier > 1.: + logger.debug("Adding section loss %s: %s", section, multiplier) + loss_wrapper.add_loss(loss_function, + weight=weight * multiplier, + mask_channel=mask_channel) + channel_idx += 1 def _get_mask_channels(self) -> List[int]: """ Obtain the channels from the face targets that the masks reside in from the training diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index d9a2632abf..d73a799211 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -8,11 +8,6 @@ from typing import Dict, List, Optional, Tuple, Union -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - import numpy as np from lib.model.nn_blocks import ( @@ -25,6 +20,11 @@ from ._base import KerasModel, ModelBase, get_all_sub_models +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -299,7 +299,7 @@ def _get_input_shape(self) -> Tuple[int, int, int]: scaling = self.config["enc_scaling"] / 100 min_size = _MODEL_MAPPING[arch].min_size - size = int(max(min_size, min(default_size, ((default_size * scaling) // 16) * 16))) + size = int(max(min_size, ((default_size * scaling) // 16) * 16)) if self.config["enc_load_weights"] and enforce_size and scaling != 1.0: logger.warning("%s requires input size to be %spx when loading imagenet weights. " diff --git a/setup.cfg b/setup.cfg index 3526b979e1..5245a28a9f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,8 @@ [flake8] max-line-length = 99 +max-complexity=10 +statistics = True +count = True exclude = .git, __pycache__ [mypy] From d02d51f2bb79af9fe9acb030178fc9548f4dc0ef Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 17 Jun 2022 22:01:57 +0100 Subject: [PATCH 623/981] doc fixes --- docs/full/plugins/train.rst | 28 +++++++++++----------------- plugins/train/model/_base/io.py | 4 ++-- tests/simple_tests.py | 2 +- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/docs/full/plugins/train.rst b/docs/full/plugins/train.rst index a449168fa1..671bfddbb7 100755 --- a/docs/full/plugins/train.rst +++ b/docs/full/plugins/train.rst @@ -8,20 +8,11 @@ The Train Package handles the Model and Trainer plugins for training models in F .. contents:: Contents :local: -model._base module -================== - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~plugins.train.model._base.model - ~plugins.train.model._base.settings - ~plugins.train.model._base.io +model package +============= model._base.model module -======================== +------------------------ .. automodule:: plugins.train.model._base.model :members: @@ -29,7 +20,7 @@ model._base.model module :show-inheritance: model._base.settings module -=========================== +--------------------------- .. automodule:: plugins.train.model._base.settings :members: @@ -37,7 +28,7 @@ model._base.settings module :show-inheritance: model._base.io module -===================== +--------------------- .. automodule:: plugins.train.model._base.io :members: @@ -45,17 +36,20 @@ model._base.io module :show-inheritance: model.original module -===================== +---------------------- .. automodule:: plugins.train.model.original :members: :undoc-members: :show-inheritance: +trainer package +=============== + trainer._base module -==================== +---------------------- .. automodule:: plugins.train.trainer._base :members: :undoc-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: diff --git a/plugins/train/model/_base/io.py b/plugins/train/model/_base/io.py index 20d63ad66d..15f44b9271 100644 --- a/plugins/train/model/_base/io.py +++ b/plugins/train/model/_base/io.py @@ -32,7 +32,7 @@ from tensorflow.keras.models import load_model, Model as KModel # noqa pylint:disable=import-error,no-name-in-module if TYPE_CHECKING: - from .._base import ModelBase + from .model import ModelBase logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -52,7 +52,7 @@ def get_all_sub_models( Returns ------- list - A list of all :class:`keras.models.Model`s found within the given model. The provided + A list of all :class:`keras.models.Model`\s found within the given model. The provided model will always be returned in the first position """ if models is None: diff --git a/tests/simple_tests.py b/tests/simple_tests.py index 7e90aedd1b..8d52a8fc5d 100644 --- a/tests/simple_tests.py +++ b/tests/simple_tests.py @@ -90,7 +90,7 @@ def extract_args(detector, aligner, in_path, out_path, args=None): return _extract_args.split() -def train_args(model, model_path, faces, iterations=1, batchsize=4, extra_args=""): +def train_args(model, model_path, faces, iterations=1, batchsize=2, extra_args=""): """ Train command """ py_exe = sys.executable args = (f"{py_exe} faceswap.py train -A {faces} -B {faces} -m {model_path} -t {model} " From 04337e0c5efd442c1ce3e2da193dd8749f1e30d8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 18 Jun 2022 00:02:28 +0100 Subject: [PATCH 624/981] SSIM Updates - Standardize DSSIM Function - Implement MSSIM function for AMD --- lib/model/__init__.py | 12 +- lib/model/losses_plaid.py | 369 +++++++++++++++----------- lib/model/losses_tf.py | 177 ++++++++---- plugins/train/_config.py | 3 +- plugins/train/model/_base/settings.py | 2 +- tests/lib/model/losses_test.py | 4 +- 6 files changed, 355 insertions(+), 212 deletions(-) diff --git a/lib/model/__init__.py b/lib/model/__init__.py index 9ac90e31cb..487097af03 100644 --- a/lib/model/__init__.py +++ b/lib/model/__init__.py @@ -3,10 +3,12 @@ from lib.utils import get_backend -from .normalization import * +from .normalization import (AdaInstanceNormalization, GroupNormalization, # noqa + InstanceNormalization, LayerNormalization, RMSNormalization) + if get_backend() == "amd": - from . import losses_plaid as losses - from . import optimizers_plaid as optimizers + from . import losses_plaid as losses # noqa + from . import optimizers_plaid as optimizers # noqa else: - from . import losses_tf as losses - from . import optimizers_tf as optimizers + from . import losses_tf as losses #type:ignore # noqa + from . import optimizers_tf as optimizers #type:ignore # noqa diff --git a/lib/model/losses_plaid.py b/lib/model/losses_plaid.py index 816718ef10..b55299a611 100644 --- a/lib/model/losses_plaid.py +++ b/lib/model/losses_plaid.py @@ -4,22 +4,29 @@ from __future__ import absolute_import import logging +from typing import List, Tuple import numpy as np +import plaidml import tensorflow as tf from keras import backend as K -from plaidml.op import extract_image_patches from lib.plaidml_utils import pad from lib.utils import FaceswapError logger = logging.getLogger(__name__) # pylint:disable=invalid-name -class DSSIMObjective(): - """ DSSIM Loss Function +class DSSIMObjective(): # pylint:disable=too-few-public-methods + """ DSSIM and MS-DSSIM Loss Functions - Difference of Structural Similarity (DSSIM loss function). Clipped between 0 and 0.5 + Difference of Structural Similarity (DSSIM loss function). + + Adapted from :func:`tensorflow.image.ssim` for a pure keras implentation. + + Notes + ----- + Channels last only. Assumes all input images are the same size and square Parameters ---------- @@ -27,179 +34,135 @@ class DSSIMObjective(): Parameter of the SSIM. Default: `0.01` k_2: float, optional Parameter of the SSIM. Default: `0.03` - kernel_size: int, optional - Size of the sliding window Default: `3` + filter_size: int, optional + size of gaussian filter Default: `11` + filter_sigma: float, optional + Width of gaussian filter Default: `1.5` max_value: float, optional Max value of the output. Default: `1.0` Notes ------ You should add a regularization term like a l2 loss in addition to this one. + """ + def __init__(self, + k_1: float = 0.01, + k_2: float = 0.03, + filter_size: int = 11, + filter_sigma: float = 1.5, + max_value: float = 1.0) -> None: + self._filter_size = filter_size + self._filter_sigma = filter_sigma + self._kernel = self._get_kernel() + + compensation = 1.0 + self._c1 = (k_1 * max_value) ** 2 + self._c2 = ((k_2 * max_value) ** 2) * compensation + + def _get_kernel(self) -> plaidml.tile.Value: + """ Obtain the base kernel for performing depthwise convolution. - References - ---------- - https://github.com/keras-team/keras-contrib/blob/master/keras_contrib/losses/dssim.py - - MIT License - - Copyright (c) 2017 Fariz Rahman - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: + Returns + ------- + :class:`plaidml.tile.Value` + The gaussian kernel based on selected size and sigma + """ + coords = np.arange(self._filter_size, dtype="float32") + coords -= (self._filter_size - 1) / 2. - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. + kernel = np.square(coords) + kernel *= -0.5 / np.square(self._filter_sigma) + kernel = np.reshape(kernel, (1, -1)) + np.reshape(kernel, (-1, 1)) + kernel = K.constant(np.reshape(kernel, (1, -1))) + kernel = K.softmax(kernel) + kernel = K.reshape(kernel, (self._filter_size, self._filter_size, 1, 1)) + return kernel - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - """ - def __init__(self, k_1=0.01, k_2=0.03, kernel_size=3, max_value=1.0): - self.__name__ = 'DSSIMObjective' - self.kernel_size = kernel_size - self.k_1 = k_1 - self.k_2 = k_2 - self.max_value = max_value - self.c_1 = (self.k_1 * self.max_value) ** 2 - self.c_2 = (self.k_2 * self.max_value) ** 2 - self.dim_ordering = K.image_data_format() - - @staticmethod - def _int_shape(input_tensor): - """ Returns the shape of tensor or variable as a tuple of int or None entries. + @classmethod + def _depthwise_conv2d(cls, + image: plaidml.tile.Value, + kernel: plaidml.tile.Value) -> plaidml.tile.Value: + """ Perform a standardized depthwise convolution. Parameters ---------- - input_tensor: tensor or variable - The input to return the shape for + image: :class:`plaidml.tile.Value` + Batch of images, channels last, to perform depthwise convolution + kernel: :class:`plaidml.tile.Value` + convolution kernel Returns ------- - tuple - A tuple of integers (or None entries) + :class:`plaidml.tile.Value` + The output from the convolution """ - return K.int_shape(input_tensor) + return K.depthwise_conv2d(image, kernel, strides=(1, 1), padding="valid") - def __call__(self, y_true, y_pred): - """ Call the DSSIM Loss Function. + def _get_ssim(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> Tuple[plaidml.tile.Value, plaidml.tile.Value]: + """ Obtain the structural similarity between a batch of true and predicted images. Parameters ---------- - y_true: tensor or variable - The ground truth value - y_pred: tensor or variable - The predicted value + y_true: :class:`plaidml.tile.Value` + The input batch of ground truth images + y_pred: :class:`plaidml.tile.Value` + The input batch of predicted images Returns ------- - tensor - The DSSIM Loss value - - Notes - ----- - There are additional parameters for this function. some of the 'modes' for edge behavior - do not yet have a gradient definition in the Theano tree and cannot be used for learning + :class:`plaidml.tile.Value` + The SSIM for the given images + :class:`plaidml.tile.Value` + The Contrast for the given images """ + channels = K.int_shape(y_pred)[-1] + kernel = K.tile(self._kernel, (1, 1, channels, 1)) - kernel = [self.kernel_size, self.kernel_size] - y_true = K.reshape(y_true, [-1] + list(self._int_shape(y_pred)[1:])) - y_pred = K.reshape(y_pred, [-1] + list(self._int_shape(y_pred)[1:])) - patches_pred = self.extract_image_patches(y_pred, - kernel, - kernel, - 'valid', - self.dim_ordering) - patches_true = self.extract_image_patches(y_true, - kernel, - kernel, - 'valid', - self.dim_ordering) - - # Get mean - u_true = K.mean(patches_true, axis=-1) - u_pred = K.mean(patches_pred, axis=-1) - # Get variance - var_true = K.var(patches_true, axis=-1) - var_pred = K.var(patches_pred, axis=-1) - # Get standard deviation - covar_true_pred = K.mean( - patches_true * patches_pred, axis=-1) - u_true * u_pred - - ssim = (2 * u_true * u_pred + self.c_1) * ( - 2 * covar_true_pred + self.c_2) - denom = (K.square(u_true) + K.square(u_pred) + self.c_1) * ( - var_pred + var_true + self.c_2) - ssim /= denom # no need for clipping, c_1 + c_2 make the denorm non-zero - return (1.0 - ssim) / 2.0 - - @staticmethod - def _preprocess_padding(padding): - """Convert keras padding to tensorflow padding. + # SSIM luminance measure is (2 * mu_x * mu_y + c1) / (mu_x ** 2 + mu_y ** 2 + c1) + mean_true = self._depthwise_conv2d(y_true, kernel) + mean_pred = self._depthwise_conv2d(y_pred, kernel) + num_lum = mean_true * mean_pred * 2.0 + den_lum = K.square(mean_true) + K.square(mean_pred) + luminance = (num_lum + self._c1) / (den_lum + self._c1) - Parameters - ---------- - padding: string, - `"same"` or `"valid"`. + # SSIM contrast-structure measure is (2 * cov_{xy} + c2) / (cov_{xx} + cov_{yy} + c2) + num_con = self._depthwise_conv2d(y_true * y_pred, kernel) * 2.0 + den_con = self._depthwise_conv2d(K.square(y_true) + K.square(y_pred), kernel) - Returns - ------- - str - `"SAME"` or `"VALID"`. + contrast = (num_con - num_lum + self._c2) / (den_con - den_lum + self._c2) - Raises - ------ - ValueError - If `padding` is invalid. - """ - if padding == 'same': - padding = 'SAME' - elif padding == 'valid': - padding = 'VALID' - else: - raise ValueError('Invalid padding:', padding) - return padding - - def extract_image_patches(self, input_tensor, k_sizes, s_sizes, - padding='same', data_format='channels_last'): - """ Extract the patches from an image. + # Average over the height x width dimensions + axes = (-3, -2) + ssim = K.mean(luminance * contrast, axis=axes) + contrast = K.mean(contrast, axis=axes) + + return ssim, contrast + + def __call__(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: + """ Call the DSSIM or MS-DSSIM Loss Function. Parameters ---------- - input_tensor: tensor - The input image - k_sizes: tuple - 2-d tuple with the kernel size - s_sizes: tuple - 2-d tuple with the strides size - padding: str, optional - `"same"` or `"valid"`. Default: `"same"` - data_format: str, optional. - `"channels_last"` or `"channels_first"`. Default: `"channels_last"` + y_true: :class:`plaidml.tile.Value` + The input batch of ground truth images + y_pred: :class:`plaidml.tile.Value` + The input batch of predicted images Returns ------- - The (k_w, k_h) patches extracted - Tensorflow ==> (batch_size, w, h, k_w, k_h, c) - Theano ==> (batch_size, w, h, c, k_w, k_h) + :class:`plaidml.tile.Value` + The DSSIM or MS-DSSIM for the given images """ - kernel = [1, k_sizes[0], k_sizes[1], 1] - strides = [1, s_sizes[0], s_sizes[1], 1] - padding = self._preprocess_padding(padding) - if data_format == 'channels_first': - input_tensor = K.permute_dimensions(input_tensor, (0, 2, 3, 1)) - patches = extract_image_patches(input_tensor, kernel, strides, [1, 1, 1, 1], padding) - return patches + ssim = self._get_ssim(y_true, y_pred)[0] + retval = (1. - ssim) / 2.0 + return K.mean(retval) -class MSSSIMLoss(): # pylint:disable=too-few-public-methods +class MSSIMLoss(DSSIMObjective): # pylint:disable=too-few-public-methods """ Multiscale Structural Similarity Loss Function Parameters @@ -225,18 +188,115 @@ class MSSSIMLoss(): # pylint:disable=too-few-public-methods You should add a regularization term like a l2 loss in addition to this one. """ def __init__(self, - k_1=0.01, - k_2=0.03, - filter_size=4, - filter_sigma=1.5, - max_value=1.0, - power_factors=(0.0448, 0.2856, 0.3001, 0.2363, 0.1333)): - self.filter_size = filter_size - self.filter_sigma = filter_sigma - self.k_1 = k_1 - self.k_2 = k_2 - self.max_value = max_value - self.power_factors = power_factors + k_1: float = 0.01, + k_2: float = 0.03, + filter_size: int = 11, + filter_sigma: float = 1.5, + max_value: float = 1.0, + power_factors: Tuple[float, ...] = (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) + ) -> None: + super().__init__(k_1=k_1, + k_2=k_2, + filter_size=filter_size, + filter_sigma=filter_sigma, + max_value=max_value) + self._power_factors = K.constant(power_factors) + + def _get_smallest_size(self, size: int, idx: int) -> int: + """ Recursive function to obtain the smallest size that the image will be scaled to. + for MS-SSIM + + Parameters + ---------- + size: int + The current scaled size to iterate through + idx: int + The current iteration to be performed. When iteration hits zero the value will + be returned + + Returns + ------- + int + The smallest size the image will be scaled to based on the original image size and + the amount of scaling factors that will occur + """ + logger.debug("scale id: %s, size: %s", idx, size) + if idx > 0: + size = self._get_smallest_size(size // 2, idx - 1) + return size + + @classmethod + def _shrink_images(cls, images: List[plaidml.tile.Value]) -> List[plaidml.tile.Value]: + """ Reduce the dimensional space of a batch of images in half. If the images are an odd + number of pixels then pad them to an even dimension prior to shrinking + + All incoming images are assumed square. + + Parameters + ---------- + images: list + The y_true, y_pred batch of images to be shrunk + + Returns + ------- + list + The y_true, y_pred batch shrunk by half + """ + if any(x % 2 != 0 for x in K.int_shape(images[1])[1:2]): + images = [pad(img, + [[0, 0], [0, 1], [0, 1], [0, 0]], + mode="REFLECT") + for img in images] + + images = [K.pool2d(img, (2, 2), strides=(2, 2), padding="valid", pool_mode="avg") + for img in images] + + return images + + def _get_ms_ssim(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: + """ Obtain the Multiscale Stuctural Similarity metric. + + Parameters + ---------- + y_true: :class:`plaidml.tile.Value` + The input batch of ground truth images + y_pred: :class:`plaidml.tile.Value` + The input batch of predicted images + + Returns + ------- + :class:`plaidml.tile.Value` + The MS-SSIM for the given images + """ + im_size = K.int_shape(y_pred)[1] + # filter size cannot be larger than the smallest scale + recursions = K.int_shape(self._power_factors)[0] + smallest_scale = self._get_smallest_size(im_size, recursions - 1) + if smallest_scale < self._filter_size: + self._filter_size = smallest_scale + self._kernel = self._get_kernel() + + images = [y_true, y_pred] + contrasts = [] + + for idx in range(recursions): + images = self._shrink_images(images) if idx > 0 else images + ssim, contrast = self._get_ssim(*images) + + if idx < recursions - 1: + contrasts.append(K.relu(K.expand_dims(contrast, axis=-1))) + + contrasts.append(K.relu(K.expand_dims(ssim, axis=-1))) + mcs_and_ssim = K.concatenate(contrasts, axis=-1) + ms_ssim = K.pow(mcs_and_ssim, self._power_factors) + + # K.prod does not work in plaidml so slow recursion it is + out = ms_ssim[..., 0] + for idx in range(1, recursions): + out *= ms_ssim[..., idx] + return out def __call__(self, y_true, y_pred): """ Call the MS-SSIM Loss Function. @@ -253,8 +313,9 @@ def __call__(self, y_true, y_pred): tensor The MS-SSIM Loss value """ - raise FaceswapError("MS-SSIM Loss is not currently compatible with PlaidML. Please select " - "a different Loss method.") + ms_ssim = self._get_ms_ssim(y_true, y_pred) + retval = 1. - ms_ssim + return K.mean(retval) class GeneralizedLoss(): # pylint:disable=too-few-public-methods diff --git a/lib/model/losses_tf.py b/lib/model/losses_tf.py index 5afe5ee4fc..76e029585a 100644 --- a/lib/model/losses_tf.py +++ b/lib/model/losses_tf.py @@ -4,7 +4,7 @@ from __future__ import absolute_import import logging -from typing import Tuple +from typing import List, Tuple import numpy as np import tensorflow as tf @@ -16,11 +16,17 @@ logger = logging.getLogger(__name__) -class DSSIMObjective(tf.keras.losses.Loss): # pylint:disable=too-few-public-methods - """ DSSIM Loss Function +class DSSIMObjective(): # pylint:disable=too-few-public-methods + """ DSSIM and MS-DSSIM Loss Functions Difference of Structural Similarity (DSSIM loss function). + Adapted from :func:`tensorflow.image.ssim` for a pure keras implentation. + + Notes + ----- + Channels last only. Assumes all input images are the same size and square + Parameters ---------- k_1: float, optional @@ -38,41 +44,118 @@ class DSSIMObjective(tf.keras.losses.Loss): # pylint:disable=too-few-public-met ------ You should add a regularization term like a l2 loss in addition to this one. """ - def __init__(self, k_1=0.01, k_2=0.03, filter_size=11, filter_sigma=1.5, max_value=1.0): - super().__init__(name="DSSIMObjective", reduction=tf.keras.losses.Reduction.NONE) + def __init__(self, + k_1: float = 0.01, + k_2: float = 0.03, + filter_size: int = 11, + filter_sigma: float = 1.5, + max_value: float = 1.0) -> None: self._filter_size = filter_size self._filter_sigma = filter_sigma - self._k_1 = k_1 - self._k_2 = k_2 - self._max_value = max_value + self._kernel = self._get_kernel() - def call(self, y_true, y_pred): - """ Call the DSSIM Loss Function. + compensation = 1.0 + self._c1 = (k_1 * max_value) ** 2 + self._c2 = ((k_2 * max_value) ** 2) * compensation + + def _get_kernel(self) -> tf.Tensor: + """ Obtain the base kernel for performing depthwise convolution. + + Returns + ------- + :class:`tf.Tensor` + The gaussian kernel based on selected size and sigma + """ + coords = np.arange(self._filter_size, dtype="float32") + coords -= (self._filter_size - 1) / 2. + + kernel = np.square(coords) + kernel *= -0.5 / np.square(self._filter_sigma) + kernel = np.reshape(kernel, (1, -1)) + np.reshape(kernel, (-1, 1)) + kernel = K.constant(np.reshape(kernel, (1, -1))) + kernel = K.softmax(kernel) + kernel = K.reshape(kernel, (self._filter_size, self._filter_size, 1, 1)) + return kernel + + @classmethod + def _depthwise_conv2d(cls, image: tf.Tensor, kernel: tf.Tensor) -> tf.Tensor: + """ Perform a standardized depthwise convolution. Parameters ---------- - y_true: tensor or variable - The ground truth value - y_pred: tensor or variable - The predicted value + image: :class:`tf.Tensor` + Batch of images, channels last, to perform depthwise convolution + kernel: :class:`tf.Tensor` + convolution kernel Returns ------- - tensor - The DSSIM Loss value + :class:`tf.Tensor` + The output from the convolution + """ + return K.depthwise_conv2d(image, kernel, strides=(1, 1), padding="valid") + + def _get_ssim(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> Tuple[tf.Tensor, tf.Tensor]: + """ Obtain the structural similarity between a batch of true and predicted images. + + Parameters + ---------- + y_true: :class:`tf.Tensor` + The input batch of ground truth images + y_pred: :class:`tf.Tensor` + The input batch of predicted images + + Returns + ------- + :class:`tf.Tensor` + The SSIM for the given images + :class:`tf.Tensor` + The Contrast for the given images + """ + channels = K.int_shape(y_true)[-1] + kernel = K.tile(self._kernel, (1, 1, channels, 1)) + + # SSIM luminance measure is (2 * mu_x * mu_y + c1) / (mu_x ** 2 + mu_y ** 2 + c1) + mean_true = self._depthwise_conv2d(y_true, kernel) + mean_pred = self._depthwise_conv2d(y_pred, kernel) + num_lum = mean_true * mean_pred * 2.0 + den_lum = K.square(mean_true) + K.square(mean_pred) + luminance = (num_lum + self._c1) / (den_lum + self._c1) + + # SSIM contrast-structure measure is (2 * cov_{xy} + c2) / (cov_{xx} + cov_{yy} + c2) + num_con = self._depthwise_conv2d(y_true * y_pred, kernel) * 2.0 + den_con = self._depthwise_conv2d(K.square(y_true) + K.square(y_pred), kernel) + + contrast = (num_con - num_lum + self._c2) / (den_con - den_lum + self._c2) + + # Average over the height x width dimensions + axes = (-3, -2) + ssim = K.mean(luminance * contrast, axis=axes) + contrast = K.mean(contrast, axis=axes) + + return ssim, contrast + + def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + """ Call the DSSIM or MS-DSSIM Loss Function. + + Parameters + ---------- + y_true: :class:`tf.Tensor` + The input batch of ground truth images + y_pred: :class:`tf.Tensor` + The input batch of predicted images + + Returns + ------- + :class:`tf.Tensor` + The DSSIM or MS-DSSIM for the given images """ - ssim = tf.image.ssim(y_true, - y_pred, - self._max_value, - filter_size=self._filter_size, - filter_sigma=self._filter_sigma, - k1=self._k_1, - k2=self._k_2) - dssim_loss = (1. - ssim) / 2.0 - return dssim_loss - - -class MSSSIMLoss(tf.keras.losses.Loss): # pylint:disable=too-few-public-methods + ssim = self._get_ssim(y_true, y_pred)[0] + retval = (1. - ssim) / 2.0 + return K.mean(retval) + + +class MSSIMLoss(): # pylint:disable=too-few-public-methods """ Multiscale Structural Similarity Loss Function Parameters @@ -98,13 +181,13 @@ class MSSSIMLoss(tf.keras.losses.Loss): # pylint:disable=too-few-public-methods You should add a regularization term like a l2 loss in addition to this one. """ def __init__(self, - k_1=0.01, - k_2=0.03, - filter_size=4, - filter_sigma=1.5, - max_value=1.0, - power_factors=(0.0448, 0.2856, 0.3001, 0.2363, 0.1333)): - super().__init__(name="SSIM_Multiscale_Loss", reduction=tf.keras.losses.Reduction.NONE) + k_1: float = 0.01, + k_2: float = 0.03, + filter_size: int = 11, + filter_sigma: float = 1.5, + max_value: float = 1.0, + power_factors: Tuple[float, ...] = (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) + ) -> None: self.filter_size = filter_size self.filter_sigma = filter_sigma self.k_1 = k_1 @@ -112,19 +195,19 @@ def __init__(self, self.max_value = max_value self.power_factors = power_factors - def call(self, y_true, y_pred): + def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: """ Call the MS-SSIM Loss Function. Parameters ---------- - y_true: tensor or variable + y_true: :class:`tf.Tensor` The ground truth value - y_pred: tensor or variable + y_pred: :class:`tf.Tensor` The predicted value Returns ------- - tensor + :class:`tf.Tensor` The MS-SSIM Loss value """ im_size = K.int_shape(y_true)[1] @@ -141,9 +224,9 @@ def call(self, y_true, y_pred): k1=self.k_1, k2=self.k_2) ms_ssim_loss = 1. - ms_ssim - return ms_ssim_loss + return K.mean(ms_ssim_loss) - def _get_smallest_size(self, size, idx): + def _get_smallest_size(self, size: int, idx: int) -> int: """ Recursive function to obtain the smallest size that the image will be scaled to. Parameters @@ -486,8 +569,8 @@ class LossWrapper(): but are not fed in to the model. This wrapper receives this additional mask data for the batch stacked onto the end of the - color channels of the received :param:`y_true` batch of images. These masks are then split - off the batch of images and applied to both the :param:`y_true` and :param:`y_pred` tensors + color channels of the received :attr:`y_true` batch of images. These masks are then split + off the batch of images and applied to both the :attr:`y_true` and :attr:`y_pred` tensors prior to feeding into the loss functions. For example, for an image of shape (4, 128, 128, 3) 3 additional masks may be stacked onto @@ -497,9 +580,9 @@ class LossWrapper(): """ def __init__(self) -> None: logger.debug("Initializing: %s", self.__class__.__name__) - self._loss_functions = [] - self._loss_weights = [] - self._mask_channels = [] + self._loss_functions: List[compile_utils.LossesContainer] = [] + self._loss_weights: List[float] = [] + self._mask_channels: List[int] = [] logger.debug("Initialized: %s", self.__class__.__name__) def add_loss(self, @@ -531,7 +614,7 @@ def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: Loss is returned as the weighted sum of the chosen losses. If masks are being applied to the loss function inputs, then they should be included as - additional channels at the end of :param:`y_true`, so that they can be split off and + additional channels at the end of :attr:`y_true`, so that they can be split off and applied to the actual inputs to the selected loss function(s). Parameters diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 182347c689..40d12e3d94 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -36,8 +36,7 @@ "produces slightly blurrier results."), ms_ssim=( "Multiscale Structural Similarity Index Metric is similar to SSIM except that it " - "performs the calculations along multiple scales of the input image. NB: This loss " - "currently does not work on AMD Cards."), + "performs the calculations along multiple scales of the input image."), smooth_loss=( "Smooth_L1 is a modification of the MAE loss to correct two of its disadvantages. " "This loss has improved stability and guidance for small errors."), diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 17461e2db7..b29fd320d9 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -59,7 +59,7 @@ def __init__(self, config: dict) -> None: smooth_loss=losses.GeneralizedLoss(), l_inf_norm=losses.LInfNorm(), ssim=losses.DSSIMObjective(), - ms_ssim=losses.MSSSIMLoss(), + ms_ssim=losses.MSSIMLoss(), gmsd=losses.GMSDLoss(), pixel_gradient_diff=losses.GradientLoss()) self._mask_channels = self._get_mask_channels() diff --git a/tests/lib/model/losses_test.py b/tests/lib/model/losses_test.py index 9a2b89eedb..02d62525db 100644 --- a/tests/lib/model/losses_test.py +++ b/tests/lib/model/losses_test.py @@ -43,7 +43,7 @@ def test_loss_output(loss_func, output_shape): _LWPARAMS = [losses.GeneralizedLoss(), losses.GradientLoss(), losses.GMSDLoss(), losses.LInfNorm(), k_losses.mean_absolute_error, k_losses.mean_squared_error, - k_losses.logcosh, losses.DSSIMObjective(), losses.MSSSIMLoss()] + k_losses.logcosh, losses.DSSIMObjective(), losses.MSSIMLoss()] _LWIDS = ["GeneralizedLoss", "GradientLoss", "GMSDLoss", "LInfNorm", "mae", "mse", "logcosh", "DSSIMObjective", "MS-SSIM"] _LWIDS = [f"{loss}[{get_backend().upper()}]" for loss in _LWIDS] @@ -55,8 +55,6 @@ def test_loss_wrapper(loss_func): if get_backend() == "amd": if isinstance(loss_func, losses.GMSDLoss): pytest.skip("GMSD Loss is not currently compatible with PlaidML") - if isinstance(loss_func, losses.MSSSIMLoss): - pytest.skip("MS-SSIM Loss is not currently compatible with PlaidML") if hasattr(loss_func, "__name__") and loss_func.__name__ == "logcosh": pytest.skip("LogCosh Loss is not currently compatible with PlaidML") y_a = K.variable(np.random.random((2, 16, 16, 4))) From d9c84a5f9f6ff22d6f91594f218bea15764de96b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 18 Jun 2022 02:29:19 +0100 Subject: [PATCH 625/981] Add Laplacian Pyramid Loss --- lib/gpu_stats/__init__.py | 10 +- lib/gpu_stats/_base.py | 20 +-- lib/gpu_stats/amd.py | 18 +-- lib/gpu_stats/apple_silicon.py | 16 +-- lib/gpu_stats/cpu.py | 22 ++-- lib/gpu_stats/nvidia.py | 8 +- lib/gpu_stats/nvidia_apple.py | 8 +- lib/model/losses_plaid.py | 169 ++++++++++++++++++++++++-- lib/model/losses_tf.py | 134 +++++++++++++++++++- plugins/extract/pipeline.py | 37 +++--- plugins/train/_config.py | 7 ++ plugins/train/model/_base/settings.py | 15 +-- setup.cfg | 10 ++ tests/lib/model/losses_test.py | 21 ++-- tests/simple_tests.py | 22 ++-- 15 files changed, 409 insertions(+), 108 deletions(-) diff --git a/lib/gpu_stats/__init__.py b/lib/gpu_stats/__init__.py index e10cac5c3e..ed962e2cfa 100644 --- a/lib/gpu_stats/__init__.py +++ b/lib/gpu_stats/__init__.py @@ -11,12 +11,12 @@ backend = get_backend() if backend == "nvidia" and platform.system().lower() == "darwin": - from .nvidia_apple import NvidiaAppleStats as GPUStats # noqa + from .nvidia_apple import NvidiaAppleStats as GPUStats # type:ignore # noqa elif backend == "nvidia": - from .nvidia import NvidiaStats as GPUStats # noqa + from .nvidia import NvidiaStats as GPUStats # type:ignore # noqa elif backend == "amd": - from .amd import AMDStats as GPUStats, setup_plaidml # noqa + from .amd import AMDStats as GPUStats, setup_plaidml # type:ignore # noqa elif backend == "apple_silicon": - from .apple_silicon import AppleSiliconStats as GPUStats # noqa + from .apple_silicon import AppleSiliconStats as GPUStats # type:ignore # noqa elif backend == "cpu": - from .cpu import CPUStats as GPUStats # noqa + from .cpu import CPUStats as GPUStats # type:ignore # noqa diff --git a/lib/gpu_stats/_base.py b/lib/gpu_stats/_base.py index 9fb0680768..f9c43ea208 100644 --- a/lib/gpu_stats/_base.py +++ b/lib/gpu_stats/_base.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ Parent class for obtaining Stats for various GPU/TPU backends. All GPU Stats should inherit -from the :class:`GPUStats` class contained here. """ +from the :class:`_GPUStats` class contained here. """ import logging import os @@ -23,7 +23,7 @@ class GPUInfo(TypedDict): vram: List[int] driver: str devices: List[str] - devices_active: List[str] + devices_active: List[int] class BiggestGPUInfo(TypedDict): @@ -50,7 +50,7 @@ def set_exclude_devices(devices: List[int]) -> None: _EXCLUDE_DEVICES.extend(devices) -class GPUStats(): +class _GPUStats(): """ Parent class for returning information of GPUs used. """ def __init__(self, log: bool = True) -> None: @@ -67,8 +67,8 @@ def __init__(self, log: bool = True) -> None: self._handles: list = self._get_handles() self._driver: str = self._get_driver() self._device_names: List[str] = self._get_device_names() - self._vram: List[float] = self._get_vram() - self._vram_free: List[float] = self._get_free_vram() + self._vram: List[int] = self._get_vram() + self._vram_free: List[int] = self._get_free_vram() if get_backend() != "cpu" and not self._active_devices: self._log("warning", "No GPU detected") @@ -164,8 +164,8 @@ def _get_active_devices(self) -> List[int]: devices = [idx for idx in range(self._device_count) if idx not in _EXCLUDE_DEVICES] env_devices = os.environ.get("CUDA_VISIBLE_DEVICES") if env_devices: - env_devices = [int(i) for i in env_devices.split(",")] - devices = [idx for idx in devices if idx in env_devices] + new_devices = [int(i) for i in env_devices.split(",")] + devices = [idx for idx in devices if idx in new_devices] self._log("debug", f"Active GPU Devices: {devices}") return devices @@ -202,7 +202,7 @@ def _get_device_names(self) -> List[str]: """ raise NotImplementedError() - def _get_vram(self) -> List[float]: + def _get_vram(self) -> List[int]: """ Override to obtain the total VRAM in Megabytes for each connected GPU. Returns @@ -213,8 +213,8 @@ def _get_vram(self) -> List[float]: """ raise NotImplementedError() - def _get_free_vram(self) -> List[float]: - """ Override to obrain the amount of VRAM that is available, in Megabytes, for each + def _get_free_vram(self) -> List[int]: + """ Override to obtain the amount of VRAM that is available, in Megabytes, for each connected GPU. Returns diff --git a/lib/gpu_stats/amd.py b/lib/gpu_stats/amd.py index 48a6157e04..39a271186e 100644 --- a/lib/gpu_stats/amd.py +++ b/lib/gpu_stats/amd.py @@ -9,7 +9,7 @@ import plaidml -from ._base import GPUStats, _EXCLUDE_DEVICES +from ._base import _GPUStats, _EXCLUDE_DEVICES _PLAIDML_INITIALIZED: bool = False @@ -40,7 +40,7 @@ def setup_plaidml(log_level: str, exclude_devices: List[int]) -> None: logger.info("Successfully set up for PlaidML") -class AMDStats(GPUStats): +class AMDStats(_GPUStats): """ Holds information and statistics about AMD GPU(s) available on the currently running system. @@ -104,9 +104,9 @@ def _supported_indices(self) -> List[int]: return retval @property - def _all_vram(self) -> List[float]: + def _all_vram(self) -> List[int]: """ list: The VRAM of each GPU device that PlaidML has discovered. """ - return [int(device.get("globalMemSize", 0)) / (1024 * 1024) + return [int(device.get("globalMemSize", 0) / (1024 * 1024)) for device in self._device_details] @property @@ -159,7 +159,7 @@ def _set_plaidml_logger(self) -> None: self._log("debug", "Setting PlaidML Default Logger") plaidml.DEFAULT_LOG_HANDLER = logging.getLogger("plaidml_root") - plaidml.DEFAULT_LOG_HANDLER.propagate = 0 + plaidml.DEFAULT_LOG_HANDLER.propagate = False numeric_level = getattr(logging, self._log_level, None) if numeric_level < 10: # DEBUG Logging @@ -344,8 +344,8 @@ def _get_driver(self) -> str: str The current AMD GPU driver versions """ - drivers = [device.get("driverVersion", "No Driver Found") - for device in self._device_details] + drivers = "|".join([device.get("driverVersion", "No Driver Found") + for device in self._device_details]) self._log("debug", f"GPU Drivers: {drivers}") return drivers @@ -361,7 +361,7 @@ def _get_device_names(self) -> List[str]: self._log("debug", f"GPU Devices: {names}") return names - def _get_vram(self) -> List[float]: + def _get_vram(self) -> List[int]: """ Obtain the VRAM in Megabytes for each connected AMD GPU as identified in :attr:`_handles`. @@ -374,7 +374,7 @@ def _get_vram(self) -> List[float]: self._log("debug", f"GPU VRAM: {vram}") return vram - def _get_free_vram(self) -> List[float]: + def _get_free_vram(self) -> List[int]: """ Obtain the amount of VRAM that is available, in Megabytes, for each connected AMD GPU. diff --git a/lib/gpu_stats/apple_silicon.py b/lib/gpu_stats/apple_silicon.py index 787eb7bc6b..11fcfab1d3 100644 --- a/lib/gpu_stats/apple_silicon.py +++ b/lib/gpu_stats/apple_silicon.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ Collects and returns Information on available Apple Silicon SoCs in Apple Macs. """ -from typing import List, Optional +from typing import Any, List import os import psutil @@ -8,13 +8,13 @@ from lib.utils import FaceswapError -from ._base import GPUStats +from ._base import _GPUStats _METAL_INITIALIZED: bool = False -class AppleSiliconStats(GPUStats): +class AppleSiliconStats(_GPUStats): """ Holds information and statistics about Apple Silicon SoC(s) available on the currently running Apple system. @@ -35,7 +35,7 @@ class AppleSiliconStats(GPUStats): """ def __init__(self, log: bool = True) -> None: # Following attribute set in :func:``_initialize`` - self._tf_devices: Optional(List[str]) = None + self._tf_devices: List[Any] = [] super().__init__(log=log) @@ -155,7 +155,7 @@ def _get_device_names(self) -> List[str]: self._log("debug", f"GPU Devices: {names}") return names - def _get_vram(self) -> List[float]: + def _get_vram(self) -> List[int]: """ Obtain the VRAM in Megabytes for each available Apple Silicon SoC(s) as identified in :attr:`_handles`. @@ -170,12 +170,12 @@ def _get_vram(self) -> List[float]: list The RAM in Megabytes for each available Apple Silicon SoC """ - vram = [(psutil.virtual_memory().total / self._device_count) / (1024 * 1024) + vram = [int((psutil.virtual_memory().total / self._device_count) / (1024 * 1024)) for _ in range(self._device_count)] self._log("debug", f"SoC RAM: {vram}") return vram - def _get_free_vram(self) -> List[float]: + def _get_free_vram(self) -> List[int]: """ Obtain the amount of VRAM that is available, in Megabytes, for each available Apple Silicon SoC. @@ -185,7 +185,7 @@ def _get_free_vram(self) -> List[float]: List of `float`s containing the amount of RAM available, in Megabytes, for each available SoC as corresponding to the values in :attr:`_handles """ - vram = [(psutil.virtual_memory().available / self._device_count) / (1024 * 1024) + vram = [int((psutil.virtual_memory().available / self._device_count) / (1024 * 1024)) for _ in range(self._device_count)] self._log("debug", f"SoC RAM free: {vram}") return vram diff --git a/lib/gpu_stats/cpu.py b/lib/gpu_stats/cpu.py index bbc9ef4d45..23090828e1 100644 --- a/lib/gpu_stats/cpu.py +++ b/lib/gpu_stats/cpu.py @@ -4,19 +4,19 @@ from typing import List -from ._base import GPUStats +from ._base import _GPUStats -class CPUStats(GPUStats): +class CPUStats(_GPUStats): """ Holds information and statistics about the CPU on the currently running system. Notes ----- - The information held here is not useful, but GPUStats is dynamically imported depending on the - backend used, so we need to make sure this class is available for Faceswap run on the CPU + The information held here is not useful, but _GPUStats is dynamically imported depending on + the backend used, so we need to make sure this class is available for Faceswap run on the CPU Backend. - The base :class:`GPUStats` handles the dummying in of information when no GPU is detected. + The base :class:`_GPUStats` handles the dummying in of information when no GPU is detected. Parameters ---------- @@ -49,7 +49,7 @@ def _get_handles(self) -> list: list An empty list for CPU Backends """ - handles = [] + handles: list = [] self._log("debug", f"GPU Handles found: {len(handles)}") return handles @@ -73,11 +73,11 @@ def _get_device_names(self) -> List[str]: list An empty list for CPU backends """ - names = [] + names: List[str] = [] self._log("debug", f"GPU Devices: {names}") return names - def _get_vram(self) -> List[float]: + def _get_vram(self) -> List[int]: """ Obtain the RAM in Megabytes for the running system. Returns @@ -85,11 +85,11 @@ def _get_vram(self) -> List[float]: list An empty list for CPU backends """ - vram = [] + vram: List[int] = [] self._log("debug", f"GPU VRAM: {vram}") return vram - def _get_free_vram(self) -> List[float]: + def _get_free_vram(self) -> List[int]: """ Obtain the amount of RAM that is available, in Megabytes, for the running system. Returns @@ -97,6 +97,6 @@ def _get_free_vram(self) -> List[float]: list An empty list for CPU backends """ - vram = [] + vram: List[int] = [] self._log("debug", f"GPU VRAM free: {vram}") return vram diff --git a/lib/gpu_stats/nvidia.py b/lib/gpu_stats/nvidia.py index a1d9d328ef..cfd5e109d6 100644 --- a/lib/gpu_stats/nvidia.py +++ b/lib/gpu_stats/nvidia.py @@ -7,10 +7,10 @@ from lib.utils import FaceswapError -from ._base import GPUStats +from ._base import _GPUStats -class NvidiaStats(GPUStats): +class NvidiaStats(_GPUStats): """ Holds information and statistics about Nvidia GPU(s) available on the currently running system. @@ -125,7 +125,7 @@ def _get_device_names(self) -> List[str]: self._log("debug", f"GPU Devices: {names}") return names - def _get_vram(self) -> List[float]: + def _get_vram(self) -> List[int]: """ Obtain the VRAM in Megabytes for each connected Nvidia GPU as identified in :attr:`_handles`. @@ -139,7 +139,7 @@ def _get_vram(self) -> List[float]: self._log("debug", f"GPU VRAM: {vram}") return vram - def _get_free_vram(self) -> List[float]: + def _get_free_vram(self) -> List[int]: """ Obtain the amount of VRAM that is available, in Megabytes, for each connected Nvidia GPU. diff --git a/lib/gpu_stats/nvidia_apple.py b/lib/gpu_stats/nvidia_apple.py index 9c5c6c616e..ae6cb74c5c 100644 --- a/lib/gpu_stats/nvidia_apple.py +++ b/lib/gpu_stats/nvidia_apple.py @@ -6,10 +6,10 @@ from lib.utils import FaceswapError -from ._base import GPUStats +from ._base import _GPUStats -class NvidiaAppleStats(GPUStats): +class NvidiaAppleStats(_GPUStats): """ Holds information and statistics about Nvidia GPU(s) available on the currently running Apple system. @@ -105,7 +105,7 @@ def _get_device_names(self) -> List[str]: self._log("debug", f"GPU Devices: {names}") return names - def _get_vram(self) -> List[float]: + def _get_vram(self) -> List[int]: """ Obtain the VRAM in Megabytes for each connected Nvidia GPU as identified in :attr:`_handles`. @@ -120,7 +120,7 @@ def _get_vram(self) -> List[float]: self._log("debug", f"GPU VRAM: {vram}") return vram - def _get_free_vram(self) -> List[float]: + def _get_free_vram(self) -> List[int]: """ Obtain the amount of VRAM that is available, in Megabytes, for each connected Nvidia GPU. diff --git a/lib/model/losses_plaid.py b/lib/model/losses_plaid.py index b55299a611..70998781d7 100644 --- a/lib/model/losses_plaid.py +++ b/lib/model/losses_plaid.py @@ -4,7 +4,7 @@ from __future__ import absolute_import import logging -from typing import List, Tuple +from typing import Callable, List, Tuple import numpy as np import plaidml @@ -621,18 +621,155 @@ def _scharr_edges(cls, image, magnitude): return output +class LaplacianPyramidLoss(): # pylint:disable=too-few-public-methods + """ Laplacian Pyramid Loss Function + + Notes + ----- + Channels last implementation on square images only. + + Parameters + ---------- + max_levels: int, Optional + The max number of laplacian pyramid levels to use. Default: `5` + gaussian_size: int, Optional + The size of the gaussian kernel. Default: `5` + gaussian_sigma: float, optional + The gaussian sigma. Default: 2.0 + + References + ---------- + https://arxiv.org/abs/1707.05776 + https://github.com/nathanaelbosch/generative-latent-optimization/blob/master/utils.py + """ + def __init__(self, + max_levels: int = 5, + gaussian_size: int = 5, + gaussian_sigma: float = 1.0) -> None: + self._max_levels = max_levels + self._weights = K.constant([np.power(2., -2 * idx) for idx in range(max_levels + 1)]) + self._gaussian_kernel = self._get_gaussian_kernel(gaussian_size, gaussian_sigma) + self._shape: Tuple[int, ...] = () + + @classmethod + def _get_gaussian_kernel(cls, size: int, sigma: float) -> plaidml.tile.Value: + """ Obtain the base gaussian kernel for the Laplacian Pyramid. + + Parameters + ---------- + size: int, Optional + The size of the gaussian kernel + sigma: float + The gaussian sigma + + Returns + ------- + :class:`plaidml.tile.Value` + The base single channel Gaussian kernel + """ + assert size % 2 == 1, ("kernel size must be uneven") + x_1 = np.linspace(- (size // 2), size // 2, size, dtype="float32") + x_1 /= np.sqrt(2)*sigma + x_2 = x_1 ** 2 + kernel = np.exp(- x_2[:, None] - x_2[None, :]) + kernel /= kernel.sum() + kernel = np.reshape(kernel, (size, size, 1, 1)) + return K.constant(kernel) + + def _conv_gaussian(self, inputs: plaidml.tile.Value) -> plaidml.tile.Value: + """ Perform Gaussian convolution on a batch of images. + + Parameters + ---------- + inputs: :class:`plaidml.tile.Value` + The input batch of images to perform Gaussian convolution on. + + Returns + ------- + :class:`plaidml.tile.Value` + The convolved images + """ + channels = self._shape[-1] + gauss = K.tile(self._gaussian_kernel, (1, 1, 1, channels)) + + # PlaidML doesn't implement replication padding like pytorch. This is an inefficient way to + # implement it for a square guassian kernel + size = K.int_shape(self._gaussian_kernel)[1] // 2 + padded_inputs = inputs + for _ in range(size): + padded_inputs = pad(padded_inputs, # noqa,pylint:disable=no-value-for-parameter,unexpected-keyword-arg + ([0, 0], [1, 1], [1, 1], [0, 0]), + mode="REFLECT") + + retval = K.conv2d(padded_inputs, gauss, strides=(1, 1), padding="valid") + return retval + + def _get_laplacian_pyramid(self, inputs: plaidml.tile.Value) -> List[plaidml.tile.Value]: + """ Obtain the Laplacian Pyramid. + + Parameters + ---------- + inputs: :class:`plaidml.tile.Value` + The input batch of images to run through the Laplacian Pyramid + + Returns + ------- + list + The tensors produced from the Laplacian Pyramid + """ + pyramid = [] + current = inputs + for _ in range(self._max_levels): + gauss = self._conv_gaussian(current) + diff = current - gauss + pyramid.append(diff) + current = K.pool2d(gauss, (2, 2), strides=(2, 2), padding="valid", pool_mode="avg") + pyramid.append(current) + return pyramid + + def __call__(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: + """ Calculate the Laplacian Pyramid Loss. + + Parameters + ---------- + y_true: :class:`plaidml.tile.Value` + The ground truth value + y_pred: :class:`plaidml.tile.Value` + The predicted value + + Returns + ------- + :class: `plaidml.tile.Value` + The loss value + """ + if not self._shape: + self._shape = K.int_shape(y_pred) + pyramid_true = self._get_laplacian_pyramid(y_true) + pyramid_pred = self._get_laplacian_pyramid(y_pred) + + losses = K.stack([K.sum(K.abs(ppred - ptrue)) / K.cast(K.prod(K.shape(ptrue)), "float32") + for ptrue, ppred in zip(pyramid_true, pyramid_pred)]) + loss = K.sum(losses * self._weights) + return loss + + class LossWrapper(): # pylint:disable=too-few-public-methods """ A wrapper class for multiple keras losses to enable multiple weighted loss functions on a single output and masking. """ - def __init__(self): + def __init__(self) -> None: logger.debug("Initializing: %s", self.__class__.__name__) - self._loss_functions = [] - self._loss_weights = [] - self._mask_channels = [] + self._loss_functions: List[Callable] = [] + self._loss_weights: List[float] = [] + self._mask_channels: List[int] = [] logger.debug("Initialized: %s", self.__class__.__name__) - def add_loss(self, function, weight=1.0, mask_channel=-1): + def add_loss(self, + function, + weight: float = 1.0, + mask_channel: int = -1) -> None: """ Add the given loss function with the given weight to the loss function chain. Parameters @@ -651,21 +788,23 @@ def add_loss(self, function, weight=1.0, mask_channel=-1): self._loss_weights.append(weight) self._mask_channels.append(mask_channel) - def __call__(self, y_true, y_pred): + def __call__(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: """ Call the sub loss functions for the loss wrapper. Weights are returned as the weighted sum of the chosen losses. Parameters ---------- - y_true: tensor or variable + y_true: :class:`plaidml.tile.Value` The ground truth value - y_pred: tensor or variable + y_pred: :class:`plaidml.tile.Value` The predicted value Returns ------- - tensor + :class:`plaidml.tile.Value` The final loss value """ loss = 0.0 @@ -685,15 +824,19 @@ def __call__(self, y_true, y_pred): return loss @classmethod - def _apply_mask(cls, y_true, y_pred, mask_channel, mask_prop=1.0): + def _apply_mask(cls, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value, + mask_channel: int, + mask_prop: float = 1.0) -> Tuple[plaidml.tile.Value, plaidml.tile.Value]: """ Apply the mask to the input y_true and y_pred. If a mask is not required then return the unmasked inputs. Parameters ---------- - y_true: tensor or variable + y_true: :class:`plaidml.tile.Value` The ground truth value - y_pred: tensor or variable + y_pred: :class:`plaidml.tile.Value` The predicted value mask_channel: int The channel within y_true that the required mask resides in diff --git a/lib/model/losses_tf.py b/lib/model/losses_tf.py index 76e029585a..3b5eaaa6ca 100644 --- a/lib/model/losses_tf.py +++ b/lib/model/losses_tf.py @@ -4,7 +4,7 @@ from __future__ import absolute_import import logging -from typing import List, Tuple +from typing import Callable, List, Tuple import numpy as np import tensorflow as tf @@ -558,6 +558,136 @@ def _scharr_edges(cls, image, magnitude): return output +class LaplacianPyramidLoss(): # pylint:disable=too-few-public-methods + """ Laplacian Pyramid Loss Function + + Notes + ----- + Channels last implementation on square images only. + + Parameters + ---------- + max_levels: int, Optional + The max number of laplacian pyramid levels to use. Default: `5` + gaussian_size: int, Optional + The size of the gaussian kernel. Default: `5` + gaussian_sigma: float, optional + The gaussian sigma. Default: 2.0 + + References + ---------- + https://arxiv.org/abs/1707.05776 + https://github.com/nathanaelbosch/generative-latent-optimization/blob/master/utils.py + """ + def __init__(self, + max_levels: int = 5, + gaussian_size: int = 5, + gaussian_sigma: float = 1.0) -> None: + self._max_levels = max_levels + self._weights = K.constant([np.power(2., -2 * idx) for idx in range(max_levels + 1)]) + self._gaussian_kernel = self._get_gaussian_kernel(gaussian_size, gaussian_sigma) + + @classmethod + def _get_gaussian_kernel(cls, size: int, sigma: float) -> tf.Tensor: + """ Obtain the base gaussian kernel for the Laplacian Pyramid. + + Parameters + ---------- + size: int, Optional + The size of the gaussian kernel + sigma: float + The gaussian sigma + + Returns + ------- + tf.Tensor + The base single channel Gaussian kernel + """ + assert size % 2 == 1, ("kernel size must be uneven") + x_1 = np.linspace(- (size // 2), size // 2, size, dtype="float32") + x_1 /= np.sqrt(2)*sigma + x_2 = x_1 ** 2 + kernel = np.exp(- x_2[:, None] - x_2[None, :]) + kernel /= kernel.sum() + kernel = np.reshape(kernel, (size, size, 1, 1)) + return K.constant(kernel) + + def _conv_gaussian(self, inputs: tf.Tensor) -> tf.Tensor: + """ Perform Gaussian convolution on a batch of images. + + Parameters + ---------- + inputs: :class:`tf.Tensor` + The input batch of images to perform Gaussian convolution on. + + Returns + ------- + :class:`tf.Tensor` + The convolved images + """ + channels = K.int_shape(inputs)[-1] + gauss = K.tile(self._gaussian_kernel, (1, 1, 1, channels)) + + # TF doesn't implement replication padding like pytorch. This is an inefficient way to + # implement it for a square guassian kernel + size = self._gaussian_kernel.shape[1] // 2 + padded_inputs = inputs + for _ in range(size): + padded_inputs = tf.pad(padded_inputs, # noqa,pylint:disable=no-value-for-parameter,unexpected-keyword-arg + ([0, 0], [1, 1], [1, 1], [0, 0]), + mode="SYMMETRIC") + + retval = K.conv2d(padded_inputs, gauss, strides=1, padding="valid") + return retval + + def _get_laplacian_pyramid(self, inputs: tf.Tensor) -> List[tf.Tensor]: + """ Obtain the Laplacian Pyramid. + + Parameters + ---------- + inputs: :class:`tf.Tensor` + The input batch of images to run through the Laplacian Pyramid + + Returns + ------- + list + The tensors produced from the Laplacian Pyramid + """ + pyramid = [] + current = inputs + for _ in range(self._max_levels): + gauss = self._conv_gaussian(current) + diff = current - gauss + pyramid.append(diff) + current = K.pool2d(gauss, (2, 2), strides=(2, 2), padding="valid", pool_mode="avg") + pyramid.append(current) + return pyramid + + def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + """ Calculate the Laplacian Pyramid Loss. + + Parameters + ---------- + y_true: :class:`tf.Tensor` + The ground truth value + y_pred: :class:`tf.Tensor` + The predicted value + + Returns + ------- + :class: `tf.Tensor` + The loss value + """ + pyramid_true = self._get_laplacian_pyramid(y_true) + pyramid_pred = self._get_laplacian_pyramid(y_pred) + + losses = K.stack([K.sum(K.abs(ppred - ptrue)) / K.cast(K.prod(K.shape(ptrue)), "float32") + for ptrue, ppred in zip(pyramid_true, pyramid_pred)]) + loss = K.sum(losses * self._weights) + + return loss + + class LossWrapper(): """ A wrapper class for multiple keras losses to enable multiple masked weighted loss functions on a single output. @@ -586,7 +716,7 @@ def __init__(self) -> None: logger.debug("Initialized: %s", self.__class__.__name__) def add_loss(self, - function: tf.keras.losses.Loss, + function: Callable, weight: float = 1.0, mask_channel: int = -1) -> None: """ Add the given loss function with the given weight to the loss function chain. diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index a03cc1945c..59a99566fb 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -122,7 +122,7 @@ def input_queue(self): For align/mask (2nd/3rd pass operations) the :attr:`ExtractMedia.detected_faces` should also be populated by calling :func:`ExtractMedia.set_detected_faces`. """ - qname = "extract{}_{}_in".format(self._instance, self._current_phase[0]) + qname = f"extract{self._instance}_{self._current_phase[0]}_in" retval = self._queues[qname] logger.trace("%s: %s", qname, retval) return retval @@ -193,7 +193,7 @@ def set_batchsize(self, plugin_type, batchsize): The batch size to use for this plugin type """ logger.debug("Overriding batchsize for plugin_type: %s to: %s", plugin_type, batchsize) - plugin = getattr(self, "_{}".format(plugin_type)) + plugin = getattr(self, f"_{plugin_type}") plugin.batchsize = batchsize def launch(self): @@ -283,10 +283,10 @@ def _parallel_scaling(self): @property def _vram_per_phase(self): """ dict: The amount of vram required for each phase in :attr:`_flow`. """ - retval = dict() + retval = {} for phase in self._flow: plugin_type, idx = self._get_plugin_type_and_index(phase) - attr = getattr(self, "_{}".format(plugin_type)) + attr = getattr(self, f"_{plugin_type}") attr = attr[idx] if idx is not None else attr retval[phase] = attr.vram logger.trace(retval) @@ -322,10 +322,9 @@ def _final_phase(self): def _output_queue(self): """ Return the correct output queue depending on the current phase """ if self.final_pass: - qname = "extract{}_{}_out".format(self._instance, self._final_phase) + qname = f"extract{self._instance}_{self._final_phase}_out" else: - qname = "extract{}_{}_in".format(self._instance, - self._phases[self._phase_index + 1][0]) + qname = f"extract{self._instance}_{self._phases[self._phase_index + 1][0]}_in" retval = self._queues[qname] logger.trace("%s: %s", qname, retval) return retval @@ -336,7 +335,7 @@ def _all_plugins(self): retval = [] for phase in self._flow: plugin_type, idx = self._get_plugin_type_and_index(phase) - attr = getattr(self, "_{}".format(plugin_type)) + attr = getattr(self, f"_{plugin_type}") attr = attr[idx] if idx is not None else attr retval.append(attr) logger.trace("All Plugins: %s", retval) @@ -348,7 +347,7 @@ def _active_plugins(self): retval = [] for phase in self._current_phase: plugin_type, idx = self._get_plugin_type_and_index(phase) - attr = getattr(self, "_{}".format(plugin_type)) + attr = getattr(self, f"_{plugin_type}") retval.append(attr[idx] if idx is not None else attr) logger.trace("Active plugins: %s", retval) return retval @@ -362,7 +361,7 @@ def _set_flow(detector, aligner, masker): retval.append("detect") if aligner is not None and aligner.lower() != "none": retval.append("align") - retval.extend(["mask_{}".format(idx) + retval.extend([f"mask_{idx}" for idx, mask in enumerate(masker) if mask is not None and mask.lower() != "none"]) logger.debug("flow: %s", retval) @@ -400,9 +399,9 @@ def _get_plugin_type_and_index(flow_phase): def _add_queues(self): """ Add the required processing queues to Queue Manager """ - queues = dict() - tasks = ["extract{}_{}_in".format(self._instance, phase) for phase in self._flow] - tasks.append("extract{}_{}_out".format(self._instance, self._final_phase)) + queues = {} + tasks = [f"extract{self._instance}_{phase}_in" for phase in self._flow] + tasks.append(f"extract{self._instance}_{self._final_phase}_out") for task in tasks: # Limit queue size to avoid stacking ram queue_manager.add_queue(task, maxsize=self._queue_size) @@ -552,17 +551,17 @@ def _load_mask(self, masker, image_is_aligned, configfile): def _launch_plugin(self, phase): """ Launch an extraction plugin """ logger.debug("Launching %s plugin", phase) - in_qname = "extract{}_{}_in".format(self._instance, phase) + in_qname = f"extract{self._instance}_{phase}_in" if phase == self._final_phase: - out_qname = "extract{}_{}_out".format(self._instance, self._final_phase) + out_qname = f"extract{self._instance}_{self._final_phase}_out" else: next_phase = self._flow[self._flow.index(phase) + 1] - out_qname = "extract{}_{}_in".format(self._instance, next_phase) + out_qname = f"extract{self._instance}_{next_phase}_in" logger.debug("in_qname: %s, out_qname: %s", in_qname, out_qname) kwargs = dict(in_queue=self._queues[in_qname], out_queue=self._queues[out_qname]) plugin_type, idx = self._get_plugin_type_and_index(phase) - plugin = getattr(self, "_{}".format(plugin_type)) + plugin = getattr(self, f"_{plugin_type}") plugin = plugin[idx] if idx is not None else plugin plugin.initialize(**kwargs) plugin.start() @@ -645,7 +644,7 @@ def _set_plugin_batchsize(self, gpu_plugins, available_vram): logger.debug("Remaining VRAM to allocate: %sMB", remaining) if batchsizes != requested_batchsizes: - text = ", ".join(["{}: {}".format(plugin.__class__.__name__, batchsize) + text = ", ".join([f"{plugin.__class__.__name__}: {batchsize}" for plugin, batchsize in zip(plugins, batchsizes)]) for plugin, batchsize in zip(plugins, batchsizes): plugin.batchsize = batchsize @@ -726,7 +725,7 @@ def get_image_copy(self, color_format): A copy of :attr:`image` in the requested :attr:`color_format` """ logger.trace("Requested color format '%s' for frame '%s'", color_format, self._filename) - image = getattr(self, "_image_as_{}".format(color_format.lower()))() + image = getattr(self, f"_image_as_{color_format.lower()}")() return image def add_detected_faces(self, faces): diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 40d12e3d94..e528f6d0ec 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -21,6 +21,13 @@ "The L_inf norm will reduce the largest individual pixel error in an image. As " "each largest error is minimized sequentially, the overall error is improved. This loss " "will be extremely focused on outliers."), + laploss=( + "Laplacian Pyramid Loss. Attempts to improve results by focussing on edges using " + "Laplacian Pyramids. As this loss function gives priority to edges over other low-" + "frequency information, like color, it should not be used on its own. The original " + "implementation uses this loss as a complimentary function to MSE. " + "Ref: Optimizing the Latent Space of Generative Networks " + "https://arxiv.org/abs/1707.05776"), logcosh=( "log(cosh(x)) acts similar to MSE for small errors and to MAE for large errors. Like " "MSE, it is very stable and prevents overshoots when errors are near zero. Like MAE, it " diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index b29fd320d9..f25834f413 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -53,15 +53,16 @@ class Loss(): def __init__(self, config: dict) -> None: logger.debug("Initializing %s", self.__class__.__name__) self._config = config - self._loss_dict = dict(mae=k_losses.mean_absolute_error, - mse=k_losses.mean_squared_error, - logcosh=k_losses.logcosh, - smooth_loss=losses.GeneralizedLoss(), + self._loss_dict = dict(gmsd=losses.GMSDLoss(), l_inf_norm=losses.LInfNorm(), - ssim=losses.DSSIMObjective(), + laploss=losses.LaplacianPyramidLoss(), + logcosh=k_losses.logcosh, ms_ssim=losses.MSSIMLoss(), - gmsd=losses.GMSDLoss(), - pixel_gradient_diff=losses.GradientLoss()) + mae=k_losses.mean_absolute_error, + mse=k_losses.mean_squared_error, + pixel_gradient_diff=losses.GradientLoss(), + ssim=losses.DSSIMObjective(), + smooth_loss=losses.GeneralizedLoss(),) self._mask_channels = self._get_mask_channels() self._inputs: List[keras.layers.Layer] = [] self._names: List[str] = [] diff --git a/setup.cfg b/setup.cfg index 5245a28a9f..4b410f72fc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,5 +10,15 @@ exclude = .git, __pycache__ ignore_missing_imports = True [mypy-keras.*] ignore_missing_imports = True +[mypy-psutil.*] +ignore_missing_imports = True [mypy-plaidml.*] ignore_missing_imports = True +[mypy-tqdm.*] +ignore_missing_imports = True +[mypy-pynvml.*] +ignore_missing_imports = True +[mypy-pynvx.*] +ignore_missing_imports = True +[mypy-cv2.*] +ignore_missing_imports = True diff --git a/tests/lib/model/losses_test.py b/tests/lib/model/losses_test.py index 02d62525db..33ad217700 100644 --- a/tests/lib/model/losses_test.py +++ b/tests/lib/model/losses_test.py @@ -41,11 +41,18 @@ def test_loss_output(loss_func, output_shape): assert output.dtype == "float32" and not np.any(np.isnan(output)) -_LWPARAMS = [losses.GeneralizedLoss(), losses.GradientLoss(), losses.GMSDLoss(), - losses.LInfNorm(), k_losses.mean_absolute_error, k_losses.mean_squared_error, - k_losses.logcosh, losses.DSSIMObjective(), losses.MSSIMLoss()] -_LWIDS = ["GeneralizedLoss", "GradientLoss", "GMSDLoss", "LInfNorm", "mae", "mse", "logcosh", - "DSSIMObjective", "MS-SSIM"] +_LWPARAMS = [losses.DSSIMObjective(), + losses.GeneralizedLoss(), + losses.GMSDLoss(), + losses.GradientLoss(), + losses.LaplacianPyramidLoss(), + losses.LInfNorm(), + k_losses.logcosh, + k_losses.mean_absolute_error, + k_losses.mean_squared_error, + losses.MSSIMLoss()] +_LWIDS = ["DSSIMObjective", "GeneralizedLoss", "GMSDLoss", "GradientLoss", "LaplacianPyramidLoss", + "LInfNorm", "logcosh", "mae", "mse", "MS-SSIM"] _LWIDS = [f"{loss}[{get_backend().upper()}]" for loss in _LWIDS] @@ -57,8 +64,8 @@ def test_loss_wrapper(loss_func): pytest.skip("GMSD Loss is not currently compatible with PlaidML") if hasattr(loss_func, "__name__") and loss_func.__name__ == "logcosh": pytest.skip("LogCosh Loss is not currently compatible with PlaidML") - y_a = K.variable(np.random.random((2, 16, 16, 4))) - y_b = K.variable(np.random.random((2, 16, 16, 3))) + y_a = K.variable(np.random.random((2, 64, 64, 4))) + y_b = K.variable(np.random.random((2, 64, 64, 3))) p_loss = losses.LossWrapper() p_loss.add_loss(loss_func, 1.0, -1) p_loss.add_loss(k_losses.mean_squared_error, 2.0, 3) diff --git a/tests/simple_tests.py b/tests/simple_tests.py index 8d52a8fc5d..973a59df0d 100644 --- a/tests/simple_tests.py +++ b/tests/simple_tests.py @@ -14,6 +14,7 @@ import os from os.path import join as pathjoin, expanduser +_TRAIN_ARGS = (1, 1) if os.environ.get("FACESWAP_BACKEND", "cpu").lower() == "amd" else (4, 4) FAIL_COUNT = 0 TEST_COUNT = 0 _COLORS = { @@ -167,18 +168,21 @@ def main(): run_test( "Train lightweight model for 1 iteration with WTL.", - train_args( - "lightweight", pathjoin(vid_base, "model"), - pathjoin(vid_base, "faces"), extra_args="-wl" - ) - ) + train_args("lightweight", + pathjoin(vid_base, "model"), + pathjoin(vid_base, "faces"), + iterations=_TRAIN_ARGS[0], + batchsize=_TRAIN_ARGS[1], + extra_args="-wl")) was_trained = run_test( "Train lightweight model for 1 iterations WITHOUT WTL.", - train_args( - "lightweight", pathjoin(vid_base, "model"), pathjoin(vid_base, "faces") - ) - ) + train_args("lightweight", + pathjoin(vid_base, "model"), + pathjoin(vid_base, "faces"), + iterations=_TRAIN_ARGS[0], + batchsize=_TRAIN_ARGS[1], + extra_args="-wl")) if was_trained: run_test( From 9d55ade27fda4423f04bf10a3e2196328b81b532 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 18 Jun 2022 11:16:03 +0100 Subject: [PATCH 626/981] amd gpu-stats bugfix --- lib/convert.py | 4 ++-- lib/gpu_stats/amd.py | 2 +- tools/manual/frameviewer/editor/mask.py | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/convert.py b/lib/convert.py index 224d6daca2..d69d81c5c3 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -162,8 +162,8 @@ def process(self, in_queue, out_queue): loglevel("Convert error traceback:", exc_info=True) log_once = True # UNCOMMENT THIS CODE BLOCK TO PRINT TRACEBACK ERRORS - import sys ; import traceback - exc_info = sys.exc_info() ; traceback.print_exception(*exc_info) + # import sys; import traceback + # exc_info = sys.exc_info(); traceback.print_exception(*exc_info) logger.trace("Out queue put: %s", item["filename"]) out_queue.put((item["filename"], image)) logger.debug("Completed convert process") diff --git a/lib/gpu_stats/amd.py b/lib/gpu_stats/amd.py index 39a271186e..e927c6bc3a 100644 --- a/lib/gpu_stats/amd.py +++ b/lib/gpu_stats/amd.py @@ -106,7 +106,7 @@ def _supported_indices(self) -> List[int]: @property def _all_vram(self) -> List[int]: """ list: The VRAM of each GPU device that PlaidML has discovered. """ - return [int(device.get("globalMemSize", 0) / (1024 * 1024)) + return [int(int(device.get("globalMemSize", 0)) / (1024 * 1024)) for device in self._device_details] @property diff --git a/tools/manual/frameviewer/editor/mask.py b/tools/manual/frameviewer/editor/mask.py index 99d3f6b09d..66372ce522 100644 --- a/tools/manual/frameviewer/editor/mask.py +++ b/tools/manual/frameviewer/editor/mask.py @@ -122,6 +122,7 @@ def _add_controls(self): default="Circle", is_radio=True, helptext=_("Select a shape for masking cursor."))) + def _set_tk_mask_change_callback(self): """ Add a trace to change the displayed mask on a mask type change. """ var = self._control_vars["display"]["MaskType"] From 308c5edf39bd804ab427c22f6191559d7efa3bd1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 18 Jun 2022 14:33:45 +0100 Subject: [PATCH 627/981] Loss updates - Split Loss to own package - Add Focal Frequency Loss (Nvidia only) - Enable GMSD for AMD --- lib/gui/__init__.py | 16 +- lib/model/__init__.py | 3 +- lib/model/loss/__init__.py | 9 + .../{losses_plaid.py => loss/loss_plaid.py} | 694 ++++++++++-------- lib/model/{losses_tf.py => loss/loss_tf.py} | 667 +++++++++++------ plugins/train/_config.py | 40 +- plugins/train/model/_base/settings.py | 27 +- tests/lib/model/losses_test.py | 17 +- 8 files changed, 887 insertions(+), 586 deletions(-) create mode 100644 lib/model/loss/__init__.py rename lib/model/{losses_plaid.py => loss/loss_plaid.py} (80%) rename lib/model/{losses_tf.py => loss/loss_tf.py} (69%) diff --git a/lib/gui/__init__.py b/lib/gui/__init__.py index 22697f72e6..020121f8b5 100644 --- a/lib/gui/__init__.py +++ b/lib/gui/__init__.py @@ -1,12 +1,12 @@ #!/usr/bin python3 """ The Faceswap GUI """ -from lib.gui.command import CommandNotebook -from lib.gui.custom_widgets import ConsoleOut, StatusBar -from lib.gui.display import DisplayNotebook -from lib.gui.options import CliOptions -from lib.gui.menu import MainMenuBar, TaskBar -from lib.gui.project import LastSession -from lib.gui.utils import (get_config, get_images, initialize_config, initialize_images, +from lib.gui.command import CommandNotebook # noqa +from lib.gui.custom_widgets import ConsoleOut, StatusBar # noqa +from lib.gui.display import DisplayNotebook # noqa +from lib.gui.options import CliOptions # noqa +from lib.gui.menu import MainMenuBar, TaskBar # noqa +from lib.gui.project import LastSession # noqa +from lib.gui.utils import (get_config, get_images, initialize_config, initialize_images, # noqa preview_trigger) -from lib.gui.wrapper import ProcessWrapper +from lib.gui.wrapper import ProcessWrapper # noqa diff --git a/lib/model/__init__.py b/lib/model/__init__.py index 487097af03..6962d814f3 100644 --- a/lib/model/__init__.py +++ b/lib/model/__init__.py @@ -5,10 +5,9 @@ from .normalization import (AdaInstanceNormalization, GroupNormalization, # noqa InstanceNormalization, LayerNormalization, RMSNormalization) +from .loss import losses # noqa if get_backend() == "amd": - from . import losses_plaid as losses # noqa from . import optimizers_plaid as optimizers # noqa else: - from . import losses_tf as losses #type:ignore # noqa from . import optimizers_tf as optimizers #type:ignore # noqa diff --git a/lib/model/loss/__init__.py b/lib/model/loss/__init__.py new file mode 100644 index 0000000000..d7c0bb2d16 --- /dev/null +++ b/lib/model/loss/__init__.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +""" Conditional imports depending on whether the AMD version is installed or not """ + +from lib.utils import get_backend + +if get_backend() == "amd": + from . import loss_plaid as losses # noqa +else: + from . import loss_tf as losses # type:ignore # noqa diff --git a/lib/model/losses_plaid.py b/lib/model/loss/loss_plaid.py similarity index 80% rename from lib/model/losses_plaid.py rename to lib/model/loss/loss_plaid.py index 70998781d7..5d01346b2d 100644 --- a/lib/model/losses_plaid.py +++ b/lib/model/loss/loss_plaid.py @@ -8,7 +8,6 @@ import numpy as np import plaidml -import tensorflow as tf from keras import backend as K from lib.plaidml_utils import pad @@ -157,165 +156,82 @@ def __call__(self, :class:`plaidml.tile.Value` The DSSIM or MS-DSSIM for the given images """ + print(K.int_shape(y_pred)) + ssim = self._get_ssim(y_true, y_pred)[0] retval = (1. - ssim) / 2.0 return K.mean(retval) -class MSSIMLoss(DSSIMObjective): # pylint:disable=too-few-public-methods - """ Multiscale Structural Similarity Loss Function +class FocalFrequencyLoss(): # pylint:disable=too-few-public-methods + """ Focal Frequencey Loss Function. - Parameters - ---------- - k_1: float, optional - Parameter of the SSIM. Default: `0.01` - k_2: float, optional - Parameter of the SSIM. Default: `0.03` - filter_size: int, optional - size of gaussian filter Default: `11` - filter_sigma: float, optional - Width of gaussian filter Default: `1.5` - max_value: float, optional - Max value of the output. Default: `1.0` - power_factors: tuple, optional - Iterable of weights for each of the scales. The number of scales used is the length of the - list. Index 0 is the unscaled resolution's weight and each increasing scale corresponds to - the image being downsampled by 2. Defaults to the values obtained in the original paper. - Default: (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) + A channels last implementation. Notes - ------ - You should add a regularization term like a l2 loss in addition to this one. - """ - def __init__(self, - k_1: float = 0.01, - k_2: float = 0.03, - filter_size: int = 11, - filter_sigma: float = 1.5, - max_value: float = 1.0, - power_factors: Tuple[float, ...] = (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) - ) -> None: - super().__init__(k_1=k_1, - k_2=k_2, - filter_size=filter_size, - filter_sigma=filter_sigma, - max_value=max_value) - self._power_factors = K.constant(power_factors) - - def _get_smallest_size(self, size: int, idx: int) -> int: - """ Recursive function to obtain the smallest size that the image will be scaled to. - for MS-SSIM - - Parameters - ---------- - size: int - The current scaled size to iterate through - idx: int - The current iteration to be performed. When iteration hits zero the value will - be returned - - Returns - ------- - int - The smallest size the image will be scaled to based on the original image size and - the amount of scaling factors that will occur - """ - logger.debug("scale id: %s, size: %s", idx, size) - if idx > 0: - size = self._get_smallest_size(size // 2, idx - 1) - return size - - @classmethod - def _shrink_images(cls, images: List[plaidml.tile.Value]) -> List[plaidml.tile.Value]: - """ Reduce the dimensional space of a batch of images in half. If the images are an odd - number of pixels then pad them to an even dimension prior to shrinking - - All incoming images are assumed square. + ----- + There is a bug in this implementation that will do an incorrect FFT if + :attr:`patch_factor` > ``1``, which means incorrect loss will be returned, so keep + patch factor at 1. - Parameters - ---------- - images: list - The y_true, y_pred batch of images to be shrunk + Parameters + ---------- + alpha: float, Optional + Scaling factor of the spectrum weight matrix for flexibility. Default: ``1.0`` + patch_factor: int, Optional + Factor to crop image patches for patch-based focal frequency loss. + Default: ``1`` + ave_spectrum: bool, Optional + ``True`` to use minibatch average spectrum otherwise ``False``. Default: ``False`` + log_matrix: bool, Optional + ``True`` to adjust the spectrum weight matrix by logarithm otherwise ``False``. + Default: ``False`` + batch_matrix: bool, Optional + ``True`` to calculate the spectrum weight matrix using batch-based statistics otherwise + ``False``. Default: ``False`` - Returns - ------- - list - The y_true, y_pred batch shrunk by half - """ - if any(x % 2 != 0 for x in K.int_shape(images[1])[1:2]): - images = [pad(img, - [[0, 0], [0, 1], [0, 1], [0, 0]], - mode="REFLECT") - for img in images] + References + ---------- + https://arxiv.org/pdf/2012.12821.pdf + https://github.com/EndlessSora/focal-frequency-loss + """ - images = [K.pool2d(img, (2, 2), strides=(2, 2), padding="valid", pool_mode="avg") - for img in images] + def __init__(self, + alpha: float = 1.0, + patch_factor: int = 1, + ave_spectrum: bool = False, + log_matrix: bool = False, + batch_matrix: bool = False) -> None: + self._alpha = alpha + self._patch_factor = patch_factor + self._ave_spectrum = ave_spectrum + self._log_matrix = log_matrix + self._batch_matrix = batch_matrix + self._dims: Tuple[int, int] = (0, 0) - return images + def __call__(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: + """ Call the Focal Frequency Loss Function. - def _get_ms_ssim(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Obtain the Multiscale Stuctural Similarity metric. + # TODO Not implemented as: + - We need a PlaidML replacement for tf.signal + - The dimensions do not appear to be readable for y_pred Parameters ---------- y_true: :class:`plaidml.tile.Value` - The input batch of ground truth images + The ground truth batch of images y_pred: :class:`plaidml.tile.Value` - The input batch of predicted images + The predicted batch of images Returns ------- :class:`plaidml.tile.Value` - The MS-SSIM for the given images - """ - im_size = K.int_shape(y_pred)[1] - # filter size cannot be larger than the smallest scale - recursions = K.int_shape(self._power_factors)[0] - smallest_scale = self._get_smallest_size(im_size, recursions - 1) - if smallest_scale < self._filter_size: - self._filter_size = smallest_scale - self._kernel = self._get_kernel() - - images = [y_true, y_pred] - contrasts = [] - - for idx in range(recursions): - images = self._shrink_images(images) if idx > 0 else images - ssim, contrast = self._get_ssim(*images) - - if idx < recursions - 1: - contrasts.append(K.relu(K.expand_dims(contrast, axis=-1))) - - contrasts.append(K.relu(K.expand_dims(ssim, axis=-1))) - mcs_and_ssim = K.concatenate(contrasts, axis=-1) - ms_ssim = K.pow(mcs_and_ssim, self._power_factors) - - # K.prod does not work in plaidml so slow recursion it is - out = ms_ssim[..., 0] - for idx in range(1, recursions): - out *= ms_ssim[..., idx] - return out - - def __call__(self, y_true, y_pred): - """ Call the MS-SSIM Loss Function. - - Parameters - ---------- - y_true: tensor or variable - The ground truth value - y_pred: tensor or variable - The predicted value - - Returns - ------- - tensor - The MS-SSIM Loss value + The loss for this batch of images """ - ms_ssim = self._get_ms_ssim(y_true, y_pred) - retval = 1. - ms_ssim - return K.mean(retval) + raise FaceswapError("Focal Frequency Loss is not currently compatible with PlaidML. " + "Please select a different Loss method.") class GeneralizedLoss(): # pylint:disable=too-few-public-methods @@ -340,55 +256,147 @@ class GeneralizedLoss(): # pylint:disable=too-few-public-methods Scale factor used to adjust to the input scale (i.e. inputs of mean `1e-4` or `256`). Default: `1.0/255.0` """ - def __init__(self, alpha=1.0, beta=1.0/255.0): - self.alpha = alpha - self.beta = beta + def __init__(self, alpha: float = 1.0, beta: float = 1.0/255.0) -> None: + self._alpha = alpha + self._beta = beta - def __call__(self, y_true, y_pred): + def __call__(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: """ Call the Generalized Loss Function Parameters ---------- - y_true: tensor or variable + y_true: :class:`plaidml.tile.Value` The ground truth value - y_pred: tensor or variable + y_pred: :class:`plaidml.tile.Value` The predicted value Returns ------- - tensor + :class:`plaidml.tile.Value` The loss value from the results of function(y_pred - y_true) """ diff = y_pred - y_true - second = (K.pow(K.pow(diff/self.beta, 2.) / K.abs(2. - self.alpha) + 1., - (self.alpha / 2.)) - 1.) - loss = (K.abs(2. - self.alpha)/self.alpha) * second - loss = K.mean(loss, axis=-1) * self.beta + second = (K.pow(K.pow(diff/self._beta, 2.) / K.abs(2. - self._alpha) + 1., + (self._alpha / 2.)) - 1.) + loss = (K.abs(2. - self._alpha)/self._alpha) * second + loss = K.mean(loss, axis=-1) * self._beta return loss -class LInfNorm(): # pylint:disable=too-few-public-methods - """ Calculate the L-inf norm as a loss function. """ +class GMSDLoss(): # pylint:disable=too-few-public-methods + """ Gradient Magnitude Similarity Deviation Loss. - def __call__(self, y_true, y_pred): - """ Call the L-inf norm loss function. + Improved image quality metric over MS-SSIM with easier calculations + + References + ---------- + http://www4.comp.polyu.edu.hk/~cslzhang/IQA/GMSD/GMSD.htm + https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf + """ + def __init__(self, input_dims: Tuple[int, int]) -> None: + self._input_dims = input_dims + + def __call__(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: + """ Return the Gradient Magnitude Similarity Deviation Loss. Parameters ---------- - y_true: tensor or variable + y_true: :class:`plaidml.tile.Value` The ground truth value - y_pred: tensor or variable + y_pred: :class:`plaidml.tile.Value` The predicted value Returns ------- - tensor + :class:`plaidml.tile.Value` The loss value """ - diff = K.abs(y_true - y_pred) - max_loss = K.max(diff, axis=(1, 2), keepdims=True) - loss = K.mean(max_loss, axis=-1) - return loss + image_shape = (None, *self._input_dims, K.int_shape(y_pred)[-1]) + true_edge = self._scharr_edges(y_true, True, image_shape) + pred_edge = self._scharr_edges(y_pred, True, image_shape) + ephsilon = 0.0025 + upper = 2.0 * true_edge * pred_edge + lower = K.square(true_edge) + K.square(pred_edge) + gms = (upper + ephsilon) / (lower + ephsilon) + gmsd = K.std(gms, axis=(1, 2, 3), keepdims=True) + gmsd = K.squeeze(gmsd, axis=-1) + return gmsd + + @classmethod + def _scharr_edges(cls, + image: plaidml.tile.Value, + magnitude: bool, + image_shape: Tuple[None, int, int, int]) -> plaidml.tile.Value: + """ Returns a tensor holding modified Scharr edge maps. + + Parameters + ---------- + image: :class:`plaidml.tile.Value` + Image tensor with shape [batch_size, h, w, d] and type float32. The image(s) must be + 2x2 or larger. + magnitude: bool + Boolean to determine if the edge magnitude or edge direction is returned + image_shape: tuple + The shape of the incoming image + + Returns + ------- + :class:`plaidml.tile.Value` + Tensor holding edge maps for each channel. Returns a tensor with shape `[batch_size, h, + w, d, 2]` where the last two dimensions hold `[[dy[0], dx[0]], [dy[1], dx[1]], ..., + [dy[d-1], dx[d-1]]]` calculated using the Scharr filter. + """ + # Define vertical and horizontal Scharr filters. + # 5x5 modified Scharr kernel ( reshape to (5,5,1,2) ) + matrix = np.array([[[[0.00070, 0.00070]], + [[0.00520, 0.00370]], + [[0.03700, 0.00000]], + [[0.00520, -0.0037]], + [[0.00070, -0.0007]]], + [[[0.00370, 0.00520]], + [[0.11870, 0.11870]], + [[0.25890, 0.00000]], + [[0.11870, -0.1187]], + [[0.00370, -0.0052]]], + [[[0.00000, 0.03700]], + [[0.00000, 0.25890]], + [[0.00000, 0.00000]], + [[0.00000, -0.2589]], + [[0.00000, -0.0370]]], + [[[-0.0037, 0.00520]], + [[-0.1187, 0.11870]], + [[-0.2589, 0.00000]], + [[-0.1187, -0.1187]], + [[-0.0037, -0.0052]]], + [[[-0.0007, 0.00070]], + [[-0.0052, 0.00370]], + [[-0.0370, 0.00000]], + [[-0.0052, -0.0037]], + [[-0.0007, -0.0007]]]]) + # num_kernels = [2] + kernels = K.constant(matrix, dtype='float32') + kernels = K.tile(kernels, [1, 1, image_shape[-1], 1]) + + # Use depth-wise convolution to calculate edge maps per channel. + # Output tensor has shape [batch_size, h, w, d * num_kernels]. + pad_sizes = [[0, 0], [2, 2], [2, 2], [0, 0]] + padded = pad(image, pad_sizes, mode='REFLECT') + output = K.depthwise_conv2d(padded, kernels) + + # TODO magnitude not implemented for plaidml + if not magnitude: # direction of edges + raise FaceswapError("Magnitude for GMSD Loss is not implemented in PlaidML") + # # Reshape to [batch_size, h, w, d, num_kernels]. + # shape = K.concatenate([image_shape, num_kernels], axis=0) + # output = K.reshape(output, shape=shape) + # output.set_shape(static_image_shape.concatenate(num_kernels)) + # output = tf.atan(K.squeeze(output[:, :, :, :, 0] / output[:, :, :, :, 1], axis=None)) + # magnitude of edges -- unified x & y edges don't work well with Neural Networks + return output class GradientLoss(): # pylint:disable=too-few-public-methods @@ -407,19 +415,21 @@ class GradientLoss(): # pylint:disable=too-few-public-methods def __init__(self): self.generalized_loss = GeneralizedLoss(alpha=1.9999) - def __call__(self, y_true, y_pred): + def __call__(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: """ Call the gradient loss function. Parameters ---------- - y_true: tensor or variable + y_true: :class:`plaidml.tile.Value` The ground truth value y_pred: tensor or variable - The predicted value + :class:`plaidml.tile.Value` Returns ------- - tensor + :class:`plaidml.tile.Value` The loss value """ tv_weight = 1.0 @@ -472,157 +482,49 @@ def _diff_yy(cls, img): return y_out - 2.0 * img @classmethod - def _diff_xy(cls, img): + def _diff_xy(cls, img: plaidml.tile.Value) -> plaidml.tile.Value: """ X-Y Difference """ # xout1 - top_left = img[:, 1:2, 1:2, :] + img[:, 0:1, 0:1, :] - inner_left = img[:, 2:, 1:2, :] + img[:, :-2, 0:1, :] - bot_left = img[:, -1:, 1:2, :] + img[:, -2:-1, 0:1, :] - xy_left = K.concatenate([top_left, inner_left, bot_left], axis=1) - - top_mid = img[:, 1:2, 2:, :] + img[:, 0:1, :-2, :] - mid_mid = img[:, 2:, 2:, :] + img[:, :-2, :-2, :] - bot_mid = img[:, -1:, 2:, :] + img[:, -2:-1, :-2, :] - xy_mid = K.concatenate([top_mid, mid_mid, bot_mid], axis=1) - - top_right = img[:, 1:2, -1:, :] + img[:, 0:1, -2:-1, :] - inner_right = img[:, 2:, -1:, :] + img[:, :-2, -2:-1, :] - bot_right = img[:, -1:, -1:, :] + img[:, -2:-1, -2:-1, :] - xy_right = K.concatenate([top_right, inner_right, bot_right], axis=1) + # Left + top = img[:, 1:2, 1:2, :] + img[:, 0:1, 0:1, :] + inner = img[:, 2:, 1:2, :] + img[:, :-2, 0:1, :] + bottom = img[:, -1:, 1:2, :] + img[:, -2:-1, 0:1, :] + xy_left = K.concatenate([top, inner, bottom], axis=1) + # Mid + top = img[:, 1:2, 2:, :] + img[:, 0:1, :-2, :] + mid = img[:, 2:, 2:, :] + img[:, :-2, :-2, :] + bottom = img[:, -1:, 2:, :] + img[:, -2:-1, :-2, :] + xy_mid = K.concatenate([top, mid, bottom], axis=1) + # Right + top = img[:, 1:2, -1:, :] + img[:, 0:1, -2:-1, :] + inner = img[:, 2:, -1:, :] + img[:, :-2, -2:-1, :] + bottom = img[:, -1:, -1:, :] + img[:, -2:-1, -2:-1, :] + xy_right = K.concatenate([top, inner, bottom], axis=1) # Xout2 - top_left = img[:, 0:1, 1:2, :] + img[:, 1:2, 0:1, :] - inner_left = img[:, :-2, 1:2, :] + img[:, 2:, 0:1, :] - bot_left = img[:, -2:-1, 1:2, :] + img[:, -1:, 0:1, :] - xy_left = K.concatenate([top_left, inner_left, bot_left], axis=1) - - top_mid = img[:, 0:1, 2:, :] + img[:, 1:2, :-2, :] - mid_mid = img[:, :-2, 2:, :] + img[:, 2:, :-2, :] - bot_mid = img[:, -2:-1, 2:, :] + img[:, -1:, :-2, :] - xy_mid = K.concatenate([top_mid, mid_mid, bot_mid], axis=1) - - top_right = img[:, 0:1, -1:, :] + img[:, 1:2, -2:-1, :] - inner_right = img[:, :-2, -1:, :] + img[:, 2:, -2:-1, :] - bot_right = img[:, -2:-1, -1:, :] + img[:, -1:, -2:-1, :] - xy_right = K.concatenate([top_right, inner_right, bot_right], axis=1) + # Left + top = img[:, 0:1, 1:2, :] + img[:, 1:2, 0:1, :] + inner = img[:, :-2, 1:2, :] + img[:, 2:, 0:1, :] + bottom = img[:, -2:-1, 1:2, :] + img[:, -1:, 0:1, :] + xy_left = K.concatenate([top, inner, bottom], axis=1) + # Mid + top = img[:, 0:1, 2:, :] + img[:, 1:2, :-2, :] + mid = img[:, :-2, 2:, :] + img[:, 2:, :-2, :] + bottom = img[:, -2:-1, 2:, :] + img[:, -1:, :-2, :] + xy_mid = K.concatenate([top, mid, bottom], axis=1) + # Right + top = img[:, 0:1, -1:, :] + img[:, 1:2, -2:-1, :] + inner = img[:, :-2, -1:, :] + img[:, 2:, -2:-1, :] + bottom = img[:, -2:-1, -1:, :] + img[:, -1:, -2:-1, :] + xy_right = K.concatenate([top, inner, bottom], axis=1) xy_out1 = K.concatenate([xy_left, xy_mid, xy_right], axis=2) xy_out2 = K.concatenate([xy_left, xy_mid, xy_right], axis=2) return (xy_out1 - xy_out2) * 0.25 -class GMSDLoss(): # pylint:disable=too-few-public-methods - """ Gradient Magnitude Similarity Deviation Loss. - - Improved image quality metric over MS-SSIM with easier calculations - - References - ---------- - http://www4.comp.polyu.edu.hk/~cslzhang/IQA/GMSD/GMSD.htm - https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf - """ - - def __call__(self, y_true, y_pred): - """ Return the Gradient Magnitude Similarity Deviation Loss. - - Parameters - ---------- - y_true: tensor or variable - The ground truth value - y_pred: tensor or variable - The predicted value - - Returns - ------- - tensor - The loss value - """ - raise FaceswapError("GMSD Loss is not currently compatible with PlaidML. Please select a " - "different Loss method.") - - true_edge = self._scharr_edges(y_true, True) - pred_edge = self._scharr_edges(y_pred, True) - ephsilon = 0.0025 - upper = 2.0 * true_edge * pred_edge - lower = K.square(true_edge) + K.square(pred_edge) - gms = (upper + ephsilon) / (lower + ephsilon) - gmsd = K.std(gms, axis=(1, 2, 3), keepdims=True) - gmsd = K.squeeze(gmsd, axis=-1) - return gmsd - - @classmethod - def _scharr_edges(cls, image, magnitude): - """ Returns a tensor holding modified Scharr edge maps. - - Parameters - ---------- - image: tensor - Image tensor with shape [batch_size, h, w, d] and type float32. The image(s) must be - 2x2 or larger. - magnitude: bool - Boolean to determine if the edge magnitude or edge direction is returned - - Returns - ------- - tensor - Tensor holding edge maps for each channel. Returns a tensor with shape `[batch_size, h, - w, d, 2]` where the last two dimensions hold `[[dy[0], dx[0]], [dy[1], dx[1]], ..., - [dy[d-1], dx[d-1]]]` calculated using the Scharr filter. - """ - - # Define vertical and horizontal Scharr filters. - # TODO PlaidML: AttributeError: 'Value' object has no attribute 'get_shape' - static_image_shape = image.get_shape() - image_shape = K.shape(image) - - # 5x5 modified Scharr kernel ( reshape to (5,5,1,2) ) - matrix = np.array([[[[0.00070, 0.00070]], - [[0.00520, 0.00370]], - [[0.03700, 0.00000]], - [[0.00520, -0.0037]], - [[0.00070, -0.0007]]], - [[[0.00370, 0.00520]], - [[0.11870, 0.11870]], - [[0.25890, 0.00000]], - [[0.11870, -0.1187]], - [[0.00370, -0.0052]]], - [[[0.00000, 0.03700]], - [[0.00000, 0.25890]], - [[0.00000, 0.00000]], - [[0.00000, -0.2589]], - [[0.00000, -0.0370]]], - [[[-0.0037, 0.00520]], - [[-0.1187, 0.11870]], - [[-0.2589, 0.00000]], - [[-0.1187, -0.1187]], - [[-0.0037, -0.0052]]], - [[[-0.0007, 0.00070]], - [[-0.0052, 0.00370]], - [[-0.0370, 0.00000]], - [[-0.0052, -0.0037]], - [[-0.0007, -0.0007]]]]) - num_kernels = [2] - kernels = K.constant(matrix, dtype='float32') - kernels = K.tile(kernels, [1, 1, image_shape[-1], 1]) - - # Use depth-wise convolution to calculate edge maps per channel. - # Output tensor has shape [batch_size, h, w, d * num_kernels]. - pad_sizes = [[0, 0], [2, 2], [2, 2], [0, 0]] - padded = pad(image, pad_sizes, mode='REFLECT') - output = K.depthwise_conv2d(padded, kernels) - - if not magnitude: # direction of edges - # Reshape to [batch_size, h, w, d, num_kernels]. - shape = K.concatenate([image_shape, num_kernels], axis=0) - output = K.reshape(output, shape=shape) - output.set_shape(static_image_shape.concatenate(num_kernels)) - output = tf.atan(K.squeeze(output[:, :, :, :, 0] / output[:, :, :, :, 1], axis=None)) - # magnitude of edges -- unified x & y edges don't work well with Neural Networks - return output - - -class LaplacianPyramidLoss(): # pylint:disable=too-few-public-methods - """ Laplacian Pyramid Loss Function +class LaplacianPyramidLoss(): # pylint:disable=too-few-public-methods + """ Laplacian Pyramid Loss Function Notes ----- @@ -755,6 +657,190 @@ def __call__(self, return loss +class LInfNorm(): # pylint:disable=too-few-public-methods + """ Calculate the L-inf norm as a loss function. """ + + def __call__(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: + """ Call the L-inf norm loss function. + + Parameters + ---------- + y_true: :class:`plaidml.tile.Value` + The ground truth value + y_pred: :class:`plaidml.tile.Value` + The predicted value + + Returns + ------- + :class:`plaidml.tile.Value` + The loss value + """ + diff = K.abs(y_true - y_pred) + max_loss = K.max(diff, axis=(1, 2), keepdims=True) + loss = K.mean(max_loss, axis=-1) + return loss + + +class MSSIMLoss(DSSIMObjective): # pylint:disable=too-few-public-methods + """ Multiscale Structural Similarity Loss Function + + Parameters + ---------- + k_1: float, optional + Parameter of the SSIM. Default: `0.01` + k_2: float, optional + Parameter of the SSIM. Default: `0.03` + filter_size: int, optional + size of gaussian filter Default: `11` + filter_sigma: float, optional + Width of gaussian filter Default: `1.5` + max_value: float, optional + Max value of the output. Default: `1.0` + power_factors: tuple, optional + Iterable of weights for each of the scales. The number of scales used is the length of the + list. Index 0 is the unscaled resolution's weight and each increasing scale corresponds to + the image being downsampled by 2. Defaults to the values obtained in the original paper. + Default: (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) + + Notes + ------ + You should add a regularization term like a l2 loss in addition to this one. + """ + def __init__(self, + k_1: float = 0.01, + k_2: float = 0.03, + filter_size: int = 11, + filter_sigma: float = 1.5, + max_value: float = 1.0, + power_factors: Tuple[float, ...] = (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) + ) -> None: + super().__init__(k_1=k_1, + k_2=k_2, + filter_size=filter_size, + filter_sigma=filter_sigma, + max_value=max_value) + self._power_factors = K.constant(power_factors) + + def _get_smallest_size(self, size: int, idx: int) -> int: + """ Recursive function to obtain the smallest size that the image will be scaled to. + for MS-SSIM + + Parameters + ---------- + size: int + The current scaled size to iterate through + idx: int + The current iteration to be performed. When iteration hits zero the value will + be returned + + Returns + ------- + int + The smallest size the image will be scaled to based on the original image size and + the amount of scaling factors that will occur + """ + logger.debug("scale id: %s, size: %s", idx, size) + if idx > 0: + size = self._get_smallest_size(size // 2, idx - 1) + return size + + @classmethod + def _shrink_images(cls, images: List[plaidml.tile.Value]) -> List[plaidml.tile.Value]: + """ Reduce the dimensional space of a batch of images in half. If the images are an odd + number of pixels then pad them to an even dimension prior to shrinking + + All incoming images are assumed square. + + Parameters + ---------- + images: list + The y_true, y_pred batch of images to be shrunk + + Returns + ------- + list + The y_true, y_pred batch shrunk by half + """ + if any(x % 2 != 0 for x in K.int_shape(images[1])[1:2]): + images = [pad(img, + [[0, 0], [0, 1], [0, 1], [0, 0]], + mode="REFLECT") + for img in images] + + images = [K.pool2d(img, (2, 2), strides=(2, 2), padding="valid", pool_mode="avg") + for img in images] + + return images + + def _get_ms_ssim(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: + """ Obtain the Multiscale Stuctural Similarity metric. + + Parameters + ---------- + y_true: :class:`plaidml.tile.Value` + The input batch of ground truth images + y_pred: :class:`plaidml.tile.Value` + The input batch of predicted images + + Returns + ------- + :class:`plaidml.tile.Value` + The MS-SSIM for the given images + """ + im_size = K.int_shape(y_pred)[1] + # filter size cannot be larger than the smallest scale + recursions = K.int_shape(self._power_factors)[0] + smallest_scale = self._get_smallest_size(im_size, recursions - 1) + if smallest_scale < self._filter_size: + self._filter_size = smallest_scale + self._kernel = self._get_kernel() + + images = [y_true, y_pred] + contrasts = [] + + for idx in range(recursions): + images = self._shrink_images(images) if idx > 0 else images + ssim, contrast = self._get_ssim(*images) + + if idx < recursions - 1: + contrasts.append(K.relu(K.expand_dims(contrast, axis=-1))) + + contrasts.append(K.relu(K.expand_dims(ssim, axis=-1))) + mcs_and_ssim = K.concatenate(contrasts, axis=-1) + ms_ssim = K.pow(mcs_and_ssim, self._power_factors) + + # K.prod does not work in plaidml so slow recursion it is + out = ms_ssim[..., 0] + for idx in range(1, recursions): + out *= ms_ssim[..., idx] + return out + + def __call__(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: + """ Call the MS-SSIM Loss Function. + + Parameters + ---------- + y_true: :class:`plaidml.tile.Value` + The ground truth value + y_pred: :class:`plaidml.tile.Value` + The predicted value + + Returns + ------- + :class:`plaidml.tile.Value` + The MS-SSIM Loss value + """ + ms_ssim = self._get_ms_ssim(y_true, y_pred) + retval = 1. - ms_ssim + return K.mean(retval) + + class LossWrapper(): # pylint:disable=too-few-public-methods """ A wrapper class for multiple keras losses to enable multiple weighted loss functions on a single output and masking. diff --git a/lib/model/losses_tf.py b/lib/model/loss/loss_tf.py similarity index 69% rename from lib/model/losses_tf.py rename to lib/model/loss/loss_tf.py index 3b5eaaa6ca..1e7ff597dc 100644 --- a/lib/model/losses_tf.py +++ b/lib/model/loss/loss_tf.py @@ -155,299 +155,253 @@ def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: return K.mean(retval) -class MSSIMLoss(): # pylint:disable=too-few-public-methods - """ Multiscale Structural Similarity Loss Function +class FocalFrequencyLoss(): # pylint:disable=too-few-public-methods + """ Focal Frequencey Loss Function. + + A channels last implementation. + + Notes + ----- + There is a bug in this implementation that will do an incorrect FFT if + :attr:`patch_factor` > ``1``, which means incorrect loss will be returned, so keep + patch factor at 1. Parameters ---------- - k_1: float, optional - Parameter of the SSIM. Default: `0.01` - k_2: float, optional - Parameter of the SSIM. Default: `0.03` - filter_size: int, optional - size of gaussian filter Default: `11` - filter_sigma: float, optional - Width of gaussian filter Default: `1.5` - max_value: float, optional - Max value of the output. Default: `1.0` - power_factors: tuple, optional - Iterable of weights for each of the scales. The number of scales used is the length of the - list. Index 0 is the unscaled resolution's weight and each increasing scale corresponds to - the image being downsampled by 2. Defaults to the values obtained in the original paper. - Default: (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) + alpha: float, Optional + Scaling factor of the spectrum weight matrix for flexibility. Default: ``1.0`` + patch_factor: int, Optional + Factor to crop image patches for patch-based focal frequency loss. + Default: ``1`` + ave_spectrum: bool, Optional + ``True`` to use minibatch average spectrum otherwise ``False``. Default: ``False`` + log_matrix: bool, Optional + ``True`` to adjust the spectrum weight matrix by logarithm otherwise ``False``. + Default: ``False`` + batch_matrix: bool, Optional + ``True`` to calculate the spectrum weight matrix using batch-based statistics otherwise + ``False``. Default: ``False`` - Notes - ------ - You should add a regularization term like a l2 loss in addition to this one. + References + ---------- + https://arxiv.org/pdf/2012.12821.pdf + https://github.com/EndlessSora/focal-frequency-loss """ - def __init__(self, - k_1: float = 0.01, - k_2: float = 0.03, - filter_size: int = 11, - filter_sigma: float = 1.5, - max_value: float = 1.0, - power_factors: Tuple[float, ...] = (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) - ) -> None: - self.filter_size = filter_size - self.filter_sigma = filter_sigma - self.k_1 = k_1 - self.k_2 = k_2 - self.max_value = max_value - self.power_factors = power_factors - def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: - """ Call the MS-SSIM Loss Function. + def __init__(self, + alpha: float = 1.0, + patch_factor: int = 1, + ave_spectrum: bool = False, + log_matrix: bool = False, + batch_matrix: bool = False) -> None: + self._alpha = alpha + # TODO Fix bug where FFT will be incorrect if patch_factor > 1 + self._patch_factor = patch_factor + self._ave_spectrum = ave_spectrum + self._log_matrix = log_matrix + self._batch_matrix = batch_matrix + self._dims: Tuple[int, int] = (0, 0) + + def _get_patches(self, inputs: tf.Tensor) -> tf.Tensor: + """ Crop the incoming batch of images into patches as defined by :attr:`_patch_factor. Parameters ---------- - y_true: :class:`tf.Tensor` - The ground truth value - y_pred: :class:`tf.Tensor` - The predicted value + inputs: :class:`tf.Tensor` + A batch of images to be converted into patches Returns ------- - :class:`tf.Tensor` - The MS-SSIM Loss value + :class`tf.Tensor`` + The incoming batch converted into patches """ - im_size = K.int_shape(y_true)[1] - # filter size cannot be larger than the smallest scale - smallest_scale = self._get_smallest_size(im_size, len(self.power_factors) - 1) - filter_size = min(self.filter_size, smallest_scale) + rows, cols = self._dims + patch_list = [] + patch_rows = cols // self._patch_factor + patch_cols = rows // self._patch_factor + for i in range(self._patch_factor): + for j in range(self._patch_factor): + row_from = i * patch_rows + row_to = (i + 1) * patch_rows + col_from = j * patch_cols + col_to = (j + 1) * patch_cols + patch_list.append(inputs[:, row_from: row_to, col_from: col_to, :]) + + retval = K.stack(patch_list, axis=1) + return retval - ms_ssim = tf.image.ssim_multiscale(y_true, - y_pred, - self.max_value, - power_factors=self.power_factors, - filter_size=filter_size, - filter_sigma=self.filter_sigma, - k1=self.k_1, - k2=self.k_2) - ms_ssim_loss = 1. - ms_ssim - return K.mean(ms_ssim_loss) - - def _get_smallest_size(self, size: int, idx: int) -> int: - """ Recursive function to obtain the smallest size that the image will be scaled to. + def _tensor_to_frequency_spectrum(self, patch: tf.Tensor) -> tf.Tensor: + """ Perform FFT to create the orthonomalized DFT frequencies. Parameters ---------- - size: int - The current scaled size to iterate through - idx: int - The current iteration to be performed. When iteration hits zero the value will - be returned + inputs: :class:`tf.Tensor` + The incoming batch of patches to convert to the frequency spectrum Returns ------- - int - The smallest size the image will be scaled to based on the original image size and - the amount of scaling factors that will occur + :class:`tf.Tensor` + The DFT frequencies split into real and imaginary numbers as float32 """ - logger.debug("scale id: %s, size: %s", idx, size) - if idx > 0: - size = self._get_smallest_size(size // 2, idx - 1) - return size + # TODO fix this for when self._patch_factor != 1. + rows, cols = self._dims + patch = K.permute_dimensions(patch, (0, 1, 4, 2, 3)) # move channels to first + patch = patch / np.sqrt(rows * cols) # Orthonormalization -class GeneralizedLoss(tf.keras.losses.Loss): # pylint:disable=too-few-public-methods - """ Generalized function used to return a large variety of mathematical loss functions. + patch = K.cast(patch, "complex64") + freq = tf.signal.fft2d(patch)[..., None] - The primary benefit is a smooth, differentiable version of L1 loss. + freq = K.concatenate([tf.math.real(freq), tf.math.imag(freq)], axis=-1) + freq = K.cast(freq, "float32") - References - ---------- - Barron, J. A More General Robust Loss Function - https://arxiv.org/pdf/1701.03077.pdf + freq = K.permute_dimensions(freq, (0, 1, 3, 4, 2, 5)) # channels to last - Example - ------- - >>> a=1.0, x>>c , c=1.0/255.0 # will give a smoothly differentiable version of L1 / MAE loss - >>> a=1.999999 (limit as a->2), beta=1.0/255.0 # will give L2 / RMSE loss - - Parameters - ---------- - alpha: float, optional - Penalty factor. Larger number give larger weight to large deviations. Default: `1.0` - beta: float, optional - Scale factor used to adjust to the input scale (i.e. inputs of mean `1e-4` or `256`). - Default: `1.0/255.0` - """ - def __init__(self, alpha=1.0, beta=1.0/255.0): - super().__init__(name="generalized_loss") - self.alpha = alpha - self.beta = beta + return freq - def call(self, y_true, y_pred): - """ Call the Generalized Loss Function + def _get_weight_matrix(self, freq_true: tf.Tensor, freq_pred: tf.Tensor) -> tf.Tensor: + """ Calculate a continuous, dynamic weight matrix based on current Euclidean distance. Parameters ---------- - y_true: tensor or variable - The ground truth value - y_pred: tensor or variable - The predicted value + freq_true: :class:`tf.Tensor` + The real and imaginary DFT frequencies for the true batch of images + freq_pred: :class:`tf.Tensor` + The real and imaginary DFT frequencies for the predicted batch of images Returns ------- - tensor - The loss value from the results of function(y_pred - y_true) + :class:`tf.Tensor` + The weights matrix for prioritizing hard frequencies """ - diff = y_pred - y_true - second = (K.pow(K.pow(diff/self.beta, 2.) / K.abs(2. - self.alpha) + 1., - (self.alpha / 2.)) - 1.) - loss = (K.abs(2. - self.alpha)/self.alpha) * second - loss = K.mean(loss, axis=-1) * self.beta - return loss + weights = K.square(freq_pred - freq_true) + weights = K.sqrt(weights[..., 0] + weights[..., 1]) + weights = K.pow(weights, self._alpha) + if self._log_matrix: # adjust the spectrum weight matrix by logarithm + weights = K.log(weights + 1.0) -class LInfNorm(tf.keras.losses.Loss): # pylint:disable=too-few-public-methods - """ Calculate the L-inf norm as a loss function. """ - def __init__(self): - super().__init__(name="l_inf_norm_loss") + if self._batch_matrix: # calculate the spectrum weight matrix using batch-based statistics + weights = weights / K.max(weights) + else: + weights = weights / K.max(K.max(weights, axis=-2), axis=-2)[..., None, None, :] + + weights = K.switch(tf.math.is_nan(weights), K.zeros_like(weights), weights) + weights = K.clip(weights, min_value=0.0, max_value=1.0) + + return weights @classmethod - def call(cls, y_true, y_pred): - """ Call the L-inf norm loss function. + def _calculate_loss(cls, + freq_true: tf.Tensor, + freq_pred: tf.Tensor, + weight_matrix: tf.Tensor) -> tf.Tensor: + """ Perform the loss calculation on the DFT spectrum applying the weights matrix. Parameters ---------- - y_true: tensor or variable - The ground truth value - y_pred: tensor or variable - The predicted value + freq_true: :class:`tf.Tensor` + The real and imaginary DFT frequencies for the true batch of images + freq_pred: :class:`tf.Tensor` + The real and imaginary DFT frequencies for the predicted batch of images Returns - ------- - tensor - The loss value + :class:`tf.Tensor` + The final loss matrix """ - diff = K.abs(y_true - y_pred) - max_loss = K.max(diff, axis=(1, 2), keepdims=True) - loss = K.mean(max_loss, axis=-1) - return loss + tmp = K.square(freq_pred - freq_true) # freq distance using squared Euclidean distance -class GradientLoss(tf.keras.losses.Loss): # pylint:disable=too-few-public-methods - """ Gradient Loss Function. + freq_distance = tmp[..., 0] + tmp[..., 1] + loss = weight_matrix * freq_distance # dynamic spectrum weighting (Hadamard product) - Calculates the first and second order gradient difference between pixels of an image in the x - and y dimensions. These gradients are then compared between the ground truth and the predicted - image and the difference is taken. When used as a loss, its minimization will result in - predicted images approaching the same level of sharpness / blurriness as the ground truth. - - References - ---------- - TV+TV2 Regularization with Non-Convex Sparseness-Inducing Penalty for Image Restoration, - Chengwu Lu & Hua Huang, 2014 - http://downloads.hindawi.com/journals/mpe/2014/790547.pdf - """ - def __init__(self): - super().__init__(name="generalized_loss") - self.generalized_loss = GeneralizedLoss(alpha=1.9999) + return loss - def call(self, y_true, y_pred): - """ Call the gradient loss function. + def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + """ Call the Focal Frequency Loss Function. Parameters ---------- - y_true: tensor or variable - The ground truth value - y_pred: tensor or variable - The predicted value + y_true: :class:`tf.Tensor` + The ground truth batch of images + y_pred: :class:`tf.Tensor` + The predicted batch of images Returns ------- - tensor - The loss value + :class:`tf.Tensor` + The loss for this batch of images """ - tv_weight = 1.0 - tv2_weight = 1.0 - loss = 0.0 - loss += tv_weight * (self.generalized_loss(self._diff_x(y_true), self._diff_x(y_pred)) + - self.generalized_loss(self._diff_y(y_true), self._diff_y(y_pred))) - loss += tv2_weight * (self.generalized_loss(self._diff_xx(y_true), self._diff_xx(y_pred)) + - self.generalized_loss(self._diff_yy(y_true), self._diff_yy(y_pred)) + - self.generalized_loss(self._diff_xy(y_true), self._diff_xy(y_pred)) - * 2.) - loss = loss / (tv_weight + tv2_weight) - # TODO simplify to use MSE instead - return loss + if not all(self._dims): + rows, cols = K.int_shape(y_true)[1:3] + assert cols % self._patch_factor == 0 and rows % self._patch_factor == 0, ( + "Patch factor must be a divisor of the image height and width") + self._dims = (rows, cols) - @classmethod - def _diff_x(cls, img): - """ X Difference """ - x_left = img[:, :, 1:2, :] - img[:, :, 0:1, :] - x_inner = img[:, :, 2:, :] - img[:, :, :-2, :] - x_right = img[:, :, -1:, :] - img[:, :, -2:-1, :] - x_out = K.concatenate([x_left, x_inner, x_right], axis=2) - return x_out * 0.5 + patches_true = self._get_patches(y_true) + patches_pred = self._get_patches(y_pred) - @classmethod - def _diff_y(cls, img): - """ Y Difference """ - y_top = img[:, 1:2, :, :] - img[:, 0:1, :, :] - y_inner = img[:, 2:, :, :] - img[:, :-2, :, :] - y_bot = img[:, -1:, :, :] - img[:, -2:-1, :, :] - y_out = K.concatenate([y_top, y_inner, y_bot], axis=1) - return y_out * 0.5 + freq_true = self._tensor_to_frequency_spectrum(patches_true) + freq_pred = self._tensor_to_frequency_spectrum(patches_pred) - @classmethod - def _diff_xx(cls, img): - """ X-X Difference """ - x_left = img[:, :, 1:2, :] + img[:, :, 0:1, :] - x_inner = img[:, :, 2:, :] + img[:, :, :-2, :] - x_right = img[:, :, -1:, :] + img[:, :, -2:-1, :] - x_out = K.concatenate([x_left, x_inner, x_right], axis=2) - return x_out - 2.0 * img + if self._ave_spectrum: # whether to use minibatch average spectrum + freq_true = K.mean(freq_true, axis=0, keepdims=True) + freq_pred = K.mean(freq_pred, axis=0, keepdims=True) - @classmethod - def _diff_yy(cls, img): - """ Y-Y Difference """ - y_top = img[:, 1:2, :, :] + img[:, 0:1, :, :] - y_inner = img[:, 2:, :, :] + img[:, :-2, :, :] - y_bot = img[:, -1:, :, :] + img[:, -2:-1, :, :] - y_out = K.concatenate([y_top, y_inner, y_bot], axis=1) - return y_out - 2.0 * img + weight_matrix = self._get_weight_matrix(freq_true, freq_pred) + return self._calculate_loss(freq_true, freq_pred, weight_matrix) - @classmethod - def _diff_xy(cls, img): - """ X-Y Difference """ - # xout1 - top_left = img[:, 1:2, 1:2, :] + img[:, 0:1, 0:1, :] - inner_left = img[:, 2:, 1:2, :] + img[:, :-2, 0:1, :] - bot_left = img[:, -1:, 1:2, :] + img[:, -2:-1, 0:1, :] - xy_left = K.concatenate([top_left, inner_left, bot_left], axis=1) - top_mid = img[:, 1:2, 2:, :] + img[:, 0:1, :-2, :] - mid_mid = img[:, 2:, 2:, :] + img[:, :-2, :-2, :] - bot_mid = img[:, -1:, 2:, :] + img[:, -2:-1, :-2, :] - xy_mid = K.concatenate([top_mid, mid_mid, bot_mid], axis=1) +class GeneralizedLoss(): # pylint:disable=too-few-public-methods + """ Generalized function used to return a large variety of mathematical loss functions. - top_right = img[:, 1:2, -1:, :] + img[:, 0:1, -2:-1, :] - inner_right = img[:, 2:, -1:, :] + img[:, :-2, -2:-1, :] - bot_right = img[:, -1:, -1:, :] + img[:, -2:-1, -2:-1, :] - xy_right = K.concatenate([top_right, inner_right, bot_right], axis=1) + The primary benefit is a smooth, differentiable version of L1 loss. - # Xout2 - top_left = img[:, 0:1, 1:2, :] + img[:, 1:2, 0:1, :] - inner_left = img[:, :-2, 1:2, :] + img[:, 2:, 0:1, :] - bot_left = img[:, -2:-1, 1:2, :] + img[:, -1:, 0:1, :] - xy_left = K.concatenate([top_left, inner_left, bot_left], axis=1) + References + ---------- + Barron, J. A General and Adaptive Robust Loss Function - https://arxiv.org/pdf/1701.03077.pdf - top_mid = img[:, 0:1, 2:, :] + img[:, 1:2, :-2, :] - mid_mid = img[:, :-2, 2:, :] + img[:, 2:, :-2, :] - bot_mid = img[:, -2:-1, 2:, :] + img[:, -1:, :-2, :] - xy_mid = K.concatenate([top_mid, mid_mid, bot_mid], axis=1) + Example + ------- + >>> a=1.0, x>>c , c=1.0/255.0 # will give a smoothly differentiable version of L1 / MAE loss + >>> a=1.999999 (limit as a->2), beta=1.0/255.0 # will give L2 / RMSE loss - top_right = img[:, 0:1, -1:, :] + img[:, 1:2, -2:-1, :] - inner_right = img[:, :-2, -1:, :] + img[:, 2:, -2:-1, :] - bot_right = img[:, -2:-1, -1:, :] + img[:, -1:, -2:-1, :] - xy_right = K.concatenate([top_right, inner_right, bot_right], axis=1) + Parameters + ---------- + alpha: float, optional + Penalty factor. Larger number give larger weight to large deviations. Default: `1.0` + beta: float, optional + Scale factor used to adjust to the input scale (i.e. inputs of mean `1e-4` or `256`). + Default: `1.0/255.0` + """ + def __init__(self, alpha: float = 1.0, beta: float = 1.0/255.0) -> None: + self._alpha = alpha + self._beta = beta - xy_out1 = K.concatenate([xy_left, xy_mid, xy_right], axis=2) - xy_out2 = K.concatenate([xy_left, xy_mid, xy_right], axis=2) - return (xy_out1 - xy_out2) * 0.25 + def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + """ Call the Generalized Loss Function + Parameters + ---------- + y_true: :class:`tf.Tensor` + The ground truth value + y_pred: :class:`tf.Tensor` + The predicted value -class GMSDLoss(tf.keras.losses.Loss): # pylint:disable=too-few-public-methods + Returns + ------- + :class:`tf.Tensor` + The loss value from the results of function(y_pred - y_true) + """ + diff = y_pred - y_true + second = (K.pow(K.pow(diff/self._beta, 2.) / K.abs(2. - self._alpha) + 1., + (self._alpha / 2.)) - 1.) + loss = (K.abs(2. - self._alpha)/self._alpha) * second + loss = K.mean(loss, axis=-1) * self._beta + return loss + + +class GMSDLoss(): # pylint:disable=too-few-public-methods """ Gradient Magnitude Similarity Deviation Loss. Improved image quality metric over MS-SSIM with easier calculations @@ -457,23 +411,19 @@ class GMSDLoss(tf.keras.losses.Loss): # pylint:disable=too-few-public-methods http://www4.comp.polyu.edu.hk/~cslzhang/IQA/GMSD/GMSD.htm https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf """ - def __init__(self): - super().__init__(name="gmsd_loss", reduction=tf.keras.losses.Reduction.NONE) - - def call(self, y_true, y_pred): + def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: """ Return the Gradient Magnitude Similarity Deviation Loss. - Parameters ---------- - y_true: tensor or variable + y_true: :class:`tf.Tensor` The ground truth value - y_pred: tensor or variable + y_pred: :class:`tf.Tensor` The predicted value Returns ------- - tensor + :class:`tf.Tensor` The loss value """ true_edge = self._scharr_edges(y_true, True) @@ -487,12 +437,12 @@ def call(self, y_true, y_pred): return gmsd @classmethod - def _scharr_edges(cls, image, magnitude): + def _scharr_edges(cls, image: tf.Tensor, magnitude: bool) -> tf.Tensor: """ Returns a tensor holding modified Scharr edge maps. Parameters ---------- - image: tensor + image: :class:`tf.Tensor` Image tensor with shape [batch_size, h, w, d] and type float32. The image(s) must be 2x2 or larger. magnitude: bool @@ -500,7 +450,7 @@ def _scharr_edges(cls, image, magnitude): Returns ------- - tensor + :class:`tf.Tensor` Tensor holding edge maps for each channel. Returns a tensor with shape `[batch_size, h, w, d, 2]` where the last two dimensions hold `[[dy[0], dx[0]], [dy[1], dx[1]], ..., [dy[d-1], dx[d-1]]]` calculated using the Scharr filter. @@ -558,6 +508,132 @@ def _scharr_edges(cls, image, magnitude): return output +class GradientLoss(): # pylint:disable=too-few-public-methods + """ Gradient Loss Function. + + Calculates the first and second order gradient difference between pixels of an image in the x + and y dimensions. These gradients are then compared between the ground truth and the predicted + image and the difference is taken. When used as a loss, its minimization will result in + predicted images approaching the same level of sharpness / blurriness as the ground truth. + + References + ---------- + TV+TV2 Regularization with Non-Convex Sparseness-Inducing Penalty for Image Restoration, + Chengwu Lu & Hua Huang, 2014 - http://downloads.hindawi.com/journals/mpe/2014/790547.pdf + """ + def __init__(self) -> None: + self.generalized_loss = GeneralizedLoss(alpha=1.9999) + self._tv_weight = 1.0 + self._tv2_weight = 1.0 + + def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + """ Call the gradient loss function. + + Parameters + ---------- + y_true: :class:`tf.Tensor` + The ground truth value + y_pred: :class:`tf.Tensor` + The predicted value + + Returns + ------- + :class:`tf.Tensor` + The loss value + """ + loss = 0.0 + loss += self._tv_weight * (self.generalized_loss(self._diff_x(y_true), + self._diff_x(y_pred)) + + self.generalized_loss(self._diff_y(y_true), + self._diff_y(y_pred))) + loss += self._tv2_weight * (self.generalized_loss(self._diff_xx(y_true), + self._diff_xx(y_pred)) + + self.generalized_loss(self._diff_yy(y_true), + self._diff_yy(y_pred)) + + self.generalized_loss(self._diff_xy(y_true), + self._diff_xy(y_pred)) * 2.) + loss = loss / (self._tv_weight + self._tv2_weight) + # TODO simplify to use MSE instead + return loss + + @classmethod + def _diff_x(cls, img: tf.Tensor) -> tf.Tensor: + """ X Difference """ + x_left = img[:, :, 1:2, :] - img[:, :, 0:1, :] + x_inner = img[:, :, 2:, :] - img[:, :, :-2, :] + x_right = img[:, :, -1:, :] - img[:, :, -2:-1, :] + x_out = K.concatenate([x_left, x_inner, x_right], axis=2) + return x_out * 0.5 + + @classmethod + def _diff_y(cls, img: tf.Tensor) -> tf.Tensor: + """ Y Difference """ + y_top = img[:, 1:2, :, :] - img[:, 0:1, :, :] + y_inner = img[:, 2:, :, :] - img[:, :-2, :, :] + y_bot = img[:, -1:, :, :] - img[:, -2:-1, :, :] + y_out = K.concatenate([y_top, y_inner, y_bot], axis=1) + return y_out * 0.5 + + @classmethod + def _diff_xx(cls, img: tf.Tensor) -> tf.Tensor: + """ X-X Difference """ + x_left = img[:, :, 1:2, :] + img[:, :, 0:1, :] + x_inner = img[:, :, 2:, :] + img[:, :, :-2, :] + x_right = img[:, :, -1:, :] + img[:, :, -2:-1, :] + x_out = K.concatenate([x_left, x_inner, x_right], axis=2) + return x_out - 2.0 * img + + @classmethod + def _diff_yy(cls, img: tf.Tensor) -> tf.Tensor: + """ Y-Y Difference """ + y_top = img[:, 1:2, :, :] + img[:, 0:1, :, :] + y_inner = img[:, 2:, :, :] + img[:, :-2, :, :] + y_bot = img[:, -1:, :, :] + img[:, -2:-1, :, :] + y_out = K.concatenate([y_top, y_inner, y_bot], axis=1) + return y_out - 2.0 * img + + @classmethod + def _diff_xy(cls, img: tf.Tensor) -> tf.Tensor: + """ X-Y Difference """ + # xout1 + # Left + top = img[:, 1:2, 1:2, :] + img[:, 0:1, 0:1, :] + inner = img[:, 2:, 1:2, :] + img[:, :-2, 0:1, :] + bottom = img[:, -1:, 1:2, :] + img[:, -2:-1, 0:1, :] + xy_left = K.concatenate([top, inner, bottom], axis=1) + # Mid + top = img[:, 1:2, 2:, :] + img[:, 0:1, :-2, :] + mid = img[:, 2:, 2:, :] + img[:, :-2, :-2, :] + bottom = img[:, -1:, 2:, :] + img[:, -2:-1, :-2, :] + xy_mid = K.concatenate([top, mid, bottom], axis=1) + # Right + top = img[:, 1:2, -1:, :] + img[:, 0:1, -2:-1, :] + inner = img[:, 2:, -1:, :] + img[:, :-2, -2:-1, :] + bottom = img[:, -1:, -1:, :] + img[:, -2:-1, -2:-1, :] + xy_right = K.concatenate([top, inner, bottom], axis=1) + + # Xout2 + # Left + top = img[:, 0:1, 1:2, :] + img[:, 1:2, 0:1, :] + inner = img[:, :-2, 1:2, :] + img[:, 2:, 0:1, :] + bottom = img[:, -2:-1, 1:2, :] + img[:, -1:, 0:1, :] + xy_left = K.concatenate([top, inner, bottom], axis=1) + # Mid + top = img[:, 0:1, 2:, :] + img[:, 1:2, :-2, :] + mid = img[:, :-2, 2:, :] + img[:, 2:, :-2, :] + bottom = img[:, -2:-1, 2:, :] + img[:, -1:, :-2, :] + xy_mid = K.concatenate([top, mid, bottom], axis=1) + # Right + top = img[:, 0:1, -1:, :] + img[:, 1:2, -2:-1, :] + inner = img[:, :-2, -1:, :] + img[:, 2:, -2:-1, :] + bottom = img[:, -2:-1, -1:, :] + img[:, -1:, -2:-1, :] + xy_right = K.concatenate([top, inner, bottom], axis=1) + + xy_out1 = K.concatenate([xy_left, xy_mid, xy_right], axis=2) + xy_out2 = K.concatenate([xy_left, xy_mid, xy_right], axis=2) + return (xy_out1 - xy_out2) * 0.25 + + class LaplacianPyramidLoss(): # pylint:disable=too-few-public-methods """ Laplacian Pyramid Loss Function @@ -600,7 +676,7 @@ def _get_gaussian_kernel(cls, size: int, sigma: float) -> tf.Tensor: Returns ------- - tf.Tensor + :class:`tf.Tensor` The base single channel Gaussian kernel """ assert size % 2 == 1, ("kernel size must be uneven") @@ -688,6 +764,123 @@ def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: return loss +class LInfNorm(): # pylint:disable=too-few-public-methods + """ Calculate the L-inf norm as a loss function. """ + def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: # noqa,pylint:disable=no-self-use + """ Call the L-inf norm loss function. + + Parameters + ---------- + y_true: :class:`tf.Tensor` + The ground truth value + y_pred: :class:`tf.Tensor` + The predicted value + + Returns + ------- + :class:`tf.Tensor` + The loss value + """ + diff = K.abs(y_true - y_pred) + max_loss = K.max(diff, axis=(1, 2), keepdims=True) + loss = K.mean(max_loss, axis=-1) + return loss + + +class MSSIMLoss(): # pylint:disable=too-few-public-methods + """ Multiscale Structural Similarity Loss Function + + Parameters + ---------- + k_1: float, optional + Parameter of the SSIM. Default: `0.01` + k_2: float, optional + Parameter of the SSIM. Default: `0.03` + filter_size: int, optional + size of gaussian filter Default: `11` + filter_sigma: float, optional + Width of gaussian filter Default: `1.5` + max_value: float, optional + Max value of the output. Default: `1.0` + power_factors: tuple, optional + Iterable of weights for each of the scales. The number of scales used is the length of the + list. Index 0 is the unscaled resolution's weight and each increasing scale corresponds to + the image being downsampled by 2. Defaults to the values obtained in the original paper. + Default: (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) + + Notes + ------ + You should add a regularization term like a l2 loss in addition to this one. + """ + def __init__(self, + k_1: float = 0.01, + k_2: float = 0.03, + filter_size: int = 11, + filter_sigma: float = 1.5, + max_value: float = 1.0, + power_factors: Tuple[float, ...] = (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) + ) -> None: + self.filter_size = filter_size + self.filter_sigma = filter_sigma + self.k_1 = k_1 + self.k_2 = k_2 + self.max_value = max_value + self.power_factors = power_factors + + def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + """ Call the MS-SSIM Loss Function. + + Parameters + ---------- + y_true: :class:`tf.Tensor` + The ground truth value + y_pred: :class:`tf.Tensor` + The predicted value + + Returns + ------- + :class:`tf.Tensor` + The MS-SSIM Loss value + """ + im_size = K.int_shape(y_true)[1] + # filter size cannot be larger than the smallest scale + smallest_scale = self._get_smallest_size(im_size, len(self.power_factors) - 1) + filter_size = min(self.filter_size, smallest_scale) + + ms_ssim = tf.image.ssim_multiscale(y_true, + y_pred, + self.max_value, + power_factors=self.power_factors, + filter_size=filter_size, + filter_sigma=self.filter_sigma, + k1=self.k_1, + k2=self.k_2) + ms_ssim_loss = 1. - ms_ssim + return K.mean(ms_ssim_loss) + + def _get_smallest_size(self, size: int, idx: int) -> int: + """ Recursive function to obtain the smallest size that the image will be scaled to. + + Parameters + ---------- + size: int + The current scaled size to iterate through + idx: int + The current iteration to be performed. When iteration hits zero the value will + be returned + + Returns + ------- + int + The smallest size the image will be scaled to based on the original image size and + the amount of scaling factors that will occur + """ + logger.debug("scale id: %s, size: %s", idx, size) + if idx > 0: + size = self._get_smallest_size(size // 2, idx - 1) + return size + + class LossWrapper(): """ A wrapper class for multiple keras losses to enable multiple masked weighted loss functions on a single output. diff --git a/plugins/train/_config.py b/plugins/train/_config.py index e528f6d0ec..d13210058a 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -13,10 +13,16 @@ "when creating a new model.") _LOSS_HELP = dict( + ffl="Focal Frequency Loss. Analyzes the frequency spectrum of the images rather than the " + "images themselves. This loss function can be used on its own, but the original paper " + "found increased benefits when using it as a complementary loss to another spacial loss " + "function (e.g. MSE). Ref: Focal Frequency Loss for Image Reconstruction and Synthesis " + "https://arxiv.org/pdf/2012.12821.pdf NB: This loss does not currently work on AMD cards.", gmsd=( "Gradient Magnitude Similarity Deviation seeks to match the global standard deviation of " - "the pixel to pixel differences between two images. Similar in approach to SSIM. NB: This " - "loss does not currently work on AMD cards."), + "the pixel to pixel differences between two images. Similar in approach to SSIM. Ref: " + "Gradient Magnitude Similarity Deviation: An Highly Efficient Perceptual Image Quality " + "Index https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf"), l_inf_norm=( "The L_inf norm will reduce the largest individual pixel error in an image. As " "each largest error is minimized sequentially, the overall error is improved. This loss " @@ -40,17 +46,20 @@ mse=( "Mean squared error will guide reconstructions of each pixel towards its average value in " "the training dataset. As an avg, it will be susceptible to outliers and typically " - "produces slightly blurrier results."), + "produces slightly blurrier results. Ref: Multi-Scale Structural Similarity for Image " + "Quality Assessment https://www.cns.nyu.edu/pub/eero/wang03b.pdf"), ms_ssim=( "Multiscale Structural Similarity Index Metric is similar to SSIM except that it " "performs the calculations along multiple scales of the input image."), smooth_loss=( "Smooth_L1 is a modification of the MAE loss to correct two of its disadvantages. " - "This loss has improved stability and guidance for small errors."), + "This loss has improved stability and guidance for small errors. Ref: A General and " + "Adaptive Robust Loss Function https://arxiv.org/pdf/1701.03077.pdf"), ssim=( "Structural Similarity Index Metric is a perception-based loss that considers changes in " "texture, luminance, contrast, and local spatial statistics of an image. Potentially " - "delivers more realistic looking images."), + "delivers more realistic looking images. Ref: Image Quality Assessment: From Error " + "Visibility to Structural Similarity http://www.cns.nyu.edu/pub/eero/wang03-reprint.pdf"), pixel_gradient_diff=( "Instead of minimizing the difference between the absolute value of each " "pixel in two reference images, compute the pixel to pixel spatial difference in each " @@ -276,11 +285,7 @@ def _set_loss(self) -> None: MAE https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine-learners-should-know-4fb140e9d4b0 MSE https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine-learners-should-know-4fb140e9d4b0 LogCosh https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine-learners-should-know-4fb140e9d4b0 - Smooth L1 https://arxiv.org/pdf/1701.03077.pdf L_inf_norm https://medium.com/@montjoile/l0-norm-l1-norm-l2-norm-l-infinity-norm-7a7d18a4f40c - SSIM http://www.cns.nyu.edu/pub/eero/wang03-reprint.pdf - MSSIM https://www.cns.nyu.edu/pub/eero/wang03b.pdf - GMSD https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf """ # noqa # pylint:enable=line-too-long logger.debug("Setting Loss config") @@ -298,8 +303,9 @@ def _set_loss(self) -> None: fixed=False, choices=[x for x in sorted(_LOSS_HELP) if x not in _NON_PRIMARY_LOSS], info="The loss function to use.\n\n\t" + - "\n\t".join(f"{k}: {v}" - for k, v in sorted(_LOSS_HELP.items()) if k not in _NON_PRIMARY_LOSS)) + "\n\n\t".join(f"{k}: {v}" + for k, v in sorted(_LOSS_HELP.items()) + if k not in _NON_PRIMARY_LOSS)) self.add_item( section=section, title="loss_function_2", @@ -312,8 +318,8 @@ def _set_loss(self) -> None: "SSIM, MS-SSIM or GMSD) it is common to add an L1 regularization(MAE) or L2 " "regularization (MSE) function. You can adjust the weighting of this loss " "function with the loss_weight_2 option.\n\n\t" + - "\n\t".join(f"{k}: {v}" - for k, v in sorted(_LOSS_HELP.items()))) + "\n\n\t".join(f"{k}: {v}" + for k, v in sorted(_LOSS_HELP.items()))) self.add_item( section=section, title="loss_weight_2", @@ -343,8 +349,8 @@ def _set_loss(self) -> None: choices=list(sorted(_LOSS_HELP)), info="The third loss function to use. You can adjust the weighting of this loss " "function with the loss_weight_3 option.\n\n\t" + - "\n\t".join(f"{k}: {v}" - for k, v in sorted(_LOSS_HELP.items()))) + "\n\n\t".join(f"{k}: {v}" + for k, v in sorted(_LOSS_HELP.items()))) self.add_item( section=section, title="loss_weight_3", @@ -374,8 +380,8 @@ def _set_loss(self) -> None: choices=list(sorted(_LOSS_HELP)), info="The fourth loss function to use. You can adjust the weighting of this loss " "function with the loss_weight_3 option.\n\n\t" + - "\n\t".join(f"{k}: {v}" - for k, v in sorted(_LOSS_HELP.items()))) + "\n\n\t".join(f"{k}: {v}" + for k, v in sorted(_LOSS_HELP.items()))) self.add_item( section=section, title="loss_weight_4", diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index f25834f413..cbbb4f1578 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -49,20 +49,14 @@ class Loss(): ---------- config: dict The configuration options for the current model plugin + input_shape: tuple + Required for AMD backends only. Some loss functions are unable to calculate the input shape + at runtime, so we add the shape as an initializing variable """ def __init__(self, config: dict) -> None: logger.debug("Initializing %s", self.__class__.__name__) self._config = config - self._loss_dict = dict(gmsd=losses.GMSDLoss(), - l_inf_norm=losses.LInfNorm(), - laploss=losses.LaplacianPyramidLoss(), - logcosh=k_losses.logcosh, - ms_ssim=losses.MSSIMLoss(), - mae=k_losses.mean_absolute_error, - mse=k_losses.mean_squared_error, - pixel_gradient_diff=losses.GradientLoss(), - ssim=losses.DSSIMObjective(), - smooth_loss=losses.GeneralizedLoss(),) + self._loss_dict: Dict[str, Callable] = {} self._mask_channels = self._get_mask_channels() self._inputs: List[keras.layers.Layer] = [] self._names: List[str] = [] @@ -103,6 +97,19 @@ def configure(self, model: keras.models.Model) -> None: The model that is to be trained """ self._inputs = model.inputs + # Some Plaid losses can't calculate the input shape at runtime, so pass these in + kwargs = dict(input_dims=model.output_shape[0][1:3]) if get_backend() == "amd" else {} + self._loss_dict = dict(ffl=losses.FocalFrequencyLoss(), + gmsd=losses.GMSDLoss(**kwargs), + l_inf_norm=losses.LInfNorm(), + laploss=losses.LaplacianPyramidLoss(), + logcosh=k_losses.logcosh, + ms_ssim=losses.MSSIMLoss(), + mae=k_losses.mean_absolute_error, + mse=k_losses.mean_squared_error, + pixel_gradient_diff=losses.GradientLoss(), + ssim=losses.DSSIMObjective(), + smooth_loss=losses.GeneralizedLoss(),) self._set_loss_names(model.outputs) self._set_loss_functions(model.output_names) self._names.insert(0, "total") diff --git a/tests/lib/model/losses_test.py b/tests/lib/model/losses_test.py index 33ad217700..16d60a6c05 100644 --- a/tests/lib/model/losses_test.py +++ b/tests/lib/model/losses_test.py @@ -16,10 +16,12 @@ # Ignore linting errors from Tensorflow's thoroughly broken import system from tensorflow.keras import backend as K, losses as k_losses # pylint:disable=import-error + +_KWARGS = dict(input_dims=(64, 64)) if get_backend() == "amd" else {} _PARAMS = [(losses.GeneralizedLoss(), (2, 16, 16)), (losses.GradientLoss(), (2, 16, 16)), # TODO Make sure these output dimensions are correct - (losses.GMSDLoss(), (2, 1, 1)), + (losses.GMSDLoss(**_KWARGS), (2, 1, 1)), # TODO Make sure these output dimensions are correct (losses.LInfNorm(), (2, 1, 1))] _IDS = ["GeneralizedLoss", "GradientLoss", "GMSDLoss", "LInfNorm"] @@ -29,8 +31,6 @@ @pytest.mark.parametrize(["loss_func", "output_shape"], _PARAMS, ids=_IDS) def test_loss_output(loss_func, output_shape): """ Basic shape tests for loss functions. """ - if get_backend() == "amd" and isinstance(loss_func, losses.GMSDLoss): - pytest.skip("GMSD Loss is not currently compatible with PlaidML") y_a = K.variable(np.random.random((2, 16, 16, 3))) y_b = K.variable(np.random.random((2, 16, 16, 3))) objective_output = loss_func(y_a, y_b) @@ -42,8 +42,9 @@ def test_loss_output(loss_func, output_shape): _LWPARAMS = [losses.DSSIMObjective(), + losses.FocalFrequencyLoss(), losses.GeneralizedLoss(), - losses.GMSDLoss(), + losses.GMSDLoss(**_KWARGS), losses.GradientLoss(), losses.LaplacianPyramidLoss(), losses.LInfNorm(), @@ -51,8 +52,8 @@ def test_loss_output(loss_func, output_shape): k_losses.mean_absolute_error, k_losses.mean_squared_error, losses.MSSIMLoss()] -_LWIDS = ["DSSIMObjective", "GeneralizedLoss", "GMSDLoss", "GradientLoss", "LaplacianPyramidLoss", - "LInfNorm", "logcosh", "mae", "mse", "MS-SSIM"] +_LWIDS = ["DSSIMObjective", "FocalFrequencyLosse", "GeneralizedLoss", "GMSDLoss", "GradientLoss", + "LaplacianPyramidLoss", "LInfNorm", "logcosh", "mae", "mse", "MS-SSIM"] _LWIDS = [f"{loss}[{get_backend().upper()}]" for loss in _LWIDS] @@ -60,8 +61,8 @@ def test_loss_output(loss_func, output_shape): def test_loss_wrapper(loss_func): """ Test penalized loss wrapper works as expected """ if get_backend() == "amd": - if isinstance(loss_func, losses.GMSDLoss): - pytest.skip("GMSD Loss is not currently compatible with PlaidML") + if isinstance(loss_func, losses.FocalFrequencyLoss): + pytest.skip("FocalFrequencyLoss Loss is not currently compatible with PlaidML") if hasattr(loss_func, "__name__") and loss_func.__name__ == "logcosh": pytest.skip("LogCosh Loss is not currently compatible with PlaidML") y_a = K.variable(np.random.random((2, 64, 64, 4))) From 42407e9d5c6a3bccad4c03aed1ea0d481171ed80 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 18 Jun 2022 14:52:18 +0100 Subject: [PATCH 628/981] linting --- lib/model/normalization/__init__.py | 10 +++++----- lib/training/__init__.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/model/normalization/__init__.py b/lib/model/normalization/__init__.py index a7f2bd759d..c6a31d2e02 100644 --- a/lib/model/normalization/__init__.py +++ b/lib/model/normalization/__init__.py @@ -2,12 +2,12 @@ """ Conditional imports depending on whether the AMD version is installed or not """ from lib.utils import get_backend -from .normalization_common import AdaInstanceNormalization -from .normalization_common import GroupNormalization -from .normalization_common import InstanceNormalization +from .normalization_common import AdaInstanceNormalization # noqa +from .normalization_common import GroupNormalization # noqa +from .normalization_common import InstanceNormalization # noqa if get_backend() == "amd": - from .normalization_plaid import LayerNormalization, RMSNormalization + from .normalization_plaid import LayerNormalization, RMSNormalization # noqa else: - from .normalization_tf import LayerNormalization, RMSNormalization + from .normalization_tf import LayerNormalization, RMSNormalization # noqa diff --git a/lib/training/__init__.py b/lib/training/__init__.py index a478362969..6b0d296658 100644 --- a/lib/training/__init__.py +++ b/lib/training/__init__.py @@ -2,5 +2,5 @@ """ Package for handling alignments files, detected faces and aligned faces along with their associated objects. """ -from .augmentation import ImageAugmentation -from .generator import TrainingDataGenerator +from .augmentation import ImageAugmentation # noqa +from .generator import TrainingDataGenerator # noqa From 76cb535e8207652ec3dd2f271afaabddcf79e481 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 18 Jun 2022 15:32:02 +0100 Subject: [PATCH 629/981] linting --- plugins/train/model/_base/io.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/train/model/_base/io.py b/plugins/train/model/_base/io.py index 15f44b9271..3fb63476de 100644 --- a/plugins/train/model/_base/io.py +++ b/plugins/train/model/_base/io.py @@ -52,8 +52,8 @@ def get_all_sub_models( Returns ------- list - A list of all :class:`keras.models.Model`\s found within the given model. The provided - model will always be returned in the first position + A list of all :class:`keras.models.Model` objects found within the given model. The + provided model will always be returned in the first position """ if models is None: models = [model] From 84b47fde95171e752cf41d91115f7ee851e51a7b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 18 Jun 2022 15:36:39 +0100 Subject: [PATCH 630/981] linting --- lib/model/loss/loss_plaid.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/model/loss/loss_plaid.py b/lib/model/loss/loss_plaid.py index 5d01346b2d..f106155a7c 100644 --- a/lib/model/loss/loss_plaid.py +++ b/lib/model/loss/loss_plaid.py @@ -156,8 +156,6 @@ def __call__(self, :class:`plaidml.tile.Value` The DSSIM or MS-DSSIM for the given images """ - print(K.int_shape(y_pred)) - ssim = self._get_ssim(y_true, y_pred)[0] retval = (1. - ssim) / 2.0 return K.mean(retval) From 1d434b73a47de1200e9a598e2aaedb6cfd7cfa20 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 18 Jun 2022 16:12:39 +0100 Subject: [PATCH 631/981] Enable LogCosh Loss for AMD --- lib/model/loss/loss_plaid.py | 41 +++++++++++++++++++++------ plugins/train/_config.py | 3 +- plugins/train/model/_base/settings.py | 26 ++++++++--------- tests/lib/model/losses_test.py | 9 ++---- 4 files changed, 49 insertions(+), 30 deletions(-) diff --git a/lib/model/loss/loss_plaid.py b/lib/model/loss/loss_plaid.py index f106155a7c..615e9ae19e 100644 --- a/lib/model/loss/loss_plaid.py +++ b/lib/model/loss/loss_plaid.py @@ -293,9 +293,6 @@ class GMSDLoss(): # pylint:disable=too-few-public-methods http://www4.comp.polyu.edu.hk/~cslzhang/IQA/GMSD/GMSD.htm https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf """ - def __init__(self, input_dims: Tuple[int, int]) -> None: - self._input_dims = input_dims - def __call__(self, y_true: plaidml.tile.Value, y_pred: plaidml.tile.Value) -> plaidml.tile.Value: @@ -313,7 +310,7 @@ def __call__(self, :class:`plaidml.tile.Value` The loss value """ - image_shape = (None, *self._input_dims, K.int_shape(y_pred)[-1]) + image_shape = K.int_shape(y_pred) true_edge = self._scharr_edges(y_true, True, image_shape) pred_edge = self._scharr_edges(y_pred, True, image_shape) ephsilon = 0.0025 @@ -681,6 +678,35 @@ def __call__(self, return loss +class LogCosh(): + """Logarithm of the hyperbolic cosine of the prediction error. + + `log(cosh(x))` is approximately equal to `(x ** 2) / 2` for small `x` and + to `abs(x) - log(2)` for large `x`. This means that 'logcosh' works mostly + like the mean squared error, but will not be so strongly affected by the + occasional wildly incorrect prediction. + """ + def __call__(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: + """ Call the LogCosh loss function. + Parameters + ---------- + y_true: :class:`plaidml.tile.Value` + The ground truth value + y_pred: :class:`plaidml.tile.Value` + The predicted value + + Returns + ------- + :class:`plaidml.tile.Value` + The loss value + """ + diff = y_pred - y_true + loss = diff + K.softplus(-2. * diff) - K.log(K.constant(2., dtype="float32")) + return K.mean(loss, axis=-1) + + class MSSIMLoss(DSSIMObjective): # pylint:disable=too-few-public-methods """ Multiscale Structural Similarity Loss Function @@ -898,10 +924,9 @@ def __call__(self, logger.debug("Processing loss function: (func: %s, weight: %s, mask_channel: %s)", func, weight, mask_channel) n_true, n_pred = self._apply_mask(y_true, y_pred, mask_channel) - if isinstance(func, DSSIMObjective): - # Extract Image Patches in SSIM requires that y_pred be of a known shape, so - # specifically reshape the tensor. - n_pred = K.reshape(n_pred, K.int_shape(y_pred)) + # Some loss functions requires that y_pred be of a known shape, so specifically + # reshape the tensor. + n_pred = K.reshape(n_pred, K.int_shape(y_pred)) this_loss = func(n_true, n_pred) loss_dims = K.ndim(this_loss) loss += (K.mean(this_loss, axis=list(range(1, loss_dims))) * weight) diff --git a/plugins/train/_config.py b/plugins/train/_config.py index d13210058a..6765f798cb 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -37,8 +37,7 @@ logcosh=( "log(cosh(x)) acts similar to MSE for small errors and to MAE for large errors. Like " "MSE, it is very stable and prevents overshoots when errors are near zero. Like MAE, it " - "is robust to outliers. NB: Due to a bug in PlaidML, this loss does not work on AMD " - "cards."), + "is robust to outliers."), mae=( "Mean absolute error will guide reconstructions of each pixel towards its median value in " "the training dataset. Robust to outliers but as a median, it can potentially ignore some " diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index cbbb4f1578..6ec1a6dfc5 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -56,7 +56,18 @@ class Loss(): def __init__(self, config: dict) -> None: logger.debug("Initializing %s", self.__class__.__name__) self._config = config - self._loss_dict: Dict[str, Callable] = {} + logcosh = losses.LogCosh() if get_backend() == "amd" else k_losses.logcosh + self._loss_dict = dict(ffl=losses.FocalFrequencyLoss(), + gmsd=losses.GMSDLoss(), + l_inf_norm=losses.LInfNorm(), + laploss=losses.LaplacianPyramidLoss(), + logcosh=logcosh, + ms_ssim=losses.MSSIMLoss(), + mae=k_losses.mean_absolute_error, + mse=k_losses.mean_squared_error, + pixel_gradient_diff=losses.GradientLoss(), + ssim=losses.DSSIMObjective(), + smooth_loss=losses.GeneralizedLoss(),) self._mask_channels = self._get_mask_channels() self._inputs: List[keras.layers.Layer] = [] self._names: List[str] = [] @@ -97,19 +108,6 @@ def configure(self, model: keras.models.Model) -> None: The model that is to be trained """ self._inputs = model.inputs - # Some Plaid losses can't calculate the input shape at runtime, so pass these in - kwargs = dict(input_dims=model.output_shape[0][1:3]) if get_backend() == "amd" else {} - self._loss_dict = dict(ffl=losses.FocalFrequencyLoss(), - gmsd=losses.GMSDLoss(**kwargs), - l_inf_norm=losses.LInfNorm(), - laploss=losses.LaplacianPyramidLoss(), - logcosh=k_losses.logcosh, - ms_ssim=losses.MSSIMLoss(), - mae=k_losses.mean_absolute_error, - mse=k_losses.mean_squared_error, - pixel_gradient_diff=losses.GradientLoss(), - ssim=losses.DSSIMObjective(), - smooth_loss=losses.GeneralizedLoss(),) self._set_loss_names(model.outputs) self._set_loss_functions(model.output_names) self._names.insert(0, "total") diff --git a/tests/lib/model/losses_test.py b/tests/lib/model/losses_test.py index 16d60a6c05..013ddc7231 100644 --- a/tests/lib/model/losses_test.py +++ b/tests/lib/model/losses_test.py @@ -17,11 +17,10 @@ from tensorflow.keras import backend as K, losses as k_losses # pylint:disable=import-error -_KWARGS = dict(input_dims=(64, 64)) if get_backend() == "amd" else {} _PARAMS = [(losses.GeneralizedLoss(), (2, 16, 16)), (losses.GradientLoss(), (2, 16, 16)), # TODO Make sure these output dimensions are correct - (losses.GMSDLoss(**_KWARGS), (2, 1, 1)), + (losses.GMSDLoss(), (2, 1, 1)), # TODO Make sure these output dimensions are correct (losses.LInfNorm(), (2, 1, 1))] _IDS = ["GeneralizedLoss", "GradientLoss", "GMSDLoss", "LInfNorm"] @@ -44,11 +43,11 @@ def test_loss_output(loss_func, output_shape): _LWPARAMS = [losses.DSSIMObjective(), losses.FocalFrequencyLoss(), losses.GeneralizedLoss(), - losses.GMSDLoss(**_KWARGS), + losses.GMSDLoss(), losses.GradientLoss(), losses.LaplacianPyramidLoss(), losses.LInfNorm(), - k_losses.logcosh, + losses.LogCosh() if get_backend() == "amd" else k_losses.logcosh, k_losses.mean_absolute_error, k_losses.mean_squared_error, losses.MSSIMLoss()] @@ -63,8 +62,6 @@ def test_loss_wrapper(loss_func): if get_backend() == "amd": if isinstance(loss_func, losses.FocalFrequencyLoss): pytest.skip("FocalFrequencyLoss Loss is not currently compatible with PlaidML") - if hasattr(loss_func, "__name__") and loss_func.__name__ == "logcosh": - pytest.skip("LogCosh Loss is not currently compatible with PlaidML") y_a = K.variable(np.random.random((2, 64, 64, 4))) y_b = K.variable(np.random.random((2, 64, 64, 3))) p_loss = losses.LossWrapper() From ef79a3d8cbfd06f3bdf4ab42252df876b256de42 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 18 Jun 2022 18:21:41 +0100 Subject: [PATCH 632/981] Add AlexNet + SqueezeNet definitions --- docs/full/lib/model.rst | 34 ++++-- docs/full/plugins/train.rst | 11 ++ lib/model/loss/loss_plaid.py | 11 +- lib/model/loss/loss_tf.py | 2 +- lib/model/nets.py | 212 +++++++++++++++++++++++++++++++++++ 5 files changed, 255 insertions(+), 15 deletions(-) create mode 100644 lib/model/nets.py diff --git a/docs/full/lib/model.rst b/docs/full/lib/model.rst index c253d9a8b3..6156211733 100755 --- a/docs/full/lib/model.rst +++ b/docs/full/lib/model.rst @@ -55,7 +55,7 @@ model.losses module ------------------- The losses listed here are generated from the docstrings in :mod:`lib.model.losses_tf`, however -the functions are excactly the same for :mod:`lib.model.losses_plaid`. The correct loss module will +the functions are exactly the same for :mod:`lib.model.losses_plaid`. The correct loss module will be imported as :mod:`lib.model.losses` depending on the backend in use. .. rubric:: Module Summary @@ -63,14 +63,32 @@ be imported as :mod:`lib.model.losses` depending on the backend in use. .. autosummary:: :nosignatures: - ~lib.model.losses_tf.DSSIMObjective - ~lib.model.losses_tf.GeneralizedLoss - ~lib.model.losses_tf.GMSDLoss - ~lib.model.losses_tf.GradientLoss - ~lib.model.losses_tf.LInfNorm - ~lib.model.losses_tf.LossWrapper + ~lib.model.loss.loss_tf.DSSIMObjective + ~lib.model.loss.loss_tf.FocalFrequencyLoss + ~lib.model.loss.loss_tf.GeneralizedLoss + ~lib.model.loss.loss_tf.GMSDLoss + ~lib.model.loss.loss_tf.GradientLoss + ~lib.model.loss.loss_tf.LaplacianPyramidLoss + ~lib.model.loss.loss_tf.LInfNorm + ~lib.model.loss.loss_tf.LossWrapper -.. automodule:: lib.model.losses_tf +.. automodule:: lib.model.loss.loss_tf + :members: + :undoc-members: + :show-inheritance: + +model.nets module +----------------- + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.model.nets.AlexNet + ~lib.model.nets.SqueezeNet + +.. automodule:: lib.model.nets :members: :undoc-members: :show-inheritance: diff --git a/docs/full/plugins/train.rst b/docs/full/plugins/train.rst index 671bfddbb7..cfaf89bd45 100755 --- a/docs/full/plugins/train.rst +++ b/docs/full/plugins/train.rst @@ -11,6 +11,17 @@ The Train Package handles the Model and Trainer plugins for training models in F model package ============= +This package contains various helper functions that plugins can inherit from + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~plugins.train.model._base.model + ~plugins.train.model._base.settings + ~plugins.train.model._base.io + model._base.model module ------------------------ diff --git a/lib/model/loss/loss_plaid.py b/lib/model/loss/loss_plaid.py index 615e9ae19e..1e2fcb1172 100644 --- a/lib/model/loss/loss_plaid.py +++ b/lib/model/loss/loss_plaid.py @@ -17,7 +17,7 @@ class DSSIMObjective(): # pylint:disable=too-few-public-methods - """ DSSIM and MS-DSSIM Loss Functions + """ DSSIM Loss Function Difference of Structural Similarity (DSSIM loss function). @@ -678,13 +678,12 @@ def __call__(self, return loss -class LogCosh(): +class LogCosh(): # pylint:disable=too-few-public-methods """Logarithm of the hyperbolic cosine of the prediction error. - `log(cosh(x))` is approximately equal to `(x ** 2) / 2` for small `x` and - to `abs(x) - log(2)` for large `x`. This means that 'logcosh' works mostly - like the mean squared error, but will not be so strongly affected by the - occasional wildly incorrect prediction. + `log(cosh(x))` is approximately equal to `(x ** 2) / 2` for small `x` and to `abs(x) - log(2)` + for large `x`. This means that 'logcosh' works mostly like the mean squared error, but will not + be so strongly affected by the occasional wildly incorrect prediction. """ def __call__(self, y_true: plaidml.tile.Value, diff --git a/lib/model/loss/loss_tf.py b/lib/model/loss/loss_tf.py index 1e7ff597dc..f8c0101db4 100644 --- a/lib/model/loss/loss_tf.py +++ b/lib/model/loss/loss_tf.py @@ -17,7 +17,7 @@ class DSSIMObjective(): # pylint:disable=too-few-public-methods - """ DSSIM and MS-DSSIM Loss Functions + """ DSSIM Loss Functions Difference of Structural Similarity (DSSIM loss function). diff --git a/lib/model/nets.py b/lib/model/nets.py new file mode 100644 index 0000000000..1acffe33c3 --- /dev/null +++ b/lib/model/nets.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" Ports of existing NN Architecture for use in faceswap.py """ +import logging +from typing import Optional, Tuple + +from lib.utils import get_backend + +if get_backend() == "amd": + from keras.layers import Concatenate, Conv2D, Input, MaxPool2D, ZeroPadding2D + from keras.models import Model + from plaidml.tile import Value as Tensor +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.layers import Concatenate, Conv2D, Input, MaxPool2D, ZeroPadding2D # noqa pylint:disable=no-name-in-module,import-error + from tensorflow.keras.models import Model # noqa pylint:disable=no-name-in-module,import-error + from tensorflow import Tensor + + +logger = logging.getLogger(__name__) + + +class _net(): # pylint:disable=too-few-public-methods + """ Base class for existing NeuralNet architecture + + Notes + ----- + All architectures assume channels_last format + + Parameters + ---------- + input_shape, Tuple, optional + The input shape for the model. Default: ``None`` + """ + def __init__(self, + input_shape: Optional[Tuple[int, int, int]] = None) -> None: + logger.debug("Initializing: %s (input_shape: %s)", self.__class__.__name__, input_shape) + self._input_shape = (None, None, 3) if input_shape is None else input_shape + assert len(self._input_shape) == 3 and self._input_shape[-1] == 3, ( + "Input shape must be in the format (height, width, channels) and the number of " + f"channels must equal 3. Received: {self._input_shape}") + logger.debug("Initialized: %s", self.__class__.__name__) + + +class AlexNet(_net): # pylint:disable=too-few-public-methods + """ AlexNet ported from torchvision version. + + Notes + ----- + This port only contains the features portion of the model. + + Reference + --------- + https://papers.nips.cc/paper/2012/file/c399862d3b9d6b76c8436e924a68c45b-Paper.pdf + + Parameters + ---------- + input_shape, Tuple, optional + The input shape for the model. Default: ``None`` + """ + def __init__(self, input_shape: Optional[Tuple[int, int, int]] = None) -> None: + super().__init__(input_shape) + self._feature_indices = [0, 3, 6, 8, 10] # For naming equivalent to PyTorch + self._filters = [64, 192, 384, 256, 256] # Filters at each block + + @classmethod + def _conv_block(cls, + inputs: Tensor, + padding: int, + filters: int, + kernel_size: int, + strides: int, + block_idx: int, + max_pool: bool) -> Tensor: + """ + The Convolutional block for AlexNet + + Parameters + ---------- + inputs: :class:`plaidml.tile.Value` or :class:`tf.Tensor` + The input tensor to the block + padding: int + The amount of zero paddin to apply prior to convolution + filters: int + The number of filters to apply during convolution + kernel_size: int + The kernel size of the convolution + strides: int + The number of strides for the convolution + block_idx: int + The index of the current block (for standardized naming convention) + max_pool: bool + ``True`` to apply a max pooling layer at the beginning of the block otherwise ``False`` + + Returns + ------- + :class:`plaidml.tile.Value` or :class:`tf.Tensor` + The output of the Convolutional block + """ + name = f"features.{block_idx}" + var_x = inputs + if max_pool: + var_x = MaxPool2D(pool_size=3, strides=2, name=f"{name}.pool")(var_x) + var_x = ZeroPadding2D(padding=padding, name=f"{name}.pad")(var_x) + var_x = Conv2D(filters, + kernel_size=kernel_size, + strides=strides, + padding="valid", + activation="relu", + name=name)(var_x) + return var_x + + def __call__(self) -> Model: + """ Create the AlexNet Model + + Returns + ------- + :class:`keras.models.Model` + The compiled AlexNet model + """ + inputs = Input(self._input_shape) + var_x = inputs + kernel_size = 11 + strides = 4 + + for idx, (filters, block_idx) in enumerate(zip(self._filters, self._feature_indices)): + padding = 2 if idx < 2 else 1 + do_max_pool = 0 < idx < 3 + var_x = self._conv_block(var_x, + padding, + filters, + kernel_size, + strides, + block_idx, + do_max_pool) + kernel_size = max(3, kernel_size // 2) + strides = 1 + return Model(inputs=inputs, outputs=[var_x]) + + +class SqueezeNet(_net): # pylint:disable=too-few-public-methods + """ SqueezeNet ported from torchvision version. + + Notes + ----- + This port only contains the features portion of the model. + + Reference + --------- + https://arxiv.org/abs/1602.07360 + + Parameters + ---------- + input_shape, Tuple, optional + The input shape for the model. Default: ``None`` + """ + + @classmethod + def _fire(cls, + inputs: Tensor, + squeeze_planes: int, + expand_planes: int, + block_idx: int) -> Tensor: + """ The fire block for SqueezeNet. + + Parameters + ---------- + inputs: :class:`plaidml.tile.Value` or :class:`tf.Tensor` + The input to the fire block + squeeze_planes: int + The number of filters for the squeeze convolution + expand_planes: int + The number of filters for the expand convolutions + block_idx: int + The index of the current block (for standardized naming convention) + + Returns + ------- + :class:`plaidml.tile.Value` or :class:`tf.Tensor` + The output of the SqueezeNet fire block + """ + name = f"features.{block_idx}" + squeezed = Conv2D(squeeze_planes, 1, activation="relu", name=f"{name}.squeeze")(inputs) + expand1 = Conv2D(expand_planes, 1, activation="relu", name=f"{name}.expand1x1")(squeezed) + expand3 = Conv2D(expand_planes, 3, + activation="relu", padding="same", name=f"{name}.expand3x3")(squeezed) + return Concatenate(axis=-1, name=name)([expand1, expand3]) + + def __call__(self) -> Model: + """ Create the SqueezeNet Model + + Returns + ------- + :class:`keras.models.Model` + The compiled SqueezeNet model + """ + inputs = Input(self._input_shape) + var_x = Conv2D(64, 3, strides=2, activation="relu", name="features.0")(inputs) + + block_idx = 2 + squeeze = 16 + expand = 64 + for idx in range(4): + if idx < 3: + var_x = MaxPool2D(pool_size=3, strides=2)(var_x) + block_idx += 1 + var_x = self._fire(var_x, squeeze, expand, block_idx) + block_idx += 1 + var_x = self._fire(var_x, squeeze, expand, block_idx) + block_idx += 1 + squeeze += 16 + expand += 64 + return Model(inputs=inputs, outputs=[var_x]) From f2e6f24651f62b28ccfb412180baca0aa7baf96a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 18 Jun 2022 19:54:02 +0100 Subject: [PATCH 633/981] Centralize model storage --- .../extract/align/.cache => .fs_cache}/.keep | 0 .gitignore | 1 + lib/utils.py | 28 ++------------ lib/vgg_face.py | 8 +--- plugins/extract/_base.py | 37 ++++++++----------- plugins/extract/detect/.cache/.keep | 0 plugins/extract/mask/.cache/.keep | 0 plugins/extract/recognition/.cache/.keep | 0 8 files changed, 22 insertions(+), 52 deletions(-) rename {plugins/extract/align/.cache => .fs_cache}/.keep (100%) delete mode 100644 plugins/extract/detect/.cache/.keep delete mode 100644 plugins/extract/mask/.cache/.keep delete mode 100644 plugins/extract/recognition/.cache/.keep diff --git a/plugins/extract/align/.cache/.keep b/.fs_cache/.keep similarity index 100% rename from plugins/extract/align/.cache/.keep rename to .fs_cache/.keep diff --git a/.gitignore b/.gitignore index fbf21eba22..6b5149d54c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ !tests/**/*.py # Core files +!.fs_cache !lib/ !lib/**/ !lib/**/*.py diff --git a/lib/utils.py b/lib/utils.py index 7f1f47458a..143f5f25ac 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -370,7 +370,7 @@ class FaceswapError(Exception): class GetModel(): # pylint:disable=too-few-public-methods - """ Check for models in their cache path. + """ Check for models in the cache path. If available, return the path, if not available, get, unzip and install model @@ -378,9 +378,6 @@ class GetModel(): # pylint:disable=too-few-public-methods ---------- model_filename: str or list The name of the model to be loaded (see notes below) - cache_dir: str - The model cache folder of the current plugin calling this class. IE: The folder that holds - the model to be loaded. git_model_id: int The second digit in the github tag that identifies this model. See https://github.com/deepfakes-models/faceswap-models for more information @@ -397,12 +394,12 @@ class GetModel(): # pylint:disable=too-few-public-methods ,"resnet_ssd_v1.prototext"]` """ - def __init__(self, model_filename, cache_dir, git_model_id): + def __init__(self, model_filename, git_model_id): self.logger = logging.getLogger(__name__) if not isinstance(model_filename, list): model_filename = [model_filename] self._model_filename = model_filename - self._cache_dir = cache_dir + self._cache_dir = os.path.join(os.path.abspath(os.path.dirname(sys.argv[0])), ".fs_cache") self._git_model_id = git_model_id self._url_base = "https://github.com/deepfakes-models/faceswap-models/releases/download" self._chunk_size = 1024 # Chunk size for downloading and unzipping @@ -456,27 +453,10 @@ def _model_exists(self): self.logger.trace(retval) return retval - @property - def _plugin_section(self): - """ str: The plugin section from the config_dir """ - path = os.path.normpath(self._cache_dir) - split = path.split(os.sep) - retval = split[split.index("plugins") + 1] - self.logger.trace(retval) - return retval - - @property - def _url_section(self): - """ int: The section ID in github for this plugin type. """ - sections = dict(extract=1, train=2, convert=3) - retval = sections[self._plugin_section] - self.logger.trace(retval) - return retval - @property def _url_download(self): """ strL Base download URL for models. """ - tag = f"v{self._url_section}.{self._git_model_id}.{self._model_version}" + tag = f"v{self._git_model_id}.{self._model_version}" retval = f"{self._url_base}/{tag}/{self._model_full_name}.zip" self.logger.trace("Download url: %s", retval) return retval diff --git a/lib/vgg_face.py b/lib/vgg_face.py index be917dba1a..9917fbac97 100644 --- a/lib/vgg_face.py +++ b/lib/vgg_face.py @@ -7,8 +7,6 @@ """ import logging -import sys -import os import cv2 import numpy as np @@ -37,9 +35,7 @@ def __init__(self, backend="CPU"): # <<< GET MODEL >>> # def get_model(self, git_model_id, model_filename, backend): """ Check if model is available, if not, download and unzip it """ - root_path = os.path.abspath(os.path.dirname(sys.argv[0])) - cache_path = os.path.join(root_path, "plugins", "extract", "recognition", ".cache") - model = GetModel(model_filename, cache_path, git_model_id).model_path + model = GetModel(model_filename, git_model_id).model_path model = cv2.dnn.readNetFromCaffe(model[1], model[0]) model.setPreferableTarget(self.get_backend(backend)) return model @@ -50,7 +46,7 @@ def get_backend(backend): if backend == "OPENCL": logger.info("Using OpenCL backend. If the process runs, you can safely ignore any of " "the failure messages.") - retval = getattr(cv2.dnn, "DNN_TARGET_{}".format(backend)) + retval = getattr(cv2.dnn, f"DNN_TARGET_{backend}") return retval def predict(self, face): diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 51c8463942..0d27e37160 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -3,8 +3,6 @@ :mod:`~plugins.extract.mask` Plugins """ import logging -import os -import sys from tensorflow.python.framework import errors_impl as tf_errors @@ -139,13 +137,13 @@ def __init__(self, git_model_id=None, model_filename=None, exclude_gpus=None, co """ int: Batchsize for feeding this model. The number of images the model should feed through at once. """ - self._queues = dict() + self._queues = {} """ dict: in + out queues and internal queues for this plugin, """ self._threads = [] """ list: Internal threads for this plugin """ - self._extract_media = dict() + self._extract_media = {} """ dict: The :class:`plugins.extract.pipeline.ExtractMedia` objects currently being processed. Stored at input for pairing back up on output of extractor process """ @@ -352,7 +350,8 @@ def check_and_raise_error(self): # <<< PROTECTED ACCESS METHODS >>> # # <<< INIT METHODS >>> # - def _get_model(self, git_model_id, model_filename): + @classmethod + def _get_model(cls, git_model_id, model_filename): """ Check if model is available, if not, download and unzip it """ if model_filename is None: logger.debug("No model_filename specified. Returning None") @@ -360,13 +359,7 @@ def _get_model(self, git_model_id, model_filename): if git_model_id is None: logger.debug("No git_model_id specified. Returning None") return None - plugin_path = os.path.join(*self.__module__.split(".")[:-1]) - if os.path.basename(plugin_path) in ("detect", "align", "mask", "recognition"): - base_path = os.path.dirname(os.path.realpath(sys.argv[0])) - cache_path = os.path.join(base_path, plugin_path, ".cache") - else: - cache_path = os.path.join(os.path.dirname(__file__), ".cache") - model = GetModel(model_filename, cache_path, git_model_id) + model = GetModel(model_filename, git_model_id) return model.model_path # <<< PLUGIN INITIALIZATION >>> # @@ -382,7 +375,7 @@ def initialize(self, *args, **kwargs): name = self.name.replace(" ", "_").lower() self._add_queues(kwargs["in_queue"], kwargs["out_queue"], - ["predict_{}".format(name), "post_{}".format(name)]) + [f"predict_{name}", f"post_{name}"]) self._compile_threads() try: self.init_model() @@ -409,7 +402,7 @@ def _add_queues(self, in_queue, out_queue, queues): self._queues["out"] = out_queue for q_name in queues: self._queues[q_name] = queue_manager.get_queue( - name="{}{}_{}".format(self._plugin_type, self._instance, q_name), + name=f"{self._plugin_type}{self._instance}_{q_name}", maxsize=self.queue_size) # <<< THREAD METHODS >>> # @@ -417,18 +410,18 @@ def _compile_threads(self): """ Compile the threads into self._threads list """ logger.debug("Compiling %s threads", self._plugin_type) name = self.name.replace(" ", "_").lower() - base_name = "{}_{}".format(self._plugin_type, name) - self._add_thread("{}_input".format(base_name), + base_name = f"{self._plugin_type}_{name}" + self._add_thread(f"{base_name}_input", self._process_input, self._queues["in"], - self._queues["predict_{}".format(name)]) - self._add_thread("{}_predict".format(base_name), + self._queues[f"predict_{name}"]) + self._add_thread(f"{base_name}_predict", self._predict, - self._queues["predict_{}".format(name)], - self._queues["post_{}".format(name)]) - self._add_thread("{}_output".format(base_name), + self._queues[f"predict_{name}"], + self._queues[f"post_{name}"]) + self._add_thread(f"{base_name}_output", self._process_output, - self._queues["post_{}".format(name)], + self._queues[f"post_{name}"], self._queues["out"]) logger.debug("Compiled %s threads: %s", self._plugin_type, self._threads) diff --git a/plugins/extract/detect/.cache/.keep b/plugins/extract/detect/.cache/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/plugins/extract/mask/.cache/.keep b/plugins/extract/mask/.cache/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/plugins/extract/recognition/.cache/.keep b/plugins/extract/recognition/.cache/.keep deleted file mode 100644 index e69de29bb2..0000000000 From bad5025aea1adb9126580e14e064e6c99089243d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 19 Jun 2022 12:32:39 +0100 Subject: [PATCH 634/981] Core updates - Change loss loading mechanism - Autosize tooltips based on content size - Random linting + code modernisation --- lib/gui/control_helper.py | 58 +++++++++++------- lib/model/layers.py | 2 +- lib/serializer.py | 22 +++---- plugins/train/model/_base/model.py | 29 ++++----- plugins/train/model/_base/settings.py | 86 ++++++++++++++++++++------- 5 files changed, 127 insertions(+), 70 deletions(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 09524be57b..f046198419 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -23,16 +23,28 @@ # We store Tooltips, ContextMenus and Commands globally when they are created # Because we need to add them back to newly cloned widgets (they are not easily accessible from # original config or are prone to getting destroyed when the original widget is destroyed) -_RECREATE_OBJECTS = dict(tooltips=dict(), commands=dict(), contextmenus=dict()) +_RECREATE_OBJECTS = dict(tooltips={}, commands={}, contextmenus={}) -def _get_tooltip(widget, text=None, text_variable=None, wrap_length=600): - """ Store the tooltip layout and widget id in _TOOLTIPS and return a tooltip """ +def _get_tooltip(widget, text=None, text_variable=None): + """ Store the tooltip layout and widget id in _TOOLTIPS and return a tooltip. + + Auto adjust tooltip width based on amount of text. + + """ _RECREATE_OBJECTS["tooltips"][str(widget)] = {"text": text, - "text_variable": text_variable, - "wrap_length": wrap_length} - logger.debug("Adding to tooltips dict: (widget: %s. text: '%s', wrap_length: %s)", - widget, text, wrap_length) + "text_variable": text_variable} + logger.debug("Adding to tooltips dict: (widget: %s. text: '%s')", widget, text) + + wrap_length = 400 + if text is not None: + while True: + if len(text) < wrap_length * 5: + break + if wrap_length > 720: + break + wrap_length = int(wrap_length * 1.10) + return Tooltip(widget, text=text, text_variable=text_variable, wrap_length=wrap_length) @@ -405,8 +417,8 @@ def __init__(self, parent, options, # pylint:disable=too-many-arguments if self._style.startswith("SPanel"): self._theme = {**self._theme, **get_config().user_theme["group_settings"]} - self.group_frames = dict() - self._sub_group_frames = dict() + self.group_frames = {} + self._sub_group_frames = {} canvas_kwargs = dict(bd=0, highlightthickness=0, bg=self._theme["panel_background"]) @@ -630,7 +642,7 @@ def set_subframes(self): """ Set a sub-frame for each possible column """ subframes = [] for idx in range(self.max_columns): - name = "af_subframe_{}".format(idx) + name = f"af_subframe_{idx}" subframe = ttk.Frame(self.parent, name=name, style=f"{self._style}TFrame") if idx < self.columns: # Only pack visible columns @@ -705,7 +717,7 @@ def _custom_kwargs(cls, widget): dict The custom keyword arguments required for recreating the given widget """ - retval = dict() + retval = {} if widget.__class__.__name__ == "MultiOption": retval = dict(value=widget._value, # pylint:disable=protected-access variable=widget._master_variable) # pylint:disable=protected-access @@ -773,7 +785,7 @@ def config_cleaner(widget): configuration from a widget We use config() instead of configure() because some items (ttk Scale) do not populate configure()""" - new_config = dict() + new_config = {} for key in widget.config(): if key == "class": continue @@ -923,7 +935,7 @@ def control_frame(self, parent): """ Frame to hold control and it's label """ logger.debug("Build control frame") frame = ttk.Frame(parent, - name="fr_{}".format(self.option.name), + name=f"fr_{self.option.name}", style=f"{self._style}Group.TFrame") frame.pack(fill=tk.X) logger.debug("Built control frame") @@ -955,7 +967,7 @@ def build_control_label(self): style=f"{self._style}Group.TLabel") lbl.pack(padx=5, pady=5, side=tk.LEFT, anchor=tk.N) if self.option.helptext is not None: - _get_tooltip(lbl, text=self.option.helptext, wrap_length=600) + _get_tooltip(lbl, text=self.option.helptext) logger.debug("Built control label: (widget: '%s', title: '%s'", self.option.name, self.option.title) @@ -975,7 +987,7 @@ def build_one_control(self): if self.option.control != ttk.Checkbutton: ctl.pack(padx=5, pady=5, fill=tk.X, expand=True) if self.option.helptext is not None and not self.helpset: - tooltip_kwargs = dict(text=self.option.helptext, wrap_length=600) + tooltip_kwargs = dict(text=self.option.helptext) if self.option.sysbrowser is not None: tooltip_kwargs["text_variable"] = self.option.tk_var _get_tooltip(ctl, **tooltip_kwargs) @@ -996,7 +1008,7 @@ def _multi_option_control(self, option_type): help_intro, help_items = self._get_multi_help_items(self.option.helptext) ctl = ttk.LabelFrame(self.frame, text=self.option.title, - name="{}_labelframe".format(option_type), + name=f"{option_type}_labelframe", style=f"{self._style}Group.TLabelframe") holder = AutoFillContainer(ctl, self.option_columns, @@ -1018,8 +1030,8 @@ def _multi_option_control(self, option_type): if choice.lower() in help_items: self.helpset = True helptext = help_items[choice.lower()] - helptext = "{}\n\n - {}".format(helptext, help_intro) - _get_tooltip(ctl, text=helptext, wrap_length=600) + helptext = f"{helptext}\n\n - {help_intro}" + _get_tooltip(ctl, text=helptext) ctl.pack(anchor=tk.W, fill=tk.X) logger.debug("Added %s option %s", option_type, choice) return holder.parent @@ -1183,14 +1195,14 @@ def _color_control(self): lbl.pack(padx=2, pady=5, side=tk.RIGHT, anchor=tk.N) frame.pack(side=tk.LEFT, anchor=tk.W) if self.option.helptext is not None: - _get_tooltip(lbl, text=self.option.helptext, wrap_length=600) + _get_tooltip(lbl, text=self.option.helptext) logger.debug("Added control to Options Frame: %s", self.option.name) return ctl def _ask_color(self, frame, title): """ Pop ask color dialog set to variable and change frame color """ color = self.option.tk_var.get() - chosen = colorchooser.askcolor(color=color, title="{} Color".format(title))[1] + chosen = colorchooser.askcolor(color=color, title=f"{title} Color")[1] if chosen is None: return frame.config(bg=chosen) @@ -1205,7 +1217,7 @@ def control_to_checkframe(self): text=self.option.title, name=self.option.name, style=f"{self._style}Group.TCheckbutton") - _get_tooltip(ctl, text=self.option.helptext, wrap_length=600) + _get_tooltip(ctl, text=self.option.helptext) ctl.pack(side=tk.TOP, anchor=tk.W, fill=tk.X) logger.debug("Added control checkframe: '%s'", self.option.name) return ctl @@ -1286,7 +1298,7 @@ def add_browser_buttons(self): cursor="hand2") _add_command(fileopn.cget("command"), cmd) fileopn.pack(padx=1, side=tk.RIGHT) - _get_tooltip(fileopn, text=self.helptext[lbl], wrap_length=600) + _get_tooltip(fileopn, text=self.helptext[lbl]) logger.debug("Added browser buttons: (action: %s, filetypes: %s", action, self.filetypes) @@ -1324,7 +1336,7 @@ def ask_multi_load(filepath, filetypes): """ Pop-up to get path to a file """ filenames = FileHandler("filename_multi", filetypes).return_file if filenames: - final_names = " ".join("\"{}\"".format(fname) for fname in filenames) + final_names = " ".join(f"\"{fname}\"" for fname in filenames) logger.debug(final_names) filepath.set(final_names) diff --git a/lib/model/layers.py b/lib/model/layers.py index 9fccc66aa5..daebb453b8 100644 --- a/lib/model/layers.py +++ b/lib/model/layers.py @@ -20,7 +20,7 @@ from tensorflow.keras.utils import get_custom_objects # noqa pylint:disable=no-name-in-module,import-error from tensorflow.keras import backend as K # pylint:disable=import-error from tensorflow.keras.layers import InputSpec, Layer # noqa pylint:disable=no-name-in-module,import-error - from tensorflow import pad + from tensorflow import pad # type:ignore from tensorflow.python.keras.utils import conv_utils # pylint:disable=no-name-in-module diff --git a/lib/serializer.py b/lib/serializer.py index db6b85d94d..d4d5ac57ca 100644 --- a/lib/serializer.py +++ b/lib/serializer.py @@ -17,8 +17,9 @@ try: import yaml + _HAS_YAML = True except ImportError: - yaml = None + _HAS_YAML = False logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -72,13 +73,13 @@ def save(self, filename, data): with open(filename, self._write_option) as s_file: s_file.write(self.marshal(data)) except IOError as err: - msg = "Error writing to '{}': {}".format(filename, err.strerror) + msg = f"Error writing to '{filename}': {err.strerror}" raise FaceswapError(msg) from err def _check_extension(self, filename): """ Check the filename has an extension. If not add the correct one for the serializer """ extension = os.path.splitext(filename)[1] - retval = filename if extension else "{}.{}".format(filename, self.file_extension) + retval = filename if extension else f"{filename}.{self.file_extension}" logger.debug("Original filename: '%s', final filename: '%s'", filename, retval) return retval @@ -109,7 +110,7 @@ def load(self, filename): retval = self.unmarshal(data) except IOError as err: - msg = "Error reading from '{}': {}".format(filename, err.strerror) + msg = f"Error reading from '{filename}': {err.strerror}" raise FaceswapError(msg) from err logger.debug("data type: %s", type(retval)) return retval @@ -137,7 +138,7 @@ def marshal(self, data): try: retval = self._marshal(data) except Exception as err: - msg = "Error serializing data for type {}: {}".format(type(data), str(err)) + msg = f"Error serializing data for type {type(data)}: {str(err)}" raise FaceswapError(msg) from err logger.debug("returned data type: %s", type(retval)) return retval @@ -165,8 +166,7 @@ def unmarshal(self, serialized_data): try: retval = self._unmarshal(serialized_data) except Exception as err: - msg = "Error unserializing data for type {}: {}".format(type(serialized_data), - str(err)) + msg = f"Error unserializing data for type {type(serialized_data)}: {str(err)}" raise FaceswapError(msg) from err logger.debug("returned data type: %s", type(retval)) return retval @@ -294,9 +294,9 @@ def get_serializer(serializer): retval = _JSONSerializer() elif serializer.lower() == "pickle": retval = _PickleSerializer() - elif serializer.lower() == "yaml" and yaml is not None: + elif serializer.lower() == "yaml" and _HAS_YAML: retval = _YAMLSerializer() - elif serializer.lower() == "yaml" and yaml is None: + elif serializer.lower() == "yaml": logger.warning("You must have PyYAML installed to use YAML as the serializer." "Switching to JSON as the serializer.") retval = _JSONSerializer @@ -336,9 +336,9 @@ def get_serializer_from_filename(filename): retval = _NPYSerializer() elif extension == ".fsa": retval = _CompressedSerializer() - elif extension in (".yaml", ".yml") and yaml is not None: + elif extension in (".yaml", ".yml") and _HAS_YAML: retval = _YAMLSerializer() - elif extension in (".yaml", ".yml") and yaml is None: + elif extension in (".yaml", ".yml"): logger.warning("You must have PyYAML installed to use YAML as the serializer.\n" "Switching to JSON as the serializer.") retval = _JSONSerializer() diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index e3f993dd40..3943d3d83a 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -195,7 +195,7 @@ def output_shapes(self) -> List[List[Tuple]]: """ list: A list of list of shape tuples for the outputs of the model with the batch dimension removed. The outer list contains 2 sub-lists (one for each side "a" and "b"). The inner sub-lists contain the output shapes for that side. """ - shapes = [tuple(K.int_shape(output)[-3:]) for output in self._model.outputs] + shapes = [tuple(K.int_shape(output)[-3:]) for output in self.model.outputs] return [shapes[:len(shapes) // 2], shapes[len(shapes) // 2:]] @property @@ -313,14 +313,15 @@ def _update_legacy_models(self) -> None: os.mkdir(self.model_dir) new_model = self.build_model(self._get_inputs()) for model_name, layer_name in legacy_mapping.items(): - old_model = load_model(os.path.join(archive_dir, model_name), compile=False) + old_model: keras.models.Model = load_model(os.path.join(archive_dir, model_name), + compile=False) layer = [layer for layer in new_model.layers if layer.name == layer_name] if not layer: logger.warning("Skipping legacy weights from '%s'...", model_name) continue - layer = layer[0] + klayer: keras.layers.Layer = layer[0] logger.info("Updating legacy weights from '%s'...", model_name) - layer.set_weights(old_model.get_weights()) + klayer.set_weights(old_model.get_weights()) filename = self._io._filename # pylint:disable=protected-access logger.info("Saving Tensorflow 2.x model to '%s'", filename) new_model.save(filename) @@ -392,7 +393,7 @@ def _output_summary(self) -> None: else: # print to logger print_fn = lambda x: logger.verbose("%s", x) # type: ignore # noqa - for idx, model in enumerate(get_all_sub_models(self._model)): + for idx, model in enumerate(get_all_sub_models(self.model)): if idx == 0: parent = model continue @@ -432,10 +433,10 @@ def _compile_model(self) -> None: weights.load(self._io.model_exists) weights.freeze() - self._loss.configure(self._model) - self._model.compile(optimizer=optimizer, loss=self._loss.functions) + self._loss.configure(self.model) + self.model.compile(optimizer=optimizer, loss=self._loss.functions) self._state.add_session_loss_names(self._loss.names) - logger.debug("Compiled Model: %s", self._model) + logger.debug("Compiled Model: %s", self.model) def _rewrite_plaid_outputs(self) -> None: """ Rewrite the output names for models using the PlaidML (Keras 2.2.4) backend @@ -447,17 +448,17 @@ def _rewrite_plaid_outputs(self) -> None: """ # TODO Remove this rewrite code if PlaidML updates to a version of Keras where this is # no longer necessary - if len(self._model.output_names) == len(set(self._model.output_names)): - logger.debug("Output names are unique, not rewriting: %s", self._model.output_names) + if len(self.model.output_names) == len(set(self.model.output_names)): + logger.debug("Output names are unique, not rewriting: %s", self.model.output_names) return - seen = {name: 0 for name in set(self._model.output_names)} + seen = {name: 0 for name in set(self.model.output_names)} new_names = [] - for name in self._model.output_names: + for name in self.model.output_names: new_names.append(f"{name}_{seen[name]}") seen[name] += 1 logger.debug("Output names rewritten: (old: %s, new: %s)", - self._model.output_names, new_names) - self._model.output_names = new_names + self.model.output_names, new_names) + self.model.output_names = new_names def _legacy_mapping(self) -> Optional[dict]: # pylint:disable=no-self-use """ The mapping of separate model files to single model layers for transferring of legacy diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 6ec1a6dfc5..4a0ed308ed 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -10,11 +10,12 @@ - Optimizer settings - General global model configuration settings """ +from dataclasses import dataclass, field import logging import platform from contextlib import nullcontext -from typing import Callable, ContextManager, Dict, List, Optional, TYPE_CHECKING +from typing import Any, Callable, ContextManager, Dict, List, Optional, TYPE_CHECKING, Union import tensorflow as tf @@ -42,6 +43,26 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name +@dataclass +class LossClass: + """ Typing class for holding loss functions. + + Parameters + ---------- + function: Callable + The function that takes in the true/predicted images and returns the loss + init: bool, Optional + Whether the loss object ``True`` needs to be initialized (i.e. it's a class) or + ``False`` it does not require initialization (i.e. it's a function). + Default ``True`` + kwargs: dict + Any keyword arguments to supply to the loss function at initialization. + """ + function: Union[Callable[[tf.Tensor, tf.Tensor], tf.Tensor], Any] = k_losses.mae + init: bool = True + kwargs: Dict[str, Any] = field(default_factory=dict) + + class Loss(): """ Holds loss names and functions for an Autoencoder. @@ -56,22 +77,27 @@ class Loss(): def __init__(self, config: dict) -> None: logger.debug("Initializing %s", self.__class__.__name__) self._config = config - logcosh = losses.LogCosh() if get_backend() == "amd" else k_losses.logcosh - self._loss_dict = dict(ffl=losses.FocalFrequencyLoss(), - gmsd=losses.GMSDLoss(), - l_inf_norm=losses.LInfNorm(), - laploss=losses.LaplacianPyramidLoss(), - logcosh=logcosh, - ms_ssim=losses.MSSIMLoss(), - mae=k_losses.mean_absolute_error, - mse=k_losses.mean_squared_error, - pixel_gradient_diff=losses.GradientLoss(), - ssim=losses.DSSIMObjective(), - smooth_loss=losses.GeneralizedLoss(),) self._mask_channels = self._get_mask_channels() self._inputs: List[keras.layers.Layer] = [] self._names: List[str] = [] self._funcs: Dict[str, Callable] = {} + + logcosh = losses.LogCosh() if get_backend() == "amd" else k_losses.logcosh + self._loss_dict = dict(ffl=LossClass(function=losses.FocalFrequencyLoss), + gmsd=LossClass(function=losses.GMSDLoss), + l_inf_norm=LossClass(function=losses.LInfNorm), + laploss=LossClass(function=losses.LaplacianPyramidLoss), + logcosh=LossClass(function=logcosh, + init=False), + ms_ssim=LossClass(function=losses.MSSIMLoss), + mae=LossClass(function=k_losses.mean_absolute_error, + init=False), + mse=LossClass(function=k_losses.mean_squared_error, + init=False), + pixel_gradient_diff=LossClass(function=losses.GradientLoss), + ssim=LossClass(function=losses.DSSIMObjective), + smooth_loss=LossClass(function=losses.GeneralizedLoss)) + logger.debug("Initialized: %s", self.__class__.__name__) @property @@ -144,6 +170,24 @@ def _set_loss_names(self, outputs: List[tf.Tensor]) -> None: self._names.append(f"{name}_{side}{suffix}") logger.debug(self._names) + def _get_function(self, name: str) -> Callable[[tf.Tensor, tf.Tensor], tf.Tensor]: + """ Obtain the requested Loss function + + Parameters + ---------- + name: str + The name of the loss function from the training configuration file + + Returns + ------- + Keras Loss Function + The requested loss function + """ + func = self._loss_dict[name] + retval = func.function(**func.kwargs) if func.init else func.function # type:ignore + logger.debug("Obtained loss function `%s` (%s)", name, retval) + return retval + def _set_loss_functions(self, output_names: List[str]): """ Set the loss functions and their associated weights. @@ -154,15 +198,15 @@ def _set_loss_functions(self, output_names: List[str]): output_names: list The output names from the model """ - face_losses = [(self._loss_dict[v], self._config.get(f"loss_weight_{k[-1]}", 100)) - for k, v in sorted(self._config.items()) + face_losses = [(lossname, self._config.get(f"loss_weight_{k[-1]}", 100)) + for k, lossname in sorted(self._config.items()) if k.startswith("loss_function") and self._config.get(f"loss_weight_{k[-1]}", 100) != 0 - and v is not None] + and lossname is not None] for name, output_name in zip(self._names, output_names): if name.startswith("mask"): - loss_func = self._loss_dict[self._config["mask_loss_function"]] + loss_func = self._get_function(self._config["mask_loss_function"]) else: loss_func = losses.LossWrapper() for func, weight in face_losses: @@ -174,7 +218,7 @@ def _set_loss_functions(self, output_names: List[str]): def _add_face_loss_function(self, loss_wrapper: losses.LossWrapper, - loss_function: Callable, + loss_function: str, weight: float) -> None: """ Add the given face loss function at the given weight and apply any mouth and eye multipliers @@ -183,13 +227,13 @@ def _add_face_loss_function(self, ---------- loss_wrapper: :class:`lib.model.losses.LossWrapper` The wrapper loss function that holds the face losses - loss_func: :class:`keras.losses.Loss` + loss_function: str The loss function to add to the loss wrapper weight: float The amount of weight to apply to the given loss function """ logger.debug("Adding loss function: %s, weight: %s", loss_function, weight) - loss_wrapper.add_loss(loss_function, + loss_wrapper.add_loss(self._get_function(loss_function), weight=weight, mask_channel=self._mask_channels[0]) @@ -199,7 +243,7 @@ def _add_face_loss_function(self, multiplier = self._config[section] * 1. if multiplier > 1.: logger.debug("Adding section loss %s: %s", section, multiplier) - loss_wrapper.add_loss(loss_function, + loss_wrapper.add_loss(self._get_function(loss_function), weight=weight * multiplier, mask_channel=mask_channel) channel_idx += 1 From d7ffcda3b0e3506e431e73951fc50eb8d3e69992 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 19 Jun 2022 18:42:34 +0100 Subject: [PATCH 635/981] Add LPIPS loss function --- docs/full/lib/model.rst | 6 + lib/model/loss/feature_loss_plaid.py | 381 ++++++++++++++++++++++++ lib/model/loss/feature_loss_tf.py | 400 ++++++++++++++++++++++++++ lib/model/loss/loss_plaid.py | 2 + lib/model/loss/loss_tf.py | 2 + lib/model/nn_blocks.py | 2 +- plugins/train/_config.py | 17 +- plugins/train/model/_base/model.py | 4 +- plugins/train/model/_base/settings.py | 6 + 9 files changed, 816 insertions(+), 4 deletions(-) create mode 100644 lib/model/loss/feature_loss_plaid.py create mode 100644 lib/model/loss/feature_loss_tf.py diff --git a/docs/full/lib/model.rst b/docs/full/lib/model.rst index 6156211733..3ca1f003d4 100755 --- a/docs/full/lib/model.rst +++ b/docs/full/lib/model.rst @@ -71,12 +71,18 @@ be imported as :mod:`lib.model.losses` depending on the backend in use. ~lib.model.loss.loss_tf.LaplacianPyramidLoss ~lib.model.loss.loss_tf.LInfNorm ~lib.model.loss.loss_tf.LossWrapper + ~lib.model.loss.feature_loss_tf.LPIPSLoss .. automodule:: lib.model.loss.loss_tf :members: :undoc-members: :show-inheritance: +.. automodule:: lib.model.loss.feature_loss_tf + :members: + :undoc-members: + :show-inheritance: + model.nets module ----------------- diff --git a/lib/model/loss/feature_loss_plaid.py b/lib/model/loss/feature_loss_plaid.py new file mode 100644 index 0000000000..8b148100e3 --- /dev/null +++ b/lib/model/loss/feature_loss_plaid.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 +""" Custom Feature Map Loss Functions for faceswap.py """ +from dataclasses import dataclass, field +import logging + +from typing import Any, Callable, Dict, Optional, List, Tuple + +import plaidml +from keras import applications as kapp +from keras.layers import Dropout, Conv2D, Input, Layer +from keras.models import Model +import keras.backend as K + +import numpy as np + +from lib.model.nets import AlexNet, SqueezeNet +from lib.utils import GetModel + +logger = logging.getLogger(__name__) + + +@dataclass +class NetInfo: + """ Data class for holding information about Trunk and Linear Layer nets. + + Parameters + ---------- + model_id: int + The model ID for the model stored in the deepfakes Model repo + model_name: str + The filename of the decompressed model/weights file + net: callable, Optional + The net definition to load, if any. Default:``None`` + init_kwargs: dict, optional + Keyword arguments to initialize any :attr:`net`. Default: empty ``dict`` + needs_init: bool, optional + True if the net needs initializing otherwise False. Default: ``True`` + """ + model_id: int = 0 + model_name: str = "" + net: Optional[Callable] = None + init_kwargs: Dict[str, Any] = field(default_factory=dict) + needs_init: bool = True + outputs: List[Layer] = field(default_factory=list) + + +class _TrunkNormLayer(Layer): + """ Create a layer for normalizing the output of the trunk model. + + Parameters + ---------- + epsilon: float, optional + A small number to add to the normalization. Default=`1e-10` + """ + def __init__(self, epsilon: float = 1e-10, **kwargs): + super().__init__(*kwargs) + self._epsilon = epsilon + + def call(self, inputs: plaidml.tile.Value, **kwargs) -> plaidml.tile.Value: + """ Call the trunk normalization layer. + + Parameters + ---------- + inputs: :class:`plaidml.tile.Value` + Input to the trunk output normalization layer + + Returns + ------- + :class:`plaidml.tile.Value` + The output from the layer + """ + norm_factor = K.sqrt(K.sum(K.square(inputs), axis=-1, keepdims=True)) + return inputs / (norm_factor + self._epsilon) + + +class _LPIPSTrunkNet(): # pylint:disable=too-few-public-methods + """ Trunk neural network loader for LPIPS Loss function. + + Parameters + ---------- + net_name: str + The name of the trunk network to load. One of "alex", "squeeze" or "vgg16" + """ + def __init__(self, net_name: str) -> None: + logger.debug("Initializing: %s (net_name '%s')", + self.__class__.__name__, net_name) + self._net = self._nets[net_name] + logger.debug("Initialized: %s ", self.__class__.__name__) + + @property + def _nets(self) -> Dict[str, NetInfo]: + """ :class:`NetInfo`: The Information about the requested net.""" + return dict( + alex=NetInfo(model_id=15, + model_name="alexnet_imagenet_no_top_v1.h5", + net=AlexNet, + outputs=[f"features.{idx}" for idx in (0, 3, 6, 8, 10)]), + squeeze=NetInfo(model_id=16, + model_name="squeezenet_imagenet_no_top_v1.h5", + net=SqueezeNet, + outputs=[f"features.{idx}" for idx in (0, 4, 7, 9, 10, 11, 12)]), + vgg16=NetInfo(model_id=17, + model_name="vgg16_imagenet_no_top_v1.h5", + net=kapp.vgg16.VGG16, + init_kwargs=dict(include_top=False, weights=None), + outputs=[f"block{i + 1}_conv{2 if i < 2 else 3}" for i in range(5)])) + + def _process_weights(self, model: Model) -> Model: + """ Save and lock weights if requested. + + Parameters + ---------- + model :class:`keras.models.Model` + The loaded trunk or linear network + + layers: list, optional + A list of layer names to explicitly load/freeze. If ``None`` then all model + layers will be processed + + Returns + ------- + :class:`keras.models.Model` + The network with weights loaded/not loaded and layers locked/unlocked + """ + weights = GetModel(self._net.model_name, self._net.model_id).model_path + model.load_weights(weights) + model.trainable = False + for layer in model.layers: + layer.trainable = False + return model + + def __call__(self) -> Model: + """ Load the Trunk net, add normalization to feature outputs, load weights and set + trainable state. + + Returns + ------- + :class:`tensorflow.keras.models.Model` + The trunk net with normalized feature output layers + """ + if self._net.net is None: + raise ValueError("No net loaded") + + model = self._net.net(**self._net.init_kwargs) + model = model if self._net.init_kwargs else model() # Non vgg need init + out_layers = [_TrunkNormLayer()(model.get_layer(name).output) + for name in self._net.outputs] + model = Model(inputs=model.input, outputs=out_layers) + model = self._process_weights(model) + return model + + +class _LinearLayer(Layer): + """ Create a layer for normalizing the output of the trunk model. + + Parameters + ---------- + use_dropout: bool, optional + Apply a dropout layer prior to the linear layer. Default: ``False`` + """ + def __init__(self, use_dropout: float = False, **kwargs): + self._use_dropout = use_dropout + super().__init__(**kwargs) + + def call(self, inputs: plaidml.tile.Value, **kwargs) -> plaidml.tile.Value: + """ Call the trunk normalization layer. + + Parameters + ---------- + inputs: :class:`plaidml.tile.Value` + Input to the trunk output normalization layer + + Returns + ------- + :class:`plaidml.tile.Value` + The output from the layer + """ + input_ = Input(K.int_shape(inputs)[1:]) + var_x = Dropout(rate=0.5)(input_) if self._use_dropout else input_ + var_x = Conv2D(1, 1, strides=1, padding="valid", use_bias=False)(var_x) + return var_x + + +class _LPIPSLinearNet(_LPIPSTrunkNet): # pylint:disable=too-few-public-methods + """ The Linear Network to be applied to the difference between the true and predicted outputs + of the trunk network. + + Parameters + ---------- + net_name: str + The name of the trunk network in use. One of "alex", "squeeze" or "vgg16" + trunk_net: :class:`keras.models.Model` + The trunk net to place the linear layer on. + use_dropout: bool + ``True`` if a dropout layer should be used in the Linear network otherwise ``False`` + """ + def __init__(self, + net_name: str, + trunk_net: Model, + use_dropout: bool) -> None: + logger.debug( + "Initializing: %s (trunk_net: %s, use_dropout: %s)", self.__class__.__name__, + trunk_net, use_dropout) + super().__init__(net_name=net_name) + + self._trunk = trunk_net + self._use_dropout = use_dropout + + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def _nets(self) -> Dict[str, NetInfo]: + """ :class:`NetInfo`: The Information about the requested net.""" + return dict( + alex=NetInfo(model_id=18, + model_name="alexnet_lpips_v1.h5",), + squeeze=NetInfo(model_id=19, + model_name="squeezenet_lpips_v1.h5"), + vgg16=NetInfo(model_id=20, + model_name="vgg16_lpips_v1.h5")) + + def _linear_block(self, net_output_layer: plaidml.tile.Value) -> Tuple[plaidml.tile.Value, + plaidml.tile.Value]: + """ Build a linear block for a trunk network output. + + Parameters + ---------- + net_output_layer: :class:`plaidml.tile.Value` + An output from the selected trunk network + + Returns + ------- + :class:`plaidml.tile.Value` + The input to the linear block + :class:`plaidml.tile.Value` + The output from the linear block + """ + in_shape = K.int_shape(net_output_layer)[1:] + input_ = Input(in_shape) + var_x = Dropout(rate=0.5)(input_) if self._use_dropout else input_ + var_x = Conv2D(1, 1, strides=1, padding="valid", use_bias=False)(var_x) + return input_, var_x + + def __call__(self) -> Model: + """ Build the linear network for the given trunk network's outputs. Load in trained weights + and set the model's trainable parameters. + + Returns + ------- + :class:`tensorflow.keras.models.Model` + The compiled Linear Net model + """ + inputs = [] + outputs = [] + for layer in self._trunk.outputs: + inp, out = self._linear_block(layer) + inputs.append(inp) + outputs.append(out) + + linear_model = Model(inputs=inputs, outputs=outputs) + linear_model = self._process_weights(linear_model) + + return linear_model + + +class LPIPSLoss(): # pylint:disable=too-few-public-methods + """ LPIPS Loss Function. + + A perceptual loss function that uses linear outputs from pretrained CNNs feature layers. + + Notes + ----- + Channels Last implementation. All trunks implemented from the original paper. + + References + ---------- + https://richzhang.github.io/PerceptualSimilarity/ + + Parameters + ---------- + trunk_network: str + The name of the trunk network to use. One of "alex", "squeeze" or "vgg16" + linear_use_dropout: bool, optional + ``True`` if a dropout layer should be used in the Linear network otherwise ``False``. + Default: ``True`` + lpips: bool, optional + ``True`` to use linear network on top of the trunk network. ``False`` to just average the + output from the trunk network. Default ``True`` + normalize: bool, optional + ``True`` if the input Tensor needs to be normalized from the 0. to 1. range to the -1. to + 1. range. Default: ``True`` + ret_per_layer: bool, optional + ``True`` to return the loss value per feature output layer otherwise ``False``. + Default: ``False`` + """ + def __init__(self, + trunk_network: str, + linear_use_dropout: bool = True, + lpips: bool = False, # TODO This should be True + normalize: bool = True, + ret_per_layer: bool = False) -> None: + logger.debug( + "Initializing: %s (trunk_network '%s', linear_use_dropout: %s, lpips: %s, " + "normalize: %s, ret_per_layer: %s)", self.__class__.__name__, trunk_network, + linear_use_dropout, lpips, normalize, ret_per_layer) + + self._use_lpips = lpips + self._normalize = normalize + self._ret_per_layer = ret_per_layer + self._shift = K.constant(np.array([-.030, -.088, -.188], + dtype="float32")[None, None, None, :]) + self._scale = K.constant(np.array([.458, .448, .450], + dtype="float32")[None, None, None, :]) + + self._trunk_net = _LPIPSTrunkNet(trunk_network)() + self._linear_net = _LPIPSLinearNet(trunk_network, self._trunk_net, linear_use_dropout)() + + logger.debug("Initialized: %s", self.__class__.__name__) + + def _process_diffs(self, inputs: List[plaidml.tile.Value]) -> List[plaidml.tile.Value]: + """ Perform processing on the Trunk Network outputs. + + If :attr:`use_ldip` is enabled, process the diff values through the linear network, + otherwise return the diff values summed on the channels axis. + + Parameters + ---------- + inputs: list + List of the squared difference of the true and predicted outputs from the trunk network + + Returns + ------- + list + List of either the linear network outputs (when using lpips) or summed network outputs + """ + if self._use_lpips: + # TODO Fix. Whilst the linear layer compiles and the weights load, PlaidML will + # error out as the graph is disconnected. + # The trunk output can be plugged straight into Linear input, but then weights for + # linear cannot be loaded, and this input would be incorrect (as linear input should + # be the diff between y_true and y_pred) + raise NotImplementedError + return self._linear_net(inputs) # pylint:disable=unreachable + return [K.sum(x, axis=-1) for x in inputs] + + def __call__(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: + """ Perform the LPIPS Loss Function. + + Parameters + ---------- + y_true: :class:`plaidml.tile.Value` + The ground truth batch of images + y_pred: :class:`plaidml.tile.Value` + The predicted batch of images + + Returns + ------- + :class:`plaidml.tile.Value` + The final loss value + """ + if self._normalize: + y_true = (y_true * 2.0) - 1.0 + y_pred = (y_pred * 2.0) - 1.0 + + y_true = (y_true - self._shift) / self._scale + y_pred = (y_pred - self._shift) / self._scale + + net_true = self._trunk_net(y_true) + net_pred = self._trunk_net(y_pred) + + diffs = [K.pow((out_true - out_pred), 2) + for out_true, out_pred in zip(net_true, net_pred)] + + res = [K.mean(diff, axis=(1, 2), keepdims=True) for diff in self._process_diffs(diffs)] + + val = K.sum(K.concatenate(res), axis=None) + + retval = (val, res) if self._ret_per_layer else val + return retval diff --git a/lib/model/loss/feature_loss_tf.py b/lib/model/loss/feature_loss_tf.py new file mode 100644 index 0000000000..37bcac79d4 --- /dev/null +++ b/lib/model/loss/feature_loss_tf.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +""" Custom Feature Map Loss Functions for faceswap.py """ +from dataclasses import dataclass, field +import logging + +from typing import Any, Callable, Dict, Optional, List, Tuple + +# Ignore linting errors from Tensorflow's thoroughly broken import system +import tensorflow as tf +from tensorflow.keras import applications as kapp # pylint:disable=import-error +from tensorflow.keras.layers import Dropout, Conv2D, Input, Layer, Resizing # noqa,pylint:disable=no-name-in-module,import-error +from tensorflow.keras.models import Model # pylint:disable=no-name-in-module,import-error +import tensorflow.keras.backend as K # pylint:disable=no-name-in-module,import-error + +import numpy as np + +from lib.model.nets import AlexNet, SqueezeNet +from lib.utils import GetModel + +logger = logging.getLogger(__name__) + + +@dataclass +class NetInfo: + """ Data class for holding information about Trunk and Linear Layer nets. + + Parameters + ---------- + model_id: int + The model ID for the model stored in the deepfakes Model repo + model_name: str + The filename of the decompressed model/weights file + net: callable, Optional + The net definition to load, if any. Default:``None`` + init_kwargs: dict, optional + Keyword arguments to initialize any :attr:`net`. Default: empty ``dict`` + needs_init: bool, optional + True if the net needs initializing otherwise False. Default: ``True`` + """ + model_id: int = 0 + model_name: str = "" + net: Optional[Callable] = None + init_kwargs: Dict[str, Any] = field(default_factory=dict) + needs_init: bool = True + outputs: List[Layer] = field(default_factory=list) + + +class _LPIPSTrunkNet(): # pylint:disable=too-few-public-methods + """ Trunk neural network loader for LPIPS Loss function. + + Parameters + ---------- + net_name: str + The name of the trunk network to load. One of "alex", "squeeze" or "vgg16" + eval_mode: bool + ``True`` for evaluation mode, ``False`` for training mode + load_weights: bool + ``True`` if pretrained trunk network weights should be loaded, otherwise ``False`` + """ + def __init__(self, net_name: str, eval_mode: bool, load_weights: bool) -> None: + logger.debug("Initializing: %s (net_name '%s', eval_mode: %s, load_weights: %s)", + self.__class__.__name__, net_name, eval_mode, load_weights) + self._eval_mode = eval_mode + self._load_weights = load_weights + self._net_name = net_name + self._net = self._nets[net_name] + logger.debug("Initialized: %s ", self.__class__.__name__) + + @property + def _nets(self) -> Dict[str, NetInfo]: + """ :class:`NetInfo`: The Information about the requested net.""" + return dict( + alex=NetInfo(model_id=15, + model_name="alexnet_imagenet_no_top_v1.h5", + net=AlexNet, + outputs=[f"features.{idx}" for idx in (0, 3, 6, 8, 10)]), + squeeze=NetInfo(model_id=16, + model_name="squeezenet_imagenet_no_top_v1.h5", + net=SqueezeNet, + outputs=[f"features.{idx}" for idx in (0, 4, 7, 9, 10, 11, 12)]), + vgg16=NetInfo(model_id=17, + model_name="vgg16_imagenet_no_top_v1.h5", + net=kapp.vgg16.VGG16, + init_kwargs=dict(include_top=False, weights=None), + outputs=[f"block{i + 1}_conv{2 if i < 2 else 3}" for i in range(5)])) + + @classmethod + def _normalize_output(cls, inputs: tf.Tensor, epsilon: float = 1e-10) -> tf.Tensor: + """ Normalize the output tensors from the trunk network. + + Parameters + ---------- + inputs: :class:`tensorflow.Tensor` + An output tensor from the trunk model + epsilon: float, optional + Epsilon to apply to the normalization operation. Default: `1e-10` + """ + norm_factor = K.sqrt(K.sum(K.square(inputs), axis=-1, keepdims=True)) + return inputs / (norm_factor + epsilon) + + def _process_weights(self, model: Model) -> Model: + """ Save and lock weights if requested. + + Parameters + ---------- + model :class:`keras.models.Model` + The loaded trunk or linear network + + Returns + ------- + :class:`keras.models.Model` + The network with weights loaded/not loaded and layers locked/unlocked + """ + if self._load_weights: + weights = GetModel(self._net.model_name, self._net.model_id).model_path + model.load_weights(weights) + + if self._eval_mode: + model.trainable = False + for layer in model.layers: + layer.trainable = False + return model + + def __call__(self) -> Model: + """ Load the Trunk net, add normalization to feature outputs, load weights and set + trainable state. + + Returns + ------- + :class:`tensorflow.keras.models.Model` + The trunk net with normalized feature output layers + """ + if self._net.net is None: + raise ValueError("No net loaded") + + model = self._net.net(**self._net.init_kwargs) + model = model if self._net_name == "vgg16" else model() + out_layers = [self._normalize_output(model.get_layer(name).output) + for name in self._net.outputs] + model = Model(inputs=model.input, outputs=out_layers) + model = self._process_weights(model) + return model + + +class _LPIPSLinearNet(_LPIPSTrunkNet): # pylint:disable=too-few-public-methods + """ The Linear Network to be applied to the difference between the true and predicted outputs + of the trunk network. + + Parameters + ---------- + net_name: str + The name of the trunk network in use. One of "alex", "squeeze" or "vgg16" + eval_mode: bool + ``True`` for evaluation mode, ``False`` for training mode + load_weights: bool + ``True`` if pretrained linear network weights should be loaded, otherwise ``False`` + trunk_net: :class:`keras.models.Model` + The trunk net to place the linear layer on. + use_dropout: bool + ``True`` if a dropout layer should be used in the Linear network otherwise ``False`` + """ + def __init__(self, + net_name: str, + eval_mode: bool, + load_weights: bool, + trunk_net: Model, + use_dropout: bool) -> None: + logger.debug( + "Initializing: %s (trunk_net: %s, use_dropout: %s)", self.__class__.__name__, + trunk_net, use_dropout) + super().__init__(net_name=net_name, eval_mode=eval_mode, load_weights=load_weights) + + self._trunk = trunk_net + self._use_dropout = use_dropout + + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def _nets(self) -> Dict[str, NetInfo]: + """ :class:`NetInfo`: The Information about the requested net.""" + return dict( + alex=NetInfo(model_id=18, + model_name="alexnet_lpips_v1.h5",), + squeeze=NetInfo(model_id=19, + model_name="squeezenet_lpips_v1.h5"), + vgg16=NetInfo(model_id=20, + model_name="vgg16_lpips_v1.h5")) + + def _linear_block(self, net_output_layer: tf.Tensor) -> Tuple[tf.Tensor, tf.Tensor]: + """ Build a linear block for a trunk network output. + + Parameters + ---------- + net_output_layer: :class:`tensorflow.Tensor` + An output from the selected trunk network + + Returns + ------- + :class:`tensorflow.Tensor` + The input to the linear block + :class:`tensorflow.Tensor` + The output from the linear block + """ + in_shape = K.int_shape(net_output_layer)[1:] + input_ = Input(in_shape) + var_x = Dropout(rate=0.5)(input_) if self._use_dropout else input_ + var_x = Conv2D(1, 1, strides=1, padding="valid", use_bias=False)(var_x) + return input_, var_x + + def __call__(self) -> Model: + """ Build the linear network for the given trunk network's outputs. Load in trained weights + and set the model's trainable parameters. + + Returns + ------- + :class:`tensorflow.keras.models.Model` + The compiled Linear Net model + """ + inputs = [] + outputs = [] + + for input_ in self._trunk.outputs: + in_, out = self._linear_block(input_) + inputs.append(in_) + outputs.append(out) + + model = Model(inputs=inputs, outputs=outputs) + model = self._process_weights(model) + return model + + +class LPIPSLoss(): # pylint:disable=too-few-public-methods + """ LPIPS Loss Function. + + A perceptual loss function that uses linear outputs from pretrained CNNs feature layers. + + Notes + ----- + Channels Last implementation. All trunks implemented from the original paper. + + References + ---------- + https://richzhang.github.io/PerceptualSimilarity/ + + Parameters + ---------- + trunk_network: str + The name of the trunk network to use. One of "alex", "squeeze" or "vgg16" + trunk_pretrained: bool, optional + ``True`` Load the imagenet pretrained weights for the trunk network. ``False`` randomly + initialize the trunk network. Default: ``True`` + trunk_eval_mode: bool, optional + ``True`` for running inference on the trunk network (standard mode), ``False`` for training + the trunk network. Default: ``True`` + linear_pretrained: bool, optional + ``True`` loads the pretrained weights for the linear network layers. ``False`` randomly + initializes the layers. Default: ``True`` + linear_eval_mode: bool, optional + ``True`` for running inference on the linear network (standard mode), ``False`` for + training the linear network. Default: ``True`` + linear_use_dropout: bool, optional + ``True`` if a dropout layer should be used in the Linear network otherwise ``False``. + Default: ``True`` + lpips: bool, optional + ``True`` to use linear network on top of the trunk network. ``False`` to just average the + output from the trunk network. Default ``True`` + spatial: bool, optional + ``True`` output the loss in the spatial domain (i.e. as a grayscale tensor of height and + width of the input image). ``Bool`` reduce the spatial dimensions for loss calculation. + Default: ``False`` + normalize: bool, optional + ``True`` if the input Tensor needs to be normalized from the 0. to 1. range to the -1. to + 1. range. Default: ``True`` + ret_per_layer: bool, optional + ``True`` to return the loss value per feature output layer otherwise ``False``. + Default: ``False`` + """ + def __init__(self, + trunk_network: str, + trunk_pretrained: bool = True, + trunk_eval_mode: bool = True, + linear_pretrained: bool = True, + linear_eval_mode: bool = True, + linear_use_dropout: bool = True, + lpips: bool = True, + spatial: bool = False, + normalize: bool = True, + ret_per_layer: bool = False) -> None: + logger.debug( + "Initializing: %s (trunk_network '%s', trunk_pretrained: %s, trunk_eval_mode: %s, " + "linear_pretrained: %s, linear_eval_mode: %s, linear_use_dropout: %s, lpips: %s, " + "spatial: %s, normalize: %s, ret_per_layer: %s)", self.__class__.__name__, + trunk_network, trunk_pretrained, trunk_eval_mode, linear_pretrained, linear_eval_mode, + linear_use_dropout, lpips, spatial, normalize, ret_per_layer) + + self._spatial = spatial + self._use_lpips = lpips + self._normalize = normalize + self._ret_per_layer = ret_per_layer + self._shift = K.constant(np.array([-.030, -.088, -.188], + dtype="float32")[None, None, None, :]) + self._scale = K.constant(np.array([.458, .448, .450], + dtype="float32")[None, None, None, :]) + + # Loss needs to be done as fp32. We could cast at output, but better to update the model + switch_mixed_precision = tf.keras.mixed_precision.global_policy().name == "mixed_float16" + if switch_mixed_precision: + logger.debug("Temporarily disabling mixed precision") + tf.keras.mixed_precision.set_global_policy("float32") + + self._trunk_net = _LPIPSTrunkNet(trunk_network, trunk_eval_mode, trunk_pretrained)() + self._linear_net = _LPIPSLinearNet(trunk_network, + linear_eval_mode, + linear_pretrained, + self._trunk_net, + linear_use_dropout)() + if switch_mixed_precision: + logger.debug("Re-enabling mixed precision") + tf.keras.mixed_precision.set_global_policy("mixed_float16") + logger.debug("Initialized: %s", self.__class__.__name__) + + def _process_diffs(self, inputs: List[tf.Tensor]) -> List[tf.Tensor]: + """ Perform processing on the Trunk Network outputs. + + If :attr:`use_ldip` is enabled, process the diff values through the linear network, + otherwise return the diff values summed on the channels axis. + + Parameters + ---------- + inputs: list + List of the squared difference of the true and predicted outputs from the trunk network + + Returns + ------- + list + List of either the linear network outputs (when using lpips) or summed network outputs + """ + if self._use_lpips: + return self._linear_net(inputs) + return [K.sum(x, axis=-1) for x in inputs] + + def _process_output(self, inputs: tf.Tensor, output_dims: tuple) -> tf.Tensor: + """ Process an individual output based on whether :attr:`is_spatial` has been selected. + + When spatial output is selected, all outputs are sized to the shape of the original True + input Tensor. When not selected, the mean across the spatial axes (h, w) are returned + + Parameters + ---------- + inputs: :class:`tensorflow.Tensor` + An individual diff output tensor from the linear network or summed output + output_dims: tuple + The (height, width) of the original true image + + Returns + ------- + :class:`tensorflow.Tensor` + Either the original tensor resized to the true image dimensions, or the mean + value across the height, width axes. + """ + if self._spatial: + return Resizing(*output_dims, interpolation="bilinear")(inputs) + return K.mean(inputs, axis=(1, 2), keepdims=True) + + def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + """ Perform the LPIPS Loss Function. + + Parameters + ---------- + y_true: :class:`tensorflow.Tensor` + The ground truth batch of images + y_pred: :class:`tensorflow.Tensor` + The predicted batch of images + + Returns + ------- + :class:`tensorflow.Tensor` + The final loss value + """ + if self._normalize: + y_true = (y_true * 2.0) - 1.0 + y_pred = (y_pred * 2.0) - 1.0 + + y_true = (y_true - self._shift) / self._scale + y_pred = (y_pred - self._shift) / self._scale + + net_true = self._trunk_net(y_true) + net_pred = self._trunk_net(y_pred) + + diffs = [(out_true - out_pred) ** 2 + for out_true, out_pred in zip(net_true, net_pred)] + + dims = K.int_shape(y_true)[1:3] + res = [self._process_output(diff, dims) for diff in self._process_diffs(diffs)] + + axis = 0 if self._spatial else None + val = K.sum(res, axis=axis) + + retval = (val, res) if self._ret_per_layer else val + return retval diff --git a/lib/model/loss/loss_plaid.py b/lib/model/loss/loss_plaid.py index 1e2fcb1172..9c83bb7dfa 100644 --- a/lib/model/loss/loss_plaid.py +++ b/lib/model/loss/loss_plaid.py @@ -13,6 +13,8 @@ from lib.plaidml_utils import pad from lib.utils import FaceswapError +from .feature_loss_plaid import LPIPSLoss #pylint:disable=unused-import # noqa + logger = logging.getLogger(__name__) # pylint:disable=invalid-name diff --git a/lib/model/loss/loss_tf.py b/lib/model/loss/loss_tf.py index f8c0101db4..6038f89290 100644 --- a/lib/model/loss/loss_tf.py +++ b/lib/model/loss/loss_tf.py @@ -13,6 +13,8 @@ from tensorflow.python.keras.engine import compile_utils # noqa pylint:disable=no-name-in-module,import-error from tensorflow.keras import backend as K # pylint:disable=import-error +from .feature_loss_tf import LPIPSLoss #pylint:disable=unused-import # noqa + logger = logging.getLogger(__name__) diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index 341ae17ed5..5cb92c2a22 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -327,7 +327,7 @@ def __call__(self, inputs: Tensor) -> Tensor: inputs = ReflectionPadding2D(stride=self._strides, kernel_size=self._args[-1], name=f"{self._name}_reflectionpadding2d")(inputs) - conv = DepthwiseConv2D if self._use_depthwise else Conv2D + conv: keras.layers.Layer = DepthwiseConv2D if self._use_depthwise else Conv2D var_x = conv(*self._args, strides=self._strides, padding=self._padding, diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 6765f798cb..fa31322c35 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -34,6 +34,21 @@ "implementation uses this loss as a complimentary function to MSE. " "Ref: Optimizing the Latent Space of Generative Networks " "https://arxiv.org/abs/1707.05776"), + lpips_alex=( + "LPIPS is a perceptual loss that uses the feature outputs of other pretrained models as a " + "loss metric. Be aware that this loss function will use more VRAM. Used on its own and " + "this loss will create a distinct moire pattern on the output, however it can be helpful " + "as a complimentary loss function. The output of this function is strong, so depending " + "on your chosen primary loss function, you are unlikely going to want to set the weight " + "above about 25%. Ref: The Unreasonable Effectiveness of Deep Features as a Perceptual " + "Metric http://arxiv.org/abs/1801.03924\nThis variant uses the AlexNet backbone. A fairly " + "light and old model which performed best in the paper's original implementation.\nNB: " + "For AMD Users the final linear layer is not implemented."), + lpips_squeeze=( + "Same as lpips_alex, but using the SqueezeNet backbone. A more lightweight " + "version of AlexNet.\nNB: For AMD Users the final linear layer is not implemented."), + lpips_vgg16="Same as lpips_alex, but using the VGG16 backbone. A more heavyweight model.\n" + "NB: For AMD Users the final linear layer is not implemented.", logcosh=( "log(cosh(x)) acts similar to MSE for small errors and to MAE for large errors. Like " "MSE, it is very stable and prevents overshoots when errors are near zero. Like MAE, it " @@ -66,7 +81,7 @@ "shifts, but maintains the structure of the image."), none="Do not use an additional loss function.") -_NON_PRIMARY_LOSS = ["none"] +_NON_PRIMARY_LOSS = ["lpips_alex", "lpips_squeeze", "lpips_vgg16", "none"] class Config(FaceswapConfig): diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 3943d3d83a..4d7d4984a0 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -804,7 +804,7 @@ def model(self) -> keras.models.Model: """ :class:`keras.models.Model`: The Faceswap model, compiled for inference. """ return self._model - def _get_nodes(self, nodes: list) -> List[Tuple[str, int]]: + def _get_nodes(self, nodes: np.ndarray) -> List[Tuple[str, int]]: """ Given in input list of nodes from a :attr:`keras.models.Model.get_config` dictionary, filters the layer name(s) and output index of the node, splitting to the correct output index in the event of multiple inputs. @@ -841,7 +841,7 @@ def _make_inference_model(self, saved_model: keras.models.Model) -> keras.models logger.debug("Compiling inference model. saved_model: %s", saved_model) struct = self._get_filtered_structure() model_inputs = self._get_inputs(saved_model.inputs) - compiled_layers = {} + compiled_layers: Dict[str, keras.layers.Layer] = {} for layer in saved_model.layers: if layer.name not in struct: logger.debug("Skipping unused layer: '%s'", layer.name) diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 4a0ed308ed..c124978b1a 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -89,6 +89,12 @@ def __init__(self, config: dict) -> None: laploss=LossClass(function=losses.LaplacianPyramidLoss), logcosh=LossClass(function=logcosh, init=False), + lpips_alex=LossClass(function=losses.LPIPSLoss, + kwargs=dict(trunk_network="alex")), + lpips_squeeze=LossClass(function=losses.LPIPSLoss, + kwargs=dict(trunk_network="squeeze")), + lpips_vgg16=LossClass(function=losses.LPIPSLoss, + kwargs=dict(trunk_network="vgg16")), ms_ssim=LossClass(function=losses.MSSIMLoss), mae=LossClass(function=k_losses.mean_absolute_error, init=False), From e2fc0703709a08f64f3aee44a5c78f9e71ebdb92 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 20 Jun 2022 03:38:47 +0100 Subject: [PATCH 636/981] Bug fixes - PhazeA tooltip spacing - Graph live cache bug --- lib/gui/analysis/event_reader.py | 12 +++++++--- lib/gui/analysis/stats.py | 10 ++++---- plugins/train/model/phaze_a_defaults.py | 32 ++++++++++++------------- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index 820a7ea24c..1f5f8bb690 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -321,13 +321,19 @@ def _add_latest_live(self, session_id, loss, timestamps): cache = self._data[session_id] for metric in ("loss", "timestamps"): data = locals()[metric] - old_shape = cache[f"{metric}_shape"] dtype = "float32" if metric == "loss" else "float64" - old = np.frombuffer(zlib.decompress(cache[metric]), dtype=dtype).reshape(old_shape) + + old = np.frombuffer(zlib.decompress(cache[metric]), dtype=dtype) + if data.ndim > 1: + old = old.reshape(-1, *data.shape[1:]) + new = np.concatenate((old, data)) - logger.debug("'%s' old_shape: %s new_shape: %s", metric, old_shape, new.shape) + + logger.debug("'%s' old_shape: %s new_shape: %s", + metric, cache[f"{metric}_shape"], new.shape) cache[f"{metric}_shape"] = new.shape cache[metric] = zlib.compress(new) + del old def get_data(self, session_id, metric): diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index 9ab8347d1e..6565f12e09 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -34,8 +34,8 @@ class GlobalSession(): def __init__(self) -> None: logger.debug("Initializing %s", self.__class__.__name__) self._state = None - self._model_dir = None - self._model_name = None + self._model_dir = "" + self._model_name = "" self._tb_logs = None self._summary = None @@ -48,7 +48,7 @@ def __init__(self) -> None: @property def is_loaded(self) -> bool: """ bool: ``True`` if session data is loaded otherwise ``False`` """ - return self._model_dir is not None + return bool(self._model_dir) @property def is_training(self) -> bool: @@ -146,8 +146,8 @@ def stop_training(self) -> None: def clear(self) -> None: """ Clear the currently loaded session. """ self._state = {} - self._model_dir = None - self._model_name = None + self._model_dir = "" + self._model_name = "" del self._tb_logs self._tb_logs = None diff --git a/plugins/train/model/phaze_a_defaults.py b/plugins/train/model/phaze_a_defaults.py index 03b1ae7c40..b930f27eba 100644 --- a/plugins/train/model/phaze_a_defaults.py +++ b/plugins/train/model/phaze_a_defaults.py @@ -142,47 +142,47 @@ info="The encoder architecture to use. See the relevant config sections for specific " "architecture tweaking.\nNB: For keras based pre-built models, the global " "initializers and padding options will be ignored for the selected encoder." - "\n\tdensenet: (32px -224px). Ref: Densely Connected Convolutional Networks (2016): " - "https://arxiv.org/abs/1608.06993?source=post_page" - "\n\tefficientnet: [Tensorflow 2.3+ only] EfficientNet has numerous variants (B0 - " + "\n\n\tdensenet: (32px -224px). Ref: Densely Connected Convolutional Networks " + "(2016): https://arxiv.org/abs/1608.06993?source=post_page" + "\n\n\tefficientnet: [Tensorflow 2.3+ only] EfficientNet has numerous variants (B0 - " "B8) that increases the model width, depth and dimensional space at each step. The " "minimum input resolution is 32px for all variants. The maximum input resolution for " "each variant is: b0: 224px, b1: 240px, b2: 260px, b3: 300px, b4: 380px, b5: 456px, " "b6: 528px, b7 600px. Ref: Rethinking Model Scaling for Convolutional Neural " "Networks (2020): https://arxiv.org/abs/1905.11946" - "\n\tefficientnet_v2: [Tensorflow 2.8+ only] EfficientNetV2 is the follow up to " + "\n\n\tefficientnet_v2: [Tensorflow 2.8+ only] EfficientNetV2 is the follow up to " "efficientnet. It has numerous variants (B0 - B3 and Small, Medium and Large) that " "increases the model width, depth and dimensional space at each step. The minimum " "input resolution is 32px for all variants. The maximum input resolution for each " "variant is: b0: 224px, b1: 240px, b2: 260px, b3: 300px, s: 384px, m: 480px, l: " "480px. Ref: EfficientNetV2: Smaller Models and Faster Training (2021): " "https://arxiv.org/abs/2104.00298" - "\n\tfs_original: (32px - 1024px). A configurable variant of the original facewap " + "\n\n\tfs_original: (32px - 1024px). A configurable variant of the original facewap " "encoder. ImageNet weights cannot be loaded for this model. Additional parameters " "can be configured with the 'fs_enc' options. A version of this encoder is used in " "the following models: Original, Original (lowmem), Dfaker, DFL-H128, DFL-SAE, IAE, " "Lightweight." - "\n\tinception_resnet_v2: (75px - 299px). Ref: Inception-ResNet and the Impact of " + "\n\n\tinception_resnet_v2: (75px - 299px). Ref: Inception-ResNet and the Impact of " "Residual Connections on Learning (2016): https://arxiv.org/abs/1602.07261" - "\n\tinceptionV3: (75px - 299px). Ref: Rethinking the Inception Architecture for " + "\n\n\tinceptionV3: (75px - 299px). Ref: Rethinking the Inception Architecture for " "Computer Vision (2015): https://arxiv.org/abs/1512.00567" - "\n\tmobilenet: (32px - 224px). Additional MobileNet parameters can be set with the " - "'mobilenet' options. Ref: MobileNets: Efficient Convolutional Neural Networks for " - "Mobile Vision Applications (2017): https://arxiv.org/abs/1704.04861" - "\n\tmobilenet_v2: (32px - 224px). Additional MobileNet parameters can be set with " + "\n\n\tmobilenet: (32px - 224px). Additional MobileNet parameters can be set with " + "the 'mobilenet' options. Ref: MobileNets: Efficient Convolutional Neural Networks " + "for Mobile Vision Applications (2017): https://arxiv.org/abs/1704.04861" + "\n\n\tmobilenet_v2: (32px - 224px). Additional MobileNet parameters can be set with " "the 'mobilenet' options. Ref: MobileNetV2: Inverted Residuals and Linear " "Bottlenecks (2018): https://arxiv.org/abs/1801.04381" - "\n\tmobilenet_v3: (32px - 224px). Additional MobileNet parameters can be set with " + "\n\n\tmobilenet_v3: (32px - 224px). Additional MobileNet parameters can be set with " "the 'mobilenet' options. Ref: Searching for MobileNetV3 (2019): " "https://arxiv.org/pdf/1905.02244.pdf" - "\n\tnasnet: (32px - 331px (large) or 224px (mobile)). Ref: Learning Transferable " + "\n\n\tnasnet: (32px - 331px (large) or 224px (mobile)). Ref: Learning Transferable " "Architectures for Scalable Image Recognition (2017): " "https://arxiv.org/abs/1707.07012" - "\n\tresnet: (32px - 224px). Deep Residual Learning for Image Recognition (2015): " + "\n\n\tresnet: (32px - 224px). Deep Residual Learning for Image Recognition (2015): " "https://arxiv.org/abs/1512.03385" - "\n\tvgg: (32px - 224px). Very Deep Convolutional Networks for Large-Scale Image " + "\n\n\tvgg: (32px - 224px). Very Deep Convolutional Networks for Large-Scale Image " "Recognition (2014): https://arxiv.org/abs/1409.1556" - "\n\txception: (71px - 229px). Ref: Deep Learning with Depthwise Separable " + "\n\n\txception: (71px - 229px). Ref: Deep Learning with Depthwise Separable " "Convolutions (2017): https://arxiv.org/abs/1409.1556.\n", datatype=str, choices=_ENCODERS, From 9e94273f8956aa003bc873cf7136043f3204b7cf Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 20 Jun 2022 12:04:35 +0100 Subject: [PATCH 637/981] bigfix - Live graph reading --- lib/gui/analysis/event_reader.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index 1f5f8bb690..fc66be3a7a 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -365,8 +365,10 @@ def get_data(self, session_id, metric): retval = {} for idx, data in raw.items(): - val = {metric: np.frombuffer(zlib.decompress(data[metric]), - dtype=dtype).reshape(data[f"{metric}_shape"])} + val = np.frombuffer(zlib.decompress(data[metric]), dtype=dtype) + shape = data[f"{metric}_shape"] + if len(shape) > 1: + val = val.reshape(-1, *shape[1:]) if metric == "loss": val["labels"] = data["labels"] retval[idx] = val From ad408f07fbdaeec56fa629ceee013b0778b494d4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 20 Jun 2022 12:46:23 +0100 Subject: [PATCH 638/981] typofix --- lib/gui/analysis/event_reader.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index fc66be3a7a..ecbaf52fc4 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -365,10 +365,11 @@ def get_data(self, session_id, metric): retval = {} for idx, data in raw.items(): - val = np.frombuffer(zlib.decompress(data[metric]), dtype=dtype) + buff = np.frombuffer(zlib.decompress(data[metric]), dtype=dtype) shape = data[f"{metric}_shape"] if len(shape) > 1: - val = val.reshape(-1, *shape[1:]) + buff = buff.reshape(-1, *shape[1:]) + val = {metric: buff} if metric == "loss": val["labels"] = data["labels"] retval[idx] = val From 5d700e869e25df9fb82f7da3bd1de86d2e09b2a3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 20 Jun 2022 13:07:37 +0100 Subject: [PATCH 639/981] stats bugfixes --- lib/gui/analysis/stats.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index 6565f12e09..4fe06be375 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -389,12 +389,14 @@ def _collate_stats(self, session_id: int) -> dict: The collated session summary statistics """ timestamps = self._time_stats[session_id] - elapsed = int(timestamps["end_time"] - timestamps["start_time"]) + start = np.nan_to_num(timestamps["start_time"]) + end = np.nan_to_num(timestamps["end_time"]) + elapsed = int(start - end) batchsize = self._session.batch_sizes.get(session_id, 0) retval = dict( session=session_id, - start=timestamps["start_time"], - end=timestamps["end_time"], + start=start, + end=end, elapsed=elapsed, rate=(((batchsize * 2) * timestamps["iterations"]) / elapsed if elapsed != 0 else 0), batch=batchsize, From da942e3b3f31fbdfe6a831f84da7e6d068e9c042 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 21 Jun 2022 12:32:09 +0100 Subject: [PATCH 640/981] Nan bugfix in Stats --- lib/gui/analysis/stats.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index 4fe06be375..1d80807ea3 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -367,12 +367,14 @@ def _get_per_session_stats(self) -> None: stats = self._per_session_stats[-1] - stats["start"] = ts_data["start_time"] - stats["end"] = ts_data["end_time"] - stats["elapsed"] = int(stats["end"] - stats["start"]) + start = np.nan_to_num(ts_data["start_time"]) + end = np.nan_to_num(ts_data["end_time"]) + stats["start"] = start + stats["end"] = end + stats["elapsed"] = int(end - start) stats["iterations"] = ts_data["iterations"] stats["rate"] = (((stats["batch"] * 2) * stats["iterations"]) - / stats["elapsed"] if stats["elapsed"] != 0 else 0) + / stats["elapsed"] if stats["elapsed"] > 0 else 0) logger.debug("per_session_stats: %s", self._per_session_stats) def _collate_stats(self, session_id: int) -> dict: From 0d2eafa48584366d750e2c7d4feb28f812daf81d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 22 Jun 2022 01:58:59 +0100 Subject: [PATCH 641/981] Enable change of precision for existing models --- plugins/train/_config.py | 1 + plugins/train/model/_base.py | 1737 ------------------------- plugins/train/model/_base/model.py | 36 +- plugins/train/model/_base/settings.py | 156 ++- 4 files changed, 182 insertions(+), 1748 deletions(-) delete mode 100644 plugins/train/model/_base.py diff --git a/plugins/train/_config.py b/plugins/train/_config.py index fa31322c35..a2903b021b 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -256,6 +256,7 @@ def _set_globals(self) -> None: title="mixed_precision", datatype=bool, default=False, + fixed=False, group="network", info="[Nvidia Only], NVIDIA GPUs can run operations in float16 faster than in " "float32. Mixed precision allows you to use a mix of float16 with float32, to " diff --git a/plugins/train/model/_base.py b/plugins/train/model/_base.py deleted file mode 100644 index 5c327d80bc..0000000000 --- a/plugins/train/model/_base.py +++ /dev/null @@ -1,1737 +0,0 @@ -#!/usr/bin/env python3 -""" -Base class for Models. ALL Models should at least inherit from this class. - -See :mod:`~plugins.train.model.original` for an annotated example for how to create model plugins. -""" -import logging -import os -import platform -import sys -import time - -from collections import OrderedDict -from contextlib import nullcontext - -import numpy as np -import tensorflow as tf - -from lib.serializer import get_serializer -from lib.model.backup_restore import Backup -from lib.model import losses, optimizers -from lib.model.nn_blocks import set_config as set_nnblock_config -from lib.utils import get_backend, FaceswapError -from plugins.train._config import Config - -if get_backend() == "amd": - from keras import losses as k_losses - from keras import backend as K - from keras.layers import Input - from keras.models import load_model, Model as KModel -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras import losses as k_losses # pylint:disable=import-error - from tensorflow.keras import backend as K # pylint:disable=import-error - from tensorflow.keras.layers import Input # pylint:disable=import-error,no-name-in-module - from tensorflow.keras.models import load_model, Model as KModel # noqa pylint:disable=import-error,no-name-in-module - - -logger = logging.getLogger(__name__) # pylint: disable=invalid-name -_CONFIG = None - - -def KerasModel(inputs, outputs, name): # pylint:disable=invalid-name - """ wrapper for :class:`keras.models.Model`. - - There are some minor foibles between Keras 2.2 and the Tensorflow version of Keras, so this - catches potential issues and fixes prior to returning the requested model. - - All models created within plugins should use this method, and should not call keras directly - for a model. - - Parameters - ---------- - inputs: a keras.Input object or list of keras.Input objects. - The input(s) of the model - outputs: keras objects - The output(s) of the model. - name: str - The name of the model. - - Returns - ------- - :class:`keras.models.Model` - A Keras Model - """ - if get_backend() == "amd": - logger.debug("Flattening inputs (%s) and outputs (%s) for AMD", inputs, outputs) - inputs = np.array(inputs).flatten().tolist() - outputs = np.array(outputs).flatten().tolist() - logger.debug("Flattened inputs (%s) and outputs (%s)", inputs, outputs) - return KModel(inputs, outputs, name=name) - - -def _get_all_sub_models(model, models=None): - """ For a given model, return all sub-models that occur (recursively) as children. - - Parameters - ---------- - model: :class:`keras.models.Model` - A Keras model to scan for sub models - models: `None` - Do not provide this parameter. It is used for recursion - - Returns - ------- - list - A list of all :class:`keras.models.Model`s found within the given model. The provided - model will always be returned in the first position - """ - if models is None: - models = [model] - else: - models.append(model) - for layer in model.layers: - if isinstance(layer, KModel): - _get_all_sub_models(layer, models=models) - return models - - -class ModelBase(): - """ Base class that all model plugins should inherit from. - - Parameters - ---------- - model_dir: str - The full path to the model save location - arguments: :class:`argparse.Namespace` - The arguments that were passed to the train or convert process as generated from - Faceswap's command line arguments - predict: bool, optional - ``True`` if the model is being loaded for inference, ``False`` if the model is being loaded - for training. Default: ``False`` - - Attributes - ---------- - input_shape: tuple or list - A `tuple` of `ints` defining the shape of the faces that the model takes as input. This - should be overridden by model plugins in their :func:`__init__` function. If the input size - is the same for both sides of the model, then this can be a single 3 dimensional `tuple`. - If the inputs have different sizes for `"A"` and `"B"` this should be a `list` of 2 3 - dimensional shape `tuples`, 1 for each side respectively. - trainer: str - Currently there is only one trainer available (`"original"`), so at present this attribute - can be ignored. If/when more trainers are added, then this attribute should be overridden - with the trainer name that a model requires in the model plugin's - :func:`__init__` function. - """ - def __init__(self, model_dir, arguments, predict=False): - logger.debug("Initializing ModelBase (%s): (model_dir: '%s', arguments: %s, predict: %s)", - self.__class__.__name__, model_dir, arguments, predict) - - self.input_shape = None # Must be set within the plugin after initializing - self.trainer = "original" # Override for plugin specific trainer - self.color_order = "bgr" # Override for plugin specific image color channel order - - self._args = arguments - self._is_predict = predict - self._model = None - - self._configfile = arguments.configfile if hasattr(arguments, "configfile") else None - self._load_config() - - if self.config["penalized_mask_loss"] and self.config["mask_type"] is None: - raise FaceswapError("Penalized Mask Loss has been selected but you have not chosen a " - "Mask to use. Please select a mask or disable Penalized Mask " - "Loss.") - - self._io = _IO(self, model_dir, self._is_predict) - self._check_multiple_models() - - self._state = State(model_dir, - self.name, - self._config_changeable_items, - False if self._is_predict else self._args.no_logs) - self._settings = _Settings(self._args, - self.config["mixed_precision"], - self.config["allow_growth"], - self._is_predict) - self._loss = _Loss() - - logger.debug("Initialized ModelBase (%s)", self.__class__.__name__) - - @property - def model(self): - """:class:`Keras.models.Model`: The compiled model for this plugin. """ - return self._model - - @property - def command_line_arguments(self): - """ :class:`argparse.Namespace`: The command line arguments passed to the model plugin from - either the train or convert script """ - return self._args - - @property - def coverage_ratio(self): - """ float: The ratio of the training image to crop out and train on as defined in user - configuration options. - - NB: The coverage ratio is a raw float, but will be applied to integer pixel images. - - To ensure consistent rounding and guaranteed even image size, the calculation for coverage - should always be: :math:`(original_size * coverage_ratio // 2) * 2` - """ - return self.config.get("coverage", 62.5) / 100 - - @property - def model_dir(self): - """str: The full path to the model folder location. """ - return self._io._model_dir # pylint:disable=protected-access - - @property - def config(self): - """ dict: The configuration dictionary for current plugin, as set by the user's - configuration settings. """ - global _CONFIG # pylint: disable=global-statement - if not _CONFIG: - model_name = self._config_section - logger.debug("Loading config for: %s", model_name) - _CONFIG = Config(model_name, configfile=self._configfile).config_dict - return _CONFIG - - @property - def name(self): - """ str: The name of this model based on the plugin name. """ - basename = os.path.basename(sys.modules[self.__module__].__file__) - return os.path.splitext(basename)[0].lower() - - @property - def model_name(self): - """ str: The name of the keras model. Generally this will be the same as :attr:`name` - but some plugins will override this when they contain multiple architectures """ - return self.name - - @property - def output_shapes(self): - """ list: A list of list of shape tuples for the outputs of the model with the batch - dimension removed. The outer list contains 2 sub-lists (one for each side "a" and "b"). - The inner sub-lists contain the output shapes for that side. """ - shapes = [tuple(K.int_shape(output)[-3:]) for output in self._model.outputs] - return [shapes[:len(shapes) // 2], shapes[len(shapes) // 2:]] - - @property - def iterations(self): - """ int: The total number of iterations that the model has trained. """ - return self._state.iterations - - # Private properties - @property - def _config_section(self): - """ str: The section name for the current plugin for loading configuration options from the - config file. """ - return ".".join(self.__module__.split(".")[-2:]) - - @property - def _config_changeable_items(self): - """ dict: The configuration options that can be updated after the model has already been - created. """ - return Config(self._config_section, configfile=self._configfile).changeable_items - - @property - def state(self): - """:class:`State`: The state settings for the current plugin. """ - return self._state - - def _load_config(self): - """ Load the global config for reference in :attr:`config` and set the faceswap blocks - configuration options in `lib.model.nn_blocks` """ - global _CONFIG # pylint: disable=global-statement - if not _CONFIG: - model_name = self._config_section - logger.debug("Loading config for: %s", model_name) - _CONFIG = Config(model_name, configfile=self._configfile).config_dict - - nn_block_keys = ['icnr_init', 'conv_aware_init', 'reflect_padding'] - set_nnblock_config({key: _CONFIG.pop(key) - for key in nn_block_keys}) - - def _check_multiple_models(self): - """ Check whether multiple models exist in the model folder, and that no models exist that - were trained with a different plugin than the requested plugin. - - Raises - ------ - FaceswapError - If multiple model files, or models for a different plugin from that requested exists - within the model folder - """ - multiple_models = self._io.multiple_models_in_folder - if multiple_models is None: - logger.debug("Contents of model folder are valid") - return - - if len(multiple_models) == 1: - msg = (f"You have requested to train with the '{self.name}' plugin, but a model file " - f"for the '{multiple_models[0]}' plugin already exists in the folder " - f"'{self.model_dir}'.\nPlease select a different model folder.") - else: - ptypes = "', '".join(multiple_models) - msg = (f"There are multiple plugin types ('{ptypes}') stored in the model folder '" - f"{self.model_dir}'. This is not supported.\nPlease split the model files into " - "their own folders before proceeding") - raise FaceswapError(msg) - - def build(self): - """ Build the model and assign to :attr:`model`. - - Within the defined strategy scope, either builds the model from scratch or loads an - existing model if one exists. - - If running inference, then the model is built only for the required side to perform the - swap function, otherwise the model is then compiled with the optimizer and chosen - loss function(s). - - Finally, a model summary is outputted to the logger at verbose level. - """ - self._update_legacy_models() - is_summary = hasattr(self._args, "summary") and self._args.summary - with self._settings.strategy_scope(): - if self._io.model_exists: - model = self._io._load() # pylint:disable=protected-access - if self._is_predict: - inference = _Inference(model, self._args.swap_model) - self._model = inference.model - else: - self._model = model - else: - self._validate_input_shape() - inputs = self._get_inputs() - self._model = self.build_model(inputs) - if not is_summary and not self._is_predict: - self._compile_model() - self._output_summary() - - def _update_legacy_models(self): - """ Load weights from legacy split models into new unified model, archiving old model files - to a new folder. """ - if self._legacy_mapping() is None: - return - if not all(os.path.isfile(os.path.join(self.model_dir, fname)) - for fname in self._legacy_mapping()): - return - archive_dir = f"{self.model_dir}_TF1_Archived" - if os.path.exists(archive_dir): - raise FaceswapError("We need to update your model files for use with Tensorflow 2.x, " - "but the archive folder already exists. Please remove the " - f"following folder to continue: '{archive_dir}'") - - logger.info("Updating legacy models for Tensorflow 2.x") - logger.info("Your Tensorflow 1.x models will be archived in the following location: '%s'", - archive_dir) - os.rename(self.model_dir, archive_dir) - os.mkdir(self.model_dir) - new_model = self.build_model(self._get_inputs()) - for model_name, layer_name in self._legacy_mapping().items(): - old_model = load_model(os.path.join(archive_dir, model_name), compile=False) - layer = [layer for layer in new_model.layers if layer.name == layer_name] - if not layer: - logger.warning("Skipping legacy weights from '%s'...", model_name) - continue - layer = layer[0] - logger.info("Updating legacy weights from '%s'...", model_name) - layer.set_weights(old_model.get_weights()) - filename = self._io._filename # pylint:disable=protected-access - logger.info("Saving Tensorflow 2.x model to '%s'", filename) - new_model.save(filename) - # Penalized Loss and Learn Mask used to be disabled automatically if a mask wasn't - # selected, so disable it if enabled, but mask_type is None - if self.config["mask_type"] is None: - self.config["penalized_mask_loss"] = False - self.config["learn_mask"] = False - self.config["eye_multiplier"] = 1 - self.config["mouth_multiplier"] = 1 - self._state.save() - - def _validate_input_shape(self): - """ Validate that the input shape is either a single shape tuple of 3 dimensions or - a list of 2 shape tuples of 3 dimensions. """ - assert len(self.input_shape) in (2, 3), "Input shape should either be a single 3 " \ - "dimensional shape tuple for use in both sides of the model, or a list of 2 3 " \ - "dimensional shape tuples for use in the 'A' and 'B' sides of the model" - if len(self.input_shape) == 2: - assert [len(shape) == 3 for shape in self.input_shape], "All input shapes should " \ - "have 3 dimensions" - - def _get_inputs(self): - """ Obtain the standardized inputs for the model. - - The inputs will be returned for the "A" and "B" sides in the shape as defined by - :attr:`input_shape`. - - Returns - ------- - list - A list of :class:`keras.layers.Input` tensors. This will be a list of 2 tensors (one - for each side) each of shapes :attr:`input_shape`. - """ - logger.debug("Getting inputs") - if len(self.input_shape) == 3: - input_shapes = [self.input_shape, self.input_shape] - else: - input_shapes = self.input_shape - inputs = [Input(shape=shape, name=f"face_in_{side}") - for side, shape in zip(("a", "b"), input_shapes)] - logger.debug("inputs: %s", inputs) - return inputs - - def build_model(self, inputs): - """ Override for Model Specific autoencoder builds. - - Parameters - ---------- - inputs: list - A list of :class:`keras.layers.Input` tensors. This will be a list of 2 tensors (one - for each side) each of shapes :attr:`input_shape`. - """ - raise NotImplementedError - - def _output_summary(self): - """ Output the summary of the model and all sub-models to the verbose logger. """ - if hasattr(self._args, "summary") and self._args.summary: - print_fn = None # Print straight to stdout - else: - # print to logger - print_fn = lambda x: logger.verbose("%s", x) # noqa - for idx, model in enumerate(_get_all_sub_models(self._model)): - if idx == 0: - parent = model - continue - model.summary(line_length=100, print_fn=print_fn) - parent.summary(line_length=100, print_fn=print_fn) - - def save(self): - """ Save the model to disk. - - Saves the serialized model, with weights, to the folder location specified when - initializing the plugin. If loss has dropped on both sides of the model, then - a backup is taken. - """ - self._io._save() # pylint:disable=protected-access - - def snapshot(self): - """ Creates a snapshot of the model folder to the models parent folder, with the number - of iterations completed appended to the end of the model name. """ - self._io._snapshot() # pylint:disable=protected-access - - def _compile_model(self): - """ Compile the model to include the Optimizer and Loss Function(s). """ - logger.debug("Compiling Model") - - optimizer = _Optimizer(self.config["optimizer"], - self.config["learning_rate"], - self.config.get("clipnorm", False), - 10 ** int(self.config["epsilon_exponent"]), - self._args).optimizer - if self._settings.use_mixed_precision: - optimizer = tf.keras.mixed_precision.LossScaleOptimizer(optimizer) - - if get_backend() == "amd": - self._rewrite_plaid_outputs() - - weights = _Weights(self) - weights.load(self._io.model_exists) - weights.freeze() - - self._loss.configure(self._model) - self._model.compile(optimizer=optimizer, loss=self._loss.functions) - self._state.add_session_loss_names(self._loss.names) - logger.debug("Compiled Model: %s", self._model) - - def _rewrite_plaid_outputs(self): - """ Rewrite the output names for models using the PlaidML (Keras 2.2.4) backend - - Keras 2.2.4 duplicates model output names if any of the models have multiple outputs - so we need to rename the outputs so we can successfully map the loss dictionaries. - - This is a bit of a hack, but it does work. - """ - # TODO Remove this rewrite code if PlaidML updates to a version of Keras where this is - # no longer necessary - if len(self._model.output_names) == len(set(self._model.output_names)): - logger.debug("Output names are unique, not rewriting: %s", self._model.output_names) - return - seen = {name: 0 for name in set(self._model.output_names)} - new_names = [] - for name in self._model.output_names: - new_names.append(f"{name}_{seen[name]}") - seen[name] += 1 - logger.debug("Output names rewritten: (old: %s, new: %s)", - self._model.output_names, new_names) - self._model.output_names = new_names - - def _legacy_mapping(self): # pylint:disable=no-self-use - """ The mapping of separate model files to single model layers for transferring of legacy - weights. - - Returns - ------- - dict or ``None`` - Dictionary of original H5 filenames for legacy models mapped to new layer names or - ``None`` if the model did not exist in Faceswap prior to Tensorflow 2 - """ - return None - - def add_history(self, loss): - """ Add the current iteration's loss history to :attr:`_io.history`. - - Called from the trainer after each iteration, for tracking loss drop over time between - save iterations. - - Parameters - ---------- - loss: list - The loss values for the A and B side for the current iteration. This should be the - collated loss values for each side. - """ - self._io.history[0].append(loss[0]) - self._io.history[1].append(loss[1]) - - -class _IO(): - """ Model saving and loading functions. - - Handles the loading and saving of the plugin model from disk as well as the model backup and - snapshot functions. - - Parameters - ---------- - plugin: :class:`Model` - The parent plugin class that owns the IO functions. - model_dir: str - The full path to the model save location - is_predict: bool - ``True`` if the model is being loaded for inference. ``False`` if the model is being loaded - for training. - """ - def __init__(self, plugin, model_dir, is_predict): - self._plugin = plugin - self._is_predict = is_predict - self._model_dir = model_dir - self._history = [[], []] # Loss histories per save iteration - self._backup = Backup(self._model_dir, self._plugin.name) - - @property - def _filename(self): - """str: The filename for this model.""" - return os.path.join(self._model_dir, f"{self._plugin.name}.h5") - - @property - def model_exists(self): - """ bool: ``True`` if a model of the type being loaded exists within the model folder - location otherwise ``False``. - """ - return os.path.isfile(self._filename) - - @property - def history(self): - """ list: list of loss histories per side for the current save iteration. """ - return self._history - - @property - def multiple_models_in_folder(self): - """ :list: or ``None`` If there are multiple model types in the requested folder, or model - types that don't correspond to the requested plugin type, then returns the list of plugin - names that exist in the folder, otherwise returns ``None`` """ - plugins = [fname.replace(".h5", "") - for fname in os.listdir(self._model_dir) - if fname.endswith(".h5")] - test_names = plugins + [self._plugin.name] - test = False if not test_names else os.path.commonprefix(test_names) == "" - retval = None if not test else plugins - logger.debug("plugin name: %s, plugins: %s, test result: %s, retval: %s", - self._plugin.name, plugins, test, retval) - return retval - - def _load(self): - """ Loads the model from disk - - If the predict function is to be called and the model cannot be found in the model folder - then an error is logged and the process exits. - - When loading the model, the plugin model folder is scanned for custom layers which are - added to Keras' custom objects. - - Returns - ------- - :class:`keras.models.Model` - The saved model loaded from disk - """ - logger.debug("Loading model: %s", self._filename) - if self._is_predict and not self.model_exists: - logger.error("Model could not be found in folder '%s'. Exiting", self._model_dir) - sys.exit(1) - - try: - model = load_model(self._filename, compile=False) - except RuntimeError as err: - if "unable to get link info" in str(err).lower(): - msg = (f"Unable to load the model from '{self._filename}'. This may be a " - "temporary error but most likely means that your model has corrupted.\n" - "You can try to load the model again but if the problem persists you " - "should use the Restore Tool to restore your model from backup.\n" - f"Original error: {str(err)}") - raise FaceswapError(msg) from err - raise err - except KeyError as err: - if "unable to open object" in str(err).lower(): - msg = (f"Unable to load the model from '{self._filename}'. This may be a " - "temporary error but most likely means that your model has corrupted.\n" - "You can try to load the model again but if the problem persists you " - "should use the Restore Tool to restore your model from backup.\n" - f"Original error: {str(err)}") - raise FaceswapError(msg) from err - raise err - - logger.info("Loaded model from disk: '%s'", self._filename) - return model - - def _save(self): - """ Backup and save the model and state file. - - Notes - ----- - The backup function actually backups the model from the previous save iteration rather than - the current save iteration. This is not a bug, but protection against long save times, as - models can get quite large, so renaming the current model file rather than copying it can - save substantial amount of time. - """ - logger.debug("Backing up and saving models") - print("") # Insert a new line to avoid spamming the same row as loss output - save_averages = self._get_save_averages() - if save_averages and self._should_backup(save_averages): - self._backup.backup_model(self._filename) - # pylint:disable=protected-access - self._backup.backup_model(self._plugin.state._filename) - - self._plugin.model.save(self._filename, include_optimizer=False) - self._plugin.state.save() - - msg = "[Saved models]" - if save_averages: - lossmsg = [f"face_{side}: {avg:.5f}" - for side, avg in zip(("a", "b"), save_averages)] - msg += f" - Average loss since last save: {', '.join(lossmsg)}" - logger.info(msg) - - def _get_save_averages(self): - """ Return the average loss since the last save iteration and reset historical loss """ - logger.debug("Getting save averages") - if not all(loss for loss in self._history): - logger.debug("No loss in history") - retval = [] - else: - retval = [sum(loss) / len(loss) for loss in self._history] - self._history = [[], []] # Reset historical loss - logger.debug("Average losses since last save: %s", retval) - return retval - - def _should_backup(self, save_averages): - """ Check whether the loss averages for this save iteration is the lowest that has been - seen. - - This protects against model corruption by only backing up the model if both sides have - seen a total fall in loss. - - Notes - ----- - This is by no means a perfect system. If the model corrupts at an iteration close - to a save iteration, then the averages may still be pushed lower than a previous - save average, resulting in backing up a corrupted model. - - Parameters - ---------- - save_averages: list - The average loss for each side for this save iteration - """ - backup = True - for side, loss in zip(("a", "b"), save_averages): - if not self._plugin.state.lowest_avg_loss.get(side, None): - logger.debug("Set initial save iteration loss average for '%s': %s", side, loss) - self._plugin.state.lowest_avg_loss[side] = loss - continue - backup = loss < self._plugin.state.lowest_avg_loss[side] if backup else backup - - if backup: # Update lowest loss values to the state file - # pylint:disable=unnecessary-comprehension - old_avgs = {key: val for key, val in self._plugin.state.lowest_avg_loss.items()} - self._plugin.state.lowest_avg_loss["a"] = save_averages[0] - self._plugin.state.lowest_avg_loss["b"] = save_averages[1] - logger.debug("Updated lowest historical save iteration averages from: %s to: %s", - old_avgs, self._plugin.state.lowest_avg_loss) - - logger.debug("Should backup: %s", backup) - return backup - - def _snapshot(self): - """ Perform a model snapshot. - - Notes - ----- - Snapshot function is called 1 iteration after the model was saved, so that it is built from - the latest save, hence iteration being reduced by 1. - """ - logger.debug("Performing snapshot. Iterations: %s", self._plugin.iterations) - self._backup.snapshot_models(self._plugin.iterations - 1) - logger.debug("Performed snapshot") - - -class _Settings(): - """ Tensorflow core training settings. - - Sets backend tensorflow settings prior to launching the model. - - Tensorflow 2 uses distribution strategies for multi-GPU/system training. These are context - managers. To enable the code to be more readable, we handle strategies the same way for Nvidia - and AMD backends. PlaidML does not support strategies, but we need to still create a context - manager so that we don't need branching logic. - - Parameters - ---------- - arguments: :class:`argparse.Namespace` - The arguments that were passed to the train or convert process as generated from - Faceswap's command line arguments - mixed_precision: bool - ``True`` if Mixed Precision training should be used otherwise ``False`` - allow_growth: bool - ``True`` if the Tensorflow allow_growth parameter should be set otherwise ``False`` - is_predict: bool, optional - ``True`` if the model is being loaded for inference, ``False`` if the model is being loaded - for training. Default: ``False`` - """ - def __init__(self, arguments, mixed_precision, allow_growth, is_predict): - logger.debug("Initializing %s: (arguments: %s, mixed_precision: %s, allow_growth: %s, " - "is_predict: %s)", self.__class__.__name__, arguments, mixed_precision, - allow_growth, is_predict) - self._set_tf_settings(allow_growth, arguments.exclude_gpus) - - use_mixed_precision = not is_predict and mixed_precision and get_backend() == "nvidia" - self._use_mixed_precision = self._set_keras_mixed_precision(use_mixed_precision, - bool(arguments.exclude_gpus)) - - distributed = False if not hasattr(arguments, "distributed") else arguments.distributed - self._strategy = self._get_strategy(distributed) - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def use_strategy(self): - """ bool: ``True`` if a distribution strategy is to be used otherwise ``False``. """ - return self._strategy is not None - - @property - def use_mixed_precision(self): - """ bool: ``True`` if mixed precision training has been enabled, otherwise ``False``. """ - return self._use_mixed_precision - - @classmethod - def _set_tf_settings(cls, allow_growth, exclude_devices): - """ Specify Devices to place operations on and Allow TensorFlow to manage VRAM growth. - - Enables the Tensorflow allow_growth option if requested in the command line arguments - - Parameters - ---------- - allow_growth: bool - ``True`` if the Tensorflow allow_growth parameter should be set otherwise ``False`` - exclude_devices: list or ``None`` - List of GPU device indices that should not be made available to Tensorflow. Pass - ``None`` if all devices should be made available - """ - if get_backend() == "amd": - return # No settings for AMD - if get_backend() == "cpu": - logger.verbose("Hiding GPUs from Tensorflow") - tf.config.set_visible_devices([], "GPU") - return - - if not exclude_devices and not allow_growth: - logger.debug("Not setting any specific Tensorflow settings") - return - - gpus = tf.config.list_physical_devices('GPU') - if exclude_devices: - gpus = [gpu for idx, gpu in enumerate(gpus) if idx not in exclude_devices] - logger.debug("Filtering devices to: %s", gpus) - tf.config.set_visible_devices(gpus, "GPU") - - if allow_growth: - logger.debug("Setting Tensorflow 'allow_growth' option") - for gpu in gpus: - logger.info("Setting allow growth for GPU: %s", gpu) - tf.config.experimental.set_memory_growth(gpu, True) - logger.debug("Set Tensorflow 'allow_growth' option") - - @classmethod - def _set_keras_mixed_precision(cls, use_mixed_precision, exclude_gpus): - """ Enable the Keras experimental Mixed Precision API. - - Enables the Keras experimental Mixed Precision API if requested in the user configuration - file. - - Parameters - ---------- - use_mixed_precision: bool - ``True`` if experimental mixed precision support should be enabled for Nvidia GPUs - otherwise ``False``. - exclude_gpus: bool - ``True`` If connected GPUs are being excluded otherwise ``False``. - """ - logger.debug("use_mixed_precision: %s, exclude_gpus: %s", - use_mixed_precision, exclude_gpus) - if not use_mixed_precision: - logger.debug("Not enabling 'mixed_precision' (backend: %s, use_mixed_precision: %s)", - get_backend(), use_mixed_precision) - return False - logger.info("Enabling Mixed Precision Training.") - - policy = tf.keras.mixed_precision.Policy('mixed_float16') - tf.keras.mixed_precision.set_global_policy(policy) - logger.debug("Enabled mixed precision. (Compute dtype: %s, variable_dtype: %s)", - policy.compute_dtype, policy.variable_dtype) - return True - - @classmethod - def _get_strategy(cls, distributed): - """ If we are running on Nvidia backend and the strategy is not `"default"` then return - the correct tensorflow distribution strategy, otherwise return ``None``. - - Notes - ----- - By default Tensorflow defaults mirrored strategy to use the Nvidia NCCL method for - reductions, however this is only available in Linux, so the method used falls back to - `Hierarchical Copy All Reduce` if the OS is not Linux. - - Parameters - ---------- - distributed: bool - ``True`` if Tensorflow mirrored strategy should be used for multiple GPU training. - ``False`` if the default strategy should be used. - - Returns - ------- - :class:`tensorflow.python.distribute.Strategy` or `None` - The request Tensorflow Strategy if the backend is Nvidia and the strategy is not - `"Default"` otherwise ``None`` - """ - if get_backend() != "nvidia": - retval = None - elif distributed: - if platform.system().lower() == "linux": - cross_device_ops = tf.distribute.NcclAllReduce() - else: - cross_device_ops = tf.distribute.HierarchicalCopyAllReduce() - logger.debug("cross_device_ops: %s", cross_device_ops) - retval = tf.distribute.MirroredStrategy(cross_device_ops=cross_device_ops) - else: - retval = tf.distribute.get_strategy() - logger.debug("Using strategy: %s", retval) - return retval - - def strategy_scope(self): - """ Return the strategy scope if we have set a strategy, otherwise return a null - context. - - Returns - ------- - :func:`tensorflow.python.distribute.Strategy.scope` or :func:`contextlib.nullcontext` - The tensorflow strategy scope if a strategy is valid in the current scenario. A null - context manager if the strategy is not valid in the current scenario - """ - retval = nullcontext() if self._strategy is None else self._strategy.scope() - logger.debug("Using strategy scope: %s", retval) - return retval - - -class _Weights(): - """ Handling of freezing and loading model weights - - Parameters - ---------- - plugin: :class:`Model` - The parent plugin class that owns the IO functions. - """ - def __init__(self, plugin): - logger.debug("Initializing %s: (plugin: %s)", self.__class__.__name__, plugin) - self._model = plugin.model - self._name = plugin.model_name - self._do_freeze = plugin._args.freeze_weights - self._weights_file = self._check_weights_file(plugin._args.load_weights) - - freeze_layers = plugin.config.get("freeze_layers") # Standardized config for freezing - load_layers = plugin.config.get("load_layers") # Standardized config for loading - self._freeze_layers = freeze_layers if freeze_layers else ["encoder"] # No plugin config - self._load_layers = load_layers if load_layers else ["encoder"] # No plugin config - logger.debug("Initialized %s", self.__class__.__name__) - - @classmethod - def _check_weights_file(cls, weights_file): - """ Validate that we have a valid path to a .h5 file. - - Parameters - ---------- - weights_file: str - The full path to a weights file - - Returns - ------- - str - The full path to a weights file - """ - if not weights_file: - logger.debug("No weights file selected.") - return None - - msg = "" - if not os.path.exists(weights_file): - msg = f"Load weights selected, but the path '{weights_file}' does not exist." - elif not os.path.splitext(weights_file)[-1].lower() == ".h5": - msg = (f"Load weights selected, but the path '{weights_file}' is not a valid Keras " - f"model (.h5) file.") - - if msg: - msg += " Please check and try again." - raise FaceswapError(msg) - - logger.verbose("Using weights file: %s", weights_file) - return weights_file - - def freeze(self): - """ If freeze has been selected in the cli arguments, then freeze those models indicated - in the plugin's configuration. """ - # Blanket unfreeze layers, as checking the value of :attr:`layer.trainable` appears to - # return ``True`` even when the weights have been frozen - for layer in _get_all_sub_models(self._model): - layer.trainable = True - - if not self._do_freeze: - logger.debug("Freeze weights deselected. Not freezing") - return - - for layer in _get_all_sub_models(self._model): - if layer.name in self._freeze_layers: - logger.info("Freezing weights for '%s' in model '%s'", layer.name, self._name) - layer.trainable = False - self._freeze_layers.remove(layer.name) - if self._freeze_layers: - logger.warning("The following layers were set to be frozen but do not exist in the " - "model: %s", self._freeze_layers) - - def load(self, model_exists): - """ Load weights for newly created models, or output warning for pre-existing models. - - Parameters - ---------- - model_exists: bool - ``True`` if a model pre-exists and is being resumed, ``False`` if this is a new model - """ - if not self._weights_file: - logger.debug("No weights file provided. Not loading weights.") - return - if model_exists and self._weights_file: - logger.warning("Ignoring weights file '%s' as this model is resuming.", - self._weights_file) - return - - weights_models = self._get_weights_model() - all_models = _get_all_sub_models(self._model) - - for model_name in self._load_layers: - sub_model = next((lyr for lyr in all_models if lyr.name == model_name), None) - sub_weights = next((lyr for lyr in weights_models if lyr.name == model_name), None) - - if not sub_model or not sub_weights: - msg = f"Skipping layer {model_name} as not in " - msg += "current_model." if not sub_model else f"weights '{self._weights_file}.'" - logger.warning(msg) - continue - - logger.info("Loading weights for layer '%s'", model_name) - skipped_ops = 0 - loaded_ops = 0 - for layer in sub_model.layers: - success = self._load_layer_weights(layer, sub_weights, model_name) - if success == 0: - skipped_ops += 1 - elif success == 1: - loaded_ops += 1 - - del weights_models - - if loaded_ops == 0: - raise FaceswapError(f"No weights were succesfully loaded from your weights file: " - f"'{self._weights_file}'. Please check and try again.") - if skipped_ops > 0: - logger.warning("%s weight(s) were unable to be loaded for your model. This is most " - "likely because the weights you are trying to load were trained with " - "different settings than you have set for your current model.", - skipped_ops) - - def _get_weights_model(self): - """ Obtain a list of all sub-models contained within the weights model. - - Returns - ------- - list - List of all models contained within the .h5 file - - Raises - ------ - FaceswapError - In the event of a failure to load the weights, or the weights belonging to a different - model - """ - retval = _get_all_sub_models(load_model(self._weights_file, compile=False)) - if not retval: - raise FaceswapError(f"Error loading weights file {self._weights_file}.") - - if retval[0].name != self._name: - raise FaceswapError(f"You are attempting to load weights from a '{retval[0].name}' " - f"model into a '{self._name}' model. This is not supported.") - return retval - - def _load_layer_weights(self, layer, sub_weights, model_name): - """ Load the weights for a single layer. - - Parameters - ---------- - layer: :class:`keras.layers.Layer` - The layer to set the weights for - sub_weights: list - The list of layers in the weights model to load weights from - model_name: str - The name of the current sub-model that is having it's weights loaded - - Returns - ------- - int - `-1` if the layer has no weights to load. `0` if weights loading was unsuccessful. `1` - if weights loading was successful - """ - old_weights = layer.get_weights() - if not old_weights: - logger.debug("Skipping layer without weights: %s", layer.name) - return -1 - - layer_weights = next((lyr for lyr in sub_weights.layers if lyr.name == layer.name), None) - if not layer_weights: - logger.warning("The weights file '%s' for layer '%s' does not contain weights for " - "'%s'. Skipping", self._weights_file, model_name, layer.name) - return 0 - - new_weights = layer_weights.get_weights() - if old_weights[0].shape != new_weights[0].shape: - logger.warning("The weights for layer '%s' are of incompatible shapes. Skipping.", - layer.name) - return 0 - logger.verbose("Setting weights for '%s'", layer.name) - layer.set_weights(layer_weights.get_weights()) - return 1 - - -class _Optimizer(): # pylint:disable=too-few-public-methods - """ Obtain the selected optimizer with the appropriate keyword arguments. - - Parameters - ---------- - optimizer: str - The selected optimizer name for the plugin - learning_rate: float - The selected learning rate to use - clipnorm: bool - Whether to clip gradients to avoid exploding/vanishing gradients - epsilon: float - The value to use for the epsilon of the optimizer - arguments: :class:`argparse.Namespace` - The arguments that were passed to the train or convert process as generated from - Faceswap's command line arguments - """ - def __init__(self, optimizer, learning_rate, clipnorm, epsilon, arguments): - logger.debug("Initializing %s: (optimizer: %s, learning_rate: %s, clipnorm: %s, " - "epsilon: %s, arguments: %s)", self.__class__.__name__, - optimizer, learning_rate, clipnorm, epsilon, arguments) - valid_optimizers = {"adabelief": (optimizers.AdaBelief, - dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), - "adam": (optimizers.Adam, - dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), - "nadam": (optimizers.Nadam, - dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), - "rms-prop": (optimizers.RMSprop, dict(epsilon=epsilon))} - self._optimizer, self._kwargs = valid_optimizers[optimizer] - - self._configure(learning_rate, clipnorm, arguments) - logger.verbose("Using %s optimizer", optimizer.title()) - logger.debug("Initialized: %s", self.__class__.__name__) - - @property - def optimizer(self): - """ :class:`keras.optimizers.Optimizer`: The requested optimizer. """ - return self._optimizer(**self._kwargs) - - def _configure(self, learning_rate, clipnorm, arguments): - """ Configure the optimizer based on user settings. - - Parameters - ---------- - learning_rate: float - The selected learning rate to use - clipnorm: bool - Whether to clip gradients to avoid exploding/vanishing gradients - arguments: :class:`argparse.Namespace` - The arguments that were passed to the train or convert process as generated from - Faceswap's command line arguments - - Notes - ----- - Clip-norm is ballooning VRAM usage, which is not expected behavior and may be a bug in - Keras/Tensorflow. - - PlaidML has a bug regarding the clip-norm parameter See: - https://github.com/plaidml/plaidml/issues/228. We workaround by simply not adding this - parameter for AMD backend users. - """ - lr_key = "lr" if get_backend() == "amd" else "learning_rate" - self._kwargs[lr_key] = learning_rate - - if clipnorm and (arguments.distributed or _CONFIG["mixed_precision"]): - logger.warning("Clipnorm has been selected, but is unsupported when using distributed " - "or mixed_precision training, so has been disabled. If you wish to " - "enable clipnorm, then you must disable these options.") - clipnorm = False - if clipnorm and get_backend() == "amd": - # TODO add clipnorm in for plaidML when it is fixed upstream. Still not fixed in - # release 0.7.0. - logger.warning("Due to a bug in plaidML, clipnorm cannot be used on AMD backends so " - "has been disabled") - clipnorm = False - if clipnorm: - self._kwargs["clipnorm"] = 1.0 - - logger.debug("optimizer kwargs: %s", self._kwargs) - - -class _Loss(): - """ Holds loss names and functions for an Autoencoder. """ - def __init__(self): - logger.debug("Initializing %s", self.__class__.__name__) - self._loss_dict = dict(mae=k_losses.mean_absolute_error, - mse=k_losses.mean_squared_error, - logcosh=k_losses.logcosh, - smooth_loss=losses.GeneralizedLoss(), - l_inf_norm=losses.LInfNorm(), - ssim=losses.DSSIMObjective(), - ms_ssim=losses.MSSSIMLoss(), - gmsd=losses.GMSDLoss(), - pixel_gradient_diff=losses.GradientLoss()) - self._uses_l2_reg = ["ssim", "ms_ssim", "gmsd"] - self._inputs = None - self._names = [] - self._funcs = {} - logger.debug("Initialized: %s", self.__class__.__name__) - - @property - def names(self): - """ list: The list of loss names for the model. """ - return self._names - - @property - def functions(self): - """ dict: The loss functions that apply to each model output. """ - return self._funcs - - @property - def _config(self): - """ :dict: The configuration options for this plugin """ - return _CONFIG - - @property - def _mask_inputs(self): - """ list: The list of input tensors to the model that contain the mask. Returns ``None`` - if there is no mask input to the model. """ - mask_inputs = [inp for inp in self._inputs if inp.name.startswith("mask")] - return None if not mask_inputs else mask_inputs - - @property - def _mask_shapes(self): - """ list: The list of shape tuples for the mask input tensors for the model. Returns - ``None`` if there is no mask input. """ - if self._mask_inputs is None: - return None - return [K.int_shape(mask_input) for mask_input in self._mask_inputs] - - def configure(self, model): - """ Configure the loss functions for the given inputs and outputs. - - Parameters - ---------- - model: :class:`keras.models.Model` - The model that is to be trained - """ - self._inputs = model.inputs - self._set_loss_names(model.outputs) - self._set_loss_functions(model.output_names) - self._names.insert(0, "total") - - def _set_loss_names(self, outputs): - """ Name the losses based on model output. - - This is used for correct naming in the state file, for display purposes only. - - Adds the loss names to :attr:`names` - - Notes - ----- - TODO Currently there is an issue in Tensorflow that wraps all outputs in an Identity layer - when running in Eager Execution mode, which means we cannot use the name of the output - layers to name the losses (https://github.com/tensorflow/tensorflow/issues/32180). - With this in mind, losses are named based on their shapes - - Parameters - ---------- - outputs: list - A list of output tensors from the model plugin - """ - # TODO Use output names if/when these are fixed upstream - split_outputs = [outputs[:len(outputs) // 2], outputs[len(outputs) // 2:]] - for side, side_output in zip(("a", "b"), split_outputs): - output_names = [output.name for output in side_output] - output_shapes = [K.int_shape(output)[1:] for output in side_output] - output_types = ["mask" if shape[-1] == 1 else "face" for shape in output_shapes] - logger.debug("side: %s, output names: %s, output_shapes: %s, output_types: %s", - side, output_names, output_shapes, output_types) - for idx, name in enumerate(output_types): - suffix = "" if output_types.count(name) == 1 else f"_{idx}" - self._names.append(f"{name}_{side}{suffix}") - logger.debug(self._names) - - def _set_loss_functions(self, output_names): - """ Set the loss functions and their associated weights. - - Adds the loss functions to the :attr:`functions` dictionary. - - Parameters - ---------- - output_names: list - The output names from the model - """ - mask_channels = self._get_mask_channels() - face_loss = self._loss_dict[self._config["loss_function"]] - - for name, output_name in zip(self._names, output_names): - if name.startswith("mask"): - loss_func = self._loss_dict[self._config["mask_loss_function"]] - else: - loss_func = losses.LossWrapper() - loss_func.add_loss(face_loss, mask_channel=mask_channels[0]) - self._add_l2_regularization_term(loss_func, mask_channels[0]) - - channel_idx = 1 - for multiplier in ("eye_multiplier", "mouth_multiplier"): - mask_channel = mask_channels[channel_idx] - if self._config[multiplier] > 1: - loss_func.add_loss(face_loss, - weight=self._config[multiplier] * 1.0, - mask_channel=mask_channel) - self._add_l2_regularization_term(loss_func, mask_channel) - channel_idx += 1 - - logger.debug("%s: (output_name: '%s', function: %s)", name, output_name, loss_func) - self._funcs[output_name] = loss_func - logger.debug("functions: %s", self._funcs) - - def _add_l2_regularization_term(self, loss_wrapper, mask_channel): - """ Check if an L2 Regularization term should be added and add to the loss function - wrapper. - - Parameters - ---------- - loss_wrapper: :class:`lib.model.losses.LossWrapper` - The wrapper loss function that holds the face losses - mask_channel: int - The channel that holds the mask in `y_true`, if a mask is used for the loss. - `-1` if the input is not masked - """ - if self._config["loss_function"] in self._uses_l2_reg and self._config["l2_reg_term"] > 0: - logger.debug("Adding L2 Regularization for Structural Loss") - loss_wrapper.add_loss(self._loss_dict["mse"], - weight=self._config["l2_reg_term"] / 100.0, - mask_channel=mask_channel) - - def _get_mask_channels(self): - """ Obtain the channels from the face targets that the masks reside in from the training - data generator. - - Returns - ------- - list: - A list of channel indices that contain the mask for the corresponding config item - """ - eye_multiplier = self._config["eye_multiplier"] - mouth_multiplier = self._config["mouth_multiplier"] - if not self._config["penalized_mask_loss"] and (eye_multiplier > 1 or - mouth_multiplier > 1): - logger.warning("You have selected eye/mouth loss multipliers greater than 1x, but " - "Penalized Mask Loss is disabled. Disabling all multipliers.") - eye_multiplier = 1 - mouth_multiplier = 1 - uses_masks = (self._config["penalized_mask_loss"], - eye_multiplier > 1, - mouth_multiplier > 1) - mask_channels = [-1 for _ in range(len(uses_masks))] - current_channel = 3 - for idx, mask_required in enumerate(uses_masks): - if mask_required: - mask_channels[idx] = current_channel - current_channel += 1 - logger.debug("uses_masks: %s, mask_channels: %s", uses_masks, mask_channels) - return mask_channels - - -class State(): - """ Holds state information relating to the plugin's saved model. - - Parameters - ---------- - model_dir: str - The full path to the model save location - model_name: str - The name of the model plugin - config_changeable_items: dict - Configuration options that can be altered when resuming a model, and their current values - no_logs: bool - ``True`` if Tensorboard logs should not be generated, otherwise ``False`` - """ - def __init__(self, model_dir, model_name, config_changeable_items, no_logs): - logger.debug("Initializing %s: (model_dir: '%s', model_name: '%s', " - "config_changeable_items: '%s', no_logs: %s", self.__class__.__name__, - model_dir, model_name, config_changeable_items, no_logs) - self._serializer = get_serializer("json") - filename = f"{model_name}_state.{self._serializer.file_extension}" - self._filename = os.path.join(model_dir, filename) - self._name = model_name - self._iterations = 0 - self._sessions = {} - self._lowest_avg_loss = {} - self._config = {} - self._load(config_changeable_items) - self._session_id = self._new_session_id() - self._create_new_session(no_logs, config_changeable_items) - logger.debug("Initialized %s:", self.__class__.__name__) - - @property - def loss_names(self): - """ list: The loss names for the current session """ - return self._sessions[self._session_id]["loss_names"] - - @property - def current_session(self): - """ dict: The state dictionary for the current :attr:`session_id`. """ - return self._sessions[self._session_id] - - @property - def iterations(self): - """ int: The total number of iterations that the model has trained. """ - return self._iterations - - @property - def lowest_avg_loss(self): - """dict: The lowest average save interval loss seen for each side. """ - return self._lowest_avg_loss - - @property - def session_id(self): - """ int: The current training session id. """ - return self._session_id - - def _new_session_id(self): - """ Generate a new session id. Returns 1 if this is a new model, or the last session id + 1 - if it is a pre-existing model. - - Returns - ------- - int - The newly generated session id - """ - if not self._sessions: - session_id = 1 - else: - session_id = max(int(key) for key in self._sessions.keys()) + 1 - logger.debug(session_id) - return session_id - - def _create_new_session(self, no_logs, config_changeable_items): - """ Initialize a new session, creating the dictionary entry for the session in - :attr:`_sessions`. - - Parameters - ---------- - no_logs: bool - ``True`` if Tensorboard logs should not be generated, otherwise ``False`` - config_changeable_items: dict - Configuration options that can be altered when resuming a model, and their current - values - """ - logger.debug("Creating new session. id: %s", self._session_id) - self._sessions[self._session_id] = dict(timestamp=time.time(), - no_logs=no_logs, - loss_names=[], - batchsize=0, - iterations=0, - config=config_changeable_items) - - def add_session_loss_names(self, loss_names): - """ Add the session loss names to the sessions dictionary. - - The loss names are used for Tensorboard logging - - Parameters - ---------- - loss_names: list - The list of loss names for this session. - """ - logger.debug("Adding session loss_names: %s", loss_names) - self._sessions[self._session_id]["loss_names"] = loss_names - - def add_session_batchsize(self, batch_size): - """ Add the session batch size to the sessions dictionary. - - Parameters - ---------- - batch_size: int - The batch size for the current training session - """ - logger.debug("Adding session batch size: %s", batch_size) - self._sessions[self._session_id]["batchsize"] = batch_size - - def increment_iterations(self): - """ Increment :attr:`iterations` and session iterations by 1. """ - self._iterations += 1 - self._sessions[self._session_id]["iterations"] += 1 - - def _load(self, config_changeable_items): - """ Load a state file and set the serialized values to the class instance. - - Updates the model's config with the values stored in the state file. - - Parameters - ---------- - config_changeable_items: dict - Configuration options that can be altered when resuming a model, and their current - values - """ - logger.debug("Loading State") - if not os.path.exists(self._filename): - logger.info("No existing state file found. Generating.") - return - state = self._serializer.load(self._filename) - self._name = state.get("name", self._name) - self._sessions = state.get("sessions", {}) - self._lowest_avg_loss = state.get("lowest_avg_loss", {}) - self._iterations = state.get("iterations", 0) - self._config = state.get("config", {}) - logger.debug("Loaded state: %s", state) - self._replace_config(config_changeable_items) - - def save(self): - """ Save the state values to the serialized state file. """ - logger.debug("Saving State") - state = {"name": self._name, - "sessions": self._sessions, - "lowest_avg_loss": self._lowest_avg_loss, - "iterations": self._iterations, - "config": _CONFIG} - self._serializer.save(self._filename, state) - logger.debug("Saved State") - - def _replace_config(self, config_changeable_items): - """ Replace the loaded config with the one contained within the state file. - - Check for any `fixed`=``False`` parameter changes and log info changes. - - Update any legacy config items to their current versions. - - Parameters - ---------- - config_changeable_items: dict - Configuration options that can be altered when resuming a model, and their current - values - """ - global _CONFIG # pylint: disable=global-statement - legacy_update = self._update_legacy_config() - # Add any new items to state config for legacy purposes and set sensible defaults for - # any values that may have been changed in the config file which could be detrimental. - legacy_defaults = dict(centering="legacy", - mask_loss_function="mse", - l2_reg_term=100, - optimizer="adam", - mixed_precision=False) - for key, val in _CONFIG.items(): - if key not in self._config.keys(): - setting = legacy_defaults.get(key, val) - logger.info("Adding new config item to state file: '%s': '%s'", key, setting) - self._config[key] = setting - self._update_changed_config_items(config_changeable_items) - logger.debug("Replacing config. Old config: %s", _CONFIG) - _CONFIG = self._config - if legacy_update: - self.save() - logger.debug("Replaced config. New config: %s", _CONFIG) - logger.info("Using configuration saved in state file") - - def _update_legacy_config(self): - """ Legacy updates for new config additions. - - When new config items are added to the Faceswap code, existing model state files need to be - updated to handle these new items. - - Current existing legacy update items: - - * loss - If old `dssim_loss` is ``true`` set new `loss_function` to `ssim` otherwise - set it to `mae`. Remove old `dssim_loss` item - - * masks - If `learn_mask` does not exist then it is set to ``True`` if `mask_type` is - not ``None`` otherwise it is set to ``False``. - - * masks type - Replace removed masks 'dfl_full' and 'facehull' with `components` mask - - Returns - ------- - bool - ``True`` if legacy items exist and state file has been updated, otherwise ``False`` - """ - logger.debug("Checking for legacy state file update") - priors = ["dssim_loss", "mask_type", "mask_type"] - new_items = ["loss_function", "learn_mask", "mask_type"] - updated = False - for old, new in zip(priors, new_items): - if old not in self._config: - logger.debug("Legacy item '%s' not in config. Skipping update", old) - continue - - # dssim_loss > loss_function - if old == "dssim_loss": - self._config[new] = "ssim" if self._config[old] else "mae" - del self._config[old] - updated = True - logger.info("Updated config from legacy dssim format. New config loss " - "function: '%s'", self._config[new]) - continue - - # Add learn mask option and set to True if model has "penalized_mask_loss" specified - if old == "mask_type" and new == "learn_mask" and new not in self._config: - self._config[new] = self._config["mask_type"] is not None - updated = True - logger.info("Added new 'learn_mask' config item for this model. Value set to: %s", - self._config[new]) - continue - - # Replace removed masks with most similar equivalent - if old == "mask_type" and new == "mask_type" and self._config[old] in ("facehull", - "dfl_full"): - old_mask = self._config[old] - self._config[new] = "components" - updated = True - logger.info("Updated 'mask_type' from '%s' to '%s' for this model", - old_mask, self._config[new]) - - logger.debug("State file updated for legacy config: %s", updated) - return updated - - def _update_changed_config_items(self, config_changeable_items): - """ Update any parameters which are not fixed and have been changed. - - Parameters - ---------- - config_changeable_items: dict - Configuration options that can be altered when resuming a model, and their current - values - """ - if not config_changeable_items: - logger.debug("No changeable parameters have been updated") - return - for key, val in config_changeable_items.items(): - old_val = self._config[key] - if old_val == val: - continue - self._config[key] = val - logger.info("Config item: '%s' has been updated from '%s' to '%s'", key, old_val, val) - - -class _Inference(): # pylint:disable=too-few-public-methods - """ Calculates required layers and compiles a saved model for inference. - - Parameters - ---------- - saved_model: :class:`keras.models.Model` - The saved trained Faceswap model - switch_sides: bool - ``True`` if the swap should be performed "B" > "A" ``False`` if the swap should be - "A" > "B" - """ - def __init__(self, saved_model, switch_sides): - logger.debug("Initializing: %s (saved_model: %s, switch_sides: %s)", - self.__class__.__name__, saved_model, switch_sides) - self._config = saved_model.get_config() - - self._input_idx = 1 if switch_sides else 0 - self._output_idx = 0 if switch_sides else 1 - - self._input_names = [inp[0] for inp in self._config["input_layers"]] - self._model = self._make_inference_model(saved_model) - logger.debug("Initialized: %s", self.__class__.__name__) - - @property - def model(self): - """ :class:`keras.models.Model`: The Faceswap model, compiled for inference. """ - return self._model - - def _get_nodes(self, nodes): - """ Given in input list of nodes from a :attr:`keras.models.Model.get_config` dictionary, - filters the layer name(s) and output index of the node, splitting to the correct output - index in the event of multiple inputs. - - Parameters - ---------- - nodes: list - A node entry from the :attr:`keras.models.Model.get_config` dictionary - - Returns - ------- - list - The (node name, output index) for each node passed in - """ - nodes = np.array(nodes, dtype="object")[..., :3] - num_layers = nodes.shape[0] - nodes = nodes[self._output_idx] if num_layers == 2 else nodes[0] - retval = [(node[0], node[2]) for node in nodes] - return retval - - def _make_inference_model(self, saved_model): - """ Extract the sub-models from the saved model that are required for inference. - - Parameters - ---------- - saved_model: :class:`keras.models.Model` - The saved trained Faceswap model - - Returns - ------- - :class:`keras.models.Model` - The model compiled for inference - """ - logger.debug("Compiling inference model. saved_model: %s", saved_model) - struct = self._get_filtered_structure() - model_inputs = self._get_inputs(saved_model.inputs) - compiled_layers = {} - for layer in saved_model.layers: - if layer.name not in struct: - logger.debug("Skipping unused layer: '%s'", layer.name) - continue - inbound = struct[layer.name] - logger.debug("Processing layer '%s': (layer: %s, inbound_nodes: %s)", - layer.name, layer, inbound) - if not inbound: - model = model_inputs - logger.debug("Adding model inputs %s: %s", layer.name, model) - else: - layer_inputs = [] - for inp in inbound: - inbound_layer = compiled_layers[inp[0]] - if isinstance(inbound_layer, list) and len(inbound_layer) > 1: - # Multi output inputs - inbound_output_idx = inp[1] - next_input = inbound_layer[inbound_output_idx] - logger.debug("Selecting output index %s from multi output inbound layer: " - "%s (using: %s)", inbound_output_idx, inbound_layer, - next_input) - else: - next_input = inbound_layer - - if get_backend() == "amd" and isinstance(next_input, list): - # tensorflow.keras and keras 2.2 behave differently for layer inputs - layer_inputs.extend(next_input) - else: - layer_inputs.append(next_input) - - logger.debug("Compiling layer '%s': layer inputs: %s", layer.name, layer_inputs) - model = layer(layer_inputs) - compiled_layers[layer.name] = model - retval = KerasModel(model_inputs, model, name=f"{saved_model.name}_inference") - logger.debug("Compiled inference model '%s': %s", retval.name, retval) - return retval - - def _get_filtered_structure(self): - """ Obtain the structure of the inference model. - - This parses the model config (in reverse) to obtain the required layers for an inference - model. - - Returns - ------- - :class:`collections.OrderedDict` - The layer name as key with the input name and output index as value. - """ - # Filter output layer - out = np.array(self._config["output_layers"], dtype="object") - if out.ndim == 2: - out = np.expand_dims(out, axis=1) # Needs to be expanded for _get_nodes - outputs = self._get_nodes(out) - - # Iterate backwards from the required output to get the reversed model structure - current_layers = [outputs[0]] - next_layers = [] - struct = OrderedDict() - drop_input = self._input_names[abs(self._input_idx - 1)] - switch_input = self._input_names[self._input_idx] - while True: - layer_info = current_layers.pop(0) - current_layer = next(lyr for lyr in self._config["layers"] - if lyr["name"] == layer_info[0]) - inbound = current_layer["inbound_nodes"] - - if not inbound: - break - - inbound_info = self._get_nodes(inbound) - - if any(inb[0] == drop_input for inb in inbound_info): # Switch inputs - inbound_info = [(switch_input if inb[0] == drop_input else inb[0], inb[1]) - for inb in inbound_info] - struct[layer_info[0]] = inbound_info - next_layers.extend(inbound_info) - - if not current_layers: - current_layers = next_layers - next_layers = [] - - struct[switch_input] = [] # Add the input layer - logger.debug("Model structure: %s", struct) - return struct - - def _get_inputs(self, inputs): - """ Obtain the inputs for the requested swap direction. - - Parameters - ---------- - inputs: list - The full list of input tensors to the saved faceswap training model - - Returns - ------- - list - List of input tensors to feed the model for the requested swap direction - """ - input_split = len(inputs) // 2 - start_idx = input_split * self._input_idx - retval = inputs[start_idx: start_idx + input_split] - logger.debug("model inputs: %s, input_split: %s, start_idx: %s, inference_inputs: %s", - inputs, input_split, start_idx, retval) - return retval diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 4d7d4984a0..81933c4d14 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -38,7 +38,7 @@ import argparse logger = logging.getLogger(__name__) # pylint: disable=invalid-name -_CONFIG = None +_CONFIG: Dict[str, Union[str, float, int, bool, None]] = {} def KerasModel(inputs: list, outputs: list, name: str) -> keras.models.Model: # noqa, pylint:disable=invalid-name @@ -285,6 +285,10 @@ def build(self) -> None: else: self._validate_input_shape() inputs = self._get_inputs() + if not self._settings.use_mixed_precision: + # Store layer names which can be switched to mixed precision + self._state.add_mixed_precision_layers( + self._settings.get_mixed_precision_layers(self.build_model, inputs)) self._model = self.build_model(inputs) if not is_summary and not self._is_predict: self._compile_model() @@ -418,6 +422,9 @@ def _compile_model(self) -> None: """ Compile the model to include the Optimizer and Loss Function(s). """ logger.debug("Compiling Model") + if self.state.model_needs_rebuild: + self._model = self._settings.check_model_precision(self._model, self._state) + optimizer = Optimizer(self.config["optimizer"], self.config["learning_rate"], self.config.get("clipnorm", False), @@ -515,9 +522,11 @@ def __init__(self, self._filename = os.path.join(model_dir, filename) self._name = model_name self._iterations = 0 + self._mixed_precision_layers: List[str] = [] + self._rebuild_model = False self._sessions: Dict[int, dict] = {} self._lowest_avg_loss: Dict[str, float] = {} - self._config = {} + self._config: Dict[str, Union[str, float, int, bool, None]] = {} self._load(config_changeable_items) self._session_id = self._new_session_id() self._create_new_session(no_logs, config_changeable_items) @@ -548,6 +557,17 @@ def session_id(self) -> int: """ int: The current training session id. """ return self._session_id + @property + def mixed_precision_layers(self) -> List[str]: + """list: Layers that can be switched between mixed-float16 and float32. """ + return self._mixed_precision_layers + + @property + def model_needs_rebuild(self) -> bool: + """bool: ``True`` if mixed precision policy has changed so model needs to be rebuilt + otherwise ``False`` """ + return self._rebuild_model + def _new_session_id(self) -> int: """ Generate a new session id. Returns 1 if this is a new model, or the last session id + 1 if it is a pre-existing model. @@ -613,6 +633,12 @@ def increment_iterations(self) -> None: self._iterations += 1 self._sessions[self._session_id]["iterations"] += 1 + def add_mixed_precision_layers(self, layers: List[str]) -> None: + """ Add the list of model's layers that are compatible for mixed precision to the + state dictionary """ + logger.debug("Storing mixed precision layers: %s", layers) + self._mixed_precision_layers = layers + def _load(self, config_changeable_items: dict) -> None: """ Load a state file and set the serialized values to the class instance. @@ -633,6 +659,7 @@ def _load(self, config_changeable_items: dict) -> None: self._sessions = state.get("sessions", {}) self._lowest_avg_loss = state.get("lowest_avg_loss", {}) self._iterations = state.get("iterations", 0) + self._mixed_precision_layers = state.get("mixed_precision_layers", []) self._config = state.get("config", {}) logger.debug("Loaded state: %s", state) self._replace_config(config_changeable_items) @@ -644,6 +671,7 @@ def save(self) -> None: "sessions": self._sessions, "lowest_avg_loss": self._lowest_avg_loss, "iterations": self._iterations, + "mixed_precision_layers": self._mixed_precision_layers, "config": _CONFIG} self._serializer.save(self._filename, state) logger.debug("Saved State") @@ -759,12 +787,15 @@ def _update_legacy_config(self) -> bool: def _update_changed_config_items(self, config_changeable_items: dict) -> None: """ Update any parameters which are not fixed and have been changed. + Set the :attr:`model_needs_rebuild` to ``True`` if mixed precision state has changed + Parameters ---------- config_changeable_items: dict Configuration options that can be altered when resuming a model, and their current values """ + rebuild_tasks = ["mixed_precision"] if not config_changeable_items: logger.debug("No changeable parameters have been updated") return @@ -774,6 +805,7 @@ def _update_changed_config_items(self, config_changeable_items: dict) -> None: continue self._config[key] = val logger.info("Config item: '%s' has been updated from '%s' to '%s'", key, old_val, val) + self._rebuild_model = not self._rebuild_model and key in rebuild_tasks class _Inference(): # pylint:disable=too-few-public-methods diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index c124978b1a..3b66fc0e8c 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -39,6 +39,7 @@ if TYPE_CHECKING: from argparse import Namespace + from .model import State logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -413,8 +414,9 @@ def __init__(self, self._set_tf_settings(allow_growth, arguments.exclude_gpus) use_mixed_precision = not is_predict and mixed_precision and get_backend() == "nvidia" - self._use_mixed_precision = self._set_keras_mixed_precision(use_mixed_precision, - bool(arguments.exclude_gpus)) + self._use_mixed_precision = self._set_keras_mixed_precision(use_mixed_precision) + if self._use_mixed_precision: + logger.info("Enabling Mixed Precision Training.") distributed = False if not hasattr(arguments, "distributed") else arguments.distributed self._strategy = self._get_strategy(distributed) @@ -487,7 +489,7 @@ def _set_tf_settings(cls, allow_growth: bool, exclude_devices: List[int]) -> Non logger.debug("Set Tensorflow 'allow_growth' option") @classmethod - def _set_keras_mixed_precision(cls, use_mixed_precision: bool, exclude_gpus: bool) -> bool: + def _set_keras_mixed_precision(cls, use_mixed_precision: bool) -> bool: """ Enable the Keras experimental Mixed Precision API. Enables the Keras experimental Mixed Precision API if requested in the user configuration @@ -498,21 +500,24 @@ def _set_keras_mixed_precision(cls, use_mixed_precision: bool, exclude_gpus: boo use_mixed_precision: bool ``True`` if experimental mixed precision support should be enabled for Nvidia GPUs otherwise ``False``. - exclude_gpus: bool - ``True`` If connected GPUs are being excluded otherwise ``False``. Returns ------- bool ``True`` if mixed precision has been enabled otherwise ``False`` """ - logger.debug("use_mixed_precision: %s, exclude_gpus: %s", - use_mixed_precision, exclude_gpus) - if not use_mixed_precision: + logger.debug("use_mixed_precision: %s", use_mixed_precision) + if not use_mixed_precision and get_backend() == "amd": logger.debug("Not enabling 'mixed_precision' (backend: %s, use_mixed_precision: %s)", get_backend(), use_mixed_precision) return False - logger.info("Enabling Mixed Precision Training.") + + if not use_mixed_precision: + policy = mixedprecision.Policy('float32') + mixedprecision.set_global_policy(policy) + logger.debug("Disabling mixed precision. (Compute dtype: %s, variable_dtype: %s)", + policy.compute_dtype, policy.variable_dtype) + return False policy = mixedprecision.Policy('mixed_float16') mixedprecision.set_global_policy(policy) @@ -557,6 +562,139 @@ def _get_strategy(cls, distributed: bool) -> Optional[tf.distribute.Strategy]: logger.debug("Using strategy: %s", retval) return retval + def _get_mixed_precision_layers(self, layers: List[dict]) -> List[str]: + """ Obtain the names of the layers in a mixed precision model that have their dtype policy + explicitly set to mixed-float16. + + Parameters + ---------- + layers: List + The list of layers that appear in a keras's model configuration `dict` + + Returns + ------- + list + A list of layer names within the model that are assigned a float16 policy + """ + retval = [] + for layer in layers: + config = layer["config"] + + if layer["class_name"] == "Functional": # Recurse into sub-models + retval.extend(self._get_mixed_precision_layers(config["layers"])) + continue + + dtype = config["dtype"] + if isinstance(dtype, dict) and dtype["config"]["name"] == "mixed_float16": + logger.debug("Adding supported mixed precision layer: %s %s", layer["name"], dtype) + retval.append(layer["name"]) + else: + logger.debug("Skipping unsupported layer: %s %s", layer["name"], dtype) + return retval + + def _switch_precision(self, layers: List[dict], compatible: List[str]) -> None: + """ Switch a model's datatype between mixed-float16 and float32. + + Parameters + ---------- + layers: List + The list of layers that appear in a keras's model configuration `dict` + compatible: List + A list of layer names that are compatible to have their datatype switched + """ + dtype = "mixed_float16" if self.use_mixed_precision else "float32" + policy = dict(class_name="Policy", config=dict(name=dtype)) + + for layer in layers: + config = layer["config"] + + if layer["class_name"] == "Functional": # Recurse into sub-models + self._switch_precision(config["layers"], compatible) + continue + + if layer["name"] not in compatible: + logger.debug("Skipping incompatible layer: %s", layer["name"]) + continue + + logger.debug("Updating dtype for %s from: %s to: %s", + layer["name"], config["dtype"], policy) + config["dtype"] = policy + + def get_mixed_precision_layers(self, + build_func: Callable[[List[keras.layers.Layer]], + keras.models.Model], + inputs: List[keras.layers.Layer]) -> List[str]: + """ Get and store the mixed precision layers from a full precision enabled model. + + Parameters + ---------- + build_func: Callable + The function to be called to compile the newly created model + inputs: + The inputs to the model to be compiled + + Returns + ------- + list + The list of layer names within the full precision model that can be switched + to mixed precision + """ + logger.info("Storing Mixed Precision compatible layers. Please ignore any following " + "warnings about using mixed precision.") + self._set_keras_mixed_precision(True) + model = build_func(inputs) + layers = self._get_mixed_precision_layers(model.get_config()["layers"]) + self._set_keras_mixed_precision(False) + del model + return layers + + def check_model_precision(self, + model: keras.models.Model, + state: "State") -> keras.models.Model: + """ Check the model's precision. + + If this is a new model, then + Rewrite an existing model's training precsion mode from mixed-float16 to float32 or + vice versa. + + This is not easy to do in keras, so we edit the model's config to change the dtype policy + for compatible layers. Create a new model from this config, then port the weights from the + old model to the new model. + + Parameters + ---------- + model: :class:`keras.models.Model` + The original saved keras model to rewrite the dtype + state: ~:class:`plugins.train.model._base.model.State` + The State information for the model + + Returns + ------- + :class:`keras.models.Model` + The original model with the datatype updated + """ + config = model.get_config() + if not self.use_mixed_precision and not state.mixed_precision_layers: + # Switched to Full Precision, get compatible layers from model if not already stored + state.add_mixed_precision_layers(self._get_mixed_precision_layers(config["layers"])) + + if self.use_mixed_precision and not state.mixed_precision_layers: + # Switching to mixed precision on a model which was started in FP32 prior to the + # ability to switch between precisions on a saved model is not supported as we + # do not have the compatible layer names + logger.warning("Switching from Full Precision to Mixed Precision is not supported on " + "older model files. Reverting to Full Precision.") + return model + + self._switch_precision(config["layers"], state.mixed_precision_layers) + + new_model = keras.models.Model().from_config(config) + new_model.set_weights(model.get_weights()) + logger.info("Mixed precision has been updated from '%s' to '%s'", + not self.use_mixed_precision, self.use_mixed_precision) + del model + return new_model + def strategy_scope(self) -> ContextManager: """ Return the strategy scope if we have set a strategy, otherwise return a null context. From 4d42591ffca0b54f1dfa764db7e7b6957568af57 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 22 Jun 2022 12:49:07 +0100 Subject: [PATCH 642/981] Mixed Precision bugfix for AMD --- plugins/train/model/_base/settings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 3b66fc0e8c..2c6163bbe8 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -641,6 +641,9 @@ def get_mixed_precision_layers(self, """ logger.info("Storing Mixed Precision compatible layers. Please ignore any following " "warnings about using mixed precision.") + if get_backend() == "amd": + logger.debug("Mixed Precision not supported for AMD. Returning empty list") + return [] self._set_keras_mixed_precision(True) model = build_func(inputs) layers = self._get_mixed_precision_layers(model.get_config()["layers"]) From 66c2b7b9792b6454f19f7bf2017ec5d3cff4b8b0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 22 Jun 2022 19:20:51 +0100 Subject: [PATCH 643/981] Bugfix: Stats elapsed time --- lib/gui/analysis/stats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index 1d80807ea3..b9c8612ae4 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -393,7 +393,7 @@ def _collate_stats(self, session_id: int) -> dict: timestamps = self._time_stats[session_id] start = np.nan_to_num(timestamps["start_time"]) end = np.nan_to_num(timestamps["end_time"]) - elapsed = int(start - end) + elapsed = int(end - start) batchsize = self._session.batch_sizes.get(session_id, 0) retval = dict( session=session_id, From 06468c97d475c0125375e77aad3f4fc1a87e8fe6 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 23 Jun 2022 14:54:09 +0100 Subject: [PATCH 644/981] Training: Add setting option to save optimizer weights --- lib/model/loss/loss_tf.py | 5 +- plugins/train/_config.py | 23 ++++ plugins/train/model/_base/io.py | 23 +++- plugins/train/model/_base/model.py | 12 +- scripts/train.py | 171 ++++++++++++++++++----------- 5 files changed, 161 insertions(+), 73 deletions(-) diff --git a/lib/model/loss/loss_tf.py b/lib/model/loss/loss_tf.py index 6038f89290..cacb3ff1fc 100644 --- a/lib/model/loss/loss_tf.py +++ b/lib/model/loss/loss_tf.py @@ -883,7 +883,7 @@ def _get_smallest_size(self, size: int, idx: int) -> int: return size -class LossWrapper(): +class LossWrapper(tf.keras.losses.Loss): """ A wrapper class for multiple keras losses to enable multiple masked weighted loss functions on a single output. @@ -905,6 +905,7 @@ class LossWrapper(): """ def __init__(self) -> None: logger.debug("Initializing: %s", self.__class__.__name__) + super().__init__(name="LossWrapper") self._loss_functions: List[compile_utils.LossesContainer] = [] self._loss_weights: List[float] = [] self._mask_channels: List[int] = [] @@ -933,7 +934,7 @@ def add_loss(self, self._loss_weights.append(weight) self._mask_channels.append(mask_channel) - def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + def call(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: """ Call the sub loss functions for the loss wrapper. Loss is returned as the weighted sum of the chosen losses. diff --git a/plugins/train/_config.py b/plugins/train/_config.py index a2903b021b..fdc430e568 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -228,6 +228,29 @@ def _set_globals(self) -> None: "NB: The value given here is the 'exponent' to the epsilon. For example, " "choosing '-7' will set the epsilon to 1e-7. Choosing '-3' will set the epsilon " "to 0.001 (1e-3).") + self.add_item( + section=section, + title="save_optimizer", + datatype=str, + group="optimizer", + default="exit", + fixed=False, + gui_radio=True, + choices=["never", "always", "exit"], + info="When to save the Optimizer Weights. Saving the optimizer weights is not " + "necessary and will increase the model file size 3x (and by extension the amount " + "of time it takes to save the model). However, it can be useful to save these " + "weights if you want to guarantee that a resumed model carries off exactly from " + "where it left off, rather than spending a few hundred iterations catching up." + "\n\t never - Don't save optimizer weights." + "\n\t always - Save the optimizer weights at every save iteration. Model saving " + "will take longer, due to the increased file size, but you will always have the " + "last saved optimizer state in your model file." + "\n\t exit - Only save the optimizer weights when explicitly terminating a " + "model. This can be when the model is actively stopped or when the target " + "iterations are met. Note: If the training session ends because of another " + "reason (e.g. power outage, Out of Memory Error, NaN detected) then the " + "optimizer weights will NOT be saved.") self.add_item( section=section, title="reflect_padding", diff --git a/plugins/train/model/_base/io.py b/plugins/train/model/_base/io.py index 3fb63476de..fb483a70ae 100644 --- a/plugins/train/model/_base/io.py +++ b/plugins/train/model/_base/io.py @@ -80,11 +80,20 @@ class IO(): is_predict: bool ``True`` if the model is being loaded for inference. ``False`` if the model is being loaded for training. + save_optimizer: ["never", "always", "exit"] + When to save the optimizer weights. `"never"` never saves the optimizer weights. `"always"` + always saves the optimizer weights. `"exit"` only saves the optimizer weights on an exit + request. """ - def __init__(self, plugin: "ModelBase", model_dir: str, is_predict: bool) -> None: + def __init__(self, + plugin: "ModelBase", + model_dir: str, + is_predict: bool, + save_optimizer: Literal["never", "always", "exit"]) -> None: self._plugin = plugin self._is_predict = is_predict self._model_dir = model_dir + self._save_optimizer = save_optimizer self._history: List[List[float]] = [[], []] # Loss histories per save iteration self._backup = Backup(self._model_dir, self._plugin.name) @@ -163,9 +172,15 @@ def _load(self) -> keras.models.Model: logger.info("Loaded model from disk: '%s'", self._filename) return model - def save(self) -> None: + def save(self, is_exit: bool = False) -> None: """ Backup and save the model and state file. + Parameters + ---------- + is_exit: bool, optional + ``True`` if the save request has come from an exit process request otherwise ``False`` + Default: ``False`` + Notes ----- The backup function actually backups the model from the previous save iteration rather than @@ -181,7 +196,9 @@ def save(self) -> None: # pylint:disable=protected-access self._backup.backup_model(self._plugin.state._filename) - self._plugin.model.save(self._filename, include_optimizer=False) + include_optimizer = self._save_optimizer == "always" or (self._save_optimizer == "exit" + and is_exit) + self._plugin.model.save(self._filename, include_optimizer=include_optimizer) self._plugin.state.save() msg = "[Saved models]" diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 81933c4d14..c671e22c38 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -124,7 +124,7 @@ def __init__(self, "Mask to use. Please select a mask or disable Penalized Mask " "Loss.") - self._io = IO(self, model_dir, self._is_predict) + self._io = IO(self, model_dir, self._is_predict, self.config["save_optimizer"]) self._check_multiple_models() self._state = State(model_dir, @@ -404,14 +404,20 @@ def _output_summary(self) -> None: model.summary(line_length=100, print_fn=print_fn) parent.summary(line_length=100, print_fn=print_fn) - def save(self) -> None: + def save(self, is_exit: bool = False) -> None: """ Save the model to disk. Saves the serialized model, with weights, to the folder location specified when initializing the plugin. If loss has dropped on both sides of the model, then a backup is taken. + + Parameters + ---------- + is_exit: bool, optional + ``True`` if the save request has come from an exit process request otherwise ``False`` + Default: ``False`` """ - self._io.save() # pylint:disable=protected-access + self._io.save(is_exit=is_exit) def snapshot(self) -> None: """ Creates a snapshot of the model folder to the models parent folder, with the number diff --git a/scripts/train.py b/scripts/train.py index 0ddc594808..a39ea9a2b4 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -243,7 +243,7 @@ def _training(self): except KeyboardInterrupt: try: logger.debug("Keyboard Interrupt Caught. Saving Weights and exiting") - model.save() + model.save(on_exit=True) trainer.clear_tensorboard() except KeyboardInterrupt: logger.info("Saving model weights has been cancelled!") @@ -337,24 +337,18 @@ def _run_training_cycle(self, model, trainer): if save_iteration: logger.debug("Save Iteration: (iteration: %s", iteration) - model.save() + model.save(is_exit=False) elif self._save_now: logger.debug("Save Requested: (iteration: %s", iteration) - model.save() + model.save(is_exit=False) self._save_now = False logger.debug("Training cycle complete") - model.save() + model.save(is_exit=True) trainer.clear_tensorboard() self._stop = True - def _monitor(self, thread): - """ Monitor the background :func:`_training` thread for key presses and errors. - - Returns - ------- - bool - ``True`` if there has been an error in the background thread otherwise ``False`` - """ + def _output_startup_info(self): + """ Print the startup information to the console. """ logger.debug("Launching Monitor") logger.info("===================================================") logger.info(" Starting") @@ -367,56 +361,6 @@ def _monitor(self, thread): logger.info(" Press 'S' to save model weights immediately") logger.info("===================================================") - keypress = KBHit(is_gui=self._args.redirect_gui) - window_created = False - err = False - while True: - try: - if self._args.preview: - with self._lock: - for name, image in self._preview_buffer.items(): - if not window_created: - self._create_resizable_window(name, image.shape) - cv2.imshow(name, image) # pylint: disable=no-member - if not window_created: - window_created = bool(self._preview_buffer) - cv_key = cv2.waitKey(1000) # pylint: disable=no-member - else: - cv_key = None - - if thread.has_error: - logger.debug("Thread error detected") - err = True - break - if self._stop: - logger.debug("Stop received") - break - - # Preview Monitor - if not self._preview_monitor(cv_key): - break - - # Console Monitor - if keypress.kbhit(): - console_key = keypress.getch() - if console_key in ("\n", "\r"): - logger.debug("Exit requested") - break - if console_key in ("s", "S"): - logger.info("Save requested") - self._save_now = True - - # GUI Preview trigger update monitor - self._process_gui_triggers() - - sleep(1) - except KeyboardInterrupt: - logger.debug("Keyboard Interrupt received") - break - keypress.set_normal_term() - logger.debug("Closed Monitor") - return err - @classmethod def _create_resizable_window(cls, name: str, image_shape: tuple) -> None: """ Create a resizable OpenCV window to hold the preview image. @@ -433,7 +377,52 @@ def _create_resizable_window(cls, name: str, image_shape: tuple) -> None: cv2.namedWindow(name, cv2.WINDOW_GUI_EXPANDED) cv2.resizeWindow(name, width, height) - def _preview_monitor(self, key_press): + def _do_preview(self, window_created: bool) -> bool: + """" Display an image preview in a resizable window + + Parameters + ---------- + window_created: bool + ``True`` if a preview window has been created otherwise ``False`` + + Returns + ------- + bool + ``True`` if a preview window has been created otherwise ``False`` + """ + with self._lock: + for name, image in self._preview_buffer.items(): + if not window_created: + self._create_resizable_window(name, image.shape) + cv2.imshow(name, image) # pylint: disable=no-member + window_created = bool(self._preview_buffer) if not window_created else window_created + return window_created + + def _check_keypress(self, keypress: KBHit) -> bool: + """ Check if a keypress has been detected. + + Parameters + ---------- + keypress: :class:`lib.keypress.KBHit` + The keypress monitor + + Returns + ------- + bool + ``True`` if an exit keypress has been detected otherwise ``False`` + """ + retval = False + if keypress.kbhit(): + console_key = keypress.getch() + if console_key in ("\n", "\r"): + logger.debug("Exit requested") + retval = True + if console_key in ("s", "S"): + logger.info("Save requested") + self._save_now = True + return retval + + def _preview_monitor(self, key_press: str) -> bool: """ Monitors keyboard presses on the pop-up OpenCV Preview Window. Parameters @@ -464,12 +453,12 @@ def _preview_monitor(self, key_press): self._refresh_preview = True if key_press == ord("m"): print("\n") - logger.verbose("Toggle mask display requested") + logger.verbose("Toggle mask display requested") # type:ignore self._toggle_preview_mask = True return True - def _process_gui_triggers(self): + def _process_gui_triggers(self) -> None: """ Check whether a file drop has occurred from the GUI to manually update the preview. """ if not self._args.redirect_gui: return @@ -489,6 +478,58 @@ def _process_gui_triggers(self): setattr(self, parent_flags[trigger], True) + def _monitor(self, thread: MultiThread) -> bool: + """ Monitor the background :func:`_training` thread for key presses and errors. + + Parameters + ---------- + thread: :class:~`lib.multithreading.MultiThread` + The thread containing the training loop + + Returns + ------- + bool + ``True`` if there has been an error in the background thread otherwise ``False`` + """ + self._output_startup_info() + keypress = KBHit(is_gui=self._args.redirect_gui) + window_created = False + err = False + while True: + try: + if self._args.preview: + self._do_preview(window_created) + cv_key = cv2.waitKey(1000) # pylint: disable=no-member + else: + cv_key = None + + if thread.has_error: + logger.debug("Thread error detected") + err = True + break + if self._stop: + logger.debug("Stop received") + break + + # Preview Monitor + if not self._preview_monitor(cv_key): + break + + # Console Monitor + if self._check_keypress(keypress): + break # Exit requested + + # GUI Preview trigger update monitor + self._process_gui_triggers() + + sleep(1) + except KeyboardInterrupt: + logger.debug("Keyboard Interrupt received") + break + keypress.set_normal_term() + logger.debug("Closed Monitor") + return err + def _show(self, image, name=""): """ Generate the preview and write preview file output. From c3c3483eed72a3b11290da2e784eacf77ca929ea Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 23 Jun 2022 15:55:24 +0100 Subject: [PATCH 645/981] AMD Bugfixes: Save Optimizer Weights --- lib/model/loss/loss_plaid.py | 1 + plugins/train/model/_base/model.py | 5 +++-- plugins/train/model/_base/settings.py | 20 ++++++++++++-------- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/lib/model/loss/loss_plaid.py b/lib/model/loss/loss_plaid.py index 9c83bb7dfa..bdd6007a07 100644 --- a/lib/model/loss/loss_plaid.py +++ b/lib/model/loss/loss_plaid.py @@ -871,6 +871,7 @@ class LossWrapper(): # pylint:disable=too-few-public-methods single output and masking. """ def __init__(self) -> None: + self.__name__ = "LossWrapper" logger.debug("Initializing: %s", self.__class__.__name__) self._loss_functions: List[Callable] = [] self._loss_weights: List[float] = [] diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index c671e22c38..2dd86f8001 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -124,6 +124,7 @@ def __init__(self, "Mask to use. Please select a mask or disable Penalized Mask " "Loss.") + self._mixed_precision = self.config["mixed_precision"] and get_backend() != "amd" self._io = IO(self, model_dir, self._is_predict, self.config["save_optimizer"]) self._check_multiple_models() @@ -132,7 +133,7 @@ def __init__(self, self._config_changeable_items, False if self._is_predict else self._args.no_logs) self._settings = Settings(self._args, - self.config["mixed_precision"], + self._mixed_precision, self.config["allow_growth"], self._is_predict) self._loss = Loss(self.config) @@ -435,7 +436,7 @@ def _compile_model(self) -> None: self.config["learning_rate"], self.config.get("clipnorm", False), 10 ** int(self.config["epsilon_exponent"]), - self.config.get("mixed_precision", False), + self._mixed_precision, self._args).optimizer if self._settings.use_mixed_precision: optimizer = self._settings.loss_scale_optimizer(optimizer) diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 2c6163bbe8..19d263e65a 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -413,7 +413,7 @@ def __init__(self, allow_growth, is_predict) self._set_tf_settings(allow_growth, arguments.exclude_gpus) - use_mixed_precision = not is_predict and mixed_precision and get_backend() == "nvidia" + use_mixed_precision = not is_predict and mixed_precision self._use_mixed_precision = self._set_keras_mixed_precision(use_mixed_precision) if self._use_mixed_precision: logger.info("Enabling Mixed Precision Training.") @@ -507,9 +507,9 @@ def _set_keras_mixed_precision(cls, use_mixed_precision: bool) -> bool: ``True`` if mixed precision has been enabled otherwise ``False`` """ logger.debug("use_mixed_precision: %s", use_mixed_precision) - if not use_mixed_precision and get_backend() == "amd": - logger.debug("Not enabling 'mixed_precision' (backend: %s, use_mixed_precision: %s)", - get_backend(), use_mixed_precision) + if get_backend() == "amd": + logger.debug("No action to perform for 'mixed_precision' on backend '%s': " + "use_mixed_precision: %s)", get_backend(), use_mixed_precision) return False if not use_mixed_precision: @@ -676,10 +676,8 @@ def check_model_precision(self, :class:`keras.models.Model` The original model with the datatype updated """ - config = model.get_config() - if not self.use_mixed_precision and not state.mixed_precision_layers: - # Switched to Full Precision, get compatible layers from model if not already stored - state.add_mixed_precision_layers(self._get_mixed_precision_layers(config["layers"])) + if get_backend() == "amd": # Mixed precision not supported on amd + return model if self.use_mixed_precision and not state.mixed_precision_layers: # Switching to mixed precision on a model which was started in FP32 prior to the @@ -689,6 +687,12 @@ def check_model_precision(self, "older model files. Reverting to Full Precision.") return model + config = model.get_config() + + if not self.use_mixed_precision and not state.mixed_precision_layers: + # Switched to Full Precision, get compatible layers from model if not already stored + state.add_mixed_precision_layers(self._get_mixed_precision_layers(config["layers"])) + self._switch_precision(config["layers"], state.mixed_precision_layers) new_model = keras.models.Model().from_config(config) From 6168170e76c7264071b5d4ec0b6d85de7c2fb4a9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 23 Jun 2022 16:10:20 +0100 Subject: [PATCH 646/981] Force saving optimizer weights on snapshot --- plugins/train/model/_base/io.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/plugins/train/model/_base/io.py b/plugins/train/model/_base/io.py index fb483a70ae..d14bf0dba0 100644 --- a/plugins/train/model/_base/io.py +++ b/plugins/train/model/_base/io.py @@ -172,15 +172,19 @@ def _load(self) -> keras.models.Model: logger.info("Loaded model from disk: '%s'", self._filename) return model - def save(self, is_exit: bool = False) -> None: + def save(self, is_exit: bool = False, force_save_optimizer: bool = False) -> None: """ Backup and save the model and state file. Parameters ---------- is_exit: bool, optional - ``True`` if the save request has come from an exit process request otherwise ``False`` + ``True`` if the save request has come from an exit process request otherwise ``False``. Default: ``False`` + force_save_optimizer: bool, optional + ``True`` to force saving the optimizer weights with the model, otherwise ``False``. + Default:``False`` + Notes ----- The backup function actually backups the model from the previous save iteration rather than @@ -196,12 +200,14 @@ def save(self, is_exit: bool = False) -> None: # pylint:disable=protected-access self._backup.backup_model(self._plugin.state._filename) - include_optimizer = self._save_optimizer == "always" or (self._save_optimizer == "exit" - and is_exit) + include_optimizer = (force_save_optimizer or + self._save_optimizer == "always" or + (self._save_optimizer == "exit" and is_exit)) + self._plugin.model.save(self._filename, include_optimizer=include_optimizer) self._plugin.state.save() - msg = "[Saved models]" + msg = "[Saved optimizer state for Snapshot]" if force_save_optimizer else "[Saved models]" if save_averages: lossmsg = [f"face_{side}: {avg:.5f}" for side, avg in zip(("a", "b"), save_averages)] @@ -266,6 +272,7 @@ def snapshot(self) -> None: the latest save, hence iteration being reduced by 1. """ logger.debug("Performing snapshot. Iterations: %s", self._plugin.iterations) + self.save(force_save_optimizer=True) self._backup.snapshot_models(self._plugin.iterations - 1) logger.debug("Performed snapshot") From 8ec1e1a091686ec481916b9e53ffe1fbf6885219 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 23 Jun 2022 18:00:22 +0100 Subject: [PATCH 647/981] Nvidia: Add AutoClip training option --- lib/model/autoclip.py | 73 ++++++++++ plugins/train/_config.py | 13 ++ plugins/train/model/_base/model.py | 22 ++- plugins/train/model/_base/settings.py | 54 +++----- plugins/train/model/dfl_sae_defaults.py | 127 ++++++++--------- plugins/train/model/unbalanced_defaults.py | 154 +++++++++------------ requirements/_requirements_base.txt | 1 + 7 files changed, 242 insertions(+), 202 deletions(-) create mode 100644 lib/model/autoclip.py diff --git a/lib/model/autoclip.py b/lib/model/autoclip.py new file mode 100644 index 0000000000..2041308b11 --- /dev/null +++ b/lib/model/autoclip.py @@ -0,0 +1,73 @@ +""" Auto clipper for clipping gradients. + +Non AMD Only +""" +from typing import List + +import tensorflow as tf +import tensorflow_probability as tfp + + +class AutoClipper(): # pylint:disable=too-few-public-methods + """ AutoClip: Adaptive Gradient Clipping for Source Separation Networks + + Parameters + ---------- + clip_percentile: int + The percentile to clip the gradients at + history_size: int, optional + The number of iterations of data to use to calculate the norm + Default: ``10000`` + + References + ---------- + tf implementation: https://github.com/pseeth/autoclip + original paper: https://arxiv.org/abs/2007.14469 + """ + def __init__(self, clip_percentile: int, history_size: int = 10000): + self._clip_percentile = clip_percentile + self._grad_history = tf.Variable(tf.zeros(history_size), trainable=False) + self._index = tf.Variable(0, trainable=False) + self._history_size = history_size + + def __call__(self, grads_and_vars: List[tf.Tensor]) -> List[tf.Tensor]: + """ Call the AutoClip function. + + Parameters + ---------- + grads_and_vars: list + The list of gradient tensors and variables for the optimizer + """ + grad_norms = [self._get_grad_norm(g) for g, _ in grads_and_vars] + total_norm = tf.norm(grad_norms) + assign_idx = tf.math.mod(self._index, self._history_size) + self._grad_history = self._grad_history[assign_idx].assign(total_norm) + self._index = self._index.assign_add(1) + clip_value = tfp.stats.percentile(self._grad_history[: self._index], + q=self._clip_percentile) + return [(tf.clip_by_norm(g, clip_value), v) for g, v in grads_and_vars] + + @classmethod + def _get_grad_norm(cls, gradients: tf.Tensor) -> tf.Tensor: + """ Obtain the L2 Norm for the gradients + + Parameters + ---------- + gradients: :class:`tensorflow.Tensor` + The gradients to calculate the L2 norm for + + Returns + ------- + :class:`tensorflow.Tensor` + The L2 Norm of the given gradients + """ + values = tf.convert_to_tensor(gradients.values + if isinstance(gradients, tf.IndexedSlices) + else gradients, name="t") + + # Calculate L2-norm, clip elements by ratio of clip_norm to L2-norm + l2sum = tf.math.reduce_sum(values * values, axis=None, keepdims=True) + pred = l2sum > 0 + # Two-tap tf.where trick to bypass NaN gradients + l2sum_safe = tf.where(pred, l2sum, tf.ones_like(l2sum)) + return tf.squeeze(tf.where(pred, tf.math.sqrt(l2sum_safe), l2sum)) diff --git a/plugins/train/_config.py b/plugins/train/_config.py index fdc430e568..d3c2d550d0 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -251,6 +251,19 @@ def _set_globals(self) -> None: "iterations are met. Note: If the training session ends because of another " "reason (e.g. power outage, Out of Memory Error, NaN detected) then the " "optimizer weights will NOT be saved.") + self.add_item( + section=section, + title="autoclip", + datatype=bool, + default=False, + info="[Nvidia Only] Apply AutoClipping to the gradients. AutoClip analyzes the " + "gradient weights and adjusts the normalization value dynamically to fit the " + "data. Can help prevent NaNs and improve model optimization at the expense of " + "VRAM. Ref: AutoClip: Adaptive Gradient Clipping for Source Separation Networks " + "https://arxiv.org/abs/2007.14469", + fixed=False, + gui_radio=True, + group="optimizer") self.add_item( section=section, title="reflect_padding", diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 2dd86f8001..c2459828d0 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -432,12 +432,11 @@ def _compile_model(self) -> None: if self.state.model_needs_rebuild: self._model = self._settings.check_model_precision(self._model, self._state) + autoclip = get_backend() != "amd" and self.config["autoclip"] optimizer = Optimizer(self.config["optimizer"], self.config["learning_rate"], - self.config.get("clipnorm", False), - 10 ** int(self.config["epsilon_exponent"]), - self._mixed_precision, - self._args).optimizer + autoclip, + 10 ** int(self.config["epsilon_exponent"])).optimizer if self._settings.use_mixed_precision: optimizer = self._settings.loss_scale_optimizer(optimizer) if get_backend() == "amd": @@ -739,14 +738,18 @@ def _update_legacy_config(self) -> bool: * masks type - Replace removed masks 'dfl_full' and 'facehull' with `components` mask + * clipnorm - Only existed in 2 models (DFL-SAE + Unbalanced). Replaced with global + option autoclip + Returns ------- bool ``True`` if legacy items exist and state file has been updated, otherwise ``False`` """ logger.debug("Checking for legacy state file update") - priors = ["dssim_loss", "mask_type", "mask_type", "l2_reg_term"] - new_items = ["loss_function", "learn_mask", "mask_type", "loss_function_2"] + priors = ["dssim_loss", "mask_type", "mask_type", "l2_reg_term", "clipnorm"] + new_items = ["loss_function", "learn_mask", "mask_type", "loss_function_2", + "autoclip"] updated = False for old, new in zip(priors, new_items): if old not in self._config: @@ -788,6 +791,13 @@ def _update_legacy_config(self) -> bool: updated = True logger.info("Updated config from legacy 'l2_reg_term' to 'loss_function_2'") + # Replace clipnorm with correct gradient clipping type and value + if old == "clipnorm": + self._config[new] = self._config[old] + del self._config[old] + updated = True + logger.info("Updated config from legacy '%s' to '%s'", old, new) + logger.debug("State file updated for legacy config: %s", updated) return updated diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 19d263e65a..2592c4d7e5 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -31,6 +31,7 @@ from tensorflow import keras from tensorflow.keras import losses as k_losses # pylint:disable=import-error from tensorflow.keras import backend as K # pylint:disable=import-error + from lib.model.autoclip import AutoClipper # pylint:disable=ungrouped-imports if get_tf_version() < 2.4: import tensorflow.keras.mixed_precision.experimental as mixedprecision # noqa pylint:disable=import-error,no-name-in-module @@ -294,26 +295,19 @@ class Optimizer(): # pylint:disable=too-few-public-methods The selected optimizer name for the plugin learning_rate: float The selected learning rate to use - clipnorm: bool - Whether to clip gradients to avoid exploding/vanishing gradients + autoclip: bool + ``True`` if AutoClip should be enabled otherwise ``False`` epsilon: float The value to use for the epsilon of the optimizer - mixed_precision: bool - ``True`` if mixed precision training is to be enabled otherwise ``False`` - arguments: :class:`argparse.Namespace` - The arguments that were passed to the train or convert process as generated from - Faceswap's command line arguments """ def __init__(self, optimizer: str, learning_rate: float, - clipnorm: bool, - epsilon: float, - mixed_precision: bool, - arguments: "Namespace") -> None: - logger.debug("Initializing %s: (optimizer: %s, learning_rate: %s, clipnorm: %s, " - "epsilon: %s, mixed_precision: %s, arguments: %s)", self.__class__.__name__, - optimizer, learning_rate, clipnorm, epsilon, mixed_precision, arguments) + autoclip: bool, + epsilon: float) -> None: + logger.debug("Initializing %s: (optimizer: %s, learning_rate: %s, autoclip: %s, " + ", epsilon: %s)", self.__class__.__name__, optimizer, learning_rate, + autoclip, epsilon) valid_optimizers = {"adabelief": (optimizers.AdaBelief, dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), "adam": (optimizers.Adam, @@ -323,7 +317,7 @@ def __init__(self, "rms-prop": (optimizers.RMSprop, dict(epsilon=epsilon))} self._optimizer, self._kwargs = valid_optimizers[optimizer] - self._configure(learning_rate, clipnorm, mixed_precision, arguments) + self._configure(learning_rate, autoclip) logger.verbose("Using %s optimizer", optimizer.title()) # type:ignore logger.debug("Initialized: %s", self.__class__.__name__) @@ -334,22 +328,15 @@ def optimizer(self) -> keras.optimizers.Optimizer: def _configure(self, learning_rate: float, - clipnorm: bool, - mixed_precision: bool, - arguments: "Namespace") -> None: + autoclip: bool) -> None: """ Configure the optimizer based on user settings. Parameters ---------- learning_rate: float The selected learning rate to use - clipnorm: bool - Whether to clip gradients to avoid exploding/vanishing gradients - mixed_precision: bool - ``True`` if mixed precision training is to be enabled otherwise ``False`` - arguments: :class:`argparse.Namespace` - The arguments that were passed to the train or convert process as generated from - Faceswap's command line arguments + autoclip: bool + ``True`` if AutoClip should be enabled otherwise ``False`` Notes ----- @@ -363,20 +350,11 @@ def _configure(self, lr_key = "lr" if get_backend() == "amd" else "learning_rate" self._kwargs[lr_key] = learning_rate - if clipnorm and (arguments.distributed or mixed_precision): - logger.warning("Clipnorm has been selected, but is unsupported when using distributed " - "or mixed_precision training, so has been disabled. If you wish to " - "enable clipnorm, then you must disable these other options.") - clipnorm = False - if clipnorm and get_backend() == "amd": - # TODO add clipnorm in for plaidML when it is fixed upstream. Still not fixed in - # release 0.7.0. - logger.warning("Due to a bug in plaidML, clipnorm cannot be used on AMD backends so " - "has been disabled") - clipnorm = False - if clipnorm: - self._kwargs["clipnorm"] = 1.0 + if not autoclip: + return + logger.info("Enabling AutoClip") + self._kwargs["gradient_transformers"] = [AutoClipper(10, history_size=10000)] logger.debug("optimizer kwargs: %s", self._kwargs) diff --git a/plugins/train/model/dfl_sae_defaults.py b/plugins/train/model/dfl_sae_defaults.py index 34fc916257..38c43d3b66 100644 --- a/plugins/train/model/dfl_sae_defaults.py +++ b/plugins/train/model/dfl_sae_defaults.py @@ -44,74 +44,59 @@ _HELPTEXT = "DFL SAE Model (Adapted from https://github.com/iperov/DeepFaceLab)" -_DEFAULTS = { - "input_size": { - "default": 128, - "info": "Resolution (in pixels) of the input image to train on.\n" - "BE AWARE Larger resolution will dramatically increase VRAM requirements.\n" - "\nMust be divisible by 16.", - "datatype": int, - "rounding": 16, - "min_max": (64, 256), - "group": "size", - "fixed": True, - }, - "clipnorm": { - "default": True, - "info": "Controls gradient clipping of the optimizer. Can prevent model corruption at " - "the expense of VRAM.", - "datatype": bool, - "fixed": False, - "group": "settings", - }, - "architecture": { - "default": "df", - "info": "Model architecture:" - "\n\t'df': Keeps the faces more natural." - "\n\t'liae': Can help fix overly different face shapes.", - "datatype": str, - "choices": ["df", "liae"], - "gui_radio": True, - "fixed": True, - "group": "network", - }, - "autoencoder_dims": { - "default": 0, - "info": "Face information is stored in AutoEncoder dimensions. If there are not enough " - "dimensions then certain facial features may not be recognized." - "\nHigher number of dimensions are better, but require more VRAM." - "\nSet to 0 to use the architecture defaults (256 for liae, 512 for df).", - "datatype": int, - "rounding": 32, - "min_max": (0, 1024), - "fixed": True, - "group": "network", - }, - "encoder_dims": { - "default": 42, - "info": "Encoder dimensions per channel. Higher number of encoder dimensions will help " - "the model to recognize more facial features, but will require more VRAM.", - "datatype": int, - "rounding": 1, - "min_max": (21, 85), - "fixed": True, - "group": "network", - }, - "decoder_dims": { - "default": 21, - "info": "Decoder dimensions per channel. Higher number of decoder dimensions will help " - "the model to improve details, but will require more VRAM.", - "datatype": int, - "rounding": 1, - "min_max": (10, 85), - "fixed": True, - "group": "network", - }, - "multiscale_decoder": { - "default": False, - "info": "Multiscale decoder can help to obtain better details.", - "datatype": bool, - "fixed": True, - "group": "network", - }, -} +_DEFAULTS = dict( + input_size=dict( + default=128, + info="Resolution (in pixels) of the input image to train on.\n" + "BE AWARE Larger resolution will dramatically increase VRAM requirements.\n" + "\nMust be divisible by 16.", + datatype=int, + rounding=16, + min_max=(64, 256), + group="size", + fixed=True), + architecture=dict( + default="df", + info="Model architecture:" + "\n\t'df': Keeps the faces more natural." + "\n\t'liae': Can help fix overly different face shapes.", + datatype=str, + choices=["df", "liae"], + gui_radio=True, + fixed=True, + group="network"), + autoencoder_dims=dict( + default=0, + info="Face information is stored in AutoEncoder dimensions. If there are not enough " + "dimensions then certain facial features may not be recognized." + "\nHigher number of dimensions are better, but require more VRAM." + "\nSet to 0 to use the architecture defaults (256 for liae, 512 for df).", + datatype=int, + rounding=32, + min_max=(0, 1024), + fixed=True, + group="network"), + encoder_dims=dict( + default=42, + info="Encoder dimensions per channel. Higher number of encoder dimensions will help " + "the model to recognize more facial features, but will require more VRAM.", + datatype=int, + rounding=1, + min_max=(21, 85), + fixed=True, + group="network"), + decoder_dims=dict( + default=21, + info="Decoder dimensions per channel. Higher number of decoder dimensions will help " + "the model to improve details, but will require more VRAM.", + datatype=int, + rounding=1, + min_max=(10, 85), + fixed=True, + group="network"), + multiscale_decoder=dict( + default=False, + info="Multiscale decoder can help to obtain better details.", + datatype=bool, + fixed=True, + group="network")) diff --git a/plugins/train/model/unbalanced_defaults.py b/plugins/train/model/unbalanced_defaults.py index 317aec23ff..28bbcfd5da 100755 --- a/plugins/train/model/unbalanced_defaults.py +++ b/plugins/train/model/unbalanced_defaults.py @@ -47,90 +47,70 @@ ) -_DEFAULTS = { - "input_size": { - "default": 128, - "info": "Resolution (in pixels) of the image to train on.\n" - "BE AWARE Larger resolution will dramatically increaseVRAM requirements.\n" - "Make sure your resolution is divisible by 64 (e.g. 64, 128, 256 etc.).\n" - "NB: Your faceset must be at least 1.6x larger than your required input " - "size.\n(e.g. 160 is the maximum input size for a 256x256 faceset).", - "datatype": int, - "rounding": 64, - "min_max": (64, 512), - "choices": [], - "gui_radio": False, - "group": "size", - "fixed": True, - }, - "lowmem": { - "default": False, - "info": "Lower memory mode. Set to 'True' if having issues with VRAM useage.\n" - "NB: Models with a changed lowmem mode are not compatible with each other.\n" - "NB: lowmem will override cutom nodes and complexity settings.", - "datatype": bool, - "rounding": None, - "min_max": None, - "choices": [], - "gui_radio": False, - "group": "settings", - "fixed": True, - }, - "clipnorm": { - "default": True, - "info": "Controls gradient clipping of the optimizer. Can prevent model corruption at " - "the expense of VRAM.", - "datatype": bool, - "rounding": None, - "min_max": None, - "choices": [], - "gui_radio": False, - "group": "settings", - "fixed": True, - }, - "nodes": { - "default": 1024, - "info": "Number of nodes for decoder. Don't change this unless you know what you are " - "doing!", - "datatype": int, - "rounding": 64, - "min_max": (512, 4096), - "choices": [], - "gui_radio": False, - "fixed": True, - "group": "network", - }, - "complexity_encoder": { - "default": 128, - "info": "Encoder Convolution Layer Complexity. sensible ranges: 128 to 160.", - "datatype": int, - "rounding": 16, - "min_max": (64, 1024), - "choices": [], - "gui_radio": False, - "fixed": True, - "group": "network", - }, - "complexity_decoder_a": { - "default": 384, - "info": "Decoder A Complexity.", - "datatype": int, - "rounding": 16, - "min_max": (64, 1024), - "choices": [], - "gui_radio": False, - "fixed": True, - "group": "network", - }, - "complexity_decoder_b": { - "default": 512, - "info": "Decoder B Complexity.", - "datatype": int, - "rounding": 16, - "min_max": (64, 1024), - "choices": [], - "gui_radio": False, - "fixed": True, - "group": "network", - }, -} +_DEFAULTS = dict( + input_size=dict( + default=128, + info="Resolution (in pixels) of the image to train on.\n" + "BE AWARE Larger resolution will dramatically increaseVRAM requirements.\n" + "Make sure your resolution is divisible by 64 (e.g. 64, 128, 256 etc.).\n" + "NB: Your faceset must be at least 1.6x larger than your required input " + "size.\n(e.g. 160 is the maximum input size for a 256x256 faceset).", + datatype=int, + rounding=64, + min_max=(64, 512), + choices=[], + gui_radio=False, + group="size", + fixed=True), + lowmem=dict( + default=False, + info="Lower memory mode. Set to 'True' if having issues with VRAM useage.\n" + "NB: Models with a changed lowmem mode are not compatible with each other.\n" + "NB: lowmem will override cutom nodes and complexity settings.", + datatype=bool, + rounding=None, + min_max=None, + choices=[], + gui_radio=False, + group="settings", + fixed=True), + nodes=dict( + default=1024, + info="Number of nodes for decoder. Don't change this unless you know what you are doing!", + datatype=int, + rounding=64, + min_max=(512, 4096), + choices=[], + gui_radio=False, + fixed=True, + group="network"), + complexity_encoder=dict( + default=128, + info="Encoder Convolution Layer Complexity. sensible ranges: 128 to 160.", + datatype=int, + rounding=16, + min_max=(64, 1024), + choices=[], + gui_radio=False, + fixed=True, + group="network"), + complexity_decoder_a=dict( + default=384, + info="Decoder A Complexity.", + datatype=int, + rounding=16, + min_max=(64, 1024), + choices=[], + gui_radio=False, + fixed=True, + group="network"), + complexity_decoder_b=dict( + default=512, + info="Decoder B Complexity.", + datatype=int, + rounding=16, + min_max=(64, 1024), + choices=[], + gui_radio=False, + fixed=True, + group="network")) diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index f3f9ff28f6..185bf2f39c 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -13,5 +13,6 @@ ffmpy==0.2.3 #nvidia-ml-py>=11.510,<300 # Pin nvidida-ml-py to <11.515 until we know if bytes->str is an error or permanent change nvidia-ml-py<11.515 +tensorflow_probability<0.17 typing-extensions pywin32>=228 ; sys_platform == "win32" From c279ac35181d696f7bfe7245793c4533acba3974 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 24 Jun 2022 13:43:26 +0100 Subject: [PATCH 648/981] Disable saving optimizer state option --- plugins/train/_config.py | 23 ----------------------- plugins/train/model/_base/io.py | 8 +++++++- plugins/train/model/_base/model.py | 9 ++++++++- scripts/train.py | 2 +- 4 files changed, 16 insertions(+), 26 deletions(-) diff --git a/plugins/train/_config.py b/plugins/train/_config.py index d3c2d550d0..c897ce05aa 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -228,29 +228,6 @@ def _set_globals(self) -> None: "NB: The value given here is the 'exponent' to the epsilon. For example, " "choosing '-7' will set the epsilon to 1e-7. Choosing '-3' will set the epsilon " "to 0.001 (1e-3).") - self.add_item( - section=section, - title="save_optimizer", - datatype=str, - group="optimizer", - default="exit", - fixed=False, - gui_radio=True, - choices=["never", "always", "exit"], - info="When to save the Optimizer Weights. Saving the optimizer weights is not " - "necessary and will increase the model file size 3x (and by extension the amount " - "of time it takes to save the model). However, it can be useful to save these " - "weights if you want to guarantee that a resumed model carries off exactly from " - "where it left off, rather than spending a few hundred iterations catching up." - "\n\t never - Don't save optimizer weights." - "\n\t always - Save the optimizer weights at every save iteration. Model saving " - "will take longer, due to the increased file size, but you will always have the " - "last saved optimizer state in your model file." - "\n\t exit - Only save the optimizer weights when explicitly terminating a " - "model. This can be when the model is actively stopped or when the target " - "iterations are met. Note: If the training session ends because of another " - "reason (e.g. power outage, Out of Memory Error, NaN detected) then the " - "optimizer weights will NOT be saved.") self.add_item( section=section, title="autoclip", diff --git a/plugins/train/model/_base/io.py b/plugins/train/model/_base/io.py index d14bf0dba0..193e84a05d 100644 --- a/plugins/train/model/_base/io.py +++ b/plugins/train/model/_base/io.py @@ -272,7 +272,13 @@ def snapshot(self) -> None: the latest save, hence iteration being reduced by 1. """ logger.debug("Performing snapshot. Iterations: %s", self._plugin.iterations) - self.save(force_save_optimizer=True) + # self.save(force_save_optimizer=True) + # TODO Re-enable saving optimizer state when h5 bug fixed: + # File "h5py/_objects.pyx", line 54, in h5py._objects.with_phil.wrapper + # File "h5py/_objects.pyx", line 55, in h5py._objects.with_phil.wrapper + # File "h5py/h5d.pyx", line 87, in h5py.h5d.create + # ValueError: Unable to create dataset (name already exists) + self._backup.snapshot_models(self._plugin.iterations - 1) logger.debug("Performed snapshot") diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index c2459828d0..b6bc63be33 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -125,7 +125,14 @@ def __init__(self, "Loss.") self._mixed_precision = self.config["mixed_precision"] and get_backend() != "amd" - self._io = IO(self, model_dir, self._is_predict, self.config["save_optimizer"]) + # self._io = IO(self, model_dir, self._is_predict, self.config["save_optimizer"]) + # TODO - Re-enable saving of optimizer once this bug is fixed: + # File "h5py/_objects.pyx", line 54, in h5py._objects.with_phil.wrapper + # File "h5py/_objects.pyx", line 55, in h5py._objects.with_phil.wrapper + # File "h5py/h5d.pyx", line 87, in h5py.h5d.create + # ValueError: Unable to create dataset (name already exists) + + self._io = IO(self, model_dir, self._is_predict, "never") self._check_multiple_models() self._state = State(model_dir, diff --git a/scripts/train.py b/scripts/train.py index a39ea9a2b4..cd768364b1 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -243,7 +243,7 @@ def _training(self): except KeyboardInterrupt: try: logger.debug("Keyboard Interrupt Caught. Saving Weights and exiting") - model.save(on_exit=True) + model.save(is_exit=True) trainer.clear_tensorboard() except KeyboardInterrupt: logger.info("Saving model weights has been cancelled!") From cfad829999382cf1df29b8199534d631a04cf62d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 26 Jun 2022 16:34:50 +0100 Subject: [PATCH 649/981] Update typing-extensions pin --- requirements/_requirements_base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index 185bf2f39c..af6511fe8f 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -14,5 +14,5 @@ ffmpy==0.2.3 # Pin nvidida-ml-py to <11.515 until we know if bytes->str is an error or permanent change nvidia-ml-py<11.515 tensorflow_probability<0.17 -typing-extensions +typing-extensions>=4.0.0 pywin32>=228 ; sys_platform == "win32" From d20d3f2ecb6aa1a6f00bd36a5e1c2225f120c3cb Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 26 Jun 2022 18:21:36 +0100 Subject: [PATCH 650/981] bugfix --- plugins/train/model/_base/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index b6bc63be33..dfcd450aa3 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -877,7 +877,7 @@ def _get_nodes(self, nodes: np.ndarray) -> List[Tuple[str, int]]: """ anodes = np.array(nodes, dtype="object")[..., :3] num_layers = anodes.shape[0] - anodes = anodes[self._output_idx] if num_layers == 2 else nodes[0] + anodes = anodes[self._output_idx] if num_layers == 2 else anodes[0] retval = [(node[0], node[2]) for node in anodes] return retval From ef5451e4f125b52576301d38e93f0339c64d71d3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 28 Jun 2022 10:47:49 +0100 Subject: [PATCH 651/981] bugfix: Convert revert warp border to transparent --- lib/convert.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/convert.py b/lib/convert.py index d69d81c5c3..f18ce5f7d3 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -245,7 +245,7 @@ def _get_new_image(self, predicted, frame_size): frame_size, placeholder, flags=cv2.WARP_INVERSE_MAP | interpolator, - borderMode=cv2.BORDER_CONSTANT) + borderMode=cv2.BORDER_TRANSPARENT) logger.trace("Got filename: '%s'. (placeholders: %s)", predicted["filename"], placeholder.shape) From eb3c612f2986f23589d64bff0780d0d6aabe42ae Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 28 Jun 2022 14:27:23 +0100 Subject: [PATCH 652/981] Replace restore tool with model tool --- docs/full/lib/model.rst | 22 +- docs/full/tools/tools.rst | 8 + lib/gui/options.py | 2 +- locales/es/LC_MESSAGES/tools.model.cli.mo | Bin 0 -> 2899 bytes locales/es/LC_MESSAGES/tools.model.cli.po | 86 ++++++ locales/es/LC_MESSAGES/tools.restore.cli.mo | Bin 889 -> 0 bytes locales/es/LC_MESSAGES/tools.restore.cli.po | 36 --- locales/tools.model.cli.pot | 63 +++++ locales/tools.restore.cli.pot | 29 -- tools/{restore => model}/__init__.py | 0 tools/model/cli.py | 69 +++++ tools/model/model.py | 285 ++++++++++++++++++++ tools/restore/cli.py | 35 --- tools/restore/restore.py | 48 ---- 14 files changed, 523 insertions(+), 160 deletions(-) create mode 100644 locales/es/LC_MESSAGES/tools.model.cli.mo create mode 100644 locales/es/LC_MESSAGES/tools.model.cli.po delete mode 100644 locales/es/LC_MESSAGES/tools.restore.cli.mo delete mode 100644 locales/es/LC_MESSAGES/tools.restore.cli.po create mode 100644 locales/tools.model.cli.pot delete mode 100644 locales/tools.restore.cli.pot rename tools/{restore => model}/__init__.py (100%) create mode 100644 tools/model/cli.py create mode 100644 tools/model/model.py delete mode 100644 tools/restore/cli.py delete mode 100644 tools/restore/restore.py diff --git a/docs/full/lib/model.rst b/docs/full/lib/model.rst index 3ca1f003d4..9af6eb6faf 100755 --- a/docs/full/lib/model.rst +++ b/docs/full/lib/model.rst @@ -1,5 +1,6 @@ +************* model package -============= +************* The Model Package handles interfacing with the neural network backend and holds custom objects. @@ -7,7 +8,7 @@ The Model Package handles interfacing with the neural network backend and holds :local: model.backup_restore module ---------------------------- +=========================== .. automodule:: lib.model.backup_restore :members: @@ -15,7 +16,7 @@ model.backup_restore module :show-inheritance: model.initializers module -------------------------- +========================= .. rubric:: Module Summary @@ -32,7 +33,7 @@ model.initializers module :show-inheritance: model.layers module -------------------- +=================== .. rubric:: Module Summary @@ -52,7 +53,7 @@ model.layers module :show-inheritance: model.losses module -------------------- +=================== The losses listed here are generated from the docstrings in :mod:`lib.model.losses_tf`, however the functions are exactly the same for :mod:`lib.model.losses_plaid`. The correct loss module will @@ -84,7 +85,7 @@ be imported as :mod:`lib.model.losses` depending on the backend in use. :show-inheritance: model.nets module ------------------ +================= .. rubric:: Module Summary @@ -100,7 +101,7 @@ model.nets module :show-inheritance: model.nn_blocks module ----------------------- +====================== .. rubric:: Module Summary @@ -122,7 +123,7 @@ model.nn_blocks module :show-inheritance: model.normalization module --------------------------- +========================== .. rubric:: Module Summary @@ -137,7 +138,7 @@ model.normalization module :show-inheritance: model.optimizers module ------------------------ +======================= The optimizers listed here are generated from the docstrings in :mod:`lib.model.optimizers_tf`, however the functions are excactly the same for :mod:`lib.model.optimizers_plaid`. The correct optimizers module will @@ -156,10 +157,9 @@ be imported as :mod:`lib.model.optimizers` depending on the backend in use. :show-inheritance: model.session module ---------------------- +===================== .. automodule:: lib.model.session :members: :undoc-members: :show-inheritance: - diff --git a/docs/full/tools/tools.rst b/docs/full/tools/tools.rst index 07468d3cea..743945322e 100644 --- a/docs/full/tools/tools.rst +++ b/docs/full/tools/tools.rst @@ -30,6 +30,14 @@ mask module :undoc-members: :show-inheritance: +model module +============ + +.. automodule:: tools.model.model + :members: + :undoc-members: + :show-inheritance: + preview module =============== diff --git a/lib/gui/options.py b/lib/gui/options.py index a207c43bcc..52f273250b 100644 --- a/lib/gui/options.py +++ b/lib/gui/options.py @@ -212,7 +212,7 @@ def expand_action_option(option, options): def gen_command_options(self, command): """ Yield each option for specified command """ - for key, val in self.opts[command].items(): + for key, val in self.opts.get(command, {}).items(): if not isinstance(val, dict): continue yield key, val diff --git a/locales/es/LC_MESSAGES/tools.model.cli.mo b/locales/es/LC_MESSAGES/tools.model.cli.mo new file mode 100644 index 0000000000000000000000000000000000000000..c9ad2cc076ad77c72c83147bdce4f91a99968884 GIT binary patch literal 2899 zcmb7GO>Z1U5N#k3SZ%PUNLp=X;Bn1A%9e}`Nf$aiIBR;qgFu(p`tAL5- zcY}hpCC#j(N;ynRZRRBNpsn#_4c(Ht^5?Rm;G{OHprtJoHt14$aTKo$?NlDDTcvb^ z);E+}6R?Htg@tPOt!z!_+ArX?b4b#(4&g5q_f5H?+QSor8f!CCPMKVd>C{eBx~+_I zGN@>C{jx)a9eb2~1RUDi_T|6Buy9~N=JuJhK>{3 zcZz)iY46f9(A-ul_OXsIrIX-v<*{}}rCcfJY3A&*U(vaD3h}~(Q;%ve*u)!I24yMT z+skOO(4O$eOsL0|bzzfDeh#Vt3a^85MwTS2s?@oR2~6p3M<2~w;@S*)%B3+j&{Q>S zpX;!Ioi5*qLp3CixhiBBldafj1mi1}>)C1(jjcmf2Y!#`kwYU*;^AjZ37udbkiZ2L zP2H3CP-u>kdb*;`nJjfdg$(j)wBeLTZ6W;$9XFRGyL2d_974xAzd|}=*bKi&B6bEdQN~+0EA9E%(+R($ruka7 zX}{?<)E)c`0QBVrh10>Ar0%bT`Lr6uexhM*y3ZA$i{w-E0I0}lbga0BlOsRZMY31VeKN6l{NR3atj;tqN^YXhGTPbN*-5rO zNOrE#_Iug(_S;+ATU(<;Y38+@t7M|&68nzs9v$@7x6`c=v!F~!CaVfo0-EBTs)Pt# zr+guuS7G}7=4zsI%8c%53DM5pm$;aPJ8c7S~oLp)kmQ+b6kt|gI zH`0OX9BiP>6vWZx3w>riAyu&;aEUEYMOomos~&N))nRS>l_@-wfn>HiJ#+!^U3KVodn?s& zUe`og`kPK?l!HHF$dF|?;e)CT&@=Q62r0n>PCJRF zWihPqf3ShMh?@{^RA{d2CP}c`(jQZ28?8Qo03fmLk|@>)sYej=>uZpQWk_mN=`P8Q zctfHNk*5)o0~}Fz60l_m?GoQcs2~2sVVJGzHiTm}*A#1~w>X#4)rI8Kps%&5axjf$4#kf$ zYXa`txe1E1`;YS=Yfqv, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-06-28 14:05+0100\n" +"PO-Revision-Date: 2022-06-28 14:11+0100\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.0\n" +"Last-Translator: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: es\n" + +#: tools/model/cli.py:13 +msgid "This tool lets you perform actions on saved Faceswap models." +msgstr "" +"Esta herramienta le permite realizar acciones en modelos Faceswap guardados." + +#: tools/model/cli.py:22 +msgid "A tool for performing actions on Faceswap trained model files" +msgstr "" +"Una herramienta para realizar acciones en archivos de modelos entrenados " +"Faceswap" + +#: tools/model/cli.py:33 +msgid "" +"Model directory. A directory containing the model you wish to perform an " +"action on." +msgstr "" +"Directorio de modelo. Un directorio que contiene el modelo en el que desea " +"realizar una acción." + +#: tools/model/cli.py:41 +msgid "" +"R|Choose which action you want to perform.\n" +"L|'inference' - Create an inference only copy of the model. Strips any " +"layers from the model which are only required for training. NB: This is for " +"exporting the model for use in external applications. Inference generated " +"models cannot be used within Faceswap. See the 'format' option for " +"specifying the model output format.\n" +"L|'nan-scan' - Scan the model file for NaNs or Infs (invalid data).\n" +"L|'restore' - Restore a model from backup." +msgstr "" +"R|Elige qué acción quieres realizar.\n" +"L|'inference': crea una copia del modelo solo de inferencia. Elimina las " +"capas del modelo que solo se requieren para el entrenamiento. NB: Esto es " +"para exportar el modelo para su uso en aplicaciones externas. Los modelos " +"generados por inferencia no se pueden usar en Faceswap. Consulte la opción " +"'formato' para especificar el formato de salida del modelo.\n" +"L|'nan-scan': escanea el archivo del modelo en busca de NaN o Inf (datos no " +"válidos).\n" +"L|'restore': restaura un modelo desde una copia de seguridad." + +#: tools/model/cli.py:55 tools/model/cli.py:66 +msgid "inference" +msgstr "inferencia" + +#: tools/model/cli.py:56 +msgid "" +"R|The format to save the model as. Note: Only used for 'inference' job.\n" +"L|'h5' - Standard Keras H5 format. Does not store any custom layer " +"information. Layers will need to be loaded from Faceswap to use.\n" +"L|'saved-model' - Tensorflow's Saved Model format. Contains all information " +"required to load the model outside of Faceswap." +msgstr "" +"R|El formato para guardar el modelo. Nota: Solo se usa para el trabajo de " +"'inference'.\n" +"L|'h5' - Formato estándar de Keras H5. No almacena ninguna información de " +"capa personalizada. Las capas deberán cargarse desde Faceswap para usar.\n" +"L|'saved-model': formato de modelo guardado de Tensorflow. Contiene toda la " +"información necesaria para cargar el modelo fuera de Faceswap." + +#: tools/model/cli.py:67 +msgid "" +"Only used for 'inference' job. Generate the inference model for B -> A " +"instead of A -> B." +msgstr "" +"Solo se usa para el trabajo de 'inference'. Genere el modelo de inferencia " +"para B -> A en lugar de A -> B." diff --git a/locales/es/LC_MESSAGES/tools.restore.cli.mo b/locales/es/LC_MESSAGES/tools.restore.cli.mo deleted file mode 100644 index 40170fef7634b35b4c5791fd9f408c3f7baf4aac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 889 zcmZuv!EVz)5M7{LeB{hwxV4})aZ07&kV7FUszgLo65xP@CiXa9V(+@U>lS|qpTM{9 z7kmdt#&*=Sf|Z_TW@q=knceyQ=-`Lob-;Mcc*S_g_`+yc#W-eMGARKBt}v8dJlyayg6bWau_~D3-Z$e6=i5 zr%;f0-Iczg9m$<~Lb0&s8|%ySRDLAul+iI{n2@{1Ds&ZUmJ1f|b@EDRDts_5KEzjg=f6k(I8Cf~giAnicVi>KJ58jd{%O$1qbrVC?Y{<7{Vbwqs zo&=xe?trl|EDWW}!!SJNN*!yroCWgZ#}F)v+F~Kkj4fRZy6QqV8IGZMCx;rIOdvWM z-?SM6xv#SifmmsToUFxCaZd;pTI@aunbY>4XB>11uPmelXp>xB@h1C;sT8)3qfKp9 zifKP5V!E3RWjI^xctTj|n?d2sW!*Ap&(Z`pr@HNw9NjL2V^%5fKZ>E4Io}esqhp{V UTPlv*(Yf11&rx&7|CSw!zmz%_&;S4c diff --git a/locales/es/LC_MESSAGES/tools.restore.cli.po b/locales/es/LC_MESSAGES/tools.restore.cli.po deleted file mode 100644 index e9a34b92fe..0000000000 --- a/locales/es/LC_MESSAGES/tools.restore.cli.po +++ /dev/null @@ -1,36 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR ORGANIZATION -# FIRST AUTHOR , YEAR. -# -msgid "" -msgstr "" -"Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-02-18 23:06-0000\n" -"PO-Revision-Date: 2021-02-19 18:05+0000\n" -"Language-Team: tokafondo\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 2.3\n" -"Last-Translator: \n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"Language: es_ES\n" - -#: tools/restore/cli.py:13 -msgid "This command lets you restore models from backup." -msgstr "Este comando permite restaurar modelos desde una copia de seguridad." - -#: tools/restore/cli.py:22 -msgid "A tool for restoring models from backup (.bk) files" -msgstr "" -"Una herramienta para restaurar modelos a partir de archivos de copia de " -"seguridad (.bk)" - -#: tools/restore/cli.py:33 -msgid "" -"Model directory. A directory containing the model you wish to restore from " -"backup." -msgstr "" -"Directorio del modelo. Un directorio que contiene el modelo que desea " -"restaurar desde la copia de seguridad." diff --git a/locales/tools.model.cli.pot b/locales/tools.model.cli.pot new file mode 100644 index 0000000000..3afb8b1073 --- /dev/null +++ b/locales/tools.model.cli.pot @@ -0,0 +1,63 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-06-28 14:05+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#: tools/model/cli.py:13 +msgid "This tool lets you perform actions on saved Faceswap models." +msgstr "" + +#: tools/model/cli.py:22 +msgid "A tool for performing actions on Faceswap trained model files" +msgstr "" + +#: tools/model/cli.py:33 +msgid "" +"Model directory. A directory containing the model you wish to perform an " +"action on." +msgstr "" + +#: tools/model/cli.py:41 +msgid "" +"R|Choose which action you want to perform.\n" +"L|'inference' - Create an inference only copy of the model. Strips any " +"layers from the model which are only required for training. NB: This is for " +"exporting the model for use in external applications. Inference generated " +"models cannot be used within Faceswap. See the 'format' option for " +"specifying the model output format.\n" +"L|'nan-scan' - Scan the model file for NaNs or Infs (invalid data).\n" +"L|'restore' - Restore a model from backup." +msgstr "" + +#: tools/model/cli.py:55 tools/model/cli.py:66 +msgid "inference" +msgstr "" + +#: tools/model/cli.py:56 +msgid "" +"R|The format to save the model as. Note: Only used for 'inference' job.\n" +"L|'h5' - Standard Keras H5 format. Does not store any custom layer " +"information. Layers will need to be loaded from Faceswap to use.\n" +"L|'saved-model' - Tensorflow's Saved Model format. Contains all information " +"required to load the model outside of Faceswap." +msgstr "" + +#: tools/model/cli.py:67 +msgid "" +"Only used for 'inference' job. Generate the inference model for B -> A " +"instead of A -> B." +msgstr "" diff --git a/locales/tools.restore.cli.pot b/locales/tools.restore.cli.pot deleted file mode 100644 index 95e331fb3a..0000000000 --- a/locales/tools.restore.cli.pot +++ /dev/null @@ -1,29 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR ORGANIZATION -# FIRST AUTHOR , YEAR. -# -msgid "" -msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-02-18 23:06-0000\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=cp1252\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" - - -#: tools/restore/cli.py:13 -msgid "This command lets you restore models from backup." -msgstr "" - -#: tools/restore/cli.py:22 -msgid "A tool for restoring models from backup (.bk) files" -msgstr "" - -#: tools/restore/cli.py:33 -msgid "Model directory. A directory containing the model you wish to restore from backup." -msgstr "" - diff --git a/tools/restore/__init__.py b/tools/model/__init__.py similarity index 100% rename from tools/restore/__init__.py rename to tools/model/__init__.py diff --git a/tools/model/cli.py b/tools/model/cli.py new file mode 100644 index 0000000000..939d6334b7 --- /dev/null +++ b/tools/model/cli.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +""" Command Line Arguments for tools """ +import gettext +from typing import Any, List, Dict + +from lib.cli.args import FaceSwapArgs +from lib.cli.actions import DirFullPaths, Radio + +# LOCALES +_LANG = gettext.translation("tools.restore.cli", localedir="locales", fallback=True) +_ = _LANG.gettext + +_HELPTEXT = _("This tool lets you perform actions on saved Faceswap models.") + + +class ModelArgs(FaceSwapArgs): + """ Class to perform actions on model files """ + + @staticmethod + def get_info() -> str: + """ Return command information """ + return _("A tool for performing actions on Faceswap trained model files") + + @staticmethod + def get_argument_list() -> List[Dict[str, Any]]: + """ Put the arguments in a list so that they are accessible from both argparse and gui """ + argument_list = [] + argument_list.append(dict( + opts=("-m", "--model-dir"), + action=DirFullPaths, + dest="model_dir", + required=True, + help=_("Model directory. A directory containing the model you wish to perform an " + "action on."))) + argument_list.append(dict( + opts=("-j", "--job"), + action=Radio, + type=str, + choices=("inference", "nan-scan", "restore"), + required=True, + help=_("R|Choose which action you want to perform." + "\nL|'inference' - Create an inference only copy of the model. Strips any " + "layers from the model which are only required for training. NB: This is for " + "exporting the model for use in external applications. Inference generated " + "models cannot be used within Faceswap. See the 'format' option for specifying " + "the model output format." + "\nL|'nan-scan' - Scan the model file for NaNs or Infs (invalid data)." + "\nL|'restore' - Restore a model from backup."))) + argument_list.append(dict( + opts=("-f", "--format"), + action=Radio, + type=str, + choices=("h5", "saved-model"), + default="h5", + group=_("inference"), + help=_("R|The format to save the model as. Note: Only used for 'inference' job." + "\nL|'h5' - Standard Keras H5 format. Does not store any custom layer " + "information. Layers will need to be loaded from Faceswap to use." + "\nL|'saved-model' - Tensorflow's Saved Model format. Contains all information " + "required to load the model outside of Faceswap."))) + argument_list.append(dict( + opts=("-s", "--swap-model"), + action="store_true", + dest="swap_model", + default=False, + group=_("inference"), + help=_("Only used for 'inference' job. Generate the inference model for B -> A " + "instead of A -> B."))) + return argument_list diff --git a/tools/model/model.py b/tools/model/model.py new file mode 100644 index 0000000000..99d49a02d5 --- /dev/null +++ b/tools/model/model.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +""" Tool to restore models from backup """ + +import logging +import os +import sys +from typing import Any, Tuple, TYPE_CHECKING, Union + +import numpy as np +import tensorflow as tf + +from lib.model.backup_restore import Backup +from lib.utils import get_backend + +# Import the following libs for custom objects +from lib.model import initializers, layers, normalization # noqa # pylint:disable=unused-import +from plugins.train.model._base.model import _Inference + +if get_backend() == "amd": + import keras +else: + from tensorflow import keras + + +if TYPE_CHECKING: + import argparse + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class Model(): # pylint:disable=too-few-public-methods + """ Tool to perform actions on a model file. + + Parameters + ---------- + :class:`argparse.Namespace` + The command line arguments calling the model tool + """ + def __init__(self, arguments: 'argparse.Namespace') -> None: + logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) + self._configure_tensorflow() + self._model_dir = self._check_folder(arguments.model_dir) + self._job = self._get_job(arguments) + + @classmethod + def _configure_tensorflow(cls) -> None: + """ Disable eager execution and force Tensorflow into CPU mode. """ + if get_backend() == "amd": + return + tf.config.set_visible_devices([], device_type="GPU") + tf.compat.v1.disable_eager_execution() + + @classmethod + def _get_job(cls, arguments: "argparse.Namespace") -> Any: + """ Get the correct object that holds the selected job. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The command line arguments received for the Model tool which will be used to initiate + the selected job + + Returns + ------- + class + The object that will perform the selected job + """ + jobs = {"inference": Inference, + "nan-scan": NaNScan, + "restore": Restore} + return jobs[arguments.job](arguments) + + @classmethod + def _check_folder(cls, model_dir: str) -> str: + """ Check that the passed in model folder exists and contains a valid model. + + If the passed in value fails any checks, process exits. + + Parameters + ---------- + model_dir: str + The model folder to be checked + + Returns + ------- + str + The confirmed location of the model folder. + """ + if not os.path.exists(model_dir): + logger.error("Model folder does not exist: '%s'", model_dir) + sys.exit(1) + + chkfiles = [fname + for fname in os.listdir(model_dir) + if fname.endswith(".h5") + and not os.path.splitext(fname)[0].endswith("_inference")] + + if not chkfiles: + logger.error("Could not find a model in the supplied folder: '%s'", model_dir) + sys.exit(1) + + if len(chkfiles) > 1: + logger.error("More than one model file found in the model folder: '%s'", model_dir) + sys.exit(1) + + model_name = os.path.splitext(chkfiles[0])[0].title() + logger.info("%s Model found", model_name) + return model_dir + + def process(self) -> None: + """ Call the selected model job.""" + self._job.process() + + +class Inference(): # pylint:disable=too-few-public-methods + """ Save an inference model from a trained Faceswap model. + + Parameters + ---------- + :class:`argparse.Namespace` + The command line arguments calling the model tool + """ + def __init__(self, arguments: "argparse.Namespace") -> None: + self._switch = arguments.swap_model + self._format = arguments.format + self._input_file, self._output_file = self._get_output_file(arguments.model_dir) + + def _get_output_file(self, model_dir: str) -> Tuple[str, str]: + """ Obtain the full path for the output model file/folder + + Parameters + ---------- + model_dir: str + The full path to the folder containing the Faceswap trained model .h5 file + + Returns + ------- + str + The full path to the source model file + str + The full path to the inference model save location + """ + model_name = next(fname for fname in os.listdir(model_dir) if fname.endswith(".h5")) + in_path = os.path.join(model_dir, model_name) + logger.debug("Model input path: '%s'", in_path) + + model_name = f"{os.path.splitext(model_name)[0]}_inference" + model_name = f"{model_name}.h5" if self._format == "h5" else model_name + out_path = os.path.join(model_dir, model_name) + logger.debug("Inference output path: '%s'", out_path) + return in_path, out_path + + def process(self) -> None: + """ Run the inference model creation process. """ + logger.info("Loading model '%s'", self._input_file) + model = keras.models.load_model(self._input_file, compile=False) + logger.info("Creating inference model...") + inference = _Inference(model, self._switch).model + logger.info("Saving to: '%s'", self._output_file) + inference.save(self._output_file) + + +class NaNScan(): # pylint:disable=too-few-public-methods + """ Tool to scan for NaN and Infs in model weights. + + Parameters + ---------- + :class:`argparse.Namespace` + The command line arguments calling the model tool + """ + def __init__(self, arguments: "argparse.Namespace") -> None: + logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) + self._model_file = self._get_model_filename(arguments.model_dir) + + @classmethod + def _get_model_filename(cls, model_dir: str) -> str: + """ Obtain the full path the model's .h5 file. + + Parameters + ---------- + model_dir: str + The full path to the folder containing the model file + + Returns + ------- + str + The full path to the saved model file + """ + model_file = next(fname for fname in os.listdir(model_dir) if fname.endswith(".h5")) + return os.path.join(model_dir, model_file) + + def _parse_weights(self, + layer: Union[keras.models.Model, keras.layers.Layer]) -> dict: + """ Recursively pass through sub-models to scan layer weights""" + weights = layer.get_weights() + logger.debug("Processing weights for layer '%s', length: '%s'", + layer.name, len(weights)) + + if not weights: + logger.debug("Skipping layer with no weights: %s", layer.name) + return {} + + if hasattr(layer, "layers"): # Must be a submodel + retval = {} + for lyr in layer.layers: + info = self._parse_weights(lyr) + if not info: + continue + retval[lyr.name] = info + return retval + + nans = sum(np.count_nonzero(np.isnan(w)) for w in weights) + infs = sum(np.count_nonzero(np.isinf(w)) for w in weights) + + if nans + infs == 0: + return {} + return dict(nans=nans, infs=infs) + + def _parse_output(self, errors: dict, indent: int = 0) -> None: + """ Parse the output of the errors dictionary and print a pretty summary. + + Parameters + ---------- + errors: dict + The nested dictionary of errors found when parsing the weights + + indent: int, optional + How far should the current printed line be indented. Default: `0` + """ + for key, val in errors.items(): + logline = f"|{'--' * indent} " + logline += key.ljust(50 - len(logline)) + if isinstance(val, dict) and "nans" not in val: + logger.info(logline) + self._parse_output(val, indent + 1) + elif isinstance(val, dict) and "nans" in val: + logline += f"nans: {val['nans']}, infs: {val['infs']}" + logger.info(logline.ljust(30)) + + def process(self) -> None: + """ Scan the loaded model for NaNs and Infs and output summary. """ + logger.info("Loading model...") + model = keras.models.load_model(self._model_file, compile=False) + logger.info("Parsing weights for invalid values...") + errors = self._parse_weights(model) + + if not errors: + logger.info("No invalid values found in model: '%s'", self._model_file) + sys.exit(1) + + logger.info("Invalid values found in model: %s", self._model_file) + self._parse_output(errors) + + +class Restore(): # pylint:disable=too-few-public-methods + """ Restore a model from backup. + + Parameters + ---------- + :class:`argparse.Namespace` + The command line arguments calling the model tool + """ + def __init__(self, arguments: "argparse.Namespace") -> None: + logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) + self._model_dir = arguments.model_dir + self._model_name = self._get_model_name() + + def process(self) -> None: + """ Perform the Restore process """ + logger.info("Starting Model Restore...") + backup = Backup(self._model_dir, self._model_name) + backup.restore() + logger.info("Completed Model Restore") + + def _get_model_name(self) -> str: + """ Additional checks to make sure that a backup exists in the model location. """ + bkfiles = [fname for fname in os.listdir(self._model_dir) if fname.endswith(".bk")] + if not bkfiles: + logger.error("Could not find any backup files in the supplied folder: '%s'", + self._model_dir) + sys.exit(1) + logger.verbose("Backup files: %s)", bkfiles) # type:ignore + + model_name = next(fname for fname in bkfiles if fname.endswith(".h5.bk")) + return model_name[:-6] diff --git a/tools/restore/cli.py b/tools/restore/cli.py deleted file mode 100644 index fe3ca9aa77..0000000000 --- a/tools/restore/cli.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -""" Command Line Arguments for tools """ -import gettext - -from lib.cli.args import FaceSwapArgs -from lib.cli.actions import DirFullPaths - - -# LOCALES -_LANG = gettext.translation("tools.restore.cli", localedir="locales", fallback=True) -_ = _LANG.gettext - -_HELPTEXT = _("This command lets you restore models from backup.") - - -class RestoreArgs(FaceSwapArgs): - """ Class to restore model files from backup """ - - @staticmethod - def get_info(): - """ Return command information """ - return _("A tool for restoring models from backup (.bk) files") - - @staticmethod - def get_argument_list(): - """ Put the arguments in a list so that they are accessible from both argparse and gui """ - argument_list = list() - argument_list.append(dict( - opts=("-m", "--model-dir"), - action=DirFullPaths, - dest="model_dir", - required=True, - help=_("Model directory. A directory containing the model you wish to restore from " - "backup."))) - return argument_list diff --git a/tools/restore/restore.py b/tools/restore/restore.py deleted file mode 100644 index 497a254d51..0000000000 --- a/tools/restore/restore.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env python3 -""" Tool to restore models from backup """ - -import logging -import os -import sys - -from lib.model.backup_restore import Backup - -logger = logging.getLogger(__name__) # pylint: disable=invalid-name - - -class Restore(): - """ Restore a model from backup """ - - def __init__(self, arguments): - logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) - self.model_dir = arguments.model_dir - self.model_name = None - - def process(self): - """ Perform the Restore process """ - logger.info("Starting Model Restore...") - self.validate() - backup = Backup(self.model_dir, self.model_name) - backup.restore() - logger.info("Completed Model Restore") - - def validate(self): - """ Make sure there is only one model in the target folder """ - if not os.path.exists(self.model_dir): - logger.error("Folder does not exist: '%s'", self.model_dir) - sys.exit(1) - chkfiles = [fname for fname in os.listdir(self.model_dir) if fname.endswith("_state.json")] - bkfiles = [fname for fname in os.listdir(self.model_dir) if fname.endswith(".bk")] - if not chkfiles: - logger.error("Could not find a model in the supplied folder: '%s'", self.model_dir) - sys.exit(1) - if len(chkfiles) > 1: - logger.error("More than one model found in the supplied folder: '%s'", self.model_dir) - sys.exit(1) - if not bkfiles: - logger.error("Could not find any backup files in the supplied folder: '%s'", - self.model_dir) - sys.exit(1) - self.model_name = chkfiles[0].replace("_state.json", "") - logger.info("%s Model found", self.model_name.title()) - logger.verbose("Backup files: %s)", bkfiles) From a3825dc58f88af7b16afaf4a1b960e434ee744d9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 29 Jun 2022 02:29:22 +0100 Subject: [PATCH 653/981] Bugfix: prevent OOM when just viewing summary --- plugins/train/model/_base/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index dfcd450aa3..12570b26a2 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -293,7 +293,7 @@ def build(self) -> None: else: self._validate_input_shape() inputs = self._get_inputs() - if not self._settings.use_mixed_precision: + if not self._settings.use_mixed_precision and not is_summary: # Store layer names which can be switched to mixed precision self._state.add_mixed_precision_layers( self._settings.get_mixed_precision_layers(self.build_model, inputs)) From 6e0360150dbef207cf16a07aa17f5b9c18e8c1ad Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 29 Jun 2022 18:10:47 +0100 Subject: [PATCH 654/981] bugfix: Correctly show installed TF Version on AMD error --- lib/cli/launcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index feb352435b..584e0f395d 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -90,7 +90,7 @@ def _test_for_tf_version(self): self._handle_import_error(msg) if backend == "amd" and tf_ver != amd_ver: msg = (f"The supported Tensorflow version for AMD cards is {amd_ver} but you have " - "version {tf_ver} installed. Please install the correct version.") + f"version {tf_ver} installed. Please install the correct version.") self._handle_import_error(msg) logger.debug("Installed Tensorflow Version: %s", tf_ver) From e59f34982ba5a92cf0679363a43646deee5215e5 Mon Sep 17 00:00:00 2001 From: Agilan Date: Thu, 30 Jun 2022 20:07:46 +1000 Subject: [PATCH 655/981] update pipeline to trigger on pull_request, not trigger on non-code changes, add caching while installing dependencies (#1241) Was going to close as 'busy work' ;) However there are benefits here. --- .github/workflows/pytest.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index b456d37492..cc020501e6 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,6 +1,11 @@ -name: Python package +name: ci/gh-actions/pytest -on: [push] +on: + push: + pull_request: + paths-ignore: + - docs/** + - "**/README.md" jobs: build: @@ -21,10 +26,12 @@ jobs: backend: amd steps: - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: './requirements/requirements_${{ matrix.backend }}.txt' - name: Install dependencies run: | python -m pip install --upgrade pip From 91fecc47b2157d684ab9c219a860df51543222a3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 30 Jun 2022 13:34:10 +0100 Subject: [PATCH 656/981] lib.Utils - add DPI detector --- lib/utils.py | 143 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 87 insertions(+), 56 deletions(-) diff --git a/lib/utils.py b/lib/utils.py index 143f5f25ac..e57e3b653d 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -5,6 +5,7 @@ import logging import os import sys +import tkinter as tk import urllib import warnings import zipfile @@ -12,9 +13,18 @@ from re import finditer from multiprocessing import current_process from socket import timeout as socket_timeout, error as socket_error +from typing import cast, List, Optional, Union, TYPE_CHECKING from tqdm import tqdm +if sys.version_info < (3, 8): + from typing_extensions import get_args, Literal +else: + from typing import get_args, Literal + +if TYPE_CHECKING: + from http.client import HTTPResponse + # Global variables _image_extensions = [ # pylint:disable=invalid-name ".bmp", ".jpeg", ".jpg", ".png", ".tif", ".tiff"] @@ -22,6 +32,7 @@ ".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv", ".ts", ".vob"] _TF_VERS = None +ValidBackends = Literal["amd", "nvidia", "cpu", "apple_silicon"] class _Backend(): # pylint:disable=too-few-public-methods @@ -29,13 +40,14 @@ class _Backend(): # pylint:disable=too-few-public-methods Variable. If file doesn't exist and a variable hasn't been set, create the config file. """ - def __init__(self): + def __init__(self) -> None: self._backends = {"1": "amd", "2": "cpu", "3": "nvidia", "4": "apple_silicon"} + self._valid_backends = list(self._backends.values()) self._config_file = self._get_config_file() self.backend = self._get_backend() @classmethod - def _get_config_file(cls): + def _get_config_file(cls) -> str: """ Obtain the location of the main Faceswap configuration file. Returns @@ -47,7 +59,7 @@ def _get_config_file(cls): config_file = os.path.join(pypath, "config", ".faceswap") return config_file - def _get_backend(self): + def _get_backend(self) -> ValidBackends: """ Return the backend from either the `FACESWAP_BACKEND` Environment Variable or from the :file:`config/.faceswap` configuration file. If neither of these exist, prompt the user to select a backend. @@ -59,7 +71,9 @@ def _get_backend(self): """ # Check if environment variable is set, if so use that if "FACESWAP_BACKEND" in os.environ: - fs_backend = os.environ["FACESWAP_BACKEND"].lower() + fs_backend = cast(ValidBackends, os.environ["FACESWAP_BACKEND"].lower()) + assert fs_backend in get_args(ValidBackends), ( + f"Faceswap backend must be one of {get_args(ValidBackends)}") print(f"Setting Faceswap backend from environment variable to {fs_backend.upper()}") return fs_backend # Intercept for sphinx docs build @@ -75,14 +89,14 @@ def _get_backend(self): except json.decoder.JSONDecodeError: self._configure_backend() continue - fs_backend = config.get("backend", None) - if fs_backend is None or fs_backend.lower() not in self._backends.values(): + fs_backend = config.get("backend", "").lower() + if not fs_backend or fs_backend not in self._backends.values(): fs_backend = self._configure_backend() if current_process().name == "MainProcess": print(f"Setting Faceswap backend to {fs_backend.upper()}") - return fs_backend.lower() + return fs_backend - def _configure_backend(self): + def _configure_backend(self) -> ValidBackends: """ Get user input to select the backend that Faceswap should use. Returns @@ -97,7 +111,7 @@ def _configure_backend(self): print(f"'{selection}' is not a valid selection. Please try again") continue break - fs_backend = self._backends[selection].lower() + fs_backend = cast(ValidBackends, self._backends[selection].lower()) config = {"backend": fs_backend} with open(self._config_file, "w", encoding="utf8") as cnf: json.dump(config, cnf) @@ -105,10 +119,10 @@ def _configure_backend(self): return fs_backend -_FS_BACKEND = _Backend().backend +_FS_BACKEND: ValidBackends = _Backend().backend -def get_backend(): +def get_backend() -> ValidBackends: """ Get the backend that Faceswap is currently configured to use. Returns @@ -119,7 +133,7 @@ def get_backend(): return _FS_BACKEND -def set_backend(backend): +def set_backend(backend: str) -> None: """ Override the configured backend with the given backend. Parameters @@ -128,10 +142,11 @@ def set_backend(backend): The backend to set faceswap to """ global _FS_BACKEND # pylint:disable=global-statement - _FS_BACKEND = backend.lower() + backend = cast(ValidBackends, backend.lower()) + _FS_BACKEND = backend -def get_tf_version(): +def get_tf_version() -> float: """ Obtain the major.minor version of currently installed Tensorflow. Returns @@ -146,7 +161,7 @@ def get_tf_version(): return _TF_VERS -def get_folder(path, make_folder=True): +def get_folder(path: str, make_folder: bool = True) -> str: """ Return a path to a folder, creating it if it doesn't exist Parameters @@ -167,13 +182,13 @@ def get_folder(path, make_folder=True): logger.debug("Requested path: '%s'", path) if not make_folder and not os.path.isdir(path): logger.debug("%s does not exist", path) - return None + return "" os.makedirs(path, exist_ok=True) logger.debug("Returning: '%s'", path) return path -def get_image_paths(directory, extension=None): +def get_image_paths(directory: str, extension: Optional[str] = None) -> List[str]: """ Obtain a list of full paths that reside within a folder. Parameters @@ -198,18 +213,31 @@ def get_image_paths(directory, extension=None): dir_scanned = sorted(os.scandir(directory), key=lambda x: x.name) logger.debug("Scanned Folder contains %s files", len(dir_scanned)) - logger.trace("Scanned Folder Contents: %s", dir_scanned) + logger.trace("Scanned Folder Contents: %s", dir_scanned) # type:ignore for chkfile in dir_scanned: if any(chkfile.name.lower().endswith(ext) for ext in image_extensions): - logger.trace("Adding '%s' to image list", chkfile.path) + logger.trace("Adding '%s' to image list", chkfile.path) # type:ignore dir_contents.append(chkfile.path) logger.debug("Returning %s images", len(dir_contents)) return dir_contents -def convert_to_secs(*args): +def get_dpi() -> float: + """ Obtain the DPI of the running screen. + + Returns + ------- + int + The obtain dots per inch of the running monitor + """ + root = tk.Tk() + dpi = root.winfo_fpixels('1i') + return float(dpi) + + +def convert_to_secs(*args: int) -> int: """ Convert a time to seconds. Parameters @@ -232,11 +260,12 @@ def convert_to_secs(*args): retval = 60 * float(args[0]) + float(args[1]) elif len(args) == 3: retval = 3600 * float(args[0]) + 60 * float(args[1]) + float(args[2]) + retval = int(retval) logger.debug("to secs: %s", retval) return retval -def full_path_split(path): +def full_path_split(path: str) -> List[str]: """ Split a full path to a location into all of it's separate components. Parameters @@ -256,7 +285,7 @@ def full_path_split(path): >>> ["foo", "baz", "bar"] """ logger = logging.getLogger(__name__) # pylint:disable=invalid-name - allparts = [] + allparts: List[str] = [] while True: parts = os.path.split(path) if parts[0] == path: # sentinel for absolute paths @@ -267,11 +296,11 @@ def full_path_split(path): break path = parts[0] allparts.insert(0, parts[1]) - logger.trace("path: %s, allparts: %s", path, allparts) + logger.trace("path: %s, allparts: %s", path, allparts) # type:ignore return allparts -def set_system_verbosity(log_level): +def set_system_verbosity(log_level: str): """ Set the verbosity level of tensorflow and suppresses future and deprecation warnings from any modules @@ -299,7 +328,7 @@ def set_system_verbosity(log_level): warnings.simplefilter(action='ignore', category=warncat) -def deprecation_warning(function, additional_info=None): +def deprecation_warning(function: str, additional_info: Optional[str] = None) -> None: """ Log at warning level that a function will be removed in a future update. Parameters @@ -317,7 +346,7 @@ def deprecation_warning(function, additional_info=None): logger.warning(msg) -def camel_case_split(identifier): +def camel_case_split(identifier: str) -> List[str]: """ Split a camel case name Parameters @@ -341,7 +370,7 @@ def camel_case_split(identifier): return [m.group(0) for m in matches] -def safe_shutdown(got_error=False): +def safe_shutdown(got_error: bool = False) -> None: """ Close all tracked queues and threads in event of crash or on shut down. Parameters @@ -394,7 +423,7 @@ class GetModel(): # pylint:disable=too-few-public-methods ,"resnet_ssd_v1.prototext"]` """ - def __init__(self, model_filename, git_model_id): + def __init__(self, model_filename: Union[str, List[str]], git_model_id: int) -> None: self.logger = logging.getLogger(__name__) if not isinstance(model_filename, list): model_filename = [model_filename] @@ -407,69 +436,69 @@ def __init__(self, model_filename, git_model_id): self._get() @property - def _model_full_name(self): + def _model_full_name(self) -> str: """ str: The full model name from the filename(s). """ common_prefix = os.path.commonprefix(self._model_filename) retval = os.path.splitext(common_prefix)[0] - self.logger.trace(retval) + self.logger.trace(retval) # type: ignore return retval @property - def _model_name(self): + def _model_name(self) -> str: """ str: The model name from the model's full name. """ retval = self._model_full_name[:self._model_full_name.rfind("_")] - self.logger.trace(retval) + self.logger.trace(retval) # type: ignore return retval @property - def _model_version(self): + def _model_version(self) -> int: """ int: The model's version number from the model full name. """ retval = int(self._model_full_name[self._model_full_name.rfind("_") + 2:]) - self.logger.trace(retval) + self.logger.trace(retval) # type: ignore return retval @property - def model_path(self): - """ str: The model path(s) in the cache folder. """ - retval = [os.path.join(self._cache_dir, fname) for fname in self._model_filename] - retval = retval[0] if len(retval) == 1 else retval - self.logger.trace(retval) + def model_path(self) -> Union[str, List[str]]: + """ str or list: The model path(s) in the cache folder. """ + paths = [os.path.join(self._cache_dir, fname) for fname in self._model_filename] + retval: Union[str, List[str]] = paths[0] if len(paths) == 1 else paths + self.logger.trace(retval) # type: ignore return retval @property - def _model_zip_path(self): + def _model_zip_path(self) -> str: """ str: The full path to downloaded zip file. """ retval = os.path.join(self._cache_dir, f"{self._model_full_name}.zip") - self.logger.trace(retval) + self.logger.trace(retval) # type: ignore return retval @property - def _model_exists(self): + def _model_exists(self) -> bool: """ bool: ``True`` if the model exists in the cache folder otherwise ``False``. """ if isinstance(self.model_path, list): retval = all(os.path.exists(pth) for pth in self.model_path) else: retval = os.path.exists(self.model_path) - self.logger.trace(retval) + self.logger.trace(retval) # type: ignore return retval @property - def _url_download(self): + def _url_download(self) -> str: """ strL Base download URL for models. """ tag = f"v{self._git_model_id}.{self._model_version}" retval = f"{self._url_base}/{tag}/{self._model_full_name}.zip" - self.logger.trace("Download url: %s", retval) + self.logger.trace("Download url: %s", retval) # type: ignore return retval @property - def _url_partial_size(self): - """ float: How many bytes have already been downloaded. """ + def _url_partial_size(self) -> int: + """ int: How many bytes have already been downloaded. """ zip_file = self._model_zip_path retval = os.path.getsize(zip_file) if os.path.exists(zip_file) else 0 - self.logger.trace(retval) + self.logger.trace(retval) # type: ignore return retval - def _get(self): + def _get(self) -> None: """ Check the model exists, if not, download the model, unzip it and place it in the model's cache folder. """ if self._model_exists: @@ -479,7 +508,7 @@ def _get(self): self._unzip_model() os.remove(self._model_zip_path) - def _download_model(self): + def _download_model(self) -> None: """ Download the model zip from github to the cache folder. """ self.logger.info("Downloading model: '%s' from: %s", self._model_name, self._url_download) for attempt in range(self._retries): @@ -507,17 +536,19 @@ def _download_model(self): self._url_download, self._cache_dir) sys.exit(1) - def _write_zipfile(self, response, downloaded_size): + def _write_zipfile(self, response: "HTTPResponse", downloaded_size: int) -> None: """ Write the model zip file to disk. Parameters ---------- - response: :class:`urllib.request.urlopen` + response: :class:`http.client.HTTPResponse` The response from the model download task downloaded_size: int The amount of bytes downloaded so far """ - length = int(response.getheader("content-length")) + downloaded_size + content_length = response.getheader("content-length") + content_length = "0" if content_length is None else content_length + length = int(content_length) + downloaded_size if length == downloaded_size: self.logger.info("Zip already exists. Skipping download") return @@ -538,7 +569,7 @@ def _write_zipfile(self, response, downloaded_size): out_file.write(buffer) pbar.close() - def _unzip_model(self): + def _unzip_model(self) -> None: """ Unzip the model file to the cache folder """ self.logger.info("Extracting: '%s'", self._model_name) try: @@ -548,12 +579,12 @@ def _unzip_model(self): self.logger.error("Unable to extract model file: %s", str(err)) sys.exit(1) - def _write_model(self, zip_file): + def _write_model(self, zip_file: zipfile.ZipFile) -> None: """ Extract files from zip file and write, with progress bar. Parameters ---------- - zip_file: str + zip_file: :class:`zipfile.ZipFile` The downloaded model zip file """ length = sum(f.file_size for f in zip_file.infolist()) From b1a8183ab42334dcaa3662870f12a9836ed94222 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 30 Jun 2022 18:41:58 +0100 Subject: [PATCH 657/981] scripts.train - type checking --- plugins/train/trainer/_base.py | 3 +- scripts/train.py | 77 ++++++++++++++++++++-------------- setup.cfg | 12 +++--- 3 files changed, 54 insertions(+), 38 deletions(-) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index cd0640867d..155b969f34 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -183,7 +183,6 @@ def train_one_step(self, viewer, timelapse_kwargs): self._model.state.increment_iterations() logger.trace("Training one step: (iteration: %s)", self._model.iterations) do_preview = viewer is not None - do_timelapse = timelapse_kwargs is not None snapshot_interval = self._model.command_line_arguments.snapshot_interval do_snapshot = (snapshot_interval != 0 and self._model.iterations - 1 >= snapshot_interval and @@ -236,7 +235,7 @@ def train_one_step(self, viewer, timelapse_kwargs): "Training - 'S': Save Now. 'R': Refresh Preview. 'M': Toggle Mask. " "'ENTER': Save and Quit") - if do_timelapse: + if timelapse_kwargs: self._timelapse.output_timelapse(timelapse_kwargs) def _log_tensorboard(self, loss): diff --git a/scripts/train.py b/scripts/train.py index cd768364b1..c095336d87 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -7,8 +7,10 @@ from threading import Lock from time import sleep +from typing import cast, Callable, Dict, List, Optional, TYPE_CHECKING import cv2 +import numpy as np from lib.image import read_image_meta from lib.keypress import KBHit @@ -16,6 +18,16 @@ from lib.utils import (get_folder, get_image_paths, FaceswapError, _image_extensions) from plugins.plugin_loader import PluginLoader +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + +if TYPE_CHECKING: + import argparse + from plugins.train.model._base import ModelBase + from plugins.train.trainer._base import TrainerBase + logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -34,7 +46,7 @@ class Train(): # pylint:disable=too-few-public-methods The arguments to be passed to the training process as generated from Faceswap's command line arguments """ - def __init__(self, arguments): + def __init__(self, arguments: "argparse.Namespace") -> None: logger.debug("Initializing %s: (args: %s", self.__class__.__name__, arguments) self._args = arguments if self._args.summary: @@ -47,16 +59,16 @@ def __init__(self, arguments): os.path.realpath(os.path.dirname(sys.argv[0])), "lib", "gui", ".cache") self._gui_triggers = dict(update=os.path.join(gui_cache, ".preview_trigger"), mask_toggle=os.path.join(gui_cache, ".preview_mask_toggle")) - self._stop = False - self._save_now = False - self._toggle_preview_mask = False - self._refresh_preview = False - self._preview_buffer = {} + self._stop: bool = False + self._save_now: bool = False + self._toggle_preview_mask: bool = False + self._refresh_preview: bool = False + self._preview_buffer: Dict[str, np.ndarray] = {} self._lock = Lock() logger.debug("Initialized %s", self.__class__.__name__) - def _get_images(self): + def _get_images(self) -> Dict[Literal["a", "b"], List[str]]: """ Check the image folders exist and contains valid extracted faces. Obtain image paths. Returns @@ -68,6 +80,7 @@ def _get_images(self): logger.debug("Getting image paths") images = {} for side in ("a", "b"): + side = cast(Literal["a", "b"], side) image_dir = getattr(self._args, f"input_{side}") if not os.path.isdir(image_dir): logger.error("Error: '%s' does not exist", image_dir) @@ -96,7 +109,7 @@ def _get_images(self): return images @classmethod - def _validate_image_counts(cls, images): + def _validate_image_counts(cls, images: Dict[Literal["a", "b"], List[str]]) -> None: """ Validate that there are sufficient images to commence training without raising an error. @@ -124,7 +137,7 @@ def _validate_image_counts(cls, images): "Results are likely to be poor.") logger.warning(msg) - def _set_timelapse(self): + def _set_timelapse(self) -> Dict[Literal["input_a", "input_b", "output"], str]: """ Set time-lapse paths if requested. Returns @@ -136,7 +149,7 @@ def _set_timelapse(self): if (not self._args.timelapse_input_a and not self._args.timelapse_input_b and not self._args.timelapse_output): - return None + return {} if (not self._args.timelapse_input_a or not self._args.timelapse_input_b or not self._args.timelapse_output): @@ -147,6 +160,7 @@ def _set_timelapse(self): timelapse_output = get_folder(self._args.timelapse_output) for side in ("a", "b"): + side = cast(Literal["a", "b"], side) folder = getattr(self._args, f"timelapse_input_{side}") if folder is not None and not os.path.isdir(folder): raise FaceswapError(f"The Timelapse path '{folder}' does not exist") @@ -168,13 +182,14 @@ def _set_timelapse(self): raise FaceswapError(f"All images in the Timelapse folder '{folder}' must exist in " f"the training folder '{training_folder}'") - kwargs = {"input_a": self._args.timelapse_input_a, - "input_b": self._args.timelapse_input_b, - "output": timelapse_output} + TKey = Literal["input_a", "input_b", "output"] + kwargs = {cast(TKey, "input_a"): self._args.timelapse_input_a, + cast(TKey, "input_b"): self._args.timelapse_input_b, + cast(TKey, "output"): timelapse_output} logger.debug("Timelapse enabled: %s", kwargs) return kwargs - def process(self): + def process(self) -> None: """ The entry point for triggering the Training Process. Should only be called from :class:`lib.cli.launcher.ScriptExecutor` @@ -190,7 +205,7 @@ def process(self): self._end_thread(thread, err) logger.debug("Completed Training Process") - def _start_thread(self): + def _start_thread(self) -> MultiThread: """ Put the :func:`_training` into a background thread so we can keep control. Returns @@ -204,7 +219,7 @@ def _start_thread(self): logger.debug("Launched Trainer thread") return thread - def _end_thread(self, thread, err): + def _end_thread(self, thread: MultiThread, err: bool) -> None: """ Output message and join thread back to main on termination. Parameters @@ -231,7 +246,7 @@ def _end_thread(self, thread, err): sys.stdout.flush() logger.debug("Ended training thread") - def _training(self): + def _training(self) -> None: """ The training process to be run inside a thread. """ try: sleep(1) # Let preview instructions flush out to logger @@ -251,7 +266,7 @@ def _training(self): except Exception as err: raise err - def _load_model(self): + def _load_model(self) -> "ModelBase": """ Load the model requested for training. Returns @@ -261,7 +276,7 @@ def _load_model(self): """ logger.debug("Loading Model") model_dir = get_folder(self._args.model_dir) - model = PluginLoader.get_model(self._args.trainer)( + model: "ModelBase" = PluginLoader.get_model(self._args.trainer)( model_dir, self._args, predict=False) @@ -269,7 +284,7 @@ def _load_model(self): logger.debug("Loaded Model") return model - def _load_trainer(self, model): + def _load_trainer(self, model: "ModelBase") -> "TrainerBase": """ Load the trainer requested for training. Parameters @@ -283,15 +298,15 @@ def _load_trainer(self, model): The requested model trainer plugin """ logger.debug("Loading Trainer") - trainer = PluginLoader.get_trainer(model.trainer) - trainer = trainer(model, - self._images, - self._args.batch_size, - self._args.configfile) + base = PluginLoader.get_trainer(model.trainer) + trainer: "TrainerBase" = base(model, + self._images, + self._args.batch_size, + self._args.configfile) logger.debug("Loaded Trainer") return trainer - def _run_training_cycle(self, model, trainer): + def _run_training_cycle(self, model: "ModelBase", trainer: "TrainerBase") -> None: """ Perform the training cycle. Handles the background training, updating previews/time-lapse on each save interval, @@ -306,12 +321,12 @@ def _run_training_cycle(self, model, trainer): """ logger.debug("Running Training Cycle") if self._args.write_image or self._args.redirect_gui or self._args.preview: - display_func = self._show + display_func: Optional[Callable] = self._show else: display_func = None for iteration in range(1, self._args.iterations + 1): - logger.trace("Training iteration: %s", iteration) + logger.trace("Training iteration: %s", iteration) # type:ignore save_iteration = iteration % self._args.save_interval == 0 or iteration == 1 if self._toggle_preview_mask: @@ -323,7 +338,7 @@ def _run_training_cycle(self, model, trainer): viewer = display_func else: viewer = None - timelapse = self._timelapse if save_iteration else None + timelapse = self._timelapse if save_iteration else {} trainer.train_one_step(viewer, timelapse) if self._stop: logger.debug("Stop received. Terminating") @@ -347,7 +362,7 @@ def _run_training_cycle(self, model, trainer): trainer.clear_tensorboard() self._stop = True - def _output_startup_info(self): + def _output_startup_info(self) -> None: """ Print the startup information to the console. """ logger.debug("Launching Monitor") logger.info("===================================================") @@ -530,7 +545,7 @@ def _monitor(self, thread: MultiThread) -> bool: logger.debug("Closed Monitor") return err - def _show(self, image, name=""): + def _show(self, image: np.ndarray, name: str = "") -> None: """ Generate the preview and write preview file output. Handles the output and display of preview images. diff --git a/setup.cfg b/setup.cfg index 4b410f72fc..a87c4be7d8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,19 +6,21 @@ count = True exclude = .git, __pycache__ [mypy] -[mypy-tensorflow.*] +[mypy-cv2.*] ignore_missing_imports = True [mypy-keras.*] ignore_missing_imports = True -[mypy-psutil.*] -ignore_missing_imports = True [mypy-plaidml.*] ignore_missing_imports = True -[mypy-tqdm.*] +[mypy-psutil.*] ignore_missing_imports = True [mypy-pynvml.*] ignore_missing_imports = True [mypy-pynvx.*] ignore_missing_imports = True -[mypy-cv2.*] +[mypy-tensorflow.*] +ignore_missing_imports = True +[mypy-tensorflow_probability.*] +ignore_missing_imports = True +[mypy-tqdm.*] ignore_missing_imports = True From 7b9fc0454d982a2425ec44e90e5b05a87d149953 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 1 Jul 2022 13:07:01 +0100 Subject: [PATCH 658/981] Live Preview - Replace cv2 with matplotlib viewer --- lib/cli/args.py | 95 ++++--- locales/es/LC_MESSAGES/lib.cli.args.po | 7 - locales/lib.cli.args.pot | 4 - locales/ru/LC_MESSAGES/lib.cli.args.po | 7 - plugins/train/trainer/_base.py | 16 +- scripts/train.py | 363 +++++++++++++++++-------- setup.cfg | 6 + 7 files changed, 317 insertions(+), 181 deletions(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index 3f9e62fa5c..dc0766db85 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -9,6 +9,8 @@ import sys import textwrap +from typing import Any, Dict, List, NoReturn, Optional + from lib.utils import get_backend from lib.gpu_stats import GPUStats @@ -28,9 +30,9 @@ class FullHelpArgumentParser(argparse.ArgumentParser): """ Extends :class:`argparse.ArgumentParser` to output full help on bad arguments. """ - def error(self, message): + def error(self, message: str) -> NoReturn: self.print_help(sys.stderr) - self.exit(2, "{}: error: {}\n".format(self.prog, message)) + self.exit(2, f"{self.prog}: error: {message}\n") class SmartFormatter(argparse.HelpFormatter): @@ -45,11 +47,15 @@ class SmartFormatter(argparse.HelpFormatter): Prefixing a new line within the help text with "L|" will turn that line into a list item in both the cli help text and the GUI. """ - def __init__(self, prog, indent_increment=2, max_help_position=24, width=None): + def __init__(self, + prog: str, + indent_increment: int = 2, + max_help_position: int = 24, + width: Optional[int] = None) -> None: super().__init__(prog, indent_increment, max_help_position, width) self._whitespace_matcher_limited = re.compile(r'[ \r\f\v]+', re.ASCII) - def _split_lines(self, text, width): + def _split_lines(self, text: str, width: int) -> List[str]: """ Split the given text by the given display width. If the text is not prefixed with "R|" then the standard @@ -62,18 +68,25 @@ def _split_lines(self, text, width): The help text that is to be formatted for display width: int The display width, in characters, for the help text + + Returns + ------- + list + A list of split strings """ if text.startswith("R|"): text = self._whitespace_matcher_limited.sub(' ', text).strip()[2:] - output = list() + output = [] for txt in text.splitlines(): indent = "" if txt.startswith("L|"): indent = " " - txt = " - {}".format(txt[2:]) + txt = f" - {txt[2:]}" output.extend(textwrap.wrap(txt, width, subsequent_indent=indent)) return output - return argparse.HelpFormatter._split_lines(self, text, width) + return argparse.HelpFormatter._split_lines(self, # pylint: disable=protected-access + text, + width) class FaceSwapArgs(): @@ -94,7 +107,10 @@ class FaceSwapArgs(): description: str, optional The description for the given command. Default: "default" """ - def __init__(self, subparser, command, description="default"): + def __init__(self, + subparser: argparse._SubParsersAction, + command: str, + description: str = "default") -> None: self.global_arguments = self._get_global_arguments() self.info = self.get_info() self.argument_list = self.get_argument_list() @@ -108,7 +124,7 @@ def __init__(self, subparser, command, description="default"): self.parser.set_defaults(func=script.execute_script) @staticmethod - def get_info(): + def get_info() -> str: """ Returns the information text for the current command. This function should be overridden with the actual command help text for each @@ -119,10 +135,10 @@ def get_info(): str The information text for this command. """ - return None + return "" @staticmethod - def get_argument_list(): + def get_argument_list() -> List[Dict[str, Any]]: """ Returns the argument list for the current command. The argument list should be a list of dictionaries pertaining to each option for a command. @@ -136,11 +152,11 @@ def get_argument_list(): list The list of command line options for the given command """ - argument_list = [] + argument_list: List[Dict[str, Any]] = [] return argument_list @staticmethod - def get_optional_arguments(): + def get_optional_arguments() -> List[Dict[str, Any]]: """ Returns the optional argument list for the current command. The optional arguments list is not always required, but is used when there are shared @@ -151,11 +167,11 @@ def get_optional_arguments(): list The list of optional command line options for the given command """ - argument_list = [] + argument_list: List[Dict[str, Any]] = [] return argument_list @staticmethod - def _get_global_arguments(): + def _get_global_arguments() -> List[Dict[str, Any]]: """ Returns the global Arguments list that are required for ALL commands in Faceswap. This method should NOT be overridden. @@ -165,7 +181,7 @@ def _get_global_arguments(): list The list of global command line options for all Faceswap commands. """ - global_args = list() + global_args: List[Dict[str, Any]] = [] if _GPUS: global_args.append(dict( opts=("-X", "--exclude-gpus"), @@ -220,16 +236,21 @@ def _get_global_arguments(): return global_args @staticmethod - def _create_parser(subparser, command, description): + def _create_parser(subparser: argparse._SubParsersAction, + command: str, + description: str) -> argparse.ArgumentParser: """ Create the parser for the selected command. Parameters ---------- + subparser: :class:`argparse._SubParsersAction` + The subparser for the given command command: str The faceswap command that is to be executed description: str The description for the given command + Returns ------- :class:`~lib.cli.args.FullHelpArgumentParser` @@ -242,7 +263,7 @@ def _create_parser(subparser, command, description): formatter_class=SmartFormatter) return parser - def _add_arguments(self): + def _add_arguments(self) -> None: """ Parse the list of dictionaries containing the command line arguments and convert to argparse parser arguments. """ options = self.global_arguments + self.argument_list + self.optional_arguments @@ -251,7 +272,7 @@ def _add_arguments(self): kwargs = {key: option[key] for key in option.keys() if key not in ("opts", "group")} self.parser.add_argument(*args, **kwargs) - def _process_suppressions(self): + def _process_suppressions(self) -> None: """ Certain options are only available for certain backends. Suppresses command line options that are not available for the running backend. @@ -281,7 +302,7 @@ class ExtractConvertArgs(FaceSwapArgs): """ @staticmethod - def get_argument_list(): + def get_argument_list() -> List[Dict[str, Any]]: """ Returns the argument list for shared Extract and Convert arguments. Returns @@ -289,7 +310,7 @@ def get_argument_list(): list The list of command line options for the given Extract and Convert """ - argument_list = list() + argument_list: List[Dict[str, Any]] = [] argument_list.append(dict( opts=("-i", "--input-dir"), action=DirOrFileFullPaths, @@ -329,7 +350,7 @@ class ExtractArgs(ExtractConvertArgs): """ @staticmethod - def get_info(): + def get_info() -> str: """ The information text for the Extract command. Returns @@ -341,7 +362,7 @@ def get_info(): "Extraction plugins can be configured in the 'Settings' Menu") @staticmethod - def get_optional_arguments(): + def get_optional_arguments() -> List[Dict[str, Any]]: """ Returns the argument list unique to the Extract command. Returns @@ -355,7 +376,7 @@ def get_optional_arguments(): default_detector = "s3fd" default_aligner = "fan" - argument_list = [] + argument_list: List[Dict[str, Any]] = [] argument_list.append(dict( opts=("-D", "--detector"), action=Radio, @@ -599,7 +620,7 @@ class ConvertArgs(ExtractConvertArgs): """ @staticmethod - def get_info(): + def get_info() -> str: """ The information text for the Convert command. Returns @@ -611,7 +632,7 @@ def get_info(): "Conversion plugins can be configured in the 'Settings' Menu") @staticmethod - def get_optional_arguments(): + def get_optional_arguments() -> List[Dict[str, Any]]: """ Returns the argument list unique to the Convert command. Returns @@ -620,7 +641,7 @@ def get_optional_arguments(): The list of optional command line options for the Convert command """ - argument_list = [] + argument_list: List[Dict[str, Any]] = [] argument_list.append(dict( opts=("-ref", "--reference-video"), action=FileFullPaths, @@ -853,7 +874,7 @@ class TrainArgs(FaceSwapArgs): """ Creates the command line arguments for training. """ @staticmethod - def get_info(): + def get_info() -> str: """ The information text for the Train command. Returns @@ -866,7 +887,7 @@ def get_info(): "Model plugins can be configured in the 'Settings' Menu") @staticmethod - def get_argument_list(): + def get_argument_list() -> List[Dict[str, Any]]: """ Returns the argument list for Train arguments. Returns @@ -874,7 +895,7 @@ def get_argument_list(): list The list of command line options for training """ - argument_list = list() + argument_list: List[Dict[str, Any]] = [] argument_list.append(dict( opts=("-A", "--input-A"), action=DirFullPaths, @@ -1054,16 +1075,6 @@ def get_argument_list(): "selected faces into the timelapse-output folder at every save iteration. If " "the input folders are supplied but no output folder, it will default to your " "model folder /timelapse/"))) - argument_list.append(dict( - opts=("-ps", "--preview-scale"), - action=Slider, - min_max=(25, 200), - rounding=25, - type=int, - dest="preview_scale", - default=100, - group=_("preview"), - help=_("Percentage amount to scale the preview by. 100%% is the model output size."))) argument_list.append(dict( opts=("-p", "--preview"), action="store_true", @@ -1131,7 +1142,7 @@ class GuiArgs(FaceSwapArgs): """ Creates the command line arguments for the GUI. """ @staticmethod - def get_argument_list(): + def get_argument_list() -> List[Dict[str, Any]]: """ Returns the argument list for GUI arguments. Returns @@ -1139,7 +1150,7 @@ def get_argument_list(): list The list of command line options for the GUI """ - argument_list = [] + argument_list: List[Dict[str, Any]] = [] argument_list.append(dict( opts=("-d", "--debug"), action="store_true", diff --git a/locales/es/LC_MESSAGES/lib.cli.args.po b/locales/es/LC_MESSAGES/lib.cli.args.po index 6662ea056e..2455aed5a4 100644 --- a/locales/es/LC_MESSAGES/lib.cli.args.po +++ b/locales/es/LC_MESSAGES/lib.cli.args.po @@ -898,13 +898,6 @@ msgstr "" msgid "preview" msgstr "previsualización" -#: lib/cli/args.py:1066 -msgid "" -"Percentage amount to scale the preview by. 100%% is the model output size." -msgstr "" -"Cantidad porcentual para escalar la vista previa. 100%% es el tamaño de " -"salida del modelo." - #: lib/cli/args.py:1073 msgid "Show training preview output. in a separate window." msgstr "" diff --git a/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index f788ab79c6..eaecdc8baf 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -371,10 +371,6 @@ msgstr "" msgid "preview" msgstr "" -#: lib/cli/args.py:1066 -msgid "Percentage amount to scale the preview by. 100%% is the model output size." -msgstr "" - #: lib/cli/args.py:1073 msgid "Show training preview output. in a separate window." msgstr "" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.po b/locales/ru/LC_MESSAGES/lib.cli.args.po index 666d12ff23..2168f0af79 100644 --- a/locales/ru/LC_MESSAGES/lib.cli.args.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args.po @@ -871,13 +871,6 @@ msgstr "" msgid "preview" msgstr "предварительный просмотр" -#: lib/cli/args.py:1066 -msgid "" -"Percentage amount to scale the preview by. 100%% is the model output size." -msgstr "" -"Величина в процентах, на которую требуется масштабировать предварительный " -"просмотр. 100 %% - размер вывода модели." - #: lib/cli/args.py:1073 msgid "Show training preview output. in a separate window." msgstr "Показывать предварительный просмотр в отдельном окне." diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 155b969f34..6ef6569002 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -78,9 +78,7 @@ def __init__(self, model, images, batch_size, configfile): self._feeder = _Feeder(images, self._model, batch_size, self._config) self._tensorboard = self._set_tensorboard() - self._samples = _Samples(self._model, - self._model.coverage_ratio, - self._model.command_line_arguments.preview_scale / 100) + self._samples = _Samples(self._model, self._model.coverage_ratio) self._timelapse = _Timelapse(self._model, self._model.coverage_ratio, self._config.get("preview_images", 14), @@ -232,8 +230,8 @@ def train_one_step(self, viewer, timelapse_kwargs): samples = self._samples.show_sample() if samples is not None: viewer(samples, - "Training - 'S': Save Now. 'R': Refresh Preview. 'M': Toggle Mask. " - "'ENTER': Save and Quit") + "Training - 'S': Save Now. 'R': Refresh Preview. 'M': Toggle Mask. 'F': " + "Toggle Screen Fit-Actual Size. 'ENTER': Save and Quit") if timelapse_kwargs: self._timelapse.output_timelapse(timelapse_kwargs) @@ -605,8 +603,6 @@ class _Samples(): # pylint:disable=too-few-public-methods The selected model that will be running this trainer coverage_ratio: float Ratio of face to be cropped out of the training image. - scaling: float, optional - The amount to scale the final preview image by. Default: `1.0` Attributes ---------- @@ -615,14 +611,13 @@ class _Samples(): # pylint:disable=too-few-public-methods dictionary should contain 2 keys ("a" and "b") with the values being the training images for generating samples corresponding to each side. """ - def __init__(self, model, coverage_ratio, scaling=1.0): + def __init__(self, model, coverage_ratio): logger.debug("Initializing %s: model: '%s', coverage_ratio: %s)", self.__class__.__name__, model, coverage_ratio) self._model = model self._display_mask = model.config["learn_mask"] or model.config["penalized_mask_loss"] self.images = {} self._coverage_ratio = coverage_ratio - self._scaling = scaling logger.debug("Initialized %s", self.__class__.__name__) def toggle_mask_display(self): @@ -787,9 +782,6 @@ def _to_full_frame(self, side, samples, predictions): images = self._compile_masked(images, samples[-1]) images = [self._overlay_foreground(full.copy(), image) for image in images] - if self._scaling != 1.0: - new_size = int(images[0].shape[1] * self._scaling) - images = [self._resize_sample(side, image, new_size) for image in images] return images def _process_full(self, side, images, prediction_size, color): diff --git a/scripts/train.py b/scripts/train.py index c095336d87..f6b2881694 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -7,15 +7,16 @@ from threading import Lock from time import sleep -from typing import cast, Callable, Dict, List, Optional, TYPE_CHECKING +from typing import cast, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING import cv2 import numpy as np +from matplotlib import backend_bases, figure, pyplot as plt, rcParams from lib.image import read_image_meta from lib.keypress import KBHit from lib.multithreading import MultiThread -from lib.utils import (get_folder, get_image_paths, FaceswapError, _image_extensions) +from lib.utils import get_dpi, get_folder, get_image_paths, FaceswapError, _image_extensions from plugins.plugin_loader import PluginLoader if sys.version_info < (3, 8): @@ -28,6 +29,7 @@ from plugins.train.model._base import ModelBase from plugins.train.trainer._base import TrainerBase + logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -61,10 +63,7 @@ def __init__(self, arguments: "argparse.Namespace") -> None: mask_toggle=os.path.join(gui_cache, ".preview_mask_toggle")) self._stop: bool = False self._save_now: bool = False - self._toggle_preview_mask: bool = False - self._refresh_preview: bool = False - self._preview_buffer: Dict[str, np.ndarray] = {} - self._lock = Lock() + self._preview = Preview() logger.debug("Initialized %s", self.__class__.__name__) @@ -249,7 +248,7 @@ def _end_thread(self, thread: MultiThread, err: bool) -> None: def _training(self) -> None: """ The training process to be run inside a thread. """ try: - sleep(1) # Let preview instructions flush out to logger + sleep(0.5) # Let preview instructions flush out to logger logger.debug("Commencing Training") logger.info("Loading data, this may take a while...") model = self._load_model() @@ -329,12 +328,11 @@ def _run_training_cycle(self, model: "ModelBase", trainer: "TrainerBase") -> Non logger.trace("Training iteration: %s", iteration) # type:ignore save_iteration = iteration % self._args.save_interval == 0 or iteration == 1 - if self._toggle_preview_mask: + if self._preview.should_toggle_mask(): trainer.toggle_mask() - self._toggle_preview_mask = False - self._refresh_preview = True + self._preview.request_refresh() - if save_iteration or self._save_now or self._refresh_preview: + if self._preview.should_refresh(): viewer = display_func else: viewer = None @@ -344,19 +342,13 @@ def _run_training_cycle(self, model: "ModelBase", trainer: "TrainerBase") -> Non logger.debug("Stop received. Terminating") break - if self._refresh_preview and viewer is not None: - if self._args.redirect_gui: # Remove any gui trigger files following an update - print("\n") - logger.info("[Preview Updated]") - self._refresh_preview = False - - if save_iteration: - logger.debug("Save Iteration: (iteration: %s", iteration) - model.save(is_exit=False) - elif self._save_now: - logger.debug("Save Requested: (iteration: %s", iteration) + if save_iteration or self._save_now: + logger.debug("Saving (save_iterations: %s, save_now: %s) Iteration: " + "(iteration: %s)", save_iteration, self._save_now, iteration) model.save(is_exit=False) self._save_now = False + self._preview.request_refresh() + logger.debug("Training cycle complete") model.save(is_exit=True) trainer.clear_tensorboard() @@ -376,43 +368,6 @@ def _output_startup_info(self) -> None: logger.info(" Press 'S' to save model weights immediately") logger.info("===================================================") - @classmethod - def _create_resizable_window(cls, name: str, image_shape: tuple) -> None: - """ Create a resizable OpenCV window to hold the preview image. - - Parameters - ---------- - name: str - The name to display in the window header and for window identification - shape: tuple - The (`rows`, `columns`, `channels`) of the image to be displayed - """ - logger.debug("Creating named window '%s' for image shape %s", name, image_shape) - height, width = image_shape[:2] - cv2.namedWindow(name, cv2.WINDOW_GUI_EXPANDED) - cv2.resizeWindow(name, width, height) - - def _do_preview(self, window_created: bool) -> bool: - """" Display an image preview in a resizable window - - Parameters - ---------- - window_created: bool - ``True`` if a preview window has been created otherwise ``False`` - - Returns - ------- - bool - ``True`` if a preview window has been created otherwise ``False`` - """ - with self._lock: - for name, image in self._preview_buffer.items(): - if not window_created: - self._create_resizable_window(name, image.shape) - cv2.imshow(name, image) # pylint: disable=no-member - window_created = bool(self._preview_buffer) if not window_created else window_created - return window_created - def _check_keypress(self, keypress: KBHit) -> bool: """ Check if a keypress has been detected. @@ -437,48 +392,12 @@ def _check_keypress(self, keypress: KBHit) -> bool: self._save_now = True return retval - def _preview_monitor(self, key_press: str) -> bool: - """ Monitors keyboard presses on the pop-up OpenCV Preview Window. - - Parameters - ---------- - key_press: str - The key press received from OpenCV or ``None`` if no press received - - Returns - ------- - bool - ``True`` if the process should continue training. ``False`` if an exit has been - requested and process should terminate - """ - if not self._args.preview: - return True - - if key_press == ord("\n") or key_press == ord("\r"): - logger.debug("Exit requested") - return False - - if key_press == ord("s"): - print("\n") - logger.info("Save requested") - self._save_now = True - if key_press == ord("r"): - print("\n") - logger.info("Refresh preview requested") - self._refresh_preview = True - if key_press == ord("m"): - print("\n") - logger.verbose("Toggle mask display requested") # type:ignore - self._toggle_preview_mask = True - - return True - def _process_gui_triggers(self) -> None: """ Check whether a file drop has occurred from the GUI to manually update the preview. """ if not self._args.redirect_gui: return - parent_flags = dict(mask_toggle="_toggle_preview_mask", update="_refresh_preview") + parent_flags = dict(mask_toggle="request_mask_toggle", update="request_refresh") for trigger in ("mask_toggle", "update"): filename = self._gui_triggers[trigger] if os.path.isfile(filename): @@ -486,12 +405,10 @@ def _process_gui_triggers(self) -> None: logger.debug("Removing gui trigger file: %s", filename) os.remove(filename) - if trigger == "update": - print("\n") - logger.info("Refresh preview requested") - - setattr(self, parent_flags[trigger], True) + print("\n") # Let log print on different line from loss output + logger.info("Refresh preview requested...") + getattr(self._preview, parent_flags[trigger])() def _monitor(self, thread: MultiThread) -> bool: """ Monitor the background :func:`_training` thread for key presses and errors. @@ -508,15 +425,11 @@ def _monitor(self, thread: MultiThread) -> bool: """ self._output_startup_info() keypress = KBHit(is_gui=self._args.redirect_gui) - window_created = False err = False while True: try: if self._args.preview: - self._do_preview(window_created) - cv_key = cv2.waitKey(1000) # pylint: disable=no-member - else: - cv_key = None + self._preview.display_preview() if thread.has_error: logger.debug("Thread error detected") @@ -527,8 +440,10 @@ def _monitor(self, thread: MultiThread) -> bool: break # Preview Monitor - if not self._preview_monitor(cv_key): + if self._preview.should_quit(): break + if self._preview.should_save(): + self._save_now = True # Console Monitor if self._check_keypress(keypress): @@ -576,10 +491,240 @@ def _show(self, image: np.ndarray, name: str = "") -> None: logger.debug("Generated preview for GUI: '%s'", img) if self._args.preview: logger.debug("Generating preview for display: '%s'", name) - with self._lock: - self._preview_buffer[name] = image + self._preview.add_image(name, image) logger.debug("Generated preview for display: '%s'", name) except Exception as err: logging.error("could not preview sample") raise err logger.debug("Updated preview: (name: %s)", name) + + +class Preview(): + """ Holds the pop up preview window and options relating to the preview in the window and the + GUI. Thread safe to take requests from the main thread and the training thread. """ + def __init__(self) -> None: + self._lock = Lock() + self._dpi: float = 0.0 + self._toggle_mask: bool = False + self._full_size: bool = False + self._refresh: bool = False + self._save: bool = False + self._quit: bool = False + self._needs_update: bool = False + self._preview_buffer: Dict[str, np.ndarray] = {} + self._images: Dict[str, Tuple[figure.Figure, Tuple[float, float]]] = {} + self._resize_ids: List[Tuple[figure.Figure, int]] = [] + self._callbacks = dict(f="_toggle_size", + m="_toggle_mask", + r="_refresh", + s="_save", + enter="_quit") + self._reassign_keys() + + @property + def _toggle_size(self) -> bool: + return self._full_size + + @_toggle_size.setter + def _toggle_size(self, value: bool) -> None: # pylint:disable=unused-argument + """ Toggle between actual size and screen-fit size. + + Parameters + ---------- + value: bool + Unused, but required for setter. The size will check the previous state and switch it + to the opposite state + """ + self._full_size = not self._full_size + self._set_resize_callback() + + @classmethod + def _reassign_keys(cls): + """ Remove `F`, 'S' and 'R' from their default bindings. """ + rcParams["keymap.fullscreen"] = [k for k in rcParams["keymap.fullscreen"] if k != "f"] + rcParams["keymap.save"] = [k for k in rcParams["keymap.save"] if k != "s"] + rcParams["keymap.home"] = [k for k in rcParams["keymap.home"] if k != "r"] + + def should_toggle_mask(self) -> bool: + """ Check whether the mask should be toggled and return the value. If ``True`` is returned + then resets :attr:`_toggle_mask` back to ``False`` + + Returns + ------- + bool + ``True`` if the mask should be toggled otherwise ``False``. """ + with self._lock: + retval = self._toggle_mask + if retval: + logger.debug("Sending toggle mask") + self._toggle_mask = False + return retval + + def should_refresh(self) -> bool: + """ Check whether the preview should be updated and return the value. If ``True`` is + returned then resets :attr:`_refresh` back to ``False`` + + Returns + ------- + bool + ``True`` if the preview should be refreshed otherwise ``False``. """ + with self._lock: + retval = self._refresh + if retval: + logger.debug("Sending should refresh") + self._refresh = False + return retval + + def should_save(self) -> bool: + """ Check whether a save request has been made. If ``True`` is returned then :attr:`_save` + is set back to ``False`` + + Returns + ------- + bool + ``True`` if a save has been requested otherwise ``False``. """ + with self._lock: + retval = self._save + if retval: + logger.debug("Sending should save") + self._save = False + return retval + + def should_quit(self) -> bool: + """ Check whether an exit request has been made. + + Returns + ------- + bool + ``True`` if an exit request has been made otherwise ``False``. """ + with self._lock: + retval = self._quit + if retval: + logger.debug("Sending should stop") + return retval + + def request_refresh(self) -> None: + """ Handle a GUI trigger or a training thread trigger (after a mask toggle) request to set + the :attr:`_refresh` to ``True`` to request a refresh on the next pass of the + training loop. """ + with self._lock: + self._refresh = True + + def request_mask_toggle(self) -> None: + """ Handle a GUI trigger request to set the Set the :attr:`_toggle_mask` to ``True`` to + request a mask toggle on next pass of the training loop. """ + logger.verbose("Toggle mask display requested...") # type:ignore + with self._lock: + self._toggle_mask = True + + def add_image(self, name: str, image: np.ndarray) -> None: + """ Add a preview image to the preview buffer. + + Parameters + ---------- + name: str + The name of the preview image to add to the buffer + image: :class:`numpy.ndarray` + The preview image to add to the buffer in BGR format. + """ + with self._lock: + logger.debug("Adding image '%s' of shape %s to preview buffer", name, image.shape) + self._preview_buffer[name] = image[..., 2::-1] # Switch to RGB + self._needs_update = True + + def display_preview(self) -> None: + """ Display an image preview in a resizable window. """ + if self._needs_update: + logger.debug("Updating preview") + with self._lock: + for name, image in self._preview_buffer.items(): + if (name not in self._images or # new preview or preview was closed + not plt.fignum_exists(self._images[name][0].number)): + self._create_resizable_window(name, image.shape) + if self._full_size: # This can only be true if preview was closed + self._set_resize_callback() + plt.figure(name) + plt.imshow(image) + self._needs_update = False + logger.debug("preview updated") # type: ignore + plt.show(block=False) + plt.pause(0.1) + + def _create_resizable_window(self, name: str, image_shape: tuple) -> None: + """ Create a resizable Matplotlib window to hold the preview image. + + Parameters + ---------- + name: str + The name to display in the window header and for window identification + shape: tuple + The (`rows`, `columns`, `channels`) of the image to be displayed + """ + logger.debug("Creating figure '%s' for image shape %s", name, image_shape) + if not self._dpi: + self._dpi = get_dpi() + height, width = image_shape[:2] + size = width / self._dpi, height / self._dpi + fig = plt.figure(name, figsize=size) + axes = plt.Axes(fig, [0., 0., 1., 1.]) # Remove axes and whitespace + axes.set_axis_off() + fig.add_axes(axes) + fig.canvas.mpl_connect("key_press_event", self._on_key_press) + fig.canvas.mpl_connect("close_event", self._on_close) + logger.debug("Created display figure of size: %s", size) + self._images[name] = (fig, size) + + def _set_resize_callback(self): + """ Sets the resize callback if displaying preview at actual size or removes it if + displaying at screen-fit size. """ + if self._full_size: + logger.debug("Setting resize callback for actual size display") + for fig, size in self._images.values(): + self._resize_ids.append((fig, fig.canvas.mpl_connect("resize_event", + self._on_resize))) + fig.set_size_inches(size) + else: + logger.debug("Removing resize callback for screen-fit display") + for fig, cid in self._resize_ids: + fig.canvas.mpl_disconnect(cid) + self._resize_ids = [] + + def _on_key_press(self, event: backend_bases.KeyEvent) -> None: + """ Callbacks for keypresses to update the requested trigger. + + - `F` (toggle full-size/fit to window) + - `M` (toggle mask), + - `R` (refresh preview), + - `S` (save now) + - `Enter` (save and exit) + + Parameters + ---------- + event: + The key press received + """ + key = event.key.lower() + if key not in self._callbacks: + return + + logger.debug("Preview window keypress '%s' received", key) + if key == "r": + print("\n") # Let log print on different line from loss output + logger.info("Refresh preview requested...") + + with self._lock: + setattr(self, self._callbacks[key], True) + + def _on_resize(self, + event: backend_bases.ResizeEvent) -> None: # noqa # pylint:disable=unused-argument + """ If the display is set to `actual size` then the image needs to be resized on any window + resize event. """ + for fig, size in self._images.values(): + fig.set_size_inches(size) + + def _on_close(self, + event: backend_bases.CloseEvent) -> None: # noqa # pylint:disable=unused-argument + """ Force an update when the figure has been closed to relaunch it. """ + logger.debug("Preview close detected") + with self._lock: + self._needs_update = True diff --git a/setup.cfg b/setup.cfg index a87c4be7d8..d72c824bab 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,8 +8,14 @@ exclude = .git, __pycache__ [mypy] [mypy-cv2.*] ignore_missing_imports = True +[mypy-imageio.*] +ignore_missing_imports = True +[mypy-imageio_ffmpeg.*] +ignore_missing_imports = True [mypy-keras.*] ignore_missing_imports = True +[mypy-matplotlib.*] +ignore_missing_imports = True [mypy-plaidml.*] ignore_missing_imports = True [mypy-psutil.*] From 3c73ae4ec9f0f30649a5e20465a268bbcfd690eb Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 1 Jul 2022 19:06:42 +0100 Subject: [PATCH 659/981] bugfix: Update preview screen in GUI --- scripts/train.py | 82 +++++++++++++++++++++++------------------------- 1 file changed, 40 insertions(+), 42 deletions(-) diff --git a/scripts/train.py b/scripts/train.py index f6b2881694..a7c274ea30 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -336,8 +336,15 @@ def _run_training_cycle(self, model: "ModelBase", trainer: "TrainerBase") -> Non viewer = display_func else: viewer = None + timelapse = self._timelapse if save_iteration else {} trainer.train_one_step(viewer, timelapse) + + if viewer is not None and not save_iteration: + # Spammy but required by GUI to know to update window + print("\n") + logger.info("[Preview Updated]") + if self._stop: logger.debug("Stop received. Terminating") break @@ -488,7 +495,7 @@ def _show(self, image: np.ndarray, name: str = "") -> None: imgfile = os.path.join(scriptpath, "lib", "gui", ".cache", "preview", img) cv2.imwrite(imgfile, image) # pylint: disable=no-member - logger.debug("Generated preview for GUI: '%s'", img) + logger.debug("Generated preview for GUI: '%s'", imgfile) if self._args.preview: logger.debug("Generating preview for display: '%s'", name) self._preview.add_image(name, image) @@ -505,37 +512,25 @@ class Preview(): def __init__(self) -> None: self._lock = Lock() self._dpi: float = 0.0 - self._toggle_mask: bool = False - self._full_size: bool = False - self._refresh: bool = False - self._save: bool = False - self._quit: bool = False + self._triggers: Dict[str, bool] = dict(toggle_mask=False, + full_size=False, + refresh=False, + save=False, + quit=False) self._needs_update: bool = False self._preview_buffer: Dict[str, np.ndarray] = {} self._images: Dict[str, Tuple[figure.Figure, Tuple[float, float]]] = {} self._resize_ids: List[Tuple[figure.Figure, int]] = [] - self._callbacks = dict(f="_toggle_size", - m="_toggle_mask", - r="_refresh", - s="_save", - enter="_quit") + self._callbacks = dict(f="full_size", + m="toggle_mask", + r="refresh", + s="save", + enter="quit") self._reassign_keys() - @property - def _toggle_size(self) -> bool: - return self._full_size - - @_toggle_size.setter - def _toggle_size(self, value: bool) -> None: # pylint:disable=unused-argument - """ Toggle between actual size and screen-fit size. - - Parameters - ---------- - value: bool - Unused, but required for setter. The size will check the previous state and switch it - to the opposite state - """ - self._full_size = not self._full_size + def _toggle_size(self) -> None: # pylint:disable=unused-argument + """ Toggle between actual size and screen-fit size. """ + self._triggers["full_size"] = not self._triggers["full_size"] self._set_resize_callback() @classmethod @@ -547,32 +542,32 @@ def _reassign_keys(cls): def should_toggle_mask(self) -> bool: """ Check whether the mask should be toggled and return the value. If ``True`` is returned - then resets :attr:`_toggle_mask` back to ``False`` + then resets mask toggle back to ``False`` Returns ------- bool ``True`` if the mask should be toggled otherwise ``False``. """ with self._lock: - retval = self._toggle_mask + retval = self._triggers["toggle_mask"] if retval: logger.debug("Sending toggle mask") - self._toggle_mask = False + self._triggers["toggle_mask"] = False return retval def should_refresh(self) -> bool: """ Check whether the preview should be updated and return the value. If ``True`` is - returned then resets :attr:`_refresh` back to ``False`` + returned then resets the refresh trigger back to ``False`` Returns ------- bool ``True`` if the preview should be refreshed otherwise ``False``. """ with self._lock: - retval = self._refresh + retval = self._triggers["refresh"] if retval: logger.debug("Sending should refresh") - self._refresh = False + self._triggers["refresh"] = False return retval def should_save(self) -> bool: @@ -584,10 +579,10 @@ def should_save(self) -> bool: bool ``True`` if a save has been requested otherwise ``False``. """ with self._lock: - retval = self._save + retval = self._triggers["save"] if retval: logger.debug("Sending should save") - self._save = False + self._triggers["save"] = False return retval def should_quit(self) -> bool: @@ -598,24 +593,24 @@ def should_quit(self) -> bool: bool ``True`` if an exit request has been made otherwise ``False``. """ with self._lock: - retval = self._quit + retval = self._triggers["quit"] if retval: logger.debug("Sending should stop") return retval def request_refresh(self) -> None: """ Handle a GUI trigger or a training thread trigger (after a mask toggle) request to set - the :attr:`_refresh` to ``True`` to request a refresh on the next pass of the + the refresh trigger to ``True`` to request a refresh on the next pass of the training loop. """ with self._lock: - self._refresh = True + self._triggers["refresh"] = True def request_mask_toggle(self) -> None: - """ Handle a GUI trigger request to set the Set the :attr:`_toggle_mask` to ``True`` to + """ Handle a GUI trigger request to set the mask toggle to ``True`` to request a mask toggle on next pass of the training loop. """ logger.verbose("Toggle mask display requested...") # type:ignore with self._lock: - self._toggle_mask = True + self._triggers["toggle_mask"] = True def add_image(self, name: str, image: np.ndarray) -> None: """ Add a preview image to the preview buffer. @@ -641,7 +636,7 @@ def display_preview(self) -> None: if (name not in self._images or # new preview or preview was closed not plt.fignum_exists(self._images[name][0].number)): self._create_resizable_window(name, image.shape) - if self._full_size: # This can only be true if preview was closed + if self._triggers["full_size"]: # Can only be true if preview was closed self._set_resize_callback() plt.figure(name) plt.imshow(image) @@ -677,7 +672,7 @@ def _create_resizable_window(self, name: str, image_shape: tuple) -> None: def _set_resize_callback(self): """ Sets the resize callback if displaying preview at actual size or removes it if displaying at screen-fit size. """ - if self._full_size: + if self._triggers["full_size"]: logger.debug("Setting resize callback for actual size display") for fig, size in self._images.values(): self._resize_ids.append((fig, fig.canvas.mpl_connect("resize_event", @@ -713,7 +708,10 @@ def _on_key_press(self, event: backend_bases.KeyEvent) -> None: logger.info("Refresh preview requested...") with self._lock: - setattr(self, self._callbacks[key], True) + if key == "f": + self._toggle_size() + else: + self._triggers[self._callbacks[key]] = True def _on_resize(self, event: backend_bases.ResizeEvent) -> None: # noqa # pylint:disable=unused-argument From c8122bc499afba4fcb99030e42e08bfb8d3a75e1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 6 Jul 2022 11:15:51 +0100 Subject: [PATCH 660/981] bugfix: Stop preview window from stealing focus --- plugins/train/model/_base/model.py | 10 +++++----- scripts/train.py | 10 ++++++---- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 12570b26a2..2e16456393 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -708,11 +708,11 @@ def _replace_config(self, config_changeable_items) -> None: legacy_update = self._update_legacy_config() # Add any new items to state config for legacy purposes where the new default may be # detrimental to an existing model. - legacy_defaults = dict(centering="legacy", - mask_loss_function="mse", - l2_reg_term=100, - optimizer="adam", - mixed_precision=False) + legacy_defaults: Dict[str, Union[str, int, bool]] = dict(centering="legacy", + mask_loss_function="mse", + l2_reg_term=100, + optimizer="adam", + mixed_precision=False) for key, val in _CONFIG.items(): if key not in self._config.keys(): setting = legacy_defaults.get(key, val) diff --git a/scripts/train.py b/scripts/train.py index a7c274ea30..4114d4ba51 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -526,7 +526,7 @@ def __init__(self) -> None: r="refresh", s="save", enter="quit") - self._reassign_keys() + self._configure_matplotlib() def _toggle_size(self) -> None: # pylint:disable=unused-argument """ Toggle between actual size and screen-fit size. """ @@ -534,11 +534,13 @@ def _toggle_size(self) -> None: # pylint:disable=unused-argument self._set_resize_callback() @classmethod - def _reassign_keys(cls): - """ Remove `F`, 'S' and 'R' from their default bindings. """ + def _configure_matplotlib(cls): + """ Remove `F`, 'S' and 'R' from their default bindings and stop Matplotlib from stealing + focus """ rcParams["keymap.fullscreen"] = [k for k in rcParams["keymap.fullscreen"] if k != "f"] rcParams["keymap.save"] = [k for k in rcParams["keymap.save"] if k != "s"] rcParams["keymap.home"] = [k for k in rcParams["keymap.home"] if k != "r"] + rcParams["figure.raise_window"] = False def should_toggle_mask(self) -> bool: """ Check whether the mask should be toggled and return the value. If ``True`` is returned @@ -641,8 +643,8 @@ def display_preview(self) -> None: plt.figure(name) plt.imshow(image) self._needs_update = False + plt.show(block=False) logger.debug("preview updated") # type: ignore - plt.show(block=False) plt.pause(0.1) def _create_resizable_window(self, name: str, image_shape: tuple) -> None: From 582c2ce40c11ef235dd3f9100f70e1e2832f8dd3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 7 Jul 2022 01:02:11 +0100 Subject: [PATCH 661/981] Add Flip Loss Function - Add Flip for AMD and TF - Split Perceptual Loss functions to own modules - Fix allowed input shape for models - Allow GUI tooltip to display at higher width --- docs/full/lib/keras_utils.rst | 8 + docs/full/lib/model.rst | 11 +- docs/full/lib/plaidml_utils.rst | 8 + lib/gui/control_helper.py | 2 +- lib/keras_utils.py | 354 ++++++++++ lib/model/loss/loss_plaid.py | 415 +----------- lib/model/loss/loss_tf.py | 341 +--------- lib/model/loss/perceptual_loss_plaid.py | 849 ++++++++++++++++++++++++ lib/model/loss/perceptual_loss_tf.py | 757 +++++++++++++++++++++ lib/plaidml_utils.py | 39 +- plugins/train/_config.py | 8 +- plugins/train/model/_base/model.py | 23 +- plugins/train/model/_base/settings.py | 21 +- tests/lib/model/losses_test.py | 3 +- 14 files changed, 2055 insertions(+), 784 deletions(-) create mode 100644 docs/full/lib/keras_utils.rst create mode 100644 docs/full/lib/plaidml_utils.rst create mode 100644 lib/keras_utils.py create mode 100644 lib/model/loss/perceptual_loss_plaid.py create mode 100644 lib/model/loss/perceptual_loss_tf.py diff --git a/docs/full/lib/keras_utils.rst b/docs/full/lib/keras_utils.rst new file mode 100644 index 0000000000..1dda86a3ec --- /dev/null +++ b/docs/full/lib/keras_utils.rst @@ -0,0 +1,8 @@ +****************** +keras_utils module +****************** + +.. automodule:: lib.keras_utils + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/lib/model.rst b/docs/full/lib/model.rst index 9af6eb6faf..2b8875ffc8 100755 --- a/docs/full/lib/model.rst +++ b/docs/full/lib/model.rst @@ -64,15 +64,16 @@ be imported as :mod:`lib.model.losses` depending on the backend in use. .. autosummary:: :nosignatures: - ~lib.model.loss.loss_tf.DSSIMObjective + ~lib.model.loss.perceptual_loss_tf.DSSIMObjective ~lib.model.loss.loss_tf.FocalFrequencyLoss ~lib.model.loss.loss_tf.GeneralizedLoss - ~lib.model.loss.loss_tf.GMSDLoss + ~lib.model.loss.perceptual_loss_tf.GMSDLoss ~lib.model.loss.loss_tf.GradientLoss ~lib.model.loss.loss_tf.LaplacianPyramidLoss ~lib.model.loss.loss_tf.LInfNorm ~lib.model.loss.loss_tf.LossWrapper ~lib.model.loss.feature_loss_tf.LPIPSLoss + ~lib.model.loss.perceptual_loss_tf.MSSIMLoss .. automodule:: lib.model.loss.loss_tf :members: @@ -84,6 +85,12 @@ be imported as :mod:`lib.model.losses` depending on the backend in use. :undoc-members: :show-inheritance: +.. automodule:: lib.model.loss.perceptual_loss_tf + :members: + :undoc-members: + :show-inheritance: + + model.nets module ================= diff --git a/docs/full/lib/plaidml_utils.rst b/docs/full/lib/plaidml_utils.rst new file mode 100644 index 0000000000..256e96ed7a --- /dev/null +++ b/docs/full/lib/plaidml_utils.rst @@ -0,0 +1,8 @@ +******************** +plaidml_utils module +******************** + +.. automodule:: lib.plaidml_utils + :members: + :undoc-members: + :show-inheritance: diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index f046198419..3b471c22b0 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -41,7 +41,7 @@ def _get_tooltip(widget, text=None, text_variable=None): while True: if len(text) < wrap_length * 5: break - if wrap_length > 720: + if wrap_length > 800: break wrap_length = int(wrap_length * 1.10) diff --git a/lib/keras_utils.py b/lib/keras_utils.py new file mode 100644 index 0000000000..435c04e131 --- /dev/null +++ b/lib/keras_utils.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +""" Common multi-backend Keras utilities """ +from typing import Optional, Tuple + +import numpy as np + +from lib.utils import get_backend + +if get_backend() == "amd": + from plaidml.tile import Value as Tensor # pylint:disable=import-error + from keras import backend as K +else: + from tensorflow import Tensor + from tensorflow.keras import backend as K # pylint:disable=import-error + + +def frobenius_norm(matrix: Tensor, + axis: int = -1, + keep_dims: bool = True, + epsilon: float = 1e-15) -> Tensor: + """ Frobenius normalization for Keras Tensor + + Parameters + ---------- + matrix: Tensor + The matrix to normalize + axis: int, optional + The axis to normalize. Default: `-1` + keep_dims: bool, Optional + Whether to retain the original matrix shape or not. Default:``True`` + epsilon: flot, optional + Epsilon to apply to the normalization to preven NaN errors on zero values + + Returns + ------- + Tensor + The normalized output + """ + return K.sqrt(K.sum(K.pow(matrix, 2), axis=axis, keepdims=keep_dims) + epsilon) + + +def replicate_pad(image: Tensor, padding: int) -> Tensor: + """ Apply replication padding to an input batch of images. Expects 4D tensor in BHWC format. + + Notes + ----- + At the time of writing Keras/Tensorflow does not have a native replication padding method. + The implementation here is probably not the most efficient, but it is a pure keras method + which should work on both TF and Plaid. + + Parameters + ---------- + image: Tensor + Image tensor to pad + pad: int + The amount of padding to apply to each side of the input image + + Returns + ------- + Tensor + The input image with replication padding applied + """ + top_pad = K.tile(image[:, :1, ...], (1, padding, 1, 1)) + bottom_pad = K.tile(image[:, -1:, ...], (1, padding, 1, 1)) + pad_top_bottom = K.concatenate([top_pad, image, bottom_pad], axis=1) + left_pad = K.tile(pad_top_bottom[..., :1, :], (1, 1, padding, 1)) + right_pad = K.tile(pad_top_bottom[..., -1:, :], (1, 1, padding, 1)) + padded = K.concatenate([left_pad, pad_top_bottom, right_pad], axis=2) + return padded + + +class ColorSpaceConvert(): # pylint:disable=too-few-public-methods + """ Transforms inputs between different color spaces on the GPU + + Notes + ----- + The following color space transformations are implemented: + - rgb to lab + - rgb to xyz + - srgb to _rgb + - srgb to ycxcz + - xyz to ycxcz + - xyz to lab + - xyz to rgb + - ycxcz to rgb + - ycxcz to xyz + + Parameters + ---------- + from_space: str + One of `"srgb"`, `"rgb"`, `"xyz"` + to_space: str + One of `"lab"`, `"rgb"`, `"ycxcz"`, `"xyz"` + batch_shape: Tuple, optional + Shape tuple (b, h, w, c) if the image being processed. Required for PlaidML backend. + Optional. Default = ``None`` + + Raises + ------ + ValueError + If the requested color space conversion is not defined + """ + def __init__(self, + from_space: str, + to_space: str, + batch_shape: Optional[Tuple[int, int, int, int]] = None) -> None: + functions = dict(rgb_lab=self._rgb_to_lab, + rgb_xyz=self._rgb_to_xyz, + srgb_rgb=self._srgb_to_rgb, + srgb_ycxcz=self._srgb_to_ycxcz, + xyz_ycxcz=self._xyz_to_ycxcz, + xyz_lab=self._xyz_to_lab, + xyz_to_rgb=self._xyz_to_rgb, + ycxcz_rgb=self._ycxcz_to_rgb, + ycxcz_xyz=self._ycxcz_to_xyz) + func_name = f"{from_space.lower()}_{to_space.lower()}" + if func_name not in functions: + raise ValueError(f"The color transform {from_space} to {to_space} is not defined.") + + self._func = functions[func_name] + self._ref_illuminant = K.constant(np.array([[[0.950428545, 1.000000000, 1.088900371]]]), + dtype="float32") + self._inv_ref_illuminant = 1. / self._ref_illuminant + + self._rgb_xyz_map = self._get_rgb_xyz_map() + self._xyz_multipliers = K.constant([116, 500, 200], dtype="float32") + self._batch_shape = batch_shape + + @classmethod + def _get_rgb_xyz_map(cls) -> Tuple[Tensor, Tensor]: + """ Obtain the mapping and inverse mapping for rgb to xyz color space conversion. + + Returns + ------- + tuple + The mapping and inverse Tensors for rgb to xyz color space conversion + """ + mapping = np.array([[10135552 / 24577794, 8788810 / 24577794, 4435075 / 24577794], + [2613072 / 12288897, 8788810 / 12288897, 887015 / 12288897], + [1425312 / 73733382, 8788810 / 73733382, 70074185 / 73733382]]) + inverse = np.linalg.inv(mapping) + return (K.constant(mapping, dtype="float32"), K.constant(inverse, dtype="float32")) + + def __call__(self, image: Tensor) -> Tensor: + """ Call the colorspace conversion function. + + Parameters + ---------- + image: Tensor + The image tensor in the colorspace defined by :param:`from_space` + + Returns + ------- + Tensor + The image tensor in the colorspace defined by :param:`to_space` + """ + return self._func(image) + + def _rgb_to_lab(self, image: Tensor) -> Tensor: + """ RGB to LAB conversion. + + Parameters + ---------- + image: Tensor + The image tensor in RGB format + + Returns + ------- + Tensor + The image tensor in LAB format + """ + converted = self._rgb_to_xyz(image) + return self._xyz_to_lab(converted) + + def _rgb_xyz_rgb(self, image: Tensor, mapping: Tensor) -> Tensor: + """ RGB to XYZ or XYZ to RGB conversion. + + Notes + ----- + The conversion in both directions is the same, but the mappping matrix for XYZ to RGB is + the inverse of RGB to XYZ. + + Reference + --------- + https://www.image-engineering.de/library/technotes/958-how-to-convert-between-srgb-and-ciexyz + + Parameters + ---------- + mapping: Tensor + The mapping matrix to perform either the XYZ to RGB or RGB to XYZ color space + conversion + + image: Tensor + The image tensor in RGB format + + Returns + ------- + Tensor + The image tensor in XYZ format + """ + dim = K.int_shape(image) if self._batch_shape is None else self._batch_shape + image = K.permute_dimensions(image, (0, 3, 1, 2)) + image = K.reshape(image, (dim[0], dim[3], dim[1] * dim[2])) + converted = K.permute_dimensions(K.dot(mapping, image), (1, 2, 0)) + return K.reshape(converted, dim) + + def _rgb_to_xyz(self, image: Tensor) -> Tensor: + """ RGB to XYZ conversion. + + Parameters + ---------- + image: Tensor + The image tensor in RGB format + + Returns + ------- + Tensor + The image tensor in XYZ format + """ + return self._rgb_xyz_rgb(image, self._rgb_xyz_map[0]) + + @classmethod + def _srgb_to_rgb(cls, image: Tensor) -> Tensor: + """ SRGB to RGB conversion. + + Notes + ----- + RGB Image is clipped to a small epsilon to stabalize training + + Parameters + ---------- + image: Tensor + The image tensor in SRGB format + + Returns + ------- + Tensor + The image tensor in RGB format + """ + limit = 0.04045 + return K.switch(image > limit, + K.pow((K.clip(image, limit, None) + 0.055) / 1.055, 2.4), + image / 12.92) + + def _srgb_to_ycxcz(self, image: Tensor) -> Tensor: + """ SRGB to YcXcZ conversion. + + Parameters + ---------- + image: Tensor + The image tensor in SRGB format + + Returns + ------- + Tensor + The image tensor in YcXcZ format + """ + converted = self._srgb_to_rgb(image) + converted = self._rgb_to_xyz(converted) + return self._xyz_to_ycxcz(converted) + + def _xyz_to_lab(self, image: Tensor) -> Tensor: + """ XYZ to LAB conversion. + + Parameters + ---------- + image: Tensor + The image tensor in XYZ format + + Returns + ------- + Tensor + The image tensor in LAB format + """ + image = image * self._inv_ref_illuminant + delta = 6 / 29 + delta_cube = delta ** 3 + factor = 1 / (3 * (delta ** 2)) + + clamped_term = K.pow(K.clip(image, delta_cube, None), 1.0 / 3.0) + div = (factor * image + (4 / 29)) + + image = K.switch(image > delta_cube, clamped_term, div) + return K.concatenate([self._xyz_multipliers[0] * image[..., 1:2] - 16., + self._xyz_multipliers[1:] * (image[..., :2] - image[..., 1:3])], + axis=-1) + + def _xyz_to_rgb(self, image: Tensor) -> Tensor: + """ XYZ to YcXcZ conversion. + + Parameters + ---------- + image: Tensor + The image tensor in XYZ format + + Returns + ------- + Tensor + The image tensor in RGB format + """ + return self._rgb_xyz_rgb(image, self._rgb_xyz_map[1]) + + def _xyz_to_ycxcz(self, image: Tensor) -> Tensor: + """ XYZ to YcXcZ conversion. + + Parameters + ---------- + image: Tensor + The image tensor in XYZ format + + Returns + ------- + Tensor + The image tensor in YcXcZ format + """ + image = image * self._inv_ref_illuminant + return K.concatenate([self._xyz_multipliers[0] * image[..., 1:2] - 16., + self._xyz_multipliers[1:] * (image[..., :2] - image[..., 1:3])], + axis=-1) + + def _ycxcz_to_rgb(self, image: Tensor) -> Tensor: + """ YcXcZ to RGB conversion. + + Parameters + ---------- + image: Tensor + The image tensor in YcXcZ format + + Returns + ------- + Tensor + The image tensor in RGB format + """ + converted = self._ycxcz_to_xyz(image) + return self._xyz_to_rgb(converted) + + def _ycxcz_to_xyz(self, image: Tensor) -> Tensor: + """ YcXcZ to XYZ conversion. + + Parameters + ---------- + image: Tensor + The image tensor in YcXcZ format + + Returns + ------- + Tensor + The image tensor in XYZ format + """ + ch_y = (image[..., 0:1] + 16.) / self._xyz_multipliers[0] + return K.concatenate([ch_y + (image[..., 1:2] / self._xyz_multipliers[1]), + ch_y, + ch_y - (image[..., 2:3] / self._xyz_multipliers[2])], + axis=-1) * self._ref_illuminant diff --git a/lib/model/loss/loss_plaid.py b/lib/model/loss/loss_plaid.py index bdd6007a07..8718613865 100644 --- a/lib/model/loss/loss_plaid.py +++ b/lib/model/loss/loss_plaid.py @@ -14,155 +14,11 @@ from lib.utils import FaceswapError from .feature_loss_plaid import LPIPSLoss #pylint:disable=unused-import # noqa +from .perceptual_loss_plaid import DSSIMObjective, GMSDLoss, LDRFLIPLoss, MSSIMLoss #pylint:disable=unused-import # noqa logger = logging.getLogger(__name__) # pylint:disable=invalid-name -class DSSIMObjective(): # pylint:disable=too-few-public-methods - """ DSSIM Loss Function - - Difference of Structural Similarity (DSSIM loss function). - - Adapted from :func:`tensorflow.image.ssim` for a pure keras implentation. - - Notes - ----- - Channels last only. Assumes all input images are the same size and square - - Parameters - ---------- - k_1: float, optional - Parameter of the SSIM. Default: `0.01` - k_2: float, optional - Parameter of the SSIM. Default: `0.03` - filter_size: int, optional - size of gaussian filter Default: `11` - filter_sigma: float, optional - Width of gaussian filter Default: `1.5` - max_value: float, optional - Max value of the output. Default: `1.0` - - Notes - ------ - You should add a regularization term like a l2 loss in addition to this one. - """ - def __init__(self, - k_1: float = 0.01, - k_2: float = 0.03, - filter_size: int = 11, - filter_sigma: float = 1.5, - max_value: float = 1.0) -> None: - self._filter_size = filter_size - self._filter_sigma = filter_sigma - self._kernel = self._get_kernel() - - compensation = 1.0 - self._c1 = (k_1 * max_value) ** 2 - self._c2 = ((k_2 * max_value) ** 2) * compensation - - def _get_kernel(self) -> plaidml.tile.Value: - """ Obtain the base kernel for performing depthwise convolution. - - Returns - ------- - :class:`plaidml.tile.Value` - The gaussian kernel based on selected size and sigma - """ - coords = np.arange(self._filter_size, dtype="float32") - coords -= (self._filter_size - 1) / 2. - - kernel = np.square(coords) - kernel *= -0.5 / np.square(self._filter_sigma) - kernel = np.reshape(kernel, (1, -1)) + np.reshape(kernel, (-1, 1)) - kernel = K.constant(np.reshape(kernel, (1, -1))) - kernel = K.softmax(kernel) - kernel = K.reshape(kernel, (self._filter_size, self._filter_size, 1, 1)) - return kernel - - @classmethod - def _depthwise_conv2d(cls, - image: plaidml.tile.Value, - kernel: plaidml.tile.Value) -> plaidml.tile.Value: - """ Perform a standardized depthwise convolution. - - Parameters - ---------- - image: :class:`plaidml.tile.Value` - Batch of images, channels last, to perform depthwise convolution - kernel: :class:`plaidml.tile.Value` - convolution kernel - - Returns - ------- - :class:`plaidml.tile.Value` - The output from the convolution - """ - return K.depthwise_conv2d(image, kernel, strides=(1, 1), padding="valid") - - def _get_ssim(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> Tuple[plaidml.tile.Value, plaidml.tile.Value]: - """ Obtain the structural similarity between a batch of true and predicted images. - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The input batch of ground truth images - y_pred: :class:`plaidml.tile.Value` - The input batch of predicted images - - Returns - ------- - :class:`plaidml.tile.Value` - The SSIM for the given images - :class:`plaidml.tile.Value` - The Contrast for the given images - """ - channels = K.int_shape(y_pred)[-1] - kernel = K.tile(self._kernel, (1, 1, channels, 1)) - - # SSIM luminance measure is (2 * mu_x * mu_y + c1) / (mu_x ** 2 + mu_y ** 2 + c1) - mean_true = self._depthwise_conv2d(y_true, kernel) - mean_pred = self._depthwise_conv2d(y_pred, kernel) - num_lum = mean_true * mean_pred * 2.0 - den_lum = K.square(mean_true) + K.square(mean_pred) - luminance = (num_lum + self._c1) / (den_lum + self._c1) - - # SSIM contrast-structure measure is (2 * cov_{xy} + c2) / (cov_{xx} + cov_{yy} + c2) - num_con = self._depthwise_conv2d(y_true * y_pred, kernel) * 2.0 - den_con = self._depthwise_conv2d(K.square(y_true) + K.square(y_pred), kernel) - - contrast = (num_con - num_lum + self._c2) / (den_con - den_lum + self._c2) - - # Average over the height x width dimensions - axes = (-3, -2) - ssim = K.mean(luminance * contrast, axis=axes) - contrast = K.mean(contrast, axis=axes) - - return ssim, contrast - - def __call__(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Call the DSSIM or MS-DSSIM Loss Function. - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The input batch of ground truth images - y_pred: :class:`plaidml.tile.Value` - The input batch of predicted images - - Returns - ------- - :class:`plaidml.tile.Value` - The DSSIM or MS-DSSIM for the given images - """ - ssim = self._get_ssim(y_true, y_pred)[0] - retval = (1. - ssim) / 2.0 - return K.mean(retval) - - class FocalFrequencyLoss(): # pylint:disable=too-few-public-methods """ Focal Frequencey Loss Function. @@ -285,117 +141,6 @@ def __call__(self, return loss -class GMSDLoss(): # pylint:disable=too-few-public-methods - """ Gradient Magnitude Similarity Deviation Loss. - - Improved image quality metric over MS-SSIM with easier calculations - - References - ---------- - http://www4.comp.polyu.edu.hk/~cslzhang/IQA/GMSD/GMSD.htm - https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf - """ - def __call__(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Return the Gradient Magnitude Similarity Deviation Loss. - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The ground truth value - y_pred: :class:`plaidml.tile.Value` - The predicted value - - Returns - ------- - :class:`plaidml.tile.Value` - The loss value - """ - image_shape = K.int_shape(y_pred) - true_edge = self._scharr_edges(y_true, True, image_shape) - pred_edge = self._scharr_edges(y_pred, True, image_shape) - ephsilon = 0.0025 - upper = 2.0 * true_edge * pred_edge - lower = K.square(true_edge) + K.square(pred_edge) - gms = (upper + ephsilon) / (lower + ephsilon) - gmsd = K.std(gms, axis=(1, 2, 3), keepdims=True) - gmsd = K.squeeze(gmsd, axis=-1) - return gmsd - - @classmethod - def _scharr_edges(cls, - image: plaidml.tile.Value, - magnitude: bool, - image_shape: Tuple[None, int, int, int]) -> plaidml.tile.Value: - """ Returns a tensor holding modified Scharr edge maps. - - Parameters - ---------- - image: :class:`plaidml.tile.Value` - Image tensor with shape [batch_size, h, w, d] and type float32. The image(s) must be - 2x2 or larger. - magnitude: bool - Boolean to determine if the edge magnitude or edge direction is returned - image_shape: tuple - The shape of the incoming image - - Returns - ------- - :class:`plaidml.tile.Value` - Tensor holding edge maps for each channel. Returns a tensor with shape `[batch_size, h, - w, d, 2]` where the last two dimensions hold `[[dy[0], dx[0]], [dy[1], dx[1]], ..., - [dy[d-1], dx[d-1]]]` calculated using the Scharr filter. - """ - # Define vertical and horizontal Scharr filters. - # 5x5 modified Scharr kernel ( reshape to (5,5,1,2) ) - matrix = np.array([[[[0.00070, 0.00070]], - [[0.00520, 0.00370]], - [[0.03700, 0.00000]], - [[0.00520, -0.0037]], - [[0.00070, -0.0007]]], - [[[0.00370, 0.00520]], - [[0.11870, 0.11870]], - [[0.25890, 0.00000]], - [[0.11870, -0.1187]], - [[0.00370, -0.0052]]], - [[[0.00000, 0.03700]], - [[0.00000, 0.25890]], - [[0.00000, 0.00000]], - [[0.00000, -0.2589]], - [[0.00000, -0.0370]]], - [[[-0.0037, 0.00520]], - [[-0.1187, 0.11870]], - [[-0.2589, 0.00000]], - [[-0.1187, -0.1187]], - [[-0.0037, -0.0052]]], - [[[-0.0007, 0.00070]], - [[-0.0052, 0.00370]], - [[-0.0370, 0.00000]], - [[-0.0052, -0.0037]], - [[-0.0007, -0.0007]]]]) - # num_kernels = [2] - kernels = K.constant(matrix, dtype='float32') - kernels = K.tile(kernels, [1, 1, image_shape[-1], 1]) - - # Use depth-wise convolution to calculate edge maps per channel. - # Output tensor has shape [batch_size, h, w, d * num_kernels]. - pad_sizes = [[0, 0], [2, 2], [2, 2], [0, 0]] - padded = pad(image, pad_sizes, mode='REFLECT') - output = K.depthwise_conv2d(padded, kernels) - - # TODO magnitude not implemented for plaidml - if not magnitude: # direction of edges - raise FaceswapError("Magnitude for GMSD Loss is not implemented in PlaidML") - # # Reshape to [batch_size, h, w, d, num_kernels]. - # shape = K.concatenate([image_shape, num_kernels], axis=0) - # output = K.reshape(output, shape=shape) - # output.set_shape(static_image_shape.concatenate(num_kernels)) - # output = tf.atan(K.squeeze(output[:, :, :, :, 0] / output[:, :, :, :, 1], axis=None)) - # magnitude of edges -- unified x & y edges don't work well with Neural Networks - return output - - class GradientLoss(): # pylint:disable=too-few-public-methods """ Gradient Loss Function. @@ -708,164 +453,6 @@ def __call__(self, return K.mean(loss, axis=-1) -class MSSIMLoss(DSSIMObjective): # pylint:disable=too-few-public-methods - """ Multiscale Structural Similarity Loss Function - - Parameters - ---------- - k_1: float, optional - Parameter of the SSIM. Default: `0.01` - k_2: float, optional - Parameter of the SSIM. Default: `0.03` - filter_size: int, optional - size of gaussian filter Default: `11` - filter_sigma: float, optional - Width of gaussian filter Default: `1.5` - max_value: float, optional - Max value of the output. Default: `1.0` - power_factors: tuple, optional - Iterable of weights for each of the scales. The number of scales used is the length of the - list. Index 0 is the unscaled resolution's weight and each increasing scale corresponds to - the image being downsampled by 2. Defaults to the values obtained in the original paper. - Default: (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) - - Notes - ------ - You should add a regularization term like a l2 loss in addition to this one. - """ - def __init__(self, - k_1: float = 0.01, - k_2: float = 0.03, - filter_size: int = 11, - filter_sigma: float = 1.5, - max_value: float = 1.0, - power_factors: Tuple[float, ...] = (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) - ) -> None: - super().__init__(k_1=k_1, - k_2=k_2, - filter_size=filter_size, - filter_sigma=filter_sigma, - max_value=max_value) - self._power_factors = K.constant(power_factors) - - def _get_smallest_size(self, size: int, idx: int) -> int: - """ Recursive function to obtain the smallest size that the image will be scaled to. - for MS-SSIM - - Parameters - ---------- - size: int - The current scaled size to iterate through - idx: int - The current iteration to be performed. When iteration hits zero the value will - be returned - - Returns - ------- - int - The smallest size the image will be scaled to based on the original image size and - the amount of scaling factors that will occur - """ - logger.debug("scale id: %s, size: %s", idx, size) - if idx > 0: - size = self._get_smallest_size(size // 2, idx - 1) - return size - - @classmethod - def _shrink_images(cls, images: List[plaidml.tile.Value]) -> List[plaidml.tile.Value]: - """ Reduce the dimensional space of a batch of images in half. If the images are an odd - number of pixels then pad them to an even dimension prior to shrinking - - All incoming images are assumed square. - - Parameters - ---------- - images: list - The y_true, y_pred batch of images to be shrunk - - Returns - ------- - list - The y_true, y_pred batch shrunk by half - """ - if any(x % 2 != 0 for x in K.int_shape(images[1])[1:2]): - images = [pad(img, - [[0, 0], [0, 1], [0, 1], [0, 0]], - mode="REFLECT") - for img in images] - - images = [K.pool2d(img, (2, 2), strides=(2, 2), padding="valid", pool_mode="avg") - for img in images] - - return images - - def _get_ms_ssim(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Obtain the Multiscale Stuctural Similarity metric. - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The input batch of ground truth images - y_pred: :class:`plaidml.tile.Value` - The input batch of predicted images - - Returns - ------- - :class:`plaidml.tile.Value` - The MS-SSIM for the given images - """ - im_size = K.int_shape(y_pred)[1] - # filter size cannot be larger than the smallest scale - recursions = K.int_shape(self._power_factors)[0] - smallest_scale = self._get_smallest_size(im_size, recursions - 1) - if smallest_scale < self._filter_size: - self._filter_size = smallest_scale - self._kernel = self._get_kernel() - - images = [y_true, y_pred] - contrasts = [] - - for idx in range(recursions): - images = self._shrink_images(images) if idx > 0 else images - ssim, contrast = self._get_ssim(*images) - - if idx < recursions - 1: - contrasts.append(K.relu(K.expand_dims(contrast, axis=-1))) - - contrasts.append(K.relu(K.expand_dims(ssim, axis=-1))) - mcs_and_ssim = K.concatenate(contrasts, axis=-1) - ms_ssim = K.pow(mcs_and_ssim, self._power_factors) - - # K.prod does not work in plaidml so slow recursion it is - out = ms_ssim[..., 0] - for idx in range(1, recursions): - out *= ms_ssim[..., idx] - return out - - def __call__(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Call the MS-SSIM Loss Function. - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The ground truth value - y_pred: :class:`plaidml.tile.Value` - The predicted value - - Returns - ------- - :class:`plaidml.tile.Value` - The MS-SSIM Loss value - """ - ms_ssim = self._get_ms_ssim(y_true, y_pred) - retval = 1. - ms_ssim - return K.mean(retval) - - class LossWrapper(): # pylint:disable=too-few-public-methods """ A wrapper class for multiple keras losses to enable multiple weighted loss functions on a single output and masking. diff --git a/lib/model/loss/loss_tf.py b/lib/model/loss/loss_tf.py index cacb3ff1fc..4b94f8f058 100644 --- a/lib/model/loss/loss_tf.py +++ b/lib/model/loss/loss_tf.py @@ -14,149 +14,11 @@ from tensorflow.keras import backend as K # pylint:disable=import-error from .feature_loss_tf import LPIPSLoss #pylint:disable=unused-import # noqa +from .perceptual_loss_tf import DSSIMObjective, GMSDLoss, LDRFLIPLoss, MSSIMLoss #pylint:disable=unused-import # noqa logger = logging.getLogger(__name__) -class DSSIMObjective(): # pylint:disable=too-few-public-methods - """ DSSIM Loss Functions - - Difference of Structural Similarity (DSSIM loss function). - - Adapted from :func:`tensorflow.image.ssim` for a pure keras implentation. - - Notes - ----- - Channels last only. Assumes all input images are the same size and square - - Parameters - ---------- - k_1: float, optional - Parameter of the SSIM. Default: `0.01` - k_2: float, optional - Parameter of the SSIM. Default: `0.03` - filter_size: int, optional - size of gaussian filter Default: `11` - filter_sigma: float, optional - Width of gaussian filter Default: `1.5` - max_value: float, optional - Max value of the output. Default: `1.0` - - Notes - ------ - You should add a regularization term like a l2 loss in addition to this one. - """ - def __init__(self, - k_1: float = 0.01, - k_2: float = 0.03, - filter_size: int = 11, - filter_sigma: float = 1.5, - max_value: float = 1.0) -> None: - self._filter_size = filter_size - self._filter_sigma = filter_sigma - self._kernel = self._get_kernel() - - compensation = 1.0 - self._c1 = (k_1 * max_value) ** 2 - self._c2 = ((k_2 * max_value) ** 2) * compensation - - def _get_kernel(self) -> tf.Tensor: - """ Obtain the base kernel for performing depthwise convolution. - - Returns - ------- - :class:`tf.Tensor` - The gaussian kernel based on selected size and sigma - """ - coords = np.arange(self._filter_size, dtype="float32") - coords -= (self._filter_size - 1) / 2. - - kernel = np.square(coords) - kernel *= -0.5 / np.square(self._filter_sigma) - kernel = np.reshape(kernel, (1, -1)) + np.reshape(kernel, (-1, 1)) - kernel = K.constant(np.reshape(kernel, (1, -1))) - kernel = K.softmax(kernel) - kernel = K.reshape(kernel, (self._filter_size, self._filter_size, 1, 1)) - return kernel - - @classmethod - def _depthwise_conv2d(cls, image: tf.Tensor, kernel: tf.Tensor) -> tf.Tensor: - """ Perform a standardized depthwise convolution. - - Parameters - ---------- - image: :class:`tf.Tensor` - Batch of images, channels last, to perform depthwise convolution - kernel: :class:`tf.Tensor` - convolution kernel - - Returns - ------- - :class:`tf.Tensor` - The output from the convolution - """ - return K.depthwise_conv2d(image, kernel, strides=(1, 1), padding="valid") - - def _get_ssim(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> Tuple[tf.Tensor, tf.Tensor]: - """ Obtain the structural similarity between a batch of true and predicted images. - - Parameters - ---------- - y_true: :class:`tf.Tensor` - The input batch of ground truth images - y_pred: :class:`tf.Tensor` - The input batch of predicted images - - Returns - ------- - :class:`tf.Tensor` - The SSIM for the given images - :class:`tf.Tensor` - The Contrast for the given images - """ - channels = K.int_shape(y_true)[-1] - kernel = K.tile(self._kernel, (1, 1, channels, 1)) - - # SSIM luminance measure is (2 * mu_x * mu_y + c1) / (mu_x ** 2 + mu_y ** 2 + c1) - mean_true = self._depthwise_conv2d(y_true, kernel) - mean_pred = self._depthwise_conv2d(y_pred, kernel) - num_lum = mean_true * mean_pred * 2.0 - den_lum = K.square(mean_true) + K.square(mean_pred) - luminance = (num_lum + self._c1) / (den_lum + self._c1) - - # SSIM contrast-structure measure is (2 * cov_{xy} + c2) / (cov_{xx} + cov_{yy} + c2) - num_con = self._depthwise_conv2d(y_true * y_pred, kernel) * 2.0 - den_con = self._depthwise_conv2d(K.square(y_true) + K.square(y_pred), kernel) - - contrast = (num_con - num_lum + self._c2) / (den_con - den_lum + self._c2) - - # Average over the height x width dimensions - axes = (-3, -2) - ssim = K.mean(luminance * contrast, axis=axes) - contrast = K.mean(contrast, axis=axes) - - return ssim, contrast - - def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: - """ Call the DSSIM or MS-DSSIM Loss Function. - - Parameters - ---------- - y_true: :class:`tf.Tensor` - The input batch of ground truth images - y_pred: :class:`tf.Tensor` - The input batch of predicted images - - Returns - ------- - :class:`tf.Tensor` - The DSSIM or MS-DSSIM for the given images - """ - ssim = self._get_ssim(y_true, y_pred)[0] - retval = (1. - ssim) / 2.0 - return K.mean(retval) - - class FocalFrequencyLoss(): # pylint:disable=too-few-public-methods """ Focal Frequencey Loss Function. @@ -403,113 +265,6 @@ def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: return loss -class GMSDLoss(): # pylint:disable=too-few-public-methods - """ Gradient Magnitude Similarity Deviation Loss. - - Improved image quality metric over MS-SSIM with easier calculations - - References - ---------- - http://www4.comp.polyu.edu.hk/~cslzhang/IQA/GMSD/GMSD.htm - https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf - """ - def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: - """ Return the Gradient Magnitude Similarity Deviation Loss. - - Parameters - ---------- - y_true: :class:`tf.Tensor` - The ground truth value - y_pred: :class:`tf.Tensor` - The predicted value - - Returns - ------- - :class:`tf.Tensor` - The loss value - """ - true_edge = self._scharr_edges(y_true, True) - pred_edge = self._scharr_edges(y_pred, True) - ephsilon = 0.0025 - upper = 2.0 * true_edge * pred_edge - lower = K.square(true_edge) + K.square(pred_edge) - gms = (upper + ephsilon) / (lower + ephsilon) - gmsd = K.std(gms, axis=(1, 2, 3), keepdims=True) - gmsd = K.squeeze(gmsd, axis=-1) - return gmsd - - @classmethod - def _scharr_edges(cls, image: tf.Tensor, magnitude: bool) -> tf.Tensor: - """ Returns a tensor holding modified Scharr edge maps. - - Parameters - ---------- - image: :class:`tf.Tensor` - Image tensor with shape [batch_size, h, w, d] and type float32. The image(s) must be - 2x2 or larger. - magnitude: bool - Boolean to determine if the edge magnitude or edge direction is returned - - Returns - ------- - :class:`tf.Tensor` - Tensor holding edge maps for each channel. Returns a tensor with shape `[batch_size, h, - w, d, 2]` where the last two dimensions hold `[[dy[0], dx[0]], [dy[1], dx[1]], ..., - [dy[d-1], dx[d-1]]]` calculated using the Scharr filter. - """ - - # Define vertical and horizontal Scharr filters. - static_image_shape = image.get_shape() - image_shape = K.shape(image) - - # 5x5 modified Scharr kernel ( reshape to (5,5,1,2) ) - matrix = np.array([[[[0.00070, 0.00070]], - [[0.00520, 0.00370]], - [[0.03700, 0.00000]], - [[0.00520, -0.0037]], - [[0.00070, -0.0007]]], - [[[0.00370, 0.00520]], - [[0.11870, 0.11870]], - [[0.25890, 0.00000]], - [[0.11870, -0.1187]], - [[0.00370, -0.0052]]], - [[[0.00000, 0.03700]], - [[0.00000, 0.25890]], - [[0.00000, 0.00000]], - [[0.00000, -0.2589]], - [[0.00000, -0.0370]]], - [[[-0.0037, 0.00520]], - [[-0.1187, 0.11870]], - [[-0.2589, 0.00000]], - [[-0.1187, -0.1187]], - [[-0.0037, -0.0052]]], - [[[-0.0007, 0.00070]], - [[-0.0052, 0.00370]], - [[-0.0370, 0.00000]], - [[-0.0052, -0.0037]], - [[-0.0007, -0.0007]]]]) - num_kernels = [2] - kernels = K.constant(matrix, dtype='float32') - kernels = K.tile(kernels, [1, 1, image_shape[-1], 1]) - - # Use depth-wise convolution to calculate edge maps per channel. - # Output tensor has shape [batch_size, h, w, d * num_kernels]. - pad_sizes = [[0, 0], [2, 2], [2, 2], [0, 0]] - padded = tf.pad(image, # pylint:disable=unexpected-keyword-arg,no-value-for-parameter - pad_sizes, - mode='REFLECT') - output = K.depthwise_conv2d(padded, kernels) - - if not magnitude: # direction of edges - # Reshape to [batch_size, h, w, d, num_kernels]. - shape = K.concatenate([image_shape, num_kernels], axis=0) - output = K.reshape(output, shape=shape) - output.set_shape(static_image_shape.concatenate(num_kernels)) - output = tf.atan(K.squeeze(output[:, :, :, :, 0] / output[:, :, :, :, 1], axis=None)) - # magnitude of edges -- unified x & y edges don't work well with Neural Networks - return output - - class GradientLoss(): # pylint:disable=too-few-public-methods """ Gradient Loss Function. @@ -789,100 +544,6 @@ def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: # noqa,p return loss -class MSSIMLoss(): # pylint:disable=too-few-public-methods - """ Multiscale Structural Similarity Loss Function - - Parameters - ---------- - k_1: float, optional - Parameter of the SSIM. Default: `0.01` - k_2: float, optional - Parameter of the SSIM. Default: `0.03` - filter_size: int, optional - size of gaussian filter Default: `11` - filter_sigma: float, optional - Width of gaussian filter Default: `1.5` - max_value: float, optional - Max value of the output. Default: `1.0` - power_factors: tuple, optional - Iterable of weights for each of the scales. The number of scales used is the length of the - list. Index 0 is the unscaled resolution's weight and each increasing scale corresponds to - the image being downsampled by 2. Defaults to the values obtained in the original paper. - Default: (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) - - Notes - ------ - You should add a regularization term like a l2 loss in addition to this one. - """ - def __init__(self, - k_1: float = 0.01, - k_2: float = 0.03, - filter_size: int = 11, - filter_sigma: float = 1.5, - max_value: float = 1.0, - power_factors: Tuple[float, ...] = (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) - ) -> None: - self.filter_size = filter_size - self.filter_sigma = filter_sigma - self.k_1 = k_1 - self.k_2 = k_2 - self.max_value = max_value - self.power_factors = power_factors - - def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: - """ Call the MS-SSIM Loss Function. - - Parameters - ---------- - y_true: :class:`tf.Tensor` - The ground truth value - y_pred: :class:`tf.Tensor` - The predicted value - - Returns - ------- - :class:`tf.Tensor` - The MS-SSIM Loss value - """ - im_size = K.int_shape(y_true)[1] - # filter size cannot be larger than the smallest scale - smallest_scale = self._get_smallest_size(im_size, len(self.power_factors) - 1) - filter_size = min(self.filter_size, smallest_scale) - - ms_ssim = tf.image.ssim_multiscale(y_true, - y_pred, - self.max_value, - power_factors=self.power_factors, - filter_size=filter_size, - filter_sigma=self.filter_sigma, - k1=self.k_1, - k2=self.k_2) - ms_ssim_loss = 1. - ms_ssim - return K.mean(ms_ssim_loss) - - def _get_smallest_size(self, size: int, idx: int) -> int: - """ Recursive function to obtain the smallest size that the image will be scaled to. - - Parameters - ---------- - size: int - The current scaled size to iterate through - idx: int - The current iteration to be performed. When iteration hits zero the value will - be returned - - Returns - ------- - int - The smallest size the image will be scaled to based on the original image size and - the amount of scaling factors that will occur - """ - logger.debug("scale id: %s, size: %s", idx, size) - if idx > 0: - size = self._get_smallest_size(size // 2, idx - 1) - return size - - class LossWrapper(tf.keras.losses.Loss): """ A wrapper class for multiple keras losses to enable multiple masked weighted loss functions on a single output. diff --git a/lib/model/loss/perceptual_loss_plaid.py b/lib/model/loss/perceptual_loss_plaid.py new file mode 100644 index 0000000000..be52822b13 --- /dev/null +++ b/lib/model/loss/perceptual_loss_plaid.py @@ -0,0 +1,849 @@ +#!/usr/bin/env python3 +""" PlaidML Keras implementation of Perceptual Loss Functions for faceswap.py """ + +import logging +import sys + +from typing import Dict, List, Optional, Tuple + +import numpy as np +import plaidml + +from keras import backend as K + +from lib.keras_utils import ColorSpaceConvert, frobenius_norm, replicate_pad +from lib.plaidml_utils import pad +from lib.utils import FaceswapError + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + + +logger = logging.getLogger(__name__) + + +class DSSIMObjective(): # pylint:disable=too-few-public-methods + """ DSSIM Loss Function + + Difference of Structural Similarity (DSSIM loss function). + + Adapted from :func:`tensorflow.image.ssim` for a pure keras implentation. + + Notes + ----- + Channels last only. Assumes all input images are the same size and square + + Parameters + ---------- + k_1: float, optional + Parameter of the SSIM. Default: `0.01` + k_2: float, optional + Parameter of the SSIM. Default: `0.03` + filter_size: int, optional + size of gaussian filter Default: `11` + filter_sigma: float, optional + Width of gaussian filter Default: `1.5` + max_value: float, optional + Max value of the output. Default: `1.0` + + Notes + ------ + You should add a regularization term like a l2 loss in addition to this one. + """ + def __init__(self, + k_1: float = 0.01, + k_2: float = 0.03, + filter_size: int = 11, + filter_sigma: float = 1.5, + max_value: float = 1.0) -> None: + self._filter_size = filter_size + self._filter_sigma = filter_sigma + self._kernel = self._get_kernel() + + compensation = 1.0 + self._c1 = (k_1 * max_value) ** 2 + self._c2 = ((k_2 * max_value) ** 2) * compensation + + def _get_kernel(self) -> plaidml.tile.Value: + """ Obtain the base kernel for performing depthwise convolution. + + Returns + ------- + :class:`plaidml.tile.Value` + The gaussian kernel based on selected size and sigma + """ + coords = np.arange(self._filter_size, dtype="float32") + coords -= (self._filter_size - 1) / 2. + + kernel = np.square(coords) + kernel *= -0.5 / np.square(self._filter_sigma) + kernel = np.reshape(kernel, (1, -1)) + np.reshape(kernel, (-1, 1)) + kernel = K.constant(np.reshape(kernel, (1, -1))) + kernel = K.softmax(kernel) + kernel = K.reshape(kernel, (self._filter_size, self._filter_size, 1, 1)) + return kernel + + @classmethod + def _depthwise_conv2d(cls, + image: plaidml.tile.Value, + kernel: plaidml.tile.Value) -> plaidml.tile.Value: + """ Perform a standardized depthwise convolution. + + Parameters + ---------- + image: :class:`plaidml.tile.Value` + Batch of images, channels last, to perform depthwise convolution + kernel: :class:`plaidml.tile.Value` + convolution kernel + + Returns + ------- + :class:`plaidml.tile.Value` + The output from the convolution + """ + return K.depthwise_conv2d(image, kernel, strides=(1, 1), padding="valid") + + def _get_ssim(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> Tuple[plaidml.tile.Value, plaidml.tile.Value]: + """ Obtain the structural similarity between a batch of true and predicted images. + + Parameters + ---------- + y_true: :class:`plaidml.tile.Value` + The input batch of ground truth images + y_pred: :class:`plaidml.tile.Value` + The input batch of predicted images + + Returns + ------- + :class:`plaidml.tile.Value` + The SSIM for the given images + :class:`plaidml.tile.Value` + The Contrast for the given images + """ + channels = K.int_shape(y_pred)[-1] + kernel = K.tile(self._kernel, (1, 1, channels, 1)) + + # SSIM luminance measure is (2 * mu_x * mu_y + c1) / (mu_x ** 2 + mu_y ** 2 + c1) + mean_true = self._depthwise_conv2d(y_true, kernel) + mean_pred = self._depthwise_conv2d(y_pred, kernel) + num_lum = mean_true * mean_pred * 2.0 + den_lum = K.square(mean_true) + K.square(mean_pred) + luminance = (num_lum + self._c1) / (den_lum + self._c1) + + # SSIM contrast-structure measure is (2 * cov_{xy} + c2) / (cov_{xx} + cov_{yy} + c2) + num_con = self._depthwise_conv2d(y_true * y_pred, kernel) * 2.0 + den_con = self._depthwise_conv2d(K.square(y_true) + K.square(y_pred), kernel) + + contrast = (num_con - num_lum + self._c2) / (den_con - den_lum + self._c2) + + # Average over the height x width dimensions + axes = (-3, -2) + ssim = K.mean(luminance * contrast, axis=axes) + contrast = K.mean(contrast, axis=axes) + + return ssim, contrast + + def __call__(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: + """ Call the DSSIM or MS-DSSIM Loss Function. + + Parameters + ---------- + y_true: :class:`plaidml.tile.Value` + The input batch of ground truth images + y_pred: :class:`plaidml.tile.Value` + The input batch of predicted images + + Returns + ------- + :class:`plaidml.tile.Value` + The DSSIM or MS-DSSIM for the given images + """ + ssim = self._get_ssim(y_true, y_pred)[0] + retval = (1. - ssim) / 2.0 + return K.mean(retval) + + +class GMSDLoss(): # pylint:disable=too-few-public-methods + """ Gradient Magnitude Similarity Deviation Loss. + + Improved image quality metric over MS-SSIM with easier calculations + + References + ---------- + http://www4.comp.polyu.edu.hk/~cslzhang/IQA/GMSD/GMSD.htm + https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf + """ + def __call__(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: + """ Return the Gradient Magnitude Similarity Deviation Loss. + + Parameters + ---------- + y_true: :class:`plaidml.tile.Value` + The ground truth value + y_pred: :class:`plaidml.tile.Value` + The predicted value + + Returns + ------- + :class:`plaidml.tile.Value` + The loss value + """ + image_shape = K.int_shape(y_pred) + true_edge = self._scharr_edges(y_true, True, image_shape) + pred_edge = self._scharr_edges(y_pred, True, image_shape) + ephsilon = 0.0025 + upper = 2.0 * true_edge * pred_edge + lower = K.square(true_edge) + K.square(pred_edge) + gms = (upper + ephsilon) / (lower + ephsilon) + gmsd = K.std(gms, axis=(1, 2, 3), keepdims=True) + gmsd = K.squeeze(gmsd, axis=-1) + return gmsd + + @classmethod + def _scharr_edges(cls, + image: plaidml.tile.Value, + magnitude: bool, + image_shape: Tuple[None, int, int, int]) -> plaidml.tile.Value: + """ Returns a tensor holding modified Scharr edge maps. + + Parameters + ---------- + image: :class:`plaidml.tile.Value` + Image tensor with shape [batch_size, h, w, d] and type float32. The image(s) must be + 2x2 or larger. + magnitude: bool + Boolean to determine if the edge magnitude or edge direction is returned + image_shape: tuple + The shape of the incoming image + + Returns + ------- + :class:`plaidml.tile.Value` + Tensor holding edge maps for each channel. Returns a tensor with shape `[batch_size, h, + w, d, 2]` where the last two dimensions hold `[[dy[0], dx[0]], [dy[1], dx[1]], ..., + [dy[d-1], dx[d-1]]]` calculated using the Scharr filter. + """ + # Define vertical and horizontal Scharr filters. + # 5x5 modified Scharr kernel ( reshape to (5,5,1,2) ) + matrix = np.array([[[[0.00070, 0.00070]], + [[0.00520, 0.00370]], + [[0.03700, 0.00000]], + [[0.00520, -0.0037]], + [[0.00070, -0.0007]]], + [[[0.00370, 0.00520]], + [[0.11870, 0.11870]], + [[0.25890, 0.00000]], + [[0.11870, -0.1187]], + [[0.00370, -0.0052]]], + [[[0.00000, 0.03700]], + [[0.00000, 0.25890]], + [[0.00000, 0.00000]], + [[0.00000, -0.2589]], + [[0.00000, -0.0370]]], + [[[-0.0037, 0.00520]], + [[-0.1187, 0.11870]], + [[-0.2589, 0.00000]], + [[-0.1187, -0.1187]], + [[-0.0037, -0.0052]]], + [[[-0.0007, 0.00070]], + [[-0.0052, 0.00370]], + [[-0.0370, 0.00000]], + [[-0.0052, -0.0037]], + [[-0.0007, -0.0007]]]]) + # num_kernels = [2] + kernels = K.constant(matrix, dtype='float32') + kernels = K.tile(kernels, [1, 1, image_shape[-1], 1]) + + # Use depth-wise convolution to calculate edge maps per channel. + # Output tensor has shape [batch_size, h, w, d * num_kernels]. + pad_sizes = [[0, 0], [2, 2], [2, 2], [0, 0]] + padded = pad(image, pad_sizes, mode='REFLECT') + output = K.depthwise_conv2d(padded, kernels) + + # TODO magnitude not implemented for plaidml + if not magnitude: # direction of edges + raise FaceswapError("Magnitude for GMSD Loss is not implemented in PlaidML") + # # Reshape to [batch_size, h, w, d, num_kernels]. + # shape = K.concatenate([image_shape, num_kernels], axis=0) + # output = K.reshape(output, shape=shape) + # output.set_shape(static_image_shape.concatenate(num_kernels)) + # output = tf.atan(K.squeeze(output[:, :, :, :, 0] / output[:, :, :, :, 1], axis=None)) + # magnitude of edges -- unified x & y edges don't work well with Neural Networks + return output + + +class LDRFLIPLoss(): # pylint:disable=too-few-public-methods + """ Computes the LDR-FLIP error map between two LDR images, assuming the images are observed + at a certain number of pixels per degree of visual angle. + + References + ---------- + https://research.nvidia.com/sites/default/files/node/3260/FLIP_Paper.pdf + https://github.com/NVlabs/flip + + License + ------- + BSD 3-Clause License + Copyright (c) 2020-2022, NVIDIA Corporation & AFFILIATES. All rights reserved. + Redistribution and use in source and binary forms, with or without modification, are permitted + provided that the following conditions are met: + Redistributions of source code must retain the above copyright notice, this list of conditions + and the following disclaimer. + Redistributions in binary form must reproduce the above copyright notice, this list of + conditions and the following disclaimer in the documentation and/or other materials provided + with the distribution. + Neither the name of the copyright holder nor the names of its contributors may be used to + endorse or promote products derived from this software without specific prior written + permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR + IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + + Parameters + ---------- + computed_distance_exponent: float, Optional + The computed distance exponent to apply to Hunt adjusted, filtered colors. + (`qc` in original paper). Default: `0.7` + feature_exponent: float, Optional + The feature exponent to apply for increasing the impact of feature difference on the + final loss value. (`qf` in original paper). Default: `0.5` + lower_threshold_exponent: float, Optional + The `pc` exponent for the color pipeline as described in the original paper: Default: `0.4` + upper_threshold_exponent: float, Optional + The `pt` exponent for the color pipeline as described in the original paper. + Default: `0.95` + epsilon: float + A small value to improve training stability. Default: `1e-15` + pixels_per_degree: float, Optional + The estimated number of pixels per degree of visual angle of the observer. This effectively + impacts the tolerance when calculating loss. The default corresponds to viewing images on a + 0.7m wide 4K monitor at 0.7m from the display. Default: ``None`` + color_order: str + The `"BGR"` or `"RGB"` color order of the incoming images + """ + def __init__(self, + computed_distance_exponent: float = 0.7, + feature_exponent: float = 0.5, + lower_threshold_exponent: float = 0.4, + upper_threshold_exponent: float = 0.95, + epsilon: float = 1e-15, + pixels_per_degree: Optional[float] = None, + color_order: Literal["bgr", "rgb"] = "bgr") -> None: + logger.debug("Initializing: %s (computed_distance_exponent '%s', feature_exponent: %s, " + "lower_threshold_exponent: %s, upper_threshold_exponent: %s, epsilon: %s, " + "pixels_per_degree: %s, color_order: %s)", self.__class__.__name__, + computed_distance_exponent, feature_exponent, lower_threshold_exponent, + upper_threshold_exponent, epsilon, pixels_per_degree, color_order) + + self._computed_distance_exponent = computed_distance_exponent + self._feature_exponent = feature_exponent + self._pc = lower_threshold_exponent + self._pt = upper_threshold_exponent + self._epsilon = epsilon + self._color_order = color_order.lower() + + if pixels_per_degree is None: + pixels_per_degree = (0.7 * 3840 / 0.7) * np.pi / 180 + self._pixels_per_degree = pixels_per_degree + self._spatial_filters = _SpatialFilters(pixels_per_degree) + self._feature_detector = _FeatureDetection(pixels_per_degree) + logger.debug("Initialized: %s ", self.__class__.__name__) + + def __call__(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: + """ Call the LDR Flip Loss Function + + Parameters + ---------- + y_true: :class:`plaidml.tile.Value` + The ground truth batch of images + y_pred: :class:`plaidml.tile.Value` + The predicted batch of images + + Returns + ------- + :class::class:`plaidml.tile.Value` + The calculated Flip loss value + """ + # TODO Fix for AMD. This loss function runs fine under plaidML end to end, but the output + # is NaN when tested on CPU. I cannot find a way to debug the values in plaidML tensors + # so cannot investigate where the NaNs are getting introduced. + # This may be a CPU issue (I cannot get plaidML to detect my Nvidia GPU) so currently this + # loss is enabled. If reports of NaNs then raise a NotImplementedError until issue can be + # properly addressed + if self._color_order == "bgr": # Switch models training in bgr order to rgb + y_true = y_true[..., 2::-1] + y_pred = y_pred[..., 2::-1] + + y_true = K.clip(y_true, 0, 1.) + y_pred = K.clip(y_pred, 0, 1.) + + rgb2ycxcz = ColorSpaceConvert("srgb", "ycxcz", batch_shape=K.int_shape(y_pred)) + true_ycxcz = rgb2ycxcz(y_true) + pred_ycxcz = rgb2ycxcz(y_pred) + + delta_e_color = self._color_pipeline(true_ycxcz, pred_ycxcz) + delta_e_features = self._process_features(true_ycxcz, pred_ycxcz) + + loss = K.pow(delta_e_color, 1 - delta_e_features) + return loss + + def _color_pipeline(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: + """ Perform the color processing part of the FLIP loss function + + Parameters + ---------- + y_true: :class:`plaidml.tile.Value` + The ground truth batch of images in YCxCz color space + y_pred: :class:`plaidml.tile.Value` + The predicted batch of images in YCxCz color space + + Returns + ------- + :class:`plaidml.tile.Value` + The exponentiated, maximum HyAB difference between two colors in Hunt-adjusted + L*A*B* space + """ + filtered_true = self._spatial_filters(y_true) + filtered_pred = self._spatial_filters(y_pred) + + rgb2lab = ColorSpaceConvert(from_space="rgb", + to_space="lab", + batch_shape=K.int_shape(filtered_pred)) + preprocessed_true = self._hunt_adjustment(rgb2lab(filtered_true)) + preprocessed_pred = self._hunt_adjustment(rgb2lab(filtered_pred)) + hunt_adjusted_green = self._hunt_adjustment( + rgb2lab(K.constant(np.array([[[[0.0, 1.0, 0.0]]]]), dtype="float32"))) + hunt_adjusted_blue = self._hunt_adjustment( + rgb2lab(K.constant(np.array([[[[0.0, 0.0, 1.0]]]]), dtype="float32"))) + + delta = self._hyab(preprocessed_true, preprocessed_pred) + power_delta = K.pow(delta, self._computed_distance_exponent) + cmax = K.pow(self._hyab(hunt_adjusted_green, hunt_adjusted_blue), + self._computed_distance_exponent) + return self._redistribute_errors(power_delta, cmax) + + def _process_features(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: + """ Perform the color processing part of the FLIP loss function + + Parameters + ---------- + y_true: :class:`plaidml.tile.Value` + The ground truth batch of images in YCxCz color space + y_pred: :class:`plaidml.tile.Value` + The predicted batch of images in YCxCz color space + + Returns + ------- + :class:`plaidml.tile.Value` + The exponentiated features delta + """ + col_y_true = (y_true[..., 0:1] + 16) / 116. + col_y_pred = (y_pred[..., 0:1] + 16) / 116. + + edges_true = self._feature_detector(col_y_true, "edge") + points_true = self._feature_detector(col_y_true, "point") + edges_pred = self._feature_detector(col_y_pred, "edge") + points_pred = self._feature_detector(col_y_pred, "point") + + delta = K.maximum(K.abs(frobenius_norm(edges_true) - frobenius_norm(edges_pred)), + K.abs(frobenius_norm(points_pred) - frobenius_norm(points_true))) + + delta = K.clip(delta, self._epsilon, None) + return K.pow(((1 / np.sqrt(2)) * delta), self._feature_exponent) + + @classmethod + def _hunt_adjustment(cls, image: plaidml.tile.Value) -> plaidml.tile.Value: + """ Apply Hunt-adjustment to an image in L*a*b* color space + + Parameters + ---------- + image: :class:`plaidml.tile.Value` + The batch of images in L*a*b* to adjust + + Returns + ------- + :class:`plaidml.tile.Value` + The hunt adjusted batch of images in L*a*b color space + """ + ch_l = image[..., 0:1] + adjusted = K.concatenate([ch_l, image[..., 1:] * (ch_l * 0.01)], axis=-1) + return adjusted + + def _hyab(self, y_true, y_pred): + """ Compute the HyAB distance between true and predicted images. + + Parameters + ---------- + y_true: :class:`plaidml.tile.Value` + The ground truth batch of images in standard or Hunt-adjusted L*A*B* color space + y_pred: :class:`plaidml.tile.Value` + The predicted batch of images in in standard or Hunt-adjusted L*A*B* color space + + Returns + ------- + :class:`plaidml.tile.Value` + image tensor containing the per-pixel HyAB distances between true and predicted images + """ + delta = y_true - y_pred + root = K.sqrt(K.clip(K.pow(delta[..., 0:1], 2), self._epsilon, None)) + delta_norm = frobenius_norm(delta[..., 1:3]) + return root + delta_norm + + def _redistribute_errors(self, power_delta_e_hyab, cmax): + """ Redistribute exponentiated HyAB errors to the [0,1] range + + Parameters + ---------- + power_delta_e_hyab: :class:`plaidml.tile.Value` + The exponentiated HyAb distance + cmax: :class:`plaidml.tile.Value` + The exponentiated, maximum HyAB difference between two colors in Hunt-adjusted + L*A*B* space + + Returns + ------- + :class:`plaidml.tile.Value` + The redistributed per-pixel HyAB distances (in range [0,1]) + """ + pccmax = self._pc * cmax + delta_e_c = K.switch( + power_delta_e_hyab < pccmax, + (self._pt / pccmax) * power_delta_e_hyab, + self._pt + ((power_delta_e_hyab - pccmax) / (cmax - pccmax)) * (1.0 - self._pt)) + return delta_e_c + + +class _SpatialFilters(): # pylint:disable=too-few-public-methods + """ Filters an image with channel specific spatial contrast sensitivity functions and clips + result to the unit cube in linear RGB. + + For use with LDRFlipLoss. + + Parameters + ---------- + pixels_per_degree: float + The estimated number of pixels per degree of visual angle of the observer. This effectively + impacts the tolerance when calculating loss. + """ + def __init__(self, pixels_per_degree: float) -> None: + self._pixels_per_degree = pixels_per_degree + self._spatial_filters, self._radius = self._generate_spatial_filters() + self._ycxcz2rgb = ColorSpaceConvert(from_space="ycxcz", to_space="rgb") + + def _generate_spatial_filters(self) -> Tuple[plaidml.tile.Value, int]: + """ Generates spatial contrast sensitivity filters with width depending on the number of + pixels per degree of visual angle of the observer for channels "A", "RG" and "BY" + + Returns + ------- + dict + the channels ("A" (Achromatic CSF), "RG" (Red-Green CSF) or "BY" (Blue-Yellow CSF)) as + key with the Filter kernel corresponding to the spatial contrast sensitivity function + of channel and kernel's radius + """ + mapping = dict(A=dict(a1=1, b1=0.0047, a2=0, b2=1e-5), + RG=dict(a1=1, b1=0.0053, a2=0, b2=1e-5), + BY=dict(a1=34.1, b1=0.04, a2=13.5, b2=0.025)) + + domain, radius = self._get_evaluation_domain(mapping["A"]["b1"], + mapping["A"]["b2"], + mapping["RG"]["b1"], + mapping["RG"]["b2"], + mapping["BY"]["b1"], + mapping["BY"]["b2"]) + + weights = np.array([self._generate_weights(mapping[channel], domain) + for channel in ("A", "RG", "BY")]) + weights = K.constant(np.moveaxis(weights, 0, -1), dtype="float32") + + return weights, radius + + def _get_evaluation_domain(self, + b1_a: float, + b2_a: float, + b1_rg: float, + b2_rg: float, + b1_by: float, + b2_by: float) -> Tuple[np.ndarray, int]: + """ TODO docstring """ + max_scale_parameter = max([b1_a, b2_a, b1_rg, b2_rg, b1_by, b2_by]) + delta_x = 1.0 / self._pixels_per_degree + radius = int(np.ceil(3 * np.sqrt(max_scale_parameter / (2 * np.pi**2)) + * self._pixels_per_degree)) + ax_x, ax_y = np.meshgrid(range(-radius, radius + 1), range(-radius, radius + 1)) + domain = (ax_x * delta_x) ** 2 + (ax_y * delta_x) ** 2 + return domain, radius + + @classmethod + def _generate_weights(cls, + channel: Dict[str, float], + domain: np.ndarray) -> plaidml.tile.Value: + """ TODO docstring """ + a_1, b_1, a_2, b_2 = channel["a1"], channel["b1"], channel["a2"], channel["b2"] + grad = (a_1 * np.sqrt(np.pi / b_1) * np.exp(-np.pi ** 2 * domain / b_1) + + a_2 * np.sqrt(np.pi / b_2) * np.exp(-np.pi ** 2 * domain / b_2)) + grad = grad / np.sum(grad) + grad = np.reshape(grad, (*grad.shape, 1)) + return grad + + def __call__(self, image: plaidml.tile.Value) -> plaidml.tile.Value: + """ Call the spacial filtering. + + Parameters + ---------- + image: Tensor + Image tensor to filter in YCxCz color space + + Returns + ------- + Tensor + The input image transformed to linear RGB after filtering with spatial contrast + sensitivity functions + """ + padded_image = replicate_pad(image, self._radius) + image_tilde_opponent = K.conv2d(padded_image, + self._spatial_filters, + strides=(1, 1), + padding="valid") + rgb = K.clip(self._ycxcz2rgb(image_tilde_opponent), 0., 1.) + return rgb + + +class _FeatureDetection(): # pylint:disable=too-few-public-methods + """ Detect features (i.e. edges amd points) in an achromatic YCxCz image. + + For use with LDRFlipLoss. + + Parameters + ---------- + pixels_per_degree: float + The number of pixels per degree of visual angle of the observer + """ + def __init__(self, pixels_per_degree: float) -> None: + width = 0.082 + self._std = 0.5 * width * pixels_per_degree + self._radius = int(np.ceil(3 * self._std)) + self._grid = np.meshgrid(range(-self._radius, self._radius + 1), + range(-self._radius, self._radius + 1)) + self._gradient = np.exp(-(self._grid[0] ** 2 + self._grid[1] ** 2) + / (2 * (self._std ** 2))) + + def __call__(self, image: plaidml.tile.Value, feature_type: str) -> plaidml.tile.Value: + """ Run the feature detection + + Parameters + ---------- + image: Tensor + Batch of images in YCxCz color space with normalized Y values + feature_type: str + Type of features to detect (`"edge"` or `"point"`) + + Returns + ------- + Tensor + Detected features in the 0-1 range + """ + feature_type = feature_type.lower() + + if feature_type == 'edge': + grad_x = np.multiply(-self._grid[0], self._gradient) + else: + grad_x = np.multiply(self._grid[0] ** 2 / (self._std ** 2) - 1, self._gradient) + + negative_weights_sum = -np.sum(grad_x[grad_x < 0]) + positive_weights_sum = np.sum(grad_x[grad_x > 0]) + + grad_x = K.constant(grad_x) + grad_x = K.switch(grad_x < 0, grad_x / negative_weights_sum, grad_x / positive_weights_sum) + kernel = K.expand_dims(K.expand_dims(grad_x, axis=-1), axis=-1) + + features_x = K.conv2d(replicate_pad(image, self._radius), + kernel, + strides=(1, 1), + padding="valid") + kernel = K.permute_dimensions(kernel, (1, 0, 2, 3)) + features_y = K.conv2d(replicate_pad(image, self._radius), + kernel, + strides=(1, 1), + padding="valid") + features = K.concatenate([features_x, features_y], axis=-1) + return features + + +class MSSIMLoss(DSSIMObjective): # pylint:disable=too-few-public-methods + """ Multiscale Structural Similarity Loss Function + + Parameters + ---------- + k_1: float, optional + Parameter of the SSIM. Default: `0.01` + k_2: float, optional + Parameter of the SSIM. Default: `0.03` + filter_size: int, optional + size of gaussian filter Default: `11` + filter_sigma: float, optional + Width of gaussian filter Default: `1.5` + max_value: float, optional + Max value of the output. Default: `1.0` + power_factors: tuple, optional + Iterable of weights for each of the scales. The number of scales used is the length of the + list. Index 0 is the unscaled resolution's weight and each increasing scale corresponds to + the image being downsampled by 2. Defaults to the values obtained in the original paper. + Default: (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) + + Notes + ------ + You should add a regularization term like a l2 loss in addition to this one. + """ + def __init__(self, + k_1: float = 0.01, + k_2: float = 0.03, + filter_size: int = 11, + filter_sigma: float = 1.5, + max_value: float = 1.0, + power_factors: Tuple[float, ...] = (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) + ) -> None: + super().__init__(k_1=k_1, + k_2=k_2, + filter_size=filter_size, + filter_sigma=filter_sigma, + max_value=max_value) + self._power_factors = K.constant(power_factors) + + def _get_smallest_size(self, size: int, idx: int) -> int: + """ Recursive function to obtain the smallest size that the image will be scaled to. + for MS-SSIM + + Parameters + ---------- + size: int + The current scaled size to iterate through + idx: int + The current iteration to be performed. When iteration hits zero the value will + be returned + + Returns + ------- + int + The smallest size the image will be scaled to based on the original image size and + the amount of scaling factors that will occur + """ + logger.debug("scale id: %s, size: %s", idx, size) + if idx > 0: + size = self._get_smallest_size(size // 2, idx - 1) + return size + + @classmethod + def _shrink_images(cls, images: List[plaidml.tile.Value]) -> List[plaidml.tile.Value]: + """ Reduce the dimensional space of a batch of images in half. If the images are an odd + number of pixels then pad them to an even dimension prior to shrinking + + All incoming images are assumed square. + + Parameters + ---------- + images: list + The y_true, y_pred batch of images to be shrunk + + Returns + ------- + list + The y_true, y_pred batch shrunk by half + """ + if any(x % 2 != 0 for x in K.int_shape(images[1])[1:2]): + images = [pad(img, + [[0, 0], [0, 1], [0, 1], [0, 0]], + mode="REFLECT") + for img in images] + + images = [K.pool2d(img, (2, 2), strides=(2, 2), padding="valid", pool_mode="avg") + for img in images] + + return images + + def _get_ms_ssim(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: + """ Obtain the Multiscale Stuctural Similarity metric. + + Parameters + ---------- + y_true: :class:`plaidml.tile.Value` + The input batch of ground truth images + y_pred: :class:`plaidml.tile.Value` + The input batch of predicted images + + Returns + ------- + :class:`plaidml.tile.Value` + The MS-SSIM for the given images + """ + im_size = K.int_shape(y_pred)[1] + # filter size cannot be larger than the smallest scale + recursions = K.int_shape(self._power_factors)[0] + smallest_scale = self._get_smallest_size(im_size, recursions - 1) + if smallest_scale < self._filter_size: + self._filter_size = smallest_scale + self._kernel = self._get_kernel() + + images = [y_true, y_pred] + contrasts = [] + + for idx in range(recursions): + images = self._shrink_images(images) if idx > 0 else images + ssim, contrast = self._get_ssim(*images) + + if idx < recursions - 1: + contrasts.append(K.relu(K.expand_dims(contrast, axis=-1))) + + contrasts.append(K.relu(K.expand_dims(ssim, axis=-1))) + mcs_and_ssim = K.concatenate(contrasts, axis=-1) + ms_ssim = K.pow(mcs_and_ssim, self._power_factors) + + # K.prod does not work in plaidml so slow recursion it is + out = ms_ssim[..., 0] + for idx in range(1, recursions): + out *= ms_ssim[..., idx] + return out + + def __call__(self, + y_true: plaidml.tile.Value, + y_pred: plaidml.tile.Value) -> plaidml.tile.Value: + """ Call the MS-SSIM Loss Function. + + Parameters + ---------- + y_true: :class:`plaidml.tile.Value` + The ground truth value + y_pred: :class:`plaidml.tile.Value` + The predicted value + + Returns + ------- + :class:`plaidml.tile.Value` + The MS-SSIM Loss value + """ + ms_ssim = self._get_ms_ssim(y_true, y_pred) + retval = 1. - ms_ssim + return K.mean(retval) diff --git a/lib/model/loss/perceptual_loss_tf.py b/lib/model/loss/perceptual_loss_tf.py new file mode 100644 index 0000000000..a2936e586e --- /dev/null +++ b/lib/model/loss/perceptual_loss_tf.py @@ -0,0 +1,757 @@ +#!/usr/bin/env python3 +""" TF Keras implementation of Perceptual Loss Functions for faceswap.py """ + +import logging +import sys + +from typing import Dict, Optional, Tuple + +import numpy as np +import tensorflow as tf + +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras import backend as K # pylint:disable=import-error + +from lib.keras_utils import ColorSpaceConvert, frobenius_norm, replicate_pad + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + +logger = logging.getLogger(__name__) + + +class DSSIMObjective(): # pylint:disable=too-few-public-methods + """ DSSIM Loss Functions + + Difference of Structural Similarity (DSSIM loss function). + + Adapted from :func:`tensorflow.image.ssim` for a pure keras implentation. + + Notes + ----- + Channels last only. Assumes all input images are the same size and square + + Parameters + ---------- + k_1: float, optional + Parameter of the SSIM. Default: `0.01` + k_2: float, optional + Parameter of the SSIM. Default: `0.03` + filter_size: int, optional + size of gaussian filter Default: `11` + filter_sigma: float, optional + Width of gaussian filter Default: `1.5` + max_value: float, optional + Max value of the output. Default: `1.0` + + Notes + ------ + You should add a regularization term like a l2 loss in addition to this one. + """ + def __init__(self, + k_1: float = 0.01, + k_2: float = 0.03, + filter_size: int = 11, + filter_sigma: float = 1.5, + max_value: float = 1.0) -> None: + self._filter_size = filter_size + self._filter_sigma = filter_sigma + self._kernel = self._get_kernel() + + compensation = 1.0 + self._c1 = (k_1 * max_value) ** 2 + self._c2 = ((k_2 * max_value) ** 2) * compensation + + def _get_kernel(self) -> tf.Tensor: + """ Obtain the base kernel for performing depthwise convolution. + + Returns + ------- + :class:`tf.Tensor` + The gaussian kernel based on selected size and sigma + """ + coords = np.arange(self._filter_size, dtype="float32") + coords -= (self._filter_size - 1) / 2. + + kernel = np.square(coords) + kernel *= -0.5 / np.square(self._filter_sigma) + kernel = np.reshape(kernel, (1, -1)) + np.reshape(kernel, (-1, 1)) + kernel = K.constant(np.reshape(kernel, (1, -1))) + kernel = K.softmax(kernel) + kernel = K.reshape(kernel, (self._filter_size, self._filter_size, 1, 1)) + return kernel + + @classmethod + def _depthwise_conv2d(cls, image: tf.Tensor, kernel: tf.Tensor) -> tf.Tensor: + """ Perform a standardized depthwise convolution. + + Parameters + ---------- + image: :class:`tf.Tensor` + Batch of images, channels last, to perform depthwise convolution + kernel: :class:`tf.Tensor` + convolution kernel + + Returns + ------- + :class:`tf.Tensor` + The output from the convolution + """ + return K.depthwise_conv2d(image, kernel, strides=(1, 1), padding="valid") + + def _get_ssim(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> Tuple[tf.Tensor, tf.Tensor]: + """ Obtain the structural similarity between a batch of true and predicted images. + + Parameters + ---------- + y_true: :class:`tf.Tensor` + The input batch of ground truth images + y_pred: :class:`tf.Tensor` + The input batch of predicted images + + Returns + ------- + :class:`tf.Tensor` + The SSIM for the given images + :class:`tf.Tensor` + The Contrast for the given images + """ + channels = K.int_shape(y_true)[-1] + kernel = K.tile(self._kernel, (1, 1, channels, 1)) + + # SSIM luminance measure is (2 * mu_x * mu_y + c1) / (mu_x ** 2 + mu_y ** 2 + c1) + mean_true = self._depthwise_conv2d(y_true, kernel) + mean_pred = self._depthwise_conv2d(y_pred, kernel) + num_lum = mean_true * mean_pred * 2.0 + den_lum = K.square(mean_true) + K.square(mean_pred) + luminance = (num_lum + self._c1) / (den_lum + self._c1) + + # SSIM contrast-structure measure is (2 * cov_{xy} + c2) / (cov_{xx} + cov_{yy} + c2) + num_con = self._depthwise_conv2d(y_true * y_pred, kernel) * 2.0 + den_con = self._depthwise_conv2d(K.square(y_true) + K.square(y_pred), kernel) + + contrast = (num_con - num_lum + self._c2) / (den_con - den_lum + self._c2) + + # Average over the height x width dimensions + axes = (-3, -2) + ssim = K.mean(luminance * contrast, axis=axes) + contrast = K.mean(contrast, axis=axes) + + return ssim, contrast + + def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + """ Call the DSSIM or MS-DSSIM Loss Function. + + Parameters + ---------- + y_true: :class:`tf.Tensor` + The input batch of ground truth images + y_pred: :class:`tf.Tensor` + The input batch of predicted images + + Returns + ------- + :class:`tf.Tensor` + The DSSIM or MS-DSSIM for the given images + """ + ssim = self._get_ssim(y_true, y_pred)[0] + retval = (1. - ssim) / 2.0 + return K.mean(retval) + + +class GMSDLoss(): # pylint:disable=too-few-public-methods + """ Gradient Magnitude Similarity Deviation Loss. + + Improved image quality metric over MS-SSIM with easier calculations + + References + ---------- + http://www4.comp.polyu.edu.hk/~cslzhang/IQA/GMSD/GMSD.htm + https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf + """ + def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + """ Return the Gradient Magnitude Similarity Deviation Loss. + + Parameters + ---------- + y_true: :class:`tf.Tensor` + The ground truth value + y_pred: :class:`tf.Tensor` + The predicted value + + Returns + ------- + :class:`tf.Tensor` + The loss value + """ + true_edge = self._scharr_edges(y_true, True) + pred_edge = self._scharr_edges(y_pred, True) + ephsilon = 0.0025 + upper = 2.0 * true_edge * pred_edge + lower = K.square(true_edge) + K.square(pred_edge) + gms = (upper + ephsilon) / (lower + ephsilon) + gmsd = K.std(gms, axis=(1, 2, 3), keepdims=True) + gmsd = K.squeeze(gmsd, axis=-1) + return gmsd + + @classmethod + def _scharr_edges(cls, image: tf.Tensor, magnitude: bool) -> tf.Tensor: + """ Returns a tensor holding modified Scharr edge maps. + + Parameters + ---------- + image: :class:`tf.Tensor` + Image tensor with shape [batch_size, h, w, d] and type float32. The image(s) must be + 2x2 or larger. + magnitude: bool + Boolean to determine if the edge magnitude or edge direction is returned + + Returns + ------- + :class:`tf.Tensor` + Tensor holding edge maps for each channel. Returns a tensor with shape `[batch_size, h, + w, d, 2]` where the last two dimensions hold `[[dy[0], dx[0]], [dy[1], dx[1]], ..., + [dy[d-1], dx[d-1]]]` calculated using the Scharr filter. + """ + + # Define vertical and horizontal Scharr filters. + static_image_shape = image.get_shape() + image_shape = K.shape(image) + + # 5x5 modified Scharr kernel ( reshape to (5,5,1,2) ) + matrix = np.array([[[[0.00070, 0.00070]], + [[0.00520, 0.00370]], + [[0.03700, 0.00000]], + [[0.00520, -0.0037]], + [[0.00070, -0.0007]]], + [[[0.00370, 0.00520]], + [[0.11870, 0.11870]], + [[0.25890, 0.00000]], + [[0.11870, -0.1187]], + [[0.00370, -0.0052]]], + [[[0.00000, 0.03700]], + [[0.00000, 0.25890]], + [[0.00000, 0.00000]], + [[0.00000, -0.2589]], + [[0.00000, -0.0370]]], + [[[-0.0037, 0.00520]], + [[-0.1187, 0.11870]], + [[-0.2589, 0.00000]], + [[-0.1187, -0.1187]], + [[-0.0037, -0.0052]]], + [[[-0.0007, 0.00070]], + [[-0.0052, 0.00370]], + [[-0.0370, 0.00000]], + [[-0.0052, -0.0037]], + [[-0.0007, -0.0007]]]]) + num_kernels = [2] + kernels = K.constant(matrix, dtype='float32') + kernels = K.tile(kernels, [1, 1, image_shape[-1], 1]) + + # Use depth-wise convolution to calculate edge maps per channel. + # Output tensor has shape [batch_size, h, w, d * num_kernels]. + pad_sizes = [[0, 0], [2, 2], [2, 2], [0, 0]] + padded = tf.pad(image, # pylint:disable=unexpected-keyword-arg,no-value-for-parameter + pad_sizes, + mode='REFLECT') + output = K.depthwise_conv2d(padded, kernels) + + if not magnitude: # direction of edges + # Reshape to [batch_size, h, w, d, num_kernels]. + shape = K.concatenate([image_shape, num_kernels], axis=0) + output = K.reshape(output, shape=shape) + output.set_shape(static_image_shape.concatenate(num_kernels)) + output = tf.atan(K.squeeze(output[:, :, :, :, 0] / output[:, :, :, :, 1], axis=None)) + # magnitude of edges -- unified x & y edges don't work well with Neural Networks + return output + + +class LDRFLIPLoss(): # pylint:disable=too-few-public-methods + """ Computes the LDR-FLIP error map between two LDR images, assuming the images are observed + at a certain number of pixels per degree of visual angle. + + References + ---------- + https://research.nvidia.com/sites/default/files/node/3260/FLIP_Paper.pdf + https://github.com/NVlabs/flip + + License + ------- + BSD 3-Clause License + Copyright (c) 2020-2022, NVIDIA Corporation & AFFILIATES. All rights reserved. + Redistribution and use in source and binary forms, with or without modification, are permitted + provided that the following conditions are met: + Redistributions of source code must retain the above copyright notice, this list of conditions + and the following disclaimer. + Redistributions in binary form must reproduce the above copyright notice, this list of + conditions and the following disclaimer in the documentation and/or other materials provided + with the distribution. + Neither the name of the copyright holder nor the names of its contributors may be used to + endorse or promote products derived from this software without specific prior written + permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR + IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + + Parameters + ---------- + computed_distance_exponent: float, Optional + The computed distance exponent to apply to Hunt adjusted, filtered colors. + (`qc` in original paper). Default: `0.7` + feature_exponent: float, Optional + The feature exponent to apply for increasing the impact of feature difference on the + final loss value. (`qf` in original paper). Default: `0.5` + lower_threshold_exponent: float, Optional + The `pc` exponent for the color pipeline as described in the original paper: Default: `0.4` + upper_threshold_exponent: float, Optional + The `pt` exponent for the color pipeline as described in the original paper. + Default: `0.95` + epsilon: float + A small value to improve training stability. Default: `1e-15` + pixels_per_degree: float, Optional + The estimated number of pixels per degree of visual angle of the observer. This effectively + impacts the tolerance when calculating loss. The default corresponds to viewing images on a + 0.7m wide 4K monitor at 0.7m from the display. Default: ``None`` + color_order: str + The `"BGR"` or `"RGB"` color order of the incoming images + """ + def __init__(self, + computed_distance_exponent: float = 0.7, + feature_exponent: float = 0.5, + lower_threshold_exponent: float = 0.4, + upper_threshold_exponent: float = 0.95, + epsilon: float = 1e-15, + pixels_per_degree: Optional[float] = None, + color_order: Literal["bgr", "rgb"] = "bgr") -> None: + logger.debug("Initializing: %s (computed_distance_exponent '%s', feature_exponent: %s, " + "lower_threshold_exponent: %s, upper_threshold_exponent: %s, epsilon: %s, " + "pixels_per_degree: %s, color_order: %s)", self.__class__.__name__, + computed_distance_exponent, feature_exponent, lower_threshold_exponent, + upper_threshold_exponent, epsilon, pixels_per_degree, color_order) + + self._computed_distance_exponent = computed_distance_exponent + self._feature_exponent = feature_exponent + self._pc = lower_threshold_exponent + self._pt = upper_threshold_exponent + self._epsilon = epsilon + self._color_order = color_order.lower() + + if pixels_per_degree is None: + pixels_per_degree = (0.7 * 3840 / 0.7) * np.pi / 180 + self._pixels_per_degree = pixels_per_degree + self._spatial_filters = _SpatialFilters(pixels_per_degree) + self._feature_detector = _FeatureDetection(pixels_per_degree) + logger.debug("Initialized: %s ", self.__class__.__name__) + + def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + """ Call the LDR Flip Loss Function + + Parameters + ---------- + y_true: :class:`tensorflow.Tensor` + The ground truth batch of images + y_pred: :class:`tensorflow.Tensor` + The predicted batch of images + + Returns + ------- + :class::class:`tensorflow.Tensor` + The calculated Flip loss value + """ + if self._color_order == "bgr": # Switch models training in bgr order to rgb + y_true = y_true[..., 2::-1] + y_pred = y_pred[..., 2::-1] + + y_true = K.clip(y_true, 0, 1.) + y_pred = K.clip(y_pred, 0, 1.) + + rgb2ycxcz = ColorSpaceConvert("srgb", "ycxcz") + true_ycxcz = rgb2ycxcz(y_true) + pred_ycxcz = rgb2ycxcz(y_pred) + + delta_e_color = self._color_pipeline(true_ycxcz, pred_ycxcz) + delta_e_features = self._process_features(true_ycxcz, pred_ycxcz) + + loss = K.pow(delta_e_color, 1 - delta_e_features) + return loss + + def _color_pipeline(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + """ Perform the color processing part of the FLIP loss function + + Parameters + ---------- + y_true: :class:`tensorflow.Tensor` + The ground truth batch of images in YCxCz color space + y_pred: :class:`tensorflow.Tensor` + The predicted batch of images in YCxCz color space + + Returns + ------- + :class:`tensorflow.Tensor` + The exponentiated, maximum HyAB difference between two colors in Hunt-adjusted + L*A*B* space + """ + filtered_true = self._spatial_filters(y_true) + filtered_pred = self._spatial_filters(y_pred) + + rgb2lab = ColorSpaceConvert(from_space="rgb", to_space="lab") + preprocessed_true = self._hunt_adjustment(rgb2lab(filtered_true)) + preprocessed_pred = self._hunt_adjustment(rgb2lab(filtered_pred)) + hunt_adjusted_green = self._hunt_adjustment(rgb2lab(K.constant([[[[0.0, 1.0, 0.0]]]], + dtype="float32"))) + hunt_adjusted_blue = self._hunt_adjustment(rgb2lab(K.constant([[[[0.0, 0.0, 1.0]]]], + dtype="float32"))) + + delta = self._hyab(preprocessed_true, preprocessed_pred) + power_delta = K.pow(delta, self._computed_distance_exponent) + cmax = K.pow(self._hyab(hunt_adjusted_green, hunt_adjusted_blue), + self._computed_distance_exponent) + return self._redistribute_errors(power_delta, cmax) + + def _process_features(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + """ Perform the color processing part of the FLIP loss function + + Parameters + ---------- + y_true: :class:`tensorflow.Tensor` + The ground truth batch of images in YCxCz color space + y_pred: :class:`tensorflow.Tensor` + The predicted batch of images in YCxCz color space + + Returns + ------- + :class:`tensorflow.Tensor` + The exponentiated features delta + """ + col_y_true = (y_true[..., 0:1] + 16) / 116. + col_y_pred = (y_pred[..., 0:1] + 16) / 116. + + edges_true = self._feature_detector(col_y_true, "edge") + points_true = self._feature_detector(col_y_true, "point") + edges_pred = self._feature_detector(col_y_pred, "edge") + points_pred = self._feature_detector(col_y_pred, "point") + + delta = K.maximum(K.abs(frobenius_norm(edges_true) - frobenius_norm(edges_pred)), + K.abs(frobenius_norm(points_pred) - frobenius_norm(points_true))) + + delta = K.clip(delta, min_value=self._epsilon, max_value=None) + return K.pow(((1 / np.sqrt(2)) * delta), self._feature_exponent) + + @classmethod + def _hunt_adjustment(cls, image: tf.Tensor) -> tf.Tensor: + """ Apply Hunt-adjustment to an image in L*a*b* color space + + Parameters + ---------- + image: :class:`tensorflow.Tensor` + The batch of images in L*a*b* to adjust + + Returns + ------- + :class:`tensorflow.Tensor` + The hunt adjusted batch of images in L*a*b color space + """ + ch_l = image[..., 0:1] + adjusted = K.concatenate([ch_l, image[..., 1:] * (ch_l * 0.01)], axis=-1) + return adjusted + + def _hyab(self, y_true, y_pred): + """ Compute the HyAB distance between true and predicted images. + + Parameters + ---------- + y_true: :class:`tensorflow.Tensor` + The ground truth batch of images in standard or Hunt-adjusted L*A*B* color space + y_pred: :class:`tensorflow.Tensor` + The predicted batch of images in in standard or Hunt-adjusted L*A*B* color space + + Returns + ------- + :class:`tensorflow.Tensor` + image tensor containing the per-pixel HyAB distances between true and predicted images + """ + delta = y_true - y_pred + root = K.sqrt(K.clip(K.pow(delta[..., 0:1], 2), min_value=self._epsilon, max_value=None)) + delta_norm = frobenius_norm(delta[..., 1:3]) + return root + delta_norm + + def _redistribute_errors(self, power_delta_e_hyab, cmax): + """ Redistribute exponentiated HyAB errors to the [0,1] range + + Parameters + ---------- + power_delta_e_hyab: :class:`tensorflow.Tensor` + The exponentiated HyAb distance + cmax: :class:`tensorflow.Tensor` + The exponentiated, maximum HyAB difference between two colors in Hunt-adjusted + L*A*B* space + + Returns + ------- + :class:`tensorflow.Tensor` + The redistributed per-pixel HyAB distances (in range [0,1]) + """ + pccmax = self._pc * cmax + delta_e_c = K.switch( + power_delta_e_hyab < pccmax, + (self._pt / pccmax) * power_delta_e_hyab, + self._pt + ((power_delta_e_hyab - pccmax) / (cmax - pccmax)) * (1.0 - self._pt)) + return delta_e_c + + +class _SpatialFilters(): # pylint:disable=too-few-public-methods + """ Filters an image with channel specific spatial contrast sensitivity functions and clips + result to the unit cube in linear RGB. + + For use with LDRFlipLoss. + + Parameters + ---------- + pixels_per_degree: float + The estimated number of pixels per degree of visual angle of the observer. This effectively + impacts the tolerance when calculating loss. + """ + def __init__(self, pixels_per_degree: float) -> None: + self._pixels_per_degree = pixels_per_degree + self._spatial_filters, self._radius = self._generate_spatial_filters() + self._ycxcz2rgb = ColorSpaceConvert(from_space="ycxcz", to_space="rgb") + + def _generate_spatial_filters(self) -> Tuple[tf.Tensor, int]: + """ Generates spatial contrast sensitivity filters with width depending on the number of + pixels per degree of visual angle of the observer for channels "A", "RG" and "BY" + + Returns + ------- + dict + the channels ("A" (Achromatic CSF), "RG" (Red-Green CSF) or "BY" (Blue-Yellow CSF)) as + key with the Filter kernel corresponding to the spatial contrast sensitivity function + of channel and kernel's radius + """ + mapping = dict(A=dict(a1=1, b1=0.0047, a2=0, b2=1e-5), + RG=dict(a1=1, b1=0.0053, a2=0, b2=1e-5), + BY=dict(a1=34.1, b1=0.04, a2=13.5, b2=0.025)) + + domain, radius = self._get_evaluation_domain(mapping["A"]["b1"], + mapping["A"]["b2"], + mapping["RG"]["b1"], + mapping["RG"]["b2"], + mapping["BY"]["b1"], + mapping["BY"]["b2"]) + + weights = np.array([self._generate_weights(mapping[channel], domain) + for channel in ("A", "RG", "BY")]) + weights = K.constant(np.moveaxis(weights, 0, -1), dtype="float32") + + return weights, radius + + def _get_evaluation_domain(self, + b1_a: float, + b2_a: float, + b1_rg: float, + b2_rg: float, + b1_by: float, + b2_by: float) -> Tuple[np.ndarray, int]: + """ TODO docstring """ + max_scale_parameter = max([b1_a, b2_a, b1_rg, b2_rg, b1_by, b2_by]) + delta_x = 1.0 / self._pixels_per_degree + radius = int(np.ceil(3 * np.sqrt(max_scale_parameter / (2 * np.pi**2)) + * self._pixels_per_degree)) + ax_x, ax_y = np.meshgrid(range(-radius, radius + 1), range(-radius, radius + 1)) + domain = (ax_x * delta_x) ** 2 + (ax_y * delta_x) ** 2 + return domain, radius + + @classmethod + def _generate_weights(cls, channel: Dict[str, float], domain: np.ndarray) -> tf.Tensor: + """ TODO docstring """ + a_1, b_1, a_2, b_2 = channel["a1"], channel["b1"], channel["a2"], channel["b2"] + grad = (a_1 * np.sqrt(np.pi / b_1) * np.exp(-np.pi ** 2 * domain / b_1) + + a_2 * np.sqrt(np.pi / b_2) * np.exp(-np.pi ** 2 * domain / b_2)) + grad = grad / np.sum(grad) + grad = np.reshape(grad, (*grad.shape, 1)) + return grad + + def __call__(self, image: tf.Tensor) -> tf.Tensor: + """ Call the spacial filtering. + + Parameters + ---------- + image: Tensor + Image tensor to filter in YCxCz color space + + Returns + ------- + Tensor + The input image transformed to linear RGB after filtering with spatial contrast + sensitivity functions + """ + padded_image = replicate_pad(image, self._radius) + image_tilde_opponent = K.conv2d(padded_image, + self._spatial_filters, + strides=1, + padding="valid") + rgb = K.clip(self._ycxcz2rgb(image_tilde_opponent), 0., 1.) + return rgb + + +class _FeatureDetection(): # pylint:disable=too-few-public-methods + """ Detect features (i.e. edges amd points) in an achromatic YCxCz image. + + For use with LDRFlipLoss. + + Parameters + ---------- + pixels_per_degree: float + The number of pixels per degree of visual angle of the observer + """ + def __init__(self, pixels_per_degree: float) -> None: + width = 0.082 + self._std = 0.5 * width * pixels_per_degree + self._radius = int(np.ceil(3 * self._std)) + self._grid = np.meshgrid(range(-self._radius, self._radius + 1), + range(-self._radius, self._radius + 1)) + self._gradient = np.exp(-(self._grid[0] ** 2 + self._grid[1] ** 2) + / (2 * (self._std ** 2))) + + def __call__(self, image: tf.Tensor, feature_type: str) -> tf.Tensor: + """ Run the feature detection + + Parameters + ---------- + image: Tensor + Batch of images in YCxCz color space with normalized Y values + feature_type: str + Type of features to detect (`"edge"` or `"point"`) + + Returns + ------- + Tensor + Detected features in the 0-1 range + """ + feature_type = feature_type.lower() + + if feature_type == 'edge': + grad_x = np.multiply(-self._grid[0], self._gradient) + else: + grad_x = np.multiply(self._grid[0] ** 2 / (self._std ** 2) - 1, self._gradient) + + negative_weights_sum = -np.sum(grad_x[grad_x < 0]) + positive_weights_sum = np.sum(grad_x[grad_x > 0]) + + grad_x = K.constant(grad_x) + grad_x = K.switch(grad_x < 0, grad_x / negative_weights_sum, grad_x / positive_weights_sum) + kernel = K.expand_dims(K.expand_dims(grad_x, axis=-1), axis=-1) + + features_x = K.conv2d(replicate_pad(image, self._radius), + kernel, + strides=1, + padding="valid") + kernel = K.permute_dimensions(kernel, (1, 0, 2, 3)) + features_y = K.conv2d(replicate_pad(image, self._radius), + kernel, + strides=1, + padding="valid") + features = K.concatenate([features_x, features_y], axis=-1) + return features + + +class MSSIMLoss(): # pylint:disable=too-few-public-methods + """ Multiscale Structural Similarity Loss Function + + Parameters + ---------- + k_1: float, optional + Parameter of the SSIM. Default: `0.01` + k_2: float, optional + Parameter of the SSIM. Default: `0.03` + filter_size: int, optional + size of gaussian filter Default: `11` + filter_sigma: float, optional + Width of gaussian filter Default: `1.5` + max_value: float, optional + Max value of the output. Default: `1.0` + power_factors: tuple, optional + Iterable of weights for each of the scales. The number of scales used is the length of the + list. Index 0 is the unscaled resolution's weight and each increasing scale corresponds to + the image being downsampled by 2. Defaults to the values obtained in the original paper. + Default: (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) + + Notes + ------ + You should add a regularization term like a l2 loss in addition to this one. + """ + def __init__(self, + k_1: float = 0.01, + k_2: float = 0.03, + filter_size: int = 11, + filter_sigma: float = 1.5, + max_value: float = 1.0, + power_factors: Tuple[float, ...] = (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) + ) -> None: + self.filter_size = filter_size + self.filter_sigma = filter_sigma + self.k_1 = k_1 + self.k_2 = k_2 + self.max_value = max_value + self.power_factors = power_factors + + def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + """ Call the MS-SSIM Loss Function. + + Parameters + ---------- + y_true: :class:`tf.Tensor` + The ground truth value + y_pred: :class:`tf.Tensor` + The predicted value + + Returns + ------- + :class:`tf.Tensor` + The MS-SSIM Loss value + """ + im_size = K.int_shape(y_true)[1] + # filter size cannot be larger than the smallest scale + smallest_scale = self._get_smallest_size(im_size, len(self.power_factors) - 1) + filter_size = min(self.filter_size, smallest_scale) + + ms_ssim = tf.image.ssim_multiscale(y_true, + y_pred, + self.max_value, + power_factors=self.power_factors, + filter_size=filter_size, + filter_sigma=self.filter_sigma, + k1=self.k_1, + k2=self.k_2) + ms_ssim_loss = 1. - ms_ssim + return K.mean(ms_ssim_loss) + + def _get_smallest_size(self, size: int, idx: int) -> int: + """ Recursive function to obtain the smallest size that the image will be scaled to. + + Parameters + ---------- + size: int + The current scaled size to iterate through + idx: int + The current iteration to be performed. When iteration hits zero the value will + be returned + + Returns + ------- + int + The smallest size the image will be scaled to based on the original image size and + the amount of scaling factors that will occur + """ + logger.debug("scale id: %s, size: %s", idx, size) + if idx > 0: + size = self._get_smallest_size(size // 2, idx - 1) + return size diff --git a/lib/plaidml_utils.py b/lib/plaidml_utils.py index 706a9f6fbf..7af2f63df8 100644 --- a/lib/plaidml_utils.py +++ b/lib/plaidml_utils.py @@ -1,12 +1,37 @@ -''' -Multiple plaidml implementation. -''' +#!/usr/bin/env python3 +""" PlaidML helper Utilities """ +from typing import Optional import plaidml -def pad(data, paddings, mode="CONSTANT", name=None, constant_value=0): - """ PlaidML Pad """ +def pad(data: plaidml.tile.Value, + paddings, + mode: str = "CONSTANT", + name: Optional[str] = None, # pylint:disable=unused-argument + constant_value: int = 0) -> plaidml.tile.Value: + """ PlaidML Pad + + Notes + ----- + Currently only Reflect padding is supported. + + Parameters + ---------- + data :class:`plaidm.tile.Value` + The tensor to pad + mode: str, optional + The padding mode to use. Default: `"CONSTANT"` + name: str, optional + The name for the operation. Unused but kept for consistency with tf.pad. Default: ``None`` + constant_value: int, optional + The value to pad the Tensor with. Default: `0` + + Returns + ------- + :class:`plaidm.tile.Value` + The padded tensor + """ # TODO: use / implement other padding method when required # CONSTANT -> SpatialPadding ? | Doesn't support first and last axis + # no support for constant_value @@ -18,9 +43,11 @@ def pad(data, paddings, mode="CONSTANT", name=None, constant_value=0): return plaidml.op.reflection_padding(data, paddings) -def is_plaidml_error(error): +def is_plaidml_error(error: Exception) -> bool: """ Test whether the given exception is a plaidml Exception. + Parameters + ---------- error: :class:`Exception` The generated error diff --git a/plugins/train/_config.py b/plugins/train/_config.py index c897ce05aa..d0fd4a2f23 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -18,6 +18,12 @@ "found increased benefits when using it as a complementary loss to another spacial loss " "function (e.g. MSE). Ref: Focal Frequency Loss for Image Reconstruction and Synthesis " "https://arxiv.org/pdf/2012.12821.pdf NB: This loss does not currently work on AMD cards.", + flip="Nvidia FLIP. A perceptual loss measure that approximates the difference perceived by " + "humans as they alternate quickly (or flip) between two images. Used on its own and this " + "loss function creates a distinct grid on the output. However it can be helpful when " + "used as a complimentary loss function. Ref: FLIP: A Difference Evaluator for " + "Alternating Images: " + "https://research.nvidia.com/sites/default/files/node/3260/FLIP_Paper.pdf", gmsd=( "Gradient Magnitude Similarity Deviation seeks to match the global standard deviation of " "the pixel to pixel differences between two images. Similar in approach to SSIM. Ref: " @@ -81,7 +87,7 @@ "shifts, but maintains the structure of the image."), none="Do not use an additional loss function.") -_NON_PRIMARY_LOSS = ["lpips_alex", "lpips_squeeze", "lpips_vgg16", "none"] +_NON_PRIMARY_LOSS = ["flip", "lpips_alex", "lpips_squeeze", "lpips_vgg16", "none"] class Config(FaceswapConfig): diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 2e16456393..9d9a6bcddf 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -34,6 +34,11 @@ from tensorflow.keras.layers import Input # pylint:disable=import-error,no-name-in-module from tensorflow.keras.models import load_model, Model as KModel # noqa pylint:disable=import-error,no-name-in-module +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + if TYPE_CHECKING: import argparse @@ -108,9 +113,9 @@ def __init__(self, self.__class__.__name__, model_dir, arguments, predict) # Input shape must be set within the plugin after initializing - self.input_shape: Union[List[Tuple[int, ...]], Tuple[int, ...]] = () + self.input_shape: Tuple[int, ...] = () self.trainer = "original" # Override for plugin specific trainer - self.color_order = "bgr" # Override for plugin specific image color channel order + self.color_order: Literal["bgr", "rgb"] = "bgr" # Override for image color channel order self._args = arguments self._is_predict = predict @@ -143,7 +148,7 @@ def __init__(self, self._mixed_precision, self.config["allow_growth"], self._is_predict) - self._loss = Loss(self.config) + self._loss = Loss(self.config, self.color_order) logger.debug("Initialized ModelBase (%s)", self.__class__.__name__) @@ -349,12 +354,7 @@ def _update_legacy_models(self) -> None: def _validate_input_shape(self) -> None: """ Validate that the input shape is either a single shape tuple of 3 dimensions or a list of 2 shape tuples of 3 dimensions. """ - assert len(self.input_shape) in (2, 3), "Input shape should either be a single 3 " \ - "dimensional shape tuple for use in both sides of the model, or a list of 2 3 " \ - "dimensional shape tuples for use in the 'A' and 'B' sides of the model" - if len(self.input_shape) == 2: - assert [len(shape) == 3 for shape in self.input_shape], "All input shapes should " \ - "have 3 dimensions" + assert len(self.input_shape) == 3, "Input shape should be a 3 dimensional shape tuple" def _get_inputs(self) -> List[keras.layers.Input]: """ Obtain the standardized inputs for the model. @@ -369,10 +369,7 @@ def _get_inputs(self) -> List[keras.layers.Input]: for each side) each of shapes :attr:`input_shape`. """ logger.debug("Getting inputs") - if len(self.input_shape) == 3: - input_shapes = [self.input_shape, self.input_shape] - else: - input_shapes = self.input_shape + input_shapes = [self.input_shape, self.input_shape] inputs = [Input(shape=shape, name=f"face_in_{side}") for side, shape in zip(("a", "b"), input_shapes)] logger.debug("inputs: %s", inputs) diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 2592c4d7e5..5aa48cdbc2 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -13,6 +13,7 @@ from dataclasses import dataclass, field import logging import platform +import sys from contextlib import nullcontext from typing import Any, Callable, ContextManager, Dict, List, Optional, TYPE_CHECKING, Union @@ -38,6 +39,11 @@ else: import tensorflow.keras.mixed_precision as mixedprecision # noqa pylint:disable=import-error,no-name-in-module +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + if TYPE_CHECKING: from argparse import Namespace from .model import State @@ -72,12 +78,11 @@ class Loss(): ---------- config: dict The configuration options for the current model plugin - input_shape: tuple - Required for AMD backends only. Some loss functions are unable to calculate the input shape - at runtime, so we add the shape as an initializing variable + color_order: str + Color order of the model. One of `"BGR"` or `"RGB"` """ - def __init__(self, config: dict) -> None: - logger.debug("Initializing %s", self.__class__.__name__) + def __init__(self, config: dict, color_order: Literal["bgr", "rgb"]) -> None: + logger.debug("Initializing %s: (color_order: %s)", self.__class__.__name__, color_order) self._config = config self._mask_channels = self._get_mask_channels() self._inputs: List[keras.layers.Layer] = [] @@ -86,6 +91,8 @@ def __init__(self, config: dict) -> None: logcosh = losses.LogCosh() if get_backend() == "amd" else k_losses.logcosh self._loss_dict = dict(ffl=LossClass(function=losses.FocalFrequencyLoss), + flip=LossClass(function=losses.LDRFLIPLoss, + kwargs=dict(color_order=color_order)), gmsd=LossClass(function=losses.GMSDLoss), l_inf_norm=LossClass(function=losses.LInfNorm), laploss=LossClass(function=losses.LaplacianPyramidLoss), @@ -315,7 +322,9 @@ def __init__(self, "nadam": (optimizers.Nadam, dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), "rms-prop": (optimizers.RMSprop, dict(epsilon=epsilon))} - self._optimizer, self._kwargs = valid_optimizers[optimizer] + optimizer_info = valid_optimizers[optimizer] + self._optimizer: Callable = optimizer_info[0] + self._kwargs: Dict[str, Any] = optimizer_info[1] self._configure(learning_rate, autoclip) logger.verbose("Using %s optimizer", optimizer.title()) # type:ignore diff --git a/tests/lib/model/losses_test.py b/tests/lib/model/losses_test.py index 013ddc7231..3d9d65ad11 100644 --- a/tests/lib/model/losses_test.py +++ b/tests/lib/model/losses_test.py @@ -46,13 +46,14 @@ def test_loss_output(loss_func, output_shape): losses.GMSDLoss(), losses.GradientLoss(), losses.LaplacianPyramidLoss(), + losses.LDRFLIPLoss(), losses.LInfNorm(), losses.LogCosh() if get_backend() == "amd" else k_losses.logcosh, k_losses.mean_absolute_error, k_losses.mean_squared_error, losses.MSSIMLoss()] _LWIDS = ["DSSIMObjective", "FocalFrequencyLosse", "GeneralizedLoss", "GMSDLoss", "GradientLoss", - "LaplacianPyramidLoss", "LInfNorm", "logcosh", "mae", "mse", "MS-SSIM"] + "LaplacianPyramidLoss", "LInfNorm", "LDRFlipLoss", "logcosh", "mae", "mse", "MS-SSIM"] _LWIDS = [f"{loss}[{get_backend().upper()}]" for loss in _LWIDS] From a3fdee617a7fe438538e050593e17da40ee346c1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 7 Jul 2022 10:05:49 +0100 Subject: [PATCH 662/981] Bugfix - Phaze-A - Correctly scale linear filters --- plugins/train/model/phaze_a.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index d73a799211..20a83bcf6f 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -629,7 +629,7 @@ def _get_curve(start_y: int, else: y_axis = [start_y] scale = 1. - abs(scale) - for _ in range(num_points): + for _ in range(num_points - 1): current_value = max(end_y, int(((y_axis[-1] * scale) // 8) * 8)) y_axis.append(current_value) if current_value == end_y: From 9945efb3c0aadabc2ff98a0a6ff76ecc956a074a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 7 Jul 2022 23:46:43 +0100 Subject: [PATCH 663/981] Bugfix: Phaze-A Fix learn mask for upscales in FC --- plugins/train/model/phaze_a.py | 59 +++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 20a83bcf6f..9bbf52da82 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -486,6 +486,12 @@ def _build_decoders( # There will only ever be 1 input. For inters: either inter out, or concatenate of inters # For g-block, this only ever has one output input_ = input_[0] if isinstance(input_, list) else input_ + + # If learning a mask and upscales have been placed into FC layer, then the mask will also + # come as an input + if self.config["learn_mask"] and self.config["dec_upscales_in_fc"]: + input_ = input_[0] + input_shape = K.int_shape(input_)[1:] if self.config["split_decoders"]: @@ -678,7 +684,7 @@ class Encoder(): # pylint:disable=too-few-public-methods config: dict The model configuration options """ - def __init__(self, input_shape: Tuple[int, int, int], config: dict) -> None: + def __init__(self, input_shape: Tuple[int, ...], config: dict) -> None: self.input_shape = input_shape self._config = config self._input_shape = input_shape @@ -968,7 +974,6 @@ def __call__(self) -> keras.models.Model: num_upscales = self._config["dec_upscales_in_fc"] if num_upscales: var_x = UpscaleBlocks(self._side, - K.int_shape(var_x)[1:], self._config, layer_indicies=(0, num_upscales))(var_x) @@ -989,8 +994,6 @@ class UpscaleBlocks(): # pylint: disable=too-few-public-methods ---------- side: ["a", "b", "both", "shared"] The side of the model that the Decoder belongs to. Used for naming - input_shape: tuple - The shape tuple for the input to the decoder. config: dict The user configuration dictionary layer_indices: tuple, optional @@ -1003,13 +1006,11 @@ class UpscaleBlocks(): # pylint: disable=too-few-public-methods def __init__(self, side: Literal["a", "b", "both", "shared"], - input_shape: Tuple[int, int, int], config: dict, layer_indicies: Optional[Tuple[int, int]] = None) -> None: - logger.debug("Initializing: %s (side: %s, input_shape: %s, layer_indicies: %s)", - self.__class__.__name__, side, input_shape, layer_indicies) + logger.debug("Initializing: %s (side: %s, layer_indicies: %s)", + self.__class__.__name__, side, layer_indicies) self._side = side - self._input_shape = input_shape self._config = config self._is_dny = self._config["dec_upscale_method"].lower() == "upscale_dny" self._layer_indicies = layer_indicies @@ -1135,29 +1136,40 @@ def _dny_entry(self, inputs: Tensor) -> Tensor: relu_alpha=0.2)(var_x) return var_x - def __call__(self, inputs: Optional[Tensor] = None) -> Tensor: - """ Decoder Network. + def __call__(self, inputs: Union[Tensor, List[Tensor]]) -> Union[Tensor, List[Tensor]]: + """ Upscale Network. Parameters - inputs: Tensor, optional - If the input is an output from another model (such as the Fully Connected Model) this - should be ``None`` otherwise it should be a Tensor + inputs: Tensor or list of tensors + Input tensor(s) to upscale block. This will be a single tensor if learn mask is not + selected or if this is the first call to the upscale blocks. If learn mask is selected + and this is not the first call to upscale blocks, then this will be a list of the face + and mask tensors. Returns ------- - :class:`keras.models.Model` - The Decoder model + Tensor or list of tensors + The output of encoder blocks. Either a single tensor (if learn mask is not enabled) or + list of tensors (if learn mask is enabled) """ - inputs = Input(shape=self._input_shape) if inputs is None else inputs - var_x = inputs start_idx, end_idx = (0, None) if self._layer_indicies is None else self._layer_indicies end_idx = None if end_idx == -1 else end_idx + if self._config["learn_mask"] and start_idx == 0: + # Mask needs to be created + var_x = inputs + var_y = inputs + elif self._config["learn_mask"]: + # Mask has already been created and is an input to upscale blocks + var_x, var_y = inputs + else: + # No mask required + var_x = inputs + if start_idx == 0: var_x = self._reshape_for_output(var_x) if self._config["learn_mask"]: - var_y = inputs var_y = self._reshape_for_output(var_y) if self._is_dny: @@ -1301,14 +1313,17 @@ def __call__(self) -> keras.models.Model: The Decoder model """ inputs = Input(shape=self._input_shape) - var_x = inputs + num_ups_in_fc = self._config["dec_upscales_in_fc"] - indicies = None if not num_ups_in_fc else (num_ups_in_fc, -1) + if self._config["learn_mask"] and num_ups_in_fc: + # Mask has already been created in FC and is an output of that model + inputs = [inputs, Input(shape=self._input_shape)] + + indicies = None if not num_ups_in_fc else (num_ups_in_fc, -1) upscales = UpscaleBlocks(self._side, - self._input_shape, self._config, - layer_indicies=indicies)(var_x) + layer_indicies=indicies)(inputs) if self._config["learn_mask"]: var_x, var_y = upscales From 89b71f7f1ef65dfa36d84d0adb344abfff50732f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 11 Jul 2022 09:30:33 +0100 Subject: [PATCH 664/981] bugfix: inference for learn mask with some models --- plugins/train/model/_base/model.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 9d9a6bcddf..ef0cf4f6a2 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -875,6 +875,11 @@ def _get_nodes(self, nodes: np.ndarray) -> List[Tuple[str, int]]: anodes = np.array(nodes, dtype="object")[..., :3] num_layers = anodes.shape[0] anodes = anodes[self._output_idx] if num_layers == 2 else anodes[0] + + # Probably better checks for this, but this occurs when DNY preset is used and learn + # mask is enabled (i.e. the mask is created in fully connected layers) + anodes = anodes.squeeze() if anodes.ndim == 3 else anodes + retval = [(node[0], node[2]) for node in anodes] return retval From 279bf38746fa19edf3bfe786685368d9aeb3117c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 11 Jul 2022 16:42:30 +0100 Subject: [PATCH 665/981] Update README.md --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index c4cf43bbda..923cdf9782 100755 --- a/README.md +++ b/README.md @@ -10,6 +10,12 @@

    

+ +

+ +
Emma Stone/Scarlett Johansson FaceSwap using the Phaze-A model +

+


Jennifer Lawrence/Steve Buscemi FaceSwap using the Villain model From 7e0dbcdaa7970f3e6bf44f0167a5b68260996603 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 17 Jul 2022 17:54:22 +0100 Subject: [PATCH 666/981] Update docs --- docs/_static/theme_overrides.css | 14 -------------- docs/conf.py | 15 ++++++++------- docs/full/lib/model.rst | 5 +++-- docs/sphinx_requirements.txt | 25 +++++++++++++------------ lib/keras_utils.py | 4 ++-- lib/model/nets.py | 8 ++++---- 6 files changed, 30 insertions(+), 41 deletions(-) delete mode 100644 docs/_static/theme_overrides.css diff --git a/docs/_static/theme_overrides.css b/docs/_static/theme_overrides.css deleted file mode 100644 index abc9c0fcee..0000000000 --- a/docs/_static/theme_overrides.css +++ /dev/null @@ -1,14 +0,0 @@ -/* override table width restrictions */ -@media screen and (min-width: 767px) { - - .wy-table-responsive table td { - /* !important prevents the common CSS stylesheets from overriding - this as on RTD they are loaded after this stylesheet */ - white-space: normal !important; - } - - .wy-table-responsive { - overflow: visible !important; - } - } - \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index bbd311d5ac..d8050831d7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,13 +12,19 @@ # import os import sys +from unittest import mock + sys.path.insert(0, os.path.abspath('../')) sys.setrecursionlimit(1500) +MOCK_MODULES = ["plaidml", "pynvx"] +for mod_name in MOCK_MODULES: + sys.modules[mod_name] = mock.Mock() + # -- Project information ----------------------------------------------------- project = 'faceswap' -copyright = '2019, faceswap.dev' +copyright = '2022, faceswap.dev' author = 'faceswap.dev' # The full version, including alpha/beta/rc tags @@ -31,6 +37,7 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = ['sphinx.ext.napoleon', "sphinx.ext.autosummary", ] +napoleon_custom_sections = ['License'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -61,12 +68,6 @@ # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] -html_context = { - 'css_files': [ - '_static/theme_overrides.css', # override wide tables in RTD theme - ], - } - master_doc = 'index' autosummary_generate = True diff --git a/docs/full/lib/model.rst b/docs/full/lib/model.rst index 2b8875ffc8..3430d2d70a 100755 --- a/docs/full/lib/model.rst +++ b/docs/full/lib/model.rst @@ -64,15 +64,16 @@ be imported as :mod:`lib.model.losses` depending on the backend in use. .. autosummary:: :nosignatures: - ~lib.model.loss.perceptual_loss_tf.DSSIMObjective ~lib.model.loss.loss_tf.FocalFrequencyLoss ~lib.model.loss.loss_tf.GeneralizedLoss - ~lib.model.loss.perceptual_loss_tf.GMSDLoss ~lib.model.loss.loss_tf.GradientLoss ~lib.model.loss.loss_tf.LaplacianPyramidLoss ~lib.model.loss.loss_tf.LInfNorm ~lib.model.loss.loss_tf.LossWrapper ~lib.model.loss.feature_loss_tf.LPIPSLoss + ~lib.model.loss.perceptual_loss_tf.DSSIMObjective + ~lib.model.loss.perceptual_loss_tf.GMSDLoss + ~lib.model.loss.perceptual_loss_tf.LDRFLIPLoss ~lib.model.loss.perceptual_loss_tf.MSSIMLoss .. automodule:: lib.model.loss.loss_tf diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index 2f84ab7cca..b4c0fc2e06 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -1,20 +1,21 @@ # NB Do not install from this requirements file # It is for documentation purposes only -tqdm==4.62 +sphinx==5.0.2 +sphinx_rtd_theme-1.0.0 +tqdm==4.64 psutil==5.8.0 -numpy==1.18.0 -opencv-python>4.5.3.0,<4.5.4.0 +numpy>=1.18.0 +opencv-python>=4.5.5.0 pillow==8.3.1 -scikit-learn==0.24.2 -fastcluster==1.1.26 +scikit-learn>=1.0.2 +fastcluster>=1.2.4 matplotlib==3.5.1 imageio==2.9.0 -imageio-ffmpeg==0.4.5 +imageio-ffmpeg==0.4.7 ffmpy==0.2.3 -nvidia-ml-py3 -pywin32==228 ; sys_platform == "win32" -pynvx==1.0.0 ; sys_platform == "darwin" -plaidml-keras==0.7.0 -tensorflow==2.2.0 -typing-extensions +nvidia-ml-py<11.515 +plaidml==0.7.0 +tensorflow>=2.8.0,<2.9.0 +tensorflow_probability<0.17 +typing-extensions>=4.0.0 diff --git a/lib/keras_utils.py b/lib/keras_utils.py index 435c04e131..aa472ad832 100644 --- a/lib/keras_utils.py +++ b/lib/keras_utils.py @@ -180,8 +180,8 @@ def _rgb_xyz_rgb(self, image: Tensor, mapping: Tensor) -> Tensor: The conversion in both directions is the same, but the mappping matrix for XYZ to RGB is the inverse of RGB to XYZ. - Reference - --------- + References + ---------- https://www.image-engineering.de/library/technotes/958-how-to-convert-between-srgb-and-ciexyz Parameters diff --git a/lib/model/nets.py b/lib/model/nets.py index 1acffe33c3..b4c01bc42d 100644 --- a/lib/model/nets.py +++ b/lib/model/nets.py @@ -48,8 +48,8 @@ class AlexNet(_net): # pylint:disable=too-few-public-methods ----- This port only contains the features portion of the model. - Reference - --------- + References + ---------- https://papers.nips.cc/paper/2012/file/c399862d3b9d6b76c8436e924a68c45b-Paper.pdf Parameters @@ -144,8 +144,8 @@ class SqueezeNet(_net): # pylint:disable=too-few-public-methods ----- This port only contains the features portion of the model. - Reference - --------- + References + ---------- https://arxiv.org/abs/1602.07360 Parameters From 924a2e9b3aec125c10b850809c5f15f6efa2946f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 17 Jul 2022 17:56:53 +0100 Subject: [PATCH 667/981] typofix --- docs/sphinx_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index b4c0fc2e06..0447e5d6c5 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -2,7 +2,7 @@ # It is for documentation purposes only sphinx==5.0.2 -sphinx_rtd_theme-1.0.0 +sphinx_rtd_theme==1.0.0 tqdm==4.64 psutil==5.8.0 numpy>=1.18.0 From e362fd58c5e93907aa432d268bdd010d2ac3c56c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 17 Jul 2022 18:05:06 +0100 Subject: [PATCH 668/981] Add DNY presets to Phaze-A --- .gitignore | 1 + .../train/model_phaze_a_dny1024_preset.json | 52 +++++++++++++++++++ .../train/model_phaze_a_dny256_preset.json | 52 +++++++++++++++++++ .../train/model_phaze_a_dny512_preset.json | 52 +++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 lib/gui/.cache/presets/train/model_phaze_a_dny1024_preset.json create mode 100644 lib/gui/.cache/presets/train/model_phaze_a_dny256_preset.json create mode 100644 lib/gui/.cache/presets/train/model_phaze_a_dny512_preset.json diff --git a/.gitignore b/.gitignore index 6b5149d54c..63f21e29a7 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ !lib/**/*.py !lib/gui/**/icons/*.png !lib/gui/**/themes/default.json +!lib/gui/**/presets/**/*.json !plugins/ !plugins/**/ !plugins/**/*.py diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dny1024_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dny1024_preset.json new file mode 100644 index 0000000000..161d53336c --- /dev/null +++ b/lib/gui/.cache/presets/train/model_phaze_a_dny1024_preset.json @@ -0,0 +1,52 @@ +{ + "output_size": 1024, + "shared_fc": "none", + "enable_gblock": false, + "split_fc": false, + "split_gblock": false, + "split_decoders": true, + "enc_architecture": "fs_original", + "enc_scaling": 100, + "enc_load_weights": false, + "bottleneck_type": "dense", + "bottleneck_norm": "none", + "bottleneck_size": 512, + "bottleneck_in_encoder": true, + "fc_depth": 0, + "fc_min_filters": 512, + "fc_max_filters": 512, + "fc_dimensions": 1, + "fc_filter_slope": 0.0, + "fc_dropout": 0.0, + "fc_upsampler": "upsample2d", + "fc_upsamples": 2, + "fc_upsample_filters": 128, + "fc_gblock_depth": 1, + "fc_gblock_min_nodes": 128, + "fc_gblock_max_nodes": 128, + "fc_gblock_filter_slope": 0.0, + "fc_gblock_dropout": 0.0, + "dec_upscale_method": "upscale_dny", + "dec_upscales_in_fc": 2, + "dec_norm": "none", + "dec_min_filters": 16, + "dec_max_filters": 512, + "dec_slope_mode": "cap_max", + "dec_filter_slope": 0.5, + "dec_res_blocks": 0, + "dec_output_kernel": 1, + "dec_gaussian": false, + "dec_skip_last_residual": false, + "freeze_layers": "encoder", + "load_layers": "encoder", + "fs_original_depth": 9, + "fs_original_min_filters": 16, + "fs_original_max_filters": 512, + "fs_original_use_alt": true, + "mobilenet_width": 1.0, + "mobilenet_depth": 1, + "mobilenet_dropout": 0.001, + "mobilenet_minimalistic": false, + "__filetype": "faceswap_preset", + "__section": "train|model|phaze_a" +} \ No newline at end of file diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dny256_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dny256_preset.json new file mode 100644 index 0000000000..e19e61dcf7 --- /dev/null +++ b/lib/gui/.cache/presets/train/model_phaze_a_dny256_preset.json @@ -0,0 +1,52 @@ +{ + "output_size": 256, + "shared_fc": "none", + "enable_gblock": false, + "split_fc": false, + "split_gblock": false, + "split_decoders": true, + "enc_architecture": "fs_original", + "enc_scaling": 25, + "enc_load_weights": false, + "bottleneck_type": "dense", + "bottleneck_norm": "none", + "bottleneck_size": 512, + "bottleneck_in_encoder": true, + "fc_depth": 0, + "fc_min_filters": 512, + "fc_max_filters": 512, + "fc_dimensions": 1, + "fc_filter_slope": 0.0, + "fc_dropout": 0.0, + "fc_upsampler": "upsample2d", + "fc_upsamples": 2, + "fc_upsample_filters": 128, + "fc_gblock_depth": 1, + "fc_gblock_min_nodes": 128, + "fc_gblock_max_nodes": 128, + "fc_gblock_filter_slope": 0.0, + "fc_gblock_dropout": 0.0, + "dec_upscale_method": "upscale_dny", + "dec_upscales_in_fc": 1, + "dec_norm": "none", + "dec_min_filters": 16, + "dec_max_filters": 512, + "dec_slope_mode": "cap_max", + "dec_filter_slope": 0.5, + "dec_res_blocks": 0, + "dec_output_kernel": 1, + "dec_gaussian": false, + "dec_skip_last_residual": false, + "freeze_layers": "encoder", + "load_layers": "encoder", + "fs_original_depth": 7, + "fs_original_min_filters": 16, + "fs_original_max_filters": 512, + "fs_original_use_alt": true, + "mobilenet_width": 1.0, + "mobilenet_depth": 1, + "mobilenet_dropout": 0.001, + "mobilenet_minimalistic": false, + "__filetype": "faceswap_preset", + "__section": "train|model|phaze_a" +} \ No newline at end of file diff --git a/lib/gui/.cache/presets/train/model_phaze_a_dny512_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_dny512_preset.json new file mode 100644 index 0000000000..9e0534d5f9 --- /dev/null +++ b/lib/gui/.cache/presets/train/model_phaze_a_dny512_preset.json @@ -0,0 +1,52 @@ +{ + "output_size": 512, + "shared_fc": "none", + "enable_gblock": false, + "split_fc": false, + "split_gblock": false, + "split_decoders": true, + "enc_architecture": "fs_original", + "enc_scaling": 50, + "enc_load_weights": false, + "bottleneck_type": "dense", + "bottleneck_norm": "none", + "bottleneck_size": 512, + "bottleneck_in_encoder": true, + "fc_depth": 0, + "fc_min_filters": 512, + "fc_max_filters": 512, + "fc_dimensions": 1, + "fc_filter_slope": 0.0, + "fc_dropout": 0.0, + "fc_upsampler": "upsample2d", + "fc_upsamples": 2, + "fc_upsample_filters": 128, + "fc_gblock_depth": 1, + "fc_gblock_min_nodes": 128, + "fc_gblock_max_nodes": 128, + "fc_gblock_filter_slope": 0.0, + "fc_gblock_dropout": 0.0, + "dec_upscale_method": "upscale_dny", + "dec_upscales_in_fc": 2, + "dec_norm": "none", + "dec_min_filters": 16, + "dec_max_filters": 512, + "dec_slope_mode": "cap_max", + "dec_filter_slope": 0.5, + "dec_res_blocks": 0, + "dec_output_kernel": 1, + "dec_gaussian": false, + "dec_skip_last_residual": false, + "freeze_layers": "encoder", + "load_layers": "encoder", + "fs_original_depth": 8, + "fs_original_min_filters": 16, + "fs_original_max_filters": 512, + "fs_original_use_alt": true, + "mobilenet_width": 1.0, + "mobilenet_depth": 1, + "mobilenet_dropout": 0.001, + "mobilenet_minimalistic": false, + "__filetype": "faceswap_preset", + "__section": "train|model|phaze_a" +} \ No newline at end of file From 049314429f71a21e6595e9d27e9e36f6a3479c42 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 18 Jul 2022 19:29:21 +0100 Subject: [PATCH 669/981] Convert: Add option to output mask separately for draw-transparent --- docs/full/plugins/convert.rst | 16 +++ plugins/convert/writer/_base.py | 58 +++++++---- plugins/convert/writer/opencv.py | 108 ++++++++++++++----- plugins/convert/writer/opencv_defaults.py | 15 +++ plugins/convert/writer/pillow.py | 120 +++++++++++++++++----- plugins/convert/writer/pillow_defaults.py | 15 +++ setup.cfg | 2 + 7 files changed, 263 insertions(+), 71 deletions(-) diff --git a/docs/full/plugins/convert.rst b/docs/full/plugins/convert.rst index 05f3a110ef..251c6a4989 100755 --- a/docs/full/plugins/convert.rst +++ b/docs/full/plugins/convert.rst @@ -44,3 +44,19 @@ writer.gif module :members: :undoc-members: :show-inheritance: + +writer.opencv module +-------------------- + +.. automodule:: plugins.convert.writer.opencv + :members: + :undoc-members: + :show-inheritance: + +writer.pillow module +-------------------- + +.. automodule:: plugins.convert.writer.pillow + :members: + :undoc-members: + :show-inheritance: diff --git a/plugins/convert/writer/_base.py b/plugins/convert/writer/_base.py index 1be1e9c04f..5e26f3b9d1 100644 --- a/plugins/convert/writer/_base.py +++ b/plugins/convert/writer/_base.py @@ -5,7 +5,9 @@ import os import re -from typing import Optional +from typing import Any, List, Optional + +import numpy as np from plugins.convert._config import Config @@ -50,8 +52,11 @@ def __init__(self, output_folder: str, configfile: Optional[str] = None) -> None logger.debug("config: %s", self.config) self.output_folder: str = output_folder + # For creating subfolders when separate mask is selected + self._subfolders_created: bool = False + # Methods for making sure frames are written out in frame order - self.re_search: re.Pattern = re.compile(r"(\d+)(?=\.\w+$)") # Identify frame numbers + self.re_search = re.compile(r"(\d+)(?=\.\w+$)") # Identify frame numbers self.cache: dict = {} # Cache for when frames must be written in correct order logger.debug("Initialized %s", self.__class__.__name__) @@ -64,7 +69,7 @@ def is_stream(self) -> bool: retval = hasattr(self, "frame_order") return retval - def output_filename(self, filename: str) -> str: + def output_filename(self, filename: str, separate_mask: bool = False) -> List[str]: """ Obtain the full path for the output file, including the correct extension, for the given input filename. @@ -75,19 +80,31 @@ def output_filename(self, filename: str) -> str: ---------- filename: str The input frame filename to generate the output file name for + separate_mask: bool, optional + ``True`` if the mask should be saved out to a sub-folder otherwise ``False`` Returns ------- - str - The full path for the output converted frame to be saved to. + list + The full path for the output converted frame to be saved to in position 1. The full + path for the mask to be output to in position 2 (if requested) """ filename = os.path.splitext(os.path.basename(filename))[0] out_filename = f"{filename}.{self.config['format']}" - out_filename = os.path.join(self.output_folder, out_filename) - logger.trace("in filename: '%s', out filename: '%s'", filename, out_filename) - return out_filename + retval = [os.path.join(self.output_folder, out_filename)] + if separate_mask: + retval.append(os.path.join(self.output_folder, "masks", out_filename)) + + if separate_mask and not self._subfolders_created: + locations = [os.path.dirname(loc) for loc in retval] + logger.debug("Creating sub-folders: %s", locations) + for location in locations: + os.makedirs(location, exist_ok=True) - def cache_frame(self, filename, image) -> None: + logger.trace("in filename: '%s', out filename: '%s'", filename, retval) # type:ignore + return retval + + def cache_frame(self, filename: str, image: np.ndarray) -> None: """ Add the incoming converted frame to the cache ready for writing out. Used for ffmpeg and gif writers to ensure that the frames are written out in the correct @@ -100,24 +117,27 @@ def cache_frame(self, filename, image) -> None: image: class:`numpy.ndarray` The converted frame corresponding to the given filename """ - frame_no = int(re.search(self.re_search, filename).group()) + re_frame = re.search(self.re_search, filename) + assert re_frame is not None + frame_no = int(re_frame.group()) self.cache[frame_no] = image - logger.trace("Added to cache. Frame no: %s", frame_no) - logger.trace("Current cache: %s", sorted(self.cache.keys())) + logger.trace("Added to cache. Frame no: %s", frame_no) # type: ignore + logger.trace("Current cache: %s", sorted(self.cache.keys())) # type:ignore - def write(self, filename: str, image) -> None: + def write(self, filename: str, image: Any) -> None: """ Override for specific frame writing method. Parameters ---------- filename: str The incoming frame filename. - image: :class:`numpy.ndarray` - The converted image to be written + image: Any + The converted image to be written. Could be a numpy array, a bytes encoded image or + any other plugin specific format """ raise NotImplementedError - def pre_encode(self, image) -> None: # pylint: disable=unused-argument,no-self-use + def pre_encode(self, image: np.ndarray) -> Any: # pylint: disable=unused-argument,no-self-use """ Some writer plugins support the pre-encoding of images prior to saving out. As patching is done in multiple threads, but writing is done in a single thread, it can speed up the process to do any pre-encoding as part of the converter process. @@ -132,9 +152,9 @@ def pre_encode(self, image) -> None: # pylint: disable=unused-argument,no-self- Returns ------- - python function or ``None`` - If ``None`` then the writer does not support pre-encoding, otherwise return the python - function that will pre-encode the image + Any or ``None`` + If ``None`` then the writer does not support pre-encoding, otherwise return output of + the plugin specific pre-enccode function """ return None diff --git a/plugins/convert/writer/opencv.py b/plugins/convert/writer/opencv.py index 17cafdc591..e179fe6804 100644 --- a/plugins/convert/writer/opencv.py +++ b/plugins/convert/writer/opencv.py @@ -2,20 +2,33 @@ """ Image output writer for faceswap.py converter Uses cv2 for writing as in testing this was a lot faster than both Pillow and ImageIO """ +from typing import List, Tuple import cv2 +import numpy as np + from ._base import Output, logger class Writer(Output): - """ Images output writer using cv2 """ - def __init__(self, output_folder, **kwargs): + """ Images output writer using cv2 + + Parameters + ---------- + output_folder: str + The full path to the output folder where the converted media should be saved + configfile: str, optional + The full path to a custom configuration ini file. If ``None`` is passed + then the file is loaded from the default location. Default: ``None``. + """ + def __init__(self, output_folder: str, **kwargs) -> None: super().__init__(output_folder, **kwargs) - self.extension = ".{}".format(self.config["format"]) - self.check_transparency_format() - self.args = self.get_save_args() + self._extension = f".{self.config['format']}" + self._check_transparency_format() + self._separate_mask = self.config["draw_transparent"] and self.config["separate_mask"] + self._args = self._get_save_args() - def check_transparency_format(self): + def _check_transparency_format(self) -> None: """ Make sure that the output format is correct if draw_transparent is selected """ transparent = self.config["draw_transparent"] if not transparent or (transparent and self.config["format"] == "png"): @@ -24,10 +37,16 @@ def check_transparency_format(self): "transparency. Changing output format to 'png'") self.config["format"] = "png" - def get_save_args(self): - """ Return the save parameters for the file format """ + def _get_save_args(self) -> Tuple[int, ...]: + """ Obtain the save parameters for the file format. + + Returns + ------- + tuple + The OpenCV specific arguments for the selected file format + """ filetype = self.config["format"] - args = list() + args: Tuple[int, ...] = tuple() if filetype == "jpg" and self.config["jpg_quality"] > 0: args = (cv2.IMWRITE_JPEG_QUALITY, # pylint: disable=no-member self.config["jpg_quality"]) @@ -37,21 +56,58 @@ def get_save_args(self): logger.debug(args) return args - def write(self, filename, image): - logger.trace("Outputting: (filename: '%s'", filename) - filename = self.output_filename(filename) - try: - with open(filename, "wb") as outfile: - outfile.write(image) - except Exception as err: # pylint: disable=broad-except - logger.error("Failed to save image '%s'. Original Error: %s", filename, err) - - def pre_encode(self, image): - """ Pre_encode the image in lib/convert.py threads as it is a LOT quicker """ - logger.trace("Pre-encoding image") - image = cv2.imencode(self.extension, image, self.args)[1] # pylint: disable=no-member - return image - - def close(self): - """ Image writer does not need a close method """ + def write(self, filename: str, image: List[bytes]) -> None: + """ Write out the pre-encoded image to disk. If separate mask has been selected, write out + the encoded mask to a sub-folder in the output directory. + + Parameters + ---------- + filename: str + The full path to write out the image to. + image: list + List of :class:`bytes` objects of length 1 (containing just the image to write out) + or length 2 (containing the image and mask to write out) + """ + logger.trace("Outputting: (filename: '%s'", filename) # type:ignore + filenames = self.output_filename(filename, self._separate_mask) + for fname, img in zip(filenames, image): + try: + with open(fname, "wb") as outfile: + outfile.write(img) + except Exception as err: # pylint: disable=broad-except + logger.error("Failed to save image '%s'. Original Error: %s", filename, err) + + def pre_encode(self, image: np.ndarray) -> List[bytes]: + """ Pre_encode the image in lib/convert.py threads as it is a LOT quicker. + + Parameters + ---------- + image: :class:`numpy.ndarray` + A 3 or 4 channel BGR swapped frame + + Returns + ------- + list + List of :class:`bytes` objects ready for writing. The list will be of length 1 with + image bytes object as the only member unless separate mask has been requested, in which + case it will be length 2 with the image in position 0 and mask in position 1 + """ + logger.trace("Pre-encoding image") # type:ignore + retval = [] + + if self._separate_mask: + mask = image[..., -1] + image = image[..., :3] + + retval.append(cv2.imencode(self._extension, # pylint: disable=no-member + mask, + self._args)[1]) + + retval.insert(0, cv2.imencode(self._extension, # pylint: disable=no-member + image, + self._args)[1]) + return retval + + def close(self) -> None: + """ Does nothing as OpenCV writer does not need a close method """ return diff --git a/plugins/convert/writer/opencv_defaults.py b/plugins/convert/writer/opencv_defaults.py index c1bd96368b..67022e1ae0 100755 --- a/plugins/convert/writer/opencv_defaults.py +++ b/plugins/convert/writer/opencv_defaults.py @@ -78,6 +78,21 @@ gui_radio=False, fixed=True, ), + separate_mask=dict( + default=False, + info="Seperate the mask into its own single channel image. This only applies when " + "'draw-transparent' is selected. If enabled, the RGB image will be saved into the " + "selected output folder whilst the masks will be saved into a sub-folder named " + "`masks`. If not enabled then the mask will be included in the alpha-channel of the " + "RGBA output.", + datatype=bool, + rounding=None, + min_max=None, + choices=[], + group="format", + gui_radio=False, + fixed=True, + ), jpg_quality=dict( default=75, info="[jpg only] Set the jpg quality. 1 is worst 95 is best. Higher quality leads to " diff --git a/plugins/convert/writer/pillow.py b/plugins/convert/writer/pillow.py index ef440f2b6c..92eb4a081d 100644 --- a/plugins/convert/writer/pillow.py +++ b/plugins/convert/writer/pillow.py @@ -1,22 +1,35 @@ #!/usr/bin/env python3 """ Image output writer for faceswap.py converter """ +from typing import Dict, List, Union from io import BytesIO from PIL import Image +import numpy as np + from ._base import Output, logger class Writer(Output): - """ Images output writer using cv2 """ - def __init__(self, output_folder, **kwargs): + """ Images output writer using Pillow + + Parameters + ---------- + output_folder: str + The full path to the output folder where the converted media should be saved + configfile: str, optional + The full path to a custom configuration ini file. If ``None`` is passed + then the file is loaded from the default location. Default: ``None``. + """ + def __init__(self, output_folder: str, **kwargs) -> None: super().__init__(output_folder, **kwargs) - self.check_transparency_format() + self._check_transparency_format() # Correct format namings for writing to byte stream - self.format_dict = dict(jpg="JPEG", jp2="JPEG 2000", tif="TIFF") - self.kwargs = self.get_save_kwargs() + self._format_dict = dict(jpg="JPEG", jp2="JPEG 2000", tif="TIFF") + self._separate_mask = self.config["draw_transparent"] and self.config["separate_mask"] + self._kwargs = self._get_save_kwargs() - def check_transparency_format(self): + def _check_transparency_format(self) -> None: """ Make sure that the output format is correct if draw_transparent is selected """ transparent = self.config["draw_transparent"] if not transparent or (transparent and self.config["format"] in ("png", "tif")): @@ -25,10 +38,16 @@ def check_transparency_format(self): "transparency. Changing output format to 'png'") self.config["format"] = "png" - def get_save_kwargs(self): - """ Return the save parameters for the file format """ + def _get_save_kwargs(self) -> Dict[str, Union[bool, int, str]]: + """ Return the save parameters for the file format + + Returns + ------- + dict + The specific keyword arguments for the selected file format + """ filetype = self.config["format"] - kwargs = dict() + kwargs = {} if filetype in ("gif", "jpg", "png"): kwargs["optimize"] = self.config["optimize"] if filetype == "gif": @@ -40,29 +59,78 @@ def get_save_kwargs(self): logger.debug(kwargs) return kwargs - def write(self, filename, image): - logger.trace("Outputting: (filename: '%s'", filename) - filename = self.output_filename(filename) + def write(self, filename: str, image: List[BytesIO]) -> None: + """ Write out the pre-encoded image to disk. If separate mask has been selected, write out + the encoded mask to a sub-folder in the output directory. + + Parameters + ---------- + filename: str + The full path to write out the image to. + image: list + List of :class:`BytesIO` objects of length 1 (containing just the image to write out) + or length 2 (containing the image and mask to write out) + """ + logger.trace("Outputting: (filename: '%s'", filename) # type:ignore + filenames = self.output_filename(filename, self._separate_mask) try: - with open(filename, "wb") as outfile: - outfile.write(image.read()) + for fname, img in zip(filenames, image): + with open(fname, "wb") as outfile: + outfile.write(img.read()) except Exception as err: # pylint: disable=broad-except logger.error("Failed to save image '%s'. Original Error: %s", filename, err) - def pre_encode(self, image): - """ Pre_encode the image in lib/convert.py threads as it is a LOT quicker """ - logger.trace("Pre-encoding image") - fmt = self.format_dict.get(self.config["format"], None) - fmt = self.config["format"].upper() if fmt is None else fmt + def pre_encode(self, image: np.ndarray) -> List[BytesIO]: + """ Pre_encode the image in lib/convert.py threads as it is a LOT quicker + + Parameters + ---------- + image: :class:`numpy.ndarray` + A 3 or 4 channel BGR swapped frame + + Returns + ------- + list + List of :class:`BytesIO` objects ready for writing. The list will be of length 1 with + image bytes object as the only member unless separate mask has been requested, in which + case it will be length 2 with the image in position 0 and mask in position 1 + """ + logger.trace("Pre-encoding image") # type:ignore + + if self._separate_mask: + encoded_mask = self._encode_image(image[..., -1]) + image = image[..., :3] + + rgb = [2, 1, 0, 3] if image.shape[2] == 4 else [2, 1, 0] + encoded_image = self._encode_image(image[..., rgb]) + + retval = [encoded_image] + + if self._separate_mask: + retval.append(encoded_mask) + + return retval + + def _encode_image(self, image: np.ndarray) -> BytesIO: + """ Encode an image in the correct format as a bytes object for saving + + Parameters + ---------- + image: :class:`np.ndarray` + The single channel mask to encode for saving + + Returns + ------- + :class:`BytesIO` + The image as a bytes object ready for writing to disk + """ + fmt = self._format_dict.get(self.config["format"], self.config["format"].upper()) encoded = BytesIO() - rgb = [2, 1, 0] - if image.shape[2] == 4: - rgb.append(3) - out_image = Image.fromarray(image[..., rgb]) - out_image.save(encoded, fmt, **self.kwargs) + out_image = Image.fromarray(image) + out_image.save(encoded, fmt, **self._kwargs) encoded.seek(0) return encoded - def close(self): - """ Image writer does not need a close method """ + def close(self) -> None: + """ Does nothing as Pillow writer does not need a close method """ return diff --git a/plugins/convert/writer/pillow_defaults.py b/plugins/convert/writer/pillow_defaults.py index d6f17bf936..58bf7e31ea 100755 --- a/plugins/convert/writer/pillow_defaults.py +++ b/plugins/convert/writer/pillow_defaults.py @@ -79,6 +79,21 @@ gui_radio=False, fixed=True, ), + separate_mask=dict( + default=False, + info="Seperate the mask into its own single channel image. This only applies when " + "'draw-transparent' is selected. If enabled, the RGB image will be saved into the " + "selected output folder whilst the masks will be saved into a sub-folder named " + "`masks`. If not enabled then the mask will be included in the alpha-channel of the " + "RGBA output.", + datatype=bool, + rounding=None, + min_max=None, + choices=[], + group="format", + gui_radio=False, + fixed=True, + ), optimize=dict( default=False, info="[gif, jpg and png only] If enabled, indicates that the encoder should make an extra " diff --git a/setup.cfg b/setup.cfg index d72c824bab..880f2f976b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,6 +16,8 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-matplotlib.*] ignore_missing_imports = True +[mypy-PIL.*] +ignore_missing_imports = True [mypy-plaidml.*] ignore_missing_imports = True [mypy-psutil.*] From 2ea05623bd684b2d1dd75679ad00441a5c751e7e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 19 Jul 2022 18:23:16 +0100 Subject: [PATCH 670/981] Update Distibution Strategies: - Add Central Storage Stategy - Deprecate 'distributed' cli argument --- lib/cli/args.py | 20 ++++++- plugins/train/model/_base/settings.py | 86 ++++++++++++++++++++------- scripts/train.py | 14 ++++- 3 files changed, 97 insertions(+), 23 deletions(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index dc0766db85..a17ccfc35b 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -1020,8 +1020,24 @@ def get_argument_list() -> List[Dict[str, Any]]: default=False, backend="nvidia", group=_("training"), - help=_("Use the Tensorflow Mirrored Distrubution Strategy to train on multiple " - "GPUs."))) + help=_("[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " + "Mirrored Distrubution Strategy to train on multiple GPUs."))) + argument_list.append(dict( + opts=("-D", "--distribution-strategy"), + dest="distribution_strategy", + action=Radio, + type=str.lower, + choices=["central-storage", "mirrored"], + backend="nvidia", + group=_("training"), + help=_("R|Select the distribution stategy to use." + "\nL|central-storage: Centralizes variables on the CPU whilst operations are " + "performed on 1 or more local GPUs. This can help save some VRAM at the cost " + "of some speed by not storing variables on the GPU. Note: Mixed-Precision is " + "not supported on multi-GPU setups." + "\nL|mirrored: Supports synchronous distributed training across multiple local " + "GPUs. A copy of the model and all variables are loaded onto each GPU with " + "batches distributed to each GPU at each iteration."))) argument_list.append(dict( opts=("-s", "--save-interval"), action=Slider, diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 5aa48cdbc2..763f38f353 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -405,15 +405,12 @@ def __init__(self, if self._use_mixed_precision: logger.info("Enabling Mixed Precision Training.") - distributed = False if not hasattr(arguments, "distributed") else arguments.distributed - self._strategy = self._get_strategy(distributed) + strategy = None + if hasattr(arguments, "distribution_strategy"): + strategy = arguments.distribution_strategy + self._strategy = self._get_strategy(strategy) logger.debug("Initialized %s", self.__class__.__name__) - @property - def use_strategy(self) -> bool: - """ bool: ``True`` if a distribution strategy is to be used otherwise ``False``. """ - return self._strategy is not None - @property def use_mixed_precision(self) -> bool: """ bool: ``True`` if mixed precision training has been enabled, otherwise ``False``. """ @@ -512,9 +509,10 @@ def _set_keras_mixed_precision(cls, use_mixed_precision: bool) -> bool: policy.compute_dtype, policy.variable_dtype) return True - @classmethod - def _get_strategy(cls, distributed: bool) -> Optional[tf.distribute.Strategy]: - """ If we are running on Nvidia backend and the strategy is not `"default"` then return + def _get_strategy(self, + strategy: Optional[Literal["central-storage", "mirrored"]] + ) -> Optional[tf.distribute.Strategy]: + """ If we are running on Nvidia backend and the strategy is not ``None`` then return the correct tensorflow distribution strategy, otherwise return ``None``. Notes @@ -523,11 +521,14 @@ def _get_strategy(cls, distributed: bool) -> Optional[tf.distribute.Strategy]: reductions, however this is only available in Linux, so the method used falls back to `Hierarchical Copy All Reduce` if the OS is not Linux. + Central Storage strategy is not compatible with Mixed Precision. However, in testing it + worked fine when using a single GPU, so we monkey-patch out the tests for Mixed-Precision + when using this strategy with a single GPU + Parameters ---------- - distributed: bool - ``True`` if Tensorflow mirrored strategy should be used for multiple GPU training. - ``False`` if the default strategy should be used. + strategy: str, optional + One of 'central-storage' or 'mirrored'. Pass ``None`` to use the default strategy Returns ------- @@ -537,18 +538,63 @@ def _get_strategy(cls, distributed: bool) -> Optional[tf.distribute.Strategy]: """ if get_backend() != "nvidia": retval = None - elif distributed: - if platform.system().lower() == "linux": - cross_device_ops = tf.distribute.NcclAllReduce() - else: - cross_device_ops = tf.distribute.HierarchicalCopyAllReduce() - logger.debug("cross_device_ops: %s", cross_device_ops) - retval = tf.distribute.MirroredStrategy(cross_device_ops=cross_device_ops) + if strategy == "mirrored": + retval = self._get_mirrored_strategy() + elif strategy == "central-storage": + retval = self._get_central_storage_strategy() else: retval = tf.distribute.get_strategy() logger.debug("Using strategy: %s", retval) return retval + @classmethod + def _get_mirrored_strategy(cls) -> tf.distribute.MirroredStrategy: + """ Obtain an instance of a Tensorflow Mirrored Strategy, setting the cross device + operations appropriate for the OS in use. + + Returns + ------- + :class:`tensorflow.distribute.MirroredStrategy` + The Mirrored Distribution Strategy object with correct cross device operations set + """ + if platform.system().lower() == "linux": + cross_device_ops = tf.distribute.NcclAllReduce() + else: + cross_device_ops = tf.distribute.HierarchicalCopyAllReduce() + logger.debug("cross_device_ops: %s", cross_device_ops) + return tf.distribute.MirroredStrategy(cross_device_ops=cross_device_ops) + + @classmethod + def _get_central_storage_strategy(cls) -> tf.distribute.experimental.CentralStorageStrategy: + """ Obtain an instance of a Tensorflow Central Storage Strategy. If the strategy is being + run on a single GPU then monkey patch Tensorflows mixed-precision strategy checks to pass + successfully. + + Returns + ------- + :class:`tensorflow.distribute.experimental.CentralStorageStrategy` + The Central Storage Distribution Strategy object + """ + gpus = tf.config.get_visible_devices("GPU") + if len(gpus) == 1: + # TODO Remove these monkey patches when Strategy supports mixed-precision + from keras.mixed_precision import loss_scale_optimizer # noqa pylint:disable=import-outside-toplevel + + # Force a return of True on Loss Scale Optimizer Stategy check + loss_scale_optimizer.strategy_supports_loss_scaling = lambda: True + + # As LossScaleOptimizer aggregates gradients internally, it passes `False` as the value + # for `experimental_aggregate_gradients` in `OptimizerV2.apply_gradients`. This causes + # the optimizer to fail when checking against this strategy. We could monkey patch + # `Optimizer.apply_gradients`, but it is a lot more code to check, so we just switch + # the `experimental_aggregate_gradients` back to `True`. In brief testing this does not + # appear to have a negative impact. + func = lambda s, grads, wvars, name: s._optimizer.apply_gradients( # noqa pylint:disable=protected-access + list(zip(grads, wvars.value)), name, experimental_aggregate_gradients=True) + loss_scale_optimizer.LossScaleOptimizer._apply_gradients = func # noqa pylint:disable=protected-access + + return tf.distribute.experimental.CentralStorageStrategy(parameter_device="/cpu:0") + def _get_mixed_precision_layers(self, layers: List[dict]) -> List[str]: """ Obtain the names of the layers in a mixed precision model that have their dtype policy explicitly set to mixed-float16. diff --git a/scripts/train.py b/scripts/train.py index 4114d4ba51..e68940928d 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -16,7 +16,8 @@ from lib.image import read_image_meta from lib.keypress import KBHit from lib.multithreading import MultiThread -from lib.utils import get_dpi, get_folder, get_image_paths, FaceswapError, _image_extensions +from lib.utils import (deprecation_warning, get_dpi, get_folder, get_image_paths, + FaceswapError, _image_extensions) from plugins.plugin_loader import PluginLoader if sys.version_info < (3, 8): @@ -51,6 +52,8 @@ class Train(): # pylint:disable=too-few-public-methods def __init__(self, arguments: "argparse.Namespace") -> None: logger.debug("Initializing %s: (args: %s", self.__class__.__name__, arguments) self._args = arguments + self._handle_deprecations() + if self._args.summary: # If just outputting summary we don't need to initialize everything return @@ -67,6 +70,15 @@ def __init__(self, arguments: "argparse.Namespace") -> None: logger.debug("Initialized %s", self.__class__.__name__) + def _handle_deprecations(self) -> None: + """ Handle the update of deprecated arguments and output warnings. """ + if self._args.distributed: + deprecation_warning("`-d`, `--distributed`", + "Please use `-D`, `--distribution-strategy`") + logger.warning("Setting 'distribution-strategy' to 'mirrored'") + setattr(self._args, "distribution_strategy", "mirrored") + del self._args.distributed + def _get_images(self) -> Dict[Literal["a", "b"], List[str]]: """ Check the image folders exist and contains valid extracted faces. Obtain image paths. From 54398559ff19efcf3ba4797598e0ee8f4d02632f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 20 Jul 2022 12:29:14 +0100 Subject: [PATCH 671/981] Set default distribution strategy to 'default' --- lib/cli/args.py | 4 +++- plugins/train/model/_base/settings.py | 13 +++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index a17ccfc35b..e5f5302009 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -1027,10 +1027,12 @@ def get_argument_list() -> List[Dict[str, Any]]: dest="distribution_strategy", action=Radio, type=str.lower, - choices=["central-storage", "mirrored"], + choices=["default", "central-storage", "mirrored"], + default="default", backend="nvidia", group=_("training"), help=_("R|Select the distribution stategy to use." + "\nL|default: Use Tensorflow's default distribution strategy." "\nL|central-storage: Centralizes variables on the CPU whilst operations are " "performed on 1 or more local GPUs. This can help save some VRAM at the cost " "of some speed by not storing variables on the GPU. Note: Mixed-Precision is " diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 763f38f353..acc8a8df4a 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -405,10 +405,7 @@ def __init__(self, if self._use_mixed_precision: logger.info("Enabling Mixed Precision Training.") - strategy = None - if hasattr(arguments, "distribution_strategy"): - strategy = arguments.distribution_strategy - self._strategy = self._get_strategy(strategy) + self._strategy = self._get_strategy(arguments.distribution_strategy) logger.debug("Initialized %s", self.__class__.__name__) @property @@ -510,7 +507,7 @@ def _set_keras_mixed_precision(cls, use_mixed_precision: bool) -> bool: return True def _get_strategy(self, - strategy: Optional[Literal["central-storage", "mirrored"]] + strategy: Literal["default", "central-storage", "mirrored"] ) -> Optional[tf.distribute.Strategy]: """ If we are running on Nvidia backend and the strategy is not ``None`` then return the correct tensorflow distribution strategy, otherwise return ``None``. @@ -527,8 +524,8 @@ def _get_strategy(self, Parameters ---------- - strategy: str, optional - One of 'central-storage' or 'mirrored'. Pass ``None`` to use the default strategy + strategy: str + One of 'default', 'central-storage' or 'mirrored'. Returns ------- @@ -538,7 +535,7 @@ def _get_strategy(self, """ if get_backend() != "nvidia": retval = None - if strategy == "mirrored": + elif strategy == "mirrored": retval = self._get_mirrored_strategy() elif strategy == "central-storage": retval = self._get_central_storage_strategy() From dcb436c9df7cc15f2e44a8ffb67b65b60b0da670 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 20 Jul 2022 12:44:22 +0100 Subject: [PATCH 672/981] Bugfix: Handle distribution strat in convert --- plugins/train/model/_base/settings.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index acc8a8df4a..76bfe5b8d7 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -405,7 +405,11 @@ def __init__(self, if self._use_mixed_precision: logger.info("Enabling Mixed Precision Training.") - self._strategy = self._get_strategy(arguments.distribution_strategy) + if hasattr(arguments, "distribution_strategy"): + strategy = arguments.distribution_strategy + else: + strategy = "default" + self._strategy = self._get_strategy(strategy) logger.debug("Initialized %s", self.__class__.__name__) @property From 03f6cb4e7e106bc227ad781a515338097fba26f9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 28 Jul 2022 23:53:31 +0100 Subject: [PATCH 673/981] setup.py: implement logging --- .install/linux/faceswap_setup_x64.sh | 2 +- .install/windows/install.nsi | 2 +- docs/full/modules.rst | 2 + docs/full/setup.rst | 8 + docs/full/update_deps.rst | 8 + lib/gui/custom_widgets.py | 24 +- lib/gui/menu.py | 59 +- lib/logger.py | 201 +++++-- requirements/_requirements_base.txt | 2 +- setup.cfg | 2 + setup.py | 797 ++++++++++++++++----------- update_deps.py | 38 +- 12 files changed, 737 insertions(+), 408 deletions(-) create mode 100644 docs/full/setup.rst create mode 100644 docs/full/update_deps.rst diff --git a/.install/linux/faceswap_setup_x64.sh b/.install/linux/faceswap_setup_x64.sh index 980540fb98..49e3450c85 100644 --- a/.install/linux/faceswap_setup_x64.sh +++ b/.install/linux/faceswap_setup_x64.sh @@ -385,7 +385,7 @@ setup_faceswap() { # Run faceswap setup script info "Setting up Faceswap..." if [ $VERSION != "cpu" ] ; then args="--$VERSION" ; else args="" ; fi - python "$DIR_FACESWAP/setup.py" --installer $args + python -u "$DIR_FACESWAP/setup.py" --installer $args } create_gui_launcher () { diff --git a/.install/windows/install.nsi b/.install/windows/install.nsi index 277a0228ec..eda5f6c28d 100644 --- a/.install/windows/install.nsi +++ b/.install/windows/install.nsi @@ -448,7 +448,7 @@ Function SetupFaceSwap StrCpy $0 "$0 --$setupType" ${EndIf} SetDetailsPrint listonly - ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda activate $\"$envName$\" && python $\"$INSTDIR\setup.py$\" $0 && conda deactivate" + ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda activate $\"$envName$\" && python -u $\"$INSTDIR\setup.py$\" $0 && conda deactivate" pop $0 ExecDos::wait $0 pop $0 diff --git a/docs/full/modules.rst b/docs/full/modules.rst index 8dd0581022..1286cb4a7e 100644 --- a/docs/full/modules.rst +++ b/docs/full/modules.rst @@ -8,3 +8,5 @@ faceswap plugins/plugins scripts tools/tools + setup + update_deps diff --git a/docs/full/setup.rst b/docs/full/setup.rst new file mode 100644 index 0000000000..baa29b9f19 --- /dev/null +++ b/docs/full/setup.rst @@ -0,0 +1,8 @@ +************ +setup module +************ + +.. automodule:: setup + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/full/update_deps.rst b/docs/full/update_deps.rst new file mode 100644 index 0000000000..aea4753eaf --- /dev/null +++ b/docs/full/update_deps.rst @@ -0,0 +1,8 @@ +****************** +update_deps module +****************** + +.. automodule:: update_deps + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index 01e19c09ed..007a767804 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -238,6 +238,7 @@ def __init__(self, console, out_type): self._console = console self._out_type = out_type self._recolor = re.compile(r".+?(\s\d+:\d+:\d+\s)(?P[A-Z]+)\s") + self._ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") logger.debug("Initialized %s", self.__class__.__name__) def _get_tag(self, string): @@ -254,6 +255,7 @@ def _get_tag(self, string): def write(self, string): """ Capture stdout/stderr """ + string = self._ansi_escape.sub("", string) self._console.insert(tk.END, string, self._get_tag(string)) self._console.see(tk.END) @@ -315,9 +317,8 @@ def __init__(self, widget): tk_.createcommand(wgt, self.dispatch) def __repr__(self): - return "%s(%s<%s>)" % (self.__class__.__name__, - self.widget.__class__.__name__, - self.widget._w) # pylint:disable=protected-access + return (f"{self.__class__.__name__}({self.widget.__class__.__name__}" + f"<{self.widget._w}>)") # pylint:disable=protected-access def close(self): "de-register operations and revert redirection created by .__init__." @@ -409,8 +410,7 @@ def __init__(self, redirect, operation): self.orig_and_operation = (redirect.orig, operation) def __repr__(self): - return "%s(%r, %r)" % (self.__class__.__name__, - self.redirect, self.operation) + return f"{self.__class__.__name__}({self.redirect}, {self.operation})" def __call__(self, *args): return self.tk_call(self.orig_and_operation + args) @@ -619,12 +619,8 @@ def tip_pos_calculator(widget, label, x_1, y_1 = mouse_x + tip_delta[0], mouse_y + tip_delta[1] x_2, y_2 = x_1 + width, y_1 + height - x_delta = x_2 - s_width - if x_delta < 0: - x_delta = 0 - y_delta = y_2 - s_height - if y_delta < 0: - y_delta = 0 + x_delta = max(x_2 - s_width, 0) + y_delta = max(y_2 - s_height, 0) offscreen = (x_delta, y_delta) != (0, 0) @@ -670,7 +666,7 @@ def tip_pos_calculator(widget, label, text = self._text if self._text_variable and self._text_variable.get(): - text += "\n\nCurrent value: '{}'".format(self._text_variable.get()) + text += f"\n\nCurrent value: '{self._text_variable.get()}'" label = tk.Label(win, text=text, justify=tk.LEFT, @@ -687,7 +683,7 @@ def tip_pos_calculator(widget, label, xpos, ypos = tip_pos_calculator(widget, label) - self._topwidget.wm_geometry("+%d+%d" % (xpos, ypos)) + self._topwidget.wm_geometry(f"+{xpos}+{ypos}") def _hide(self): """ Hide the tooltip """ @@ -819,7 +815,7 @@ def __init__(self, title, total): center = np.array(( (self.master.winfo_width() // 2) - (self.winfo_width() // 2), (self.master.winfo_height() // 2) - (self.winfo_height() // 2))) + offset - self.wm_geometry("+{}+{}".format(*center)) + self.wm_geometry(f"+{center[0]}+{center[1]}") get_config().set_cursor_busy() self.grab_set() diff --git a/lib/gui/menu.py b/lib/gui/menu.py index f5f6aa16f1..370313d38b 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -166,10 +166,10 @@ def build_recent_menu(self): kwargs = dict(filename=filename) else: load_func = self._config.tasks.load - lbl = "{} Task".format(command) + lbl = f"{command} Task" kwargs = dict(filename=filename, current_tab=False) self.recent_menu.add_command( - label="{} ({})".format(filename, lbl.title()), + label=f"{filename} ({lbl.title()})", command=lambda kw=kwargs, fn=load_func: fn(**kw)) if removed_files: for recent_item in removed_files: @@ -188,7 +188,7 @@ def build_recent_menu(self): def clear_recent_files(serializer, menu_file): """ Creates or clears recent file list """ logger.debug("clearing recent files list: '%s'", menu_file) - serializer.save(menu_file, list()) + serializer.save(menu_file, []) def refresh_recent_menu(self): """ Refresh recent menu on save/load of files """ @@ -263,9 +263,9 @@ def _get_branches(): error then `None` is returned """ gitcmd = "git branch -a" - cmd = Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, cwd=_WORKING_DIR) - stdout, _ = cmd.communicate() - retcode = cmd.poll() + with Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, cwd=_WORKING_DIR) as cmd: + stdout, _ = cmd.communicate() + retcode = cmd.poll() if retcode != 0: logger.debug("Unable to list git branches. return code: %s, message: %s", retcode, stdout.decode().strip().replace("\n", " - ")) @@ -315,10 +315,10 @@ def _switch_branch(branch): The branch to switch to """ logger.info("Switching branch to '%s'...", branch) - gitcmd = "git checkout {}".format(branch) - cmd = Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, cwd=_WORKING_DIR) - stdout, _ = cmd.communicate() - retcode = cmd.poll() + gitcmd = f"git checkout {branch}" + with Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, cwd=_WORKING_DIR) as cmd: + stdout, _ = cmd.communicate() + retcode = cmd.poll() if retcode != 0: logger.error("Unable to switch branch. return code: %s, message: %s", retcode, stdout.decode().strip().replace("\n", " - ")) @@ -358,7 +358,7 @@ def output_sysinfo(self): from lib.sysinfo import sysinfo # pylint:disable=import-outside-toplevel info = sysinfo except Exception as err: # pylint:disable=broad-except - info = "Error obtaining system info: {}".format(str(err)) + info = f"Error obtaining system info: {str(err)}" self.clear_console() logger.debug("Obtained system information: %s", info) print(info) @@ -382,7 +382,7 @@ def update(self): success = False if self.check_for_updates(encoding): success = self.do_update(encoding) - update_deps.main(logger=logger) + update_deps.main(is_gui=True) if success: logger.info("Please restart Faceswap to complete the update.") self.root.config(cursor="") @@ -395,9 +395,9 @@ def check_for_updates(encoding, check=False): update = False msg = "" gitcmd = "git remote update && git status -uno" - cmd = Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, cwd=_WORKING_DIR) - stdout, _ = cmd.communicate() - retcode = cmd.poll() + with Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, cwd=_WORKING_DIR) as cmd: + stdout, _ = cmd.communicate() + retcode = cmd.poll() if retcode != 0: msg = ("Git is not installed or you are not running a cloned repo. " "Unable to check for updates") @@ -427,15 +427,20 @@ def do_update(encoding): """ Update Faceswap """ logger.info("A new version is available. Updating...") gitcmd = "git pull" - cmd = Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, bufsize=1, cwd=_WORKING_DIR) - while True: - output = cmd.stdout.readline().decode(encoding) - if output == "" and cmd.poll() is not None: - break - if output: - logger.debug("'%s' output: '%s'", gitcmd, output.strip()) - print(output.strip()) - retcode = cmd.poll() + with Popen(gitcmd, + shell=True, + stdout=PIPE, + stderr=STDOUT, + bufsize=1, + cwd=_WORKING_DIR) as cmd: + while True: + output = cmd.stdout.readline().decode(encoding) + if output == "" and cmd.poll() is not None: + break + if output: + logger.debug("'%s' output: '%s'", gitcmd, output.strip()) + print(output.strip()) + retcode = cmd.poll() logger.debug("'%s' returncode: %s", gitcmd, retcode) if retcode != 0: logger.info("An error occurred during update. return code: %s", retcode) @@ -482,7 +487,7 @@ def _task_btns(self): frame.pack(side=tk.LEFT, anchor=tk.W, expand=False, padx=2) for loadtype in ("load", "save", "save_as", "clear", "reload"): - btntype = "{}2".format(loadtype) + btntype = f"{loadtype}2" logger.debug("Adding button: '%s'", btntype) loader, kwargs = self._loader_and_kwargs(loadtype) @@ -507,7 +512,7 @@ def _loader_and_kwargs(btntype): kwargs = dict(save_as=True) else: loader = btntype - kwargs = dict() + kwargs = {} logger.debug("btntype: %s, loader: %s, kwargs: %s", btntype, loader, kwargs) return loader, kwargs @@ -516,7 +521,7 @@ def _settings_btns(self): frame = ttk.Frame(self._btn_frame) frame.pack(side=tk.LEFT, anchor=tk.W, expand=False, padx=2) for name in ("extract", "train", "convert"): - btntype = "settings_{}".format(name) + btntype = f"settings_{name}" btntype = btntype if btntype in get_images().icons else "settings" logger.debug("Adding button: '%s'", btntype) btn = ttk.Button( diff --git a/lib/logger.py b/lib/logger.py index 452080c3b1..255f0131f5 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -4,16 +4,19 @@ import logging from logging.handlers import RotatingFileHandler import os +import platform +import re import sys +import time import traceback from datetime import datetime -from tqdm import tqdm +from typing import Union class FaceswapLogger(logging.Logger): """ A standard :class:`logging.logger` with additional "verbose" and "trace" levels added. """ - def __init__(self, name): + def __init__(self, name: str) -> None: for new_level in (("VERBOSE", 15), ("TRACE", 5)): level_name, level_num = new_level if hasattr(logging, level_name): @@ -22,7 +25,7 @@ def __init__(self, name): setattr(logging, level_name, level_num) super().__init__(name) - def verbose(self, msg, *args, **kwargs): + def verbose(self, msg: str, *args, **kwargs) -> None: # pylint:disable=wrong-spelling-in-docstring """ Create a log message at severity level 15. @@ -38,7 +41,7 @@ def verbose(self, msg, *args, **kwargs): if self.isEnabledFor(15): self._log(15, msg, args, **kwargs) - def trace(self, msg, *args, **kwargs): + def trace(self, msg: str, *args, **kwargs) -> None: # pylint:disable=wrong-spelling-in-docstring """ Create a log message at severity level 5. @@ -55,6 +58,102 @@ def trace(self, msg, *args, **kwargs): self._log(5, msg, args, **kwargs) +class ColoredFormatter(logging.Formatter): + """ Overrides the stand :class:`logging.Formatter` to enable colored labels for message level + labels on supported platforms + + Parameters + ---------- + fmt: str + The format string for the message as a whole + pad_newlines: bool, Optional + If ``True`` new lines will be padded to appear in line with the log message, if ``False`` + they will be left aligned + + kwargs: dict + Standard :class:`logging.Formatter` keyword arguments + """ + def __init__(self, fmt: str, pad_newlines: bool = False, **kwargs) -> None: + super().__init__(fmt, **kwargs) + self._use_color = platform.system().lower() in ("linux", "darwin") + self._level_colors = dict(CRITICAL="\033[31m", # red + ERROR="\033[31m", # red + WARNING="\033[33m", # yellow + INFO="\033[32m", # green + VERBOSE="\033[34m") # blue + self._default_color = "\033[0m" + self._newline_padding = self._get_newline_padding(pad_newlines, fmt) + + def _get_newline_padding(self, pad_newlines: bool, fmt: str) -> int: + """ Parses the format string to obtain padding for newlines if requested + + Parameters + ---------- + fmt: str + The format string for the message as a whole + pad_newlines: bool, Optional + If ``True`` new lines will be padded to appear in line with the log message, if + ``False`` they will be left aligned + + Returns + ------- + int + The amount of padding to apply to the front of newlines + """ + if not pad_newlines: + return 0 + msg_idx = fmt.find("%(message)") + 1 + filtered = fmt[:msg_idx - 1] + spaces = filtered.count(" ") + pads = [int(pad.replace("s", "")) for pad in re.findall(r"\ds", filtered)] + if "asctime" in filtered: + pads.append(self._get_sample_time_string()) + return sum(pads) + spaces + + def _get_sample_time_string(self) -> int: + """ Obtain a sample time string and calculate correct padding. + + This may be inaccurate wheb ticking over an integer from single to double digits, but that + shouldn't be a huge issue. + + Returns + ------- + int + The length of the formatted date-time string + """ + sample_time = time.time() + date_format = self.datefmt if self.datefmt else self.default_time_format + datestring = time.strftime(date_format, logging.Formatter.converter(sample_time)) + if not self.datefmt and self.default_msec_format: + msecs = (sample_time - int(sample_time)) * 1000 + datestring = self.default_msec_format % (datestring, msecs) + return len(datestring) + + def format(self, record: logging.LogRecord) -> str: + """ Color the log message level if supported otherwise return the standard log message. + + Parameters + ---------- + record: :class:`logging.LogRecord` + The incoming log record to be formatted for entry into the logger. + + Returns + ------- + str + The formatted log message + """ + formatted = super().format(record) + levelname = record.levelname + if self._use_color and levelname in self._level_colors: + formatted = re.sub(levelname, + f"{self._level_colors[levelname]}{levelname}{self._default_color}", + formatted, + 1) + if self._newline_padding: + formatted = formatted.replace("\n", f"\n{' ' * self._newline_padding}") + return formatted + + class FaceswapFormatter(logging.Formatter): """ Overrides the standard :class:`logging.Formatter`. @@ -63,7 +162,7 @@ class FaceswapFormatter(logging.Formatter): Rewrites some upstream warning messages to debug level to avoid spamming the console. """ - def format(self, record): + def format(self, record: logging.LogRecord) -> str: """ Strip new lines from log records and rewrite certain warning messages to debug level. Parameters @@ -102,7 +201,7 @@ def format(self, record): return msg @classmethod - def _rewrite_warnings(cls, record): + def _rewrite_warnings(cls, record: logging.LogRecord) -> logging.LogRecord: """ Change certain warning messages from WARNING to DEBUG to avoid passing non-important information to output. @@ -133,7 +232,7 @@ def _rewrite_warnings(cls, record): return record @classmethod - def _lower_external(cls, record): + def _lower_external(cls, record: logging.LogRecord) -> logging.LogRecord: """ Some external libs log at a higher level than we would really like, so lower their log level. @@ -162,7 +261,7 @@ class RollingBuffer(collections.deque): """File-like that keeps a certain number of lines of text in memory for writing out to the crash log. """ - def write(self, buffer): + def write(self, buffer: str) -> None: """ Splits lines from the incoming buffer and writes them out to the rolling buffer. Parameters @@ -178,7 +277,7 @@ class TqdmHandler(logging.StreamHandler): """ Overrides :class:`logging.StreamHandler` to use :func:`tqdm.tqdm.write` rather than writing to :func:`sys.stderr` so that log messages do not mess up tqdm progress bars. """ - def emit(self, record): + def emit(self, record: logging.LogRecord) -> None: """ Format the incoming message and pass to :func:`tqdm.tqdm.write`. Parameters @@ -186,11 +285,13 @@ def emit(self, record): record : :class:`logging.LogRecord` The incoming log record to be formatted for entry into the logger. """ + # tqdm is imported here as it won't be installed when setup.py is running + from tqdm import tqdm # pylint:disable=import-outside-toplevel msg = self.format(record) tqdm.write(msg) -def _set_root_logger(loglevel=logging.INFO): +def _set_root_logger(loglevel: int = logging.INFO) -> logging.Logger: """ Setup the root logger. Parameters @@ -208,7 +309,7 @@ def _set_root_logger(loglevel=logging.INFO): return rootlogger -def log_setup(loglevel, log_file, command, is_gui=False): +def log_setup(loglevel, log_file: str, command: str, is_gui: bool = False) -> None: """ Set up logging for Faceswap. Sets up the root logger, the formatting for the crash logger and the file logger, and sets up @@ -230,19 +331,32 @@ def log_setup(loglevel, log_file, command, is_gui=False): numeric_loglevel = get_loglevel(loglevel) root_loglevel = min(logging.DEBUG, numeric_loglevel) rootlogger = _set_root_logger(loglevel=root_loglevel) - log_format = FaceswapFormatter("%(asctime)s %(processName)-15s %(threadName)-30s " - "%(module)-15s %(funcName)-30s %(levelname)-8s %(message)s", - datefmt="%m/%d/%Y %H:%M:%S") - f_handler = _file_handler(numeric_loglevel, log_file, log_format, command) - s_handler = _stream_handler(numeric_loglevel, is_gui) - c_handler = _crash_handler(log_format) + + if command == "setup": + log_format = FaceswapFormatter("%(asctime)s %(module)-16s %(funcName)-30s %(levelname)-8s " + "%(message)s", datefmt="%m/%d/%Y %H:%M:%S") + s_handler = _stream_setup_handler(numeric_loglevel) + f_handler = _file_handler(root_loglevel, log_file, log_format, command) + else: + log_format = FaceswapFormatter("%(asctime)s %(processName)-15s %(threadName)-30s " + "%(module)-15s %(funcName)-30s %(levelname)-8s %(message)s", + datefmt="%m/%d/%Y %H:%M:%S") + s_handler = _stream_handler(numeric_loglevel, is_gui) + f_handler = _file_handler(numeric_loglevel, log_file, log_format, command) + rootlogger.addHandler(f_handler) rootlogger.addHandler(s_handler) - rootlogger.addHandler(c_handler) - logging.info("Log level set to: %s", loglevel.upper()) + if command != "setup": + c_handler = _crash_handler(log_format) + rootlogger.addHandler(c_handler) + logging.info("Log level set to: %s", loglevel.upper()) -def _file_handler(loglevel, log_file, log_format, command): + +def _file_handler(loglevel, + log_file: str, + log_format: FaceswapFormatter, + command: str) -> RotatingFileHandler: """ Add a rotating file handler for the current Faceswap session. 1 backup is always kept. Parameters @@ -270,21 +384,21 @@ def _file_handler(loglevel, log_file, log_format, command): filename += "_gui.log" if command == "gui" else ".log" should_rotate = os.path.isfile(filename) - log_file = RotatingFileHandler(filename, backupCount=1, encoding="utf-8") + handler = RotatingFileHandler(filename, backupCount=1, encoding="utf-8") if should_rotate: - log_file.doRollover() - log_file.setFormatter(log_format) - log_file.setLevel(loglevel) - return log_file + handler.doRollover() + handler.setFormatter(log_format) + handler.setLevel(loglevel) + return handler -def _stream_handler(loglevel, is_gui): +def _stream_handler(loglevel: int, is_gui: bool) -> Union[logging.StreamHandler, TqdmHandler]: """ Add a stream handler for the current Faceswap session. The stream handler will only ever output at a maximum of VERBOSE level to avoid spamming the console. Parameters ---------- - loglevel: str + loglevel: int The requested log level that messages should be logged at. is_gui: bool, optional Whether Faceswap is running in the GUI or not. Dictates where the stream handler should @@ -311,7 +425,30 @@ def _stream_handler(loglevel, is_gui): return log_console -def _crash_handler(log_format): +def _stream_setup_handler(loglevel: int) -> logging.StreamHandler: + """ Add a stream handler for faceswap's setup.py script + This stream handler outputs a limited set of easy to use information using colored labels + if available. It will only ever output at a minimum of INFO level + + Parameters + ---------- + loglevel: int + The requested log level that messages should be logged at. + + Returns + ------- + :class:`logging.StreamHandler` + The stream handler to use + """ + loglevel = max(loglevel, 15) + log_format = ColoredFormatter("%(levelname)-8s %(message)s", pad_newlines=True) + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(log_format) + handler.setLevel(loglevel) + return handler + + +def _crash_handler(log_format: FaceswapFormatter) -> logging.StreamHandler: """ Add a handler that stores the last 100 debug lines to :attr:'_DEBUG_BUFFER' for use in crash reports. @@ -331,7 +468,7 @@ def _crash_handler(log_format): return log_crash -def get_loglevel(loglevel): +def get_loglevel(loglevel: str) -> int: """ Check whether a valid log level has been supplied, and return the numeric log level that corresponds to the given string level. @@ -351,7 +488,7 @@ def get_loglevel(loglevel): return numeric_level -def crash_log(): +def crash_log() -> str: """ On a crash, write out the contents of :func:`_DEBUG_BUFFER` containing the last 100 lines of debug messages to a crash report in the root Faceswap folder. @@ -379,11 +516,11 @@ def crash_log(): _OLD_FACTORY = logging.getLogRecordFactory() -def _faceswap_logrecord(*args, **kwargs): +def _faceswap_logrecord(*args, **kwargs) -> logging.LogRecord: """ Add a flag to :class:`logging.LogRecord` to not strip formatting from particular records. """ record = _OLD_FACTORY(*args, **kwargs) - record.strip_spaces = True + record.strip_spaces = True # type:ignore return record diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index af6511fe8f..41d82871fc 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -13,6 +13,6 @@ ffmpy==0.2.3 #nvidia-ml-py>=11.510,<300 # Pin nvidida-ml-py to <11.515 until we know if bytes->str is an error or permanent change nvidia-ml-py<11.515 -tensorflow_probability<0.17 +tensorflow-probability<0.17 typing-extensions>=4.0.0 pywin32>=228 ; sys_platform == "win32" diff --git a/setup.cfg b/setup.cfg index 880f2f976b..3822ce6271 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,6 +16,8 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-matplotlib.*] ignore_missing_imports = True +[mypy-pexpect.*] +ignore_missing_imports = True [mypy-PIL.*] ignore_missing_imports = True [mypy-plaidml.*] diff --git a/setup.py b/setup.py index 1dc2cd5b83..8d013afd26 100755 --- a/setup.py +++ b/setup.py @@ -2,6 +2,7 @@ """ Install packages for faceswap.py """ # >>> Environment +import logging import ctypes import json import locale @@ -10,22 +11,24 @@ import os import re import sys -from subprocess import CalledProcessError, run, PIPE, Popen -from typing import Dict, List, Optional, Tuple, TYPE_CHECKING, Union +from subprocess import run, PIPE, Popen, STDOUT +from typing import Dict, List, Optional, Tuple from pkg_resources import parse_requirements, Requirement -if TYPE_CHECKING: - from logging import Logger +from lib.logger import log_setup - -INSTALL_FAILED = False +logger = logging.getLogger(__name__) # pylint: disable=invalid-name +_INSTALL_FAILED = False # Revisions of tensorflow GPU and cuda/cudnn requirements. These relate specifically to the # Tensorflow builds available from pypi -TENSORFLOW_REQUIREMENTS = {">=2.4.0,<2.5.0": ["11.0", "8.0"], - ">=2.5.0,<2.9.0": ["11.2", "8.1"]} +_TENSORFLOW_REQUIREMENTS = {">=2.4.0,<2.5.0": ["11.0", "8.0"], + ">=2.5.0,<2.9.0": ["11.2", "8.1"]} +# Packages that are explicitly required for setup.py +_INSTALLER_REQUIREMENTS = ["pexpect>=4.8.0"] + # Mapping of Python packages to their conda names if different from pip or in non-default channel -CONDA_MAPPING = { +_CONDA_MAPPING: Dict[str, Tuple[str, str]] = { # "opencv-python": ("opencv", "conda-forge"), # Periodic issues with conda-forge opencv "fastcluster": ("fastcluster", "conda-forge"), "imageio-ffmpeg": ("imageio-ffmpeg", "conda-forge"), @@ -34,13 +37,16 @@ class Environment(): - """ The current install environment """ - def __init__(self, logger: Optional["Logger"] = None, updater: bool = False) -> None: - """ logger will override built in Output() function if passed in - updater indicates that this is being run from update_deps.py - so certain steps can be skipped/output limited """ + """ The current install environment + + Parameters + ---------- + updater: bool, Optional + ``True`` of the script is being called by Faceswap's internal updater. ``False`` if full + setup is running. Default: ``False`` + """ + def __init__(self, updater: bool = False) -> None: self.conda_required_packages: List[Tuple[str, ...]] = [("tk", )] - self.output: Union["Logger", "Output"] = logger if logger else Output() self.updater = updater # Flag that setup is being run by installer so steps can be skipped self.is_installer: bool = False @@ -50,18 +56,18 @@ def __init__(self, logger: Optional["Logger"] = None, updater: bool = False) -> self.enable_apple_silicon: bool = False self.enable_docker: bool = False self.enable_cuda: bool = False - self.required_packages: List[Tuple[str, Tuple[str, str]]] = [] - self.missing_packages: List[str] = [] - self.conda_missing_packages: List[str] = [] - - self.process_arguments() - self.check_permission() - self.check_system() - self.check_python() - self.output_runtime_info() - self.check_pip() - self.upgrade_pip() - self.set_ld_library_path() + self.required_packages: List[Tuple[str, List[Tuple[str, str]]]] = [] + self.missing_packages: List[Tuple[str, List[Tuple[str, str]]]] = [] + self.conda_missing_packages: List[Tuple[str, ...]] = [] + + self._process_arguments() + self._check_permission() + self._check_system() + self._check_python() + self._output_runtime_info() + self._check_pip() + self._upgrade_pip() + self._set_ld_library_path() self.installed_packages = self.get_installed_packages() self.installed_packages.update(self.get_installed_conda_packages()) @@ -107,13 +113,14 @@ def is_virtualenv(self) -> bool: retval = (os.path.basename(prefix) == "envs") return retval - def process_arguments(self) -> None: + def _process_arguments(self) -> None: """ Process any cli arguments and dummy in cli arguments if calling from updater. """ args = [arg for arg in sys.argv] # pylint:disable=unnecessary-comprehension if self.updater: from lib.utils import get_backend # pylint:disable=import-outside-toplevel args.append(f"--{get_backend()}") + logger.debug(args) for arg in args: if arg == "--installer": self.is_installer = True @@ -144,75 +151,82 @@ def get_required_packages(self) -> None: package = package.strip() if package and (not package.startswith(("#", "-r"))): requirements.append(package) - self.required_packages = [(pkg.name, pkg.specs) + + # Add required installer packages + if self.os_version[0] != "Windows": + for inst in _INSTALLER_REQUIREMENTS: + requirements.insert(0, inst) + + self.required_packages = [(pkg.unsafe_name, pkg.specs) for pkg in parse_requirements(requirements) if pkg.marker is None or pkg.marker.evaluate()] + logger.debug(self.required_packages) - def check_permission(self) -> None: + def _check_permission(self) -> None: """ Check for Admin permissions """ if self.updater: return if self.is_admin: - self.output.info("Running as Root/Admin") + logger.info("Running as Root/Admin") else: - self.output.info("Running without root/admin privileges") + logger.info("Running without root/admin privileges") - def check_system(self) -> None: + def _check_system(self) -> None: """ Check the system """ if not self.updater: - self.output.info("The tool provides tips for installation\n" - "and installs required python packages") - self.output.info(f"Setup in {self.os_version[0]} {self.os_version[1]}") + logger.info("The tool provides tips for installation and installs required python " + "packages") + logger.info("Setup in %s %s", self.os_version[0], self.os_version[1]) if not self.updater and not self.os_version[0] in ["Windows", "Linux", "Darwin"]: - self.output.error(f"Your system {self.os_version[0]} is not supported!") + logger.error("Your system %s is not supported!", self.os_version[0]) sys.exit(1) if self.os_version[0].lower() == "darwin" and platform.machine() == "arm64": self.enable_apple_silicon = True if not self.updater and not self.is_conda: - self.output.error("Setting up Faceswap for Apple Silicon outside of a Conda " - "environment is unsupported") + logger.error("Setting up Faceswap for Apple Silicon outside of a Conda " + "environment is unsupported") sys.exit(1) - def check_python(self) -> None: + def _check_python(self) -> None: """ Check python and virtual environment status """ - self.output.info(f"Installed Python: {self.py_version[0]} {self.py_version[1]}") + logger.info("Installed Python: %s %s", self.py_version[0], self.py_version[1]) if self.updater: return if not ((3, 7) <= sys.version_info < (3, 10) and self.py_version[1] == "64bit"): - self.output.error("Please run this script with Python version 3.7 to 3.9 " - "64bit and try again.") + logger.error("Please run this script with Python version 3.7 to 3.9 64bit and try " + "again.") sys.exit(1) if self.enable_amd and sys.version_info >= (3, 9): - self.output.error("The AMD version of Faceswap cannot be installed on versions of " - "Python higher than 3.8") + logger.error("The AMD version of Faceswap cannot be installed on versions of Python " + "higher than 3.8") sys.exit(1) - def output_runtime_info(self) -> None: + def _output_runtime_info(self) -> None: """ Output run time info """ if self.is_conda: - self.output.info("Running in Conda") + logger.info("Running in Conda") if self.is_virtualenv: - self.output.info("Running in a Virtual Environment") - self.output.info(f"Encoding: {self.encoding}") + logger.info("Running in a Virtual Environment") + logger.info("Encoding: %s", self.encoding) - def check_pip(self) -> None: + def _check_pip(self) -> None: """ Check installed pip version """ if self.updater: return try: import pip # noqa pylint:disable=unused-import,import-outside-toplevel except ImportError: - self.output.error("Import pip failed. Please Install python3-pip and try again") + logger.error("Import pip failed. Please Install python3-pip and try again") sys.exit(1) - def upgrade_pip(self) -> None: + def _upgrade_pip(self) -> None: """ Upgrade pip to latest version """ if not self.is_conda: # Don't do this with Conda, as we must use Conda version of pip - self.output.info("Upgrading pip...") + logger.info("Upgrading pip...") pipexe = [sys.executable, "-m", "pip"] pipexe.extend(["install", "--no-cache-dir", "-qq", "--upgrade"]) if not self.is_admin and not self.is_virtualenv: @@ -221,7 +235,7 @@ def upgrade_pip(self) -> None: run(pipexe, check=True) import pip # pylint:disable=import-outside-toplevel pip_version = pip.__version__ - self.output.info(f"Installed pip: {pip_version}") + logger.info("Installed pip: %s", pip_version) def get_installed_packages(self) -> Dict[str, str]: """ Get currently installed packages """ @@ -234,6 +248,7 @@ def get_installed_packages(self) -> Dict[str, str]: continue item = pkg.split("==") installed_packages[item[0]] = item[1] + logger.debug(installed_packages) return installed_packages def get_installed_conda_packages(self) -> Dict[str, str]: @@ -247,6 +262,7 @@ def get_installed_conda_packages(self) -> Dict[str, str]: for pkg in installed: item = pkg.split(" ") retval[item[0]] = item[1] + logger.debug(retval) return retval def update_tf_dep(self) -> None: @@ -257,7 +273,7 @@ def update_tf_dep(self) -> None: tf_ver = None cudnn_inst = self.cudnn_version.split(".") - for key, val in TENSORFLOW_REQUIREMENTS.items(): + for key, val in _TENSORFLOW_REQUIREMENTS.items(): cuda_req = val[0] cudnn_req = val[1].split(".") if cuda_req == self.cuda_version and (cudnn_req[0] == cudnn_inst[0] and @@ -276,17 +292,18 @@ def update_tf_dep(self) -> None: next(parse_requirements(tf_ver)).specs)) return - self.output.warning( + logger.warning( "The minimum Tensorflow requirement is 2.4 \n" - "Tensorflow currently has no official prebuild for your CUDA, cuDNN " - "combination.\nEither install a combination that Tensorflow supports or " - "build and install your own tensorflow-gpu.\r\n" - f"CUDA Version: {self.cuda_version}\r\n" - f"cuDNN Version: {self.cudnn_version}\r\n" + "Tensorflow currently has no official prebuild for your CUDA, cuDNN combination.\n" + "Either install a combination that Tensorflow supports or build and install your own " + "tensorflow-gpu.\r\n" + "CUDA Version: %s\r\n" + "cuDNN Version: %s\r\n" "Help:\n" "Building Tensorflow: https://www.tensorflow.org/install/install_sources\r\n" "Tensorflow supported versions: " - "https://www.tensorflow.org/install/source#tested_build_configurations") + "https://www.tensorflow.org/install/source#tested_build_configurations", + self.cuda_version, self.cudnn_version) custom_tf = input("Location of custom tensorflow-gpu wheel (leave " "blank to manually install): ") @@ -294,12 +311,15 @@ def update_tf_dep(self) -> None: return custom_tf = os.path.realpath(os.path.expanduser(custom_tf)) + global _INSTALL_FAILED # pylint:disable=global-statement if not os.path.isfile(custom_tf): - self.output.error(f"{custom_tf} not found") + logger.error("%s not found", custom_tf) + _INSTALL_FAILED = True elif os.path.splitext(custom_tf)[1] != ".whl": - self.output.error(f"{custom_tf} is not a valid pip wheel") + logger.error("%s is not a valid pip wheel", custom_tf) + _INSTALL_FAILED = True elif custom_tf: - self.required_packages.append((custom_tf, (custom_tf, ""))) + self.required_packages.append((custom_tf, [(custom_tf, "")])) def set_config(self) -> None: """ Set the backend in the faceswap config file """ @@ -316,9 +336,9 @@ def set_config(self) -> None: config_file = os.path.join(pypath, "config", ".faceswap") with open(config_file, "w", encoding="utf8") as cnf: json.dump(config, cnf) - self.output.info(f"Faceswap config written to: {config_file}") + logger.info("Faceswap config written to: %s", config_file) - def set_ld_library_path(self) -> None: + def _set_ld_library_path(self) -> None: """ Update the LD_LIBRARY_PATH environment variable when activating a conda environment and revert it when deactivating. Linux/conda only @@ -361,158 +381,132 @@ def set_ld_library_path(self) -> None: afile.write("export LD_LIBRARY_PATH=${OLD_LD_LIBRARY_PATH}\n") afile.write("unset OLD_LD_LIBRARY_PATH\n") - self.output.info(f"Cuda search path set to '{conda_libs}'") + logger.info("Cuda search path set to '%s'", conda_libs) -class Output(): - """ Format and display output """ - def __init__(self) -> None: - self.red: str = "\033[31m" - self.green: str = "\033[32m" - self.yellow: str = "\033[33m" - self.default_color: str = "\033[0m" - self.term_support_color: bool = platform.system().lower() in ("linux", "darwin") - - @staticmethod - def __indent_text_block(text: str) -> str: - """ Indent a text block """ - lines = text.splitlines() - if len(lines) > 1: - out = lines[0] + "\r\n" - for i in range(1, len(lines)-1): - out = out + " " + lines[i] + "\r\n" - out = out + " " + lines[-1] - return out - return text - - def info(self, text: str) -> None: - """ Format INFO Text """ - trm = "INFO " - if self.term_support_color: - trm = f"{self.green}INFO {self.default_color} " - print(trm + self.__indent_text_block(text)) - - def warning(self, text: str) -> None: - """ Format WARNING Text """ - trm = "WARNING " - if self.term_support_color: - trm = f"{self.yellow}WARNING{self.default_color} " - print(trm + self.__indent_text_block(text)) - - def error(self, text: str) -> None: - """ Format ERROR Text """ - global INSTALL_FAILED # pylint:disable=global-statement - trm = "ERROR " - if self.term_support_color: - trm = f"{self.red}ERROR {self.default_color} " - print(trm + self.__indent_text_block(text)) - INSTALL_FAILED = True - - -class Checks(): - """ Pre-installation checks """ +class Checks(): # pylint:disable=too-few-public-methods + """ Pre-installation checks + + Parameters + ---------- + environment: :class:`Environment` + Environment class holding information about the running system + """ def __init__(self, environment: Environment) -> None: - self.env: Environment = environment - self.output: Output = Output() - self.tips: Tips = Tips() + self._env: Environment = environment + self._tips: Tips = Tips() # Checks not required for installer - if self.env.is_installer: + if self._env.is_installer: return # Checks not required for Apple Silicon - if self.env.enable_apple_silicon: + if self._env.enable_apple_silicon: return - - # Ask AMD/Docker/Cuda - self.amd_ask_enable() - if not self.env.enable_amd: - self.docker_ask_enable() - self.cuda_ask_enable() - if self.env.os_version[0] != "Linux" and self.env.enable_docker and self.env.enable_cuda: - self.docker_confirm() - if self.env.enable_docker: - self.docker_tips() - self.env.set_config() + self._user_input() + self._check_cuda() + self._env.update_tf_dep() + if self._env.os_version[0] == "Windows": + self._tips.pip() + + def _user_input(self) -> None: + """ Get user input for AMD/Cuda/Docker """ + self._amd_ask_enable() + if not self._env.enable_amd: + self._docker_ask_enable() + self._cuda_ask_enable() + if self._env.os_version[0] != "Linux" and (self._env.enable_docker + and self._env.enable_cuda): + self._docker_confirm() + if self._env.enable_docker: + self._docker_tips() + self._env.set_config() sys.exit(0) - # Check for CUDA and cuDNN - if self.env.enable_cuda and self.env.is_conda: - self.output.info("Skipping Cuda/cuDNN checks for Conda install") - elif self.env.enable_cuda and self.env.os_version[0] in ("Linux", "Windows"): - check = CudaCheck() - if check.cuda_version: - self.env.cuda_version = check.cuda_version - self.output.info("CUDA version: " + self.env.cuda_version) - else: - self.output.error("CUDA not found. Install and try again.\n" - "Recommended version: CUDA 10.1 cuDNN 7.6\n" - "CUDA: https://developer.nvidia.com/cuda-downloads\n" - "cuDNN: https://developer.nvidia.com/rdp/cudnn-download") - return - - if check.cudnn_version: - self.env.cudnn_version = ".".join(check.cudnn_version.split(".")[:2]) - self.output.info(f"cuDNN version: {self.env.cudnn_version}") - else: - self.output.error("cuDNN not found. See " - "https://github.com/deepfakes/faceswap/blob/master/INSTALL.md#" - "cudnn for instructions") - return - elif self.env.enable_cuda and self.env.os_version[0] not in ("Linux", "Windows"): - self.tips.macos() - self.output.warning("Cannot find CUDA on macOS") - self.env.cuda_version = input("Manually specify CUDA version: ") - - self.env.update_tf_dep() - if self.env.os_version[0] == "Windows": - self.tips.pip() - - def amd_ask_enable(self) -> None: + def _amd_ask_enable(self) -> None: """ Enable or disable Plaidml for AMD""" - self.output.info("AMD Support: AMD GPU support is currently limited.\r\n" - "Nvidia Users MUST answer 'no' to this option.") + logger.info("AMD Support: AMD GPU support is currently limited.\r\n" + "Nvidia Users MUST answer 'no' to this option.") i = input("Enable AMD Support? [y/N] ") if i in ("Y", "y"): - self.output.info("AMD Support Enabled") - self.env.enable_amd = True + logger.info("AMD Support Enabled") + self._env.enable_amd = True else: - self.output.info("AMD Support Disabled") - self.env.enable_amd = False + logger.info("AMD Support Disabled") + self._env.enable_amd = False - def docker_ask_enable(self) -> None: + def _docker_ask_enable(self) -> None: """ Enable or disable Docker """ i = input("Enable Docker? [y/N] ") if i in ("Y", "y"): - self.output.info("Docker Enabled") - self.env.enable_docker = True + logger.info("Docker Enabled") + self._env.enable_docker = True else: - self.output.info("Docker Disabled") - self.env.enable_docker = False + logger.info("Docker Disabled") + self._env.enable_docker = False - def docker_confirm(self) -> None: + def _docker_confirm(self) -> None: """ Warn if nvidia-docker on non-Linux system """ - self.output.warning("Nvidia-Docker is only supported on Linux.\r\n" - "Only CPU is supported in Docker for your system") - self.docker_ask_enable() - if self.env.enable_docker: - self.output.warning("CUDA Disabled") - self.env.enable_cuda = False - - def docker_tips(self) -> None: + logger.warning("Nvidia-Docker is only supported on Linux.\r\n" + "Only CPU is supported in Docker for your system") + self._docker_ask_enable() + if self._env.enable_docker: + logger.warning("CUDA Disabled") + self._env.enable_cuda = False + + def _docker_tips(self) -> None: """ Provide tips for Docker use """ - if not self.env.enable_cuda: - self.tips.docker_no_cuda() + if not self._env.enable_cuda: + self._tips.docker_no_cuda() else: - self.tips.docker_cuda() + self._tips.docker_cuda() - def cuda_ask_enable(self) -> None: + def _cuda_ask_enable(self) -> None: """ Enable or disable CUDA """ i = input("Enable CUDA? [Y/n] ") if i in ("", "Y", "y"): - self.output.info("CUDA Enabled") - self.env.enable_cuda = True + logger.info("CUDA Enabled") + self._env.enable_cuda = True else: - self.output.info("CUDA Disabled") - self.env.enable_cuda = False + logger.info("CUDA Disabled") + self._env.enable_cuda = False + + def _check_cuda(self) -> None: + """ Check for Cuda and cuDNN Locations. """ + if not self._env.enable_cuda: + logger.debug("Skipping Cuda checks as not enabled") + return + + if self._env.is_conda: + logger.info("Skipping Cuda/cuDNN checks for Conda install") + return + + if self._env.os_version[0] in ("Linux", "Windows"): + global _INSTALL_FAILED # pylint:disable=global-statement + check = CudaCheck() + if check.cuda_version: + self._env.cuda_version = check.cuda_version + logger.info("CUDA version: %s", self._env.cuda_version) + else: + logger.error("CUDA not found. Install and try again.\n" + "Recommended version: CUDA 10.1 cuDNN 7.6\n" + "CUDA: https://developer.nvidia.com/cuda-downloads\n" + "cuDNN: https://developer.nvidia.com/rdp/cudnn-download") + _INSTALL_FAILED = True + return + + if check.cudnn_version: + self._env.cudnn_version = ".".join(check.cudnn_version.split(".")[:2]) + logger.info("cuDNN version: %s", self._env.cudnn_version) + else: + logger.error("cuDNN not found. See " + "https://github.com/deepfakes/faceswap/blob/master/INSTALL.md#" + "cudnn for instructions") + _INSTALL_FAILED = True + return + + # If we get here we're on MacOS + self._tips.macos() + logger.warning("Cannot find CUDA on macOS") + self._env.cuda_version = input("Manually specify CUDA version: ") class CudaCheck(): # pylint:disable=too-few-public-methods @@ -528,7 +522,8 @@ def __init__(self) -> None: for key in os.environ if key.lower().startswith("cuda_path_v")] self._cudnn_header_files: List[str] = ["cudnn_version.h", "cudnn.h"] - + logger.debug("cuda keys: %s, cudnn header files: %s", + self._cuda_keys, self._cudnn_header_files) if self._os in ("windows", "linux"): self._cuda_check() self._cudnn_check() @@ -560,6 +555,7 @@ def _cuda_check(self) -> None: # Failed to load nvcc, manual check getattr(self, f"_cuda_check_{self._os}")() + logger.debug("Cuda Version: %s, Cuda Path: %s", self.cuda_version, self.cuda_path) def _cuda_check_linux(self) -> None: """ For Linux check the dynamic link loader for libcudart. If not found with ldconfig then @@ -589,6 +585,7 @@ def _cudnn_check(self): """ Check Linux or Windows cuDNN Version from cudnn.h and add to :attr:`cudnn_version`. """ cudnn_checkfiles = getattr(self, f"_get_checkfiles_{self._os}")() cudnn_checkfile = next((hdr for hdr in cudnn_checkfiles if os.path.isfile(hdr)), None) + logger.debug("cudnn checkfiles: %s", cudnn_checkfile) if not cudnn_checkfile: return found = 0 @@ -608,6 +605,7 @@ def _cudnn_check(self): if found != 3: # Full version could not be determined return self.cudnn_version = ".".join([str(major), str(minor), str(patchlevel)]) + logger.debug("cudnn version: %s", self.cudnn_version) def _get_checkfiles_linux(self) -> List[str]: """ Return the the files to check for cuDNN locations for Linux by querying @@ -648,92 +646,153 @@ def _get_checkfiles_windows(self) -> List[str]: return cudnn_checkfiles -class Install(): - """ Install the requirements """ - def __init__(self, environment: Environment): +class Install(): # pylint:disable=too-few-public-methods + """ Handles installation of Faceswap requirements + + Parameters + ---------- + environment: :class:`Environment` + Environment class holding information about the running system + is_gui: bool, Optional + ``True`` if the caller is the Faceswap GUI. Used to prevent output of progress bars + which get scrambled in the GUI + """ + def __init__(self, environment: Environment, is_gui: bool = False) -> None: self._operators = {"==": operator.eq, ">=": operator.ge, "<=": operator.le, ">": operator.gt, "<": operator.lt} - self.output = environment.output - self.env = environment - - if not self.env.is_installer and not self.env.updater: - self.ask_continue() - self.env.get_required_packages() - self.check_missing_dep() - self.check_conda_missing_dep() - if (self.env.updater and - not self.env.missing_packages and not self.env.conda_missing_packages): - self.output.info("All Dependencies are up to date") + self._env = environment + self._is_gui = is_gui + + if not self._env.is_installer and not self._env.updater: + self._ask_continue() + self._env.get_required_packages() + self._check_missing_dep() + self._check_conda_missing_dep() + if (self._env.updater and + not self._env.missing_packages and not self._env.conda_missing_packages): + logger.info("All Dependencies are up to date") return - self.install_missing_dep() - if self.env.updater: + logger.info("Installing Required Python Packages. This may take some time...") + self._install_setup_packages() + self._install_missing_dep() + if self._env.updater: return - self.output.info("All python3 dependencies are met.\r\nYou are good to go.\r\n\r\n" - "Enter: 'python faceswap.py -h' to see the options\r\n" - " 'python faceswap.py gui' to launch the GUI") + logger.info("All python3 dependencies are met.\r\nYou are good to go.\r\n\r\n" + "Enter: 'python faceswap.py -h' to see the options\r\n" + " 'python faceswap.py gui' to launch the GUI") - def ask_continue(self): + @classmethod + def _ask_continue(cls) -> None: """ Ask Continue with Install """ inp = input("Please ensure your System Dependencies are met. Continue? [y/N] ") if inp in ("", "N", "n"): - self.output.error("Please install system dependencies to continue") + logger.error("Please install system dependencies to continue") sys.exit(1) - def check_missing_dep(self): + def _check_missing_dep(self) -> None: """ Check for missing dependencies """ - for key, specs in self.env.required_packages: + for key, specs in self._env.required_packages: - if self.env.is_conda: # Get Conda alias for Key - key = CONDA_MAPPING.get(key, (key, None))[0] + if self._env.is_conda: # Get Conda alias for Key + key = _CONDA_MAPPING.get(key, (key, None))[0] - if key not in self.env.installed_packages: + if key not in self._env.installed_packages: # Add not installed packages to missing packages list - self.env.missing_packages.append((key, specs)) + self._env.missing_packages.append((key, specs)) continue - installed_vers = self.env.installed_packages.get(key, "") + installed_vers = self._env.installed_packages.get(key, "") - if specs and not all(self._operators[spec[0]](installed_vers, spec[1]) + if specs and not all(self._operators[spec[0]]( + [int(s) for s in installed_vers.split(".")], + [int(s) for s in spec[1].split(".")]) for spec in specs): - self.env.missing_packages.append((key, specs)) + self._env.missing_packages.append((key, specs)) + logger.debug(self._env.missing_packages) - def check_conda_missing_dep(self): + def _check_conda_missing_dep(self) -> None: """ Check for conda missing dependencies """ - if not self.env.is_conda: + if not self._env.is_conda: return - for pkg in self.env.conda_required_packages: + installed_conda_packages = self._env.get_installed_conda_packages() + for pkg in self._env.conda_required_packages: key = pkg[0].split("==")[0] - if key not in self.env.installed_packages: - self.env.conda_missing_packages.append(pkg) + if key not in self._env.installed_packages: + self._env.conda_missing_packages.append(pkg) continue if len(pkg[0].split("==")) > 1: - if pkg[0].split("==")[1] != self.env.installed_conda_packages.get(key): - self.env.conda_missing_packages.append(pkg) + if pkg[0].split("==")[1] != installed_conda_packages.get(key): + self._env.conda_missing_packages.append(pkg) continue + logger.debug(self._env.conda_missing_packages) + + @classmethod + def _format_package(cls, package: str, version: List[Tuple[str, str]]) -> str: + """ Format a parsed requirement package and version string to a format that can be used by + the installer. + + Parameters + ---------- + package: str + The package name + version: list + The parsed requirement version strings + + Returns + ------- + str + The formatted full package and version string + """ + return f"{package}{','.join(''.join(spec) for spec in version)}" + + def _install_setup_packages(self) -> None: + """ Install any packages that are required for the setup.py installer to work. This + includes the pexpect package if it is not already installed. - def install_missing_dep(self): + Subprocess is used as we do not currently have pexpect + """ + setup_packages = [(pkg.unsafe_name, pkg.specs) + for pkg in parse_requirements(_INSTALLER_REQUIREMENTS)] + + for pkg in setup_packages: + if pkg not in self._env.missing_packages: + continue + self._env.missing_packages.pop(self._env.missing_packages.index(pkg)) + pkg_str = self._format_package(*pkg) + if self._env.is_conda: + cmd = ["conda", "install", "-y"] + else: + cmd = [sys.executable, "-m", "pip", "install", "--no-cache-dir"] + if self._env.is_admin: + cmd.append("--user") + cmd.append(pkg_str) + + clean_pkg = pkg_str.replace("\"", "") + if self._subproc_installer(cmd, clean_pkg) != 0: + logger.error("Unable to install package: %s. Process aborted", clean_pkg) + sys.exit(1) + + def _install_missing_dep(self) -> None: """ Install missing dependencies """ # Install conda packages first - if self.env.conda_missing_packages: - self.install_conda_packages() - if self.env.missing_packages: - self.install_python_packages() + if self._env.conda_missing_packages: + self._install_conda_packages() + if self._env.missing_packages: + self._install_python_packages() - def install_python_packages(self): + def _install_python_packages(self) -> None: """ Install required pip packages """ - self.output.info("Installing Required Python Packages. This may take some time...") conda_only = False - for pkg, version in self.env.missing_packages: - if self.env.is_conda: - pkg = CONDA_MAPPING.get(pkg, (pkg, None)) - channel = None if len(pkg) != 2 else pkg[1] - pkg = pkg[0] - if version: - pkg = f"{pkg}{','.join(''.join(spec) for spec in version)}" - if self.env.is_conda: + for pkg, version in self._env.missing_packages: + if self._env.is_conda: + mapping = _CONDA_MAPPING.get(pkg, (pkg, "")) + channel = None if mapping[1] == "" else mapping[1] + pkg = mapping[0] + pkg = self._format_package(pkg, version) if version else pkg + if self._env.is_conda: if pkg.startswith("tensorflow-gpu"): # From TF 2.4 onwards, Anaconda Tensorflow becomes a mess. The version of 2.5 # installed by Anaconda is compiled against an incorrect numpy version which @@ -744,32 +803,47 @@ def install_python_packages(self): # TODO Revert to Conda if they get their act together # Rewrite tensorflow requirement to versions from highest available cuda/cudnn - highest_cuda = sorted(TENSORFLOW_REQUIREMENTS.values())[-1] - compat_tf = next(k for k, v in TENSORFLOW_REQUIREMENTS.items() + highest_cuda = sorted(_TENSORFLOW_REQUIREMENTS.values())[-1] + compat_tf = next(k for k, v in _TENSORFLOW_REQUIREMENTS.items() if v == highest_cuda) pkg = f"tensorflow-gpu{compat_tf}" conda_only = True - verbose = pkg.startswith("tensorflow") or self.env.updater - if self.conda_installer(pkg, - verbose=verbose, channel=channel, conda_only=conda_only): + if self._conda_installer(pkg, channel=channel, conda_only=conda_only): continue - self.pip_installer(pkg) + self._pip_installer(pkg) - def install_conda_packages(self): + def _install_conda_packages(self) -> None: """ Install required conda packages """ - self.output.info("Installing Required Conda Packages. This may take some time...") - for pkg in self.env.conda_missing_packages: + logger.info("Installing Required Conda Packages. This may take some time...") + for pkg in self._env.conda_missing_packages: channel = None if len(pkg) != 2 else pkg[1] - self.conda_installer(pkg[0], channel=channel, conda_only=True) + self._conda_installer(pkg[0], channel=channel, conda_only=True) + + def _conda_installer(self, + package: str, + channel: Optional[str] = None, + conda_only: bool = False) -> bool: + """ Install a conda package + + Parameters + ---------- + package: str + The full formatted package, with version, to be installed + channel: str, optional + The Conda channel to install from. Select ``None`` for default channel. + Default: ``None`` + conda_only: bool, optional + ``True`` if the package is only available in Conda. Default: ``False`` - def conda_installer(self, package, channel=None, verbose=False, conda_only=False): - """ Install a conda package """ + Returns + ------- + bool + ``True`` if the package was succesfully installed otherwise ``False`` + """ # Packages with special characters need to be enclosed in double quotes success = True condaexe = ["conda", "install", "-y"] - if not verbose or self.env.updater: - condaexe.append("-q") if channel: condaexe.extend(["-c", channel]) @@ -777,7 +851,7 @@ def conda_installer(self, package, channel=None, verbose=False, conda_only=False # Here we will install the cuda/cudnn toolkits, currently only available from # conda-forge, but fail tensorflow itself so that it can be handled by pip. specs = Requirement.parse(package).specs - for key, val in TENSORFLOW_REQUIREMENTS.items(): + for key, val in _TENSORFLOW_REQUIREMENTS.items(): req_specs = Requirement.parse("foobar" + key).specs if all(item in req_specs for item in specs): cuda, cudnn = val @@ -792,53 +866,142 @@ def conda_installer(self, package, channel=None, verbose=False, conda_only=False condaexe.append(package) clean_pkg = package.replace("\"", "") - self.output.info(f"Installing {clean_pkg}") - shell = self.env.os_version[0] == "Windows" - try: - if verbose: - run(condaexe, check=True, shell=shell) - else: - with open(os.devnull, "w", encoding="utf8") as devnull: - run(condaexe, stdout=devnull, stderr=devnull, check=True, shell=shell) - except CalledProcessError: - if not conda_only: - self.output.info(f"{package} not available in Conda. Installing with pip") - else: - self.output.warning(f"Couldn't install {package} with Conda. " - "Please install this package manually") - success = False + retcode = self._pexpect_installer(condaexe, clean_pkg) + + if retcode != 0 and not conda_only: + logger.info("%s not available in Conda. Installing with pip", package) + elif retcode != 0: + logger.warning("Couldn't install %s with Conda. Please install this package " + "manually", package) + success = retcode == 0 and success return success - def pip_installer(self, package): - """ Install a pip package """ - pipexe = [sys.executable, "-m", "pip"] - # hide info/warning and fix cache hang - pipexe.extend(["install", "--no-cache-dir"]) - if not self.env.updater and not package.startswith("tensorflow"): - pipexe.append("-qq") + def _pip_installer(self, package: str) -> None: + """ Install a pip package + + Parameters + ---------- + package: str + The full formatted package, with version, to be installed + """ + pipexe = [sys.executable, "-m", "pip", "install", "--no-cache-dir"] # install as user to solve perm restriction - if not self.env.is_admin and not self.env.is_virtualenv: + if not self._env.is_admin and not self._env.is_virtualenv: pipexe.append("--user") - msg = f"Installing {package}" - self.output.info(msg) pipexe.append(package) - try: - run(pipexe, check=True) - except CalledProcessError: - self.output.warning(f"Couldn't install {package} with pip. " - "Please install this package manually") + + if self._pexpect_installer(pipexe, package) != 0: + logger.warning("Couldn't install %s with pip. Please install this package manually", + package) + + def _pexpect_installer(self, command: List[str], package: str) -> int: + """ Run an install command using pexpect and log output. + + Pexpect is used so we can get unbuffered output to display updates + + Parameters + ---------- + command: list + The command to run + package: str + The package name that is being installed + + Returns + ------- + int + The return code from the subprocess + """ + import pexpect # pylint:disable=import-outside-toplevel + logger.info("Installing %s", package) + + proc = pexpect.spawn(" ".join(command), + encoding=self._env.encoding, + codec_errors="replace", + timeout=None) + last_line_cr = False + while True: + try: + idx = proc.expect(["\r\n", "\r"]) + line = proc.before.rstrip() + if line and idx == 0: + if last_line_cr: + last_line_cr = False + # Output last line of progress bar and go to next line + if not self._is_gui: + print(line) + logger.verbose(line) # type:ignore + elif line and idx == 1: + last_line_cr = True + logger.debug(line) + if not self._is_gui: + print(line, end="\r") + except pexpect.EOF: + break + proc.close() + returncode = proc.exitstatus + logger.debug("Package: %s, returncode: %s", package, returncode) + return returncode + + def _subproc_installer(self, command: List[str], package: str) -> int: + """ Run an install command using subprocess Popen. + + pexpect uses pty which is not useable in Windows. The pexpect popen_spawn module does not + give easy access to the return code, and also dumps stdout to console so we use subprocess + for Windows. The downside of this is that we cannot do unbuffered reads, so the process can + look like it hangs. + + #TODO Implement real time read functionality for windows + + Parameters + ---------- + command: list + The command to run + package: str + The package name that is being installed + + Returns + ------- + int + The return code from the subprocess + """ + logger.info("Installing %s", package) + shell = self._env.os_version[0] == "Windows" + + with Popen(command, bufsize=0, stdout=PIPE, stderr=STDOUT, shell=shell) as proc: + last_line_cr = False + while True: + if proc.stdout is not None: + line = proc.stdout.readline().decode(self._env.encoding, errors="replace") + if line == "" and proc.poll is not None: + break + + is_cr = line.startswith("\r") + line = line.rstrip() + + if line and not is_cr: + if last_line_cr: + last_line_cr = False + # Go to next line + if not self._is_gui: + print("") + logger.verbose(line) # type:ignore + elif line: + last_line_cr = True + logger.debug(line) + if not self._is_gui: + print(line, end="\r") + returncode = proc.wait() + logger.debug("Package: %s, returncode: %s", package, returncode) + return returncode class Tips(): """ Display installation Tips """ - def __init__(self): - self.output = Output() - - def docker_no_cuda(self): + @classmethod + def docker_no_cuda(cls) -> None: """ Output Tips for Docker without Cuda """ - path = os.path.dirname(os.path.realpath(__file__)) - self.output.info( + logger.info( "1. Install Docker\n" "https://www.docker.com/community-edition\n\n" "2. Build Docker Image For Faceswap\n" @@ -847,7 +1010,7 @@ def docker_no_cuda(self): "# without GUI\n" "docker run -tid -p 8888:8888 \\ \n" "\t--hostname deepfakes-cpu --name deepfakes-cpu \\ \n" - f"\t-v {path}:/srv \\ \n" + "\t-v %s:/srv \\ \n" "\tdeepfakes-cpu\n\n" "# with gui. tools.py gui working.\n" "## enable local access to X11 server\n" @@ -855,7 +1018,7 @@ def docker_no_cuda(self): "## create container\n" "nvidia-docker run -tid -p 8888:8888 \\ \n" "\t--hostname deepfakes-cpu --name deepfakes-cpu \\ \n" - f"\t-v {path}:/srv \\ \n" + "\t-v %s:/srv \\ \n" "\t-v /tmp/.X11-unix:/tmp/.X11-unix \\ \n" "\t-e DISPLAY=unix$DISPLAY \\ \n" "\t-e AUDIO_GID=`getent group audio | cut -d: -f3` \\ \n" @@ -864,14 +1027,14 @@ def docker_no_cuda(self): "\t-e UID=`id -u` \\ \n" "\tdeepfakes-cpu \n\n" "4. Open a new terminal to run faceswap.py in /srv\n" - "docker exec -it deepfakes-cpu bash") - self.output.info("That's all you need to do with a docker. Have fun.") - - def docker_cuda(self): - """ Output Tips for Docker wit Cuda""" + "docker exec -it deepfakes-cpu bash", path, path) + logger.info("That's all you need to do with a docker. Have fun.") + @classmethod + def docker_cuda(cls) -> None: + """ Output Tips for Docker with Cuda""" path = os.path.dirname(os.path.realpath(__file__)) - self.output.info( + logger.info( "1. Install Docker\n" "https://www.docker.com/community-edition\n\n" "2. Install latest CUDA\n" @@ -884,7 +1047,7 @@ def docker_cuda(self): "# without gui \n" "docker run -tid -p 8888:8888 \\ \n" "\t--hostname deepfakes-gpu --name deepfakes-gpu \\ \n" - f"\t-v {path}:/srv \\ \n" + "\t-v %s:/srv \\ \n" "\tdeepfakes-gpu\n\n" "# with gui.\n" "## enable local access to X11 server\n" @@ -894,7 +1057,7 @@ def docker_cuda(self): "## create container\n" "nvidia-docker run -tid -p 8888:8888 \\ \n" "\t--hostname deepfakes-gpu --name deepfakes-gpu \\ \n" - f"\t-v {path}:/srv \\ \n" + "\t-v %s:/srv \\ \n" "\t-v /tmp/.X11-unix:/tmp/.X11-unix \\ \n" "\t-e DISPLAY=unix$DISPLAY \\ \n" "\t-e AUDIO_GID=`getent group audio | cut -d: -f3` \\ \n" @@ -903,11 +1066,13 @@ def docker_cuda(self): "\t-e UID=`id -u` \\ \n" "\tdeepfakes-gpu\n\n" "6. Open a new terminal to interact with the project\n" - "docker exec deepfakes-gpu python /srv/faceswap.py gui\n") + "docker exec deepfakes-gpu python /srv/faceswap.py gui\n", + path, path) - def macos(self): + @classmethod + def macos(cls) -> None: """ Output Tips for macOS""" - self.output.info( + logger.info( "setup.py does not directly support macOS. The following tips should help:\n\n" "1. Install system dependencies:\n" "XCode from the Apple Store\n" @@ -922,17 +1087,21 @@ def macos(self): "CUDA: https://developer.nvidia.com/cuda-downloads" "cuDNN: https://developer.nvidia.com/rdp/cudnn-download\n\n") - def pip(self): + @classmethod + def pip(cls) -> None: """ Pip Tips """ - self.output.info("1. Install PIP requirements\n" - "You may want to execute `chcp 65001` in cmd line\n" - "to fix Unicode issues on Windows when installing dependencies") + logger.info("1. Install PIP requirements\n" + "You may want to execute `chcp 65001` in cmd line\n" + "to fix Unicode issues on Windows when installing dependencies") if __name__ == "__main__": + logfile = os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), "faceswap_setup.log") + log_setup("INFO", logfile, "setup") + logger.debug("Setup called with args: %s", sys.argv) ENV = Environment() Checks(ENV) ENV.set_config() - if INSTALL_FAILED: + if _INSTALL_FAILED: sys.exit(1) Install(ENV) diff --git a/update_deps.py b/update_deps.py index b4a49452ab..8065de564b 100644 --- a/update_deps.py +++ b/update_deps.py @@ -3,30 +3,32 @@ Checks for installed Conda / Pip packages and updates accordingly """ +import logging +import os +import sys -from setup import Environment, Install, Output +from lib.logger import log_setup +from setup import Environment, Install -_LOGGER = None +logger = logging.getLogger(__name__) -def output(msg): - """ Output to print or logger """ - if _LOGGER is not None: - _LOGGER.info(msg) - else: - Output().info(msg) +def main(is_gui=False) -> None: + """ Check for and update dependencies - -def main(logger=None): - """ Check for and update dependencies """ - if logger is not None: - global _LOGGER # pylint:disable=global-statement - _LOGGER = logger - output("Updating dependencies...") - update = Environment(logger=logger, updater=True) - Install(update) - output("Dependencies updated") + Parameters + ---------- + is_gui: bool, optional + ``True`` if being called by the GUI. Prevents the updater from outputting progress bars + which get scrambled in the GUI + """ + logger.info("Updating dependencies...") + update = Environment(updater=True) + Install(update, is_gui=is_gui) + logger.info("Dependencies updated") if __name__ == "__main__": + logfile = os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), "faceswap_update.log") + log_setup("INFO", logfile, "setup") main() From 1c75d012b09dac6c71a31ebfdeaea2d58775092b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 29 Jul 2022 00:18:45 +0100 Subject: [PATCH 674/981] setup.py: bugfix - use correct installer for Windows --- setup.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 8d013afd26..8cc1eb01dd 100755 --- a/setup.py +++ b/setup.py @@ -665,6 +665,10 @@ def __init__(self, environment: Environment, is_gui: bool = False) -> None: "<": operator.lt} self._env = environment self._is_gui = is_gui + if self._env.os_version[0] == "Windows": + self._installer = self._subproc_installer + else: + self._installer = self._pexpect_installer if not self._env.is_installer and not self._env.updater: self._ask_continue() @@ -866,7 +870,7 @@ def _conda_installer(self, condaexe.append(package) clean_pkg = package.replace("\"", "") - retcode = self._pexpect_installer(condaexe, clean_pkg) + retcode = self._installer(condaexe, clean_pkg) if retcode != 0 and not conda_only: logger.info("%s not available in Conda. Installing with pip", package) @@ -890,7 +894,7 @@ def _pip_installer(self, package: str) -> None: pipexe.append("--user") pipexe.append(package) - if self._pexpect_installer(pipexe, package) != 0: + if self._installer(pipexe, package) != 0: logger.warning("Couldn't install %s with pip. Please install this package manually", package) From ed87326d6804c615a1dbac9618db069a47b4ef15 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 29 Jul 2022 09:31:18 +0100 Subject: [PATCH 675/981] bugfix: Fix setup for Windows --- setup.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/setup.py b/setup.py index 8cc1eb01dd..233293e647 100755 --- a/setup.py +++ b/setup.py @@ -97,7 +97,7 @@ def is_conda(self) -> bool: def is_admin(self) -> bool: """ Check whether user is admin """ try: - retval = os.getuid() == 0 + retval = os.getuid() == 0 # type: ignore except AttributeError: retval = ctypes.windll.shell32.IsUserAnAdmin() != 0 # type: ignore return retval @@ -684,9 +684,14 @@ def __init__(self, environment: Environment, is_gui: bool = False) -> None: self._install_missing_dep() if self._env.updater: return - logger.info("All python3 dependencies are met.\r\nYou are good to go.\r\n\r\n" - "Enter: 'python faceswap.py -h' to see the options\r\n" - " 'python faceswap.py gui' to launch the GUI") + if not _INSTALL_FAILED: + logger.info("All python3 dependencies are met.\r\nYou are good to go.\r\n\r\n" + "Enter: 'python faceswap.py -h' to see the options\r\n" + " 'python faceswap.py gui' to launch the GUI") + else: + logger.error("Some packages failed to install. This may be a temporary error which " + "might be fixed by re-running this script. Otherwise please install " + "these packages manually.") @classmethod def _ask_continue(cls) -> None: @@ -897,6 +902,8 @@ def _pip_installer(self, package: str) -> None: if self._installer(pipexe, package) != 0: logger.warning("Couldn't install %s with pip. Please install this package manually", package) + global _INSTALL_FAILED # pylint:disable=global-statement + _INSTALL_FAILED = True def _pexpect_installer(self, command: List[str], package: str) -> int: """ Run an install command using pexpect and log output. @@ -915,7 +922,7 @@ def _pexpect_installer(self, command: List[str], package: str) -> int: int The return code from the subprocess """ - import pexpect # pylint:disable=import-outside-toplevel + import pexpect # pylint:disable=import-outside-toplevel,import-error logger.info("Installing %s", package) proc = pexpect.spawn(" ".join(command), @@ -969,8 +976,7 @@ def _subproc_installer(self, command: List[str], package: str) -> int: The return code from the subprocess """ logger.info("Installing %s", package) - shell = self._env.os_version[0] == "Windows" - + shell = self._env.os_version[0] == "Windows" and command[0] == "conda" with Popen(command, bufsize=0, stdout=PIPE, stderr=STDOUT, shell=shell) as proc: last_line_cr = False while True: From 92912a7061c722a680e2740129eb90f5223f2c7f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 30 Jul 2022 13:45:09 +0100 Subject: [PATCH 676/981] Update setup.py - Realtime output for Windows - color logging for compatible Windows versions --- .pylintrc | 82 +-------------------- lib/logger.py | 22 +++++- setup.cfg | 2 + setup.py | 197 +++++++++++++++++++++++++++++++++++++------------- 4 files changed, 173 insertions(+), 130 deletions(-) diff --git a/.pylintrc b/.pylintrc index 5c52c8bf7d..988c43de78 100644 --- a/.pylintrc +++ b/.pylintrc @@ -60,85 +60,14 @@ confidence= # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use "--disable=all --enable=classes # --disable=W". -disable=print-statement, - parameter-unpacking, - unpacking-in-except, - old-raise-syntax, - backtick, - long-suffix, - old-ne-operator, - old-octal-literal, - import-star-module-level, - non-ascii-bytes-literal, - raw-checker-failed, +disable=raw-checker-failed, bad-inline-option, locally-disabled, file-ignored, suppressed-message, useless-suppression, deprecated-pragma, - use-symbolic-message-instead, - apply-builtin, - basestring-builtin, - buffer-builtin, - cmp-builtin, - coerce-builtin, - execfile-builtin, - file-builtin, - long-builtin, - raw_input-builtin, - reduce-builtin, - standarderror-builtin, - unicode-builtin, - xrange-builtin, - coerce-method, - delslice-method, - getslice-method, - setslice-method, - no-absolute-import, - old-division, - dict-iter-method, - dict-view-method, - next-method-called, - metaclass-assignment, - indexing-exception, - raising-string, - reload-builtin, - oct-method, - hex-method, - nonzero-method, - cmp-method, - input-builtin, - round-builtin, - intern-builtin, - unichr-builtin, - map-builtin-not-iterating, - zip-builtin-not-iterating, - range-builtin-not-iterating, - filter-builtin-not-iterating, - using-cmp-argument, - eq-without-hash, - div-method, - idiv-method, - rdiv-method, - exception-message-attribute, - invalid-str-codec, - sys-max-int, - bad-python3-import, - deprecated-string-function, - deprecated-str-translate-call, - deprecated-itertools-function, - deprecated-types-field, - next-method-defined, - dict-items-not-iterating, - dict-keys-not-iterating, - dict-values-not-iterating, - deprecated-operator-function, - deprecated-urllib-function, - xreadlines-attribute, - deprecated-sys-function, - exception-escape, - comprehension-escape + use-symbolic-message-instead # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option @@ -360,13 +289,6 @@ max-line-length=100 # Maximum number of lines in a module. max-module-lines=1000 -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma, - dict-separator - # Allow the body of a class to be on the same line as the declaration if body # contains single statement. single-line-class-stmt=no diff --git a/lib/logger.py b/lib/logger.py index 255f0131f5..c2d0d76ea6 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -75,7 +75,7 @@ class ColoredFormatter(logging.Formatter): """ def __init__(self, fmt: str, pad_newlines: bool = False, **kwargs) -> None: super().__init__(fmt, **kwargs) - self._use_color = platform.system().lower() in ("linux", "darwin") + self._use_color = self._get_color_compatibility() self._level_colors = dict(CRITICAL="\033[31m", # red ERROR="\033[31m", # red WARNING="\033[33m", # yellow @@ -84,6 +84,26 @@ def __init__(self, fmt: str, pad_newlines: bool = False, **kwargs) -> None: self._default_color = "\033[0m" self._newline_padding = self._get_newline_padding(pad_newlines, fmt) + @classmethod + def _get_color_compatibility(cls) -> bool: + """ Return whether the system supports color ansi codes. Most OSes do other than Windows + below Windows 10 version 1511. + + Returns + ------- + bool + ``True`` if the system supports color ansi codes otherwise ``False`` + """ + if platform.system().lower() != "windows": + return True + try: + win = sys.getwindowsversion() + if win.major >= 10 and win.build >= 10586: + return True + except Exception: # pylint:disable=broad-except + return False + return False + def _get_newline_padding(self, pad_newlines: bool, fmt: str) -> int: """ Parses the format string to obtain padding for newlines if requested diff --git a/setup.cfg b/setup.cfg index 3822ce6271..45baa393c6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,3 +34,5 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-tqdm.*] ignore_missing_imports = True +[mypy-winpty.*] +ignore_missing_imports = True diff --git a/setup.py b/setup.py index 233293e647..88564578ab 100755 --- a/setup.py +++ b/setup.py @@ -11,7 +11,8 @@ import os import re import sys -from subprocess import run, PIPE, Popen, STDOUT +from shutil import which +from subprocess import list2cmdline, PIPE, Popen, run, STDOUT from typing import Dict, List, Optional, Tuple from pkg_resources import parse_requirements, Requirement @@ -25,7 +26,7 @@ _TENSORFLOW_REQUIREMENTS = {">=2.4.0,<2.5.0": ["11.0", "8.0"], ">=2.5.0,<2.9.0": ["11.2", "8.1"]} # Packages that are explicitly required for setup.py -_INSTALLER_REQUIREMENTS = ["pexpect>=4.8.0"] +_INSTALLER_REQUIREMENTS = [("pexpect>=4.8.0", "!Windows"), ("pywinpty==2.0.2", "Windows")] # Mapping of Python packages to their conda names if different from pip or in non-default channel _CONDA_MAPPING: Dict[str, Tuple[str, str]] = { @@ -153,9 +154,9 @@ def get_required_packages(self) -> None: requirements.append(package) # Add required installer packages - if self.os_version[0] != "Windows": - for inst in _INSTALLER_REQUIREMENTS: - requirements.insert(0, inst) + for pkg, plat in _INSTALLER_REQUIREMENTS: + if self.os_version[0] == plat or (plat[0] == "!" and self.os_version[0] != plat[1:]): + requirements.insert(0, pkg) self.required_packages = [(pkg.unsafe_name, pkg.specs) for pkg in parse_requirements(requirements) @@ -666,7 +667,7 @@ def __init__(self, environment: Environment, is_gui: bool = False) -> None: self._env = environment self._is_gui = is_gui if self._env.os_version[0] == "Windows": - self._installer = self._subproc_installer + self._installer = self._pywinpty_installer else: self._installer = self._pexpect_installer @@ -763,8 +764,8 @@ def _install_setup_packages(self) -> None: Subprocess is used as we do not currently have pexpect """ - setup_packages = [(pkg.unsafe_name, pkg.specs) - for pkg in parse_requirements(_INSTALLER_REQUIREMENTS)] + pkgs = [pkg[0] for pkg in _INSTALLER_REQUIREMENTS] + setup_packages = [(pkg.unsafe_name, pkg.specs) for pkg in parse_requirements(pkgs)] for pkg in setup_packages: if pkg not in self._env.missing_packages: @@ -773,6 +774,8 @@ def _install_setup_packages(self) -> None: pkg_str = self._format_package(*pkg) if self._env.is_conda: cmd = ["conda", "install", "-y"] + if any(char in pkg_str for char in (" ", "<", ">", "*", "|")): + pkg_str = f"\"{pkg_str}\"" else: cmd = [sys.executable, "-m", "pip", "install", "--no-cache-dir"] if self._env.is_admin: @@ -818,21 +821,21 @@ def _install_python_packages(self) -> None: pkg = f"tensorflow-gpu{compat_tf}" conda_only = True - if self._conda_installer(pkg, channel=channel, conda_only=conda_only): + if self._from_conda(pkg, channel=channel, conda_only=conda_only): continue - self._pip_installer(pkg) + self._from_pip(pkg) def _install_conda_packages(self) -> None: """ Install required conda packages """ logger.info("Installing Required Conda Packages. This may take some time...") for pkg in self._env.conda_missing_packages: channel = None if len(pkg) != 2 else pkg[1] - self._conda_installer(pkg[0], channel=channel, conda_only=True) + self._from_conda(pkg[0], channel=channel, conda_only=True) - def _conda_installer(self, - package: str, - channel: Optional[str] = None, - conda_only: bool = False) -> bool: + def _from_conda(self, + package: str, + channel: Optional[str] = None, + conda_only: bool = False) -> bool: """ Install a conda package Parameters @@ -885,7 +888,7 @@ def _conda_installer(self, success = retcode == 0 and success return success - def _pip_installer(self, package: str) -> None: + def _from_pip(self, package: str) -> None: """ Install a pip package Parameters @@ -893,7 +896,7 @@ def _pip_installer(self, package: str) -> None: package: str The full formatted package, with version, to be installed """ - pipexe = [sys.executable, "-m", "pip", "install", "--no-cache-dir"] + pipexe = [sys.executable, "-u", "-m", "pip", "install", "--no-cache-dir"] # install as user to solve perm restriction if not self._env.is_admin and not self._env.is_virtualenv: pipexe.append("--user") @@ -922,36 +925,132 @@ def _pexpect_installer(self, command: List[str], package: str) -> int: int The return code from the subprocess """ - import pexpect # pylint:disable=import-outside-toplevel,import-error - logger.info("Installing %s", package) + try: + import pexpect # pylint:disable=import-outside-toplevel,import-error + logger.info("Installing %s", package) + logger.debug("argv: %s", command) + + proc = pexpect.spawn(" ".join(command), + encoding=self._env.encoding, + codec_errors="replace", + timeout=None) + last_line_cr = False + while True: + try: + idx = proc.expect(["\r\n", "\r"]) + line = proc.before.rstrip() + if line and idx == 0: + if last_line_cr: + last_line_cr = False + # Output last line of progress bar and go to next line + if not self._is_gui: + print(line) + logger.verbose(line) # type:ignore + elif line and idx == 1: + last_line_cr = True + logger.debug(line) + if not self._is_gui: + print(line, end="\r") + except pexpect.EOF: + break + proc.close() + returncode = proc.exitstatus + logger.debug("Package: %s, returncode: %s", package, returncode) + return returncode + except Exception as err: # pylint:disable=broad-except + logger.debug("Failed to install with pexpect. Falling back to subprocess. Error: %s", + str(err)) + return self._subproc_installer(command, package) - proc = pexpect.spawn(" ".join(command), - encoding=self._env.encoding, - codec_errors="replace", - timeout=None) - last_line_cr = False - while True: - try: - idx = proc.expect(["\r\n", "\r"]) - line = proc.before.rstrip() - if line and idx == 0: - if last_line_cr: - last_line_cr = False - # Output last line of progress bar and go to next line + def _pywinpty_installer(self, command: List[str], package: str) -> int: + """ Run an install command using pywinpty and log output. + + pywinpty is used so we can get unbuffered output to display updates + + Parameters + ---------- + command: list + The command to run + package: str + The package name that is being installed + + Returns + ------- + int + The return code from the subprocess + """ + try: + import winpty # pylint:disable=import-outside-toplevel,import-error + logger.info("Installing %s", package) + cmd = which(command[0], path=os.environ.get('PATH', os.defpath)) + # For some reason with WinPTY we need to pass in the full command. Probably a bug + cmdline = list2cmdline(command) + logger.debug("argv: %s, cmd: '%s', cmdline: '%s'", command, cmd, cmdline) + + proc = winpty.PTY( + 100, + 24, + backend=winpty.enums.Backend.WinPTY, # ConPTY hangs and has lots of Ansi Escapes + agent_config=winpty.enums.AgentConfig.WINPTY_FLAG_PLAIN_OUTPUT) # Strip all Ansi + + if not proc.spawn(cmd, cmdline=cmdline): + del proc + raise RuntimeError("Failed to spawn winpty") + + pbar = re.compile(r"(?:eta\s[\d\W]+)|(?:\s+\|\s+\d+%)\Z") + lines = [] + out = "" + eof = False + last_line_cr = False + num_bytes = 1024 + while True: + try: + from_pty = proc.read(num_bytes) + except winpty.WinptyError as err: + if any(val in str(err) for val in ["EOF", "pipe has been ended"]): + # Get remaining bytes. On a comms error, the buffer remains unread so keep + # halving buffer amount until down to 1 when we know we have everything + if num_bytes == 1: + eof = True + from_pty = "" + num_bytes //= 2 + else: + raise + out += from_pty + if "\n" in out: + lines.extend(out.split("\n")) + if out.endswith("\n") or eof: # Ends on newline or is EOF + out = "" + else: # roll over semi-consumed line to next read + out = lines[-1] + lines = lines[:-1] + + for line in lines: # Dump the output to log + line = line.rstrip() + is_cr = bool(pbar.search(line)) + if line and not is_cr: + if last_line_cr: + last_line_cr = False + if not self._is_gui: # Go to next line + print("") + logger.verbose(line) # type:ignore + elif line: + last_line_cr = True + logger.debug(line) if not self._is_gui: - print(line) - logger.verbose(line) # type:ignore - elif line and idx == 1: - last_line_cr = True - logger.debug(line) - if not self._is_gui: - print(line, end="\r") - except pexpect.EOF: - break - proc.close() - returncode = proc.exitstatus - logger.debug("Package: %s, returncode: %s", package, returncode) - return returncode + print(line, end="\r") + lines = [] + if eof: + returncode = proc.get_exitstatus() + break + + del proc + logger.debug("Package: %s, returncode: %s", package, returncode) + return returncode + except Exception as err: # pylint:disable=broad-except + logger.debug("Failed to install with pexpect. Falling back to subprocess. Error: %s", + str(err)) + return self._subproc_installer(command, package) def _subproc_installer(self, command: List[str], package: str) -> int: """ Run an install command using subprocess Popen. @@ -961,8 +1060,6 @@ def _subproc_installer(self, command: List[str], package: str) -> int: for Windows. The downside of this is that we cannot do unbuffered reads, so the process can look like it hangs. - #TODO Implement real time read functionality for windows - Parameters ---------- command: list @@ -977,12 +1074,15 @@ def _subproc_installer(self, command: List[str], package: str) -> int: """ logger.info("Installing %s", package) shell = self._env.os_version[0] == "Windows" and command[0] == "conda" + logger.debug("argv: %s", command) + with Popen(command, bufsize=0, stdout=PIPE, stderr=STDOUT, shell=shell) as proc: last_line_cr = False while True: if proc.stdout is not None: line = proc.stdout.readline().decode(self._env.encoding, errors="replace") - if line == "" and proc.poll is not None: + returncode = proc.poll() + if line == "" and returncode is not None: break is_cr = line.startswith("\r") @@ -1000,7 +1100,6 @@ def _subproc_installer(self, command: List[str], package: str) -> int: logger.debug(line) if not self._is_gui: print(line, end="\r") - returncode = proc.wait() logger.debug("Package: %s, returncode: %s", package, returncode) return returncode From eb3a7fcf2cb158b2ad94373182688901f54241f8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 30 Jul 2022 14:10:14 +0100 Subject: [PATCH 677/981] setup.py - Force newlines in Windows for installer --- setup.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 88564578ab..f471b19bc0 100755 --- a/setup.py +++ b/setup.py @@ -1031,14 +1031,17 @@ def _pywinpty_installer(self, command: List[str], package: str) -> int: if line and not is_cr: if last_line_cr: last_line_cr = False - if not self._is_gui: # Go to next line + if not self._is_gui and not self._env.is_installer: + # Go to next line print("") logger.verbose(line) # type:ignore elif line: last_line_cr = True logger.debug(line) if not self._is_gui: - print(line, end="\r") + # NSIS only updates on line endings, so force new line for installer + print(line, + end=None if self._env.is_installer else "\r") lines = [] if eof: returncode = proc.get_exitstatus() From 8da4fac645b5356084c90ee6a888d5431d3f093e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 30 Jul 2022 17:13:30 +0100 Subject: [PATCH 678/981] setup.py - Update console width for WIndows installer --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f471b19bc0..77fb3ffed5 100755 --- a/setup.py +++ b/setup.py @@ -988,7 +988,7 @@ def _pywinpty_installer(self, command: List[str], package: str) -> int: logger.debug("argv: %s, cmd: '%s', cmdline: '%s'", command, cmd, cmdline) proc = winpty.PTY( - 100, + 80 if self._env.is_installer else 100, 24, backend=winpty.enums.Backend.WinPTY, # ConPTY hangs and has lots of Ansi Escapes agent_config=winpty.enums.AgentConfig.WINPTY_FLAG_PLAIN_OUTPUT) # Strip all Ansi From d5df9e45e7e7fee561e958d6dd89f5b043e2c51e Mon Sep 17 00:00:00 2001 From: Chatcharin Sangbutsarakum <67754293+what-in-the-nim@users.noreply.github.com> Date: Tue, 2 Aug 2022 16:29:47 +0700 Subject: [PATCH 679/981] Fix GUI tooltips on configuration setting window in Original/ Trainer/ Color Augmentation section (#1252) * Fix info description of color augmentation group * Cut sentence due to PEP8 --- plugins/train/trainer/original_defaults.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/train/trainer/original_defaults.py b/plugins/train/trainer/original_defaults.py index be760eff44..943cd40b4d 100755 --- a/plugins/train/trainer/original_defaults.py +++ b/plugins/train/trainer/original_defaults.py @@ -88,7 +88,7 @@ color_lightness=dict( default=30, info="Percentage amount to randomly alter the lightness of each training image.\n" - "NB: This is ignored if the 'no-flip' option is enabled", + "NB: This is ignored if the 'no-augment-color' option is enabled", datatype=int, rounding=1, min_max=(0, 75), @@ -96,8 +96,8 @@ color_ab=dict( default=8, info="Percentage amount to randomly alter the 'a' and 'b' colors of the L*a*b* color " - "space of each training image.\nNB: This is ignored if the 'no-flip' option is " - "enabled", + "space of each training image.\nNB: This is ignored if the 'no-augment-color' option" + "is enabled", datatype=int, rounding=1, min_max=(0, 50), From 26e26c628803e592ce876e101a45033c87a5a97b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 2 Aug 2022 17:12:26 +0100 Subject: [PATCH 680/981] Update TF to 2.9 - Update TF to 2.7 to 2.9 - Bump dependencies - Remove decode from pynvml calls - force keras predict functions to non-verbose - update tests - update Tensorboard logging - Update docs --- INSTALL.md | 2 +- lib/cli/launcher.py | 39 +++++++----- lib/gpu_stats/nvidia.py | 4 +- lib/gui/analysis/event_reader.py | 2 +- lib/logger.py | 2 +- lib/model/session.py | 67 ++++++++++++++------- plugins/train/model/_base/settings.py | 8 +-- plugins/train/trainer/_base.py | 22 ++++--- requirements/_requirements_base.txt | 21 +++---- requirements/requirements_amd.txt | 1 + requirements/requirements_apple_silicon.txt | 7 ++- requirements/requirements_cpu.txt | 3 +- requirements/requirements_nvidia.txt | 3 +- scripts/convert.py | 2 +- setup.py | 5 +- tests/lib/model/layers_test.py | 4 +- tests/lib/model/nn_blocks_test.py | 4 +- tests/lib/model/normalization_test.py | 2 +- tests/simple_tests.py | 41 +++++++++++-- tests/startup_test.py | 2 +- 20 files changed, 152 insertions(+), 89 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 2adb73ec15..b9df7e2ece 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -221,7 +221,7 @@ Obtain git for your distribution from the [git website](https://git-scm.com/down The recommended install method is to use a Conda3 Environment as this will handle the installation of Nvidia's CUDA and cuDNN straight into your Conda Environment. This is by far the easiest and most reliable way to setup the project. - MiniConda3 is recommended: [MiniConda3](https://docs.conda.io/en/latest/miniconda.html) -Alternatively you can install Python (>= 3.7-3.9 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Tensorflow yourself, make sure you install the correct Cuda and cuDNN package for the currently installed version of Tensorflow (Current release: Tensorflow 2.8. Release v1.0: Tensorflow 1.15). You can check for the compatible versions here: (https://www.tensorflow.org/install/source#gpu). +Alternatively you can install Python (>= 3.7-3.9 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Tensorflow yourself, make sure you install the correct Cuda and cuDNN package for the currently installed version of Tensorflow (Current release: Tensorflow 2.9. Release v1.0: Tensorflow 1.15). You can check for the compatible versions here: (https://www.tensorflow.org/install/source#gpu). - Python distributions: - apt/yum install python3 (Linux) - [Installer](https://www.python.org/downloads/release/python-368/) (Windows) diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 584e0f395d..1171a43b68 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -6,12 +6,16 @@ import sys from importlib import import_module +from typing import Callable, TYPE_CHECKING from lib.gpu_stats import set_exclude_devices, GPUStats from lib.logger import crash_log, log_setup from lib.utils import (FaceswapError, get_backend, get_tf_version, safe_shutdown, set_backend, set_system_verbosity) +if TYPE_CHECKING: + import argparse + logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -27,10 +31,10 @@ class ScriptExecutor(): # pylint:disable=too-few-public-methods command: str The faceswap command that is being executed """ - def __init__(self, command): + def __init__(self, command: str) -> None: self._command = command.lower() - def _import_script(self): + def _import_script(self) -> Callable: """ Imports the relevant script as indicated by :attr:`_command` from the scripts folder. Returns @@ -47,17 +51,17 @@ def _import_script(self): script = getattr(module, self._command.title()) return script - def _test_for_tf_version(self): + def _test_for_tf_version(self) -> None: """ Check that the required Tensorflow version is installed. Raises ------ FaceswapError - If Tensorflow is not found, or is not between versions 2.4 and 2.8 + If Tensorflow is not found, or is not between versions 2.4 and 2.9 """ amd_ver = 2.2 - min_ver = 2.4 - max_ver = 2.8 + min_ver = 2.7 + max_ver = 2.9 try: # Ensure tensorflow doesn't pin all threads to one core when using Math Kernel Library os.environ["TF_MIN_GPU_MULTIPROCESSOR_COUNT"] = "4" @@ -95,7 +99,7 @@ def _test_for_tf_version(self): logger.debug("Installed Tensorflow Version: %s", tf_ver) @classmethod - def _handle_import_error(cls, message): + def _handle_import_error(cls, message: str) -> None: """ Display the error message to the console and wait for user input to dismiss it, if running GUI under Windows, otherwise use standard error handling. @@ -112,15 +116,15 @@ def _handle_import_error(cls, message): else: raise FaceswapError(message) - def _test_for_gui(self): + def _test_for_gui(self) -> None: """ If running the gui, performs check to ensure necessary prerequisites are present. """ if self._command != "gui": return self._test_tkinter() self._check_display() - @staticmethod - def _test_tkinter(): + @classmethod + def _test_tkinter(cls) -> None: """ If the user is running the GUI, test whether the tkinter app is available on their machine. If not exit gracefully. @@ -147,8 +151,8 @@ def _test_tkinter(): logger.info("Fedora: sudo dnf install python3-tkinter") raise FaceswapError("TkInter not found") from err - @staticmethod - def _check_display(): + @classmethod + def _check_display(cls) -> None: """ Check whether there is a display to output the GUI to. If running on Windows then it is assumed that we are not running in headless mode @@ -164,7 +168,7 @@ def _check_display(): "See https://support.apple.com/en-gb/HT201341") raise FaceswapError("No display detected. GUI mode has been disabled.") - def execute_script(self, arguments): + def execute_script(self, arguments: "argparse.Namespace") -> None: """ Performs final set up and launches the requested :attr:`_command` with the given command line arguments. @@ -205,7 +209,7 @@ def execute_script(self, arguments): finally: safe_shutdown(got_error=not success) - def _configure_backend(self, arguments): + def _configure_backend(self, arguments: "argparse.Namespace") -> None: """ Configure the backend. Exclude any GPUs for use by Faceswap when requested. @@ -245,13 +249,18 @@ def _configure_backend(self, arguments): safe_shutdown(got_error=True) @classmethod - def _setup_amd(cls, arguments): + def _setup_amd(cls, arguments: "argparse.Namespace") -> bool: """ Test for plaidml and perform setup for AMD. Parameters ---------- arguments: :class:`argparse.Namespace` The command line arguments passed to Faceswap. + + Returns + ------- + bool + ``True`` if AMD was set up succesfully otherwise ``False`` """ logger.debug("Setting up for AMD") try: diff --git a/lib/gpu_stats/nvidia.py b/lib/gpu_stats/nvidia.py index cfd5e109d6..64f42f81e1 100644 --- a/lib/gpu_stats/nvidia.py +++ b/lib/gpu_stats/nvidia.py @@ -105,7 +105,7 @@ def _get_driver(self) -> str: The current GPU driver version """ try: - driver = pynvml.nvmlSystemGetDriverVersion().decode("utf-8") + driver = pynvml.nvmlSystemGetDriverVersion() except pynvml.NVMLError as err: self._log("debug", f"Unable to obtain driver. Original error: {str(err)}") driver = "No Nvidia driver found" @@ -120,7 +120,7 @@ def _get_device_names(self) -> List[str]: list The list of connected Nvidia GPU names """ - names = [pynvml.nvmlDeviceGetName(handle).decode("utf-8") + names = [pynvml.nvmlDeviceGetName(handle) for handle in self._handles] self._log("debug", f"GPU Devices: {names}") return names diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index ecbaf52fc4..3fdf596b32 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -734,7 +734,7 @@ def _process_event(cls, event, step): if not loss: # Need to convert a tensor to a float for TF2.8 logged data. This maybe due to change # in logging or may be due to work around put in place in FS training function for the - # following bug in TF 2.8 when writing records: + # following bug in TF 2.8/2.9 when writing records: # https://github.com/keras-team/keras/issues/16173 loss = float(tf.make_ndarray(summary.tensor)) diff --git a/lib/logger.py b/lib/logger.py index c2d0d76ea6..984c6dc06c 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -97,7 +97,7 @@ def _get_color_compatibility(cls) -> bool: if platform.system().lower() != "windows": return True try: - win = sys.getwindowsversion() + win = sys.getwindowsversion() # type:ignore # pylint:disable=no-member if win.major >= 10 and win.build >= 10586: return True except Exception: # pylint:disable=broad-except diff --git a/lib/model/session.py b/lib/model/session.py index ac84048a1e..b13474f5f0 100644 --- a/lib/model/session.py +++ b/lib/model/session.py @@ -2,6 +2,7 @@ """ Settings manager for Keras Backend """ import logging +from typing import Callable, List, Optional, Union import numpy as np import tensorflow as tf @@ -51,19 +52,27 @@ class KSession(): ``None`` to not exclude any GPUs. Default: ``None`` """ - def __init__(self, name, model_path, model_kwargs=None, allow_growth=False, exclude_gpus=None): - logger.trace("Initializing: %s (name: %s, model_path: %s, model_kwargs: %s, " - "allow_growth: %s, exclude_gpus: %s)", self.__class__.__name__, name, - model_path, model_kwargs, allow_growth, exclude_gpus) + def __init__(self, + name: str, + model_path: str, + model_kwargs: Optional[dict] = None, + allow_growth: bool = False, + exclude_gpus: Optional[List[int]] = None) -> None: + logger.trace("Initializing: %s (name: %s, model_path: %s, " # type:ignore + "model_kwargs: %s, allow_growth: %s, exclude_gpus: %s)", + self.__class__.__name__, name, model_path, model_kwargs, allow_growth, + exclude_gpus) self._name = name self._backend = get_backend() - self._set_session(allow_growth, exclude_gpus) + self._set_session(allow_growth, [] if exclude_gpus is None else exclude_gpus) self._model_path = model_path self._model_kwargs = {} if not model_kwargs else model_kwargs - self._model = None - logger.trace("Initialized: %s", self.__class__.__name__,) + self._model: Optional[Model] = None + logger.trace("Initialized: %s", self.__class__.__name__,) # type:ignore - def predict(self, feed, batch_size=None): + def predict(self, + feed: Union[List[np.ndarray], np.ndarray], + batch_size: Optional[int] = None) -> Union[List[np.ndarray], np.ndarray]: """ Get predictions from the model. This method is a wrapper for :func:`keras.predict()` function. For Tensorflow backends @@ -76,12 +85,23 @@ def predict(self, feed, batch_size=None): feed: numpy.ndarray or list The feed to be provided to the model as input. This should be a :class:`numpy.ndarray` for single inputs or a `list` of :class:`numpy.ndarray` objects for multiple inputs. + batchsize: int, optional + The batch size to run prediction at. Default ``None`` + + Returns + ------- + :class:`numpy.ndarray` + The predictions from the model """ + assert self._model is not None if self._backend == "amd" and batch_size is not None: return self._amd_predict_with_optimized_batchsizes(feed, batch_size) - return self._model.predict(feed, batch_size=batch_size) + return self._model.predict(feed, verbose=0, batch_size=batch_size) - def _amd_predict_with_optimized_batchsizes(self, feed, batch_size): + def _amd_predict_with_optimized_batchsizes( + self, + feed: Union[List[np.ndarray], np.ndarray], + batch_size: int) -> Union[List[np.ndarray], np.ndarray]: """ Minimizes the amount of kernels to be compiled when using the ``amd`` backend with varying batch sizes while trying to keep the batchsize as high as possible. @@ -93,6 +113,7 @@ def _amd_predict_with_optimized_batchsizes(self, feed, batch_size): batch_size: int The upper batchsize to use. """ + assert self._model is not None if isinstance(feed, np.ndarray): feed = [feed] items = feed[0].shape[0] @@ -112,7 +133,7 @@ def _amd_predict_with_optimized_batchsizes(self, feed, batch_size): return np.concatenate(results) return [np.concatenate(x) for x in zip(*results)] - def _set_session(self, allow_growth, exclude_gpus): + def _set_session(self, allow_growth: bool, exclude_gpus: list) -> None: """ Sets the backend session options. For AMD backend this does nothing. @@ -124,18 +145,18 @@ def _set_session(self, allow_growth, exclude_gpus): Parameters ---------- - allow_growth: bool, optional + allow_growth: bool Enable the Tensorflow GPU allow_growth configuration option. This option prevents Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation - and slower performance. Default: False - exclude_gpus: list, optional + and slower performance + exclude_gpus: list A list of indices correlating to connected GPUs that Tensorflow should not use. Pass - ``None`` to not exclude any GPUs. Default: ``None`` + ``None`` to not exclude any GPUs """ if self._backend == "amd": return if self._backend == "cpu": - logger.verbose("Hiding GPUs from Tensorflow") + logger.verbose("Hiding GPUs from Tensorflow") # type:ignore tf.config.set_visible_devices([], "GPU") return @@ -150,7 +171,7 @@ def _set_session(self, allow_growth, exclude_gpus): logger.info("Setting allow growth for GPU: %s", gpu) tf.config.experimental.set_memory_growth(gpu, True) - def load_model(self): + def load_model(self) -> None: """ Loads a model. This method is a wrapper for :func:`keras.models.load_model()`. Loads a model and its @@ -161,12 +182,12 @@ def load_model(self): For Tensorflow backends, the `make_predict_function` method is called on the model to make it thread safe. """ - logger.verbose("Initializing plugin model: %s", self._name) + logger.verbose("Initializing plugin model: %s", self._name) # type:ignore self._model = k_load_model(self._model_path, compile=False, **self._model_kwargs) if self._backend != "amd": self._model.make_predict_function() - def define_model(self, function): + def define_model(self, function: Callable) -> None: """ Defines a model from the given function. This method acts as a wrapper for :class:`keras.models.Model()`. @@ -180,7 +201,7 @@ def define_model(self, function): """ self._model = Model(*function()) - def load_model_weights(self): + def load_model_weights(self) -> None: """ Load model weights for a defined model inside the correct session. This method is a wrapper for :class:`keras.load_weights()`. Once a model has been defined @@ -190,12 +211,13 @@ def load_model_weights(self): For Tensorflow backends, the `make_predict_function` method is called on the model to make it thread safe. """ - logger.verbose("Initializing plugin model: %s", self._name) + logger.verbose("Initializing plugin model: %s", self._name) # type:ignore + assert self._model is not None self._model.load_weights(self._model_path) if self._backend != "amd": self._model.make_predict_function() - def append_softmax_activation(self, layer_index=-1): + def append_softmax_activation(self, layer_index: int = -1) -> None: """ Append a softmax activation layer to a model Occasionally a softmax activation layer needs to be added to a model's output. @@ -208,5 +230,6 @@ def append_softmax_activation(self, layer_index=-1): softmax activation layer. Default: `-1` (The final layer of the model) """ logger.debug("Appending Softmax Activation to model: (layer_index: %s)", layer_index) + assert self._model is not None softmax = Activation("softmax", name="softmax")(self._model.layers[layer_index].output) self._model = Model(inputs=self._model.input, outputs=[softmax]) diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 76bfe5b8d7..9ffcf83916 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -21,23 +21,21 @@ import tensorflow as tf from lib.model import losses, optimizers -from lib.utils import get_backend, get_tf_version +from lib.utils import get_backend if get_backend() == "amd": import keras from keras import losses as k_losses from keras import backend as K + import tensorflow.keras.mixed_precision.experimental as mixedprecision # noqa pylint:disable=import-error,no-name-in-module,ungrouped-imports else: # Ignore linting errors from Tensorflow's thoroughly broken import system from tensorflow import keras from tensorflow.keras import losses as k_losses # pylint:disable=import-error from tensorflow.keras import backend as K # pylint:disable=import-error + import tensorflow.keras.mixed_precision as mixedprecision # noqa pylint:disable=import-error,no-name-in-module from lib.model.autoclip import AutoClipper # pylint:disable=ungrouped-imports -if get_tf_version() < 2.4: - import tensorflow.keras.mixed_precision.experimental as mixedprecision # noqa pylint:disable=import-error,no-name-in-module -else: - import tensorflow.keras.mixed_precision as mixedprecision # noqa pylint:disable=import-error,no-name-in-module if sys.version_info < (3, 8): from typing_extensions import Literal diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 6ef6569002..52605fcec8 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -250,15 +250,17 @@ def _log_tensorboard(self, loss): logs = {log[0]: log[1] for log in zip(self._model.state.loss_names, loss)} - self._tensorboard.on_train_batch_end(self._model.iterations, logs=logs) - if get_tf_version() == 2.8: - # Bug in TF 2.8 where batch recording got deleted. + if get_tf_version() > 2.7: + # Bug in TF 2.8/2.9 where batch recording got deleted. # ref: https://github.com/keras-team/keras/issues/16173 - for name, value in logs.items(): - tf.summary.scalar( - "batch_" + name, - value, - step=self._model._model._train_counter) # pylint:disable=protected-access + with tf.summary.record_if(True), self._tensorboard._train_writer.as_default(): # noqa pylint:disable=protected-access,not-context-manager + for name, value in logs.items(): + tf.summary.scalar( + "batch_" + name, + value, + step=self._tensorboard._train_step) # pylint:disable=protected-access + else: + self._tensorboard.on_train_batch_end(self._model.iterations, logs=logs) def _collate_and_store_loss(self, loss): """ Collate the loss into totals for each side. @@ -725,8 +727,8 @@ def _get_predictions(self, feed_a, feed_b): """ logger.debug("Getting Predictions") preds = {} - standard = self._model.model.predict([feed_a, feed_b]) - swapped = self._model.model.predict([feed_b, feed_a]) + standard = self._model.model.predict([feed_a, feed_b], verbose=0) + swapped = self._model.model.predict([feed_b, feed_a], verbose=0) if self._model.config["learn_mask"] and get_backend() == "amd": # Ravel results for plaidml diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index 41d82871fc..3bea3f2f68 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -1,18 +1,15 @@ tqdm>=4.64 -psutil>=5.8.0 -numpy>=1.18.0 -opencv-python>=4.5.5.0 -pillow>=8.3.1 -scikit-learn>=1.0.2 -fastcluster>=1.2.4 +psutil>=5.9.0 +opencv-python>=4.6.0.0 +pillow>=9.2.0 +scikit-learn>=1.1.0 +fastcluster>=1.2.6 matplotlib>=3.5.1 -imageio>=2.9.0 +imageio>=2.19.3 imageio-ffmpeg>=0.4.7 -ffmpy==0.2.3 +ffmpy>=0.3.0 # Exclude badly numbered Python2 version of nvidia-ml-py -#nvidia-ml-py>=11.510,<300 -# Pin nvidida-ml-py to <11.515 until we know if bytes->str is an error or permanent change -nvidia-ml-py<11.515 -tensorflow-probability<0.17 +nvidia-ml-py>=11.515,<300 +tensorflow-probability typing-extensions>=4.0.0 pywin32>=228 ; sys_platform == "win32" diff --git a/requirements/requirements_amd.txt b/requirements/requirements_amd.txt index 18a4dd6245..b235e2cd65 100644 --- a/requirements/requirements_amd.txt +++ b/requirements/requirements_amd.txt @@ -1,5 +1,6 @@ -r _requirements_base.txt # tf2.2 is last version that tensorboard logging works with old Keras +numpy>=1.18.0,<1.20.0 protobuf>= 3.19.0,<3.20.0 # TF has started pulling in incompatible protobuf tensorflow>=2.2.0,<2.3.0 plaidml-keras==0.7.0 diff --git a/requirements/requirements_apple_silicon.txt b/requirements/requirements_apple_silicon.txt index 216b9ce522..64407d9cca 100644 --- a/requirements/requirements_apple_silicon.txt +++ b/requirements/requirements_apple_silicon.txt @@ -1,5 +1,6 @@ protobuf>= 3.19.0,<3.20.0 # TF has started pulling in incompatible protobuf -tensorflow-macos>=2.8.0,<2.9.0 -tensorflow-deps>=2.8.0,<2.9.0 -tensorflow-metal>=0.4.0,<0.5.0 +numpy>=1.22.0 +tensorflow-macos>=2.8.0,<2.10.0 +tensorflow-deps>=2.8.0,<2.10.0 +tensorflow-metal>=0.4.0,<0.6.0 libblas # Conda only diff --git a/requirements/requirements_cpu.txt b/requirements/requirements_cpu.txt index 2f03386aa7..af5ccfc9e3 100644 --- a/requirements/requirements_cpu.txt +++ b/requirements/requirements_cpu.txt @@ -1,2 +1,3 @@ -r _requirements_base.txt -tensorflow>=2.4.0,<2.9.0 +numpy>=1.22.0 +tensorflow>=2.7.0,<2.10.0 diff --git a/requirements/requirements_nvidia.txt b/requirements/requirements_nvidia.txt index fa0364f6d9..f68763ccde 100644 --- a/requirements/requirements_nvidia.txt +++ b/requirements/requirements_nvidia.txt @@ -1,3 +1,4 @@ -r _requirements_base.txt -tensorflow-gpu>=2.4.0,<2.9.0 +numpy>=1.22.0 +tensorflow-gpu>=2.7.0,<2.10.0 pynvx==1.0.0 ; sys_platform == "darwin" diff --git a/scripts/convert.py b/scripts/convert.py index 243a5d1124..51404e9cbc 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -979,7 +979,7 @@ def _predict(self, feed_faces, batch_size=None): feed = [feed_faces] logger.trace("Input shape(s): %s", [item.shape for item in feed]) - predicted = self._model.model.predict(feed, batch_size=batch_size) + predicted = self._model.model.predict(feed, verbose=0, batch_size=batch_size) predicted = predicted if isinstance(predicted, list) else [predicted] if self._model.color_order.lower() == "rgb": diff --git a/setup.py b/setup.py index 77fb3ffed5..f5b0935aee 100755 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Install packages for faceswap.py """ +# pylint: disable=too-many-lines -# >>> Environment import logging import ctypes import json @@ -23,8 +23,7 @@ _INSTALL_FAILED = False # Revisions of tensorflow GPU and cuda/cudnn requirements. These relate specifically to the # Tensorflow builds available from pypi -_TENSORFLOW_REQUIREMENTS = {">=2.4.0,<2.5.0": ["11.0", "8.0"], - ">=2.5.0,<2.9.0": ["11.2", "8.1"]} +_TENSORFLOW_REQUIREMENTS = {">=2.7.0,<2.10.0": ["11.2", "8.1"]} # Packages that are explicitly required for setup.py _INSTALLER_REQUIREMENTS = [("pexpect>=4.8.0", "!Windows"), ("pywinpty==2.0.2", "Windows")] diff --git a/tests/lib/model/layers_test.py b/tests/lib/model/layers_test.py index b6c8fb9286..12caf96bc6 100644 --- a/tests/lib/model/layers_test.py +++ b/tests/lib/model/layers_test.py @@ -71,7 +71,7 @@ def layer_test(layer_cls, kwargs={}, input_shape=None, input_dtype=None, # check with the functional API model = Model(inp, outp) - actual_output = model.predict(input_data) + actual_output = model.predict(input_data, verbose=0) actual_output_shape = actual_output.shape for expected_dim, actual_dim in zip(expected_output_shape, actual_output_shape): @@ -87,7 +87,7 @@ def layer_test(layer_cls, kwargs={}, input_shape=None, input_dtype=None, if model.weights: weights = model.get_weights() recovered_model.set_weights(weights) - _output = recovered_model.predict(input_data) + _output = recovered_model.predict(input_data, verbose=0) assert_allclose(_output, actual_output, rtol=1e-3) # test training mode (e.g. useful when the layer has a diff --git a/tests/lib/model/nn_blocks_test.py b/tests/lib/model/nn_blocks_test.py index d775be8b0e..7244e855ae 100644 --- a/tests/lib/model/nn_blocks_test.py +++ b/tests/lib/model/nn_blocks_test.py @@ -47,7 +47,7 @@ def block_test(layer_func, kwargs={}, input_shape=None): # check with the functional API model = Model(inp, outp) - actual_output = model.predict(input_data) + actual_output = model.predict(input_data, verbose=0) # test serialization, weight setting at model level model_config = model.get_config() @@ -55,7 +55,7 @@ def block_test(layer_func, kwargs={}, input_shape=None): if model.weights: weights = model.get_weights() recovered_model.set_weights(weights) - _output = recovered_model.predict(input_data) + _output = recovered_model.predict(input_data, verbose=0) assert_allclose(_output, actual_output, rtol=1e-3) # for further checks in the caller function diff --git a/tests/lib/model/normalization_test.py b/tests/lib/model/normalization_test.py index 7f3b1fb7f6..6674f6e955 100644 --- a/tests/lib/model/normalization_test.py +++ b/tests/lib/model/normalization_test.py @@ -86,7 +86,7 @@ def test_adain_normalization(center, scale): model = models.Model(inputs, norm(inputs)) data = [10 * np.random.random(shape) for shape in shapes] - actual_output = model.predict(data) + actual_output = model.predict(data, verbose=0) actual_output_shape = actual_output.shape for expected_dim, actual_dim in zip(expected_output_shape, diff --git a/tests/simple_tests.py b/tests/simple_tests.py index 973a59df0d..7581feafcd 100644 --- a/tests/simple_tests.py +++ b/tests/simple_tests.py @@ -25,7 +25,6 @@ "ENDC": "\033[0m" } - def print_colored(text, color="OK", bold=False): """ Print colored text This might not work on windows, @@ -55,7 +54,7 @@ def run_test(name, cmd): """ run a test """ global FAIL_COUNT, TEST_COUNT # pylint:disable=global-statement print_status(f"[?] running {name}") - print(f"Cmd: {''.join(cmd)}") + print(f"Cmd: {' '.join(cmd)}") TEST_COUNT += 1 try: check_call(cmd) @@ -117,6 +116,31 @@ def sort_args(in_path, out_path, sortby="face", groupby="hist", method="rename") return _sort_args.split() +def set_train_config(value): + """ Update the mixed_precision and autoclip values to given value + + Parameters + ---------- + value: bool + The value to set the config parameters to. + """ + old_val, new_val = ("False", "True") if value else ("True", "False") + base_path = os.path.split(os.path.dirname(os.path.abspath(__file__)))[0] + train_ini = os.path.join(base_path, "config", "train.ini") + try: + cmd = ["sed", "-i", f"s/autoclip = {old_val}/autoclip = {new_val}/", train_ini] + check_call(cmd) + cmd = ["sed", + "-i", + f"s/mixed_precision = {old_val}/mixed_precision = {new_val}/", + train_ini] + check_call(cmd) + print_ok(f"Set autoclip and mixed_precision to `{new_val}`") + except CalledProcessError as err: + print_fail(f"[-] Test failed with {err}") + return False + + def main(): """ Main testing script """ vid_src = "https://faceswap.dev/data/test.mp4" @@ -149,6 +173,12 @@ def main(): ) if vid_extract: + run_test( + "Generate configs and test help output", + ( + py_exe, "faceswap.py", "-h" + ) + ) run_test( "Sort faces.", sort_args( @@ -165,9 +195,9 @@ def main(): "-fc", pathjoin(vid_base, "faces_sorted"), ) ) - + set_train_config(True) run_test( - "Train lightweight model for 1 iteration with WTL.", + "Train lightweight model for 1 iteration with WTL, AutoClip, MixedPrecion", train_args("lightweight", pathjoin(vid_base, "model"), pathjoin(vid_base, "faces"), @@ -175,8 +205,9 @@ def main(): batchsize=_TRAIN_ARGS[1], extra_args="-wl")) + set_train_config(False) was_trained = run_test( - "Train lightweight model for 1 iterations WITHOUT WTL.", + "Train lightweight model for 1 iterations WITHOUT WTL, AutoClip, MixedPrecion", train_args("lightweight", pathjoin(vid_base, "model"), pathjoin(vid_base, "faces"), diff --git a/tests/startup_test.py b/tests/startup_test.py index d126358fae..85a92a504d 100644 --- a/tests/startup_test.py +++ b/tests/startup_test.py @@ -31,5 +31,5 @@ def test_backend(dummy): # pylint:disable=unused-argument def test_keras(dummy): # pylint:disable=unused-argument """ Sanity check to ensure that tensorflow keras is being used for CPU and standard keras for AMD. """ - assert ((_BACKEND == "cpu" and keras.__version__ in ("2.4.0", "2.6.0", "2.7.0", "2.8.0")) or + assert ((_BACKEND == "cpu" and keras.__version__ in ("2.7.0", "2.8.0", "2.9.0")) or (_BACKEND == "amd" and keras.__version__ == "2.2.4")) From 92f142b8e73db5b035e473d3a6252df2d4db5c89 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 2 Aug 2022 17:25:58 +0100 Subject: [PATCH 681/981] Lower numpy + scikit-image pins for python 3.7 --- requirements/_requirements_base.txt | 3 ++- requirements/requirements_apple_silicon.txt | 3 ++- requirements/requirements_nvidia.txt | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index 3bea3f2f68..2bf9866a0f 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -2,7 +2,8 @@ tqdm>=4.64 psutil>=5.9.0 opencv-python>=4.6.0.0 pillow>=9.2.0 -scikit-learn>=1.1.0 +scikit-learn==1.0.2; python_version < '3.8' +scikit-learn>=1.1.0; python_version > '3.7' fastcluster>=1.2.6 matplotlib>=3.5.1 imageio>=2.19.3 diff --git a/requirements/requirements_apple_silicon.txt b/requirements/requirements_apple_silicon.txt index 64407d9cca..28a2821bcc 100644 --- a/requirements/requirements_apple_silicon.txt +++ b/requirements/requirements_apple_silicon.txt @@ -1,5 +1,6 @@ protobuf>= 3.19.0,<3.20.0 # TF has started pulling in incompatible protobuf -numpy>=1.22.0 +numpy>=1.21.0; python_version < '3.8' +numpy>=1.22.0; python_version > '3.7' tensorflow-macos>=2.8.0,<2.10.0 tensorflow-deps>=2.8.0,<2.10.0 tensorflow-metal>=0.4.0,<0.6.0 diff --git a/requirements/requirements_nvidia.txt b/requirements/requirements_nvidia.txt index f68763ccde..07733bf734 100644 --- a/requirements/requirements_nvidia.txt +++ b/requirements/requirements_nvidia.txt @@ -1,4 +1,5 @@ -r _requirements_base.txt -numpy>=1.22.0 +numpy>=1.21.0; python_version < '3.8' +numpy>=1.22.0; python_version > '3.7' tensorflow-gpu>=2.7.0,<2.10.0 pynvx==1.0.0 ; sys_platform == "darwin" From 3b95c3c9c276782e13243d0e657824430749c923 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 2 Aug 2022 17:33:43 +0100 Subject: [PATCH 682/981] Update python 3.7 pins --- requirements/_requirements_base.txt | 2 +- requirements/requirements_apple_silicon.txt | 2 +- requirements/requirements_cpu.txt | 3 ++- requirements/requirements_nvidia.txt | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index 2bf9866a0f..431d29c1bb 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -3,7 +3,7 @@ psutil>=5.9.0 opencv-python>=4.6.0.0 pillow>=9.2.0 scikit-learn==1.0.2; python_version < '3.8' -scikit-learn>=1.1.0; python_version > '3.7' +scikit-learn>=1.1.0; python_version >= '3.8' fastcluster>=1.2.6 matplotlib>=3.5.1 imageio>=2.19.3 diff --git a/requirements/requirements_apple_silicon.txt b/requirements/requirements_apple_silicon.txt index 28a2821bcc..f0318b226f 100644 --- a/requirements/requirements_apple_silicon.txt +++ b/requirements/requirements_apple_silicon.txt @@ -1,6 +1,6 @@ protobuf>= 3.19.0,<3.20.0 # TF has started pulling in incompatible protobuf numpy>=1.21.0; python_version < '3.8' -numpy>=1.22.0; python_version > '3.7' +numpy>=1.22.0; python_version >= '3.8' tensorflow-macos>=2.8.0,<2.10.0 tensorflow-deps>=2.8.0,<2.10.0 tensorflow-metal>=0.4.0,<0.6.0 diff --git a/requirements/requirements_cpu.txt b/requirements/requirements_cpu.txt index af5ccfc9e3..d37f2a03e1 100644 --- a/requirements/requirements_cpu.txt +++ b/requirements/requirements_cpu.txt @@ -1,3 +1,4 @@ -r _requirements_base.txt -numpy>=1.22.0 +numpy>=1.21.0; python_version < '3.8' +numpy>=1.22.0; python_version >= '3.8' tensorflow>=2.7.0,<2.10.0 diff --git a/requirements/requirements_nvidia.txt b/requirements/requirements_nvidia.txt index 07733bf734..f70bfd0e8d 100644 --- a/requirements/requirements_nvidia.txt +++ b/requirements/requirements_nvidia.txt @@ -1,5 +1,5 @@ -r _requirements_base.txt numpy>=1.21.0; python_version < '3.8' -numpy>=1.22.0; python_version > '3.7' +numpy>=1.22.0; python_version >= '3.8' tensorflow-gpu>=2.7.0,<2.10.0 pynvx==1.0.0 ; sys_platform == "darwin" From c79ed7628ae4dd5dc4d0e3098b7f74bb16b270a9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 2 Aug 2022 18:33:45 +0100 Subject: [PATCH 683/981] re-pin Tensorflow Probability --- requirements/_requirements_base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index 431d29c1bb..860803167d 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -11,6 +11,6 @@ imageio-ffmpeg>=0.4.7 ffmpy>=0.3.0 # Exclude badly numbered Python2 version of nvidia-ml-py nvidia-ml-py>=11.515,<300 -tensorflow-probability +tensorflow-probability<0.17 typing-extensions>=4.0.0 pywin32>=228 ; sys_platform == "win32" From 997db773c39a0f1613b198044d984a87051ec256 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 3 Aug 2022 00:37:19 +0100 Subject: [PATCH 684/981] pin max numpy version to 1.22.x --- requirements/requirements_apple_silicon.txt | 3 +-- requirements/requirements_cpu.txt | 3 +-- requirements/requirements_nvidia.txt | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/requirements/requirements_apple_silicon.txt b/requirements/requirements_apple_silicon.txt index f0318b226f..306afbb8ee 100644 --- a/requirements/requirements_apple_silicon.txt +++ b/requirements/requirements_apple_silicon.txt @@ -1,6 +1,5 @@ protobuf>= 3.19.0,<3.20.0 # TF has started pulling in incompatible protobuf -numpy>=1.21.0; python_version < '3.8' -numpy>=1.22.0; python_version >= '3.8' +numpy>=1.21.0,<1.23.0 tensorflow-macos>=2.8.0,<2.10.0 tensorflow-deps>=2.8.0,<2.10.0 tensorflow-metal>=0.4.0,<0.6.0 diff --git a/requirements/requirements_cpu.txt b/requirements/requirements_cpu.txt index d37f2a03e1..16ca7c2492 100644 --- a/requirements/requirements_cpu.txt +++ b/requirements/requirements_cpu.txt @@ -1,4 +1,3 @@ -r _requirements_base.txt -numpy>=1.21.0; python_version < '3.8' -numpy>=1.22.0; python_version >= '3.8' +numpy>=1.21.0,<1.23.0 tensorflow>=2.7.0,<2.10.0 diff --git a/requirements/requirements_nvidia.txt b/requirements/requirements_nvidia.txt index f70bfd0e8d..f7581e55b0 100644 --- a/requirements/requirements_nvidia.txt +++ b/requirements/requirements_nvidia.txt @@ -1,5 +1,4 @@ -r _requirements_base.txt -numpy>=1.21.0; python_version < '3.8' -numpy>=1.22.0; python_version >= '3.8' +numpy>=1.21.0,<1.23.0 tensorflow-gpu>=2.7.0,<2.10.0 pynvx==1.0.0 ; sys_platform == "darwin" From 6cd30126fd3abf01d49cea7575d9d8f4f53169ac Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 3 Aug 2022 00:57:47 +0100 Subject: [PATCH 685/981] Revert "pin max numpy version to 1.22.x" This reverts commit 997db773c39a0f1613b198044d984a87051ec256. --- requirements/requirements_apple_silicon.txt | 3 ++- requirements/requirements_cpu.txt | 3 ++- requirements/requirements_nvidia.txt | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/requirements/requirements_apple_silicon.txt b/requirements/requirements_apple_silicon.txt index 306afbb8ee..f0318b226f 100644 --- a/requirements/requirements_apple_silicon.txt +++ b/requirements/requirements_apple_silicon.txt @@ -1,5 +1,6 @@ protobuf>= 3.19.0,<3.20.0 # TF has started pulling in incompatible protobuf -numpy>=1.21.0,<1.23.0 +numpy>=1.21.0; python_version < '3.8' +numpy>=1.22.0; python_version >= '3.8' tensorflow-macos>=2.8.0,<2.10.0 tensorflow-deps>=2.8.0,<2.10.0 tensorflow-metal>=0.4.0,<0.6.0 diff --git a/requirements/requirements_cpu.txt b/requirements/requirements_cpu.txt index 16ca7c2492..d37f2a03e1 100644 --- a/requirements/requirements_cpu.txt +++ b/requirements/requirements_cpu.txt @@ -1,3 +1,4 @@ -r _requirements_base.txt -numpy>=1.21.0,<1.23.0 +numpy>=1.21.0; python_version < '3.8' +numpy>=1.22.0; python_version >= '3.8' tensorflow>=2.7.0,<2.10.0 diff --git a/requirements/requirements_nvidia.txt b/requirements/requirements_nvidia.txt index f7581e55b0..f70bfd0e8d 100644 --- a/requirements/requirements_nvidia.txt +++ b/requirements/requirements_nvidia.txt @@ -1,4 +1,5 @@ -r _requirements_base.txt -numpy>=1.21.0,<1.23.0 +numpy>=1.21.0; python_version < '3.8' +numpy>=1.22.0; python_version >= '3.8' tensorflow-gpu>=2.7.0,<2.10.0 pynvx==1.0.0 ; sys_platform == "darwin" From 629c02a61e1ad5f769f8f7388a091d5ce9aa8160 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 5 Aug 2022 14:09:05 +0100 Subject: [PATCH 686/981] Add "custom" mask --- lib/cli/args.py | 10 +- locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 42406 -> 43200 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 239 +++++---- locales/es/LC_MESSAGES/tools.mask.cli.mo | Bin 7933 -> 8462 bytes locales/es/LC_MESSAGES/tools.mask.cli.po | 55 +- locales/lib.cli.args.pot | 625 ++++++++++++++++------- locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 54962 -> 56046 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 232 +++++---- locales/tools.mask.cli.pot | 119 +++-- plugins/extract/mask/custom.py | 42 ++ plugins/extract/mask/custom_defaults.py | 79 +++ plugins/plugin_loader.py | 9 +- plugins/train/_config.py | 6 +- tools/mask/cli.py | 9 +- 14 files changed, 966 insertions(+), 459 deletions(-) create mode 100644 plugins/extract/mask/custom.py create mode 100644 plugins/extract/mask/custom_defaults.py diff --git a/lib/cli/args.py b/lib/cli/args.py index e5f5302009..8c0a501729 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -419,6 +419,10 @@ def get_optional_arguments() -> List[Dict[str, Any]]: "\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|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 so will not use any additional VRAM." "\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." @@ -698,13 +702,15 @@ def get_optional_arguments() -> List[Dict[str, Any]]: help=_("R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool." "\nL|none: Don't use a mask." - "\nL|bisenet-fp-face: Relatively lightweight NN based mask that provides more " + "\nL|bisenet-fp_face: Relatively lightweight NN based mask that provides more " "refined control over the area to be masked (configurable in mask settings). " "Use this version of bisenet-fp if your model is trained with 'face' or " "'legacy' centering." - "\nL|bisenet-fp-head: Relatively lightweight NN based mask that provides more " + "\nL|bisenet-fp_head: Relatively lightweight NN based mask that provides more " "refined control over the area to be masked (configurable in mask settings). " "Use this version of bisenet-fp if your model is trained with 'head' centering." + "\nL|custom_face: Custom user created, face centered mask." + "\nL|custom_head: Custom user created, head centered mask." "\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." diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo index 78be3fdb0134e33cfa68ef19fe8f01f6a7b97117..7d2e75ab8fcc0841d32e254c81d74e7d5c557c6f 100644 GIT binary patch delta 2890 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 rIyAbE5#akFV^uKPbx2F|pXl Configure Extract 'Plugins':\n" @@ -118,7 +119,7 @@ msgstr "" "detectar más caras y tiene menos falsos positivos que otros detectores " "basados en GPU, pero uso muchos más recursos." -#: lib/cli/args.py:382 +#: lib/cli/args.py:403 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " @@ -130,7 +131,7 @@ msgstr "" "pero es menos preciso. Elegir este si necesita rapidez y no usar la GPU.\n" "L|fan: El mejor alineador. Rápido en la GPU, y lento en la CPU." -#: lib/cli/args.py:394 +#: lib/cli/args.py:415 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -139,6 +140,10 @@ msgid "" "L|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).\n" +"L|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 so will not use any additional VRAM.\n" "L|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.\n" @@ -167,6 +172,11 @@ msgstr "" "L|bisenet-fp: Máscara relativamente ligera basada en NN que proporciona un " "control más refinado sobre el área a enmascarar, incluido el enmascaramiento " "completo de la cabeza (configurable en la configuración de la máscara).\n" +"L|custom: Una máscara ficticia que llena el área de la máscara con 1 o 0 " +"(configurable en la configuración). Esto solo es necesario si tiene la " +"intención de editar manualmente las máscaras personalizadas usted mismo en " +"la herramienta manual. Esta máscara no usa la GPU, por lo que no usará VRAM " +"adicional.\n" "L|vgg-clear: Máscara diseñada para proporcionar una segmentación inteligente " "de rostros principalmente frontales y libres de obstrucciones. Los rostros " "de perfil y las obstrucciones pueden dar lugar a un rendimiento inferior.\n" @@ -191,7 +201,7 @@ msgstr "" "referencia y la máscara se extiende hacia arriba en la frente.\n" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -#: lib/cli/args.py:429 +#: lib/cli/args.py:454 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -214,7 +224,7 @@ msgstr "" "L|hist: Iguala los histogramas de los canales RGB.\n" "L|mean: Normalizar los colores de la cara a la media." -#: lib/cli/args.py:447 +#: lib/cli/args.py:472 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -231,7 +241,7 @@ msgstr "" "más veces se vuelva a introducir la cara en el alineador, menos " "microfluctuaciones se producirán, pero la extracción será más larga." -#: lib/cli/args.py:459 +#: lib/cli/args.py:484 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -243,13 +253,13 @@ msgstr "" "un solo número para usar incrementos de ese tamaño hasta 360, o pase una " "lista de números para enumerar exactamente qué ángulos comprobar." -#: lib/cli/args.py:471 lib/cli/args.py:481 lib/cli/args.py:494 -#: lib/cli/args.py:508 lib/cli/args.py:749 lib/cli/args.py:763 -#: lib/cli/args.py:776 lib/cli/args.py:790 +#: lib/cli/args.py:496 lib/cli/args.py:506 lib/cli/args.py:519 +#: lib/cli/args.py:533 lib/cli/args.py:776 lib/cli/args.py:790 +#: lib/cli/args.py:803 lib/cli/args.py:817 msgid "Face Processing" msgstr "Proceso de Caras" -#: lib/cli/args.py:472 +#: lib/cli/args.py:497 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -258,7 +268,7 @@ msgstr "" "a lo largo de la diagonal del cuadro delimitador. Establecer a 0 para " "desactivar" -#: lib/cli/args.py:482 lib/cli/args.py:764 +#: lib/cli/args.py:507 lib/cli/args.py:791 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -272,7 +282,7 @@ msgstr "" "uso del filtro de caras disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args.py:495 lib/cli/args.py:777 +#: lib/cli/args.py:520 lib/cli/args.py:804 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -286,7 +296,7 @@ msgstr "" "del filtro facial disminuirá significativamente la velocidad de extracción y " "no se puede garantizar su precisión." -#: lib/cli/args.py:509 lib/cli/args.py:791 +#: lib/cli/args.py:534 lib/cli/args.py:818 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -298,12 +308,12 @@ msgstr "" "NB: El uso del filtro facial disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args.py:520 lib/cli/args.py:532 lib/cli/args.py:544 -#: lib/cli/args.py:556 +#: lib/cli/args.py:545 lib/cli/args.py:557 lib/cli/args.py:569 +#: lib/cli/args.py:581 msgid "output" msgstr "salida" -#: lib/cli/args.py:521 +#: lib/cli/args.py:546 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -313,7 +323,7 @@ msgstr "" "pretende entrenar admite el tamaño deseado. Esto sólo tendrá que ser " "cambiado para los modelos de alta resolución." -#: lib/cli/args.py:533 +#: lib/cli/args.py:558 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -323,7 +333,7 @@ msgstr "" "extraer las caras. Por ejemplo, un valor de 1 extraerá las caras de cada " "fotograma, un valor de 10 extraerá las caras de cada 10 fotogramas." -#: lib/cli/args.py:545 +#: lib/cli/args.py:570 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -339,18 +349,18 @@ msgstr "" "ADVERTENCIA: No interrumpa el script al escribir el archivo porque podría " "corromperse. Poner a 0 para desactivar" -#: lib/cli/args.py:557 +#: lib/cli/args.py:582 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" "Dibujar puntos de referencia en las caras de salida para fines de depuración." -#: lib/cli/args.py:563 lib/cli/args.py:572 lib/cli/args.py:580 -#: lib/cli/args.py:587 lib/cli/args.py:803 lib/cli/args.py:814 -#: lib/cli/args.py:822 lib/cli/args.py:841 lib/cli/args.py:847 +#: lib/cli/args.py:588 lib/cli/args.py:597 lib/cli/args.py:605 +#: lib/cli/args.py:612 lib/cli/args.py:830 lib/cli/args.py:841 +#: lib/cli/args.py:849 lib/cli/args.py:868 lib/cli/args.py:874 msgid "settings" msgstr "ajustes" -#: lib/cli/args.py:564 +#: lib/cli/args.py:589 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " @@ -360,7 +370,7 @@ msgstr "" "extracción por separado (una tras otra) en lugar de hacerlo todo al mismo " "tiempo. Útil si la VRAM es escasa." -#: lib/cli/args.py:573 +#: lib/cli/args.py:598 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -368,29 +378,29 @@ msgstr "" "Omite los fotogramas que ya han sido extraídos y que existen en el archivo " "de alineaciones" -#: lib/cli/args.py:581 +#: lib/cli/args.py:606 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" "Omitir los fotogramas que ya tienen caras detectadas en el archivo de " "alineaciones" -#: lib/cli/args.py:588 +#: lib/cli/args.py:613 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "No guardar las caras detectadas en el disco. Crear sólo un archivo de " "alineaciones" -#: lib/cli/args.py:610 +#: lib/cli/args.py:635 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" msgstr "" "Cambia las caras originales de un vídeo/imágenes de origen por las caras " "finales.\n" -"Los plugins de conversión pueden ser configurados en el menú \"Configuración" -"\"" +"Los plugins de conversión pueden ser configurados en el menú " +"\"Configuración\"" -#: lib/cli/args.py:631 +#: lib/cli/args.py:656 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -400,7 +410,7 @@ msgstr "" "original del que se extrajeron los fotogramas de origen (para extraer los " "fps y el audio)." -#: lib/cli/args.py:640 +#: lib/cli/args.py:665 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -408,7 +418,7 @@ msgstr "" "Directorio del modelo. El directorio que contiene el modelo entrenado que " "desea utilizar para la conversión." -#: lib/cli/args.py:650 +#: lib/cli/args.py:675 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -448,19 +458,21 @@ msgstr "" "colores. Generalmente no da resultados muy satisfactorios.\n" "L|none: No realice el ajuste de color." -#: lib/cli/args.py:677 +#: lib/cli/args.py:702 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" "L|none: Don't use a mask.\n" -"L|bisenet-fp-face: Relatively lightweight NN based mask that provides more " +"L|bisenet-fp_face: Relatively lightweight NN based mask that provides more " "refined control over the area to be masked (configurable in mask settings). " "Use this version of bisenet-fp if your model is trained with 'face' or " "'legacy' centering.\n" -"L|bisenet-fp-head: Relatively lightweight NN based mask that provides more " +"L|bisenet-fp_head: Relatively lightweight NN based mask that provides more " "refined control over the area to be masked (configurable in mask settings). " "Use this version of bisenet-fp if your model is trained with 'head' " "centering.\n" +"L|custom_face: Custom user created, face centered mask.\n" +"L|custom_head: Custom user created, head centered mask.\n" "L|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.\n" @@ -494,6 +506,10 @@ msgstr "" "un control más refinado sobre el área a enmascarar (configurable en la " "configuración de la máscara). Utilice esta versión de bisenet-fp si su " "modelo está entrenado con centrado de 'cabeza'.\n" +"L|custom_face: Máscara personalizada creada por el usuario y centrada en el " +"rostro..\n" +"L|custom_head: Máscara personalizada centrada en la cabeza creada por el " +"usuario.\n" "L|components: Máscara diseñada para proporcionar una segmentación facial " "basada en el posicionamiento de las ubicaciones de los puntos de referencia. " "Se construye un casco convexo alrededor del exterior de los puntos de " @@ -518,7 +534,7 @@ msgstr "" "L|predicted: Si la opción 'Learn Mask' se habilitó durante el entrenamiento, " "esto usará la máscara que fue creada por el modelo entrenado." -#: lib/cli/args.py:713 +#: lib/cli/args.py:740 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -544,20 +560,21 @@ msgstr "" "L|pillow: [images] Más lento que opencv, pero tiene más opciones y soporta " "más formatos." -#: lib/cli/args.py:732 lib/cli/args.py:739 lib/cli/args.py:833 +#: lib/cli/args.py:759 lib/cli/args.py:766 lib/cli/args.py:860 msgid "Frame Processing" msgstr "Proceso de fotogramas" -#: lib/cli/args.py:733 +#: lib/cli/args.py:760 +#, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " "at source dimensions. 50%% at half size 200%% at double size" msgstr "" "Escala los fotogramas finales de salida en esta cantidad. 100%% dará salida " -"a los fotogramas a las dimensiones de origen. 50%% a la mitad de tamaño. 200%" -"% al doble de tamaño" +"a los fotogramas a las dimensiones de origen. 50%% a la mitad de tamaño. " +"200%% al doble de tamaño" -#: lib/cli/args.py:740 +#: lib/cli/args.py:767 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -571,7 +588,7 @@ msgstr "" "imágenes, ¡los nombres de los archivos deben terminar con el número de " "fotograma!" -#: lib/cli/args.py:750 +#: lib/cli/args.py:777 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -587,7 +604,7 @@ msgstr "" "especificada. Si se deja en blanco, se convertirán todas las caras que " "existan en el archivo de alineaciones." -#: lib/cli/args.py:804 +#: lib/cli/args.py:831 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -604,7 +621,7 @@ msgstr "" "procesos que los disponibles en su sistema. Si 'singleprocess' está " "habilitado, este ajuste será ignorado." -#: lib/cli/args.py:815 +#: lib/cli/args.py:842 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -612,7 +629,7 @@ msgstr "" "[LEGACY] Sólo es necesario seleccionar esta opción si se está cargando un " "modelo heredado si hay varios modelos en la carpeta de modelos" -#: lib/cli/args.py:823 +#: lib/cli/args.py:850 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -627,7 +644,7 @@ msgstr "" "de baja calidad. Si se encuentra un archivo de alineaciones, esta opción " "será ignorada." -#: lib/cli/args.py:834 +#: lib/cli/args.py:861 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -635,16 +652,16 @@ msgstr "" "Cuando se usa con --frame-ranges, la salida incluye los fotogramas no " "procesados en vez de descartarlos." -#: lib/cli/args.py:842 +#: lib/cli/args.py:869 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Intercambiar el modelo. En vez de convertir de A a B, convierte de B a A" -#: lib/cli/args.py:848 +#: lib/cli/args.py:875 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Desactiva el multiproceso. Es más lento, pero usa menos recursos." -#: lib/cli/args.py:864 +#: lib/cli/args.py:891 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -656,11 +673,11 @@ msgstr "" "hasta más de una semana.\n" "Los plugins de los modelos pueden configurarse en el menú \"Ajustes\"" -#: lib/cli/args.py:883 lib/cli/args.py:892 +#: lib/cli/args.py:910 lib/cli/args.py:919 msgid "faces" msgstr "caras" -#: lib/cli/args.py:884 +#: lib/cli/args.py:911 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -670,7 +687,7 @@ msgstr "" "para la cara A. Esta es la cara original, es decir, la cara que se quiere " "eliminar y sustituir por la cara B." -#: lib/cli/args.py:893 +#: lib/cli/args.py:920 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -680,12 +697,12 @@ msgstr "" "para la cara B. Esta es la cara de intercambio, es decir, la cara que se " "quiere colocar en la cabeza de la persona A." -#: lib/cli/args.py:901 lib/cli/args.py:913 lib/cli/args.py:929 -#: lib/cli/args.py:954 lib/cli/args.py:964 +#: lib/cli/args.py:928 lib/cli/args.py:940 lib/cli/args.py:956 +#: lib/cli/args.py:981 lib/cli/args.py:991 msgid "model" msgstr "modelo" -#: lib/cli/args.py:902 +#: lib/cli/args.py:929 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -699,7 +716,7 @@ msgstr "" "carpeta que no exista (que se creará). Si continúa entrenando un modelo " "existente, especifique la ubicación del modelo existente." -#: lib/cli/args.py:914 +#: lib/cli/args.py:941 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -723,7 +740,7 @@ msgstr "" "NB: Los pesos solo se pueden cargar desde modelos del mismo complemento que " "desea entrenar." -#: lib/cli/args.py:930 +#: lib/cli/args.py:957 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -768,7 +785,7 @@ msgstr "" "recursos (se necesita una GPU con una buena cantidad de VRAM). Bueno para " "los detalles, pero más susceptible a las diferencias de color." -#: lib/cli/args.py:955 +#: lib/cli/args.py:982 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -780,7 +797,7 @@ msgstr "" "muestra un resumen del modelo que crearía el complemento elegido y los " "ajustes de configuración." -#: lib/cli/args.py:965 +#: lib/cli/args.py:992 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -794,12 +811,12 @@ msgstr "" "congelará el codificador, pero algunos modelos pueden tener opciones de " "configuración para congelar otras capas." -#: lib/cli/args.py:978 lib/cli/args.py:990 lib/cli/args.py:1001 -#: lib/cli/args.py:1087 +#: lib/cli/args.py:1005 lib/cli/args.py:1017 lib/cli/args.py:1028 +#: lib/cli/args.py:1039 lib/cli/args.py:1122 msgid "training" msgstr "entrenamiento" -#: lib/cli/args.py:979 +#: lib/cli/args.py:1006 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -812,7 +829,7 @@ msgstr "" "momento es el doble del número que se establece aquí. Los lotes más grandes " "requieren más RAM de la GPU." -#: lib/cli/args.py:991 +#: lib/cli/args.py:1018 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -827,22 +844,36 @@ msgstr "" "automáticamente en un número determinado de iteraciones, puede establecer " "ese valor aquí." -#: lib/cli/args.py:1002 +#: lib/cli/args.py:1029 +msgid "" +"[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " +"Mirrored Distrubution Strategy to train on multiple GPUs." +msgstr "" +"[Obsoleto: use '-D, --distribution-strategy' en su lugar] Use la estrategia " +"de distribución duplicada de Tensorflow para entrenar en varias GPU." + +#: lib/cli/args.py:1040 msgid "" -"Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." +"R|Select the distribution stategy to use.\n" +"L|default: Use Tensorflow's default distribution strategy.\n" +"L|central-storage: Centralizes variables on the CPU whilst operations are " +"performed on 1 or more local GPUs. This can help save some VRAM at the cost " +"of some speed by not storing variables on the GPU. Note: Mixed-Precision is " +"not supported on multi-GPU setups.\n" +"L|mirrored: Supports synchronous distributed training across multiple local " +"GPUs. A copy of the model and all variables are loaded onto each GPU with " +"batches distributed to each GPU at each iteration." msgstr "" -"Utilice la estrategia de distribución en espejo de Tensorflow para entrenar " -"en múltiples GPUs." -#: lib/cli/args.py:1012 lib/cli/args.py:1022 +#: lib/cli/args.py:1057 lib/cli/args.py:1067 msgid "Saving" msgstr "Guardar" -#: lib/cli/args.py:1013 +#: lib/cli/args.py:1058 msgid "Sets the number of iterations between each model save." msgstr "Establece el número de iteraciones entre cada guardado del modelo." -#: lib/cli/args.py:1023 +#: lib/cli/args.py:1068 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -850,11 +881,11 @@ msgstr "" "Establece el número de iteraciones antes de guardar una copia de seguridad " "del modelo en su estado actual. Establece 0 para que esté desactivado." -#: lib/cli/args.py:1030 lib/cli/args.py:1041 lib/cli/args.py:1052 +#: lib/cli/args.py:1075 lib/cli/args.py:1086 lib/cli/args.py:1097 msgid "timelapse" msgstr "intervalo" -#: lib/cli/args.py:1031 +#: lib/cli/args.py:1076 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -868,7 +899,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-B." -#: lib/cli/args.py:1042 +#: lib/cli/args.py:1087 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -882,7 +913,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-A." -#: lib/cli/args.py:1053 +#: lib/cli/args.py:1098 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -894,17 +925,17 @@ msgstr "" "Si se suministran las carpetas de entrada pero no la carpeta de salida, se " "guardará por defecto en la carpeta del modelo /timelapse/" -#: lib/cli/args.py:1065 lib/cli/args.py:1072 lib/cli/args.py:1079 +#: lib/cli/args.py:1107 lib/cli/args.py:1114 msgid "preview" msgstr "previsualización" -#: lib/cli/args.py:1073 +#: lib/cli/args.py:1108 msgid "Show training preview output. in a separate window." msgstr "" "Mostrar la salida de la vista previa del entrenamiento. en una ventana " "separada." -#: lib/cli/args.py:1080 +#: lib/cli/args.py:1115 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -912,7 +943,7 @@ msgstr "" "Escribe el resultado del entrenamiento en un archivo. La imagen se " "almacenará en la raíz de su carpeta FaceSwap." -#: lib/cli/args.py:1088 +#: lib/cli/args.py:1123 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -920,12 +951,12 @@ msgstr "" "Desactiva el registro de TensorBoard. NB: Desactivar los registros significa " "que no podrá utilizar el gráfico o el análisis de esta sesión en la GUI." -#: lib/cli/args.py:1095 lib/cli/args.py:1104 lib/cli/args.py:1113 -#: lib/cli/args.py:1122 +#: lib/cli/args.py:1130 lib/cli/args.py:1139 lib/cli/args.py:1148 +#: lib/cli/args.py:1157 msgid "augmentation" msgstr "aumento" -#: lib/cli/args.py:1096 +#: lib/cli/args.py:1131 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -935,7 +966,7 @@ msgstr "" "conjunto de caras opuestas en lugar de deformar la cara al azar. Esta es la " "forma 'dfaker' de hacer la deformación." -#: lib/cli/args.py:1105 +#: lib/cli/args.py:1140 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -946,7 +977,7 @@ msgstr "" "general, esto debería dejarse sin efecto, excepto durante el 'entrenamiento " "de ajuste'." -#: lib/cli/args.py:1114 +#: lib/cli/args.py:1149 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -956,7 +987,7 @@ msgstr "" "diferencias de color entre los conjuntos A y B, con un mayor coste de tiempo " "de entrenamiento. Activa esta opción para desactivar el aumento de color." -#: lib/cli/args.py:1123 +#: lib/cli/args.py:1158 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -969,7 +1000,7 @@ msgstr "" "esta opción desde el principio, es probable que arruine el modelo y se " "obtengan resultados terribles." -#: lib/cli/args.py:1148 +#: lib/cli/args.py:1183 msgid "Output to Shell console instead of GUI console" msgstr "Salida a la consola Shell en lugar de la consola GUI" diff --git a/locales/es/LC_MESSAGES/tools.mask.cli.mo b/locales/es/LC_MESSAGES/tools.mask.cli.mo index efae7fa7c6833c442306c4172ad0f66353c4b996..950caebd86b9ef42136361841bc9ebcb7767ba85 100644 GIT binary patch delta 815 zcmYk3Noy2A7>2(@v#24iQ4o|*ytvSzCo!M{5fb8d2!@adf{B#fH8Z8%RgJmT!6ct>5D-lP~Tpzw05|z4j1C)hqj zG!H&IO!Nf&3Qi#Y?h(30_fctWi0Bh822K!t0Kb9V-9)cX5?#ms4HH2l{Q`r4KVT5h zdx{8kXjfQchS86?r(NkfHUu>UW#z!1lo`}n=-TXAP73EuUgZf+i#%^}F5Cn54dJ<_ zvdm#Ax6Xu>f*&d0V3B29acnHdj?W}U*Hpc*;$9|MX?D_krR(nOFwZrVV|+$uEw*y0 zP*$d_YTPmfLcP@KB71}Sxfmbo`2O?lAA_Skqr>rVr8BjEy6?t6)8@6{e0|L&!V376c$Fx@ONHbt zlem)^udfB)Qk?7vg=bu0!*RPgbb1+1UBM%;GnwJ=4>FNXScMbCUP)ahmwqK`y`w|5 z;FPf2&gBwbqJjG_#&J_xXSB%FW0B$luMF#R<#M#pK}f}PKgHv9hq=_l!r delta 320 zcmWm8yGsK>5XbRvVjkx+=Lif+E2}@Bs>f3Kl^EL5qm3HntMDGJ;}f zVRhF22a1S=ot+4RSSUe6;%^6bKRdIt%bd>r`Ss1Cml!<7;TE{*fD>8kgfId>(pP@n z4Qk_ECgi6~%BC!-uO&BRDFxfAU)a!)E%_xIy|5k|*Z_*6#A|ET-Ol5Mxb80g5 M?ZsqxIb5~=0agJwfdBvi diff --git a/locales/es/LC_MESSAGES/tools.mask.cli.po b/locales/es/LC_MESSAGES/tools.mask.cli.po index f09fe4cd72..698d139e1e 100644 --- a/locales/es/LC_MESSAGES/tools.mask.cli.po +++ b/locales/es/LC_MESSAGES/tools.mask.cli.po @@ -5,24 +5,25 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-05-17 18:17+0100\n" -"PO-Revision-Date: 2021-05-17 18:18+0100\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-08-05 14:00+0100\n" +"PO-Revision-Date: 2022-08-05 14:05+0100\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es_ES\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 2.4.3\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.0.1\n" -#: tools/mask/cli.py:15 +#: /home/matt/faceswap/tools/mask/cli.py:15 msgid "This command lets you generate masks for existing alignments." msgstr "" "Este comando permite generar máscaras para las alineaciones existentes." -#: tools/mask/cli.py:24 +#: /home/matt/faceswap/tools/mask/cli.py:24 msgid "" "Mask tool\n" "Generate masks for existing alignments files." @@ -30,11 +31,13 @@ msgstr "" "Herramienta de máscara\n" "Genera máscaras para los archivos de alineación existentes." -#: tools/mask/cli.py:32 tools/mask/cli.py:41 tools/mask/cli.py:51 +#: /home/matt/faceswap/tools/mask/cli.py:33 +#: /home/matt/faceswap/tools/mask/cli.py:42 +#: /home/matt/faceswap/tools/mask/cli.py:52 msgid "data" msgstr "datos" -#: tools/mask/cli.py:35 +#: /home/matt/faceswap/tools/mask/cli.py:36 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." @@ -43,13 +46,13 @@ msgstr "" "Nota: si la máscara ya existe en el archivo de alineaciones, se " "sobrescribirá." -#: tools/mask/cli.py:44 +#: /home/matt/faceswap/tools/mask/cli.py:45 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:53 +#: /home/matt/faceswap/tools/mask/cli.py:54 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" @@ -59,11 +62,12 @@ 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:62 tools/mask/cli.py:90 +#: /home/matt/faceswap/tools/mask/cli.py:63 +#: /home/matt/faceswap/tools/mask/cli.py:95 msgid "process" msgstr "proceso" -#: tools/mask/cli.py:63 +#: /home/matt/faceswap/tools/mask/cli.py:64 msgid "" "R|Masker to use.\n" "L|bisenet-fp: Relatively lightweight NN based mask that provides more " @@ -72,6 +76,10 @@ msgid "" "L|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.\n" +"L|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.\n" "L|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 " @@ -96,6 +104,10 @@ msgstr "" "basada en la posición de los puntos de referencia. Se construye un casco " "convexo alrededor del exterior de los puntos de referencia para crear una " "máscara.\n" +"L|custom: Una máscara ficticia que llena el área de la máscara con 1 o 0 " +"(configurable en la configuración). Esto solo es necesario si tiene la " +"intención de editar manualmente las máscaras personalizadas usted mismo en " +"la herramienta manual. Esta máscara no utiliza la GPU.\n" "L|extended: Máscara diseñada para proporcionar una segmentación facial " "basada en el posicionamiento de las ubicaciones de los puntos de referencia. " "Se construye un casco convexo alrededor del exterior de los puntos de " @@ -114,7 +126,7 @@ msgstr "" "descripción. Los rostros de perfil pueden dar lugar a un rendimiento " "inferior." -#: tools/mask/cli.py:91 +#: /home/matt/faceswap/tools/mask/cli.py:96 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 " @@ -134,12 +146,15 @@ msgstr "" "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:104 tools/mask/cli.py:111 tools/mask/cli.py:124 -#: tools/mask/cli.py:137 tools/mask/cli.py:146 +#: /home/matt/faceswap/tools/mask/cli.py:109 +#: /home/matt/faceswap/tools/mask/cli.py:116 +#: /home/matt/faceswap/tools/mask/cli.py:129 +#: /home/matt/faceswap/tools/mask/cli.py:142 +#: /home/matt/faceswap/tools/mask/cli.py:151 msgid "output" msgstr "salida" -#: tools/mask/cli.py:105 +#: /home/matt/faceswap/tools/mask/cli.py:110 msgid "" "Optional output location. If provided, a preview of the masks created will " "be output in the given folder." @@ -147,7 +162,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:115 +#: /home/matt/faceswap/tools/mask/cli.py:120 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 " @@ -160,7 +175,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:128 +#: /home/matt/faceswap/tools/mask/cli.py:133 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 " @@ -171,7 +186,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:138 +#: /home/matt/faceswap/tools/mask/cli.py:143 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" @@ -186,7 +201,7 @@ msgstr "" "enmascarada.\n" "L|mask: Sólo emite la máscara como una imagen de un solo canal." -#: tools/mask/cli.py:147 +#: /home/matt/faceswap/tools/mask/cli.py:152 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/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index eaecdc8baf..e91594fb70 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -1,410 +1,661 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR ORGANIZATION +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # +#, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-05-17 18:04+0100\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-08-05 13:58+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" +"Language: \n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=cp1252\n" +"Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" - -#: lib/cli/args.py:177 lib/cli/args.py:187 lib/cli/args.py:195 -#: lib/cli/args.py:205 +#: lib/cli/args.py:193 lib/cli/args.py:203 lib/cli/args.py:211 +#: lib/cli/args.py:221 msgid "Global Options" msgstr "" -#: lib/cli/args.py:178 +#: lib/cli/args.py:194 msgid "" -"R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond to any GPU(s) that you do not wish to be made available to Faceswap. Selecting all GPUs here will force Faceswap into CPU mode.\n" +"R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " +"to any GPU(s) that you do not wish to be made available to Faceswap. " +"Selecting all GPUs here will force Faceswap into CPU mode.\n" "L|{}" msgstr "" -#: lib/cli/args.py:188 -msgid "Optionally overide the saved config with the path to a custom config file." +#: lib/cli/args.py:204 +msgid "" +"Optionally overide the saved config with the path to a custom config file." msgstr "" -#: lib/cli/args.py:196 -msgid "Log level. Stick with INFO or VERBOSE unless you need to file an error report. Be careful with TRACE as it will generate a lot of data" +#: lib/cli/args.py:212 +msgid "" +"Log level. Stick with INFO or VERBOSE unless you need to file an error " +"report. Be careful with TRACE as it will generate a lot of data" msgstr "" -#: lib/cli/args.py:206 +#: lib/cli/args.py:222 msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "" -#: lib/cli/args.py:299 lib/cli/args.py:308 lib/cli/args.py:316 -#: lib/cli/args.py:630 lib/cli/args.py:639 +#: lib/cli/args.py:320 lib/cli/args.py:329 lib/cli/args.py:337 +#: lib/cli/args.py:655 lib/cli/args.py:664 msgid "Data" msgstr "" -#: lib/cli/args.py:300 -msgid "Input directory or video. Either a directory containing the image files you wish to process or path to a video file. NB: This should be the source video/frames NOT the source faces." +#: lib/cli/args.py:321 +msgid "" +"Input directory or video. Either a directory containing the image files you " +"wish to process or path to a video file. NB: This should be the source video/" +"frames NOT the source faces." msgstr "" -#: lib/cli/args.py:309 +#: lib/cli/args.py:330 msgid "Output directory. This is where the converted files will be saved." msgstr "" -#: lib/cli/args.py:317 -msgid "Optional path to an alignments file. Leave blank if the alignments file is at the default location." +#: lib/cli/args.py:338 +msgid "" +"Optional path to an alignments file. Leave blank if the alignments file is " +"at the default location." msgstr "" -#: lib/cli/args.py:340 +#: lib/cli/args.py:361 msgid "" "Extract faces from image or video sources.\n" "Extraction plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:365 lib/cli/args.py:381 lib/cli/args.py:393 -#: lib/cli/args.py:428 lib/cli/args.py:446 lib/cli/args.py:458 -#: lib/cli/args.py:649 lib/cli/args.py:676 lib/cli/args.py:712 +#: lib/cli/args.py:386 lib/cli/args.py:402 lib/cli/args.py:414 +#: lib/cli/args.py:453 lib/cli/args.py:471 lib/cli/args.py:483 +#: lib/cli/args.py:674 lib/cli/args.py:701 lib/cli/args.py:739 msgid "Plugins" msgstr "" -#: lib/cli/args.py:366 +#: lib/cli/args.py:387 msgid "" -"R|Detector to use. Some of these have configurable settings in '/config/extract.ini' or 'Settings > Configure Extract 'Plugins':\n" -"L|cv2-dnn: A CPU only extractor which is the least reliable and least resource intensive. Use this if not using a GPU and time is important.\n" -"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources than other GPU detectors but can often return more false positives.\n" -"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and fewer false positives than other GPU detectors, but is a lot more resource intensive." -msgstr "" - -#: lib/cli/args.py:382 +"R|Detector to use. Some of these have configurable settings in '/config/" +"extract.ini' or 'Settings > Configure Extract 'Plugins':\n" +"L|cv2-dnn: A CPU only extractor which is the least reliable and least " +"resource intensive. Use this if not using a GPU and time is important.\n" +"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " +"than other GPU detectors but can often return more false positives.\n" +"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " +"fewer false positives than other GPU detectors, but is a lot more resource " +"intensive." +msgstr "" + +#: lib/cli/args.py:403 msgid "" "R|Aligner to use.\n" -"L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, but less accurate. Only use this if not using a GPU and time is important.\n" +"L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " +"but less accurate. Only use this if not using a GPU and time is important.\n" "L|fan: Best aligner. Fast on GPU, slow on CPU." msgstr "" -#: lib/cli/args.py:394 +#: lib/cli/args.py:415 msgid "" -"R|Additional Masker(s) to use. The masks generated here will all take up GPU RAM. You can select none, one or multiple masks, but the extraction may take longer the more you select. NB: The Extended and Components (landmark based) masks are automatically generated on extraction.\n" -"L|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).\n" -"L|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.\n" -"L|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.\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.\n" +"R|Additional Masker(s) to use. The masks generated here will all take up GPU " +"RAM. You can select none, one or multiple masks, but the extraction may take " +"longer the more you select. NB: The Extended and Components (landmark based) " +"masks are automatically generated on extraction.\n" +"L|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).\n" +"L|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 so will not use any additional VRAM.\n" +"L|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.\n" +"L|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.\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.\n" "The auto generated masks are as follows:\n" -"L|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.\n" -"L|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.\n" +"L|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.\n" +"L|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.\n" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" msgstr "" -#: lib/cli/args.py:429 +#: lib/cli/args.py:454 msgid "" -"R|Performing normalization can help the aligner better align faces with difficult lighting conditions at an extraction speed cost. Different methods will yield different results on different sets. NB: This does not impact the output face, just the input to the aligner.\n" +"R|Performing normalization can help the aligner better align faces with " +"difficult lighting conditions at an extraction speed cost. Different methods " +"will yield different results on different sets. NB: This does not impact the " +"output face, just the input to the aligner.\n" "L|none: Don't perform normalization on the face.\n" -"L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the face.\n" +"L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " +"face.\n" "L|hist: Equalize the histograms on the RGB channels.\n" "L|mean: Normalize the face colors to the mean." msgstr "" -#: lib/cli/args.py:447 -msgid "The number of times to re-feed the detected face into the aligner. Each time the face is re-fed into the aligner the bounding box is adjusted by a small amount. The final landmarks are then averaged from each iteration. Helps to remove 'micro-jitter' but at the cost of slower extraction speed. The more times the face is re-fed into the aligner, the less micro-jitter should occur but the longer extraction will take." +#: lib/cli/args.py:472 +msgid "" +"The number of times to re-feed the detected face into the aligner. Each time " +"the face is re-fed into the aligner the bounding box is adjusted by a small " +"amount. The final landmarks are then averaged from each iteration. Helps to " +"remove 'micro-jitter' but at the cost of slower extraction speed. The more " +"times the face is re-fed into the aligner, the less micro-jitter should " +"occur but the longer extraction will take." msgstr "" -#: lib/cli/args.py:459 -msgid "If a face isn't found, rotate the images to try to find a face. Can find more faces at the cost of extraction speed. Pass in a single number to use increments of that size up to 360, or pass in a list of numbers to enumerate exactly what angles to check." +#: lib/cli/args.py:484 +msgid "" +"If a face isn't found, rotate the images to try to find a face. Can find " +"more faces at the cost of extraction speed. Pass in a single number to use " +"increments of that size up to 360, or pass in a list of numbers to enumerate " +"exactly what angles to check." msgstr "" -#: lib/cli/args.py:471 lib/cli/args.py:481 lib/cli/args.py:494 -#: lib/cli/args.py:508 lib/cli/args.py:749 lib/cli/args.py:763 -#: lib/cli/args.py:776 lib/cli/args.py:790 +#: lib/cli/args.py:496 lib/cli/args.py:506 lib/cli/args.py:519 +#: lib/cli/args.py:533 lib/cli/args.py:776 lib/cli/args.py:790 +#: lib/cli/args.py:803 lib/cli/args.py:817 msgid "Face Processing" msgstr "" -#: lib/cli/args.py:472 -msgid "Filters out faces detected below this size. Length, in pixels across the diagonal of the bounding box. Set to 0 for off" +#: lib/cli/args.py:497 +msgid "" +"Filters out faces detected below this size. Length, in pixels across the " +"diagonal of the bounding box. Set to 0 for off" msgstr "" -#: lib/cli/args.py:482 lib/cli/args.py:764 -msgid "Optionally filter out people who you do not wish to process by passing in an image of that person. Should be a front portrait with a single person in the image. Multiple images can be added space separated. NB: Using face filter will significantly decrease extraction speed and its accuracy cannot be guaranteed." +#: lib/cli/args.py:507 lib/cli/args.py:791 +msgid "" +"Optionally filter out people who you do not wish to process by passing in an " +"image of that person. Should be a front portrait with a single person in the " +"image. Multiple images can be added space separated. NB: Using face filter " +"will significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." msgstr "" -#: lib/cli/args.py:495 lib/cli/args.py:777 -msgid "Optionally select people you wish to process by passing in an image of that person. Should be a front portrait with a single person in the image. Multiple images can be added space separated. NB: Using face filter will significantly decrease extraction speed and its accuracy cannot be guaranteed." +#: lib/cli/args.py:520 lib/cli/args.py:804 +msgid "" +"Optionally select people you wish to process by passing in an image of that " +"person. Should be a front portrait with a single person in the image. " +"Multiple images can be added space separated. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." msgstr "" -#: lib/cli/args.py:509 lib/cli/args.py:791 -msgid "For use with the optional nfilter/filter files. Threshold for positive face recognition. Lower values are stricter. NB: Using face filter will significantly decrease extraction speed and its accuracy cannot be guaranteed." +#: lib/cli/args.py:534 lib/cli/args.py:818 +msgid "" +"For use with the optional nfilter/filter files. Threshold for positive face " +"recognition. Lower values are stricter. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." msgstr "" -#: lib/cli/args.py:520 lib/cli/args.py:532 lib/cli/args.py:544 -#: lib/cli/args.py:556 +#: lib/cli/args.py:545 lib/cli/args.py:557 lib/cli/args.py:569 +#: lib/cli/args.py:581 msgid "output" msgstr "" -#: lib/cli/args.py:521 -msgid "The output size of extracted faces. Make sure that the model you intend to train supports your required size. This will only need to be changed for hi-res models." +#: lib/cli/args.py:546 +msgid "" +"The output size of extracted faces. Make sure that the model you intend to " +"train supports your required size. This will only need to be changed for hi-" +"res models." msgstr "" -#: lib/cli/args.py:533 -msgid "Extract every 'nth' frame. This option will skip frames when extracting faces. For example a value of 1 will extract faces from every frame, a value of 10 will extract faces from every 10th frame." +#: lib/cli/args.py:558 +msgid "" +"Extract every 'nth' frame. This option will skip frames when extracting " +"faces. For example a value of 1 will extract faces from every frame, a value " +"of 10 will extract faces from every 10th frame." msgstr "" -#: lib/cli/args.py:545 -msgid "Automatically save the alignments file after a set amount of frames. By default the alignments file is only saved at the end of the extraction process. NB: If extracting in 2 passes then the alignments file will only start to be saved out during the second pass. WARNING: Don't interrupt the script when writing the file because it might get corrupted. Set to 0 to turn off" +#: lib/cli/args.py:570 +msgid "" +"Automatically save the alignments file after a set amount of frames. By " +"default the alignments file is only saved at the end of the extraction " +"process. NB: If extracting in 2 passes then the alignments file will only " +"start to be saved out during the second pass. WARNING: Don't interrupt the " +"script when writing the file because it might get corrupted. Set to 0 to " +"turn off" msgstr "" -#: lib/cli/args.py:557 +#: lib/cli/args.py:582 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" -#: lib/cli/args.py:563 lib/cli/args.py:572 lib/cli/args.py:580 -#: lib/cli/args.py:587 lib/cli/args.py:803 lib/cli/args.py:814 -#: lib/cli/args.py:822 lib/cli/args.py:841 lib/cli/args.py:847 +#: lib/cli/args.py:588 lib/cli/args.py:597 lib/cli/args.py:605 +#: lib/cli/args.py:612 lib/cli/args.py:830 lib/cli/args.py:841 +#: lib/cli/args.py:849 lib/cli/args.py:868 lib/cli/args.py:874 msgid "settings" msgstr "" -#: lib/cli/args.py:564 -msgid "Don't run extraction in parallel. Will run each part of the extraction process separately (one after the other) rather than all at the smae time. Useful if VRAM is at a premium." +#: lib/cli/args.py:589 +msgid "" +"Don't run extraction in parallel. Will run each part of the extraction " +"process separately (one after the other) rather than all at the smae time. " +"Useful if VRAM is at a premium." msgstr "" -#: lib/cli/args.py:573 -msgid "Skips frames that have already been extracted and exist in the alignments file" +#: lib/cli/args.py:598 +msgid "" +"Skips frames that have already been extracted and exist in the alignments " +"file" msgstr "" -#: lib/cli/args.py:581 +#: lib/cli/args.py:606 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" -#: lib/cli/args.py:588 +#: lib/cli/args.py:613 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" -#: lib/cli/args.py:610 +#: lib/cli/args.py:635 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:631 -msgid "Only required if converting from images to video. Provide The original video that the source frames were extracted from (for extracting the fps and audio)." +#: lib/cli/args.py:656 +msgid "" +"Only required if converting from images to video. Provide The original video " +"that the source frames were extracted from (for extracting the fps and " +"audio)." msgstr "" -#: lib/cli/args.py:640 -msgid "Model directory. The directory containing the trained model you wish to use for conversion." +#: lib/cli/args.py:665 +msgid "" +"Model directory. The directory containing the trained model you wish to use " +"for conversion." msgstr "" -#: lib/cli/args.py:650 +#: lib/cli/args.py:675 msgid "" -"R|Performs color adjustment to the swapped face. Some of these options have configurable settings in '/config/convert.ini' or 'Settings > Configure Convert Plugins':\n" -"L|avg-color: Adjust the mean of each color channel in the swapped reconstruction to equal the mean of the masked area in the original image.\n" -"L|color-transfer: Transfers the color distribution from the source to the target image using the mean and standard deviations of the L*a*b* color space.\n" -"L|manual-balance: Manually adjust the balance of the image in a variety of color spaces. Best used with the Preview tool to set correct values.\n" -"L|match-hist: Adjust the histogram of each color channel in the swapped reconstruction to equal the histogram of the masked area in the original image.\n" -"L|seamless-clone: Use cv2's seamless clone function to remove extreme gradients at the mask seam by smoothing colors. Generally does not give very satisfactory results.\n" +"R|Performs color adjustment to the swapped face. Some of these options have " +"configurable settings in '/config/convert.ini' or 'Settings > Configure " +"Convert Plugins':\n" +"L|avg-color: Adjust the mean of each color channel in the swapped " +"reconstruction to equal the mean of the masked area in the original image.\n" +"L|color-transfer: Transfers the color distribution from the source to the " +"target image using the mean and standard deviations of the L*a*b* color " +"space.\n" +"L|manual-balance: Manually adjust the balance of the image in a variety of " +"color spaces. Best used with the Preview tool to set correct values.\n" +"L|match-hist: Adjust the histogram of each color channel in the swapped " +"reconstruction to equal the histogram of the masked area in the original " +"image.\n" +"L|seamless-clone: Use cv2's seamless clone function to remove extreme " +"gradients at the mask seam by smoothing colors. Generally does not give very " +"satisfactory results.\n" "L|none: Don't perform color adjustment." msgstr "" -#: lib/cli/args.py:677 +#: lib/cli/args.py:702 msgid "" -"R|Masker to use. NB: The mask you require must exist within the alignments file. You can add additional masks with the Mask Tool.\n" +"R|Masker to use. NB: The mask you require must exist within the alignments " +"file. You can add additional masks with the Mask Tool.\n" "L|none: Don't use a mask.\n" -"L|bisenet-fp-face: Relatively lightweight NN based mask that provides more refined control over the area to be masked (configurable in mask settings). Use this version of bisenet-fp if your model is trained with 'face' or 'legacy' centering.\n" -"L|bisenet-fp-head: Relatively lightweight NN based mask that provides more refined control over the area to be masked (configurable in mask settings). Use this version of bisenet-fp if your model is trained with 'head' centering.\n" -"L|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.\n" -"L|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.\n" -"L|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.\n" -"L|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.\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.\n" -"L|predicted: If the 'Learn Mask' option was enabled during training, this will use the mask that was created by the trained model." +"L|bisenet-fp_face: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'face' or " +"'legacy' centering.\n" +"L|bisenet-fp_head: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'head' " +"centering.\n" +"L|custom_face: Custom user created, face centered mask.\n" +"L|custom_head: Custom user created, head centered mask.\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\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.\n" +"L|predicted: If the 'Learn Mask' option was enabled during training, this " +"will use the mask that was created by the trained model." msgstr "" -#: lib/cli/args.py:713 +#: lib/cli/args.py:740 msgid "" -"R|The plugin to use to output the converted images. The writers are configurable in '/config/convert.ini' or 'Settings > Configure Convert Plugins:'\n" -"L|ffmpeg: [video] Writes out the convert straight to video. When the input is a series of images then the '-ref' (--reference-video) parameter must be set.\n" +"R|The plugin to use to output the converted images. The writers are " +"configurable in '/config/convert.ini' or 'Settings > Configure Convert " +"Plugins:'\n" +"L|ffmpeg: [video] Writes out the convert straight to video. When the input " +"is a series of images then the '-ref' (--reference-video) parameter must be " +"set.\n" "L|gif: [animated image] Create an animated gif.\n" -"L|opencv: [images] The fastest image writer, but less options and formats than other plugins.\n" -"L|pillow: [images] Slower than opencv, but has more options and supports more formats." +"L|opencv: [images] The fastest image writer, but less options and formats " +"than other plugins.\n" +"L|pillow: [images] Slower than opencv, but has more options and supports " +"more formats." msgstr "" -#: lib/cli/args.py:732 lib/cli/args.py:739 lib/cli/args.py:833 +#: lib/cli/args.py:759 lib/cli/args.py:766 lib/cli/args.py:860 msgid "Frame Processing" msgstr "" -#: lib/cli/args.py:733 -msgid "Scale the final output frames by this amount. 100%% will output the frames at source dimensions. 50%% at half size 200%% at double size" +#: lib/cli/args.py:760 +#, python-format +msgid "" +"Scale the final output frames by this amount. 100%% will output the frames " +"at source dimensions. 50%% at half size 200%% at double size" msgstr "" -#: lib/cli/args.py:740 -msgid "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use --frame-ranges 10-50 90-100. Frames falling outside of the selected range will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are converting from images, then the filenames must end with the frame-number!" +#: lib/cli/args.py:767 +msgid "" +"Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " +"--frame-ranges 10-50 90-100. Frames falling outside of the selected range " +"will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " +"converting from images, then the filenames must end with the frame-number!" msgstr "" -#: lib/cli/args.py:750 -msgid "If you have not cleansed your alignments file, then you can filter out faces by defining a folder here that contains the faces extracted from your input files/video. If this folder is defined, then only faces that exist within your alignments file and also exist within the specified folder will be converted. Leaving this blank will convert all faces that exist within the alignments file." +#: lib/cli/args.py:777 +msgid "" +"If you have not cleansed your alignments file, then you can filter out faces " +"by defining a folder here that contains the faces extracted from your input " +"files/video. If this folder is defined, then only faces that exist within " +"your alignments file and also exist within the specified folder will be " +"converted. Leaving this blank will convert all faces that exist within the " +"alignments file." msgstr "" -#: lib/cli/args.py:804 -msgid "The maximum number of parallel processes for performing conversion. Converting images is system RAM heavy so it is possible to run out of memory if you have a lot of processes and not enough RAM to accommodate them all. Setting this to 0 will use the maximum available. No matter what you set this to, it will never attempt to use more processes than are available on your system. If singleprocess is enabled this setting will be ignored." +#: lib/cli/args.py:831 +msgid "" +"The maximum number of parallel processes for performing conversion. " +"Converting images is system RAM heavy so it is possible to run out of memory " +"if you have a lot of processes and not enough RAM to accommodate them all. " +"Setting this to 0 will use the maximum available. No matter what you set " +"this to, it will never attempt to use more processes than are available on " +"your system. If singleprocess is enabled this setting will be ignored." msgstr "" -#: lib/cli/args.py:815 -msgid "[LEGACY] This only needs to be selected if a legacy model is being loaded or if there are multiple models in the model folder" +#: lib/cli/args.py:842 +msgid "" +"[LEGACY] This only needs to be selected if a legacy model is being loaded or " +"if there are multiple models in the model folder" msgstr "" -#: lib/cli/args.py:823 -msgid "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean alignments file for your destination video. However, if you wish you can generate the alignments on-the-fly by enabling this option. This will use an inferior extraction pipeline and will lead to substandard results. If an alignments file is found, this option will be ignored." +#: lib/cli/args.py:850 +msgid "" +"Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " +"alignments file for your destination video. However, if you wish you can " +"generate the alignments on-the-fly by enabling this option. This will use an " +"inferior extraction pipeline and will lead to substandard results. If an " +"alignments file is found, this option will be ignored." msgstr "" -#: lib/cli/args.py:834 -msgid "When used with --frame-ranges outputs the unchanged frames that are not processed instead of discarding them." +#: lib/cli/args.py:861 +msgid "" +"When used with --frame-ranges outputs the unchanged frames that are not " +"processed instead of discarding them." msgstr "" -#: lib/cli/args.py:842 +#: lib/cli/args.py:869 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" -#: lib/cli/args.py:848 +#: lib/cli/args.py:875 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "" -#: lib/cli/args.py:864 +#: lib/cli/args.py:891 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" "Model plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:883 lib/cli/args.py:892 +#: lib/cli/args.py:910 lib/cli/args.py:919 msgid "faces" msgstr "" -#: lib/cli/args.py:884 -msgid "Input directory. A directory containing training images for face A. This is the original face, i.e. the face that you want to remove and replace with face B." +#: lib/cli/args.py:911 +msgid "" +"Input directory. A directory containing training images for face A. This is " +"the original face, i.e. the face that you want to remove and replace with " +"face B." msgstr "" -#: lib/cli/args.py:893 -msgid "Input directory. A directory containing training images for face B. This is the swap face, i.e. the face that you want to place onto the head of person A." +#: lib/cli/args.py:920 +msgid "" +"Input directory. A directory containing training images for face B. This is " +"the swap face, i.e. the face that you want to place onto the head of person " +"A." msgstr "" -#: lib/cli/args.py:901 lib/cli/args.py:913 lib/cli/args.py:929 -#: lib/cli/args.py:954 lib/cli/args.py:964 +#: lib/cli/args.py:928 lib/cli/args.py:940 lib/cli/args.py:956 +#: lib/cli/args.py:981 lib/cli/args.py:991 msgid "model" msgstr "" -#: lib/cli/args.py:902 -msgid "Model directory. This is where the training data will be stored. You should always specify a new folder for new models. If starting a new model, select either an empty folder, or a folder which does not exist (which will be created). If continuing to train an existing model, specify the location of the existing model." -msgstr "" - -#: lib/cli/args.py:914 +#: lib/cli/args.py:929 msgid "" -"R|Load the weights from a pre-existing model into a newly created model. For most models this will load weights from the Encoder of the given model into the encoder of the newly created model. Some plugins may have specific configuration options allowing you to load weights from other layers. Weights will only be loaded when creating a new model. This option will be ignored if you are resuming an existing model. Generally you will also want to 'freeze-weights' whilst the rest of your model catches up with your Encoder.\n" -"NB: Weights can only be loaded from models of the same plugin as you intend to train." +"Model directory. This is where the training data will be stored. You should " +"always specify a new folder for new models. If starting a new model, select " +"either an empty folder, or a folder which does not exist (which will be " +"created). If continuing to train an existing model, specify the location of " +"the existing model." msgstr "" -#: lib/cli/args.py:930 +#: lib/cli/args.py:941 +msgid "" +"R|Load the weights from a pre-existing model into a newly created model. For " +"most models this will load weights from the Encoder of the given model into " +"the encoder of the newly created model. Some plugins may have specific " +"configuration options allowing you to load weights from other layers. " +"Weights will only be loaded when creating a new model. This option will be " +"ignored if you are resuming an existing model. Generally you will also want " +"to 'freeze-weights' whilst the rest of your model catches up with your " +"Encoder.\n" +"NB: Weights can only be loaded from models of the same plugin as you intend " +"to train." +msgstr "" + +#: lib/cli/args.py:957 msgid "" -"R|Select which trainer to use. Trainers can be configured from the Settings menu or the config folder.\n" +"R|Select which trainer to use. Trainers can be configured from the Settings " +"menu or the config folder.\n" "L|original: The original model created by /u/deepfakes.\n" -"L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' for full dfaker method.\n" +"L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " +"for full dfaker method.\n" "L|dfl-h128: 128px in/out model from deepfacelab\n" "L|dfl-sae: Adaptable model from deepfacelab\n" "L|dlight: A lightweight, high resolution DFaker variant.\n" "L|iae: A model that uses intermediate layers to try to get better details\n" -"L|lightweight: A lightweight model for low-end cards. Don't expect great results. Can train as low as 1.6GB with batch size 8.\n" -"L|realface: A high detail, dual density model based on DFaker, with customizable in/out resolution. The autoencoders are unbalanced so B>A swaps won't work so well. By andenixa et al. Very configurable.\n" -"L|unbalanced: 128px in/out model from andenixa. The autoencoders are unbalanced so B>A swaps won't work so well. Very configurable.\n" -"L|villain: 128px in/out model from villainguy. Very resource hungry (You will require a GPU with a fair amount of VRAM). Good for details, but more susceptible to color differences." -msgstr "" - -#: lib/cli/args.py:955 -msgid "Output a summary of the model and exit. If a model folder is provided then a summary of the saved model is displayed. Otherwise a summary of the model that would be created by the chosen plugin and configuration settings is displayed." +"L|lightweight: A lightweight model for low-end cards. Don't expect great " +"results. Can train as low as 1.6GB with batch size 8.\n" +"L|realface: A high detail, dual density model based on DFaker, with " +"customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " +"won't work so well. By andenixa et al. Very configurable.\n" +"L|unbalanced: 128px in/out model from andenixa. The autoencoders are " +"unbalanced so B>A swaps won't work so well. Very configurable.\n" +"L|villain: 128px in/out model from villainguy. Very resource hungry (You " +"will require a GPU with a fair amount of VRAM). Good for details, but more " +"susceptible to color differences." +msgstr "" + +#: lib/cli/args.py:982 +msgid "" +"Output a summary of the model and exit. If a model folder is provided then a " +"summary of the saved model is displayed. Otherwise a summary of the model " +"that would be created by the chosen plugin and configuration settings is " +"displayed." msgstr "" -#: lib/cli/args.py:965 -msgid "Freeze the weights of the model. Freezing weights means that some of the parameters in the model will no longer continue to learn, but those that are not frozen will continue to learn. For most models, this will freeze the encoder, but some models may have configuration options for freezing other layers." +#: lib/cli/args.py:992 +msgid "" +"Freeze the weights of the model. Freezing weights means that some of the " +"parameters in the model will no longer continue to learn, but those that are " +"not frozen will continue to learn. For most models, this will freeze the " +"encoder, but some models may have configuration options for freezing other " +"layers." msgstr "" -#: lib/cli/args.py:978 lib/cli/args.py:990 lib/cli/args.py:1001 -#: lib/cli/args.py:1087 +#: lib/cli/args.py:1005 lib/cli/args.py:1017 lib/cli/args.py:1028 +#: lib/cli/args.py:1039 lib/cli/args.py:1122 msgid "training" msgstr "" -#: lib/cli/args.py:979 -msgid "Batch size. This is the number of images processed through the model for each side per iteration. NB: As the model is fed 2 sides at a time, the actual number of images within the model at any one time is double the number that you set here. Larger batches require more GPU RAM." +#: lib/cli/args.py:1006 +msgid "" +"Batch size. This is the number of images processed through the model for " +"each side per iteration. NB: As the model is fed 2 sides at a time, the " +"actual number of images within the model at any one time is double the " +"number that you set here. Larger batches require more GPU RAM." msgstr "" -#: lib/cli/args.py:991 -msgid "Length of training in iterations. This is only really used for automation. There is no 'correct' number of iterations a model should be trained for. You should stop training when you are happy with the previews. However, if you want the model to stop automatically at a set number of iterations, you can set that value here." +#: lib/cli/args.py:1018 +msgid "" +"Length of training in iterations. This is only really used for automation. " +"There is no 'correct' number of iterations a model should be trained for. " +"You should stop training when you are happy with the previews. However, if " +"you want the model to stop automatically at a set number of iterations, you " +"can set that value here." msgstr "" -#: lib/cli/args.py:1002 -msgid "Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." +#: lib/cli/args.py:1029 +msgid "" +"[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " +"Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" -#: lib/cli/args.py:1012 lib/cli/args.py:1022 +#: lib/cli/args.py:1040 +msgid "" +"R|Select the distribution stategy to use.\n" +"L|default: Use Tensorflow's default distribution strategy.\n" +"L|central-storage: Centralizes variables on the CPU whilst operations are " +"performed on 1 or more local GPUs. This can help save some VRAM at the cost " +"of some speed by not storing variables on the GPU. Note: Mixed-Precision is " +"not supported on multi-GPU setups.\n" +"L|mirrored: Supports synchronous distributed training across multiple local " +"GPUs. A copy of the model and all variables are loaded onto each GPU with " +"batches distributed to each GPU at each iteration." +msgstr "" + +#: lib/cli/args.py:1057 lib/cli/args.py:1067 msgid "Saving" msgstr "" -#: lib/cli/args.py:1013 +#: lib/cli/args.py:1058 msgid "Sets the number of iterations between each model save." msgstr "" -#: lib/cli/args.py:1023 -msgid "Sets the number of iterations before saving a backup snapshot of the model in it's current state. Set to 0 for off." +#: lib/cli/args.py:1068 +msgid "" +"Sets the number of iterations before saving a backup snapshot of the model " +"in it's current state. Set to 0 for off." msgstr "" -#: lib/cli/args.py:1030 lib/cli/args.py:1041 lib/cli/args.py:1052 +#: lib/cli/args.py:1075 lib/cli/args.py:1086 lib/cli/args.py:1097 msgid "timelapse" msgstr "" -#: lib/cli/args.py:1031 -msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. This should be the input folder of 'A' faces that you would like to use for creating the timelapse. You must also supply a --timelapse-output and a --timelapse-input-B parameter." +#: lib/cli/args.py:1076 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. " +"This should be the input folder of 'A' faces that you would like to use for " +"creating the timelapse. You must also supply a --timelapse-output and a --" +"timelapse-input-B parameter." msgstr "" -#: lib/cli/args.py:1042 -msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. This should be the input folder of 'B' faces that you would like to use for creating the timelapse. You must also supply a --timelapse-output and a --timelapse-input-A parameter." +#: lib/cli/args.py:1087 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. " +"This should be the input folder of 'B' faces that you would like to use for " +"creating the timelapse. You must also supply a --timelapse-output and a --" +"timelapse-input-A parameter." msgstr "" -#: lib/cli/args.py:1053 -msgid "Optional for creating a timelapse. Timelapse will save an image of your selected faces into the timelapse-output folder at every save iteration. If the input folders are supplied but no output folder, it will default to your model folder /timelapse/" +#: lib/cli/args.py:1098 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. If " +"the input folders are supplied but no output folder, it will default to your " +"model folder /timelapse/" msgstr "" -#: lib/cli/args.py:1065 lib/cli/args.py:1072 lib/cli/args.py:1079 +#: lib/cli/args.py:1107 lib/cli/args.py:1114 msgid "preview" msgstr "" -#: lib/cli/args.py:1073 +#: lib/cli/args.py:1108 msgid "Show training preview output. in a separate window." msgstr "" -#: lib/cli/args.py:1080 -msgid "Writes the training result to a file. The image will be stored in the root of your FaceSwap folder." +#: lib/cli/args.py:1115 +msgid "" +"Writes the training result to a file. The image will be stored in the root " +"of your FaceSwap folder." msgstr "" -#: lib/cli/args.py:1088 -msgid "Disables TensorBoard logging. NB: Disabling logs means that you will not be able to use the graph or analysis for this session in the GUI." +#: lib/cli/args.py:1123 +msgid "" +"Disables TensorBoard logging. NB: Disabling logs means that you will not be " +"able to use the graph or analysis for this session in the GUI." msgstr "" -#: lib/cli/args.py:1095 lib/cli/args.py:1104 lib/cli/args.py:1113 -#: lib/cli/args.py:1122 +#: lib/cli/args.py:1130 lib/cli/args.py:1139 lib/cli/args.py:1148 +#: lib/cli/args.py:1157 msgid "augmentation" msgstr "" -#: lib/cli/args.py:1096 -msgid "Warps training faces to closely matched Landmarks from the opposite face-set rather than randomly warping the face. This is the 'dfaker' way of doing warping." +#: lib/cli/args.py:1131 +msgid "" +"Warps training faces to closely matched Landmarks from the opposite face-set " +"rather than randomly warping the face. This is the 'dfaker' way of doing " +"warping." msgstr "" -#: lib/cli/args.py:1105 -msgid "To effectively learn, a random set of images are flipped horizontally. Sometimes it is desirable for this not to occur. Generally this should be left off except for during 'fit training'." +#: lib/cli/args.py:1140 +msgid "" +"To effectively learn, a random set of images are flipped horizontally. " +"Sometimes it is desirable for this not to occur. Generally this should be " +"left off except for during 'fit training'." msgstr "" -#: lib/cli/args.py:1114 -msgid "Color augmentation helps make the model less susceptible to color differences between the A and B sets, at an increased training time cost. Enable this option to disable color augmentation." +#: lib/cli/args.py:1149 +msgid "" +"Color augmentation helps make the model less susceptible to color " +"differences between the A and B sets, at an increased training time cost. " +"Enable this option to disable color augmentation." msgstr "" -#: lib/cli/args.py:1123 -msgid "Warping is integral to training the Neural Network. This option should only be enabled towards the very end of training to try to bring out more detail. Think of it as 'fine-tuning'. Enabling this option from the beginning is likely to kill a model and lead to terrible results." +#: lib/cli/args.py:1158 +msgid "" +"Warping is integral to training the Neural Network. This option should only " +"be enabled towards the very end of training to try to bring out more detail. " +"Think of it as 'fine-tuning'. Enabling this option from the beginning is " +"likely to kill a model and lead to terrible results." msgstr "" -#: lib/cli/args.py:1148 +#: lib/cli/args.py:1183 msgid "Output to Shell console instead of GUI console" msgstr "" - diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo index 509595df4b2932691375bcfd8cfb12c7ea3ad5d8..396f83c558475283b32cce1e10a672d4bebfc42d 100644 GIT binary patch delta 3112 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 diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.po b/locales/ru/LC_MESSAGES/lib.cli.args.po index 2168f0af79..aac0550e9e 100644 --- a/locales/ru/LC_MESSAGES/lib.cli.args.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args.po @@ -5,25 +5,26 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2021-05-17 18:04+0100\n" -"PO-Revision-Date: 2021-05-17 18:11+0100\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-08-05 13:58+0100\n" +"PO-Revision-Date: 2022-08-05 14:07+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "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" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 2.4.3\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.0.1\n" -#: lib/cli/args.py:177 lib/cli/args.py:187 lib/cli/args.py:195 -#: lib/cli/args.py:205 +#: lib/cli/args.py:193 lib/cli/args.py:203 lib/cli/args.py:211 +#: lib/cli/args.py:221 msgid "Global Options" msgstr "Общие настройки" -#: lib/cli/args.py:178 +#: lib/cli/args.py:194 msgid "" "R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " "to any GPU(s) that you do not wish to be made available to Faceswap. " @@ -35,13 +36,13 @@ msgstr "" "GPU Faceswap будет работать в режиме CPU.\n" "L|{}" -#: lib/cli/args.py:188 +#: lib/cli/args.py:204 msgid "" "Optionally overide the saved config with the path to a custom config file." msgstr "" "Переназначить путь к файлу конфигурации пользовательским. (Необязательно)" -#: lib/cli/args.py:196 +#: lib/cli/args.py:212 msgid "" "Log level. Stick with INFO or VERBOSE unless you need to file an error " "report. Be careful with TRACE as it will generate a lot of data" @@ -50,18 +51,18 @@ msgstr "" "случаев когда вам нужно отправить отчёт об ошибке. Будьте осторожнее при " "указании уровня TRACE, так как будет сгенерировано очень много данных" -#: lib/cli/args.py:206 +#: lib/cli/args.py:222 msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "" "Путь для сохранения файла журнала. Оставьте пустым, чтобы сохранить в папке " "с faceswap" -#: lib/cli/args.py:299 lib/cli/args.py:308 lib/cli/args.py:316 -#: lib/cli/args.py:630 lib/cli/args.py:639 +#: lib/cli/args.py:320 lib/cli/args.py:329 lib/cli/args.py:337 +#: lib/cli/args.py:655 lib/cli/args.py:664 msgid "Data" msgstr "Данные" -#: lib/cli/args.py:300 +#: lib/cli/args.py:321 msgid "" "Input directory or video. Either a directory containing the image files you " "wish to process or path to a video file. NB: This should be the source video/" @@ -71,17 +72,17 @@ msgstr "" "видео файл. Примечание: должно указывать на исходное видео либо набор " "извлеченных кадров, а НЕ уже извлеченных лица." -#: lib/cli/args.py:309 +#: lib/cli/args.py:330 msgid "Output directory. This is where the converted files will be saved." msgstr "Папка для сохранения преобразованных файлов." -#: lib/cli/args.py:317 +#: lib/cli/args.py:338 msgid "" "Optional path to an alignments file. Leave blank if the alignments file is " "at the default location." msgstr "Путь к файлу выравнивания. Оставьте пустым, для пути по умолчанию." -#: lib/cli/args.py:340 +#: lib/cli/args.py:361 msgid "" "Extract faces from image or video sources.\n" "Extraction plugins can be configured in the 'Settings' Menu" @@ -89,13 +90,13 @@ msgstr "" "Извлечь лица из изображений или видео источников.\n" "Плагины извлечения можно настроить в меню 'Настройки'" -#: lib/cli/args.py:365 lib/cli/args.py:381 lib/cli/args.py:393 -#: lib/cli/args.py:428 lib/cli/args.py:446 lib/cli/args.py:458 -#: lib/cli/args.py:649 lib/cli/args.py:676 lib/cli/args.py:712 +#: lib/cli/args.py:386 lib/cli/args.py:402 lib/cli/args.py:414 +#: lib/cli/args.py:453 lib/cli/args.py:471 lib/cli/args.py:483 +#: lib/cli/args.py:674 lib/cli/args.py:701 lib/cli/args.py:739 msgid "Plugins" msgstr "Плагины" -#: lib/cli/args.py:366 +#: lib/cli/args.py:387 msgid "" "R|Detector to use. Some of these have configurable settings in '/config/" "extract.ini' or 'Settings > Configure Extract 'Plugins':\n" @@ -119,7 +120,7 @@ msgstr "" "детектировать лицо в большем кол-ве ситуация и меньшим кол-вом ошибок, чем " "другие GPU, но значительно более требователен к ресурсам." -#: lib/cli/args.py:382 +#: lib/cli/args.py:403 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " @@ -132,7 +133,7 @@ msgstr "" "использовать GPU.\n" "L|fan: Лучший выравниватель. Быстрый на GPU, медленный на CPU." -#: lib/cli/args.py:394 +#: lib/cli/args.py:415 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -141,6 +142,10 @@ msgid "" "L|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).\n" +"L|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 so will not use any additional VRAM.\n" "L|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.\n" @@ -170,6 +175,11 @@ msgstr "" "L|bisenet-fp: Относительно легкая маска на основе NN, которая обеспечивает " "более точный контроль над маскируемой областью, включая полное маскирование " "головы (настраивается в настройках маски).\n" +"L|custom: Пустая маска, которая заполняет область маски всеми единицами или " +"нулями (настраивается в настройках). Это требуется только в том случае, если " +"вы намерены вручную редактировать пользовательские маски в ручном " +"инструменте. Эта маска не использует графический процессор, поэтому не будет " +"использовать дополнительную видеопамять..\n" "L|vgg-clear: Маска предназначена для умной сегментации преимущественно " "фронтальных лиц без препятствий. Фотографии в профиль могут быть обработаны " "посредственно.\n" @@ -189,7 +199,7 @@ msgstr "" "ориентиров лица и расширяется вверх на лоб.\n" "(пример: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -#: lib/cli/args.py:429 +#: lib/cli/args.py:454 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -210,7 +220,7 @@ msgstr "" "L|hist: Выравнивание гистограммы каналов RGB каналов.\n" "L|mean: Усреднение цветов лица." -#: lib/cli/args.py:447 +#: lib/cli/args.py:472 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -225,7 +235,7 @@ msgstr "" "замедления скорости извлечения. Чем больше проходов выравнивания, тем меньше " "микродрожание, но тем дольше идет извлечение." -#: lib/cli/args.py:459 +#: lib/cli/args.py:484 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -237,13 +247,13 @@ msgstr "" "использовать приращения этого размера до 360, либо передайте список чисел, " "чтобы точно указать, какие углы проверять." -#: lib/cli/args.py:471 lib/cli/args.py:481 lib/cli/args.py:494 -#: lib/cli/args.py:508 lib/cli/args.py:749 lib/cli/args.py:763 -#: lib/cli/args.py:776 lib/cli/args.py:790 +#: lib/cli/args.py:496 lib/cli/args.py:506 lib/cli/args.py:519 +#: lib/cli/args.py:533 lib/cli/args.py:776 lib/cli/args.py:790 +#: lib/cli/args.py:803 lib/cli/args.py:817 msgid "Face Processing" msgstr "Обработка лиц" -#: lib/cli/args.py:472 +#: lib/cli/args.py:497 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -251,7 +261,7 @@ msgstr "" "Отбрасывает лица ниже указанного размера. Длина указывается в пикселях по " "диагонали. Установите в 0 для отключения" -#: lib/cli/args.py:482 lib/cli/args.py:764 +#: lib/cli/args.py:507 lib/cli/args.py:791 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -265,7 +275,7 @@ msgstr "" "пробел. Прим.: Фильтрация лиц существенно снижает скорость извлечения, при " "этом точность не гарантируется." -#: lib/cli/args.py:495 lib/cli/args.py:777 +#: lib/cli/args.py:520 lib/cli/args.py:804 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -279,7 +289,7 @@ msgstr "" "изображений через пробел. Прим.: Использование фильтра существенно замедлит " "скорость извлечения. Также точность не гарантируется." -#: lib/cli/args.py:509 lib/cli/args.py:791 +#: lib/cli/args.py:534 lib/cli/args.py:818 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -290,12 +300,12 @@ msgstr "" "лица. Чем ниже значения, тем строже. Прим.: Использование фильтра лиц " "существенно замедлит скорость извлечения. Также точность не гарантируется." -#: lib/cli/args.py:520 lib/cli/args.py:532 lib/cli/args.py:544 -#: lib/cli/args.py:556 +#: lib/cli/args.py:545 lib/cli/args.py:557 lib/cli/args.py:569 +#: lib/cli/args.py:581 msgid "output" msgstr "вывод" -#: lib/cli/args.py:521 +#: lib/cli/args.py:546 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -305,7 +315,7 @@ msgstr "" "поддерживает такой входной размер. Стоит изменять только для моделей " "высокого разрешения." -#: lib/cli/args.py:533 +#: lib/cli/args.py:558 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -315,7 +325,7 @@ msgstr "" "извлечении. Например, значение 1 будет искать лица в каждом кадре, а " "значение 10 в каждом 10том кадре." -#: lib/cli/args.py:545 +#: lib/cli/args.py:570 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -330,17 +340,17 @@ msgstr "" "только во время второго прохода. ВНИМАНИЕ: Не прерывайте выполнение во время " "записи, так как это может повлечь порчу файла. Установите в 0 для выключения" -#: lib/cli/args.py:557 +#: lib/cli/args.py:582 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "Рисовать ландмарки на выходных лицах для нужд отладки." -#: lib/cli/args.py:563 lib/cli/args.py:572 lib/cli/args.py:580 -#: lib/cli/args.py:587 lib/cli/args.py:803 lib/cli/args.py:814 -#: lib/cli/args.py:822 lib/cli/args.py:841 lib/cli/args.py:847 +#: lib/cli/args.py:588 lib/cli/args.py:597 lib/cli/args.py:605 +#: lib/cli/args.py:612 lib/cli/args.py:830 lib/cli/args.py:841 +#: lib/cli/args.py:849 lib/cli/args.py:868 lib/cli/args.py:874 msgid "settings" msgstr "настройки" -#: lib/cli/args.py:564 +#: lib/cli/args.py:589 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " @@ -350,7 +360,7 @@ msgstr "" "стадия извлечения будет запущена отдельно (одна, за другой). Полезно при " "нехватке VRAM." -#: lib/cli/args.py:573 +#: lib/cli/args.py:598 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -358,16 +368,16 @@ msgstr "" "Пропускать кадры, которые уже были извлечены и существуют в файле " "выравнивания" -#: lib/cli/args.py:581 +#: lib/cli/args.py:606 msgid "Skip frames that already have detected faces in the alignments file" msgstr "Пропускать кадры, для которых в файле выравнивания есть найденные лица" -#: lib/cli/args.py:588 +#: lib/cli/args.py:613 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "Не сохранять найденные лица на носитель. Просто создать файл выравнивания" -#: lib/cli/args.py:610 +#: lib/cli/args.py:635 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -375,7 +385,7 @@ msgstr "" "Заменить оригиналы лица в исходном видео/фотографиях новыми.\n" "Плагины конвертации могут быть настроены в меню 'Настройки'" -#: lib/cli/args.py:631 +#: lib/cli/args.py:656 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -385,7 +395,7 @@ msgstr "" "Предоставьте исходное видео, из которого были извлечены кадры (для настройки " "частоты кадров, а также аудио)." -#: lib/cli/args.py:640 +#: lib/cli/args.py:665 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -393,7 +403,7 @@ msgstr "" "Папка с моделью. Папка, содержащая обученную модель, которую вы хотите " "использовать для преобразования." -#: lib/cli/args.py:650 +#: lib/cli/args.py:675 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -432,19 +442,21 @@ msgstr "" "дает удовлетворительных результатов.\n" "L|none: Не производить подгонку цвета." -#: lib/cli/args.py:677 +#: lib/cli/args.py:702 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" "L|none: Don't use a mask.\n" -"L|bisenet-fp-face: Relatively lightweight NN based mask that provides more " +"L|bisenet-fp_face: Relatively lightweight NN based mask that provides more " "refined control over the area to be masked (configurable in mask settings). " "Use this version of bisenet-fp if your model is trained with 'face' or " "'legacy' centering.\n" -"L|bisenet-fp-head: Relatively lightweight NN based mask that provides more " +"L|bisenet-fp_head: Relatively lightweight NN based mask that provides more " "refined control over the area to be masked (configurable in mask settings). " "Use this version of bisenet-fp if your model is trained with 'head' " "centering.\n" +"L|custom_face: Custom user created, face centered mask.\n" +"L|custom_head: Custom user created, head centered mask.\n" "L|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.\n" @@ -480,6 +492,8 @@ msgstr "" "L| components: маска, предназначенная для сегментации лица на основе " "найденных ориентиров. Маска создается построением выпуклого многоугольника " "вокруг внешних ориентиров лица.\n" +"L|custom_face: Маска, созданная пользователем, по центру лица.\n" +"L|custom_head: Маска, созданная пользователем, по центру головы.\n" "L| extended: маска, предназначенная для сегментации лица на основе " "расположения ориентиров. Маска создается построением выпуклого " "многоугольника вокруг внешних ориентиров лица и продолжается вверх на лоб.\n" @@ -497,7 +511,7 @@ msgstr "" "L| predicted: Если во время обучения была включена опция «Learn Mask», будет " "использоваться маска, созданная обученной моделью." -#: lib/cli/args.py:713 +#: lib/cli/args.py:740 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -523,11 +537,12 @@ msgstr "" "L|pillow: [изображения] Более медленный, чем opencv, но имеет больше опций и " "поддерживает больше форматов." -#: lib/cli/args.py:732 lib/cli/args.py:739 lib/cli/args.py:833 +#: lib/cli/args.py:759 lib/cli/args.py:766 lib/cli/args.py:860 msgid "Frame Processing" msgstr "Обработка кадров" -#: lib/cli/args.py:733 +#: lib/cli/args.py:760 +#, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " "at source dimensions. 50%% at half size 200%% at double size" @@ -536,7 +551,7 @@ msgstr "" "кадры в исходном размере. 50%% половина от размера, а 200%% в удвоенном " "размере" -#: lib/cli/args.py:740 +#: lib/cli/args.py:767 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -549,7 +564,7 @@ msgstr "" "unchanged). Прим.: Если при конверсии используются изображения, то имена " "файлов должны заканчиваться номером кадра!" -#: lib/cli/args.py:750 +#: lib/cli/args.py:777 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -565,7 +580,7 @@ msgstr "" "Если оставить это поле пустым, то все лица, которые существуют в файле " "выравниваний будут сконвертированы." -#: lib/cli/args.py:804 +#: lib/cli/args.py:831 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -582,7 +597,7 @@ msgstr "" "будет использоваться больше процессов, чем доступно в вашей системе. Если " "включен одиночный процесс, этот параметр будет проигнорирован." -#: lib/cli/args.py:815 +#: lib/cli/args.py:842 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -590,7 +605,7 @@ msgstr "" "[СОВМЕСТИМОСТЬ] Это нужно выбирать только в том случае, если загружается " "устаревшая модель или если в папке сохранения есть несколько моделей" -#: lib/cli/args.py:823 +#: lib/cli/args.py:850 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -604,7 +619,7 @@ msgstr "" "использованию улучшенного конвейера экстракции и некачественных результатов. " "Если файл выравниваний найден, этот параметр будет проигнорирован." -#: lib/cli/args.py:834 +#: lib/cli/args.py:861 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -612,16 +627,16 @@ msgstr "" "При использовании с --frame-range кадры не попавшие в диапазон выводятся " "неизменными, вместо их пропуска." -#: lib/cli/args.py:842 +#: lib/cli/args.py:869 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Поменять модели местами. Вместо преобразования из A -> B, преобразует B -> A" -#: lib/cli/args.py:848 +#: lib/cli/args.py:875 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Отключить многопроцессорность. Медленнее, но менее ресурсоемко." -#: lib/cli/args.py:864 +#: lib/cli/args.py:891 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -632,11 +647,11 @@ msgstr "" "Обучение моделей может занять долгое время: от 24 часов до недели\n" "Каждую модель можно отдельно настроить в меню «Настройки»" -#: lib/cli/args.py:883 lib/cli/args.py:892 +#: lib/cli/args.py:910 lib/cli/args.py:919 msgid "faces" msgstr "лица" -#: lib/cli/args.py:884 +#: lib/cli/args.py:911 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -645,7 +660,7 @@ msgstr "" "Входная папка. Папка содержащая изображения для тренировки лица A. Это " "исходное лицо т.е. лицо, которое вы хотите убрать, заменив лицом B." -#: lib/cli/args.py:893 +#: lib/cli/args.py:920 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -654,12 +669,12 @@ msgstr "" "Входная папка. Папка содержащая изображения для тренировки лица B. Это новое " "лицо т.е. лицо, которое вы хотите поместить на голову человека A." -#: lib/cli/args.py:901 lib/cli/args.py:913 lib/cli/args.py:929 -#: lib/cli/args.py:954 lib/cli/args.py:964 +#: lib/cli/args.py:928 lib/cli/args.py:940 lib/cli/args.py:956 +#: lib/cli/args.py:981 lib/cli/args.py:991 msgid "model" msgstr "модель" -#: lib/cli/args.py:902 +#: lib/cli/args.py:929 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -673,7 +688,7 @@ msgstr "" "будет создана). Если вы хотите продолжить тренировку, выберите папку с уже " "существующими сохранениями." -#: lib/cli/args.py:914 +#: lib/cli/args.py:941 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -697,7 +712,7 @@ msgstr "" "NB: Вес можно загружать только из моделей того же плагина, который вы " "собираетесь тренировать." -#: lib/cli/args.py:930 +#: lib/cli/args.py:957 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -743,7 +758,7 @@ msgstr "" "ресурсам (Вам потребуется GPU с хорошим количеством видеопамяти). Хороша для " "деталей, но подвержена к неправильной передаче цвета." -#: lib/cli/args.py:955 +#: lib/cli/args.py:982 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -755,7 +770,7 @@ msgstr "" "сводная информация о модели, которая будет создана выбранным плагином, и " "параметрами конфигурации." -#: lib/cli/args.py:965 +#: lib/cli/args.py:992 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -769,12 +784,12 @@ msgstr "" "некоторые модели могут иметь параметры конфигурации для замораживания других " "слоев." -#: lib/cli/args.py:978 lib/cli/args.py:990 lib/cli/args.py:1001 -#: lib/cli/args.py:1087 +#: lib/cli/args.py:1005 lib/cli/args.py:1017 lib/cli/args.py:1028 +#: lib/cli/args.py:1039 lib/cli/args.py:1122 msgid "training" msgstr "тренировка" -#: lib/cli/args.py:979 +#: lib/cli/args.py:1006 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -787,7 +802,7 @@ msgstr "" "изображений в два раза больше этого числа. Увеличение размера партии требует " "больше памяти GPU." -#: lib/cli/args.py:991 +#: lib/cli/args.py:1018 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -801,22 +816,37 @@ msgstr "" "Однако, если вы хотите, чтобы тренировка прервалась после указанного кол-ва " "итерация, вы можете ввести это здесь." -#: lib/cli/args.py:1002 +#: lib/cli/args.py:1029 +msgid "" +"[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " +"Mirrored Distrubution Strategy to train on multiple GPUs." +msgstr "" +"[Устарело — вместо этого используйте ‘-D, --distribution-strategy’] " +"Используйте стратегию зеркального распространения Tensorflow для обучения на " +"нескольких графических процессорах." + +#: lib/cli/args.py:1040 msgid "" -"Use the Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." +"R|Select the distribution stategy to use.\n" +"L|default: Use Tensorflow's default distribution strategy.\n" +"L|central-storage: Centralizes variables on the CPU whilst operations are " +"performed on 1 or more local GPUs. This can help save some VRAM at the cost " +"of some speed by not storing variables on the GPU. Note: Mixed-Precision is " +"not supported on multi-GPU setups.\n" +"L|mirrored: Supports synchronous distributed training across multiple local " +"GPUs. A copy of the model and all variables are loaded onto each GPU with " +"batches distributed to each GPU at each iteration." msgstr "" -"Использовать стратегию зеркального распределения Tensorflow для совместной " -"тренировки сразу на нескольких GPU." -#: lib/cli/args.py:1012 lib/cli/args.py:1022 +#: lib/cli/args.py:1057 lib/cli/args.py:1067 msgid "Saving" msgstr "Сохранение" -#: lib/cli/args.py:1013 +#: lib/cli/args.py:1058 msgid "Sets the number of iterations between each model save." msgstr "Установка количества итераций между сохранениями модели." -#: lib/cli/args.py:1023 +#: lib/cli/args.py:1068 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -824,11 +854,11 @@ msgstr "" "Устанавливает кол-во итераций перед созданием резервной копии модели. " "Установите в 0 для отключения." -#: lib/cli/args.py:1030 lib/cli/args.py:1041 lib/cli/args.py:1052 +#: lib/cli/args.py:1075 lib/cli/args.py:1086 lib/cli/args.py:1097 msgid "timelapse" msgstr "таймлапс" -#: lib/cli/args.py:1031 +#: lib/cli/args.py:1076 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -841,7 +871,7 @@ msgstr "" "папку лиц набора 'A' для использования при создании таймлапса. Вам также " "нужно указать параметры--timelapse-output и --timelapse-input-B." -#: lib/cli/args.py:1042 +#: lib/cli/args.py:1087 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -855,7 +885,7 @@ msgstr "" "таймлапса. Вы также должны указать параметр --timelapse-output и --timelapse-" "input-A." -#: lib/cli/args.py:1053 +#: lib/cli/args.py:1098 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -867,15 +897,15 @@ msgstr "" "указаны только входные папки, то по умолчанию вывод будет сохранен вместе с " "моделью в подкаталог /timelapse/" -#: lib/cli/args.py:1065 lib/cli/args.py:1072 lib/cli/args.py:1079 +#: lib/cli/args.py:1107 lib/cli/args.py:1114 msgid "preview" msgstr "предварительный просмотр" -#: lib/cli/args.py:1073 +#: lib/cli/args.py:1108 msgid "Show training preview output. in a separate window." msgstr "Показывать предварительный просмотр в отдельном окне." -#: lib/cli/args.py:1080 +#: lib/cli/args.py:1115 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -883,7 +913,7 @@ msgstr "" "Записывает результат тренировки в файл. Файл будет сохранен в коренной папке " "FaceSwap." -#: lib/cli/args.py:1088 +#: lib/cli/args.py:1123 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -891,12 +921,12 @@ msgstr "" "Отключает журнал TensorBoard. Примечание: Отключение журналов означает, что " "вы не сможете использовать графики или анализ сессии внутри GUI." -#: lib/cli/args.py:1095 lib/cli/args.py:1104 lib/cli/args.py:1113 -#: lib/cli/args.py:1122 +#: lib/cli/args.py:1130 lib/cli/args.py:1139 lib/cli/args.py:1148 +#: lib/cli/args.py:1157 msgid "augmentation" msgstr "аугментация" -#: lib/cli/args.py:1096 +#: lib/cli/args.py:1131 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -906,7 +936,7 @@ msgstr "" "Ориентирами/Landmarks противоположного набора лиц. Этот способ используется " "пакетом \"dfaker\"." -#: lib/cli/args.py:1105 +#: lib/cli/args.py:1140 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -917,7 +947,7 @@ msgstr "" "происходило. Как правило, эту настройку не стоит трогать, за исключением " "периода «финальной шлифовки»." -#: lib/cli/args.py:1114 +#: lib/cli/args.py:1149 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -927,7 +957,7 @@ msgstr "" "цвета между наборами A and B ценой некоторого замедления скорости " "тренировки. Включите эту опцию для отключения цветовой аугментации." -#: lib/cli/args.py:1123 +#: lib/cli/args.py:1158 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -940,7 +970,7 @@ msgstr "" "Включение этой опции с самого начала может убить модель и привести к ужасным " "результатам." -#: lib/cli/args.py:1148 +#: lib/cli/args.py:1183 msgid "Output to Shell console instead of GUI console" msgstr "Вывод в системную консоль вместо GUI" diff --git a/locales/tools.mask.cli.pot b/locales/tools.mask.cli.pot index 0e3d2c1c09..a1ed571387 100644 --- a/locales/tools.mask.cli.pot +++ b/locales/tools.mask.cli.pot @@ -1,90 +1,132 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR ORGANIZATION +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # +#, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-05-17 18:17+0100\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-08-05 14:00+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" +"Language: \n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=cp1252\n" +"Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" - -#: tools/mask/cli.py:15 +#: /home/matt/faceswap/tools/mask/cli.py:15 msgid "This command lets you generate masks for existing alignments." msgstr "" -#: tools/mask/cli.py:24 +#: /home/matt/faceswap/tools/mask/cli.py:24 msgid "" "Mask tool\n" "Generate masks for existing alignments files." msgstr "" -#: tools/mask/cli.py:32 tools/mask/cli.py:41 tools/mask/cli.py:51 +#: /home/matt/faceswap/tools/mask/cli.py:33 +#: /home/matt/faceswap/tools/mask/cli.py:42 +#: /home/matt/faceswap/tools/mask/cli.py:52 msgid "data" msgstr "" -#: tools/mask/cli.py:35 -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." +#: /home/matt/faceswap/tools/mask/cli.py:36 +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." msgstr "" -#: tools/mask/cli.py:44 +#: /home/matt/faceswap/tools/mask/cli.py:45 msgid "Directory containing extracted faces, source frames, or a video file." msgstr "" -#: tools/mask/cli.py:53 +#: /home/matt/faceswap/tools/mask/cli.py:54 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:62 tools/mask/cli.py:90 +#: /home/matt/faceswap/tools/mask/cli.py:63 +#: /home/matt/faceswap/tools/mask/cli.py:95 msgid "process" msgstr "" -#: tools/mask/cli.py:63 +#: /home/matt/faceswap/tools/mask/cli.py:64 msgid "" "R|Masker to use.\n" -"L|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).\n" -"L|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.\n" -"L|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.\n" -"L|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.\n" -"L|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.\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." -msgstr "" - -#: tools/mask/cli.py:91 +"L|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).\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\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." +msgstr "" + +#: /home/matt/faceswap/tools/mask/cli.py:96 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" +"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." +"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 "" -#: tools/mask/cli.py:104 tools/mask/cli.py:111 tools/mask/cli.py:124 -#: tools/mask/cli.py:137 tools/mask/cli.py:146 +#: /home/matt/faceswap/tools/mask/cli.py:109 +#: /home/matt/faceswap/tools/mask/cli.py:116 +#: /home/matt/faceswap/tools/mask/cli.py:129 +#: /home/matt/faceswap/tools/mask/cli.py:142 +#: /home/matt/faceswap/tools/mask/cli.py:151 msgid "output" msgstr "" -#: tools/mask/cli.py:105 -msgid "Optional output location. If provided, a preview of the masks created will be output in the given folder." +#: /home/matt/faceswap/tools/mask/cli.py:110 +msgid "" +"Optional output location. If provided, a preview of the masks created will " +"be output in the given folder." msgstr "" -#: tools/mask/cli.py:115 -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 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" +#: /home/matt/faceswap/tools/mask/cli.py:120 +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 " +"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" msgstr "" -#: tools/mask/cli.py:128 -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" +#: /home/matt/faceswap/tools/mask/cli.py:133 +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:138 +#: /home/matt/faceswap/tools/mask/cli.py:143 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" @@ -92,7 +134,8 @@ msgid "" "L|mask: Only output the mask as a single channel image." msgstr "" -#: tools/mask/cli.py:147 -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." +#: /home/matt/faceswap/tools/mask/cli.py:152 +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." msgstr "" - diff --git a/plugins/extract/mask/custom.py b/plugins/extract/mask/custom.py new file mode 100644 index 0000000000..cf8a90eddc --- /dev/null +++ b/plugins/extract/mask/custom.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +""" Components Mask for faceswap.py """ + +import numpy as np +from ._base import Masker, logger + + +class Mask(Masker): + """ A mask that fills the whole face area with 1s or 0s (depending on user selected settings) + for custom editing. """ + def __init__(self, **kwargs): + git_model_id = None + model_filename = None + super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.input_size = 256 + self.name = "Custom" + self.vram = 0 # Doesn't use GPU + self.vram_per_batch = 0 + self.batchsize = self.config["batch-size"] + self._storage_centering = self.config["centering"] + # Separate storage for face and head masks + self._storage_name = f"{self._storage_name}_{self._storage_centering}" + + def init_model(self): + logger.debug("No mask model to initialize") + + def process_input(self, batch): + """ Compile the detected faces for prediction """ + batch["feed"] = np.zeros((self.batchsize, self.input_size, self.input_size, 1), + dtype="float32") + return batch + + def predict(self, batch): + """ Run model to get predictions """ + if self.config["fill"]: + batch["feed"][:] = 1.0 + batch["prediction"] = batch["feed"] + return batch + + def process_output(self, batch): + """ Compile found faces for output """ + return batch diff --git a/plugins/extract/mask/custom_defaults.py b/plugins/extract/mask/custom_defaults.py new file mode 100644 index 0000000000..30e5c84fb5 --- /dev/null +++ b/plugins/extract/mask/custom_defaults.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +""" + The default options for the faceswap BiSeNet Face Parsing plugin. + + Defaults files should be named _defaults.py + Any items placed into this file will automatically get added to the relevant config .ini files + within the faceswap/config folder. + + The following variables should be defined: + _HELPTEXT: A string describing what this plugin does + _DEFAULTS: A dictionary containing the options, defaults and meta information. The + dictionary should be defined as: + {: {}} + + should always be lower text. + dictionary requirements are listed below. + + The following keys are expected for the _DEFAULTS dict: + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid data types are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + group: [optional]. A group for grouping options together in the GUI. If not + provided this will not group this option with any others. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and data types this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and data types this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. +""" + + +_HELPTEXT = ( + "Custom (dummy) Mask options..\n" + "The custom mask just fills a face patch with all 0's (masked out) or all 1's (masked in) for " + "later manual editing. It does not use the GPU for creation." + ) + + +_DEFAULTS = { + "batch-size": dict( + default=8, + info="The batch size to use. To a point, higher batch sizes equal better performance, but " + "setting it too high can harm performance.", + datatype=int, + rounding=1, + min_max=(1, 64), + group="settings"), + "centering": dict( + default="face", + info="Whether to create a dummy mask with face or head centering.", + choices=["face", "head"], + datatype=str, + group="settings", + gui_radio=True), + "fill": dict( + default=False, + info="Whether the mask should be filled (True) in which case the custom mask will be " + "created with the whole area masked in (i.e. you would need to manually edit out the " + "background) or unfilled (False) in which case you would need to manually edit in " + "the face.", + datatype=bool, + group="settings", + gui_radio=True, + ), +} diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 5345216c4c..c631a526ee 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -194,9 +194,12 @@ def get_available_extractors(extractor_type, add_none=False, extend_plugin=False if not item.name.startswith("_") and not item.name.endswith("defaults.py") and item.name.endswith(".py")] - if extend_plugin and extractor_type == "mask" and "bisenet-fp" in extractors: - extractors.remove("bisenet-fp") - extractors.extend(["bisenet-fp_face", "bisenet-fp_head"]) + extendable = ["bisenet-fp", "custom"] + if extend_plugin and extractor_type == "mask" and any(ext in extendable + for ext in extractors): + for msk in extendable: + extractors.remove(msk) + extractors.extend([f"{msk}_face", f"{msk}_head"]) extractors = sorted(extractors) if add_none: diff --git a/plugins/train/_config.py b/plugins/train/_config.py index d0fd4a2f23..8ecaacdb42 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -505,16 +505,18 @@ def _set_loss(self) -> None: "exist in the alignments file then it will be generated prior to training " "commencing." "\n\tnone: Don't use a mask." - "\n\tbisenet-fp-face: Relatively lightweight NN based mask that provides more " + "\n\tbisenet-fp_face: Relatively lightweight NN based mask that provides more " "refined control over the area to be masked (configurable in mask settings). " "Use this version of bisenet-fp if your model is trained with 'face' or " "'legacy' centering." - "\n\tbisenet-fp-head: Relatively lightweight NN based mask that provides more " + "\n\tbisenet-fp_head: Relatively lightweight NN based mask that provides more " "refined control over the area to be masked (configurable in mask settings). " "Use this version of bisenet-fp if your model is trained with 'head' centering." "\n\tcomponents: 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." + "\n\tcustom_face: Custom user created, face centered mask." + "\n\tcustom_head: Custom user created, head centered mask." "\n\textended: 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." diff --git a/tools/mask/cli.py b/tools/mask/cli.py index ac0d8be662..a0934cccc7 100644 --- a/tools/mask/cli.py +++ b/tools/mask/cli.py @@ -23,8 +23,9 @@ def get_info(): """ Return command information """ return _("Mask tool\nGenerate masks for existing alignments files.") - def get_argument_list(self): - argument_list = list() + @staticmethod + def get_argument_list(): + argument_list = [] argument_list.append(dict( opts=("-a", "--alignments"), action=FileFullPaths, @@ -67,6 +68,10 @@ def get_argument_list(self): "\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." From 26dde3c19a1e348b1abb58d0a79b4c85fe0a0c95 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 8 Aug 2022 14:29:32 +0100 Subject: [PATCH 687/981] Add CPU option to BiSeNet and MTCNN - Add CPU option to KSession - MTCNN optimizations - Update docs for bisenet + mtcnn --- docs/full/plugins/extract.rst | 43 +- lib/model/session.py | 62 +- plugins/extract/detect/mtcnn.py | 920 ++++++++++++-------- plugins/extract/detect/mtcnn_defaults.py | 7 + plugins/extract/mask/bisenet_fp.py | 16 +- plugins/extract/mask/bisenet_fp_defaults.py | 6 + 6 files changed, 660 insertions(+), 394 deletions(-) diff --git a/docs/full/plugins/extract.rst b/docs/full/plugins/extract.rst index 59da672e2d..aa996bfd9d 100755 --- a/docs/full/plugins/extract.rst +++ b/docs/full/plugins/extract.rst @@ -39,6 +39,29 @@ _base module :undoc-members: :show-inheritance: +align._base module +------------------ + +.. automodule:: plugins.extract.align._base + :members: + :undoc-members: + :show-inheritance: + +vgg\_face2\_keras module +------------------------ + +.. automodule:: plugins.extract.recognition.vgg_face2_keras + :members: + :undoc-members: + :show-inheritance: + + +detect plugins package +====================== + +.. contents:: Contents + :local: + detect._base module ------------------- @@ -47,14 +70,21 @@ detect._base module :undoc-members: :show-inheritance: -align._base module ------------------- +detect.mtcnn module +------------------- -.. automodule:: plugins.extract.align._base +.. automodule:: plugins.extract.detect.mtcnn :members: :undoc-members: :show-inheritance: + +mask plugins package +==================== + +.. contents:: Contents + :local: + mask._base module ----------------- @@ -63,10 +93,9 @@ mask._base module :undoc-members: :show-inheritance: -vgg\_face2\_keras module ------------------------- - -.. automodule:: plugins.extract.recognition.vgg_face2_keras +mask.bisenet_fp module +---------------------- +.. automodule:: plugins.extract.mask.bisenet_fp :members: :undoc-members: :show-inheritance: \ No newline at end of file diff --git a/lib/model/session.py b/lib/model/session.py index b13474f5f0..340b195146 100644 --- a/lib/model/session.py +++ b/lib/model/session.py @@ -1,8 +1,9 @@ #!/usr/bin python3 """ Settings manager for Keras Backend """ +from contextlib import nullcontext import logging -from typing import Callable, List, Optional, Union +from typing import Callable, ContextManager, List, Optional, Union import numpy as np import tensorflow as tf @@ -50,21 +51,25 @@ class KSession(): exclude_gpus: list, optional A list of indices correlating to connected GPUs that Tensorflow should not use. Pass ``None`` to not exclude any GPUs. Default: ``None`` - + cpu_mode: bool, optional + ``True`` run the model on CPU. Default: ``False`` """ def __init__(self, name: str, model_path: str, model_kwargs: Optional[dict] = None, allow_growth: bool = False, - exclude_gpus: Optional[List[int]] = None) -> None: + exclude_gpus: Optional[List[int]] = None, + cpu_mode: bool = False) -> None: logger.trace("Initializing: %s (name: %s, model_path: %s, " # type:ignore - "model_kwargs: %s, allow_growth: %s, exclude_gpus: %s)", + "model_kwargs: %s, allow_growth: %s, exclude_gpus: %s, cpu_mode: %s)", self.__class__.__name__, name, model_path, model_kwargs, allow_growth, - exclude_gpus) + exclude_gpus, cpu_mode) self._name = name self._backend = get_backend() - self._set_session(allow_growth, [] if exclude_gpus is None else exclude_gpus) + self._context = self._set_session(allow_growth, + [] if exclude_gpus is None else exclude_gpus, + cpu_mode) self._model_path = model_path self._model_kwargs = {} if not model_kwargs else model_kwargs self._model: Optional[Model] = None @@ -94,9 +99,10 @@ def predict(self, The predictions from the model """ assert self._model is not None - if self._backend == "amd" and batch_size is not None: - return self._amd_predict_with_optimized_batchsizes(feed, batch_size) - return self._model.predict(feed, verbose=0, batch_size=batch_size) + with self._context: + if self._backend == "amd" and batch_size is not None: + return self._amd_predict_with_optimized_batchsizes(feed, batch_size) + return self._model.predict(feed, verbose=0, batch_size=batch_size) def _amd_predict_with_optimized_batchsizes( self, @@ -133,7 +139,10 @@ def _amd_predict_with_optimized_batchsizes( return np.concatenate(results) return [np.concatenate(x) for x in zip(*results)] - def _set_session(self, allow_growth: bool, exclude_gpus: list) -> None: + def _set_session(self, + allow_growth: bool, + exclude_gpus: list, + cpu_mode: bool) -> ContextManager: """ Sets the backend session options. For AMD backend this does nothing. @@ -152,13 +161,16 @@ def _set_session(self, allow_growth: bool, exclude_gpus: list) -> None: exclude_gpus: list A list of indices correlating to connected GPUs that Tensorflow should not use. Pass ``None`` to not exclude any GPUs + cpu_mode: bool + ``True`` run the model on CPU. Default: ``False`` """ + retval = nullcontext() if self._backend == "amd": - return + return retval if self._backend == "cpu": logger.verbose("Hiding GPUs from Tensorflow") # type:ignore tf.config.set_visible_devices([], "GPU") - return + return retval gpus = tf.config.list_physical_devices('GPU') if exclude_gpus: @@ -171,6 +183,10 @@ def _set_session(self, allow_growth: bool, exclude_gpus: list) -> None: logger.info("Setting allow growth for GPU: %s", gpu) tf.config.experimental.set_memory_growth(gpu, True) + if cpu_mode: + retval = tf.device("/device:cpu:0") + return retval + def load_model(self) -> None: """ Loads a model. @@ -183,9 +199,10 @@ def load_model(self) -> None: it thread safe. """ logger.verbose("Initializing plugin model: %s", self._name) # type:ignore - self._model = k_load_model(self._model_path, compile=False, **self._model_kwargs) - if self._backend != "amd": - self._model.make_predict_function() + with self._context: + self._model = k_load_model(self._model_path, compile=False, **self._model_kwargs) + if self._backend != "amd": + self._model.make_predict_function() def define_model(self, function: Callable) -> None: """ Defines a model from the given function. @@ -199,7 +216,8 @@ def define_model(self, function: Callable) -> None: ``outputs``. The function that generates these results should be passed in, NOT the results themselves, as the function needs to be executed within the correct context. """ - self._model = Model(*function()) + with self._context: + self._model = Model(*function()) def load_model_weights(self) -> None: """ Load model weights for a defined model inside the correct session. @@ -213,9 +231,10 @@ def load_model_weights(self) -> None: """ logger.verbose("Initializing plugin model: %s", self._name) # type:ignore assert self._model is not None - self._model.load_weights(self._model_path) - if self._backend != "amd": - self._model.make_predict_function() + with self._context: + self._model.load_weights(self._model_path) + if self._backend != "amd": + self._model.make_predict_function() def append_softmax_activation(self, layer_index: int = -1) -> None: """ Append a softmax activation layer to a model @@ -231,5 +250,6 @@ def append_softmax_activation(self, layer_index: int = -1) -> None: """ logger.debug("Appending Softmax Activation to model: (layer_index: %s)", layer_index) assert self._model is not None - softmax = Activation("softmax", name="softmax")(self._model.layers[layer_index].output) - self._model = Model(inputs=self._model.input, outputs=[softmax]) + with self._context: + softmax = Activation("softmax", name="softmax")(self._model.layers[layer_index].output) + self._model = Model(inputs=self._model.input, outputs=[softmax]) diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py index ab82c443d5..28d41085e4 100644 --- a/plugins/extract/detect/mtcnn.py +++ b/plugins/extract/detect/mtcnn.py @@ -2,6 +2,7 @@ """ MTCNN Face detection plugin """ from __future__ import absolute_import, division, print_function +from typing import Dict, List, Optional, Tuple, Union import cv2 import numpy as np @@ -18,21 +19,21 @@ class Detect(Detector): - """ MTCNN detector for face recognition """ - def __init__(self, **kwargs): + """ MTCNN detector for face recognition. """ + def __init__(self, **kwargs) -> None: git_model_id = 2 model_filename = ["mtcnn_det_v2.1.h5", "mtcnn_det_v2.2.h5", "mtcnn_det_v2.3.h5"] super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.name = "MTCNN" self.input_size = 640 - self.vram = 320 - self.vram_warnings = 64 # Will run at this with warnings - self.vram_per_batch = 32 + self.vram = 320 if not self.config["cpu"] else 0 + self.vram_warnings = 64 if not self.config["cpu"] else 0 # Will run at this with warnings + self.vram_per_batch = 32 if not self.config["cpu"] else 0 self.batchsize = self.config["batch-size"] - self.kwargs = self.validate_kwargs() + self.kwargs = self._validate_kwargs() self.color_format = "RGB" - def validate_kwargs(self): + def _validate_kwargs(self) -> Dict[str, Union[int, float, List[float]]]: """ Validate that config options are correct. If not reset to default """ valid = True threshold = [self.config["threshold_1"], @@ -40,7 +41,8 @@ def validate_kwargs(self): self.config["threshold_3"]] kwargs = {"minsize": self.config["minsize"], "threshold": threshold, - "factor": self.config["scalefactor"]} + "factor": self.config["scalefactor"], + "input_size": self.input_size} if kwargs["minsize"] < 10: valid = False @@ -50,35 +52,69 @@ def validate_kwargs(self): valid = False if not valid: - kwargs = {"minsize": 20, # minimum size of face - "threshold": [0.6, 0.7, 0.7], # three steps threshold - "factor": 0.709} # scale factor + kwargs = {} logger.warning("Invalid MTCNN options in config. Running with defaults") + logger.debug("Using mtcnn kwargs: %s", kwargs) return kwargs - def init_model(self): - """ Initialize S3FD Model""" + def init_model(self) -> None: + """ Initialize MTCNN Model. """ self.model = MTCNN(self.model_path, self.config["allow_growth"], self._exclude_gpus, + self.config["cpu"], **self.kwargs) - def process_input(self, batch): - """ Compile the detection image(s) for prediction """ + def process_input(self, batch: dict) -> dict: + """ Compile the detection image(s) for prediction + + Parameters + ---------- + batch: dict + Contains the batch that is currently being passed through the plugin process + + Returns + ------- + dict + The batch with input processed + + """ batch["feed"] = (batch["image"] - 127.5) / 127.5 return batch - def predict(self, batch): - """ Run model to get predictions """ + def predict(self, batch: dict) -> dict: + """ Run model to get predictions + + Parameters + ---------- + batch: dict + Contains the batch to pass through the MTCNN model + + Returns + ------- + dict + The batch with the predictions added to the dictionary + """ prediction, points = self.model.detect_faces(batch["feed"]) - logger.trace("filename: %s, prediction: %s, mtcnn_points: %s", + logger.trace("filename: %s, prediction: %s, mtcnn_points: %s", # type:ignore batch["filename"], prediction, points) batch["prediction"], batch["mtcnn_points"] = prediction, points return batch - def process_output(self, batch): - """ Post process the detected faces """ + def process_output(self, batch: dict) -> dict: + """ MTCNN performs no post processing so the original batch is returned + + Parameters + ---------- + batch: dict + Contains the batch to apply postprocessing to + + Returns + ------- + dict + The originally received batch + """ return batch @@ -113,18 +149,57 @@ def process_output(self, batch): class PNet(KSession): - """ Keras P-Net model for MTCNN """ - def __init__(self, model_path, allow_growth, exclude_gpus): + """ Keras P-Net model for MTCNN + + Parameters + ---------- + model_path: str + The path to the keras model file + allow_growth: bool, optional + Enable the Tensorflow GPU allow_growth configuration option. This option prevents + Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation and + slower performance. Default: ``False`` + exclude_gpus: list, optional + A list of indices correlating to connected GPUs that Tensorflow should not use. Pass + ``None`` to not exclude any GPUs. Default: ``None`` + cpu_mode: bool, optional + ``True`` run the model on CPU. Default: ``False`` + input_size: int + The input size of the model + minsize: int, optional + The minimum size of a face to accept as a detection. Default: `20` + threshold: list, optional + Threshold for P-Net + """ + def __init__(self, + model_path: str, + allow_growth: bool, + exclude_gpus: List[int], + cpu_mode: bool, + input_size: int, + min_size: int, + factor: float, + threshold: float) -> None: super().__init__("MTCNN-PNet", model_path, allow_growth=allow_growth, - exclude_gpus=exclude_gpus) + exclude_gpus=exclude_gpus, + cpu_mode=cpu_mode) + self.define_model(self.model_definition) self.load_model_weights() + self._input_size = input_size + self._threshold = threshold + + self._pnet_scales = self._calculate_scales(min_size, factor) + self._pnet_sizes = [(int(input_size * scale), int(input_size * scale)) + for scale in self._pnet_scales] + self._pnet_input: Optional[List[np.ndarray]] = None + @staticmethod def model_definition(): - """ Keras P-Network for MTCNN """ + """ Keras P-Network Definition for MTCNN """ input_ = Input(shape=(None, None, 3)) var_x = Conv2D(10, (3, 3), strides=1, padding='valid', name='conv1')(input_) var_x = PReLU(shared_axes=[1, 2], name='PReLU1')(var_x) @@ -137,20 +212,166 @@ def model_definition(): bbox_regress = Conv2D(4, (1, 1), name='conv4-2')(var_x) return [input_], [classifier, bbox_regress] + def _calculate_scales(self, + minsize: int, + factor: float) -> List[float]: + """ Calculate multi-scale + + Parameters + ---------- + minsize: int + Minimum size for a face to be accepted + factor: float + Scaling factor + + Returns + ------- + list + List of scale floats + """ + factor_count = 0 + var_m = 12.0 / minsize + minl = self._input_size * var_m + # create scale pyramid + scales = [] + while minl >= 12: + scales += [var_m * np.power(factor, factor_count)] + minl = minl * factor + factor_count += 1 + logger.trace(scales) # type:ignore + return scales + + def __call__(self, images: np.ndarray) -> List[np.ndarray]: + """ first stage - fast proposal network (p-net) to obtain face candidates + + Parameters + ---------- + images: :class:`numpy.ndarray` + The batch of images to detect faces in + + Returns + ------- + List + List of face candidates from P-Net + """ + batch_size = images.shape[0] + rectangles: List[List[List[Union[int, float]]]] = [[] for _ in range(batch_size)] + scores: List[List[np.ndarray]] = [[] for _ in range(batch_size)] + + if self._pnet_input is None: + self._pnet_input = [np.empty((batch_size, rheight, rwidth, 3), dtype="float32") + for rheight, rwidth in self._pnet_sizes] + + for scale, batch, (rheight, rwidth) in zip(self._pnet_scales, + self._pnet_input, + self._pnet_sizes): + _ = [cv2.resize(images[idx], (rwidth, rheight), dst=batch[idx]) + for idx in range(batch_size)] + cls_prob, roi = self.predict(batch) + cls_prob = cls_prob[..., 1] + out_side = max(cls_prob.shape[1:3]) + cls_prob = np.swapaxes(cls_prob, 1, 2) + roi = np.swapaxes(roi, 1, 3) + for idx in range(batch_size): + # first index 0 = class score, 1 = one hot representation + rect, score = self._detect_face_12net(cls_prob[idx, ...], + roi[idx, ...], + out_side, + 1 / scale) + rectangles[idx].extend(rect) + scores[idx].extend(score) + + return [nms(np.array(rect), np.array(score), 0.7, "iou")[0] # don't output scores + for rect, score in zip(rectangles, scores)] + + def _detect_face_12net(self, + class_probabilities: np.ndarray, + roi: np.ndarray, + size: int, + scale: float) -> Tuple[np.ndarray, np.ndarray]: + """ Detect face position and calibrate bounding box on 12net feature map(matrix version) + + Parameters + ---------- + class_probabilities: :class:`numpy.ndarray` + softmax feature map for face classify + roi: :class:`numpy.ndarray` + feature map for regression + size: int + feature map's largest size + scale: float + current input image scale in multi-scales + + Returns + ------- + list + Calibrated face candidates + """ + in_side = 2 * size + 11 + stride = 0. if size == 1 else float(in_side - 12) / (size - 1) + (var_x, var_y) = np.nonzero(class_probabilities >= self._threshold) + boundingbox = np.array([var_x, var_y]).T + + boundingbox = np.concatenate((np.fix((stride * (boundingbox) + 0) * scale), + np.fix((stride * (boundingbox) + 11) * scale)), axis=1) + offset = roi[:4, var_x, var_y].T + boundingbox = boundingbox + offset * 12.0 * scale + rectangles = np.concatenate((boundingbox, + np.array([class_probabilities[var_x, var_y]]).T), axis=1) + rectangles = rect2square(rectangles) + + np.clip(rectangles[..., :4], 0., self._input_size, out=rectangles[..., :4]) + pick = np.where(np.logical_and(rectangles[..., 2] > rectangles[..., 0], + rectangles[..., 3] > rectangles[..., 1]))[0] + rects = rectangles[pick, :4].astype("int") + scores = rectangles[pick, 4] + + return nms(rects, scores, 0.3, "iou") + class RNet(KSession): - """ Keras R-Net model for MTCNN """ - def __init__(self, model_path, allow_growth, exclude_gpus): + """ Keras R-Net model Definition for MTCNN + + Parameters + ---------- + model_path: str + The path to the keras model file + allow_growth: bool, optional + Enable the Tensorflow GPU allow_growth configuration option. This option prevents + Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation and + slower performance. Default: ``False`` + exclude_gpus: list, optional + A list of indices correlating to connected GPUs that Tensorflow should not use. Pass + ``None`` to not exclude any GPUs. Default: ``None`` + cpu_mode: bool, optional + ``True`` run the model on CPU. Default: ``False`` + input_size: int + The input size of the model + threshold: list, optional + Threshold for R-Net + + """ + def __init__(self, + model_path: str, + allow_growth: bool, + exclude_gpus: List[int], + cpu_mode: bool, + input_size: int, + threshold: float) -> None: super().__init__("MTCNN-RNet", model_path, allow_growth=allow_growth, - exclude_gpus=exclude_gpus) + exclude_gpus=exclude_gpus, + cpu_mode=cpu_mode) self.define_model(self.model_definition) self.load_model_weights() + self._input_size = input_size + self._threshold = threshold + @staticmethod def model_definition(): - """ Keras R-Network for MTCNN """ + """ Keras R-Network Definition for MTCNN """ input_ = Input(shape=(24, 24, 3)) var_x = Conv2D(28, (3, 3), strides=1, padding='valid', name='conv1')(input_) var_x = PReLU(shared_axes=[1, 2], name='prelu1')(var_x) @@ -170,17 +391,115 @@ def model_definition(): bbox_regress = Dense(4, name='conv5-2')(var_x) return [input_], [classifier, bbox_regress] + def __call__(self, + images: np.ndarray, + rectangle_batch: List[np.ndarray], + ) -> List[np.ndarray]: + """ second stage - refinement of face candidates with r-net + + Parameters + ---------- + images: :class:`numpy.ndarray` + The batch of images to detect faces in + rectangle_batch: + List of :class:`numpy.ndarray` face candidates from P-Net + + Returns + ------- + List + List of :class:`numpy.ndarray` refined face candidates from R-Net + """ + ret: List[np.ndarray] = [] + for idx, (rectangles, image) in enumerate(zip(rectangle_batch, images)): + if not np.any(rectangles): + ret.append(np.array([])) + continue + + feed_batch = np.empty((rectangles.shape[0], 24, 24, 3), dtype="float32") + + _ = [cv2.resize(image[rect[1]: rect[3], rect[0]: rect[2]], + (24, 24), + dst=feed_batch[idx]) + for idx, rect in enumerate(rectangles)] + + cls_prob, roi_prob = self.predict(feed_batch, + batch_size=128 if get_backend() == "amd" else None) + ret.append(self._filter_face_24net(cls_prob, roi_prob, rectangles)) + return ret + + def _filter_face_24net(self, + class_probabilities: np.ndarray, + roi: np.ndarray, + rectangles: np.ndarray, + ) -> np.ndarray: + """ Filter face position and calibrate bounding box on 12net's output + + Parameters + ---------- + class_probabilities: class:`np.ndarray` + Softmax feature map for face classify + roi: :class:`numpy.ndarray` + Feature map for regression + rectangles: list + 12net's predict + + Returns + ------- + list + rectangles in the format [[x, y, x1, y1, score]] + """ + prob = class_probabilities[:, 1] + pick = np.nonzero(prob >= self._threshold) + + bbox = rectangles.T[:4, pick] + scores = np.array([prob[pick]]).T.ravel() + deltas = roi.T[:4, pick] + + dims = np.tile([bbox[2] - bbox[0], bbox[3] - bbox[1]], (2, 1, 1)) + bbox = np.transpose(bbox + deltas * dims).reshape(-1, 4) + bbox = np.clip(rect2square(bbox), 0, self._input_size).astype("int") + return nms(bbox, scores, 0.3, "iou")[0] + class ONet(KSession): - """ Keras O-Net model for MTCNN """ - def __init__(self, model_path, allow_growth, exclude_gpus): + """ Keras O-Net model for MTCNN + + Parameters + ---------- + model_path: str + The path to the keras model file + allow_growth: bool, optional + Enable the Tensorflow GPU allow_growth configuration option. This option prevents + Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation and + slower performance. Default: ``False`` + exclude_gpus: list, optional + A list of indices correlating to connected GPUs that Tensorflow should not use. Pass + ``None`` to not exclude any GPUs. Default: ``None`` + cpu_mode: bool, optional + ``True`` run the model on CPU. Default: ``False`` + input_size: int + The input size of the model + threshold: list, optional + Threshold for O-Net + """ + def __init__(self, + model_path: str, + allow_growth: bool, + exclude_gpus: List[int], + cpu_mode: bool, + input_size: int, + threshold: float) -> None: super().__init__("MTCNN-ONet", model_path, allow_growth=allow_growth, - exclude_gpus=exclude_gpus) + exclude_gpus=exclude_gpus, + cpu_mode=cpu_mode) self.define_model(self.model_definition) self.load_model_weights() + self._input_size = input_size + self._threshold = threshold + @staticmethod def model_definition(): """ Keras O-Network for MTCNN """ @@ -206,357 +525,238 @@ def model_definition(): landmark_regress = Dense(10, name='conv6-3')(var_x) return [input_], [classifier, bbox_regress, landmark_regress] + def __call__(self, + images: np.ndarray, + rectangle_batch: List[np.ndarray] + ) -> List[Tuple[np.ndarray, np.ndarray]]: + """ Third stage - further refinement and facial landmarks positions with o-net + + Parameters + ---------- + images: :class:`numpy.ndarray` + The batch of images to detect faces in + rectangle_batch: + List of :class:`numpy.ndarray` face candidates from R-Net + + Returns + ------- + List + List of refined final candidates, scores and landmark points from O-Net + """ + ret: List[Tuple[np.ndarray, np.ndarray]] = [] + for idx, rectangles in enumerate(rectangle_batch): + if not np.any(rectangles): + ret.append((np.empty((0, 5)), np.empty(0))) + continue + image = images[idx] + feed_batch = np.empty((rectangles.shape[0], 48, 48, 3), dtype="float32") -class MTCNN(): - """ MTCNN Detector for face alignment """ - # TODO Batching for r-net and o-net + _ = [cv2.resize(image[rect[1]: rect[3], rect[0]: rect[2]], + (48, 48), + dst=feed_batch[idx]) + for idx, rect in enumerate(rectangles)] - def __init__(self, model_path, allow_growth, exclude_gpus, minsize, threshold, factor): - """ - minsize: minimum faces' size - threshold: threshold=[th1, th2, th3], th1-3 are three steps threshold - factor: the factor used to create a scaling pyramid of face sizes to - detect in the image. - p-net, r-net, o-net: caffemodel + cls_probs, roi_probs, pts_probs = self.predict( + feed_batch, + batch_size=128 if get_backend() == "amd" else None) + ret.append(self._filter_face_48net(cls_probs, roi_probs, pts_probs, rectangles)) + return ret + + def _filter_face_48net(self, class_probabilities: np.ndarray, + roi: np.ndarray, + points: np.ndarray, + rectangles: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """ Filter face position and calibrate bounding box on 12net's output + + Parameters + ---------- + class_probabilities: :class:`numpy.ndarray` : class_probabilities[1] is face possibility + Array of face probabilities + roi: :class:`numpy.ndarray` + offset + points: :class:`numpy.ndarray` + 5 point face landmark + rectangles: :class:`numpy.ndarray` + 12net's predict, rectangles[i][0:3] is the position, rectangles[i][4] is score + + Returns + ------- + boxes: :class:`numpy.ndarray` + The [l, t, r, b, score] bounding boxes + points: :class:`numpy.ndarray` + The 5 point landmarks """ + prob = class_probabilities[:, 1] + pick = np.nonzero(prob >= self._threshold)[0] + scores = np.array([prob[pick]]).T.ravel() + + bbox = rectangles[pick] + dims = np.array([bbox[..., 2] - bbox[..., 0], bbox[..., 3] - bbox[..., 1]]).T + + pts = np.vstack( + np.hsplit(points[pick], 2)).reshape(2, -1, 5).transpose(1, 2, 0).reshape(-1, 10) + pts = np.tile(dims, (1, 5)) * pts + np.tile(bbox[..., :2], (1, 5)) + + bbox = np.clip(np.floor(bbox + roi[pick] * np.tile(dims, (1, 2))), + 0., + self._input_size) + + indices = np.where( + np.logical_and(bbox[..., 2] > bbox[..., 0], bbox[..., 3] > bbox[..., 1]))[0] + picks = np.concatenate([bbox[indices], pts[indices]], axis=-1) + + results, scores = nms(picks, scores, 0.3, "iom") + return np.concatenate([results[..., :4], scores[..., None]], axis=-1), results[..., 4:].T + + +class MTCNN(): # pylint: disable=too-few-public-methods + """ MTCNN Detector for face alignment + + Parameters + ---------- + model_path: list + List of paths to the 3 MTCNN subnet weights + allow_growth: bool, optional + Enable the Tensorflow GPU allow_growth configuration option. This option prevents + Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation and + slower performance. Default: ``False`` + exclude_gpus: list, optional + A list of indices correlating to connected GPUs that Tensorflow should not use. Pass + ``None`` to not exclude any GPUs. Default: ``None`` + cpu_mode: bool, optional + ``True`` run the model on CPU. Default: ``False`` + input_size: int, optional + The height, width input size to the model. Default: 640 + minsize: int, optional + The minimum size of a face to accept as a detection. Default: `20` + threshold: list, optional + List of floats for the three steps, Default: `[0.6, 0.7, 0.7]` + factor: float, optional + The factor used to create a scaling pyramid of face sizes to detect in the image. + Default: `0.709` + """ + def __init__(self, + model_path: List[str], + allow_growth: bool, + exclude_gpus: List[int], + cpu_mode: bool, + input_size: int = 640, + minsize: int = 20, + threshold: Optional[List[float]] = None, + factor: float = 0.709) -> None: logger.debug("Initializing: %s: (model_path: '%s', allow_growth: %s, exclude_gpus: %s, " - "minsize: %s, threshold: %s, factor: %s)", self.__class__.__name__, - model_path, allow_growth, exclude_gpus, minsize, threshold, factor) - self.minsize = minsize - self.threshold = threshold - self.factor = factor - - self.pnet = PNet(model_path[0], allow_growth, exclude_gpus) - self.rnet = RNet(model_path[1], allow_growth, exclude_gpus) - self.onet = ONet(model_path[2], allow_growth, exclude_gpus) - self._pnet_scales = None + "input_size: %s, minsize: %s, threshold: %s, factor: %s)", + self.__class__.__name__, model_path, allow_growth, exclude_gpus, + input_size, minsize, threshold, factor) + + threshold = [0.6, 0.7, 0.7] if threshold is None else threshold + self._pnet = PNet(model_path[0], + allow_growth, + exclude_gpus, + cpu_mode, + input_size, + minsize, + factor, + threshold[0]) + self._rnet = RNet(model_path[1], + allow_growth, + exclude_gpus, + cpu_mode, + input_size, + threshold[1]) + self._onet = ONet(model_path[2], + allow_growth, + exclude_gpus, + cpu_mode, + input_size, + threshold[2]) + logger.debug("Initialized: %s", self.__class__.__name__) - def detect_faces(self, batch): + def detect_faces(self, batch: np.ndarray) -> Tuple[List[np.ndarray], List[np.ndarray]]: """Detects faces in an image, and returns bounding boxes and points for them. - batch: input batch + + Parameters + ---------- + batch: :class:`numpy.ndarray` + The input batch of images to detect face in + + Returns + ------- + List + list of numpy arrays containing the bounding box and 5 point landmarks + of detected faces """ - origin_h, origin_w = batch.shape[1:3] - rectangles = self.detect_pnet(batch, origin_h, origin_w) - rectangles = self.detect_rnet(batch, rectangles, origin_h, origin_w) - rectangles = self.detect_onet(batch, rectangles, origin_h, origin_w) - ret_boxes = [] - ret_points = [] - for rects in rectangles: - if rects: - total_boxes = np.array([result[:5] for result in rects]) - points = np.array([result[5:] for result in rects]).T - else: - total_boxes = np.empty((0, 9)) - points = np.empty(0) - ret_boxes.append(total_boxes) - ret_points.append(points) + rectangles = self._pnet(batch) + rectangles = self._rnet(batch, rectangles) + + ret_boxes, ret_points = zip(*self._onet(batch, rectangles)) return ret_boxes, ret_points - def detect_pnet(self, images, height, width): - # pylint: disable=too-many-locals - """ first stage - fast proposal network (p-net) to obtain face candidates """ - if self._pnet_scales is None: - self._pnet_scales = calculate_scales(height, width, self.minsize, self.factor) - rectangles = [[] for _ in range(images.shape[0])] - batch_items = images.shape[0] - for scale in self._pnet_scales: - rwidth, rheight = int(width * scale), int(height * scale) - batch = np.empty((batch_items, rheight, rwidth, 3), dtype="float32") - for idx in range(batch_items): - batch[idx, ...] = cv2.resize(images[idx, ...], (rwidth, rheight)) - output = self.pnet.predict(batch) - cls_prob = output[0][..., 1] - roi = output[1] - out_h, out_w = cls_prob.shape[1:3] - out_side = max(out_h, out_w) - cls_prob = np.swapaxes(cls_prob, 1, 2) - roi = np.swapaxes(roi, 1, 3) - for idx in range(batch_items): - # first index 0 = class score, 1 = one hot representation - rectangle = detect_face_12net(cls_prob[idx, ...], - roi[idx, ...], - out_side, - 1 / scale, - width, - height, - self.threshold[0]) - rectangles[idx].extend(rectangle) - return [nms(x, 0.7, 'iou') for x in rectangles] - - def detect_rnet(self, images, rectangle_batch, height, width): - """ second stage - refinement of face candidates with r-net """ - ret = [] - # TODO: batching - for idx, rectangles in enumerate(rectangle_batch): - if not rectangles: - ret.append([]) - continue - image = images[idx] - crop_number = 0 - predict_24_batch = [] - for rect in rectangles: - crop_img = image[int(rect[1]):int(rect[3]), int(rect[0]):int(rect[2])] - scale_img = cv2.resize(crop_img, (24, 24)) - predict_24_batch.append(scale_img) - crop_number += 1 - predict_24_batch = np.array(predict_24_batch) - output = self.rnet.predict(predict_24_batch, batch_size=128) - cls_prob = output[0] - cls_prob = np.array(cls_prob) - roi_prob = output[1] - roi_prob = np.array(roi_prob) - ret.append(filter_face_24net( - cls_prob, roi_prob, rectangles, width, height, self.threshold[1] - )) - return ret - def detect_onet(self, images, rectangle_batch, height, width): - """ third stage - further refinement and facial landmarks positions with o-net """ - ret = [] - # TODO: batching - for idx, rectangles in enumerate(rectangle_batch): - if not rectangles: - ret.append([]) - continue - image = images[idx] - crop_number = 0 - predict_batch = [] - for rect in rectangles: - crop_img = image[int(rect[1]):int(rect[3]), int(rect[0]):int(rect[2])] - scale_img = cv2.resize(crop_img, (48, 48)) - predict_batch.append(scale_img) - crop_number += 1 - predict_batch = np.array(predict_batch) - output = self.onet.predict(predict_batch, batch_size=128) - cls_prob = output[0] - roi_prob = output[1] - pts_prob = output[2] # index - ret.append(filter_face_48net( - cls_prob, - roi_prob, - pts_prob, - rectangles, - width, - height, - self.threshold[2] - )) - return ret +def nms(rectangles: np.ndarray, + scores: np.ndarray, + threshold: float, + method: str = "iom") -> Tuple[np.ndarray, np.ndarray]: + """ apply non-maximum suppression on ROIs in same scale(matrix version) + Parameters + ---------- + rectangles: :class:`np.ndarray` + The [b, l, t, r, b] bounding box detection candidates + threshold: float + Threshold for succesful match + method: str, optional + "iom" method or default. Defalt: "iom" + + Returns + ------- + rectangles: :class:`np.ndarray` + The [b, l, t, r, b] bounding boxes + scores :class:`np.ndarray` + The associated scores for the rectangles -def detect_face_12net(cls_prob, roi, out_side, scale, width, height, threshold): - # pylint: disable=too-many-locals, too-many-arguments - """ Detect face position and calibrate bounding box on 12net feature map(matrix version) - Input: - cls_prob : softmax feature map for face classify - roi : feature map for regression - out_side : feature map's largest size - scale : current input image scale in multi-scales - width : image's origin width - height : image's origin height - threshold: 0.6 can have 99% recall rate - """ - in_side = 2*out_side+11 - stride = 0 - if out_side != 1: - stride = float(in_side-12)/(out_side-1) - (var_x, var_y) = np.where(cls_prob >= threshold) - boundingbox = np.array([var_x, var_y]).T - bb1 = np.fix((stride * (boundingbox) + 0) * scale) - bb2 = np.fix((stride * (boundingbox) + 11) * scale) - boundingbox = np.concatenate((bb1, bb2), axis=1) - dx_1 = roi[0][var_x, var_y] - dx_2 = roi[1][var_x, var_y] - dx3 = roi[2][var_x, var_y] - dx4 = roi[3][var_x, var_y] - score = np.array([cls_prob[var_x, var_y]]).T - offset = np.array([dx_1, dx_2, dx3, dx4]).T - boundingbox = boundingbox + offset*12.0*scale - rectangles = np.concatenate((boundingbox, score), axis=1) - rectangles = rect2square(rectangles) - pick = [] - for rect in rectangles: - x_1 = int(max(0, rect[0])) - y_1 = int(max(0, rect[1])) - x_2 = int(min(width, rect[2])) - y_2 = int(min(height, rect[3])) - sc_ = rect[4] - if x_2 > x_1 and y_2 > y_1: - pick.append([x_1, y_1, x_2, y_2, sc_]) - return nms(pick, 0.3, "iou") - - -def filter_face_24net(cls_prob, roi, rectangles, width, height, threshold): - # pylint: disable=too-many-locals, too-many-arguments - """ Filter face position and calibrate bounding box on 12net's output - Input: - cls_prob : softmax feature map for face classify - roi_prob : feature map for regression - rectangles: 12net's predict - width : image's origin width - height : image's origin height - threshold : 0.6 can have 97% recall rate - Output: - rectangles: possible face positions - """ - prob = cls_prob[:, 1] - pick = np.where(prob >= threshold) - rectangles = np.array(rectangles) - x_1 = rectangles[pick, 0] - y_1 = rectangles[pick, 1] - x_2 = rectangles[pick, 2] - y_2 = rectangles[pick, 3] - sc_ = np.array([prob[pick]]).T - dx_1 = roi[pick, 0] - dx_2 = roi[pick, 1] - dx3 = roi[pick, 2] - dx4 = roi[pick, 3] - r_width = x_2-x_1 - r_height = y_2-y_1 - x_1 = np.array([(x_1 + dx_1 * r_width)[0]]).T - y_1 = np.array([(y_1 + dx_2 * r_height)[0]]).T - x_2 = np.array([(x_2 + dx3 * r_width)[0]]).T - y_2 = np.array([(y_2 + dx4 * r_height)[0]]).T - rectangles = np.concatenate((x_1, y_1, x_2, y_2, sc_), axis=1) - rectangles = rect2square(rectangles) - pick = [] - for rect in rectangles: - x_1 = int(max(0, rect[0])) - y_1 = int(max(0, rect[1])) - x_2 = int(min(width, rect[2])) - y_2 = int(min(height, rect[3])) - sc_ = rect[4] - if x_2 > x_1 and y_2 > y_1: - pick.append([x_1, y_1, x_2, y_2, sc_]) - return nms(pick, 0.3, 'iou') - - -def filter_face_48net(cls_prob, roi, pts, rectangles, width, height, threshold): - # pylint: disable=too-many-locals, too-many-arguments - """ Filter face position and calibrate bounding box on 12net's output - Input: - cls_prob : cls_prob[1] is face possibility - roi : roi offset - pts : 5 landmark - rectangles: 12net's predict, rectangles[i][0:3] is the position, rectangles[i][4] is score - width : image's origin width - height : image's origin height - threshold : 0.7 can have 94% recall rate on CelebA-database - Output: - rectangles: face positions and landmarks - """ - prob = cls_prob[:, 1] - pick = np.where(prob >= threshold) - rectangles = np.array(rectangles) - x_1 = rectangles[pick, 0] - y_1 = rectangles[pick, 1] - x_2 = rectangles[pick, 2] - y_2 = rectangles[pick, 3] - sc_ = np.array([prob[pick]]).T - dx_1 = roi[pick, 0] - dx_2 = roi[pick, 1] - dx3 = roi[pick, 2] - dx4 = roi[pick, 3] - r_width = x_2-x_1 - r_height = y_2-y_1 - pts0 = np.array([(r_width * pts[pick, 0] + x_1)[0]]).T - pts1 = np.array([(r_height * pts[pick, 5] + y_1)[0]]).T - pts2 = np.array([(r_width * pts[pick, 1] + x_1)[0]]).T - pts3 = np.array([(r_height * pts[pick, 6] + y_1)[0]]).T - pts4 = np.array([(r_width * pts[pick, 2] + x_1)[0]]).T - pts5 = np.array([(r_height * pts[pick, 7] + y_1)[0]]).T - pts6 = np.array([(r_width * pts[pick, 3] + x_1)[0]]).T - pts7 = np.array([(r_height * pts[pick, 8] + y_1)[0]]).T - pts8 = np.array([(r_width * pts[pick, 4] + x_1)[0]]).T - pts9 = np.array([(r_height * pts[pick, 9] + y_1)[0]]).T - x_1 = np.array([(x_1 + dx_1 * r_width)[0]]).T - y_1 = np.array([(y_1 + dx_2 * r_height)[0]]).T - x_2 = np.array([(x_2 + dx3 * r_width)[0]]).T - y_2 = np.array([(y_2 + dx4 * r_height)[0]]).T - rectangles = np.concatenate((x_1, y_1, x_2, y_2, sc_, - pts0, pts1, pts2, pts3, pts4, pts5, pts6, pts7, pts8, pts9), - axis=1) - pick = [] - for rect in rectangles: - x_1 = int(max(0, rect[0])) - y_1 = int(max(0, rect[1])) - x_2 = int(min(width, rect[2])) - y_2 = int(min(height, rect[3])) - if x_2 > x_1 and y_2 > y_1: - pick.append([x_1, y_1, x_2, y_2, - rect[4], rect[5], rect[6], rect[7], rect[8], rect[9], - rect[10], rect[11], rect[12], rect[13], rect[14]]) - return nms(pick, 0.3, 'iom') - - -def nms(rectangles, threshold, method): - # pylint:disable=too-many-locals - """ apply non-maximum suppression on ROIs in same scale(matrix version) - Input: - rectangles: rectangles[i][0:3] is the position, rectangles[i][4] is score - Output: - rectangles: same as input """ - if not rectangles: - return rectangles - boxes = np.array(rectangles) - x_1 = boxes[:, 0] - y_1 = boxes[:, 1] - x_2 = boxes[:, 2] - y_2 = boxes[:, 3] - var_s = boxes[:, 4] - area = np.multiply(x_2-x_1+1, y_2-y_1+1) - s_sort = np.array(var_s.argsort()) + if not np.any(rectangles): + return rectangles, scores + bboxes = rectangles[..., :4].T + area = np.multiply(bboxes[2] - bboxes[0] + 1, bboxes[3] - bboxes[1] + 1) + s_sort = scores.argsort() + pick = [] while len(s_sort) > 0: - # s_sort[-1] have highest prob score, s_sort[0:-1]->others - xx_1 = np.maximum(x_1[s_sort[-1]], x_1[s_sort[0:-1]]) - yy_1 = np.maximum(y_1[s_sort[-1]], y_1[s_sort[0:-1]]) - xx_2 = np.minimum(x_2[s_sort[-1]], x_2[s_sort[0:-1]]) - yy_2 = np.minimum(y_2[s_sort[-1]], y_2[s_sort[0:-1]]) - width = np.maximum(0.0, xx_2 - xx_1 + 1) - height = np.maximum(0.0, yy_2 - yy_1 + 1) - inter = width * height - if method == 'iom': + s_bboxes = np.concatenate([ # s_sort[-1] have highest prob score, s_sort[0:-1]->others + np.maximum(bboxes[:2, s_sort[-1], None], bboxes[:2, s_sort[0:-1]]), + np.minimum(bboxes[2:, s_sort[-1], None], bboxes[2:, s_sort[0:-1]])], axis=0) + + inter = (np.maximum(0.0, s_bboxes[2] - s_bboxes[0] + 1) * + np.maximum(0.0, s_bboxes[3] - s_bboxes[1] + 1)) + + if method == "iom": var_o = inter / np.minimum(area[s_sort[-1]], area[s_sort[0:-1]]) else: var_o = inter / (area[s_sort[-1]] + area[s_sort[0:-1]] - inter) + pick.append(s_sort[-1]) s_sort = s_sort[np.where(var_o <= threshold)[0]] - result_rectangle = boxes[pick].tolist() - return result_rectangle - - -def calculate_scales(height, width, minsize, factor): - """ Calculate multi-scale - Input: - height: Original image height - width: Original image width - minsize: Minimum size for a face to be accepted - factor: Scaling factor - Output: - scales : Multi-scale - """ - factor_count = 0 - minl = np.amin([height, width]) - var_m = 12.0 / minsize - minl = minl * var_m - # create scale pyramid - scales = [] - while minl >= 12: - scales += [var_m * np.power(factor, factor_count)] - minl = minl * factor - factor_count += 1 - logger.trace(scales) - return scales - - -def rect2square(rectangles): + + result_rectangle = rectangles[pick] + result_scores = scores[pick] + return result_rectangle, result_scores + + +def rect2square(rectangles: np.ndarray) -> np.ndarray: """ change rectangles into squares (matrix version) - Input: - rectangles: rectangles[i][0:3] is the position, rectangles[i][4] is score - Output: - squares: same as input + + Parameters + ---------- + rectangles: :class:`numpy.ndarray` + [b, x, y, x1, y1] rectangles + + Return + ------ + list + Original rectangle changed to a square """ width = rectangles[:, 2] - rectangles[:, 0] height = rectangles[:, 3] - rectangles[:, 1] diff --git a/plugins/extract/detect/mtcnn_defaults.py b/plugins/extract/detect/mtcnn_defaults.py index 4e73eae737..4fa29be9ca 100755 --- a/plugins/extract/detect/mtcnn_defaults.py +++ b/plugins/extract/detect/mtcnn_defaults.py @@ -88,6 +88,13 @@ gui_radio=False, fixed=True, ), + "cpu": dict( + default=True, + info="[Nvidia Only] MTCNN detector still runs fairly quickly on CPU on some setups. " + "Enable CPU mode here to use the CPU for this detector to save some VRAM at a speed " + "cost.", + datatype=bool, + group="settings"), "threshold_1": dict( default=0.6, info="First stage threshold for face detection. This stage obtains face candidates.", diff --git a/plugins/extract/mask/bisenet_fp.py b/plugins/extract/mask/bisenet_fp.py index 8136c3078c..22153255d5 100644 --- a/plugins/extract/mask/bisenet_fp.py +++ b/plugins/extract/mask/bisenet_fp.py @@ -36,9 +36,9 @@ def __init__(self, **kwargs): self.name = "BiSeNet - Face Parsing" self.input_size = 512 self.color_format = "RGB" - self.vram = 2304 - self.vram_warnings = 256 - self.vram_per_batch = 64 + self.vram = 2304 if not self.config["cpu"] else 0 + self.vram_warnings = 256 if not self.config["cpu"] else 0 + self.vram_per_batch = 64 if not self.config["cpu"] else 0 self.batchsize = self.config["batch-size"] self._segment_indices = self._get_segment_indices() @@ -107,7 +107,8 @@ def init_model(self): self.config["allow_growth"], self._exclude_gpus, self.input_size, - lbls) + lbls, + self.config["cpu"]) placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), dtype="float32") @@ -535,12 +536,15 @@ class BiSeNet(KSession): The input size to the model num_classes: int The number of segmentation classes to create + cpu_mode: bool, optional + ``True`` run the model on CPU. Default: ``False`` """ - def __init__(self, model_path, allow_growth, exclude_gpus, input_size, num_classes): + def __init__(self, model_path, allow_growth, exclude_gpus, input_size, num_classes, cpu_mode): super().__init__("BiSeNet Face Parsing", model_path, allow_growth=allow_growth, - exclude_gpus=exclude_gpus) + exclude_gpus=exclude_gpus, + cpu_mode=cpu_mode) self._input_size = input_size self._num_classes = num_classes self._cp = ContextPath() diff --git a/plugins/extract/mask/bisenet_fp_defaults.py b/plugins/extract/mask/bisenet_fp_defaults.py index ab556299e5..cfc2a34c68 100644 --- a/plugins/extract/mask/bisenet_fp_defaults.py +++ b/plugins/extract/mask/bisenet_fp_defaults.py @@ -63,6 +63,12 @@ group="settings", gui_radio=False, fixed=True), + "cpu": dict( + default=False, + info="[Nvidia Only] BiseNet mask still runs fairly quickly on CPU on some setups. Enable " + "CPU mode here to use the CPU for this masker to save some VRAM at a speed cost.", + datatype=bool, + group="settings"), "weights": dict( default="faceswap", info="The trained weights to use.\n" From 39ac1f53a3d08d14d88ac3686b7cf49aa8a1a74f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 10 Aug 2022 17:38:04 +0100 Subject: [PATCH 688/981] setup.py - Fix AMD conflicts --- requirements/_requirements_base.txt | 4 ++-- setup.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index 860803167d..4bcd487e76 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -2,8 +2,8 @@ tqdm>=4.64 psutil>=5.9.0 opencv-python>=4.6.0.0 pillow>=9.2.0 -scikit-learn==1.0.2; python_version < '3.8' -scikit-learn>=1.1.0; python_version >= '3.8' +scikit-learn==1.0.2; python_version < '3.9' # AMD needs version 1.0.2 and 1.1.0 not available in Python 3.7 +scikit-learn>=1.1.0; python_version >= '3.9' fastcluster>=1.2.6 matplotlib>=3.5.1 imageio>=2.19.3 diff --git a/setup.py b/setup.py index f5b0935aee..78355cdbaa 100755 --- a/setup.py +++ b/setup.py @@ -32,9 +32,13 @@ # "opencv-python": ("opencv", "conda-forge"), # Periodic issues with conda-forge opencv "fastcluster": ("fastcluster", "conda-forge"), "imageio-ffmpeg": ("imageio-ffmpeg", "conda-forge"), + "scikit-learn": ("scikit-learn", "conda-forge"), # Exists in Default but is dependency hell "tensorflow-deps": ("tensorflow-deps", "apple"), "libblas": ("libblas", "conda-forge")} +# Packages that should be installed first to prevent version conflicts +_PRIORITY = ["numpy"] + class Environment(): """ The current install environment @@ -720,6 +724,13 @@ def _check_missing_dep(self) -> None: [int(s) for s in spec[1].split(".")]) for spec in specs): self._env.missing_packages.append((key, specs)) + + for priority in reversed(_PRIORITY): + # Put priority packages at beginning of list + package = next((pkg for pkg in self._env.missing_packages if pkg[0] == priority), None) + if package: + idx = self._env.missing_packages.index(package) + self._env.missing_packages.insert(0, self._env.missing_packages.pop(idx)) logger.debug(self._env.missing_packages) def _check_conda_missing_dep(self) -> None: From ee25a31d33d6e443d519e6459de8adb78616a5bd Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 12 Aug 2022 01:46:16 +0100 Subject: [PATCH 689/981] bugfix: setup.py - Fix issue with Conda AMD install --- setup.py | 60 ++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/setup.py b/setup.py index 78355cdbaa..c7b43ca822 100755 --- a/setup.py +++ b/setup.py @@ -71,7 +71,7 @@ def __init__(self, updater: bool = False) -> None: self._output_runtime_info() self._check_pip() self._upgrade_pip() - self._set_ld_library_path() + self._set_env_vars() self.installed_packages = self.get_installed_packages() self.installed_packages.update(self.get_installed_conda_packages()) @@ -342,9 +342,17 @@ def set_config(self) -> None: json.dump(config, cnf) logger.info("Faceswap config written to: %s", config_file) - def _set_ld_library_path(self) -> None: - """ Update the LD_LIBRARY_PATH environment variable when activating a conda environment - and revert it when deactivating. Linux/conda only + def _set_env_vars(self) -> None: + """ There are some foibles under Conda which need to be worked around in different + situations. + + Linux: + Update the LD_LIBRARY_PATH environment variable when activating a conda environment + and revert it when deactivating. + + Windows + AMD + Python 3.8: + Add CONDA_DLL_SEARCH_MODIFICATION_ENABLE=1 environment variable to get around a bug which + prevents SciPy from loading in this config: https://github.com/scipy/scipy/issues/14002 Notes ----- @@ -353,18 +361,25 @@ def _set_ld_library_path(self) -> None: We update the environment variable for all instances using Conda as it shouldn't hurt anything and may help avoid conflicts with globally installed Cuda """ - if not self.is_conda or not self.enable_cuda or self.os_version[0].lower() != "linux": + if not self.is_conda: + return + + linux_update = self.os_version[0].lower() == "linux" and self.enable_cuda + windows_update = (self.os_version[0].lower() == "windows" and + self.enable_amd and (3, 8) <= sys.version_info < (3, 9)) + + if not linux_update and not windows_update: return conda_prefix = os.environ["CONDA_PREFIX"] activate_folder = os.path.join(conda_prefix, "etc", "conda", "activate.d") deactivate_folder = os.path.join(conda_prefix, "etc", "conda", "deactivate.d") - os.makedirs(activate_folder, exist_ok=True) os.makedirs(deactivate_folder, exist_ok=True) - activate_script = os.path.join(conda_prefix, activate_folder, "env_vars.sh") - deactivate_script = os.path.join(conda_prefix, deactivate_folder, "env_vars.sh") + ext = ".bat" if windows_update else ".sh" + activate_script = os.path.join(conda_prefix, activate_folder, f"env_vars{ext}") + deactivate_script = os.path.join(conda_prefix, deactivate_folder, f"env_vars{ext}") if os.path.isfile(activate_script): # Only create file if it does not already exist. There may be instances where people @@ -372,20 +387,27 @@ def _set_ld_library_path(self) -> None: # people should already know what they are doing. return - conda_libs = os.path.join(conda_prefix, "lib") - shebang = "#!/bin/sh\n\n" + if linux_update: + conda_libs = os.path.join(conda_prefix, "lib") + activate = ["#!/bin/sh\n\n", + "export OLD_LD_LIBRARY_PATH=${LD_LIBRARY_PATH}\n", + f"export LD_LIBRARY_PATH='{conda_libs}':${{LD_LIBRARY_PATH}}\n"] + deactivate = ["#!/bin/sh\n\n", + "export LD_LIBRARY_PATH=${OLD_LD_LIBRARY_PATH}\n", + "unset OLD_LD_LIBRARY_PATH\n"] + logger.info("Cuda search path set to '%s'", conda_libs) + + if windows_update: + activate = ["@ECHO OFF\n", + "set CONDA_DLL_SEARCH_MODIFICATION_ENABLE=1\n"] + deactivate = ["@ECHO OFF\n", + "set CONDA_DLL_SEARCH_MODIFICATION_ENABLE=\n"] + logger.verbose("CONDA_DLL_SEARCH_MODIFICATION_ENABLE set to 1") # type: ignore with open(activate_script, "w", encoding="utf8") as afile: - afile.write(f"{shebang}") - afile.write("export OLD_LD_LIBRARY_PATH=${LD_LIBRARY_PATH}\n") - afile.write(f"export LD_LIBRARY_PATH='{conda_libs}':${{LD_LIBRARY_PATH}}\n") - + afile.writelines(activate) with open(deactivate_script, "w", encoding="utf8") as afile: - afile.write(f"{shebang}") - afile.write("export LD_LIBRARY_PATH=${OLD_LD_LIBRARY_PATH}\n") - afile.write("unset OLD_LD_LIBRARY_PATH\n") - - logger.info("Cuda search path set to '%s'", conda_libs) + afile.writelines(deactivate) class Checks(): # pylint:disable=too-few-public-methods From c0d0f04b7036b04ab13ec44fbba4cb3fabb3456b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 18 Aug 2022 19:35:50 +0100 Subject: [PATCH 690/981] bufix: mask plugin: Fix error when no mask is selected --- lib/utils.py | 95 ++++++++++++++++++++++++++++++ plugins/convert/mask/mask_blend.py | 3 +- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/lib/utils.py b/lib/utils.py index e57e3b653d..bdf769cff3 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -13,8 +13,10 @@ from re import finditer from multiprocessing import current_process from socket import timeout as socket_timeout, error as socket_error +from time import time from typing import cast, List, Optional, Union, TYPE_CHECKING +import numpy as np from tqdm import tqdm if sys.version_info < (3, 8): @@ -608,3 +610,96 @@ def _write_model(self, zip_file: zipfile.ZipFile) -> None: out_file.write(buffer) zip_file.close() pbar.close() + + +class DebugTimes(): + """ A simple tool to help debug timings. + """ + def __init__(self): + self._times = {} + self._steps = {} + self._interval = 1 + + def step_start(self, name: str, record: bool = True) -> None: + """ Start the timer for the given step name. + + Parameters + ---------- + name: str + The name of the step to start the timer for + record: bool, optional + ``True`` to record the step time, ``False`` to not record it. + Used for when you have conditional code to time, but do not want to insert if/else + statements in the code. Default: `True` + """ + if not record: + return + self._steps[name] = time() + + def step_end(self, name: str, record: bool = True) -> None: + """ Stop the timer and record elapsed time for the given step name. + + Parameters + ---------- + name: str + The name of the step to end the timer for + record: bool, optional + ``True`` to record the step time, ``False`` to not record it. + Used for when you have conditional code to time, but do not want to insert if/else + statements in the code. Default: `True` + """ + if not record: + return + self._times.setdefault(name, []).append(time() - self._steps.pop(name)) + + @classmethod + def _format_column(cls, text: str, width: int) -> str: + """ Pad the given text to be aligned to the given width. + + Parameters + ---------- + text: str + The text to be formatted + width: int + The size of the column to insert the text into + + Returns + ------- + str + The text with the correct amount of padding applied + """ + return f"{text}{' ' * (width - len(text))}" + + def summary(self, decimal_places: int = 6, interval: int = 1) -> None: + """ Output a summary of step times. + + Parameters + ---------- + decimal_places: int, optional + The number of decimal places to display the summary elapsed times to + interval: int, optional + How many times summary must be called before printing to console. Default: 1 + """ + interval = max(1, interval) + if interval != self._interval: + self._interval += 1 + return + + name_col = max(len(key) for key in self._times) + 4 + items_col = 8 + time_col = decimal_places + 4 + print("") + print("-" * (name_col + items_col + (3 * time_col))) + print(f"{self._format_column('Step', name_col)}{self._format_column('Count', items_col)}" + f"{self._format_column('Min', time_col)}{self._format_column('Avg', time_col)}" + f"{self._format_column('Max', time_col)}") + print("-" * (name_col + items_col + (3 * time_col))) + for key, val in self._times.items(): + _min = f"{np.min(val):.{decimal_places}f}" + avg = f"{np.mean(val):.{decimal_places}f}" + _max = f"{np.max(val):.{decimal_places}f}" + num = str(len(val)) + print(f"{self._format_column(key, name_col)}{self._format_column(num, items_col)}" + f"{self._format_column(_min, time_col)}{self._format_column(avg, time_col)}" + f"{self._format_column(_max, time_col)}") + self._interval = 1 diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index 83477884e8..af4c6fa6b9 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -152,9 +152,10 @@ def run(self, raw_mask = mask.copy() if self._mask_type != "none": - out = self._erode(mask) if self._do_erode else mask out = np.minimum(out, self._box) + else: + out = mask logger.trace( # type: ignore "mask shape: %s, raw_mask shape: %s", mask.shape, raw_mask.shape) From 73111dda31ae78970ea2d1a7260a499ad64df5ef Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 19 Aug 2022 10:13:50 +0100 Subject: [PATCH 691/981] Minor updates: - Update .pylintrc for opencv - Update setup.cfg for scipy - Typing update for lib.utils.DebugTimes --- .pylintrc | 4 ++-- lib/utils.py | 6 +++--- setup.cfg | 2 ++ 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.pylintrc b/.pylintrc index 988c43de78..67b3264633 100644 --- a/.pylintrc +++ b/.pylintrc @@ -346,7 +346,7 @@ contextmanager-decorators=contextlib.contextmanager # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E1101 when accessed. Python regular # expressions are accepted. -generated-members= +generated-members=cv2.* # Tells whether missing members accessed in mixin class should be ignored. A # mixin class is detected if its name ends with "mixin" (case insensitive). @@ -402,7 +402,7 @@ notes=FIXME, max-args=10 # Maximum number of attributes for a class (see R0902). -max-attributes=10 +max-attributes=12 # Maximum number of boolean expressions in an if statement. max-bool-expr=5 diff --git a/lib/utils.py b/lib/utils.py index bdf769cff3..a5469fa5db 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -14,7 +14,7 @@ from multiprocessing import current_process from socket import timeout as socket_timeout, error as socket_error from time import time -from typing import cast, List, Optional, Union, TYPE_CHECKING +from typing import cast, Dict, List, Optional, Union, TYPE_CHECKING import numpy as np from tqdm import tqdm @@ -616,8 +616,8 @@ class DebugTimes(): """ A simple tool to help debug timings. """ def __init__(self): - self._times = {} - self._steps = {} + self._times: Dict[str, List[float]] = {} + self._steps: Dict[str, float] = {} self._interval = 1 def step_start(self, name: str, record: bool = True) -> None: diff --git a/setup.cfg b/setup.cfg index 45baa393c6..1dfb9bba15 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,6 +28,8 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-pynvx.*] ignore_missing_imports = True +[mypy-scipy.*] +ignore_missing_imports = True [mypy-tensorflow.*] ignore_missing_imports = True [mypy-tensorflow_probability.*] From 32950897376b48e0f08b46385602e4df902cf49e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 19 Aug 2022 10:57:40 +0100 Subject: [PATCH 692/981] lib.detected_face.Mask - Add source + target offset and coverage to set_sub_crop method --- lib/align/__init__.py | 3 +- lib/align/aligned_face.py | 130 +++++++++++++++++++--------- lib/align/detected_face.py | 69 ++++++++++----- lib/convert.py | 7 +- lib/training/generator.py | 3 +- plugins/convert/mask/mask_blend.py | 84 ++++++++---------- tools/manual/faceviewer/viewport.py | 3 +- tools/sort/sort.py | 8 +- 8 files changed, 189 insertions(+), 118 deletions(-) diff --git a/lib/align/__init__.py b/lib/align/__init__.py index 12b82f283a..d599199dae 100644 --- a/lib/align/__init__.py +++ b/lib/align/__init__.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """ Package for handling alignments files, detected faces and aligned faces along with their associated objects. """ -from .aligned_face import AlignedFace, _EXTRACT_RATIOS, get_matrix_scaling, get_centered_size, PoseEstimate, transform_image # noqa +from .aligned_face import (AlignedFace, _EXTRACT_RATIOS, get_adjusted_center, # noqa + get_matrix_scaling, get_centered_size, PoseEstimate, transform_image) from .alignments import Alignments # noqa from .detected_face import BlurMask, DetectedFace, Mask, update_legacy_png_header # noqa diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index b5dd48687d..d71ac66943 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -2,13 +2,20 @@ """ Aligner for faceswap.py """ import logging +import sys from threading import Lock import cv2 import numpy as np +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + logger = logging.getLogger(__name__) # pylint: disable=invalid-name +CenteringType = Literal["face", "head", "legacy"] _MEAN_FACE = np.array([[0.010086, 0.106454], [0.085135, 0.038915], [0.191003, 0.018748], [0.300643, 0.034489], [0.403270, 0.077391], [0.596729, 0.077391], @@ -116,6 +123,88 @@ def transform_image(image, matrix, size, padding=0): return retval +def get_adjusted_center(image_size: int, + source_offset: np.ndarray, + target_offset: np.ndarray, + source_centering: CenteringType) -> np.ndarray: + """ Obtain the correct center of a face extracted image to translate between two different + extract centerings. + + Parameters + ---------- + image_size: int + The size of the image at the given :attr:`source_centering` + source_offset: :class:`numpy.ndarray` + The pose offset to translate a base extracted face to source centering + target_offset: :class:`numpy.ndarray` + The pose offset to translate a base extracted face to target centering + source_centering: ["face", "head", "legacy"] + The centering of the source image + + Returns + ------- + :class:`numpy.ndarray` + The center point of the image at the given size for the target centering + """ + source_size = image_size - (image_size * _EXTRACT_RATIOS[source_centering]) + offset = target_offset - source_offset + offset *= source_size + center = np.rint(offset + image_size / 2).astype("int32") + logger.trace("image_size: %s, source_offset: %s, target_offset: %s, " # type: ignore + "source_centering: '%s', adjusted_offset: %s, center: %s", image_size, + source_offset, target_offset, source_centering, offset, center) + return center + + +def get_centered_size(source_centering: CenteringType, + target_centering: CenteringType, + size: int, + coverage_ratio: float = 1.0) -> int: + """ Obtain the size of a cropped face from an aligned image. + + Given an image of a certain dimensions, returns the dimensions of the sub-crop within that + image for the requested centering at the requested coverage ratio + + Notes + ----- + `"legacy"` places the nose in the center of the image (the original method for aligning). + `"face"` aligns for the nose to be in the center of the face (top to bottom) but the center + of the skull for left to right. `"head"` places the center in the middle of the skull in 3D + space. + + The ROI in relation to the source image is calculated by rounding the padding of one side + to the nearest integer then applying this padding to the center of the crop, to ensure that + any dimensions always have an even number of pixels. + + Parameters + ---------- + source_centering: ["head", "face", "legacy"] + The centering that the original image is aligned at + target_centering: ["head", "face", "legacy"] + The centering that the sub-crop size should be obtained for + size: int + The size of the source image to obtain the cropped size for + coverage_ratio: float, optional + The coverage ratio to be applied to the target image. Default: `1.0` + + Returns + ------- + int + The pixel size of a sub-crop image from a full head aligned image with the given coverage + ratio + """ + if source_centering == target_centering and coverage_ratio == 1.0: + retval = size + else: + src_size = size - (size * _EXTRACT_RATIOS[source_centering]) + retval = 2 * int(np.rint((src_size / (1 - _EXTRACT_RATIOS[target_centering]) + * coverage_ratio) / 2)) + logger.trace("source_centering: %s, target_centering: %s, size: %s, " # type: ignore + "coverage_ratio: %s, source_size: %s, crop_size: %s", source_centering, + target_centering, size, coverage_ratio, src_size, retval) + return retval + + class AlignedFace(): """ Class to align a face. @@ -618,47 +707,6 @@ def _get_offset(self): return offset -def get_centered_size(source_centering, target_centering, size): - """ Obtain the size of a cropped face from an aligned image. - - Given an image of a certain dimensions, returns the dimensions of the sub-crop within that - image for the requested centering. - - Notes - ----- - `"legacy"` places the nose in the center of the image (the original method for aligning). - `"face"` aligns for the nose to be in the center of the face (top to bottom) but the center - of the skull for left to right. `"head"` places the center in the middle of the skull in 3D - space. - - The ROI in relation to the source image is calculated by rounding the padding of one side - to the nearest integer then applying this padding to the center of the crop, to ensure that - any dimensions always have an even number of pixels. - - Parameters - ---------- - source_centering: ["head", "face", "legacy"] - The centering that the original image is aligned at - target_centering: ["head", "face", "legacy"] - The centering that the sub-crop size should be obtained for - size: int - The size of the source image to obtain the cropped size for - - Returns - ------- - int - The pixel size of a sub-crop image from a full head aligned image - """ - if source_centering == target_centering: - retval = size - else: - src_size = size - (size * _EXTRACT_RATIOS[source_centering]) - retval = 2 * int(np.rint(src_size / (1 - _EXTRACT_RATIOS[target_centering]) / 2)) - logger.trace("source_centering: %s, target_centering: %s, size: %s, crop_size: %s", - source_centering, target_centering, size, retval) - return retval - - def _umeyama(source, destination, estimate_scale): """Estimate N-D similarity transformation with or without scaling. diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index d82155f161..fb5a9e316e 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -1,9 +1,13 @@ #!/usr/bin python3 """ Face and landmarks detection for faceswap.py """ + import logging +import sys import os from hashlib import sha1 +from typing import Dict, List, TYPE_CHECKING + from zlib import compress, decompress import cv2 @@ -11,7 +15,15 @@ from lib.image import encode_image, read_image from lib.utils import FaceswapError -from . import AlignedFace, _EXTRACT_RATIOS, get_centered_size +from . import AlignedFace, get_adjusted_center, get_centered_size + +if TYPE_CHECKING: + from .aligned_face import CenteringType + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -466,7 +478,8 @@ def __init__(self, storage_size=128, storage_centering="face"): self._blur = dict() self._blur_kernel = 0 self._threshold = 0.0 - self._sub_crop = dict(size=None, slice_in=[], slice_out=[]) + self._sub_crop_size = 0 + self._sub_crop_slices: Dict[Literal["in", "out"], List[slice]] = {} self.set_blur_and_threshold() logger.trace("Initialized: %s", self.__class__.__name__) @@ -485,12 +498,12 @@ def mask(self): mask, self._blur["kernel"], passes=self._blur["passes"]).blurred - if self._sub_crop["size"]: # Crop the mask to the given centering - out = np.zeros((self._sub_crop["size"], self._sub_crop["size"], 1), dtype=mask.dtype) - slice_in, slice_out = self._sub_crop["slice_in"], self._sub_crop["slice_out"] + if self._sub_crop_size: # Crop the mask to the given centering + out = np.zeros((self._sub_crop_size, self._sub_crop_size, 1), dtype=mask.dtype) + slice_in, slice_out = self._sub_crop_slices["in"], self._sub_crop_slices["out"] out[slice_out[0], slice_out[1], :] = mask[slice_in[0], slice_in[1], :] mask = out - logger.trace("mask shape: %s", mask.shape) + logger.trace("mask shape: %s", mask.shape) # type: ignore return mask @property @@ -612,7 +625,11 @@ def set_blur_and_threshold(self, self._blur["passes"] = blur_passes self._threshold = (threshold / 100.0) * 255.0 - def set_sub_crop(self, offset, centering): + def set_sub_crop(self, + source_offset: np.ndarray, + target_offset: np.ndarray, + centering: "CenteringType", + coverage_ratio: float = 1.0) -> None: """ Set the internal crop area of the mask to be returned. This impacts the returned mask from :attr:`mask` if the requested mask is required for @@ -620,31 +637,41 @@ def set_sub_crop(self, offset, centering): Parameters ---------- - offset: :class:`numpy.ndarray` - The (x, y) offset from the center point to return the mask for - centering: str + source_offset: :class:`numpy.ndarray` + The (x, y) offset for the mask at its stored centering + target_offset: :class:`numpy.ndarray` + The (x, y) offset for the mask at the requested target centering + centering: str The centering to set the sub crop area for. One of `"legacy"`, `"face"`. `"head"` + coverage_ratio: float, optional + The coverage ratio to be applied to the target image. ``None`` for default (1.0). + Default: ``None`` """ - if centering == self.stored_centering: + if centering == self.stored_centering and coverage_ratio == 1.0: return - src_size = self.stored_size - (self.stored_size * _EXTRACT_RATIOS[self.stored_centering]) - offset *= ((self.stored_size - (src_size / 2)) / 2) - center = np.rint(offset + self.stored_size / 2).astype("int32") - - crop_size = get_centered_size(self.stored_centering, centering, self.stored_size) + center = get_adjusted_center(self.stored_size, + source_offset, + target_offset, + self.stored_centering) + crop_size = get_centered_size(self.stored_centering, + centering, + self.stored_size, + coverage_ratio=coverage_ratio) roi = np.array([center - crop_size // 2, center + crop_size // 2]).ravel() - self._sub_crop["size"] = crop_size - self._sub_crop["slice_in"] = [slice(max(roi[1], 0), max(roi[3], 0)), - slice(max(roi[0], 0), max(roi[2], 0))] - self._sub_crop["slice_out"] = [ + self._sub_crop_size = crop_size + self._sub_crop_slices["in"] = [slice(max(roi[1], 0), max(roi[3], 0)), + slice(max(roi[0], 0), max(roi[2], 0))] + self._sub_crop_slices["out"] = [ slice(max(roi[1] * -1, 0), crop_size - min(crop_size, max(0, roi[3] - self.stored_size))), slice(max(roi[0] * -1, 0), crop_size - min(crop_size, max(0, roi[2] - self.stored_size)))] - logger.trace("src_size: %s, roi: %s, sub_crop: %s", src_size, roi, self._sub_crop) + logger.trace("src_size: %s, coverage_ratio: %s, sub_crop_size: %s, ", # type: ignore + "sub_crop_slices: %s", roi, coverage_ratio, self._sub_crop_size, + self._sub_crop_slices) def _adjust_affine_matrix(self, mask_size, affine_matrix): """ Adjust the affine matrix for the mask's storage size diff --git a/lib/convert.py b/lib/convert.py index f18ce5f7d3..09cee357c4 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -318,9 +318,10 @@ def _get_image_mask(self, new_face, detected_face, predicted_mask, reference_fac mask_centering = detected_face.mask[self._args.mask_type].stored_centering else: mask_centering = "face" # Unused but requires a valid value - crop_offset = (reference_face.pose.offset[self._centering] - - reference_face.pose.offset[mask_centering]) - mask, raw_mask = self._adjustments["mask"].run(detected_face, crop_offset, self._centering, + mask, raw_mask = self._adjustments["mask"].run(detected_face, + reference_face.pose.offset[mask_centering], + reference_face.pose.offset[self._centering], + self._centering, predicted_mask=predicted_mask) logger.trace("Adding mask to alpha channel") new_face = np.concatenate((new_face, mask), -1) diff --git a/lib/training/generator.py b/lib/training/generator.py index c8bbf2f56c..22b668b994 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -393,7 +393,8 @@ def _add_mask(self, filename, detected_face): threshold=self._config["mask_threshold"]) pose = self._cache[key]["aligned_face"].pose - mask.set_sub_crop(pose.offset[self._centering] - pose.offset[mask.stored_centering], + mask.set_sub_crop(pose.offset[mask.stored_centering], + pose.offset[self._centering], self._centering) logger.trace("Caching mask for: %s", filename) diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index af4c6fa6b9..bcc1ac13ca 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -121,7 +121,8 @@ def _get_box(self, output_size: int) -> np.ndarray: def run(self, detected_face: DetectedFace, - sub_crop_offset: Optional[np.ndarray], + source_offset: np.ndarray, + target_offset: np.ndarray, centering: Literal["legacy", "face", "head"], predicted_mask: Optional[np.ndarray] = None) -> Tuple[np.ndarray, np.ndarray]: """ Obtain the requested mask type and perform any defined mask manipulations. @@ -130,8 +131,10 @@ def run(self, ---------- detected_face: :class:`lib.align.DetectedFace` The DetectedFace object as returned from :class:`scripts.convert.Predictor`. - sub_crop_offset: :class:`numpy.ndarray`, optional - The (x, y) offset to crop the mask from the center point. + source_offset: :class:`numpy.ndarray` + The (x, y) offset for the mask at its stored centering + target_offset: :class:`numpy.ndarray` + The (x, y) offset for the mask at the requested target centering centering: [`"legacy"`, `"face"`, `"head"`] The centering to obtain the mask for predicted_mask: :class:`numpy.ndarray`, optional @@ -146,9 +149,14 @@ def run(self, The mask with no erosion/dilation applied """ logger.trace("Performing mask adjustment: (detected_face: %s, " # type: ignore - "sub_crop_offset: %s, centering: '%s', predicted_mask: %s", - detected_face, sub_crop_offset, centering, predicted_mask is not None) - mask = self._get_mask(detected_face, predicted_mask, centering, sub_crop_offset) + "source_offset: %s, target_offset: %s, centering: '%s', predicted_mask: %s", + detected_face, source_offset, target_offset, centering, + predicted_mask is not None) + mask = self._get_mask(detected_face, + predicted_mask, + centering, + source_offset, + target_offset) raw_mask = mask.copy() if self._mask_type != "none": @@ -165,7 +173,8 @@ def _get_mask(self, detected_face: DetectedFace, predicted_mask: Optional[np.ndarray], centering: Literal["legacy", "face", "head"], - sub_crop_offset: Optional[np.ndarray]) -> np.ndarray: + source_offset: np.ndarray, + target_offset: np.ndarray) -> np.ndarray: """ Return the requested mask with any requested blurring applied. Parameters @@ -177,9 +186,10 @@ def _get_mask(self, with a mask, otherwise ``None`` centering: [`"legacy"`, `"face"`, `"head"`] The centering to obtain the mask for - sub_crop_offset: :class:`numpy.ndarray` - The (x, y) offset to crop the mask from the center point. Set to `None` if the mask - does not need to be offset for alternative centering + source_offset: :class:`numpy.ndarray` + The (x, y) offset for the mask at its stored centering + target_offset: :class:`numpy.ndarray` + The (x, y) offset for the mask at the requested target centering Returns ------- @@ -191,7 +201,7 @@ def _get_mask(self, elif self._mask_type == "predicted" and predicted_mask is not None: mask = self._process_predicted_mask(predicted_mask) else: - mask = self._get_stored_mask(detected_face, centering, sub_crop_offset) + mask = self._get_stored_mask(detected_face, centering, source_offset, target_offset) logger.trace(mask.shape) # type: ignore return mask @@ -209,7 +219,7 @@ def _process_predicted_mask(self, mask: np.ndarray) -> np.ndarray: :class:`numpy.ndarray` The processed predicted mask """ - blur_type = self._config["type"] + blur_type = self._config["type"].lower() if blur_type is not None: mask = BlurMask(blur_type, mask, @@ -220,7 +230,8 @@ def _process_predicted_mask(self, mask: np.ndarray) -> np.ndarray: def _get_stored_mask(self, detected_face: DetectedFace, centering: Literal["legacy", "face", "head"], - sub_crop_offset: Optional[np.ndarray]) -> np.ndarray: + source_offset: np.ndarray, + target_offset: np.ndarray) -> np.ndarray: """ get the requested stored mask from the detected face object. Parameters @@ -229,9 +240,10 @@ def _get_stored_mask(self, The DetectedFace object as returned from :class:`scripts.convert.Predictor`. centering: [`"legacy"`, `"face"`, `"head"`] The centering to obtain the mask for - sub_crop_offset: :class:`numpy.ndarray` - The (x, y) offset to crop the mask from the center point. Set to `None` if the mask - does not need to be offset for alternative centering + source_offset: :class:`numpy.ndarray` + The (x, y) offset for the mask at its stored centering + target_offset: :class:`numpy.ndarray` + The (x, y) offset for the mask at the requested target centering Returns ------- @@ -243,42 +255,18 @@ def _get_stored_mask(self, blur_type=self._config["type"], blur_passes=self._config["passes"], threshold=self._config["threshold"]) - if sub_crop_offset is not None and np.any(sub_crop_offset): - mask.set_sub_crop(sub_crop_offset, centering) - mask = self._crop_to_coverage(mask.mask) - mask_size = mask.shape[0] + mask.set_sub_crop(source_offset, target_offset, centering, self._coverage_ratio) + face_mask = mask.mask + mask_size = face_mask.shape[0] face_size = self._box.shape[0] if mask_size != face_size: interp = cv2.INTER_CUBIC if mask_size < face_size else cv2.INTER_AREA - mask = cv2.resize(mask, - self._box.shape[:2], - interpolation=interp)[..., None].astype("float32") / 255. + face_mask = cv2.resize(face_mask, + self._box.shape[:2], + interpolation=interp)[..., None].astype("float32") / 255. else: - mask = np.float32(mask) / 255. - return mask - - def _crop_to_coverage(self, mask: np.ndarray) -> np.ndarray: - """ Crop the mask to the correct dimensions based on coverage ratio. - - Parameters - ---------- - mask: :class:`numpy.ndarray` - The original mask to be cropped - - Returns - ------- - :class:`numpy.ndarray` - The cropped mask - """ - if self._coverage_ratio == 1.0: - return mask - mask_size = mask.shape[0] - padding = round((mask_size * (1 - self._coverage_ratio)) / 2) - mask_slice = slice(padding, mask_size - padding) - mask = mask[mask_slice, mask_slice, :] - logger.trace("mask_size: %s, coverage: %s, padding: %s, final shape: %s", # type: ignore - mask_size, self._coverage_ratio, padding, mask.shape) - return mask + face_mask = face_mask.astype("float32") / 255. + return face_mask # MASK MANIPULATIONS def _erode(self, mask: np.ndarray) -> np.ndarray: diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index 8c566f9a97..c826d80fdb 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -128,7 +128,8 @@ def _obtain_mask(cls, detected_face, mask_type): return None if mask.stored_centering != "face": face = AlignedFace(detected_face.landmarks_xy) - mask.set_sub_crop(face.pose.offset["face"] - face.pose.offset[mask.stored_centering], + mask.set_sub_crop(face.pose.offset[mask.stored_centering], + face.pose.offset["face"], centering="face") return mask.mask.squeeze() diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 47b7ea2155..662223d945 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -841,7 +841,9 @@ def estimate_blur(cls, image, metadata=None): size=256, is_aligned=True) mask = det_face.mask["components"] - mask.set_sub_crop(aln_face.pose.offset[mask.stored_centering] * -1, centering="legacy") + mask.set_sub_crop(aln_face.pose.offset[mask.stored_centering], + aln_face.pose.offset["legacy"], + centering="legacy") mask = cv2.resize(mask.mask, (256, 256), interpolation=cv2.INTER_CUBIC)[..., None] image = np.minimum(aln_face.face, mask) if image.ndim == 3: @@ -882,7 +884,9 @@ def estimate_blur_fft(cls, image, metadata=None): size=256, is_aligned=True) mask = det_face.mask["components"] - mask.set_sub_crop(aln_face.pose.offset[mask.stored_centering] * -1, centering="legacy") + mask.set_sub_crop(aln_face.pose.offset[mask.stored_centering], + aln_face.pose.offset["legacy"], + centering="legacy") mask = cv2.resize(mask.mask, (256, 256), interpolation=cv2.INTER_CUBIC)[..., None] image = np.minimum(aln_face.face, mask) if image.ndim == 3: From a2de4a97985dc62db3b140a924aeac2be733abf8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 19 Aug 2022 11:53:46 +0100 Subject: [PATCH 693/981] lib.align.aligned_face updates - Typing - Legacy support for pre-aligned faces - Coverage support for pre-aligned faces - Standardized retrieval of sub-crops --- lib/align/aligned_face.py | 453 ++++++++++++++++++++++---------------- 1 file changed, 268 insertions(+), 185 deletions(-) diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index d71ac66943..d993cd129a 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -1,17 +1,20 @@ #!/usr/bin/env python3 """ Aligner for faceswap.py """ +from dataclasses import dataclass, field import logging import sys from threading import Lock +from typing import Dict, Optional, Tuple + import cv2 import numpy as np if sys.version_info < (3, 8): - from typing_extensions import Literal + from typing_extensions import get_args, Literal else: - from typing import Literal + from typing import get_args, Literal logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -65,7 +68,7 @@ _EXTRACT_RATIOS = dict(legacy=0.375, face=0.5, head=0.625) -def get_matrix_scaling(matrix): +def get_matrix_scaling(matrix: np.ndarray) -> Tuple[int, int]: """ Given a matrix, return the cv2 Interpolation method and inverse interpolation method for applying the matrix on an image. @@ -87,11 +90,15 @@ def get_matrix_scaling(matrix): interpolators = cv2.INTER_CUBIC, cv2.INTER_AREA else: interpolators = cv2.INTER_AREA, cv2.INTER_CUBIC - logger.trace("interpolator: %s, inverse interpolator: %s", interpolators[0], interpolators[1]) + logger.trace("interpolator: %s, inverse interpolator: %s", # type: ignore + interpolators[0], interpolators[1]) return interpolators -def transform_image(image, matrix, size, padding=0): +def transform_image(image: np.ndarray, + matrix: np.ndarray, + size: int, + padding: int = 0) -> np.ndarray: """ Perform transformation on an image, applying the given size and padding to the matrix. Parameters @@ -110,7 +117,7 @@ def transform_image(image, matrix, size, padding=0): :class:`numpy.ndarray` The transformed image """ - logger.trace("image shape: %s, matrix: %s, size: %s. padding: %s", + logger.trace("image shape: %s, matrix: %s, size: %s. padding: %s", # type: ignore image.shape, matrix, size, padding) # transform the matrix for size and padding mat = matrix * (size - 2 * padding) @@ -119,7 +126,8 @@ def transform_image(image, matrix, size, padding=0): # transform image interpolators = get_matrix_scaling(mat) retval = cv2.warpAffine(image, mat, (size, size), flags=interpolators[0]) - logger.trace("transformed matrix: %s, final image shape: %s", mat, image.shape) + logger.trace("transformed matrix: %s, final image shape: %s", # type: ignore + mat, image.shape) return retval @@ -205,6 +213,72 @@ def get_centered_size(source_centering: CenteringType, return retval +@dataclass +class _FaceCache: # pylint:disable=too-many-instance-attributes + """ Cache for storing items related to a single aligned face. + + Items are cached so that they are only created the first time they are called. + Each item includes a threading lock to make cache creation thread safe. + + Parameters + ---------- + pose: :class:`lib.align.PoseEstimate`, optional + The estimated pose in 3D space. Default: ``None`` + original_roi: :class:`numpy.ndarray`, optional + The location of the extracted face box within the original frame. Default: ``None`` + landmarks: :class:`numpy.ndarray`, optional + The 68 point facial landmarks aligned to the extracted face box. Default: ``None`` + landmarks_normalized: :class:`numpy.ndarray`: + The 68 point facial landmarks normalized to 0.0 - 1.0 as aligned by Umeyama. + Default: ``None`` + average_distance: float, optional + The average distance of the core landmarks (18-67) from the mean face that was used for + aligning the image. Default: `0.0` + adjusted_matrix: :class:`numpy.ndarray`, optional + The 3x2 transformation matrix for extracting and aligning the core face area out of the + original frame with padding and sizing applied. Default: ``None`` + interpolators: tuple, optional + (`interpolator` and `reverse interpolator`) for the :attr:`adjusted matrix`. + Default: `(0, 0)` + cropped_roi, dict, optional + The (`left`, `top`, `right`, `bottom` location of the region of interest within an + aligned face centered for each centering. Default: `{}` + cropped_slices: dict, optional + The slices for an input full head image and output cropped image. Default: `{}` + """ + pose: Optional["PoseEstimate"] = None + original_roi: Optional[np.ndarray] = None + landmarks: Optional[np.ndarray] = None + landmarks_normalized: Optional[np.ndarray] = None + average_distance: float = 0.0 + adjusted_matrix: Optional[np.ndarray] = None + interpolators: Tuple[int, int] = (0, 0) + cropped_roi: Dict[CenteringType, np.ndarray] = field(default_factory=dict) + cropped_slices: Dict[CenteringType, Dict[Literal["in", "out"], + Tuple[slice, slice]]] = field(default_factory=dict) + + _locks: Dict[str, Lock] = field(default_factory=dict) + + def __post_init__(self): + """ Initialize the locks for the class parameters """ + self._locks = {name: Lock() for name in self.__dict__} + + def lock(self, name: str) -> Lock: + """ Obtain the lock for the given property + + Parameters + ---------- + name: str + The name of a parameter within the cache + + Returns + ------- + :class:`threading.Lock` + The lock associated with the requested parameter + """ + return self._locks[name] + + class AlignedFace(): """ Class to align a face. @@ -236,179 +310,183 @@ class AlignedFace(): is_aligned_face: bool, optional Indicates that the :attr:`image` is an aligned face rather than a frame. Default: ``False`` + is_legacy: bool, optional + Only used if `is_aligned` is ``True``. ``True`` indicates that the aligned image being + loaded is a legacy extracted face rather than a current head extracted face """ - def __init__(self, landmarks, image=None, centering="face", size=64, coverage_ratio=1.0, - dtype=None, is_aligned=False): - logger.trace("Initializing: %s (image shape: %s, centering: '%s', size: %s, " - "coverage_ratio: %s, dtype: %s, is_aligned: %s)", self.__class__.__name__, - image if image is None else image.shape, centering, size, coverage_ratio, - dtype, is_aligned) + def __init__(self, + landmarks: np.ndarray, + image: Optional[np.ndarray] = None, + centering: CenteringType = "face", + size: int = 64, + coverage_ratio: float = 1.0, + dtype: Optional[str] = None, + is_aligned: bool = False, + is_legacy: bool = False) -> None: + logger.trace("Initializing: %s (image shape: %s, centering: '%s', " # type: ignore + "size: %s, coverage_ratio: %s, dtype: %s, is_aligned: %s, is_legacy: %s)", + self.__class__.__name__, image if image is None else image.shape, + centering, size, coverage_ratio, dtype, is_aligned, is_legacy) self._frame_landmarks = landmarks self._centering = centering self._size = size + self._coverage_ratio = coverage_ratio self._dtype = dtype self._is_aligned = is_aligned + self._source_centering: CenteringType = "legacy" if is_legacy and is_aligned else "head" self._matrices = dict(legacy=_umeyama(landmarks[17:], _MEAN_FACE, True)[0:2], - face=None, - head=None) + face=np.array([]), + head=np.array([])) self._padding = self._padding_from_coverage(size, coverage_ratio) - self._cache = self._set_cache() + self._cache = _FaceCache() self._face = self.extract_face(image) - logger.trace("Initialized: %s (matrix: %s, padding: %s, face shape: %s)", + logger.trace("Initialized: %s (matrix: %s, padding: %s, face shape: %s)", # type: ignore self.__class__.__name__, self._matrices["legacy"], self._padding, self._face if self._face is None else self._face.shape) @property - def size(self): + def centering(self) -> Literal["legacy", "head", "face"]: + """ str: The centering of the Aligned Face. One of `"legacy"`, `"head"`, `"face"`. """ + return self._centering + + @property + def size(self) -> int: """ int: The size (in pixels) of one side of the square extracted face image. """ return self._size @property - def padding(self): + def padding(self) -> int: """ int: The amount of padding (in pixels) that is applied to each side of the extracted face image for the selected extract type. """ return self._padding[self._centering] @property - def matrix(self): + def matrix(self) -> np.ndarray: """ :class:`numpy.ndarray`: The 3x2 transformation matrix for extracting and aligning the core face area out of the original frame, with no padding or sizing applied. The returned matrix is offset for the given :attr:`centering`. """ - if self._matrices[self._centering] is None: + if not np.any(self._matrices[self._centering]): matrix = self._matrices["legacy"].copy() matrix[:, 2] -= self.pose.offset[self._centering] self._matrices[self._centering] = matrix - logger.trace("original matrix: %s, new matrix: %s", self._matrices["legacy"], matrix) + logger.trace("original matrix: %s, new matrix: %s", # type: ignore + self._matrices["legacy"], matrix) return self._matrices[self._centering] @property - def _head_size(self): - """ int: The size of the full head extract image calculated from the required - centering. """ - with self._cache["head_size"][1]: - if self._centering not in self._cache["head_size"][0]: - self._cache["head_size"][0][self._centering] = get_centered_size(self._centering, - "head", - self.size) - return self._cache["head_size"][0][self._centering] - - @property - def pose(self): + def pose(self) -> "PoseEstimate": """ :class:`lib.align.PoseEstimate`: The estimated pose in 3D space. """ - with self._cache["pose"][1]: - if self._cache["pose"][0] is None: + with self._cache.lock("pose"): + if self._cache.pose is None: lms = cv2.transform(np.expand_dims(self._frame_landmarks, axis=1), self._matrices["legacy"]).squeeze() - self._cache["pose"][0] = PoseEstimate(lms) - return self._cache["pose"][0] + self._cache.pose = PoseEstimate(lms) + return self._cache.pose @property - def adjusted_matrix(self): + def adjusted_matrix(self) -> np.ndarray: """ :class:`numpy.ndarray`: The 3x2 transformation matrix for extracting and aligning the core face area out of the original frame with padding and sizing applied. """ - with self._cache["adjusted_matrix"][1]: - if self._cache["adjusted_matrix"][0] is None: + with self._cache.lock("adjusted_matrix"): + if self._cache.adjusted_matrix is None: matrix = self.matrix.copy() mat = matrix * (self._size - 2 * self.padding) mat[:, 2] += self.padding - logger.trace("adjusted_matrix: %s", mat) - self._cache["adjusted_matrix"][0] = mat - return self._cache["adjusted_matrix"][0] + logger.trace("adjusted_matrix: %s", mat) # type: ignore + self._cache.adjusted_matrix = mat + return self._cache.adjusted_matrix @property - def face(self): + def face(self) -> Optional[np.ndarray]: """ :class:`numpy.ndarray`: The aligned face at the given :attr:`size` at the specified :attr:`coverage` in the given :attr:`dtype`. If an :attr:`image` has not been provided then an the attribute will return ``None``. """ return self._face @property - def original_roi(self): + def original_roi(self) -> np.ndarray: """ :class:`numpy.ndarray`: The location of the extracted face box within the original frame. """ - with self._cache["original_roi"][1]: - if self._cache["original_roi"][0] is None: + with self._cache.lock("original_roi"): + if self._cache.original_roi is None: roi = np.array([[0, 0], [0, self._size - 1], [self._size - 1, self._size - 1], [self._size - 1, 0]]) roi = np.rint(self.transform_points(roi, invert=True)).astype("int32") - logger.trace("original roi: %s", roi) - self._cache["original_roi"][0] = roi - return self._cache["original_roi"][0] + logger.trace("original roi: %s", roi) # type: ignore + self._cache.original_roi = roi + return self._cache.original_roi[0] @property - def landmarks(self): + def landmarks(self) -> np.ndarray: """ :class:`numpy.ndarray`: The 68 point facial landmarks aligned to the extracted face box. """ - with self._cache["landmarks"][1]: - if self._cache["landmarks"][0] is None: + with self._cache.lock("landmarks"): + if self._cache.landmarks is None: lms = self.transform_points(self._frame_landmarks) - logger.trace("aligned landmarks: %s", lms) - self._cache["landmarks"][0] = lms - return self._cache["landmarks"][0] + logger.trace("aligned landmarks: %s", lms) # type: ignore + self._cache.landmarks = lms + return self._cache.landmarks @property - def normalized_landmarks(self): + def normalized_landmarks(self) -> np.ndarray: """ :class:`numpy.ndarray`: The 68 point facial landmarks normalized to 0.0 - 1.0 as aligned by Umeyama. """ - with self._cache["landmarks_normalized"][1]: - if self._cache["landmarks_normalized"][0] is None: + with self._cache.lock("landmarks_normalized"): + if self._cache.landmarks_normalized is None: lms = np.expand_dims(self._frame_landmarks, axis=1) lms = cv2.transform(lms, self._matrices["legacy"], lms.shape).squeeze() - logger.trace("normalized landmarks: %s", lms) - self._cache["landmarks_normalized"][0] = lms - return self._cache["landmarks_normalized"][0] + logger.trace("normalized landmarks: %s", lms) # type: ignore + self._cache.landmarks_normalized = lms + return self._cache.landmarks_normalized @property - def interpolators(self): + def interpolators(self) -> Tuple[int, int]: """ tuple: (`interpolator` and `reverse interpolator`) for the :attr:`adjusted matrix`. """ - with self._cache["interpolators"][1]: - if self._cache["interpolators"][0] is None: + with self._cache.lock("interpolators"): + if not any(self._cache.interpolators): interpolators = get_matrix_scaling(self.adjusted_matrix) - logger.trace("interpolators: %s", interpolators) - self._cache["interpolators"][0] = interpolators - return self._cache["interpolators"][0] + logger.trace("interpolators: %s", interpolators) # type: ignore + self._cache.interpolators = interpolators + return self._cache.interpolators @property - def average_distance(self): + def average_distance(self) -> float: """ float: The average distance of the core landmarks (18-67) from the mean face that was used for aligning the image. """ - with self._cache["average_distance"][1]: - if self._cache["average_distance"][0] is None: - # pylint:disable=unsubscriptable-object + with self._cache.lock("average_distance"): + if not self._cache.average_distance: average_distance = np.mean(np.abs(self.normalized_landmarks[17:] - _MEAN_FACE)) - logger.trace("average_distance: %s", average_distance) - self._cache["average_distance"][0] = average_distance - return self._cache["average_distance"][0] + logger.trace("average_distance: %s", average_distance) # type: ignore + self._cache.average_distance = average_distance + return self._cache.average_distance @classmethod - def _set_cache(cls): - """ Set the cache items. + def _padding_from_coverage(cls, size: int, coverage_ratio: float) -> Dict[CenteringType, int]: + """ Return the image padding for a face from coverage_ratio set against a + pre-padded training image. - Items are cached so that they are only created the first time they are called. - Each item includes a threading lock to make cache creation thread safe. + Parameters + ---------- + size: int + The final size of the aligned image in pixels + coverage_ratio: float + The ratio of the final image to pad to Returns ------- dict - The Aligned Face cache + The padding required, in pixels for 'head', 'face' and 'legacy' face types """ - return dict(pose=[None, Lock()], - original_roi=[None, Lock()], - landmarks=[None, Lock()], - landmarks_normalized=[None, Lock()], - average_distance=[None, Lock()], - adjusted_matrix=[None, Lock()], - interpolators=[None, Lock()], - head_size=[dict(), Lock()], - cropped_roi=[dict(), Lock()], - cropped_size=[dict(), Lock()], - cropped_slices=[dict(), Lock()]) - - def transform_points(self, points, invert=False): + retval = {_type: round((size * (coverage_ratio - (1 - _EXTRACT_RATIOS[_type]))) / 2) + for _type in get_args(Literal["legacy", "face", "head"])} + logger.trace(retval) # type: ignore + return retval + + def transform_points(self, points: np.ndarray, invert: bool = False) -> np.ndarray: """ Perform transformation on a series of (x, y) co-ordinates in world space into aligned face space. @@ -428,11 +506,11 @@ def transform_points(self, points, invert=False): retval = np.expand_dims(points, axis=1) mat = cv2.invertAffineTransform(self.adjusted_matrix) if invert else self.adjusted_matrix retval = cv2.transform(retval, mat, retval.shape).squeeze() - logger.trace("invert: %s, Original points: %s, transformed points: %s", + logger.trace("invert: %s, Original points: %s, transformed points: %s", # type: ignore invert, points, retval) return retval - def extract_face(self, image): + def extract_face(self, image: Optional[np.ndarray]) -> Optional[np.ndarray]: """ Extract the face from a source image and populate :attr:`face`. If an image is not provided then ``None`` is returned. @@ -449,10 +527,13 @@ def extract_face(self, image): ``None`` if no image has been provided. """ if image is None: - logger.trace("_extract_face called without a loaded image. Returning empty face.") + logger.trace("_extract_face called without a loaded image. " # type: ignore + "Returning empty face.") return None - if self._is_aligned and self._centering != "head": # Crop out the sub face from full head + if self._is_aligned and (self._centering != self._source_centering or + self._coverage_ratio != 1.0): + # Crop out the sub face from full head image = self._convert_centering(image) if self._is_aligned and image.shape[0] != self._size: # Resize the given aligned face @@ -465,13 +546,13 @@ def extract_face(self, image): retval = retval if self._dtype is None else retval.astype(self._dtype) return retval - def _convert_centering(self, image): + def _convert_centering(self, image: np.ndarray) -> np.ndarray: """ When the face being loaded is pre-aligned, the loaded image will have 'head' centering so it needs to be cropped out to the appropriate centering. This function temporarily converts this object to a full head aligned face, extracts the sub-cropped face to the correct centering, reverse the sub crop and returns the cropped - face. + face at the selected coverage ratio. Parameters ---------- @@ -481,50 +562,72 @@ def _convert_centering(self, image): Returns ------- :class:`numpy.ndarray` - The aligned image with the correct centering + The aligned image with the correct centering, scaled to image input size """ - # Input image is sized up because of integer rounding - logger.trace("head_size: %s, image_size: %s, target_size: %s", - self._head_size, image.shape[0], self.size) - if self._head_size != image.shape[0]: - interp = cv2.INTER_CUBIC if image.shape[0] < self._head_size else cv2.INTER_AREA - image = cv2.resize(image, (self._head_size, self._head_size), interpolation=interp) - - out = np.zeros((self.size, self.size, image.shape[-1]), dtype=image.dtype) - slices = self._get_cropped_slices() + logger.trace("image_size: %s, target_size: %s, coverage_ratio: %s", # type: ignore + image.shape[0], self.size, self._coverage_ratio) + + img_size = image.shape[0] + target_size = get_centered_size(self._source_centering, + self._centering, + img_size, + self._coverage_ratio) + out = np.zeros((target_size, target_size, image.shape[-1]), dtype=image.dtype) + + slices = self._get_cropped_slices(img_size, target_size) out[slices["out"][0], slices["out"][1], :] = image[slices["in"][0], slices["in"][1], :] - logger.trace("Cropped from aligned extract: (centering: %s, in shape: %s, out shape: %s)", - self._centering, image.shape, out.shape) + logger.trace("Cropped from aligned extract: (centering: %s, in shape: %s, " # type: ignore + "out shape: %s)", self._centering, image.shape, out.shape) return out - @classmethod - def _padding_from_coverage(cls, size, coverage_ratio): - """ Return the image padding for a face from coverage_ratio set against a - pre-padded training image. + def _get_cropped_slices(self, + image_size: int, + target_size: int, + ) -> Dict[Literal["in", "out"], Tuple[slice, slice]]: + """ Obtain the slices to turn a full head extract into an alternatively centered extract. Parameters ---------- - size: int - The final size of the aligned image in pixels - coverage_ratio: float - The ratio of the final image to pad to + image_size: int + The size of the full head extracted image loaded from disk + target_size: int + The size of the target centered face with coverage ratio applied in relation to the + original image size Returns ------- dict - The padding required, in pixels for 'head', 'face' and 'legacy' face types + The slices for an input full head image and output cropped image """ - retval = {_type: round((size * (coverage_ratio - (1 - _EXTRACT_RATIOS[_type]))) / 2) - for _type in ("legacy", "face", "head")} - logger.trace(retval) - return retval - - def get_cropped_roi(self, centering): + with self._cache.lock("cropped_slices"): + if not self._cache.cropped_slices.get(self._centering): + roi = self.get_cropped_roi(image_size, target_size, self._centering) + slice_in = (slice(max(roi[1], 0), max(roi[3], 0)), + slice(max(roi[0], 0), max(roi[2], 0))) + slice_out = (slice(max(roi[1] * -1, 0), + target_size - min(target_size, max(0, roi[3] - image_size))), + slice(max(roi[0] * -1, 0), + target_size - min(target_size, max(0, roi[2] - image_size)))) + self._cache.cropped_slices[self._centering] = {"in": slice_in, "out": slice_out} + logger.trace("centering: %s, cropped_slices: %s", # type: ignore + self._centering, self._cache.cropped_slices[self._centering]) + return self._cache.cropped_slices[self._centering] + + def get_cropped_roi(self, + image_size: int, + target_size: int, + centering: CenteringType) -> np.ndarray: """ Obtain the region of interest within an aligned face set to centered coverage for an alternative centering Parameters ---------- + image_size: int + The size of the full head extracted image loaded from disk + target_size: int + The size of the target centered face with coverage ratio applied in relation to the + original image size + centering: ["legacy", "face"] The type of centering to obtain the region of interest for. "legacy" places the nose in the center of the image (the original method for aligning). "face" aligns for the @@ -537,42 +640,18 @@ def get_cropped_roi(self, centering): The (`left`, `top`, `right`, `bottom` location of the region of interest within an aligned face centered on the head for the given centering """ - with self._cache["cropped_roi"][1]: - if centering not in self._cache["cropped_roi"][0]: - offset = self.pose.offset.get(centering, np.float32((0, 0))) # legacy = 0.0 - adjusted = offset - self.pose.offset["head"] - adjusted *= (self._head_size - (self._head_size * _EXTRACT_RATIOS["head"])) - - center = np.rint(adjusted + self._head_size / 2).astype("int32") - padding = self.size // 2 + with self._cache.lock("cropped_roi"): + if centering not in self._cache.cropped_roi: + center = get_adjusted_center(image_size, + self.pose.offset[self._source_centering], + self.pose.offset[self.centering], + self._source_centering) + padding = target_size // 2 roi = np.array([center - padding, center + padding]).ravel() - logger.trace("centering: '%s', center: %s, padding: %s, sub roi: %s", - centering, center, padding, roi) - self._cache["cropped_roi"][0][centering] = roi - return self._cache["cropped_roi"][0][centering] - - def _get_cropped_slices(self): - """ Obtain the slices to turn a full head extract into an alternatively centered extract. - - Returns - ------- - dict - The slices for an input full head image and output cropped image - """ - with self._cache["cropped_slices"][1]: - if not self._cache["cropped_slices"][0].get(self._centering): - roi = self.get_cropped_roi(self._centering) - slice_in = [slice(max(roi[1], 0), max(roi[3], 0)), - slice(max(roi[0], 0), max(roi[2], 0))] - slice_out = [slice(max(roi[1] * -1, 0), - self._size - min(self._size, max(0, roi[3] - self._head_size))), - slice(max(roi[0] * -1, 0), - self._size - min(self._size, max(0, roi[2] - self._head_size)))] - self._cache["cropped_slices"][0][self._centering] = {"in": slice_in, - "out": slice_out} - logger.trace("centering: %s, cropped_slices: %s", - self._centering, self._cache["cropped_slices"][0][self._centering]) - return self._cache["cropped_slices"][0][self._centering] + logger.trace("centering: '%s', center: %s, padding: %s, " # type: ignore + "sub roi: %s", centering, center, padding, roi) + self._cache.cropped_roi[centering] = roi + return self._cache.cropped_roi[centering] class PoseEstimate(): @@ -588,21 +667,23 @@ class PoseEstimate(): Head Pose Estimation using OpenCV and Dlib - https://www.learnopencv.com/tag/solvepnp/ 3D Model points - http://aifi.isr.uc.pt/Downloads/OpenGL/glAnthropometric3DModel.cpp """ - def __init__(self, landmarks): + def __init__(self, landmarks: np.ndarray) -> None: self._distortion_coefficients = np.zeros((4, 1)) # Assuming no lens distortion - self._xyz_2d = None + self._xyz_2d: Optional[np.ndarray] = None self._camera_matrix = self._get_camera_matrix() self._rotation, self._translation = self._solve_pnp(landmarks) self._offset = self._get_offset() - self._pitch_yaw = None + self._pitch_yaw: Tuple[int, int] = (0, 0) @property - def xyz_2d(self): + def xyz_2d(self) -> np.ndarray: """ :class:`numpy.ndarray` projected (x, y) coordinates for each x, y, z point at a constant distance from adjusted center of the skull (0.5, 0.5) in the 2D space. """ if self._xyz_2d is None: - xyz = cv2.projectPoints(np.float32([[6, 0, -2.3], [0, 6, -2.3], [0, 0, 3.7]]), + xyz = cv2.projectPoints(np.array([[6., 0., -2.3], + [0., 6., -2.3], + [0., 0., 3.7]]).astype("float32"), self._rotation, self._translation, self._camera_matrix, @@ -611,36 +692,36 @@ def xyz_2d(self): return self._xyz_2d @property - def offset(self): + def offset(self) -> Dict[CenteringType, np.ndarray]: """ dict: The amount to offset a standard 0.0 - 1.0 umeyama transformation matrix for a from the center of the face (between the eyes) or center of the head (middle of skull) rather than the nose area. """ return self._offset @property - def pitch(self): + def pitch(self) -> float: """ float: The pitch of the aligned face in eular angles """ - if not self._pitch_yaw: + if not any(self._pitch_yaw): self._get_pitch_yaw() return self._pitch_yaw[0] @property - def yaw(self): + def yaw(self) -> float: """ float: The yaw of the aligned face in eular angles """ - if not self._pitch_yaw: + if not any(self._pitch_yaw): self._get_pitch_yaw() return self._pitch_yaw[1] - def _get_pitch_yaw(self): + def _get_pitch_yaw(self) -> None: """ Obtain the yaw and pitch from the :attr:`_rotation` in eular angles. """ proj_matrix = np.zeros((3, 4), dtype="float32") proj_matrix[:3, :3] = cv2.Rodrigues(self._rotation)[0] euler = cv2.decomposeProjectionMatrix(proj_matrix)[-1] self._pitch_yaw = (euler[0][0], euler[1][0]) - logger.trace("yaw_pitch: %s", self._pitch_yaw) + logger.trace("yaw_pitch: %s", self._pitch_yaw) # type: ignore @classmethod - def _get_camera_matrix(cls): + def _get_camera_matrix(cls) -> np.ndarray: """ Obtain an estimate of the camera matrix based off the original frame dimensions. Returns @@ -652,10 +733,10 @@ def _get_camera_matrix(cls): camera_matrix = np.array([[focal_length, 0, 0.5], [0, focal_length, 0.5], [0, 0, 1]], dtype="double") - logger.trace("camera_matrix: %s", camera_matrix) + logger.trace("camera_matrix: %s", camera_matrix) # type: ignore return camera_matrix - def _solve_pnp(self, landmarks): + def _solve_pnp(self, landmarks: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: """ Solve the Perspective-n-Point for the given landmarks. Takes 2D landmarks in world space and estimates the rotation and translation vectors @@ -680,10 +761,11 @@ def _solve_pnp(self, landmarks): self._camera_matrix, self._distortion_coefficients, flags=cv2.SOLVEPNP_ITERATIVE) - logger.trace("points: %s, rotation: %s, translation: %s", points, rotation, translation) + logger.trace("points: %s, rotation: %s, translation: %s", # type: ignore + points, rotation, translation) return rotation, translation - def _get_offset(self): + def _get_offset(self) -> Dict[CenteringType, np.ndarray]: """ Obtain the offset between the original center of the extracted face to the new center of the head in 2D space. @@ -692,22 +774,23 @@ def _get_offset(self): :class:`numpy.ndarray` The x, y offset of the new center from the old center. """ - offset = dict(legacy=np.array([0.0, 0.0])) - points = dict(head=(0, 0, -2.3), face=(0, -1.5, 4.2)) + offset: Dict[CenteringType, np.ndarray] = dict(legacy=np.array([0.0, 0.0])) + points: Dict[Literal["face", "head"], Tuple[float, ...]] = dict(head=(0.0, 0.0, -2.3), + face=(0.0, -1.5, 4.2)) for key, pnts in points.items(): - center = cv2.projectPoints(np.float32([pnts]), + center = cv2.projectPoints(np.array([pnts]).astype("float32"), self._rotation, self._translation, self._camera_matrix, self._distortion_coefficients)[0].squeeze() - logger.trace("center %s: %s", key, center) + logger.trace("center %s: %s", key, center) # type: ignore offset[key] = center - (0.5, 0.5) - logger.trace("offset: %s", offset) + logger.trace("offset: %s", offset) # type: ignore return offset -def _umeyama(source, destination, estimate_scale): +def _umeyama(source: np.ndarray, destination: np.ndarray, estimate_scale: bool) -> np.ndarray: """Estimate N-D similarity transformation with or without scaling. Imported, and slightly adapted, directly from: From 5e73437be47f2410439a3c6716de96354e6a0c94 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 19 Aug 2022 12:36:08 +0100 Subject: [PATCH 694/981] lib.align updates: - alignments.py - Add typed dicts for imported alignments - Explicitly check for presence of thumb value in alignments dict - linting - detected_face.py - Typing - Linting - Legacy support for pre-aligned face - Update dependencies to new property names --- lib/align/alignments.py | 93 ++++-- lib/align/detected_face.py | 463 +++++++++++++++------------- plugins/extract/align/_base.py | 18 +- plugins/extract/align/cv2_dnn.py | 2 +- plugins/extract/align/fan.py | 4 +- plugins/extract/detect/_base.py | 24 +- tools/alignments/jobs.py | 5 +- tools/manual/detected_faces.py | 34 +- tools/manual/faceviewer/viewport.py | 19 +- tools/sort/sort.py | 23 +- 10 files changed, 389 insertions(+), 296 deletions(-) diff --git a/lib/align/alignments.py b/lib/align/alignments.py index 9e0f1aee9a..7ea5d31906 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -4,17 +4,25 @@ import logging import os +import sys from datetime import datetime +from typing import Dict, List, Optional, TYPE_CHECKING, Union import numpy as np from lib.serializer import get_serializer, get_serializer_from_filename from lib.utils import FaceswapError -logger = logging.getLogger(__name__) # pylint: disable=invalid-name -_VERSION = 2.2 +if sys.version_info < (3, 8): + from typing_extensions import TypedDict +else: + from typing import TypedDict +if TYPE_CHECKING: + from .aligned_face import CenteringType +logger = logging.getLogger(__name__) # pylint: disable=invalid-name +_VERSION = 2.2 # VERSION TRACKING # 1.0 - Never really existed. Basically any alignments file prior to version 2.0 # 2.0 - Implementation of full head extract. Any alignments version below this will have used @@ -23,6 +31,47 @@ # or stored in alignments file # 2.2 - Add support for differently centered masks (i.e. not all masks stored as face centering) + +# TODO Convert these to Dataclasses +class MaskAlignmentsFileDict(TypedDict): + """ Typed Dictionary for storing Masks. """ + mask: bytes + affine_matrix: Union[List[float], np.ndarray] + interpolator: int + stored_size: int + stored_centering: "CenteringType" + + +class PNGHeaderAlignmentsDict(TypedDict): + """ Base Dictionary for storing Alignment Information in Alignments files and PNG Headers. """ + x: int + y: int + w: int + h: int + landmarks_xy: Union[List[float], np.ndarray] + mask: Dict[str, MaskAlignmentsFileDict] + + +class AlignmentFileDict(PNGHeaderAlignmentsDict): + """ Typed Dictionary for storing Alignment Information in alignments files. """ + thumb: Optional[np.ndarray] + + +class PNGHeaderSourceDict(TypedDict): + """ Dictionary for storing additional meta information in PNG headers """ + alignments_version: float + original_filename: str + face_index: int + source_filename: str + source_is_video: bool + + +class PNGHeaderDict(TypedDict): + """ Dictionary for storing all alignment and meta information in PNG Headers """ + alignments: PNGHeaderAlignmentsDict + source: PNGHeaderSourceDict + + class Alignments(): """ The alignments file is a custom serialized ``.fsa`` file that holds information for each frame for a video or series of images. @@ -51,8 +100,8 @@ def __init__(self, folder, filename="alignments"): self._meta = None self._data = self._load() self._update_legacy() - self._hashes_to_frame = dict() - self._hashes_to_alignment = dict() + self._hashes_to_frame = {} + self._hashes_to_alignment = {} self._thumbnails = Thumbnails(self) logger.debug("Initialized %s", self.__class__.__name__) @@ -109,7 +158,7 @@ def hashes_to_frame(self): logger.debug("Generating hashes to frame") for frame_name, val in self._data.items(): for idx, face in enumerate(val["faces"]): - self._hashes_to_frame.setdefault(face["hash"], dict())[frame_name] = idx + self._hashes_to_frame.setdefault(face["hash"], {})[frame_name] = idx return self._hashes_to_frame @property @@ -136,12 +185,12 @@ def hashes_to_alignment(self): def mask_summary(self): """ dict: The mask type names stored in the alignments :attr:`data` as key with the number of faces which possess the mask type as value. """ - masks = dict() + masks = {} for val in self._data.values(): for face in val["faces"]: if face.get("mask", None) is None: masks["none"] = masks.get("none", 0) + 1 - for key in face.get("mask", dict()): + for key in face.get("mask", {}): masks[key] = masks.get(key, 0) + 1 return masks @@ -202,7 +251,7 @@ def _get_location(self, folder, filename): if extension[1:] == self._serializer.file_extension: logger.debug("Valid Alignments filename provided: '%s'", filename) else: - filename = "{}.{}".format(noext_name, self._serializer.file_extension) + filename = f"{noext_name}.{self._serializer.file_extension}" logger.debug("File extension set from serializer: '%s'", self._serializer.file_extension) location = os.path.join(str(folder), filename) @@ -229,8 +278,7 @@ def _load(self): """ logger.debug("Loading alignments") if not self.have_alignments_file: - raise FaceswapError("Error: Alignments file not found at " - "{}".format(self._file)) + raise FaceswapError(f"Error: Alignments file not found at {self._file}") logger.info("Reading alignments from: '%s'", self._file) data = self._serializer.load(self._file) @@ -294,7 +342,7 @@ def save_video_meta_data(self, pts_time, keyframes): for idx, pts in enumerate(pts_time): meta = dict(pts_time=pts, keyframe=idx in keyframes) - key = "{}_{:06d}.png".format(basename, idx + 1) + key = f"{basename}_{idx + 1:06d}.png" if key not in self.data: self.data[key] = dict(video_meta=meta, faces=[]) else: @@ -303,16 +351,16 @@ def save_video_meta_data(self, pts_time, keyframes): logger.debug("Alignments count: %s, timestamp count: %s", len(self.data), len(pts_time)) if len(self.data) != len(pts_time): raise FaceswapError( - "There is a mismatch between the number of frames found in the video file ({}) " - "and the number of frames found in the alignments file ({})." - "\nThis can be caused by a number of issues:" + "There is a mismatch between the number of frames found in the video file " + f"({len(pts_time)}) and the number of frames found in the alignments file " + f"({len(self.data)}).\nThis can be caused by a number of issues:" "\n - The video has a Variable Frame Rate and FFMPEG is having a hard time " "calculating the correct number of frames." "\n - You are working with a Merged Alignments file. This is not supported for " "your current use case." "\nYou should either extract the video to individual frames, re-encode the " "video at a constant frame rate and re-run extraction or work with a dedicated " - "alignments file for your requested video.".format(len(pts_time), len(self.data))) + "alignments file for your requested video.") self.save() @classmethod @@ -389,7 +437,7 @@ def frame_has_faces(self, frame_name): ``True`` if the given frame_name exists within the alignments :attr:`data` and has at least 1 face associated with it, otherwise ``False`` """ - retval = bool(self._data.get(frame_name, dict()).get("faces", [])) + retval = bool(self._data.get(frame_name, {}).get("faces", [])) logger.trace("'%s': %s", frame_name, retval) return retval @@ -412,7 +460,7 @@ def frame_has_multiple_faces(self, frame_name): if not frame_name: retval = False else: - retval = bool(len(self._data.get(frame_name, dict()).get("faces", [])) > 1) + retval = bool(len(self._data.get(frame_name, {}).get("faces", [])) > 1) logger.trace("'%s': %s", frame_name, retval) return retval @@ -457,7 +505,7 @@ def get_faces_in_frame(self, frame_name): The list of face dictionaries that appear within the requested frame_name """ logger.trace("Getting faces for frame_name: '%s'", frame_name) - return self._data.get(frame_name, dict()).get("faces", []) + return self._data.get(frame_name, {}).get("faces", []) def _count_faces_in_frame(self, frame_name): """ Return number of faces that appear within :attr:`data` for the given frame_name. @@ -473,7 +521,7 @@ def _count_faces_in_frame(self, frame_name): int The number of faces that appear in the given frame_name """ - retval = len(self._data.get(frame_name, dict()).get("faces", [])) + retval = len(self._data.get(frame_name, {}).get("faces", [])) logger.trace(retval) return retval @@ -643,7 +691,7 @@ def _test_for_legacy(self, location): logger.debug("Checking for legacy alignments file formats: '%s'", location) filename = os.path.splitext(location)[0] for ext in (".json", ".p", ".pickle", ".yaml"): - legacy_filename = "{}{}".format(filename, ext) + legacy_filename = f"{filename}{ext}" if os.path.exists(legacy_filename): logger.debug("Legacy alignments file exists: '%s'", legacy_filename) _ = self._update_file_format(*os.path.split(legacy_filename)) @@ -667,8 +715,7 @@ def _update_file_format(self, folder, filename): """ logger.info("Reformatting legacy alignments file...") old_location = os.path.join(str(folder), filename) - new_location = "{}.{}".format(os.path.splitext(old_location)[0], - self._serializer.file_extension) + new_location = f"{os.path.splitext(old_location)[0]}.{self._serializer.file_extension}" if os.path.exists(old_location): if os.path.exists(new_location): logger.info("Using existing updated alignments file found at '%s'. If you do not " @@ -792,7 +839,7 @@ def __init__(self, alignments): def has_thumbnails(self): """ bool: ``True`` if all faces in the alignments file contain thumbnail images otherwise ``False``. """ - retval = all("thumb" in face + retval = all(face.get("thumb") for frame in self._alignments_dict.values() for face in frame["faces"]) logger.trace(retval) diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index fb5a9e316e..04ada9b568 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -6,8 +6,7 @@ import os from hashlib import sha1 -from typing import Dict, List, TYPE_CHECKING - +from typing import Callable, Dict, List, Optional, Tuple, TYPE_CHECKING, Union from zlib import compress, decompress import cv2 @@ -15,6 +14,8 @@ from lib.image import encode_image, read_image from lib.utils import FaceswapError +from .alignments import (Alignments, AlignmentFileDict, MaskAlignmentsFileDict, + PNGHeaderAlignmentsDict, PNGHeaderDict, PNGHeaderSourceDict) from . import AlignedFace, get_adjusted_center, get_centered_size if TYPE_CHECKING: @@ -40,16 +41,16 @@ class DetectedFace(): ---------- image: numpy.ndarray, optional Original frame that holds this face. Optional (not required if just storing coordinates) - x: int + left: int The left most point (in pixels) of the face's bounding box as discovered in :mod:`plugins.extract.detect` - w: int + width: int The width (in pixels) of the face's bounding box as discovered in :mod:`plugins.extract.detect` - y: int + top: int The top most point (in pixels) of the face's bounding box as discovered in :mod:`plugins.extract.detect` - h: int + height: int The height (in pixels) of the face's bounding box as discovered in :mod:`plugins.extract.detect` landmarks_xy: list @@ -65,16 +66,16 @@ class DetectedFace(): This is a generic image placeholder that should not be relied on to be holding a particular image. It may hold the source frame that holds the face, a cropped face or a scaled image depending on the method using this object. - x: int + left: int The left most point (in pixels) of the face's bounding box as discovered in :mod:`plugins.extract.detect` - w: int + width: int The width (in pixels) of the face's bounding box as discovered in :mod:`plugins.extract.detect` - y: int + top: int The top most point (in pixels) of the face's bounding box as discovered in :mod:`plugins.extract.detect` - h: int + height: int The height (in pixels) of the face's bounding box as discovered in :mod:`plugins.extract.detect` landmarks_xy: list @@ -83,49 +84,63 @@ class DetectedFace(): The generated mask(s) for the face as generated in :mod:`plugins.extract.mask`. Is a dict of {**name** (`str`): :class:`Mask`}. """ - def __init__(self, image=None, x=None, w=None, y=None, h=None, landmarks_xy=None, mask=None, - filename=None): - logger.trace("Initializing %s: (image: %s, x: %s, w: %s, y: %s, h:%s, landmarks_xy: %s, " - "mask: %s, filename: %s)", + def __init__(self, + image: Optional[np.ndarray] = None, + left: Optional[int] = None, + width: Optional[int] = None, + top: Optional[int] = None, + height: Optional[int] = None, + landmarks_xy: Optional[np.ndarray] = None, + mask: Optional[Dict[str, "Mask"]] = None, + filename: Optional[str] = None) -> None: + logger.trace("Initializing %s: (image: %s, left: %s, width: %s, top: %s, " # type: ignore + "height: %s, landmarks_xy: %s, mask: %s, filename: %s)", self.__class__.__name__, - image.shape if image is not None and image.any() else image, - x, w, y, h, landmarks_xy, - {k: v.shape for k, v in mask} if mask is not None else mask, - filename) + image.shape if image is not None and image.any() else image, left, width, top, + height, landmarks_xy, mask, filename) self.image = image - self.x = x # pylint:disable=invalid-name - self.w = w # pylint:disable=invalid-name - self.y = y # pylint:disable=invalid-name - self.h = h # pylint:disable=invalid-name - self.landmarks_xy = landmarks_xy - self.thumbnail = None - self.mask = dict() if mask is None else mask + self.left = left + self.width = width + self.top = top + self.height = height + self._landmarks_xy = landmarks_xy + self.thumbnail: Optional[np.ndarray] = None + self.mask = {} if mask is None else mask - self.aligned = None - logger.trace("Initialized %s", self.__class__.__name__) + self._aligned: Optional[AlignedFace] = None + logger.trace("Initialized %s", self.__class__.__name__) # type: ignore @property - def left(self): - """int: Left point (in pixels) of face detection bounding box within the parent image """ - return self.x + def aligned(self) -> AlignedFace: + """ The aligned face connected to this detected face. """ + assert self._aligned is not None + return self._aligned @property - def top(self): - """int: Top point (in pixels) of face detection bounding box within the parent image """ - return self.y + def landmarks_xy(self) -> np.ndarray: + """ The aligned face connected to this detected face. """ + assert self._landmarks_xy is not None + return self._landmarks_xy @property - def right(self): + def right(self) -> int: """int: Right point (in pixels) of face detection bounding box within the parent image """ - return self.x + self.w + assert self.left is not None and self.width is not None + return self.left + self.width @property - def bottom(self): + def bottom(self) -> int: """int: Bottom point (in pixels) of face detection bounding box within the parent image """ - return self.y + self.h - - def add_mask(self, name, mask, affine_matrix, interpolator, - storage_size=128, storage_centering="face"): + assert self.top is not None and self.height is not None + return self.top + self.height + + def add_mask(self, + name: str, + mask: np.ndarray, + affine_matrix: np.ndarray, + interpolator: int, + storage_size: int = 128, + storage_centering: "CenteringType" = "face") -> None: """ Add a :class:`Mask` to this detected face The mask should be the original output from :mod:`plugins.extract.mask` @@ -150,9 +165,9 @@ def add_mask(self, name, mask, affine_matrix, interpolator, The centering to store the mask at. One of `"legacy"`, `"face"`, `"head"`. Default: `"face"` """ - logger.trace("name: '%s', mask shape: %s, affine_matrix: %s, interpolator: %s, " - "storage_size: %s, storage_centering: %s)", name, mask.shape, affine_matrix, - interpolator, storage_size, storage_centering) + logger.trace("name: '%s', mask shape: %s, affine_matrix: %s, " # type: ignore + "interpolator: %s, storage_size: %s, storage_centering: %s)", name, + mask.shape, affine_matrix, interpolator, storage_size, storage_centering) fsmask = Mask(storage_size=storage_size, storage_centering=storage_centering) fsmask.add(mask, affine_matrix, interpolator) self.mask[name] = fsmask @@ -211,7 +226,7 @@ def get_landmark_mask(self, size, area, retval = mask.get(as_zip=as_zip) return retval - def to_alignment(self): + def to_alignment(self) -> AlignmentFileDict: """ Return the detected face formatted for an alignments file returns @@ -221,18 +236,21 @@ def to_alignment(self): ``landmarks_xy``, ``mask``. The additional key ``thumb`` will be provided if the detected face object contains a thumbnail. """ - alignment = dict(x=self.x, - w=self.w, - y=self.y, - h=self.h, - landmarks_xy=self.landmarks_xy, - mask={name: mask.to_dict() for name, mask in self.mask.items()}) - if self.thumbnail is not None: - alignment["thumb"] = self.thumbnail - logger.trace("Returning: %s", alignment) + if (self.left is None or self.width is None or self.top is None or self.height is None): + raise AssertionError("Some detected face variables have not been initialized") + alignment = AlignmentFileDict(x=self.left, + w=self.width, + y=self.top, + h=self.height, + landmarks_xy=self.landmarks_xy, + mask={name: mask.to_dict() + for name, mask in self.mask.items()}, + thumb=self.thumbnail) + logger.trace("Returning: %s", alignment) # type: ignore return alignment - def from_alignment(self, alignment, image=None, with_thumb=False): + def from_alignment(self, alignment: AlignmentFileDict, + image: Optional[np.ndarray] = None, with_thumb: bool = False) -> None: """ Set the attributes of this class from an alignments file and optionally load the face into the ``image`` attribute. @@ -253,50 +271,53 @@ def from_alignment(self, alignment, image=None, with_thumb=False): Default: ``False`` """ - logger.trace("Creating from alignment: (alignment: %s, has_image: %s)", + logger.trace("Creating from alignment: (alignment: %s, has_image: %s)", # type: ignore alignment, bool(image is not None)) - self.x = alignment["x"] - self.w = alignment["w"] - self.y = alignment["y"] - self.h = alignment["h"] + self.left = alignment["x"] + self.width = alignment["w"] + self.top = alignment["y"] + self.height = alignment["h"] landmarks = alignment["landmarks_xy"] if not isinstance(landmarks, np.ndarray): landmarks = np.array(landmarks, dtype="float32") - self.landmarks_xy = landmarks.copy() + self._landmarks_xy = landmarks.copy() if with_thumb: # Thumbnails currently only used for manual tool. Default to None - self.thumbnail = alignment.get("thumb", None) + self.thumbnail = alignment.get("thumb") # Manual tool and legacy alignments will not have a mask - self.aligned = None + self._aligned = None if alignment.get("mask", None) is not None: - self.mask = dict() + self.mask = {} for name, mask_dict in alignment["mask"].items(): self.mask[name] = Mask() self.mask[name].from_dict(mask_dict) if image is not None and image.any(): self._image_to_face(image) - logger.trace("Created from alignment: (x: %s, w: %s, y: %s. h: %s, " - "landmarks: %s, mask: %s)", - self.x, self.w, self.y, self.h, self.landmarks_xy, self.mask) + logger.trace("Created from alignment: (left: %s, width: %s, top: %s, " # type: ignore + "height: %s, landmarks: %s, mask: %s)", self.left, self.width, self.top, + self.height, self.landmarks_xy, self.mask) - def to_png_meta(self): + def to_png_meta(self) -> PNGHeaderAlignmentsDict: """ Return the detected face formatted for insertion into a png itxt header. returns: dict The alignments dict will be returned with the keys ``x``, ``w``, ``y``, ``h``, ``landmarks_xy`` and ``mask`` """ - alignment = dict(x=self.x, - w=self.w, - y=self.y, - h=self.h, - landmarks_xy=self.landmarks_xy.tolist(), - mask={name: mask.to_png_meta() for name, mask in self.mask.items()}) + if (self.left is None or self.width is None or self.top is None or self.height is None): + raise AssertionError("Some detected face variables have not been initialized") + alignment = PNGHeaderAlignmentsDict( + x=self.left, + w=self.width, + y=self.top, + h=self.height, + landmarks_xy=self.landmarks_xy.tolist(), + mask={name: mask.to_png_meta() for name, mask in self.mask.items()}) return alignment - def from_png_meta(self, alignment): + def from_png_meta(self, alignment: PNGHeaderAlignmentsDict) -> None: """ Set the attributes of this class from alignments stored in a png exif header. Parameters @@ -305,26 +326,35 @@ def from_png_meta(self, alignment): A dictionary entry for a face from alignments stored in a png exif header containing the keys ``x``, ``w``, ``y``, ``h``, ``landmarks_xy`` and ``mask`` """ - self.x = alignment["x"] - self.w = alignment["w"] - self.y = alignment["y"] - self.h = alignment["h"] - self.landmarks_xy = np.array(alignment["landmarks_xy"], dtype="float32") - self.mask = dict() + self.left = alignment["x"] + self.width = alignment["w"] + self.top = alignment["y"] + self.height = alignment["h"] + self._landmarks_xy = np.array(alignment["landmarks_xy"], dtype="float32") + self.mask = {} for name, mask_dict in alignment["mask"].items(): self.mask[name] = Mask() self.mask[name].from_dict(mask_dict) - logger.trace("Created from png exif header: (x: %s, w: %s, y: %s. h: %s, landmarks: %s, " - "mask: %s)", self.x, self.w, self.y, self.h, self.landmarks_xy, self.mask) + logger.trace("Created from png exif header: (left: %s, width: %s, top: %s " # type: ignore + " height: %s, andmarks: %s, mask: %s)", self.left, self.width, self.top, + self.height, self.landmarks_xy, self.mask) - def _image_to_face(self, image): + def _image_to_face(self, image: np.ndarray) -> None: """ set self.image to be the cropped face from detected bounding box """ - logger.trace("Cropping face from image") + logger.trace("Cropping face from image") # type: ignore self.image = image[self.top: self.bottom, self.left: self.right] # <<< Aligned Face methods and properties >>> # - def load_aligned(self, image, size=256, dtype=None, centering="head", force=False): + def load_aligned(self, + image: Optional[np.ndarray], + size: int = 256, + dtype: Optional[str] = None, + centering: "CenteringType" = "head", + coverage_ratio: float = 1.0, + force: bool = False, + is_aligned: bool = False, + is_legacy: bool = False) -> None: """ Align a face from a given image. Aligning a face is a relatively expensive task and is not required for all uses of @@ -350,25 +380,36 @@ def load_aligned(self, image, size=256, dtype=None, centering="head", force=Fals right. "head" aligns for the center of the skull (in 3D space) being the center of the extracted image, with the crop holding the full head. Default: `"head"` + coverage_ratio: float, optional + The amount of the aligned image to return. A ratio of 1.0 will return the full contents + of the aligned image. A ratio of 0.5 will return an image of the given size, but will + crop to the central 50%% of the image. Default: `1.0` force: bool, optional Force an update of the aligned face, even if it is already loaded. Default: ``False`` - + is_aligned: bool, optional + Indicates that the :attr:`image` is an aligned face rather than a frame. + Default: ``False`` + is_legacy: bool, optional + Only used if `is_aligned` is ``True``. ``True`` indicates that the aligned image being + loaded is a legacy extracted face rather than a current head extracted face Notes ----- This method must be executed to get access to the following an :class:`AlignedFace` object """ - if self.aligned and not force: + if self._aligned and not force: # Don't reload an already aligned face - logger.trace("Skipping alignment calculation for already aligned face") + logger.trace("Skipping alignment calculation for already aligned face") # type: ignore else: - logger.trace("Loading aligned face: (size: %s, dtype: %s)", size, dtype) - self.aligned = AlignedFace(self.landmarks_xy, - image=image, - centering=centering, - size=size, - coverage_ratio=1.0, - dtype=dtype, - is_aligned=False) + logger.trace("Loading aligned face: (size: %s, dtype: %s)", # type: ignore + size, dtype) + self._aligned = AlignedFace(self.landmarks_xy, + image=image, + centering=centering, + size=size, + coverage_ratio=coverage_ratio, + dtype=dtype, + is_aligned=is_aligned, + is_legacy=is_aligned and is_legacy) class _LandmarksMask(): # pylint:disable=too-few-public-methods @@ -465,39 +506,43 @@ class Mask(): stored_centering: str The centering that the mask is stored at. One of `"legacy"`, `"face"`, `"head"` """ - def __init__(self, storage_size=128, storage_centering="face"): - logger.trace("Initializing: %s (storage_size: %s, storage_centering: %s)", + def __init__(self, + storage_size: int = 128, + storage_centering: "CenteringType" = "face") -> None: + logger.trace("Initializing: %s (storage_size: %s, storage_centering: %s)", # type: ignore self.__class__.__name__, storage_size, storage_centering) self.stored_size = storage_size self.stored_centering = storage_centering - self._mask = None - self._affine_matrix = None - self._interpolator = None + self._mask: Optional[bytes] = None + self._affine_matrix: Optional[np.ndarray] = None + self._interpolator: Optional[int] = None - self._blur = dict() - self._blur_kernel = 0 + self._blur_type: Optional[Literal["gaussian", "normalized"]] = None + self._blur_passes: int = 0 + self._blur_kernel: Union[float, int] = 0 self._threshold = 0.0 self._sub_crop_size = 0 self._sub_crop_slices: Dict[Literal["in", "out"], List[slice]] = {} + self.set_blur_and_threshold() - logger.trace("Initialized: %s", self.__class__.__name__) + logger.trace("Initialized: %s", self.__class__.__name__) # type: ignore @property - def mask(self): - """ numpy.ndarray: The mask at the size of :attr:`stored_size` with any requested blurring, - threshold amount and centering applied.""" + def mask(self) -> np.ndarray: + """ :class:`numpy.ndarray`: The mask at the size of :attr:`stored_size` with any requested + blurring, threshold amount and centering applied.""" mask = self.stored_mask - if self._threshold != 0.0 or self._blur["kernel"] != 0: + if self._threshold != 0.0 or self._blur_kernel != 0: mask = mask.copy() if self._threshold != 0.0: mask[mask < self._threshold] = 0.0 mask[mask > 255.0 - self._threshold] = 255.0 - if self._blur["kernel"] != 0: - mask = BlurMask(self._blur["type"], + if self._blur_kernel != 0 and self._blur_type is not None: + mask = BlurMask(self._blur_type, mask, - self._blur["kernel"], - passes=self._blur["passes"]).blurred + self._blur_kernel, + passes=self._blur_passes).blurred if self._sub_crop_size: # Crop the mask to the given centering out = np.zeros((self._sub_crop_size, self._sub_crop_size, 1), dtype=mask.dtype) slice_in, slice_out = self._sub_crop_slices["in"], self._sub_crop_slices["out"] @@ -507,16 +552,17 @@ def mask(self): return mask @property - def stored_mask(self): + def stored_mask(self) -> np.ndarray: """ :class:`numpy.ndarray`: The mask at the size of :attr:`stored_size` as it is stored (i.e. with no blurring/centering applied). """ + assert self._mask is not None dims = (self.stored_size, self.stored_size, 1) mask = np.frombuffer(decompress(self._mask), dtype="uint8").reshape(dims) - logger.trace("stored mask shape: %s", mask.shape) + logger.trace("stored mask shape: %s", mask.shape) # type: ignore return mask @property - def original_roi(self): + def original_roi(self) -> np.ndarray: """ :class: `numpy.ndarray`: The original region of interest of the mask in the source frame. """ points = np.array([[0, 0], @@ -525,20 +571,22 @@ def original_roi(self): [self.stored_size - 1, 0]], np.int32).reshape((-1, 1, 2)) matrix = cv2.invertAffineTransform(self._affine_matrix) roi = cv2.transform(points, matrix).reshape((4, 2)) - logger.trace("Returning: %s", roi) + logger.trace("Returning: %s", roi) # type: ignore return roi @property - def affine_matrix(self): + def affine_matrix(self) -> np.ndarray: """ :class: `numpy.ndarray`: The affine matrix to transpose the mask to a full frame. """ + assert self._affine_matrix is not None return self._affine_matrix @property - def interpolator(self): + def interpolator(self) -> int: """ int: The cv2 interpolator required to transpose the mask to a full frame. """ + assert self._interpolator is not None return self._interpolator - def get_full_frame_mask(self, width, height): + def get_full_frame_mask(self, width: int, height: int) -> np.ndarray: """ Return the stored mask in a full size frame of the given dimensions Parameters @@ -550,7 +598,7 @@ def get_full_frame_mask(self, width, height): Returns ------- - numpy.ndarray: The mask affined to the original full frame of the given dimensions + :class:`numpy.ndarray`: The mask affined to the original full frame of the given dimensions """ frame = np.zeros((width, height, 1), dtype="uint8") mask = cv2.warpAffine(self.mask, @@ -559,48 +607,51 @@ def get_full_frame_mask(self, width, height): frame, flags=cv2.WARP_INVERSE_MAP | self._interpolator, borderMode=cv2.BORDER_CONSTANT) - logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, mask max: %s", + logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, mask max: %s", # type: ignore mask.shape, mask.dtype, mask.min(), mask.max()) return mask - def add(self, mask, affine_matrix, interpolator): + def add(self, mask: np.ndarray, affine_matrix: np.ndarray, interpolator: int) -> None: """ Add a Faceswap mask to this :class:`Mask`. The mask should be the original output from :mod:`plugins.extract.mask` Parameters ---------- - mask: numpy.ndarray + mask: :class:`numpy.ndarray` The mask that is to be added as output from :mod:`plugins.extract.mask` It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` - affine_matrix: numpy.ndarray + affine_matrix: :class:`numpy.ndarray` The transformation matrix required to transform the mask to the original frame. interpolator, int: The CV2 interpolator required to transform this mask to it's original frame """ - logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, mask max: %s, " + logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, mask max: %s, " # type: ignore "affine_matrix: %s, interpolator: %s)", mask.shape, mask.dtype, mask.min(), affine_matrix, mask.max(), interpolator) self._affine_matrix = self._adjust_affine_matrix(mask.shape[0], affine_matrix) self._interpolator = interpolator self.replace_mask(mask) - def replace_mask(self, mask): + def replace_mask(self, mask: np.ndarray) -> None: """ Replace the existing :attr:`_mask` with the given mask. Parameters ---------- - mask: numpy.ndarray + mask: :class:`numpy.ndarray` The mask that is to be added as output from :mod:`plugins.extract.mask`. It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` """ mask = (cv2.resize(mask, (self.stored_size, self.stored_size), interpolation=cv2.INTER_AREA) * 255.0).astype("uint8") - self._mask = compress(mask) + self._mask = compress(mask.tobytes()) def set_blur_and_threshold(self, - blur_kernel=0, blur_type="gaussian", blur_passes=1, threshold=0): + blur_kernel: int = 0, + blur_type: Optional[Literal["gaussian", "normalized"]] = "gaussian", + blur_passes: int = 1, + threshold: int = 0) -> None: """ Set the internal blur kernel and threshold amount for returned masks Parameters @@ -617,12 +668,13 @@ def set_blur_and_threshold(self, The threshold amount to minimize/maximize mask values to 0 and 100. Percentage value. Default: 0 """ - logger.trace("blur_kernel: %s, threshold: %s", blur_kernel, threshold) + logger.trace("blur_kernel: %s, blur_type: %s, blur_passes: %s, ", # type: ignore + "threshold: %s", blur_kernel, blur_type, blur_passes, threshold) if blur_type is not None: blur_kernel += 0 if blur_kernel == 0 or blur_kernel % 2 == 1 else 1 - self._blur["kernel"] = blur_kernel - self._blur["type"] = blur_type - self._blur["passes"] = blur_passes + self._blur_kernel = blur_kernel + self._blur_type = blur_type + self._blur_passes = blur_passes self._threshold = (threshold / 100.0) * 255.0 def set_sub_crop(self, @@ -641,7 +693,7 @@ def set_sub_crop(self, The (x, y) offset for the mask at its stored centering target_offset: :class:`numpy.ndarray` The (x, y) offset for the mask at the requested target centering - centering: str + centering: str The centering to set the sub crop area for. One of `"legacy"`, `"face"`. `"head"` coverage_ratio: float, optional The coverage ratio to be applied to the target image. ``None`` for default (1.0). @@ -673,45 +725,55 @@ def set_sub_crop(self, "sub_crop_slices: %s", roi, coverage_ratio, self._sub_crop_size, self._sub_crop_slices) - def _adjust_affine_matrix(self, mask_size, affine_matrix): + def _adjust_affine_matrix(self, mask_size: int, affine_matrix: np.ndarray) -> np.ndarray: """ Adjust the affine matrix for the mask's storage size Parameters ---------- mask_size: int The original size of the mask. - affine_matrix: numpy.ndarray + affine_matrix: :class:`numpy.ndarray` The affine matrix to transform the mask at original size to the parent frame. Returns ------- - affine_matrix: numpy,ndarray + affine_matrix: :class:`numpy,ndarray` The affine matrix adjusted for the mask at its stored dimensions. """ zoom = self.stored_size / mask_size zoom_mat = np.array([[zoom, 0, 0.], [0, zoom, 0.]]) adjust_mat = np.dot(zoom_mat, np.concatenate((affine_matrix, np.array([[0., 0., 1.]])))) - logger.trace("storage_size: %s, mask_size: %s, zoom: %s, original matrix: %s, " - "adjusted_matrix: %s", self.stored_size, mask_size, zoom, affine_matrix.shape, - adjust_mat.shape) + logger.trace("storage_size: %s, mask_size: %s, zoom: %s, " # type: ignore + "original matrix: %s, adjusted_matrix: %s", self.stored_size, mask_size, zoom, + affine_matrix.shape, adjust_mat.shape) return adjust_mat - def to_dict(self): + def to_dict(self, is_png=False) -> MaskAlignmentsFileDict: """ Convert the mask to a dictionary for saving to an alignments file + Parameters + ---------- + is_png: bool + ``True`` if the dictionary is being created for storage in a png header otherwise + ``False``. Default: ``False`` + Returns ------- dict: The :class:`Mask` for saving to an alignments file. Contains the keys ``mask``, ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` """ - retval = dict() - for key in ("mask", "affine_matrix", "interpolator", "stored_size", "stored_centering"): - retval[key] = getattr(self, self._attr_name(key)) - logger.trace({k: v if k != "mask" else type(v) for k, v in retval.items()}) + assert self._mask is not None + affine_matrix = self.affine_matrix.tolist() if is_png else self.affine_matrix + retval = MaskAlignmentsFileDict(mask=self._mask, + affine_matrix=affine_matrix, + interpolator=self.interpolator, + stored_size=self.stored_size, + stored_centering=self.stored_centering) + logger.trace({k: v if k != "mask" else type(v) for k, v in retval.items()}) # type: ignore return retval - def to_png_meta(self): + def to_png_meta(self) -> MaskAlignmentsFileDict: """ Convert the mask to a dictionary supported by png itxt headers. Returns @@ -720,17 +782,9 @@ def to_png_meta(self): The :class:`Mask` for saving to an alignments file. Contains the keys ``mask``, ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` """ - retval = dict() - for key in ("mask", "affine_matrix", "interpolator", "stored_size", "stored_centering"): - val = getattr(self, self._attr_name(key)) - if isinstance(val, np.ndarray): - retval[key] = val.tolist() - else: - retval[key] = val - logger.trace({k: v if k != "mask" else type(v) for k, v in retval.items()}) - return retval + return self.to_dict(is_png=True) - def from_dict(self, mask_dict): + def from_dict(self, mask_dict: MaskAlignmentsFileDict) -> None: """ Populates the :class:`Mask` from a dictionary loaded from an alignments file. Parameters @@ -739,31 +793,16 @@ def from_dict(self, mask_dict): A dictionary stored in an alignments file containing the keys ``mask``, ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` """ - for key in ("mask", "affine_matrix", "interpolator", "stored_size", "stored_centering"): - val = mask_dict.get(key) - val = "face" if key == "stored_centering" and val is None else val - if key == "affine_matrix" and not isinstance(val, np.ndarray): - val = np.array(val, dtype="float64") - setattr(self, self._attr_name(key), val) - logger.trace("%s - %s", key, val if key != "mask" else type(val)) - - @staticmethod - def _attr_name(dict_key): - """ The :class:`Mask` attribute name for the given dictionary key - - Parameters - ---------- - dict_key: str - The key name from an alignments dictionary - - Returns - ------- - attribute_name: str - The attribute name for the given key for :class:`Mask` - """ - retval = "_{}".format(dict_key) if not dict_key.startswith("stored") else dict_key - logger.trace("dict_key: %s, attribute_name: %s", dict_key, retval) - return retval + self._mask = mask_dict["mask"] + affine_matrix = mask_dict["affine_matrix"] + self._affine_matrix = (affine_matrix if isinstance(affine_matrix, np.ndarray) + else np.array(affine_matrix, dtype="float64")) + self._interpolator = mask_dict["interpolator"] + self.stored_size = mask_dict["stored_size"] + centering = mask_dict.get("stored_centering") + self.stored_centering = "face" if centering is None else centering + logger.trace({k: v if k != "mask" else type(v) # type: ignore + for k, v in mask_dict.items()}) class BlurMask(): # pylint:disable=too-few-public-methods @@ -795,64 +834,70 @@ class BlurMask(): # pylint:disable=too-few-public-methods >>> print(new_mask.shape) (128, 128, 1) """ - def __init__(self, blur_type, mask, kernel, is_ratio=False, passes=1): - logger.trace("Initializing %s: (blur_type: '%s', mask_shape: %s, kernel: %s, " - "is_ratio: %s, passes: %s)", self.__class__.__name__, blur_type, mask.shape, - kernel, is_ratio, passes) - self._blur_type = blur_type.lower() + def __init__(self, + blur_type: Literal["gaussian", "normalized"], + mask: np.ndarray, + kernel: Union[int, float], + is_ratio: bool = False, + passes: int = 1) -> None: + logger.trace("Initializing %s: (blur_type: '%s', mask_shape: %s, " # type: ignore + "kernel: %s, is_ratio: %s, passes: %s)", self.__class__.__name__, blur_type, + mask.shape, kernel, is_ratio, passes) + self._blur_type = blur_type self._mask = mask self._passes = passes kernel_size = self._get_kernel_size(kernel, is_ratio) self._kernel_size = self._get_kernel_tuple(kernel_size) - logger.trace("Initialized %s", self.__class__.__name__) + logger.trace("Initialized %s", self.__class__.__name__) # type: ignore @property - def blurred(self): + def blurred(self) -> np.ndarray: """ :class:`numpy.ndarray`: The final mask with blurring applied. """ func = self._func_mapping[self._blur_type] kwargs = self._get_kwargs() blurred = self._mask for i in range(self._passes): + assert isinstance(kwargs["ksize"], tuple) ksize = int(kwargs["ksize"][0]) - logger.trace("Pass: %s, kernel_size: %s", i + 1, (ksize, ksize)) + logger.trace("Pass: %s, kernel_size: %s", i + 1, (ksize, ksize)) # type: ignore blurred = func(blurred, **kwargs) ksize = int(round(ksize * self._multipass_factor)) kwargs["ksize"] = self._get_kernel_tuple(ksize) blurred = blurred[..., None] - logger.trace("Returning blurred mask. Shape: %s", blurred.shape) + logger.trace("Returning blurred mask. Shape: %s", blurred.shape) # type: ignore return blurred @property - def _multipass_factor(self): + def _multipass_factor(self) -> float: """ For multiple passes the kernel must be scaled down. This value is different for box filter and gaussian """ factor = dict(gaussian=0.8, normalized=0.5) return factor[self._blur_type] @property - def _sigma(self): + def _sigma(self) -> Literal[0]: """ int: The Sigma for Gaussian Blur. Returns 0 to force calculation from kernel size. """ return 0 @property - def _func_mapping(self): + def _func_mapping(self) -> Dict[Literal["gaussian", "normalized"], Callable]: """ dict: :attr:`_blur_type` mapped to cv2 Function name. """ return dict(gaussian=cv2.GaussianBlur, # pylint: disable = no-member normalized=cv2.blur) # pylint: disable = no-member @property - def _kwarg_requirements(self): + def _kwarg_requirements(self) -> Dict[Literal["gaussian", "normalized"], List[str]]: """ dict: :attr:`_blur_type` mapped to cv2 Function required keyword arguments. """ return dict(gaussian=["ksize", "sigmaX"], normalized=["ksize"]) @property - def _kwarg_mapping(self): + def _kwarg_mapping(self) -> Dict[str, Union[int, Tuple[int, int]]]: """ dict: cv2 function keyword arguments mapped to their parameters. """ return dict(ksize=self._kernel_size, sigmaX=self._sigma) - def _get_kernel_size(self, kernel, is_ratio): + def _get_kernel_size(self, kernel: Union[int, float], is_ratio: bool) -> int: """ Set the kernel size to absolute value. If :attr:`is_ratio` is ``True`` then the kernel size is calculated from the given ratio and @@ -873,16 +918,16 @@ def _get_kernel_size(self, kernel, is_ratio): The size (in pixels) of the blur kernel """ if not is_ratio: - return kernel + return int(kernel) mask_diameter = np.sqrt(np.sum(self._mask)) radius = round(max(1., mask_diameter * kernel / 100.)) kernel_size = int(radius * 2 + 1) - logger.trace("kernel_size: %s", kernel_size) + logger.trace("kernel_size: %s", kernel_size) # type: ignore return kernel_size @staticmethod - def _get_kernel_tuple(kernel_size): + def _get_kernel_tuple(kernel_size: int) -> Tuple[int, int]: """ Make sure kernel_size is odd and return it as a tuple. Parameters @@ -897,21 +942,22 @@ def _get_kernel_tuple(kernel_size): """ kernel_size += 1 if kernel_size % 2 == 0 else 0 retval = (kernel_size, kernel_size) - logger.trace(retval) + logger.trace(retval) # type: ignore return retval - def _get_kwargs(self): + def _get_kwargs(self) -> Dict[str, Union[int, Tuple[int, int]]]: """ dict: the valid keyword arguments for the requested :attr:`_blur_type` """ retval = {kword: self._kwarg_mapping[kword] for kword in self._kwarg_requirements[self._blur_type]} - logger.trace("BlurMask kwargs: %s", retval) + logger.trace("BlurMask kwargs: %s", retval) # type: ignore return retval -_HASHES_SEEN = dict() +_HASHES_SEEN: Dict[str, Dict[str, int]] = {} -def update_legacy_png_header(filename, alignments): +def update_legacy_png_header(filename: str, alignments: Alignments + ) -> Optional[PNGHeaderDict]: """ Update a legacy extracted face from pre v2.1 alignments by placing the alignment data for the face in the png exif header for the given filename with the given alignment data. @@ -938,7 +984,7 @@ def update_legacy_png_header(filename, alignments): # effective enough folder = os.path.dirname(filename) if folder not in _HASHES_SEEN: - _HASHES_SEEN[folder] = dict() + _HASHES_SEEN[folder] = {} hashes_seen = _HASHES_SEEN[folder] in_image = read_image(filename, raise_error=True) @@ -954,9 +1000,10 @@ def update_legacy_png_header(filename, alignments): detected_face.from_alignment(alignment) # For dupe hash handling, make sure we get a different filename for repeat hashes src_fname, face_idx = list(alignments.hashes_to_frame[in_hash].items())[hashes_seen[in_hash]] - orig_filename = "{}_{}.png".format(os.path.splitext(src_fname)[0], face_idx) - meta = dict(alignments=detected_face.to_png_meta(), - source=dict(alignments_version=alignments.version, + orig_filename = f"{os.path.splitext(src_fname)[0]}_{face_idx}.png" + meta = PNGHeaderDict(alignments=detected_face.to_png_meta(), + source=PNGHeaderSourceDict( + alignments_version=alignments.version, original_filename=orig_filename, face_index=face_idx, source_filename=src_fname, diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index 12e3a33b31..d030fde8b6 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -17,7 +17,7 @@ import cv2 import numpy as np -from tensorflow.python.framework import errors_impl as tf_errors +from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa from lib.utils import get_backend, FaceswapError from plugins.extract._base import Extractor, logger, ExtractMedia @@ -69,7 +69,7 @@ def __init__(self, git_model_id=None, model_filename=None, self.set_normalize_method(normalize_method) self._plugin_type = "align" - self._faces_per_filename = dict() # Tracking for recompiling face batches + self._faces_per_filename = {} # Tracking for recompiling face batches self._rollover = None # Items that are rolled over from the previous batch in get_batch self._output_faces = [] self._additional_keys = [] @@ -122,7 +122,7 @@ def get_batch(self, queue): A dictionary of lists of :attr:`~plugins.extract._base.Extractor.batchsize`: """ exhausted = False - batch = dict() + batch = {} idx = 0 while idx < self.batchsize: item = self._collect_item(queue) @@ -200,7 +200,7 @@ def finalize(self, batch): for face, landmarks in zip(batch["detected_faces"], batch["landmarks"]): if not isinstance(landmarks, np.ndarray): landmarks = np.array(landmarks) - face.landmarks_xy = landmarks + face._landmarks_xy = landmarks logger.trace("Item out: %s", {key: val.shape if isinstance(val, np.ndarray) else val for key, val in batch.items()}) @@ -244,13 +244,13 @@ def _process_input(self, batch): if not self._additional_keys: existing_keys = list(batch.keys()) - original_boxes = np.array([(face.x, face.y, face.w, face.h) + original_boxes = np.array([(face.left, face.top, face.width, face.height) for face in batch["detected_faces"]]) adjusted_boxes = self._get_adjusted_boxes(original_boxes) - retval = dict() + retval = {} for bounding_boxes in adjusted_boxes: for face, box in zip(batch["detected_faces"], bounding_boxes): - face.x, face.y, face.w, face.h = box + face.left, face.top, face.width, face.height = box result = self.process_input(batch) if not self._additional_keys: @@ -261,7 +261,7 @@ def _process_input(self, batch): # Place the original bounding box back to detected face objects for face, box in zip(batch["detected_faces"], original_boxes): - face.x, face.y, face.w, face.h = box + face.left, face.top, face.width, face.height = box batch.update(retval) return batch @@ -356,7 +356,7 @@ def _normalize_faces(self, faces): if self._normalize_method is None: return faces logger.trace("Normalizing faces") - meth = getattr(self, "_normalize_{}".format(self._normalize_method.lower())) + meth = getattr(self, f"_normalize_{self._normalize_method.lower()}") faces = [meth(face) for face in faces] logger.trace("Normalized faces") return faces diff --git a/plugins/extract/align/cv2_dnn.py b/plugins/extract/align/cv2_dnn.py index d9d1fae091..7d3b8f7725 100644 --- a/plugins/extract/align/cv2_dnn.py +++ b/plugins/extract/align/cv2_dnn.py @@ -68,7 +68,7 @@ def align_image(self, batch): det_face.top, det_face.right, det_face.bottom) - diff_height_width = det_face.h - det_face.w + diff_height_width = det_face.height - det_face.width offset_y = int(abs(diff_height_width / 2)) box_moved = self.move_box(box, [0, offset_y]) # Make box square. diff --git a/plugins/extract/align/fan.py b/plugins/extract/align/fan.py index 4f9c9ae55d..5fa4e0b028 100644 --- a/plugins/extract/align/fan.py +++ b/plugins/extract/align/fan.py @@ -53,8 +53,8 @@ def get_center_scale(self, detected_faces): center_scale = np.empty((len(detected_faces), 68, 3), dtype='float32') for index, face in enumerate(detected_faces): x_center = (face.left + face.right) / 2.0 - y_center = (face.top + face.bottom) / 2.0 - face.h * 0.12 - scale = (face.w + face.h) * self.reference_scale + y_center = (face.top + face.bottom) / 2.0 - face.height * 0.12 + scale = (face.width + face.height) * self.reference_scale center_scale[index, :, 0] = np.full(68, x_center, dtype='float32') center_scale[index, :, 1] = np.full(68, y_center, dtype='float32') center_scale[index, :, 2] = np.full(68, scale, dtype='float32') diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index 97c6effdaa..93a93c0b92 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -18,7 +18,7 @@ import cv2 import numpy as np -from tensorflow.python.framework import errors_impl as tf_errors +from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa from lib.align import DetectedFace from lib.utils import get_backend, FaceswapError @@ -112,7 +112,7 @@ def get_batch(self, queue): A dictionary of lists of :attr:`~plugins.extract._base.Extractor.batchsize`. """ exhausted = False - batch = dict() + batch = {} for _ in range(self.batchsize): item = self._get_item(queue) if item == "EOF": @@ -194,10 +194,10 @@ def finalize(self, batch): @staticmethod def to_detected_face(left, top, right, bottom): """ Return a :class:`~lib.align.DetectedFace` object for the bounding box """ - return DetectedFace(x=int(round(left)), - w=int(round(right - left)), - y=int(round(top)), - h=int(round(bottom - top))) + return DetectedFace(left=int(round(left)), + width=int(round(right - left)), + top=int(round(top)), + height=int(round(bottom - top))) # <<< PROTECTED ACCESS METHODS >>> # # <<< PREDICT WRAPPER >>> # @@ -332,7 +332,7 @@ def _filter_small_faces(self, detected_faces): for faces in detected_faces: this_image = [] for face in faces: - face_size = (face.w ** 2 + face.h ** 2) ** 0.5 + face_size = (face.width ** 2 + face.height ** 2) ** 0.5 if face_size < self.min_size: logger.debug("Removing detected face: (face_size: %s, min_size: %s", face_size, self.min_size) @@ -382,7 +382,7 @@ def _rotate_batch(self, batch, angle): batch["initial_feed"] = batch["feed"].copy() return - retval = dict() + retval = {} for img, faces, rotmat in zip(batch["initial_feed"], batch["prediction"], batch["rotmat"]): if faces.any(): image = np.zeros_like(img) @@ -431,10 +431,10 @@ def _rotate_face(face, rotation_matrix): width = pt_x1 - pt_x height = pt_y1 - pt_y - face.x = int(pt_x) - face.y = int(pt_y) - face.w = int(width) - face.h = int(height) + face.left = int(pt_x) + face.top = int(pt_y) + face.width = int(width) + face.height = int(height) return face def _rotate_image_by_angle(self, image, angle): diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index f536e3a04a..bfc3b75579 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -369,7 +369,7 @@ def _annotate_extract_boxes(cls, image, face, index): for area in ("face", "head"): face.load_aligned(image, centering=area, force=True) color = (0, 255, 0) if area == "face" else (0, 0, 255) - top_left = face.aligned.original_roi[0] # pylint:disable=unsubscriptable-object + top_left = face.aligned.original_roi[0] top_left = (top_left[0], top_left[1] - 10) cv2.putText(image, str(index), top_left, cv2.FONT_HERSHEY_DUPLEX, 1.0, color, 1) cv2.polylines(image, [face.aligned.original_roi], True, color, 1) @@ -385,7 +385,8 @@ def _annotate_pose(cls, image, face): face: :class:`lib.align.AlignedFace` The aligned face loaded for head centering """ - center = np.int32((face.aligned.size / 2, face.aligned.size / 2)).reshape(1, 2) + center = np.array((face.aligned.size / 2, + face.aligned.size / 2)).astype("int32").reshape(1, 2) center = np.rint(face.aligned.transform_points(center, invert=True)).astype("int32") points = face.aligned.pose.xyz_2d * face.aligned.size points = np.rint(face.aligned.transform_points(points, invert=True)).astype("int32") diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index 43138f51da..2afc563c59 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -188,7 +188,7 @@ def _set_tk_vars(): dict The internal variable name as key with the tkinter variable as value """ - retval = dict() + retval = {} for name in ("unsaved", "edited", "face_count_changed"): var = tk.BooleanVar() var.set(False) @@ -219,7 +219,7 @@ def _get_alignments(self, alignments_path, input_location): filename = "alignments.fsa" if self._globals.is_video: folder, vid = os.path.split(os.path.splitext(input_location)[0]) - filename = "{}_{}".format(vid, filename) + filename = f"{vid}_{filename}" else: folder = input_location retval = Alignments(folder, filename) @@ -407,7 +407,7 @@ def _background_extract(self, output_folder, progress_queue): progress_queue.put(1) for face_idx, face in enumerate(self._frame_faces[frame_idx]): - output = "{}_{}{}".format(frame_name, str(face_idx), ".png") + output = f"{frame_name}_{face_idx}.png" aligned = AlignedFace(face.landmarks_xy, image=image, centering="head", @@ -657,11 +657,11 @@ def bounding_box(self, frame_index, face_index, pnt_x, width, pnt_y, height, ali logger.trace("frame_index: %s, face_index %s, pnt_x %s, width %s, pnt_y %s, height %s, " "aligner: %s", frame_index, face_index, pnt_x, width, pnt_y, height, aligner) face = self._faces_at_frame_index(frame_index)[face_index] - face.x = pnt_x - face.w = width - face.y = pnt_y - face.h = height - face.landmarks_xy = self._extractor.get_landmarks(frame_index, face_index, aligner) + face.left = pnt_x + face.width = width + face.top = pnt_y + face.height = height + face._landmarks_xy = self._extractor.get_landmarks(frame_index, face_index, aligner) self._globals.tk_update.set(True) def landmark(self, frame_index, face_index, landmark_index, shift_x, shift_y, is_zoomed): @@ -689,7 +689,7 @@ def landmark(self, frame_index, face_index, landmark_index, shift_x, shift_y, is aligned = AlignedFace(face.landmarks_xy, centering="face", size=min(self._globals.frame_display_dims)) - landmark = aligned.landmarks[landmark_index] # pylint:disable=unsubscriptable-object + landmark = aligned.landmarks[landmark_index] landmark += (shift_x, shift_y) matrix = aligned.adjusted_matrix matrix = cv2.invertAffineTransform(matrix) @@ -728,9 +728,9 @@ def landmarks(self, frame_index, face_index, shift_x, shift_y): aligned with the newly adjusted landmarks. """ face = self._faces_at_frame_index(frame_index)[face_index] - face.x += shift_x - face.y += shift_y - face.landmarks_xy += (shift_x, shift_y) + face.left += shift_x + face.top += shift_y + face._landmarks_xy += (shift_x, shift_y) self._globals.tk_update.set(True) def landmarks_rotate(self, frame_index, face_index, angle, center): @@ -751,8 +751,8 @@ def landmarks_rotate(self, frame_index, face_index, angle, center): """ face = self._faces_at_frame_index(frame_index)[face_index] rot_mat = cv2.getRotationMatrix2D(tuple(center.astype("float32")), angle, 1.) - face.landmarks_xy = cv2.transform(np.expand_dims(face.landmarks_xy, axis=0), - rot_mat).squeeze() + face._landmarks_xy = cv2.transform(np.expand_dims(face.landmarks_xy, axis=0), + rot_mat).squeeze() self._globals.tk_update.set(True) def landmarks_scale(self, frame_index, face_index, scale, center): @@ -772,7 +772,7 @@ def landmarks_scale(self, frame_index, face_index, scale, center): The center point of the Landmark's Extract Box """ face = self._faces_at_frame_index(frame_index)[face_index] - face.landmarks_xy = ((face.landmarks_xy - center) * scale) + center + face._landmarks_xy = ((face.landmarks_xy - center) * scale) + center self._globals.tk_update.set(True) def mask(self, frame_index, face_index, mask, mask_type): @@ -825,7 +825,7 @@ def copy(self, frame_index, direction): # aligned_face cannot be deep copied, so remove and recreate to_copy = self._faces_at_frame_index(idx) for face in to_copy: - face.aligned = None + face._aligned = None # pylint:disable=protected-access copied = deepcopy(to_copy) for old_face, new_face in zip(to_copy, copied): @@ -1012,7 +1012,7 @@ def _load_from_video(self, pts_start, pts_end, start_index, segment_count): vidname = sample_filename[:sample_filename.rfind("_")] for idx, frame in enumerate(reader): frame_idx = idx + start_index - filename = "{}_{:06d}.png".format(vidname, frame_idx + 1) + filename = f"{vidname}_{frame_idx + 1:06d}.png" self._set_thumbail(filename, frame[..., ::-1], frame_idx) if idx == segment_count - 1: # Sometimes extra frames are picked up at the end of a segment, so stop diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index c826d80fdb..73c9205975 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -39,8 +39,8 @@ def __init__(self, canvas, tk_edited_variable): nose=(27, 36), jaw=(0, 17), chin=(8, 11)) - self._landmarks = dict() - self._tk_faces = dict() + self._landmarks = {} + self._tk_faces = {} self._objects = VisibleObjects(self) self._hoverbox = HoverBox(self) self._active_frame = ActiveFrame(self, tk_edited_variable) @@ -135,8 +135,8 @@ def _obtain_mask(cls, detected_face, mask_type): def reset(self): """ Reset all the cached objects on a face size change. """ - self._landmarks = dict() - self._tk_faces = dict() + self._landmarks = {} + self._tk_faces = {} def update(self, refresh_annotations=False): """ Update the viewport. @@ -198,7 +198,7 @@ def _update_viewport(self, refresh_annotations): def _discard_tk_faces(self): """ Remove any :class:`TKFace` objects from the cache that are not currently displayed. """ - keys = ["{}_{}".format(pnt_x, pnt_y) + keys = [f"{pnt_x}_{pnt_y}" for pnt_x, pnt_y in self._objects.visible_grid[:2].T.reshape(-1, 2)] for key in list(self._tk_faces): if key not in keys: @@ -303,7 +303,7 @@ def get_landmarks(self, frame_index, face_index, face, top_left, refresh=False): (`polygon`, `line`). The value is a list containing the (x, y) coordinates of each part of the mesh annotation, from the top left corner location. """ - key = "{}_{}".format(frame_index, face_index) + key = f"{frame_index}_{face_index}" landmarks = self._landmarks.get(key, None) if not landmarks or refresh: aligned = AlignedFace(face.landmarks_xy, @@ -311,7 +311,6 @@ def get_landmarks(self, frame_index, face_index, face, top_left, refresh=False): size=self.face_size) landmarks = dict(polygon=[], line=[]) for area, val in self._landmark_mapping.items(): - # pylint:disable=unsubscriptable-object points = aligned.landmarks[val[0]:val[1]] + top_left shape = "polygon" if area.endswith("eye") or area.startswith("mouth") else "line" landmarks[shape].append(points) @@ -844,13 +843,13 @@ def _clear_previous(self): self._canvas.itemconfig("active_highlighter", state="hidden") for key in ("polygon", "line"): - tag = "active_mesh_{}".format(key) + tag = f"active_mesh_{key}" self._canvas.itemconfig(tag, **self._viewport.mesh_kwargs[key], width=1) self._canvas.dtag(tag) if self._viewport.selected_editor == "mask" and not self._optional_annotations["mask"]: for key, tk_face in self._tk_faces.items(): - if key.startswith("{}_".format(self._last_execution["frame_index"])): + if key.startswith(f"{self._last_execution['frame_index']}_"): tk_face.update_mask(None) def _set_active_objects(self): @@ -995,7 +994,7 @@ def _show_mesh(self, mesh_ids, face_index, detected_face, top_left): for idx, mesh_id in enumerate(mesh_ids[key]): self._canvas.coords(mesh_id, *landmarks[key][idx].flatten()) self._canvas.itemconfig(mesh_id, state=state, **kwarg) - self._canvas.addtag_withtag("active_mesh_{}".format(key), mesh_id) + self._canvas.addtag_withtag(f"active_mesh_{key}", mesh_id) class TKFace(): diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 662223d945..a6350effad 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -62,7 +62,7 @@ def process(self): # If logging is enabled, prepare container if self._args.log_changes: - self.changes = dict() + self.changes = {} # Assign default sort_log.json value if user didn't specify one if self._args.log_file_path == 'sort_log.json': @@ -97,7 +97,7 @@ def launch_aligner(self): def alignment_dict(filename, image): """ Set the image to an ExtractMedia object for alignment """ height, width = image.shape[:2] - face = DetectedFace(x=0, w=width, y=0, h=height) + face = DetectedFace(left=0, width=width, top=0, height=height) return ExtractMedia(filename, image, detected_faces=[face]) def _get_landmarks(self): @@ -400,8 +400,7 @@ def sort_size(self): centering="legacy", is_aligned=True) roi = aligned_face.original_roi - size = ((roi[1][0] - roi[0][0]) ** 2 + # pylint:disable=unsubscriptable-object - (roi[1][1] - roi[0][1]) ** 2) ** 0.5 # pylint:disable=unsubscriptable-object + size = ((roi[1][0] - roi[0][0]) ** 2 + (roi[1][1] - roi[0][1]) ** 2) ** 0.5 img_list.append((filename, size)) logger.info("Sorting...") @@ -481,7 +480,7 @@ def group_face_cnn(self, img_list): logger.info("Grouping by face-cnn similarity...") # Groups are of the form: group_num -> reference faces - reference_groups = dict() + reference_groups = {} # Bins array, where index is the group number and value is # an array containing the file paths to the images in that group. @@ -569,7 +568,7 @@ def group_hist(self, img_list): logger.info("Grouping by histogram...") # Groups are of the form: group_num -> reference histogram - reference_groups = dict() + reference_groups = {} # Bins array, where index is the group number and value is # an array containing the file paths to the images in that group @@ -623,7 +622,7 @@ def final_process_rename(self, img_list): src = img_list[i] if isinstance(img_list[i], str) else img_list[i][0] src_basename = os.path.basename(src) - dst = os.path.join(output_dir, '{:05d}_{}'.format(i, src_basename)) + dst = os.path.join(output_dir, f"{i:05d}_{src_basename}") try: process_file(src, dst, self.changes) except FileNotFoundError as err: @@ -724,7 +723,7 @@ def reload_images(self, group_method, img_list): for img in image_list] temp_list = list(zip(filename_list, black_pixels)) else: - raise ValueError("{} group_method not found.".format(group_method)) + raise ValueError(f"{group_method} group_method not found.") return self.splice_lists(img_list, temp_list) @@ -967,10 +966,10 @@ def renaming(src, output_dir, i, changes): src_basename = os.path.basename(src) __src = os.path.join(output_dir, - '{:05d}_{}'.format(i, src_basename)) + f"{i:05d}_{src_basename}") dst = os.path.join( output_dir, - '{:05d}{}'.format(i, os.path.splitext(src_basename)[1])) + f"{i:05d}{os.path.splitext(src_basename)[1]}") changes[src] = dst return __src, dst else: @@ -979,10 +978,10 @@ def renaming(src, output_dir, i, changes): # pylint: disable=unused-argument src_basename = os.path.basename(src) src = os.path.join(output_dir, - '{:05d}_{}'.format(i, src_basename)) + f"{i:05d}_{src_basename}") dst = os.path.join( output_dir, - '{:05d}{}'.format(i, os.path.splitext(src_basename)[1])) + f"{i:05d}{os.path.splitext(src_basename)[1]}") return src, dst return renaming From 2beceffad9b15c1fd78f06b9b272563321c5a41e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 21 Aug 2022 18:59:45 +0100 Subject: [PATCH 695/981] Data Augmentation update (#1263) - lib.detected_face - Subclass Masks for Landmark based masks - Add training mask propery + methods to DetectedFace - lib.training_training - subclass TrainingDataGenerator for training and preview data - Split cache into own module - Reduce thread count to 1 to prevent image corruption + data re-use - Process on largest model input/output size rather than stored image size - Size and crop masks during caching stage - Implement ring buffer for data flow - Fix preview reload bug - augmentation - typing - switch color aug order - better initialization - Fix warp + landmark warp to correctly apply at different image scales - Slightly improved warp caching - Don't store whether image is_preview. Handle all data as training images implicitly - plugins.trainer: Typing and fixes to work with trainingdata refactor --- docs/full/lib/training.rst | 9 + lib/align/detected_face.py | 254 +++--- lib/cli/launcher.py | 15 +- lib/image.py | 26 +- lib/training/__init__.py | 2 +- lib/training/augmentation.py | 488 +++++------ lib/training/cache.py | 507 +++++++++++ lib/training/generator.py | 1230 ++++++++++++--------------- plugins/train/model/_base/model.py | 7 +- plugins/train/trainer/_base.py | 595 ++++++------- requirements/_requirements_base.txt | 1 + scripts/extract.py | 33 +- setup.cfg | 4 + 13 files changed, 1761 insertions(+), 1410 deletions(-) create mode 100644 lib/training/cache.py diff --git a/docs/full/lib/training.rst b/docs/full/lib/training.rst index ff8c3cde04..c23c617583 100644 --- a/docs/full/lib/training.rst +++ b/docs/full/lib/training.rst @@ -15,6 +15,15 @@ augmentation module :undoc-members: :show-inheritance: +cache module +============ + +.. automodule:: lib.training.cache + :members: + :undoc-members: + :show-inheritance: + + generator module ================ diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index 04ada9b568..eec3ca4fbf 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -106,6 +106,7 @@ def __init__(self, self._landmarks_xy = landmarks_xy self.thumbnail: Optional[np.ndarray] = None self.mask = {} if mask is None else mask + self._training_masks: Optional[Tuple[bytes, Tuple[int, int, int]]] = None self._aligned: Optional[AlignedFace] = None logger.trace("Initialized %s", self.__class__.__name__) # type: ignore @@ -172,59 +173,84 @@ def add_mask(self, fsmask.add(mask, affine_matrix, interpolator) self.mask[name] = fsmask - def get_landmark_mask(self, size, area, - aligned=True, centering="face", dilation=0, blur_kernel=0, as_zip=False): - """ Obtain a single channel mask based on the face's landmark points. + def get_landmark_mask(self, + area: Literal["eye", "face", "mouth"], + blur_kernel: int, + dilation: int) -> np.ndarray: + """ Add a :class:`LandmarksMask` to this detected face + + Landmark based masks are generated from face Aligned Face landmark points. An aligned + face must be loaded. As the data is coming from the already aligned face, no further mask + cropping is required. Parameters ---------- - size: int or tuple - The size of the aligned mask to retrieve. Should be an `int` if an aligned face is - being requested, or a ('height', 'width') shape tuple if a full frame is being - requested - area: ["mouth", "eyes"] + area: ["face", "mouth", "eye"] The type of mask to obtain. `face` is a full face mask the others are masks for those specific areas - aligned: bool, optional - ``True`` if the returned mask should be for an aligned face. ``False`` if a full frame - mask should be returned. Default ``True`` - centering: ["legacy", "face", "head"], optional - Only used if `aligned`=``True``. The centering for the landmarks based mask. Should be - the same as the centering used for the extracted face that this mask will be applied - to. "legacy" places the nose in the center of the image (the original method for - aligning). "face" aligns for the nose to be in the center of the face (top to bottom) - but the center of the skull for left to right. "head" aligns for the center of the - skull (in 3D space) being the center of the extracted image, with the crop holding the - full head. Default: `"face"` - dilation: int, optional + blur_kernel: int + The size of the kernel for blurring the mask edges + dilation: int The amount of dilation to apply to the mask. `0` for none. Default: `0` - blur_kernel: int, optional - The kernel size for applying gaussian blur to apply to the mask. `0` for none. - Default: `0` - as_zip: bool, optional - ``True`` if the mask should be returned zipped otherwise ``False`` Returns ------- - :class:`numpy.ndarray` or zipped array - The mask as a single channel image of the given :attr:`size` dimension. If - :attr:`as_zip` is ``True`` then the :class:`numpy.ndarray` will be contained within a - zipped container + :class:`numpy.ndarray` + The generated landmarks mask for the selected area """ # TODO Face mask generation from landmarks - logger.trace("size: %s, area: %s, aligned: %s, dilation: %s, blur_kernel: %s, as_zip: %s", - size, area, aligned, dilation, blur_kernel, as_zip) - areas = dict(mouth=[slice(48, 60)], eyes=[slice(36, 42), slice(42, 48)]) - if aligned: - face = AlignedFace(self.landmarks_xy, centering=centering, size=size) - landmarks = face.landmarks - size = (size, size) - else: - landmarks = self.landmarks_xy - points = [landmarks[zone] for zone in areas[area]] # pylint:disable=unsubscriptable-object - mask = _LandmarksMask(size, points, dilation=dilation, blur_kernel=blur_kernel) - retval = mask.get(as_zip=as_zip) - return retval + logger.trace("area: %s, dilation: %s", area, dilation) # type: ignore + areas = dict(mouth=[slice(48, 60)], eye=[slice(36, 42), slice(42, 48)]) + points = [self.aligned.landmarks[zone] + for zone in areas[area]] + + lmmask = LandmarksMask(points, + storage_size=self.aligned.size, + storage_centering=self.aligned.centering, + dilation=dilation) + lmmask.set_blur_and_threshold(blur_kernel=blur_kernel) + lmmask.generate_mask( + self.aligned.adjusted_matrix, + self.aligned.interpolators[1]) + return lmmask.mask + + def store_training_masks(self, + masks: List[Optional[np.ndarray]], + delete_masks: bool = False) -> None: + """ Concatenate and compress the given training masks and store for retrieval. + + Parameters + ---------- + masks: list + A list of training mask. Must be all be uint-8 3D arrays of the same size in + 0-255 range + delete_masks: bool, optional + ``True`` to delete any of the :class:`Mask` objects owned by this detected face. Use to + free up unrequired memory usage. Default: ``False`` + """ + if delete_masks: + del self.mask + self.mask = {} + + valid = [msk for msk in masks if msk is not None] + if not valid: + return + combined = np.concatenate(valid, axis=-1) + self._training_masks = (compress(combined), combined.shape) + + def get_training_masks(self) -> Optional[np.ndarray]: + """ Obtain the decompressed combined training masks. + + Returns + ------- + :class:`numpy.ndarray` + A 3D array containing the decompressed training masks as uint8 in 0-255 range if + training masks are present otherwise ``None`` + """ + if not self._training_masks: + return None + return np.frombuffer(decompress(self._training_masks[0]), + dtype="uint8").reshape(self._training_masks[1]) def to_alignment(self) -> AlignmentFileDict: """ Return the detected face formatted for an alignments file @@ -412,77 +438,6 @@ def load_aligned(self, is_legacy=is_aligned and is_legacy) -class _LandmarksMask(): # pylint:disable=too-few-public-methods - """ Create a single channel mask from aligned landmark points. - - size: tuple - The (height, width) shape tuple that the mask should be returned as - points: list - A list of landmark points that correspond to the given shape tuple to create - the mask. Each item in the list should be a :class:`numpy.ndarray` that a filled - convex polygon will be created from - dilation: int, optional - The amount of dilation to apply to the mask. `0` for none. Default: `0` - blur_kernel: int, optional - The kernel size for applying gaussian blur to apply to the mask. `0` for none. Default: `0` - """ - def __init__(self, size, points, dilation=0, blur_kernel=0): - logger.trace("Initializing: %s: (size: %s, points: %s, dilation: %s, blur_kernel: %s)", - self.__class__.__name__, size, points, dilation, blur_kernel) - self._size = size - self._points = points - self._dilation = dilation - self._blur_kernel = blur_kernel - self._mask = None - logger.trace("Initialized: %s", self.__class__.__name__) - - def get(self, as_zip=False): - """ Obtain the mask. - - Parameters - ---------- - as_zip: bool, optional - ``True`` if the mask should be returned zipped otherwise ``False`` - - Returns - ------- - :class:`numpy.ndarray` or zipped array - The mask as a single channel image of the given :attr:`size` dimension. If - :attr:`as_zip` is ``True`` then the :class:`numpy.ndarray` will be contained within a - zipped container - """ - if not np.any(self._mask): - self._generate_mask() - retval = compress(self._mask) if as_zip else self._mask - logger.trace("as_zip: %s, retval type: %s", as_zip, type(retval)) - return retval - - def _generate_mask(self): - """ Generate the mask. - - Creates the mask applying any requested dilation and blurring and assigns to - :attr:`_mask` - - Returns - ------- - :class:`numpy.ndarray` - The mask as a single channel image of the given :attr:`size` dimension. - """ - mask = np.zeros((self._size) + (1, ), dtype="float32") - for landmarks in self._points: - lms = np.rint(landmarks).astype("int") - cv2.fillConvexPoly(mask, cv2.convexHull(lms), 1.0, lineType=cv2.LINE_AA) - if self._dilation != 0: - mask = cv2.dilate(mask, - cv2.getStructuringElement(cv2.MORPH_ELLIPSE, - (self._dilation, self._dilation)), - iterations=1) - if self._blur_kernel != 0: - mask = BlurMask("gaussian", mask, self._blur_kernel).blurred - logger.trace("mask: (shape: %s, dtype: %s)", mask.shape, mask.dtype) - self._mask = (mask * 255.0).astype("uint8") - - class Mask(): """ Face Mask information and convenience methods @@ -805,6 +760,79 @@ def from_dict(self, mask_dict: MaskAlignmentsFileDict) -> None: for k, v in mask_dict.items()}) +class LandmarksMask(Mask): + """ Create a single channel mask from aligned landmark points. + + Landmarks masks are created on the fly, so the stored centering and size should be the same as + the aligned face that the mask will be applied to. As the masks are created on the fly, blur + + dilation is applied to the mask at creation (prior to compression) rather than after + decompression when requested. + + Note + ---- + Threshold is not used for Landmarks mask as the mask is binary + + Parameters + ---------- + points: list + A list of landmark points that correspond to the given storage_size to create + the mask. Each item in the list should be a :class:`numpy.ndarray` that a filled + convex polygon will be created from + storage_size: int, optional + The size (in pixels) that the compressed mask should be stored at. Default: 128. + storage_centering, str (optional): + The centering to store the mask at. One of `"legacy"`, `"face"`, `"head"`. + Default: `"face"` + dilation: int, optional + The amount of dilation to apply to the mask. `0` for none. Default: `0` + """ + def __init__(self, + points: List[np.ndarray], + storage_size: int = 128, + storage_centering: "CenteringType" = "face", + dilation: int = 0) -> None: + super().__init__(storage_size=storage_size, storage_centering=storage_centering) + self._points = points + self._dilation = dilation + + @property + def mask(self) -> np.ndarray: + """ :class:`numpy.ndarray`: Overrides the default mask property, creating the processed + mask at first call and compressing it. The decompressed mask is returned from this + property. """ + return self.stored_mask + + def generate_mask(self, affine_matrix: np.ndarray, interpolator: int) -> None: + """ Generate the mask. + + Creates the mask applying any requested dilation and blurring and assigns compressed mask + to :attr:`_mask` + + Parameters + ---------- + affine_matrix: :class:`numpy.ndarray` + The transformation matrix required to transform the mask to the original frame. + interpolator, int: + The CV2 interpolator required to transform this mask to it's original frame + """ + mask = np.zeros((self.stored_size, self.stored_size, 1), dtype="float32") + for landmarks in self._points: + lms = np.rint(landmarks).astype("int") + cv2.fillConvexPoly(mask, cv2.convexHull(lms), 1.0, lineType=cv2.LINE_AA) + if self._dilation != 0: + mask = cv2.dilate(mask, + cv2.getStructuringElement(cv2.MORPH_ELLIPSE, + (self._dilation, self._dilation)), + iterations=1) + if self._blur_kernel != 0 and self._blur_type is not None: + mask = BlurMask(self._blur_type, + mask, + self._blur_kernel, + passes=self._blur_passes).blurred + logger.trace("mask: (shape: %s, dtype: %s)", mask.shape, mask.dtype) # type: ignore + self.add(mask, affine_matrix, interpolator) + + class BlurMask(): # pylint:disable=too-few-public-methods """ Factory class to return the correct blur object for requested blur type. diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 1171a43b68..15fb3c27d9 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -42,6 +42,7 @@ def _import_script(self) -> Callable: class: Faceswap Script The uninitialized script from the faceswap scripts folder. """ + self._set_environment_variables() self._test_for_tf_version() self._test_for_gui() cmd = os.path.basename(sys.argv[0]) @@ -51,6 +52,17 @@ def _import_script(self) -> Callable: script = getattr(module, self._command.title()) return script + def _set_environment_variables(self) -> None: + """ Set the number of threads that numexpr can use and TF environment variables. """ + # Allocate a decent number of threads to numexpr to suppress warnings + cpu_count = os.cpu_count() + allocate = cpu_count - cpu_count // 3 if cpu_count is not None else 1 + os.environ["NUMEXPR_MAX_THREADS"] = str(max(1, allocate)) + + # Ensure tensorflow doesn't pin all threads to one core when using Math Kernel Library + os.environ["TF_MIN_GPU_MULTIPROCESSOR_COUNT"] = "4" + os.environ["KMP_AFFINITY"] = "disabled" + def _test_for_tf_version(self) -> None: """ Check that the required Tensorflow version is installed. @@ -63,9 +75,6 @@ def _test_for_tf_version(self) -> None: min_ver = 2.7 max_ver = 2.9 try: - # Ensure tensorflow doesn't pin all threads to one core when using Math Kernel Library - os.environ["TF_MIN_GPU_MULTIPROCESSOR_COUNT"] = "4" - os.environ["KMP_AFFINITY"] = "disabled" import tensorflow as tf # noqa pylint:disable=import-outside-toplevel,unused-import except ImportError as err: if "DLL load failed while importing" in str(err): diff --git a/lib/image.py b/lib/image.py index c7dc31b244..6b72339f2b 100644 --- a/lib/image.py +++ b/lib/image.py @@ -356,26 +356,20 @@ def read_image_batch(filenames, with_metadata=False): >>> images = read_image_batch(image_filenames) """ logger.trace("Requested batch: '%s'", filenames) - executor = futures.ThreadPoolExecutor() - with executor: + batch = [None for _ in range(len(filenames))] + if with_metadata: + meta = [None for _ in range(len(filenames))] + + with futures.ThreadPoolExecutor() as executor: images = {executor.submit(read_image, filename, - raise_error=True, with_metadata=with_metadata): filename - for filename in filenames} - batch = [None for _ in range(len(filenames))] - if with_metadata: - meta = [None for _ in range(len(filenames))] - # There is no guarantee that the same filename will not be passed through multiple times - # (and when shuffle is true this can definitely happen), so we can't just call - # filenames.index(). - return_indices = {filename: [idx for idx, fname in enumerate(filenames) - if fname == filename] - for filename in set(filenames)} + raise_error=True, with_metadata=with_metadata): idx + for idx, filename in enumerate(filenames)} for future in futures.as_completed(images): - return_idx = return_indices[images[future]].pop() + ret_idx = images[future] if with_metadata: - batch[return_idx], meta[return_idx] = future.result() + batch[ret_idx], meta[ret_idx] = future.result() else: - batch[return_idx] = future.result() + batch[ret_idx] = future.result() batch = np.array(batch) retval = (batch, meta) if with_metadata else batch diff --git a/lib/training/__init__.py b/lib/training/__init__.py index 6b0d296658..b697eeff0f 100644 --- a/lib/training/__init__.py +++ b/lib/training/__init__.py @@ -3,4 +3,4 @@ associated objects. """ from .augmentation import ImageAugmentation # noqa -from .generator import TrainingDataGenerator # noqa +from .generator import PreviewDataGenerator, TrainingDataGenerator # noqa diff --git a/lib/training/augmentation.py b/lib/training/augmentation.py index 5b0eff2309..d5e44a800a 100644 --- a/lib/training/augmentation.py +++ b/lib/training/augmentation.py @@ -1,16 +1,67 @@ #!/usr/bin/env python3 """ Processes the augmentation of images for feeding into a Faceswap model. """ +from dataclasses import dataclass import logging +from typing import Tuple, TYPE_CHECKING import cv2 +import numexpr as ne import numpy as np from scipy.interpolate import griddata from lib.image import batch_convert_color +if TYPE_CHECKING: + from plugins.train.trainer._base import ConfigType + logger = logging.getLogger(__name__) # pylint: disable=invalid-name +@dataclass +class AugConstants: + """ Dataclass for holding constants for Image Augmentation. + + Paramaters + ---------- + clahe_base_contrast: int + The base number for Contrast Limited Adaptive Histogram Equalization + clahe_chance: float + Probability to perform Contrast Limited Adaptive Histogram Equilization + clahe_max_size: int + Maximum clahe window size + lab_adjust: np.ndarray + Adjustment amounts for L*A*B augmentation + transform_rotation: int + Rotation range for transformations + transform_zoom: float + Zoom range for transformations + transform_shift: float + Shift range for transformations + warp_maps: :class:`numpy.ndarray` + The stacked (x, y) mappings for image warping + warp_pads: tuple + The padding to apply for image warping + warp_slices: slice + The slices for extracting a warped image + warp_lm_edge_anchors: :class:`numpy.ndarray` + The edge anchors for landmark based warping + warp_lm_grids: :class:`numpy.ndarray` + The grids for landmark based warping + """ + clahe_base_contrast: int + clahe_chance: float + clahe_max_size: int + lab_adjust: np.ndarray + transform_rotation: int + transform_zoom: float + transform_shift: float + warp_maps: np.ndarray + warp_pad: Tuple[int, int] + warp_slices: slice + warp_lm_edge_anchors: np.ndarray + warp_lm_grids: np.ndarray + + class ImageAugmentation(): """ Performs augmentation on batches of training images. @@ -18,191 +69,81 @@ class ImageAugmentation(): ---------- batchsize: int The number of images that will be fed through the augmentation functions at once. - is_display: bool - Whether the images being fed through will be used for Preview or Time-lapse. Disables - the "warp" augmentation for these images. - input_size: int - The expected input size for the model. It is assumed that the input to the model is always - a square image. This is the size, in pixels, of the `width` and the `height` of the input - to the model. - output_shapes: list - A list of tuples defining the output shapes from the model, in the order that the outputs - are returned. The tuples should be in (`height`, `width`, `channels`) format. - coverage_ratio: float - The ratio of the training image to be trained on. Dictates how much of the image will be - cropped out. E.G: a coverage ratio of 0.625 will result in cropping a 160px box from a - 256px image (:math:`256 * 0.625 = 160`) + processing_size: int + The largest input or output size of the model. This is the size that images are processed + at. config: dict The configuration `dict` generated from :file:`config.train.ini` containing the trainer plugin configuration options. - - Attributes - ---------- - initialized: bool - Flag to indicate whether :class:`ImageAugmentation` has been initialized with the training - image size in order to cache certain augmentation operations (see :func:`initialize`) - is_display: bool - Flag to indicate whether these augmentations are for time-lapses/preview images (``True``) - or standard training data (``False``) """ - def __init__(self, batchsize, is_display, input_size, output_shapes, coverage_ratio, config): - logger.debug("Initializing %s: (batchsize: %s, is_display: %s, input_size: %s, " - "output_shapes: %s, coverage_ratio: %s, config: %s)", - self.__class__.__name__, batchsize, is_display, input_size, output_shapes, - coverage_ratio, config) - - self.initialized = False - self.is_display = is_display - - # Set on first image load from initialize - self._training_size = 0 - self._constants = None - + def __init__(self, + batchsize: int, + processing_size: int, + config: "ConfigType") -> None: + logger.debug("Initializing %s: (batchsize: %s, processing_size: %s, " + "config: %s)", + self.__class__.__name__, batchsize, processing_size, config) + + self._processing_size = processing_size self._batchsize = batchsize self._config = config - # Transform and Warp args - self._input_size = input_size - self._output_sizes = [shape[1] for shape in output_shapes if shape[2] == 3] - logger.debug("Output sizes: %s", self._output_sizes) + # Warp args - self._coverage_ratio = coverage_ratio - self._scale = 5 # Normal random variable scale + self._warp_scale = 5 / 256 * self._processing_size # Normal random variable scale + self._warp_lm_scale = 2 / 256 * self._processing_size # Normal random variable scale + self._constants = self._get_constants() logger.debug("Initialized %s", self.__class__.__name__) - def initialize(self, training_size): + def _get_constants(self) -> AugConstants: """ Initializes the caching of constants for use in various image augmentations. - The training image size is not known prior to loading the images from disk and commencing - training, so it cannot be set in the :func:`__init__` method. When the first training batch - is loaded this function should be called to initialize the class and perform various - calculations based on this input size to cache certain constants for image augmentation - calculations. + Returns + ------- + dict + Cached constants that are used for various augmentations + """ + logger.debug("Initializing constants.") - Parameters - ---------- - training_size: int - The size of the training images stored on disk that are to be fed into - :class:`ImageAugmentation`. The training images should always be square and of the - same size. This is the size, in pixels, of the `width` and the `height` of the - training images. - """ - logger.debug("Initializing constants. training_size: %s", training_size) - self._training_size = training_size - coverage = int(self._training_size * self._coverage_ratio // 2) * 2 + # Transform + tform_shift = (int(self._config.get("shift_range", 5)) / 100) * self._processing_size # Color Aug - clahe_base_contrast = training_size // 128 - # Target Images - tgt_slices = slice(self._training_size // 2 - coverage // 2, - self._training_size // 2 + coverage // 2) + amount_l = int(self._config.get("color_lightness", 30)) / 100 + amount_ab = int(self._config.get("color_ab", 8)) / 100 + lab_adjust = np.array([amount_l, amount_ab, amount_ab], dtype="float32") # Random Warp - warp_range_ = np.linspace(self._training_size // 2 - coverage // 2, - self._training_size // 2 + coverage // 2, 5, dtype='float32') - warp_mapx = np.broadcast_to(warp_range_, (self._batchsize, 5, 5)).astype("float32") + warp_range = np.linspace(0, self._processing_size, 5, dtype='float32') + warp_mapx = np.broadcast_to(warp_range, (self._batchsize, 5, 5)).astype("float32") warp_mapy = np.broadcast_to(warp_mapx[0].T, (self._batchsize, 5, 5)).astype("float32") - - warp_pad = int(1.25 * self._input_size) - warp_slices = slice(warp_pad // 10, -warp_pad // 10) + warp_pad = int(1.25 * self._processing_size) # Random Warp Landmarks - p_mx = self._training_size - 1 - p_hf = (self._training_size // 2) - 1 + p_mx = self._processing_size - 1 + p_hf = (self._processing_size // 2) - 1 edge_anchors = np.array([(0, 0), (0, p_mx), (p_mx, p_mx), (p_mx, 0), (p_hf, 0), (p_hf, p_mx), (p_mx, p_hf), (0, p_hf)]).astype("int32") edge_anchors = np.broadcast_to(edge_anchors, (self._batchsize, 8, 2)) - grids = np.mgrid[0:p_mx:complex(self._training_size), 0:p_mx:complex(self._training_size)] - - self._constants = dict(clahe_base_contrast=clahe_base_contrast, - tgt_slices=tgt_slices, - warp_mapx=warp_mapx, - warp_mapy=warp_mapy, - warp_pad=warp_pad, - warp_slices=warp_slices, - warp_lm_edge_anchors=edge_anchors, - warp_lm_grids=grids) - self.initialized = True - logger.debug("Initialized constants: %s", {k: str(v) if isinstance(v, np.ndarray) else v - for k, v in self._constants.items()}) - - # <<< TARGET IMAGES >>> # - def get_targets(self, batch): - """ Returns the target images, and masks, if required. - - Parameters - ---------- - batch: :class:`numpy.ndarray` - This should be a 4+-dimensional array of training images in the format (`batchsize`, - `height`, `width`, `channels`). Targets should be requested after performing image - transformations but prior to performing warps. - - The 4th channel should be the mask. Any channels above the 4th should be any additional - masks that are requested. - - Returns - ------- - dict - The following keys will be within the returned dictionary: - - * **targets** (`list`) - A list of 4-dimensional :class:`numpy.ndarray` s in the \ - order and size of each output of the model as defined in :attr:`output_shapes`. The \ - format of these arrays will be (`batchsize`, `height`, `width`, `3`). **NB:** \ - masks are not included in the `targets` list. If masks are to be included in the \ - output they will be returned as their own item from the `masks` key. - - * **masks** (:class:`numpy.ndarray`) - A 4-dimensional array containing the target \ - masks in the format (`batchsize`, `height`, `width`, `1`). - """ - logger.trace("Compiling targets: batch shape: %s", batch.shape) - slices = self._constants["tgt_slices"] - target_batch = [np.array([cv2.resize(image[slices, slices, :], - (size, size), - cv2.INTER_AREA) - for image in batch], dtype='float32') / 255. - for size in self._output_sizes] - logger.trace("Target image shapes: %s", - [tgt_images.shape for tgt_images in target_batch]) - - retval = self._separate_target_mask(target_batch) - logger.trace("Final targets: %s", - {k: v.shape if isinstance(v, np.ndarray) else [img.shape for img in v] - for k, v in retval.items()}) - return retval - - @staticmethod - def _separate_target_mask(target_batch): - """ Return the batch and the batch of final masks - - Parameters - ---------- - target_batch: list - List of 4 dimension :class:`numpy.ndarray` objects resized the model outputs. - The 4th channel of the array contains the face mask, any additional channels after - this are additional masks (e.g. eye mask and mouth mask) - - Returns - ------- - dict: - The targets and the masks separated into their own items. The targets are a list of - 3 channel, 4 dimensional :class:`numpy.ndarray` objects sized for each output from the - model. The masks are a :class:`numpy.ndarray` of the final output size. Any additional - masks(e.g. eye and mouth masks) will be collated together into a :class:`numpy.ndarray` - of the final output size. The number of channels will be the number of additional - masks available - """ - logger.trace("target_batch shapes: %s", [tgt.shape for tgt in target_batch]) - retval = dict(targets=[batch[..., :3] for batch in target_batch], - masks=target_batch[-1][..., 3][..., None]) - if target_batch[-1].shape[-1] > 4: - retval["additional_masks"] = target_batch[-1][..., 4:] - logger.trace("returning: %s", {k: v.shape if isinstance(v, np.ndarray) else [tgt.shape - for tgt in v] - for k, v in retval.items()}) + grids = np.mgrid[0: p_mx: complex(self._processing_size), # type: ignore + 0: p_mx: complex(self._processing_size)] # type: ignore + retval = AugConstants(clahe_base_contrast=max(2, self._processing_size // 128), + clahe_chance=int(self._config.get("color_clahe_chance", 50)) / 100, + clahe_max_size=int(self._config.get("color_clahe_max_size", 4)), + lab_adjust=lab_adjust, + transform_rotation=int(self._config.get("rotation_range", 10)), + transform_zoom=int(self._config.get("zoom_amount", 5)) / 100, + transform_shift=tform_shift, + warp_maps=np.stack((warp_mapx, warp_mapy), axis=1), + warp_pad=(warp_pad, warp_pad), + warp_slices=slice(warp_pad // 10, -warp_pad // 10), + warp_lm_edge_anchors=edge_anchors, + warp_lm_grids=grids) + logger.debug("Initialized constants: %s", retval) return retval # <<< COLOR AUGMENTATION >>> # - def color_adjust(self, batch): + def color_adjust(self, batch: np.ndarray) -> np.ndarray: """ Perform color augmentation on the passed in batch. The color adjustment parameters are set in :file:`config.train.ini` @@ -219,49 +160,44 @@ def color_adjust(self, batch): A 4-dimensional array of the same shape as :attr:`batch` with color augmentation applied. """ - if not self.is_display: - logger.trace("Augmenting color") - batch = batch_convert_color(batch, "BGR2LAB") - batch = self._random_clahe(batch) - batch = self._random_lab(batch) - batch = batch_convert_color(batch, "LAB2BGR") + logger.trace("Augmenting color") # type: ignore + batch = batch_convert_color(batch, "BGR2LAB") + self._random_lab(batch) + self._random_clahe(batch) + batch = batch_convert_color(batch, "LAB2BGR") return batch - def _random_clahe(self, batch): + def _random_clahe(self, batch: np.ndarray) -> None: """ Randomly perform Contrast Limited Adaptive Histogram Equalization on a batch of images """ - base_contrast = self._constants["clahe_base_contrast"] + base_contrast = self._constants.clahe_base_contrast batch_random = np.random.rand(self._batchsize) - indices = np.where(batch_random < self._config.get("color_clahe_chance", 50) / 100)[0] + indices = np.where(batch_random < self._constants.clahe_chance)[0] if not np.any(indices): - return batch - - grid_bases = np.rint(np.random.uniform(0, - self._config.get("color_clahe_max_size", 4), - size=indices.shape[0])).astype("uint8") - contrast_adjustment = (grid_bases * (base_contrast // 2)) - grid_sizes = contrast_adjustment + base_contrast - logger.trace("Adjusting Contrast. Grid Sizes: %s", grid_sizes) + return + grid_bases = np.random.randint(self._constants.clahe_max_size + 1, + size=indices.shape[0], + dtype="uint8") + grid_sizes = (grid_bases * (base_contrast // 2)) + base_contrast + logger.trace("Adjusting Contrast. Grid Sizes: %s", grid_sizes) # type: ignore clahes = [cv2.createCLAHE(clipLimit=2.0, # pylint: disable=no-member tileGridSize=(grid_size, grid_size)) for grid_size in grid_sizes] for idx, clahe in zip(indices, clahes): - batch[idx, :, :, 0] = clahe.apply(batch[idx, :, :, 0]) - return batch + batch[idx, :, :, 0] = clahe.apply(batch[idx, :, :, 0], ) - def _random_lab(self, batch): + def _random_lab(self, batch: np.ndarray) -> None: """ Perform random color/lightness adjustment in L*a*b* color space on a batch of images """ - amount_l = self._config.get("color_lightness", 30) / 100 - amount_ab = self._config.get("color_ab", 8) / 100 - adjust = np.array([amount_l, amount_ab, amount_ab], dtype="float32") - randoms = ( - (np.random.rand(self._batchsize, 1, 1, 3).astype("float32") * (adjust * 2)) - adjust) - logger.trace("Random LAB adjustments: %s", randoms) - + randoms = np.random.uniform(-self._constants.lab_adjust, + self._constants.lab_adjust, + size=(self._batchsize, 1, 1, 3)).astype("float32") + logger.trace("Random LAB adjustments: %s", randoms) # type: ignore + # Iterating through the images and channels is much faster than numpy.where and slightly + # faster than numexpr.where. for image, rand in zip(batch, randoms): for idx in range(rand.shape[-1]): adjustment = rand[:, :, idx] @@ -269,10 +205,9 @@ def _random_lab(self, batch): image[:, :, idx] = ((255 - image[:, :, idx]) * adjustment) + image[:, :, idx] else: image[:, :, idx] = image[:, :, idx] * (1 + adjustment) - return batch # <<< IMAGE AUGMENTATION >>> # - def transform(self, batch): + def transform(self, batch: np.ndarray): """ Perform random transformation on the passed in batch. The transformation parameters are set in :file:`config.train.ini` @@ -282,47 +217,36 @@ def transform(self, batch): batch: :class:`numpy.ndarray` The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `channels`) and in `BGR` format. - - Returns - ---------- - :class:`numpy.ndarray` - A 4-dimensional array of the same shape as :attr:`batch` with transformation applied. """ - if self.is_display: - return batch - logger.trace("Randomly transforming image") - rotation_range = self._config.get("rotation_range", 10) - zoom_range = self._config.get("zoom_amount", 5) / 100 - shift_range = self._config.get("shift_range", 5) / 100 - - rotation = np.random.uniform(-rotation_range, - rotation_range, + logger.trace("Randomly transforming image") # type: ignore + + rotation = np.random.uniform(-self._constants.transform_rotation, + self._constants.transform_rotation, size=self._batchsize).astype("float32") - scale = np.random.uniform(1 - zoom_range, - 1 + zoom_range, + scale = np.random.uniform(1 - self._constants.transform_zoom, + 1 + self._constants.transform_zoom, size=self._batchsize).astype("float32") - tform = np.random.uniform( - -shift_range, - shift_range, - size=(self._batchsize, 2)).astype("float32") * self._training_size + tform = np.random.uniform(-self._constants.transform_shift, + self._constants.transform_shift, + size=(self._batchsize, 2)).astype("float32") mats = np.array( - [cv2.getRotationMatrix2D((self._training_size // 2, self._training_size // 2), + [cv2.getRotationMatrix2D((self._processing_size // 2, self._processing_size // 2), rot, scl) for rot, scl in zip(rotation, scale)]).astype("float32") mats[..., 2] += tform - batch = np.array([cv2.warpAffine(image, - mat, - (self._training_size, self._training_size), - borderMode=cv2.BORDER_REPLICATE) - for image, mat in zip(batch, mats)]) + for image, mat in zip(batch, mats): + cv2.warpAffine(image, + mat, + (self._processing_size, self._processing_size), + dst=image, + borderMode=cv2.BORDER_REPLICATE) - logger.trace("Randomly transformed image") - return batch + logger.trace("Randomly transformed image") # type: ignore - def random_flip(self, batch): + def random_flip(self, batch: np.ndarray): """ Perform random horizontal flipping on the passed in batch. The probability of flipping an image is set in :file:`config.train.ini` @@ -332,21 +256,15 @@ def random_flip(self, batch): batch: :class:`numpy.ndarray` The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `channels`) and in `BGR` format. - - Returns - ---------- - :class:`numpy.ndarray` - A 4-dimensional array of the same shape as :attr:`batch` with transformation applied. """ - if not self.is_display: - logger.trace("Randomly flipping image") - randoms = np.random.rand(self._batchsize) - indices = np.where(randoms > self._config.get("random_flip", 50) / 100)[0] - batch[indices] = batch[indices, :, ::-1] - logger.trace("Randomly flipped %s images of %s", len(indices), self._batchsize) - return batch - - def warp(self, batch, to_landmarks=False, **kwargs): + logger.trace("Randomly flipping image") # type: ignore + randoms = np.random.rand(self._batchsize) + indices = np.where(randoms > int(self._config.get("random_flip", 50)) / 100)[0] + batch[indices] = batch[indices, :, ::-1] + logger.trace("Randomly flipped %s images of %s", # type: ignore + len(indices), self._batchsize) + + def warp(self, batch: np.ndarray, to_landmarks: bool = False, **kwargs) -> np.ndarray: """ Perform random warping on the passed in batch by one of two methods. Parameters @@ -367,43 +285,71 @@ def warp(self, batch, to_landmarks=False, **kwargs): * **batch_dst_points** (:class:`numpy.ndarray`) - A batch of randomly chosen closest \ match destination faces landmarks. This is a 3-dimensional array in the shape \ (`batchsize`, `68`, `2`). + Returns ---------- :class:`numpy.ndarray` A 4-dimensional array of the same shape as :attr:`batch` with warping applied. """ if to_landmarks: - return self._random_warp_landmarks(batch, **kwargs).astype("float32") / 255.0 - return self._random_warp(batch).astype("float32") / 255.0 + return self._random_warp_landmarks(batch, **kwargs) + return self._random_warp(batch) - def _random_warp(self, batch): - """ Randomly warp the input batch """ - logger.trace("Randomly warping batch") - mapx = self._constants["warp_mapx"] - mapy = self._constants["warp_mapy"] - pad = self._constants["warp_pad"] - slices = self._constants["warp_slices"] + def _random_warp(self, batch: np.ndarray) -> np.ndarray: + """ Randomly warp the input batch + Parameters + ---------- + batch: :class:`numpy.ndarray` + The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, + `3`) and in `BGR` format. + + Returns + ---------- + :class:`numpy.ndarray` + A 4-dimensional array of the same shape as :attr:`batch` with warping applied. + """ + logger.trace("Randomly warping batch") # type: ignore + slices = self._constants.warp_slices rands = np.random.normal(size=(self._batchsize, 2, 5, 5), - scale=self._scale).astype("float32") - batch_maps = np.stack((mapx, mapy), axis=1) + rands - batch_interp = np.array([[cv2.resize(map_, (pad, pad))[slices, slices] for map_ in maps] + scale=self._warp_scale).astype("float32") + batch_maps = ne.evaluate("m + r", local_dict=dict(m=self._constants.warp_maps, r=rands)) + batch_interp = np.array([[cv2.resize(map_, self._constants.warp_pad)[slices, slices] + for map_ in maps] for maps in batch_maps]) warped_batch = np.array([cv2.remap(image, interp[0], interp[1], cv2.INTER_LINEAR) for image, interp in zip(batch, batch_interp)]) - logger.trace("Warped image shape: %s", warped_batch.shape) + logger.trace("Warped image shape: %s", warped_batch.shape) # type: ignore return warped_batch - def _random_warp_landmarks(self, batch, batch_src_points, batch_dst_points): - """ From dfaker. Warp the image to a similar set of landmarks from the opposite side """ - logger.trace("Randomly warping landmarks") - edge_anchors = self._constants["warp_lm_edge_anchors"] - grids = self._constants["warp_lm_grids"] - slices = self._constants["tgt_slices"] + def _random_warp_landmarks(self, + batch: np.ndarray, + batch_src_points: np.ndarray, + batch_dst_points: np.ndarray) -> np.ndarray: + """ From dfaker. Warp the image to a similar set of landmarks from the opposite side + + batch: :class:`numpy.ndarray` + The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, + `3`) and in `BGR` format. + batch_src_points :class:`numpy.ndarray` + A batch of 68 point landmarks for the source faces. This is a 3-dimensional array in + the shape (`batchsize`, `68`, `2`). + batch_dst_points :class:`numpy.ndarray` + A batch of randomly chosen closest match destination faces landmarks. This is a + 3-dimensional array in the shape (`batchsize`, `68`, `2`). + + Returns + ---------- + :class:`numpy.ndarray` + A 4-dimensional array of the same shape as :attr:`batch` with warping applied. + """ + logger.trace("Randomly warping landmarks") # type: ignore + edge_anchors = self._constants.warp_lm_edge_anchors + grids = self._constants.warp_lm_grids batch_dst = (batch_dst_points + np.random.normal(size=batch_dst_points.shape, - scale=2.0)) + scale=self._warp_lm_scale)) face_cores = [cv2.convexHull(np.concatenate([src[17:], dst[17:]], axis=0)) for src, dst in zip(batch_src_points.astype("int32"), @@ -418,14 +364,14 @@ def _random_warp_landmarks(self, batch, batch_src_points, batch_dst_points): for src, dst, face_core in zip(batch_src[:, :18, :], batch_dst[:, :18, :], face_cores)] - batch_src = [np.delete(src, idxs, axis=0) for idxs, src in zip(rem_indices, batch_src)] - batch_dst = [np.delete(dst, idxs, axis=0) for idxs, dst in zip(rem_indices, batch_dst)] + lbatch_src = [np.delete(src, idxs, axis=0) for idxs, src in zip(rem_indices, batch_src)] + lbatch_dst = [np.delete(dst, idxs, axis=0) for idxs, dst in zip(rem_indices, batch_dst)] grid_z = np.array([griddata(dst, src, (grids[0], grids[1]), method="linear") - for src, dst in zip(batch_src, batch_dst)]) + for src, dst in zip(lbatch_src, lbatch_dst)]) maps = grid_z.reshape((self._batchsize, - self._training_size, - self._training_size, + self._processing_size, + self._processing_size, 2)).astype("float32") warped_batch = np.array([cv2.remap(image, map_[..., 1], @@ -433,33 +379,5 @@ def _random_warp_landmarks(self, batch, batch_src_points, batch_dst_points): cv2.INTER_LINEAR, cv2.BORDER_TRANSPARENT) for image, map_ in zip(batch, maps)]) - warped_batch = np.array([cv2.resize(image[slices, slices, :], - (self._input_size, self._input_size), - cv2.INTER_AREA) - for image in warped_batch]) - logger.trace("Warped batch shape: %s", warped_batch.shape) + logger.trace("Warped batch shape: %s", warped_batch.shape) # type: ignore return warped_batch - - def skip_warp(self, batch): - """ Returns the images resized and cropped for feeding the model, if warping has been - disabled. - - Parameters - ---------- - batch: :class:`numpy.ndarray` - The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, - `3`) and in `BGR` format. - - Returns - ------- - :class:`numpy.ndarray` - The given batch cropped and resized for feeding the model - """ - logger.trace("Compiling skip warp images: batch shape: %s", batch.shape) - slices = self._constants["tgt_slices"] - retval = np.array([cv2.resize(image[slices, slices, :], - (self._input_size, self._input_size), - cv2.INTER_AREA) - for image in batch], dtype='float32') / 255. - logger.trace("feed batch shape: %s", retval.shape) - return retval diff --git a/lib/training/cache.py b/lib/training/cache.py new file mode 100644 index 0000000000..94014ef410 --- /dev/null +++ b/lib/training/cache.py @@ -0,0 +1,507 @@ +#!/usr/bin/env python3 +""" Holds the data cache for training data generators """ +import logging +import os +import sys + +from threading import Lock +from typing import cast, Dict, List, Optional, Tuple, TYPE_CHECKING + +import cv2 +import numpy as np +from tqdm import tqdm + +from lib.align import DetectedFace +from lib.align.aligned_face import CenteringType +from lib.image import read_image_batch, read_image_meta_batch +from lib.utils import FaceswapError + +if sys.version_info < (3, 8): + from typing_extensions import get_args, Literal +else: + from typing import get_args, Literal + +if TYPE_CHECKING: + from .generator import ConfigType + from lib.align.alignments import PNGHeaderAlignmentsDict, PNGHeaderDict + +logger = logging.getLogger(__name__) + +_FACE_CACHES: Dict[str, "_Cache"] = {} + + +def get_cache(side: Literal["a", "b"], + filenames: Optional[List[str]] = None, + config: Optional["ConfigType"] = None, + size: Optional[int] = None, + coverage_ratio: Optional[float] = None) -> "_Cache": + """ Obtain a :class:`_Cache` object for the given side. If the object does not pre-exist then + create it. + + Parameters + ---------- + side: str + `"a"` or `"b"`. The side of the model to obtain the cache for + filenames: list + The filenames of all the images. This can either be the full path or the base name. If the + full paths are passed in, they are stripped to base name for use as the cache key. Must be + passed for the first call of this function for each side. For subsequent calls this + parameter is ignored. Default: ``None`` + config: dict, optional + The user selected training configuration options. Must be passed for the first call of this + function for each side. For subsequent calls this parameter is ignored. Default: ``None`` + size: int, optional + The largest output size of the model. Must be passed for the first call of this function + for each side. For subsequent calls this parameter is ignored. Default: ``None`` + coverage_ratio: float: optional + The coverage ratio that the model is using. Must be passed for the first call of this + function for each side. For subsequent calls this parameter is ignored. Default: ``None`` + + Returns + ------- + :class:`_Cache` + The face meta information cache for the requested side + """ + if not _FACE_CACHES.get(side): + assert config is not None, ("config must be provided for first call to cache") + assert filenames is not None, ("filenames must be provided for first call to cache") + assert size is not None, ("size must be provided for first call to cache") + assert coverage_ratio is not None, ("coverage_ratio must be provided for first call to " + "cache") + logger.debug("Creating cache. side: %s, size: %s, coverage_ratio: %s", + side, size, coverage_ratio) + _FACE_CACHES[side] = _Cache(filenames, config, size, coverage_ratio) + return _FACE_CACHES[side] + + +def _check_reset(face_cache: "_Cache") -> bool: + """ Check whether a given cache needs to be reset because a face centering change has been + detected in the other cache. + + Parameters + ---------- + face_cache: :class:`_Cache` + The cache object that is checking whether it should reset + + Returns + ------- + bool + ``True`` if the given object should reset the cache, otherwise ``False`` + """ + check_cache = next((cache for cache in _FACE_CACHES.values() if cache != face_cache), None) + retval = False if check_cache is None else check_cache.check_reset() + return retval + + +class _Cache(): + """ A thread safe mechanism for collecting and holding face meta information (masks, " + "alignments data etc.) for multiple :class:`TrainingDataGenerator`s. + + Each side may have up to 3 generators (training, preview and time-lapse). To conserve VRAM + these need to share access to the same face information for the images they are processing. + + As the cache is populated at run-time, thread safe writes are required for the first epoch. + Following that, the cache is only used for reads, which is thread safe intrinsically. + + It would probably be quicker to set locks on each individual face, but for code complexity + reasons, and the fact that the lock is only taken up during cache population, and it should + only be being read multiple times on save iterations, we lock the whole cache during writes. + + Parameters + ---------- + filenames: list + The filenames of all the images. This can either be the full path or the base name. If the + full paths are passed in, they are stripped to base name for use as the cache key. + config: dict + The user selected training configuration options + size: int + The largest output size of the model + coverage_ratio: float + The coverage ratio that the model is using. + """ + def __init__(self, + filenames: List[str], + config: "ConfigType", + size: int, + coverage_ratio: float) -> None: + logger.debug("Initializing: %s (filenames: %s, size: %s, coverage_ratio: %s)", + self.__class__.__name__, len(filenames), size, coverage_ratio) + self._lock = Lock() + self._cache_info = dict(cache_full=False, has_reset=False) + self._partially_loaded: List[str] = [] + + self._image_count = len(filenames) + self._cache: Dict[str, DetectedFace] = {} + self._aligned_landmarks: Dict[str, np.ndarray] = {} + self._extract_version = 0.0 + self._size = size + + assert config["centering"] in get_args(CenteringType) + self._centering: CenteringType = cast(CenteringType, config["centering"]) + self._config = config + self._coverage_ratio = coverage_ratio + + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def cache_full(self) -> bool: + """bool: ``True`` if the cache has been fully populated. ``False`` if there are items still + to be cached. """ + if self._cache_info["cache_full"]: + return self._cache_info["cache_full"] + with self._lock: + return self._cache_info["cache_full"] + + @property + def aligned_landmarks(self) -> Dict[str, np.ndarray]: + """ dict: The filename as key, aligned landmarks as value. """ + # Note: Aligned landmarks are only used for warp-to-landmarks, so this can safely populate + # all of the aligned landmarks for the entire cache. + if not self._aligned_landmarks: + with self._lock: + # For Warp-To-Landmarks a race condition can occur where this is referenced from + # the opposite side prior to it being populated, so block on a lock. + self._aligned_landmarks = {key: face.aligned.landmarks + for key, face in self._cache.items()} + return self._aligned_landmarks + + @property + def size(self) -> int: + """ int: The pixel size of the cropped aligned face """ + return self._size + + def check_reset(self) -> bool: + """ Check whether this cache has been reset due to a face centering change, and reset the + flag if it has. + + Returns + ------- + bool + ``True`` if the cache has been reset because of a face centering change due to + legacy alignments, otherwise ``False``. """ + retval = self._cache_info["has_reset"] + if retval: + logger.debug("Resetting 'has_reset' flag") + self._cache_info["has_reset"] = False + return retval + + def get_items(self, filenames: List[str]) -> List[DetectedFace]: + """ Obtain the cached items for a list of filenames. The returned list is in the same order + as the provided filenames. + + Parameters + ---------- + filenames: list + A list of image filenames to obtain the cached data for + + Returns + ------- + list + List of DetectedFace objects holding the cached metadata. The list returns in the same + order as the filenames received + """ + return [self._cache[os.path.basename(filename)] for filename in filenames] + + def cache_metadata(self, filenames: List[str]) -> np.ndarray: + """ Obtain the batch with metadata for items that need caching and cache DetectedFace + objects to :attr:`_cache`. + + Parameters + ---------- + filenames: list + List of full paths to image file names + + Returns + ------- + :class:`numpy.ndarray` + The batch of face images loaded from disk + """ + keys = [os.path.basename(filename) for filename in filenames] + with self._lock: + if _check_reset(self): + self._reset_cache(False) + + needs_cache = [filename + for filename, key in zip(filenames, keys) + if key not in self._cache or key in self._partially_loaded] + logger.trace("Needs cache: %s", needs_cache) # type: ignore + + if not needs_cache: + # Don't bother reading the metadata if no images in this batch need caching + logger.debug("All metadata already cached for: %s", keys) + return read_image_batch(filenames) + + batch, metadata = read_image_batch(filenames, with_metadata=True) + + if len(batch.shape) == 1: + folder = os.path.dirname(filenames[0]) + details = [ + f"{key} ({f'{img.shape[1]}px' if isinstance(img, np.ndarray) else type(img)})" + for key, img in zip(keys, batch)] + msg = (f"There are mismatched image sizes in the folder '{folder}'. All training " + "images for each side must have the same dimensions.\nThe batch that " + f"failed contains the following files:\n{details}.") + raise FaceswapError(msg) + + # Populate items into cache + for filename in needs_cache: + key = os.path.basename(filename) + meta = metadata[filenames.index(filename)] + + # Version Check + self._validate_version(meta, filename) + if self._partially_loaded: # Faces already loaded for Warp-to-landmarks + self._partially_loaded.remove(key) + detected_face = self._cache[key] + else: + detected_face = self._load_detected_face(filename, meta["alignments"]) + + self._prepare_masks(filename, detected_face) + self._cache[key] = detected_face + + # Update the :attr:`cache_full` attribute + cache_full = not self._partially_loaded and len(self._cache) == self._image_count + if cache_full: + logger.verbose("Cache filled: '%s'", os.path.dirname(filenames[0])) # type: ignore + self._cache_info["cache_full"] = cache_full + + return batch + + def pre_fill(self, filenames: List[str], side: Literal["a", "b"]) -> None: + """ When warp to landmarks is enabled, the cache must be pre-filled, as each side needs + access to the other side's alignments. + + Parameters + ---------- + filenames: list + The list of full paths to the images to load the metadata from + side: str + `"a"` or `"b"`. The side of the model being cached. Used for info output + """ + with self._lock: + for filename, meta in tqdm(read_image_meta_batch(filenames), + desc=f"WTL: Caching Landmarks ({side.upper()})", + total=len(filenames), + leave=False): + if "itxt" not in meta or "alignments" not in meta["itxt"]: + raise FaceswapError(f"Invalid face image found. Aborting: '{filename}'") + + meta = meta["itxt"] + key = os.path.basename(filename) + # Version Check + self._validate_version(meta, filename) + detected_face = self._load_detected_face(filename, meta["alignments"]) + self._cache[key] = detected_face + self._partially_loaded.append(key) + + def _validate_version(self, png_meta: "PNGHeaderDict", filename: str) -> None: + """ Validate that there are not a mix of v1.0 extracted faces and v2.x faces. + + Parameters + ---------- + png_meta: dict + The information held within the Faceswap PNG Header + filename: str + The full path to the file being validated + + Raises + ------ + FaceswapError + If a version 1.0 face appears in a 2.x set or vice versa + """ + alignment_version = png_meta["source"]["alignments_version"] + + if not self._extract_version: + logger.debug("Setting initial extract version: %s", alignment_version) + self._extract_version = alignment_version + if alignment_version == 1.0 and self._centering != "legacy": + self._reset_cache(True) + return + + if (self._extract_version == 1.0 and alignment_version > 1.0) or ( + alignment_version == 1.0 and self._extract_version > 1.0): + raise FaceswapError("Mixing legacy and full head extracted facesets is not supported. " + "The following folder contains a mix of extracted face types: " + f"'{os.path.dirname(filename)}'") + + self._extract_version = min(alignment_version, self._extract_version) + + def _reset_cache(self, set_flag: bool) -> None: + """ In the event that a legacy extracted face has been seen, and centering is not legacy + the cache will need to be reset for legacy centering. + + Parameters + ---------- + set_flag: bool + ``True`` if the flag should be set to indicate that the cache is being reset because of + a legacy face set/centering mismatch. ``False`` if the cache is being reset because it + has detected a reset flag from the opposite cache. + """ + if set_flag: + logger.warning("You are using legacy extracted faces but have selected '%s' centering " + "which is incompatible. Switching centering to 'legacy'", + self._centering) + self._config["centering"] = "legacy" + self._centering = "legacy" + self._cache = {} + self._cache_info["cache_full"] = False + if set_flag: + self._cache_info["has_reset"] = True + + def _load_detected_face(self, + filename: str, + alignments: "PNGHeaderAlignmentsDict") -> DetectedFace: + """ Load a :class:`DetectedFace` object and load its associated `aligned` property. + + Parameters + ---------- + filename: str + The file path for the current image + alignments: dict + The alignments for a single face, extracted from a PNG header + + Returns + ------- + :class:`lib.align.DetectedFace` + The loaded Detected Face object + """ + detected_face = DetectedFace() + detected_face.from_png_meta(alignments) + detected_face.load_aligned(None, + size=self._size, + centering=self._centering, + coverage_ratio=self._coverage_ratio, + is_aligned=True, + is_legacy=self._extract_version == 1.0) + logger.trace("Cached aligned face for: %s", filename) # type: ignore + return detected_face + + def _prepare_masks(self, filename: str, detected_face: DetectedFace) -> None: + """ Prepare the masks required from training, and compile into a single compressed array + + Parameters + ---------- + filename: str + The file path for the current image + detected_face: :class:`lib.align.DetectedFace` + The detected face object that holds the masks + """ + masks = [(self._get_face_mask(filename, detected_face))] + for area in get_args(Literal["eye", "mouth"]): + masks.append(self._get_localized_mask(filename, detected_face, area)) + + detected_face.store_training_masks(masks, delete_masks=True) + logger.trace("Stored masks for filename: %s)", filename) # type: ignore + + def _get_face_mask(self, filename: str, detected_face: DetectedFace) -> Optional[np.ndarray]: + """ Obtain the training sized face mask from the :class:`DetectedFace` for the requested + mask type. + + Parameters + ---------- + filename: str + The file path for the current image + detected_face: :class:`lib.align.DetectedFace` + The detected face object that holds the masks + + Raises + ------ + FaceswapError + If the requested mask type is not available an error is returned along with a list + of available masks + """ + if not self._config["penalized_mask_loss"] and not self._config["learn_mask"]: + return None + + if not self._config["mask_type"]: + logger.debug("No mask selected. Not validating") + return None + + if self._config["mask_type"] not in detected_face.mask: + raise FaceswapError( + f"You have selected the mask type '{self._config['mask_type']}' but at least one " + "face does not contain the selected mask.\n" + f"The face that failed was: '{filename}'\n" + f"The masks that exist for this face are: {list(detected_face.mask)}") + + mask = detected_face.mask[str(self._config["mask_type"])] + mask.set_blur_and_threshold(blur_kernel=int(self._config["mask_blur_kernel"]), + threshold=int(self._config["mask_threshold"])) + + pose = detected_face.aligned.pose + mask.set_sub_crop(pose.offset[mask.stored_centering], + pose.offset[self._centering], + self._centering, + self._coverage_ratio) + face_mask = mask.mask + if self._size != face_mask.shape[0]: + interpolator = cv2.INTER_CUBIC if mask.stored_size < self._size else cv2.INTER_AREA + face_mask = cv2.resize(face_mask, + (self._size, self._size), + interpolation=interpolator)[..., None] + + logger.trace("Obtained face mask for: %s %s", filename, face_mask.shape) # type: ignore + return face_mask + + def _get_localized_mask(self, + filename: str, + detected_face: DetectedFace, + area: Literal["eye", "mouth"]) -> Optional[np.ndarray]: + """ Obtain a localized mask for the given area if it is required for training. + + Parameters + ---------- + filename: str + The file path for the current image + detected_face: :class:`lib.align.DetectedFace` + The detected face object that holds the masks + area: str + `"eye"` or `"mouth"`. The area of the face to obtain the mask for + """ + if not self._config["penalized_mask_loss"] or int(self._config[f"{area}_multiplier"]) <= 1: + return None + mask = detected_face.get_landmark_mask(area, self._size // 16, self._size // 32) + logger.trace("Caching localized '%s' mask for: %s %s", # type: ignore + area, filename, mask.shape) + return mask + + +class RingBuffer(): # pylint: disable=too-few-public-methods + """ Rolling buffer for holding training/preview batches + + Parameters + ---------- + batch_size: int + The batch size to create the buffer for + image_shape: tuple + The height/width/channels shape of a single image in the batch + buffer_size: int, optional + The number of arrays to hold in the rolling buffer. Default: `2` + dtype: str, optional + The datatype to create the buffer as. Default: `"uint8"` + """ + def __init__(self, + batch_size: int, + image_shape: Tuple[int, int, int], + buffer_size: int = 2, + dtype: str = "uint8") -> None: + logger.debug("Initializing: %s (batch_size: %s, image_shape: %s, buffer_size: %s, " + "dtype: %s", self.__class__.__name__, batch_size, image_shape, buffer_size, + dtype) + self._max_index = buffer_size - 1 + self._index = 0 + self._buffer = [np.empty((batch_size, *image_shape), dtype=dtype) + for _ in range(buffer_size)] + logger.debug("Initialized: %s", self.__class__.__name__) # type: ignore + + def __call__(self) -> np.ndarray: + """ Obtain the next array from the ring buffer + + Returns + ------- + :class:`np.ndarray` + A pre-allocated numpy array from the buffer + """ + retval = self._buffer[self._index] + self._index += 1 if self._index < self._max_index else -self._max_index + return retval diff --git a/lib/training/generator.py b/lib/training/generator.py index 22b668b994..ad0ae0ecb8 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -3,179 +3,207 @@ import logging import os +import sys +from concurrent import futures from random import shuffle, choice -from threading import Lock -from zlib import decompress +from typing import cast, Dict, Generator, List, Tuple, TYPE_CHECKING, Union -import numpy as np import cv2 -from tqdm import tqdm -from lib.align import AlignedFace, DetectedFace, get_centered_size -from lib.image import read_image_batch, read_image_meta_batch +import numpy as np +import numexpr as ne +from lib.align import AlignedFace, DetectedFace +from lib.align.aligned_face import CenteringType +from lib.image import read_image_batch from lib.multithreading import BackgroundGenerator from lib.utils import FaceswapError from . import ImageAugmentation +from .cache import get_cache, RingBuffer -logger = logging.getLogger(__name__) # pylint: disable=invalid-name - -_FACE_CACHES = dict() - - -def _get_cache(side, filenames, config): - """ Obtain a :class:`_Cache` object for the given side. If the object does not pre-exist then - create it. - - Parameters - ---------- - side: str - `"a"` or `"b"`. The side of the model to obtain the cache for - filenames: list - The filenames of all the images. This can either be the full path or the base name. If the - full paths are passed in, they are stripped to base name for use as the cache key. - config: dict - The user selected training configuration options - - Returns - ------- - :class:`_Cache` - The face meta information cache for the requested side - """ - if not _FACE_CACHES.get(side): - logger.debug("Creating cache. Side: %s", side) - _FACE_CACHES[side] = _Cache(filenames, config) - return _FACE_CACHES[side] - - -def _check_reset(face_cache): - """ Check whether a given cache needs to be reset because a face centering change has been - detected in the other cache. - - Parameters - ---------- - face_cache: :class:`_Cache` - The cache object that is checking whether it should reset - - Returns - ------- - bool - ``True`` if the given object should reset the cache, otherwise ``False`` - """ - check_cache = next((cache for cache in _FACE_CACHES.values() if cache != face_cache), None) - retval = check_cache if check_cache is None else check_cache.check_reset() - return retval +if sys.version_info < (3, 8): + from typing_extensions import get_args, Literal +else: + from typing import get_args, Literal +if TYPE_CHECKING: + from plugins.train.model._base import ModelBase + from .cache import _Cache -class _Cache(): - """ A thread safe mechanism for collecting and holding face meta information (masks, " - "alignments data etc.) for multiple :class:`TrainingDataGenerator`s. +logger = logging.getLogger(__name__) +ConfigType = Dict[str, Union[bool, int, float, str]] # TODO Dataclass +BatchType = Tuple[np.ndarray, List[np.ndarray]] - Each side may have up to 3 generators (training, preview and time-lapse). To conserve VRAM - these need to share access to the same face information for the images they are processing. - As the cache is populated at run-time, thread safe writes are required for the first epoch. - Following that, the cache is only used for reads, which is thread safe intrinsically. +class DataGenerator(): + """ Parent class for Training and Preview Data Generators. - It would probably be quicker to set locks on each individual face, but for code complexity - reasons, and the fact that the lock is only taken up during cache population, and it should - only be being read multiple times on save iterations, we lock the whole cache during writes. + This class is called from :mod:`plugins.train.trainer._base` and launches a background + iterator that compiles augmented data, target data and sample data. Parameters ---------- - filenames: list - The filenames of all the images. This can either be the full path or the base name. If the - full paths are passed in, they are stripped to base name for use as the cache key. + model: :class:`~plugins.train.model.ModelBase` + The model that this data generator is feeding config: dict - The user selected training configuration options + The configuration `dict` generated from :file:`config.train.ini` containing the trainer + plugin configuration options. + side: {'a' or 'b'} + The side of the model that this iterator is for. + images: list + A list of image paths that will be used to compile the final augmented data from. + batch_size: int + The batch size for this iterator. Images will be returned in :class:`numpy.ndarray` + objects of this size from the iterator. """ - def __init__(self, filenames, config): - self._lock = Lock() - self._cache = {os.path.basename(filename): dict(cached=False) for filename in filenames} - self._aligned_landmarks = None - self._partial_load = False - self._cache_full = False - self._extract_version = None - self._has_reset = False - self._size = None - - self._centering = config["centering"] + def __init__(self, + config: ConfigType, + model: "ModelBase", + side: Literal["a", "b"], + images: List[str], + batch_size: int) -> None: + logger.debug("Initializing %s: (model: %s, side: %s, images: %s , " # type: ignore + "batch_size: %s, config: %s)", self.__class__.__name__, model.name, side, + len(images), batch_size, config) self._config = config + self._side = side + self._images = images + self._batch_size = batch_size + self._process_size = max([model.input_shape[1]] + [img[1] + for img in model.output_shapes[0]]) + self._output_sizes = [shape[0] for shape in model.output_shapes[0] if shape[-1] != 1] + self._coverage_ratio = model.coverage_ratio + self._color_order = model.color_order.lower() + self._use_mask = self._config["mask_type"] and (self._config["penalized_mask_loss"] or + self._config["learn_mask"]) + + self._validate_samples() + self._buffer = RingBuffer(batch_size, + (self._process_size, self._process_size, self._total_channels), + dtype="uint8") + self._face_cache: "_Cache" = get_cache(side, + filenames=images, + config=self._config, + size=self._process_size, + coverage_ratio=self._coverage_ratio) + logger.debug("Initialized %s", self.__class__.__name__) @property - def cache_full(self): - """bool: ``True`` if the cache has been fully populated. ``False`` if there are items still - to be cached. """ - if self._cache_full: - return self._cache_full - with self._lock: - return self._cache_full - - @property - def partially_loaded(self): - """ bool: ``True`` if the cache has been partially loaded for Warp To Landmarks otherwise - ``False`` """ - if self._partial_load: - return self._partial_load - with self._lock: - return self._partial_load + def _total_channels(self) -> int: + """int: The total number of channels, including mask channels that the target image + should hold. """ + channels = 3 + if self._config["mask_type"] and (self._config["learn_mask"] or + self._config["penalized_mask_loss"]): + channels += 1 + + mults = [area for area in ["eye", "mouth"] if int(self._config[f"{area}_multiplier"]) > 1] + if self._config["penalized_mask_loss"] and mults: + channels += len(mults) + return channels + + def minibatch_ab(self, do_shuffle: bool = True) -> Generator[BatchType, None, None]: + """ A Background iterator to return augmented images, samples and targets. - @property - def extract_version(self): - """ float: The alignments file version used to extract the faces. """ - return self._extract_version + The exit point from this class and the sole attribute that should be referenced. Called + from :mod:`plugins.train.trainer._base`. Returns an iterator that yields images for + training, preview and time-lapses. - @property - def aligned_landmarks(self): - """ dict: The filename as key, aligned landmarks as value """ - if self._aligned_landmarks is None: - with self._lock: - # For Warp-To-Landmarks a race condition can occur where this is referenced from - # the opposite side prior to it being populated, so block on a lock. - self._aligned_landmarks = {key: val["aligned_face"].landmarks - for key, val in self._cache.items()} - return self._aligned_landmarks + Parameters + ---------- + do_shuffle: bool, optional + Whether data should be shuffled prior to loading from disk. If true, each time the full + list of filenames are processed, the data will be reshuffled to make sure they are not + returned in the same order. Default: ``True`` - @property - def crop_size(self): - """ int: The pixel size of the cropped aligned face """ - return self._size + Yields + ------ + feed: list + 4-dimensional array of faces to feed the training the model (:attr:`x` parameter for + :func:`keras.models.model.train_on_batch`.). The array returned is in the format + (`batch size`, `height`, `width`, `channels`). + targets: list + List of 4-dimensional :class:`numpy.ndarray` objects in the order and size of each + output of the model. The format of these arrays will be (`batch size`, `height`, + `width`, `x`). This is the :attr:`y` parameter for + :func:`keras.models.model.train_on_batch`. The number of channels here will vary. + The first 3 channels are (rgb/bgr). The 4th channel is the face mask. Any subsequent + channels are area masks (e.g. eye/mouth masks) + """ + logger.debug("do_shuffle: %s", do_shuffle) + args = (do_shuffle, ) + batcher = BackgroundGenerator(self._minibatch, thread_count=1, args=args) + return batcher.iterator() - def check_reset(self): - """ Check whether this cache has been reset due to a face centering change, and reset the - flag if it has. + # << INTERNAL METHODS >> # + def _validate_samples(self) -> None: + """ Ensures that the total number of images within :attr:`images` is greater or equal to + the selected :attr:`batch_size`. - Returns - ------- - bool - ``True`` if the cache has been reset because of a face centering change due to - legacy alignments, otherwise ``False``. """ - retval = self._has_reset - if retval: - logger.debug("Resetting 'has_reset' flag") - self._has_reset = False - return retval + Raises + ------ + :class:`FaceswapError` + If the number of images loaded is smaller than the selected batch size + """ + length = len(self._images) + msg = ("Number of images is lower than batch-size (Note that too few images may lead to " + f"bad training). # images: {length}, batch-size: {self._batch_size}") + try: + assert length >= self._batch_size, msg + except AssertionError as err: + msg += ("\nYou should increase the number of images in your training set or lower " + "your batch-size.") + raise FaceswapError(msg) from err - def get_items(self, filenames): - """ Obtain the cached items for a list of filenames. The returned list is in the same order - as the provided filenames. + def _minibatch(self, do_shuffle: bool) -> Generator[BatchType, None, None]: + """ A generator function that yields the augmented, target and sample images for the + current batch on the current side. Parameters ---------- - filenames: list - A list of image filenames to obtain the cached data for + do_shuffle: bool, optional + Whether data should be shuffled prior to loading from disk. If true, each time the full + list of filenames are processed, the data will be reshuffled to make sure they are not + returned in the same order. Default: ``True`` - Returns - ------- - list - List of dictionaries containing the cached metadata. The list returns in the same order - as the filenames received + Yields + ------ + feed: list + 4-dimensional array of faces to feed the training the model (:attr:`x` parameter for + :func:`keras.models.model.train_on_batch`.). The array returned is in the format + (`batch size`, `height`, `width`, `channels`). + targets: list + List of 4-dimensional :class:`numpy.ndarray` objects in the order and size of each + output of the model. The format of these arrays will be (`batch size`, `height`, + `width`, `x`). This is the :attr:`y` parameter for + :func:`keras.models.model.train_on_batch`. The number of channels here will vary. + The first 3 channels are (rgb/bgr). The 4th channel is the face mask. Any subsequent + channels are area masks (e.g. eye/mouth masks) """ - return [self._cache[os.path.basename(filename)] for filename in filenames] + logger.debug("Loading minibatch generator: (image_count: %s, do_shuffle: %s)", + len(self._images), do_shuffle) - def cache_metadata(self, filenames): - """ Obtain the batch with metadata for items that need caching and cache them to - :attr:`_cache`. + def _img_iter(imgs): + """ Infinite iterator for recursing through image list and reshuffling at each epoch""" + while True: + if do_shuffle: + shuffle(imgs) + for img in imgs: + yield img + + img_iter = _img_iter(self._images[:]) + while True: + img_paths = [next(img_iter) # pylint:disable=stop-iteration-return + for _ in range(self._batch_size)] + retval = self._process_batch(img_paths) + yield retval + + def _get_images_with_meta(self, filenames: List[str]) -> Tuple[np.ndarray, List[DetectedFace]]: + """ Obtain the raw face images with associated :class:`DetectedFace` objects for this + batch. + + If this is the first time a face has been loaded, then it's meta data is extracted + from the png header and added to :attr:`_face_cache`. Parameters ---------- @@ -184,250 +212,161 @@ def cache_metadata(self, filenames): Returns ------- - :class:`numpy.ndarray` - The batch of face images loaded from disk + raw_faces: :class:`numpy.ndarray` + The full sized batch of training images for the given filenames + list + Batch of :class:`~lib.align.DetectedFace` objects for the given filename including the + aligned face objects for the model output size """ - keys = [os.path.basename(filename) for filename in filenames] - with self._lock: - if _check_reset(self): - self._reset_cache(False) - - needs_cache = [filename - for filename, key in zip(filenames, keys) - if not self._cache[key]["cached"]] - logger.trace("Needs cache: %s", needs_cache) - - if not needs_cache: - # Don't bother reading the metadata if no images in this batch need caching - logger.debug("All metadata already cached for: %s", keys) - return read_image_batch(filenames) - - batch, metadata = read_image_batch(filenames, with_metadata=True) - - if len(batch.shape) == 1: - folder = os.path.dirname(filenames[0]) - details = [ - "{0} ({1})".format( - key, f"{img.shape[1]}px" if isinstance(img, np.ndarray) else type(img)) - for key, img in zip(keys, batch)] - msg = (f"There are mismatched image sizes in the folder '{folder}'. All training " - "images for each side must have the same dimensions.\nThe batch that " - f"failed contains the following files:\n{details}.") - raise FaceswapError(msg) - - # Populate items into cache - for filename in needs_cache: - key = os.path.basename(filename) - meta = metadata[filenames.index(filename)] - - # Version Check - self._validate_version(meta, filename) - if self._partial_load: # Faces already loaded for Warp-to-landmarks - detected_face = self._cache[key]["detected_face"] - else: - detected_face = self._add_aligned_face(filename, - meta["alignments"], - batch.shape[1]) - - self._add_mask(filename, detected_face) - for area in ("eye", "mouth"): - self._add_localized_mask(filename, detected_face, area) - - self._cache[key]["cached"] = True - # Update the :attr:`cache_full` attribute - cache_full = all(item["cached"] for item in self._cache.values()) - if cache_full: - logger.verbose("Cache filled: '%s'", os.path.dirname(filenames[0])) - self._cache_full = cache_full - - return batch - - def pre_fill(self, filenames, side): - """ When warp to landmarks is enabled, the cache must be pre-filled, as each side needs - access to the other side's alignments. + if not self._face_cache.cache_full: + raw_faces = self._face_cache.cache_metadata(filenames) + else: + raw_faces = read_image_batch(filenames) + + detected_faces = self._face_cache.get_items(filenames) + logger.trace("filenames: %s, raw_faces: '%s', detected_faces: %s", # type: ignore + filenames, raw_faces.shape, len(detected_faces)) + return raw_faces, detected_faces + + def _crop_to_coverage(self, + filenames: List[str], + images: np.ndarray, + detected_faces: List[DetectedFace], + batch: np.ndarray) -> None: + """ Crops the training image out of the full extract image based on the centering and + coveage used in the user's configuration settings. + + If legacy extract images are being used then this just returns the extracted batch with + their corresponding landmarks. + + Uses thread pool execution for about a 33% speed increase @ 64 batch size Parameters ---------- filenames: list - The list of full paths to the images to load the metadata from - side: str - `"a"` or `"b"`. The side of the model being cached. Used for info output + The list of filenames that correspond to this batch + images: :class:`numpy.ndarray` + The batch of faces that have been loaded from disk + detected_faces: list + The list of :class:`lib.align.DetectedFace` items corresponding to the batch + batch: :class:`np.ndarray` + The pre-allocated array to hold this batch """ - with self._lock: - for filename, meta in tqdm(read_image_meta_batch(filenames), - desc="WTL: Caching Landmarks ({})".format(side.upper()), - total=len(filenames), - leave=False): - if "itxt" not in meta or "alignments" not in meta["itxt"]: - raise FaceswapError(f"Invalid face image found. Aborting: '{filename}'") - - size = meta["width"] - meta = meta["itxt"] - # Version Check - self._validate_version(meta, filename) - detected_face = self._add_aligned_face(filename, meta["alignments"], size) - self._cache[os.path.basename(filename)]["detected_face"] = detected_face - self._partial_load = True - - def _validate_version(self, png_meta, filename): - """ Validate that there are not a mix of v1.0 extracted faces and v2.x faces. + logger.trace("Cropping training images info: (filenames: %s, side: '%s')", # type: ignore + filenames, self._side) - Parameters - ---------- - png_meta: dict - The information held within the Faceswap PNG Header - filename: str - The full path to the file being validated + with futures.ThreadPoolExecutor() as executor: + proc = {executor.submit(face.aligned.extract_face, img): idx + for idx, (face, img) in enumerate(zip(detected_faces, images))} - Raises - ------ - FaceswapError - If a version 1.0 face appears in a 2.x set or vice versa - """ - alignment_version = png_meta["source"]["alignments_version"] - - if not self._extract_version: - logger.debug("Setting initial extract version: %s", alignment_version) - self._extract_version = alignment_version - if alignment_version == 1.0 and self._centering != "legacy": - self._reset_cache(True) - return + for future in futures.as_completed(proc): + batch[proc[future], ..., :3] = future.result() - if (self._extract_version == 1.0 and alignment_version > 1.0) or ( - alignment_version == 1.0 and self._extract_version > 1.0): - raise FaceswapError("Mixing legacy and full head extracted facesets is not supported. " - "The following folder contains a mix of extracted face types: " - "{}".format(os.path.dirname(filename))) + def _apply_mask(self, detected_faces: List[DetectedFace], batch: np.ndarray) -> None: + """ Applies the masks to the 4th channel of the batch. - self._extract_version = min(alignment_version, self._extract_version) + If the configuration options `eye_multiplier` and/or `mouth_multiplier` are greater than 1 + then these masks are applied to the final channels of the batch respectively. - def _reset_cache(self, set_flag): - """ In the event that a legacy extracted face has been seen, and centering is not legacy - the cache will need to be reset for legacy centering. + If masks are not being used then this function returns having done nothing Parameters ---------- - set_flag: bool - ``True`` if the flag should be set to indicate that the cache is being reset because of - a legacy face set/centering mismatch. ``False`` if the cache is being reset because it - has detected a reset flag from the opposite cache. + detected_face: list + The list of :class:`~lib.align.DetectedFace` objects corresponding to the batch + batch: :class:`numpy.ndarray` + The preallocated array to apply masks to + side: str + '"a"' or '"b"' the side that is being processed """ - if set_flag: - logger.warning("You are using legacy extracted faces but have selected '%s' centering " - "which is incompatible. Switching centering to 'legacy'", - self._centering) - self._config["centering"] = "legacy" - self._centering = "legacy" - self._cache = {key: dict(cached=False) for key in self._cache} - self._cache_full = False - self._size = None - if set_flag: - self._has_reset = True - - def _add_aligned_face(self, filename, alignments, image_size): - """ Add a :class:`lib.align.AlignedFace` object to the cache. + if not self._use_mask: + return + + masks = np.array([face.get_training_masks() for face in detected_faces]) + batch[..., 3:] = masks + + logger.trace("side: %s, masks: %s, batch: %s", # type: ignore + self._side, masks.shape, batch.shape) + + def _process_batch(self, filenames: List[str]) -> BatchType: + """ Prepares data for feeding through subclassed methods. + + If this is the first time a face has been loaded, then it's meta data is extracted from the + png header and added to :attr:`_face_cache` Parameters ---------- - filename: str - The file path for the current image - alignments: dict - The alignments for a single face, extracted from a PNG header - image_size: int - The pixel size of the image loaded from disk + filenames: list + List of full paths to image file names for a single batch Returns ------- - :class:`lib.align.DetectedFace` - The Detected Face object that was used to create the Aligned Face + list + 4-dimensional array of faces to feed the training the model. + list + List of 4-dimensional :class:`numpy.ndarray`. The number of channels here will vary. + The first 3 channels are (rgb/bgr). The 4th channel is the face mask. Any subsequent + channels are area masks (e.g. eye/mouth masks) """ - if self._size is None: - self._size = get_centered_size("legacy" if self._extract_version == 1.0 else "head", - self._centering, - image_size) - - detected_face = DetectedFace() - detected_face.from_png_meta(alignments) + raw_faces, detected_faces = self._get_images_with_meta(filenames) + batch = self._buffer() + self._crop_to_coverage(filenames, raw_faces, detected_faces, batch) + self._apply_mask(detected_faces, batch) - aligned_face = AlignedFace(detected_face.landmarks_xy, - centering=self._centering, - size=self._size, - is_aligned=True) - logger.trace("Caching aligned face for: %s", filename) - self._cache[os.path.basename(filename)]["aligned_face"] = aligned_face - return detected_face + return self.process_batch(filenames, raw_faces, detected_faces, batch) - def _add_mask(self, filename, detected_face): - """ Load the mask to the cache if a mask is required for training. + def process_batch(self, + filenames: List[str], + images: np.ndarray, + detected_faces: List[DetectedFace], + batch: np.ndarray) -> BatchType: + """ Override for processing the batch for the current generator. Parameters ---------- - filename: str - The file path for the current image - detected_face: :class:`lib.align.DetectedFace` - The detected face object that holds the masks + filenames: list + List of full paths to image file names for a single batch + images: :class:`numpy.ndarray` + The batch of faces corresponding to the filenames + detected_faces: list + List of :class:`~lib.align.DetectedFace` objects with aligned data and masks loaded for + the current batch + batch: :class:`numpy.ndarray` + The pre-allocated batch with images and masks populated for the selected coverage and + centering - Raises - ------ - FaceswapError - If the requested mask type is not available an error is returned along with a list - of available masks + Returns + ------- + list + 4-dimensional array of faces to feed the training the model. + list + List of 4-dimensional :class:`numpy.ndarray`. The number of channels here will vary. + The first 3 channels are (rgb/bgr). The 4th channel is the face mask. Any subsequent + channels are area masks (e.g. eye/mouth masks) """ - if not self._config["penalized_mask_loss"] and not self._config["learn_mask"]: - return - - if not self._config["mask_type"]: - logger.debug("No mask selected. Not validating") - return - - if self._config["mask_type"] not in detected_face.mask: - raise FaceswapError( - "You have selected the mask type '{}' but at least one face does not contain the " - "selected mask.\nThe face that failed was: '{}'\nThe masks that exist for this " - "face are: {}".format( - self._config["mask_type"], filename, list(detected_face.mask))) - - key = os.path.basename(filename) - mask = detected_face.mask[self._config["mask_type"]] - mask.set_blur_and_threshold(blur_kernel=self._config["mask_blur_kernel"], - threshold=self._config["mask_threshold"]) + raise NotImplementedError() - pose = self._cache[key]["aligned_face"].pose - mask.set_sub_crop(pose.offset[mask.stored_centering], - pose.offset[self._centering], - self._centering) + def _set_color_order(self, batch) -> None: + """ Set the color order correctly for the model's input type. - logger.trace("Caching mask for: %s", filename) - self._cache[key]["mask"] = mask + batch: :class:`numpy.ndarray` + The pre-allocated batch with images in the first 3 channels in BGR order + """ + if self._color_order == "rgb": + batch[..., :3] = batch[..., [2, 1, 0]] - def _add_localized_mask(self, filename, detected_face, area): - """ Load a localized mask to the cache for the given area if it is required for training. + def _to_float32(self, in_array: np.ndarray) -> np.ndarray: + """ Cast an UINT8 array in 0-255 range to float32 in 0.0-1.0 range. - Parameters - ---------- - filename: str - The file path for the current image - detected_face: :class:`lib.align.DetectedFace` - The detected face object that holds the masks - area: str - `"eye"` or `"mouth"`. The area of the face to obtain the mask for + in_array: :class:`numpy.ndarray` + The input uint8 array """ - if not self._config["penalized_mask_loss"] or self._config[f"{area}_multiplier"] <= 1: - return - key = "eyes" if area == "eye" else area - - logger.trace("Caching localized '%s' mask for: %s", key, filename) - self._cache[os.path.basename(filename)][f"mask_{key}"] = detected_face.get_landmark_mask( - self._size, - key, - aligned=True, - centering=self._centering, - dilation=self._size // 32, - blur_kernel=self._size // 16, - as_zip=True) + return ne.evaluate("x / c", + local_dict=dict(x=in_array, c=np.float32(255)), + casting="unsafe") -class TrainingDataGenerator(): # pylint:disable=too-few-public-methods +class TrainingDataGenerator(DataGenerator): # pylint:disable=too-few-public-methods """ A Training Data Generator for compiling data for feeding to a model. This class is called from :mod:`plugins.train.trainer._base` and launches a background @@ -435,406 +374,327 @@ class TrainingDataGenerator(): # pylint:disable=too-few-public-methods Parameters ---------- - model_input_size: int - The expected input size for the model. It is assumed that the input to the model is always - a square image. This is the size, in pixels, of the `width` and the `height` of the input - to the model. - model_output_shapes: list - A list of tuples defining the output shapes from the model, in the order that the outputs - are returned. The tuples should be in (`height`, `width`, `channels`) format. - coverage_ratio: float - The ratio of the training image to be trained on. Dictates how much of the image will be - cropped out. E.G: a coverage ratio of 0.625 will result in cropping a 160px box from a - 256px image (:math:`256 * 0.625 = 160`). - color_order: ["rgb", "bgr"] - The color order that the model expects as input - augment_color: bool - ``True`` if color is to be augmented, otherwise ``False`` - no_flip: bool - ``True`` if the image shouldn't be randomly flipped as part of augmentation, otherwise - ``False`` - no_warp: bool - ``True`` if the image shouldn't be warped as part of augmentation, otherwise ``False`` - warp_to_landmarks: bool - ``True`` if the random warp method should warp to similar landmarks from the other side, - ``False`` if the standard random warp method should be used. - face_cache: dict - A thread safe dictionary containing a cache of information relating to all faces being - trained on + model: :class:`~plugins.train.model.ModelBase` + The model that this data generator is feeding config: dict The configuration `dict` generated from :file:`config.train.ini` containing the trainer plugin configuration options. + side: {'a' or 'b'} + The side of the model that this iterator is for. + images: list + A list of image paths that will be used to compile the final augmented data from. + batch_size: int + The batch size for this iterator. Images will be returned in :class:`numpy.ndarray` + objects of this size from the iterator. """ - def __init__(self, model_input_size, model_output_shapes, coverage_ratio, color_order, - augment_color, no_flip, no_warp, warp_to_landmarks, config): - logger.debug("Initializing %s: (model_input_size: %s, model_output_shapes: %s, " - "coverage_ratio: %s, color_order: %s, augment_color: %s, no_flip: %s, " - "no_warp: %s, warp_to_landmarks: %s, config: %s)", - self.__class__.__name__, model_input_size, model_output_shapes, - coverage_ratio, color_order, augment_color, no_flip, no_warp, - warp_to_landmarks, config) - self._config = config - self._model_input_size = model_input_size - self._model_output_shapes = model_output_shapes - self._coverage_ratio = coverage_ratio - self._color_order = color_order.lower() - self._augment_color = augment_color - self._no_flip = no_flip - self._warp_to_landmarks = warp_to_landmarks - self._no_warp = no_warp - - # Batchsize and processing class are set when this class is called by a feeder - # from lib.training_data - self._batchsize = 0 - self._face_cache = None - self._nearest_landmarks = dict() - self._processing = None - logger.debug("Initialized %s", self.__class__.__name__) + def __init__(self, + config: ConfigType, + model: "ModelBase", + side: Literal["a", "b"], + images: List[str], + batch_size: int) -> None: + super().__init__(config, model, side, images, batch_size) + self._augment_color = not model.command_line_arguments.no_augment_color + self._no_flip = model.command_line_arguments.no_flip + self._no_warp = model.command_line_arguments.no_warp + self._warp_to_landmarks = (not self._no_warp + and model.command_line_arguments.warp_to_landmarks) + self._model_input_size = model.input_shape[1] - def minibatch_ab(self, images, batchsize, side, - do_shuffle=True, is_preview=False, is_timelapse=False): - """ A Background iterator to return augmented images, samples and targets. + if self._warp_to_landmarks: + self._face_cache.pre_fill(images, side) + self._processing = ImageAugmentation(batch_size, + self._process_size, + self._config) + self._nearest_landmarks: Dict[str, Tuple[str, ...]] = {} + logger.debug("Initialized %s", self.__class__.__name__) - The exit point from this class and the sole attribute that should be referenced. Called - from :mod:`plugins.train.trainer._base`. Returns an iterator that yields images for - training, preview and time-lapses. + def _create_targets(self, batch: np.ndarray) -> List[np.ndarray]: + """ Compile target images, with masks, for the model output sizes. Parameters ---------- - images: list - A list of image paths that will be used to compile the final augmented data from. - batchsize: int - The batchsize for this iterator. Images will be returned in :class:`numpy.ndarray` - objects of this size from the iterator. - side: {'a' or 'b'} - The side of the model that this iterator is for. - do_shuffle: bool, optional - Whether data should be shuffled prior to loading from disk. If true, each time the full - list of filenames are processed, the data will be reshuffled to make sure they are not - returned in the same order. Default: ``True`` - is_preview: bool, optional - Indicates whether this iterator is generating preview images. If ``True`` then certain - augmentations will not be performed. Default: ``False`` - is_timelapse: bool optional - Indicates whether this iterator is generating time-lapse images. If ``True``, then - certain augmentations will not be performed. Default: ``False`` + batch: :class:`numpy.ndarray` + This should be a 4-dimensional array of training images in the format (`batch size`, + `height`, `width`, `channels`). Targets should be requested after performing image + transformations but prior to performing warps. The 4th channel should be the mask. + Any channels above the 4th should be any additional area masks (e.g. eye/mouth) that + are required. - Yields - ------ - dict - The following items are contained in each `dict` yielded from this iterator: - - * **feed** (:class:`numpy.ndarray`) - The feed for the model. The array returned is \ - in the format (`batchsize`, `height`, `width`, `channels`). This is the :attr:`x` \ - parameter for :func:`keras.models.model.train_on_batch`. - - * **targets** (`list`) - A list of 4-dimensional :class:`numpy.ndarray` objects in \ - the order and size of each output of the model as defined in \ - :attr:`model_output_shapes`. the format of these arrays will be (`batchsize`, \ - `height`, `width`, `3`). This is the :attr:`y` parameter for \ - :func:`keras.models.model.train_on_batch` **NB:** masks are not included in the \ - `targets` list. If required for feeding into the Keras model, they will need to be \ - added to this list in :mod:`plugins.train.trainer._base` from the `masks` key. - - * **masks** (:class:`numpy.ndarray`) - A 4-dimensional array containing the target \ - masks in the format (`batchsize`, `height`, `width`, `1`). - - * **samples** (:class:`numpy.ndarray`) - A 4-dimensional array containing the samples \ - for feeding to the model's predict function for generating preview and time-lapse \ - samples. The array will be in the format (`batchsize`, `height`, `width`, \ - `channels`). **NB:** This item will only exist in the `dict` if :attr:`is_preview` \ - or :attr:`is_timelapse` is ``True`` + Returns + ------- + list + List of 4-dimensional target images, at all model output sizes, with masks compiled + into channels 4+ for each output size """ - logger.debug("Queue batches: (image_count: %s, batchsize: %s, side: '%s', do_shuffle: %s, " - "is_preview, %s, is_timelapse: %s)", len(images), batchsize, side, do_shuffle, - is_preview, is_timelapse) - self._batchsize = batchsize - self._face_cache = _get_cache(side, images, self._config) - self._processing = ImageAugmentation(batchsize, - is_preview or is_timelapse, - self._model_input_size, - self._model_output_shapes, - self._coverage_ratio, - self._config) - - if self._warp_to_landmarks and not self._face_cache.partially_loaded: - self._face_cache.pre_fill(images, side) - - args = (images, side, do_shuffle, batchsize) - batcher = BackgroundGenerator(self._minibatch, thread_count=2, args=args) - return batcher.iterator() - - # << INTERNAL METHODS >> # - def _validate_samples(self, data): - """ Ensures that the total number of images within :attr:`images` is greater or equal to - the selected :attr:`batchsize`. Raises an exception if this is not the case. """ - length = len(data) - msg = ("Number of images is lower than batch-size (Note that too few " - "images may lead to bad training). # images: {}, " - "batch-size: {}".format(length, self._batchsize)) - try: - assert length >= self._batchsize, msg - except AssertionError as err: - msg += ("\nYou should increase the number of images in your training set or lower " - "your batch-size.") - raise FaceswapError(msg) from err - - def _minibatch(self, images, side, do_shuffle, batchsize): - """ A generator function that yields the augmented, target and sample images. - see :func:`minibatch_ab` for more details on the output. """ - logger.debug("Loading minibatch generator: (image_count: %s, side: '%s', do_shuffle: %s)", - len(images), side, do_shuffle) - self._validate_samples(images) - - def _img_iter(imgs): - while True: - if do_shuffle: - shuffle(imgs) - for img in imgs: - yield img - - img_iter = _img_iter(images) - while True: - img_paths = [next(img_iter) for _ in range(batchsize)] - yield self._process_batch(img_paths, side) - - logger.debug("Finished minibatch generator: (side: '%s')", side) + logger.trace("Compiling targets: batch shape: %s", batch.shape) # type: ignore + if len(self._output_sizes) == 1 and self._output_sizes[0] == self._process_size: + # Rolling buffer here makes next to no difference, so just create array on the fly + retval = [self._to_float32(batch)] + else: + retval = [self._to_float32(np.array([cv2.resize(image, (size, size), cv2.INTER_AREA) + for image in batch])) + for size in self._output_sizes] + logger.trace("Processed targets: %s", [t.shape for t in retval]) # type: ignore + return retval - def _process_batch(self, filenames, side): + def process_batch(self, + filenames: List[str], + images: np.ndarray, + detected_faces: List[DetectedFace], + batch: np.ndarray) -> BatchType: """ Performs the augmentation and compiles target images and samples. - If this is the first time a face has been loaded, then it's meta data is extracted from the - png header and added to :attr:`_face_cache` - - See - :func:`minibatch_ab` for more details on the output. - Parameters ---------- filenames: list - List of full paths to image file names - side: str - The side of the model being trained on (`a` or `b`) - """ - logger.trace("Process batch: (filenames: '%s', side: '%s')", filenames, side) - - if not self._face_cache.cache_full: - batch = self._face_cache.cache_metadata(filenames) - else: - batch = read_image_batch(filenames) - - cache = self._face_cache.get_items(filenames) - batch, landmarks = self._crop_to_center(filenames, cache, batch, side) - batch = self._apply_mask(filenames, cache, batch, side) - processed = dict() - - # Initialize processing training size on first image - if not self._processing.initialized: - self._processing.initialize(batch.shape[1]) + List of full paths to image file names for a single batch + images: :class:`numpy.ndarray` + The batch of faces corresponding to the filenames + detected_faces: list + List of :class:`~lib.align.DetectedFace` objects with aligned data and masks loaded for + the current batch + batch: :class:`numpy.ndarray` + The pre-allocated batch with images and masks populated for the selected coverage and + centering - # Get Landmarks prior to manipulating the image - if self._warp_to_landmarks: - batch_dst_pts = self._get_closest_match(filenames, side, landmarks) - warp_kwargs = dict(batch_src_points=landmarks, batch_dst_points=batch_dst_pts) - else: - warp_kwargs = dict() + Returns + ------- + feed: list + 4-dimensional array of faces to feed the training the model (:attr:`x` parameter for + :func:`keras.models.model.train_on_batch`.). The array returned is in the format + (`batch size`, `height`, `width`, `channels`). + targets: list + List of 4-dimensional :class:`numpy.ndarray` objects in the order and size of each + output of the model. The format of these arrays will be (`batch size`, `height`, + `width`, `x`). This is the :attr:`y` parameter for + :func:`keras.models.model.train_on_batch`. The number of channels here will vary. + The first 3 channels are (rgb/bgr). The 4th channel is the face mask. Any subsequent + channels are area masks (e.g. eye/mouth masks) + """ + logger.trace("Process training: (side: '%s', filenames: '%s', images: %s, " # type:ignore + "batch: %s, detected_faces: %s)", self._side, filenames, images.shape, + batch.shape, len(detected_faces)) # Color Augmentation of the image only if self._augment_color: batch[..., :3] = self._processing.color_adjust(batch[..., :3]) # Random Transform and flip - batch = self._processing.transform(batch) + self._processing.transform(batch) + if not self._no_flip: - batch = self._processing.random_flip(batch) + self._processing.random_flip(batch) # Switch color order for RGB models - if self._color_order == "rgb": - batch[..., :3] = batch[..., [2, 1, 0]] - - # Add samples to output if this is for display - if self._processing.is_display: - processed["samples"] = batch[..., :3].astype("float32") / 255.0 + self._set_color_order(batch) # Get Targets - processed.update(self._processing.get_targets(batch)) + targets = self._create_targets(batch) - # Random Warp # TODO change masks to have a input mask and a warped target mask - if self._no_warp: - processed["feed"] = [self._processing.skip_warp(batch[..., :3])] + # TODO Look at potential for applying mask on input + # Random Warp + if self._warp_to_landmarks: + landmarks = np.array([face.aligned.landmarks for face in detected_faces]) + batch_dst_pts = self._get_closest_match(filenames, landmarks) + warp_kwargs = dict(batch_src_points=landmarks, batch_dst_points=batch_dst_pts) else: - processed["feed"] = [self._processing.warp(batch[..., :3], - self._warp_to_landmarks, - **warp_kwargs)] + warp_kwargs = {} + + warped = batch[..., :3] if self._no_warp else self._processing.warp( + batch[..., :3], + self._warp_to_landmarks, + **warp_kwargs) + + if self._model_input_size != self._process_size: + feed = self._to_float32(np.array([cv2.resize(image, + (self._model_input_size, + self._model_input_size), + cv2.INTER_AREA) + for image in warped])) + else: + feed = self._to_float32(warped) - logger.trace("Processed batch: (filenames: %s, side: '%s', processed: %s)", - filenames, - side, - {k: v.shape if isinstance(v, np.ndarray) else[i.shape for i in v] - for k, v in processed.items()}) - return processed + logger.trace("Processed batch: (filenames: %s, side: '%s', " # type: ignore + "feed: %s, targets: %s)", filenames, self._side, + [f.shape for f in feed], [t.shape for t in targets]) - def _crop_to_center(self, filenames, cache, batch, side): - """ Crops the training image out of the full extract image based on the centering used in - the user's configuration settings. + return feed, targets - If legacy extract images are being used then this just returns the extracted batch with - their corresponding landmarks. + def _get_closest_match(self, filenames: List[str], batch_src_points: np.ndarray) -> np.ndarray: + """ Only called if the :attr:`_warp_to_landmarks` is ``True``. Gets the closest + matched 68 point landmarks from the opposite training set. Parameters ---------- filenames: list - The list of filenames that correspond to this batch - cache: list - The list of cached items (aligned faces, masks etc.) corresponding to the batch - batch: :class:`numpy.ndarray` - The batch of faces that have been loaded from disk - side: str - '"a"' or '"b"' the side that is being processed + Filenames for current batch + batch_src_points: :class:`np.ndarray` + The source landmarks for the current batch Returns ------- - batch: :class:`numpy.ndarray` - The centered faces cropped out of the loaded batch - landmarks: :class:`numpy.ndarray` - The aligned landmarks for this batch. NB: The aligned landmarks do not directly - correspond to the size of the extracted face. They are scaled to the source training - image, not the sub-image. + :class:`np.ndarray` + Randomly selected closest matches from the other side's landmarks + """ + logger.trace("Retrieving closest matched landmarks: (filenames: '%s', " # type: ignore + "src_points: '%s')", filenames, batch_src_points) + lm_side: Literal["a", "b"] = "a" if self._side == "b" else "b" + other_cache = get_cache(lm_side) + landmarks = other_cache.aligned_landmarks + + try: + closest_matches = [self._nearest_landmarks[os.path.basename(filename)] + for filename in filenames] + except KeyError: + # Resize mismatched training image size landmarks + sizes = {side: cache.size for side, cache in zip((self._side, lm_side), + (self._face_cache, other_cache))} + if len(set(sizes.values())) > 1: + scale = sizes[self._side] / sizes[lm_side] + landmarks = {key: lms * scale for key, lms in landmarks.items()} + closest_matches = self._cache_closest_matches(filenames, batch_src_points, landmarks) + + batch_dst_points = np.array([landmarks[choice(fname)] for fname in closest_matches]) + logger.trace("Returning: (batch_dst_points: %s)", batch_dst_points.shape) # type: ignore + return batch_dst_points + + def _cache_closest_matches(self, + filenames: List[str], + batch_src_points: np.ndarray, + landmarks: Dict[str, np.ndarray]) -> List[Tuple[str, ...]]: + """ Cache the nearest landmarks for this batch + + Parameters + ---------- + filenames: list + Filenames for current batch + batch_src_points: :class:`np.ndarray` + The source landmarks for the current batch + landmarks: dict + The destination landmarks with associated filenames - Raises - ------ - FaceswapError - If Alignment information is not available for any of the images being loaded in - the batch """ - logger.trace("Cropping training images info: (filenames: %s, side: '%s')", filenames, side) - aligned = [item["aligned_face"] for item in cache] + logger.trace("Caching closest matches") # type:ignore + dst_landmarks = list(landmarks.items()) + dst_points = np.array([lm[1] for lm in dst_landmarks]) + batch_closest_matches: List[Tuple[str, ...]] = [] - if self._face_cache.extract_version == 1.0: - # Legacy extract. Don't crop, just return batch with landmarks - return batch, np.array([face.landmarks for face in aligned]) + for filename, src_points in zip(filenames, batch_src_points): + closest = (np.mean(np.square(src_points - dst_points), axis=(1, 2))).argsort()[:10] + closest_matches = tuple(dst_landmarks[i][0] for i in closest) + self._nearest_landmarks[os.path.basename(filename)] = closest_matches + batch_closest_matches.append(closest_matches) + logger.trace("Cached closest matches") # type:ignore + return batch_closest_matches - landmarks = np.array([face.landmarks for face in aligned]) - cropped = np.array([align.extract_face(img) for align, img in zip(aligned, batch)]) - return cropped, landmarks - def _apply_mask(self, filenames, cache, batch, side): - """ Applies the mask to the 4th channel of the image. If masks are not being used - applies a dummy all ones mask. +class PreviewDataGenerator(DataGenerator): + """ Generator for compiling images for generating previews. - If the configuration options `eye_multiplier` and/or `mouth_multiplier` are greater than 1 - then these masks are applied to the final channels of the batch respectively. + This class is called from :mod:`plugins.train.trainer._base` and launches a background + iterator that compiles sample preview data for feeding the model's predict function and for + display. + + Parameters + ---------- + model: :class:`~plugins.train.model.ModelBase` + The model that this data generator is feeding + config: dict + The configuration `dict` generated from :file:`config.train.ini` containing the trainer + plugin configuration options. + side: {'a' or 'b'} + The side of the model that this iterator is for. + images: list + A list of image paths that will be used to compile the final images. + batch_size: int + The batch size for this iterator. Images will be returned in :class:`numpy.ndarray` + objects of this size from the iterator. + """ + def _create_samples(self, + images: np.ndarray, + detected_faces: List[DetectedFace]) -> List[np.ndarray]: + """ Compile the 'sample' images. These are the 100% coverage images which hold the model + output in the preview window. Parameters ---------- - filenames: list - The list of filenames that correspond to this batch - cache: list - The list of cached items (aligned faces, masks etc.) corresponding to the batch - batch: :class:`numpy.ndarray` - The batch of faces that have been loaded from disk - side: str - '"a"' or '"b"' the side that is being processed + images: :class:`numpy.ndarray` + The original batch of images as loaded from disk. + detected_faces: list + List of :class:`~lib.align.DetectedFace` for the current batch Returns ------- - :class:`numpy.ndarray` - The batch with masks applied to the final channels + list + List of 4-dimensional target images, at final model output size """ - logger.trace("Input filenames: %s, batch shape: %s, side: %s", - filenames, batch.shape, side) - size = batch.shape[1] - - for key in ("mask", "mask_eyes", "mask_mouth"): - lookup = cache[0].get(key) - if lookup is None and key != "mask": - continue - - if lookup is None and key == "mask": - logger.trace("Creating dummy masks. side: %s", side) - masks = np.ones_like(batch[..., :1], dtype=batch.dtype) - else: - logger.trace("Obtaining masks for batch. (key: %s side: %s)", key, side) - - masks = np.array([self._get_mask(item[key], size) - for item in cache], dtype=batch.dtype) - masks = self._resize_masks(size, masks) - logger.trace("masks: (key: %s, shape: %s)", key, masks.shape) - batch = np.concatenate((batch, masks), axis=-1) - logger.trace("Output batch shape: %s, side: %s", batch.shape, side) - return batch - - @classmethod - def _get_mask(cls, item, size): - """ Decompress zipped eye and mouth masks, or return the stored mask + logger.trace("Compiling samples: images shape: %s, detected_faces: %s ", # type: ignore + images.shape, len(detected_faces)) + output_size = self._output_sizes[-1] + full_size = 2 * int(np.rint((output_size / self._coverage_ratio) / 2)) + + assert self._config["centering"] in get_args(CenteringType) + retval = np.empty((full_size, full_size, 3), dtype="float32") + retval = self._to_float32(np.array([AlignedFace(face.landmarks_xy, + image=images[idx], + centering=cast(CenteringType, + self._config["centering"]), + size=full_size, + dtype="uint8", + is_aligned=True).face + for idx, face in enumerate(detected_faces)])) + + logger.trace("Processed samples: %s", retval.shape) # type: ignore + return [retval] + + def process_batch(self, + filenames: List[str], + images: np.ndarray, + detected_faces: List[DetectedFace], + batch: np.ndarray) -> BatchType: + """ Creates the full size preview images and the sub-cropped images for feeding the model's + predict function. Parameters ---------- - item: :class:`lib.align.Mask` or `bytes` - Either a stored face mask object or a zipped eye or mouth mask - size: int - The size of the stored eye or mouth mask for reshaping + filenames: list + List of full paths to image file names for a single batch + images: :class:`numpy.ndarray` + The batch of faces corresponding to the filenames + detected_faces: list + List of :class:`~lib.align.DetectedFace` objects with aligned data and masks loaded for + the current batch + batch: :class:`numpy.ndarray` + The pre-allocated batch with images and masks populated for the selected coverage and + centering Returns ------- - class:`numpy.ndarray` - The decompressed mask + feed: list + List of 4-dimensional :class:`numpy.ndarray` objects at model input size for feeding + the model's predict function. The first 3 channels are (rgb/bgr). The 4th channel is + the face mask. + samples: list + 4-dimensional array containing the 100% coverage images at the model's centering for + for generating previews. The array returned is in the format + (`batch size`, `height`, `width`, `channels`). """ - if isinstance(item, bytes): - retval = np.frombuffer(decompress(item), dtype="uint8").reshape(size, size, 1) - else: - retval = item.mask - return retval + logger.trace("Process preview: (side: '%s', filenames: '%s', images: %s, " # type:ignore + "batch: %s, detected_faces: %s)", self._side, filenames, images.shape, + batch.shape, len(detected_faces)) - @classmethod - def _resize_masks(cls, target_size, masks): - """ Resize the masks to the target size """ - logger.trace("target size: %s, masks shape: %s", target_size, masks.shape) - mask_size = masks.shape[1] - if target_size == mask_size: - logger.trace("Mask and targets the same size. Not resizing") - return masks - interpolator = cv2.INTER_CUBIC if mask_size < target_size else cv2.INTER_AREA - masks = np.array([cv2.resize(mask, - (target_size, target_size), - interpolation=interpolator)[..., None] - for mask in masks]) - logger.trace("Resized masks: %s", masks.shape) - return masks - - def _get_closest_match(self, filenames, side, batch_src_points): - """ Only called if the :attr:`_warp_to_landmarks` is ``True``. Gets the closest - matched 68 point landmarks from the opposite training set. """ - logger.trace("Retrieving closest matched landmarks: (filenames: '%s', src_points: '%s'", - filenames, batch_src_points) - lm_side = "a" if side == "b" else "b" - landmarks = _FACE_CACHES[lm_side].aligned_landmarks - - closest_matches = [self._nearest_landmarks.get(os.path.basename(filename)) - for filename in filenames] - if None in closest_matches: - # Resize mismatched training image size landmarks - sizes = {side: cache.crop_size for side, cache in _FACE_CACHES.items()} - if len(set(sizes.values())) > 1: - scale = sizes[side] / sizes[lm_side] - landmarks = {key: lms * scale for key, lms in landmarks.items()} - closest_matches = self._cache_closest_matches(filenames, batch_src_points, landmarks) + self._set_color_order(batch) # Switch color order for RGB models - batch_dst_points = np.array([landmarks[choice(fname)] for fname in closest_matches]) - logger.trace("Returning: (batch_dst_points: %s)", batch_dst_points.shape) - return batch_dst_points + if not self._use_mask: + mask = np.zeros_like(batch[..., 0])[..., None] + 255 + batch = np.concatenate([batch, mask], axis=-1) - def _cache_closest_matches(self, filenames, batch_src_points, landmarks): - """ Cache the nearest landmarks for this batch """ - logger.trace("Caching closest matches") - dst_landmarks = list(landmarks.items()) - dst_points = np.array([lm[1] for lm in dst_landmarks]) - batch_closest_matches = list() + feed = self._to_float32(batch[..., :4]) - for filename, src_points in zip(filenames, batch_src_points): - closest = (np.mean(np.square(src_points - dst_points), axis=(1, 2))).argsort()[:10] - closest_matches = tuple(dst_landmarks[i][0] for i in closest) - self._nearest_landmarks[os.path.basename(filename)] = closest_matches - batch_closest_matches.append(closest_matches) - logger.trace("Cached closest matches") - return batch_closest_matches + samples = self._create_samples(images, detected_faces) + + logger.trace("Processed batch: (filenames: %s, side: '%s', " # type: ignore + "feed: %s, targets: %s, samples: %s)", filenames, self._side, + [f.shape for f in feed], [t.shape for t in samples]) + return feed, samples diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index ef0cf4f6a2..c7bab46641 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -204,11 +204,12 @@ def model_name(self) -> str: return self.name @property - def output_shapes(self) -> List[List[Tuple]]: + def output_shapes(self) -> List[List[Tuple[int, int, int]]]: """ list: A list of list of shape tuples for the outputs of the model with the batch dimension removed. The outer list contains 2 sub-lists (one for each side "a" and "b"). The inner sub-lists contain the output shapes for that side. """ - shapes = [tuple(K.int_shape(output)[-3:]) for output in self.model.outputs] + shapes: List[Tuple[int, int, int]] = [tuple(K.int_shape(output)[-3:]) # type: ignore + for output in self.model.outputs] return [shapes[:len(shapes) // 2], shapes[len(shapes) // 2:]] @property @@ -477,7 +478,7 @@ def _rewrite_plaid_outputs(self) -> None: self.model.output_names, new_names) self.model.output_names = new_names - def _legacy_mapping(self) -> Optional[dict]: # pylint:disable=no-self-use + def _legacy_mapping(self) -> Optional[dict]: """ The mapping of separate model files to single model layers for transferring of legacy weights. diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 52605fcec8..a1ae3d5526 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -7,10 +7,11 @@ with "original" unique code split out to the original plugin. """ -# pylint:disable=too-many-lines import logging import os +import sys import time +from typing import Callable, cast, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 import numpy as np @@ -19,14 +20,23 @@ from tensorflow.python.framework import ( # pylint:disable=no-name-in-module errors_impl as tf_errors) -from lib.training import TrainingDataGenerator +from lib.training import PreviewDataGenerator, TrainingDataGenerator +from lib.training.generator import BatchType, ConfigType, DataGenerator from lib.utils import FaceswapError, get_backend, get_folder, get_image_paths, get_tf_version from plugins.train._config import Config +if TYPE_CHECKING: + from plugins.train.model._base import ModelBase + +if sys.version_info < (3, 8): + from typing_extensions import get_args, Literal +else: + from typing import get_args, Literal + logger = logging.getLogger(__name__) # pylint: disable=invalid-name -def _get_config(plugin_name, configfile=None): +def _get_config(plugin_name: str, configfile: Optional[str] = None) -> ConfigType: """ Return the configuration for the requested trainer. Parameters @@ -39,8 +49,8 @@ def _get_config(plugin_name, configfile=None): Returns ------- - :class:`lib.config.FaceswapConfig` - The configuration file for the requested plugin + dict + The configuration dictionary for the requested plugin """ return Config(plugin_name, configfile=configfile).config_dict @@ -65,7 +75,11 @@ class TrainerBase(): from the default :file:`.config.train.ini` file. """ - def __init__(self, model, images, batch_size, configfile): + def __init__(self, + model: "ModelBase", + images: Dict[Literal["a", "b"], List[str]], + batch_size: int, + configfile: Optional[str]) -> None: logger.debug("Initializing %s: (model: '%s', batch_size: %s)", self.__class__.__name__, model, batch_size) self._model = model @@ -81,12 +95,12 @@ def __init__(self, model, images, batch_size, configfile): self._samples = _Samples(self._model, self._model.coverage_ratio) self._timelapse = _Timelapse(self._model, self._model.coverage_ratio, - self._config.get("preview_images", 14), + int(self._config.get("preview_images", 14)), self._feeder, self._images) logger.debug("Initialized %s", self.__class__.__name__) - def _get_config(self, configfile): + def _get_config(self, configfile: Optional[str]) -> ConfigType: """ Get the saved training config options. Override any global settings with the setting provided from the model's saved config. @@ -111,7 +125,7 @@ def _get_config(self, configfile): config[key] = new_val return config - def _set_tensorboard(self): + def _set_tensorboard(self) -> tf.keras.callbacks.TensorBoard: """ Set up Tensorboard callback for logging loss. Bypassed if command line option "no-logs" has been selected. @@ -122,7 +136,7 @@ def _set_tensorboard(self): Tensorboard object for the the current training session. """ if self._model.state.current_session["no_logs"]: - logger.verbose("TensorBoard logging disabled") + logger.verbose("TensorBoard logging disabled") # type: ignore return None logger.debug("Enabling TensorBoard Logging") @@ -140,14 +154,16 @@ def _set_tensorboard(self): embeddings_metadata=None) tensorboard.set_model(self._model.model) tensorboard.on_train_begin(0) - logger.verbose("Enabled TensorBoard Logging") + logger.verbose("Enabled TensorBoard Logging") # type: ignore return tensorboard - def toggle_mask(self): + def toggle_mask(self) -> None: """ Toggle the mask overlay on or off based on user input. """ self._samples.toggle_mask_display() - def train_one_step(self, viewer, timelapse_kwargs): + def train_one_step(self, + viewer: Optional[Callable[[np.ndarray, str], None]], + timelapse_kwargs: Optional[Dict[str, str]]) -> None: """ Running training on a batch of images for each side. Triggered from the training cycle in :class:`scripts.train.Train`. @@ -171,7 +187,7 @@ def train_one_step(self, viewer, timelapse_kwargs): Parameters ---------- - viewer: :func:`scripts.train.Train._show` + viewer: :func:`scripts.train.Train._show` or ``None`` The function that will display the preview image timelapse_kwargs: dict The keyword arguments for generating time-lapse previews. If a time-lapse preview is @@ -179,16 +195,19 @@ def train_one_step(self, viewer, timelapse_kwargs): the keys being `input_a`, `input_b`, `output`. """ self._model.state.increment_iterations() - logger.trace("Training one step: (iteration: %s)", self._model.iterations) - do_preview = viewer is not None + logger.trace("Training one step: (iteration: %s)", self._model.iterations) # type: ignore snapshot_interval = self._model.command_line_arguments.snapshot_interval do_snapshot = (snapshot_interval != 0 and self._model.iterations - 1 >= snapshot_interval and (self._model.iterations - 1) % snapshot_interval == 0) model_inputs, model_targets = self._feeder.get_batch() + if get_backend() == "amd": # Expand out AMD inputs + targets + model_inputs = [inp for side in model_inputs for inp in side] # type: ignore + model_targets = [tgt for side in model_targets for tgt in side] # type: ignore + try: - loss = self._model.model.train_on_batch(model_inputs, y=model_targets) + loss: List[float] = self._model.model.train_on_batch(model_inputs, y=model_targets) except tf_errors.ResourceExhaustedError as err: msg = ("You do not have enough GPU memory available to train the selected model at " "the selected settings. You can try a number of things:" @@ -220,23 +239,11 @@ def train_one_step(self, viewer, timelapse_kwargs): self._log_tensorboard(loss) loss = self._collate_and_store_loss(loss[1:]) self._print_loss(loss) - if do_snapshot: self._model.snapshot() + self._update_viewers(viewer, timelapse_kwargs) - if do_preview: - self._feeder.generate_preview(do_preview) - self._samples.images = self._feeder.compile_sample(None) - samples = self._samples.show_sample() - if samples is not None: - viewer(samples, - "Training - 'S': Save Now. 'R': Refresh Preview. 'M': Toggle Mask. 'F': " - "Toggle Screen Fit-Actual Size. 'ENTER': Save and Quit") - - if timelapse_kwargs: - self._timelapse.output_timelapse(timelapse_kwargs) - - def _log_tensorboard(self, loss): + def _log_tensorboard(self, loss: List[float]) -> None: """ Log current loss to Tensorboard log files Parameters @@ -246,7 +253,7 @@ def _log_tensorboard(self, loss): """ if not self._tensorboard: return - logger.trace("Updating TensorBoard log") + logger.trace("Updating TensorBoard log") # type: ignore logs = {log[0]: log[1] for log in zip(self._model.state.loss_names, loss)} @@ -262,7 +269,7 @@ def _log_tensorboard(self, loss): else: self._tensorboard.on_train_batch_end(self._model.iterations, logs=logs) - def _collate_and_store_loss(self, loss): + def _collate_and_store_loss(self, loss: List[float]) -> List[float]: """ Collate the loss into totals for each side. The losses are summed into a total for each side. Loss totals are added to @@ -273,12 +280,13 @@ def _collate_and_store_loss(self, loss): Parameters ---------- loss: list - The list of loss ``floats`` for this iteration. + The list of loss ``floats`` for each side this iteration (excluding total combined + loss) Returns ------- list - List of 2 ``floats`` which is the total loss for each side + List of 2 ``floats`` which is the total loss for each side (eg sum of face + mask loss) Raises ------ @@ -294,10 +302,10 @@ def _collate_and_store_loss(self, loss): split = len(loss) // 2 combined_loss = [sum(loss[:split]), sum(loss[split:])] self._model.add_history(combined_loss) - logger.trace("original loss: %s, comibed_loss: %s", loss, combined_loss) + logger.trace("original loss: %s, combined_loss: %s", loss, combined_loss) # type: ignore return combined_loss - def _print_loss(self, loss): + def _print_loss(self, loss: List[float]) -> None: """ Outputs the loss for the current iteration to the console. Parameters @@ -316,7 +324,32 @@ def _print_loss(self, loss): logger.warning("Swallowed OS Error caused by Tensorflow distributed training. output " "line: %s, error: %s", output, str(err)) - def clear_tensorboard(self): + def _update_viewers(self, + viewer: Optional[Callable[[np.ndarray, str], None]], + timelapse_kwargs: Optional[Dict[str, str]]) -> None: + """ Update the preview viewer and timelapse output + + Parameters + ---------- + viewer: :func:`scripts.train.Train._show` or ``None`` + The function that will display the preview image + timelapse_kwargs: dict + The keyword arguments for generating time-lapse previews. If a time-lapse preview is + not required then this should be ``None``. Otherwise all values should be full paths + the keys being `input_a`, `input_b`, `output`. + """ + if viewer is not None: + self._samples.images = self._feeder.generate_preview() + samples = self._samples.show_sample() + if samples is not None: + viewer(samples, + "Training - 'S': Save Now. 'R': Refresh Preview. 'M': Toggle Mask. 'F': " + "Toggle Screen Fit-Actual Size. 'ENTER': Save and Quit") + + if timelapse_kwargs: + self._timelapse.output_timelapse(timelapse_kwargs) + + def clear_tensorboard(self) -> None: """ Stop Tensorboard logging. Tensorboard logging needs to be explicitly shutdown on training termination. Called from @@ -342,77 +375,84 @@ class _Feeder(): config: :class:`lib.config.FaceswapConfig` The configuration for this trainer """ - def __init__(self, images, model, batch_size, config): + def __init__(self, + images: Dict[Literal["a", "b"], List[str]], + model: 'ModelBase', + batch_size: int, + config: ConfigType) -> None: logger.debug("Initializing %s: num_images: %s, batch_size: %s, config: %s)", - self.__class__.__name__, len(images), batch_size, config) + self.__class__.__name__, {k: len(v) for k, v in images.items()}, batch_size, + config) self._model = model self._images = images + self._batch_size = batch_size self._config = config - self._target = {} - self._samples = {} - self._masks = {} - - self._feeds = {side: self._load_generator(idx).minibatch_ab(images[side], batch_size, side) - for idx, side in enumerate(("a", "b"))} + self._feeds = {side: self._load_generator(side, False).minibatch_ab() + for side in get_args(Literal["a", "b"])} self._display_feeds = dict(preview=self._set_preview_feed(), timelapse={}) logger.debug("Initialized %s:", self.__class__.__name__) - def _load_generator(self, output_index): + def _load_generator(self, + side: Literal["a", "b"], + is_display: bool, + batch_size: Optional[int] = None, + images: Optional[List[str]] = None) -> DataGenerator: """ Load the :class:`~lib.training_data.TrainingDataGenerator` for this feeder. Parameters ---------- - output_index: int - The output index from the model to get output shapes for + side: ["a", "b"] + The side of the model to load the generator for + is_display: bool + ``True`` if the generator is for creating preview/time-lapse images. ``False`` if it is + for creating training images + batch_size: int, optional + If ``None`` then the batch size selected in command line arguments is used, otherwise + the batch size provided here is used. + images: list, optional. Default: ``None`` + If provided then this will be used as the list of images for the generator. If ``None`` + then the training folder images for the side will be used. Default: ``None`` Returns ------- :class:`~lib.training_data.TrainingDataGenerator` The training data generator """ - logger.debug("Loading generator") - input_size = self._model.model.input_shape[output_index][1] - output_shapes = self._model.output_shapes[output_index] - logger.debug("input_size: %s, output_shapes: %s", input_size, output_shapes) - generator = TrainingDataGenerator(input_size, - output_shapes, - self._model.coverage_ratio, - self._model.color_order, - not self._model.command_line_arguments.no_augment_color, - self._model.command_line_arguments.no_flip, - self._model.command_line_arguments.no_warp, - self._model.command_line_arguments.warp_to_landmarks, - self._config) - return generator - - def _set_preview_feed(self): + logger.debug("Loading generator, side: %s, is_display: %s, batch_size: %s", + side, is_display, batch_size) + generator = PreviewDataGenerator if is_display else TrainingDataGenerator + retval = generator(self._config, + self._model, + side, + self._images[side] if images is None else images, + self._batch_size if batch_size is None else batch_size) + return retval + + def _set_preview_feed(self) -> Dict[Literal["a", "b"], Generator[BatchType, None, None]]: """ Set the preview feed for this feeder. - Creates a generator from :class:`lib.training_data.TrainingDataGenerator` specifically + Creates a generator from :class:`lib.training_data.PreviewDataGenerator` specifically for previews for the feeder. Returns ------- dict - The side ("a" or "b") as key, :class:`~lib.training_data.TrainingDataGenerator` as + The side ("a" or "b") as key, :class:`~lib.training_data.PreviewDataGenerator` as value. """ - retval = {} - for idx, side in enumerate(("a", "b")): + retval: Dict[Literal["a", "b"], Generator[BatchType, None, None]] = {} + for side in get_args(Literal["a", "b"]): logger.debug("Setting preview feed: (side: '%s')", side) - preview_images = self._config.get("preview_images", 14) + preview_images = int(self._config.get("preview_images", 14)) preview_images = min(max(preview_images, 2), 16) batchsize = min(len(self._images[side]), preview_images) - retval[side] = self._load_generator(idx).minibatch_ab(self._images[side], - batchsize, - side, - do_shuffle=True, - is_preview=True) - logger.debug("Set preview feed. Batchsize: %s", batchsize) + retval[side] = self._load_generator(side, + True, + batch_size=batchsize).minibatch_ab() return retval - def get_batch(self): + def get_batch(self) -> Tuple[List[List[np.ndarray]], ...]: """ Get the feed data and the targets for each training side for feeding into the model's train function. @@ -423,110 +463,79 @@ def get_batch(self): model_targets: list The targets for the model for each side A and B """ - model_inputs = [] - model_targets = [] + model_inputs: List[List[np.ndarray]] = [] + model_targets: List[List[np.ndarray]] = [] for side in ("a", "b"): - batch = next(self._feeds[side]) - side_inputs = batch["feed"] - side_targets = self._compile_mask_targets(batch["targets"], - batch["masks"], - batch.get("additional_masks", None)) - if self._model.config["learn_mask"]: - side_targets = side_targets + [batch["masks"]] - logger.trace("side: %s, input_shapes: %s, target_shapes: %s", - side, [i.shape for i in side_inputs], [i.shape for i in side_targets]) - if get_backend() == "amd": - model_inputs.extend(side_inputs) - model_targets.extend(side_targets) - else: - model_inputs.append(side_inputs) - model_targets.append(side_targets) - return model_inputs, model_targets + side_feed, side_targets = next(self._feeds[side]) + if self._model.config["learn_mask"]: # Add the face mask as it's own target + side_targets += [side_targets[-1][..., 3][..., None]] + logger.trace("side: %s, input_shapes: %s, target_shapes: %s", # type: ignore + side, side_feed.shape, [i.shape for i in side_targets]) + model_inputs.append([side_feed]) + model_targets.append(side_targets) - def _compile_mask_targets(self, targets, masks, additional_masks): - """ Compile the masks into the targets for penalized loss and for targeted learning. + return model_inputs, model_targets - Penalized loss expects the target mask to be included for all outputs in the 4th channel - of the targets. Any additional masks are placed into subsequent channels for extraction - by the relevant loss functions. + def generate_preview(self, + is_timelapse: bool = False) -> Dict[Literal["a", "b"], List[np.ndarray]]: + """ Generate the images for preview window or timelapse Parameters ---------- - targets: list - The targets for the model, with the mask as the final entry in the list - masks: list - The masks for the model - additional_masks: list or ``None`` - Any additional masks for the model, or ``None`` if no additional masks are required + is_timelapse, bool, optional + ``True`` if preview is to be generated for a Timelapse otherwise ``False``. + Default: ``False`` Returns ------- - list - The targets for the model with the mask compiled into the 4th channel. The original - mask is still output as the final item in the list - """ - if not self._model.config["penalized_mask_loss"] and additional_masks is None: - logger.trace("No masks to compile. Returning targets") - return targets - - if not self._model.config["penalized_mask_loss"] and additional_masks is not None: - masks = additional_masks - elif additional_masks is not None: - masks = np.concatenate((masks, additional_masks), axis=-1) - - for idx, tgt in enumerate(targets): - tgt_dim = tgt.shape[1] - if tgt_dim == masks.shape[1]: - add_masks = masks - else: - add_masks = np.array([cv2.resize(mask, (tgt_dim, tgt_dim)) - for mask in masks]) - if add_masks.ndim == 3: - add_masks = add_masks[..., None] - targets[idx] = np.concatenate((tgt, add_masks), axis=-1) - logger.trace("masks added to targets: %s", [tgt.shape for tgt in targets]) - return targets - - def generate_preview(self, do_preview): - """ Generate the preview images. - - Parameters - ---------- - do_preview: bool - Whether the previews should be generated. ``True`` if they should ``False`` if they - should not be generated, in which case currently stored previews should be deleted. + dict + Dictionary for side A and B of list of numpy arrays corresponding to the + samples, targets and masks for this preview """ - if not do_preview: - self._samples = {} - self._target = {} - self._masks = {} - return - logger.debug("Generating preview") - for side in ("a", "b"): - batch = next(self._display_feeds["preview"][side]) - self._samples[side] = batch["samples"] - self._target[side] = batch["targets"][-1] - self._masks[side] = batch["masks"] - - def compile_sample(self, batch_size, samples=None, images=None, masks=None): + logger.debug("Generating preview (is_timelapse: %s)", is_timelapse) + + batchsizes: List[int] = [] + feed: Dict[Literal["a", "b"], np.ndarray] = {} + samples: Dict[Literal["a", "b"], np.ndarray] = {} + masks: Dict[Literal["a", "b"], np.ndarray] = {} + + # MyPy can't recurse into nested dicts to get the type :( + iterator = cast(Dict[Literal["a", "b"], Generator[BatchType, None, None]], + self._display_feeds["timelapse" if is_timelapse else "preview"]) + for side in get_args(Literal["a", "b"]): + side_feed, side_samples = next(iterator[side]) + batchsizes.append(len(side_samples[0])) + samples[side] = side_samples[0] + feed[side] = side_feed[..., :3] + masks[side] = side_feed[..., 3][..., None] + + logger.debug("Generated samples: is_timelapse: %s, images: %s", is_timelapse, + {key: {k: v.shape for k, v in item.items()} + for key, item + in zip(("feed", "samples", "sides"), (feed, samples, masks))}) + return self.compile_sample(min(batchsizes), feed, samples, masks) + + def compile_sample(self, + image_count: int, + feed: Dict[Literal["a", "b"], np.ndarray], + samples: Dict[Literal["a", "b"], np.ndarray], + masks: Dict[Literal["a", "b"], np.ndarray] + ) -> Dict[Literal["a", "b"], List[np.ndarray]]: """ Compile the preview samples for display. Parameters ---------- - batch_size: int - The requested batch size for each training iterations - samples: dict, optional - Dictionary for side "a", "b" of :class:`numpy.ndarray`. The sample images that should - be used for creating the preview. If ``None`` then the samples will be generated from - the internal random image generator. Default: ``None`` - images: dict, optional - Dictionary for side "a", "b" of :class:`numpy.ndarray`. The target images that should - be used for creating the preview. If ``None`` then the targets will be generated from - the internal random image generator. Default: ``None`` - masks: dict, optional + image_count: int + The number of images to limit the sample output to. + feed: dict + Dictionary for side "a", "b" of :class:`numpy.ndarray`. The images that should be fed + into the model for obtaining a prediction + samples: dict + Dictionary for side "a", "b" of :class:`numpy.ndarray`. The 100% coverage target images + that should be used for creating the preview. + masks: dict Dictionary for side "a", "b" of :class:`numpy.ndarray`. The masks that should be used - for creating the preview. If ``None`` then the masks will be generated from the - internal random image generator. Default: ``None`` + for creating the preview. Returns ------- @@ -534,65 +543,46 @@ def compile_sample(self, batch_size, samples=None, images=None, masks=None): The list of samples, targets and masks as :class:`numpy.ndarrays` for creating a preview image """ - num_images = self._config.get("preview_images", 14) - num_images = min(batch_size, num_images) if batch_size is not None else num_images - retval = {} - for side in ("a", "b"): + num_images = min(image_count, int(self._config.get("preview_images", 14))) + retval: Dict[Literal["a", "b"], List[np.ndarray]] = {} + for side in get_args(Literal["a", "b"]): logger.debug("Compiling samples: (side: '%s', samples: %s)", side, num_images) - side_images = images[side] if images is not None else self._target[side] - side_masks = masks[side] if masks is not None else self._masks[side] - side_samples = samples[side] if samples is not None else self._samples[side] - retval[side] = [side_samples[0:num_images], - side_images[0:num_images], - side_masks[0:num_images]] + retval[side] = [feed[side][0:num_images], + samples[side][0:num_images], + masks[side][0:num_images]] + logger.debug("Compiled Samples: %s", {k: [i.shape for i in v] for k, v in retval.items()}) return retval - def compile_timelapse_sample(self): - """ Compile the sample images for creating a time-lapse frame. - - Returns - ------- - dict - For sides "a" and "b"; The list of samples, targets and masks as - :class:`numpy.ndarrays` for creating a time-lapse frame - """ - batchsizes = [] - samples = {} - images = {} - masks = {} - for side in ("a", "b"): - batch = next(self._display_feeds["timelapse"][side]) - batchsizes.append(len(batch["samples"])) - samples[side] = batch["samples"] - images[side] = batch["targets"][-1] - masks[side] = batch["masks"] - batchsize = min(batchsizes) - sample = self.compile_sample(batchsize, samples=samples, images=images, masks=masks) - return sample - - def set_timelapse_feed(self, images, batch_size): + def set_timelapse_feed(self, + images: Dict[Literal["a", "b"], List[str]], + batch_size: int) -> None: """ Set the time-lapse feed for this feeder. - Creates a generator from :class:`lib.training_data.TrainingDataGenerator` specifically + Creates a generator from :class:`lib.training_data.PreviewDataGenerator` specifically for generating time-lapse previews for the feeder. Parameters ---------- - images: list - The list of full paths to the images for creating the time-lapse for this - :class:`_Feeder` + images: dict + The list of full paths to the images for creating the time-lapse for each side batch_size: int The number of images to be used to create the time-lapse preview. """ logger.debug("Setting time-lapse feed: (input_images: '%s', batch_size: %s)", images, batch_size) - for idx, side in enumerate(("a", "b")): - self._display_feeds["timelapse"][side] = self._load_generator(idx).minibatch_ab( - images[side][:batch_size], - batch_size, - side, - do_shuffle=False, - is_timelapse=True) + + # MyPy can't recurse into nested dicts to get the type :( + iterator = cast(Dict[Literal["a", "b"], Generator[BatchType, None, None]], + self._display_feeds["timelapse"]) + + for side in get_args(Literal["a", "b"]): + imgs = images[side] + logger.debug("Setting preview feed: (side: '%s', images: %s)", side, len(imgs)) + + iterator[side] = self._load_generator(side, + True, + batch_size=batch_size, + images=imgs).minibatch_ab() logger.debug("Set time-lapse feed: %s", self._display_feeds["timelapse"]) @@ -613,25 +603,25 @@ class _Samples(): # pylint:disable=too-few-public-methods dictionary should contain 2 keys ("a" and "b") with the values being the training images for generating samples corresponding to each side. """ - def __init__(self, model, coverage_ratio): + def __init__(self, model: "ModelBase", coverage_ratio: float) -> None: logger.debug("Initializing %s: model: '%s', coverage_ratio: %s)", self.__class__.__name__, model, coverage_ratio) self._model = model self._display_mask = model.config["learn_mask"] or model.config["penalized_mask_loss"] - self.images = {} + self.images: Dict[Literal["a", "b"], List[np.ndarray]] = {} self._coverage_ratio = coverage_ratio logger.debug("Initialized %s", self.__class__.__name__) - def toggle_mask_display(self): + def toggle_mask_display(self) -> None: """ Toggle the mask overlay on or off depending on user input. """ if not (self._model.config["learn_mask"] or self._model.config["penalized_mask_loss"]): return display_mask = not self._display_mask - print("\n") # Break to not garble loss output + print("") # Break to not garble loss output logger.info("Toggling mask display %s...", "on" if display_mask else "off") self._display_mask = display_mask - def show_sample(self): + def show_sample(self) -> np.ndarray: """ Compile a preview image. Returns @@ -640,49 +630,23 @@ def show_sample(self): A compiled preview image ready for display or saving """ logger.debug("Showing sample") - feeds = {} - figures = {} - headers = {} - for idx, side in enumerate(("a", "b")): - samples = self.images[side] - faces = samples[1] + feeds: Dict[Literal["a", "b"], np.ndarray] = {} + for idx, side in enumerate(get_args(Literal["a", "b"])): input_shape = self._model.model.input_shape[idx][1:] - if input_shape[0] / faces.shape[1] != 1.0: - feeds[side] = self._resize_sample(side, faces, input_shape[0]) + if input_shape[0] / self.images[side][0].shape[1] != 1.0: + feeds[side] = self._resize_sample(side, self.images[side][1], input_shape[0]) feeds[side] = feeds[side].reshape((-1, ) + input_shape) else: - feeds[side] = faces + feeds[side] = self.images[side][0] preds = self._get_predictions(feeds["a"], feeds["b"]) - - for side, samples in self.images.items(): - other_side = "a" if side == "b" else "b" - predictions = [preds[f"{side}_{side}"], - preds[f"{other_side}_{side}"]] - display = self._to_full_frame(side, samples, predictions) - headers[side] = self._get_headers(side, display[0].shape[1]) - figures[side] = np.stack([display[0], display[1], display[2], ], axis=1) - if self.images[side][0].shape[0] % 2 == 1: - figures[side] = np.concatenate([figures[side], - np.expand_dims(figures[side][0], 0)]) - - width = 4 - side_cols = width // 2 - if side_cols != 1: - headers = self._duplicate_headers(headers, side_cols) - - header = np.concatenate([headers["a"], headers["b"]], axis=1) - figure = np.concatenate([figures["a"], figures["b"]], axis=0) - height = int(figure.shape[0] / width) - figure = figure.reshape((width, height) + figure.shape[1:]) - figure = _stack_images(figure) - figure = np.concatenate((header, figure), axis=0) - - logger.debug("Compiled sample") - return np.clip(figure * 255, 0, 255).astype('uint8') + return self._compile_preview(preds) @classmethod - def _resize_sample(cls, side, sample, target_size): + def _resize_sample(cls, + side: Literal["a", "b"], + sample: np.ndarray, + target_size: int) -> np.ndarray: """ Resize a given image to the target size. Parameters @@ -710,15 +674,15 @@ def _resize_sample(cls, side, sample, target_size): logger.debug("Resized sample: (side: '%s' shape: %s)", side, retval.shape) return retval - def _get_predictions(self, feed_a, feed_b): + def _get_predictions(self, feed_a: np.ndarray, feed_b: np.ndarray) -> Dict[str, np.ndarray]: """ Feed the samples to the model and return predictions Parameters ---------- - feed_a: list - List of :class:`numpy.ndarray` of feed images for the "a" side - feed_a: list - List of :class:`numpy.ndarray` of feed images for the "b" side + feed_a: :class:`numpy.ndarray` + Feed images for the "a" side + feed_a: :class:`numpy.ndarray` + Feed images for the "b" side Returns ------- @@ -726,7 +690,7 @@ def _get_predictions(self, feed_a, feed_b): List of :class:`numpy.ndarray` of predictions received from the model """ logger.debug("Getting Predictions") - preds = {} + preds: Dict[str, np.ndarray] = {} standard = self._model.model.predict([feed_a, feed_b], verbose=0) swapped = self._model.model.predict([feed_b, feed_a], verbose=0) @@ -751,7 +715,51 @@ def _get_predictions(self, feed_a, feed_b): logger.debug("Returning predictions: %s", {key: val.shape for key, val in preds.items()}) return preds - def _to_full_frame(self, side, samples, predictions): + def _compile_preview(self, predictions: Dict[str, np.ndarray]) -> np.ndarray: + """ Compile predictions and images into the final preview image. + + Parameters + ---------- + predictions: dict + The predictions from the model + + Returns + ------- + :class:`numpy.ndarry` + A compiled preview image ready for display or saving + """ + figures: Dict[Literal["a", "b"], np.ndarray] = {} + headers: Dict[Literal["a", "b"], np.ndarray] = {} + + for side, samples in self.images.items(): + other_side = "a" if side == "b" else "b" + preds = [predictions[f"{side}_{side}"], + predictions[f"{other_side}_{side}"]] + display = self._to_full_frame(side, samples, preds) + headers[side] = self._get_headers(side, display[0].shape[1]) + figures[side] = np.stack([display[0], display[1], display[2], ], axis=1) + if self.images[side][1].shape[0] % 2 == 1: + figures[side] = np.concatenate([figures[side], + np.expand_dims(figures[side][0], 0)]) + + width = 4 + if width // 2 != 1: + headers = self._duplicate_headers(headers, width // 2) + + header = np.concatenate([headers["a"], headers["b"]], axis=1) + figure = np.concatenate([figures["a"], figures["b"]], axis=0) + height = int(figure.shape[0] / width) + figure = figure.reshape((width, height) + figure.shape[1:]) + figure = _stack_images(figure) + figure = np.concatenate((header, figure), axis=0) + + logger.debug("Compiled sample") + return np.clip(figure * 255, 0, 255).astype('uint8') + + def _to_full_frame(self, + side: Literal["a", "b"], + samples: List[np.ndarray], + predictions: List[np.ndarray]) -> List[np.ndarray]: """ Patch targets and prediction images into images of model output size. Parameters @@ -759,7 +767,7 @@ def _to_full_frame(self, side, samples, predictions): side: {"a" or "b"} The side that these samples are for samples: list - List of :class:`numpy.ndarray` of feed images and target images + List of :class:`numpy.ndarray` of feed images and sample images predictions: list List of :class: `numpy.ndarray` of predictions from the model @@ -770,7 +778,7 @@ def _to_full_frame(self, side, samples, predictions): """ logger.debug("side: '%s', number of sample arrays: %s, prediction.shapes: %s)", side, len(samples), [pred.shape for pred in predictions]) - full, faces = samples[:2] + faces, full = samples[:2] if self._model.color_order.lower() == "rgb": # Switch color order for RGB model display full = full[..., ::-1] @@ -786,7 +794,11 @@ def _to_full_frame(self, side, samples, predictions): return images - def _process_full(self, side, images, prediction_size, color): + def _process_full(self, + side: Literal["a", "b"], + images: np.ndarray, + prediction_size: int, + color: Tuple[int, int, int]) -> np.ndarray: """ Add a frame overlay to preview images indicating the region of interest. This applies the red border that appears in the preview images. @@ -828,7 +840,7 @@ def _process_full(self, side, images, prediction_size, color): return images @classmethod - def _compile_masked(cls, faces, masks): + def _compile_masked(cls, faces: List[np.ndarray], masks: np.ndarray) -> List[np.ndarray]: """ Add the mask to the faces for masked preview. Places an opaque red layer over areas of the face that are masked out. @@ -848,6 +860,7 @@ def _compile_masked(cls, faces, masks): """ orig_masks = np.tile(1 - np.rint(masks), 3) orig_masks[np.where((orig_masks == [1., 1., 1.]).all(axis=3))] = [0., 0., 1.] + masks3: Union[List[np.ndarray], np.ndarray] = [] if faces[-1].shape[-1] == 4: # Mask contained in alpha channel of predictions pred_masks = [np.tile(1 - np.rint(face[..., -1])[..., None], 3) for face in faces[-2:]] @@ -865,15 +878,15 @@ def _compile_masked(cls, faces, masks): return retval @classmethod - def _overlay_foreground(cls, backgrounds, foregrounds): + def _overlay_foreground(cls, backgrounds: np.ndarray, foregrounds: np.ndarray) -> np.ndarray: """ Overlay the preview images into the center of the background images Parameters ---------- - backgrounds: list - List of :class:`numpy.ndarray` background images for placing the preview images onto - backgrounds: list - List of :class:`numpy.ndarray` preview images for placing onto the background images + backgrounds: :class:`numpy.ndarray` + Background images for placing the preview images onto + backgrounds: :class:`numpy.ndarray` + Preview images for placing onto the background images Returns ------- @@ -888,7 +901,7 @@ def _overlay_foreground(cls, backgrounds, foregrounds): return backgrounds @classmethod - def _get_headers(cls, side, width): + def _get_headers(cls, side: Literal["a", "b"], width: int) -> np.ndarray: """ Set header row for the final preview frame Parameters @@ -906,12 +919,11 @@ def _get_headers(cls, side, width): logger.debug("side: '%s', width: %s", side, width) titles = ("Original", "Swap") if side == "a" else ("Swap", "Original") - side = side.upper() height = int(width / 4.5) total_width = width * 3 logger.debug("height: %s, total_width: %s", height, total_width) font = cv2.FONT_HERSHEY_SIMPLEX - texts = [f"{titles[0]} ({side})", + texts = [f"{titles[0]} ({side.upper()})", f"{titles[0]} > {titles[0]}", f"{titles[0]} > {titles[1]}"] scaling = (width / 144) * 0.45 @@ -936,20 +948,22 @@ def _get_headers(cls, side, width): return header_box @classmethod - def _duplicate_headers(cls, headers, columns): + def _duplicate_headers(cls, + headers: Dict[Literal["a", "b"], np.ndarray], + columns: int) -> Dict[Literal["a", "b"], np.ndarray]: """ Duplicate headers for the number of columns displayed for each side. Parameters ---------- - headers: :class:`numpy.ndarray` - The header to be duplicated + headers: dict + The headers to be duplicated for each side columns: int The number of columns that the header needs to be duplicated for Returns ------- - :class:`numpy.ndarray` - The original headers duplicated by the number of columns + :class:dict + The original headers duplicated by the number of columns for each side """ for side, header in headers.items(): duped = tuple(header for _ in range(columns)) @@ -971,12 +985,17 @@ class _Timelapse(): # pylint:disable=too-few-public-methods The amount to scale the final preview image by. Default: `1.0` image_count: int The number of preview images to be displayed in the time-lapse - feeder: dict - The :class:`_Feeder` for generating the time-lapse images. + feeder: :class:`_Feeder` + The feeder for generating the time-lapse images. image_paths: dict The full paths to the training images for each side of the model """ - def __init__(self, model, coverage_ratio, image_count, feeder, image_paths): + def __init__(self, + model: "ModelBase", + coverage_ratio: float, + image_count: int, + feeder: _Feeder, + image_paths: Dict[Literal["a", "b"], List[str]]) -> None: logger.debug("Initializing %s: model: %s, coverage_ratio: %s, image_count: %s, " "feeder: '%s', image_paths: %s)", self.__class__.__name__, model, coverage_ratio, image_count, feeder, len(image_paths)) @@ -985,10 +1004,10 @@ def __init__(self, model, coverage_ratio, image_count, feeder, image_paths): self._model = model self._feeder = feeder self._image_paths = image_paths - self._output_file = None + self._output_file = "" logger.debug("Initialized %s", self.__class__.__name__) - def _setup(self, input_a=None, input_b=None, output=None): + def _setup(self, input_a: str, input_b: str, output: str) -> None: """ Setup the time-lapse folder locations and the time-lapse feed. Parameters @@ -1002,15 +1021,15 @@ def _setup(self, input_a=None, input_b=None, output=None): default to the model folder """ logger.debug("Setting up time-lapse") - if output is None: + if not output: output = get_folder(os.path.join(str(self._model.model_dir), f"{self._model.name}_timelapse")) - self._output_file = str(output) + self._output_file = output logger.debug("Time-lapse output set to '%s'", self._output_file) # Rewrite paths to pull from the training images so mask and face data can be accessed - images = {} - for side, input_ in zip(("a", "b"), (input_a, input_b)): + images: Dict[Literal["a", "b"], List[str]] = {} + for side, input_ in zip(get_args(Literal["a", "b"]), (input_a, input_b)): training_path = os.path.dirname(self._image_paths[side][0]) images[side] = [os.path.join(training_path, os.path.basename(pth)) for pth in get_image_paths(input_)] @@ -1021,7 +1040,7 @@ def _setup(self, input_a=None, input_b=None, output=None): self._feeder.set_timelapse_feed(images, batchsize) logger.debug("Set up time-lapse") - def output_timelapse(self, timelapse_kwargs): + def output_timelapse(self, timelapse_kwargs: Dict[str, str]) -> None: """ Generate the time-lapse samples and output the created time-lapse to the specified output folder. @@ -1036,7 +1055,7 @@ def output_timelapse(self, timelapse_kwargs): self._setup(**timelapse_kwargs) logger.debug("Getting time-lapse samples") - self._samples.images = self._feeder.compile_timelapse_sample() + self._samples.images = self._feeder.generate_preview(is_timelapse=True) logger.debug("Got time-lapse samples: %s", {side: len(images) for side, images in self._samples.images.items()}) @@ -1049,7 +1068,7 @@ def output_timelapse(self, timelapse_kwargs): logger.debug("Created time-lapse: '%s'", filename) -def _stack_images(images): +def _stack_images(images: np.ndarray) -> np.ndarray: """ Stack images evenly for preview. Parameters diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index 4bcd487e76..581c0c54db 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -1,5 +1,6 @@ tqdm>=4.64 psutil>=5.9.0 +numexpr>=2.8.3 opencv-python>=4.6.0.0 pillow>=9.2.0 scikit-learn==1.0.2; python_version < '3.9' # AMD needs version 1.0.2 and 1.1.0 not available in Python 3.7 diff --git a/scripts/extract.py b/scripts/extract.py index 91f4fa1219..6176f2488e 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -6,7 +6,7 @@ import logging import os import sys -from typing import TYPE_CHECKING, Optional +from typing import List, Dict, TYPE_CHECKING, Optional from tqdm import tqdm @@ -71,7 +71,7 @@ def __init__(self, arguments: argparse.Namespace) -> None: min_size=self._args.min_size, normalize_method=normalization, re_feed=self._args.re_feed) - self._threads = [] + self._threads: List[MultiThread] = [] self._verify_output = False logger.debug("Initialized %s", self.__class__.__name__) @@ -101,13 +101,14 @@ def _set_skip_list(self) -> None: skip_list = [] for idx, filename in enumerate(self._images.file_list): if idx % self._skip_num != 0: - logger.trace("Adding image '%s' to skip list due to extract_every_n = %s", - filename, self._skip_num) + logger.trace("Adding image '%s' to skip list due to " # type: ignore + "extract_every_n = %s", filename, self._skip_num) skip_list.append(idx) # Items may be in the alignments file if skip-existing[-faces] is selected elif os.path.basename(filename) in self._alignments.data: self._existing_count += 1 - logger.trace("Removing image: '%s' due to previously existing", filename) + logger.trace("Removing image: '%s' due to previously existing", # type: ignore + filename) skip_list.append(idx) if self._existing_count != 0: logger.info("Skipping %s frames due to skip_existing/skip_existing_faces.", @@ -142,7 +143,7 @@ def _threaded_redirector(self, task: str, io_args: Optional[tuple] = None) -> No Any arguments that need to be provided to the background function """ logger.debug("Threading task: (Task: '%s')", task) - io_args = tuple() if io_args is None else (io_args, ) + io_args = tuple() if io_args is None else io_args func = getattr(self, f"_{task}") io_thread = MultiThread(func, *io_args, thread_count=1) io_thread.start() @@ -165,7 +166,7 @@ def _load(self) -> None: load_queue.put("EOF") logger.debug("Load Images: Complete") - def _reload(self, detected_faces: dict[str, ExtractMedia]) -> None: + def _reload(self, detected_faces: Dict[str, ExtractMedia]) -> None: """ Reload the images and pair to detected face When the extraction pipeline is running in serial mode, images are reloaded from disk, @@ -183,7 +184,7 @@ def _reload(self, detected_faces: dict[str, ExtractMedia]) -> None: if load_queue.shutdown.is_set(): logger.debug("Reload Queue: Stop signal received. Terminating") break - logger.trace("Reloading image: '%s'", filename) + logger.trace("Reloading image: '%s'", filename) # type: ignore extract_media = detected_faces.pop(filename, None) if not extract_media: logger.warning("Couldn't find faces for: %s", filename) @@ -231,8 +232,8 @@ def _run_extraction(self) -> None: if not is_final: logger.debug("Reloading images") - self._threaded_redirector("reload", detected_faces) - if not self._args.skip_saving_faces: + self._threaded_redirector("reload", (detected_faces, )) + if saver is not None: saver.close() def _check_thread_error(self) -> None: @@ -263,13 +264,13 @@ def _output_processing(self, extract_media: ExtractMedia, size: int) -> None: faces_count = len(extract_media.detected_faces) if faces_count == 0: - logger.verbose("No faces were detected in image: %s", + logger.verbose("No faces were detected in image: %s", # type: ignore os.path.basename(extract_media.filename)) if not self._verify_output and faces_count > 1: self._verify_output = True - def _output_faces(self, saver: ImagesSaver, extract_media: ExtractMedia) -> None: + def _output_faces(self, saver: Optional[ImagesSaver], extract_media: ExtractMedia) -> None: """ Output faces to save thread Set the face filename based on the frame name and put the face to the @@ -278,12 +279,12 @@ def _output_faces(self, saver: ImagesSaver, extract_media: ExtractMedia) -> None Parameters ---------- - saver: lib.images.ImagesSaver - The background saver for saving the image + saver: :class:`lib.images.ImagesSaver` or ``None`` + The background saver for saving the image or ``None`` if faces are not to be saved extract_media: :class:`~plugins.extract.pipeline.ExtractMedia` The output from :class:`~plugins.extract.Pipeline.Extractor` """ - logger.trace("Outputting faces for %s", extract_media.filename) + logger.trace("Outputting faces for %s", extract_media.filename) # type: ignore final_faces = [] filename = os.path.splitext(os.path.basename(extract_media.filename))[0] extension = ".png" @@ -299,7 +300,7 @@ def _output_faces(self, saver: ImagesSaver, extract_media: ExtractMedia) -> None source_frame_dims=extract_media.image_size)) image = encode_image(face.aligned.face, extension, metadata=meta) - if not self._args.skip_saving_faces: + if saver is not None: saver.save(output_filename, image) final_faces.append(face.to_alignment()) self._alignments.data[os.path.basename(extract_media.filename)] = dict(faces=final_faces) diff --git a/setup.cfg b/setup.cfg index 1dfb9bba15..f11334206b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,6 +8,8 @@ exclude = .git, __pycache__ [mypy] [mypy-cv2.*] ignore_missing_imports = True +[mypy-fastcluster.*] +ignore_missing_imports = True [mypy-imageio.*] ignore_missing_imports = True [mypy-imageio_ffmpeg.*] @@ -16,6 +18,8 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-matplotlib.*] ignore_missing_imports = True +[mypy-numexpr.*] +ignore_missing_imports = True [mypy-pexpect.*] ignore_missing_imports = True [mypy-PIL.*] From 5ad580cc8d3794c8112612fbca95cc4bfe847266 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 21 Aug 2022 19:23:10 +0100 Subject: [PATCH 696/981] Update README.md --- .github/workflows/pytest.yml | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index cc020501e6..844f705af7 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,4 +1,4 @@ -name: ci/gh-actions/pytest +name: ci/build on: push: diff --git a/README.md b/README.md index 923cdf9782..c2b248b222 100755 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@
Jennifer Lawrence/Steve Buscemi FaceSwap using the Villain model

-[![Build Status](https://travis-ci.org/deepfakes/faceswap.svg?branch=master)](https://travis-ci.org/deepfakes/faceswap) [![Documentation Status](https://readthedocs.org/projects/faceswap/badge/?version=latest)](https://faceswap.readthedocs.io/en/latest/?badge=latest) +![Build Status](https://github.com/deepfakes/faceswap/actions/workflows/pytest.yml/badge.svg) [![Documentation Status](https://readthedocs.org/projects/faceswap/badge/?version=latest)](https://faceswap.readthedocs.io/en/latest/?badge=latest) Make sure you check out [INSTALL.md](INSTALL.md) before getting started. From e9bac5dee217a8ec73cdabc93a6ae8565059d561 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 22 Aug 2022 02:27:44 +0100 Subject: [PATCH 697/981] typofix --- docs/full/lib/training.rst | 12 ++++++------ setup.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/full/lib/training.rst b/docs/full/lib/training.rst index c23c617583..cf07768319 100644 --- a/docs/full/lib/training.rst +++ b/docs/full/lib/training.rst @@ -7,16 +7,16 @@ The training Package handles the processing of faces for feeding into a Faceswap .. contents:: Contents :local: -augmentation module -=================== +training.augmentation module +============================ .. automodule:: lib.training.augmentation :members: :undoc-members: :show-inheritance: -cache module -============ +training.cache module +===================== .. automodule:: lib.training.cache :members: @@ -24,8 +24,8 @@ cache module :show-inheritance: -generator module -================ +training.generator module +========================= .. automodule:: lib.training.generator :members: diff --git a/setup.py b/setup.py index c7b43ca822..89294df322 100755 --- a/setup.py +++ b/setup.py @@ -1083,7 +1083,7 @@ def _pywinpty_installer(self, command: List[str], package: str) -> int: logger.debug("Package: %s, returncode: %s", package, returncode) return returncode except Exception as err: # pylint:disable=broad-except - logger.debug("Failed to install with pexpect. Falling back to subprocess. Error: %s", + logger.debug("Failed to install with winpty. Falling back to subprocess. Error: %s", str(err)) return self._subproc_installer(command, package) From 66845ea5f0acb8bff080d54e3978ec41971f4168 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 22 Aug 2022 10:41:33 +0100 Subject: [PATCH 698/981] bugfix: Alignments - Thumbnail generation check --- docs/sphinx_requirements.txt | 1 + lib/align/alignments.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index 0447e5d6c5..58e47eb849 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -5,6 +5,7 @@ sphinx==5.0.2 sphinx_rtd_theme==1.0.0 tqdm==4.64 psutil==5.8.0 +numexpr>=2.8.3 numpy>=1.18.0 opencv-python>=4.5.5.0 pillow==8.3.1 diff --git a/lib/align/alignments.py b/lib/align/alignments.py index 7ea5d31906..b161020332 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -839,7 +839,7 @@ def __init__(self, alignments): def has_thumbnails(self): """ bool: ``True`` if all faces in the alignments file contain thumbnail images otherwise ``False``. """ - retval = all(face.get("thumb") + retval = all(np.any(face.get("thumb")) for frame in self._alignments_dict.values() for face in frame["faces"]) logger.trace(retval) From 0837273d4608e5ed9154c02c733085cd9896fd5b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 22 Aug 2022 21:31:51 +0100 Subject: [PATCH 699/981] bugfix: CV2 error on previews --- plugins/train/trainer/_base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index a1ae3d5526..fc0c9d2fbf 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -665,7 +665,8 @@ def _resize_sample(cls, """ scale = target_size / sample.shape[1] if scale == 1.0: - return sample + # cv2 complains if we don't do this :/ + return np.ascontiguousarray(sample) logger.debug("Resizing sample: (side: '%s', sample.shape: %s, target_size: %s, scale: %s)", side, sample.shape, target_size, scale) interpn = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA From 76dbc4c7d0d2a779ba464bcbedcf3b0550ff6207 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 23 Aug 2022 12:13:12 +0100 Subject: [PATCH 700/981] bugfix: setup.py - Windows - Don't fail + fallback install on non-english systems - Windows - Suppress duplicate waiting messages --- setup.py | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/setup.py b/setup.py index 89294df322..614d678729 100755 --- a/setup.py +++ b/setup.py @@ -1035,19 +1035,31 @@ def _pywinpty_installer(self, command: List[str], package: str) -> int: eof = False last_line_cr = False num_bytes = 1024 + seen_lines = set() while True: try: from_pty = proc.read(num_bytes) - except winpty.WinptyError as err: - if any(val in str(err) for val in ["EOF", "pipe has been ended"]): - # Get remaining bytes. On a comms error, the buffer remains unread so keep - # halving buffer amount until down to 1 when we know we have everything - if num_bytes == 1: - eof = True - from_pty = "" - num_bytes //= 2 - else: - raise + except winpty.WinptyError: + # TODO Reinsert this check + # The error message "pipe has been ended" is language specific so this check + # fails on non english systems. For now we just swallow all errors until no + # bytes are left to read and then check the return code + # if any(val in str(err) for val in ["EOF", "pipe has been ended"]): + # # Get remaining bytes. On a comms error, the buffer remains unread so keep + # # halving buffer amount until down to 1 when we know we have everything + # if num_bytes == 1: + # eof = True + # from_pty = "" + # num_bytes //= 2 + # else: + # raise + + # Get remaining bytes. On a comms error, the buffer remains unread so keep + # halving buffer amount until down to 1 when we know we have everything + if num_bytes == 1: + eof = True + from_pty = "" + num_bytes //= 2 out += from_pty if "\n" in out: lines.extend(out.split("\n")) @@ -1066,7 +1078,10 @@ def _pywinpty_installer(self, command: List[str], package: str) -> int: if not self._is_gui and not self._env.is_installer: # Go to next line print("") - logger.verbose(line) # type:ignore + if line not in seen_lines: + # Supress repeat "waiting" lines from spamming the logfile + logger.verbose(line) # type:ignore + seen_lines.add(line) elif line: last_line_cr = True logger.debug(line) From 1919366d183addd7bd93962985bc0b3bb3f88b2f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 23 Aug 2022 17:38:39 +0100 Subject: [PATCH 701/981] setup.py - cleanup installers --- setup.py | 493 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 316 insertions(+), 177 deletions(-) diff --git a/setup.py b/setup.py index 614d678729..382f7e5583 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ import sys from shutil import which from subprocess import list2cmdline, PIPE, Popen, run, STDOUT -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple, Type from pkg_resources import parse_requirements, Requirement @@ -54,8 +54,6 @@ def __init__(self, updater: bool = False) -> None: self.updater = updater # Flag that setup is being run by installer so steps can be skipped self.is_installer: bool = False - self.cuda_version: str = "" - self.cudnn_version: str = "" self.enable_amd: bool = False self.enable_apple_silicon: bool = False self.enable_docker: bool = False @@ -63,6 +61,7 @@ def __init__(self, updater: bool = False) -> None: self.required_packages: List[Tuple[str, List[Tuple[str, str]]]] = [] self.missing_packages: List[Tuple[str, List[Tuple[str, str]]]] = [] self.conda_missing_packages: List[Tuple[str, ...]] = [] + self.cuda_cudnn = ["", ""] self._process_arguments() self._check_permission() @@ -106,6 +105,16 @@ def is_admin(self) -> bool: retval = ctypes.windll.shell32.IsUserAnAdmin() != 0 # type: ignore return retval + @property + def cuda_version(self) -> str: + """ str: The detected globally installed Cuda Version """ + return self.cuda_cudnn[0] + + @property + def cudnn_version(self) -> str: + """ str: The detected globally installed cuDNN Version """ + return self.cuda_cudnn[1] + @property def is_virtualenv(self) -> bool: """ Check whether this is a virtual environment """ @@ -509,7 +518,7 @@ def _check_cuda(self) -> None: global _INSTALL_FAILED # pylint:disable=global-statement check = CudaCheck() if check.cuda_version: - self._env.cuda_version = check.cuda_version + self._env.cuda_cudnn[0] = check.cuda_version logger.info("CUDA version: %s", self._env.cuda_version) else: logger.error("CUDA not found. Install and try again.\n" @@ -520,7 +529,7 @@ def _check_cuda(self) -> None: return if check.cudnn_version: - self._env.cudnn_version = ".".join(check.cudnn_version.split(".")[:2]) + self._env.cuda_cudnn[1] = ".".join(check.cudnn_version.split(".")[:2]) logger.info("cuDNN version: %s", self._env.cudnn_version) else: logger.error("cuDNN not found. See " @@ -532,7 +541,7 @@ def _check_cuda(self) -> None: # If we get here we're on MacOS self._tips.macos() logger.warning("Cannot find CUDA on macOS") - self._env.cuda_version = input("Manually specify CUDA version: ") + self._env.cuda_cudnn[0] = input("Manually specify CUDA version: ") class CudaCheck(): # pylint:disable=too-few-public-methods @@ -692,9 +701,9 @@ def __init__(self, environment: Environment, is_gui: bool = False) -> None: self._env = environment self._is_gui = is_gui if self._env.os_version[0] == "Windows": - self._installer = self._pywinpty_installer + self._installer: Type[Installer] = WinPTYInstaller else: - self._installer = self._pexpect_installer + self._installer = PexpectInstaller if not self._env.is_installer and not self._env.updater: self._ask_continue() @@ -815,7 +824,8 @@ def _install_setup_packages(self) -> None: cmd.append(pkg_str) clean_pkg = pkg_str.replace("\"", "") - if self._subproc_installer(cmd, clean_pkg) != 0: + installer = SubProcInstaller(self._env, clean_pkg, cmd, self._is_gui) + if installer() != 0: logger.error("Unable to install package: %s. Process aborted", clean_pkg) sys.exit(1) @@ -910,7 +920,8 @@ def _from_conda(self, condaexe.append(package) clean_pkg = package.replace("\"", "") - retcode = self._installer(condaexe, clean_pkg) + installer = self._installer(self._env, clean_pkg, condaexe, self._is_gui) + retcode = installer() if retcode != 0 and not conda_only: logger.info("%s not available in Conda. Installing with pip", package) @@ -934,200 +945,331 @@ def _from_pip(self, package: str) -> None: pipexe.append("--user") pipexe.append(package) - if self._installer(pipexe, package) != 0: + installer = self._installer(self._env, package, pipexe, self._is_gui) + if installer() != 0: logger.warning("Couldn't install %s with pip. Please install this package manually", package) global _INSTALL_FAILED # pylint:disable=global-statement _INSTALL_FAILED = True - def _pexpect_installer(self, command: List[str], package: str) -> int: - """ Run an install command using pexpect and log output. - Pexpect is used so we can get unbuffered output to display updates +class Installer(): + """ Parent class for package installers. - Parameters - ---------- - command: list - The command to run - package: str - The package name that is being installed + PyWinPty is used for Windows, Pexpect is used for Linux, as these can provide us with realtime + output. + + Subprocess is used as a fallback if any of the above fail, but this caches output, so it can + look like the process has hung to the end user + + Parameters + ---------- + environment: :class:`Environment` + Environment class holding information about the running system + package: str + The package name that is being installed + command: list + The command to run + is_gui: bool + ``True if the process is being called from the Faceswap GUI + """ + def __init__(self, + environment: Environment, + package: str, + command: List[str], + is_gui: bool) -> None: + logger.info("Installing %s", package) + logger.debug("argv: %s", command) + self._env = environment + self._package = package + self._command = command + self._is_gui = is_gui + self._last_line_cr = False + self._seen_lines: Set[str] = set() + + def __call__(self) -> int: + """ Call the subclassed call function Returns ------- int - The return code from the subprocess + The return code of the package install process """ try: - import pexpect # pylint:disable=import-outside-toplevel,import-error - logger.info("Installing %s", package) - logger.debug("argv: %s", command) - - proc = pexpect.spawn(" ".join(command), - encoding=self._env.encoding, - codec_errors="replace", - timeout=None) - last_line_cr = False - while True: - try: - idx = proc.expect(["\r\n", "\r"]) - line = proc.before.rstrip() - if line and idx == 0: - if last_line_cr: - last_line_cr = False - # Output last line of progress bar and go to next line - if not self._is_gui: - print(line) - logger.verbose(line) # type:ignore - elif line and idx == 1: - last_line_cr = True - logger.debug(line) - if not self._is_gui: - print(line, end="\r") - except pexpect.EOF: - break - proc.close() - returncode = proc.exitstatus - logger.debug("Package: %s, returncode: %s", package, returncode) - return returncode + returncode = self.call() except Exception as err: # pylint:disable=broad-except - logger.debug("Failed to install with pexpect. Falling back to subprocess. Error: %s", - str(err)) - return self._subproc_installer(command, package) + logger.debug("Failed to install with %s. Falling back to subprocess. Error: %s", + self.__class__.__name__, str(err)) + returncode = SubProcInstaller(self._env, self._package, self._command, self._is_gui)() + + logger.debug("Package: %s, returncode: %s", self._package, returncode) + return returncode + + def call(self) -> int: + """ Override for package installer specific logic. - def _pywinpty_installer(self, command: List[str], package: str) -> int: - """ Run an install command using pywinpty and log output. + Returns + ------- + int + The return code of the package install process + """ + raise NotImplementedError() - pywinpty is used so we can get unbuffered output to display updates + def _non_gui_print(self, text: str, end: Optional[str] = None) -> None: + """ Print output to console if not running in the GUI Parameters ---------- - command: list - The command to run - package: str - The package name that is being installed + text: str + The text to print + end: str, optional + The line ending to use. Default: ``None`` (new line) + """ + if self._is_gui: + return + print(text, end=end) + + def _seen_line_log(self, text: str) -> None: + """ Output gets spammed to the log file when conda is waiting/processing. Only log each + unique line once. + + Parameters + ---------- + text: str + The text to log + """ + if text not in self._seen_lines: + return + logger.verbose(text) # type:ignore + self._seen_lines.add(text) + + +class PexpectInstaller(Installer): # pylint: disable=too-few-public-methods + """ Package installer for Linux/macOS using Pexpect + + Uses Pexpect for installing packages allowing access to realtime feedback + + Parameters + ---------- + environment: :class:`Environment` + Environment class holding information about the running system + package: str + The package name that is being installed + command: list + The command to run + is_gui: bool + ``True if the process is being called from the Faceswap GUI + """ + def call(self) -> int: + """ Install a package using the Pexpect module Returns ------- int - The return code from the subprocess + The return code of the package install process """ - try: - import winpty # pylint:disable=import-outside-toplevel,import-error - logger.info("Installing %s", package) - cmd = which(command[0], path=os.environ.get('PATH', os.defpath)) - # For some reason with WinPTY we need to pass in the full command. Probably a bug - cmdline = list2cmdline(command) - logger.debug("argv: %s, cmd: '%s', cmdline: '%s'", command, cmd, cmdline) - - proc = winpty.PTY( - 80 if self._env.is_installer else 100, - 24, - backend=winpty.enums.Backend.WinPTY, # ConPTY hangs and has lots of Ansi Escapes - agent_config=winpty.enums.AgentConfig.WINPTY_FLAG_PLAIN_OUTPUT) # Strip all Ansi - - if not proc.spawn(cmd, cmdline=cmdline): - del proc - raise RuntimeError("Failed to spawn winpty") - - pbar = re.compile(r"(?:eta\s[\d\W]+)|(?:\s+\|\s+\d+%)\Z") - lines = [] - out = "" - eof = False - last_line_cr = False - num_bytes = 1024 - seen_lines = set() - while True: - try: - from_pty = proc.read(num_bytes) - except winpty.WinptyError: - # TODO Reinsert this check - # The error message "pipe has been ended" is language specific so this check - # fails on non english systems. For now we just swallow all errors until no - # bytes are left to read and then check the return code - # if any(val in str(err) for val in ["EOF", "pipe has been ended"]): - # # Get remaining bytes. On a comms error, the buffer remains unread so keep - # # halving buffer amount until down to 1 when we know we have everything - # if num_bytes == 1: - # eof = True - # from_pty = "" - # num_bytes //= 2 - # else: - # raise - - # Get remaining bytes. On a comms error, the buffer remains unread so keep - # halving buffer amount until down to 1 when we know we have everything - if num_bytes == 1: - eof = True - from_pty = "" - num_bytes //= 2 - out += from_pty - if "\n" in out: - lines.extend(out.split("\n")) - if out.endswith("\n") or eof: # Ends on newline or is EOF - out = "" - else: # roll over semi-consumed line to next read - out = lines[-1] - lines = lines[:-1] - - for line in lines: # Dump the output to log - line = line.rstrip() - is_cr = bool(pbar.search(line)) - if line and not is_cr: - if last_line_cr: - last_line_cr = False - if not self._is_gui and not self._env.is_installer: - # Go to next line - print("") - if line not in seen_lines: - # Supress repeat "waiting" lines from spamming the logfile - logger.verbose(line) # type:ignore - seen_lines.add(line) - elif line: - last_line_cr = True - logger.debug(line) - if not self._is_gui: - # NSIS only updates on line endings, so force new line for installer - print(line, - end=None if self._env.is_installer else "\r") - lines = [] - if eof: - returncode = proc.get_exitstatus() - break + import pexpect # pylint:disable=import-outside-toplevel,import-error + proc = pexpect.spawn(" ".join(self._command), + encoding=self._env.encoding, codec_errors="replace", timeout=None) + while True: + try: + idx = proc.expect(["\r\n", "\r"]) + line = proc.before.rstrip() + if line and idx == 0: + if self._last_line_cr: + self._last_line_cr = False + # Output last line of progress bar and go to next line + self._non_gui_print(line) + self._seen_line_log(line) + elif line and idx == 1: + self._last_line_cr = True + logger.debug(line) + self._non_gui_print(line, end="\r") + except pexpect.EOF: + break + proc.close() + return proc.exitstatus - del proc - logger.debug("Package: %s, returncode: %s", package, returncode) - return returncode - except Exception as err: # pylint:disable=broad-except - logger.debug("Failed to install with winpty. Falling back to subprocess. Error: %s", - str(err)) - return self._subproc_installer(command, package) - def _subproc_installer(self, command: List[str], package: str) -> int: - """ Run an install command using subprocess Popen. +class WinPTYInstaller(Installer): # pylint: disable=too-few-public-methods + """ Package installer for Windows using WinPTY - pexpect uses pty which is not useable in Windows. The pexpect popen_spawn module does not - give easy access to the return code, and also dumps stdout to console so we use subprocess - for Windows. The downside of this is that we cannot do unbuffered reads, so the process can - look like it hangs. + Spawns a pseudo PTY for installing packages allowing access to realtime feedback + + Parameters + ---------- + environment: :class:`Environment` + Environment class holding information about the running system + package: str + The package name that is being installed + command: list + The command to run + is_gui: bool + ``True if the process is being called from the Faceswap GUI + """ + def __init__(self, + environment: Environment, + package: str, + command: List[str], + is_gui: bool) -> None: + super().__init__(environment, package, command, is_gui) + self._cmd = which(command[0], path=os.environ.get('PATH', os.defpath)) + self._cmdline = list2cmdline(command) + logger.debug("cmd: '%s', cmdline: '%s'", self._cmd, self._cmdline) + + self._pbar = re.compile(r"(?:eta\s[\d\W]+)|(?:\s+\|\s+\d+%)\Z") + self._eof = False + self._read_bytes = 1024 + + self._lines: List[str] = [] + self._out = "" + + def _read_from_pty(self, proc: Any, winpty_error: Any) -> None: + """ Read :attr:`_num_bytes` from WinPTY. If there is an error reading, recursively halve + the number of bytes read until we get a succesful read. If we get down to 1 byte without a + succesful read, assume we are at EOF. Parameters ---------- - command: list - The command to run - package: str - The package name that is being installed + proc: :class:`winpty.PTY` + The WinPTY process + winpty_error: :class:`winpty.WinptyError` + The winpty error exception. Passed in as WinPTY is not in global scope + """ + try: + from_pty = proc.read(self._read_bytes) + except winpty_error: + # TODO Reinsert this check + # The error message "pipe has been ended" is language specific so this check + # fails on non english systems. For now we just swallow all errors until no + # bytes are left to read and then check the return code + # if any(val in str(err) for val in ["EOF", "pipe has been ended"]): + # # Get remaining bytes. On a comms error, the buffer remains unread so keep + # # halving buffer amount until down to 1 when we know we have everything + # if self._read_bytes == 1: + # self._eof = True + # from_pty = "" + # self._read_bytes //= 2 + # else: + # raise + + # Get remaining bytes. On a comms error, the buffer remains unread so keep + # halving buffer amount until down to 1 when we know we have everything + if self._read_bytes == 1: + self._eof = True + from_pty = "" + self._read_bytes //= 2 + + self._out += from_pty + + def _out_to_lines(self) -> None: + """ Process the winpty output into separate lines. Roll over any semi-consumed lines to the + next proc call. """ + if "\n" not in self._out: + return + + self._lines.extend(self._out.split("\n")) + + if self._out.endswith("\n") or self._eof: # Ends on newline or is EOF + self._out = "" + else: # roll over semi-consumed line to next read + self._out = self._lines[-1] + self._lines = self._lines[:-1] + + def _parse_lines(self) -> None: + """ Process the latest batch of lines that have been received from winPTY. """ + for line in self._lines: # Dump the output to log + line = line.rstrip() + is_cr = bool(self._pbar.search(line)) + if line and not is_cr: + if self._last_line_cr: + self._last_line_cr = False + if not self._env.is_installer: + # Go to next line + self._non_gui_print("") + self._seen_line_log(line) + elif line: + self._last_line_cr = True + logger.debug(line) + # NSIS only updates on line endings, so force new line for installer + self._non_gui_print(line, end=None if self._env.is_installer else "\r") + self._lines = [] + + def call(self) -> int: + """ Install a package using the PyWinPTY module Returns ------- int - The return code from the subprocess + The return code of the package install process """ - logger.info("Installing %s", package) - shell = self._env.os_version[0] == "Windows" and command[0] == "conda" - logger.debug("argv: %s", command) + import winpty # pylint:disable=import-outside-toplevel,import-error + # For some reason with WinPTY we need to pass in the full command. Probably a bug + proc = winpty.PTY( + 80 if self._env.is_installer else 100, + 24, + backend=winpty.enums.Backend.WinPTY, # ConPTY hangs and has lots of Ansi Escapes + agent_config=winpty.enums.AgentConfig.WINPTY_FLAG_PLAIN_OUTPUT) # Strip all Ansi + + if not proc.spawn(self._cmd, cmdline=self._cmdline): + del proc + raise RuntimeError("Failed to spawn winpty") + + while True: + self._read_from_pty(proc, winpty.WinptyError) + self._out_to_lines() + self._parse_lines() + + if self._eof: + returncode = proc.get_exitstatus() + break + + del proc + logger.debug("Package: %s, returncode: %s", self._package, returncode) + return returncode + + +class SubProcInstaller(Installer): + """ The fallback package installer if either of the OS specific installers fail. + + Uses the python Subprocess module to install packages. Feedback does not return in realtime + so the process can look like it has hung to the end user + + Parameters + ---------- + environment: :class:`Environment` + Environment class holding information about the running system + package: str + The package name that is being installed + command: list + The command to run + is_gui: bool + ``True if the process is being called from the Faceswap GUI + """ + def __init__(self, + environment: Environment, + package: str, + command: List[str], + is_gui: bool) -> None: + super().__init__(environment, package, command, is_gui) + self._shell = self._env.os_version[0] == "Windows" and command[0] == "conda" + + def __call__(self) -> int: + """ Override default call function so we don't recursively call ourselves on failure. """ + returncode = self.call() + logger.debug("Package: %s, returncode: %s", self._package, returncode) + return returncode - with Popen(command, bufsize=0, stdout=PIPE, stderr=STDOUT, shell=shell) as proc: - last_line_cr = False + def call(self) -> int: + """ Install a package using the Subprocess module + + Returns + ------- + int + The return code of the package install process + """ + with Popen(self._command, + bufsize=0, stdout=PIPE, stderr=STDOUT, shell=self._shell) as proc: while True: if proc.stdout is not None: line = proc.stdout.readline().decode(self._env.encoding, errors="replace") @@ -1139,18 +1281,15 @@ def _subproc_installer(self, command: List[str], package: str) -> int: line = line.rstrip() if line and not is_cr: - if last_line_cr: - last_line_cr = False + if self._last_line_cr: + self._last_line_cr = False # Go to next line - if not self._is_gui: - print("") - logger.verbose(line) # type:ignore + self._non_gui_print("") + self._seen_line_log(line) elif line: - last_line_cr = True + self._last_line_cr = True logger.debug(line) - if not self._is_gui: - print(line, end="\r") - logger.debug("Package: %s, returncode: %s", package, returncode) + self._non_gui_print("", end="\r") return returncode From b2887715776af505d7b6b58d5c739528c986c647 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 23 Aug 2022 18:19:51 +0100 Subject: [PATCH 702/981] typofix: setup.py --- setup.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 382f7e5583..9c1f46d22d 100755 --- a/setup.py +++ b/setup.py @@ -1038,7 +1038,7 @@ def _seen_line_log(self, text: str) -> None: text: str The text to log """ - if text not in self._seen_lines: + if text in self._seen_lines: return logger.verbose(text) # type:ignore self._seen_lines.add(text) @@ -1225,7 +1225,6 @@ def call(self) -> int: break del proc - logger.debug("Package: %s, returncode: %s", self._package, returncode) return returncode From 9e503bdaa2bfe2baaea50ad2e4bf742f309d9d10 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 23 Aug 2022 19:19:16 +0100 Subject: [PATCH 703/981] bugfix: debug landmarks --- lib/align/aligned_face.py | 2 +- scripts/fsmedia.py | 44 +++++++++++++++++++++++++++------------ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index d993cd129a..21ca147c2b 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -644,7 +644,7 @@ def get_cropped_roi(self, if centering not in self._cache.cropped_roi: center = get_adjusted_center(image_size, self.pose.offset[self._source_centering], - self.pose.offset[self.centering], + self.pose.offset[centering], self._source_centering) padding = target_size // 2 roi = np.array([center - padding, center + padding]).ravel() diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index e721f47fd5..2f28739651 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -14,7 +14,7 @@ import numpy as np import imageio -from lib.align import Alignments as AlignmentsBase +from lib.align import Alignments as AlignmentsBase, get_centered_size from lib.face_filter import FaceFilter as FilterFunc from lib.image import count_frames, read_image from lib.utils import (camel_case_split, get_image_paths, _video_extensions) @@ -98,7 +98,7 @@ def _set_folder_filename(self, input_is_video): elif input_is_video: logger.debug("Alignments from Video File: '%s'", self._args.input_dir) folder, filename = os.path.split(self._args.input_dir) - filename = "{}_alignments".format(os.path.splitext(filename)[0]) + filename = f"{os.path.splitext(filename)[0]}_alignments" else: logger.debug("Alignments from Input Folder: '%s'", self._args.input_dir) folder = str(self._args.input_dir) @@ -119,7 +119,7 @@ def _load(self): Any alignments that have already been extracted if skip existing has been selected otherwise an empty dictionary """ - data = dict() + data = {} if not self._is_extract: if not self.have_alignments_file: return data @@ -280,7 +280,7 @@ def _load_video_frames(self): for i, frame in enumerate(reader): # Convert to BGR for cv2 compatibility frame = frame[:, :, ::-1] - filename = "{}_{:06d}.png".format(vidname, i + 1) + filename = f"{vidname}_{i + 1:06d}.png" logger.trace("Loading video frame: '%s'", filename) yield filename, frame reader.close() @@ -358,13 +358,13 @@ def _set_actions(self): The list of :class:`PostProcessAction` to be performed """ postprocess_items = self._get_items() - actions = list() + actions = [] for action, options in postprocess_items.items(): - options = dict() if options is None else options + options = {} if options is None else options args = options.get("args", tuple()) - kwargs = options.get("kwargs", dict()) + kwargs = options.get("kwargs", {}) args = args if isinstance(args, tuple) else tuple() - kwargs = kwargs if isinstance(kwargs, dict) else dict() + kwargs = kwargs if isinstance(kwargs, dict) else {} task = globals()[action](*args, **kwargs) if task.valid: logger.debug("Adding Postprocess action: '%s'", task) @@ -388,7 +388,7 @@ def _get_items(self): The name of the action to be performed as the key. Any action specific arguments and keyword arguments as the value. """ - postprocess_items = dict() + postprocess_items = {} # Debug Landmarks if (hasattr(self._args, 'debug_landmarks') and self._args.debug_landmarks): postprocess_items["DebugLandmarks"] = None @@ -410,7 +410,7 @@ def _get_items(self): face_filter = dict(detector=detector, aligner=aligner, multiprocess=not self._args.singleprocess) - filter_lists = dict() + filter_lists = {} if hasattr(self._args, "ref_threshold"): face_filter["ref_threshold"] = self._args.ref_threshold for filter_type in ('filter', 'nfilter'): @@ -481,6 +481,10 @@ def process(self, extract_media): class DebugLandmarks(PostProcessAction): # pylint: disable=too-few-public-methods """ Draw debug landmarks on face output. Extract Only """ + def __init__(self, *args, **kwargs): + super().__init__(self, *args, **kwargs) + self._face_size = 0 + self._legacy_size = 0 def process(self, extract_media): """ Draw landmarks on a face. @@ -499,6 +503,17 @@ def process(self, extract_media): """ frame = os.path.splitext(os.path.basename(extract_media.filename))[0] for idx, face in enumerate(extract_media.detected_faces): + if not self._face_size: + self._face_size = get_centered_size(face.aligned.centering, + "face", + face.aligned.size) + logger.debug("set face size: %s", self._face_size) + if not self._legacy_size: + self._legacy_size = get_centered_size(face.aligned.centering, + "legacy", + face.aligned.size) + logger.debug("set legacy size: %s", self._legacy_size) + logger.trace("Drawing Landmarks. Frame: '%s'. Face: %s", frame, idx) # Landmarks for (pos_x, pos_y) in face.aligned.landmarks.astype("int32"): @@ -510,8 +525,11 @@ def process(self, extract_media): cv2.line(face.aligned.face, center, tuple(points[0]), (255, 0, 0), 1) cv2.line(face.aligned.face, center, tuple(points[2]), (0, 0, 255), 1) # Face centering - roi = face.aligned.get_cropped_roi("face") + roi = face.aligned.get_cropped_roi(face.aligned.size, self._face_size, "face") cv2.rectangle(face.aligned.face, tuple(roi[:2]), tuple(roi[2:]), (0, 255, 0), 1) + # Legacy centering + roi = face.aligned.get_cropped_roi(face.aligned.size, self._legacy_size, "legacy") + cv2.rectangle(face.aligned.face, tuple(roi[:2]), tuple(roi[2:]), (0, 0, 255), 1) class FaceFilter(PostProcessAction): @@ -599,7 +617,7 @@ def _set_face_filter(f_type, f_args): The confirmed existing paths to filter files to use """ if not f_args: - return list() + return [] logger.info("%s: %s", f_type.title(), f_args) filter_files = f_args if isinstance(f_args, list) else [f_args] @@ -627,7 +645,7 @@ def process(self, extract_media): """ if not self._filter: return - ret_faces = list() + ret_faces = [] for idx, detect_face in enumerate(extract_media.detected_faces): check_item = detect_face["face"] if isinstance(detect_face, dict) else detect_face if not self._filter.check(extract_media.image, check_item): From 326110f09d45dbdce2e490fa1ae4b1208e5efe2c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 25 Aug 2022 12:38:05 +0100 Subject: [PATCH 704/981] bugfix - timelapse image loader multithreading.py - typing + docs --- docs/full/lib/multithreading.rst | 7 + lib/multithreading.py | 227 ++++++++++++++++++++++++------- lib/training/generator.py | 2 +- plugins/train/trainer/_base.py | 2 +- 4 files changed, 188 insertions(+), 50 deletions(-) create mode 100644 docs/full/lib/multithreading.rst diff --git a/docs/full/lib/multithreading.rst b/docs/full/lib/multithreading.rst new file mode 100644 index 0000000000..e786abe83a --- /dev/null +++ b/docs/full/lib/multithreading.rst @@ -0,0 +1,7 @@ +multithreading module +===================== + +.. automodule:: lib.multithreading + :members: + :undoc-members: + :show-inheritance: diff --git a/lib/multithreading.py b/lib/multithreading.py index 58f72ebf4a..e4ca376b7c 100644 --- a/lib/multithreading.py +++ b/lib/multithreading.py @@ -7,8 +7,13 @@ import queue as Queue import sys import threading +from types import TracebackType +from typing import Any, Callable, Dict, Generator, List, Tuple, Type, Optional, Set, Union logger = logging.getLogger(__name__) # pylint: disable=invalid-name +_ErrorType = Optional[Union[Tuple[Type[BaseException], BaseException, TracebackType], + Tuple[Any, Any, Any]]] +_THREAD_NAMES: Set[str] = set() def total_cpus(): @@ -16,22 +21,76 @@ def total_cpus(): return cpu_count() +def _get_name(name: str) -> str: + """ Obtain a unique name for a thread + + Parameters + ---------- + name: str + The requested name + + Returns + ------- + str + The request name with "_#" appended (# being an integer) making the name unique + """ + idx = 0 + real_name = name + while True: + if real_name in _THREAD_NAMES: + real_name = f"{name}_{idx}" + idx += 1 + continue + _THREAD_NAMES.add(real_name) + return real_name + + class FSThread(threading.Thread): - """ Subclass of thread that passes errors back to parent """ - def __init__(self, group=None, target=None, name=None, # pylint: disable=too-many-arguments - args=(), kwargs=None, *, daemon=None): - super().__init__(group=group, target=target, name=name, - args=args, kwargs=kwargs, daemon=daemon) - self.err = None - - def check_and_raise_error(self): - """ Checks for errors in thread and raises them in caller """ + """ Subclass of thread that passes errors back to parent + + Parameters + ---------- + target: callable object, Optional + The callable object to be invoked by the run() method. If ``None`` nothing is called. + Default: ``None`` + name: str, optional + The thread name. if ``None`` a unique name is constructed of the form "Thread-N" where N + is a small decimal number. Default: ``None`` + args: tuple + The argument tuple for the target invocation. Default: (). + kwargs: dict + keyword arguments for the target invocation. Default: {}. + """ + _target: Callable + _args: Tuple + _kwargs: Dict[str, Any] + _name: str + + def __init__(self, + target: Optional[Callable] = None, + name: Optional[str] = None, + args: Tuple = (), + kwargs: Dict[str, Any] = None, + *, + daemon: Optional[bool] = None) -> None: + super().__init__(target=target, name=name, args=args, kwargs=kwargs, daemon=daemon) + self.err: _ErrorType = None + + def check_and_raise_error(self) -> None: + """ Checks for errors in thread and raises them in caller. + + Raises + ------ + Error + Re-raised error from within the thread + """ if not self.err: return logger.debug("Thread error caught: %s", self.err) raise self.err[1].with_traceback(self.err[2]) - def run(self): + def run(self) -> None: + """ Runs the target, reraising any errors from within the thread in the caller. """ try: if self._target: self._target(*self._args, **self._kwargs) @@ -45,53 +104,85 @@ def run(self): class MultiThread(): - """ Threading for IO heavy ops - Catches errors in thread and rethrows to parent """ - def __init__(self, target, *args, thread_count=1, name=None, **kwargs): - self._name = name if name else target.__name__ + """ Threading for IO heavy ops. Catches errors in thread and rethrows to parent. + + Parameters + ---------- + target: callable object + The callable object to be invoked by the run() method. + args: tuple + The argument tuple for the target invocation. Default: (). + thread_count: int, optional + The number of threads to use. Default: 1 + name: str, optional + The thread name. if ``None`` a unique name is constructed of the form {target.__name__}_N + where N is an incrementing integer. Default: ``None`` + kwargs: dict + keyword arguments for the target invocation. Default: {}. + """ + def __init__(self, + target: Callable, + *args, + thread_count: int = 1, + name: Optional[str] = None, + **kwargs) -> None: + self._name = _get_name(name if name else target.__name__) logger.debug("Initializing %s: (target: '%s', thread_count: %s)", self.__class__.__name__, self._name, thread_count) - logger.trace("args: %s, kwargs: %s", args, kwargs) + logger.trace("args: %s, kwargs: %s", args, kwargs) # type:ignore self.daemon = True self._thread_count = thread_count - self._threads = list() + self._threads: List[FSThread] = [] self._target = target self._args = args self._kwargs = kwargs logger.debug("Initialized %s: '%s'", self.__class__.__name__, self._name) @property - def has_error(self): - """ Return true if a thread has errored, otherwise false """ + def has_error(self) -> bool: + """ bool: ``True`` if a thread has errored, otherwise ``False`` """ return any(thread.err for thread in self._threads) @property - def errors(self): - """ Return a list of thread errors """ + def errors(self) -> List[_ErrorType]: + """ list: List of thread error values """ return [thread.err for thread in self._threads if thread.err] @property - def name(self): - """ Return thread name """ + def name(self) -> str: + """ :str: The name of the thread """ return self._name - def check_and_raise_error(self): - """ Checks for errors in thread and raises them in caller """ + def check_and_raise_error(self) -> None: + """ Checks for errors in thread and raises them in caller. + + Raises + ------ + Error + Re-raised error from within the thread + """ if not self.has_error: return logger.debug("Thread error caught: %s", self.errors) error = self.errors[0] + assert error is not None raise error[1].with_traceback(error[2]) - def is_alive(self): - """ Return true if any thread is alive else false """ + def is_alive(self) -> bool: + """ Check if any threads are still alive + + Returns + ------- + bool + ``True`` if any threads are alive. ``False`` if no threads are alive + """ return any(thread.is_alive() for thread in self._threads) - def start(self): - """ Start a thread with the given method and args """ + def start(self) -> None: + """ Start all the threads for the given method, args and kwargs """ logger.debug("Starting thread(s): '%s'", self._name) for idx in range(self._thread_count): - name = "{}_{}".format(self._name, idx) + name = self._name if self._thread_count == 1 else f"{self._name}_{idx}" logger.debug("Starting thread %s of %s: '%s'", idx + 1, self._thread_count, name) thread = FSThread(name=name, @@ -103,13 +194,18 @@ def start(self): self._threads.append(thread) logger.debug("Started all threads '%s': %s", self._name, len(self._threads)) - def completed(self): - """ Return False if there are any alive threads else True """ + def completed(self) -> bool: + """ Check if all threads have completed + + Returns + ------- + ``True`` if all threads have completed otherwise ``False`` + """ retval = all(not thread.is_alive() for thread in self._threads) logger.debug(retval) return retval - def join(self): + def join(self) -> None: """ Join the running threads, catching and re-raising any errors """ logger.debug("Joining Threads: '%s'", self._name) for thread in self._threads: @@ -123,24 +219,53 @@ def join(self): class BackgroundGenerator(MultiThread): - """ Run a queue in the background. From: - https://stackoverflow.com/questions/7323664/ """ - # See below why prefetch count is flawed - def __init__(self, generator, prefetch=1, thread_count=2, - queue=None, args=None, kwargs=None): - # pylint:disable=too-many-arguments - super().__init__(target=self._run, thread_count=thread_count) - self.queue = queue or Queue.Queue(prefetch) + """ Run a task in the background background and queue data for consumption + + Parameters + ---------- + generator: iterable + The generator to run in the background + prefetch, int, optional + The number of items to pre-fetch from the generator before blocking (see Notes). Default: 1 + name: str, optional + The thread name. if ``None`` a unique name is constructed of the form + {generator.__name__}_N where N is an incrementing integer. Default: ``None`` + args: tuple, Optional + The argument tuple for generator invocation. Default: ``None``. + kwargs: dict, Optional + keyword arguments for the generator invocation. Default: ``None``. + + Notes + ----- + Putting to the internal queue only blocks if put is called while queue has already + reached max size. Therefore this means prefetch is actually 1 more than the parameter + supplied (N in the queue, one waiting for insertion) + + References + ---------- + https://stackoverflow.com/questions/7323664/ + """ + def __init__(self, + generator: Callable, + prefetch: int = 1, + name: Optional[str] = None, + args: Optional[Tuple] = None, + kwargs: Optional[Dict[str, Any]] = None) -> None: + super().__init__(name=name, target=self._run) + self.queue: Queue.Queue = Queue.Queue(prefetch) self.generator = generator self._gen_args = args or tuple() - self._gen_kwargs = kwargs or dict() + self._gen_kwargs = kwargs or {} self.start() - def _run(self): - """ Put until queue size is reached. - Note: put blocks only if put is called while queue has already - reached max size => this makes prefetch + thread_count prefetched items! - N in the the queue, one waiting for insertion per thread! """ + def _run(self) -> None: + """ Run the :attr:`_generator` and put into the queue until until queue size is reached. + + Raises + ------ + Exception + If there is a failure to run the generator and put to the queue + """ try: for item in self.generator(*self._gen_args, **self._gen_kwargs): self.queue.put(item) @@ -149,8 +274,14 @@ def _run(self): self.queue.put(None) raise - def iterator(self): - """ Iterate items out of the queue """ + def iterator(self) -> Generator: + """ Iterate items out of the queue + + Yields + ------ + Any + The items from the generator + """ while True: next_item = self.queue.get() self.check_and_raise_error() diff --git a/lib/training/generator.py b/lib/training/generator.py index ad0ae0ecb8..b688714e92 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -132,7 +132,7 @@ def minibatch_ab(self, do_shuffle: bool = True) -> Generator[BatchType, None, No """ logger.debug("do_shuffle: %s", do_shuffle) args = (do_shuffle, ) - batcher = BackgroundGenerator(self._minibatch, thread_count=1, args=args) + batcher = BackgroundGenerator(self._minibatch, args=args) return batcher.iterator() # << INTERNAL METHODS >> # diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index fc0c9d2fbf..26ceed7348 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -582,7 +582,7 @@ def set_timelapse_feed(self, iterator[side] = self._load_generator(side, True, batch_size=batch_size, - images=imgs).minibatch_ab() + images=imgs).minibatch_ab(do_shuffle=False) logger.debug("Set time-lapse feed: %s", self._display_feeds["timelapse"]) From 9bd86eb8109a4e12c85807ccae040f19b4c1f3db Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 26 Aug 2022 09:19:52 +0100 Subject: [PATCH 705/981] setup - Exit with errorcode if any packages failed --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 9c1f46d22d..fcee94baf4 100755 --- a/setup.py +++ b/setup.py @@ -727,6 +727,7 @@ def __init__(self, environment: Environment, is_gui: bool = False) -> None: logger.error("Some packages failed to install. This may be a temporary error which " "might be fixed by re-running this script. Otherwise please install " "these packages manually.") + sys.exit(1) @classmethod def _ask_continue(cls) -> None: From 1022651eb8a7741014f5d2ec7cbfe882120dfa5f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 26 Aug 2022 23:56:03 +0100 Subject: [PATCH 706/981] Bugfix: convert - Gif Writer - Fix non-launch error on Gif Writer - convert plugins - linting - convert/fs_media/preview/queue_manager - typing - Change convert items from dict to Dataclass --- lib/convert.py | 229 ++++++++----- lib/queue_manager.py | 134 +++++--- plugins/convert/color/avg_color.py | 3 +- plugins/convert/color/manual_balance.py | 4 +- plugins/convert/color/seamless_clone.py | 3 +- plugins/convert/writer/_base.py | 2 +- plugins/convert/writer/ffmpeg.py | 33 +- plugins/convert/writer/gif.py | 30 +- plugins/convert/writer/opencv.py | 8 +- plugins/extract/pipeline.py | 84 +++-- scripts/convert.py | 412 +++++++++++++---------- scripts/fsmedia.py | 131 ++++---- tools/preview/preview.py | 417 ++++++++++++++---------- 13 files changed, 894 insertions(+), 596 deletions(-) diff --git a/lib/convert.py b/lib/convert.py index 09cee357c4..885e4df1d7 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -2,15 +2,56 @@ """ Converter for Faceswap """ import logging +import sys +from dataclasses import dataclass +from typing import Callable, cast, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 import numpy as np from plugins.plugin_loader import PluginLoader +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + +if TYPE_CHECKING: + from argparse import Namespace + from lib.align.aligned_face import AlignedFace, CenteringType + from lib.align.detected_face import DetectedFace + from lib.config import FaceswapConfig + from lib.queue_manager import EventQueue + from scripts.convert import ConvertItem + from plugins.convert.color._base import Adjustment as ColorAdjust + from plugins.convert.color.seamless_clone import Color as SeamlessAdjust + from plugins.convert.mask.mask_blend import Mask as MaskAdjust + from plugins.convert.scaling._base import Adjustment as ScalingAdjust + logger = logging.getLogger(__name__) # pylint: disable=invalid-name +@dataclass +class Adjustments: + """ Dataclass to hold the optional processing plugins + + Parameters + ---------- + color: :class:`~plugins.color._base.Adjustment`, Optional + The selected color processing plugin. Default: `None` + mask: :class:`~plugins.mask_blend.Mask`, Optional + The selected mask processing plugin. Default: `None` + seamless: :class:`~plugins.color.seamless_clone.Color`, Optional + The selected mask processing plugin. Default: `None` + sharpening: :class:`~plugins.scaling._base.Adjustment`, Optional + The selected mask processing plugin. Default: `None` + """ + color: Optional["ColorAdjust"] = None + mask: Optional["MaskAdjust"] = None + seamless: Optional["SeamlessAdjust"] = None + sharpening: Optional["ScalingAdjust"] = None + + class Converter(): """ The converter is responsible for swapping the original face(s) in a frame with the output of a trained Faceswap model. @@ -37,8 +78,14 @@ class Converter(): Optional location of custom configuration ``ini`` file. If ``None`` then use the default config location. Default: ``None`` """ - def __init__(self, output_size, coverage_ratio, centering, draw_transparent, pre_encode, - arguments, configfile=None): + def __init__(self, + output_size: int, + coverage_ratio: float, + centering: "CenteringType", + draw_transparent: bool, + pre_encode: Optional[Callable[[np.ndarray], List[bytes]]], + arguments: "Namespace", + configfile: Optional[str] = None) -> None: logger.debug("Initializing %s: (output_size: %s, coverage_ratio: %s, centering: %s, " "draw_transparent: %s, pre_encode: %s, arguments: %s, configfile: %s)", self.__class__.__name__, output_size, coverage_ratio, centering, @@ -52,18 +99,18 @@ def __init__(self, output_size, coverage_ratio, centering, draw_transparent, pre self._configfile = configfile self._scale = arguments.output_scale / 100 - self._adjustments = dict(mask=None, color=None, seamless=None, sharpening=None) + self._adjustments = Adjustments() self._load_plugins() logger.debug("Initialized %s", self.__class__.__name__) @property - def cli_arguments(self): + def cli_arguments(self) -> "Namespace": """:class:`argparse.Namespace`: The command line arguments passed to the convert process """ return self._args - def reinitialize(self, config): + def reinitialize(self, config: "FaceswapConfig") -> None: """ Reinitialize this :class:`Converter`. Called as part of the :mod:`~tools.preview` tool. Resets all adjustments then loads the @@ -75,11 +122,13 @@ def reinitialize(self, config): Pre-loaded :class:`lib.config.FaceswapConfig`. used over any configuration on disk. """ logger.debug("Reinitializing converter") - self._adjustments = dict(mask=None, color=None, seamless=None, sharpening=None) + self._adjustments = Adjustments() self._load_plugins(config=config, disable_logging=True) logger.debug("Reinitialized converter") - def _load_plugins(self, config=None, disable_logging=False): + def _load_plugins(self, + config: Optional["FaceswapConfig"] = None, + disable_logging: bool = False) -> None: """ Load the requested adjustment plugins. Loads the :mod:`plugins.converter` plugins that have been requested for this conversion @@ -95,30 +144,32 @@ def _load_plugins(self, config=None, disable_logging=False): suppress these messages otherwise ``False``. Default: ``False`` """ logger.debug("Loading plugins. config: %s", config) - self._adjustments["mask"] = PluginLoader.get_converter( - "mask", - "mask_blend", - disable_logging=disable_logging)(self._args.mask_type, - self._output_size, - self._coverage_ratio, - configfile=self._configfile, - config=config) + self._adjustments.mask = PluginLoader.get_converter("mask", + "mask_blend", + disable_logging=disable_logging)( + self._args.mask_type, + self._output_size, + self._coverage_ratio, + configfile=self._configfile, + config=config) if self._args.color_adjustment != "none" and self._args.color_adjustment is not None: - self._adjustments["color"] = PluginLoader.get_converter( - "color", - self._args.color_adjustment, - disable_logging=disable_logging)(configfile=self._configfile, config=config) - - sharpening = PluginLoader.get_converter( - "scaling", - "sharpen", - disable_logging=disable_logging)(configfile=self._configfile, config=config) - if sharpening.config.get("method", None) is not None: - self._adjustments["sharpening"] = sharpening + self._adjustments.color = PluginLoader.get_converter("color", + self._args.color_adjustment, + disable_logging=disable_logging)( + configfile=self._configfile, + config=config) + + sharpening = PluginLoader.get_converter("scaling", + "sharpen", + disable_logging=disable_logging)( + configfile=self._configfile, + config=config) + if sharpening.config.get("method") is not None: + self._adjustments.sharpening = sharpening logger.debug("Loaded plugins: %s", self._adjustments) - def process(self, in_queue, out_queue): + def process(self, in_queue: "EventQueue", out_queue: "EventQueue"): """ Main convert process. Takes items from the in queue, runs the relevant adjustments, patches faces to final frame @@ -126,10 +177,10 @@ def process(self, in_queue, out_queue): Parameters ---------- - in_queue: :class:`queue.Queue` + in_queue: :class:`~lib.queue_manager.EventQueue` The output from :class:`scripts.convert.Predictor`. Contains detected faces from the Faceswap model as well as the frame to be patched. - out_queue: :class:`queue.Queue` + out_queue: :class:`~lib.queue_manager.EventQueue` The queue to place patched frames into for writing by one of Faceswap's :mod:`plugins.convert.writer` plugins. """ @@ -137,45 +188,44 @@ def process(self, in_queue, out_queue): in_queue, out_queue) log_once = False while True: - items = in_queue.get() - if items == "EOF": + inbound: Union[Literal["EOF"], "ConvertItem", List["ConvertItem"]] = in_queue.get() + if inbound == "EOF": logger.debug("EOF Received") logger.debug("Patch queue finished") # Signal EOF to other processes in pool logger.debug("Putting EOF back to in_queue") - in_queue.put(items) + in_queue.put(inbound) break - if isinstance(items, dict): - items = [items] + items = inbound if isinstance(inbound, list) else [inbound] for item in items: - logger.trace("Patch queue got: '%s'", item["filename"]) + logger.trace("Patch queue got: '%s'", item.inbound.filename) # type: ignore try: image = self._patch_image(item) except Exception as err: # pylint: disable=broad-except # Log error and output original frame logger.error("Failed to convert image: '%s'. Reason: %s", - item["filename"], str(err)) - image = item["image"] + item.inbound.filename, str(err)) + image = item.inbound.image - loglevel = logger.trace if log_once else logger.warning + loglevel = logger.trace if log_once else logger.warning # type: ignore loglevel("Convert error traceback:", exc_info=True) log_once = True # UNCOMMENT THIS CODE BLOCK TO PRINT TRACEBACK ERRORS # import sys; import traceback # exc_info = sys.exc_info(); traceback.print_exception(*exc_info) - logger.trace("Out queue put: %s", item["filename"]) - out_queue.put((item["filename"], image)) + logger.trace("Out queue put: %s", item.inbound.filename) # type: ignore + out_queue.put((item.inbound.filename, image)) logger.debug("Completed convert process") - def _patch_image(self, predicted): + def _patch_image(self, predicted: "ConvertItem") -> Union[np.ndarray, List[bytes]]: """ Patch a swapped face onto a frame. Run selected adjustments and swap the faces in a frame. Parameters ---------- - predicted: dict + predicted: :class:`~scripts.convert.ConvertItem` The output from :class:`scripts.convert.Predictor`. Returns @@ -186,8 +236,8 @@ def _patch_image(self, predicted): function (if it has one) """ - logger.trace("Patching image: '%s'", predicted["filename"]) - frame_size = (predicted["image"].shape[1], predicted["image"].shape[0]) + logger.trace("Patching image: '%s'", predicted.inbound.filename) # type: ignore + frame_size = (predicted.inbound.image.shape[1], predicted.inbound.image.shape[0]) new_image, background = self._get_new_image(predicted, frame_size) patched_face = self._post_warp_adjustments(background, new_image) patched_face = self._scale_image(patched_face) @@ -195,12 +245,16 @@ def _patch_image(self, predicted): patched_face = np.rint(patched_face, out=np.empty(patched_face.shape, dtype="uint8"), casting='unsafe') - if self._writer_pre_encode is not None: - patched_face = self._writer_pre_encode(patched_face) - logger.trace("Patched image: '%s'", predicted["filename"]) - return patched_face + if self._writer_pre_encode is None: + retval: Union[np.ndarray, List[bytes]] = patched_face + else: + retval = self._writer_pre_encode(patched_face) + logger.trace("Patched image: '%s'", predicted.inbound.filename) # type: ignore + return retval - def _get_new_image(self, predicted, frame_size): + def _get_new_image(self, + predicted: "ConvertItem", + frame_size: Tuple[int, int]) -> Tuple[np.ndarray, np.ndarray]: """ Get the new face from the predictor and apply pre-warp manipulations. Applies any requested adjustments to the raw output of the Faceswap model @@ -208,7 +262,7 @@ def _get_new_image(self, predicted, frame_size): Parameters ---------- - predicted: dict + predicted: :class:`~scripts.convert.ConvertItem` The output from :class:`scripts.convert.Predictor`. frame_size: tuple The (`width`, `height`) of the final frame in pixels @@ -220,16 +274,16 @@ def _get_new_image(self, predicted, frame_size): background: :class: `numpy.ndarray` The original frame """ - logger.trace("Getting: (filename: '%s', faces: %s)", - predicted["filename"], len(predicted["swapped_faces"])) + logger.trace("Getting: (filename: '%s', faces: %s)", # type: ignore + predicted.inbound.filename, len(predicted.swapped_faces)) placeholder = np.zeros((frame_size[1], frame_size[0], 4), dtype="float32") - background = predicted["image"] / np.array(255.0, dtype="float32") + background = predicted.inbound.image / np.array(255.0, dtype="float32") placeholder[:, :, :3] = background - for new_face, detected_face, reference_face in zip(predicted["swapped_faces"], - predicted["detected_faces"], - predicted["reference_faces"]): + for new_face, detected_face, reference_face in zip(predicted.swapped_faces, + predicted.inbound.detected_faces, + predicted.reference_faces): predicted_mask = new_face[:, :, -1] if new_face.shape[2] == 4 else None new_face = new_face[:, :, :3] interpolator = reference_face.interpolators[1] @@ -247,12 +301,16 @@ def _get_new_image(self, predicted, frame_size): flags=cv2.WARP_INVERSE_MAP | interpolator, borderMode=cv2.BORDER_TRANSPARENT) - logger.trace("Got filename: '%s'. (placeholders: %s)", - predicted["filename"], placeholder.shape) + logger.trace("Got filename: '%s'. (placeholders: %s)", # type: ignore + predicted.inbound.filename, placeholder.shape) return placeholder, background - def _pre_warp_adjustments(self, new_face, detected_face, reference_face, predicted_mask): + def _pre_warp_adjustments(self, + new_face: np.ndarray, + detected_face: "DetectedFace", + reference_face: "AlignedFace", + predicted_mask: Optional[np.ndarray]) -> np.ndarray: """ Run any requested adjustments that can be performed on the raw output from the Faceswap model. @@ -277,21 +335,25 @@ def _pre_warp_adjustments(self, new_face, detected_face, reference_face, predict The face output from the Faceswap Model with any requested pre-warp adjustments performed. """ - logger.trace("new_face shape: %s, predicted_mask shape: %s", new_face.shape, - predicted_mask.shape if predicted_mask is not None else None) - old_face = reference_face.face[..., :3] / 255.0 + logger.trace("new_face shape: %s, predicted_mask shape: %s", # type: ignore + new_face.shape, predicted_mask.shape if predicted_mask is not None else None) + old_face = cast(np.ndarray, reference_face.face)[..., :3] / 255.0 new_face, raw_mask = self._get_image_mask(new_face, detected_face, predicted_mask, reference_face) - if self._adjustments["color"] is not None: - new_face = self._adjustments["color"].run(old_face, new_face, raw_mask) - if self._adjustments["seamless"] is not None: - new_face = self._adjustments["seamless"].run(old_face, new_face, raw_mask) - logger.trace("returning: new_face shape %s", new_face.shape) + if self._adjustments.color is not None: + new_face = self._adjustments.color.run(old_face, new_face, raw_mask) + if self._adjustments.seamless is not None: + new_face = self._adjustments.seamless.run(old_face, new_face, raw_mask) + logger.trace("returning: new_face shape %s", new_face.shape) # type: ignore return new_face - def _get_image_mask(self, new_face, detected_face, predicted_mask, reference_face): + def _get_image_mask(self, + new_face: np.ndarray, + detected_face: "DetectedFace", + predicted_mask: Optional[np.ndarray], + reference_face: "AlignedFace") -> Tuple[np.ndarray, np.ndarray]: """ Return any selected image mask Places the requested mask into the new face's Alpha channel. @@ -312,23 +374,26 @@ def _get_image_mask(self, new_face, detected_face, predicted_mask, reference_fac ------- :class:`numpy.ndarray` The swapped face with the requested mask added to the Alpha channel + :class:`numpy.ndarray` + The raw mask with no erosion or blurring applied """ - logger.trace("Getting mask. Image shape: %s", new_face.shape) + logger.trace("Getting mask. Image shape: %s", new_face.shape) # type: ignore if self._args.mask_type not in ("none", "predicted"): mask_centering = detected_face.mask[self._args.mask_type].stored_centering else: mask_centering = "face" # Unused but requires a valid value - mask, raw_mask = self._adjustments["mask"].run(detected_face, - reference_face.pose.offset[mask_centering], - reference_face.pose.offset[self._centering], - self._centering, - predicted_mask=predicted_mask) - logger.trace("Adding mask to alpha channel") + assert self._adjustments.mask is not None + mask, raw_mask = self._adjustments.mask.run(detected_face, + reference_face.pose.offset[mask_centering], + reference_face.pose.offset[self._centering], + self._centering, + predicted_mask=predicted_mask) + logger.trace("Adding mask to alpha channel") # type: ignore new_face = np.concatenate((new_face, mask), -1) - logger.trace("Got mask. Image shape: %s", new_face.shape) + logger.trace("Got mask. Image shape: %s", new_face.shape) # type: ignore return new_face, raw_mask - def _post_warp_adjustments(self, background, new_image): + def _post_warp_adjustments(self, background: np.ndarray, new_image: np.ndarray) -> np.ndarray: """ Perform any requested adjustments to the swapped faces after they have been transformed into the final frame. @@ -344,8 +409,8 @@ def _post_warp_adjustments(self, background, new_image): :class:`numpy.ndarray` The final merged and swapped frame with any requested post-warp adjustments applied """ - if self._adjustments["sharpening"] is not None: - new_image = self._adjustments["sharpening"].run(new_image) + if self._adjustments.sharpening is not None: + new_image = self._adjustments.sharpening.run(new_image) if self._draw_transparent: frame = new_image @@ -360,7 +425,7 @@ def _post_warp_adjustments(self, background, new_image): np.clip(frame, 0.0, 1.0, out=frame) return frame - def _scale_image(self, frame): + def _scale_image(self, frame: np.ndarray) -> np.ndarray: """ Scale the final image if requested. If output scale has been requested in command line arguments, scale the output @@ -378,11 +443,11 @@ def _scale_image(self, frame): """ if self._scale == 1: return frame - logger.trace("source frame: %s", frame.shape) + logger.trace("source frame: %s", frame.shape) # type: ignore interp = cv2.INTER_CUBIC if self._scale > 1 else cv2.INTER_AREA dims = (round((frame.shape[1] / 2 * self._scale) * 2), round((frame.shape[0] / 2 * self._scale) * 2)) frame = cv2.resize(frame, dims, interpolation=interp) - logger.trace("resized frame: %s", frame.shape) + logger.trace("resized frame: %s", frame.shape) # type: ignore np.clip(frame, 0.0, 1.0, out=frame) return frame diff --git a/lib/queue_manager.py b/lib/queue_manager.py index f9fb83e63b..d34dd5fa6a 100644 --- a/lib/queue_manager.py +++ b/lib/queue_manager.py @@ -6,6 +6,7 @@ import logging import threading +from typing import Dict from queue import Queue, Empty as QueueEmpty # pylint: disable=unused-import; # noqa from time import sleep @@ -13,22 +14,42 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name -class QueueManager(): - """ Manage queues for availabilty across processes - Don't import this class directly, instead - import the variable: queue_manager """ - def __init__(self): +class EventQueue(Queue): + """ Standard Queue object with a separate global shutdown parameter indicating that the main + process, and by extension this queue, should be shut down. + + Parameters + ---------- + shutdown_event: :class:`threading.Event` + The global shutdown event common to all managed queues + maxsize: int, Optional + Upperbound limit on the number of items that can be placed in the queue. Default: `0` + """ + def __init__(self, shutdown_event: threading.Event, maxsize: int = 0) -> None: + super().__init__(maxsize=maxsize) + self._shutdown = shutdown_event + + @property + def shutdown(self) -> threading.Event: + """ :class:`threading.Event`: The global shutdown event """ + return self._shutdown + + +class _QueueManager(): + """ Manage :class:`EventQueue` objects for availabilty across processes. + + Notes + ----- + Don't import this class directly, instead import via :func:`queue_manager` """ + def __init__(self) -> None: logger.debug("Initializing %s", self.__class__.__name__) self.shutdown = threading.Event() - self.queues = dict() + self.queues: Dict[str, EventQueue] = {} logger.debug("Initialized %s", self.__class__.__name__) - def add_queue(self, name, maxsize=0, create_new=False): - """ Add a queue to the manager. - - Adds an event "shutdown" to the queue that can be used to indicate to a process that any - activity on the queue should cease. + def add_queue(self, name: str, maxsize: int = 0, create_new: bool = False) -> str: + """ Add a :class:`EventQueue` to the manager. Parameters ---------- @@ -50,77 +71,108 @@ def add_queue(self, name, maxsize=0, create_new=False): logger.debug("QueueManager adding: (name: '%s', maxsize: %s, create_new: %s)", name, maxsize, create_new) if not create_new and name in self.queues: - raise ValueError("Queue '{}' already exists.".format(name)) + raise ValueError(f"Queue '{name}' already exists.") if create_new and name in self.queues: i = 0 while name in self.queues: name = f"{name}{i}" logger.debug("Duplicate queue name. Updated to: '%s'", name) - queue = Queue(maxsize=maxsize) - - setattr(queue, "shutdown", self.shutdown) - self.queues[name] = queue + self.queues[name] = EventQueue(self.shutdown, maxsize=maxsize) logger.debug("QueueManager added: (name: '%s')", name) return name - def del_queue(self, name): - """ remove a queue from the manager """ + def del_queue(self, name: str) -> None: + """ Remove a queue from the manager + + Parameters + ---------- + name: str + The name of the queue to be deleted. Must exist within the queue manager. + """ logger.debug("QueueManager deleting: '%s'", name) del self.queues[name] logger.debug("QueueManager deleted: '%s'", name) - def get_queue(self, name, maxsize=0): - """ Return a queue from the manager - If it doesn't exist, create it """ + def get_queue(self, name: str, maxsize: int = 0) -> EventQueue: + """ Return a :class:`EventQueue` from the manager. If it doesn't exist, create it. + + Parameters + ---------- + name: str + The name of the queue to obtain + maxsize: int, Optional + The maximum queue size. Set to `0` for unlimited. Only used if the requested queue + does not already exist. Default: `0` + """ logger.debug("QueueManager getting: '%s'", name) - queue = self.queues.get(name, None) + queue = self.queues.get(name) if not queue: self.add_queue(name, maxsize) queue = self.queues[name] logger.debug("QueueManager got: '%s'", name) return queue - def terminate_queues(self): - """ Set shutdown event, clear and send EOF to all queues - To be called if there is an error """ + def terminate_queues(self) -> None: + """ Terminates all managed queues. + + Sets the global shutdown event, clears and send EOF to all queues. To be called if there + is an error """ logger.debug("QueueManager terminating all queues") self.shutdown.set() - self.flush_queues() + self._flush_queues() for q_name, queue in self.queues.items(): logger.debug("QueueManager terminating: '%s'", q_name) queue.put("EOF") logger.debug("QueueManager terminated all queues") - def flush_queues(self): - """ Empty out all queues """ + def _flush_queues(self): + """ Empty out the contents of every managed queue. """ for q_name in self.queues: self.flush_queue(q_name) logger.debug("QueueManager flushed all queues") - def flush_queue(self, q_name): - """ Empty out a specific queue """ - logger.debug("QueueManager flushing: '%s'", q_name) - queue = self.queues[q_name] + def flush_queue(self, name: str) -> None: + """ Flush the contents from a managed queue. + + Parameters + ---------- + name: str + The name of the managed :class:`EventQueue` to flush + """ + logger.debug("QueueManager flushing: '%s'", name) + queue = self.queues[name] while not queue.empty(): queue.get(True, 1) - def debug_monitor(self, update_secs=2): - """ Debug tool for monitoring queues """ - thread = threading.Thread(target=self.debug_queue_sizes, - args=(update_secs, )) + def debug_monitor(self, update_interval: int = 2) -> None: + """ A debug tool for monitoring managed :class:`EventQueues`. + + Prints queue sizes to the console for all managed queues. + + Parameters + ---------- + update_interval: int, Optional + The number of seconds between printing information to the console. Default: 2 + """ + thread = threading.Thread(target=self._debug_queue_sizes, + args=(update_interval, )) thread.daemon = True thread.start() - def debug_queue_sizes(self, update_secs): - """ Output the queue sizes - logged to INFO so it also displays in console + def _debug_queue_sizes(self, update_interval) -> None: + """ Print the queue size for each managed queue to console. + + Parameters + ---------- + update_interval: int + The number of seconds between printing information to the console """ while True: logger.info("====================================================") for name in sorted(self.queues.keys()): logger.info("%s: %s", name, self.queues[name].qsize()) - sleep(update_secs) + sleep(update_interval) -queue_manager = QueueManager() # pylint: disable=invalid-name +queue_manager = _QueueManager() # pylint: disable=invalid-name diff --git a/plugins/convert/color/avg_color.py b/plugins/convert/color/avg_color.py index 4483a3104b..89d0bac361 100644 --- a/plugins/convert/color/avg_color.py +++ b/plugins/convert/color/avg_color.py @@ -8,8 +8,7 @@ class Color(Adjustment): """ Adjust the mean of the color channels to be the same for the swap and old frame """ - @staticmethod - def process(old_face, new_face, raw_mask): + def process(self, old_face, new_face, raw_mask): for _ in [0, 1]: diff = old_face - new_face avg_diff = np.sum(diff * raw_mask, axis=(0, 1)) diff --git a/plugins/convert/color/manual_balance.py b/plugins/convert/color/manual_balance.py index 7acb30c95c..dfd0ceb199 100644 --- a/plugins/convert/color/manual_balance.py +++ b/plugins/convert/color/manual_balance.py @@ -43,7 +43,7 @@ def convert_colorspace(self, new_face, to_bgr=False): """ Convert colorspace based on mode or back to bgr """ mode = self.config["colorspace"].lower() colorspace = "YCrCb" if mode == "ycrcb" else mode.upper() - conversion = "{}2BGR".format(colorspace) if to_bgr else "BGR2{}".format(colorspace) + conversion = f"{colorspace}2BGR" if to_bgr else f"BGR2{colorspace}" image = cv2.cvtColor(new_face.astype("uint8"), # pylint: disable=no-member - getattr(cv2, "COLOR_{}".format(conversion))).astype("float32") / 255.0 + getattr(cv2, f"COLOR_{conversion}")).astype("float32") / 255.0 return image diff --git a/plugins/convert/color/seamless_clone.py b/plugins/convert/color/seamless_clone.py index ccbc3bd19c..09e8bc73de 100644 --- a/plugins/convert/color/seamless_clone.py +++ b/plugins/convert/color/seamless_clone.py @@ -16,8 +16,7 @@ class Color(Adjustment): and does not have a natural home, so here for now. """ - @staticmethod - def process(old_face, new_face, raw_mask): + def process(self, old_face, new_face, raw_mask): height, width, _ = old_face.shape height = height // 2 width = width // 2 diff --git a/plugins/convert/writer/_base.py b/plugins/convert/writer/_base.py index 5e26f3b9d1..c68fae9399 100644 --- a/plugins/convert/writer/_base.py +++ b/plugins/convert/writer/_base.py @@ -137,7 +137,7 @@ def write(self, filename: str, image: Any) -> None: """ raise NotImplementedError - def pre_encode(self, image: np.ndarray) -> Any: # pylint: disable=unused-argument,no-self-use + def pre_encode(self, image: np.ndarray) -> Any: # pylint: disable=unused-argument """ Some writer plugins support the pre-encoding of images prior to saving out. As patching is done in multiple threads, but writing is done in a single thread, it can speed up the process to do any pre-encoding as part of the converter process. diff --git a/plugins/convert/writer/ffmpeg.py b/plugins/convert/writer/ffmpeg.py index ca1f33bf02..36fbdd3e5f 100644 --- a/plugins/convert/writer/ffmpeg.py +++ b/plugins/convert/writer/ffmpeg.py @@ -3,7 +3,7 @@ import os from math import ceil from subprocess import CalledProcessError, check_output, STDOUT -from typing import Optional, List, Tuple, Generator +from typing import cast, Generator, List, Optional, Tuple import imageio import imageio_ffmpeg as im_ffm @@ -32,7 +32,7 @@ class Writer(Output): def __init__(self, output_folder: str, total_count: int, - frame_ranges: Optional[List[Tuple[int]]], + frame_ranges: Optional[List[Tuple[int, int]]], source_video: str, **kwargs) -> None: super().__init__(output_folder, **kwargs) @@ -40,7 +40,7 @@ def __init__(self, total_count, frame_ranges, source_video) self._source_video: str = source_video self._output_filename: str = self._get_output_filename() - self._frame_ranges: Optional[List[Tuple[int]]] = frame_ranges + self._frame_ranges: Optional[List[Tuple[int, int]]] = frame_ranges self.frame_order: List[int] = self._set_frame_order(total_count) self._output_dimensions: Optional[str] = None # Fix dims on 1st received frame # Need to know dimensions of first frame, so set writer then @@ -90,7 +90,7 @@ def _audio_codec(self) -> Optional[str]: """ str or ``None``: The audio codec to use. This will either be ``"copy"`` (the default) or ``None`` if skip muxing has been selected in configuration options, or if frame ranges have been passed in the command line arguments. """ - retval = "copy" + retval: Optional[str] = "copy" if self.config["skip_mux"]: logger.info("Skipping audio muxing due to configuration settings.") retval = None @@ -128,9 +128,9 @@ def _test_for_audio_stream(self) -> bool: try: out = check_output(cmd, stderr=STDOUT) except CalledProcessError as err: - out = err.output.decode(errors="ignore") - raise ValueError("Error checking audio stream. Status: " - f"{err.returncode}\n{out}") from err + err_out = err.output.decode(errors="ignore") + msg = f"Error checking audio stream. Status: {err.returncode}\n{err_out}" + raise ValueError(msg) from err retval = False for line in out.splitlines(): @@ -191,7 +191,7 @@ def _set_frame_order(self, total_count: int) -> List[int]: logger.debug("frame_order: %s", retval) return retval - def _get_writer(self, frame_dims: Tuple[int]) -> Generator[None, np.ndarray, None]: + def _get_writer(self, frame_dims: Tuple[int, int]) -> Generator[None, np.ndarray, None]: """ Add the requested encoding options and return the writer. Parameters @@ -235,15 +235,16 @@ def write(self, filename: str, image: np.ndarray) -> None: image: :class:`numpy.ndarray` The converted image to be written """ - logger.trace("Received frame: (filename: '%s', shape: %s", filename, image.shape) + logger.trace("Received frame: (filename: '%s', shape: %s", # type: ignore + filename, image.shape) if not self._output_dimensions: - input_dims = image.shape[:2] + input_dims = cast(Tuple[int, int], image.shape[:2]) self._set_dimensions(input_dims) self._writer = self._get_writer(input_dims) self.cache_frame(filename, image) self._save_from_cache() - def _set_dimensions(self, frame_dims: Tuple[int]) -> None: + def _set_dimensions(self, frame_dims: Tuple[int, int]) -> None: """ Set the attribute :attr:`_output_dimensions` based on the first frame received. This protects against different sized images coming in and ensures all images are written to ffmpeg at the same size. Dimensions are mapped to a macro block size 8. @@ -261,16 +262,18 @@ def _set_dimensions(self, frame_dims: Tuple[int]) -> None: def _save_from_cache(self) -> None: """ Writes any consecutive frames to the video container that are ready to be output from the cache. """ + assert self._writer is not None while self.frame_order: if self.frame_order[0] not in self.cache: - logger.trace("Next frame not ready. Continuing") + logger.trace("Next frame not ready. Continuing") # type: ignore break save_no = self.frame_order.pop(0) save_image = self.cache.pop(save_no) - logger.trace("Rendering from cache. Frame no: %s", save_no) + logger.trace("Rendering from cache. Frame no: %s", save_no) # type: ignore self._writer.send(np.ascontiguousarray(save_image[:, :, ::-1])) - logger.trace("Current cache size: %s", len(self.cache)) + logger.trace("Current cache size: %s", len(self.cache)) # type: ignore def close(self) -> None: """ Close the ffmpeg writer and mux the audio """ - self._writer.close() + if self._writer is not None: + self._writer.close() diff --git a/plugins/convert/writer/gif.py b/plugins/convert/writer/gif.py index fa9bfe0c52..f31090faf2 100644 --- a/plugins/convert/writer/gif.py +++ b/plugins/convert/writer/gif.py @@ -1,13 +1,16 @@ #!/usr/bin/env python3 """ Animated GIF writer for faceswap.py converter """ import os -from typing import Optional, List, Tuple +from typing import Optional, List, Tuple, TYPE_CHECKING import cv2 import imageio from ._base import Output, logger +if TYPE_CHECKING: + from imageio.plugins.pillowmulti import GIFFormat + class Writer(Output): """ GIF output writer using imageio. @@ -28,12 +31,12 @@ class Writer(Output): def __init__(self, output_folder: str, total_count: int, - frame_ranges: Optional[List[Tuple[int]]], + frame_ranges: Optional[List[Tuple[int, int]]], **kwargs) -> None: logger.debug("total_count: %s, frame_ranges: %s", total_count, frame_ranges) super().__init__(output_folder, **kwargs) self.frame_order: List[int] = self._set_frame_order(total_count, frame_ranges) - self._output_dimensions: Optional[str] = None # Fix dims on 1st received frame + self._output_dimensions: Optional[Tuple[int, int]] = None # Fix dims on 1st received frame # Need to know dimensions of first frame, so set writer then self._writer: Optional[imageio.plugins.pillowmulti.GIFFormat.Writer] = None self._gif_file: Optional[str] = None # Set filename based on first file seen @@ -46,7 +49,8 @@ def _gif_params(self) -> dict: return kwargs @staticmethod - def _set_frame_order(total_count: int, frame_ranges: Optional[List[Tuple[int]]]) -> List[int]: + def _set_frame_order(total_count: int, + frame_ranges: Optional[List[Tuple[int, int]]]) -> List[int]: """ Obtain the full list of frames to be converted in order. Parameters @@ -71,7 +75,7 @@ def _set_frame_order(total_count: int, frame_ranges: Optional[List[Tuple[int]]]) logger.debug("frame_order: %s", retval) return retval - def _get_writer(self) -> imageio.plugins.pillowmulti.GIFFormat.Writer: + def _get_writer(self) -> "GIFFormat.Writer": """ Obtain the GIF writer with the requested GIF encoding options. Returns @@ -80,7 +84,6 @@ def _get_writer(self) -> imageio.plugins.pillowmulti.GIFFormat.Writer: The imageio GIF writer """ logger.debug("writer config: %s", self.config) - return imageio.get_writer(self._gif_file, mode="i", **self._gif_params) @@ -96,7 +99,8 @@ def write(self, filename: str, image) -> None: image: :class:`numpy.ndarray` The converted image to be written """ - logger.trace("Received frame: (filename: '%s', shape: %s", filename, image.shape) + logger.trace("Received frame: (filename: '%s', shape: %s", # type: ignore + filename, image.shape) if not self._gif_file: self._set_gif_filename(filename) self._set_dimensions(image.shape[:2]) @@ -140,7 +144,7 @@ def _set_gif_filename(self, filename: str) -> None: self._gif_file = retval logger.info("Outputting to: '%s'", self._gif_file) - def _set_dimensions(self, frame_dims: str) -> None: + def _set_dimensions(self, frame_dims: Tuple[int, int]) -> None: """ Set the attribute :attr:`_output_dimensions` based on the first frame received. This protects against different sized images coming in and ensure all images get written to the Gif at the sema dimensions. """ @@ -151,16 +155,18 @@ def _set_dimensions(self, frame_dims: str) -> None: def _save_from_cache(self) -> None: """ Writes any consecutive frames to the GIF container that are ready to be output from the cache. """ + assert self._writer is not None while self.frame_order: if self.frame_order[0] not in self.cache: - logger.trace("Next frame not ready. Continuing") + logger.trace("Next frame not ready. Continuing") # type: ignore break save_no = self.frame_order.pop(0) save_image = self.cache.pop(save_no) - logger.trace("Rendering from cache. Frame no: %s", save_no) + logger.trace("Rendering from cache. Frame no: %s", save_no) # type: ignore self._writer.append_data(save_image[:, :, ::-1]) - logger.trace("Current cache size: %s", len(self.cache)) + logger.trace("Current cache size: %s", len(self.cache)) # type: ignore def close(self) -> None: """ Close the GIF writer on completion. """ - self._writer.close() + if self._writer is not None: + self._writer.close() diff --git a/plugins/convert/writer/opencv.py b/plugins/convert/writer/opencv.py index e179fe6804..2f3b91ec9e 100644 --- a/plugins/convert/writer/opencv.py +++ b/plugins/convert/writer/opencv.py @@ -48,10 +48,10 @@ def _get_save_args(self) -> Tuple[int, ...]: filetype = self.config["format"] args: Tuple[int, ...] = tuple() if filetype == "jpg" and self.config["jpg_quality"] > 0: - args = (cv2.IMWRITE_JPEG_QUALITY, # pylint: disable=no-member + args = (cv2.IMWRITE_JPEG_QUALITY, self.config["jpg_quality"]) if filetype == "png" and self.config["png_compress_level"] > -1: - args = (cv2.IMWRITE_PNG_COMPRESSION, # pylint: disable=no-member + args = (cv2.IMWRITE_PNG_COMPRESSION, self.config["png_compress_level"]) logger.debug(args) return args @@ -99,11 +99,11 @@ def pre_encode(self, image: np.ndarray) -> List[bytes]: mask = image[..., -1] image = image[..., :3] - retval.append(cv2.imencode(self._extension, # pylint: disable=no-member + retval.append(cv2.imencode(self._extension, mask, self._args)[1]) - retval.insert(0, cv2.imencode(self._extension, # pylint: disable=no-member + retval.insert(0, cv2.imencode(self._extension, image, self._args)[1]) return retval diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 59a99566fb..5bdb774571 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -11,6 +11,8 @@ """ import logging +import sys +from typing import cast, List, Optional, Tuple, TYPE_CHECKING import cv2 @@ -19,6 +21,15 @@ from lib.utils import get_backend from plugins.plugin_loader import PluginLoader +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + +if TYPE_CHECKING: + import numpy as np + from lib.align.detected_face import DetectedFace + logger = logging.getLogger(__name__) # pylint:disable=invalid-name _INSTANCES = -1 # Tracking for multiple instances of pipeline @@ -577,15 +588,15 @@ def _set_extractor_batchsize(self): if get_backend() != "nvidia": logger.debug("Backend is not Nvidia. Not updating batchsize requirements") return - if sum([plugin.vram for plugin in self._active_plugins]) == 0: + if sum(plugin.vram for plugin in self._active_plugins) == 0: logger.debug("No plugins use VRAM. Not updating batchsize requirements.") return - batch_required = sum([plugin.vram_per_batch * plugin.batchsize - for plugin in self._active_plugins]) + batch_required = sum(plugin.vram_per_batch * plugin.batchsize + for plugin in self._active_plugins) gpu_plugins = [p for p in self._current_phase if self._vram_per_phase[p] > 0] scaling = self._parallel_scaling.get(len(gpu_plugins), self._scaling_fallback) - plugins_required = sum([self._vram_per_phase[p] for p in gpu_plugins]) * scaling + plugins_required = sum(self._vram_per_phase[p] for p in gpu_plugins) * scaling if plugins_required + batch_required <= self._vram_stats["vram_free"]: logger.debug("Plugin requirements within threshold: (plugins_required: %sMB, " "vram_free: %sMB)", plugins_required, self._vram_stats["vram_free"]) @@ -674,44 +685,50 @@ class ExtractMedia(): The original frame detected_faces: list, optional A list of :class:`~lib.align.DetectedFace` objects. Detected faces can be added - later with :func:`add_detected_faces`. Default: ``None`` + later with :func:`add_detected_faces`. Setting ``None`` will default to an empty list. + Default: ``None`` """ - def __init__(self, filename, image, detected_faces=None): - logger.trace("Initializing %s: (filename: '%s', image shape: %s, detected_faces: %s)", - self.__class__.__name__, filename, image.shape, detected_faces) + def __init__(self, + filename: str, + image: "np.ndarray", + detected_faces: Optional[List["DetectedFace"]] = None) -> None: + logger.trace("Initializing %s: (filename: '%s', image shape: %s, " # type: ignore + "detected_faces: %s)", self.__class__.__name__, filename, image.shape, + detected_faces) self._filename = filename - self._image = image - self._image_shape = image.shape - self._detected_faces = detected_faces + self._image: Optional["np.ndarray"] = image + self._image_shape = cast(Tuple[int, int, int], image.shape) + self._detected_faces: List["DetectedFace"] = ([] if detected_faces is None + else detected_faces) @property - def filename(self): + def filename(self) -> str: """ str: The base name of the :attr:`image` filename. """ return self._filename @property - def image(self): + def image(self) -> "np.ndarray": """ :class:`numpy.ndarray`: The source frame for this object. """ + assert self._image is not None return self._image @property - def image_shape(self): + def image_shape(self) -> Tuple[int, int, int]: """ tuple: The shape of the stored :attr:`image`. """ return self._image_shape @property - def image_size(self): + def image_size(self) -> Tuple[int, int]: """ tuple: The (`height`, `width`) of the stored :attr:`image`. """ return self._image_shape[:2] @property - def detected_faces(self): - """list: A list of :class:`~lib.align.DetectedFace` objects in the - :attr:`image`. """ + def detected_faces(self) -> List["DetectedFace"]: + """list: A list of :class:`~lib.align.DetectedFace` objects in the :attr:`image`. """ return self._detected_faces - def get_image_copy(self, color_format): + def get_image_copy(self, color_format: Literal["BGR", "RGB", "GRAY"]) -> "np.ndarray": """ Get a copy of the image in the requested color format. Parameters @@ -724,11 +741,12 @@ def get_image_copy(self, color_format): :class:`numpy.ndarray`: A copy of :attr:`image` in the requested :attr:`color_format` """ - logger.trace("Requested color format '%s' for frame '%s'", color_format, self._filename) + logger.trace("Requested color format '%s' for frame '%s'", # type: ignore + color_format, self._filename) image = getattr(self, f"_image_as_{color_format.lower()}")() return image - def add_detected_faces(self, faces): + def add_detected_faces(self, faces: List["DetectedFace"]) -> None: """ Add detected faces to the object. Called at the end of each extraction phase. Parameters @@ -736,21 +754,21 @@ def add_detected_faces(self, faces): faces: list A list of :class:`~lib.align.DetectedFace` objects """ - logger.trace("Adding detected faces for filename: '%s'. (faces: %s, lrtb: %s)", - self._filename, faces, + logger.trace("Adding detected faces for filename: '%s'. " # type: ignore + "(faces: %s, lrtb: %s)", self._filename, faces, [(face.left, face.right, face.top, face.bottom) for face in faces]) self._detected_faces = faces - def remove_image(self): + def remove_image(self) -> None: """ Delete the image and reset :attr:`image` to ``None``. Required for multi-phase extraction to avoid the frames stacking RAM. """ - logger.trace("Removing image for filename: '%s'", self._filename) + logger.trace("Removing image for filename: '%s'", self._filename) # type: ignore del self._image self._image = None - def set_image(self, image): + def set_image(self, image: "np.ndarray") -> None: """ Add the image back into :attr:`image` Required for multi-phase extraction adds the image back to this object. @@ -760,33 +778,33 @@ def set_image(self, image): image: :class:`numpy.ndarry` The original frame to be re-applied to for this :attr:`filename` """ - logger.trace("Reapplying image: (filename: `%s`, image shape: %s)", + logger.trace("Reapplying image: (filename: `%s`, image shape: %s)", # type: ignore self._filename, image.shape) self._image = image - def _image_as_bgr(self): + def _image_as_bgr(self) -> "np.ndarray": """ Get a copy of the source frame in BGR format. Returns ------- :class:`numpy.ndarray`: A copy of :attr:`image` in BGR color format """ - return self._image[..., :3].copy() + return self.image[..., :3].copy() - def _image_as_rgb(self): + def _image_as_rgb(self) -> "np.ndarray": """ Get a copy of the source frame in RGB format. Returns ------- :class:`numpy.ndarray`: A copy of :attr:`image` in RGB color format """ - return self._image[..., 2::-1].copy() + return self.image[..., 2::-1].copy() - def _image_as_gray(self): + def _image_as_gray(self) -> "np.ndarray": """ Get a copy of the source frame in gray-scale format. Returns ------- :class:`numpy.ndarray`: A copy of :attr:`image` in gray-scale color format """ - return cv2.cvtColor(self._image.copy(), cv2.COLOR_BGR2GRAY) + return cv2.cvtColor(self.image.copy(), cv2.COLOR_BGR2GRAY) diff --git a/scripts/convert.py b/scripts/convert.py index 51404e9cbc..6fbf7acadd 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -1,12 +1,14 @@ #!/usr/bin python3 """ Main entry point to the convert process of FaceSwap """ +from dataclasses import dataclass, field import logging import re import os import sys from threading import Event from time import sleep +from typing import Callable, cast, Dict, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 import numpy as np @@ -24,9 +26,46 @@ from plugins.extract.pipeline import Extractor, ExtractMedia from plugins.plugin_loader import PluginLoader +if sys.version_info < (3, 8): + from typing_extensions import get_args, Literal +else: + from typing import get_args, Literal + +if TYPE_CHECKING: + from argparse import Namespace + from plugins.convert.writer._base import Output + from plugins.train.model._base import ModelBase + from lib.align.aligned_face import CenteringType + from lib.queue_manager import EventQueue + + logger = logging.getLogger(__name__) # pylint: disable=invalid-name +@dataclass +class ConvertItem: + """ A single frame with associated objects passing through the convert process. + + Parameters + ---------- + input: :class:`~plugins.extract.pipeline.ExtractMedia` + The ExtractMedia object holding the :attr:`filename`, :attr:`image` and attr:`list` of + :class:`~lib.align.DetectedFace` objects loaded from disk + feed_faces: list, Optional + list of :class:`lib.align.AlignedFace` objects for feeding into the model's predict + function + reference_faces: list, Optional + list of :class:`lib.align.AlignedFace` objects at model output sized for using as reference + in the convert functionfor feeding into the model's predict + swapped_faces: :class:`np.ndarray` + The swapped faces returned from the model's predict function + """ + inbound: ExtractMedia + feed_faces: List[AlignedFace] = field(default_factory=list) + reference_faces: List[AlignedFace] = field(default_factory=list) + swapped_faces: np.ndarray = np.array([]) + + class Convert(): # pylint:disable=too-few-public-methods """ The Faceswap Face Conversion Process. @@ -45,11 +84,10 @@ class Convert(): # pylint:disable=too-few-public-methods The arguments to be passed to the convert process as generated from Faceswap's command line arguments """ - def __init__(self, arguments): + def __init__(self, arguments: "Namespace") -> None: logger.debug("Initializing %s: (args: %s)", self.__class__.__name__, arguments) self._args = arguments - self._patch_threads = None self._images = ImagesLoader(self._args.input_dir, fast_count=True) self._alignments = Alignments(self._args, False, self._images.is_video) if self._alignments.version == 1.0: @@ -74,12 +112,13 @@ def __init__(self, arguments): self._disk_io.pre_encode, arguments, configfile=configfile) - + self._patch_threads = self._get_threads() logger.debug("Initialized %s", self.__class__.__name__) @property - def _queue_size(self): + def _queue_size(self) -> int: """ int: Size of the converter queues. 16 for single process otherwise 32 """ + # TODO why do we need such big queues? if self._args.singleprocess: retval = 16 else: @@ -88,7 +127,7 @@ def _queue_size(self): return retval @property - def _pool_processes(self): + def _pool_processes(self) -> int: """ int: The number of threads to run in parallel. Based on user options and number of available processors. """ if self._args.singleprocess: @@ -101,7 +140,7 @@ def _pool_processes(self): logger.debug(retval) return retval - def _validate(self): + def _validate(self) -> None: """ Validate the Command Line Options. Ensure that certain cli selections are valid and won't result in an error. Checks: @@ -133,12 +172,12 @@ def _validate(self): if (not self._args.on_the_fly and self._args.mask_type not in ("none", "predicted") and not self._alignments.mask_is_valid(self._args.mask_type)): - msg = ("You have selected the Mask Type `{}` but at least one face does not have this " - "mask stored in the Alignments File.\nYou should generate the required masks " - "with the Mask Tool or set the Mask Type option to an existing Mask Type.\nA " - "summary of existing masks is as follows:\nTotal faces: {}, Masks: " - "{}".format(self._args.mask_type, self._alignments.faces_count, - self._alignments.mask_summary)) + msg = (f"You have selected the Mask Type `{self._args.mask_type}` but at least one " + "face does not have this mask stored in the Alignments File.\nYou should " + "generate the required masks with the Mask Tool or set the Mask Type option to " + "an existing Mask Type.\nA summary of existing masks is as follows:\nTotal " + f"faces: {self._alignments.faces_count}, " + f"Masks: {self._alignments.mask_summary}") raise FaceswapError(msg) if self._args.mask_type == "predicted" and not self._predictor.has_predicted_mask: @@ -154,16 +193,34 @@ def _validate(self): "mask. Selecting first available mask: '%s'", mask_type) self._args.mask_type = mask_type - def _add_queues(self): + def _add_queues(self) -> None: """ Add the queues for in, patch and out. """ logger.debug("Adding queues. Queue size: %s", self._queue_size) for qname in ("convert_in", "convert_out", "patch"): queue_manager.add_queue(qname, self._queue_size) - def process(self): + def _get_threads(self) -> MultiThread: + """ Get the threads for patching the converted faces onto the frames. + + Returns + :class:`lib.multithreading.MultiThread` + The threads that perform the patching of swapped faces onto the output frames + """ + # TODO Check if multiple threads actually speeds anything up + save_queue = queue_manager.get_queue("convert_out") + patch_queue = queue_manager.get_queue("patch") + return MultiThread(self._converter.process, patch_queue, save_queue, + thread_count=self._pool_processes, name="patch") + + def process(self) -> None: """ The entry point for triggering the Conversion Process. Should only be called from :class:`lib.cli.launcher.ScriptExecutor` + + Raises + ------ + FaceswapError + Error raised if the process runs out of memory """ logger.debug("Starting Conversion") # queue_manager.debug_monitor(5) @@ -184,15 +241,10 @@ def process(self): "'singleprocess' flag (-sp) or lowering the number of parallel jobs (-j).") raise FaceswapError(msg) from err - def _convert_images(self): + def _convert_images(self) -> None: """ Start the multi-threaded patching process, monitor all threads for errors and join on completion. """ logger.debug("Converting images") - save_queue = queue_manager.get_queue("convert_out") - patch_queue = queue_manager.get_queue("patch") - self._patch_threads = MultiThread(self._converter.process, patch_queue, save_queue, - thread_count=self._pool_processes, name="patch") - self._patch_threads.start() while True: self._check_thread_error() @@ -206,11 +258,17 @@ def _convert_images(self): self._patch_threads.join() logger.debug("Putting EOF") - save_queue.put("EOF") + queue_manager.get_queue("convert_out").put("EOF") logger.debug("Converted images") - def _check_thread_error(self): - """ Monitor all running threads for errors, and raise accordingly. """ + def _check_thread_error(self) -> None: + """ Monitor all running threads for errors, and raise accordingly. + + Raises + ------ + Error + Re-raises any error encountered within any of the running threads + """ for thread in (self._predictor.thread, self._disk_io.load_thread, self._disk_io.save_thread, @@ -236,7 +294,8 @@ class DiskIO(): line arguments """ - def __init__(self, alignments, images, arguments): + def __init__(self, + alignments: Alignments, images: ImagesLoader, arguments: "Namespace") -> None: logger.debug("Initializing %s: (alignments: %s, images: %s, arguments: %s)", self.__class__.__name__, alignments, images, arguments) self._alignments = alignments @@ -253,61 +312,63 @@ def __init__(self, alignments, images, arguments): # Extractor for on the fly detection self._extractor = self._load_extractor() - self._queues = dict(load=None, save=None) - self._threads = dict(oad=None, save=None) + self._queues: Dict[Literal["load", "save"], "EventQueue"] = {} + self._threads: Dict[Literal["load", "save"], MultiThread] = {} self._init_threads() logger.debug("Initialized %s", self.__class__.__name__) @property - def completion_event(self): + def completion_event(self) -> Event: """ :class:`event.Event`: Event is set when the DiskIO Save task is complete """ return self._completion_event @property - def draw_transparent(self): + def draw_transparent(self) -> bool: """ bool: ``True`` if the selected writer's Draw_transparent configuration item is set otherwise ``False`` """ return self._writer.config.get("draw_transparent", False) @property - def pre_encode(self): + def pre_encode(self) -> Optional[Callable[[np.ndarray], List[bytes]]]: """ python function: Selected writer's pre-encode function, if it has one, otherwise ``None`` """ dummy = np.zeros((20, 20, 3), dtype="uint8") test = self._writer.pre_encode(dummy) - retval = None if test is None else self._writer.pre_encode + retval: Optional[Callable[[np.ndarray], + List[bytes]]] = None if test is None else self._writer.pre_encode logger.debug("Writer pre_encode function: %s", retval) return retval @property - def save_thread(self): + def save_thread(self) -> MultiThread: """ :class:`lib.multithreading.MultiThread`: The thread that is running the image writing operation. """ return self._threads["save"] @property - def load_thread(self): + def load_thread(self) -> MultiThread: """ :class:`lib.multithreading.MultiThread`: The thread that is running the image loading operation. """ return self._threads["load"] @property - def load_queue(self): - """ :class:`queue.Queue()`: The queue that images and detected faces are loaded into. """ + def load_queue(self) -> "EventQueue": + """ :class:`~lib.queue_manager.EventQueue`: The queue that images and detected faces are " + "loaded into. """ return self._queues["load"] @property - def _total_count(self): + def _total_count(self) -> int: """ int: The total number of frames to be converted """ if self._frame_ranges and not self._args.keep_unchanged: - retval = sum([fr[1] - fr[0] + 1 for fr in self._frame_ranges]) + retval = sum(fr[1] - fr[0] + 1 for fr in self._frame_ranges) else: retval = self._images.count logger.debug(retval) return retval # Initialization - def _get_writer(self): + def _get_writer(self) -> "Output": """ Load the selected writer plugin. Returns @@ -328,7 +389,7 @@ def _get_writer(self): return PluginLoader.get_converter("writer", self._args.writer)(*args, configfile=configfile) - def _get_frame_ranges(self): + def _get_frame_ranges(self) -> Optional[List[Tuple[int, int]]]: """ Obtain the frame ranges that are to be converted. If frame ranges have been specified, then split the command line formatted arguments into @@ -357,7 +418,7 @@ def _get_frame_ranges(self): raise FaceswapError("Frame Ranges specified, but could not determine frame numbering " "from filenames") - retval = list() + retval = [] for rng in self._args.frame_ranges: if "-" not in rng: raise FaceswapError("Frame Ranges not specified in the correct format") @@ -366,7 +427,7 @@ def _get_frame_ranges(self): logger.debug("frame ranges: %s", retval) return retval - def _load_extractor(self): + def _load_extractor(self) -> Optional[Extractor]: """ Load the CV2-DNN Face Extractor Chain. For On-The-Fly conversion we use a CPU based extractor to avoid stacking the GPU. @@ -405,18 +466,18 @@ def _load_extractor(self): logger.debug("Loaded extractor") return extractor - def _init_threads(self): + def _init_threads(self) -> None: """ Initialize queues and threads. Creates the load and save queues and the load and save threads. Starts the threads. """ logger.debug("Initializing DiskIO Threads") - for task in ("load", "save"): + for task in get_args(Literal["load", "save"]): self._add_queue(task) self._start_thread(task) logger.debug("Initialized DiskIO Threads") - def _add_queue(self, task): + def _add_queue(self, task: Literal["load", "save"]) -> None: """ Add the queue to queue_manager and to :attr:`self._queues` for the given task. Parameters @@ -434,7 +495,7 @@ def _add_queue(self, task): self._queues[task] = queue_manager.get_queue(q_name) logger.debug("Added queue for task: '%s'", task) - def _start_thread(self, task): + def _start_thread(self, task: Literal["load", "save"]) -> None: """ Create the thread for the given task, add it it :attr:`self._threads` and start it. Parameters @@ -444,14 +505,14 @@ def _start_thread(self, task): """ logger.debug("Starting thread: '%s'", task) args = self._completion_event if task == "save" else None - func = getattr(self, "_{}".format(task)) + func = getattr(self, f"_{task}") io_thread = MultiThread(func, args, thread_count=1) io_thread.start() self._threads[task] = io_thread logger.debug("Started thread: '%s'", task) # Loading tasks - def _load(self, *args): # pylint: disable=unused-argument + def _load(self, *args) -> None: # pylint: disable=unused-argument """ Load frames from disk. In a background thread: @@ -474,23 +535,23 @@ def _load(self, *args): # pylint: disable=unused-argument continue if self._check_skipframe(filename): if self._args.keep_unchanged: - logger.trace("Saving unchanged frame: %s", filename) + logger.trace("Saving unchanged frame: %s", filename) # type:ignore out_file = os.path.join(self._args.output_dir, os.path.basename(filename)) self._queues["save"].put((out_file, image)) else: - logger.trace("Discarding frame: '%s'", filename) + logger.trace("Discarding frame: '%s'", filename) # type:ignore continue detected_faces = self._get_detected_faces(filename, image) - item = dict(filename=filename, image=image, detected_faces=detected_faces) - self._pre_process.do_actions(item) + item = ConvertItem(ExtractMedia(filename, image, detected_faces)) + self._pre_process.do_actions(item.inbound) self._queues["load"].put(item) logger.debug("Putting EOF") self._queues["load"].put("EOF") logger.debug("Load Images: Complete") - def _check_skipframe(self, filename): + def _check_skipframe(self, filename: str) -> bool: """ Check whether a frame is to be skipped. Parameters @@ -504,18 +565,18 @@ def _check_skipframe(self, filename): ``True`` if the frame is to be skipped otherwise ``False`` """ if not self._frame_ranges: - return None + return False indices = self._imageidxre.findall(filename) if not indices: logger.warning("Could not determine frame number. Frame will be converted: '%s'", filename) return False - idx = int(indices[0]) if indices else None + idx = int(indices[0]) skipframe = not any(map(lambda b: b[0] <= idx <= b[1], self._frame_ranges)) - logger.trace("idx: %s, skipframe: %s", idx, skipframe) + logger.trace("idx: %s, skipframe: %s", idx, skipframe) # type: ignore return skipframe - def _get_detected_faces(self, filename, image): + def _get_detected_faces(self, filename: str, image: np.ndarray) -> List[DetectedFace]: """ Return the detected faces for the given image. If we have an alignments file, then the detected faces are created from that file. If @@ -533,15 +594,15 @@ def _get_detected_faces(self, filename, image): list List of :class:`lib.align.DetectedFace` objects """ - logger.trace("Getting faces for: '%s'", filename) + logger.trace("Getting faces for: '%s'", filename) # type:ignore if not self._extractor: detected_faces = self._alignments_faces(os.path.basename(filename), image) else: detected_faces = self._detect_faces(filename, image) - logger.trace("Got %s faces for: '%s'", len(detected_faces), filename) + logger.trace("Got %s faces for: '%s'", len(detected_faces), filename) # type:ignore return detected_faces - def _alignments_faces(self, frame_name, image): + def _alignments_faces(self, frame_name: str, image: np.ndarray) -> List[DetectedFace]: """ Return detected faces from an alignments file. Parameters @@ -557,10 +618,10 @@ def _alignments_faces(self, frame_name, image): List of :class:`lib.align.DetectedFace` objects """ if not self._check_alignments(frame_name): - return list() + return [] faces = self._alignments.get_faces_in_frame(frame_name) - detected_faces = list() + detected_faces = [] for rawface in faces: face = DetectedFace() @@ -568,7 +629,7 @@ def _alignments_faces(self, frame_name, image): detected_faces.append(face) return detected_faces - def _check_alignments(self, frame_name): + def _check_alignments(self, frame_name: str) -> bool: """ Ensure that we have alignments for the current frame. If we have no alignments for this image, skip it and output a message. @@ -585,11 +646,10 @@ def _check_alignments(self, frame_name): """ have_alignments = self._alignments.frame_exists(frame_name) if not have_alignments: - tqdm.write("No alignment found for {}, " - "skipping".format(frame_name)) + tqdm.write(f"No alignment found for {frame_name}, skipping") return have_alignments - def _detect_faces(self, filename, image): + def _detect_faces(self, filename: str, image: np.ndarray) -> List[DetectedFace]: """ Extract the face from a frame for On-The-Fly conversion. Pulls detected faces out of the Extraction pipeline. @@ -606,12 +666,13 @@ def _detect_faces(self, filename, image): list List of :class:`lib.align.DetectedFace` objects """ + assert self._extractor is not None self._extractor.input_queue.put(ExtractMedia(filename, image)) faces = next(self._extractor.detected_faces()) return faces.detected_faces # Saving tasks - def _save(self, completion_event): + def _save(self, completion_event: Event) -> None: """ Save the converted images. Puts the selected writer into a background thread and feeds it from the output of the @@ -650,7 +711,7 @@ class Predict(): Parameters ---------- - in_queue: :class:`queue.Queue` + in_queue: :class:`~lib.queue_manager.EventQueue` The queue that contains images and detected faces for feeding the model queue_size: int The maximum size of the input queue @@ -658,7 +719,7 @@ class Predict(): The arguments that were passed to the convert process as generated from Faceswap's command line arguments """ - def __init__(self, in_queue, queue_size, arguments): + def __init__(self, in_queue: "EventQueue", queue_size: int, arguments: "Namespace") -> None: logger.debug("Initializing %s: (args: %s, queue_size: %s, in_queue: %s)", self.__class__.__name__, arguments, queue_size, in_queue) self._args = arguments @@ -678,52 +739,52 @@ def __init__(self, in_queue, queue_size, arguments): logger.debug("Initialized %s: (out_queue: %s)", self.__class__.__name__, self._out_queue) @property - def thread(self): + def thread(self) -> MultiThread: """ :class:`~lib.multithreading.MultiThread`: The thread that is running the prediction function from the Faceswap model. """ return self._thread @property - def in_queue(self): - """ :class:`queue.Queue`: The input queue to the predictor. """ + def in_queue(self) -> "EventQueue": + """ :class:`~lib.queue_manager.EventQueue`: The input queue to the predictor. """ return self._in_queue @property - def out_queue(self): - """ :class:`queue.Queue`: The output queue from the predictor. """ + def out_queue(self) -> "EventQueue": + """ :class:`~lib.queue_manager.EventQueue`: The output queue from the predictor. """ return self._out_queue @property - def faces_count(self): + def faces_count(self) -> int: """ int: The total number of faces seen by the Predictor. """ return self._faces_count @property - def verify_output(self): + def verify_output(self) -> bool: """ bool: ``True`` if multiple faces have been found in frames, otherwise ``False``. """ return self._verify_output @property - def coverage_ratio(self): + def coverage_ratio(self) -> float: """ float: The coverage ratio that the model was trained at. """ return self._coverage_ratio @property - def centering(self): - """ str: The centering that the model was trained on (`"face"` or `"legacy"`) """ + def centering(self) -> "CenteringType": + """ str: The centering that the model was trained on (`"head", "face"` or `"legacy"`) """ return self._centering @property - def has_predicted_mask(self): + def has_predicted_mask(self) -> bool: """ bool: ``True`` if the model was trained to learn a mask, otherwise ``False``. """ return bool(self._model.config.get("learn_mask", False)) @property - def output_size(self): + def output_size(self) -> int: """ int: The size in pixels of the Faceswap model output. """ return self._sizes["output"] - def _get_io_sizes(self): + def _get_io_sizes(self) -> Dict[str, int]: """ Obtain the input size and output size of the model. Returns @@ -739,7 +800,7 @@ def _get_io_sizes(self): logger.debug(retval) return retval - def _load_model(self): + def _load_model(self) -> "ModelBase": """ Load the Faceswap model. Returns @@ -757,7 +818,7 @@ def _load_model(self): logger.debug("Loaded Model") return model - def _get_batchsize(self, queue_size): + def _get_batchsize(self, queue_size: int) -> int: """ Get the batch size for feeding the model. Sets the batch size to 1 if inference is being run on CPU, otherwise the minimum of the @@ -781,7 +842,7 @@ def _get_batchsize(self, queue_size): logger.debug("Got batchsize: %s", batchsize) return batchsize - def _get_model_name(self, model_dir): + def _get_model_name(self, model_dir: str) -> str: """ Return the name of the Faceswap model used. If a "trainer" option has been selected in the command line arguments, use that value, @@ -802,13 +863,13 @@ def _get_model_name(self, model_dir): logger.debug("Trainer name provided: '%s'", self._args.trainer) return self._args.trainer - statefile = [fname for fname in os.listdir(str(model_dir)) - if fname.endswith("_state.json")] - if len(statefile) != 1: - raise FaceswapError("There should be 1 state file in your model folder. {} were " - "found. Specify a trainer with the '-t', '--trainer' " - "option.".format(len(statefile))) - statefile = os.path.join(str(model_dir), statefile[0]) + statefiles = [fname for fname in os.listdir(str(model_dir)) + if fname.endswith("_state.json")] + if len(statefiles) != 1: + raise FaceswapError("There should be 1 state file in your model folder. " + f"{len(statefiles)} were found. Specify a trainer with the '-t', " + "'--trainer' option.") + statefile = os.path.join(str(model_dir), statefiles[0]) state = self._serializer.load(statefile) trainer = state.get("name", None) @@ -819,7 +880,7 @@ def _get_model_name(self, model_dir): logger.debug("Trainer from state file: '%s'", trainer) return trainer - def _launch_predictor(self): + def _launch_predictor(self) -> MultiThread: """ Launch the prediction process in a background thread. Starts the prediction thread and returns the thread. @@ -833,7 +894,7 @@ def _launch_predictor(self): thread.start() return thread - def _predict_faces(self): + def _predict_faces(self) -> None: """ Run Prediction on the Faceswap model in a background thread. Reads from the :attr:`self._in_queue`, prepares images for prediction @@ -841,64 +902,63 @@ def _predict_faces(self): """ faces_seen = 0 consecutive_no_faces = 0 - batch = list() + batch: List[ConvertItem] = [] is_amd = get_backend() == "amd" while True: - item = self._in_queue.get() - if item != "EOF": - logger.trace("Got from queue: '%s'", item["filename"]) - faces_count = len(item["detected_faces"]) - - # Safety measure. If a large stream of frames appear that do not have faces, - # these will stack up into RAM. Keep a count of consecutive frames with no faces. - # If self._batchsize number of frames appear, force the current batch through - # to clear RAM. - consecutive_no_faces = consecutive_no_faces + 1 if faces_count == 0 else 0 - self._faces_count += faces_count - if faces_count > 1: - self._verify_output = True - logger.verbose("Found more than one face in an image! '%s'", - os.path.basename(item["filename"])) - - self.load_aligned(item) - - faces_seen += faces_count - batch.append(item) - - if item != "EOF" and (faces_seen < self._batchsize and - consecutive_no_faces < self._batchsize): - logger.trace("Continuing. Current batchsize: %s, consecutive_no_faces: %s", - faces_seen, consecutive_no_faces) + item: Union[Literal["EOF"], ConvertItem] = self._in_queue.get() + if item == "EOF": + logger.debug("EOF Received") + break + logger.trace("Got from queue: '%s'", item.inbound.filename) # type:ignore + faces_count = len(item.inbound.detected_faces) + + # Safety measure. If a large stream of frames appear that do not have faces, + # these will stack up into RAM. Keep a count of consecutive frames with no faces. + # If self._batchsize number of frames appear, force the current batch through + # to clear RAM. + consecutive_no_faces = consecutive_no_faces + 1 if faces_count == 0 else 0 + self._faces_count += faces_count + if faces_count > 1: + self._verify_output = True + logger.verbose("Found more than one face in an image! '%s'", # type:ignore + os.path.basename(item.inbound.filename)) + + self.load_aligned(item) + faces_seen += faces_count + + batch.append(item) + + if faces_seen < self._batchsize and consecutive_no_faces < self._batchsize: + logger.trace("Continuing. Current batchsize: %s, " # type:ignore + "consecutive_no_faces: %s", faces_seen, consecutive_no_faces) continue if batch: - logger.trace("Batching to predictor. Frames: %s, Faces: %s", + logger.trace("Batching to predictor. Frames: %s, Faces: %s", # type:ignore len(batch), faces_seen) feed_batch = [feed_face for item in batch - for feed_face in item["feed_faces"]] + for feed_face in item.feed_faces] if faces_seen != 0: feed_faces = self._compile_feed_faces(feed_batch) batch_size = None if is_amd and feed_faces.shape[0] != self._batchsize: - logger.verbose("Fallback to BS=1") + logger.verbose("Fallback to BS=1") # type:ignore batch_size = 1 predicted = self._predict(feed_faces, batch_size) else: - predicted = list() + predicted = np.array([]) self._queue_out_frames(batch, predicted) consecutive_no_faces = 0 faces_seen = 0 - batch = list() - if item == "EOF": - logger.debug("EOF Received") - break + batch = [] + logger.debug("Putting EOF") self._out_queue.put("EOF") logger.debug("Load queue complete") - def load_aligned(self, item): + def load_aligned(self, item: ConvertItem) -> None: """ Load the model's feed faces and the reference output faces. For each detected face in the incoming item, load the feed face and reference face @@ -906,18 +966,15 @@ def load_aligned(self, item): Parameters ---------- - item: dict - The incoming image, list of :class:`~lib.align.DetectedFace` objects and list of - :class:`~lib.align.AlignedFace` objects for the feed face(s) and list of - :class:`~lib.align.AlignedFace` objects for the reference face(s) - + item: :class:`ConvertMedia` + The convert media object, containing the ExctractMedia for the current image """ - logger.trace("Loading aligned faces: '%s'", item["filename"]) + logger.trace("Loading aligned faces: '%s'", item.inbound.filename) # type:ignore feed_faces = [] reference_faces = [] - for detected_face in item["detected_faces"]: + for detected_face in item.inbound.detected_faces: feed_face = AlignedFace(detected_face.landmarks_xy, - image=item["image"], + image=item.inbound.image, centering=self._centering, size=self._sizes["input"], coverage_ratio=self._coverage_ratio, @@ -926,18 +983,18 @@ def load_aligned(self, item): reference_faces.append(feed_face) else: reference_faces.append(AlignedFace(detected_face.landmarks_xy, - image=item["image"], + image=item.inbound.image, centering=self._centering, size=self._sizes["output"], coverage_ratio=self._coverage_ratio, dtype="float32")) feed_faces.append(feed_face) - item["feed_faces"] = feed_faces - item["reference_faces"] = reference_faces - logger.trace("Loaded aligned faces: '%s'", item["filename"]) + item.feed_faces = feed_faces + item.reference_faces = reference_faces + logger.trace("Loaded aligned faces: '%s'", item.inbound.filename) # type:ignore @staticmethod - def _compile_feed_faces(feed_faces): + def _compile_feed_faces(feed_faces: List[AlignedFace]) -> np.ndarray: """ Compile a batch of faces for feeding into the Predictor. Parameters @@ -950,12 +1007,13 @@ def _compile_feed_faces(feed_faces): :class:`numpy.ndarray` A batch of faces ready for feeding into the Faceswap model. """ - logger.trace("Compiling feed face. Batchsize: %s", len(feed_faces)) - retval = np.stack([feed_face.face[..., :3] for feed_face in feed_faces]) / 255.0 - logger.trace("Compiled Feed faces. Shape: %s", retval.shape) + logger.trace("Compiling feed face. Batchsize: %s", len(feed_faces)) # type:ignore + retval = np.stack([cast(np.ndarray, feed_face.face)[..., :3] + for feed_face in feed_faces]) / 255.0 + logger.trace("Compiled Feed faces. Shape: %s", retval.shape) # type:ignore return retval - def _predict(self, feed_faces, batch_size=None): + def _predict(self, feed_faces: np.ndarray, batch_size: Optional[int] = None) -> np.ndarray: """ Run the Faceswap models' prediction function. Parameters @@ -971,32 +1029,33 @@ def _predict(self, feed_faces, batch_size=None): :class:`numpy.ndarray` The swapped faces for the given batch """ - logger.trace("Predicting: Batchsize: %s", len(feed_faces)) + logger.trace("Predicting: Batchsize: %s", len(feed_faces)) # type:ignore if self._model.color_order.lower() == "rgb": feed_faces = feed_faces[..., ::-1] feed = [feed_faces] - logger.trace("Input shape(s): %s", [item.shape for item in feed]) + logger.trace("Input shape(s): %s", [item.shape for item in feed]) # type:ignore - predicted = self._model.model.predict(feed, verbose=0, batch_size=batch_size) - predicted = predicted if isinstance(predicted, list) else [predicted] + inbound = self._model.model.predict(feed, verbose=0, batch_size=batch_size) + predicted: List[np.ndarray] = inbound if isinstance(inbound, list) else [inbound] if self._model.color_order.lower() == "rgb": predicted[0] = predicted[0][..., ::-1] - logger.trace("Output shape(s): %s", [predict.shape for predict in predicted]) + logger.trace("Output shape(s): %s", # type:ignore + [predict.shape for predict in predicted]) # Only take last output(s) if predicted[-1].shape[-1] == 1: # Merge mask to alpha channel - predicted = np.concatenate(predicted[-2:], axis=-1).astype("float32") + retval = np.concatenate(predicted[-2:], axis=-1).astype("float32") else: - predicted = predicted[-1].astype("float32") + retval = predicted[-1].astype("float32") - logger.trace("Final shape: %s", predicted.shape) - return predicted + logger.trace("Final shape: %s", retval.shape) # type:ignore + return retval - def _queue_out_frames(self, batch, swapped_faces): + def _queue_out_frames(self, batch: List[ConvertItem], swapped_faces: np.ndarray) -> None: """ Compile the batch back to original frames and put to the Out Queue. For batching, faces are split away from their frames. This compiles all detected faces @@ -1009,21 +1068,20 @@ def _queue_out_frames(self, batch, swapped_faces): swapped_faces: :class:`numpy.ndarray` The predictions returned from the model's predict function """ - logger.trace("Queueing out batch. Batchsize: %s", len(batch)) + logger.trace("Queueing out batch. Batchsize: %s", len(batch)) # type:ignore pointer = 0 for item in batch: - num_faces = len(item["detected_faces"]) - if num_faces == 0: - item["swapped_faces"] = np.array(list()) - else: - item["swapped_faces"] = swapped_faces[pointer:pointer + num_faces] - - logger.trace("Putting to queue. ('%s', detected_faces: %s, reference_faces: %s, " - "swapped_faces: %s)", item["filename"], len(item["detected_faces"]), - len(item["reference_faces"]), item["swapped_faces"].shape[0]) + num_faces = len(item.inbound.detected_faces) + if num_faces != 0: + item.swapped_faces = swapped_faces[pointer:pointer + num_faces] + + logger.trace("Putting to queue. ('%s', detected_faces: %s, " # type:ignore + "reference_faces: %s, swapped_faces: %s)", item.inbound.filename, + len(item.inbound.detected_faces), len(item.reference_faces), + item.swapped_faces.shape[0]) pointer += num_faces self._out_queue.put(batch) - logger.trace("Queued out batch. Batchsize: %s", len(batch)) + logger.trace("Queued out batch. Batchsize: %s", len(batch)) # type:ignore class OptionalActions(): # pylint:disable=too-few-public-methods @@ -1041,8 +1099,10 @@ class OptionalActions(): # pylint:disable=too-few-public-methods alignments: :class:`lib.align.Alignments` The alignments file for this conversion """ - - def __init__(self, arguments, input_images, alignments): + def __init__(self, + arguments: "Namespace", + input_images: List[np.ndarray], + alignments: Alignments) -> None: logger.debug("Initializing %s", self.__class__.__name__) self._args = arguments self._input_images = input_images @@ -1052,7 +1112,7 @@ def __init__(self, arguments, input_images, alignments): logger.debug("Initialized %s", self.__class__.__name__) # SKIP FACES # - def _remove_skipped_faces(self): + def _remove_skipped_faces(self) -> None: """ If the user has specified an input aligned directory, remove any non-matching faces from the alignments file. """ logger.debug("Filtering Faces") @@ -1064,7 +1124,7 @@ def _remove_skipped_faces(self): self._alignments.filter_faces(accept_dict, filter_out=False) logger.info("Faces filtered out: %s", pre_face_count - self._alignments.faces_count) - def _get_face_metadata(self): + def _get_face_metadata(self) -> Dict[str, List[int]]: """ Check for the existence of an aligned directory for identifying which faces in the target frames should be swapped. If it exists, scan the folder for face's metadata @@ -1073,12 +1133,12 @@ def _get_face_metadata(self): dict Dictionary of source frame names with a list of associated face indices to be skipped """ - retval = dict() + retval: Dict[str, List[int]] = {} input_aligned_dir = self._args.input_aligned_dir if input_aligned_dir is None: - logger.verbose("Aligned directory not specified. All faces listed in the " - "alignments file will be converted") + logger.verbose("Aligned directory not specified. All faces listed in " # type:ignore + "the alignments file will be converted") return retval if not os.path.isdir(input_aligned_dir): logger.warning("Aligned directory not found. All faces listed in the " @@ -1100,13 +1160,13 @@ def _get_face_metadata(self): data = update_legacy_png_header(fullpath, self._alignments) if not data: raise FaceswapError( - "Some of the faces being passed in from '{}' could not be matched to the " - "alignments file '{}'\nPlease double check your sources and try " - "again.".format(input_aligned_dir, self._alignments.file)) + f"Some of the faces being passed in from '{input_aligned_dir}' could not " + f"be matched to the alignments file '{self._alignments.file}'\n" + "Please double check your sources and try again.") meta = data["source"] else: meta = metadata["itxt"]["source"] - retval.setdefault(meta["source_filename"], list()).append(meta["face_index"]) + retval.setdefault(meta["source_filename"], []).append(meta["face_index"]) if not retval: raise FaceswapError("Aligned directory is empty, no faces will be converted!") diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 2f28739651..6f09d96a3a 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -9,6 +9,7 @@ import logging import os import sys +from typing import Any, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 import numpy as np @@ -19,10 +20,19 @@ from lib.image import count_frames, read_image from lib.utils import (camel_case_split, get_image_paths, _video_extensions) +if sys.version_info < (3, 8): + from typing_extensions import get_args, Literal +else: + from typing import get_args, Literal + +if TYPE_CHECKING: + from argparse import Namespace + from plugins.extract.pipeline import ExtractMedia + logger = logging.getLogger(__name__) # pylint: disable=invalid-name -def finalize(images_found, num_faces_detected, verify_output): +def finalize(images_found: int, num_faces_detected: int, verify_output: bool) -> None: """ Output summary statistics at the end of the extract or convert processes. Parameters @@ -62,7 +72,10 @@ class Alignments(AlignmentsBase): ``True`` if the input to the process is a video, ``False`` if it is a folder of images. Default: False """ - def __init__(self, arguments, is_extract, input_is_video=False): + def __init__(self, + arguments: "Namespace", + is_extract: bool, + input_is_video: bool = False) -> None: logger.debug("Initializing %s: (is_extract: %s, input_is_video: %s)", self.__class__.__name__, is_extract, input_is_video) self._args = arguments @@ -71,7 +84,7 @@ def __init__(self, arguments, is_extract, input_is_video=False): super().__init__(folder, filename=filename) logger.debug("Initialized %s", self.__class__.__name__) - def _set_folder_filename(self, input_is_video): + def _set_folder_filename(self, input_is_video: bool) -> Tuple[str, str]: """ Return the folder and the filename for the alignments file. If the input is a video, the alignments file will be stored in the same folder @@ -106,7 +119,7 @@ def _set_folder_filename(self, input_is_video): logger.debug("Setting Alignments: (folder: '%s' filename: '%s')", folder, filename) return folder, filename - def _load(self): + def _load(self) -> Dict[str, Any]: """ Override the parent :func:`~lib.align.Alignments._load` to handle skip existing frames and faces on extract. @@ -119,10 +132,10 @@ def _load(self): Any alignments that have already been extracted if skip existing has been selected otherwise an empty dictionary """ - data = {} + data: Dict[str, Any] = {} + if not self._is_extract and not self.have_alignments_file: + return data if not self._is_extract: - if not self.have_alignments_file: - return data data = super()._load() return data @@ -146,7 +159,7 @@ def _load(self): logger.debug("Frames with no faces selected for redetection: %s", len(del_keys)) for key in del_keys: if key in data: - logger.trace("Selected for redetection: '%s'", key) + logger.trace("Selected for redetection: '%s'", key) # type: ignore del data[key] return data @@ -160,7 +173,7 @@ class Images(): arguments: :class:`argparse.Namespace` The command line arguments that were passed to Faceswap """ - def __init__(self, arguments): + def __init__(self, arguments: "Namespace") -> None: logger.debug("Initializing %s", self.__class__.__name__) self._args = arguments self._is_video = self._check_input_folder() @@ -169,22 +182,22 @@ def __init__(self, arguments): logger.debug("Initialized %s", self.__class__.__name__) @property - def is_video(self): + def is_video(self) -> bool: """bool: ``True`` if the input is a video file otherwise ``False``. """ return self._is_video @property - def input_images(self): + def input_images(self) -> Union[str, List[str]]: """str or list: Path to the video file if the input is a video otherwise list of image paths. """ return self._input_images @property - def images_found(self): + def images_found(self) -> int: """int: The number of frames that exist in the video file, or the folder of images. """ return self._images_found - def _count_images(self): + def _count_images(self) -> int: """ Get the number of Frames from a video file or folder of images. Returns @@ -198,7 +211,7 @@ def _count_images(self): retval = len(self._input_images) return retval - def _check_input_folder(self): + def _check_input_folder(self) -> bool: """ Check whether the input is a folder or video. Returns @@ -218,7 +231,7 @@ def _check_input_folder(self): retval = False return retval - def _get_input_images(self): + def _get_input_images(self) -> Union[str, List[str]]: """ Return the list of images or path to video file that is to be processed. Returns @@ -233,7 +246,7 @@ def _get_input_images(self): return input_images - def load(self): + def load(self) -> Generator[Tuple[str, np.ndarray], None, None]: """ Generator to load frames from a folder of images or from a video file. Yields @@ -247,7 +260,7 @@ def load(self): for filename, image in iterator(): yield filename, image - def _load_disk_frames(self): + def _load_disk_frames(self) -> Generator[Tuple[str, np.ndarray], None, None]: """ Generator to load frames from a folder of images. Yields @@ -264,7 +277,7 @@ def _load_disk_frames(self): continue yield filename, image - def _load_video_frames(self): + def _load_video_frames(self) -> Generator[Tuple[str, np.ndarray], None, None]: """ Generator to load frames from a video file. Yields @@ -281,11 +294,11 @@ def _load_video_frames(self): # Convert to BGR for cv2 compatibility frame = frame[:, :, ::-1] filename = f"{vidname}_{i + 1:06d}.png" - logger.trace("Loading video frame: '%s'", filename) + logger.trace("Loading video frame: '%s'", filename) # type: ignore yield filename, frame reader.close() - def load_one_image(self, filename): + def load_one_image(self, filename) -> np.ndarray: """ Obtain a single image for the given filename. Parameters @@ -299,19 +312,20 @@ def load_one_image(self, filename): The image for the requested filename, """ - logger.trace("Loading image: '%s'", filename) + logger.trace("Loading image: '%s'", filename) # type: ignore if self._is_video: if filename.isdigit(): frame_no = filename else: frame_no = os.path.splitext(filename)[0][filename.rfind("_") + 1:] - logger.trace("Extracted frame_no %s from filename '%s'", frame_no, filename) + logger.trace("Extracted frame_no %s from filename '%s'", # type: ignore + frame_no, filename) retval = self._load_one_video_frame(int(frame_no)) else: retval = read_image(filename, raise_error=True) return retval - def _load_one_video_frame(self, frame_no): + def _load_one_video_frame(self, frame_no: int) -> np.ndarray: """ Obtain a single frame from a video file. Parameters @@ -324,7 +338,7 @@ def _load_one_video_frame(self, frame_no): :class:`numpy.ndarray` The image for the requested frame index, """ - logger.trace("Loading video frame: %s", frame_no) + logger.trace("Loading video frame: %s", frame_no) # type: ignore reader = imageio.get_reader(self._args.input_dir, "ffmpeg") reader.set_image_index(frame_no - 1) frame = reader.get_next_data()[:, :, ::-1] @@ -343,13 +357,13 @@ class PostProcess(): # pylint:disable=too-few-public-methods arguments: :class:`argparse.Namespace` The command line arguments that were passed to Faceswap """ - def __init__(self, arguments): + def __init__(self, arguments: "Namespace") -> None: logger.debug("Initializing %s", self.__class__.__name__) self._args = arguments self._actions = self._set_actions() logger.debug("Initialized %s", self.__class__.__name__) - def _set_actions(self): + def _set_actions(self) -> List["PostProcessAction"]: """ Compile the requested actions to be performed into a list Returns @@ -358,7 +372,7 @@ def _set_actions(self): The list of :class:`PostProcessAction` to be performed """ postprocess_items = self._get_items() - actions = [] + actions: List["PostProcessAction"] = [] for action, options in postprocess_items.items(): options = {} if options is None else options args = options.get("args", tuple()) @@ -370,13 +384,13 @@ def _set_actions(self): logger.debug("Adding Postprocess action: '%s'", task) actions.append(task) - for action in actions: - action_name = camel_case_split(action.__class__.__name__) + for ppaction in actions: + action_name = camel_case_split(ppaction.__class__.__name__) logger.info("Adding post processing item: %s", " ".join(action_name)) return actions - def _get_items(self): + def _get_items(self) -> Dict[str, Optional[Dict[str, Union[tuple, dict]]]]: """ Check the passed in command line arguments for requested actions, For any requested actions, add the item to the actions list along with @@ -388,7 +402,7 @@ def _get_items(self): The name of the action to be performed as the key. Any action specific arguments and keyword arguments as the value. """ - postprocess_items = {} + postprocess_items: Dict[str, Optional[Dict[str, Union[tuple, dict]]]] = {} # Debug Landmarks if (hasattr(self._args, 'debug_landmarks') and self._args.debug_landmarks): postprocess_items["DebugLandmarks"] = None @@ -423,7 +437,7 @@ def _get_items(self): logger.debug("Postprocess Items: %s", postprocess_items) return postprocess_items - def do_actions(self, extract_media): + def do_actions(self, extract_media: "ExtractMedia") -> None: """ Perform the requested optional post-processing actions on the given image. Parameters @@ -455,19 +469,19 @@ class PostProcessAction(): # pylint: disable=too-few-public-methods kwargs: dict Varies for specific post process action """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: logger.debug("Initializing %s: (args: %s, kwargs: %s)", self.__class__.__name__, args, kwargs) self._valid = True # Set to False if invalid parameters passed in to disable logger.debug("Initialized base class %s", self.__class__.__name__) @property - def valid(self): + def valid(self) -> bool: """bool: ``True`` if the action if the parameters passed in for this action are valid, otherwise ``False`` """ return self._valid - def process(self, extract_media): + def process(self, extract_media: "ExtractMedia") -> None: """ Override for specific post processing action Parameters @@ -481,12 +495,12 @@ def process(self, extract_media): class DebugLandmarks(PostProcessAction): # pylint: disable=too-few-public-methods """ Draw debug landmarks on face output. Extract Only """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(self, *args, **kwargs) self._face_size = 0 self._legacy_size = 0 - def process(self, extract_media): + def process(self, extract_media: "ExtractMedia") -> None: """ Draw landmarks on a face. Parameters @@ -494,12 +508,6 @@ def process(self, extract_media): extract_media: :class:`~plugins.extract.pipeline.ExtractMedia` The :class:`~plugins.extract.pipeline.ExtractMedia` object that contains the faces to draw the landmarks on to - - Returns - ------- - :class:`~plugins.extract.pipeline.ExtractMedia` - The original :class:`~plugins.extract.pipeline.ExtractMedia` with landmarks drawn - onto the face """ frame = os.path.splitext(os.path.basename(extract_media.filename))[0] for idx, face in enumerate(extract_media.detected_faces): @@ -514,12 +522,12 @@ def process(self, extract_media): face.aligned.size) logger.debug("set legacy size: %s", self._legacy_size) - logger.trace("Drawing Landmarks. Frame: '%s'. Face: %s", frame, idx) + logger.trace("Drawing Landmarks. Frame: '%s'. Face: %s", frame, idx) # type: ignore # Landmarks for (pos_x, pos_y) in face.aligned.landmarks.astype("int32"): cv2.circle(face.aligned.face, (pos_x, pos_y), 1, (0, 255, 255), -1) # Pose - center = tuple(np.int32((face.aligned.size / 2, face.aligned.size / 2))) + center = (face.aligned.size // 2, face.aligned.size // 2) points = (face.aligned.pose.xyz_2d * face.aligned.size).astype("int32") cv2.line(face.aligned.face, center, tuple(points[1]), (0, 255, 0), 1) cv2.line(face.aligned.face, center, tuple(points[0]), (255, 0, 0), 1) @@ -554,13 +562,18 @@ class FaceFilter(PostProcessAction): * **filter_lists** (`dict`) - The filter and nfilter image paths """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) logger.info("Extracting and aligning face for Face Filter...") self._filter = self._load_face_filter(**kwargs) logger.debug("Initialized %s", self.__class__.__name__) - def _load_face_filter(self, filter_lists, ref_threshold, aligner, detector, multiprocess): + def _load_face_filter(self, + filter_lists: Dict[str, str], + ref_threshold: float, + aligner: str, + detector: str, + multiprocess: bool) -> Optional[FilterFunc]: """ Set up and load the :class:`~lib.face_filter.FaceFilter`. Parameters @@ -586,7 +599,7 @@ def _load_face_filter(self, filter_lists, ref_threshold, aligner, detector, mult facefilter = None filter_files = [self._set_face_filter(f_type, filter_lists[f_type]) - for f_type in ("filter", "nfilter")] + for f_type in get_args(Literal["filter", "nfilter"])] if any(filters for filters in filter_files): facefilter = FilterFunc(filter_files[0], @@ -597,11 +610,13 @@ def _load_face_filter(self, filter_lists, ref_threshold, aligner, detector, mult ref_threshold) logger.debug("Face filter: %s", facefilter) else: - self.valid = False + self._valid = False return facefilter - @staticmethod - def _set_face_filter(f_type, f_args): + @classmethod + def _set_face_filter(cls, + f_type: Literal["filter", "nfilter"], + f_args: Union[str, List[str]]) -> List[str]: """ Check filter files exist and add the filter file paths to a list. Parameters @@ -621,14 +636,14 @@ def _set_face_filter(f_type, f_args): logger.info("%s: %s", f_type.title(), f_args) filter_files = f_args if isinstance(f_args, list) else [f_args] - filter_files = list(filter(lambda fpath: os.path.exists(fpath), filter_files)) + filter_files = [fpath for fpath in filter_files if os.path.exists(fpath)] if not filter_files: logger.warning("Face %s files were requested, but no files could be found. This " "filter will not be applied.", f_type) logger.debug("Face Filter files: %s", filter_files) return filter_files - def process(self, extract_media): + def process(self, extract_media: "ExtractMedia") -> None: """ Filters in or out any wanted or unwanted faces based on command line arguments. Parameters @@ -636,12 +651,6 @@ def process(self, extract_media): extract_media: :class:`~plugins.extract.pipeline.ExtractMedia` The :class:`~plugins.extract.pipeline.ExtractMedia` object to perform the face filtering on. - - Returns - ------- - :class:`~plugins.extract.pipeline.ExtractMedia` - The original :class:`~plugins.extract.pipeline.ExtractMedia` with any requested filters - applied """ if not self._filter: return @@ -649,10 +658,10 @@ def process(self, extract_media): for idx, detect_face in enumerate(extract_media.detected_faces): check_item = detect_face["face"] if isinstance(detect_face, dict) else detect_face if not self._filter.check(extract_media.image, check_item): - logger.verbose("Skipping not recognized face: (Frame: %s Face %s)", + logger.verbose("Skipping not recognized face: (Frame: %s Face %s)", # type: ignore extract_media.filename, idx) continue - logger.trace("Accepting recognised face. Frame: %s. Face: %s", + logger.trace("Accepting recognised face. Frame: %s. Face: %s", # type: ignore extract_media.filename, idx) ret_faces.append(detect_face) extract_media.add_detected_faces(ret_faces) diff --git a/tools/preview/preview.py b/tools/preview/preview.py index 1efda36b87..5764c90f34 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 """ Tool to preview swaps and tweak configuration prior to running a convert """ +from dataclasses import dataclass, field import gettext import logging import random import tkinter as tk -from tkinter import ttk +from tkinter import PhotoImage, ttk +from typing import Any, Callable, cast, Dict, List, Optional, Tuple, TYPE_CHECKING, Union import os import sys @@ -25,10 +27,21 @@ from lib.utils import FaceswapError from lib.queue_manager import queue_manager from scripts.fsmedia import Alignments, Images -from scripts.convert import Predict +from scripts.convert import Predict, ConvertItem from plugins.plugin_loader import PluginLoader from plugins.convert._config import Config +from plugins.extract.pipeline import ExtractMedia + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + +if TYPE_CHECKING: + from argparse import Namespace + from lib.align.aligned_face import CenteringType + from lib.queue_manager import EventQueue logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -50,14 +63,16 @@ class Preview(tk.Tk): # pylint:disable=too-few-public-methods arguments: :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in from :mod:`tools.py` """ + _w: str - def __init__(self, arguments): + def __init__(self, arguments: "Namespace") -> None: logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) super().__init__() self._config_tools = ConfigTools() self._lock = Lock() - self._tk_vars = dict(refresh=tk.BooleanVar(), busy=tk.BooleanVar()) + self._tk_vars: Dict[Literal["refresh", "busy"], + tk.BooleanVar] = dict(refresh=tk.BooleanVar(), busy=tk.BooleanVar()) for val in self._tk_vars.values(): val.set(False) self._display = FacesDisplay(256, 64, self._tk_vars) @@ -74,20 +89,20 @@ def __init__(self, arguments): self._tk_vars) self._initialize_tkinter() - self._image_canvas = None - self._opts_book = None - self._cli_frame = None # cli frame holds cli options + self._image_canvas: Optional[ImagesCanvas] = None + self._opts_book: Optional[OptionsBook] = None + self._cli_frame: Optional[ActionFrame] = None # cli frame holds cli options logger.debug("Initialized %s", self.__class__.__name__) @property - def _available_masks(self): + def _available_masks(self) -> List[str]: """ list: The mask names that are available for every face in the alignments file """ retval = [key for key, val in self._samples.alignments.mask_summary.items() if val == self._samples.alignments.faces_count] return retval - def _initialize_tkinter(self): + def _initialize_tkinter(self) -> None: """ Initialize a standalone tkinter instance. """ logger.debug("Initializing tkinter") initialize_config(self, None, None) @@ -97,10 +112,11 @@ def _initialize_tkinter(self): self.tk.call( "wm", "iconphoto", - self._w, get_images().icons["favicon"]) # pylint:disable=protected-access + self._w, + get_images().icons["favicon"]) # pylint:disable=protected-access logger.debug("Initialized tkinter") - def process(self): + def process(self) -> None: """ The entry point for the Preview tool from :file:`lib.tools.cli`. Launch the tkinter preview Window and run main loop. @@ -108,7 +124,7 @@ def process(self): self._build_ui() self.mainloop() - def _refresh(self, *args): + def _refresh(self, *args) -> None: """ Load new faces to display in preview. Parameters @@ -116,21 +132,22 @@ def _refresh(self, *args): *args: tuple Unused, but required for tkinter callback. """ - logger.trace("Refreshing swapped faces. args: %s", args) + logger.trace("Refreshing swapped faces. args: %s", args) # type: ignore self._tk_vars["busy"].set(True) self._config_tools.update_config() with self._lock: + assert self._cli_frame is not None self._patch.converter_arguments = self._cli_frame.convert_args self._patch.current_config = self._config_tools.config self._patch.trigger.set() - logger.trace("Refreshed swapped faces") + logger.trace("Refreshed swapped faces") # type: ignore - def _build_ui(self): + def _build_ui(self) -> None: """ Build the elements for displaying preview images and options panels. """ container = ttk.PanedWindow(self, orient=tk.VERTICAL) container.pack(fill=tk.BOTH, expand=True) - container.preview_display = self._display + setattr(container, "preview_display", self._display) # TODO subclass not setattr self._image_canvas = ImagesCanvas(container, self._tk_vars) container.add(self._image_canvas, weight=3) @@ -177,7 +194,12 @@ class Samples(): An event to indicate that a converter patch should be run """ - def __init__(self, arguments, sample_size, display, lock, trigger_patch): + def __init__(self, + arguments: "Namespace", + sample_size: int, + display: "FacesDisplay", + lock: Lock, + trigger_patch: Event) -> None: logger.debug("Initializing %s: (arguments: '%s', sample_size: %s, display: %s, lock: %s, " "trigger_patch: %s)", self.__class__.__name__, arguments, sample_size, display, lock, trigger_patch) @@ -185,8 +207,8 @@ def __init__(self, arguments, sample_size, display, lock, trigger_patch): self._display = display self._lock = lock self._trigger_patch = trigger_patch - self._input_images = [] - self._predicted_images = [] + self._input_images: List[ConvertItem] = [] + self._predicted_images: List[Tuple[ConvertItem, np.ndarray]] = [] self._images = Images(arguments) self._alignments = Alignments(arguments, @@ -212,33 +234,33 @@ def __init__(self, arguments, sample_size, display, lock, trigger_patch): logger.debug("Initialized %s", self.__class__.__name__) @property - def sample_size(self): + def sample_size(self) -> int: """ int: The number of samples to take from the input video/images """ return self._sample_size @property - def predicted_images(self): + def predicted_images(self) -> List[Tuple[ConvertItem, np.ndarray]]: """ list: The predicted faces output from the Faceswap model """ return self._predicted_images @property - def alignments(self): + def alignments(self) -> Alignments: """ :class:`~lib.align.Alignments`: The alignments for the preview faces """ return self._alignments @property - def predictor(self): + def predictor(self) -> Predict: """ :class:`~scripts.convert.Predict`: The Predictor for the Faceswap model """ return self._predictor @property - def _random_choice(self): + def _random_choice(self) -> List[int]: """ list: Random indices from the :attr:`_indices` group """ retval = [random.choice(indices) for indices in self._indices] logger.debug(retval) return retval - def _get_filelist(self): + def _get_filelist(self) -> List[str]: """ Get a list of files for the input, filtering out those frames which do not contain faces. @@ -248,7 +270,7 @@ def _get_filelist(self): A list of filenames of frames that contain faces. """ logger.debug("Filtering file list to frames with faces") - if self._images.is_video: + if isinstance(self._images.input_images, str): filelist = [f"{os.path.splitext(self._images.input_images)[0]}_{frame_no:06d}.png" for frame_no in range(1, self._images.images_found + 1)] else: @@ -266,7 +288,7 @@ def _get_filelist(self): raise FaceswapError(msg) from err return retval - def _get_indices(self): + def _get_indices(self) -> List[List[int]]: """ Get indices for each sample group. Obtain :attr:`self.sample_size` evenly sized groups of indices @@ -291,7 +313,7 @@ def _get_indices(self): for idx, pool in enumerate(retval)]) return retval - def generate(self): + def generate(self) -> None: """ Generate a sample set. Selects :attr:`sample_size` random faces. Runs them through prediction to obtain the @@ -301,7 +323,7 @@ def generate(self): self._predict() self._trigger_patch.set() - def _load_frames(self): + def _load_frames(self) -> None: """ Load a sample of random frames. * Picks a random face from each indices group. @@ -320,14 +342,14 @@ def _load_frames(self): face = self._alignments.get_faces_in_frame(filename)[0] detected_face = DetectedFace() detected_face.from_alignment(face, image=image) - self._input_images.append({"filename": filename, - "image": image, - "detected_faces": [detected_face]}) + inbound = ExtractMedia(filename=filename, image=image, detected_faces=[detected_face]) + self._input_images.append(ConvertItem(inbound=inbound)) self._display.source = self._input_images self._display.update_source = True - logger.debug("Selected frames: %s", [frame["filename"] for frame in self._input_images]) + logger.debug("Selected frames: %s", + [frame.inbound.filename for frame in self._input_images]) - def _predict(self): + def _predict(self) -> None: """ Predict from the loaded frames. With a threading lock (to prevent stacking), run the selected faces through the Faceswap @@ -340,7 +362,9 @@ def _predict(self): idx = 0 while idx < self._sample_size: logger.debug("Predicting face %s of %s", idx + 1, self._sample_size) - items = self._predictor.out_queue.get() + items: Union[Literal["EOF"], + List[Tuple[ConvertItem, + np.ndarray]]] = self._predictor.out_queue.get() if items == "EOF": logger.debug("Received EOF") break @@ -383,8 +407,15 @@ class Patch(): current_config::class:`lib.config.FaceswapConfig` The currently set configuration for the patch queue """ - def __init__(self, arguments, available_masks, samples, - display, lock, trigger, config_tools, tk_vars): + def __init__(self, + arguments: "Namespace", + available_masks: List[str], + samples: Samples, + display: "FacesDisplay", + lock: Lock, + trigger: Event, + config_tools: "ConfigTools", + tk_vars: Dict[Literal["refresh", "busy"], tk.BooleanVar]) -> None: logger.debug("Initializing %s: (arguments: '%s', available_masks: %s, samples: %s, " "display: %s, lock: %s, trigger: %s, config_tools: %s, tk_vars %s)", self.__class__.__name__, arguments, available_masks, samples, display, lock, @@ -395,7 +426,7 @@ def __init__(self, arguments, available_masks, samples, self._lock = lock self._trigger = trigger self.current_config = config_tools.config - self.converter_arguments = None # Updated converter arguments dict + self.converter_arguments: Optional[Dict[str, Any]] = None # Updated converter args dict configfile = arguments.configfile if hasattr(arguments, "configfile") else None self._converter = Converter(output_size=self._samples.predictor.output_size, @@ -420,18 +451,19 @@ def __init__(self, arguments, available_masks, samples, logger.debug("Initializing %s", self.__class__.__name__) @property - def trigger(self): + def trigger(self) -> Event: """ :class:`threading.Event`: The trigger to indicate that a patching run should commence. """ return self._trigger @property - def converter(self): + def converter(self) -> Converter: """ :class:`lib.convert.Converter`: The converter to use for patching the images. """ return self._converter @staticmethod - def _generate_converter_arguments(arguments, available_masks): + def _generate_converter_arguments(arguments: "Namespace", + available_masks: List[str]) -> "Namespace": """ Add the default converter arguments to the initial arguments. Ensure the mask selection is available. @@ -448,7 +480,7 @@ def _generate_converter_arguments(arguments, available_masks): arguments added """ valid_masks = available_masks + ["none"] - converter_arguments = ConvertArgs(None, "convert").get_optional_arguments() + converter_arguments = ConvertArgs(None, "convert").get_optional_arguments() # type: ignore for item in converter_arguments: value = item.get("default", None) # Skip options without a default value @@ -466,7 +498,12 @@ def _generate_converter_arguments(arguments, available_masks): logger.debug(arguments) return arguments - def _process(self, trigger_event, shutdown_event, patch_queue_in, samples, tk_vars): + def _process(self, + trigger_event: Event, + shutdown_event: Event, + patch_queue_in: "EventQueue", + samples: Samples, + tk_vars: Dict[Literal["refresh", "busy"], tk.BooleanVar]) -> None: """ The face patching process. Runs in a thread, and waits for an event to be set. Once triggered, runs a patching @@ -478,7 +515,7 @@ def _process(self, trigger_event, shutdown_event, patch_queue_in, samples, tk_va Set by parent process when a patching run should be executed shutdown_event :class:`threading.Event` Set by parent process if a shutdown has been requested - patch_queue_in: :class:`queue.Queue` + patch_queue_in: :class:`~lib.queue_manager.EventQueue` The input queue for the patching process samples: :class:`Samples` The Samples for display. @@ -511,7 +548,7 @@ def _process(self, trigger_event, shutdown_event, patch_queue_in, samples, tk_va logger.debug("Closed patch process thread") - def _update_converter_arguments(self): + def _update_converter_arguments(self) -> None: """ Update the converter arguments to the currently selected values. """ logger.debug("Updating Converter cli arguments") if self.converter_arguments is None: @@ -523,31 +560,35 @@ def _update_converter_arguments(self): logger.debug("Updated Converter cli arguments") @staticmethod - def _feed_swapped_faces(patch_queue_in, samples): + def _feed_swapped_faces(patch_queue_in: "EventQueue", samples: Samples) -> None: """ Feed swapped faces to the converter's in-queue. Parameters ---------- - patch_queue_in: :class:`queue.Queue` + patch_queue_in: :class:`~lib.queue_manager.EventQueue` The input queue for the patching process samples: :class:`Samples` The Samples for display. """ - logger.trace("feeding swapped faces to converter") + logger.trace("feeding swapped faces to converter") # type: ignore for item in samples.predicted_images: patch_queue_in.put(item) - logger.trace("fed %s swapped faces to converter", len(samples.predicted_images)) - logger.trace("Putting EOF to converter") + logger.trace("fed %s swapped faces to converter", # type: ignore + len(samples.predicted_images)) + logger.trace("Putting EOF to converter") # type: ignore patch_queue_in.put("EOF") - def _patch_faces(self, queue_in, queue_out, sample_size): + def _patch_faces(self, + queue_in: "EventQueue", + queue_out: "EventQueue", + sample_size: int) -> List[np.ndarray]: """ Patch faces. Run the convert process on the swapped faces and return the patched faces. - patch_queue_in: :class:`queue.Queue` + patch_queue_in: :class:`~lib.queue_manager.EventQueue` The input queue for the patching process - queue_out: :class:`queue.Queue` + queue_out: :class:`~lib.queue_manager.EventQueue` The output queue from the patching process sample_size: int The number of samples to be displayed @@ -557,20 +598,29 @@ def _patch_faces(self, queue_in, queue_out, sample_size): list The swapped faces patched with the selected convert settings """ - logger.trace("Patching faces") + logger.trace("Patching faces") # type: ignore self._converter.process(queue_in, queue_out) swapped = [] idx = 0 while idx < sample_size: - logger.trace("Patching image %s of %s", idx + 1, sample_size) + logger.trace("Patching image %s of %s", idx + 1, sample_size) # type: ignore item = queue_out.get() swapped.append(item[1]) - logger.trace("Patched image %s of %s", idx + 1, sample_size) + logger.trace("Patched image %s of %s", idx + 1, sample_size) # type: ignore idx += 1 - logger.trace("Patched faces") + logger.trace("Patched faces") # type: ignore return swapped +@dataclass +class _Faces: + """ Dataclass for holding faces """ + filenames: List[str] = field(default_factory=list) + matrix: List[np.ndarray] = field(default_factory=list) + src: List[np.ndarray] = field(default_factory=list) + dst: List[np.ndarray] = field(default_factory=list) + + class FacesDisplay(): """ Compiles the 2 rows of sample faces (original and swapped) into a single image @@ -594,40 +644,43 @@ class FacesDisplay(): The list of :class:`numpy.ndarray` swapped and patched preview images for bottom row of display """ - def __init__(self, size, padding, tk_vars): - logger.trace("Initializing %s: (size: %s, padding: %s, tk_vars: %s)", + def __init__(self, + size: int, + padding: int, + tk_vars: Dict[Literal["refresh", "busy"], tk.BooleanVar]) -> None: + logger.trace("Initializing %s: (size: %s, padding: %s, tk_vars: %s)", # type: ignore self.__class__.__name__, size, padding, tk_vars) self._size = size self._display_dims = (1, 1) self._tk_vars = tk_vars self._padding = padding - self._faces = {} - self._centering = None - self._faces_source = None - self._faces_dest = None - self._tk_image = None + self._faces = _Faces() + self._centering: Optional["CenteringType"] = None + self._faces_source: np.ndarray = np.array([]) + self._faces_dest: np.ndarray = np.array([]) + self._tk_image: Optional[PhotoImage] = None # Set from Samples self.update_source = False - self.source = [] # Source images, filenames + detected faces + self.source: List[ConvertItem] = [] # Source images, filenames + detected faces # Set from Patch - self.destination = [] # Swapped + patched images + self.destination: List[np.ndarray] = [] # Swapped + patched images - logger.trace("Initialized %s", self.__class__.__name__) + logger.trace("Initialized %s", self.__class__.__name__) # type: ignore @property - def tk_image(self): + def tk_image(self) -> Optional[PhotoImage]: """ :class:`PIL.ImageTk.PhotoImage`: The compiled preview display in tkinter display format """ return self._tk_image @property - def _total_columns(self): - """ Return the total number of images that are being displayed """ + def _total_columns(self) -> int: + """ int: The total number of images that are being displayed """ return len(self.source) - def set_centering(self, centering): + def set_centering(self, centering: "CenteringType") -> None: """ The centering that the model uses is not known at initialization time. Set :attr:`_centering` when the model has been loaded. @@ -638,7 +691,7 @@ def set_centering(self, centering): """ self._centering = centering - def set_display_dimensions(self, dimensions): + def set_display_dimensions(self, dimensions: Tuple[int, int]) -> None: """ Adjust the size of the frame that will hold the preview samples. Parameters @@ -648,20 +701,20 @@ def set_display_dimensions(self, dimensions): """ self._display_dims = dimensions - def update_tk_image(self): + def update_tk_image(self) -> None: """ Build the full preview images and compile :attr:`tk_image` for display. """ - logger.trace("Updating tk image") + logger.trace("Updating tk image") # type: ignore self._build_faces_image() img = np.vstack((self._faces_source, self._faces_dest)) size = self._get_scale_size(img) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) - img = Image.fromarray(img) - img = img.resize(size, Image.ANTIALIAS) - self._tk_image = ImageTk.PhotoImage(img) + pilimg = Image.fromarray(img) + pilimg = pilimg.resize(size, Image.ANTIALIAS) + self._tk_image = ImageTk.PhotoImage(pilimg) self._tk_vars["refresh"].set(False) - logger.trace("Updated tk image") + logger.trace("Updated tk image") # type: ignore - def _get_scale_size(self, image): + def _get_scale_size(self, image: np.ndarray) -> Tuple[int, int]: """ Get the size that the full preview image should be resized to fit in the display window. @@ -685,66 +738,64 @@ def _get_scale_size(self, image): else: scale = self._display_dims[1] / float(image.shape[0]) size = (max(1, int(image.shape[1] * scale)), self._display_dims[1]) - logger.trace("scale: %s, size: %s", scale, size) + logger.trace("scale: %s, size: %s", scale, size) # type: ignore return size - def _build_faces_image(self): + def _build_faces_image(self) -> None: """ Compile the source and destination rows of the preview image. """ - logger.trace("Building Faces Image") + logger.trace("Building Faces Image") # type: ignore update_all = self.update_source self._faces_from_frames() if update_all: header = self._header_text() - source = np.hstack([self._draw_rect(face) for face in self._faces["src"]]) + source = np.hstack([self._draw_rect(face) for face in self._faces.src]) self._faces_source = np.vstack((header, source)) - self._faces_dest = np.hstack([self._draw_rect(face) for face in self._faces["dst"]]) + self._faces_dest = np.hstack([self._draw_rect(face) for face in self._faces.dst]) logger.debug("source row shape: %s, swapped row shape: %s", self._faces_dest.shape, self._faces_source.shape) - def _faces_from_frames(self): + def _faces_from_frames(self) -> None: """ Extract the preview faces from the source frames and apply the requisite padding. """ logger.debug("Extracting faces from frames: Number images: %s", len(self.source)) if self.update_source: self._crop_source_faces() self._crop_destination_faces() logger.debug("Extracted faces from frames: %s", - {k: len(v) for k, v in self._faces.items()}) + {k: len(v) for k, v in self._faces.__dict__.items()}) - def _crop_source_faces(self): + def _crop_source_faces(self) -> None: """ Extract the source faces from the source frames, along with their filenames and the transformation matrix used to extract the faces. """ logger.debug("Updating source faces") - self._faces = {} - for image in self.source: - detected_face = image["detected_faces"][0] - src_img = image["image"] - detected_face.load_aligned(src_img, size=self._size, centering=self._centering) + self._faces = _Faces() # Init new class + for item in self.source: + detected_face = item.inbound.detected_faces[0] + src_img = item.inbound.image + detected_face.load_aligned(src_img, + size=self._size, + centering=cast("CenteringType", self._centering)) matrix = detected_face.aligned.matrix - self._faces.setdefault("filenames", - []).append(os.path.splitext(image["filename"])[0]) - self._faces.setdefault("matrix", []).append(matrix) - self._faces.setdefault("src", []).append(transform_image(src_img, - matrix, - self._size, - self._padding)) + self._faces.filenames.append(os.path.splitext(item.inbound.filename)[0]) + self._faces.matrix.append(matrix) + self._faces.src.append(transform_image(src_img, matrix, self._size, self._padding)) self.update_source = False logger.debug("Updated source faces") - def _crop_destination_faces(self): + def _crop_destination_faces(self) -> None: """ Extract the swapped faces from the swapped frames using the source face destination matrices. """ logger.debug("Updating destination faces") - self._faces["dst"] = [] - destination = self.destination if self.destination else [np.ones_like(src["image"]) + self._faces.dst = [] + destination = self.destination if self.destination else [np.ones_like(src.inbound.image) for src in self.source] for idx, image in enumerate(destination): - self._faces["dst"].append(transform_image(image, - self._faces["matrix"][idx], - self._size, - self._padding)) + self._faces.dst.append(transform_image(image, + self._faces.matrix[idx], + self._size, + self._padding)) logger.debug("Updated destination faces") - def _header_text(self): + def _header_text(self) -> np.ndarray: """ Create the header text displaying the frame name for each preview column. Returns @@ -756,7 +807,7 @@ def _header_text(self): height = self._size // 8 font = cv2.FONT_HERSHEY_SIMPLEX # Get size of placed text for positioning - text_sizes = [cv2.getTextSize(self._faces["filenames"][idx], + text_sizes = [cv2.getTextSize(self._faces.filenames[idx], font, font_scale, 1)[0] @@ -766,9 +817,9 @@ def _header_text(self): text_x = [int((self._size - text_sizes[idx][0]) / 2) + self._size * idx for idx in range(self._total_columns)] logger.debug("filenames: %s, text_sizes: %s, text_x: %s, text_y: %s", - self._faces["filenames"], text_sizes, text_x, text_y) + self._faces.filenames, text_sizes, text_x, text_y) header_box = np.ones((height, self._size * self._total_columns, 3), np.uint8) * 255 - for idx, text in enumerate(self._faces["filenames"]): + for idx, text in enumerate(self._faces.filenames): cv2.putText(header_box, text, (text_x[idx], text_y), @@ -780,7 +831,7 @@ def _header_text(self): logger.debug("header_box.shape: %s", header_box.shape) return header_box - def _draw_rect(self, image): + def _draw_rect(self, image: np.ndarray) -> np.ndarray: """ Place a white border around a given image. Parameters @@ -805,36 +856,39 @@ class ConfigTools(): tk_vars: dict Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` """ - def __init__(self): + def __init__(self) -> None: self._config = Config(None) - self.tk_vars = {} + self.tk_vars: Dict[str, Dict[str, Union[tk.BooleanVar, + tk.StringVar, + tk.IntVar, + tk.DoubleVar]]] = {} self._config_dicts = self._get_config_dicts() # Holds currently saved config @property - def config(self): + def config(self) -> Config: """ :class:`plugins.convert._config.Config` The convert configuration """ return self._config @property - def config_dicts(self): + def config_dicts(self) -> Dict[str, Any]: """ dict: The convert configuration options in dictionary form.""" return self._config_dicts @property - def sections(self): + def sections(self) -> List[str]: """ list: The sorted section names that exist within the convert Configuration options. """ return sorted(set(plugin.split(".")[0] for plugin in self._config.config.sections() if plugin.split(".")[0] != "writer")) @property - def plugins_dict(self): + def plugins_dict(self) -> Dict[str, List[str]]: """ dict: Dictionary of configuration option sections as key with a list of containing plugins as the value """ return {section: sorted([plugin.split(".")[1] for plugin in self._config.config.sections() if plugin.split(".")[0] == section]) for section in self.sections} - def update_config(self): + def update_config(self) -> None: """ Update :attr:`config` with the currently selected values from the GUI. """ for section, items in self.tk_vars.items(): for item, value in items.items(): @@ -847,11 +901,11 @@ def update_config(self): new_value = str(0) old_value = self._config.config[section][item] if new_value != old_value: - logger.trace("Updating config: %s, %s from %s to %s", + logger.trace("Updating config: %s, %s from %s to %s", # type: ignore section, item, old_value, new_value) self._config.config[section][item] = new_value - def _get_config_dicts(self): + def _get_config_dicts(self) -> Dict[str, Dict[str, Any]]: """ Obtain a custom configuration dictionary for convert configuration items in use by the preview tool formatted for control helper. @@ -861,7 +915,7 @@ def _get_config_dicts(self): Each configuration section as keys, with the values as a dict of option: :class:`lib.gui.control_helper.ControlOption` pairs. """ logger.debug("Formatting Config for GUI") - config_dicts = {} + config_dicts: Dict[str, Dict[str, Any]] = {} for section in self._config.config.sections(): if section.startswith("writer."): continue @@ -884,7 +938,7 @@ def _get_config_dicts(self): logger.debug("Formatted Config for GUI: %s", config_dicts) return config_dicts - def reset_config_to_saved(self, section=None): + def reset_config_to_saved(self, section: Optional[str] = None) -> None: """ Reset the GUI parameters to their saved values within the configuration file. Parameters @@ -905,7 +959,7 @@ def reset_config_to_saved(self, section=None): logger.debug("Setting %s - %s to saved value %s", config_section, item, val) logger.debug("Reset to saved config: %s", section) - def reset_config_to_default(self, section=None): + def reset_config_to_default(self, section: Optional[str] = None) -> None: """ Reset the GUI parameters to their default configuration values. Parameters @@ -927,7 +981,7 @@ def reset_config_to_default(self, section=None): config_section, item, default) logger.debug("Reset to default: %s", section) - def save_config(self, section=None): + def save_config(self, section: Optional[str] = None) -> None: """ Save the configuration ``.ini`` file with the currently stored values. Notes @@ -984,7 +1038,9 @@ class ImagesCanvas(ttk.Frame): # pylint:disable=too-many-ancestors tk_vars: dict Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` """ - def __init__(self, parent, tk_vars): + def __init__(self, + parent: ttk.PanedWindow, + tk_vars: Dict[Literal["refresh", "busy"], tk.BooleanVar]) -> None: logger.debug("Initializing %s: (parent: %s, tk_vars: %s)", self.__class__.__name__, parent, tk_vars) super().__init__(parent) @@ -992,7 +1048,7 @@ def __init__(self, parent, tk_vars): self._refresh_display_trigger = tk_vars["refresh"] self._refresh_display_trigger.trace("w", self._refresh_display_callback) - self._display = parent.preview_display + self._display: FacesDisplay = parent.preview_display # type: ignore self._canvas = tk.Canvas(self, bd=0, highlightthickness=0) self._canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True) self._displaycanvas = self._canvas.create_image(0, 0, @@ -1001,23 +1057,23 @@ def __init__(self, parent, tk_vars): self.bind("", self._resize) logger.debug("Initialized %s", self.__class__.__name__) - def _refresh_display_callback(self, *args): + def _refresh_display_callback(self, *args) -> None: """ Add a trace to refresh display on callback """ if not self._refresh_display_trigger.get(): return - logger.trace("Refresh display trigger received: %s", args) + logger.trace("Refresh display trigger received: %s", args) # type: ignore self._reload() - def _resize(self, event): + def _resize(self, event: tk.Event) -> None: """ Resize the image to fit the frame, maintaining aspect ratio """ - logger.trace("Resizing preview image") + logger.trace("Resizing preview image") # type: ignore framesize = (event.width, event.height) self._display.set_display_dimensions(framesize) self._reload() - def _reload(self): + def _reload(self) -> None: """ Reload the preview image """ - logger.trace("Reloading preview image") + logger.trace("Reloading preview image") # type: ignore self._display.update_tk_image() self._canvas.itemconfig(self._displaycanvas, image=self._display.tk_image) @@ -1046,8 +1102,16 @@ class ActionFrame(ttk.Frame): # pylint: disable=too-many-ancestors tk_vars: dict Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` """ - def __init__(self, parent, available_masks, has_predicted_mask, selected_color, - selected_mask_type, config_tools, patch_callback, refresh_callback, tk_vars): + def __init__(self, + parent: ttk.Frame, + available_masks: List[str], + has_predicted_mask: bool, + selected_color: str, + selected_mask_type: str, + config_tools: ConfigTools, + patch_callback: Callable[[], None], + refresh_callback: Callable[[], None], + tk_vars: Dict[Literal["refresh", "busy"], tk.BooleanVar]) -> None: logger.debug("Initializing %s: (available_masks: %s, has_predicted_mask: %s, " "selected_color: %s, selected_mask_type: %s, patch_callback: %s, " "refresh_callback: %s, tk_vars: %s)", @@ -1059,7 +1123,7 @@ def __init__(self, parent, available_masks, has_predicted_mask, selected_color, self.pack(side=tk.LEFT, anchor=tk.N, fill=tk.Y) self._options = ["color", "mask_type"] self._busy_tkvar = tk_vars["busy"] - self._tk_vars = {} + self._tk_vars: Dict[str, tk.StringVar] = {} d_locals = locals() defaults = {opt: self._format_to_display(d_locals[f"selected_{opt}"]) @@ -1071,14 +1135,14 @@ def __init__(self, parent, available_masks, has_predicted_mask, selected_color, has_predicted_mask) @property - def convert_args(self): + def convert_args(self) -> Dict[str, Any]: """ dict: Currently selected Command line arguments from the :class:`ActionFrame`. """ return {opt if opt != "color" else "color_adjustment": self._format_from_display(self._tk_vars[opt].get()) for opt in self._options} @staticmethod - def _format_from_display(var): + def _format_from_display(var: str) -> str: """ Format a variable from the display version to the command line action version. Parameters @@ -1094,7 +1158,7 @@ def _format_from_display(var): return var.replace(" ", "_").lower() @staticmethod - def _format_to_display(var): + def _format_to_display(var: str) -> str: """ Format a variable from the command line action version to the display version. Parameters ---------- @@ -1108,8 +1172,12 @@ def _format_to_display(var): """ return var.replace("_", " ").replace("-", " ").title() - def _build_frame(self, defaults, refresh_callback, patch_callback, - available_masks, has_predicted_mask): + def _build_frame(self, + defaults: Dict[str, Any], + refresh_callback: Callable[[], None], + patch_callback: Callable[[], None], + available_masks: List[str], + has_predicted_mask: bool) -> ttk.Progressbar: """ Build the :class:`ActionFrame`. Parameters @@ -1146,7 +1214,11 @@ def _build_frame(self, defaults, refresh_callback, patch_callback, logger.debug("Built Action frame") return busy_indicator - def _add_cli_choices(self, parent, defaults, available_masks, has_predicted_mask): + def _add_cli_choices(self, + parent: ttk.Frame, + defaults: Dict[str, Any], + available_masks: List[str], + has_predicted_mask: bool) -> None: """ Create :class:`lib.gui.control_helper.ControlPanel` object for the command line options. @@ -1163,7 +1235,10 @@ def _add_cli_choices(self, parent, defaults, available_masks, has_predicted_mask panel_kwargs = dict(blank_nones=False, label_width=10, style="CPanel") ControlPanel(parent, cp_options, header_text=None, **panel_kwargs) - def _get_control_panel_options(self, defaults, available_masks, has_predicted_mask): + def _get_control_panel_options(self, + defaults: Dict[str, Any], + available_masks: List[str], + has_predicted_mask: bool) -> List[ControlPanelOption]: """ Create :class:`lib.gui.control_helper.ControlPanelOption` objects for the command line options. @@ -1179,7 +1254,7 @@ def _get_control_panel_options(self, defaults, available_masks, has_predicted_ma list The list of `lib.gui.control_helper.ControlPanelOption` objects for the Action Frame """ - cp_options = [] + cp_options: List[ControlPanelOption] = [] for opt in self._options: if opt == "mask_type": choices = self._create_mask_choices(defaults, available_masks, has_predicted_mask) @@ -1196,8 +1271,11 @@ def _get_control_panel_options(self, defaults, available_masks, has_predicted_ma cp_options.append(cp_option) return cp_options - @staticmethod - def _create_mask_choices(defaults, available_masks, has_predicted_mask): + @classmethod + def _create_mask_choices(cls, + defaults: Dict[str, Any], + available_masks: List[str], + has_predicted_mask: bool) -> List[str]: """ Set the mask choices and default mask based on available masks. Parameters @@ -1225,8 +1303,10 @@ def _create_mask_choices(defaults, available_masks, has_predicted_mask): logger.debug("Final mask choices: %s", available_masks) return available_masks - @staticmethod - def _add_refresh_button(parent, refresh_callback): + @classmethod + def _add_refresh_button(cls, + parent: ttk.Frame, + refresh_callback: Callable[[], None]) -> None: """ Add a button to refresh the images. Parameters @@ -1237,7 +1317,7 @@ def _add_refresh_button(parent, refresh_callback): btn = ttk.Button(parent, text="Update Samples", command=refresh_callback) btn.pack(padx=5, pady=5, side=tk.TOP, fill=tk.X, anchor=tk.N) - def _add_patch_callback(self, patch_callback): + def _add_patch_callback(self, patch_callback: Callable[[], None]) -> None: """ Add callback to re-patch images on action option change. Parameters @@ -1248,7 +1328,7 @@ def _add_patch_callback(self, patch_callback): for tk_var in self._tk_vars.values(): tk_var.trace("w", patch_callback) - def _add_busy_indicator(self, parent): + def _add_busy_indicator(self, parent: ttk.Frame) -> ttk.Progressbar: """ Place progress bar into bottom bar to indicate when processing. Parameters @@ -1268,7 +1348,7 @@ def _add_busy_indicator(self, parent): self._busy_tkvar.trace("w", self._busy_indicator_trace) return pbar - def _busy_indicator_trace(self, *args): + def _busy_indicator_trace(self, *args) -> None: """ Show or hide busy indicator based on whether the preview is updating. Parameters @@ -1276,25 +1356,25 @@ def _busy_indicator_trace(self, *args): args: unused Required for tkinter event, but unused """ - logger.trace("Busy indicator trace: %s", args) + logger.trace("Busy indicator trace: %s", args) # type: ignore if self._busy_tkvar.get(): self._start_busy_indicator() else: self._stop_busy_indicator() - def _stop_busy_indicator(self): + def _stop_busy_indicator(self) -> None: """ Stop and hide progress bar """ logger.debug("Stopping busy indicator") self._busy_indicator.stop() self._busy_indicator.pack_forget() - def _start_busy_indicator(self): + def _start_busy_indicator(self) -> None: """ Start and display progress bar """ logger.debug("Starting busy indicator") self._busy_indicator.pack(side=tk.LEFT, padx=5, pady=(5, 10), fill=tk.X, expand=True) self._busy_indicator.start() - def _add_actions(self, parent): + def _add_actions(self, parent: ttk.Frame) -> None: """ Add Action Buttons to the :class:`ActionFrame` Parameters @@ -1344,20 +1424,23 @@ class OptionsBook(ttk.Notebook): # pylint:disable=too-many-ancestors config_tools: :class:`ConfigTools` Tools for loading and saving configuration files """ - def __init__(self, parent, config_tools, patch_callback): + def __init__(self, + parent: ttk.Frame, + config_tools: ConfigTools, + patch_callback: Callable[[], None]) -> None: logger.debug("Initializing %s: (parent: %s, config: %s)", self.__class__.__name__, parent, config_tools) super().__init__(parent) self.pack(side=tk.RIGHT, anchor=tk.N, fill=tk.BOTH, expand=True) self.config_tools = config_tools - self._tabs = {} + self._tabs: Dict[str, Dict[str, Union[ttk.Notebook, ConfigFrame]]] = {} self._build_tabs() self._build_sub_tabs() self._add_patch_callback(patch_callback) logger.debug("Initialized %s", self.__class__.__name__) - def _build_tabs(self): + def _build_tabs(self) -> None: """ Build the notebook tabs for the each configuration section. """ logger.debug("Build Tabs") for section in self.config_tools.sections: @@ -1365,7 +1448,7 @@ def _build_tabs(self): self._tabs[section] = {"tab": tab} self.add(tab, text=section.replace("_", " ").title()) - def _build_sub_tabs(self): + def _build_sub_tabs(self) -> None: """ Build the notebook sub tabs for each convert section's plugin. """ for section, plugins in self.config_tools.plugins_dict.items(): for plugin in plugins: @@ -1373,9 +1456,10 @@ def _build_sub_tabs(self): config_dict = self.config_tools.config_dicts[config_key] tab = ConfigFrame(self, config_key, config_dict) self._tabs[section][plugin] = tab - self._tabs[section]["tab"].add(tab, text=plugin.replace("_", " ").title()) + text = plugin.replace("_", " ").title() + cast(ttk.Notebook, self._tabs[section]["tab"]).add(tab, text=text) - def _add_patch_callback(self, patch_callback): + def _add_patch_callback(self, patch_callback: Callable[[], None]) -> None: """ Add callback to re-patch images on configuration option change. Parameters @@ -1401,7 +1485,10 @@ class ConfigFrame(ttk.Frame): # pylint: disable=too-many-ancestors The options for this section/plugin """ - def __init__(self, parent, config_key, options): + def __init__(self, + parent: OptionsBook, + config_key: str, + options: Dict[str, Any]): logger.debug("Initializing %s", self.__class__.__name__) super().__init__(parent) self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) @@ -1415,7 +1502,7 @@ def __init__(self, parent, config_key, options): self._build_frame(parent, config_key) logger.debug("Initialized %s", self.__class__.__name__) - def _build_frame(self, parent, config_key): + def _build_frame(self, parent: OptionsBook, config_key: str) -> None: """ Build the options frame for this command Parameters @@ -1434,14 +1521,14 @@ def _build_frame(self, parent, config_key): self._add_actions(parent, config_key) logger.debug("Added Config Frame") - def _add_frame_separator(self): + def _add_frame_separator(self) -> None: """ Add a separator between top and bottom frames. """ logger.debug("Add frame seperator") sep = ttk.Frame(self._action_frame, height=2, relief=tk.RIDGE) sep.pack(fill=tk.X, pady=5, side=tk.TOP) logger.debug("Added frame seperator") - def _add_actions(self, parent, config_key): + def _add_actions(self, parent: OptionsBook, config_key: str) -> None: """ Add Action Buttons. Parameters @@ -1471,7 +1558,7 @@ def _add_actions(self, parent, config_key): btnutl = ttk.Button(btn_frame, image=img, - command=lambda cmd=action: cmd(config_key)) + command=lambda cmd=action: cmd(config_key)) # type: ignore btnutl.pack(padx=2, side=tk.RIGHT) Tooltip(btnutl, text=text, wrap_length=200) logger.debug("Added util buttons") From 58926e9328b1406ce3604c83c271e07b0c87825a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 27 Aug 2022 00:03:03 +0100 Subject: [PATCH 707/981] Typofix: tools.preview --- tools/preview/preview.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/preview/preview.py b/tools/preview/preview.py index 5764c90f34..712b08ad3c 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -6,7 +6,7 @@ import logging import random import tkinter as tk -from tkinter import PhotoImage, ttk +from tkinter import ttk from typing import Any, Callable, cast, Dict, List, Optional, Tuple, TYPE_CHECKING, Union import os import sys @@ -659,7 +659,7 @@ def __init__(self, self._centering: Optional["CenteringType"] = None self._faces_source: np.ndarray = np.array([]) self._faces_dest: np.ndarray = np.array([]) - self._tk_image: Optional[PhotoImage] = None + self._tk_image: Optional[ImageTk.PhotoImage] = None # Set from Samples self.update_source = False @@ -670,7 +670,7 @@ def __init__(self, logger.trace("Initialized %s", self.__class__.__name__) # type: ignore @property - def tk_image(self) -> Optional[PhotoImage]: + def tk_image(self) -> Optional[ImageTk.PhotoImage]: """ :class:`PIL.ImageTk.PhotoImage`: The compiled preview display in tkinter display format """ return self._tk_image From 04b197ec5ff9e6177d4fa3059488f3e781549f85 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 27 Aug 2022 08:13:45 +0100 Subject: [PATCH 708/981] convert: reduce RAM consumption --- scripts/convert.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/scripts/convert.py b/scripts/convert.py index 6fbf7acadd..0e88303247 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -117,12 +117,8 @@ def __init__(self, arguments: "Namespace") -> None: @property def _queue_size(self) -> int: - """ int: Size of the converter queues. 16 for single process otherwise 32 """ - # TODO why do we need such big queues? - if self._args.singleprocess: - retval = 16 - else: - retval = 32 + """ int: Size of the converter queues. 2 for single process otherwise 4 """ + retval = 2 if self._args.singleprocess or self._args.jobs == 1 else 4 logger.debug(retval) return retval @@ -206,7 +202,6 @@ def _get_threads(self) -> MultiThread: :class:`lib.multithreading.MultiThread` The threads that perform the patching of swapped faces onto the output frames """ - # TODO Check if multiple threads actually speeds anything up save_queue = queue_manager.get_queue("convert_out") patch_queue = queue_manager.get_queue("patch") return MultiThread(self._converter.process, patch_queue, save_queue, From 2a7c18ac1d2da4231ab41cd2f2e29a09ea0f7baa Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 28 Aug 2022 12:25:30 +0100 Subject: [PATCH 709/981] bugfixes: - lib.training: Correct input + output size for pre-existing models - lib.align.detected_faces - fix trace logging - lib.util.debug_times --- lib/align/detected_face.py | 4 ++-- lib/training/generator.py | 8 +++---- lib/utils.py | 46 +++++++++++++++++++++++++++----------- 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index eec3ca4fbf..602609b245 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -623,7 +623,7 @@ def set_blur_and_threshold(self, The threshold amount to minimize/maximize mask values to 0 and 100. Percentage value. Default: 0 """ - logger.trace("blur_kernel: %s, blur_type: %s, blur_passes: %s, ", # type: ignore + logger.trace("blur_kernel: %s, blur_type: %s, blur_passes: %s, " # type: ignore "threshold: %s", blur_kernel, blur_type, blur_passes, threshold) if blur_type is not None: blur_kernel += 0 if blur_kernel == 0 or blur_kernel % 2 == 1 else 1 @@ -676,7 +676,7 @@ def set_sub_crop(self, slice(max(roi[0] * -1, 0), crop_size - min(crop_size, max(0, roi[2] - self.stored_size)))] - logger.trace("src_size: %s, coverage_ratio: %s, sub_crop_size: %s, ", # type: ignore + logger.trace("src_size: %s, coverage_ratio: %s, sub_crop_size: %s, " # type: ignore "sub_crop_slices: %s", roi, coverage_ratio, self._sub_crop_size, self._sub_crop_slices) diff --git a/lib/training/generator.py b/lib/training/generator.py index b688714e92..3ae9e0ebff 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -69,8 +69,8 @@ def __init__(self, self._side = side self._images = images self._batch_size = batch_size - self._process_size = max([model.input_shape[1]] + [img[1] - for img in model.output_shapes[0]]) + self._process_size = max(img[1] + for img in model.model.input_shape + model.model.output_shape) self._output_sizes = [shape[0] for shape in model.output_shapes[0] if shape[-1] != 1] self._coverage_ratio = model.coverage_ratio self._color_order = model.color_order.lower() @@ -399,7 +399,7 @@ def __init__(self, self._no_warp = model.command_line_arguments.no_warp self._warp_to_landmarks = (not self._no_warp and model.command_line_arguments.warp_to_landmarks) - self._model_input_size = model.input_shape[1] + self._model_input_size = max(img[1] for img in model.model.input_shape) if self._warp_to_landmarks: self._face_cache.pre_fill(images, side) @@ -695,6 +695,6 @@ def process_batch(self, samples = self._create_samples(images, detected_faces) logger.trace("Processed batch: (filenames: %s, side: '%s', " # type: ignore - "feed: %s, targets: %s, samples: %s)", filenames, self._side, + "feed: %s, targets: %s)", filenames, self._side, [f.shape for f in feed], [t.shape for t in samples]) return feed, samples diff --git a/lib/utils.py b/lib/utils.py index a5469fa5db..6d582bca00 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -614,11 +614,22 @@ def _write_model(self, zip_file: zipfile.ZipFile) -> None: class DebugTimes(): """ A simple tool to help debug timings. + + Parameters + ---------- + min: bool, Optional + Display minimum time in summary stats. Default: ``True`` + mean: bool, Optional + Display mean time in summary stats. Default: ``True`` + max: bool, Optional + Display maximum time in summary stats. Default: ``True`` """ - def __init__(self): + def __init__(self, + show_min: bool = True, show_mean: bool = True, show_max: bool = True) -> None: self._times: Dict[str, List[float]] = {} self._steps: Dict[str, float] = {} self._interval = 1 + self._display = dict(min=show_min, mean=show_mean, max=show_max) def step_start(self, name: str, record: bool = True) -> None: """ Start the timer for the given step name. @@ -687,19 +698,28 @@ def summary(self, decimal_places: int = 6, interval: int = 1) -> None: name_col = max(len(key) for key in self._times) + 4 items_col = 8 - time_col = decimal_places + 4 + time_col = (decimal_places + 4) * sum(1 for v in self._display.values() if v) + separator = "-" * (name_col + items_col + time_col) print("") - print("-" * (name_col + items_col + (3 * time_col))) - print(f"{self._format_column('Step', name_col)}{self._format_column('Count', items_col)}" - f"{self._format_column('Min', time_col)}{self._format_column('Avg', time_col)}" - f"{self._format_column('Max', time_col)}") - print("-" * (name_col + items_col + (3 * time_col))) + print(separator) + header = (f"{self._format_column('Step', name_col)}" + f"{self._format_column('Count', items_col)}") + header += f"{self._format_column('Min', time_col)}" if self._display["min"] else "" + header += f"{self._format_column('Avg', time_col)}" if self._display["mean"] else "" + header += f"{self._format_column('Max', time_col)}" if self._display["max"] else "" + print(header) + print(separator) for key, val in self._times.items(): - _min = f"{np.min(val):.{decimal_places}f}" - avg = f"{np.mean(val):.{decimal_places}f}" - _max = f"{np.max(val):.{decimal_places}f}" num = str(len(val)) - print(f"{self._format_column(key, name_col)}{self._format_column(num, items_col)}" - f"{self._format_column(_min, time_col)}{self._format_column(avg, time_col)}" - f"{self._format_column(_max, time_col)}") + contents = f"{self._format_column(key, name_col)}{self._format_column(num, items_col)}" + if self._display["min"]: + _min = f"{np.min(val):.{decimal_places}f}" + contents += f"{self._format_column(_min, time_col)}" + if self._display["mean"]: + avg = f"{np.mean(val):.{decimal_places}f}" + contents += f"{self._format_column(avg, time_col)}" + if self._display["max"]: + _max = f"{np.max(val):.{decimal_places}f}" + contents += f"{self._format_column(_max, time_col)}" + print(contents) self._interval = 1 From 05077265d7032a8329281997d695ede3ad9ca5f9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 28 Aug 2022 16:41:52 +0100 Subject: [PATCH 710/981] utils.debug_time - thread support --- lib/utils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/utils.py b/lib/utils.py index 6d582bca00..116a03f4f1 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -13,6 +13,7 @@ from re import finditer from multiprocessing import current_process from socket import timeout as socket_timeout, error as socket_error +from threading import get_ident from time import time from typing import cast, Dict, List, Optional, Union, TYPE_CHECKING @@ -645,7 +646,8 @@ def step_start(self, name: str, record: bool = True) -> None: """ if not record: return - self._steps[name] = time() + storename = name + str(get_ident()) + self._steps[storename] = time() def step_end(self, name: str, record: bool = True) -> None: """ Stop the timer and record elapsed time for the given step name. @@ -661,7 +663,8 @@ def step_end(self, name: str, record: bool = True) -> None: """ if not record: return - self._times.setdefault(name, []).append(time() - self._steps.pop(name)) + storename = name + str(get_ident()) + self._times.setdefault(name, []).append(time() - self._steps.pop(storename)) @classmethod def _format_column(cls, text: str, width: int) -> str: From f3b88d5626fb407fb3db2d480f1c2d71a71ae5bf Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 29 Aug 2022 01:04:40 +0100 Subject: [PATCH 711/981] bugfix: Get correct output size for learn mask --- lib/training/generator.py | 9 +++++---- plugins/train/model/_base/model.py | 21 +++++++++++++-------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/lib/training/generator.py b/lib/training/generator.py index 3ae9e0ebff..966c886ddb 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -69,9 +69,10 @@ def __init__(self, self._side = side self._images = images self._batch_size = batch_size - self._process_size = max(img[1] - for img in model.model.input_shape + model.model.output_shape) - self._output_sizes = [shape[0] for shape in model.output_shapes[0] if shape[-1] != 1] + + self._process_size = max(img[1] for img in model.input_shapes + model.output_shapes) + self._output_sizes = [shape[1] for shape in model.output_shapes if shape[-1] != 1] + self._coverage_ratio = model.coverage_ratio self._color_order = model.color_order.lower() self._use_mask = self._config["mask_type"] and (self._config["penalized_mask_loss"] or @@ -399,7 +400,7 @@ def __init__(self, self._no_warp = model.command_line_arguments.no_warp self._warp_to_landmarks = (not self._no_warp and model.command_line_arguments.warp_to_landmarks) - self._model_input_size = max(img[1] for img in model.model.input_shape) + self._model_input_size = max(img[1] for img in model.input_shapes) if self._warp_to_landmarks: self._face_cache.pre_fill(images, side) diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index c7bab46641..1f890b8675 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -10,7 +10,7 @@ import time from collections import OrderedDict -from typing import Dict, List, Optional, Tuple, TYPE_CHECKING, Union +from typing import cast, Dict, List, Optional, Tuple, TYPE_CHECKING, Union import numpy as np @@ -204,13 +204,18 @@ def model_name(self) -> str: return self.name @property - def output_shapes(self) -> List[List[Tuple[int, int, int]]]: - """ list: A list of list of shape tuples for the outputs of the model with the batch - dimension removed. The outer list contains 2 sub-lists (one for each side "a" and "b"). - The inner sub-lists contain the output shapes for that side. """ - shapes: List[Tuple[int, int, int]] = [tuple(K.int_shape(output)[-3:]) # type: ignore - for output in self.model.outputs] - return [shapes[:len(shapes) // 2], shapes[len(shapes) // 2:]] + def input_shapes(self) -> List[Tuple[None, int, int, int]]: + """ list: A flattened list corresponding to all of the inputs to the model. """ + shapes = [cast(Tuple[None, int, int, int], K.int_shape(inputs)) + for inputs in self.model.inputs] + return shapes + + @property + def output_shapes(self) -> List[Tuple[None, int, int, int]]: + """ list: A flattened list corresponding to all of the outputs of the model. """ + shapes = [cast(Tuple[None, int, int, int], K.int_shape(output)) + for output in self.model.outputs] + return shapes @property def iterations(self) -> int: From fe8e34f99ef95b8d18cacfde0329731f6f87d2b3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 29 Aug 2022 01:48:20 +0100 Subject: [PATCH 712/981] bugfix: Generator for AMD --- lib/training/generator.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/training/generator.py b/lib/training/generator.py index 966c886ddb..2429f39126 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -71,7 +71,7 @@ def __init__(self, self._batch_size = batch_size self._process_size = max(img[1] for img in model.input_shapes + model.output_shapes) - self._output_sizes = [shape[1] for shape in model.output_shapes if shape[-1] != 1] + self._output_sizes = self._get_output_sizes(model) self._coverage_ratio = model.coverage_ratio self._color_order = model.color_order.lower() @@ -103,6 +103,27 @@ def _total_channels(self) -> int: channels += len(mults) return channels + def _get_output_sizes(self, model: "ModelBase") -> List[int]: + """ Obtain the size of each output tensor for the model. + + Parameters + ---------- + model: :class:`~plugins.train.model.ModelBase` + The model that this data generator is feeding + + Returns + ------- + list + A list of integers for the model output size for the current side + """ + out_shapes = model.output_shapes + split = len(out_shapes) // 2 + side_out = out_shapes[:split] if self._side == "a" else out_shapes[split:] + retval = [shape[1] for shape in side_out if shape[-1] != 1] + logger.debug("side: %s, model output shapes: %s, output sizes: %s", + self._side, model.output_shapes, retval) + return retval + def minibatch_ab(self, do_shuffle: bool = True) -> Generator[BatchType, None, None]: """ A Background iterator to return augmented images, samples and targets. @@ -313,7 +334,6 @@ def _process_batch(self, filenames: List[str]) -> BatchType: batch = self._buffer() self._crop_to_coverage(filenames, raw_faces, detected_faces, batch) self._apply_mask(detected_faces, batch) - return self.process_batch(filenames, raw_faces, detected_faces, batch) def process_batch(self, From de1068b417a07b9188ed4687a0fa335cbf5493c7 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 30 Aug 2022 12:39:39 +0100 Subject: [PATCH 713/981] Bugfix: Preview - show predictions at correct size --- lib/training/generator.py | 25 ++++++++++++------------- plugins/train/trainer/_base.py | 10 +++++----- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/lib/training/generator.py b/lib/training/generator.py index 2429f39126..bda8a1315f 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -72,6 +72,7 @@ def __init__(self, self._process_size = max(img[1] for img in model.input_shapes + model.output_shapes) self._output_sizes = self._get_output_sizes(model) + self._model_input_size = max(img[1] for img in model.input_shapes) self._coverage_ratio = model.coverage_ratio self._color_order = model.color_order.lower() @@ -323,7 +324,7 @@ def _process_batch(self, filenames: List[str]) -> BatchType: Returns ------- - list + :class:`numpy.ndarray` 4-dimensional array of faces to feed the training the model. list List of 4-dimensional :class:`numpy.ndarray`. The number of channels here will vary. @@ -334,7 +335,13 @@ def _process_batch(self, filenames: List[str]) -> BatchType: batch = self._buffer() self._crop_to_coverage(filenames, raw_faces, detected_faces, batch) self._apply_mask(detected_faces, batch) - return self.process_batch(filenames, raw_faces, detected_faces, batch) + feed, targets = self.process_batch(filenames, raw_faces, detected_faces, batch) + + logger.trace("Processed %s batch side %s. (filenames: %s, feed: %s, " # type: ignore + "targets: %s)", self.__class__.__name__, self._side, filenames, + feed.shape, [t.shape for t in targets]) + + return feed, targets def process_batch(self, filenames: List[str], @@ -420,7 +427,6 @@ def __init__(self, self._no_warp = model.command_line_arguments.no_warp self._warp_to_landmarks = (not self._no_warp and model.command_line_arguments.warp_to_landmarks) - self._model_input_size = max(img[1] for img in model.input_shapes) if self._warp_to_landmarks: self._face_cache.pre_fill(images, side) @@ -481,7 +487,7 @@ def process_batch(self, Returns ------- - feed: list + feed: :class:`numpy.ndarray` 4-dimensional array of faces to feed the training the model (:attr:`x` parameter for :func:`keras.models.model.train_on_batch`.). The array returned is in the format (`batch size`, `height`, `width`, `channels`). @@ -536,10 +542,6 @@ def process_batch(self, else: feed = self._to_float32(warped) - logger.trace("Processed batch: (filenames: %s, side: '%s', " # type: ignore - "feed: %s, targets: %s)", filenames, self._side, - [f.shape for f in feed], [t.shape for t in targets]) - return feed, targets def _get_closest_match(self, filenames: List[str], batch_src_points: np.ndarray) -> np.ndarray: @@ -692,7 +694,7 @@ def process_batch(self, Returns ------- - feed: list + feed: :class:`numpy.ndarray` List of 4-dimensional :class:`numpy.ndarray` objects at model input size for feeding the model's predict function. The first 3 channels are (rgb/bgr). The 4th channel is the face mask. @@ -711,11 +713,8 @@ def process_batch(self, mask = np.zeros_like(batch[..., 0])[..., None] + 255 batch = np.concatenate([batch, mask], axis=-1) - feed = self._to_float32(batch[..., :4]) + feed = self._to_float32(batch[..., :4]) # Don't resize here: we want masks at output res. samples = self._create_samples(images, detected_faces) - logger.trace("Processed batch: (filenames: %s, side: '%s', " # type: ignore - "feed: %s, targets: %s)", filenames, self._side, - [f.shape for f in feed], [t.shape for t in samples]) return feed, samples diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 26ceed7348..508928df2a 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -632,12 +632,12 @@ def show_sample(self) -> np.ndarray: logger.debug("Showing sample") feeds: Dict[Literal["a", "b"], np.ndarray] = {} for idx, side in enumerate(get_args(Literal["a", "b"])): + feed = self.images[side][0] input_shape = self._model.model.input_shape[idx][1:] - if input_shape[0] / self.images[side][0].shape[1] != 1.0: - feeds[side] = self._resize_sample(side, self.images[side][1], input_shape[0]) - feeds[side] = feeds[side].reshape((-1, ) + input_shape) + if input_shape[0] / feed.shape[1] != 1.0: + feeds[side] = self._resize_sample(side, feed, input_shape[0]) else: - feeds[side] = self.images[side][0] + feeds[side] = feed preds = self._get_predictions(feeds["a"], feeds["b"]) return self._compile_preview(preds) @@ -787,7 +787,7 @@ def _to_full_frame(self, predictions = [pred[..., ::-1] if pred.shape[-1] == 3 else pred for pred in predictions] - full = self._process_full(side, full, predictions[0].shape[1], (0, 0, 255)) + full = self._process_full(side, full, predictions[0].shape[1], (0., 0., 1.0)) images = [faces] + predictions if self._display_mask: images = self._compile_masked(images, samples[-1]) From 97da6250ffdb04dd870889561a8dc36b8ef24c19 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 30 Aug 2022 12:39:59 +0100 Subject: [PATCH 714/981] typing fix --- plugins/train/trainer/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 508928df2a..8846a5a224 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -799,7 +799,7 @@ def _process_full(self, side: Literal["a", "b"], images: np.ndarray, prediction_size: int, - color: Tuple[int, int, int]) -> np.ndarray: + color: Tuple[float, float, float]) -> np.ndarray: """ Add a frame overlay to preview images indicating the region of interest. This applies the red border that appears in the preview images. From 13cfb3f39e72e9ca181f173b7b3db2a048db0d08 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 31 Aug 2022 19:48:47 +0100 Subject: [PATCH 715/981] extract: Add batch processing mode --- lib/cli/args.py | 9 ++ locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 43200 -> 44879 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 191 ++++++++++++++----------- locales/lib.cli.args.pot | 171 +++++++++++----------- locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 56046 -> 58532 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 190 +++++++++++++----------- plugins/extract/_base.py | 10 +- plugins/extract/pipeline.py | 188 ++++++++++++++---------- plugins/plugin_loader.py | 42 ++++-- scripts/extract.py | 145 ++++++++++++++++--- 10 files changed, 585 insertions(+), 361 deletions(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index 8c0a501729..90a3ee3ff1 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -377,6 +377,15 @@ def get_optional_arguments() -> List[Dict[str, Any]]: default_aligner = "fan" argument_list: List[Dict[str, Any]] = [] + argument_list.append(dict( + opts=("-b", "--batch-mode"), + action="store_true", + dest="batch_mode", + default=False, + group=_("Data"), + help=_("R|If selected then the input_dir should be a parent folder containing " + "multiple videos and/or folders of images you wish to extract from. The faces " + "will be output to separate sub-folders in the output_dir."))) argument_list.append(dict( opts=("-D", "--detector"), action=Radio, diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo index 7d2e75ab8fcc0841d32e254c81d74e7d5c557c6f..56e1b25d4c530e92cb00936573a9668b3dc5f414 100644 GIT binary patch delta 3418 zcmZvdeUMdU8NeS_19nph&=oQ0WwB7%4;BRhMO_yV5M*J2Y$f&GbKbl6=(*=y&N+9N zCE<>w&6;_z>s*4&P+F{c@2Ky!Hx_X(DymM`whIr{O!Wh1rk5 zKf+E9{sVTy>!*vbMFwVw{2K;vKlA08B0pgK=PO0N3BP@n$k$(W&%phR=g$(k zAHD?dV1Dv7BH!SCSu>kUSa=uqvhen`1zh+#$QDkKkdlrhE;(mF&L*yjf%IQHC=5>jDjsuT( z<905(3_rek30AKVScF z1>4r)|9j~Kbnb#bf{(#}!XI++9?U%qZ{7fz|IJ3?33qcyxpY&J6Mu!zGj85oWQPp3wbp50rk40ehjQ*HiuYUpohwPz#=gQ{eNEJd+n-Gkm!*{xKXtp`XDcaLzW7 zP4K7Nv5I}u?<+oh7OFw#;B#>Kr$kH=Qn(;V%lJ)&V zB41%VtsWFEI|_HOFymq3#6tXth+#ZCBx~WVm4eSkg)9FSi#)*m&Z-DumS63pv>4Ay zC|Nz9lA!RRjPSt|a1_1^sYKaP<1SoVFVp!G6Q9{j!ZnFFJn{#|?>#2+b9j0m8Z-ag zXDKTVwCyjF@bxcYHA#5(0G?v~lV2sg#PZzNMDAm}9`|V|k9ko)p>J-_O1P3n&$2H{zedyp2S1ECo5rs2{1Wi_%H;oZsG)%@}{&6Q9dhj zCtQnMf@q@Uo`H8D+Ab>~T@B7{@P-CEp?Yr(vJp`a-iS0>U2dnx!Fr>@9sz`6&HZxKV!Zb5+)$th=!as>Kj7o7*!Ru`8Hn^)2GabgB4b9zs z+fsE&kQ&cMrtHG1N$oHWh%3%)-_om_lTPGrmZw)S92byO`;t17YYLQNxM$%P36)Nx^K z{ai84A?uaZS>#MsHiI_v%I!G+g4@sAh0pR6LDmSx*vXaG&768ux9MKKV)3G}_xoPy ztoH}lS=5AwOKkgj)!;kwG9~$IktH%o)n$|gUiN}2VcS{LfJB*#hHS1iwa_Su(XRM| z1Da(N#Q32h7pbzCWYhet?^3tc7M0B~k~C+jU8c!)Fu4 zdww+>tN&!i!X--=n=Z4YebJ&tEn5>CrD2|fCUGehkhW}jv|p=VEj{?<7w65jhN20B z-8Q+PP#Tj554>pVRpRdOU?qsSR`s7Kq5oSVjhf-3xup}~MB&ipHBt%kGAi7l?#Qb} zwZcOfpE#>ZMc7!^YB9Jk%_E;Dk)v8xlkcWlLpNhbYrakJlP$+dqP=h^Rz-`-$%`ge z+zVVn1cx1834E*RM*j0#;U$VVc(JbJT0O5F`@glrL6r64N);FL2xuy(l!BoeFI`~^ zN>s*QEuPEVfD5as$FGo_hU8a&phE5#bZ^vC;Ky~7j-QAqCA}{yG`z?a{oHL6axhQR zxEiRjVNq4F(U(xuMcsUXeV&F>t4S3Pc^@V!iFsanHHv$maR72R$+No&i<$I*>VKST+BYlOxs<#vclln+`Fa^G6es~ym z!6~q@ue6x&I^eh1>HVb|_$Q2$LaL6HUd6EjUVy*DczBZ0bUr)_GocbBhLq)(?!ZF0 z0e?ZFbPW5uBxwhHbAZ$gQ|s(6P8rUIVI7pOpeQuET{eafRcmS{KS6pW{#PmlAKX zq}G*$75f^zX5YUckrm~;vgB{3kwg)t%HVeZg=VY=j-qitecv_onQH^RRC~ zvZm7Q?i%iZC3gRB(b>&Fkjs0X6LrHo@S9KFgVFw(+hD^k=_d15GTBnGY!7b)_LqAl zf~ME@NgPgHa4W3XFR^!ZL#wgXt*%zqz-a7s9yx6Todg`4piSHftq5CTe|QA4p63Hz zn0X4kMuB?;HY{Qj7)?NNC=*$2^+DDwDqI+^uFRf<9%OxuGK*$;OGh&iSJ``#f&9qs zwY3ryKs;XF^E()hKx2?KyJr7w4@HcjklU=0=-!CW5VDgOqv2-JcypBM_5S~pjL8e? zz3EBNLKut2qaG*) Configure Extract 'Plugins':\n" @@ -119,7 +129,7 @@ msgstr "" "detectar más caras y tiene menos falsos positivos que otros detectores " "basados en GPU, pero uso muchos más recursos." -#: lib/cli/args.py:403 +#: lib/cli/args.py:412 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " @@ -131,7 +141,7 @@ msgstr "" "pero es menos preciso. Elegir este si necesita rapidez y no usar la GPU.\n" "L|fan: El mejor alineador. Rápido en la GPU, y lento en la CPU." -#: lib/cli/args.py:415 +#: lib/cli/args.py:424 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -201,7 +211,7 @@ msgstr "" "referencia y la máscara se extiende hacia arriba en la frente.\n" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -#: lib/cli/args.py:454 +#: lib/cli/args.py:463 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -224,7 +234,7 @@ msgstr "" "L|hist: Iguala los histogramas de los canales RGB.\n" "L|mean: Normalizar los colores de la cara a la media." -#: lib/cli/args.py:472 +#: lib/cli/args.py:481 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -241,7 +251,7 @@ msgstr "" "más veces se vuelva a introducir la cara en el alineador, menos " "microfluctuaciones se producirán, pero la extracción será más larga." -#: lib/cli/args.py:484 +#: lib/cli/args.py:493 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -253,13 +263,13 @@ msgstr "" "un solo número para usar incrementos de ese tamaño hasta 360, o pase una " "lista de números para enumerar exactamente qué ángulos comprobar." -#: lib/cli/args.py:496 lib/cli/args.py:506 lib/cli/args.py:519 -#: lib/cli/args.py:533 lib/cli/args.py:776 lib/cli/args.py:790 -#: lib/cli/args.py:803 lib/cli/args.py:817 +#: lib/cli/args.py:505 lib/cli/args.py:515 lib/cli/args.py:528 +#: lib/cli/args.py:542 lib/cli/args.py:785 lib/cli/args.py:799 +#: lib/cli/args.py:812 lib/cli/args.py:826 msgid "Face Processing" msgstr "Proceso de Caras" -#: lib/cli/args.py:497 +#: lib/cli/args.py:506 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -268,7 +278,7 @@ msgstr "" "a lo largo de la diagonal del cuadro delimitador. Establecer a 0 para " "desactivar" -#: lib/cli/args.py:507 lib/cli/args.py:791 +#: lib/cli/args.py:516 lib/cli/args.py:800 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -282,7 +292,7 @@ msgstr "" "uso del filtro de caras disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args.py:520 lib/cli/args.py:804 +#: lib/cli/args.py:529 lib/cli/args.py:813 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -296,7 +306,7 @@ msgstr "" "del filtro facial disminuirá significativamente la velocidad de extracción y " "no se puede garantizar su precisión." -#: lib/cli/args.py:534 lib/cli/args.py:818 +#: lib/cli/args.py:543 lib/cli/args.py:827 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -308,12 +318,12 @@ msgstr "" "NB: El uso del filtro facial disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args.py:545 lib/cli/args.py:557 lib/cli/args.py:569 -#: lib/cli/args.py:581 +#: lib/cli/args.py:554 lib/cli/args.py:566 lib/cli/args.py:578 +#: lib/cli/args.py:590 msgid "output" msgstr "salida" -#: lib/cli/args.py:546 +#: lib/cli/args.py:555 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -323,7 +333,7 @@ msgstr "" "pretende entrenar admite el tamaño deseado. Esto sólo tendrá que ser " "cambiado para los modelos de alta resolución." -#: lib/cli/args.py:558 +#: lib/cli/args.py:567 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -333,7 +343,7 @@ msgstr "" "extraer las caras. Por ejemplo, un valor de 1 extraerá las caras de cada " "fotograma, un valor de 10 extraerá las caras de cada 10 fotogramas." -#: lib/cli/args.py:570 +#: lib/cli/args.py:579 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -349,18 +359,18 @@ msgstr "" "ADVERTENCIA: No interrumpa el script al escribir el archivo porque podría " "corromperse. Poner a 0 para desactivar" -#: lib/cli/args.py:582 +#: lib/cli/args.py:591 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" "Dibujar puntos de referencia en las caras de salida para fines de depuración." -#: lib/cli/args.py:588 lib/cli/args.py:597 lib/cli/args.py:605 -#: lib/cli/args.py:612 lib/cli/args.py:830 lib/cli/args.py:841 -#: lib/cli/args.py:849 lib/cli/args.py:868 lib/cli/args.py:874 +#: lib/cli/args.py:597 lib/cli/args.py:606 lib/cli/args.py:614 +#: lib/cli/args.py:621 lib/cli/args.py:839 lib/cli/args.py:850 +#: lib/cli/args.py:858 lib/cli/args.py:877 lib/cli/args.py:883 msgid "settings" msgstr "ajustes" -#: lib/cli/args.py:589 +#: lib/cli/args.py:598 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " @@ -370,7 +380,7 @@ msgstr "" "extracción por separado (una tras otra) en lugar de hacerlo todo al mismo " "tiempo. Útil si la VRAM es escasa." -#: lib/cli/args.py:598 +#: lib/cli/args.py:607 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -378,19 +388,19 @@ msgstr "" "Omite los fotogramas que ya han sido extraídos y que existen en el archivo " "de alineaciones" -#: lib/cli/args.py:606 +#: lib/cli/args.py:615 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" "Omitir los fotogramas que ya tienen caras detectadas en el archivo de " "alineaciones" -#: lib/cli/args.py:613 +#: lib/cli/args.py:622 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "No guardar las caras detectadas en el disco. Crear sólo un archivo de " "alineaciones" -#: lib/cli/args.py:635 +#: lib/cli/args.py:644 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -400,7 +410,7 @@ msgstr "" "Los plugins de conversión pueden ser configurados en el menú " "\"Configuración\"" -#: lib/cli/args.py:656 +#: lib/cli/args.py:665 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -410,7 +420,7 @@ msgstr "" "original del que se extrajeron los fotogramas de origen (para extraer los " "fps y el audio)." -#: lib/cli/args.py:665 +#: lib/cli/args.py:674 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -418,7 +428,7 @@ msgstr "" "Directorio del modelo. El directorio que contiene el modelo entrenado que " "desea utilizar para la conversión." -#: lib/cli/args.py:675 +#: lib/cli/args.py:684 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -458,7 +468,7 @@ msgstr "" "colores. Generalmente no da resultados muy satisfactorios.\n" "L|none: No realice el ajuste de color." -#: lib/cli/args.py:702 +#: lib/cli/args.py:711 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -534,7 +544,7 @@ msgstr "" "L|predicted: Si la opción 'Learn Mask' se habilitó durante el entrenamiento, " "esto usará la máscara que fue creada por el modelo entrenado." -#: lib/cli/args.py:740 +#: lib/cli/args.py:749 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -560,11 +570,11 @@ msgstr "" "L|pillow: [images] Más lento que opencv, pero tiene más opciones y soporta " "más formatos." -#: lib/cli/args.py:759 lib/cli/args.py:766 lib/cli/args.py:860 +#: lib/cli/args.py:768 lib/cli/args.py:775 lib/cli/args.py:869 msgid "Frame Processing" msgstr "Proceso de fotogramas" -#: lib/cli/args.py:760 +#: lib/cli/args.py:769 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -574,7 +584,7 @@ msgstr "" "a los fotogramas a las dimensiones de origen. 50%% a la mitad de tamaño. " "200%% al doble de tamaño" -#: lib/cli/args.py:767 +#: lib/cli/args.py:776 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -588,7 +598,7 @@ msgstr "" "imágenes, ¡los nombres de los archivos deben terminar con el número de " "fotograma!" -#: lib/cli/args.py:777 +#: lib/cli/args.py:786 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -604,7 +614,7 @@ msgstr "" "especificada. Si se deja en blanco, se convertirán todas las caras que " "existan en el archivo de alineaciones." -#: lib/cli/args.py:831 +#: lib/cli/args.py:840 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -621,7 +631,7 @@ msgstr "" "procesos que los disponibles en su sistema. Si 'singleprocess' está " "habilitado, este ajuste será ignorado." -#: lib/cli/args.py:842 +#: lib/cli/args.py:851 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -629,7 +639,7 @@ msgstr "" "[LEGACY] Sólo es necesario seleccionar esta opción si se está cargando un " "modelo heredado si hay varios modelos en la carpeta de modelos" -#: lib/cli/args.py:850 +#: lib/cli/args.py:859 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -644,7 +654,7 @@ msgstr "" "de baja calidad. Si se encuentra un archivo de alineaciones, esta opción " "será ignorada." -#: lib/cli/args.py:861 +#: lib/cli/args.py:870 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -652,16 +662,16 @@ msgstr "" "Cuando se usa con --frame-ranges, la salida incluye los fotogramas no " "procesados en vez de descartarlos." -#: lib/cli/args.py:869 +#: lib/cli/args.py:878 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Intercambiar el modelo. En vez de convertir de A a B, convierte de B a A" -#: lib/cli/args.py:875 +#: lib/cli/args.py:884 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Desactiva el multiproceso. Es más lento, pero usa menos recursos." -#: lib/cli/args.py:891 +#: lib/cli/args.py:900 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -673,11 +683,11 @@ msgstr "" "hasta más de una semana.\n" "Los plugins de los modelos pueden configurarse en el menú \"Ajustes\"" -#: lib/cli/args.py:910 lib/cli/args.py:919 +#: lib/cli/args.py:919 lib/cli/args.py:928 msgid "faces" msgstr "caras" -#: lib/cli/args.py:911 +#: lib/cli/args.py:920 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -687,7 +697,7 @@ msgstr "" "para la cara A. Esta es la cara original, es decir, la cara que se quiere " "eliminar y sustituir por la cara B." -#: lib/cli/args.py:920 +#: lib/cli/args.py:929 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -697,12 +707,12 @@ msgstr "" "para la cara B. Esta es la cara de intercambio, es decir, la cara que se " "quiere colocar en la cabeza de la persona A." -#: lib/cli/args.py:928 lib/cli/args.py:940 lib/cli/args.py:956 -#: lib/cli/args.py:981 lib/cli/args.py:991 +#: lib/cli/args.py:937 lib/cli/args.py:949 lib/cli/args.py:965 +#: lib/cli/args.py:990 lib/cli/args.py:1000 msgid "model" msgstr "modelo" -#: lib/cli/args.py:929 +#: lib/cli/args.py:938 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -716,7 +726,7 @@ msgstr "" "carpeta que no exista (que se creará). Si continúa entrenando un modelo " "existente, especifique la ubicación del modelo existente." -#: lib/cli/args.py:941 +#: lib/cli/args.py:950 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -740,7 +750,7 @@ msgstr "" "NB: Los pesos solo se pueden cargar desde modelos del mismo complemento que " "desea entrenar." -#: lib/cli/args.py:957 +#: lib/cli/args.py:966 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -785,7 +795,7 @@ msgstr "" "recursos (se necesita una GPU con una buena cantidad de VRAM). Bueno para " "los detalles, pero más susceptible a las diferencias de color." -#: lib/cli/args.py:982 +#: lib/cli/args.py:991 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -797,7 +807,7 @@ msgstr "" "muestra un resumen del modelo que crearía el complemento elegido y los " "ajustes de configuración." -#: lib/cli/args.py:992 +#: lib/cli/args.py:1001 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -811,12 +821,12 @@ msgstr "" "congelará el codificador, pero algunos modelos pueden tener opciones de " "configuración para congelar otras capas." -#: lib/cli/args.py:1005 lib/cli/args.py:1017 lib/cli/args.py:1028 -#: lib/cli/args.py:1039 lib/cli/args.py:1122 +#: lib/cli/args.py:1014 lib/cli/args.py:1026 lib/cli/args.py:1037 +#: lib/cli/args.py:1048 lib/cli/args.py:1131 msgid "training" msgstr "entrenamiento" -#: lib/cli/args.py:1006 +#: lib/cli/args.py:1015 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -829,7 +839,7 @@ msgstr "" "momento es el doble del número que se establece aquí. Los lotes más grandes " "requieren más RAM de la GPU." -#: lib/cli/args.py:1018 +#: lib/cli/args.py:1027 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -844,7 +854,7 @@ msgstr "" "automáticamente en un número determinado de iteraciones, puede establecer " "ese valor aquí." -#: lib/cli/args.py:1029 +#: lib/cli/args.py:1038 msgid "" "[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " "Mirrored Distrubution Strategy to train on multiple GPUs." @@ -852,7 +862,7 @@ msgstr "" "[Obsoleto: use '-D, --distribution-strategy' en su lugar] Use la estrategia " "de distribución duplicada de Tensorflow para entrenar en varias GPU." -#: lib/cli/args.py:1040 +#: lib/cli/args.py:1049 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -864,16 +874,29 @@ msgid "" "GPUs. A copy of the model and all variables are loaded onto each GPU with " "batches distributed to each GPU at each iteration." msgstr "" - -#: lib/cli/args.py:1057 lib/cli/args.py:1067 +"562 / 5,000\n" +"Translation results\n" +"R|Seleccione la estrategia de distribución a utilizar.\n" +"L|default: utiliza la estrategia de distribución predeterminada de " +"Tensorflow.\n" +"L|central-storage: centraliza las variables en la CPU mientras que las " +"operaciones se realizan en 1 o más GPU locales. Esto puede ayudar a ahorrar " +"algo de VRAM a costa de cierta velocidad al no almacenar variables en la " +"GPU. Nota: Mixed-Precision no es compatible con configuraciones de múltiples " +"GPU.\n" +"L|mirrored: Admite el entrenamiento distribuido síncrono en varias GPU " +"locales. Se carga una copia del modelo y todas las variables en cada GPU con " +"lotes distribuidos a cada GPU en cada iteración." + +#: lib/cli/args.py:1066 lib/cli/args.py:1076 msgid "Saving" msgstr "Guardar" -#: lib/cli/args.py:1058 +#: lib/cli/args.py:1067 msgid "Sets the number of iterations between each model save." msgstr "Establece el número de iteraciones entre cada guardado del modelo." -#: lib/cli/args.py:1068 +#: lib/cli/args.py:1077 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -881,11 +904,11 @@ msgstr "" "Establece el número de iteraciones antes de guardar una copia de seguridad " "del modelo en su estado actual. Establece 0 para que esté desactivado." -#: lib/cli/args.py:1075 lib/cli/args.py:1086 lib/cli/args.py:1097 +#: lib/cli/args.py:1084 lib/cli/args.py:1095 lib/cli/args.py:1106 msgid "timelapse" msgstr "intervalo" -#: lib/cli/args.py:1076 +#: lib/cli/args.py:1085 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -899,7 +922,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-B." -#: lib/cli/args.py:1087 +#: lib/cli/args.py:1096 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -913,7 +936,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-A." -#: lib/cli/args.py:1098 +#: lib/cli/args.py:1107 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -925,17 +948,17 @@ msgstr "" "Si se suministran las carpetas de entrada pero no la carpeta de salida, se " "guardará por defecto en la carpeta del modelo /timelapse/" -#: lib/cli/args.py:1107 lib/cli/args.py:1114 +#: lib/cli/args.py:1116 lib/cli/args.py:1123 msgid "preview" msgstr "previsualización" -#: lib/cli/args.py:1108 +#: lib/cli/args.py:1117 msgid "Show training preview output. in a separate window." msgstr "" "Mostrar la salida de la vista previa del entrenamiento. en una ventana " "separada." -#: lib/cli/args.py:1115 +#: lib/cli/args.py:1124 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -943,7 +966,7 @@ msgstr "" "Escribe el resultado del entrenamiento en un archivo. La imagen se " "almacenará en la raíz de su carpeta FaceSwap." -#: lib/cli/args.py:1123 +#: lib/cli/args.py:1132 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -951,12 +974,12 @@ msgstr "" "Desactiva el registro de TensorBoard. NB: Desactivar los registros significa " "que no podrá utilizar el gráfico o el análisis de esta sesión en la GUI." -#: lib/cli/args.py:1130 lib/cli/args.py:1139 lib/cli/args.py:1148 -#: lib/cli/args.py:1157 +#: lib/cli/args.py:1139 lib/cli/args.py:1148 lib/cli/args.py:1157 +#: lib/cli/args.py:1166 msgid "augmentation" msgstr "aumento" -#: lib/cli/args.py:1131 +#: lib/cli/args.py:1140 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -966,7 +989,7 @@ msgstr "" "conjunto de caras opuestas en lugar de deformar la cara al azar. Esta es la " "forma 'dfaker' de hacer la deformación." -#: lib/cli/args.py:1140 +#: lib/cli/args.py:1149 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -977,7 +1000,7 @@ msgstr "" "general, esto debería dejarse sin efecto, excepto durante el 'entrenamiento " "de ajuste'." -#: lib/cli/args.py:1149 +#: lib/cli/args.py:1158 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -987,7 +1010,7 @@ msgstr "" "diferencias de color entre los conjuntos A y B, con un mayor coste de tiempo " "de entrenamiento. Activa esta opción para desactivar el aumento de color." -#: lib/cli/args.py:1158 +#: lib/cli/args.py:1167 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -1000,7 +1023,7 @@ msgstr "" "esta opción desde el principio, es probable que arruine el modelo y se " "obtengan resultados terribles." -#: lib/cli/args.py:1183 +#: lib/cli/args.py:1192 msgid "Output to Shell console instead of GUI console" msgstr "Salida a la consola Shell en lugar de la consola GUI" diff --git a/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index e91594fb70..5cdbb67ebe 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-08-05 13:58+0100\n" +"POT-Creation-Date: 2022-08-31 19:19+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -46,7 +46,7 @@ msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "" #: lib/cli/args.py:320 lib/cli/args.py:329 lib/cli/args.py:337 -#: lib/cli/args.py:655 lib/cli/args.py:664 +#: lib/cli/args.py:385 lib/cli/args.py:664 lib/cli/args.py:673 msgid "Data" msgstr "" @@ -73,13 +73,20 @@ msgid "" "Extraction plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:386 lib/cli/args.py:402 lib/cli/args.py:414 -#: lib/cli/args.py:453 lib/cli/args.py:471 lib/cli/args.py:483 -#: lib/cli/args.py:674 lib/cli/args.py:701 lib/cli/args.py:739 +#: lib/cli/args.py:386 +msgid "" +"R|If selected then the input_dir should be a parent folder containing " +"multiple videos and/or folders of images you wish to extract from. The faces " +"will be output to separate sub-folders in the output_dir." +msgstr "" + +#: lib/cli/args.py:395 lib/cli/args.py:411 lib/cli/args.py:423 +#: lib/cli/args.py:462 lib/cli/args.py:480 lib/cli/args.py:492 +#: lib/cli/args.py:683 lib/cli/args.py:710 lib/cli/args.py:748 msgid "Plugins" msgstr "" -#: lib/cli/args.py:387 +#: lib/cli/args.py:396 msgid "" "R|Detector to use. Some of these have configurable settings in '/config/" "extract.ini' or 'Settings > Configure Extract 'Plugins':\n" @@ -92,7 +99,7 @@ msgid "" "intensive." msgstr "" -#: lib/cli/args.py:403 +#: lib/cli/args.py:412 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " @@ -100,7 +107,7 @@ msgid "" "L|fan: Best aligner. Fast on GPU, slow on CPU." msgstr "" -#: lib/cli/args.py:415 +#: lib/cli/args.py:424 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -135,7 +142,7 @@ msgid "" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" msgstr "" -#: lib/cli/args.py:454 +#: lib/cli/args.py:463 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -148,7 +155,7 @@ msgid "" "L|mean: Normalize the face colors to the mean." msgstr "" -#: lib/cli/args.py:472 +#: lib/cli/args.py:481 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -158,7 +165,7 @@ msgid "" "occur but the longer extraction will take." msgstr "" -#: lib/cli/args.py:484 +#: lib/cli/args.py:493 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -166,19 +173,19 @@ msgid "" "exactly what angles to check." msgstr "" -#: lib/cli/args.py:496 lib/cli/args.py:506 lib/cli/args.py:519 -#: lib/cli/args.py:533 lib/cli/args.py:776 lib/cli/args.py:790 -#: lib/cli/args.py:803 lib/cli/args.py:817 +#: lib/cli/args.py:505 lib/cli/args.py:515 lib/cli/args.py:528 +#: lib/cli/args.py:542 lib/cli/args.py:785 lib/cli/args.py:799 +#: lib/cli/args.py:812 lib/cli/args.py:826 msgid "Face Processing" msgstr "" -#: lib/cli/args.py:497 +#: lib/cli/args.py:506 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" msgstr "" -#: lib/cli/args.py:507 lib/cli/args.py:791 +#: lib/cli/args.py:516 lib/cli/args.py:800 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -187,7 +194,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args.py:520 lib/cli/args.py:804 +#: lib/cli/args.py:529 lib/cli/args.py:813 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -196,7 +203,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args.py:534 lib/cli/args.py:818 +#: lib/cli/args.py:543 lib/cli/args.py:827 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -204,26 +211,26 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args.py:545 lib/cli/args.py:557 lib/cli/args.py:569 -#: lib/cli/args.py:581 +#: lib/cli/args.py:554 lib/cli/args.py:566 lib/cli/args.py:578 +#: lib/cli/args.py:590 msgid "output" msgstr "" -#: lib/cli/args.py:546 +#: lib/cli/args.py:555 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" "res models." msgstr "" -#: lib/cli/args.py:558 +#: lib/cli/args.py:567 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " "of 10 will extract faces from every 10th frame." msgstr "" -#: lib/cli/args.py:570 +#: lib/cli/args.py:579 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -233,57 +240,57 @@ msgid "" "turn off" msgstr "" -#: lib/cli/args.py:582 +#: lib/cli/args.py:591 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" -#: lib/cli/args.py:588 lib/cli/args.py:597 lib/cli/args.py:605 -#: lib/cli/args.py:612 lib/cli/args.py:830 lib/cli/args.py:841 -#: lib/cli/args.py:849 lib/cli/args.py:868 lib/cli/args.py:874 +#: lib/cli/args.py:597 lib/cli/args.py:606 lib/cli/args.py:614 +#: lib/cli/args.py:621 lib/cli/args.py:839 lib/cli/args.py:850 +#: lib/cli/args.py:858 lib/cli/args.py:877 lib/cli/args.py:883 msgid "settings" msgstr "" -#: lib/cli/args.py:589 +#: lib/cli/args.py:598 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " "Useful if VRAM is at a premium." msgstr "" -#: lib/cli/args.py:598 +#: lib/cli/args.py:607 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" msgstr "" -#: lib/cli/args.py:606 +#: lib/cli/args.py:615 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" -#: lib/cli/args.py:613 +#: lib/cli/args.py:622 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" -#: lib/cli/args.py:635 +#: lib/cli/args.py:644 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:656 +#: lib/cli/args.py:665 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " "audio)." msgstr "" -#: lib/cli/args.py:665 +#: lib/cli/args.py:674 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." msgstr "" -#: lib/cli/args.py:675 +#: lib/cli/args.py:684 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -304,7 +311,7 @@ msgid "" "L|none: Don't perform color adjustment." msgstr "" -#: lib/cli/args.py:702 +#: lib/cli/args.py:711 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -341,7 +348,7 @@ msgid "" "will use the mask that was created by the trained model." msgstr "" -#: lib/cli/args.py:740 +#: lib/cli/args.py:749 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -356,18 +363,18 @@ msgid "" "more formats." msgstr "" -#: lib/cli/args.py:759 lib/cli/args.py:766 lib/cli/args.py:860 +#: lib/cli/args.py:768 lib/cli/args.py:775 lib/cli/args.py:869 msgid "Frame Processing" msgstr "" -#: lib/cli/args.py:760 +#: lib/cli/args.py:769 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " "at source dimensions. 50%% at half size 200%% at double size" msgstr "" -#: lib/cli/args.py:767 +#: lib/cli/args.py:776 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -375,7 +382,7 @@ msgid "" "converting from images, then the filenames must end with the frame-number!" msgstr "" -#: lib/cli/args.py:777 +#: lib/cli/args.py:786 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -385,7 +392,7 @@ msgid "" "alignments file." msgstr "" -#: lib/cli/args.py:831 +#: lib/cli/args.py:840 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -395,13 +402,13 @@ msgid "" "your system. If singleprocess is enabled this setting will be ignored." msgstr "" -#: lib/cli/args.py:842 +#: lib/cli/args.py:851 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" msgstr "" -#: lib/cli/args.py:850 +#: lib/cli/args.py:859 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -410,51 +417,51 @@ msgid "" "alignments file is found, this option will be ignored." msgstr "" -#: lib/cli/args.py:861 +#: lib/cli/args.py:870 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." msgstr "" -#: lib/cli/args.py:869 +#: lib/cli/args.py:878 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" -#: lib/cli/args.py:875 +#: lib/cli/args.py:884 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "" -#: lib/cli/args.py:891 +#: lib/cli/args.py:900 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" "Model plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:910 lib/cli/args.py:919 +#: lib/cli/args.py:919 lib/cli/args.py:928 msgid "faces" msgstr "" -#: lib/cli/args.py:911 +#: lib/cli/args.py:920 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " "face B." msgstr "" -#: lib/cli/args.py:920 +#: lib/cli/args.py:929 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " "A." msgstr "" -#: lib/cli/args.py:928 lib/cli/args.py:940 lib/cli/args.py:956 -#: lib/cli/args.py:981 lib/cli/args.py:991 +#: lib/cli/args.py:937 lib/cli/args.py:949 lib/cli/args.py:965 +#: lib/cli/args.py:990 lib/cli/args.py:1000 msgid "model" msgstr "" -#: lib/cli/args.py:929 +#: lib/cli/args.py:938 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -463,7 +470,7 @@ msgid "" "the existing model." msgstr "" -#: lib/cli/args.py:941 +#: lib/cli/args.py:950 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -477,7 +484,7 @@ msgid "" "to train." msgstr "" -#: lib/cli/args.py:957 +#: lib/cli/args.py:966 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -500,7 +507,7 @@ msgid "" "susceptible to color differences." msgstr "" -#: lib/cli/args.py:982 +#: lib/cli/args.py:991 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -508,7 +515,7 @@ msgid "" "displayed." msgstr "" -#: lib/cli/args.py:992 +#: lib/cli/args.py:1001 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -517,12 +524,12 @@ msgid "" "layers." msgstr "" -#: lib/cli/args.py:1005 lib/cli/args.py:1017 lib/cli/args.py:1028 -#: lib/cli/args.py:1039 lib/cli/args.py:1122 +#: lib/cli/args.py:1014 lib/cli/args.py:1026 lib/cli/args.py:1037 +#: lib/cli/args.py:1048 lib/cli/args.py:1131 msgid "training" msgstr "" -#: lib/cli/args.py:1006 +#: lib/cli/args.py:1015 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -530,7 +537,7 @@ msgid "" "number that you set here. Larger batches require more GPU RAM." msgstr "" -#: lib/cli/args.py:1018 +#: lib/cli/args.py:1027 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -539,13 +546,13 @@ msgid "" "can set that value here." msgstr "" -#: lib/cli/args.py:1029 +#: lib/cli/args.py:1038 msgid "" "[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " "Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" -#: lib/cli/args.py:1040 +#: lib/cli/args.py:1049 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -558,25 +565,25 @@ msgid "" "batches distributed to each GPU at each iteration." msgstr "" -#: lib/cli/args.py:1057 lib/cli/args.py:1067 +#: lib/cli/args.py:1066 lib/cli/args.py:1076 msgid "Saving" msgstr "" -#: lib/cli/args.py:1058 +#: lib/cli/args.py:1067 msgid "Sets the number of iterations between each model save." msgstr "" -#: lib/cli/args.py:1068 +#: lib/cli/args.py:1077 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." msgstr "" -#: lib/cli/args.py:1075 lib/cli/args.py:1086 lib/cli/args.py:1097 +#: lib/cli/args.py:1084 lib/cli/args.py:1095 lib/cli/args.py:1106 msgid "timelapse" msgstr "" -#: lib/cli/args.py:1076 +#: lib/cli/args.py:1085 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -585,7 +592,7 @@ msgid "" "timelapse-input-B parameter." msgstr "" -#: lib/cli/args.py:1087 +#: lib/cli/args.py:1096 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -594,7 +601,7 @@ msgid "" "timelapse-input-A parameter." msgstr "" -#: lib/cli/args.py:1098 +#: lib/cli/args.py:1107 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -602,53 +609,53 @@ msgid "" "model folder /timelapse/" msgstr "" -#: lib/cli/args.py:1107 lib/cli/args.py:1114 +#: lib/cli/args.py:1116 lib/cli/args.py:1123 msgid "preview" msgstr "" -#: lib/cli/args.py:1108 +#: lib/cli/args.py:1117 msgid "Show training preview output. in a separate window." msgstr "" -#: lib/cli/args.py:1115 +#: lib/cli/args.py:1124 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." msgstr "" -#: lib/cli/args.py:1123 +#: lib/cli/args.py:1132 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." msgstr "" -#: lib/cli/args.py:1130 lib/cli/args.py:1139 lib/cli/args.py:1148 -#: lib/cli/args.py:1157 +#: lib/cli/args.py:1139 lib/cli/args.py:1148 lib/cli/args.py:1157 +#: lib/cli/args.py:1166 msgid "augmentation" msgstr "" -#: lib/cli/args.py:1131 +#: lib/cli/args.py:1140 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " "warping." msgstr "" -#: lib/cli/args.py:1140 +#: lib/cli/args.py:1149 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " "left off except for during 'fit training'." msgstr "" -#: lib/cli/args.py:1149 +#: lib/cli/args.py:1158 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " "Enable this option to disable color augmentation." msgstr "" -#: lib/cli/args.py:1158 +#: lib/cli/args.py:1167 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -656,6 +663,6 @@ msgid "" "likely to kill a model and lead to terrible results." msgstr "" -#: lib/cli/args.py:1183 +#: lib/cli/args.py:1192 msgid "Output to Shell console instead of GUI console" msgstr "" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo index 396f83c558475283b32cce1e10a672d4bebfc42d..1396e1c9cae28fa612dc74546e199237a1d66225 100644 GIT binary patch delta 3778 zcmZvcYmijc5yu;r@QQ#uc0oWmEG8)I!$n0|-XJQ-A_8kt7$LJWcbQ3M=Zhu$Re0_V$plR1Qi55N^H{C-M+=4O#F`p7hqd(fW;S2G`EL~dig2Kz%SGM-H{xB#!i-9h zeK_o{f_4tN0Upm4k#)7mT-JZKn84uv@mv8ymqt5as0bC!~Yx>!Xc49 z77o>k&=<0n@P7qH!B*B6;5D6jpCvL2$ID;^I5a9ko5)??pn0*|0KSjDDo#HzA8(6% z!g?l2IlzX;MYgcskO~5RJDUy;UPZX;Ss2b$Scv1JP3XbzZWsA8_?4Y_v%X}P2urfG zS>!S1-QZKqC$&*a=C6QV*q840*_L-{h^4Go?icwEm;^_I!*E*6Rm_&~a}0-CR_kc0 zPYwsyW7CTSf&R!#BKy&Qd{pFj>hL`}n}ar;;to@*iqm9E4Oi0;t<-Gik3~K$BHQyK z+(`M9&c2a-M!ij7%%}ZQ1d`+(5K^6N>Ahu;Cb*c8y}}G zDFRsfTWs05CLOgB`Z2LYGLzmCiKUDawKMUE zsk2PTBtt1Hkv8>qJYuCx*iNKFu|zDf)ih+{=~yyunQgI%WjiL6h*a9Cf}vyVdJ}61 zZM7WJWM@oc%!!(`ZLBBKsZjVq_Dk6f6=oyH)`!9vHOAs`JT$VIUQXmpf@YMM~9XMWBdnC-QBfR4f~+i}TC5 z8pw)uwWcu|i#us!CoQ%iAoxjHhFQIxYJhc2s|<8C*r?-nI21Scud8*Gl9*${p@fNA z@uYD=+aSPhu*@Utm(`dM%y2;1)~%_bE;vcc%2KQ*ld#j|ZKt$48r4-nv3rGCYp036 zCia9CDPNbe!ZAm=;y~XulT6wvZ3%RKb<1(04(UwNQ5qUzsTAQxs?CPL)G&L?=$UlX)P>UFXc^>Rb@~7+IP*PX=|U+w-dZ$a{O~)fOx67AxpO-{T=&eX?DJlW zd)DnTZo7BDJ>@mKC*5ARugrL@Zr^~5;huK;uLg>l|J>m6jT zZZvNgx1W9bg9&@J_-1*`JKdAsQy62?>*l?d+_3DZ>2AB*#Ws0Pbh|3~_X7&H=ySVs zV{0c4En~?&llw{Y)ZB~C@VI*hr?c#^&pTLQ+!u&pZxC6Vd)jOBT7!hzuf(UZd52IM zw~uJ`FyeK4Njk65Y8qE!Rx%3LF}P@tABj!Pef#3L+?e)BgS|t!E!#_P7#{J5ux`3%`En)!IdBh{WbSx zcxVrbL&ba>O{*KayGgC!d6KQWt`wEzirK!`JLEmfVLlC7#j4z~y*Ewh^7a^3p_wfT zl;lb-RTUZchdFy=akdA-Aekb1&-ewUJqV1Nt8yO+YvKn9izEFa`b73{T&sp~hNCdq z7i31!brY8Qh+bPI=oy{pM%Q>`ePazyH`0mm_F%1Ts^oNO`q%KKNx@f#8hX1i(&K~X z?7`eW4fZ*a)?}8ahh7G|lyXfJeeV&xeZhuEuxHb|!sw-D{sd%jZT=mN2nlDTl--2tepK71>09@sBquVEy_d5(E3WVwmIqh?SGwigKmwQmef1@zs`YGEk-A| z>;To{HKHSxP@iL0*=4y~tL+^y-a;)vi*JxG+`0j6sQs-wwGG^~$A98T>tycIj%gj| II%nVVUl@J;UH||9 delta 1792 zcmYk7X-t$?6oy}A5l{vM6cme~C5N^#dVjS(eN`Yqv5Amn@_opIrt0W zq+I;pz<05mdrJMV6XT_O*rO7pQkd6^4Z~G%82k}-fRCVyN*lb6?M#sh+p}(}beZ)v zHBFk0qd%iJ(s4_s^pFLjbGb40hgl|F;yl{Wh9ZLp3F)x!o( z-lvMw!#iW8mG~VK>;>v3G6&=Kz{k)rNlK>Of3h?eJB`2}WZZU`ihsy-={WxC8Qgz2 zDt%{4*I+TMr9UYHJBnC%_>q17ZS{Ze5o{@@yE7WVyl(i7TUT+2&X z@v$_6_KF4ehQC{6FPuQUs__5erc%Jb9JYTPF61fv1v8d2nhwz`B~y*zKBr-Chnung zVdHM>6>FrU^lz(`-ox)%$01+`;;A?E$Bppt=OJ>;9B1{e+- zp*dkQWF9LqfkvV@Q=yRH){{!GEGuH_PdyA}Au~@1GNlZ;5Z@=Om^BBpzWUm>2EcSQ z43#62-YRBOgM4)-j7KquXsJ8ujgpZm0XE+J4)N7`LSC~HkxAg!#od|OaMa7^$%V1L z`r9y97>)9fxs^a<5{r+Rh?@0^@}VhxP%#>a`k^3{gv|eo`L7_Il#NPw1lE_RBSso} zT`GK)95@seBECUZ+{n#0rIK2v&nki`DAMzHU`>*zcgFW&o|nbJkwKXQ`aMkT%i*M0Me diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.po b/locales/ru/LC_MESSAGES/lib.cli.args.po index aac0550e9e..bf14c2117e 100644 --- a/locales/ru/LC_MESSAGES/lib.cli.args.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-08-05 13:58+0100\n" -"PO-Revision-Date: 2022-08-05 14:07+0100\n" +"POT-Creation-Date: 2022-08-31 19:19+0100\n" +"PO-Revision-Date: 2022-08-31 19:22+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -58,7 +58,7 @@ msgstr "" "с faceswap" #: lib/cli/args.py:320 lib/cli/args.py:329 lib/cli/args.py:337 -#: lib/cli/args.py:655 lib/cli/args.py:664 +#: lib/cli/args.py:385 lib/cli/args.py:664 lib/cli/args.py:673 msgid "Data" msgstr "Данные" @@ -90,13 +90,23 @@ msgstr "" "Извлечь лица из изображений или видео источников.\n" "Плагины извлечения можно настроить в меню 'Настройки'" -#: lib/cli/args.py:386 lib/cli/args.py:402 lib/cli/args.py:414 -#: lib/cli/args.py:453 lib/cli/args.py:471 lib/cli/args.py:483 -#: lib/cli/args.py:674 lib/cli/args.py:701 lib/cli/args.py:739 +#: lib/cli/args.py:386 +msgid "" +"R|If selected then the input_dir should be a parent folder containing " +"multiple videos and/or folders of images you wish to extract from. The faces " +"will be output to separate sub-folders in the output_dir." +msgstr "" +"R|Если выбрано, то input_dir должна быть родительской папкой, содержащей " +"несколько видео и/или папок изображений, из которых вы хотите извлечь. Лица " +"будут выводиться в отдельные подпапки в output_dir." + +#: lib/cli/args.py:395 lib/cli/args.py:411 lib/cli/args.py:423 +#: lib/cli/args.py:462 lib/cli/args.py:480 lib/cli/args.py:492 +#: lib/cli/args.py:683 lib/cli/args.py:710 lib/cli/args.py:748 msgid "Plugins" msgstr "Плагины" -#: lib/cli/args.py:387 +#: lib/cli/args.py:396 msgid "" "R|Detector to use. Some of these have configurable settings in '/config/" "extract.ini' or 'Settings > Configure Extract 'Plugins':\n" @@ -120,7 +130,7 @@ msgstr "" "детектировать лицо в большем кол-ве ситуация и меньшим кол-вом ошибок, чем " "другие GPU, но значительно более требователен к ресурсам." -#: lib/cli/args.py:403 +#: lib/cli/args.py:412 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " @@ -133,7 +143,7 @@ msgstr "" "использовать GPU.\n" "L|fan: Лучший выравниватель. Быстрый на GPU, медленный на CPU." -#: lib/cli/args.py:415 +#: lib/cli/args.py:424 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -199,7 +209,7 @@ msgstr "" "ориентиров лица и расширяется вверх на лоб.\n" "(пример: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -#: lib/cli/args.py:454 +#: lib/cli/args.py:463 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -220,7 +230,7 @@ msgstr "" "L|hist: Выравнивание гистограммы каналов RGB каналов.\n" "L|mean: Усреднение цветов лица." -#: lib/cli/args.py:472 +#: lib/cli/args.py:481 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -235,7 +245,7 @@ msgstr "" "замедления скорости извлечения. Чем больше проходов выравнивания, тем меньше " "микродрожание, но тем дольше идет извлечение." -#: lib/cli/args.py:484 +#: lib/cli/args.py:493 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -247,13 +257,13 @@ msgstr "" "использовать приращения этого размера до 360, либо передайте список чисел, " "чтобы точно указать, какие углы проверять." -#: lib/cli/args.py:496 lib/cli/args.py:506 lib/cli/args.py:519 -#: lib/cli/args.py:533 lib/cli/args.py:776 lib/cli/args.py:790 -#: lib/cli/args.py:803 lib/cli/args.py:817 +#: lib/cli/args.py:505 lib/cli/args.py:515 lib/cli/args.py:528 +#: lib/cli/args.py:542 lib/cli/args.py:785 lib/cli/args.py:799 +#: lib/cli/args.py:812 lib/cli/args.py:826 msgid "Face Processing" msgstr "Обработка лиц" -#: lib/cli/args.py:497 +#: lib/cli/args.py:506 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -261,7 +271,7 @@ msgstr "" "Отбрасывает лица ниже указанного размера. Длина указывается в пикселях по " "диагонали. Установите в 0 для отключения" -#: lib/cli/args.py:507 lib/cli/args.py:791 +#: lib/cli/args.py:516 lib/cli/args.py:800 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -275,7 +285,7 @@ msgstr "" "пробел. Прим.: Фильтрация лиц существенно снижает скорость извлечения, при " "этом точность не гарантируется." -#: lib/cli/args.py:520 lib/cli/args.py:804 +#: lib/cli/args.py:529 lib/cli/args.py:813 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -289,7 +299,7 @@ msgstr "" "изображений через пробел. Прим.: Использование фильтра существенно замедлит " "скорость извлечения. Также точность не гарантируется." -#: lib/cli/args.py:534 lib/cli/args.py:818 +#: lib/cli/args.py:543 lib/cli/args.py:827 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -300,12 +310,12 @@ msgstr "" "лица. Чем ниже значения, тем строже. Прим.: Использование фильтра лиц " "существенно замедлит скорость извлечения. Также точность не гарантируется." -#: lib/cli/args.py:545 lib/cli/args.py:557 lib/cli/args.py:569 -#: lib/cli/args.py:581 +#: lib/cli/args.py:554 lib/cli/args.py:566 lib/cli/args.py:578 +#: lib/cli/args.py:590 msgid "output" msgstr "вывод" -#: lib/cli/args.py:546 +#: lib/cli/args.py:555 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -315,7 +325,7 @@ msgstr "" "поддерживает такой входной размер. Стоит изменять только для моделей " "высокого разрешения." -#: lib/cli/args.py:558 +#: lib/cli/args.py:567 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -325,7 +335,7 @@ msgstr "" "извлечении. Например, значение 1 будет искать лица в каждом кадре, а " "значение 10 в каждом 10том кадре." -#: lib/cli/args.py:570 +#: lib/cli/args.py:579 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -340,17 +350,17 @@ msgstr "" "только во время второго прохода. ВНИМАНИЕ: Не прерывайте выполнение во время " "записи, так как это может повлечь порчу файла. Установите в 0 для выключения" -#: lib/cli/args.py:582 +#: lib/cli/args.py:591 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "Рисовать ландмарки на выходных лицах для нужд отладки." -#: lib/cli/args.py:588 lib/cli/args.py:597 lib/cli/args.py:605 -#: lib/cli/args.py:612 lib/cli/args.py:830 lib/cli/args.py:841 -#: lib/cli/args.py:849 lib/cli/args.py:868 lib/cli/args.py:874 +#: lib/cli/args.py:597 lib/cli/args.py:606 lib/cli/args.py:614 +#: lib/cli/args.py:621 lib/cli/args.py:839 lib/cli/args.py:850 +#: lib/cli/args.py:858 lib/cli/args.py:877 lib/cli/args.py:883 msgid "settings" msgstr "настройки" -#: lib/cli/args.py:589 +#: lib/cli/args.py:598 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " @@ -360,7 +370,7 @@ msgstr "" "стадия извлечения будет запущена отдельно (одна, за другой). Полезно при " "нехватке VRAM." -#: lib/cli/args.py:598 +#: lib/cli/args.py:607 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -368,16 +378,16 @@ msgstr "" "Пропускать кадры, которые уже были извлечены и существуют в файле " "выравнивания" -#: lib/cli/args.py:606 +#: lib/cli/args.py:615 msgid "Skip frames that already have detected faces in the alignments file" msgstr "Пропускать кадры, для которых в файле выравнивания есть найденные лица" -#: lib/cli/args.py:613 +#: lib/cli/args.py:622 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "Не сохранять найденные лица на носитель. Просто создать файл выравнивания" -#: lib/cli/args.py:635 +#: lib/cli/args.py:644 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -385,7 +395,7 @@ msgstr "" "Заменить оригиналы лица в исходном видео/фотографиях новыми.\n" "Плагины конвертации могут быть настроены в меню 'Настройки'" -#: lib/cli/args.py:656 +#: lib/cli/args.py:665 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -395,7 +405,7 @@ msgstr "" "Предоставьте исходное видео, из которого были извлечены кадры (для настройки " "частоты кадров, а также аудио)." -#: lib/cli/args.py:665 +#: lib/cli/args.py:674 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -403,7 +413,7 @@ msgstr "" "Папка с моделью. Папка, содержащая обученную модель, которую вы хотите " "использовать для преобразования." -#: lib/cli/args.py:675 +#: lib/cli/args.py:684 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -442,7 +452,7 @@ msgstr "" "дает удовлетворительных результатов.\n" "L|none: Не производить подгонку цвета." -#: lib/cli/args.py:702 +#: lib/cli/args.py:711 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -511,7 +521,7 @@ msgstr "" "L| predicted: Если во время обучения была включена опция «Learn Mask», будет " "использоваться маска, созданная обученной моделью." -#: lib/cli/args.py:740 +#: lib/cli/args.py:749 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -537,11 +547,11 @@ msgstr "" "L|pillow: [изображения] Более медленный, чем opencv, но имеет больше опций и " "поддерживает больше форматов." -#: lib/cli/args.py:759 lib/cli/args.py:766 lib/cli/args.py:860 +#: lib/cli/args.py:768 lib/cli/args.py:775 lib/cli/args.py:869 msgid "Frame Processing" msgstr "Обработка кадров" -#: lib/cli/args.py:760 +#: lib/cli/args.py:769 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -551,7 +561,7 @@ msgstr "" "кадры в исходном размере. 50%% половина от размера, а 200%% в удвоенном " "размере" -#: lib/cli/args.py:767 +#: lib/cli/args.py:776 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -564,7 +574,7 @@ msgstr "" "unchanged). Прим.: Если при конверсии используются изображения, то имена " "файлов должны заканчиваться номером кадра!" -#: lib/cli/args.py:777 +#: lib/cli/args.py:786 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -580,7 +590,7 @@ msgstr "" "Если оставить это поле пустым, то все лица, которые существуют в файле " "выравниваний будут сконвертированы." -#: lib/cli/args.py:831 +#: lib/cli/args.py:840 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -597,7 +607,7 @@ msgstr "" "будет использоваться больше процессов, чем доступно в вашей системе. Если " "включен одиночный процесс, этот параметр будет проигнорирован." -#: lib/cli/args.py:842 +#: lib/cli/args.py:851 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -605,7 +615,7 @@ msgstr "" "[СОВМЕСТИМОСТЬ] Это нужно выбирать только в том случае, если загружается " "устаревшая модель или если в папке сохранения есть несколько моделей" -#: lib/cli/args.py:850 +#: lib/cli/args.py:859 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -619,7 +629,7 @@ msgstr "" "использованию улучшенного конвейера экстракции и некачественных результатов. " "Если файл выравниваний найден, этот параметр будет проигнорирован." -#: lib/cli/args.py:861 +#: lib/cli/args.py:870 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -627,16 +637,16 @@ msgstr "" "При использовании с --frame-range кадры не попавшие в диапазон выводятся " "неизменными, вместо их пропуска." -#: lib/cli/args.py:869 +#: lib/cli/args.py:878 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Поменять модели местами. Вместо преобразования из A -> B, преобразует B -> A" -#: lib/cli/args.py:875 +#: lib/cli/args.py:884 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Отключить многопроцессорность. Медленнее, но менее ресурсоемко." -#: lib/cli/args.py:891 +#: lib/cli/args.py:900 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -647,11 +657,11 @@ msgstr "" "Обучение моделей может занять долгое время: от 24 часов до недели\n" "Каждую модель можно отдельно настроить в меню «Настройки»" -#: lib/cli/args.py:910 lib/cli/args.py:919 +#: lib/cli/args.py:919 lib/cli/args.py:928 msgid "faces" msgstr "лица" -#: lib/cli/args.py:911 +#: lib/cli/args.py:920 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -660,7 +670,7 @@ msgstr "" "Входная папка. Папка содержащая изображения для тренировки лица A. Это " "исходное лицо т.е. лицо, которое вы хотите убрать, заменив лицом B." -#: lib/cli/args.py:920 +#: lib/cli/args.py:929 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -669,12 +679,12 @@ msgstr "" "Входная папка. Папка содержащая изображения для тренировки лица B. Это новое " "лицо т.е. лицо, которое вы хотите поместить на голову человека A." -#: lib/cli/args.py:928 lib/cli/args.py:940 lib/cli/args.py:956 -#: lib/cli/args.py:981 lib/cli/args.py:991 +#: lib/cli/args.py:937 lib/cli/args.py:949 lib/cli/args.py:965 +#: lib/cli/args.py:990 lib/cli/args.py:1000 msgid "model" msgstr "модель" -#: lib/cli/args.py:929 +#: lib/cli/args.py:938 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -688,7 +698,7 @@ msgstr "" "будет создана). Если вы хотите продолжить тренировку, выберите папку с уже " "существующими сохранениями." -#: lib/cli/args.py:941 +#: lib/cli/args.py:950 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -712,7 +722,7 @@ msgstr "" "NB: Вес можно загружать только из моделей того же плагина, который вы " "собираетесь тренировать." -#: lib/cli/args.py:957 +#: lib/cli/args.py:966 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -758,7 +768,7 @@ msgstr "" "ресурсам (Вам потребуется GPU с хорошим количеством видеопамяти). Хороша для " "деталей, но подвержена к неправильной передаче цвета." -#: lib/cli/args.py:982 +#: lib/cli/args.py:991 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -770,7 +780,7 @@ msgstr "" "сводная информация о модели, которая будет создана выбранным плагином, и " "параметрами конфигурации." -#: lib/cli/args.py:992 +#: lib/cli/args.py:1001 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -784,12 +794,12 @@ msgstr "" "некоторые модели могут иметь параметры конфигурации для замораживания других " "слоев." -#: lib/cli/args.py:1005 lib/cli/args.py:1017 lib/cli/args.py:1028 -#: lib/cli/args.py:1039 lib/cli/args.py:1122 +#: lib/cli/args.py:1014 lib/cli/args.py:1026 lib/cli/args.py:1037 +#: lib/cli/args.py:1048 lib/cli/args.py:1131 msgid "training" msgstr "тренировка" -#: lib/cli/args.py:1006 +#: lib/cli/args.py:1015 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -802,7 +812,7 @@ msgstr "" "изображений в два раза больше этого числа. Увеличение размера партии требует " "больше памяти GPU." -#: lib/cli/args.py:1018 +#: lib/cli/args.py:1027 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -816,7 +826,7 @@ msgstr "" "Однако, если вы хотите, чтобы тренировка прервалась после указанного кол-ва " "итерация, вы можете ввести это здесь." -#: lib/cli/args.py:1029 +#: lib/cli/args.py:1038 msgid "" "[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " "Mirrored Distrubution Strategy to train on multiple GPUs." @@ -825,7 +835,7 @@ msgstr "" "Используйте стратегию зеркального распространения Tensorflow для обучения на " "нескольких графических процессорах." -#: lib/cli/args.py:1040 +#: lib/cli/args.py:1049 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -837,16 +847,28 @@ msgid "" "GPUs. A copy of the model and all variables are loaded onto each GPU with " "batches distributed to each GPU at each iteration." msgstr "" - -#: lib/cli/args.py:1057 lib/cli/args.py:1067 +"R|Выберите стратегию распределения для использования.\n" +"L|default: использовать стратегию распространения Tensorflow по умолчанию.\n" +"L|central-storage: централизует переменные в ЦП, в то время как операции " +"выполняются на 1 или нескольких локальных графических процессорах. Это может " +"помочь сэкономить часть видеопамяти за счет некоторой скорости за счет " +"отказа от хранения переменных в графическом процессоре. Примечание. " +"Смешанная точность не поддерживается в конфигурациях с несколькими " +"графическими процессорами.\n" +"L|mirrored: поддерживает синхронное распределенное обучение на нескольких " +"локальных графических процессорах. Копия модели и все переменные загружаются " +"в каждый GPU, причем пакеты распределяются между каждым GPU на каждой " +"итерации." + +#: lib/cli/args.py:1066 lib/cli/args.py:1076 msgid "Saving" msgstr "Сохранение" -#: lib/cli/args.py:1058 +#: lib/cli/args.py:1067 msgid "Sets the number of iterations between each model save." msgstr "Установка количества итераций между сохранениями модели." -#: lib/cli/args.py:1068 +#: lib/cli/args.py:1077 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -854,11 +876,11 @@ msgstr "" "Устанавливает кол-во итераций перед созданием резервной копии модели. " "Установите в 0 для отключения." -#: lib/cli/args.py:1075 lib/cli/args.py:1086 lib/cli/args.py:1097 +#: lib/cli/args.py:1084 lib/cli/args.py:1095 lib/cli/args.py:1106 msgid "timelapse" msgstr "таймлапс" -#: lib/cli/args.py:1076 +#: lib/cli/args.py:1085 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -871,7 +893,7 @@ msgstr "" "папку лиц набора 'A' для использования при создании таймлапса. Вам также " "нужно указать параметры--timelapse-output и --timelapse-input-B." -#: lib/cli/args.py:1087 +#: lib/cli/args.py:1096 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -885,7 +907,7 @@ msgstr "" "таймлапса. Вы также должны указать параметр --timelapse-output и --timelapse-" "input-A." -#: lib/cli/args.py:1098 +#: lib/cli/args.py:1107 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -897,15 +919,15 @@ msgstr "" "указаны только входные папки, то по умолчанию вывод будет сохранен вместе с " "моделью в подкаталог /timelapse/" -#: lib/cli/args.py:1107 lib/cli/args.py:1114 +#: lib/cli/args.py:1116 lib/cli/args.py:1123 msgid "preview" msgstr "предварительный просмотр" -#: lib/cli/args.py:1108 +#: lib/cli/args.py:1117 msgid "Show training preview output. in a separate window." msgstr "Показывать предварительный просмотр в отдельном окне." -#: lib/cli/args.py:1115 +#: lib/cli/args.py:1124 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -913,7 +935,7 @@ msgstr "" "Записывает результат тренировки в файл. Файл будет сохранен в коренной папке " "FaceSwap." -#: lib/cli/args.py:1123 +#: lib/cli/args.py:1132 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -921,12 +943,12 @@ msgstr "" "Отключает журнал TensorBoard. Примечание: Отключение журналов означает, что " "вы не сможете использовать графики или анализ сессии внутри GUI." -#: lib/cli/args.py:1130 lib/cli/args.py:1139 lib/cli/args.py:1148 -#: lib/cli/args.py:1157 +#: lib/cli/args.py:1139 lib/cli/args.py:1148 lib/cli/args.py:1157 +#: lib/cli/args.py:1166 msgid "augmentation" msgstr "аугментация" -#: lib/cli/args.py:1131 +#: lib/cli/args.py:1140 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -936,7 +958,7 @@ msgstr "" "Ориентирами/Landmarks противоположного набора лиц. Этот способ используется " "пакетом \"dfaker\"." -#: lib/cli/args.py:1140 +#: lib/cli/args.py:1149 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -947,7 +969,7 @@ msgstr "" "происходило. Как правило, эту настройку не стоит трогать, за исключением " "периода «финальной шлифовки»." -#: lib/cli/args.py:1149 +#: lib/cli/args.py:1158 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -957,7 +979,7 @@ msgstr "" "цвета между наборами A and B ценой некоторого замедления скорости " "тренировки. Включите эту опцию для отключения цветовой аугментации." -#: lib/cli/args.py:1158 +#: lib/cli/args.py:1167 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -970,7 +992,7 @@ msgstr "" "Включение этой опции с самого начала может убить модель и привести к ужасным " "результатам." -#: lib/cli/args.py:1183 +#: lib/cli/args.py:1192 msgid "Output to Shell console instead of GUI console" msgstr "Вывод в системную консоль вместо GUI" diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 0d27e37160..647b79b132 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -4,7 +4,7 @@ """ import logging -from tensorflow.python.framework import errors_impl as tf_errors +from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa from lib.multithreading import MultiThread from lib.queue_manager import queue_manager @@ -106,6 +106,7 @@ def __init__(self, git_model_id=None, model_filename=None, exclude_gpus=None, co "configfile: %s, instance: %s, )", self.__class__.__name__, git_model_id, model_filename, exclude_gpus, configfile, instance) + self._is_initialized = False self._instance = instance self._exclude_gpus = exclude_gpus self.config = _get_config(".".join(self.__module__.split(".")[-2:]), configfile=configfile) @@ -370,6 +371,12 @@ def initialize(self, *args, **kwargs): """ logger.debug("initialize %s: (args: %s, kwargs: %s)", self.__class__.__name__, args, kwargs) + if self._is_initialized: + # When batch processing, plugins will be initialized on first job in batch + logger.debug("Plugin already initialized: %s (%s)", + self.name, self._plugin_type.title()) + return + logger.info("Initializing %s (%s)...", self.name, self._plugin_type.title()) self.queue_size = 1 name = self.name.replace(" ", "_").lower() @@ -391,6 +398,7 @@ def initialize(self, *args, **kwargs): "option to `True`.") raise FaceswapError(msg) from err raise err + self._is_initialized = True logger.info("Initialized %s (%s) with batchsize of %s", self.name, self._plugin_type.title(), self.batchsize) diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 5bdb774571..c7a6f42595 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -12,12 +12,12 @@ import logging import sys -from typing import cast, List, Optional, Tuple, TYPE_CHECKING +from typing import cast, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 from lib.gpu_stats import GPUStats -from lib.queue_manager import queue_manager, QueueEmpty +from lib.queue_manager import EventQueue, queue_manager, QueueEmpty from lib.utils import get_backend from plugins.plugin_loader import PluginLoader @@ -29,6 +29,10 @@ if TYPE_CHECKING: import numpy as np from lib.align.detected_face import DetectedFace + from plugins.extract._base import Extractor as PluginExtractor + from plugins.extract.detect._base import Detector + from plugins.extract.align._base import Aligner + from plugins.extract.mask._base import Masker logger = logging.getLogger(__name__) # pylint:disable=invalid-name _INSTANCES = -1 # Tracking for multiple instances of pipeline @@ -91,9 +95,18 @@ class Extractor(): The current phase that the pipeline is running. Used in conjunction with :attr:`passes` and :attr:`final_pass` to indicate to the caller which phase is being processed """ - def __init__(self, detector, aligner, masker, configfile=None, multiprocess=False, - exclude_gpus=None, rotate_images=None, min_size=20, normalize_method=None, - re_feed=0, image_is_aligned=False): + def __init__(self, + detector: str, + aligner: str, + masker: Union[str, List[str]], + configfile: Optional[str] = None, + multiprocess: bool = False, + exclude_gpus: Optional[List[int]] = None, + rotate_images: Optional[List[int]] = None, + min_size: int = 20, + normalize_method: Optional[str] = None, + re_feed: int = 0, + image_is_aligned: bool = False) -> None: logger.debug("Initializing %s: (detector: %s, aligner: %s, masker: %s, configfile: %s, " "multiprocess: %s, exclude_gpus: %s, rotate_images: %s, min_size: %s, " "normalize_method: %s, re_feed: %s, image_is_aligned: %s)", @@ -121,7 +134,7 @@ def __init__(self, detector, aligner, masker, configfile=None, multiprocess=Fals logger.debug("Initialized %s", self.__class__.__name__) @property - def input_queue(self): + def input_queue(self) -> EventQueue: """ queue: Return the correct input queue depending on the current phase The input queue is the entry point into the extraction pipeline. An :class:`ExtractMedia` @@ -135,11 +148,11 @@ def input_queue(self): """ qname = f"extract{self._instance}_{self._current_phase[0]}_in" retval = self._queues[qname] - logger.trace("%s: %s", qname, retval) + logger.trace("%s: %s", qname, retval) # type: ignore return retval @property - def passes(self): + def passes(self) -> int: """ int: Returns the total number of passes the extractor needs to make. This is calculated on several factors (vram available, plugin choice, @@ -157,21 +170,21 @@ def passes(self): >>> extractor.input_queue.put(extract_media) """ retval = len(self._phases) - logger.trace(retval) + logger.trace(retval) # type: ignore return retval @property - def phase_text(self): + def phase_text(self) -> str: """ str: The plugins that are running in the current phase, formatted for info text output. """ plugin_types = set(self._get_plugin_type_and_index(phase)[0] for phase in self._current_phase) retval = ", ".join(plugin_type.title() for plugin_type in list(plugin_types)) - logger.trace(retval) + logger.trace(retval) # type: ignore return retval @property - def final_pass(self): + def final_pass(self) -> bool: """ bool, Return ``True`` if this is the final extractor pass otherwise ``False`` Useful for iterating over the pipeline :attr:`passes` or :func:`detected_faces` and @@ -188,17 +201,24 @@ def final_pass(self): >>> extractor.input_queue.put(extract_media) """ retval = self._phase_index == len(self._phases) - 1 - logger.trace(retval) + logger.trace(retval) # type: ignore return retval - def set_batchsize(self, plugin_type, batchsize): + def reset_phase_index(self) -> None: + """ Reset the current phase index back to 0. Used for when batch processing is used in + extract. """ + self._phase_index = 0 + + def set_batchsize(self, + plugin_type: Literal["align", "detect"], + batchsize: int) -> None: """ Set the batch size of a given :attr:`plugin_type` to the given :attr:`batchsize`. This should be set prior to :func:`launch` if the batch size is to be manually overridden Parameters ---------- - plugin_type: {'aligner', 'detector'} + plugin_type: {'align', 'detect'} The plugin_type to be overridden batchsize: int The batch size to use for this plugin type @@ -207,7 +227,7 @@ def set_batchsize(self, plugin_type, batchsize): plugin = getattr(self, f"_{plugin_type}") plugin.batchsize = batchsize - def launch(self): + def launch(self) -> None: """ Launches the plugin(s) This launches the plugins held in the pipeline, and should be called at the beginning @@ -223,7 +243,7 @@ def launch(self): for phase in self._current_phase: self._launch_plugin(phase) - def detected_faces(self): + def detected_faces(self) -> Generator["ExtractMedia", None, None]: """ Generator that returns results, frame by frame from the extraction pipeline This is the exit point for the extraction pipeline and is used to obtain the output @@ -258,9 +278,6 @@ def detected_faces(self): self._join_threads() if self.final_pass: - # Cleanup queues - for q_name in self._queues: - queue_manager.del_queue(q_name) logger.debug("Detection Complete") else: self._phase_index += 1 @@ -268,7 +285,7 @@ def detected_faces(self): # <<< INTERNAL METHODS >>> # @property - def _parallel_scaling(self): + def _parallel_scaling(self) -> Dict[int, float]: """ dict: key is number of parallel plugins being loaded, value is the scaling factor that the total base vram for those plugins should be scaled by @@ -288,11 +305,11 @@ def _parallel_scaling(self): 3: 0.55, 4: 0.5, 5: 0.4} - logger.trace(retval) + logger.trace(retval) # type: ignore return retval @property - def _vram_per_phase(self): + def _vram_per_phase(self) -> Dict[str, float]: """ dict: The amount of vram required for each phase in :attr:`_flow`. """ retval = {} for phase in self._flow: @@ -300,11 +317,11 @@ def _vram_per_phase(self): attr = getattr(self, f"_{plugin_type}") attr = attr[idx] if idx is not None else attr retval[phase] = attr.vram - logger.trace(retval) + logger.trace(retval) # type: ignore return retval @property - def _total_vram_required(self): + def _total_vram_required(self) -> float: """ Return vram required for all phases plus the buffer """ vrams = self._vram_per_phase vram_required_count = sum(1 for p in vrams.values() if p > 0) @@ -316,32 +333,32 @@ def _total_vram_required(self): return retval @property - def _current_phase(self): + def _current_phase(self) -> List[str]: """ list: The current phase from :attr:`_phases` that is running through the extractor. """ retval = self._phases[self._phase_index] - logger.trace(retval) + logger.trace(retval) # type: ignore return retval @property - def _final_phase(self): + def _final_phase(self) -> str: """ Return the final phase from the flow list """ retval = self._flow[-1] - logger.trace(retval) + logger.trace(retval) # type: ignore return retval @property - def _output_queue(self): + def _output_queue(self) -> EventQueue: """ Return the correct output queue depending on the current phase """ if self.final_pass: qname = f"extract{self._instance}_{self._final_phase}_out" else: qname = f"extract{self._instance}_{self._phases[self._phase_index + 1][0]}_in" retval = self._queues[qname] - logger.trace("%s: %s", qname, retval) + logger.trace("%s: %s", qname, retval) # type: ignore return retval @property - def _all_plugins(self): + def _all_plugins(self) -> List["PluginExtractor"]: """ Return list of all plugin objects in this pipeline """ retval = [] for phase in self._flow: @@ -349,22 +366,22 @@ def _all_plugins(self): attr = getattr(self, f"_{plugin_type}") attr = attr[idx] if idx is not None else attr retval.append(attr) - logger.trace("All Plugins: %s", retval) + logger.trace("All Plugins: %s", retval) # type: ignore return retval @property - def _active_plugins(self): + def _active_plugins(self) -> List["PluginExtractor"]: """ Return the plugins that are currently active based on pass """ retval = [] for phase in self._current_phase: plugin_type, idx = self._get_plugin_type_and_index(phase) attr = getattr(self, f"_{plugin_type}") retval.append(attr[idx] if idx is not None else attr) - logger.trace("Active plugins: %s", retval) + logger.trace("Active plugins: %s", retval) # type: ignore return retval @staticmethod - def _set_flow(detector, aligner, masker): + def _set_flow(detector: str, aligner: str, masker: List[str]) -> List[str]: """ Set the flow list based on the input plugins """ logger.debug("detector: %s, aligner: %s, masker: %s", detector, aligner, masker) retval = [] @@ -379,7 +396,7 @@ def _set_flow(detector, aligner, masker): return retval @staticmethod - def _get_plugin_type_and_index(flow_phase): + def _get_plugin_type_and_index(flow_phase: str) -> Tuple[str, Optional[int]]: """ Obtain the plugin type and index for the plugin for the given flow phase. When multiple plugins for the same phase are allowed (e.g. Mask) this will return @@ -399,16 +416,16 @@ def _get_plugin_type_and_index(flow_phase): The index of this plugin type within the flow, if there are multiple plugins in use otherwise ``None`` if there is only 1 plugin in use for the given phase """ - idx = flow_phase.split("_")[-1] - if idx.isdigit(): - idx = int(idx) + sidx = flow_phase.split("_")[-1] + if sidx.isdigit(): + idx: Optional[int] = int(sidx) plugin_type = "_".join(flow_phase.split("_")[:-1]) else: plugin_type = flow_phase idx = None return plugin_type, idx - def _add_queues(self): + def _add_queues(self) -> Dict[str, EventQueue]: """ Add the required processing queues to Queue Manager """ queues = {} tasks = [f"extract{self._instance}_{phase}_in" for phase in self._flow] @@ -421,7 +438,7 @@ def _add_queues(self): return queues @staticmethod - def _get_vram_stats(): + def _get_vram_stats() -> Dict[str, Union[int, str]]: """ Obtain statistics on available VRAM and subtract a constant buffer from available vram. Returns @@ -432,14 +449,14 @@ def _get_vram_stats(): vram_buffer = 256 # Leave a buffer for VRAM allocation gpu_stats = GPUStats() stats = gpu_stats.get_card_most_free() - retval = dict(count=gpu_stats.device_count, - device=stats["device"], - vram_free=int(stats["free"] - vram_buffer), - vram_total=int(stats["total"])) + retval: Dict[str, Union[int, str]] = dict(count=gpu_stats.device_count, + device=stats["device"], + vram_free=int(stats["free"] - vram_buffer), + vram_total=int(stats["total"])) logger.debug(retval) return retval - def _set_parallel_processing(self, multiprocess): + def _set_parallel_processing(self, multiprocess: bool) -> bool: """ Set whether to run detect, align, and mask together or separately. Parameters @@ -459,17 +476,17 @@ def _set_parallel_processing(self, multiprocess): logger.debug("Parallel processing disabled by amd") return False - logger.verbose("%s - %sMB free of %sMB", + logger.verbose("%s - %sMB free of %sMB", # type: ignore self._vram_stats["device"], self._vram_stats["vram_free"], self._vram_stats["vram_total"]) - if self._vram_stats["vram_free"] <= self._total_vram_required: + if cast(int, self._vram_stats["vram_free"]) <= self._total_vram_required: logger.warning("Not enough free VRAM for parallel processing. " "Switching to serial") return False return True - def _set_phases(self, multiprocess): + def _set_phases(self, multiprocess: bool) -> List[List[str]]: """ If not enough VRAM is available, then chunk :attr:`_flow` up into phases that will fit into VRAM, otherwise return the single flow. @@ -484,9 +501,9 @@ def _set_phases(self, multiprocess): The jobs to be undertaken split into phases that fit into GPU RAM """ force_single_process = not multiprocess or get_backend() == "amd" - phases = [] - current_phase = [] - available = self._vram_stats["vram_free"] + phases: List[List[str]] = [] + current_phase: List[str] = [] + available = cast(int, self._vram_stats["vram_free"]) for phase in self._flow: num_plugins = len([p for p in current_phase if self._vram_per_phase[p] > 0]) num_plugins += 1 if self._vram_per_phase[phase] > 0 else 0 @@ -518,48 +535,59 @@ def _set_phases(self, multiprocess): return phases # << INTERNAL PLUGIN HANDLING >> # - def _load_align(self, aligner, configfile, normalize_method, re_feed): + def _load_align(self, + aligner: str, + configfile: Optional[str], + normalize_method: Optional[str], + re_feed: int) -> Optional["Aligner"]: """ Set global arguments and load aligner plugin """ if aligner is None or aligner.lower() == "none": logger.debug("No aligner selected. Returning None") return None aligner_name = aligner.replace("-", "_").lower() logger.debug("Loading Aligner: '%s'", aligner_name) - aligner = PluginLoader.get_aligner(aligner_name)(exclude_gpus=self._exclude_gpus, - configfile=configfile, - normalize_method=normalize_method, - re_feed=re_feed, - instance=self._instance) - return aligner - - def _load_detect(self, detector, rotation, min_size, configfile): + plugin = PluginLoader.get_aligner(aligner_name)(exclude_gpus=self._exclude_gpus, + configfile=configfile, + normalize_method=normalize_method, + re_feed=re_feed, + instance=self._instance) + return plugin + + def _load_detect(self, + detector: str, + rotation: Optional[List[int]], + min_size: int, + configfile: Optional[str]) -> Optional["Detector"]: """ Set global arguments and load detector plugin """ if detector is None or detector.lower() == "none": logger.debug("No detector selected. Returning None") return None detector_name = detector.replace("-", "_").lower() logger.debug("Loading Detector: '%s'", detector_name) - detector = PluginLoader.get_detector(detector_name)(exclude_gpus=self._exclude_gpus, - rotation=rotation, - min_size=min_size, - configfile=configfile, - instance=self._instance) - return detector - - def _load_mask(self, masker, image_is_aligned, configfile): + plugin = PluginLoader.get_detector(detector_name)(exclude_gpus=self._exclude_gpus, + rotation=rotation, + min_size=min_size, + configfile=configfile, + instance=self._instance) + return plugin + + def _load_mask(self, + masker: str, + image_is_aligned: bool, + configfile: Optional[str]) -> Optional["Masker"]: """ Set global arguments and load masker plugin """ if masker is None or masker.lower() == "none": logger.debug("No masker selected. Returning None") return None masker_name = masker.replace("-", "_").lower() logger.debug("Loading Masker: '%s'", masker_name) - masker = PluginLoader.get_masker(masker_name)(exclude_gpus=self._exclude_gpus, + plugin = PluginLoader.get_masker(masker_name)(exclude_gpus=self._exclude_gpus, image_is_aligned=image_is_aligned, configfile=configfile, instance=self._instance) - return masker + return plugin - def _launch_plugin(self, phase): + def _launch_plugin(self, phase: str) -> None: """ Launch an extraction plugin """ logger.debug("Launching %s plugin", phase) in_qname = f"extract{self._instance}_{phase}_in" @@ -578,7 +606,7 @@ def _launch_plugin(self, phase): plugin.start() logger.debug("Launched %s plugin", phase) - def _set_extractor_batchsize(self): + def _set_extractor_batchsize(self) -> None: """ Sets the batch size of the requested plugins based on their vram, their vram_per_batch_requirements and the number of plugins being loaded in the current phase. @@ -597,15 +625,16 @@ def _set_extractor_batchsize(self): gpu_plugins = [p for p in self._current_phase if self._vram_per_phase[p] > 0] scaling = self._parallel_scaling.get(len(gpu_plugins), self._scaling_fallback) plugins_required = sum(self._vram_per_phase[p] for p in gpu_plugins) * scaling - if plugins_required + batch_required <= self._vram_stats["vram_free"]: + if plugins_required + batch_required <= cast(int, self._vram_stats["vram_free"]): logger.debug("Plugin requirements within threshold: (plugins_required: %sMB, " "vram_free: %sMB)", plugins_required, self._vram_stats["vram_free"]) return # Hacky split across plugins that use vram - available_vram = (self._vram_stats["vram_free"] - plugins_required) // len(gpu_plugins) + available_vram = (cast(int, self._vram_stats["vram_free"]) + - plugins_required) // len(gpu_plugins) self._set_plugin_batchsize(gpu_plugins, available_vram) - def set_aligner_normalization_method(self, method): + def set_aligner_normalization_method(self, method: str) -> None: """ Change the normalization method for faces fed into the aligner. Parameters @@ -613,10 +642,11 @@ def set_aligner_normalization_method(self, method): method: {"none", "clahe", "hist", "mean"} The normalization method to apply to faces prior to feeding into the aligner's model """ + assert self._align is not None logger.debug("Setting to: '%s'", method) self._align.set_normalize_method(method) - def _set_plugin_batchsize(self, gpu_plugins, available_vram): + def _set_plugin_batchsize(self, gpu_plugins: List[str], available_vram: float) -> None: """ Set the batch size for the given plugin based on given available vram. Do not update plugins which have a vram_per_batch of 0 (CPU plugins) due to zero division error. @@ -666,7 +696,7 @@ def _join_threads(self): for plugin in self._active_plugins: plugin.join() - def _check_and_raise_error(self): + def _check_and_raise_error(self) -> bool: """ Check all threads for errors and raise if one occurs """ for plugin in self._active_plugins: if plugin.check_and_raise_error(): diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index c631a526ee..44e55b66a6 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -3,7 +3,21 @@ import logging import os +import sys from importlib import import_module +from typing import Callable, List, Type, TYPE_CHECKING + +if TYPE_CHECKING: + from plugins.extract.detect._base import Detector + from plugins.extract.align._base import Aligner + from plugins.extract.mask._base import Masker + from plugins.train.model._base import ModelBase + from plugins.train.trainer._base import TrainerBase + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -21,7 +35,7 @@ class PluginLoader(): >>> aligner = PluginLoader.get_aligner('cv2-dnn') """ @staticmethod - def get_detector(name, disable_logging=False): + def get_detector(name: str, disable_logging: bool = False) -> Type["Detector"]: """ Return requested detector plugin Parameters @@ -40,7 +54,7 @@ def get_detector(name, disable_logging=False): return PluginLoader._import("extract.detect", name, disable_logging) @staticmethod - def get_aligner(name, disable_logging=False): + def get_aligner(name: str, disable_logging: bool = False) -> Type["Aligner"]: """ Return requested aligner plugin Parameters @@ -59,7 +73,7 @@ def get_aligner(name, disable_logging=False): return PluginLoader._import("extract.align", name, disable_logging) @staticmethod - def get_masker(name, disable_logging=False): + def get_masker(name: str, disable_logging: bool = False) -> Type["Masker"]: """ Return requested masker plugin Parameters @@ -78,7 +92,7 @@ def get_masker(name, disable_logging=False): return PluginLoader._import("extract.mask", name, disable_logging) @staticmethod - def get_model(name, disable_logging=False): + def get_model(name: str, disable_logging: bool = False) -> "ModelBase": """ Return requested training model plugin Parameters @@ -97,7 +111,7 @@ def get_model(name, disable_logging=False): return PluginLoader._import("train.model", name, disable_logging) @staticmethod - def get_trainer(name, disable_logging=False): + def get_trainer(name: str, disable_logging: bool = False) -> "TrainerBase": """ Return requested training trainer plugin Parameters @@ -116,7 +130,7 @@ def get_trainer(name, disable_logging=False): return PluginLoader._import("train.trainer", name, disable_logging) @staticmethod - def get_converter(category, name, disable_logging=False): + def get_converter(category: str, name: str, disable_logging: bool = False) -> Callable: """ Return requested converter plugin Converters work slightly differently to other faceswap plugins. They are created to do a @@ -136,10 +150,10 @@ def get_converter(category, name, disable_logging=False): :class:`plugins.convert` object: A converter sub plugin """ - return PluginLoader._import("convert.{}".format(category), name, disable_logging) + return PluginLoader._import(f"convert.{category}", name, disable_logging) @staticmethod - def _import(attr, name, disable_logging): + def _import(attr: str, name: str, disable_logging: bool): """ Import the plugin's module Parameters @@ -164,12 +178,14 @@ def _import(attr, name, disable_logging): return getattr(module, ttl) @staticmethod - def get_available_extractors(extractor_type, add_none=False, extend_plugin=False): + def get_available_extractors(extractor_type: Literal["align", "detect", "mask"], + add_none: bool = False, + extend_plugin: bool = False) -> List[str]: """ Return a list of available extractors of the given type Parameters ---------- - extractor_type: {'aligner', 'detector', 'masker'} + extractor_type: {'align', 'detect', 'mask'} The type of extractor to return the plugins for add_none: bool, optional Append "none" to the list of returned plugins. Default: False @@ -207,7 +223,7 @@ def get_available_extractors(extractor_type, add_none=False, extend_plugin=False return extractors @staticmethod - def get_available_models(): + def get_available_models() -> List[str]: """ Return a list of available training models Returns @@ -224,7 +240,7 @@ def get_available_models(): return models @staticmethod - def get_default_model(): + def get_default_model() -> str: """ Return the default training model plugin name Returns @@ -237,7 +253,7 @@ def get_default_model(): return 'original' if 'original' in models else models[0] @staticmethod - def get_available_convert_plugins(convert_category, add_none=True): + def get_available_convert_plugins(convert_category: str, add_none: bool = True) -> List[str]: """ Return a list of available converter plugins in the given category Parameters diff --git a/scripts/extract.py b/scripts/extract.py index 6176f2488e..81bce50cec 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -6,19 +6,17 @@ import logging import os import sys -from typing import List, Dict, TYPE_CHECKING, Optional +from argparse import Namespace +from typing import List, Dict, Optional from tqdm import tqdm from lib.image import encode_image, generate_thumbnail, ImagesLoader, ImagesSaver from lib.multithreading import MultiThread -from lib.utils import get_folder +from lib.utils import get_folder, _video_extensions from plugins.extract.pipeline import Extractor, ExtractMedia from scripts.fsmedia import Alignments, PostProcess, finalize -if TYPE_CHECKING: - import argparse - tqdm.monitor_interval = 0 # workaround for TqdmSynchronisationWarning logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -42,23 +40,14 @@ class Extract(): # pylint:disable=too-few-public-methods The arguments to be passed to the extraction process as generated from Faceswap's command line arguments """ - def __init__(self, arguments: argparse.Namespace) -> None: + def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: (args: %s", self.__class__.__name__, arguments) self._args = arguments - self._output_dir = None if self._args.skip_saving_faces else get_folder( - self._args.output_dir) - - logger.info("Output Directory: %s", self._args.output_dir) - self._images = ImagesLoader(self._args.input_dir, fast_count=True) - self._alignments = Alignments(self._args, True, self._images.is_video) - - self._existing_count = 0 - self._set_skip_list() + self._input_locations = self._get_input_locations() + self._validate_batchmode() - self._post_process = PostProcess(arguments) configfile = self._args.configfile if hasattr(self._args, "configfile") else None normalization = None if self._args.normalization == "none" else self._args.normalization - maskers = ["components", "extended"] maskers += self._args.masker if self._args.masker else [] self._extractor = Extractor(self._args.detector, @@ -71,6 +60,127 @@ def __init__(self, arguments: argparse.Namespace) -> None: min_size=self._args.min_size, normalize_method=normalization, re_feed=self._args.re_feed) + + def _get_input_locations(self) -> List[str]: + """ Obtain the full path to input locations. Will be a list of locations if batch mode is + selected, or a containing a single location if batch mode is not selected. + + Returns + ------- + list: + The list of input location paths + """ + if not self._args.batch_mode or os.path.isfile(self._args.input_dir): + return [self._args.input_dir] # Not batch mode or a single file + + retval = [os.path.join(self._args.input_dir, fname) + for fname in os.listdir(self._args.input_dir) + if os.path.isdir(os.path.join(self._args.input_dir, fname)) + or os.path.splitext(fname)[-1].lower() in _video_extensions] + logger.debug("Input locations: %s", retval) + return retval + + def _validate_batchmode(self): + """ Validate the command line arguments. + + If batch-mode selected and there is only one object to extract from, then batch mode is + disabled + + If processing in batch mode, some of the given arguments may not make sense, in which case + a warning is shown and those options are reset. + + """ + if not self._args.batch_mode: + return + + if os.path.isfile(self._args.input_dir): + logger.warning("Batch mode selected but input is not a folder. Switching to normal " + "mode") + self._args.batch_mode = False + + if not self._input_locations: + logger.error("Batch mode selected, but no valid files found in input location: '%s'. " + "Exiting.", self._args.input_dir) + sys.exit(1) + + if self._args.alignments_path: + logger.warning("Custom alignments path not supported for batch mode. " + "Reverting to default.") + self._args.alignments_path = None + + def _output_for_input(self, input_location: str) -> str: + """ Obtain the path to an output folder for faces for a given input location. + + If not running in batch mode, then the user supplied output location will be returned, + otherwise a sub-folder within the user supplied output location will be returned based on + the input filename + + Parameters + ---------- + input_location: str + The full path to an input video or folder of images + """ + if not self._args.batch_mode: + return self._args.output_dir + + retval = os.path.join(self._args.output_dir, + os.path.splitext(os.path.basename(input_location))[0]) + logger.debug("Returning output: '%s' for input: '%s'", retval, input_location) + return retval + + def process(self): + """ The entry point for triggering the Extraction Process. + + Should only be called from :class:`lib.cli.launcher.ScriptExecutor` + """ + logger.info('Starting, this may take a while...') + inputs = self._input_locations + if self._args.batch_mode: + logger.info("Batch mode selected processing: %s", self._input_locations) + for job_no, location in enumerate(self._input_locations): + if self._args.batch_mode: + logger.info("Processing job %s of %s: '%s'", job_no + 1, len(inputs), location) + arguments = Namespace(**self._args.__dict__) + arguments.input_dir = location + arguments.output_dir = self._output_for_input(location) + else: + arguments = self._args + extract = _Extract(self._extractor, arguments) + extract.process() + self._extractor.reset_phase_index() + + +class _Extract(): # pylint:disable=too-few-public-methods + """ The Actual extraction process. + + This class is called by the parent :class:`Extract` process + + Parameters + ---------- + extractor: :class:`~plugins.extract.pipeline.Extractor` + The extractor pipeline for running extractions + arguments: :class:`argparse.Namespace` + The arguments to be passed to the extraction process as generated from Faceswap's command + line arguments + """ + def __init__(self, + extractor: Extractor, + arguments: Namespace) -> None: + logger.debug("Initializing %s: (extractor: %s, args: %s)", self.__class__.__name__, + extractor, arguments) + self._args = arguments + self._output_dir = None if self._args.skip_saving_faces else get_folder( + self._args.output_dir) + + logger.info("Output Directory: %s", self._output_dir) + self._images = ImagesLoader(self._args.input_dir, fast_count=True) + self._alignments = Alignments(self._args, True, self._images.is_video) + self._extractor = extractor + + self._existing_count = 0 + self._set_skip_list() + + self._post_process = PostProcess(arguments) self._threads: List[MultiThread] = [] self._verify_output = False logger.debug("Initialized %s", self.__class__.__name__) @@ -121,7 +231,6 @@ def process(self) -> None: Should only be called from :class:`lib.cli.launcher.ScriptExecutor` """ - logger.info('Starting, this may take a while...') # from lib.queue_manager import queue_manager ; queue_manager.debug_monitor(3) self._threaded_redirector("load") self._run_extraction() From dc18c74eea0c7837a820d27628cb12b0824fa30e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 2 Sep 2022 00:19:57 +0100 Subject: [PATCH 716/981] Bugfix: Preview for extract in batch mode --- lib/gui/options.py | 27 ++- lib/gui/utils.py | 524 +++++++++++++++++++++++++++--------------- lib/multithreading.py | 2 +- 3 files changed, 359 insertions(+), 194 deletions(-) diff --git a/lib/gui/options.py b/lib/gui/options.py index 52f273250b..695988e4f2 100644 --- a/lib/gui/options.py +++ b/lib/gui/options.py @@ -21,8 +21,8 @@ class CliOptions(): def __init__(self): logger.debug("Initializing %s", self.__class__.__name__) self.categories = ("faceswap", "tools") - self.commands = dict() - self.opts = dict() + self.commands = {} + self.opts = {} self.build_options() logger.debug("Initialized %s", self.__class__.__name__) @@ -59,12 +59,12 @@ def _get_tools_cli_classes(): """ Parse the tools cli scripts for the argument classes """ base_path = os.path.realpath(os.path.dirname(sys.argv[0])) tools_dir = os.path.join(base_path, "tools") - mod_classes = dict() + mod_classes = {} for tool_name in sorted(os.listdir(tools_dir)): cli_file = os.path.join(tools_dir, tool_name, "cli.py") if os.path.exists(cli_file): mod = ".".join(("tools", tool_name, "cli")) - mod_classes["{}Args".format(tool_name.title())] = import_module(mod) + mod_classes[f"{tool_name.title()}Args"] = import_module(mod) return mod_classes def sort_commands(self, category, classes): @@ -88,7 +88,7 @@ def format_command_name(classname): def extract_options(self, cli_source, mod_classes): """ Extract the existing ArgParse Options into master options Dictionary """ - subopts = dict() + subopts = {} for classname in mod_classes: logger.debug("Processing: (classname: '%s')", classname) command = self.format_command_name(classname) @@ -176,7 +176,7 @@ def get_sysbrowser(self, option, options, command): actions.ContextFullPaths): return None - retval = dict() + retval = {} action_option = None if option.get("action_option", None) is not None: self.expand_action_option(option, options) @@ -253,11 +253,11 @@ def clear(self, command=None): def get_option_values(self, command=None): """ Return all or single command control titles with the associated tk_var value """ - ctl_dict = dict() + ctl_dict = {} for cmd, opts in self.opts.items(): if command and command != cmd: continue - cmd_dict = dict() + cmd_dict = {} for key, val in opts.items(): if not isinstance(val, dict): continue @@ -276,11 +276,15 @@ def get_one_option_variable(self, command, title): def gen_cli_arguments(self, command): """ Return the generated cli arguments for the selected command """ + output_dir = None + batch_mode = False for _, option in self.gen_command_options(command): optval = str(option["cpanel_option"].get()) opt = option["opts"][0] - if command in ("extract", "convert") and opt == "-o": - get_images().set_faceswap_output_path(optval) + if command in ("extract", "convert") and opt == "-o": # Output location for preview + output_dir = optval + if command == "extract" and opt == "-b": # Check for batch mode + batch_mode = optval if optval in ("False", ""): continue if optval == "True": @@ -295,3 +299,6 @@ def gen_cli_arguments(self, command): else: opt = (opt, optval) yield opt + + if command in ("extract", "convert") and output_dir is not None: + get_images().set_faceswap_output_path(output_dir, batch_mode=batch_mode) diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 8189fb7c08..4c45f75c08 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """ Utility functions for the GUI """ +from dataclasses import dataclass, field import logging import os import platform @@ -8,6 +9,8 @@ from tkinter import filedialog from threading import Event, Thread +from typing import (Any, Callable, cast, Dict, IO, List, Optional, + Sequence, Tuple, Type, TYPE_CHECKING, Union) from queue import Queue import numpy as np @@ -18,14 +21,30 @@ from .project import Project, Tasks from .theme import Style +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + +if TYPE_CHECKING: + from types import TracebackType + from .options import CliOptions + from .custom_widgets import StatusBar + from .command import CommandNotebook + from .command import ToolsNotebook + from lib.multithreading import _ErrorType + + logger = logging.getLogger(__name__) # pylint: disable=invalid-name -_CONFIG = None -_IMAGES = None -_PREVIEW_TRIGGER = None +_CONFIG: Optional["Config"] = None +_IMAGES: Optional["Images"] = None +_PREVIEW_TRIGGER: Optional["PreviewTrigger"] = None PATHCACHE = os.path.join(os.path.realpath(os.path.dirname(sys.argv[0])), "lib", "gui", ".cache") -def initialize_config(root, cli_opts, statusbar): +def initialize_config(root: tk.Tk, + cli_opts: "CliOptions", + statusbar: "StatusBar") -> Optional["Config"]: """ Initialize the GUI Master :class:`Config` and add to global constant. This should only be called once on first GUI startup. Future access to :class:`Config` @@ -35,10 +54,16 @@ def initialize_config(root, cli_opts, statusbar): ---------- root: :class:`tkinter.Tk` The root Tkinter object - cli_opts: :class:`lib.gui.options.CliOpts` + cli_opts: :class:`lib.gui.options.CliOptions` The command line options object statusbar: :class:`lib.gui.custom_widgets.StatusBar` The GUI Status bar + + Returns + ------- + :class:`Config` or ``None`` + ``None`` if the config has already been initialized otherwise the global configuration + options """ global _CONFIG # pylint: disable=global-statement if _CONFIG is not None: @@ -49,7 +74,7 @@ def initialize_config(root, cli_opts, statusbar): return _CONFIG -def get_config(): +def get_config() -> "Config": """ Get the Master GUI configuration. Returns @@ -57,10 +82,11 @@ def get_config(): :class:`Config` The Master GUI Config """ + assert _CONFIG is not None return _CONFIG -def initialize_images(): +def initialize_images() -> None: """ Initialize the :class:`Images` handler and add to global constant. This should only be called once on first GUI startup. Future access to :class:`Images` @@ -73,7 +99,7 @@ def initialize_images(): _IMAGES = Images() -def get_images(): +def get_images() -> "Images": """ Get the Master GUI Images handler. Returns @@ -81,15 +107,22 @@ def get_images(): :class:`Images` The Master GUI Images handler """ + assert _IMAGES is not None return _IMAGES +_FileType = Literal["default", "alignments", "config_project", "config_task", + "config_all", "csv", "image", "ini", "state", "log", "video"] +_HandleType = Literal["open", "save", "filename", "filename_multi", "save_filename", + "context", "dir"] + + class FileHandler(): # pylint:disable=too-few-public-methods """ Handles all GUI File Dialog actions and tasks. Parameters ---------- - handle_type: ['open', 'save', 'filename', 'filename_multi', 'save_filename', 'context', `dir`] + handle_type: ['open', 'save', 'filename', 'filename_multi', 'save_filename', 'context', 'dir'] The type of file dialog to return. `open` and `save` will perform the open and save actions and return the file. `filename` returns the filename from an `open` dialog. `filename_multi` allows for multi-selection of files and returns a list of files selected. @@ -113,7 +146,7 @@ class FileHandler(): # pylint:disable=too-few-public-methods Required for context handling file dialog, otherwise unused. Default: ``None`` action: str, optional Required for context handling file dialog, otherwise unused. Default: ``None`` - variable: :class:`tkinter.StringVar`, optional + variable: str, optional Required for context handling file dialog, otherwise unused. The variable to associate with this file dialog. Default: ``None`` @@ -130,8 +163,15 @@ class FileHandler(): # pylint:disable=too-few-public-methods '/path/to/selected/video.mp4' """ - def __init__(self, handle_type, file_type, title=None, initial_folder=None, initial_file=None, - command=None, action=None, variable=None): + def __init__(self, + handle_type: _HandleType, + file_type: _FileType, + title: Optional[str] = None, + initial_folder: Optional[str] = None, + initial_file: Optional[str] = None, + command: Optional[str] = None, + action: Optional[str] = None, + variable: Optional[str] = None) -> None: logger.debug("Initializing %s: (handle_type: '%s', file_type: '%s', title: '%s', " "initial_folder: '%s', initial_file: '%s', command: '%s', action: '%s', " "variable: %s)", self.__class__.__name__, handle_type, file_type, title, @@ -152,11 +192,11 @@ def __init__(self, handle_type, file_type, title=None, initial_folder=None, init logger.debug("Initialized %s", self.__class__.__name__) @property - def _filetypes(self): + def _filetypes(self) -> Dict[str, List[Tuple[str, str]]]: """ dict: The accepted extensions for each file type for opening/saving """ all_files = ("All files", "*.*") filetypes = dict( - default=(all_files,), + default=[all_files], alignments=[("Faceswap Alignments", "*.fsa"), all_files], config_project=[("Faceswap Project files", "*.fsw"), all_files], config_task=[("Faceswap Task files", "*.fst"), all_files], @@ -193,11 +233,11 @@ def _filetypes(self): multi = [f"{key.title()} Files"] multi.append(" ".join([ftype[1] for ftype in filetypes[key] if ftype[0] != "All files"])) - filetypes[key].insert(0, tuple(multi)) + filetypes[key].insert(0, cast(Tuple[str, str], tuple(multi))) return filetypes @property - def _contexts(self): + def _contexts(self) -> Dict[str, Dict[str, Union[str, Dict[str, str]]]]: """dict: Mapping of commands, actions and their corresponding file dialog for context handle types. """ return dict(effmpeg=dict(input={"extract": "filename", @@ -218,7 +258,7 @@ def _contexts(self): "slice": "save_filename"})) @classmethod - def _set_dummy_master(cls): + def _set_dummy_master(cls) -> Optional[tk.Frame]: """ Add an option to force black font on Linux file dialogs KDE issue that displays light font on white background). @@ -232,21 +272,22 @@ def _set_dummy_master(cls): The dummy master frame for Linux systems, otherwise ``None`` """ if platform.system().lower() == "linux": - retval = tk.Frame() - retval.option_add("*foreground", "black") + frame = tk.Frame() + frame.option_add("*foreground", "black") + retval: Optional[tk.Frame] = frame else: retval = None return retval - def _remove_dummy_master(self): + def _remove_dummy_master(self) -> None: """ Destroy the dummy master widget on Linux systems. """ - if platform.system().lower() != "linux": + if platform.system().lower() != "linux" or self._dummy_master is None: return self._dummy_master.destroy() del self._dummy_master self._dummy_master = None - def _set_defaults(self): + def _set_defaults(self) -> Dict[str, Optional[str]]: """ Set the default file type for the file dialog. Generally the first found file type will be used, but this is overridden if it is not appropriate. @@ -255,16 +296,24 @@ def _set_defaults(self): dict: The default file extension for each file type """ - defaults = {key: next(ext for ext in val[0][1].split(" ")).replace("*", "") - for key, val in self._filetypes.items()} + defaults: Dict[str, Optional[str]] = { + key: next(ext for ext in val[0][1].split(" ")).replace("*", "") + for key, val in self._filetypes.items()} defaults["default"] = None defaults["video"] = ".mp4" defaults["image"] = ".png" logger.debug(defaults) return defaults - def _set_kwargs(self, title, initial_folder, initial_file, file_type, command, action, - variable=None): + def _set_kwargs(self, + title: Optional[str], + initial_folder: Optional[str], + initial_file: Optional[str], + file_type: _FileType, + command: Optional[str], + action: Optional[str], + variable: Optional[str] = None + ) -> Dict[str, Union[None, tk.Frame, str, List[Tuple[str, str]]]]: """ Generate the required kwargs for the requested file dialog browser. Parameters @@ -284,7 +333,7 @@ def _set_kwargs(self, title, initial_folder, initial_file, file_type, command, a Required for context handling file dialog, otherwise unused. action: str Required for context handling file dialog, otherwise unused. - variable: :class:`tkinter.StringVar`, optional + variable: str, optional Required for context handling file dialog, otherwise unused. The variable to associate with this file dialog. Default: ``None`` @@ -297,9 +346,11 @@ def _set_kwargs(self, title, initial_folder, initial_file, file_type, command, a "file_type: '%s', command: '%s': action: '%s', variable: '%s')", title, initial_folder, initial_file, file_type, command, action, variable) - kwargs = dict(master=self._dummy_master) + kwargs: Dict[str, Union[None, tk.Frame, str, + List[Tuple[str, str]]]] = dict(master=self._dummy_master) if self._handletype.lower() == "context": + assert command is not None and action is not None and variable is not None self._set_context_handletype(command, action, variable) if title is not None: @@ -323,7 +374,7 @@ def _set_kwargs(self, title, initial_folder, initial_file, file_type, command, a logger.debug("Set Kwargs: %s", kwargs) return kwargs - def _set_context_handletype(self, command, action, variable): + def _set_context_handletype(self, command: str, action: str, variable: str) -> None: """ Sets the correct handle type based on context. Parameters @@ -332,53 +383,55 @@ def _set_context_handletype(self, command, action, variable): The command that is being executed. Used to look up the context actions action: str The action that is being performed. Used to look up the correct file dialog - variable: :class:`tkinter.StringVar` + variable: str The variable associated with this file dialog """ if self._contexts[command].get(variable, None) is not None: - handletype = self._contexts[command][variable][action] + handletype = cast(Dict[str, Dict[str, Dict[str, str]]], + self._contexts)[command][variable][action] else: - handletype = self._contexts[command][action] + handletype = cast(Dict[str, Dict[str, str]], + self._contexts)[command][action] logger.debug(handletype) - self._handletype = handletype + self._handletype = cast(_HandleType, handletype) - def _open(self): + def _open(self) -> Optional[IO]: """ Open a file. """ logger.debug("Popping Open browser") - return filedialog.askopenfile(**self._kwargs) + return filedialog.askopenfile(**self._kwargs) # type: ignore - def _save(self): + def _save(self) -> Optional[IO]: """ Save a file. """ logger.debug("Popping Save browser") - return filedialog.asksaveasfile(**self._kwargs) + return filedialog.asksaveasfile(**self._kwargs) # type: ignore - def _dir(self): + def _dir(self) -> str: """ Get a directory location. """ logger.debug("Popping Dir browser") return filedialog.askdirectory(**self._kwargs) - def _savedir(self): + def _savedir(self) -> str: """ Get a save directory location. """ logger.debug("Popping SaveDir browser") return filedialog.askdirectory(**self._kwargs) - def _filename(self): + def _filename(self) -> str: """ Get an existing file location. """ logger.debug("Popping Filename browser") return filedialog.askopenfilename(**self._kwargs) - def _filename_multi(self): + def _filename_multi(self) -> Tuple[str, ...]: """ Get multiple existing file locations. """ logger.debug("Popping Filename browser") return filedialog.askopenfilenames(**self._kwargs) - def _save_filename(self): + def _save_filename(self) -> str: """ Get a save file location. """ logger.debug("Popping Save Filename browser") return filedialog.asksaveasfilename(**self._kwargs) @staticmethod - def _nothing(): # pylint: disable=useless-return + def _nothing() -> None: # pylint: disable=useless-return """ Method that does nothing, used for disabling open/save pop up. """ logger.debug("Popping Nothing browser") return @@ -390,22 +443,27 @@ class Images(): This class should be initialized on GUI startup through :func:`initialize_images`. Any further access to this class should be through :func:`get_images`. """ - def __init__(self): + def __init__(self) -> None: logger.debug("Initializing %s", self.__class__.__name__) self._pathpreview = os.path.join(PATHCACHE, "preview") - self._pathoutput = None - self._previewoutput = None - self._previewtrain = {} - self._previewcache = dict(modified=None, # cache for extract and convert - images=None, - filenames=[], - placeholder=None) + self._pathoutput: Optional[str] = None + self._batch_mode = False + self._previewoutput: Optional[Tuple[Image.Image, ImageTk.PhotoImage]] = None + self._previewtrain: Dict[str, List[Union[Image.Image, + ImageTk.PhotoImage, + None, + float]]] = {} + self._previewcache: Dict[str, Union[None, float, np.ndarray, List[str]]] = dict( + modified=None, # cache for extract and convert + images=None, + filenames=[], + placeholder=None) self._errcount = 0 self._icons = self._load_icons() logger.debug("Initialized %s", self.__class__.__name__) @property - def previewoutput(self): + def previewoutput(self) -> Optional[Tuple[Image.Image, ImageTk.PhotoImage]]: """ Tuple or ``None``: First item in the tuple is the extract or convert preview image (:class:`PIL.Image`), the second item is the image in a format that tkinter can display (:class:`PIL.ImageTK.PhotoImage`). @@ -415,7 +473,7 @@ def previewoutput(self): return self._previewoutput @property - def previewtrain(self): + def previewtrain(self) -> Dict[str, List[Union[Image.Image, ImageTk.PhotoImage, None, float]]]: """ dict or ``None``: The training preview images. Dictionary key is the image name (`str`). Dictionary values are a `list` of the training image (:class:`PIL.Image`), the image formatted for tkinter display (:class:`PIL.ImageTK.PhotoImage`), the last @@ -427,7 +485,7 @@ def previewtrain(self): return self._previewtrain @property - def icons(self): + def icons(self) -> Dict[str, ImageTk.PhotoImage]: """ dict: The faceswap icons for all parts of the GUI. The dictionary key is the icon name (`str`) the value is the icon sized and formatted for display (:class:`PIL.ImageTK.PhotoImage`). @@ -442,7 +500,7 @@ def icons(self): return self._icons @staticmethod - def _load_icons(): + def _load_icons() -> Dict[str, ImageTk.PhotoImage]: """ Scan the icons cache folder and load the icons into :attr:`icons` for retrieval throughout the GUI. @@ -454,7 +512,7 @@ def _load_icons(): """ size = get_config().user_config_dict.get("icon_size", 16) size = int(round(size * get_config().scaling_factor)) - icons = {} + icons: Dict[str, ImageTk.PhotoImage] = {} pathicons = os.path.join(PATHCACHE, "icons") for fname in os.listdir(pathicons): name, ext = os.path.splitext(fname) @@ -466,7 +524,7 @@ def _load_icons(): logger.debug(icons) return icons - def set_faceswap_output_path(self, location): + def set_faceswap_output_path(self, location: str, batch_mode: bool = False) -> None: """ Set the path that will contain the output from an Extract or Convert task. Required so that the GUI can fetch output images to display for return in @@ -476,10 +534,13 @@ def set_faceswap_output_path(self, location): ---------- location: str The output location that has been specified for an Extract or Convert task + batch_mode: bool + ``True`` if extracting in batch mode otherwise False """ self._pathoutput = location + self._batch_mode = batch_mode - def delete_preview(self): + def delete_preview(self) -> None: """ Delete the preview files in the cache folder and reset the image cache. Should be called when terminating tasks, or when Faceswap starts up or shuts down. @@ -490,7 +551,7 @@ def delete_preview(self): fullitem = os.path.join(self._pathpreview, item) logger.debug("Deleting: '%s'", fullitem) os.remove(fullitem) - for fname in self._previewcache["filenames"]: + for fname in cast(List[str], self._previewcache["filenames"]): if os.path.basename(fname) == ".gui_preview.jpg": logger.debug("Deleting: '%s'", fname) try: @@ -499,10 +560,11 @@ def delete_preview(self): logger.debug("File does not exist: %s", fname) self._clear_image_cache() - def _clear_image_cache(self): + def _clear_image_cache(self) -> None: """ Clear all cached images. """ logger.debug("Clearing image cache") self._pathoutput = None + self._batch_mode = False self._previewoutput = None self._previewtrain = {} self._previewcache = dict(modified=None, # cache for extract and convert @@ -511,7 +573,7 @@ def _clear_image_cache(self): placeholder=None) @staticmethod - def _get_images(image_path): + def _get_images(image_path: str) -> List[str]: """ Get the images stored within the given directory. Parameters @@ -528,13 +590,13 @@ def _get_images(image_path): logger.debug("Getting images: '%s'", image_path) if not os.path.isdir(image_path): logger.debug("Folder does not exist") - return None + return [] files = [os.path.join(image_path, f) for f in os.listdir(image_path) if f.lower().endswith((".png", ".jpg"))] logger.debug("Image files: %s", files) return files - def load_latest_preview(self, thumbnail_size, frame_dims): + def load_latest_preview(self, thumbnail_size: int, frame_dims: Tuple[int, int]) -> None: """ Load the latest preview image for extract and convert. Retrieves the latest preview images from the faceswap output folder, resizes to thumbnails @@ -550,7 +612,9 @@ def load_latest_preview(self, thumbnail_size, frame_dims): """ logger.debug("Loading preview image: (thumbnail_size: %s, frame_dims: %s)", thumbnail_size, frame_dims) - image_files = self._get_images(self._pathoutput) + assert self._pathoutput is not None + image_path = self._get_newest_folder() if self._batch_mode else self._pathoutput + image_files = self._get_images(image_path) gui_preview = os.path.join(self._pathoutput, ".gui_preview.jpg") if not image_files or (len(image_files) == 1 and gui_preview not in image_files): logger.debug("No preview to display") @@ -582,7 +646,27 @@ def load_latest_preview(self, thumbnail_size, frame_dims): logger.debug("Displaying preview: %s", self._previewcache["filenames"]) self._previewoutput = (show_image, ImageTk.PhotoImage(show_image)) - def _get_newest_filenames(self, image_files): + def _get_newest_folder(self) -> str: + """ Obtain the most recent folder created in the extraction output folder when processing + in batch mode. + + Returns + ------- + str + The most recently modified folder within the parent output folder. If no folders have + been created, returns the parent output folder + + """ + assert self._pathoutput is not None + folders = [os.path.join(self._pathoutput, folder) + for folder in os.listdir(self._pathoutput) + if os.path.isdir(os.path.join(self._pathoutput, folder))] + folders.sort(key=os.path.getmtime) + retval = folders[-1] if folders else self._pathoutput + logger.debug("sorted folders: %s, return value: %s", folders, retval) + return retval + + def _get_newest_filenames(self, image_files: List[str]) -> List[str]: """ Return image filenames that have been modified since the last check. Parameters @@ -599,16 +683,19 @@ def _get_newest_filenames(self, image_files): retval = image_files else: retval = [fname for fname in image_files - if os.path.getmtime(fname) > self._previewcache["modified"]] + if os.path.getmtime(fname) > cast(float, self._previewcache["modified"])] if not retval: logger.debug("No new images in output folder") else: - self._previewcache["modified"] = max([os.path.getmtime(img) for img in retval]) + self._previewcache["modified"] = max(os.path.getmtime(img) for img in retval) logger.debug("Number new images: %s, Last Modified: %s", len(retval), self._previewcache["modified"]) return retval - def _load_images_to_cache(self, image_files, frame_dims, thumbnail_size): + def _load_images_to_cache(self, + image_files: List[str], + frame_dims: Tuple[int, int], + thumbnail_size: int) -> bool: """ Load preview images to the image cache. Load new images and append to cache, filtering the cache the number of thumbnails that will @@ -634,7 +721,7 @@ def _load_images_to_cache(self, image_files, frame_dims, thumbnail_size): logger.debug("num_images: %s", num_images) if num_images == 0: return False - samples = [] + samples: List[np.ndarray] = [] start_idx = len(image_files) - num_images if len(image_files) > num_images else 0 show_files = sorted(image_files, key=os.path.getctime)[start_idx:] dropped_files = [] @@ -667,39 +754,77 @@ def _load_images_to_cache(self, image_files, frame_dims, thumbnail_size): dropped_files.append(fname) continue - if img.size[0] != img.size[1]: - # Pad to square - new_img = Image.new("RGB", (thumbnail_size, thumbnail_size)) - new_img.paste(img, ((thumbnail_size - img.size[0])//2, - (thumbnail_size - img.size[1])//2)) - img = new_img - draw = ImageDraw.Draw(img) - draw.rectangle(((0, 0), (thumbnail_size, thumbnail_size)), outline="#E5E5E5", width=1) - samples.append(np.array(img)) - - samples = np.array(samples) - if not np.any(samples): + samples.append(self._pad_and_border(img, thumbnail_size)) + + return self._process_samples(samples, + [fname for fname in show_files if fname not in dropped_files], + num_images) + + def _pad_and_border(self, image: Image.Image, size: int) -> np.ndarray: + """ Pad rectangle images to a square and draw borders + + Parameters + ---------- + image: :class:`PIL.Image` + The image to process + size: int + The size of the image as it should be displayed + + Returns + ------- + :class:`PIL.Image`: + The processed image + """ + if image.size[0] != image.size[1]: + # Pad to square + new_img = Image.new("RGB", (size, size)) + new_img.paste(image, ((size - image.size[0]) // 2, (size - image.size[1]) // 2)) + image = new_img + draw = ImageDraw.Draw(image) + draw.rectangle(((0, 0), (size, size)), outline="#E5E5E5", width=1) + retval = np.array(image) + logger.trace("image shape: %s", retval.shape) # type: ignore + return retval + + def _process_samples(self, + samples: List[np.ndarray], + filenames: List[str], + num_images: int) -> bool: + """ Process the latest sample images into a displayable image. + + Parameters + ---------- + samples: list + The list of extract/convert preview images to display + filenames: list + The full path to the filenames corresponding to the images + num_images: int + The number of images that should be displayed + + Returns + ------- + bool + ``True`` if samples succesfully compiled otherwise ``False`` + """ + asamples = np.array(samples) + if not np.any(asamples): logger.debug("No preview images collected.") return False - if dropped_files: - logger.debug("Removing dropped files: %s", dropped_files) - show_files = [fname for fname in show_files if fname not in dropped_files] - - self._previewcache["filenames"] = (self._previewcache["filenames"] + - show_files)[-num_images:] - cache = self._previewcache["images"] + self._previewcache["filenames"] = (cast(List[str], self._previewcache["filenames"]) + + filenames)[-num_images:] + cache = cast(Optional[np.ndarray], self._previewcache["images"]) if cache is None: logger.debug("Creating new cache") - cache = samples[-num_images:] + cache = asamples[-num_images:] else: logger.debug("Appending to existing cache") - cache = np.concatenate((cache, samples))[-num_images:] + cache = np.concatenate((cache, asamples))[-num_images:] self._previewcache["images"] = cache - logger.debug("Cache shape: %s", self._previewcache["images"].shape) + logger.debug("Cache shape: %s", cast(np.ndarray, self._previewcache["images"]).shape) return True - def _place_previews(self, frame_dims): + def _place_previews(self, frame_dims: Tuple[int, int]) -> Image.Image: """ Format the preview thumbnails stored in the cache into a grid fitting the display panel. @@ -709,13 +834,14 @@ def _place_previews(self, frame_dims): The (width (`int`), height (`int`)) of the display panel that will display the preview Returns + ------- :class:`PIL.Image`: The final preview display image """ if self._previewcache.get("images", None) is None: logger.debug("No images in cache. Returning None") return None - samples = self._previewcache["images"].copy() + samples = cast(np.ndarray, self._previewcache["images"]).copy() num_images, thumbnail_size = samples.shape[:2] if self._previewcache["placeholder"] is None: self._create_placeholder(thumbnail_size) @@ -729,16 +855,16 @@ def _place_previews(self, frame_dims): remainder = (cols * rows) - num_images if remainder != 0: logger.debug("Padding sample display. Remainder: %s", remainder) - placeholder = np.concatenate([np.expand_dims(self._previewcache["placeholder"], - 0)] * remainder) + placeholder = np.concatenate([np.expand_dims( + cast(np.ndarray, self._previewcache["placeholder"]), 0)] * remainder) samples = np.concatenate((samples, placeholder)) - display = np.vstack([np.hstack(samples[row * cols: (row + 1) * cols]) + display = np.vstack([np.hstack(cast(Sequence, samples[row * cols: (row + 1) * cols])) for row in range(rows)]) logger.debug("display shape: %s", display.shape) return Image.fromarray(display) - def _create_placeholder(self, thumbnail_size): + def _create_placeholder(self, thumbnail_size: int) -> None: """ Create a placeholder image for when there are fewer thumbnails available than columns to display them. @@ -755,7 +881,7 @@ def _create_placeholder(self, thumbnail_size): self._previewcache["placeholder"] = placeholder logger.debug("Created placeholder. shape: %s", placeholder.shape) - def load_training_preview(self): + def load_training_preview(self) -> None: """ Load the training preview images. Reads the training image currently stored in the cache folder and loads them to @@ -776,6 +902,8 @@ def load_training_preview(self): try: logger.debug("Displaying preview: '%s'", img) size = self._get_current_size(name) + if not size: + return self._previewtrain[name] = [Image.open(img), None, modified] self.resize_image(name, size) self._errcount = 0 @@ -790,9 +918,9 @@ def load_training_preview(self): else: logger.error("Error reading the preview file for '%s'", img) print(f"Error reading the preview file for {name}") - self._previewtrain[name] = None + del self._previewtrain[name] - def _get_current_size(self, name): + def _get_current_size(self, name: str) -> Optional[Tuple[int, int]]: """ Return the size of the currently displayed training preview image. Parameters @@ -808,16 +936,16 @@ def _get_current_size(self, name): The height of the training image """ logger.debug("Getting size: '%s'", name) - if not self._previewtrain.get(name, None): + if not self._previewtrain.get(name): return None - img = self._previewtrain[name][1] + img = cast(Image.Image, self._previewtrain[name][1]) if not img: return None logger.debug("Got size: (name: '%s', width: '%s', height: '%s')", name, img.width(), img.height()) return img.width(), img.height() - def resize_image(self, name, frame_dims): + def resize_image(self, name: str, frame_dims: Tuple[int, int]): """ Resize the training preview image based on the passed in frame size. If the canvas that holds the preview image changes, update the image size @@ -831,7 +959,7 @@ def resize_image(self, name, frame_dims): The (width (`int`), height (`int`)) of the display panel that will display the preview """ logger.debug("Resizing image: (name: '%s', frame_dims: %s", name, frame_dims) - displayimg = self._previewtrain[name][0] + displayimg = cast(Image.Image, self._previewtrain[name][0]) if frame_dims: frameratio = float(frame_dims[0]) / float(frame_dims[1]) imgratio = float(displayimg.size[0]) / float(displayimg.size[1]) @@ -858,6 +986,18 @@ def resize_image(self, name, frame_dims): self._previewtrain[name][1] = ImageTk.PhotoImage(displayimg) +@dataclass +class _GuiObjects: + """ Data class for commonly accessed GUI Objects """ + cli_opts: "CliOptions" + tk_vars: Dict[str, Union[tk.BooleanVar, tk.StringVar]] + project: Project + tasks: Tasks + status_bar: "StatusBar" + default_options: Dict[str, Dict[str, Any]] = field(default_factory=dict) + command_notebook: Optional["CommandNotebook"] = None + + class Config(): """ The centralized configuration class for holding items that should be made available to all parts of the GUI. @@ -874,22 +1014,21 @@ class Config(): statusbar: :class:`lib.gui.custom_widgets.StatusBar` The GUI Status bar """ - def __init__(self, root, cli_opts, statusbar): + def __init__(self, root: tk.Tk, cli_opts: "CliOptions", statusbar: "StatusBar") -> None: logger.debug("Initializing %s: (root %s, cli_opts: %s, statusbar: %s)", self.__class__.__name__, root, cli_opts, statusbar) - self._default_font = tk.font.nametofont("TkDefaultFont").configure()["family"] + self._default_font = cast(dict, tk.font.nametofont("TkDefaultFont").configure())["family"] self._constants = dict( root=root, scaling_factor=self._get_scaling(root), default_font=self._default_font) - self._gui_objects = dict( + self._gui_objects = _GuiObjects( cli_opts=cli_opts, tk_vars=self._set_tk_vars(), project=Project(self, FileHandler), tasks=Tasks(self, FileHandler), - default_options=None, - status_bar=statusbar, - command_notebook=None) # set in command.py + status_bar=statusbar) + self._user_config = UserConfig(None) self._style = Style(self.default_font, root, PATHCACHE) self._user_theme = self._style.user_theme @@ -897,96 +1036,100 @@ def __init__(self, root, cli_opts, statusbar): # Constants @property - def root(self): + def root(self) -> tk.Tk: """ :class:`tkinter.Tk`: The root tkinter window. """ return self._constants["root"] @property - def scaling_factor(self): + def scaling_factor(self) -> float: """ float: The scaling factor for current display. """ return self._constants["scaling_factor"] @property - def pathcache(self): + def pathcache(self) -> str: """ str: The path to the GUI cache folder """ return PATHCACHE # GUI Objects @property - def cli_opts(self): + def cli_opts(self) -> "CliOptions": """ :class:`lib.gui.options.CliOptions`: The command line options for this GUI Session. """ - return self._gui_objects["cli_opts"] + return self._gui_objects.cli_opts @property - def tk_vars(self): + def tk_vars(self) -> Dict[str, Union[tk.StringVar, tk.BooleanVar]]: """ dict: The global tkinter variables. """ - return self._gui_objects["tk_vars"] + return self._gui_objects.tk_vars @property - def project(self): + def project(self) -> Project: """ :class:`lib.gui.project.Project`: The project session handler. """ - return self._gui_objects["project"] + return self._gui_objects.project @property - def tasks(self): + def tasks(self) -> Tasks: """ :class:`lib.gui.project.Tasks`: The session tasks handler. """ - return self._gui_objects["tasks"] + return self._gui_objects.tasks @property - def default_options(self): + def default_options(self) -> Dict[str, Dict[str, Any]]: """ dict: The default options for all tabs """ - return self._gui_objects["default_options"] + return self._gui_objects.default_options @property - def statusbar(self): + def statusbar(self) -> "StatusBar": """ :class:`lib.gui.custom_widgets.StatusBar`: The GUI StatusBar :class:`tkinter.ttk.Frame`. """ - return self._gui_objects["status_bar"] + return self._gui_objects.status_bar @property - def command_notebook(self): - """ :class:`lib.gui.command.CommandNoteboook`: The main Faceswap Command Notebook. """ - return self._gui_objects["command_notebook"] + def command_notebook(self) -> Optional["CommandNotebook"]: + """ :class:`lib.gui.command.CommandNotebook`: The main Faceswap Command Notebook. """ + return self._gui_objects.command_notebook # Convenience GUI Objects @property - def tools_notebook(self): + def tools_notebook(self) -> "ToolsNotebook": """ :class:`lib.gui.command.ToolsNotebook`: The Faceswap Tools sub-Notebook. """ + assert self.command_notebook is not None return self.command_notebook.tools_notebook @property - def modified_vars(self): + def modified_vars(self) -> Dict[str, "tk.BooleanVar"]: """ dict: The command notebook modified tkinter variables. """ + assert self.command_notebook is not None return self.command_notebook.modified_vars @property - def _command_tabs(self): + def _command_tabs(self) -> Dict[str, int]: """ dict: Command tab titles with their IDs. """ + assert self.command_notebook is not None return self.command_notebook.tab_names @property - def _tools_tabs(self): + def _tools_tabs(self) -> Dict[str, int]: """ dict: Tools command tab titles with their IDs. """ + assert self.command_notebook is not None return self.command_notebook.tools_tab_names # Config @property - def user_config(self): + def user_config(self) -> UserConfig: """ dict: The GUI config in dict form. """ return self._user_config @property - def user_config_dict(self): + def user_config_dict(self) -> Dict[str, Any]: # TODO Dataclass """ dict: The GUI config in dict form. """ return self._user_config.config_dict @property - def user_theme(self): + def user_theme(self) -> Dict[str, Any]: # TODO Dataclass """ dict: The GUI theme selection options. """ return self._user_theme @property - def default_font(self): + def default_font(self) -> Tuple[str, int]: """ tuple: The selected font as configured in user settings. First item is the font (`str`) second item the font size (`int`). """ font = self.user_config_dict["font"] @@ -994,7 +1137,7 @@ def default_font(self): return (font, self.user_config_dict["font_size"]) @staticmethod - def _get_scaling(root): + def _get_scaling(root) -> float: """ Get the display DPI. Returns @@ -1007,7 +1150,7 @@ def _get_scaling(root): logger.debug("dpi: %s, scaling: %s'", dpi, scaling) return scaling - def set_default_options(self): + def set_default_options(self) -> None: """ Set the default options for :mod:`lib.gui.projects` The Default GUI options are stored on Faceswap startup. @@ -1017,10 +1160,10 @@ def set_default_options(self): """ default = self.cli_opts.get_option_values() logger.debug(default) - self._gui_objects["default_options"] = default + self._gui_objects.default_options = default self.project.set_default_options() - def set_command_notebook(self, notebook): + def set_command_notebook(self, notebook: "CommandNotebook") -> None: """ Set the command notebook to the :attr:`command_notebook` attribute and enable the modified callback for :attr:`project`. @@ -1030,10 +1173,10 @@ def set_command_notebook(self, notebook): The main command notebook for the Faceswap GUI """ logger.debug("Setting commane notebook: %s", notebook) - self._gui_objects["command_notebook"] = notebook + self._gui_objects.command_notebook = notebook self.project.set_modified_callback() - def set_active_tab_by_name(self, name): + def set_active_tab_by_name(self, name: str) -> None: """ Sets the :attr:`command_notebook` or :attr:`tools_notebook` to active based on given name. @@ -1042,6 +1185,7 @@ def set_active_tab_by_name(self, name): name: str The name of the tab to set active """ + assert self.command_notebook is not None name = name.lower() if name in self._command_tabs: tab_id = self._command_tabs[name] @@ -1056,7 +1200,7 @@ def set_active_tab_by_name(self, name): logger.debug("Name couldn't be found. Setting to id 0: %s", name) self.command_notebook.select(0) - def set_modified_true(self, command): + def set_modified_true(self, command: str) -> None: """ Set the modified variable to ``True`` for the given command in :attr:`modified_vars`. Parameters @@ -1072,11 +1216,11 @@ def set_modified_true(self, command): tkvar.set(True) logger.debug("Set modified var to True for: '%s'", command) - def refresh_config(self): + def refresh_config(self) -> None: """ Reload the user config from file. """ self._user_config = UserConfig(None) - def set_cursor_busy(self, widget=None): + def set_cursor_busy(self, widget: Optional[tk.Widget] = None) -> None: """ Set the root or widget cursor to busy. Parameters @@ -1086,11 +1230,11 @@ def set_cursor_busy(self, widget=None): cursor busy for the whole of the GUI. Default: ``None``. """ logger.debug("Setting cursor to busy. widget: %s", widget) - widget = self.root if widget is None else widget - widget.config(cursor="watch") - widget.update_idletasks() + component = self.root if widget is None else widget + component.config(cursor="watch") # type: ignore + component.update_idletasks() - def set_cursor_default(self, widget=None): + def set_cursor_default(self, widget: Optional[tk.Widget] = None) -> None: """ Set the root or widget cursor to default. Parameters @@ -1100,18 +1244,18 @@ def set_cursor_default(self, widget=None): cursor busy for the whole of the GUI. Default: ``None`` """ logger.debug("Setting cursor to default. widget: %s", widget) - widget = self.root if widget is None else widget - widget.config(cursor="") - widget.update_idletasks() + component = self.root if widget is None else widget + component.config(cursor="") # type: ignore + component.update_idletasks() @staticmethod - def _set_tk_vars(): + def _set_tk_vars() -> Dict[str, Union[tk.StringVar, tk.BooleanVar]]: """ Set the global tkinter variables stored for easy access in :class:`Config`. The variables are available through :attr:`tk_vars`. """ display = tk.StringVar() - display.set(None) + display.set("") runningtask = tk.BooleanVar() runningtask.set(False) @@ -1120,10 +1264,10 @@ def _set_tk_vars(): istraining.set(False) actioncommand = tk.StringVar() - actioncommand.set(None) + actioncommand.set("") generatecommand = tk.StringVar() - generatecommand.set(None) + generatecommand.set("") console_clear = tk.BooleanVar() console_clear.set(False) @@ -1135,21 +1279,22 @@ def _set_tk_vars(): updatepreview.set(False) analysis_folder = tk.StringVar() - analysis_folder.set(None) - - tk_vars = dict(display=display, - runningtask=runningtask, - istraining=istraining, - action=actioncommand, - generate=generatecommand, - console_clear=console_clear, - refreshgraph=refreshgraph, - updatepreview=updatepreview, - analysis_folder=analysis_folder) + analysis_folder.set("") + + tk_vars: Dict[str, Union[tk.StringVar, tk.BooleanVar]] = dict( + display=display, + runningtask=runningtask, + istraining=istraining, + action=actioncommand, + generate=generatecommand, + console_clear=console_clear, + refreshgraph=refreshgraph, + updatepreview=updatepreview, + analysis_folder=analysis_folder) logger.debug(tk_vars) return tk_vars - def set_root_title(self, text=None): + def set_root_title(self, text: Optional[str] = None) -> None: """ Set the main title text for Faceswap. The title will always begin with 'Faceswap.py'. Additional text can be appended. @@ -1163,7 +1308,7 @@ def set_root_title(self, text=None): title += f" - {text}" if text is not None and text else "" self.root.title(title) - def set_geometry(self, width, height, fullscreen=False): + def set_geometry(self, width: int, height: int, fullscreen: bool = False) -> None: """ Set the geometry for the root tkinter object. Parameters @@ -1205,36 +1350,49 @@ class LongRunningTask(Thread): The widget that this :class:`LongRunningTask` is associated with. Used for setting the busy cursor in the correct location. Default: ``None``. """ - def __init__(self, group=None, target=None, name=None, args=(), kwargs=None, *, daemon=True, + _target: Callable + _args: Tuple + _kwargs: Dict[str, Any] + _name: str + + def __init__(self, + target: Optional[Callable] = None, + name: Optional[str] = None, + args: Tuple = (), + kwargs: Optional[Dict[str, Any]] = None, + *, + daemon: bool = True, widget=None): - logger.debug("Initializing %s: (group: %s, target: %s, name: %s, args: %s, kwargs: %s, " - "daemon: %s)", self.__class__.__name__, group, target, name, args, kwargs, + logger.debug("Initializing %s: (target: %s, name: %s, args: %s, kwargs: %s, " + "daemon: %s)", self.__class__.__name__, target, name, args, kwargs, daemon) - super().__init__(group=group, target=target, name=name, args=args, kwargs=kwargs, + super().__init__(target=target, name=name, args=args, kwargs=kwargs, daemon=daemon) - self.err = None + self.err: "_ErrorType" = None self._widget = widget self._config = get_config() self._config.set_cursor_busy(widget=self._widget) self._complete = Event() - self._queue = Queue() + self._queue: Queue = Queue() logger.debug("Initialized %s", self.__class__.__name__,) @property - def complete(self): + def complete(self) -> Event: """ :class:`threading.Event`: Event is set if the thread has completed its task, otherwise it is unset. """ return self._complete - def run(self): + def run(self) -> None: """ Commence the given task in a background thread. """ try: if self._target: retval = self._target(*self._args, **self._kwargs) self._queue.put(retval) except Exception: # pylint: disable=broad-except - self.err = sys.exc_info() + self.err = cast(Tuple[Type[BaseException], BaseException, "TracebackType"], + sys.exc_info()) + assert self.err is not None logger.debug("Error in thread (%s): %s", self._name, self.err[1].with_traceback(self.err[2])) finally: @@ -1243,7 +1401,7 @@ def run(self): # an argument that has a member that points to the thread. del self._target, self._args, self._kwargs - def get_result(self): + def get_result(self) -> Any: """ Return the result from the given task. Returns @@ -1275,14 +1433,14 @@ class PreviewTrigger(): Writes a file to the cache folder that is picked up by the main process. """ - def __init__(self): + def __init__(self) -> None: logger.debug("Initializing: %s", self.__class__.__name__) self._trigger_files = dict(update=os.path.join(PATHCACHE, ".preview_trigger"), mask_toggle=os.path.join(PATHCACHE, ".preview_mask_toggle")) logger.debug("Initialized: %s (trigger_files: %s)", self.__class__.__name__, self._trigger_files) - def set(self, trigger_type): + def set(self, trigger_type: Literal["update", "mask_toggle"]): """ Place the trigger file into the cache folder Parameters @@ -1297,7 +1455,7 @@ def set(self, trigger_type): pass logger.debug("Set preview trigger: %s", trigger) - def clear(self, trigger_type=None): + def clear(self, trigger_type: Optional[Literal["update", "mask_toggle"]] = None) -> None: """ Remove the trigger file from the cache folder. Parameters @@ -1316,7 +1474,7 @@ def clear(self, trigger_type=None): logger.debug("Removed preview trigger: %s", trigger) -def preview_trigger(): +def preview_trigger() -> PreviewTrigger: """ Set the global preview trigger if it has not already been set and return. Returns diff --git a/lib/multithreading.py b/lib/multithreading.py index e4ca376b7c..1e6835eb06 100644 --- a/lib/multithreading.py +++ b/lib/multithreading.py @@ -70,7 +70,7 @@ def __init__(self, target: Optional[Callable] = None, name: Optional[str] = None, args: Tuple = (), - kwargs: Dict[str, Any] = None, + kwargs: Optional[Dict[str, Any]] = None, *, daemon: Optional[bool] = None) -> None: super().__init__(target=target, name=name, args=args, kwargs=kwargs, daemon=daemon) From 477e3e20135810a851d11044ac9d3c221e6cce85 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 2 Sep 2022 11:50:58 +0100 Subject: [PATCH 717/981] bugfix: Train: Toggle mask when 'learn_mask' is selected --- plugins/train/trainer/_base.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 8846a5a224..5e7ee916b4 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -789,8 +789,13 @@ def _to_full_frame(self, full = self._process_full(side, full, predictions[0].shape[1], (0., 0., 1.0)) images = [faces] + predictions + if self._display_mask: images = self._compile_masked(images, samples[-1]) + elif self._model.config["learn_mask"]: + # Remove masks when learn mask is selected but mask toggle is off + images = [batch[..., :3] for batch in images] + images = [self._overlay_foreground(full.copy(), image) for image in images] return images From 8fdb856d05b8a73657a8a023bc032566d2561e36 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 2 Sep 2022 12:10:41 +0100 Subject: [PATCH 718/981] Bugfix: Display preview image in gui --- lib/gui/utils.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 4c45f75c08..15c5c1c530 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -902,8 +902,6 @@ def load_training_preview(self) -> None: try: logger.debug("Displaying preview: '%s'", img) size = self._get_current_size(name) - if not size: - return self._previewtrain[name] = [Image.open(img), None, modified] self.resize_image(name, size) self._errcount = 0 @@ -945,7 +943,7 @@ def _get_current_size(self, name: str) -> Optional[Tuple[int, int]]: name, img.width(), img.height()) return img.width(), img.height() - def resize_image(self, name: str, frame_dims: Tuple[int, int]): + def resize_image(self, name: str, frame_dims: Optional[Tuple[int, int]]) -> None: """ Resize the training preview image based on the passed in frame size. If the canvas that holds the preview image changes, update the image size @@ -955,8 +953,9 @@ def resize_image(self, name: str, frame_dims: Tuple[int, int]): ---------- name: str The name of the training image to be resized - frame_dims: tuple - The (width (`int`), height (`int`)) of the display panel that will display the preview + frame_dims: tuple, optional + The (width (`int`), height (`int`)) of the display panel that will display the preview. + ``None`` if the frame dimensions are not known. """ logger.debug("Resizing image: (name: '%s', frame_dims: %s", name, frame_dims) displayimg = cast(Image.Image, self._previewtrain[name][0]) From ae7793e87667a94ee64fac580235cd27f34b38b7 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 2 Sep 2022 12:42:52 +0100 Subject: [PATCH 719/981] bugfix: convert - Process final items on truncated batch --- plugins/plugin_loader.py | 2 +- scripts/convert.py | 50 ++++++++++++++++++++++++++-------------- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 44e55b66a6..bc3e09cf11 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -92,7 +92,7 @@ def get_masker(name: str, disable_logging: bool = False) -> Type["Masker"]: return PluginLoader._import("extract.mask", name, disable_logging) @staticmethod - def get_model(name: str, disable_logging: bool = False) -> "ModelBase": + def get_model(name: str, disable_logging: bool = False) -> Type["ModelBase"]: """ Return requested training model plugin Parameters diff --git a/scripts/convert.py b/scripts/convert.py index 0e88303247..4956172517 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -898,11 +898,12 @@ def _predict_faces(self) -> None: faces_seen = 0 consecutive_no_faces = 0 batch: List[ConvertItem] = [] - is_amd = get_backend() == "amd" while True: item: Union[Literal["EOF"], ConvertItem] = self._in_queue.get() if item == "EOF": logger.debug("EOF Received") + if batch: # Process out any remaining items + self._process_batch(batch, faces_seen) break logger.trace("Got from queue: '%s'", item.inbound.filename) # type:ignore faces_count = len(item.inbound.detected_faces) @@ -928,22 +929,7 @@ def _predict_faces(self) -> None: "consecutive_no_faces: %s", faces_seen, consecutive_no_faces) continue - if batch: - logger.trace("Batching to predictor. Frames: %s, Faces: %s", # type:ignore - len(batch), faces_seen) - feed_batch = [feed_face for item in batch - for feed_face in item.feed_faces] - if faces_seen != 0: - feed_faces = self._compile_feed_faces(feed_batch) - batch_size = None - if is_amd and feed_faces.shape[0] != self._batchsize: - logger.verbose("Fallback to BS=1") # type:ignore - batch_size = 1 - predicted = self._predict(feed_faces, batch_size) - else: - predicted = np.array([]) - - self._queue_out_frames(batch, predicted) + self._process_batch(batch, faces_seen) consecutive_no_faces = 0 faces_seen = 0 @@ -953,6 +939,36 @@ def _predict_faces(self) -> None: self._out_queue.put("EOF") logger.debug("Load queue complete") + def _process_batch(self, batch: List[ConvertItem], faces_seen: int): + """ Predict faces on the given batch of images and queue out to patch thread + + Parameters + ---------- + batch: list + List of :class:`ConvertItem` objects for the current batch + faces_seen: int + The number of faces seen in the current batch + + Returns + ------- + :class:`np.narray` + The predicted faces for the current batch + """ + logger.trace("Batching to predictor. Frames: %s, Faces: %s", # type:ignore + len(batch), faces_seen) + feed_batch = [feed_face for item in batch for feed_face in item.feed_faces] + if faces_seen != 0: + feed_faces = self._compile_feed_faces(feed_batch) + batch_size = None + if get_backend() == "amd" and feed_faces.shape[0] != self._batchsize: + logger.verbose("Fallback to BS=1") # type:ignore + batch_size = 1 + predicted = self._predict(feed_faces, batch_size) + else: + predicted = np.array([]) + + self._queue_out_frames(batch, predicted) + def load_aligned(self, item: ConvertItem) -> None: """ Load the model's feed faces and the reference output faces. From c60aca8fb2ecb314dd805397a669cfbce4f57989 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 7 Sep 2022 10:29:25 +0100 Subject: [PATCH 720/981] bugfix - gui extract preview on batch mode --- lib/gui/utils.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/gui/utils.py b/lib/gui/utils.py index 15c5c1c530..79f2d18764 100644 --- a/lib/gui/utils.py +++ b/lib/gui/utils.py @@ -658,9 +658,11 @@ def _get_newest_folder(self) -> str: """ assert self._pathoutput is not None - folders = [os.path.join(self._pathoutput, folder) - for folder in os.listdir(self._pathoutput) - if os.path.isdir(os.path.join(self._pathoutput, folder))] + folders = [] if not os.path.exists(self._pathoutput) else [ + os.path.join(self._pathoutput, folder) + for folder in os.listdir(self._pathoutput) + if os.path.isdir(os.path.join(self._pathoutput, folder))] + folders.sort(key=os.path.getmtime) retval = folders[-1] if folders else self._pathoutput logger.debug("sorted folders: %s, return value: %s", folders, retval) From 681b775c7b4d9cab07150be31436fafdc5f0ded4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 7 Sep 2022 10:58:32 +0100 Subject: [PATCH 721/981] bugfix: preview color order for RGB models --- lib/training/generator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/training/generator.py b/lib/training/generator.py index bda8a1315f..9970206249 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -707,7 +707,9 @@ def process_batch(self, "batch: %s, detected_faces: %s)", self._side, filenames, images.shape, batch.shape, len(detected_faces)) - self._set_color_order(batch) # Switch color order for RGB models + # Switch color order for RGB models + self._set_color_order(batch) + self._set_color_order(images) if not self._use_mask: mask = np.zeros_like(batch[..., 0])[..., None] + 255 From 7da2cc3dd266aabebf41a31384cc2e0e7e5af6e5 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 7 Sep 2022 11:49:52 +0100 Subject: [PATCH 722/981] Training - Use custom preview pop-out --- docs/full/lib/training.rst | 18 + lib/image.py | 4 +- lib/logger.py | 2 +- lib/training/__init__.py | 12 + lib/training/preview_cv.py | 195 ++++++++ lib/training/preview_tk.py | 831 +++++++++++++++++++++++++++++++++ plugins/plugin_loader.py | 2 +- plugins/train/trainer/_base.py | 14 +- scripts/train.py | 356 +++++--------- 9 files changed, 1190 insertions(+), 244 deletions(-) create mode 100644 lib/training/preview_cv.py create mode 100644 lib/training/preview_tk.py diff --git a/docs/full/lib/training.rst b/docs/full/lib/training.rst index cf07768319..579e4751eb 100644 --- a/docs/full/lib/training.rst +++ b/docs/full/lib/training.rst @@ -31,3 +31,21 @@ training.generator module :members: :undoc-members: :show-inheritance: + + +training.preview_cv module +========================== + +.. automodule:: lib.training.preview_cv + :members: + :undoc-members: + :show-inheritance: + + +training.preview_tk module +========================== + +.. automodule:: lib.training.preview_tk + :members: + :undoc-members: + :show-inheritance: diff --git a/lib/image.py b/lib/image.py index 6b72339f2b..a8337b83ac 100644 --- a/lib/image.py +++ b/lib/image.py @@ -32,7 +32,7 @@ # <<< IMAGE IO >>> # -class FfmpegReader(imageio.plugins.ffmpeg.FfmpegFormat.Reader): +class FfmpegReader(imageio.plugins.ffmpeg.FfmpegFormat.Reader): # type:ignore """ Monkey patch imageio ffmpeg to use keyframes whilst seeking """ def __init__(self, format, request): super().__init__(format, request) @@ -250,7 +250,7 @@ def _initialize(self, index=0): self._read_gen.__next__() # we already have meta data -imageio.plugins.ffmpeg.FfmpegFormat.Reader = FfmpegReader +imageio.plugins.ffmpeg.FfmpegFormat.Reader = FfmpegReader # type: ignore def read_image(filename, raise_error=False, with_metadata=False): diff --git a/lib/logger.py b/lib/logger.py index 984c6dc06c..c7b34f5d94 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -396,7 +396,7 @@ def _file_handler(loglevel, :class:`logging.RotatingFileHandler` The logging file handler """ - if log_file is not None: + if log_file: filename = log_file else: filename = os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), "faceswap") diff --git a/lib/training/__init__.py b/lib/training/__init__.py index b697eeff0f..821ac8c7c8 100644 --- a/lib/training/__init__.py +++ b/lib/training/__init__.py @@ -2,5 +2,17 @@ """ Package for handling alignments files, detected faces and aligned faces along with their associated objects. """ +from typing import Type, TYPE_CHECKING + from .augmentation import ImageAugmentation # noqa from .generator import PreviewDataGenerator, TrainingDataGenerator # noqa +from .preview_cv import PreviewBuffer , TriggerType # noqa + +if TYPE_CHECKING: + from .preview_cv import PreviewBase + Preview: Type[PreviewBase] + +try: + from .preview_tk import PreviewTk as Preview # noqa +except ImportError: + from .preview_cv import PreviewCV as Preview # noqa diff --git a/lib/training/preview_cv.py b/lib/training/preview_cv.py new file mode 100644 index 0000000000..6e1a18f89e --- /dev/null +++ b/lib/training/preview_cv.py @@ -0,0 +1,195 @@ +#!/usr/bin/python +""" The pop up preview window for Faceswap. + +If Tkinter is installed, then this will be used to manage the preview image, otherwise we +fallback to opencv's imshow +""" +import logging +import sys + +from threading import Event, Lock +from time import sleep + +from typing import Dict, Generator, List, Optional, Tuple, TYPE_CHECKING + +import cv2 + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + + +if TYPE_CHECKING: + import numpy as np + +logger = logging.getLogger(__name__) +TriggerType = Dict[Literal["toggle_mask", "refresh", "save", "quit", "shutdown"], Event] +TriggerKeysType = Literal["m", "r", "s", "enter"] +TriggerNamesType = Literal["toggle_mask", "refresh", "save", "quit"] + + +class PreviewBuffer(): + """ A thread safe class for holding preview images """ + def __init__(self): + logger.debug("Initializing: %s", __class__.__name__) + self._images: Dict[str, "np.ndarray"] = {} + self._lock = Lock() + self._updated = Event() + logger.debug("Initialized: %s", __class__.__name__) + + @property + def is_updated(self) -> bool: + """ bool: ``True`` when new images have been loaded into the preview buffer """ + return self._updated.is_set() + + def add_image(self, name: str, image: "np.ndarray") -> None: + """ Add an image to the preview buffer in a thread safe way """ + logger.debug("Adding image: (name: '%s', shape: %s)", name, image.shape) + with self._lock: + self._images[name] = image + logger.debug("Added images: %s", list(self._images)) + self._updated.set() + + def get_images(self) -> Generator[Tuple[str, "np.ndarray"], None, None]: + """ Get the latest images from the preview buffer. When iterator is exhausted clears the + :attr:`updated` event. + + Yields + ------ + name: str + The name of the image + :class:`numpy.ndarray` + The image in BGR format + """ + logger.debug("Retrieving images: %s", list(self._images)) + with self._lock: + for name, image in self._images.items(): + logger.debug("Yielding: '%s' (%s)", name, image.shape) + yield name, image + if self.is_updated: + logger.debug("Clearing updated event") + self._updated.clear() + logger.debug("Retrieved images") + + +class PreviewBase(): # pylint:disable=too-few-public-methods + """ Parent class for OpenCV and Tkinter Preview Windows + + Parameters + ---------- + preview_buffer: :class:`PreviewBuffer` + The thread safe object holding the preview images + triggers: dict, optional + Dictionary of event triggers for pop-up preview. Not required when running inside the GUI. + Default: `None` + """ + def __init__(self, + preview_buffer: PreviewBuffer, + triggers: Optional[TriggerType] = None) -> None: + logger.debug("Initializing %s parent (triggers: %s)", + self.__class__.__name__, triggers) + self._triggers = triggers + self._buffer = preview_buffer + self._keymaps: Dict[TriggerKeysType, TriggerNamesType] = dict(m="toggle_mask", + r="refresh", + s="save", + enter="quit") + self._title = "" + logger.debug("Initialized %s parent", self.__class__.__name__) + + @property + def _should_shutdown(self) -> bool: + """ bool: ``True`` if the preview has received an external signal to shutdown otherwise + ``False`` """ + if self._triggers is None or not self._triggers["shutdown"].is_set(): + return False + logger.debug("Shutdown signal received") + return True + + def _launch(self) -> None: + """ Wait until an image is loaded into the preview buffer and call the child's + :func:`_display_preview` function """ + logger.debug("Launching %s", self.__class__.__name__) + while True: + if not self._buffer.is_updated: + logger.debug("Waiting for preview image") + sleep(1) + continue + break + logger.debug("Launching preview") + self._display_preview() + + def _display_preview(self) -> None: + """ Override for preview viewer's display loop """ + raise NotImplementedError() + + +class PreviewCV(PreviewBase): # pylint:disable=too-few-public-methods + """ Simple fall back preview viewer using OpenCV for when TKinter is not available + + Parameters + ---------- + preview_buffer: :class:`PreviewBuffer` + The thread safe object holding the preview images + triggers: dict + Dictionary of event triggers for pop-up preview. + """ + def __init__(self, + preview_buffer: PreviewBuffer, + triggers: TriggerType) -> None: + logger.debug("Unable to import Tkinter. Falling back to OpenCV") + super().__init__(preview_buffer, triggers=triggers) + self._triggers: TriggerType = self._triggers + self._windows: List[str] = [] + + self._lookup = {ord(key): val + for key, val in self._keymaps.items() if key != "enter"} + self._lookup[ord("\n")] = self._keymaps["enter"] + self._lookup[ord("\r")] = self._keymaps["enter"] + + self._launch() + + @property + def _window_closed(self) -> bool: + """ bool: ``True`` if any window has been closed otherwise ``False`` """ + retval = any(cv2.getWindowProperty(win, cv2.WND_PROP_VISIBLE) < 1 for win in self._windows) + if retval: + logger.debug("Window closed detected") + return retval + + def _check_keypress(self, key: int): + """ Check whether we have received a valid key press from OpenCV window and handle + accordingly. + + Parameters + ---------- + key_press: int + The key press received from OpenCV + """ + if not key or key == -1 or key not in self._lookup: + return + + if key == ord("r"): + print("") # Let log print on different line from loss output + logger.info("Refresh preview requested...") + + self._triggers[self._lookup[key]].set() + logger.debug("Processed keypress '%s'. Set event for '%s'", key, self._lookup[key]) + + def _display_preview(self): + """ Handle the displaying of the images currently in :attr:`_preview_buffer`""" + while True: + if self._buffer.is_updated or self._window_closed: + for name, image in self._buffer.get_images(): + logger.debug("showing image: '%s' (%s)", name, image.shape) + cv2.imshow(name, image) + self._windows.append(name) + + key = cv2.waitKey(1000) + self._check_keypress(key) + + if self._triggers["shutdown"].is_set(): + logger.debug("Shutdown received") + break + logger.debug("%s shutdown", self.__class__.__name__) diff --git a/lib/training/preview_tk.py b/lib/training/preview_tk.py new file mode 100644 index 0000000000..d2ecf47382 --- /dev/null +++ b/lib/training/preview_tk.py @@ -0,0 +1,831 @@ +#!/usr/bin/python +""" The pop up preview window for Faceswap. + +If Tkinter is installed, then this will be used to manage the preview image, otherwise we +fallback to opencv's imshow +""" +import logging +import os +import sys +import tkinter as tk + +from datetime import datetime +from platform import system +from tkinter import ttk +from math import ceil, floor + +from typing import cast, List, Optional, Tuple, TYPE_CHECKING +from PIL import Image, ImageTk + +import cv2 + +from .preview_cv import PreviewBase, TriggerKeysType + +if TYPE_CHECKING: + import numpy as np + from .preview_cv import PreviewBuffer, TriggerType + +logger = logging.getLogger(__name__) + +# TODO Embed this object in GUI + + +class _Taskbar(tk.Frame): + """ Taskbar at bottom of Preview window + + Parameters + ---------- + parent: :class:`tkinter.Frame` + The parent frame that holds the canvas and taskbar + is_standalone: bool + ``True`` if preview is a pop-up window otherwise ``False`` + """ + def __init__(self, parent: tk.Frame, is_standalone: bool) -> None: + logger.debug("Initializing %s (parent: '%s', is_standalone: %s)", + self.__class__.__name__, parent, is_standalone) + super().__init__(parent) + self._min_max_scales = (20, 400) + self._vars = dict(save=tk.BooleanVar(), + scale=tk.StringVar(), + slider=tk.IntVar(), + interpolator=tk.IntVar()) + self._interpolators = [("nearest_neighbour", cv2.INTER_NEAREST), + ("bicubic", cv2.INTER_CUBIC)] + self._scale = self._add_scale_combo() + self._slider = self._add_scale_slider() + self._add_interpolator_radio() + if is_standalone: + self._add_save_button() + self.pack(side=tk.BOTTOM, fill=tk.X, padx=2, pady=2) + logger.debug("Initialized %s ('%s')", self.__class__.__name__, self) + + @property + def min_scale(self) -> int: + """ int: The minimum allowed scale """ + return self._min_max_scales[0] + + @property + def max_scale(self) -> int: + """ int: The maximum allowed scale """ + return self._min_max_scales[1] + + @property + def save_var(self) -> tk.BooleanVar: + """:class:`tkinter.IntVar`: Variable which is set to ``True`` when the save button has been. + pressed """ + retval = self._vars["save"] + assert isinstance(retval, tk.BooleanVar) + return retval + + @property + def scale_var(self) -> tk.StringVar: + """:class:`tkinter.StringVar`: The variable holding the currently selected "##%" formatted + percentage scaling amount displayed in the Combobox. """ + retval = self._vars["scale"] + assert isinstance(retval, tk.StringVar) + return retval + + @property + def slider_var(self) -> tk.IntVar: + """:class:`tkinter.IntVar`: The variable holding the currently selected percentage scaling + amount in the slider. """ + retval = self._vars["slider"] + assert isinstance(retval, tk.IntVar) + return retval + + @property + def interpolator_var(self) -> tk.IntVar: + """:class:`tkinter.IntVar`: The variable holding the CV2 Interpolator Enum. """ + retval = self._vars["interpolator"] + assert isinstance(retval, tk.IntVar) + return retval + + def _add_scale_combo(self) -> ttk.Combobox: + """ Add a scale combo for selecting zoom amount. + + Returns + ------- + :class:`tkinter.ttk.Combobox` + The Combobox widget + """ + logger.debug("Adding scale combo") + self.scale_var.set("100%") + scale = ttk.Combobox(self, + textvariable=self.scale_var, + values=["Fit"], + state="readonly", + width=10) + scale.pack(side=tk.RIGHT) + scale.bind("", self._clear_combo_focus) # Remove auto-focus on widget text box + logger.debug("Added scale combo: '%s'", scale) + return scale + + def _clear_combo_focus(self, *args) -> None: # pylint: disable=unused-argument + """ Remove the highlighting and stealing of focus that the combobox annoyingly + implements. """ + logger.debug("Clearing scale combo focus") + self._scale.selection_clear() + self._scale.winfo_toplevel().focus_set() + logger.debug("Cleared scale combo focus") + + def _add_scale_slider(self) -> tk.Scale: + """ Add a scale slider for zooming the image. + + Returns + ------- + :class:`tkinter.Scale` + The scale widget + """ + logger.debug("Adding scale slider") + self.slider_var.set(100) + slider = tk.Scale(self, + orient=tk.HORIZONTAL, + to=self.max_scale, + showvalue=False, + variable=self.slider_var, + command=self._on_slider_update) + slider.pack(side=tk.RIGHT) + logger.debug("Added scale slider: '%s'", slider) + return slider + + def _add_interpolator_radio(self) -> None: + """ Add a radio box to choose interpolator """ + frame = tk.Frame(self) + for text, mode in self._interpolators: + radio = tk.Radiobutton(frame, text=text, value=mode, variable=self.interpolator_var) + radio.pack(side=tk.LEFT, anchor=tk.W) + self.interpolator_var.set(cv2.INTER_NEAREST) + frame.pack(side=tk.RIGHT) + + def _add_save_button(self) -> None: + """ Add a save button for saving out original preview """ + logger.debug("Adding save button") + button = tk.Button(self, + text="Save", + cursor="hand2", + command=lambda: self.save_var.set(True)) + button.pack(side=tk.LEFT) + logger.debug("Added save burron: '%s'", button) + + def _on_slider_update(self, value) -> None: + """ Callback for when the scale slider is adjusted. Adjusts the combo box display to the + current slider value. + + Parameters + ---------- + value: int + The value that the slider has been set to + """ + self.scale_var.set(f"{value}%") + + def set_min_max_scale(self, min_scale: int, max_scale: int) -> None: + """ Set the minimum and maximum value that we allow an image to be scaled down to. This + impacts the slider and combo box min/max values: + + Parameters + ---------- + min_scale: int + The minimum percentage scale that is permitted + max_scale: int + The maximum percentage scale that is permitted + """ + logger.debug("Setting min/max scales: (min: %s, max: %s)", min_scale, max_scale) + self._min_max_scales = (min_scale, max_scale) + self._slider.config(from_=self.min_scale, to=max_scale) + scales = [10, 25, 50, 75, 100, 200, 300, 400, 800] + if min_scale not in scales: + scales.insert(0, min_scale) + if max_scale not in scales: + scales.append(max_scale) + choices = ["Fit", *[f"{x}%" for x in scales if self.max_scale >= x >= self.min_scale]] + self._scale.config(values=choices) + logger.debug("Set min/max scale. min_max_scales: %s, scale combo choices: %s", + self._min_max_scales, choices) + + def cycle_interpolators(self, *args): # pylint:disable=unused-argument + """ Cycle interpolators on a keypress callback """ + current = next(i for i in self._interpolators if i[1] == self.interpolator_var.get()) + next_idx = self._interpolators.index(current) + 1 + next_idx = 0 if next_idx == len(self._interpolators) else next_idx + self.interpolator_var.set(self._interpolators[next_idx][1]) + + +class _PreviewCanvas(tk.Canvas): # pylint:disable=too-many-ancestors + """ The canvas that holds the preview image + + Parameters + ---------- + parent: :class:`tkinter.Frame` + The parent frame that will hold the Canvas and taskbar + scale_var: :class:`tkinter.StringVar` + The variable that holds the value from the scale combo box + screen_dimensions: tuple + The (`width`, `height`) of the displaying monitor + """ + def __init__(self, + parent: tk.Frame, + scale_var: tk.StringVar, + screen_dimensions: Tuple[int, int]) -> None: + logger.debug("Initializing %s (parent: '%s', scale_var: %s, screen_dimensions: %s)", + self.__class__.__name__, parent, scale_var, screen_dimensions) + frame = tk.Frame(parent) + super().__init__(frame) + + self._screen_dimensions = screen_dimensions + self._var_scale = scale_var + self._configure_scrollbars(frame) + self._image: Optional[ImageTk.PhotoImage] = None + self._image_id = self.create_image(self.width / 2, + self.height / 2, + anchor=tk.CENTER, + image=self._image) + self.pack(fill=tk.BOTH, expand=True) + self.bind("", self._resize) + frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + logger.debug("Initialized %s ('%s')", self.__class__.__name__, self) + + @property + def image_id(self) -> int: + """ int: The ID of the preview image item within the canvas """ + return self._image_id + + @property + def width(self) -> int: + """int: The pixel width of canvas""" + return self.winfo_width() + + @property + def height(self) -> int: + """int: The pixel width of the canvas""" + return self.winfo_height() + + def _configure_scrollbars(self, frame: tk.Frame) -> None: + """ Add X and Y scrollbars to the frame and set to scroll the canvas. + + Parameters + ---------- + frame: :class:`tkinter.Frame` + The parent frame to the canvas + """ + logger.debug("Configuring scrollbars") + x_scrollbar = tk.Scrollbar(frame, orient="horizontal", command=self.xview) + x_scrollbar.pack(side=tk.BOTTOM, fill=tk.X) + + y_scrollbar = tk.Scrollbar(frame, command=self.yview) + y_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + self.configure(xscrollcommand=x_scrollbar.set, yscrollcommand=y_scrollbar.set) + logger.debug("Configured scrollbars. x: '%s', y: '%s'", x_scrollbar, y_scrollbar) + + def _resize(self, event: tk.Event) -> None: # pylint: disable=unused-argument + """ Place the image in center of canvas on resize event and move to top left + + Parameters + ---------- + event: :class:`tkinter.Event` + The canvas resize event. Unused. + """ + if self._var_scale.get() == "Fit": # Trigger an update to resize image + logger.debug("Triggering redraw for 'Fit' Scaling") + self._var_scale.set("Fit") + return + + self.configure(scrollregion=self.bbox("all")) + self.update_idletasks() + assert self._image is not None + + # Move to top left when resizing into screen dimensions (initial startup) + if self.width > self._screen_dimensions[0]: + logger.debug("Moving image to left edge") + self.xview_moveto(0.0) + if self.height > self._screen_dimensions[1]: + logger.debug("Moving image to top edge") + self.yview_moveto(0.0) + + def _center_image(self, point_x: float, point_y: float) -> None: + """ Center the image on the canvas on a resize or image update. + + Parameters + ---------- + point_x: int + The x point to center on + point_y: int + The y point to center on + """ + canvas_location = (self.canvasx(point_x), self.canvasy(point_y)) + logger.debug("Centering canvas for size (%s, %s). New image coordinates: %s", + point_x, point_y, canvas_location) + self.coords(self.image_id, canvas_location) + + def set_image(self, + image: ImageTk.PhotoImage, + center_image: bool = False) -> None: + """ Update the canvas with the given image and update area/scrollbars accordingly + + Parameters + ---------- + image: :class:`ImageTK.PhotoImage` + The preview image to display in the canvas + bool, optional + ``True`` if the image should be re-centered. Default ``True`` + """ + logger.debug("Setting canvas image. ID: %s, size: %s for canvas size: %s (recenter: %s)", + self.image_id, (image.width(), image.height()), (self.width, self.height), + center_image) + self._image = image + self.itemconfig(self.image_id, image=self._image) + self.config(width=self._image.width(), height=self._image.height()) + self.update_idletasks() + if center_image: + self._center_image(self.width / 2, self.height / 2) + self.configure(scrollregion=self.bbox("all")) + logger.debug("set canvas image. Canvas size: %s", (self.width, self.height)) + + +class _Image(): + """ Holds the source image and the resized display image for the canvas + + Parameters + ---------- + save_variable: :class:`tkinter.BooleanVar` + Variable that indicates a save preview has been requested + """ + def __init__(self, save_variable: tk.BooleanVar) -> None: + logger.debug("Initializing %s: (save_variable: %s)", + self.__class__.__name__, save_variable) + self._source: Optional["np.ndarray"] = None + self._display: Optional[ImageTk.PhotoImage] = None + self._scale = 1.0 + self._interpolation = cv2.INTER_NEAREST + + self._save_var = save_variable + self._save_var.trace("w", self.save_preview) + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def display_image(self) -> ImageTk.PhotoImage: + """ :class:`PIL.ImageTk.PhotoImage`: The current display image """ + assert self._display is not None + return self._display + + @property + def source(self) -> "np.ndarray": + """ :class:`PIL.Image.Image`: The current source preview image """ + assert self._source is not None + return self._source + + @property + def scale(self) -> int: + """int: The current display scale as a percentage of original image size """ + return int(self._scale * 100) + + def set_source_image(self, name: str, image: "np.ndarray") -> None: + """ Set the source image to :attr:`source` + + Parameters + ---------- + name: str + The name of the preview image to load + image: :class:`numpy.ndarray` + The image to use in RGB format + """ + logger.debug("Setting source image. name: '%s', shape: %s", name, image.shape) + self._source = image + + def set_display_image(self) -> None: + """ Obtain the scaled image and set to :attr:`display_image` """ + logger.debug("Setting display image. Scale: %s", self._scale) + image = self.source[..., 2::-1] # TO RGB + if self._scale != 1.0: + interp = self._interpolation if self._scale > 1.0 else cv2.INTER_NEAREST + dims = (int(round(self.source.shape[1] * self._scale, 0)), + int(round(self.source.shape[0] * self._scale, 0))) + image = cv2.resize(image, dims, interpolation=interp) + self._display = ImageTk.PhotoImage(Image.fromarray(image)) + logger.debug("Set display image. Size: %s", + (self._display.width(), self._display.height())) + + def set_scale(self, scale: float) -> bool: + """ Set the display scale to the given value. + + Parameters + ---------- + scale: float + The value to set scaling to + + Returns + ------- + bool + ``True`` if the scale has been changed otherwise ``False`` + """ + if self._scale == scale: + return False + logger.debug("Setting scale: %s", scale) + self._scale = scale + return True + + def set_interpolation(self, interpolation: int) -> bool: + """ Set the interpolation enum to the given value. + + Parameters + ---------- + interpolation: int + The value to set interpolation to + + Returns + ------- + bool + ``True`` if the interpolation has been changed otherwise ``False`` + """ + if self._interpolation == interpolation: + return False + logger.debug("Setting interpolation: %s") + self._interpolation = interpolation + return True + + def save_preview(self, *args) -> None: + """ Save out the full size preview to the faceswap folder on a save button press + + Parameters + ---------- + args: tuple + Tuple containing either the key press event (Ctrl+s shortcut) or the tk variable + arguments (save button press) + """ + if not self._save_var.get() and not isinstance(args[0], tk.Event): + return + + root_path = os.path.join(os.path.realpath(os.path.dirname(sys.argv[0]))) + now = datetime.now().strftime("%Y-%m-%d_%H.%M.%S") + filename = os.path.join(root_path, f"preview_{now}.png") + cv2.imwrite(filename, self.source) + print("") + logger.info("Saved preview to: '%s'", filename) + self._save_var.set(False) + + +class _Bindings(): # pylint: disable=too-few-public-methods + """ Handle Mouse and Keyboard bindings for the canvas. + + Parameters + ---------- + canvas: :class:`_PreviewCanvas` + The canvas that holds the preview image + taskbar: :class:`_Taskbar` + The taskbar widget which holds the scaling variables + image: :class:`_Image` + The object which holds the source and display version of the preview image + """ + def __init__(self, canvas: _PreviewCanvas, taskbar: _Taskbar, image: _Image) -> None: + logger.debug("Initializing %s (canvas: '%s', taskbar: '%s', image: '%s')", + self.__class__.__name__, canvas, taskbar, image) + self._canvas = canvas + self._taskbar = taskbar + self._image = image + + self._drag_data: List[float] = [0., 0.] + self._set_mouse_bindings() + self._set_key_bindings() + logger.debug("Initialized %s", self.__class__.__name__,) + + def _on_bound_zoom(self, event: tk.Event) -> None: + """ Action to perform on a valid zoom key press or mouse wheel action + + Parameters + ---------- + event: :class:`tkinter.Event` + The key press or mouse wheel event + """ + if event.keysym in ("KP_Add", "plus") or event.num == 4 or event.delta > 0: + scale = min(self._taskbar.max_scale, self._image.scale + 25) + else: + scale = max(self._taskbar.min_scale, self._image.scale - 25) + logger.trace("Bound zoom action: (event: %s, scale: %s)", event, scale) # type: ignore + self._taskbar.scale_var.set(f"{scale}%") + + def _on_mouse_click(self, event: tk.Event) -> None: + """ log initial click coordinates for mouse click + drag action + + Parameters + ---------- + event: :class:`tkinter.Event` + The mouse event + """ + self._drag_data = [event.x / self._image.display_image.width(), + event.y / self._image.display_image.height()] + logger.trace("Mouse click action: (event: %s, drag_data: %s)", # type: ignore + event, self._drag_data) + + def _on_mouse_drag(self, event: tk.Event) -> None: + """ Drag image left, right, up or down + + Parameters + ---------- + event: :class:`tkinter.Event` + The mouse event + """ + location_x = event.x / self._image.display_image.width() + location_y = event.y / self._image.display_image.height() + + if self._canvas.xview() != (0.0, 1.0): + to_x = min(1.0, max(0.0, self._drag_data[0] - location_x + self._canvas.xview()[0])) + self._canvas.xview_moveto(to_x) + if self._canvas.yview() != (0.0, 1.0): + to_y = min(1.0, max(0.0, self._drag_data[1] - location_y + self._canvas.yview()[0])) + self._canvas.yview_moveto(to_y) + + self._drag_data = [location_x, location_y] + + def _on_key_move(self, event: tk.Event) -> None: + """ Action to perform on a valid move key press + + Parameters + ---------- + event: :class:`tkinter.Event` + The key press event + """ + move_axis = self._canvas.xview if event.keysym in ("Left", "Right") else self._canvas.yview + visible = (move_axis()[1] - move_axis()[0]) + amount = -visible / 25 if event.keysym in ("Up", "Left") else visible / 25 + logger.trace("Key move event: (event: %s, move_axis: %s, visible: %s, " # type: ignore + "amount: %s)", move_axis, visible, amount) + move_axis(tk.MOVETO, min(1.0, max(0.0, move_axis()[0] + amount))) + + def _set_mouse_bindings(self) -> None: + """ Set the mouse bindings for interacting with the preview image + + Mousewheel: Zoom in and out + Mouse click: Move image + """ + logger.debug("Binding mouse events") + if system() == "Linux": + self._canvas.tag_bind(self._canvas.image_id, "", self._on_bound_zoom) + self._canvas.tag_bind(self._canvas.image_id, "", self._on_bound_zoom) + else: + self._canvas.tag_bind(self._canvas.image_id, "", self._on_bound_zoom) + + self._canvas.tag_bind(self._canvas.image_id, "", self._on_mouse_click) + self._canvas.tag_bind(self._canvas.image_id, "", self._on_mouse_drag) + logger.debug("Bound mouse events") + + def _set_key_bindings(self) -> None: + # TODO set bind location for GUI + """ Set the keyboard bindings. + + Up/Down/Left/Right: Moves image + +/-: Zooms image + ctrl+s: Save + i: Cycle interpolators + """ + logger.debug("Binding key events") + root = self._canvas.winfo_toplevel() + for key in ("Left", "Right", "Up", "Down"): + root.bind(f"<{key}>", self._on_key_move) + for key in ("Key-plus", "Key-minus", "Key-KP_Add", "Key-KP_Subtract"): + root.bind(f"<{key}>", self._on_bound_zoom) + root.bind("", self._image.save_preview) + root.bind("", self._taskbar.cycle_interpolators) + logger.debug("Bound key events") + + +class PreviewTk(PreviewBase): # pylint:disable=too-few-public-methods + """ Holds a preview window for displaying the pop out preview. + + Parameters + ---------- + preview_buffer: :class:`PreviewBuffer` + The thread safe object holding the preview images + parent: tkinter widget, optional + If this viewer is being called from the GUI the parent widget should be passed in here. + If this is a standalone pop-up window then pass ``None``. Default: ``None`` + triggers: dict, optional + Dictionary of event triggers for pop-up preview. Not required when running inside the GUI. + Default: `None` + """ + def __init__(self, + preview_buffer: "PreviewBuffer", + parent: Optional[tk.Widget] = None, + triggers: Optional["TriggerType"] = None) -> None: + logger.debug("Initializing %s (parent: '%s')", self.__class__.__name__, parent) + super().__init__(preview_buffer, triggers=triggers) + self._is_standalone = parent is None + self._initialized = not self._is_standalone + self._root = parent if parent is not None else tk.Tk() + self._master_frame = tk.Frame(self._root) + + self._taskbar = _Taskbar(self._master_frame, self._is_standalone) + + self._screen_dimensions = self._get_geometry() + self._canvas = _PreviewCanvas(self._master_frame, + self._taskbar.scale_var, + self._screen_dimensions) + + self._image = _Image(self._taskbar.save_var) + + _Bindings(self._canvas, self._taskbar, self._image) + + self._taskbar.scale_var.trace("w", self._set_scale) + self._taskbar.interpolator_var.trace("w", self._set_interpolation) + + self._process_triggers() + self._master_frame.pack(fill=tk.BOTH, expand=True) + logger.debug("Initialized %s", self.__class__.__name__) + self._output_helptext() + self._launch() + + @classmethod + def _output_helptext(cls) -> None: + """ Output the keybindings to Console. """ + logger.info("---------------------------------------------------") + logger.info(" Preview key bindings:") + logger.info(" Zoom: +/-") + logger.info(" Toggle Zoom Mode: i") + logger.info(" Move: arrow keys") + logger.info(" Save Preview: Ctrl+s") + logger.info("---------------------------------------------------") + + def _get_geometry(self) -> Tuple[int, int]: + """ Obtain the geometry of the current screen. + + Just pulling screen width and height does not account for multiple monitors, so dummy in a + window to pull actual dimensions before hiding it again. + + Returns + ------- + Tuple + The (`width`, `height`) of the current monitor's display + """ + # TODO skip when loading in GUI? + logger.debug("Obtaining screen geometry") + assert isinstance(self._root, tk.Tk) + self._root.update_idletasks() + self._root.attributes("-fullscreen", True) + self._root.state("iconic") + retval = self._root.winfo_width(), self._root.winfo_height() + self._root.attributes("-fullscreen", False) + self._root.state("withdraw") + logger.debug("Obtained screen geometry: %s", retval) + return retval + + def _set_min_max_scales(self) -> None: + """ Set the minimum and maximum area that we allow to scale image to. """ + logger.debug("Calculating minimum scale for screen dimensions %s", self._screen_dimensions) + half_screen = tuple(x // 2 for x in self._screen_dimensions) + min_scales = (half_screen[0] / self._image.source.shape[1], + half_screen[1] / self._image.source.shape[0]) + min_scale = min(1.0, min(min_scales)) + min_scale = (ceil(min_scale * 10)) * 10 + + eight_screen = tuple(x * 8 for x in self._screen_dimensions) + max_scales = (eight_screen[0] / self._image.source.shape[1], + eight_screen[1] / self._image.source.shape[0]) + max_scale = min(8.0, max(1.0, min(max_scales))) + max_scale = (floor(max_scale * 10)) * 10 + + logger.debug("Calculated minimum scale: %s, maximum_scale: %s", min_scale, max_scale) + self._taskbar.set_min_max_scale(min_scale, max_scale) + + def _initialize_window(self) -> None: + """ Initialize the window to fit into the current screen """ + logger.debug("Initializing window") + assert isinstance(self._root, tk.Tk) + width = min(self._master_frame.winfo_reqwidth(), self._screen_dimensions[0]) + height = min(self._master_frame.winfo_reqheight(), self._screen_dimensions[1]) + self._set_min_max_scales() + self._root.state("normal") + self._root.geometry(f"{width}x{height}") + self._root.protocol("WM_DELETE_WINDOW", lambda: None) # Intercept close window + self._initialized = True + logger.debug("Initialized window: (width: %s, height: %s)", width, height) + + def _update_image(self, center_image: bool = False) -> None: + """ Update the image displayed in the canvas and set the canvas size and scroll region + accordingly + + center_image: bool = ``True`` + ``True`` if the image in the canvas should be recentered. Defaul:``True`` + """ + logger.debug("Updating image (center_image: %s)", center_image) + self._image.set_display_image() + self._canvas.set_image(self._image.display_image, center_image) + logger.debug("Updated image") + + def _convert_fit_scale(self) -> str: + """ Convert "Fit" scale to the actual scaling amount + + Returns + ------- + str + The fit scaling in '##%' format + """ + logger.debug("Converting 'Fit' scaling") + width_scale = self._canvas.width / self._image.source.shape[1] + height_scale = self._canvas.height / self._image.source.shape[0] + scale = min(width_scale, height_scale) * 100 + retval = f"{floor(scale)}%" + logger.debug("Converted 'Fit' scaling: (width_scale: %s, height_scale: %s, scale: %s, " + "retval: '%s'", width_scale, height_scale, scale, retval) + return retval + + def _set_scale(self, *args) -> None: # pylint:disable=unused-argument + """ Update the image on a scale request """ + txtscale = self._taskbar.scale_var.get() + logger.debug("Setting scale: '%s'", txtscale) + txtscale = self._convert_fit_scale() if txtscale == "Fit" else txtscale + scale = int(txtscale[:-1]) # Strip percentage and convert to int + logger.debug("Got scale: %s", scale) + + if self._image.set_scale(scale / 100): + logger.debug("Updating for new scale") + self._taskbar.slider_var.set(scale) + self._update_image(center_image=True) + + def _set_interpolation(self, *args) -> None: # pylint:disable=unused-argument + """ Callback for when the interpolator is change""" + interp = self._taskbar.interpolator_var.get() + if not self._image.set_interpolation(interp) or self._image.scale <= 1.0: + return + self._update_image(center_image=False) + + def _process_triggers(self) -> None: + """ Process the standard faceswap key press triggers: + + m = toggle_mask + r = refresh + s = save + enter = quit + """ + if self._triggers is None: # Don't need triggers for GUI + return + logger.debug("Processing triggers") + root = self._canvas.winfo_toplevel() + for key in self._keymaps: + bindkey = "Return" if key == "enter" else key + logger.debug("Adding trigger for key: '%s'", bindkey) + + root.bind(f"<{bindkey}>", self._on_keypress) + logger.debug("Processed triggers") + + def _on_keypress(self, event: tk.Event) -> None: + """ Update the triggers on a keypress event for picking up by main faceswap process. + + Parameters + ---------- + event: :class:`tkinter.Event` + The valid preview trigger keypress + """ + if self._triggers is None: # Don't need triggers for GUI + return + keypress = "enter" if event.keysym == "Return" else event.keysym + key = cast(TriggerKeysType, keypress) + logger.debug("Processing keypress '%s'", key) + if key == "r": + print("") # Let log print on different line from loss output + logger.info("Refresh preview requested...") + + self._triggers[self._keymaps[key]].set() + logger.debug("Processed keypress '%s'. Set event for '%s'", key, self._keymaps[key]) + + def _display_preview(self) -> None: + """ Handle the displaying of the images currently in :attr:`_preview_buffer`""" + if self._should_shutdown: + self._root.destroy() + + if not self._buffer.is_updated: + self._root.after(1000, self._display_preview) + return + + for name, image in self._buffer.get_images(): + logger.debug("Updating image: (name: '%s', shape: %s)", name, image.shape) + if self._is_standalone and not self._title: + assert isinstance(self._root, tk.Tk) + self._title = name + logger.debug("Setting title: '%s;", self._title) + self._root.title(self._title) + self._image.set_source_image(name, image) + self._update_image(center_image=not self._initialized) + + self._root.after(1000, self._display_preview) + + if not self._initialized: + self._initialize_window() + self._root.mainloop() + + +def main(): + """ Load image from first given argument and display + + python -m lib.training.preview_tk + """ + from lib.logger import log_setup # pylint:disable=import-outside-toplevel + from .preview_cv import PreviewBuffer # pylint:disable=import-outside-toplevel + log_setup("DEBUG", "faceswap_preview.log", "Test", False) + + img = cv2.imread(sys.argv[-1], cv2.IMREAD_UNCHANGED) + buff = PreviewBuffer() # pylint:disable=used-before-assignment + buff.add_image("test_image", img) + PreviewTk(buff) + + +if __name__ == "__main__": + main() diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index bc3e09cf11..4ebfec50c0 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -111,7 +111,7 @@ def get_model(name: str, disable_logging: bool = False) -> Type["ModelBase"]: return PluginLoader._import("train.model", name, disable_logging) @staticmethod - def get_trainer(name: str, disable_logging: bool = False) -> "TrainerBase": + def get_trainer(name: str, disable_logging: bool = False) -> Type["TrainerBase"]: """ Return requested training trainer plugin Parameters diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 5e7ee916b4..8c82529bca 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -163,7 +163,9 @@ def toggle_mask(self) -> None: def train_one_step(self, viewer: Optional[Callable[[np.ndarray, str], None]], - timelapse_kwargs: Optional[Dict[str, str]]) -> None: + timelapse_kwargs: Optional[Dict[Literal["input_a", + "input_b", + "output"], str]]) -> None: """ Running training on a batch of images for each side. Triggered from the training cycle in :class:`scripts.train.Train`. @@ -326,7 +328,9 @@ def _print_loss(self, loss: List[float]) -> None: def _update_viewers(self, viewer: Optional[Callable[[np.ndarray, str], None]], - timelapse_kwargs: Optional[Dict[str, str]]) -> None: + timelapse_kwargs: Optional[Dict[Literal["input_a", + "input_b", + "output"], str]]) -> None: """ Update the preview viewer and timelapse output Parameters @@ -1046,7 +1050,9 @@ def _setup(self, input_a: str, input_b: str, output: str) -> None: self._feeder.set_timelapse_feed(images, batchsize) logger.debug("Set up time-lapse") - def output_timelapse(self, timelapse_kwargs: Dict[str, str]) -> None: + def output_timelapse(self, timelapse_kwargs: Dict[Literal["input_a", + "input_b", + "output"], str]) -> None: """ Generate the time-lapse samples and output the created time-lapse to the specified output folder. @@ -1058,7 +1064,7 @@ def output_timelapse(self, timelapse_kwargs: Dict[str, str]) -> None: """ logger.debug("Ouputting time-lapse") if not self._output_file: - self._setup(**timelapse_kwargs) + self._setup(**cast(Dict[str, str], timelapse_kwargs)) logger.debug("Getting time-lapse samples") self._samples.images = self._feeder.generate_preview(is_timelapse=True) diff --git a/scripts/train.py b/scripts/train.py index e68940928d..9349e1391e 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -5,18 +5,18 @@ import os import sys -from threading import Lock from time import sleep -from typing import cast, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING +from threading import Event +from typing import cast, Callable, Dict, List, Optional, TYPE_CHECKING import cv2 import numpy as np -from matplotlib import backend_bases, figure, pyplot as plt, rcParams from lib.image import read_image_meta from lib.keypress import KBHit -from lib.multithreading import MultiThread -from lib.utils import (deprecation_warning, get_dpi, get_folder, get_image_paths, +from lib.multithreading import MultiThread, FSThread +from lib.training import Preview, PreviewBuffer, TriggerType +from lib.utils import (deprecation_warning, get_folder, get_image_paths, FaceswapError, _image_extensions) from plugins.plugin_loader import PluginLoader @@ -31,7 +31,7 @@ from plugins.train.trainer._base import TrainerBase -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class Train(): # pylint:disable=too-few-public-methods @@ -62,11 +62,12 @@ def __init__(self, arguments: "argparse.Namespace") -> None: self._timelapse = self._set_timelapse() gui_cache = os.path.join( os.path.realpath(os.path.dirname(sys.argv[0])), "lib", "gui", ".cache") - self._gui_triggers = dict(update=os.path.join(gui_cache, ".preview_trigger"), - mask_toggle=os.path.join(gui_cache, ".preview_mask_toggle")) + self._gui_triggers: Dict[Literal["mask", "refresh"], str] = dict( + mask=os.path.join(gui_cache, ".preview_trigger"), + refresh=os.path.join(gui_cache, ".preview_mask_toggle")) self._stop: bool = False self._save_now: bool = False - self._preview = Preview() + self._preview = PreviewInterface(self._args.preview) logger.debug("Initialized %s", self.__class__.__name__) @@ -331,6 +332,7 @@ def _run_training_cycle(self, model: "ModelBase", trainer: "TrainerBase") -> Non The requested model trainer plugin """ logger.debug("Running Training Cycle") + update_preview_images = False if self._args.write_image or self._args.redirect_gui or self._args.preview: display_func: Optional[Callable] = self._show else: @@ -339,13 +341,15 @@ def _run_training_cycle(self, model: "ModelBase", trainer: "TrainerBase") -> Non for iteration in range(1, self._args.iterations + 1): logger.trace("Training iteration: %s", iteration) # type:ignore save_iteration = iteration % self._args.save_interval == 0 or iteration == 1 + gui_triggers = self._process_gui_triggers() - if self._preview.should_toggle_mask(): + if self._preview.should_toggle_mask or gui_triggers["mask"]: trainer.toggle_mask() - self._preview.request_refresh() + update_preview_images = True - if self._preview.should_refresh(): + if self._preview.should_refresh or gui_triggers["refresh"] or update_preview_images: viewer = display_func + update_preview_images = False else: viewer = None @@ -354,7 +358,7 @@ def _run_training_cycle(self, model: "ModelBase", trainer: "TrainerBase") -> Non if viewer is not None and not save_iteration: # Spammy but required by GUI to know to update window - print("\n") + print("") logger.info("[Preview Updated]") if self._stop: @@ -366,7 +370,7 @@ def _run_training_cycle(self, model: "ModelBase", trainer: "TrainerBase") -> Non "(iteration: %s)", save_iteration, self._save_now, iteration) model.save(is_exit=False) self._save_now = False - self._preview.request_refresh() + update_preview_images = True logger.debug("Training cycle complete") model.save(is_exit=True) @@ -411,23 +415,28 @@ def _check_keypress(self, keypress: KBHit) -> bool: self._save_now = True return retval - def _process_gui_triggers(self) -> None: - """ Check whether a file drop has occurred from the GUI to manually update the preview. """ + def _process_gui_triggers(self) -> Dict[Literal["mask", "refresh"], bool]: + """ Check whether a file drop has occurred from the GUI to manually update the preview. + + Returns + ------- + dict + The trigger name as key and boolean as value + """ + retval: Dict[Literal["mask", "refresh"], bool] = {key: False for key in self._gui_triggers} if not self._args.redirect_gui: - return + return retval - parent_flags = dict(mask_toggle="request_mask_toggle", update="request_refresh") - for trigger in ("mask_toggle", "update"): - filename = self._gui_triggers[trigger] + for trigger, filename in self._gui_triggers.items(): if os.path.isfile(filename): logger.debug("GUI Trigger received for: '%s'", trigger) - + retval[trigger] = True logger.debug("Removing gui trigger file: %s", filename) os.remove(filename) - if trigger == "update": - print("\n") # Let log print on different line from loss output + if trigger == "refresh": + print("") # Let log print on different line from loss output logger.info("Refresh preview requested...") - getattr(self._preview, parent_flags[trigger])() + return retval def _monitor(self, thread: MultiThread) -> bool: """ Monitor the background :func:`_training` thread for key presses and errors. @@ -447,9 +456,6 @@ def _monitor(self, thread: MultiThread) -> bool: err = False while True: try: - if self._args.preview: - self._preview.display_preview() - if thread.has_error: logger.debug("Thread error detected") err = True @@ -459,22 +465,20 @@ def _monitor(self, thread: MultiThread) -> bool: break # Preview Monitor - if self._preview.should_quit(): + if self._preview.should_quit: break - if self._preview.should_save(): + if self._preview.should_save: self._save_now = True # Console Monitor if self._check_keypress(keypress): break # Exit requested - # GUI Preview trigger update monitor - self._process_gui_triggers() - sleep(1) except KeyboardInterrupt: logger.debug("Keyboard Interrupt received") break + self._preview.shutdown() keypress.set_normal_term() logger.debug("Closed Monitor") return err @@ -510,7 +514,7 @@ def _show(self, image: np.ndarray, name: str = "") -> None: logger.debug("Generated preview for GUI: '%s'", imgfile) if self._args.preview: logger.debug("Generating preview for display: '%s'", name) - self._preview.add_image(name, image) + self._preview.buffer.add_image(name, image) logger.debug("Generated preview for display: '%s'", name) except Exception as err: logging.error("could not preview sample") @@ -518,225 +522,105 @@ def _show(self, image: np.ndarray, name: str = "") -> None: logger.debug("Updated preview: (name: %s)", name) -class Preview(): - """ Holds the pop up preview window and options relating to the preview in the window and the - GUI. Thread safe to take requests from the main thread and the training thread. """ - def __init__(self) -> None: - self._lock = Lock() - self._dpi: float = 0.0 - self._triggers: Dict[str, bool] = dict(toggle_mask=False, - full_size=False, - refresh=False, - save=False, - quit=False) - self._needs_update: bool = False - self._preview_buffer: Dict[str, np.ndarray] = {} - self._images: Dict[str, Tuple[figure.Figure, Tuple[float, float]]] = {} - self._resize_ids: List[Tuple[figure.Figure, int]] = [] - self._callbacks = dict(f="full_size", - m="toggle_mask", - r="refresh", - s="save", - enter="quit") - self._configure_matplotlib() - - def _toggle_size(self) -> None: # pylint:disable=unused-argument - """ Toggle between actual size and screen-fit size. """ - self._triggers["full_size"] = not self._triggers["full_size"] - self._set_resize_callback() - - @classmethod - def _configure_matplotlib(cls): - """ Remove `F`, 'S' and 'R' from their default bindings and stop Matplotlib from stealing - focus """ - rcParams["keymap.fullscreen"] = [k for k in rcParams["keymap.fullscreen"] if k != "f"] - rcParams["keymap.save"] = [k for k in rcParams["keymap.save"] if k != "s"] - rcParams["keymap.home"] = [k for k in rcParams["keymap.home"] if k != "r"] - rcParams["figure.raise_window"] = False +class PreviewInterface(): + """ Run the preview window in a thread and interface with it + Parameters + ---------- + use_preview: bool + ``True`` if pop-up preview window has been requested otherwise ``False`` + """ + def __init__(self, use_preview: bool) -> None: + self._active = use_preview + self._triggers: TriggerType = dict(toggle_mask=Event(), + refresh=Event(), + save=Event(), + quit=Event(), + shutdown=Event()) + self._buffer = PreviewBuffer() + self._thread = self._launch_thread() + + @property + def buffer(self) -> PreviewBuffer: + """ :class:`PreviewBuffer`: The thread save preview image object """ + return self._buffer + + @property def should_toggle_mask(self) -> bool: - """ Check whether the mask should be toggled and return the value. If ``True`` is returned - then resets mask toggle back to ``False`` - - Returns - ------- - bool - ``True`` if the mask should be toggled otherwise ``False``. """ - with self._lock: - retval = self._triggers["toggle_mask"] - if retval: - logger.debug("Sending toggle mask") - self._triggers["toggle_mask"] = False + """ bool: Check whether the mask should be toggled and return the value. If ``True`` is + returned then resets mask toggle back to ``False`` """ + if not self._active: + return False + retval = self._triggers["toggle_mask"].is_set() + if retval: + logger.debug("Sending toggle mask") + self._triggers["toggle_mask"].clear() return retval + @property def should_refresh(self) -> bool: - """ Check whether the preview should be updated and return the value. If ``True`` is - returned then resets the refresh trigger back to ``False`` - - Returns - ------- - bool - ``True`` if the preview should be refreshed otherwise ``False``. """ - with self._lock: - retval = self._triggers["refresh"] - if retval: - logger.debug("Sending should refresh") - self._triggers["refresh"] = False - return retval + """ bool: Check whether the preview should be updated and return the value. If ``True`` is + returned then resets the refresh trigger back to ``False`` """ + if not self._active: + return False + retval = self._triggers["refresh"].is_set() + if retval: + logger.debug("Sending should refresh") + self._triggers["refresh"].clear() + return retval + @property def should_save(self) -> bool: - """ Check whether a save request has been made. If ``True`` is returned then :attr:`_save` - is set back to ``False`` - - Returns - ------- - bool - ``True`` if a save has been requested otherwise ``False``. """ - with self._lock: - retval = self._triggers["save"] - if retval: - logger.debug("Sending should save") - self._triggers["save"] = False + """ bool: Check whether a save request has been made. If ``True`` is returned then save + trigger is set back to ``False`` """ + if not self._active: + return False + retval = self._triggers["save"].is_set() + if retval: + logger.debug("Sending should save") + self._triggers["save"].clear() return retval + @property def should_quit(self) -> bool: - """ Check whether an exit request has been made. + """ bool: Check whether an exit request has been made. ``True`` if an exit request has + been made otherwise ``False``. - Returns - ------- - bool - ``True`` if an exit request has been made otherwise ``False``. """ - with self._lock: - retval = self._triggers["quit"] + Raises + ------ + Error + Re-raises any error within the preview thread + """ + if self._thread is None: + return False + + self._thread.check_and_raise_error() + + retval = self._triggers["quit"].is_set() if retval: logger.debug("Sending should stop") return retval - def request_refresh(self) -> None: - """ Handle a GUI trigger or a training thread trigger (after a mask toggle) request to set - the refresh trigger to ``True`` to request a refresh on the next pass of the - training loop. """ - with self._lock: - self._triggers["refresh"] = True - - def request_mask_toggle(self) -> None: - """ Handle a GUI trigger request to set the mask toggle to ``True`` to - request a mask toggle on next pass of the training loop. """ - logger.verbose("Toggle mask display requested...") # type:ignore - with self._lock: - self._triggers["toggle_mask"] = True - - def add_image(self, name: str, image: np.ndarray) -> None: - """ Add a preview image to the preview buffer. - - Parameters - ---------- - name: str - The name of the preview image to add to the buffer - image: :class:`numpy.ndarray` - The preview image to add to the buffer in BGR format. - """ - with self._lock: - logger.debug("Adding image '%s' of shape %s to preview buffer", name, image.shape) - self._preview_buffer[name] = image[..., 2::-1] # Switch to RGB - self._needs_update = True - - def display_preview(self) -> None: - """ Display an image preview in a resizable window. """ - if self._needs_update: - logger.debug("Updating preview") - with self._lock: - for name, image in self._preview_buffer.items(): - if (name not in self._images or # new preview or preview was closed - not plt.fignum_exists(self._images[name][0].number)): - self._create_resizable_window(name, image.shape) - if self._triggers["full_size"]: # Can only be true if preview was closed - self._set_resize_callback() - plt.figure(name) - plt.imshow(image) - self._needs_update = False - plt.show(block=False) - logger.debug("preview updated") # type: ignore - plt.pause(0.1) - - def _create_resizable_window(self, name: str, image_shape: tuple) -> None: - """ Create a resizable Matplotlib window to hold the preview image. + def _launch_thread(self) -> Optional[FSThread]: + """ Launch the preview viewer in it's own thread if preview has been selected - Parameters - ---------- - name: str - The name to display in the window header and for window identification - shape: tuple - The (`rows`, `columns`, `channels`) of the image to be displayed + Returns + ------- + :class:`lib.multithreading.FSThread` or ``None`` + The thread that holds the preview viewer if preview is selected otherwise ``None`` """ - logger.debug("Creating figure '%s' for image shape %s", name, image_shape) - if not self._dpi: - self._dpi = get_dpi() - height, width = image_shape[:2] - size = width / self._dpi, height / self._dpi - fig = plt.figure(name, figsize=size) - axes = plt.Axes(fig, [0., 0., 1., 1.]) # Remove axes and whitespace - axes.set_axis_off() - fig.add_axes(axes) - fig.canvas.mpl_connect("key_press_event", self._on_key_press) - fig.canvas.mpl_connect("close_event", self._on_close) - logger.debug("Created display figure of size: %s", size) - self._images[name] = (fig, size) - - def _set_resize_callback(self): - """ Sets the resize callback if displaying preview at actual size or removes it if - displaying at screen-fit size. """ - if self._triggers["full_size"]: - logger.debug("Setting resize callback for actual size display") - for fig, size in self._images.values(): - self._resize_ids.append((fig, fig.canvas.mpl_connect("resize_event", - self._on_resize))) - fig.set_size_inches(size) - else: - logger.debug("Removing resize callback for screen-fit display") - for fig, cid in self._resize_ids: - fig.canvas.mpl_disconnect(cid) - self._resize_ids = [] - - def _on_key_press(self, event: backend_bases.KeyEvent) -> None: - """ Callbacks for keypresses to update the requested trigger. - - - `F` (toggle full-size/fit to window) - - `M` (toggle mask), - - `R` (refresh preview), - - `S` (save now) - - `Enter` (save and exit) + if not self._active: + return None + thread = FSThread(target=Preview, + name="preview", + args=(self._buffer, ), + kwargs=dict(triggers=self._triggers)) + thread.start() + return thread - Parameters - ---------- - event: - The key press received - """ - key = event.key.lower() - if key not in self._callbacks: + def shutdown(self) -> None: + """ Send a signal to shutdown the preview window. """ + if not self._active: return - - logger.debug("Preview window keypress '%s' received", key) - if key == "r": - print("\n") # Let log print on different line from loss output - logger.info("Refresh preview requested...") - - with self._lock: - if key == "f": - self._toggle_size() - else: - self._triggers[self._callbacks[key]] = True - - def _on_resize(self, - event: backend_bases.ResizeEvent) -> None: # noqa # pylint:disable=unused-argument - """ If the display is set to `actual size` then the image needs to be resized on any window - resize event. """ - for fig, size in self._images.values(): - fig.set_size_inches(size) - - def _on_close(self, - event: backend_bases.CloseEvent) -> None: # noqa # pylint:disable=unused-argument - """ Force an update when the figure has been closed to relaunch it. """ - logger.debug("Preview close detected") - with self._lock: - self._needs_update = True + logger.debug("Sending shutdown to preview viewer") + self._triggers["shutdown"].set() From 89d124c311bb9761a37189245275d76bdf214fda Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 7 Sep 2022 12:40:19 +0100 Subject: [PATCH 723/981] Sort - Add Batch Mode. Speed up yaw sort --- locales/es/LC_MESSAGES/tools.sort.cli.mo | Bin 10116 -> 10610 bytes locales/es/LC_MESSAGES/tools.sort.cli.po | 46 +++++--- locales/tools.sort.cli.pot | 133 ++++++++++++++++------- tools/sort/cli.py | 10 ++ tools/sort/sort.py | 93 ++++++++++++++-- 5 files changed, 219 insertions(+), 63 deletions(-) diff --git a/locales/es/LC_MESSAGES/tools.sort.cli.mo b/locales/es/LC_MESSAGES/tools.sort.cli.mo index 504ee6da2476b52fda7eaf41365180b285682369..bc14f8b69a13f48e1da5905bf010516f0eac3f3d 100644 GIT binary patch delta 957 zcmY+BO=uHQ5XYyr+N6Fb#fn-T5sY6OlK9!67b%58O114lP?4A2x7i1qx9Ps!6!nmc z9z7Mkhz0LGD7kv`Akv!$L7|>Ryy{6s!T)YjsSjpZwvw%H;yAh^8#CCk))d_r?_6KiSpH#GGuQoi$?`%CWA-4Pz|? zRUWxEYt6~FGAhv4BpOw@QmRy}(u7sI$mL4)?D^%j+|%V}`P0j8{=#6f+@D){ZI3_JLSflaD% zq>+My(7|M3k!w96p5}&RsoP3~ub*rD4_MB>3MSo{0~J=VX+_MFTxv6O<;DRlh Sb<|-UH0z-(bXUf|Wd8#Hp5jOV delta 464 zcmX}oze|Eq6vpw#v{EZ8wS+RXpr81LT9i6OgG&&YPEA3Bk%Ki91~s@iR7-=@(hyNg zLz91n1dTyZEj2`AEm07CuR#ZnAJ6rD&pFrkTqRR}@;Z)1bdy2yk_?a$8z00z2AZS; z+@(IZOM@76NMD@K;SF`NReHf@r!;|Y_=-I)Da>y_7^4m`=nk`(kjfhKO7zeww%{7_ zu8P=!B}_BCg6j;d`=k@j2)^U-gKJb%zzK?}@KH1+Gpe(oYQy=0ajn4+8X`Z_s zmfW_oWFL_hIPi?JJuahJWCdAVd8D_BNT2^`;~ST1Ea^eBw2AgV#IB8h)s&`RCu#bd z%XX0_+~y@S#9q6$?Kx}T?knfeQpU>VONFh%=7yDC-Oi`N@n}3|MH5zZHXMtmrV`ah X-)Z&2KU3ZDM`{oLdspo!^z8ZtF3mXj diff --git a/locales/es/LC_MESSAGES/tools.sort.cli.po b/locales/es/LC_MESSAGES/tools.sort.cli.po index 2a585be3ed..064fb65b9e 100644 --- a/locales/es/LC_MESSAGES/tools.sort.cli.po +++ b/locales/es/LC_MESSAGES/tools.sort.cli.po @@ -5,17 +5,18 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-08-07 12:34+0100\n" -"PO-Revision-Date: 2021-08-07 12:38+0100\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-09-07 12:34+0100\n" +"PO-Revision-Date: 2022-09-07 12:35+0100\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es_ES\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 2.4.3\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.0.1\n" #: tools/sort/cli.py:14 msgid "This command lets you sort images using various methods." @@ -26,7 +27,7 @@ msgstr "" msgid "Sort faces using a number of different techniques" msgstr "Clasificar los rostros mediante diferentes técnicas" -#: tools/sort/cli.py:33 tools/sort/cli.py:40 +#: tools/sort/cli.py:33 tools/sort/cli.py:40 tools/sort/cli.py:47 msgid "data" msgstr "datos" @@ -38,11 +39,21 @@ msgstr "Directorio de entrada de caras alineadas." msgid "Output directory for sorted aligned faces." msgstr "Directorio de salida para las caras alineadas ordenadas." -#: tools/sort/cli.py:50 tools/sort/cli.py:99 +#: tools/sort/cli.py:48 +msgid "" +"R|If selected then the input_dir should be a parent folder containing " +"multiple folders of faces you wish to sort. The faces will be output to " +"separate sub-folders in the output_dir if 'rename' has been selected" +msgstr "" +"R|Si se selecciona, input_dir debe ser una carpeta principal que contenga " +"varias carpetas de caras que desea ordenar. Las caras se enviarán a " +"subcarpetas separadas en output_dir si se ha seleccionado 'cambiar nombre'" + +#: tools/sort/cli.py:60 tools/sort/cli.py:109 msgid "sort settings" msgstr "ajustes de ordenación" -#: tools/sort/cli.py:52 +#: tools/sort/cli.py:62 msgid "" "R|Sort by method. Choose how images are sorted. \n" "L|'blur': Sort faces by blurriness.\n" @@ -114,12 +125,12 @@ msgstr "" "imagen es negra .\n" "Por defecto: face" -#: tools/sort/cli.py:88 tools/sort/cli.py:115 tools/sort/cli.py:127 -#: tools/sort/cli.py:138 +#: tools/sort/cli.py:98 tools/sort/cli.py:125 tools/sort/cli.py:137 +#: tools/sort/cli.py:148 msgid "output" msgstr "salida" -#: tools/sort/cli.py:89 +#: tools/sort/cli.py:99 msgid "" "Keeps the original files in the input directory. Be careful when using this " "with rename grouping and no specified output directory as this would keep " @@ -130,7 +141,7 @@ msgstr "" "de salida, ya que esto mantendría los archivos originales y renombrados en " "el mismo directorio." -#: tools/sort/cli.py:101 +#: tools/sort/cli.py:111 msgid "" "Float value. Minimum threshold to use for grouping comparison with 'face-" "cnn' and 'hist' methods. The lower the value the more discriminating the " @@ -151,7 +162,7 @@ msgstr "" "podría resultar en la creación de muchos directorios. Por defecto: 'face-" "cnn' = 7.2, 'hist' = 0.3" -#: tools/sort/cli.py:116 +#: tools/sort/cli.py:126 msgid "" "R|Default: rename.\n" "L|'folders': files are sorted using the -s/--sort-by method, then they are " @@ -165,7 +176,7 @@ msgstr "" "L|'rename': los archivos se ordenan utilizando el método -s/--sort-by y " "luego se renombran." -#: tools/sort/cli.py:129 +#: tools/sort/cli.py:139 msgid "" "Group by method. When -fp/--final-processing by folders choose the how the " "images are grouped after sorting. Default: hist" @@ -173,7 +184,8 @@ msgstr "" "Método de agrupamiento. Elija la forma de agrupar las imágenes, en el caso " "de hacerlo por carpetas, después de la clasificación. Por defecto: hist" -#: tools/sort/cli.py:140 +#: tools/sort/cli.py:150 +#, python-format msgid "" "Integer value. Number of folders that will be used to group by blur, face-" "yaw and black-pixels. For blur folder 0 will be the least blurry, while the " @@ -199,11 +211,11 @@ msgstr "" "de píxeles negros. Para 10, la primera carpeta tendrá las caras con 0 a 10%% " "de píxeles negros, la segunda de 11 a 20%%, etc. Valor por defecto: 5" -#: tools/sort/cli.py:154 tools/sort/cli.py:164 +#: tools/sort/cli.py:164 tools/sort/cli.py:174 msgid "settings" msgstr "ajustes" -#: tools/sort/cli.py:156 +#: tools/sort/cli.py:166 msgid "" "Logs file renaming changes if grouping by renaming, or it logs the file " "copying/movement if grouping by folders. If no log file is specified with " @@ -215,7 +227,7 @@ msgstr "" "se especifica ningún archivo de registro con '--log-file', se creará un " "archivo 'sort_log.json' en el directorio de entrada." -#: tools/sort/cli.py:167 +#: tools/sort/cli.py:177 msgid "" "Specify a log file to use for saving the renaming or grouping information. " "If specified extension isn't 'json' or 'yaml', then json will be used as the " diff --git a/locales/tools.sort.cli.pot b/locales/tools.sort.cli.pot index ef03658792..099400fdc4 100644 --- a/locales/tools.sort.cli.pot +++ b/locales/tools.sort.cli.pot @@ -1,19 +1,21 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR ORGANIZATION +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # +#, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-08-07 12:34+0100\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-09-07 12:34+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" +"Language: \n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=cp1252\n" +"Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" - #: tools/sort/cli.py:14 msgid "This command lets you sort images using various methods." @@ -23,7 +25,7 @@ msgstr "" msgid "Sort faces using a number of different techniques" msgstr "" -#: tools/sort/cli.py:33 tools/sort/cli.py:40 +#: tools/sort/cli.py:33 tools/sort/cli.py:40 tools/sort/cli.py:47 msgid "data" msgstr "" @@ -35,68 +37,123 @@ msgstr "" msgid "Output directory for sorted aligned faces." msgstr "" -#: tools/sort/cli.py:50 tools/sort/cli.py:99 +#: tools/sort/cli.py:48 +msgid "" +"R|If selected then the input_dir should be a parent folder containing " +"multiple folders of faces you wish to sort. The faces will be output to " +"separate sub-folders in the output_dir if 'rename' has been selected" +msgstr "" + +#: tools/sort/cli.py:60 tools/sort/cli.py:109 msgid "sort settings" msgstr "" -#: tools/sort/cli.py:52 +#: tools/sort/cli.py:62 msgid "" "R|Sort by method. Choose how images are sorted. \n" "L|'blur': Sort faces by blurriness.\n" "L|'blur-fft': Sort faces by fft filtered blurriness.\n" -"L|'distance' Sort faces by the estimated distance of the alignments from an 'average' face. This can be useful for eliminating misaligned faces.\n" -"L|'face': Use VGG Face to sort by face similarity. This uses a pairwise clustering algorithm to check the distances between 512 features on every face in your set and order them appropriately.\n" -"L|'face-cnn': Sort faces by their landmarks. You can adjust the threshold with the '-t' (--ref_threshold) option.\n" +"L|'distance' Sort faces by the estimated distance of the alignments from an " +"'average' face. This can be useful for eliminating misaligned faces.\n" +"L|'face': Use VGG Face to sort by face similarity. This uses a pairwise " +"clustering algorithm to check the distances between 512 features on every " +"face in your set and order them appropriately.\n" +"L|'face-cnn': Sort faces by their landmarks. You can adjust the threshold " +"with the '-t' (--ref_threshold) option.\n" "L|'face-cnn-dissim': Like 'face-cnn' but sorts by dissimilarity.\n" "L|'face-yaw': Sort faces by Yaw (rotation left to right).\n" -"L|'hist': Sort faces by their color histogram. You can adjust the threshold with the '-t' (--ref_threshold) option.\n" +"L|'hist': Sort faces by their color histogram. You can adjust the threshold " +"with the '-t' (--ref_threshold) option.\n" "L|'hist-dissim': Like 'hist' but sorts by dissimilarity.\n" -"L|'color-gray': Sort images by the average intensity of the converted grayscale color channel.\n" -"L|'color-luma': Sort images by the average intensity of the converted Y color channel. Bright lighting and oversaturated images will be ranked first.\n" -"L|'color-green': Sort images by the average intensity of the converted Cg color channel. Green images will be ranked first and red images will be last.\n" -"L|'color-orange': Sort images by the average intensity of the converted Co color channel. Orange images will be ranked first and blue images will be last.\n" -"L|'size': Sort images by their size in the original frame. Faces closer to the camera and from higher resolution sources will be sorted first, whilst faces further from the camera and from lower resolution sources will be sorted last.\n" -"L|'black-pixels': Sort images by their number of black pixels. Useful when faces are near borders and a large part of the image is black.\n" +"L|'color-gray': Sort images by the average intensity of the converted " +"grayscale color channel.\n" +"L|'color-luma': Sort images by the average intensity of the converted Y " +"color channel. Bright lighting and oversaturated images will be ranked " +"first.\n" +"L|'color-green': Sort images by the average intensity of the converted Cg " +"color channel. Green images will be ranked first and red images will be " +"last.\n" +"L|'color-orange': Sort images by the average intensity of the converted Co " +"color channel. Orange images will be ranked first and blue images will be " +"last.\n" +"L|'size': Sort images by their size in the original frame. Faces closer to " +"the camera and from higher resolution sources will be sorted first, whilst " +"faces further from the camera and from lower resolution sources will be " +"sorted last.\n" +"L|'black-pixels': Sort images by their number of black pixels. Useful when " +"faces are near borders and a large part of the image is black.\n" "Default: face" msgstr "" -#: tools/sort/cli.py:88 tools/sort/cli.py:115 tools/sort/cli.py:127 -#: tools/sort/cli.py:138 +#: tools/sort/cli.py:98 tools/sort/cli.py:125 tools/sort/cli.py:137 +#: tools/sort/cli.py:148 msgid "output" msgstr "" -#: tools/sort/cli.py:89 -msgid "Keeps the original files in the input directory. Be careful when using this with rename grouping and no specified output directory as this would keep the original and renamed files in the same directory." +#: tools/sort/cli.py:99 +msgid "" +"Keeps the original files in the input directory. Be careful when using this " +"with rename grouping and no specified output directory as this would keep " +"the original and renamed files in the same directory." msgstr "" -#: tools/sort/cli.py:101 -msgid "Float value. Minimum threshold to use for grouping comparison with 'face-cnn' and 'hist' methods. The lower the value the more discriminating the grouping is. Leaving -1.0 will allow the program set the default value automatically. For face-cnn 7.2 should be enough, with 4 being very discriminating. For hist 0.3 should be enough, with 0.2 being very discriminating. Be careful setting a value that's too low in a directory with many images, as this could result in a lot of directories being created. Defaults: face-cnn 7.2, hist 0.3" +#: tools/sort/cli.py:111 +msgid "" +"Float value. Minimum threshold to use for grouping comparison with 'face-" +"cnn' and 'hist' methods. The lower the value the more discriminating the " +"grouping is. Leaving -1.0 will allow the program set the default value " +"automatically. For face-cnn 7.2 should be enough, with 4 being very " +"discriminating. For hist 0.3 should be enough, with 0.2 being very " +"discriminating. Be careful setting a value that's too low in a directory " +"with many images, as this could result in a lot of directories being " +"created. Defaults: face-cnn 7.2, hist 0.3" msgstr "" -#: tools/sort/cli.py:116 +#: tools/sort/cli.py:126 msgid "" "R|Default: rename.\n" -"L|'folders': files are sorted using the -s/--sort-by method, then they are organized into folders using the -g/--group-by grouping method.\n" +"L|'folders': files are sorted using the -s/--sort-by method, then they are " +"organized into folders using the -g/--group-by grouping method.\n" "L|'rename': files are sorted using the -s/--sort-by then they are renamed." msgstr "" -#: tools/sort/cli.py:129 -msgid "Group by method. When -fp/--final-processing by folders choose the how the images are grouped after sorting. Default: hist" -msgstr "" - -#: tools/sort/cli.py:140 -msgid "Integer value. Number of folders that will be used to group by blur, face-yaw and black-pixels. For blur folder 0 will be the least blurry, while the last folder will be the blurriest. For face-yaw the number of bins is by how much 180 degrees is divided. So if you use 18, then each folder will be a 10 degree increment. Folder 0 will contain faces looking the most to the left whereas the last folder will contain the faces looking the most to the right. If the number of images doesn't divide evenly into the number of bins, the remaining images get put in the last bin. For black-pixels it represents the divider of the percentage of black pixels. For 10, first folder will have the faces with 0 to 10%% black pixels, second 11 to 20%%, etc. Default value: 5" +#: tools/sort/cli.py:139 +msgid "" +"Group by method. When -fp/--final-processing by folders choose the how the " +"images are grouped after sorting. Default: hist" msgstr "" -#: tools/sort/cli.py:154 tools/sort/cli.py:164 +#: tools/sort/cli.py:150 +#, python-format +msgid "" +"Integer value. Number of folders that will be used to group by blur, face-" +"yaw and black-pixels. For blur folder 0 will be the least blurry, while the " +"last folder will be the blurriest. For face-yaw the number of bins is by how " +"much 180 degrees is divided. So if you use 18, then each folder will be a 10 " +"degree increment. Folder 0 will contain faces looking the most to the left " +"whereas the last folder will contain the faces looking the most to the " +"right. If the number of images doesn't divide evenly into the number of " +"bins, the remaining images get put in the last bin. For black-pixels it " +"represents the divider of the percentage of black pixels. For 10, first " +"folder will have the faces with 0 to 10%% black pixels, second 11 to 20%%, " +"etc. Default value: 5" +msgstr "" + +#: tools/sort/cli.py:164 tools/sort/cli.py:174 msgid "settings" msgstr "" -#: tools/sort/cli.py:156 -msgid "Logs file renaming changes if grouping by renaming, or it logs the file copying/movement if grouping by folders. If no log file is specified with '--log-file', then a 'sort_log.json' file will be created in the input directory." +#: tools/sort/cli.py:166 +msgid "" +"Logs file renaming changes if grouping by renaming, or it logs the file " +"copying/movement if grouping by folders. If no log file is specified with " +"'--log-file', then a 'sort_log.json' file will be created in the input " +"directory." msgstr "" -#: tools/sort/cli.py:167 -msgid "Specify a log file to use for saving the renaming or grouping information. If specified extension isn't 'json' or 'yaml', then json will be used as the serializer, with the supplied filename. Default: sort_log.json" +#: tools/sort/cli.py:177 +msgid "" +"Specify a log file to use for saving the renaming or grouping information. " +"If specified extension isn't 'json' or 'yaml', then json will be used as the " +"serializer, with the supplied filename. Default: sort_log.json" msgstr "" - diff --git a/tools/sort/cli.py b/tools/sort/cli.py index bde6b9ad8a..fdb1d469da 100644 --- a/tools/sort/cli.py +++ b/tools/sort/cli.py @@ -39,6 +39,16 @@ def get_argument_list(): dest="output_dir", group=_("data"), help=_("Output directory for sorted aligned faces."))) + argument_list.append(dict( + opts=("-B", "--batch-mode"), + action="store_true", + dest="batch_mode", + default=False, + group=_("data"), + help=_("R|If selected then the input_dir should be a parent folder containing " + "multiple folders of faces you wish to sort. The faces " + "will be output to separate sub-folders in the output_dir if 'rename' has been " + "selected"))) argument_list.append(dict( opts=('-s', '--sort-by'), action=Radio, diff --git a/tools/sort/sort.py b/tools/sort/sort.py index a6350effad..4c114f9d1f 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -6,8 +6,10 @@ import os import sys import operator +from argparse import Namespace from concurrent import futures from shutil import copyfile +from typing import List import numpy as np import cv2 @@ -21,10 +23,83 @@ from plugins.extract.recognition.vgg_face2_keras import VGGFace2 as VGGFace from plugins.extract.pipeline import Extractor, ExtractMedia -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) -class Sort(): +class Sort(): # pylint:disable=too-few-public-methods + """ Sorts folders of faces based on input criteria + + Wrapper for the sort process to run in either batch mode or single use mode + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The arguments to be passed to the extraction process as generated from Faceswap's command + line arguments + """ + def __init__(self, arguments: Namespace) -> None: + self._args = arguments + self._input_locations = self._get_input_locations() + + def _get_input_locations(self) -> List[str]: + """ Obtain the full path to input locations. Will be a list of locations if batch mode is + selected, or a containing a single location if batch mode is not selected. + + Returns + ------- + list: + The list of input location paths + """ + if not self._args.batch_mode: + return [self._args.input_dir] + + retval = [os.path.join(self._args.input_dir, fname) + for fname in os.listdir(self._args.input_dir) + if os.path.isdir(os.path.join(self._args.input_dir, fname))] + logger.info("Input locations: %s", retval) + return retval + + def _output_for_input(self, input_location: str) -> str: + """ Obtain the path to an output folder for faces for a given input location. + + If not running in batch mode, then the user supplied output location will be returned, + otherwise a sub-folder within the user supplied output location will be returned based on + the input filename + + Parameters + ---------- + input_location: str + The full path to an input video or folder of images + """ + if not self._args.batch_mode or self._args.output_dir is None: + return self._args.output_dir + + retval = os.path.join(self._args.output_dir, os.path.basename(input_location)) + logger.info("Returning output: '%s' for input: '%s'", retval, input_location) + return retval + + def process(self) -> None: + """ The entry point for triggering the Sort Process. + + Should only be called from :class:`lib.cli.launcher.ScriptExecutor` + """ + logger.info('Starting, this may take a while...') + inputs = self._input_locations + if self._args.batch_mode: + logger.info("Batch mode selected processing: %s", self._input_locations) + for job_no, location in enumerate(self._input_locations): + if self._args.batch_mode: + logger.info("Processing job %s of %s: '%s'", job_no + 1, len(inputs), location) + arguments = Namespace(**self._args.__dict__) + arguments.input_dir = location + arguments.output_dir = self._output_for_input(location) + else: + arguments = self._args + sort = _Sort(arguments) + sort.process() + + +class _Sort(): """ Sorts folders of faces based on input criteria """ # pylint: disable=no-member @@ -308,19 +383,21 @@ def sort_face_yaw(self): logger.info("Sorting by estimated face yaw angle..") filenames = [] yaws = [] - for filename, image, metadata in tqdm(self._loader.load(), - desc="Classifying Faces", - total=self._loader.count, - leave=False): + filelist = [os.path.join(self._loader.location, fname) + for fname in os.listdir(self._loader.location) + if os.path.splitext(fname)[-1] == ".png"] + for filename, metadata in tqdm(read_image_meta_batch(filelist), + total=len(filelist), + desc="Calculating Yaw"): if not metadata: msg = ("The images to be sorted do not contain alignment data. Images must have " "been generated by Faceswap's Extract process.\nIf you are sorting an " "older faceset, then you should re-extract the faces from your source " "alignments file to generate this data.") raise FaceswapError(msg) - alignments = metadata["alignments"] + alignments = metadata["itxt"]["alignments"] aligned_face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), - image=image, + image=None, centering="legacy", is_aligned=True) filenames.append(filename) From 6985495902dd114b7b41f3e8b9a0de4e75d7c0ef Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 7 Sep 2022 12:45:20 +0100 Subject: [PATCH 724/981] sort - lower log level --- tools/sort/sort.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 4c114f9d1f..6c27d50326 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -38,8 +38,10 @@ class Sort(): # pylint:disable=too-few-public-methods line arguments """ def __init__(self, arguments: Namespace) -> None: + logger.debug("Initializing: %s (args: %s)", self.__class__.__name__, arguments) self._args = arguments self._input_locations = self._get_input_locations() + logger.debug("Initialized: %s", self.__class__.__name__) def _get_input_locations(self) -> List[str]: """ Obtain the full path to input locations. Will be a list of locations if batch mode is @@ -56,7 +58,7 @@ def _get_input_locations(self) -> List[str]: retval = [os.path.join(self._args.input_dir, fname) for fname in os.listdir(self._args.input_dir) if os.path.isdir(os.path.join(self._args.input_dir, fname))] - logger.info("Input locations: %s", retval) + logger.debug("Input locations: %s", retval) return retval def _output_for_input(self, input_location: str) -> str: @@ -75,7 +77,7 @@ def _output_for_input(self, input_location: str) -> str: return self._args.output_dir retval = os.path.join(self._args.output_dir, os.path.basename(input_location)) - logger.info("Returning output: '%s' for input: '%s'", retval, input_location) + logger.debug("Returning output: '%s' for input: '%s'", retval, input_location) return retval def process(self) -> None: From 42a010b17a7760916351f694a8484205df8ab7fe Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 7 Sep 2022 19:33:56 +0100 Subject: [PATCH 725/981] bugfix: Windows Mousewheel in preview --- lib/training/preview_tk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/training/preview_tk.py b/lib/training/preview_tk.py index d2ecf47382..0889686c10 100644 --- a/lib/training/preview_tk.py +++ b/lib/training/preview_tk.py @@ -562,7 +562,7 @@ def _set_mouse_bindings(self) -> None: self._canvas.tag_bind(self._canvas.image_id, "", self._on_bound_zoom) self._canvas.tag_bind(self._canvas.image_id, "", self._on_bound_zoom) else: - self._canvas.tag_bind(self._canvas.image_id, "", self._on_bound_zoom) + self._canvas.bind("", self._on_bound_zoom) self._canvas.tag_bind(self._canvas.image_id, "", self._on_mouse_click) self._canvas.tag_bind(self._canvas.image_id, "", self._on_mouse_drag) From 98d01760e469fd2108eed8d0b0a1ba6297c3177c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 13 Sep 2022 13:08:59 +0100 Subject: [PATCH 726/981] Overhaul sort: - Standardize image data reading and writing - Optimize loading (just one pass required) - Make all sort groups binnable (to greater or lesser results) - Add sort by pitch - Deprecate multiple options - linting, docs + locales --- docs/full/tools/sort.rst | 34 + docs/full/tools/tools.rst | 1 + lib/align/aligned_face.py | 2 +- lib/image.py | 23 + locales/es/LC_MESSAGES/tools.mo | Bin 717 -> 0 bytes locales/es/LC_MESSAGES/tools.po | 27 - locales/es/LC_MESSAGES/tools.sort.cli.mo | Bin 10610 -> 15170 bytes locales/es/LC_MESSAGES/tools.sort.cli.po | 613 ++++++--- locales/tools.pot | 21 - locales/tools.sort.cli.pot | 284 ++-- .../extract/recognition/vgg_face2_keras.py | 226 +++- tools.py | 5 +- tools/sort/cli.py | 207 +-- tools/sort/sort.py | 1171 +++-------------- tools/sort/sort_methods.py | 1031 +++++++++++++++ tools/sort/sort_methods_aligned.py | 370 ++++++ 16 files changed, 2633 insertions(+), 1382 deletions(-) create mode 100644 docs/full/tools/sort.rst delete mode 100644 locales/es/LC_MESSAGES/tools.mo delete mode 100644 locales/es/LC_MESSAGES/tools.po delete mode 100644 locales/tools.pot create mode 100644 tools/sort/sort_methods.py create mode 100644 tools/sort/sort_methods_aligned.py diff --git a/docs/full/tools/sort.rst b/docs/full/tools/sort.rst new file mode 100644 index 0000000000..eafa244d1f --- /dev/null +++ b/docs/full/tools/sort.rst @@ -0,0 +1,34 @@ +************ +sort package +************ + +.. contents:: Contents + :local: + + +sort module +=========== +The Sort Module is the main entry point into the Sort Tool. + +.. automodule:: tools.sort.sort + :members: + :undoc-members: + :show-inheritance: + + +sort_methods module +=================== + +.. automodule:: tools.sort.sort_methods + :members: + :undoc-members: + :show-inheritance: + + +sort_methods_algigned module +============================ + +.. automodule:: tools.sort.sort_methods_algigned + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/tools/tools.rst b/docs/full/tools/tools.rst index 743945322e..1b9cd19240 100644 --- a/docs/full/tools/tools.rst +++ b/docs/full/tools/tools.rst @@ -14,6 +14,7 @@ Subpackages :maxdepth: 1 manual + sort alignments module ================= diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 21ca147c2b..d1d7063471 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -418,7 +418,7 @@ def original_roi(self) -> np.ndarray: roi = np.rint(self.transform_points(roi, invert=True)).astype("int32") logger.trace("original roi: %s", roi) # type: ignore self._cache.original_roi = roi - return self._cache.original_roi[0] + return self._cache.original_roi @property def landmarks(self) -> np.ndarray: diff --git a/lib/image.py b/lib/image.py index a8337b83ac..bac0668626 100644 --- a/lib/image.py +++ b/lib/image.py @@ -1227,6 +1227,29 @@ def __init__(self, path, skip_list=None, count=None): path, count) super().__init__(path, queue_size=8, skip_list=skip_list, count=count) + def _get_count_and_filelist(self, fast_count, count): + """ Override default implementation to only return png files from the source folder + + Parameters + ---------- + fast_count: bool + Not used for faces loader + count: int + The number of images that the loader will encounter if already known, otherwise + ``None`` + """ + if isinstance(self.location, (list, tuple)): + file_list = self.location + else: + file_list = get_image_paths(self.location) + + self._file_list = [fname for fname in file_list + if os.path.splitext(fname)[-1].lower() == ".png"] + self._count = len(self.file_list) if count is None else count + + logger.debug("count: %s", self.count) + logger.trace("filelist: %s", self.file_list) + def _from_folder(self): """ Generator for loading images from a folder Faces will only ever be loaded from a folder, so this is the only function requiring diff --git a/locales/es/LC_MESSAGES/tools.mo b/locales/es/LC_MESSAGES/tools.mo deleted file mode 100644 index afc32e4c192bb5b28842536461c58756b9b97a89..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 717 zcmYLH&5qMR3=Rk`IU;f6U~V8f*`x@mr1S#YMJur)KtbG)o4CnrlbPB1*>3j0Tkrrp z0^+~}^aXeVo&_hYsHIPiV}IY+}MN8-i)>v+XfnEm-6?z0z)seQ~$$9V%f!Den(y&9te>4PU?WAu2!FAZS zbVGDBraRmcxL|1{1yzxCo<*V2JCGiB+HPGE6;_{9>BVU!K5>QADu)f07ObI4|Snm$R{;y0-3fHjE~VV850Tij?>>G>B=9O7Nwd)*`Vba79Uq9Vs@yrsN9CESgKN2Qq?)k*3!qXNs;B2 zD)F+#s*pwv, YEAR. -# -msgid "" -msgstr "" -"Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-02-18 23:49-0000\n" -"PO-Revision-Date: 2021-02-19 18:00+0000\n" -"Language-Team: tokafondo\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 2.3\n" -"Last-Translator: \n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"Language: es_ES\n" - -#: tools.py:46 -msgid "" -"Please backup your data and/or test the tool you want to use with a smaller " -"data set to make sure you understand how it works." -msgstr "" -"Por favor, haga una copia de seguridad de sus datos, y pruebe la " -"herramienta que quiere utilizar con un conjunto de datos más pequeño para " -"asegurarse de que entiende cómo funciona." diff --git a/locales/es/LC_MESSAGES/tools.sort.cli.mo b/locales/es/LC_MESSAGES/tools.sort.cli.mo index bc14f8b69a13f48e1da5905bf010516f0eac3f3d..ec1ebb240a17cb378204d8df6893b15dec264bb6 100644 GIT binary patch literal 15170 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 literal 10610 zcmb`N&#xrcRmTemguL)8CL)NRH$kCoc-?+)Y%9Yvv%uq-(TF$k)6Cex0%YCSUEN(X z)m82K@g_a8#9zP?7A%lvX$eb2-dXH=6|rLxDT}aT!Im8$7JR&U+4Pf z7ozBYc|Pa*uiX1DM$vz89sgn!{Sns}z7$3O$@Lo7f8+Xx2T|l$^FLgVx&JK=`~lZf zu2;GK!LLUVq@us#`U_k=S4c%muD{IneXf5E4}ZgT$++c|x=*I~4Rqp?h>kqmA`1hh{!U2E#`%&~N_y01Aq66Ol7uVa| z|KE#I^z&SALR|cua)tfq9@pRC`j=c;ZfkyxtNy;kM{5vq=MT1`Uum9*7VO)fbl(0+ z#$VzinSYhfm-$E^?cZ10FE`WNSMI{6bv$u*k}O%(D_1RxxLoGx%vHIo%h=8H!Yzut zUMJbYP4m^-7fG3C?om=L-DvKo@pzhLBj>Z38!eNv8o5XHEjezH|i?h65EDwV)uku6(Uc|*_kGnub(z&C_ zE6*Zu1nuXLcq4XGU&Qk|72hVb@08-JQHjcP>B}XV^Keka(<+A-Q}xPc8<(v7A}$Y| zXD!@d4+(j=9n?>Yv7CDP^&2j<>`G%q;U9hmj$djhzC&4yl7}JoE~+I?ZSI17abR;~b1d*q;UI;;tQQrxPZF5U6fBR7i|MT`M?HA^m%nXGp&cgfss^4fy) z`0Al(WiIyY*<--Fc#o;hq9{pTjj_^643WFo}<)d zj1S54c(5;UOGhuCPpe24OW3$QH$uG@8ef^^ahZ+e1d`ar7jc&2_*uhAzg}81>GA&+ zB8cyX#)7mUM?$WSS(dlf?d|Q7irs4h2-O71!nx*B+PjX6Denk7Ihpy28~V!e5$==} zd*)yIi{KbR6&AopQs(i|H@~?<<&a!}?q=@zSnsd!{?NtMw2O@3=_l^B=+EPL9eB*q zp#qVUQ~5^_GuaKEi5nywY?t9N!M6C5aa6>aUk$RZQh1iTave{Tc|zpmb+sFhZQglg zdFLSvnSwihL?KWmV28YwI;hKNljt;Gl(r%)7hlt*&q!`^@2(CI5k`*phmOd^T&Z@N zmnrzB`Fg|4msk0Px%d9=CM%e`AsU;*si>eAGiYNp9`l+Em&T(es=OO1QN73W$pebs zC~R)COOsNcrT^%yef6o9ilEZqZd$whL6eqG8vRWUPCpnm+>c1rrcCA0W`AqT*o{l_ zvYw97Rw%s<`)Fxnxi>EsK1&{Rti@47w(|#QETCap7m-f=fts-rtojph!r|tYfrG{= zCehsw2>dcmDQ!y4%_8{1(cZ&B2DQ~Yum-W$=Cvlp$X8a>yJU_1TTxU1R3I!fK1#}^ zf=NmVAfydLKm?h*acixJIfAn2%KCiV(rUsftTLW($x)*Kwin{1Vq_BdthtH!}sganO@*t6Kr+Os&ymoxW&A~i1+1&wHsU%=238R3eg5qn`38k#OP(?%$NWKQ# ztcwISfgM``4C3yai}mj7y&LF4*J6BCi0)XvC^I zTC|G%k{vA&cxhJ+lFXugtrkPt4;BnPgTSJZp4SEE(rYN^ii3UXp?Usv z;+?3&X69KU4p=oQlu^bK(dICGK%d z6WJy7vR*&YGwyzWc#|ivm$>0s2bm%jo%@kAbV)=%SV1aDc9h?a(Qq!UUS&^PPs^C<2UL> zIliBtxP!B|#&_e3M4QHMl7&v_L#|vIAN|Sr=v(gi%E>FQ{qfQ9(b2&v!9KoE@Rg}& zGQrMM!o3D1jPJ*ObwZFl^mDqIynAs1>Ui(Qy@NZq@7(AaW2AF%oz4@pkMD2R99UtR zm)9wM-0Moh1#(?``~J=G)qb7M#Z|^PvS~iU<|ppzc~Tvm0h5?X(73OdX=H1=Ew5d9 z-G#5$Ud-IruesxwUO%`MX9O>KfBeSg#H}~YPK}YWopmvm8Gq_VEisMSzkBA&+TP=BLT_l-MNg>n{(<1C2XLt)%yB?_PVsN1Xz;~? zV*5m=x2JLV#NA7H0S^zn4b$i^Q`bz;pa)(&2aem$mbk-B*f7@sDvZ;%Pc%;xL`3>C z$v9wHL$z7VAZGiK7Z1Scc`{A3nEsaQEO)O0+J|Gc0ijUrHCX#JB1eZ;o<`<=oGs9J zoyLqnEkD$)#83?r;oKm~raMT#J?@;35_8fE!<|t3p2Vt0Fbxx0WqIzmok(ZwMkmkgl42YE5PiPufNZah} z_gR32ZS6TKTw@(9WoA#2f+#kfh8R_R*}y8qwWfDKOCo#X{;b@76l6}QV6da1ooq^% zrj80d?s9}%cH=tJ9Amte*lEA`NFGZD#U~Z=BGn4J!df`9#4?vj9=~5FaWVTbl=5TK zq+_C9d&@4nD(a%ghDohrh_*zoYhxO@&@&W!XtMM)2sX>P3~T~OSBJ&!%LZ*sx88ol zC>Pacj8Ai%y&LdZ@|2mVocJzDFb~7M2g;6aZ(~DF13u28B{bmIn(DIRl|PQqw~hEN zlQSx1yr{DfIL!O}aNHN-MlRLlU5)(iPyp~^+!x38#dMim@TJA6$QabvXB)}UL?^BE zepOYB^JO zk;o(P2Mr-RU4_Xc6;&ZJ7)1sLT?%Fa+KgvxGO`dMOoP*pkZb@X!pR0v3`fwsO}8Ht zah%^8SOug@x;rz%Sl)dDh`R#A=ifkRgKu@r`wqNqKf$L%>@-76 zLr74CP!~fXJ^%yw-`gPw`Z=ay{h+}BTx^h;R5cUU#sQkMoA|M~4+Bgkyj{)JN z#nT!^20-7<4E0O?Nw9-}!_W>T{pebR3^X#lRMS39003vwDcjiu|JLKt~I)dRc9XNS}~P(px!?Jk~8V+2w&2&GG6v@j!{1cJa* z*h>CtlQ(`0B_qjp8cSODs<>lDYVt1l-yaw|ysna2ZrTqVG^5hb%fY)LMfT~)TE6pw z{{kX>!O(<}2`DAZOqC*4Sn*bCVJw#hv^M0e9hiPz=B_UScfD>=lic6B3Bs@CLwq%C zsQPfnfW~+=4)vgENW!l@L51D?^mFq`^L5G@J)arLA{%h<98Ue?S^&Bc=@P5WJoT9@ z5+s#h`R(@r3kLJ~ePpWW4Zm8$7)bLWv$74Z6xpt;hBsRGdiBLR?DNt-7dy0t==v=|t~rI;*fy?IR6jDj-aQf_yfc z6_~e%$h*nG8RvIRhD~U%W8)A#JBL}4K>JjmFr$S?wf%6Ksn|p}dK^4}vmDys?L2h7 z!%ja`HYSGjqE_0QQEiu$TX2Td403u{I1&8DSj_-eK(UA6-~o_1ytU)pZkmgK^kW8R zV_nLsfKQm?;sy-TRHsOUePhu=^Rni@r&%9Dp4%PcX!zO>cEK0QpPK7pF8g02tAs~+ osJIsXz5NGZ+9tjI9B`sZZ_a4fnybi4D&z3xDuV`!rmyJ#0V0=z=>Px# diff --git a/locales/es/LC_MESSAGES/tools.sort.cli.po b/locales/es/LC_MESSAGES/tools.sort.cli.po index 064fb65b9e..1c6a138031 100644 --- a/locales/es/LC_MESSAGES/tools.sort.cli.po +++ b/locales/es/LC_MESSAGES/tools.sort.cli.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-09-07 12:34+0100\n" -"PO-Revision-Date: 2022-09-07 12:35+0100\n" +"POT-Creation-Date: 2022-09-13 12:49+0100\n" +"PO-Revision-Date: 2022-09-13 12:54+0100\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es_ES\n" @@ -23,199 +23,342 @@ msgid "This command lets you sort images using various methods." msgstr "" "Este comando le permite ordenar las imágenes utilizando varios métodos." -#: tools/sort/cli.py:23 +#: tools/sort/cli.py:20 +msgid "" +" Adjust the '-t' ('--threshold') parameter to control the strength of " +"grouping." +msgstr "" +" Ajuste el parámetro '-t' ('--threshold') para controlar la fuerza de la " +"agrupación." + +#: tools/sort/cli.py:21 +msgid "" +" Adjust the '-b' ('--bins') parameter to control the number of bins for " +"grouping. Each image is allocated to a bin by the percentage of color pixels " +"that appear in the image." +msgstr "" +" Ajuste el parámetro '-b' ('--bins') para controlar el número de " +"contenedores para agrupar. Cada imagen se asigna a un contenedor por el " +"porcentaje de píxeles de color que aparecen en la imagen." + +#: tools/sort/cli.py:24 +msgid "" +" Adjust the '-b' ('--bins') parameter to control the number of bins for " +"grouping. Each image is allocated to a bin by the number of degrees the face " +"is orientated from center." +msgstr "" +" Ajuste el parámetro '-b' ('--bins') para controlar el número de " +"contenedores para agrupar. Cada imagen se asigna a un contenedor por el " +"número de grados que la cara está orientada desde el centro." + +#: tools/sort/cli.py:27 +msgid "" +" Adjust the '-b' ('--bins') parameter to control the number of bins for " +"grouping. The minimum and maximum values are taken for the chosen sort " +"metric. The bins are then populated with the results from the group sorting." +msgstr "" +" Ajuste el parámetro '-b' ('--bins') para controlar el número de " +"contenedores para agrupar. Los valores mínimo y máximo se toman para la " +"métrica de clasificación elegida. Luego, los contenedores se llenan con los " +"resultados de la clasificación de grupos." + +#: tools/sort/cli.py:31 +msgid "faces by blurriness." +msgstr "rostros por desenfoque." + +#: tools/sort/cli.py:32 +msgid "faces by fft filtered blurriness." +msgstr "caras por borrosidad filtrada fft." + +#: tools/sort/cli.py:33 +msgid "" +"faces by the estimated distance of the alignments from an 'average' face. " +"This can be useful for eliminating misaligned faces. Sorts from most like an " +"average face to least like an average face." +msgstr "" +"caras por la distancia estimada de las alineaciones desde una cara " +"'promedio'. Esto puede ser útil para eliminar caras desalineadas. Ordena de " +"más parecido a un rostro promedio a menos parecido a un rostro promedio." + +#: tools/sort/cli.py:36 +msgid "" +"faces using VGG Face2 by face similarity. This uses a pairwise clustering " +"algorithm to check the distances between 512 features on every face in your " +"set and order them appropriately." +msgstr "" +"caras usando VGG Face2 por similitud de caras. Esto utiliza un algoritmo de " +"agrupamiento por pares para verificar las distancias entre 512 " +"características en cada cara de su conjunto y ordenarlas apropiadamente." + +#: tools/sort/cli.py:39 +msgid "faces by their landmarks." +msgstr "caras por sus puntos de referencia." + +#: tools/sort/cli.py:40 +msgid "Like 'face-cnn' but sorts by dissimilarity." +msgstr "Como 'face-cnn' pero ordenada por la similitud." + +#: tools/sort/cli.py:41 +msgid "faces by Yaw (rotation left to right)." +msgstr "caras por guiñada (rotación de izquierda a derecha)." + +#: tools/sort/cli.py:42 +msgid "faces by Pitch (rotation up and down)." +msgstr "caras por Pitch (rotación arriba y abajo)." + +#: tools/sort/cli.py:43 +msgid "faces by their color histogram." +msgstr "caras por su histograma de color." + +#: tools/sort/cli.py:44 +msgid "Like 'hist' but sorts by dissimilarity." +msgstr "Como 'hist' pero ordenada por la disimilitud." + +#: tools/sort/cli.py:45 +msgid "" +"images by the average intensity of the converted grayscale color channel." +msgstr "" +"imágenes por la intensidad media del canal de color en escala de grises " +"convertido." + +#: tools/sort/cli.py:46 +msgid "" +"images by their number of black pixels. Useful when faces are near borders " +"and a large part of the image is black." +msgstr "" +"imágenes por su número de píxeles negros. Útil cuando las caras están cerca " +"de los bordes y una gran parte de la imagen es negra." + +#: tools/sort/cli.py:48 +msgid "" +"images by the average intensity of the converted Y color channel. Bright " +"lighting and oversaturated images will be ranked first." +msgstr "" +"imágenes por la intensidad media del canal de color Y convertido. La " +"iluminación brillante y las imágenes sobresaturadas se clasificarán en " +"primer lugar." + +#: tools/sort/cli.py:50 +msgid "" +"images by the average intensity of the converted Cg color channel. Green " +"images will be ranked first and red images will be last." +msgstr "" +"imágenes por la intensidad media del canal de color Cg convertido. Las " +"imágenes verdes se clasificarán primero y las imágenes rojas serán las " +"últimas." + +#: tools/sort/cli.py:52 +msgid "" +"images by the average intensity of the converted Co color channel. Orange " +"images will be ranked first and blue images will be last." +msgstr "" +"imágenes por la intensidad media del canal de color Co convertido. Las " +"imágenes naranjas se clasificarán en primer lugar y las imágenes azules en " +"último lugar." + +#: tools/sort/cli.py:54 +msgid "" +"images by their size in the original frame. Faces further from the camera " +"and from lower resolution sources will be sorted first, whilst faces closer " +"to the camera and from higher resolution sources will be sorted last." +msgstr "" +"imágenes por su tamaño en el marco original. Las caras más alejadas de la " +"cámara y de fuentes de menor resolución se ordenarán primero, mientras que " +"las caras más cercanas a la cámara y de fuentes de mayor resolución se " +"ordenarán en último lugar." + +#: tools/sort/cli.py:57 +msgid " option is deprecated. Use 'yaw'" +msgstr " la opción está en desuso. Usa 'yaw'" + +#: tools/sort/cli.py:58 +msgid " option is deprecated. Use 'color-black'" +msgstr " la opción está en desuso. Usa 'color-black'" + +#: tools/sort/cli.py:80 msgid "Sort faces using a number of different techniques" msgstr "Clasificar los rostros mediante diferentes técnicas" -#: tools/sort/cli.py:33 tools/sort/cli.py:40 tools/sort/cli.py:47 +#: tools/sort/cli.py:90 tools/sort/cli.py:97 tools/sort/cli.py:108 +#: tools/sort/cli.py:146 msgid "data" msgstr "datos" -#: tools/sort/cli.py:34 +#: tools/sort/cli.py:91 msgid "Input directory of aligned faces." msgstr "Directorio de entrada de caras alineadas." -#: tools/sort/cli.py:41 -msgid "Output directory for sorted aligned faces." -msgstr "Directorio de salida para las caras alineadas ordenadas." +#: tools/sort/cli.py:98 +msgid "" +"Output directory for sorted aligned faces. If not provided and 'keep' is " +"selected then a new folder called 'sorted' will be created within the input " +"folder to house the output. If not provided and 'keep' is not selected then " +"the images will be sorted in-place, overwriting the original contents of the " +"'input_dir'" +msgstr "" +"Directorio de salida para caras alineadas ordenadas. Si no se proporciona y " +"se selecciona 'keep', se creará una nueva carpeta llamada 'sorted' dentro de " +"la carpeta de entrada para albergar la salida. Si no se proporciona y no se " +"selecciona 'keep', las imágenes se ordenarán en el lugar, sobrescribiendo el " +"contenido original de 'input_dir'" -#: tools/sort/cli.py:48 +#: tools/sort/cli.py:109 msgid "" "R|If selected then the input_dir should be a parent folder containing " "multiple folders of faces you wish to sort. The faces will be output to " -"separate sub-folders in the output_dir if 'rename' has been selected" +"separate sub-folders in the output_dir" msgstr "" "R|Si se selecciona, input_dir debe ser una carpeta principal que contenga " "varias carpetas de caras que desea ordenar. Las caras se enviarán a " -"subcarpetas separadas en output_dir si se ha seleccionado 'cambiar nombre'" +"subcarpetas separadas en output_dir" -#: tools/sort/cli.py:60 tools/sort/cli.py:109 +#: tools/sort/cli.py:118 msgid "sort settings" msgstr "ajustes de ordenación" -#: tools/sort/cli.py:62 -msgid "" -"R|Sort by method. Choose how images are sorted. \n" -"L|'blur': Sort faces by blurriness.\n" -"L|'blur-fft': Sort faces by fft filtered blurriness.\n" -"L|'distance' Sort faces by the estimated distance of the alignments from an " -"'average' face. This can be useful for eliminating misaligned faces.\n" -"L|'face': Use VGG Face to sort by face similarity. This uses a pairwise " -"clustering algorithm to check the distances between 512 features on every " -"face in your set and order them appropriately.\n" -"L|'face-cnn': Sort faces by their landmarks. You can adjust the threshold " -"with the '-t' (--ref_threshold) option.\n" -"L|'face-cnn-dissim': Like 'face-cnn' but sorts by dissimilarity.\n" -"L|'face-yaw': Sort faces by Yaw (rotation left to right).\n" -"L|'hist': Sort faces by their color histogram. You can adjust the threshold " -"with the '-t' (--ref_threshold) option.\n" -"L|'hist-dissim': Like 'hist' but sorts by dissimilarity.\n" -"L|'color-gray': Sort images by the average intensity of the converted " -"grayscale color channel.\n" -"L|'color-luma': Sort images by the average intensity of the converted Y " -"color channel. Bright lighting and oversaturated images will be ranked " -"first.\n" -"L|'color-green': Sort images by the average intensity of the converted Cg " -"color channel. Green images will be ranked first and red images will be " -"last.\n" -"L|'color-orange': Sort images by the average intensity of the converted Co " -"color channel. Orange images will be ranked first and blue images will be " -"last.\n" -"L|'size': Sort images by their size in the original frame. Faces closer to " -"the camera and from higher resolution sources will be sorted first, whilst " -"faces further from the camera and from lower resolution sources will be " -"sorted last.\n" -"L|'black-pixels': Sort images by their number of black pixels. Useful when " -"faces are near borders and a large part of the image is black.\n" -"Default: face" -msgstr "" -"R|Método de ordenación. Elige cómo se ordenan las imágenes. \n" -"L|'blur': Ordena las caras por desenfoque.\n" -"L|'blur-fft': Ordena las caras por fft filtrado desenfoque.\n" -"L|'distance' Ordene las caras por la distancia estimada de las alineaciones " -"desde una cara \"promedio\". Esto puede resultar útil para eliminar caras " -"desalineadas.\n" -"L|'face': Utiliza VGG Face para ordenar por similitud de caras. Esto utiliza " -"un algoritmo de agrupación por pares para comprobar las distancias entre 512 " -"características en cada cara en su conjunto y ordenarlos adecuadamente.\n" -"L|'face-cnn': Ordena las caras por sus puntos de referencia. Puedes ajustar " -"el umbral con la opción '-t' (--ref_threshold).\n" -"L|'face-cnn-dissim': Como 'face-cnn' pero ordena por disimilitud.\n" -"L|'face-yaw': Ordena las caras por Yaw (rotación de izquierda a derecha).\n" -"L|'hist': Ordena las caras por su histograma de color. Puedes ajustar el " -"umbral con la opción '-t' (--ref_threshold).\n" -"L|'hist-dissim': Como 'hist' pero ordena por disimilitud.\n" -"L|'color-gray': Ordena las imágenes por la intensidad media del canal de " -"color previa conversión a escala de grises convertido.\n" -"L|'color-luma': Ordena las imágenes por la intensidad media del canal de " -"color Y. Las imágenes muy brillantes y sobresaturadas se clasificarán " -"primero.\n" -"L|'color-green': Ordena las imágenes por la intensidad media del canal de " -"color Cg. Las imágenes verdes serán clasificadas primero y las rojas serán " -"las últimas.\n" -"L|'color-orange': Ordena las imágenes por la intensidad media del canal de " -"color Co. Las imágenes naranjas serán clasificadas primero y las azules " -"serán las últimas.\n" -"L|'size': Ordena las imágenes por su tamaño en el marco original. Los " -"rostros más cercanos a la cámara y de fuentes de mayor resolución se " -"ordenarán primero, mientras que los rostros más alejados de la cámara y de " -"fuentes de menor resolución se ordenarán en último lugar.\n" -"\vL|'black-pixels': Ordene las imágenes por su número de píxeles negros. " -"Útil cuando los rostros están cerca de los bordes y una gran parte de la " -"imagen es negra .\n" -"Por defecto: face" - -#: tools/sort/cli.py:98 tools/sort/cli.py:125 tools/sort/cli.py:137 -#: tools/sort/cli.py:148 +#: tools/sort/cli.py:120 +msgid "" +"R|Choose how images are sorted. Selecting a sort method gives the images a " +"new filename based on the order the image appears within the given method.\n" +"L|'none': Don't sort the images. When a 'group-by' method is selected, " +"selecting 'none' means that the files will be moved/copied into their " +"respective bins, but the files will keep their original filenames. Selecting " +"'none' for both 'sort-by' and 'group-by' will do nothing" +msgstr "" +"R|Elige cómo se ordenan las imágenes. Al seleccionar un método de " +"clasificación, las imágenes reciben un nuevo nombre de archivo basado en el " +"orden en que aparece la imagen dentro del método dado.\n" +"L|'none': No ordenar las imágenes. Cuando se selecciona un método de " +"'agrupar por', seleccionar 'none' significa que los archivos se moverán/" +"copiarán en sus contenedores respectivos, pero los archivos mantendrán sus " +"nombres de archivo originales. Seleccionar 'none' para 'sort-by' y 'group-" +"by' no hará nada" + +#: tools/sort/cli.py:133 tools/sort/cli.py:160 tools/sort/cli.py:189 +msgid "group settings" +msgstr "ajustes de grupo" + +#: tools/sort/cli.py:135 +msgid "" +"R|Selecting a group by method will move/copy files into numbered bins based " +"on the selected method.\n" +"L|'none': Don't bin the images. Folders will be sorted by the selected 'sort-" +"by' but will not be binned, instead they will be sorted into a single " +"folder. Selecting 'none' for both 'sort-by' and 'group-by' will do nothing" +msgstr "" +"R|Al seleccionar un grupo por método, los archivos se moverán/copiarán en " +"contenedores numerados según el método seleccionado.\n" +"L|'none': No agrupar las imágenes. Las carpetas se ordenarán por el 'sort-" +"by' seleccionado, pero no se agruparán, sino que se ordenarán en una sola " +"carpeta. Seleccionar 'none' para 'sort-by' y 'group-by' no hará nada" + +#: tools/sort/cli.py:147 +msgid "" +"Whether to keep the original files in their original location. Choosing a " +"'sort-by' method means that the files have to be renamed. Selecting 'keep' " +"means that the original files will be kept, and the renamed files will be " +"created in the specified output folder. Unselecting keep means that the " +"original files will be moved and renamed based on the selected sort/group " +"criteria." +msgstr "" +"Ya sea para mantener los archivos originales en su ubicación original. " +"Elegir un método de 'sort-by' significa que los archivos tienen que ser " +"renombrados. Seleccionar 'keep' significa que los archivos originales se " +"mantendrán y los archivos renombrados se crearán en la carpeta de salida " +"especificada. Deseleccionar 'keep' significa que los archivos originales se " +"moverán y cambiarán de nombre en función de los criterios de clasificación/" +"grupo seleccionados." + +#: tools/sort/cli.py:162 +msgid "" +"R|Float value. Minimum threshold to use for grouping comparison with 'face-" +"cnn' 'hist' and 'face' methods.\n" +"The lower the value the more discriminating the grouping is. Leaving -1.0 " +"will allow Faceswap to choose the default value.\n" +"L|For 'face-cnn' 7.2 should be enough, with 4 being very discriminating. \n" +"L|For 'hist' 0.3 should be enough, with 0.2 being very discriminating. \n" +"L|For 'face' between 0.1 (few bins) to 0.4 (more bins) should be about " +"right.\n" +"Be careful setting a value that's too extrene in a directory with many " +"images, as this could result in a lot of folders being created. Defaults: " +"face-cnn 7.2, hist 0.3, face 0.25" +msgstr "" +"R|Valor flotante. Umbral mínimo a usar para agrupar la comparación con los " +"métodos 'face-cnn' 'hist' y 'face'.\n" +"Cuanto más bajo es el valor, más discriminatoria es la agrupación. Dejar " +"-1.0 permitirá que Faceswap elija el valor predeterminado.\n" +"L|Para 'face-cnn' 7.2 debería ser suficiente, siendo 4 muy discriminatorio.\n" +"L|Para 'hist' 0.3 debería ser suficiente, siendo 0.2 muy discriminatorio.\n" +"L|Para 'face', entre 0,1 (pocos contenedores) y 0,4 (más contenedores) " +"debería ser correcto.\n" +"Tenga cuidado al establecer un valor que sea demasiado extremo en un " +"directorio con muchas imágenes, ya que esto podría resultar en la creación " +"de muchas carpetas. Valores predeterminados: face-cnn 7.2, hist 0.3, face " +"0.25" + +#: tools/sort/cli.py:179 msgid "output" msgstr "salida" -#: tools/sort/cli.py:99 -msgid "" -"Keeps the original files in the input directory. Be careful when using this " -"with rename grouping and no specified output directory as this would keep " -"the original and renamed files in the same directory." -msgstr "" -"Mantiene los archivos originales en el directorio de entrada. Tenga cuidado " -"al usar esto con la agrupación de renombre y sin especificar el directorio " -"de salida, ya que esto mantendría los archivos originales y renombrados en " -"el mismo directorio." - -#: tools/sort/cli.py:111 -msgid "" -"Float value. Minimum threshold to use for grouping comparison with 'face-" -"cnn' and 'hist' methods. The lower the value the more discriminating the " -"grouping is. Leaving -1.0 will allow the program set the default value " -"automatically. For face-cnn 7.2 should be enough, with 4 being very " -"discriminating. For hist 0.3 should be enough, with 0.2 being very " -"discriminating. Be careful setting a value that's too low in a directory " -"with many images, as this could result in a lot of directories being " -"created. Defaults: face-cnn 7.2, hist 0.3" -msgstr "" -"Valor flotante. Umbral mínimo a utilizar para la comparación de agrupaciones " -"con los métodos 'face-cnn' e 'hist'. Cuanto más bajo sea el valor, más " -"discriminante será la agrupación. Si se deja -1.0, el programa establecerá " -"el valor por defecto automáticamente. Para 'face-cnn' 7.2 debería ser " -"suficiente, siendo 4 muy discriminante. Para 'hist' 0.3 debería ser " -"suficiente, siendo 0,2 muy discriminante. Tenga cuidado al establecer un " -"valor demasiado bajo en un directorio con muchas imágenes, ya que esto " -"podría resultar en la creación de muchos directorios. Por defecto: 'face-" -"cnn' = 7.2, 'hist' = 0.3" - -#: tools/sort/cli.py:126 -msgid "" -"R|Default: rename.\n" -"L|'folders': files are sorted using the -s/--sort-by method, then they are " -"organized into folders using the -g/--group-by grouping method.\n" -"L|'rename': files are sorted using the -s/--sort-by then they are renamed." -msgstr "" -"R|Por defecto: renombrar.\n" -"L|'folders': los archivos se ordenan utilizando el método -s/--sort-by, y " -"luego se organizan en carpetas utilizando el método de agrupación -g/--group-" -"by.\n" -"L|'rename': los archivos se ordenan utilizando el método -s/--sort-by y " -"luego se renombran." - -#: tools/sort/cli.py:139 -msgid "" -"Group by method. When -fp/--final-processing by folders choose the how the " -"images are grouped after sorting. Default: hist" -msgstr "" -"Método de agrupamiento. Elija la forma de agrupar las imágenes, en el caso " -"de hacerlo por carpetas, después de la clasificación. Por defecto: hist" - -#: tools/sort/cli.py:150 +#: tools/sort/cli.py:180 +msgid "" +"Deprecated and no longer used. The final processing will be dictated by the " +"sort/group by methods and whether 'keep_original' is selected." +msgstr "" +"En desuso y ya no se usa. El procesamiento final será dictado por los " +"métodos de ordenación/agrupación y si se selecciona 'keepl'." + +#: tools/sort/cli.py:191 #, python-format msgid "" -"Integer value. Number of folders that will be used to group by blur, face-" -"yaw and black-pixels. For blur folder 0 will be the least blurry, while the " -"last folder will be the blurriest. For face-yaw the number of bins is by how " -"much 180 degrees is divided. So if you use 18, then each folder will be a 10 " -"degree increment. Folder 0 will contain faces looking the most to the left " -"whereas the last folder will contain the faces looking the most to the " -"right. If the number of images doesn't divide evenly into the number of " -"bins, the remaining images get put in the last bin. For black-pixels it " -"represents the divider of the percentage of black pixels. For 10, first " -"folder will have the faces with 0 to 10%% black pixels, second 11 to 20%%, " -"etc. Default value: 5" -msgstr "" -"Valor entero. Número de carpetas que se utilizarán al agrupar por 'blur' y " -"'face-yaw'. Para 'blur' la carpeta 0 será la menos borrosa, mientras que la " -"última carpeta será la más borrosa. Para 'face-yaw' el número de carpetas es " -"por cuanto se dividen los 180 grados. Así que si usas 18, entonces cada " -"carpeta será un incremento de 10 grados. La carpeta 0 contendrá las caras " -"que miren más a la izquierda, mientras que la última carpeta contendrá las " -"caras que miren más a la derecha. Si el número de imágenes no se divide " -"uniformemente en el número de carpetas, las imágenes restantes se colocan en " -"la última carpeta. Para píxeles negros, representa el divisor del porcentaje " -"de píxeles negros. Para 10, la primera carpeta tendrá las caras con 0 a 10%% " -"de píxeles negros, la segunda de 11 a 20%%, etc. Valor por defecto: 5" - -#: tools/sort/cli.py:164 tools/sort/cli.py:174 +"R|Integer value. Used to control the number of bins created for grouping by: " +"any 'blur' methods, 'color' methods or 'face metric' methods ('distance', " +"'size') and 'orientation; methods ('yaw', 'pitch'). For any other grouping " +"methods see the '-t' ('--threshold') option.\n" +"L|For 'face metric' methods the bins are filled, according the the " +"distribution of faces between the minimum and maximum chosen metric.\n" +"L|For 'color' methods the number of bins represents the divider of the " +"percentage of colored pixels. Eg. For a bin number of '5': The first folder " +"will have the faces with 0%% to 20%% colored pixels, second 21%% to 40%%, " +"etc. Any empty bins will be deleted, so you may end up with fewer bins than " +"selected.\n" +"L|For 'blur' methods folder 0 will be the least blurry, while the last " +"folder will be the blurriest.\n" +"L|For 'orientation' methods the number of bins is dictated by how much 180 " +"degrees is divided. Eg. If 18 is selected, then each folder will be a 10 " +"degree increment. Folder 0 will contain faces looking the most to the left/" +"down whereas the last folder will contain the faces looking the most to the " +"right/up. NB: Some bins may be empty if faces do not fit the criteria.\n" +"Default value: 5" +msgstr "" +"R|Valor entero. Se utiliza para controlar el número de contenedores creados " +"para agrupar por: cualquier método de 'blur', método de 'color' o método de " +"'face metric' ('distance', 'size') y 'orientación; métodos ('yaw', 'pitch'). " +"Para cualquier otro método de agrupación, consulte la opción '-t' ('--" +"threshold').\n" +"L|Para los métodos de 'face metric', los contenedores se llenan de acuerdo " +"con la distribución de caras entre la métrica mínima y máxima elegida.\n" +"L|Para los métodos de 'color', el número de contenedores representa el " +"divisor del porcentaje de píxeles coloreados. P.ej. Para un número de " +"contenedor de '5': la primera carpeta tendrá las caras con 0%% a 20%% " +"píxeles de color, la segunda 21%% a 40%%, etc. Se eliminarán todos los " +"contenedores vacíos, por lo que puede terminar con menos contenedores que " +"los seleccionados.\n" +"L|Para los métodos 'blur', la carpeta 0 será la menos borrosa, mientras que " +"la última carpeta será la más borrosa.\n" +"L|Para los métodos de 'orientation', el número de contenedores está dictado " +"por cuánto se dividen 180 grados. P.ej. Si se selecciona 18, cada carpeta " +"tendrá un incremento de 10 grados. La carpeta 0 contendrá las caras que " +"miran más hacia la izquierda/abajo, mientras que la última carpeta contendrá " +"las caras que miran más hacia la derecha/arriba. NB: algunos contenedores " +"pueden estar vacíos si las caras no se ajustan a los criterios.\n" +"Valor predeterminado: 5" + +#: tools/sort/cli.py:213 tools/sort/cli.py:223 msgid "settings" msgstr "ajustes" -#: tools/sort/cli.py:166 +#: tools/sort/cli.py:215 msgid "" "Logs file renaming changes if grouping by renaming, or it logs the file " "copying/movement if grouping by folders. If no log file is specified with " @@ -227,7 +370,7 @@ msgstr "" "se especifica ningún archivo de registro con '--log-file', se creará un " "archivo 'sort_log.json' en el directorio de entrada." -#: tools/sort/cli.py:177 +#: tools/sort/cli.py:226 msgid "" "Specify a log file to use for saving the renaming or grouping information. " "If specified extension isn't 'json' or 'yaml', then json will be used as the " @@ -237,3 +380,137 @@ msgstr "" "información de renombrado o agrupación. Si la extensión especificada no es " "'json' o 'yaml', se utilizará json como serializador, con el nombre de " "archivo suministrado. Por defecto: sort_log.json" + +#~ msgid "Output directory for sorted aligned faces." +#~ msgstr "Directorio de salida para las caras alineadas ordenadas." + +#~ msgid "" +#~ "R|Sort by method. Choose how images are sorted. \n" +#~ "L|'blur': Sort faces by blurriness.\n" +#~ "L|'blur-fft': Sort faces by fft filtered blurriness.\n" +#~ "L|'distance' Sort faces by the estimated distance of the alignments from " +#~ "an 'average' face. This can be useful for eliminating misaligned faces.\n" +#~ "L|'face': Use VGG Face to sort by face similarity. This uses a pairwise " +#~ "clustering algorithm to check the distances between 512 features on every " +#~ "face in your set and order them appropriately.\n" +#~ "L|'face-cnn': Sort faces by their landmarks. You can adjust the threshold " +#~ "with the '-t' (--ref_threshold) option.\n" +#~ "L|'face-cnn-dissim': Like 'face-cnn' but sorts by dissimilarity.\n" +#~ "L|'face-yaw': Sort faces by Yaw (rotation left to right).\n" +#~ "L|'hist': Sort faces by their color histogram. You can adjust the " +#~ "threshold with the '-t' (--ref_threshold) option.\n" +#~ "L|'hist-dissim': Like 'hist' but sorts by dissimilarity.\n" +#~ "L|'color-gray': Sort images by the average intensity of the converted " +#~ "grayscale color channel.\n" +#~ "L|'color-luma': Sort images by the average intensity of the converted Y " +#~ "color channel. Bright lighting and oversaturated images will be ranked " +#~ "first.\n" +#~ "L|'color-green': Sort images by the average intensity of the converted Cg " +#~ "color channel. Green images will be ranked first and red images will be " +#~ "last.\n" +#~ "L|'color-orange': Sort images by the average intensity of the converted " +#~ "Co color channel. Orange images will be ranked first and blue images will " +#~ "be last.\n" +#~ "L|'size': Sort images by their size in the original frame. Faces closer " +#~ "to the camera and from higher resolution sources will be sorted first, " +#~ "whilst faces further from the camera and from lower resolution sources " +#~ "will be sorted last.\n" +#~ "L|'black-pixels': Sort images by their number of black pixels. Useful " +#~ "when faces are near borders and a large part of the image is black.\n" +#~ "Default: face" +#~ msgstr "" +#~ "R|Método de ordenación. Elige cómo se ordenan las imágenes. \n" +#~ "L|'blur': Ordena las caras por desenfoque.\n" +#~ "L|'blur-fft': Ordena las caras por fft filtrado desenfoque.\n" +#~ "L|'distance' Ordene las caras por la distancia estimada de las " +#~ "alineaciones desde una cara \"promedio\". Esto puede resultar útil para " +#~ "eliminar caras desalineadas.\n" +#~ "L|'face': Utiliza VGG Face para ordenar por similitud de caras. Esto " +#~ "utiliza un algoritmo de agrupación por pares para comprobar las " +#~ "distancias entre 512 características en cada cara en su conjunto y " +#~ "ordenarlos adecuadamente.\n" +#~ "L|'face-cnn': Ordena las caras por sus puntos de referencia. Puedes " +#~ "ajustar el umbral con la opción '-t' (--ref_threshold).\n" +#~ "L|'face-cnn-dissim': Como 'face-cnn' pero ordena por disimilitud.\n" +#~ "L|'face-yaw': Ordena las caras por Yaw (rotación de izquierda a " +#~ "derecha).\n" +#~ "L|'hist': Ordena las caras por su histograma de color. Puedes ajustar el " +#~ "umbral con la opción '-t' (--ref_threshold).\n" +#~ "L|'hist-dissim': Como 'hist' pero ordena por disimilitud.\n" +#~ "L|'color-gray': Ordena las imágenes por la intensidad media del canal de " +#~ "color previa conversión a escala de grises convertido.\n" +#~ "L|'color-luma': Ordena las imágenes por la intensidad media del canal de " +#~ "color Y. Las imágenes muy brillantes y sobresaturadas se clasificarán " +#~ "primero.\n" +#~ "L|'color-green': Ordena las imágenes por la intensidad media del canal de " +#~ "color Cg. Las imágenes verdes serán clasificadas primero y las rojas " +#~ "serán las últimas.\n" +#~ "L|'color-orange': Ordena las imágenes por la intensidad media del canal " +#~ "de color Co. Las imágenes naranjas serán clasificadas primero y las " +#~ "azules serán las últimas.\n" +#~ "L|'size': Ordena las imágenes por su tamaño en el marco original. Los " +#~ "rostros más cercanos a la cámara y de fuentes de mayor resolución se " +#~ "ordenarán primero, mientras que los rostros más alejados de la cámara y " +#~ "de fuentes de menor resolución se ordenarán en último lugar.\n" +#~ "\vL|'black-pixels': Ordene las imágenes por su número de píxeles negros. " +#~ "Útil cuando los rostros están cerca de los bordes y una gran parte de la " +#~ "imagen es negra .\n" +#~ "Por defecto: face" + +#~ msgid "" +#~ "Keeps the original files in the input directory. Be careful when using " +#~ "this with rename grouping and no specified output directory as this would " +#~ "keep the original and renamed files in the same directory." +#~ msgstr "" +#~ "Mantiene los archivos originales en el directorio de entrada. Tenga " +#~ "cuidado al usar esto con la agrupación de renombre y sin especificar el " +#~ "directorio de salida, ya que esto mantendría los archivos originales y " +#~ "renombrados en el mismo directorio." + +#~ msgid "" +#~ "R|Default: rename.\n" +#~ "L|'folders': files are sorted using the -s/--sort-by method, then they " +#~ "are organized into folders using the -g/--group-by grouping method.\n" +#~ "L|'rename': files are sorted using the -s/--sort-by then they are renamed." +#~ msgstr "" +#~ "R|Por defecto: renombrar.\n" +#~ "L|'folders': los archivos se ordenan utilizando el método -s/--sort-by, y " +#~ "luego se organizan en carpetas utilizando el método de agrupación -g/--" +#~ "group-by.\n" +#~ "L|'rename': los archivos se ordenan utilizando el método -s/--sort-by y " +#~ "luego se renombran." + +#~ msgid "" +#~ "Group by method. When -fp/--final-processing by folders choose the how " +#~ "the images are grouped after sorting. Default: hist" +#~ msgstr "" +#~ "Método de agrupamiento. Elija la forma de agrupar las imágenes, en el " +#~ "caso de hacerlo por carpetas, después de la clasificación. Por defecto: " +#~ "hist" + +#, python-format +#~ msgid "" +#~ "Integer value. Number of folders that will be used to group by blur, face-" +#~ "yaw and black-pixels. For blur folder 0 will be the least blurry, while " +#~ "the last folder will be the blurriest. For face-yaw the number of bins is " +#~ "by how much 180 degrees is divided. So if you use 18, then each folder " +#~ "will be a 10 degree increment. Folder 0 will contain faces looking the " +#~ "most to the left whereas the last folder will contain the faces looking " +#~ "the most to the right. If the number of images doesn't divide evenly into " +#~ "the number of bins, the remaining images get put in the last bin. For " +#~ "black-pixels it represents the divider of the percentage of black pixels. " +#~ "For 10, first folder will have the faces with 0 to 10%% black pixels, " +#~ "second 11 to 20%%, etc. Default value: 5" +#~ msgstr "" +#~ "Valor entero. Número de carpetas que se utilizarán al agrupar por 'blur' " +#~ "y 'face-yaw'. Para 'blur' la carpeta 0 será la menos borrosa, mientras " +#~ "que la última carpeta será la más borrosa. Para 'face-yaw' el número de " +#~ "carpetas es por cuanto se dividen los 180 grados. Así que si usas 18, " +#~ "entonces cada carpeta será un incremento de 10 grados. La carpeta 0 " +#~ "contendrá las caras que miren más a la izquierda, mientras que la última " +#~ "carpeta contendrá las caras que miren más a la derecha. Si el número de " +#~ "imágenes no se divide uniformemente en el número de carpetas, las " +#~ "imágenes restantes se colocan en la última carpeta. Para píxeles negros, " +#~ "representa el divisor del porcentaje de píxeles negros. Para 10, la " +#~ "primera carpeta tendrá las caras con 0 a 10%% de píxeles negros, la " +#~ "segunda de 11 a 20%%, etc. Valor por defecto: 5" diff --git a/locales/tools.pot b/locales/tools.pot deleted file mode 100644 index 78cf388e1e..0000000000 --- a/locales/tools.pot +++ /dev/null @@ -1,21 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR ORGANIZATION -# FIRST AUTHOR , YEAR. -# -msgid "" -msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-02-18 23:49-0000\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=cp1252\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" - - -#: tools.py:46 -msgid "Please backup your data and/or test the tool you want to use with a smaller data set to make sure you understand how it works." -msgstr "" - diff --git a/locales/tools.sort.cli.pot b/locales/tools.sort.cli.pot index 099400fdc4..46666d0119 100644 --- a/locales/tools.sort.cli.pot +++ b/locales/tools.sort.cli.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-09-07 12:34+0100\n" +"POT-Creation-Date: 2022-09-13 12:49+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -21,129 +21,243 @@ msgstr "" msgid "This command lets you sort images using various methods." msgstr "" -#: tools/sort/cli.py:23 +#: tools/sort/cli.py:20 +msgid "" +" Adjust the '-t' ('--threshold') parameter to control the strength of " +"grouping." +msgstr "" + +#: tools/sort/cli.py:21 +msgid "" +" Adjust the '-b' ('--bins') parameter to control the number of bins for " +"grouping. Each image is allocated to a bin by the percentage of color pixels " +"that appear in the image." +msgstr "" + +#: tools/sort/cli.py:24 +msgid "" +" Adjust the '-b' ('--bins') parameter to control the number of bins for " +"grouping. Each image is allocated to a bin by the number of degrees the face " +"is orientated from center." +msgstr "" + +#: tools/sort/cli.py:27 +msgid "" +" Adjust the '-b' ('--bins') parameter to control the number of bins for " +"grouping. The minimum and maximum values are taken for the chosen sort " +"metric. The bins are then populated with the results from the group sorting." +msgstr "" + +#: tools/sort/cli.py:31 +msgid "faces by blurriness." +msgstr "" + +#: tools/sort/cli.py:32 +msgid "faces by fft filtered blurriness." +msgstr "" + +#: tools/sort/cli.py:33 +msgid "" +"faces by the estimated distance of the alignments from an 'average' face. " +"This can be useful for eliminating misaligned faces. Sorts from most like an " +"average face to least like an average face." +msgstr "" + +#: tools/sort/cli.py:36 +msgid "" +"faces using VGG Face2 by face similarity. This uses a pairwise clustering " +"algorithm to check the distances between 512 features on every face in your " +"set and order them appropriately." +msgstr "" + +#: tools/sort/cli.py:39 +msgid "faces by their landmarks." +msgstr "" + +#: tools/sort/cli.py:40 +msgid "Like 'face-cnn' but sorts by dissimilarity." +msgstr "" + +#: tools/sort/cli.py:41 +msgid "faces by Yaw (rotation left to right)." +msgstr "" + +#: tools/sort/cli.py:42 +msgid "faces by Pitch (rotation up and down)." +msgstr "" + +#: tools/sort/cli.py:43 +msgid "faces by their color histogram." +msgstr "" + +#: tools/sort/cli.py:44 +msgid "Like 'hist' but sorts by dissimilarity." +msgstr "" + +#: tools/sort/cli.py:45 +msgid "" +"images by the average intensity of the converted grayscale color channel." +msgstr "" + +#: tools/sort/cli.py:46 +msgid "" +"images by their number of black pixels. Useful when faces are near borders " +"and a large part of the image is black." +msgstr "" + +#: tools/sort/cli.py:48 +msgid "" +"images by the average intensity of the converted Y color channel. Bright " +"lighting and oversaturated images will be ranked first." +msgstr "" + +#: tools/sort/cli.py:50 +msgid "" +"images by the average intensity of the converted Cg color channel. Green " +"images will be ranked first and red images will be last." +msgstr "" + +#: tools/sort/cli.py:52 +msgid "" +"images by the average intensity of the converted Co color channel. Orange " +"images will be ranked first and blue images will be last." +msgstr "" + +#: tools/sort/cli.py:54 +msgid "" +"images by their size in the original frame. Faces further from the camera " +"and from lower resolution sources will be sorted first, whilst faces closer " +"to the camera and from higher resolution sources will be sorted last." +msgstr "" + +#: tools/sort/cli.py:57 +msgid " option is deprecated. Use 'yaw'" +msgstr "" + +#: tools/sort/cli.py:58 +msgid " option is deprecated. Use 'color-black'" +msgstr "" + +#: tools/sort/cli.py:80 msgid "Sort faces using a number of different techniques" msgstr "" -#: tools/sort/cli.py:33 tools/sort/cli.py:40 tools/sort/cli.py:47 +#: tools/sort/cli.py:90 tools/sort/cli.py:97 tools/sort/cli.py:108 +#: tools/sort/cli.py:146 msgid "data" msgstr "" -#: tools/sort/cli.py:34 +#: tools/sort/cli.py:91 msgid "Input directory of aligned faces." msgstr "" -#: tools/sort/cli.py:41 -msgid "Output directory for sorted aligned faces." +#: tools/sort/cli.py:98 +msgid "" +"Output directory for sorted aligned faces. If not provided and 'keep' is " +"selected then a new folder called 'sorted' will be created within the input " +"folder to house the output. If not provided and 'keep' is not selected then " +"the images will be sorted in-place, overwriting the original contents of the " +"'input_dir'" msgstr "" -#: tools/sort/cli.py:48 +#: tools/sort/cli.py:109 msgid "" "R|If selected then the input_dir should be a parent folder containing " "multiple folders of faces you wish to sort. The faces will be output to " -"separate sub-folders in the output_dir if 'rename' has been selected" +"separate sub-folders in the output_dir" msgstr "" -#: tools/sort/cli.py:60 tools/sort/cli.py:109 +#: tools/sort/cli.py:118 msgid "sort settings" msgstr "" -#: tools/sort/cli.py:62 -msgid "" -"R|Sort by method. Choose how images are sorted. \n" -"L|'blur': Sort faces by blurriness.\n" -"L|'blur-fft': Sort faces by fft filtered blurriness.\n" -"L|'distance' Sort faces by the estimated distance of the alignments from an " -"'average' face. This can be useful for eliminating misaligned faces.\n" -"L|'face': Use VGG Face to sort by face similarity. This uses a pairwise " -"clustering algorithm to check the distances between 512 features on every " -"face in your set and order them appropriately.\n" -"L|'face-cnn': Sort faces by their landmarks. You can adjust the threshold " -"with the '-t' (--ref_threshold) option.\n" -"L|'face-cnn-dissim': Like 'face-cnn' but sorts by dissimilarity.\n" -"L|'face-yaw': Sort faces by Yaw (rotation left to right).\n" -"L|'hist': Sort faces by their color histogram. You can adjust the threshold " -"with the '-t' (--ref_threshold) option.\n" -"L|'hist-dissim': Like 'hist' but sorts by dissimilarity.\n" -"L|'color-gray': Sort images by the average intensity of the converted " -"grayscale color channel.\n" -"L|'color-luma': Sort images by the average intensity of the converted Y " -"color channel. Bright lighting and oversaturated images will be ranked " -"first.\n" -"L|'color-green': Sort images by the average intensity of the converted Cg " -"color channel. Green images will be ranked first and red images will be " -"last.\n" -"L|'color-orange': Sort images by the average intensity of the converted Co " -"color channel. Orange images will be ranked first and blue images will be " -"last.\n" -"L|'size': Sort images by their size in the original frame. Faces closer to " -"the camera and from higher resolution sources will be sorted first, whilst " -"faces further from the camera and from lower resolution sources will be " -"sorted last.\n" -"L|'black-pixels': Sort images by their number of black pixels. Useful when " -"faces are near borders and a large part of the image is black.\n" -"Default: face" -msgstr "" - -#: tools/sort/cli.py:98 tools/sort/cli.py:125 tools/sort/cli.py:137 -#: tools/sort/cli.py:148 -msgid "output" +#: tools/sort/cli.py:120 +msgid "" +"R|Choose how images are sorted. Selecting a sort method gives the images a " +"new filename based on the order the image appears within the given method.\n" +"L|'none': Don't sort the images. When a 'group-by' method is selected, " +"selecting 'none' means that the files will be moved/copied into their " +"respective bins, but the files will keep their original filenames. Selecting " +"'none' for both 'sort-by' and 'group-by' will do nothing" +msgstr "" + +#: tools/sort/cli.py:133 tools/sort/cli.py:160 tools/sort/cli.py:189 +msgid "group settings" msgstr "" -#: tools/sort/cli.py:99 +#: tools/sort/cli.py:135 msgid "" -"Keeps the original files in the input directory. Be careful when using this " -"with rename grouping and no specified output directory as this would keep " -"the original and renamed files in the same directory." +"R|Selecting a group by method will move/copy files into numbered bins based " +"on the selected method.\n" +"L|'none': Don't bin the images. Folders will be sorted by the selected 'sort-" +"by' but will not be binned, instead they will be sorted into a single " +"folder. Selecting 'none' for both 'sort-by' and 'group-by' will do nothing" msgstr "" -#: tools/sort/cli.py:111 +#: tools/sort/cli.py:147 msgid "" -"Float value. Minimum threshold to use for grouping comparison with 'face-" -"cnn' and 'hist' methods. The lower the value the more discriminating the " -"grouping is. Leaving -1.0 will allow the program set the default value " -"automatically. For face-cnn 7.2 should be enough, with 4 being very " -"discriminating. For hist 0.3 should be enough, with 0.2 being very " -"discriminating. Be careful setting a value that's too low in a directory " -"with many images, as this could result in a lot of directories being " -"created. Defaults: face-cnn 7.2, hist 0.3" +"Whether to keep the original files in their original location. Choosing a " +"'sort-by' method means that the files have to be renamed. Selecting 'keep' " +"means that the original files will be kept, and the renamed files will be " +"created in the specified output folder. Unselecting keep means that the " +"original files will be moved and renamed based on the selected sort/group " +"criteria." msgstr "" -#: tools/sort/cli.py:126 +#: tools/sort/cli.py:162 msgid "" -"R|Default: rename.\n" -"L|'folders': files are sorted using the -s/--sort-by method, then they are " -"organized into folders using the -g/--group-by grouping method.\n" -"L|'rename': files are sorted using the -s/--sort-by then they are renamed." +"R|Float value. Minimum threshold to use for grouping comparison with 'face-" +"cnn' 'hist' and 'face' methods.\n" +"The lower the value the more discriminating the grouping is. Leaving -1.0 " +"will allow Faceswap to choose the default value.\n" +"L|For 'face-cnn' 7.2 should be enough, with 4 being very discriminating. \n" +"L|For 'hist' 0.3 should be enough, with 0.2 being very discriminating. \n" +"L|For 'face' between 0.1 (few bins) to 0.4 (more bins) should be about " +"right.\n" +"Be careful setting a value that's too extrene in a directory with many " +"images, as this could result in a lot of folders being created. Defaults: " +"face-cnn 7.2, hist 0.3, face 0.25" +msgstr "" + +#: tools/sort/cli.py:179 +msgid "output" msgstr "" -#: tools/sort/cli.py:139 +#: tools/sort/cli.py:180 msgid "" -"Group by method. When -fp/--final-processing by folders choose the how the " -"images are grouped after sorting. Default: hist" +"Deprecated and no longer used. The final processing will be dictated by the " +"sort/group by methods and whether 'keep_original' is selected." msgstr "" -#: tools/sort/cli.py:150 +#: tools/sort/cli.py:191 #, python-format msgid "" -"Integer value. Number of folders that will be used to group by blur, face-" -"yaw and black-pixels. For blur folder 0 will be the least blurry, while the " -"last folder will be the blurriest. For face-yaw the number of bins is by how " -"much 180 degrees is divided. So if you use 18, then each folder will be a 10 " -"degree increment. Folder 0 will contain faces looking the most to the left " -"whereas the last folder will contain the faces looking the most to the " -"right. If the number of images doesn't divide evenly into the number of " -"bins, the remaining images get put in the last bin. For black-pixels it " -"represents the divider of the percentage of black pixels. For 10, first " -"folder will have the faces with 0 to 10%% black pixels, second 11 to 20%%, " -"etc. Default value: 5" +"R|Integer value. Used to control the number of bins created for grouping by: " +"any 'blur' methods, 'color' methods or 'face metric' methods ('distance', " +"'size') and 'orientation; methods ('yaw', 'pitch'). For any other grouping " +"methods see the '-t' ('--threshold') option.\n" +"L|For 'face metric' methods the bins are filled, according the the " +"distribution of faces between the minimum and maximum chosen metric.\n" +"L|For 'color' methods the number of bins represents the divider of the " +"percentage of colored pixels. Eg. For a bin number of '5': The first folder " +"will have the faces with 0%% to 20%% colored pixels, second 21%% to 40%%, " +"etc. Any empty bins will be deleted, so you may end up with fewer bins than " +"selected.\n" +"L|For 'blur' methods folder 0 will be the least blurry, while the last " +"folder will be the blurriest.\n" +"L|For 'orientation' methods the number of bins is dictated by how much 180 " +"degrees is divided. Eg. If 18 is selected, then each folder will be a 10 " +"degree increment. Folder 0 will contain faces looking the most to the left/" +"down whereas the last folder will contain the faces looking the most to the " +"right/up. NB: Some bins may be empty if faces do not fit the criteria.\n" +"Default value: 5" msgstr "" -#: tools/sort/cli.py:164 tools/sort/cli.py:174 +#: tools/sort/cli.py:213 tools/sort/cli.py:223 msgid "settings" msgstr "" -#: tools/sort/cli.py:166 +#: tools/sort/cli.py:215 msgid "" "Logs file renaming changes if grouping by renaming, or it logs the file " "copying/movement if grouping by folders. If no log file is specified with " @@ -151,7 +265,7 @@ msgid "" "directory." msgstr "" -#: tools/sort/cli.py:177 +#: tools/sort/cli.py:226 msgid "" "Specify a log file to use for saving the renaming or grouping information. " "If specified extension isn't 'json' or 'yaml', then json will be used as the " diff --git a/plugins/extract/recognition/vgg_face2_keras.py b/plugins/extract/recognition/vgg_face2_keras.py index 10ca5d7ce9..2d6acceaa6 100644 --- a/plugins/extract/recognition/vgg_face2_keras.py +++ b/plugins/extract/recognition/vgg_face2_keras.py @@ -2,10 +2,13 @@ """ VGG_Face2 inference and sorting """ import logging -import psutil +import sys + +from typing import Dict, Generator, List, Tuple, Optional import cv2 import numpy as np +import psutil from fastcluster import linkage, linkage_vector from lib.model.layers import L2_normalize @@ -13,6 +16,12 @@ from lib.utils import FaceswapError from plugins.extract._base import Extractor + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -41,8 +50,11 @@ def __init__(self, *args, **kwargs): # pylint:disable=unused-argument self._plugin_type = "recognition" self.name = "VGG_Face2" self.input_size = 224 + self._bins = {} # Average image provided in https://github.com/ox-vgg/vgg_face2 self._average_img = np.array([91.4953, 103.8827, 131.0912]) + self._iterator = self._get_bin() + logger.debug("Initialized %s", self.__class__.__name__) # <<< GET MODEL >>> # @@ -56,6 +68,26 @@ def init_model(self): exclude_gpus=self._exclude_gpus) self.model.load_model() + @classmethod + def _get_bin(cls) -> Generator[int, None, None]: + """ Generator that yields incremented integers + + Yields + ------ + int + An integer 1 larger than the last integer returned + """ + + i = 0 + while True: + yield i + i += 1 + + @property + def next_bin(self) -> int: + """ int: The next available bin id in the iterator """ + return next(self._iterator) + def predict(self, batch): """ Return encodings for given image from vgg_face2. @@ -115,45 +147,48 @@ def find_cosine_similiarity(source_face, test_face): var_c = np.sum(np.multiply(test_face, test_face)) return 1 - (var_a / (np.sqrt(var_b) * np.sqrt(var_c))) - def sorted_similarity(self, predictions, method="ward"): - """ Sort a matrix of predictions by similarity. - Transforms a distance matrix into a sorted distance matrix according to the order implied - by the hierarchical tree (dendrogram). +class Cluster(): # pylint: disable=too-few-public-methods + """ Cluster the outputs from a VGG-Face 2 Model + + Parameters + ---------- + predictions: numpy.ndarray + A stacked matrix of vgg_face2 predictions of the shape (`N`, `D`) where `N` is the + number of observations and `D` are the number of dimensions. NB: The given + :attr:`predictions` will be overwritten to save memory. If you still require the + original values you should take a copy prior to running this method + method: ['single','centroid','median','ward'] + The clustering method to use. + threshold: float, optional + The threshold to start creating bins for. Set to ``None`` to disable binning + """ - Parameters - ---------- - predictions: numpy.ndarray - A stacked matrix of vgg_face2 predictions of the shape (`N`, `D`) where `N` is the - number of observations and `D` are the number of dimensions. NB: The given - :attr:`predictions` will be overwritten to save memory. If you still require the - original values you should take a copy prior to running this method - method: ['single','centroid','median','ward'] - The clustering method to use. + def __init__(self, + predictions: np.ndarray, + method: Literal["single", "centroid", "median", "ward"], + threshold: Optional[float] = None) -> None: + logger.debug("Initializing: %s (predictions: %s, method: %s, threshold: %s)", + self.__class__.__name__, predictions.shape, method, threshold) + self._num_predictions = predictions.shape[0] - Returns - ------- - list: - List of indices with the order implied by the hierarchical tree - """ - logger.info("Sorting face distances. Depending on your dataset this may take some time...") - num_predictions, dims = predictions.shape + self._should_output_bins = threshold is not None + self._threshold = 0.0 if threshold is None else threshold + self._bins: Dict[int, int] = {} + self._iterator = self._integer_iterator() - kwargs = dict(method=method) - if self._use_vector_linkage(num_predictions, dims): - func = linkage_vector - else: - kwargs["preserve_input"] = False - func = linkage + self._result_linkage = self._do_linkage(predictions, method) + logger.debug("Initialized %s", self.__class__.__name__) - result_linkage = func(predictions, **kwargs) - result_order = self._seriation(result_linkage, - num_predictions, - num_predictions + num_predictions - 2) - return result_order + @classmethod + def _integer_iterator(cls) -> Generator[int, None, None]: + """ Iterator that just yields consecutive integers """ + i = -1 + while True: + i += 1 + yield i - @staticmethod - def _use_vector_linkage(item_count, dims): + def _use_vector_linkage(self, dims: int) -> bool: """ Calculate the RAM that will be required to sort these images and select the appropriate clustering method. @@ -167,8 +202,6 @@ def _use_vector_linkage(item_count, dims): Parameters ---------- - item_count: int - The number of images that are to be processed dims: int The number of dimensions in the vgg_face output @@ -181,13 +214,13 @@ def _use_vector_linkage(item_count, dims): divider = 1024 * 1024 # bytes to MB free_ram = psutil.virtual_memory().available / divider - linkage_required = (((item_count ** 2) * np_float) / 1.8) / divider - vector_required = ((item_count * dims) * np_float) / divider + linkage_required = (((self._num_predictions ** 2) * np_float) / 1.8) / divider + vector_required = ((self._num_predictions * dims) * np_float) / divider logger.debug("free_ram: %sMB, linkage_required: %sMB, vector_required: %sMB", int(free_ram), int(linkage_required), int(vector_required)) if linkage_required < free_ram: - logger.verbose("Using linkage method") + logger.verbose("Using linkage method") # type:ignore retval = False elif vector_required < free_ram: logger.warning("Not enough RAM to perform linkage clustering. Using vector " @@ -197,12 +230,85 @@ def _use_vector_linkage(item_count, dims): retval = True else: raise FaceswapError("Not enough RAM available to sort faces. Try reducing " - "the size of your dataset. Free RAM: {}MB. " - "Required RAM: {}MB".format(int(free_ram), int(vector_required))) + f"the size of your dataset. Free RAM: {int(free_ram)}MB. " + f"Required RAM: {int(vector_required)}MB") logger.debug(retval) return retval - def _seriation(self, tree, points, current_index): + def _do_linkage(self, + predictions: np.ndarray, + method: Literal["single", "centroid", "median", "ward"]) -> np.ndarray: + """ Use FastCluster to perform vector or standard linkage + + Parameters + ---------- + predictions: :class:`numpy.ndarray` + A stacked matrix of vgg_face2 predictions of the shape (`N`, `D`) where `N` is the + number of observations and `D` are the number of dimensions. + method: ['single','centroid','median','ward'] + The clustering method to use. + + Returns + ------- + :class:`numpy.ndarray` + The [`num_predictions`, 4] linkage vector + """ + dims = predictions.shape[-1] + if self._use_vector_linkage(dims): + retval = linkage_vector(predictions, method=method) + else: + retval = linkage(predictions, method=method, preserve_input=False) + logger.debug("Linkage shape: %s", retval.shape) + return retval + + def _process_leaf_node(self, + current_index: int, + current_bin: int) -> List[Tuple[int, int]]: + """ Process the output when we have hit a leaf node """ + if not self._should_output_bins: + return [(current_index, 0)] + + if current_bin not in self._bins: + next_val = 0 if not self._bins else max(self._bins.values()) + 1 + self._bins[current_bin] = next_val + return [(current_index, self._bins[current_bin])] + + def _get_bin(self, + tree: np.ndarray, + points: int, + current_index: int, + current_bin: int) -> int: + """ Obtain the bin that we are currently in. + + If we are not currently below the threshold for binning, get a new bin ID from the integer + iterator. + + Parameters + ---------- + tree: numpy.ndarray + A hierarchical tree (dendrogram) + points: int + The number of points given to the clustering process + current_index: int + The position in the tree for the recursive traversal + current_bin int, optional + The ID for the bin we are currently in. Only used when binning is enabled + + Returns + ------- + int + The current bin ID for the node + """ + if tree[current_index - points, 2] >= self._threshold: + current_bin = next(self._iterator) + logger.debug("Creating new bin ID: %s", current_bin) + return current_bin + + def _seriation(self, + tree: np.ndarray, + points: int, + current_index: int, + current_bin: int = 0) -> List[Tuple[int, int]]: """ Seriation method for sorted similarity. Seriation computes the order implied by a hierarchical tree (dendrogram). @@ -215,14 +321,44 @@ def _seriation(self, tree, points, current_index): The number of points given to the clustering process current_index: int The position in the tree for the recursive traversal + current_bin int, optional + The ID for the bin we are currently in. Only used when binning is enabled Returns ------- list: The indices in the order implied by the hierarchical tree """ - if current_index < points: - return [current_index] + if current_index < points: # Output the leaf node + return self._process_leaf_node(current_index, current_bin) + + if self._should_output_bins: + current_bin = self._get_bin(tree, points, current_index, current_bin) + left = int(tree[current_index-points, 0]) right = int(tree[current_index-points, 1]) - return self._seriation(tree, points, left) + self._seriation(tree, points, right) + + serate_left = self._seriation(tree, points, left, current_bin=current_bin) + serate_right = self._seriation(tree, points, right, current_bin=current_bin) + + return serate_left + serate_right # type: ignore + + def __call__(self) -> List[Tuple[int, int]]: + """ Process the linkages. + + Transforms a distance matrix into a sorted distance matrix according to the order implied + by the hierarchical tree (dendrogram). + + Returns + ------- + list: + List of indices with the order implied by the hierarchical tree or list of tuples of + (`index`, `bin`) if a binning threshold was provided + """ + logger.info("Sorting face distances. Depending on your dataset this may take some time...") + if self._threshold: + self._threshold = self._result_linkage[:, 2].max() * self._threshold + result_order = self._seriation(self._result_linkage, + self._num_predictions, + self._num_predictions + self._num_predictions - 2) + return result_order diff --git a/tools.py b/tools.py index e17dce0d61..326e15be25 100755 --- a/tools.py +++ b/tools.py @@ -37,15 +37,12 @@ def _get_cli_opts(): if os.path.exists(cli_file): mod = ".".join(("tools", tool_name, "cli")) module = import_module(mod) - cliarg_class = getattr(module, "{}Args".format(tool_name.title())) + cliarg_class = getattr(module, f"{tool_name.title()}Args") help_text = getattr(module, "_HELPTEXT") yield tool_name, help_text, cliarg_class if __name__ == "__main__": - print(_("Please backup your data and/or test the tool you want to use with a smaller data set " - "to make sure you understand how it works.")) - PARSER = FullHelpArgumentParser() SUBPARSER = PARSER.add_subparsers() for tool, helptext, cli_args in _get_cli_opts(): diff --git a/tools/sort/cli.py b/tools/sort/cli.py index fdb1d469da..0c54c7d9a2 100644 --- a/tools/sort/cli.py +++ b/tools/sort/cli.py @@ -12,6 +12,63 @@ _HELPTEXT = _("This command lets you sort images using various methods.") +_SORT_METHODS = ( + "none", "blur", "blur-fft", "distance", "face", "face-cnn", "face-cnn-dissim", + "yaw", "pitch", "hist", "hist-dissim", "color-black", "color-gray", "color-luma", + "color-green", "color-orange", "size", "face-yaw", "black-pixels") + +_GPTHRESHOLD = _(" Adjust the '-t' ('--threshold') parameter to control the strength of grouping.") +_GPCOLOR = _(" Adjust the '-b' ('--bins') parameter to control the number of bins for grouping. " + "Each image is allocated to a bin by the percentage of color pixels that appear in " + "the image.") +_GPDEGREES = _(" Adjust the '-b' ('--bins') parameter to control the number of bins for grouping. " + "Each image is allocated to a bin by the number of degrees the face is orientated " + "from center.") +_GPLINEAR = _(" Adjust the '-b' ('--bins') parameter to control the number of bins for grouping. " + "The minimum and maximum values are taken for the chosen sort metric. The bins " + "are then populated with the results from the group sorting.") +_METHOD_TEXT = { + "blur": _("faces by blurriness."), + "blur-fft": _("faces by fft filtered blurriness."), + "distance": _("faces by the estimated distance of the alignments from an 'average' face. This " + "can be useful for eliminating misaligned faces. Sorts from most like an " + "average face to least like an average face."), + "face": _("faces using VGG Face2 by face similarity. This uses a pairwise clustering " + "algorithm to check the distances between 512 features on every face in your set " + "and order them appropriately."), + "face-cnn": _("faces by their landmarks."), + "face-cnn-dissim": _("Like 'face-cnn' but sorts by dissimilarity."), + "yaw": _("faces by Yaw (rotation left to right)."), + "pitch": _("faces by Pitch (rotation up and down)."), + "hist": _("faces by their color histogram."), + "hist-dissim": _("Like 'hist' but sorts by dissimilarity."), + "color-gray": _("images by the average intensity of the converted grayscale color channel."), + "color-black": _("images by their number of black pixels. Useful when faces are near borders " + "and a large part of the image is black."), + "color-luma": _("images by the average intensity of the converted Y color channel. Bright " + "lighting and oversaturated images will be ranked first."), + "color-green": _("images by the average intensity of the converted Cg color channel. Green " + "images will be ranked first and red images will be last."), + "color-orange": _("images by the average intensity of the converted Co color channel. Orange " + "images will be ranked first and blue images will be last."), + "size": _("images by their size in the original frame. Faces further from the camera and from " + "lower resolution sources will be sorted first, whilst faces closer to the camera " + "and from higher resolution sources will be sorted last."), + "face-yaw": _(" option is deprecated. Use 'yaw'"), + "black-pixels": _(" option is deprecated. Use 'color-black'")} + +_BIN_TYPES = [ + (("face", "face-cnn", "face-cnn-dissim", "hist", "hist-dissim"), _GPTHRESHOLD), + (("color-black", "color-gray", "color-luma", "color-green", "color-orange"), _GPCOLOR), + (("yaw", "pitch"), _GPDEGREES), + (("blur", "blur-fft", "distance", "size"), _GPLINEAR)] +_SORT_HELP = "" +_GROUP_HELP = "" + +for method in sorted(_METHOD_TEXT): + _SORT_HELP += f"\nL|{method}: {_('Sort')} {_METHOD_TEXT[method]}" + _GROUP_HELP += (f"\nL|{method}: {_('Group')} {_METHOD_TEXT[method]} " + f"{next((x[1] for x in _BIN_TYPES if method in x[0]), '')}") class SortArgs(FaceSwapArgs): @@ -25,7 +82,7 @@ def get_info(): @staticmethod def get_argument_list(): """ Put the arguments in a list so that they are accessible from both argparse and gui """ - argument_list = list() + argument_list = [] argument_list.append(dict( opts=('-i', '--input'), action=DirFullPaths, @@ -38,7 +95,11 @@ def get_argument_list(): action=DirFullPaths, dest="output_dir", group=_("data"), - help=_("Output directory for sorted aligned faces."))) + help=_("Output directory for sorted aligned faces. If not provided and 'keep' is " + "selected then a new folder called 'sorted' will be created within the input " + "folder to house the output. If not provided and 'keep' is not selected then " + "the images will be sorted in-place, overwriting the original contents of the " + "'input_dir'"))) argument_list.append(dict( opts=("-B", "--batch-mode"), action="store_true", @@ -47,97 +108,77 @@ def get_argument_list(): group=_("data"), help=_("R|If selected then the input_dir should be a parent folder containing " "multiple folders of faces you wish to sort. The faces " - "will be output to separate sub-folders in the output_dir if 'rename' has been " - "selected"))) + "will be output to separate sub-folders in the output_dir"))) argument_list.append(dict( opts=('-s', '--sort-by'), action=Radio, type=str, - choices=("blur", "blur-fft", "distance", "face", "face-cnn", "face-cnn-dissim", - "face-yaw", "hist", "hist-dissim", "color-gray", "color-luma", "color-green", - "color-orange", "size", "black-pixels"), + choices=_SORT_METHODS, dest='sort_method', group=_("sort settings"), default="face", - help=_("R|Sort by method. Choose how images are sorted. " - "\nL|'blur': Sort faces by blurriness." - "\nL|'blur-fft': Sort faces by fft filtered blurriness." - "\nL|'distance' Sort faces by the estimated distance of the alignments from an " - "'average' face. This can be useful for eliminating misaligned faces." - "\nL|'face': Use VGG Face to sort by face similarity. This uses a pairwise " - "clustering algorithm to check the distances between 512 features on every " - "face in your set and order them appropriately." - "\nL|'face-cnn': Sort faces by their landmarks. You can adjust the threshold " - "with the '-t' (--ref_threshold) option." - "\nL|'face-cnn-dissim': Like 'face-cnn' but sorts by dissimilarity." - "\nL|'face-yaw': Sort faces by Yaw (rotation left to right)." - "\nL|'hist': Sort faces by their color histogram. You can adjust the threshold " - "with the '-t' (--ref_threshold) option." - "\nL|'hist-dissim': Like 'hist' but sorts by dissimilarity." - "\nL|'color-gray': Sort images by the average intensity of the converted " - "grayscale color channel." - "\nL|'color-luma': Sort images by the average intensity of the converted Y " - "color channel. Bright lighting and oversaturated images will be ranked first." - "\nL|'color-green': Sort images by the average intensity of the converted Cg " - "color channel. Green images will be ranked first and red images will be last." - "\nL|'color-orange': Sort images by the average intensity of the converted Co " - "color channel. Orange images will be ranked first and blue images will be " - "last." - "\nL|'size': Sort images by their size in the original frame. Faces closer to " - "the camera and from higher resolution sources will be sorted first, whilst " - "faces further from the camera and from lower resolution sources will be " - "sorted last." - "\nL|'black-pixels': Sort images by their number of black pixels. Useful when " - "faces are near borders and a large part of the image is black." + help=_("R|Choose how images are sorted. Selecting a sort method gives the images a " + "new filename based on the order the image appears within the given method." + "\nL|'none': Don't sort the images. When a 'group-by' method is selected, " + "selecting 'none' means that the files will be moved/copied into their " + "respective bins, but the files will keep their original filenames. Selecting " + "'none' for both 'sort-by' and 'group-by' will do nothing" + _SORT_HELP + "\nDefault: face"))) + argument_list.append(dict( + opts=('-g', '--group-by'), + action=Radio, + type=str, + choices=_SORT_METHODS, + dest='group_method', + group=_("group settings"), + default="none", + help=_("R|Selecting a group by method will move/copy files into numbered bins based " + "on the selected method." + "\nL|'none': Don't bin the images. Folders will be sorted by the selected " + "'sort-by' but will not be binned, instead they will be sorted into a single " + "folder. Selecting 'none' for both 'sort-by' and 'group-by' will do nothing" + + _GROUP_HELP + "\nDefault: none"))) argument_list.append(dict( opts=('-k', '--keep'), action='store_true', dest='keep_original', default=False, - group=_("output"), - help=_("Keeps the original files in the input directory. Be careful when using this " - "with rename grouping and no specified output directory as this would keep the " - "original and renamed files in the same directory."))) + group=_("data"), + help=_("Whether to keep the original files in their original location. Choosing a " + "'sort-by' method means that the files have to be renamed. Selecting 'keep' " + "means that the original files will be kept, and the renamed files will be " + "created in the specified output folder. Unselecting keep means that the " + "original files will be moved and renamed based on the selected sort/group " + "criteria."))) argument_list.append(dict( - opts=('-t', '--ref_threshold'), + opts=('-t', '--threshold'), action=Slider, min_max=(-1.0, 10.0), rounding=2, type=float, - dest='min_threshold', - group=_("sort settings"), + dest='threshold', + group=_("group settings"), default=-1.0, - help=_("Float value. Minimum threshold to use for grouping comparison with 'face-cnn' " - "and 'hist' methods. The lower the value the more discriminating the grouping " - "is. Leaving -1.0 will allow the program set the default value automatically. " - "For face-cnn 7.2 should be enough, with 4 being very discriminating. For hist " - "0.3 should be enough, with 0.2 being very discriminating. Be careful setting " - "a value that's too low in a directory with many images, as this could result " - "in a lot of directories being created. Defaults: face-cnn 7.2, hist 0.3"))) + help=_("R|Float value. Minimum threshold to use for grouping comparison with " + "'face-cnn' 'hist' and 'face' methods." + "\nThe lower the value the more discriminating the grouping is. Leaving " + "-1.0 will allow Faceswap to choose the default value." + "\nL|For 'face-cnn' 7.2 should be enough, with 4 being very discriminating. " + "\nL|For 'hist' 0.3 should be enough, with 0.2 being very discriminating. " + "\nL|For 'face' between 0.1 (few bins) to 0.4 (more bins) should " + "be about right." + "\nBe careful setting a value that's too extrene in a directory " + "with many images, as this could result in a lot of folders being created. " + "Defaults: face-cnn 7.2, hist 0.3, face 0.25"))) argument_list.append(dict( opts=('-fp', '--final-process'), action=Radio, type=str, choices=("folders", "rename"), dest='final_process', - default="rename", group=_("output"), - help=_("R|Default: rename." - "\nL|'folders': files are sorted using the -s/--sort-by method, then they are " - "organized into folders using the -g/--group-by grouping method." - "\nL|'rename': files are sorted using the -s/--sort-by then they are " - "renamed."))) - argument_list.append(dict( - opts=('-g', '--group-by'), - action=Radio, - type=str, - choices=("blur", "blur-fft", "face-cnn", "face-yaw", "hist", "black-pixels"), - dest='group_method', - group=_("output"), - default="hist", - help=_("Group by method. When -fp/--final-processing by folders choose the how the " - "images are grouped after sorting. Default: hist"))) + help=_("Deprecated and no longer used. The final processing will be dictated by the " + "sort/group by methods and whether 'keep_original' is selected."))) argument_list.append(dict( opts=('-b', '--bins'), action=Slider, @@ -145,19 +186,27 @@ def get_argument_list(): rounding=1, type=int, dest='num_bins', - group=_("output"), + group=_("group settings"), default=5, - help=_("Integer value. Number of folders that will be used to group by blur, " - "face-yaw and black-pixels. For blur folder 0 will be the least blurry, while " - "the last folder will be the blurriest. For face-yaw the number of bins is by " - "how much 180 degrees is divided. So if you use 18, then each folder will be " - "a 10 degree increment. Folder 0 will contain faces looking the most to the " - "left whereas the last folder will contain the faces looking the most to the " - "right. If the number of images doesn't divide evenly into the number of " - "bins, the remaining images get put in the last bin. For black-pixels it " - "represents the divider of the percentage of black pixels. For 10, first " - "folder will have the faces with 0 to 10%% black pixels, second 11 to 20%%, " - "etc. Default value: 5"))) + help=_("R|Integer value. Used to control the number of bins created for grouping by: " + "any 'blur' methods, 'color' methods or 'face metric' methods ('distance', " + "'size') and 'orientation; methods ('yaw', 'pitch'). For any other grouping " + "methods see the '-t' ('--threshold') option." + "\nL|For 'face metric' methods the bins are filled, according the the " + "distribution of faces between the minimum and maximum chosen metric." + "\nL|For 'color' methods the number of bins represents the divider of the " + "percentage of colored pixels. Eg. For a bin number of '5': The first folder " + "will have the faces with 0%% to 20%% colored pixels, second 21%% to 40%%, " + "etc. Any empty bins will be deleted, so you may end up with fewer bins than " + "selected." + "\nL|For 'blur' methods folder 0 will be the least blurry, while " + "the last folder will be the blurriest." + "\nL|For 'orientation' methods the number of bins is dictated by how much 180 " + "degrees is divided. Eg. If 18 is selected, then each folder will be a 10 " + "degree increment. Folder 0 will contain faces looking the most to the " + "left/down whereas the last folder will contain the faces looking the most to " + "the right/up. NB: Some bins may be empty if faces do not fit the criteria." + "\nDefault value: 5"))) argument_list.append(dict( opts=('-l', '--log-changes'), action='store_true', diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 6c27d50326..092fc40d2a 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -5,23 +5,23 @@ import logging import os import sys -import operator + + from argparse import Namespace -from concurrent import futures -from shutil import copyfile -from typing import List +from shutil import copyfile, rmtree +from typing import List, Optional, TYPE_CHECKING -import numpy as np -import cv2 from tqdm import tqdm # faceswap imports -from lib.serializer import get_serializer_from_filename -from lib.align import AlignedFace, DetectedFace -from lib.image import FacesLoader, read_image, read_image_meta_batch -from lib.utils import FaceswapError -from plugins.extract.recognition.vgg_face2_keras import VGGFace2 as VGGFace -from plugins.extract.pipeline import Extractor, ExtractMedia +from lib.serializer import Serializer, get_serializer_from_filename +from lib.utils import deprecation_warning + +from .sort_methods import SortBlur, SortColor, SortFace, SortHistogram, SortMultiMethod +from .sort_methods_aligned import SortDistance, SortFaceCNN, SortPitch, SortSize, SortYaw + +if TYPE_CHECKING: + from .sort_methods import SortMethod logger = logging.getLogger(__name__) @@ -40,9 +40,31 @@ class Sort(): # pylint:disable=too-few-public-methods def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing: %s (args: %s)", self.__class__.__name__, arguments) self._args = arguments + self._handle_deprecations() self._input_locations = self._get_input_locations() logger.debug("Initialized: %s", self.__class__.__name__) + def _handle_deprecations(self): + """ Warn that 'final_process' is deprecated and remove from arguments """ + if self._args.final_process: + deprecation_warning("`-fp`, `--final-process`", "This option will be ignored") + logger.warning("Final processing is dictated by your choice of 'sort-by' and " + "'group-by' options and whether 'keep' has been selected.") + del self._args.final_process + if "face-yaw" in (self._args.sort_method, self._args.group_method): + deprecation_warning("`face-yaw` sort option", "Please use option 'yaw' going forward.") + sort_ = self._args.sort_method + group_ = self._args.group_method + self._args.sort_method = "yaw" if sort_ == "face-yaw" else sort_ + self._args.group_method = "yaw" if group_ == "face-yaw" else group_ + if "black-pixels" in (self._args.sort_method, self._args.group_method): + deprecation_warning("`black-pixels` sort option", + "Please use option 'color-black' going forward.") + sort_ = self._args.sort_method + group_ = self._args.group_method + self._args.sort_method = "color-black" if sort_ == "black-pixels" else sort_ + self._args.group_method = "color-black" if group_ == "black-pixels" else group_ + def _get_input_locations(self) -> List[str]: """ Obtain the full path to input locations. Will be a list of locations if batch mode is selected, or a containing a single location if batch mode is not selected. @@ -101,985 +123,230 @@ def process(self) -> None: sort.process() -class _Sort(): +class _Sort(): # pylint:disable=too-few-public-methods """ Sorts folders of faces based on input criteria """ - # pylint: disable=no-member - def __init__(self, arguments): - self._args = arguments - self.changes = None - self.serializer = None - self._vgg_face = None - self._loader = FacesLoader(self._args.input_dir) - - def process(self): - """ Main processing function of the sort tool """ - - # Setting default argument values that cannot be set by argparse - - # Set output folder to the same value as input folder - # if the user didn't specify it. - if self._args.output_dir is None: - logger.verbose("No output directory provided. Using input folder as output folder.") - self._args.output_dir = self._args.input_dir - - # Assigning default threshold values based on grouping method - if (self._args.final_process == "folders" - and self._args.min_threshold < 0.0): - method = self._args.group_method.lower() - if method == 'face-cnn': - self._args.min_threshold = 7.2 - elif method == 'hist': - self._args.min_threshold = 0.3 - - # Load VGG Face if sorting by face - if self._args.sort_method.lower() == "face": - self._vgg_face = VGGFace(exclude_gpus=self._args.exclude_gpus) - self._vgg_face.init_model() - - # If logging is enabled, prepare container - if self._args.log_changes: - self.changes = {} - - # Assign default sort_log.json value if user didn't specify one - if self._args.log_file_path == 'sort_log.json': - self._args.log_file_path = os.path.join(self._args.input_dir, - 'sort_log.json') - - # Set serializer based on log file extension - self.serializer = get_serializer_from_filename(self._args.log_file_path) - - # Prepare sort, group and final process method names - _sort = "sort_" + self._args.sort_method.lower() - _group = "group_" + self._args.group_method.lower() - _final = "final_process_" + self._args.final_process.lower() - if _sort.startswith('sort_color-'): - self._args.color_method = _sort.replace('sort_color-', '') - _sort = _sort[:10] - self._args.sort_method = _sort.replace('-', '_') - self._args.group_method = _group.replace('-', '_') - self._args.final_process = _final.replace('-', '_') - - self.sort_process() - - def launch_aligner(self): - """ Load the aligner plugin to retrieve landmarks """ - extractor = Extractor(None, "fan", None, - normalize_method="hist", exclude_gpus=self._args.exclude_gpus) - extractor.set_batchsize("align", 1) - extractor.launch() - return extractor - - @staticmethod - def alignment_dict(filename, image): - """ Set the image to an ExtractMedia object for alignment """ - height, width = image.shape[:2] - face = DetectedFace(left=0, width=width, top=0, height=height) - return ExtractMedia(filename, image, detected_faces=[face]) - - def _get_landmarks(self): - """ Multi-threaded, parallel and sequentially ordered landmark loader """ - extractor = self.launch_aligner() - filename_list, image_list = self._get_images() - feed_list = list(map(Sort.alignment_dict, filename_list, image_list)) - landmarks = np.zeros((len(feed_list), 68, 2), dtype='float32') - - logger.info("Finding landmarks in images...") - # TODO thread the put to queue so we don't have to put and get at the same time - # Or even better, set up a proper background loader from disk (i.e. use lib.image.ImageIO) - for idx, feed in enumerate(tqdm(feed_list, desc="Aligning", file=sys.stdout)): - extractor.input_queue.put(feed) - landmarks[idx] = next(extractor.detected_faces()).detected_faces[0].landmarks_xy - - return filename_list, image_list, landmarks - - def _get_images(self): - """ Multi-threaded, parallel and sequentially ordered image loader """ - logger.info("Loading images...") - filename_list = self.find_images(self._args.input_dir) - with futures.ThreadPoolExecutor() as executor: - image_list = list(tqdm(executor.map(read_image, filename_list), - desc="Loading Images", - file=sys.stdout, - total=len(filename_list))) - - return filename_list, image_list + logger.debug("Initializing %s: arguments: %s", self.__class__.__name__, arguments) + self._processes = dict(blur=SortBlur, + blur_fft=SortBlur, + distance=SortDistance, + yaw=SortYaw, + pitch=SortPitch, + size=SortSize, + face=SortFace, + face_cnn=SortFaceCNN, + face_cnn_dissim=SortFaceCNN, + hist=SortHistogram, + hist_dissim=SortHistogram, + color_black=SortColor, + color_gray=SortColor, + color_luma=SortColor, + color_green=SortColor, + color_orange=SortColor) + + self._args = self._parse_arguments(arguments) + self._changes = {} + self.serializer: Optional[Serializer] = None + + if arguments.log_changes: + self.serializer = get_serializer_from_filename(arguments.log_file_path) + + self._sorter = self._get_sorter() + logger.debug("Initialized %s", self.__class__.__name__) + + def _set_output_folder(self, arguments): + """ Set the output folder correctly if it has not been provided + Parameters: + ---------- + arguments: :class:`argparse.Namespace` + The command line arguments passed to the sort process - def sort_process(self): - """ - This method dynamically assigns the functions that will be used to run - the core process of sorting, optionally grouping, renaming/moving into - folders. After the functions are assigned they are executed. + Returns + ------- + :class:`argparse.Namespace` + The command line arguments with output folder correctly set """ - sort_method = self._args.sort_method.lower() - group_method = self._args.group_method.lower() - final_method = self._args.final_process.lower() - - img_list = getattr(self, sort_method)() - if "folders" in final_method: - # Check if non-dissimilarity sort method and group method are not the same - if group_method.replace('group_', '') not in sort_method: - img_list = self.reload_images(group_method, img_list) - img_list = getattr(self, group_method)(img_list) - else: - img_list = getattr(self, group_method)(img_list) - - getattr(self, final_method)(img_list) - - logger.info("Done.") - - # Methods for sorting - def sort_distance(self): - """ Sort by comparison of face landmark points to mean face by average distance of core - landmarks. """ - logger.info("Sorting by average distance of landmarks...") - filenames = [] - distances = [] - filelist = [os.path.join(self._loader.location, fname) - for fname in os.listdir(self._loader.location) - if os.path.splitext(fname)[-1] == ".png"] - for filename, metadata in tqdm(read_image_meta_batch(filelist), - total=len(filelist), - desc="Calculating Distances"): - if not metadata: - msg = ("The images to be sorted do not contain alignment data. Images must have " - "been generated by Faceswap's Extract process.\nIf you are sorting an " - "older faceset, then you should re-extract the faces from your source " - "alignments file to generate this data.") - raise FaceswapError(msg) - alignments = metadata["itxt"]["alignments"] - aligned_face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32")) - filenames.append(filename) - distances.append(aligned_face.average_distance) - - logger.info("Sorting...") - matched_list = list(zip(filenames, distances)) - img_list = sorted(matched_list, key=operator.itemgetter(1)) - return img_list - - def sort_blur(self): - """ Sort by blur amount """ - logger.info("Sorting by estimated image blur...") - - blurs = [(filename, self.estimate_blur(image, metadata)) - for filename, image, metadata in tqdm(self._loader.load(), - desc="Estimating blur", - total=self._loader.count, - leave=False)] - logger.info("Sorting...") - return sorted(blurs, key=lambda x: x[1], reverse=True) - - def sort_blur_fft(self): - """ Sort by fft filtered blur amount with fft""" - logger.info("Sorting by estimated fft filtered image blur...") - - fft_blurs = [(filename, self.estimate_blur_fft(image, metadata)) - for filename, image, metadata in tqdm(self._loader.load(), - desc="Estimating fft blur score", - total=self._loader.count, - leave=False)] - logger.info("Sorting...") - return sorted(fft_blurs, key=lambda x: x[1], reverse=True) - - def sort_color(self): - """ Score by channel average intensity """ - logger.info("Sorting by channel average intensity...") - desired_channel = {'gray': 0, 'luma': 0, 'orange': 1, 'green': 2} - method = self._args.color_method - channel_to_sort = next(v for (k, v) in desired_channel.items() if method.endswith(k)) - filename_list, image_list = self._get_images() - - logger.info("Converting to appropriate colorspace...") - same_size = all(img.size == image_list[0].size for img in image_list) - images = np.array(image_list, dtype='float32')[None, ...] if same_size else image_list - converted_images = self._convert_color(images, same_size, method) - - logger.info("Scoring each image...") - if same_size: - scores = np.average(converted_images[0], axis=(1, 2)) - else: - progress_bar = tqdm(converted_images, desc="Scoring", file=sys.stdout) - scores = np.array([np.average(image, axis=(0, 1)) for image in progress_bar]) - - logger.info("Sorting...") - matched_list = list(zip(filename_list, scores[:, channel_to_sort])) - sorted_file_img_list = sorted(matched_list, key=operator.itemgetter(1), reverse=True) - return sorted_file_img_list - - def sort_face(self): - """ Sort by identity similarity """ - logger.info("Sorting by identity similarity...") - filenames = [] - preds = [] - for filename, image, metadata in tqdm(self._loader.load(), - desc="Classifying Faces", - total=self._loader.count, - leave=False): - if not metadata: - msg = ("The images to be sorted do not contain alignment data. Images must have " - "been generated by Faceswap's Extract process.\nIf you are sorting an " - "older faceset, then you should re-extract the faces from your source " - "alignments file to generate this data.") - raise FaceswapError(msg) - alignments = metadata["alignments"] - face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), - image=image, - centering="legacy", - size=self._vgg_face.input_size, - is_aligned=True).face - filenames.append(filename) - preds.append(self._vgg_face.predict(face)) - - logger.info("Sorting by ward linkage...") - - indices = self._vgg_face.sorted_similarity(np.array(preds), method="ward") - img_list = np.array(filenames)[indices] - return img_list - - def sort_face_cnn(self): - """ Sort by landmark similarity """ - logger.info("Sorting by landmark similarity...") - filename_list, _, landmarks = self._get_landmarks() - img_list = list(zip(filename_list, landmarks)) - - logger.info("Comparing landmarks and sorting...") - img_list_len = len(img_list) - for i in tqdm(range(0, img_list_len - 1), desc="Comparing", file=sys.stdout): - min_score = float("inf") - j_min_score = i + 1 - for j in range(i + 1, img_list_len): - fl1 = img_list[i][1] - fl2 = img_list[j][1] - score = np.sum(np.absolute((fl2 - fl1).flatten())) - if score < min_score: - min_score = score - j_min_score = j - (img_list[i + 1], img_list[j_min_score]) = (img_list[j_min_score], img_list[i + 1]) - return img_list - - def sort_face_cnn_dissim(self): - """ Sort by landmark dissimilarity """ - logger.info("Sorting by landmark dissimilarity...") - filename_list, _, landmarks = self._get_landmarks() - scores = np.zeros(len(filename_list), dtype='float32') - img_list = list(list(items) for items in zip(filename_list, landmarks, scores)) - - logger.info("Comparing landmarks...") - img_list_len = len(img_list) - for i in tqdm(range(0, img_list_len - 1), desc="Comparing", file=sys.stdout): - score_total = 0 - for j in range(i + 1, img_list_len): - if i == j: - continue - fl1 = img_list[i][1] - fl2 = img_list[j][1] - score_total += np.sum(np.absolute((fl2 - fl1).flatten())) - img_list[i][2] = score_total - - logger.info("Sorting...") - img_list = sorted(img_list, key=operator.itemgetter(2), reverse=True) - return img_list - - def sort_face_yaw(self): - """ Sort by estimated face yaw angle """ - logger.info("Sorting by estimated face yaw angle..") - filenames = [] - yaws = [] - filelist = [os.path.join(self._loader.location, fname) - for fname in os.listdir(self._loader.location) - if os.path.splitext(fname)[-1] == ".png"] - for filename, metadata in tqdm(read_image_meta_batch(filelist), - total=len(filelist), - desc="Calculating Yaw"): - if not metadata: - msg = ("The images to be sorted do not contain alignment data. Images must have " - "been generated by Faceswap's Extract process.\nIf you are sorting an " - "older faceset, then you should re-extract the faces from your source " - "alignments file to generate this data.") - raise FaceswapError(msg) - alignments = metadata["itxt"]["alignments"] - aligned_face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), - image=None, - centering="legacy", - is_aligned=True) - filenames.append(filename) - yaws.append(aligned_face.pose.yaw) - - logger.info("Sorting...") - matched_list = list(zip(filenames, yaws)) - img_list = sorted(matched_list, key=operator.itemgetter(1), reverse=True) - return img_list - - def sort_hist(self): - """ Sort by image histogram similarity """ - logger.info("Sorting by histogram similarity...") - - # TODO We have metadata here, so we can mask the face for hist sorting - img_list = [(filename, cv2.calcHist([image], [0], None, [256], [0, 256])) - for filename, image, _ in tqdm(self._loader.load(), - desc="Calculating histograms", - total=self._loader.count, - leave=False)] - - logger.info("Comparing histograms and sorting...") - img_list_len = len(img_list) - for i in tqdm(range(0, img_list_len - 1), desc="Comparing histograms", file=sys.stdout): - min_score = float("inf") - j_min_score = i + 1 - for j in range(i + 1, img_list_len): - score = cv2.compareHist(img_list[i][1], img_list[j][1], cv2.HISTCMP_BHATTACHARYYA) - if score < min_score: - min_score = score - j_min_score = j - (img_list[i + 1], img_list[j_min_score]) = (img_list[j_min_score], img_list[i + 1]) - return img_list - - def sort_hist_dissim(self): - """ Sort by image histogram dissimilarity """ - logger.info("Sorting by histogram dissimilarity...") - - # TODO We have metadata here, so we can mask the face for hist sorting - img_list = [[filename, cv2.calcHist([image], [0], None, [256], [0, 256]), 0.0] - for filename, image, _ in tqdm(self._loader.load(), - desc="Calculating histograms", - total=self._loader.count, - leave=False)] - - img_list_len = len(img_list) - for i in tqdm(range(0, img_list_len), desc="Comparing histograms", file=sys.stdout): - score_total = 0 - for j in range(0, img_list_len): - if i == j: - continue - score_total += cv2.compareHist(img_list[i][1], - img_list[j][1], - cv2.HISTCMP_BHATTACHARYYA) - img_list[i][2] = score_total - - logger.info("Sorting...") - return sorted(img_list, key=lambda x: x[2], reverse=True) - - def sort_size(self): - """ Sort the faces by largest face (in original frame) to smallest """ - logger.info("Sorting by original face size...") - img_list = [] - for filename, image, metadata in tqdm(self._loader.load(), - desc="Calculating face sizes", - total=self._loader.count, - leave=False): - if not metadata: - msg = ("The images to be sorted do not contain alignment data. Images must have " - "been generated by Faceswap's Extract process.\nIf you are sorting an " - "older faceset, then you should re-extract the faces from your source " - "alignments file to generate this data.") - raise FaceswapError(msg) - alignments = metadata["alignments"] - aligned_face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), - image=image, - centering="legacy", - is_aligned=True) - roi = aligned_face.original_roi - size = ((roi[1][0] - roi[0][0]) ** 2 + (roi[1][1] - roi[0][1]) ** 2) ** 0.5 - img_list.append((filename, size)) - - logger.info("Sorting...") - return sorted(img_list, key=lambda x: x[1], reverse=True) - - def sort_black_pixels(self): - """ Sort by percentage of black pixels + logger.debug("setting output folder: %s", arguments.output_dir) + input_dir = arguments.input_dir + output_dir = arguments.output_dir + sort_method = arguments.sort_method + group_method = arguments.group_method + + needs_rename = sort_method != "none" and group_method == "none" + + if needs_rename and arguments.keep_original and (not output_dir or + output_dir == input_dir): + output_dir = os.path.join(input_dir, "sorted") + logger.warning("No output folder selected, but files need renaming. " + "Outputting to: '%s'", output_dir) + elif not output_dir: + output_dir = input_dir + logger.warning("No output folder selected, files will be sorted in place in: '%s'", + output_dir) + + arguments.output_dir = output_dir + logger.debug("Set output folder: %s", arguments.output_dir) + return arguments + + def _parse_arguments(self, arguments): + """ Parse the arguments and update/format relevant choices + + Parameters: + ---------- + arguments: :class:`argparse.Namespace` + The command line arguments passed to the sort process - Calculates the sum of black pixels, get the percentage X 3 channels + Returns + ------- + :class:`argparse.Namespace` + The formatted command line arguments """ - logger.info("Sorting by percentage of black pixels...") - img_list = [(filename, np.ndarray.all(image == [0, 0, 0], axis=2).sum()/image.size*100*3) - for filename, image, _ in tqdm(self._loader.load(), - desc="Calculating black pixels", - total=self._loader.count, - leave=False)] - img_list_len = len(img_list) - for i in tqdm(range(0, img_list_len - 1), desc="Comparing black pixels", file=sys.stdout): - for j in range(0, img_list_len-i-1): - if img_list[j][1] > img_list[j+1][1]: - temp = img_list[j] - img_list[j] = img_list[j+1] - img_list[j+1] = temp - return img_list - - # Methods for grouping - def group_blur(self, img_list): - """ Group into bins by blur """ - # Starting the binning process - num_bins = self._args.num_bins - - # The last bin will get all extra images if it's - # not possible to distribute them evenly - num_per_bin = len(img_list) // num_bins - remainder = len(img_list) % num_bins - - logger.info("Grouping by blur...") - bins = [[] for _ in range(num_bins)] - idx = 0 - for i in range(num_bins): - for _ in range(num_per_bin): - bins[i].append(img_list[idx][0]) - idx += 1 - - # If remainder is 0, nothing gets added to the last bin. - for i in range(1, remainder + 1): - bins[-1].append(img_list[-i][0]) - - return bins - - def group_blur_fft(self, img_list): - """ Group into bins by fft blur score""" - # Starting the binning process - num_bins = self._args.num_bins - - # The last bin will get all extra images if it's - # not possible to distribute them evenly - num_per_bin = len(img_list) // num_bins - remainder = len(img_list) % num_bins - - logger.info("Grouping by fft blur score...") - bins = [[] for _ in range(num_bins)] - idx = 0 - for i in range(num_bins): - for _ in range(num_per_bin): - bins[i].append(img_list[idx][0]) - idx += 1 - - # If remainder is 0, nothing gets added to the last bin. - for i in range(1, remainder + 1): - bins[-1].append(img_list[-i][0]) - - return bins - - def group_face_cnn(self, img_list): - """ Group into bins by CNN face similarity """ - logger.info("Grouping by face-cnn similarity...") - - # Groups are of the form: group_num -> reference faces - reference_groups = {} - - # Bins array, where index is the group number and value is - # an array containing the file paths to the images in that group. - bins = [] - - # Comparison threshold used to decide how similar - # faces have to be to be grouped together. - # It is multiplied by 1000 here to allow the cli option to use smaller - # numbers. - min_threshold = self._args.min_threshold * 1000 - - img_list_len = len(img_list) + logger.debug("Cleaning arguments: %s", arguments) + if arguments.sort_method == "none" and arguments.group_method == "none": + logger.error("Both sort-by and group-by are 'None'. Nothing to do.") + sys.exit(1) - for i in tqdm(range(0, img_list_len - 1), - desc="Grouping", - file=sys.stdout): - fl1 = img_list[i][1] - - current_best = [-1, float("inf")] - - for key, references in reference_groups.items(): - try: - score = self.get_avg_score_faces_cnn(fl1, references) - except TypeError: - score = float("inf") - except ZeroDivisionError: - score = float("inf") - if score < current_best[1]: - current_best[0], current_best[1] = key, score - - if current_best[1] < min_threshold: - reference_groups[current_best[0]].append(fl1[0]) - bins[current_best[0]].append(img_list[i][0]) - else: - reference_groups[len(reference_groups)] = [img_list[i][1]] - bins.append([img_list[i][0]]) - - return bins - - def group_face_yaw(self, img_list): - """ Group into bins by yaw of face """ - # Starting the binning process - num_bins = self._args.num_bins + # Prepare sort, group and final process method names + arguments.sort_method = arguments.sort_method.lower().replace("-", "_") + arguments.group_method = arguments.group_method.lower().replace("-", "_") - # The last bin will get all extra images if it's - # not possible to distribute them evenly - num_per_bin = len(img_list) // num_bins - remainder = len(img_list) % num_bins + arguments = self._set_output_folder(arguments) - logger.info("Grouping by face-yaw...") - bins = [[] for _ in range(num_bins)] - idx = 0 - for i in range(num_bins): - for _ in range(num_per_bin): - bins[i].append(img_list[idx][0]) - idx += 1 + if arguments.log_changes and arguments.log_file_path == "sort_log.json": + # Assign default sort_log.json value if user didn't specify one + arguments.log_file_path = os.path.join(self._args.input_dir, 'sort_log.json') - # If remainder is 0, nothing gets added to the last bin. - for i in range(1, remainder + 1): - bins[-1].append(img_list[-i][0]) + logger.debug("Cleaned arguments: %s", arguments) + return arguments - return bins + def _get_sorter(self) -> "SortMethod": + """ Obtain a sorter/grouper combo for the selected sort/group by options - def group_black_pixels(self, img_list): - """ Group into bins by percentage of black pixels - :type img_list: (str, float) + Returns + ------- + :class:`SortMethod` + The sorter or combined sorter for sorting and grouping based on user selections """ - logger.info("Grouping by percentage of black pixels...") - - # Starting the binning process - bins = [[] for _ in range(self._args.num_bins)] - # Get edges of bins from 0 to 100 - bins_edges = self._near_split(100, self._args.num_bins) - # Get the proper bin number for each img order - img_bins = np.digitize([x[1] for x in img_list], bins_edges, right=True) - - # Place imgs in bins - for idx, _bin in enumerate(img_bins): - bins[_bin].append(img_list[idx][0]) - - return bins - - def group_hist(self, img_list): - """ Group into bins by histogram """ - logger.info("Grouping by histogram...") - - # Groups are of the form: group_num -> reference histogram - reference_groups = {} - - # Bins array, where index is the group number and value is - # an array containing the file paths to the images in that group - bins = [] - - min_threshold = self._args.min_threshold - - img_list_len = len(img_list) - reference_groups[0] = [img_list[0][1]] - bins.append([img_list[0][0]]) - - for i in tqdm(range(1, img_list_len), - desc="Grouping", - file=sys.stdout): - current_best = [-1, float("inf")] - for key, value in reference_groups.items(): - score = self.get_avg_score_hist(img_list[i][1], value) - if score < current_best[1]: - current_best[0], current_best[1] = key, score - - if current_best[1] < min_threshold: - reference_groups[current_best[0]].append(img_list[i][1]) - bins[current_best[0]].append(img_list[i][0]) - else: - reference_groups[len(reference_groups)] = [img_list[i][1]] - bins.append([img_list[i][0]]) + sort_method = self._args.sort_method + group_method = self._args.group_method - return bins + sort_method = group_method if sort_method == "none" else sort_method + sorter = self._processes[sort_method](self._args, + is_group=self._args.sort_method == "none") - # Final process methods - def final_process_rename(self, img_list): - """ Rename the files """ - output_dir = self._args.output_dir - - process_file = self.set_process_file_method(self._args.log_changes, - self._args.keep_original) - - # Make sure output directory exists - if not os.path.exists(output_dir): - os.makedirs(output_dir) - - description = ( - "Copying and Renaming" if self._args.keep_original - else "Moving and Renaming" - ) - - for i in tqdm(range(0, len(img_list)), - desc=description, - leave=False, - file=sys.stdout): - src = img_list[i] if isinstance(img_list[i], str) else img_list[i][0] - src_basename = os.path.basename(src) - - dst = os.path.join(output_dir, f"{i:05d}_{src_basename}") - try: - process_file(src, dst, self.changes) - except FileNotFoundError as err: - logger.error(err) - logger.error('fail to rename %s', src) - - for i in tqdm(range(0, len(img_list)), - desc=description, - file=sys.stdout): - renaming = self.set_renaming_method(self._args.log_changes) - fname = img_list[i] if isinstance(img_list[i], str) else img_list[i][0] - src, dst = renaming(fname, output_dir, i, self.changes) - - try: - os.rename(src, dst) - except FileNotFoundError as err: - logger.error(err) - logger.error('fail to rename %s', format(src)) - - if self._args.log_changes: - self.write_to_log(self.changes) - - def final_process_folders(self, bins): - """ Move the files to folders """ - output_dir = self._args.output_dir + if sort_method != "none" and group_method != "none" and group_method != sort_method: + grouper = self._processes[group_method](self._args, is_group=True) + retval = SortMultiMethod(self._args, sorter, grouper) + logger.debug("Got sorter + grouper: %s (%s, %s)", retval, sorter, grouper) - process_file = self.set_process_file_method(self._args.log_changes, - self._args.keep_original) - - # First create new directories to avoid checking - # for directory existence in the moving loop - logger.info("Creating group directories.") - for i in range(len(bins)): - directory = os.path.join(output_dir, str(i)) - if not os.path.exists(directory): - os.makedirs(directory) - - description = ( - "Copying into Groups" if self._args.keep_original - else "Moving into Groups" - ) - - logger.info("Total groups found: %s", len(bins)) - for i in tqdm(range(len(bins)), desc=description, file=sys.stdout): - for j in range(len(bins[i])): - src = bins[i][j] - src_basename = os.path.basename(src) + else: - dst = os.path.join(output_dir, str(i), src_basename) - try: - process_file(src, dst, self.changes) - except FileNotFoundError as err: - logger.error(err) - logger.error("Failed to move '%s' to '%s'", src, dst) + retval = sorter - if self._args.log_changes: - self.write_to_log(self.changes) + logger.debug("Final sorter: %s", retval) + return retval - # Various helper methods - def write_to_log(self, changes): + def _write_to_log(self, changes): """ Write the changes to log file """ logger.info("Writing sort log to: '%s'", self._args.log_file_path) self.serializer.save(self._args.log_file_path, changes) - def reload_images(self, group_method, img_list): - """ - Reloads the image list by replacing the comparative values with those - that the chosen grouping method expects. - :param group_method: str name of the grouping method that will be used. - :param img_list: image list that has been sorted by one of the sort - methods. - :return: img_list but with the comparative values that the chosen - grouping method expects. - """ - logger.info("Preparing to group...") - if group_method == 'group_blur': - filename_list, image_list = self._get_images() - blurs = [self.estimate_blur(img) for img in image_list] - temp_list = list(zip(filename_list, blurs)) - elif group_method == 'group_blur_fft': - filename_list, image_list = self._get_images() - fft_blurs = [self.estimate_blur_fft(img) for img in image_list] - temp_list = list(zip(filename_list, fft_blurs)) - elif group_method == 'group_face_cnn': - filename_list, image_list, landmarks = self._get_landmarks() - temp_list = list(zip(filename_list, landmarks)) - elif group_method == 'group_face_yaw': - filename_list, image_list, landmarks = self._get_landmarks() - yaws = [self.calc_landmarks_face_yaw(mark) for mark in landmarks] - temp_list = list(zip(filename_list, yaws)) - elif group_method == 'group_hist': - filename_list, image_list = self._get_images() - histograms = [cv2.calcHist([img], [0], None, [256], [0, 256]) for img in image_list] - temp_list = list(zip(filename_list, histograms)) - elif group_method == 'group_black_pixels': - filename_list, image_list = self._get_images() - black_pixels = [np.ndarray.all(img == [0, 0, 0], axis=2).sum()/img.size*100*3 - for img in image_list] - temp_list = list(zip(filename_list, black_pixels)) - else: - raise ValueError(f"{group_method} group_method not found.") - - return self.splice_lists(img_list, temp_list) - - @staticmethod - def _near_split(bin_range, num_bins): - """ Obtain the split for the given number of bins for the given range - - Parameters - ---------- - bin_range: int - The range of data to separate into bins - num_bins: int - The number of bins to create + def process(self) -> None: + """ Main processing function of the sort tool - Returns - ------- - list - The split dividers for the given number of bins for the given range + This method dynamically assigns the functions that will be used to run + the core process of sorting, optionally grouping, renaming/moving into + folders. After the functions are assigned they are executed. """ - quotient, remainder = divmod(bin_range, num_bins) - seps = [quotient + 1] * remainder + [quotient] * (num_bins - remainder) - uplimit = 0 - bins = [0] - for sep in seps: - bins.append(uplimit + sep) - uplimit += sep - return bins - - @staticmethod - def _convert_color(imgs, same_size, method): - """ Helper function to convert color spaces """ - - if method.endswith('gray'): - conversion = np.array([[0.0722], [0.7152], [0.2126]]) - else: - conversion = np.array([[0.25, 0.5, 0.25], [-0.5, 0.0, 0.5], [-0.25, 0.5, -0.25]]) - - if same_size: - path = 'greedy' - operation = 'bijk, kl -> bijl' if method.endswith('gray') else 'bijl, kl -> bijk' + if self._args.group_method != "none": + # Check if non-dissimilarity sort method and group method are not the same + self._output_groups() else: - operation = 'ijk, kl -> ijl' if method.endswith('gray') else 'ijl, kl -> ijk' - path = np.einsum_path(operation, imgs[0][..., :3], conversion, optimize='optimal')[0] - - progress_bar = tqdm(imgs, desc="Converting", file=sys.stdout) - images = [np.einsum(operation, img[..., :3], conversion, optimize=path).astype('float32') - for img in progress_bar] - return images - - @staticmethod - def splice_lists(sorted_list, new_vals_list): - """ - This method replaces the value at index 1 in each sub-list in the - sorted_list with the value that is calculated for the same img_path, - but found in new_vals_list. - - Format of lists: [[img_path, value], [img_path2, value2], ...] + self._output_non_grouped() - :param sorted_list: list that has been sorted by one of the sort - methods. - :param new_vals_list: list that has been loaded by a different method - than the sorted_list. - :return: list that is sorted in the same way as the input sorted list - but the values corresponding to each image are from new_vals_list. - """ - new_list = [] - # Make new list of just image paths to serve as an index - val_index_list = [i[0] for i in new_vals_list] - for i in tqdm(range(len(sorted_list)), desc="Splicing", file=sys.stdout): - current_img = sorted_list[i] if isinstance(sorted_list[i], str) else sorted_list[i][0] - new_val_index = val_index_list.index(current_img) - new_list.append([current_img, new_vals_list[new_val_index][1]]) - - return new_list + if self._args.log_changes: + self._write_to_log(self._changes) - @staticmethod - def find_images(input_dir): - """ Return list of images at specified location """ - result = [] - extensions = [".jpg", ".png", ".jpeg"] - for root, _, files in os.walk(input_dir): - for file in files: - if os.path.splitext(file)[1].lower() in extensions: - result.append(os.path.join(root, file)) - break - return result + logger.info("Done.") - @classmethod - def estimate_blur(cls, image, metadata=None): - """ Estimate the amount of blur an image has with the variance of the Laplacian. - Normalize by pixel number to offset the effect of image size on pixel gradients & variance. + def _sort_file(self, source: str, destination: str) -> None: + """ Copy or move a file based on whether 'keep original' has been selected and log changes + if required. Parameters ---------- - image: :class:`numpy.ndarray` - The face image to calculate blur for - metadata: dict, optional - The metadata for the face image or ``None`` if no metadata is available. If metadata is - provided the face will be masked by the "components" mask prior to calculating blur. - Default:``None`` - - Returns - ------- - float - The estimated blur score for the face + source: str + The full path to the source file that is being sorted + destination: str + The full path to where the source file should be moved/renamed """ - if metadata is not None: - alignments = metadata["alignments"] - det_face = DetectedFace() - det_face.from_png_meta(alignments) - aln_face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), - image=image, - centering="legacy", - size=256, - is_aligned=True) - mask = det_face.mask["components"] - mask.set_sub_crop(aln_face.pose.offset[mask.stored_centering], - aln_face.pose.offset["legacy"], - centering="legacy") - mask = cv2.resize(mask.mask, (256, 256), interpolation=cv2.INTER_CUBIC)[..., None] - image = np.minimum(aln_face.face, mask) - if image.ndim == 3: - image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) - blur_map = cv2.Laplacian(image, cv2.CV_32F) - score = np.var(blur_map) / np.sqrt(image.shape[0] * image.shape[1]) - return score + try: + if self._args.keep_original: + copyfile(source, destination) + else: + os.rename(source, destination) + except FileNotFoundError as err: + logger.error("Failed to sort '%s' to '%s'. Original error: %s", + source, destination, str(err)) - @classmethod - def estimate_blur_fft(cls, image, metadata=None): - """ Estimate the amount of blur a fft filtered image has. + if self._args.log_changes: + self._changes[source] = destination - Parameters - ---------- - image: :class:`numpy.ndarray` - Use Fourier Transform to analyze the frequency characteristics of the masked - face using 2D Discrete Fourier Transform (DFT) filter to find the frequency domain. - A mean value is assigned to the magnitude spectrum and returns a blur score. - Adapted from https://www.pyimagesearch.com/2020/06/15/ - opencv-fast-fourier-transform-fft-for-blur-detection-in-images-and-video-streams/ - metadata: dict, optional - The metadata for the face image or ``None`` if no metadata is available. If metadata is - provided the face will be masked by the "components" mask prior to calculating blur. - Default:``None`` + def _output_groups(self) -> None: + """ Move the files to folders. - Returns - ------- - float - The estimated fft blur score for the face + Obtains the bins and original filenames from :attr:`_sorter` and outputs into appropriate + bins in the output location """ - if metadata is not None: - alignments = metadata["alignments"] - det_face = DetectedFace() - det_face.from_png_meta(alignments) - aln_face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), - image=image, - centering="legacy", - size=256, - is_aligned=True) - mask = det_face.mask["components"] - mask.set_sub_crop(aln_face.pose.offset[mask.stored_centering], - aln_face.pose.offset["legacy"], - centering="legacy") - mask = cv2.resize(mask.mask, (256, 256), interpolation=cv2.INTER_CUBIC)[..., None] - image = np.minimum(aln_face.face, mask) - if image.ndim == 3: - image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) - height, width = image.shape - c_height, c_width = (int(height / 2.0), int(width / 2.0)) - fft = np.fft.fft2(image) - fft_shift = np.fft.fftshift(fft) - fft_shift[c_height - 75:c_height + 75, c_width - 75:c_width + 75] = 0 - ifft_shift = np.fft.ifftshift(fft_shift) - shift_back = np.fft.ifft2(ifft_shift) - magnitude = np.log(np.abs(shift_back)) - score = np.mean(magnitude) - return score - - @staticmethod - def calc_landmarks_face_pitch(flm): - """ UNUSED - Calculate the amount of pitch in a face """ - var_t = ((flm[6][1] - flm[8][1]) + (flm[10][1] - flm[8][1])) / 2.0 - var_b = flm[8][1] - return var_b - var_t + is_rename = self._args.sort_method != "none" + + logger.info("Creating %s group folders in '%s'.", + len(self._sorter.binned), self._args.output_dir) + bin_names = [f"_{b}" for b in self._sorter.bin_names] + if is_rename: + bin_names = [f"{name}_by_{self._args.sort_method}" for name in bin_names] + for name in bin_names: + folder = os.path.join(self._args.output_dir, name) + if os.path.exists(folder): + rmtree(folder) + os.makedirs(folder) + + description = f"{'Copying' if self._args.keep_original else 'Moving'} into groups" + description += " and renaming" if is_rename else "" + + pbar = tqdm(range(len(self._sorter.sorted_filelist)), + desc=description, + file=sys.stdout, + leave=False) + idx = 0 + for bin_id, bin_ in enumerate(self._sorter.binned): + pbar.set_description(f"{description}: Bin {bin_id + 1} of {len(self._sorter.binned)}") + output_path = os.path.join(self._args.output_dir, bin_names[bin_id]) + if not bin_: + logger.debug("Removing empty bin: %s", output_path) + os.rmdir(output_path) + for source in bin_: + basename = os.path.basename(source) + dst_name = f"{idx:06d}_{basename}" if is_rename else basename + dest = os.path.join(output_path, dst_name) + self._sort_file(source, dest) + idx += 1 + pbar.update(1) - @staticmethod - def calc_landmarks_face_yaw(flm): - """ Calculate the amount of yaw in a face """ - var_l = ((flm[27][0] - flm[0][0]) - + (flm[28][0] - flm[1][0]) - + (flm[29][0] - flm[2][0])) / 3.0 - var_r = ((flm[16][0] - flm[27][0]) - + (flm[15][0] - flm[28][0]) - + (flm[14][0] - flm[29][0])) / 3.0 - return var_r - var_l + # Output methods + def _output_non_grouped(self) -> None: + """ Output non-grouped files. - @staticmethod - def set_process_file_method(log_changes, keep_original): - """ - Assigns the final file processing method based on whether changes are - being logged and whether the original files are being kept in the - input directory. - Relevant cli arguments: -k, -l - :return: function reference + These are files which are sorted but not binned, so just the filename gets updated """ - if log_changes: - if keep_original: - def process_file(src, dst, changes): - """ Process file method if logging changes - and keeping original """ - copyfile(src, dst) - changes[src] = dst - - else: - def process_file(src, dst, changes): - """ Process file method if logging changes - and not keeping original """ - os.rename(src, dst) - changes[src] = dst - - else: - if keep_original: - def process_file(src, dst, changes): # pylint: disable=unused-argument - """ Process file method if not logging changes - and keeping original """ - copyfile(src, dst) - - else: - def process_file(src, dst, changes): # pylint: disable=unused-argument - """ Process file method if not logging changes - and not keeping original """ - os.rename(src, dst) - return process_file - - @staticmethod - def set_renaming_method(log_changes): - """ Set the method for renaming files """ - if log_changes: - def renaming(src, output_dir, i, changes): - """ Rename files method if logging changes """ - src_basename = os.path.basename(src) - - __src = os.path.join(output_dir, - f"{i:05d}_{src_basename}") - dst = os.path.join( - output_dir, - f"{i:05d}{os.path.splitext(src_basename)[1]}") - changes[src] = dst - return __src, dst - else: - def renaming(src, output_dir, i, changes): # pylint: disable=unused-argument - """ Rename files method if not logging changes """ - src_basename = os.path.basename(src) - - src = os.path.join(output_dir, - f"{i:05d}_{src_basename}") - dst = os.path.join( - output_dir, - f"{i:05d}{os.path.splitext(src_basename)[1]}") - return src, dst - return renaming + output_dir = self._args.output_dir + os.makedirs(output_dir, exist_ok=True) - @staticmethod - def get_avg_score_hist(img1, references): - """ Return the average histogram score between a face and - reference image """ - scores = [] - for img2 in references: - score = cv2.compareHist(img1, img2, cv2.HISTCMP_BHATTACHARYYA) - scores.append(score) - return sum(scores) / len(scores) + description = f"{'Copying' if self._args.keep_original else 'Moving'} and renaming" + for idx, source in enumerate(tqdm(self._sorter.sorted_filelist, + desc=description, + file=sys.stdout, + leave=False)): + dest = os.path.join(output_dir, f"{idx:06d}_{os.path.basename(source)}") - @staticmethod - def get_avg_score_faces_cnn(fl1, references): - """ Return the average CNN similarity score - between a face and reference image """ - scores = [] - for fl2 in references: - score = np.sum(np.absolute((fl2 - fl1).flatten())) - scores.append(score) - return sum(scores) / len(scores) + self._sort_file(source, dest) diff --git a/tools/sort/sort_methods.py b/tools/sort/sort_methods.py new file mode 100644 index 0000000000..646a3f48cf --- /dev/null +++ b/tools/sort/sort_methods.py @@ -0,0 +1,1031 @@ +#!/usr/bin/env python3 +""" Sorting methods for the sorting tool. + +All sorting methods inherit from :class:`SortMethod` and control functions for scorting one item, +sorting a full list of scores and binning based on those sorted scores. +""" +import logging +import operator +import sys + +from typing import Any, cast, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union + +import cv2 +import numpy as np +from tqdm import tqdm + +from lib.align import AlignedFace, DetectedFace +from lib.image import FacesLoader, ImagesLoader, read_image_meta_batch +from lib.utils import FaceswapError +from plugins.extract.recognition.vgg_face2_keras import Cluster, VGGFace2 as VGGFace + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + +if TYPE_CHECKING: + from argparse import Namespace + from lib.align.alignments import PNGHeaderAlignmentsDict + +logger = logging.getLogger(__name__) + + +ImgMetaType = Generator[Tuple[str, + Optional[np.ndarray], + Optional["PNGHeaderAlignmentsDict"]], None, None] + + +class InfoLoader(): + """ Loads aligned faces and/or face metadata + + Parameters + ---------- + input_dir: str + Full path to containing folder of faces to be supported + loader_type: ["face", "meta", "all"] + Dictates the type of iterator that will be used. "face" just loads the image with the + filename, "meta" just loads the image alignment data with the filename. "all" loads + the image and the alignment data with the filename + """ + def __init__(self, + input_dir: str, + info_type: Literal["face", "meta", "all"]) -> None: + logger.debug("Initializing: %s (input_dir: %s, info_type: %s)", + self.__class__.__name__, input_dir, info_type) + self._info_type = info_type + self._iterator = None + self._description = "Reading image statistics..." + self._loader = ImagesLoader(input_dir) if info_type == "face" else FacesLoader(input_dir) + if self._loader.count == 0: + logger.error("No images to process in location: '%s'", input_dir) + sys.exit(1) + + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def filelist_count(self) -> int: + """ int: The number of files to be processed """ + return len(self._loader.file_list) + + def _get_iterator(self) -> ImgMetaType: + """ Obtain the iterator for the selected :attr:`info_type`. + + Returns + ------- + generator + The correct generator for the given info_type + """ + if self._info_type == "all": + return self._full_data_reader() + if self._info_type == "meta": + return self._metadata_reader() + return self._image_data_reader() + + def __call__(self) -> ImgMetaType: + """ Return the selected iterator + + The resulting generator: + + Yields + ------ + filename: str + The filename that has been read + image: :class:`numpy.ndarray or ``None`` + The aligned face image loaded from disk for 'face' and 'all' info_types + otherwise ``None`` + alignments: dict or ``None`` + The alignments dict for 'all' and 'meta' infor_types otherwise ``None`` + """ + iterator = self._get_iterator() + return iterator + + @classmethod + def _get_alignments(cls, metadata: Dict[str, Any]) -> Optional["PNGHeaderAlignmentsDict"]: + """ Obtain the alignments from a PNG Header + + Parameters + ---------- + metadata: dict + The header data from a PNG file + + Returns + ------- + dict or ``None`` + The alignments dictionary from the PNG header, if it exists, otherwise ``None`` + """ + if not metadata or not metadata.get("alignments"): + return None + return metadata["alignments"] + + def _metadata_reader(self) -> ImgMetaType: + """ Load metadata from saved aligned faces + + Yields + ------ + filename: str + The filename that has been read + image: None + This will always be ``None`` with the metadata reader + alignments: dict or ``None`` + The alignment data for the given face or ``None`` if no alignments found + """ + for filename, metadata in tqdm(read_image_meta_batch(self._loader.file_list), + total=self._loader.count, + desc=self._description, + leave=False): + alignments = self._get_alignments(metadata.get("itxt", {})) + yield filename, None, alignments + + def _full_data_reader(self) -> ImgMetaType: + """ Load the image and metadata from a folder of aligned faces + + Yields + ------ + filename: str + The filename that has been read + image: :class:`numpy.ndarray + The aligned face image loaded from disk + alignments: dict or ``None`` + The alignment data for the given face or ``None`` if no alignments found + """ + for filename, image, metadata in tqdm(self._loader.load(), + desc=self._description, + total=self._loader.count, + leave=False): + alignments = self._get_alignments(metadata) + yield filename, image, alignments + + def _image_data_reader(self) -> ImgMetaType: + """ Just loads the images with their filenames + + Yields + ------ + filename: str + The filename that has been read + image: :class:`numpy.ndarray + The aligned face image loaded from disk + alignments: ``None`` + Alignments will always be ``None`` with the image data reader + """ + for filename, image in tqdm(self._loader.load(), + desc=self._description, + total=self._loader.count, + leave=False): + yield filename, image, None + + +class SortMethod(): + """ Parent class for sort methods. All sort methods should inherit from this class + + Parameters: + ---------- + arguments: :class:`argparse.Namespace` + The command line arguments passed to the sort process + loader_type: ["face", "meta", "all"] + The type of image loader to use. "face" just loads the image with the filename, "meta" + just loads the image alignment data with the filename. "all" loads the image and the + alignment data with the filename + is_group: bool, optional + Set to ``True`` if this class is going to be called exclusively for binning. + Default: ``False`` + """ + def __init__(self, + arguments: "Namespace", + loader_type: Literal["face", "meta", "all"] = "meta", + is_group: bool = False) -> None: + logger.debug("Initializing %s: loader_type: '%s' is_group: %s, arguments: %s", + self.__class__.__name__, loader_type, is_group, arguments) + self._is_group = is_group + self._log_once = True + self._method = arguments.group_method if self._is_group else arguments.sort_method + + self._num_bins: int = arguments.num_bins + self._bin_names: List[str] = [] + + self._loader_type = loader_type + self._iterator = self._get_file_iterator(arguments.input_dir) + + self._result: List[Tuple[str, Union[float, np.ndarray]]] = [] + self._binned: List[List[str]] = [] + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def loader_type(self) -> Literal["face", "meta", "all"]: + """ ["face", "meta", "all"]: The loader that this sorter uses """ + return self._loader_type + + @property + def binned(self) -> List[List[str]]: + """ list: List of bins (list) containing the filenames belonging to the bin. The binning + process is called when this property is first accessed""" + if not self._binned: + self._binned = self._binning() + logger.debug({f"bin_{idx}": len(bin_) for idx, bin_ in enumerate(self._binned)}) + return self._binned + + @property + def sorted_filelist(self) -> List[str]: + """ list: List of sorted filenames for given sorter in a single list. The sort process is + called when this property is first accessed """ + if not self._result: + self._sort_filelist() + retval = [item[0] for item in self._result] + logger.debug(retval) + else: + retval = [item[0] for item in self._result] + return retval + + @property + def bin_names(self) -> List[str]: + """ list: The name of each created bin, if they exist, otherwise an empty list """ + return self._bin_names + + def _get_file_iterator(self, input_dir: str) -> InfoLoader: + """ Override for method specific iterators. + + Parameters + ---------- + input_dir: str + Full path to containing folder of faces to be supported + + Returns + ------- + :class:`InfoLoader` + The correct InfoLoader iterator for the current sort method + """ + return InfoLoader(input_dir, self.loader_type) + + def _sort_filelist(self) -> None: + """ Call the sort method's logic to populate the :attr:`_results` attribute. + + Put logic for scoring an individual frame in in :attr:`score_image` of the child + + Returns + ------- + list + The sorted file. A list of tuples with the filename in the first position and score in + the second position + """ + for filename, image, alignments in self._iterator(): + self.score_image(filename, image, alignments) + + self.sort() + logger.debug("sorted list: %s", + [r[0] if isinstance(r, (tuple, list)) else r for r in self._result]) + + @classmethod + def _get_unique_labels(cls, numbers: List[float]) -> List[str]: + """ For a list of threshold values for displaying in the bin name, get the lowest number of + decimal figures (down to int) required to have a unique set of folder names and return the + formatted numbers. + + Parameters + ---------- + numbers: list + The list of floating point threshold numbers being used as boundary points + + Returns + ------- + list + The string formatted numbers at the lowest precision possible to represent them + uniquely + """ + i = 0 + while True: + rounded = [round(n, i) for n in numbers] + if len(set(rounded)) == len(numbers): + break + i += 1 + + if i == 0: + retval = [str(int(n)) for n in rounded] + else: + pre, post = zip(*[str(r).split(".") for r in rounded]) + rpad = max(len(x) for x in post) + retval = [f"{str(int(left))}.{str(int(right)).ljust(rpad, '0')}" + for left, right in zip(pre, post)] + logger.debug("rounded values: %s, formatted labels: %s", rounded, retval) + return retval + + def _binning_linear_threshold(self, units: str = "", multiplier: int = 1) -> List[List[str]]: + """ Standard linear binning method for binning by threshold. + + The minimum and maximum result from :attr:`_result` are taken, A range is created between + these min and max values and is divided to get the number of bins to hold the data + + Parameters + ---------- + units, str, optional + The units to use for the bin name for displaying the threshold values. This this should + correspond the value in position 1 of :attr:`_result`. + Default: "" (no units) + multiplier: int, optional + The amount to multiply the contents in position 1 of :attr:`_results` for displaying in + the bin folder name + + Returns + ------- + list + List of bins of filenames + """ + sizes = np.array([i[1] for i in self._result]) + thresholds = np.linspace(sizes.min(), sizes.max(), self._num_bins + 1) + labels = self._get_unique_labels(thresholds * multiplier) + + self._bin_names = [f"{self._method}_{idx:03d}_" + f"{labels[idx]}{units}_to_{labels[idx + 1]}{units}" + for idx in range(self._num_bins)] + + bins: List[List[str]] = [[] for _ in range(self._num_bins)] + for filename, result in self._result: + bin_idx = next(bin_id for bin_id, thresh in enumerate(thresholds) + if result <= thresh) - 1 + bins[bin_idx].append(filename) + + return bins + + def _binning(self) -> List[List[str]]: + """ Called when :attr:`binning` is first accessed. Checks if sorting has been done, if not + triggers it, then does binning + + Returns + ------- + list + List of bins of filenames + """ + if not self._result: + self._sort_filelist() + retval = self.binning() + + if not self._bin_names: + self._bin_names = [f"{self._method}_{i:03d}" for i in range(len(retval))] + + logger.debug({bin_name: len(bin_) for bin_name, bin_ in zip(self._bin_names, retval)}) + + return retval + + def sort(self) -> None: + """ Override for method specific logic for sorting the loaded statistics + + The scored list :attr:`_result` should be sorted in place + """ + raise NotImplementedError() + + def score_image(self, + filename: str, + image: Optional[np.ndarray], + alignments: Optional["PNGHeaderAlignmentsDict"]) -> None: + """ Override for sort method's specificic logic. This method should be executed to get a + single score from a single image and add the result to :attr:`_result` + + Parameters + ---------- + filename: str + The filename of the currently processing image + image: :class:`np.ndarray` or ``None`` + A face image loaded from disk or ``None`` + alignments: dict or ``None`` + The alignments dictionary for the aligned face or ``None`` + """ + raise NotImplementedError() + + def binning(self) -> List[List[str]]: + """ Group into bins by their sorted score. Override for method specific binning techniques. + + Binning takes the results from :attr:`_result` compiled during :func:`_sort_filelist` and + organizes into bins for output. + + Returns + ------- + list + List of bins of filenames + """ + raise NotImplementedError() + + @classmethod + def _mask_face(cls, image: np.ndarray, alignments: "PNGHeaderAlignmentsDict") -> np.ndarray: + """ Function for applying the mask to an aligned face if both the face image and alignment + data are available. + + Parameters + ---------- + image: :class:`numpy.ndarray` + The aligned face image loaded from disk + alignments: Dict + The alignments data corresponding to the loaded image + + Returns + ------- + :class:`numpy.ndarray` + The original image with the mask applied + """ + det_face = DetectedFace() + det_face.from_png_meta(alignments) + aln_face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), + image=image, + centering="legacy", + size=256, + is_aligned=True) + mask = det_face.mask["components"] + mask.set_sub_crop(aln_face.pose.offset[mask.stored_centering], + aln_face.pose.offset["legacy"], + centering="legacy") + nmask = cv2.resize(mask.mask, (256, 256), interpolation=cv2.INTER_CUBIC)[..., None] + assert aln_face.face is not None + return np.minimum(aln_face.face, nmask) + + +class SortMultiMethod(SortMethod): + """ A Parent sort method that runs 2 different underlying methods (one for sorting one for + binning) in instances where grouping has been requested, but the sort method is different from + the group method + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The command line arguments passed to the sort process + sort_method: :class:`SortMethod` + A sort method object for sorting the images + group_method: :class:`SortMethod` + A sort method object used for sorting and binning the images + """ + def __init__(self, + arguments: "Namespace", + sort_method: SortMethod, + group_method: SortMethod) -> None: + self._sorter = sort_method + self._grouper = group_method + self._is_built = False + super().__init__(arguments) + + def _get_file_iterator(self, input_dir: str) -> InfoLoader: + """ Override to get a group specific iterator. If the sorter and grouper use the same kind + of iterator, use that. Otherwise return the 'all' iterator, as which ever way it is cut all + outputs will be required + + Parameters + ---------- + input_dir: str + Full path to containing folder of faces to be supported + + Returns + ------- + :class:`InfoLoader` + The correct InfoLoader iterator for the current sort method + """ + if self._sorter.loader_type == self._grouper.loader_type: + return InfoLoader(input_dir, self._sorter.loader_type) + return InfoLoader(input_dir, "all") + + def score_image(self, + filename: str, + image: Optional[np.ndarray], + alignments: Optional["PNGHeaderAlignmentsDict"]) -> None: + """ Score a single image for sort method: "distance", "yaw" "pitch" or "size" and add the + result to :attr:`_result` + + Parameters + ---------- + filename: str + The filename of the currently processing image + image: :class:`np.ndarray` or ``None`` + A face image loaded from disk or ``None`` + alignments: dict or ``None`` + The alignments dictionary for the aligned face or ``None`` + """ + self._sorter.score_image(filename, image, alignments) + self._grouper.score_image(filename, image, alignments) + + def sort(self) -> None: + """ Sort the sorter and grouper methods """ + logger.debug("Sorting") + self._sorter.sort() + self._result = self._sorter.sorted_filelist # type:ignore + self._grouper.sort() + self._binned = self._grouper.binned + self._bin_names = self._grouper.bin_names + logger.debug("Sorted") + + def binning(self) -> List[List[str]]: + """ Override standard binning, to bin by the group-by method and sort by the sorting + method. + + Go through the grouped binned results, and reorder each bin contents based on the + sorted list + + Returns + ------- + list + List of bins of filenames + """ + sorted_ = self._result + output: List[List[str]] = [] + for bin_ in tqdm(self._binned, desc="Binning and sorting", file=sys.stdout, leave=False): + indices: Dict[int, str] = {} + for filename in bin_: + indices[sorted_.index(filename)] = filename + output.append([indices[idx] for idx in sorted(indices)]) + return output + + +class SortBlur(SortMethod): + """ Sort images by blur or blur-fft amount + + Parameters: + ---------- + arguments: :class:`argparse.Namespace` + The command line arguments passed to the sort process + is_group: bool, optional + Set to ``True`` if this class is going to be called exclusively for binning. + Default: ``False`` + """ + def __init__(self, arguments: "Namespace", is_group: bool = False) -> None: + super().__init__(arguments, loader_type="all", is_group=is_group) + method = arguments.group_method if self._is_group else arguments.sort_method + self._use_fft = method == "blur_fft" + + def estimate_blur(self, image: np.ndarray, alignments=None) -> float: + """ Estimate the amount of blur an image has with the variance of the Laplacian. + Normalize by pixel number to offset the effect of image size on pixel gradients & variance. + + Parameters + ---------- + image: :class:`numpy.ndarray` + The face image to calculate blur for + alignments: dict, optional + The metadata for the face image or ``None`` if no metadata is available. If metadata is + provided the face will be masked by the "components" mask prior to calculating blur. + Default:``None`` + + Returns + ------- + float + The estimated blur score for the face + """ + if alignments is not None: + image = self._mask_face(image, alignments) + if image.ndim == 3: + image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + blur_map = cv2.Laplacian(image, cv2.CV_32F) + score = np.var(blur_map) / np.sqrt(image.shape[0] * image.shape[1]) + return score + + def estimate_blur_fft(self, + image: np.ndarray, + alignments: Optional["PNGHeaderAlignmentsDict"] = None) -> float: + """ Estimate the amount of blur a fft filtered image has. + + Parameters + ---------- + image: :class:`numpy.ndarray` + Use Fourier Transform to analyze the frequency characteristics of the masked + face using 2D Discrete Fourier Transform (DFT) filter to find the frequency domain. + A mean value is assigned to the magnitude spectrum and returns a blur score. + Adapted from https://www.pyimagesearch.com/2020/06/15/ + opencv-fast-fourier-transform-fft-for-blur-detection-in-images-and-video-streams/ + alignments: dict, optional + The metadata for the face image or ``None`` if no metadata is available. If metadata is + provided the face will be masked by the "components" mask prior to calculating blur. + Default:``None`` + + Returns + ------- + float + The estimated fft blur score for the face + """ + if alignments is not None: + image = self._mask_face(image, alignments) + + if image.ndim == 3: + image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + + height, width = image.shape + c_height, c_width = (int(height / 2.0), int(width / 2.0)) + fft = np.fft.fft2(image) + fft_shift = np.fft.fftshift(fft) + fft_shift[c_height - 75:c_height + 75, c_width - 75:c_width + 75] = 0 + ifft_shift = np.fft.ifftshift(fft_shift) + shift_back = np.fft.ifft2(ifft_shift) + magnitude = np.log(np.abs(shift_back)) + score = np.mean(magnitude) + + return score + + def score_image(self, + filename: str, + image: Optional[np.ndarray], + alignments: Optional["PNGHeaderAlignmentsDict"]) -> None: + """ Score a single image for blur or blur-fft and add the result to :attr:`_result` + + Parameters + ---------- + filename: str + The filename of the currently processing image + image: :class:`np.ndarray` + A face image loaded from disk + alignments: dict or ``None`` + The alignments dictionary for the aligned face or ``None`` + """ + assert image is not None + if self._log_once: + msg = "Grouping" if self._is_group else "Sorting" + inf = "fft_filtered " if self._use_fft else " " + logger.info("%s by estimated %simage blur...", msg, inf) + self._log_once = False + + estimator = self.estimate_blur_fft if self._use_fft else self.estimate_blur + self._result.append((filename, estimator(image, alignments))) + + def sort(self) -> None: + """ Sort by metric score. Order in reverse for distance sort. """ + logger.info("Sorting...") + self._result = sorted(self._result, key=operator.itemgetter(1), reverse=True) + + def binning(self) -> List[List[str]]: + """ Create bins to split linearly from the lowest to the highest sample value + + Returns + ------- + list + List of bins of filenames + """ + return self._binning_linear_threshold(multiplier=100) + + +class SortColor(SortMethod): + """ Score by channel average intensity or black pixels. + + Parameters: + ---------- + arguments: :class:`argparse.Namespace` + The command line arguments passed to the sort process + is_group: bool, optional + Set to ``True`` if this class is going to be called exclusively for binning. + Default: ``False`` + """ + def __init__(self, arguments: "Namespace", is_group: bool = False) -> None: + super().__init__(arguments, loader_type="face", is_group=is_group) + self._desired_channel = {'gray': 0, 'luma': 0, 'orange': 1, 'green': 2} + + method = arguments.group_method if self._is_group else arguments.sort_method + self._method = method.replace("color_", "") + + def _convert_color(self, image: np.ndarray) -> np.ndarray: + """ Helper function to convert color spaces + + Parameters + ---------- + image: :class:`numpy.ndarray` + The original image to convert color space for + + Returns + ------- + :class:`numpy.ndarray` + The color converted image + """ + if self._method == 'gray': + conversion = np.array([[0.0722], [0.7152], [0.2126]]) + else: + conversion = np.array([[0.25, 0.5, 0.25], [-0.5, 0.0, 0.5], [-0.25, 0.5, -0.25]]) + + operation = 'ijk, kl -> ijl' if self._method == "gray" else 'ijl, kl -> ijk' + path = np.einsum_path(operation, image[..., :3], conversion, optimize='optimal')[0] + return np.einsum(operation, image[..., :3], conversion, optimize=path).astype('float32') + + def _near_split(self, bin_range: int) -> List[int]: + """ Obtain the split for the given number of bins for the given range + + Parameters + ---------- + bin_range: int + The range of data to separate into bins + + Returns + ------- + list + The split dividers for the given number of bins for the given range + """ + quotient, remainder = divmod(bin_range, self._num_bins) + seps = [quotient + 1] * remainder + [quotient] * (self._num_bins - remainder) + uplimit = 0 + bins = [0] + for sep in seps: + bins.append(uplimit + sep) + uplimit += sep + return bins + + def binning(self) -> List[List[str]]: + """ Group into bins by percentage of black pixels """ + # TODO. Only grouped by black pixels. Check color + + logger.info("Grouping by percentage of %s...", self._method) + + # Starting the binning process + bins: List[List[str]] = [[] for _ in range(self._num_bins)] + # Get edges of bins from 0 to 100 + bins_edges = self._near_split(100) + # Get the proper bin number for each img order + img_bins = np.digitize([float(x[1]) for x in self._result], bins_edges, right=True) + + # Place imgs in bins + for idx, _bin in enumerate(img_bins): + bins[_bin].append(self._result[idx][0]) + + retval = [b for b in bins if b] + return retval + + def score_image(self, + filename: str, + image: Optional[np.ndarray], + alignments: Optional["PNGHeaderAlignmentsDict"]) -> None: + """ Score a single image for color + + Parameters + ---------- + filename: str + The filename of the currently processing image + image: :class:`np.ndarray` + A face image loaded from disk + alignments: dict or ``None`` + The alignments dictionary for the aligned face or ``None`` + """ + if self._log_once: + msg = "Grouping" if self._is_group else "Sorting" + if self._method == "black": + logger.info("%s by percentage of black pixels...", msg) + else: + logger.info("%s by channel average intensity...", msg) + self._log_once = False + + assert image is not None + if self._method == "black": + score = np.ndarray.all(image == [0, 0, 0], axis=2).sum()/image.size*100*3 + else: + channel_to_sort = self._desired_channel[self._method] + score = np.average(self._convert_color(image), axis=(0, 1))[channel_to_sort] + self._result.append((filename, score)) + + def sort(self) -> None: + """ Sort by metric score. Order in reverse for distance sort. """ + if self._method == "black": + self._sort_black_pixels() + return + self._result = sorted(self._result, key=operator.itemgetter(1), reverse=True) + + def _sort_black_pixels(self) -> None: + """ Sort by percentage of black pixels + + Calculates the sum of black pixels, gets the percentage X 3 channels + """ + img_list_len = len(self._result) + for i in tqdm(range(0, img_list_len - 1), + desc="Comparing black pixels", file=sys.stdout, + leave=False): + for j in range(0, img_list_len-i-1): + if self._result[j][1] > self._result[j+1][1]: + temp = self._result[j] + self._result[j] = self._result[j+1] + self._result[j+1] = temp + + +class SortFace(SortMethod): + """ Sort by identity similarity using VGG Face 2 + + Parameters: + ---------- + arguments: :class:`argparse.Namespace` + The command line arguments passed to the sort process + is_group: bool, optional + Set to ``True`` if this class is going to be called exclusively for binning. + Default: ``False`` + """ + def __init__(self, arguments: "Namespace", is_group: bool = False) -> None: + super().__init__(arguments, loader_type="all", is_group=is_group) + self._vgg_face = VGGFace(exclude_gpus=arguments.exclude_gpus) + self._vgg_face.init_model() + threshold = arguments.threshold + self._threshold: Optional[float] = 0.25 if threshold < 0 else threshold + + def score_image(self, + filename: str, + image: Optional[np.ndarray], + alignments: Optional["PNGHeaderAlignmentsDict"]) -> None: + """ Processing logic for sort by face method + + Parameters + ---------- + filename: str + The filename of the currently processing image + image: :class:`np.ndarray` + A face image loaded from disk + alignments: dict or ``None`` + The alignments dictionary for the aligned face or ``None`` + """ + if self._log_once: + msg = "Grouping" if self._is_group else "Sorting" + logger.info("%s by identity similarity...", msg) + self._log_once = False + + if not alignments: + msg = ("The images to be sorted do not contain alignment data. Images must have " + "been generated by Faceswap's Extract process.\nIf you are sorting an " + "older faceset, then you should re-extract the faces from your source " + "alignments file to generate this data.") + raise FaceswapError(msg) + face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), + image=image, + centering="legacy", + size=self._vgg_face.input_size, + is_aligned=True).face + self._result.append((filename, self._vgg_face.predict(face))) + + def sort(self) -> None: + """ Sort by dendogram. + + Parameters + ---------- + matched_list: list + The list of tuples with filename in first position and face encoding in the 2nd + + Returns + ------- + list + The original list, sorted for this metric + """ + logger.info("Sorting by ward linkage. This may take some time...") + preds = np.array([item[1] for item in self._result]) + indices = Cluster(np.array(preds), "ward", threshold=self._threshold)() + self._result = [(self._result[idx][0], float(score)) for idx, score in indices] + + def binning(self) -> List[List[str]]: + """ Group into bins by their sorted score + + The bin ID has been output in the 2nd column of :attr:`_result` so use that for binnin + + Returns + ------- + list + List of bins of filenames + """ + num_bins = len(set(int(i[1]) for i in self._result)) + logger.info("Grouping by %s...", self.__class__.__name__.replace("Sort", "")) + bins: List[List[str]] = [[] for _ in range(num_bins)] + + for filename, bin_id in self._result: + bins[int(bin_id)].append(filename) + + return bins + + +class SortHistogram(SortMethod): + """ Sort by image histogram similarity or dissimilarity + + Parameters: + ---------- + arguments: :class:`argparse.Namespace` + The command line arguments passed to the sort process + is_group: bool, optional + Set to ``True`` if this class is going to be called exclusively for binning. + Default: ``False`` + """ + def __init__(self, arguments: "Namespace", is_group: bool = False) -> None: + super().__init__(arguments, loader_type="all", is_group=is_group) + method = arguments.group_method if self._is_group else arguments.sort_method + self._is_dissim = method == "hist-dissim" + self._threshold: float = 0.3 if arguments.threshold < 0.0 else arguments.threshold + + def _calc_histogram(self, + image: np.ndarray, + alignments: Optional["PNGHeaderAlignmentsDict"]) -> np.ndarray: + if alignments: + image = self._mask_face(image, alignments) + return cv2.calcHist([image], [0], None, [256], [0, 256]) + + def _sort_dissim(self) -> None: + """ Sort histograms by dissimilarity """ + img_list_len = len(self._result) + for i in tqdm(range(0, img_list_len), + desc="Comparing histograms", + file=sys.stdout, + leave=False): + score_total = 0 + for j in range(0, img_list_len): + if i == j: + continue + score_total += cv2.compareHist(self._result[i][1], + self._result[j][1], + cv2.HISTCMP_BHATTACHARYYA) + self._result[i][2] = score_total + + self._result = sorted(self._result, key=operator.itemgetter(2), reverse=True) + + def _sort_sim(self) -> None: + """ Sort histograms by similarity """ + img_list_len = len(self._result) + for i in tqdm(range(0, img_list_len - 1), + desc="Comparing histograms", + file=sys.stdout, + leave=False): + min_score = float("inf") + j_min_score = i + 1 + for j in range(i + 1, img_list_len): + score = cv2.compareHist(self._result[i][1], + self._result[j][1], + cv2.HISTCMP_BHATTACHARYYA) + if score < min_score: + min_score = score + j_min_score = j + (self._result[i + 1], self._result[j_min_score]) = (self._result[j_min_score], + self._result[i + 1]) + + @classmethod + def _get_avg_score(cls, image: np.ndarray, references: List[np.ndarray]) -> float: + """ Return the average histogram score between a face and reference images + + Parameters + ---------- + image: :class:`numpy.ndarray` + The image to test + references: list + List of reference images to test the original image against + + Returns + ------- + float + The average score between the histograms + """ + scores = [] + for img2 in references: + score = cv2.compareHist(image, img2, cv2.HISTCMP_BHATTACHARYYA) + scores.append(score) + return sum(scores) / len(scores) + + def binning(self) -> List[List[str]]: + """ Group into bins by histogram """ + msg = "dissimilarity" if self._is_dissim else "similarity" + logger.info("Grouping by %s...", msg) + + # Groups are of the form: group_num -> reference histogram + reference_groups: Dict[int, List[np.ndarray]] = {} + + # Bins array, where index is the group number and value is + # an array containing the file paths to the images in that group + bins: List[List[str]] = [] + + threshold = self._threshold + + img_list_len = len(self._result) + reference_groups[0] = [cast(np.ndarray, self._result[0][1])] + bins.append([self._result[0][0]]) + + for i in tqdm(range(1, img_list_len), + desc="Grouping", + file=sys.stdout, + leave=False): + current_key = -1 + current_score = float("inf") + for key, value in reference_groups.items(): + score = self._get_avg_score(self._result[i][1], value) + if score < current_score: + current_key, current_score = key, score + + if current_score < threshold: + reference_groups[cast(int, current_key)].append(self._result[i][1]) + bins[current_key].append(self._result[i][0]) + else: + reference_groups[len(reference_groups)] = [self._result[i][1]] + bins.append([self._result[i][0]]) + + return bins + + def score_image(self, + filename: str, + image: Optional[np.ndarray], + alignments: Optional["PNGHeaderAlignmentsDict"]) -> None: + """ Collect the histogram for the given face + + Parameters + ---------- + filename: str + The filename of the currently processing image + image: :class:`np.ndarray` + A face image loaded from disk + alignments: dict or ``None`` + The alignments dictionary for the aligned face or ``None`` + """ + if self._log_once: + msg = "Grouping" if self._is_group else "Sorting" + logger.info("%s by histogram similarity...", msg) + self._log_once = False + + assert image is not None + self._result.append((filename, self._calc_histogram(image, alignments))) + + def sort(self) -> None: + """ Sort by histogram. """ + logger.info("Comparing histograms and sorting...") + if self._is_dissim: + self._sort_dissim() + return + self._sort_sim() diff --git a/tools/sort/sort_methods_aligned.py b/tools/sort/sort_methods_aligned.py new file mode 100644 index 0000000000..e3febb2e4b --- /dev/null +++ b/tools/sort/sort_methods_aligned.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +""" Sorting methods that use the properties of a :class:`lib.align.AlignedFace` object to obtain +their sorting metrics. +""" +import logging +import operator +import sys + +from typing import Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union + +import numpy as np +from tqdm import tqdm + +from lib.align import AlignedFace +from lib.utils import FaceswapError +from .sort_methods import SortMethod + +if TYPE_CHECKING: + from argparse import Namespace + from lib.align.alignments import PNGHeaderAlignmentsDict + +logger = logging.getLogger(__name__) + + +ImgMetaType = Generator[Tuple[str, + Optional[np.ndarray], + Optional["PNGHeaderAlignmentsDict"]], None, None] + + +class SortAlignedMetric(SortMethod): # pylint:disable=too-few-public-methods + """ Sort by comparison of metrics stored in an Aligned Face objects. This is a parent class + for sort by aligned metrics methods. Individual methods should inherit from this class + + Parameters: + ---------- + arguments: :class:`argparse.Namespace` + The command line arguments passed to the sort process + sort_reverse: bool, optional + ``True`` if the sorted results should be in reverse order. Default: ``True`` + is_group: bool, optional + Set to ``True`` if this class is going to be called exclusively for binning. + Default: ``False`` + """ + def _get_metric(self, aligned_face: AlignedFace) -> Union[np.ndarray, float]: + """ Obtain the correct metric for the given sort method" + + Parameters + ---------- + aligned_face: :class:`lib.align.AlignedFace` + The aligned face to extract the metric from + + Returns + ------- + float or :class:`numpy.ndarray` + The metric for the current face based on chosen sort method + """ + raise NotImplementedError + + def sort(self) -> None: + """ Sort by metric score. Order in reverse for distance sort. """ + logger.info("Sorting...") + self._result = sorted(self._result, key=operator.itemgetter(1), reverse=True) + + def score_image(self, + filename: str, + image: Optional[np.ndarray], + alignments: Optional["PNGHeaderAlignmentsDict"]) -> None: + """ Score a single image for sort method: "distance", "yaw", "pitch" or "size" and add the + result to :attr:`_result` + + Parameters + ---------- + filename: str + The filename of the currently processing image + image: :class:`np.ndarray` or ``None`` + A face image loaded from disk or ``None`` + alignments: dict or ``None`` + The alignments dictionary for the aligned face or ``None`` + """ + if self._log_once: + msg = "Grouping" if self._is_group else "Sorting" + logger.info("%s by %s...", msg, self._method) + self._log_once = False + + if not alignments: + msg = ("The images to be sorted do not contain alignment data. Images must have " + "been generated by Faceswap's Extract process.\nIf you are sorting an " + "older faceset, then you should re-extract the faces from your source " + "alignments file to generate this data.") + raise FaceswapError(msg) + + face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32")) + self._result.append((filename, self._get_metric(face))) + + +class SortDistance(SortAlignedMetric): + """ Sorting mechanism for sorting faces from small to large """ + def _get_metric(self, aligned_face: AlignedFace) -> float: + """ Obtain the distance from mean face metric for the given face + + Parameters + ---------- + aligned_face: :class:`lib.align.AlignedFace` + The aligned face to extract the metric from + + Returns + ------- + float + The distance metric for the current face + """ + return aligned_face.average_distance + + def sort(self) -> None: + """ Override default sort to sort in ascending order. """ + logger.info("Sorting...") + self._result = sorted(self._result, key=operator.itemgetter(1), reverse=False) + + def binning(self) -> List[List[str]]: + """ Create bins to split linearly from the lowest to the highest sample value + + Returns + ------- + list + List of bins of filenames + """ + return self._binning_linear_threshold(multiplier=100) + + +class SortPitch(SortAlignedMetric): + """ Sorting mechansim for sorting a face by pitch (down to up) """ + def _get_metric(self, aligned_face: AlignedFace) -> float: + """ Obtain the pitch metric for the given face + + Parameters + ---------- + aligned_face: :class:`lib.align.AlignedFace` + The aligned face to extract the metric from + + Returns + ------- + float + The pitch metric for the current face + """ + return aligned_face.pose.pitch + + def binning(self) -> List[List[str]]: + """ Create bins from 0 degrees to 180 degrees based on number of bins + + Allocate item to bin when it is in range of one of the pre-allocated bins + + Returns + ------- + list + List of bins of filenames + """ + thresholds = (np.linspace(90, -90, self._num_bins + 1)) + + # Start bin names from 0 for more intuitive experience + names = np.flip(thresholds.astype("int")) + 90 + self._bin_names = [f"{self._method}_" + f"{idx:03d}_{int(names[idx])}" + f"degs_to_{int(names[idx + 1])}degs" + for idx in range(self._num_bins)] + + bins: List[List[str]] = [[] for _ in range(self._num_bins)] + for filename, result in self._result: + result = np.clip(result, -90.0, 90.0) + bin_idx = next(bin_id for bin_id, thresh in enumerate(thresholds) + if result >= thresh) - 1 + bins[bin_idx].append(filename) + return bins + + +class SortYaw(SortPitch): + """ Sorting mechansim for sorting a face by yaw (left to right). Same logic as sort yaw, but + with different metric """ + def _get_metric(self, aligned_face: AlignedFace) -> float: + """ Obtain the yaw metric for the given face + + Parameters + ---------- + aligned_face: :class:`lib.align.AlignedFace` + The aligned face to extract the metric from + + Returns + ------- + float + The yaw metric for the current face + """ + return aligned_face.pose.yaw + + +class SortSize(SortAlignedMetric): + """ Sorting mechanism for sorting faces from small to large """ + def _get_metric(self, aligned_face: AlignedFace) -> float: + """ Obtain the size metric for the given face + + Parameters + ---------- + aligned_face: :class:`lib.align.AlignedFace` + The aligned face to extract the metric from + + Returns + ------- + float + The size metric for the current face + """ + roi = aligned_face.original_roi + size = ((roi[1][0] - roi[0][0]) ** 2 + (roi[1][1] - roi[0][1]) ** 2) ** 0.5 + return size + + def binning(self) -> List[List[str]]: + """ Create bins to split linearly from the lowest to the highest sample value + + Allocate item to bin when it is in range of one of the pre-allocated bins + + Returns + ------- + list + List of bins of filenames + """ + return self._binning_linear_threshold(units="px") + + +class SortFaceCNN(SortAlignedMetric): + """ Sort by landmark similarity or dissimilarity + + Parameters: + ---------- + arguments: :class:`argparse.Namespace` + The command line arguments passed to the sort process + is_group: bool, optional + Set to ``True`` if this class is going to be called exclusively for binning. + Default: ``False`` + """ + def __init__(self, arguments: "Namespace", is_group: bool = False) -> None: + super().__init__(arguments, is_group=is_group) + self._is_dissim = self._method == "face-cnn-dissim" + self._threshold: float = 7.2 if arguments.threshold < 1.0 else arguments.threshold + + def _get_metric(self, aligned_face: AlignedFace) -> np.ndarray: + """ Obtain the xy aligned landmarks for the face" + + Parameters + ---------- + aligned_face: :class:`lib.align.AlignedFace` + The aligned face to extract the metric from + + Returns + ------- + float + The metric for the current face based on chosen sort method + """ + return aligned_face.landmarks + + def sort(self) -> None: + """ Sort by landmarks. """ + logger.info("Comparing landmarks and sorting...") + if self._is_dissim: + self._sort_landmarks_dissim() + return + self._sort_landmarks_ssim() + + def _sort_landmarks_ssim(self) -> None: + """ Sort landmarks by similarity """ + img_list_len = len(self._result) + for i in tqdm(range(0, img_list_len - 1), desc="Comparing", file=sys.stdout, leave=False): + min_score = float("inf") + j_min_score = i + 1 + for j in range(i + 1, img_list_len): + fl1 = self._result[i][1] + fl2 = self._result[j][1] + score = np.sum(np.absolute((fl2 - fl1).flatten())) + if score < min_score: + min_score = score + j_min_score = j + (self._result[i + 1], self._result[j_min_score]) = (self._result[j_min_score], + self._result[i + 1]) + + def _sort_landmarks_dissim(self) -> None: + """ Sort landmarks by dissimilarity """ + logger.info("Comparing landmarks...") + img_list_len = len(self._result) + for i in tqdm(range(0, img_list_len - 1), desc="Comparing", file=sys.stdout, leave=False): + score_total = 0 + for j in range(i + 1, img_list_len): + if i == j: + continue + fl1 = self._result[i][1] + fl2 = self._result[j][1] + score_total += np.sum(np.absolute((fl2 - fl1).flatten())) + self._result[i][2] = score_total + + logger.info("Sorting...") + self._result = sorted(self._result, key=operator.itemgetter(2), reverse=True) + + def binning(self) -> List[List[str]]: + """ Group into bins by CNN face similarity + + Returns + ------- + list + List of bins of filenames + """ + msg = "dissimilarity" if self._is_dissim else "similarity" + logger.info("Grouping by face-cnn %s...", msg) + + # Groups are of the form: group_num -> reference faces + reference_groups: Dict[int, List[np.ndarray]] = {} + + # Bins array, where index is the group number and value is + # an array containing the file paths to the images in that group. + bins: List[List[str]] = [] + + # Comparison threshold used to decide how similar + # faces have to be to be grouped together. + # It is multiplied by 1000 here to allow the cli option to use smaller + # numbers. + threshold = self._threshold * 1000 + img_list_len = len(self._result) + + for i in tqdm(range(0, img_list_len - 1), + desc="Grouping", + file=sys.stdout, + leave=False): + fl1 = self._result[i][1] + + current_key = -1 + current_score = float("inf") + + for key, references in reference_groups.items(): + try: + score = self._get_avg_score(fl1, references) + except TypeError: + score = float("inf") + except ZeroDivisionError: + score = float("inf") + if score < current_score: + current_key, current_score = key, score + + if current_score < threshold: + reference_groups[current_key].append(fl1[0]) + bins[current_key].append(self._result[i][0]) + else: + reference_groups[len(reference_groups)] = [self._result[i][1]] + bins.append([self._result[i][0]]) + + return bins + + @classmethod + def _get_avg_score(cls, face: np.ndarray, references: List[np.ndarray]) -> float: + """ Return the average CNN similarity score between a face and reference images + + Parameters + ---------- + face: :class:`numpy.ndarray` + The face to check against reference images + references: list + List of reference arrays to compare the face against + + Returns + ------- + float + The average score between the face and the references + """ + scores = [] + for ref in references: + score = np.sum(np.absolute((ref - face).flatten())) + scores.append(score) + return sum(scores) / len(scores) From 952d79922b1980c815bc7979a5bb75f7e7635adf Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 13 Sep 2022 18:54:01 +0100 Subject: [PATCH 727/981] Bugfixes: - Extract - batch mode. Exclude folders with no images - Train. Trigger the correct preview/mask update from gui trigger --- scripts/extract.py | 9 ++++++--- scripts/train.py | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/scripts/extract.py b/scripts/extract.py index 81bce50cec..b8e7ac1568 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -13,7 +13,7 @@ from lib.image import encode_image, generate_thumbnail, ImagesLoader, ImagesSaver from lib.multithreading import MultiThread -from lib.utils import get_folder, _video_extensions +from lib.utils import get_folder, _image_extensions, _video_extensions from plugins.extract.pipeline import Extractor, ExtractMedia from scripts.fsmedia import Alignments, PostProcess, finalize @@ -75,8 +75,11 @@ def _get_input_locations(self) -> List[str]: retval = [os.path.join(self._args.input_dir, fname) for fname in os.listdir(self._args.input_dir) - if os.path.isdir(os.path.join(self._args.input_dir, fname)) - or os.path.splitext(fname)[-1].lower() in _video_extensions] + if (os.path.isdir(os.path.join(self._args.input_dir, fname)) # folder images + and any(os.path.splitext(iname)[-1].lower() in _image_extensions + for iname in os.listdir(os.path.join(self._args.input_dir, fname)))) + or os.path.splitext(fname)[-1].lower() in _video_extensions] # video + logger.debug("Input locations: %s", retval) return retval diff --git a/scripts/train.py b/scripts/train.py index 9349e1391e..e59c815a1b 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -63,8 +63,8 @@ def __init__(self, arguments: "argparse.Namespace") -> None: gui_cache = os.path.join( os.path.realpath(os.path.dirname(sys.argv[0])), "lib", "gui", ".cache") self._gui_triggers: Dict[Literal["mask", "refresh"], str] = dict( - mask=os.path.join(gui_cache, ".preview_trigger"), - refresh=os.path.join(gui_cache, ".preview_mask_toggle")) + mask=os.path.join(gui_cache, ".preview_mask_toggle"), + refresh=os.path.join(gui_cache, ".preview_trigger")) self._stop: bool = False self._save_now: bool = False self._preview = PreviewInterface(self._args.preview) From 2d312a9db228c025d0bd2ea7a4f747a2c644b5d8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 14 Sep 2022 19:14:03 +0100 Subject: [PATCH 728/981] Minor updates and fixups - Mask Tool - Typing + BiSeNet mask update fix - Alignments Tool - Auto search for alignments file --- .../es/LC_MESSAGES/tools.alignments.cli.mo | Bin 9131 -> 9459 bytes .../es/LC_MESSAGES/tools.alignments.cli.po | 75 ++++--- locales/tools.alignments.cli.pot | 50 ++--- plugins/extract/pipeline.py | 58 ++++-- tools/alignments/alignments.py | 59 +++++- tools/alignments/cli.py | 15 +- tools/mask/mask.py | 188 +++++++++++------- 7 files changed, 297 insertions(+), 148 deletions(-) diff --git a/locales/es/LC_MESSAGES/tools.alignments.cli.mo b/locales/es/LC_MESSAGES/tools.alignments.cli.mo index 178318469501d692d03cd8cb0add718ddd8cc94f..53299df4381cac0047cf803f5ebc3f38ce8b3d28 100644 GIT binary patch delta 673 zcmY+AKWI}?6vj`|_%F7hY5GS9>WLJi*alifYSE!CrBXq#AOwrtymyn_@N#eZ-hHi^ zbkJ3lI|zz`lY@dvb{(?l;2zE{66WB01g>6v3Z zGdF*9&b=7E>uzO_xN`RRKxroJ?vLzqKPR?#K4-hhqzK$*evf-TI#fQdkfj#NsN3OK z|DUJL+VnPVnb5-;f@Nd{;&`gtK^_@w#WAq3Qd<#rAF$M((KZyJ6ZwhLM#~6K90-mp z$`k!jj7eWw88(#`z9{jE@lAxd5l$j1qJbimYq64{)b2w*UtoClJo=IUdEmM@zG+TZ M1uojjjNQxp1rgc3umAu6 delta 428 zcmXZXJxjw-6b9fowW7oiY-${&=mo(l*rBC52!amcw<3r_oowv?tO5kD4P+?9(6 zIyg8wNoEH(3sMCCfVen0=@0M~g`4}FbMCpx$=BT7%$a+958$&27-<1+;=rH*JhuT$ ztberwuXL(|TMV!}fgHo0E`Y6=qr8S?YSMMO$@`KHGk-_`i}Zt*Y2NzthE6aylh{U` z!E6%9P^%kQqm#5jFX&S%;M%|jU!3U!3ap>@b7=ZTPdHdZa7gAW13)u9r~C(8(%%7Z z2lTu#XqbA}NE(PvjjPzu+D<7DT)x`fEBVS5Tb?gur6S5+F%as&QMK^57QM%-?dE7E zmlxUGcs{EOiEAyaG5wtw(JxkCv~Sg8hO1Xoy}Dp`EKCQXCsZJ+%29#zOP;7nUy9Ik py+Bm>guX1wGK0Tw{GX>Cb_K#!fveKO*^^b*(~ov4I_f#>`~ljeUJ3vJ diff --git a/locales/es/LC_MESSAGES/tools.alignments.cli.po b/locales/es/LC_MESSAGES/tools.alignments.cli.po index 0287766d44..5407e4e795 100644 --- a/locales/es/LC_MESSAGES/tools.alignments.cli.po +++ b/locales/es/LC_MESSAGES/tools.alignments.cli.po @@ -6,26 +6,26 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-05-24 12:38+0100\n" -"PO-Revision-Date: 2022-05-24 12:41+0100\n" +"POT-Creation-Date: 2022-09-14 18:36+0100\n" +"PO-Revision-Date: 2022-09-14 18:38+0100\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es_ES\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 3.0\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.0.1\n" -#: tools/alignments/cli.py:15 +#: tools/alignments/cli.py:17 msgid "" "This command lets you perform various tasks pertaining to an alignments file." msgstr "" "Este comando le permite realizar varias tareas relacionadas con un archivo " "de alineación." -#: tools/alignments/cli.py:30 +#: tools/alignments/cli.py:32 msgid "" "Alignments tool\n" "This tool allows you to perform numerous actions on or using an alignments " @@ -36,16 +36,16 @@ msgstr "" "caras o una fuente de fotogramas, usando opcionalmente su correspondiente " "archivo de alineación." -#: tools/alignments/cli.py:41 +#: tools/alignments/cli.py:44 msgid " Must Pass in a frames folder/source video file (-fr)." msgstr "" " Debe indicar una carpeta de fotogramas o archivo de vídeo de origen (-fr)." -#: tools/alignments/cli.py:42 +#: tools/alignments/cli.py:45 msgid " Must Pass in a faces folder (-fc)." msgstr " Debe indicar una carpeta de caras (-fc)." -#: tools/alignments/cli.py:43 +#: tools/alignments/cli.py:46 msgid "" " Must Pass in either a frames folder/source video file OR afaces folder (-fr " "or -fc)." @@ -53,7 +53,7 @@ msgstr "" " Debe indicar una carpeta de fotogramas o archivo de vídeo de origen, o una " "carpeta de caras (-fr o -fc)." -#: tools/alignments/cli.py:45 +#: tools/alignments/cli.py:48 msgid "" " Must Pass in a frames folder/source video file AND a faces folder (-fr and -" "fc)." @@ -61,15 +61,15 @@ msgstr "" " Debe indicar una carpeta de fotogramas o archivo de vídeo de origen, y una " "carpeta de caras (-fr y -fc)." -#: tools/alignments/cli.py:47 +#: tools/alignments/cli.py:50 msgid " Use the output option (-o) to process results." msgstr " Usar la opción de salida (-o) para procesar los resultados." -#: tools/alignments/cli.py:55 tools/alignments/cli.py:94 +#: tools/alignments/cli.py:58 tools/alignments/cli.py:97 msgid "processing" msgstr "proceso" -#: tools/alignments/cli.py:57 +#: tools/alignments/cli.py:60 #, python-brace-format msgid "" "R|Choose which action you want to perform. NB: All actions require an " @@ -142,7 +142,7 @@ msgstr "" "L|'spatial': Realiza un filtrado espacial y temporal para suavizar las " "alineaciones (¡EXPERIMENTAL!)" -#: tools/alignments/cli.py:96 +#: tools/alignments/cli.py:99 msgid "" "R|How to output discovered items ('faces' and 'frames' only):\n" "L|'console': Print the list of frames to the screen. (DEFAULT)\n" @@ -158,37 +158,41 @@ msgstr "" "L|'move': Mueve los elementos descubiertos a una subcarpeta dentro del " "directorio de origen." -#: tools/alignments/cli.py:107 tools/alignments/cli.py:118 -#: tools/alignments/cli.py:125 +#: tools/alignments/cli.py:110 tools/alignments/cli.py:123 +#: tools/alignments/cli.py:130 tools/alignments/cli.py:149 msgid "data" msgstr "datos" -#: tools/alignments/cli.py:111 +#: tools/alignments/cli.py:114 msgid "" -"Full path to the alignments file to be processed. This is required for all " -"jobs except for 'from-faces' when the alignments file will be generated in " -"the specified faces folder." +"Full path to the alignments file to be processed. If you have input a " +"'frames_dir' and don't provide this option, the process will try to find the " +"alignments file at the default location. All jobs require an alignments file " +"with the exception of 'from-faces' when the alignments file will be " +"generated in the specified faces folder." msgstr "" -"Ruta completa del archivo de alineaciones a procesar. Esto es necesario para " -"todos los trabajos excepto para 'caras desde' cuando el archivo de " -"alineaciones se generará en la carpeta de caras especificada." +"Ruta completa al archivo de alineaciones a procesar. Si ingresó un " +"'frames_dir' y no proporciona esta opción, el proceso intentará encontrar el " +"archivo de alineaciones en la ubicación predeterminada. Todos los trabajos " +"requieren un archivo de alineaciones con la excepción de 'from-faces' cuando " +"el archivo de alineaciones se generará en la carpeta de caras especificada." -#: tools/alignments/cli.py:119 +#: tools/alignments/cli.py:124 msgid "Directory containing extracted faces." msgstr "Directorio que contiene las caras extraídas." -#: tools/alignments/cli.py:126 +#: tools/alignments/cli.py:131 msgid "Directory containing source frames that faces were extracted from." msgstr "" "Directorio que contiene los fotogramas de origen de los que se extrajeron " "las caras." -#: tools/alignments/cli.py:135 tools/alignments/cli.py:146 -#: tools/alignments/cli.py:156 +#: tools/alignments/cli.py:140 tools/alignments/cli.py:164 +#: tools/alignments/cli.py:174 msgid "extract" msgstr "extracción" -#: tools/alignments/cli.py:136 +#: tools/alignments/cli.py:141 msgid "" "[Extract only] Extract every 'nth' frame. This option will skip frames when " "extracting faces. For example a value of 1 will extract faces from every " @@ -199,11 +203,22 @@ msgstr "" "caras de cada fotograma, un valor de 10 extraerá las caras de cada 10 " "fotogramas." -#: tools/alignments/cli.py:147 +#: tools/alignments/cli.py:150 +msgid "" +"R|If selected then:\n" +"L|'frames_folder' should be a parent folder containing multiple videos/" +"folders of images you need to work on.\n" +"L|'faces_folder' should be a parent folder containing multiple folders of " +"faces you wish to manage.\n" +"L|'alignments_file'. should be a parent folder containing multiple alignment " +"files." +msgstr "" + +#: tools/alignments/cli.py:165 msgid "[Extract only] The output size of extracted faces." msgstr "[Sólo extracción] El tamaño de salida de las caras extraídas." -#: tools/alignments/cli.py:157 +#: tools/alignments/cli.py:175 msgid "" "[Extract only] Only extract faces that have been resized by this percent or " "more to meet the specified extract size (`-sz`, `--size`). Useful for " diff --git a/locales/tools.alignments.cli.pot b/locales/tools.alignments.cli.pot index 985132cecd..5e73ef8bcb 100644 --- a/locales/tools.alignments.cli.pot +++ b/locales/tools.alignments.cli.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-05-24 12:38+0100\n" +"POT-Creation-Date: 2022-09-14 18:45+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,47 +17,47 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: tools/alignments/cli.py:15 +#: tools/alignments/cli.py:17 msgid "" "This command lets you perform various tasks pertaining to an alignments file." msgstr "" -#: tools/alignments/cli.py:30 +#: tools/alignments/cli.py:32 msgid "" "Alignments tool\n" "This tool allows you to perform numerous actions on or using an alignments " "file against its corresponding faceset/frame source." msgstr "" -#: tools/alignments/cli.py:41 +#: tools/alignments/cli.py:44 msgid " Must Pass in a frames folder/source video file (-fr)." msgstr "" -#: tools/alignments/cli.py:42 +#: tools/alignments/cli.py:45 msgid " Must Pass in a faces folder (-fc)." msgstr "" -#: tools/alignments/cli.py:43 +#: tools/alignments/cli.py:46 msgid "" " Must Pass in either a frames folder/source video file OR afaces folder (-fr " "or -fc)." msgstr "" -#: tools/alignments/cli.py:45 +#: tools/alignments/cli.py:48 msgid "" " Must Pass in a frames folder/source video file AND a faces folder (-fr and -" "fc)." msgstr "" -#: tools/alignments/cli.py:47 +#: tools/alignments/cli.py:50 msgid " Use the output option (-o) to process results." msgstr "" -#: tools/alignments/cli.py:55 tools/alignments/cli.py:94 +#: tools/alignments/cli.py:58 tools/alignments/cli.py:97 msgid "processing" msgstr "" -#: tools/alignments/cli.py:57 +#: tools/alignments/cli.py:60 #, python-brace-format msgid "" "R|Choose which action you want to perform. NB: All actions require an " @@ -94,7 +94,7 @@ msgid "" "(EXPERIMENTAL!)" msgstr "" -#: tools/alignments/cli.py:96 +#: tools/alignments/cli.py:99 msgid "" "R|How to output discovered items ('faces' and 'frames' only):\n" "L|'console': Print the list of frames to the screen. (DEFAULT)\n" @@ -104,43 +104,45 @@ msgid "" "directory." msgstr "" -#: tools/alignments/cli.py:107 tools/alignments/cli.py:118 -#: tools/alignments/cli.py:125 +#: tools/alignments/cli.py:110 tools/alignments/cli.py:123 +#: tools/alignments/cli.py:130 msgid "data" msgstr "" -#: tools/alignments/cli.py:111 +#: tools/alignments/cli.py:114 msgid "" -"Full path to the alignments file to be processed. This is required for all " -"jobs except for 'from-faces' when the alignments file will be generated in " -"the specified faces folder." +"Full path to the alignments file to be processed. If you have input a " +"'frames_dir' and don't provide this option, the process will try to find the " +"alignments file at the default location. All jobs require an alignments file " +"with the exception of 'from-faces' when the alignments file will be " +"generated in the specified faces folder." msgstr "" -#: tools/alignments/cli.py:119 +#: tools/alignments/cli.py:124 msgid "Directory containing extracted faces." msgstr "" -#: tools/alignments/cli.py:126 +#: tools/alignments/cli.py:131 msgid "Directory containing source frames that faces were extracted from." msgstr "" -#: tools/alignments/cli.py:135 tools/alignments/cli.py:146 -#: tools/alignments/cli.py:156 +#: tools/alignments/cli.py:140 tools/alignments/cli.py:151 +#: tools/alignments/cli.py:161 msgid "extract" msgstr "" -#: tools/alignments/cli.py:136 +#: tools/alignments/cli.py:141 msgid "" "[Extract only] Extract every 'nth' frame. This option will skip frames when " "extracting faces. For example a value of 1 will extract faces from every " "frame, a value of 10 will extract faces from every 10th frame." msgstr "" -#: tools/alignments/cli.py:147 +#: tools/alignments/cli.py:152 msgid "[Extract only] The output size of extracted faces." msgstr "" -#: tools/alignments/cli.py:157 +#: tools/alignments/cli.py:162 msgid "" "[Extract only] Only extract faces that have been resized by this percent or " "more to meet the specified extract size (`-sz`, `--size`). Useful for " diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index c7a6f42595..bbe57c0e97 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -12,7 +12,7 @@ import logging import sys -from typing import cast, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union +from typing import Any, cast, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 @@ -54,11 +54,11 @@ class Extractor(): Parameters ---------- - detector: str + detector: str or ``None`` The name of a detector plugin as exists in :mod:`plugins.extract.detect` - aligner: str + aligner: str or ``None The name of an aligner plugin as exists in :mod:`plugins.extract.align` - masker: str or list + masker: str or list or ``None The name of a masker plugin(s) as exists in :mod:`plugins.extract.mask`. This can be a single masker or a list of multiple maskers configfile: str, optional @@ -96,9 +96,9 @@ class Extractor(): :attr:`final_pass` to indicate to the caller which phase is being processed """ def __init__(self, - detector: str, - aligner: str, - masker: Union[str, List[str]], + detector: Optional[str], + aligner: Optional[str], + masker: Optional[Union[str, List[str]]], configfile: Optional[str] = None, multiprocess: bool = False, exclude_gpus: Optional[List[int]] = None, @@ -114,8 +114,9 @@ def __init__(self, exclude_gpus, rotate_images, min_size, normalize_method, re_feed, image_is_aligned) self._instance = _get_instance() - masker = [masker] if not isinstance(masker, list) else masker - self._flow = self._set_flow(detector, aligner, masker) + maskers = [cast(Optional[str], + masker)] if not isinstance(masker, list) else cast(List[Optional[str]], masker) + self._flow = self._set_flow(detector, aligner, maskers) self._exclude_gpus = exclude_gpus # We only ever need 1 item in each queue. This is 2 items cached (1 in queue 1 waiting # for queue) at each point. Adding more just stacks RAM with no speed benefit. @@ -125,7 +126,7 @@ def __init__(self, self._vram_stats = self._get_vram_stats() self._detect = self._load_detect(detector, rotate_images, min_size, configfile) self._align = self._load_align(aligner, configfile, normalize_method, re_feed) - self._mask = [self._load_mask(mask, image_is_aligned, configfile) for mask in masker] + self._mask = [self._load_mask(mask, image_is_aligned, configfile) for mask in maskers] self._is_parallel = self._set_parallel_processing(multiprocess) self._phases = self._set_phases(multiprocess) self._phase_index = 0 @@ -381,7 +382,9 @@ def _active_plugins(self) -> List["PluginExtractor"]: return retval @staticmethod - def _set_flow(detector: str, aligner: str, masker: List[str]) -> List[str]: + def _set_flow(detector: Optional[str], + aligner: Optional[str], + masker: List[Optional[str]]) -> List[str]: """ Set the flow list based on the input plugins """ logger.debug("detector: %s, aligner: %s, masker: %s", detector, aligner, masker) retval = [] @@ -536,7 +539,7 @@ def _set_phases(self, multiprocess: bool) -> List[List[str]]: # << INTERNAL PLUGIN HANDLING >> # def _load_align(self, - aligner: str, + aligner: Optional[str], configfile: Optional[str], normalize_method: Optional[str], re_feed: int) -> Optional["Aligner"]: @@ -554,7 +557,7 @@ def _load_align(self, return plugin def _load_detect(self, - detector: str, + detector: Optional[str], rotation: Optional[List[int]], min_size: int, configfile: Optional[str]) -> Optional["Detector"]: @@ -572,7 +575,7 @@ def _load_detect(self, return plugin def _load_mask(self, - masker: str, + masker: Optional[str], image_is_aligned: bool, configfile: Optional[str]) -> Optional["Masker"]: """ Set global arguments and load masker plugin """ @@ -731,6 +734,7 @@ def __init__(self, self._image_shape = cast(Tuple[int, int, int], image.shape) self._detected_faces: List["DetectedFace"] = ([] if detected_faces is None else detected_faces) + self._frame_metadata: Dict[str, Any] = {} @property def filename(self) -> str: @@ -758,6 +762,20 @@ def detected_faces(self) -> List["DetectedFace"]: """list: A list of :class:`~lib.align.DetectedFace` objects in the :attr:`image`. """ return self._detected_faces + @property + def frame_metadata(self) -> dict: + """ dict: The frame metadata that has been added from an aligned image. This property + should only be called after :func:`add_frame_metadata` has been called when processing + an aligned face. For all other instances an assertion error will be raised. + + Raises + ------ + AssertionError + If frame metadata has not been populated from an aligned image + """ + assert self._frame_metadata is not None + return self._frame_metadata + def get_image_copy(self, color_format: Literal["BGR", "RGB", "GRAY"]) -> "np.ndarray": """ Get a copy of the image in the requested color format. @@ -812,6 +830,18 @@ def set_image(self, image: "np.ndarray") -> None: self._filename, image.shape) self._image = image + def add_frame_metadata(self, metadata: Dict[str, Any]) -> None: + """ Add the source frame metadata from an aligned PNG's header data. + + metadata: dict + The contents of the 'source' field in the PNG header + """ + logger.trace("Adding PNG Source data for '%s': %s", # type:ignore + self._filename, metadata) + dims: Tuple[int, int] = metadata["source_frame_dims"] + self._image_shape = (*dims, 3) + self._frame_metadata = metadata + def _image_as_bgr(self) -> "np.ndarray": """ Get a copy of the source frame in BGR format. diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index 010b20260e..26562c4905 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -1,13 +1,17 @@ #!/usr/bin/env python3 """ Tools for manipulating the alignments serialized file """ import logging +import os +import sys -from typing import TYPE_CHECKING +from typing import Any, TYPE_CHECKING +from lib.utils import _video_extensions from .media import AlignmentData from .jobs import (Check, Draw, Extract, FromFaces, Rename, # noqa pylint: disable=unused-import RemoveFaces, Sort, Spatial) + if TYPE_CHECKING: from argparse import Namespace @@ -27,20 +31,59 @@ class Alignments(): # pylint:disable=too-few-public-methods """ def __init__(self, arguments: "Namespace") -> None: logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) - self.args = arguments - job = self.args.job - self.alignments = None if job == "from-faces" else AlignmentData(self.args.alignments_file) + self._args = arguments + job = self._args.job + alignment_file = self._find_alignments() + self.alignments = None if job == "from-faces" else AlignmentData(alignment_file) logger.debug("Initialized %s", self.__class__.__name__) + def _find_alignments(self) -> str: + """ If an alignments folder is required and hasn't been provided, scan for a file based on + the video folder. + + Exits if an alignments file cannot be located + + Returns + ------- + str + The full path to an alignments file + """ + fname = self._args.alignments_file + frames = self._args.frames_dir + if fname and os.path.isfile(fname) and os.path.splitext(fname)[-1].lower() == ".fsa": + return fname + if fname: + logger.error("Not a valid alignments file: '%s'", fname) + sys.exit(1) + + if not frames or not os.path.exists(frames): + logger.error("Not a valid frames folder: '%s'. Can't scan for alignments.", frames) + sys.exit(1) + + fname = "alignments.fsa" + if os.path.isdir(frames) and os.path.exists(os.path.join(frames, fname)): + return fname + + if os.path.isdir(frames) or os.path.splitext(frames)[-1] not in _video_extensions: + logger.error("Can't find a valid alignments file in location: %s", frames) + sys.exit(1) + + fname = f"{os.path.splitext(frames)[0]}_{fname}" + if not os.path.exists(fname): + logger.error("Can't find a valid alignments file for video: %s", frames) + sys.exit(1) + + return fname + def process(self) -> None: """ The entry point for the Alignments tool from :mod:`lib.tools.alignments.cli`. Launches the selected alignments job. """ - if self.args.job in ("missing-alignments", "missing-frames", "multi-faces", "no-faces"): - job = Check + if self._args.job in ("missing-alignments", "missing-frames", "multi-faces", "no-faces"): + job: Any = Check else: - job = globals()[self.args.job.title().replace("-", "")] - job = job(self.alignments, self.args) + job = globals()[self._args.job.title().replace("-", "")] + job = job(self.alignments, self._args) logger.debug(job) job.process() diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py index 4ff3a5dfdf..58e4b259fe 100644 --- a/tools/alignments/cli.py +++ b/tools/alignments/cli.py @@ -3,6 +3,8 @@ import sys import gettext +from typing import Any, List, Dict + from lib.cli.args import FaceSwapArgs from lib.cli.actions import DirOrFileFullPaths, DirFullPaths, FileFullPaths, Radio, Slider @@ -30,7 +32,8 @@ def get_info() -> str: return _("Alignments tool\nThis tool allows you to perform numerous actions on or using " "an alignments file against its corresponding faceset/frame source.") - def get_argument_list(self) -> dict: + @staticmethod + def get_argument_list() -> List[Dict[str, Any]]: """ Collect the argparse argument options. Returns @@ -106,11 +109,13 @@ def get_argument_list(self) -> dict: type=str, group=_("data"), # hacky solution to not require alignments file if creating alignments from faces: - required="from-faces" not in sys.argv, + required=not any(val in sys.argv for val in ["from-faces", "-fr", "-frames_folder"]), filetypes="alignments", - help=_("Full path to the alignments file to be processed. This is required for all " - "jobs except for 'from-faces' when the alignments file will be generated in " - "the specified faces folder."))) + help=_("Full path to the alignments file to be processed. If you have input a " + "'frames_dir' and don't provide this option, the process will try to find the " + "alignments file at the default location. All jobs require an alignments file " + "with the exception of 'from-faces' when the alignments file will be generated " + "in the specified faces folder."))) argument_list.append(dict( opts=("-fc", "-faces_folder"), action=DirFullPaths, diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 6dbb2ac197..2a2c238e88 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -3,6 +3,7 @@ import logging import os import sys +from typing import Any, cast, Dict, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 import numpy as np @@ -15,6 +16,11 @@ from lib.utils import get_folder from plugins.extract.pipeline import Extractor, ExtractMedia +if TYPE_CHECKING: + from argparse import Namespace + from lib.align.aligned_face import CenteringType + from lib.align.alignments import AlignmentFileDict + from lib.queue_manager import EventQueue logger = logging.getLogger(__name__) # pylint:disable=invalid-name @@ -31,7 +37,7 @@ class Mask(): # pylint:disable=too-few-public-methods arguments: :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in from :mod:`tools.py` """ - def __init__(self, arguments): + 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" @@ -47,17 +53,18 @@ def __init__(self, arguments): self._saver = self._set_saver(arguments) loader = FacesLoader if self._input_is_faces else ImagesLoader self._loader = loader(arguments.input) - self._faces_saver = None + self._faces_saver: Optional[ImagesSaver] = None self._alignments = Alignments(os.path.dirname(arguments.alignments), filename=os.path.basename(arguments.alignments)) self._extractor = self._get_extractor(arguments.exclude_gpus) + self._set_correct_mask_type() self._extractor_input_thread = self._feed_extractor() logger.debug("Initialized %s", self.__class__.__name__) - def _check_input(self, mask_input): + def _check_input(self, mask_input: str) -> None: """ Check the input is valid. If it isn't exit with a logged error Parameters @@ -74,7 +81,7 @@ def _check_input(self, mask_input): sys.exit(0) logger.debug("input '%s' is valid", mask_input) - def _set_saver(self, arguments): + def _set_saver(self, arguments: "Namespace") -> Optional[ImagesSaver]: """ set the saver in a background thread Parameters @@ -100,7 +107,7 @@ def _set_saver(self, arguments): logger.debug(saver) return saver - def _get_extractor(self, exclude_gpus): + def _get_extractor(self, exclude_gpus: List[int]) -> Optional[Extractor]: """ Obtain a Mask extractor plugin and launch it Parameters @@ -125,7 +132,22 @@ def _get_extractor(self, exclude_gpus): logger.debug(extractor) return extractor - def _feed_extractor(self): + 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 @@ -134,17 +156,63 @@ def _feed_extractor(self): :class:`lib.multithreading.Multithread`: The thread that is feeding the extractor. """ - masker_input = getattr(self, - "_input_{}".format("faces" if self._input_is_faces else "frames")) + masker_input = getattr(self, f"_input_{'faces' if self._input_is_faces else 'frames'}") logger.debug("masker_input: %s", masker_input) - args = tuple() if self._update_type == "output" else (self._extractor.input_queue, ) + 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 _input_faces(self, *args): + def _process_face(self, + filename: str, + image: np.ndarray, + metadata: Dict[str, Any]) -> Optional["ExtractMedia"]: + """ 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"] + alignment = self._alignments.get_faces_in_frame(frame_name) + if not alignment or face_index > len(alignment) - 1: + self._counts["skip"] += 1 + logger.warning("Skipping Face not found in alignments file: '%s'", filename) + return None + alignment = alignment[face_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]) + media.add_frame_metadata(metadata["source"]) + self._counts["update"] += 1 + return media + + def _input_faces(self, *args: Union[tuple, Tuple["EventQueue"]]) -> None: """ Input pre-aligned faces to the Extractor plugin inside a thread Parameters @@ -156,7 +224,7 @@ def _input_faces(self, *args): log_once = False logger.debug("args: %s", args) if self._update_type != "output": - queue = args[0] + queue = 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 not log_once: @@ -174,35 +242,14 @@ def _input_faces(self, *args): logger.error("You can re-extract the face-set by using the Alignments Tool's " "Extract job.") break - frame_name = metadata["source"]["source_filename"] - face_index = metadata["source"]["face_index"] - alignment = self._alignments.get_faces_in_frame(frame_name) - if not alignment or face_index > len(alignment) - 1: - self._counts["skip"] += 1 - logger.warning("Skipping Face not found in alignments file: '%s'", filename) - continue - alignment = alignment[face_index] - self._counts["face"] += 1 - - if self._check_for_missing(frame_name, face_index, alignment): - continue - - detected_face = self._get_detected_face(alignment) - if self._update_type == "output": - detected_face.image = image - self._save(frame_name, face_index, detected_face) - else: - media = ExtractMedia(filename, image, detected_faces=[detected_face]) - # Hacky overload of ExtractMedia's shape parameter to apply the actual original - # frame dimension - media._image_shape = (*metadata["source"]["source_frame_dims"], 3) - setattr(media, "mask_tool_face_info", metadata["source"]) # TODO formalize + media = self._process_face(filename, image, metadata) + if media is not None: queue.put(media) - self._counts["update"] += 1 + if self._update_type != "output": queue.put("EOF") - def _input_frames(self, *args): + def _input_frames(self, *args: Union[tuple, Tuple["EventQueue"]]) -> None: """ Input frames to the Extractor plugin inside a thread Parameters @@ -213,7 +260,7 @@ def _input_frames(self, *args): """ logger.debug("args: %s", args) if self._update_type != "output": - queue = args[0] + queue = 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): @@ -245,7 +292,7 @@ def _input_frames(self, *args): if self._update_type != "output": queue.put("EOF") - def _check_for_missing(self, frame, idx, alignment): + def _check_for_missing(self, frame: str, idx: int, alignment: "AlignmentFileDict") -> bool: """ Check if the alignment is missing the requested mask_type Parameters @@ -270,7 +317,7 @@ def _check_for_missing(self, frame, idx, alignment): logger.debug("Mask pre-exists for face: '%s' - %s", frame, idx) return retval - def _get_output_suffix(self, arguments): + def _get_output_suffix(self, arguments: "Namespace") -> str: """ The filename suffix, based on selected output options. Parameters @@ -285,11 +332,11 @@ def _get_output_suffix(self, arguments): """ sfx = "mask_preview_" sfx += "face_" if not arguments.full_frame or self._input_is_faces else "frame_" - sfx += "{}.png".format(arguments.output_type) + sfx += f"{arguments.output_type}.png" return sfx @staticmethod - def _get_detected_face(alignment): + def _get_detected_face(alignment: "AlignmentFileDict") -> DetectedFace: """ Convert an alignment dict item to a detected_face object Parameters @@ -306,11 +353,12 @@ def _get_detected_face(alignment): detected_face.from_alignment(alignment) return detected_face - def process(self): + 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, "_update_{}".format("faces" if self._input_is_faces else "frames")) + 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(): @@ -320,6 +368,7 @@ def process(self): 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() @@ -337,24 +386,26 @@ def process(self): self._counts["update"], self._counts["face"]) logger.debug("Completed masker process") - def _update_faces(self, extractor_output): + 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: dict + 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.mask_tool_face_info["source_filename"] - face_index = extractor_output.mask_tool_face_info["face_index"] - logger.trace("Saving face: (frame: %s, face index: %s)", frame_name, face_index) + 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) self._alignments.update_face(frame_name, face_index, face.to_alignment()) metadata = dict(alignments=face.to_png_meta(), - source=extractor_output.mask_tool_face_info) + source=extractor_output.frame_metadata) self._faces_saver.save(extractor_output.filename, encode_image(extractor_output.image, ".png", metadata=metadata)) @@ -362,14 +413,14 @@ def _update_faces(self, extractor_output): face.image = extractor_output.image self._save(frame_name, face_index, face) - def _update_frames(self, extractor_output): + 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: dict + extractor_output: :class:`plugins.extract.pipeline.ExtractMedia` The output from the :class:`plugins.extract.pipeline.Extractor` object """ frame = os.path.basename(extractor_output.filename) @@ -379,7 +430,7 @@ def _update_frames(self, extractor_output): face.image = extractor_output.image self._save(frame, idx, face) - def _save(self, frame, idx, detected_face): + def _save(self, frame: str, idx: int, detected_face: DetectedFace) -> None: """ Build the mask preview image and save Parameters @@ -391,6 +442,7 @@ def _save(self, frame, idx, detected_face): 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: @@ -406,15 +458,14 @@ def _save(self, frame, idx, detected_face): 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, "{}_{}_{}".format( - os.path.splitext(frame)[0], - idx, - f"{mask_type}_{self._output['suffix']}")) + 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) + logger.trace("filename: '%s', image_shape: %s", filename, image.shape) # type: ignore self._saver.save(filename, image) - def _create_image(self, detected_face, mask_type): + def _create_image(self, detected_face: DetectedFace, mask_type: str) -> np.ndarray: """ Create a mask preview image for saving out to disk Parameters @@ -433,6 +484,7 @@ def _create_image(self, detected_face, mask_type): - 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: @@ -442,26 +494,28 @@ def _create_image(self, detected_face, mask_type): size=detected_face.image.shape[0], is_aligned=True).face else: - centering = "legacy" if self._alignments.version == 1.0 else mask.stored_centering + centering: "CenteringType" = ("legacy" if 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 mask = 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 - mask = mask.get_full_frame_mask(face.shape[1], face.shape[0]) - mask = np.expand_dims(mask, -1) + 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") * mask.astype("float32") / 255.).astype("uint8") - mask = np.tile(mask, 3) - for img in (face, masked, mask): + 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, mask), axis=1) + out_image = np.concatenate((face, masked, imask), axis=1) elif self._output["type"] == "mask": - out_image = mask + out_image = imask elif self._output["type"] == "masked": - out_image = np.concatenate([face, mask], axis=-1) + out_image = np.concatenate([face, imask], axis=-1) return out_image From 8a803e24c4249aa94d06291ad51deaaca62710f4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 18 Sep 2022 18:25:33 +0100 Subject: [PATCH 729/981] bugfix: Suppress OMP error on CPU Windows version --- lib/cli/launcher.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 15fb3c27d9..cf7143cbd4 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -63,6 +63,22 @@ def _set_environment_variables(self) -> None: os.environ["TF_MIN_GPU_MULTIPROCESSOR_COUNT"] = "4" os.environ["KMP_AFFINITY"] = "disabled" + # If running under CPU on Windows, the following error can be encountered: + # OMP: Error #15: Initializing libiomp5md.dll, but found libiomp5 already initialized. + # OMP: Hint This means that multiple copies of the OpenMP runtime have been linked into + # the program. That is dangerous, since it can degrade performance or cause incorrect + # results. The best thing to do is to ensure that only a single OpenMP runtime is linked + # into the process, e.g. by avoiding static linking of the OpenMP runtime in any library. + # As an unsafe, unsupported, undocumented workaround you can set the environment variable + # KMP_DUPLICATE_LIB_OK=TRUE to allow the program to continue to execute, but that may cause + # crashes or silently produce incorrect results. For more information, + # please see http://www.intel.com/software/products/support/. + # + # TODO find a better way than just allowing multiple libs + if get_backend() == "cpu" and platform.system() == "Windows": + logger.debug("Setting `KMP_DUPLICATE_LIB_OK` environment variable to `TRUE`") + os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE" + def _test_for_tf_version(self) -> None: """ Check that the required Tensorflow version is installed. From a8f22cc019d56cec18ccd8223587d97dc4b37d04 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 18 Sep 2022 19:44:41 +0100 Subject: [PATCH 730/981] Extract updates: - Default CPU detector to MTCNN - add basic Aligner false positive filters - Typing: align + plugins - Use specific AlignerBatch class for alignment - --- lib/cli/args.py | 3 +- plugins/extract/_base.py | 4 +- plugins/extract/_config.py | 45 +++- plugins/extract/align/_base.py | 360 ++++++++++++++++++++++++------- plugins/extract/align/cv2_dnn.py | 204 ++++++++++++++---- plugins/extract/align/fan.py | 229 ++++++++++++++------ plugins/extract/pipeline.py | 31 ++- scripts/extract.py | 5 +- 8 files changed, 685 insertions(+), 196 deletions(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index 90a3ee3ff1..43bee8a553 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -371,7 +371,8 @@ def get_optional_arguments() -> List[Dict[str, Any]]: The list of optional command line options for the Extract command """ if get_backend() == "cpu": - default_detector = default_aligner = "cv2-dnn" + default_detector = "mtcnn" + default_aligner = "cv2-dnn" else: default_detector = "s3fd" default_aligner = "fan" diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 647b79b132..8f44caef64 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -3,7 +3,7 @@ :mod:`~plugins.extract.mask` Plugins """ import logging - +from typing import Dict from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa from lib.multithreading import MultiThread @@ -144,7 +144,7 @@ def __init__(self, git_model_id=None, model_filename=None, exclude_gpus=None, co self._threads = [] """ list: Internal threads for this plugin """ - self._extract_media = {} + self._extract_media: Dict[str, ExtractMedia] = {} """ dict: The :class:`plugins.extract.pipeline.ExtractMedia` objects currently being processed. Stored at input for pairing back up on output of extractor process """ diff --git a/plugins/extract/_config.py b/plugins/extract/_config.py index e2e58643f1..d3151a2b7e 100644 --- a/plugins/extract/_config.py +++ b/plugins/extract/_config.py @@ -26,8 +26,51 @@ def set_globals(self): section = "global" self.add_section(title=section, info="Options that apply to all extraction plugins") self.add_item( - section=section, title="allow_growth", datatype=bool, default=False, group="settings", + section=section, + title="allow_growth", + datatype=bool, + default=False, + group="settings", info="[Nvidia Only]. Enable the Tensorflow GPU `allow_growth` configuration option. " "This option prevents Tensorflow from allocating all of the GPU VRAM at launch " "but can lead to higher VRAM fragmentation and slower performance. Should only " "be enabled if you are having problems running extraction.") + self.add_item( + section=section, + title="aligner_min_scale", + datatype=float, + min_max=(0.0, 1.0), + rounding=2, + default=0.05, + group="filters", + info="Filters out faces below this size. This is a multiplier of the minimum " + "dimension of the frame (i.e. 1280x720 = 720). If the original face extract " + "box is smaller than the minimum dimension times this multiplier, it is " + "considered a false positive and discarded. Faces which are found to be " + "unusually smaller than the frame tend to be misaligned images, except in " + "extreme long-shots. These can be usually be safely discarded.") + self.add_item( + section=section, + title="aligner_max_scale", + datatype=float, + min_max=(0.0, 10.0), + rounding=2, + default=2.00, + group="filters", + info="Filters out faces above this size. This is a multiplier of the minimum " + "dimension of the frame (i.e. 1280x720 = 720). If the original face extract " + "box is larger than the minimum dimension times this multiplier, it is " + "considered a false positive and discarded. Faces which are found to be " + "unusually larger than the frame tend to be misaligned images except in extreme " + "close-ups. These can be usually be safely discarded.") + self.add_item( + section=section, + title="aligner_distance", + datatype=float, + min_max=(0.0, 25.0), + rounding=1, + default=16, + group="filters", + info="Filters out faces who's landmarks are above this distance from an 'average' " + "face. Values above 16 tend to be fairly safe. Values above 10 will remove more " + "false positives, but may also filter out some faces at extreme angles.") diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index d030fde8b6..f2e7773c99 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -12,16 +12,59 @@ >>> "landmarks": [list of 68 point face landmarks] >>> "detected_faces": []} """ +import sys +from dataclasses import dataclass, field +from typing import Any, cast, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 import numpy as np from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa +from lib.align import AlignedFace, DetectedFace from lib.utils import get_backend, FaceswapError from plugins.extract._base import Extractor, logger, ExtractMedia +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + +if TYPE_CHECKING: + from queue import Queue + + +@dataclass +class AlignerBatch: + """ Dataclass for holding items flowing through the aligner. + + Parameters + ---------- + image: list + List of :class:`numpy.ndarray` containing the original frame + detected_faces: list + List of :class:`~lib.align.DetectedFace` objects + filename: list + List of original frame filenames for the batch + feed: list + List of feed images to feed the aligner net for each re-feed increment + prediction: list + List of predictions. Direct output from the aligner net + landmarks: list + List of 68 point :class:`numpy.ndarray` landmark points returned from the aligner + data: dict + Any aligner specific data required during the processing phase. List of dictionaries for + holding data on each sub-batch if re-feed > 1 + """ + image: List[np.ndarray] = field(default_factory=list) + detected_faces: List[DetectedFace] = field(default_factory=list) + filename: List[str] = field(default_factory=list) + feed: List[np.ndarray] = field(default_factory=list) + prediction: np.ndarray = np.empty([]) + landmarks: np.ndarray = np.empty([]) + data: List[Dict[str, Any]] = field(default_factory=list) + class Aligner(Extractor): # pylint:disable=abstract-method """ Aligner plugin _base Object @@ -55,8 +98,13 @@ class Aligner(Extractor): # pylint:disable=abstract-method plugins.extract.mask._base : Masker parent class for extraction plugins. """ - def __init__(self, git_model_id=None, model_filename=None, - configfile=None, instance=0, normalize_method=None, re_feed=0, **kwargs): + def __init__(self, + git_model_id: Optional[int] = None, + model_filename: Optional[str] = None, + configfile: Optional[str] = None, + instance: int = 0, + normalize_method: Optional[Literal["none", "clahe", "hist", "mean"]] = None, + re_feed: int = 0, **kwargs) -> None: logger.debug("Initializing %s: (normalize_method: %s, re_feed: %s)", self.__class__.__name__, normalize_method, re_feed) super().__init__(git_model_id, @@ -64,18 +112,21 @@ def __init__(self, git_model_id=None, model_filename=None, configfile=configfile, instance=instance, **kwargs) - self._normalize_method = None + self._normalize_method: Optional[Literal["clahe", "hist", "mean"]] = None self._re_feed = re_feed self.set_normalize_method(normalize_method) self._plugin_type = "align" - self._faces_per_filename = {} # Tracking for recompiling face batches - self._rollover = None # Items that are rolled over from the previous batch in get_batch - self._output_faces = [] - self._additional_keys = [] + self._faces_per_filename: Dict[str, int] = {} # Tracking for recompiling batches + self._rollover: Optional[ExtractMedia] = None # batch rollover items + self._output_faces: List[DetectedFace] = [] + self._filter = AlignedFilter(min_scale=self.config["aligner_min_scale"], + max_scale=self.config["aligner_max_scale"], + distance=self.config["aligner_distance"]) logger.debug("Initialized %s", self.__class__.__name__) - def set_normalize_method(self, method): + def set_normalize_method(self, + method: Optional[Literal["none", "clahe", "hist", "mean"]]) -> None: """ Set the normalization method for feeding faces into the aligner. Parameters @@ -84,10 +135,10 @@ def set_normalize_method(self, method): The normalization method to apply to faces prior to feeding into the model """ method = None if method is None or method.lower() == "none" else method - self._normalize_method = method + self._normalize_method = cast(Optional[Literal["clahe", "hist", "mean"]], method) # << QUEUE METHODS >>> # - def get_batch(self, queue): + def get_batch(self, queue: "Queue") -> Tuple[bool, AlignerBatch]: """ Get items for inputting into the aligner from the queue in batches Items are returned from the ``queue`` in batches of @@ -122,12 +173,13 @@ def get_batch(self, queue): A dictionary of lists of :attr:`~plugins.extract._base.Extractor.batchsize`: """ exhausted = False - batch = {} + batch = AlignerBatch() idx = 0 while idx < self.batchsize: item = self._collect_item(queue) if item == "EOF": - logger.trace("EOF received") + logger.trace("EOF received") # type:ignore + self._filter.output_counts() exhausted = True break # Put frames with no faces into the out queue to keep TQDM consistent @@ -137,9 +189,9 @@ def get_batch(self, queue): converted_image = item.get_image_copy(self.color_format) for f_idx, face in enumerate(item.detected_faces): - batch.setdefault("image", []).append(converted_image) - batch.setdefault("detected_faces", []).append(face) - batch.setdefault("filename", []).append(item.filename) + batch.image.append(converted_image) + batch.detected_faces.append(face) + batch.filename.append(item.filename) idx += 1 if idx == self.batchsize: frame_faces = len(item.detected_faces) @@ -148,36 +200,48 @@ def get_batch(self, queue): item.filename, item.image, detected_faces=item.detected_faces[f_idx + 1:]) - logger.trace("Rolled over %s faces of %s to next batch for '%s'", - len(self._rollover.detected_faces), frame_faces, + logger.trace("Rolled over %s faces of %s to next batch " # type:ignore + "for '%s'", len(self._rollover.detected_faces), frame_faces, item.filename) break if batch: - logger.trace("Returning batch: %s", {k: v.shape if isinstance(v, np.ndarray) else v - for k, v in batch.items()}) + logger.trace("Returning batch: %s", {k: v.shape # type:ignore + if isinstance(v, np.ndarray) else v + for k, v in batch.__dict__.items()}) else: - logger.trace(item) + logger.trace(item) # type:ignore return exhausted, batch - def _collect_item(self, queue): - """ Collect the item from the :attr:`_rollover` dict or from the queue - Add face count per frame to self._faces_per_filename for joining - batches back up in finalize """ + def _collect_item(self, queue: "Queue") -> Union[Literal["EOF"], ExtractMedia]: + """ Collect the item from the :attr:`_rollover` dict or from the queue. Add face count per + frame to self._faces_per_filename for joining batches back up in finalize + + Parameters + ---------- + queue: :class:`queue.Queue` + The input queue to the aligner. Should contain + :class:`~plugins.extract.pipeline.ExtractMedia` objects + + Returns + ------- + :class:`~plugins.extract.pipeline.ExtractMedia` or EOF + The next extract media object, or EOF if pipe has ended + """ if self._rollover is not None: - logger.trace("Getting from _rollover: (filename: `%s`, faces: %s)", + logger.trace("Getting from _rollover: (filename: `%s`, faces: %s)", # type:ignore self._rollover.filename, len(self._rollover.detected_faces)) item = self._rollover self._rollover = None else: item = self._get_item(queue) if item != "EOF": - logger.trace("Getting from queue: (filename: %s, faces: %s)", + logger.trace("Getting from queue: (filename: %s, faces: %s)", # type:ignore item.filename, len(item.detected_faces)) self._faces_per_filename[item.filename] = len(item.detected_faces) return item # <<< FINALIZE METHODS >>> # - def finalize(self, batch): + def finalize(self, batch: AlignerBatch) -> Generator[ExtractMedia, None, None]: """ Finalize the output from Aligner This should be called as the final task of each `plugin`. @@ -186,9 +250,8 @@ def finalize(self, batch): Parameters ---------- - batch : dict - The final ``dict`` from the `plugin` process. It must contain the `keys`: - ``detected_faces``, ``landmarks``, ``filename`` + batch : :class:`AlignerBatch` + The final batch item from the `plugin` process. Yields ------ @@ -197,32 +260,35 @@ def finalize(self, batch): and landmarks for the detected faces found in the frame. """ - for face, landmarks in zip(batch["detected_faces"], batch["landmarks"]): + for face, landmarks in zip(batch.detected_faces, batch.landmarks): if not isinstance(landmarks, np.ndarray): landmarks = np.array(landmarks) face._landmarks_xy = landmarks - logger.trace("Item out: %s", {key: val.shape if isinstance(val, np.ndarray) else val - for key, val in batch.items()}) + logger.trace("Item out: %s", {key: val.shape # type:ignore + if isinstance(val, np.ndarray) else val + for key, val in batch.__dict__.items()}) - for filename, face in zip(batch["filename"], batch["detected_faces"]): + for frame, filename, face in zip(batch.image, batch.filename, batch.detected_faces): self._output_faces.append(face) if len(self._output_faces) != self._faces_per_filename[filename]: continue + self._output_faces = self._filter(self._output_faces, min(frame.shape[:2])) + output = self._extract_media.pop(filename) output.add_detected_faces(self._output_faces) self._output_faces = [] - logger.trace("Final Output: (filename: '%s', image shape: %s, detected_faces: %s, " - "item: %s)", + logger.trace("Final Output: (filename: '%s', image shape: %s, " # type:ignore + "detected_faces: %s, item: %s)", output.filename, output.image_shape, output.detected_faces, output) yield output # <<< PROTECTED METHODS >>> # # << PROCESS_INPUT WRAPPER >> - def _process_input(self, batch): + def _process_input(self, batch: AlignerBatch) -> AlignerBatch: """ Process the input to the aligner model multiple times based on the user selected `re-feed` command line option. This adjusts the bounding box for the face to be fed into the model by a random amount within 0.05 pixels of the detected face's shortest axis. @@ -233,40 +299,32 @@ def _process_input(self, batch): Parameters ---------- - batch: dict + batch: :class:`AlignerBatch` Contains the batch that is currently being passed through the plugin process Returns ------- - dict + :class:`AlignerBatch` The batch with input processed """ - if not self._additional_keys: - existing_keys = list(batch.keys()) - original_boxes = np.array([(face.left, face.top, face.width, face.height) - for face in batch["detected_faces"]]) + for face in batch.detected_faces]) adjusted_boxes = self._get_adjusted_boxes(original_boxes) - retval = {} + + # Put in random re-feed data to the bounding boxes for bounding_boxes in adjusted_boxes: - for face, box in zip(batch["detected_faces"], bounding_boxes): + for face, box in zip(batch.detected_faces, bounding_boxes): face.left, face.top, face.width, face.height = box - result = self.process_input(batch) - if not self._additional_keys: - self._additional_keys = [key for key in result if key not in existing_keys] - for key in self._additional_keys: - retval.setdefault(key, []).append(batch[key]) - del batch[key] + self.process_input(batch) # Place the original bounding box back to detected face objects - for face, box in zip(batch["detected_faces"], original_boxes): + for face, box in zip(batch.detected_faces, original_boxes): face.left, face.top, face.width, face.height = box - batch.update(retval) return batch - def _get_adjusted_boxes(self, original_boxes): + def _get_adjusted_boxes(self, original_boxes: np.ndarray) -> np.ndarray: """ Obtain an array of adjusted bounding boxes based on the number of re-feed iterations that have been selected and the minimum dimension of the original bounding box. @@ -288,14 +346,30 @@ def _get_adjusted_boxes(self, original_boxes): rands = np.random.rand(self._re_feed, *original_boxes.shape) * 2 - 1 new_boxes = np.rint(original_boxes + (rands * max_shift[None, :, None])).astype("int32") retval = np.concatenate((original_boxes[None, ...], new_boxes)) - logger.trace(retval) + logger.trace(retval) # type:ignore return retval # <<< PREDICT WRAPPER >>> # - def _predict(self, batch): - """ Just return the aligner's predict function """ + def _predict(self, batch: AlignerBatch) -> AlignerBatch: + """ Just return the aligner's predict function + + Parameters + ---------- + batch: :class:`AlignerBatch` + The current batch to find alignments for + + Returns + ------- + :class:`AlignerBatch` + The batch item with the :attr:`prediction` populated + + Raises + ------ + FaceswapError + If GPU resources are exhausted + """ try: - batch["prediction"] = [self.predict(feed) for feed in batch["feed"]] + batch.prediction = np.array([self.predict(feed) for feed in batch.feed]) return batch except tf_errors.ResourceExhaustedError as err: msg = ("You do not have enough GPU memory available to run detection at the " @@ -325,45 +399,72 @@ def _predict(self, batch): raise FaceswapError(msg) from err raise - def _process_output(self, batch): + def _process_output(self, batch: AlignerBatch) -> AlignerBatch: """ Process the output from the aligner model multiple times based on the user selected `re-feed amount` configuration option, then average the results for final prediction. Parameters ---------- - batch : dict + batch : :class:`AlignerBatch` Contains the batch that is currently being passed through the plugin process + + Returns + ------- + :class:`AlignerBatch` + The batch item with :attr:`landmarks` populated """ landmarks = [] for idx in range(self._re_feed + 1): - subbatch = {key: val - for key, val in batch.items() - if key not in ["feed", "prediction"] + self._additional_keys} - subbatch["prediction"] = batch["prediction"][idx] - for key in self._additional_keys: - subbatch[key] = batch[key][idx] + # Create a pseudo object that only populates the data, feed and prediction slots with + # the current re-feed iteration + subbatch = AlignerBatch(image=batch.image, + detected_faces=batch.detected_faces, + filename=batch.filename, + feed=[batch.feed[idx]], + prediction=batch.prediction[idx], + data=[batch.data[idx]]) self.process_output(subbatch) - landmarks.append(subbatch["landmarks"]) - batch["landmarks"] = np.average(landmarks, axis=0) + landmarks.append(subbatch.landmarks) + batch.landmarks = np.average(landmarks, axis=0) return batch # <<< FACE NORMALIZATION METHODS >>> # - def _normalize_faces(self, faces): + def _normalize_faces(self, faces: List[np.ndarray]) -> List[np.ndarray]: """ Normalizes the face for feeding into model - The normalization method is dictated by the normalization command line argument + + Parameters + ---------- + faces: :class:`numpy.ndarray` + The faces to normalize + + Returns + ------- + :class:`numpy.ndarray` + The normalized faces """ if self._normalize_method is None: return faces - logger.trace("Normalizing faces") + logger.trace("Normalizing faces") # type:ignore meth = getattr(self, f"_normalize_{self._normalize_method.lower()}") faces = [meth(face) for face in faces] - logger.trace("Normalized faces") + logger.trace("Normalized faces") # type:ignore return faces - @staticmethod - def _normalize_mean(face): - """ Normalize Face to the Mean """ + @classmethod + def _normalize_mean(cls, face: np.ndarray) -> np.ndarray: + """ Normalize Face to the Mean + + Parameters + ---------- + faces: :class:`numpy.ndarray` + The faces to normalize + + Returns + ------- + :class:`numpy.ndarray` + The normalized faces + """ face = face / 255.0 for chan in range(3): layer = face[:, :, chan] @@ -371,17 +472,114 @@ def _normalize_mean(face): face[:, :, chan] = layer return face * 255.0 - @staticmethod - def _normalize_hist(face): - """ Equalize the RGB histogram channels """ + @classmethod + def _normalize_hist(cls, face: np.ndarray) -> np.ndarray: + """ Equalize the RGB histogram channels + + Parameters + ---------- + faces: :class:`numpy.ndarray` + The faces to normalize + + Returns + ------- + :class:`numpy.ndarray` + The normalized faces + """ for chan in range(3): face[:, :, chan] = cv2.equalizeHist(face[:, :, chan]) return face - @staticmethod - def _normalize_clahe(face): - """ Perform Contrast Limited Adaptive Histogram Equalization """ + @classmethod + def _normalize_clahe(cls, face: np.ndarray) -> np.ndarray: + """ Perform Contrast Limited Adaptive Histogram Equalization + + Parameters + ---------- + faces: :class:`numpy.ndarray` + The faces to normalize + + Returns + ------- + :class:`numpy.ndarray` + The normalized faces + """ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(4, 4)) for chan in range(3): face[:, :, chan] = clahe.apply(face[:, :, chan]) return face + + +class AlignedFilter(): + """ Applies filters on the output of the aligner + + Parameters + ---------- + min_scale: float + Filters out faces that have been aligned at below this value as a multiplier of the + minimum frame dimension. Set to ``0`` for off. + max_scale: float + Filters out faces that have been aligned at above this value as a multiplier of the + minimum frame dimension. Set to ``0`` for off. + distance: float: + Filters out faces that are further than this distance from an "average" face. Set to + ``0`` for off. + """ + def __init__(self, min_scale: float, max_scale: float, distance: float): + logger.debug("Initializing %s: (min_scale: %s, max_scale: %s, distance: %s)", + self.__class__.__name__, min_scale, max_scale, distance) + self._min_scale = min_scale + self._max_scale = max_scale + self._distance = distance / 100. + self._active = max_scale > 0.0 or min_scale > 0.0 or distance > 0.0 + self._counts: Dict[str, int] = dict(min_scale=0, max_scale=0, distance=0) + logger.debug("Initialized %s: ", self.__class__.__name__) + + def __call__(self, faces: List[DetectedFace], minimum_dimension: int) -> List[DetectedFace]: + """ Apply the filter to the incoming batch + + Parameters + ---------- + batch: list + List of detected face objects to filter out on size + minimum_dimension: int + The minimum (height, width) of the original frame + + Returns + ------- + list + The filtered list of detected face objects + + """ + if not self._active: + return faces + + max_size = minimum_dimension * self._max_scale + min_size = minimum_dimension * self._min_scale + retval: List[DetectedFace] = [] + for face in faces: + test = AlignedFace(landmarks=face.landmarks_xy, centering="face") + if self._min_scale > 0.0 or self._max_scale > 0.0: + roi = test.original_roi + size = ((roi[1][0] - roi[0][0]) ** 2 + (roi[1][1] - roi[0][1]) ** 2) ** 0.5 + if self._min_scale > 0.0 and size < min_size: + self._counts["min_scale"] += 1 + continue + if self._max_scale > 0.0 and size > max_size: + self._counts["max_scale"] += 1 + continue + if 0.0 < self._distance < test.average_distance: + self._counts["distance"] += 1 + continue + retval.append(face) + return retval + + def output_counts(self): + """ Output the counts of filtered items """ + if not self._active: + return + counts = [f"{key} ({getattr(self, f'_{key}'):.2f}): {count}" + for key, count in self._counts.items() + if count > 0] + if counts: + logger.info("Aligner filtered: [%s)", ", ".join(counts)) diff --git a/plugins/extract/align/cv2_dnn.py b/plugins/extract/align/cv2_dnn.py index 7d3b8f7725..cbaf497175 100644 --- a/plugins/extract/align/cv2_dnn.py +++ b/plugins/extract/align/cv2_dnn.py @@ -23,16 +23,20 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from typing import cast, List, Tuple, TYPE_CHECKING import cv2 import numpy as np -from ._base import Aligner, logger +from ._base import Aligner, AlignerBatch, logger + +if TYPE_CHECKING: + from lib.align.detected_face import DetectedFace class Align(Aligner): """ Perform transformation to align and get landmarks """ - def __init__(self, **kwargs): + def __init__(self, **kwargs) -> None: git_model_id = 1 model_filename = "cnn-facial-landmark_v1.pb" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) @@ -44,33 +48,81 @@ def __init__(self, **kwargs): self.vram_per_batch = 0 self.batchsize = 1 - def init_model(self): + def init_model(self) -> None: """ Initialize CV2 DNN Detector Model""" self.model = cv2.dnn.readNetFromTensorflow(self.model_path) # pylint: disable=no-member self.model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) # pylint: disable=no-member - def process_input(self, batch): - """ Compile the detected faces for prediction """ - faces, batch["roi"], batch["offsets"] = self.align_image(batch) + def process_input(self, batch: AlignerBatch) -> None: + """ Compile the detected faces for prediction + + Parameters + ---------- + batch: :class:`AlignerBatch` + The current batch to process input for + + Returns + ------- + :class:`AlignerBatch` + The batch item with the :attr:`feed` populated and any required :attr:`data` added + """ + faces, roi, offsets = self.align_image(batch) faces = self._normalize_faces(faces) - batch["feed"] = np.array(faces, dtype="float32")[..., :3].transpose((0, 3, 1, 2)) - return batch + batch.data.append(dict(roi=roi, offsets=offsets)) + batch.feed.append(np.array(faces, dtype="float32")[..., :3].transpose((0, 3, 1, 2))) + + def _get_box_and_offset(self, face: "DetectedFace") -> Tuple[List[int], int]: + """Obtain the bounding box and offset from a detected face. + + + Parameters + ---------- + face: :class:`~lib.align.DetectedFace` + The detected face object to obtain the bounding box and offset from - def align_image(self, batch): - """ Align the incoming image for prediction """ - logger.trace("Aligning image around center") + Returns + ------- + box: list + The [left, top, right, bottom] bounding box + offset: int + The offset of the box (difference between half width vs height) + """ + + box = cast(List[int], [face.left, + face.top, + face.right, + face.bottom]) + diff_height_width = cast(int, face.height) - cast(int, face.width) + offset = int(abs(diff_height_width / 2)) + return box, offset + + def align_image(self, batch: AlignerBatch) -> Tuple[List[np.ndarray], + List[List[int]], + List[Tuple[int, int]]]: + """ Align the incoming image for prediction + + Parameters + ---------- + batch: :class:`AlignerBatch` + The current batch to align the input for + + Returns + ------- + faces: list + List of feed faces for the aligner + rois: list + List of roi's for the faces + offsets: list + List of offsets for the faces + """ + logger.trace("Aligning image around center") # type:ignore sizes = (self.input_size, self.input_size) rois = [] faces = [] offsets = [] - for det_face, image in zip(batch["detected_faces"], batch["image"]): - box = (det_face.left, - det_face.top, - det_face.right, - det_face.bottom) - diff_height_width = det_face.height - det_face.width - offset_y = int(abs(diff_height_width / 2)) - box_moved = self.move_box(box, [0, offset_y]) + for det_face, image in zip(batch.detected_faces, batch.image): + box, offset_y = self._get_box_and_offset(det_face) + box_moved = self.move_box(box, (0, offset_y)) # Make box square. roi = self.get_square_box(box_moved) @@ -85,9 +137,24 @@ def align_image(self, batch): offsets.append(offset) return faces, rois, offsets - @staticmethod - def move_box(box, offset): - """Move the box to direction specified by vector offset""" + @classmethod + def move_box(cls, + box: List[int], + offset: Tuple[int, int]) -> List[int]: + """Move the box to direction specified by vector offset + + Parameters + ---------- + box: list + The (`left`, `top`, `right`, `bottom`) box positions + offset: tuple + (x, y) offset to move the box + + Returns + ------- + list + The original box shifted by the offset + """ left = box[0] + offset[0] top = box[1] + offset[1] right = box[2] + offset[0] @@ -95,8 +162,19 @@ def move_box(box, offset): return [left, top, right, bottom] @staticmethod - def get_square_box(box): - """Get a square box out of the given box, by expanding it.""" + def get_square_box(box: List[int]) -> List[int]: + """Get a square box out of the given box, by expanding it. + + Parameters + ---------- + box: list + The (`left`, `top`, `right`, `bottom`) box positions + + Returns + ------- + list + The original box but made square + """ left = box[0] top = box[1] right = box[2] @@ -127,15 +205,29 @@ def get_square_box(box): return [left, top, right, bottom] - @staticmethod - def pad_image(box, image): - """Pad image if face-box falls outside of boundaries """ + @classmethod + def pad_image(cls, box: List[int], image: np.ndarray) -> Tuple[np.ndarray, Tuple[int, int]]: + """Pad image if face-box falls outside of boundaries + + Parameters + ---------- + box: list + The (`left`, `top`, `right`, `bottom`) roi box positions + image: :class:`numpy.ndarray` + The image to be padded + + Returns + ------- + :class:`numpy.ndarray` + The padded image + """ height, width = image.shape[:2] pad_l = 1 - box[0] if box[0] < 0 else 0 pad_t = 1 - box[1] if box[1] < 0 else 0 pad_r = box[2] - width if box[2] > width else 0 pad_b = box[3] - height if box[3] > height else 0 - logger.trace("Padding: (l: %s, t: %s, r: %s, b: %s)", pad_l, pad_t, pad_r, pad_b) + logger.trace("Padding: (l: %s, t: %s, r: %s, b: %s)", # type:ignore + pad_l, pad_t, pad_r, pad_b) padded_image = cv2.copyMakeBorder(image.copy(), pad_t, pad_b, @@ -144,29 +236,61 @@ def pad_image(box, image): cv2.BORDER_CONSTANT, value=(0, 0, 0)) offsets = (pad_l - pad_r, pad_t - pad_b) - logger.trace("image_shape: %s, Padded shape: %s, box: %s, offsets: %s", + logger.trace("image_shape: %s, Padded shape: %s, box: %s, offsets: %s", # type:ignore image.shape, padded_image.shape, box, offsets) return padded_image, offsets - def predict(self, batch): - """ Predict the 68 point landmarks """ - logger.trace("Predicting Landmarks") + def predict(self, batch: AlignerBatch) -> np.ndarray: + """ Predict the 68 point landmarks + + Parameters + ---------- + batch: :class:`numpy.ndarray` + The batch to feed into the aligner + + Returns + ------- + :class:`numpy.ndarray` + The predictions from the aligner + """ + logger.trace("Predicting Landmarks") # type:ignore self.model.setInput(batch) retval = self.model.forward() return retval - def process_output(self, batch): - """ Process the output from the model """ + def process_output(self, batch: AlignerBatch) -> AlignerBatch: + """ Process the output from the model + + Parameters + ---------- + batch: :class:`AlignerBatch` + The current batch from the model with :attr:`predictions` populated + + Returns + ------- + :class:`AlignerBatch` + The current batch with the :attr:`landmarks` populated + """ self.get_pts_from_predict(batch) return batch - @staticmethod - def get_pts_from_predict(batch): - """ Get points from predictor """ - for prediction, roi, offset in zip(batch["prediction"], batch["roi"], batch["offsets"]): + @classmethod + def get_pts_from_predict(cls, batch: AlignerBatch): + """ Get points from predictor and populates the :attr:`landmarks` property + + Parameters + ---------- + batch: :class:`AlignerBatch` + The current batch from the model with :attr:`predictions` populated + """ + landmarks = [] + for prediction, roi, offset in zip(batch.prediction, + batch.data[0]["roi"], + batch.data[0]["offsets"]): points = np.reshape(prediction, (-1, 2)) points *= (roi[2] - roi[0]) points[:, 0] += (roi[0] - offset[0]) points[:, 1] += (roi[1] - offset[1]) - batch.setdefault("landmarks", []).append(points) - logger.trace("Predicted Landmarks: %s", batch["landmarks"]) + landmarks.append(points) + batch.landmarks = np.array(landmarks) + logger.trace("Predicted Landmarks: %s", batch.landmarks) # type:ignore diff --git a/plugins/extract/align/fan.py b/plugins/extract/align/fan.py index 5fa4e0b028..75708804b6 100644 --- a/plugins/extract/align/fan.py +++ b/plugins/extract/align/fan.py @@ -3,16 +3,21 @@ Code adapted and modified from: https://github.com/1adrianb/face-alignment """ +from typing import cast, List, TYPE_CHECKING + import cv2 import numpy as np from lib.model.session import KSession -from ._base import Aligner, logger +from ._base import Aligner, AlignerBatch, logger + +if TYPE_CHECKING: + from lib.align import DetectedFace class Align(Aligner): """ Perform transformation to align and get landmarks """ - def __init__(self, **kwargs): + def __init__(self, **kwargs) -> None: git_model_id = 13 model_filename = "face-alignment-network_2d4_keras_v2.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) @@ -22,10 +27,10 @@ def __init__(self, **kwargs): self.vram = 2240 self.vram_warnings = 512 # Will run at this with warnings self.vram_per_batch = 64 - self.batchsize = self.config["batch-size"] + self.batchsize: int = self.config["batch-size"] self.reference_scale = 200. / 195. - def init_model(self): + def init_model(self) -> None: """ Initialize FAN model """ self.model = KSession(self.name, self.model_path, @@ -37,69 +42,133 @@ def init_model(self): placeholder = np.zeros(placeholder_shape, dtype="float32") self.model.predict(placeholder) - def process_input(self, batch): - """ Compile the detected faces for prediction """ + def process_input(self, batch: AlignerBatch) -> None: + """ Compile the detected faces for prediction + + Parameters + ---------- + batch: :class:`AlignerBatch` + The current batch to process input for + """ logger.debug("Aligning faces around center") - batch["center_scale"] = self.get_center_scale(batch["detected_faces"]) - faces = self.crop(batch) - logger.trace("Aligned image around center") + center_scale = self.get_center_scale(batch.detected_faces) + faces = self.crop(batch, center_scale) + logger.trace("Aligned image around center") # type:ignore faces = self._normalize_faces(faces) - batch["feed"] = np.array(faces, dtype="float32")[..., :3] / 255.0 - return batch + batch.data.append(dict(center_scale=center_scale)) + batch.feed.append(np.array(faces, dtype="float32")[..., :3] / 255.0) + + def get_center_scale(self, detected_faces: List["DetectedFace"]) -> np.ndarray: + """ Get the center and set scale of bounding box - def get_center_scale(self, detected_faces): - """ Get the center and set scale of bounding box """ + Parameters + ---------- + detected_faces: list + List of :class:`~lib.align.DetectedFace` objects for the batch + + Returns + ------- + :class:`numpy.ndarray` + The center and scale of the bounding box + """ logger.debug("Calculating center and scale") center_scale = np.empty((len(detected_faces), 68, 3), dtype='float32') for index, face in enumerate(detected_faces): - x_center = (face.left + face.right) / 2.0 - y_center = (face.top + face.bottom) / 2.0 - face.height * 0.12 - scale = (face.width + face.height) * self.reference_scale + x_center = (cast(int, face.left) + face.right) / 2.0 + y_center = (cast(int, face.top) + face.bottom) / 2.0 - cast(int, face.height) * 0.12 + scale = (cast(int, face.width) + cast(int, face.height)) * self.reference_scale center_scale[index, :, 0] = np.full(68, x_center, dtype='float32') center_scale[index, :, 1] = np.full(68, y_center, dtype='float32') center_scale[index, :, 2] = np.full(68, scale, dtype='float32') - logger.trace("Calculated center and scale: %s", center_scale) + logger.trace("Calculated center and scale: %s", center_scale) # type:ignore return center_scale - def crop(self, batch): # pylint:disable=too-many-locals - """ Crop image around the center point """ + def _crop_image(self, + image: np.ndarray, + top_left: np.ndarray, + bottom_right: np.ndarray) -> np.ndarray: + """ Crop a single image + + Parameters + ---------- + image: :class:`numpy.ndarray` + The image to crop + top_left: :class:`numpy.ndarray` + The top left (x, y) point to crop from + bottom_right: :class:`numpy.ndarray` + The bottom right (x, y) point to crop to + + Returns + ------- + :class:`numpy.ndarray` + The cropped image + """ + bottom_right_width, bottom_right_height = bottom_right[0].astype('int32') + top_left_width, top_left_height = top_left[0].astype('int32') + new_dim = (bottom_right_height - top_left_height, + bottom_right_width - top_left_width, + 3 if image.ndim > 2 else 1) + new_img = np.empty(new_dim, dtype=np.uint8) + + new_x = slice(max(0, -top_left_width), + min(bottom_right_width, image.shape[1]) - top_left_width) + new_y = slice(max(0, -top_left_height), + min(bottom_right_height, image.shape[0]) - top_left_height) + old_x = slice(max(0, top_left_width), min(bottom_right_width, image.shape[1])) + old_y = slice(max(0, top_left_height), min(bottom_right_height, image.shape[0])) + new_img[new_y, new_x] = image[old_y, old_x] + + interp = cv2.INTER_CUBIC if new_dim[0] < self.input_size else cv2.INTER_AREA + return cv2.resize(new_img, + dsize=(self.input_size, self.input_size), + interpolation=interp) + + def crop(self, batch: AlignerBatch, center_scale: np.ndarray) -> List[np.ndarray]: + """ Crop image around the center point + + Parameters + ---------- + batch: :class:`AlignerBatch` + The current batch to crop the image for + center_scale: :class:`numpy.ndarray` + The center and scale for the bounding box + + Returns + ------- + list + List of cropped images for the batch + """ logger.debug("Cropping images") - sizes = (self.input_size, self.input_size) - batch_shape = batch["center_scale"].shape[:2] + batch_shape = center_scale.shape[:2] resolutions = np.full(batch_shape, self.input_size, dtype='float32') matrix_ones = np.ones(batch_shape + (3,), dtype='float32') matrix_size = np.full(batch_shape + (3,), self.input_size, dtype='float32') matrix_size[..., 2] = 1.0 - upper_left = self.transform(matrix_ones, batch["center_scale"], resolutions) - bot_right = self.transform(matrix_size, batch["center_scale"], resolutions) + upper_left = self.transform(matrix_ones, center_scale, resolutions) + bot_right = self.transform(matrix_size, center_scale, resolutions) # TODO second pass .. convert to matrix - new_images = [] - for image, top_left, bottom_right in zip(batch["image"], upper_left, bot_right): - height, width = image.shape[:2] - channels = 3 if image.ndim > 2 else 1 - bottom_right_width, bottom_right_height = bottom_right[0].astype('int32') - top_left_width, top_left_height = top_left[0].astype('int32') - new_dim = (bottom_right_height - top_left_height, - bottom_right_width - top_left_width, - channels) - new_img = np.empty(new_dim, dtype=np.uint8) - - new_x = slice(max(0, -top_left_width), min(bottom_right_width, width) - top_left_width) - new_y = slice(max(0, -top_left_height), - min(bottom_right_height, height) - top_left_height) - old_x = slice(max(0, top_left_width), min(bottom_right_width, width)) - old_y = slice(max(0, top_left_height), min(bottom_right_height, height)) - new_img[new_y, new_x] = image[old_y, old_x] - - interp = cv2.INTER_CUBIC if new_dim[0] < self.input_size else cv2.INTER_AREA - new_images.append(cv2.resize(new_img, dsize=sizes, interpolation=interp)) - logger.trace("Cropped images") + new_images = [self._crop_image(image, top_left, bottom_right) + for image, top_left, bottom_right in zip(batch.image, upper_left, bot_right)] + logger.trace("Cropped images") # type:ignore return new_images - @staticmethod - def transform(points, center_scales, resolutions): - """ Transform Image """ + @classmethod + def transform(cls, + points: np.ndarray, + center_scales: np.ndarray, + resolutions: np.ndarray) -> np.ndarray: + """ Transform Image + + Parameters + ---------- + points: :class:`numpy.ndarray` + The points to transform + center_scales: :class:`numpy.ndarray` + The calculated centers and scales for the batch + resolutions: :class:`numpy.ndarray` + The resolutions + """ logger.debug("Transforming Points") num_images, num_landmarks = points.shape[:2] transform_matrix = np.eye(3, dtype='float32') @@ -113,45 +182,79 @@ def transform(points, center_scales, resolutions): transform_matrix[:, :, 1, 2] = translations[:, :, 1] # y translation new_points = np.einsum('abij, abj -> abi', transform_matrix, points, optimize='greedy') retval = new_points[:, :, :2].astype('float32') - logger.trace("Transformed Points: %s", retval) + logger.trace("Transformed Points: %s", retval) # type:ignore return retval - def predict(self, batch): - """ Predict the 68 point landmarks """ + def predict(self, batch: np.ndarray) -> np.ndarray: + """ Predict the 68 point landmarks + + Parameters + ---------- + batch: :class:`numpy.ndarray` + The batch to feed into the aligner + + Returns + ------- + :class:`numpy.ndarray` + The predictions from the aligner + """ logger.debug("Predicting Landmarks") # TODO Remove lazy transpose and change points from predict to use the correct # order retval = self.model.predict(batch)[-1].transpose(0, 3, 1, 2) - logger.trace(retval.shape) + logger.trace(retval.shape) # type:ignore return retval - def process_output(self, batch): - """ Process the output from the model """ + def process_output(self, batch: AlignerBatch) -> AlignerBatch: + """ Process the output from the model + + Parameters + ---------- + batch: :class:`AlignerBatch` + The current batch from the model with :attr:`predictions` populated + + Returns + ------- + :class:`AlignerBatch` + The current batch with the :attr:`landmarks` populated + """ self.get_pts_from_predict(batch) return batch - def get_pts_from_predict(self, batch): - """ Get points from predictor """ + def get_pts_from_predict(self, batch: AlignerBatch): + """ Get points from predictor and populate the :attr:`landmarks` property of the + :class:`AlignerBatch` + + Parameters + ---------- + batch: :class:`AlignerBatch` + The current batch from the model with :attr:`predictions` populated + """ logger.debug("Obtain points from prediction") - num_images, num_landmarks, height, width = batch["prediction"].shape + num_images, num_landmarks = batch.prediction.shape[:2] image_slice = np.repeat(np.arange(num_images)[:, None], num_landmarks, axis=1) landmark_slice = np.repeat(np.arange(num_landmarks)[None, :], num_images, axis=0) resolution = np.full((num_images, num_landmarks), 64, dtype='int32') subpixel_landmarks = np.ones((num_images, num_landmarks, 3), dtype='float32') - flat_indices = batch["prediction"].reshape(num_images, num_landmarks, -1).argmax(-1) - indices = np.array(np.unravel_index(flat_indices, (height, width))) - min_clipped = np.minimum(indices + 1, height - 1) + indices = np.array(np.unravel_index(batch.prediction.reshape(num_images, + num_landmarks, + -1).argmax(-1), + (batch.prediction.shape[2], # height + batch.prediction.shape[3]))) # width + min_clipped = np.minimum(indices + 1, batch.prediction.shape[2] - 1) max_clipped = np.maximum(indices - 1, 0) offsets = [(image_slice, landmark_slice, indices[0], min_clipped[1]), (image_slice, landmark_slice, indices[0], max_clipped[1]), (image_slice, landmark_slice, min_clipped[0], indices[1]), (image_slice, landmark_slice, max_clipped[0], indices[1])] - x_subpixel_shift = batch["prediction"][offsets[0]] - batch["prediction"][offsets[1]] - y_subpixel_shift = batch["prediction"][offsets[2]] - batch["prediction"][offsets[3]] + x_subpixel_shift = batch.prediction[offsets[0]] - batch.prediction[offsets[1]] + y_subpixel_shift = batch.prediction[offsets[2]] - batch.prediction[offsets[3]] # TODO improve rudimentary sub-pixel logic to centroid of 3x3 window algorithm subpixel_landmarks[:, :, 0] = indices[1] + np.sign(x_subpixel_shift) * 0.25 + 0.5 subpixel_landmarks[:, :, 1] = indices[0] + np.sign(y_subpixel_shift) * 0.25 + 0.5 - batch["landmarks"] = self.transform(subpixel_landmarks, batch["center_scale"], resolution) - logger.trace("Obtained points from prediction: %s", batch["landmarks"]) + batch.landmarks = self.transform(subpixel_landmarks, + batch.data[0]["center_scale"], + resolution) + logger.trace("Obtained points from prediction: %s", batch.landmarks) # type:ignore diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index bbe57c0e97..ab63e8772a 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -76,7 +76,7 @@ class Extractor(): exactly what angles to check. Can also pass in ``'on'`` to increment at 90 degree intervals. Default: ``None`` min_size: int, optional - Used to set the :attr:`plugins.extract.detect.min_size` attribute Filters out faces + Used to set the :attr:`plugins.extract.detect.min_size` attribute. Filters out faces detected below this size. Length, in pixels across the diagonal of the bounding box. Set to ``0`` for off. Default: ``0`` normalize_method: {`None`, 'clahe', 'hist', 'mean'}, optional @@ -103,8 +103,8 @@ def __init__(self, multiprocess: bool = False, exclude_gpus: Optional[List[int]] = None, rotate_images: Optional[List[int]] = None, - min_size: int = 20, - normalize_method: Optional[str] = None, + min_size: int = 0, + normalize_method: Optional[Literal["none", "clahe", "hist", "mean"]] = None, re_feed: int = 0, image_is_aligned: bool = False) -> None: logger.debug("Initializing %s: (detector: %s, aligner: %s, masker: %s, configfile: %s, " @@ -541,9 +541,25 @@ def _set_phases(self, multiprocess: bool) -> List[List[str]]: def _load_align(self, aligner: Optional[str], configfile: Optional[str], - normalize_method: Optional[str], + normalize_method: Optional[Literal["none", "clahe", "hist", "mean"]], re_feed: int) -> Optional["Aligner"]: - """ Set global arguments and load aligner plugin """ + """ Set global arguments and load aligner plugin + + Parameters + ---------- + aligner: str + The aligner plugin to load or ``None`` for no aligner + configfile: str + Optional full path to custom config file + normalize_method: str + Optional normalization method to use + re_feed: int + The number of times to adjust the image and re-feed to get an average score + + Returns + ------- + Aligner plugin if one is specified otherwise ``None`` + """ if aligner is None or aligner.lower() == "none": logger.debug("No aligner selected. Returning None") return None @@ -637,7 +653,10 @@ def _set_extractor_batchsize(self) -> None: - plugins_required) // len(gpu_plugins) self._set_plugin_batchsize(gpu_plugins, available_vram) - def set_aligner_normalization_method(self, method: str) -> None: + def set_aligner_normalization_method(self, method: Optional[Literal["none", + "clahe", + "hist", + "mean"]]) -> None: """ Change the normalization method for faces fed into the aligner. Parameters diff --git a/scripts/extract.py b/scripts/extract.py index b8e7ac1568..40638b5358 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -83,7 +83,7 @@ def _get_input_locations(self) -> List[str]: logger.debug("Input locations: %s", retval) return retval - def _validate_batchmode(self): + def _validate_batchmode(self) -> None: """ Validate the command line arguments. If batch-mode selected and there is only one object to extract from, then batch mode is @@ -330,7 +330,8 @@ def _run_extraction(self) -> None: for idx, extract_media in enumerate(tqdm(self._extractor.detected_faces(), total=self._images.process_count, file=sys.stdout, - desc=desc)): + desc=desc, + leave=False)): self._check_thread_error() if is_final: self._output_processing(extract_media, size) From 5ba07eaa58c8926e5c7763e4ce56a0e016f91ec3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 18 Sep 2022 20:44:01 +0100 Subject: [PATCH 731/981] bugfix: Extract - don't put empty batches through aligner --- plugins/extract/_base.py | 5 ++++- plugins/extract/align/_base.py | 13 ++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 8f44caef64..c6c49cbca7 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -453,7 +453,10 @@ def _thread_process(self, function, in_queue, out_queue): # Process input items to batches exhausted, batch = self.get_batch(in_queue) if exhausted: - if batch: + # TODO Move all batch items to common dataclass. Currently migrated: + # Align + if (isinstance(batch, dict) and batch or + not isinstance(batch, dict) and batch.filename): # Put the final batch batch = function(batch) out_queue.put(batch) diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index f2e7773c99..06e685460f 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -179,7 +179,6 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, AlignerBatch]: item = self._collect_item(queue) if item == "EOF": logger.trace("EOF received") # type:ignore - self._filter.output_counts() exhausted = True break # Put frames with no faces into the out queue to keep TQDM consistent @@ -204,12 +203,16 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, AlignerBatch]: "for '%s'", len(self._rollover.detected_faces), frame_faces, item.filename) break - if batch: - logger.trace("Returning batch: %s", {k: v.shape # type:ignore - if isinstance(v, np.ndarray) else v + if batch.filename: + logger.trace("Returning batch: %s", {k: len(v) # type:ignore + if isinstance(v, list) else v for k, v in batch.__dict__.items()}) else: - logger.trace(item) # type:ignore + logger.debug(item) # type:ignore + + # TODO Move to end of process not beginning + self._filter.output_counts() + return exhausted, batch def _collect_item(self, queue: "Queue") -> Union[Literal["EOF"], ExtractMedia]: From f5fa5b43370f6641481928b378f079e2a0119249 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 19 Sep 2022 00:13:41 +0100 Subject: [PATCH 732/981] Extract filter: Allow saving of filtered images --- lib/image.py | 34 ++++++++++++++------- plugins/extract/_config.py | 8 +++++ plugins/extract/align/_base.py | 54 +++++++++++++++++++++++++--------- plugins/extract/pipeline.py | 21 +++++++++++++ scripts/extract.py | 3 +- 5 files changed, 94 insertions(+), 26 deletions(-) diff --git a/lib/image.py b/lib/image.py index bac0668626..2aed239e2b 100644 --- a/lib/image.py +++ b/lib/image.py @@ -11,6 +11,7 @@ from ast import literal_eval from bisect import bisect from concurrent import futures +from typing import Optional from zlib import crc32 import cv2 @@ -1422,7 +1423,7 @@ def _process(self, queue): executor.submit(self._save, *item) executor.shutdown() - def _save(self, filename, image): + def _save(self, filename: str, image: bytes, sub_folder: Optional[str]) -> None: """ Save a single image inside a ThreadPoolExecutor Parameters @@ -1430,21 +1431,28 @@ def _save(self, filename, image): filename: str The filename of the image to be saved. NB: Any folders passed in with the filename will be stripped and replaced with :attr:`location`. - image: numpy.ndarray - The image to be saved + image: bytes + The encoded image to be saved + subfolder: str or ``None`` + If the file should be saved in a subfolder in the output location, the subfolder should + be provided here. ``None`` for no subfolder. """ - filename = os.path.join(self.location, os.path.basename(filename)) + location = os.path.join(self.location, sub_folder) if sub_folder else self._location + if sub_folder and not os.path.exists(location): + os.makedirs(location) + + filename = os.path.join(location, os.path.basename(filename)) try: if self._as_bytes: with open(filename, "wb") as out_file: out_file.write(image) else: cv2.imwrite(filename, image) - logger.trace("Saved image: '%s'", filename) + logger.trace("Saved image: '%s'", filename) # type:ignore except Exception as err: # pylint: disable=broad-except logger.error("Failed to save image '%s'. Original Error: %s", filename, err) - def save(self, filename, image): + def save(self, filename: str, image: bytes, sub_folder: Optional[str] = None) -> None: """ Save the given image in the background thread Ensure that :func:`close` is called once all save operations are complete. @@ -1452,13 +1460,17 @@ def save(self, filename, image): Parameters ---------- filename: str - The filename of the image to be saved - image: numpy.ndarray - The image to be saved + The filename of the image to be saved. NB: Any folders passed in with the filename + will be stripped and replaced with :attr:`location`. + image: bytes + The encoded image to be saved + subfolder: str, optional + If the file should be saved in a subfolder in the output location, the subfolder should + be provided here. ``None`` for no subfolder. Default: ``None`` """ self._set_thread() - logger.trace("Putting to save queue: '%s'", filename) - self._queue.put((filename, image)) + logger.trace("Putting to save queue: '%s'", filename) # type:ignore + self._queue.put((filename, image, sub_folder)) def close(self): """ Signal to the Save Threads that they should be closed and cleanly shutdown diff --git a/plugins/extract/_config.py b/plugins/extract/_config.py index d3151a2b7e..1080a938af 100644 --- a/plugins/extract/_config.py +++ b/plugins/extract/_config.py @@ -74,3 +74,11 @@ def set_globals(self): info="Filters out faces who's landmarks are above this distance from an 'average' " "face. Values above 16 tend to be fairly safe. Values above 10 will remove more " "false positives, but may also filter out some faces at extreme angles.") + self.add_item( + section=section, + title="save_filtered", + datatype=bool, + default=False, + group="filters", + info="If enabled, saves any filtered out images into a sub-folder during the " + "extraction process. If disabled, filtered faces are deleted.") diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index 06e685460f..d5c1805f65 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -122,7 +122,8 @@ def __init__(self, self._output_faces: List[DetectedFace] = [] self._filter = AlignedFilter(min_scale=self.config["aligner_min_scale"], max_scale=self.config["aligner_max_scale"], - distance=self.config["aligner_distance"]) + distance=self.config["aligner_distance"], + save_output=self.config["save_filtered"]) logger.debug("Initialized %s", self.__class__.__name__) def set_normalize_method(self, @@ -211,7 +212,8 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, AlignerBatch]: logger.debug(item) # type:ignore # TODO Move to end of process not beginning - self._filter.output_counts() + if exhausted: + self._filter.output_counts() return exhausted, batch @@ -277,10 +279,11 @@ def finalize(self, batch: AlignerBatch) -> Generator[ExtractMedia, None, None]: if len(self._output_faces) != self._faces_per_filename[filename]: continue - self._output_faces = self._filter(self._output_faces, min(frame.shape[:2])) + self._output_faces, folders = self._filter(self._output_faces, min(frame.shape[:2])) output = self._extract_media.pop(filename) output.add_detected_faces(self._output_faces) + output.add_sub_folders(folders) self._output_faces = [] logger.trace("Final Output: (filename: '%s', image shape: %s, " # type:ignore @@ -524,21 +527,31 @@ class AlignedFilter(): max_scale: float Filters out faces that have been aligned at above this value as a multiplier of the minimum frame dimension. Set to ``0`` for off. - distance: float: + distance: float Filters out faces that are further than this distance from an "average" face. Set to ``0`` for off. + save_output: bool + ``True`` if the filtered faces should be kept as they are being saved. ``False`` if they + should be deleted """ - def __init__(self, min_scale: float, max_scale: float, distance: float): - logger.debug("Initializing %s: (min_scale: %s, max_scale: %s, distance: %s)", - self.__class__.__name__, min_scale, max_scale, distance) + def __init__(self, + min_scale: float, + max_scale: float, + distance: float, + save_output: bool) -> None: + logger.debug("Initializing %s: (min_scale: %s, max_scale: %s, distance: %s, " + "save_output: %s)", self.__class__.__name__, min_scale, max_scale, distance, + save_output) self._min_scale = min_scale self._max_scale = max_scale self._distance = distance / 100. + self._save_output = save_output self._active = max_scale > 0.0 or min_scale > 0.0 or distance > 0.0 self._counts: Dict[str, int] = dict(min_scale=0, max_scale=0, distance=0) logger.debug("Initialized %s: ", self.__class__.__name__) - def __call__(self, faces: List[DetectedFace], minimum_dimension: int) -> List[DetectedFace]: + def __call__(self, faces: List[DetectedFace], minimum_dimension: int + ) -> Tuple[List[DetectedFace], List[Optional[str]]]: """ Apply the filter to the incoming batch Parameters @@ -550,32 +563,45 @@ def __call__(self, faces: List[DetectedFace], minimum_dimension: int) -> List[De Returns ------- - list - The filtered list of detected face objects - + detected_faces: list + The filtered list of detected face objects, if saving filtered faces has not been + selected or the full list of detected faces + sub_folders: list + List of ``Nones`` if saving filtered faces has not been selected or list of ``Nones`` + and sub folder names corresponding the filtered face location """ + sub_folders: List[Optional[str]] = [None for _ in range(len(faces))] if not self._active: - return faces + return faces, sub_folders max_size = minimum_dimension * self._max_scale min_size = minimum_dimension * self._min_scale retval: List[DetectedFace] = [] - for face in faces: + for idx, face in enumerate(faces): test = AlignedFace(landmarks=face.landmarks_xy, centering="face") if self._min_scale > 0.0 or self._max_scale > 0.0: roi = test.original_roi size = ((roi[1][0] - roi[0][0]) ** 2 + (roi[1][1] - roi[0][1]) ** 2) ** 0.5 if self._min_scale > 0.0 and size < min_size: self._counts["min_scale"] += 1 + if self._save_output: + retval.append(face) + sub_folders[idx] = "_align_filt_min_scale" continue if self._max_scale > 0.0 and size > max_size: self._counts["max_scale"] += 1 + if self._save_output: + retval.append(face) + sub_folders[idx] = "_align_filt_max_scale" continue if 0.0 < self._distance < test.average_distance: self._counts["distance"] += 1 + if self._save_output: + retval.append(face) + sub_folders[idx] = "_align_filt_distance" continue retval.append(face) - return retval + return retval, sub_folders def output_counts(self): """ Output the counts of filtered items """ diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index ab63e8772a..87b1b101e6 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -754,6 +754,7 @@ def __init__(self, self._detected_faces: List["DetectedFace"] = ([] if detected_faces is None else detected_faces) self._frame_metadata: Dict[str, Any] = {} + self._sub_folders: List[Optional[str]] = [] @property def filename(self) -> str: @@ -795,6 +796,13 @@ def frame_metadata(self) -> dict: assert self._frame_metadata is not None return self._frame_metadata + @property + def sub_folders(self) -> List[Optional[str]]: + """ list: The sub_folders that the faces should be output to. Used when binning filter + output is enabled. The list corresponds to the list of detected faces + """ + return self._sub_folders + def get_image_copy(self, color_format: Literal["BGR", "RGB", "GRAY"]) -> "np.ndarray": """ Get a copy of the image in the requested color format. @@ -826,6 +834,19 @@ def add_detected_faces(self, faces: List["DetectedFace"]) -> None: [(face.left, face.right, face.top, face.bottom) for face in faces]) self._detected_faces = faces + def add_sub_folders(self, folders: List[Optional[str]]) -> None: + """ Add detected faces to the object. Called at the end of each extraction phase. + + Parameters + ---------- + folders: list + A list of str sub folder names or ``None`` if no sub folder is required. Should + correspond to the detected faces list + """ + logger.trace("Adding sub folders for filename: '%s'. " # type: ignore + "(folders: %s)", self._filename, folders,) + self._sub_folders = folders + def remove_image(self) -> None: """ Delete the image and reset :attr:`image` to ``None``. diff --git a/scripts/extract.py b/scripts/extract.py index 40638b5358..bb3aca3ced 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -414,7 +414,8 @@ def _output_faces(self, saver: Optional[ImagesSaver], extract_media: ExtractMedi image = encode_image(face.aligned.face, extension, metadata=meta) if saver is not None: - saver.save(output_filename, image) + sub_folder = extract_media.sub_folders[idx] + saver.save(output_filename, image, sub_folder) final_faces.append(face.to_alignment()) self._alignments.data[os.path.basename(extract_media.filename)] = dict(faces=final_faces) del extract_media From 71726c1ff5eaae148352889f589484e108df84e0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 19 Sep 2022 02:14:09 +0100 Subject: [PATCH 733/981] Extract: Add metric information to debug images --- scripts/fsmedia.py | 106 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 2 deletions(-) diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 6f09d96a3a..f5307fc195 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -27,6 +27,7 @@ if TYPE_CHECKING: from argparse import Namespace + from lib.align import AlignedFace from plugins.extract.pipeline import ExtractMedia logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -499,6 +500,105 @@ def __init__(self, *args, **kwargs) -> None: super().__init__(self, *args, **kwargs) self._face_size = 0 self._legacy_size = 0 + self._font = cv2.FONT_HERSHEY_SIMPLEX + self._font_scale = 0.0 + self._font_pad = 0 + + def _initialize_font(self, size: int) -> None: + """ Set the font scaling sizes on first call + + Parameters + ---------- + size: int + The pixel size of the saved aligned face + """ + self._font_scale = size / 512 + self._font_pad = size // 64 + + def _border_text(self, + image: np.ndarray, + text: str, + color: Tuple[int, int, int], + position: Tuple[int, int]) -> None: + """ Create text on an image with a black border + + Parameters + ---------- + image: :class:`numpy.ndarray` + The image to put bordered text on to + text: str + The text to place the image + color: tuple + The color of the text + position: tuple + The (x, y) co-ordinates to place the text + """ + thickness = 2 + for idx in range(2): + text_color = (0, 0, 0) if idx == 0 else color + cv2.putText(image, + text, + position, + self._font, + self._font_scale, + text_color, + thickness, + lineType=cv2.LINE_AA) + thickness //= 2 + + def _annotate_face_box(self, face: "AlignedFace") -> None: + """ Annotate the face extract box and print the original size in pixels + + face: :class:`~lib.align.AlignedFace` + The object containing the aligned face to annotate + """ + assert face.face is not None + color = (0, 255, 0) + roi = face.get_cropped_roi(face.size, self._face_size, "face") + cv2.rectangle(face.face, tuple(roi[:2]), tuple(roi[2:]), color, 1) + + # Size in top right corner + roi_pnts = np.array([[roi[0], roi[1]], + [roi[0], roi[3]], + [roi[2], roi[3]], + [roi[2], roi[1]]]) + orig_roi = face.transform_points(roi_pnts, invert=True) + size = int(round(((orig_roi[1][0] - orig_roi[0][0]) ** 2 + + (orig_roi[1][1] - orig_roi[0][1]) ** 2) ** 0.5)) + text_img = face.face.copy() + text = f"{size}px" + text_size = cv2.getTextSize(text, self._font, self._font_scale, 1)[0] + pos_x = roi[2] - (text_size[0] + self._font_pad) + pos_y = roi[1] + text_size[1] + self._font_pad + + self._border_text(text_img, text, color, (pos_x, pos_y)) + cv2.addWeighted(text_img, 0.75, face.face, 0.25, 0, face.face) + + def _print_stats(self, face: "AlignedFace") -> None: + """ Print various metrics on the output face images + + Parameters + ---------- + face: :class:`~lib.align.AlignedFace` + The loaded aligned face + """ + assert face.face is not None + text_image = face.face.copy() + texts = [f"pitch: {face.pose.pitch:.2f}", + f"yaw: {face.pose.yaw:.2f}", + f"distance: {face.average_distance:.2f}"] + colors = [(255, 0, 0), (0, 0, 255), (255, 255, 255)] + text_sizes = [cv2.getTextSize(text, self._font, self._font_scale, 1)[0] for text in texts] + init_y = self._font_pad + text_sizes[0][1] + final_y = face.size - text_sizes[-1][1] + pos_y = [init_y, init_y + text_sizes[0][1] + self._font_pad, final_y] + pos_x = self._font_pad + + for idx, text in enumerate(texts): + self._border_text(text_image, text, colors[idx], (pos_x, pos_y[idx])) + + # Apply text to face + cv2.addWeighted(text_image, 0.75, face.face, 0.25, 0, face.face) def process(self, extract_media: "ExtractMedia") -> None: """ Draw landmarks on a face. @@ -521,6 +621,8 @@ def process(self, extract_media: "ExtractMedia") -> None: "legacy", face.aligned.size) logger.debug("set legacy size: %s", self._legacy_size) + if not self._font_scale: + self._initialize_font(face.aligned.size) logger.trace("Drawing Landmarks. Frame: '%s'. Face: %s", frame, idx) # type: ignore # Landmarks @@ -533,11 +635,11 @@ def process(self, extract_media: "ExtractMedia") -> None: cv2.line(face.aligned.face, center, tuple(points[0]), (255, 0, 0), 1) cv2.line(face.aligned.face, center, tuple(points[2]), (0, 0, 255), 1) # Face centering - roi = face.aligned.get_cropped_roi(face.aligned.size, self._face_size, "face") - cv2.rectangle(face.aligned.face, tuple(roi[:2]), tuple(roi[2:]), (0, 255, 0), 1) + self._annotate_face_box(face.aligned) # Legacy centering roi = face.aligned.get_cropped_roi(face.aligned.size, self._legacy_size, "legacy") cv2.rectangle(face.aligned.face, tuple(roi[:2]), tuple(roi[2:]), (0, 0, 255), 1) + self._print_stats(face.aligned) class FaceFilter(PostProcessAction): From 50d23792ca7c0d884b4799dde2166aec974446ef Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 21 Sep 2022 01:04:33 +0100 Subject: [PATCH 734/981] Aligner filter - set sensible defaults --- locales/es/LC_MESSAGES/tools.sort.cli.mo | Bin 15170 -> 15172 bytes locales/es/LC_MESSAGES/tools.sort.cli.po | 8 ++++---- locales/tools.sort.cli.pot | 4 ++-- plugins/extract/_config.py | 10 ++++++---- scripts/extract.py | 3 +++ tools/sort/cli.py | 2 +- 6 files changed, 16 insertions(+), 11 deletions(-) diff --git a/locales/es/LC_MESSAGES/tools.sort.cli.mo b/locales/es/LC_MESSAGES/tools.sort.cli.mo index ec1ebb240a17cb378204d8df6893b15dec264bb6..22721a9c3d59854fcec48b4404e4e90f879054b8 100644 GIT binary patch delta 653 zcmXZZPe{{Y9LMp`Z%flGlTtU$wr7?$HEqh))GiVfbV-Dq5d~)bl)!B{#cG!&fn7v` zA0bhO`46O1F7ndJ4D8@VB#;i_%`V|ZItjgFonFuL{e7S3`F_8@)zP)l7ZaJZONtgr zA0;gxlRg}h28yN2mQ-<4Y9@ZWRQisS*ot# z?Duj8$GysEg~bd3-qcN^V>p1%@E1PCc%^g=e`5+;Pf0)UJ?7){DyaY`ksdUKcPMZH zzp;1wq_4PvdBh#6h_QcVGEx(ZJ(KIePr2jluVNi;;064Hjd-qF@>o(g#xQ<{%bDQX zS!om38N)JpuQ|M!Cta_X?vl69C1K({jPnTl3H;a4RHjARBCvzx>l0ZG_!%$aE;2Rw zBNA^airaV#0~jWW8Wh7VOk)XlMx{5{i}QGZ(>TljyMY~TxkdvoSy&WM)t(!r?~-(w zgc>ZzMrX+twLH#_%WoZbEO)1+Y?u3Sp*NmP*{1J7Vz|zXBu%I(VuJnlBRiG-d9<>m uprzS_LhX^ZtaW;*$Q%FiZrI#QCPwUpeV=ojd16B6!_LECd3Gi^d*UyCuw^g+ delta 653 zcmXZZPe@cz6vy%3o5}KDC6Rl4u>!Bhc^f0G$-OlwQjG(K_q?A&I#zoIzY sX2OwLi_hlXOx8cFOJ{i8Iel>zUmnUuJS|8W$@61Dq{nkpKVy diff --git a/locales/es/LC_MESSAGES/tools.sort.cli.po b/locales/es/LC_MESSAGES/tools.sort.cli.po index 1c6a138031..c3ef421275 100644 --- a/locales/es/LC_MESSAGES/tools.sort.cli.po +++ b/locales/es/LC_MESSAGES/tools.sort.cli.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-09-13 12:49+0100\n" -"PO-Revision-Date: 2022-09-13 12:54+0100\n" +"POT-Creation-Date: 2022-09-21 00:58+0100\n" +"PO-Revision-Date: 2022-09-21 00:59+0100\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es_ES\n" @@ -277,7 +277,7 @@ msgid "" "will allow Faceswap to choose the default value.\n" "L|For 'face-cnn' 7.2 should be enough, with 4 being very discriminating. \n" "L|For 'hist' 0.3 should be enough, with 0.2 being very discriminating. \n" -"L|For 'face' between 0.1 (few bins) to 0.4 (more bins) should be about " +"L|For 'face' between 0.1 (more bins) to 0.5 (fewer bins) should be about " "right.\n" "Be careful setting a value that's too extrene in a directory with many " "images, as this could result in a lot of folders being created. Defaults: " @@ -289,7 +289,7 @@ msgstr "" "-1.0 permitirá que Faceswap elija el valor predeterminado.\n" "L|Para 'face-cnn' 7.2 debería ser suficiente, siendo 4 muy discriminatorio.\n" "L|Para 'hist' 0.3 debería ser suficiente, siendo 0.2 muy discriminatorio.\n" -"L|Para 'face', entre 0,1 (pocos contenedores) y 0,4 (más contenedores) " +"L|Para 'face', entre 0,1 (más contenedores) y 0,4 (pocos contenedores) " "debería ser correcto.\n" "Tenga cuidado al establecer un valor que sea demasiado extremo en un " "directorio con muchas imágenes, ya que esto podría resultar en la creación " diff --git a/locales/tools.sort.cli.pot b/locales/tools.sort.cli.pot index 46666d0119..2ca29b2746 100644 --- a/locales/tools.sort.cli.pot +++ b/locales/tools.sort.cli.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-09-13 12:49+0100\n" +"POT-Creation-Date: 2022-09-21 00:58+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -212,7 +212,7 @@ msgid "" "will allow Faceswap to choose the default value.\n" "L|For 'face-cnn' 7.2 should be enough, with 4 being very discriminating. \n" "L|For 'hist' 0.3 should be enough, with 0.2 being very discriminating. \n" -"L|For 'face' between 0.1 (few bins) to 0.4 (more bins) should be about " +"L|For 'face' between 0.1 (more bins) to 0.5 (fewer bins) should be about " "right.\n" "Be careful setting a value that's too extrene in a directory with many " "images, as this could result in a lot of folders being created. Defaults: " diff --git a/plugins/extract/_config.py b/plugins/extract/_config.py index 1080a938af..91072b47d3 100644 --- a/plugins/extract/_config.py +++ b/plugins/extract/_config.py @@ -41,7 +41,7 @@ def set_globals(self): datatype=float, min_max=(0.0, 1.0), rounding=2, - default=0.05, + default=0.07, group="filters", info="Filters out faces below this size. This is a multiplier of the minimum " "dimension of the frame (i.e. 1280x720 = 720). If the original face extract " @@ -69,10 +69,10 @@ def set_globals(self): datatype=float, min_max=(0.0, 25.0), rounding=1, - default=16, + default=15, group="filters", info="Filters out faces who's landmarks are above this distance from an 'average' " - "face. Values above 16 tend to be fairly safe. Values above 10 will remove more " + "face. Values above 15 tend to be fairly safe. Values above 10 will remove more " "false positives, but may also filter out some faces at extreme angles.") self.add_item( section=section, @@ -81,4 +81,6 @@ def set_globals(self): default=False, group="filters", info="If enabled, saves any filtered out images into a sub-folder during the " - "extraction process. If disabled, filtered faces are deleted.") + "extraction process. If disabled, filtered faces are deleted. Note: The faces " + "will always be filtered out of the alignments file, regardless of whether you " + "keep the faces or not.") diff --git a/scripts/extract.py b/scripts/extract.py index bb3aca3ced..b3198a7f2b 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -416,6 +416,9 @@ def _output_faces(self, saver: Optional[ImagesSaver], extract_media: ExtractMedi if saver is not None: sub_folder = extract_media.sub_folders[idx] saver.save(output_filename, image, sub_folder) + if extract_media.sub_folders[idx]: # This is a filtered out face being binned + continue final_faces.append(face.to_alignment()) + self._alignments.data[os.path.basename(extract_media.filename)] = dict(faces=final_faces) del extract_media diff --git a/tools/sort/cli.py b/tools/sort/cli.py index 0c54c7d9a2..65b1edc3e3 100644 --- a/tools/sort/cli.py +++ b/tools/sort/cli.py @@ -165,7 +165,7 @@ def get_argument_list(): "-1.0 will allow Faceswap to choose the default value." "\nL|For 'face-cnn' 7.2 should be enough, with 4 being very discriminating. " "\nL|For 'hist' 0.3 should be enough, with 0.2 being very discriminating. " - "\nL|For 'face' between 0.1 (few bins) to 0.4 (more bins) should " + "\nL|For 'face' between 0.1 (more bins) to 0.5 (fewer bins) should " "be about right." "\nBe careful setting a value that's too extrene in a directory " "with many images, as this could result in a lot of folders being created. " From de11b4c189f79e9ecd39753e99376430e87ad578 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 23 Sep 2022 01:37:51 +0100 Subject: [PATCH 735/981] Bugfix: Extract. numbering issue when binning results --- scripts/extract.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/scripts/extract.py b/scripts/extract.py index b3198a7f2b..171f85f28e 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -402,21 +402,28 @@ def _output_faces(self, saver: Optional[ImagesSaver], extract_media: ExtractMedi filename = os.path.splitext(os.path.basename(extract_media.filename))[0] extension = ".png" - for idx, face in enumerate(extract_media.detected_faces): - output_filename = f"{filename}_{idx}{extension}" + skip_idx = 0 + for face_id, face in enumerate(extract_media.detected_faces): + real_face_id = face_id - skip_idx + output_filename = f"{filename}_{real_face_id}{extension}" meta = dict(alignments=face.to_png_meta(), source=dict(alignments_version=self._alignments.version, original_filename=output_filename, - face_index=idx, + face_index=real_face_id, source_filename=os.path.basename(extract_media.filename), source_is_video=self._images.is_video, source_frame_dims=extract_media.image_size)) image = encode_image(face.aligned.face, extension, metadata=meta) + sub_folder = extract_media.sub_folders[face_id] + # Binned faces shouldn't risk filename clash, so just use original id + out_name = output_filename if not sub_folder else f"{filename}_{face_id}{extension}" + if saver is not None: - sub_folder = extract_media.sub_folders[idx] - saver.save(output_filename, image, sub_folder) - if extract_media.sub_folders[idx]: # This is a filtered out face being binned + saver.save(out_name, image, sub_folder) + + if sub_folder: # This is a filtered out face being binned + skip_idx += 1 continue final_faces.append(face.to_alignment()) From 892d8626ed4e7f834ac5607af59f14f5476d5997 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 23 Sep 2022 13:23:53 +0100 Subject: [PATCH 736/981] Bugfix: Alignments tool - don't error on from-faces job --- setup.py | 2 +- tools/alignments/alignments.py | 8 ++- tools/alignments/jobs.py | 96 +++++++++++++++++++--------------- 3 files changed, 62 insertions(+), 44 deletions(-) diff --git a/setup.py b/setup.py index fcee94baf4..6505895fab 100755 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ class Environment(): Parameters ---------- updater: bool, Optional - ``True`` of the script is being called by Faceswap's internal updater. ``False`` if full + ``True`` if the script is being called by Faceswap's internal updater. ``False`` if full setup is running. Default: ``False`` """ def __init__(self, updater: bool = False) -> None: diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index 26562c4905..3ad9636204 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -33,8 +33,12 @@ def __init__(self, arguments: "Namespace") -> None: logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) self._args = arguments job = self._args.job - alignment_file = self._find_alignments() - self.alignments = None if job == "from-faces" else AlignmentData(alignment_file) + + if job == "from-faces": + self.alignments = None + else: + self.alignments = AlignmentData(self._find_alignments()) + logger.debug("Initialized %s", self.__class__.__name__) def _find_alignments(self) -> str: diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index bfc3b75579..98640497f0 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -5,7 +5,7 @@ import os import sys from datetime import datetime -from typing import List, Tuple, TYPE_CHECKING, Optional +from typing import Dict, List, Tuple, TYPE_CHECKING, Optional from argparse import Namespace @@ -16,7 +16,7 @@ from tqdm import tqdm from lib.align import DetectedFace, _EXTRACT_RATIOS -from lib.align.alignments import _VERSION +from lib.align.alignments import _VERSION, AlignmentFileDict from lib.image import (encode_image, generate_thumbnail, ImagesSaver, read_image_meta_batch, update_existing_metadata) from plugins.extract.pipeline import Extractor, ExtractMedia @@ -107,7 +107,7 @@ def _get_no_faces(self): """ yield each frame that has no face match in alignments file """ self.output_message = "Frames with no faces" for frame in tqdm(self._items, desc=self.output_message): - logger.trace(frame) + logger.trace(frame) # type:ignore frame_name = frame["frame_fullname"] if not self._alignments.frame_has_faces(frame_name): logger.debug("Returning: '%s'", frame_name) @@ -127,7 +127,7 @@ def _get_multi_faces_frames(self): filename = item["frame_fullname"] if not self._alignments.frame_has_multiple_faces(filename): continue - logger.trace("Returning: '%s'", filename) + logger.trace("Returning: '%s'", filename) # type:ignore yield filename def _get_multi_faces_faces(self): @@ -137,7 +137,7 @@ def _get_multi_faces_faces(self): if not self._alignments.frame_has_multiple_faces(item["source_filename"]): continue retval = (item["current_filename"], item["face_index"]) - logger.trace("Returning: '%s'", retval) + logger.trace("Returning: '%s'", retval) # type:ignore yield retval def _get_missing_alignments(self): @@ -163,7 +163,7 @@ def _get_missing_frames(self): def _output_results(self, items_output): """ Output the results in the requested format """ - logger.trace("items_output: %s", items_output) + logger.trace("items_output: %s", items_output) # type:ignore if self._output == "move" and self._is_video and self._type == "frames": logger.warning("Move was selected with an input video. This is not possible so " "falling back to console output") @@ -306,7 +306,7 @@ def process(self): frame_name = frame["frame_fullname"] if not self._alignments.frame_exists(frame_name): - logger.verbose("Skipping '%s' - Alignments not found", frame_name) + logger.verbose("Skipping '%s' - Alignments not found", frame_name) # type:ignore continue self._annotate_image(frame_name) @@ -321,7 +321,7 @@ def _annotate_image(self, frame_name): frame_name: str The full path to the original frame """ - logger.trace("Annotating frame: '%s'", frame_name) + logger.trace("Annotating frame: '%s'", frame_name) # type:ignore image = self._frames.load_image(frame_name) for idx, alignment in enumerate(self._alignments.get_faces_in_frame(frame_name)): @@ -410,7 +410,7 @@ def __init__(self, alignments: "AlignmentData", arguments: Namespace) -> None: self._arguments = arguments self._alignments = alignments self._is_legacy = self._alignments.version == 1.0 # pylint:disable=protected-access - self._mask_pipeline = None + self._mask_pipeline: Optional[Extractor] = None self._faces_dir = arguments.faces_dir self._min_size = self._get_min_size(arguments.size, arguments.min_size) @@ -418,7 +418,7 @@ def __init__(self, alignments: "AlignmentData", arguments: Namespace) -> None: self._extracted_faces = ExtractedFaces(self._frames, self._alignments, size=arguments.size) - self._saver = None + self._saver: Optional[ImagesSaver] = None logger.debug("Initialized %s", self.__class__.__name__) @classmethod @@ -487,7 +487,7 @@ def _check_folder(self) -> None: if err: logger.error(err) sys.exit(0) - logger.verbose("Creating output folder at '%s'", self._faces_dir) + logger.verbose("Creating output folder at '%s'", self._faces_dir) # type:ignore def _legacy_check(self) -> None: """ Check whether the alignments file was created with the legacy extraction method. @@ -528,7 +528,7 @@ def _export_faces(self) -> None: total=count, desc="Saving extracted faces"): frame_name = os.path.basename(filename) if not self._alignments.frame_exists(frame_name): - logger.verbose("Skipping '%s' - Alignments not found", frame_name) + logger.verbose("Skipping '%s' - Alignments not found", frame_name) # type:ignore continue extracted_faces += self._output_faces(frame_name, image) if self._is_legacy and extracted_faces != 0 and self._min_size == 0: @@ -552,8 +552,8 @@ def _set_skip_list(self) -> Optional[List[int]]: skip_list = [] for idx, item in enumerate(self._frames.file_list_sorted): if idx % skip_num != 0: - logger.trace("Adding image '%s' to skip list due to extract_every_n = %s", - item["frame_fullname"], skip_num) + logger.trace("Adding image '%s' to skip list due to " # type:ignore + "extract_every_n = %s", item["frame_fullname"], skip_num) skip_list.append(idx) logger.debug("Adding skip list: %s", skip_list) return skip_list @@ -573,10 +573,11 @@ def _output_faces(self, filename: str, image: np.ndarray) -> int: int The total number of faces that have been extracted """ - logger.trace("Outputting frame: %s", filename) + logger.trace("Outputting frame: %s", filename) # type:ignore face_count = 0 frame_name = os.path.splitext(filename)[0] faces = self._select_valid_faces(filename, image) + assert self._saver is not None if not faces: return face_count if self._is_legacy: @@ -621,7 +622,7 @@ def _select_valid_faces(self, frame: str, image: np.ndarray) -> List[DetectedFac sizes = self._extracted_faces.get_roi_size_for_frame(frame) valid_faces = [faces[idx] for idx, size in enumerate(sizes) if size >= self._min_size] - logger.trace("frame: '%s', total_faces: %s, valid_faces: %s", + logger.trace("frame: '%s', total_faces: %s, valid_faces: %s", # type:ignore frame, len(faces), len(valid_faces)) return valid_faces @@ -648,6 +649,7 @@ def _process_legacy(self, The updated list of :class:`lib.align.DetectedFace` objects for the current frame """ # Update landmarks based masks for face centering + assert self._mask_pipeline is not None mask_item = ExtractMedia(filename, image, detected_faces=detected_faces) self._mask_pipeline.input_queue.put(mask_item) faces = next(self._mask_pipeline.detected_faces()).detected_faces @@ -747,14 +749,14 @@ def process(self) -> None: """ Run the job to read faces from a folder to create alignments file(s). """ logger.info("[CREATE ALIGNMENTS FROM FACES]") # Tidy up cli output skip_count = 0 - d_align = {} + d_align: Dict[str, Dict[str, List[Tuple[int, AlignmentFileDict, str, dict]]]] = {} for filename, meta in tqdm(read_image_meta_batch(self._filelist), desc="Generating Alignments", total=len(self._filelist), leave=False): if "itxt" not in meta or "alignments" not in meta["itxt"]: - logger.verbose("skipping invalid file: '%s'", filename) + logger.verbose("skipping invalid file: '%s'", filename) # type:ignore skip_count += 1 continue @@ -792,10 +794,10 @@ def _get_alignments_filename(cls, source_data: dict) -> str: src_name = source_data["source_filename"] prefix = f"{src_name.rpartition('_')[0]}_" if is_video else "" retval = f"{prefix}alignments.fsa" - logger.trace("Extracted alignments file filename: '%s'", retval) + logger.trace("Extracted alignments file filename: '%s'", retval) # type:ignore return retval - def _extract_alignment(self, metadata: dict) -> Tuple[str, int, dict]: + def _extract_alignment(self, metadata: dict) -> Tuple[str, int, AlignmentFileDict]: """ Extract alignment data from a PNG image's itxt header. Formats the landmarks into a numpy array and adds in mask centering information if it is @@ -822,11 +824,12 @@ def _extract_alignment(self, metadata: dict) -> Tuple[str, int, dict]: version = src["alignments_version"] if version < 2.2: - logger.trace("Updating mask centering for frame '%s', face index: %s, version: %s", - frame_name, face_index, version) + logger.trace("Updating mask centering for frame '%s', face index: %s, " # type:ignore + "version: %s", frame_name, face_index, version) self._update_mask_centering(alignment) - logger.trace("Extracted alignment for frame: '%s', face index: %s", frame_name, face_index) + logger.trace("Extracted alignment for frame: '%s', face index: %s", # type:ignore + frame_name, face_index) return frame_name, face_index, alignment @classmethod @@ -845,7 +848,12 @@ def _update_mask_centering(cls, alignment: dict) -> None: for mask in alignment["mask"].values(): mask["stored_centering"] = "face" - def _sort_alignments(self, alignments: dict) -> dict: + def _sort_alignments(self, + alignments: Dict[str, Dict[str, List[Tuple[int, + AlignmentFileDict, + str, + dict]]]] + ) -> Dict[str, Dict[str, List[AlignmentFileDict]]] : """ Sort the faces into face index order as they appeared in the original alignments file. If the face index stored in the png header does not match it's position in the alignments @@ -862,12 +870,12 @@ def _sort_alignments(self, alignments: dict) -> dict: Returns ------- dict - The alignments file dictionaries sorted into the correct face order, ready for savind + The alignments file dictionaries sorted into the correct face order, ready for saving """ logger.info("Sorting and checking faces...") - aln_sorted = {} + aln_sorted: Dict[str, Dict[str, List[AlignmentFileDict]]] = {} for fname, frames in alignments.items(): - this_file = {} + this_file: Dict[str, List[AlignmentFileDict]] = {} for frame in tqdm(sorted(frames), desc=f"Sorting {fname}", leave=False): this_file[frame] = [] for real_idx, (f_id, alignment, f_path, f_src) in enumerate(sorted(frames[frame])): @@ -881,7 +889,7 @@ def _sort_alignments(self, alignments: dict) -> dict: def _update_png_header(cls, face_path: str, new_index: int, - alignment: dict, + alignment: AlignmentFileDict, source_info: dict) -> None: """ Update the PNG header for faces where the stored index does not correspond with the alignments file. This can occur when frames with multiple faces have had some faces deleted @@ -904,9 +912,9 @@ def _update_png_header(cls, face.from_alignment(alignment) new_filename = f"{os.path.splitext(source_info['source_filename'])[0]}_{new_index}.png" - logger.trace("Updating png header for '%s': (face index from %s to %s, original filename " - "from '%s' to '%s'", face_path, source_info["face_index"], new_index, - source_info["original_filename"], new_filename) + logger.trace("Updating png header for '%s': (face index from %s to %s, " # type:ignore + "original filename from '%s' to '%s'", face_path, source_info["face_index"], + new_index, source_info["original_filename"], new_filename) source_info["face_index"] = new_index source_info["original_filename"] = new_filename @@ -1036,7 +1044,7 @@ class Rename(): # pylint:disable=too-few-public-methods """ def __init__(self, alignments: "AlignmentData", - arguments: Namespace, + arguments: Optional[Namespace], faces: Optional[Faces] = None) -> None: logger.debug("Initializing %s: (arguments: %s, faces: %s)", self.__class__.__name__, arguments, faces) @@ -1046,7 +1054,11 @@ def __init__(self, if alignments.version < 2.1: # Update headers of faces generated with hash based alignments kwargs["alignments"] = alignments - self._faces = faces if faces else Faces(arguments.faces_dir, **kwargs) + if faces: + self._faces = faces + else: + assert arguments is not None + self._faces = Faces(arguments.faces_dir, **kwargs) logger.debug("Initialized %s", self.__class__.__name__) def process(self) -> None: @@ -1092,7 +1104,7 @@ def _rename_faces(self, filename_mappings: List[Tuple[str, str]]) -> int: new = new + ".tmp" conflicts.append(new) - logger.verbose("Renaming '%s' to '%s'", old, new) + logger.verbose("Renaming '%s' to '%s'", old, new) # type:ignore os.rename(old, new) rename_count += 1 if conflicts: @@ -1103,7 +1115,7 @@ def _rename_faces(self, filename_mappings: List[Tuple[str, str]]) -> int: # then the user has done something stupid, so we will delete the file and # replace. They can always re-extract :/ os.remove(new) - logger.verbose("Renaming '%s' to '%s'", old, new) + logger.verbose("Renaming '%s' to '%s'", old, new) # type:ignore os.rename(old, new) return rename_count @@ -1132,20 +1144,21 @@ def process(self) -> None: logger.warning("If you have a face-set corresponding to the alignment file you " "processed then you should run the 'Extract' job to regenerate it.") - def reindex_faces(self) -> None: + def reindex_faces(self) -> int: """ Re-Index the faces """ reindexed = 0 for alignment in tqdm(self._alignments.yield_faces(), desc="Sort alignment indexes", total=self._alignments.frames_count): frame, alignments, count, key = alignment if count <= 1: - logger.trace("0 or 1 face in frame. Not sorting: '%s'", frame) + logger.trace("0 or 1 face in frame. Not sorting: '%s'", frame) # type:ignore continue sorted_alignments = sorted(alignments, key=lambda x: (x["x"])) if sorted_alignments == alignments: - logger.trace("Alignments already in correct order. Not sorting: '%s'", frame) + logger.trace("Alignments already in correct order. Not " # type:ignore + "sorting: '%s'", frame) continue - logger.trace("Sorting alignments for frame: '%s'", frame) + logger.trace("Sorting alignments for frame: '%s'", frame) # type:ignore self._alignments.data[key]["faces"] = sorted_alignments reindexed += 1 logger.info("%s Frames had their faces reindexed", reindexed) @@ -1301,9 +1314,10 @@ def update_alignments(self, landmarks): """ Update smoothed landmarks back to alignments """ logger.debug("Update alignments") for idx, frame in tqdm(self.mappings.items(), desc="Updating"): - logger.trace("Updating: (frame: %s)", frame) + logger.trace("Updating: (frame: %s)", frame) # type:ignore landmarks_update = landmarks[:, :, idx] landmarks_xy = landmarks_update.reshape(68, 2).tolist() self._alignments.data[frame]["faces"][0]["landmarks_xy"] = landmarks_xy - logger.trace("Updated: (frame: '%s', landmarks: %s)", frame, landmarks_xy) + logger.trace("Updated: (frame: '%s', landmarks: %s)", # type:ignore + frame, landmarks_xy) logger.debug("Updated alignments") From 1c8eb24b38e87e42088dd393ae0573460c3476e9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 23 Sep 2022 14:14:29 +0100 Subject: [PATCH 737/981] Bugfix: Correct naming of alignments file for videos with dots in filename --- scripts/fsmedia.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index f5307fc195..34ee1b909d 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -112,7 +112,7 @@ def _set_folder_filename(self, input_is_video: bool) -> Tuple[str, str]: elif input_is_video: logger.debug("Alignments from Video File: '%s'", self._args.input_dir) folder, filename = os.path.split(self._args.input_dir) - filename = f"{os.path.splitext(filename)[0]}_alignments" + filename = f"{os.path.splitext(filename)[0]}_alignments.fsa" else: logger.debug("Alignments from Input Folder: '%s'", self._args.input_dir) folder = str(self._args.input_dir) @@ -586,12 +586,14 @@ def _print_stats(self, face: "AlignedFace") -> None: text_image = face.face.copy() texts = [f"pitch: {face.pose.pitch:.2f}", f"yaw: {face.pose.yaw:.2f}", + f"roll: {face.pose.roll: .2f}", f"distance: {face.average_distance:.2f}"] - colors = [(255, 0, 0), (0, 0, 255), (255, 255, 255)] + colors = [(255, 0, 0), (0, 0, 255), (0, 255, 0), (255, 255, 255)] text_sizes = [cv2.getTextSize(text, self._font, self._font_scale, 1)[0] for text in texts] - init_y = self._font_pad + text_sizes[0][1] + final_y = face.size - text_sizes[-1][1] - pos_y = [init_y, init_y + text_sizes[0][1] + self._font_pad, final_y] + pos_y = [(size[1] + self._font_pad) * (idx + 1) + for idx, size in enumerate(text_sizes)][:-1] + [final_y] pos_x = self._font_pad for idx, text in enumerate(texts): From a7d0898f64adc9816427c4923074c7955ce95ac8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 23 Sep 2022 14:18:48 +0100 Subject: [PATCH 738/981] sort tool: Add sort by roll --- lib/align/aligned_face.py | 31 +++++++---- locales/es/LC_MESSAGES/tools.sort.cli.mo | Bin 15172 -> 15559 bytes locales/es/LC_MESSAGES/tools.sort.cli.po | 68 +++++++++++++---------- locales/tools.sort.cli.pot | 63 +++++++++++---------- setup.cfg | 2 + tools/sort/cli.py | 6 +- tools/sort/sort.py | 3 +- tools/sort/sort_methods_aligned.py | 21 ++++++- 8 files changed, 121 insertions(+), 73 deletions(-) diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index d1d7063471..ad238cab18 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -5,7 +5,7 @@ import logging import sys from threading import Lock -from typing import Dict, Optional, Tuple +from typing import cast, Dict, Optional, Tuple import cv2 @@ -674,7 +674,7 @@ def __init__(self, landmarks: np.ndarray) -> None: self._camera_matrix = self._get_camera_matrix() self._rotation, self._translation = self._solve_pnp(landmarks) self._offset = self._get_offset() - self._pitch_yaw: Tuple[int, int] = (0, 0) + self._pitch_yaw_roll: Tuple[float, float, float] = (0, 0, 0) @property def xyz_2d(self) -> np.ndarray: @@ -701,24 +701,31 @@ def offset(self) -> Dict[CenteringType, np.ndarray]: @property def pitch(self) -> float: """ float: The pitch of the aligned face in eular angles """ - if not any(self._pitch_yaw): - self._get_pitch_yaw() - return self._pitch_yaw[0] + if not any(self._pitch_yaw_roll): + self._get_pitch_yaw_roll() + return self._pitch_yaw_roll[0] @property def yaw(self) -> float: """ float: The yaw of the aligned face in eular angles """ - if not any(self._pitch_yaw): - self._get_pitch_yaw() - return self._pitch_yaw[1] + if not any(self._pitch_yaw_roll): + self._get_pitch_yaw_roll() + return self._pitch_yaw_roll[1] - def _get_pitch_yaw(self) -> None: - """ Obtain the yaw and pitch from the :attr:`_rotation` in eular angles. """ + @property + def roll(self) -> float: + """ float: The roll of the aligned face in eular angles """ + if not any(self._pitch_yaw_roll): + self._get_pitch_yaw_roll() + return self._pitch_yaw_roll[2] + + def _get_pitch_yaw_roll(self) -> None: + """ Obtain the yaw, roll and pitch from the :attr:`_rotation` in eular angles. """ proj_matrix = np.zeros((3, 4), dtype="float32") proj_matrix[:3, :3] = cv2.Rodrigues(self._rotation)[0] euler = cv2.decomposeProjectionMatrix(proj_matrix)[-1] - self._pitch_yaw = (euler[0][0], euler[1][0]) - logger.trace("yaw_pitch: %s", self._pitch_yaw) # type: ignore + self._pitch_yaw_roll = cast(Tuple[float, float, float], tuple(euler.squeeze())) + logger.trace("yaw_pitch: %s", self._pitch_yaw_roll) # type: ignore @classmethod def _get_camera_matrix(cls) -> np.ndarray: diff --git a/locales/es/LC_MESSAGES/tools.sort.cli.mo b/locales/es/LC_MESSAGES/tools.sort.cli.mo index 22721a9c3d59854fcec48b4404e4e90f879054b8..a4420970f517001c0452821997dff300f3f4cee1 100644 GIT binary patch delta 1274 zcmY+DOKenC7{~u*T1p|l3OKD&dWxbHq*`0B1!EKfq)|dbK;oh#r*lu43%B>`y)z|r z!9Yk{xNX?@Kue6$4X=?9h$}{7*eM|<4bct61sj*LG4Xfq5CUK3oZoqT=YG%mW%}1+ zwZA%BPYYSkSjC81q^#2 zE^UAt;Op=Jybo(|v_&-qOE7}bGab?lMt3cfenR2KGtwoD9eq~X#(n>CX^``1h4cqJ z14m$WCGWUD4^dZtQF;vyz&W`8(f(h^6RqxQIyMAvU|;)L^m5VFE%DO`(^a?^Vp~gJ zl1{>IcnW?6Pr%-nr7`#&OyL9QGc!&a30|W;WC^|xiA7^@97hkpTkNl_mTqESHLSOB zvBO9ov;VLU@8MbKBl#VE4j;mPSX(2#3FqNPxB!bSQu`~shrO>#1u{9kR=N#m$opmV zc2naYD3`B?MeoPA_|`D{j<<=DgZH41XlzKj!+sWe{qm+}4P*El`!C^Uc!Y_p>MX`(%&XYhfK}ioTx=>X#!5Bz-X?@9%}Qk4`-+UihuHknX<wga)T>L*5sOW$I delta 892 zcmX}rT}YEr9LMqBd^Rnk@~s}%=AJDzo3e7DZnK*RD!M2kY)T}QWV=ZNp&)OT1O_6z z8Di24Q5k_@wDp3}RT$NUHzGjU&~7jjNwbXi|nah21hZ4*YG=D#lan90e|8! z#(l+%%p$?+I%y;(Wsn#&g?$`w2H*4B3P^8o5j|-pvW}EwU^*!6!QW^ia5(N59!681 zMcj*jF@jAyCEF#P#3T;Z^Kmk`&>$`0J3f+Q-A!J8NSTf|OJ`a4hmW)|-a|UmEi2v%TMOuB_<@B#kE z+juXoes%M3AW@8T5?vg?-&ss$fSbrYQ6p~02r?~#TW=(169&}*@$I(sc0dvIy5CTjoYAbw$HMoq_q%U71q$rB{9uhf79G_ m{!3I&Z>0P4GuC`{u6E8-77ezxcD5(-&+C5K`AZ!qN>>2_SY~\n" "Language-Team: LANGUAGE \n" @@ -87,71 +87,78 @@ msgid "faces by Pitch (rotation up and down)." msgstr "" #: tools/sort/cli.py:43 +msgid "" +"faces by Roll (rotation). Aligned faces should have a roll value close to " +"zero. The further the Roll value from zero the higher liklihood the face is " +"misaligned." +msgstr "" + +#: tools/sort/cli.py:45 msgid "faces by their color histogram." msgstr "" -#: tools/sort/cli.py:44 +#: tools/sort/cli.py:46 msgid "Like 'hist' but sorts by dissimilarity." msgstr "" -#: tools/sort/cli.py:45 +#: tools/sort/cli.py:47 msgid "" "images by the average intensity of the converted grayscale color channel." msgstr "" -#: tools/sort/cli.py:46 +#: tools/sort/cli.py:48 msgid "" "images by their number of black pixels. Useful when faces are near borders " "and a large part of the image is black." msgstr "" -#: tools/sort/cli.py:48 +#: tools/sort/cli.py:50 msgid "" "images by the average intensity of the converted Y color channel. Bright " "lighting and oversaturated images will be ranked first." msgstr "" -#: tools/sort/cli.py:50 +#: tools/sort/cli.py:52 msgid "" "images by the average intensity of the converted Cg color channel. Green " "images will be ranked first and red images will be last." msgstr "" -#: tools/sort/cli.py:52 +#: tools/sort/cli.py:54 msgid "" "images by the average intensity of the converted Co color channel. Orange " "images will be ranked first and blue images will be last." msgstr "" -#: tools/sort/cli.py:54 +#: tools/sort/cli.py:56 msgid "" "images by their size in the original frame. Faces further from the camera " "and from lower resolution sources will be sorted first, whilst faces closer " "to the camera and from higher resolution sources will be sorted last." msgstr "" -#: tools/sort/cli.py:57 +#: tools/sort/cli.py:59 msgid " option is deprecated. Use 'yaw'" msgstr "" -#: tools/sort/cli.py:58 +#: tools/sort/cli.py:60 msgid " option is deprecated. Use 'color-black'" msgstr "" -#: tools/sort/cli.py:80 +#: tools/sort/cli.py:82 msgid "Sort faces using a number of different techniques" msgstr "" -#: tools/sort/cli.py:90 tools/sort/cli.py:97 tools/sort/cli.py:108 -#: tools/sort/cli.py:146 +#: tools/sort/cli.py:92 tools/sort/cli.py:99 tools/sort/cli.py:110 +#: tools/sort/cli.py:148 msgid "data" msgstr "" -#: tools/sort/cli.py:91 +#: tools/sort/cli.py:93 msgid "Input directory of aligned faces." msgstr "" -#: tools/sort/cli.py:98 +#: tools/sort/cli.py:100 msgid "" "Output directory for sorted aligned faces. If not provided and 'keep' is " "selected then a new folder called 'sorted' will be created within the input " @@ -160,18 +167,18 @@ msgid "" "'input_dir'" msgstr "" -#: tools/sort/cli.py:109 +#: tools/sort/cli.py:111 msgid "" "R|If selected then the input_dir should be a parent folder containing " "multiple folders of faces you wish to sort. The faces will be output to " "separate sub-folders in the output_dir" msgstr "" -#: tools/sort/cli.py:118 +#: tools/sort/cli.py:120 msgid "sort settings" msgstr "" -#: tools/sort/cli.py:120 +#: tools/sort/cli.py:122 msgid "" "R|Choose how images are sorted. Selecting a sort method gives the images a " "new filename based on the order the image appears within the given method.\n" @@ -181,11 +188,11 @@ msgid "" "'none' for both 'sort-by' and 'group-by' will do nothing" msgstr "" -#: tools/sort/cli.py:133 tools/sort/cli.py:160 tools/sort/cli.py:189 +#: tools/sort/cli.py:135 tools/sort/cli.py:162 tools/sort/cli.py:191 msgid "group settings" msgstr "" -#: tools/sort/cli.py:135 +#: tools/sort/cli.py:137 msgid "" "R|Selecting a group by method will move/copy files into numbered bins based " "on the selected method.\n" @@ -194,7 +201,7 @@ msgid "" "folder. Selecting 'none' for both 'sort-by' and 'group-by' will do nothing" msgstr "" -#: tools/sort/cli.py:147 +#: tools/sort/cli.py:149 msgid "" "Whether to keep the original files in their original location. Choosing a " "'sort-by' method means that the files have to be renamed. Selecting 'keep' " @@ -204,7 +211,7 @@ msgid "" "criteria." msgstr "" -#: tools/sort/cli.py:162 +#: tools/sort/cli.py:164 msgid "" "R|Float value. Minimum threshold to use for grouping comparison with 'face-" "cnn' 'hist' and 'face' methods.\n" @@ -219,17 +226,17 @@ msgid "" "face-cnn 7.2, hist 0.3, face 0.25" msgstr "" -#: tools/sort/cli.py:179 +#: tools/sort/cli.py:181 msgid "output" msgstr "" -#: tools/sort/cli.py:180 +#: tools/sort/cli.py:182 msgid "" "Deprecated and no longer used. The final processing will be dictated by the " "sort/group by methods and whether 'keep_original' is selected." msgstr "" -#: tools/sort/cli.py:191 +#: tools/sort/cli.py:193 #, python-format msgid "" "R|Integer value. Used to control the number of bins created for grouping by: " @@ -253,11 +260,11 @@ msgid "" "Default value: 5" msgstr "" -#: tools/sort/cli.py:213 tools/sort/cli.py:223 +#: tools/sort/cli.py:215 tools/sort/cli.py:225 msgid "settings" msgstr "" -#: tools/sort/cli.py:215 +#: tools/sort/cli.py:217 msgid "" "Logs file renaming changes if grouping by renaming, or it logs the file " "copying/movement if grouping by folders. If no log file is specified with " @@ -265,7 +272,7 @@ msgid "" "directory." msgstr "" -#: tools/sort/cli.py:226 +#: tools/sort/cli.py:228 msgid "" "Specify a log file to use for saving the renaming or grouping information. " "If specified extension isn't 'json' or 'yaml', then json will be used as the " diff --git a/setup.cfg b/setup.cfg index f11334206b..3b6b52cdfe 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,6 +34,8 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-scipy.*] ignore_missing_imports = True +[mypy-sklearn.*] +ignore_missing_imports = True [mypy-tensorflow.*] ignore_missing_imports = True [mypy-tensorflow_probability.*] diff --git a/tools/sort/cli.py b/tools/sort/cli.py index 65b1edc3e3..c293a1034c 100644 --- a/tools/sort/cli.py +++ b/tools/sort/cli.py @@ -14,7 +14,7 @@ _HELPTEXT = _("This command lets you sort images using various methods.") _SORT_METHODS = ( "none", "blur", "blur-fft", "distance", "face", "face-cnn", "face-cnn-dissim", - "yaw", "pitch", "hist", "hist-dissim", "color-black", "color-gray", "color-luma", + "yaw", "pitch", "roll", "hist", "hist-dissim", "color-black", "color-gray", "color-luma", "color-green", "color-orange", "size", "face-yaw", "black-pixels") _GPTHRESHOLD = _(" Adjust the '-t' ('--threshold') parameter to control the strength of grouping.") @@ -40,6 +40,8 @@ "face-cnn-dissim": _("Like 'face-cnn' but sorts by dissimilarity."), "yaw": _("faces by Yaw (rotation left to right)."), "pitch": _("faces by Pitch (rotation up and down)."), + "roll": _("faces by Roll (rotation). Aligned faces should have a roll value close to zero. " + "The further the Roll value from zero the higher liklihood the face is misaligned."), "hist": _("faces by their color histogram."), "hist-dissim": _("Like 'hist' but sorts by dissimilarity."), "color-gray": _("images by the average intensity of the converted grayscale color channel."), @@ -60,7 +62,7 @@ _BIN_TYPES = [ (("face", "face-cnn", "face-cnn-dissim", "hist", "hist-dissim"), _GPTHRESHOLD), (("color-black", "color-gray", "color-luma", "color-green", "color-orange"), _GPCOLOR), - (("yaw", "pitch"), _GPDEGREES), + (("yaw", "pitch", "roll"), _GPDEGREES), (("blur", "blur-fft", "distance", "size"), _GPLINEAR)] _SORT_HELP = "" _GROUP_HELP = "" diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 092fc40d2a..1d4148d74d 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -18,7 +18,7 @@ from lib.utils import deprecation_warning from .sort_methods import SortBlur, SortColor, SortFace, SortHistogram, SortMultiMethod -from .sort_methods_aligned import SortDistance, SortFaceCNN, SortPitch, SortSize, SortYaw +from .sort_methods_aligned import SortDistance, SortFaceCNN, SortPitch, SortSize, SortYaw, SortRoll if TYPE_CHECKING: from .sort_methods import SortMethod @@ -132,6 +132,7 @@ def __init__(self, arguments): distance=SortDistance, yaw=SortYaw, pitch=SortPitch, + roll=SortRoll, size=SortSize, face=SortFace, face_cnn=SortFaceCNN, diff --git a/tools/sort/sort_methods_aligned.py b/tools/sort/sort_methods_aligned.py index e3febb2e4b..5da4d8c53d 100644 --- a/tools/sort/sort_methods_aligned.py +++ b/tools/sort/sort_methods_aligned.py @@ -172,7 +172,7 @@ def binning(self) -> List[List[str]]: class SortYaw(SortPitch): - """ Sorting mechansim for sorting a face by yaw (left to right). Same logic as sort yaw, but + """ Sorting mechansim for sorting a face by yaw (left to right). Same logic as sort pitch, but with different metric """ def _get_metric(self, aligned_face: AlignedFace) -> float: """ Obtain the yaw metric for the given face @@ -190,6 +190,25 @@ def _get_metric(self, aligned_face: AlignedFace) -> float: return aligned_face.pose.yaw +class SortRoll(SortPitch): + """ Sorting mechansim for sorting a face by roll (rotation). Same logic as sort pitch, but + with different metric """ + def _get_metric(self, aligned_face: AlignedFace) -> float: + """ Obtain the roll metric for the given face + + Parameters + ---------- + aligned_face: :class:`lib.align.AlignedFace` + The aligned face to extract the metric from + + Returns + ------- + float + The yaw metric for the current face + """ + return aligned_face.pose.roll + + class SortSize(SortAlignedMetric): """ Sorting mechanism for sorting faces from small to large """ def _get_metric(self, aligned_face: AlignedFace) -> float: From 8776e21629dbe2b1992d4a8d3f75da5e7d12fce2 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 23 Sep 2022 14:25:21 +0100 Subject: [PATCH 739/981] Extract: Add roll filter to aligner filters --- plugins/extract/_config.py | 12 ++++ plugins/extract/align/_base.py | 114 ++++++++++++++++++++++++++------- 2 files changed, 104 insertions(+), 22 deletions(-) diff --git a/plugins/extract/_config.py b/plugins/extract/_config.py index 91072b47d3..5252f76f48 100644 --- a/plugins/extract/_config.py +++ b/plugins/extract/_config.py @@ -74,6 +74,18 @@ def set_globals(self): info="Filters out faces who's landmarks are above this distance from an 'average' " "face. Values above 15 tend to be fairly safe. Values above 10 will remove more " "false positives, but may also filter out some faces at extreme angles.") + self.add_item( + section=section, + title="aligner_roll", + datatype=float, + min_max=(0.0, 45.0), + rounding=1, + default=15.0, + group="filters", + info="Filters out faces who's calculated roll is greater than zero +/- this value in " + "degrees. Aligned faces should have a roll value close to zero. Values that are a " + "significant distance from 0 degrees tend to be misaligned images. These can usually " + "be safely disgarded.") self.add_item( section=section, title="save_filtered", diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index d5c1805f65..f355717223 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -123,6 +123,7 @@ def __init__(self, self._filter = AlignedFilter(min_scale=self.config["aligner_min_scale"], max_scale=self.config["aligner_max_scale"], distance=self.config["aligner_distance"], + roll=self.config["aligner_roll"], save_output=self.config["save_filtered"]) logger.debug("Initialized %s", self.__class__.__name__) @@ -530,6 +531,9 @@ class AlignedFilter(): distance: float Filters out faces that are further than this distance from an "average" face. Set to ``0`` for off. + roll: float + Filters out faces with a roll value outside of 0 +/- the value given here. Set to ``0`` + for off. save_output: bool ``True`` if the filtered faces should be kept as they are being saved. ``False`` if they should be deleted @@ -538,16 +542,18 @@ def __init__(self, min_scale: float, max_scale: float, distance: float, + roll: float, save_output: bool) -> None: - logger.debug("Initializing %s: (min_scale: %s, max_scale: %s, distance: %s, " + logger.debug("Initializing %s: (min_scale: %s, max_scale: %s, distance: %s, roll, %s" "save_output: %s)", self.__class__.__name__, min_scale, max_scale, distance, - save_output) + roll, save_output) self._min_scale = min_scale self._max_scale = max_scale self._distance = distance / 100. + self._roll = roll self._save_output = save_output self._active = max_scale > 0.0 or min_scale > 0.0 or distance > 0.0 - self._counts: Dict[str, int] = dict(min_scale=0, max_scale=0, distance=0) + self._counts: Dict[str, int] = dict(min_scale=0, max_scale=0, distance=0, roll=0) logger.debug("Initialized %s: ", self.__class__.__name__) def __call__(self, faces: List[DetectedFace], minimum_dimension: int @@ -574,35 +580,99 @@ def __call__(self, faces: List[DetectedFace], minimum_dimension: int if not self._active: return faces, sub_folders - max_size = minimum_dimension * self._max_scale - min_size = minimum_dimension * self._min_scale retval: List[DetectedFace] = [] for idx, face in enumerate(faces): - test = AlignedFace(landmarks=face.landmarks_xy, centering="face") - if self._min_scale > 0.0 or self._max_scale > 0.0: - roi = test.original_roi - size = ((roi[1][0] - roi[0][0]) ** 2 + (roi[1][1] - roi[0][1]) ** 2) ** 0.5 - if self._min_scale > 0.0 and size < min_size: - self._counts["min_scale"] += 1 - if self._save_output: - retval.append(face) - sub_folders[idx] = "_align_filt_min_scale" - continue - if self._max_scale > 0.0 and size > max_size: - self._counts["max_scale"] += 1 - if self._save_output: - retval.append(face) - sub_folders[idx] = "_align_filt_max_scale" - continue - if 0.0 < self._distance < test.average_distance: + aligned = AlignedFace(landmarks=face.landmarks_xy, centering="face") + + min_max = self._scale_test(aligned, minimum_dimension) + if min_max in ("min", "max"): + self._counts[f"{min_max}_scale"] += 1 + if self._save_output: + retval.append(face) + sub_folders[idx] = f"_align_filt_{min_max}_scale" + continue + + if 0.0 < self._distance < aligned.average_distance: self._counts["distance"] += 1 if self._save_output: retval.append(face) sub_folders[idx] = "_align_filt_distance" continue + + if not -self._roll <= aligned.pose.roll <= self._roll: + self._counts["roll"] += 1 + if self._save_output: + retval.append(face) + sub_folders[idx] = "_align_filt_roll" + continue + retval.append(face) return retval, sub_folders + def _scale_test(self, + face: AlignedFace, + minimum_dimension: int) -> Optional[Literal["min", "max"]]: + """ Test if a face is below or above the min/max size thresholds. Returns as soon as a test + fails. + + Parameters + ---------- + face: :class:`~lib.aligned.AlignedFace` + The aligned face to test the original size of. + + minimum_dimension: int + The minimum (height, width) of the original frame + + Returns + ------- + "min", "max" or ``None`` + Returns min or max if the face failed the minimum or maximum test respectively. + ``None`` if all tests passed + """ + + if self._min_scale <= 0.0 and self._max_scale <= 0.0: + return None + + roi = face.original_roi + size = ((roi[1][0] - roi[0][0]) ** 2 + (roi[1][1] - roi[0][1]) ** 2) ** 0.5 + + if self._min_scale > 0.0 and size < minimum_dimension * self._min_scale: + return "min" + + if self._max_scale > 0.0 and size > minimum_dimension * self._max_scale: + return "max" + + return None + + def filtered_mask(self, faces: List[DetectedFace], minimum_dimension: int) -> List[bool]: + """ Obtain a list of boolean values for the given faces indicating whether they pass the + filter test. + + Parameters + ---------- + faces: list + List of detected face objects to test the filters for + minimum_dimension: int + The minimum (height, width) of the original frame + + Returns + ------- + list + List of bools corresponding to any of the input DetectedFace objects that passed a + test. ``False`` the face passed the test. ``True`` it failed + """ + retval = [False for _ in range(len(faces))] + for idx, face in enumerate(faces): + aligned = AlignedFace(landmarks=face.landmarks_xy) + if self._scale_test(aligned, minimum_dimension) is not None: + retval[idx] = True + continue + if 0.0 < self._distance < aligned.average_distance: + retval[idx] = True + continue + + return retval + def output_counts(self): """ Output the counts of filtered items """ if not self._active: From fba2e6e85131666a66d2f4f04f165b965fd629e4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 23 Sep 2022 19:48:48 +0100 Subject: [PATCH 740/981] Bugfix: Alignments tool sorting error in 'from-faces' --- plugins/extract/_config.py | 4 ++-- tools/alignments/jobs.py | 16 +++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/plugins/extract/_config.py b/plugins/extract/_config.py index 5252f76f48..e2de04acb9 100644 --- a/plugins/extract/_config.py +++ b/plugins/extract/_config.py @@ -67,9 +67,9 @@ def set_globals(self): section=section, title="aligner_distance", datatype=float, - min_max=(0.0, 25.0), + min_max=(0.0, 45.0), rounding=1, - default=15, + default=22.5, group="filters", info="Filters out faces who's landmarks are above this distance from an 'average' " "face. Values above 15 tend to be fairly safe. Values above 10 will remove more " diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index 98640497f0..eafe637157 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -2,6 +2,7 @@ """ Tools for manipulating the alignments serialized file """ import logging +from operator import itemgetter import os import sys from datetime import datetime @@ -850,10 +851,10 @@ def _update_mask_centering(cls, alignment: dict) -> None: def _sort_alignments(self, alignments: Dict[str, Dict[str, List[Tuple[int, - AlignmentFileDict, - str, - dict]]]] - ) -> Dict[str, Dict[str, List[AlignmentFileDict]]] : + AlignmentFileDict, + str, + dict]]]] + ) -> Dict[str, Dict[str, List[AlignmentFileDict]]]: """ Sort the faces into face index order as they appeared in the original alignments file. If the face index stored in the png header does not match it's position in the alignments @@ -878,10 +879,11 @@ def _sort_alignments(self, this_file: Dict[str, List[AlignmentFileDict]] = {} for frame in tqdm(sorted(frames), desc=f"Sorting {fname}", leave=False): this_file[frame] = [] - for real_idx, (f_id, alignment, f_path, f_src) in enumerate(sorted(frames[frame])): + for real_idx, (f_id, almt, f_path, f_src) in enumerate(sorted(frames[frame], + key=itemgetter(0))): if real_idx != f_id: - self._update_png_header(f_path, real_idx, alignment, f_src) - this_file[frame].append(alignment) + self._update_png_header(f_path, real_idx, almt, f_src) + this_file[frame].append(almt) aln_sorted[fname] = this_file return aln_sorted From 376c41949865443e97ae8e6fe60580d976a58934 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 24 Sep 2022 01:09:41 +0100 Subject: [PATCH 741/981] Bugfix: Manual tool. Explicitly disable aligner filters --- plugins/extract/align/_base.py | 27 +++++++++++++++++---------- plugins/extract/pipeline.py | 25 ++++++++++++++++++------- setup.cfg | 2 ++ tools/manual/manual.py | 11 ++++++----- 4 files changed, 43 insertions(+), 22 deletions(-) diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index f355717223..b1722ba8e5 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -80,10 +80,11 @@ class Aligner(Extractor): # pylint:disable=abstract-method The name of the model file to be loaded normalize_method: {`None`, 'clahe', 'hist', 'mean'}, optional Normalize the images fed to the aligner. Default: ``None`` - re_feed: int + re_feed: int, optional The number of times to re-feed a slightly adjusted bounding box into the aligner. Default: `0` - + disable_filter: bool, optional + Disable all aligner filters regardless of config option. Default: ``False`` Other Parameters ---------------- configfile: str, optional @@ -104,9 +105,11 @@ def __init__(self, configfile: Optional[str] = None, instance: int = 0, normalize_method: Optional[Literal["none", "clahe", "hist", "mean"]] = None, - re_feed: int = 0, **kwargs) -> None: - logger.debug("Initializing %s: (normalize_method: %s, re_feed: %s)", - self.__class__.__name__, normalize_method, re_feed) + re_feed: int = 0, + disable_filter: bool = False, + **kwargs) -> None: + logger.debug("Initializing %s: (normalize_method: %s, re_feed: %s, disable_filter: %s)", + self.__class__.__name__, normalize_method, re_feed, disable_filter) super().__init__(git_model_id, model_filename, configfile=configfile, @@ -124,7 +127,8 @@ def __init__(self, max_scale=self.config["aligner_max_scale"], distance=self.config["aligner_distance"], roll=self.config["aligner_roll"], - save_output=self.config["save_filtered"]) + save_output=self.config["save_filtered"], + disable=disable_filter) logger.debug("Initialized %s", self.__class__.__name__) def set_normalize_method(self, @@ -537,22 +541,25 @@ class AlignedFilter(): save_output: bool ``True`` if the filtered faces should be kept as they are being saved. ``False`` if they should be deleted + disable: bool, Optional + ``True`` to disable the filter regardless of config options. Default: ``False`` """ def __init__(self, min_scale: float, max_scale: float, distance: float, roll: float, - save_output: bool) -> None: + save_output: bool, + disable: bool = False) -> None: logger.debug("Initializing %s: (min_scale: %s, max_scale: %s, distance: %s, roll, %s" - "save_output: %s)", self.__class__.__name__, min_scale, max_scale, distance, - roll, save_output) + "save_output: %s, disable: %s)", self.__class__.__name__, min_scale, + max_scale, distance, roll, save_output, disable) self._min_scale = min_scale self._max_scale = max_scale self._distance = distance / 100. self._roll = roll self._save_output = save_output - self._active = max_scale > 0.0 or min_scale > 0.0 or distance > 0.0 + self._active = not disable and (max_scale > 0.0 or min_scale > 0.0 or distance > 0.0) self._counts: Dict[str, int] = dict(min_scale=0, max_scale=0, distance=0, roll=0) logger.debug("Initialized %s: ", self.__class__.__name__) diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 87b1b101e6..f1a1e9c539 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -85,6 +85,8 @@ class Extractor(): re_feed: int The number of times to re-feed a slightly adjusted bounding box into the aligner. Default: `0` + disable_filter: bool, optional + Disable all aligner filters regardless of config option. Default: ``False`` image_is_aligned: bool, optional Used to set the :attr:`plugins.extract.mask.image_is_aligned` attribute. Indicates to the masker that the fed in image is an aligned face rather than a frame. Default: ``False`` @@ -106,13 +108,14 @@ def __init__(self, min_size: int = 0, normalize_method: Optional[Literal["none", "clahe", "hist", "mean"]] = None, re_feed: int = 0, - image_is_aligned: bool = False) -> None: + disable_filter: bool = False, + image_is_aligned: bool = False,) -> None: logger.debug("Initializing %s: (detector: %s, aligner: %s, masker: %s, configfile: %s, " "multiprocess: %s, exclude_gpus: %s, rotate_images: %s, min_size: %s, " - "normalize_method: %s, re_feed: %s, image_is_aligned: %s)", - self.__class__.__name__, detector, aligner, masker, configfile, multiprocess, - exclude_gpus, rotate_images, min_size, normalize_method, re_feed, - image_is_aligned) + "normalize_method: %s, re_feed: %s, disable_filter: %s, " + "image_is_aligned: %s)", self.__class__.__name__, detector, aligner, masker, + configfile, multiprocess, exclude_gpus, rotate_images, min_size, + normalize_method, re_feed, disable_filter, image_is_aligned) self._instance = _get_instance() maskers = [cast(Optional[str], masker)] if not isinstance(masker, list) else cast(List[Optional[str]], masker) @@ -125,7 +128,11 @@ def __init__(self, self._scaling_fallback = 0.4 self._vram_stats = self._get_vram_stats() self._detect = self._load_detect(detector, rotate_images, min_size, configfile) - self._align = self._load_align(aligner, configfile, normalize_method, re_feed) + self._align = self._load_align(aligner, + configfile, + normalize_method, + re_feed, + disable_filter) self._mask = [self._load_mask(mask, image_is_aligned, configfile) for mask in maskers] self._is_parallel = self._set_parallel_processing(multiprocess) self._phases = self._set_phases(multiprocess) @@ -542,7 +549,8 @@ def _load_align(self, aligner: Optional[str], configfile: Optional[str], normalize_method: Optional[Literal["none", "clahe", "hist", "mean"]], - re_feed: int) -> Optional["Aligner"]: + re_feed: int, + disable_filter: bool) -> Optional["Aligner"]: """ Set global arguments and load aligner plugin Parameters @@ -555,6 +563,8 @@ def _load_align(self, Optional normalization method to use re_feed: int The number of times to adjust the image and re-feed to get an average score + disable_filter: bool + Disable all aligner filters regardless of config option Returns ------- @@ -569,6 +579,7 @@ def _load_align(self, configfile=configfile, normalize_method=normalize_method, re_feed=re_feed, + disable_filter=disable_filter, instance=self._instance) return plugin diff --git a/setup.cfg b/setup.cfg index 3b6b52cdfe..8c55340e27 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,5 +42,7 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-tqdm.*] ignore_missing_imports = True +[mypy-win32console.*] +ignore_missing_imports = True [mypy-winpty.*] ignore_missing_imports = True diff --git a/tools/manual/manual.py b/tools/manual/manual.py index 360f67c9bf..b36e2ee02c 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -315,7 +315,7 @@ def _initialize(self): self._initialize_face_options() frame = ttk.Frame(self) frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) - panels = dict() + panels = {} for name, editor in self._display_frame.editors.items(): logger.debug("Initializing control panel for '%s' editor", name) controls = editor.controls @@ -421,7 +421,7 @@ def _get_tk_vars(cls): dict The variable name as key, the variable as value """ - retval = dict() + retval = {} for name in ("frame_index", "transport_index", "face_index", "filter_distance"): var = tk.IntVar() var.set(10 if name == "filter_distance" else 0) @@ -687,7 +687,7 @@ def _background_init_aligner(self): logger.debug("Launching aligner initialization thread") thread = MultiThread(self._init_aligner, thread_count=1, - name="{}.init_aligner".format(self.__class__.__name__)) + name=f"{self.__class__.__name__}.init_aligner") thread.start() logger.debug("Launched aligner initialization thread") return thread @@ -705,7 +705,8 @@ def _init_aligner(self): ["components", "extended"], exclude_gpus=exclude_gpus, multiprocess=True, - normalize_method="hist") + normalize_method="hist", + disable_filter=True) if plugin: aligner.set_batchsize("align", 1) # Set the batchsize to 1 aligner.launch() @@ -852,7 +853,7 @@ def _background_init_frames(self, frames_location, video_meta_data): frames_location, video_meta_data, thread_count=1, - name="{}.init_frames".format(self.__class__.__name__)) + name=f"{self.__class__.__name__}.init_frames") thread.start() return thread From e5356a417e7c2124e75c4a2994ed604fc0a3cc74 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 25 Sep 2022 18:22:48 +0100 Subject: [PATCH 742/981] Alignments update: - Store face embeddings in PNG header when sorting - typing + refactor - Update alignments keys for 'identity' and 'video_meta' + bump to v2.3 - General typing fixes --- lib/align/alignments.py | 828 +++++++++++++++++++---------- lib/align/detected_face.py | 40 +- lib/gui/display_graph.py | 70 +-- setup.cfg | 4 + tools/sort/sort_methods.py | 78 ++- tools/sort/sort_methods_aligned.py | 7 +- 6 files changed, 678 insertions(+), 349 deletions(-) diff --git a/lib/align/alignments.py b/lib/align/alignments.py index b161020332..c1a7620c19 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -6,7 +6,7 @@ import os import sys from datetime import datetime -from typing import Dict, List, Optional, TYPE_CHECKING, Union +from typing import cast, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union import numpy as np @@ -22,7 +22,7 @@ from .aligned_face import CenteringType logger = logging.getLogger(__name__) # pylint: disable=invalid-name -_VERSION = 2.2 +_VERSION = 2.3 # VERSION TRACKING # 1.0 - Never really existed. Basically any alignments file prior to version 2.0 # 2.0 - Implementation of full head extract. Any alignments version below this will have used @@ -30,6 +30,8 @@ # 2.1 - Alignments data to extracted face PNG header. SHA1 hashes of faces no longer calculated # or stored in alignments file # 2.2 - Add support for differently centered masks (i.e. not all masks stored as face centering) +# 2.3 - Add 'identity' key to alignments file. May or may not be populated, to contain vggface2 +# embeddings. Make 'video_meta' key a standard key. Can be unpopulated # TODO Convert these to Dataclasses @@ -43,17 +45,19 @@ class MaskAlignmentsFileDict(TypedDict): class PNGHeaderAlignmentsDict(TypedDict): - """ Base Dictionary for storing Alignment Information in Alignments files and PNG Headers. """ + """ Base Dictionary for storing a single faces' Alignment Information in Alignments files and + PNG Headers. """ x: int y: int w: int h: int landmarks_xy: Union[List[float], np.ndarray] mask: Dict[str, MaskAlignmentsFileDict] + identity: Dict[str, List[float]] class AlignmentFileDict(PNGHeaderAlignmentsDict): - """ Typed Dictionary for storing Alignment Information in alignments files. """ + """ Typed Dictionary for storing a single faces' Alignment Information in alignments files. """ thumb: Optional[np.ndarray] @@ -66,6 +70,12 @@ class PNGHeaderSourceDict(TypedDict): source_is_video: bool +class AlignmentDict(TypedDict): + """ Dictionary for holding all of the alignment information within a single alignment file """ + faces: List[AlignmentFileDict] + video_meta: Dict[str, Union[float, int]] + + class PNGHeaderDict(TypedDict): """ Dictionary for storing all alignment and meta information in PNG Headers """ alignments: PNGHeaderAlignmentsDict @@ -91,78 +101,63 @@ class Alignments(): The filename of the ``.fsa`` alignments file. If not provided then the given folder will be checked for a default alignments file filename. Default: "alignments" """ - def __init__(self, folder, filename="alignments"): + def __init__(self, folder: str, filename: str = "alignments") -> None: logger.debug("Initializing %s: (folder: '%s', filename: '%s')", self.__class__.__name__, folder, filename) - self._version = _VERSION - self._serializer = get_serializer("compressed") - self._file = self._get_location(folder, filename) - self._meta = None + self._io = _IO(self, folder, filename) self._data = self._load() - self._update_legacy() - self._hashes_to_frame = {} - self._hashes_to_alignment = {} + self._io.update_legacy() + + self._legacy = _Legacy(self) self._thumbnails = Thumbnails(self) logger.debug("Initialized %s", self.__class__.__name__) # << PROPERTIES >> # @property - def frames_count(self): + def frames_count(self) -> int: """ int: The number of frames that appear in the alignments :attr:`data`. """ retval = len(self._data) - logger.trace(retval) + logger.trace(retval) # type:ignore return retval @property - def faces_count(self): + def faces_count(self) -> int: """ int: The total number of faces that appear in the alignments :attr:`data`. """ retval = sum(len(val["faces"]) for val in self._data.values()) - logger.trace(retval) + logger.trace(retval) # type:ignore return retval @property - def file(self): + def file(self) -> str: """ str: The full path to the currently loaded alignments file. """ - return self._file + return self._io.file @property - def data(self): + def data(self) -> Dict[str, AlignmentDict]: """ dict: The loaded alignments :attr:`file` in dictionary form. """ return self._data @property - def have_alignments_file(self): + def have_alignments_file(self) -> bool: """ bool: ``True`` if an alignments file exists at location :attr:`file` otherwise ``False``. """ - retval = os.path.exists(self._file) - logger.trace(retval) - return retval + return self._io.have_alignments_file @property - def hashes_to_frame(self): + def hashes_to_frame(self) -> Dict[str, Dict[str, int]]: """ dict: The SHA1 hash of the face mapped to the frame(s) and face index within the frame - that the hash corresponds to. The structure of the dictionary is: - - {**SHA1_hash** (`str`): {**filename** (`str`): **face_index** (`int`)}}. + that the hash corresponds to. Notes ----- This method is depractated and exists purely for updating legacy hash based alignments to new png header storage in :class:`lib.align.update_legacy_png_header`. - - The first time this property is referenced, the dictionary will be created and cached. - Subsequent references will be made to this cached dictionary. """ - if not self._hashes_to_frame: - logger.debug("Generating hashes to frame") - for frame_name, val in self._data.items(): - for idx, face in enumerate(val["faces"]): - self._hashes_to_frame.setdefault(face["hash"], {})[frame_name] = idx - return self._hashes_to_frame + return self._legacy.hashes_to_frame @property - def hashes_to_alignment(self): + def hashes_to_alignment(self) -> Dict[str, AlignmentFileDict]: """ dict: The SHA1 hash of the face mapped to the alignment for the face that the hash corresponds to. The structure of the dictionary is: @@ -170,22 +165,14 @@ def hashes_to_alignment(self): ----- This method is depractated and exists purely for updating legacy hash based alignments to new png header storage in :class:`lib.align.update_legacy_png_header`. - - The first time this property is referenced, the dictionary will be created and cached. - Subsequent references will be made to this cached dictionary. """ - if not self._hashes_to_alignment: - logger.debug("Generating hashes to alignment") - self._hashes_to_alignment = {face["hash"]: face - for val in self._data.values() - for face in val["faces"]} - return self._hashes_to_alignment + return self._legacy.hashes_to_alignment @property - def mask_summary(self): + def mask_summary(self) -> Dict[str, int]: """ dict: The mask type names stored in the alignments :attr:`data` as key with the number of faces which possess the mask type as value. """ - masks = {} + masks: Dict[str, int] = {} for val in self._data.values(): for face in val["faces"]: if face.get("mask", None) is None: @@ -195,80 +182,38 @@ def mask_summary(self): return masks @property - def video_meta_data(self): + def video_meta_data(self) -> Dict[str, Optional[Union[List[int], List[float]]]]: """ dict: The frame meta data stored in the alignments file. If data does not exist in the alignments file then ``None`` is returned for each Key """ - retval = dict(pts_time=None, keyframes=None) - pts_time = [] - keyframes = [] + retval: Dict[str, Optional[Union[List[int], + List[float]]]] = dict(pts_time=None, keyframes=None) + pts_time: List[float] = [] + keyframes: List[int] = [] for idx, key in enumerate(sorted(self.data)): - if "video_meta" not in self.data[key]: + if not self.data[key]["video_meta"]: return retval meta = self.data[key]["video_meta"] - pts_time.append(meta["pts_time"]) + pts_time.append(cast(float, meta["pts_time"])) if meta["keyframe"]: keyframes.append(idx) retval = dict(pts_time=pts_time, keyframes=keyframes) return retval @property - def thumbnails(self): + def thumbnails(self) -> "Thumbnails": """ :class:`~lib.align.Thumbnails`: The low resolution thumbnail images that exist within the alignments file """ return self._thumbnails @property - def version(self): + def version(self) -> float: """ float: The alignments file version number. """ - return self._version - - # << INIT FUNCTIONS >> # + return self._io.version - def _get_location(self, folder, filename): - """ Obtains the location of an alignments file. - - If a legacy alignments file is provided/discovered, then the alignments file will be - updated to the custom ``.fsa`` format and saved. - - Parameters - ---------- - folder: str - The folder that the alignments file is located in - filename: str - The filename of the alignments file - - Returns - ------- - str - The full path to the alignments file - """ - logger.debug("Getting location: (folder: '%s', filename: '%s')", folder, filename) - noext_name, extension = os.path.splitext(filename) - if extension in (".json", ".p", ".pickle", ".yaml", ".yml"): - # Reformat legacy alignments file - filename = self._update_file_format(folder, filename) - logger.debug("Updated legacy alignments. New filename: '%s'", filename) - if extension[1:] == self._serializer.file_extension: - logger.debug("Valid Alignments filename provided: '%s'", filename) - else: - filename = f"{noext_name}.{self._serializer.file_extension}" - logger.debug("File extension set from serializer: '%s'", - self._serializer.file_extension) - location = os.path.join(str(folder), filename) - if not os.path.exists(location): - # Test for old format alignments files and reformat if they exist. This will be - # executed if an alignments file has not been explicitly provided therefore it will not - # have been picked up in the extension test - self._test_for_legacy(location) - logger.verbose("Alignments filepath: '%s'", location) - return location - - # << I/O >> # - - def _load(self): + def _load(self) -> Dict[str, AlignmentDict]: """ Load the alignments data from the serialized alignments :attr:`file`. - Populates :attr:`_meta` with the alignment file's meta information as well as returning + Populates :attr:`_version` with the alignment file's loaded version as well as returning the serialized data. Returns @@ -276,48 +221,23 @@ def _load(self): dict: The loaded alignments data """ - logger.debug("Loading alignments") - if not self.have_alignments_file: - raise FaceswapError(f"Error: Alignments file not found at {self._file}") + return self._io.load() - logger.info("Reading alignments from: '%s'", self._file) - data = self._serializer.load(self._file) - self._meta = data.get("__meta__", dict(version=1.0)) - self._version = self._meta["version"] - data = data.get("__data__", data) - logger.debug("Loaded alignments") - return data - - def save(self): + def save(self) -> None: """ Write the contents of :attr:`data` and :attr:`_meta` to a serialized ``.fsa`` file at the location :attr:`file`. """ - logger.debug("Saving alignments") - logger.info("Writing alignments to: '%s'", self._file) - data = dict(__meta__=dict(version=self._version), - __data__=self._data) - self._serializer.save(self._file, data) - logger.debug("Saved alignments") + return self._io.save() - def backup(self): + def backup(self) -> None: """ Create a backup copy of the alignments :attr:`file`. Creates a copy of the serialized alignments :attr:`file` appending a timestamp onto the end of the file name and storing in the same folder as the original :attr:`file`. """ - logger.debug("Backing up alignments") - if not os.path.isfile(self._file): - logger.debug("No alignments to back up") - return - now = datetime.now().strftime("%Y%m%d_%H%M%S") - src = self._file - split = os.path.splitext(src) - dst = split[0] + "_" + now + split[1] - logger.info("Backing up original alignments to '%s'", dst) - os.rename(src, dst) - logger.debug("Backed up alignments") + return self._io.backup() - def save_video_meta_data(self, pts_time, keyframes): + def save_video_meta_data(self, pts_time: List[float], keyframes: List[int]) -> None: """ Save video meta data to the alignments file. If the alignments file does not have an entry for every frame (e.g. if Extract Every N @@ -341,7 +261,7 @@ def save_video_meta_data(self, pts_time, keyframes): logger.info("Saving video meta information to Alignments file") for idx, pts in enumerate(pts_time): - meta = dict(pts_time=pts, keyframe=idx in keyframes) + meta: Dict[str, Union[float, int]] = dict(pts_time=pts, keyframe=idx in keyframes) key = f"{basename}_{idx + 1:06d}.png" if key not in self.data: self.data[key] = dict(video_meta=meta, faces=[]) @@ -361,10 +281,11 @@ def save_video_meta_data(self, pts_time, keyframes): "\nYou should either extract the video to individual frames, re-encode the " "video at a constant frame rate and re-run extraction or work with a dedicated " "alignments file for your requested video.") - self.save() + self._io.save() @classmethod - def _pad_leading_frames(cls, pts_time, keyframes): + def _pad_leading_frames(cls, pts_time: List[float], keyframes: List[int]) -> Tuple[List[float], + List[int]]: """ Calculate the number of frames to pad the video by when the first frame is not a key frame. @@ -374,9 +295,11 @@ def _pad_leading_frames(cls, pts_time, keyframes): Parameters ---------- - pts_time: list + pts_time: list A list of presentation timestamps (`float`) in frame index order for every frame in the input video + keyframes: list + A list of keyframes (`int`) for the input video Returns ------- @@ -386,7 +309,7 @@ def _pad_leading_frames(cls, pts_time, keyframes): """ start_pts = pts_time[0] logger.debug("Video not cut on keyframe. Start pts: %s", start_pts) - gaps = [] + gaps: List[float] = [] prev_time = None for item in pts_time: if prev_time is not None: @@ -403,8 +326,7 @@ def _pad_leading_frames(cls, pts_time, keyframes): return pts_time, keyframes # << VALIDATION >> # - - def frame_exists(self, frame_name): + def frame_exists(self, frame_name: str) -> bool: """ Check whether a given frame_name exists within the alignments :attr:`data`. Parameters @@ -419,10 +341,10 @@ def frame_exists(self, frame_name): otherwise ``False`` """ retval = frame_name in self._data.keys() - logger.trace("'%s': %s", frame_name, retval) + logger.trace("'%s': %s", frame_name, retval) # type:ignore return retval - def frame_has_faces(self, frame_name): + def frame_has_faces(self, frame_name: str) -> bool: """ Check whether a given frame_name exists within the alignments :attr:`data` and contains at least 1 face. @@ -437,11 +359,12 @@ def frame_has_faces(self, frame_name): ``True`` if the given frame_name exists within the alignments :attr:`data` and has at least 1 face associated with it, otherwise ``False`` """ - retval = bool(self._data.get(frame_name, {}).get("faces", [])) - logger.trace("'%s': %s", frame_name, retval) + frame_data = self._data.get(frame_name, cast(AlignmentDict, {})) + retval = bool(frame_data.get("faces", [])) + logger.trace("'%s': %s", frame_name, retval) # type:ignore return retval - def frame_has_multiple_faces(self, frame_name): + def frame_has_multiple_faces(self, frame_name: str) -> bool: """ Check whether a given frame_name exists within the alignments :attr:`data` and contains more than 1 face. @@ -460,11 +383,12 @@ def frame_has_multiple_faces(self, frame_name): if not frame_name: retval = False else: - retval = bool(len(self._data.get(frame_name, {}).get("faces", [])) > 1) - logger.trace("'%s': %s", frame_name, retval) + frame_data = self._data.get(frame_name, cast(AlignmentDict, {})) + retval = bool(len(frame_data.get("faces", [])) > 1) + logger.trace("'%s': %s", frame_name, retval) # type:ignore return retval - def mask_is_valid(self, mask_type): + def mask_is_valid(self, mask_type: str) -> bool: """ Ensure the given ``mask_type`` is valid for the alignments :attr:`data`. Every face in the alignments :attr:`data` must have the given mask type to successfully @@ -481,16 +405,15 @@ def mask_is_valid(self, mask_type): ``True`` if all faces in the current alignments possess the given ``mask_type`` otherwise ``False`` """ - retval = any([(face.get("mask", None) is not None and - face["mask"].get(mask_type, None) is not None) - for val in self._data.values() - for face in val["faces"]]) + retval = any((face.get("mask", None) is not None and + face["mask"].get(mask_type, None) is not None) + for val in self._data.values() + for face in val["faces"]) logger.debug(retval) return retval # << DATA >> # - - def get_faces_in_frame(self, frame_name): + def get_faces_in_frame(self, frame_name: str) -> List[AlignmentFileDict]: """ Obtain the faces from :attr:`data` associated with a given frame_name. Parameters @@ -504,10 +427,11 @@ def get_faces_in_frame(self, frame_name): list The list of face dictionaries that appear within the requested frame_name """ - logger.trace("Getting faces for frame_name: '%s'", frame_name) - return self._data.get(frame_name, {}).get("faces", []) + logger.trace("Getting faces for frame_name: '%s'", frame_name) # type:ignore + frame_data = self._data.get(frame_name, cast(AlignmentDict, {})) + return frame_data.get("faces", cast(List[AlignmentFileDict], [])) - def _count_faces_in_frame(self, frame_name): + 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 @@ -521,13 +445,13 @@ def _count_faces_in_frame(self, frame_name): int The number of faces that appear in the given frame_name """ - retval = len(self._data.get(frame_name, {}).get("faces", [])) - logger.trace(retval) + frame_data = self._data.get(frame_name, cast(AlignmentDict, {})) + retval = len(frame_data.get("faces", [])) + logger.trace(retval) # type:ignore return retval # << MANIPULATION >> # - - def delete_face_at_index(self, frame_name, face_index): + def delete_face_at_index(self, frame_name: str, face_index: int) -> bool: """ Delete the face for the given frame_name at the given face index from :attr:`data`. Parameters @@ -553,7 +477,7 @@ def delete_face_at_index(self, frame_name, face_index): logger.debug("Deleted face: (frame_name: '%s', face_index %s)", frame_name, face_index) return True - def add_face(self, frame_name, face): + def add_face(self, frame_name: str, face: AlignmentFileDict) -> int: """ Add a new face for the given frame_name in :attr:`data` and return it's index. Parameters @@ -572,13 +496,13 @@ def add_face(self, frame_name, face): """ logger.debug("Adding face to frame_name: '%s'", frame_name) if frame_name not in self._data: - self._data[frame_name] = dict(faces=[]) + self._data[frame_name] = dict(faces=[], video_meta={}) self._data[frame_name]["faces"].append(face) retval = self._count_faces_in_frame(frame_name) - 1 logger.debug("Returning new face index: %s", retval) return retval - def update_face(self, frame_name, face_index, face): + def update_face(self, frame_name: str, face_index: int, face: AlignmentFileDict) -> None: """ Update the face for the given frame_name at the given face index in :attr:`data`. Parameters @@ -595,7 +519,7 @@ def update_face(self, frame_name, face_index, face): logger.debug("Updating face %s for frame_name '%s'", face_index, frame_name) self._data[frame_name]["faces"][face_index] = face - def filter_faces(self, filter_dict, filter_out=False): + def filter_faces(self, filter_dict: Dict[str, List[int]], filter_out: bool = False) -> None: """ Remove faces from :attr:`data` based on a given filter list. Parameters @@ -616,15 +540,15 @@ def filter_faces(self, filter_dict, filter_out=False): else: filter_list = [idx for idx in range(len(frame_data["faces"])) if idx not in face_indices] - logger.trace("frame: '%s', filter_list: %s", source_frame, filter_list) + logger.trace("frame: '%s', filter_list: %s", source_frame, filter_list) # type:ignore for face_idx in reversed(sorted(filter_list)): - logger.verbose("Filtering out face: (filename: %s, index: %s)", + logger.verbose("Filtering out face: (filename: %s, index: %s)", # type:ignore source_frame, face_idx) del frame_data["faces"][face_idx] # << GENERATORS >> # - def yield_faces(self): + 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 are yielded by frame. @@ -647,58 +571,49 @@ def yield_faces(self): for frame_fullname, val in self._data.items(): frame_name = os.path.splitext(frame_fullname)[0] face_count = len(val["faces"]) - logger.trace("Yielding: (frame: '%s', faces: %s, frame_fullname: '%s')", + logger.trace("Yielding: (frame: '%s', faces: %s, frame_fullname: '%s')", # type:ignore frame_name, face_count, frame_fullname) yield frame_name, val["faces"], face_count, frame_fullname - # << LEGACY FUNCTIONS >> # - def _update_legacy(self): - """ Check whether the alignments are legacy, and if so update them to current alignments - format. """ - updated = False - if self._has_legacy_structure(): - self._update_legacy_structure() - - if self._has_legacy_landmarksxy(): - logger.info("Updating legacy landmarksXY to landmarks_xy") - self._update_legacy_landmarksxy() - updated = True - if self._has_legacy_landmarks_list(): - logger.info("Updating legacy landmarks from list to numpy array") - self._update_legacy_landmarks_list() - updated = True - if self._version < 2.2: - logger.info("Updating legacy mask centering") - self._update_mask_centering() - updated = True - if updated: - self._version = _VERSION - self.save() +class _IO(): + """ Class to handle the saving/loading of an alignments file. - # # - # Serializer is now a compressed pickle custom format. This used to be any number - # of serializers - def _test_for_legacy(self, location): - """ For alignments filenames passed in without an extension, test for legacy - serialization formats and update to current ``.fsa`` format if any are found. + Parameters + ---------- + alignments: :class:'~Alignments` + The parent alignments class that these IO operations belong to + folder: str + The folder that contains the alignments ``.fsa`` file + filename: str + The filename of the ``.fsa`` alignments file. + """ + def __init__(self, alignments: Alignments, folder: str, filename: str) -> None: + logger.debug("Initializing %s: (alignments: %s)", self.__class__.__name__, alignments) + self._alignments = alignments + self._serializer = get_serializer("compressed") + self._file = self._get_location(folder, filename) + self._version: float = _VERSION - Parameters - ---------- - location: str - The folder location to check for legacy alignments - """ - logger.debug("Checking for legacy alignments file formats: '%s'", location) - filename = os.path.splitext(location)[0] - for ext in (".json", ".p", ".pickle", ".yaml"): - legacy_filename = f"{filename}{ext}" - if os.path.exists(legacy_filename): - logger.debug("Legacy alignments file exists: '%s'", legacy_filename) - _ = self._update_file_format(*os.path.split(legacy_filename)) - break - logger.debug("Legacy alignments file does not exist: '%s'", legacy_filename) + @property + def file(self) -> str: + """ str: The full path to the currently loaded alignments file. """ + return self._file - def _update_file_format(self, folder, filename): + @property + def version(self) -> float: + """ float: The alignments file version number. """ + return self._version + + @property + def have_alignments_file(self) -> bool: + """ bool: ``True`` if an alignments file exists at location :attr:`file` otherwise + ``False``. """ + retval = os.path.exists(self._file) + logger.trace(retval) # type:ignore + return retval + + def _update_file_format(self, folder: str, filename: str) -> str: """ Convert old style serialized alignments to new ``.fsa`` format. Parameters @@ -728,94 +643,128 @@ def _update_file_format(self, folder, filename): self._serializer.save(new_location, data) return os.path.basename(new_location) - # # - # Alignments were structured: {frame_name: }. We need to be able to store - # information at the frame level, so new structure is: {frame_name: {faces: }} - def _has_legacy_structure(self): - """ Test whether the alignments file is laid out in the old structure of - `{frame_name: [faces]}` + def _test_for_legacy(self, location: str) -> None: + """ For alignments filenames passed in without an extension, test for legacy + serialization formats and update to current ``.fsa`` format if any are found. - Returns - ------- - bool - ``True`` if the file has legacy structure otherwise ``False`` + Parameters + ---------- + location: str + The folder location to check for legacy alignments """ - retval = any(isinstance(val, list) for val in self._data.values()) - logger.debug("legacy structure: %s", retval) - return retval + logger.debug("Checking for legacy alignments file formats: '%s'", location) + filename = os.path.splitext(location)[0] + for ext in (".json", ".p", ".pickle", ".yaml"): + legacy_filename = f"{filename}{ext}" + if os.path.exists(legacy_filename): + logger.debug("Legacy alignments file exists: '%s'", legacy_filename) + _ = self._update_file_format(*os.path.split(legacy_filename)) + break + logger.debug("Legacy alignments file does not exist: '%s'", legacy_filename) - def _update_legacy_structure(self): - """ Update legacy alignments files from the format `{frame_name: [faces}` to the - format `{frame_name: {faces: [faces]}`.""" - for key, val in self._data.items(): - self._data[key] = dict(faces=val) - logger.debug("Updated alignments file structure") - - # # - # Landmarks renamed from landmarksXY to landmarks_xy for PEP compliance - def _has_legacy_landmarksxy(self): - """ check for legacy landmarksXY keys. + def _get_location(self, folder: str, filename: str) -> str: + """ Obtains the location of an alignments file. + + If a legacy alignments file is provided/discovered, then the alignments file will be + updated to the custom ``.fsa`` format and saved. + + Parameters + ---------- + folder: str + The folder that the alignments file is located in + filename: str + The filename of the alignments file Returns ------- - bool - ``True`` if the alignments file contains legacy `landmarksXY` keys otherwise ``False`` + str + The full path to the alignments file """ - logger.debug("checking legacy landmarksXY") - retval = (any(key == "landmarksXY" - for val in self._data.values() - for alignment in val["faces"] - for key in alignment)) - logger.debug("legacy landmarksXY: %s", retval) - return retval + logger.debug("Getting location: (folder: '%s', filename: '%s')", folder, filename) + noext_name, extension = os.path.splitext(filename) + if extension in (".json", ".p", ".pickle", ".yaml", ".yml"): + # Reformat legacy alignments file + filename = self._update_file_format(folder, filename) + logger.debug("Updated legacy alignments. New filename: '%s'", filename) + if extension[1:] == self._serializer.file_extension: + logger.debug("Valid Alignments filename provided: '%s'", filename) + else: + filename = f"{noext_name}.{self._serializer.file_extension}" + logger.debug("File extension set from serializer: '%s'", + self._serializer.file_extension) + location = os.path.join(str(folder), filename) + if not os.path.exists(location): + # Test for old format alignments files and reformat if they exist. This will be + # executed if an alignments file has not been explicitly provided therefore it will not + # have been picked up in the extension test + self._test_for_legacy(location) + logger.verbose("Alignments filepath: '%s'", location) # type:ignore + return location - def _update_legacy_landmarksxy(self): - """ Update legacy `landmarksXY` keys to PEP compliant `landmarks_xy` keys. """ - update_count = 0 - for val in self._data.values(): - for alignment in val["faces"]: - alignment["landmarks_xy"] = alignment.pop("landmarksXY") - update_count += 1 - logger.debug("Updated landmarks_xy: %s", update_count) + def update_legacy(self) -> None: + """ Check whether the alignments are legacy, and if so update them to current alignments + format. """ + updates = [updater.is_updated for updater in (_FileStructure(self._alignments), + _LandmarkRename(self._alignments), + _ListToNumpy(self._alignments), + _MaskCentering(self._alignments), + _IdentityAndVideoMeta(self._alignments))] + if any(updates): + self._version = _VERSION + logger.info("Updating alignments file to version %s", self._version) + self.save() - # Landmarks stored as list instead of numpy array - def _has_legacy_landmarks_list(self): - """ check for legacy landmarks stored as `list` rather than :class:`numpy.ndarray`. + def load(self) -> Dict[str, AlignmentDict]: + """ Load the alignments data from the serialized alignments :attr:`file`. + + Populates :attr:`_version` with the alignment file's loaded version as well as returning + the serialized data. Returns ------- - bool - ``True`` if not all landmarks are :class:`numpy.ndarray` otherwise ``False`` + dict: + The loaded alignments data """ - logger.debug("checking legacy landmarks as list") - retval = not all(isinstance(face["landmarks_xy"], np.ndarray) - for val in self._data.values() - for face in val["faces"]) - return retval + logger.debug("Loading alignments") + if not self.have_alignments_file: + raise FaceswapError(f"Error: Alignments file not found at {self._file}") - def _update_legacy_landmarks_list(self): - """ Update landmarks stored as `list` to :class:`numpy.ndarray`. """ - update_count = 0 - for val in self._data.values(): - for alignment in val["faces"]: - test = alignment["landmarks_xy"] - if not isinstance(test, np.ndarray): - alignment["landmarks_xy"] = np.array(test, dtype="float32") - update_count += 1 - logger.debug("Updated landmarks_xy: %s", update_count) + logger.info("Reading alignments from: '%s'", self._file) + data = self._serializer.load(self._file) + meta = data.get("__meta__", dict(version=1.0)) + self._version = meta["version"] + data = data.get("__data__", data) + logger.debug("Loaded alignments") + return data - # Masks not containing the stored_centering parameters. Prior to this implementation all masks - # were stored with face centering - def _update_mask_centering(self): - update_count = 0 - for val in self._data.values(): - for alignment in val["faces"]: - if "mask" not in alignment: - alignment["mask"] = {} - for mask in alignment["mask"].values(): - mask["stored_centering"] = "face" - update_count += 1 - logger.debug("Updated legacy mask centering: %s", update_count) + def save(self) -> None: + """ Write the contents of :attr:`data` and :attr:`_meta` to a serialized ``.fsa`` file at + the location :attr:`file`. """ + logger.debug("Saving alignments") + logger.info("Writing alignments to: '%s'", self._file) + data = dict(__meta__=dict(version=self._version), + __data__=self._alignments.data) + self._serializer.save(self._file, data) + logger.debug("Saved alignments") + + def backup(self) -> None: + """ Create a backup copy of the alignments :attr:`file`. + + Creates a copy of the serialized alignments :attr:`file` appending a + timestamp onto the end of the file name and storing in the same folder as + the original :attr:`file`. + """ + logger.debug("Backing up alignments") + if not os.path.isfile(self._file): + logger.debug("No alignments to back up") + return + now = datetime.now().strftime("%Y%m%d_%H%M%S") + src = self._file + split = os.path.splitext(src) + dst = split[0] + "_" + now + split[1] + logger.info("Backing up original alignments to '%s'", dst) + os.rename(src, dst) + logger.debug("Backed up alignments") class Thumbnails(): @@ -829,23 +778,23 @@ class Thumbnails(): alignments: :class:'~lib.align.Alignments` The parent alignments class that these thumbs belong to """ - def __init__(self, alignments): + def __init__(self, alignments: Alignments) -> None: logger.debug("Initializing %s: (alignments: %s)", self.__class__.__name__, alignments) self._alignments_dict = alignments.data self._frame_list = list(sorted(self._alignments_dict)) logger.debug("Initialized %s", self.__class__.__name__) @property - def has_thumbnails(self): + def has_thumbnails(self) -> bool: """ bool: ``True`` if all faces in the alignments file contain thumbnail images otherwise ``False``. """ - retval = all(np.any(face.get("thumb")) + retval = all(np.any(face.get("thumb")) # type:ignore # numpy complaining about ``None`` for frame in self._alignments_dict.values() for face in frame["faces"]) - logger.trace(retval) + logger.trace(retval) # type:ignore return retval - def get_thumbnail_by_index(self, frame_index, face_index): + def get_thumbnail_by_index(self, frame_index: int, face_index: int) -> np.ndarray: """ Obtain a jpg thumbnail from the given frame index for the given face index Parameters @@ -861,11 +810,12 @@ def get_thumbnail_by_index(self, frame_index, face_index): The encoded jpg thumbnail """ retval = self._alignments_dict[self._frame_list[frame_index]]["faces"][face_index]["thumb"] - logger.trace("frame index: %s, face_index: %s, thumb shape: %s", + assert retval is not None + logger.trace("frame index: %s, face_index: %s, thumb shape: %s", # type:ignore frame_index, face_index, retval.shape) return retval - def add_thumbnail(self, frame, face_index, thumb): + def add_thumbnail(self, frame: str, face_index: int, thumb: np.ndarray) -> None: """ Add a thumbnail for the given face index for the given frame. Parameters @@ -880,3 +830,295 @@ def add_thumbnail(self, frame, face_index, thumb): logger.debug("frame: %s, face_index: %s, thumb shape: %s thumb dtype: %s", frame, face_index, thumb.shape, thumb.dtype) self._alignments_dict[frame]["faces"][face_index]["thumb"] = thumb + + +class _Updater(): + """ Base class for inheriting to test for and update of an alignments file property + + Parameters + ---------- + alignments: :class:`~Alignments` + The alignments object that is being tested and updated + """ + def __init__(self, alignments: Alignments) -> None: + self._alignments = alignments + self._needs_update = self._test() + if self._needs_update: + self._update() + + @property + def is_updated(self) -> bool: + """ bool. ``True`` if this updater has been run otherwise ``False`` """ + return self._needs_update + + def _test(self) -> bool: + """ Calls the child's :func:`test` method and logs output + + Returns + ------- + bool + ``True`` if the test condition is met otherwise ``False`` + """ + logger.debug("checking %s", self.__class__.__name__) + retval = self.test() + logger.debug("legacy %s: %s", self.__class__.__name__, retval) + return retval + + def test(self) -> bool: + """ Override to set the condition to test for. + + Returns + ------- + bool + ``True`` if the test condition is met otherwise ``False`` + """ + raise NotImplementedError() + + def _update(self) -> int: + """ Calls the child's :func:`update` method, logs output and sets the + :attr:`is_updated` flag + + Returns + ------- + int + The number of items that were updated + """ + retval = self.update() + logger.debug("Updated %s: %s", self.__class__.__name__, retval) + return retval + + def update(self) -> int: + """ Override to set the action to perform on the alignments object if the test has + passed + + Returns + ------- + int + The number of items that were updated + """ + raise NotImplementedError() + + +class _FileStructure(_Updater): + """ Alignments were structured: {frame_name: }. We need to be able to store + information at the frame level, so new structure is: {frame_name: {faces: }} + """ + def test(self) -> bool: + """ Test whether the alignments file is laid out in the old structure of + `{frame_name: [faces]}` + + Returns + ------- + bool + ``True`` if the file has legacy structure otherwise ``False`` + """ + return any(isinstance(val, list) for val in self._alignments.data.values()) + + def update(self) -> int: + """ Update legacy alignments files from the format `{frame_name: [faces}` to the + format `{frame_name: {faces: [faces]}`. + + Returns + ------- + int + The number of items that were updated + """ + updated = 0 + for key, val in self._alignments.data.items(): + if not isinstance(val, list): + continue + self._alignments.data[key] = dict(faces=val) + updated += 1 + return updated + + +class _LandmarkRename(_Updater): + """ Landmarks renamed from landmarksXY to landmarks_xy for PEP compliance """ + def test(self) -> bool: + """ check for legacy landmarksXY keys. + + Returns + ------- + bool + ``True`` if the alignments file contains legacy `landmarksXY` keys otherwise ``False`` + """ + return (any(key == "landmarksXY" + for val in self._alignments.data.values() + for alignment in val["faces"] + for key in alignment)) + + def update(self) -> int: + """ Update legacy `landmarksXY` keys to PEP compliant `landmarks_xy` keys. + + Returns + ------- + int + The number of landmarks keys that were changed + """ + update_count = 0 + for val in self._alignments.data.values(): + for alignment in val["faces"]: + if "landmarksXY" in alignment: + alignment["landmarks_xy"] = alignment.pop("landmarksXY") # type:ignore + update_count += 1 + return update_count + + +class _ListToNumpy(_Updater): + """ Landmarks stored as list instead of numpy array """ + def test(self) -> bool: + """ check for legacy landmarks stored as `list` rather than :class:`numpy.ndarray`. + + Returns + ------- + bool + ``True`` if not all landmarks are :class:`numpy.ndarray` otherwise ``False`` + """ + return not all(isinstance(face["landmarks_xy"], np.ndarray) + for val in self._alignments.data.values() + for face in val["faces"]) + + def update(self) -> int: + """ Update landmarks stored as `list` to :class:`numpy.ndarray`. + + Returns + ------- + int + The number of landmarks keys that were changed + """ + update_count = 0 + for val in self._alignments.data.values(): + for alignment in val["faces"]: + test = alignment["landmarks_xy"] + if not isinstance(test, np.ndarray): + alignment["landmarks_xy"] = np.array(test, dtype="float32") + update_count += 1 + return update_count + + +class _MaskCentering(_Updater): + """ Masks not containing the stored_centering parameters. Prior to this implementation all + masks were stored with face centering """ + + def test(self) -> bool: + """ Mask centering was introduced in alignments version 2.2 + + Returns + ------- + bool + ``True`` mask centering requires updating otherwise ``False`` + """ + return self._alignments.version < 2.2 + + def update(self) -> int: + """ Add the mask key to the alignment file and update the centering of existing masks + + Returns + ------- + int + The number of masks that were updated + """ + update_count = 0 + for val in self._alignments.data.values(): + for alignment in val["faces"]: + if "mask" not in alignment: + alignment["mask"] = {} + for mask in alignment["mask"].values(): + mask["stored_centering"] = "face" + update_count += 1 + return update_count + + +class _IdentityAndVideoMeta(_Updater): + """ Prior to version 2.3 the identity key did not exist and the video_meta key was not + compulsory. These should now both always appear, but do not need to be populated. """ + + def test(self) -> bool: + """ Identity Key was introduced in alignments version 2.3 + + Returns + ------- + bool + ``True`` identity key needs inserting otherwise ``False`` + """ + return self._alignments.version < 2.3 + + # Identity information was not previously stored in the alignments file. + def update(self) -> int: + """ Add the video_meta and identity keys to the alignment file and leave empty + + Returns + ------- + int + The number of keys inserted + """ + update_count = 0 + for val in self._alignments.data.values(): + this_update = 0 + if "video_meta" not in val: + val["video_meta"] = {} + this_update = 1 + for alignment in val["faces"]: + if "identity" not in alignment: + alignment["identity"] = {} + this_update = 1 + update_count += this_update + return update_count + + +class _Legacy(): + """ Legacy alignments properties that are no longer used, but are still required for backwards + compatibility/upgrading reasons. + + Parameters + ---------- + alignments: :class:`~Alignments` + The alignments object that requires these legacy properties + """ + def __init__(self, alignments: Alignments) -> None: + self._alignments = alignments + self._hashes_to_frame: Dict[str, Dict[str, int]] = {} + self._hashes_to_alignment: Dict[str, AlignmentFileDict] = {} + + @property + def hashes_to_frame(self) -> Dict[str, Dict[str, int]]: + """ dict: The SHA1 hash of the face mapped to the frame(s) and face index within the frame + that the hash corresponds to. The structure of the dictionary is: + + {**SHA1_hash** (`str`): {**filename** (`str`): **face_index** (`int`)}}. + + Notes + ----- + This method is deprecated and exists purely for updating legacy hash based alignments + to new png header storage in :class:`lib.align.update_legacy_png_header`. + + The first time this property is referenced, the dictionary will be created and cached. + Subsequent references will be made to this cached dictionary. + """ + if not self._hashes_to_frame: + logger.debug("Generating hashes to frame") + for frame_name, val in self._alignments.data.items(): + for idx, face in enumerate(val["faces"]): + self._hashes_to_frame.setdefault( + face["hash"], {})[frame_name] = idx # type:ignore + return self._hashes_to_frame + + @property + def hashes_to_alignment(self) -> Dict[str, AlignmentFileDict]: + """ dict: The SHA1 hash of the face mapped to the alignment for the face that the hash + corresponds to. The structure of the dictionary is: + + Notes + ----- + This method is deprecated and exists purely for updating legacy hash based alignments + to new png header storage in :class:`lib.align.update_legacy_png_header`. + + The first time this property is referenced, the dictionary will be created and cached. + Subsequent references will be made to this cached dictionary. + """ + if not self._hashes_to_alignment: + logger.debug("Generating hashes to alignment") + self._hashes_to_alignment = {face["hash"]: face # type:ignore + for val in self._alignments.data.values() + for face in val["faces"]} + return self._hashes_to_alignment diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index 602609b245..7c263eea7a 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -6,7 +6,7 @@ import os from hashlib import sha1 -from typing import Callable, Dict, List, Optional, Tuple, TYPE_CHECKING, Union +from typing import cast, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING, Union from zlib import compress, decompress import cv2 @@ -104,6 +104,7 @@ def __init__(self, self.top = top self.height = height self._landmarks_xy = landmarks_xy + self._identity: Dict[Literal["vggface2"], np.ndarray] = {} self.thumbnail: Optional[np.ndarray] = None self.mask = {} if mask is None else mask self._training_masks: Optional[Tuple[bytes, Tuple[int, int, int]]] = None @@ -135,6 +136,11 @@ def bottom(self) -> int: assert self.top is not None and self.height is not None return self.top + self.height + @property + def identity(self) -> Dict[Literal["vggface2"], np.ndarray]: + """ dict: Identity mechanism as key, identity embedding as value. """ + return self._identity + def add_mask(self, name: str, mask: np.ndarray, @@ -173,6 +179,23 @@ def add_mask(self, fsmask.add(mask, affine_matrix, interpolator) self.mask[name] = fsmask + def add_identity(self, name: Literal["vggface2"], embedding: np.ndarray, ) -> None: + """ Add an identity embedding to this detected face. If an identity already exists for the + given :attr:`name` it will be overwritten + + Parameters + ---------- + name: str + The name of the mechanism that calculated the identity + embedding: numpy.ndarray + The identity embedding + """ + logger.trace("name: '%s', embedding shape: %s", # type: ignore + name, embedding.shape) + assert name == "vggface2" + assert embedding.shape[0] == 512 + self._identity[name] = embedding + def get_landmark_mask(self, area: Literal["eye", "face", "mouth"], blur_kernel: int, @@ -271,6 +294,7 @@ def to_alignment(self) -> AlignmentFileDict: landmarks_xy=self.landmarks_xy, mask={name: mask.to_dict() for name, mask in self.mask.items()}, + identity={k: v.tolist() for k, v in self._identity.items()}, thumb=self.thumbnail) logger.trace("Returning: %s", alignment) # type: ignore return alignment @@ -306,6 +330,8 @@ def from_alignment(self, alignment: AlignmentFileDict, landmarks = alignment["landmarks_xy"] if not isinstance(landmarks, np.ndarray): landmarks = np.array(landmarks, dtype="float32") + self._identity = {cast(Literal["vggface2"], k): np.array(v, dtype="float32") + for k, v in alignment.get("identity", {}).items()} self._landmarks_xy = landmarks.copy() if with_thumb: @@ -340,7 +366,8 @@ def to_png_meta(self) -> PNGHeaderAlignmentsDict: y=self.top, h=self.height, landmarks_xy=self.landmarks_xy.tolist(), - mask={name: mask.to_png_meta() for name, mask in self.mask.items()}) + mask={name: mask.to_png_meta() for name, mask in self.mask.items()}, + identity={k: v.tolist() for k, v in self._identity.items()}) return alignment def from_png_meta(self, alignment: PNGHeaderAlignmentsDict) -> None: @@ -361,9 +388,14 @@ def from_png_meta(self, alignment: PNGHeaderAlignmentsDict) -> None: for name, mask_dict in alignment["mask"].items(): self.mask[name] = Mask() self.mask[name].from_dict(mask_dict) + self._identity = {} + for key, val in alignment.get("identity", {}).items(): + assert key in ["vggface2"] + self._identity[cast(Literal["vggface2"], key)] = np.array(val, dtype="float32") logger.trace("Created from png exif header: (left: %s, width: %s, top: %s " # type: ignore - " height: %s, andmarks: %s, mask: %s)", self.left, self.width, self.top, - self.height, self.landmarks_xy, self.mask) + " height: %s, landmarks: %s, mask: %s, identity: %s)", self.left, self.width, + self.top, self.height, self.landmarks_xy, self.mask, + {k: v.shape for k, v in self._identity.items()}) def _image_to_face(self, image: np.ndarray) -> None: """ set self.image to be the cropped face from detected bounding box """ diff --git a/lib/gui/display_graph.py b/lib/gui/display_graph.py index c2187450e4..b7324caa22 100755 --- a/lib/gui/display_graph.py +++ b/lib/gui/display_graph.py @@ -6,7 +6,7 @@ import tkinter as tk from tkinter import ttk -from typing import Union, List, Tuple +from typing import cast, Union, List, Optional, Tuple, TYPE_CHECKING from math import ceil, floor import numpy as np @@ -20,6 +20,9 @@ from .custom_widgets import Tooltip from .utils import get_config, get_images, LongRunningTask +if TYPE_CHECKING: + from matplotlib.lines import Line2D + matplotlib.use("TkAgg") logger: logging.Logger = logging.getLogger(__name__) @@ -46,8 +49,8 @@ def __init__(self, parent: ttk.Frame, data, ylabel: str) -> None: self._ylabel = ylabel self._colourmaps = ["Reds", "Blues", "Greens", "Purples", "Oranges", "Greys", "copper", "summer", "bone", "hot", "cool", "pink", "Wistia", "spring", "winter"] - self._lines = [] - self._toolbar = None + self._lines: List["Line2D"] = [] + self._toolbar: Optional["NavigationToolbar"] = None self._fig = Figure(figsize=(4, 4), dpi=75) self._ax1 = self._fig.add_subplot(1, 1, 1) @@ -84,7 +87,7 @@ def _update_plot(self, initiate: bool = True) -> None: Whether the graph should be initialized for the first time (``True``) or data is being updated for an existing graph (``False``). Default: ``True`` """ - logger.trace("Updating plot") + logger.trace("Updating plot") # type:ignore if initiate: logger.debug("Initializing plot") self._lines = [] @@ -112,7 +115,7 @@ def _update_plot(self, initiate: bool = True) -> None: if initiate: self._legend_place() - logger.trace("Updated plot") + logger.trace("Updated plot") # type:ignore def _axes_labels_set(self) -> None: """ Set the X and Y axes labels. """ @@ -145,12 +148,13 @@ def _axes_limits_set(self, data: List[float]) -> None: ymin, ymax = self._axes_data_get_min_max(data) self._ax1.set_ylim(ymin, ymax) self._ax1.set_xlim(xmin, xmax) - logger.trace("axes ranges: (y: (%s, %s), x:(0, %s)", ymin, ymax, xmax) + logger.trace("axes ranges: (y: (%s, %s), x:(0, %s)", # type:ignore + ymin, ymax, xmax) else: self._axes_limits_set_default() @staticmethod - def _axes_data_get_min_max(data: List[float]) -> Tuple[float]: + def _axes_data_get_min_max(data: List[float]) -> Tuple[float, float]: """ Obtain the minimum and maximum values for the y-axis from the given data points. Parameters @@ -163,14 +167,14 @@ def _axes_data_get_min_max(data: List[float]) -> Tuple[float]: tuple The minimum and maximum values for the y axis """ - ymin, ymax = [], [] + ymins, ymaxs = [], [] for item in data: # TODO Handle as array not loop - ymin.append(np.nanmin(item) * 1000) - ymax.append(np.nanmax(item) * 1000) - ymin = floor(min(ymin)) / 1000 - ymax = ceil(max(ymax)) / 1000 - logger.trace("ymin: %s, ymax: %s", ymin, ymax) + ymins.append(np.nanmin(item) * 1000) + ymaxs.append(np.nanmax(item) * 1000) + ymin = floor(min(ymins)) / 1000 + ymax = ceil(max(ymaxs)) / 1000 + logger.trace("ymin: %s, ymax: %s", ymin, ymax) # type:ignore return ymin, ymax def _axes_set_yscale(self, scale: str) -> None: @@ -197,9 +201,9 @@ def _lines_sort(self, keys: List[str]) -> List[List[Union[str, int, Tuple[float] list A list of loss keys with their corresponding line formatting and color information """ - logger.trace("Sorting lines") - raw_lines = [] - sorted_lines = [] + logger.trace("Sorting lines") # type:ignore + raw_lines: List[List[str]] = [] + sorted_lines: List[List[str]] = [] for key in sorted(keys): title = key.replace("_", " ").title() if key.startswith("raw"): @@ -213,7 +217,7 @@ def _lines_sort(self, keys: List[str]) -> List[List[Union[str, int, Tuple[float] return lines @staticmethod - def _lines_groupsize(raw_lines: List[str], sorted_lines: List[str]) -> int: + def _lines_groupsize(raw_lines: List[List[str]], sorted_lines: List[List[str]]) -> int: """ Get the number of items in each group. If raw data isn't selected, then check the length of remaining groups until something is @@ -238,11 +242,11 @@ def _lines_groupsize(raw_lines: List[str], sorted_lines: List[str]) -> int: keys = [key[0][:key[0].find("_")] for key in sorted_lines] distinct_keys = set(keys) groupsize = len(keys) // len(distinct_keys) - logger.trace(groupsize) + logger.trace(groupsize) # type:ignore return groupsize def _lines_style(self, - lines: List[str], + lines: List[List[str]], groupsize: int) -> List[List[Union[str, int, Tuple[float]]]]: """ Obtain the color map and line width for each group. @@ -258,14 +262,15 @@ def _lines_style(self, list A list of loss keys with their corresponding line formatting and color information """ - logger.trace("Setting lines style") + logger.trace("Setting lines style") # type:ignore groups = int(len(lines) / groupsize) colours = self._lines_create_colors(groupsize, groups) widths = list(range(1, groups + 1)) - for idx, item in enumerate(lines): + retval = cast(List[List[Union[str, int, Tuple[float]]]], lines) + for idx, item in enumerate(retval): linewidth = widths[idx // groupsize] item.extend((linewidth, colours[idx])) - return lines + return retval def _lines_create_colors(self, groupsize: int, groups: int) -> List[Tuple[float]]: """ Create the color maps. @@ -288,7 +293,7 @@ def _lines_create_colors(self, groupsize: int, groups: int) -> List[Tuple[float] cmap = matplotlib.cm.get_cmap(colour) cpoint = 1 - (i / 5) colours.append(cmap(cpoint)) - logger.trace(colours) + logger.trace(colours) # type:ignore return colours def _legend_place(self) -> None: @@ -331,13 +336,13 @@ class TrainingGraph(GraphBase): # pylint: disable=too-many-ancestors def __init__(self, parent: ttk.Frame, data, ylabel: str) -> None: super().__init__(parent, data, ylabel) - self._thread = None # Thread for LongRunningTask - self._displayed_keys = [] + self._thread: Optional[LongRunningTask] = None # Thread for LongRunningTask + self._displayed_keys: List[str] = [] self._add_callback() def _add_callback(self) -> None: """ Add the variable trace to update graph on refresh button press or save iteration. """ - get_config().tk_vars["refreshgraph"].trace("w", self.refresh) + get_config().tk_vars["refreshgraph"].trace("w", self.refresh) # type:ignore def build(self) -> None: """ Build the Training graph. """ @@ -347,7 +352,7 @@ def build(self) -> None: def refresh(self, *args) -> None: # pylint: disable=unused-argument """ Read the latest loss data and apply to current graph """ - refresh_var = get_config().tk_vars["refreshgraph"] + refresh_var = cast(tk.BooleanVar, get_config().tk_vars["refreshgraph"]) if not refresh_var.get() and self._thread is None: return @@ -402,8 +407,8 @@ def _resize_fig(self) -> None: class Event(): # pylint: disable=too-few-public-methods """ Event class that needs to be passed to plotcanvas.resize """ pass # pylint: disable=unnecessary-pass - Event.width = self.winfo_width() - Event.height = self.winfo_height() + setattr(Event, "width", self.winfo_width()) + setattr(Event, "height", self.winfo_height()) self._plotcanvas.resize(Event) # pylint: disable=no-value-for-parameter @@ -485,7 +490,7 @@ class NavigationToolbar(NavigationToolbar2Tk): # pylint: disable=too-many-ances def __init__(self, # pylint: disable=super-init-not-called canvas: FigureCanvasTkAgg, - window: SessionGraph, + window: ttk.Frame, *, pack_toolbar: bool = True) -> None: @@ -558,7 +563,10 @@ def _Button(frame: ttk.Frame, # pylint:disable=arguments-differ img = get_images().icons[icon] if not toggle: - btn = ttk.Button(frame, text=text, image=img, command=command) + btn: Union[ttk.Button, ttk.Checkbutton] = ttk.Button(frame, + text=text, + image=img, + command=command) else: var = tk.IntVar(master=frame) btn = ttk.Checkbutton(frame, text=text, image=img, command=command, variable=var) diff --git a/setup.cfg b/setup.cfg index 8c55340e27..3075936d94 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,6 +10,8 @@ exclude = .git, __pycache__ ignore_missing_imports = True [mypy-fastcluster.*] ignore_missing_imports = True +[mypy-ffmpy.*] +ignore_missing_imports = True [mypy-imageio.*] ignore_missing_imports = True [mypy-imageio_ffmpeg.*] @@ -32,6 +34,8 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-pynvx.*] ignore_missing_imports = True +[mypy-pytest.*] +ignore_missing_imports = True [mypy-scipy.*] ignore_missing_imports = True [mypy-sklearn.*] diff --git a/tools/sort/sort_methods.py b/tools/sort/sort_methods.py index 646a3f48cf..2b80c627cf 100644 --- a/tools/sort/sort_methods.py +++ b/tools/sort/sort_methods.py @@ -15,7 +15,7 @@ from tqdm import tqdm from lib.align import AlignedFace, DetectedFace -from lib.image import FacesLoader, ImagesLoader, read_image_meta_batch +from lib.image import FacesLoader, ImagesLoader, read_image_meta_batch, update_existing_metadata from lib.utils import FaceswapError from plugins.extract.recognition.vgg_face2_keras import Cluster, VGGFace2 as VGGFace @@ -26,7 +26,7 @@ if TYPE_CHECKING: from argparse import Namespace - from lib.align.alignments import PNGHeaderAlignmentsDict + from lib.align.alignments import PNGHeaderAlignmentsDict, PNGHeaderSourceDict logger = logging.getLogger(__name__) @@ -57,6 +57,7 @@ def __init__(self, self._iterator = None self._description = "Reading image statistics..." self._loader = ImagesLoader(input_dir) if info_type == "face" else FacesLoader(input_dir) + self._cached_source_data: Dict[str, "PNGHeaderSourceDict"] = {} if self._loader.count == 0: logger.error("No images to process in location: '%s'", input_dir) sys.exit(1) @@ -100,12 +101,18 @@ def __call__(self) -> ImgMetaType: iterator = self._get_iterator() return iterator - @classmethod - def _get_alignments(cls, metadata: Dict[str, Any]) -> Optional["PNGHeaderAlignmentsDict"]: - """ Obtain the alignments from a PNG Header + def _get_alignments(self, + filename: str, + metadata: Dict[str, Any]) -> Optional["PNGHeaderAlignmentsDict"]: + """ Obtain the alignments from a PNG Header. + + The other image metadata is cached locally in case a sort method needs to write back to the + PNG header Parameters ---------- + filename: str + Full path to the image PNG file metadata: dict The header data from a PNG file @@ -114,8 +121,9 @@ def _get_alignments(cls, metadata: Dict[str, Any]) -> Optional["PNGHeaderAlignme dict or ``None`` The alignments dictionary from the PNG header, if it exists, otherwise ``None`` """ - if not metadata or not metadata.get("alignments"): + if not metadata or not metadata.get("alignments") or not metadata.get("source"): return None + self._cached_source_data[filename] = metadata["source"] return metadata["alignments"] def _metadata_reader(self) -> ImgMetaType: @@ -134,7 +142,7 @@ def _metadata_reader(self) -> ImgMetaType: total=self._loader.count, desc=self._description, leave=False): - alignments = self._get_alignments(metadata.get("itxt", {})) + alignments = self._get_alignments(filename, metadata.get("itxt", {})) yield filename, None, alignments def _full_data_reader(self) -> ImgMetaType: @@ -153,7 +161,7 @@ def _full_data_reader(self) -> ImgMetaType: desc=self._description, total=self._loader.count, leave=False): - alignments = self._get_alignments(metadata) + alignments = self._get_alignments(filename, metadata) yield filename, image, alignments def _image_data_reader(self) -> ImgMetaType: @@ -174,6 +182,28 @@ def _image_data_reader(self) -> ImgMetaType: leave=False): yield filename, image, None + def update_png_header(self, filename: str, alignments: "PNGHeaderAlignmentsDict") -> None: + """ Update the PNG header of the given file with the given alignments. + + NB: Header information can only be updated if the face is already on at least alignment + version 2.2. If below this version, then the header is not updated + + + Parameters + ---------- + filename: str + Full path to the PNG file to update + alignments: dict + The alignments to update into the PNG header + """ + vers = self._cached_source_data[filename]["alignments_version"] + if vers < 2.2: + return + + self._cached_source_data[filename]["alignments_version"] = 2.3 if vers == 2.2 else vers + header = dict(alignments=alignments, source=self._cached_source_data[filename]) + update_existing_metadata(filename, header) + class SortMethod(): """ Parent class for sort methods. All sort methods should inherit from this class @@ -805,13 +835,17 @@ def __init__(self, arguments: "Namespace", is_group: bool = False) -> None: self._vgg_face = VGGFace(exclude_gpus=arguments.exclude_gpus) self._vgg_face.init_model() threshold = arguments.threshold + self._output_update_info = True self._threshold: Optional[float] = 0.25 if threshold < 0 else threshold def score_image(self, filename: str, image: Optional[np.ndarray], alignments: Optional["PNGHeaderAlignmentsDict"]) -> None: - """ Processing logic for sort by face method + """ Processing logic for sort by face method. + + Reads header information from the PNG file to look for VGGFace2 embedding. If it does not + exist, the embedding is obtained and added back into the PNG Header. Parameters ---------- @@ -822,23 +856,37 @@ def score_image(self, alignments: dict or ``None`` The alignments dictionary for the aligned face or ``None`` """ - if self._log_once: - msg = "Grouping" if self._is_group else "Sorting" - logger.info("%s by identity similarity...", msg) - self._log_once = False - if not alignments: msg = ("The images to be sorted do not contain alignment data. Images must have " "been generated by Faceswap's Extract process.\nIf you are sorting an " "older faceset, then you should re-extract the faces from your source " "alignments file to generate this data.") raise FaceswapError(msg) + + if self._log_once: + msg = "Grouping" if self._is_group else "Sorting" + logger.info("%s by identity similarity...", msg) + self._log_once = False + + if alignments.get("identity", {}).get("vggface2"): + embedding = np.array(alignments["identity"]["vggface2"], dtype="float32") + self._result.append((filename, embedding)) + return + + if self._output_update_info: + logger.info("VGG Face2 Embeddings are being written to the image header. " + "Sorting by this method will be quicker next time") + self._output_update_info = False + face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), image=image, centering="legacy", size=self._vgg_face.input_size, is_aligned=True).face - self._result.append((filename, self._vgg_face.predict(face))) + embedding = self._vgg_face.predict(face) + alignments.setdefault("identity", {})["vggface2"] = embedding.tolist() + self._iterator.update_png_header(filename, alignments) + self._result.append((filename, embedding)) def sort(self) -> None: """ Sort by dendogram. diff --git a/tools/sort/sort_methods_aligned.py b/tools/sort/sort_methods_aligned.py index 5da4d8c53d..d3bcbcf20c 100644 --- a/tools/sort/sort_methods_aligned.py +++ b/tools/sort/sort_methods_aligned.py @@ -6,7 +6,7 @@ import operator import sys -from typing import Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union +from typing import Dict, List, Optional, TYPE_CHECKING, Union import numpy as np from tqdm import tqdm @@ -22,11 +22,6 @@ logger = logging.getLogger(__name__) -ImgMetaType = Generator[Tuple[str, - Optional[np.ndarray], - Optional["PNGHeaderAlignmentsDict"]], None, None] - - class SortAlignedMetric(SortMethod): # pylint:disable=too-few-public-methods """ Sort by comparison of metrics stored in an Aligned Face objects. This is a parent class for sort by aligned metrics methods. Individual methods should inherit from this class From 9e23f836eafc432ce8d31a89247a8830431cbbef Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 25 Sep 2022 18:51:13 +0100 Subject: [PATCH 743/981] Bugfix: sort. Don't error on callback in SortMulti --- tools/sort/sort_methods.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tools/sort/sort_methods.py b/tools/sort/sort_methods.py index 2b80c627cf..88de3081cd 100644 --- a/tools/sort/sort_methods.py +++ b/tools/sort/sort_methods.py @@ -492,7 +492,8 @@ def __init__(self, def _get_file_iterator(self, input_dir: str) -> InfoLoader: """ Override to get a group specific iterator. If the sorter and grouper use the same kind of iterator, use that. Otherwise return the 'all' iterator, as which ever way it is cut all - outputs will be required + outputs will be required. Monkey patch the actual loader used into the children in case of + any callbacks. Parameters ---------- @@ -505,8 +506,12 @@ def _get_file_iterator(self, input_dir: str) -> InfoLoader: The correct InfoLoader iterator for the current sort method """ if self._sorter.loader_type == self._grouper.loader_type: - return InfoLoader(input_dir, self._sorter.loader_type) - return InfoLoader(input_dir, "all") + retval = InfoLoader(input_dir, self._sorter.loader_type) + else: + retval = InfoLoader(input_dir, "all") + self._sorter._iterator = retval # pylint: disable=protected-access + self._grouper._iterator = retval # pylint: disable=protected-access + return retval def score_image(self, filename: str, From e2a77e7c6e84e81f642cb22f528e25e3f2d2dbc1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 26 Sep 2022 02:22:28 +0100 Subject: [PATCH 744/981] Alignments Tool - Typing, Documentation + Re-org --- docs/full/tools/alignments.rst | 50 ++ docs/full/tools/sort.rst | 6 +- tools/alignments/alignments.py | 6 +- tools/alignments/jobs.py | 1284 +++++++------------------------ tools/alignments/jobs_faces.py | 449 +++++++++++ tools/alignments/jobs_frames.py | 484 ++++++++++++ tools/alignments/media.py | 402 +++++++--- 7 files changed, 1566 insertions(+), 1115 deletions(-) create mode 100644 docs/full/tools/alignments.rst create mode 100644 tools/alignments/jobs_faces.py create mode 100644 tools/alignments/jobs_frames.py diff --git a/docs/full/tools/alignments.rst b/docs/full/tools/alignments.rst new file mode 100644 index 0000000000..33119bfa64 --- /dev/null +++ b/docs/full/tools/alignments.rst @@ -0,0 +1,50 @@ +****************** +alignments package +****************** + +.. contents:: Contents + :local: + + +alignments module +***************** +The Alignments Module is the main entry point into the Alignments Tool. + +.. automodule:: tools.alignments.alignments + :members: + :undoc-members: + :show-inheritance: + + +jobs_faces module +================= + +.. automodule:: tools.alignments.jobs_faces + :members: + :undoc-members: + :show-inheritance: + + +jobs_frames module +================== + +.. automodule:: tools.alignments.jobs_frames + :members: + :undoc-members: + :show-inheritance: + +jobs module +=========== + +.. automodule:: tools.alignments.jobs + :members: + :undoc-members: + :show-inheritance: + +media module +============ + +.. automodule:: tools.alignments.media + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/tools/sort.rst b/docs/full/tools/sort.rst index eafa244d1f..6335339799 100644 --- a/docs/full/tools/sort.rst +++ b/docs/full/tools/sort.rst @@ -25,10 +25,10 @@ sort_methods module :show-inheritance: -sort_methods_algigned module -============================ +sort_methods_aligned module +=========================== -.. automodule:: tools.sort.sort_methods_algigned +.. automodule:: tools.sort.sort_methods_aligned :members: :undoc-members: :show-inheritance: diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index 3ad9636204..2d23cce28e 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -8,9 +8,9 @@ from lib.utils import _video_extensions from .media import AlignmentData -from .jobs import (Check, Draw, Extract, FromFaces, Rename, # noqa pylint: disable=unused-import - RemoveFaces, Sort, Spatial) - +from .jobs import Check, Sort, Spatial # noqa pylint: disable=unused-import +from .jobs_faces import FromFaces, RemoveFaces, Rename # noqa pylint: disable=unused-import +from .jobs_frames import Draw, Extract # noqa pylint: disable=unused-import if TYPE_CHECKING: from argparse import Namespace diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index eafe637157..b1c0af413e 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -2,33 +2,29 @@ """ Tools for manipulating the alignments serialized file """ import logging -from operator import itemgetter import os import sys from datetime import datetime -from typing import Dict, List, Tuple, TYPE_CHECKING, Optional +from typing import cast, Dict, Generator, List, Tuple, TYPE_CHECKING, Optional, Union -from argparse import Namespace - -import cv2 import numpy as np from scipy import signal from sklearn import decomposition from tqdm import tqdm -from lib.align import DetectedFace, _EXTRACT_RATIOS -from lib.align.alignments import _VERSION, AlignmentFileDict -from lib.image import (encode_image, generate_thumbnail, ImagesSaver, - read_image_meta_batch, update_existing_metadata) -from plugins.extract.pipeline import Extractor, ExtractMedia -from scripts.fsmedia import Alignments +from .media import Faces, Frames -from .media import ExtractedFaces, Faces, Frames +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal if TYPE_CHECKING: + from argparse import Namespace + from lib.align.alignments import PNGHeaderSourceDict from .media import AlignmentData -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class Check(): @@ -41,11 +37,11 @@ class Check(): arguments: :class:`argparse.Namespace` The command line arguments that have called this job """ - def __init__(self, alignments, arguments): + def __init__(self, alignments: "AlignmentData", arguments: "Namespace") -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self._alignments = alignments self._job = arguments.job - self._type = None + self._type: Optional[Literal["faces", "frames"]] = None self._is_video = False # Set when getting items self._output = arguments.output self._source_dir = self._get_source_dir(arguments) @@ -55,8 +51,19 @@ def __init__(self, alignments, arguments): self.output_message = "" logger.debug("Initialized %s", self.__class__.__name__) - def _get_source_dir(self, arguments): - """ Set the correct source folder """ + def _get_source_dir(self, arguments: "Namespace") -> str: + """ Set the correct source folder + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The command line arguments for the Alignments tool + + Returns + ------- + str + Full path to the source folder + """ if (hasattr(arguments, "faces_dir") and arguments.faces_dir and hasattr(arguments, "frames_dir") and arguments.frames_dir): logger.error("Only select a source frames (-fr) or source faces (-fc) folder") @@ -73,21 +80,31 @@ def _get_source_dir(self, arguments): logger.debug("type: '%s', source_dir: '%s'", self._type, source_dir) return source_dir - def _get_items(self): - """ Set the correct items to process """ - items = globals()[self._type.title()](self._source_dir) + def _get_items(self) -> Union[List[Dict[str, str]], List[Dict[str, "PNGHeaderSourceDict"]]]: + """ Set the correct items to process + + Returns + ------- + list + Sorted list of dictionaries for either faces or frames. If faces the dictionaries + have the current filename as key, with the header source data as value. If frames + the dictionaries will contain the keys 'frame_fullname', 'frame_name', 'extension'. + """ + assert self._type is not None + items: Union[Frames, Faces] = globals()[self._type.title()](self._source_dir) self._is_video = items.is_video - return items.file_list_sorted + return cast(Union[List[Dict[str, str]], List[Dict[str, "PNGHeaderSourceDict"]]], + items.file_list_sorted) - def process(self): + def process(self) -> None: """ Process the frames check against the alignments file """ + assert self._type is not None logger.info("[CHECK %s]", self._type.upper()) items_output = self._compile_output() self._output_results(items_output) - def _validate(self): - """ Check that the selected type is valid for - selected task and job """ + def _validate(self) -> None: + """ Check that the selected type is valid for selected task and job """ if self._job == "missing-frames" and self._output == "move": logger.warning("Missing_frames was selected with move output, but there will " "be nothing to move. Defaulting to output: console") @@ -97,73 +114,129 @@ def _validate(self): "supported for 'multi-faces'") sys.exit(1) - def _compile_output(self): - """ Compile list of frames that meet criteria """ + def _compile_output(self) -> Union[List[str], List[Tuple[str, int]]]: + """ Compile list of frames that meet criteria + + Returns + ------- + list + List of filenames or filenames and face indices for the selected criteria + """ action = self._job.replace("-", "_") processor = getattr(self, f"_get_{action}") logger.debug("Processor: %s", processor) return [item for item in processor()] # pylint:disable=unnecessary-comprehension - def _get_no_faces(self): - """ yield each frame that has no face match in alignments file """ + def _get_no_faces(self) -> Generator[str, None, None]: + """ yield each frame that has no face match in alignments file + + Yields + ------ + str + The frame name of any frames which have no faces + """ self.output_message = "Frames with no faces" - for frame in tqdm(self._items, desc=self.output_message): + for frame in tqdm(cast(List[Dict[str, str]], self._items), + desc=self.output_message, + leave=False): logger.trace(frame) # type:ignore frame_name = frame["frame_fullname"] if not self._alignments.frame_has_faces(frame_name): logger.debug("Returning: '%s'", frame_name) yield frame_name - def _get_multi_faces(self): - """ yield each frame or face that has multiple faces - matched in alignments file """ + def _get_multi_faces(self) -> Union[Generator[str, None, None], + Generator[Tuple[str, int], None, None]]: + """ yield each frame or face that has multiple faces matched in alignments file + + Yields + ------ + str or tuple + The frame name of any frames which have multiple faces and potentially the face id + """ process_type = getattr(self, f"_get_multi_faces_{self._type}") for item in process_type(): yield item - def _get_multi_faces_frames(self): - """ Return Frames that contain multiple faces """ + def _get_multi_faces_frames(self) -> Generator[str, None, None]: + """ Return Frames that contain multiple faces + + Yields + ------ + str + The frame name of any frames which have multiple faces + """ self.output_message = "Frames with multiple faces" - for item in tqdm(self._items, desc=self.output_message): + for item in tqdm(cast(List[Dict[str, str]], self._items), + desc=self.output_message, + leave=False): filename = item["frame_fullname"] if not self._alignments.frame_has_multiple_faces(filename): continue logger.trace("Returning: '%s'", filename) # type:ignore yield filename - def _get_multi_faces_faces(self): - """ Return Faces when there are multiple faces in a frame """ + def _get_multi_faces_faces(self) -> Generator[Tuple[str, int], None, None]: + """ Return Faces when there are multiple faces in a frame + + Yields + ------ + tuple + The frame name and the face id of any frames which have multiple faces + """ self.output_message = "Multiple faces in frame" - for item in tqdm(self._items, desc=self.output_message): + for item in tqdm(cast(List[Tuple[str, "PNGHeaderSourceDict"]], self._items), + desc=self.output_message, + leave=False): if not self._alignments.frame_has_multiple_faces(item["source_filename"]): continue - retval = (item["current_filename"], item["face_index"]) + retval = (item[0], item[1]["face_index"]) logger.trace("Returning: '%s'", retval) # type:ignore yield retval - def _get_missing_alignments(self): - """ yield each frame that does not exist in alignments file """ + def _get_missing_alignments(self) -> Generator[str, None, None]: + """ yield each frame that does not exist in alignments file + + Yields + ------ + str + The frame name of any frames missing alignments + """ self.output_message = "Frames missing from alignments file" exclude_filetypes = set(["yaml", "yml", "p", "json", "txt"]) - for frame in tqdm(self._items, desc=self.output_message): + for frame in tqdm(cast(Dict[str, str], self._items), + desc=self.output_message, + leave=False): frame_name = frame["frame_fullname"] if (frame["frame_extension"] not in exclude_filetypes and not self._alignments.frame_exists(frame_name)): logger.debug("Returning: '%s'", frame_name) yield frame_name - def _get_missing_frames(self): - """ yield each frame in alignments that does - not have a matching file """ + def _get_missing_frames(self) -> Generator[str, None, None]: + """ yield each frame in alignments that does not have a matching file + + Yields + ------ + str + The frame name of any frames in alignments with no matching file + """ self.output_message = "Missing frames that are in alignments file" frames = set(item["frame_fullname"] for item in self._items) - for frame in tqdm(self._alignments.data.keys(), desc=self.output_message): + for frame in tqdm(self._alignments.data.keys(), desc=self.output_message, leave=False): if frame not in frames: logger.debug("Returning: '%s'", frame) yield frame - def _output_results(self, items_output): - """ Output the results in the requested format """ + def _output_results(self, items_output: Union[List[str], List[Tuple[str, int]]]) -> None: + """ Output the results in the requested format + + Parameters + ---------- + items_output + The list of frame names, and potentially face ids, of any items which met the + selection criteria + """ logger.trace("items_output: %s", items_output) # type:ignore if self._output == "move" and self._is_video and self._type == "frames": logger.warning("Move was selected with an input video. This is not possible so " @@ -177,33 +250,54 @@ def _output_results(self, items_output): return if self._job == "multi-faces" and self._type == "faces": # Strip the index for printed/file output - items_output = [item[0] for item in items_output] + final_output = [item[0] for item in items_output] + else: + final_output = cast(List[str], items_output) output_message = "-----------------------------------------------\r\n" - output_message += f" {self.output_message} ({len(items_output)})\r\n" + output_message += f" {self.output_message} ({len(final_output)})\r\n" output_message += "-----------------------------------------------\r\n" - output_message += "\r\n".join(items_output) + output_message += "\r\n".join(final_output) if self._output == "console": for line in output_message.splitlines(): logger.info(line) if self._output == "file": - self.output_file(output_message, len(items_output)) + self.output_file(output_message, len(final_output)) + + def _get_output_folder(self) -> str: + """ Return output folder. Needs to be in the root if input is a video and processing + frames - def _get_output_folder(self): - """ Return output folder. Needs to be in the root if input is a - video and processing frames """ + Returns + ------- + str + Full path to the output folder + """ if self._is_video and self._type == "frames": return os.path.dirname(self._source_dir) return self._source_dir - def _get_filename_prefix(self): - """ Video name needs to be prefixed to filename if input is a - video and processing frames """ + def _get_filename_prefix(self) -> str: + """ Video name needs to be prefixed to filename if input is a video and processing frames + + Returns + ------- + str + The common filename prefix to use + """ if self._is_video and self._type == "frames": return f"{os.path.basename(self._source_dir)}_" return "" - def output_file(self, output_message, items_discovered): - """ Save the output to a text file in the frames directory """ + def output_file(self, output_message: str, items_discovered: int) -> None: + """ Save the output to a text file in the frames directory + + Parameters + ---------- + output_message: str + The message to write out to file + items_discovered: int + The number of items which matched the criteria + """ now = datetime.now().strftime("%Y%m%d_%H%M%S") dst_dir = self._get_output_folder() filename = (f"{self._get_filename_prefix()}{self.output_message.replace(' ', '_').lower()}" @@ -213,8 +307,14 @@ def output_file(self, output_message, items_discovered): with open(output_file, "w", encoding="utf8") as f_output: f_output.write(output_message) - def _move_file(self, items_output): - """ Move the identified frames to a new sub folder """ + def _move_file(self, items_output: Union[List[str], List[Tuple[str, int]]]) -> None: + """ Move the identified frames to a new sub folder + + Parameters + ---------- + items_output: list + List of items to move + """ now = datetime.now().strftime("%Y%m%d_%H%M%S") folder_name = (f"{self._get_filename_prefix()}" f"{self.output_message.replace(' ','_').lower()}_{now}") @@ -226,8 +326,16 @@ def _move_file(self, items_output): logger.debug("Move function: %s", move) move(output_folder, items_output) - def _move_frames(self, output_folder, items_output): - """ Move frames into single sub folder """ + def _move_frames(self, output_folder: str, items_output: List[str]) -> None: + """ Move frames into single sub folder + + Parameters + ---------- + output_folder: str + The folder to move the output to + items_output: list + List of items to move + """ logger.info("Moving %s frame(s) to '%s'", len(items_output), output_folder) for frame in items_output: src = os.path.join(self._source_dir, frame) @@ -235,9 +343,16 @@ def _move_frames(self, output_folder, items_output): logger.debug("Moving: '%s' to '%s'", src, dst) os.rename(src, dst) - def _move_faces(self, output_folder, items_output): - """ Make additional sub folders for each face that appears - Enables easier manual sorting """ + def _move_faces(self, output_folder: str, items_output: List[Tuple[str, int]]) -> None: + """ Make additional sub folders for each face that appears Enables easier manual sorting + + Parameters + ---------- + output_folder: str + The folder to move the output to + items_output: list + List of items and face indices to move + """ logger.info("Moving %s faces(s) to '%s'", len(items_output), output_folder) for frame, idx in items_output: src = os.path.join(self._source_dir, frame) @@ -250,878 +365,6 @@ def _move_faces(self, output_folder, items_output): os.rename(src, dst) -class Draw(): # pylint:disable=too-few-public-methods - """ Draws annotations onto original frames and saves into a sub-folder next to the original - frames. - - Parameters - --------- - alignments: :class:`tools.alignments.media.AlignmentsData` - The loaded alignments corresponding to the frames to be annotated - arguments: :class:`argparse.Namespace` - The command line arguments that have called this job - """ - def __init__(self, alignments, arguments): - logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - self._alignments = alignments - self._frames = Frames(arguments.frames_dir) - self._output_folder = self._set_output() - self._mesh_areas = dict(mouth=(48, 68), - right_eyebrow=(17, 22), - left_eyebrow=(22, 27), - right_eye=(36, 42), - left_eye=(42, 48), - nose=(27, 36), - jaw=(0, 17), - chin=(8, 11)) - logger.debug("Initialized %s", self.__class__.__name__) - - def _set_output(self): - """ Set the output folder path. - - If annotating a folder of frames, output will be placed in a sub folder within the frames - folder. If annotating a video, output will be a folder next to the original video. - - Returns - ------- - str - Full path to the output folder - - """ - now = datetime.now().strftime("%Y%m%d_%H%M%S") - folder_name = f"drawn_landmarks_{now}" - if self._frames.is_video: - dest_folder = os.path.dirname(self._frames.folder) - else: - dest_folder = self._frames.folder - output_folder = os.path.join(dest_folder, folder_name) - logger.debug("Creating folder: '%s'", output_folder) - os.makedirs(output_folder) - return output_folder - - def process(self): - """ Runs the process to draw face annotations onto original source frames. """ - logger.info("[DRAW LANDMARKS]") # Tidy up cli output - frames_drawn = 0 - for frame in tqdm(self._frames.file_list_sorted, desc="Drawing landmarks"): - frame_name = frame["frame_fullname"] - - if not self._alignments.frame_exists(frame_name): - logger.verbose("Skipping '%s' - Alignments not found", frame_name) # type:ignore - continue - - self._annotate_image(frame_name) - frames_drawn += 1 - logger.info("%s Frame(s) output", frames_drawn) - - def _annotate_image(self, frame_name): - """ Annotate the frame with each face that appears in the alignments file. - - Parameters - ---------- - frame_name: str - The full path to the original frame - """ - logger.trace("Annotating frame: '%s'", frame_name) # type:ignore - image = self._frames.load_image(frame_name) - - for idx, alignment in enumerate(self._alignments.get_faces_in_frame(frame_name)): - face = DetectedFace() - face.from_alignment(alignment, image=image) - # Bounding Box - cv2.rectangle(image, (face.left, face.top), (face.right, face.bottom), (255, 0, 0), 1) - self._annotate_landmarks(image, np.rint(face.landmarks_xy).astype("int32")) - self._annotate_extract_boxes(image, face, idx) - self._annotate_pose(image, face) # Pose (head is still loaded) - - self._frames.save_image(self._output_folder, frame_name, image) - - def _annotate_landmarks(self, image, landmarks): - """ Annotate the extract boxes onto the frame. - - Parameters - ---------- - image: :class:`numpy.ndarray` - The frame that extract boxes are to be annotated on to - landmarks: :class:`numpy.ndarray` - The 68 point landmarks that are to be annotated onto the frame - index: int - The face index for the given face - """ - # Mesh - for area, indices in self._mesh_areas.items(): - fill = area in ("right_eye", "left_eye", "mouth") - cv2.polylines(image, [landmarks[indices[0]:indices[1]]], fill, (255, 255, 0), 1) - # Landmarks - for (pos_x, pos_y) in landmarks: - cv2.circle(image, (pos_x, pos_y), 1, (0, 255, 255), -1) - - @classmethod - def _annotate_extract_boxes(cls, image, face, index): - """ Annotate the mesh and landmarks boxes onto the frame. - - Parameters - ---------- - image: :class:`numpy.ndarray` - The frame that mesh and landmarks are to be annotated on to - face: :class:`lib.align.AlignedFace` - The aligned face - """ - for area in ("face", "head"): - face.load_aligned(image, centering=area, force=True) - color = (0, 255, 0) if area == "face" else (0, 0, 255) - top_left = face.aligned.original_roi[0] - top_left = (top_left[0], top_left[1] - 10) - cv2.putText(image, str(index), top_left, cv2.FONT_HERSHEY_DUPLEX, 1.0, color, 1) - cv2.polylines(image, [face.aligned.original_roi], True, color, 1) - - @classmethod - def _annotate_pose(cls, image, face): - """ Annotate the pose onto the frame. - - Parameters - ---------- - image: :class:`numpy.ndarray` - The frame that pose is to be annotated on to - face: :class:`lib.align.AlignedFace` - The aligned face loaded for head centering - """ - center = np.array((face.aligned.size / 2, - face.aligned.size / 2)).astype("int32").reshape(1, 2) - center = np.rint(face.aligned.transform_points(center, invert=True)).astype("int32") - points = face.aligned.pose.xyz_2d * face.aligned.size - points = np.rint(face.aligned.transform_points(points, invert=True)).astype("int32") - cv2.line(image, tuple(center), tuple(points[1]), (0, 255, 0), 2) - cv2.line(image, tuple(center), tuple(points[0]), (255, 0, 0), 2) - cv2.line(image, tuple(center), tuple(points[2]), (0, 0, 255), 2) - - -class Extract(): # pylint:disable=too-few-public-methods - """ Re-extract faces from source frames based on Alignment data - - Parameters - ---------- - alignments: :class:`tools.lib_alignments.media.AlignmentData` - The alignments data loaded from an alignments file for this rename job - arguments: :class:`argparse.Namespace` - The :mod:`argparse` arguments as passed in from :mod:`tools.py` - """ - def __init__(self, alignments: "AlignmentData", arguments: Namespace) -> None: - logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - self._arguments = arguments - self._alignments = alignments - self._is_legacy = self._alignments.version == 1.0 # pylint:disable=protected-access - self._mask_pipeline: Optional[Extractor] = None - self._faces_dir = arguments.faces_dir - self._min_size = self._get_min_size(arguments.size, arguments.min_size) - - self._frames = Frames(arguments.frames_dir, self._get_count()) - self._extracted_faces = ExtractedFaces(self._frames, - self._alignments, - size=arguments.size) - self._saver: Optional[ImagesSaver] = None - logger.debug("Initialized %s", self.__class__.__name__) - - @classmethod - def _get_min_size(cls, extract_size: int, min_size: int) -> int: - """ Obtain the minimum size that a face has been resized from to be included as a valid - extract. - - Parameters - ---------- - extract_size: int - The requested size of the extracted images - min_size: int - The percentage amount that has been supplied for valid faces (as a percentage of - extract size) - - Returns - ------- - int - The minimum size, in pixels, that a face is resized from to be considered valid - """ - retval = 0 if min_size == 0 else max(4, int(extract_size * (min_size / 100.))) - logger.debug("Extract size: %s, min percentage size: %s, min_size: %s", - extract_size, min_size, retval) - return retval - - def _get_count(self) -> Optional[int]: - """ If the alignments file has been run through the manual tool, then it will hold video - meta information, meaning that the count of frames in the alignment file can be relied - on to be accurate. - - Returns - ------- - int or ``None`` - For video input which contain video meta-data in the alignments file then the count of - frames is returned. In all other cases ``None`` is returned - """ - has_meta = all(val is not None for val in self._alignments.video_meta_data.values()) - retval = len(self._alignments.video_meta_data["pts_time"]) if has_meta else None - logger.debug("Frame count from alignments file: (has_meta: %s, %s", has_meta, retval) - return retval - - def process(self) -> None: - """ Run the re-extraction from Alignments file process""" - logger.info("[EXTRACT FACES]") # Tidy up cli output - self._check_folder() - if self._is_legacy: - self._legacy_check() - self._saver = ImagesSaver(self._faces_dir, as_bytes=True) - - if self._min_size > 0: - logger.info("Only selecting faces that have been resized from a minimum resolution " - "of %spx", self._min_size) - - self._export_faces() - - def _check_folder(self) -> None: - """ Check that the faces folder doesn't pre-exist and create. """ - err = None - if not self._faces_dir: - err = "ERROR: Output faces folder not provided." - elif not os.path.isdir(self._faces_dir): - logger.debug("Creating folder: '%s'", self._faces_dir) - os.makedirs(self._faces_dir) - elif os.listdir(self._faces_dir): - err = f"ERROR: Output faces folder should be empty: '{self._faces_dir}'" - if err: - logger.error(err) - sys.exit(0) - logger.verbose("Creating output folder at '%s'", self._faces_dir) # type:ignore - - def _legacy_check(self) -> None: - """ Check whether the alignments file was created with the legacy extraction method. - - If so, force user to re-extract all faces if any options have been specified, otherwise - raise the appropriate warnings and set the legacy options. - """ - if self._min_size > 0 or self._arguments.extract_every_n != 1: - logger.warning("This alignments file was generated with the legacy extraction method.") - logger.warning("You should run this extraction job, but with 'min_size' set to 0 and " - "'extract-every-n' set to 1 to update the alignments file.") - logger.warning("You can then re-run this extraction job with your chosen options.") - sys.exit(0) - - maskers = ["components", "extended"] - nn_masks = [mask for mask in list(self._alignments.mask_summary) if mask not in maskers] - logtype = logger.warning if nn_masks else logger.info - logtype("This alignments file was created with the legacy extraction method and will be " - "updated.") - logtype("Faces will be extracted using the new method and landmarks based masks will be " - "regenerated.") - if nn_masks: - logtype("However, the NN based masks '%s' will be cropped to the legacy extraction " - "method, so you may want to run the mask tool to regenerate these " - "masks.", "', '".join(nn_masks)) - self._mask_pipeline = Extractor(None, None, maskers, multiprocess=True) - self._mask_pipeline.launch() - # Update alignments versioning - self._alignments._version = _VERSION # pylint:disable=protected-access - - def _export_faces(self) -> None: - """ Export the faces to the output folder. """ - extracted_faces = 0 - skip_list = self._set_skip_list() - count = self._frames.count if skip_list is None else self._frames.count - len(skip_list) - - for filename, image in tqdm(self._frames.stream(skip_list=skip_list), - total=count, desc="Saving extracted faces"): - frame_name = os.path.basename(filename) - if not self._alignments.frame_exists(frame_name): - logger.verbose("Skipping '%s' - Alignments not found", frame_name) # type:ignore - continue - extracted_faces += self._output_faces(frame_name, image) - if self._is_legacy and extracted_faces != 0 and self._min_size == 0: - self._alignments.save() - logger.info("%s face(s) extracted", extracted_faces) - - def _set_skip_list(self) -> Optional[List[int]]: - """ Set the indices for frames that should be skipped based on the `extract_every_n` - command line option. - - Returns - ------- - list or ``None`` - A list of indices to be skipped if extract_every_n is not `1` otherwise - returns ``None`` - """ - skip_num = self._arguments.extract_every_n - if skip_num == 1: - logger.debug("Not skipping any frames") - return None - skip_list = [] - for idx, item in enumerate(self._frames.file_list_sorted): - if idx % skip_num != 0: - logger.trace("Adding image '%s' to skip list due to " # type:ignore - "extract_every_n = %s", item["frame_fullname"], skip_num) - skip_list.append(idx) - logger.debug("Adding skip list: %s", skip_list) - return skip_list - - def _output_faces(self, filename: str, image: np.ndarray) -> int: - """ For each frame save out the faces - - Parameters - ---------- - filename: str - The filename (without the full path) of the current frame - image: :class:`numpy.ndarray` - The full frame that faces are to be extracted from - - Returns - ------- - int - The total number of faces that have been extracted - """ - logger.trace("Outputting frame: %s", filename) # type:ignore - face_count = 0 - frame_name = os.path.splitext(filename)[0] - faces = self._select_valid_faces(filename, image) - assert self._saver is not None - if not faces: - return face_count - if self._is_legacy: - faces = self._process_legacy(filename, image, faces) - - for idx, face in enumerate(faces): - output = f"{frame_name}_{idx}.png" - meta = dict(alignments=face.to_png_meta(), - source=dict(alignments_version=self._alignments.version, - original_filename=output, - face_index=idx, - source_filename=filename, - source_is_video=self._frames.is_video, - source_frame_dims=image.shape[:2])) - self._saver.save(output, encode_image(face.aligned.face, ".png", metadata=meta)) - if self._min_size == 0 and self._is_legacy: - face.thumbnail = generate_thumbnail(face.aligned.face, size=96, quality=60) - self._alignments.data[filename]["faces"][idx] = face.to_alignment() - face_count += 1 - self._saver.close() - return face_count - - def _select_valid_faces(self, frame: str, image: np.ndarray) -> List[DetectedFace]: - """ Return the aligned faces from a frame that meet the selection criteria, - - Parameters - ---------- - frame: str - The filename (without the full path) of the current frame - image: :class:`numpy.ndarray` - The full frame that faces are to be extracted from - - Returns - ------- - list: - List of valid :class:`lib,align.DetectedFace` objects - """ - faces = self._extracted_faces.get_faces_in_frame(frame, image=image) - if self._min_size == 0: - valid_faces = faces - else: - sizes = self._extracted_faces.get_roi_size_for_frame(frame) - valid_faces = [faces[idx] for idx, size in enumerate(sizes) - if size >= self._min_size] - logger.trace("frame: '%s', total_faces: %s, valid_faces: %s", # type:ignore - frame, len(faces), len(valid_faces)) - return valid_faces - - def _process_legacy(self, - filename: str, - image: np.ndarray, - detected_faces: List[DetectedFace]) -> List[DetectedFace]: - """ Process legacy face extractions to new extraction method. - - Updates stored masks to new extract size - - Parameters - ---------- - filename: str - The current frame filename - image: :class:`numpy.ndarray` - The current image the contains the faces - detected_faces: list - list of :class:`lib.align.DetectedFace` objects for the current frame - - Returns - ------- - list - The updated list of :class:`lib.align.DetectedFace` objects for the current frame - """ - # Update landmarks based masks for face centering - assert self._mask_pipeline is not None - mask_item = ExtractMedia(filename, image, detected_faces=detected_faces) - self._mask_pipeline.input_queue.put(mask_item) - faces = next(self._mask_pipeline.detected_faces()).detected_faces - - # Pad and shift Neural Network based masks to face centering - for face in faces: - self._pad_legacy_masks(face) - return faces - - @classmethod - def _pad_legacy_masks(cls, detected_face: DetectedFace) -> None: - """ Recenter legacy Neural Network based masks from legacy centering to face centering - and pad accordingly. - - Update the masks back into the detected face objects. - - Parameters - ---------- - detected_face: :class:`lib.align.DetectedFace` - The detected face to update the masks for - """ - offset = detected_face.aligned.pose.offset["face"] - for name, mask in detected_face.mask.items(): # Re-center mask and pad to face size - if name in ("components", "extended"): - continue - old_mask = mask.mask.astype("float32") / 255.0 - size = old_mask.shape[0] - new_size = int(size + (size * _EXTRACT_RATIOS["face"]) / 2) - - shift = np.rint(offset * (size - (size * _EXTRACT_RATIOS["face"]))).astype("int32") - pos = np.array([(new_size // 2 - size // 2) - shift[1], - (new_size // 2) + (size // 2) - shift[1], - (new_size // 2 - size // 2) - shift[0], - (new_size // 2) + (size // 2) - shift[0]]) - bounds = np.array([max(0, pos[0]), min(new_size, pos[1]), - max(0, pos[2]), min(new_size, pos[3])]) - - slice_in = [slice(0 - (pos[0] - bounds[0]), size - (pos[1] - bounds[1])), - slice(0 - (pos[2] - bounds[2]), size - (pos[3] - bounds[3]))] - slice_out = [slice(bounds[0], bounds[1]), slice(bounds[2], bounds[3])] - - new_mask = np.zeros((new_size, new_size, 1), dtype="float32") - new_mask[slice_out[0], slice_out[1], :] = old_mask[slice_in[0], slice_in[1], :] - - mask.replace_mask(new_mask) - # Get the affine matrix from recently generated components mask - # pylint:disable=protected-access - mask._affine_matrix = detected_face.mask["components"].affine_matrix - - -class FromFaces(): # pylint:disable=too-few-public-methods - """ Scan a folder of Faceswap Extracted Faces and re-create the associated alignments file(s) - - Parameters - ---------- - alignments: NoneType - Parameter included for standard job naming convention, but not used for this process. - arguments: :class:`argparse.Namespace` - The :mod:`argparse` arguments as passed in from :mod:`tools.py` - """ - def __init__(self, alignments: None, arguments: Namespace) -> None: - logger.debug("Initializing %s: (alignments: %s, arguments: %s)", - self.__class__.__name__, alignments, arguments) - self._faces_dir = arguments.faces_dir - self._filelist = self._get_filenames() - logger.debug("Initialized %s", self.__class__.__name__) - - def _get_filenames(self) -> List[str]: - """ Obtain the full path to all filenames in the specified faces folder. - - Only png files will be returned, any other files will be ignored. An error is output if - the returned filelist is not valid - - Returns - ------- - list - Full path list to face png files - """ - err = None - if not self._faces_dir: - err = "A faces folder must be provided." - elif not os.path.isdir(self._faces_dir): - err = f"The Faces location '{self._faces_dir}' does not exit" - else: - filelist = [os.path.join(self._faces_dir, fname) - for fname in os.listdir(self._faces_dir) - if os.path.splitext(fname.lower())[1] == ".png"] - if not err and not filelist: - err = "Faces folder should contain Faceswap extracted PNG files" - if err: - logger.error(err) - sys.exit(0) - logger.debug("Collected %s png images from folder '%s'", len(filelist), self._faces_dir) - return filelist - - def process(self) -> None: - """ Run the job to read faces from a folder to create alignments file(s). """ - logger.info("[CREATE ALIGNMENTS FROM FACES]") # Tidy up cli output - skip_count = 0 - d_align: Dict[str, Dict[str, List[Tuple[int, AlignmentFileDict, str, dict]]]] = {} - for filename, meta in tqdm(read_image_meta_batch(self._filelist), - desc="Generating Alignments", - total=len(self._filelist), - leave=False): - - if "itxt" not in meta or "alignments" not in meta["itxt"]: - logger.verbose("skipping invalid file: '%s'", filename) # type:ignore - skip_count += 1 - continue - - align_fname = self._get_alignments_filename(meta["itxt"]["source"]) - source_name, f_idx, alignment = self._extract_alignment(meta) - full_info = (f_idx, alignment, filename, meta["itxt"]["source"]) - - d_align.setdefault(align_fname, {}).setdefault(source_name, []).append(full_info) - - alignments = self._sort_alignments(d_align) - self._save_alignments(alignments) - if skip_count > 1: - logger.warning("%s of %s files skipped that do not contain valid alignment data", - skip_count, len(self._filelist)) - logger.warning("Run the process in verbose mode to see which files were skipped") - - @classmethod - def _get_alignments_filename(cls, source_data: dict) -> str: - """ Obtain the name of the alignments file from the source information contained within the - PNG metadata. - - Parameters - ---------- - source_data: dict - The source information contained within a Faceswap extracted PNG - - Returns - ------- - str: - If the face was generated from a video file, the filename will be - `'_alignments.fsa'`. If it was extracted from an image file it will be - `'alignments.fsa'` - """ - is_video = source_data["source_is_video"] - src_name = source_data["source_filename"] - prefix = f"{src_name.rpartition('_')[0]}_" if is_video else "" - retval = f"{prefix}alignments.fsa" - logger.trace("Extracted alignments file filename: '%s'", retval) # type:ignore - return retval - - def _extract_alignment(self, metadata: dict) -> Tuple[str, int, AlignmentFileDict]: - """ Extract alignment data from a PNG image's itxt header. - - Formats the landmarks into a numpy array and adds in mask centering information if it is - from an older extract. - - Parameters - ---------- - metadata: dict - An extracted faces PNG Header data - - Returns - ------- - tuple - The alignment's source frame name in position 0. The index of the face within the - alignment file in position 1. The alignment data correctly formatted for writing to an - alignments file in positin 2 - """ - alignment = metadata["itxt"]["alignments"] - alignment["landmarks_xy"] = np.array(alignment["landmarks_xy"], dtype="float32") - - src = metadata["itxt"]["source"] - frame_name = src["source_filename"] - face_index = int(src["face_index"]) - version = src["alignments_version"] - - if version < 2.2: - logger.trace("Updating mask centering for frame '%s', face index: %s, " # type:ignore - "version: %s", frame_name, face_index, version) - self._update_mask_centering(alignment) - - logger.trace("Extracted alignment for frame: '%s', face index: %s", # type:ignore - frame_name, face_index) - return frame_name, face_index, alignment - - @classmethod - def _update_mask_centering(cls, alignment: dict) -> None: - """ Prior to alignment version 2.2 all masks were stored with face centering. - - Update the existing masks with correct centering parameter. - - Parameters - ---------- - alignment: dict - The alignment for the face to have the mask centering parameter updated - """ - if "mask" not in alignment: - alignment["mask"] = {} - for mask in alignment["mask"].values(): - mask["stored_centering"] = "face" - - def _sort_alignments(self, - alignments: Dict[str, Dict[str, List[Tuple[int, - AlignmentFileDict, - str, - dict]]]] - ) -> Dict[str, Dict[str, List[AlignmentFileDict]]]: - """ Sort the faces into face index order as they appeared in the original alignments file. - - If the face index stored in the png header does not match it's position in the alignments - file (i.e. A face has been removed from a frame) then update the header of the - corresponding png to the correct index as exists in the newly created alignments file. - - Parameters - ---------- - alignments: dict - The unsorted alignments file(s) as generated from the face PNG headers, including the - face index of the face within it's respective frame, the original face filename and - the orignal face header source information - - Returns - ------- - dict - The alignments file dictionaries sorted into the correct face order, ready for saving - """ - logger.info("Sorting and checking faces...") - aln_sorted: Dict[str, Dict[str, List[AlignmentFileDict]]] = {} - for fname, frames in alignments.items(): - this_file: Dict[str, List[AlignmentFileDict]] = {} - for frame in tqdm(sorted(frames), desc=f"Sorting {fname}", leave=False): - this_file[frame] = [] - for real_idx, (f_id, almt, f_path, f_src) in enumerate(sorted(frames[frame], - key=itemgetter(0))): - if real_idx != f_id: - self._update_png_header(f_path, real_idx, almt, f_src) - this_file[frame].append(almt) - aln_sorted[fname] = this_file - return aln_sorted - - @classmethod - def _update_png_header(cls, - face_path: str, - new_index: int, - alignment: AlignmentFileDict, - source_info: dict) -> None: - """ Update the PNG header for faces where the stored index does not correspond with the - alignments file. This can occur when frames with multiple faces have had some faces deleted - from the faces folder. - - Updates the original filename and index in the png header. - - Parameters - ---------- - face_path: str - Full path to the saved face image that requires updating - new_index: int - The new index as it appears in the newly generated alignments file - alignment: dict - The alignment information to store in the png header - source_info: dict - The face source information as extracted from the original face png file - """ - face = DetectedFace() - face.from_alignment(alignment) - new_filename = f"{os.path.splitext(source_info['source_filename'])[0]}_{new_index}.png" - - logger.trace("Updating png header for '%s': (face index from %s to %s, " # type:ignore - "original filename from '%s' to '%s'", face_path, source_info["face_index"], - new_index, source_info["original_filename"], new_filename) - - source_info["face_index"] = new_index - source_info["original_filename"] = new_filename - meta = dict(alignments=face.to_png_meta(), source=source_info) - update_existing_metadata(face_path, meta) - - def _save_alignments(self, all_alignments: dict) -> None: - """ Save the newely generated alignments file(s). - - If an alignments file already exists in the source faces folder, back it up rather than - overwriting - - Parameters - ---------- - all_alignments: dict - The alignment(s) dictionaries found in the faces folder. Alignment filename as key, - corresponding alignments as value. - """ - for fname, alignments in all_alignments.items(): - 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.backup() - aln.save() - - -class RemoveFaces(): # pylint:disable=too-few-public-methods - """ Remove items from alignments file. - - Parameters - --------- - alignments: :class:`tools.alignments.media.AlignmentsData` - The loaded alignments containing faces to be removed - arguments: :class:`argparse.Namespace` - The command line arguments that have called this job - """ - def __init__(self, alignments: "AlignmentData", arguments: Namespace) -> None: - logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - self._alignments = alignments - - kwargs = {} - if alignments.version < 2.1: - # Update headers of faces generated with hash based alignments - kwargs["alignments"] = alignments - self._items = Faces(arguments.faces_dir, **kwargs) - logger.debug("Initialized %s", self.__class__.__name__) - - def process(self) -> None: - """ Run the job to remove faces from an alignments file that do not exist within a faces - folder. """ - logger.info("[REMOVE FACES FROM ALIGNMENTS]") # Tidy up cli output - - if not self._items.items: - logger.error("No matching faces found in your faces folder. This would remove all " - "faces from your alignments file. Process aborted.") - return - - pre_face_count = self._alignments.faces_count - self._alignments.filter_faces(self._items.items, filter_out=False) - del_count = pre_face_count - self._alignments.faces_count - if del_count == 0: - logger.info("No changes made to alignments file. Exiting") - return - - logger.info("%s alignment(s) were removed from alignments file", del_count) - - self._update_png_headers() - self._alignments.save() - - rename = Rename(self._alignments, None, self._items) - rename.process() - - def _update_png_headers(self) -> None: - """ Update the EXIF iTXt field of any face PNGs that have had their face index changed. - - Notes - ----- - This could be quicker if parellizing in threads, however, Windows (at least) does not seem - to like this and has a tendency to throw permission errors, so this remains single threaded - for now. - """ - to_update = [ # Items whose face index has changed - x for x in self._items.file_list_sorted - if x["face_index"] != self._items.items[x["source_filename"]].index(x["face_index"])] - - for file_info in tqdm(to_update, desc="Updating PNG Headers", leave=False): - frame = file_info["source_filename"] - face_index = file_info["face_index"] - new_index = self._items.items[frame].index(face_index) - - fullpath = os.path.join(self._items.folder, file_info["current_filename"]) - logger.debug("Updating png header for '%s': face index from %s to %s", - fullpath, face_index, new_index) - - # Update file_list_sorted for rename task - orig_filename = f"{os.path.splitext(frame)[0]}_{new_index}.png" - file_info["face_index"] = new_index - file_info["original_filename"] = orig_filename - - face = DetectedFace() - face.from_alignment(self._alignments.get_faces_in_frame(frame)[new_index]) - meta = dict(alignments=face.to_png_meta(), - source=dict(alignments_version=file_info["alignments_version"], - original_filename=orig_filename, - face_index=new_index, - source_filename=frame, - source_is_video=file_info["source_is_video"], - source_frame_dims=file_info.get("source_frame_dims"))) - update_existing_metadata(fullpath, meta) - - logger.info("%s Extracted face(s) had their header information updated", len(to_update)) - - -class Rename(): # pylint:disable=too-few-public-methods - """ Rename faces in a folder to match their filename as stored in an alignments file. - - Parameters - ---------- - alignments: :class:`tools.lib_alignments.media.AlignmentData` - The alignments data loaded from an alignments file for this rename job - arguments: :class:`argparse.Namespace` - The :mod:`argparse` arguments as passed in from :mod:`tools.py` - faces: :class:`tools.lib_alignments.media.Faces`, Optional - An optional faces object, if the rename task is being called by another job. - Default: ``None`` - """ - def __init__(self, - alignments: "AlignmentData", - arguments: Optional[Namespace], - faces: Optional[Faces] = None) -> None: - logger.debug("Initializing %s: (arguments: %s, faces: %s)", - self.__class__.__name__, arguments, faces) - self._alignments = alignments - - kwargs = {} - if alignments.version < 2.1: - # Update headers of faces generated with hash based alignments - kwargs["alignments"] = alignments - if faces: - self._faces = faces - else: - assert arguments is not None - self._faces = Faces(arguments.faces_dir, **kwargs) - logger.debug("Initialized %s", self.__class__.__name__) - - def process(self) -> None: - """ Process the face renaming """ - logger.info("[RENAME FACES]") # Tidy up cli output - rename_mappings = sorted([(face["current_filename"], face["original_filename"]) - for face in self._faces.file_list_sorted - if face["current_filename"] != face["original_filename"]], - key=lambda x: x[1]) - rename_count = self._rename_faces(rename_mappings) - logger.info("%s faces renamed", rename_count) - - def _rename_faces(self, filename_mappings: List[Tuple[str, str]]) -> int: - """ Rename faces back to their original name as exists in the alignments file. - - If the source and destination filename are the same then skip that file. - - Parameters - ---------- - filename_mappings: list - List of tuples of (`source filename`, `destination filename`) ordered by destination - filename - - Returns - ------- - int - The number of faces that have been renamed - """ - if not filename_mappings: - return 0 - - rename_count = 0 - conflicts = [] - for src, dst in tqdm(filename_mappings, desc="Renaming Faces"): - old = os.path.join(self._faces.folder, src) - new = os.path.join(self._faces.folder, dst) - - if os.path.exists(new): - # Interim add .tmp extension to files that will cause a rename conflict, to - # process afterwards - logger.debug("interim renaming file to avoid conflict: (src: '%s', dst: '%s')", - src, dst) - new = new + ".tmp" - conflicts.append(new) - - logger.verbose("Renaming '%s' to '%s'", old, new) # type:ignore - os.rename(old, new) - rename_count += 1 - if conflicts: - for old in tqdm(conflicts, desc="Renaming Faces"): - new = old[:-4] # Remove .tmp extension - if os.path.exists(new): - # This should only be running on faces. If there is still a conflict - # then the user has done something stupid, so we will delete the file and - # replace. They can always re-extract :/ - os.remove(new) - logger.verbose("Renaming '%s' to '%s'", old, new) # type:ignore - os.rename(old, new) - return rename_count - - class Sort(): """ Sort alignments' index by the order they appear in an image in left to right order. @@ -1132,7 +375,7 @@ class Sort(): arguments: :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in from :mod:`tools.py` """ - def __init__(self, alignments: "AlignmentData", arguments: Namespace) -> None: + def __init__(self, alignments: "AlignmentData", arguments: "Namespace") -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self._alignments = alignments logger.debug("Initialized %s", self.__class__.__name__) @@ -1150,7 +393,9 @@ def reindex_faces(self) -> int: """ Re-Index the faces """ reindexed = 0 for alignment in tqdm(self._alignments.yield_faces(), - desc="Sort alignment indexes", total=self._alignments.frames_count): + desc="Sort alignment indexes", + total=self._alignments.frames_count, + leave=False): frame, alignments, count, key = alignment if count <= 1: logger.trace("0 or 1 face in frame. Not sorting: '%s'", frame) # type:ignore @@ -1167,21 +412,30 @@ def reindex_faces(self) -> int: return reindexed -class Spatial(): +class Spatial(): # pylint:disable=too-few-public-methods """ Apply spatial temporal filtering to landmarks - Adapted from: - https://www.kaggle.com/selfishgene/animating-and-smoothing-3d-facial-keypoints/notebook """ - def __init__(self, alignments, arguments): + Parameters + ---------- + alignments: :class:`tools.lib_alignments.media.AlignmentData` + The alignments data loaded from an alignments file for this rename job + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + + Reference + --------- + https://www.kaggle.com/selfishgene/animating-and-smoothing-3d-facial-keypoints/notebook + """ + def __init__(self, alignments: "AlignmentData", arguments: "Namespace") -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self.arguments = arguments self._alignments = alignments - self.mappings = {} - self.normalized = {} - self.shapes_model = None + self._mappings: Dict[int, str] = {} + self._normalized: Dict[str, np.ndarray] = {} + self._shapes_model: Optional[decomposition.PCA] = None logger.debug("Initialized %s", self.__class__.__name__) - def process(self): + def process(self) -> None: """ Perform spatial filtering """ logger.info("[SPATIO-TEMPORAL FILTERING]") # Tidy up cli output logger.info("NB: The process only processes the alignments for the first " @@ -1189,19 +443,35 @@ def process(self): "there is only a single face in the alignments file and all false positives " "have been removed") - self.normalize() - self.shape_model() - landmarks = self.spatially_filter() - landmarks = self.temporally_smooth(landmarks) - self.update_alignments(landmarks) + self._normalize() + self._shape_model() + landmarks = self._spatially_filter() + landmarks = self._temporally_smooth(landmarks) + self._update_alignments(landmarks) self._alignments.save() logger.warning("If you have a face-set corresponding to the alignment file you " "processed then you should run the 'Extract' job to regenerate it.") # Define shape normalization utility functions @staticmethod - def normalize_shapes(shapes_im_coords): - """ Normalize a 2D or 3D shape """ + def _normalize_shapes(shapes_im_coords: np.ndarray + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """ Normalize a 2D or 3D shape + + Parameters + ---------- + shaped_im_coords: :class:`numpy.ndarray` + The 68 point landmarks + + Returns + ------- + shapes_normalized: :class:`numpy.ndarray` + The normalized shapes + scale_factors: :class:`numpy.ndarray` + The scale factors + mean_coords: :class:`numpy.ndarray` + The mean coordinates + """ logger.debug("Normalize shapes") (num_pts, num_dims, _) = shapes_im_coords.shape @@ -1220,8 +490,25 @@ def normalize_shapes(shapes_im_coords): return shapes_normalized, scale_factors, mean_coords @staticmethod - def normalized_to_original(shapes_normalized, scale_factors, mean_coords): - """ Transform a normalized shape back to original image coordinates """ + def _normalized_to_original(shapes_normalized: np.ndarray, + scale_factors: np.ndarray, + mean_coords: np.ndarray) -> np.ndarray: + """ Transform a normalized shape back to original image coordinates + + Parameters + ---------- + shapes_normalized: :class:`numpy.ndarray` + The normalized shapes + scale_factors: :class:`numpy.ndarray` + The scale factors + mean_coords: :class:`numpy.ndarray` + The mean coordinates + + Returns + ------- + :class:`numpy.ndarray` + The normalized shape transformed back to original coordinates + """ logger.debug("Normalize to original") (num_pts, num_dims, _) = shapes_normalized.shape @@ -1233,14 +520,14 @@ def normalized_to_original(shapes_normalized, scale_factors, mean_coords): logger.debug("Normalized to original: %s", shapes_im_coords) return shapes_im_coords - def normalize(self): + def _normalize(self) -> None: """ Compile all original and normalized alignments """ logger.debug("Normalize") count = sum(1 for val in self._alignments.data.values() if val["faces"]) landmarks_all = np.zeros((68, 2, int(count))) end = 0 - for key in tqdm(sorted(self._alignments.data.keys()), desc="Compiling"): + for key in tqdm(sorted(self._alignments.data.keys()), desc="Compiling", leave=False): val = self._alignments.data[key]["faces"] if not val: continue @@ -1252,53 +539,70 @@ def normalize(self): # Store in one big array landmarks_all[:, :, start:end] = landmarks # Make sure we keep track of the mapping to the original frame - self.mappings[start] = key + self._mappings[start] = key # Normalize shapes - normalized_shape = self.normalize_shapes(landmarks_all) - self.normalized["landmarks"] = normalized_shape[0] - self.normalized["scale_factors"] = normalized_shape[1] - self.normalized["mean_coords"] = normalized_shape[2] - logger.debug("Normalized: %s", self.normalized) + normalized_shape = self._normalize_shapes(landmarks_all) + self._normalized["landmarks"] = normalized_shape[0] + self._normalized["scale_factors"] = normalized_shape[1] + self._normalized["mean_coords"] = normalized_shape[2] + logger.debug("Normalized: %s", self._normalized) - def shape_model(self): + def _shape_model(self) -> None: """ build 2D shape model """ logger.debug("Shape model") - landmarks_norm = self.normalized["landmarks"] + landmarks_norm = self._normalized["landmarks"] num_components = 20 normalized_shapes_tbl = np.reshape(landmarks_norm, [68*2, landmarks_norm.shape[2]]).T - self.shapes_model = decomposition.PCA(n_components=num_components, - whiten=True, - random_state=1).fit(normalized_shapes_tbl) - explained = self.shapes_model.explained_variance_ratio_.sum() + self._shapes_model = decomposition.PCA(n_components=num_components, + whiten=True, + random_state=1).fit(normalized_shapes_tbl) + explained = self._shapes_model.explained_variance_ratio_.sum() logger.info("Total explained percent by PCA model with %s components is %s%%", num_components, round(100 * explained, 1)) logger.debug("Shaped model") - def spatially_filter(self): - """ interpret the shapes using our shape model - (project and reconstruct) """ + def _spatially_filter(self) -> np.ndarray: + """ interpret the shapes using our shape model (project and reconstruct) + + Returns + ------- + :class:`numpy.ndarray` + The filtered landmarks in original coordinate space + """ logger.debug("Spatially Filter") - landmarks_norm = self.normalized["landmarks"] + assert self._shapes_model is not None + landmarks_norm = self._normalized["landmarks"] # Convert to matrix form landmarks_norm_table = np.reshape(landmarks_norm, [68 * 2, landmarks_norm.shape[2]]).T # Project onto shapes model and reconstruct - landmarks_norm_table_rec = self.shapes_model.inverse_transform( - self.shapes_model.transform(landmarks_norm_table)) + landmarks_norm_table_rec = self._shapes_model.inverse_transform( + self._shapes_model.transform(landmarks_norm_table)) # Convert back to shapes (numKeypoint, num_dims, numFrames) landmarks_norm_rec = np.reshape(landmarks_norm_table_rec.T, [68, 2, landmarks_norm.shape[2]]) # Transform back to image co-ordinates - retval = self.normalized_to_original(landmarks_norm_rec, - self.normalized["scale_factors"], - self.normalized["mean_coords"]) + retval = self._normalized_to_original(landmarks_norm_rec, + self._normalized["scale_factors"], + self._normalized["mean_coords"]) logger.debug("Spatially Filtered: %s", retval) return retval @staticmethod - def temporally_smooth(landmarks): - """ apply temporal filtering on the 2D points """ + def _temporally_smooth(landmarks: np.ndarray) -> np.ndarray: + """ apply temporal filtering on the 2D points + + Parameters + ---------- + landmarks: :class:`numpy.ndarray` + 68 point landmarks to be temporally smoothed + + Returns + ------- + :class: `numpy.ndarray` + The temporally smoothed landmarks + """ logger.debug("Temporally Smooth") filter_half_length = 2 temporal_filter = np.ones((1, 1, 2 * filter_half_length + 1)) @@ -1312,10 +616,16 @@ def temporally_smooth(landmarks): logger.debug("Temporally Smoothed: %s", retval) return retval - def update_alignments(self, landmarks): - """ Update smoothed landmarks back to alignments """ + def _update_alignments(self, landmarks: np.ndarray) -> None: + """ Update smoothed landmarks back to alignments + + Parameters + ---------- + landmarks: :class:`numpy.ndarray` + The smoothed landmarks + """ logger.debug("Update alignments") - for idx, frame in tqdm(self.mappings.items(), desc="Updating"): + for idx, frame in tqdm(self._mappings.items(), desc="Updating", leave=False): logger.trace("Updating: (frame: %s)", frame) # type:ignore landmarks_update = landmarks[:, :, idx] landmarks_xy = landmarks_update.reshape(68, 2).tolist() diff --git a/tools/alignments/jobs_faces.py b/tools/alignments/jobs_faces.py new file mode 100644 index 0000000000..7995ddf215 --- /dev/null +++ b/tools/alignments/jobs_faces.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +""" Tools for manipulating the alignments using extracted Faces as a source """ +import os +import sys +import logging +from argparse import Namespace +from operator import itemgetter +from typing import cast, Dict, List, Optional, Tuple, TYPE_CHECKING, Union + +import numpy as np +from tqdm import tqdm + +from lib.align import DetectedFace +from lib.image import update_existing_metadata, read_image_meta_batch # TODO remove +from scripts.fsmedia import Alignments + +from .media import Faces + +if TYPE_CHECKING: + from .media import AlignmentData + from lib.align.alignments import AlignmentFileDict, PNGHeaderSourceDict + +logger = logging.getLogger(__name__) + + +class FromFaces(): # pylint:disable=too-few-public-methods + """ Scan a folder of Faceswap Extracted Faces and re-create the associated alignments file(s) + + Parameters + ---------- + alignments: NoneType + Parameter included for standard job naming convention, but not used for this process. + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + """ + def __init__(self, alignments: None, arguments: Namespace) -> None: + logger.debug("Initializing %s: (alignments: %s, arguments: %s)", + self.__class__.__name__, alignments, arguments) + self._faces_dir = arguments.faces_dir + self._filelist = self._get_filenames() + logger.debug("Initialized %s", self.__class__.__name__) + + def _get_filenames(self) -> List[str]: + """ Obtain the full path to all filenames in the specified faces folder. + + Only png files will be returned, any other files will be ignored. An error is output if + the returned filelist is not valid + + Returns + ------- + list + Full path list to face png files + """ + err = None + if not self._faces_dir: + err = "A faces folder must be provided." + elif not os.path.isdir(self._faces_dir): + err = f"The Faces location '{self._faces_dir}' does not exit" + else: + filelist = [os.path.join(self._faces_dir, fname) + for fname in os.listdir(self._faces_dir) + if os.path.splitext(fname.lower())[1] == ".png"] + if not err and not filelist: + err = "Faces folder should contain Faceswap extracted PNG files" + if err: + logger.error(err) + sys.exit(0) + logger.debug("Collected %s png images from folder '%s'", len(filelist), self._faces_dir) + return filelist + + def process(self) -> None: + """ Run the job to read faces from a folder to create alignments file(s). """ + logger.info("[CREATE ALIGNMENTS FROM FACES]") # Tidy up cli output + skip_count = 0 + d_align: Dict[str, Dict[str, List[Tuple[int, "AlignmentFileDict", str, dict]]]] = {} + for filename, meta in tqdm(read_image_meta_batch(self._filelist), + desc="Generating Alignments", + total=len(self._filelist), + leave=False): + + if "itxt" not in meta or "alignments" not in meta["itxt"]: + logger.verbose("skipping invalid file: '%s'", filename) # type:ignore + skip_count += 1 + continue + + align_fname = self._get_alignments_filename(meta["itxt"]["source"]) + source_name, f_idx, alignment = self._extract_alignment(meta) + full_info = (f_idx, alignment, filename, meta["itxt"]["source"]) + + d_align.setdefault(align_fname, {}).setdefault(source_name, []).append(full_info) + + alignments = self._sort_alignments(d_align) + self._save_alignments(alignments) + if skip_count > 1: + logger.warning("%s of %s files skipped that do not contain valid alignment data", + skip_count, len(self._filelist)) + logger.warning("Run the process in verbose mode to see which files were skipped") + + @classmethod + def _get_alignments_filename(cls, source_data: dict) -> str: + """ Obtain the name of the alignments file from the source information contained within the + PNG metadata. + + Parameters + ---------- + source_data: dict + The source information contained within a Faceswap extracted PNG + + Returns + ------- + str: + If the face was generated from a video file, the filename will be + `'_alignments.fsa'`. If it was extracted from an image file it will be + `'alignments.fsa'` + """ + is_video = source_data["source_is_video"] + src_name = source_data["source_filename"] + prefix = f"{src_name.rpartition('_')[0]}_" if is_video else "" + retval = f"{prefix}alignments.fsa" + logger.trace("Extracted alignments file filename: '%s'", retval) # type:ignore + return retval + + def _extract_alignment(self, metadata: dict) -> Tuple[str, int, "AlignmentFileDict"]: + """ Extract alignment data from a PNG image's itxt header. + + Formats the landmarks into a numpy array and adds in mask centering information if it is + from an older extract. + + Parameters + ---------- + metadata: dict + An extracted faces PNG Header data + + Returns + ------- + tuple + The alignment's source frame name in position 0. The index of the face within the + alignment file in position 1. The alignment data correctly formatted for writing to an + alignments file in positin 2 + """ + alignment = metadata["itxt"]["alignments"] + alignment["landmarks_xy"] = np.array(alignment["landmarks_xy"], dtype="float32") + + src = metadata["itxt"]["source"] + frame_name = src["source_filename"] + face_index = int(src["face_index"]) + version = src["alignments_version"] + + if version < 2.2: + logger.trace("Updating mask centering for frame '%s', face index: %s, " # type:ignore + "version: %s", frame_name, face_index, version) + self._update_mask_centering(alignment) + + logger.trace("Extracted alignment for frame: '%s', face index: %s", # type:ignore + frame_name, face_index) + return frame_name, face_index, alignment + + @classmethod + def _update_mask_centering(cls, alignment: dict) -> None: + """ Prior to alignment version 2.2 all masks were stored with face centering. + + Update the existing masks with correct centering parameter. + + Parameters + ---------- + alignment: dict + The alignment for the face to have the mask centering parameter updated + """ + if "mask" not in alignment: + alignment["mask"] = {} + for mask in alignment["mask"].values(): + mask["stored_centering"] = "face" + + def _sort_alignments(self, + alignments: Dict[str, Dict[str, List[Tuple[int, + "AlignmentFileDict", + str, + dict]]]] + ) -> Dict[str, Dict[str, List["AlignmentFileDict"]]]: + """ Sort the faces into face index order as they appeared in the original alignments file. + + If the face index stored in the png header does not match it's position in the alignments + file (i.e. A face has been removed from a frame) then update the header of the + corresponding png to the correct index as exists in the newly created alignments file. + + Parameters + ---------- + alignments: dict + The unsorted alignments file(s) as generated from the face PNG headers, including the + face index of the face within it's respective frame, the original face filename and + the orignal face header source information + + Returns + ------- + dict + The alignments file dictionaries sorted into the correct face order, ready for saving + """ + logger.info("Sorting and checking faces...") + aln_sorted: Dict[str, Dict[str, List["AlignmentFileDict"]]] = {} + for fname, frames in alignments.items(): + this_file: Dict[str, List["AlignmentFileDict"]] = {} + for frame in tqdm(sorted(frames), desc=f"Sorting {fname}", leave=False): + this_file[frame] = [] + for real_idx, (f_id, almt, f_path, f_src) in enumerate(sorted(frames[frame], + key=itemgetter(0))): + if real_idx != f_id: + self._update_png_header(f_path, real_idx, almt, f_src) + this_file[frame].append(almt) + aln_sorted[fname] = this_file + return aln_sorted + + @classmethod + def _update_png_header(cls, + face_path: str, + new_index: int, + alignment: "AlignmentFileDict", + source_info: dict) -> None: + """ Update the PNG header for faces where the stored index does not correspond with the + alignments file. This can occur when frames with multiple faces have had some faces deleted + from the faces folder. + + Updates the original filename and index in the png header. + + Parameters + ---------- + face_path: str + Full path to the saved face image that requires updating + new_index: int + The new index as it appears in the newly generated alignments file + alignment: dict + The alignment information to store in the png header + source_info: dict + The face source information as extracted from the original face png file + """ + face = DetectedFace() + face.from_alignment(alignment) + new_filename = f"{os.path.splitext(source_info['source_filename'])[0]}_{new_index}.png" + + logger.trace("Updating png header for '%s': (face index from %s to %s, " # type:ignore + "original filename from '%s' to '%s'", face_path, source_info["face_index"], + new_index, source_info["original_filename"], new_filename) + + source_info["face_index"] = new_index + source_info["original_filename"] = new_filename + meta = dict(alignments=face.to_png_meta(), source=source_info) + update_existing_metadata(face_path, meta) + + def _save_alignments(self, all_alignments: dict) -> None: + """ Save the newely generated alignments file(s). + + If an alignments file already exists in the source faces folder, back it up rather than + overwriting + + Parameters + ---------- + all_alignments: dict + The alignment(s) dictionaries found in the faces folder. Alignment filename as key, + corresponding alignments as value. + """ + for fname, alignments in all_alignments.items(): + 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.backup() + aln.save() + + +class Rename(): # pylint:disable=too-few-public-methods + """ Rename faces in a folder to match their filename as stored in an alignments file. + + Parameters + ---------- + alignments: :class:`tools.lib_alignments.media.AlignmentData` + The alignments data loaded from an alignments file for this rename job + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + faces: :class:`tools.lib_alignments.media.Faces`, Optional + An optional faces object, if the rename task is being called by another job. + Default: ``None`` + """ + def __init__(self, + alignments: "AlignmentData", + arguments: Optional[Namespace], + faces: Optional[Faces] = None) -> None: + logger.debug("Initializing %s: (arguments: %s, faces: %s)", + self.__class__.__name__, arguments, faces) + self._alignments = alignments + + kwargs: Dict[str, Union[bool, "AlignmentData"]] = dict(with_alignments=False) + if alignments.version < 2.1: + # Update headers of faces generated with hash based alignments + kwargs["alignments"] = alignments + if faces: + self._faces = faces + else: + assert arguments is not None + self._faces = Faces(arguments.faces_dir, **kwargs) # type:ignore # needs TypedDict :/ + logger.debug("Initialized %s", self.__class__.__name__) + + def process(self) -> None: + """ Process the face renaming """ + logger.info("[RENAME FACES]") # Tidy up cli output + filelist = cast(List[Tuple[str, "PNGHeaderSourceDict"]], self._faces.file_list_sorted) + rename_mappings = sorted([(face[0], face[1]["original_filename"]) + for face in filelist + if face[0] != face[1]["original_filename"]], + key=lambda x: x[1]) + rename_count = self._rename_faces(rename_mappings) + logger.info("%s faces renamed", rename_count) + + def _rename_faces(self, filename_mappings: List[Tuple[str, str]]) -> int: + """ Rename faces back to their original name as exists in the alignments file. + + If the source and destination filename are the same then skip that file. + + Parameters + ---------- + filename_mappings: list + List of tuples of (`source filename`, `destination filename`) ordered by destination + filename + + Returns + ------- + int + The number of faces that have been renamed + """ + if not filename_mappings: + return 0 + + rename_count = 0 + conflicts = [] + for src, dst in tqdm(filename_mappings, desc="Renaming Faces", leave=False): + old = os.path.join(self._faces.folder, src) + new = os.path.join(self._faces.folder, dst) + + if os.path.exists(new): + # Interim add .tmp extension to files that will cause a rename conflict, to + # process afterwards + logger.debug("interim renaming file to avoid conflict: (src: '%s', dst: '%s')", + src, dst) + new = new + ".tmp" + conflicts.append(new) + + logger.verbose("Renaming '%s' to '%s'", old, new) # type:ignore + os.rename(old, new) + rename_count += 1 + if conflicts: + for old in tqdm(conflicts, desc="Renaming Faces", leave=False): + new = old[:-4] # Remove .tmp extension + if os.path.exists(new): + # This should only be running on faces. If there is still a conflict + # then the user has done something stupid, so we will delete the file and + # replace. They can always re-extract :/ + os.remove(new) + logger.verbose("Renaming '%s' to '%s'", old, new) # type:ignore + os.rename(old, new) + return rename_count + + +class RemoveFaces(): # pylint:disable=too-few-public-methods + """ Remove items from alignments file. + + Parameters + --------- + alignments: :class:`tools.alignments.media.AlignmentsData` + The loaded alignments containing faces to be removed + arguments: :class:`argparse.Namespace` + The command line arguments that have called this job + """ + def __init__(self, alignments: "AlignmentData", arguments: Namespace) -> None: + logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) + self._alignments = alignments + + kwargs: Dict[str, Union[bool, "AlignmentData"]] = dict(with_alignments=False) + if alignments.version < 2.1: + # Update headers of faces generated with hash based alignments + kwargs["alignments"] = alignments + self._items = Faces(arguments.faces_dir, **kwargs) # type:ignore # needs TypedDict :/ + logger.debug("Initialized %s", self.__class__.__name__) + + def process(self) -> None: + """ Run the job to remove faces from an alignments file that do not exist within a faces + folder. """ + logger.info("[REMOVE FACES FROM ALIGNMENTS]") # Tidy up cli output + + if not self._items.items: + logger.error("No matching faces found in your faces folder. This would remove all " + "faces from your alignments file. Process aborted.") + return + + items = cast(Dict[str, List[int]], self._items.items) + pre_face_count = self._alignments.faces_count + self._alignments.filter_faces(items, filter_out=False) + del_count = pre_face_count - self._alignments.faces_count + if del_count == 0: + logger.info("No changes made to alignments file. Exiting") + return + + logger.info("%s alignment(s) were removed from alignments file", del_count) + + self._update_png_headers() + self._alignments.save() + + rename = Rename(self._alignments, None, self._items) + rename.process() + + def _update_png_headers(self) -> None: + """ Update the EXIF iTXt field of any face PNGs that have had their face index changed. + + Notes + ----- + This could be quicker if parellizing in threads, however, Windows (at least) does not seem + to like this and has a tendency to throw permission errors, so this remains single threaded + for now. + """ + filelist = cast(List[Tuple[str, "PNGHeaderSourceDict"]], self._items.file_list_sorted) + items = cast(Dict[str, List[int]], self._items.items) + to_update = [ # Items whose face index has changed + x for x in filelist + if x[1]["face_index"] != items[x[1]["source_filename"]].index(x[1]["face_index"])] + + for item in tqdm(to_update, desc="Updating PNG Headers", leave=False): + filename, file_info = item + frame = file_info["source_filename"] + face_index = file_info["face_index"] + new_index = items[frame].index(face_index) + + fullpath = os.path.join(self._items.folder, filename) + logger.debug("Updating png header for '%s': face index from %s to %s", + fullpath, face_index, new_index) + + # Update file_list_sorted for rename task + orig_filename = f"{os.path.splitext(frame)[0]}_{new_index}.png" + file_info["face_index"] = new_index + file_info["original_filename"] = orig_filename + + face = DetectedFace() + face.from_alignment(self._alignments.get_faces_in_frame(frame)[new_index]) + meta = dict(alignments=face.to_png_meta(), + source=dict(alignments_version=file_info["alignments_version"], + original_filename=orig_filename, + face_index=new_index, + source_filename=frame, + source_is_video=file_info["source_is_video"], + source_frame_dims=file_info.get("source_frame_dims"))) + update_existing_metadata(fullpath, meta) + + logger.info("%s Extracted face(s) had their header information updated", len(to_update)) diff --git a/tools/alignments/jobs_frames.py b/tools/alignments/jobs_frames.py new file mode 100644 index 0000000000..64172235ef --- /dev/null +++ b/tools/alignments/jobs_frames.py @@ -0,0 +1,484 @@ +#!/usr/bin/env python3 +""" Tools for manipulating the alignments using Frames as a source """ +import logging +import os +import sys +from datetime import datetime +from typing import cast, Dict, List, Optional, TYPE_CHECKING, Union + +import cv2 +import numpy as np +from tqdm import tqdm + +from lib.align import DetectedFace, _EXTRACT_RATIOS +from lib.align.alignments import _VERSION +from lib.image import encode_image, generate_thumbnail, ImagesSaver +from plugins.extract.pipeline import Extractor, ExtractMedia +from .media import ExtractedFaces, Frames + +if sys.version_info < (3, 8): + from typing_extensions import get_args, Literal +else: + from typing import get_args, Literal + +if TYPE_CHECKING: + from argparse import Namespace + from .media import AlignmentData + +logger = logging.getLogger(__name__) + + +class Draw(): # pylint:disable=too-few-public-methods + """ Draws annotations onto original frames and saves into a sub-folder next to the original + frames. + + Parameters + --------- + alignments: :class:`tools.alignments.media.AlignmentsData` + The loaded alignments corresponding to the frames to be annotated + arguments: :class:`argparse.Namespace` + The command line arguments that have called this job + """ + def __init__(self, alignments: "AlignmentData", arguments: "Namespace") -> None: + logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) + self._alignments = alignments + self._frames = Frames(arguments.frames_dir) + self._output_folder = self._set_output() + self._mesh_areas = dict(mouth=(48, 68), + right_eyebrow=(17, 22), + left_eyebrow=(22, 27), + right_eye=(36, 42), + left_eye=(42, 48), + nose=(27, 36), + jaw=(0, 17), + chin=(8, 11)) + logger.debug("Initialized %s", self.__class__.__name__) + + def _set_output(self) -> str: + """ Set the output folder path. + + If annotating a folder of frames, output will be placed in a sub folder within the frames + folder. If annotating a video, output will be a folder next to the original video. + + Returns + ------- + str + Full path to the output folder + + """ + now = datetime.now().strftime("%Y%m%d_%H%M%S") + folder_name = f"drawn_landmarks_{now}" + if self._frames.is_video: + dest_folder = os.path.dirname(self._frames.folder) + else: + dest_folder = self._frames.folder + output_folder = os.path.join(dest_folder, folder_name) + logger.debug("Creating folder: '%s'", output_folder) + os.makedirs(output_folder) + return output_folder + + def process(self) -> None: + """ Runs the process to draw face annotations onto original source frames. """ + logger.info("[DRAW LANDMARKS]") # Tidy up cli output + frames_drawn = 0 + for frame in tqdm(self._frames.file_list_sorted, desc="Drawing landmarks", leave=False): + frame_name = frame["frame_fullname"] + + if not self._alignments.frame_exists(frame_name): + logger.verbose("Skipping '%s' - Alignments not found", frame_name) # type:ignore + continue + + self._annotate_image(frame_name) + frames_drawn += 1 + logger.info("%s Frame(s) output", frames_drawn) + + def _annotate_image(self, frame_name: str) -> None: + """ Annotate the frame with each face that appears in the alignments file. + + Parameters + ---------- + frame_name: str + The full path to the original frame + """ + logger.trace("Annotating frame: '%s'", frame_name) # type:ignore + image = self._frames.load_image(frame_name) + + for idx, alignment in enumerate(self._alignments.get_faces_in_frame(frame_name)): + face = DetectedFace() + face.from_alignment(alignment, image=image) + # Bounding Box + cv2.rectangle(image, (face.left, face.top), (face.right, face.bottom), (255, 0, 0), 1) + self._annotate_landmarks(image, np.rint(face.landmarks_xy).astype("int32")) + self._annotate_extract_boxes(image, face, idx) + self._annotate_pose(image, face) # Pose (head is still loaded) + + self._frames.save_image(self._output_folder, frame_name, image) + + def _annotate_landmarks(self, image: np.ndarray, landmarks: np.ndarray) -> None: + """ Annotate the extract boxes onto the frame. + + Parameters + ---------- + image: :class:`numpy.ndarray` + The frame that extract boxes are to be annotated on to + landmarks: :class:`numpy.ndarray` + The 68 point landmarks that are to be annotated onto the frame + """ + # Mesh + for area, indices in self._mesh_areas.items(): + fill = area in ("right_eye", "left_eye", "mouth") + cv2.polylines(image, [landmarks[indices[0]:indices[1]]], fill, (255, 255, 0), 1) + # Landmarks + for (pos_x, pos_y) in landmarks: + cv2.circle(image, (pos_x, pos_y), 1, (0, 255, 255), -1) + + @classmethod + def _annotate_extract_boxes(cls, image: np.ndarray, face: DetectedFace, index: int) -> None: + """ Annotate the mesh and landmarks boxes onto the frame. + + Parameters + ---------- + image: :class:`numpy.ndarray` + The frame that mesh and landmarks are to be annotated on to + face: :class:`lib.align.DetectedFace` + The aligned face + index: int + The face index for the given face + """ + for area in get_args(Literal["face", "head"]): + face.load_aligned(image, centering=area, force=True) + color = (0, 255, 0) if area == "face" else (0, 0, 255) + top_left = face.aligned.original_roi[0] + top_left = (top_left[0], top_left[1] - 10) + cv2.putText(image, str(index), top_left, cv2.FONT_HERSHEY_DUPLEX, 1.0, color, 1) + cv2.polylines(image, [face.aligned.original_roi], True, color, 1) + + @classmethod + def _annotate_pose(cls, image: np.ndarray, face: DetectedFace) -> None: + """ Annotate the pose onto the frame. + + Parameters + ---------- + image: :class:`numpy.ndarray` + The frame that pose is to be annotated on to + face: :class:`lib.align.DetectedFace` + The aligned face loaded for head centering + """ + center = np.array((face.aligned.size / 2, + face.aligned.size / 2)).astype("int32").reshape(1, 2) + center = np.rint(face.aligned.transform_points(center, invert=True)).astype("int32") + points = face.aligned.pose.xyz_2d * face.aligned.size + points = np.rint(face.aligned.transform_points(points, invert=True)).astype("int32") + cv2.line(image, tuple(center), tuple(points[1]), (0, 255, 0), 2) + cv2.line(image, tuple(center), tuple(points[0]), (255, 0, 0), 2) + cv2.line(image, tuple(center), tuple(points[2]), (0, 0, 255), 2) + + +class Extract(): # pylint:disable=too-few-public-methods + """ Re-extract faces from source frames based on Alignment data + + Parameters + ---------- + alignments: :class:`tools.lib_alignments.media.AlignmentData` + The alignments data loaded from an alignments file for this rename job + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + """ + def __init__(self, alignments: "AlignmentData", arguments: "Namespace") -> None: + logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) + self._arguments = arguments + self._alignments = alignments + self._is_legacy = self._alignments.version == 1.0 # pylint:disable=protected-access + self._mask_pipeline: Optional[Extractor] = None + self._faces_dir = arguments.faces_dir + self._min_size = self._get_min_size(arguments.size, arguments.min_size) + + self._frames = Frames(arguments.frames_dir, self._get_count()) + self._extracted_faces = ExtractedFaces(self._frames, + self._alignments, + size=arguments.size) + self._saver: Optional[ImagesSaver] = None + logger.debug("Initialized %s", self.__class__.__name__) + + @classmethod + def _get_min_size(cls, extract_size: int, min_size: int) -> int: + """ Obtain the minimum size that a face has been resized from to be included as a valid + extract. + + Parameters + ---------- + extract_size: int + The requested size of the extracted images + min_size: int + The percentage amount that has been supplied for valid faces (as a percentage of + extract size) + + Returns + ------- + int + The minimum size, in pixels, that a face is resized from to be considered valid + """ + retval = 0 if min_size == 0 else max(4, int(extract_size * (min_size / 100.))) + logger.debug("Extract size: %s, min percentage size: %s, min_size: %s", + extract_size, min_size, retval) + return retval + + def _get_count(self) -> Optional[int]: + """ If the alignments file has been run through the manual tool, then it will hold video + meta information, meaning that the count of frames in the alignment file can be relied + on to be accurate. + + Returns + ------- + int or ``None`` + For video input which contain video meta-data in the alignments file then the count of + frames is returned. In all other cases ``None`` is returned + """ + meta = self._alignments.video_meta_data + has_meta = all(val is not None for val in meta.values()) + if has_meta: + retval = None + else: + retval = len(cast(Dict[str, Union[List[int], List[float]]], meta["pts_time"])) + logger.debug("Frame count from alignments file: (has_meta: %s, %s", has_meta, retval) + return retval + + def process(self) -> None: + """ Run the re-extraction from Alignments file process""" + logger.info("[EXTRACT FACES]") # Tidy up cli output + self._check_folder() + if self._is_legacy: + self._legacy_check() + self._saver = ImagesSaver(self._faces_dir, as_bytes=True) + + if self._min_size > 0: + logger.info("Only selecting faces that have been resized from a minimum resolution " + "of %spx", self._min_size) + + self._export_faces() + + def _check_folder(self) -> None: + """ Check that the faces folder doesn't pre-exist and create. """ + err = None + if not self._faces_dir: + err = "ERROR: Output faces folder not provided." + elif not os.path.isdir(self._faces_dir): + logger.debug("Creating folder: '%s'", self._faces_dir) + os.makedirs(self._faces_dir) + elif os.listdir(self._faces_dir): + err = f"ERROR: Output faces folder should be empty: '{self._faces_dir}'" + if err: + logger.error(err) + sys.exit(0) + logger.verbose("Creating output folder at '%s'", self._faces_dir) # type:ignore + + def _legacy_check(self) -> None: + """ Check whether the alignments file was created with the legacy extraction method. + + If so, force user to re-extract all faces if any options have been specified, otherwise + raise the appropriate warnings and set the legacy options. + """ + if self._min_size > 0 or self._arguments.extract_every_n != 1: + logger.warning("This alignments file was generated with the legacy extraction method.") + logger.warning("You should run this extraction job, but with 'min_size' set to 0 and " + "'extract-every-n' set to 1 to update the alignments file.") + logger.warning("You can then re-run this extraction job with your chosen options.") + sys.exit(0) + + maskers = ["components", "extended"] + nn_masks = [mask for mask in list(self._alignments.mask_summary) if mask not in maskers] + logtype = logger.warning if nn_masks else logger.info + logtype("This alignments file was created with the legacy extraction method and will be " + "updated.") + logtype("Faces will be extracted using the new method and landmarks based masks will be " + "regenerated.") + if nn_masks: + logtype("However, the NN based masks '%s' will be cropped to the legacy extraction " + "method, so you may want to run the mask tool to regenerate these " + "masks.", "', '".join(nn_masks)) + self._mask_pipeline = Extractor(None, None, maskers, multiprocess=True) + self._mask_pipeline.launch() + # Update alignments versioning + self._alignments._io._version = _VERSION # pylint:disable=protected-access + + def _export_faces(self) -> None: + """ Export the faces to the output folder. """ + extracted_faces = 0 + skip_list = self._set_skip_list() + count = self._frames.count if skip_list is None else self._frames.count - len(skip_list) + + for filename, image in tqdm(self._frames.stream(skip_list=skip_list), + total=count, desc="Saving extracted faces", + leave=False): + frame_name = os.path.basename(filename) + if not self._alignments.frame_exists(frame_name): + logger.verbose("Skipping '%s' - Alignments not found", frame_name) # type:ignore + continue + extracted_faces += self._output_faces(frame_name, image) + if self._is_legacy and extracted_faces != 0 and self._min_size == 0: + self._alignments.save() + logger.info("%s face(s) extracted", extracted_faces) + + def _set_skip_list(self) -> Optional[List[int]]: + """ Set the indices for frames that should be skipped based on the `extract_every_n` + command line option. + + Returns + ------- + list or ``None`` + A list of indices to be skipped if extract_every_n is not `1` otherwise + returns ``None`` + """ + skip_num = self._arguments.extract_every_n + if skip_num == 1: + logger.debug("Not skipping any frames") + return None + skip_list = [] + for idx, item in enumerate(cast(List[Dict[str, str]], self._frames.file_list_sorted)): + if idx % skip_num != 0: + logger.trace("Adding image '%s' to skip list due to " # type:ignore + "extract_every_n = %s", item["frame_fullname"], skip_num) + skip_list.append(idx) + logger.debug("Adding skip list: %s", skip_list) + return skip_list + + def _output_faces(self, filename: str, image: np.ndarray) -> int: + """ For each frame save out the faces + + Parameters + ---------- + filename: str + The filename (without the full path) of the current frame + image: :class:`numpy.ndarray` + The full frame that faces are to be extracted from + + Returns + ------- + int + The total number of faces that have been extracted + """ + logger.trace("Outputting frame: %s", filename) # type:ignore + face_count = 0 + frame_name = os.path.splitext(filename)[0] + faces = self._select_valid_faces(filename, image) + assert self._saver is not None + if not faces: + return face_count + if self._is_legacy: + faces = self._process_legacy(filename, image, faces) + + for idx, face in enumerate(faces): + output = f"{frame_name}_{idx}.png" + meta = dict(alignments=face.to_png_meta(), + source=dict(alignments_version=self._alignments.version, + original_filename=output, + face_index=idx, + source_filename=filename, + source_is_video=self._frames.is_video, + source_frame_dims=image.shape[:2])) + self._saver.save(output, encode_image(face.aligned.face, ".png", metadata=meta)) + if self._min_size == 0 and self._is_legacy: + face.thumbnail = generate_thumbnail(face.aligned.face, size=96, quality=60) + self._alignments.data[filename]["faces"][idx] = face.to_alignment() + face_count += 1 + self._saver.close() + return face_count + + def _select_valid_faces(self, frame: str, image: np.ndarray) -> List[DetectedFace]: + """ Return the aligned faces from a frame that meet the selection criteria, + + Parameters + ---------- + frame: str + The filename (without the full path) of the current frame + image: :class:`numpy.ndarray` + The full frame that faces are to be extracted from + + Returns + ------- + list: + List of valid :class:`lib,align.DetectedFace` objects + """ + faces = self._extracted_faces.get_faces_in_frame(frame, image=image) + if self._min_size == 0: + valid_faces = faces + else: + sizes = self._extracted_faces.get_roi_size_for_frame(frame) + valid_faces = [faces[idx] for idx, size in enumerate(sizes) + if size >= self._min_size] + logger.trace("frame: '%s', total_faces: %s, valid_faces: %s", # type:ignore + frame, len(faces), len(valid_faces)) + return valid_faces + + def _process_legacy(self, + filename: str, + image: np.ndarray, + detected_faces: List[DetectedFace]) -> List[DetectedFace]: + """ Process legacy face extractions to new extraction method. + + Updates stored masks to new extract size + + Parameters + ---------- + filename: str + The current frame filename + image: :class:`numpy.ndarray` + The current image the contains the faces + detected_faces: list + list of :class:`lib.align.DetectedFace` objects for the current frame + + Returns + ------- + list + The updated list of :class:`lib.align.DetectedFace` objects for the current frame + """ + # Update landmarks based masks for face centering + assert self._mask_pipeline is not None + mask_item = ExtractMedia(filename, image, detected_faces=detected_faces) + self._mask_pipeline.input_queue.put(mask_item) + faces = next(self._mask_pipeline.detected_faces()).detected_faces + + # Pad and shift Neural Network based masks to face centering + for face in faces: + self._pad_legacy_masks(face) + return faces + + @classmethod + def _pad_legacy_masks(cls, detected_face: DetectedFace) -> None: + """ Recenter legacy Neural Network based masks from legacy centering to face centering + and pad accordingly. + + Update the masks back into the detected face objects. + + Parameters + ---------- + detected_face: :class:`lib.align.DetectedFace` + The detected face to update the masks for + """ + offset = detected_face.aligned.pose.offset["face"] + for name, mask in detected_face.mask.items(): # Re-center mask and pad to face size + if name in ("components", "extended"): + continue + old_mask = mask.mask.astype("float32") / 255.0 + size = old_mask.shape[0] + new_size = int(size + (size * _EXTRACT_RATIOS["face"]) / 2) + + shift = np.rint(offset * (size - (size * _EXTRACT_RATIOS["face"]))).astype("int32") + pos = np.array([(new_size // 2 - size // 2) - shift[1], + (new_size // 2) + (size // 2) - shift[1], + (new_size // 2 - size // 2) - shift[0], + (new_size // 2) + (size // 2) - shift[0]]) + bounds = np.array([max(0, pos[0]), min(new_size, pos[1]), + max(0, pos[2]), min(new_size, pos[3])]) + + slice_in = [slice(0 - (pos[0] - bounds[0]), size - (pos[1] - bounds[1])), + slice(0 - (pos[2] - bounds[2]), size - (pos[3] - bounds[3]))] + slice_out = [slice(bounds[0], bounds[1]), slice(bounds[2], bounds[3])] + + new_mask = np.zeros((new_size, new_size, 1), dtype="float32") + new_mask[slice_out[0], slice_out[1], :] = old_mask[slice_in[0], slice_in[1], :] + + mask.replace_mask(new_mask) + # Get the affine matrix from recently generated components mask + # pylint:disable=protected-access + mask._affine_matrix = detected_face.mask["components"].affine_matrix diff --git a/tools/alignments/media.py b/tools/alignments/media.py index 196601a551..c5e2a3269d 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -3,8 +3,10 @@ for alignments tool """ import logging +from operator import itemgetter import os import sys +from typing import cast, Generator, Dict, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 from tqdm import tqdm @@ -17,53 +19,59 @@ png_write_meta, read_image, read_image_meta_batch) from lib.utils import _image_extensions, _video_extensions, FaceswapError +if TYPE_CHECKING: + import numpy as np + from lib.align.alignments import AlignmentFileDict, PNGHeaderDict, PNGHeaderSourceDict + logger = logging.getLogger(__name__) # pylint: disable=invalid-name class AlignmentData(Alignments): - """ Class to hold the alignment data """ + """ Class to hold the alignment data - def __init__(self, alignments_file): + Paramaters + ---------- + alignments_file: str + Full path to an alignments file + """ + def __init__(self, alignments_file: str) -> None: logger.debug("Initializing %s: (alignments file: '%s')", self.__class__.__name__, alignments_file) logger.info("[ALIGNMENT DATA]") # Tidy up cli output folder, filename = self.check_file_exists(alignments_file) super().__init__(folder, filename=filename) - logger.verbose("%s items loaded", self.frames_count) + logger.verbose("%s items loaded", self.frames_count) # type: ignore logger.debug("Initialized %s", self.__class__.__name__) @staticmethod - def check_file_exists(alignments_file): - """ Check the alignments file exists""" + def check_file_exists(alignments_file: str) -> Tuple[str, str]: + """ Check the alignments file exists + + Paramaters + ---------- + alignments_file: str + Full path to an alignments file + + Returns + ------- + folder: str + The full path to the folder containing the alignments file + filename: str + The filename of the alignments file + """ folder, filename = os.path.split(alignments_file) if not os.path.isfile(alignments_file): logger.error("ERROR: alignments file not found at: '%s'", alignments_file) sys.exit(0) if folder: - logger.verbose("Alignments file exists at '%s'", alignments_file) + logger.verbose("Alignments file exists at '%s'", alignments_file) # type: ignore return folder, filename - def save(self): + def save(self) -> None: """ Backup copy of old alignments and save new alignments """ self.backup() super().save() - def reload(self): - """ Read the alignments data from the correct format """ - logger.debug("Re-loading alignments") - self._data = self._load() - logger.debug("Re-loaded alignments") - - def set_filename(self, filename): - """ Set the :attr:`_file` to the given filename. - - Parameters - ---------- - filename: str - The full path and filename to set the alignments file name to - """ - self._file = filename - class MediaLoader(): """ Class to load images. @@ -76,25 +84,25 @@ class MediaLoader(): If the total frame count is known it can be passed in here which will skip analyzing a video file. If the count is not passed in, it will be calculated. """ - def __init__(self, folder, count=None): + def __init__(self, folder: str, count: Optional[int] = None): logger.debug("Initializing %s: (folder: '%s')", self.__class__.__name__, folder) logger.info("[%s DATA]", self.__class__.__name__.upper()) self._count = count self.folder = folder - self.vid_reader = self.check_input_folder() + self._vid_reader = self.check_input_folder() self.file_list_sorted = self.sorted_items() self.items = self.load_items() - logger.verbose("%s items loaded", self.count) + logger.verbose("%s items loaded", self.count) # type: ignore logger.debug("Initialized %s", self.__class__.__name__) @property - def is_video(self): - """ Return whether source is a video or not """ - return self.vid_reader is not None + def is_video(self) -> bool: + """ bool: Return whether source is a video or not """ + return self._vid_reader is not None @property - def count(self): - """ Number of faces or frames """ + def count(self) -> int: + """ int: Number of faces or frames """ if self._count is not None: return self._count if self.is_video: @@ -103,16 +111,21 @@ def count(self): self._count = len(self.file_list_sorted) return self._count - def check_input_folder(self): + def check_input_folder(self) -> Optional[cv2.VideoCapture]: """ makes sure that the frames or faces folder exists - If frames folder contains a video file return imageio reader object """ + If frames folder contains a video file return imageio reader object + + Returns + ------- + :class:`cv2.VideoCapture` + Object for reading a video stream + """ err = None loadtype = self.__class__.__name__ if not self.folder: - err = "ERROR: A {} folder must be specified".format(loadtype) + err = f"ERROR: A {loadtype} folder must be specified" elif not os.path.exists(self.folder): - err = ("ERROR: The {} location {} could not be " - "found".format(loadtype, self.folder)) + err = f"ERROR: The {loadtype} location {self.folder} could not be found" if err: logger.error(err) sys.exit(0) @@ -120,61 +133,87 @@ def check_input_folder(self): if (loadtype == "Frames" and os.path.isfile(self.folder) and os.path.splitext(self.folder)[1].lower() in _video_extensions): - logger.verbose("Video exists at: '%s'", self.folder) + logger.verbose("Video exists at: '%s'", self.folder) # type: ignore retval = cv2.VideoCapture(self.folder) # pylint: disable=no-member # TODO ImageIO single frame seek seems slow. Look into this # retval = imageio.get_reader(self.folder, "ffmpeg") else: - logger.verbose("Folder exists at '%s'", self.folder) + logger.verbose("Folder exists at '%s'", self.folder) # type: ignore retval = None return retval @staticmethod - def valid_extension(filename): - """ Check whether passed in file has a valid extension """ + def valid_extension(filename) -> bool: + """ bool: Check whether passed in file has a valid extension """ extension = os.path.splitext(filename)[1] retval = extension.lower() in _image_extensions - logger.trace("Filename has valid extension: '%s': %s", filename, retval) + logger.trace("Filename has valid extension: '%s': %s", filename, retval) # type: ignore return retval - @staticmethod - def sorted_items(): + def sorted_items(self) -> Union[List[Dict[str, str]], + List[Tuple[str, "PNGHeaderSourceDict"]], + List[Tuple[str, "PNGHeaderDict"]]]: """ Override for specific folder processing """ - return list() + raise NotImplementedError() - @staticmethod - def process_folder(): + def process_folder(self) -> Union[Generator[Dict[str, str], None, None], + Generator[Tuple[str, "PNGHeaderDict"], None, None], + Generator[Tuple[str, "PNGHeaderSourceDict"], None, None]]: """ Override for specific folder processing """ - return list() + raise NotImplementedError() - @staticmethod - def load_items(): + def load_items(self) -> Union[Dict[str, List[int]], + Dict[str, Tuple[str, str]]]: """ Override for specific item loading """ - return dict() + raise NotImplementedError() + + def load_image(self, filename: str) -> "np.ndarray": + """ Load an image - def load_image(self, filename): - """ Load an image """ + Parameters + ---------- + filename: str + The filename of the image to load + + Returns + ------- + :class:`numpy.ndarray` + The loaded image + """ if self.is_video: image = self.load_video_frame(filename) else: src = os.path.join(self.folder, filename) - logger.trace("Loading image: '%s'", src) + logger.trace("Loading image: '%s'", src) # type: ignore image = read_image(src, raise_error=True) return image - def load_video_frame(self, filename): - """ Load a requested frame from video """ + def load_video_frame(self, filename: str) -> "np.ndarray": + """ Load a requested frame from video + + Parameters + ---------- + filename: str + The frame name to load + + Returns + ------- + :class:`numpy.ndarray` + The loaded image + """ + assert self._vid_reader is not None frame = os.path.splitext(filename)[0] - logger.trace("Loading video frame: '%s'", frame) + logger.trace("Loading video frame: '%s'", frame) # type: ignore frame_no = int(frame[frame.rfind("_") + 1:]) - 1 - self.vid_reader.set(cv2.CAP_PROP_POS_FRAMES, frame_no) # pylint: disable=no-member - _, image = self.vid_reader.read() + self._vid_reader.set(cv2.CAP_PROP_POS_FRAMES, frame_no) # pylint: disable=no-member + _, image = self._vid_reader.read() # TODO imageio single frame seek seems slow. Look into this - # self.vid_reader.set_image_index(frame_no) - # image = self.vid_reader.get_next_data()[:, :, ::-1] + # self._vid_reader.set_image_index(frame_no) + # image = self._vid_reader.get_next_data()[:, :, ::-1] return image - def stream(self, skip_list=None): + def stream(self, skip_list: Optional[List[int]] = None + ) -> Generator[Tuple[str, "np.ndarray"], None, None]: """ Load the images in :attr:`folder` in the order they are received from :class:`lib.image.ImagesLoader` in a background thread. @@ -198,11 +237,14 @@ def stream(self, skip_list=None): yield filename, image @staticmethod - def save_image(output_folder, filename, image, metadata=None): + def save_image(output_folder: str, + filename: str, + image: "np.ndarray", + metadata: Optional["PNGHeaderDict"] = None) -> None: """ Save an image """ output_file = os.path.join(output_folder, filename) output_file = os.path.splitext(output_file)[0] + ".png" - logger.trace("Saving image: '%s'", output_file) + logger.trace("Saving image: '%s'", output_file) # type: ignore if metadata: encoded_image = cv2.imencode(".png", image)[1] encoded_image = png_write_meta(encoded_image.tobytes(), metadata) @@ -223,12 +265,21 @@ class Faces(MediaLoader): The alignments object that contains the faces. Used to update legacy hash based faces for None: self._alignments = alignments + self._with_alignments = with_alignments super().__init__(folder) - def process_folder(self): + def process_folder(self) -> Union[Generator[Tuple[str, "PNGHeaderDict"], None, None], + Generator[Tuple[str, "PNGHeaderSourceDict"], None, None]]: """ Iterate through the faces folder pulling out various information for each face. Yields @@ -267,17 +318,19 @@ def process_folder(self): data = update_legacy_png_header(fullpath, self._alignments) if not data: raise FaceswapError( - "Some of the faces being passed in from '{}' could not be matched to the " - "alignments file '{}'\nPlease double check your sources and try " - "again.".format(self.folder, self._alignments.file)) - retval = data["source"] + f"Some of the faces being passed in from '{self.folder}' could not be " + f"matched to the alignments file '{self._alignments.file}'\nPlease double " + "check your sources and try again.") + sub_dict = data if self._with_alignments else data["source"] else: - retval = metadata["itxt"]["source"] + sub_dict = (metadata["itxt"] if self._with_alignments + else metadata["itxt"]["source"]) - retval["current_filename"] = os.path.basename(fullpath) + retval: Union[Tuple[str, "PNGHeaderDict"], Tuple[str, "PNGHeaderSourceDict"]] + retval = (os.path.basename(fullpath), sub_dict) # type:ignore yield retval - def load_items(self): + def load_items(self) -> Dict[str, List[int]]: """ Load the face names into dictionary. Returns @@ -285,13 +338,20 @@ def load_items(self): dict The source filename as key with list of face indices for the frame as value """ - faces = dict() - for face in self.file_list_sorted: - faces.setdefault(face["source_filename"], list()).append(face["face_index"]) - logger.trace(faces) + faces: Dict[str, List[int]] = {} + for face in cast(Union[List[Tuple[str, "PNGHeaderDict"]], + List[Tuple[str, "PNGHeaderSourceDict"]]], + self.file_list_sorted): + src: "PNGHeaderSourceDict" = cast( + "PNGHeaderDict", + face[1])["source"] if self._with_alignments else cast("PNGHeaderSourceDict", + face[1]) + faces.setdefault(src["source_filename"], []).append(src["face_index"]) + logger.trace(faces) # type: ignore return faces - def sorted_items(self): + def sorted_items(self) -> Union[List[Tuple[str, "PNGHeaderDict"]], + List[Tuple[str, "PNGHeaderSourceDict"]]]: """ Return the items sorted by the saved file name. Returns @@ -299,22 +359,36 @@ def sorted_items(self): list List of `dict` objects for each face found, sorted by the face's current filename """ - items = sorted(self.process_folder(), key=lambda x: (x["current_filename"])) - logger.trace(items) + items = cast(Union[List[Tuple[str, "PNGHeaderDict"]], + List[Tuple[str, "PNGHeaderSourceDict"]]], + sorted(self.process_folder(), key=itemgetter(0))) + logger.trace(items) # type: ignore return items class Frames(MediaLoader): """ Object to hold the frames that are to be checked against """ - def process_folder(self): - """ Iterate through the frames folder pulling the base filename """ + def process_folder(self) -> Generator[Dict[str, str], None, None]: + """ Iterate through the frames folder pulling the base filename + + Yields + ------ + dict + The full framename, the filename and the file extension of the frame + """ iterator = self.process_video if self.is_video else self.process_frames for item in iterator(): yield item - def process_frames(self): - """ Process exported Frames """ + def process_frames(self) -> Generator[Dict[str, str], None, None]: + """ Process exported Frames + + Yields + ------ + dict + The full framename, the filename and the file extension of the frame + """ logger.info("Loading file list from %s", self.folder) for frame in os.listdir(self.folder): if not self.valid_extension(frame): @@ -325,69 +399,122 @@ def process_frames(self): retval = {"frame_fullname": frame, "frame_name": filename, "frame_extension": file_extension} - logger.trace(retval) + logger.trace(retval) # type: ignore yield retval - def process_video(self): - """Dummy in frames for video """ + def process_video(self) -> Generator[Dict[str, str], None, None]: + """Dummy in frames for video + + Yields + ------ + dict + The full framename, the filename and the file extension of the frame + """ logger.info("Loading video frames from %s", self.folder) vidname = os.path.splitext(os.path.basename(self.folder))[0] for i in range(self.count): idx = i + 1 # Keep filename format for outputted face - filename = "{}_{:06d}".format(vidname, idx) - retval = {"frame_fullname": "{}.png".format(filename), + filename = f"{vidname}_{idx:06d}" + retval = {"frame_fullname": f"{filename}.png", "frame_name": filename, "frame_extension": ".png"} - logger.trace(retval) + logger.trace(retval) # type: ignore yield retval - def load_items(self): - """ Load the frame info into dictionary """ - frames = dict() - for frame in self.file_list_sorted: + def load_items(self) -> Dict[str, Tuple[str, str]]: + """ Load the frame info into dictionary + + Returns + ------- + dict + Fullname as key, tuple of frame name and extension as value + """ + frames: Dict[str, Tuple[str, str]] = {} + for frame in cast(List[Dict[str, str]], self.file_list_sorted): frames[frame["frame_fullname"]] = (frame["frame_name"], frame["frame_extension"]) - logger.trace(frames) + logger.trace(frames) # type: ignore return frames - def sorted_items(self): - """ Return the items sorted by filename """ + def sorted_items(self) -> List[Dict[str, str]]: + """ Return the items sorted by filename + + Returns + ------- + list + The sorted list of frame information + """ items = sorted(self.process_folder(), key=lambda x: (x["frame_name"])) - logger.trace(items) + logger.trace(items) # type: ignore return items class ExtractedFaces(): - """ Holds the extracted faces and matrix for - alignments """ - def __init__(self, frames, alignments, size=512): - logger.trace("Initializing %s: size: %s", self.__class__.__name__, size) + """ Holds the extracted faces and matrix for alignments + + Parameters + ---------- + frames: :class:`Frames` + The frames object to extract faces from + alignments: :class:`AlignmentData` + The alignment data corresponding to the frames + size: int, optional + The extract face size. Default: 512 + """ + def __init__(self, frames: Frames, alignments: AlignmentData, size: int = 512) -> None: + logger.trace("Initializing %s: size: %s", # type: ignore + self.__class__.__name__, size) self.size = size self.padding = int(size * 0.1875) self.alignments = alignments self.frames = frames - self.current_frame = None - self.faces = list() - logger.trace("Initialized %s", self.__class__.__name__) + self.current_frame: Optional[str] = None + self.faces: List[DetectedFace] = [] + logger.trace("Initialized %s", self.__class__.__name__) # type: ignore - def get_faces(self, frame, image=None): - """ Return faces and transformed landmarks - for each face in a given frame with it's alignments""" - logger.trace("Getting faces for frame: '%s'", frame) + def get_faces(self, frame: str, image: Optional["np.ndarray"] = None) -> None: + """ Obtain faces and transformed landmarks for each face in a given frame with its + alignments + + Parameters + ---------- + frame: str + The frame name to obtain faces for + image: :class:`numpy.ndarray`, optional + The image to extract the face from, if we already have it, otherwise ``None`` to + load the image. Default: ``None`` + """ + logger.trace("Getting faces for frame: '%s'", frame) # type: ignore self.current_frame = None alignments = self.alignments.get_faces_in_frame(frame) - logger.trace("Alignments for frame: (frame: '%s', alignments: %s)", frame, alignments) + logger.trace("Alignments for frame: (frame: '%s', alignments: %s)", # type: ignore + frame, alignments) if not alignments: - self.faces = list() + self.faces = [] return image = self.frames.load_image(frame) if image is None else image self.faces = [self.extract_one_face(alignment, image) for alignment in alignments] self.current_frame = frame - def extract_one_face(self, alignment, image): - """ Extract one face from image """ - logger.trace("Extracting one face: (frame: '%s', alignment: %s)", + def extract_one_face(self, + alignment: "AlignmentFileDict", + image: "np.ndarray") -> DetectedFace: + """ Extract one face from image + + Parameters + ---------- + alignment: dict + The alignment for a single face + image: :class:`numpy.ndarray` + The image to extract the face from + + Returns + ------- + :class:`~lib.align.DetectedFace` + The detected face object for the given alignment with the aligned face loaded + """ + logger.trace("Extracting one face: (frame: '%s', alignment: %s)", # type: ignore self.current_frame, alignment) face = DetectedFace() face.from_alignment(alignment, image=image) @@ -395,20 +522,51 @@ def extract_one_face(self, alignment, image): face.thumbnail = generate_thumbnail(face.aligned.face, size=80, quality=60) return face - def get_faces_in_frame(self, frame, update=False, image=None): - """ Return the faces for the selected frame """ - logger.trace("frame: '%s', update: %s", frame, update) + def get_faces_in_frame(self, + frame: str, + update: bool = False, + image: Optional["np.ndarray"] = None) -> List[DetectedFace]: + """ Return the faces for the selected frame + + Parameters + ---------- + frame: str + The frame name to get the faces for + update: bool, optional + ``True`` if the faces should be refreshed regardless of current frame. ``False`` to not + force a refresh. Default ``False`` + image: :class:`numpy.ndarray`, optional + Image to load faces from if it exists, otherwise ``None`` to load the image. + Default: ``None`` + + Returns + ------- + list + List of :class:`~lib.align.DetectedFace` objects for the frame, with the aligned face + loaded + """ + logger.trace("frame: '%s', update: %s", frame, update) # type: ignore if self.current_frame != frame or update: self.get_faces(frame, image=image) return self.faces - def get_roi_size_for_frame(self, frame): - """ Return the size of the original extract box for - the selected frame """ - logger.trace("frame: '%s'", frame) + def get_roi_size_for_frame(self, frame: str) -> List[int]: + """ Return the size of the original extract box for the selected frame. + + Parameters + ---------- + frame: str + The frame to obtain the original sized bounding boxes for + + Returns + ------- + list + List of original pixel sizes of faces held within the frame + """ + logger.trace("frame: '%s'", frame) # type: ignore if self.current_frame != frame: self.get_faces(frame) - sizes = list() + sizes = [] for face in self.faces: roi = face.aligned.original_roi.squeeze() top_left, top_right = roi[0], roi[3] @@ -419,5 +577,5 @@ def get_roi_size_for_frame(self, frame): else: length = int(((len_x ** 2) + (len_y ** 2)) ** 0.5) sizes.append(length) - logger.trace("sizes: '%s'", sizes) + logger.trace("sizes: '%s'", sizes) # type: ignore return sizes From 5805d76de4b2fd51bed651c980d0e04af72ce39b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 26 Sep 2022 12:48:52 +0100 Subject: [PATCH 745/981] alignments tool - Remove-face - Minor update --- docs/full/tools/tools.rst | 8 +-- tools/alignments/jobs_faces.py | 107 +++++++++------------------------ 2 files changed, 31 insertions(+), 84 deletions(-) diff --git a/docs/full/tools/tools.rst b/docs/full/tools/tools.rst index 1b9cd19240..d14a72aa0d 100644 --- a/docs/full/tools/tools.rst +++ b/docs/full/tools/tools.rst @@ -13,16 +13,10 @@ Subpackages .. toctree:: :maxdepth: 1 + alignments manual sort -alignments module -================= -.. automodule:: tools.alignments.alignments - :members: - :undoc-members: - :show-inheritance: - mask module =========== diff --git a/tools/alignments/jobs_faces.py b/tools/alignments/jobs_faces.py index 7995ddf215..856dc1d10f 100644 --- a/tools/alignments/jobs_faces.py +++ b/tools/alignments/jobs_faces.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 """ Tools for manipulating the alignments using extracted Faces as a source """ import os -import sys import logging from argparse import Namespace from operator import itemgetter @@ -11,14 +10,15 @@ from tqdm import tqdm from lib.align import DetectedFace -from lib.image import update_existing_metadata, read_image_meta_batch # TODO remove +from lib.image import update_existing_metadata # TODO remove from scripts.fsmedia import Alignments from .media import Faces if TYPE_CHECKING: from .media import AlignmentData - from lib.align.alignments import AlignmentFileDict, PNGHeaderSourceDict + from lib.align.alignments import (AlignmentDict, AlignmentFileDict, + PNGHeaderDict, PNGHeaderSourceDict) logger = logging.getLogger(__name__) @@ -37,64 +37,31 @@ def __init__(self, alignments: None, arguments: Namespace) -> None: logger.debug("Initializing %s: (alignments: %s, arguments: %s)", self.__class__.__name__, alignments, arguments) self._faces_dir = arguments.faces_dir - self._filelist = self._get_filenames() + self._faces = Faces(arguments.faces_dir, with_alignments=True) logger.debug("Initialized %s", self.__class__.__name__) - def _get_filenames(self) -> List[str]: - """ Obtain the full path to all filenames in the specified faces folder. - - Only png files will be returned, any other files will be ignored. An error is output if - the returned filelist is not valid - - Returns - ------- - list - Full path list to face png files - """ - err = None - if not self._faces_dir: - err = "A faces folder must be provided." - elif not os.path.isdir(self._faces_dir): - err = f"The Faces location '{self._faces_dir}' does not exit" - else: - filelist = [os.path.join(self._faces_dir, fname) - for fname in os.listdir(self._faces_dir) - if os.path.splitext(fname.lower())[1] == ".png"] - if not err and not filelist: - err = "Faces folder should contain Faceswap extracted PNG files" - if err: - logger.error(err) - sys.exit(0) - logger.debug("Collected %s png images from folder '%s'", len(filelist), self._faces_dir) - return filelist - def process(self) -> None: """ Run the job to read faces from a folder to create alignments file(s). """ logger.info("[CREATE ALIGNMENTS FROM FACES]") # Tidy up cli output - skip_count = 0 + + all_versions: Dict[str, List[float]] = {} d_align: Dict[str, Dict[str, List[Tuple[int, "AlignmentFileDict", str, dict]]]] = {} - for filename, meta in tqdm(read_image_meta_batch(self._filelist), + filelist = cast(List[Tuple[str, "PNGHeaderDict"]], self._faces.file_list_sorted) + for filename, meta in tqdm(filelist, desc="Generating Alignments", - total=len(self._filelist), + total=len(filelist), leave=False): - if "itxt" not in meta or "alignments" not in meta["itxt"]: - logger.verbose("skipping invalid file: '%s'", filename) # type:ignore - skip_count += 1 - continue - - align_fname = self._get_alignments_filename(meta["itxt"]["source"]) + align_fname = self._get_alignments_filename(meta["source"]) source_name, f_idx, alignment = self._extract_alignment(meta) - full_info = (f_idx, alignment, filename, meta["itxt"]["source"]) + full_info = (f_idx, alignment, filename, meta["source"]) d_align.setdefault(align_fname, {}).setdefault(source_name, []).append(full_info) + all_versions.setdefault(align_fname, []).append(meta["source"]["alignments_version"]) + versions = {k: min(v) for k, v in all_versions.items()} alignments = self._sort_alignments(d_align) - self._save_alignments(alignments) - if skip_count > 1: - logger.warning("%s of %s files skipped that do not contain valid alignment data", - skip_count, len(self._filelist)) - logger.warning("Run the process in verbose mode to see which files were skipped") + self._save_alignments(alignments, versions) @classmethod def _get_alignments_filename(cls, source_data: dict) -> str: @@ -138,45 +105,23 @@ def _extract_alignment(self, metadata: dict) -> Tuple[str, int, "AlignmentFileDi alignment file in position 1. The alignment data correctly formatted for writing to an alignments file in positin 2 """ - alignment = metadata["itxt"]["alignments"] + alignment = metadata["alignments"] alignment["landmarks_xy"] = np.array(alignment["landmarks_xy"], dtype="float32") - src = metadata["itxt"]["source"] + src = metadata["source"] frame_name = src["source_filename"] face_index = int(src["face_index"]) - version = src["alignments_version"] - - if version < 2.2: - logger.trace("Updating mask centering for frame '%s', face index: %s, " # type:ignore - "version: %s", frame_name, face_index, version) - self._update_mask_centering(alignment) logger.trace("Extracted alignment for frame: '%s', face index: %s", # type:ignore frame_name, face_index) return frame_name, face_index, alignment - @classmethod - def _update_mask_centering(cls, alignment: dict) -> None: - """ Prior to alignment version 2.2 all masks were stored with face centering. - - Update the existing masks with correct centering parameter. - - Parameters - ---------- - alignment: dict - The alignment for the face to have the mask centering parameter updated - """ - if "mask" not in alignment: - alignment["mask"] = {} - for mask in alignment["mask"].values(): - mask["stored_centering"] = "face" - def _sort_alignments(self, alignments: Dict[str, Dict[str, List[Tuple[int, "AlignmentFileDict", str, dict]]]] - ) -> Dict[str, Dict[str, List["AlignmentFileDict"]]]: + ) -> Dict[str, Dict[str, "AlignmentDict"]]: """ Sort the faces into face index order as they appeared in the original alignments file. If the face index stored in the png header does not match it's position in the alignments @@ -196,16 +141,16 @@ def _sort_alignments(self, The alignments file dictionaries sorted into the correct face order, ready for saving """ logger.info("Sorting and checking faces...") - aln_sorted: Dict[str, Dict[str, List["AlignmentFileDict"]]] = {} + aln_sorted: Dict[str, Dict[str, "AlignmentDict"]] = {} for fname, frames in alignments.items(): - this_file: Dict[str, List["AlignmentFileDict"]] = {} + this_file: Dict[str, "AlignmentDict"] = {} for frame in tqdm(sorted(frames), desc=f"Sorting {fname}", leave=False): - this_file[frame] = [] + this_file[frame] = dict(video_meta={}, faces=[]) for real_idx, (f_id, almt, f_path, f_src) in enumerate(sorted(frames[frame], key=itemgetter(0))): if real_idx != f_id: self._update_png_header(f_path, real_idx, almt, f_src) - this_file[frame].append(almt) + this_file[frame]["faces"].append(almt) aln_sorted[fname] = this_file return aln_sorted @@ -245,7 +190,9 @@ def _update_png_header(cls, meta = dict(alignments=face.to_png_meta(), source=source_info) update_existing_metadata(face_path, meta) - def _save_alignments(self, all_alignments: dict) -> None: + def _save_alignments(self, + all_alignments: Dict[str, Dict[str, "AlignmentDict"]], + versions: Dict[str, float]) -> None: """ Save the newely generated alignments file(s). If an alignments file already exists in the source faces folder, back it up rather than @@ -256,12 +203,18 @@ def _save_alignments(self, all_alignments: dict) -> None: all_alignments: dict The alignment(s) dictionaries found in the faces folder. Alignment filename as key, corresponding alignments as value. + versions: dict + The minimum version number that exists in a face set for each alignments file to be + generated """ for fname, alignments in all_alignments.items(): + version = versions[fname] 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._io._version = version # pylint:disable=protected-access + aln._io.update_legacy() # pylint:disable=protected-access aln.backup() aln.save() From c79175cbde5600bebd65785f3821fc74b3a80cbe Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 26 Sep 2022 19:32:09 +0100 Subject: [PATCH 746/981] Alignments Tool updates - Copy info back to alignments file from faces --- lib/align/alignments.py | 2 +- tools/alignments/jobs.py | 23 +++++-- tools/alignments/jobs_faces.py | 111 ++++++++++++++++++++++++++++---- tools/alignments/jobs_frames.py | 5 +- tools/alignments/media.py | 43 ++++--------- 5 files changed, 131 insertions(+), 53 deletions(-) diff --git a/lib/align/alignments.py b/lib/align/alignments.py index c1a7620c19..560b57eadf 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -190,7 +190,7 @@ def video_meta_data(self) -> Dict[str, Optional[Union[List[int], List[float]]]]: pts_time: List[float] = [] keyframes: List[int] = [] for idx, key in enumerate(sorted(self.data)): - if not self.data[key]["video_meta"]: + if not self.data[key].get("video_meta", {}): return retval meta = self.data[key]["video_meta"] pts_time.append(cast(float, meta["pts_time"])) diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index b1c0af413e..36d1d685c5 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -13,6 +13,7 @@ from tqdm import tqdm from .media import Faces, Frames +from .jobs_faces import FaceToFile if sys.version_info < (3, 8): from typing_extensions import Literal @@ -21,7 +22,7 @@ if TYPE_CHECKING: from argparse import Namespace - from lib.align.alignments import PNGHeaderSourceDict + from lib.align.alignments import PNGHeaderDict from .media import AlignmentData logger = logging.getLogger(__name__) @@ -80,7 +81,7 @@ def _get_source_dir(self, arguments: "Namespace") -> str: logger.debug("type: '%s', source_dir: '%s'", self._type, source_dir) return source_dir - def _get_items(self) -> Union[List[Dict[str, str]], List[Dict[str, "PNGHeaderSourceDict"]]]: + def _get_items(self) -> Union[List[Dict[str, str]], List[Tuple[str, "PNGHeaderDict"]]]: """ Set the correct items to process Returns @@ -93,7 +94,7 @@ def _get_items(self) -> Union[List[Dict[str, str]], List[Dict[str, "PNGHeaderSou assert self._type is not None items: Union[Frames, Faces] = globals()[self._type.title()](self._source_dir) self._is_video = items.is_video - return cast(Union[List[Dict[str, str]], List[Dict[str, "PNGHeaderSourceDict"]]], + return cast(Union[List[Dict[str, str]], List[Tuple[str, "PNGHeaderDict"]]], items.file_list_sorted) def process(self) -> None: @@ -101,6 +102,13 @@ def process(self) -> None: assert self._type is not None logger.info("[CHECK %s]", self._type.upper()) items_output = self._compile_output() + + if self._type == "faces": + filelist = cast(List[Tuple[str, "PNGHeaderDict"]], self._items) + check_update = FaceToFile(self._alignments, [val[1] for val in filelist]) + if check_update(): + self._alignments.save() + self._output_results(items_output) def _validate(self) -> None: @@ -185,12 +193,13 @@ def _get_multi_faces_faces(self) -> Generator[Tuple[str, int], None, None]: The frame name and the face id of any frames which have multiple faces """ self.output_message = "Multiple faces in frame" - for item in tqdm(cast(List[Tuple[str, "PNGHeaderSourceDict"]], self._items), + for item in tqdm(cast(List[Tuple[str, "PNGHeaderDict"]], self._items), desc=self.output_message, leave=False): - if not self._alignments.frame_has_multiple_faces(item["source_filename"]): + src = item[1]["source"] + if not self._alignments.frame_has_multiple_faces(src["source_filename"]): continue - retval = (item[0], item[1]["face_index"]) + retval = (item[0], src["face_index"]) logger.trace("Returning: '%s'", retval) # type:ignore yield retval @@ -222,7 +231,7 @@ def _get_missing_frames(self) -> Generator[str, None, None]: The frame name of any frames in alignments with no matching file """ self.output_message = "Missing frames that are in alignments file" - frames = set(item["frame_fullname"] for item in self._items) + frames = set(item["frame_fullname"] for item in cast(List[Dict[str, str]], self._items)) for frame in tqdm(self._alignments.data.keys(), desc=self.output_message, leave=False): if frame not in frames: logger.debug("Returning: '%s'", frame) diff --git a/tools/alignments/jobs_faces.py b/tools/alignments/jobs_faces.py index 856dc1d10f..f87353ff04 100644 --- a/tools/alignments/jobs_faces.py +++ b/tools/alignments/jobs_faces.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 """ Tools for manipulating the alignments using extracted Faces as a source """ -import os import logging +import os +import sys from argparse import Namespace from operator import itemgetter -from typing import cast, Dict, List, Optional, Tuple, TYPE_CHECKING, Union +from typing import cast, Dict, List, Optional, Tuple, TYPE_CHECKING import numpy as np from tqdm import tqdm @@ -15,10 +16,15 @@ from .media import Faces +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + if TYPE_CHECKING: from .media import AlignmentData from lib.align.alignments import (AlignmentDict, AlignmentFileDict, - PNGHeaderDict, PNGHeaderSourceDict) + PNGHeaderDict, PNGHeaderAlignmentsDict) logger = logging.getLogger(__name__) @@ -37,7 +43,7 @@ def __init__(self, alignments: None, arguments: Namespace) -> None: logger.debug("Initializing %s: (alignments: %s, arguments: %s)", self.__class__.__name__, alignments, arguments) self._faces_dir = arguments.faces_dir - self._faces = Faces(arguments.faces_dir, with_alignments=True) + self._faces = Faces(arguments.faces_dir) logger.debug("Initialized %s", self.__class__.__name__) def process(self) -> None: @@ -240,7 +246,7 @@ def __init__(self, self.__class__.__name__, arguments, faces) self._alignments = alignments - kwargs: Dict[str, Union[bool, "AlignmentData"]] = dict(with_alignments=False) + kwargs = {} if alignments.version < 2.1: # Update headers of faces generated with hash based alignments kwargs["alignments"] = alignments @@ -254,14 +260,19 @@ def __init__(self, def process(self) -> None: """ Process the face renaming """ logger.info("[RENAME FACES]") # Tidy up cli output - filelist = cast(List[Tuple[str, "PNGHeaderSourceDict"]], self._faces.file_list_sorted) - rename_mappings = sorted([(face[0], face[1]["original_filename"]) + filelist = cast(List[Tuple[str, "PNGHeaderDict"]], self._faces.file_list_sorted) + rename_mappings = sorted([(face[0], face[1]["source"]["original_filename"]) for face in filelist - if face[0] != face[1]["original_filename"]], + if face[0] != face[1]["source"]["original_filename"]], key=lambda x: x[1]) rename_count = self._rename_faces(rename_mappings) logger.info("%s faces renamed", rename_count) + filelist = cast(List[Tuple[str, "PNGHeaderDict"]], self._faces.file_list_sorted) + copyback = FaceToFile(self._alignments, [val[1] for val in filelist]) + if copyback(): + self._alignments.save() + def _rename_faces(self, filename_mappings: List[Tuple[str, str]]) -> int: """ Rename faces back to their original name as exists in the alignments file. @@ -325,7 +336,7 @@ def __init__(self, alignments: "AlignmentData", arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self._alignments = alignments - kwargs: Dict[str, Union[bool, "AlignmentData"]] = dict(with_alignments=False) + kwargs = {} if alignments.version < 2.1: # Update headers of faces generated with hash based alignments kwargs["alignments"] = alignments @@ -367,10 +378,11 @@ def _update_png_headers(self) -> None: to like this and has a tendency to throw permission errors, so this remains single threaded for now. """ - filelist = cast(List[Tuple[str, "PNGHeaderSourceDict"]], self._items.file_list_sorted) items = cast(Dict[str, List[int]], self._items.items) + srcs = [(x[0], x[1]["source"]) + for x in cast(List[Tuple[str, "PNGHeaderDict"]], self._items.file_list_sorted)] to_update = [ # Items whose face index has changed - x for x in filelist + x for x in srcs if x[1]["face_index"] != items[x[1]["source_filename"]].index(x[1]["face_index"])] for item in tqdm(to_update, desc="Updating PNG Headers", leave=False): @@ -400,3 +412,80 @@ def _update_png_headers(self) -> None: update_existing_metadata(fullpath, meta) logger.info("%s Extracted face(s) had their header information updated", len(to_update)) + + +class FaceToFile(): # pylint:disable=too-few-public-methods + """ Updates any optional/missing keys in the alignments file with any data that has been + populated in a PNGHeader. Includes masks and identity fields. + + Parameters + --------- + alignments: :class:`tools.alignments.media.AlignmentsData` + The loaded alignments containing faces to be removed + face_data: list + List of :class:`PNGHeaderDict` objects + """ + def __init__(self, alignments: "AlignmentData", face_data: List["PNGHeaderDict"]) -> None: + logger.debug("Initializing %s: alignments: %s, face_data: %s", + self.__class__.__name__, alignments, len(face_data)) + self._alignments = alignments + self._face_alignments = face_data + self._updatable_keys: List[Literal["identity", "mask"]] = ["identity", "mask"] + self._counts: Dict[str, int] = {} + logger.debug("Initialized %s", self.__class__.__name__) + + def _check_and_update(self, + alignment: "PNGHeaderAlignmentsDict", + face: "AlignmentFileDict") -> None: + """ Check whether the key requires updating and update it. + + alignment: dict + The alignment dictionary from the PNG Header + face: dict + The alignment dictionary for the face from the alignments file + """ + for key in self._updatable_keys: + if key == "mask": + exist_masks = face["mask"] + for mask_name, mask_data in alignment["mask"].items(): + if mask_name in exist_masks: + continue + exist_masks[mask_name] = mask_data + count_key = f"mask_{mask_name}" + self._counts[count_key] = self._counts.get(count_key, 0) + 1 + continue + + if not face.get(key, {}) and alignment.get(key): + face[key] = alignment[key] + self._counts[key] = self._counts.get(key, 0) + 1 + + def __call__(self) -> bool: + """ Parse through the face data updating any entries in the alignments file. + + Returns + ------- + bool + ``True`` if any alignment information was updated otherwise ``False`` + """ + for meta in tqdm(self._face_alignments, + desc="Updating Alignments File from PNG Header", + leave=False): + src = meta["source"] + alignment = meta["alignments"] + if not any(alignment.get(key, {}) for key in self._updatable_keys): + continue + + faces = self._alignments.get_faces_in_frame(src["source_filename"]) + if len(faces) < src["face_index"] + 1: # list index out of range + logger.debug("Skipped face '%s'. Index does not exist in alignments file", + src["original_filename"]) + continue + + face = faces[src["face_index"]] + self._check_and_update(alignment, face) + + retval = False + if self._counts: + retval = True + logger.info("Updated alignments file from PNG Data: %s", self._counts) + return retval diff --git a/tools/alignments/jobs_frames.py b/tools/alignments/jobs_frames.py index 64172235ef..950f1e8b12 100644 --- a/tools/alignments/jobs_frames.py +++ b/tools/alignments/jobs_frames.py @@ -237,9 +237,10 @@ def _get_count(self) -> Optional[int]: meta = self._alignments.video_meta_data has_meta = all(val is not None for val in meta.values()) if has_meta: - retval = None + retval: Optional[int] = len(cast(Dict[str, Union[List[int], List[float]]], + meta["pts_time"])) else: - retval = len(cast(Dict[str, Union[List[int], List[float]]], meta["pts_time"])) + retval = None logger.debug("Frame count from alignments file: (has_meta: %s, %s", has_meta, retval) return retval diff --git a/tools/alignments/media.py b/tools/alignments/media.py index c5e2a3269d..bdaffc7627 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -21,7 +21,7 @@ if TYPE_CHECKING: import numpy as np - from lib.align.alignments import AlignmentFileDict, PNGHeaderDict, PNGHeaderSourceDict + from lib.align.alignments import AlignmentFileDict, PNGHeaderDict logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -151,14 +151,12 @@ def valid_extension(filename) -> bool: return retval def sorted_items(self) -> Union[List[Dict[str, str]], - List[Tuple[str, "PNGHeaderSourceDict"]], List[Tuple[str, "PNGHeaderDict"]]]: """ Override for specific folder processing """ raise NotImplementedError() def process_folder(self) -> Union[Generator[Dict[str, str], None, None], - Generator[Tuple[str, "PNGHeaderDict"], None, None], - Generator[Tuple[str, "PNGHeaderSourceDict"], None, None]]: + Generator[Tuple[str, "PNGHeaderDict"], None, None]]: """ Override for specific folder processing """ raise NotImplementedError() @@ -265,21 +263,12 @@ class Faces(MediaLoader): The alignments object that contains the faces. Used to update legacy hash based faces for None: + def __init__(self, folder: str, alignments: Optional[Alignments] = None) -> None: self._alignments = alignments - self._with_alignments = with_alignments super().__init__(folder) - def process_folder(self) -> Union[Generator[Tuple[str, "PNGHeaderDict"], None, None], - Generator[Tuple[str, "PNGHeaderSourceDict"], None, None]]: + def process_folder(self) -> Generator[Tuple[str, "PNGHeaderDict"], None, None]: """ Iterate through the faces folder pulling out various information for each face. Yields @@ -321,13 +310,11 @@ def process_folder(self) -> Union[Generator[Tuple[str, "PNGHeaderDict"], None, N f"Some of the faces being passed in from '{self.folder}' could not be " f"matched to the alignments file '{self._alignments.file}'\nPlease double " "check your sources and try again.") - sub_dict = data if self._with_alignments else data["source"] + sub_dict = data else: - sub_dict = (metadata["itxt"] if self._with_alignments - else metadata["itxt"]["source"]) + sub_dict = cast("PNGHeaderDict", metadata["itxt"]) - retval: Union[Tuple[str, "PNGHeaderDict"], Tuple[str, "PNGHeaderSourceDict"]] - retval = (os.path.basename(fullpath), sub_dict) # type:ignore + retval = (os.path.basename(fullpath), sub_dict) yield retval def load_items(self) -> Dict[str, List[int]]: @@ -339,19 +326,13 @@ def load_items(self) -> Dict[str, List[int]]: The source filename as key with list of face indices for the frame as value """ faces: Dict[str, List[int]] = {} - for face in cast(Union[List[Tuple[str, "PNGHeaderDict"]], - List[Tuple[str, "PNGHeaderSourceDict"]]], - self.file_list_sorted): - src: "PNGHeaderSourceDict" = cast( - "PNGHeaderDict", - face[1])["source"] if self._with_alignments else cast("PNGHeaderSourceDict", - face[1]) + for face in cast(List[Tuple[str, "PNGHeaderDict"]], self.file_list_sorted): + src = face[1]["source"] faces.setdefault(src["source_filename"], []).append(src["face_index"]) logger.trace(faces) # type: ignore return faces - def sorted_items(self) -> Union[List[Tuple[str, "PNGHeaderDict"]], - List[Tuple[str, "PNGHeaderSourceDict"]]]: + def sorted_items(self) -> List[Tuple[str, "PNGHeaderDict"]]: """ Return the items sorted by the saved file name. Returns @@ -359,9 +340,7 @@ def sorted_items(self) -> Union[List[Tuple[str, "PNGHeaderDict"]], list List of `dict` objects for each face found, sorted by the face's current filename """ - items = cast(Union[List[Tuple[str, "PNGHeaderDict"]], - List[Tuple[str, "PNGHeaderSourceDict"]]], - sorted(self.process_folder(), key=itemgetter(0))) + items = sorted(self.process_folder(), key=itemgetter(0)) logger.trace(items) # type: ignore return items From 220335f55df001f22289f7ab9a282ecb614c3e51 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 26 Sep 2022 22:51:25 +0100 Subject: [PATCH 747/981] bugfix: Extract: Add video_meta to alignments dict --- scripts/extract.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/extract.py b/scripts/extract.py index 171f85f28e..6e48f969b9 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -427,5 +427,6 @@ def _output_faces(self, saver: Optional[ImagesSaver], extract_media: ExtractMedi continue final_faces.append(face.to_alignment()) - self._alignments.data[os.path.basename(extract_media.filename)] = dict(faces=final_faces) + self._alignments.data[os.path.basename(extract_media.filename)] = dict(faces=final_faces, + video_meta={}) del extract_media From 765e385177bda9b9e99951492ef33b34b4e4773e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 30 Sep 2022 18:36:35 +0100 Subject: [PATCH 748/981] Extract: Typing and standardization --- lib/align/detected_face.py | 12 + lib/training/preview_tk.py | 4 +- plugins/convert/writer/ffmpeg.py | 6 +- plugins/extract/_base.py | 329 +++++++++++++------ plugins/extract/_config.py | 4 +- plugins/extract/align/_base.py | 97 ++---- plugins/extract/align/cv2_dnn.py | 28 +- plugins/extract/align/fan.py | 36 +- plugins/extract/detect/_base.py | 438 ++++++++++++++++++------- plugins/extract/detect/cv2_dnn.py | 51 +-- plugins/extract/detect/mtcnn.py | 67 ++-- plugins/extract/detect/s3fd.py | 119 ++++--- plugins/extract/mask/_base.py | 142 ++++---- plugins/extract/mask/bisenet_fp.py | 99 +++--- plugins/extract/mask/components.py | 36 +- plugins/extract/mask/custom.py | 26 +- plugins/extract/mask/extended.py | 47 ++- plugins/extract/mask/unet_dfl.py | 35 +- plugins/extract/mask/vgg_clear.py | 53 +-- plugins/extract/mask/vgg_obstructed.py | 53 +-- plugins/extract/pipeline.py | 26 +- tests/simple_tests.py | 1 + 22 files changed, 1067 insertions(+), 642 deletions(-) diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index 7c263eea7a..de8959f32b 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -179,6 +179,18 @@ def add_mask(self, fsmask.add(mask, affine_matrix, interpolator) self.mask[name] = fsmask + def add_landmarks_xy(self, landmarks: np.ndarray) -> None: + """ Add landmarks to the detected face object. If landmarks alread exist, they will be + overwritten. + + Parameters + ---------- + landmarks: :class:`numpy.ndarray` + The 68 point face landmarks to add for the face + """ + logger.trace("landmarks shape: '%s'", landmarks.shape) # type: ignore + self._landmarks_xy = landmarks + def add_identity(self, name: Literal["vggface2"], embedding: np.ndarray, ) -> None: """ Add an identity embedding to this detected face. If an identity already exists for the given :attr:`name` it will be overwritten diff --git a/lib/training/preview_tk.py b/lib/training/preview_tk.py index 0889686c10..3d2394d3ab 100644 --- a/lib/training/preview_tk.py +++ b/lib/training/preview_tk.py @@ -71,8 +71,8 @@ def max_scale(self) -> int: @property def save_var(self) -> tk.BooleanVar: - """:class:`tkinter.IntVar`: Variable which is set to ``True`` when the save button has been. - pressed """ + """:class:`tkinter.IntVar`: Variable which is set to ``True`` when the save button has + been. pressed """ retval = self._vars["save"] assert isinstance(retval, tk.BooleanVar) return retval diff --git a/plugins/convert/writer/ffmpeg.py b/plugins/convert/writer/ffmpeg.py index 36fbdd3e5f..de9db06c8f 100644 --- a/plugins/convert/writer/ffmpeg.py +++ b/plugins/convert/writer/ffmpeg.py @@ -87,9 +87,9 @@ def _output_params(self) -> List[str]: @property def _audio_codec(self) -> Optional[str]: - """ str or ``None``: The audio codec to use. This will either be ``"copy"`` (the default) or - ``None`` if skip muxing has been selected in configuration options, or if frame ranges have - been passed in the command line arguments. """ + """ str or ``None``: The audio codec to use. This will either be ``"copy"`` (the default) + or ``None`` if skip muxing has been selected in configuration options, or if frame ranges + have been passed in the command line arguments. """ retval: Optional[str] = "copy" if self.config["skip_mux"]: logger.info("Skipping audio muxing due to configuration settings.") diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index c6c49cbca7..d971456774 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -3,7 +3,13 @@ :mod:`~plugins.extract.mask` Plugins """ import logging -from typing import Dict +import sys + +from dataclasses import dataclass, field +from typing import (Any, Callable, Dict, Generator, List, Optional, + Sequence, Union, Tuple, TYPE_CHECKING) + +import numpy as np from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa from lib.multithreading import MultiThread @@ -12,13 +18,27 @@ from ._config import Config from .pipeline import ExtractMedia -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal +if TYPE_CHECKING: + from queue import Queue + import cv2 + from lib.align import DetectedFace + from lib.model.session import KSession + from .align._base import AlignerBatch + from .detect._base import DetectorBatch + from .mask._base import MaskerBatch + + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name # TODO CPU mode # TODO Run with warnings mode -def _get_config(plugin_name, configfile=None): +def _get_config(plugin_name: str, configfile: Optional[str] = None) -> Dict[str, Any]: """ Return the configuration for the requested model Parameters @@ -37,6 +57,43 @@ def _get_config(plugin_name, configfile=None): return Config(plugin_name, configfile=configfile).config_dict +BatchType = Union["DetectorBatch", "AlignerBatch", "MaskerBatch"] + + +@dataclass +class ExtractorBatch: + """ Dataclass for holding a batch flowing through post Detector plugins. + + The batch size for post Detector plugins is not the same as the overall batch size. + An image may contain 0 or more detected faces, and these need to be split and recombined + to be able to utilize a plugin's internal batch size. + + Plugin types will inherit from this class and add required keys. + + Parameters + ---------- + image: list + List of :class:`numpy.ndarray` containing the original frames + detected_faces: list + List of :class:`~lib.align.DetectedFace` objects + filename: list + List of original frame filenames for the batch + feed: :class:`numpy.nd.array` + Batch of feed images to feed the net with + prediction: :class:`numpy.nd.array` + Batch of predictions. Direct output from the aligner net + data: dict + Any specific data required during the processing phase for a particular plugin + """ + image: List[np.ndarray] = field(default_factory=list) + detected_faces: Sequence[Union["DetectedFace", + List["DetectedFace"]]] = field(default_factory=list) + filename: List[str] = field(default_factory=list) + feed: np.ndarray = np.array([]) + prediction: np.ndarray = np.array([]) + data: List[Dict[str, Any]] = field(default_factory=list) + + class Extractor(): """ Extractor Plugin Object @@ -100,12 +157,15 @@ class Extractor(): plugins.extract.pipeline : The extract pipeline that configures and calls all plugins """ - def __init__(self, git_model_id=None, model_filename=None, exclude_gpus=None, configfile=None, - instance=0): + def __init__(self, + git_model_id: Optional[int] = None, + model_filename: Optional[Union[str, List[str]]] = None, + exclude_gpus: Optional[List[int]] = None, + configfile: Optional[str] = None, + instance: int = 0) -> None: logger.debug("Initializing %s: (git_model_id: %s, model_filename: %s, exclude_gpus: %s, " "configfile: %s, instance: %s, )", self.__class__.__name__, git_model_id, model_filename, exclude_gpus, configfile, instance) - self._is_initialized = False self._instance = instance self._exclude_gpus = exclude_gpus @@ -117,20 +177,19 @@ def __init__(self, git_model_id=None, model_filename=None, exclude_gpus=None, co be a list of strings """ # << SET THE FOLLOWING IN PLUGINS __init__ IF DIFFERENT FROM DEFAULT >> # - self.name = None - self.input_size = None - self.color_format = "BGR" - self.vram = None - self.vram_warnings = None # Will run at this with warnings - self.vram_per_batch = None + self.name: Optional[str] = None + self.input_size = 0 + self.color_format: Literal["BGR", "RGB", "GRAY"] = "BGR" + self.vram = 0 + self.vram_warnings = 0 # Will run at this with warnings + self.vram_per_batch = 0 # << THE FOLLOWING ARE SET IN self.initialize METHOD >> # self.queue_size = 1 """ int: Queue size for all internal queues. Set in :func:`initialize()` """ - self.model = None - """varies: The model for this plugin. - Set in the plugin's :func:`init_model()` method """ + self.model: Optional[Union["KSession", "cv2.dnn.Net"]] = None + """varies: The model for this plugin. Set in the plugin's :func:`init_model()` method """ # For detectors that support batching, this should be set to the calculated batch size # that the amount of available VRAM will support. @@ -138,10 +197,10 @@ def __init__(self, git_model_id=None, model_filename=None, exclude_gpus=None, co """ int: Batchsize for feeding this model. The number of images the model should feed through at once. """ - self._queues = {} + self._queues: Dict[str, "Queue"] = {} """ dict: in + out queues and internal queues for this plugin, """ - self._threads = [] + self._threads: List[MultiThread] = [] """ list: Internal threads for this plugin """ self._extract_media: Dict[str, ExtractMedia] = {} @@ -149,82 +208,82 @@ def __init__(self, git_model_id=None, model_filename=None, exclude_gpus=None, co processed. Stored at input for pairing back up on output of extractor process """ # << THE FOLLOWING PROTECTED ATTRIBUTES ARE SET IN PLUGIN TYPE _base.py >>> # - self._plugin_type = None - """ str: Plugin type. ``detect`` or ``align`` - set in ``._base`` """ + self._plugin_type: Optional[Literal["align", "detect", "recognition", "mask"]] = None + """ str: Plugin type. ``detect`, ``align``, ``recognise`` or ``mask`` set in + ``._base`` """ + + # << Objects for splitting frame's detected faces and rejoining them >> + # << for post-detector pliugins >> + self._faces_per_filename: Dict[str, int] = {} # Tracking for recompiling batches + self._rollover: Optional[ExtractMedia] = None # batch rollover items + self._output_faces: List["DetectedFace"] = [] # Recompiled output faces from plugin logger.debug("Initialized _base %s", self.__class__.__name__) # <<< OVERIDABLE METHODS >>> # - def init_model(self): + def init_model(self) -> None: """ **Override method** Override this method to execute the specific model initialization method """ raise NotImplementedError - def process_input(self, batch): + def process_input(self, batch: BatchType) -> None: """ **Override method** Override this method for specific extractor pre-processing of image Parameters ---------- - batch : dict + batch : :class:`ExtractorBatch` Contains the batch that is currently being passed through the plugin process - - Notes - ----- - When preparing an input to the model a key ``feed`` must be added - to the :attr:`batch` ``dict`` which contains this input. """ raise NotImplementedError - def predict(self, batch): + def predict(self, feed: np.ndarray) -> np.ndarray: """ **Override method** Override this method for specific extractor model prediction function Parameters ---------- - batch : dict - Contains the batch that is currently being passed through the plugin process + feed: :class:`numpy.ndarray` + The feed images for the batch Notes ----- - Input for :func:`predict` should have been set in :func:`process_input` with the addition - of a ``feed`` key to the :attr:`batch` ``dict``. + Input for :func:`predict` should have been set in :func:`process_input` - Output from the model should add the key ``prediction`` to the :attr:`batch` ``dict``. + Output from the model should populate the key :attr:`prediction` of the :attr:`batch`. For Detect: - the expected output for the ``prediction`` key of the :attr:`batch` dict should be a + the expected output for the :attr:`prediction` of the :attr:`batch` should be a ``list`` of :attr:`batchsize` of detected face points. These points should be either a ``list``, ``tuple`` or ``numpy.ndarray`` with the first 4 items being the `left`, `top`, `right`, `bottom` points, in that order """ raise NotImplementedError - def process_output(self, batch): + def process_output(self, batch: BatchType) -> None: """ **Override method** Override this method for specific extractor model post predict function Parameters ---------- - batch : dict + batch: :class:`ExtractorBatch` Contains the batch that is currently being passed through the plugin process Notes ----- For Align: - The key ``landmarks`` must be returned in the :attr:`batch` ``dict`` from this method. - This should be a ``list`` or ``numpy.ndarray`` of :attr:`batchsize` containing a - ``list``, ``tuple`` or ``numpy.ndarray`` of `(x, y)` coordinates of the 68 point + The :attr:`landmarks` must be populated in :attr:`batch` from this method. + This should be a ``list`` or :class:`numpy.ndarray` of :attr:`batchsize` containing a + ``list``, ``tuple`` or :class:`numpy.ndarray` of `(x, y)` coordinates of the 68 point landmarks as calculated from the :attr:`model`. """ raise NotImplementedError - def _predict(self, batch): + def _predict(self, batch: BatchType) -> BatchType: """ **Override method** (at `` level) This method should be overridden at the `` level (IE. @@ -236,12 +295,12 @@ def _predict(self, batch): Parameters ---------- - batch : dict + batch: :class:`ExtractorBatch` Contains the batch that is currently being passed through the plugin process """ raise NotImplementedError - def _process_input(self, batch): + def _process_input(self, batch: BatchType) -> BatchType: """ **Override method** (at `` level) This method should be overridden at the `` level (IE. @@ -255,17 +314,18 @@ def _process_input(self, batch): Parameters ---------- - batch : dict + batch: :class:`ExtractorBatch` Contains the batch that is currently being passed through the plugin process Notes ----- - When preparing an input to the model a key ``feed`` must be added - to the :attr:`batch` ``dict`` which contains this input. + When preparing an input to the model a the attribute :attr:`feed` must be added + to the :attr:`batch` which contains this input. """ - return self.process_input(batch) + self.process_input(batch) + return batch - def _process_output(self, batch): + def _process_output(self, batch: BatchType) -> BatchType: """ **Override method** (at `` level) This method should be overridden at the `` level (IE. @@ -279,12 +339,13 @@ def _process_output(self, batch): Parameters ---------- - batch : dict + batch: :class:`ExtractorBatch` Contains the batch that is currently being passed through the plugin process """ - return self.process_output(batch) + self.process_output(batch) + return batch - def finalize(self, batch): + def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: """ **Override method** (at `` level) This method should be overridden at the `` level (IE. @@ -296,13 +357,12 @@ def finalize(self, batch): Parameters ---------- - batch : dict + batch: :class:`ExtractorBatch` Contains the batch that is currently being passed through the plugin process - """ raise NotImplementedError - def get_batch(self, queue): + def get_batch(self, queue: "Queue") -> Tuple[bool, BatchType]: """ **Override method** (at `` level) This method should be overridden at the `` level (IE. @@ -320,7 +380,7 @@ def get_batch(self, queue): raise NotImplementedError # <<< THREADING METHODS >>> # - def start(self): + def start(self) -> None: """ Start all threads Exposed for :mod:`~plugins.extract.pipeline` to start plugin's threads @@ -328,7 +388,7 @@ def start(self): for thread in self._threads: thread.start() - def join(self): + def join(self) -> None: """ Join all threads Exposed for :mod:`~plugins.extract.pipeline` to join plugin's threads @@ -337,22 +397,56 @@ def join(self): thread.join() del thread - def check_and_raise_error(self): + def check_and_raise_error(self) -> None: """ Check all threads for errors Exposed for :mod:`~plugins.extract.pipeline` to check plugin's threads for errors """ for thread in self._threads: - err = thread.check_and_raise_error() - if err is not None: - logger.debug("thread_error_detected") - return True - return False + thread.check_and_raise_error() + + def rollover_collector(self, queue: "Queue") -> Union[Literal["EOF"], ExtractMedia]: + """ For extractors after the Detectors, the number of detected faces per frame vs extractor + batch size mean that faces will need to be split/re-joined with frames. The rollover + collector can be used to rollover items that don't fit in a batch. + + Collect the item from the :attr:`_rollover` dict or from the queue. Add face count per + frame to self._faces_per_filename for joining batches back up in finalize + + Parameters + ---------- + queue: :class:`queue.Queue` + The input queue to the aligner. Should contain + :class:`~plugins.extract.pipeline.ExtractMedia` objects + + Returns + ------- + :class:`~plugins.extract.pipeline.ExtractMedia` or EOF + The next extract media object, or EOF if pipe has ended + """ + if self._rollover is not None: + logger.trace("Getting from _rollover: (filename: `%s`, faces: %s)", # type:ignore + self._rollover.filename, len(self._rollover.detected_faces)) + item: Union[Literal["EOF"], ExtractMedia] = self._rollover + self._rollover = None + else: + next_item = self._get_item(queue) + # Rollover collector should only be used at entry to plugin + assert isinstance(next_item, (ExtractMedia, str)) + item = next_item + if item != "EOF": + logger.trace("Getting from queue: (filename: %s, faces: %s)", # type:ignore + item.filename, len(item.detected_faces)) + self._faces_per_filename[item.filename] = len(item.detected_faces) + return item # <<< PROTECTED ACCESS METHODS >>> # # <<< INIT METHODS >>> # @classmethod - def _get_model(cls, git_model_id, model_filename): + def _get_model(cls, + git_model_id: Optional[int], + model_filename: Optional[Union[str, List[str]]] + ) -> Optional[Union[str, List[str]]]: """ Check if model is available, if not, download and unzip it """ if model_filename is None: logger.debug("No model_filename specified. Returning None") @@ -364,13 +458,14 @@ def _get_model(cls, git_model_id, model_filename): return model.model_path # <<< PLUGIN INITIALIZATION >>> # - def initialize(self, *args, **kwargs): + def initialize(self, *args, **kwargs) -> None: """ Initialize the extractor plugin Should be called from :mod:`~plugins.extract.pipeline` """ logger.debug("initialize %s: (args: %s, kwargs: %s)", self.__class__.__name__, args, kwargs) + assert self._plugin_type is not None and self.name is not None if self._is_initialized: # When batch processing, plugins will be initialized on first job in batch logger.debug("Plugin already initialized: %s (%s)", @@ -402,7 +497,10 @@ def initialize(self, *args, **kwargs): logger.info("Initialized %s (%s) with batchsize of %s", self.name, self._plugin_type.title(), self.batchsize) - def _add_queues(self, in_queue, out_queue, queues): + def _add_queues(self, + in_queue: "Queue", + out_queue: "Queue", + queues: List[str]) -> None: """ Add the queues in_queue and out_queue should be previously created queue manager queues. queues should be a list of queue names """ @@ -414,8 +512,9 @@ def _add_queues(self, in_queue, out_queue, queues): maxsize=self.queue_size) # <<< THREAD METHODS >>> # - def _compile_threads(self): + def _compile_threads(self) -> None: """ Compile the threads into self._threads list """ + assert self.name is not None logger.debug("Compiling %s threads", self._plugin_type) name = self.name.replace(" ", "_").lower() base_name = f"{self._plugin_type}_{name}" @@ -433,7 +532,11 @@ def _compile_threads(self): self._queues["out"]) logger.debug("Compiled %s threads: %s", self._plugin_type, self._threads) - def _add_thread(self, name, function, in_queue, out_queue): + def _add_thread(self, + name: str, + function: Callable[[BatchType], BatchType], + in_queue: "Queue", + out_queue: "Queue") -> None: """ Add a MultiThread thread to self._threads """ logger.debug("Adding thread: (name: %s, function: %s, in_queue: %s, out_queue: %s)", name, function, in_queue, out_queue) @@ -444,27 +547,64 @@ def _add_thread(self, name, function, in_queue, out_queue): out_queue=out_queue)) logger.debug("Added thread: %s", name) - def _thread_process(self, function, in_queue, out_queue): - """ Perform a plugin function in a thread """ - func_name = function.__name__ - logger.debug("threading: (function: '%s')", func_name) + def _obtain_batch_item(self, function: Callable[[BatchType], BatchType], + in_queue: "Queue", + out_queue: "Queue") -> Optional[BatchType]: + """ Obtain the batch item from the in queue for the current process. + + Parameters + ---------- + function: callable + The current plugin function being run + in_queue: :class:`queue.Queue` + The input queue for the function + out_queue: :class:`queue.Queue` + The output queue from the function + + Returns + ------- + :class:`ExtractorBatch` or ``None`` + The batch, if one exists, or ``None`` if queue is exhausted + """ + batch: Union[Literal["EOF"], BatchType, ExtractMedia] + if function.__name__ == "_process_input": # Process input items to batches + exhausted, batch = self.get_batch(in_queue) + if exhausted: + if batch.filename: + # Put the final batch + batch = function(batch) + out_queue.put(batch) + return None + else: + batch = self._get_item(in_queue) + if batch == "EOF": + return None + + # ExtractMedia should only ever be the output of _get_item at the entry to a + # plugin's pipeline (ie in _process_input) + assert not isinstance(batch, ExtractMedia) + return batch + + def _thread_process(self, + function: Callable[[BatchType], BatchType], + in_queue: "Queue", + out_queue: "Queue") -> None: + """ Perform a plugin function in a thread + + Parameters + ---------- + function: callable + The current plugin function being run + in_queue: :class:`queue.Queue` + The input queue for the function + out_queue: :class:`queue.Queue` + The output queue from the function + """ + logger.debug("threading: (function: '%s')", function.__name__) while True: - if func_name == "_process_input": - # Process input items to batches - exhausted, batch = self.get_batch(in_queue) - if exhausted: - # TODO Move all batch items to common dataclass. Currently migrated: - # Align - if (isinstance(batch, dict) and batch or - not isinstance(batch, dict) and batch.filename): - # Put the final batch - batch = function(batch) - out_queue.put(batch) - break - else: - batch = self._get_item(in_queue) - if batch == "EOF": - break + batch = self._obtain_batch_item(function, in_queue, out_queue) + if batch is None: + break try: batch = function(batch) except tf_errors.UnknownError as err: @@ -479,7 +619,7 @@ def _thread_process(self, function, in_queue, out_queue): "`allow_growth option to `True`.") raise FaceswapError(msg) from err raise err - if func_name == "_process_output": + if function.__name__ == "_process_output": # Process output items to individual items from batch for item in self.finalize(batch): out_queue.put(item) @@ -489,19 +629,14 @@ def _thread_process(self, function, in_queue, out_queue): out_queue.put("EOF") # <<< QUEUE METHODS >>> # - def _get_item(self, queue): + def _get_item(self, queue: "Queue") -> Union[Literal["EOF"], ExtractMedia, BatchType]: """ Yield one item from a queue """ item = queue.get() if isinstance(item, ExtractMedia): - logger.trace("filename: '%s', image shape: %s, detected_faces: %s, queue: %s, " - "item: %s", + logger.trace("filename: '%s', image shape: %s, detected_faces: %s, " # type:ignore + "queue: %s, item: %s", item.filename, item.image_shape, item.detected_faces, queue, item) self._extract_media[item.filename] = item else: - logger.trace("item: %s, queue: %s", item, queue) + logger.trace("item: %s, queue: %s", item, queue) # type:ignore return item - - @staticmethod - def _dict_lists_to_list_dicts(dictionary): - """ Convert a dictionary of lists to a list of dictionaries """ - return [dict(zip(dictionary, val)) for val in zip(*dictionary.values())] diff --git a/plugins/extract/_config.py b/plugins/extract/_config.py index e2de04acb9..ecb5f353ee 100644 --- a/plugins/extract/_config.py +++ b/plugins/extract/_config.py @@ -78,9 +78,9 @@ def set_globals(self): section=section, title="aligner_roll", datatype=float, - min_max=(0.0, 45.0), + min_max=(0.0, 90.0), rounding=1, - default=15.0, + default=45.0, group="filters", info="Filters out faces who's calculated roll is greater than zero +/- this value in " "degrees. Aligned faces should have a roll value close to zero. Values that are a " diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index b1722ba8e5..26a9fec7fe 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -12,10 +12,11 @@ >>> "landmarks": [list of 68 point face landmarks] >>> "detected_faces": []} """ +import logging import sys from dataclasses import dataclass, field -from typing import Any, cast, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union +from typing import cast, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING import cv2 import numpy as np @@ -24,7 +25,7 @@ from lib.align import AlignedFace, DetectedFace from lib.utils import get_backend, FaceswapError -from plugins.extract._base import Extractor, logger, ExtractMedia +from plugins.extract._base import BatchType, Extractor, ExtractMedia, ExtractorBatch if sys.version_info < (3, 8): from typing_extensions import Literal @@ -34,36 +35,26 @@ if TYPE_CHECKING: from queue import Queue +logger = logging.getLogger(__name__) + @dataclass -class AlignerBatch: +class AlignerBatch(ExtractorBatch): """ Dataclass for holding items flowing through the aligner. + Inherits from :class:`~plugins.extract._base.ExtractorBatch` + Parameters ---------- - image: list - List of :class:`numpy.ndarray` containing the original frame - detected_faces: list - List of :class:`~lib.align.DetectedFace` objects - filename: list - List of original frame filenames for the batch - feed: list - List of feed images to feed the aligner net for each re-feed increment - prediction: list - List of predictions. Direct output from the aligner net landmarks: list List of 68 point :class:`numpy.ndarray` landmark points returned from the aligner - data: dict - Any aligner specific data required during the processing phase. List of dictionaries for - holding data on each sub-batch if re-feed > 1 + refeeds: list + List of :class:`numpy.ndarrays` for holding each of the feeds that will be put through the + model for each refeed """ - image: List[np.ndarray] = field(default_factory=list) - detected_faces: List[DetectedFace] = field(default_factory=list) - filename: List[str] = field(default_factory=list) - feed: List[np.ndarray] = field(default_factory=list) - prediction: np.ndarray = np.empty([]) - landmarks: np.ndarray = np.empty([]) - data: List[Dict[str, Any]] = field(default_factory=list) + detected_faces: List["DetectedFace"] = field(default_factory=list) + landmarks: np.ndarray = np.array([]) + refeeds: List[np.ndarray] = field(default_factory=list) class Aligner(Extractor): # pylint:disable=abstract-method @@ -120,9 +111,6 @@ def __init__(self, self.set_normalize_method(normalize_method) self._plugin_type = "align" - self._faces_per_filename: Dict[str, int] = {} # Tracking for recompiling batches - self._rollover: Optional[ExtractMedia] = None # batch rollover items - self._output_faces: List[DetectedFace] = [] self._filter = AlignedFilter(min_scale=self.config["aligner_min_scale"], max_scale=self.config["aligner_max_scale"], distance=self.config["aligner_distance"], @@ -175,14 +163,14 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, AlignerBatch]: ------- exhausted, bool ``True`` if queue is exhausted, ``False`` if not - batch, dict - A dictionary of lists of :attr:`~plugins.extract._base.Extractor.batchsize`: + batch, :class:`~plugins.extract._base.ExtractorBatch` + The batch object for the current batch """ exhausted = False batch = AlignerBatch() idx = 0 while idx < self.batchsize: - item = self._collect_item(queue) + item = self.rollover_collector(queue) if item == "EOF": logger.trace("EOF received") # type:ignore exhausted = True @@ -211,7 +199,7 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, AlignerBatch]: break if batch.filename: logger.trace("Returning batch: %s", {k: len(v) # type:ignore - if isinstance(v, list) else v + if isinstance(v, (list, np.ndarray)) else v for k, v in batch.__dict__.items()}) else: logger.debug(item) # type:ignore @@ -222,36 +210,8 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, AlignerBatch]: return exhausted, batch - def _collect_item(self, queue: "Queue") -> Union[Literal["EOF"], ExtractMedia]: - """ Collect the item from the :attr:`_rollover` dict or from the queue. Add face count per - frame to self._faces_per_filename for joining batches back up in finalize - - Parameters - ---------- - queue: :class:`queue.Queue` - The input queue to the aligner. Should contain - :class:`~plugins.extract.pipeline.ExtractMedia` objects - - Returns - ------- - :class:`~plugins.extract.pipeline.ExtractMedia` or EOF - The next extract media object, or EOF if pipe has ended - """ - if self._rollover is not None: - logger.trace("Getting from _rollover: (filename: `%s`, faces: %s)", # type:ignore - self._rollover.filename, len(self._rollover.detected_faces)) - item = self._rollover - self._rollover = None - else: - item = self._get_item(queue) - if item != "EOF": - logger.trace("Getting from queue: (filename: %s, faces: %s)", # type:ignore - item.filename, len(item.detected_faces)) - self._faces_per_filename[item.filename] = len(item.detected_faces) - return item - # <<< FINALIZE METHODS >>> # - def finalize(self, batch: AlignerBatch) -> Generator[ExtractMedia, None, None]: + def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: """ Finalize the output from Aligner This should be called as the final task of each `plugin`. @@ -270,10 +230,11 @@ def finalize(self, batch: AlignerBatch) -> Generator[ExtractMedia, None, None]: and landmarks for the detected faces found in the frame. """ + assert isinstance(batch, AlignerBatch) for face, landmarks in zip(batch.detected_faces, batch.landmarks): if not isinstance(landmarks, np.ndarray): landmarks = np.array(landmarks) - face._landmarks_xy = landmarks + face.add_landmarks_xy(landmarks) logger.trace("Item out: %s", {key: val.shape # type:ignore if isinstance(val, np.ndarray) else val @@ -299,7 +260,7 @@ def finalize(self, batch: AlignerBatch) -> Generator[ExtractMedia, None, None]: # <<< PROTECTED METHODS >>> # # << PROCESS_INPUT WRAPPER >> - def _process_input(self, batch: AlignerBatch) -> AlignerBatch: + def _process_input(self, batch: BatchType) -> AlignerBatch: """ Process the input to the aligner model multiple times based on the user selected `re-feed` command line option. This adjusts the bounding box for the face to be fed into the model by a random amount within 0.05 pixels of the detected face's shortest axis. @@ -318,6 +279,7 @@ def _process_input(self, batch: AlignerBatch) -> AlignerBatch: :class:`AlignerBatch` The batch with input processed """ + assert isinstance(batch, AlignerBatch) original_boxes = np.array([(face.left, face.top, face.width, face.height) for face in batch.detected_faces]) adjusted_boxes = self._get_adjusted_boxes(original_boxes) @@ -328,6 +290,9 @@ def _process_input(self, batch: AlignerBatch) -> AlignerBatch: face.left, face.top, face.width, face.height = box self.process_input(batch) + # Move the populated feed into the batch refeed list. It will be overwritten at next + # iteration + batch.refeeds.append(batch.feed) # Place the original bounding box back to detected face objects for face, box in zip(batch.detected_faces, original_boxes): @@ -361,7 +326,7 @@ def _get_adjusted_boxes(self, original_boxes: np.ndarray) -> np.ndarray: return retval # <<< PREDICT WRAPPER >>> # - def _predict(self, batch: AlignerBatch) -> AlignerBatch: + def _predict(self, batch: BatchType) -> AlignerBatch: """ Just return the aligner's predict function Parameters @@ -379,8 +344,9 @@ def _predict(self, batch: AlignerBatch) -> AlignerBatch: FaceswapError If GPU resources are exhausted """ + assert isinstance(batch, AlignerBatch) try: - batch.prediction = np.array([self.predict(feed) for feed in batch.feed]) + batch.prediction = np.array([self.predict(feed) for feed in batch.refeeds]) return batch except tf_errors.ResourceExhaustedError as err: msg = ("You do not have enough GPU memory available to run detection at the " @@ -410,7 +376,7 @@ def _predict(self, batch: AlignerBatch) -> AlignerBatch: raise FaceswapError(msg) from err raise - def _process_output(self, batch: AlignerBatch) -> AlignerBatch: + def _process_output(self, batch: BatchType) -> AlignerBatch: """ Process the output from the aligner model multiple times based on the user selected `re-feed amount` configuration option, then average the results for final prediction. @@ -424,6 +390,7 @@ def _process_output(self, batch: AlignerBatch) -> AlignerBatch: :class:`AlignerBatch` The batch item with :attr:`landmarks` populated """ + assert isinstance(batch, AlignerBatch) landmarks = [] for idx in range(self._re_feed + 1): # Create a pseudo object that only populates the data, feed and prediction slots with @@ -431,7 +398,7 @@ def _process_output(self, batch: AlignerBatch) -> AlignerBatch: subbatch = AlignerBatch(image=batch.image, detected_faces=batch.detected_faces, filename=batch.filename, - feed=[batch.feed[idx]], + feed=batch.refeeds[idx], prediction=batch.prediction[idx], data=[batch.data[idx]]) self.process_output(subbatch) diff --git a/plugins/extract/align/cv2_dnn.py b/plugins/extract/align/cv2_dnn.py index cbaf497175..3459e38c7c 100644 --- a/plugins/extract/align/cv2_dnn.py +++ b/plugins/extract/align/cv2_dnn.py @@ -23,16 +23,19 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +import logging from typing import cast, List, Tuple, TYPE_CHECKING import cv2 import numpy as np -from ._base import Aligner, AlignerBatch, logger +from ._base import Aligner, AlignerBatch, BatchType if TYPE_CHECKING: from lib.align.detected_face import DetectedFace +logger = logging.getLogger(__name__) + class Align(Aligner): """ Perform transformation to align and get landmarks """ @@ -41,6 +44,7 @@ def __init__(self, **kwargs) -> None: model_filename = "cnn-facial-landmark_v1.pb" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.model: cv2.dnn.Net self.name = "cv2-DNN Aligner" self.input_size = 128 self.color_format = "RGB" @@ -53,7 +57,7 @@ def init_model(self) -> None: self.model = cv2.dnn.readNetFromTensorflow(self.model_path) # pylint: disable=no-member self.model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) # pylint: disable=no-member - def process_input(self, batch: AlignerBatch) -> None: + def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction Parameters @@ -66,10 +70,11 @@ def process_input(self, batch: AlignerBatch) -> None: :class:`AlignerBatch` The batch item with the :attr:`feed` populated and any required :attr:`data` added """ + assert isinstance(batch, AlignerBatch) faces, roi, offsets = self.align_image(batch) faces = self._normalize_faces(faces) batch.data.append(dict(roi=roi, offsets=offsets)) - batch.feed.append(np.array(faces, dtype="float32")[..., :3].transpose((0, 3, 1, 2))) + batch.feed = np.array(faces, dtype="float32")[..., :3].transpose((0, 3, 1, 2)) def _get_box_and_offset(self, face: "DetectedFace") -> Tuple[List[int], int]: """Obtain the bounding box and offset from a detected face. @@ -240,12 +245,12 @@ def pad_image(cls, box: List[int], image: np.ndarray) -> Tuple[np.ndarray, Tuple image.shape, padded_image.shape, box, offsets) return padded_image, offsets - def predict(self, batch: AlignerBatch) -> np.ndarray: + def predict(self, feed: np.ndarray) -> np.ndarray: """ Predict the 68 point landmarks Parameters ---------- - batch: :class:`numpy.ndarray` + feed: :class:`numpy.ndarray` The batch to feed into the aligner Returns @@ -253,26 +258,21 @@ def predict(self, batch: AlignerBatch) -> np.ndarray: :class:`numpy.ndarray` The predictions from the aligner """ - logger.trace("Predicting Landmarks") # type:ignore - self.model.setInput(batch) + assert isinstance(self.model, cv2.dnn.Net) + self.model.setInput(feed) retval = self.model.forward() return retval - def process_output(self, batch: AlignerBatch) -> AlignerBatch: + def process_output(self, batch: BatchType) -> None: """ Process the output from the model Parameters ---------- batch: :class:`AlignerBatch` The current batch from the model with :attr:`predictions` populated - - Returns - ------- - :class:`AlignerBatch` - The current batch with the :attr:`landmarks` populated """ + assert isinstance(batch, AlignerBatch) self.get_pts_from_predict(batch) - return batch @classmethod def get_pts_from_predict(cls, batch: AlignerBatch): diff --git a/plugins/extract/align/fan.py b/plugins/extract/align/fan.py index 75708804b6..5a632f3aac 100644 --- a/plugins/extract/align/fan.py +++ b/plugins/extract/align/fan.py @@ -3,17 +3,20 @@ Code adapted and modified from: https://github.com/1adrianb/face-alignment """ +import logging from typing import cast, List, TYPE_CHECKING import cv2 import numpy as np from lib.model.session import KSession -from ._base import Aligner, AlignerBatch, logger +from ._base import Aligner, AlignerBatch, BatchType if TYPE_CHECKING: from lib.align import DetectedFace +logger = logging.getLogger(__name__) + class Align(Aligner): """ Perform transformation to align and get landmarks """ @@ -21,6 +24,7 @@ def __init__(self, **kwargs) -> None: git_model_id = 13 model_filename = "face-alignment-network_2d4_keras_v2.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.model: KSession self.name = "FAN" self.input_size = 256 self.color_format = "RGB" @@ -32,6 +36,8 @@ def __init__(self, **kwargs) -> None: def init_model(self) -> None: """ Initialize FAN model """ + assert isinstance(self.name, str) + assert isinstance(self.model_path, str) self.model = KSession(self.name, self.model_path, allow_growth=self.config["allow_growth"], @@ -42,7 +48,7 @@ def init_model(self) -> None: placeholder = np.zeros(placeholder_shape, dtype="float32") self.model.predict(placeholder) - def process_input(self, batch: AlignerBatch) -> None: + def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction Parameters @@ -50,13 +56,14 @@ def process_input(self, batch: AlignerBatch) -> None: batch: :class:`AlignerBatch` The current batch to process input for """ - logger.debug("Aligning faces around center") + assert isinstance(batch, AlignerBatch) + logger.trace("Aligning faces around center") # type:ignore center_scale = self.get_center_scale(batch.detected_faces) faces = self.crop(batch, center_scale) logger.trace("Aligned image around center") # type:ignore faces = self._normalize_faces(faces) batch.data.append(dict(center_scale=center_scale)) - batch.feed.append(np.array(faces, dtype="float32")[..., :3] / 255.0) + batch.feed = np.array(faces, dtype="float32")[..., :3] / 255.0 def get_center_scale(self, detected_faces: List["DetectedFace"]) -> np.ndarray: """ Get the center and set scale of bounding box @@ -71,7 +78,7 @@ def get_center_scale(self, detected_faces: List["DetectedFace"]) -> np.ndarray: :class:`numpy.ndarray` The center and scale of the bounding box """ - logger.debug("Calculating center and scale") + logger.trace("Calculating center and scale") # type:ignore center_scale = np.empty((len(detected_faces), 68, 3), dtype='float32') for index, face in enumerate(detected_faces): x_center = (cast(int, face.left) + face.right) / 2.0 @@ -185,7 +192,7 @@ def transform(cls, logger.trace("Transformed Points: %s", retval) # type:ignore return retval - def predict(self, batch: np.ndarray) -> np.ndarray: + def predict(self, feed: np.ndarray) -> np.ndarray: """ Predict the 68 point landmarks Parameters @@ -198,30 +205,25 @@ def predict(self, batch: np.ndarray) -> np.ndarray: :class:`numpy.ndarray` The predictions from the aligner """ - logger.debug("Predicting Landmarks") + logger.trace("Predicting Landmarks") # type:ignore # TODO Remove lazy transpose and change points from predict to use the correct # order - retval = self.model.predict(batch)[-1].transpose(0, 3, 1, 2) + retval = self.model.predict(feed)[-1].transpose(0, 3, 1, 2) logger.trace(retval.shape) # type:ignore return retval - def process_output(self, batch: AlignerBatch) -> AlignerBatch: + def process_output(self, batch: BatchType) -> None: """ Process the output from the model Parameters ---------- batch: :class:`AlignerBatch` The current batch from the model with :attr:`predictions` populated - - Returns - ------- - :class:`AlignerBatch` - The current batch with the :attr:`landmarks` populated """ + assert isinstance(batch, AlignerBatch) self.get_pts_from_predict(batch) - return batch - def get_pts_from_predict(self, batch: AlignerBatch): + def get_pts_from_predict(self, batch: AlignerBatch) -> None: """ Get points from predictor and populate the :attr:`landmarks` property of the :class:`AlignerBatch` @@ -230,7 +232,7 @@ def get_pts_from_predict(self, batch: AlignerBatch): batch: :class:`AlignerBatch` The current batch from the model with :attr:`predictions` populated """ - logger.debug("Obtain points from prediction") + logger.trace("Obtain points from prediction") # type:ignore num_images, num_landmarks = batch.prediction.shape[:2] image_slice = np.repeat(np.arange(num_images)[:, None], num_landmarks, axis=1) landmark_slice = np.repeat(np.arange(num_landmarks)[None, :], num_images, axis=0) diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index 93a93c0b92..3c9bfcd78f 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -13,8 +13,12 @@ To get a :class:`~lib.align.DetectedFace` object use the function: ->>> face = self.to_detected_face(, , , ) +>>> face = self._to_detected_face(, , , ) """ +import logging +from dataclasses import dataclass, field +from typing import cast, Generator, List, Optional, Tuple, TYPE_CHECKING, Union + import cv2 import numpy as np @@ -23,7 +27,37 @@ from lib.align import DetectedFace from lib.utils import get_backend, FaceswapError -from plugins.extract._base import Extractor, logger +from plugins.extract._base import BatchType, Extractor, ExtractorBatch +from plugins.extract.pipeline import ExtractMedia + +if TYPE_CHECKING: + from queue import Queue + +logger = logging.getLogger(__name__) + + +@dataclass +class DetectorBatch(ExtractorBatch): + """ Dataclass for holding items flowing through the aligner. + + Inherits from :class:`~plugins.extract._base.ExtractorBatch` + + Parameters + ---------- + rotation_matrix: :class:`numpy.ndarray` + The rotation matrix for any requested rotations + scale: float + The scaling factor to take the input image back to original size + pad: tuple + The amount of padding to apply to the image to feed the network + initial_feed: :class:`numpy.ndarray` + Used to hold the initial :attr:`feed` when rotate images is enabled + """ + detected_faces: List[List["DetectedFace"]] = field(default_factory=list) + rotation_matrix: List[np.ndarray] = field(default_factory=list) + scale: List[float] = field(default_factory=list) + pad: List[Tuple[int, int]] = field(default_factory=list) + initial_feed: np.ndarray = np.array([]) class Detector(Extractor): # pylint:disable=abstract-method @@ -60,8 +94,14 @@ class Detector(Extractor): # pylint:disable=abstract-method plugins.extract.mask._base : Masker parent class for extraction plugins. """ - def __init__(self, git_model_id=None, model_filename=None, - configfile=None, instance=0, rotation=None, min_size=0, **kwargs): + def __init__(self, + git_model_id: Optional[int] = None, + model_filename: Optional[Union[str, List[str]]] = None, + configfile: Optional[str] = None, + instance: int = 0, + rotation: Optional[str] = None, + min_size: int = 0, + **kwargs) -> None: logger.debug("Initializing %s: (rotation: %s, min_size: %s)", self.__class__.__name__, rotation, min_size) super().__init__(git_model_id, @@ -77,7 +117,7 @@ def __init__(self, git_model_id=None, model_filename=None, logger.debug("Initialized _base %s", self.__class__.__name__) # <<< QUEUE METHODS >>> # - def get_batch(self, queue): + def get_batch(self, queue: "Queue") -> Tuple[bool, DetectorBatch]: """ Get items for inputting to the detector plugin in batches Items are received as :class:`~plugins.extract.pipeline.ExtractMedia` objects and converted @@ -108,41 +148,41 @@ def get_batch(self, queue): ------- exhausted, bool ``True`` if queue is exhausted, ``False`` if not. - batch, dict - A dictionary of lists of :attr:`~plugins.extract._base.Extractor.batchsize`. + batch, :class:`~plugins.extract._base.ExtractorBatch` + The batch object for the current batch """ exhausted = False - batch = {} + batch = DetectorBatch() for _ in range(self.batchsize): item = self._get_item(queue) if item == "EOF": exhausted = True break - batch.setdefault("filename", []).append(item.filename) + assert isinstance(item, ExtractMedia) + batch.filename.append(item.filename) image, scale, pad = self._compile_detection_image(item) - batch.setdefault("image", []).append(image) - batch.setdefault("scale", []).append(scale) - batch.setdefault("pad", []).append(pad) + batch.image.append(image) + batch.scale.append(scale) + batch.pad.append(pad) if batch: - batch["image"] = np.array(batch["image"], dtype="float32") - logger.trace("Returning batch: %s", {k: v.shape if isinstance(v, np.ndarray) else v - for k, v in batch.items()}) + logger.trace("Returning batch: %s", # type: ignore + {k: len(v) if isinstance(v, (list, np.ndarray)) else v + for k, v in batch.__dict__.items()}) else: - logger.trace(item) + logger.trace(item) # type:ignore return exhausted, batch # <<< FINALIZE METHODS>>> # - def finalize(self, batch): + def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: """ Finalize the output from Detector This should be called as the final task of each ``plugin``. Parameters ---------- - batch : dict - The final ``dict`` from the `plugin` process. It must contain the keys ``filename``, - ``faces`` + batch : :class:`~plugins.extract._base.ExtractorBatch` + The batch object for the current batch Yields ------ @@ -150,50 +190,66 @@ def finalize(self, batch): The :attr:`DetectedFaces` list will be populated for this class with the bounding boxes for the detected faces found in the frame. """ - if not isinstance(batch, dict): - logger.trace("Item out: %s", batch) - return batch + assert isinstance(batch, DetectorBatch) + logger.trace("Item out: %s", # type:ignore + {k: len(v) if isinstance(v, (list, np.ndarray)) else v + for k, v in batch.__dict__.items()}) - logger.trace("Item out: %s", {k: v.shape if isinstance(v, np.ndarray) else v - for k, v in batch.items()}) - - batch_faces = [[self.to_detected_face(face[0], face[1], face[2], face[3]) + batch_faces = [[self._to_detected_face(face[0], face[1], face[2], face[3]) for face in faces] - for faces in batch["prediction"]] + for faces in batch.prediction] # Rotations - if any(m.any() for m in batch["rotmat"]) and any(batch_faces): + if any(m.any() for m in batch.rotation_matrix) and any(batch_faces): batch_faces = [[self._rotate_face(face, rotmat) if rotmat.any() else face for face in faces] - for faces, rotmat in zip(batch_faces, batch["rotmat"])] + for faces, rotmat in zip(batch_faces, batch.rotation_matrix)] # Remove zero sized faces batch_faces = self._remove_zero_sized_faces(batch_faces) # Scale back out to original frame - batch["detected_faces"] = [[self.to_detected_face((face.left - pad[0]) / scale, - (face.top - pad[1]) / scale, - (face.right - pad[0]) / scale, - (face.bottom - pad[1]) / scale) - for face in faces] - for scale, pad, faces in zip(batch["scale"], - batch["pad"], - batch_faces)] - - if self.min_size > 0 and batch.get("detected_faces", None): - batch["detected_faces"] = self._filter_small_faces(batch["detected_faces"]) - - batch = self._dict_lists_to_list_dicts(batch) - for item in batch: - output = self._extract_media.pop(item["filename"]) - output.add_detected_faces(item["detected_faces"]) - logger.trace("final output: (filename: '%s', image shape: %s, detected_faces: %s, " - "item: %s", output.filename, output.image_shape, output.detected_faces, - output) + batch.detected_faces = [[self._to_detected_face((face.left - pad[0]) / scale, + (face.top - pad[1]) / scale, + (face.right - pad[0]) / scale, + (face.bottom - pad[1]) / scale) + for face in faces + if face.left is not None and face.top is not None] + for scale, pad, faces in zip(batch.scale, + batch.pad, + batch_faces)] + + if self.min_size > 0 and batch.detected_faces: + batch.detected_faces = self._filter_small_faces(batch.detected_faces) + + for idx, filename in enumerate(batch.filename): + output = self._extract_media.pop(filename) + output.add_detected_faces(batch.detected_faces[idx]) + + logger.trace("final output: (filename: '%s', image shape: %s, " # type:ignore + "detected_faces: %s, item: %s", output.filename, output.image_shape, + output.detected_faces, output) yield output @staticmethod - def to_detected_face(left, top, right, bottom): - """ Return a :class:`~lib.align.DetectedFace` object for the bounding box """ + def _to_detected_face(left: float, top: float, right: float, bottom: float): + """ Convert a bounding box to a detected face object + + Parameters + ---------- + left: float + The left point of the detection bounding box + top: float + The top point of the detection bounding box + right: float + The right point of the detection bounding box + bottom: float + The bottom point of the detection bounding box + + Returns + ------- + class:`~lib.align.DetectedFace` + The detected face object for the given bounding box + """ return DetectedFace(left=int(round(left)), width=int(round(right - left)), top=int(round(top)), @@ -201,15 +257,18 @@ def to_detected_face(left, top, right, bottom): # <<< PROTECTED ACCESS METHODS >>> # # <<< PREDICT WRAPPER >>> # - def _predict(self, batch): + def _predict(self, batch: BatchType) -> DetectorBatch: """ Wrap models predict function in rotations """ - batch["rotmat"] = [np.array([]) for _ in range(len(batch["feed"]))] - found_faces = [np.array([]) for _ in range(len(batch["feed"]))] + assert isinstance(batch, DetectorBatch) + batch.rotation_matrix = [np.array([]) for _ in range(len(batch.feed))] + found_faces: List[np.ndarray] = [np.array([]) for _ in range(len(batch.feed))] for angle in self.rotation: # Rotate the batch and insert placeholders for already found faces self._rotate_batch(batch, angle) try: - batch = self.predict(batch) + batch.prediction = self.predict(batch.feed) + logger.trace("angle: %s, filenames: %s, prediction: %s", # type:ignore + angle, batch.filename, batch.prediction) except tf_errors.ResourceExhaustedError as err: msg = ("You do not have enough GPU memory available to run detection at the " "selected batch size. You can try a number of things:" @@ -238,29 +297,41 @@ def _predict(self, batch): raise FaceswapError(msg) from err raise - if angle != 0 and any([face.any() for face in batch["prediction"]]): - logger.verbose("found face(s) by rotating image %s degrees", angle) + if angle != 0 and any(face.any() for face in batch.prediction): + logger.verbose("found face(s) by rotating image %s degrees", # type:ignore + angle) - found_faces = [face if not found.any() else found - for face, found in zip(batch["prediction"], found_faces)] + found_faces = cast(List[np.ndarray], ([face if not found.any() else found + for face, found in zip(batch.prediction, + found_faces)])) - if all([face.any() for face in found_faces]): - logger.trace("Faces found for all images") + if all(face.any() for face in found_faces): + logger.trace("Faces found for all images") # type:ignore break - batch["prediction"] = found_faces - logger.trace("detect_prediction output: (filenames: %s, prediction: %s, rotmat: %s)", - batch["filename"], batch["prediction"], batch["rotmat"]) + batch.prediction = np.array(found_faces, dtype="object") + logger.trace("detect_prediction output: (filenames: %s, prediction: %s, " # type:ignore + "rotmat: %s)", batch.filename, batch.prediction, batch.rotation_matrix) return batch # <<< DETECTION IMAGE COMPILATION METHODS >>> # - def _compile_detection_image(self, item): + def _compile_detection_image(self, item: ExtractMedia + ) -> Tuple[np.ndarray, float, Tuple[int, int]]: """ Compile the detection image for feeding into the model Parameters ---------- item: :class:`plugins.extract.pipeline.ExtractMedia` The input item from the pipeline + + Returns + ------- + image: :class:`numpy.ndarray` + The original image formatted for detection + scale: float + The scaling factor for the image + pad: int + The amount of padding applied to the image """ image = item.get_image_copy(self.color_format) scale = self._set_scale(item.image_size) @@ -268,36 +339,87 @@ def _compile_detection_image(self, item): image = self._scale_image(image, item.image_size, scale) image = self._pad_image(image) - logger.trace("compiled: (images shape: %s, scale: %s, pad: %s)", image.shape, scale, pad) + logger.trace("compiled: (images shape: %s, scale: %s, pad: %s)", # type:ignore + image.shape, scale, pad) return image, scale, pad - def _set_scale(self, image_size): - """ Set the scale factor for incoming image """ + def _set_scale(self, image_size: Tuple[int, int]) -> float: + """ Set the scale factor for incoming image + + Parameters + ---------- + image_size: tuple + The (height, width) of the original image + + Returns + ------- + float + The scaling factor from original image size to model input size + """ scale = self.input_size / max(image_size) - logger.trace("Detector scale: %s", scale) + logger.trace("Detector scale: %s", scale) # type:ignore return scale - def _set_padding(self, image_size, scale): - """ Set the image padding for non-square images """ + def _set_padding(self, image_size: Tuple[int, int], scale: float) -> Tuple[int, int]: + """ Set the image padding for non-square images + + Parameters + ---------- + image_size: tuple + The (height, width) of the original image + scale: float + The scaling factor from original image size to model input size + + Returns + ------- + tuple + The amount of padding to apply to the x and y axes + """ pad_left = int(self.input_size - int(image_size[1] * scale)) // 2 pad_top = int(self.input_size - int(image_size[0] * scale)) // 2 return pad_left, pad_top @staticmethod - def _scale_image(image, image_size, scale): - """ Scale the image and optional pad to given size """ + def _scale_image(image: np.ndarray, image_size: Tuple[int, int], scale: float) -> np.ndarray: + """ Scale the image and optional pad to given size + + Parameters + ---------- + image: :class:`numpy.ndarray` + The image to be scalued + image_size: tuple + The image (height, width) + scale: float + The scaling factor to apply to the image + + Returns + ------- + :class:`numpy.ndarray` + The scaled image + """ interpln = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA if scale != 1.0: dims = (int(image_size[1] * scale), int(image_size[0] * scale)) - logger.trace("Resizing detection image from %s to %s. Scale=%s", + logger.trace("Resizing detection image from %s to %s. Scale=%s", # type:ignore "x".join(str(i) for i in reversed(image_size)), "x".join(str(i) for i in dims), scale) image = cv2.resize(image, dims, interpolation=interpln) - logger.trace("Resized image shape: %s", image.shape) + logger.trace("Resized image shape: %s", image.shape) # type:ignore return image - def _pad_image(self, image): - """ Pad a resized image to input size """ + def _pad_image(self, image: np.ndarray) -> np.ndarray: + """ Pad a resized image to input size + + Parameters + ---------- + image: :class:`numpy.ndarray` + The image to have padding applied + + Returns + ------- + :class:`numpy.ndarray` + The image with padding applied + """ height, width = image.shape[:2] if width < self.input_size or height < self.input_size: pad_l = (self.input_size - width) // 2 @@ -310,28 +432,53 @@ def _pad_image(self, image): pad_l, pad_r, cv2.BORDER_CONSTANT) - logger.trace("Padded image shape: %s", image.shape) + logger.trace("Padded image shape: %s", image.shape) # type:ignore return image # <<< FINALIZE METHODS >>> # - def _remove_zero_sized_faces(self, batch_faces): - """ Remove items from batch_faces where detected face is of zero size - or face falls entirely outside of image """ - logger.trace("Input sizes: %s", [len(face) for face in batch_faces]) + def _remove_zero_sized_faces(self, batch_faces: List[List[DetectedFace]] + ) -> List[List[DetectedFace]]: + """ Remove items from batch_faces where detected face is of zero size or face falls + entirely outside of image + + Parameters + ---------- + batch_faces: list + List of detected face objects + + Returns + ------- + list + List of detected face objects with filtered out faces removed + """ + logger.trace("Input sizes: %s", [len(face) for face in batch_faces]) # type: ignore retval = [[face for face in faces - if face.right > 0 and face.left < self.input_size - and face.bottom > 0 and face.top < self.input_size] + if face.right > 0 and face.left is not None and face.left < self.input_size + and face.bottom > 0 and face.top is not None and face.top < self.input_size] for faces in batch_faces] - logger.trace("Output sizes: %s", [len(face) for face in retval]) + logger.trace("Output sizes: %s", [len(face) for face in retval]) # type: ignore return retval - def _filter_small_faces(self, detected_faces): - """ Filter out any faces smaller than the min size threshold """ + def _filter_small_faces(self, detected_faces: List[List[DetectedFace]] + ) -> List[List[DetectedFace]]: + """ Filter out any faces smaller than the min size threshold + + Parameters + ---------- + detected_faces: list + List of detected face objects + + Returns + ------- + list + List of detected face objects with filtered out faces removed + """ retval = [] for faces in detected_faces: this_image = [] for face in faces: + assert face.width is not None and face.height is not None face_size = (face.width ** 2 + face.height ** 2) ** 0.5 if face_size < self.min_size: logger.debug("Removing detected face: (face_size: %s, min_size: %s", @@ -343,59 +490,74 @@ def _filter_small_faces(self, detected_faces): # <<< IMAGE ROTATION METHODS >>> # @staticmethod - def _get_rotation_angles(rotation): - """ Set the rotation angles. Includes backwards compatibility for the - 'on' and 'off' options: - - 'on' - increment 90 degrees - - 'off' - disable - - 0 is prepended to the list, as whatever happens, we want to - scan the image in it's upright state """ + def _get_rotation_angles(rotation: Optional[str]) -> List[int]: + """ Set the rotation angles. + + Parameters + ---------- + str + List of requested rotation angles + + Returns + ------- + list + The complete list of rotation angles to apply + """ rotation_angles = [0] - if not rotation or rotation.lower() == "off": + if not rotation: logger.debug("Not setting rotation angles") return rotation_angles - if rotation.lower() == "on": - rotation_angles.extend(range(90, 360, 90)) - else: - passed_angles = [int(angle) - for angle in rotation.split(",") - if int(angle) != 0] - if len(passed_angles) == 1: - rotation_step_size = passed_angles[0] - rotation_angles.extend(range(rotation_step_size, - 360, - rotation_step_size)) - elif len(passed_angles) > 1: - rotation_angles.extend(passed_angles) + passed_angles = [int(angle) + for angle in rotation.split(",") + if int(angle) != 0] + if len(passed_angles) == 1: + rotation_step_size = passed_angles[0] + rotation_angles.extend(range(rotation_step_size, + 360, + rotation_step_size)) + elif len(passed_angles) > 1: + rotation_angles.extend(passed_angles) logger.debug("Rotation Angles: %s", rotation_angles) return rotation_angles - def _rotate_batch(self, batch, angle): + def _rotate_batch(self, batch: DetectorBatch, angle: int) -> None: """ Rotate images in a batch by given angle + if any faces have already been detected for a batch, store the existing rotation - matrix and replace the feed image with a placeholder """ + matrix and replace the feed image with a placeholder + + Parameters + ---------- + batch: :class:`DetectorBatch` + The batch to apply rotation to + angle: int + The amount of degrees to rotate the image by + """ if angle == 0: # Set the initial batch so we always rotate from zero - batch["initial_feed"] = batch["feed"].copy() + batch.initial_feed = batch.feed.copy() return - retval = {} - for img, faces, rotmat in zip(batch["initial_feed"], batch["prediction"], batch["rotmat"]): + feeds: List[np.ndarray] = [] + rotmats: List[np.ndarray] = [] + for img, faces, rotmat in zip(batch.initial_feed, + batch.prediction, + batch.rotation_matrix): if faces.any(): image = np.zeros_like(img) matrix = rotmat else: image, matrix = self._rotate_image_by_angle(img, angle) - retval.setdefault("feed", []).append(image) - retval.setdefault("rotmat", []).append(matrix) - batch["feed"] = np.array(retval["feed"], dtype="float32") - batch["rotmat"] = retval["rotmat"] + feeds.append(image) + rotmats.append(matrix) + batch.feed = np.array(feeds, dtype="float32") + batch.rotation_matrix = rotmats @staticmethod - def _rotate_face(face, rotation_matrix): + def _rotate_face(face: DetectedFace, rotation_matrix: np.ndarray) -> DetectedFace: """ Rotates the detection bounding box around the given rotation matrix. Parameters @@ -411,7 +573,8 @@ def _rotate_face(face, rotation_matrix): :class:`DetectedFace` The same class with the detection bounding box points rotated by the given matrix. """ - logger.trace("Rotating face: (face: %s, rotation_matrix: %s)", face, rotation_matrix) + logger.trace("Rotating face: (face: %s, rotation_matrix: %s)", # type: ignore + face, rotation_matrix) bounding_box = [[face.left, face.top], [face.right, face.top], [face.right, face.bottom], @@ -424,10 +587,10 @@ def _rotate_face(face, rotation_matrix): rotated = transformed.squeeze() # Bounding box should follow x, y planes, so get min/max for non-90 degree rotations - pt_x = min([pnt[0] for pnt in rotated]) - pt_y = min([pnt[1] for pnt in rotated]) - pt_x1 = max([pnt[0] for pnt in rotated]) - pt_y1 = max([pnt[1] for pnt in rotated]) + pt_x = min(pnt[0] for pnt in rotated) + pt_y = min(pnt[1] for pnt in rotated) + pt_x1 = max(pnt[0] for pnt in rotated) + pt_y1 = max(pnt[1] for pnt in rotated) width = pt_x1 - pt_x height = pt_y1 - pt_y @@ -437,11 +600,32 @@ def _rotate_face(face, rotation_matrix): face.height = int(height) return face - def _rotate_image_by_angle(self, image, angle): + def _rotate_image_by_angle(self, + image: np.ndarray, + angle: int) -> Tuple[np.ndarray, np.ndarray]: """ Rotate an image by a given angle. - From: https://stackoverflow.com/questions/22041699 """ - logger.trace("Rotating image: (image: %s, angle: %s)", image.shape, angle) + Parameters + ---------- + image: :class:`numpy.ndarray` + The image to be rotated + angle: int + The angle, in degrees, to rotate the image by + + Returns + ------- + image: :class:`numpy.ndarray` + The rotated image + rotation_matrix: :class:`numpy.ndarray` + The rotation matrix used to rotate the image + + Reference + --------- + https://stackoverflow.com/questions/22041699 + """ + + logger.trace("Rotating image: (image: %s, angle: %s)", # type:ignore + image.shape, angle) channels_first = image.shape[0] <= 4 if channels_first: image = np.moveaxis(image, 0, 2) @@ -451,7 +635,7 @@ def _rotate_image_by_angle(self, image, angle): rotation_matrix = cv2.getRotationMatrix2D(image_center, -1.*angle, 1.) rotation_matrix[0, 2] += self.input_size / 2 - image_center[0] rotation_matrix[1, 2] += self.input_size / 2 - image_center[1] - logger.trace("Rotated image: (rotation_matrix: %s", rotation_matrix) + logger.trace("Rotated image: (rotation_matrix: %s", rotation_matrix) # type:ignore image = cv2.warpAffine(image, rotation_matrix, (self.input_size, self.input_size)) if channels_first: image = np.moveaxis(image, 2, 0) diff --git a/plugins/extract/detect/cv2_dnn.py b/plugins/extract/detect/cv2_dnn.py index 08ce23aa89..fd3e39142c 100644 --- a/plugins/extract/detect/cv2_dnn.py +++ b/plugins/extract/detect/cv2_dnn.py @@ -1,14 +1,18 @@ #!/usr/bin/env python3 """ OpenCV DNN Face detection plugin """ +import logging import numpy as np -from ._base import cv2, Detector, logger +from ._base import BatchType, cv2, Detector, DetectorBatch + + +logger = logging.getLogger(__name__) class Detect(Detector): """ CV2 DNN detector for face recognition """ - def __init__(self, **kwargs): + def __init__(self, **kwargs) -> None: git_model_id = 4 model_filename = ["resnet_ssd_v1.caffemodel", "resnet_ssd_v1.prototxt"] super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) @@ -19,44 +23,45 @@ def __init__(self, **kwargs): self.batchsize = 1 self.confidence = self.config["confidence"] / 100 - def init_model(self): + def init_model(self) -> None: """ Initialize CV2 DNN Detector Model""" + assert isinstance(self.model_path, list) self.model = cv2.dnn.readNetFromCaffe(self.model_path[1], # pylint: disable=no-member self.model_path[0]) self.model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) # pylint: disable=no-member - def process_input(self, batch): + def process_input(self, batch: BatchType) -> None: """ Compile the detection image(s) for prediction """ - batch["feed"] = cv2.dnn.blobFromImages(batch["image"], # pylint: disable=no-member - scalefactor=1.0, - size=(self.input_size, self.input_size), - mean=[104, 117, 123], - swapRB=False, - crop=False) - return batch - - def predict(self, batch): + assert isinstance(batch, DetectorBatch) + batch.feed = cv2.dnn.blobFromImages(batch.image, # pylint: disable=no-member + scalefactor=1.0, + size=(self.input_size, self.input_size), + mean=[104, 117, 123], + swapRB=False, + crop=False) + + def predict(self, feed: np.ndarray) -> np.ndarray: """ Run model to get predictions """ - self.model.setInput(batch["feed"]) + assert isinstance(self.model, cv2.dnn.Net) + self.model.setInput(feed) predictions = self.model.forward() - batch["prediction"] = self.finalize_predictions(predictions) - return batch + return self.finalize_predictions(predictions) - def finalize_predictions(self, predictions): + def finalize_predictions(self, predictions: np.ndarray) -> np.ndarray: """ Filter faces based on confidence level """ - faces = list() + faces = [] for i in range(predictions.shape[2]): confidence = predictions[0, 0, i, 2] if confidence >= self.confidence: - logger.trace("Accepting due to confidence %s >= %s", + logger.trace("Accepting due to confidence %s >= %s", # type:ignore confidence, self.confidence) faces.append([(predictions[0, 0, i, 3] * self.input_size), (predictions[0, 0, i, 4] * self.input_size), (predictions[0, 0, i, 5] * self.input_size), (predictions[0, 0, i, 6] * self.input_size)]) - logger.trace("faces: %s", faces) - return [np.array(faces)] + logger.trace("faces: %s", faces) # type:ignore + return np.array(faces)[None, ...] - def process_output(self, batch): + def process_output(self, batch: BatchType) -> None: """ Compile found faces for output """ - return batch + return diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py index 28d41085e4..eb1d0e5599 100644 --- a/plugins/extract/detect/mtcnn.py +++ b/plugins/extract/detect/mtcnn.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ MTCNN Face detection plugin """ - from __future__ import absolute_import, division, print_function +import logging from typing import Dict, List, Optional, Tuple, Union import cv2 @@ -9,13 +9,17 @@ from lib.model.session import KSession from lib.utils import get_backend -from ._base import Detector, logger +from ._base import BatchType, Detector if get_backend() == "amd": from keras.layers import Conv2D, Dense, Flatten, Input, MaxPool2D, Permute, PReLU + from plaidml.tile import Value as Tensor # pylint:disable=import-error else: # Ignore linting errors from Tensorflow's thoroughly broken import system from tensorflow.keras.layers import Conv2D, Dense, Flatten, Input, MaxPool2D, Permute, PReLU # noqa pylint:disable=no-name-in-module,import-error + from tensorflow import Tensor + +logger = logging.getLogger(__name__) class Detect(Detector): @@ -60,35 +64,29 @@ def _validate_kwargs(self) -> Dict[str, Union[int, float, List[float]]]: def init_model(self) -> None: """ Initialize MTCNN Model. """ + assert isinstance(self.model_path, list) self.model = MTCNN(self.model_path, self.config["allow_growth"], self._exclude_gpus, self.config["cpu"], - **self.kwargs) + **self.kwargs) # type:ignore - def process_input(self, batch: dict) -> dict: + def process_input(self, batch: BatchType) -> None: """ Compile the detection image(s) for prediction Parameters ---------- - batch: dict + batch: :class:`~plugins.extract.detect._base.DetectorBatch` Contains the batch that is currently being passed through the plugin process - - Returns - ------- - dict - The batch with input processed - """ - batch["feed"] = (batch["image"] - 127.5) / 127.5 - return batch + batch.feed = (np.array(batch.image, dtype="float32") - 127.5) / 127.5 - def predict(self, batch: dict) -> dict: + def predict(self, feed: np.ndarray) -> np.ndarray: """ Run model to get predictions Parameters ---------- - batch: dict + batch: :class:`~plugins.extract.detect._base.DetectorBatch` Contains the batch to pass through the MTCNN model Returns @@ -96,26 +94,21 @@ def predict(self, batch: dict) -> dict: dict The batch with the predictions added to the dictionary """ - prediction, points = self.model.detect_faces(batch["feed"]) - logger.trace("filename: %s, prediction: %s, mtcnn_points: %s", # type:ignore - batch["filename"], prediction, points) - batch["prediction"], batch["mtcnn_points"] = prediction, points - return batch + assert isinstance(self.model, MTCNN) + prediction, points = self.model.detect_faces(feed) + logger.trace("prediction: %s, mtcnn_points: %s", # type:ignore + prediction, points) + return prediction - def process_output(self, batch: dict) -> dict: + def process_output(self, batch: BatchType) -> None: """ MTCNN performs no post processing so the original batch is returned Parameters ---------- - batch: dict + batch: :class:`~plugins.extract.detect._base.DetectorBatch` Contains the batch to apply postprocessing to - - Returns - ------- - dict - The originally received batch """ - return batch + return # MTCNN Detector @@ -174,7 +167,7 @@ class PNet(KSession): def __init__(self, model_path: str, allow_growth: bool, - exclude_gpus: List[int], + exclude_gpus: Optional[List[int]], cpu_mode: bool, input_size: int, min_size: int, @@ -198,7 +191,7 @@ def __init__(self, self._pnet_input: Optional[List[np.ndarray]] = None @staticmethod - def model_definition(): + def model_definition() -> Tuple[List[Tensor], List[Tensor]]: """ Keras P-Network Definition for MTCNN """ input_ = Input(shape=(None, None, 3)) var_x = Conv2D(10, (3, 3), strides=1, padding='valid', name='conv1')(input_) @@ -354,7 +347,7 @@ class RNet(KSession): def __init__(self, model_path: str, allow_growth: bool, - exclude_gpus: List[int], + exclude_gpus: Optional[List[int]], cpu_mode: bool, input_size: int, threshold: float) -> None: @@ -370,7 +363,7 @@ def __init__(self, self._threshold = threshold @staticmethod - def model_definition(): + def model_definition() -> Tuple[List[Tensor], List[Tensor]]: """ Keras R-Network Definition for MTCNN """ input_ = Input(shape=(24, 24, 3)) var_x = Conv2D(28, (3, 3), strides=1, padding='valid', name='conv1')(input_) @@ -485,7 +478,7 @@ class ONet(KSession): def __init__(self, model_path: str, allow_growth: bool, - exclude_gpus: List[int], + exclude_gpus: Optional[List[int]], cpu_mode: bool, input_size: int, threshold: float) -> None: @@ -501,7 +494,7 @@ def __init__(self, self._threshold = threshold @staticmethod - def model_definition(): + def model_definition() -> Tuple[List[Tensor], List[Tensor]]: """ Keras O-Network for MTCNN """ input_ = Input(shape=(48, 48, 3)) var_x = Conv2D(32, (3, 3), strides=1, padding='valid', name='conv1')(input_) @@ -638,7 +631,7 @@ class MTCNN(): # pylint: disable=too-few-public-methods def __init__(self, model_path: List[str], allow_growth: bool, - exclude_gpus: List[int], + exclude_gpus: Optional[List[int]], cpu_mode: bool, input_size: int = 640, minsize: int = 20, @@ -673,7 +666,7 @@ def __init__(self, logger.debug("Initialized: %s", self.__class__.__name__) - def detect_faces(self, batch: np.ndarray) -> Tuple[List[np.ndarray], List[np.ndarray]]: + def detect_faces(self, batch: np.ndarray) -> Tuple[np.ndarray, Tuple[np.ndarray]]: """Detects faces in an image, and returns bounding boxes and points for them. Parameters @@ -691,7 +684,7 @@ def detect_faces(self, batch: np.ndarray) -> Tuple[List[np.ndarray], List[np.nda rectangles = self._rnet(batch, rectangles) ret_boxes, ret_points = zip(*self._onet(batch, rectangles)) - return ret_boxes, ret_points + return np.array(ret_boxes, dtype="object"), ret_points def nms(rectangles: np.ndarray, diff --git a/plugins/extract/detect/s3fd.py b/plugins/extract/detect/s3fd.py index 59205d6c91..2d1241c48a 100644 --- a/plugins/extract/detect/s3fd.py +++ b/plugins/extract/detect/s3fd.py @@ -5,29 +5,35 @@ Adapted from S3FD Port in FAN: https://github.com/1adrianb/face-alignment """ +import logging +from typing import List, Optional, Tuple from scipy.special import logsumexp import numpy as np from lib.model.session import KSession from lib.utils import get_backend -from ._base import Detector, logger +from ._base import BatchType, Detector if get_backend() == "amd": import keras from keras import backend as K from keras.layers import Concatenate, Conv2D, Input, Maximum, MaxPooling2D, ZeroPadding2D + from plaidml.tile import Value as Tensor # pylint:disable=import-error else: # Ignore linting errors from Tensorflow's thoroughly broken import system from tensorflow import keras from tensorflow.keras import backend as K # pylint:disable=import-error from tensorflow.keras.layers import ( # pylint:disable=no-name-in-module,import-error Concatenate, Conv2D, Input, Maximum, MaxPooling2D, ZeroPadding2D) + from tensorflow import Tensor + +logger = logging.getLogger(__name__) class Detect(Detector): """ S3FD detector for face recognition """ - def __init__(self, **kwargs): + def __init__(self, **kwargs) -> None: git_model_id = 11 model_filename = "s3fd_keras_v2.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) @@ -38,8 +44,9 @@ def __init__(self, **kwargs): self.vram_per_batch = 208 self.batchsize = self.config["batch-size"] - def init_model(self): + def init_model(self) -> None: """ Initialize S3FD Model""" + assert isinstance(self.model_path, str) confidence = self.config["confidence"] / 100 model_kwargs = dict(custom_objects=dict(L2Norm=L2Norm, SliceO2K=SliceO2K)) self.model = S3fd(self.model_path, @@ -48,21 +55,21 @@ def init_model(self): self._exclude_gpus, confidence) - def process_input(self, batch): + def process_input(self, batch: BatchType) -> None: """ Compile the detection image(s) for prediction """ - batch["feed"] = self.model.prepare_batch(batch["image"]) - return batch + assert isinstance(self.model, S3fd) + batch.feed = self.model.prepare_batch(np.array(batch.image)) - def predict(self, batch): + def predict(self, feed: np.ndarray) -> np.ndarray: """ Run model to get predictions """ - predictions = self.model.predict(batch["feed"]) - batch["prediction"] = self.model.finalize_predictions(predictions) - logger.trace("filename: %s, prediction: %s", batch["filename"], batch["prediction"]) - return batch + assert isinstance(self.model, S3fd) + predictions = self.model.predict(feed) + assert isinstance(predictions, list) + return self.model.finalize_predictions(predictions) - def process_output(self, batch): + def process_output(self, batch) -> None: """ Compile found faces for output """ - return batch + return ################################################################################ @@ -78,7 +85,7 @@ class L2Norm(keras.layers.Layer): scale: float, optional The scaling for initial weights. Default: `1.0` """ - def __init__(self, n_channels, scale=1.0, **kwargs): + def __init__(self, n_channels: int, scale: float = 1.0, **kwargs) -> None: super().__init__(**kwargs) self._n_channels = n_channels self._scale = scale @@ -88,7 +95,7 @@ def __init__(self, n_channels, scale=1.0, **kwargs): initializer=keras.initializers.Constant(value=self._scale), dtype="float32") - def call(self, inputs): + def call(self, inputs: Tensor) -> Tensor: # pylint:disable=arguments-differ """ Call the L2 Normalization Layer. Parameters @@ -105,7 +112,7 @@ def call(self, inputs): var_x = inputs / norm * self.w return var_x - def get_config(self): + def get_config(self) -> dict: """ Returns the config of the layer. Returns @@ -121,14 +128,19 @@ def get_config(self): class SliceO2K(keras.layers.Layer): """ Custom Keras Slice layer generated by onnx2keras. """ - def __init__(self, starts, ends, axes=None, steps=None, **kwargs): + def __init__(self, + starts: List[int], + ends: List[int], + axes: Optional[List[int]] = None, + steps: Optional[List[int]] = None, + **kwargs) -> None: self._starts = starts self._ends = ends self._axes = axes self._steps = steps super().__init__(**kwargs) - def _get_slices(self, dimensions): + def _get_slices(self, dimensions: int) -> List[Tuple[int, ...]]: """ Obtain slices for the given number of dimensions. Parameters @@ -141,16 +153,12 @@ def _get_slices(self, dimensions): list The slices for the given number of dimensions """ - axes = self._axes - steps = self._steps - if axes is None: - axes = tuple(range(dimensions)) - if steps is None: - steps = (1,) * len(axes) + axes = tuple(range(dimensions)) if self._axes is None else self._axes + steps = (1,) * len(axes) if self._steps is None else self._steps assert len(axes) == len(steps) == len(self._starts) == len(self._ends) return list(zip(axes, self._starts, self._ends, steps)) - def compute_output_shape(self, input_shape): + def compute_output_shape(self, input_shape: Tuple[int, ...]) -> Tuple[int, ...]: """Computes the output shape of the layer. Assumes that the layer will be built to match that input shape provided. @@ -166,25 +174,25 @@ def compute_output_shape(self, input_shape): tuple An output shape tuple. """ - input_shape = list(input_shape) - for a_x, start, end, steps in self._get_slices(len(input_shape)): - size = input_shape[a_x] + in_shape = list(input_shape) + for a_x, start, end, steps in self._get_slices(len(in_shape)): + size = in_shape[a_x] if a_x == 0: raise AttributeError("Can not slice batch axis.") if size is None: if start < 0 or end < 0: raise AttributeError("Negative slices not supported on symbolic axes") logger.warning("Slicing symbolic axis might lead to problems.") - input_shape[a_x] = (end - start) // steps + in_shape[a_x] = (end - start) // steps continue if start < 0: start = size - start if end < 0: end = size - end - input_shape[a_x] = (min(size, end) - start) // steps - return tuple(input_shape) + in_shape[a_x] = (min(size, end) - start) // steps + return tuple(in_shape) - def call(self, inputs, **kwargs): # pylint:disable=unused-argument + def call(self, inputs, **kwargs): # pylint:disable=unused-argument,arguments-differ """This is where the layer's logic lives. Parameters @@ -204,7 +212,7 @@ def call(self, inputs, **kwargs): # pylint:disable=unused-argument retval = inputs[tuple(slices)] return retval - def get_config(self): + def get_config(self) -> dict: """ Returns the config of the layer. Returns @@ -222,7 +230,12 @@ def get_config(self): class S3fd(KSession): """ Keras Network """ - def __init__(self, model_path, model_kwargs, allow_growth, exclude_gpus, confidence): + def __init__(self, + model_path: str, + model_kwargs: dict, + allow_growth: bool, + exclude_gpus: Optional[List[int]], + confidence: float) -> None: logger.debug("Initializing: %s: (model_path: '%s', model_kwargs: %s, allow_growth: %s, " "exclude_gpus: %s, confidence: %s)", self.__class__.__name__, model_path, model_kwargs, allow_growth, exclude_gpus, confidence) @@ -237,7 +250,7 @@ def __init__(self, model_path, model_kwargs, allow_growth, exclude_gpus, confide self.average_img = np.array([104.0, 117.0, 123.0]) logger.debug("Initialized: %s", self.__class__.__name__) - def model_definition(self): + def model_definition(self) -> Tuple[List[Tensor], List[Tensor]]: """ Keras S3FD Model Definition, adapted from FAN pytorch implementation. """ input_ = Input(shape=(640, 640, 3)) var_x = self.conv_block(input_, 64, 1, 2) @@ -306,7 +319,7 @@ def model_definition(self): return [input_], [cls1, reg1, cls2, reg2, cls3, reg3, cls4, reg4, cls5, reg5, cls6, reg6] @classmethod - def conv_block(cls, inputs, filters, idx, recursions): + def conv_block(cls, inputs: Tensor, filters: int, idx: int, recursions: int) -> Tensor: """ First round convolutions with zero padding added. Parameters @@ -338,7 +351,7 @@ def conv_block(cls, inputs, filters, idx, recursions): return var_x @classmethod - def conv_up(cls, inputs, filters, idx): + def conv_up(cls, inputs: Tensor, filters: int, idx: int) -> Tensor: """ Convolution up filter blocks with zero padding added. Parameters @@ -369,7 +382,7 @@ def conv_up(cls, inputs, filters, idx): name=rec_name)(var_x) return var_x - def prepare_batch(self, batch): + def prepare_batch(self, batch: np.ndarray) -> np.ndarray: """ Prepare a batch for prediction. Normalizes the feed images. @@ -387,7 +400,7 @@ def prepare_batch(self, batch): batch = batch - self.average_img return batch - def finalize_predictions(self, bounding_boxes_scales): + def finalize_predictions(self, bounding_boxes_scales: List[np.ndarray]) -> np.ndarray: """ Process the output from the model to obtain faces Parameters @@ -400,11 +413,11 @@ def finalize_predictions(self, bounding_boxes_scales): for img in batch_size: bboxlist = [scale[img:img+1] for scale in bounding_boxes_scales] boxes = self._post_process(bboxlist) - bboxlist = self._nms(boxes, 0.5) - ret.append(bboxlist) - return ret + finallist = self._nms(boxes, 0.5) + ret.append(finallist) + return np.array(ret, dtype="object") - def _post_process(self, bboxlist): + def _post_process(self, bboxlist: List[np.ndarray]) -> np.ndarray: """ Perform post processing on output TODO: do this on the batch. """ @@ -428,12 +441,12 @@ def _post_process(self, bboxlist): return return_numpy @staticmethod - def softmax(inp, axis): + def softmax(inp, axis: int) -> np.ndarray: """Compute softmax values for each sets of scores in x.""" return np.exp(inp - logsumexp(inp, axis=axis, keepdims=True)) @staticmethod - def decode(location, priors): + def decode(location: np.ndarray, priors: np.ndarray) -> np.ndarray: """Decode locations from predictions using priors to undo the encoding we did for offset regression at train time. @@ -457,28 +470,28 @@ def decode(location, priors): return boxes @staticmethod - def _nms(boxes, threshold): + def _nms(boxes: np.ndarray, threshold: float) -> np.ndarray: """ Perform Non-Maximum Suppression """ retained_box_indices = [] areas = (boxes[:, 2] - boxes[:, 0] + 1) * (boxes[:, 3] - boxes[:, 1] + 1) ranked_indices = boxes[:, 4].argsort()[::-1] while ranked_indices.size > 0: - best = ranked_indices[0] - rest = ranked_indices[1:] + best_rest = ranked_indices[0], ranked_indices[1:] - max_of_xy = np.maximum(boxes[best, :2], boxes[rest, :2]) - min_of_xy = np.minimum(boxes[best, 2:4], boxes[rest, 2:4]) + max_of_xy = np.maximum(boxes[best_rest[0], :2], boxes[best_rest[1], :2]) + min_of_xy = np.minimum(boxes[best_rest[0], 2:4], boxes[best_rest[1], 2:4]) width_height = np.maximum(0, min_of_xy - max_of_xy + 1) intersection_areas = width_height[:, 0] * width_height[:, 1] - iou = intersection_areas / (areas[best] + areas[rest] - intersection_areas) + iou = intersection_areas / (areas[best_rest[0]] + + areas[best_rest[1]] - intersection_areas) overlapping_boxes = (iou > threshold).nonzero()[0] if len(overlapping_boxes) != 0: overlap_set = ranked_indices[overlapping_boxes + 1] vote = np.average(boxes[overlap_set, :4], axis=0, weights=boxes[overlap_set, 4]) - boxes[best, :4] = vote - retained_box_indices.append(best) + boxes[best_rest[0], :4] = vote + retained_box_indices.append(best_rest[0]) non_overlapping_boxes = (iou <= threshold).nonzero()[0] ranked_indices = ranked_indices[non_overlapping_boxes + 1] diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 6b982bb882..f2997e1a1b 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -12,15 +12,41 @@ >>> {"filename": , >>> "detected_faces": } """ +import logging +from dataclasses import dataclass, field +from typing import Generator, List, Optional, Tuple, TYPE_CHECKING import cv2 import numpy as np -from tensorflow.python.framework import errors_impl as tf_errors +from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa from lib.align import AlignedFace, transform_image from lib.utils import get_backend, FaceswapError -from plugins.extract._base import Extractor, ExtractMedia, logger +from plugins.extract._base import BatchType, Extractor, ExtractorBatch, ExtractMedia + +if TYPE_CHECKING: + from queue import Queue + from lib.align import DetectedFace + from lib.align.aligned_face import CenteringType + +logger = logging.getLogger(__name__) + + +@dataclass +class MaskerBatch(ExtractorBatch): + """ Dataclass for holding items flowing through the aligner. + + Inherits from :class:`~plugins.extract._base.ExtractorBatch` + + Parameters + ---------- + roi_masks: list + The region of interest masks for the batch + """ + detected_faces: List["DetectedFace"] = field(default_factory=list) + roi_masks: List[np.ndarray] = field(default_factory=list) + feed_faces: List[AlignedFace] = field(default_factory=list) class Masker(Extractor): # pylint:disable=abstract-method @@ -53,9 +79,15 @@ class Masker(Extractor): # pylint:disable=abstract-method plugins.extract.align._base : Aligner parent class for extraction plugins. """ - def __init__(self, git_model_id=None, model_filename=None, configfile=None, - instance=0, image_is_aligned=False, **kwargs): - logger.debug("Initializing %s: (configfile: %s, )", self.__class__.__name__, configfile) + def __init__(self, + git_model_id: Optional[int] = None, + model_filename: Optional[str] = None, + configfile: Optional[str] = None, + instance: int = 0, + image_is_aligned=False, + **kwargs) -> None: + logger.debug("Initializing %s: (configfile: %s, image_is_aligned: %s)", + self.__class__.__name__, configfile, image_is_aligned) super().__init__(git_model_id, model_filename, configfile=configfile, @@ -66,15 +98,12 @@ def __init__(self, git_model_id=None, model_filename=None, configfile=None, self._plugin_type = "mask" self._image_is_aligned = image_is_aligned - self._storage_name = self.__module__.split(".")[-1].replace("_", "-") - self._storage_centering = "face" # Centering to store the mask at + self._storage_name = self.__module__.rsplit(".", maxsplit=1)[-1].replace("_", "-") + self._storage_centering: "CenteringType" = "face" # Centering to store the mask at self._storage_size = 128 # Size to store masks at. Leave this at default - self._faces_per_filename = dict() # Tracking for recompiling face batches - self._rollover = None # Items that are rolled over from the previous batch in get_batch - self._output_faces = [] logger.debug("Initialized %s", self.__class__.__name__) - def get_batch(self, queue): + def get_batch(self, queue: "Queue") -> Tuple[bool, MaskerBatch]: """ Get items for inputting into the masker from the queue in batches Items are returned from the ``queue`` in batches of @@ -104,16 +133,16 @@ def get_batch(self, queue): ------- exhausted, bool ``True`` if queue is exhausted, ``False`` if not - batch, dict - A dictionary of lists of :attr:`~plugins.extract._base.Extractor.batchsize`: + batch, :class:`~plugins.extract._base.ExtractorBatch` + The batch object for the current batch """ exhausted = False - batch = dict() + batch = MaskerBatch() idx = 0 while idx < self.batchsize: - item = self._collect_item(queue) + item = self.rollover_collector(queue) if item == "EOF": - logger.trace("EOF received") + logger.trace("EOF received") # type: ignore exhausted = True break # Put frames with no faces into the out queue to keep TQDM consistent @@ -137,6 +166,7 @@ def get_batch(self, queue): dtype="float32", is_aligned=self._image_is_aligned) + assert feed_face.face is not None if not self._image_is_aligned: # Split roi mask from feed face alpha channel roi_mask = feed_face.face[..., 3] @@ -148,10 +178,10 @@ def get_batch(self, queue): feed_face.size, padding=feed_face.padding) - batch.setdefault("roi_masks", []).append(roi_mask) - batch.setdefault("detected_faces", []).append(face) - batch.setdefault("feed_faces", []).append(feed_face) - batch.setdefault("filename", []).append(item.filename) + batch.roi_masks.append(roi_mask) + batch.detected_faces.append(face) + batch.feed_faces.append(feed_face) + batch.filename.append(item.filename) idx += 1 if idx == self.batchsize: frame_faces = len(item.detected_faces) @@ -160,38 +190,33 @@ def get_batch(self, queue): item.filename, item.image, detected_faces=item.detected_faces[f_idx + 1:]) - logger.trace("Rolled over %s faces of %s to next batch for '%s'", - len(self._rollover.detected_faces), frame_faces, + logger.trace("Rolled over %s faces of %s to next batch " # type:ignore + "for '%s'", len(self._rollover.detected_faces), frame_faces, item.filename) break if batch: - logger.trace("Returning batch: %s", {k: v.shape if isinstance(v, np.ndarray) else v - for k, v in batch.items()}) + logger.trace("Returning batch: %s", # type:ignore + {k: len(v) if isinstance(v, (list, np.ndarray)) else v + for k, v in batch.__dict__.items()}) else: - logger.trace(item) + logger.trace(item) # type:ignore return exhausted, batch - def _collect_item(self, queue): - """ Collect the item from the _rollover dict or from the queue - Add face count per frame to self._faces_per_filename for joining - batches back up in finalize """ - if self._rollover is not None: - logger.trace("Getting from _rollover: (filename: `%s`, faces: %s)", - self._rollover.filename, len(self._rollover.detected_faces)) - item = self._rollover - self._rollover = None - else: - item = self._get_item(queue) - if item != "EOF": - logger.trace("Getting from queue: (filename: %s, faces: %s)", - item.filename, len(item.detected_faces)) - self._faces_per_filename[item.filename] = len(item.detected_faces) - return item - - def _predict(self, batch): + def _predict(self, batch: BatchType) -> MaskerBatch: """ Just return the masker's predict function """ + assert isinstance(batch, MaskerBatch) + assert self.name is not None try: - return self.predict(batch) + # slightly hacky workaround to deal with landmarks based masks: + if self.name.lower() in ("components", "extended"): + feed = np.empty(2, dtype="object") + feed[0] = batch.feed + feed[1] = batch.feed_faces + else: + feed = batch.feed + + batch.prediction = self.predict(feed) + return batch except tf_errors.ResourceExhaustedError as err: msg = ("You do not have enough GPU memory available to run detection at the " "selected batch size. You can try a number of things:" @@ -220,7 +245,7 @@ def _predict(self, batch): raise FaceswapError(msg) from err raise - def finalize(self, batch): + def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: """ Finalize the output from Masker This should be called as the final task of each `plugin`. @@ -239,10 +264,11 @@ def finalize(self, batch): The :attr:`DetectedFaces` list will be populated for this class with the bounding boxes, landmarks and masks for the detected faces found in the frame. """ - for mask, face, feed_face, roi_mask in zip(batch["prediction"], - batch["detected_faces"], - batch["feed_faces"], - batch["roi_masks"]): + assert isinstance(batch, MaskerBatch) + for mask, face, feed_face, roi_mask in zip(batch.prediction, + batch.detected_faces, + batch.feed_faces, + batch.roi_masks): self._crop_out_of_bounds(mask, roi_mask) face.add_mask(self._storage_name, mask, @@ -250,11 +276,12 @@ def finalize(self, batch): feed_face.interpolators[1], storage_size=self._storage_size, storage_centering=self._storage_centering) - del batch["feed_faces"] + del batch.feed - logger.trace("Item out: %s", {key: val.shape if isinstance(val, np.ndarray) else val - for key, val in batch.items()}) - for filename, face in zip(batch["filename"], batch["detected_faces"]): + logger.trace("Item out: %s", # type: ignore + {key: val.shape if isinstance(val, np.ndarray) else val + for key, val in batch.__dict__.items()}) + for filename, face in zip(batch.filename, batch.detected_faces): self._output_faces.append(face) if len(self._output_faces) != self._faces_per_filename[filename]: continue @@ -262,13 +289,14 @@ def finalize(self, batch): output = self._extract_media.pop(filename) output.add_detected_faces(self._output_faces) self._output_faces = [] - logger.trace("Yielding: (filename: '%s', image: %s, detected_faces: %s)", - output.filename, output.image_shape, len(output.detected_faces)) + logger.trace("Yielding: (filename: '%s', image: %s, " # type:ignore + "detected_faces: %s)", output.filename, output.image_shape, + len(output.detected_faces)) yield output # <<< PROTECTED ACCESS METHODS >>> # @classmethod - def _resize(cls, image, target_size): + def _resize(cls, image: np.ndarray, target_size: int) -> np.ndarray: """ resize input and output of mask models appropriately """ height, width, channels = image.shape image_size = max(height, width) @@ -281,7 +309,7 @@ def _resize(cls, image, target_size): return resized @classmethod - def _crop_out_of_bounds(cls, mask, roi_mask): + def _crop_out_of_bounds(cls, mask: np.ndarray, roi_mask: np.ndarray) -> None: """ Un-mask any area of the predicted mask that falls outside of the original frame. Parameters diff --git a/plugins/extract/mask/bisenet_fp.py b/plugins/extract/mask/bisenet_fp.py index 22153255d5..6326ced393 100644 --- a/plugins/extract/mask/bisenet_fp.py +++ b/plugins/extract/mask/bisenet_fp.py @@ -4,35 +4,43 @@ Architecture and Pre-Trained Model ported from PyTorch to Keras by TorzDF from https://github.com/zllrunning/face-parsing.PyTorch """ +import logging +from typing import cast, List, Optional, Tuple + import numpy as np from lib.model.session import KSession from lib.utils import get_backend from plugins.extract._base import _get_config -from ._base import Masker, logger +from ._base import BatchType, Masker, MaskerBatch if get_backend() == "amd": from keras import backend as K from keras.layers import ( Activation, Add, BatchNormalization, Concatenate, Conv2D, GlobalAveragePooling2D, Input, MaxPooling2D, Multiply, Reshape, UpSampling2D, ZeroPadding2D) + from plaidml.tile import Value as Tensor # pylint:disable=import-error else: # Ignore linting errors from Tensorflow's thoroughly broken import system from tensorflow.keras import backend as K # pylint:disable=import-error from tensorflow.keras.layers import ( # pylint:disable=no-name-in-module,import-error Activation, Add, BatchNormalization, Concatenate, Conv2D, GlobalAveragePooling2D, Input, MaxPooling2D, Multiply, Reshape, UpSampling2D, ZeroPadding2D) + from tensorflow import Tensor + +logger = logging.getLogger(__name__) class Mask(Masker): """ Neural network to process face image into a segmentation mask of the face """ - def __init__(self, **kwargs): + def __init__(self, **kwargs) -> None: self._is_faceswap, version = self._check_weights_selection(kwargs.get("configfile")) git_model_id = 14 model_filename = f"bisnet_face_parsing_v{version}.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.model: KSession self.name = "BiSeNet - Face Parsing" self.input_size = 512 self.color_format = "RGB" @@ -46,7 +54,7 @@ def __init__(self, **kwargs): # Separate storage for face and head masks self._storage_name = f"{self._storage_name}_{self._storage_centering}" - def _check_weights_selection(self, configfile): + def _check_weights_selection(self, configfile: Optional[str]) -> Tuple[bool, int]: """ Check which weights have been selected. This is required for passing along the correct file name for the corresponding weights @@ -70,7 +78,7 @@ def _check_weights_selection(self, configfile): version = 1 if not is_faceswap else 2 if config.get("include_hair") else 3 return is_faceswap, version - def _get_segment_indices(self): + def _get_segment_indices(self) -> List[int]: """ Obtain the segment indices to include within the face mask area based on user configuration settings. @@ -100,8 +108,9 @@ def _get_segment_indices(self): logger.debug("Selected segment indices: %s", retval) return retval - def init_model(self): + def init_model(self) -> None: """ Initialize the BiSeNet Face Parsing model. """ + assert isinstance(self.model_path, str) lbls = 5 if self._is_faceswap else 19 self.model = BiSeNet(self.model_path, self.config["allow_growth"], @@ -114,27 +123,25 @@ def init_model(self): dtype="float32") self.model.predict(placeholder) - def process_input(self, batch): + def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction """ + assert isinstance(batch, MaskerBatch) mean = (0.384, 0.314, 0.279) if self._is_faceswap else (0.485, 0.456, 0.406) std = (0.324, 0.286, 0.275) if self._is_faceswap else (0.229, 0.224, 0.225) - batch["feed"] = ((np.array([feed.face[..., :3] - for feed in batch["feed_faces"]], - dtype="float32") / 255.0) - mean) / std - logger.trace("feed shape: %s", batch["feed"].shape) - return batch + batch.feed = ((np.array([cast(np.ndarray, feed.face)[..., :3] + for feed in batch.feed_faces], + dtype="float32") / 255.0) - mean) / std + logger.trace("feed shape: %s", batch.feed.shape) # type:ignore - def predict(self, batch): + def predict(self, feed: np.ndarray) -> np.ndarray: """ Run model to get predictions """ - batch["prediction"] = self.model.predict(batch["feed"])[0] - return batch + return self.model.predict(feed)[0] - def process_output(self, batch): + def process_output(self, batch: BatchType) -> None: """ Compile found faces for output """ - pred = batch["prediction"].argmax(-1).astype("uint8") - batch["prediction"] = np.isin(pred, self._segment_indices).astype("float32") - return batch + pred = batch.prediction.argmax(-1).astype("uint8") + batch.prediction = np.isin(pred, self._segment_indices).astype("float32") # BiSeNet Face-Parsing Model @@ -164,7 +171,7 @@ def process_output(self, batch): _NAME_TRACKER = set() -def _get_name(name, start_idx=1): +def _get_name(name: str, start_idx: int = 1) -> str: """ Auto numbering to keep track of layer names. Names are kept the same as the PyTorch original model, to enable easier porting of weights. @@ -218,8 +225,13 @@ class ConvBn(): # pylint:disable=too-few-public-methods The starting index for naming the layers within the block. See :func:`_get_name` for more information. Default: `1` """ - def __init__(self, filters, - kernel_size=3, strides=1, padding=1, activation=True, prefix="", start_idx=1): + def __init__(self, filters: int, + kernel_size: int = 3, + strides: int = 1, + padding: int = 1, + activation: int = True, + prefix: str = "", + start_idx: int = 1) -> None: self._filters = filters self._kernel_size = kernel_size self._strides = strides @@ -228,7 +240,7 @@ def __init__(self, filters, self._prefix = f"{prefix}." if prefix else prefix self._start_idx = start_idx - def __call__(self, inputs): + def __call__(self, inputs: Tensor) -> Tensor: """ Call the Convolutional Batch Normalization block. Parameters @@ -268,7 +280,7 @@ class ResNet18(): # pylint:disable=too-few-public-methods def __init__(self): self._feature_index = 1 if K.image_data_format() == "channels_first" else -1 - def _basic_block(self, inputs, prefix, filters, strides=1): + def _basic_block(self, inputs: Tensor, prefix: str, filters: int, strides: int = 1) -> Tensor: """ The basic building block for ResNet 18. Parameters @@ -306,7 +318,12 @@ def _basic_block(self, inputs, prefix, filters, strides=1): var_x = Activation("relu", name=f"{prefix}.relu")(var_x) return var_x - def _basic_layer(self, inputs, prefix, filters, num_blocks, strides=1): + def _basic_layer(self, + inputs: Tensor, + prefix: str, + filters: int, + num_blocks: int, + strides: int = 1) -> Tensor: """ The basic layer for ResNet 18. Recursively builds from :func:`_basic_block`. Parameters @@ -333,7 +350,7 @@ def _basic_layer(self, inputs, prefix, filters, num_blocks, strides=1): var_x = self._basic_block(var_x, f"{prefix}.{i + 1}", filters, strides=1) return var_x - def __call__(self, inputs): + def __call__(self, inputs: Tensor) -> Tensor: """ Call the ResNet 18 block. Parameters @@ -367,10 +384,10 @@ class AttentionRefinementModule(): # pylint:disable=too-few-public-methods The dimensionality of the output space (i.e. the number of output filters in the convolution). """ - def __init__(self, filters): + def __init__(self, filters: int) -> None: self._filters = filters - def __call__(self, inputs, feats): + def __call__(self, inputs: Tensor, feats: int) -> Tensor: """ Call the Attention Refinement block. Parameters @@ -401,7 +418,7 @@ class ContextPath(): # pylint:disable=too-few-public-methods def __init__(self): self._resnet = ResNet18() - def __call__(self, inputs): + def __call__(self, inputs: Tensor) -> Tensor: """ Call the Context Path block. Parameters @@ -444,10 +461,10 @@ class FeatureFusionModule(): # pylint:disable=too-few-public-methods The dimensionality of the output space (i.e. the number of output filters in the convolution). """ - def __init__(self, filters): + def __init__(self, filters: int) -> None: self._filters = filters - def __call__(self, inputs): + def __call__(self, inputs: Tensor) -> Tensor: """ Call the Feature Fusion block. Parameters @@ -492,12 +509,12 @@ class BiSeNetOutput(): # pylint:disable=too-few-public-methods label, str, optional The label for this output (for naming). Default: `""` (i.e. empty string, or no label) """ - def __init__(self, filters, num_classes, label=""): + def __init__(self, filters: int, num_classes: int, label: str = "") -> None: self._filters = filters self._num_classes = num_classes self._label = label - def __call__(self, inputs): + def __call__(self, inputs: Tensor) -> Tensor: """ Call the BiSeNet Output block. Parameters @@ -539,7 +556,13 @@ class BiSeNet(KSession): cpu_mode: bool, optional ``True`` run the model on CPU. Default: ``False`` """ - def __init__(self, model_path, allow_growth, exclude_gpus, input_size, num_classes, cpu_mode): + def __init__(self, + model_path: str, + allow_growth: bool, + exclude_gpus: Optional[List[int]], + input_size: int, + num_classes: int, + cpu_mode: bool) -> None: super().__init__("BiSeNet Face Parsing", model_path, allow_growth=allow_growth, @@ -551,7 +574,7 @@ def __init__(self, model_path, allow_growth, exclude_gpus, input_size, num_class self.define_model(self._model_definition) self.load_model_weights() - def _model_definition(self): + def _model_definition(self) -> Tuple[Tensor, List[Tensor]]: """ Definition of the VGG Obstructed Model. Returns @@ -562,12 +585,12 @@ def _model_definition(self): """ input_ = Input((self._input_size, self._input_size, 3)) - feat_res8, feat_cp8, feat_cp16 = self._cp(input_) - feat_fuse = FeatureFusionModule(256)([feat_res8, feat_cp8]) + features = self._cp(input_) # res8, cp8, cp16 + feat_fuse = FeatureFusionModule(256)([features[0], features[1]]) feat_out = BiSeNetOutput(256, self._num_classes)(feat_fuse) - feat_out16 = BiSeNetOutput(64, self._num_classes, label="16")(feat_cp8) - feat_out32 = BiSeNetOutput(64, self._num_classes, label="32")(feat_cp16) + feat_out16 = BiSeNetOutput(64, self._num_classes, label="16")(features[1]) + feat_out32 = BiSeNetOutput(64, self._num_classes, label="32")(features[2]) height, width = K.int_shape(input_)[1:3] f_h, f_w = K.int_shape(feat_out)[1:3] diff --git a/plugins/extract/mask/components.py b/plugins/extract/mask/components.py index 6f9d1474f0..0dc35e5e24 100644 --- a/plugins/extract/mask/components.py +++ b/plugins/extract/mask/components.py @@ -1,14 +1,22 @@ #!/usr/bin/env python3 """ Components Mask for faceswap.py """ +import logging +from typing import List, Tuple, TYPE_CHECKING import cv2 import numpy as np -from ._base import Masker, logger + +from ._base import BatchType, Masker + +if TYPE_CHECKING: + from lib.align.aligned_face import AlignedFace + +logger = logging.getLogger(__name__) class Mask(Masker): """ Perform transformation to align and get landmarks """ - def __init__(self, **kwargs): + def __init__(self, **kwargs) -> None: git_model_id = None model_filename = None super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) @@ -18,32 +26,32 @@ def __init__(self, **kwargs): self.vram_per_batch = 0 self.batchsize = 1 - def init_model(self): + def init_model(self) -> None: logger.debug("No mask model to initialize") - def process_input(self, batch): + def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction """ - batch["feed"] = np.zeros((self.batchsize, self.input_size, self.input_size, 1), - dtype="float32") - return batch + batch.feed = np.zeros((self.batchsize, self.input_size, self.input_size, 1), + dtype="float32") - def predict(self, batch): + def predict(self, feed: np.ndarray) -> np.ndarray: """ Run model to get predictions """ - for mask, face in zip(batch["feed"], batch["feed_faces"]): + faces: List["AlignedFace"] = feed[1] + feed = feed[0] + for mask, face in zip(feed, faces): parts = self.parse_parts(np.array(face.landmarks)) for item in parts: item = np.rint(np.concatenate(item)).astype("int32") hull = cv2.convexHull(item) cv2.fillConvexPoly(mask, hull, 1.0, lineType=cv2.LINE_AA) - batch["prediction"] = batch["feed"] - return batch + return feed - def process_output(self, batch): + def process_output(self, batch: BatchType) -> None: """ Compile found faces for output """ - return batch + return @staticmethod - def parse_parts(landmarks): + def parse_parts(landmarks: np.ndarray) -> List[Tuple[np.ndarray, ...]]: """ Component face hull mask """ r_jaw = (landmarks[0:9], landmarks[17:18]) l_jaw = (landmarks[8:17], landmarks[26:27]) diff --git a/plugins/extract/mask/custom.py b/plugins/extract/mask/custom.py index cf8a90eddc..b1e3328471 100644 --- a/plugins/extract/mask/custom.py +++ b/plugins/extract/mask/custom.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 """ Components Mask for faceswap.py """ - +import logging import numpy as np -from ._base import Masker, logger +from ._base import BatchType, Masker + +logger = logging.getLogger(__name__) class Mask(Masker): @@ -21,22 +23,20 @@ def __init__(self, **kwargs): # Separate storage for face and head masks self._storage_name = f"{self._storage_name}_{self._storage_centering}" - def init_model(self): + def init_model(self) -> None: logger.debug("No mask model to initialize") - def process_input(self, batch): + def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction """ - batch["feed"] = np.zeros((self.batchsize, self.input_size, self.input_size, 1), - dtype="float32") - return batch + batch.feed = np.zeros((self.batchsize, self.input_size, self.input_size, 1), + dtype="float32") - def predict(self, batch): + def predict(self, feed: np.ndarray) -> np.ndarray: """ Run model to get predictions """ if self.config["fill"]: - batch["feed"][:] = 1.0 - batch["prediction"] = batch["feed"] - return batch + feed[:] = 1.0 + return feed - def process_output(self, batch): + def process_output(self, batch: BatchType) -> None: """ Compile found faces for output """ - return batch + return diff --git a/plugins/extract/mask/extended.py b/plugins/extract/mask/extended.py index 11df6a5856..fa253ba15f 100644 --- a/plugins/extract/mask/extended.py +++ b/plugins/extract/mask/extended.py @@ -1,9 +1,16 @@ #!/usr/bin/env python3 """ Extended Mask for faceswap.py """ +import logging +from typing import List, Tuple, TYPE_CHECKING import cv2 import numpy as np -from ._base import Masker, logger +from ._base import BatchType, Masker + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from lib.align.aligned_face import AlignedFace class Mask(Masker): @@ -18,33 +25,39 @@ def __init__(self, **kwargs): self.vram_per_batch = 0 self.batchsize = 1 - def init_model(self): + def init_model(self) -> None: logger.debug("No mask model to initialize") - def process_input(self, batch): + def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction """ - batch["feed"] = np.zeros((self.batchsize, self.input_size, self.input_size, 1), - dtype="float32") - return batch + batch.feed = np.zeros((self.batchsize, self.input_size, self.input_size, 1), + dtype="float32") - def predict(self, batch): + def predict(self, feed: np.ndarray) -> np.ndarray: """ Run model to get predictions """ - for mask, face in zip(batch["feed"], batch["feed_faces"]): + faces: List["AlignedFace"] = feed[1] + feed = feed[0] + for mask, face in zip(feed, faces): parts = self.parse_parts(np.array(face.landmarks)) for item in parts: item = np.rint(np.concatenate(item)).astype("int32") hull = cv2.convexHull(item) cv2.fillConvexPoly(mask, hull, 1.0, lineType=cv2.LINE_AA) - batch["prediction"] = batch["feed"] - return batch + return feed - def process_output(self, batch): + def process_output(self, batch: BatchType) -> None: """ Compile found faces for output """ - return batch + return - @staticmethod - def parse_parts(landmarks): - """ Extended face hull mask """ + @classmethod + def _adjust_mask_top(cls, landmarks: np.ndarray) -> None: + """ Adjust the top of the mask to extend above eyebrows + + Parameters + ---------- + landmarks: :class:`numpy.ndarray` + The 68 point landmarks to be adjusted + """ # mid points between the side of face and eye point ml_pnt = (landmarks[36] + landmarks[0]) // 2 mr_pnt = (landmarks[16] + landmarks[45]) // 2 @@ -65,6 +78,10 @@ def parse_parts(landmarks): landmarks[17:22] = top_l + ((top_l - bot_l) // 2) landmarks[22:27] = top_r + ((top_r - bot_r) // 2) + def parse_parts(self, landmarks: np.ndarray) -> List[Tuple[np.ndarray, ...]]: + """ Extended face hull mask """ + self._adjust_mask_top(landmarks) + r_jaw = (landmarks[0:9], landmarks[17:18]) l_jaw = (landmarks[8:17], landmarks[26:27]) r_cheek = (landmarks[17:20], landmarks[8:9]) diff --git a/plugins/extract/mask/unet_dfl.py b/plugins/extract/mask/unet_dfl.py index 9dd45613ad..930b074cec 100644 --- a/plugins/extract/mask/unet_dfl.py +++ b/plugins/extract/mask/unet_dfl.py @@ -12,18 +12,23 @@ Model file sourced from... https://github.com/iperov/DeepFaceLab/blob/master/nnlib/FANSeg_256_full_face.h5 """ +import logging +from typing import cast import numpy as np from lib.model.session import KSession -from ._base import Masker, logger +from ._base import BatchType, Masker, MaskerBatch + +logger = logging.getLogger(__name__) class Mask(Masker): """ Neural network to process face image into a segmentation mask of the face """ - def __init__(self, **kwargs): + def __init__(self, **kwargs) -> None: git_model_id = 6 model_filename = "DFL_256_sigmoid_v1.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.model: KSession self.name = "U-Net" self.input_size = 256 self.vram = 3424 @@ -32,10 +37,11 @@ def __init__(self, **kwargs): self.batchsize = self.config["batch-size"] self._storage_centering = "legacy" - def init_model(self): + def init_model(self) -> None: + assert self.name is not None and isinstance(self.model_path, str) self.model = KSession(self.name, self.model_path, - model_kwargs=dict(), + model_kwargs={}, allow_growth=self.config["allow_growth"], exclude_gpus=self._exclude_gpus) self.model.load_model() @@ -43,18 +49,19 @@ def init_model(self): dtype="float32") self.model.predict(placeholder) - def process_input(self, batch): + def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction """ - batch["feed"] = np.array([feed.face[..., :3] - for feed in batch["feed_faces"]], dtype="float32") / 255.0 - logger.trace("feed shape: %s", batch["feed"].shape) - return batch + assert isinstance(batch, MaskerBatch) + batch.feed = np.array([cast(np.ndarray, feed.face)[..., :3] + for feed in batch.feed_faces], dtype="float32") / 255.0 + logger.trace("feed shape: %s", batch.feed.shape) # type: ignore - def predict(self, batch): + def predict(self, feed: np.ndarray) -> np.ndarray: """ Run model to get predictions """ - batch["prediction"] = self.model.predict(batch["feed"]) - return batch + retval = self.model.predict(feed) + assert isinstance(retval, np.ndarray) + return retval - def process_output(self, batch): + def process_output(self, batch: BatchType) -> None: """ Compile found faces for output """ - return batch + return diff --git a/plugins/extract/mask/vgg_clear.py b/plugins/extract/mask/vgg_clear.py index 2b37f9b118..7bde0c84d4 100644 --- a/plugins/extract/mask/vgg_clear.py +++ b/plugins/extract/mask/vgg_clear.py @@ -1,29 +1,36 @@ #!/usr/bin/env python3 """ VGG Clear face mask plugin. """ +import logging +from typing import cast, List, Optional, Tuple import numpy as np from lib.model.session import KSession from lib.utils import get_backend -from ._base import Masker, logger +from ._base import BatchType, Masker, MaskerBatch if get_backend() == "amd": from keras.layers import ( Add, Conv2D, Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, MaxPooling2D, ZeroPadding2D) + from plaidml.tile import Value as Tensor # pylint:disable=import-error else: # Ignore linting errors from Tensorflow's thoroughly broken import system from tensorflow.keras.layers import ( # pylint:disable=no-name-in-module,import-error Add, Conv2D, Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, MaxPooling2D, ZeroPadding2D) + from tensorflow import Tensor + +logger = logging.getLogger(__name__) class Mask(Masker): """ Neural network to process face image into a segmentation mask of the face """ - def __init__(self, **kwargs): + def __init__(self, **kwargs) -> None: git_model_id = 8 model_filename = "Nirkin_300_softmax_v1.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.model: KSession self.name = "VGG Clear" self.input_size = 300 self.vram = 2944 @@ -31,7 +38,8 @@ def __init__(self, **kwargs): self.vram_per_batch = 400 self.batchsize = self.config["batch-size"] - def init_model(self): + def init_model(self) -> None: + assert isinstance(self.model_path, str) self.model = VGGClear(self.model_path, allow_growth=self.config["allow_growth"], exclude_gpus=self._exclude_gpus) @@ -40,23 +48,23 @@ def init_model(self): dtype="float32") self.model.predict(placeholder) - def process_input(self, batch): + def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction """ - input_ = np.array([feed.face[..., :3] - for feed in batch["feed_faces"]], dtype="float32") - batch["feed"] = input_ - np.mean(input_, axis=(1, 2))[:, None, None, :] - logger.trace("feed shape: %s", batch["feed"].shape) - return batch + assert isinstance(batch, MaskerBatch) + input_ = np.array([cast(np.ndarray, feed.face)[..., :3] + for feed in batch.feed_faces], dtype="float32") + batch.feed = input_ - np.mean(input_, axis=(1, 2))[:, None, None, :] + logger.trace("feed shape: %s", batch.feed.shape) # type: ignore - def predict(self, batch): + def predict(self, feed: np.ndarray) -> np.ndarray: """ Run model to get predictions """ - predictions = self.model.predict(batch["feed"]) - batch["prediction"] = predictions[..., -1] - return batch + predictions = self.model.predict(feed) + assert isinstance(predictions, np.ndarray) + return predictions[..., -1] - def process_output(self, batch): + def process_output(self, batch: BatchType) -> None: """ Compile found faces for output """ - return batch + return class VGGClear(KSession): @@ -87,7 +95,10 @@ class VGGClear(KSession): https://github.com/YuvalNirkin/face_segmentation/releases/download/1.1/face_seg_fcn8s_300_no_aug.zip """ - def __init__(self, model_path, allow_growth, exclude_gpus): + def __init__(self, + model_path: str, + allow_growth: bool, + exclude_gpus: Optional[List[int]]): super().__init__("VGG Obstructed", model_path, allow_growth=allow_growth, @@ -96,7 +107,7 @@ def __init__(self, model_path, allow_growth, exclude_gpus): self.load_model_weights() @classmethod - def _model_definition(cls): + def _model_definition(cls) -> Tuple[Tensor, Tensor]: """ Definition of the VGG Obstructed Model. Returns @@ -158,13 +169,13 @@ class _ConvBlock(): # pylint:disable=too-few-public-methods iterations: int The number of consecutive Conv2D layers to create """ - def __init__(self, level, filters, iterations): + def __init__(self, level: int, filters: int, iterations: int) -> None: self._name = f"conv{level}_" self._level = level self._filters = filters self._iterator = range(1, iterations + 1) - def __call__(self, inputs): + def __call__(self, inputs: Tensor) -> Tensor: """ Call the convolutional loop. Parameters @@ -203,12 +214,12 @@ class _ScorePool(): # pylint:disable=too-few-public-methods crop: tuple The amount of 2D cropping to apply. Tuple of `ints` """ - def __init__(self, level, scale, crop): + def __init__(self, level: int, scale: float, crop: Tuple[int, int]): self._name = f"_pool{level}" self._cropping = (crop, crop) self._scale = scale - def __call__(self, inputs): + def __call__(self, inputs: Tensor) -> Tensor: """ Score pool block. Parameters diff --git a/plugins/extract/mask/vgg_obstructed.py b/plugins/extract/mask/vgg_obstructed.py index ba8a596e84..37a0b312f3 100644 --- a/plugins/extract/mask/vgg_obstructed.py +++ b/plugins/extract/mask/vgg_obstructed.py @@ -1,30 +1,37 @@ #!/usr/bin/env python3 """ VGG Obstructed face mask plugin """ +import logging +from typing import cast, List, Optional, Tuple import numpy as np - from lib.model.session import KSession from lib.utils import get_backend -from ._base import Masker, logger +from ._base import BatchType, Masker, MaskerBatch + +logger = logging.getLogger(__name__) + if get_backend() == "amd": from keras.layers import ( Add, Conv2D, Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, MaxPooling2D, ZeroPadding2D) + from plaidml.tile import Value as Tensor # pylint:disable=import-error else: # Ignore linting errors from Tensorflow's thoroughly broken import system from tensorflow.keras.layers import ( # pylint:disable=no-name-in-module,import-error Add, Conv2D, Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, MaxPooling2D, ZeroPadding2D) + from tensorflow import Tensor class Mask(Masker): """ Neural network to process face image into a segmentation mask of the face """ - def __init__(self, **kwargs): + def __init__(self, **kwargs) -> None: git_model_id = 5 model_filename = "Nirkin_500_softmax_v1.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.model: KSession self.name = "VGG Obstructed" self.input_size = 500 self.vram = 3936 @@ -32,7 +39,8 @@ def __init__(self, **kwargs): self.vram_per_batch = 304 self.batchsize = self.config["batch-size"] - def init_model(self): + def init_model(self) -> None: + assert isinstance(self.model_path, str) self.model = VGGObstructed(self.model_path, allow_growth=self.config["allow_growth"], exclude_gpus=self._exclude_gpus) @@ -41,22 +49,22 @@ def init_model(self): dtype="float32") self.model.predict(placeholder) - def process_input(self, batch): + def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction """ - input_ = [feed.face[..., :3] for feed in batch["feed_faces"]] - batch["feed"] = input_ - np.mean(input_, axis=(1, 2))[:, None, None, :] - logger.trace("feed shape: %s", batch["feed"].shape) - return batch + assert isinstance(batch, MaskerBatch) + input_ = [cast(np.ndarray, feed.face)[..., :3] for feed in batch.feed_faces] + batch.feed = input_ - np.mean(input_, axis=(1, 2))[:, None, None, :] + logger.trace("feed shape: %s", batch.feed.shape) # type:ignore - def predict(self, batch): + def predict(self, feed: np.ndarray) -> np.ndarray: """ Run model to get predictions """ - predictions = self.model.predict(batch["feed"]) - batch["prediction"] = predictions[..., 0] * -1.0 + 1.0 - return batch + predictions = self.model.predict(feed) + assert isinstance(predictions, np.ndarray) + return predictions[..., 0] * -1.0 + 1.0 - def process_output(self, batch): + def process_output(self, batch: BatchType) -> None: """ Compile found faces for output """ - return batch + return class VGGObstructed(KSession): @@ -84,7 +92,10 @@ class VGGObstructed(KSession): Model file sourced from: https://github.com/YuvalNirkin/face_segmentation/releases/download/1.0/face_seg_fcn8s.zip """ - def __init__(self, model_path, allow_growth, exclude_gpus): + def __init__(self, + model_path: str, + allow_growth: bool, + exclude_gpus: Optional[List[int]]) -> None: super().__init__("VGG Obstructed", model_path, allow_growth=allow_growth, @@ -93,7 +104,7 @@ def __init__(self, model_path, allow_growth, exclude_gpus): self.load_model_weights() @classmethod - def _model_definition(cls): + def _model_definition(cls) -> Tuple[Tensor, Tensor]: """ Definition of the VGG Obstructed Model. Returns @@ -158,13 +169,13 @@ class _ConvBlock(): # pylint:disable=too-few-public-methods iterations: int The number of consecutive Conv2D layers to create """ - def __init__(self, level, filters, iterations): + def __init__(self, level: int, filters: int, iterations: int) -> None: self._name = f"conv{level}_" self._level = level self._filters = filters self._iterator = range(1, iterations + 1) - def __call__(self, inputs): + def __call__(self, inputs: Tensor) -> Tensor: """ Call the convolutional loop. Parameters @@ -203,12 +214,12 @@ class _ScorePool(): # pylint:disable=too-few-public-methods crop: int The amount of 2D cropping to apply """ - def __init__(self, level, scale, crop): + def __init__(self, level: int, scale: float, crop: int) -> None: self._name = f"_pool{level}" self._cropping = ((crop, crop), (crop, crop)) self._scale = scale - def __call__(self, inputs): + def __call__(self, inputs: Tensor) -> Tensor: """ Score pool block. Parameters diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index f1a1e9c539..0d0610b33f 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -104,7 +104,7 @@ def __init__(self, configfile: Optional[str] = None, multiprocess: bool = False, exclude_gpus: Optional[List[int]] = None, - rotate_images: Optional[List[int]] = None, + rotate_images: Optional[str] = None, min_size: int = 0, normalize_method: Optional[Literal["none", "clahe", "hist", "mean"]] = None, re_feed: int = 0, @@ -275,8 +275,7 @@ def detected_faces(self) -> Generator["ExtractMedia", None, None]: out_queue = self._output_queue while True: try: - if self._check_and_raise_error(): - break + self._check_and_raise_error() faces = out_queue.get(True, 1) if faces == "EOF": break @@ -392,7 +391,18 @@ def _active_plugins(self) -> List["PluginExtractor"]: def _set_flow(detector: Optional[str], aligner: Optional[str], masker: List[Optional[str]]) -> List[str]: - """ Set the flow list based on the input plugins """ + """ Set the flow list based on the input plugins + + Parameters + ---------- + detector: str or ``None`` + The name of a detector plugin as exists in :mod:`plugins.extract.detect` + aligner: str or ``None + The name of an aligner plugin as exists in :mod:`plugins.extract.align` + masker: str or list or ``None + The name of a masker plugin(s) as exists in :mod:`plugins.extract.mask`. + This can be a single masker or a list of multiple maskers + """ logger.debug("detector: %s, aligner: %s, masker: %s", detector, aligner, masker) retval = [] if detector is not None and detector.lower() != "none": @@ -585,7 +595,7 @@ def _load_align(self, def _load_detect(self, detector: Optional[str], - rotation: Optional[List[int]], + rotation: Optional[str], min_size: int, configfile: Optional[str]) -> Optional["Detector"]: """ Set global arguments and load detector plugin """ @@ -729,12 +739,10 @@ def _join_threads(self): for plugin in self._active_plugins: plugin.join() - def _check_and_raise_error(self) -> bool: + def _check_and_raise_error(self) -> None: """ Check all threads for errors and raise if one occurs """ for plugin in self._active_plugins: - if plugin.check_and_raise_error(): - return True - return False + plugin.check_and_raise_error() class ExtractMedia(): diff --git a/tests/simple_tests.py b/tests/simple_tests.py index 7581feafcd..cd0e547b59 100644 --- a/tests/simple_tests.py +++ b/tests/simple_tests.py @@ -25,6 +25,7 @@ "ENDC": "\033[0m" } + def print_colored(text, color="OK", bold=False): """ Print colored text This might not work on windows, From 856067b3bbf51c00473506eb749b066507bdbdb3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 30 Sep 2022 23:45:21 +0100 Subject: [PATCH 749/981] typofix: tools.mask --- tools/mask/mask.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 2a2c238e88..8ddce415b8 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -499,9 +499,9 @@ def _create_image(self, detected_face: DetectedFace, mask_type: str) -> np.ndarr detected_face.load_aligned(detected_face.image, centering=centering, force=True) face = detected_face.aligned.face assert face is not None - mask = cv2.resize(detected_face.mask[mask_type].mask, - (face.shape[1], face.shape[0]), - interpolation=cv2.INTER_CUBIC)[..., 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]) From 6f48d7f001733594447209e5694948b11fc7798d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 1 Oct 2022 17:04:58 +0100 Subject: [PATCH 750/981] Phaze-A - Sym384 Preset --- .../train/model_phaze_a_sym384_preset.json | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 lib/gui/.cache/presets/train/model_phaze_a_sym384_preset.json diff --git a/lib/gui/.cache/presets/train/model_phaze_a_sym384_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_sym384_preset.json new file mode 100644 index 0000000000..e836a856f8 --- /dev/null +++ b/lib/gui/.cache/presets/train/model_phaze_a_sym384_preset.json @@ -0,0 +1,52 @@ +{ + "output_size": 384, + "shared_fc": "none", + "enable_gblock": false, + "split_fc": false, + "split_gblock": false, + "split_decoders": true, + "enc_architecture": "efficientnet_v2_s", + "enc_scaling": 100, + "enc_load_weights": true, + "bottleneck_type": "max_pooling", + "bottleneck_norm": "none", + "bottleneck_size": 1280, + "bottleneck_in_encoder": true, + "fc_depth": 1, + "fc_min_filters": 1536, + "fc_max_filters": 1536, + "fc_dimensions": 3, + "fc_filter_slope": -0.5, + "fc_dropout": 0.0, + "fc_upsampler": "subpixel", + "fc_upsamples": 0, + "fc_upsample_filters": 1280, + "fc_gblock_depth": 3, + "fc_gblock_min_nodes": 512, + "fc_gblock_max_nodes": 512, + "fc_gblock_filter_slope": -0.5, + "fc_gblock_dropout": 0.0, + "dec_upscale_method": "upscale_dny", + "dec_upscales_in_fc": 2, + "dec_norm": "none", + "dec_min_filters": 24, + "dec_max_filters": 1536, + "dec_slope_mode": "cap_max", + "dec_filter_slope": 0.5, + "dec_res_blocks": 1, + "dec_output_kernel": 3, + "dec_gaussian": true, + "dec_skip_last_residual": true, + "freeze_layers": "keras_encoder", + "load_layers": "encoder", + "fs_original_depth": 4, + "fs_original_min_filters": 128, + "fs_original_max_filters": 1024, + "fs_original_use_alt": false, + "mobilenet_width": 1.0, + "mobilenet_depth": 1, + "mobilenet_dropout": 0.001, + "mobilenet_minimalistic": false, + "__filetype": "faceswap_preset", + "__section": "train|model|phaze_a" +} From 1f31af233ec0e15d46351fa41f9a11a0ed393d5f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 4 Oct 2022 12:13:26 +0100 Subject: [PATCH 751/981] bugfix: Aligner size filter to int64 --- plugins/extract/align/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index 26a9fec7fe..cd0d38e0f1 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -607,7 +607,7 @@ def _scale_test(self, if self._min_scale <= 0.0 and self._max_scale <= 0.0: return None - roi = face.original_roi + roi = face.original_roi.astype("int64") size = ((roi[1][0] - roi[0][0]) ** 2 + (roi[1][1] - roi[0][1]) ** 2) ** 0.5 if self._min_scale > 0.0 and size < minimum_dimension * self._min_scale: From f1e3339fbee89e2881001a28112de9299b21e8e1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 10 Oct 2022 13:09:02 +0100 Subject: [PATCH 752/981] Add vggface2 to extraction pipeline --- lib/align/detected_face.py | 6 +- lib/cli/args.py | 7 + locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 44879 -> 45184 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 177 ++++++------ locales/lib.cli.args.pot | 173 ++++++------ locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 58532 -> 58943 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 177 ++++++------ plugins/extract/_base.py | 5 +- plugins/extract/pipeline.py | 40 ++- plugins/extract/recognition/_base.py | 253 ++++++++++++++++++ .../{vgg_face2_keras.py => vgg_face2.py} | 109 +++----- .../extract/recognition/vgg_face2_defaults.py | 73 +++++ plugins/plugin_loader.py | 20 ++ scripts/extract.py | 2 + tools/manual/detected_faces.py | 1 + tools/sort/sort_methods.py | 5 +- 16 files changed, 705 insertions(+), 343 deletions(-) create mode 100644 plugins/extract/recognition/_base.py rename plugins/extract/recognition/{vgg_face2_keras.py => vgg_face2.py} (80%) create mode 100644 plugins/extract/recognition/vgg_face2_defaults.py diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index de8959f32b..c6dfe45aba 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -104,7 +104,7 @@ def __init__(self, self.top = top self.height = height self._landmarks_xy = landmarks_xy - self._identity: Dict[Literal["vggface2"], np.ndarray] = {} + self._identity: Dict[str, np.ndarray] = {} self.thumbnail: Optional[np.ndarray] = None self.mask = {} if mask is None else mask self._training_masks: Optional[Tuple[bytes, Tuple[int, int, int]]] = None @@ -137,7 +137,7 @@ def bottom(self) -> int: return self.top + self.height @property - def identity(self) -> Dict[Literal["vggface2"], np.ndarray]: + def identity(self) -> Dict[str, np.ndarray]: """ dict: Identity mechanism as key, identity embedding as value. """ return self._identity @@ -191,7 +191,7 @@ def add_landmarks_xy(self, landmarks: np.ndarray) -> None: logger.trace("landmarks shape: '%s'", landmarks.shape) # type: ignore self._landmarks_xy = landmarks - def add_identity(self, name: Literal["vggface2"], embedding: np.ndarray, ) -> None: + def add_identity(self, name: str, embedding: np.ndarray, ) -> None: """ Add an identity embedding to this detected face. If an identity already exists for the given :attr:`name` it will be overwritten diff --git a/lib/cli/args.py b/lib/cli/args.py index 43bee8a553..1878ca9a66 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -495,6 +495,13 @@ def get_optional_arguments() -> List[Dict[str, Any]]: "more faces at the cost of extraction speed. Pass in a single number to use " "increments of that size up to 360, or pass in a list of numbers to enumerate " "exactly what angles to check."))) + argument_list.append(dict( + opts=("-I", "--identity"), + action="store_true", + default=False, + group=_("Plugins"), + help=_("Obtain and store face identity encodings from VGGFace2. Slows down extract a " + "little, but will save time if using 'sort by face'"))) argument_list.append(dict( opts=("-min", "--min-size"), action=Slider, diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo index 56e1b25d4c530e92cb00936573a9668b3dc5f414..05ce66fa9c922a9697eec318427b24cfaf556020 100644 GIT binary patch delta 2132 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 a6_*uGii9H3sU^kT=a Configure Extract 'Plugins':\n" @@ -129,7 +130,7 @@ msgstr "" "detectar más caras y tiene menos falsos positivos que otros detectores " "basados en GPU, pero uso muchos más recursos." -#: lib/cli/args.py:412 +#: lib/cli/args.py:413 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " @@ -141,7 +142,7 @@ msgstr "" "pero es menos preciso. Elegir este si necesita rapidez y no usar la GPU.\n" "L|fan: El mejor alineador. Rápido en la GPU, y lento en la CPU." -#: lib/cli/args.py:424 +#: lib/cli/args.py:425 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -211,7 +212,7 @@ msgstr "" "referencia y la máscara se extiende hacia arriba en la frente.\n" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -#: lib/cli/args.py:463 +#: lib/cli/args.py:464 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -234,7 +235,7 @@ msgstr "" "L|hist: Iguala los histogramas de los canales RGB.\n" "L|mean: Normalizar los colores de la cara a la media." -#: lib/cli/args.py:481 +#: lib/cli/args.py:482 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -251,7 +252,7 @@ msgstr "" "más veces se vuelva a introducir la cara en el alineador, menos " "microfluctuaciones se producirán, pero la extracción será más larga." -#: lib/cli/args.py:493 +#: lib/cli/args.py:494 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -263,13 +264,21 @@ msgstr "" "un solo número para usar incrementos de ese tamaño hasta 360, o pase una " "lista de números para enumerar exactamente qué ángulos comprobar." -#: lib/cli/args.py:505 lib/cli/args.py:515 lib/cli/args.py:528 -#: lib/cli/args.py:542 lib/cli/args.py:785 lib/cli/args.py:799 -#: lib/cli/args.py:812 lib/cli/args.py:826 +#: lib/cli/args.py:503 +msgid "" +"Obtain and store face identity encodings from VGGFace2. Slows down extract a " +"little, but will save time if using 'sort by face'" +msgstr "" +"Obtenga y almacene codificaciones de identidad facial de VGGFace2. Ralentiza " +"un poco la extracción, pero ahorrará tiempo si usa 'sort by face'" + +#: lib/cli/args.py:513 lib/cli/args.py:523 lib/cli/args.py:536 +#: lib/cli/args.py:550 lib/cli/args.py:793 lib/cli/args.py:807 +#: lib/cli/args.py:820 lib/cli/args.py:834 msgid "Face Processing" msgstr "Proceso de Caras" -#: lib/cli/args.py:506 +#: lib/cli/args.py:514 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -278,7 +287,7 @@ msgstr "" "a lo largo de la diagonal del cuadro delimitador. Establecer a 0 para " "desactivar" -#: lib/cli/args.py:516 lib/cli/args.py:800 +#: lib/cli/args.py:524 lib/cli/args.py:808 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -292,7 +301,7 @@ msgstr "" "uso del filtro de caras disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args.py:529 lib/cli/args.py:813 +#: lib/cli/args.py:537 lib/cli/args.py:821 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -306,7 +315,7 @@ msgstr "" "del filtro facial disminuirá significativamente la velocidad de extracción y " "no se puede garantizar su precisión." -#: lib/cli/args.py:543 lib/cli/args.py:827 +#: lib/cli/args.py:551 lib/cli/args.py:835 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -318,12 +327,12 @@ msgstr "" "NB: El uso del filtro facial disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args.py:554 lib/cli/args.py:566 lib/cli/args.py:578 -#: lib/cli/args.py:590 +#: lib/cli/args.py:562 lib/cli/args.py:574 lib/cli/args.py:586 +#: lib/cli/args.py:598 msgid "output" msgstr "salida" -#: lib/cli/args.py:555 +#: lib/cli/args.py:563 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -333,7 +342,7 @@ msgstr "" "pretende entrenar admite el tamaño deseado. Esto sólo tendrá que ser " "cambiado para los modelos de alta resolución." -#: lib/cli/args.py:567 +#: lib/cli/args.py:575 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -343,7 +352,7 @@ msgstr "" "extraer las caras. Por ejemplo, un valor de 1 extraerá las caras de cada " "fotograma, un valor de 10 extraerá las caras de cada 10 fotogramas." -#: lib/cli/args.py:579 +#: lib/cli/args.py:587 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -359,18 +368,18 @@ msgstr "" "ADVERTENCIA: No interrumpa el script al escribir el archivo porque podría " "corromperse. Poner a 0 para desactivar" -#: lib/cli/args.py:591 +#: lib/cli/args.py:599 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" "Dibujar puntos de referencia en las caras de salida para fines de depuración." -#: lib/cli/args.py:597 lib/cli/args.py:606 lib/cli/args.py:614 -#: lib/cli/args.py:621 lib/cli/args.py:839 lib/cli/args.py:850 -#: lib/cli/args.py:858 lib/cli/args.py:877 lib/cli/args.py:883 +#: lib/cli/args.py:605 lib/cli/args.py:614 lib/cli/args.py:622 +#: lib/cli/args.py:629 lib/cli/args.py:847 lib/cli/args.py:858 +#: lib/cli/args.py:866 lib/cli/args.py:885 lib/cli/args.py:891 msgid "settings" msgstr "ajustes" -#: lib/cli/args.py:598 +#: lib/cli/args.py:606 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " @@ -380,7 +389,7 @@ msgstr "" "extracción por separado (una tras otra) en lugar de hacerlo todo al mismo " "tiempo. Útil si la VRAM es escasa." -#: lib/cli/args.py:607 +#: lib/cli/args.py:615 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -388,19 +397,19 @@ msgstr "" "Omite los fotogramas que ya han sido extraídos y que existen en el archivo " "de alineaciones" -#: lib/cli/args.py:615 +#: lib/cli/args.py:623 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" "Omitir los fotogramas que ya tienen caras detectadas en el archivo de " "alineaciones" -#: lib/cli/args.py:622 +#: lib/cli/args.py:630 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "No guardar las caras detectadas en el disco. Crear sólo un archivo de " "alineaciones" -#: lib/cli/args.py:644 +#: lib/cli/args.py:652 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -410,7 +419,7 @@ msgstr "" "Los plugins de conversión pueden ser configurados en el menú " "\"Configuración\"" -#: lib/cli/args.py:665 +#: lib/cli/args.py:673 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -420,7 +429,7 @@ msgstr "" "original del que se extrajeron los fotogramas de origen (para extraer los " "fps y el audio)." -#: lib/cli/args.py:674 +#: lib/cli/args.py:682 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -428,7 +437,7 @@ msgstr "" "Directorio del modelo. El directorio que contiene el modelo entrenado que " "desea utilizar para la conversión." -#: lib/cli/args.py:684 +#: lib/cli/args.py:692 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -468,7 +477,7 @@ msgstr "" "colores. Generalmente no da resultados muy satisfactorios.\n" "L|none: No realice el ajuste de color." -#: lib/cli/args.py:711 +#: lib/cli/args.py:719 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -544,7 +553,7 @@ msgstr "" "L|predicted: Si la opción 'Learn Mask' se habilitó durante el entrenamiento, " "esto usará la máscara que fue creada por el modelo entrenado." -#: lib/cli/args.py:749 +#: lib/cli/args.py:757 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -570,11 +579,11 @@ msgstr "" "L|pillow: [images] Más lento que opencv, pero tiene más opciones y soporta " "más formatos." -#: lib/cli/args.py:768 lib/cli/args.py:775 lib/cli/args.py:869 +#: lib/cli/args.py:776 lib/cli/args.py:783 lib/cli/args.py:877 msgid "Frame Processing" msgstr "Proceso de fotogramas" -#: lib/cli/args.py:769 +#: lib/cli/args.py:777 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -584,7 +593,7 @@ msgstr "" "a los fotogramas a las dimensiones de origen. 50%% a la mitad de tamaño. " "200%% al doble de tamaño" -#: lib/cli/args.py:776 +#: lib/cli/args.py:784 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -598,7 +607,7 @@ msgstr "" "imágenes, ¡los nombres de los archivos deben terminar con el número de " "fotograma!" -#: lib/cli/args.py:786 +#: lib/cli/args.py:794 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -614,7 +623,7 @@ msgstr "" "especificada. Si se deja en blanco, se convertirán todas las caras que " "existan en el archivo de alineaciones." -#: lib/cli/args.py:840 +#: lib/cli/args.py:848 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -631,7 +640,7 @@ msgstr "" "procesos que los disponibles en su sistema. Si 'singleprocess' está " "habilitado, este ajuste será ignorado." -#: lib/cli/args.py:851 +#: lib/cli/args.py:859 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -639,7 +648,7 @@ msgstr "" "[LEGACY] Sólo es necesario seleccionar esta opción si se está cargando un " "modelo heredado si hay varios modelos en la carpeta de modelos" -#: lib/cli/args.py:859 +#: lib/cli/args.py:867 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -654,7 +663,7 @@ msgstr "" "de baja calidad. Si se encuentra un archivo de alineaciones, esta opción " "será ignorada." -#: lib/cli/args.py:870 +#: lib/cli/args.py:878 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -662,16 +671,16 @@ msgstr "" "Cuando se usa con --frame-ranges, la salida incluye los fotogramas no " "procesados en vez de descartarlos." -#: lib/cli/args.py:878 +#: lib/cli/args.py:886 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Intercambiar el modelo. En vez de convertir de A a B, convierte de B a A" -#: lib/cli/args.py:884 +#: lib/cli/args.py:892 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Desactiva el multiproceso. Es más lento, pero usa menos recursos." -#: lib/cli/args.py:900 +#: lib/cli/args.py:908 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -683,11 +692,11 @@ msgstr "" "hasta más de una semana.\n" "Los plugins de los modelos pueden configurarse en el menú \"Ajustes\"" -#: lib/cli/args.py:919 lib/cli/args.py:928 +#: lib/cli/args.py:927 lib/cli/args.py:936 msgid "faces" msgstr "caras" -#: lib/cli/args.py:920 +#: lib/cli/args.py:928 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -697,7 +706,7 @@ msgstr "" "para la cara A. Esta es la cara original, es decir, la cara que se quiere " "eliminar y sustituir por la cara B." -#: lib/cli/args.py:929 +#: lib/cli/args.py:937 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -707,12 +716,12 @@ msgstr "" "para la cara B. Esta es la cara de intercambio, es decir, la cara que se " "quiere colocar en la cabeza de la persona A." -#: lib/cli/args.py:937 lib/cli/args.py:949 lib/cli/args.py:965 -#: lib/cli/args.py:990 lib/cli/args.py:1000 +#: lib/cli/args.py:945 lib/cli/args.py:957 lib/cli/args.py:973 +#: lib/cli/args.py:998 lib/cli/args.py:1008 msgid "model" msgstr "modelo" -#: lib/cli/args.py:938 +#: lib/cli/args.py:946 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -726,7 +735,7 @@ msgstr "" "carpeta que no exista (que se creará). Si continúa entrenando un modelo " "existente, especifique la ubicación del modelo existente." -#: lib/cli/args.py:950 +#: lib/cli/args.py:958 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -750,7 +759,7 @@ msgstr "" "NB: Los pesos solo se pueden cargar desde modelos del mismo complemento que " "desea entrenar." -#: lib/cli/args.py:966 +#: lib/cli/args.py:974 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -795,7 +804,7 @@ msgstr "" "recursos (se necesita una GPU con una buena cantidad de VRAM). Bueno para " "los detalles, pero más susceptible a las diferencias de color." -#: lib/cli/args.py:991 +#: lib/cli/args.py:999 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -807,7 +816,7 @@ msgstr "" "muestra un resumen del modelo que crearía el complemento elegido y los " "ajustes de configuración." -#: lib/cli/args.py:1001 +#: lib/cli/args.py:1009 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -821,12 +830,12 @@ msgstr "" "congelará el codificador, pero algunos modelos pueden tener opciones de " "configuración para congelar otras capas." -#: lib/cli/args.py:1014 lib/cli/args.py:1026 lib/cli/args.py:1037 -#: lib/cli/args.py:1048 lib/cli/args.py:1131 +#: lib/cli/args.py:1022 lib/cli/args.py:1034 lib/cli/args.py:1045 +#: lib/cli/args.py:1056 lib/cli/args.py:1139 msgid "training" msgstr "entrenamiento" -#: lib/cli/args.py:1015 +#: lib/cli/args.py:1023 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -839,7 +848,7 @@ msgstr "" "momento es el doble del número que se establece aquí. Los lotes más grandes " "requieren más RAM de la GPU." -#: lib/cli/args.py:1027 +#: lib/cli/args.py:1035 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -854,7 +863,7 @@ msgstr "" "automáticamente en un número determinado de iteraciones, puede establecer " "ese valor aquí." -#: lib/cli/args.py:1038 +#: lib/cli/args.py:1046 msgid "" "[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " "Mirrored Distrubution Strategy to train on multiple GPUs." @@ -862,7 +871,7 @@ msgstr "" "[Obsoleto: use '-D, --distribution-strategy' en su lugar] Use la estrategia " "de distribución duplicada de Tensorflow para entrenar en varias GPU." -#: lib/cli/args.py:1049 +#: lib/cli/args.py:1057 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -888,15 +897,15 @@ msgstr "" "locales. Se carga una copia del modelo y todas las variables en cada GPU con " "lotes distribuidos a cada GPU en cada iteración." -#: lib/cli/args.py:1066 lib/cli/args.py:1076 +#: lib/cli/args.py:1074 lib/cli/args.py:1084 msgid "Saving" msgstr "Guardar" -#: lib/cli/args.py:1067 +#: lib/cli/args.py:1075 msgid "Sets the number of iterations between each model save." msgstr "Establece el número de iteraciones entre cada guardado del modelo." -#: lib/cli/args.py:1077 +#: lib/cli/args.py:1085 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -904,11 +913,11 @@ msgstr "" "Establece el número de iteraciones antes de guardar una copia de seguridad " "del modelo en su estado actual. Establece 0 para que esté desactivado." -#: lib/cli/args.py:1084 lib/cli/args.py:1095 lib/cli/args.py:1106 +#: lib/cli/args.py:1092 lib/cli/args.py:1103 lib/cli/args.py:1114 msgid "timelapse" msgstr "intervalo" -#: lib/cli/args.py:1085 +#: lib/cli/args.py:1093 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -922,7 +931,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-B." -#: lib/cli/args.py:1096 +#: lib/cli/args.py:1104 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -936,7 +945,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-A." -#: lib/cli/args.py:1107 +#: lib/cli/args.py:1115 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -948,17 +957,17 @@ msgstr "" "Si se suministran las carpetas de entrada pero no la carpeta de salida, se " "guardará por defecto en la carpeta del modelo /timelapse/" -#: lib/cli/args.py:1116 lib/cli/args.py:1123 +#: lib/cli/args.py:1124 lib/cli/args.py:1131 msgid "preview" msgstr "previsualización" -#: lib/cli/args.py:1117 +#: lib/cli/args.py:1125 msgid "Show training preview output. in a separate window." msgstr "" "Mostrar la salida de la vista previa del entrenamiento. en una ventana " "separada." -#: lib/cli/args.py:1124 +#: lib/cli/args.py:1132 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -966,7 +975,7 @@ msgstr "" "Escribe el resultado del entrenamiento en un archivo. La imagen se " "almacenará en la raíz de su carpeta FaceSwap." -#: lib/cli/args.py:1132 +#: lib/cli/args.py:1140 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -974,12 +983,12 @@ msgstr "" "Desactiva el registro de TensorBoard. NB: Desactivar los registros significa " "que no podrá utilizar el gráfico o el análisis de esta sesión en la GUI." -#: lib/cli/args.py:1139 lib/cli/args.py:1148 lib/cli/args.py:1157 -#: lib/cli/args.py:1166 +#: lib/cli/args.py:1147 lib/cli/args.py:1156 lib/cli/args.py:1165 +#: lib/cli/args.py:1174 msgid "augmentation" msgstr "aumento" -#: lib/cli/args.py:1140 +#: lib/cli/args.py:1148 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -989,7 +998,7 @@ msgstr "" "conjunto de caras opuestas en lugar de deformar la cara al azar. Esta es la " "forma 'dfaker' de hacer la deformación." -#: lib/cli/args.py:1149 +#: lib/cli/args.py:1157 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -1000,7 +1009,7 @@ msgstr "" "general, esto debería dejarse sin efecto, excepto durante el 'entrenamiento " "de ajuste'." -#: lib/cli/args.py:1158 +#: lib/cli/args.py:1166 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -1010,7 +1019,7 @@ msgstr "" "diferencias de color entre los conjuntos A y B, con un mayor coste de tiempo " "de entrenamiento. Activa esta opción para desactivar el aumento de color." -#: lib/cli/args.py:1167 +#: lib/cli/args.py:1175 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -1023,7 +1032,7 @@ msgstr "" "esta opción desde el principio, es probable que arruine el modelo y se " "obtengan resultados terribles." -#: lib/cli/args.py:1192 +#: lib/cli/args.py:1200 msgid "Output to Shell console instead of GUI console" msgstr "Salida a la consola Shell en lugar de la consola GUI" diff --git a/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index 5cdbb67ebe..48ef5429d9 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-08-31 19:19+0100\n" +"POT-Creation-Date: 2022-10-10 13:05+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -46,7 +46,7 @@ msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "" #: lib/cli/args.py:320 lib/cli/args.py:329 lib/cli/args.py:337 -#: lib/cli/args.py:385 lib/cli/args.py:664 lib/cli/args.py:673 +#: lib/cli/args.py:386 lib/cli/args.py:672 lib/cli/args.py:681 msgid "Data" msgstr "" @@ -73,20 +73,21 @@ msgid "" "Extraction plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:386 +#: lib/cli/args.py:387 msgid "" "R|If selected then the input_dir should be a parent folder containing " "multiple videos and/or folders of images you wish to extract from. The faces " "will be output to separate sub-folders in the output_dir." msgstr "" -#: lib/cli/args.py:395 lib/cli/args.py:411 lib/cli/args.py:423 -#: lib/cli/args.py:462 lib/cli/args.py:480 lib/cli/args.py:492 -#: lib/cli/args.py:683 lib/cli/args.py:710 lib/cli/args.py:748 +#: lib/cli/args.py:396 lib/cli/args.py:412 lib/cli/args.py:424 +#: lib/cli/args.py:463 lib/cli/args.py:481 lib/cli/args.py:493 +#: lib/cli/args.py:502 lib/cli/args.py:691 lib/cli/args.py:718 +#: lib/cli/args.py:756 msgid "Plugins" msgstr "" -#: lib/cli/args.py:396 +#: lib/cli/args.py:397 msgid "" "R|Detector to use. Some of these have configurable settings in '/config/" "extract.ini' or 'Settings > Configure Extract 'Plugins':\n" @@ -99,7 +100,7 @@ msgid "" "intensive." msgstr "" -#: lib/cli/args.py:412 +#: lib/cli/args.py:413 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " @@ -107,7 +108,7 @@ msgid "" "L|fan: Best aligner. Fast on GPU, slow on CPU." msgstr "" -#: lib/cli/args.py:424 +#: lib/cli/args.py:425 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -142,7 +143,7 @@ msgid "" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" msgstr "" -#: lib/cli/args.py:463 +#: lib/cli/args.py:464 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -155,7 +156,7 @@ msgid "" "L|mean: Normalize the face colors to the mean." msgstr "" -#: lib/cli/args.py:481 +#: lib/cli/args.py:482 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -165,7 +166,7 @@ msgid "" "occur but the longer extraction will take." msgstr "" -#: lib/cli/args.py:493 +#: lib/cli/args.py:494 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -173,19 +174,25 @@ msgid "" "exactly what angles to check." msgstr "" -#: lib/cli/args.py:505 lib/cli/args.py:515 lib/cli/args.py:528 -#: lib/cli/args.py:542 lib/cli/args.py:785 lib/cli/args.py:799 -#: lib/cli/args.py:812 lib/cli/args.py:826 +#: lib/cli/args.py:503 +msgid "" +"Obtain and store face identity encodings from VGGFace2. Slows down extract a " +"little, but will save time if using 'sort by face'" +msgstr "" + +#: lib/cli/args.py:513 lib/cli/args.py:523 lib/cli/args.py:536 +#: lib/cli/args.py:550 lib/cli/args.py:793 lib/cli/args.py:807 +#: lib/cli/args.py:820 lib/cli/args.py:834 msgid "Face Processing" msgstr "" -#: lib/cli/args.py:506 +#: lib/cli/args.py:514 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" msgstr "" -#: lib/cli/args.py:516 lib/cli/args.py:800 +#: lib/cli/args.py:524 lib/cli/args.py:808 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -194,7 +201,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args.py:529 lib/cli/args.py:813 +#: lib/cli/args.py:537 lib/cli/args.py:821 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -203,7 +210,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args.py:543 lib/cli/args.py:827 +#: lib/cli/args.py:551 lib/cli/args.py:835 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -211,26 +218,26 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args.py:554 lib/cli/args.py:566 lib/cli/args.py:578 -#: lib/cli/args.py:590 +#: lib/cli/args.py:562 lib/cli/args.py:574 lib/cli/args.py:586 +#: lib/cli/args.py:598 msgid "output" msgstr "" -#: lib/cli/args.py:555 +#: lib/cli/args.py:563 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" "res models." msgstr "" -#: lib/cli/args.py:567 +#: lib/cli/args.py:575 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " "of 10 will extract faces from every 10th frame." msgstr "" -#: lib/cli/args.py:579 +#: lib/cli/args.py:587 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -240,57 +247,57 @@ msgid "" "turn off" msgstr "" -#: lib/cli/args.py:591 +#: lib/cli/args.py:599 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" -#: lib/cli/args.py:597 lib/cli/args.py:606 lib/cli/args.py:614 -#: lib/cli/args.py:621 lib/cli/args.py:839 lib/cli/args.py:850 -#: lib/cli/args.py:858 lib/cli/args.py:877 lib/cli/args.py:883 +#: lib/cli/args.py:605 lib/cli/args.py:614 lib/cli/args.py:622 +#: lib/cli/args.py:629 lib/cli/args.py:847 lib/cli/args.py:858 +#: lib/cli/args.py:866 lib/cli/args.py:885 lib/cli/args.py:891 msgid "settings" msgstr "" -#: lib/cli/args.py:598 +#: lib/cli/args.py:606 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " "Useful if VRAM is at a premium." msgstr "" -#: lib/cli/args.py:607 +#: lib/cli/args.py:615 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" msgstr "" -#: lib/cli/args.py:615 +#: lib/cli/args.py:623 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" -#: lib/cli/args.py:622 +#: lib/cli/args.py:630 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" -#: lib/cli/args.py:644 +#: lib/cli/args.py:652 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:665 +#: lib/cli/args.py:673 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " "audio)." msgstr "" -#: lib/cli/args.py:674 +#: lib/cli/args.py:682 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." msgstr "" -#: lib/cli/args.py:684 +#: lib/cli/args.py:692 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -311,7 +318,7 @@ msgid "" "L|none: Don't perform color adjustment." msgstr "" -#: lib/cli/args.py:711 +#: lib/cli/args.py:719 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -348,7 +355,7 @@ msgid "" "will use the mask that was created by the trained model." msgstr "" -#: lib/cli/args.py:749 +#: lib/cli/args.py:757 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -363,18 +370,18 @@ msgid "" "more formats." msgstr "" -#: lib/cli/args.py:768 lib/cli/args.py:775 lib/cli/args.py:869 +#: lib/cli/args.py:776 lib/cli/args.py:783 lib/cli/args.py:877 msgid "Frame Processing" msgstr "" -#: lib/cli/args.py:769 +#: lib/cli/args.py:777 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " "at source dimensions. 50%% at half size 200%% at double size" msgstr "" -#: lib/cli/args.py:776 +#: lib/cli/args.py:784 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -382,7 +389,7 @@ msgid "" "converting from images, then the filenames must end with the frame-number!" msgstr "" -#: lib/cli/args.py:786 +#: lib/cli/args.py:794 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -392,7 +399,7 @@ msgid "" "alignments file." msgstr "" -#: lib/cli/args.py:840 +#: lib/cli/args.py:848 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -402,13 +409,13 @@ msgid "" "your system. If singleprocess is enabled this setting will be ignored." msgstr "" -#: lib/cli/args.py:851 +#: lib/cli/args.py:859 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" msgstr "" -#: lib/cli/args.py:859 +#: lib/cli/args.py:867 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -417,51 +424,51 @@ msgid "" "alignments file is found, this option will be ignored." msgstr "" -#: lib/cli/args.py:870 +#: lib/cli/args.py:878 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." msgstr "" -#: lib/cli/args.py:878 +#: lib/cli/args.py:886 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" -#: lib/cli/args.py:884 +#: lib/cli/args.py:892 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "" -#: lib/cli/args.py:900 +#: lib/cli/args.py:908 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" "Model plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:919 lib/cli/args.py:928 +#: lib/cli/args.py:927 lib/cli/args.py:936 msgid "faces" msgstr "" -#: lib/cli/args.py:920 +#: lib/cli/args.py:928 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " "face B." msgstr "" -#: lib/cli/args.py:929 +#: lib/cli/args.py:937 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " "A." msgstr "" -#: lib/cli/args.py:937 lib/cli/args.py:949 lib/cli/args.py:965 -#: lib/cli/args.py:990 lib/cli/args.py:1000 +#: lib/cli/args.py:945 lib/cli/args.py:957 lib/cli/args.py:973 +#: lib/cli/args.py:998 lib/cli/args.py:1008 msgid "model" msgstr "" -#: lib/cli/args.py:938 +#: lib/cli/args.py:946 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -470,7 +477,7 @@ msgid "" "the existing model." msgstr "" -#: lib/cli/args.py:950 +#: lib/cli/args.py:958 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -484,7 +491,7 @@ msgid "" "to train." msgstr "" -#: lib/cli/args.py:966 +#: lib/cli/args.py:974 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -507,7 +514,7 @@ msgid "" "susceptible to color differences." msgstr "" -#: lib/cli/args.py:991 +#: lib/cli/args.py:999 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -515,7 +522,7 @@ msgid "" "displayed." msgstr "" -#: lib/cli/args.py:1001 +#: lib/cli/args.py:1009 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -524,12 +531,12 @@ msgid "" "layers." msgstr "" -#: lib/cli/args.py:1014 lib/cli/args.py:1026 lib/cli/args.py:1037 -#: lib/cli/args.py:1048 lib/cli/args.py:1131 +#: lib/cli/args.py:1022 lib/cli/args.py:1034 lib/cli/args.py:1045 +#: lib/cli/args.py:1056 lib/cli/args.py:1139 msgid "training" msgstr "" -#: lib/cli/args.py:1015 +#: lib/cli/args.py:1023 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -537,7 +544,7 @@ msgid "" "number that you set here. Larger batches require more GPU RAM." msgstr "" -#: lib/cli/args.py:1027 +#: lib/cli/args.py:1035 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -546,13 +553,13 @@ msgid "" "can set that value here." msgstr "" -#: lib/cli/args.py:1038 +#: lib/cli/args.py:1046 msgid "" "[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " "Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" -#: lib/cli/args.py:1049 +#: lib/cli/args.py:1057 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -565,25 +572,25 @@ msgid "" "batches distributed to each GPU at each iteration." msgstr "" -#: lib/cli/args.py:1066 lib/cli/args.py:1076 +#: lib/cli/args.py:1074 lib/cli/args.py:1084 msgid "Saving" msgstr "" -#: lib/cli/args.py:1067 +#: lib/cli/args.py:1075 msgid "Sets the number of iterations between each model save." msgstr "" -#: lib/cli/args.py:1077 +#: lib/cli/args.py:1085 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." msgstr "" -#: lib/cli/args.py:1084 lib/cli/args.py:1095 lib/cli/args.py:1106 +#: lib/cli/args.py:1092 lib/cli/args.py:1103 lib/cli/args.py:1114 msgid "timelapse" msgstr "" -#: lib/cli/args.py:1085 +#: lib/cli/args.py:1093 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -592,7 +599,7 @@ msgid "" "timelapse-input-B parameter." msgstr "" -#: lib/cli/args.py:1096 +#: lib/cli/args.py:1104 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -601,7 +608,7 @@ msgid "" "timelapse-input-A parameter." msgstr "" -#: lib/cli/args.py:1107 +#: lib/cli/args.py:1115 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -609,53 +616,53 @@ msgid "" "model folder /timelapse/" msgstr "" -#: lib/cli/args.py:1116 lib/cli/args.py:1123 +#: lib/cli/args.py:1124 lib/cli/args.py:1131 msgid "preview" msgstr "" -#: lib/cli/args.py:1117 +#: lib/cli/args.py:1125 msgid "Show training preview output. in a separate window." msgstr "" -#: lib/cli/args.py:1124 +#: lib/cli/args.py:1132 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." msgstr "" -#: lib/cli/args.py:1132 +#: lib/cli/args.py:1140 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." msgstr "" -#: lib/cli/args.py:1139 lib/cli/args.py:1148 lib/cli/args.py:1157 -#: lib/cli/args.py:1166 +#: lib/cli/args.py:1147 lib/cli/args.py:1156 lib/cli/args.py:1165 +#: lib/cli/args.py:1174 msgid "augmentation" msgstr "" -#: lib/cli/args.py:1140 +#: lib/cli/args.py:1148 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " "warping." msgstr "" -#: lib/cli/args.py:1149 +#: lib/cli/args.py:1157 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " "left off except for during 'fit training'." msgstr "" -#: lib/cli/args.py:1158 +#: lib/cli/args.py:1166 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " "Enable this option to disable color augmentation." msgstr "" -#: lib/cli/args.py:1167 +#: lib/cli/args.py:1175 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -663,6 +670,6 @@ msgid "" "likely to kill a model and lead to terrible results." msgstr "" -#: lib/cli/args.py:1192 +#: lib/cli/args.py:1200 msgid "Output to Shell console instead of GUI console" msgstr "" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo index 1396e1c9cae28fa612dc74546e199237a1d66225..6df5de854a2fead5c9b6c4758f914b31a8bea4dc 100644 GIT binary patch delta 2188 zcmYk7d2Ezb5WuIlR!Rj5wMAOMCs(0BPmyv%3#C8-DPRye+-|qBxb4Q>Ewu1=eai zJO#(Zi%fzY@CD{2Jt}ezeM29SayX^0NIwy;oS;*Up#gTnbgT)m7e5(H90aprQGXGJ z$i@L8_u%Vr7xvc@MFQykl0@p^d3Xp~lSO9ZzXx}r7d$TVJiG{JU>`L|@9yO^DI$k) zbi+H)m&#xUY$6<*pA2C!>@iP>BVXxV!0G!7zeZm*F3<6#J){gqsN?azuV*qYsGZxoAQ>MI;Bmbq2y4 z?4Lz=(eKU?iPZ}iF%J%eZg>;xOBx&W$&p;c(dBTOv{ci%P@g&)1cnp#bGISa7*LgN} znS_`P^$jLM9kS_=e3L>L2Ny^5=U^=xmcjrxzr8#hk+)Weyo$YjWq5;()t(nad9a8eY`x7K5<1=c;g>9ZV8T)l*C;I z{!>GF;s0|Zewe#Ssm)XHI1_$3KzL(#(}Ur1>8YoDuunKj$zk7njLjLJ@{Y(=HtJ~* zN#)Hh1SnVD^i~TI@Z&2Y>v6b@@SAi(A9}vY zM({k`3m5SY+TI1fg4U}d2Uxh^8gn^7$CrFNSa{J5kxQ_q13R44$&K+h-sA&^ey5A1 zW`6QF%-8k*8J%1lwr{C%c^; zZ!_r4MEXW76icY}=XVnO%iORYs?ovpbt377bOKI9rX#Zvsw>o<5>|c!W<%C{e-vRv77J!6IZF;vE_>mPD8ii;*Nmmz-{td#GhcI1MUf99Koil&#Y0EVEa>=vL(}XN7up?%YEBIT>oH!&&WCWzOnKWqZZr zvRFOJq8xUQ$6*_%N~=7o+U{^Dw`GG(dF&OeSgxwvETq!hPM1fO)`T`n6MtdK*-7!) zS>v;_RCeyGPfB)bEmo0_|Rzm-`d2m(eUDUqB%%JIe;P6I0Lt0oSX!wnwGCpF>7EGtvbB_@)eRWZ_ zNonJh9>Kom8;0IU4{y;sE2DuOS;5?+jI(u{yHrx!`ww2LtyzqEb9&+m8M?R!4&^E|)jytn6lkDv4$ z{M-|cCda=dS`(Uz-Welx!m^wAq5Y8{ZH$xtglX`xvC_x*d%RLSEK5SL3hp*PJOsVt zq+<9He2BcO@C5d~t%{X3w7hw|KN$^*gN8;Zw0B22>_(Y*(>0h`8 z_TleHkq%@3ev32!-%OSE!&Ro$BxUCW@9(N-z06ylB(fqKNE%r;bQnLOoYB{sgaH!euv$aBP|@m zH*=)ZjISNJ(n+|2>^1^f`O@CXBMW4Q<5{04~sY$N9h zw~%BkT;|rqUvLoHyWFjvA=r+cMp5mw@8LG6W)=f_pq1C2X7=AY`*7F_`e7m*gw&bd zf?oJ;%>EGWWqiq#oZCl{Zcpn!jL<_`yBKp@{Yr+ue5T<=c!RQM@(;5w$;L|`mep^U_pPveKLY>}?O`?iuz zyrM@Uq{?TcRoKVi^Vn0ibC%c#;8DK!_c^<*^AtoS@%&xV%diVhfQclPa}~pBbY3T+ zg=iZ^b-mxc9--Hm1pfU4(k}eh2BmYB@CKF5K%qn2Va_V=16Io!)>9C@oY~e-q#GVq zJ1TJ_b%V;D#Q%&x#l)~PzLMA^orP>_Skl1GL=fe z^NU+_K4?{%4U=F2%!j2g8Lox)K=nXd;a>e574bGWjU81W)}IQwdCqg24XY5Zs&l97;2p@Cov%&QS_-noSu1tnJlodg zA#}!f+*bAXAa>4qED9ViTM3sSc3!+E&NZC~E08tYDQmVv)-qgZc8qy&GO`D!5!v<5 zL9rHAIlU589rN4{?Mhh-x^T980ewGOguF3-YK+vS^U~c3m!XBojwTCPn~920F`AC% xqI#sNNOEFJO5}=fqvBPqG5(-;LP?H*L9^wQoD23{tsxD_aXoQ diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.po b/locales/ru/LC_MESSAGES/lib.cli.args.po index bf14c2117e..362a188f23 100644 --- a/locales/ru/LC_MESSAGES/lib.cli.args.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-08-31 19:19+0100\n" -"PO-Revision-Date: 2022-08-31 19:22+0100\n" +"POT-Creation-Date: 2022-10-10 13:05+0100\n" +"PO-Revision-Date: 2022-10-10 13:08+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -58,7 +58,7 @@ msgstr "" "с faceswap" #: lib/cli/args.py:320 lib/cli/args.py:329 lib/cli/args.py:337 -#: lib/cli/args.py:385 lib/cli/args.py:664 lib/cli/args.py:673 +#: lib/cli/args.py:386 lib/cli/args.py:672 lib/cli/args.py:681 msgid "Data" msgstr "Данные" @@ -90,7 +90,7 @@ msgstr "" "Извлечь лица из изображений или видео источников.\n" "Плагины извлечения можно настроить в меню 'Настройки'" -#: lib/cli/args.py:386 +#: lib/cli/args.py:387 msgid "" "R|If selected then the input_dir should be a parent folder containing " "multiple videos and/or folders of images you wish to extract from. The faces " @@ -100,13 +100,14 @@ msgstr "" "несколько видео и/или папок изображений, из которых вы хотите извлечь. Лица " "будут выводиться в отдельные подпапки в output_dir." -#: lib/cli/args.py:395 lib/cli/args.py:411 lib/cli/args.py:423 -#: lib/cli/args.py:462 lib/cli/args.py:480 lib/cli/args.py:492 -#: lib/cli/args.py:683 lib/cli/args.py:710 lib/cli/args.py:748 +#: lib/cli/args.py:396 lib/cli/args.py:412 lib/cli/args.py:424 +#: lib/cli/args.py:463 lib/cli/args.py:481 lib/cli/args.py:493 +#: lib/cli/args.py:502 lib/cli/args.py:691 lib/cli/args.py:718 +#: lib/cli/args.py:756 msgid "Plugins" msgstr "Плагины" -#: lib/cli/args.py:396 +#: lib/cli/args.py:397 msgid "" "R|Detector to use. Some of these have configurable settings in '/config/" "extract.ini' or 'Settings > Configure Extract 'Plugins':\n" @@ -130,7 +131,7 @@ msgstr "" "детектировать лицо в большем кол-ве ситуация и меньшим кол-вом ошибок, чем " "другие GPU, но значительно более требователен к ресурсам." -#: lib/cli/args.py:412 +#: lib/cli/args.py:413 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " @@ -143,7 +144,7 @@ msgstr "" "использовать GPU.\n" "L|fan: Лучший выравниватель. Быстрый на GPU, медленный на CPU." -#: lib/cli/args.py:424 +#: lib/cli/args.py:425 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -209,7 +210,7 @@ msgstr "" "ориентиров лица и расширяется вверх на лоб.\n" "(пример: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -#: lib/cli/args.py:463 +#: lib/cli/args.py:464 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -230,7 +231,7 @@ msgstr "" "L|hist: Выравнивание гистограммы каналов RGB каналов.\n" "L|mean: Усреднение цветов лица." -#: lib/cli/args.py:481 +#: lib/cli/args.py:482 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -245,7 +246,7 @@ msgstr "" "замедления скорости извлечения. Чем больше проходов выравнивания, тем меньше " "микродрожание, но тем дольше идет извлечение." -#: lib/cli/args.py:493 +#: lib/cli/args.py:494 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -257,13 +258,21 @@ msgstr "" "использовать приращения этого размера до 360, либо передайте список чисел, " "чтобы точно указать, какие углы проверять." -#: lib/cli/args.py:505 lib/cli/args.py:515 lib/cli/args.py:528 -#: lib/cli/args.py:542 lib/cli/args.py:785 lib/cli/args.py:799 -#: lib/cli/args.py:812 lib/cli/args.py:826 +#: lib/cli/args.py:503 +msgid "" +"Obtain and store face identity encodings from VGGFace2. Slows down extract a " +"little, but will save time if using 'sort by face'" +msgstr "" +"Получите и сохраните кодировку идентификации лица от VGGFace2. Немного " +"замедляет извлечение, но сэкономит время при использовании «sort by face»" + +#: lib/cli/args.py:513 lib/cli/args.py:523 lib/cli/args.py:536 +#: lib/cli/args.py:550 lib/cli/args.py:793 lib/cli/args.py:807 +#: lib/cli/args.py:820 lib/cli/args.py:834 msgid "Face Processing" msgstr "Обработка лиц" -#: lib/cli/args.py:506 +#: lib/cli/args.py:514 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -271,7 +280,7 @@ msgstr "" "Отбрасывает лица ниже указанного размера. Длина указывается в пикселях по " "диагонали. Установите в 0 для отключения" -#: lib/cli/args.py:516 lib/cli/args.py:800 +#: lib/cli/args.py:524 lib/cli/args.py:808 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -285,7 +294,7 @@ msgstr "" "пробел. Прим.: Фильтрация лиц существенно снижает скорость извлечения, при " "этом точность не гарантируется." -#: lib/cli/args.py:529 lib/cli/args.py:813 +#: lib/cli/args.py:537 lib/cli/args.py:821 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -299,7 +308,7 @@ msgstr "" "изображений через пробел. Прим.: Использование фильтра существенно замедлит " "скорость извлечения. Также точность не гарантируется." -#: lib/cli/args.py:543 lib/cli/args.py:827 +#: lib/cli/args.py:551 lib/cli/args.py:835 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -310,12 +319,12 @@ msgstr "" "лица. Чем ниже значения, тем строже. Прим.: Использование фильтра лиц " "существенно замедлит скорость извлечения. Также точность не гарантируется." -#: lib/cli/args.py:554 lib/cli/args.py:566 lib/cli/args.py:578 -#: lib/cli/args.py:590 +#: lib/cli/args.py:562 lib/cli/args.py:574 lib/cli/args.py:586 +#: lib/cli/args.py:598 msgid "output" msgstr "вывод" -#: lib/cli/args.py:555 +#: lib/cli/args.py:563 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -325,7 +334,7 @@ msgstr "" "поддерживает такой входной размер. Стоит изменять только для моделей " "высокого разрешения." -#: lib/cli/args.py:567 +#: lib/cli/args.py:575 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -335,7 +344,7 @@ msgstr "" "извлечении. Например, значение 1 будет искать лица в каждом кадре, а " "значение 10 в каждом 10том кадре." -#: lib/cli/args.py:579 +#: lib/cli/args.py:587 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -350,17 +359,17 @@ msgstr "" "только во время второго прохода. ВНИМАНИЕ: Не прерывайте выполнение во время " "записи, так как это может повлечь порчу файла. Установите в 0 для выключения" -#: lib/cli/args.py:591 +#: lib/cli/args.py:599 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "Рисовать ландмарки на выходных лицах для нужд отладки." -#: lib/cli/args.py:597 lib/cli/args.py:606 lib/cli/args.py:614 -#: lib/cli/args.py:621 lib/cli/args.py:839 lib/cli/args.py:850 -#: lib/cli/args.py:858 lib/cli/args.py:877 lib/cli/args.py:883 +#: lib/cli/args.py:605 lib/cli/args.py:614 lib/cli/args.py:622 +#: lib/cli/args.py:629 lib/cli/args.py:847 lib/cli/args.py:858 +#: lib/cli/args.py:866 lib/cli/args.py:885 lib/cli/args.py:891 msgid "settings" msgstr "настройки" -#: lib/cli/args.py:598 +#: lib/cli/args.py:606 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " @@ -370,7 +379,7 @@ msgstr "" "стадия извлечения будет запущена отдельно (одна, за другой). Полезно при " "нехватке VRAM." -#: lib/cli/args.py:607 +#: lib/cli/args.py:615 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -378,16 +387,16 @@ msgstr "" "Пропускать кадры, которые уже были извлечены и существуют в файле " "выравнивания" -#: lib/cli/args.py:615 +#: lib/cli/args.py:623 msgid "Skip frames that already have detected faces in the alignments file" msgstr "Пропускать кадры, для которых в файле выравнивания есть найденные лица" -#: lib/cli/args.py:622 +#: lib/cli/args.py:630 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "Не сохранять найденные лица на носитель. Просто создать файл выравнивания" -#: lib/cli/args.py:644 +#: lib/cli/args.py:652 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -395,7 +404,7 @@ msgstr "" "Заменить оригиналы лица в исходном видео/фотографиях новыми.\n" "Плагины конвертации могут быть настроены в меню 'Настройки'" -#: lib/cli/args.py:665 +#: lib/cli/args.py:673 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -405,7 +414,7 @@ msgstr "" "Предоставьте исходное видео, из которого были извлечены кадры (для настройки " "частоты кадров, а также аудио)." -#: lib/cli/args.py:674 +#: lib/cli/args.py:682 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -413,7 +422,7 @@ msgstr "" "Папка с моделью. Папка, содержащая обученную модель, которую вы хотите " "использовать для преобразования." -#: lib/cli/args.py:684 +#: lib/cli/args.py:692 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -452,7 +461,7 @@ msgstr "" "дает удовлетворительных результатов.\n" "L|none: Не производить подгонку цвета." -#: lib/cli/args.py:711 +#: lib/cli/args.py:719 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -521,7 +530,7 @@ msgstr "" "L| predicted: Если во время обучения была включена опция «Learn Mask», будет " "использоваться маска, созданная обученной моделью." -#: lib/cli/args.py:749 +#: lib/cli/args.py:757 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -547,11 +556,11 @@ msgstr "" "L|pillow: [изображения] Более медленный, чем opencv, но имеет больше опций и " "поддерживает больше форматов." -#: lib/cli/args.py:768 lib/cli/args.py:775 lib/cli/args.py:869 +#: lib/cli/args.py:776 lib/cli/args.py:783 lib/cli/args.py:877 msgid "Frame Processing" msgstr "Обработка кадров" -#: lib/cli/args.py:769 +#: lib/cli/args.py:777 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -561,7 +570,7 @@ msgstr "" "кадры в исходном размере. 50%% половина от размера, а 200%% в удвоенном " "размере" -#: lib/cli/args.py:776 +#: lib/cli/args.py:784 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -574,7 +583,7 @@ msgstr "" "unchanged). Прим.: Если при конверсии используются изображения, то имена " "файлов должны заканчиваться номером кадра!" -#: lib/cli/args.py:786 +#: lib/cli/args.py:794 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -590,7 +599,7 @@ msgstr "" "Если оставить это поле пустым, то все лица, которые существуют в файле " "выравниваний будут сконвертированы." -#: lib/cli/args.py:840 +#: lib/cli/args.py:848 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -607,7 +616,7 @@ msgstr "" "будет использоваться больше процессов, чем доступно в вашей системе. Если " "включен одиночный процесс, этот параметр будет проигнорирован." -#: lib/cli/args.py:851 +#: lib/cli/args.py:859 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -615,7 +624,7 @@ msgstr "" "[СОВМЕСТИМОСТЬ] Это нужно выбирать только в том случае, если загружается " "устаревшая модель или если в папке сохранения есть несколько моделей" -#: lib/cli/args.py:859 +#: lib/cli/args.py:867 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -629,7 +638,7 @@ msgstr "" "использованию улучшенного конвейера экстракции и некачественных результатов. " "Если файл выравниваний найден, этот параметр будет проигнорирован." -#: lib/cli/args.py:870 +#: lib/cli/args.py:878 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -637,16 +646,16 @@ msgstr "" "При использовании с --frame-range кадры не попавшие в диапазон выводятся " "неизменными, вместо их пропуска." -#: lib/cli/args.py:878 +#: lib/cli/args.py:886 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Поменять модели местами. Вместо преобразования из A -> B, преобразует B -> A" -#: lib/cli/args.py:884 +#: lib/cli/args.py:892 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Отключить многопроцессорность. Медленнее, но менее ресурсоемко." -#: lib/cli/args.py:900 +#: lib/cli/args.py:908 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -657,11 +666,11 @@ msgstr "" "Обучение моделей может занять долгое время: от 24 часов до недели\n" "Каждую модель можно отдельно настроить в меню «Настройки»" -#: lib/cli/args.py:919 lib/cli/args.py:928 +#: lib/cli/args.py:927 lib/cli/args.py:936 msgid "faces" msgstr "лица" -#: lib/cli/args.py:920 +#: lib/cli/args.py:928 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -670,7 +679,7 @@ msgstr "" "Входная папка. Папка содержащая изображения для тренировки лица A. Это " "исходное лицо т.е. лицо, которое вы хотите убрать, заменив лицом B." -#: lib/cli/args.py:929 +#: lib/cli/args.py:937 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -679,12 +688,12 @@ msgstr "" "Входная папка. Папка содержащая изображения для тренировки лица B. Это новое " "лицо т.е. лицо, которое вы хотите поместить на голову человека A." -#: lib/cli/args.py:937 lib/cli/args.py:949 lib/cli/args.py:965 -#: lib/cli/args.py:990 lib/cli/args.py:1000 +#: lib/cli/args.py:945 lib/cli/args.py:957 lib/cli/args.py:973 +#: lib/cli/args.py:998 lib/cli/args.py:1008 msgid "model" msgstr "модель" -#: lib/cli/args.py:938 +#: lib/cli/args.py:946 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -698,7 +707,7 @@ msgstr "" "будет создана). Если вы хотите продолжить тренировку, выберите папку с уже " "существующими сохранениями." -#: lib/cli/args.py:950 +#: lib/cli/args.py:958 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -722,7 +731,7 @@ msgstr "" "NB: Вес можно загружать только из моделей того же плагина, который вы " "собираетесь тренировать." -#: lib/cli/args.py:966 +#: lib/cli/args.py:974 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -768,7 +777,7 @@ msgstr "" "ресурсам (Вам потребуется GPU с хорошим количеством видеопамяти). Хороша для " "деталей, но подвержена к неправильной передаче цвета." -#: lib/cli/args.py:991 +#: lib/cli/args.py:999 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -780,7 +789,7 @@ msgstr "" "сводная информация о модели, которая будет создана выбранным плагином, и " "параметрами конфигурации." -#: lib/cli/args.py:1001 +#: lib/cli/args.py:1009 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -794,12 +803,12 @@ msgstr "" "некоторые модели могут иметь параметры конфигурации для замораживания других " "слоев." -#: lib/cli/args.py:1014 lib/cli/args.py:1026 lib/cli/args.py:1037 -#: lib/cli/args.py:1048 lib/cli/args.py:1131 +#: lib/cli/args.py:1022 lib/cli/args.py:1034 lib/cli/args.py:1045 +#: lib/cli/args.py:1056 lib/cli/args.py:1139 msgid "training" msgstr "тренировка" -#: lib/cli/args.py:1015 +#: lib/cli/args.py:1023 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -812,7 +821,7 @@ msgstr "" "изображений в два раза больше этого числа. Увеличение размера партии требует " "больше памяти GPU." -#: lib/cli/args.py:1027 +#: lib/cli/args.py:1035 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -826,7 +835,7 @@ msgstr "" "Однако, если вы хотите, чтобы тренировка прервалась после указанного кол-ва " "итерация, вы можете ввести это здесь." -#: lib/cli/args.py:1038 +#: lib/cli/args.py:1046 msgid "" "[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " "Mirrored Distrubution Strategy to train on multiple GPUs." @@ -835,7 +844,7 @@ msgstr "" "Используйте стратегию зеркального распространения Tensorflow для обучения на " "нескольких графических процессорах." -#: lib/cli/args.py:1049 +#: lib/cli/args.py:1057 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -860,15 +869,15 @@ msgstr "" "в каждый GPU, причем пакеты распределяются между каждым GPU на каждой " "итерации." -#: lib/cli/args.py:1066 lib/cli/args.py:1076 +#: lib/cli/args.py:1074 lib/cli/args.py:1084 msgid "Saving" msgstr "Сохранение" -#: lib/cli/args.py:1067 +#: lib/cli/args.py:1075 msgid "Sets the number of iterations between each model save." msgstr "Установка количества итераций между сохранениями модели." -#: lib/cli/args.py:1077 +#: lib/cli/args.py:1085 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -876,11 +885,11 @@ msgstr "" "Устанавливает кол-во итераций перед созданием резервной копии модели. " "Установите в 0 для отключения." -#: lib/cli/args.py:1084 lib/cli/args.py:1095 lib/cli/args.py:1106 +#: lib/cli/args.py:1092 lib/cli/args.py:1103 lib/cli/args.py:1114 msgid "timelapse" msgstr "таймлапс" -#: lib/cli/args.py:1085 +#: lib/cli/args.py:1093 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -893,7 +902,7 @@ msgstr "" "папку лиц набора 'A' для использования при создании таймлапса. Вам также " "нужно указать параметры--timelapse-output и --timelapse-input-B." -#: lib/cli/args.py:1096 +#: lib/cli/args.py:1104 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -907,7 +916,7 @@ msgstr "" "таймлапса. Вы также должны указать параметр --timelapse-output и --timelapse-" "input-A." -#: lib/cli/args.py:1107 +#: lib/cli/args.py:1115 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -919,15 +928,15 @@ msgstr "" "указаны только входные папки, то по умолчанию вывод будет сохранен вместе с " "моделью в подкаталог /timelapse/" -#: lib/cli/args.py:1116 lib/cli/args.py:1123 +#: lib/cli/args.py:1124 lib/cli/args.py:1131 msgid "preview" msgstr "предварительный просмотр" -#: lib/cli/args.py:1117 +#: lib/cli/args.py:1125 msgid "Show training preview output. in a separate window." msgstr "Показывать предварительный просмотр в отдельном окне." -#: lib/cli/args.py:1124 +#: lib/cli/args.py:1132 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -935,7 +944,7 @@ msgstr "" "Записывает результат тренировки в файл. Файл будет сохранен в коренной папке " "FaceSwap." -#: lib/cli/args.py:1132 +#: lib/cli/args.py:1140 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -943,12 +952,12 @@ msgstr "" "Отключает журнал TensorBoard. Примечание: Отключение журналов означает, что " "вы не сможете использовать графики или анализ сессии внутри GUI." -#: lib/cli/args.py:1139 lib/cli/args.py:1148 lib/cli/args.py:1157 -#: lib/cli/args.py:1166 +#: lib/cli/args.py:1147 lib/cli/args.py:1156 lib/cli/args.py:1165 +#: lib/cli/args.py:1174 msgid "augmentation" msgstr "аугментация" -#: lib/cli/args.py:1140 +#: lib/cli/args.py:1148 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -958,7 +967,7 @@ msgstr "" "Ориентирами/Landmarks противоположного набора лиц. Этот способ используется " "пакетом \"dfaker\"." -#: lib/cli/args.py:1149 +#: lib/cli/args.py:1157 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -969,7 +978,7 @@ msgstr "" "происходило. Как правило, эту настройку не стоит трогать, за исключением " "периода «финальной шлифовки»." -#: lib/cli/args.py:1158 +#: lib/cli/args.py:1166 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -979,7 +988,7 @@ msgstr "" "цвета между наборами A and B ценой некоторого замедления скорости " "тренировки. Включите эту опцию для отключения цветовой аугментации." -#: lib/cli/args.py:1167 +#: lib/cli/args.py:1175 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -992,7 +1001,7 @@ msgstr "" "Включение этой опции с самого начала может убить модель и привести к ужасным " "результатам." -#: lib/cli/args.py:1192 +#: lib/cli/args.py:1200 msgid "Output to Shell console instead of GUI console" msgstr "Вывод в системную консоль вместо GUI" diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index d971456774..db8e88f599 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -31,10 +31,9 @@ from .align._base import AlignerBatch from .detect._base import DetectorBatch from .mask._base import MaskerBatch - + from .recognition._base import RecogBatch logger = logging.getLogger(__name__) # pylint: disable=invalid-name -# TODO CPU mode # TODO Run with warnings mode @@ -57,7 +56,7 @@ def _get_config(plugin_name: str, configfile: Optional[str] = None) -> Dict[str, return Config(plugin_name, configfile=configfile).config_dict -BatchType = Union["DetectorBatch", "AlignerBatch", "MaskerBatch"] +BatchType = Union["DetectorBatch", "AlignerBatch", "MaskerBatch", "RecogBatch"] @dataclass diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 0d0610b33f..9e120cf31b 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -33,6 +33,7 @@ from plugins.extract.detect._base import Detector from plugins.extract.align._base import Aligner from plugins.extract.mask._base import Masker + from plugins.extract.recognition._base import Identity logger = logging.getLogger(__name__) # pylint:disable=invalid-name _INSTANCES = -1 # Tracking for multiple instances of pipeline @@ -61,6 +62,9 @@ class Extractor(): masker: str or list or ``None The name of a masker plugin(s) as exists in :mod:`plugins.extract.mask`. This can be a single masker or a list of multiple maskers + recognition: str or ``None`` + The name of the recognition plugin to use. ``None`` to not do face recognition. + Default: ``None`` configfile: str, optional The path to a custom ``extract.ini`` configfile. If ``None`` then the system :file:`config/extract.ini` file will be used. @@ -101,6 +105,7 @@ def __init__(self, detector: Optional[str], aligner: Optional[str], masker: Optional[Union[str, List[str]]], + recognition: Optional[str] = None, configfile: Optional[str] = None, multiprocess: bool = False, exclude_gpus: Optional[List[int]] = None, @@ -110,16 +115,16 @@ def __init__(self, re_feed: int = 0, disable_filter: bool = False, image_is_aligned: bool = False,) -> None: - logger.debug("Initializing %s: (detector: %s, aligner: %s, masker: %s, configfile: %s, " - "multiprocess: %s, exclude_gpus: %s, rotate_images: %s, min_size: %s, " - "normalize_method: %s, re_feed: %s, disable_filter: %s, " + logger.debug("Initializing %s: (detector: %s, aligner: %s, masker: %s, recognition: %s, " + "configfile: %s, multiprocess: %s, exclude_gpus: %s, rotate_images: %s, " + "min_size: %s, normalize_method: %s, re_feed: %s, disable_filter: %s, " "image_is_aligned: %s)", self.__class__.__name__, detector, aligner, masker, - configfile, multiprocess, exclude_gpus, rotate_images, min_size, + recognition, configfile, multiprocess, exclude_gpus, rotate_images, min_size, normalize_method, re_feed, disable_filter, image_is_aligned) self._instance = _get_instance() maskers = [cast(Optional[str], masker)] if not isinstance(masker, list) else cast(List[Optional[str]], masker) - self._flow = self._set_flow(detector, aligner, maskers) + self._flow = self._set_flow(detector, aligner, maskers, recognition) self._exclude_gpus = exclude_gpus # We only ever need 1 item in each queue. This is 2 items cached (1 in queue 1 waiting # for queue) at each point. Adding more just stacks RAM with no speed benefit. @@ -133,6 +138,7 @@ def __init__(self, normalize_method, re_feed, disable_filter) + self._recognition = self._load_recognition(recognition, configfile) self._mask = [self._load_mask(mask, image_is_aligned, configfile) for mask in maskers] self._is_parallel = self._set_parallel_processing(multiprocess) self._phases = self._set_phases(multiprocess) @@ -390,7 +396,8 @@ def _active_plugins(self) -> List["PluginExtractor"]: @staticmethod def _set_flow(detector: Optional[str], aligner: Optional[str], - masker: List[Optional[str]]) -> List[str]: + masker: List[Optional[str]], + recognition: Optional[str]) -> List[str]: """ Set the flow list based on the input plugins Parameters @@ -402,13 +409,18 @@ def _set_flow(detector: Optional[str], masker: str or list or ``None The name of a masker plugin(s) as exists in :mod:`plugins.extract.mask`. This can be a single masker or a list of multiple maskers + recognition: str or ``None`` + The name of the recognition plugin to use. ``None`` to not do face recognition. """ - logger.debug("detector: %s, aligner: %s, masker: %s", detector, aligner, masker) + logger.debug("detector: %s, aligner: %s, masker: %s recognition: %s", + detector, aligner, masker, recognition) retval = [] if detector is not None and detector.lower() != "none": retval.append("detect") if aligner is not None and aligner.lower() != "none": retval.append("align") + if recognition is not None and recognition.lower() != "none": + retval.append("recognition") retval.extend([f"mask_{idx}" for idx, mask in enumerate(masker) if mask is not None and mask.lower() != "none"]) @@ -627,6 +639,20 @@ def _load_mask(self, instance=self._instance) return plugin + def _load_recognition(self, + recognition: Optional[str], + configfile: Optional[str]) -> Optional["Identity"]: + """ Set global arguments and load recognition plugin """ + if recognition is None or recognition.lower() == "none": + logger.debug("No recognition selected. Returning None") + return None + recognition_name = recognition.replace("-", "_").lower() + logger.debug("Loading Recognition: '%s'", recognition_name) + plugin = PluginLoader.get_recognition(recognition_name)(exclude_gpus=self._exclude_gpus, + configfile=configfile, + instance=self._instance) + return plugin + def _launch_plugin(self, phase: str) -> None: """ Launch an extraction plugin """ logger.debug("Launching %s plugin", phase) diff --git a/plugins/extract/recognition/_base.py b/plugins/extract/recognition/_base.py new file mode 100644 index 0000000000..d6359151f0 --- /dev/null +++ b/plugins/extract/recognition/_base.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" Base class for Face Recognition plugins + +All Recognition Plugins should inherit from this class. +See the override methods for which methods are required. + +The plugin will receive a :class:`~plugins.extract.pipeline.ExtractMedia` object. + +For each source frame, the plugin must pass a dict to finalize containing: + +>>> {'filename': , +>>> 'detected_faces': >> face = self.to_detected_face(, , , ) +""" +import logging + +from dataclasses import dataclass, field +from typing import Generator, List, Optional, Tuple, TYPE_CHECKING + +import numpy as np +from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa + +from lib.align import AlignedFace +from lib.utils import FaceswapError, get_backend +from plugins.extract._base import BatchType, Extractor, ExtractorBatch +from plugins.extract.pipeline import ExtractMedia + +if TYPE_CHECKING: + from queue import Queue + from lib.align import DetectedFace + from lib.align.aligned_face import CenteringType + +logger = logging.getLogger(__name__) + + +@dataclass +class RecogBatch(ExtractorBatch): + """ Dataclass for holding items flowing through the aligner. + + Inherits from :class:`~plugins.extract._base.ExtractorBatch` + """ + detected_faces: List["DetectedFace"] = field(default_factory=list) + feed_faces: List[AlignedFace] = field(default_factory=list) + + +class Identity(Extractor): # pylint:disable=abstract-method + """ Face Recognition Object + + Parent class for all Recognition plugins + + Parameters + ---------- + git_model_id: int + The second digit in the github tag that identifies this model. See + https://github.com/deepfakes-models/faceswap-models for more information + model_filename: str + The name of the model file to be loaded + image_is_aligned: bool, optional + Indicates that the passed in image is an aligned face rather than a frame. + Default: ``False`` + + Other Parameters + ---------------- + configfile: str, optional + Path to a custom configuration ``ini`` file. Default: Use system configfile + + See Also + -------- + plugins.extract.pipeline : The extraction pipeline for calling plugins + plugins.extract.detect : Detector plugins + plugins.extract._base : Parent class for all extraction plugins + plugins.extract.align._base : Aligner parent class for extraction plugins. + plugins.extract.mask._base : Masker parent class for extraction plugins. + """ + + def __init__(self, + git_model_id: Optional[int] = None, + model_filename: Optional[str] = None, + configfile: Optional[str] = None, + instance: int = 0, + image_is_aligned=False, + **kwargs): + logger.debug("Initializing %s", self.__class__.__name__) + super().__init__(git_model_id, + model_filename, + configfile=configfile, + instance=instance, + **kwargs) + self.input_size = 256 # Override for model specific input_size + self.centering: "CenteringType" = "legacy" # Override for model specific centering + self.coverage_ratio = 1.0 # Override for model specific coverage_ratio + + self._plugin_type = "recognition" + self._image_is_aligned = image_is_aligned + logger.debug("Initialized _base %s", self.__class__.__name__) + + def get_batch(self, queue: "Queue") -> Tuple[bool, RecogBatch]: + """ Get items for inputting into the recognition from the queue in batches + + Items are returned from the ``queue`` in batches of + :attr:`~plugins.extract._base.Extractor.batchsize` + + Items are received as :class:`~plugins.extract.pipeline.ExtractMedia` objects and converted + to :class:`RecogBatch` for internal processing. + + To ensure consistent batch sizes for masker the items are split into separate items for + each :class:`~lib.align.DetectedFace` object. + + Remember to put ``'EOF'`` to the out queue after processing + the final batch + + Outputs items in the following format. All lists are of length + :attr:`~plugins.extract._base.Extractor.batchsize`: + + >>> {'filename': [], + >>> 'detected_faces': [[ RecogBatch: + """ Just return the recognition's predict function """ + assert isinstance(batch, RecogBatch) + try: + # slightly hacky workaround to deal with landmarks based masks: + batch.prediction = self.predict(batch.feed) + return batch + except tf_errors.ResourceExhaustedError as err: + msg = ("You do not have enough GPU memory available to run recognition at the " + "selected batch size. You can try a number of things:" + "\n1) Close any other application that is using your GPU (web browsers are " + "particularly bad for this)." + "\n2) Lower the batchsize (the amount of images fed into the model) by " + "editing the plugin settings (GUI: Settings > Configure extract settings, " + "CLI: Edit the file faceswap/config/extract.ini)." + "\n3) Enable 'Single Process' mode.") + raise FaceswapError(msg) from err + except Exception as err: + if get_backend() == "amd": + # pylint:disable=import-outside-toplevel + from lib.plaidml_utils import is_plaidml_error + if (is_plaidml_error(err) and ( + "CL_MEM_OBJECT_ALLOCATION_FAILURE" in str(err).upper() or + "enough memory for the current schedule" in str(err).lower())): + msg = ("You do not have enough GPU memory available to run detection at " + "the selected batch size. You can try a number of things:" + "\n1) Close any other application that is using your GPU (web " + "browsers are particularly bad for this)." + "\n2) Lower the batchsize (the amount of images fed into the " + "model) by editing the plugin settings (GUI: Settings > Configure " + "extract settings, CLI: Edit the file " + "faceswap/config/extract.ini).") + raise FaceswapError(msg) from err + raise + + def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: + """ Finalize the output from Masker + + This should be called as the final task of each `plugin`. + + Pairs the detected faces back up with their original frame before yielding each frame. + + Parameters + ---------- + batch : :class:`RecogBatch` + The final batch item from the `plugin` process. + + Yields + ------ + :class:`~plugins.extract.pipeline.ExtractMedia` + The :attr:`DetectedFaces` list will be populated for this class with the bounding + boxes, landmarks and masks for the detected faces found in the frame. + """ + assert isinstance(batch, RecogBatch) + assert isinstance(self.name, str) + for identity, face in zip(batch.prediction, batch.detected_faces): + face.add_identity(self.name.lower(), identity) + del batch.feed + + logger.trace("Item out: %s", # type: ignore + {key: val.shape if isinstance(val, np.ndarray) else val + for key, val in batch.__dict__.items()}) + for filename, face in zip(batch.filename, batch.detected_faces): + self._output_faces.append(face) + if len(self._output_faces) != self._faces_per_filename[filename]: + continue + + output = self._extract_media.pop(filename) + output.add_detected_faces(self._output_faces) + self._output_faces = [] + logger.trace("Yielding: (filename: '%s', image: %s, " # type:ignore + "detected_faces: %s)", output.filename, output.image_shape, + len(output.detected_faces)) + yield output diff --git a/plugins/extract/recognition/vgg_face2_keras.py b/plugins/extract/recognition/vgg_face2.py similarity index 80% rename from plugins/extract/recognition/vgg_face2_keras.py rename to plugins/extract/recognition/vgg_face2.py index 2d6acceaa6..f7c2642714 100644 --- a/plugins/extract/recognition/vgg_face2_keras.py +++ b/plugins/extract/recognition/vgg_face2.py @@ -4,9 +4,8 @@ import logging import sys -from typing import Dict, Generator, List, Tuple, Optional +from typing import cast, Dict, Generator, List, Tuple, Optional -import cv2 import numpy as np import psutil from fastcluster import linkage, linkage_vector @@ -14,7 +13,7 @@ from lib.model.layers import L2_normalize from lib.model.session import KSession from lib.utils import FaceswapError -from plugins.extract._base import Extractor +from ._base import BatchType, RecogBatch, Identity if sys.version_info < (3, 8): @@ -25,7 +24,7 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name -class VGGFace2(Extractor): # pylint:disable=abstract-method +class Recognition(Identity): """ VGG Face feature extraction. Extracts feature vectors from faces in order to compare similarity. @@ -42,53 +41,47 @@ class VGGFace2(Extractor): # pylint:disable=abstract-method https://creativecommons.org/licenses/by-nc/4.0/ """ - def __init__(self, *args, **kwargs): # pylint:disable=unused-argument + def __init__(self, *args, **kwargs) -> None: # pylint:disable=unused-argument logger.debug("Initializing %s", self.__class__.__name__) git_model_id = 10 - model_filename = ["vggface2_resnet50_v2.h5"] + model_filename = "vggface2_resnet50_v2.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self._plugin_type = "recognition" - self.name = "VGG_Face2" + self.model: KSession + self.name: str = "VGGFace2" self.input_size = 224 - self._bins = {} + self.color_format = "BGR" + + self.vram = 2468 if not self.config["cpu"] else 0 + self.vram_warnings = 192 if not self.config["cpu"] else 0 + self.vram_per_batch = 32 if not self.config["cpu"] else 0 + self.batchsize = self.config["batch-size"] + # Average image provided in https://github.com/ox-vgg/vgg_face2 self._average_img = np.array([91.4953, 103.8827, 131.0912]) - self._iterator = self._get_bin() - logger.debug("Initialized %s", self.__class__.__name__) # <<< GET MODEL >>> # - def init_model(self): + def init_model(self) -> None: """ Initialize VGG Face 2 Model. """ + assert isinstance(self.model_path, str) model_kwargs = dict(custom_objects={'L2_normalize': L2_normalize}) self.model = KSession(self.name, self.model_path, model_kwargs=model_kwargs, allow_growth=self.config["allow_growth"], - exclude_gpus=self._exclude_gpus) + exclude_gpus=self._exclude_gpus, + cpu_mode=self.config["cpu"]) self.model.load_model() - @classmethod - def _get_bin(cls) -> Generator[int, None, None]: - """ Generator that yields incremented integers - - Yields - ------ - int - An integer 1 larger than the last integer returned - """ + def process_input(self, batch: BatchType) -> None: + """ Compile the detected faces for prediction """ + assert isinstance(batch, RecogBatch) + batch.feed = np.array([cast(np.ndarray, feed.face)[..., :3] + for feed in batch.feed_faces], + dtype="float32") - self._average_img + logger.trace("feed shape: %s", batch.feed.shape) # type:ignore - i = 0 - while True: - yield i - i += 1 - - @property - def next_bin(self) -> int: - """ int: The next available bin id in the iterator """ - return next(self._iterator) - - def predict(self, batch): + def predict(self, feed: np.ndarray) -> np.ndarray: """ Return encodings for given image from vgg_face2. Parameters @@ -101,51 +94,13 @@ def predict(self, batch): numpy.ndarray The encodings for the face """ - face = batch - if face.shape[0] != self.input_size: - face = self._resize_face(face) - face = face[None, :, :, :3] - self._average_img - preds = self.model.predict(face) - return preds[0, :] - - def _resize_face(self, face): - """ Resize incoming face to model_input_size. - - Parameters - ---------- - face: numpy.ndarray - The face to be fed through the predictor. Should be in BGR channel order - - Returns - ------- - numpy.ndarray - The face resized to model input size - """ - sizes = (self.input_size, self.input_size) - interpolation = cv2.INTER_CUBIC if face.shape[0] < self.input_size else cv2.INTER_AREA - face = cv2.resize(face, dsize=sizes, interpolation=interpolation) - return face - - @staticmethod - def find_cosine_similiarity(source_face, test_face): - """ Find the cosine similarity between two faces. - - Parameters - ---------- - source_face: numpy.ndarray - The first face to test against :attr:`test_face` - test_face: numpy.ndarray - The second face to test against :attr:`source_face` + retval = self.model.predict(feed) + assert isinstance(retval, np.ndarray) + return retval - Returns - ------- - float: - The cosine similarity between the two faces - """ - var_a = np.matmul(np.transpose(source_face), test_face) - var_b = np.sum(np.multiply(source_face, source_face)) - var_c = np.sum(np.multiply(test_face, test_face)) - return 1 - (var_a / (np.sqrt(var_b) * np.sqrt(var_c))) + def process_output(self, batch: BatchType) -> None: + """ No output processing for vgg_face2 """ + return class Cluster(): # pylint: disable=too-few-public-methods diff --git a/plugins/extract/recognition/vgg_face2_defaults.py b/plugins/extract/recognition/vgg_face2_defaults.py new file mode 100644 index 0000000000..91b6614032 --- /dev/null +++ b/plugins/extract/recognition/vgg_face2_defaults.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +""" + The default options for the faceswap VGG Face2 recognition plugin. + + Defaults files should be named _defaults.py + Any items placed into this file will automatically get added to the relevant config .ini files + within the faceswap/config folder. + + The following variables should be defined: + _HELPTEXT: A string describing what this plugin does + _DEFAULTS: A dictionary containing the options, defaults and meta information. The + dictionary should be defined as: + {: {}} + + should always be lower text. + dictionary requirements are listed below. + + The following keys are expected for the _DEFAULTS dict: + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid data types are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + group: [optional]. A group for grouping options together in the GUI. If not + provided this will not group this option with any others. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and data types this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and data types this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. +""" + + +_HELPTEXT = ( + "VGG Face 2 identity recognition.\n" + "A Keras port of the model trained for VGGFace2: A dataset for recognising faces across pose " + "and age. (https://arxiv.org/abs/1710.08092)" + ) + + +_DEFAULTS = { + "batch-size": dict( + default=16, + info="The batch size to use. To a point, higher batch sizes equal better performance, but " + "setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can accomodate " + "then this will automatically be lowered.", + datatype=int, + rounding=1, + min_max=(1, 64), + choices=[], + group="settings", + gui_radio=False, + fixed=True), + "cpu": dict( + default=False, + info="[Nvidia Only] VGG Face2 still runs fairly quickly on CPU on some setups. Enable " + "CPU mode here to use the CPU for this plugin to save some VRAM at a speed cost.", + datatype=bool, + group="settings"), +} diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 4ebfec50c0..6c60b3595d 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -11,6 +11,7 @@ from plugins.extract.detect._base import Detector from plugins.extract.align._base import Aligner from plugins.extract.mask._base import Masker + from plugins.extract.recognition._base import Identity from plugins.train.model._base import ModelBase from plugins.train.trainer._base import TrainerBase @@ -91,6 +92,25 @@ def get_masker(name: str, disable_logging: bool = False) -> Type["Masker"]: """ return PluginLoader._import("extract.mask", name, disable_logging) + @staticmethod + def get_recognition(name: str, disable_logging: bool = False) -> Type["Identity"]: + """ Return requested recognition plugin + + Parameters + ---------- + name: str + The name of the requested reccognition plugin + disable_logging: bool, optional + Whether to disable the INFO log message that the plugin is being imported. + Default: `False` + + Returns + ------- + :class:`plugins.extract.recognition` object: + An extraction recognition plugin + """ + return PluginLoader._import("extract.recognition", name, disable_logging) + @staticmethod def get_model(name: str, disable_logging: bool = False) -> Type["ModelBase"]: """ Return requested training model plugin diff --git a/scripts/extract.py b/scripts/extract.py index 6e48f969b9..c30c86db9d 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -50,9 +50,11 @@ def __init__(self, arguments: Namespace) -> None: normalization = None if self._args.normalization == "none" else self._args.normalization maskers = ["components", "extended"] maskers += self._args.masker if self._args.masker else [] + recognition = "vgg_face2" if arguments.identity else None self._extractor = Extractor(self._args.detector, self._args.aligner, maskers, + recognition=recognition, configfile=configfile, multiprocess=not self._args.singleprocess, exclude_gpus=self._args.exclude_gpus, diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index 2afc563c59..4850e89524 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -850,6 +850,7 @@ def post_edit_trigger(self, frame_index, face_index): face = self._frame_faces[frame_index][face_index] face.load_aligned(None, force=True) # Update average distance face.mask = self._extractor.get_masks(frame_index, face_index) + face._identity = {} aligned = AlignedFace(face.landmarks_xy, image=self._globals.current_frame["image"], diff --git a/tools/sort/sort_methods.py b/tools/sort/sort_methods.py index 88de3081cd..a661c6a5ba 100644 --- a/tools/sort/sort_methods.py +++ b/tools/sort/sort_methods.py @@ -17,7 +17,7 @@ from lib.align import AlignedFace, DetectedFace from lib.image import FacesLoader, ImagesLoader, read_image_meta_batch, update_existing_metadata from lib.utils import FaceswapError -from plugins.extract.recognition.vgg_face2_keras import Cluster, VGGFace2 as VGGFace +from plugins.extract.recognition.vgg_face2 import Cluster, Recognition as VGGFace if sys.version_info < (3, 8): from typing_extensions import Literal @@ -888,7 +888,8 @@ def score_image(self, centering="legacy", size=self._vgg_face.input_size, is_aligned=True).face - embedding = self._vgg_face.predict(face) + assert face is not None + embedding = self._vgg_face.predict(face[None, ...])[0] alignments.setdefault("identity", {})["vggface2"] = embedding.tolist() self._iterator.update_png_header(filename, alignments) self._result.append((filename, embedding)) From 403f981e0fd9c152bbbbfc73b3e7b6b9a6256a28 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 12 Oct 2022 12:03:59 +0100 Subject: [PATCH 753/981] bugfix: Catch learn-mask error when no mask type selected --- plugins/train/model/_base/model.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 1f890b8675..4ea3e4318f 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -129,6 +129,10 @@ def __init__(self, "Mask to use. Please select a mask or disable Penalized Mask " "Loss.") + if self.config["learn_mask"] and self.config["mask_type"] is None: + raise FaceswapError("'Learn Mask' has been selected but you have not chosen a Mask to " + "use. Please select a mask or disable 'Learn Mask'.") + self._mixed_precision = self.config["mixed_precision"] and get_backend() != "amd" # self._io = IO(self, model_dir, self._is_predict, self.config["save_optimizer"]) # TODO - Re-enable saving of optimizer once this bug is fixed: From a061290b1935ffa0e8d7e0201a4a9aa6f1d9b39d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 12 Oct 2022 13:17:59 +0100 Subject: [PATCH 754/981] typing: lib.gui.popup_session --- lib/gui/popup_session.py | 199 +++++++++++++++++++++++---------------- 1 file changed, 116 insertions(+), 83 deletions(-) diff --git a/lib/gui/popup_session.py b/lib/gui/popup_session.py index cbf0e2361a..08083fc5a4 100644 --- a/lib/gui/popup_session.py +++ b/lib/gui/popup_session.py @@ -5,8 +5,10 @@ import gettext import logging import tkinter as tk + +from dataclasses import dataclass, field from tkinter import ttk -from typing import List +from typing import Dict, List, Optional, Tuple, Type, Union from .control_helper import ControlBuilder, ControlPanelOption from .custom_widgets import Tooltip @@ -21,6 +23,52 @@ _ = _LANG.gettext +@dataclass +class SessionTKVars: + """ Dataclass for holding the tk variables required for the session popup + + Parameters + ---------- + buildgraph: :class:`tkinter.BooleanVar` + Trigger variable to indicate the graph should be rebuilt + status: :class:`tkinter.StringVar` + The variable holding the current status of the popup window + display: :class:`tkinter.StringVar` + Variable indicating the type of information to be displayed + scale: :class:`tkinter.StringVar` + Variable indicating whether to display as log or linear data + raw: :class:`tkinter.BooleanVar` + Variable to indicate raw data should be displayed + trend: :class:`tkinter.BooleanVar` + Variable to indicate that trend data should be displayed + avg: :class:`tkinter.BooleanVar` + Variable to indicate that rolling average data should be displayed + smoothed: :class:`tkinter.BooleanVar` + Variable to indicate that smoothed data should be displayed + outliers: :class:`tkinter.BooleanVar` + Variable to indicate that outliers should be displayed + loss_keys: dict + Dictionary of names to :class:`tkinter.BooleanVar` indicating whether specific loss items + should be displayed + avgiterations: :class:`tkinter.IntVar` + The number of iterations to use for rolling average + smoothamount: :class:`tkinter.DoubleVar` + The amount of smoothing to apply for smoothed data + """ + buildgraph = tk.BooleanVar() + status = tk.StringVar() + display = tk.StringVar() + scale = tk.StringVar() + raw = tk.BooleanVar() + trend = tk.BooleanVar() + avg = tk.BooleanVar() + smoothed = tk.BooleanVar() + outliers = tk.BooleanVar() + loss_keys: Dict[str, tk.BooleanVar] = field(default_factory=dict) + avgiterations = tk.IntVar() + smoothamount = tk.DoubleVar() + + class SessionPopUp(tk.Toplevel): """ Pop up for detailed graph/stats for selected session. @@ -34,15 +82,16 @@ def __init__(self, session_id: int, data_points: int) -> None: logger.debug("Initializing: %s: (session_id: %s, data_points: %s)", self.__class__.__name__, session_id, data_points) super().__init__() - self._thread = None # Thread for loading data in a background task + self._thread: Optional[LongRunningTask] = None # Thread for loading data in background self._default_view = "avg" if data_points > 1000 else "smoothed" self._session_id = None if session_id == "Total" else int(session_id) - self._graph_frame = None - self._graph = None - self._display_data = None + self._graph_frame = ttk.Frame(self) + self._graph: Optional[SessionGraph] = None + self._display_data: Optional[Calculations] = None self._vars = self._set_vars() + self._graph_initialised = False optsframe = self._layout_frames() @@ -56,24 +105,19 @@ def __init__(self, session_id: int, data_points: int) -> None: logger.debug("Initialized: %s", self.__class__.__name__) - def _set_vars(self) -> dict: + def _set_vars(self) -> SessionTKVars: """ Set status tkinter String variable and tkinter Boolean variable to callback when the graph is ready to build. Returns ------- - dict + :class:`SessionTKVars` The tkinter Variables for the pop up graph """ logger.debug("Setting tk graph build variable and internal variables") - - retval = dict(status=tk.StringVar()) - - var = tk.BooleanVar() - var.set(False) - var.trace("w", self._graph_build) - - retval["buildgraph"] = var + retval = SessionTKVars() + retval.buildgraph.set(False) + retval.buildgraph.trace("w", self._graph_build) return retval def _layout_frames(self) -> ttk.Frame: @@ -82,7 +126,6 @@ def _layout_frames(self) -> ttk.Frame: leftframe = ttk.Frame(self) sep = ttk.Frame(self, width=2, relief=tk.RIDGE) - self._graph_frame = ttk.Frame(self) self._graph_frame.pack(side=tk.RIGHT, fill=tk.BOTH, pady=5, expand=True) sep.pack(fill=tk.Y, side=tk.LEFT) @@ -122,7 +165,7 @@ def _opts_combobox(self, frame: ttk.Frame) -> None: choices = dict(Display=("Loss", "Rate"), Scale=("Linear", "Log")) for item in ["Display", "Scale"]: - var = tk.StringVar() + var: tk.StringVar = getattr(self._vars, item.lower()) cmbframe = ttk.Frame(frame) lblcmb = ttk.Label(cmbframe, text=f"{item}:", width=7, anchor=tk.W) @@ -132,8 +175,6 @@ def _opts_combobox(self, frame: ttk.Frame) -> None: cmd = self._option_button_reload if item == "Display" else self._graph_scale var.trace("w", cmd) - self._vars[item.lower().strip()] = var - hlp = self._set_help(item) Tooltip(cmbframe, text=hlp, wrap_length=200) @@ -160,10 +201,9 @@ def _opts_checkbuttons(self, frame: ttk.Frame) -> None: else: text = f"Show {item.title()}" - var = tk.BooleanVar() + var: tk.BooleanVar = getattr(self._vars, item) if item == self._default_view: var.set(True) - self._vars[item] = var ctl = ttk.Checkbutton(frame, variable=var, text=text) hlp = self._set_help(item) @@ -207,7 +247,7 @@ def _opts_loss_keys(self, frame: ttk.Frame) -> None: Tooltip(ctl, text=helptext, wrap_length=200) ctl.pack(side=tk.TOP, padx=5, pady=5, anchor=tk.W) - self._vars["loss_keys"] = lk_vars + self._vars.loss_keys = lk_vars logger.debug("Built Loss Key Check Buttons") def _opts_slider(self, frame: ttk.Frame) -> None: @@ -223,11 +263,11 @@ def _opts_slider(self, frame: ttk.Frame) -> None: logger.debug("Building Slider Controls") for item in ("avgiterations", "smoothamount"): if item == "avgiterations": - dtype = int + dtype: Union[Type[int], Type[float]] = int text = "Iterations to Average:" - default = 500 + default: Union[int, float] = 500 rounding = 25 - min_max = (25, 2500) + min_max: Tuple[int, Union[int, float]] = (25, 2500) elif item == "smoothamount": dtype = float text = "Smoothing Amount:" @@ -240,7 +280,7 @@ def _opts_slider(self, frame: ttk.Frame) -> None: rounding=rounding, min_max=min_max, helptext=self._set_help(item)) - self._vars[item] = slider.tk_var + setattr(self._vars, item, slider.tk_var) ControlBuilder(frame, slider, 1, 19, None, "Analysis.", True) logger.debug("Built Sliders") @@ -256,7 +296,7 @@ def _opts_buttons(self, frame: ttk.Frame) -> None: btnframe = ttk.Frame(frame) lblstatus = ttk.Label(btnframe, width=40, - textvariable=self._vars["status"], + textvariable=self._vars.status, anchor=tk.W) for btntype in ("reload", "save"): @@ -297,6 +337,7 @@ def _option_button_save(self) -> None: logger.debug("Save Cancelled") return logger.debug("Saving to: %s", savefile) + assert self._display_data is not None save_data = self._display_data.stats fieldnames = sorted(key for key in save_data.keys()) @@ -320,9 +361,10 @@ def _option_button_reload(self, *args) -> None: # pylint: disable=unused-argume if not valid: logger.debug("Invalid data") return + assert self._graph is not None self._graph.refresh(self._display_data, - self._vars["display"].get(), - self._vars["scale"].get()) + self._vars.display.get(), + self._vars.scale.get()) logger.debug("Refreshed Graph") def _graph_scale(self, *args) -> None: # pylint: disable=unused-argument @@ -333,9 +375,10 @@ def _graph_scale(self, *args) -> None: # pylint: disable=unused-argument args: tuple Required for TK Callback but unused """ + assert self._graph is not None if not self._graph_initialised: return - self._graph.set_yscale_type(self._vars["scale"].get()) + self._graph.set_yscale_type(self._vars.scale.get()) @classmethod def _set_help(cls, action: str) -> str: @@ -351,32 +394,21 @@ def _set_help(cls, action: str) -> str: str The help text for the given action """ - hlp = "" - action = action.lower() - if action == "reload": - hlp = _("Refresh graph") - elif action == "save": - hlp = _("Save display data to csv") - elif action == "avgiterations": - hlp = _("Number of data points to sample for rolling average") - elif action == "smoothamount": - hlp = _("Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing") - elif action == "outliers": - hlp = _("Flatten data points that fall more than 1 standard deviation from the mean " - "to the mean value.") - elif action == "avg": - hlp = _("Display rolling average of the data") - elif action == "smoothed": - hlp = _("Smooth the data") - elif action == "raw": - hlp = _("Display raw data") - elif action == "trend": - hlp = _("Display polynormal data trend") - elif action == "display": - hlp = _("Set the data to display") - elif action == "scale": - hlp = _("Change y-axis scale") - return hlp + lookup = dict( + reload=_("Refresh graph"), + save=_("Save display data to csv"), + avgiterations=_("Number of data points to sample for rolling average"), + smoothamount=_("Set the smoothing amount. 0 is no smoothing, 0.99 is maximum " + "smoothing"), + outliers=_("Flatten data points that fall more than 1 standard deviation from the " + "mean to the mean value."), + avg=_("Display rolling average of the data"), + smoothed=_("Smooth the data"), + raw=_("Display raw data"), + trend=_("Display polynormal data trend"), + display=_("Set the data to display"), + scale=_("Change y-axis scale")) + return lookup.get(action.lower(), "") def _compile_display_data(self) -> bool: """ Compile the data to be displayed. @@ -388,7 +420,7 @@ def _compile_display_data(self) -> bool: """ if self._thread is None: logger.debug("Compiling Display Data in background thread") - loss_keys = [key for key, val in self._vars["loss_keys"].items() + loss_keys = [key for key, val in self._vars.loss_keys.items() if val.get()] logger.debug("Selected loss_keys: %s", loss_keys) @@ -397,7 +429,7 @@ def _compile_display_data(self) -> bool: if not self._check_valid_selection(loss_keys, selections): logger.warning("No data to display. Not refreshing") return False - self._vars["status"].set("Loading Data...") + self._vars.status.set("Loading Data...") if self._graph is not None: self._graph.pack_forget() @@ -405,12 +437,12 @@ def _compile_display_data(self) -> bool: self.update_idletasks() kwargs = dict(session_id=self._session_id, - display=self._vars["display"].get(), + display=self._vars.display.get(), loss_keys=loss_keys, selections=selections, - avg_samples=self._vars["avgiterations"].get(), - smooth_amount=self._vars["smoothamount"].get(), - flatten_outliers=self._vars["outliers"].get()) + avg_samples=self._vars.avgiterations.get(), + smooth_amount=self._vars.smoothamount.get(), + flatten_outliers=self._vars.outliers.get()) self._thread = LongRunningTask(target=self._get_display_data, kwargs=kwargs, widget=self) @@ -427,14 +459,14 @@ def _compile_display_data(self) -> bool: self._thread = None if not self._check_valid_data(): logger.warning("No valid data to display. Not refreshing") - self._vars["status"].set("") + self._vars.status.set("") return False logger.debug("Compiled Display Data") - self._vars["buildgraph"].set(True) + self._vars.buildgraph.set(True) return True @classmethod - def _get_display_data(cls, **kwargs) -> None: + def _get_display_data(cls, **kwargs) -> Calculations: """ Get the display data in a LongRunningTask. Parameters @@ -464,7 +496,7 @@ def _check_valid_selection(self, loss_keys: List[str], selections: List[str]) -> bool ``True` if there is data to be displayed, otherwise ``False`` """ - display = self._vars["display"].get().lower() + display = self._vars.display.get().lower() logger.debug("Validating selection. (loss_keys: %s, selections: %s, display: %s)", loss_keys, selections, display) if not selections or (display == "loss" and not loss_keys): @@ -480,6 +512,7 @@ def _check_valid_data(self) -> bool: bool ``True` if there is data to be displayed, otherwise ``False`` """ + assert self._display_data is not None logger.debug("Validating data. %s", {key: len(val) for key, val in self._display_data.stats.items()}) if any(len(val) == 0 # pylint:disable=len-as-condition @@ -497,11 +530,10 @@ def _selections_to_list(self) -> List[str]: """ logger.debug("Compiling selections to list") selections = [] - for key, val in self._vars.items(): - if (isinstance(val, tk.BooleanVar) - and key != "outliers" - and val.get()): - selections.append(key) + for item in ("raw", "trend", "avg", "smoothed"): + var: tk.BooleanVar = getattr(self._vars, item) + if var.get(): + selections.append(item) logger.debug("Compiling selections to list: %s", selections) return selections @@ -513,25 +545,26 @@ def _graph_build(self, *args) -> None: # pylint:disable=unused-argument args: tuple Required for TK Callback but unused """ - if not self._vars["buildgraph"].get(): + if not self._vars.buildgraph.get(): return - self._vars["status"].set("Loading Data...") + self._vars.status.set("Loading Data...") logger.debug("Building Graph") self._lbl_loading.pack_forget() self.update_idletasks() if self._graph is None: - self._graph = SessionGraph(self._graph_frame, - self._display_data, - self._vars["display"].get(), - self._vars["scale"].get()) - self._graph.pack(expand=True, fill=tk.BOTH) - self._graph.build() + graph = SessionGraph(self._graph_frame, + self._display_data, + self._vars.display.get(), + self._vars.scale.get()) + graph.pack(expand=True, fill=tk.BOTH) + graph.build() + self._graph = graph self._graph_initialised = True else: self._graph.refresh(self._display_data, - self._vars["display"].get(), - self._vars["scale"].get()) + self._vars.display.get(), + self._vars.scale.get()) self._graph.pack(fill=tk.BOTH, expand=True) - self._vars["status"].set("") - self._vars["buildgraph"].set(False) + self._vars.status.set("") + self._vars.buildgraph.set(False) logger.debug("Built Graph") From 8910ae505bbfbe0d7ff09e206ef06438fef57728 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 12 Oct 2022 19:05:32 +0100 Subject: [PATCH 755/981] bugfix: fix gui initialization too early --- lib/gui/popup_session.py | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/lib/gui/popup_session.py b/lib/gui/popup_session.py index 08083fc5a4..d1511e599c 100644 --- a/lib/gui/popup_session.py +++ b/lib/gui/popup_session.py @@ -55,18 +55,18 @@ class SessionTKVars: smoothamount: :class:`tkinter.DoubleVar` The amount of smoothing to apply for smoothed data """ - buildgraph = tk.BooleanVar() - status = tk.StringVar() - display = tk.StringVar() - scale = tk.StringVar() - raw = tk.BooleanVar() - trend = tk.BooleanVar() - avg = tk.BooleanVar() - smoothed = tk.BooleanVar() - outliers = tk.BooleanVar() + buildgraph: tk.BooleanVar + status: tk.StringVar + display: tk.StringVar + scale: tk.StringVar + raw: tk.BooleanVar + trend: tk.BooleanVar + avg: tk.BooleanVar + smoothed: tk.BooleanVar + outliers: tk.BooleanVar + avgiterations: tk.IntVar + smoothamount: tk.DoubleVar loss_keys: Dict[str, tk.BooleanVar] = field(default_factory=dict) - avgiterations = tk.IntVar() - smoothamount = tk.DoubleVar() class SessionPopUp(tk.Toplevel): @@ -115,7 +115,17 @@ def _set_vars(self) -> SessionTKVars: The tkinter Variables for the pop up graph """ logger.debug("Setting tk graph build variable and internal variables") - retval = SessionTKVars() + retval = SessionTKVars(buildgraph=tk.BooleanVar(), + status=tk.StringVar(), + display=tk.StringVar(), + scale=tk.StringVar(), + raw=tk.BooleanVar(), + trend=tk.BooleanVar(), + avg=tk.BooleanVar(), + smoothed=tk.BooleanVar(), + outliers=tk.BooleanVar(), + avgiterations=tk.IntVar(), + smoothamount=tk.DoubleVar()) retval.buildgraph.set(False) retval.buildgraph.trace("w", self._graph_build) return retval From 47867a0dd424b3e31d7beead0ffdb8b37c970a9e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 13 Oct 2022 11:46:54 +0100 Subject: [PATCH 756/981] typing: lib.gui.analysis.stats --- lib/gui/analysis/stats.py | 112 +++++++++++++++++++++----------------- lib/gui/control_helper.py | 3 +- 2 files changed, 63 insertions(+), 52 deletions(-) diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index b9c8612ae4..a92a4b0299 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -13,8 +13,7 @@ from math import ceil from threading import Event -from typing import List, Optional, Tuple, Union -from typing_extensions import Self +from typing import Any, cast, Dict, List, Optional, Tuple, Union import numpy as np @@ -33,12 +32,12 @@ class GlobalSession(): """ def __init__(self) -> None: logger.debug("Initializing %s", self.__class__.__name__) - self._state = None + self._state: Dict[str, Any] = {} self._model_dir = "" self._model_name = "" - self._tb_logs = None - self._summary = None + self._tb_logs: Optional[TensorBoardLogs] = None + self._summary: Optional[SessionsSummary] = None self._is_training = False self._is_querying = Event() @@ -62,9 +61,9 @@ def model_filename(self) -> str: return os.path.join(self._model_dir, self._model_name) @property - def batch_sizes(self) -> dict: + def batch_sizes(self) -> Dict[int, int]: """ dict: The batch sizes for each session_id for the model. """ - if self._state is None: + if not self._state: return {} return {int(sess_id): sess["batchsize"] for sess_id, sess in self._state.get("sessions", {}).items()} @@ -72,19 +71,21 @@ def batch_sizes(self) -> dict: @property def full_summary(self) -> List[dict]: """ list: List of dictionaries containing summary statistics for each session id. """ + assert self._summary is not None return self._summary.get_summary_stats() @property def logging_disabled(self) -> bool: """ bool: ``True`` if logging is enabled for the currently training session otherwise ``False``. """ - if self._state is None: + if not self._state: return True return self._state["sessions"][str(self.session_ids[-1])]["no_logs"] @property def session_ids(self) -> List[int]: """ list: The sorted list of all existing session ids in the state file """ + assert self._tb_logs is not None return self._tb_logs.session_ids def _load_state_file(self) -> None: @@ -96,8 +97,8 @@ def _load_state_file(self) -> None: logger.debug("Loaded state: %s", self._state) def initialize_session(self, - model_folder: Optional[str], - model_name: Optional[str], + model_folder: str, + model_name: str, is_training: bool = False) -> None: """ Initialize a Session. @@ -106,12 +107,14 @@ def initialize_session(self, Parameters ---------- - model_folder: str, optional + model_folder: str, If loading a session manually (e.g. for the analysis tab), then the path to the model - folder must be provided. For training sessions, this should be left at ``None`` + folder must be provided. For training sessions, this should be passed through from the + launcher model_name: str, optional If loading a session manually (e.g. for the analysis tab), then the model filename - must be provided. For training sessions, this should be left at ``None`` + must be provided. For training sessions, this should be passed through from the + launcher is_training: bool, optional ``True`` if the session is being initialized for a training session, otherwise ``False``. Default: ``False`` @@ -120,6 +123,7 @@ def initialize_session(self, if self._model_dir == model_folder and self._model_name == model_name: if is_training: + assert self._tb_logs is not None self._tb_logs.set_training(is_training) self._load_state_file() self._is_training = True @@ -157,7 +161,7 @@ def clear(self) -> None: self._is_training = False - def get_loss(self, session_id: Optional[int]) -> dict: + def get_loss(self, session_id: Optional[int]) -> Dict[str, np.ndarray]: """ Obtain the loss values for the given session_id. Parameters @@ -176,13 +180,15 @@ def get_loss(self, session_id: Optional[int]) -> dict: if self._is_training: self._is_querying.set() + assert self._tb_logs is not None loss_dict = self._tb_logs.get_loss(session_id=session_id) if session_id is None: - retval = {} + all_loss: Dict[str, List[float]] = {} for key in sorted(loss_dict): for loss_key, loss in loss_dict[key].items(): - retval.setdefault(loss_key, []).extend(loss) - retval = {key: np.array(val, dtype="float32") for key, val in retval.items()} + all_loss.setdefault(loss_key, []).extend(loss) + retval: Dict[str, np.ndarray] = {key: np.array(val, dtype="float32") + for key, val in all_loss.items()} else: retval = loss_dict.get(session_id, {}) @@ -190,7 +196,8 @@ def get_loss(self, session_id: Optional[int]) -> dict: self._is_querying.clear() return retval - def get_timestamps(self, session_id: Optional[int]) -> Union[dict, np.ndarray]: + def get_timestamps(self, session_id: Optional[int]) -> Union[Dict[int, np.ndarray], + np.ndarray]: """ Obtain the time stamps keys for the given session_id. Parameters @@ -211,6 +218,7 @@ def get_timestamps(self, session_id: Optional[int]) -> Union[dict, np.ndarray]: if self._is_training: self._is_querying.set() + assert self._tb_logs is not None retval = self._tb_logs.get_timestamps(session_id=session_id) if session_id is not None: retval = retval[session_id] @@ -249,16 +257,17 @@ def get_loss_keys(self, session_id: Optional[int]) -> List[str]: loss_keys = {int(sess_id): [name for name in session["loss_names"] if name != "total"] for sess_id, session in self._state["sessions"].items()} else: + assert self._tb_logs is not None loss_keys = {sess_id: list(logs.keys()) for sess_id, logs in self._tb_logs.get_loss(session_id=session_id).items()} if session_id is None: - retval = list(set(loss_key - for session in loss_keys.values() - for loss_key in session)) + retval: List[str] = list(set(loss_key + for session in loss_keys.values() + for loss_key in session)) else: - retval = loss_keys.get(session_id) + retval = loss_keys.get(session_id, []) return retval @@ -279,8 +288,8 @@ def __init__(self, session: GlobalSession) -> None: self._session = session self._state = session._state - self._time_stats = None - self._per_session_stats = None + self._time_stats: Dict[int, Dict[str, Union[float, int]]] = {} + self._per_session_stats: List[Dict[str, Any]] = [] logger.debug("Initialized %s", self.__class__.__name__) def get_summary_stats(self) -> List[dict]: @@ -315,20 +324,21 @@ def _get_time_stats(self) -> None: If the main Session is currently training, then the training session ID is updated with the latest stats. """ - if self._time_stats is None: + if not self._time_stats: logger.debug("Collating summary time stamps") self._time_stats = { sess_id: dict(start_time=np.min(timestamps) if np.any(timestamps) else 0, end_time=np.max(timestamps) if np.any(timestamps) else 0, iterations=timestamps.shape[0] if np.any(timestamps) else 0) - for sess_id, timestamps in self._session.get_timestamps(None).items()} + for sess_id, timestamps in cast(Dict[int, np.ndarray], + self._session.get_timestamps(None)).items()} elif _SESSION.is_training: logger.debug("Updating summary time stamps for training session") session_id = _SESSION.session_ids[-1] - latest = self._session.get_timestamps(session_id) + latest = cast(np.ndarray, self._session.get_timestamps(session_id)) self._time_stats[session_id] = dict( start_time=np.min(latest) if np.any(latest) else 0, @@ -344,12 +354,12 @@ def _get_per_session_stats(self) -> None: If a training session is running, then updates the training sessions stats only. """ - if self._per_session_stats is None: + if not self._per_session_stats: logger.debug("Collating per session stats") compiled = [] for session_id in self._time_stats: logger.debug("Compiling session ID: %s", session_id) - if self._state is None: + if not self._state: logger.debug("Session state dict doesn't exist. Most likely task has been " "terminated during compilation") return @@ -377,7 +387,7 @@ def _get_per_session_stats(self) -> None: / stats["elapsed"] if stats["elapsed"] > 0 else 0) logger.debug("per_session_stats: %s", self._per_session_stats) - def _collate_stats(self, session_id: int) -> dict: + def _collate_stats(self, session_id: int) -> Dict[str, Union[int, float]]: """ Collate the session summary statistics for the given session ID. Parameters @@ -406,14 +416,14 @@ def _collate_stats(self, session_id: int) -> dict: logger.debug(retval) return retval - def _total_stats(self) -> dict: + def _total_stats(self) -> Dict[str, Union[str, int, float]]: """ Compile the Totals stats. Totals are fully calculated each time as they will change on the basis of the training session. Returns ------- - dict: + dict The Session name, start time, end time, elapsed time, rate, batch size and number of iterations for all session ids within the loaded data. """ @@ -486,8 +496,8 @@ def _convert_time(cls, timestamp: float) -> Tuple[str, str, str]: tuple (`hours`, `minutes`, `seconds`) as strings """ - hrs = int(timestamp // 3600) - hrs = f"{hrs:02d}" if hrs < 10 else str(hrs) + ihrs = int(timestamp // 3600) + hrs = f"{ihrs:02d}" if ihrs < 10 else str(ihrs) mins = f"{(int(timestamp % 3600) // 60):02d}" secs = f"{(int(timestamp % 3600) % 60):02d}" return hrs, mins, secs @@ -536,13 +546,13 @@ def __init__(self, session_id, self._loss_keys = loss_keys if isinstance(loss_keys, list) else [loss_keys] self._selections = selections if isinstance(selections, list) else [selections] self._is_totals = session_id is None - self._args = dict(avg_samples=avg_samples, - smooth_amount=smooth_amount, - flatten_outliers=flatten_outliers) + self._args: Dict[str, Union[int, float]] = dict(avg_samples=avg_samples, + smooth_amount=smooth_amount, + flatten_outliers=flatten_outliers) self._iterations = 0 self._limit = 0 self._start_iteration = 0 - self._stats = {} + self._stats: Dict[str, np.ndarray] = {} self.refresh() logger.debug("Initialized %s", self.__class__.__name__) @@ -557,11 +567,11 @@ def start_iteration(self) -> int: return self._start_iteration @property - def stats(self) -> dict: + def stats(self) -> Dict[str, np.ndarray]: """ dict: The final calculated statistics """ return self._stats - def refresh(self) -> Optional[Self]: + def refresh(self) -> Optional["Calculations"]: """ Refresh the stats """ logger.debug("Refreshing") if not _SESSION.is_loaded: @@ -658,11 +668,11 @@ def _get_raw(self) -> None: if len(iterations) > 1: # Crop all losses to the same number of items if self._iterations == 0: - self.stats = {lossname: np.array([], dtype=loss.dtype) - for lossname, loss in self.stats.items()} + self._stats = {lossname: np.array([], dtype=loss.dtype) + for lossname, loss in self.stats.items()} else: - self.stats = {lossname: loss[:self._iterations] - for lossname, loss in self.stats.items()} + self._stats = {lossname: loss[:self._iterations] + for lossname, loss in self.stats.items()} else: # Rate calculation data = self._calc_rate_total() if self._is_totals else self._calc_rate() @@ -719,8 +729,8 @@ def _calc_rate(self) -> np.ndarray: The training rate for each iteration of the selected session """ logger.debug("Calculating rate") - retval = (_SESSION.batch_sizes[self._session_id] * 2) / np.diff(_SESSION.get_timestamps( - self._session_id)) + batch_size = _SESSION.batch_sizes[self._session_id] * 2 + retval = batch_size / np.diff(cast(np.ndarray, _SESSION.get_timestamps(self._session_id))) logger.debug("Calculated rate: Item_count: %s", len(retval)) return retval @@ -740,8 +750,8 @@ def _calc_rate_total(cls) -> np.ndarray: """ logger.debug("Calculating totals rate") batchsizes = _SESSION.batch_sizes - total_timestamps = _SESSION.get_timestamps(None) - rate = [] + total_timestamps = cast(Dict[int, np.ndarray], _SESSION.get_timestamps(None)) + rate: List[float] = [] for sess_id in sorted(total_timestamps.keys()): batchsize = batchsizes[sess_id] timestamps = total_timestamps[sess_id] @@ -781,7 +791,7 @@ def _calc_avg(self, data: np.ndarray) -> np.ndarray: The moving average for the given data """ logger.debug("Calculating Average. Data points: %s", len(data)) - window = self._args["avg_samples"] + window = cast(int, self._args["avg_samples"]) pad = ceil(window / 2) datapoints = data.shape[0] @@ -968,8 +978,8 @@ def _ewma_vectorized(self, out /= scaling_factors[-2::-1] # cumulative sums / scaling if offset != 0: - offset = np.array(offset, copy=False).astype(self._dtype, copy=False) - out += offset * scaling_factors[1:] + noffset = np.array(offset, copy=False).astype(self._dtype, copy=False) + out += noffset * scaling_factors[1:] def _ewma_vectorized_2d(self, data: np.ndarray, out: np.ndarray) -> None: """ Calculates the exponential moving average over the last axis. diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 3b471c22b0..a3dabc272a 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -8,6 +8,7 @@ from tkinter import colorchooser, ttk from itertools import zip_longest from functools import partial +from typing import Any, Dict from _tkinter import Tcl_Obj, TclError @@ -23,7 +24,7 @@ # We store Tooltips, ContextMenus and Commands globally when they are created # Because we need to add them back to newly cloned widgets (they are not easily accessible from # original config or are prone to getting destroyed when the original widget is destroyed) -_RECREATE_OBJECTS = dict(tooltips={}, commands={}, contextmenus={}) +_RECREATE_OBJECTS: Dict[str, Dict[str, Any]] = dict(tooltips={}, commands={}, contextmenus={}) def _get_tooltip(widget, text=None, text_variable=None): From f32f460e3fc91ddb60b24bd048e275ed48036c61 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 15 Oct 2022 19:17:24 +0100 Subject: [PATCH 757/981] Refactoring - Change saved previews to PNG - Split lib.gui.utils - Disable flake8 on __init__ imports --- lib/gui/__init__.py | 16 +- lib/gui/utils.py | 1490 --------------------------------- lib/gui/utils/__init__.py | 7 + lib/gui/utils/config.py | 420 ++++++++++ lib/gui/utils/file_handler.py | 342 ++++++++ lib/gui/utils/image.py | 659 +++++++++++++++ lib/gui/utils/misc.py | 107 +++ scripts/train.py | 9 +- setup.cfg | 1 + 9 files changed, 1548 insertions(+), 1503 deletions(-) delete mode 100644 lib/gui/utils.py create mode 100644 lib/gui/utils/__init__.py create mode 100644 lib/gui/utils/config.py create mode 100644 lib/gui/utils/file_handler.py create mode 100644 lib/gui/utils/image.py create mode 100644 lib/gui/utils/misc.py diff --git a/lib/gui/__init__.py b/lib/gui/__init__.py index 020121f8b5..22697f72e6 100644 --- a/lib/gui/__init__.py +++ b/lib/gui/__init__.py @@ -1,12 +1,12 @@ #!/usr/bin python3 """ The Faceswap GUI """ -from lib.gui.command import CommandNotebook # noqa -from lib.gui.custom_widgets import ConsoleOut, StatusBar # noqa -from lib.gui.display import DisplayNotebook # noqa -from lib.gui.options import CliOptions # noqa -from lib.gui.menu import MainMenuBar, TaskBar # noqa -from lib.gui.project import LastSession # noqa -from lib.gui.utils import (get_config, get_images, initialize_config, initialize_images, # noqa +from lib.gui.command import CommandNotebook +from lib.gui.custom_widgets import ConsoleOut, StatusBar +from lib.gui.display import DisplayNotebook +from lib.gui.options import CliOptions +from lib.gui.menu import MainMenuBar, TaskBar +from lib.gui.project import LastSession +from lib.gui.utils import (get_config, get_images, initialize_config, initialize_images, preview_trigger) -from lib.gui.wrapper import ProcessWrapper # noqa +from lib.gui.wrapper import ProcessWrapper diff --git a/lib/gui/utils.py b/lib/gui/utils.py deleted file mode 100644 index 79f2d18764..0000000000 --- a/lib/gui/utils.py +++ /dev/null @@ -1,1490 +0,0 @@ -#!/usr/bin/env python3 -""" Utility functions for the GUI """ -from dataclasses import dataclass, field -import logging -import os -import platform -import sys -import tkinter as tk - -from tkinter import filedialog -from threading import Event, Thread -from typing import (Any, Callable, cast, Dict, IO, List, Optional, - Sequence, Tuple, Type, TYPE_CHECKING, Union) -from queue import Queue - -import numpy as np - -from PIL import Image, ImageDraw, ImageTk - -from ._config import Config as UserConfig -from .project import Project, Tasks -from .theme import Style - -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - -if TYPE_CHECKING: - from types import TracebackType - from .options import CliOptions - from .custom_widgets import StatusBar - from .command import CommandNotebook - from .command import ToolsNotebook - from lib.multithreading import _ErrorType - - -logger = logging.getLogger(__name__) # pylint: disable=invalid-name -_CONFIG: Optional["Config"] = None -_IMAGES: Optional["Images"] = None -_PREVIEW_TRIGGER: Optional["PreviewTrigger"] = None -PATHCACHE = os.path.join(os.path.realpath(os.path.dirname(sys.argv[0])), "lib", "gui", ".cache") - - -def initialize_config(root: tk.Tk, - cli_opts: "CliOptions", - statusbar: "StatusBar") -> Optional["Config"]: - """ Initialize the GUI Master :class:`Config` and add to global constant. - - This should only be called once on first GUI startup. Future access to :class:`Config` - should only be executed through :func:`get_config`. - - Parameters - ---------- - root: :class:`tkinter.Tk` - The root Tkinter object - cli_opts: :class:`lib.gui.options.CliOptions` - The command line options object - statusbar: :class:`lib.gui.custom_widgets.StatusBar` - The GUI Status bar - - Returns - ------- - :class:`Config` or ``None`` - ``None`` if the config has already been initialized otherwise the global configuration - options - """ - global _CONFIG # pylint: disable=global-statement - if _CONFIG is not None: - return None - logger.debug("Initializing config: (root: %s, cli_opts: %s, " - "statusbar: %s)", root, cli_opts, statusbar) - _CONFIG = Config(root, cli_opts, statusbar) - return _CONFIG - - -def get_config() -> "Config": - """ Get the Master GUI configuration. - - Returns - ------- - :class:`Config` - The Master GUI Config - """ - assert _CONFIG is not None - return _CONFIG - - -def initialize_images() -> None: - """ Initialize the :class:`Images` handler and add to global constant. - - This should only be called once on first GUI startup. Future access to :class:`Images` - handler should only be executed through :func:`get_images`. - """ - global _IMAGES # pylint: disable=global-statement - if _IMAGES is not None: - return - logger.debug("Initializing images") - _IMAGES = Images() - - -def get_images() -> "Images": - """ Get the Master GUI Images handler. - - Returns - ------- - :class:`Images` - The Master GUI Images handler - """ - assert _IMAGES is not None - return _IMAGES - - -_FileType = Literal["default", "alignments", "config_project", "config_task", - "config_all", "csv", "image", "ini", "state", "log", "video"] -_HandleType = Literal["open", "save", "filename", "filename_multi", "save_filename", - "context", "dir"] - - -class FileHandler(): # pylint:disable=too-few-public-methods - """ Handles all GUI File Dialog actions and tasks. - - Parameters - ---------- - handle_type: ['open', 'save', 'filename', 'filename_multi', 'save_filename', 'context', 'dir'] - The type of file dialog to return. `open` and `save` will perform the open and save actions - and return the file. `filename` returns the filename from an `open` dialog. - `filename_multi` allows for multi-selection of files and returns a list of files selected. - `save_filename` returns the filename from a `save as` dialog. `context` is a context - sensitive parameter that returns a certain dialog based on the current options. `dir` asks - for a folder location. - file_type: ['default', 'alignments', 'config_project', 'config_task', 'config_all', 'csv', \ - 'image', 'ini', 'state', 'log', 'video'] - The type of file that this dialog is for. `default` allows selection of any files. Other - options limit the file type selection - title: str, optional - The title to display on the file dialog. If `None` then the default title will be used. - Default: ``None`` - initial_folder: str, optional - The folder to initially open with the file dialog. If `None` then tkinter will decide. - Default: ``None`` - initial_file: str, optional - The filename to set with the file dialog. If `None` then tkinter no initial filename is. - specified. Default: ``None`` - command: str, optional - Required for context handling file dialog, otherwise unused. Default: ``None`` - action: str, optional - Required for context handling file dialog, otherwise unused. Default: ``None`` - variable: str, optional - Required for context handling file dialog, otherwise unused. The variable to associate - with this file dialog. Default: ``None`` - - Attributes - ---------- - return_file: str or object - The return value from the file dialog - - Example - ------- - >>> handler = FileHandler('filename', 'video', title='Select a video...') - >>> video_file = handler.return_file - >>> print(video_file) - '/path/to/selected/video.mp4' - """ - - def __init__(self, - handle_type: _HandleType, - file_type: _FileType, - title: Optional[str] = None, - initial_folder: Optional[str] = None, - initial_file: Optional[str] = None, - command: Optional[str] = None, - action: Optional[str] = None, - variable: Optional[str] = None) -> None: - logger.debug("Initializing %s: (handle_type: '%s', file_type: '%s', title: '%s', " - "initial_folder: '%s', initial_file: '%s', command: '%s', action: '%s', " - "variable: %s)", self.__class__.__name__, handle_type, file_type, title, - initial_folder, initial_file, command, action, variable) - self._handletype = handle_type - self._dummy_master = self._set_dummy_master() - self._defaults = self._set_defaults() - self._kwargs = self._set_kwargs(title, - initial_folder, - initial_file, - file_type, - command, - action, - variable) - self.return_file = getattr(self, f"_{self._handletype.lower()}")() - self._remove_dummy_master() - - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def _filetypes(self) -> Dict[str, List[Tuple[str, str]]]: - """ dict: The accepted extensions for each file type for opening/saving """ - all_files = ("All files", "*.*") - filetypes = dict( - default=[all_files], - alignments=[("Faceswap Alignments", "*.fsa"), all_files], - config_project=[("Faceswap Project files", "*.fsw"), all_files], - config_task=[("Faceswap Task files", "*.fst"), all_files], - config_all=[("Faceswap Project and Task files", "*.fst *.fsw"), all_files], - csv=[("Comma separated values", "*.csv"), all_files], - image=[("Bitmap", "*.bmp"), - ("JPG", "*.jpeg *.jpg"), - ("PNG", "*.png"), - ("TIFF", "*.tif *.tiff"), - all_files], - ini=[("Faceswap config files", "*.ini"), all_files], - json=[("JSON file", "*.json"), all_files], - model=[("Keras model files", "*.h5"), all_files], - state=[("State files", "*.json"), all_files], - log=[("Log files", "*.log"), all_files], - video=[("Audio Video Interleave", "*.avi"), - ("Flash Video", "*.flv"), - ("Matroska", "*.mkv"), - ("MOV", "*.mov"), - ("MP4", "*.mp4"), - ("MPEG", "*.mpeg *.mpg *.ts *.vob"), - ("WebM", "*.webm"), - ("Windows Media Video", "*.wmv"), - all_files]) - - # Add in multi-select options and upper case extensions for Linux - for key in filetypes: - if platform.system() == "Linux": - filetypes[key] = [item - if item[0] == "All files" - else (item[0], f"{item[1]} {item[1].upper()}") - for item in filetypes[key]] - if len(filetypes[key]) > 2: - multi = [f"{key.title()} Files"] - multi.append(" ".join([ftype[1] - for ftype in filetypes[key] if ftype[0] != "All files"])) - filetypes[key].insert(0, cast(Tuple[str, str], tuple(multi))) - return filetypes - - @property - def _contexts(self) -> Dict[str, Dict[str, Union[str, Dict[str, str]]]]: - """dict: Mapping of commands, actions and their corresponding file dialog for context - handle types. """ - return dict(effmpeg=dict(input={"extract": "filename", - "gen-vid": "dir", - "get-fps": "filename", - "get-info": "filename", - "mux-audio": "filename", - "rescale": "filename", - "rotate": "filename", - "slice": "filename"}, - output={"extract": "dir", - "gen-vid": "save_filename", - "get-fps": "nothing", - "get-info": "nothing", - "mux-audio": "save_filename", - "rescale": "save_filename", - "rotate": "save_filename", - "slice": "save_filename"})) - - @classmethod - def _set_dummy_master(cls) -> Optional[tk.Frame]: - """ Add an option to force black font on Linux file dialogs KDE issue that displays light - font on white background). - - This is a pretty hacky solution, but tkinter does not allow direct editing of file dialogs, - so we create a dummy frame and add the foreground option there, so that the file dialog can - inherit the foreground. - - Returns - ------- - tkinter.Frame or ``None`` - The dummy master frame for Linux systems, otherwise ``None`` - """ - if platform.system().lower() == "linux": - frame = tk.Frame() - frame.option_add("*foreground", "black") - retval: Optional[tk.Frame] = frame - else: - retval = None - return retval - - def _remove_dummy_master(self) -> None: - """ Destroy the dummy master widget on Linux systems. """ - if platform.system().lower() != "linux" or self._dummy_master is None: - return - self._dummy_master.destroy() - del self._dummy_master - self._dummy_master = None - - def _set_defaults(self) -> Dict[str, Optional[str]]: - """ Set the default file type for the file dialog. Generally the first found file type - will be used, but this is overridden if it is not appropriate. - - Returns - ------- - dict: - The default file extension for each file type - """ - defaults: Dict[str, Optional[str]] = { - key: next(ext for ext in val[0][1].split(" ")).replace("*", "") - for key, val in self._filetypes.items()} - defaults["default"] = None - defaults["video"] = ".mp4" - defaults["image"] = ".png" - logger.debug(defaults) - return defaults - - def _set_kwargs(self, - title: Optional[str], - initial_folder: Optional[str], - initial_file: Optional[str], - file_type: _FileType, - command: Optional[str], - action: Optional[str], - variable: Optional[str] = None - ) -> Dict[str, Union[None, tk.Frame, str, List[Tuple[str, str]]]]: - """ Generate the required kwargs for the requested file dialog browser. - - Parameters - ---------- - title: str - The title to display on the file dialog. If `None` then the default title will be used. - initial_folder: str - The folder to initially open with the file dialog. If `None` then tkinter will decide. - initial_file: str - The filename to set with the file dialog. If `None` then tkinter no initial filename - is. - file_type: ['default', 'alignments', 'config_project', 'config_task', 'config_all', \ - 'csv', 'image', 'ini', 'state', 'log', 'video'] - The type of file that this dialog is for. `default` allows selection of any files. - Other options limit the file type selection - command: str - Required for context handling file dialog, otherwise unused. - action: str - Required for context handling file dialog, otherwise unused. - variable: str, optional - Required for context handling file dialog, otherwise unused. The variable to associate - with this file dialog. Default: ``None`` - - Returns - ------- - dict: - The key word arguments for the file dialog to be launched - """ - logger.debug("Setting Kwargs: (title: %s, initial_folder: %s, initial_file: '%s', " - "file_type: '%s', command: '%s': action: '%s', variable: '%s')", - title, initial_folder, initial_file, file_type, command, action, variable) - - kwargs: Dict[str, Union[None, tk.Frame, str, - List[Tuple[str, str]]]] = dict(master=self._dummy_master) - - if self._handletype.lower() == "context": - assert command is not None and action is not None and variable is not None - self._set_context_handletype(command, action, variable) - - if title is not None: - kwargs["title"] = title - - if initial_folder is not None: - kwargs["initialdir"] = initial_folder - - if initial_file is not None: - kwargs["initialfile"] = initial_file - - if self._handletype.lower() in ( - "open", "save", "filename", "filename_multi", "save_filename"): - kwargs["filetypes"] = self._filetypes[file_type] - if self._defaults.get(file_type): - kwargs['defaultextension'] = self._defaults[file_type] - if self._handletype.lower() == "save": - kwargs["mode"] = "w" - if self._handletype.lower() == "open": - kwargs["mode"] = "r" - logger.debug("Set Kwargs: %s", kwargs) - return kwargs - - def _set_context_handletype(self, command: str, action: str, variable: str) -> None: - """ Sets the correct handle type based on context. - - Parameters - ---------- - command: str - The command that is being executed. Used to look up the context actions - action: str - The action that is being performed. Used to look up the correct file dialog - variable: str - The variable associated with this file dialog - """ - if self._contexts[command].get(variable, None) is not None: - handletype = cast(Dict[str, Dict[str, Dict[str, str]]], - self._contexts)[command][variable][action] - else: - handletype = cast(Dict[str, Dict[str, str]], - self._contexts)[command][action] - logger.debug(handletype) - self._handletype = cast(_HandleType, handletype) - - def _open(self) -> Optional[IO]: - """ Open a file. """ - logger.debug("Popping Open browser") - return filedialog.askopenfile(**self._kwargs) # type: ignore - - def _save(self) -> Optional[IO]: - """ Save a file. """ - logger.debug("Popping Save browser") - return filedialog.asksaveasfile(**self._kwargs) # type: ignore - - def _dir(self) -> str: - """ Get a directory location. """ - logger.debug("Popping Dir browser") - return filedialog.askdirectory(**self._kwargs) - - def _savedir(self) -> str: - """ Get a save directory location. """ - logger.debug("Popping SaveDir browser") - return filedialog.askdirectory(**self._kwargs) - - def _filename(self) -> str: - """ Get an existing file location. """ - logger.debug("Popping Filename browser") - return filedialog.askopenfilename(**self._kwargs) - - def _filename_multi(self) -> Tuple[str, ...]: - """ Get multiple existing file locations. """ - logger.debug("Popping Filename browser") - return filedialog.askopenfilenames(**self._kwargs) - - def _save_filename(self) -> str: - """ Get a save file location. """ - logger.debug("Popping Save Filename browser") - return filedialog.asksaveasfilename(**self._kwargs) - - @staticmethod - def _nothing() -> None: # pylint: disable=useless-return - """ Method that does nothing, used for disabling open/save pop up. """ - logger.debug("Popping Nothing browser") - return - - -class Images(): - """ The centralized image repository for holding all icons and images required by the GUI. - - This class should be initialized on GUI startup through :func:`initialize_images`. Any further - access to this class should be through :func:`get_images`. - """ - def __init__(self) -> None: - logger.debug("Initializing %s", self.__class__.__name__) - self._pathpreview = os.path.join(PATHCACHE, "preview") - self._pathoutput: Optional[str] = None - self._batch_mode = False - self._previewoutput: Optional[Tuple[Image.Image, ImageTk.PhotoImage]] = None - self._previewtrain: Dict[str, List[Union[Image.Image, - ImageTk.PhotoImage, - None, - float]]] = {} - self._previewcache: Dict[str, Union[None, float, np.ndarray, List[str]]] = dict( - modified=None, # cache for extract and convert - images=None, - filenames=[], - placeholder=None) - self._errcount = 0 - self._icons = self._load_icons() - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def previewoutput(self) -> Optional[Tuple[Image.Image, ImageTk.PhotoImage]]: - """ Tuple or ``None``: First item in the tuple is the extract or convert preview image - (:class:`PIL.Image`), the second item is the image in a format that tkinter can display - (:class:`PIL.ImageTK.PhotoImage`). - - The value of the property is ``None`` if no extract or convert task is running or there are - no files available in the output folder. """ - return self._previewoutput - - @property - def previewtrain(self) -> Dict[str, List[Union[Image.Image, ImageTk.PhotoImage, None, float]]]: - """ dict or ``None``: The training preview images. Dictionary key is the image name - (`str`). Dictionary values are a `list` of the training image (:class:`PIL.Image`), the - image formatted for tkinter display (:class:`PIL.ImageTK.PhotoImage`), the last - modification time of the image (`float`). - - The value of this property is ``None`` if training is not running or there are no preview - images available. - """ - return self._previewtrain - - @property - def icons(self) -> Dict[str, ImageTk.PhotoImage]: - """ dict: The faceswap icons for all parts of the GUI. The dictionary key is the icon - name (`str`) the value is the icon sized and formatted for display - (:class:`PIL.ImageTK.PhotoImage`). - - Example - ------- - >>> icons = get_images().icons - >>> save = icons["save"] - >>> button = ttk.Button(parent, image=save) - >>> button.pack() - """ - return self._icons - - @staticmethod - def _load_icons() -> Dict[str, ImageTk.PhotoImage]: - """ Scan the icons cache folder and load the icons into :attr:`icons` for retrieval - throughout the GUI. - - Returns - ------- - dict: - The icons formatted as described in :attr:`icons` - - """ - size = get_config().user_config_dict.get("icon_size", 16) - size = int(round(size * get_config().scaling_factor)) - icons: Dict[str, ImageTk.PhotoImage] = {} - pathicons = os.path.join(PATHCACHE, "icons") - for fname in os.listdir(pathicons): - name, ext = os.path.splitext(fname) - if ext != ".png": - continue - img = Image.open(os.path.join(pathicons, fname)) - img = ImageTk.PhotoImage(img.resize((size, size), resample=Image.HAMMING)) - icons[name] = img - logger.debug(icons) - return icons - - def set_faceswap_output_path(self, location: str, batch_mode: bool = False) -> None: - """ Set the path that will contain the output from an Extract or Convert task. - - Required so that the GUI can fetch output images to display for return in - :attr:`previewoutput`. - - Parameters - ---------- - location: str - The output location that has been specified for an Extract or Convert task - batch_mode: bool - ``True`` if extracting in batch mode otherwise False - """ - self._pathoutput = location - self._batch_mode = batch_mode - - def delete_preview(self) -> None: - """ Delete the preview files in the cache folder and reset the image cache. - - Should be called when terminating tasks, or when Faceswap starts up or shuts down. - """ - logger.debug("Deleting previews") - for item in os.listdir(self._pathpreview): - if item.startswith(".gui_training_preview") and item.endswith(".jpg"): - fullitem = os.path.join(self._pathpreview, item) - logger.debug("Deleting: '%s'", fullitem) - os.remove(fullitem) - for fname in cast(List[str], self._previewcache["filenames"]): - if os.path.basename(fname) == ".gui_preview.jpg": - logger.debug("Deleting: '%s'", fname) - try: - os.remove(fname) - except FileNotFoundError: - logger.debug("File does not exist: %s", fname) - self._clear_image_cache() - - def _clear_image_cache(self) -> None: - """ Clear all cached images. """ - logger.debug("Clearing image cache") - self._pathoutput = None - self._batch_mode = False - self._previewoutput = None - self._previewtrain = {} - self._previewcache = dict(modified=None, # cache for extract and convert - images=None, - filenames=[], - placeholder=None) - - @staticmethod - def _get_images(image_path: str) -> List[str]: - """ Get the images stored within the given directory. - - Parameters - ---------- - image_path: str - The folder containing images to be scanned - - Returns - ------- - list: - The image filenames stored within the given folder - - """ - logger.debug("Getting images: '%s'", image_path) - if not os.path.isdir(image_path): - logger.debug("Folder does not exist") - return [] - files = [os.path.join(image_path, f) - for f in os.listdir(image_path) if f.lower().endswith((".png", ".jpg"))] - logger.debug("Image files: %s", files) - return files - - def load_latest_preview(self, thumbnail_size: int, frame_dims: Tuple[int, int]) -> None: - """ Load the latest preview image for extract and convert. - - Retrieves the latest preview images from the faceswap output folder, resizes to thumbnails - and lays out for display. Places the images into :attr:`previewoutput` for loading into - the display panel. - - Parameters - ---------- - thumbnail_size: int - The size of each thumbnail that should be created - frame_dims: tuple - The (width (`int`), height (`int`)) of the display panel that will display the preview - """ - logger.debug("Loading preview image: (thumbnail_size: %s, frame_dims: %s)", - thumbnail_size, frame_dims) - assert self._pathoutput is not None - image_path = self._get_newest_folder() if self._batch_mode else self._pathoutput - image_files = self._get_images(image_path) - gui_preview = os.path.join(self._pathoutput, ".gui_preview.jpg") - if not image_files or (len(image_files) == 1 and gui_preview not in image_files): - logger.debug("No preview to display") - return - # Filter to just the gui_preview if it exists in folder output - image_files = [gui_preview] if gui_preview in image_files else image_files - logger.debug("Image Files: %s", len(image_files)) - - image_files = self._get_newest_filenames(image_files) - if not image_files: - return - - if not self._load_images_to_cache(image_files, frame_dims, thumbnail_size): - logger.debug("Failed to load any preview images") - if gui_preview in image_files: - # Reset last modified for failed loading of a gui preview image so it is picked - # up next time - self._previewcache["modified"] = None - return - - if image_files == [gui_preview]: - # Delete the preview image so that the main scripts know to output another - logger.debug("Deleting preview image") - os.remove(image_files[0]) - show_image = self._place_previews(frame_dims) - if not show_image: - self._previewoutput = None - return - logger.debug("Displaying preview: %s", self._previewcache["filenames"]) - self._previewoutput = (show_image, ImageTk.PhotoImage(show_image)) - - def _get_newest_folder(self) -> str: - """ Obtain the most recent folder created in the extraction output folder when processing - in batch mode. - - Returns - ------- - str - The most recently modified folder within the parent output folder. If no folders have - been created, returns the parent output folder - - """ - assert self._pathoutput is not None - folders = [] if not os.path.exists(self._pathoutput) else [ - os.path.join(self._pathoutput, folder) - for folder in os.listdir(self._pathoutput) - if os.path.isdir(os.path.join(self._pathoutput, folder))] - - folders.sort(key=os.path.getmtime) - retval = folders[-1] if folders else self._pathoutput - logger.debug("sorted folders: %s, return value: %s", folders, retval) - return retval - - def _get_newest_filenames(self, image_files: List[str]) -> List[str]: - """ Return image filenames that have been modified since the last check. - - Parameters - ---------- - image_files: list - The list of image files to check the modification date for - - Returns - ------- - list: - A list of images that have been modified since the last check - """ - if self._previewcache["modified"] is None: - retval = image_files - else: - retval = [fname for fname in image_files - if os.path.getmtime(fname) > cast(float, self._previewcache["modified"])] - if not retval: - logger.debug("No new images in output folder") - else: - self._previewcache["modified"] = max(os.path.getmtime(img) for img in retval) - logger.debug("Number new images: %s, Last Modified: %s", - len(retval), self._previewcache["modified"]) - return retval - - def _load_images_to_cache(self, - image_files: List[str], - frame_dims: Tuple[int, int], - thumbnail_size: int) -> bool: - """ Load preview images to the image cache. - - Load new images and append to cache, filtering the cache the number of thumbnails that will - fit inside the display panel. - - Parameters - ---------- - image_files: list - A list of new image files that have been modified since the last check - frame_dims: tuple - The (width (`int`), height (`int`)) of the display panel that will display the preview - thumbnail_size: int - The size of each thumbnail that should be created - - Returns - ------- - bool - ``True`` if images were successfully loaded to cache otherwise ``False`` - """ - logger.debug("Number image_files: %s, frame_dims: %s, thumbnail_size: %s", - len(image_files), frame_dims, thumbnail_size) - num_images = (frame_dims[0] // thumbnail_size) * (frame_dims[1] // thumbnail_size) - logger.debug("num_images: %s", num_images) - if num_images == 0: - return False - samples: List[np.ndarray] = [] - start_idx = len(image_files) - num_images if len(image_files) > num_images else 0 - show_files = sorted(image_files, key=os.path.getctime)[start_idx:] - dropped_files = [] - for fname in show_files: - try: - img = Image.open(fname) - except PermissionError as err: - logger.debug("Permission error opening preview file: '%s'. Original error: %s", - fname, str(err)) - dropped_files.append(fname) - continue - except Exception as err: # pylint:disable=broad-except - # Swallow any issues with opening an image rather than spamming console - # Can happen when trying to read partially saved images - logger.debug("Error opening preview file: '%s'. Original error: %s", - fname, str(err)) - dropped_files.append(fname) - continue - - width, height = img.size - scaling = thumbnail_size / max(width, height) - logger.debug("image width: %s, height: %s, scaling: %s", width, height, scaling) - - try: - img = img.resize((int(width * scaling), int(height * scaling))) - except OSError as err: - # Image only gets loaded when we call a method, so may error on partial loads - logger.debug("OS Error resizing preview image: '%s'. Original error: %s", - fname, err) - dropped_files.append(fname) - continue - - samples.append(self._pad_and_border(img, thumbnail_size)) - - return self._process_samples(samples, - [fname for fname in show_files if fname not in dropped_files], - num_images) - - def _pad_and_border(self, image: Image.Image, size: int) -> np.ndarray: - """ Pad rectangle images to a square and draw borders - - Parameters - ---------- - image: :class:`PIL.Image` - The image to process - size: int - The size of the image as it should be displayed - - Returns - ------- - :class:`PIL.Image`: - The processed image - """ - if image.size[0] != image.size[1]: - # Pad to square - new_img = Image.new("RGB", (size, size)) - new_img.paste(image, ((size - image.size[0]) // 2, (size - image.size[1]) // 2)) - image = new_img - draw = ImageDraw.Draw(image) - draw.rectangle(((0, 0), (size, size)), outline="#E5E5E5", width=1) - retval = np.array(image) - logger.trace("image shape: %s", retval.shape) # type: ignore - return retval - - def _process_samples(self, - samples: List[np.ndarray], - filenames: List[str], - num_images: int) -> bool: - """ Process the latest sample images into a displayable image. - - Parameters - ---------- - samples: list - The list of extract/convert preview images to display - filenames: list - The full path to the filenames corresponding to the images - num_images: int - The number of images that should be displayed - - Returns - ------- - bool - ``True`` if samples succesfully compiled otherwise ``False`` - """ - asamples = np.array(samples) - if not np.any(asamples): - logger.debug("No preview images collected.") - return False - - self._previewcache["filenames"] = (cast(List[str], self._previewcache["filenames"]) + - filenames)[-num_images:] - cache = cast(Optional[np.ndarray], self._previewcache["images"]) - if cache is None: - logger.debug("Creating new cache") - cache = asamples[-num_images:] - else: - logger.debug("Appending to existing cache") - cache = np.concatenate((cache, asamples))[-num_images:] - self._previewcache["images"] = cache - logger.debug("Cache shape: %s", cast(np.ndarray, self._previewcache["images"]).shape) - return True - - def _place_previews(self, frame_dims: Tuple[int, int]) -> Image.Image: - """ Format the preview thumbnails stored in the cache into a grid fitting the display - panel. - - Parameters - ---------- - frame_dims: tuple - The (width (`int`), height (`int`)) of the display panel that will display the preview - - Returns - ------- - :class:`PIL.Image`: - The final preview display image - """ - if self._previewcache.get("images", None) is None: - logger.debug("No images in cache. Returning None") - return None - samples = cast(np.ndarray, self._previewcache["images"]).copy() - num_images, thumbnail_size = samples.shape[:2] - if self._previewcache["placeholder"] is None: - self._create_placeholder(thumbnail_size) - - logger.debug("num_images: %s, thumbnail_size: %s", num_images, thumbnail_size) - cols, rows = frame_dims[0] // thumbnail_size, frame_dims[1] // thumbnail_size - logger.debug("cols: %s, rows: %s", cols, rows) - if cols == 0 or rows == 0: - logger.debug("Cols or Rows is zero. No items to display") - return None - remainder = (cols * rows) - num_images - if remainder != 0: - logger.debug("Padding sample display. Remainder: %s", remainder) - placeholder = np.concatenate([np.expand_dims( - cast(np.ndarray, self._previewcache["placeholder"]), 0)] * remainder) - samples = np.concatenate((samples, placeholder)) - - display = np.vstack([np.hstack(cast(Sequence, samples[row * cols: (row + 1) * cols])) - for row in range(rows)]) - logger.debug("display shape: %s", display.shape) - return Image.fromarray(display) - - def _create_placeholder(self, thumbnail_size: int) -> None: - """ Create a placeholder image for when there are fewer thumbnails available - than columns to display them. - - Parameters - ---------- - thumbnail_size: int - The size of the thumbnail that the placeholder should replicate - """ - logger.debug("Creating placeholder. thumbnail_size: %s", thumbnail_size) - placeholder = Image.new("RGB", (thumbnail_size, thumbnail_size)) - draw = ImageDraw.Draw(placeholder) - draw.rectangle(((0, 0), (thumbnail_size, thumbnail_size)), outline="#E5E5E5", width=1) - placeholder = np.array(placeholder) - self._previewcache["placeholder"] = placeholder - logger.debug("Created placeholder. shape: %s", placeholder.shape) - - def load_training_preview(self) -> None: - """ Load the training preview images. - - Reads the training image currently stored in the cache folder and loads them to - :attr:`previewtrain` for retrieval in the GUI. - """ - logger.debug("Loading Training preview images") - image_files = self._get_images(self._pathpreview) - modified = None - if not image_files: - logger.debug("No preview to display") - self._previewtrain = {} - return - for img in image_files: - modified = os.path.getmtime(img) if modified is None else modified - name = os.path.basename(img) - name = os.path.splitext(name)[0] - name = name[name.rfind("_") + 1:].title() - try: - logger.debug("Displaying preview: '%s'", img) - size = self._get_current_size(name) - self._previewtrain[name] = [Image.open(img), None, modified] - self.resize_image(name, size) - self._errcount = 0 - except ValueError: - # This is probably an error reading the file whilst it's - # being saved so ignore it for now and only pick up if - # there have been multiple consecutive fails - logger.warning("Unable to display preview: (image: '%s', attempt: %s)", - img, self._errcount) - if self._errcount < 10: - self._errcount += 1 - else: - logger.error("Error reading the preview file for '%s'", img) - print(f"Error reading the preview file for {name}") - del self._previewtrain[name] - - def _get_current_size(self, name: str) -> Optional[Tuple[int, int]]: - """ Return the size of the currently displayed training preview image. - - Parameters - ---------- - name: str - The name of the training image to get the size for - - Returns - ------- - width: int - The width of the training image - height: int - The height of the training image - """ - logger.debug("Getting size: '%s'", name) - if not self._previewtrain.get(name): - return None - img = cast(Image.Image, self._previewtrain[name][1]) - if not img: - return None - logger.debug("Got size: (name: '%s', width: '%s', height: '%s')", - name, img.width(), img.height()) - return img.width(), img.height() - - def resize_image(self, name: str, frame_dims: Optional[Tuple[int, int]]) -> None: - """ Resize the training preview image based on the passed in frame size. - - If the canvas that holds the preview image changes, update the image size - to fit the new canvas and refresh :attr:`previewtrain`. - - Parameters - ---------- - name: str - The name of the training image to be resized - frame_dims: tuple, optional - The (width (`int`), height (`int`)) of the display panel that will display the preview. - ``None`` if the frame dimensions are not known. - """ - logger.debug("Resizing image: (name: '%s', frame_dims: %s", name, frame_dims) - displayimg = cast(Image.Image, self._previewtrain[name][0]) - if frame_dims: - frameratio = float(frame_dims[0]) / float(frame_dims[1]) - imgratio = float(displayimg.size[0]) / float(displayimg.size[1]) - - if frameratio <= imgratio: - scale = frame_dims[0] / float(displayimg.size[0]) - size = (frame_dims[0], int(displayimg.size[1] * scale)) - else: - scale = frame_dims[1] / float(displayimg.size[1]) - size = (int(displayimg.size[0] * scale), frame_dims[1]) - logger.debug("Scaling: (scale: %s, size: %s", scale, size) - - # Hacky fix to force a reload if it happens to find corrupted - # data, probably due to reading the image whilst it is partially - # saved. If it continues to fail, then eventually raise. - for i in range(0, 1000): - try: - displayimg = displayimg.resize(size, Image.ANTIALIAS) - except OSError: - if i == 999: - raise - continue - break - self._previewtrain[name][1] = ImageTk.PhotoImage(displayimg) - - -@dataclass -class _GuiObjects: - """ Data class for commonly accessed GUI Objects """ - cli_opts: "CliOptions" - tk_vars: Dict[str, Union[tk.BooleanVar, tk.StringVar]] - project: Project - tasks: Tasks - status_bar: "StatusBar" - default_options: Dict[str, Dict[str, Any]] = field(default_factory=dict) - command_notebook: Optional["CommandNotebook"] = None - - -class Config(): - """ The centralized configuration class for holding items that should be made available to all - parts of the GUI. - - This class should be initialized on GUI startup through :func:`initialize_config`. Any further - access to this class should be through :func:`get_config`. - - Parameters - ---------- - root: :class:`tkinter.Tk` - The root Tkinter object - cli_opts: :class:`lib.gui.options.CliOpts` - The command line options object - statusbar: :class:`lib.gui.custom_widgets.StatusBar` - The GUI Status bar - """ - def __init__(self, root: tk.Tk, cli_opts: "CliOptions", statusbar: "StatusBar") -> None: - logger.debug("Initializing %s: (root %s, cli_opts: %s, statusbar: %s)", - self.__class__.__name__, root, cli_opts, statusbar) - self._default_font = cast(dict, tk.font.nametofont("TkDefaultFont").configure())["family"] - self._constants = dict( - root=root, - scaling_factor=self._get_scaling(root), - default_font=self._default_font) - self._gui_objects = _GuiObjects( - cli_opts=cli_opts, - tk_vars=self._set_tk_vars(), - project=Project(self, FileHandler), - tasks=Tasks(self, FileHandler), - status_bar=statusbar) - - self._user_config = UserConfig(None) - self._style = Style(self.default_font, root, PATHCACHE) - self._user_theme = self._style.user_theme - logger.debug("Initialized %s", self.__class__.__name__) - - # Constants - @property - def root(self) -> tk.Tk: - """ :class:`tkinter.Tk`: The root tkinter window. """ - return self._constants["root"] - - @property - def scaling_factor(self) -> float: - """ float: The scaling factor for current display. """ - return self._constants["scaling_factor"] - - @property - def pathcache(self) -> str: - """ str: The path to the GUI cache folder """ - return PATHCACHE - - # GUI Objects - @property - def cli_opts(self) -> "CliOptions": - """ :class:`lib.gui.options.CliOptions`: The command line options for this GUI Session. """ - return self._gui_objects.cli_opts - - @property - def tk_vars(self) -> Dict[str, Union[tk.StringVar, tk.BooleanVar]]: - """ dict: The global tkinter variables. """ - return self._gui_objects.tk_vars - - @property - def project(self) -> Project: - """ :class:`lib.gui.project.Project`: The project session handler. """ - return self._gui_objects.project - - @property - def tasks(self) -> Tasks: - """ :class:`lib.gui.project.Tasks`: The session tasks handler. """ - return self._gui_objects.tasks - - @property - def default_options(self) -> Dict[str, Dict[str, Any]]: - """ dict: The default options for all tabs """ - return self._gui_objects.default_options - - @property - def statusbar(self) -> "StatusBar": - """ :class:`lib.gui.custom_widgets.StatusBar`: The GUI StatusBar - :class:`tkinter.ttk.Frame`. """ - return self._gui_objects.status_bar - - @property - def command_notebook(self) -> Optional["CommandNotebook"]: - """ :class:`lib.gui.command.CommandNotebook`: The main Faceswap Command Notebook. """ - return self._gui_objects.command_notebook - - # Convenience GUI Objects - @property - def tools_notebook(self) -> "ToolsNotebook": - """ :class:`lib.gui.command.ToolsNotebook`: The Faceswap Tools sub-Notebook. """ - assert self.command_notebook is not None - return self.command_notebook.tools_notebook - - @property - def modified_vars(self) -> Dict[str, "tk.BooleanVar"]: - """ dict: The command notebook modified tkinter variables. """ - assert self.command_notebook is not None - return self.command_notebook.modified_vars - - @property - def _command_tabs(self) -> Dict[str, int]: - """ dict: Command tab titles with their IDs. """ - assert self.command_notebook is not None - return self.command_notebook.tab_names - - @property - def _tools_tabs(self) -> Dict[str, int]: - """ dict: Tools command tab titles with their IDs. """ - assert self.command_notebook is not None - return self.command_notebook.tools_tab_names - - # Config - @property - def user_config(self) -> UserConfig: - """ dict: The GUI config in dict form. """ - return self._user_config - - @property - def user_config_dict(self) -> Dict[str, Any]: # TODO Dataclass - """ dict: The GUI config in dict form. """ - return self._user_config.config_dict - - @property - def user_theme(self) -> Dict[str, Any]: # TODO Dataclass - """ dict: The GUI theme selection options. """ - return self._user_theme - - @property - def default_font(self) -> Tuple[str, int]: - """ tuple: The selected font as configured in user settings. First item is the font (`str`) - second item the font size (`int`). """ - font = self.user_config_dict["font"] - font = self._default_font if font == "default" else font - return (font, self.user_config_dict["font_size"]) - - @staticmethod - def _get_scaling(root) -> float: - """ Get the display DPI. - - Returns - ------- - float: - The scaling factor - """ - dpi = root.winfo_fpixels("1i") - scaling = dpi / 72.0 - logger.debug("dpi: %s, scaling: %s'", dpi, scaling) - return scaling - - def set_default_options(self) -> None: - """ Set the default options for :mod:`lib.gui.projects` - - The Default GUI options are stored on Faceswap startup. - - Exposed as the :attr:`_default_opts` for a project cannot be set until after the main - Command Tabs have been loaded. - """ - default = self.cli_opts.get_option_values() - logger.debug(default) - self._gui_objects.default_options = default - self.project.set_default_options() - - def set_command_notebook(self, notebook: "CommandNotebook") -> None: - """ Set the command notebook to the :attr:`command_notebook` attribute - and enable the modified callback for :attr:`project`. - - Parameters - ---------- - notebook: :class:`lib.gui.command.CommandNotebook` - The main command notebook for the Faceswap GUI - """ - logger.debug("Setting commane notebook: %s", notebook) - self._gui_objects.command_notebook = notebook - self.project.set_modified_callback() - - def set_active_tab_by_name(self, name: str) -> None: - """ Sets the :attr:`command_notebook` or :attr:`tools_notebook` to active based on given - name. - - Parameters - ---------- - name: str - The name of the tab to set active - """ - assert self.command_notebook is not None - name = name.lower() - if name in self._command_tabs: - tab_id = self._command_tabs[name] - logger.debug("Setting active tab to: (name: %s, id: %s)", name, tab_id) - self.command_notebook.select(tab_id) - elif name in self._tools_tabs: - self.command_notebook.select(self._command_tabs["tools"]) - tab_id = self._tools_tabs[name] - logger.debug("Setting active Tools tab to: (name: %s, id: %s)", name, tab_id) - self.tools_notebook.select() - else: - logger.debug("Name couldn't be found. Setting to id 0: %s", name) - self.command_notebook.select(0) - - def set_modified_true(self, command: str) -> None: - """ Set the modified variable to ``True`` for the given command in :attr:`modified_vars`. - - Parameters - ---------- - command: str - The command to set the modified state to ``True`` - - """ - tkvar = self.modified_vars.get(command, None) - if tkvar is None: - logger.debug("No tkvar for command: '%s'", command) - return - tkvar.set(True) - logger.debug("Set modified var to True for: '%s'", command) - - def refresh_config(self) -> None: - """ Reload the user config from file. """ - self._user_config = UserConfig(None) - - def set_cursor_busy(self, widget: Optional[tk.Widget] = None) -> None: - """ Set the root or widget cursor to busy. - - Parameters - ---------- - widget: tkinter object, optional - The widget to set busy cursor for. If the provided value is ``None`` then sets the - cursor busy for the whole of the GUI. Default: ``None``. - """ - logger.debug("Setting cursor to busy. widget: %s", widget) - component = self.root if widget is None else widget - component.config(cursor="watch") # type: ignore - component.update_idletasks() - - def set_cursor_default(self, widget: Optional[tk.Widget] = None) -> None: - """ Set the root or widget cursor to default. - - Parameters - ---------- - widget: tkinter object, optional - The widget to set default cursor for. If the provided value is ``None`` then sets the - cursor busy for the whole of the GUI. Default: ``None`` - """ - logger.debug("Setting cursor to default. widget: %s", widget) - component = self.root if widget is None else widget - component.config(cursor="") # type: ignore - component.update_idletasks() - - @staticmethod - def _set_tk_vars() -> Dict[str, Union[tk.StringVar, tk.BooleanVar]]: - """ Set the global tkinter variables stored for easy access in :class:`Config`. - - The variables are available through :attr:`tk_vars`. - """ - display = tk.StringVar() - display.set("") - - runningtask = tk.BooleanVar() - runningtask.set(False) - - istraining = tk.BooleanVar() - istraining.set(False) - - actioncommand = tk.StringVar() - actioncommand.set("") - - generatecommand = tk.StringVar() - generatecommand.set("") - - console_clear = tk.BooleanVar() - console_clear.set(False) - - refreshgraph = tk.BooleanVar() - refreshgraph.set(False) - - updatepreview = tk.BooleanVar() - updatepreview.set(False) - - analysis_folder = tk.StringVar() - analysis_folder.set("") - - tk_vars: Dict[str, Union[tk.StringVar, tk.BooleanVar]] = dict( - display=display, - runningtask=runningtask, - istraining=istraining, - action=actioncommand, - generate=generatecommand, - console_clear=console_clear, - refreshgraph=refreshgraph, - updatepreview=updatepreview, - analysis_folder=analysis_folder) - logger.debug(tk_vars) - return tk_vars - - def set_root_title(self, text: Optional[str] = None) -> None: - """ Set the main title text for Faceswap. - - The title will always begin with 'Faceswap.py'. Additional text can be appended. - - Parameters - ---------- - text: str, optional - Additional text to be appended to the GUI title bar. Default: ``None`` - """ - title = "Faceswap.py" - title += f" - {text}" if text is not None and text else "" - self.root.title(title) - - def set_geometry(self, width: int, height: int, fullscreen: bool = False) -> None: - """ Set the geometry for the root tkinter object. - - Parameters - ---------- - width: int - The width to set the window to (prior to scaling) - height: int - The height to set the window to (prior to scaling) - fullscreen: bool, optional - Whether to set the window to full-screen mode. If ``True`` then :attr:`width` and - :attr:`height` are ignored. Default: ``False`` - """ - self.root.tk.call("tk", "scaling", self.scaling_factor) - if fullscreen: - initial_dimensions = (self.root.winfo_screenwidth(), self.root.winfo_screenheight()) - else: - initial_dimensions = (round(width * self.scaling_factor), - round(height * self.scaling_factor)) - - if fullscreen and sys.platform in ("win32", "darwin"): - self.root.state('zoomed') - elif fullscreen: - self.root.attributes('-zoomed', True) - else: - self.root.geometry(f"{str(initial_dimensions[0])}x{str(initial_dimensions[1])}+80+80") - logger.debug("Geometry: %sx%s", *initial_dimensions) - - -class LongRunningTask(Thread): - """ Runs long running tasks in a background thread to prevent the GUI from becoming - unresponsive. - - This is sub-classed from :class:`Threading.Thread` so check documentation there for base - parameters. Additional parameters listed below. - - Parameters - ---------- - widget: tkinter object, optional - The widget that this :class:`LongRunningTask` is associated with. Used for setting the busy - cursor in the correct location. Default: ``None``. - """ - _target: Callable - _args: Tuple - _kwargs: Dict[str, Any] - _name: str - - def __init__(self, - target: Optional[Callable] = None, - name: Optional[str] = None, - args: Tuple = (), - kwargs: Optional[Dict[str, Any]] = None, - *, - daemon: bool = True, - widget=None): - logger.debug("Initializing %s: (target: %s, name: %s, args: %s, kwargs: %s, " - "daemon: %s)", self.__class__.__name__, target, name, args, kwargs, - daemon) - super().__init__(target=target, name=name, args=args, kwargs=kwargs, - daemon=daemon) - self.err: "_ErrorType" = None - self._widget = widget - self._config = get_config() - self._config.set_cursor_busy(widget=self._widget) - self._complete = Event() - self._queue: Queue = Queue() - logger.debug("Initialized %s", self.__class__.__name__,) - - @property - def complete(self) -> Event: - """ :class:`threading.Event`: Event is set if the thread has completed its task, - otherwise it is unset. - """ - return self._complete - - def run(self) -> None: - """ Commence the given task in a background thread. """ - try: - if self._target: - retval = self._target(*self._args, **self._kwargs) - self._queue.put(retval) - except Exception: # pylint: disable=broad-except - self.err = cast(Tuple[Type[BaseException], BaseException, "TracebackType"], - sys.exc_info()) - assert self.err is not None - logger.debug("Error in thread (%s): %s", self._name, - self.err[1].with_traceback(self.err[2])) - finally: - self._complete.set() - # Avoid a ref-cycle if the thread is running a function with - # an argument that has a member that points to the thread. - del self._target, self._args, self._kwargs - - def get_result(self) -> Any: - """ Return the result from the given task. - - Returns - ------- - varies: - The result of the thread will depend on the given task. If a call is made to - :func:`get_result` prior to the thread completing its task then ``None`` will be - returned - """ - if not self._complete.is_set(): - logger.warning("Aborting attempt to retrieve result from a LongRunningTask that is " - "still running") - return None - if self.err: - logger.debug("Error caught in thread") - self._config.set_cursor_default(widget=self._widget) - raise self.err[1].with_traceback(self.err[2]) - - logger.debug("Getting result from thread") - retval = self._queue.get() - logger.debug("Got result from thread") - self._config.set_cursor_default(widget=self._widget) - return retval - - -class PreviewTrigger(): - """ Triggers to indicate to underlying Faceswap process that the preview image should - be updated. - - Writes a file to the cache folder that is picked up by the main process. - """ - def __init__(self) -> None: - logger.debug("Initializing: %s", self.__class__.__name__) - self._trigger_files = dict(update=os.path.join(PATHCACHE, ".preview_trigger"), - mask_toggle=os.path.join(PATHCACHE, ".preview_mask_toggle")) - logger.debug("Initialized: %s (trigger_files: %s)", - self.__class__.__name__, self._trigger_files) - - def set(self, trigger_type: Literal["update", "mask_toggle"]): - """ Place the trigger file into the cache folder - - Parameters - ---------- - trigger_type: ["update", "mask_toggle"] - The type of action to trigger. 'update': Full preview update. 'mask_toggle': toggle - mask on and off - """ - trigger = self._trigger_files[trigger_type] - if not os.path.isfile(trigger): - with open(trigger, "w", encoding="utf8"): - pass - logger.debug("Set preview trigger: %s", trigger) - - def clear(self, trigger_type: Optional[Literal["update", "mask_toggle"]] = None) -> None: - """ Remove the trigger file from the cache folder. - - Parameters - ---------- - trigger_type: ["update", "mask_toggle", ``None``], optional - The trigger to clear. 'update': Full preview update. 'mask_toggle': toggle mask on - and off. ``None`` - clear all triggers. Default: ``None`` - """ - if trigger_type is None: - triggers = list(self._trigger_files.values()) - else: - triggers = [self._trigger_files[trigger_type]] - for trigger in triggers: - if os.path.isfile(trigger): - os.remove(trigger) - logger.debug("Removed preview trigger: %s", trigger) - - -def preview_trigger() -> PreviewTrigger: - """ Set the global preview trigger if it has not already been set and return. - - Returns - ------- - :class:`PreviewTrigger` - The trigger to indicate to the main faceswap process that it should perform a training - preview update - """ - global _PREVIEW_TRIGGER # pylint:disable=global-statement - if _PREVIEW_TRIGGER is None: - _PREVIEW_TRIGGER = PreviewTrigger() - return _PREVIEW_TRIGGER diff --git a/lib/gui/utils/__init__.py b/lib/gui/utils/__init__.py new file mode 100644 index 0000000000..24e46983d7 --- /dev/null +++ b/lib/gui/utils/__init__.py @@ -0,0 +1,7 @@ +#!/usr/bin python3 +""" Utilities for the Faceswap GUI """ + +from .config import get_config, initialize_config, PATHCACHE +from .file_handler import FileHandler +from .image import get_images, initialize_images, preview_trigger +from .misc import LongRunningTask diff --git a/lib/gui/utils/config.py b/lib/gui/utils/config.py new file mode 100644 index 0000000000..00a49414a8 --- /dev/null +++ b/lib/gui/utils/config.py @@ -0,0 +1,420 @@ +#!/usr/bin python3 +""" Global configuration optiopns for the Faceswap GUI """ +import logging +import os +import sys +import tkinter as tk + +from dataclasses import dataclass, field +from typing import Any, cast, Dict, Optional, Tuple, TYPE_CHECKING, Union + +from lib.gui._config import Config as UserConfig +from lib.gui.project import Project, Tasks +from lib.gui.theme import Style +from .file_handler import FileHandler + +if TYPE_CHECKING: + from lib.gui.options import CliOptions + from lib.gui.custom_widgets import StatusBar + from lib.gui.command import CommandNotebook + from lib.gui.command import ToolsNotebook + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + +PATHCACHE = os.path.join(os.path.realpath(os.path.dirname(sys.argv[0])), "lib", "gui", ".cache") +_CONFIG: Optional["Config"] = None + + +def initialize_config(root: tk.Tk, + cli_opts: "CliOptions", + statusbar: "StatusBar") -> Optional["Config"]: + """ Initialize the GUI Master :class:`Config` and add to global constant. + + This should only be called once on first GUI startup. Future access to :class:`Config` + should only be executed through :func:`get_config`. + + Parameters + ---------- + root: :class:`tkinter.Tk` + The root Tkinter object + cli_opts: :class:`lib.gui.options.CliOptions` + The command line options object + statusbar: :class:`lib.gui.custom_widgets.StatusBar` + The GUI Status bar + + Returns + ------- + :class:`Config` or ``None`` + ``None`` if the config has already been initialized otherwise the global configuration + options + """ + global _CONFIG # pylint: disable=global-statement + if _CONFIG is not None: + return None + logger.debug("Initializing config: (root: %s, cli_opts: %s, " + "statusbar: %s)", root, cli_opts, statusbar) + _CONFIG = Config(root, cli_opts, statusbar) + return _CONFIG + + +def get_config() -> "Config": + """ Get the Master GUI configuration. + + Returns + ------- + :class:`Config` + The Master GUI Config + """ + assert _CONFIG is not None + return _CONFIG + + +@dataclass +class _GuiObjects: + """ Data class for commonly accessed GUI Objects """ + cli_opts: "CliOptions" + tk_vars: Dict[str, Union[tk.BooleanVar, tk.StringVar]] + project: Project + tasks: Tasks + status_bar: "StatusBar" + default_options: Dict[str, Dict[str, Any]] = field(default_factory=dict) + command_notebook: Optional["CommandNotebook"] = None + + +class Config(): + """ The centralized configuration class for holding items that should be made available to all + parts of the GUI. + + This class should be initialized on GUI startup through :func:`initialize_config`. Any further + access to this class should be through :func:`get_config`. + + Parameters + ---------- + root: :class:`tkinter.Tk` + The root Tkinter object + cli_opts: :class:`lib.gui.options.CliOpts` + The command line options object + statusbar: :class:`lib.gui.custom_widgets.StatusBar` + The GUI Status bar + """ + def __init__(self, root: tk.Tk, cli_opts: "CliOptions", statusbar: "StatusBar") -> None: + logger.debug("Initializing %s: (root %s, cli_opts: %s, statusbar: %s)", + self.__class__.__name__, root, cli_opts, statusbar) + self._default_font = cast(dict, tk.font.nametofont("TkDefaultFont").configure())["family"] + self._constants = dict( + root=root, + scaling_factor=self._get_scaling(root), + default_font=self._default_font) + self._gui_objects = _GuiObjects( + cli_opts=cli_opts, + tk_vars=self._set_tk_vars(), + project=Project(self, FileHandler), + tasks=Tasks(self, FileHandler), + status_bar=statusbar) + + self._user_config = UserConfig(None) + self._style = Style(self.default_font, root, PATHCACHE) + self._user_theme = self._style.user_theme + logger.debug("Initialized %s", self.__class__.__name__) + + # Constants + @property + def root(self) -> tk.Tk: + """ :class:`tkinter.Tk`: The root tkinter window. """ + return self._constants["root"] + + @property + def scaling_factor(self) -> float: + """ float: The scaling factor for current display. """ + return self._constants["scaling_factor"] + + @property + def pathcache(self) -> str: + """ str: The path to the GUI cache folder """ + return PATHCACHE + + # GUI Objects + @property + def cli_opts(self) -> "CliOptions": + """ :class:`lib.gui.options.CliOptions`: The command line options for this GUI Session. """ + return self._gui_objects.cli_opts + + @property + def tk_vars(self) -> Dict[str, Union[tk.StringVar, tk.BooleanVar]]: + """ dict: The global tkinter variables. """ + return self._gui_objects.tk_vars + + @property + def project(self) -> Project: + """ :class:`lib.gui.project.Project`: The project session handler. """ + return self._gui_objects.project + + @property + def tasks(self) -> Tasks: + """ :class:`lib.gui.project.Tasks`: The session tasks handler. """ + return self._gui_objects.tasks + + @property + def default_options(self) -> Dict[str, Dict[str, Any]]: + """ dict: The default options for all tabs """ + return self._gui_objects.default_options + + @property + def statusbar(self) -> "StatusBar": + """ :class:`lib.gui.custom_widgets.StatusBar`: The GUI StatusBar + :class:`tkinter.ttk.Frame`. """ + return self._gui_objects.status_bar + + @property + def command_notebook(self) -> Optional["CommandNotebook"]: + """ :class:`lib.gui.command.CommandNotebook`: The main Faceswap Command Notebook. """ + return self._gui_objects.command_notebook + + # Convenience GUI Objects + @property + def tools_notebook(self) -> "ToolsNotebook": + """ :class:`lib.gui.command.ToolsNotebook`: The Faceswap Tools sub-Notebook. """ + assert self.command_notebook is not None + return self.command_notebook.tools_notebook + + @property + def modified_vars(self) -> Dict[str, "tk.BooleanVar"]: + """ dict: The command notebook modified tkinter variables. """ + assert self.command_notebook is not None + return self.command_notebook.modified_vars + + @property + def _command_tabs(self) -> Dict[str, int]: + """ dict: Command tab titles with their IDs. """ + assert self.command_notebook is not None + return self.command_notebook.tab_names + + @property + def _tools_tabs(self) -> Dict[str, int]: + """ dict: Tools command tab titles with their IDs. """ + assert self.command_notebook is not None + return self.command_notebook.tools_tab_names + + # Config + @property + def user_config(self) -> UserConfig: + """ dict: The GUI config in dict form. """ + return self._user_config + + @property + def user_config_dict(self) -> Dict[str, Any]: # TODO Dataclass + """ dict: The GUI config in dict form. """ + return self._user_config.config_dict + + @property + def user_theme(self) -> Dict[str, Any]: # TODO Dataclass + """ dict: The GUI theme selection options. """ + return self._user_theme + + @property + def default_font(self) -> Tuple[str, int]: + """ tuple: The selected font as configured in user settings. First item is the font (`str`) + second item the font size (`int`). """ + font = self.user_config_dict["font"] + font = self._default_font if font == "default" else font + return (font, self.user_config_dict["font_size"]) + + @staticmethod + def _get_scaling(root) -> float: + """ Get the display DPI. + + Returns + ------- + float: + The scaling factor + """ + dpi = root.winfo_fpixels("1i") + scaling = dpi / 72.0 + logger.debug("dpi: %s, scaling: %s'", dpi, scaling) + return scaling + + def set_default_options(self) -> None: + """ Set the default options for :mod:`lib.gui.projects` + + The Default GUI options are stored on Faceswap startup. + + Exposed as the :attr:`_default_opts` for a project cannot be set until after the main + Command Tabs have been loaded. + """ + default = self.cli_opts.get_option_values() + logger.debug(default) + self._gui_objects.default_options = default + self.project.set_default_options() + + def set_command_notebook(self, notebook: "CommandNotebook") -> None: + """ Set the command notebook to the :attr:`command_notebook` attribute + and enable the modified callback for :attr:`project`. + + Parameters + ---------- + notebook: :class:`lib.gui.command.CommandNotebook` + The main command notebook for the Faceswap GUI + """ + logger.debug("Setting commane notebook: %s", notebook) + self._gui_objects.command_notebook = notebook + self.project.set_modified_callback() + + def set_active_tab_by_name(self, name: str) -> None: + """ Sets the :attr:`command_notebook` or :attr:`tools_notebook` to active based on given + name. + + Parameters + ---------- + name: str + The name of the tab to set active + """ + assert self.command_notebook is not None + name = name.lower() + if name in self._command_tabs: + tab_id = self._command_tabs[name] + logger.debug("Setting active tab to: (name: %s, id: %s)", name, tab_id) + self.command_notebook.select(tab_id) + elif name in self._tools_tabs: + self.command_notebook.select(self._command_tabs["tools"]) + tab_id = self._tools_tabs[name] + logger.debug("Setting active Tools tab to: (name: %s, id: %s)", name, tab_id) + self.tools_notebook.select() + else: + logger.debug("Name couldn't be found. Setting to id 0: %s", name) + self.command_notebook.select(0) + + def set_modified_true(self, command: str) -> None: + """ Set the modified variable to ``True`` for the given command in :attr:`modified_vars`. + + Parameters + ---------- + command: str + The command to set the modified state to ``True`` + + """ + tkvar = self.modified_vars.get(command, None) + if tkvar is None: + logger.debug("No tkvar for command: '%s'", command) + return + tkvar.set(True) + logger.debug("Set modified var to True for: '%s'", command) + + def refresh_config(self) -> None: + """ Reload the user config from file. """ + self._user_config = UserConfig(None) + + def set_cursor_busy(self, widget: Optional[tk.Widget] = None) -> None: + """ Set the root or widget cursor to busy. + + Parameters + ---------- + widget: tkinter object, optional + The widget to set busy cursor for. If the provided value is ``None`` then sets the + cursor busy for the whole of the GUI. Default: ``None``. + """ + logger.debug("Setting cursor to busy. widget: %s", widget) + component = self.root if widget is None else widget + component.config(cursor="watch") # type: ignore + component.update_idletasks() + + def set_cursor_default(self, widget: Optional[tk.Widget] = None) -> None: + """ Set the root or widget cursor to default. + + Parameters + ---------- + widget: tkinter object, optional + The widget to set default cursor for. If the provided value is ``None`` then sets the + cursor busy for the whole of the GUI. Default: ``None`` + """ + logger.debug("Setting cursor to default. widget: %s", widget) + component = self.root if widget is None else widget + component.config(cursor="") # type: ignore + component.update_idletasks() + + @staticmethod + def _set_tk_vars() -> Dict[str, Union[tk.StringVar, tk.BooleanVar]]: + """ Set the global tkinter variables stored for easy access in :class:`Config`. + + The variables are available through :attr:`tk_vars`. + """ + display = tk.StringVar() + display.set("") + + runningtask = tk.BooleanVar() + runningtask.set(False) + + istraining = tk.BooleanVar() + istraining.set(False) + + actioncommand = tk.StringVar() + actioncommand.set("") + + generatecommand = tk.StringVar() + generatecommand.set("") + + console_clear = tk.BooleanVar() + console_clear.set(False) + + refreshgraph = tk.BooleanVar() + refreshgraph.set(False) + + updatepreview = tk.BooleanVar() + updatepreview.set(False) + + analysis_folder = tk.StringVar() + analysis_folder.set("") + + tk_vars: Dict[str, Union[tk.StringVar, tk.BooleanVar]] = dict( + display=display, + runningtask=runningtask, + istraining=istraining, + action=actioncommand, + generate=generatecommand, + console_clear=console_clear, + refreshgraph=refreshgraph, + updatepreview=updatepreview, + analysis_folder=analysis_folder) + logger.debug(tk_vars) + return tk_vars + + def set_root_title(self, text: Optional[str] = None) -> None: + """ Set the main title text for Faceswap. + + The title will always begin with 'Faceswap.py'. Additional text can be appended. + + Parameters + ---------- + text: str, optional + Additional text to be appended to the GUI title bar. Default: ``None`` + """ + title = "Faceswap.py" + title += f" - {text}" if text is not None and text else "" + self.root.title(title) + + def set_geometry(self, width: int, height: int, fullscreen: bool = False) -> None: + """ Set the geometry for the root tkinter object. + + Parameters + ---------- + width: int + The width to set the window to (prior to scaling) + height: int + The height to set the window to (prior to scaling) + fullscreen: bool, optional + Whether to set the window to full-screen mode. If ``True`` then :attr:`width` and + :attr:`height` are ignored. Default: ``False`` + """ + self.root.tk.call("tk", "scaling", self.scaling_factor) + if fullscreen: + initial_dimensions = (self.root.winfo_screenwidth(), self.root.winfo_screenheight()) + else: + initial_dimensions = (round(width * self.scaling_factor), + round(height * self.scaling_factor)) + + if fullscreen and sys.platform in ("win32", "darwin"): + self.root.state('zoomed') + elif fullscreen: + self.root.attributes('-zoomed', True) + else: + self.root.geometry(f"{str(initial_dimensions[0])}x{str(initial_dimensions[1])}+80+80") + logger.debug("Geometry: %sx%s", *initial_dimensions) diff --git a/lib/gui/utils/file_handler.py b/lib/gui/utils/file_handler.py new file mode 100644 index 0000000000..617da1ae3a --- /dev/null +++ b/lib/gui/utils/file_handler.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +""" File browser utility functions for the Faceswap GUI. """ +import logging +import platform +import sys +import tkinter as tk +from tkinter import filedialog + +from typing import cast, Dict, IO, List, Optional, Tuple, Union + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + +_FILETYPE = Literal["default", "alignments", "config_project", "config_task", + "config_all", "csv", "image", "ini", "state", "log", "video"] +_HANDLETYPE = Literal["open", "save", "filename", "filename_multi", "save_filename", + "context", "dir"] + + +class FileHandler(): # pylint:disable=too-few-public-methods + """ Handles all GUI File Dialog actions and tasks. + + Parameters + ---------- + handle_type: ['open', 'save', 'filename', 'filename_multi', 'save_filename', 'context', 'dir'] + The type of file dialog to return. `open` and `save` will perform the open and save actions + and return the file. `filename` returns the filename from an `open` dialog. + `filename_multi` allows for multi-selection of files and returns a list of files selected. + `save_filename` returns the filename from a `save as` dialog. `context` is a context + sensitive parameter that returns a certain dialog based on the current options. `dir` asks + for a folder location. + file_type: ['default', 'alignments', 'config_project', 'config_task', 'config_all', 'csv', \ + 'image', 'ini', 'state', 'log', 'video'] + The type of file that this dialog is for. `default` allows selection of any files. Other + options limit the file type selection + title: str, optional + The title to display on the file dialog. If `None` then the default title will be used. + Default: ``None`` + initial_folder: str, optional + The folder to initially open with the file dialog. If `None` then tkinter will decide. + Default: ``None`` + initial_file: str, optional + The filename to set with the file dialog. If `None` then tkinter no initial filename is. + specified. Default: ``None`` + command: str, optional + Required for context handling file dialog, otherwise unused. Default: ``None`` + action: str, optional + Required for context handling file dialog, otherwise unused. Default: ``None`` + variable: str, optional + Required for context handling file dialog, otherwise unused. The variable to associate + with this file dialog. Default: ``None`` + + Attributes + ---------- + return_file: str or object + The return value from the file dialog + + Example + ------- + >>> handler = FileHandler('filename', 'video', title='Select a video...') + >>> video_file = handler.return_file + >>> print(video_file) + '/path/to/selected/video.mp4' + """ + + def __init__(self, + handle_type: _HANDLETYPE, + file_type: _FILETYPE, + title: Optional[str] = None, + initial_folder: Optional[str] = None, + initial_file: Optional[str] = None, + command: Optional[str] = None, + action: Optional[str] = None, + variable: Optional[str] = None) -> None: + logger.debug("Initializing %s: (handle_type: '%s', file_type: '%s', title: '%s', " + "initial_folder: '%s', initial_file: '%s', command: '%s', action: '%s', " + "variable: %s)", self.__class__.__name__, handle_type, file_type, title, + initial_folder, initial_file, command, action, variable) + self._handletype = handle_type + self._dummy_master = self._set_dummy_master() + self._defaults = self._set_defaults() + self._kwargs = self._set_kwargs(title, + initial_folder, + initial_file, + file_type, + command, + action, + variable) + self.return_file = getattr(self, f"_{self._handletype.lower()}")() + self._remove_dummy_master() + + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def _filetypes(self) -> Dict[str, List[Tuple[str, str]]]: + """ dict: The accepted extensions for each file type for opening/saving """ + all_files = ("All files", "*.*") + filetypes = dict( + default=[all_files], + alignments=[("Faceswap Alignments", "*.fsa"), all_files], + config_project=[("Faceswap Project files", "*.fsw"), all_files], + config_task=[("Faceswap Task files", "*.fst"), all_files], + config_all=[("Faceswap Project and Task files", "*.fst *.fsw"), all_files], + csv=[("Comma separated values", "*.csv"), all_files], + image=[("Bitmap", "*.bmp"), + ("JPG", "*.jpeg *.jpg"), + ("PNG", "*.png"), + ("TIFF", "*.tif *.tiff"), + all_files], + ini=[("Faceswap config files", "*.ini"), all_files], + json=[("JSON file", "*.json"), all_files], + model=[("Keras model files", "*.h5"), all_files], + state=[("State files", "*.json"), all_files], + log=[("Log files", "*.log"), all_files], + video=[("Audio Video Interleave", "*.avi"), + ("Flash Video", "*.flv"), + ("Matroska", "*.mkv"), + ("MOV", "*.mov"), + ("MP4", "*.mp4"), + ("MPEG", "*.mpeg *.mpg *.ts *.vob"), + ("WebM", "*.webm"), + ("Windows Media Video", "*.wmv"), + all_files]) + + # Add in multi-select options and upper case extensions for Linux + for key in filetypes: + if platform.system() == "Linux": + filetypes[key] = [item + if item[0] == "All files" + else (item[0], f"{item[1]} {item[1].upper()}") + for item in filetypes[key]] + if len(filetypes[key]) > 2: + multi = [f"{key.title()} Files"] + multi.append(" ".join([ftype[1] + for ftype in filetypes[key] if ftype[0] != "All files"])) + filetypes[key].insert(0, cast(Tuple[str, str], tuple(multi))) + return filetypes + + @property + def _contexts(self) -> Dict[str, Dict[str, Union[str, Dict[str, str]]]]: + """dict: Mapping of commands, actions and their corresponding file dialog for context + handle types. """ + return dict(effmpeg=dict(input={"extract": "filename", + "gen-vid": "dir", + "get-fps": "filename", + "get-info": "filename", + "mux-audio": "filename", + "rescale": "filename", + "rotate": "filename", + "slice": "filename"}, + output={"extract": "dir", + "gen-vid": "save_filename", + "get-fps": "nothing", + "get-info": "nothing", + "mux-audio": "save_filename", + "rescale": "save_filename", + "rotate": "save_filename", + "slice": "save_filename"})) + + @classmethod + def _set_dummy_master(cls) -> Optional[tk.Frame]: + """ Add an option to force black font on Linux file dialogs KDE issue that displays light + font on white background). + + This is a pretty hacky solution, but tkinter does not allow direct editing of file dialogs, + so we create a dummy frame and add the foreground option there, so that the file dialog can + inherit the foreground. + + Returns + ------- + tkinter.Frame or ``None`` + The dummy master frame for Linux systems, otherwise ``None`` + """ + if platform.system().lower() == "linux": + frame = tk.Frame() + frame.option_add("*foreground", "black") + retval: Optional[tk.Frame] = frame + else: + retval = None + return retval + + def _remove_dummy_master(self) -> None: + """ Destroy the dummy master widget on Linux systems. """ + if platform.system().lower() != "linux" or self._dummy_master is None: + return + self._dummy_master.destroy() + del self._dummy_master + self._dummy_master = None + + def _set_defaults(self) -> Dict[str, Optional[str]]: + """ Set the default file type for the file dialog. Generally the first found file type + will be used, but this is overridden if it is not appropriate. + + Returns + ------- + dict: + The default file extension for each file type + """ + defaults: Dict[str, Optional[str]] = { + key: next(ext for ext in val[0][1].split(" ")).replace("*", "") + for key, val in self._filetypes.items()} + defaults["default"] = None + defaults["video"] = ".mp4" + defaults["image"] = ".png" + logger.debug(defaults) + return defaults + + def _set_kwargs(self, + title: Optional[str], + initial_folder: Optional[str], + initial_file: Optional[str], + file_type: _FILETYPE, + command: Optional[str], + action: Optional[str], + variable: Optional[str] = None + ) -> Dict[str, Union[None, tk.Frame, str, List[Tuple[str, str]]]]: + """ Generate the required kwargs for the requested file dialog browser. + + Parameters + ---------- + title: str + The title to display on the file dialog. If `None` then the default title will be used. + initial_folder: str + The folder to initially open with the file dialog. If `None` then tkinter will decide. + initial_file: str + The filename to set with the file dialog. If `None` then tkinter no initial filename + is. + file_type: ['default', 'alignments', 'config_project', 'config_task', 'config_all', \ + 'csv', 'image', 'ini', 'state', 'log', 'video'] + The type of file that this dialog is for. `default` allows selection of any files. + Other options limit the file type selection + command: str + Required for context handling file dialog, otherwise unused. + action: str + Required for context handling file dialog, otherwise unused. + variable: str, optional + Required for context handling file dialog, otherwise unused. The variable to associate + with this file dialog. Default: ``None`` + + Returns + ------- + dict: + The key word arguments for the file dialog to be launched + """ + logger.debug("Setting Kwargs: (title: %s, initial_folder: %s, initial_file: '%s', " + "file_type: '%s', command: '%s': action: '%s', variable: '%s')", + title, initial_folder, initial_file, file_type, command, action, variable) + + kwargs: Dict[str, Union[None, tk.Frame, str, + List[Tuple[str, str]]]] = dict(master=self._dummy_master) + + if self._handletype.lower() == "context": + assert command is not None and action is not None and variable is not None + self._set_context_handletype(command, action, variable) + + if title is not None: + kwargs["title"] = title + + if initial_folder is not None: + kwargs["initialdir"] = initial_folder + + if initial_file is not None: + kwargs["initialfile"] = initial_file + + if self._handletype.lower() in ( + "open", "save", "filename", "filename_multi", "save_filename"): + kwargs["filetypes"] = self._filetypes[file_type] + if self._defaults.get(file_type): + kwargs['defaultextension'] = self._defaults[file_type] + if self._handletype.lower() == "save": + kwargs["mode"] = "w" + if self._handletype.lower() == "open": + kwargs["mode"] = "r" + logger.debug("Set Kwargs: %s", kwargs) + return kwargs + + def _set_context_handletype(self, command: str, action: str, variable: str) -> None: + """ Sets the correct handle type based on context. + + Parameters + ---------- + command: str + The command that is being executed. Used to look up the context actions + action: str + The action that is being performed. Used to look up the correct file dialog + variable: str + The variable associated with this file dialog + """ + if self._contexts[command].get(variable, None) is not None: + handletype = cast(Dict[str, Dict[str, Dict[str, str]]], + self._contexts)[command][variable][action] + else: + handletype = cast(Dict[str, Dict[str, str]], + self._contexts)[command][action] + logger.debug(handletype) + self._handletype = cast(_HANDLETYPE, handletype) + + def _open(self) -> Optional[IO]: + """ Open a file. """ + logger.debug("Popping Open browser") + return filedialog.askopenfile(**self._kwargs) # type: ignore + + def _save(self) -> Optional[IO]: + """ Save a file. """ + logger.debug("Popping Save browser") + return filedialog.asksaveasfile(**self._kwargs) # type: ignore + + def _dir(self) -> str: + """ Get a directory location. """ + logger.debug("Popping Dir browser") + return filedialog.askdirectory(**self._kwargs) + + def _savedir(self) -> str: + """ Get a save directory location. """ + logger.debug("Popping SaveDir browser") + return filedialog.askdirectory(**self._kwargs) + + def _filename(self) -> str: + """ Get an existing file location. """ + logger.debug("Popping Filename browser") + return filedialog.askopenfilename(**self._kwargs) + + def _filename_multi(self) -> Tuple[str, ...]: + """ Get multiple existing file locations. """ + logger.debug("Popping Filename browser") + return filedialog.askopenfilenames(**self._kwargs) + + def _save_filename(self) -> str: + """ Get a save file location. """ + logger.debug("Popping Save Filename browser") + return filedialog.asksaveasfilename(**self._kwargs) + + @staticmethod + def _nothing() -> None: # pylint: disable=useless-return + """ Method that does nothing, used for disabling open/save pop up. """ + logger.debug("Popping Nothing browser") + return diff --git a/lib/gui/utils/image.py b/lib/gui/utils/image.py new file mode 100644 index 0000000000..ea0f8effb6 --- /dev/null +++ b/lib/gui/utils/image.py @@ -0,0 +1,659 @@ +#!/usr/bin python3 +""" Utilities for handling images in the Faceswap GUI """ + +import logging +import os +import sys +from typing import cast, Dict, List, Optional, Sequence, Tuple, Union + +import numpy as np +from PIL import Image, ImageDraw, ImageTk + +from .config import get_config, PATHCACHE + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + +_IMAGES: Optional["Images"] = None +_PREVIEW_TRIGGER: Optional["PreviewTrigger"] = None + + +def initialize_images() -> None: + """ Initialize the :class:`Images` handler and add to global constant. + + This should only be called once on first GUI startup. Future access to :class:`Images` + handler should only be executed through :func:`get_images`. + """ + global _IMAGES # pylint: disable=global-statement + if _IMAGES is not None: + return + logger.debug("Initializing images") + _IMAGES = Images() + + +def get_images() -> "Images": + """ Get the Master GUI Images handler. + + Returns + ------- + :class:`Images` + The Master GUI Images handler + """ + assert _IMAGES is not None + return _IMAGES + + +class Images(): + """ The centralized image repository for holding all icons and images required by the GUI. + + This class should be initialized on GUI startup through :func:`initialize_images`. Any further + access to this class should be through :func:`get_images`. + """ + def __init__(self) -> None: + logger.debug("Initializing %s", self.__class__.__name__) + self._pathpreview = os.path.join(PATHCACHE, "preview") + self._pathoutput: Optional[str] = None + self._batch_mode = False + self._previewoutput: Optional[Tuple[Image.Image, ImageTk.PhotoImage]] = None + self._previewtrain: Dict[str, List[Union[Image.Image, + ImageTk.PhotoImage, + None, + float]]] = {} + self._previewcache: Dict[str, Union[None, float, np.ndarray, List[str]]] = dict( + modified=None, # cache for extract and convert + images=None, + filenames=[], + placeholder=None) + self._errcount = 0 + self._icons = self._load_icons() + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def previewoutput(self) -> Optional[Tuple[Image.Image, ImageTk.PhotoImage]]: + """ Tuple or ``None``: First item in the tuple is the extract or convert preview image + (:class:`PIL.Image`), the second item is the image in a format that tkinter can display + (:class:`PIL.ImageTK.PhotoImage`). + + The value of the property is ``None`` if no extract or convert task is running or there are + no files available in the output folder. """ + return self._previewoutput + + @property + def previewtrain(self) -> Dict[str, List[Union[Image.Image, ImageTk.PhotoImage, None, float]]]: + """ dict or ``None``: The training preview images. Dictionary key is the image name + (`str`). Dictionary values are a `list` of the training image (:class:`PIL.Image`), the + image formatted for tkinter display (:class:`PIL.ImageTK.PhotoImage`), the last + modification time of the image (`float`). + + The value of this property is ``None`` if training is not running or there are no preview + images available. + """ + return self._previewtrain + + @property + def icons(self) -> Dict[str, ImageTk.PhotoImage]: + """ dict: The faceswap icons for all parts of the GUI. The dictionary key is the icon + name (`str`) the value is the icon sized and formatted for display + (:class:`PIL.ImageTK.PhotoImage`). + + Example + ------- + >>> icons = get_images().icons + >>> save = icons["save"] + >>> button = ttk.Button(parent, image=save) + >>> button.pack() + """ + return self._icons + + @staticmethod + def _load_icons() -> Dict[str, ImageTk.PhotoImage]: + """ Scan the icons cache folder and load the icons into :attr:`icons` for retrieval + throughout the GUI. + + Returns + ------- + dict: + The icons formatted as described in :attr:`icons` + + """ + size = get_config().user_config_dict.get("icon_size", 16) + size = int(round(size * get_config().scaling_factor)) + icons: Dict[str, ImageTk.PhotoImage] = {} + pathicons = os.path.join(PATHCACHE, "icons") + for fname in os.listdir(pathicons): + name, ext = os.path.splitext(fname) + if ext != ".png": + continue + img = Image.open(os.path.join(pathicons, fname)) + img = ImageTk.PhotoImage(img.resize((size, size), resample=Image.HAMMING)) + icons[name] = img + logger.debug(icons) + return icons + + def set_faceswap_output_path(self, location: str, batch_mode: bool = False) -> None: + """ Set the path that will contain the output from an Extract or Convert task. + + Required so that the GUI can fetch output images to display for return in + :attr:`previewoutput`. + + Parameters + ---------- + location: str + The output location that has been specified for an Extract or Convert task + batch_mode: bool + ``True`` if extracting in batch mode otherwise False + """ + self._pathoutput = location + self._batch_mode = batch_mode + + def delete_preview(self) -> None: + """ Delete the preview files in the cache folder and reset the image cache. + + Should be called when terminating tasks, or when Faceswap starts up or shuts down. + """ + logger.debug("Deleting previews") + for item in os.listdir(self._pathpreview): + if item.startswith(".gui_training_preview") and item.endswith(".jpg"): + fullitem = os.path.join(self._pathpreview, item) + logger.debug("Deleting: '%s'", fullitem) + os.remove(fullitem) + for fname in cast(List[str], self._previewcache["filenames"]): + if os.path.basename(fname) == ".gui_preview.jpg": + logger.debug("Deleting: '%s'", fname) + try: + os.remove(fname) + except FileNotFoundError: + logger.debug("File does not exist: %s", fname) + self._clear_image_cache() + + def _clear_image_cache(self) -> None: + """ Clear all cached images. """ + logger.debug("Clearing image cache") + self._pathoutput = None + self._batch_mode = False + self._previewoutput = None + self._previewtrain = {} + self._previewcache = dict(modified=None, # cache for extract and convert + images=None, + filenames=[], + placeholder=None) + + @staticmethod + def _get_images(image_path: str) -> List[str]: + """ Get the images stored within the given directory. + + Parameters + ---------- + image_path: str + The folder containing images to be scanned + + Returns + ------- + list: + The image filenames stored within the given folder + + """ + logger.debug("Getting images: '%s'", image_path) + if not os.path.isdir(image_path): + logger.debug("Folder does not exist") + return [] + files = [os.path.join(image_path, f) + for f in os.listdir(image_path) if f.lower().endswith((".png", ".jpg"))] + logger.debug("Image files: %s", files) + return files + + def load_latest_preview(self, thumbnail_size: int, frame_dims: Tuple[int, int]) -> None: + """ Load the latest preview image for extract and convert. + + Retrieves the latest preview images from the faceswap output folder, resizes to thumbnails + and lays out for display. Places the images into :attr:`previewoutput` for loading into + the display panel. + + Parameters + ---------- + thumbnail_size: int + The size of each thumbnail that should be created + frame_dims: tuple + The (width (`int`), height (`int`)) of the display panel that will display the preview + """ + logger.debug("Loading preview image: (thumbnail_size: %s, frame_dims: %s)", + thumbnail_size, frame_dims) + assert self._pathoutput is not None + image_path = self._get_newest_folder() if self._batch_mode else self._pathoutput + image_files = self._get_images(image_path) + gui_preview = os.path.join(self._pathoutput, ".gui_preview.jpg") + if not image_files or (len(image_files) == 1 and gui_preview not in image_files): + logger.debug("No preview to display") + return + # Filter to just the gui_preview if it exists in folder output + image_files = [gui_preview] if gui_preview in image_files else image_files + logger.debug("Image Files: %s", len(image_files)) + + image_files = self._get_newest_filenames(image_files) + if not image_files: + return + + if not self._load_images_to_cache(image_files, frame_dims, thumbnail_size): + logger.debug("Failed to load any preview images") + if gui_preview in image_files: + # Reset last modified for failed loading of a gui preview image so it is picked + # up next time + self._previewcache["modified"] = None + return + + if image_files == [gui_preview]: + # Delete the preview image so that the main scripts know to output another + logger.debug("Deleting preview image") + os.remove(image_files[0]) + show_image = self._place_previews(frame_dims) + if not show_image: + self._previewoutput = None + return + logger.debug("Displaying preview: %s", self._previewcache["filenames"]) + self._previewoutput = (show_image, ImageTk.PhotoImage(show_image)) + + def _get_newest_folder(self) -> str: + """ Obtain the most recent folder created in the extraction output folder when processing + in batch mode. + + Returns + ------- + str + The most recently modified folder within the parent output folder. If no folders have + been created, returns the parent output folder + + """ + assert self._pathoutput is not None + folders = [] if not os.path.exists(self._pathoutput) else [ + os.path.join(self._pathoutput, folder) + for folder in os.listdir(self._pathoutput) + if os.path.isdir(os.path.join(self._pathoutput, folder))] + + folders.sort(key=os.path.getmtime) + retval = folders[-1] if folders else self._pathoutput + logger.debug("sorted folders: %s, return value: %s", folders, retval) + return retval + + def _get_newest_filenames(self, image_files: List[str]) -> List[str]: + """ Return image filenames that have been modified since the last check. + + Parameters + ---------- + image_files: list + The list of image files to check the modification date for + + Returns + ------- + list: + A list of images that have been modified since the last check + """ + if self._previewcache["modified"] is None: + retval = image_files + else: + retval = [fname for fname in image_files + if os.path.getmtime(fname) > cast(float, self._previewcache["modified"])] + if not retval: + logger.debug("No new images in output folder") + else: + self._previewcache["modified"] = max(os.path.getmtime(img) for img in retval) + logger.debug("Number new images: %s, Last Modified: %s", + len(retval), self._previewcache["modified"]) + return retval + + def _load_images_to_cache(self, + image_files: List[str], + frame_dims: Tuple[int, int], + thumbnail_size: int) -> bool: + """ Load preview images to the image cache. + + Load new images and append to cache, filtering the cache the number of thumbnails that will + fit inside the display panel. + + Parameters + ---------- + image_files: list + A list of new image files that have been modified since the last check + frame_dims: tuple + The (width (`int`), height (`int`)) of the display panel that will display the preview + thumbnail_size: int + The size of each thumbnail that should be created + + Returns + ------- + bool + ``True`` if images were successfully loaded to cache otherwise ``False`` + """ + logger.debug("Number image_files: %s, frame_dims: %s, thumbnail_size: %s", + len(image_files), frame_dims, thumbnail_size) + num_images = (frame_dims[0] // thumbnail_size) * (frame_dims[1] // thumbnail_size) + logger.debug("num_images: %s", num_images) + if num_images == 0: + return False + samples: List[np.ndarray] = [] + start_idx = len(image_files) - num_images if len(image_files) > num_images else 0 + show_files = sorted(image_files, key=os.path.getctime)[start_idx:] + dropped_files = [] + for fname in show_files: + try: + img = Image.open(fname) + except PermissionError as err: + logger.debug("Permission error opening preview file: '%s'. Original error: %s", + fname, str(err)) + dropped_files.append(fname) + continue + except Exception as err: # pylint:disable=broad-except + # Swallow any issues with opening an image rather than spamming console + # Can happen when trying to read partially saved images + logger.debug("Error opening preview file: '%s'. Original error: %s", + fname, str(err)) + dropped_files.append(fname) + continue + + width, height = img.size + scaling = thumbnail_size / max(width, height) + logger.debug("image width: %s, height: %s, scaling: %s", width, height, scaling) + + try: + img = img.resize((int(width * scaling), int(height * scaling))) + except OSError as err: + # Image only gets loaded when we call a method, so may error on partial loads + logger.debug("OS Error resizing preview image: '%s'. Original error: %s", + fname, err) + dropped_files.append(fname) + continue + + samples.append(self._pad_and_border(img, thumbnail_size)) + + return self._process_samples(samples, + [fname for fname in show_files if fname not in dropped_files], + num_images) + + def _pad_and_border(self, image: Image.Image, size: int) -> np.ndarray: + """ Pad rectangle images to a square and draw borders + + Parameters + ---------- + image: :class:`PIL.Image` + The image to process + size: int + The size of the image as it should be displayed + + Returns + ------- + :class:`PIL.Image`: + The processed image + """ + if image.size[0] != image.size[1]: + # Pad to square + new_img = Image.new("RGB", (size, size)) + new_img.paste(image, ((size - image.size[0]) // 2, (size - image.size[1]) // 2)) + image = new_img + draw = ImageDraw.Draw(image) + draw.rectangle(((0, 0), (size, size)), outline="#E5E5E5", width=1) + retval = np.array(image) + logger.trace("image shape: %s", retval.shape) # type: ignore + return retval + + def _process_samples(self, + samples: List[np.ndarray], + filenames: List[str], + num_images: int) -> bool: + """ Process the latest sample images into a displayable image. + + Parameters + ---------- + samples: list + The list of extract/convert preview images to display + filenames: list + The full path to the filenames corresponding to the images + num_images: int + The number of images that should be displayed + + Returns + ------- + bool + ``True`` if samples succesfully compiled otherwise ``False`` + """ + asamples = np.array(samples) + if not np.any(asamples): + logger.debug("No preview images collected.") + return False + + self._previewcache["filenames"] = (cast(List[str], self._previewcache["filenames"]) + + filenames)[-num_images:] + cache = cast(Optional[np.ndarray], self._previewcache["images"]) + if cache is None: + logger.debug("Creating new cache") + cache = asamples[-num_images:] + else: + logger.debug("Appending to existing cache") + cache = np.concatenate((cache, asamples))[-num_images:] + self._previewcache["images"] = cache + logger.debug("Cache shape: %s", cast(np.ndarray, self._previewcache["images"]).shape) + return True + + def _place_previews(self, frame_dims: Tuple[int, int]) -> Image.Image: + """ Format the preview thumbnails stored in the cache into a grid fitting the display + panel. + + Parameters + ---------- + frame_dims: tuple + The (width (`int`), height (`int`)) of the display panel that will display the preview + + Returns + ------- + :class:`PIL.Image`: + The final preview display image + """ + if self._previewcache.get("images", None) is None: + logger.debug("No images in cache. Returning None") + return None + samples = cast(np.ndarray, self._previewcache["images"]).copy() + num_images, thumbnail_size = samples.shape[:2] + if self._previewcache["placeholder"] is None: + self._create_placeholder(thumbnail_size) + + logger.debug("num_images: %s, thumbnail_size: %s", num_images, thumbnail_size) + cols, rows = frame_dims[0] // thumbnail_size, frame_dims[1] // thumbnail_size + logger.debug("cols: %s, rows: %s", cols, rows) + if cols == 0 or rows == 0: + logger.debug("Cols or Rows is zero. No items to display") + return None + remainder = (cols * rows) - num_images + if remainder != 0: + logger.debug("Padding sample display. Remainder: %s", remainder) + placeholder = np.concatenate([np.expand_dims( + cast(np.ndarray, self._previewcache["placeholder"]), 0)] * remainder) + samples = np.concatenate((samples, placeholder)) + + display = np.vstack([np.hstack(cast(Sequence, samples[row * cols: (row + 1) * cols])) + for row in range(rows)]) + logger.debug("display shape: %s", display.shape) + return Image.fromarray(display) + + def _create_placeholder(self, thumbnail_size: int) -> None: + """ Create a placeholder image for when there are fewer thumbnails available + than columns to display them. + + Parameters + ---------- + thumbnail_size: int + The size of the thumbnail that the placeholder should replicate + """ + logger.debug("Creating placeholder. thumbnail_size: %s", thumbnail_size) + placeholder = Image.new("RGB", (thumbnail_size, thumbnail_size)) + draw = ImageDraw.Draw(placeholder) + draw.rectangle(((0, 0), (thumbnail_size, thumbnail_size)), outline="#E5E5E5", width=1) + placeholder = np.array(placeholder) + self._previewcache["placeholder"] = placeholder + logger.debug("Created placeholder. shape: %s", placeholder.shape) + + def load_training_preview(self) -> None: + """ Load the training preview images. + + Reads the training image currently stored in the cache folder and loads them to + :attr:`previewtrain` for retrieval in the GUI. + """ + logger.debug("Loading Training preview images") + image_files = self._get_images(self._pathpreview) + modified = None + if not image_files: + logger.debug("No preview to display") + self._previewtrain = {} + return + for img in image_files: + modified = os.path.getmtime(img) if modified is None else modified + name = os.path.basename(img) + name = os.path.splitext(name)[0] + name = name[name.rfind("_") + 1:].title() + try: + logger.debug("Displaying preview: '%s'", img) + size = self._get_current_size(name) + self._previewtrain[name] = [Image.open(img), None, modified] + self.resize_image(name, size) + self._errcount = 0 + except ValueError: + # This is probably an error reading the file whilst it's + # being saved so ignore it for now and only pick up if + # there have been multiple consecutive fails + logger.warning("Unable to display preview: (image: '%s', attempt: %s)", + img, self._errcount) + if self._errcount < 10: + self._errcount += 1 + else: + logger.error("Error reading the preview file for '%s'", img) + print(f"Error reading the preview file for {name}") + del self._previewtrain[name] + + def _get_current_size(self, name: str) -> Optional[Tuple[int, int]]: + """ Return the size of the currently displayed training preview image. + + Parameters + ---------- + name: str + The name of the training image to get the size for + + Returns + ------- + width: int + The width of the training image + height: int + The height of the training image + """ + logger.debug("Getting size: '%s'", name) + if not self._previewtrain.get(name): + return None + img = cast(Image.Image, self._previewtrain[name][1]) + if not img: + return None + logger.debug("Got size: (name: '%s', width: '%s', height: '%s')", + name, img.width(), img.height()) + return img.width(), img.height() + + def resize_image(self, name: str, frame_dims: Optional[Tuple[int, int]]) -> None: + """ Resize the training preview image based on the passed in frame size. + + If the canvas that holds the preview image changes, update the image size + to fit the new canvas and refresh :attr:`previewtrain`. + + Parameters + ---------- + name: str + The name of the training image to be resized + frame_dims: tuple, optional + The (width (`int`), height (`int`)) of the display panel that will display the preview. + ``None`` if the frame dimensions are not known. + """ + logger.debug("Resizing image: (name: '%s', frame_dims: %s", name, frame_dims) + displayimg = cast(Image.Image, self._previewtrain[name][0]) + if frame_dims: + frameratio = float(frame_dims[0]) / float(frame_dims[1]) + imgratio = float(displayimg.size[0]) / float(displayimg.size[1]) + + if frameratio <= imgratio: + scale = frame_dims[0] / float(displayimg.size[0]) + size = (frame_dims[0], int(displayimg.size[1] * scale)) + else: + scale = frame_dims[1] / float(displayimg.size[1]) + size = (int(displayimg.size[0] * scale), frame_dims[1]) + logger.debug("Scaling: (scale: %s, size: %s", scale, size) + + # Hacky fix to force a reload if it happens to find corrupted + # data, probably due to reading the image whilst it is partially + # saved. If it continues to fail, then eventually raise. + for i in range(0, 1000): + try: + displayimg = displayimg.resize(size, Image.ANTIALIAS) + except OSError: + if i == 999: + raise + continue + break + self._previewtrain[name][1] = ImageTk.PhotoImage(displayimg) + + +class PreviewTrigger(): + """ Triggers to indicate to underlying Faceswap process that the preview image should + be updated. + + Writes a file to the cache folder that is picked up by the main process. + """ + def __init__(self) -> None: + logger.debug("Initializing: %s", self.__class__.__name__) + self._trigger_files = dict(update=os.path.join(PATHCACHE, ".preview_trigger"), + mask_toggle=os.path.join(PATHCACHE, ".preview_mask_toggle")) + logger.debug("Initialized: %s (trigger_files: %s)", + self.__class__.__name__, self._trigger_files) + + def set(self, trigger_type: Literal["update", "mask_toggle"]): + """ Place the trigger file into the cache folder + + Parameters + ---------- + trigger_type: ["update", "mask_toggle"] + The type of action to trigger. 'update': Full preview update. 'mask_toggle': toggle + mask on and off + """ + trigger = self._trigger_files[trigger_type] + if not os.path.isfile(trigger): + with open(trigger, "w", encoding="utf8"): + pass + logger.debug("Set preview trigger: %s", trigger) + + def clear(self, trigger_type: Optional[Literal["update", "mask_toggle"]] = None) -> None: + """ Remove the trigger file from the cache folder. + + Parameters + ---------- + trigger_type: ["update", "mask_toggle", ``None``], optional + The trigger to clear. 'update': Full preview update. 'mask_toggle': toggle mask on + and off. ``None`` - clear all triggers. Default: ``None`` + """ + if trigger_type is None: + triggers = list(self._trigger_files.values()) + else: + triggers = [self._trigger_files[trigger_type]] + for trigger in triggers: + if os.path.isfile(trigger): + os.remove(trigger) + logger.debug("Removed preview trigger: %s", trigger) + + +def preview_trigger() -> PreviewTrigger: + """ Set the global preview trigger if it has not already been set and return. + + Returns + ------- + :class:`PreviewTrigger` + The trigger to indicate to the main faceswap process that it should perform a training + preview update + """ + global _PREVIEW_TRIGGER # pylint:disable=global-statement + if _PREVIEW_TRIGGER is None: + _PREVIEW_TRIGGER = PreviewTrigger() + return _PREVIEW_TRIGGER diff --git a/lib/gui/utils/misc.py b/lib/gui/utils/misc.py new file mode 100644 index 0000000000..142cb2623f --- /dev/null +++ b/lib/gui/utils/misc.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" Miscellaneous Utility functions for the GUI. Includes LongRunningTask object """ +import logging +import sys + +from threading import Event, Thread +from typing import (Any, Callable, cast, Dict, Optional, Tuple, Type, TYPE_CHECKING) +from queue import Queue + +from .config import get_config + +if TYPE_CHECKING: + from types import TracebackType + from lib.multithreading import _ErrorType + + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + +class LongRunningTask(Thread): + """ Runs long running tasks in a background thread to prevent the GUI from becoming + unresponsive. + + This is sub-classed from :class:`Threading.Thread` so check documentation there for base + parameters. Additional parameters listed below. + + Parameters + ---------- + widget: tkinter object, optional + The widget that this :class:`LongRunningTask` is associated with. Used for setting the busy + cursor in the correct location. Default: ``None``. + """ + _target: Callable + _args: Tuple + _kwargs: Dict[str, Any] + _name: str + + def __init__(self, + target: Optional[Callable] = None, + name: Optional[str] = None, + args: Tuple = (), + kwargs: Optional[Dict[str, Any]] = None, + *, + daemon: bool = True, + widget=None): + logger.debug("Initializing %s: (target: %s, name: %s, args: %s, kwargs: %s, " + "daemon: %s)", self.__class__.__name__, target, name, args, kwargs, + daemon) + super().__init__(target=target, name=name, args=args, kwargs=kwargs, + daemon=daemon) + self.err: "_ErrorType" = None + self._widget = widget + self._config = get_config() + self._config.set_cursor_busy(widget=self._widget) + self._complete = Event() + self._queue: Queue = Queue() + logger.debug("Initialized %s", self.__class__.__name__,) + + @property + def complete(self) -> Event: + """ :class:`threading.Event`: Event is set if the thread has completed its task, + otherwise it is unset. + """ + return self._complete + + def run(self) -> None: + """ Commence the given task in a background thread. """ + try: + if self._target: + retval = self._target(*self._args, **self._kwargs) + self._queue.put(retval) + except Exception: # pylint: disable=broad-except + self.err = cast(Tuple[Type[BaseException], BaseException, "TracebackType"], + sys.exc_info()) + assert self.err is not None + logger.debug("Error in thread (%s): %s", self._name, + self.err[1].with_traceback(self.err[2])) + finally: + self._complete.set() + # Avoid a ref-cycle if the thread is running a function with + # an argument that has a member that points to the thread. + del self._target, self._args, self._kwargs + + def get_result(self) -> Any: + """ Return the result from the given task. + + Returns + ------- + varies: + The result of the thread will depend on the given task. If a call is made to + :func:`get_result` prior to the thread completing its task then ``None`` will be + returned + """ + if not self._complete.is_set(): + logger.warning("Aborting attempt to retrieve result from a LongRunningTask that is " + "still running") + return None + if self.err: + logger.debug("Error caught in thread") + self._config.set_cursor_default(widget=self._widget) + raise self.err[1].with_traceback(self.err[2]) + + logger.debug("Getting result from thread") + retval = self._queue.get() + logger.debug("Got result from thread") + self._config.set_cursor_default(widget=self._widget) + return retval diff --git a/scripts/train.py b/scripts/train.py index e59c815a1b..9a9529bff1 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -494,22 +494,21 @@ def _show(self, image: np.ndarray, name: str = "") -> None: The preview image to be displayed and/or written out name: str, optional The name of the image for saving or display purposes. If an empty string is passed - then it will automatically be names. Default: "" + then it will automatically be named. Default: "" """ logger.debug("Updating preview: (name: %s)", name) try: scriptpath = os.path.realpath(os.path.dirname(sys.argv[0])) if self._args.write_image: logger.debug("Saving preview to disk") - img = "training_preview.jpg" + img = "training_preview.png" imgfile = os.path.join(scriptpath, img) cv2.imwrite(imgfile, image) # pylint: disable=no-member logger.debug("Saved preview to: '%s'", img) if self._args.redirect_gui: logger.debug("Generating preview for GUI") - img = ".gui_training_preview.jpg" - imgfile = os.path.join(scriptpath, "lib", "gui", - ".cache", "preview", img) + img = ".gui_training_preview.png" + imgfile = os.path.join(scriptpath, "lib", "gui", ".cache", "preview", img) cv2.imwrite(imgfile, image) # pylint: disable=no-member logger.debug("Generated preview for GUI: '%s'", imgfile) if self._args.preview: diff --git a/setup.cfg b/setup.cfg index 3075936d94..31ffe69390 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,6 +4,7 @@ max-complexity=10 statistics = True count = True exclude = .git, __pycache__ +per-file-ignores = __init__.py:F401 [mypy] [mypy-cv2.*] From a7c315951f5733398debee267115cfa6c8d6ee10 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 16 Oct 2022 14:17:11 +0100 Subject: [PATCH 758/981] docs update --- docs/full/lib/gui.rst | 51 ++++++++++++++++++++++++++++++------------ lib/gui/utils/image.py | 2 +- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/docs/full/lib/gui.rst b/docs/full/lib/gui.rst index aa8f54d18c..62b13d8571 100755 --- a/docs/full/lib/gui.rst +++ b/docs/full/lib/gui.rst @@ -139,28 +139,51 @@ theme module :undoc-members: :show-inheritance: -utils module -============ +utils package +============= -.. rubric:: Module Summary +.. rubric:: Package Summary .. autosummary:: :nosignatures: - - ~lib.gui.utils.Config - ~lib.gui.utils.FileHandler - ~lib.gui.utils.Images - ~lib.gui.utils.LongRunningTask - ~lib.gui.utils.get_config - ~lib.gui.utils.get_images - ~lib.gui.utils.initialize_config - ~lib.gui.utils.initialize_images -.. rubric:: Module + ~lib.gui.utils.config.Config + ~lib.gui.utils.config.initialize_config + ~lib.gui.utils.config.get_config + ~lib.gui.utils.file_handler.FileHandler + ~lib.gui.utils.image.Images + ~lib.gui.utils.image.get_images + ~lib.gui.utils.image.initialize_images + ~lib.gui.utils.misc.LongRunningTask + -.. automodule:: lib.gui.utils +.. rubric:: config Module + +.. automodule:: lib.gui.utils.config :members: :undoc-members: :show-inheritance: +.. rubric:: file_handler Module + +.. automodule:: lib.gui.utils.file_handler + :members: + :undoc-members: + :show-inheritance: + + +.. rubric:: image Module + +.. automodule:: lib.gui.utils.image + :members: + :undoc-members: + :show-inheritance: + + +.. rubric:: misc Module + +.. automodule:: lib.gui.utils.misc + :members: + :undoc-members: + :show-inheritance: diff --git a/lib/gui/utils/image.py b/lib/gui/utils/image.py index ea0f8effb6..3208ae82ec 100644 --- a/lib/gui/utils/image.py +++ b/lib/gui/utils/image.py @@ -157,7 +157,7 @@ def delete_preview(self) -> None: """ logger.debug("Deleting previews") for item in os.listdir(self._pathpreview): - if item.startswith(".gui_training_preview") and item.endswith(".jpg"): + if item.startswith(".gui_training_preview") and item.endswith((".jpg", ".png")): fullitem = os.path.join(self._pathpreview, item) logger.debug("Deleting: '%s'", fullitem) os.remove(fullitem) From dab823a3eb7a5257cb1e0818ee10ed234d3de97f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 17 Oct 2022 18:14:04 +0100 Subject: [PATCH 759/981] Typing - lib.gui.display_command --- docs/full/lib/gui.rst | 8 ++ lib/gui/_config.py | 8 +- lib/gui/analysis/stats.py | 4 +- lib/gui/command.py | 22 ++-- lib/gui/control_helper.py | 4 +- lib/gui/custom_widgets.py | 2 +- lib/gui/display.py | 10 +- lib/gui/display_analysis.py | 6 +- lib/gui/display_command.py | 200 ++++++++++++++++++++++------------ lib/gui/display_graph.py | 6 +- lib/gui/display_page.py | 12 +- lib/gui/menu.py | 2 +- lib/gui/popup_configure.py | 2 +- lib/gui/project.py | 19 ++-- lib/gui/theme.py | 6 +- lib/gui/utils/config.py | 135 ++++++++++++++--------- lib/gui/utils/file_handler.py | 9 +- lib/gui/utils/image.py | 5 +- lib/gui/wrapper.py | 102 +++++++++-------- scripts/gui.py | 4 +- 20 files changed, 331 insertions(+), 235 deletions(-) diff --git a/docs/full/lib/gui.rst b/docs/full/lib/gui.rst index 62b13d8571..cce3d1b8e6 100755 --- a/docs/full/lib/gui.rst +++ b/docs/full/lib/gui.rst @@ -88,6 +88,14 @@ display\_analysis module :undoc-members: :show-inheritance: +display\_command module +======================= + +.. automodule:: lib.gui.display_command + :members: + :undoc-members: + :show-inheritance: + display\_graph module ===================== diff --git a/lib/gui/_config.py b/lib/gui/_config.py index 1c26a238f0..d34b2f186d 100644 --- a/lib/gui/_config.py +++ b/lib/gui/_config.py @@ -102,12 +102,12 @@ def get_clean_fonts(): A list of valid fonts for the system """ fmanager = font_manager.FontManager() - fonts = dict() + fonts = {} for font in fmanager.ttflist: if str(font.weight) in ("400", "normal", "regular"): - fonts.setdefault(font.name, dict())["regular"] = True + fonts.setdefault(font.name, {})["regular"] = True if str(font.weight) in ("700", "bold"): - fonts.setdefault(font.name, dict())["bold"] = True + fonts.setdefault(font.name, {})["bold"] = True valid_fonts = {key for key, val in fonts.items() if len(val) == 2} retval = sorted(list(valid_fonts.intersection(tk_font.families()))) if not retval: @@ -115,5 +115,5 @@ def get_clean_fonts(): # prefixed logger.debug("No bold/regular fonts found. Running simple filter") retval = sorted([fnt for fnt in tk_font.families() - if not fnt.startswith("@") and not any([ord(c) > 127 for c in fnt])]) + if not fnt.startswith("@") and not any(ord(c) > 127 for c in fnt)]) return ["default"] + retval diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index a92a4b0299..62cd7261d1 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -528,8 +528,8 @@ class Calculations(): """ def __init__(self, session_id, display: str = "loss", - loss_keys: str = "loss", - selections: str = "raw", + loss_keys: Union[List[str], str] = "loss", + selections: Union[List[str], str] = "raw", avg_samples: int = 500, smooth_amount: float = 0.90, flatten_outliers: bool = False) -> None: diff --git a/lib/gui/command.py b/lib/gui/command.py index bac9e8c109..56f4106aa6 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -22,7 +22,7 @@ class CommandNotebook(ttk.Notebook): # pylint:disable=too-many-ancestors def __init__(self, parent): logger.debug("Initializing %s: (parent: %s)", self.__class__.__name__, parent) - self.actionbtns = dict() + self.actionbtns = {} super().__init__(parent) parent.add(self) @@ -50,7 +50,7 @@ def set_running_task_trace(self): to change the action buttons text and command """ logger.debug("Set running trace") tk_vars = get_config().tk_vars - tk_vars["runningtask"].trace("w", self.change_action_button) + tk_vars.running_task.trace("w", self.change_action_button) def build_tabs(self): """ Build the tabs for the relevant command """ @@ -73,14 +73,14 @@ def change_action_button(self, *args): for cmd, action in self.actionbtns.items(): btnact = action - if tk_vars["runningtask"].get(): + if tk_vars.running_task.get(): ttl = " Stop" img = get_images().icons["stop"] hlp = "Exit the running process" else: - ttl = " {}".format(cmd.title()) + ttl = f" {cmd.title()}" img = get_images().icons["start"] - hlp = "Run the {} script".format(cmd.title()) + hlp = f"Run the {cmd.title()} script" logger.debug("Updated Action Button: '%s'", ttl) btnact.config(text=ttl, image=img) Tooltip(btnact, text=hlp, wrap_length=200) @@ -88,7 +88,7 @@ def change_action_button(self, *args): def _set_modified_vars(self): """ Set the tkinter variable for each tab to indicate whether contents have been modified """ - tkvars = dict() + tkvars = {} for tab in self.tab_names: if tab == "tools": for ttab in self.tools_tab_names: @@ -116,7 +116,7 @@ class CommandTab(ttk.Frame): # pylint:disable=too-many-ancestors def __init__(self, parent, category, command): logger.debug("Initializing %s: (category: '%s', command: '%s')", self.__class__.__name__, category, command) - super().__init__(parent, name="tab_{}".format(command.lower())) + super().__init__(parent, name=f"tab_{command.lower()}") self.category = category self.actionbtns = parent.actionbtns @@ -171,14 +171,14 @@ def add_action_button(self, category, actionbtns): actframe.pack(fill=tk.X, side=tk.RIGHT) tk_vars = get_config().tk_vars - var_value = "{},{}".format(category, self.command) + var_value = f"{category},{self.command}" btngen = ttk.Button(actframe, image=get_images().icons["generate"], text=" Generate", compound=tk.LEFT, width=14, - command=lambda: tk_vars["generate"].set(var_value)) + command=lambda: tk_vars.generate_command.set(var_value)) btngen.pack(side=tk.LEFT, padx=5) Tooltip(btngen, text=_("Output command line options to the console"), @@ -186,10 +186,10 @@ def add_action_button(self, category, actionbtns): btnact = ttk.Button(actframe, image=get_images().icons["start"], - text=" {}".format(self.title), + text=f" {self.title}", compound=tk.LEFT, width=14, - command=lambda: tk_vars["action"].set(var_value)) + command=lambda: tk_vars.action_command.set(var_value)) btnact.pack(side=tk.LEFT, fill=tk.X, expand=True) Tooltip(btnact, text=_("Run the {} script").format(self.title), diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index a3dabc272a..7ee99d5cb4 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -343,12 +343,12 @@ def _model_callback(var): if not config.user_config_dict["auto_load_model_stats"]: logger.debug("Session updating disabled by user config") return - if config.tk_vars["runningtask"].get(): + if config.tk_vars.running_task.get(): logger.debug("Task running. Not updating session") return folder = var.get() logger.debug("Setting analysis model folder callback: '%s'", folder) - get_config().tk_vars["analysis_folder"].set(folder) + get_config().tk_vars.analysis_folder.set(folder) class ControlPanel(ttk.Frame): # pylint:disable=too-many-ancestors diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index 007a767804..6eb015b57c 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -141,7 +141,7 @@ def __init__(self, parent, debug): self._console = _ReadOnlyText(self, relief=tk.FLAT) rc_menu = ContextMenu(self._console) rc_menu.cm_bind() - self._console_clear = get_config().tk_vars['console_clear'] + self._console_clear = get_config().tk_vars.console_clear self._set_console_clear_var_trace() self._debug = debug self._build_console() diff --git a/lib/gui/display.py b/lib/gui/display.py index 86141296cd..be309abc3c 100644 --- a/lib/gui/display.py +++ b/lib/gui/display.py @@ -35,8 +35,8 @@ def __init__(self, parent): super().__init__(parent) parent.add(self) tk_vars = get_config().tk_vars - self._wrapper_var = tk_vars["display"] - self._runningtask = tk_vars["runningtask"] + self._wrapper_var = tk_vars.display + self._running_task = tk_vars.running_task self._set_wrapper_var_trace() self._add_static_tabs() @@ -46,10 +46,10 @@ def __init__(self, parent): logger.debug("Initialized %s", self.__class__.__name__) @property - def runningtask(self): + def running_task(self): """ :class:`tkinter.BooleanVar`: The global tkinter variable that indicates whether a Faceswap task is currently running or not. """ - return self._runningtask + return self._running_task def _set_wrapper_var_trace(self): """ Sets the trigger to update the displayed notebook's pages when the global tkinter @@ -95,7 +95,7 @@ def _command_display(self, command): command: str The Faceswap command that is being executed """ - build_tabs = getattr(self, "_{}_tabs".format(command)) + build_tabs = getattr(self, f"_{command}_tabs") build_tabs() def _extract_tabs(self, command="extract"): diff --git a/lib/gui/display_analysis.py b/lib/gui/display_analysis.py index a682227622..28c851b2e1 100644 --- a/lib/gui/display_analysis.py +++ b/lib/gui/display_analysis.py @@ -63,9 +63,9 @@ def set_vars(self): The dictionary of variable names to tkinter variables """ return dict(selected_id=tk.StringVar(), - refresh_graph=get_config().tk_vars["refreshgraph"], - is_training=get_config().tk_vars["istraining"], - analysis_folder=get_config().tk_vars["analysis_folder"]) + refresh_graph=get_config().tk_vars.refresh_graph, + is_training=get_config().tk_vars.is_training, + analysis_folder=get_config().tk_vars.analysis_folder) def on_tab_select(self): """ Callback for when the analysis tab is selected. diff --git a/lib/gui/display_command.py b/lib/gui/display_command.py index a43b54acab..a354c4b23b 100644 --- a/lib/gui/display_command.py +++ b/lib/gui/display_command.py @@ -4,10 +4,11 @@ import gettext import logging import os +import sys import tkinter as tk from tkinter import ttk - +from typing import cast, Dict, Optional, Tuple, TYPE_CHECKING from .display_graph import TrainingGraph from .display_page import DisplayOptionalPage @@ -16,6 +17,14 @@ from .control_helper import set_slider_rounding from .utils import FileHandler, get_config, get_images, preview_trigger +if sys.version_info < (3, 8): + from typing_extensions import get_args, Literal +else: + from typing import get_args, Literal + +if TYPE_CHECKING: + from PIL import Image + logger = logging.getLogger(__name__) # pylint: disable=invalid-name # LOCALES @@ -26,23 +35,23 @@ class PreviewExtract(DisplayOptionalPage): # pylint: disable=too-many-ancestors """ Tab to display output preview images for extract and convert """ - def display_item_set(self): + def display_item_set(self) -> None: """ Load the latest preview if available """ - logger.trace("Loading latest preview") + logger.trace("Loading latest preview") # type:ignore size = 256 if self.command == "convert" else 128 get_images().load_latest_preview(thumbnail_size=int(size * get_config().scaling_factor), frame_dims=(self.winfo_width(), self.winfo_height())) self.display_item = get_images().previewoutput - def display_item_process(self): + def display_item_process(self) -> None: """ Display the preview """ - logger.trace("Displaying preview") + logger.trace("Displaying preview") # type:ignore if not self.subnotebook.children: self.add_child() else: self.update_child() - def add_child(self): + def add_child(self) -> None: """ Add the preview label child """ logger.debug("Adding child") preview = self.subnotebook_add_page(self.tabname, widget=None) @@ -50,13 +59,13 @@ def add_child(self): lblpreview.pack(side=tk.TOP, anchor=tk.NW) Tooltip(lblpreview, text=self.helptext, wrap_length=200) - def update_child(self): + def update_child(self) -> None: """ Update the preview image on the label """ - logger.trace("Updating preview") + logger.trace("Updating preview") # type:ignore for widget in self.subnotebook_get_widgets(): widget.configure(image=get_images().previewoutput[1]) - def save_items(self): + def save_items(self) -> None: """ Open save dialogue and save preview """ location = FileHandler("dir", None).return_file if not location: @@ -71,52 +80,53 @@ def save_items(self): class PreviewTrain(DisplayOptionalPage): # pylint: disable=too-many-ancestors """ Training preview image(s) """ - def __init__(self, *args, **kwargs): - self.update_preview = get_config().tk_vars["updatepreview"] + def __init__(self, *args, **kwargs) -> None: + self.update_preview = get_config().tk_vars.update_preview super().__init__(*args, **kwargs) - def add_options(self): + def add_options(self) -> None: """ Add the additional options """ self._add_option_refresh() self._add_option_mask_toggle() super().add_options() - def _add_option_refresh(self): + def _add_option_refresh(self) -> None: """ Add refresh button to refresh preview immediately """ logger.debug("Adding refresh option") btnrefresh = ttk.Button(self.optsframe, image=get_images().icons["reload"], - command=lambda x="update": preview_trigger().set(x)) + command=lambda x="update": preview_trigger().set(x)) # type:ignore btnrefresh.pack(padx=2, side=tk.RIGHT) Tooltip(btnrefresh, text=_("Preview updates at every model save. Click to refresh now."), wrap_length=200) logger.debug("Added refresh option") - def _add_option_mask_toggle(self): + def _add_option_mask_toggle(self) -> None: """ Add button to toggle mask display on and off """ logger.debug("Adding mask toggle option") - btntoggle = ttk.Button(self.optsframe, - image=get_images().icons["mask2"], - command=lambda x="mask_toggle": preview_trigger().set(x)) + btntoggle = ttk.Button( + self.optsframe, + image=get_images().icons["mask2"], + command=lambda x="mask_toggle": preview_trigger().set(x)) # type:ignore btntoggle.pack(padx=2, side=tk.RIGHT) Tooltip(btntoggle, text=_("Click to toggle mask overlay on and off."), wrap_length=200) logger.debug("Added mask toggle option") - def display_item_set(self): + def display_item_set(self) -> None: """ Load the latest preview if available """ - logger.trace("Loading latest preview") + logger.trace("Loading latest preview") # type:ignore if not self.update_preview.get(): - logger.trace("Preview not updated") + logger.trace("Preview not updated") # type:ignore return get_images().load_training_preview() self.display_item = get_images().previewtrain - def display_item_process(self): + def display_item_process(self) -> None: """ Display the preview(s) resized as appropriate """ - logger.trace("Displaying preview") + logger.trace("Displaying preview") # type:ignore sortednames = sorted(list(get_images().previewtrain.keys())) existing = self.subnotebook_get_titles_ids() should_update = self.update_preview.get() @@ -131,23 +141,37 @@ def display_item_process(self): if should_update: self.update_preview.set(False) - def add_child(self, name): - """ Add the preview canvas child """ + def add_child(self, name: str) -> None: + """ Add the preview canvas child + + Parameters + ---------- + name: str + The name of the notebook tab to add + """ logger.debug("Adding child") preview = PreviewTrainCanvas(self.subnotebook, name) preview = self.subnotebook_add_page(name, widget=preview) Tooltip(preview, text=self.helptext, wrap_length=200) self.vars["modified"].set(get_images().previewtrain[name][2]) - def update_child(self, tab_id, name): - """ Update the preview canvas """ + def update_child(self, tab_id: int, name: str) -> None: + """ Update the preview canvas + + Parameters + ---------- + tab_id: int + The index of the tab to update + name: str + The name of the tab to update + """ logger.debug("Updating preview") if self.vars["modified"].get() != get_images().previewtrain[name][2]: self.vars["modified"].set(get_images().previewtrain[name][2]) widget = self.subnotebook_page_from_id(tab_id) widget.reload() - def save_items(self): + def save_items(self) -> None: """ Open save dialogue and save preview """ location = FileHandler("dir", None).return_file if not location: @@ -157,8 +181,16 @@ def save_items(self): class PreviewTrainCanvas(ttk.Frame): # pylint: disable=too-many-ancestors - """ Canvas to hold a training preview image """ - def __init__(self, parent, previewname): + """ Canvas to hold a training preview image + + Parameters + ---------- + parent: :class:`tkinter.ttk.Notebook` + The notebook that the training image canvas belongs to + previewname: str + The name of the preview image displayed in the canvas + """ + def __init__(self, parent: ttk.Notebook, previewname: str) -> None: logger.debug("Initializing %s: (previewname: '%s')", self.__class__.__name__, previewname) ttk.Frame.__init__(self, parent) @@ -175,38 +207,56 @@ def __init__(self, parent, previewname): self.bind("", self.resize) logger.debug("Initialized %s:", self.__class__.__name__) - def resize(self, event): - """ Resize the image to fit the frame, maintaining aspect ratio """ - logger.trace("Resizing preview image") - framesize = (event.width, event.height) + def resize(self, event: tk.Event) -> None: + """ Resize the image to fit the frame, maintaining aspect ratio + + Parameters + ---------- + event: :class:`tkinter.Event` + The resize event object + """ + logger.trace("Resizing preview image") # type:ignore + framesize: Optional[Tuple[int, int]] = (event.width, event.height) # Sometimes image is resized before frame is drawn framesize = None if framesize == (1, 1) else framesize get_images().resize_image(self.name, framesize) self.reload() - def reload(self): + def reload(self) -> None: """ Reload the preview image """ - logger.trace("Reloading preview image") + logger.trace("Reloading preview image") # type:ignore self.previewimage = get_images().previewtrain[self.name][1] self.canvas.itemconfig(self.imgcanvas, image=self.previewimage) - def save_preview(self, location): - """ Save the figure to file """ + def save_preview(self, location: str) -> None: + """ Save the figure to file. + + Parameters + ---------- + location: str + The full path to the location to save the preview image + """ filename = self.name now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") filename = os.path.join(location, f"{filename}_{now}.png") - get_images().previewtrain[self.name][0].save(filename) + cast("Image.Image", get_images().previewtrain[self.name][0]).save(filename) logger.debug("Saved preview to %s", filename) print(f"Saved preview to {filename}") class GraphDisplay(DisplayOptionalPage): # pylint: disable=too-many-ancestors """ The Graph Tab of the Display section """ - def __init__(self, parent, tab_name, helptext, wait_time, command=None): - self._trace_vars = {} + def __init__(self, + parent: ttk.Notebook, + tab_name: str, + helptext: str, + wait_time: int, + command: Optional[str] = None) -> None: + self._trace_vars: Dict[Literal["smoothgraph", "display_iterations"], + Tuple[tk.BooleanVar, str]] = {} super().__init__(parent, tab_name, helptext, wait_time, command) - def set_vars(self): + def set_vars(self) -> None: """ Add graphing specific variables to the default variables. Overrides original method. @@ -237,17 +287,17 @@ def set_vars(self): logger.debug(tk_vars) return tk_vars - def on_tab_select(self): + def on_tab_select(self) -> None: """ Callback for when the graph tab is selected. Pull latest data and run the tab's update code when the tab is selected. """ logger.debug("Callback received for '%s' tab", self.tabname) if self.display_item is not None: - get_config().tk_vars["refreshgraph"].set(True) + get_config().tk_vars.refresh_graph.set(True) self._update_page() - def add_options(self): + def add_options(self) -> None: """ Add the additional options """ self._add_option_refresh() super().add_options() @@ -256,10 +306,10 @@ def add_options(self): self._add_option_smoothing() self._add_option_iterations() - def _add_option_refresh(self): + def _add_option_refresh(self) -> None: """ Add refresh button to refresh graph immediately """ logger.debug("Adding refresh option") - tk_var = get_config().tk_vars["refreshgraph"] + tk_var = get_config().tk_vars.refresh_graph btnrefresh = ttk.Button(self.optsframe, image=get_images().icons["reload"], command=lambda: tk_var.set(True)) @@ -269,7 +319,7 @@ def _add_option_refresh(self): wrap_length=200) logger.debug("Added refresh option") - def _add_option_raw(self): + def _add_option_raw(self) -> None: """ Add check-button to hide/display raw data """ logger.debug("Adding display raw option") tk_var = self.vars["raw_data"] @@ -277,11 +327,11 @@ def _add_option_raw(self): self.optsframe, variable=tk_var, text="Raw", - command=lambda v=tk_var: self._display_data_callback("raw", v)) + command=lambda v=tk_var: self._display_data_callback("raw", v)) # type:ignore chkbtn.pack(side=tk.RIGHT, padx=5, anchor=tk.W) Tooltip(chkbtn, text=_("Display the raw loss data"), wrap_length=200) - def _add_option_smoothed(self): + def _add_option_smoothed(self) -> None: """ Add check-button to hide/display smoothed data """ logger.debug("Adding display smoothed option") tk_var = self.vars["smooth_data"] @@ -289,11 +339,11 @@ def _add_option_smoothed(self): self.optsframe, variable=tk_var, text="Smoothed", - command=lambda v=tk_var: self._display_data_callback("smoothed", v)) + command=lambda v=tk_var: self._display_data_callback("smoothed", v)) # type:ignore chkbtn.pack(side=tk.RIGHT, padx=5, anchor=tk.W) Tooltip(chkbtn, text=_("Display the smoothed loss data"), wrap_length=200) - def _add_option_smoothing(self): + def _add_option_smoothing(self) -> None: """ Add a slider to adjust the smoothing amount """ logger.debug("Adding Smoothing Slider") tk_var = self.vars["smoothgraph"] @@ -312,7 +362,7 @@ def _add_option_smoothing(self): ctl = ttk.Scale( ctl_frame, variable=tk_var, - command=lambda val, var=tk_var, dt=float, rn=3, mm=min_max: + command=lambda val, var=tk_var, dt=float, rn=3, mm=min_max: # type:ignore set_slider_rounding(val, var, dt, rn, mm)) ctl["from_"] = min_max[0] ctl["to"] = min_max[1] @@ -323,7 +373,7 @@ def _add_option_smoothing(self): wrap_length=200) logger.debug("Added Smoothing Slider") - def _add_option_iterations(self): + def _add_option_iterations(self) -> None: """ Add a slider to adjust the amount if iterations to display """ logger.debug("Adding Iterations Slider") tk_var = self.vars["display_iterations"] @@ -342,7 +392,7 @@ def _add_option_iterations(self): ctl = ttk.Scale( ctl_frame, variable=tk_var, - command=lambda val, var=tk_var, dt=int, rn=1000, mm=min_max: + command=lambda val, var=tk_var, dt=int, rn=1000, mm=min_max: # type:ignore set_slider_rounding(val, var, dt, rn, mm)) ctl["from_"] = min_max[0] ctl["to"] = min_max[1] @@ -353,25 +403,25 @@ def _add_option_iterations(self): wrap_length=200) logger.debug("Added Iterations Slider") - def display_item_set(self): + def display_item_set(self) -> None: """ Load the graph(s) if available """ if Session.is_training and Session.logging_disabled: - logger.trace("Logs disabled. Hiding graph") + logger.trace("Logs disabled. Hiding graph") # type:ignore self.set_info("Graph is disabled as 'no-logs' has been selected") self.display_item = None self._clear_trace_variables() elif Session.is_training and self.display_item is None: - logger.trace("Loading graph") + logger.trace("Loading graph") # type:ignore self.display_item = Session self._add_trace_variables() elif Session.is_training and self.display_item is not None: - logger.trace("Graph already displayed. Nothing to do.") + logger.trace("Graph already displayed. Nothing to do.") # type:ignore else: - logger.trace("Clearing graph") + logger.trace("Clearing graph") # type:ignore self.display_item = None self._clear_trace_variables() - def display_item_process(self): + def display_item_process(self) -> None: """ Add a single graph to the graph window """ if not Session.is_training: logger.debug("Waiting for Session Data to become available to graph") @@ -404,7 +454,7 @@ def display_item_process(self): smooth_amount=self.vars["smoothgraph"].get()) self.add_child(tabname, data) - def _smooth_amount_callback(self, *args): + def _smooth_amount_callback(self, *args) -> None: """ Update each graph's smooth amount on variable change """ try: smooth_amount = self.vars["smoothgraph"].get() @@ -416,7 +466,7 @@ def _smooth_amount_callback(self, *args): for graph in self.subnotebook.children.values(): graph.calcs.set_smooth_amount(smooth_amount) - def _iteration_limit_callback(self, *args): + def _iteration_limit_callback(self, *args) -> None: """ Limit the amount of data displayed in the live graph on a iteration slider variable change. """ try: @@ -429,7 +479,7 @@ def _iteration_limit_callback(self, *args): for graph in self.subnotebook.children.values(): graph.calcs.set_iterations_limit(limit) - def _display_data_callback(self, line, variable): + def _display_data_callback(self, line: str, variable: tk.BooleanVar) -> None: """ Update the displayed graph lines based on option check button selection. Parameters @@ -444,15 +494,23 @@ def _display_data_callback(self, line, variable): for graph in self.subnotebook.children.values(): graph.calcs.update_selections(line, var) - def add_child(self, name, data): - """ Add the graph for the selected keys """ + def add_child(self, name: str, data: Calculations) -> None: + """ Add the graph for the selected keys. + + Parameters + ---------- + name: str + The name of the graph to add to the notebook + data: :class:`~lib.gui.analysis.stats.Calculations` + The object holding the data to be graphed + """ logger.debug("Adding child: %s", name) graph = TrainingGraph(self.subnotebook, data, "Loss") graph.build() graph = self.subnotebook_add_page(name, widget=graph) Tooltip(graph, text=self.helptext, wrap_length=200) - def save_items(self): + def save_items(self) -> None: """ Open save dialogue and save graphs """ graphlocation = FileHandler("dir", None).return_file if not graphlocation: @@ -460,15 +518,15 @@ def save_items(self): for graph in self.subnotebook.children.values(): graph.save_fig(graphlocation) - def _add_trace_variables(self): + def _add_trace_variables(self) -> None: """ Add tracing for when the option sliders are updated, for updating the graph. """ - for name, action in zip(("smoothgraph", "display_iterations"), + for name, action in zip(get_args(Literal["smoothgraph", "display_iterations"]), (self._smooth_amount_callback, self._iteration_limit_callback)): var = self.vars[name] if name not in self._trace_vars: self._trace_vars[name] = (var, var.trace("w", action)) - def _clear_trace_variables(self): + def _clear_trace_variables(self) -> None: """ Clear all of the trace variables from :attr:`_trace_vars` and reset the dictionary. """ if self._trace_vars: for name, (var, trace) in self._trace_vars.items(): @@ -476,7 +534,7 @@ def _clear_trace_variables(self): var.trace_vdelete("w", trace) self._trace_vars = {} - def close(self): + def close(self) -> None: """ Clear the plots from RAM """ self._clear_trace_variables() if self.subnotebook is None: diff --git a/lib/gui/display_graph.py b/lib/gui/display_graph.py index b7324caa22..503cce5221 100755 --- a/lib/gui/display_graph.py +++ b/lib/gui/display_graph.py @@ -342,7 +342,7 @@ def __init__(self, parent: ttk.Frame, data, ylabel: str) -> None: def _add_callback(self) -> None: """ Add the variable trace to update graph on refresh button press or save iteration. """ - get_config().tk_vars["refreshgraph"].trace("w", self.refresh) # type:ignore + get_config().tk_vars.refresh_graph.trace("w", self.refresh) # type:ignore def build(self) -> None: """ Build the Training graph. """ @@ -352,7 +352,7 @@ def build(self) -> None: def refresh(self, *args) -> None: # pylint: disable=unused-argument """ Read the latest loss data and apply to current graph """ - refresh_var = cast(tk.BooleanVar, get_config().tk_vars["refreshgraph"]) + refresh_var = cast(tk.BooleanVar, get_config().tk_vars.refresh_graph) if not refresh_var.get() and self._thread is None: return @@ -529,7 +529,7 @@ def __init__(self, # pylint: disable=super-init-not-called self.pack(side=tk.BOTTOM, fill=tk.X) @staticmethod - def _Button(frame: ttk.Frame, # pylint:disable=arguments-differ + def _Button(frame: ttk.Frame, # pylint:disable=arguments-differ,arguments-renamed text: str, image_file: str, toggle: bool, diff --git a/lib/gui/display_page.py b/lib/gui/display_page.py index 32ebcb1bb2..05d2bae031 100644 --- a/lib/gui/display_page.py +++ b/lib/gui/display_page.py @@ -25,7 +25,7 @@ def __init__(self, parent, tab_name, helptext): ttk.Frame.__init__(self, parent) self._parent = parent - self.runningtask = parent.runningtask + self.running_task = parent.running_task self.helptext = helptext self.tabname = tab_name @@ -56,12 +56,11 @@ def add_optional_vars(self, varsdict): logger.debug("Adding: (%s: %s)", key, val) self.vars[key] = val - @staticmethod - def set_vars(): + def set_vars(self): """ Override to return a dict of page specific variables """ return {} - def on_tab_select(self): # pylint:disable=no-self-use + def on_tab_select(self): """ Override for specific actions when the current tab is selected """ logger.debug("Returning as 'on_tab_select' not implemented for %s", self.__class__.__name__) @@ -183,8 +182,7 @@ def __init__(self, parent, tab_name, helptext, wait_time, command=None): self.update_idletasks() self._update_page() - @staticmethod - def set_vars(): + def set_vars(self): """ Analysis specific vars """ enabled = tk.BooleanVar() enabled.set(True) @@ -265,7 +263,7 @@ def on_chkenable_change(self): def _update_page(self): """ Update the latest preview item """ - if not self.runningtask.get() or not self._tab_is_active: + if not self.running_task.get() or not self._tab_is_active: return if self.vars["enabled"].get(): logger.trace("Updating page: %s", self.__class__.__name__) diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 370313d38b..6cbc2d2fe9 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -347,7 +347,7 @@ def in_thread(self, action): @staticmethod def clear_console(): """ Clear the console window """ - get_config().tk_vars["console_clear"].set(True) + get_config().tk_vars.console_clear.set(True) def output_sysinfo(self): """ Output system information to console """ diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 1119990026..bc49316a9f 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -682,7 +682,7 @@ def save(self, page_only=False): logger.info("Saved config: '%s'", config.configfile) if category == "gui": - if not get_config().tk_vars["runningtask"].get(): + if not get_config().tk_vars.running_task.get(): get_config().root.rebuild() else: logger.info("Can't redraw GUI whilst a task is running. GUI Settings will be " diff --git a/lib/gui/project.py b/lib/gui/project.py index 7967a70129..508890fcd2 100644 --- a/lib/gui/project.py +++ b/lib/gui/project.py @@ -144,7 +144,7 @@ def _set_filename(self, filename=None, sess_type="project"): bool: `True` if filename has been successfully set otherwise ``False`` """ logger.debug("filename: '%s', sess_type: '%s'", filename, sess_type) - handler = "config_{}".format(sess_type) + handler = f"config_{sess_type}" if filename is None: logger.debug("Popping file handler") @@ -156,7 +156,7 @@ def _set_filename(self, filename=None, sess_type="project"): cfgfile.close() if not os.path.isfile(filename): - msg = "File does not exist: '{}'".format(filename) + msg = f"File does not exist: '{filename}'" logger.error(msg) return False ext = os.path.splitext(filename)[1] @@ -209,7 +209,7 @@ def _get_options_for_command(self, command): opts = self._options.get(command, None) retval = {command: opts} if not opts: - self._config.tk_vars["console_clear"].set(True) + self._config.tk_vars.console_clear.set(True) logger.info("No %s section found in file", command) retval = None logger.debug(retval) @@ -380,10 +380,9 @@ def _save_as_to_filename(self, session_type): True if :attr:`filename` successfully set otherwise ``False`` """ logger.debug("Popping save as file handler. session_type: '%s'", session_type) - title = "Save {}As...".format("{} ".format(session_type.title()) - if session_type != "all" else "") + title = f"Save {f'{session_type.title()} ' if session_type != 'all' else ''}As..." cfgfile = self._file_handler("save", - "config_{}".format(session_type), + f"config_{session_type}", title=title, initial_folder=self._dirname).return_file if not cfgfile: @@ -432,7 +431,7 @@ class Tasks(_GuiSession): """ def __init__(self, config, file_handler): super().__init__(config, file_handler) - self._tasks = dict() + self._tasks = {} @property def _is_project(self): @@ -539,7 +538,7 @@ def _update_legacy_task(self, filename): logger.debug("Not a .fsw file: '%s'", filename) return filename - new_filename = "{}.fst".format(fname) + new_filename = f"{fname}.fst" logger.debug("Renaming '%s' to '%s'", filename, new_filename) os.rename(filename, new_filename) self._del_from_recent(filename, save=True) @@ -612,7 +611,7 @@ def clear_tasks(self): called by :class:`Project` when a project has been loaded which is in fact a task. """ logger.debug("Clearing stored tasks") - self._tasks = dict() + self._tasks = {} def add_project_task(self, filename, command, options): """ Add an individual task from a loaded :class:`Project` to the internal :attr:`_tasks` @@ -684,7 +683,7 @@ def cli_options(self): @property def _project_modified(self): """bool: ``True`` if the project has been modified otherwise ``False``. """ - return any([var.get() for var in self._modified_vars.values()]) + return any(var.get() for var in self._modified_vars.values()) @property def _tasks(self): diff --git a/lib/gui/theme.py b/lib/gui/theme.py index 2e29abbe62..777894b370 100644 --- a/lib/gui/theme.py +++ b/lib/gui/theme.py @@ -196,7 +196,7 @@ def combobox(self, key, control_color, active_color, arrow_color, control_border The color of the input field's border """ # All the stock down arrow images are bad - images = dict() + images = {} for state in ("active", "normal"): images[f"arrow_{state}"] = self._images.get_image( (20, 20), @@ -343,7 +343,7 @@ def scrollbar(self, key, trough_color, border_color, control_backgrounds, contro "control_backgrounds: %s, control_foregrounds: %s, control_borders: %s)", key, trough_color, border_color, control_backgrounds, control_foregrounds, control_borders) - images = dict() + images = {} for idx, state in enumerate(("normal", "disabled", "active")): # Create arrow and slider widgets for each state img_args = ((16, 16), control_backgrounds[idx]) @@ -370,7 +370,7 @@ def scrollbar(self, key, trough_color, border_color, control_backgrounds, contro ("disabled", images[f"img_{lookup}_disabled"]), ("pressed !disabled", images[f"img_{lookup}_active"]), ("active !disabled", images[f"img_{lookup}_active"])) - kwargs = dict(border=1, sticky="ns") if element == "thumb" else dict() + kwargs = dict(border=1, sticky="ns") if element == "thumb" else {} self._style.element_create(*args, **kwargs) # Get a configurable trough diff --git a/lib/gui/utils/config.py b/lib/gui/utils/config.py index 00a49414a8..b57e78cad0 100644 --- a/lib/gui/utils/config.py +++ b/lib/gui/utils/config.py @@ -6,7 +6,7 @@ import tkinter as tk from dataclasses import dataclass, field -from typing import Any, cast, Dict, Optional, Tuple, TYPE_CHECKING, Union +from typing import Any, cast, Dict, Optional, Tuple, TYPE_CHECKING from lib.gui._config import Config as UserConfig from lib.gui.project import Project, Tasks @@ -69,11 +69,92 @@ def get_config() -> "Config": return _CONFIG +class GlobalVariables(): + """ Global tkinter variables accessible from all parts of the GUI. Should only be accessed from + :attr:`get_config().tk_vars` """ + def __init__(self) -> None: + logger.debug("Initializing %s", self.__class__.__name__) + self._display = tk.StringVar() + self._running_task = tk.BooleanVar() + self._is_training = tk.BooleanVar() + self._action_command = tk.StringVar() + self._generate_command = tk.StringVar() + self._console_clear = tk.BooleanVar() + self._refresh_graph = tk.BooleanVar() + self._update_preview = tk.BooleanVar() + self._analysis_folder = tk.StringVar() + + self._initialize_variables() + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def display(self) -> tk.StringVar: + """ :class:`tkinter.StringVar`: The current Faceswap command running """ + return self._display + + @property + def running_task(self) -> tk.BooleanVar: + """ :class:`tkinter.BooleanVar`: ``True`` if a Faceswap task is running otherwise + ``False`` """ + return self._running_task + + @property + def is_training(self) -> tk.BooleanVar: + """ :class:`tkinter.BooleanVar`: ``True`` if Faceswap is currently training otherwise + ``False`` """ + return self._is_training + + @property + def action_command(self) -> tk.StringVar: + """ :class:`tkinter.StringVar`: The command line action to perform """ + return self._action_command + + @property + def generate_command(self) -> tk.StringVar: + """ :class:`tkinter.StringVar`: The command line action to generate """ + return self._generate_command + + @property + def console_clear(self) -> tk.BooleanVar: + """ :class:`tkinter.BooleanVar`: ``True`` if the console should be cleared otherwise + ``False`` """ + return self._console_clear + + @property + def refresh_graph(self) -> tk.BooleanVar: + """ :class:`tkinter.BooleanVar`: ``True`` if the training graph should be refreshed + otherwise ``False`` """ + return self._refresh_graph + + @property + def update_preview(self) -> tk.BooleanVar: + """ :class:`tkinter.BooleanVar`: ``True`` if the preview should be refreshed + otherwise ``False`` """ + return self._update_preview + + @property + def analysis_folder(self) -> tk.StringVar: + """ :class:`tkinter.StringVar`: Full path the analysis folder""" + return self._analysis_folder + + def _initialize_variables(self) -> None: + """ Initialize the default variable values""" + self._display.set("") + self._running_task.set(False) + self._is_training.set(False) + self._action_command.set("") + self._generate_command.set("") + self._console_clear.set(False) + self._refresh_graph.set(False) + self._update_preview.set(False) + self._analysis_folder.set("") + + @dataclass class _GuiObjects: """ Data class for commonly accessed GUI Objects """ cli_opts: "CliOptions" - tk_vars: Dict[str, Union[tk.BooleanVar, tk.StringVar]] + tk_vars: GlobalVariables project: Project tasks: Tasks status_bar: "StatusBar" @@ -107,7 +188,7 @@ def __init__(self, root: tk.Tk, cli_opts: "CliOptions", statusbar: "StatusBar") default_font=self._default_font) self._gui_objects = _GuiObjects( cli_opts=cli_opts, - tk_vars=self._set_tk_vars(), + tk_vars=GlobalVariables(), project=Project(self, FileHandler), tasks=Tasks(self, FileHandler), status_bar=statusbar) @@ -140,7 +221,7 @@ def cli_opts(self) -> "CliOptions": return self._gui_objects.cli_opts @property - def tk_vars(self) -> Dict[str, Union[tk.StringVar, tk.BooleanVar]]: + def tk_vars(self) -> GlobalVariables: """ dict: The global tkinter variables. """ return self._gui_objects.tk_vars @@ -331,52 +412,6 @@ def set_cursor_default(self, widget: Optional[tk.Widget] = None) -> None: component.config(cursor="") # type: ignore component.update_idletasks() - @staticmethod - def _set_tk_vars() -> Dict[str, Union[tk.StringVar, tk.BooleanVar]]: - """ Set the global tkinter variables stored for easy access in :class:`Config`. - - The variables are available through :attr:`tk_vars`. - """ - display = tk.StringVar() - display.set("") - - runningtask = tk.BooleanVar() - runningtask.set(False) - - istraining = tk.BooleanVar() - istraining.set(False) - - actioncommand = tk.StringVar() - actioncommand.set("") - - generatecommand = tk.StringVar() - generatecommand.set("") - - console_clear = tk.BooleanVar() - console_clear.set(False) - - refreshgraph = tk.BooleanVar() - refreshgraph.set(False) - - updatepreview = tk.BooleanVar() - updatepreview.set(False) - - analysis_folder = tk.StringVar() - analysis_folder.set("") - - tk_vars: Dict[str, Union[tk.StringVar, tk.BooleanVar]] = dict( - display=display, - runningtask=runningtask, - istraining=istraining, - action=actioncommand, - generate=generatecommand, - console_clear=console_clear, - refreshgraph=refreshgraph, - updatepreview=updatepreview, - analysis_folder=analysis_folder) - logger.debug(tk_vars) - return tk_vars - def set_root_title(self, text: Optional[str] = None) -> None: """ Set the main title text for Faceswap. diff --git a/lib/gui/utils/file_handler.py b/lib/gui/utils/file_handler.py index 617da1ae3a..7a77c08e8e 100644 --- a/lib/gui/utils/file_handler.py +++ b/lib/gui/utils/file_handler.py @@ -35,7 +35,7 @@ class FileHandler(): # pylint:disable=too-few-public-methods sensitive parameter that returns a certain dialog based on the current options. `dir` asks for a folder location. file_type: ['default', 'alignments', 'config_project', 'config_task', 'config_all', 'csv', \ - 'image', 'ini', 'state', 'log', 'video'] + 'image', 'ini', 'state', 'log', 'video'] or ``None`` The type of file that this dialog is for. `default` allows selection of any files. Other options limit the file type selection title: str, optional @@ -70,7 +70,7 @@ class FileHandler(): # pylint:disable=too-few-public-methods def __init__(self, handle_type: _HANDLETYPE, - file_type: _FILETYPE, + file_type: Optional[_FILETYPE], title: Optional[str] = None, initial_folder: Optional[str] = None, initial_file: Optional[str] = None, @@ -214,7 +214,7 @@ def _set_kwargs(self, title: Optional[str], initial_folder: Optional[str], initial_file: Optional[str], - file_type: _FILETYPE, + file_type: Optional[_FILETYPE], command: Optional[str], action: Optional[str], variable: Optional[str] = None @@ -231,7 +231,7 @@ def _set_kwargs(self, The filename to set with the file dialog. If `None` then tkinter no initial filename is. file_type: ['default', 'alignments', 'config_project', 'config_task', 'config_all', \ - 'csv', 'image', 'ini', 'state', 'log', 'video'] + 'csv', 'image', 'ini', 'state', 'log', 'video'] or ``None`` The type of file that this dialog is for. `default` allows selection of any files. Other options limit the file type selection command: str @@ -269,6 +269,7 @@ def _set_kwargs(self, if self._handletype.lower() in ( "open", "save", "filename", "filename_multi", "save_filename"): + assert file_type is not None kwargs["filetypes"] = self._filetypes[file_type] if self._defaults.get(file_type): kwargs['defaultextension'] = self._defaults[file_type] diff --git a/lib/gui/utils/image.py b/lib/gui/utils/image.py index 3208ae82ec..0ae2ee3cea 100644 --- a/lib/gui/utils/image.py +++ b/lib/gui/utils/image.py @@ -73,13 +73,14 @@ def __init__(self) -> None: logger.debug("Initialized %s", self.__class__.__name__) @property - def previewoutput(self) -> Optional[Tuple[Image.Image, ImageTk.PhotoImage]]: - """ Tuple or ``None``: First item in the tuple is the extract or convert preview image + def previewoutput(self) -> Tuple[Image.Image, ImageTk.PhotoImage]: + """ Tuple: First item in the tuple is the extract or convert preview image (:class:`PIL.Image`), the second item is the image in a format that tkinter can display (:class:`PIL.ImageTK.PhotoImage`). The value of the property is ``None`` if no extract or convert task is running or there are no files available in the output folder. """ + assert self._previewoutput is not None return self._previewoutput @property diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index 276e34b773..6a5f7bc0cb 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -32,56 +32,56 @@ def __init__(self): self.pathscript = os.path.realpath(os.path.dirname(sys.argv[0])) self.command = None self.statusbar = get_config().statusbar - self._training_session_location = dict() + self._training_session_location = {} self.task = FaceswapControl(self) logger.debug("Initialized %s", self.__class__.__name__) def set_callbacks(self): """ Set the tkinter variable callbacks """ logger.debug("Setting tk variable traces") - self.tk_vars["action"].trace("w", self.action_command) - self.tk_vars["generate"].trace("w", self.generate_command) + self.tk_vars.action_command.trace("w", self.action_command) + self.tk_vars.generate_command.trace("w", self.generate_command) def action_command(self, *args): """ The action to perform when the action button is pressed """ - if not self.tk_vars["action"].get(): + if not self.tk_vars.action_command.get(): return - category, command = self.tk_vars["action"].get().split(",") + category, command = self.tk_vars.action_command.get().split(",") - if self.tk_vars["runningtask"].get(): + if self.tk_vars.running_task.get(): self.task.terminate() else: self.command = command args = self.prepare(category) self.task.execute_script(command, args) - self.tk_vars["action"].set(None) + self.tk_vars.action_command.set("") def generate_command(self, *args): """ Generate the command line arguments and output """ - if not self.tk_vars["generate"].get(): + if not self.tk_vars.generate_command.get(): return - category, command = self.tk_vars["generate"].get().split(",") + category, command = self.tk_vars.generate_command.get().split(",") args = self.build_args(category, command=command, generate=True) - self.tk_vars["console_clear"].set(True) + self.tk_vars.console_clear.set(True) logger.debug(" ".join(args)) print(" ".join(args)) - self.tk_vars["generate"].set(None) + self.tk_vars.generate_command.set("") def prepare(self, category): """ Prepare the environment for execution """ logger.debug("Preparing for execution") - self.tk_vars["runningtask"].set(True) - self.tk_vars["console_clear"].set(True) + self.tk_vars.running_task.set(True) + self.tk_vars.console_clear.set(True) if self.command == "train": - self.tk_vars["istraining"].set(True) + self.tk_vars.is_training.set(True) print("Loading...") - self.statusbar.message.set("Executing - {}.py".format(self.command)) + self.statusbar.message.set(f"Executing - {self.command}.py") mode = "indeterminate" if self.command in ("effmpeg", "train") else "determinate" self.statusbar.start(mode) args = self.build_args(category) - self.tk_vars["display"].set(self.command) + self.tk_vars.display.set(self.command) logger.debug("Prepared for execution") return args @@ -94,7 +94,7 @@ def build_args(self, category, command=None, generate=False): logger.debug("Build cli arguments: (category: %s, command: %s, generate: %s)", category, command, generate) command = self.command if not command else command - script = "{}.{}".format(category, "py") + script = f"{category}.py" pathexecscript = os.path.join(self.pathscript, script) args = [sys.executable] if generate else [sys.executable, "-u"] @@ -110,7 +110,7 @@ def build_args(self, category, command=None, generate=False): args.append("-gui") # Indicate to Faceswap that we are running the GUI if generate: # Delimit args with spaces - args = ['"{}"'.format(arg) if " " in arg and not arg.startswith(("[", "(")) + args = [f'"{arg}"' if " " in arg and not arg.startswith(("[", "(")) and not arg.endswith(("]", ")")) else arg for arg in args] logger.debug("Built cli arguments: (%s)", args) @@ -135,13 +135,13 @@ def _get_training_session_info(self, cli_option): def terminate(self, message): """ Finalize wrapper when process has exited """ logger.debug("Terminating Faceswap processes") - self.tk_vars["runningtask"].set(False) + self.tk_vars.running_task.set(False) if self.task.command == "train": - self.tk_vars["istraining"].set(False) + self.tk_vars.is_training.set(False) Session.stop_training() self.statusbar.stop() self.statusbar.message.set(message) - self.tk_vars["display"].set(None) + self.tk_vars.display.set("") get_images().delete_preview() preview_trigger().clear(trigger_type=None) self.command = None @@ -202,11 +202,12 @@ def read_stdout(self): (self.command == "effmpeg" and self.capture_ffmpeg(output)) or (self.command not in ("train", "effmpeg") and self.capture_tqdm(output))): continue - if self.command == "train" and self.wrapper.tk_vars["istraining"].get(): + if self.command == "train" and self.wrapper.tk_vars.is_training.get(): if "[saved models]" in output.strip().lower(): logger.debug("Trigger GUI Training update") - logger.trace("tk_vars: %s", {itm: var.get() - for itm, var in self.wrapper.tk_vars.items()}) + logger.trace("tk_vars: %s", + {itm: var.get() + for itm, var in self.wrapper.tk_vars.__dict__.items()}) if not Session.is_training: # Don't initialize session until after the first save as state # file must exist first @@ -215,10 +216,10 @@ def read_stdout(self): self._session_info["model_folder"], self._session_info["model_name"], is_training=True) - self.wrapper.tk_vars["updatepreview"].set(True) - self.wrapper.tk_vars["refreshgraph"].set(True) + self.wrapper.tk_vars.update_preview.set(True) + self.wrapper.tk_vars.refresh_graph.set(True) if "[preview updated]" in output.strip().lower(): - self.wrapper.tk_vars["updatepreview"].set(True) + self.wrapper.tk_vars.update_preview.set(True) continue print(output.rstrip()) returncode = self.process.poll() @@ -282,8 +283,8 @@ def capture_loss(self, string): logger.trace("Not loss message. Returning False") return False - message = "Total Iterations: {} | ".format(int(loss[0][0])) - message += " ".join(["{}: {}".format(itm[1], itm[2]) for itm in loss]) + message = f"Total Iterations: {int(loss[0][0])} | " + message += " ".join([f"{itm[1]}: {itm[2]}" for itm in loss]) if not message: logger.trace("Error creating loss message. Returning False") return False @@ -298,9 +299,8 @@ def capture_loss(self, string): self.train_stats["iterations"] = iterations elapsed = self.calc_elapsed() - message = "Elapsed: {} | Session Iterations: {} {}".format( - elapsed, - self.train_stats["iterations"], message) + message = (f"Elapsed: {elapsed} | " + f"Session Iterations: {self.train_stats['iterations']} {message}") self.statusbar.progress_update(message, 0, False) logger.trace("Succesfully captured loss: %s", message) return True @@ -312,14 +312,14 @@ def calc_elapsed(self): try: hrs = int(elapsed_time // 3600) if hrs < 10: - hrs = "{0:02d}".format(hrs) - mins = "{0:02d}".format((int(elapsed_time % 3600) // 60)) - secs = "{0:02d}".format((int(elapsed_time % 3600) % 60)) + hrs = f"{hrs:02d}" + mins = f"{(int(elapsed_time % 3600) // 60):02d}" + secs = f"{(int(elapsed_time % 3600) % 60):02d}" except ZeroDivisionError: hrs = "00" mins = "00" secs = "00" - return "{}:{}:{}".format(hrs, mins, secs) + return f"{hrs}:{mins}:{secs}" def capture_tqdm(self, string): """ Capture tqdm output for progress bar """ @@ -332,20 +332,16 @@ def capture_tqdm(self, string): logger.trace("tqdm initializing. Skipping") return True description = tqdm["dsc"].strip() - description = description if description == "" else "{} | ".format(description[:-1]) - processtime = "Elapsed: {} Remaining: {}".format(tqdm["tme"].split("<")[0], - tqdm["tme"].split("<")[1]) - message = "{}{} | {} | {} | {}".format(description, - processtime, - tqdm["rte"], - tqdm["itm"], - tqdm["pct"]) + description = description if description == "" else f"{description[:-1]} | " + processtime = (f"Elapsed: {tqdm['tme'].split('<')[0]} " + f"Remaining: {tqdm['tme'].split('<')[1]}") + msg = f"{description}{processtime} | {tqdm['rte']} | {tqdm['itm']} | {tqdm['pct']}" position = tqdm["pct"].replace("%", "") position = int(position) if position.isdigit() else 0 - self.statusbar.progress_update(message, position, True) - logger.trace("Succesfully captured tqdm message: %s", message) + self.statusbar.progress_update(msg, position, True) + logger.trace("Succesfully captured tqdm message: %s", msg) return True def capture_ffmpeg(self, string): @@ -358,7 +354,7 @@ def capture_ffmpeg(self, string): message = "" for item in ffmpeg: - message += "{}: {} ".format(item[0], item[1]) + message += f"{item[0]}: {item[1]} " if not message: logger.trace("Error creating ffmpeg message. Returning False") return False @@ -375,7 +371,7 @@ def terminate(self): self.thread = LongRunningTask(target=self.terminate_in_thread, args=(self.command, self.process)) if self.command == "train": - self.wrapper.tk_vars["istraining"].set(False) + self.wrapper.tk_vars.is_training.set(False) self.thread.start() self.config.root.after(1000, self.terminate) elif not self.thread.complete.is_set(): @@ -448,7 +444,7 @@ def terminate_all_children(): print("Killed") else: for child in alive: - msg = "Process {} survived SIGKILL. Giving up".format(child) + msg = f"Process {child} survived SIGKILL. Giving up" logger.debug(msg) print(msg) @@ -460,12 +456,12 @@ def set_final_status(self, returncode): if returncode in (0, 3221225786): status = "Ready" elif returncode == -15: - status = "Terminated - {}.py".format(self.command) + status = f"Terminated - {self.command}.py" elif returncode == -9: - status = "Killed - {}.py".format(self.command) + status = f"Killed - {self.command}.py" elif returncode == -6: - status = "Aborted - {}.py".format(self.command) + status = f"Aborted - {self.command}.py" else: - status = "Failed - {}.py. Return Code: {}".format(self.command, returncode) + status = f"Failed - {self.command}.py. Return Code: {returncode}" logger.debug("Set final status: %s", status) return status diff --git a/scripts/gui.py b/scripts/gui.py index da69b9042a..63f8dbf59d 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -144,7 +144,7 @@ def close_app(self, *args): # pylint: disable=unused-argument if not self._config.project.confirm_close(): return - if self._config.tk_vars["runningtask"].get(): + if self._config.tk_vars.running_task.get(): self.wrapper.task.terminate() self._last_session.save() @@ -161,7 +161,7 @@ def _confirm_close_on_running_task(self): ------- bool: ``True`` if user confirms close, ``False`` if user cancels close """ - if not self._config.tk_vars["runningtask"].get(): + if not self._config.tk_vars.running_task.get(): logger.debug("No tasks currently running") return True From 2e8ef5e3c8f2df0f1cca9b342baa8aaa6f620650 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 20 Oct 2022 18:51:39 +0100 Subject: [PATCH 760/981] GUI - Preview updates - Training preview. Embed preview pop-out window - Bugfix - convert/extract previews --- lib/gui/display_command.py | 174 +++------ lib/gui/display_page.py | 6 +- lib/gui/options.py | 3 +- lib/gui/utils/config.py | 8 - lib/gui/utils/image.py | 724 +++++++++++++++++++------------------ lib/gui/wrapper.py | 85 +++-- lib/training/__init__.py | 10 +- lib/training/preview_tk.py | 201 +++++++--- scripts/train.py | 3 +- 9 files changed, 638 insertions(+), 576 deletions(-) diff --git a/lib/gui/display_command.py b/lib/gui/display_command.py index a354c4b23b..ae761e7074 100644 --- a/lib/gui/display_command.py +++ b/lib/gui/display_command.py @@ -8,7 +8,9 @@ import tkinter as tk from tkinter import ttk -from typing import cast, Dict, Optional, Tuple, TYPE_CHECKING +from typing import Dict, Optional, Tuple + +from lib.training.preview_tk import PreviewTk from .display_graph import TrainingGraph from .display_page import DisplayOptionalPage @@ -22,9 +24,6 @@ else: from typing import get_args, Literal -if TYPE_CHECKING: - from PIL import Image - logger = logging.getLogger(__name__) # pylint: disable=invalid-name # LOCALES @@ -34,14 +33,24 @@ class PreviewExtract(DisplayOptionalPage): # pylint: disable=too-many-ancestors """ Tab to display output preview images for extract and convert """ + def __init__(self, *args, **kwargs) -> None: + logger.debug("Initializing %s (args: %s, kwargs: %s)", + self.__class__.__name__, args, kwargs) + self._preview = get_images().preview_extract + super().__init__(*args, **kwargs) + logger.debug("Initialized %s", self.__class__.__name__) def display_item_set(self) -> None: """ Load the latest preview if available """ logger.trace("Loading latest preview") # type:ignore - size = 256 if self.command == "convert" else 128 - get_images().load_latest_preview(thumbnail_size=int(size * get_config().scaling_factor), - frame_dims=(self.winfo_width(), self.winfo_height())) - self.display_item = get_images().previewoutput + size = int(256 if self.command == "convert" else 128 * get_config().scaling_factor) + if not self._preview.load_latest_preview(thumbnail_size=size, + frame_dims=(self.winfo_width(), + self.winfo_height())): + logger.trace("Preview not updated") # type:ignore + return + logger.debug("Preview loaded") + self.display_item = True def display_item_process(self) -> None: """ Display the preview """ @@ -55,7 +64,7 @@ def add_child(self) -> None: """ Add the preview label child """ logger.debug("Adding child") preview = self.subnotebook_add_page(self.tabname, widget=None) - lblpreview = ttk.Label(preview, image=get_images().previewoutput[1]) + lblpreview = ttk.Label(preview, image=self._preview.image) lblpreview.pack(side=tk.TOP, anchor=tk.NW) Tooltip(lblpreview, text=self.helptext, wrap_length=200) @@ -63,7 +72,7 @@ def update_child(self) -> None: """ Update the preview image on the label """ logger.trace("Updating preview") # type:ignore for widget in self.subnotebook_get_widgets(): - widget.configure(image=get_images().previewoutput[1]) + widget.configure(image=self._preview.image) def save_items(self) -> None: """ Open save dialogue and save preview """ @@ -73,16 +82,19 @@ def save_items(self) -> None: filename = "extract_convert_preview" now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") filename = os.path.join(location, f"{filename}_{now}.png") - get_images().previewoutput[0].save(filename) - logger.debug("Saved preview to %s", filename) + self._preview.save(filename) print(f"Saved preview to {filename}") class PreviewTrain(DisplayOptionalPage): # pylint: disable=too-many-ancestors """ Training preview image(s) """ def __init__(self, *args, **kwargs) -> None: - self.update_preview = get_config().tk_vars.update_preview + logger.debug("Initializing %s (args: %s, kwargs: %s)", + self.__class__.__name__, args, kwargs) + self._preview = get_images().preview_train + self._display: Optional[PreviewTk] = None super().__init__(*args, **kwargs) + logger.debug("Initialized %s", self.__class__.__name__) def add_options(self) -> None: """ Add the additional options """ @@ -90,6 +102,18 @@ def add_options(self) -> None: self._add_option_mask_toggle() super().add_options() + def subnotebook_hide(self) -> None: + """ Override default subnotebook hide action to also remove the embedded option bar + control and reset the training image buffer """ + if self.subnotebook and self.subnotebook.winfo_ismapped(): + logger.debug("Removing preview controls from options bar") + if self._display is not None: + self._display.remove_option_controls() + super().subnotebook_hide() + del self._display + self._display = None + self._preview.reset() + def _add_option_refresh(self) -> None: """ Add refresh button to refresh preview immediately """ logger.debug("Adding refresh option") @@ -117,131 +141,33 @@ def _add_option_mask_toggle(self) -> None: def display_item_set(self) -> None: """ Load the latest preview if available """ + # TODO This seems to be triggering faster than the waittime logger.trace("Loading latest preview") # type:ignore - if not self.update_preview.get(): + if not self._preview.load(): logger.trace("Preview not updated") # type:ignore return - get_images().load_training_preview() - self.display_item = get_images().previewtrain + logger.debug("Preview loaded") + self.display_item = True def display_item_process(self) -> None: """ Display the preview(s) resized as appropriate """ - logger.trace("Displaying preview") # type:ignore - sortednames = sorted(list(get_images().previewtrain.keys())) - existing = self.subnotebook_get_titles_ids() - should_update = self.update_preview.get() - - for name in sortednames: - if name not in existing: - self.add_child(name) - elif should_update: - tab_id = existing[name] - self.update_child(tab_id, name) - - if should_update: - self.update_preview.set(False) - - def add_child(self, name: str) -> None: - """ Add the preview canvas child - - Parameters - ---------- - name: str - The name of the notebook tab to add - """ - logger.debug("Adding child") - preview = PreviewTrainCanvas(self.subnotebook, name) - preview = self.subnotebook_add_page(name, widget=preview) - Tooltip(preview, text=self.helptext, wrap_length=200) - self.vars["modified"].set(get_images().previewtrain[name][2]) - - def update_child(self, tab_id: int, name: str) -> None: - """ Update the preview canvas + if self.subnotebook.children: + return - Parameters - ---------- - tab_id: int - The index of the tab to update - name: str - The name of the tab to update - """ - logger.debug("Updating preview") - if self.vars["modified"].get() != get_images().previewtrain[name][2]: - self.vars["modified"].set(get_images().previewtrain[name][2]) - widget = self.subnotebook_page_from_id(tab_id) - widget.reload() + logger.debug("Displaying preview") + self._display = PreviewTk(self._preview.buffer, self.subnotebook, self.optsframe, None) + self.subnotebook_add_page(self.tabname, widget=self._display.master_frame) def save_items(self) -> None: """ Open save dialogue and save preview """ + if self._display is None: + return + location = FileHandler("dir", None).return_file if not location: return - for preview in self.subnotebook.children.values(): - preview.save_preview(location) - - -class PreviewTrainCanvas(ttk.Frame): # pylint: disable=too-many-ancestors - """ Canvas to hold a training preview image - - Parameters - ---------- - parent: :class:`tkinter.ttk.Notebook` - The notebook that the training image canvas belongs to - previewname: str - The name of the preview image displayed in the canvas - """ - def __init__(self, parent: ttk.Notebook, previewname: str) -> None: - logger.debug("Initializing %s: (previewname: '%s')", self.__class__.__name__, previewname) - ttk.Frame.__init__(self, parent) - - self.name = previewname - get_images().resize_image(self.name, None) - self.previewimage = get_images().previewtrain[self.name][1] - - self.canvas = tk.Canvas(self, bd=0, highlightthickness=0) - self.canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True) - self.imgcanvas = self.canvas.create_image(0, - 0, - image=self.previewimage, - anchor=tk.NW) - self.bind("", self.resize) - logger.debug("Initialized %s:", self.__class__.__name__) - - def resize(self, event: tk.Event) -> None: - """ Resize the image to fit the frame, maintaining aspect ratio - Parameters - ---------- - event: :class:`tkinter.Event` - The resize event object - """ - logger.trace("Resizing preview image") # type:ignore - framesize: Optional[Tuple[int, int]] = (event.width, event.height) - # Sometimes image is resized before frame is drawn - framesize = None if framesize == (1, 1) else framesize - get_images().resize_image(self.name, framesize) - self.reload() - - def reload(self) -> None: - """ Reload the preview image """ - logger.trace("Reloading preview image") # type:ignore - self.previewimage = get_images().previewtrain[self.name][1] - self.canvas.itemconfig(self.imgcanvas, image=self.previewimage) - - def save_preview(self, location: str) -> None: - """ Save the figure to file. - - Parameters - ---------- - location: str - The full path to the location to save the preview image - """ - filename = self.name - now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - filename = os.path.join(location, f"{filename}_{now}.png") - cast("Image.Image", get_images().previewtrain[self.name][0]).save(filename) - logger.debug("Saved preview to %s", filename) - print(f"Saved preview to {filename}") + self._display.save(location) class GraphDisplay(DisplayOptionalPage): # pylint: disable=too-many-ancestors diff --git a/lib/gui/display_page.py b/lib/gui/display_page.py index 05d2bae031..b45afc14d9 100644 --- a/lib/gui/display_page.py +++ b/lib/gui/display_page.py @@ -190,12 +190,8 @@ def set_vars(self): ready = tk.BooleanVar() ready.set(False) - modified = tk.DoubleVar() - modified.set(None) - tk_vars = {"enabled": enabled, - "ready": ready, - "modified": modified} + "ready": ready} logger.debug(tk_vars) return tk_vars diff --git a/lib/gui/options.py b/lib/gui/options.py index 695988e4f2..babf0e572d 100644 --- a/lib/gui/options.py +++ b/lib/gui/options.py @@ -301,4 +301,5 @@ def gen_cli_arguments(self, command): yield opt if command in ("extract", "convert") and output_dir is not None: - get_images().set_faceswap_output_path(output_dir, batch_mode=batch_mode) + get_images().preview_extract.set_faceswap_output_path(output_dir, + batch_mode=batch_mode) diff --git a/lib/gui/utils/config.py b/lib/gui/utils/config.py index b57e78cad0..1c8dd191da 100644 --- a/lib/gui/utils/config.py +++ b/lib/gui/utils/config.py @@ -81,7 +81,6 @@ def __init__(self) -> None: self._generate_command = tk.StringVar() self._console_clear = tk.BooleanVar() self._refresh_graph = tk.BooleanVar() - self._update_preview = tk.BooleanVar() self._analysis_folder = tk.StringVar() self._initialize_variables() @@ -126,12 +125,6 @@ def refresh_graph(self) -> tk.BooleanVar: otherwise ``False`` """ return self._refresh_graph - @property - def update_preview(self) -> tk.BooleanVar: - """ :class:`tkinter.BooleanVar`: ``True`` if the preview should be refreshed - otherwise ``False`` """ - return self._update_preview - @property def analysis_folder(self) -> tk.StringVar: """ :class:`tkinter.StringVar`: Full path the analysis folder""" @@ -146,7 +139,6 @@ def _initialize_variables(self) -> None: self._generate_command.set("") self._console_clear.set(False) self._refresh_graph.set(False) - self._update_preview.set(False) self._analysis_folder.set("") diff --git a/lib/gui/utils/image.py b/lib/gui/utils/image.py index 0ae2ee3cea..ed8b1c7a9d 100644 --- a/lib/gui/utils/image.py +++ b/lib/gui/utils/image.py @@ -4,11 +4,14 @@ import logging import os import sys -from typing import cast, Dict, List, Optional, Sequence, Tuple, Union +from typing import cast, Dict, List, Optional, Sequence, Tuple +import cv2 import numpy as np from PIL import Image, ImageDraw, ImageTk +from lib.training.preview_cv import PreviewBuffer + from .config import get_config, PATHCACHE if sys.version_info < (3, 8): @@ -20,6 +23,7 @@ _IMAGES: Optional["Images"] = None _PREVIEW_TRIGGER: Optional["PreviewTrigger"] = None +TRAININGPREVIEW = ".gui_training_preview.png" def initialize_images() -> None: @@ -47,99 +51,150 @@ def get_images() -> "Images": return _IMAGES -class Images(): - """ The centralized image repository for holding all icons and images required by the GUI. +def _get_previews(image_path: str) -> List[str]: + """ Get the images stored within the given directory. + + Parameters + ---------- + image_path: str + The folder containing images to be scanned + + Returns + ------- + list: + The image filenames stored within the given folder - This class should be initialized on GUI startup through :func:`initialize_images`. Any further - access to this class should be through :func:`get_images`. """ - def __init__(self) -> None: - logger.debug("Initializing %s", self.__class__.__name__) - self._pathpreview = os.path.join(PATHCACHE, "preview") - self._pathoutput: Optional[str] = None - self._batch_mode = False - self._previewoutput: Optional[Tuple[Image.Image, ImageTk.PhotoImage]] = None - self._previewtrain: Dict[str, List[Union[Image.Image, - ImageTk.PhotoImage, - None, - float]]] = {} - self._previewcache: Dict[str, Union[None, float, np.ndarray, List[str]]] = dict( - modified=None, # cache for extract and convert - images=None, - filenames=[], - placeholder=None) - self._errcount = 0 - self._icons = self._load_icons() + logger.debug("Getting images: '%s'", image_path) + if not os.path.isdir(image_path): + logger.debug("Folder does not exist") + return [] + files = [os.path.join(image_path, f) + for f in os.listdir(image_path) if f.lower().endswith((".png", ".jpg"))] + logger.debug("Image files: %s", files) + return files + + +class PreviewTrain(): + """ Handles the loading of the training preview image(s) and adding to the display buffer + + Parameters + ---------- + cache_path: str + Full path to the cache folder that contains the preview images + """ + def __init__(self, cache_path: str) -> None: + logger.debug("Initializing %s: (cache_path: '%s')", self.__class__.__name__, cache_path) + self._buffer = PreviewBuffer() + self._cache_path = cache_path + self._modified: float = 0.0 + self._error_count: int = 0 logger.debug("Initialized %s", self.__class__.__name__) @property - def previewoutput(self) -> Tuple[Image.Image, ImageTk.PhotoImage]: - """ Tuple: First item in the tuple is the extract or convert preview image - (:class:`PIL.Image`), the second item is the image in a format that tkinter can display - (:class:`PIL.ImageTK.PhotoImage`). + def buffer(self) -> PreviewBuffer: + """ :class:`~lib.training.PreviewBuffer` The preview buffer for the training preview + image. """ + return self._buffer + + def load(self) -> bool: + """ Load the latest training preview image(s) from disk and add to :attr:`buffer` """ + logger.trace("Loading Training preview images") # type:ignore + image_files = _get_previews(self._cache_path) + filename = next((fname for fname in image_files + if os.path.basename(fname) == TRAININGPREVIEW), "") + if not filename: + logger.trace("No preview to display") # type:ignore + return False + try: + modified = os.path.getmtime(filename) + if modified <= self._modified: + logger.trace("preview '%s' not updated. Current timestamp: %s, " # type:ignore + "existing timestamp: %s", filename, modified, self._modified) + return False + + logger.debug("Loading preview: '%s'", filename) + img = cv2.imread(filename, cv2.IMREAD_UNCHANGED) + self._modified = modified + self._buffer.add_image(os.path.basename(filename), img) + self._error_count = 0 + except ValueError: + # This is probably an error reading the file whilst it's being saved so ignore it + # for now and only pick up if there have been multiple consecutive fails + logger.warning("Unable to display preview: (image: '%s', attempt: %s)", + img, self._error_count) + if self._error_count < 10: + self._error_count += 1 + else: + logger.error("Error reading the preview file for '%s'", filename) + return False - The value of the property is ``None`` if no extract or convert task is running or there are - no files available in the output folder. """ - assert self._previewoutput is not None - return self._previewoutput + logger.debug("Loaded preview: '%s' (%s)", filename, img.shape) + return True - @property - def previewtrain(self) -> Dict[str, List[Union[Image.Image, ImageTk.PhotoImage, None, float]]]: - """ dict or ``None``: The training preview images. Dictionary key is the image name - (`str`). Dictionary values are a `list` of the training image (:class:`PIL.Image`), the - image formatted for tkinter display (:class:`PIL.ImageTK.PhotoImage`), the last - modification time of the image (`float`). - - The value of this property is ``None`` if training is not running or there are no preview - images available. + def reset(self) -> None: + """ Reset the preview buffer when the display page has been disabled. + + Notes + ----- + The buffer requires resetting, otherwise the re-enabled preview window hangs waiting for a + training image that has already been marked as processed """ - return self._previewtrain + logger.debug("Resetting training preview") + del self._buffer + self._buffer = PreviewBuffer() + self._modified = 0.0 + self._error_count = 0 - @property - def icons(self) -> Dict[str, ImageTk.PhotoImage]: - """ dict: The faceswap icons for all parts of the GUI. The dictionary key is the icon - name (`str`) the value is the icon sized and formatted for display - (:class:`PIL.ImageTK.PhotoImage`). - Example - ------- - >>> icons = get_images().icons - >>> save = icons["save"] - >>> button = ttk.Button(parent, image=save) - >>> button.pack() - """ - return self._icons +class PreviewExtract(): + """ Handles the loading of preview images for extract and convert - @staticmethod - def _load_icons() -> Dict[str, ImageTk.PhotoImage]: - """ Scan the icons cache folder and load the icons into :attr:`icons` for retrieval - throughout the GUI. + Parameters + ---------- + cache_path: str + Full path to the cache folder that contains the preview images + """ + def __init__(self, cache_path: str) -> None: + logger.debug("Initializing %s: (cache_path: '%s')", self.__class__.__name__, cache_path) + self._cache_path = cache_path - Returns - ------- - dict: - The icons formatted as described in :attr:`icons` + self._batch_mode = False + self._output_path = "" + self._modified: float = 0.0 + self._filenames: List[str] = [] + self._images: Optional[np.ndarray] = None + self._placeholder: Optional[np.ndarray] = None + + self._preview_image: Optional[Image.Image] = None + self._preview_image_tk: Optional[ImageTk.PhotoImage] = None + + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def image(self) -> ImageTk.PhotoImage: + """:class:`PIL.ImageTk.PhotoImage` The preview image for displaying in a tkinter canvas """ + assert self._preview_image_tk is not None + return self._preview_image_tk + + def save(self, filename: str) -> None: + """ Save the currently displaying preview image to the given location + + Parameters + ---------- + filename: str + The full path to the filename to save the preview image to """ - size = get_config().user_config_dict.get("icon_size", 16) - size = int(round(size * get_config().scaling_factor)) - icons: Dict[str, ImageTk.PhotoImage] = {} - pathicons = os.path.join(PATHCACHE, "icons") - for fname in os.listdir(pathicons): - name, ext = os.path.splitext(fname) - if ext != ".png": - continue - img = Image.open(os.path.join(pathicons, fname)) - img = ImageTk.PhotoImage(img.resize((size, size), resample=Image.HAMMING)) - icons[name] = img - logger.debug(icons) - return icons + logger.debug("Saving preview to %s", filename) + assert self._preview_image is not None + self._preview_image.save(filename) def set_faceswap_output_path(self, location: str, batch_mode: bool = False) -> None: """ Set the path that will contain the output from an Extract or Convert task. Required so that the GUI can fetch output images to display for return in - :attr:`previewoutput`. + :attr:`preview_image`. Parameters ---------- @@ -148,115 +203,9 @@ def set_faceswap_output_path(self, location: str, batch_mode: bool = False) -> N batch_mode: bool ``True`` if extracting in batch mode otherwise False """ - self._pathoutput = location + self._output_path = location self._batch_mode = batch_mode - def delete_preview(self) -> None: - """ Delete the preview files in the cache folder and reset the image cache. - - Should be called when terminating tasks, or when Faceswap starts up or shuts down. - """ - logger.debug("Deleting previews") - for item in os.listdir(self._pathpreview): - if item.startswith(".gui_training_preview") and item.endswith((".jpg", ".png")): - fullitem = os.path.join(self._pathpreview, item) - logger.debug("Deleting: '%s'", fullitem) - os.remove(fullitem) - for fname in cast(List[str], self._previewcache["filenames"]): - if os.path.basename(fname) == ".gui_preview.jpg": - logger.debug("Deleting: '%s'", fname) - try: - os.remove(fname) - except FileNotFoundError: - logger.debug("File does not exist: %s", fname) - self._clear_image_cache() - - def _clear_image_cache(self) -> None: - """ Clear all cached images. """ - logger.debug("Clearing image cache") - self._pathoutput = None - self._batch_mode = False - self._previewoutput = None - self._previewtrain = {} - self._previewcache = dict(modified=None, # cache for extract and convert - images=None, - filenames=[], - placeholder=None) - - @staticmethod - def _get_images(image_path: str) -> List[str]: - """ Get the images stored within the given directory. - - Parameters - ---------- - image_path: str - The folder containing images to be scanned - - Returns - ------- - list: - The image filenames stored within the given folder - - """ - logger.debug("Getting images: '%s'", image_path) - if not os.path.isdir(image_path): - logger.debug("Folder does not exist") - return [] - files = [os.path.join(image_path, f) - for f in os.listdir(image_path) if f.lower().endswith((".png", ".jpg"))] - logger.debug("Image files: %s", files) - return files - - def load_latest_preview(self, thumbnail_size: int, frame_dims: Tuple[int, int]) -> None: - """ Load the latest preview image for extract and convert. - - Retrieves the latest preview images from the faceswap output folder, resizes to thumbnails - and lays out for display. Places the images into :attr:`previewoutput` for loading into - the display panel. - - Parameters - ---------- - thumbnail_size: int - The size of each thumbnail that should be created - frame_dims: tuple - The (width (`int`), height (`int`)) of the display panel that will display the preview - """ - logger.debug("Loading preview image: (thumbnail_size: %s, frame_dims: %s)", - thumbnail_size, frame_dims) - assert self._pathoutput is not None - image_path = self._get_newest_folder() if self._batch_mode else self._pathoutput - image_files = self._get_images(image_path) - gui_preview = os.path.join(self._pathoutput, ".gui_preview.jpg") - if not image_files or (len(image_files) == 1 and gui_preview not in image_files): - logger.debug("No preview to display") - return - # Filter to just the gui_preview if it exists in folder output - image_files = [gui_preview] if gui_preview in image_files else image_files - logger.debug("Image Files: %s", len(image_files)) - - image_files = self._get_newest_filenames(image_files) - if not image_files: - return - - if not self._load_images_to_cache(image_files, frame_dims, thumbnail_size): - logger.debug("Failed to load any preview images") - if gui_preview in image_files: - # Reset last modified for failed loading of a gui preview image so it is picked - # up next time - self._previewcache["modified"] = None - return - - if image_files == [gui_preview]: - # Delete the preview image so that the main scripts know to output another - logger.debug("Deleting preview image") - os.remove(image_files[0]) - show_image = self._place_previews(frame_dims) - if not show_image: - self._previewoutput = None - return - logger.debug("Displaying preview: %s", self._previewcache["filenames"]) - self._previewoutput = (show_image, ImageTk.PhotoImage(show_image)) - def _get_newest_folder(self) -> str: """ Obtain the most recent folder created in the extraction output folder when processing in batch mode. @@ -268,14 +217,13 @@ def _get_newest_folder(self) -> str: been created, returns the parent output folder """ - assert self._pathoutput is not None - folders = [] if not os.path.exists(self._pathoutput) else [ - os.path.join(self._pathoutput, folder) - for folder in os.listdir(self._pathoutput) - if os.path.isdir(os.path.join(self._pathoutput, folder))] + folders = [] if not os.path.exists(self._output_path) else [ + os.path.join(self._output_path, folder) + for folder in os.listdir(self._output_path) + if os.path.isdir(os.path.join(self._output_path, folder))] folders.sort(key=os.path.getmtime) - retval = folders[-1] if folders else self._pathoutput + retval = folders[-1] if folders else self._output_path logger.debug("sorted folders: %s, return value: %s", folders, retval) return retval @@ -292,27 +240,93 @@ def _get_newest_filenames(self, image_files: List[str]) -> List[str]: list: A list of images that have been modified since the last check """ - if self._previewcache["modified"] is None: + if not self._modified: retval = image_files else: retval = [fname for fname in image_files - if os.path.getmtime(fname) > cast(float, self._previewcache["modified"])] + if os.path.getmtime(fname) > self._modified] if not retval: logger.debug("No new images in output folder") else: - self._previewcache["modified"] = max(os.path.getmtime(img) for img in retval) + self._modified = max(os.path.getmtime(img) for img in retval) logger.debug("Number new images: %s, Last Modified: %s", - len(retval), self._previewcache["modified"]) + len(retval), self._modified) return retval + def _pad_and_border(self, image: Image.Image, size: int) -> np.ndarray: + """ Pad rectangle images to a square and draw borders + + Parameters + ---------- + image: :class:`PIL.Image` + The image to process + size: int + The size of the image as it should be displayed + + Returns + ------- + :class:`numpy.ndarray`: + The processed image + """ + if image.size[0] != image.size[1]: + # Pad to square + new_img = Image.new("RGB", (size, size)) + new_img.paste(image, ((size - image.size[0]) // 2, (size - image.size[1]) // 2)) + image = new_img + draw = ImageDraw.Draw(image) + draw.rectangle(((0, 0), (size, size)), outline="#E5E5E5", width=1) + retval = np.array(image) + logger.trace("image shape: %s", retval.shape) # type: ignore + return retval + + def _process_samples(self, + samples: List[np.ndarray], + filenames: List[str], + num_images: int) -> bool: + """ Process the latest sample images into a displayable image. + + Parameters + ---------- + samples: list + The list of extract/convert preview images to display + filenames: list + The full path to the filenames corresponding to the images + num_images: int + The number of images that should be displayed + + Returns + ------- + bool + ``True`` if samples succesfully compiled otherwise ``False`` + """ + asamples = np.array(samples) + if not np.any(asamples): + logger.debug("No preview images collected.") + return False + + self._filenames = (self._filenames + filenames)[-num_images:] + cache = self._images + + if cache is None: + logger.debug("Creating new cache") + cache = asamples[-num_images:] + else: + logger.debug("Appending to existing cache") + cache = np.concatenate((cache, asamples))[-num_images:] + + self._images = cache + assert self._images is not None + logger.debug("Cache shape: %s", self._images.shape) + return True + def _load_images_to_cache(self, image_files: List[str], frame_dims: Tuple[int, int], thumbnail_size: int) -> bool: """ Load preview images to the image cache. - Load new images and append to cache, filtering the cache the number of thumbnails that will - fit inside the display panel. + Load new images and append to cache, filtering the cache to the number of thumbnails that + will fit inside the display panel. Parameters ---------- @@ -373,69 +387,22 @@ def _load_images_to_cache(self, [fname for fname in show_files if fname not in dropped_files], num_images) - def _pad_and_border(self, image: Image.Image, size: int) -> np.ndarray: - """ Pad rectangle images to a square and draw borders - - Parameters - ---------- - image: :class:`PIL.Image` - The image to process - size: int - The size of the image as it should be displayed - - Returns - ------- - :class:`PIL.Image`: - The processed image - """ - if image.size[0] != image.size[1]: - # Pad to square - new_img = Image.new("RGB", (size, size)) - new_img.paste(image, ((size - image.size[0]) // 2, (size - image.size[1]) // 2)) - image = new_img - draw = ImageDraw.Draw(image) - draw.rectangle(((0, 0), (size, size)), outline="#E5E5E5", width=1) - retval = np.array(image) - logger.trace("image shape: %s", retval.shape) # type: ignore - return retval - - def _process_samples(self, - samples: List[np.ndarray], - filenames: List[str], - num_images: int) -> bool: - """ Process the latest sample images into a displayable image. + def _create_placeholder(self, thumbnail_size: int) -> None: + """ Create a placeholder image for when there are fewer thumbnails available + than columns to display them. Parameters ---------- - samples: list - The list of extract/convert preview images to display - filenames: list - The full path to the filenames corresponding to the images - num_images: int - The number of images that should be displayed - - Returns - ------- - bool - ``True`` if samples succesfully compiled otherwise ``False`` + thumbnail_size: int + The size of the thumbnail that the placeholder should replicate """ - asamples = np.array(samples) - if not np.any(asamples): - logger.debug("No preview images collected.") - return False - - self._previewcache["filenames"] = (cast(List[str], self._previewcache["filenames"]) + - filenames)[-num_images:] - cache = cast(Optional[np.ndarray], self._previewcache["images"]) - if cache is None: - logger.debug("Creating new cache") - cache = asamples[-num_images:] - else: - logger.debug("Appending to existing cache") - cache = np.concatenate((cache, asamples))[-num_images:] - self._previewcache["images"] = cache - logger.debug("Cache shape: %s", cast(np.ndarray, self._previewcache["images"]).shape) - return True + logger.debug("Creating placeholder. thumbnail_size: %s", thumbnail_size) + placeholder = Image.new("RGB", (thumbnail_size, thumbnail_size)) + draw = ImageDraw.Draw(placeholder) + draw.rectangle(((0, 0), (thumbnail_size, thumbnail_size)), outline="#E5E5E5", width=1) + placeholder = np.array(placeholder) + self._placeholder = placeholder + logger.debug("Created placeholder. shape: %s", placeholder.shape) def _place_previews(self, frame_dims: Tuple[int, int]) -> Image.Image: """ Format the preview thumbnails stored in the cache into a grid fitting the display @@ -451,12 +418,12 @@ def _place_previews(self, frame_dims: Tuple[int, int]) -> Image.Image: :class:`PIL.Image`: The final preview display image """ - if self._previewcache.get("images", None) is None: + if self._images is None: logger.debug("No images in cache. Returning None") return None - samples = cast(np.ndarray, self._previewcache["images"]).copy() + samples = self._images.copy() num_images, thumbnail_size = samples.shape[:2] - if self._previewcache["placeholder"] is None: + if self._placeholder is None: self._create_placeholder(thumbnail_size) logger.debug("num_images: %s, thumbnail_size: %s", num_images, thumbnail_size) @@ -465,11 +432,12 @@ def _place_previews(self, frame_dims: Tuple[int, int]) -> Image.Image: if cols == 0 or rows == 0: logger.debug("Cols or Rows is zero. No items to display") return None + remainder = (cols * rows) - num_images if remainder != 0: logger.debug("Padding sample display. Remainder: %s", remainder) - placeholder = np.concatenate([np.expand_dims( - cast(np.ndarray, self._previewcache["placeholder"]), 0)] * remainder) + assert self._placeholder is not None + placeholder = np.concatenate([np.expand_dims(self._placeholder, 0)] * remainder) samples = np.concatenate((samples, placeholder)) display = np.vstack([np.hstack(cast(Sequence, samples[row * cols: (row + 1) * cols])) @@ -477,125 +445,159 @@ def _place_previews(self, frame_dims: Tuple[int, int]) -> Image.Image: logger.debug("display shape: %s", display.shape) return Image.fromarray(display) - def _create_placeholder(self, thumbnail_size: int) -> None: - """ Create a placeholder image for when there are fewer thumbnails available - than columns to display them. + def load_latest_preview(self, thumbnail_size: int, frame_dims: Tuple[int, int]) -> bool: + """ Load the latest preview image for extract and convert. + + Retrieves the latest preview images from the faceswap output folder, resizes to thumbnails + and lays out for display. Places the images into :attr:`preview_image` for loading into + the display panel. Parameters ---------- thumbnail_size: int - The size of the thumbnail that the placeholder should replicate + The size of each thumbnail that should be created + frame_dims: tuple + The (width (`int`), height (`int`)) of the display panel that will display the preview + + Returns + ------- + bool + ``True`` if a preview was succesfully loaded otherwise ``False`` """ - logger.debug("Creating placeholder. thumbnail_size: %s", thumbnail_size) - placeholder = Image.new("RGB", (thumbnail_size, thumbnail_size)) - draw = ImageDraw.Draw(placeholder) - draw.rectangle(((0, 0), (thumbnail_size, thumbnail_size)), outline="#E5E5E5", width=1) - placeholder = np.array(placeholder) - self._previewcache["placeholder"] = placeholder - logger.debug("Created placeholder. shape: %s", placeholder.shape) + logger.debug("Loading preview image: (thumbnail_size: %s, frame_dims: %s)", + thumbnail_size, frame_dims) + image_path = self._get_newest_folder() if self._batch_mode else self._output_path + image_files = _get_previews(image_path) + gui_preview = os.path.join(self._output_path, ".gui_preview.jpg") + if not image_files or (len(image_files) == 1 and gui_preview not in image_files): + logger.debug("No preview to display") + return False + # Filter to just the gui_preview if it exists in folder output + image_files = [gui_preview] if gui_preview in image_files else image_files + logger.debug("Image Files: %s", len(image_files)) + + image_files = self._get_newest_filenames(image_files) + if not image_files: + return False + + if not self._load_images_to_cache(image_files, frame_dims, thumbnail_size): + logger.debug("Failed to load any preview images") + if gui_preview in image_files: + # Reset last modified for failed loading of a gui preview image so it is picked + # up next time + self._modified = 0.0 + return False + + if image_files == [gui_preview]: + # Delete the preview image so that the main scripts know to output another + logger.debug("Deleting preview image") + os.remove(image_files[0]) + show_image = self._place_previews(frame_dims) + if not show_image: + self._preview_image = None + self._preview_image_tk = None + return False + + logger.debug("Displaying preview: %s", self._filenames) + self._preview_image = show_image + self._preview_image_tk = ImageTk.PhotoImage(show_image) + return True + + def delete_previews(self) -> None: + """ Remove any image preview files """ + for fname in self._filenames: + if os.path.basename(fname) == ".gui_preview.jpg": + logger.debug("Deleting: '%s'", fname) + try: + os.remove(fname) + except FileNotFoundError: + logger.debug("File does not exist: %s", fname) - def load_training_preview(self) -> None: - """ Load the training preview images. - Reads the training image currently stored in the cache folder and loads them to - :attr:`previewtrain` for retrieval in the GUI. +class Images(): + """ The centralized image repository for holding all icons and images required by the GUI. + + This class should be initialized on GUI startup through :func:`initialize_images`. Any further + access to this class should be through :func:`get_images`. + """ + def __init__(self) -> None: + logger.debug("Initializing %s", self.__class__.__name__) + self._pathpreview = os.path.join(PATHCACHE, "preview") + self._pathoutput: Optional[str] = None + self._batch_mode = False + self._preview_train = PreviewTrain(self._pathpreview) + self._preview_extract = PreviewExtract(self._pathpreview) + self._icons = self._load_icons() + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def preview_train(self) -> PreviewTrain: + """ :class:`PreviewTrain` The object handling the training preview images """ + return self._preview_train + + @property + def preview_extract(self) -> PreviewExtract: + """ :class:`PreviewTrain` The object handling the training preview images """ + return self._preview_extract + + @property + def icons(self) -> Dict[str, ImageTk.PhotoImage]: + """ dict: The faceswap icons for all parts of the GUI. The dictionary key is the icon + name (`str`) the value is the icon sized and formatted for display + (:class:`PIL.ImageTK.PhotoImage`). + + Example + ------- + >>> icons = get_images().icons + >>> save = icons["save"] + >>> button = ttk.Button(parent, image=save) + >>> button.pack() """ - logger.debug("Loading Training preview images") - image_files = self._get_images(self._pathpreview) - modified = None - if not image_files: - logger.debug("No preview to display") - self._previewtrain = {} - return - for img in image_files: - modified = os.path.getmtime(img) if modified is None else modified - name = os.path.basename(img) - name = os.path.splitext(name)[0] - name = name[name.rfind("_") + 1:].title() - try: - logger.debug("Displaying preview: '%s'", img) - size = self._get_current_size(name) - self._previewtrain[name] = [Image.open(img), None, modified] - self.resize_image(name, size) - self._errcount = 0 - except ValueError: - # This is probably an error reading the file whilst it's - # being saved so ignore it for now and only pick up if - # there have been multiple consecutive fails - logger.warning("Unable to display preview: (image: '%s', attempt: %s)", - img, self._errcount) - if self._errcount < 10: - self._errcount += 1 - else: - logger.error("Error reading the preview file for '%s'", img) - print(f"Error reading the preview file for {name}") - del self._previewtrain[name] - - def _get_current_size(self, name: str) -> Optional[Tuple[int, int]]: - """ Return the size of the currently displayed training preview image. + return self._icons - Parameters - ---------- - name: str - The name of the training image to get the size for + @staticmethod + def _load_icons() -> Dict[str, ImageTk.PhotoImage]: + """ Scan the icons cache folder and load the icons into :attr:`icons` for retrieval + throughout the GUI. Returns ------- - width: int - The width of the training image - height: int - The height of the training image - """ - logger.debug("Getting size: '%s'", name) - if not self._previewtrain.get(name): - return None - img = cast(Image.Image, self._previewtrain[name][1]) - if not img: - return None - logger.debug("Got size: (name: '%s', width: '%s', height: '%s')", - name, img.width(), img.height()) - return img.width(), img.height() + dict: + The icons formatted as described in :attr:`icons` - def resize_image(self, name: str, frame_dims: Optional[Tuple[int, int]]) -> None: - """ Resize the training preview image based on the passed in frame size. + """ + size = get_config().user_config_dict.get("icon_size", 16) + size = int(round(size * get_config().scaling_factor)) + icons: Dict[str, ImageTk.PhotoImage] = {} + pathicons = os.path.join(PATHCACHE, "icons") + for fname in os.listdir(pathicons): + name, ext = os.path.splitext(fname) + if ext != ".png": + continue + img = Image.open(os.path.join(pathicons, fname)) + img = ImageTk.PhotoImage(img.resize((size, size), resample=Image.HAMMING)) + icons[name] = img + logger.debug(icons) + return icons - If the canvas that holds the preview image changes, update the image size - to fit the new canvas and refresh :attr:`previewtrain`. + def delete_preview(self) -> None: + """ Delete the preview files in the cache folder and reset the image cache. - Parameters - ---------- - name: str - The name of the training image to be resized - frame_dims: tuple, optional - The (width (`int`), height (`int`)) of the display panel that will display the preview. - ``None`` if the frame dimensions are not known. + Should be called when terminating tasks, or when Faceswap starts up or shuts down. """ - logger.debug("Resizing image: (name: '%s', frame_dims: %s", name, frame_dims) - displayimg = cast(Image.Image, self._previewtrain[name][0]) - if frame_dims: - frameratio = float(frame_dims[0]) / float(frame_dims[1]) - imgratio = float(displayimg.size[0]) / float(displayimg.size[1]) - - if frameratio <= imgratio: - scale = frame_dims[0] / float(displayimg.size[0]) - size = (frame_dims[0], int(displayimg.size[1] * scale)) - else: - scale = frame_dims[1] / float(displayimg.size[1]) - size = (int(displayimg.size[0] * scale), frame_dims[1]) - logger.debug("Scaling: (scale: %s, size: %s", scale, size) - - # Hacky fix to force a reload if it happens to find corrupted - # data, probably due to reading the image whilst it is partially - # saved. If it continues to fail, then eventually raise. - for i in range(0, 1000): - try: - displayimg = displayimg.resize(size, Image.ANTIALIAS) - except OSError: - if i == 999: - raise - continue - break - self._previewtrain[name][1] = ImageTk.PhotoImage(displayimg) + logger.debug("Deleting previews") + for item in os.listdir(self._pathpreview): + if item.startswith(os.path.splitext(TRAININGPREVIEW)[0]) and item.endswith((".jpg", + ".png")): + fullitem = os.path.join(self._pathpreview, item) + logger.debug("Deleting: '%s'", fullitem) + os.remove(fullitem) + + self._preview_extract.delete_previews() + del self._preview_train + del self._preview_extract + self._preview_train = PreviewTrain(self._pathpreview) + self._preview_extract = PreviewExtract(self._pathpreview) class PreviewTrigger(): diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index 6a5f7bc0cb..4a2fce86d6 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -184,9 +184,58 @@ def execute_script(self, command, args): self.thread_stderr() logger.debug("Executed Faceswap") - def read_stdout(self): - """ Read stdout from the subprocess. If training, pass the loss - values to Queue """ + def _process_progress_stdout(self, output: str) -> bool: + """ Process stdout for any faceswap processes that update the status/progress bar(s) + + Parameters + ---------- + output: str + The output line read from stdout + + Returns + ------- + bool + ``True`` if all actions have been completed on the output line otherwise ``False`` + """ + if self.command == "train" and self.capture_loss(output): + return True + + if self.command == "effmpeg" and self.capture_ffmpeg(output): + return True + + if self.command not in ("train", "effmpeg") and self.capture_tqdm(output): + return True + + return False + + def _process_training_stdout(self, output: str) -> None: + """ Process any triggers that are required to update the GUI when Faceswap is running a + training session. + + Parameters + ---------- + output: str + The output line read from stdout + """ + if self.command != "train" or not self.wrapper.tk_vars.is_training.get(): + return + + if "[saved models]" not in output.strip().lower(): + return + + logger.debug("Trigger GUI Training update") + logger.trace("tk_vars: %s", {itm: var.get() # type:ignore + for itm, var in self.wrapper.tk_vars.__dict__.items()}) + if not Session.is_training: + # Don't initialize session until after the first save as state file must exist first + logger.debug("Initializing curret training session") + Session.initialize_session(self._session_info["model_folder"], + self._session_info["model_name"], + is_training=True) + self.wrapper.tk_vars.refresh_graph.set(True) + + def read_stdout(self) -> None: + """ Read stdout from the subprocess. """ logger.debug("Opening stdout reader") while True: try: @@ -195,33 +244,17 @@ def read_stdout(self): if str(err).lower().startswith("i/o operation on closed file"): break raise + if output == "" and self.process.poll() is not None: break + + if output and self._process_progress_stdout(output): + continue + if output: - if ((self.command == "train" and self.capture_loss(output)) or - (self.command == "effmpeg" and self.capture_ffmpeg(output)) or - (self.command not in ("train", "effmpeg") and self.capture_tqdm(output))): - continue - if self.command == "train" and self.wrapper.tk_vars.is_training.get(): - if "[saved models]" in output.strip().lower(): - logger.debug("Trigger GUI Training update") - logger.trace("tk_vars: %s", - {itm: var.get() - for itm, var in self.wrapper.tk_vars.__dict__.items()}) - if not Session.is_training: - # Don't initialize session until after the first save as state - # file must exist first - logger.debug("Initializing curret training session") - Session.initialize_session( - self._session_info["model_folder"], - self._session_info["model_name"], - is_training=True) - self.wrapper.tk_vars.update_preview.set(True) - self.wrapper.tk_vars.refresh_graph.set(True) - if "[preview updated]" in output.strip().lower(): - self.wrapper.tk_vars.update_preview.set(True) - continue + self._process_training_stdout(output) print(output.rstrip()) + returncode = self.process.poll() message = self.set_final_status(returncode) self.wrapper.terminate(message) diff --git a/lib/training/__init__.py b/lib/training/__init__.py index 821ac8c7c8..2990579392 100644 --- a/lib/training/__init__.py +++ b/lib/training/__init__.py @@ -4,15 +4,15 @@ from typing import Type, TYPE_CHECKING -from .augmentation import ImageAugmentation # noqa -from .generator import PreviewDataGenerator, TrainingDataGenerator # noqa -from .preview_cv import PreviewBuffer , TriggerType # noqa +from .augmentation import ImageAugmentation +from .generator import PreviewDataGenerator, TrainingDataGenerator +from .preview_cv import PreviewBuffer, TriggerType if TYPE_CHECKING: from .preview_cv import PreviewBase Preview: Type[PreviewBase] try: - from .preview_tk import PreviewTk as Preview # noqa + from .preview_tk import PreviewTk as Preview except ImportError: - from .preview_cv import PreviewCV as Preview # noqa + from .preview_cv import PreviewCV as Preview diff --git a/lib/training/preview_tk.py b/lib/training/preview_tk.py index 3d2394d3ab..3b567ba1e3 100644 --- a/lib/training/preview_tk.py +++ b/lib/training/preview_tk.py @@ -27,23 +27,24 @@ logger = logging.getLogger(__name__) -# TODO Embed this object in GUI - -class _Taskbar(tk.Frame): +class _Taskbar(): """ Taskbar at bottom of Preview window Parameters ---------- parent: :class:`tkinter.Frame` The parent frame that holds the canvas and taskbar - is_standalone: bool - ``True`` if preview is a pop-up window otherwise ``False`` + taskbar: :class:`tkinter.ttk.Frame` or ``None`` + None if preview is a pop-up window otherwise ttk.Frame if taskbar is managed by the GUI """ - def __init__(self, parent: tk.Frame, is_standalone: bool) -> None: - logger.debug("Initializing %s (parent: '%s', is_standalone: %s)", - self.__class__.__name__, parent, is_standalone) - super().__init__(parent) + def __init__(self, parent: tk.Frame, taskbar: Optional[ttk.Frame]) -> None: + logger.debug("Initializing %s (parent: '%s', taskbar: %s)", + self.__class__.__name__, parent, taskbar) + self._is_standalone = taskbar is None + self._gui_mapped: List[tk.Widget] = [] + self._frame = tk.Frame(parent) if taskbar is None else taskbar + self._min_max_scales = (20, 400) self._vars = dict(save=tk.BooleanVar(), scale=tk.StringVar(), @@ -54,9 +55,11 @@ def __init__(self, parent: tk.Frame, is_standalone: bool) -> None: self._scale = self._add_scale_combo() self._slider = self._add_scale_slider() self._add_interpolator_radio() - if is_standalone: + + if self._is_standalone: self._add_save_button() - self.pack(side=tk.BOTTOM, fill=tk.X, padx=2, pady=2) + self._frame.pack(side=tk.BOTTOM, fill=tk.X, padx=2, pady=2) + logger.debug("Initialized %s ('%s')", self.__class__.__name__, self) @property @@ -100,6 +103,14 @@ def interpolator_var(self) -> tk.IntVar: assert isinstance(retval, tk.IntVar) return retval + def _track_widget(self, widget: tk.Widget) -> None: + """ If running embedded in the GUI track the widgets so that they can be destroyed if + the preview is disabled """ + if self._is_standalone: + return + logger.debug("Tracking option bar widget for GUI: %s", widget) + self._gui_mapped.append(widget) + def _add_scale_combo(self) -> ttk.Combobox: """ Add a scale combo for selecting zoom amount. @@ -110,13 +121,14 @@ def _add_scale_combo(self) -> ttk.Combobox: """ logger.debug("Adding scale combo") self.scale_var.set("100%") - scale = ttk.Combobox(self, + scale = ttk.Combobox(self._frame, textvariable=self.scale_var, values=["Fit"], state="readonly", width=10) scale.pack(side=tk.RIGHT) scale.bind("", self._clear_combo_focus) # Remove auto-focus on widget text box + self._track_widget(scale) logger.debug("Added scale combo: '%s'", scale) return scale @@ -138,29 +150,35 @@ def _add_scale_slider(self) -> tk.Scale: """ logger.debug("Adding scale slider") self.slider_var.set(100) - slider = tk.Scale(self, + slider = tk.Scale(self._frame, orient=tk.HORIZONTAL, to=self.max_scale, showvalue=False, variable=self.slider_var, command=self._on_slider_update) slider.pack(side=tk.RIGHT) + self._track_widget(slider) logger.debug("Added scale slider: '%s'", slider) return slider def _add_interpolator_radio(self) -> None: """ Add a radio box to choose interpolator """ - frame = tk.Frame(self) + frame = tk.Frame(self._frame) for text, mode in self._interpolators: + logger.debug("Adding %s radio button", text) radio = tk.Radiobutton(frame, text=text, value=mode, variable=self.interpolator_var) radio.pack(side=tk.LEFT, anchor=tk.W) + self._track_widget(radio) + + logger.debug("Added %s radio button", radio) self.interpolator_var.set(cv2.INTER_NEAREST) frame.pack(side=tk.RIGHT) + self._track_widget(frame) def _add_save_button(self) -> None: """ Add a save button for saving out original preview """ logger.debug("Adding save button") - button = tk.Button(self, + button = tk.Button(self._frame, text="Save", cursor="hand2", command=lambda: self.save_var.set(True)) @@ -202,13 +220,29 @@ def set_min_max_scale(self, min_scale: int, max_scale: int) -> None: logger.debug("Set min/max scale. min_max_scales: %s, scale combo choices: %s", self._min_max_scales, choices) - def cycle_interpolators(self, *args): # pylint:disable=unused-argument + def cycle_interpolators(self, *args) -> None: # pylint:disable=unused-argument """ Cycle interpolators on a keypress callback """ current = next(i for i in self._interpolators if i[1] == self.interpolator_var.get()) next_idx = self._interpolators.index(current) + 1 next_idx = 0 if next_idx == len(self._interpolators) else next_idx self.interpolator_var.set(self._interpolators[next_idx][1]) + def destroy_widgets(self) -> None: + """ Remove the taskbar widgets when the preview within the GUI has been disabled """ + if self._is_standalone: + return + + for widget in self._gui_mapped: + if widget.winfo_ismapped(): + logger.debug("Removing widget: %s", widget) + widget.pack_forget() + widget.destroy() + del widget + + for var in list(self._vars): + logger.debug("Deleting tk variable: %s", var) + del self._vars[var] + class _PreviewCanvas(tk.Canvas): # pylint:disable=too-many-ancestors """ The canvas that holds the preview image @@ -221,16 +255,20 @@ class _PreviewCanvas(tk.Canvas): # pylint:disable=too-many-ancestors The variable that holds the value from the scale combo box screen_dimensions: tuple The (`width`, `height`) of the displaying monitor + is_standalone: bool + ``True`` if the preview is standalone, ``False`` if it is in the GUI """ def __init__(self, parent: tk.Frame, scale_var: tk.StringVar, - screen_dimensions: Tuple[int, int]) -> None: + screen_dimensions: Tuple[int, int], + is_standalone: bool) -> None: logger.debug("Initializing %s (parent: '%s', scale_var: %s, screen_dimensions: %s)", self.__class__.__name__, parent, scale_var, screen_dimensions) frame = tk.Frame(parent) super().__init__(frame) + self._is_standalone = is_standalone self._screen_dimensions = screen_dimensions self._var_scale = scale_var self._configure_scrollbars(frame) @@ -292,7 +330,9 @@ def _resize(self, event: tk.Event) -> None: # pylint: disable=unused-argument self.configure(scrollregion=self.bbox("all")) self.update_idletasks() + assert self._image is not None + self._center_image(self.width / 2, self.height / 2) # Move to top left when resizing into screen dimensions (initial startup) if self.width > self._screen_dimensions[0]: @@ -334,7 +374,10 @@ def set_image(self, center_image) self._image = image self.itemconfig(self.image_id, image=self._image) - self.config(width=self._image.width(), height=self._image.height()) + + if self._is_standalone: # canvas size should not be updated inside GUI + self.config(width=self._image.width(), height=self._image.height()) + self.update_idletasks() if center_image: self._center_image(self.width / 2, self.height / 2) @@ -348,11 +391,15 @@ class _Image(): Parameters ---------- save_variable: :class:`tkinter.BooleanVar` - Variable that indicates a save preview has been requested + Variable that indicates a save preview has been requested in standalone mode + is_standalone: bool + ``True`` if the preview is running in standalone mode. ``False`` if it is running in the + GUI """ - def __init__(self, save_variable: tk.BooleanVar) -> None: - logger.debug("Initializing %s: (save_variable: %s)", - self.__class__.__name__, save_variable) + def __init__(self, save_variable: tk.BooleanVar, is_standalone: bool) -> None: + logger.debug("Initializing %s: (save_variable: %s, is_standalone: %s)", + self.__class__.__name__, save_variable, is_standalone) + self._is_standalone = is_standalone self._source: Optional["np.ndarray"] = None self._display: Optional[ImageTk.PhotoImage] = None self._scale = 1.0 @@ -396,7 +443,7 @@ def set_display_image(self) -> None: """ Obtain the scaled image and set to :attr:`display_image` """ logger.debug("Setting display image. Scale: %s", self._scale) image = self.source[..., 2::-1] # TO RGB - if self._scale != 1.0: + if self._scale not in (0.0, 1.0): # Scale will be 0,0 on initial load in GUI interp = self._interpolation if self._scale > 1.0 else cv2.INTER_NEAREST dims = (int(round(self.source.shape[1] * self._scale, 0)), int(round(self.source.shape[0] * self._scale, 0))) @@ -449,19 +496,25 @@ def save_preview(self, *args) -> None: Parameters ---------- args: tuple - Tuple containing either the key press event (Ctrl+s shortcut) or the tk variable - arguments (save button press) + Tuple containing either the key press event (Ctrl+s shortcut), the tk variable + arguments (standalone save button press) or the folder location (GUI save button press) """ - if not self._save_var.get() and not isinstance(args[0], tk.Event): + if self._is_standalone and not self._save_var.get() and not isinstance(args[0], tk.Event): return - root_path = os.path.join(os.path.realpath(os.path.dirname(sys.argv[0]))) + if self._is_standalone: + root_path = os.path.join(os.path.realpath(os.path.dirname(sys.argv[0]))) + else: + root_path = args[0] + now = datetime.now().strftime("%Y-%m-%d_%H.%M.%S") filename = os.path.join(root_path, f"preview_{now}.png") cv2.imwrite(filename, self.source) print("") logger.info("Saved preview to: '%s'", filename) - self._save_var.set(False) + + if self._is_standalone: + self._save_var.set(False) class _Bindings(): # pylint: disable=too-few-public-methods @@ -475,8 +528,14 @@ class _Bindings(): # pylint: disable=too-few-public-methods The taskbar widget which holds the scaling variables image: :class:`_Image` The object which holds the source and display version of the preview image + is_standalone: bool + ``True`` if the preview is standalone, ``False`` if it is embedded in the GUI """ - def __init__(self, canvas: _PreviewCanvas, taskbar: _Taskbar, image: _Image) -> None: + def __init__(self, + canvas: _PreviewCanvas, + taskbar: _Taskbar, + image: _Image, + is_standalone: bool) -> None: logger.debug("Initializing %s (canvas: '%s', taskbar: '%s', image: '%s')", self.__class__.__name__, canvas, taskbar, image) self._canvas = canvas @@ -485,7 +544,7 @@ def __init__(self, canvas: _PreviewCanvas, taskbar: _Taskbar, image: _Image) -> self._drag_data: List[float] = [0., 0.] self._set_mouse_bindings() - self._set_key_bindings() + self._set_key_bindings(is_standalone) logger.debug("Initialized %s", self.__class__.__name__,) def _on_bound_zoom(self, event: tk.Event) -> None: @@ -568,15 +627,21 @@ def _set_mouse_bindings(self) -> None: self._canvas.tag_bind(self._canvas.image_id, "", self._on_mouse_drag) logger.debug("Bound mouse events") - def _set_key_bindings(self) -> None: - # TODO set bind location for GUI + def _set_key_bindings(self, is_standalone: bool) -> None: """ Set the keyboard bindings. Up/Down/Left/Right: Moves image +/-: Zooms image ctrl+s: Save i: Cycle interpolators + + Parameters + ---------- + ``True`` if the preview is standalone, ``False`` if it is embedded in the GUI """ + if not is_standalone: + # Don't bind keys for GUI as it adds complication + return logger.debug("Binding key events") root = self._canvas.winfo_toplevel() for key in ("Left", "Right", "Up", "Down"): @@ -598,6 +663,9 @@ class PreviewTk(PreviewBase): # pylint:disable=too-few-public-methods parent: tkinter widget, optional If this viewer is being called from the GUI the parent widget should be passed in here. If this is a standalone pop-up window then pass ``None``. Default: ``None`` + taskbar: :class:`tkinter.ttk.Frame`, optional + If this viewer is being called from the GUI the parent's option frame should be passed in + here. If this is a standalone pop-up window then pass ``None``. Default: ``None`` triggers: dict, optional Dictionary of event triggers for pop-up preview. Not required when running inside the GUI. Default: `None` @@ -605,37 +673,70 @@ class PreviewTk(PreviewBase): # pylint:disable=too-few-public-methods def __init__(self, preview_buffer: "PreviewBuffer", parent: Optional[tk.Widget] = None, + taskbar: Optional[ttk.Frame] = None, triggers: Optional["TriggerType"] = None) -> None: logger.debug("Initializing %s (parent: '%s')", self.__class__.__name__, parent) super().__init__(preview_buffer, triggers=triggers) self._is_standalone = parent is None - self._initialized = not self._is_standalone + self._initialized = False self._root = parent if parent is not None else tk.Tk() self._master_frame = tk.Frame(self._root) - self._taskbar = _Taskbar(self._master_frame, self._is_standalone) + self._taskbar = _Taskbar(self._master_frame, taskbar) self._screen_dimensions = self._get_geometry() self._canvas = _PreviewCanvas(self._master_frame, self._taskbar.scale_var, - self._screen_dimensions) + self._screen_dimensions, + self._is_standalone) - self._image = _Image(self._taskbar.save_var) + self._image = _Image(self._taskbar.save_var, self._is_standalone) - _Bindings(self._canvas, self._taskbar, self._image) + _Bindings(self._canvas, self._taskbar, self._image, self._is_standalone) self._taskbar.scale_var.trace("w", self._set_scale) self._taskbar.interpolator_var.trace("w", self._set_interpolation) self._process_triggers() - self._master_frame.pack(fill=tk.BOTH, expand=True) - logger.debug("Initialized %s", self.__class__.__name__) + + if self._is_standalone: + self.pack(fill=tk.BOTH, expand=True) + self._output_helptext() + + logger.debug("Initialized %s", self.__class__.__name__) + self._launch() - @classmethod - def _output_helptext(cls) -> None: + @property + def master_frame(self) -> tk.Frame: + """ :class:`tkinter.Frame`: The master frame that holds the preview window """ + return self._master_frame + + def pack(self, *args, **kwargs): + """ Redirect calls to pack the widget to pack the actual :attr:`_master_frame`. + + Takes standard :class:`tkinter.Frame` pack arguments + """ + logger.debug("Packing master frame: (args: %s, kwargs: %s)", args, kwargs) + self._master_frame.pack(*args, **kwargs) + + def save(self, location: str) -> None: + """ Save action to be performed when save button pressed from the GUI. + + location: str + Full path to the folder to save the preview image to + """ + self._image.save_preview(location) + + def remove_option_controls(self) -> None: + """ Remove the taskbar options controls when the preview is disabled in the GUI """ + self._taskbar.destroy_widgets() + + def _output_helptext(self) -> None: """ Output the keybindings to Console. """ + if not self._is_standalone: + return logger.info("---------------------------------------------------") logger.info(" Preview key bindings:") logger.info(" Zoom: +/-") @@ -645,7 +746,8 @@ def _output_helptext(cls) -> None: logger.info("---------------------------------------------------") def _get_geometry(self) -> Tuple[int, int]: - """ Obtain the geometry of the current screen. + """ Obtain the geometry of the current screen (standalone) or the dimensions of the widget + holding the preview window (GUI). Just pulling screen width and height does not account for multiple monitors, so dummy in a window to pull actual dimensions before hiding it again. @@ -655,9 +757,14 @@ def _get_geometry(self) -> Tuple[int, int]: Tuple The (`width`, `height`) of the current monitor's display """ - # TODO skip when loading in GUI? - logger.debug("Obtaining screen geometry") + if not self._is_standalone: + root = self._root.winfo_toplevel() # Get dims of whole GUI + retval = root.winfo_width(), root.winfo_height() + logger.debug("Obtained frame geometry: %s", retval) + return retval + assert isinstance(self._root, tk.Tk) + logger.debug("Obtaining screen geometry") self._root.update_idletasks() self._root.attributes("-fullscreen", True) self._root.state("iconic") @@ -807,9 +914,13 @@ def _display_preview(self) -> None: self._root.after(1000, self._display_preview) - if not self._initialized: + if not self._initialized and self._is_standalone: self._initialize_window() self._root.mainloop() + if not self._initialized: # Set initialized to True for GUI + self._set_min_max_scales() + self._taskbar.scale_var.set("Fit") + self._initialized = True def main(): diff --git a/scripts/train.py b/scripts/train.py index 9a9529bff1..dbb928acda 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -12,6 +12,7 @@ import cv2 import numpy as np +from lib.gui.utils.image import TRAININGPREVIEW from lib.image import read_image_meta from lib.keypress import KBHit from lib.multithreading import MultiThread, FSThread @@ -507,7 +508,7 @@ def _show(self, image: np.ndarray, name: str = "") -> None: logger.debug("Saved preview to: '%s'", img) if self._args.redirect_gui: logger.debug("Generating preview for GUI") - img = ".gui_training_preview.png" + img = TRAININGPREVIEW imgfile = os.path.join(scriptpath, "lib", "gui", ".cache", "preview", img) cv2.imwrite(imgfile, image) # pylint: disable=no-member logger.debug("Generated preview for GUI: '%s'", imgfile) From d1a7f7a8bc027ac5008461f44a9bde38543139d6 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 23 Oct 2022 01:51:03 +0100 Subject: [PATCH 761/981] bugfix: Don't error if preview unsuccessfully read --- lib/gui/utils/image.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/gui/utils/image.py b/lib/gui/utils/image.py index ed8b1c7a9d..3163774445 100644 --- a/lib/gui/utils/image.py +++ b/lib/gui/utils/image.py @@ -115,14 +115,15 @@ def load(self) -> bool: logger.debug("Loading preview: '%s'", filename) img = cv2.imread(filename, cv2.IMREAD_UNCHANGED) + assert img is not None self._modified = modified self._buffer.add_image(os.path.basename(filename), img) self._error_count = 0 - except ValueError: + except (ValueError, AssertionError): # This is probably an error reading the file whilst it's being saved so ignore it # for now and only pick up if there have been multiple consecutive fails - logger.warning("Unable to display preview: (image: '%s', attempt: %s)", - img, self._error_count) + logger.debug("Unable to display preview: (image: '%s', attempt: %s)", + img, self._error_count) if self._error_count < 10: self._error_count += 1 else: From 1d1face00d9476896e7857d3976afce383585d1b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 31 Oct 2022 18:25:32 +0000 Subject: [PATCH 762/981] Update Face Filter - Remove old face filter - plugins.extract.pipeline: Expose plugins directly - Change `is_aligned` from plugin level to ExtractMedia level - Allow extract pipeline to take faceswap aligned images - Add ability for recognition plugins to accept aligned faces as input - Add face filter to recognition plugin - Move extractor pipeline IO ops to own class --- lib/align/alignments.py | 1 + lib/align/detected_face.py | 3 +- lib/cli/args.py | 24 +- lib/face_filter.py | 181 --------- lib/image.py | 30 +- locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 45184 -> 46565 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 236 ++++++----- locales/lib.cli.args.pot | 196 +++++---- locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 58943 -> 60859 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 235 ++++++----- plugins/extract/align/_base.py | 11 +- plugins/extract/detect/_base.py | 4 + plugins/extract/mask/_base.py | 17 +- plugins/extract/pipeline.py | 90 +++-- plugins/extract/recognition/_base.py | 261 +++++++++++- scripts/extract.py | 537 ++++++++++++++++++++----- scripts/fsmedia.py | 160 -------- tools/manual/manual.py | 3 +- tools/mask/mask.py | 22 +- 19 files changed, 1186 insertions(+), 825 deletions(-) delete mode 100644 lib/face_filter.py diff --git a/lib/align/alignments.py b/lib/align/alignments.py index 560b57eadf..cc1c52ea9e 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -68,6 +68,7 @@ class PNGHeaderSourceDict(TypedDict): face_index: int source_filename: str source_is_video: bool + source_frame_dims: Optional[Tuple[int, int]] class AlignmentDict(TypedDict): diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index c6dfe45aba..74ca9a9553 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -1079,7 +1079,8 @@ def update_legacy_png_header(filename: str, alignments: Alignments original_filename=orig_filename, face_index=face_idx, source_filename=src_fname, - source_is_video=False)) # Can't check so set false + source_is_video=False, # Can't check so set false + source_frame_dims=None)) out_filename = f"{os.path.splitext(filename)[0]}.png" # Make sure saved file is png out_image = encode_image(in_image, ".png", metadata=meta) diff --git a/lib/cli/args.py b/lib/cli/args.py index 1878ca9a66..fad3634f11 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -521,11 +521,10 @@ def get_optional_arguments() -> List[Dict[str, Any]]: default=None, nargs="+", group=_("Face Processing"), - help=_("Optionally filter out people who you do not wish to process by passing in an " - "image of that person. Should be a front portrait with a single person in the " - "image. Multiple images can be added space separated. NB: Using face filter " - "will significantly decrease extraction speed and its accuracy cannot be " - "guaranteed."))) + help=_("Optionally filter out people who you do not wish to extract by passing in " + "images of those people. Should be a small variety of images at different " + "angles and in different conditions. Multiple images can be added space " + "separated."))) argument_list.append(dict( opts=("-f", "--filter"), action=FilesFullPaths, @@ -534,11 +533,10 @@ def get_optional_arguments() -> List[Dict[str, Any]]: default=None, nargs="+", group=_("Face Processing"), - help=_("Optionally select people you wish to process by passing in an image of that " - "person. Should be a front portrait with a single person in the image. " - "Multiple images can be added space separated. NB: Using face filter will " - "significantly decrease extraction speed and its accuracy cannot be " - "guaranteed."))) + help=_("Optionally select people you wish to extract by passing in images of that " + "person. Should be a small variety of images at different angles and in " + "different conditions. Multiple identities can be filtered. Multiple images " + "can be added space separated."))) argument_list.append(dict( opts=("-l", "--ref_threshold"), action=Slider, @@ -546,12 +544,10 @@ def get_optional_arguments() -> List[Dict[str, Any]]: rounding=2, type=float, dest="ref_threshold", - default=0.4, + default=0.65, group=_("Face Processing"), help=_("For use with the optional nfilter/filter files. Threshold for positive face " - "recognition. Lower values are stricter. NB: Using face filter will " - "significantly decrease extraction speed and its accuracy cannot be " - "guaranteed."))) + "recognition. Higher values are stricter."))) argument_list.append(dict( opts=("-sz", "--size"), action=Slider, diff --git a/lib/face_filter.py b/lib/face_filter.py deleted file mode 100644 index c6589a386f..0000000000 --- a/lib/face_filter.py +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin python3 -""" Face Filterer for extraction in faceswap.py """ - -import logging - -from lib.align import AlignedFace -from lib.vgg_face import VGGFace -from lib.image import read_image -from plugins.extract.pipeline import Extractor, ExtractMedia - -logger = logging.getLogger(__name__) # pylint: disable=invalid-name - - -def avg(arr): - """ Return an average """ - return sum(arr) * 1.0 / len(arr) - - -class FaceFilter(): - """ Face filter for extraction - NB: we take only first face, so the reference file should only contain one face. """ - - def __init__(self, reference_file_paths, nreference_file_paths, detector, aligner, - multiprocess=False, threshold=0.4): - logger.debug("Initializing %s: (reference_file_paths: %s, nreference_file_paths: %s, " - "detector: %s, aligner: %s, multiprocess: %s, threshold: %s)", - self.__class__.__name__, reference_file_paths, nreference_file_paths, - detector, aligner, multiprocess, threshold) - self.vgg_face = VGGFace() - self.filters = self.load_images(reference_file_paths, nreference_file_paths) - # TODO Revert face-filter to use the selected detector and aligner. - # Currently Tensorflow does not release vram after it has been allocated - # Whilst this vram can still be used, the pipeline for the extraction process can't see - # it so thinks there is not enough vram available. - # Either the pipeline will need to be changed to be re-usable by face-filter and extraction - # Or another vram measurement technique will need to be implemented to for when tensorflow - # has already performed allocation. For now we force CPU detectors. - - # self.align_faces(detector, aligner, multiprocess) - self.align_faces("cv2-dnn", "cv2-dnn", "none", multiprocess) - - self.get_filter_encodings() - self.threshold = threshold - logger.debug("Initialized %s", self.__class__.__name__) - - @staticmethod - def load_images(reference_file_paths, nreference_file_paths): - """ Load the images """ - retval = dict() - for fpath in reference_file_paths: - retval[fpath] = {"image": read_image(fpath, raise_error=True), - "type": "filter"} - for fpath in nreference_file_paths: - retval[fpath] = {"image": read_image(fpath, raise_error=True), - "type": "nfilter"} - logger.debug("Loaded filter images: %s", {k: v["type"] for k, v in retval.items()}) - return retval - - # Extraction pipeline - def align_faces(self, detector_name, aligner_name, masker_name, multiprocess): - """ Use the requested detectors to retrieve landmarks for filter images """ - extractor = Extractor(detector_name, - aligner_name, - masker_name, - multiprocess=multiprocess) - self.run_extractor(extractor) - del extractor - self.load_aligned_face() - - def run_extractor(self, extractor): - """ Run extractor to get faces """ - for _ in range(extractor.passes): - extractor.launch() - self.queue_images(extractor) - for faces in extractor.detected_faces(): - filename = faces.filename - detected_faces = faces.detected_faces - if len(detected_faces) > 1: - logger.warning("Multiple faces found in %s file: '%s'. Using first detected " - "face.", self.filters[filename]["type"], filename) - self.filters[filename]["detected_face"] = detected_faces[0] - - def queue_images(self, extractor): - """ queue images for detection and alignment """ - in_queue = extractor.input_queue - for fname, img in self.filters.items(): - logger.debug("Adding to filter queue: '%s' (%s)", fname, img["type"]) - feed_dict = ExtractMedia(fname, img["image"], detected_faces=img.get("detected_faces")) - logger.debug("Queueing filename: '%s' items: %s", fname, feed_dict) - in_queue.put(feed_dict) - logger.debug("Sending EOF to filter queue") - in_queue.put("EOF") - - def load_aligned_face(self): - """ Align the faces for vgg_face input """ - for filename, face in self.filters.items(): - logger.debug("Loading aligned face: '%s'", filename) - image = face["image"] - detected_face = face["detected_face"] - detected_face.load_aligned(image, centering="legacy", size=224) - face["face"] = detected_face.aligned.face - del face["image"] - logger.debug("Loaded aligned face: ('%s', shape: %s)", - filename, face["face"].shape) - - def get_filter_encodings(self): - """ Return filter face encodings from Keras VGG Face """ - for filename, face in self.filters.items(): - logger.debug("Getting encodings for: '%s'", filename) - encodings = self.vgg_face.predict(face["face"]) - logger.debug("Filter Filename: %s, encoding shape: %s", filename, encodings.shape) - face["encoding"] = encodings - del face["face"] - - def check(self, image, detected_face): - """ Check the extracted Face - - Parameters - ---------- - image: :class:`numpy.ndarray` - The original frame that contains the face to be checked - detected_face: :class:`lib.align.DetectedFace` - The detected face object that contains the face to be checked - - Returns - ------- - bool - ``True`` if the face matches a filter otherwise ``False`` - """ - logger.trace("Checking face with FaceFilter") - distances = {"filter": list(), "nfilter": list()} - feed = AlignedFace(detected_face.landmarks_xy, image=image, size=224, centering="legacy") - encodings = self.vgg_face.predict(feed.face) - for filt in self.filters.values(): - similarity = self.vgg_face.find_cosine_similiarity(filt["encoding"], encodings) - distances[filt["type"]].append(similarity) - - avgs = {key: avg(val) if val else None for key, val in distances.items()} - mins = {key: min(val) if val else None for key, val in distances.items()} - # Filter - if distances["filter"] and avgs["filter"] > self.threshold: - msg = "Rejecting filter face: {} > {}".format(round(avgs["filter"], 2), self.threshold) - retval = False - # nFilter no Filter - elif not distances["filter"] and avgs["nfilter"] < self.threshold: - msg = "Rejecting nFilter face: {} < {}".format(round(avgs["nfilter"], 2), - self.threshold) - retval = False - # Filter with nFilter - elif distances["filter"] and distances["nfilter"] and mins["filter"] > mins["nfilter"]: - msg = ("Rejecting face as distance from nfilter sample is smaller: (filter: {}, " - "nfilter: {})".format(round(mins["filter"], 2), round(mins["nfilter"], 2))) - retval = False - elif distances["filter"] and distances["nfilter"] and avgs["filter"] > avgs["nfilter"]: - msg = ("Rejecting face as average distance from nfilter sample is smaller: (filter: " - "{}, nfilter: {})".format(round(mins["filter"], 2), round(mins["nfilter"], 2))) - retval = False - elif distances["filter"] and distances["nfilter"]: - # k-nearest-neighbor classifier - var_k = min(5, min(len(distances["filter"]), len(distances["nfilter"])) + 1) - var_n = sum(list(map(lambda x: x[0], - list(sorted([(1, d) for d in distances["filter"]] + - [(0, d) for d in distances["nfilter"]], - key=lambda x: x[1]))[:var_k]))) - ratio = var_n/var_k - if ratio < 0.5: - msg = ("Rejecting face as k-nearest neighbors classification is less than " - "0.5: {}".format(round(ratio, 2))) - retval = False - else: - msg = None - retval = True - else: - msg = None - retval = True - if msg: - logger.verbose(msg) - else: - logger.trace("Accepted face: (similarity: %s, threshold: %s)", - distances, self.threshold) - return retval diff --git a/lib/image.py b/lib/image.py index 2aed239e2b..36c60b8d2a 100644 --- a/lib/image.py +++ b/lib/image.py @@ -11,7 +11,7 @@ from ast import literal_eval from bisect import bisect from concurrent import futures -from typing import Optional +from typing import Optional, TYPE_CHECKING, Union from zlib import crc32 import cv2 @@ -24,6 +24,9 @@ from lib.queue_manager import queue_manager, QueueEmpty from lib.utils import convert_to_secs, FaceswapError, _video_extensions, get_image_paths +if TYPE_CHECKING: + from lib.align.alignments import PNGHeaderDict + logger = logging.getLogger(__name__) # pylint:disable=invalid-name # ################### # @@ -552,7 +555,9 @@ def update_existing_metadata(filename, metadata): os.replace(tmp_filename, filename) -def encode_image(image, extension, metadata=None): +def encode_image(image: np.ndarray, + extension: str, + metadata: Optional["PNGHeaderDict"] = None) -> bytes: """ Encode an image. Parameters @@ -580,7 +585,7 @@ def encode_image(image, extension, metadata=None): raise ValueError("Metadata is only supported for .png images") retval = cv2.imencode(extension, image)[1] if metadata: - retval = np.frombuffer(png_write_meta(retval.tobytes(), metadata), dtype="uint8") + retval = png_write_meta(retval.tobytes(), metadata) return retval @@ -1032,7 +1037,7 @@ def _check_for_video(self): If the given location is a file and does not have a valid video extension. """ - if os.path.isdir(self.location): + if not isinstance(self.location, str) or os.path.isdir(self.location): retval = False elif os.path.splitext(self.location)[1].lower() in _video_extensions: retval = True @@ -1423,7 +1428,10 @@ def _process(self, queue): executor.submit(self._save, *item) executor.shutdown() - def _save(self, filename: str, image: bytes, sub_folder: Optional[str]) -> None: + def _save(self, + filename: str, + image: Union[bytes, np.ndarray], + sub_folder: Optional[str]) -> None: """ Save a single image inside a ThreadPoolExecutor Parameters @@ -1431,8 +1439,8 @@ def _save(self, filename: str, image: bytes, sub_folder: Optional[str]) -> None: filename: str The filename of the image to be saved. NB: Any folders passed in with the filename will be stripped and replaced with :attr:`location`. - image: bytes - The encoded image to be saved + image: bytes or :class:`numpy.ndarray` + The encoded image or numpy array to be saved subfolder: str or ``None`` If the file should be saved in a subfolder in the output location, the subfolder should be provided here. ``None`` for no subfolder. @@ -1444,15 +1452,19 @@ def _save(self, filename: str, image: bytes, sub_folder: Optional[str]) -> None: filename = os.path.join(location, os.path.basename(filename)) try: if self._as_bytes: + assert isinstance(image, bytes) with open(filename, "wb") as out_file: out_file.write(image) else: cv2.imwrite(filename, image) logger.trace("Saved image: '%s'", filename) # type:ignore except Exception as err: # pylint: disable=broad-except - logger.error("Failed to save image '%s'. Original Error: %s", filename, err) + logger.error("Failed to save image '%s'. Original Error: %s", filename, str(err)) - def save(self, filename: str, image: bytes, sub_folder: Optional[str] = None) -> None: + def save(self, + filename: str, + image: Union[bytes, np.ndarray], + sub_folder: Optional[str] = None) -> None: """ Save the given image in the background thread Ensure that :func:`close` is called once all save operations are complete. diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo index 05ce66fa9c922a9697eec318427b24cfaf556020..574e72c56d864954d89ffc8757ff7d075a54d7d3 100644 GIT binary patch delta 2778 zcmcJQc~Dhl6u=LNKp?3IngVihDVJmsHF3!WMRHA0F(L8t-sSP?@h-gk9ztnYQ)tGh zNoJcFTBDhx*-OWxebgKlKgg{l0U)?R@8) z`?2Co|vM55qfI1auK zS1WtG$R7A4%!Bz|M3%5_IXsMhDnXUbhPl#=gD3 z$a(A^4iK3P4-VuaP;M0|09i0bg}RQ;EW-N`{22Y{Fp=@3VGFa5qo2;k24Bw+sldK` zxX4Q8pB^Fd0|!+R|HGX`7TqZ_obhx{b47_1O%X{(-&aUl)V_GR5`F|NSUQ7?kD_y& zPA-g|$xY!HSO6b_v*8iQo{}_6_i^wExCO481F`4M6?qGO;&6SDT{E+bMP9^i z%oC}GQSi1^FRHX_!wolJU(@KZb3DLnJMa@c z#KhhAiL7Em;C_*((0}*Yp<74%%CKLB>#&!aB0JzksA1n&gAMMlwV6bsRb*>|BEP_I z;bO+mg+!*IHx?|VU@)wK3*fGLk?$Gk{D25)mn(1t%zuz@!g|Q3Etg>;vA+txLGMl; zAC1IO_!at!RYV1P9_J$1vsUvtGJk)wU8KcN^XW#B|E6aMB@-^aAW}@yn>ULrL_htq zo$I`9R6ZL`f-UeeR7KjfUF2@~0i@0(0KP-iZU?J>Fc(y_hJjn&)U&Cqm^-lF5 zk^ghMY>#*DwnNmyX)0&Udqm!0;mJ4n#Nehd>44F1^18tW_ymmKYdg9T;=gQ$(eM?A zJE6*b7{Wn85k+DILy};Yb{Et*On_gOTnS2ul0Q=cwxWl+j2}q#wf9sNR+v znu>^q+~rv3z#d2^M{k@=XB5KQ(e9lJSvTZ%Bo!Hgs5TN&_3-DW-Uk)_jxEtq2Er~* zUqv9t=}&N&Z9lxWmf{!_U@meS(i@qJ%tKV0j*LTYK_()+tkM_Zv1|VamJC&B$0CD} zjx7l#78!v|MFt~jD*yfIWFe}mJGS|bQsA%`yu<0M!W)LDJYQ==olXYi3)w-9qf0jZLiy1rr+%idd*0`)v_!jE9^<%5s~^&zO-az zq&5C0NBXTyZC1z^^l~H3>&8nQ!AFXm)nEZJsNxx8GitWkWM-Twc?!bM=-9<=WUv-R0GNT8(LORGpcr%``2Q6EncM z)orZ=$QQ(9nSNU}>@1m9W;m{VTVkx*mfYzrZ`Y>SDI=-mi@2J^y{*+__}R*BXojU* zq$|KyIv1T{a3zb$A-|5fHe|Hz(e0$Ub+=k@eYMe^p!U46S6i#!6Y}9ygLdOUT6^x) z{R|E$rZ_?dQPgx#!0_lTKU+h>t+gj2{?*NEUj8pmy3DB**k7G&52PL-$_MNUu$&Sg N8h?A8LTmK+KLAs&ITQc@ delta 1881 zcmYk7eN5F=7{|XL@~UzHMS)Ndl9WO&lpu!4i@d3bVfjK5aFwuOPF^A_`Up_->gjZk*@i|HMc8Ap@OGoLr0sn@( zQW(sDCrF3!gH#?yJZzR!h~Ext@rTmbCH^n)C;Y_OQW*@%VBxR=R>Cew@^l`qpm7O) zf$zzdiXGB@gU06sGMV)$`~$WUzc`mkA7bX;tU2#NiY6C zOQmpoa5eMbbl3%t!38j(M#_h^upB-K9b4fg8g(#=ZI!{b@F{o{j`HHvWzsObx*QVk zW$^njd8NxQq1DFo^(>C~=~dDeSbdM1xl#DEji<?xhd5tdYhTcXqA&yo2l94Gq8}#J`5;Vb^+h!#Rzd z|8q>-(MTs9(Hp2x_&u~DY;1CO)B)od7xMtACZ5_%o#5v~d+}%s{lvq4ZcWU9hl!VM zbdUC{P40_dYa`o?^F2u6(m%&(mtG)nX0w~JiifyTbew|+iN|(GufyXojrcP1+6X%y zcKPvEH>H`|q$c9Su!`{^9Q=LwHSjl^-_4bVhkB$t;TYTwodmYa71aS4PmYhkAMr=X z%pQtrU?&BNpR!x}2!`%q<;1VRLB==qxyAS^t8Jqg)BDL7{rg^|kF(7hq@M5}Kjt3k z)gkF^o^uT{)X{-qw;Iz=NcD7Vg4ByfVFQeM#l5#1Acd^=AYBT5m4~pylW-jFKIML3 zcAa(?Tt(XcVqDG}?h$2;aFy|U-{cCz!ndSOyZ=Kp4iT7oM%oWg!U&khGTDHNpcUnk zAgiFAbse;E&5%T>9ZrUwkoEj$CA5~HDZvPrC1Cc9g}gs-UKUw^>=VrQMpID|DnN@- z1*%78>F6#LhPas`yMavEO({oqoY`m=`oC2Mom^;{yd60+gF#EV**s*0VpVRtGOKeT zZ;-_>4b`Ak$jma4k1PYXA}cb!Q_4bRXa!0{x1khd7H^^1H0}tvfj5#C&sgKAMJGK)re$YV~>>~F}-4r>Ck!<&Q((R7rFlF?0@A8aJRr8k46J>6Tk zv~+LwwRCiBPxJRhY>f0bdy+i}uishYi}rW6?vMBX O+4D(=|Jz5yi~j`=2Kc7{ diff --git a/locales/es/LC_MESSAGES/lib.cli.args.po b/locales/es/LC_MESSAGES/lib.cli.args.po index 73474510a7..905452a62d 100644 --- a/locales/es/LC_MESSAGES/lib.cli.args.po +++ b/locales/es/LC_MESSAGES/lib.cli.args.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-10-10 13:05+0100\n" -"PO-Revision-Date: 2022-10-10 13:07+0100\n" +"POT-Creation-Date: 2022-10-31 11:51+0000\n" +"PO-Revision-Date: 2022-10-31 11:53+0000\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es\n" @@ -55,7 +55,7 @@ msgstr "" "almacenarlo en la carpeta pde instalación de faceswap" #: lib/cli/args.py:320 lib/cli/args.py:329 lib/cli/args.py:337 -#: lib/cli/args.py:386 lib/cli/args.py:672 lib/cli/args.py:681 +#: lib/cli/args.py:386 lib/cli/args.py:668 lib/cli/args.py:677 msgid "Data" msgstr "Datos" @@ -102,8 +102,8 @@ msgstr "" #: lib/cli/args.py:396 lib/cli/args.py:412 lib/cli/args.py:424 #: lib/cli/args.py:463 lib/cli/args.py:481 lib/cli/args.py:493 -#: lib/cli/args.py:502 lib/cli/args.py:691 lib/cli/args.py:718 -#: lib/cli/args.py:756 +#: lib/cli/args.py:502 lib/cli/args.py:687 lib/cli/args.py:714 +#: lib/cli/args.py:752 msgid "Plugins" msgstr "Extensiones" @@ -272,9 +272,9 @@ msgstr "" "Obtenga y almacene codificaciones de identidad facial de VGGFace2. Ralentiza " "un poco la extracción, pero ahorrará tiempo si usa 'sort by face'" -#: lib/cli/args.py:513 lib/cli/args.py:523 lib/cli/args.py:536 -#: lib/cli/args.py:550 lib/cli/args.py:793 lib/cli/args.py:807 -#: lib/cli/args.py:820 lib/cli/args.py:834 +#: lib/cli/args.py:513 lib/cli/args.py:523 lib/cli/args.py:535 +#: lib/cli/args.py:548 lib/cli/args.py:789 lib/cli/args.py:803 +#: lib/cli/args.py:816 lib/cli/args.py:830 msgid "Face Processing" msgstr "Proceso de Caras" @@ -287,52 +287,44 @@ msgstr "" "a lo largo de la diagonal del cuadro delimitador. Establecer a 0 para " "desactivar" -#: lib/cli/args.py:524 lib/cli/args.py:808 +#: lib/cli/args.py:524 msgid "" -"Optionally filter out people who you do not wish to process by passing in an " -"image of that person. Should be a front portrait with a single person in the " -"image. Multiple images can be added space separated. NB: Using face filter " -"will significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." +"Optionally filter out people who you do not wish to extract by passing in " +"images of those people. Should be a small variety of images at different " +"angles and in different conditions. Multiple images can be added space " +"separated." msgstr "" -"Opcionalmente, puede filtrar las personas que no desea procesar pasando una " -"imagen de esa persona. Debe ser un retrato frontal con una sola persona en " -"la imagen. Se pueden añadir varias imágenes separadas por espacios. NB: El " -"uso del filtro de caras disminuirá significativamente la velocidad de " -"extracción y no se puede garantizar su precisión." +"Opcionalmente, filtre a las personas que no desea extraer pasando imágenes " +"de esas personas. Debe ser una pequeña variedad de imágenes en diferentes " +"ángulos y en diferentes condiciones. Se pueden agregar varias imágenes " +"separadas por espacios." -#: lib/cli/args.py:537 lib/cli/args.py:821 +#: lib/cli/args.py:536 msgid "" -"Optionally select people you wish to process by passing in an image of that " -"person. Should be a front portrait with a single person in the image. " -"Multiple images can be added space separated. NB: Using face filter will " -"significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." +"Optionally select people you wish to extract by passing in images of that " +"person. Should be a small variety of images at different angles and in " +"different conditions. Multiple identities can be filtered. Multiple images " +"can be added space separated." msgstr "" -"Opcionalmente, seleccione las personas que desea procesar pasando una imagen " -"de esa persona. Debe ser un retrato frontal con una sola persona en la " -"imagen. Se pueden añadir varias imágenes separadas por espacios. NB: El uso " -"del filtro facial disminuirá significativamente la velocidad de extracción y " -"no se puede garantizar su precisión." +"Opcionalmente, seleccione las personas que desea extraer pasando imágenes de " +"esa persona. Debe ser una pequeña variedad de imágenes en diferentes ángulos " +"y en diferentes condiciones. Se pueden filtrar múltiples identidades. Se " +"pueden agregar varias imágenes separadas por espacios." -#: lib/cli/args.py:551 lib/cli/args.py:835 +#: lib/cli/args.py:549 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " -"recognition. Lower values are stricter. NB: Using face filter will " -"significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." +"recognition. Higher values are stricter." msgstr "" -"Para usar con los archivos opcionales nfilter/filter. Umbral para el " -"reconocimiento positivo de caras. Los valores más bajos son más estrictos. " -"NB: El uso del filtro facial disminuirá significativamente la velocidad de " -"extracción y no se puede garantizar su precisión." +"Para usar con los archivos nfilter/filter opcionales. Umbral para el " +"reconocimiento facial positivo. Los valores más altos son más estrictos." -#: lib/cli/args.py:562 lib/cli/args.py:574 lib/cli/args.py:586 -#: lib/cli/args.py:598 +#: lib/cli/args.py:558 lib/cli/args.py:570 lib/cli/args.py:582 +#: lib/cli/args.py:594 msgid "output" msgstr "salida" -#: lib/cli/args.py:563 +#: lib/cli/args.py:559 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -342,7 +334,7 @@ msgstr "" "pretende entrenar admite el tamaño deseado. Esto sólo tendrá que ser " "cambiado para los modelos de alta resolución." -#: lib/cli/args.py:575 +#: lib/cli/args.py:571 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -352,7 +344,7 @@ msgstr "" "extraer las caras. Por ejemplo, un valor de 1 extraerá las caras de cada " "fotograma, un valor de 10 extraerá las caras de cada 10 fotogramas." -#: lib/cli/args.py:587 +#: lib/cli/args.py:583 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -368,18 +360,18 @@ msgstr "" "ADVERTENCIA: No interrumpa el script al escribir el archivo porque podría " "corromperse. Poner a 0 para desactivar" -#: lib/cli/args.py:599 +#: lib/cli/args.py:595 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" "Dibujar puntos de referencia en las caras de salida para fines de depuración." -#: lib/cli/args.py:605 lib/cli/args.py:614 lib/cli/args.py:622 -#: lib/cli/args.py:629 lib/cli/args.py:847 lib/cli/args.py:858 -#: lib/cli/args.py:866 lib/cli/args.py:885 lib/cli/args.py:891 +#: lib/cli/args.py:601 lib/cli/args.py:610 lib/cli/args.py:618 +#: lib/cli/args.py:625 lib/cli/args.py:843 lib/cli/args.py:854 +#: lib/cli/args.py:862 lib/cli/args.py:881 lib/cli/args.py:887 msgid "settings" msgstr "ajustes" -#: lib/cli/args.py:606 +#: lib/cli/args.py:602 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " @@ -389,7 +381,7 @@ msgstr "" "extracción por separado (una tras otra) en lugar de hacerlo todo al mismo " "tiempo. Útil si la VRAM es escasa." -#: lib/cli/args.py:615 +#: lib/cli/args.py:611 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -397,19 +389,19 @@ msgstr "" "Omite los fotogramas que ya han sido extraídos y que existen en el archivo " "de alineaciones" -#: lib/cli/args.py:623 +#: lib/cli/args.py:619 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" "Omitir los fotogramas que ya tienen caras detectadas en el archivo de " "alineaciones" -#: lib/cli/args.py:630 +#: lib/cli/args.py:626 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "No guardar las caras detectadas en el disco. Crear sólo un archivo de " "alineaciones" -#: lib/cli/args.py:652 +#: lib/cli/args.py:648 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -419,7 +411,7 @@ msgstr "" "Los plugins de conversión pueden ser configurados en el menú " "\"Configuración\"" -#: lib/cli/args.py:673 +#: lib/cli/args.py:669 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -429,7 +421,7 @@ msgstr "" "original del que se extrajeron los fotogramas de origen (para extraer los " "fps y el audio)." -#: lib/cli/args.py:682 +#: lib/cli/args.py:678 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -437,7 +429,7 @@ msgstr "" "Directorio del modelo. El directorio que contiene el modelo entrenado que " "desea utilizar para la conversión." -#: lib/cli/args.py:692 +#: lib/cli/args.py:688 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -477,7 +469,7 @@ msgstr "" "colores. Generalmente no da resultados muy satisfactorios.\n" "L|none: No realice el ajuste de color." -#: lib/cli/args.py:719 +#: lib/cli/args.py:715 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -553,7 +545,7 @@ msgstr "" "L|predicted: Si la opción 'Learn Mask' se habilitó durante el entrenamiento, " "esto usará la máscara que fue creada por el modelo entrenado." -#: lib/cli/args.py:757 +#: lib/cli/args.py:753 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -579,11 +571,11 @@ msgstr "" "L|pillow: [images] Más lento que opencv, pero tiene más opciones y soporta " "más formatos." -#: lib/cli/args.py:776 lib/cli/args.py:783 lib/cli/args.py:877 +#: lib/cli/args.py:772 lib/cli/args.py:779 lib/cli/args.py:873 msgid "Frame Processing" msgstr "Proceso de fotogramas" -#: lib/cli/args.py:777 +#: lib/cli/args.py:773 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -593,7 +585,7 @@ msgstr "" "a los fotogramas a las dimensiones de origen. 50%% a la mitad de tamaño. " "200%% al doble de tamaño" -#: lib/cli/args.py:784 +#: lib/cli/args.py:780 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -607,7 +599,7 @@ msgstr "" "imágenes, ¡los nombres de los archivos deben terminar con el número de " "fotograma!" -#: lib/cli/args.py:794 +#: lib/cli/args.py:790 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -623,7 +615,47 @@ msgstr "" "especificada. Si se deja en blanco, se convertirán todas las caras que " "existan en el archivo de alineaciones." -#: lib/cli/args.py:848 +#: lib/cli/args.py:804 +msgid "" +"Optionally filter out people who you do not wish to process by passing in an " +"image of that person. Should be a front portrait with a single person in the " +"image. Multiple images can be added space separated. NB: Using face filter " +"will significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"Opcionalmente, puede filtrar las personas que no desea procesar pasando una " +"imagen de esa persona. Debe ser un retrato frontal con una sola persona en " +"la imagen. Se pueden añadir varias imágenes separadas por espacios. NB: El " +"uso del filtro de caras disminuirá significativamente la velocidad de " +"extracción y no se puede garantizar su precisión." + +#: lib/cli/args.py:817 +msgid "" +"Optionally select people you wish to process by passing in an image of that " +"person. Should be a front portrait with a single person in the image. " +"Multiple images can be added space separated. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"Opcionalmente, seleccione las personas que desea procesar pasando una imagen " +"de esa persona. Debe ser un retrato frontal con una sola persona en la " +"imagen. Se pueden añadir varias imágenes separadas por espacios. NB: El uso " +"del filtro facial disminuirá significativamente la velocidad de extracción y " +"no se puede garantizar su precisión." + +#: lib/cli/args.py:831 +msgid "" +"For use with the optional nfilter/filter files. Threshold for positive face " +"recognition. Lower values are stricter. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"Para usar con los archivos opcionales nfilter/filter. Umbral para el " +"reconocimiento positivo de caras. Los valores más bajos son más estrictos. " +"NB: El uso del filtro facial disminuirá significativamente la velocidad de " +"extracción y no se puede garantizar su precisión." + +#: lib/cli/args.py:844 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -640,7 +672,7 @@ msgstr "" "procesos que los disponibles en su sistema. Si 'singleprocess' está " "habilitado, este ajuste será ignorado." -#: lib/cli/args.py:859 +#: lib/cli/args.py:855 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -648,7 +680,7 @@ msgstr "" "[LEGACY] Sólo es necesario seleccionar esta opción si se está cargando un " "modelo heredado si hay varios modelos en la carpeta de modelos" -#: lib/cli/args.py:867 +#: lib/cli/args.py:863 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -663,7 +695,7 @@ msgstr "" "de baja calidad. Si se encuentra un archivo de alineaciones, esta opción " "será ignorada." -#: lib/cli/args.py:878 +#: lib/cli/args.py:874 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -671,16 +703,16 @@ msgstr "" "Cuando se usa con --frame-ranges, la salida incluye los fotogramas no " "procesados en vez de descartarlos." -#: lib/cli/args.py:886 +#: lib/cli/args.py:882 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Intercambiar el modelo. En vez de convertir de A a B, convierte de B a A" -#: lib/cli/args.py:892 +#: lib/cli/args.py:888 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Desactiva el multiproceso. Es más lento, pero usa menos recursos." -#: lib/cli/args.py:908 +#: lib/cli/args.py:904 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -692,11 +724,11 @@ msgstr "" "hasta más de una semana.\n" "Los plugins de los modelos pueden configurarse en el menú \"Ajustes\"" -#: lib/cli/args.py:927 lib/cli/args.py:936 +#: lib/cli/args.py:923 lib/cli/args.py:932 msgid "faces" msgstr "caras" -#: lib/cli/args.py:928 +#: lib/cli/args.py:924 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -706,7 +738,7 @@ msgstr "" "para la cara A. Esta es la cara original, es decir, la cara que se quiere " "eliminar y sustituir por la cara B." -#: lib/cli/args.py:937 +#: lib/cli/args.py:933 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -716,12 +748,12 @@ msgstr "" "para la cara B. Esta es la cara de intercambio, es decir, la cara que se " "quiere colocar en la cabeza de la persona A." -#: lib/cli/args.py:945 lib/cli/args.py:957 lib/cli/args.py:973 -#: lib/cli/args.py:998 lib/cli/args.py:1008 +#: lib/cli/args.py:941 lib/cli/args.py:953 lib/cli/args.py:969 +#: lib/cli/args.py:994 lib/cli/args.py:1004 msgid "model" msgstr "modelo" -#: lib/cli/args.py:946 +#: lib/cli/args.py:942 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -735,7 +767,7 @@ msgstr "" "carpeta que no exista (que se creará). Si continúa entrenando un modelo " "existente, especifique la ubicación del modelo existente." -#: lib/cli/args.py:958 +#: lib/cli/args.py:954 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -759,7 +791,7 @@ msgstr "" "NB: Los pesos solo se pueden cargar desde modelos del mismo complemento que " "desea entrenar." -#: lib/cli/args.py:974 +#: lib/cli/args.py:970 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -804,7 +836,7 @@ msgstr "" "recursos (se necesita una GPU con una buena cantidad de VRAM). Bueno para " "los detalles, pero más susceptible a las diferencias de color." -#: lib/cli/args.py:999 +#: lib/cli/args.py:995 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -816,7 +848,7 @@ msgstr "" "muestra un resumen del modelo que crearía el complemento elegido y los " "ajustes de configuración." -#: lib/cli/args.py:1009 +#: lib/cli/args.py:1005 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -830,12 +862,12 @@ msgstr "" "congelará el codificador, pero algunos modelos pueden tener opciones de " "configuración para congelar otras capas." -#: lib/cli/args.py:1022 lib/cli/args.py:1034 lib/cli/args.py:1045 -#: lib/cli/args.py:1056 lib/cli/args.py:1139 +#: lib/cli/args.py:1018 lib/cli/args.py:1030 lib/cli/args.py:1041 +#: lib/cli/args.py:1052 lib/cli/args.py:1135 msgid "training" msgstr "entrenamiento" -#: lib/cli/args.py:1023 +#: lib/cli/args.py:1019 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -848,7 +880,7 @@ msgstr "" "momento es el doble del número que se establece aquí. Los lotes más grandes " "requieren más RAM de la GPU." -#: lib/cli/args.py:1035 +#: lib/cli/args.py:1031 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -863,7 +895,7 @@ msgstr "" "automáticamente en un número determinado de iteraciones, puede establecer " "ese valor aquí." -#: lib/cli/args.py:1046 +#: lib/cli/args.py:1042 msgid "" "[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " "Mirrored Distrubution Strategy to train on multiple GPUs." @@ -871,7 +903,7 @@ msgstr "" "[Obsoleto: use '-D, --distribution-strategy' en su lugar] Use la estrategia " "de distribución duplicada de Tensorflow para entrenar en varias GPU." -#: lib/cli/args.py:1057 +#: lib/cli/args.py:1053 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -897,15 +929,15 @@ msgstr "" "locales. Se carga una copia del modelo y todas las variables en cada GPU con " "lotes distribuidos a cada GPU en cada iteración." -#: lib/cli/args.py:1074 lib/cli/args.py:1084 +#: lib/cli/args.py:1070 lib/cli/args.py:1080 msgid "Saving" msgstr "Guardar" -#: lib/cli/args.py:1075 +#: lib/cli/args.py:1071 msgid "Sets the number of iterations between each model save." msgstr "Establece el número de iteraciones entre cada guardado del modelo." -#: lib/cli/args.py:1085 +#: lib/cli/args.py:1081 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -913,11 +945,11 @@ msgstr "" "Establece el número de iteraciones antes de guardar una copia de seguridad " "del modelo en su estado actual. Establece 0 para que esté desactivado." -#: lib/cli/args.py:1092 lib/cli/args.py:1103 lib/cli/args.py:1114 +#: lib/cli/args.py:1088 lib/cli/args.py:1099 lib/cli/args.py:1110 msgid "timelapse" msgstr "intervalo" -#: lib/cli/args.py:1093 +#: lib/cli/args.py:1089 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -931,7 +963,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-B." -#: lib/cli/args.py:1104 +#: lib/cli/args.py:1100 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -945,7 +977,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-A." -#: lib/cli/args.py:1115 +#: lib/cli/args.py:1111 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -957,17 +989,17 @@ msgstr "" "Si se suministran las carpetas de entrada pero no la carpeta de salida, se " "guardará por defecto en la carpeta del modelo /timelapse/" -#: lib/cli/args.py:1124 lib/cli/args.py:1131 +#: lib/cli/args.py:1120 lib/cli/args.py:1127 msgid "preview" msgstr "previsualización" -#: lib/cli/args.py:1125 +#: lib/cli/args.py:1121 msgid "Show training preview output. in a separate window." msgstr "" "Mostrar la salida de la vista previa del entrenamiento. en una ventana " "separada." -#: lib/cli/args.py:1132 +#: lib/cli/args.py:1128 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -975,7 +1007,7 @@ msgstr "" "Escribe el resultado del entrenamiento en un archivo. La imagen se " "almacenará en la raíz de su carpeta FaceSwap." -#: lib/cli/args.py:1140 +#: lib/cli/args.py:1136 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -983,12 +1015,12 @@ msgstr "" "Desactiva el registro de TensorBoard. NB: Desactivar los registros significa " "que no podrá utilizar el gráfico o el análisis de esta sesión en la GUI." -#: lib/cli/args.py:1147 lib/cli/args.py:1156 lib/cli/args.py:1165 -#: lib/cli/args.py:1174 +#: lib/cli/args.py:1143 lib/cli/args.py:1152 lib/cli/args.py:1161 +#: lib/cli/args.py:1170 msgid "augmentation" msgstr "aumento" -#: lib/cli/args.py:1148 +#: lib/cli/args.py:1144 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -998,7 +1030,7 @@ msgstr "" "conjunto de caras opuestas en lugar de deformar la cara al azar. Esta es la " "forma 'dfaker' de hacer la deformación." -#: lib/cli/args.py:1157 +#: lib/cli/args.py:1153 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -1009,7 +1041,7 @@ msgstr "" "general, esto debería dejarse sin efecto, excepto durante el 'entrenamiento " "de ajuste'." -#: lib/cli/args.py:1166 +#: lib/cli/args.py:1162 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -1019,7 +1051,7 @@ msgstr "" "diferencias de color entre los conjuntos A y B, con un mayor coste de tiempo " "de entrenamiento. Activa esta opción para desactivar el aumento de color." -#: lib/cli/args.py:1175 +#: lib/cli/args.py:1171 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -1032,7 +1064,7 @@ msgstr "" "esta opción desde el principio, es probable que arruine el modelo y se " "obtengan resultados terribles." -#: lib/cli/args.py:1200 +#: lib/cli/args.py:1196 msgid "Output to Shell console instead of GUI console" msgstr "Salida a la consola Shell en lugar de la consola GUI" diff --git a/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index 48ef5429d9..788ba912c8 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-10-10 13:05+0100\n" +"POT-Creation-Date: 2022-10-31 11:51+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -46,7 +46,7 @@ msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "" #: lib/cli/args.py:320 lib/cli/args.py:329 lib/cli/args.py:337 -#: lib/cli/args.py:386 lib/cli/args.py:672 lib/cli/args.py:681 +#: lib/cli/args.py:386 lib/cli/args.py:668 lib/cli/args.py:677 msgid "Data" msgstr "" @@ -82,8 +82,8 @@ msgstr "" #: lib/cli/args.py:396 lib/cli/args.py:412 lib/cli/args.py:424 #: lib/cli/args.py:463 lib/cli/args.py:481 lib/cli/args.py:493 -#: lib/cli/args.py:502 lib/cli/args.py:691 lib/cli/args.py:718 -#: lib/cli/args.py:756 +#: lib/cli/args.py:502 lib/cli/args.py:687 lib/cli/args.py:714 +#: lib/cli/args.py:752 msgid "Plugins" msgstr "" @@ -180,9 +180,9 @@ msgid "" "little, but will save time if using 'sort by face'" msgstr "" -#: lib/cli/args.py:513 lib/cli/args.py:523 lib/cli/args.py:536 -#: lib/cli/args.py:550 lib/cli/args.py:793 lib/cli/args.py:807 -#: lib/cli/args.py:820 lib/cli/args.py:834 +#: lib/cli/args.py:513 lib/cli/args.py:523 lib/cli/args.py:535 +#: lib/cli/args.py:548 lib/cli/args.py:789 lib/cli/args.py:803 +#: lib/cli/args.py:816 lib/cli/args.py:830 msgid "Face Processing" msgstr "" @@ -192,52 +192,48 @@ msgid "" "diagonal of the bounding box. Set to 0 for off" msgstr "" -#: lib/cli/args.py:524 lib/cli/args.py:808 +#: lib/cli/args.py:524 msgid "" -"Optionally filter out people who you do not wish to process by passing in an " -"image of that person. Should be a front portrait with a single person in the " -"image. Multiple images can be added space separated. NB: Using face filter " -"will significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." +"Optionally filter out people who you do not wish to extract by passing in " +"images of those people. Should be a small variety of images at different " +"angles and in different conditions. Multiple images can be added space " +"separated." msgstr "" -#: lib/cli/args.py:537 lib/cli/args.py:821 +#: lib/cli/args.py:536 msgid "" -"Optionally select people you wish to process by passing in an image of that " -"person. Should be a front portrait with a single person in the image. " -"Multiple images can be added space separated. NB: Using face filter will " -"significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." +"Optionally select people you wish to extract by passing in images of that " +"person. Should be a small variety of images at different angles and in " +"different conditions. Multiple identities can be filtered. Multiple images " +"can be added space separated." msgstr "" -#: lib/cli/args.py:551 lib/cli/args.py:835 +#: lib/cli/args.py:549 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " -"recognition. Lower values are stricter. NB: Using face filter will " -"significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." +"recognition. Higher values are stricter." msgstr "" -#: lib/cli/args.py:562 lib/cli/args.py:574 lib/cli/args.py:586 -#: lib/cli/args.py:598 +#: lib/cli/args.py:558 lib/cli/args.py:570 lib/cli/args.py:582 +#: lib/cli/args.py:594 msgid "output" msgstr "" -#: lib/cli/args.py:563 +#: lib/cli/args.py:559 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" "res models." msgstr "" -#: lib/cli/args.py:575 +#: lib/cli/args.py:571 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " "of 10 will extract faces from every 10th frame." msgstr "" -#: lib/cli/args.py:587 +#: lib/cli/args.py:583 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -247,57 +243,57 @@ msgid "" "turn off" msgstr "" -#: lib/cli/args.py:599 +#: lib/cli/args.py:595 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" -#: lib/cli/args.py:605 lib/cli/args.py:614 lib/cli/args.py:622 -#: lib/cli/args.py:629 lib/cli/args.py:847 lib/cli/args.py:858 -#: lib/cli/args.py:866 lib/cli/args.py:885 lib/cli/args.py:891 +#: lib/cli/args.py:601 lib/cli/args.py:610 lib/cli/args.py:618 +#: lib/cli/args.py:625 lib/cli/args.py:843 lib/cli/args.py:854 +#: lib/cli/args.py:862 lib/cli/args.py:881 lib/cli/args.py:887 msgid "settings" msgstr "" -#: lib/cli/args.py:606 +#: lib/cli/args.py:602 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " "Useful if VRAM is at a premium." msgstr "" -#: lib/cli/args.py:615 +#: lib/cli/args.py:611 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" msgstr "" -#: lib/cli/args.py:623 +#: lib/cli/args.py:619 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" -#: lib/cli/args.py:630 +#: lib/cli/args.py:626 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" -#: lib/cli/args.py:652 +#: lib/cli/args.py:648 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:673 +#: lib/cli/args.py:669 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " "audio)." msgstr "" -#: lib/cli/args.py:682 +#: lib/cli/args.py:678 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." msgstr "" -#: lib/cli/args.py:692 +#: lib/cli/args.py:688 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -318,7 +314,7 @@ msgid "" "L|none: Don't perform color adjustment." msgstr "" -#: lib/cli/args.py:719 +#: lib/cli/args.py:715 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -355,7 +351,7 @@ msgid "" "will use the mask that was created by the trained model." msgstr "" -#: lib/cli/args.py:757 +#: lib/cli/args.py:753 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -370,18 +366,18 @@ msgid "" "more formats." msgstr "" -#: lib/cli/args.py:776 lib/cli/args.py:783 lib/cli/args.py:877 +#: lib/cli/args.py:772 lib/cli/args.py:779 lib/cli/args.py:873 msgid "Frame Processing" msgstr "" -#: lib/cli/args.py:777 +#: lib/cli/args.py:773 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " "at source dimensions. 50%% at half size 200%% at double size" msgstr "" -#: lib/cli/args.py:784 +#: lib/cli/args.py:780 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -389,7 +385,7 @@ msgid "" "converting from images, then the filenames must end with the frame-number!" msgstr "" -#: lib/cli/args.py:794 +#: lib/cli/args.py:790 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -399,7 +395,33 @@ msgid "" "alignments file." msgstr "" -#: lib/cli/args.py:848 +#: lib/cli/args.py:804 +msgid "" +"Optionally filter out people who you do not wish to process by passing in an " +"image of that person. Should be a front portrait with a single person in the " +"image. Multiple images can be added space separated. NB: Using face filter " +"will significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" + +#: lib/cli/args.py:817 +msgid "" +"Optionally select people you wish to process by passing in an image of that " +"person. Should be a front portrait with a single person in the image. " +"Multiple images can be added space separated. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" + +#: lib/cli/args.py:831 +msgid "" +"For use with the optional nfilter/filter files. Threshold for positive face " +"recognition. Lower values are stricter. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" + +#: lib/cli/args.py:844 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -409,13 +431,13 @@ msgid "" "your system. If singleprocess is enabled this setting will be ignored." msgstr "" -#: lib/cli/args.py:859 +#: lib/cli/args.py:855 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" msgstr "" -#: lib/cli/args.py:867 +#: lib/cli/args.py:863 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -424,51 +446,51 @@ msgid "" "alignments file is found, this option will be ignored." msgstr "" -#: lib/cli/args.py:878 +#: lib/cli/args.py:874 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." msgstr "" -#: lib/cli/args.py:886 +#: lib/cli/args.py:882 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" -#: lib/cli/args.py:892 +#: lib/cli/args.py:888 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "" -#: lib/cli/args.py:908 +#: lib/cli/args.py:904 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" "Model plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:927 lib/cli/args.py:936 +#: lib/cli/args.py:923 lib/cli/args.py:932 msgid "faces" msgstr "" -#: lib/cli/args.py:928 +#: lib/cli/args.py:924 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " "face B." msgstr "" -#: lib/cli/args.py:937 +#: lib/cli/args.py:933 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " "A." msgstr "" -#: lib/cli/args.py:945 lib/cli/args.py:957 lib/cli/args.py:973 -#: lib/cli/args.py:998 lib/cli/args.py:1008 +#: lib/cli/args.py:941 lib/cli/args.py:953 lib/cli/args.py:969 +#: lib/cli/args.py:994 lib/cli/args.py:1004 msgid "model" msgstr "" -#: lib/cli/args.py:946 +#: lib/cli/args.py:942 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -477,7 +499,7 @@ msgid "" "the existing model." msgstr "" -#: lib/cli/args.py:958 +#: lib/cli/args.py:954 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -491,7 +513,7 @@ msgid "" "to train." msgstr "" -#: lib/cli/args.py:974 +#: lib/cli/args.py:970 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -514,7 +536,7 @@ msgid "" "susceptible to color differences." msgstr "" -#: lib/cli/args.py:999 +#: lib/cli/args.py:995 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -522,7 +544,7 @@ msgid "" "displayed." msgstr "" -#: lib/cli/args.py:1009 +#: lib/cli/args.py:1005 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -531,12 +553,12 @@ msgid "" "layers." msgstr "" -#: lib/cli/args.py:1022 lib/cli/args.py:1034 lib/cli/args.py:1045 -#: lib/cli/args.py:1056 lib/cli/args.py:1139 +#: lib/cli/args.py:1018 lib/cli/args.py:1030 lib/cli/args.py:1041 +#: lib/cli/args.py:1052 lib/cli/args.py:1135 msgid "training" msgstr "" -#: lib/cli/args.py:1023 +#: lib/cli/args.py:1019 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -544,7 +566,7 @@ msgid "" "number that you set here. Larger batches require more GPU RAM." msgstr "" -#: lib/cli/args.py:1035 +#: lib/cli/args.py:1031 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -553,13 +575,13 @@ msgid "" "can set that value here." msgstr "" -#: lib/cli/args.py:1046 +#: lib/cli/args.py:1042 msgid "" "[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " "Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" -#: lib/cli/args.py:1057 +#: lib/cli/args.py:1053 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -572,25 +594,25 @@ msgid "" "batches distributed to each GPU at each iteration." msgstr "" -#: lib/cli/args.py:1074 lib/cli/args.py:1084 +#: lib/cli/args.py:1070 lib/cli/args.py:1080 msgid "Saving" msgstr "" -#: lib/cli/args.py:1075 +#: lib/cli/args.py:1071 msgid "Sets the number of iterations between each model save." msgstr "" -#: lib/cli/args.py:1085 +#: lib/cli/args.py:1081 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." msgstr "" -#: lib/cli/args.py:1092 lib/cli/args.py:1103 lib/cli/args.py:1114 +#: lib/cli/args.py:1088 lib/cli/args.py:1099 lib/cli/args.py:1110 msgid "timelapse" msgstr "" -#: lib/cli/args.py:1093 +#: lib/cli/args.py:1089 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -599,7 +621,7 @@ msgid "" "timelapse-input-B parameter." msgstr "" -#: lib/cli/args.py:1104 +#: lib/cli/args.py:1100 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -608,7 +630,7 @@ msgid "" "timelapse-input-A parameter." msgstr "" -#: lib/cli/args.py:1115 +#: lib/cli/args.py:1111 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -616,53 +638,53 @@ msgid "" "model folder /timelapse/" msgstr "" -#: lib/cli/args.py:1124 lib/cli/args.py:1131 +#: lib/cli/args.py:1120 lib/cli/args.py:1127 msgid "preview" msgstr "" -#: lib/cli/args.py:1125 +#: lib/cli/args.py:1121 msgid "Show training preview output. in a separate window." msgstr "" -#: lib/cli/args.py:1132 +#: lib/cli/args.py:1128 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." msgstr "" -#: lib/cli/args.py:1140 +#: lib/cli/args.py:1136 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." msgstr "" -#: lib/cli/args.py:1147 lib/cli/args.py:1156 lib/cli/args.py:1165 -#: lib/cli/args.py:1174 +#: lib/cli/args.py:1143 lib/cli/args.py:1152 lib/cli/args.py:1161 +#: lib/cli/args.py:1170 msgid "augmentation" msgstr "" -#: lib/cli/args.py:1148 +#: lib/cli/args.py:1144 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " "warping." msgstr "" -#: lib/cli/args.py:1157 +#: lib/cli/args.py:1153 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " "left off except for during 'fit training'." msgstr "" -#: lib/cli/args.py:1166 +#: lib/cli/args.py:1162 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " "Enable this option to disable color augmentation." msgstr "" -#: lib/cli/args.py:1175 +#: lib/cli/args.py:1171 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -670,6 +692,6 @@ msgid "" "likely to kill a model and lead to terrible results." msgstr "" -#: lib/cli/args.py:1200 +#: lib/cli/args.py:1196 msgid "Output to Shell console instead of GUI console" msgstr "" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo index 6df5de854a2fead5c9b6c4758f914b31a8bea4dc..f13df69ccf0b384bd24ee021bf474d8a6d129660 100644 GIT binary patch delta 3182 zcmcJQeQ;FO6~GTkBnSyXU?TK$o3Oh+kM+QkXLS(W!58 z_IJ;@=iYnHx#ztcKat*ZGCgu?XvR~*a}P2KIgut3fsb6l56^~7k%oRE8(}W|Vt`05 z_WlD!GGHI9fN#RB+CE661MY;=;oK~d2UxcW_MxB37O93m8!VEej~dADgBbE)7`DUT z!ptEe{K@b6DQ1JOVHq4hRD`v%e3;0;U^Q&PZVVTRqkrxZ*#r0IiaZBrUoA3^`IE2( zU0oxx0{#Nt+>cb#^PG(5jud&GiC@8gz}1vvE(`C=cerP?NG0|&V?@rv`AXzQ=8FnM zZbz@Y)>-#5EX4leSdnws-x()z3+x?FB2caqSpZ64rY`k*28$@~X80cZiAf?exrY6C zzm5J$DK>bxOk@rAO_N1h@&9Cs$d?>cOa1%OMed&^GMV`?oK~43vUHw^2fb?{*P{0& z%2xO;48aw5koYtPZ!;)|{qH1GI0G(#o8e;kI%H27zDT4IE{9zl{1My_cl;1yFJB_k z1K)SqzSQZN#dnG9#ctd!vKD46b1E5J#{DO11HTMepV1MSHsS%07y|t7dd%=%Iu4nxmSPXrB z5!y(02WW2my5Je?yCU@>UJUEGkiX&ZA+Nx{q18YG;$RV`j=XfE6TqQ|xL52yd6+8T zm!xbJ%y(~Q48JC!Phq@~N&NOS@#-?%OPM>dk7yBDh5gqw!sFN@g%?(HH8 z9H+O5aBcFJ9lSzt!A_B&y0ji2U z`$eu5|ECYpfSj}LDCN)O{^$4dUUS*rK1#?Xns18yoo>#hzP!?s^FH4JHW~*L=uW6;TAaOBR)RxEvW4)KNdL+Ps3-}_xMSXAOS4LSftSO&AuXTX8*R#*a; z!l5t<`IgC3a5(470S4?NlM%h(+lQj0J{V(M12Lsa|LuGZgBuZj__fF)u4igV>T>Du1BsxmLPmLQqS%D>VGyPklDyMWHge2+=Fkbz>Rm`{5tfCC@TfTj26)_yc$RBRC1^@9G*8-Eje&=Z@#P zoB^jJbC8*??N*qlBfle-owzSV%0uB)iJ6rc5RQUEse$QE*x^jF003GGwWA*%vq7lG`rDUvLkQyHtU#`Q1)ZiVXNEf z;a95x=YSFi#md@eCGAJ8q!m-vanOTwSqVF8#m#(WiG6hCJ9YW&W+K~T_Owhe zfBCGZ=!ia2=aI^>*WRXdrBms>%7F~@JS8hOE1FBp>1)Tv4n%jSjlX;~xjF64D(8+j zG*2*p6U@xNP({1-@)b0T{8{GirW{kX42pd3_4@8|Rs46oeyZFYNcC|TIufW eA=OqrotjFRuRWV9)oAM*%U z{B$VLb3HQXZPA`av(SfAq!(fNRDNiVX;O256ak&EJw!T<|46751lLcOmckm?Vf^qo zObL?~K_9GUUG#m@IqV(b(q_11hBQ<1sE5u@94FuyOvUSfA^fDXFc!LCX@tZSwMI&J z;p=cO{@0?UKJ1y%(qVWW9)xvHsfh8ra4&Y@1JZMF5a!{3IM(d$;WTm5AqFPkZP*si zWG3t)9EP7I@G$&A4@%kC^{@>4SR!wU{UiJV+c{S%fB{Jy92UV6xD66KeGFI78Gu)@ z9cfaIhj35Oxr8H`RYUL(Sd0H`2H|Ewa+dTf8+|}L&jk|VB~ljSb$N(8*q={$vF{X1 z!REoGtb;Ld8|;RU!>Hww8vnBB&>4|4sg+APgu-ENo;->WA|_^)muiJA91 z{09GU1C@d8ZIb?CJetz_7lv=;P>hFrxFB=l#%6ot?>TNI1HF{Q9VY(MLU}R%XDj2- zy-T_Ue=~7p-Zfr;De~=+Hej!5qcX6)uW+~6ImEr6`KL(;qOKhbE`x(`KlFrk*k#oN zbE(ba@E8kz=_0&A-01NFgYn;h zU&Fd9QWp;{9APa7@O{O%g9le!lPi;eqcMWWte~cJlQ>ldWgqba(+~z)=_pgD30{RxC7cmj~SoYIGKTlSt-4CIJ_sTvUXpF00MqcLCxSL}iMXv+mYZN+AkEd{V4L1@~Hs zzn2M1(Idz++wZLOGZmJjXk<#xG|Js-EO4C&sxvb<$g>zL1*M}hl#fg^7mh0}|C_l))1pwZ)#E3>d3#f1&Gr}SY8o0^620xyHidYr9kC8?)tu%4 z@5Q7pcbF?P(dAy8nVpvD@-{t_;_!CYw@9$J?FWTW^e1{|5s# B71{s* diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.po b/locales/ru/LC_MESSAGES/lib.cli.args.po index 362a188f23..4bae0ffb0a 100644 --- a/locales/ru/LC_MESSAGES/lib.cli.args.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-10-10 13:05+0100\n" -"PO-Revision-Date: 2022-10-10 13:08+0100\n" +"POT-Creation-Date: 2022-10-31 11:51+0000\n" +"PO-Revision-Date: 2022-10-31 11:54+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -58,7 +58,7 @@ msgstr "" "с faceswap" #: lib/cli/args.py:320 lib/cli/args.py:329 lib/cli/args.py:337 -#: lib/cli/args.py:386 lib/cli/args.py:672 lib/cli/args.py:681 +#: lib/cli/args.py:386 lib/cli/args.py:668 lib/cli/args.py:677 msgid "Data" msgstr "Данные" @@ -102,8 +102,8 @@ msgstr "" #: lib/cli/args.py:396 lib/cli/args.py:412 lib/cli/args.py:424 #: lib/cli/args.py:463 lib/cli/args.py:481 lib/cli/args.py:493 -#: lib/cli/args.py:502 lib/cli/args.py:691 lib/cli/args.py:718 -#: lib/cli/args.py:756 +#: lib/cli/args.py:502 lib/cli/args.py:687 lib/cli/args.py:714 +#: lib/cli/args.py:752 msgid "Plugins" msgstr "Плагины" @@ -266,9 +266,9 @@ msgstr "" "Получите и сохраните кодировку идентификации лица от VGGFace2. Немного " "замедляет извлечение, но сэкономит время при использовании «sort by face»" -#: lib/cli/args.py:513 lib/cli/args.py:523 lib/cli/args.py:536 -#: lib/cli/args.py:550 lib/cli/args.py:793 lib/cli/args.py:807 -#: lib/cli/args.py:820 lib/cli/args.py:834 +#: lib/cli/args.py:513 lib/cli/args.py:523 lib/cli/args.py:535 +#: lib/cli/args.py:548 lib/cli/args.py:789 lib/cli/args.py:803 +#: lib/cli/args.py:816 lib/cli/args.py:830 msgid "Face Processing" msgstr "Обработка лиц" @@ -280,51 +280,45 @@ msgstr "" "Отбрасывает лица ниже указанного размера. Длина указывается в пикселях по " "диагонали. Установите в 0 для отключения" -#: lib/cli/args.py:524 lib/cli/args.py:808 +#: lib/cli/args.py:524 msgid "" -"Optionally filter out people who you do not wish to process by passing in an " -"image of that person. Should be a front portrait with a single person in the " -"image. Multiple images can be added space separated. NB: Using face filter " -"will significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." +"Optionally filter out people who you do not wish to extract by passing in " +"images of those people. Should be a small variety of images at different " +"angles and in different conditions. Multiple images can be added space " +"separated." msgstr "" -"Дополнительно вы можете отфильтровать лица людей, которых вы не хотите " -"обрабатывать указав изображение этого человека. На изображении должен быть " -"фронтальный портрет одного человека . Можно указать несколько файлов через " -"пробел. Прим.: Фильтрация лиц существенно снижает скорость извлечения, при " -"этом точность не гарантируется." +"При желании отфильтруйте людей, которых вы не хотите извлекать, передав " +"изображения этих людей. Должно быть небольшое разнообразие снимков под " +"разными углами и в разных условиях. Несколько изображений могут быть " +"добавлены через пробел." -#: lib/cli/args.py:537 lib/cli/args.py:821 +#: lib/cli/args.py:536 msgid "" -"Optionally select people you wish to process by passing in an image of that " -"person. Should be a front portrait with a single person in the image. " -"Multiple images can be added space separated. NB: Using face filter will " -"significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." +"Optionally select people you wish to extract by passing in images of that " +"person. Should be a small variety of images at different angles and in " +"different conditions. Multiple identities can be filtered. Multiple images " +"can be added space separated." msgstr "" -"Дополнительно вы можете выбрать людей, которых вы хотели бы включить в " -"обработку путем указания изображения этого человека. Должен быть фронтальный " -"портрет с лишь одним человеком на картинке. Можно выбрать несколько " -"изображений через пробел. Прим.: Использование фильтра существенно замедлит " -"скорость извлечения. Также точность не гарантируется." +"При желании выберите людей, которых вы хотите извлечь, передав изображения " +"этого человека. Должно быть небольшое разнообразие снимков под разными " +"углами и в разных условиях. Множественные личности могут быть отфильтрованы. " +"Несколько изображений могут быть добавлены через пробел." -#: lib/cli/args.py:551 lib/cli/args.py:835 +#: lib/cli/args.py:549 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " -"recognition. Lower values are stricter. NB: Using face filter will " -"significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." +"recognition. Higher values are stricter." msgstr "" -"Только при использовании файлов nfilter/filter. Порог для распознавания " -"лица. Чем ниже значения, тем строже. Прим.: Использование фильтра лиц " -"существенно замедлит скорость извлечения. Также точность не гарантируется." +"Для использования с дополнительными файлами nfilter/filter. Порог " +"положительного распознавания лиц. Более высокие значения являются более " +"строгими." -#: lib/cli/args.py:562 lib/cli/args.py:574 lib/cli/args.py:586 -#: lib/cli/args.py:598 +#: lib/cli/args.py:558 lib/cli/args.py:570 lib/cli/args.py:582 +#: lib/cli/args.py:594 msgid "output" msgstr "вывод" -#: lib/cli/args.py:563 +#: lib/cli/args.py:559 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -334,7 +328,7 @@ msgstr "" "поддерживает такой входной размер. Стоит изменять только для моделей " "высокого разрешения." -#: lib/cli/args.py:575 +#: lib/cli/args.py:571 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -344,7 +338,7 @@ msgstr "" "извлечении. Например, значение 1 будет искать лица в каждом кадре, а " "значение 10 в каждом 10том кадре." -#: lib/cli/args.py:587 +#: lib/cli/args.py:583 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -359,17 +353,17 @@ msgstr "" "только во время второго прохода. ВНИМАНИЕ: Не прерывайте выполнение во время " "записи, так как это может повлечь порчу файла. Установите в 0 для выключения" -#: lib/cli/args.py:599 +#: lib/cli/args.py:595 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "Рисовать ландмарки на выходных лицах для нужд отладки." -#: lib/cli/args.py:605 lib/cli/args.py:614 lib/cli/args.py:622 -#: lib/cli/args.py:629 lib/cli/args.py:847 lib/cli/args.py:858 -#: lib/cli/args.py:866 lib/cli/args.py:885 lib/cli/args.py:891 +#: lib/cli/args.py:601 lib/cli/args.py:610 lib/cli/args.py:618 +#: lib/cli/args.py:625 lib/cli/args.py:843 lib/cli/args.py:854 +#: lib/cli/args.py:862 lib/cli/args.py:881 lib/cli/args.py:887 msgid "settings" msgstr "настройки" -#: lib/cli/args.py:606 +#: lib/cli/args.py:602 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " @@ -379,7 +373,7 @@ msgstr "" "стадия извлечения будет запущена отдельно (одна, за другой). Полезно при " "нехватке VRAM." -#: lib/cli/args.py:615 +#: lib/cli/args.py:611 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -387,16 +381,16 @@ msgstr "" "Пропускать кадры, которые уже были извлечены и существуют в файле " "выравнивания" -#: lib/cli/args.py:623 +#: lib/cli/args.py:619 msgid "Skip frames that already have detected faces in the alignments file" msgstr "Пропускать кадры, для которых в файле выравнивания есть найденные лица" -#: lib/cli/args.py:630 +#: lib/cli/args.py:626 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "Не сохранять найденные лица на носитель. Просто создать файл выравнивания" -#: lib/cli/args.py:652 +#: lib/cli/args.py:648 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -404,7 +398,7 @@ msgstr "" "Заменить оригиналы лица в исходном видео/фотографиях новыми.\n" "Плагины конвертации могут быть настроены в меню 'Настройки'" -#: lib/cli/args.py:673 +#: lib/cli/args.py:669 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -414,7 +408,7 @@ msgstr "" "Предоставьте исходное видео, из которого были извлечены кадры (для настройки " "частоты кадров, а также аудио)." -#: lib/cli/args.py:682 +#: lib/cli/args.py:678 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -422,7 +416,7 @@ msgstr "" "Папка с моделью. Папка, содержащая обученную модель, которую вы хотите " "использовать для преобразования." -#: lib/cli/args.py:692 +#: lib/cli/args.py:688 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -461,7 +455,7 @@ msgstr "" "дает удовлетворительных результатов.\n" "L|none: Не производить подгонку цвета." -#: lib/cli/args.py:719 +#: lib/cli/args.py:715 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -530,7 +524,7 @@ msgstr "" "L| predicted: Если во время обучения была включена опция «Learn Mask», будет " "использоваться маска, созданная обученной моделью." -#: lib/cli/args.py:757 +#: lib/cli/args.py:753 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -556,11 +550,11 @@ msgstr "" "L|pillow: [изображения] Более медленный, чем opencv, но имеет больше опций и " "поддерживает больше форматов." -#: lib/cli/args.py:776 lib/cli/args.py:783 lib/cli/args.py:877 +#: lib/cli/args.py:772 lib/cli/args.py:779 lib/cli/args.py:873 msgid "Frame Processing" msgstr "Обработка кадров" -#: lib/cli/args.py:777 +#: lib/cli/args.py:773 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -570,7 +564,7 @@ msgstr "" "кадры в исходном размере. 50%% половина от размера, а 200%% в удвоенном " "размере" -#: lib/cli/args.py:784 +#: lib/cli/args.py:780 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -583,7 +577,7 @@ msgstr "" "unchanged). Прим.: Если при конверсии используются изображения, то имена " "файлов должны заканчиваться номером кадра!" -#: lib/cli/args.py:794 +#: lib/cli/args.py:790 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -599,7 +593,46 @@ msgstr "" "Если оставить это поле пустым, то все лица, которые существуют в файле " "выравниваний будут сконвертированы." -#: lib/cli/args.py:848 +#: lib/cli/args.py:804 +msgid "" +"Optionally filter out people who you do not wish to process by passing in an " +"image of that person. Should be a front portrait with a single person in the " +"image. Multiple images can be added space separated. NB: Using face filter " +"will significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"Дополнительно вы можете отфильтровать лица людей, которых вы не хотите " +"обрабатывать указав изображение этого человека. На изображении должен быть " +"фронтальный портрет одного человека . Можно указать несколько файлов через " +"пробел. Прим.: Фильтрация лиц существенно снижает скорость извлечения, при " +"этом точность не гарантируется." + +#: lib/cli/args.py:817 +msgid "" +"Optionally select people you wish to process by passing in an image of that " +"person. Should be a front portrait with a single person in the image. " +"Multiple images can be added space separated. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"Дополнительно вы можете выбрать людей, которых вы хотели бы включить в " +"обработку путем указания изображения этого человека. Должен быть фронтальный " +"портрет с лишь одним человеком на картинке. Можно выбрать несколько " +"изображений через пробел. Прим.: Использование фильтра существенно замедлит " +"скорость извлечения. Также точность не гарантируется." + +#: lib/cli/args.py:831 +msgid "" +"For use with the optional nfilter/filter files. Threshold for positive face " +"recognition. Lower values are stricter. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"Только при использовании файлов nfilter/filter. Порог для распознавания " +"лица. Чем ниже значения, тем строже. Прим.: Использование фильтра лиц " +"существенно замедлит скорость извлечения. Также точность не гарантируется." + +#: lib/cli/args.py:844 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -616,7 +649,7 @@ msgstr "" "будет использоваться больше процессов, чем доступно в вашей системе. Если " "включен одиночный процесс, этот параметр будет проигнорирован." -#: lib/cli/args.py:859 +#: lib/cli/args.py:855 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -624,7 +657,7 @@ msgstr "" "[СОВМЕСТИМОСТЬ] Это нужно выбирать только в том случае, если загружается " "устаревшая модель или если в папке сохранения есть несколько моделей" -#: lib/cli/args.py:867 +#: lib/cli/args.py:863 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -638,7 +671,7 @@ msgstr "" "использованию улучшенного конвейера экстракции и некачественных результатов. " "Если файл выравниваний найден, этот параметр будет проигнорирован." -#: lib/cli/args.py:878 +#: lib/cli/args.py:874 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -646,16 +679,16 @@ msgstr "" "При использовании с --frame-range кадры не попавшие в диапазон выводятся " "неизменными, вместо их пропуска." -#: lib/cli/args.py:886 +#: lib/cli/args.py:882 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Поменять модели местами. Вместо преобразования из A -> B, преобразует B -> A" -#: lib/cli/args.py:892 +#: lib/cli/args.py:888 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Отключить многопроцессорность. Медленнее, но менее ресурсоемко." -#: lib/cli/args.py:908 +#: lib/cli/args.py:904 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -666,11 +699,11 @@ msgstr "" "Обучение моделей может занять долгое время: от 24 часов до недели\n" "Каждую модель можно отдельно настроить в меню «Настройки»" -#: lib/cli/args.py:927 lib/cli/args.py:936 +#: lib/cli/args.py:923 lib/cli/args.py:932 msgid "faces" msgstr "лица" -#: lib/cli/args.py:928 +#: lib/cli/args.py:924 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -679,7 +712,7 @@ msgstr "" "Входная папка. Папка содержащая изображения для тренировки лица A. Это " "исходное лицо т.е. лицо, которое вы хотите убрать, заменив лицом B." -#: lib/cli/args.py:937 +#: lib/cli/args.py:933 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -688,12 +721,12 @@ msgstr "" "Входная папка. Папка содержащая изображения для тренировки лица B. Это новое " "лицо т.е. лицо, которое вы хотите поместить на голову человека A." -#: lib/cli/args.py:945 lib/cli/args.py:957 lib/cli/args.py:973 -#: lib/cli/args.py:998 lib/cli/args.py:1008 +#: lib/cli/args.py:941 lib/cli/args.py:953 lib/cli/args.py:969 +#: lib/cli/args.py:994 lib/cli/args.py:1004 msgid "model" msgstr "модель" -#: lib/cli/args.py:946 +#: lib/cli/args.py:942 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -707,7 +740,7 @@ msgstr "" "будет создана). Если вы хотите продолжить тренировку, выберите папку с уже " "существующими сохранениями." -#: lib/cli/args.py:958 +#: lib/cli/args.py:954 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -731,7 +764,7 @@ msgstr "" "NB: Вес можно загружать только из моделей того же плагина, который вы " "собираетесь тренировать." -#: lib/cli/args.py:974 +#: lib/cli/args.py:970 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -777,7 +810,7 @@ msgstr "" "ресурсам (Вам потребуется GPU с хорошим количеством видеопамяти). Хороша для " "деталей, но подвержена к неправильной передаче цвета." -#: lib/cli/args.py:999 +#: lib/cli/args.py:995 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -789,7 +822,7 @@ msgstr "" "сводная информация о модели, которая будет создана выбранным плагином, и " "параметрами конфигурации." -#: lib/cli/args.py:1009 +#: lib/cli/args.py:1005 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -803,12 +836,12 @@ msgstr "" "некоторые модели могут иметь параметры конфигурации для замораживания других " "слоев." -#: lib/cli/args.py:1022 lib/cli/args.py:1034 lib/cli/args.py:1045 -#: lib/cli/args.py:1056 lib/cli/args.py:1139 +#: lib/cli/args.py:1018 lib/cli/args.py:1030 lib/cli/args.py:1041 +#: lib/cli/args.py:1052 lib/cli/args.py:1135 msgid "training" msgstr "тренировка" -#: lib/cli/args.py:1023 +#: lib/cli/args.py:1019 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -821,7 +854,7 @@ msgstr "" "изображений в два раза больше этого числа. Увеличение размера партии требует " "больше памяти GPU." -#: lib/cli/args.py:1035 +#: lib/cli/args.py:1031 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -835,7 +868,7 @@ msgstr "" "Однако, если вы хотите, чтобы тренировка прервалась после указанного кол-ва " "итерация, вы можете ввести это здесь." -#: lib/cli/args.py:1046 +#: lib/cli/args.py:1042 msgid "" "[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " "Mirrored Distrubution Strategy to train on multiple GPUs." @@ -844,7 +877,7 @@ msgstr "" "Используйте стратегию зеркального распространения Tensorflow для обучения на " "нескольких графических процессорах." -#: lib/cli/args.py:1057 +#: lib/cli/args.py:1053 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -869,15 +902,15 @@ msgstr "" "в каждый GPU, причем пакеты распределяются между каждым GPU на каждой " "итерации." -#: lib/cli/args.py:1074 lib/cli/args.py:1084 +#: lib/cli/args.py:1070 lib/cli/args.py:1080 msgid "Saving" msgstr "Сохранение" -#: lib/cli/args.py:1075 +#: lib/cli/args.py:1071 msgid "Sets the number of iterations between each model save." msgstr "Установка количества итераций между сохранениями модели." -#: lib/cli/args.py:1085 +#: lib/cli/args.py:1081 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -885,11 +918,11 @@ msgstr "" "Устанавливает кол-во итераций перед созданием резервной копии модели. " "Установите в 0 для отключения." -#: lib/cli/args.py:1092 lib/cli/args.py:1103 lib/cli/args.py:1114 +#: lib/cli/args.py:1088 lib/cli/args.py:1099 lib/cli/args.py:1110 msgid "timelapse" msgstr "таймлапс" -#: lib/cli/args.py:1093 +#: lib/cli/args.py:1089 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -902,7 +935,7 @@ msgstr "" "папку лиц набора 'A' для использования при создании таймлапса. Вам также " "нужно указать параметры--timelapse-output и --timelapse-input-B." -#: lib/cli/args.py:1104 +#: lib/cli/args.py:1100 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -916,7 +949,7 @@ msgstr "" "таймлапса. Вы также должны указать параметр --timelapse-output и --timelapse-" "input-A." -#: lib/cli/args.py:1115 +#: lib/cli/args.py:1111 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -928,15 +961,15 @@ msgstr "" "указаны только входные папки, то по умолчанию вывод будет сохранен вместе с " "моделью в подкаталог /timelapse/" -#: lib/cli/args.py:1124 lib/cli/args.py:1131 +#: lib/cli/args.py:1120 lib/cli/args.py:1127 msgid "preview" msgstr "предварительный просмотр" -#: lib/cli/args.py:1125 +#: lib/cli/args.py:1121 msgid "Show training preview output. in a separate window." msgstr "Показывать предварительный просмотр в отдельном окне." -#: lib/cli/args.py:1132 +#: lib/cli/args.py:1128 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -944,7 +977,7 @@ msgstr "" "Записывает результат тренировки в файл. Файл будет сохранен в коренной папке " "FaceSwap." -#: lib/cli/args.py:1140 +#: lib/cli/args.py:1136 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -952,12 +985,12 @@ msgstr "" "Отключает журнал TensorBoard. Примечание: Отключение журналов означает, что " "вы не сможете использовать графики или анализ сессии внутри GUI." -#: lib/cli/args.py:1147 lib/cli/args.py:1156 lib/cli/args.py:1165 -#: lib/cli/args.py:1174 +#: lib/cli/args.py:1143 lib/cli/args.py:1152 lib/cli/args.py:1161 +#: lib/cli/args.py:1170 msgid "augmentation" msgstr "аугментация" -#: lib/cli/args.py:1148 +#: lib/cli/args.py:1144 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -967,7 +1000,7 @@ msgstr "" "Ориентирами/Landmarks противоположного набора лиц. Этот способ используется " "пакетом \"dfaker\"." -#: lib/cli/args.py:1157 +#: lib/cli/args.py:1153 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -978,7 +1011,7 @@ msgstr "" "происходило. Как правило, эту настройку не стоит трогать, за исключением " "периода «финальной шлифовки»." -#: lib/cli/args.py:1166 +#: lib/cli/args.py:1162 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -988,7 +1021,7 @@ msgstr "" "цвета между наборами A and B ценой некоторого замедления скорости " "тренировки. Включите эту опцию для отключения цветовой аугментации." -#: lib/cli/args.py:1175 +#: lib/cli/args.py:1171 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -1001,7 +1034,7 @@ msgstr "" "Включение этой опции с самого начала может убить модель и привести к ужасным " "результатам." -#: lib/cli/args.py:1200 +#: lib/cli/args.py:1196 msgid "Output to Shell console instead of GUI console" msgstr "Вывод в системную консоль вместо GUI" diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index cd0d38e0f1..c5f38eba53 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -175,8 +175,8 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, AlignerBatch]: logger.trace("EOF received") # type:ignore exhausted = True break - # Put frames with no faces into the out queue to keep TQDM consistent - if not item.detected_faces: + # Put frames with no faces or are already aligned into the out queue + if not item.detected_faces or item.is_aligned: self._queues["out"].put(item) continue @@ -192,7 +192,8 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, AlignerBatch]: self._rollover = ExtractMedia( item.filename, item.image, - detected_faces=item.detected_faces[f_idx + 1:]) + detected_faces=item.detected_faces[f_idx + 1:], + is_aligned=item.is_aligned) logger.trace("Rolled over %s faces of %s to next batch " # type:ignore "for '%s'", len(self._rollover.detected_faces), frame_faces, item.filename) @@ -536,7 +537,7 @@ def __call__(self, faces: List[DetectedFace], minimum_dimension: int Parameters ---------- - batch: list + faces: list List of detected face objects to filter out on size minimum_dimension: int The minimum (height, width) of the original frame @@ -655,4 +656,4 @@ def output_counts(self): for key, count in self._counts.items() if count > 0] if counts: - logger.info("Aligner filtered: [%s)", ", ".join(counts)) + logger.info("Aligner filtered: (%s)", ", ".join(counts)) diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index 3c9bfcd78f..772852ef68 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -159,6 +159,10 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, DetectorBatch]: exhausted = True break assert isinstance(item, ExtractMedia) + # Put items that are already aligned into the out queue + if item.is_aligned: + self._queues["out"].put(item) + continue batch.filename.append(item.filename) image, scale, pad = self._compile_detection_image(item) batch.image.append(image) diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index f2997e1a1b..de9ab9ee07 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -61,9 +61,6 @@ class Masker(Extractor): # pylint:disable=abstract-method https://github.com/deepfakes-models/faceswap-models for more information model_filename: str The name of the model file to be loaded - image_is_aligned: bool, optional - Indicates that the passed in image is an aligned face rather than a frame. - Default: ``False`` Other Parameters ---------------- @@ -84,10 +81,8 @@ def __init__(self, model_filename: Optional[str] = None, configfile: Optional[str] = None, instance: int = 0, - image_is_aligned=False, **kwargs) -> None: - logger.debug("Initializing %s: (configfile: %s, image_is_aligned: %s)", - self.__class__.__name__, configfile, image_is_aligned) + logger.debug("Initializing %s: (configfile: %s)", self.__class__.__name__, configfile) super().__init__(git_model_id, model_filename, configfile=configfile, @@ -97,7 +92,6 @@ def __init__(self, self.coverage_ratio = 1.0 # Override for model specific coverage_ratio self._plugin_type = "mask" - self._image_is_aligned = image_is_aligned self._storage_name = self.__module__.rsplit(".", maxsplit=1)[-1].replace("_", "-") self._storage_centering: "CenteringType" = "face" # Centering to store the mask at self._storage_size = 128 # Size to store masks at. Leave this at default @@ -154,7 +148,7 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, MaskerBatch]: image = item.get_image_copy(self.color_format) roi = np.ones((*item.image_size[:2], 1), dtype="float32") - if not self._image_is_aligned: + if not item.is_aligned: # Add the ROI mask to image so we can get the ROI mask with a single warp image = np.concatenate([image, roi], axis=-1) @@ -164,10 +158,10 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, MaskerBatch]: size=self.input_size, coverage_ratio=self.coverage_ratio, dtype="float32", - is_aligned=self._image_is_aligned) + is_aligned=item.is_aligned) assert feed_face.face is not None - if not self._image_is_aligned: + if not item.is_aligned: # Split roi mask from feed face alpha channel roi_mask = feed_face.face[..., 3] feed_face._face = feed_face.face[..., :3] # pylint:disable=protected-access @@ -189,7 +183,8 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, MaskerBatch]: self._rollover = ExtractMedia( item.filename, item.image, - detected_faces=item.detected_faces[f_idx + 1:]) + detected_faces=item.detected_faces[f_idx + 1:], + is_aligned=item.is_aligned) logger.trace("Rolled over %s faces of %s to next batch " # type:ignore "for '%s'", len(self._rollover.detected_faces), frame_faces, item.filename) diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 9e120cf31b..3e4e9534a3 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -12,7 +12,7 @@ import logging import sys -from typing import Any, cast, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union +from typing import cast, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 @@ -28,6 +28,7 @@ if TYPE_CHECKING: import numpy as np + from lib.align.alignments import PNGHeaderSourceDict from lib.align.detected_face import DetectedFace from plugins.extract._base import Extractor as PluginExtractor from plugins.extract.detect._base import Detector @@ -91,9 +92,6 @@ class Extractor(): Default: `0` disable_filter: bool, optional Disable all aligner filters regardless of config option. Default: ``False`` - image_is_aligned: bool, optional - Used to set the :attr:`plugins.extract.mask.image_is_aligned` attribute. Indicates to the - masker that the fed in image is an aligned face rather than a frame. Default: ``False`` Attributes ---------- @@ -113,14 +111,13 @@ def __init__(self, min_size: int = 0, normalize_method: Optional[Literal["none", "clahe", "hist", "mean"]] = None, re_feed: int = 0, - disable_filter: bool = False, - image_is_aligned: bool = False,) -> None: + disable_filter: bool = False) -> None: logger.debug("Initializing %s: (detector: %s, aligner: %s, masker: %s, recognition: %s, " "configfile: %s, multiprocess: %s, exclude_gpus: %s, rotate_images: %s, " - "min_size: %s, normalize_method: %s, re_feed: %s, disable_filter: %s, " - "image_is_aligned: %s)", self.__class__.__name__, detector, aligner, masker, - recognition, configfile, multiprocess, exclude_gpus, rotate_images, min_size, - normalize_method, re_feed, disable_filter, image_is_aligned) + "min_size: %s, normalize_method: %s, re_feed: %s, disable_filter: %s, )", + self.__class__.__name__, detector, aligner, masker, recognition, configfile, + multiprocess, exclude_gpus, rotate_images, min_size, normalize_method, + re_feed, disable_filter) self._instance = _get_instance() maskers = [cast(Optional[str], masker)] if not isinstance(masker, list) else cast(List[Optional[str]], masker) @@ -139,7 +136,7 @@ def __init__(self, re_feed, disable_filter) self._recognition = self._load_recognition(recognition, configfile) - self._mask = [self._load_mask(mask, image_is_aligned, configfile) for mask in maskers] + self._mask = [self._load_mask(mask, configfile) for mask in maskers] self._is_parallel = self._set_parallel_processing(multiprocess) self._phases = self._set_phases(multiprocess) self._phase_index = 0 @@ -218,6 +215,18 @@ def final_pass(self) -> bool: logger.trace(retval) # type: ignore return retval + @property + def aligner(self) -> "Aligner": + """ The currently selected aligner plugin """ + assert self._align is not None + return self._align + + @property + def recognition(self) -> "Identity": + """ The currently selected recognition plugin """ + assert self._recognition is not None + return self._recognition + def reset_phase_index(self) -> None: """ Reset the current phase index back to 0. Used for when batch processing is used in extract. """ @@ -625,16 +634,27 @@ def _load_detect(self, def _load_mask(self, masker: Optional[str], - image_is_aligned: bool, configfile: Optional[str]) -> Optional["Masker"]: - """ Set global arguments and load masker plugin """ + """ Set global arguments and load masker plugin + + Parameters + ---------- + masker: str or ``none`` + The name of the masker plugin to use or ``None`` if no masker + configfile: str + Full path to custom config.ini file or ``None`` to use default + + Returns + ------- + :class:`~plugins.extract.mask._base.Masker` or ``None`` + The masker plugin to use or ``None`` if no masker selected + """ if masker is None or masker.lower() == "none": logger.debug("No masker selected. Returning None") return None masker_name = masker.replace("-", "_").lower() logger.debug("Loading Masker: '%s'", masker_name) plugin = PluginLoader.get_masker(masker_name)(exclude_gpus=self._exclude_gpus, - image_is_aligned=image_is_aligned, configfile=configfile, instance=self._instance) return plugin @@ -700,21 +720,6 @@ def _set_extractor_batchsize(self) -> None: - plugins_required) // len(gpu_plugins) self._set_plugin_batchsize(gpu_plugins, available_vram) - def set_aligner_normalization_method(self, method: Optional[Literal["none", - "clahe", - "hist", - "mean"]]) -> None: - """ Change the normalization method for faces fed into the aligner. - - Parameters - ---------- - method: {"none", "clahe", "hist", "mean"} - The normalization method to apply to faces prior to feeding into the aligner's model - """ - assert self._align is not None - logger.debug("Setting to: '%s'", method) - self._align.set_normalize_method(method) - def _set_plugin_batchsize(self, gpu_plugins: List[str], available_vram: float) -> None: """ Set the batch size for the given plugin based on given available vram. Do not update plugins which have a vram_per_batch of 0 (CPU plugins) due to @@ -779,26 +784,32 @@ class ExtractMedia(): filename: str The base name of the original frame's filename image: :class:`numpy.ndarray` - The original frame + The original frame or a faceswap aligned face image detected_faces: list, optional A list of :class:`~lib.align.DetectedFace` objects. Detected faces can be added later with :func:`add_detected_faces`. Setting ``None`` will default to an empty list. Default: ``None`` + is_aligned: bool, optional + ``True`` if the :attr:`image` is an aligned faceswap image otherwise ``False``. Used for + face filtering with vggface2. Aligned faceswap images will automatically skip detection, + alignment and masking. Default: ``False`` """ def __init__(self, filename: str, image: "np.ndarray", - detected_faces: Optional[List["DetectedFace"]] = None) -> None: + detected_faces: Optional[List["DetectedFace"]] = None, + is_aligned: bool = False) -> None: logger.trace("Initializing %s: (filename: '%s', image shape: %s, " # type: ignore - "detected_faces: %s)", self.__class__.__name__, filename, image.shape, - detected_faces) + "detected_faces: %s, is_aligned: %s)", self.__class__.__name__, filename, + image.shape, detected_faces, is_aligned) self._filename = filename self._image: Optional["np.ndarray"] = image self._image_shape = cast(Tuple[int, int, int], image.shape) self._detected_faces: List["DetectedFace"] = ([] if detected_faces is None else detected_faces) - self._frame_metadata: Dict[str, Any] = {} + self._is_aligned = is_aligned + self._frame_metadata: Optional["PNGHeaderSourceDict"] = None self._sub_folders: List[Optional[str]] = [] @property @@ -828,7 +839,12 @@ def detected_faces(self) -> List["DetectedFace"]: return self._detected_faces @property - def frame_metadata(self) -> dict: + def is_aligned(self) -> bool: + """ bool. ``True`` if :attr:`image` is an aligned faceswap image otherwise ``False`` """ + return self._is_aligned + + @property + def frame_metadata(self) -> "PNGHeaderSourceDict": """ dict: The frame metadata that has been added from an aligned image. This property should only be called after :func:`add_frame_metadata` has been called when processing an aligned face. For all other instances an assertion error will be raised. @@ -915,7 +931,7 @@ def set_image(self, image: "np.ndarray") -> None: self._filename, image.shape) self._image = image - def add_frame_metadata(self, metadata: Dict[str, Any]) -> None: + def add_frame_metadata(self, metadata: "PNGHeaderSourceDict") -> None: """ Add the source frame metadata from an aligned PNG's header data. metadata: dict @@ -923,7 +939,7 @@ def add_frame_metadata(self, metadata: Dict[str, Any]) -> None: """ logger.trace("Adding PNG Source data for '%s': %s", # type:ignore self._filename, metadata) - dims: Tuple[int, int] = metadata["source_frame_dims"] + dims = cast(Tuple[int, int], metadata["source_frame_dims"]) self._image_shape = (*dims, 3) self._frame_metadata = metadata diff --git a/plugins/extract/recognition/_base.py b/plugins/extract/recognition/_base.py index d6359151f0..a472318073 100644 --- a/plugins/extract/recognition/_base.py +++ b/plugins/extract/recognition/_base.py @@ -16,6 +16,7 @@ >>> face = self.to_detected_face(, , , ) """ import logging +import sys from dataclasses import dataclass, field from typing import Generator, List, Optional, Tuple, TYPE_CHECKING @@ -23,14 +24,20 @@ import numpy as np from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa -from lib.align import AlignedFace +from lib.align import AlignedFace, DetectedFace +from lib.image import read_image_meta from lib.utils import FaceswapError, get_backend from plugins.extract._base import BatchType, Extractor, ExtractorBatch from plugins.extract.pipeline import ExtractMedia +if sys.version_info < (3, 8): + from typing_extensions import get_args, Literal +else: + from typing import get_args, Literal + + if TYPE_CHECKING: from queue import Queue - from lib.align import DetectedFace from lib.align.aligned_face import CenteringType logger = logging.getLogger(__name__) @@ -58,9 +65,6 @@ class Identity(Extractor): # pylint:disable=abstract-method https://github.com/deepfakes-models/faceswap-models for more information model_filename: str The name of the model file to be loaded - image_is_aligned: bool, optional - Indicates that the passed in image is an aligned face rather than a frame. - Default: ``False`` Other Parameters ---------------- @@ -81,7 +85,6 @@ def __init__(self, model_filename: Optional[str] = None, configfile: Optional[str] = None, instance: int = 0, - image_is_aligned=False, **kwargs): logger.debug("Initializing %s", self.__class__.__name__) super().__init__(git_model_id, @@ -94,9 +97,27 @@ def __init__(self, self.coverage_ratio = 1.0 # Override for model specific coverage_ratio self._plugin_type = "recognition" - self._image_is_aligned = image_is_aligned + self._filter = IdentityFilter(self.config["save_filtered"]) logger.debug("Initialized _base %s", self.__class__.__name__) + def _get_detected_from_aligned(self, item: ExtractMedia) -> None: + """ Obtain detected face objects for when loading in aligned faces and a detected face + object does not exist + + Parameters + ---------- + item: :class:`~plugins.extract.pipeline.ExtractMedia` + The extract media to populate the detected face for + """ + detected_face = DetectedFace() + meta = read_image_meta(item.filename).get("itxt", {}).get("alignments") + if meta: + detected_face.from_png_meta(meta) + item.add_detected_faces([detected_face]) + self._faces_per_filename[item.filename] += 1 # Track this added face + logger.debug("Obtained detected face: (filename: %s, detected_face: %s)", + item.filename, item.detected_faces) + def get_batch(self, queue: "Queue") -> Tuple[bool, RecogBatch]: """ Get items for inputting into the recognition from the queue in batches @@ -140,9 +161,12 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, RecogBatch]: exhausted = True break # Put frames with no faces into the out queue to keep TQDM consistent - if not item.detected_faces: + if not item.is_aligned and not item.detected_faces: self._queues["out"].put(item) continue + if item.is_aligned and not item.detected_faces: + self._get_detected_from_aligned(item) + for f_idx, face in enumerate(item.detected_faces): image = item.get_image_copy(self.color_format) @@ -152,7 +176,7 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, RecogBatch]: size=self.input_size, coverage_ratio=self.coverage_ratio, dtype="float32", - is_aligned=self._image_is_aligned) + is_aligned=item.is_aligned) batch.detected_faces.append(face) batch.feed_faces.append(feed_face) @@ -164,7 +188,8 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, RecogBatch]: self._rollover = ExtractMedia( item.filename, item.image, - detected_faces=item.detected_faces[f_idx + 1:]) + detected_faces=item.detected_faces[f_idx + 1:], + is_aligned=item.is_aligned) logger.trace("Rolled over %s faces of %s to next batch " # type:ignore "for '%s'", len(self._rollover.detected_faces), frame_faces, item.filename) @@ -175,6 +200,11 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, RecogBatch]: for k, v in batch.__dict__.items()}) else: logger.trace(item) # type:ignore + + # TODO Move to end of process not beginning + if exhausted: + self._filter.output_counts() + return exhausted, batch def _predict(self, batch: BatchType) -> RecogBatch: @@ -239,15 +269,226 @@ def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: logger.trace("Item out: %s", # type: ignore {key: val.shape if isinstance(val, np.ndarray) else val for key, val in batch.__dict__.items()}) + for filename, face in zip(batch.filename, batch.detected_faces): self._output_faces.append(face) if len(self._output_faces) != self._faces_per_filename[filename]: continue output = self._extract_media.pop(filename) + self._output_faces = self._filter(self._output_faces, output.sub_folders) + output.add_detected_faces(self._output_faces) self._output_faces = [] logger.trace("Yielding: (filename: '%s', image: %s, " # type:ignore "detected_faces: %s)", output.filename, output.image_shape, len(output.detected_faces)) yield output + + def add_identity_filters(self, + filters: np.ndarray, + nfilters: np.ndarray, + threshold: float) -> None: + """ Add identity encodings to filter by identity in the recognition plugin + + Parameters + ---------- + filters: :class:`numpy.ndarray` + The array of filter embeddings to use + nfilters: :class:`numpy.ndarray` + The array of nfilter embeddings to use + threshold: float + The threshold for a positive filter match + """ + logger.debug("Adding identity filters") + self._filter.add_filters(filters, nfilters, threshold) + logger.debug("Added identity filters") + + +class IdentityFilter(): + """ Applies filters on the output of the recognition plugin + + Parameters + ---------- + save_output: bool + ``True`` if the filtered faces should be kept as they are being saved. ``False`` if they + should be deleted + """ + def __init__(self, save_output: bool) -> None: + logger.debug("Initializing %s: (save_output: %s)", self.__class__.__name__, save_output) + self._save_output = save_output + self._filter: Optional[np.ndarray] = None + self._nfilter: Optional[np.ndarray] = None + self._threshold = 0.0 + self._filter_enabled: bool = False + self._nfilter_enabled: bool = False + self._active: bool = False + self._counts = 0 + logger.debug("Initialized %s", self.__class__.__name__) + + def add_filters(self, filters: np.ndarray, nfilters: np.ndarray, threshold) -> None: + """ Add identity encodings to the filter and set whether each filter is enabled + + Parameters + ---------- + filters: :class:`numpy.ndarray` + The array of filter embeddings to use + nfilters: :class:`numpy.ndarray` + The array of nfilter embeddings to use + threshold: float + The threshold for a positive filter match + """ + logger.debug("Adding filters: %s, nfilters: %s, threshold: %s", + filters.shape, nfilters.shape, threshold) + self._filter = filters + self._nfilter = nfilters + self._threshold = threshold + self._filter_enabled = bool(np.any(self._filter)) + self._nfilter_enabled = bool(np.any(self._nfilter)) + self._active = self._filter_enabled or self._nfilter_enabled + logger.debug("filter active: %s, nfilter active: %s, all active: %s", + self._filter_enabled, self._nfilter_enabled, self._active) + + @classmethod + def _find_cosine_similiarity(cls, + source_identities: np.ndarray, + test_identity: np.ndarray) -> np.ndarray: + """ Find the cosine similarity between a source face identity and a test face identity + + Parameters + --------- + source_identities: :class:`numpy.ndarray` + The identity encoding for the source face identities + test_identity: :class:`numpy.ndarray` + The identity encoding for the face identity to test against the sources + + Returns + ------- + :class:`numpy.ndarray`: + The cosine similarity between a face identity and the source identities + """ + s_norm = np.linalg.norm(source_identities, axis=1) + i_norm = np.linalg.norm(test_identity) + retval = source_identities @ test_identity / (s_norm * i_norm) + return retval + + def _get_matches(self, + filter_type: Literal["filter", "nfilter"], + identities: np.ndarray) -> np.ndarray: + """ Obtain the average and minimum distances for each face against the source identities + to test against + + Parameters + ---------- + filter_type ["filter", "nfilter"] + The filter type to use for calculating the distance + identities: :class:`numpy.ndarray` + The identity encodings for the current face(s) being checked + + Returns + ------- + :class:`numpy.ndarray` + Boolean array. ``True`` if identity should be filtered otherwise ``False`` + """ + encodings = self._filter if filter_type == "filter" else self._nfilter + assert encodings is not None + distances = np.array([self._find_cosine_similiarity(encodings, identity) + for identity in identities]) + is_match = np.any(distances >= self._threshold, axis=-1) + # Invert for filter (set the `True` match to `False` for should filter) + retval = np.invert(is_match) if filter_type == "filter" else is_match + logger.trace("filter_type: %s, distances shape: %s, is_match: %s, ", # type: ignore + "retval: %s", filter_type, distances.shape, is_match, retval) + return retval + + def _filter_faces(self, + faces: List[DetectedFace], + sub_folders: List[Optional[str]], + should_filter: List[bool]) -> List[DetectedFace]: + """ Filter the detected faces, either removing filtered faces from the list of detected + faces or setting the output subfolder to `"_identity"` for any filtered faces if saving + output is enabled. + + Parameters + ---------- + faces: list + List of detected face objects to filter out on size + sub_folders: list + List of subfolder locations for any faces that have already been filtered when + config option `save_filtered` has been enabled. + should_filter: list + List of 'bool' corresponding to face that have not already been marked for filtering. + ``True`` indicates face should be filtered, ``False`` indicates face should be kept + + Returns + ------- + detected_faces: list + The filtered list of detected face objects, if saving filtered faces has not been + selected or the full list of detected faces + """ + retval: List[DetectedFace] = [] + self._counts += sum(should_filter) + for idx, face in enumerate(faces): + fldr = sub_folders[idx] + if fldr is not None: + # Saving to sub folder is selected and face is already filtered + # so this face was excluded from identity check + retval.append(face) + continue + to_filter = should_filter.pop(0) + if not to_filter or self._save_output: + # Keep the face if not marked as filtered or we are to output to a subfolder + retval.append(face) + if to_filter and self._save_output: + sub_folders[idx] = "_identity" + + return retval + + def __call__(self, + faces: List[DetectedFace], + sub_folders: List[Optional[str]]) -> List[DetectedFace]: + """ Call the identity filter function + + Parameters + ---------- + faces: list + List of detected face objects to filter out on size + sub_folders: list + List of subfolder locations for any faces that have already been filtered when + config option `save_filtered` has been enabled. + + Returns + ------- + detected_faces: list + The filtered list of detected face objects, if saving filtered faces has not been + selected or the full list of detected faces + """ + if not self._active: + return faces + + identities = np.array([face.identity["vggface2"] for face, fldr in zip(faces, sub_folders) + if fldr is None]) + logger.trace("face_count: %s, already_filtered: %s, identity_shape: %s", # type: ignore + len(faces), sum(x is not None for x in sub_folders), identities.shape) + + if not np.any(identities): + logger.trace("All faces already filtered: %s", sub_folders) # type: ignore + return faces + + should_filter: List[np.ndarray] = [] + for f_type in get_args(Literal["filter", "nfilter"]): + if not getattr(self, f"_{f_type}_enabled"): + continue + should_filter.append(self._get_matches(f_type, identities)) + + # If any of the filter or nfilter evaluate to 'should filter' then filter out face + final_filter: List[bool] = np.array(should_filter).max(axis=0).tolist() + logger.trace("should_filter: %s, final_filter: %s", # type: ignore + should_filter, final_filter) + return self._filter_faces(faces, sub_folders, final_filter) + + def output_counts(self): + """ Output the counts of filtered items """ + if not self._active or not self._counts: + return + logger.info("Identity filtered: (%s)", self._counts) diff --git a/scripts/extract.py b/scripts/extract.py index c30c86db9d..4d54299a9f 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -7,18 +7,22 @@ import os import sys from argparse import Namespace -from typing import List, Dict, Optional +from typing import List, Dict, Optional, Tuple, TYPE_CHECKING, Union +import numpy as np from tqdm import tqdm +from lib.align.alignments import PNGHeaderDict -from lib.image import encode_image, generate_thumbnail, ImagesLoader, ImagesSaver +from lib.image import encode_image, generate_thumbnail, ImagesLoader, ImagesSaver, read_image_meta from lib.multithreading import MultiThread from lib.utils import get_folder, _image_extensions, _video_extensions from plugins.extract.pipeline import Extractor, ExtractMedia from scripts.fsmedia import Alignments, PostProcess, finalize +if TYPE_CHECKING: + from lib.align.alignments import PNGHeaderAlignmentsDict -tqdm.monitor_interval = 0 # workaround for TqdmSynchronisationWarning +# tqdm.monitor_interval = 0 # workaround for TqdmSynchronisationWarning # TODO? logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -50,7 +54,9 @@ def __init__(self, arguments: Namespace) -> None: normalization = None if self._args.normalization == "none" else self._args.normalization maskers = ["components", "extended"] maskers += self._args.masker if self._args.masker else [] - recognition = "vgg_face2" if arguments.identity else None + recognition = ("vgg_face2" + if arguments.identity or arguments.filter or arguments.nfilter + else None) self._extractor = Extractor(self._args.detector, self._args.aligner, maskers, @@ -62,6 +68,10 @@ def __init__(self, arguments: Namespace) -> None: min_size=self._args.min_size, normalize_method=normalization, re_feed=self._args.re_feed) + self._filter = Filter(self._args.ref_threshold, + self._args.filter, + self._args.nfilter, + self._extractor) def _get_input_locations(self) -> List[str]: """ Obtain the full path to input locations. Will be a list of locations if batch mode is @@ -133,7 +143,7 @@ def _output_for_input(self, input_location: str) -> str: logger.debug("Returning output: '%s' for input: '%s'", retval, input_location) return retval - def process(self): + def process(self) -> None: """ The entry point for triggering the Extraction Process. Should only be called from :class:`lib.cli.launcher.ScriptExecutor` @@ -155,96 +165,349 @@ def process(self): self._extractor.reset_phase_index() -class _Extract(): # pylint:disable=too-few-public-methods - """ The Actual extraction process. - - This class is called by the parent :class:`Extract` process +class Filter(): + """ Obtains and holds face identity embeddings for any filter/nfilter image files + passed in from the command line. Parameters ---------- + filter_files: str, list or ``None`` + The list of filter file(s) passed in as command line arguments + nfilter_files: str, list or ``None`` + The list of nfilter file(s) passed in as command line arguments extractor: :class:`~plugins.extract.pipeline.Extractor` - The extractor pipeline for running extractions - arguments: :class:`argparse.Namespace` - The arguments to be passed to the extraction process as generated from Faceswap's command - line arguments + The extractor pipeline for obtaining face identity from images """ def __init__(self, - extractor: Extractor, - arguments: Namespace) -> None: - logger.debug("Initializing %s: (extractor: %s, args: %s)", self.__class__.__name__, - extractor, arguments) - self._args = arguments - self._output_dir = None if self._args.skip_saving_faces else get_folder( - self._args.output_dir) + threshold: float, + filter_files: Optional[Union[str, List[str]]], + nfilter_files: Optional[Union[str, List[str]]], + extractor: Extractor) -> None: + logger.debug("Initializing %s: (threshold: %s, filter_files: %s, nfilter_files: %s " + "extractor: %s)", self.__class__.__name__, threshold, filter_files, + nfilter_files, extractor) + self._threshold = threshold + self._filter_files, self._nfilter_files = self._validate_inputs(filter_files, + nfilter_files) + + if not self._filter_files and not self._nfilter_files: + logger.debug("Filter not selected. Exiting %s", self.__class__.__name__) + return - logger.info("Output Directory: %s", self._output_dir) - self._images = ImagesLoader(self._args.input_dir, fast_count=True) - self._alignments = Alignments(self._args, True, self._images.is_video) + self._embeddings: List[np.ndarray] = [np.array([]) for _ in self._filter_files] + self._nembeddings: List[np.ndarray] = [np.array([]) for _ in self._nfilter_files] self._extractor = extractor - self._existing_count = 0 - self._set_skip_list() - - self._post_process = PostProcess(arguments) - self._threads: List[MultiThread] = [] - self._verify_output = False + self._get_embeddings() + self._extractor.recognition.add_identity_filters(self.embeddings, + self.n_embeddings, + self._threshold) logger.debug("Initialized %s", self.__class__.__name__) @property - def _save_interval(self) -> Optional[int]: - """ int: The number of frames to be processed between each saving of the alignments file if - it has been provided, otherwise ``None`` """ - if hasattr(self._args, "save_interval"): - return self._args.save_interval - return None + def active(self): + """ bool: ``True`` if filter files have been passed in command line arguments. ``False`` if + no filter files have been provided """ + return bool(self._filter_files) or bool(self._nfilter_files) @property - def _skip_num(self) -> int: - """ int: Number of frames to skip if extract_every_n has been provided """ - return self._args.extract_every_n if hasattr(self._args, "extract_every_n") else 1 + def embeddings(self) -> np.ndarray: + """ :class:`numpy.ndarray`: The filter embeddings""" + if self._embeddings and all(np.any(e) for e in self._embeddings): + retval = np.concatenate(self._embeddings, axis=0) + else: + retval = np.array([]) + return retval - def _set_skip_list(self) -> None: - """ Add the skip list to the image loader + @property + def n_embeddings(self) -> np.ndarray: + """ :class:`numpy.ndarray`: The n-filter embeddings""" + if self._nembeddings and all(np.any(e) for e in self._nembeddings): + retval = np.concatenate(self._nembeddings, axis=0) + else: + retval = np.array([]) + return retval - Checks against `extract_every_n` and the existence of alignments data (can exist if - `skip_existing` or `skip_existing_faces` has been provided) and compiles a list of frame - indices that should not be processed, providing these to :class:`lib.image.ImagesLoader`. + @classmethod + def _validate_inputs(cls, + filter_files: Optional[Union[str, List[str]]], + nfilter_files: Optional[Union[str, List[str]]]) -> Tuple[List[str], + List[str]]: + """ Validates that the given filter/nfilter files exist, are image files and are unique + + Parameters + ---------- + filter_files: str, list or ``None`` + The list of filter file(s) passed in as command line arguments + nfilter_files: str, list or ``None`` + The list of nfilter file(s) passed in as command line arguments + + Returns + ------- + filter_files: list + List of full paths to filter files + nfilter_files: list + List of full paths to nfilter files """ - if self._skip_num == 1 and not self._alignments.data: - logger.debug("No frames to be skipped") + error = False + retval: List[List[str]] = [] + for files in (filter_files, nfilter_files): + filt_files = [files] if isinstance(files, str) else files + filt_files = [] if filt_files is None else filt_files + for file in filt_files: + if (not os.path.isfile(file) or + os.path.splitext(file)[-1].lower() not in _image_extensions): + logger.warning("Filter file '%s' does not exist or is not an image file", file) + error = True + retval.append(filt_files) + + filters = retval[0] + nfilters = retval[1] + f_fnames = set(os.path.basename(fname) for fname in filters) + n_fnames = set(os.path.basename(fname) for fname in nfilters) + if f_fnames.intersection(n_fnames): + error = True + logger.warning("filter and nfilter filenames should be unique. The following " + "filenames exist in both folders: %s", f_fnames.intersection(n_fnames)) + + if error: + logger.error("There was a problem processing filter files. See the above warnings for " + "details") + sys.exit(1) + logger.debug("filter_files: %s, nfilter_files: %s", retval[0], retval[1]) + + return filters, nfilters + + @classmethod + def _identity_from_extracted(cls, filename) -> Tuple[np.ndarray, bool]: + """ Test whether the given image is a faceswap extracted face and contains identity + information. If so, return the identity embedding + + Parameters + ---------- + filename: str + Full path to the image file to load + + Returns + ------- + :class:`numpy.ndarray` + The identity embeddings, if they can be obtained from the image header, otherwise an + empty array + bool + ``True`` if the image is a faceswap extracted image otherwise ``False`` + """ + if os.path.splitext(filename)[-1].lower() != ".png": + logger.info("'%s' not a png. Returning empty array", filename) + return np.array([]), False + + meta = read_image_meta(filename) + if "itxt" not in meta or "alignments" not in meta["itxt"]: + logger.debug("'%s' does not contain faceswap data. Returning empty array", filename) + return np.array([]), False + + align: "PNGHeaderAlignmentsDict" = meta["itxt"]["alignments"] + if "identity" not in align or "vggface2" not in align["identity"]: + logger.debug("'%s' does not contain identity data. Returning empty array", filename) + return np.array([]), True + + retval = np.array(align["identity"]["vggface2"]) + logger.debug("Obtained identity for '%s'. Shape: %s", filename, retval.shape) + + return retval, True + + def _process_extracted(self, item: ExtractMedia) -> None: + """ Process the output from the extraction pipeline. + + If no face has been detected, or multiple faces are detected for the inclusive filter, + embeddings and filenames are removed from the filter. + + if a single face is detected or multiple faces are detected for the exclusive filter, + embeddings are added to the relevent filter list + + Parameters + ---------- + item: :class:`plugins.extract.Pipeline.ExtracMedia` + The output from the extraction pipeline containing the identity encodings + """ + is_filter = item.filename in self._filter_files + lbl = "filter" if is_filter else "nfilter" + filelist = self._filter_files if is_filter else self._nfilter_files + embeddings = self._embeddings if is_filter else self._nembeddings + identities = np.array([face.identity["vggface2"] for face in item.detected_faces]) + idx = filelist.index(item.filename) + + if len(item.detected_faces) == 0: + logger.warning("No faces detected for %s in file '%s'. Image will not be used", + lbl, os.path.basename(item.filename)) + filelist.pop(idx) + embeddings.pop(idx) return - skip_list = [] - for idx, filename in enumerate(self._images.file_list): - if idx % self._skip_num != 0: - logger.trace("Adding image '%s' to skip list due to " # type: ignore - "extract_every_n = %s", filename, self._skip_num) - skip_list.append(idx) - # Items may be in the alignments file if skip-existing[-faces] is selected - elif os.path.basename(filename) in self._alignments.data: - self._existing_count += 1 - logger.trace("Removing image: '%s' due to previously existing", # type: ignore - filename) - skip_list.append(idx) - if self._existing_count != 0: - logger.info("Skipping %s frames due to skip_existing/skip_existing_faces.", - self._existing_count) - logger.debug("Adding skip list: %s", skip_list) - self._images.add_skip_list(skip_list) - def process(self) -> None: - """ The entry point for triggering the Extraction Process. + if len(item.detected_faces) == 1: + logger.debug("Adding identity for %s from file '%s'", lbl, item.filename) + embeddings[idx] = identities + return - Should only be called from :class:`lib.cli.launcher.ScriptExecutor` + if len(item.detected_faces) > 1 and is_filter: + logger.warning("%s faces detected for filter in '%s'. These identies will not be used", + len(item.detected_faces), os.path.basename(item.filename)) + filelist.pop(idx) + embeddings.pop(idx) + return + + if len(item.detected_faces) > 1 and not is_filter: + logger.warning("%s faces detected for nfilter in '%s'. All of these identies will be " + "used", len(item.detected_faces), os.path.basename(item.filename)) + embeddings[idx] = identities + return + + def _identity_from_extractor(self, file_list: List[str], aligned: List[str]) -> None: + """ Obtain the identity embeddings from the extraction pipeline + + Parameters + ---------- + filesile_list: list + List of full path to images to run through the extraction pipeline + aligned: list + List of full path to images that exist in attr:`filelist` that are faceswap aligned + images """ - # from lib.queue_manager import queue_manager ; queue_manager.debug_monitor(3) + logger.info("Extracting faces to obtain identity from images") + logger.debug("Files requiring full extraction: %s", + [fname for fname in file_list if fname not in aligned]) + logger.debug("Aligned files requiring identity info: %s", aligned) + + loader = PipelineLoader(file_list, self._extractor, aligned_filenames=aligned) + loader.launch() + + for phase in range(self._extractor.passes): + is_final = self._extractor.final_pass + detected_faces: Dict[str, ExtractMedia] = {} + self._extractor.launch() + desc = "Obtaining reference face Identity" + if self._extractor.passes > 1: + desc = (f"{desc } pass {phase + 1} of {self._extractor.passes}: " + f"{self._extractor.phase_text}") + for extract_media in tqdm(self._extractor.detected_faces(), + total=len(file_list), + file=sys.stdout, + desc=desc): + if is_final: + self._process_extracted(extract_media) + else: + extract_media.remove_image() + # cache extract_media for next run + detected_faces[extract_media.filename] = extract_media + + if not is_final: + logger.debug("Reloading images") + loader.reload(detected_faces) + + self._extractor.reset_phase_index() + + def _get_embeddings(self) -> None: + """ Obtain the embeddings for the given filter lists """ + needs_extraction: List[str] = [] + aligned: List[str] = [] + + for files, embed in zip((self._filter_files, self._nfilter_files), + (self._embeddings, self._nembeddings)): + for idx, file in enumerate(files): + identity, is_aligned = self._identity_from_extracted(file) + if np.any(identity): + logger.debug("Obtained identity from png header: '%s'", file) + embed[idx] = identity[None, ...] + continue + + needs_extraction.append(file) + if is_aligned: + aligned.append(file) + + if needs_extraction: + self._identity_from_extractor(needs_extraction, aligned) + + if not self._nfilter_files and not self._filter_files: + logger.error("No faces were detected from your selected identity filter files") + sys.exit(1) + + logger.debug("Filter: (filenames: %s, shape: %s), nFilter: (filenames: %s, shape: %s)", + [os.path.basename(f) for f in self._filter_files], + self.embeddings.shape, + [os.path.basename(f) for f in self._nfilter_files], + self.n_embeddings.shape) + + +class PipelineLoader(): + """ Handles loading and reloading images into the extraction pipeline. + + Parameters + ---------- + path: str or list of str + Full path to a folder of images or a video file or a list of image files + extractor: :class:`~plugins.extract.pipeline.Extractor` + The extractor pipeline for obtaining face identity from images + aligned_filenames: list, optional + Used for when the loader is used for getting face filter embeddings. List of full path to + image files that exist in :attr:`path` that are aligned faceswap images + """ + def __init__(self, + path: Union[str, List[str]], + extractor: Extractor, + aligned_filenames: Optional[List[str]] = None) -> None: + logger.debug("Initializing %s: (path: %s, extractor: %s, aligned_filenames: %s)", + self.__class__.__name__, path, extractor, aligned_filenames) + self._images = ImagesLoader(path, fast_count=True) + self._extractor = extractor + self._threads: List[MultiThread] = [] + self._aligned_filenames = [] if aligned_filenames is None else aligned_filenames + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def is_video(self) -> bool: + """ bool: ``True`` if the input location is a video file, ``False`` if it is a folder of + images """ + return self._images.is_video + + @property + def file_list(self) -> List[str]: + """ list: A full list of files in the source location. If the input is a video + then this is a list of dummy filenames as corresponding to an alignments file """ + return self._images.file_list + + @property + def process_count(self) -> int: + """ int: The number of images or video frames to be processed (IE the total count less + items that are to be skipped from the :attr:`skip_list`)""" + return self._images.process_count + + def add_skip_list(self, skip_list: List[int]) -> None: + """ Add a skip list to the :class:`ImagesLoader` + + Parameters + ---------- + skip_list: list + A list of indices corresponding to the frame indices that should be skipped by the + :func:`load` function. + """ + self._images.add_skip_list(skip_list) + + def launch(self) -> None: + """ Launch the image loading pipeline """ self._threaded_redirector("load") - self._run_extraction() + + def reload(self, detected_faces: Dict[str, ExtractMedia]) -> None: + """ Reload images for multiple pipeline passes """ + self._threaded_redirector("reload", (detected_faces, )) + + def check_thread_error(self) -> None: + """ Check if any errors have occurred in the running threads and raise their errors """ + for thread in self._threads: + thread.check_and_raise_error() + + def join(self) -> None: + """ Join all open loader threads """ for thread in self._threads: thread.join() - self._alignments.save() - finalize(self._images.process_count + self._existing_count, - self._alignments.faces_count, - self._verify_output) def _threaded_redirector(self, task: str, io_args: Optional[tuple] = None) -> None: """ Redirect image input/output tasks to relevant queues in background thread @@ -275,7 +538,8 @@ def _load(self) -> None: if load_queue.shutdown.is_set(): logger.debug("Load Queue: Stop signal received. Terminating") break - item = ExtractMedia(filename, image[..., :3]) + is_aligned = filename in self._aligned_filenames + item = ExtractMedia(filename, image[..., :3], is_aligned=is_aligned) load_queue.put(item) load_queue.put("EOF") logger.debug("Load Images: Complete") @@ -308,6 +572,97 @@ def _reload(self, detected_faces: Dict[str, ExtractMedia]) -> None: load_queue.put("EOF") logger.debug("Reload Images: Complete") + +class _Extract(): # pylint:disable=too-few-public-methods + """ The Actual extraction process. + + This class is called by the parent :class:`Extract` process + + Parameters + ---------- + extractor: :class:`~plugins.extract.pipeline.Extractor` + The extractor pipeline for running extractions + arguments: :class:`argparse.Namespace` + The arguments to be passed to the extraction process as generated from Faceswap's command + line arguments + """ + def __init__(self, + extractor: Extractor, + arguments: Namespace) -> None: + logger.debug("Initializing %s: (extractor: %s, args: %s)", self.__class__.__name__, + extractor, arguments) + self._args = arguments + self._output_dir = None if self._args.skip_saving_faces else get_folder( + self._args.output_dir) + + logger.info("Output Directory: %s", self._output_dir) + self._loader = PipelineLoader(self._args.input_dir, extractor) + + self._alignments = Alignments(self._args, True, self._loader.is_video) + self._extractor = extractor + + self._existing_count = 0 + self._set_skip_list() + + self._post_process = PostProcess(arguments) + self._verify_output = False + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def _save_interval(self) -> Optional[int]: + """ int: The number of frames to be processed between each saving of the alignments file if + it has been provided, otherwise ``None`` """ + if hasattr(self._args, "save_interval"): + return self._args.save_interval + return None + + @property + def _skip_num(self) -> int: + """ int: Number of frames to skip if extract_every_n has been provided """ + return self._args.extract_every_n if hasattr(self._args, "extract_every_n") else 1 + + def _set_skip_list(self) -> None: + """ Add the skip list to the image loader + + Checks against `extract_every_n` and the existence of alignments data (can exist if + `skip_existing` or `skip_existing_faces` has been provided) and compiles a list of frame + indices that should not be processed, providing these to :class:`lib.image.ImagesLoader`. + """ + if self._skip_num == 1 and not self._alignments.data: + logger.debug("No frames to be skipped") + return + skip_list = [] + for idx, filename in enumerate(self._loader.file_list): + if idx % self._skip_num != 0: + logger.trace("Adding image '%s' to skip list due to " # type: ignore + "extract_every_n = %s", filename, self._skip_num) + skip_list.append(idx) + # Items may be in the alignments file if skip-existing[-faces] is selected + elif os.path.basename(filename) in self._alignments.data: + self._existing_count += 1 + logger.trace("Removing image: '%s' due to previously existing", # type: ignore + filename) + skip_list.append(idx) + if self._existing_count != 0: + logger.info("Skipping %s frames due to skip_existing/skip_existing_faces.", + self._existing_count) + logger.debug("Adding skip list: %s", skip_list) + self._loader.add_skip_list(skip_list) + + def process(self) -> None: + """ The entry point for triggering the Extraction Process. + + Should only be called from :class:`lib.cli.launcher.ScriptExecutor` + """ + # from lib.queue_manager import queue_manager ; queue_manager.debug_monitor(3) + self._loader.launch() + self._run_extraction() + self._loader.join() + self._alignments.save() + finalize(self._loader.process_count + self._existing_count, + self._alignments.faces_count, + self._verify_output) + def _run_extraction(self) -> None: """ The main Faceswap Extraction process @@ -318,23 +673,19 @@ def _run_extraction(self) -> None: size = self._args.size if hasattr(self._args, "size") else 256 saver = None if self._args.skip_saving_faces else ImagesSaver(self._output_dir, as_bytes=True) - exception = False - for phase in range(self._extractor.passes): - if exception: - break is_final = self._extractor.final_pass - detected_faces = {} + detected_faces: Dict[str, ExtractMedia] = {} self._extractor.launch() - self._check_thread_error() + self._loader.check_thread_error() ph_desc = "Extraction" if self._extractor.passes == 1 else self._extractor.phase_text desc = f"Running pass {phase + 1} of {self._extractor.passes}: {ph_desc}" for idx, extract_media in enumerate(tqdm(self._extractor.detected_faces(), - total=self._images.process_count, + total=self._loader.process_count, file=sys.stdout, desc=desc, leave=False)): - self._check_thread_error() + self._loader.check_thread_error() if is_final: self._output_processing(extract_media, size) self._output_faces(saver, extract_media) @@ -347,15 +698,10 @@ def _run_extraction(self) -> None: if not is_final: logger.debug("Reloading images") - self._threaded_redirector("reload", (detected_faces, )) + self._loader.reload(detected_faces) if saver is not None: saver.close() - def _check_thread_error(self) -> None: - """ Check if any errors have occurred in the running threads and their errors """ - for thread in self._threads: - thread.check_and_raise_error() - def _output_processing(self, extract_media: ExtractMedia, size: int) -> None: """ Prepare faces for output @@ -408,14 +754,17 @@ def _output_faces(self, saver: Optional[ImagesSaver], extract_media: ExtractMedi for face_id, face in enumerate(extract_media.detected_faces): real_face_id = face_id - skip_idx output_filename = f"{filename}_{real_face_id}{extension}" - meta = dict(alignments=face.to_png_meta(), - source=dict(alignments_version=self._alignments.version, - original_filename=output_filename, - face_index=real_face_id, - source_filename=os.path.basename(extract_media.filename), - source_is_video=self._images.is_video, - source_frame_dims=extract_media.image_size)) - image = encode_image(face.aligned.face, extension, metadata=meta) + aligned = face.aligned.face + assert aligned is not None + meta: PNGHeaderDict = dict( + alignments=face.to_png_meta(), + source=dict(alignments_version=self._alignments.version, + original_filename=output_filename, + face_index=real_face_id, + source_filename=os.path.basename(extract_media.filename), + source_is_video=self._loader.is_video, + source_frame_dims=extract_media.image_size)) + image = encode_image(aligned, extension, metadata=meta) sub_folder = extract_media.sub_folders[face_id] # Binned faces shouldn't risk filename clash, so just use original id diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 34ee1b909d..02339d58dc 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -16,15 +16,9 @@ import imageio from lib.align import Alignments as AlignmentsBase, get_centered_size -from lib.face_filter import FaceFilter as FilterFunc from lib.image import count_frames, read_image from lib.utils import (camel_case_split, get_image_paths, _video_extensions) -if sys.version_info < (3, 8): - from typing_extensions import get_args, Literal -else: - from typing import get_args, Literal - if TYPE_CHECKING: from argparse import Namespace from lib.align import AlignedFace @@ -408,33 +402,6 @@ def _get_items(self) -> Dict[str, Optional[Dict[str, Union[tuple, dict]]]]: if (hasattr(self._args, 'debug_landmarks') and self._args.debug_landmarks): postprocess_items["DebugLandmarks"] = None - # Face Filter post processing - if ((hasattr(self._args, "filter") and self._args.filter is not None) or - (hasattr(self._args, "nfilter") and - self._args.nfilter is not None)): - - if hasattr(self._args, "detector"): - detector = self._args.detector.replace("-", "_").lower() - else: - detector = "cv2_dnn" - if hasattr(self._args, "aligner"): - aligner = self._args.aligner.replace("-", "_").lower() - else: - aligner = "cv2_dnn" - - face_filter = dict(detector=detector, - aligner=aligner, - multiprocess=not self._args.singleprocess) - filter_lists = {} - if hasattr(self._args, "ref_threshold"): - face_filter["ref_threshold"] = self._args.ref_threshold - for filter_type in ('filter', 'nfilter'): - filter_args = getattr(self._args, filter_type, None) - filter_args = None if not filter_args else filter_args - filter_lists[filter_type] = filter_args - face_filter["filter_lists"] = filter_lists - postprocess_items["FaceFilter"] = {"kwargs": face_filter} - logger.debug("Postprocess Items: %s", postprocess_items) return postprocess_items @@ -642,130 +609,3 @@ def process(self, extract_media: "ExtractMedia") -> None: roi = face.aligned.get_cropped_roi(face.aligned.size, self._legacy_size, "legacy") cv2.rectangle(face.aligned.face, tuple(roi[:2]), tuple(roi[2:]), (0, 0, 255), 1) self._print_stats(face.aligned) - - -class FaceFilter(PostProcessAction): - """ Filter in or out faces based on input image(s). Extract or Convert - - Parameters - ----------- - args: tuple - Unused - kwargs: dict - Keyword arguments for face filter: - - * **detector** (`str`) - The detector to use - - * **aligner** (`str`) - The aligner to use - - * **multiprocess** (`bool`) - Whether to run the extraction pipeline in single process \ - mode or not - - * **ref_threshold** (`float`) - The reference threshold for a positive match - - * **filter_lists** (`dict`) - The filter and nfilter image paths - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - logger.info("Extracting and aligning face for Face Filter...") - self._filter = self._load_face_filter(**kwargs) - logger.debug("Initialized %s", self.__class__.__name__) - - def _load_face_filter(self, - filter_lists: Dict[str, str], - ref_threshold: float, - aligner: str, - detector: str, - multiprocess: bool) -> Optional[FilterFunc]: - """ Set up and load the :class:`~lib.face_filter.FaceFilter`. - - Parameters - ---------- - filter_lists: dict - The filter and nfilter image paths - ref_threshold: float - The reference threshold for a positive match - aligner: str - The aligner to use - detector: str - The detector to use - multiprocess: bool - Whether to run the extraction pipeline in single process mode or not - - Returns - ------- - :class:`~lib.face_filter.FaceFilter` - The face filter - """ - if not any(val for val in filter_lists.values()): - return None - - facefilter = None - filter_files = [self._set_face_filter(f_type, filter_lists[f_type]) - for f_type in get_args(Literal["filter", "nfilter"])] - - if any(filters for filters in filter_files): - facefilter = FilterFunc(filter_files[0], - filter_files[1], - detector, - aligner, - multiprocess, - ref_threshold) - logger.debug("Face filter: %s", facefilter) - else: - self._valid = False - return facefilter - - @classmethod - def _set_face_filter(cls, - f_type: Literal["filter", "nfilter"], - f_args: Union[str, List[str]]) -> List[str]: - """ Check filter files exist and add the filter file paths to a list. - - Parameters - ---------- - f_type: {"filter", "nfilter"} - The type of filter to create this list for - f_args: str or list - The filter image(s) to use - - Returns - ------- - list - The confirmed existing paths to filter files to use - """ - if not f_args: - return [] - - logger.info("%s: %s", f_type.title(), f_args) - filter_files = f_args if isinstance(f_args, list) else [f_args] - filter_files = [fpath for fpath in filter_files if os.path.exists(fpath)] - if not filter_files: - logger.warning("Face %s files were requested, but no files could be found. This " - "filter will not be applied.", f_type) - logger.debug("Face Filter files: %s", filter_files) - return filter_files - - def process(self, extract_media: "ExtractMedia") -> None: - """ Filters in or out any wanted or unwanted faces based on command line arguments. - - Parameters - ---------- - extract_media: :class:`~plugins.extract.pipeline.ExtractMedia` - The :class:`~plugins.extract.pipeline.ExtractMedia` object to perform the - face filtering on. - """ - if not self._filter: - return - ret_faces = [] - for idx, detect_face in enumerate(extract_media.detected_faces): - check_item = detect_face["face"] if isinstance(detect_face, dict) else detect_face - if not self._filter.check(extract_media.image, check_item): - logger.verbose("Skipping not recognized face: (Frame: %s Face %s)", # type: ignore - extract_media.filename, idx) - continue - logger.trace("Accepting recognised face. Frame: %s. Face: %s", # type: ignore - extract_media.filename, idx) - ret_faces.append(detect_face) - extract_media.add_detected_faces(ret_faces) diff --git a/tools/manual/manual.py b/tools/manual/manual.py index b36e2ee02c..ded172fe69 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -801,7 +801,8 @@ def set_normalization_method(self, method): for plugin, aligner in self._aligners.items(): if plugin == "mask": continue - aligner.set_aligner_normalization_method(method) + logger.debug("Setting to: '%s'", method) + aligner.aligner.set_normalize_method(method) class FrameLoader(): diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 8ddce415b8..4202663b4e 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -19,7 +19,7 @@ if TYPE_CHECKING: from argparse import Namespace from lib.align.aligned_face import CenteringType - from lib.align.alignments import AlignmentFileDict + from lib.align.alignments import AlignmentFileDict, PNGHeaderDict, PNGHeaderSourceDict from lib.queue_manager import EventQueue logger = logging.getLogger(__name__) # pylint:disable=invalid-name @@ -125,9 +125,7 @@ def _get_extractor(self, exclude_gpus: List[int]) -> Optional[Extractor]: 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, - image_is_aligned=self._input_is_faces) + extractor = Extractor(None, None, self._mask_type, exclude_gpus=exclude_gpus) extractor.launch() logger.debug(extractor) return extractor @@ -172,7 +170,7 @@ def _feed_extractor(self) -> MultiThread: def _process_face(self, filename: str, image: np.ndarray, - metadata: Dict[str, Any]) -> Optional["ExtractMedia"]: + metadata: "PNGHeaderDict") -> Optional["ExtractMedia"]: """ Process a single face when masking from face images filename: str @@ -190,12 +188,12 @@ def _process_face(self, """ frame_name = metadata["source"]["source_filename"] face_index = metadata["source"]["face_index"] - alignment = self._alignments.get_faces_in_frame(frame_name) - if not alignment or face_index > len(alignment) - 1: + 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 = alignment[face_index] + alignment = alignments[face_index] self._counts["face"] += 1 if self._check_for_missing(frame_name, face_index, alignment): @@ -207,7 +205,7 @@ def _process_face(self, self._save(frame_name, face_index, detected_face) return None - media = ExtractMedia(filename, image, detected_faces=[detected_face]) + media = ExtractMedia(filename, image, detected_faces=[detected_face], is_aligned=True) media.add_frame_metadata(metadata["source"]) self._counts["update"] += 1 return media @@ -236,7 +234,7 @@ def _input_faces(self, *args: Union[tuple, Tuple["EventQueue"]]) -> None: 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["source"]: + if not metadata.get("source_frame_dims"): 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 " @@ -404,8 +402,8 @@ def _update_faces(self, extractor_output: ExtractMedia) -> None: frame_name, face_index) self._alignments.update_face(frame_name, face_index, face.to_alignment()) - metadata = dict(alignments=face.to_png_meta(), - source=extractor_output.frame_metadata) + metadata: "PNGHeaderDict" = dict(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)) From bfaa465fbd10aaef706fe7cef5f9bc95bdf102a1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 1 Nov 2022 18:05:37 +0000 Subject: [PATCH 763/981] Bugfix - Alignments tool. Use full path when updating png header --- tools/alignments/jobs_faces.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/alignments/jobs_faces.py b/tools/alignments/jobs_faces.py index f87353ff04..3a74735776 100644 --- a/tools/alignments/jobs_faces.py +++ b/tools/alignments/jobs_faces.py @@ -155,7 +155,8 @@ def _sort_alignments(self, for real_idx, (f_id, almt, f_path, f_src) in enumerate(sorted(frames[frame], key=itemgetter(0))): if real_idx != f_id: - self._update_png_header(f_path, real_idx, almt, f_src) + full_path = os.path.join(self._faces_dir, f_path) + self._update_png_header(full_path, real_idx, almt, f_src) this_file[frame]["faces"].append(almt) aln_sorted[fname] = this_file return aln_sorted From 0f5d2e887c9787bde748dccfdb8561143122e868 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 4 Nov 2022 18:43:55 +0000 Subject: [PATCH 764/981] Face-filter updates: - Allow selecting folder as well as multiple images - Lower default threshold and update helptext - bugfix: detector error when using all aligned faces - Standardize output folder name --- lib/cli/actions.py | 57 +++++++++++++++++++------ lib/cli/args.py | 18 ++++---- lib/gui/options.py | 3 ++ locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 46565 -> 46737 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 24 ++++++----- locales/lib.cli.args.pot | 10 ++--- locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 60859 -> 61041 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 22 +++++----- plugins/extract/align/_base.py | 7 +-- plugins/extract/detect/_base.py | 8 +++- plugins/extract/recognition/_base.py | 8 ++-- scripts/extract.py | 40 ++++++++++------- tools/alignments/jobs_faces.py | 3 +- 13 files changed, 126 insertions(+), 74 deletions(-) mode change 100644 => 100755 locales/es/LC_MESSAGES/lib.cli.args.mo mode change 100644 => 100755 locales/es/LC_MESSAGES/lib.cli.args.po mode change 100644 => 100755 locales/ru/LC_MESSAGES/lib.cli.args.mo mode change 100644 => 100755 locales/ru/LC_MESSAGES/lib.cli.args.po diff --git a/lib/cli/actions.py b/lib/cli/actions.py index c5599d2b04..73e5511539 100644 --- a/lib/cli/actions.py +++ b/lib/cli/actions.py @@ -113,7 +113,7 @@ class FilesFullPaths(FileFullPaths): # pylint: disable=too-few-public-methods def __init__(self, *args, filetypes=None, **kwargs): if kwargs.get("nargs", None) is None: opt = kwargs["option_strings"] - raise ValueError("nargs must be provided for FilesFullPaths: {}".format(opt)) + raise ValueError(f"nargs must be provided for FilesFullPaths: {opt}") super().__init__(*args, **kwargs) @@ -147,6 +147,37 @@ class DirOrFileFullPaths(FileFullPaths): # pylint: disable=too-few-public-metho pass # pylint: disable=unnecessary-pass +class DirOrFilesFullPaths(FileFullPaths): # pylint: disable=too-few-public-methods + """ Adds support to the GUI to launch either a file browser for selecting multiple files + or a folder browser. + + Some inputs (for example face filter) can come from a folder of images or from multiple + image file. This indicates to the GUI that it should place 2 buttons (one for a folder + browser, one for a multi-file browser) for file/folder browsing. + + The standard :class:`argparse.Action` is extended with the additional parameter + :attr:`filetypes`, indicating to the GUI that it should pop a file browser, and limit + the results to the file types listed. As well as the standard parameters, the following + parameter is required: + + Parameters + ---------- + filetypes: str + The accepted file types for this option. This is the key for the GUIs lookup table which + can be found in :class:`lib.gui.utils.FileHandler`. NB: This parameter is only used for + the file browser and not the folder browser + + Example + ------- + >>> argument_list = [] + >>> argument_list.append(dict( + >>> opts=("-f", "--input_frames"), + >>> action=DirOrFileFullPaths, + >>> filetypes="video))" + """ + pass # pylint: disable=unnecessary-pass + + class SaveFileFullPaths(FileFullPaths): """ Adds support for a Save File dialog in the GUI. @@ -207,11 +238,11 @@ class ContextFullPaths(FileFullPaths): def __init__(self, *args, filetypes=None, action_option=None, **kwargs): opt = kwargs["option_strings"] if kwargs.get("nargs", None) is not None: - raise ValueError("nargs not allowed for ContextFullPaths: {}".format(opt)) + raise ValueError(f"nargs not allowed for ContextFullPaths: {opt}") if filetypes is None: - raise ValueError("filetypes is required for ContextFullPaths: {}".format(opt)) + raise ValueError(f"filetypes is required for ContextFullPaths: {opt}") if action_option is None: - raise ValueError("action_option is required for ContextFullPaths: {}".format(opt)) + raise ValueError(f"action_option is required for ContextFullPaths: {opt}") super().__init__(*args, filetypes=filetypes, **kwargs) self.action_option = action_option @@ -252,9 +283,9 @@ class Radio(argparse.Action): # pylint: disable=too-few-public-methods def __init__(self, *args, **kwargs): opt = kwargs["option_strings"] if kwargs.get("nargs", None) is not None: - raise ValueError("nargs not allowed for Radio buttons: {}".format(opt)) + raise ValueError(f"nargs not allowed for Radio buttons: {opt}") if not kwargs.get("choices", []): - raise ValueError("Choices must be provided for Radio buttons: {}".format(opt)) + raise ValueError(f"Choices must be provided for Radio buttons: {opt}") super().__init__(*args, **kwargs) def __call__(self, parser, namespace, values, option_string=None): @@ -280,9 +311,9 @@ class MultiOption(argparse.Action): # pylint: disable=too-few-public-methods def __init__(self, *args, **kwargs): opt = kwargs["option_strings"] if not kwargs.get("nargs", []): - raise ValueError("nargs must be provided for MultiOption: {}".format(opt)) + raise ValueError(f"nargs must be provided for MultiOption: {opt}") if not kwargs.get("choices", []): - raise ValueError("Choices must be provided for MultiOption: {}".format(opt)) + raise ValueError(f"Choices must be provided for MultiOption: {opt}") super().__init__(*args, **kwargs) def __call__(self, parser, namespace, values, option_string=None): @@ -335,15 +366,15 @@ class Slider(argparse.Action): # pylint: disable=too-few-public-methods def __init__(self, *args, min_max=None, rounding=None, **kwargs): opt = kwargs["option_strings"] if kwargs.get("nargs", None) is not None: - raise ValueError("nargs not allowed for Slider: {}".format(opt)) + raise ValueError(f"nargs not allowed for Slider: {opt}") if kwargs.get("default", None) is None: - raise ValueError("A default value must be supplied for Slider: {}".format(opt)) + raise ValueError(f"A default value must be supplied for Slider: {opt}") if kwargs.get("type", None) not in (int, float): - raise ValueError("Sliders only accept int and float data types: {}".format(opt)) + raise ValueError(f"Sliders only accept int and float data types: {opt}") if min_max is None: - raise ValueError("min_max must be provided for Sliders: {}".format(opt)) + raise ValueError(f"min_max must be provided for Sliders: {opt}") if rounding is None: - raise ValueError("rounding must be provided for Sliders: {}".format(opt)) + raise ValueError(f"rounding must be provided for Sliders: {opt}") super().__init__(*args, **kwargs) self.min_max = min_max diff --git a/lib/cli/args.py b/lib/cli/args.py index fad3634f11..aa50c94ab0 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -16,8 +16,8 @@ from plugins.plugin_loader import PluginLoader -from .actions import (DirFullPaths, DirOrFileFullPaths, FileFullPaths, FilesFullPaths, MultiOption, - Radio, SaveFileFullPaths, Slider) +from .actions import (DirFullPaths, DirOrFileFullPaths, DirOrFilesFullPaths, FileFullPaths, + FilesFullPaths, MultiOption, Radio, SaveFileFullPaths, Slider) from .launcher import ScriptExecutor logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -515,7 +515,7 @@ def get_optional_arguments() -> List[Dict[str, Any]]: "diagonal of the bounding box. Set to 0 for off"))) argument_list.append(dict( opts=("-n", "--nfilter"), - action=FilesFullPaths, + action=DirOrFilesFullPaths, filetypes="image", dest="nfilter", default=None, @@ -523,11 +523,11 @@ def get_optional_arguments() -> List[Dict[str, Any]]: group=_("Face Processing"), help=_("Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " - "angles and in different conditions. Multiple images can be added space " - "separated."))) + "angles and in different conditions. A folder containing the required images " + "or multiple image files, space separated, can be selected."))) argument_list.append(dict( opts=("-f", "--filter"), - action=FilesFullPaths, + action=DirOrFilesFullPaths, filetypes="image", dest="filter", default=None, @@ -535,8 +535,8 @@ def get_optional_arguments() -> List[Dict[str, Any]]: group=_("Face Processing"), help=_("Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " - "different conditions. Multiple identities can be filtered. Multiple images " - "can be added space separated."))) + "different conditions A folder containing the required images or multiple " + "image files, space separated, can be selected."))) argument_list.append(dict( opts=("-l", "--ref_threshold"), action=Slider, @@ -544,7 +544,7 @@ def get_optional_arguments() -> List[Dict[str, Any]]: rounding=2, type=float, dest="ref_threshold", - default=0.65, + default=0.60, group=_("Face Processing"), help=_("For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter."))) diff --git a/lib/gui/options.py b/lib/gui/options.py index babf0e572d..e4eaea8e27 100644 --- a/lib/gui/options.py +++ b/lib/gui/options.py @@ -172,6 +172,7 @@ def get_sysbrowser(self, option, options, command): actions.FileFullPaths, actions.FilesFullPaths, actions.DirOrFileFullPaths, + actions.DirOrFilesFullPaths, actions.SaveFileFullPaths, actions.ContextFullPaths): return None @@ -190,6 +191,8 @@ def get_sysbrowser(self, option, options, command): retval["browser"] = ["save"] elif action == actions.DirOrFileFullPaths: retval["browser"] = ["folder", "load"] + elif action == actions.DirOrFilesFullPaths: + retval["browser"] = ["folder", "multi_load"] elif action == actions.ContextFullPaths and action_option: retval["browser"] = ["context"] retval["command"] = command diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo old mode 100644 new mode 100755 index 574e72c56d864954d89ffc8757ff7d075a54d7d3..47f408a90b997c89163cc56d3a3385779c48ee0e GIT binary patch delta 1641 zcmciB{clrM9LMobhbs?if-cNB10MDOG0tV{rdA@LfCHHW5rw+p@V33{DqXwvw%b5( z@expnU~v(N1tEk7Au@_3nITbTLSi&Vf+hsxFN%o|1dYZJW7PNM&Obqy+`Z2Cd(OS* ze9t+(_+!cTOC`see1kXpqz8-?#MRTK#gg8rlqL{=gSX*NRniaS%WI^0=$j$c;R0+% z2gl5I#%1t94K+ zGVxS;_u{#wNJsxN>3ytt#Pdrmr~kF(EROu4N2P9D^cV}oBEIO3UmEAhr}aitSV{CADj zH3n&Hz;B2@&zENVrG317k$A9(9G)na8p*FOQA(TnV6asBgNdGhMEZD)R69p1;e`j7 zt!bz}b11`V|$dH;8lwQJ`V2VMR z_*)YDuxBCC5m+RBfR{$y9+IZezo1&$L*7~}wc^+%ED#g;iu*pU$NHtxJ@_4_;n1kV z=x*RobWiN>6pisT{58^Dn2v5d8Ecs^1NUR+GHD@Bua&OS-&*GlJip%CQ3!XD@5QrN z_>8yXYuL?qfytBZ9r;w?@4hXUDt+{DtRv@AwL73;l@~q_>FwY4J|3hvTdFQU4JdJ#^Hql#b$c zH2Gjh8#z3)%5xHhc8|6#Dc!=~uz~)e4(Unasx{mf;;pz82i8izlTYiCD7$WADORrI zG;uAaGrw-oxR0|R!C&Z@NFiVLvr_zt_{A-p3PxXLC*%uXliuO|Gu_@Lt$tf-=8|sO z$w|^b{H|0@(YyPkWyFIAyjqtZ;^s5XqiEv|tYm&|IxIbnpCR{5(~d9z#<331<6O+( zc=lryudu`PV=Nkf?&n`aerUj3aQ|`dgluMWOV)i-I!FJNQ~bklw?jG5c#k`>jYcPp z*D?LHx6&QR2GoZJ9vrnF-P$`i4lf`Fp)c@WynNd3N~@S&Mso^J7Eh2Tm*@YV{bgFz z7qZKZfIEYV>{Z4z-%R@rV}>u=Icdzhe|uKG(~z0x&nYm29WBXNTZ?7JB33*ZOUA5r zvnd=m8!fXr){?XmR>U63EVRGR%605a!{#&&pjxn6& Ion^EC1<$$(h5!Hn diff --git a/locales/es/LC_MESSAGES/lib.cli.args.po b/locales/es/LC_MESSAGES/lib.cli.args.po old mode 100644 new mode 100755 index 905452a62d..13a3f5c621 --- a/locales/es/LC_MESSAGES/lib.cli.args.po +++ b/locales/es/LC_MESSAGES/lib.cli.args.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-10-31 11:51+0000\n" -"PO-Revision-Date: 2022-10-31 11:53+0000\n" +"POT-Creation-Date: 2022-11-02 10:39+0000\n" +"PO-Revision-Date: 2022-11-02 10:41+0000\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es\n" @@ -291,25 +291,27 @@ msgstr "" msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " -"angles and in different conditions. Multiple images can be added space " -"separated." +"angles and in different conditions. A folder containing the required images " +"or multiple image files, space separated, can be selected." msgstr "" "Opcionalmente, filtre a las personas que no desea extraer pasando imágenes " "de esas personas. Debe ser una pequeña variedad de imágenes en diferentes " -"ángulos y en diferentes condiciones. Se pueden agregar varias imágenes " -"separadas por espacios." +"ángulos y en diferentes condiciones. Se puede seleccionar una carpeta que " +"contenga las imágenes requeridas o múltiples archivos de imágenes, separados " +"por espacios." #: lib/cli/args.py:536 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " -"different conditions. Multiple identities can be filtered. Multiple images " -"can be added space separated." +"different conditions A folder containing the required images or multiple " +"image files, space separated, can be selected." msgstr "" "Opcionalmente, seleccione las personas que desea extraer pasando imágenes de " -"esa persona. Debe ser una pequeña variedad de imágenes en diferentes ángulos " -"y en diferentes condiciones. Se pueden filtrar múltiples identidades. Se " -"pueden agregar varias imágenes separadas por espacios." +"esa persona. Debe haber una pequeña variedad de imágenes en diferentes " +"ángulos y en diferentes condiciones. Se puede seleccionar una carpeta que " +"contenga las imágenes requeridas o múltiples archivos de imágenes, separados " +"por espacios." #: lib/cli/args.py:549 msgid "" diff --git a/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index 788ba912c8..06185012b3 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-10-31 11:51+0000\n" +"POT-Creation-Date: 2022-11-02 10:39+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -196,16 +196,16 @@ msgstr "" msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " -"angles and in different conditions. Multiple images can be added space " -"separated." +"angles and in different conditions. A folder containing the required images " +"or multiple image files, space separated, can be selected." msgstr "" #: lib/cli/args.py:536 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " -"different conditions. Multiple identities can be filtered. Multiple images " -"can be added space separated." +"different conditions A folder containing the required images or multiple " +"image files, space separated, can be selected." msgstr "" #: lib/cli/args.py:549 diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo old mode 100644 new mode 100755 index f13df69ccf0b384bd24ee021bf474d8a6d129660..b7ee44d6552b95a9f42e3aa6ab87379c3ea2cd88 GIT binary patch delta 1679 zcmcK2ZA?~m7{~D+Ac!v08DgN>5l|`-1!fHhemy94ng=O+4ZJkp@CbkNeEL74hvn*%d?nNv}mop~QoDFJ6q6hRKJ|k{-o?*-|_%$7C$SvDk^}e4M~< zh$qo$X`pl-za)-HkWS-om`(oEL{+Gk0WtSWS18oHL^>HHy|GM6q(ctXa>hspmrGNK zuct^M?!;+QEl$NUd=nSq9V?_HOvT4=J0{}^T#4h-{To<=Rm88SS4iy?zKhnkuy+;G zu;oeVC`M-Z?Za@|_dmtr$iM%zRER0hut03b8n^$m(o5Kgx8aP{QZUXLH38iXJb>|d&oEG_W zMbh{5yN*ARKT{%kh&#EDYqU=R*4z8C!qxV%-$2;}}h+{Pqy4BRd) zVS*djg^MetA^fw-ZKO!G#8?e*&hA@S_p-DeOLt1U$lJRqLcF$)U19NFse|#q?vuDR zHN7G|jDO=nT)JPno%-tKW7z~=J04;{R3pdEqdR#>dWbmoT`3du-;;Kd|Ex(mOaB#z zrLQRz%R`wqR{HC>l)z*we?uWuCLxL44s`X%qGJ&Kdt* zP5h35#6DzGx`r%Pm1q6=$^Fs~#0Ri}iObKidloR^N9jH$t{jj~;Rl1{am&x_nD*iG zTnF)-UwEjDPZ?%B-o%wjG&Ee0#^9-oymA`sOa4bWgzhW8jN|bJ&co0VX)-RtNtlN2 z4%dxXTZ0$7ujAGOFX3>pP3tH>+=UUBmsn(48I~V@jN|>_Eoyn=WwakwHay8aqZFuX@ Ip56`r0Eh+74*&oF delta 1445 zcmb7?YiyHc6vzMFr6R&4h7AG1w__VHvvplJ1IwJEGA~$+^D-51>{6gu7s@&kA*@-6 zb`B8itKgT-Kq5gy%u4ODIagvbKZqv0^@3b9FhabbGeHAhKB&Lf5kL9GCe80T|8t&m zp7)${`K#=av)Kbxj>ttx%MEFXLn?Ji4`R0#O25N*;9~T{3#3cfpB71X!_i`?9I8aB z2X1&B+yZ?Zy5JYspOi^=WJw1JK7oC{0u2s$q!#q;m5M0Fh4WR?WeRPmmBzBAr|YCj z0&b;RYmU@#uau8Hc)#Sd6Z6U#{0w%$RV$?1;R)!4&WEIXU=6H?U2rA*5Ry_eeNs1U z^hHQY;nN5Q;ogTK4(}t<2t0X>@dl{?|H?|0(nie zo%p%%3XH==_f4F7U?$hV~A<=ANn8&a9Plctwpw%fg;kS6D{qTzCCm(QR zD`U-K{?Bfs4Fsm?TP*>@U7X?72yKI@Zk#0O+aYmR{q*@L`pqv&YtTPt5ne)H+)Jym zlQ05@cS%FUE!`tAHT}F-x(n97BJG1Qm`8ohk4tsar80Pc3&A(oPL?k3fOIEz=uH+3 zCJxb7^y!D0EAjh|uz(Z_y+`{Q=JHX=!?2sj=pZvRKazf9nG0y|ObXt3Qkp@YA~=Ly z{iU>#`ud3jW6@KmxCqm*2QE7;@kkwqw!Zp|bPk?_`$>H4EQ`kj8WBrLe0-d4z$+Kf z;RhG_V*FL#NVi~je#f}DAOD{F@PSJ#v=_%Xha8yyBd^6VXJT>*J#ZR!4V((^gKpRW zZ-P-c1HK0BFFZ71Zq2Qkw}hjerjWmne+mD8e`Y3ky`#Zg>h#ho*1-%p=Qa8lBUb)NX{zWQ#rMw% Li List of bools corresponding to any of the input DetectedFace objects that passed a test. ``False`` the face passed the test. ``True`` it failed """ - retval = [False for _ in range(len(faces))] + retval = [True for _ in range(len(faces))] for idx, face in enumerate(faces): aligned = AlignedFace(landmarks=face.landmarks_xy) if self._scale_test(aligned, minimum_dimension) is not None: - retval[idx] = True continue if 0.0 < self._distance < aligned.average_distance: - retval[idx] = True continue + if not -self._roll <= aligned.pose.roll <= self._roll: + continue + retval[idx] = False return retval diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index 772852ef68..d401de1952 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -169,12 +169,18 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, DetectorBatch]: batch.scale.append(scale) batch.pad.append(pad) - if batch: + if batch.filename: logger.trace("Returning batch: %s", # type: ignore {k: len(v) if isinstance(v, (list, np.ndarray)) else v for k, v in batch.__dict__.items()}) else: logger.trace(item) # type:ignore + + if not exhausted and not batch.filename: + # This occurs when face filter is fed aligned faces. + # Need to re-run until EOF is hit + return self.get_batch(queue) + return exhausted, batch # <<< FINALIZE METHODS>>> # diff --git a/plugins/extract/recognition/_base.py b/plugins/extract/recognition/_base.py index a472318073..00ef95c8c9 100644 --- a/plugins/extract/recognition/_base.py +++ b/plugins/extract/recognition/_base.py @@ -406,8 +406,8 @@ def _filter_faces(self, sub_folders: List[Optional[str]], should_filter: List[bool]) -> List[DetectedFace]: """ Filter the detected faces, either removing filtered faces from the list of detected - faces or setting the output subfolder to `"_identity"` for any filtered faces if saving - output is enabled. + faces or setting the output subfolder to `"_identity_filt"` for any filtered faces if + saving output is enabled. Parameters ---------- @@ -440,7 +440,7 @@ def _filter_faces(self, # Keep the face if not marked as filtered or we are to output to a subfolder retval.append(face) if to_filter and self._save_output: - sub_folders[idx] = "_identity" + sub_folders[idx] = "_identity_filt" return retval @@ -491,4 +491,4 @@ def output_counts(self): """ Output the counts of filtered items """ if not self._active or not self._counts: return - logger.info("Identity filtered: (%s)", self._counts) + logger.info("Identity filtered (%s): %s", self._threshold, self._counts) diff --git a/scripts/extract.py b/scripts/extract.py index 4d54299a9f..4cc38a15b1 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -171,17 +171,17 @@ class Filter(): Parameters ---------- - filter_files: str, list or ``None`` + filter_files: list or ``None`` The list of filter file(s) passed in as command line arguments - nfilter_files: str, list or ``None`` + nfilter_files: list or ``None`` The list of nfilter file(s) passed in as command line arguments extractor: :class:`~plugins.extract.pipeline.Extractor` The extractor pipeline for obtaining face identity from images """ def __init__(self, threshold: float, - filter_files: Optional[Union[str, List[str]]], - nfilter_files: Optional[Union[str, List[str]]], + filter_files: Optional[List[str]], + nfilter_files: Optional[List[str]], extractor: Extractor) -> None: logger.debug("Initializing %s: (threshold: %s, filter_files: %s, nfilter_files: %s " "extractor: %s)", self.__class__.__name__, threshold, filter_files, @@ -230,16 +230,15 @@ def n_embeddings(self) -> np.ndarray: @classmethod def _validate_inputs(cls, - filter_files: Optional[Union[str, List[str]]], - nfilter_files: Optional[Union[str, List[str]]]) -> Tuple[List[str], - List[str]]: + filter_files: Optional[List[str]], + nfilter_files: Optional[List[str]]) -> Tuple[List[str], List[str]]: """ Validates that the given filter/nfilter files exist, are image files and are unique Parameters ---------- - filter_files: str, list or ``None`` + filter_files: list or ``None`` The list of filter file(s) passed in as command line arguments - nfilter_files: str, list or ``None`` + nfilter_files: list or ``None`` The list of nfilter file(s) passed in as command line arguments Returns @@ -251,9 +250,19 @@ def _validate_inputs(cls, """ error = False retval: List[List[str]] = [] + for files in (filter_files, nfilter_files): - filt_files = [files] if isinstance(files, str) else files - filt_files = [] if filt_files is None else filt_files + + if isinstance(files, list) and len(files) == 1 and os.path.isdir(files[0]): + # Get images from folder, if folder passed in + dirname = files[0] + files = [os.path.join(dirname, fname) + for fname in os.listdir(dirname) + if os.path.splitext(fname)[-1].lower() in _image_extensions] + logger.debug("Collected files from folder '%s': %s", dirname, + [os.path.basename(f) for f in files]) + + filt_files = [] if files is None else files for file in filt_files: if (not os.path.isfile(file) or os.path.splitext(file)[-1].lower() not in _image_extensions): @@ -297,7 +306,7 @@ def _identity_from_extracted(cls, filename) -> Tuple[np.ndarray, bool]: ``True`` if the image is a faceswap extracted image otherwise ``False`` """ if os.path.splitext(filename)[-1].lower() != ".png": - logger.info("'%s' not a png. Returning empty array", filename) + logger.debug("'%s' not a png. Returning empty array", filename) return np.array([]), False meta = read_image_meta(filename) @@ -748,12 +757,11 @@ def _output_faces(self, saver: Optional[ImagesSaver], extract_media: ExtractMedi logger.trace("Outputting faces for %s", extract_media.filename) # type: ignore final_faces = [] filename = os.path.splitext(os.path.basename(extract_media.filename))[0] - extension = ".png" skip_idx = 0 for face_id, face in enumerate(extract_media.detected_faces): real_face_id = face_id - skip_idx - output_filename = f"{filename}_{real_face_id}{extension}" + output_filename = f"{filename}_{real_face_id}.png" aligned = face.aligned.face assert aligned is not None meta: PNGHeaderDict = dict( @@ -764,11 +772,11 @@ def _output_faces(self, saver: Optional[ImagesSaver], extract_media: ExtractMedi source_filename=os.path.basename(extract_media.filename), source_is_video=self._loader.is_video, source_frame_dims=extract_media.image_size)) - image = encode_image(aligned, extension, metadata=meta) + image = encode_image(aligned, ".png", metadata=meta) sub_folder = extract_media.sub_folders[face_id] # Binned faces shouldn't risk filename clash, so just use original id - out_name = output_filename if not sub_folder else f"{filename}_{face_id}{extension}" + out_name = output_filename if not sub_folder else f"{filename}_{face_id}.png" if saver is not None: saver.save(out_name, image, sub_folder) diff --git a/tools/alignments/jobs_faces.py b/tools/alignments/jobs_faces.py index f87353ff04..3a74735776 100644 --- a/tools/alignments/jobs_faces.py +++ b/tools/alignments/jobs_faces.py @@ -155,7 +155,8 @@ def _sort_alignments(self, for real_idx, (f_id, almt, f_path, f_src) in enumerate(sorted(frames[frame], key=itemgetter(0))): if real_idx != f_id: - self._update_png_header(f_path, real_idx, almt, f_src) + full_path = os.path.join(self._faces_dir, f_path) + self._update_png_header(full_path, real_idx, almt, f_src) this_file[frame]["faces"].append(almt) aln_sorted[fname] = this_file return aln_sorted From cb8ec69789e043f15f3cf47049ab50bf9dfedfbf Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 8 Nov 2022 12:10:56 +0000 Subject: [PATCH 765/981] Aligner updates - Add filter re-feeds option - bugfix roll calculation --- plugins/extract/_config.py | 11 ++++++ plugins/extract/align/_base.py | 63 ++++++++++++++++++++++++++++------ 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/plugins/extract/_config.py b/plugins/extract/_config.py index ecb5f353ee..b8d373fc9d 100644 --- a/plugins/extract/_config.py +++ b/plugins/extract/_config.py @@ -86,6 +86,17 @@ def set_globals(self): "degrees. Aligned faces should have a roll value close to zero. Values that are a " "significant distance from 0 degrees tend to be misaligned images. These can usually " "be safely disgarded.") + self.add_item( + section=section, + title="filter_refeed", + datatype=bool, + default=True, + group="filters", + info="If enabled, and re-feed has been selected for extraction, then interim " + "alignments will be filtered prior to averaging the final landmarks. This can " + "help improve the final alignments by removing any obvious misaligns from the " + "interim results, and may also help pick up difficult alignments. If disabled, " + "then all re-feed results will be averaged.") self.add_item( section=section, title="save_filtered", diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index 3e70f54db0..9e7a99c4a4 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -377,10 +377,46 @@ def _predict(self, batch: BatchType) -> AlignerBatch: raise FaceswapError(msg) from err raise + def _get_mean_landmarks(self, landmarks: np.ndarray, masks: List[List[bool]]) -> np.ndarray: + """ Obtain the averaged landmarks from the re-fed alignments. If config option + 'filter_refeed' is enabled, then average those results which have not been filtered out + otherwise average all results + + Parameters + ---------- + landmarks: :class:`numpy.ndarray` + The batch of re-fed alignments + masks: list + List of boolean values indicating whether each re-fed alignments passed or failed + the filter test + + Returns + ------- + :class:`numpy.ndarray` + The final averaged landmarks + """ + if not self.config["filter_refeed"]: + return landmarks.mean(axis=0).astype("float32") + + mask = np.array(masks) + if any(np.all(masked) for masked in mask.T): + # hacky fix for faces which entirely failed the filter + # We just unmask one value as it is junk anyway and will be discarded on output + for idx, masked in enumerate(mask.T): + if np.all(masked): + mask[0, idx] = False + + mask = np.broadcast_to(np.reshape(mask, (*landmarks.shape[:2], 1, 1)), + landmarks.shape) + return np.ma.array(landmarks, mask=mask).mean(axis=0).data.astype("float32") + def _process_output(self, batch: BatchType) -> AlignerBatch: """ Process the output from the aligner model multiple times based on the user selected `re-feed amount` configuration option, then average the results for final prediction. + If the config option 'filter_refeed' is enabled, then mask out any returned alignments + that fail a filter test + Parameters ---------- batch : :class:`AlignerBatch` @@ -392,7 +428,8 @@ def _process_output(self, batch: BatchType) -> AlignerBatch: The batch item with :attr:`landmarks` populated """ assert isinstance(batch, AlignerBatch) - landmarks = [] + landmark_list: List[np.ndarray] = [] + masks: List[List[bool]] = [] for idx in range(self._re_feed + 1): # Create a pseudo object that only populates the data, feed and prediction slots with # the current re-feed iteration @@ -403,8 +440,14 @@ def _process_output(self, batch: BatchType) -> AlignerBatch: prediction=batch.prediction[idx], data=[batch.data[idx]]) self.process_output(subbatch) - landmarks.append(subbatch.landmarks) - batch.landmarks = np.average(landmarks, axis=0) + landmark_list.append(subbatch.landmarks) + + if self.config["filter_refeed"]: + fcs = [DetectedFace(landmarks_xy=lm) for lm in subbatch.landmarks.copy()] + min_sizes = [min(img.shape[:2]) for img in batch.image] + masks.append(self._filter.filtered_mask(fcs, min_sizes)) + + batch.landmarks = self._get_mean_landmarks(np.array(landmark_list), masks) return batch # <<< FACE NORMALIZATION METHODS >>> # @@ -574,7 +617,7 @@ def __call__(self, faces: List[DetectedFace], minimum_dimension: int sub_folders[idx] = "_align_filt_distance" continue - if not -self._roll <= aligned.pose.roll <= self._roll: + if not 0.0 < abs(aligned.pose.roll) < self._roll: self._counts["roll"] += 1 if self._save_output: retval.append(face) @@ -619,7 +662,7 @@ def _scale_test(self, return None - def filtered_mask(self, faces: List[DetectedFace], minimum_dimension: int) -> List[bool]: + def filtered_mask(self, faces: List[DetectedFace], minimum_dimension: List[int]) -> List[bool]: """ Obtain a list of boolean values for the given faces indicating whether they pass the filter test. @@ -627,8 +670,8 @@ def filtered_mask(self, faces: List[DetectedFace], minimum_dimension: int) -> Li ---------- faces: list List of detected face objects to test the filters for - minimum_dimension: int - The minimum (height, width) of the original frame + minimum_dimension: list + The minimum (height, width) of the original frames that the faces come from Returns ------- @@ -637,13 +680,13 @@ def filtered_mask(self, faces: List[DetectedFace], minimum_dimension: int) -> Li test. ``False`` the face passed the test. ``True`` it failed """ retval = [True for _ in range(len(faces))] - for idx, face in enumerate(faces): + for idx, (face, dim) in enumerate(zip(faces, minimum_dimension)): aligned = AlignedFace(landmarks=face.landmarks_xy) - if self._scale_test(aligned, minimum_dimension) is not None: + if self._scale_test(aligned, dim) is not None: continue if 0.0 < self._distance < aligned.average_distance: continue - if not -self._roll <= aligned.pose.roll <= self._roll: + if not 0.0 < abs(aligned.pose.roll) < self._roll: continue retval[idx] = False From c698f45a35e452dce6292070186aa5b08df02a6d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 8 Nov 2022 17:09:43 +0000 Subject: [PATCH 766/981] Aligner - Add feature position filter --- lib/align/aligned_face.py | 22 +++++++++++++++++++++ plugins/extract/_config.py | 9 +++++++++ plugins/extract/align/_base.py | 36 ++++++++++++++++++++++++++++------ 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index ad238cab18..5c9ff88a6f 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -234,6 +234,11 @@ class _FaceCache: # pylint:disable=too-many-instance-attributes average_distance: float, optional The average distance of the core landmarks (18-67) from the mean face that was used for aligning the image. Default: `0.0` + relative_eye_mouth_position: float, optional + A float value representing the relative position of the lowest eye/eye-brow point to the + highest mouth point. Positive values indicate that eyes/eyebrows are aligned above the + mouth, negative values indicate that eyes/eyebrows are misaligned below the mouth. + Default: `0.0` adjusted_matrix: :class:`numpy.ndarray`, optional The 3x2 transformation matrix for extracting and aligning the core face area out of the original frame with padding and sizing applied. Default: ``None`` @@ -251,6 +256,7 @@ class _FaceCache: # pylint:disable=too-many-instance-attributes landmarks: Optional[np.ndarray] = None landmarks_normalized: Optional[np.ndarray] = None average_distance: float = 0.0 + relative_eye_mouth_position: float = 0.0 adjusted_matrix: Optional[np.ndarray] = None interpolators: Tuple[int, int] = (0, 0) cropped_roi: Dict[CenteringType, np.ndarray] = field(default_factory=dict) @@ -464,6 +470,22 @@ def average_distance(self) -> float: self._cache.average_distance = average_distance return self._cache.average_distance + @property + def relative_eye_mouth_position(self) -> float: + """ float: Value representing the relative position of the lowest eye/eye-brow point to the + highest mouth point. Positive values indicate that eyes/eyebrows are aligned above the + mouth, negative values indicate that eyes/eyebrows are misaligned below the mouth. """ + with self._cache.lock("relative_eye_mouth_position"): + if not self._cache.relative_eye_mouth_position: + lowest_eyes = np.max(self.normalized_landmarks[np.r_[17:27, 36:48], 1]) + highest_mouth = np.min(self.normalized_landmarks[48:68, 1]) + position = highest_mouth - lowest_eyes + logger.trace("lowest_eyes: %s, highest_mouth: %s, " # type: ignore + "relative_eye_mouth_position: %s", lowest_eyes, highest_mouth, + position) + self._cache.relative_eye_mouth_position = position + return self._cache.relative_eye_mouth_position + @classmethod def _padding_from_coverage(cls, size: int, coverage_ratio: float) -> Dict[CenteringType, int]: """ Return the image padding for a face from coverage_ratio set against a diff --git a/plugins/extract/_config.py b/plugins/extract/_config.py index b8d373fc9d..8e5e5300c0 100644 --- a/plugins/extract/_config.py +++ b/plugins/extract/_config.py @@ -86,6 +86,15 @@ def set_globals(self): "degrees. Aligned faces should have a roll value close to zero. Values that are a " "significant distance from 0 degrees tend to be misaligned images. These can usually " "be safely disgarded.") + self.add_item( + section=section, + title="aligner_features", + datatype=bool, + default=True, + group="filters", + info="Filters out faces where the lowest point of the aligned face's eye or eyebrow " + "is lower than the highest point of the aligned face's mouth. Any faces where this " + "occurs are misaligned and can be safely disgarded.") self.add_item( section=section, title="filter_refeed", diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index 9e7a99c4a4..085111540b 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -111,7 +111,8 @@ def __init__(self, self.set_normalize_method(normalize_method) self._plugin_type = "align" - self._filter = AlignedFilter(min_scale=self.config["aligner_min_scale"], + self._filter = AlignedFilter(feature_filter=self.config["aligner_features"], + min_scale=self.config["aligner_min_scale"], max_scale=self.config["aligner_max_scale"], distance=self.config["aligner_distance"], roll=self.config["aligner_roll"], @@ -537,6 +538,9 @@ class AlignedFilter(): Parameters ---------- + feature_filter: bool + ``True`` to enable filter to check relative position of eyes/eyebrows and mouth. ``False`` + to disable. min_scale: float Filters out faces that have been aligned at below this value as a multiplier of the minimum frame dimension. Set to ``0`` for off. @@ -556,22 +560,33 @@ class AlignedFilter(): ``True`` to disable the filter regardless of config options. Default: ``False`` """ def __init__(self, + feature_filter: bool, min_scale: float, max_scale: float, distance: float, roll: float, save_output: bool, disable: bool = False) -> None: - logger.debug("Initializing %s: (min_scale: %s, max_scale: %s, distance: %s, roll, %s" - "save_output: %s, disable: %s)", self.__class__.__name__, min_scale, - max_scale, distance, roll, save_output, disable) + logger.debug("Initializing %s: (feature_filter: %s, min_scale: %s, max_scale: %s, " + "distance: %s, roll, %s, save_output: %s, disable: %s)", + self.__class__.__name__, feature_filter, min_scale, max_scale, distance, roll, + save_output, disable) + self._features = feature_filter self._min_scale = min_scale self._max_scale = max_scale self._distance = distance / 100. self._roll = roll self._save_output = save_output - self._active = not disable and (max_scale > 0.0 or min_scale > 0.0 or distance > 0.0) - self._counts: Dict[str, int] = dict(min_scale=0, max_scale=0, distance=0, roll=0) + self._active = not disable and (feature_filter or + max_scale > 0.0 or + min_scale > 0.0 or + distance > 0.0 or + roll > 0.0) + self._counts: Dict[str, int] = dict(features=0, + min_scale=0, + max_scale=0, + distance=0, + roll=0) logger.debug("Initialized %s: ", self.__class__.__name__) def __call__(self, faces: List[DetectedFace], minimum_dimension: int @@ -602,6 +617,13 @@ def __call__(self, faces: List[DetectedFace], minimum_dimension: int for idx, face in enumerate(faces): aligned = AlignedFace(landmarks=face.landmarks_xy, centering="face") + if self._features and aligned.relative_eye_mouth_position < 0.0: + self._counts["features"] += 1 + if self._save_output: + retval.append(face) + sub_folders[idx] = "_align_features" + continue + min_max = self._scale_test(aligned, minimum_dimension) if min_max in ("min", "max"): self._counts[f"{min_max}_scale"] += 1 @@ -682,6 +704,8 @@ def filtered_mask(self, faces: List[DetectedFace], minimum_dimension: List[int]) retval = [True for _ in range(len(faces))] for idx, (face, dim) in enumerate(zip(faces, minimum_dimension)): aligned = AlignedFace(landmarks=face.landmarks_xy) + if self._features and aligned.relative_eye_mouth_position < 0.0: + continue if self._scale_test(aligned, dim) is not None: continue if 0.0 < self._distance < aligned.average_distance: From 113b7d7db44525925398ba77f23e42d89479d1e0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 9 Nov 2022 12:56:23 +0000 Subject: [PATCH 767/981] Aligner filters bugfixes - Skip roll check if disabled - Correct subfolder name for features check --- plugins/extract/align/_base.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base.py index 085111540b..6b961b9705 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base.py @@ -621,7 +621,7 @@ def __call__(self, faces: List[DetectedFace], minimum_dimension: int self._counts["features"] += 1 if self._save_output: retval.append(face) - sub_folders[idx] = "_align_features" + sub_folders[idx] = "_align_filt_features" continue min_max = self._scale_test(aligned, minimum_dimension) @@ -639,7 +639,7 @@ def __call__(self, faces: List[DetectedFace], minimum_dimension: int sub_folders[idx] = "_align_filt_distance" continue - if not 0.0 < abs(aligned.pose.roll) < self._roll: + if self._roll != 0.0 and not 0.0 < abs(aligned.pose.roll) < self._roll: self._counts["roll"] += 1 if self._save_output: retval.append(face) @@ -710,7 +710,7 @@ def filtered_mask(self, faces: List[DetectedFace], minimum_dimension: List[int]) continue if 0.0 < self._distance < aligned.average_distance: continue - if not 0.0 < abs(aligned.pose.roll) < self._roll: + if self._roll != 0.0 and not 0.0 < abs(aligned.pose.roll) < self._roll: continue retval[idx] = False From e3b457693e3f9903186d8ded70528a700382af57 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 10 Nov 2022 01:41:40 +0000 Subject: [PATCH 768/981] bugfix - extract filters: spaces in folder names --- lib/cli/actions.py | 49 +++++++++++++++++++++++++++++++++------------- scripts/extract.py | 44 +++++++++++++++++++++++++++++------------ 2 files changed, 67 insertions(+), 26 deletions(-) diff --git a/lib/cli/actions.py b/lib/cli/actions.py index 73e5511539..7c03caed1c 100644 --- a/lib/cli/actions.py +++ b/lib/cli/actions.py @@ -7,6 +7,7 @@ import argparse import os +from typing import Any, List, Optional, Tuple, Union # << FILE HANDLING >> @@ -18,7 +19,7 @@ class _FullPaths(argparse.Action): # pylint: disable=too-few-public-methods called directly. It is the base class for the various different file handling methods. """ - def __call__(self, parser, namespace, values, option_string=None): + def __call__(self, parser, namespace, values, option_string=None) -> None: if isinstance(values, (list, tuple)): vals = [os.path.abspath(os.path.expanduser(val)) for val in values] else: @@ -68,7 +69,7 @@ class FileFullPaths(_FullPaths): >>> filetypes="video))" """ # pylint: disable=too-few-public-methods - def __init__(self, *args, filetypes=None, **kwargs): + def __init__(self, *args, filetypes: Optional[str] = None, **kwargs) -> None: super().__init__(*args, **kwargs) self.filetypes = filetypes @@ -110,7 +111,7 @@ class FilesFullPaths(FileFullPaths): # pylint: disable=too-few-public-methods >>> filetypes="image", >>> nargs="+")) """ - def __init__(self, *args, filetypes=None, **kwargs): + def __init__(self, *args, filetypes: Optional[str] = None, **kwargs) -> None: if kwargs.get("nargs", None) is None: opt = kwargs["option_strings"] raise ValueError(f"nargs must be provided for FilesFullPaths: {opt}") @@ -144,7 +145,6 @@ class DirOrFileFullPaths(FileFullPaths): # pylint: disable=too-few-public-metho >>> action=DirOrFileFullPaths, >>> filetypes="video))" """ - pass # pylint: disable=unnecessary-pass class DirOrFilesFullPaths(FileFullPaths): # pylint: disable=too-few-public-methods @@ -175,7 +175,20 @@ class DirOrFilesFullPaths(FileFullPaths): # pylint: disable=too-few-public-meth >>> action=DirOrFileFullPaths, >>> filetypes="video))" """ - pass # pylint: disable=unnecessary-pass + def __call__(self, parser, namespace, values, option_string=None) -> None: + """ Override :class:`_FullPaths` __call__ function. + + The input for this option can be a space separated list of files or a single folder. + Folders can have spaces in them, so we don't want to blindly expand the paths. + + We check whether the input can be resolved to a folder first before expanding. + """ + assert isinstance(values, (list, tuple)) + folder = os.path.abspath(os.path.expanduser(" ".join(values))) + if os.path.isdir(folder): + setattr(namespace, self.dest, [folder]) + else: # file list so call parent method + super().__call__(parser, namespace, values, option_string) class SaveFileFullPaths(FileFullPaths): @@ -235,7 +248,11 @@ class ContextFullPaths(FileFullPaths): >>> action_option="-a")) """ # pylint: disable=too-few-public-methods, too-many-arguments - def __init__(self, *args, filetypes=None, action_option=None, **kwargs): + def __init__(self, + *args, + filetypes: Optional[str] = None, + action_option: Optional[str] = None, + **kwargs) -> None: opt = kwargs["option_strings"] if kwargs.get("nargs", None) is not None: raise ValueError(f"nargs not allowed for ContextFullPaths: {opt}") @@ -246,7 +263,7 @@ def __init__(self, *args, filetypes=None, action_option=None, **kwargs): super().__init__(*args, filetypes=filetypes, **kwargs) self.action_option = action_option - def _get_kwargs(self): + def _get_kwargs(self) -> List[Tuple[str, Any]]: names = ["option_strings", "dest", "nargs", @@ -280,7 +297,7 @@ class Radio(argparse.Action): # pylint: disable=too-few-public-methods >>> action=Radio, >>> choices=["foo", "bar")) """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: opt = kwargs["option_strings"] if kwargs.get("nargs", None) is not None: raise ValueError(f"nargs not allowed for Radio buttons: {opt}") @@ -288,7 +305,7 @@ def __init__(self, *args, **kwargs): raise ValueError(f"Choices must be provided for Radio buttons: {opt}") super().__init__(*args, **kwargs) - def __call__(self, parser, namespace, values, option_string=None): + def __call__(self, parser, namespace, values, option_string=None) -> None: setattr(namespace, self.dest, values) @@ -308,7 +325,7 @@ class MultiOption(argparse.Action): # pylint: disable=too-few-public-methods >>> action=MultiOption, >>> choices=["foo", "bar")) """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: opt = kwargs["option_strings"] if not kwargs.get("nargs", []): raise ValueError(f"nargs must be provided for MultiOption: {opt}") @@ -316,7 +333,7 @@ def __init__(self, *args, **kwargs): raise ValueError(f"Choices must be provided for MultiOption: {opt}") super().__init__(*args, **kwargs) - def __call__(self, parser, namespace, values, option_string=None): + def __call__(self, parser, namespace, values, option_string=None) -> None: setattr(namespace, self.dest, values) @@ -363,7 +380,11 @@ class Slider(argparse.Action): # pylint: disable=too-few-public-methods >>> type=float, >>> default=5.00)) """ - def __init__(self, *args, min_max=None, rounding=None, **kwargs): + def __init__(self, + *args, + min_max: Optional[Union[Tuple[int, int], Tuple[float, float]]] = None, + rounding: Optional[int] = None, + **kwargs) -> None: opt = kwargs["option_strings"] if kwargs.get("nargs", None) is not None: raise ValueError(f"nargs not allowed for Slider: {opt}") @@ -380,7 +401,7 @@ def __init__(self, *args, min_max=None, rounding=None, **kwargs): self.min_max = min_max self.rounding = rounding - def _get_kwargs(self): + def _get_kwargs(self) -> List[Tuple[str, Any]]: names = ["option_strings", "dest", "nargs", @@ -394,5 +415,5 @@ def _get_kwargs(self): "rounding"] # Decimal places to round floats to or step interval for ints return [(name, getattr(self, name)) for name in names] - def __call__(self, parser, namespace, values, option_string=None): + def __call__(self, parser, namespace, values, option_string=None) -> None: setattr(namespace, self.dest, values) diff --git a/scripts/extract.py b/scripts/extract.py index 4cc38a15b1..283bbd15d0 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -229,7 +229,37 @@ def n_embeddings(self) -> np.ndarray: return retval @classmethod - def _validate_inputs(cls, + def _files_from_folder(cls, input_location: List[str]) -> List[str]: + """ Test whether the input location is a folder and if so, return the list of contained + image files, otherwise return the original input location + + Parameters + --------- + input_files: list + A list of full paths to individual files or to a folder location + + Returns + ------- + bool + Either the original list of files provided, or the image files that exist in the + provided folder location + """ + if not input_location or len(input_location) > 1: + return input_location + + test_folder = input_location[0] + if not os.path.isdir(test_folder): + logger.debug("'%s' is not a folder. Returning original list", test_folder) + return input_location + + retval = [os.path.join(test_folder, fname) + for fname in os.listdir(test_folder) + if os.path.splitext(fname)[-1].lower() in _image_extensions] + logger.info("Collected files from folder '%s': %s", test_folder, + [os.path.basename(f) for f in retval]) + return retval + + def _validate_inputs(self, filter_files: Optional[List[str]], nfilter_files: Optional[List[str]]) -> Tuple[List[str], List[str]]: """ Validates that the given filter/nfilter files exist, are image files and are unique @@ -252,17 +282,7 @@ def _validate_inputs(cls, retval: List[List[str]] = [] for files in (filter_files, nfilter_files): - - if isinstance(files, list) and len(files) == 1 and os.path.isdir(files[0]): - # Get images from folder, if folder passed in - dirname = files[0] - files = [os.path.join(dirname, fname) - for fname in os.listdir(dirname) - if os.path.splitext(fname)[-1].lower() in _image_extensions] - logger.debug("Collected files from folder '%s': %s", dirname, - [os.path.basename(f) for f in files]) - - filt_files = [] if files is None else files + filt_files = [] if files is None else self._files_from_folder(files) for file in filt_files: if (not os.path.isfile(file) or os.path.splitext(file)[-1].lower() not in _image_extensions): From f4c738dad42187bae2cfebe1b2b6e050edc94846 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 14 Nov 2022 01:08:30 +0000 Subject: [PATCH 769/981] Reduce LPIPS strength by factor of 10 --- lib/model/loss/feature_loss_plaid.py | 2 +- lib/model/loss/feature_loss_tf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/model/loss/feature_loss_plaid.py b/lib/model/loss/feature_loss_plaid.py index 8b148100e3..af7b4eebe8 100644 --- a/lib/model/loss/feature_loss_plaid.py +++ b/lib/model/loss/feature_loss_plaid.py @@ -378,4 +378,4 @@ def __call__(self, val = K.sum(K.concatenate(res), axis=None) retval = (val, res) if self._ret_per_layer else val - return retval + return retval / 10.0 # Reduce by factor of 10 'cos this loss is STRONG diff --git a/lib/model/loss/feature_loss_tf.py b/lib/model/loss/feature_loss_tf.py index 37bcac79d4..2601455b61 100644 --- a/lib/model/loss/feature_loss_tf.py +++ b/lib/model/loss/feature_loss_tf.py @@ -397,4 +397,4 @@ def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: val = K.sum(res, axis=axis) retval = (val, res) if self._ret_per_layer else val - return retval + return retval / 10.0 # Reduce by factor of 10 'cos this loss is STRONG From 9e2026f6feba4fc1d60e0d985cbc1ba9c44a4848 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 22 Nov 2022 00:37:29 +0000 Subject: [PATCH 770/981] Extract: Implement re-align/2nd pass - implement configurable re-align function in extract - update locales + documentation - re-factor align._base and split to separate modules - move normalization method to plugin parent - bugfix: FAN use zeros for pre-processing crop - lint AlignedFilter --- docs/full/plugins/extract.rst | 68 +- lib/cli/args.py | 9 + lib/utils.py | 2 +- locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 46737 -> 47210 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 171 +++-- locales/lib.cli.args.pot | 165 +++-- locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 61041 -> 61680 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 170 +++-- plugins/extract/_base.py | 6 +- plugins/extract/_config.py | 19 +- plugins/extract/align/_base/__init__.py | 4 + .../align/{_base.py => _base/aligner.py} | 692 ++++++++++-------- plugins/extract/align/_base/processing.py | 493 +++++++++++++ plugins/extract/align/cv2_dnn.py | 45 +- plugins/extract/align/fan.py | 33 +- plugins/extract/pipeline.py | 18 +- scripts/extract.py | 3 +- setup.cfg | 2 + 18 files changed, 1319 insertions(+), 581 deletions(-) create mode 100644 plugins/extract/align/_base/__init__.py rename plugins/extract/align/{_base.py => _base/aligner.py} (53%) create mode 100644 plugins/extract/align/_base/processing.py diff --git a/docs/full/plugins/extract.rst b/docs/full/plugins/extract.rst index aa996bfd9d..8faa9e9d3f 100755 --- a/docs/full/plugins/extract.rst +++ b/docs/full/plugins/extract.rst @@ -7,9 +7,9 @@ The Extract Package handles the various plugins available for extracting face se .. contents:: Contents :local: + pipeline module =============== - .. rubric:: Module Summary .. autosummary:: @@ -25,32 +25,44 @@ pipeline module :undoc-members: :show-inheritance: -extract plugins package -======================= - -.. contents:: Contents - :local: _base module ------------- - +============ .. automodule:: plugins.extract._base :members: :undoc-members: :show-inheritance: -align._base module ------------------- -.. automodule:: plugins.extract.align._base +align plugins package +===================== +.. contents:: Contents + :local: + +align._base.aligner module +-------------------------- +.. automodule:: plugins.extract.align._base.aligner :members: :undoc-members: :show-inheritance: -vgg\_face2\_keras module ------------------------- +align._base.processing module +----------------------------- +.. automodule:: plugins.extract.align._base.processing + :members: + :undoc-members: + :show-inheritance: + +align.cv2_dnn module +------------------- +.. automodule:: plugins.extract.align.cv2_dnn + :members: + :undoc-members: + :show-inheritance: -.. automodule:: plugins.extract.recognition.vgg_face2_keras +align.fan module +------------------- +.. automodule:: plugins.extract.align.fan :members: :undoc-members: :show-inheritance: @@ -58,13 +70,11 @@ vgg\_face2\_keras module detect plugins package ====================== - .. contents:: Contents :local: detect._base module ------------------- - .. automodule:: plugins.extract.detect._base :members: :undoc-members: @@ -72,7 +82,6 @@ detect._base module detect.mtcnn module ------------------- - .. automodule:: plugins.extract.detect.mtcnn :members: :undoc-members: @@ -81,13 +90,11 @@ detect.mtcnn module mask plugins package ==================== - .. contents:: Contents :local: mask._base module ----------------- - .. automodule:: plugins.extract.mask._base :members: :undoc-members: @@ -98,4 +105,25 @@ mask.bisenet_fp module .. automodule:: plugins.extract.mask.bisenet_fp :members: :undoc-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: + + +recognition plugins package +=========================== +.. contents:: Contents + :local: + +recognition._base module +------------------------ +.. automodule:: plugins.extract.recognition._base + :members: + :undoc-members: + :show-inheritance: + + +recognition.vgg_face2 module +---------------------------- +.. automodule:: plugins.extract.recognition.vgg_face2 + :members: + :undoc-members: + :show-inheritance: diff --git a/lib/cli/args.py b/lib/cli/args.py index aa50c94ab0..cf8d1bbe36 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -485,6 +485,15 @@ def get_optional_arguments() -> List[Dict[str, Any]]: "remove 'micro-jitter' but at the cost of slower extraction speed. The more " "times the face is re-fed into the aligner, the less micro-jitter should occur " "but the longer extraction will take."))) + argument_list.append(dict( + opts=("-a", "--re-align"), + action="store_true", + dest="re_align", + default=False, + group=_("Plugins"), + help=_("Re-feed the initially found aligned face through the aligner. Can help " + "produce better alignments for faces that are rotated beyond 45 degrees in " + "the frame or are at extreme angles. Slows down extraction."))) argument_list.append(dict( opts=("-r", "--rotate-images"), type=str, diff --git a/lib/utils.py b/lib/utils.py index 116a03f4f1..f00aa28e2b 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -34,7 +34,7 @@ _video_extensions = [ # pylint:disable=invalid-name ".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv", ".ts", ".vob"] -_TF_VERS = None +_TF_VERS: Optional[float] = None ValidBackends = Literal["amd", "nvidia", "cpu", "apple_silicon"] diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo index 47f408a90b997c89163cc56d3a3385779c48ee0e..295cd69f56a74ec6bd374a1609102f5bb7839da2 100755 GIT binary patch delta 2256 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 diff --git a/locales/es/LC_MESSAGES/lib.cli.args.po b/locales/es/LC_MESSAGES/lib.cli.args.po index 13a3f5c621..6f738b7f39 100755 --- a/locales/es/LC_MESSAGES/lib.cli.args.po +++ b/locales/es/LC_MESSAGES/lib.cli.args.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-11-02 10:39+0000\n" -"PO-Revision-Date: 2022-11-02 10:41+0000\n" +"POT-Creation-Date: 2022-11-20 01:34+0000\n" +"PO-Revision-Date: 2022-11-20 01:35+0000\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es\n" @@ -55,7 +55,7 @@ msgstr "" "almacenarlo en la carpeta pde instalación de faceswap" #: lib/cli/args.py:320 lib/cli/args.py:329 lib/cli/args.py:337 -#: lib/cli/args.py:386 lib/cli/args.py:668 lib/cli/args.py:677 +#: lib/cli/args.py:386 lib/cli/args.py:677 lib/cli/args.py:686 msgid "Data" msgstr "Datos" @@ -102,8 +102,8 @@ msgstr "" #: lib/cli/args.py:396 lib/cli/args.py:412 lib/cli/args.py:424 #: lib/cli/args.py:463 lib/cli/args.py:481 lib/cli/args.py:493 -#: lib/cli/args.py:502 lib/cli/args.py:687 lib/cli/args.py:714 -#: lib/cli/args.py:752 +#: lib/cli/args.py:502 lib/cli/args.py:511 lib/cli/args.py:696 +#: lib/cli/args.py:723 lib/cli/args.py:761 msgid "Plugins" msgstr "Extensiones" @@ -254,6 +254,17 @@ msgstr "" #: lib/cli/args.py:494 msgid "" +"Re-feed the initially found aligned face through the aligner. Can help " +"produce better alignments for faces that are rotated beyond 45 degrees in " +"the frame or are at extreme angles. Slows down extraction." +msgstr "" +"Vuelva a introducir la cara alineada encontrada inicialmente a través del " +"alineador. Puede ayudar a producir mejores alineaciones para las caras que " +"se giran más de 45 grados en el marco o se encuentran en ángulos extremos. " +"Ralentiza la extracción." + +#: lib/cli/args.py:503 +msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " "increments of that size up to 360, or pass in a list of numbers to enumerate " @@ -264,7 +275,7 @@ msgstr "" "un solo número para usar incrementos de ese tamaño hasta 360, o pase una " "lista de números para enumerar exactamente qué ángulos comprobar." -#: lib/cli/args.py:503 +#: lib/cli/args.py:512 msgid "" "Obtain and store face identity encodings from VGGFace2. Slows down extract a " "little, but will save time if using 'sort by face'" @@ -272,13 +283,13 @@ msgstr "" "Obtenga y almacene codificaciones de identidad facial de VGGFace2. Ralentiza " "un poco la extracción, pero ahorrará tiempo si usa 'sort by face'" -#: lib/cli/args.py:513 lib/cli/args.py:523 lib/cli/args.py:535 -#: lib/cli/args.py:548 lib/cli/args.py:789 lib/cli/args.py:803 -#: lib/cli/args.py:816 lib/cli/args.py:830 +#: lib/cli/args.py:522 lib/cli/args.py:532 lib/cli/args.py:544 +#: lib/cli/args.py:557 lib/cli/args.py:798 lib/cli/args.py:812 +#: lib/cli/args.py:825 lib/cli/args.py:839 msgid "Face Processing" msgstr "Proceso de Caras" -#: lib/cli/args.py:514 +#: lib/cli/args.py:523 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -287,7 +298,7 @@ msgstr "" "a lo largo de la diagonal del cuadro delimitador. Establecer a 0 para " "desactivar" -#: lib/cli/args.py:524 +#: lib/cli/args.py:533 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -300,7 +311,7 @@ msgstr "" "contenga las imágenes requeridas o múltiples archivos de imágenes, separados " "por espacios." -#: lib/cli/args.py:536 +#: lib/cli/args.py:545 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -313,7 +324,7 @@ msgstr "" "contenga las imágenes requeridas o múltiples archivos de imágenes, separados " "por espacios." -#: lib/cli/args.py:549 +#: lib/cli/args.py:558 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." @@ -321,12 +332,12 @@ msgstr "" "Para usar con los archivos nfilter/filter opcionales. Umbral para el " "reconocimiento facial positivo. Los valores más altos son más estrictos." -#: lib/cli/args.py:558 lib/cli/args.py:570 lib/cli/args.py:582 -#: lib/cli/args.py:594 +#: lib/cli/args.py:567 lib/cli/args.py:579 lib/cli/args.py:591 +#: lib/cli/args.py:603 msgid "output" msgstr "salida" -#: lib/cli/args.py:559 +#: lib/cli/args.py:568 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -336,7 +347,7 @@ msgstr "" "pretende entrenar admite el tamaño deseado. Esto sólo tendrá que ser " "cambiado para los modelos de alta resolución." -#: lib/cli/args.py:571 +#: lib/cli/args.py:580 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -346,7 +357,7 @@ msgstr "" "extraer las caras. Por ejemplo, un valor de 1 extraerá las caras de cada " "fotograma, un valor de 10 extraerá las caras de cada 10 fotogramas." -#: lib/cli/args.py:583 +#: lib/cli/args.py:592 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -362,18 +373,18 @@ msgstr "" "ADVERTENCIA: No interrumpa el script al escribir el archivo porque podría " "corromperse. Poner a 0 para desactivar" -#: lib/cli/args.py:595 +#: lib/cli/args.py:604 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" "Dibujar puntos de referencia en las caras de salida para fines de depuración." -#: lib/cli/args.py:601 lib/cli/args.py:610 lib/cli/args.py:618 -#: lib/cli/args.py:625 lib/cli/args.py:843 lib/cli/args.py:854 -#: lib/cli/args.py:862 lib/cli/args.py:881 lib/cli/args.py:887 +#: lib/cli/args.py:610 lib/cli/args.py:619 lib/cli/args.py:627 +#: lib/cli/args.py:634 lib/cli/args.py:852 lib/cli/args.py:863 +#: lib/cli/args.py:871 lib/cli/args.py:890 lib/cli/args.py:896 msgid "settings" msgstr "ajustes" -#: lib/cli/args.py:602 +#: lib/cli/args.py:611 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " @@ -383,7 +394,7 @@ msgstr "" "extracción por separado (una tras otra) en lugar de hacerlo todo al mismo " "tiempo. Útil si la VRAM es escasa." -#: lib/cli/args.py:611 +#: lib/cli/args.py:620 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -391,19 +402,19 @@ msgstr "" "Omite los fotogramas que ya han sido extraídos y que existen en el archivo " "de alineaciones" -#: lib/cli/args.py:619 +#: lib/cli/args.py:628 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" "Omitir los fotogramas que ya tienen caras detectadas en el archivo de " "alineaciones" -#: lib/cli/args.py:626 +#: lib/cli/args.py:635 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "No guardar las caras detectadas en el disco. Crear sólo un archivo de " "alineaciones" -#: lib/cli/args.py:648 +#: lib/cli/args.py:657 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -413,7 +424,7 @@ msgstr "" "Los plugins de conversión pueden ser configurados en el menú " "\"Configuración\"" -#: lib/cli/args.py:669 +#: lib/cli/args.py:678 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -423,7 +434,7 @@ msgstr "" "original del que se extrajeron los fotogramas de origen (para extraer los " "fps y el audio)." -#: lib/cli/args.py:678 +#: lib/cli/args.py:687 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -431,7 +442,7 @@ msgstr "" "Directorio del modelo. El directorio que contiene el modelo entrenado que " "desea utilizar para la conversión." -#: lib/cli/args.py:688 +#: lib/cli/args.py:697 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -471,7 +482,7 @@ msgstr "" "colores. Generalmente no da resultados muy satisfactorios.\n" "L|none: No realice el ajuste de color." -#: lib/cli/args.py:715 +#: lib/cli/args.py:724 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -547,7 +558,7 @@ msgstr "" "L|predicted: Si la opción 'Learn Mask' se habilitó durante el entrenamiento, " "esto usará la máscara que fue creada por el modelo entrenado." -#: lib/cli/args.py:753 +#: lib/cli/args.py:762 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -573,11 +584,11 @@ msgstr "" "L|pillow: [images] Más lento que opencv, pero tiene más opciones y soporta " "más formatos." -#: lib/cli/args.py:772 lib/cli/args.py:779 lib/cli/args.py:873 +#: lib/cli/args.py:781 lib/cli/args.py:788 lib/cli/args.py:882 msgid "Frame Processing" msgstr "Proceso de fotogramas" -#: lib/cli/args.py:773 +#: lib/cli/args.py:782 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -587,7 +598,7 @@ msgstr "" "a los fotogramas a las dimensiones de origen. 50%% a la mitad de tamaño. " "200%% al doble de tamaño" -#: lib/cli/args.py:780 +#: lib/cli/args.py:789 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -601,7 +612,7 @@ msgstr "" "imágenes, ¡los nombres de los archivos deben terminar con el número de " "fotograma!" -#: lib/cli/args.py:790 +#: lib/cli/args.py:799 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -617,7 +628,7 @@ msgstr "" "especificada. Si se deja en blanco, se convertirán todas las caras que " "existan en el archivo de alineaciones." -#: lib/cli/args.py:804 +#: lib/cli/args.py:813 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -631,7 +642,7 @@ msgstr "" "uso del filtro de caras disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args.py:817 +#: lib/cli/args.py:826 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -645,7 +656,7 @@ msgstr "" "del filtro facial disminuirá significativamente la velocidad de extracción y " "no se puede garantizar su precisión." -#: lib/cli/args.py:831 +#: lib/cli/args.py:840 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -657,7 +668,7 @@ msgstr "" "NB: El uso del filtro facial disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args.py:844 +#: lib/cli/args.py:853 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -674,7 +685,7 @@ msgstr "" "procesos que los disponibles en su sistema. Si 'singleprocess' está " "habilitado, este ajuste será ignorado." -#: lib/cli/args.py:855 +#: lib/cli/args.py:864 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -682,7 +693,7 @@ msgstr "" "[LEGACY] Sólo es necesario seleccionar esta opción si se está cargando un " "modelo heredado si hay varios modelos en la carpeta de modelos" -#: lib/cli/args.py:863 +#: lib/cli/args.py:872 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -697,7 +708,7 @@ msgstr "" "de baja calidad. Si se encuentra un archivo de alineaciones, esta opción " "será ignorada." -#: lib/cli/args.py:874 +#: lib/cli/args.py:883 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -705,16 +716,16 @@ msgstr "" "Cuando se usa con --frame-ranges, la salida incluye los fotogramas no " "procesados en vez de descartarlos." -#: lib/cli/args.py:882 +#: lib/cli/args.py:891 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Intercambiar el modelo. En vez de convertir de A a B, convierte de B a A" -#: lib/cli/args.py:888 +#: lib/cli/args.py:897 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Desactiva el multiproceso. Es más lento, pero usa menos recursos." -#: lib/cli/args.py:904 +#: lib/cli/args.py:913 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -726,11 +737,11 @@ msgstr "" "hasta más de una semana.\n" "Los plugins de los modelos pueden configurarse en el menú \"Ajustes\"" -#: lib/cli/args.py:923 lib/cli/args.py:932 +#: lib/cli/args.py:932 lib/cli/args.py:941 msgid "faces" msgstr "caras" -#: lib/cli/args.py:924 +#: lib/cli/args.py:933 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -740,7 +751,7 @@ msgstr "" "para la cara A. Esta es la cara original, es decir, la cara que se quiere " "eliminar y sustituir por la cara B." -#: lib/cli/args.py:933 +#: lib/cli/args.py:942 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -750,12 +761,12 @@ msgstr "" "para la cara B. Esta es la cara de intercambio, es decir, la cara que se " "quiere colocar en la cabeza de la persona A." -#: lib/cli/args.py:941 lib/cli/args.py:953 lib/cli/args.py:969 -#: lib/cli/args.py:994 lib/cli/args.py:1004 +#: lib/cli/args.py:950 lib/cli/args.py:962 lib/cli/args.py:978 +#: lib/cli/args.py:1003 lib/cli/args.py:1013 msgid "model" msgstr "modelo" -#: lib/cli/args.py:942 +#: lib/cli/args.py:951 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -769,7 +780,7 @@ msgstr "" "carpeta que no exista (que se creará). Si continúa entrenando un modelo " "existente, especifique la ubicación del modelo existente." -#: lib/cli/args.py:954 +#: lib/cli/args.py:963 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -793,7 +804,7 @@ msgstr "" "NB: Los pesos solo se pueden cargar desde modelos del mismo complemento que " "desea entrenar." -#: lib/cli/args.py:970 +#: lib/cli/args.py:979 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -838,7 +849,7 @@ msgstr "" "recursos (se necesita una GPU con una buena cantidad de VRAM). Bueno para " "los detalles, pero más susceptible a las diferencias de color." -#: lib/cli/args.py:995 +#: lib/cli/args.py:1004 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -850,7 +861,7 @@ msgstr "" "muestra un resumen del modelo que crearía el complemento elegido y los " "ajustes de configuración." -#: lib/cli/args.py:1005 +#: lib/cli/args.py:1014 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -864,12 +875,12 @@ msgstr "" "congelará el codificador, pero algunos modelos pueden tener opciones de " "configuración para congelar otras capas." -#: lib/cli/args.py:1018 lib/cli/args.py:1030 lib/cli/args.py:1041 -#: lib/cli/args.py:1052 lib/cli/args.py:1135 +#: lib/cli/args.py:1027 lib/cli/args.py:1039 lib/cli/args.py:1050 +#: lib/cli/args.py:1061 lib/cli/args.py:1144 msgid "training" msgstr "entrenamiento" -#: lib/cli/args.py:1019 +#: lib/cli/args.py:1028 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -882,7 +893,7 @@ msgstr "" "momento es el doble del número que se establece aquí. Los lotes más grandes " "requieren más RAM de la GPU." -#: lib/cli/args.py:1031 +#: lib/cli/args.py:1040 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -897,7 +908,7 @@ msgstr "" "automáticamente en un número determinado de iteraciones, puede establecer " "ese valor aquí." -#: lib/cli/args.py:1042 +#: lib/cli/args.py:1051 msgid "" "[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " "Mirrored Distrubution Strategy to train on multiple GPUs." @@ -905,7 +916,7 @@ msgstr "" "[Obsoleto: use '-D, --distribution-strategy' en su lugar] Use la estrategia " "de distribución duplicada de Tensorflow para entrenar en varias GPU." -#: lib/cli/args.py:1053 +#: lib/cli/args.py:1062 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -931,15 +942,15 @@ msgstr "" "locales. Se carga una copia del modelo y todas las variables en cada GPU con " "lotes distribuidos a cada GPU en cada iteración." -#: lib/cli/args.py:1070 lib/cli/args.py:1080 +#: lib/cli/args.py:1079 lib/cli/args.py:1089 msgid "Saving" msgstr "Guardar" -#: lib/cli/args.py:1071 +#: lib/cli/args.py:1080 msgid "Sets the number of iterations between each model save." msgstr "Establece el número de iteraciones entre cada guardado del modelo." -#: lib/cli/args.py:1081 +#: lib/cli/args.py:1090 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -947,11 +958,11 @@ msgstr "" "Establece el número de iteraciones antes de guardar una copia de seguridad " "del modelo en su estado actual. Establece 0 para que esté desactivado." -#: lib/cli/args.py:1088 lib/cli/args.py:1099 lib/cli/args.py:1110 +#: lib/cli/args.py:1097 lib/cli/args.py:1108 lib/cli/args.py:1119 msgid "timelapse" msgstr "intervalo" -#: lib/cli/args.py:1089 +#: lib/cli/args.py:1098 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -965,7 +976,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-B." -#: lib/cli/args.py:1100 +#: lib/cli/args.py:1109 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -979,7 +990,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-A." -#: lib/cli/args.py:1111 +#: lib/cli/args.py:1120 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -991,17 +1002,17 @@ msgstr "" "Si se suministran las carpetas de entrada pero no la carpeta de salida, se " "guardará por defecto en la carpeta del modelo /timelapse/" -#: lib/cli/args.py:1120 lib/cli/args.py:1127 +#: lib/cli/args.py:1129 lib/cli/args.py:1136 msgid "preview" msgstr "previsualización" -#: lib/cli/args.py:1121 +#: lib/cli/args.py:1130 msgid "Show training preview output. in a separate window." msgstr "" "Mostrar la salida de la vista previa del entrenamiento. en una ventana " "separada." -#: lib/cli/args.py:1128 +#: lib/cli/args.py:1137 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -1009,7 +1020,7 @@ msgstr "" "Escribe el resultado del entrenamiento en un archivo. La imagen se " "almacenará en la raíz de su carpeta FaceSwap." -#: lib/cli/args.py:1136 +#: lib/cli/args.py:1145 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -1017,12 +1028,12 @@ msgstr "" "Desactiva el registro de TensorBoard. NB: Desactivar los registros significa " "que no podrá utilizar el gráfico o el análisis de esta sesión en la GUI." -#: lib/cli/args.py:1143 lib/cli/args.py:1152 lib/cli/args.py:1161 -#: lib/cli/args.py:1170 +#: lib/cli/args.py:1152 lib/cli/args.py:1161 lib/cli/args.py:1170 +#: lib/cli/args.py:1179 msgid "augmentation" msgstr "aumento" -#: lib/cli/args.py:1144 +#: lib/cli/args.py:1153 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -1032,7 +1043,7 @@ msgstr "" "conjunto de caras opuestas en lugar de deformar la cara al azar. Esta es la " "forma 'dfaker' de hacer la deformación." -#: lib/cli/args.py:1153 +#: lib/cli/args.py:1162 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -1043,7 +1054,7 @@ msgstr "" "general, esto debería dejarse sin efecto, excepto durante el 'entrenamiento " "de ajuste'." -#: lib/cli/args.py:1162 +#: lib/cli/args.py:1171 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -1053,7 +1064,7 @@ msgstr "" "diferencias de color entre los conjuntos A y B, con un mayor coste de tiempo " "de entrenamiento. Activa esta opción para desactivar el aumento de color." -#: lib/cli/args.py:1171 +#: lib/cli/args.py:1180 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -1066,7 +1077,7 @@ msgstr "" "esta opción desde el principio, es probable que arruine el modelo y se " "obtengan resultados terribles." -#: lib/cli/args.py:1196 +#: lib/cli/args.py:1205 msgid "Output to Shell console instead of GUI console" msgstr "Salida a la consola Shell en lugar de la consola GUI" diff --git a/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index 06185012b3..7045caeb07 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-11-02 10:39+0000\n" +"POT-Creation-Date: 2022-11-20 01:34+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -46,7 +46,7 @@ msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "" #: lib/cli/args.py:320 lib/cli/args.py:329 lib/cli/args.py:337 -#: lib/cli/args.py:386 lib/cli/args.py:668 lib/cli/args.py:677 +#: lib/cli/args.py:386 lib/cli/args.py:677 lib/cli/args.py:686 msgid "Data" msgstr "" @@ -82,8 +82,8 @@ msgstr "" #: lib/cli/args.py:396 lib/cli/args.py:412 lib/cli/args.py:424 #: lib/cli/args.py:463 lib/cli/args.py:481 lib/cli/args.py:493 -#: lib/cli/args.py:502 lib/cli/args.py:687 lib/cli/args.py:714 -#: lib/cli/args.py:752 +#: lib/cli/args.py:502 lib/cli/args.py:511 lib/cli/args.py:696 +#: lib/cli/args.py:723 lib/cli/args.py:761 msgid "Plugins" msgstr "" @@ -168,31 +168,38 @@ msgstr "" #: lib/cli/args.py:494 msgid "" +"Re-feed the initially found aligned face through the aligner. Can help " +"produce better alignments for faces that are rotated beyond 45 degrees in " +"the frame or are at extreme angles. Slows down extraction." +msgstr "" + +#: lib/cli/args.py:503 +msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " "increments of that size up to 360, or pass in a list of numbers to enumerate " "exactly what angles to check." msgstr "" -#: lib/cli/args.py:503 +#: lib/cli/args.py:512 msgid "" "Obtain and store face identity encodings from VGGFace2. Slows down extract a " "little, but will save time if using 'sort by face'" msgstr "" -#: lib/cli/args.py:513 lib/cli/args.py:523 lib/cli/args.py:535 -#: lib/cli/args.py:548 lib/cli/args.py:789 lib/cli/args.py:803 -#: lib/cli/args.py:816 lib/cli/args.py:830 +#: lib/cli/args.py:522 lib/cli/args.py:532 lib/cli/args.py:544 +#: lib/cli/args.py:557 lib/cli/args.py:798 lib/cli/args.py:812 +#: lib/cli/args.py:825 lib/cli/args.py:839 msgid "Face Processing" msgstr "" -#: lib/cli/args.py:514 +#: lib/cli/args.py:523 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" msgstr "" -#: lib/cli/args.py:524 +#: lib/cli/args.py:533 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -200,7 +207,7 @@ msgid "" "or multiple image files, space separated, can be selected." msgstr "" -#: lib/cli/args.py:536 +#: lib/cli/args.py:545 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -208,32 +215,32 @@ msgid "" "image files, space separated, can be selected." msgstr "" -#: lib/cli/args.py:549 +#: lib/cli/args.py:558 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." msgstr "" -#: lib/cli/args.py:558 lib/cli/args.py:570 lib/cli/args.py:582 -#: lib/cli/args.py:594 +#: lib/cli/args.py:567 lib/cli/args.py:579 lib/cli/args.py:591 +#: lib/cli/args.py:603 msgid "output" msgstr "" -#: lib/cli/args.py:559 +#: lib/cli/args.py:568 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" "res models." msgstr "" -#: lib/cli/args.py:571 +#: lib/cli/args.py:580 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " "of 10 will extract faces from every 10th frame." msgstr "" -#: lib/cli/args.py:583 +#: lib/cli/args.py:592 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -243,57 +250,57 @@ msgid "" "turn off" msgstr "" -#: lib/cli/args.py:595 +#: lib/cli/args.py:604 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" -#: lib/cli/args.py:601 lib/cli/args.py:610 lib/cli/args.py:618 -#: lib/cli/args.py:625 lib/cli/args.py:843 lib/cli/args.py:854 -#: lib/cli/args.py:862 lib/cli/args.py:881 lib/cli/args.py:887 +#: lib/cli/args.py:610 lib/cli/args.py:619 lib/cli/args.py:627 +#: lib/cli/args.py:634 lib/cli/args.py:852 lib/cli/args.py:863 +#: lib/cli/args.py:871 lib/cli/args.py:890 lib/cli/args.py:896 msgid "settings" msgstr "" -#: lib/cli/args.py:602 +#: lib/cli/args.py:611 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " "Useful if VRAM is at a premium." msgstr "" -#: lib/cli/args.py:611 +#: lib/cli/args.py:620 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" msgstr "" -#: lib/cli/args.py:619 +#: lib/cli/args.py:628 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" -#: lib/cli/args.py:626 +#: lib/cli/args.py:635 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" -#: lib/cli/args.py:648 +#: lib/cli/args.py:657 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:669 +#: lib/cli/args.py:678 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " "audio)." msgstr "" -#: lib/cli/args.py:678 +#: lib/cli/args.py:687 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." msgstr "" -#: lib/cli/args.py:688 +#: lib/cli/args.py:697 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -314,7 +321,7 @@ msgid "" "L|none: Don't perform color adjustment." msgstr "" -#: lib/cli/args.py:715 +#: lib/cli/args.py:724 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -351,7 +358,7 @@ msgid "" "will use the mask that was created by the trained model." msgstr "" -#: lib/cli/args.py:753 +#: lib/cli/args.py:762 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -366,18 +373,18 @@ msgid "" "more formats." msgstr "" -#: lib/cli/args.py:772 lib/cli/args.py:779 lib/cli/args.py:873 +#: lib/cli/args.py:781 lib/cli/args.py:788 lib/cli/args.py:882 msgid "Frame Processing" msgstr "" -#: lib/cli/args.py:773 +#: lib/cli/args.py:782 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " "at source dimensions. 50%% at half size 200%% at double size" msgstr "" -#: lib/cli/args.py:780 +#: lib/cli/args.py:789 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -385,7 +392,7 @@ msgid "" "converting from images, then the filenames must end with the frame-number!" msgstr "" -#: lib/cli/args.py:790 +#: lib/cli/args.py:799 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -395,7 +402,7 @@ msgid "" "alignments file." msgstr "" -#: lib/cli/args.py:804 +#: lib/cli/args.py:813 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -404,7 +411,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args.py:817 +#: lib/cli/args.py:826 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -413,7 +420,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args.py:831 +#: lib/cli/args.py:840 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -421,7 +428,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args.py:844 +#: lib/cli/args.py:853 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -431,13 +438,13 @@ msgid "" "your system. If singleprocess is enabled this setting will be ignored." msgstr "" -#: lib/cli/args.py:855 +#: lib/cli/args.py:864 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" msgstr "" -#: lib/cli/args.py:863 +#: lib/cli/args.py:872 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -446,51 +453,51 @@ msgid "" "alignments file is found, this option will be ignored." msgstr "" -#: lib/cli/args.py:874 +#: lib/cli/args.py:883 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." msgstr "" -#: lib/cli/args.py:882 +#: lib/cli/args.py:891 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" -#: lib/cli/args.py:888 +#: lib/cli/args.py:897 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "" -#: lib/cli/args.py:904 +#: lib/cli/args.py:913 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" "Model plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:923 lib/cli/args.py:932 +#: lib/cli/args.py:932 lib/cli/args.py:941 msgid "faces" msgstr "" -#: lib/cli/args.py:924 +#: lib/cli/args.py:933 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " "face B." msgstr "" -#: lib/cli/args.py:933 +#: lib/cli/args.py:942 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " "A." msgstr "" -#: lib/cli/args.py:941 lib/cli/args.py:953 lib/cli/args.py:969 -#: lib/cli/args.py:994 lib/cli/args.py:1004 +#: lib/cli/args.py:950 lib/cli/args.py:962 lib/cli/args.py:978 +#: lib/cli/args.py:1003 lib/cli/args.py:1013 msgid "model" msgstr "" -#: lib/cli/args.py:942 +#: lib/cli/args.py:951 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -499,7 +506,7 @@ msgid "" "the existing model." msgstr "" -#: lib/cli/args.py:954 +#: lib/cli/args.py:963 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -513,7 +520,7 @@ msgid "" "to train." msgstr "" -#: lib/cli/args.py:970 +#: lib/cli/args.py:979 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -536,7 +543,7 @@ msgid "" "susceptible to color differences." msgstr "" -#: lib/cli/args.py:995 +#: lib/cli/args.py:1004 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -544,7 +551,7 @@ msgid "" "displayed." msgstr "" -#: lib/cli/args.py:1005 +#: lib/cli/args.py:1014 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -553,12 +560,12 @@ msgid "" "layers." msgstr "" -#: lib/cli/args.py:1018 lib/cli/args.py:1030 lib/cli/args.py:1041 -#: lib/cli/args.py:1052 lib/cli/args.py:1135 +#: lib/cli/args.py:1027 lib/cli/args.py:1039 lib/cli/args.py:1050 +#: lib/cli/args.py:1061 lib/cli/args.py:1144 msgid "training" msgstr "" -#: lib/cli/args.py:1019 +#: lib/cli/args.py:1028 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -566,7 +573,7 @@ msgid "" "number that you set here. Larger batches require more GPU RAM." msgstr "" -#: lib/cli/args.py:1031 +#: lib/cli/args.py:1040 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -575,13 +582,13 @@ msgid "" "can set that value here." msgstr "" -#: lib/cli/args.py:1042 +#: lib/cli/args.py:1051 msgid "" "[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " "Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" -#: lib/cli/args.py:1053 +#: lib/cli/args.py:1062 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -594,25 +601,25 @@ msgid "" "batches distributed to each GPU at each iteration." msgstr "" -#: lib/cli/args.py:1070 lib/cli/args.py:1080 +#: lib/cli/args.py:1079 lib/cli/args.py:1089 msgid "Saving" msgstr "" -#: lib/cli/args.py:1071 +#: lib/cli/args.py:1080 msgid "Sets the number of iterations between each model save." msgstr "" -#: lib/cli/args.py:1081 +#: lib/cli/args.py:1090 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." msgstr "" -#: lib/cli/args.py:1088 lib/cli/args.py:1099 lib/cli/args.py:1110 +#: lib/cli/args.py:1097 lib/cli/args.py:1108 lib/cli/args.py:1119 msgid "timelapse" msgstr "" -#: lib/cli/args.py:1089 +#: lib/cli/args.py:1098 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -621,7 +628,7 @@ msgid "" "timelapse-input-B parameter." msgstr "" -#: lib/cli/args.py:1100 +#: lib/cli/args.py:1109 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -630,7 +637,7 @@ msgid "" "timelapse-input-A parameter." msgstr "" -#: lib/cli/args.py:1111 +#: lib/cli/args.py:1120 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -638,53 +645,53 @@ msgid "" "model folder /timelapse/" msgstr "" -#: lib/cli/args.py:1120 lib/cli/args.py:1127 +#: lib/cli/args.py:1129 lib/cli/args.py:1136 msgid "preview" msgstr "" -#: lib/cli/args.py:1121 +#: lib/cli/args.py:1130 msgid "Show training preview output. in a separate window." msgstr "" -#: lib/cli/args.py:1128 +#: lib/cli/args.py:1137 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." msgstr "" -#: lib/cli/args.py:1136 +#: lib/cli/args.py:1145 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." msgstr "" -#: lib/cli/args.py:1143 lib/cli/args.py:1152 lib/cli/args.py:1161 -#: lib/cli/args.py:1170 +#: lib/cli/args.py:1152 lib/cli/args.py:1161 lib/cli/args.py:1170 +#: lib/cli/args.py:1179 msgid "augmentation" msgstr "" -#: lib/cli/args.py:1144 +#: lib/cli/args.py:1153 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " "warping." msgstr "" -#: lib/cli/args.py:1153 +#: lib/cli/args.py:1162 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " "left off except for during 'fit training'." msgstr "" -#: lib/cli/args.py:1162 +#: lib/cli/args.py:1171 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " "Enable this option to disable color augmentation." msgstr "" -#: lib/cli/args.py:1171 +#: lib/cli/args.py:1180 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -692,6 +699,6 @@ msgid "" "likely to kill a model and lead to terrible results." msgstr "" -#: lib/cli/args.py:1196 +#: lib/cli/args.py:1205 msgid "Output to Shell console instead of GUI console" msgstr "" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo index b7ee44d6552b95a9f42e3aa6ab87379c3ea2cd88..9c2081e2646c7ea35fc80ec87c3e3bf693a18ef3 100755 GIT binary patch delta 2356 zcmYk72~d?)6o3yQ;6^SeBv|}vVs1~AC3h0kQUpm{GFyDW&r2S({Ru@NA+m&jBip>u%9OzisxigZT52ZzFkgG7GA z9y3@Z8TN`{BXBO94l7`YI_l$ePQX(zjbq+5MC1(mExadj9p8)+`2k(Vh@1ol%)-7i zPNWKdNhB#2te(UX2a2pn!jAQqMx0?DdK)Bm3i0=XBWvqH?!yyAdxJGWB*wfX~*6zj~al- z;VF0x1~cBAFH#QM3q&Xrnew*C5a!#V4X$U~l#{eLDRTVIdgxqXpm8Zq7`F2S{=(t! z5}q3DAiFmi?jAPgV?kUL(>6Lt-sPn7WB932@>)?vwGgTSZ=gKf&#=pVxYZbx_^? zVK_OI!raE&N8i1NKE;>vv3h1Ad-w6|;Pp=^RP4#0iJTAR{!e_#lVicXZ6Z-2RPWck zb1r@RQI3+!{r)7U5lBL3NRWl6p5wbfpoOpn{jKu|9CX3DvQ4lY{T{4_%Pxv+fsdfF zZ@I)Z!akQpb_8-^|k_1+gK|Al$C+-$`HC z1)1nQ792G#fu8!93`Nuv7=*+k-H^xIe82LdpAm4ZzyGp#Xh`(PX?|_0pA+DCBpI3P zx4i@-ky#BTA?eW`ePph#=VEXNe<6vlkZRIT*{eXX`k)oF#er zK1XZ7Wz$mZ4lQ3VT%s*;Ide+z%F^9#-DS-z(jD$%Lb-eaikWG5Yj&5extwmho48r} za%U>*#3yJudY((yi%G$^K(5PPq-)Gn?#$BPal3RZc1K>JUTo7c3!SCKT8^{S;Ty7N zy9=BSTf>o<8?l|o#A>ml$Hyf!)Gdgc;4wZj+Kqa%+-Nt~7;Q$oW*o-QXw(@8Fg7W* ziIIAYZAP70#XzfBYk6WY4&d8_%2efDZ`R;VL=2CaTa6a8lHkl&(VC2A&D?0T8g<9p|oP#LoID7;shfpJ>kaI8v3{npe@?}yaitZ=`w$|4YA~OVQkkQxR z3~f2{tCCi(HKubm-BSFcWXsA(fh=(7LR8!`(=xjr-t$NA&i!7`{d>Nj`+nXtIuLlR zKd|9k*!XtwJb_Kcy2nWk@YNt*JTKd&+5l+-jD%N%r6cIJ5NSN@f)BxOV2jZwN?*cO zm<~OYq&4)bhh6yRLZxcB_bzF&nJR>L9YPGOfo<>?XrChS(-5y@2KW=Y;LI?IzFO{( z{)N@B8NDi8I)FbCA?<*BBBc-D!YC=5`aal+Z0^qhDp4SMg9^3mTazzX!Q-O?MhpYfn{jfpd*>pDt*2FdwpUWkK>#xo`qp>i1W`7ACBK2iW=LGV4V4mP;GadsbWHmw2U@=;!rv z{?{q=AlyfT?lsbr_}AA;9SpFtf<>SQR!K+E=TsAWcm@6dgV(Vz>ighpaPTR~iC*=z zlt}+3m<;zn!)c-SKSy%Y?iL(CKhyAnv=-qo0l7hgskPD_7|!y%GzhP=R$jBgTEOTl zQVIGO8ySRl<2G^q=zkj?g&B>~MR;?wsbrGZxw;ewIcAd!tD2?N@Wn0Co9GRvT9^c3 z5iLj}ol`91Qfm>l_n|1w8!>mxU9qy)m!Y+=Tr0d#kt`)ogglh)Z z?US~kAMcQQX_nKfwo!|%w>XL7zzsTlp&X+5#6Fp=u1NFi~ z_?Lc`I8}Xg#tPNsv-HESfow`QAZt})uce3eNx$N^!)=UP+t2PnTL;t3Vkk{BcRxqV@nEkjj03;miX#zv5QhErC`z6 zGK^b0_T=;CQ=_}Fg;*LEhY{VezmEuLZsP*qSzBz*TuM3EEX<_-bj%#XMC|`hso%-< zGYZc4mml?|+no<&`#q1J8897Ngk}1*M_>$A(Ai`!c6RPe97+jJamTw-GUmEF-!4mC K&{@&`Zux)a0TE9C diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.po b/locales/ru/LC_MESSAGES/lib.cli.args.po index 7f12eb77fa..368e736817 100755 --- a/locales/ru/LC_MESSAGES/lib.cli.args.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-11-02 10:39+0000\n" -"PO-Revision-Date: 2022-11-02 10:42+0000\n" +"POT-Creation-Date: 2022-11-20 01:34+0000\n" +"PO-Revision-Date: 2022-11-20 01:35+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -58,7 +58,7 @@ msgstr "" "с faceswap" #: lib/cli/args.py:320 lib/cli/args.py:329 lib/cli/args.py:337 -#: lib/cli/args.py:386 lib/cli/args.py:668 lib/cli/args.py:677 +#: lib/cli/args.py:386 lib/cli/args.py:677 lib/cli/args.py:686 msgid "Data" msgstr "Данные" @@ -102,8 +102,8 @@ msgstr "" #: lib/cli/args.py:396 lib/cli/args.py:412 lib/cli/args.py:424 #: lib/cli/args.py:463 lib/cli/args.py:481 lib/cli/args.py:493 -#: lib/cli/args.py:502 lib/cli/args.py:687 lib/cli/args.py:714 -#: lib/cli/args.py:752 +#: lib/cli/args.py:502 lib/cli/args.py:511 lib/cli/args.py:696 +#: lib/cli/args.py:723 lib/cli/args.py:761 msgid "Plugins" msgstr "Плагины" @@ -248,6 +248,16 @@ msgstr "" #: lib/cli/args.py:494 msgid "" +"Re-feed the initially found aligned face through the aligner. Can help " +"produce better alignments for faces that are rotated beyond 45 degrees in " +"the frame or are at extreme angles. Slows down extraction." +msgstr "" +"Повторно подайте первоначально найденное выровненное лицо через элайнер. " +"Может помочь улучшить выравнивание лиц, которые повернуты в кадре более чем " +"на 45 градусов или находятся под экстремальными углами. Замедляет извлечение." + +#: lib/cli/args.py:503 +msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " "increments of that size up to 360, or pass in a list of numbers to enumerate " @@ -258,7 +268,7 @@ msgstr "" "использовать приращения этого размера до 360, либо передайте список чисел, " "чтобы точно указать, какие углы проверять." -#: lib/cli/args.py:503 +#: lib/cli/args.py:512 msgid "" "Obtain and store face identity encodings from VGGFace2. Slows down extract a " "little, but will save time if using 'sort by face'" @@ -266,13 +276,13 @@ msgstr "" "Получите и сохраните кодировку идентификации лица от VGGFace2. Немного " "замедляет извлечение, но сэкономит время при использовании «sort by face»" -#: lib/cli/args.py:513 lib/cli/args.py:523 lib/cli/args.py:535 -#: lib/cli/args.py:548 lib/cli/args.py:789 lib/cli/args.py:803 -#: lib/cli/args.py:816 lib/cli/args.py:830 +#: lib/cli/args.py:522 lib/cli/args.py:532 lib/cli/args.py:544 +#: lib/cli/args.py:557 lib/cli/args.py:798 lib/cli/args.py:812 +#: lib/cli/args.py:825 lib/cli/args.py:839 msgid "Face Processing" msgstr "Обработка лиц" -#: lib/cli/args.py:514 +#: lib/cli/args.py:523 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -280,7 +290,7 @@ msgstr "" "Отбрасывает лица ниже указанного размера. Длина указывается в пикселях по " "диагонали. Установите в 0 для отключения" -#: lib/cli/args.py:524 +#: lib/cli/args.py:533 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -292,7 +302,7 @@ msgstr "" "разными углами и в разных условиях. Можно выбрать папку, содержащую " "требуемые изображения или несколько файлов изображений, разделенных пробелом." -#: lib/cli/args.py:536 +#: lib/cli/args.py:545 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -304,7 +314,7 @@ msgstr "" "углами и в разных условиях. Можно выбрать папку, содержащую необходимые " "изображения или несколько файлов изображений, разделенных пробелом." -#: lib/cli/args.py:549 +#: lib/cli/args.py:558 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." @@ -313,12 +323,12 @@ msgstr "" "положительного распознавания лиц. Более высокие значения являются более " "строгими." -#: lib/cli/args.py:558 lib/cli/args.py:570 lib/cli/args.py:582 -#: lib/cli/args.py:594 +#: lib/cli/args.py:567 lib/cli/args.py:579 lib/cli/args.py:591 +#: lib/cli/args.py:603 msgid "output" msgstr "вывод" -#: lib/cli/args.py:559 +#: lib/cli/args.py:568 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -328,7 +338,7 @@ msgstr "" "поддерживает такой входной размер. Стоит изменять только для моделей " "высокого разрешения." -#: lib/cli/args.py:571 +#: lib/cli/args.py:580 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -338,7 +348,7 @@ msgstr "" "извлечении. Например, значение 1 будет искать лица в каждом кадре, а " "значение 10 в каждом 10том кадре." -#: lib/cli/args.py:583 +#: lib/cli/args.py:592 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -353,17 +363,17 @@ msgstr "" "только во время второго прохода. ВНИМАНИЕ: Не прерывайте выполнение во время " "записи, так как это может повлечь порчу файла. Установите в 0 для выключения" -#: lib/cli/args.py:595 +#: lib/cli/args.py:604 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "Рисовать ландмарки на выходных лицах для нужд отладки." -#: lib/cli/args.py:601 lib/cli/args.py:610 lib/cli/args.py:618 -#: lib/cli/args.py:625 lib/cli/args.py:843 lib/cli/args.py:854 -#: lib/cli/args.py:862 lib/cli/args.py:881 lib/cli/args.py:887 +#: lib/cli/args.py:610 lib/cli/args.py:619 lib/cli/args.py:627 +#: lib/cli/args.py:634 lib/cli/args.py:852 lib/cli/args.py:863 +#: lib/cli/args.py:871 lib/cli/args.py:890 lib/cli/args.py:896 msgid "settings" msgstr "настройки" -#: lib/cli/args.py:602 +#: lib/cli/args.py:611 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " @@ -373,7 +383,7 @@ msgstr "" "стадия извлечения будет запущена отдельно (одна, за другой). Полезно при " "нехватке VRAM." -#: lib/cli/args.py:611 +#: lib/cli/args.py:620 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -381,16 +391,16 @@ msgstr "" "Пропускать кадры, которые уже были извлечены и существуют в файле " "выравнивания" -#: lib/cli/args.py:619 +#: lib/cli/args.py:628 msgid "Skip frames that already have detected faces in the alignments file" msgstr "Пропускать кадры, для которых в файле выравнивания есть найденные лица" -#: lib/cli/args.py:626 +#: lib/cli/args.py:635 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "Не сохранять найденные лица на носитель. Просто создать файл выравнивания" -#: lib/cli/args.py:648 +#: lib/cli/args.py:657 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -398,7 +408,7 @@ msgstr "" "Заменить оригиналы лица в исходном видео/фотографиях новыми.\n" "Плагины конвертации могут быть настроены в меню 'Настройки'" -#: lib/cli/args.py:669 +#: lib/cli/args.py:678 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -408,7 +418,7 @@ msgstr "" "Предоставьте исходное видео, из которого были извлечены кадры (для настройки " "частоты кадров, а также аудио)." -#: lib/cli/args.py:678 +#: lib/cli/args.py:687 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -416,7 +426,7 @@ msgstr "" "Папка с моделью. Папка, содержащая обученную модель, которую вы хотите " "использовать для преобразования." -#: lib/cli/args.py:688 +#: lib/cli/args.py:697 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -455,7 +465,7 @@ msgstr "" "дает удовлетворительных результатов.\n" "L|none: Не производить подгонку цвета." -#: lib/cli/args.py:715 +#: lib/cli/args.py:724 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -524,7 +534,7 @@ msgstr "" "L| predicted: Если во время обучения была включена опция «Learn Mask», будет " "использоваться маска, созданная обученной моделью." -#: lib/cli/args.py:753 +#: lib/cli/args.py:762 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -550,11 +560,11 @@ msgstr "" "L|pillow: [изображения] Более медленный, чем opencv, но имеет больше опций и " "поддерживает больше форматов." -#: lib/cli/args.py:772 lib/cli/args.py:779 lib/cli/args.py:873 +#: lib/cli/args.py:781 lib/cli/args.py:788 lib/cli/args.py:882 msgid "Frame Processing" msgstr "Обработка кадров" -#: lib/cli/args.py:773 +#: lib/cli/args.py:782 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -564,7 +574,7 @@ msgstr "" "кадры в исходном размере. 50%% половина от размера, а 200%% в удвоенном " "размере" -#: lib/cli/args.py:780 +#: lib/cli/args.py:789 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -577,7 +587,7 @@ msgstr "" "unchanged). Прим.: Если при конверсии используются изображения, то имена " "файлов должны заканчиваться номером кадра!" -#: lib/cli/args.py:790 +#: lib/cli/args.py:799 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -593,7 +603,7 @@ msgstr "" "Если оставить это поле пустым, то все лица, которые существуют в файле " "выравниваний будут сконвертированы." -#: lib/cli/args.py:804 +#: lib/cli/args.py:813 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -607,7 +617,7 @@ msgstr "" "пробел. Прим.: Фильтрация лиц существенно снижает скорость извлечения, при " "этом точность не гарантируется." -#: lib/cli/args.py:817 +#: lib/cli/args.py:826 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -621,7 +631,7 @@ msgstr "" "изображений через пробел. Прим.: Использование фильтра существенно замедлит " "скорость извлечения. Также точность не гарантируется." -#: lib/cli/args.py:831 +#: lib/cli/args.py:840 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -632,7 +642,7 @@ msgstr "" "лица. Чем ниже значения, тем строже. Прим.: Использование фильтра лиц " "существенно замедлит скорость извлечения. Также точность не гарантируется." -#: lib/cli/args.py:844 +#: lib/cli/args.py:853 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -649,7 +659,7 @@ msgstr "" "будет использоваться больше процессов, чем доступно в вашей системе. Если " "включен одиночный процесс, этот параметр будет проигнорирован." -#: lib/cli/args.py:855 +#: lib/cli/args.py:864 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -657,7 +667,7 @@ msgstr "" "[СОВМЕСТИМОСТЬ] Это нужно выбирать только в том случае, если загружается " "устаревшая модель или если в папке сохранения есть несколько моделей" -#: lib/cli/args.py:863 +#: lib/cli/args.py:872 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -671,7 +681,7 @@ msgstr "" "использованию улучшенного конвейера экстракции и некачественных результатов. " "Если файл выравниваний найден, этот параметр будет проигнорирован." -#: lib/cli/args.py:874 +#: lib/cli/args.py:883 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -679,16 +689,16 @@ msgstr "" "При использовании с --frame-range кадры не попавшие в диапазон выводятся " "неизменными, вместо их пропуска." -#: lib/cli/args.py:882 +#: lib/cli/args.py:891 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Поменять модели местами. Вместо преобразования из A -> B, преобразует B -> A" -#: lib/cli/args.py:888 +#: lib/cli/args.py:897 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Отключить многопроцессорность. Медленнее, но менее ресурсоемко." -#: lib/cli/args.py:904 +#: lib/cli/args.py:913 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -699,11 +709,11 @@ msgstr "" "Обучение моделей может занять долгое время: от 24 часов до недели\n" "Каждую модель можно отдельно настроить в меню «Настройки»" -#: lib/cli/args.py:923 lib/cli/args.py:932 +#: lib/cli/args.py:932 lib/cli/args.py:941 msgid "faces" msgstr "лица" -#: lib/cli/args.py:924 +#: lib/cli/args.py:933 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -712,7 +722,7 @@ msgstr "" "Входная папка. Папка содержащая изображения для тренировки лица A. Это " "исходное лицо т.е. лицо, которое вы хотите убрать, заменив лицом B." -#: lib/cli/args.py:933 +#: lib/cli/args.py:942 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -721,12 +731,12 @@ msgstr "" "Входная папка. Папка содержащая изображения для тренировки лица B. Это новое " "лицо т.е. лицо, которое вы хотите поместить на голову человека A." -#: lib/cli/args.py:941 lib/cli/args.py:953 lib/cli/args.py:969 -#: lib/cli/args.py:994 lib/cli/args.py:1004 +#: lib/cli/args.py:950 lib/cli/args.py:962 lib/cli/args.py:978 +#: lib/cli/args.py:1003 lib/cli/args.py:1013 msgid "model" msgstr "модель" -#: lib/cli/args.py:942 +#: lib/cli/args.py:951 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -740,7 +750,7 @@ msgstr "" "будет создана). Если вы хотите продолжить тренировку, выберите папку с уже " "существующими сохранениями." -#: lib/cli/args.py:954 +#: lib/cli/args.py:963 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -764,7 +774,7 @@ msgstr "" "NB: Вес можно загружать только из моделей того же плагина, который вы " "собираетесь тренировать." -#: lib/cli/args.py:970 +#: lib/cli/args.py:979 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -810,7 +820,7 @@ msgstr "" "ресурсам (Вам потребуется GPU с хорошим количеством видеопамяти). Хороша для " "деталей, но подвержена к неправильной передаче цвета." -#: lib/cli/args.py:995 +#: lib/cli/args.py:1004 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -822,7 +832,7 @@ msgstr "" "сводная информация о модели, которая будет создана выбранным плагином, и " "параметрами конфигурации." -#: lib/cli/args.py:1005 +#: lib/cli/args.py:1014 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -836,12 +846,12 @@ msgstr "" "некоторые модели могут иметь параметры конфигурации для замораживания других " "слоев." -#: lib/cli/args.py:1018 lib/cli/args.py:1030 lib/cli/args.py:1041 -#: lib/cli/args.py:1052 lib/cli/args.py:1135 +#: lib/cli/args.py:1027 lib/cli/args.py:1039 lib/cli/args.py:1050 +#: lib/cli/args.py:1061 lib/cli/args.py:1144 msgid "training" msgstr "тренировка" -#: lib/cli/args.py:1019 +#: lib/cli/args.py:1028 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -854,7 +864,7 @@ msgstr "" "изображений в два раза больше этого числа. Увеличение размера партии требует " "больше памяти GPU." -#: lib/cli/args.py:1031 +#: lib/cli/args.py:1040 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -868,7 +878,7 @@ msgstr "" "Однако, если вы хотите, чтобы тренировка прервалась после указанного кол-ва " "итерация, вы можете ввести это здесь." -#: lib/cli/args.py:1042 +#: lib/cli/args.py:1051 msgid "" "[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " "Mirrored Distrubution Strategy to train on multiple GPUs." @@ -877,7 +887,7 @@ msgstr "" "Используйте стратегию зеркального распространения Tensorflow для обучения на " "нескольких графических процессорах." -#: lib/cli/args.py:1053 +#: lib/cli/args.py:1062 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -902,15 +912,15 @@ msgstr "" "в каждый GPU, причем пакеты распределяются между каждым GPU на каждой " "итерации." -#: lib/cli/args.py:1070 lib/cli/args.py:1080 +#: lib/cli/args.py:1079 lib/cli/args.py:1089 msgid "Saving" msgstr "Сохранение" -#: lib/cli/args.py:1071 +#: lib/cli/args.py:1080 msgid "Sets the number of iterations between each model save." msgstr "Установка количества итераций между сохранениями модели." -#: lib/cli/args.py:1081 +#: lib/cli/args.py:1090 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -918,11 +928,11 @@ msgstr "" "Устанавливает кол-во итераций перед созданием резервной копии модели. " "Установите в 0 для отключения." -#: lib/cli/args.py:1088 lib/cli/args.py:1099 lib/cli/args.py:1110 +#: lib/cli/args.py:1097 lib/cli/args.py:1108 lib/cli/args.py:1119 msgid "timelapse" msgstr "таймлапс" -#: lib/cli/args.py:1089 +#: lib/cli/args.py:1098 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -935,7 +945,7 @@ msgstr "" "папку лиц набора 'A' для использования при создании таймлапса. Вам также " "нужно указать параметры--timelapse-output и --timelapse-input-B." -#: lib/cli/args.py:1100 +#: lib/cli/args.py:1109 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -949,7 +959,7 @@ msgstr "" "таймлапса. Вы также должны указать параметр --timelapse-output и --timelapse-" "input-A." -#: lib/cli/args.py:1111 +#: lib/cli/args.py:1120 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -961,15 +971,15 @@ msgstr "" "указаны только входные папки, то по умолчанию вывод будет сохранен вместе с " "моделью в подкаталог /timelapse/" -#: lib/cli/args.py:1120 lib/cli/args.py:1127 +#: lib/cli/args.py:1129 lib/cli/args.py:1136 msgid "preview" msgstr "предварительный просмотр" -#: lib/cli/args.py:1121 +#: lib/cli/args.py:1130 msgid "Show training preview output. in a separate window." msgstr "Показывать предварительный просмотр в отдельном окне." -#: lib/cli/args.py:1128 +#: lib/cli/args.py:1137 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -977,7 +987,7 @@ msgstr "" "Записывает результат тренировки в файл. Файл будет сохранен в коренной папке " "FaceSwap." -#: lib/cli/args.py:1136 +#: lib/cli/args.py:1145 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -985,12 +995,12 @@ msgstr "" "Отключает журнал TensorBoard. Примечание: Отключение журналов означает, что " "вы не сможете использовать графики или анализ сессии внутри GUI." -#: lib/cli/args.py:1143 lib/cli/args.py:1152 lib/cli/args.py:1161 -#: lib/cli/args.py:1170 +#: lib/cli/args.py:1152 lib/cli/args.py:1161 lib/cli/args.py:1170 +#: lib/cli/args.py:1179 msgid "augmentation" msgstr "аугментация" -#: lib/cli/args.py:1144 +#: lib/cli/args.py:1153 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -1000,7 +1010,7 @@ msgstr "" "Ориентирами/Landmarks противоположного набора лиц. Этот способ используется " "пакетом \"dfaker\"." -#: lib/cli/args.py:1153 +#: lib/cli/args.py:1162 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -1011,7 +1021,7 @@ msgstr "" "происходило. Как правило, эту настройку не стоит трогать, за исключением " "периода «финальной шлифовки»." -#: lib/cli/args.py:1162 +#: lib/cli/args.py:1171 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -1021,7 +1031,7 @@ msgstr "" "цвета между наборами A and B ценой некоторого замедления скорости " "тренировки. Включите эту опцию для отключения цветовой аугментации." -#: lib/cli/args.py:1171 +#: lib/cli/args.py:1180 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -1034,7 +1044,7 @@ msgstr "" "Включение этой опции с самого начала может убить модель и привести к ужасным " "результатам." -#: lib/cli/args.py:1196 +#: lib/cli/args.py:1205 msgid "Output to Shell console instead of GUI console" msgstr "Вывод в системную консоль вместо GUI" diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index db8e88f599..188eb67a29 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -33,7 +33,7 @@ from .mask._base import MaskerBatch from .recognition._base import RecogBatch -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) # TODO Run with warnings mode @@ -77,7 +77,7 @@ class ExtractorBatch: List of :class:`~lib.align.DetectedFace` objects filename: list List of original frame filenames for the batch - feed: :class:`numpy.nd.array` + feed: :class:`numpy.ndarray` Batch of feed images to feed the net with prediction: :class:`numpy.nd.array` Batch of predictions. Direct output from the aligner net @@ -604,6 +604,8 @@ def _thread_process(self, batch = self._obtain_batch_item(function, in_queue, out_queue) if batch is None: break + if not batch.filename: # Batch not populated. Possible during re-aligns + continue try: batch = function(batch) except tf_errors.UnknownError as err: diff --git a/plugins/extract/_config.py b/plugins/extract/_config.py index 8e5e5300c0..2ec15be51d 100644 --- a/plugins/extract/_config.py +++ b/plugins/extract/_config.py @@ -101,7 +101,7 @@ def set_globals(self): datatype=bool, default=True, group="filters", - info="If enabled, and re-feed has been selected for extraction, then interim " + info="If enabled, and 're-feed' has been selected for extraction, then interim " "alignments will be filtered prior to averaging the final landmarks. This can " "help improve the final alignments by removing any obvious misaligns from the " "interim results, and may also help pick up difficult alignments. If disabled, " @@ -116,3 +116,20 @@ def set_globals(self): "extraction process. If disabled, filtered faces are deleted. Note: The faces " "will always be filtered out of the alignments file, regardless of whether you " "keep the faces or not.") + self.add_item( + section=section, + title="realign_refeeds", + datatype=bool, + default=True, + group="re-align", + info="If enabled, and 're-align' has been selected for extraction, then all re-feed " + "iterations are re-aligned. If disabled, then only the final averaged output " + "from re-feed will be re-aligned.") + self.add_item( + section=section, + title="filter_realign", + datatype=bool, + default=True, + group="re-align", + info="If enabled, and 're-align' has been selected for extraction, then any " + "alignments which would be filtered out will not be re-aligned.") diff --git a/plugins/extract/align/_base/__init__.py b/plugins/extract/align/_base/__init__.py new file mode 100644 index 0000000000..6e32deea62 --- /dev/null +++ b/plugins/extract/align/_base/__init__.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 +""" Base class for Aligner plugins ALL aligners should at least inherit from this class. """ + +from .aligner import Aligner, AlignerBatch, BatchType diff --git a/plugins/extract/align/_base.py b/plugins/extract/align/_base/aligner.py similarity index 53% rename from plugins/extract/align/_base.py rename to plugins/extract/align/_base/aligner.py index 6b961b9705..64a85de713 100644 --- a/plugins/extract/align/_base.py +++ b/plugins/extract/align/_base/aligner.py @@ -16,16 +16,17 @@ import sys from dataclasses import dataclass, field -from typing import cast, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING +from time import sleep +from typing import cast, Generator, List, Optional, Tuple, TYPE_CHECKING import cv2 import numpy as np from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa -from lib.align import AlignedFace, DetectedFace from lib.utils import get_backend, FaceswapError from plugins.extract._base import BatchType, Extractor, ExtractMedia, ExtractorBatch +from .processing import AlignedFilter, ReAlign if sys.version_info < (3, 8): from typing_extensions import Literal @@ -34,8 +35,24 @@ if TYPE_CHECKING: from queue import Queue + from lib.align import DetectedFace + from lib.align.aligned_face import CenteringType logger = logging.getLogger(__name__) +_BATCH_IDX: int = 0 + + +def _get_new_batch_id() -> int: + """ Obtain the next available batch index + + Returns + ------- + int + The next available unique batch id + """ + global _BATCH_IDX # pylint:disable=global-statement + _BATCH_IDX += 1 + return _BATCH_IDX @dataclass @@ -46,15 +63,46 @@ class AlignerBatch(ExtractorBatch): Parameters ---------- + batch_id: int + A unique integer for tracking this batch landmarks: list List of 68 point :class:`numpy.ndarray` landmark points returned from the aligner refeeds: list List of :class:`numpy.ndarrays` for holding each of the feeds that will be put through the model for each refeed + second_pass: bool, optional + ``True`` if this batch is passing through the aligner for a second time as re-align has + been selected otherwise ``False``. Default: ``False`` + second_pass_masks: :class:`numpy.ndarray`, optional + The masks used to filter out re-feed values for passing to the re-aligner. """ + batch_id: int = 0 detected_faces: List["DetectedFace"] = field(default_factory=list) landmarks: np.ndarray = np.array([]) refeeds: List[np.ndarray] = field(default_factory=list) + second_pass: bool = False + second_pass_masks: np.ndarray = np.array([]) + + def __repr__(self): + """ Prettier repr for debug printing """ + data = [{k: v.shape if isinstance(v, np.ndarray) else v for k, v in dat.items()} + for dat in self.data] + return ("AlignerBatch(" + f"batch_id={self.batch_id}, " + f"image={[img.shape for img in self.image]}, " + f"detected_faces={self.detected_faces}, " + f"filename={self.filename}, " + f"feed={self.feed.shape}, " + f"prediction={self.prediction.shape}, " + f"data={data}, " + f"landmarks={self.landmarks.shape}, " + f"refeeds={[feed.shape for feed in self.refeeds]}, " + f"second_pass={self.second_pass}, " + f"second_pass_masks={self.second_pass_masks})") + + def __post_init__(self): + """ Make sure that we have been given a non-zero ID """ + assert self.batch_id != 0, ("A batch ID must be specified for Aligner Batches") class Aligner(Extractor): # pylint:disable=abstract-method @@ -74,6 +122,9 @@ class Aligner(Extractor): # pylint:disable=abstract-method re_feed: int, optional The number of times to re-feed a slightly adjusted bounding box into the aligner. Default: `0` + re_align: bool, optional + ``True`` to obtain landmarks by passing the initially aligned face back through the + aligner. Default ``False`` disable_filter: bool, optional Disable all aligner filters regardless of config option. Default: ``False`` Other Parameters @@ -97,20 +148,22 @@ def __init__(self, instance: int = 0, normalize_method: Optional[Literal["none", "clahe", "hist", "mean"]] = None, re_feed: int = 0, + re_align: bool = False, disable_filter: bool = False, **kwargs) -> None: - logger.debug("Initializing %s: (normalize_method: %s, re_feed: %s, disable_filter: %s)", - self.__class__.__name__, normalize_method, re_feed, disable_filter) + logger.debug("Initializing %s: (normalize_method: %s, re_feed: %s, re_align: %s, " + "disable_filter: %s)", self.__class__.__name__, normalize_method, re_feed, + re_align, disable_filter) super().__init__(git_model_id, model_filename, configfile=configfile, instance=instance, **kwargs) + self._plugin_type = "align" + self.realign_centering: "CenteringType" = "face" # overide for plugin specific centering + self._eof_seen = False self._normalize_method: Optional[Literal["clahe", "hist", "mean"]] = None self._re_feed = re_feed - self.set_normalize_method(normalize_method) - - self._plugin_type = "align" self._filter = AlignedFilter(feature_filter=self.config["aligner_features"], min_scale=self.config["aligner_min_scale"], max_scale=self.config["aligner_max_scale"], @@ -118,6 +171,14 @@ def __init__(self, roll=self.config["aligner_roll"], save_output=self.config["save_filtered"], disable=disable_filter) + self._re_align = ReAlign(re_align, + self.config["realign_refeeds"], + self.config["filter_realign"]) + self._needs_refeed_masks: bool = self._re_feed > 0 and ( + self.config["filter_refeed"] or (self._re_align.do_refeeds and + self._re_align.do_filter)) + self.set_normalize_method(normalize_method) + logger.debug("Initialized %s", self.__class__.__name__) def set_normalize_method(self, @@ -132,7 +193,54 @@ def set_normalize_method(self, method = None if method is None or method.lower() == "none" else method self._normalize_method = cast(Optional[Literal["clahe", "hist", "mean"]], method) - # << QUEUE METHODS >>> # + def initialize(self, *args, **kwargs) -> None: + """ Add a call to add model input size to the re-aligner """ + self._re_align.set_input_size_and_centering(self.input_size, self.realign_centering) + super().initialize(*args, **kwargs) + + def _handle_realigns(self, queue: "Queue") -> Optional[Tuple[bool, AlignerBatch]]: + """ Handle any items waiting for a second pass through the aligner. + + If EOF has been recieved and items are still being processed through the first pass + then wait for a short time and try again to collect them. + + On EOF return exhausted flag with an empty batch + + Parameters + ---------- + queue : queue.Queue() + The ``queue`` that the plugin will be fed from. + + Returns + ------- + ``None`` or tuple + If items are processed then returns (`bool`, :class:`AlignerBatch`) containing the + exhausted flag and the batch to be processed. If no items are processed returns + ``None`` + """ + if not self._re_align.active: + return None + + exhausted = False + if self._re_align.items_queued: + batch = self._re_align.get_batch() + logger.trace("Re-align batch: %s", batch) # type: ignore[attr-defined] + return exhausted, batch + + if self._eof_seen and self._re_align.items_tracked: + # EOF seen and items still being processed on first pass + logger.debug("Tracked re-align items waiting to be flushed, retrying...") + sleep(0.25) + return self.get_batch(queue) + + if self._eof_seen: + exhausted = True + logger.debug("All items processed. Returning empty batch") + self._filter.output_counts() + return exhausted, AlignerBatch(batch_id=-1) + + return None + def get_batch(self, queue: "Queue") -> Tuple[bool, AlignerBatch]: """ Get items for inputting into the aligner from the queue in batches @@ -168,14 +276,21 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, AlignerBatch]: The batch object for the current batch """ exhausted = False - batch = AlignerBatch() + + realign_batch = self._handle_realigns(queue) + if realign_batch is not None: + return realign_batch + + batch = AlignerBatch(batch_id=_get_new_batch_id()) idx = 0 while idx < self.batchsize: item = self.rollover_collector(queue) if item == "EOF": - logger.trace("EOF received") # type:ignore - exhausted = True + logger.debug("EOF received") + self._eof_seen = True + exhausted = not self._re_align.items_tracked break + # Put frames with no faces or are already aligned into the out queue if not item.detected_faces or item.is_aligned: self._queues["out"].put(item) @@ -195,16 +310,15 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, AlignerBatch]: item.image, detected_faces=item.detected_faces[f_idx + 1:], is_aligned=item.is_aligned) - logger.trace("Rolled over %s faces of %s to next batch " # type:ignore - "for '%s'", len(self._rollover.detected_faces), frame_faces, - item.filename) + logger.trace("Rolled over %s faces of %s to " # type: ignore[attr-defined] + "next batch for '%s'", len(self._rollover.detected_faces), + frame_faces, item.filename) break if batch.filename: - logger.trace("Returning batch: %s", {k: len(v) # type:ignore - if isinstance(v, (list, np.ndarray)) else v - for k, v in batch.__dict__.items()}) + logger.trace("Returning batch: %s", batch) # type: ignore[attr-defined] + self._re_align.track_batch(batch.batch_id) else: - logger.debug(item) # type:ignore + logger.debug(item) # TODO Move to end of process not beginning if exhausted: @@ -212,6 +326,22 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, AlignerBatch]: return exhausted, batch + def faces_to_feed(self, faces: np.ndarray) -> np.ndarray: + """ Overide for specific plugin processing to convert a batch of face images from UINT8 + (0-255) into the correct format for the plugin's inference + + Parameters + ---------- + faces: :class:`numpy.ndarray` + The batch of faces in UINT8 format + + Returns + ------- + class: `numpy.ndarray` + The batch of faces in the format to feed through the plugin + """ + raise NotImplementedError() + # <<< FINALIZE METHODS >>> # def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: """ Finalize the output from Aligner @@ -231,16 +361,17 @@ def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: The :attr:`DetectedFaces` list will be populated for this class with the bounding boxes and landmarks for the detected faces found in the frame. """ - assert isinstance(batch, AlignerBatch) + if not batch.second_pass and self._re_align.active: + # Add the batch for second pass re-alignment and return + self._re_align.add_batch(batch) + return for face, landmarks in zip(batch.detected_faces, batch.landmarks): if not isinstance(landmarks, np.ndarray): landmarks = np.array(landmarks) face.add_landmarks_xy(landmarks) - logger.trace("Item out: %s", {key: val.shape # type:ignore - if isinstance(val, np.ndarray) else val - for key, val in batch.__dict__.items()}) + logger.trace("Item out: %s", batch) # type: ignore[attr-defined] for frame, filename, face in zip(batch.image, batch.filename, batch.detected_faces): self._output_faces.append(face) @@ -254,16 +385,44 @@ def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: output.add_sub_folders(folders) self._output_faces = [] - logger.trace("Final Output: (filename: '%s', image shape: %s, " # type:ignore - "detected_faces: %s, item: %s)", - output.filename, output.image_shape, output.detected_faces, output) + logger.trace("Final Output: (filename: '%s', image " # type: ignore[attr-defined] + "shape: %s, detected_faces: %s, item: %s)", output.filename, + output.image_shape, output.detected_faces, output) yield output + self._re_align.untrack_batch(batch.batch_id) # <<< PROTECTED METHODS >>> # - # << PROCESS_INPUT WRAPPER >> - def _process_input(self, batch: BatchType) -> AlignerBatch: - """ Process the input to the aligner model multiple times based on the user selected + def _get_adjusted_boxes(self, original_boxes: np.ndarray) -> np.ndarray: + """ Obtain an array of adjusted bounding boxes based on the number of re-feed iterations + that have been selected and the minimum dimension of the original bounding box. + + Parameters + ---------- + original_boxes: :class:`numpy.ndarray` + The original ('x', 'y', 'w', 'h') detected face boxes corresponding to the incoming + detected face objects + + Returns + ------- + :class:`numpy.ndarray` + The original boxes (in position 0) and the randomly adjusted bounding boxes + """ + if self._re_feed == 0: + return original_boxes[None, ...] + beta = 0.05 + max_shift = np.min(original_boxes[..., 2:], axis=1) * beta + rands = np.random.rand(self._re_feed, *original_boxes.shape) * 2 - 1 + new_boxes = np.rint(original_boxes + (rands * max_shift[None, :, None])).astype("int32") + retval = np.concatenate((original_boxes[None, ...], new_boxes)) + logger.trace(retval) # type: ignore[attr-defined] + return retval + + def _process_input_first_pass(self, batch: AlignerBatch) -> None: + """ Standard pre-processing for aligners for first pass (if re-align selected) or the + only pass. + + Process the input to the aligner model multiple times based on the user selected `re-feed` command line option. This adjusts the bounding box for the face to be fed into the model by a random amount within 0.05 pixels of the detected face's shortest axis. @@ -275,13 +434,7 @@ def _process_input(self, batch: BatchType) -> AlignerBatch: ---------- batch: :class:`AlignerBatch` Contains the batch that is currently being passed through the plugin process - - Returns - ------- - :class:`AlignerBatch` - The batch with input processed """ - assert isinstance(batch, AlignerBatch) original_boxes = np.array([(face.left, face.top, face.width, face.height) for face in batch.detected_faces]) adjusted_boxes = self._get_adjusted_boxes(original_boxes) @@ -292,6 +445,7 @@ def _process_input(self, batch: BatchType) -> AlignerBatch: face.left, face.top, face.width, face.height = box self.process_input(batch) + batch.feed = self.faces_to_feed(self._normalize_faces(batch.feed)) # Move the populated feed into the batch refeed list. It will be overwritten at next # iteration batch.refeeds.append(batch.feed) @@ -300,33 +454,65 @@ def _process_input(self, batch: BatchType) -> AlignerBatch: for face, box in zip(batch.detected_faces, original_boxes): face.left, face.top, face.width, face.height = box - return batch - - def _get_adjusted_boxes(self, original_boxes: np.ndarray) -> np.ndarray: - """ Obtain an array of adjusted bounding boxes based on the number of re-feed iterations - that have been selected and the minimum dimension of the original bounding box. + def _get_realign_masks(self, batch: AlignerBatch) -> np.ndarray: + """ Obtain the masks required for processing re-aligns Parameters ---------- - original_boxes: :class:`numpy.ndarray` - The original ('x', 'y', 'w', 'h') detected face boxes corresponding to the incoming - detected face objects + batch: :class:`AlignerBatch` + Contains the batch that is currently being passed through the plugin process Returns ------- :class:`numpy.ndarray` - The original boxes (in position 0) and the randomly adjusted bounding boxes + The filter masks required for masking the re-aligns """ - if self._re_feed == 0: - return original_boxes[None, ...] - beta = 0.05 - max_shift = np.min(original_boxes[..., 2:], axis=1) * beta - rands = np.random.rand(self._re_feed, *original_boxes.shape) * 2 - 1 - new_boxes = np.rint(original_boxes + (rands * max_shift[None, :, None])).astype("int32") - retval = np.concatenate((original_boxes[None, ...], new_boxes)) - logger.trace(retval) # type:ignore + if self._re_align.do_refeeds: + retval = batch.second_pass_masks # Masks already calculated during re-feed + elif self._re_align.do_filter: + retval = self._filter.filtered_mask(batch)[None, ...] + else: + retval = np.zeros((batch.landmarks.shape[0], ), dtype="bool")[None, ...] return retval + def _process_input_second_pass(self, batch: AlignerBatch) -> None: + """ Process the input for 2nd-pass re-alignment + + Parameters + ---------- + batch: :class:`AlignerBatch` + Contains the batch that is currently being passed through the plugin process + """ + batch.second_pass_masks = self._get_realign_masks(batch) + + if not self._re_align.do_refeeds: + # Expand the dimensions for re-aligns for consistent handling of code + batch.landmarks = batch.landmarks[None, ...] + + refeeds = self._re_align.process_batch(batch) + batch.refeeds = [self.faces_to_feed(self._normalize_faces(faces)) for faces in refeeds] + + def _process_input(self, batch: BatchType) -> AlignerBatch: + """ Perform pre-processing depending on whether this is the first/only pass through the + aligner or the 2nd pass when re-align has been selected + + Parameters + ---------- + batch: :class:`AlignerBatch` + Contains the batch that is currently being passed through the plugin process + + Returns + ------- + :class:`AlignerBatch` + The batch with input processed + """ + assert isinstance(batch, AlignerBatch) + if batch.second_pass: + self._process_input_second_pass(batch) + else: + self._process_input_first_pass(batch) + return batch + # <<< PREDICT WRAPPER >>> # def _predict(self, batch: BatchType) -> AlignerBatch: """ Just return the aligner's predict function @@ -378,7 +564,93 @@ def _predict(self, batch: BatchType) -> AlignerBatch: raise FaceswapError(msg) from err raise - def _get_mean_landmarks(self, landmarks: np.ndarray, masks: List[List[bool]]) -> np.ndarray: + def _process_refeeds(self, batch: AlignerBatch) -> List[AlignerBatch]: + """ Process the output for each selected re-feed + + Parameters + ---------- + batch: :class:`AlignerBatch` + The batch object passing through the aligner + + Returns + ------- + list + List of :class:`AlignerBatch` objects. Each object in the list contains the + results for each selected re-feed + """ + retval: List[AlignerBatch] = [] + if batch.second_pass: + # Re-insert empty sub-patches for re-population in ReAlign for filtered out batches + selected_idx = 0 + for mask in batch.second_pass_masks: + all_filtered = np.all(mask) + if not all_filtered: + feed = batch.refeeds[selected_idx] + pred = batch.prediction[selected_idx] + data = batch.data[selected_idx] + selected_idx += 1 + else: # All resuts have been filtered out + feed = pred = np.array([]) + data = {} + + subbatch = AlignerBatch(batch_id=batch.batch_id, + image=batch.image, + detected_faces=batch.detected_faces, + filename=batch.filename, + feed=feed, + prediction=pred, + data=[data], + second_pass=batch.second_pass) + + if not all_filtered: + self.process_output(subbatch) + + retval.append(subbatch) + else: + for feed, pred, data in zip(batch.refeeds, batch.prediction, batch.data): + subbatch = AlignerBatch(batch_id=batch.batch_id, + image=batch.image, + detected_faces=batch.detected_faces, + filename=batch.filename, + feed=feed, + prediction=pred, + data=[data], + second_pass=batch.second_pass) + self.process_output(subbatch) + retval.append(subbatch) + return retval + + def _get_refeed_filter_masks(self, + subbatches: List[AlignerBatch], + original_masks: Optional[np.ndarray] = None) -> np.ndarray: + """ Obtain the boolean mask array for masking out failed re-feed results if filter refeed + has been selected + + Parameters + ---------- + subbatches: list + List of sub-batch results for each re-feed performed + original_masks: :class:`numpy.ndarray`, Optional + If passing in the second pass landmarks, these should be the original filter masks so + that we don't calculate the mask again for already filtered faces. Default: ``None`` + + Returns + ------- + :class:`numpy.ndarray` + boolean values for every detected face indicating whether the interim landmarks have + passed the filter test + """ + retval = np.zeros((len(subbatches), subbatches[0].landmarks.shape[0]), dtype="bool") + + if not self._needs_refeed_masks: + return retval + + retval = retval if original_masks is None else original_masks + for subbatch, masks in zip(subbatches, retval): + masks[:] = self._filter.filtered_mask(subbatch, np.flatnonzero(masks)) + return retval + + def _get_mean_landmarks(self, landmarks: np.ndarray, masks: np.ndarray) -> np.ndarray: """ Obtain the averaged landmarks from the re-fed alignments. If config option 'filter_refeed' is enabled, then average those results which have not been filtered out otherwise average all results @@ -387,7 +659,7 @@ def _get_mean_landmarks(self, landmarks: np.ndarray, masks: List[List[bool]]) -> ---------- landmarks: :class:`numpy.ndarray` The batch of re-fed alignments - masks: list + masks: :class:`numpy.ndarray` List of boolean values indicating whether each re-fed alignments passed or failed the filter test @@ -396,20 +668,65 @@ def _get_mean_landmarks(self, landmarks: np.ndarray, masks: List[List[bool]]) -> :class:`numpy.ndarray` The final averaged landmarks """ - if not self.config["filter_refeed"]: - return landmarks.mean(axis=0).astype("float32") - - mask = np.array(masks) - if any(np.all(masked) for masked in mask.T): + if any(np.all(masked) for masked in masks.T): # hacky fix for faces which entirely failed the filter # We just unmask one value as it is junk anyway and will be discarded on output - for idx, masked in enumerate(mask.T): + for idx, masked in enumerate(masks.T): if np.all(masked): - mask[0, idx] = False + masks[0, idx] = False + + masks = np.broadcast_to(np.reshape(masks, (*landmarks.shape[:2], 1, 1)), + landmarks.shape) + return np.ma.array(landmarks, mask=masks).mean(axis=0).data.astype("float32") + + def _process_output_first_pass(self, subbatches: List[AlignerBatch]) -> Tuple[np.ndarray, + np.ndarray]: + """ Process the output from the aligner if this is the first or only pass. + + Parameters + ---------- + subbatches: list + List of sub-batch results for each re-feed performed + + Returns + ------- + landmarks: :class:`numpy.ndarray` + If re-align is not selected or if re-align has been selected but only on the final + output (ie: realign_reefeeds is ``False``) then the averaged batch of landmarks for all + re-feeds is returned. + If re-align_refeeds has been selected, then this will output each batch of re-feed + landmarks. + masks: :class:`numpy.ndarray` + Boolean mask corresponding to the re-fed landmarks output indicating any values which + should be filtered out prior to further processing + """ + masks = self._get_refeed_filter_masks(subbatches) + all_landmarks = np.array([sub.landmarks for sub in subbatches]) + + # re-align not selected or not filtering the re-feeds + if not self._re_align.do_refeeds: + retval = self._get_mean_landmarks(all_landmarks, masks) + return retval, masks + + # Re-align selected with filter re-feeds + return all_landmarks, masks + + def _process_output_second_pass(self, + subbatches: List[AlignerBatch], + masks: np.ndarray) -> np.ndarray: + """ Process the output from the aligner if this is the first or only pass. - mask = np.broadcast_to(np.reshape(mask, (*landmarks.shape[:2], 1, 1)), - landmarks.shape) - return np.ma.array(landmarks, mask=mask).mean(axis=0).data.astype("float32") + Parameters + ---------- + subbatches: list + List of sub-batch results for each re-aligned re-feed performed + masks: :class:`numpy.ndarray` + The original re-feed filter masks from the first pass + """ + self._re_align.process_output(subbatches, masks) + masks = self._get_refeed_filter_masks(subbatches, original_masks=masks) + all_landmarks = np.array([sub.landmarks for sub in subbatches]) + return self._get_mean_landmarks(all_landmarks, masks) def _process_output(self, batch: BatchType) -> AlignerBatch: """ Process the output from the aligner model multiple times based on the user selected @@ -429,37 +746,24 @@ def _process_output(self, batch: BatchType) -> AlignerBatch: The batch item with :attr:`landmarks` populated """ assert isinstance(batch, AlignerBatch) - landmark_list: List[np.ndarray] = [] - masks: List[List[bool]] = [] - for idx in range(self._re_feed + 1): - # Create a pseudo object that only populates the data, feed and prediction slots with - # the current re-feed iteration - subbatch = AlignerBatch(image=batch.image, - detected_faces=batch.detected_faces, - filename=batch.filename, - feed=batch.refeeds[idx], - prediction=batch.prediction[idx], - data=[batch.data[idx]]) - self.process_output(subbatch) - landmark_list.append(subbatch.landmarks) - - if self.config["filter_refeed"]: - fcs = [DetectedFace(landmarks_xy=lm) for lm in subbatch.landmarks.copy()] - min_sizes = [min(img.shape[:2]) for img in batch.image] - masks.append(self._filter.filtered_mask(fcs, min_sizes)) - - batch.landmarks = self._get_mean_landmarks(np.array(landmark_list), masks) + subbatches = self._process_refeeds(batch) + if batch.second_pass: + batch.landmarks = self._process_output_second_pass(subbatches, batch.second_pass_masks) + else: + landmarks, masks = self._process_output_first_pass(subbatches) + batch.landmarks = landmarks + batch.second_pass_masks = masks return batch # <<< FACE NORMALIZATION METHODS >>> # - def _normalize_faces(self, faces: List[np.ndarray]) -> List[np.ndarray]: + def _normalize_faces(self, faces: np.ndarray) -> np.ndarray: """ Normalizes the face for feeding into model The normalization method is dictated by the normalization command line argument Parameters ---------- faces: :class:`numpy.ndarray` - The faces to normalize + The batch of faces to normalize Returns ------- @@ -468,10 +772,10 @@ def _normalize_faces(self, faces: List[np.ndarray]) -> List[np.ndarray]: """ if self._normalize_method is None: return faces - logger.trace("Normalizing faces") # type:ignore + logger.trace("Normalizing faces") # type: ignore[attr-defined] meth = getattr(self, f"_normalize_{self._normalize_method.lower()}") - faces = [meth(face) for face in faces] - logger.trace("Normalized faces") # type:ignore + faces = np.array([meth(face) for face in faces]) + logger.trace("Normalized faces") # type: ignore[attr-defined] return faces @classmethod @@ -480,13 +784,13 @@ def _normalize_mean(cls, face: np.ndarray) -> np.ndarray: Parameters ---------- - faces: :class:`numpy.ndarray` - The faces to normalize + face: :class:`numpy.ndarray` + The face to normalize Returns ------- :class:`numpy.ndarray` - The normalized faces + The normalized face """ face = face / 255.0 for chan in range(3): @@ -501,13 +805,13 @@ def _normalize_hist(cls, face: np.ndarray) -> np.ndarray: Parameters ---------- - faces: :class:`numpy.ndarray` - The faces to normalize + face: :class:`numpy.ndarray` + The face to normalize Returns ------- :class:`numpy.ndarray` - The normalized faces + The normalized face """ for chan in range(3): face[:, :, chan] = cv2.equalizeHist(face[:, :, chan]) @@ -519,209 +823,15 @@ def _normalize_clahe(cls, face: np.ndarray) -> np.ndarray: Parameters ---------- - faces: :class:`numpy.ndarray` - The faces to normalize + face: :class:`numpy.ndarray` + The face to normalize Returns ------- :class:`numpy.ndarray` - The normalized faces + The normalized face """ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(4, 4)) for chan in range(3): face[:, :, chan] = clahe.apply(face[:, :, chan]) return face - - -class AlignedFilter(): - """ Applies filters on the output of the aligner - - Parameters - ---------- - feature_filter: bool - ``True`` to enable filter to check relative position of eyes/eyebrows and mouth. ``False`` - to disable. - min_scale: float - Filters out faces that have been aligned at below this value as a multiplier of the - minimum frame dimension. Set to ``0`` for off. - max_scale: float - Filters out faces that have been aligned at above this value as a multiplier of the - minimum frame dimension. Set to ``0`` for off. - distance: float - Filters out faces that are further than this distance from an "average" face. Set to - ``0`` for off. - roll: float - Filters out faces with a roll value outside of 0 +/- the value given here. Set to ``0`` - for off. - save_output: bool - ``True`` if the filtered faces should be kept as they are being saved. ``False`` if they - should be deleted - disable: bool, Optional - ``True`` to disable the filter regardless of config options. Default: ``False`` - """ - def __init__(self, - feature_filter: bool, - min_scale: float, - max_scale: float, - distance: float, - roll: float, - save_output: bool, - disable: bool = False) -> None: - logger.debug("Initializing %s: (feature_filter: %s, min_scale: %s, max_scale: %s, " - "distance: %s, roll, %s, save_output: %s, disable: %s)", - self.__class__.__name__, feature_filter, min_scale, max_scale, distance, roll, - save_output, disable) - self._features = feature_filter - self._min_scale = min_scale - self._max_scale = max_scale - self._distance = distance / 100. - self._roll = roll - self._save_output = save_output - self._active = not disable and (feature_filter or - max_scale > 0.0 or - min_scale > 0.0 or - distance > 0.0 or - roll > 0.0) - self._counts: Dict[str, int] = dict(features=0, - min_scale=0, - max_scale=0, - distance=0, - roll=0) - logger.debug("Initialized %s: ", self.__class__.__name__) - - def __call__(self, faces: List[DetectedFace], minimum_dimension: int - ) -> Tuple[List[DetectedFace], List[Optional[str]]]: - """ Apply the filter to the incoming batch - - Parameters - ---------- - faces: list - List of detected face objects to filter out on size - minimum_dimension: int - The minimum (height, width) of the original frame - - Returns - ------- - detected_faces: list - The filtered list of detected face objects, if saving filtered faces has not been - selected or the full list of detected faces - sub_folders: list - List of ``Nones`` if saving filtered faces has not been selected or list of ``Nones`` - and sub folder names corresponding the filtered face location - """ - sub_folders: List[Optional[str]] = [None for _ in range(len(faces))] - if not self._active: - return faces, sub_folders - - retval: List[DetectedFace] = [] - for idx, face in enumerate(faces): - aligned = AlignedFace(landmarks=face.landmarks_xy, centering="face") - - if self._features and aligned.relative_eye_mouth_position < 0.0: - self._counts["features"] += 1 - if self._save_output: - retval.append(face) - sub_folders[idx] = "_align_filt_features" - continue - - min_max = self._scale_test(aligned, minimum_dimension) - if min_max in ("min", "max"): - self._counts[f"{min_max}_scale"] += 1 - if self._save_output: - retval.append(face) - sub_folders[idx] = f"_align_filt_{min_max}_scale" - continue - - if 0.0 < self._distance < aligned.average_distance: - self._counts["distance"] += 1 - if self._save_output: - retval.append(face) - sub_folders[idx] = "_align_filt_distance" - continue - - if self._roll != 0.0 and not 0.0 < abs(aligned.pose.roll) < self._roll: - self._counts["roll"] += 1 - if self._save_output: - retval.append(face) - sub_folders[idx] = "_align_filt_roll" - continue - - retval.append(face) - return retval, sub_folders - - def _scale_test(self, - face: AlignedFace, - minimum_dimension: int) -> Optional[Literal["min", "max"]]: - """ Test if a face is below or above the min/max size thresholds. Returns as soon as a test - fails. - - Parameters - ---------- - face: :class:`~lib.aligned.AlignedFace` - The aligned face to test the original size of. - - minimum_dimension: int - The minimum (height, width) of the original frame - - Returns - ------- - "min", "max" or ``None`` - Returns min or max if the face failed the minimum or maximum test respectively. - ``None`` if all tests passed - """ - - if self._min_scale <= 0.0 and self._max_scale <= 0.0: - return None - - roi = face.original_roi.astype("int64") - size = ((roi[1][0] - roi[0][0]) ** 2 + (roi[1][1] - roi[0][1]) ** 2) ** 0.5 - - if self._min_scale > 0.0 and size < minimum_dimension * self._min_scale: - return "min" - - if self._max_scale > 0.0 and size > minimum_dimension * self._max_scale: - return "max" - - return None - - def filtered_mask(self, faces: List[DetectedFace], minimum_dimension: List[int]) -> List[bool]: - """ Obtain a list of boolean values for the given faces indicating whether they pass the - filter test. - - Parameters - ---------- - faces: list - List of detected face objects to test the filters for - minimum_dimension: list - The minimum (height, width) of the original frames that the faces come from - - Returns - ------- - list - List of bools corresponding to any of the input DetectedFace objects that passed a - test. ``False`` the face passed the test. ``True`` it failed - """ - retval = [True for _ in range(len(faces))] - for idx, (face, dim) in enumerate(zip(faces, minimum_dimension)): - aligned = AlignedFace(landmarks=face.landmarks_xy) - if self._features and aligned.relative_eye_mouth_position < 0.0: - continue - if self._scale_test(aligned, dim) is not None: - continue - if 0.0 < self._distance < aligned.average_distance: - continue - if self._roll != 0.0 and not 0.0 < abs(aligned.pose.roll) < self._roll: - continue - retval[idx] = False - - return retval - - def output_counts(self): - """ Output the counts of filtered items """ - if not self._active: - return - counts = [f"{key} ({getattr(self, f'_{key}'):.2f}): {count}" - for key, count in self._counts.items() - if count > 0] - if counts: - logger.info("Aligner filtered: (%s)", ", ".join(counts)) diff --git a/plugins/extract/align/_base/processing.py b/plugins/extract/align/_base/processing.py new file mode 100644 index 0000000000..e4c49a9a2a --- /dev/null +++ b/plugins/extract/align/_base/processing.py @@ -0,0 +1,493 @@ +#!/usr/bin/env python3 +""" Processing methods for aligner plugins """ +import logging +import sys + +from threading import Lock +from typing import Dict, List, Optional, Tuple, TYPE_CHECKING, Union + +import numpy as np + +from lib.align import AlignedFace + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + +if TYPE_CHECKING: + from lib.align import DetectedFace + from .aligner import AlignerBatch + from lib.align.aligned_face import CenteringType + +logger = logging.getLogger(__name__) + + +class AlignedFilter(): + """ Applies filters on the output of the aligner + + Parameters + ---------- + feature_filter: bool + ``True`` to enable filter to check relative position of eyes/eyebrows and mouth. ``False`` + to disable. + min_scale: float + Filters out faces that have been aligned at below this value as a multiplier of the + minimum frame dimension. Set to ``0`` for off. + max_scale: float + Filters out faces that have been aligned at above this value as a multiplier of the + minimum frame dimension. Set to ``0`` for off. + distance: float + Filters out faces that are further than this distance from an "average" face. Set to + ``0`` for off. + roll: float + Filters out faces with a roll value outside of 0 +/- the value given here. Set to ``0`` + for off. + save_output: bool + ``True`` if the filtered faces should be kept as they are being saved. ``False`` if they + should be deleted + disable: bool, Optional + ``True`` to disable the filter regardless of config options. Default: ``False`` + """ + def __init__(self, + feature_filter: bool, + min_scale: float, + max_scale: float, + distance: float, + roll: float, + save_output: bool, + disable: bool = False) -> None: + logger.debug("Initializing %s: (feature_filter: %s, min_scale: %s, max_scale: %s, " + "distance: %s, roll, %s, save_output: %s, disable: %s)", + self.__class__.__name__, feature_filter, min_scale, max_scale, distance, roll, + save_output, disable) + self._features = feature_filter + self._min_scale = min_scale + self._max_scale = max_scale + self._distance = distance / 100. + self._roll = roll + self._save_output = save_output + self._active = not disable and (feature_filter or + max_scale > 0.0 or + min_scale > 0.0 or + distance > 0.0 or + roll > 0.0) + self._counts: Dict[str, int] = dict(features=0, + min_scale=0, + max_scale=0, + distance=0, + roll=0) + logger.debug("Initialized %s: ", self.__class__.__name__) + + def _scale_test(self, + face: AlignedFace, + minimum_dimension: int) -> Optional[Literal["min", "max"]]: + """ Test if a face is below or above the min/max size thresholds. Returns as soon as a test + fails. + + Parameters + ---------- + face: :class:`~lib.aligned.AlignedFace` + The aligned face to test the original size of. + + minimum_dimension: int + The minimum (height, width) of the original frame + + Returns + ------- + "min", "max" or ``None`` + Returns min or max if the face failed the minimum or maximum test respectively. + ``None`` if all tests passed + """ + + if self._min_scale <= 0.0 and self._max_scale <= 0.0: + return None + + roi = face.original_roi.astype("int64") + size = ((roi[1][0] - roi[0][0]) ** 2 + (roi[1][1] - roi[0][1]) ** 2) ** 0.5 + + if self._min_scale > 0.0 and size < minimum_dimension * self._min_scale: + return "min" + + if self._max_scale > 0.0 and size > minimum_dimension * self._max_scale: + return "max" + + return None + + def _handle_filtered(self, + key: str, + face: "DetectedFace", + faces: List["DetectedFace"], + sub_folders: List[Optional[str]], + sub_folder_index: int) -> None: + """ Add the filtered item to the filter counts. + + If config option `save_filtered` has been enabled then add the face to the output faces + list and update the sub_folder list with the correct name for this face. + + Parameters + ---------- + key: str + The key to use for the filter counts dictionary and the sub_folder name + face: :class:`~lib.align.detected_face.DetectedFace` + The detected face object to be filtered out + faces: list + The list of faces that will be returned from the filter + sub_folders: list + List of sub folder names corresponding to the list of detected face objects + sub_folder_index: int + The index within the sub-folder list that the filtered face belongs to + """ + self._counts[key] += 1 + if not self._save_output: + return + + faces.append(face) + sub_folders[sub_folder_index] = f"_align_filt_{key}" + + def __call__(self, faces: List["DetectedFace"], minimum_dimension: int + ) -> Tuple[List["DetectedFace"], List[Optional[str]]]: + """ Apply the filter to the incoming batch + + Parameters + ---------- + faces: list + List of detected face objects to filter out on size + minimum_dimension: int + The minimum (height, width) of the original frame + + Returns + ------- + detected_faces: list + The filtered list of detected face objects, if saving filtered faces has not been + selected or the full list of detected faces + sub_folders: list + List of ``Nones`` if saving filtered faces has not been selected or list of ``Nones`` + and sub folder names corresponding the filtered face location + """ + sub_folders: List[Optional[str]] = [None for _ in range(len(faces))] + if not self._active: + return faces, sub_folders + + retval: List["DetectedFace"] = [] + for idx, face in enumerate(faces): + aligned = AlignedFace(landmarks=face.landmarks_xy, centering="face") + + if self._features and aligned.relative_eye_mouth_position < 0.0: + self._handle_filtered("features", face, retval, sub_folders, idx) + continue + + min_max = self._scale_test(aligned, minimum_dimension) + if min_max in ("min", "max"): + self._handle_filtered(f"{min_max}_scale", face, retval, sub_folders, idx) + continue + + if 0.0 < self._distance < aligned.average_distance: + self._handle_filtered("distance", face, retval, sub_folders, idx) + continue + + if self._roll != 0.0 and not 0.0 < abs(aligned.pose.roll) < self._roll: + self._handle_filtered("roll", face, retval, sub_folders, idx) + continue + + retval.append(face) + return retval, sub_folders + + def filtered_mask(self, + batch: "AlignerBatch", + skip: Optional[Union[np.ndarray, List[int]]] = None) -> np.ndarray: + """ Obtain a list of boolean values for the given batch indicating whether they pass the + filter test. + + Parameters + ---------- + batch: :class:`AlignerBatch` + The batch of face to obtain masks for + skip: list or :class:`numpy.ndarray`, optional + List or 1D numpy array of indices indicating faces that have already been filter + masked and so should not be filtered again. Values in these index positions will be + returned as ``True`` + + Returns + ------- + :class:`numpy.ndarray` + Boolean mask array corresponding to any of the input DetectedFace objects that passed a + test. ``False`` the face passed the test. ``True`` it failed + """ + skip = [] if skip is None else skip + retval = np.ones((len(batch.detected_faces), ), dtype="bool") + for idx, (landmarks, image) in enumerate(zip(batch.landmarks, batch.image)): + if idx in skip: + continue + face = AlignedFace(landmarks) + if self._features and face.relative_eye_mouth_position < 0.0: + continue + if self._scale_test(face, min(image.shape[:2])) is not None: + continue + if 0.0 < self._distance < face.average_distance: + continue + if self._roll != 0.0 and not 0.0 < abs(face.pose.roll) < self._roll: + continue + retval[idx] = False + return retval + + def output_counts(self): + """ Output the counts of filtered items """ + if not self._active: + return + counts = [f"{key} ({getattr(self, f'_{key}'):.2f}): {count}" + for key, count in self._counts.items() + if count > 0] + if counts: + logger.info("Aligner filtered: (%s)", ", ".join(counts)) + + +class ReAlign(): + """ Holds data and methods for 2nd pass re-aligns + + Parameters + ---------- + active: bool + ``True`` if re-alignment has been requested otherwise ``False`` + do_refeeds: bool + ``True`` if re-feeds should be re-aligned, ``False`` if just the final output of the + re-feeds should be aligned + do_filter: bool + ``True`` if aligner filtered out faces should not be re-aligned. ``False`` if all faces + should be re-aligned + """ + def __init__(self, active: bool, do_refeeds: bool, do_filter: bool) -> None: + logger.debug("Initializing %s: (active: %s, do_refeeds: %s, do_filter: %s)", + self.__class__.__name__, active, do_refeeds, do_filter) + self._active = active + self._do_refeeds = do_refeeds + self._do_filter = do_filter + self._centering: "CenteringType" = "face" + self._size = 0 + self._tracked_lock = Lock() + self._tracked_batchs: Dict[int, Dict[Literal["filtered_landmarks"], List[np.ndarray]]] = {} + # TODO. Probably does not need to be a list, just alignerbatch + self._queue_lock = Lock() + self._queued: List["AlignerBatch"] = [] + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def active(self) -> bool: + """bool: ``True`` if re_aligns have been selected otherwise ``False``""" + return self._active + + @property + def do_refeeds(self) -> bool: + """bool: ``True`` if re-aligning is active and re-aligning re-feeds has been selected + otherwise ``False``""" + return self._active and self._do_refeeds + + @property + def do_filter(self) -> bool: + """bool: ``True`` if re-aligning is active and faces which failed the aligner filter test + should not be re-aligned otherwise ``False``""" + return self._active and self._do_filter + + @property + def items_queued(self) -> bool: + """bool: ``True`` if re-align is active and items are queued for a 2nd pass otherwise + ``False`` """ + with self._queue_lock: + return self._active and bool(self._queued) + + @property + def items_tracked(self) -> bool: + """bool: ``True`` if items exist in the tracker so still need to be processed """ + with self._tracked_lock: + return bool(self._tracked_batchs) + + def set_input_size_and_centering(self, input_size: int, centering: "CenteringType") -> None: + """ Set the input size of the loaded plugin once the model has been loaded + + Parameters + ---------- + input_size: int + The input size, in pixels, of the aligner plugin + centering: ["face", "head" or "legacy"] + The centering to align the image at for re-aligning + """ + logger.debug("input_size: %s, centering: %s", input_size, centering) + self._size = input_size + self._centering = centering + + def track_batch(self, batch_id: int) -> None: + """ Add newly seen batch id from the aligner to the batch tracker, so that we can keep + track of whether there are still batches to be processed when the aligner hits 'EOF' + + Parameters + ---------- + batch_id: int + The batch id to add to batch tracking + """ + if not self._active: + return + logger.trace("Tracking batch id: %s", batch_id) # type: ignore[attr-defined] + with self._tracked_lock: + self._tracked_batchs[batch_id] = {} + + def untrack_batch(self, batch_id: int) -> None: + """ Remove the tracked batch from the tracker once the batch has been fully processed + + Parameters + ---------- + batch_id: int + The batch id to remove from batch tracking + """ + if not self._active: + return + logger.trace("Removing batch id from tracking: %s", batch_id) # type: ignore[attr-defined] + with self._tracked_lock: + del self._tracked_batchs[batch_id] + + def add_batch(self, batch: "AlignerBatch") -> None: + """ Add first pass alignments to the queue for picking up for re-alignment, update their + :attr:`second_pass` attribute to ``True`` and clear attributes not required. + + Parameters + ---------- + batch: :class:`AlignerBatch` + aligner batch to perform re-alignment on + """ + with self._queue_lock: + logger.trace("Queueing for second pass: %s", batch) # type: ignore[attr-defined] + batch.second_pass = True + batch.feed = np.array([]) + batch.prediction = np.array([]) + batch.refeeds = [] + batch.data = [] + self._queued.append(batch) + + def get_batch(self) -> "AlignerBatch": + """ Retrieve the next batch currently queued for re-alignment + + Returns + ------- + :class:`AlignerBatch` + The next :class:`AlignerBatch` for re-alignment + """ + with self._queue_lock: + retval = self._queued.pop(0) + logger.trace("Retrieving for second pass: %s", # type: ignore[attr-defined] + retval.filename) + return retval + + def process_batch(self, batch: "AlignerBatch") -> List[np.ndarray]: + """ Pre process a batch object for re-aligning through the aligner. + + Parameters + ---------- + batch: :class:`AlignerBatch` + aligner batch to perform pre-processing on + + Returns + ------- + list + List of UINT8 aligned faces batch for each selected refeed + """ + logger.trace("Processing batch: %s, landmarks: %s", # type: ignore[attr-defined] + batch.filename, [b.shape for b in batch.landmarks]) + retval: List[np.ndarray] = [] + filtered_landmarks: List[np.ndarray] = [] + for landmarks, masks in zip(batch.landmarks, batch.second_pass_masks): + if not np.all(masks): # At least one face has not already been filtered + aligned_faces = [AlignedFace(lms, + image=image, + size=self._size, + centering=self._centering) + for image, lms, msk in zip(batch.image, landmarks, masks) + if not msk] + faces = np.array([aligned.face for aligned in aligned_faces + if aligned.face is not None]) + retval.append(faces) + batch.data.append({"aligned_faces": aligned_faces}) + + if np.any(masks): + # Track the original landmarks for re-insertion on the other side + filtered_landmarks.append(landmarks[masks]) + + with self._tracked_lock: + self._tracked_batchs[batch.batch_id] = {"filtered_landmarks": filtered_landmarks} + batch.landmarks = np.array([]) # Clear the old landmarks + return retval + + def _transform_to_frame(self, batch: "AlignerBatch") -> np.ndarray: + """ Transform the predicted landmarks from the aligned face image back into frame + co-ordinates + + Parameters + ---------- + batch: :class:`AlignerBatch` + An aligner batch containing the aligned faces in the data field and the face + co-ordinate landmarks in the landmarks field + + Returns + ------- + :class:`numpy.ndarray` + The landmarks transformed to frame space + """ + faces: List[AlignedFace] = batch.data[0]["aligned_faces"] + retval = np.array([aligned.transform_points(landmarks, invert=True) + for landmarks, aligned in zip(batch.landmarks, faces)]) + logger.trace("Transformed points: original max: %s, " # type: ignore[attr-defined] + "new max: %s", batch.landmarks.max(), retval.max()) + return retval + + def _re_insert_filtered(self, batch: "AlignerBatch", masks: np.ndarray) -> np.ndarray: + """ Re-insert landmarks that were filtered out from the re-align process back into the + landmark results + + Parameters + ---------- + batch: :class:`AlignerBatch` + An aligner batch containing the aligned faces in the data field and the landmarks in + frame space in the landmarks field + masks: np.ndarray + The original filter masks for this batch + + Returns + ------- + :class:`numpy.ndarray` + The full batch of landmarks with filtered out values re-inserted + """ + if not np.any(masks): + logger.trace("No landmarks to re-insert: %s", masks) # type: ignore[attr-defined] + return batch.landmarks + + with self._tracked_lock: + filtered = self._tracked_batchs[batch.batch_id]["filtered_landmarks"].pop(0) + + if np.all(masks): + retval = filtered + else: + retval = np.empty((masks.shape[0], *filtered.shape[1:]), dtype=filtered.dtype) + retval[~masks] = batch.landmarks + retval[masks] = filtered + + logger.trace("Filtered re-inserted: old shape: %s, " # type: ignore[attr-defined] + "new shape: %s)", batch.landmarks.shape, retval.shape) + + return retval + + def process_output(self, subbatches: List["AlignerBatch"], batch_masks: np.ndarray) -> None: + """ Process the output from the re-align pass. + + - Transform landmarks from aligned face space to face space + - Re-insert faces that were filtered out from the re-align process back into the + landmarks list + + Parameters + ---------- + subbatches: list + List of sub-batch results for each re-aligned re-feed performed + batch_masks: :class:`numpy.ndarray` + The original re-feed filter masks from the first pass + """ + for batch, masks in zip(subbatches, batch_masks): + if not np.all(masks): + batch.landmarks = self._transform_to_frame(batch) + batch.landmarks = self._re_insert_filtered(batch, masks) diff --git a/plugins/extract/align/cv2_dnn.py b/plugins/extract/align/cv2_dnn.py index 3459e38c7c..9b883c2ff6 100644 --- a/plugins/extract/align/cv2_dnn.py +++ b/plugins/extract/align/cv2_dnn.py @@ -51,12 +51,28 @@ def __init__(self, **kwargs) -> None: self.vram = 0 # Doesn't use GPU self.vram_per_batch = 0 self.batchsize = 1 + self.realign_centering = "legacy" def init_model(self) -> None: """ Initialize CV2 DNN Detector Model""" self.model = cv2.dnn.readNetFromTensorflow(self.model_path) # pylint: disable=no-member self.model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) # pylint: disable=no-member + def faces_to_feed(self, faces: np.ndarray) -> np.ndarray: + """ Convert a batch of face images from UINT8 (0-255) to fp32 (0.0-255.0) + + Parameters + ---------- + faces: :class:`numpy.ndarray` + The batch of faces in UINT8 format + + Returns + ------- + class: `numpy.ndarray` + The batch of faces as fp32 + """ + return faces.astype("float32").transpose((0, 3, 1, 2)) + def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction @@ -71,10 +87,9 @@ def process_input(self, batch: BatchType) -> None: The batch item with the :attr:`feed` populated and any required :attr:`data` added """ assert isinstance(batch, AlignerBatch) - faces, roi, offsets = self.align_image(batch) - faces = self._normalize_faces(faces) + lfaces, roi, offsets = self.align_image(batch) + batch.feed = np.array(lfaces)[..., :3] batch.data.append(dict(roi=roi, offsets=offsets)) - batch.feed = np.array(faces, dtype="float32")[..., :3].transpose((0, 3, 1, 2)) def _get_box_and_offset(self, face: "DetectedFace") -> Tuple[List[int], int]: """Obtain the bounding box and offset from a detected face. @@ -274,8 +289,7 @@ def process_output(self, batch: BatchType) -> None: assert isinstance(batch, AlignerBatch) self.get_pts_from_predict(batch) - @classmethod - def get_pts_from_predict(cls, batch: AlignerBatch): + def get_pts_from_predict(self, batch: AlignerBatch): """ Get points from predictor and populates the :attr:`landmarks` property Parameters @@ -284,13 +298,16 @@ def get_pts_from_predict(cls, batch: AlignerBatch): The current batch from the model with :attr:`predictions` populated """ landmarks = [] - for prediction, roi, offset in zip(batch.prediction, - batch.data[0]["roi"], - batch.data[0]["offsets"]): - points = np.reshape(prediction, (-1, 2)) - points *= (roi[2] - roi[0]) - points[:, 0] += (roi[0] - offset[0]) - points[:, 1] += (roi[1] - offset[1]) - landmarks.append(points) - batch.landmarks = np.array(landmarks) + if batch.second_pass: + batch.landmarks = batch.prediction.reshape(self.batchsize, -1, 2) * self.input_size + else: + for prediction, roi, offset in zip(batch.prediction, + batch.data[0]["roi"], + batch.data[0]["offsets"]): + points = np.reshape(prediction, (-1, 2)) + points *= (roi[2] - roi[0]) + points[:, 0] += (roi[0] - offset[0]) + points[:, 1] += (roi[1] - offset[1]) + landmarks.append(points) + batch.landmarks = np.array(landmarks) logger.trace("Predicted Landmarks: %s", batch.landmarks) # type:ignore diff --git a/plugins/extract/align/fan.py b/plugins/extract/align/fan.py index 5a632f3aac..a5c7343c82 100644 --- a/plugins/extract/align/fan.py +++ b/plugins/extract/align/fan.py @@ -31,6 +31,7 @@ def __init__(self, **kwargs) -> None: self.vram = 2240 self.vram_warnings = 512 # Will run at this with warnings self.vram_per_batch = 64 + self.realign_centering = "head" self.batchsize: int = self.config["batch-size"] self.reference_scale = 200. / 195. @@ -48,6 +49,21 @@ def init_model(self) -> None: placeholder = np.zeros(placeholder_shape, dtype="float32") self.model.predict(placeholder) + def faces_to_feed(self, faces: np.ndarray) -> np.ndarray: + """ Convert a batch of face images from UINT8 (0-255) to fp32 (0.0-1.0) + + Parameters + ---------- + faces: :class:`numpy.ndarray` + The batch of faces in UINT8 format + + Returns + ------- + class: `numpy.ndarray` + The batch of faces as fp32 in 0.0 to 1.0 range + """ + return faces.astype("float32") / 255. + def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction @@ -59,11 +75,9 @@ def process_input(self, batch: BatchType) -> None: assert isinstance(batch, AlignerBatch) logger.trace("Aligning faces around center") # type:ignore center_scale = self.get_center_scale(batch.detected_faces) - faces = self.crop(batch, center_scale) - logger.trace("Aligned image around center") # type:ignore - faces = self._normalize_faces(faces) + batch.feed = np.array(self.crop(batch, center_scale))[..., :3] batch.data.append(dict(center_scale=center_scale)) - batch.feed = np.array(faces, dtype="float32")[..., :3] / 255.0 + logger.trace("Aligned image around center") # type:ignore def get_center_scale(self, detected_faces: List["DetectedFace"]) -> np.ndarray: """ Get the center and set scale of bounding box @@ -115,7 +129,7 @@ def _crop_image(self, new_dim = (bottom_right_height - top_left_height, bottom_right_width - top_left_width, 3 if image.ndim > 2 else 1) - new_img = np.empty(new_dim, dtype=np.uint8) + new_img = np.zeros(new_dim, dtype=np.uint8) new_x = slice(max(0, -top_left_width), min(bottom_right_width, image.shape[1]) - top_left_width) @@ -256,7 +270,10 @@ def get_pts_from_predict(self, batch: AlignerBatch) -> None: subpixel_landmarks[:, :, 0] = indices[1] + np.sign(x_subpixel_shift) * 0.25 + 0.5 subpixel_landmarks[:, :, 1] = indices[0] + np.sign(y_subpixel_shift) * 0.25 + 0.5 - batch.landmarks = self.transform(subpixel_landmarks, - batch.data[0]["center_scale"], - resolution) + if batch.second_pass: # Transformation handled by plugin parent for re-aligned faces + batch.landmarks = subpixel_landmarks[..., :2] * 4. + else: + batch.landmarks = self.transform(subpixel_landmarks, + batch.data[0]["center_scale"], + resolution) logger.trace("Obtained points from prediction: %s", batch.landmarks) # type:ignore diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 3e4e9534a3..9f310211db 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -90,6 +90,9 @@ class Extractor(): re_feed: int The number of times to re-feed a slightly adjusted bounding box into the aligner. Default: `0` + re_align: bool, optional + ``True`` to obtain landmarks by passing the initially aligned face back through the + aligner. Default ``False`` disable_filter: bool, optional Disable all aligner filters regardless of config option. Default: ``False`` @@ -111,13 +114,14 @@ def __init__(self, min_size: int = 0, normalize_method: Optional[Literal["none", "clahe", "hist", "mean"]] = None, re_feed: int = 0, + re_align: bool = False, disable_filter: bool = False) -> None: logger.debug("Initializing %s: (detector: %s, aligner: %s, masker: %s, recognition: %s, " "configfile: %s, multiprocess: %s, exclude_gpus: %s, rotate_images: %s, " - "min_size: %s, normalize_method: %s, re_feed: %s, disable_filter: %s, )", - self.__class__.__name__, detector, aligner, masker, recognition, configfile, - multiprocess, exclude_gpus, rotate_images, min_size, normalize_method, - re_feed, disable_filter) + "min_size: %s, normalize_method: %s, re_feed: %s, re_align: %s, " + "disable_filter: %s)", self.__class__.__name__, detector, aligner, masker, + recognition, configfile, multiprocess, exclude_gpus, rotate_images, min_size, + normalize_method, re_feed, re_align, disable_filter) self._instance = _get_instance() maskers = [cast(Optional[str], masker)] if not isinstance(masker, list) else cast(List[Optional[str]], masker) @@ -134,6 +138,7 @@ def __init__(self, configfile, normalize_method, re_feed, + re_align, disable_filter) self._recognition = self._load_recognition(recognition, configfile) self._mask = [self._load_mask(mask, configfile) for mask in maskers] @@ -581,6 +586,7 @@ def _load_align(self, configfile: Optional[str], normalize_method: Optional[Literal["none", "clahe", "hist", "mean"]], re_feed: int, + re_align: bool, disable_filter: bool) -> Optional["Aligner"]: """ Set global arguments and load aligner plugin @@ -594,6 +600,9 @@ def _load_align(self, Optional normalization method to use re_feed: int The number of times to adjust the image and re-feed to get an average score + re_align: bool + ``True`` to obtain landmarks by passing the initially aligned face back through the + aligner. disable_filter: bool Disable all aligner filters regardless of config option @@ -610,6 +619,7 @@ def _load_align(self, configfile=configfile, normalize_method=normalize_method, re_feed=re_feed, + re_align=re_align, disable_filter=disable_filter, instance=self._instance) return plugin diff --git a/scripts/extract.py b/scripts/extract.py index 283bbd15d0..4e9c189d2d 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -67,7 +67,8 @@ def __init__(self, arguments: Namespace) -> None: rotate_images=self._args.rotate_images, min_size=self._args.min_size, normalize_method=normalization, - re_feed=self._args.re_feed) + re_feed=self._args.re_feed, + re_align=self._args.re_align) self._filter = Filter(self._args.ref_threshold, self._args.filter, self._args.nfilter, diff --git a/setup.cfg b/setup.cfg index 31ffe69390..ebcf0bc849 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,6 +23,8 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-numexpr.*] ignore_missing_imports = True +[mypy-numpy.core._multiarray_umath.*] +ignore_missing_imports = True [mypy-pexpect.*] ignore_missing_imports = True [mypy-PIL.*] From 2faef58c5f7956e464a4af04f5ca6ba5634bf4a5 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 22 Nov 2022 17:34:44 +0000 Subject: [PATCH 771/981] Bugfix - Extract - re-align with identity filtering enabled --- plugins/extract/align/_base/aligner.py | 3 ++- plugins/extract/align/fan.py | 25 +++++++++++++------------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/plugins/extract/align/_base/aligner.py b/plugins/extract/align/_base/aligner.py index 64a85de713..caac79011f 100644 --- a/plugins/extract/align/_base/aligner.py +++ b/plugins/extract/align/_base/aligner.py @@ -237,6 +237,7 @@ def _handle_realigns(self, queue: "Queue") -> Optional[Tuple[bool, AlignerBatch] exhausted = True logger.debug("All items processed. Returning empty batch") self._filter.output_counts() + self._eof_seen = False # Reset for plugin re-use return exhausted, AlignerBatch(batch_id=-1) return None @@ -288,7 +289,7 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, AlignerBatch]: if item == "EOF": logger.debug("EOF received") self._eof_seen = True - exhausted = not self._re_align.items_tracked + exhausted = not self._re_align.active break # Put frames with no faces or are already aligned into the out queue diff --git a/plugins/extract/align/fan.py b/plugins/extract/align/fan.py index a5c7343c82..5a12610acf 100644 --- a/plugins/extract/align/fan.py +++ b/plugins/extract/align/fan.py @@ -73,11 +73,11 @@ def process_input(self, batch: BatchType) -> None: The current batch to process input for """ assert isinstance(batch, AlignerBatch) - logger.trace("Aligning faces around center") # type:ignore + logger.trace("Aligning faces around center") # type:ignore[attr-defined] center_scale = self.get_center_scale(batch.detected_faces) batch.feed = np.array(self.crop(batch, center_scale))[..., :3] batch.data.append(dict(center_scale=center_scale)) - logger.trace("Aligned image around center") # type:ignore + logger.trace("Aligned image around center") # type:ignore[attr-defined] def get_center_scale(self, detected_faces: List["DetectedFace"]) -> np.ndarray: """ Get the center and set scale of bounding box @@ -92,7 +92,7 @@ def get_center_scale(self, detected_faces: List["DetectedFace"]) -> np.ndarray: :class:`numpy.ndarray` The center and scale of the bounding box """ - logger.trace("Calculating center and scale") # type:ignore + logger.trace("Calculating center and scale") # type:ignore[attr-defined] center_scale = np.empty((len(detected_faces), 68, 3), dtype='float32') for index, face in enumerate(detected_faces): x_center = (cast(int, face.left) + face.right) / 2.0 @@ -101,7 +101,7 @@ def get_center_scale(self, detected_faces: List["DetectedFace"]) -> np.ndarray: center_scale[index, :, 0] = np.full(68, x_center, dtype='float32') center_scale[index, :, 1] = np.full(68, y_center, dtype='float32') center_scale[index, :, 2] = np.full(68, scale, dtype='float32') - logger.trace("Calculated center and scale: %s", center_scale) # type:ignore + logger.trace("Calculated center and scale: %s", center_scale) # type:ignore[attr-defined] return center_scale def _crop_image(self, @@ -159,7 +159,7 @@ def crop(self, batch: AlignerBatch, center_scale: np.ndarray) -> List[np.ndarray list List of cropped images for the batch """ - logger.debug("Cropping images") + logger.trace("Cropping images") # type:ignore[attr-defined] batch_shape = center_scale.shape[:2] resolutions = np.full(batch_shape, self.input_size, dtype='float32') matrix_ones = np.ones(batch_shape + (3,), dtype='float32') @@ -171,7 +171,7 @@ def crop(self, batch: AlignerBatch, center_scale: np.ndarray) -> List[np.ndarray # TODO second pass .. convert to matrix new_images = [self._crop_image(image, top_left, bottom_right) for image, top_left, bottom_right in zip(batch.image, upper_left, bot_right)] - logger.trace("Cropped images") # type:ignore + logger.trace("Cropped images") # type:ignore[attr-defined] return new_images @classmethod @@ -190,7 +190,7 @@ def transform(cls, resolutions: :class:`numpy.ndarray` The resolutions """ - logger.debug("Transforming Points") + logger.trace("Transforming Points") # type:ignore[attr-defined] num_images, num_landmarks = points.shape[:2] transform_matrix = np.eye(3, dtype='float32') transform_matrix = np.repeat(transform_matrix[None, :], num_landmarks, axis=0) @@ -203,7 +203,7 @@ def transform(cls, transform_matrix[:, :, 1, 2] = translations[:, :, 1] # y translation new_points = np.einsum('abij, abj -> abi', transform_matrix, points, optimize='greedy') retval = new_points[:, :, :2].astype('float32') - logger.trace("Transformed Points: %s", retval) # type:ignore + logger.trace("Transformed Points: %s", retval) # type:ignore[attr-defined] return retval def predict(self, feed: np.ndarray) -> np.ndarray: @@ -219,11 +219,11 @@ def predict(self, feed: np.ndarray) -> np.ndarray: :class:`numpy.ndarray` The predictions from the aligner """ - logger.trace("Predicting Landmarks") # type:ignore + logger.trace("Predicting Landmarks") # type:ignore[attr-defined] # TODO Remove lazy transpose and change points from predict to use the correct # order retval = self.model.predict(feed)[-1].transpose(0, 3, 1, 2) - logger.trace(retval.shape) # type:ignore + logger.trace(retval.shape) # type:ignore[attr-defined] return retval def process_output(self, batch: BatchType) -> None: @@ -246,7 +246,7 @@ def get_pts_from_predict(self, batch: AlignerBatch) -> None: batch: :class:`AlignerBatch` The current batch from the model with :attr:`predictions` populated """ - logger.trace("Obtain points from prediction") # type:ignore + logger.trace("Obtain points from prediction") # type:ignore[attr-defined] num_images, num_landmarks = batch.prediction.shape[:2] image_slice = np.repeat(np.arange(num_images)[:, None], num_landmarks, axis=1) landmark_slice = np.repeat(np.arange(num_landmarks)[None, :], num_images, axis=0) @@ -276,4 +276,5 @@ def get_pts_from_predict(self, batch: AlignerBatch) -> None: batch.landmarks = self.transform(subpixel_landmarks, batch.data[0]["center_scale"], resolution) - logger.trace("Obtained points from prediction: %s", batch.landmarks) # type:ignore + logger.trace("Obtained points from prediction: %s", # type:ignore[attr-defined] + batch.landmarks) From 01b03209dddd071a0f0a602380a9512c507e1879 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 15 Dec 2022 22:58:05 +0000 Subject: [PATCH 772/981] Update _requirements_base.txt Update requirements.txt - pin matplotlib --- requirements/_requirements_base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index 581c0c54db..f4464226b6 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -6,7 +6,7 @@ pillow>=9.2.0 scikit-learn==1.0.2; python_version < '3.9' # AMD needs version 1.0.2 and 1.1.0 not available in Python 3.7 scikit-learn>=1.1.0; python_version >= '3.9' fastcluster>=1.2.6 -matplotlib>=3.5.1 +matplotlib>=3.5.1,<3.6.0 imageio>=2.19.3 imageio-ffmpeg>=0.4.7 ffmpy>=0.3.0 From 48c886b3dce3d3117ad16edaf35c8abd28dc51f5 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 18 Dec 2022 19:02:17 +0000 Subject: [PATCH 773/981] Allow decoding errors --- lib/gpu_stats/amd.py | 24 ++++++++++------- lib/gui/menu.py | 14 ++++++---- lib/image.py | 9 ++++--- lib/keypress.py | 4 +-- lib/serializer.py | 28 +++++++------------- lib/sysinfo.py | 63 ++++++++++++++++++++++---------------------- setup.py | 4 +-- 7 files changed, 74 insertions(+), 72 deletions(-) diff --git a/lib/gpu_stats/amd.py b/lib/gpu_stats/amd.py index e927c6bc3a..bddeae8584 100644 --- a/lib/gpu_stats/amd.py +++ b/lib/gpu_stats/amd.py @@ -85,7 +85,7 @@ def active_devices(self) -> List[int]: @property def _plaid_ids(self) -> List[str]: """ list: The device identification for each GPU device that PlaidML has discovered. """ - return [device.id.decode("utf-8") for device in self._all_devices] + return [device.id.decode("utf-8", errors="replace") for device in self._all_devices] @property def _experimental_indices(self) -> List[int]: @@ -186,7 +186,9 @@ def _get_supported_devices(self) -> List[plaidml._DeviceConfig]: supported = [d for d in devices if d.details - and json.loads(d.details.decode("utf-8")).get("type", "cpu").lower() == "gpu"] + and json.loads( + d.details.decode("utf-8", + errors="replace")).get("type", "cpu").lower() == "gpu"] self._log("debug", f"Obtained supported devices: {supported}") return supported @@ -206,7 +208,9 @@ def _get_all_devices(self) -> List[plaidml._DeviceConfig]: experi = [d for d in devices if d.details - and json.loads(d.details.decode("utf-8")).get("type", "cpu").lower() == "gpu"] + and json.loads( + d.details.decode("utf-8", + errors="replace")).get("type", "cpu").lower() == "gpu"] self._log("debug", f"Obtained experimental Devices: {experi}") @@ -240,7 +244,7 @@ def _get_fallback_devices(self) -> List[plaidml._DeviceConfig]: raise RuntimeError("No valid devices could be found for plaidML.") self._log("warning", f"PlaidML could not find a GPU. Falling back to: " - f"{[d.id.decode('utf-8') for d in devices]}") + f"{[d.id.decode('utf-8', errors='replace') for d in devices]}") return devices def _get_device_details(self) -> List[dict]: @@ -254,10 +258,10 @@ def _get_device_details(self) -> List[dict]: details = [] for dev in self._all_devices: if dev.details: - details.append(json.loads(dev.details.decode("utf-8"))) + details.append(json.loads(dev.details.decode("utf-8", errors="replace"))) else: - details.append(dict(vendor=dev.id.decode("utf-8"), - name=dev.description.decode("utf-8"), + details.append(dict(vendor=dev.id.decode("utf-8", errors="replace"), + name=dev.description.decode("utf-8", errors="replace"), globalMemSize=4 * 1024 * 1024 * 1024)) # 4GB dummy ram self._log("debug", f"Obtained Device details: {details}") return details @@ -284,11 +288,11 @@ def _select_largest_gpu(self) -> None: self._log("error", "Please run `plaidml-setup` to set up your GPU.") sys.exit(1) - max_vram = max([self._all_vram[idx] for idx in indices]) + max_vram = max(self._all_vram[idx] for idx in indices) self._log("debug", f"Max VRAM: {max_vram}") - gpu_idx = min([idx for idx, vram in enumerate(self._all_vram) - if vram == max_vram and idx in indices]) + gpu_idx = min(idx for idx, vram in enumerate(self._all_vram) + if vram == max_vram and idx in indices) self._log("debug", f"GPU IDX: {gpu_idx}") selected_gpu = self._plaid_ids[gpu_idx] diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 6cbc2d2fe9..019010c880 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -268,9 +268,11 @@ def _get_branches(): retcode = cmd.poll() if retcode != 0: logger.debug("Unable to list git branches. return code: %s, message: %s", - retcode, stdout.decode().strip().replace("\n", " - ")) + retcode, + stdout.decode(locale.getpreferredencoding(), + errors="replace").strip().replace("\n", " - ")) return None - return stdout.decode(locale.getpreferredencoding()) + return stdout.decode(locale.getpreferredencoding(), errors="replace") @staticmethod def _filter_branches(stdout): @@ -321,7 +323,9 @@ def _switch_branch(branch): retcode = cmd.poll() if retcode != 0: logger.error("Unable to switch branch. return code: %s, message: %s", - retcode, stdout.decode().strip().replace("\n", " - ")) + retcode, + stdout.decode(locale.getdefaultlocale(), + errors="replace").strip().replace("\n", " - ")) return logger.info("Succesfully switched to '%s'. You may want to check for updates to make sure " "that you have the latest code.", branch) @@ -402,7 +406,7 @@ def check_for_updates(encoding, check=False): msg = ("Git is not installed or you are not running a cloned repo. " "Unable to check for updates") else: - chk = stdout.decode(encoding).splitlines() + chk = stdout.decode(encoding, errors="replace").splitlines() for line in chk: if line.lower().startswith("your branch is ahead"): msg = "Your branch is ahead of the remote repo. Not updating" @@ -434,7 +438,7 @@ def do_update(encoding): bufsize=1, cwd=_WORKING_DIR) as cmd: while True: - output = cmd.stdout.readline().decode(encoding) + output = cmd.stdout.readline().decode(encoding, errors="replace") if output == "" and cmd.poll() is not None: break if output: diff --git a/lib/image.py b/lib/image.py index 36c60b8d2a..0cfe9eeb06 100644 --- a/lib/image.py +++ b/lib/image.py @@ -433,10 +433,11 @@ def read_image_meta(filename): elif field == b"iTXt": keyword, value = infile.read(length).split(b"\0", 1) if keyword == b"faceswap": - retval["itxt"] = literal_eval(value[4:].decode("utf-8")) + retval["itxt"] = literal_eval(value[4:].decode("utf-8", errors="replace")) break else: - logger.trace("Skipping iTXt chunk: '%s'", keyword.decode("latin-1", "ignore")) + logger.trace("Skipping iTXt chunk: '%s'", keyword.decode("latin-1", + errors="ignore")) length = 0 # Reset marker for next chunk infile.seek(length + 4, 1) logger.trace("filename: %s, metadata: %s", filename, retval) @@ -645,9 +646,9 @@ def png_read_meta(png): pointer += 8 keyword, value = png[pointer:pointer + length].split(b"\0", 1) if keyword == b"faceswap": - retval = literal_eval(value[4:].decode("utf-8")) + retval = literal_eval(value[4:].decode("utf-8", errors="ignore")) break - logger.trace("Skipping iTXt chunk: '%s'", keyword.decode("latin-1", "ignore")) + logger.trace("Skipping iTXt chunk: '%s'", keyword.decode("latin-1", errors="ignore")) pointer += length + 4 return retval diff --git a/lib/keypress.py b/lib/keypress.py index 55c4450a5b..98d1872005 100644 --- a/lib/keypress.py +++ b/lib/keypress.py @@ -62,7 +62,7 @@ def getch(self): if (self.is_gui or not sys.stdout.isatty()) and os.name != "nt": return None if os.name == "nt": - return msvcrt.getch().decode("utf-8") + return msvcrt.getch().decode("utf-8", errors="replace") return sys.stdin.read(1) def getarrow(self): @@ -83,7 +83,7 @@ def getarrow(self): char = sys.stdin.read(3)[2] vals = [65, 67, 66, 68] - return vals.index(ord(char.decode("utf-8"))) + return vals.index(ord(char.decode("utf-8", errors="replace"))) def kbhit(self): """ Returns True if keyboard character was hit, False otherwise. """ diff --git a/lib/serializer.py b/lib/serializer.py index d4d5ac57ca..4300e95f45 100644 --- a/lib/serializer.py +++ b/lib/serializer.py @@ -171,13 +171,11 @@ def unmarshal(self, serialized_data): logger.debug("returned data type: %s", type(retval)) return retval - @classmethod - def _marshal(cls, data): + def _marshal(self, data): """ Override for serializer specific marshalling """ raise NotImplementedError() - @classmethod - def _unmarshal(cls, data): + def _unmarshal(self, data): """ Override for serializer specific unmarshalling """ raise NotImplementedError() @@ -188,13 +186,11 @@ def __init__(self): super().__init__() self._file_extension = "yml" - @classmethod - def _marshal(cls, data): + def _marshal(self, data): return yaml.dump(data, default_flow_style=False).encode("utf-8") - @classmethod - def _unmarshal(cls, data): - return yaml.load(data.decode("utf-8"), Loader=yaml.FullLoader) + def _unmarshal(self, data): + return yaml.load(data.decode("utf-8", errors="replace"), Loader=yaml.FullLoader) class _JSONSerializer(Serializer): @@ -203,13 +199,11 @@ def __init__(self): super().__init__() self._file_extension = "json" - @classmethod - def _marshal(cls, data): + def _marshal(self, data): return json.dumps(data, indent=2).encode("utf-8") - @classmethod - def _unmarshal(cls, data): - return json.loads(data.decode("utf-8")) + def _unmarshal(self, data): + return json.loads(data.decode("utf-8", errors="replace")) class _PickleSerializer(Serializer): @@ -218,12 +212,10 @@ def __init__(self): super().__init__() self._file_extension = "pickle" - @classmethod - def _marshal(cls, data): + def _marshal(self, data): return pickle.dumps(data) - @classmethod - def _unmarshal(cls, data): + def _unmarshal(self, data): return pickle.loads(data) diff --git a/lib/sysinfo.py b/lib/sysinfo.py index e0ef06fbfe..1e6c12742b 100644 --- a/lib/sysinfo.py +++ b/lib/sysinfo.py @@ -95,9 +95,8 @@ def _fs_command(self): @property def _installed_pip(self): """ str: The list of installed pip packages within Faceswap's scope. """ - pip = Popen("{} -m pip freeze".format(sys.executable), - shell=True, stdout=PIPE) - installed = pip.communicate()[0].decode().splitlines() + with Popen(f"{sys.executable} -m pip freeze", shell=True, stdout=PIPE) as pip: + installed = pip.communicate()[0].decode(self._encoding, errors="replace").splitlines() return "\n".join(installed) @property @@ -105,11 +104,11 @@ def _installed_conda(self): """ str: The list of installed Conda packages within Faceswap's scope. """ if not self._is_conda: return None - conda = Popen("conda list", shell=True, stdout=PIPE, stderr=PIPE) - stdout, stderr = conda.communicate() + with Popen("conda list", shell=True, stdout=PIPE, stderr=PIPE) as conda: + stdout, stderr = conda.communicate() if stderr: return "Could not get package list" - installed = stdout.decode().splitlines() + installed = stdout.decode(self._encoding, errors="replace").splitlines() return "\n".join(installed) @property @@ -117,32 +116,33 @@ def _conda_version(self): """ str: The installed version of Conda, or `N/A` if Conda is not installed. """ if not self._is_conda: return "N/A" - conda = Popen("conda --version", shell=True, stdout=PIPE, stderr=PIPE) - stdout, stderr = conda.communicate() + with Popen("conda --version", shell=True, stdout=PIPE, stderr=PIPE) as conda: + stdout, stderr = conda.communicate() if stderr: return "Conda is used, but version not found" - version = stdout.decode().splitlines() + version = stdout.decode(self._encoding, errors="replace").splitlines() return "\n".join(version) @property def _git_branch(self): """ str: The git branch that is currently being used to execute Faceswap. """ - git = Popen("git status", shell=True, stdout=PIPE, stderr=PIPE) - stdout, stderr = git.communicate() + with Popen("git status", shell=True, stdout=PIPE, stderr=PIPE) as git: + stdout, stderr = git.communicate() if stderr: return "Not Found" - branch = stdout.decode().splitlines()[0].replace("On branch ", "") + branch = stdout.decode(self._encoding, + errors="replace").splitlines()[0].replace("On branch ", "") return branch @property def _git_commits(self): """ str: The last 5 git commits for the currently running Faceswap. """ - git = Popen("git log --pretty=oneline --abbrev-commit -n 5", - shell=True, stdout=PIPE, stderr=PIPE) - stdout, stderr = git.communicate() + with Popen("git log --pretty=oneline --abbrev-commit -n 5", + shell=True, stdout=PIPE, stderr=PIPE) as git: + stdout, stderr = git.communicate() if stderr: return "Not Found" - commits = stdout.decode().splitlines() + commits = stdout.decode(self._encoding, errors="replace").splitlines() return ". ".join(commits) @property @@ -193,14 +193,14 @@ def full_info(self): "gpu_cuda": self._cuda_version, "gpu_cudnn": self._cudnn_version, "gpu_driver": self._gpu["driver"], - "gpu_devices": ", ".join(["GPU_{}: {}".format(idx, device) + "gpu_devices": ", ".join([f"GPU_{idx}: {device}" for idx, device in enumerate(self._gpu["devices"])]), - "gpu_vram": ", ".join(["GPU_{}: {}MB".format(idx, int(vram)) + "gpu_vram": ", ".join([f"GPU_{idx}: {int(vram)}MB" for idx, vram in enumerate(self._gpu["vram"])]), - "gpu_devices_active": ", ".join(["GPU_{}".format(idx) + "gpu_devices_active": ", ".join([f"GPU_{idx}" for idx in self._gpu["devices_active"]])} for key in sorted(sys_info.keys()): - retval += ("{0: <20} {1}\n".format(key + ":", sys_info[key])) + retval += (f"{key + ':':<20} {sys_info[key]}\n") retval += "\n=============== Pip Packages ===============\n" retval += self._installed_pip if self._is_conda: @@ -219,11 +219,11 @@ def _format_ram(self): str The total, available, used and free RAM displayed in Megabytes """ - retval = list() + retval = [] for name in ("total", "available", "used", "free"): - value = getattr(self, "_ram_{}".format(name)) + value = getattr(self, f"_ram_{name}") value = int(value / (1024 * 1024)) - retval.append("{}: {}MB".format(name.capitalize(), value)) + retval.append(f"{name.capitalize()}: {value}MB") return ", ".join(retval) @@ -241,7 +241,8 @@ def get_sysinfo(): try: retval = _SysInfo().full_info() except Exception as err: # pylint: disable=broad-except - retval = "Exception occured trying to retrieve sysinfo: {}".format(err) + retval = f"Exception occured trying to retrieve sysinfo: {str(err)}" + raise return retval @@ -284,7 +285,7 @@ def _parse_configs(self, config_files): for cfile in config_files: fname = os.path.basename(cfile) ext = os.path.splitext(cfile)[1] - formatted += "\n--------- {} ---------\n".format(fname) + formatted += f"\n--------- {fname} ---------\n" if ext == ".ini": formatted += self._parse_ini(cfile) elif fname == ".faceswap": @@ -305,14 +306,14 @@ def _parse_ini(self, config_file): The current configuration in the config file formatted in a human readable format """ formatted = "" - with open(config_file, "r") as cfile: + with open(config_file, "r", encoding="utf-8", errors="replace") as cfile: for line in cfile.readlines(): line = line.strip() if line.startswith("#") or not line: continue item = line.split("=") if len(item) == 1: - formatted += "\n{}\n".format(item[0].strip()) + formatted += f"\n{item[0].strip()}\n" else: formatted += self._format_text(item[0], item[1]) return formatted @@ -331,7 +332,7 @@ def _parse_json(self, config_file): The current configuration in the config file formatted as a python dictionary """ formatted = "" - with open(config_file, "r") as cfile: + with open(config_file, "r", encoding="utf-8", errors="replace") as cfile: conf_dict = json.load(cfile) for key in sorted(conf_dict.keys()): formatted += self._format_text(key, conf_dict[key]) @@ -353,7 +354,7 @@ def _format_text(key, value): str The formatted key value pair for display """ - return "{0: <25} {1}\n".format(key.strip() + ":", value.strip()) + return f"{key.strip() + ':':<25} {value.strip()}\n" class _State(): # pylint:disable=too-few-public-methods @@ -395,12 +396,12 @@ def _get_state_file(self): """ if not self._is_training or self._model_dir is None or self._trainer is None: return "" - fname = os.path.join(self._model_dir, "{}_state.json".format(self._trainer)) + fname = os.path.join(self._model_dir, f"{self._trainer}_state.json") if not os.path.isfile(fname): return "" retval = "\n\n=============== State File =================\n" - with open(fname, "r") as sfile: + with open(fname, "r", encoding="utf-8", errors="replace") as sfile: retval += sfile.read() return retval diff --git a/setup.py b/setup.py index 6505895fab..fc5395683d 100755 --- a/setup.py +++ b/setup.py @@ -254,7 +254,7 @@ def get_installed_packages(self) -> Dict[str, str]: """ Get currently installed packages """ installed_packages = {} with Popen(f"\"{sys.executable}\" -m pip freeze --local", shell=True, stdout=PIPE) as chk: - installed = chk.communicate()[0].decode(self.encoding).splitlines() + installed = chk.communicate()[0].decode(self.encoding, errors="ignore").splitlines() for pkg in installed: if "==" not in pkg: @@ -574,7 +574,7 @@ def _cuda_check(self) -> None: stdout, stderr = chk.communicate() if not stderr: version = re.search(r".*release (?P\d+\.\d+)", - stdout.decode(locale.getpreferredencoding())) + stdout.decode(locale.getpreferredencoding(), errors="ignore")) if version is not None: self.cuda_version = version.groupdict().get("cuda", None) locate = "where" if self._os == "windows" else "which" From ac6bda7f00b9a402b6bac8729dd43e7785c60fa3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 19 Dec 2022 11:00:09 +0000 Subject: [PATCH 774/981] bugfix: Mask tool, fix faces input type --- tools/mask/mask.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 4202663b4e..e0a33d5238 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -3,7 +3,7 @@ import logging import os import sys -from typing import Any, cast, Dict, List, Optional, Tuple, TYPE_CHECKING, Union +from typing import cast, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 import numpy as np @@ -19,7 +19,7 @@ if TYPE_CHECKING: from argparse import Namespace from lib.align.aligned_face import CenteringType - from lib.align.alignments import AlignmentFileDict, PNGHeaderDict, PNGHeaderSourceDict + from lib.align.alignments import AlignmentFileDict, PNGHeaderDict from lib.queue_manager import EventQueue logger = logging.getLogger(__name__) # pylint:disable=invalid-name @@ -234,7 +234,7 @@ def _input_faces(self, *args: Union[tuple, Tuple["EventQueue"]]) -> None: logger.warning("Legacy face not found in alignments file. This face has not " "been updated: '%s'", filename) continue - if not metadata.get("source_frame_dims"): + 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 " From 84ae6006bb420ce263c3ef0adeebdc37f7b9379a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 19 Dec 2022 11:26:32 +0000 Subject: [PATCH 775/981] Bugfix - TF Version check --- lib/cli/launcher.py | 6 ++-- lib/utils.py | 9 +++--- plugins/train/model/phaze_a.py | 53 ++++++++++++++++++++++------------ plugins/train/trainer/_base.py | 2 +- 4 files changed, 44 insertions(+), 26 deletions(-) diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index cf7143cbd4..3801065c5c 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -87,9 +87,9 @@ def _test_for_tf_version(self) -> None: FaceswapError If Tensorflow is not found, or is not between versions 2.4 and 2.9 """ - amd_ver = 2.2 - min_ver = 2.7 - max_ver = 2.9 + amd_ver = (2, 2) + min_ver = (2, 7) + max_ver = (2, 9) try: import tensorflow as tf # noqa pylint:disable=import-outside-toplevel,unused-import except ImportError as err: diff --git a/lib/utils.py b/lib/utils.py index f00aa28e2b..ffdfae7b7b 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -15,7 +15,7 @@ from socket import timeout as socket_timeout, error as socket_error from threading import get_ident from time import time -from typing import cast, Dict, List, Optional, Union, TYPE_CHECKING +from typing import cast, Dict, List, Optional, Union, Tuple, TYPE_CHECKING import numpy as np from tqdm import tqdm @@ -34,7 +34,7 @@ _video_extensions = [ # pylint:disable=invalid-name ".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv", ".ts", ".vob"] -_TF_VERS: Optional[float] = None +_TF_VERS: Optional[Tuple[int, int]] = None ValidBackends = Literal["amd", "nvidia", "cpu", "apple_silicon"] @@ -149,7 +149,7 @@ def set_backend(backend: str) -> None: _FS_BACKEND = backend -def get_tf_version() -> float: +def get_tf_version() -> Tuple[int, int]: """ Obtain the major.minor version of currently installed Tensorflow. Returns @@ -160,7 +160,8 @@ def get_tf_version() -> float: global _TF_VERS # pylint:disable=global-statement if _TF_VERS is None: import tensorflow as tf # pylint:disable=import-outside-toplevel - _TF_VERS = float(".".join(tf.__version__.split(".")[:2])) # pylint:disable=no-member + split = tf.__version__.split(".")[:2] + _TF_VERS = (int(split[0]), int(split[1])) return _TF_VERS diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 9bbf52da82..5829c562d9 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -80,7 +80,7 @@ class _EncoderInfo: keras_name: str default_size: int no_amd: bool = False - tf_min: float = 2.0 + tf_min: Tuple[int, int] = (2, 0) scaling: Tuple[int, int] = (0, 1) min_size: int = 32 enforce_for_weights: bool = False @@ -95,35 +95,50 @@ class _EncoderInfo: densenet201=_EncoderInfo( keras_name="DenseNet201", default_size=224), efficientnet_b0=_EncoderInfo( - keras_name="EfficientNetB0", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=224), + keras_name="EfficientNetB0", + no_amd=True, tf_min=(2, 3), scaling=(0, 255), default_size=224), efficientnet_b1=_EncoderInfo( - keras_name="EfficientNetB1", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=240), + keras_name="EfficientNetB1", + no_amd=True, tf_min=(2, 3), scaling=(0, 255), default_size=240), efficientnet_b2=_EncoderInfo( - keras_name="EfficientNetB2", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=260), + keras_name="EfficientNetB2", + no_amd=True, tf_min=(2, 3), scaling=(0, 255), default_size=260), efficientnet_b3=_EncoderInfo( - keras_name="EfficientNetB3", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=300), + keras_name="EfficientNetB3", + no_amd=True, tf_min=(2, 3), scaling=(0, 255), default_size=300), efficientnet_b4=_EncoderInfo( - keras_name="EfficientNetB4", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=380), + keras_name="EfficientNetB4", + no_amd=True, tf_min=(2, 3), scaling=(0, 255), default_size=380), efficientnet_b5=_EncoderInfo( - keras_name="EfficientNetB5", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=456), + keras_name="EfficientNetB5", + no_amd=True, tf_min=(2, 3), scaling=(0, 255), default_size=456), efficientnet_b6=_EncoderInfo( - keras_name="EfficientNetB6", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=528), + keras_name="EfficientNetB6", + no_amd=True, tf_min=(2, 3), scaling=(0, 255), default_size=528), efficientnet_b7=_EncoderInfo( - keras_name="EfficientNetB7", no_amd=True, tf_min=2.3, scaling=(0, 255), default_size=600), + keras_name="EfficientNetB7", + no_amd=True, tf_min=(2, 3), scaling=(0, 255), default_size=600), efficientnet_v2_b0=_EncoderInfo( - keras_name="EfficientNetV2B0", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=224), + keras_name="EfficientNetV2B0", + no_amd=True, tf_min=(2, 8), scaling=(-1, 1), default_size=224), efficientnet_v2_b1=_EncoderInfo( - keras_name="EfficientNetV2B1", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=240), + keras_name="EfficientNetV2B1", + no_amd=True, tf_min=(2, 8), scaling=(-1, 1), default_size=240), efficientnet_v2_b2=_EncoderInfo( - keras_name="EfficientNetV2B2", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=260), + keras_name="EfficientNetV2B2", + no_amd=True, tf_min=(2, 8), scaling=(-1, 1), default_size=260), efficientnet_v2_b3=_EncoderInfo( - keras_name="EfficientNetV2B3", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=300), + keras_name="EfficientNetV2B3", + no_amd=True, tf_min=(2, 8), scaling=(-1, 1), default_size=300), efficientnet_v2_s=_EncoderInfo( - keras_name="EfficientNetV2S", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=384), + keras_name="EfficientNetV2S", + no_amd=True, tf_min=(2, 8), scaling=(-1, 1), default_size=384), efficientnet_v2_m=_EncoderInfo( - keras_name="EfficientNetV2M", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=480), + keras_name="EfficientNetV2M", + no_amd=True, tf_min=(2, 8), scaling=(-1, 1), default_size=480), efficientnet_v2_l=_EncoderInfo( - keras_name="EfficientNetV2L", no_amd=True, tf_min=2.8, scaling=(-1, 1), default_size=480), + keras_name="EfficientNetV2L", + no_amd=True, tf_min=(2, 8), scaling=(-1, 1), default_size=480), inception_resnet_v2=_EncoderInfo( keras_name="InceptionResNetV2", scaling=(-1, 1), min_size=75, default_size=299), inception_v3=_EncoderInfo( @@ -133,9 +148,11 @@ class _EncoderInfo: mobilenet_v2=_EncoderInfo( keras_name="MobileNetV2", scaling=(-1, 1), default_size=224), mobilenet_v3_large=_EncoderInfo( - keras_name="MobileNetV3Large", no_amd=True, tf_min=2.4, scaling=(-1, 1), default_size=224), + keras_name="MobileNetV3Large", + no_amd=True, tf_min=(2, 4), scaling=(-1, 1), default_size=224), mobilenet_v3_small=_EncoderInfo( - keras_name="MobileNetV3Small", no_amd=True, tf_min=2.4, scaling=(-1, 1), default_size=224), + keras_name="MobileNetV3Small", + no_amd=True, tf_min=(2, 4), scaling=(-1, 1), default_size=224), nasnet_large=_EncoderInfo( keras_name="NASNetLarge", scaling=(-1, 1), default_size=331, enforce_for_weights=True), nasnet_mobile=_EncoderInfo( diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 8c82529bca..2371d5d4d1 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -259,7 +259,7 @@ def _log_tensorboard(self, loss: List[float]) -> None: logs = {log[0]: log[1] for log in zip(self._model.state.loss_names, loss)} - if get_tf_version() > 2.7: + if get_tf_version() > (2, 7): # Bug in TF 2.8/2.9 where batch recording got deleted. # ref: https://github.com/keras-team/keras/issues/16173 with tf.summary.record_if(True), self._tensorboard._train_writer.as_default(): # noqa pylint:disable=protected-access,not-context-manager From eefffe243d4ff9dcac08e8de480cca4632f12089 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 20 Dec 2022 01:41:03 +0000 Subject: [PATCH 776/981] Bugfix: AMD. Pin numpy, matplotlib and numexpr --- requirements/_requirements_base.txt | 6 ++++-- requirements/requirements_amd.txt | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index f4464226b6..4054ee3658 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -1,12 +1,14 @@ tqdm>=4.64 psutil>=5.9.0 -numexpr>=2.8.3 +numexpr>=2.7.3; python_version < '3.9' # >=2.8.0 conflicts in Conda +numexpr>=2.8.3; python_version >= '3.9' opencv-python>=4.6.0.0 pillow>=9.2.0 scikit-learn==1.0.2; python_version < '3.9' # AMD needs version 1.0.2 and 1.1.0 not available in Python 3.7 scikit-learn>=1.1.0; python_version >= '3.9' fastcluster>=1.2.6 -matplotlib>=3.5.1,<3.6.0 +matplotlib>=3.4.3,<3.6.0; python_version < '3.9' # >=3.5.0 conflicts in Conda +matplotlib>=3.5.1,<3.6.0; python_version >= '3.9' imageio>=2.19.3 imageio-ffmpeg>=0.4.7 ffmpy>=0.3.0 diff --git a/requirements/requirements_amd.txt b/requirements/requirements_amd.txt index b235e2cd65..87c28448d2 100644 --- a/requirements/requirements_amd.txt +++ b/requirements/requirements_amd.txt @@ -1,6 +1,6 @@ -r _requirements_base.txt # tf2.2 is last version that tensorboard logging works with old Keras -numpy>=1.18.0,<1.20.0 +numpy>=1.18.0,<1.19.0 # TF Will uninstall anything equal or over 1.19.0 protobuf>= 3.19.0,<3.20.0 # TF has started pulling in incompatible protobuf tensorflow>=2.2.0,<2.3.0 plaidml-keras==0.7.0 From d750af1e9245bf8ed7735989087184dbf9fd7443 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 20 Dec 2022 15:30:26 +0000 Subject: [PATCH 777/981] bump TF to version 2.10 --- lib/cli/launcher.py | 2 +- lib/gpu_stats/amd.py | 1 + plugins/train/model/_base/model.py | 5 +++-- plugins/train/trainer/_base.py | 2 +- requirements/requirements_apple_silicon.txt | 4 ++-- requirements/requirements_cpu.txt | 2 +- requirements/requirements_nvidia.txt | 2 +- setup.py | 2 +- 8 files changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 3801065c5c..f6fc5e0972 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -89,7 +89,7 @@ def _test_for_tf_version(self) -> None: """ amd_ver = (2, 2) min_ver = (2, 7) - max_ver = (2, 9) + max_ver = (2, 10) try: import tensorflow as tf # noqa pylint:disable=import-outside-toplevel,unused-import except ImportError as err: diff --git a/lib/gpu_stats/amd.py b/lib/gpu_stats/amd.py index bddeae8584..f9e513f690 100644 --- a/lib/gpu_stats/amd.py +++ b/lib/gpu_stats/amd.py @@ -162,6 +162,7 @@ def _set_plaidml_logger(self) -> None: plaidml.DEFAULT_LOG_HANDLER.propagate = False numeric_level = getattr(logging, self._log_level, None) + assert numeric_level is not None if numeric_level < 10: # DEBUG Logging plaidml._internal_set_vlog(1) # pylint:disable=protected-access elif numeric_level < 20: # INFO Logging diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 4ea3e4318f..472110fc73 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -198,8 +198,9 @@ def config(self) -> dict: @property def name(self) -> str: """ str: The name of this model based on the plugin name. """ - basename = os.path.basename(sys.modules[self.__module__].__file__) - return os.path.splitext(basename)[0].lower() + _name = sys.modules[self.__module__].__file__ + assert isinstance(_name, str) + return os.path.splitext(os.path.basename(_name))[0].lower() @property def model_name(self) -> str: diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 2371d5d4d1..8daa162843 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -260,7 +260,7 @@ def _log_tensorboard(self, loss: List[float]) -> None: for log in zip(self._model.state.loss_names, loss)} if get_tf_version() > (2, 7): - # Bug in TF 2.8/2.9 where batch recording got deleted. + # Bug in TF 2.8/2.9/2.10 where batch recording got deleted. # ref: https://github.com/keras-team/keras/issues/16173 with tf.summary.record_if(True), self._tensorboard._train_writer.as_default(): # noqa pylint:disable=protected-access,not-context-manager for name, value in logs.items(): diff --git a/requirements/requirements_apple_silicon.txt b/requirements/requirements_apple_silicon.txt index f0318b226f..2e2c5d521d 100644 --- a/requirements/requirements_apple_silicon.txt +++ b/requirements/requirements_apple_silicon.txt @@ -1,7 +1,7 @@ protobuf>= 3.19.0,<3.20.0 # TF has started pulling in incompatible protobuf numpy>=1.21.0; python_version < '3.8' numpy>=1.22.0; python_version >= '3.8' -tensorflow-macos>=2.8.0,<2.10.0 -tensorflow-deps>=2.8.0,<2.10.0 +tensorflow-macos>=2.8.0,<2.11.0 +tensorflow-deps>=2.8.0,<2.11.0 tensorflow-metal>=0.4.0,<0.6.0 libblas # Conda only diff --git a/requirements/requirements_cpu.txt b/requirements/requirements_cpu.txt index d37f2a03e1..6456db27ff 100644 --- a/requirements/requirements_cpu.txt +++ b/requirements/requirements_cpu.txt @@ -1,4 +1,4 @@ -r _requirements_base.txt numpy>=1.21.0; python_version < '3.8' numpy>=1.22.0; python_version >= '3.8' -tensorflow>=2.7.0,<2.10.0 +tensorflow>=2.7.0,<2.11.0 diff --git a/requirements/requirements_nvidia.txt b/requirements/requirements_nvidia.txt index f70bfd0e8d..a5b2dbe152 100644 --- a/requirements/requirements_nvidia.txt +++ b/requirements/requirements_nvidia.txt @@ -1,5 +1,5 @@ -r _requirements_base.txt numpy>=1.21.0; python_version < '3.8' numpy>=1.22.0; python_version >= '3.8' -tensorflow-gpu>=2.7.0,<2.10.0 +tensorflow-gpu>=2.7.0,<2.11.0 pynvx==1.0.0 ; sys_platform == "darwin" diff --git a/setup.py b/setup.py index fc5395683d..5ce31e4016 100755 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ _INSTALL_FAILED = False # Revisions of tensorflow GPU and cuda/cudnn requirements. These relate specifically to the # Tensorflow builds available from pypi -_TENSORFLOW_REQUIREMENTS = {">=2.7.0,<2.10.0": ["11.2", "8.1"]} +_TENSORFLOW_REQUIREMENTS = {">=2.7.0,<2.11.0": ["11.2", "8.1"]} # Packages that are explicitly required for setup.py _INSTALLER_REQUIREMENTS = [("pexpect>=4.8.0", "!Windows"), ("pywinpty==2.0.2", "Windows")] From ddedb5269600a2c130b58649ed0ade282eb25595 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 20 Dec 2022 15:35:10 +0000 Subject: [PATCH 778/981] Update tests for tf2.10 --- tests/startup_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/startup_test.py b/tests/startup_test.py index 85a92a504d..ece5af1e2a 100644 --- a/tests/startup_test.py +++ b/tests/startup_test.py @@ -31,5 +31,5 @@ def test_backend(dummy): # pylint:disable=unused-argument def test_keras(dummy): # pylint:disable=unused-argument """ Sanity check to ensure that tensorflow keras is being used for CPU and standard keras for AMD. """ - assert ((_BACKEND == "cpu" and keras.__version__ in ("2.7.0", "2.8.0", "2.9.0")) or + assert ((_BACKEND == "cpu" and keras.__version__ in ("2.7.0", "2.8.0", "2.9.0", "2.10.0")) or (_BACKEND == "amd" and keras.__version__ == "2.2.4")) From dc94ed9cad0def56f6954313aaf50b7b762b5e14 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 6 Jan 2023 14:45:57 +0000 Subject: [PATCH 779/981] DirectML Support (#1291) * DML Support - Add requirements. Update setup.py. Update Windows Installer - Add 'directml' backend. Update backend supporting logic - GPUStats - Add GPU querying from Win SDK for DML backend - Windows PlaidML - Deprecation Warning - Remove deprecated '-d' training flag - Logging in DML functions - Unittest - Windows Unit Test - lib.utils --- .github/workflows/pytest.yml | 41 +- .install/windows/install.nsi | 25 +- docs/conf.py | 4 +- docs/full/lib/gpu_stats.rst | 8 + docs/full/modules.rst | 1 + docs/full/plugins/extract.rst | 4 +- docs/full/scripts.rst | 1 - docs/full/tests/lib.gpu_stats.rst | 15 + docs/full/tests/lib.rst | 26 + docs/full/tests/tests.rst | 16 + docs/sphinx_requirements.txt | 3 + lib/cli/args.py | 12 +- lib/cli/launcher.py | 17 +- lib/gpu_stats/__init__.py | 14 +- lib/gpu_stats/_base.py | 113 ++-- lib/gpu_stats/directml.py | 619 ++++++++++++++++++ lib/gpu_stats/nvidia.py | 20 +- lib/gui/utils/file_handler.py | 10 +- lib/model/session.py | 2 +- lib/multithreading.py | 2 +- lib/sysinfo.py | 108 +-- lib/training/augmentation.py | 2 +- lib/utils.py | 315 ++++++--- plugins/convert/color/color_transfer.py | 25 +- plugins/extract/align/_base/processing.py | 2 +- plugins/extract/detect/mtcnn_defaults.py | 2 +- plugins/extract/mask/bisenet_fp_defaults.py | 2 +- plugins/extract/pipeline.py | 18 +- .../extract/recognition/vgg_face2_defaults.py | 2 +- plugins/train/_config.py | 17 +- plugins/train/model/_base/settings.py | 9 +- requirements/requirements_cpu.txt | 2 +- requirements/requirements_directml.txt | 6 + scripts/train.py | 9 +- setup.cfg | 5 + setup.py | 115 ++-- tests/lib/gpu_stats/__init__.py | 0 tests/lib/gpu_stats/_base_test.py | 161 +++++ tests/lib/utils_test.py | 618 +++++++++++++++++ tests/startup_test.py | 8 +- tools/alignments/media.py | 4 +- tools/sort/sort.py | 4 +- tools/sort/sort_methods.py | 10 +- tools/sort/sort_methods_aligned.py | 4 +- 44 files changed, 2057 insertions(+), 344 deletions(-) create mode 100644 docs/full/tests/lib.gpu_stats.rst create mode 100644 docs/full/tests/lib.rst create mode 100644 docs/full/tests/tests.rst create mode 100644 lib/gpu_stats/directml.py create mode 100644 requirements/requirements_directml.txt create mode 100644 tests/lib/gpu_stats/__init__.py create mode 100644 tests/lib/gpu_stats/_base_test.py create mode 100644 tests/lib/utils_test.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 844f705af7..121ef5b19f 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -8,7 +8,7 @@ on: - "**/README.md" jobs: - build: + build_linux: runs-on: ubuntu-latest strategy: @@ -35,7 +35,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 pylint mypy pytest wheel + pip install flake8 pylint mypy pytest pytest-mock wheel pip install -r ./requirements/requirements_${{ matrix.backend }}.txt - name: Lint with flake8 run: | @@ -52,4 +52,39 @@ jobs: run: | FACESWAP_BACKEND="${{ matrix.backend }}" KERAS_BACKEND="${{ matrix.kbackend }}" python tests/simple_tests.py; if [ "${{ matrix.backend }}" == "amd" ] ; then rm -f ~/.plaidml; fi ; - \ No newline at end of file + + build_windows: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9"] + backend: ["cpu", "directml"] + include: + - backend: "cpu" + - backend: "directml" + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: './requirements/requirements_${{ matrix.backend }}.txt' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pylint mypy pytest pytest-mock wheel + pip install -r ./requirements/requirements_${{ matrix.backend }}.txt + - name: Set Backend EnvVar + run: echo "FACESWAP_BACKEND=${{ matrix.backend }}" | Out-File -FilePath $env:GITHUB_ENV -Append + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --select=E9,F63,F7,F82 --show-source + # exit-zero treats all errors as warnings. + flake8 . --exit-zero + - name: Simple Tests + run: py.test -v tests + - name: End to End Tests + run: python tests/simple_tests.py diff --git a/.install/windows/install.nsi b/.install/windows/install.nsi index eda5f6c28d..56addc9331 100644 --- a/.install/windows/install.nsi +++ b/.install/windows/install.nsi @@ -129,25 +129,32 @@ Function pgPrereqCreate ${NSD_CreateLabel} 10% $lblPos% 80% 14u "Faceswap" Pop $0 - StrCpy $lblPos 46 + StrCpy $lblPos 50 # Info Custom Options - ${NSD_CreateGroupBox} 5% 40% 90% 60% "Custom Items" + ${NSD_CreateGroupBox} 5% 40% 90% 120% "Custom Items" Pop $0 ${NSD_CreateRadioButton} 10% $lblPos% 27% 11u "Setup for NVIDIA GPU" Pop $ctlRadio ${NSD_AddStyle} $ctlRadio ${WS_GROUP} nsDialogs::SetUserData $ctlRadio "nvidia" ${NSD_OnClick} $ctlRadio RadioClick - ${NSD_CreateRadioButton} 40% $lblPos% 25% 11u "Setup for AMD GPU" + ${NSD_CreateRadioButton} 50% $lblPos% 30% 11u "Setup for DirectML" Pop $ctlRadio - nsDialogs::SetUserData $ctlRadio "amd" + nsDialogs::SetUserData $ctlRadio "directml" ${NSD_OnClick} $ctlRadio RadioClick - ${NSD_CreateRadioButton} 70% $lblPos% 20% 11u "Setup for CPU" + + intOp $lblPos $lblPos + 10 + + ${NSD_CreateRadioButton} 10% $lblPos% 25% 11u "Setup for CPU" Pop $ctlRadio nsDialogs::SetUserData $ctlRadio "cpu" ${NSD_OnClick} $ctlRadio RadioClick + ${NSD_CreateRadioButton} 50% $lblPos% 40% 11u "Setup for AMD (deprecated)" + Pop $ctlRadio + nsDialogs::SetUserData $ctlRadio "amd" + ${NSD_OnClick} $ctlRadio RadioClick - intOp $lblPos $lblPos + 10 + intOp $lblPos $lblPos + 12 ${NSD_CreateLabel} 10% $lblPos% 80% 10u "Environment Name (NB: Existing envs with this name will be deleted):" pop $0 @@ -200,7 +207,7 @@ FunctionEnd Function CheckSetupType ${If} $setupType == "" - MessageBox MB_OK "Please specify whether to setup for Nvidia, AMD or CPU." + MessageBox MB_OK "Please specify whether to setup for Nvidia, DirectML or CPU." Abort ${EndIf} StrCpy $Log "$log(check) Setting up for: $setupType$\n" @@ -444,9 +451,7 @@ FunctionEnd Function SetupFaceSwap DetailPrint "Setting up FaceSwap Environment... This may take a while" StrCpy $0 "${flagsSetup}" - ${If} $setupType != "cpu" - StrCpy $0 "$0 --$setupType" - ${EndIf} + StrCpy $0 "$0 --$setupType" SetDetailsPrint listonly ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda activate $\"$envName$\" && python -u $\"$INSTDIR\setup.py$\" $0 && conda deactivate" pop $0 diff --git a/docs/conf.py b/docs/conf.py index d8050831d7..35c7a22696 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,10 +14,12 @@ import sys from unittest import mock +os.environ["FACESWAP_BACKEND"] = "nvidia" sys.path.insert(0, os.path.abspath('../')) sys.setrecursionlimit(1500) -MOCK_MODULES = ["plaidml", "pynvx"] + +MOCK_MODULES = ["plaidml", "pynvx", "ctypes.windll", "comtypes"] for mod_name in MOCK_MODULES: sys.modules[mod_name] = mock.Mock() diff --git a/docs/full/lib/gpu_stats.rst b/docs/full/lib/gpu_stats.rst index 6f6aaa309a..4f8dcfc5c1 100755 --- a/docs/full/lib/gpu_stats.rst +++ b/docs/full/lib/gpu_stats.rst @@ -38,6 +38,14 @@ gpu_stats.cpu module :undoc-members: :show-inheritance: +gpu_stats.directml module +------------------------- + +.. automodule:: lib.gpu_stats.directml + :members: + :undoc-members: + :show-inheritance: + gpu_stats.nvidia_apple module ----------------------------- diff --git a/docs/full/modules.rst b/docs/full/modules.rst index 1286cb4a7e..877e28ef66 100644 --- a/docs/full/modules.rst +++ b/docs/full/modules.rst @@ -7,6 +7,7 @@ faceswap lib/lib plugins/plugins scripts + tests/tests tools/tools setup update_deps diff --git a/docs/full/plugins/extract.rst b/docs/full/plugins/extract.rst index 8faa9e9d3f..5eb3caee1d 100755 --- a/docs/full/plugins/extract.rst +++ b/docs/full/plugins/extract.rst @@ -54,14 +54,14 @@ align._base.processing module :show-inheritance: align.cv2_dnn module -------------------- +-------------------- .. automodule:: plugins.extract.align.cv2_dnn :members: :undoc-members: :show-inheritance: align.fan module -------------------- +---------------- .. automodule:: plugins.extract.align.fan :members: :undoc-members: diff --git a/docs/full/scripts.rst b/docs/full/scripts.rst index eb8871b4ca..1736d5a308 100644 --- a/docs/full/scripts.rst +++ b/docs/full/scripts.rst @@ -51,7 +51,6 @@ fsmedia module ~scripts.fsmedia.Alignments ~scripts.fsmedia.DebugLandmarks - ~scripts.fsmedia.FaceFilter ~scripts.fsmedia.Images ~scripts.fsmedia.PostProcess ~scripts.fsmedia.finalize diff --git a/docs/full/tests/lib.gpu_stats.rst b/docs/full/tests/lib.gpu_stats.rst new file mode 100644 index 0000000000..dbca67ef7f --- /dev/null +++ b/docs/full/tests/lib.gpu_stats.rst @@ -0,0 +1,15 @@ +***************** +gpu_stats package +***************** + +.. contents:: Contents + :local: + +_base_test module +***************** +Unittests for the :class:`~lib.gpu_stats._base` module + +.. automodule:: tests.lib.gpu_stats._base_test + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/full/tests/lib.rst b/docs/full/tests/lib.rst new file mode 100644 index 0000000000..62b8842cba --- /dev/null +++ b/docs/full/tests/lib.rst @@ -0,0 +1,26 @@ +*********** +lib package +*********** + +.. contents:: Contents + :local: + +Subpackages +=========== + +.. toctree:: + :maxdepth: 1 + + gpu_stats + + +utils_test module +***************** +Unit tests for lib.utils + +.. rubric:: Module + +.. automodule:: tests.lib.utils_test + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/tests/tests.rst b/docs/full/tests/tests.rst new file mode 100644 index 0000000000..0785dd7f82 --- /dev/null +++ b/docs/full/tests/tests.rst @@ -0,0 +1,16 @@ +************* +tests package +************* + +The Tests Package provides Faceswap's Unit Tests. + +.. contents:: Contents + :local: + +Subpackages +=========== + +.. toctree:: + :maxdepth: 1 + + lib diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index 58e47eb849..b4cb179ced 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -12,11 +12,14 @@ pillow==8.3.1 scikit-learn>=1.0.2 fastcluster>=1.2.4 matplotlib==3.5.1 +numexpr imageio==2.9.0 imageio-ffmpeg==0.4.7 ffmpy==0.2.3 nvidia-ml-py<11.515 plaidml==0.7.0 +pytest==7.2.0 +pytest-mock==3.10.0 tensorflow>=2.8.0,<2.9.0 tensorflow_probability<0.17 typing-extensions>=4.0.0 diff --git a/lib/cli/args.py b/lib/cli/args.py index cf8d1bbe36..5835098270 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -606,7 +606,7 @@ def get_optional_arguments() -> List[Dict[str, Any]]: opts=("-sp", "--singleprocess"), action="store_true", default=False, - backend="nvidia", + backend=("nvidia", "directml"), group=_("settings"), help=_("Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " @@ -1042,14 +1042,6 @@ def get_argument_list() -> List[Dict[str, Any]]: "You should stop training when you are happy with the previews. However, if " "you want the model to stop automatically at a set number of iterations, you " "can set that value here."))) - argument_list.append(dict( - opts=("-d", "--distributed"), - action="store_true", - default=False, - backend="nvidia", - group=_("training"), - help=_("[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " - "Mirrored Distrubution Strategy to train on multiple GPUs."))) argument_list.append(dict( opts=("-D", "--distribution-strategy"), dest="distribution_strategy", @@ -1057,7 +1049,7 @@ def get_argument_list() -> List[Dict[str, Any]]: type=str.lower, choices=["default", "central-storage", "mirrored"], default="default", - backend="nvidia", + backend=("nvidia", "directml"), group=_("training"), help=_("R|Select the distribution stategy to use." "\nL|default: Use Tensorflow's default distribution strategy." diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index f6fc5e0972..53fe7e2da3 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -10,8 +10,8 @@ from lib.gpu_stats import set_exclude_devices, GPUStats from lib.logger import crash_log, log_setup -from lib.utils import (FaceswapError, get_backend, get_tf_version, safe_shutdown, - set_backend, set_system_verbosity) +from lib.utils import (deprecation_warning, FaceswapError, get_backend, get_tf_version, + safe_shutdown, set_backend, set_system_verbosity) if TYPE_CHECKING: import argparse @@ -88,6 +88,7 @@ def _test_for_tf_version(self) -> None: If Tensorflow is not found, or is not between versions 2.4 and 2.9 """ amd_ver = (2, 2) + directml_ver = (2, 10) min_ver = (2, 7) max_ver = (2, 10) try: @@ -121,6 +122,10 @@ def _test_for_tf_version(self) -> None: msg = (f"The supported Tensorflow version for AMD cards is {amd_ver} but you have " f"version {tf_ver} installed. Please install the correct version.") self._handle_import_error(msg) + if backend == "directml" and tf_ver != directml_ver: + msg = (f"The supported Tensorflow version for DirectML cards is {directml_ver} but " + f"you have version {tf_ver} installed. Please install the correct version.") + self._handle_import_error(msg) logger.debug("Installed Tensorflow Version: %s", tf_ver) @classmethod @@ -246,8 +251,8 @@ def _configure_backend(self, arguments: "argparse.Namespace") -> None: arguments: :class:`argparse.Namespace` The command line arguments passed to Faceswap. """ - if get_backend() == "cpu": - # Cpu backends will not have this attribute + if not hasattr(arguments, "exclude_gpus"): + # CPU backends and systems where no GPU was detected will not have this attribute logger.debug("Adding missing exclude gpus argument to namespace") setattr(arguments, "exclude_gpus", None) return @@ -288,6 +293,10 @@ def _setup_amd(cls, arguments: "argparse.Namespace") -> bool: ``True`` if AMD was set up succesfully otherwise ``False`` """ logger.debug("Setting up for AMD") + if platform.system() == "Windows": + deprecation_warning("The AMD backend", + additional_info="Please consider re-installing using the " + "'DirectML' backend") try: import plaidml # noqa pylint:disable=unused-import,import-outside-toplevel except ImportError: diff --git a/lib/gpu_stats/__init__.py b/lib/gpu_stats/__init__.py index ed962e2cfa..8fe016963e 100644 --- a/lib/gpu_stats/__init__.py +++ b/lib/gpu_stats/__init__.py @@ -6,17 +6,19 @@ from lib.utils import get_backend -from ._base import set_exclude_devices # noqa +from ._base import set_exclude_devices backend = get_backend() if backend == "nvidia" and platform.system().lower() == "darwin": - from .nvidia_apple import NvidiaAppleStats as GPUStats # type:ignore # noqa + from .nvidia_apple import NvidiaAppleStats as GPUStats # type:ignore elif backend == "nvidia": - from .nvidia import NvidiaStats as GPUStats # type:ignore # noqa + from .nvidia import NvidiaStats as GPUStats # type:ignore elif backend == "amd": - from .amd import AMDStats as GPUStats, setup_plaidml # type:ignore # noqa + from .amd import AMDStats as GPUStats, setup_plaidml # type:ignore elif backend == "apple_silicon": - from .apple_silicon import AppleSiliconStats as GPUStats # type:ignore # noqa + from .apple_silicon import AppleSiliconStats as GPUStats # type:ignore +elif backend == "directml": + from .directml import DirectML as GPUStats # type:ignore elif backend == "cpu": - from .cpu import CPUStats as GPUStats # type:ignore # noqa + from .cpu import CPUStats as GPUStats # type:ignore diff --git a/lib/gpu_stats/_base.py b/lib/gpu_stats/_base.py index f9c43ea208..b45bd011aa 100644 --- a/lib/gpu_stats/_base.py +++ b/lib/gpu_stats/_base.py @@ -3,31 +3,54 @@ from the :class:`_GPUStats` class contained here. """ import logging -import os -import sys +from dataclasses import dataclass from typing import List, Optional from lib.utils import get_backend -if sys.version_info < (3, 8): - from typing_extensions import TypedDict -else: - from typing import TypedDict - _EXCLUDE_DEVICES: List[int] = [] -class GPUInfo(TypedDict): - """ Typed Dictionary for returning Full GPU Information. """ +@dataclass +class GPUInfo(): + """Dataclass for storing information about the available GPUs on the system. + + Attributes: + ---------- + vram: list[int] + List of integers representing the total VRAM available on each GPU, in MB. + vram_free: list[int] + List of integers representing the free VRAM available on each GPU, in MB. + driver: str + String representing the driver version being used for the GPUs. + devices: list[str] + List of strings representing the names of each GPU device. + devices_active: list[int] + List of integers representing the indices of the active GPU devices. + """ vram: List[int] + vram_free: List[int] driver: str devices: List[str] devices_active: List[int] -class BiggestGPUInfo(TypedDict): - """ Typed Dictionary for returning GPU Information about the card with most available VRAM. """ +@dataclass +class BiggestGPUInfo(): + """ Dataclass for holding GPU Information about the card with most available VRAM. + + Attributes + ---------- + card_id: int + Integer representing the index of the GPU device. + device: str + The name of the device + free: float + The amount of available VRAM on the GPU + total: float + the total amount of VRAM on the GPU + """ card_id: int device: str free: float @@ -40,8 +63,12 @@ def set_exclude_devices(devices: List[int]) -> None: Parameters ---------- - devices: list - list of indices corresponding to the GPU devices connected to the computer + devices: list[int] + list of GPU device indices to exclude + + Example + ------- + >>> set_exclude_devices([0, 1]) # Exclude the first two GPU devices """ logger = logging.getLogger(__name__) logger.debug("Excluding GPU indicies: %s", devices) @@ -51,7 +78,13 @@ def set_exclude_devices(devices: List[int]) -> None: class _GPUStats(): - """ Parent class for returning information of GPUs used. """ + """ Parent class for collecting GPU device information. + + Parameters: + ----------- + log : bool, optional + Flag indicating whether or not to log debug messages. Default: `True`. + """ def __init__(self, log: bool = True) -> None: # Logger is held internally, as we don't want to log when obtaining system stats on crash @@ -83,7 +116,7 @@ def device_count(self) -> int: @property def cli_devices(self) -> List[str]: - """ list: List of available devices for use in faceswap's command line arguments. """ + """ list[str]: Formatted index: name text string for each GPU """ return [f"{idx}: {device}" for idx, device in enumerate(self._device_names)] @property @@ -93,22 +126,9 @@ def exclude_all_devices(self) -> bool: @property def sys_info(self) -> GPUInfo: - """ dict: GPU Stats that are required for system information logging. - - The dictionary contains the following data: - - **vram** (`list`): the total amount of VRAM in Megabytes for each GPU as pertaining to - :attr:`_handles` - - **driver** (`str`): The GPU driver version that is installed on the OS - - **devices** (`list`): The device name of each GPU on the system as pertaining - to :attr:`_handles` - - **devices_active** (`list`): The device name of each active GPU on the system as - pertaining to :attr:`_handles` - """ + """ :class:`GPUInfo`: The GPU Stats that are required for system information logging """ return GPUInfo(vram=self._vram, + vram_free=self._get_free_vram(), driver=self._driver, devices=self._device_names, devices_active=self._active_devices) @@ -129,16 +149,16 @@ def _log(self, level: str, message: str) -> None: logger = getattr(self._logger, level.lower()) logger(message) - def _initialize(self): - """ Override for GPU specific initialization code. """ + def _initialize(self) -> None: + """ Override to initialize the GPU device handles and any other necessary resources. """ self._is_initialized = True - def _shutdown(self): - """ Override for GPU specific shutdown code. """ + def _shutdown(self) -> None: + """ Override to shutdown the GPU device handles and any other necessary resources. """ self._is_initialized = False def _get_device_count(self) -> int: - """ Override to obtain GPU specific device count + """ Override to obtain the number of GPU devices Returns ------- @@ -148,13 +168,12 @@ def _get_device_count(self) -> int: raise NotImplementedError() def _get_active_devices(self) -> List[int]: - """ Obtain the indices of active GPUs (those that have not been explicitly excluded by - CUDA_VISIBLE_DEVICES environment variable or explicitly excluded in the command line - arguments). + """ Obtain the indices of active GPUs (those that have not been explicitly excluded in + the command line arguments). Notes ----- - Override for GPUs that do not use CUDA + Override for GPU specific checking Returns ------- @@ -162,10 +181,6 @@ def _get_active_devices(self) -> List[int]: The list of device indices that are available for Faceswap to use """ devices = [idx for idx in range(self._device_count) if idx not in _EXCLUDE_DEVICES] - env_devices = os.environ.get("CUDA_VISIBLE_DEVICES") - if env_devices: - new_devices = [int(i) for i in env_devices.split(",")] - devices = [idx for idx in devices if idx in new_devices] self._log("debug", f"Active GPU Devices: {devices}") return devices @@ -230,17 +245,7 @@ def get_card_most_free(self) -> BiggestGPUInfo: Returns ------- - dict - The dictionary contains the following data: - - **card_id** (`int`): The index of the card as pertaining to :attr:`_handles` - - **device** (`str`): The name of the device - - **free** (`float`): The amount of available VRAM on the GPU - - **total** (`float`): the total amount of VRAM on the GPU - + :class:`BiggestGpuInfo` If a GPU is not detected then the **card_id** is returned as ``-1`` and the amount of free and total RAM available is fixed to 2048 Megabytes. """ diff --git a/lib/gpu_stats/directml.py b/lib/gpu_stats/directml.py new file mode 100644 index 0000000000..1dc907351d --- /dev/null +++ b/lib/gpu_stats/directml.py @@ -0,0 +1,619 @@ +#!/usr/bin/env python3 +""" Collects and returns Information on DirectX 12 hardware devices for DirectML. """ +import os +import ctypes +from ctypes import POINTER, Structure, windll +from dataclasses import dataclass +from enum import Enum, IntEnum +from typing import Any, Callable, cast, List + +from comtypes import COMError, IUnknown, GUID, STDMETHOD, HRESULT + +from ._base import _GPUStats + +# Monkey patch default ctypes.c_uint32 value to Enum ctypes property for easier tracking of types +# We can't just subclass as the attribute will be assumed to be part of the Enumeration, so we +# attach it directly and suck up the typing errors. +setattr(Enum, "ctype", ctypes.c_uint32) + + +############################# +# CTYPES SUPPORTING OBJECTS # +############################# +# GUIDs +@dataclass +class LookupGUID: + """ GUIDs that are required for creating COM objects which are used and discarded. + + Reference + --------- + https://learn.microsoft.com/en-us/windows/win32/api/d3d12/nn-d3d12-id3d12device2 + """ + IDXGIDevice = GUID("{54ec77fa-1377-44e6-8c32-88fd5f44c84c}") + ID3D12Device = GUID("{189819f1-1db6-4b57-be54-1821339b85f7}") + + +# ENUMS +class DXGIGpuPreference(IntEnum): + """ The preference of GPU for the app to run on. + + Reference + --------- + https://learn.microsoft.com/en-us/windows/win32/api/dxgi1_6/ne-dxgi1_6-dxgi_gpu_preference + """ + DXGI_GPU_PREFERENCE_UNSPECIFIED = 0 + DXGI_GPU_PREFERENCE_MINIMUM_POWER = 1 + DXGI_GPU_PREFERENCE_HIGH_PERFORMANCE = 2 + + +class DXGIAdapterFlag(IntEnum): + """ Identifies the type of DXGI adapter. + + Reference + --------- + https://learn.microsoft.com/en-us/windows/win32/api/dxgi/ne-dxgi-dxgi_adapter_flag + """ + DXGI_ADAPTER_FLAG_NONE = 0 + DXGI_ADAPTER_FLAG_REMOTE = 1 + DXGI_ADAPTER_FLAG_SOFTWARE = 2 + DXGI_ADAPTER_FLAG_FORCE_DWORD = 0xffffffff + + +class DXGIMemorySegmentGroup(IntEnum): + """ Constants that specify an adapter's memory segment grouping. + + Reference + --------- + https://learn.microsoft.com/en-us/windows/win32/api/dxgi1_4/ne-dxgi1_4-dxgi_memory_segment_group + """ + DXGI_MEMORY_SEGMENT_GROUP_LOCAL = 0 + DXGI_MEMORY_SEGMENT_GROUP_NON_LOCAL = 1 + + +class D3DFeatureLevel(Enum): + """ Describes the set of features targeted by a Direct3D device. + + Reference + --------- + https://learn.microsoft.com/en-us/windows/win32/api/d3dcommon/ne-d3dcommon-d3d_feature_level + """ + D3D_FEATURE_LEVEL_1_0_CORE = 0x1000 + D3D_FEATURE_LEVEL_9_1 = 0x9100 + D3D_FEATURE_LEVEL_9_2 = 0x9200 + D3D_FEATURE_LEVEL_9_3 = 0x9300 + D3D_FEATURE_LEVEL_10_0 = 0xa000 + D3D_FEATURE_LEVEL_10_1 = 0xa100 + D3D_FEATURE_LEVEL_11_0 = 0xb000 + D3D_FEATURE_LEVEL_11_1 = 0xb100 + D3D_FEATURE_LEVEL_12_0 = 0xc000 + D3D_FEATURE_LEVEL_12_1 = 0xc100 + D3D_FEATURE_LEVEL_12_2 = 0xc200 + + +class VendorID(Enum): + """ DirectX VendorID Enum """ + AMD = 0x1002 + NVIDIA = 0x10DE + MICROSOFT = 0x1414 + QUALCOMM = 0x4D4F4351 + INTEL = 0x8086 + + +# STRUCTS +class StructureRepr(Structure): # pylint:disable=too-few-public-methods + """ Override the standard structure class to add a useful __repr__ for logging """ + def __repr__(self) -> str: + """ Output the class name and the structure contents """ + content = ["=".join([field[0], str(getattr(self, field[0]))]) + for field in self._fields_] + if self.__dict__: # Add manually added parameters + content.extend("=".join([key, str(val)]) for key, val in self.__dict__.items()) + return f"{self.__class__.__name__}({', '.join(content)})" + + +class LUID(StructureRepr): # pylint:disable=too-few-public-methods + """ Local Identifier for an adaptor + + Reference + --------- + https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-luid """ + _fields_ = [("LowPart", ctypes.c_ulong), ("HighPart", ctypes.c_long)] + + +class DriverVersion(StructureRepr): # pylint:disable=too-few-public-methods + """ Stucture (based off LARGE_INTEGER) to hold the driver version + + Reference + --------- + https://docs.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-large_integer-r1""" + _fields_ = [("parts_a", ctypes.c_uint16), + ("parts_b", ctypes.c_uint16), + ("parts_c", ctypes.c_uint16), + ("parts_d", ctypes.c_uint16)] + + +class DXGIAdapterDesc1(StructureRepr): # pylint:disable=too-few-public-methods + """ Describes an adapter (or video card) using DXGI 1.1 + + Reference + --------- + https://learn.microsoft.com/en-us/windows/win32/api/dxgi/ns-dxgi-DXGIAdapterDesc1 """ + _fields_ = [ + ("Description", ctypes.c_wchar * 128), + ("VendorId", ctypes.c_uint), + ("DeviceId", ctypes.c_uint), + ("SubSysId", ctypes.c_uint), + ("Revision", ctypes.c_uint), + ("DedicatedVideoMemory", ctypes.c_size_t), + ("DedicatedSystemMemory", ctypes.c_size_t), + ("SharedSystemMemory", ctypes.c_size_t), + ("AdapterLuid", LUID), + ("Flags", DXGIAdapterFlag.ctype)] # type:ignore[attr-defined] # pylint: disable=no-member + + +class DXGIQueryVideoMemoryInfo(StructureRepr): # pylint:disable=too-few-public-methods + """ Describes the current video memory budgeting parameters. + + Reference + --------- + https://learn.microsoft.com/en-us/windows/win32/api/dxgi1_4/ns-dxgi1_4-dxgi_query_video_memory_info + """ + _fields_ = [("Budget", ctypes.c_uint64), + ("CurrentUsage", ctypes.c_uint64), + ("AvailableForReservation", ctypes.c_uint64), + ("CurrentReservation", ctypes.c_uint64)] + + +# COM OBjects +class IDXObject(IUnknown): + """ Base interface for all DXGI objects. + + Reference + --------- + https://learn.microsoft.com/en-us/windows/win32/api/dxgi/nn-dxgi-idxgiobject + """ + _iid_ = GUID("{aec22fb8-76f3-4639-9be0-28eb43a67a2e}") + _methods_ = [STDMETHOD(HRESULT, "SetPrivateData", + [GUID, ctypes.c_uint, POINTER(ctypes.c_void_p)]), + STDMETHOD(HRESULT, "SetPrivateDataInterface", [GUID, POINTER(IUnknown)]), + STDMETHOD(HRESULT, "GetPrivateData", + [GUID, POINTER(ctypes.c_uint), POINTER(ctypes.c_void_p)]), + STDMETHOD(HRESULT, "GetParent", [GUID, POINTER(POINTER(ctypes.c_void_p))])] + + +class IDXGIFactory6(IDXObject): + """ Implements methods for generating DXGI objects + + Reference + --------- + https://learn.microsoft.com/en-us/windows/win32/api/dxgi/nn-dxgi-idxgifactory + """ + _iid_ = GUID("{c1b6694f-ff09-44a9-b03c-77900a0a1d17}") + + _methods_ = [STDMETHOD(HRESULT, "EnumAdapters"), # IDXGIFactory + STDMETHOD(HRESULT, "MakeWindowAssociation"), + STDMETHOD(HRESULT, "GetWindowAssociation"), + STDMETHOD(HRESULT, "CreateSwapChain"), + STDMETHOD(HRESULT, "CreateSoftwareAdapter"), + STDMETHOD(HRESULT, "EnumAdapters1"), # IDXGIFactory1 + STDMETHOD(ctypes.c_bool, "IsCurrent"), + STDMETHOD(ctypes.c_bool, "IsWindowedStereoEnabled"), # IDXGIFactory2 + STDMETHOD(HRESULT, "CreateSwapChainForHwnd"), + STDMETHOD(HRESULT, "CreateSwapChainForCoreWindow"), + STDMETHOD(HRESULT, "GetSharedResourceAdapterLuid"), + STDMETHOD(HRESULT, "RegisterStereoStatusWindow"), + STDMETHOD(HRESULT, "RegisterStereoStatusEvent"), + STDMETHOD(None, "UnregisterStereoStatus"), + STDMETHOD(HRESULT, "RegisterOcclusionStatusWindow"), + STDMETHOD(HRESULT, "RegisterOcclusionStatusEvent"), + STDMETHOD(None, "UnregisterOcclusionStatus"), + STDMETHOD(HRESULT, "CreateSwapChainForComposition"), + STDMETHOD(ctypes.c_uint, "GetCreationFlags"), # IDXGIFactory3 + STDMETHOD(HRESULT, "EnumAdapterByLuid", # IDXGIFactory4 + [LUID, GUID, POINTER(POINTER(ctypes.c_void_p))]), + STDMETHOD(HRESULT, "EnumWarpAdapter"), + STDMETHOD(HRESULT, "CheckFeatureSupport"), # IDXGIFactory5 + STDMETHOD(HRESULT, # IDXGIFactory6 + "EnumAdapterByGpuPreference", + [ctypes.c_uint, + DXGIGpuPreference.ctype, # type:ignore[attr-defined] # pylint:disable=no-member # noqa:E501 + GUID, + POINTER(ctypes.c_void_p)])] + + +class IDXGIAdapter3(IDXObject): + """ Represents a display sub-system (including one or more GPU's, DACs and video memory). + + Reference + --------- + https://learn.microsoft.com/en-us/windows/win32/api/dxgi1_4/nn-dxgi1_4-idxgiadapter3 + """ + _iid_ = GUID("{645967a4-1392-4310-a798-8053ce3e93fd}") + _methods_ = [STDMETHOD(HRESULT, "EnumOutputs"), # v1.0 Methods + STDMETHOD(HRESULT, "GetDesc"), + STDMETHOD(HRESULT, "CheckInterfaceSupport", # v1.1 Methods + [GUID, POINTER(DriverVersion)]), + STDMETHOD(HRESULT, "GetDesc1", [POINTER(DXGIAdapterDesc1)]), + STDMETHOD(HRESULT, "GetDesc2"), # v1.2 Methods + STDMETHOD(HRESULT, # v1.3 Methods + "RegisterHardwareContentProtectionTeardownStatusEvent"), + STDMETHOD(None, "UnregisterHardwareContentProtectionTeardownStatus"), + STDMETHOD(HRESULT, + "QueryVideoMemoryInfo", + [ctypes.c_uint, + DXGIMemorySegmentGroup.ctype, # type:ignore[attr-defined] # pylint:disable=no-member # noqa:E501 + POINTER(DXGIQueryVideoMemoryInfo)]), + STDMETHOD(HRESULT, "SetVideoMemoryReservation"), + STDMETHOD(HRESULT, "RegisterVideoMemoryBudgetChangeNotificationEvent"), + STDMETHOD(None, "UnregisterVideoMemoryBudgetChangeNotification")] + + +########################### +# PYTHON COLLATED OBJECTS # +########################### +@dataclass +class Device: + """ Holds information about a device attached to an adapter. + + Parameters + ---------- + description: :class:`DXGIAdapterDesc1` + The information returned from DXGI.dll about the device + driver_version: str + The driver version of the device + local_mem: :class:`DXGIQueryVideoMemoryInfo` + The amount of local memory currently available + non_local_mem: :class:`DXGIQueryVideoMemoryInfo` + The amount of non-local memory currently available + is_d3d12: bool + ``True`` if the device supports DirectX12 + is_compute_only: bool + ``True`` if the device is only compute (no graphics) + """ + description: DXGIAdapterDesc1 + driver_version: str + local_mem: DXGIQueryVideoMemoryInfo + non_local_mem: DXGIQueryVideoMemoryInfo + is_d3d12: bool + is_compute_only: bool = False + + @property + def is_software_adapter(self) -> bool: + """ bool: ``True`` if this is a software adapter. """ + return self.description.Flags == DXGIAdapterFlag.DXGI_ADAPTER_FLAG_SOFTWARE.value + + @property + def is_valid(self) -> bool: + """ bool: ``True`` if this adapter is a hardware adaptor and is not the basic renderer """ + if self.is_software_adapter: + return False + + if (self.description.VendorId == VendorID.MICROSOFT.value and + self.description.DeviceId == 0x8c): + return False + + return True + + +class Adapters(): # pylint:disable=too-few-public-methods + """ Wrapper to obtain connected DirectX Graphics interface adapters from Windows + + Parameters + ---------- + log_func: :func:`~lib.gpu_stats._base._log` + The logging function to use from the parent GPUStats class + """ + def __init__(self, log_func: Callable[[str, str], None]) -> None: + self._log = log_func + self._log("debug", f"Initializing {self.__class__.__name__}: (log_func: {log_func})") + + self._factory = self._get_factory() + self._adapters = self._get_adapters() + self._devices = self._process_adapters() + + self._valid_adaptors: List[Device] = [] + self._log("debug", f"Initialized {self.__class__.__name__}") + + def _get_factory(self) -> ctypes._Pointer: + """ Get a DXGI 1.1 Factory object + + Reference + --------- + https://learn.microsoft.com/en-us/windows/win32/api/dxgi/nf-dxgi-createdxgifactory1 + + Returns + ------- + :class:`ctypes._Pointer` + A pointer to a :class:`IDXGIFactory6` COM instance + """ + factory_func = windll.dxgi.CreateDXGIFactory + factory_func.argtypes = (GUID, POINTER(ctypes.c_void_p)) + factory_func.restype = HRESULT + handle = ctypes.c_void_p(0) + factory_func(IDXGIFactory6._iid_, ctypes.byref(handle)) # pylint:disable=protected-access + retval = ctypes.POINTER(IDXGIFactory6)(cast(IDXGIFactory6, handle.value)) + self._log("debug", f"factory: {retval}") + return retval + + @property + def valid_adapters(self) -> List[Device]: + """ list[:class:`Device`]: DirectX 12 compatible hardware :class:`Device` objects """ + if self._valid_adaptors: + return self._valid_adaptors + + for device in self._devices: + if not device.is_valid: + # Sorted by most performant so everything after first basic adapter is skipped + break + if not device.is_d3d12: + continue + self._valid_adaptors.append(device) + self._log("debug", f"valid_adaptors: {self._valid_adaptors}") + return self._valid_adaptors + + def _get_adapters(self) -> List[ctypes._Pointer]: + """ Obtain DirectX 12 supporting hardware adapter objects and add a Device class for + obtaining details + + Returns + ------- + list + List of :class:`ctypes._Pointer` objects + """ + idx = 0 + retval = [] + while True: + try: + handle = ctypes.c_void_p(0) + success = self._factory.EnumAdapterByGpuPreference( # type:ignore[attr-defined] + idx, + DXGIGpuPreference.DXGI_GPU_PREFERENCE_HIGH_PERFORMANCE.value, + IDXGIAdapter3._iid_, # pylint:disable=protected-access + ctypes.byref(handle)) + if success != 0: + raise AttributeError("Error calling EnumAdapterByGpuPreference. Result: " + f"{hex(ctypes.c_ulong(success).value)}") + adapter = POINTER(IDXGIAdapter3)(cast(IDXGIAdapter3, handle.value)) + self._log("debug", f"found adapter: {adapter}") + retval.append(adapter) + except COMError as err: + err_code = hex(ctypes.c_ulong(err.hresult).value) # pylint:disable=no-member + self._log( + "debug", + "COM Error. Breaking: " + f"{err.text}({err_code})") # pylint:disable=no-member + break + finally: + idx += 1 + + self._log("debug", f"adapters: {retval}") + return retval + + def _query_adapter(self, func: Callable[[Any], Any], *args: Any) -> None: + """ Query an adapter function, logging if the HRESULT is not a success + + Parameters + ---------- + func: Callable[[Any], Any] + The adaptor function to call + args: Any + The arguments to pass to the adaptor function + """ + check = func(*args) + if check: + self._log("debug", f"Failed HRESULT for func {func}({args}): " + f"{hex(ctypes.c_ulong(check).value)}") + + def _test_d3d12(self, adapter: ctypes._Pointer) -> bool: + """ Test whether the given adapter supports DirectX 12 + + Parameters + ---------- + adapter: :class:`ctypes._Pointer` + A pointer to an adapter instance + + Returns + ------- + bool + ``True`` if the given adapter supports DirectX 12 + """ + factory_func = windll.d3d12.D3D12CreateDevice + factory_func.argtypes = ( + POINTER(IUnknown), + D3DFeatureLevel.ctype, GUID) # type:ignore[attr-defined] # pylint:disable=no-member + factory_func.restype = HRESULT + success = factory_func(adapter, + D3DFeatureLevel.D3D_FEATURE_LEVEL_11_0.value, + LookupGUID.ID3D12Device) + return success in (0, 1) + + def _process_adapters(self) -> List[Device]: + """ Process the adapters to add discovered information. + + Returns + ------- + list[:class:`Device`] + List of device of objects found in the adapters + """ + retval = [] + for adapter in self._adapters: + # Description + desc = DXGIAdapterDesc1() + self._query_adapter(adapter.GetDesc1, ctypes.byref(desc)) # type:ignore[attr-defined] + + # Driver Version + driver = DriverVersion() + self._query_adapter(adapter.CheckInterfaceSupport, # type:ignore[attr-defined] + LookupGUID.IDXGIDevice, + ctypes.byref(driver)) + driver_version = f"{driver.parts_d}.{driver.parts_c}.{driver.parts_b}.{driver.parts_a}" + + # Current Memory + local_mem = DXGIQueryVideoMemoryInfo() + self._query_adapter(adapter.QueryVideoMemoryInfo, # type:ignore[attr-defined] + 0, + DXGIMemorySegmentGroup.DXGI_MEMORY_SEGMENT_GROUP_LOCAL.value, + local_mem) + non_local_mem = DXGIQueryVideoMemoryInfo() + self._query_adapter( + adapter.QueryVideoMemoryInfo, # type:ignore[attr-defined] + 0, + DXGIMemorySegmentGroup.DXGI_MEMORY_SEGMENT_GROUP_NON_LOCAL.value, + non_local_mem) + + # is_d3d12 + is_d3d12 = self._test_d3d12(adapter) + + retval.append(Device(desc, driver_version, local_mem, non_local_mem, is_d3d12)) + + return retval + + +class DirectML(_GPUStats): + """ Holds information and statistics about GPUs connected using Windows API + + Parameters + ---------- + log: bool, optional + Whether the class should output information to the logger. There may be occasions where the + logger has not yet been set up when this class is queried. Attempting to log in these + instances will raise an error. If GPU stats are being queried prior to the logger being + available then this parameter should be set to ``False``. Otherwise set to ``True``. + Default: ``True`` + """ + def __init__(self, log: bool = True) -> None: + self._devices: List[Device] = [] + super().__init__(log=log) + + @property + def _all_vram(self) -> List[int]: + """ list: The VRAM of each GPU device that the DX API has discovered. """ + return [int(device.description.DedicatedVideoMemory / (1024 * 1024)) + for device in self._devices] + + @property + def names(self) -> List[str]: + """ list: The name of each GPU device that the DX API has discovered. """ + return [device.description.Description for device in self._devices] + + def _get_active_devices(self) -> List[int]: + """ Obtain the indices of active GPUs (those that have not been explicitly excluded by + DML_VISIBLE_DEVICES environment variable or explicitly excluded in the command line + arguments). + + Returns + ------- + list + The list of device indices that are available for Faceswap to use + """ + devices = super()._get_active_devices() + env_devices = os.environ.get("DML_VISIBLE_DEVICES") + if env_devices: + new_devices = [int(i) for i in env_devices.split(",")] + devices = [idx for idx in devices if idx in new_devices] + self._log("debug", f"Active GPU Devices: {devices}") + return devices + + def _get_devices(self) -> List[Device]: + """ Obtain all detected DX API devices. + + Returns + ------- + list + The :class:`~dx_lib.Device` objects for GPUs that the DX API has discovered. + """ + adapters = Adapters(log_func=self._log) + devices = adapters.valid_adapters + self._log("debug", f"Obtained Devices: {devices}") + return devices + + def _initialize(self) -> None: + """ Initialize DX Core for DirectML backend. + + If :attr:`_is_initialized` is ``True`` then this function just returns performing no + action. + + if ``False`` then PlaidML is setup, if not already, and GPU information is extracted + from the PlaidML context. + """ + if self._is_initialized: + return + self._log("debug", "Initializing Win DX API for DirectML.") + self._devices = self._get_devices() + super()._initialize() + + def _get_device_count(self) -> int: + """ Detect the number of GPUs available from the DX API. + + Returns + ------- + int + The total number of GPUs available + """ + retval = len(self._devices) + self._log("debug", f"GPU Device count: {retval}") + return retval + + def _get_handles(self) -> list: + """ The DX API doesn't really use device handles, so we just return the all devices list + + Returns + ------- + list + The list of all discovered GPUs + """ + handles = self._devices + self._log("debug", f"DirectML GPU Handles found: {handles}") + return handles + + def _get_driver(self) -> str: + """ Obtain the driver versions currently in use. + + Returns + ------- + str + The current DirectX 12 GPU driver versions + """ + drivers = "|".join([device.driver_version if device.driver_version else "No Driver Found" + for device in self._devices]) + self._log("debug", f"GPU Drivers: {drivers}") + return drivers + + def _get_device_names(self) -> List[str]: + """ Obtain the list of names of connected GPUs as identified in :attr:`_handles`. + + Returns + ------- + list + The list of connected Nvidia GPU names + """ + names = self.names + self._log("debug", f"GPU Devices: {names}") + return names + + def _get_vram(self) -> List[int]: + """ Obtain the VRAM in Megabytes for each connected DirectML GPU as identified in + :attr:`_handles`. + + Returns + ------- + list + The VRAM in Megabytes for each connected Nvidia GPU + """ + vram = self._all_vram + self._log("debug", f"GPU VRAM: {vram}") + return vram + + def _get_free_vram(self) -> List[int]: + """ Obtain the amount of VRAM that is available, in Megabytes, for each connected DirectX + 12 supporting GPU. + + Returns + ------- + list + List of `float`s containing the amount of VRAM available, in Megabytes, for each + connected GPU as corresponding to the values in :attr:`_handles + """ + vram = [int(device.local_mem.Budget / (1024 * 1024)) for device in self._devices] + self._log("debug", f"GPU VRAM free: {vram}") + return vram diff --git a/lib/gpu_stats/nvidia.py b/lib/gpu_stats/nvidia.py index 64f42f81e1..959c9e42c5 100644 --- a/lib/gpu_stats/nvidia.py +++ b/lib/gpu_stats/nvidia.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ Collects and returns Information on available Nvidia GPUs. """ - +import os from typing import List import pynvml @@ -83,6 +83,24 @@ def _get_device_count(self) -> int: self._log("debug", f"GPU Device count: {retval}") return retval + def _get_active_devices(self) -> List[int]: + """ Obtain the indices of active GPUs (those that have not been explicitly excluded by + CUDA_VISIBLE_DEVICES environment variable or explicitly excluded in the command line + arguments). + + Returns + ------- + list + The list of device indices that are available for Faceswap to use + """ + devices = super()._get_active_devices() + env_devices = os.environ.get("CUDA_VISIBLE_DEVICES") + if env_devices: + new_devices = [int(i) for i in env_devices.split(",")] + devices = [idx for idx in devices if idx in new_devices] + self._log("debug", f"Active GPU Devices: {devices}") + return devices + def _get_handles(self) -> list: """ Obtain the device handles for all connected Nvidia GPUs. diff --git a/lib/gui/utils/file_handler.py b/lib/gui/utils/file_handler.py index 7a77c08e8e..c3826072e9 100644 --- a/lib/gui/utils/file_handler.py +++ b/lib/gui/utils/file_handler.py @@ -314,27 +314,27 @@ def _save(self) -> Optional[IO]: def _dir(self) -> str: """ Get a directory location. """ logger.debug("Popping Dir browser") - return filedialog.askdirectory(**self._kwargs) + return filedialog.askdirectory(**self._kwargs) # type: ignore def _savedir(self) -> str: """ Get a save directory location. """ logger.debug("Popping SaveDir browser") - return filedialog.askdirectory(**self._kwargs) + return filedialog.askdirectory(**self._kwargs) # type: ignore def _filename(self) -> str: """ Get an existing file location. """ logger.debug("Popping Filename browser") - return filedialog.askopenfilename(**self._kwargs) + return filedialog.askopenfilename(**self._kwargs) # type: ignore def _filename_multi(self) -> Tuple[str, ...]: """ Get multiple existing file locations. """ logger.debug("Popping Filename browser") - return filedialog.askopenfilenames(**self._kwargs) + return filedialog.askopenfilenames(**self._kwargs) # type: ignore def _save_filename(self) -> str: """ Get a save file location. """ logger.debug("Popping Save Filename browser") - return filedialog.asksaveasfilename(**self._kwargs) + return filedialog.asksaveasfilename(**self._kwargs) # type: ignore @staticmethod def _nothing() -> None: # pylint: disable=useless-return diff --git a/lib/model/session.py b/lib/model/session.py index 340b195146..eb66e8bacf 100644 --- a/lib/model/session.py +++ b/lib/model/session.py @@ -178,7 +178,7 @@ def _set_session(self, logger.debug("Filtering devices to: %s", gpus) tf.config.set_visible_devices(gpus, "GPU") - if allow_growth: + if allow_growth and self._backend == "nvidia": for gpu in gpus: logger.info("Setting allow growth for GPU: %s", gpu) tf.config.experimental.set_memory_growth(gpu, True) diff --git a/lib/multithreading.py b/lib/multithreading.py index 1e6835eb06..3ebf4938b3 100644 --- a/lib/multithreading.py +++ b/lib/multithreading.py @@ -230,7 +230,7 @@ class BackgroundGenerator(MultiThread): name: str, optional The thread name. if ``None`` a unique name is constructed of the form {generator.__name__}_N where N is an incrementing integer. Default: ``None`` - args: tuple, Optional + args: tuple, Optional The argument tuple for generator invocation. Default: ``None``. kwargs: dict, Optional keyword arguments for the generator invocation. Default: ``None``. diff --git a/lib/sysinfo.py b/lib/sysinfo.py index 1e6c12742b..7b0bd6d544 100644 --- a/lib/sysinfo.py +++ b/lib/sysinfo.py @@ -7,20 +7,22 @@ import platform import sys from subprocess import PIPE, Popen +from typing import List, Optional import psutil from lib.gpu_stats import GPUStats +from lib.utils import get_backend from setup import CudaCheck class _SysInfo(): # pylint:disable=too-few-public-methods """ Obtain information about the System, Python and GPU """ - def __init__(self): + def __init__(self) -> None: self._state_file = _State().state_file self._configs = _Configs().configs self._system = dict(platform=platform.platform(), - system=platform.system(), + system=platform.system().lower(), machine=platform.machine(), release=platform.release(), processor=platform.processor(), @@ -31,33 +33,33 @@ def __init__(self): self._cuda_check = CudaCheck() @property - def _encoding(self): + def _encoding(self) -> str: """ str: The system preferred encoding """ return locale.getpreferredencoding() @property - def _is_conda(self): + def _is_conda(self) -> bool: """ bool: `True` if running in a Conda environment otherwise ``False``. """ return ("conda" in sys.version.lower() or os.path.exists(os.path.join(sys.prefix, 'conda-meta'))) @property - def _is_linux(self): + def _is_linux(self) -> bool: """ bool: `True` if running on a Linux system otherwise ``False``. """ - return self._system["system"].lower() == "linux" + return self._system["system"] == "linux" @property - def _is_macos(self): + def _is_macos(self) -> bool: """ bool: `True` if running on a macOS system otherwise ``False``. """ - return self._system["system"].lower() == "darwin" + return self._system["system"] == "darwin" @property - def _is_windows(self): + def _is_windows(self) -> bool: """ bool: `True` if running on a Windows system otherwise ``False``. """ - return self._system["system"].lower() == "windows" + return self._system["system"] == "windows" @property - def _is_virtual_env(self): + def _is_virtual_env(self) -> bool: """ bool: `True` if running inside a virtual environment otherwise ``False``. """ if not self._is_conda: retval = (hasattr(sys, "real_prefix") or @@ -68,42 +70,42 @@ def _is_virtual_env(self): return retval @property - def _ram_free(self): + def _ram_free(self) -> int: """ int: The amount of free RAM in bytes. """ return psutil.virtual_memory().free @property - def _ram_total(self): + def _ram_total(self) -> int: """ int: The amount of total RAM in bytes. """ return psutil.virtual_memory().total @property - def _ram_available(self): + def _ram_available(self) -> int: """ int: The amount of available RAM in bytes. """ return psutil.virtual_memory().available @property - def _ram_used(self): + def _ram_used(self) -> int: """ int: The amount of used RAM in bytes. """ return psutil.virtual_memory().used @property - def _fs_command(self): + def _fs_command(self) -> str: """ str: The command line command used to execute faceswap. """ return " ".join(sys.argv) @property - def _installed_pip(self): + def _installed_pip(self) -> str: """ str: The list of installed pip packages within Faceswap's scope. """ with Popen(f"{sys.executable} -m pip freeze", shell=True, stdout=PIPE) as pip: installed = pip.communicate()[0].decode(self._encoding, errors="replace").splitlines() return "\n".join(installed) @property - def _installed_conda(self): + def _installed_conda(self) -> str: """ str: The list of installed Conda packages within Faceswap's scope. """ if not self._is_conda: - return None + return "" with Popen("conda list", shell=True, stdout=PIPE, stderr=PIPE) as conda: stdout, stderr = conda.communicate() if stderr: @@ -112,7 +114,7 @@ def _installed_conda(self): return "\n".join(installed) @property - def _conda_version(self): + def _conda_version(self) -> str: """ str: The installed version of Conda, or `N/A` if Conda is not installed. """ if not self._is_conda: return "N/A" @@ -124,7 +126,7 @@ def _conda_version(self): return "\n".join(version) @property - def _git_branch(self): + def _git_branch(self) -> str: """ str: The git branch that is currently being used to execute Faceswap. """ with Popen("git status", shell=True, stdout=PIPE, stderr=PIPE) as git: stdout, stderr = git.communicate() @@ -135,7 +137,7 @@ def _git_branch(self): return branch @property - def _git_commits(self): + def _git_commits(self) -> str: """ str: The last 5 git commits for the currently running Faceswap. """ with Popen("git log --pretty=oneline --abbrev-commit -n 5", shell=True, stdout=PIPE, stderr=PIPE) as git: @@ -146,7 +148,7 @@ def _git_commits(self): return ". ".join(commits) @property - def _cuda_version(self): + def _cuda_version(self) -> str: """ str: The installed CUDA version. """ # TODO Handle multiple CUDA installs retval = self._cuda_check.cuda_version @@ -157,7 +159,7 @@ def _cuda_version(self): return retval @property - def _cudnn_version(self): + def _cudnn_version(self) -> str: """ str: The installed cuDNN version. """ retval = self._cuda_check.cudnn_version if not retval: @@ -166,7 +168,7 @@ def _cudnn_version(self): retval += ". Check Conda packages for Conda cuDNN" return retval - def full_info(self): + def full_info(self) -> str: """ Obtain extensive system information stats, formatted into a human readable format. Returns @@ -176,7 +178,8 @@ def full_info(self): console or a log file. """ retval = "\n============ System Information ============\n" - sys_info = {"os_platform": self._system["platform"], + sys_info = {"backend": get_backend(), + "os_platform": self._system["platform"], "os_machine": self._system["machine"], "os_release": self._system["release"], "py_conda_version": self._conda_version, @@ -192,13 +195,15 @@ def full_info(self): "git_commits": self._git_commits, "gpu_cuda": self._cuda_version, "gpu_cudnn": self._cudnn_version, - "gpu_driver": self._gpu["driver"], + "gpu_driver": self._gpu.driver, "gpu_devices": ", ".join([f"GPU_{idx}: {device}" - for idx, device in enumerate(self._gpu["devices"])]), - "gpu_vram": ", ".join([f"GPU_{idx}: {int(vram)}MB" - for idx, vram in enumerate(self._gpu["vram"])]), + for idx, device in enumerate(self._gpu.devices)]), + "gpu_vram": ", ".join( + f"GPU_{idx}: {int(vram)}MB ({int(vram_free)}MB free)" + for idx, (vram, vram_free) in enumerate(zip(self._gpu.vram, + self._gpu.vram_free))), "gpu_devices_active": ", ".join([f"GPU_{idx}" - for idx in self._gpu["devices_active"]])} + for idx in self._gpu.devices_active])} for key in sorted(sys_info.keys()): retval += (f"{key + ':':<20} {sys_info[key]}\n") retval += "\n=============== Pip Packages ===============\n" @@ -211,7 +216,7 @@ def full_info(self): retval += self._configs return retval - def _format_ram(self): + def _format_ram(self) -> str: """ Format the RAM stats into Megabytes to make it more readable. Returns @@ -227,7 +232,7 @@ def _format_ram(self): return ", ".join(retval) -def get_sysinfo(): +def get_sysinfo() -> str: """ Obtain extensive system information stats, formatted into a human readable format. If an error occurs obtaining the system information, then the error message is returned instead. @@ -250,11 +255,11 @@ class _Configs(): # pylint:disable=too-few-public-methods """ Parses the config files in /faceswap/config and outputs the information stored within them in a human readable format. """ - def __init__(self): + def __init__(self) -> None: self.config_dir = os.path.join(os.path.abspath(os.path.dirname(sys.argv[0])), "config") self.configs = self._get_configs() - def _get_configs(self): + def _get_configs(self) -> str: """ Obtain the formatted configurations from the config folder. Returns @@ -262,13 +267,16 @@ def _get_configs(self): str The current configuration in the config files formatted in a human readable format """ - config_files = [os.path.join(self.config_dir, cfile) - for cfile in os.listdir(self.config_dir) - if os.path.basename(cfile) == ".faceswap" - or os.path.splitext(cfile)[1] == ".ini"] - return self._parse_configs(config_files) + try: + config_files = [os.path.join(self.config_dir, cfile) + for cfile in os.listdir(self.config_dir) + if os.path.basename(cfile) == ".faceswap" + or os.path.splitext(cfile)[1] == ".ini"] + return self._parse_configs(config_files) + except FileNotFoundError: + return "" - def _parse_configs(self, config_files): + def _parse_configs(self, config_files: List[str]) -> str: """ Parse the given list of config files into a human readable format. Parameters @@ -292,7 +300,7 @@ def _parse_configs(self, config_files): formatted += self._parse_json(cfile) return formatted - def _parse_ini(self, config_file): + def _parse_ini(self, config_file: str) -> str: """ Parse an ``.ini`` formatted config file into a human readable format. Parameters @@ -318,8 +326,8 @@ def _parse_ini(self, config_file): formatted += self._format_text(item[0], item[1]) return formatted - def _parse_json(self, config_file): - """ Parse an ``.json`` formatted config file into a python dictionary. + def _parse_json(self, config_file: str) -> str: + """ Parse an ``.json`` formatted config file into a formatted string. Parameters ---------- @@ -331,7 +339,7 @@ def _parse_json(self, config_file): dict The current configuration in the config file formatted as a python dictionary """ - formatted = "" + formatted: str = "" with open(config_file, "r", encoding="utf-8", errors="replace") as cfile: conf_dict = json.load(cfile) for key in sorted(conf_dict.keys()): @@ -339,7 +347,7 @@ def _parse_json(self, config_file): return formatted @staticmethod - def _format_text(key, value): + def _format_text(key: str, value: str) -> str: """Format a key value pair into a consistently spaced string output for display. Parameters @@ -360,19 +368,19 @@ def _format_text(key, value): class _State(): # pylint:disable=too-few-public-methods """ Parses the state file in the current model directory, if the model is training, and formats the content into a human readable format. """ - def __init__(self): + def __init__(self) -> None: self._model_dir = self._get_arg("-m", "--model-dir") self._trainer = self._get_arg("-t", "--trainer") self.state_file = self._get_state_file() @property - def _is_training(self): + def _is_training(self) -> bool: """ bool: ``True`` if this function has been called during a training session otherwise ``False``. """ return len(sys.argv) > 1 and sys.argv[1].lower() == "train" @staticmethod - def _get_arg(*args): + def _get_arg(*args: str) -> Optional[str]: """ Obtain the value for a given command line option from sys.argv. Returns @@ -386,7 +394,7 @@ def _get_arg(*args): return cmd[cmd.index(opt) + 1] return None - def _get_state_file(self): + def _get_state_file(self) -> str: """ Parses the model's state file and compiles the contents into a human readable string. Returns diff --git a/lib/training/augmentation.py b/lib/training/augmentation.py index d5e44a800a..1eec9e8bf2 100644 --- a/lib/training/augmentation.py +++ b/lib/training/augmentation.py @@ -21,7 +21,7 @@ class AugConstants: """ Dataclass for holding constants for Image Augmentation. - Paramaters + Parameters ---------- clahe_base_contrast: int The base number for Contrast Limited Adaptive Histogram Equalization diff --git a/lib/utils.py b/lib/utils.py index ffdfae7b7b..296786280a 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -6,16 +6,16 @@ import os import sys import tkinter as tk -import urllib import warnings import zipfile -from re import finditer from multiprocessing import current_process +from re import finditer from socket import timeout as socket_timeout, error as socket_error from threading import get_ident from time import time from typing import cast, Dict, List, Optional, Union, Tuple, TYPE_CHECKING +from urllib import request, error as urlliberror import numpy as np from tqdm import tqdm @@ -35,7 +35,7 @@ ".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv", ".ts", ".vob"] _TF_VERS: Optional[Tuple[int, int]] = None -ValidBackends = Literal["amd", "nvidia", "cpu", "apple_silicon"] +ValidBackends = Literal["amd", "nvidia", "cpu", "apple_silicon", "directml"] class _Backend(): # pylint:disable=too-few-public-methods @@ -44,7 +44,11 @@ class _Backend(): # pylint:disable=too-few-public-methods If file doesn't exist and a variable hasn't been set, create the config file. """ def __init__(self) -> None: - self._backends = {"1": "amd", "2": "cpu", "3": "nvidia", "4": "apple_silicon"} + self._backends: Dict[str, ValidBackends] = {"1": "cpu", + "2": "directml", + "3": "nvidia", + "4": "apple_silicon", + "5": "amd"} self._valid_backends = list(self._backends.values()) self._config_file = self._get_config_file() self.backend = self._get_backend() @@ -109,12 +113,14 @@ def _configure_backend(self) -> ValidBackends: """ print("First time configuration. Please select the required backend") while True: - selection = input("1: AMD, 2: CPU, 3: NVIDIA, 4: APPLE SILICON: ") - if selection not in ("1", "2", "3", "4"): + txt = ", ".join([": ".join([key, val.upper().replace("_", " ")]) + for key, val in self._backends.items()]) + selection = input(f"{txt}: ") + if selection not in self._backends: print(f"'{selection}' is not a valid selection. Please try again") continue break - fs_backend = cast(ValidBackends, self._backends[selection].lower()) + fs_backend = self._backends[selection] config = {"backend": fs_backend} with open(self._config_file, "w", encoding="utf8") as cnf: json.dump(config, cnf) @@ -131,7 +137,14 @@ def get_backend() -> ValidBackends: Returns ------- str - The backend configuration in use by Faceswap + The backend configuration in use by Faceswap. One of ["amd", "cpu", "directml", "nvidia", + "apple_silicon"] + + Example + ------- + >>> from lib.utils import get_backend + >>> get_backend() + 'nvidia' """ return _FS_BACKEND @@ -141,8 +154,13 @@ def set_backend(backend: str) -> None: Parameters ---------- - backend: ["amd", "cpu", "nvidia", "apple_silicon"] + backend: ["amd", "cpu", "directml", "nvidia", "apple_silicon"] The backend to set faceswap to + + Example + ------- + >>> from lib.utils import set_backend + >>> set_backend("nvidia") """ global _FS_BACKEND # pylint:disable=global-statement backend = cast(ValidBackends, backend.lower()) @@ -150,12 +168,18 @@ def set_backend(backend: str) -> None: def get_tf_version() -> Tuple[int, int]: - """ Obtain the major.minor version of currently installed Tensorflow. + """ Obtain the major. minor version of currently installed Tensorflow. Returns ------- - float - The currently installed tensorflow version + tuple[int, int] + A tuple of the form (major, minor) representing the version of TensorFlow that is installed + + Example + ------- + >>> from lib.utils import get_tf_version + >>> get_tf_version() + (2, 9) """ global _TF_VERS # pylint:disable=global-statement if _TF_VERS is None: @@ -181,8 +205,17 @@ def get_folder(path: str, make_folder: bool = True) -> str: str or `None` The path to the requested folder. If `make_folder` is set to ``False`` and the requested path does not exist, then ``None`` is returned + + Example + ------- + >>> from lib.utils import get_folder + >>> get_folder('/tmp/myfolder') + '/tmp/myfolder' + + >>> get_folder('/tmp/myfolder', make_folder=False) + '' """ - logger = logging.getLogger(__name__) # pylint:disable=invalid-name + logger = logging.getLogger(__name__) logger.debug("Requested path: '%s'", path) if not make_folder and not os.path.isdir(path): logger.debug("%s does not exist", path) @@ -193,21 +226,34 @@ def get_folder(path: str, make_folder: bool = True) -> str: def get_image_paths(directory: str, extension: Optional[str] = None) -> List[str]: - """ Obtain a list of full paths that reside within a folder. + """ Gets the image paths from a given directory. + + The function searches for files with the specified extension(s) in the given directory, and + returns a list of their paths. If no extension is provided, the function will search for files + with any of the following extensions: '.bmp', '.jpeg', '.jpg', '.png', '.tif', '.tiff' Parameters ---------- directory: str - The folder that contains the images to be returned + The directory to search in extension: str - The specific image extensions that should be returned + The file extension to search for. If not provided, all image file types will be searched + for Returns ------- - list + list[str] The list of full paths to the images contained within the given folder + + Example + ------- + >>> from lib.utils import get_image_paths + >>> get_image_paths('/path/to/directory') + ['/path/to/directory/image1.jpg', '/path/to/directory/image2.png'] + >>> get_image_paths('/path/to/directory', '.jpg') + ['/path/to/directory/image1.jpg'] """ - logger = logging.getLogger(__name__) # pylint:disable=invalid-name + logger = logging.getLogger(__name__) image_extensions = _image_extensions if extension is None else [extension] dir_contents = [] @@ -217,45 +263,68 @@ def get_image_paths(directory: str, extension: Optional[str] = None) -> List[str dir_scanned = sorted(os.scandir(directory), key=lambda x: x.name) logger.debug("Scanned Folder contains %s files", len(dir_scanned)) - logger.trace("Scanned Folder Contents: %s", dir_scanned) # type:ignore + logger.trace("Scanned Folder Contents: %s", dir_scanned) # type:ignore[attr-defined] for chkfile in dir_scanned: if any(chkfile.name.lower().endswith(ext) for ext in image_extensions): - logger.trace("Adding '%s' to image list", chkfile.path) # type:ignore + logger.trace("Adding '%s' to image list", chkfile.path) # type:ignore[attr-defined] dir_contents.append(chkfile.path) logger.debug("Returning %s images", len(dir_contents)) return dir_contents -def get_dpi() -> float: - """ Obtain the DPI of the running screen. +def get_dpi() -> Optional[float]: + """ Gets the DPI (dots per inch) of the display screen. Returns ------- - int - The obtain dots per inch of the running monitor + float or ``None`` + The DPI of the display screen or ``None`` if the dpi couldn't be obtained (ie: if the + function is called on a headless system) + + Example + ------- + >>> from lib.utils import get_dpi + >>> get_dpi() + 96.0 """ - root = tk.Tk() - dpi = root.winfo_fpixels('1i') + logger = logging.getLogger(__name__) + try: + root = tk.Tk() + dpi = root.winfo_fpixels('1i') + except tk.TclError: + logger.warning("Display not detected. Could not obtain DPI") + return None + return float(dpi) def convert_to_secs(*args: int) -> int: - """ Convert a time to seconds. + """ Convert time in hours, minutes, and seconds to seconds. Parameters ---------- - args: tuple - 2 or 3 ints. If 2 ints are supplied, then (`minutes`, `seconds`) is implied. If 3 ints are - supplied then (`hours`, `minutes`, `seconds`) is implied. + *args: int + 1, 2 or 3 ints. If 2 ints are supplied, then (`minutes`, `seconds`) is implied. If 3 ints + are supplied then (`hours`, `minutes`, `seconds`) is implied. Returns ------- int The given time converted to seconds + + Example + ------- + >>> from lib.utils import convert_to_secs + >>> convert_to_secs(1, 30, 0) + 5400 + >>> convert_to_secs(0, 15, 30) + 930 + >>> convert_to_secs(0, 0, 45) + 45 """ - logger = logging.getLogger(__name__) # pylint:disable=invalid-name + logger = logging.getLogger(__name__) logger.debug("from time: %s", args) retval = 0.0 if len(args) == 1: @@ -270,7 +339,7 @@ def convert_to_secs(*args: int) -> int: def full_path_split(path: str) -> List[str]: - """ Split a full path to a location into all of it's separate components. + """ Split a file path into all of its parts. Parameters ---------- @@ -284,11 +353,13 @@ def full_path_split(path: str) -> List[str]: Example ------- - >>> path = "/foo/baz/bar" - >>> full_path_split(path) - >>> ["foo", "baz", "bar"] + >>> from lib.utils import full_path_split + >>> full_path_split("/usr/local/bin/python") + ['usr', 'local', 'bin', 'python'] + >>> full_path_split("relative/path/to/file.txt") + ['relative', 'path', 'to', 'file.txt']] """ - logger = logging.getLogger(__name__) # pylint:disable=invalid-name + logger = logging.getLogger(__name__) allparts: List[str] = [] while True: parts = os.path.split(path) @@ -300,28 +371,35 @@ def full_path_split(path: str) -> List[str]: break path = parts[0] allparts.insert(0, parts[1]) - logger.trace("path: %s, allparts: %s", path, allparts) # type:ignore + logger.trace("path: %s, allparts: %s", path, allparts) # type:ignore[attr-defined] + # Remove any empty strings which may have got inserted + allparts = [part for part in allparts if part] return allparts def set_system_verbosity(log_level: str): """ Set the verbosity level of tensorflow and suppresses future and deprecation warnings from - any modules + any modules. + + This function sets the `TF_CPP_MIN_LOG_LEVEL` environment variable to control the verbosity of + TensorFlow output, as well as filters certain warning types to be ignored. The log level is + determined based on the input string `log_level`. Parameters ---------- log_level: str - The requested Faceswap log level + The requested Faceswap log level. References ---------- https://stackoverflow.com/questions/35911252/disable-tensorflow-debugging-information - Can be set to: - 0: all logs shown. 1: filter out INFO logs. 2: filter out WARNING logs. 3: filter out ERROR - logs. - """ - logger = logging.getLogger(__name__) # pylint:disable=invalid-name + Example + ------- + >>> from lib.utils import set_system_verbosity + >>> set_system_verbosity('warning') + """ + logger = logging.getLogger(__name__) from lib.logger import get_loglevel # pylint:disable=import-outside-toplevel numeric_level = get_loglevel(log_level) log_level = "3" if numeric_level > 15 else "0" @@ -333,40 +411,53 @@ def set_system_verbosity(log_level: str): def deprecation_warning(function: str, additional_info: Optional[str] = None) -> None: - """ Log at warning level that a function will be removed in a future update. + """ Log a deprecation warning message. + + This function logs a warning message to indicate that the specified function has been + deprecated and will be removed in future. An optional additional message can also be included. Parameters ---------- function: str - The function that will be deprecated. + The name of the function that will be deprecated. additional_info: str, optional Any additional information to display with the deprecation message. Default: ``None`` + + Example + ------- + >>> from lib.utils import deprecation_warning + >>> deprecation_warning('old_function', 'Use new_function instead.') """ - logger = logging.getLogger(__name__) # pylint:disable=invalid-name + logger = logging.getLogger(__name__) logger.debug("func_name: %s, additional_info: %s", function, additional_info) - msg = f"{function} has been deprecated and will be removed from a future update." + msg = f"{function} has been deprecated and will be removed from a future update." if additional_info is not None: msg += f" {additional_info}" logger.warning(msg) def camel_case_split(identifier: str) -> List[str]: - """ Split a camel case name + """ Split a camelCase string into a list of its individual parts Parameters ---------- identifier: str - The camel case text to be split + The camelCase text to be split Returns ------- - list - A list of the given identifier split into it's constituent parts - + list[str] + A list of the individual parts of the camelCase string. References ---------- https://stackoverflow.com/questions/29916065 + + Example + ------- + >>> from lib.utils import camel_case_split + >>> camel_case_split('camelCaseExample') + ['camel', 'Case', 'Example'] """ matches = finditer( ".+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)", @@ -375,15 +466,24 @@ def camel_case_split(identifier: str) -> List[str]: def safe_shutdown(got_error: bool = False) -> None: - """ Close all tracked queues and threads in event of crash or on shut down. + """ Safely shut down the system. + + This function terminates the queue manager and exits the program in a clean and orderly manner. + An optional boolean parameter can be used to indicate whether an error occurred during the + program's execution. Parameters ---------- got_error: bool, optional - ``True`` if this function is being called as the result of raised error, otherwise - ``False``. Default: ``False`` + ``True`` if this function is being called as the result of raised error. Default: ``False`` + + Example + ------- + >>> from lib.utils import safe_shutdown + >>> safe_shutdown() + >>> safe_shutdown(True) """ - logger = logging.getLogger(__name__) # pylint:disable=invalid-name + logger = logging.getLogger(__name__) logger.debug("Safely shutting down") from lib.queue_manager import queue_manager # pylint:disable=import-outside-toplevel queue_manager.terminate_queues() @@ -398,6 +498,15 @@ class FaceswapError(Exception): ------ FaceswapError on a captured error + + Example + ------- + >>> from lib.utils import FaceswapError + >>> try: + ... # Some code that may raise an error + ... except SomeError: + ... raise FaceswapError("There was an error while running the code") + FaceswapError: There was an error while running the code """ pass # pylint:disable=unnecessary-pass @@ -425,6 +534,11 @@ class GetModel(): # pylint:disable=too-few-public-methods number: `_v.` (eg: `["mtcnn_det_v1.1.py", "mtcnn_det_v1.2.py", "mtcnn_det_v1.3.py"]`, `["resnet_ssd_v1.caffemodel" ,"resnet_ssd_v1.prototext"]` + + Example + ------- + >>> from lib.utils import GetModel + >>> model_downloader = GetModel("s3fd_keras_v2.h5", 11) """ def __init__(self, model_filename: Union[str, List[str]], git_model_id: int) -> None: @@ -444,36 +558,44 @@ def _model_full_name(self) -> str: """ str: The full model name from the filename(s). """ common_prefix = os.path.commonprefix(self._model_filename) retval = os.path.splitext(common_prefix)[0] - self.logger.trace(retval) # type: ignore + self.logger.trace(retval) # type:ignore[attr-defined] return retval @property def _model_name(self) -> str: """ str: The model name from the model's full name. """ retval = self._model_full_name[:self._model_full_name.rfind("_")] - self.logger.trace(retval) # type: ignore + self.logger.trace(retval) # type:ignore[attr-defined] return retval @property def _model_version(self) -> int: """ int: The model's version number from the model full name. """ retval = int(self._model_full_name[self._model_full_name.rfind("_") + 2:]) - self.logger.trace(retval) # type: ignore + self.logger.trace(retval) # type:ignore[attr-defined] return retval @property def model_path(self) -> Union[str, List[str]]: - """ str or list: The model path(s) in the cache folder. """ + """ str or list[str]: The model path(s) in the cache folder. + + Example + ------- + >>> from lib.utils import GetModel + >>> model_downloader = GetModel("s3fd_keras_v2.h5", 11) + >>> model_downloader.model_path + '/path/to/s3fd_keras_v2.h5' + """ paths = [os.path.join(self._cache_dir, fname) for fname in self._model_filename] retval: Union[str, List[str]] = paths[0] if len(paths) == 1 else paths - self.logger.trace(retval) # type: ignore + self.logger.trace(retval) # type:ignore[attr-defined] return retval @property def _model_zip_path(self) -> str: """ str: The full path to downloaded zip file. """ retval = os.path.join(self._cache_dir, f"{self._model_full_name}.zip") - self.logger.trace(retval) # type: ignore + self.logger.trace(retval) # type:ignore[attr-defined] return retval @property @@ -483,7 +605,7 @@ def _model_exists(self) -> bool: retval = all(os.path.exists(pth) for pth in self.model_path) else: retval = os.path.exists(self.model_path) - self.logger.trace(retval) # type: ignore + self.logger.trace(retval) # type:ignore[attr-defined] return retval @property @@ -491,7 +613,7 @@ def _url_download(self) -> str: """ strL Base download URL for models. """ tag = f"v{self._git_model_id}.{self._model_version}" retval = f"{self._url_base}/{tag}/{self._model_full_name}.zip" - self.logger.trace("Download url: %s", retval) # type: ignore + self.logger.trace("Download url: %s", retval) # type:ignore[attr-defined] return retval @property @@ -499,7 +621,7 @@ def _url_partial_size(self) -> int: """ int: How many bytes have already been downloaded. """ zip_file = self._model_zip_path retval = os.path.getsize(zip_file) if os.path.exists(zip_file) else 0 - self.logger.trace(retval) # type: ignore + self.logger.trace(retval) # type:ignore[attr-defined] return retval def _get(self) -> None: @@ -518,16 +640,16 @@ def _download_model(self) -> None: for attempt in range(self._retries): try: downloaded_size = self._url_partial_size - req = urllib.request.Request(self._url_download) + req = request.Request(self._url_download) if downloaded_size != 0: req.add_header("Range", f"bytes={downloaded_size}-") - with urllib.request.urlopen(req, timeout=10) as response: + with request.urlopen(req, timeout=10) as response: self.logger.debug("header info: {%s}", response.info()) self.logger.debug("Return Code: %s", response.getcode()) self._write_zipfile(response, downloaded_size) break except (socket_error, socket_timeout, - urllib.error.HTTPError, urllib.error.URLError) as err: + urlliberror.HTTPError, urlliberror.URLError) as err: if attempt + 1 < self._retries: self.logger.warning("Error downloading model (%s). Retrying %s of %s...", str(err), attempt + 2, self._retries) @@ -610,7 +732,6 @@ def _write_model(self, zip_file: zipfile.ZipFile) -> None: break pbar.update(len(buffer)) out_file.write(buffer) - zip_file.close() pbar.close() @@ -620,11 +741,24 @@ class DebugTimes(): Parameters ---------- min: bool, Optional - Display minimum time in summary stats. Default: ``True`` + Display minimum time taken in summary stats. Default: ``True`` mean: bool, Optional - Display mean time in summary stats. Default: ``True`` + Display mean time taken in summary stats. Default: ``True`` max: bool, Optional - Display maximum time in summary stats. Default: ``True`` + Display maximum time taken in summary stats. Default: ``True`` + + Example + ------- + >>> from lib.utils import DebugTimes + >>> debug_times = DebugTimes() + >>> debug_times.step_start("step 1") + >>> # do something here + >>> debug_times.step_end("step 1") + >>> debug_times.summary() + ---------------------------------- + Step Count Min + ---------------------------------- + step 1 1 0.000000 """ def __init__(self, show_min: bool = True, show_mean: bool = True, show_max: bool = True) -> None: @@ -644,6 +778,14 @@ def step_start(self, name: str, record: bool = True) -> None: ``True`` to record the step time, ``False`` to not record it. Used for when you have conditional code to time, but do not want to insert if/else statements in the code. Default: `True` + + Example + ------- + >>> from lib.util import DebugTimes + >>> debug_times = DebugTimes() + >>> debug_times.step_start("Example Step") + >>> # do something here + >>> debug_times.step_end("Example Step") """ if not record: return @@ -651,7 +793,7 @@ def step_start(self, name: str, record: bool = True) -> None: self._steps[storename] = time() def step_end(self, name: str, record: bool = True) -> None: - """ Stop the timer and record elapsed time for the given step name. + """ Stop the timer and record elapsed time for the given step name. Parameters ---------- @@ -661,6 +803,14 @@ def step_end(self, name: str, record: bool = True) -> None: ``True`` to record the step time, ``False`` to not record it. Used for when you have conditional code to time, but do not want to insert if/else statements in the code. Default: `True` + + Example + ------- + >>> from lib.util import DebugTimes + >>> debug_times = DebugTimes() + >>> debug_times.step_start("Example Step") + >>> # do something here + >>> debug_times.step_end("Example Step") """ if not record: return @@ -686,14 +836,27 @@ def _format_column(cls, text: str, width: int) -> str: return f"{text}{' ' * (width - len(text))}" def summary(self, decimal_places: int = 6, interval: int = 1) -> None: - """ Output a summary of step times. + """ Print a summary of step times. Parameters ---------- decimal_places: int, optional - The number of decimal places to display the summary elapsed times to + The number of decimal places to display the summary elapsed times to. Default: 6 interval: int, optional How many times summary must be called before printing to console. Default: 1 + + Example + ------- + >>> from lib.utils import DebugTimes + >>> debug = DebugTimes() + >>> debug.step_start("test") + >>> time.sleep(0.5) + >>> debug.step_end("test") + >>> debug.summary() + ---------------------------------- + Step Count Min + ---------------------------------- + test 1 0.500000 """ interval = max(1, interval) if interval != self._interval: diff --git a/plugins/convert/color/color_transfer.py b/plugins/convert/color/color_transfer.py index 17ae9d29ff..2425d9bfb5 100644 --- a/plugins/convert/color/color_transfer.py +++ b/plugins/convert/color/color_transfer.py @@ -40,8 +40,8 @@ class Color(Adjustment): def process(self, old_face, new_face, raw_mask): """ - Parameters: - ------- + Parameters + ---------- source: NumPy array OpenCV image in BGR color space (the source image) target: NumPy array @@ -59,7 +59,7 @@ def process(self, old_face, new_face, raw_mask): the scaling factor proposed in the paper. This method seems to produce more consistently aesthetically pleasing results - Returns: + Returns ------- transfer: NumPy array OpenCV image (w, h, 3) NumPy array (uint8) @@ -127,12 +127,13 @@ def process(self, old_face, new_face, raw_mask): @staticmethod def image_stats(image): """ - Parameters: - ------- + Parameters + ---------- + image: NumPy array OpenCV image in L*a*b* color space - Returns: + Returns ------- Tuple of mean and standard deviations for the L*, a*, and b* channels, respectively @@ -151,13 +152,13 @@ def _min_max_scale(arr, new_range=(0, 255)): """ Perform min-max scaling to a NumPy array - Parameters: - ------- + Parameters + ---------- arr: NumPy array to be scaled to [new_min, new_max] range new_range: tuple of form (min, max) specifying range of transformed array - Returns: + Returns ------- NumPy array that has been scaled to be in [new_range[0], new_range[1]] range @@ -182,14 +183,14 @@ def _scale_array(self, arr, clip=True): Trim NumPy array values to be in [0, 255] range with option of clipping or scaling. - Parameters: - ------- + Parameters + ---------- arr: array to be trimmed to [0, 255] range clip: should array be scaled by np.clip? if False then input array will be min-max scaled to range [max([arr.min(), 0]), min([arr.max(), 255])] - Returns: + Returns ------- NumPy array that has been scaled to be in [0, 255] range """ diff --git a/plugins/extract/align/_base/processing.py b/plugins/extract/align/_base/processing.py index e4c49a9a2a..8c13171c40 100644 --- a/plugins/extract/align/_base/processing.py +++ b/plugins/extract/align/_base/processing.py @@ -308,7 +308,7 @@ def set_input_size_and_centering(self, input_size: int, centering: "CenteringTyp ---------- input_size: int The input size, in pixels, of the aligner plugin - centering: ["face", "head" or "legacy"] + centering: ["face", "head" or "legacy"] The centering to align the image at for re-aligning """ logger.debug("input_size: %s, centering: %s", input_size, centering) diff --git a/plugins/extract/detect/mtcnn_defaults.py b/plugins/extract/detect/mtcnn_defaults.py index 4fa29be9ca..1881463ef0 100755 --- a/plugins/extract/detect/mtcnn_defaults.py +++ b/plugins/extract/detect/mtcnn_defaults.py @@ -90,7 +90,7 @@ ), "cpu": dict( default=True, - info="[Nvidia Only] MTCNN detector still runs fairly quickly on CPU on some setups. " + info="[Not AMD] MTCNN detector still runs fairly quickly on CPU on some setups. " "Enable CPU mode here to use the CPU for this detector to save some VRAM at a speed " "cost.", datatype=bool, diff --git a/plugins/extract/mask/bisenet_fp_defaults.py b/plugins/extract/mask/bisenet_fp_defaults.py index cfc2a34c68..70187c6bd7 100644 --- a/plugins/extract/mask/bisenet_fp_defaults.py +++ b/plugins/extract/mask/bisenet_fp_defaults.py @@ -65,7 +65,7 @@ fixed=True), "cpu": dict( default=False, - info="[Nvidia Only] BiseNet mask still runs fairly quickly on CPU on some setups. Enable " + info="[Not AMD] BiseNet mask still runs fairly quickly on CPU on some setups. Enable " "CPU mode here to use the CPU for this masker to save some VRAM at a speed cost.", datatype=bool, group="settings"), diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 9f310211db..9b9179b465 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -7,8 +7,7 @@ This module sets up a pipeline for the extraction workflow, loading detect, align and mask plugins either in parallel or in series, giving easy access to input and output. - - """ +""" import logging import sys @@ -58,9 +57,9 @@ class Extractor(): ---------- detector: str or ``None`` The name of a detector plugin as exists in :mod:`plugins.extract.detect` - aligner: str or ``None + aligner: str or ``None`` The name of an aligner plugin as exists in :mod:`plugins.extract.align` - masker: str or list or ``None + masker: str or list or ``None`` The name of a masker plugin(s) as exists in :mod:`plugins.extract.mask`. This can be a single masker or a list of multiple maskers recognition: str or ``None`` @@ -496,9 +495,9 @@ def _get_vram_stats() -> Dict[str, Union[int, str]]: gpu_stats = GPUStats() stats = gpu_stats.get_card_most_free() retval: Dict[str, Union[int, str]] = dict(count=gpu_stats.device_count, - device=stats["device"], - vram_free=int(stats["free"] - vram_buffer), - vram_total=int(stats["total"])) + device=stats.device, + vram_free=int(stats.free - vram_buffer), + vram_total=int(stats.total)) logger.debug(retval) return retval @@ -709,8 +708,9 @@ def _set_extractor_batchsize(self) -> None: Only adjusts if the the configured batch size requires more vram than is available. Nvidia only. """ - if get_backend() != "nvidia": - logger.debug("Backend is not Nvidia. Not updating batchsize requirements") + backend = get_backend() + if backend not in ("nvidia", "directml"): + logger.debug("Not updating batchsize requirements for backend: '%s'", backend) return if sum(plugin.vram for plugin in self._active_plugins) == 0: logger.debug("No plugins use VRAM. Not updating batchsize requirements.") diff --git a/plugins/extract/recognition/vgg_face2_defaults.py b/plugins/extract/recognition/vgg_face2_defaults.py index 91b6614032..16af834922 100644 --- a/plugins/extract/recognition/vgg_face2_defaults.py +++ b/plugins/extract/recognition/vgg_face2_defaults.py @@ -66,7 +66,7 @@ fixed=True), "cpu": dict( default=False, - info="[Nvidia Only] VGG Face2 still runs fairly quickly on CPU on some setups. Enable " + info="[Not AMD] VGG Face2 still runs fairly quickly on CPU on some setups. Enable " "CPU mode here to use the CPU for this plugin to save some VRAM at a speed cost.", datatype=bool, group="settings"), diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 8ecaacdb42..7f8cd0f62f 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -239,7 +239,7 @@ def _set_globals(self) -> None: title="autoclip", datatype=bool, default=False, - info="[Nvidia Only] Apply AutoClipping to the gradients. AutoClip analyzes the " + info="[Not AMD] Apply AutoClipping to the gradients. AutoClip analyzes the " "gradient weights and adjusts the normalization value dynamically to fit the " "data. Can help prevent NaNs and improve model optimization at the expense of " "VRAM. Ref: AutoClip: Adaptive Gradient Clipping for Source Separation Networks " @@ -277,15 +277,16 @@ def _set_globals(self) -> None: default=False, fixed=False, group="network", - info="[Nvidia Only], NVIDIA GPUs can run operations in float16 faster than in " + info="[Not AMD], NVIDIA GPUs can run operations in float16 faster than in " "float32. Mixed precision allows you to use a mix of float16 with float32, to " "get the performance benefits from float16 and the numeric stability benefits " - "from float32.\n\nWhile mixed precision will run on most Nvidia models, it will " - "only speed up training on more recent GPUs. Those with compute capability 7.0 " - "or higher will see the greatest performance benefit from mixed precision " - "because they have Tensor Cores. Older GPUs offer no math performance benefit " - "for using mixed precision, however memory and bandwidth savings can enable some " - "speedups. Generally RTX GPUs and later will offer the most benefit.") + "from float32.\n\nThis is untested on DirectML backend, but will run on most " + "Nvidia models. it will only speed up training on more recent GPUs. Those with " + "compute capability 7.0 or higher will see the greatest performance benefit from " + "mixed precision because they have Tensor Cores. Older GPUs offer no math " + "performance benefit for using mixed precision, however memory and bandwidth " + "savings can enable some speedups. Generally RTX GPUs and later will offer the " + "most benefit.") self.add_item( section=section, title="nan_protection", diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 9ffcf83916..0baf91f464 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -447,9 +447,10 @@ def _set_tf_settings(cls, allow_growth: bool, exclude_devices: List[int]) -> Non List of GPU device indices that should not be made available to Tensorflow. Pass ``None`` if all devices should be made available """ - if get_backend() == "amd": + backend = get_backend() + if backend == "amd": return # No settings for AMD - if get_backend() == "cpu": + if backend == "cpu": logger.verbose("Hiding GPUs from Tensorflow") # type:ignore tf.config.set_visible_devices([], "GPU") return @@ -464,7 +465,7 @@ def _set_tf_settings(cls, allow_growth: bool, exclude_devices: List[int]) -> Non logger.debug("Filtering devices to: %s", gpus) tf.config.set_visible_devices(gpus, "GPU") - if allow_growth: + if allow_growth and backend == "nvidia": logger.debug("Setting Tensorflow 'allow_growth' option") for gpu in gpus: logger.info("Setting allow growth for GPU: %s", gpu) @@ -535,7 +536,7 @@ def _get_strategy(self, The request Tensorflow Strategy if the backend is Nvidia and the strategy is not `"Default"` otherwise ``None`` """ - if get_backend() != "nvidia": + if get_backend() not in ("nvidia", "directml"): retval = None elif strategy == "mirrored": retval = self._get_mirrored_strategy() diff --git a/requirements/requirements_cpu.txt b/requirements/requirements_cpu.txt index 6456db27ff..9eee198cd1 100644 --- a/requirements/requirements_cpu.txt +++ b/requirements/requirements_cpu.txt @@ -1,4 +1,4 @@ -r _requirements_base.txt numpy>=1.21.0; python_version < '3.8' numpy>=1.22.0; python_version >= '3.8' -tensorflow>=2.7.0,<2.11.0 +tensorflow-cpu>=2.7.0,<2.11.0 diff --git a/requirements/requirements_directml.txt b/requirements/requirements_directml.txt new file mode 100644 index 0000000000..940b5b3a22 --- /dev/null +++ b/requirements/requirements_directml.txt @@ -0,0 +1,6 @@ +-r _requirements_base.txt +numpy>=1.21.0; python_version < '3.8' +numpy>=1.22.0; python_version >= '3.8' +tensorflow-cpu>=2.10.0,<2.11.0 +tensorflow-directml-plugin +comtypes diff --git a/scripts/train.py b/scripts/train.py index dbb928acda..62be0aa041 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -17,7 +17,7 @@ from lib.keypress import KBHit from lib.multithreading import MultiThread, FSThread from lib.training import Preview, PreviewBuffer, TriggerType -from lib.utils import (deprecation_warning, get_folder, get_image_paths, +from lib.utils import (get_folder, get_image_paths, FaceswapError, _image_extensions) from plugins.plugin_loader import PluginLoader @@ -74,12 +74,7 @@ def __init__(self, arguments: "argparse.Namespace") -> None: def _handle_deprecations(self) -> None: """ Handle the update of deprecated arguments and output warnings. """ - if self._args.distributed: - deprecation_warning("`-d`, `--distributed`", - "Please use `-D`, `--distribution-strategy`") - logger.warning("Setting 'distribution-strategy' to 'mirrored'") - setattr(self._args, "distribution_strategy", "mirrored") - del self._args.distributed + return def _get_images(self) -> Dict[Literal["a", "b"], List[str]]: """ Check the image folders exist and contains valid extracted faces. Obtain image paths. diff --git a/setup.cfg b/setup.cfg index ebcf0bc849..9c6103dc47 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,6 +7,8 @@ exclude = .git, __pycache__ per-file-ignores = __init__.py:F401 [mypy] +[mypy-comtypes.*] +ignore_missing_imports = True [mypy-cv2.*] ignore_missing_imports = True [mypy-fastcluster.*] @@ -37,6 +39,9 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-pynvx.*] ignore_missing_imports = True +[mypy-pyparsing.*] # Remove this when fixed https://github.com/pyparsing/pyparsing/issues/385 +follow_imports = skip +ignore_missing_imports = True [mypy-pytest.*] ignore_missing_imports = True [mypy-scipy.*] diff --git a/setup.py b/setup.py index 5ce31e4016..7e0b0c3a59 100755 --- a/setup.py +++ b/setup.py @@ -19,6 +19,12 @@ from lib.logger import log_setup +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + + logger = logging.getLogger(__name__) # pylint: disable=invalid-name _INSTALL_FAILED = False # Revisions of tensorflow GPU and cuda/cudnn requirements. These relate specifically to the @@ -49,15 +55,16 @@ class Environment(): ``True`` if the script is being called by Faceswap's internal updater. ``False`` if full setup is running. Default: ``False`` """ + + _backends = (("nvidia", "amd", "apple_silicon", "directml", "cpu")) + def __init__(self, updater: bool = False) -> None: self.conda_required_packages: List[Tuple[str, ...]] = [("tk", )] self.updater = updater # Flag that setup is being run by installer so steps can be skipped self.is_installer: bool = False - self.enable_amd: bool = False - self.enable_apple_silicon: bool = False + self.backend: Optional[Literal["nvidia", "amd", "apple_silicon", "directml", "cpu"]] = None self.enable_docker: bool = False - self.enable_cuda: bool = False self.required_packages: List[Tuple[str, List[Tuple[str, str]]]] = [] self.missing_packages: List[Tuple[str, List[Tuple[str, str]]]] = [] self.conda_missing_packages: List[Tuple[str, ...]] = [] @@ -137,24 +144,13 @@ def _process_arguments(self) -> None: for arg in args: if arg == "--installer": self.is_installer = True - if arg == "--nvidia": - self.enable_cuda = True - if arg == "--amd": - self.enable_amd = True - if arg == "--apple-silicon": - self.enable_apple_silicon = True + if not self.backend and (arg.startswith("--") and + arg.replace("--", "") in self._backends): + self.backend = arg.replace("--", "").lower() # type:ignore def get_required_packages(self) -> None: """ Load requirements list """ - if self.enable_amd: - suffix = "amd.txt" - elif self.enable_cuda: - suffix = "nvidia.txt" - elif self.enable_apple_silicon: - suffix = "apple_silicon.txt" - else: - suffix = "cpu.txt" - req_files = ["_requirements_base.txt", f"requirements_{suffix}"] + req_files = ["_requirements_base.txt", f"requirements_{self.backend}.txt"] pypath = os.path.dirname(os.path.realpath(__file__)) requirements = [] for req_file in req_files: @@ -194,7 +190,7 @@ def _check_system(self) -> None: logger.error("Your system %s is not supported!", self.os_version[0]) sys.exit(1) if self.os_version[0].lower() == "darwin" and platform.machine() == "arm64": - self.enable_apple_silicon = True + self.backend = "apple_silicon" if not self.updater and not self.is_conda: logger.error("Setting up Faceswap for Apple Silicon outside of a Conda " @@ -212,7 +208,7 @@ def _check_python(self) -> None: logger.error("Please run this script with Python version 3.7 to 3.9 64bit and try " "again.") sys.exit(1) - if self.enable_amd and sys.version_info >= (3, 9): + if self.backend == "amd" and sys.version_info >= (3, 9): logger.error("The AMD version of Faceswap cannot be installed on versions of Python " "higher than 3.8") sys.exit(1) @@ -280,7 +276,7 @@ def get_installed_conda_packages(self) -> Dict[str, str]: def update_tf_dep(self) -> None: """ Update Tensorflow Dependency """ - if self.is_conda or not self.enable_cuda: + if self.is_conda or self.backend != "nvidia": # CPU/AMD doesn't need Cuda and Conda handles Cuda and cuDNN so nothing to do here return @@ -336,15 +332,7 @@ def update_tf_dep(self) -> None: def set_config(self) -> None: """ Set the backend in the faceswap config file """ - if self.enable_amd: - backend = "amd" - elif self.enable_cuda: - backend = "nvidia" - elif self.enable_apple_silicon: - backend = "apple_silicon" - else: - backend = "cpu" - config = {"backend": backend} + config = {"backend": self.backend} pypath = os.path.dirname(os.path.realpath(__file__)) config_file = os.path.join(pypath, "config", ".faceswap") with open(config_file, "w", encoding="utf8") as cnf: @@ -373,9 +361,9 @@ def _set_env_vars(self) -> None: if not self.is_conda: return - linux_update = self.os_version[0].lower() == "linux" and self.enable_cuda + linux_update = self.os_version[0].lower() == "linux" and self.backend == "nvidia" windows_update = (self.os_version[0].lower() == "windows" and - self.enable_amd and (3, 8) <= sys.version_info < (3, 9)) + self.backend == "amd" and (3, 8) <= sys.version_info < (3, 9)) if not linux_update and not windows_update: return @@ -434,7 +422,7 @@ def __init__(self, environment: Environment) -> None: if self._env.is_installer: return # Checks not required for Apple Silicon - if self._env.enable_apple_silicon: + if self._env.backend == "apple_silicon": return self._user_input() self._check_cuda() @@ -442,32 +430,44 @@ def __init__(self, environment: Environment) -> None: if self._env.os_version[0] == "Windows": self._tips.pip() + def _directml_ask_enable(self) -> None: + """ Set backend to 'directml' if OS is Windows and DirectML support required """ + if self._env.os_version[0] != "Windows": + return + logger.info("DirectML support:\r\nIf you are using an AMD or Intel GPU, then select 'yes'." + "\r\nNvidia users should answer 'no'.") + i = input("Enable DirectML Support? [y/N] ") + if i in ("Y", "y"): + logger.info("DirectML Support Enabled") + self._env.backend = "directml" + + def _amd_ask_enable(self) -> None: + """ Set backend to 'amd' to use plaidML if AMD support required """ + logger.info("AMD Support:\r\nThis version is deprecated and will be removed from a future " + "update.\r\n" + "AMD users should select 'DirectML support' if possible.\r\n" + "Nvidia Users MUST answer 'no' to this option.") + i = input("Enable AMD Support? [y/N] ") + if i in ("Y", "y"): + logger.info("AMD Support Enabled") + self._env.backend = "amd" + def _user_input(self) -> None: """ Get user input for AMD/Cuda/Docker """ - self._amd_ask_enable() - if not self._env.enable_amd: + self._directml_ask_enable() + if not self._env.backend: + self._amd_ask_enable() + if not self._env.backend: self._docker_ask_enable() self._cuda_ask_enable() if self._env.os_version[0] != "Linux" and (self._env.enable_docker - and self._env.enable_cuda): + and self._env.backend == "nvidia"): self._docker_confirm() if self._env.enable_docker: self._docker_tips() self._env.set_config() sys.exit(0) - def _amd_ask_enable(self) -> None: - """ Enable or disable Plaidml for AMD""" - logger.info("AMD Support: AMD GPU support is currently limited.\r\n" - "Nvidia Users MUST answer 'no' to this option.") - i = input("Enable AMD Support? [y/N] ") - if i in ("Y", "y"): - logger.info("AMD Support Enabled") - self._env.enable_amd = True - else: - logger.info("AMD Support Disabled") - self._env.enable_amd = False - def _docker_ask_enable(self) -> None: """ Enable or disable Docker """ i = input("Enable Docker? [y/N] ") @@ -485,11 +485,11 @@ def _docker_confirm(self) -> None: self._docker_ask_enable() if self._env.enable_docker: logger.warning("CUDA Disabled") - self._env.enable_cuda = False + self._env.backend = "cpu" def _docker_tips(self) -> None: """ Provide tips for Docker use """ - if not self._env.enable_cuda: + if self._env.backend != "nvidia": self._tips.docker_no_cuda() else: self._tips.docker_cuda() @@ -499,14 +499,11 @@ def _cuda_ask_enable(self) -> None: i = input("Enable CUDA? [Y/n] ") if i in ("", "Y", "y"): logger.info("CUDA Enabled") - self._env.enable_cuda = True - else: - logger.info("CUDA Disabled") - self._env.enable_cuda = False + self._env.backend = "nvidia" def _check_cuda(self) -> None: """ Check for Cuda and cuDNN Locations. """ - if not self._env.enable_cuda: + if self._env.backend != "nvidia": logger.debug("Skipping Cuda checks as not enabled") return @@ -972,7 +969,7 @@ class Installer(): command: list The command to run is_gui: bool - ``True if the process is being called from the Faceswap GUI + ``True`` if the process is being called from the Faceswap GUI """ def __init__(self, environment: Environment, @@ -1059,7 +1056,7 @@ class PexpectInstaller(Installer): # pylint: disable=too-few-public-methods command: list The command to run is_gui: bool - ``True if the process is being called from the Faceswap GUI + ``True`` if the process is being called from the Faceswap GUI """ def call(self) -> int: """ Install a package using the Pexpect module @@ -1106,7 +1103,7 @@ class WinPTYInstaller(Installer): # pylint: disable=too-few-public-methods command: list The command to run is_gui: bool - ``True if the process is being called from the Faceswap GUI + ``True`` if the process is being called from the Faceswap GUI """ def __init__(self, environment: Environment, @@ -1244,7 +1241,7 @@ class SubProcInstaller(Installer): command: list The command to run is_gui: bool - ``True if the process is being called from the Faceswap GUI + ``True`` if the process is being called from the Faceswap GUI """ def __init__(self, environment: Environment, diff --git a/tests/lib/gpu_stats/__init__.py b/tests/lib/gpu_stats/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/gpu_stats/_base_test.py b/tests/lib/gpu_stats/_base_test.py new file mode 100644 index 0000000000..97485f513e --- /dev/null +++ b/tests/lib/gpu_stats/_base_test.py @@ -0,0 +1,161 @@ +#!/usr/bin python3 +""" Pytest unit tests for :mod:`lib.gpu_stats._base` """ +from dataclasses import dataclass +from typing import cast +from unittest.mock import MagicMock + +import pytest +import pytest_mock + +# pylint:disable=protected-access +from lib.gpu_stats import _base +from lib.gpu_stats._base import BiggestGPUInfo, GPUInfo, _GPUStats, set_exclude_devices +from lib.utils import get_backend + + +def test_set_exclude_devices(monkeypatch: pytest.MonkeyPatch) -> None: + """ Test that :func:`~lib.gpu_stats._base.set_exclude_devices` adds devices + + Parameters + ---------- + monkeypatch: :class:`pytest.MonkeyPatch` + Monkey patching _EXCLUDE_DEVICES + """ + monkeypatch.setattr(_base, "_EXCLUDE_DEVICES", []) + assert not _base._EXCLUDE_DEVICES + set_exclude_devices([0, 1]) + assert _base._EXCLUDE_DEVICES == [0, 1] + + +@dataclass +class _DummyData: + """ Dummy data for initializing and testing :class:`~lib.gpu_stats._base._GPUStats` """ + device_count = 2 + active_devices = [0, 1] + handles = [0, 1] + driver = "test_driver" + device_names = ['test_device_0', 'test_device_1'] + vram = [1024, 2048] + free_vram = [512, 1024] + + +@pytest.fixture(name="gpu_stats_instance") +def fixture__gpu_stats_instance(mocker: pytest_mock.MockerFixture) -> _GPUStats: + """ Create a fixture of the :class:`~lib.gpu_stats._base._GPUStats` object + + Parameters + ---------- + mocker: :class:`pytest_mock.MockerFixture` + Mocker for dummying in function calls + """ + mocker.patch.object(_GPUStats, '_initialize') + mocker.patch.object(_GPUStats, '_shutdown') + mocker.patch.object(_GPUStats, '_get_device_count', return_value=_DummyData.device_count) + mocker.patch.object(_GPUStats, '_get_active_devices', return_value=_DummyData.active_devices) + mocker.patch.object(_GPUStats, '_get_handles', return_value=_DummyData.handles) + mocker.patch.object(_GPUStats, '_get_driver', return_value=_DummyData.driver) + mocker.patch.object(_GPUStats, '_get_device_names', return_value=_DummyData.device_names) + mocker.patch.object(_GPUStats, '_get_vram', return_value=_DummyData.vram) + mocker.patch.object(_GPUStats, '_get_free_vram', return_value=_DummyData.free_vram) + gpu_stats = _GPUStats() + return gpu_stats + + +def test__gpu_stats_init_(gpu_stats_instance: _GPUStats) -> None: + """ Test that the base :class:`~lib.gpu_stats._base._GPUStats` class initializes correctly + + Parameters + ---------- + gpu_stats_instance: :class:`_GPUStats` + Fixture instance of the _GPUStats base class + """ + # Ensure that the object is initialized and shutdown correctly + assert gpu_stats_instance._is_initialized is False + assert cast(MagicMock, gpu_stats_instance._initialize).call_count == 1 + assert cast(MagicMock, gpu_stats_instance._shutdown).call_count == 1 + + # Ensure that the object correctly gets and stores the device count, active devices, + # handles, driver, device names, and VRAM information + assert gpu_stats_instance.device_count == _DummyData.device_count + assert gpu_stats_instance._active_devices == _DummyData.active_devices + assert gpu_stats_instance._handles == _DummyData.handles + assert gpu_stats_instance._driver == _DummyData.driver + assert gpu_stats_instance._device_names == _DummyData.device_names + assert gpu_stats_instance._vram == _DummyData.vram + + +def test__gpu_stats_properties(gpu_stats_instance: _GPUStats) -> None: + """ Test that the :class:`~lib.gpu_stats._base._GPUStats` properties are set and formatted + correctly. + + Parameters + ---------- + gpu_stats_instance: :class:`_GPUStats` + Fixture instance of the _GPUStats base class + """ + assert gpu_stats_instance.cli_devices == ['0: test_device_0', '1: test_device_1'] + assert gpu_stats_instance.sys_info == GPUInfo(vram=_DummyData.vram, + vram_free=_DummyData.free_vram, + driver=_DummyData.driver, + devices=_DummyData.device_names, + devices_active=_DummyData.active_devices) + + +def test__gpu_stats_get_card_most_free(mocker: pytest_mock.MockerFixture, + gpu_stats_instance: _GPUStats) -> None: + """ Confirm that :func:`ib.gpu_stats._base._GPUStats.get_card_most_free` functions + correctly + + Parameters + ---------- + mocker: :class:`pytest_mock.MockerFixture` + Mocker for dummying in function calls + gpu_stats_instance: :class:`_GPUStats` + Fixture instance of the _GPUStats base class + """ + assert gpu_stats_instance.get_card_most_free() == BiggestGPUInfo(card_id=1, + device='test_device_1', + free=1024, + total=2048) + mocker.patch.object(_GPUStats, '_get_active_devices', return_value=[]) + gpu_stats = _GPUStats() + assert gpu_stats.get_card_most_free() == BiggestGPUInfo(card_id=-1, + device='No GPU devices found', + free=2048, + total=2048) + + +def test__gpu_stats_exclude_all_devices(gpu_stats_instance: _GPUStats) -> None: + """ Ensure that the object correctly returns whether all devices are excluded + + Parameters + ---------- + gpu_stats_instance: :class:`_GPUStats` + Fixture instance of the _GPUStats base class + """ + assert gpu_stats_instance.exclude_all_devices is False + set_exclude_devices([0, 1]) + assert gpu_stats_instance.exclude_all_devices is True + + +def test__gpu_stats_no_active_devices( + caplog: pytest.LogCaptureFixture, + gpu_stats_instance: _GPUStats, # pylint:disable=unused-argument + mocker: pytest_mock.MockerFixture) -> None: + """ Ensure that no active GPUs raises a warning when not in CPU mode + + Parameters + ---------- + caplog: :class:`pytest.LogCaptureFixture` + Pytest's log capturing fixture + gpu_stats_instance: :class:`_GPUStats` + Fixture instance of the _GPUStats base class + mocker: :class:`pytest_mock.MockerFixture` + Mocker for dummying in function calls + """ + if get_backend() == "cpu": + return + caplog.set_level("WARNING") + mocker.patch.object(_GPUStats, '_get_active_devices', return_value=[]) + _GPUStats() + assert "No GPU detected" in caplog.messages diff --git a/tests/lib/utils_test.py b/tests/lib/utils_test.py new file mode 100644 index 0000000000..4419f6f903 --- /dev/null +++ b/tests/lib/utils_test.py @@ -0,0 +1,618 @@ +#!/usr/bin python3 +""" Pytest unit tests for :mod:`lib.utils` """ +import os +import time +import warnings +import zipfile + +from io import StringIO +from socket import timeout as socket_timeout, error as socket_error +from shutil import rmtree +from typing import Any, cast, List, Tuple, Union +from unittest.mock import MagicMock +from urllib import error as urlliberror + +import pytest +import pytest_mock + +from lib import utils +from lib.utils import ( + _Backend, camel_case_split, convert_to_secs, DebugTimes, deprecation_warning, FaceswapError, + full_path_split, get_backend, get_dpi, get_folder, get_image_paths, get_tf_version, GetModel, + safe_shutdown, set_backend, set_system_verbosity) + +from lib.logger import log_setup +log_setup("DEBUG", "", "PyTest, False") # Need to setup logging to avoid trace/verbose errors + + +# pylint:disable=protected-access + + +# Backend tests +def test_set_backend(monkeypatch: pytest.MonkeyPatch) -> None: + """ Test the :func:`~lib.utils.set_backend` function + + Parameters + ---------- + monkeypatch: :class:`pytest.MonkeyPatch` + Monkey patching _FS_BACKEND + """ + monkeypatch.setattr(utils, "_FS_BACKEND", "cpu") # _FS_BACKEND already defined + set_backend("directml") + assert utils._FS_BACKEND == "directml" + monkeypatch.delattr(utils, "_FS_BACKEND") # _FS_BACKEND is not already defined + set_backend("amd") + assert utils._FS_BACKEND == "amd" + + +def test_get_backend(monkeypatch: pytest.MonkeyPatch) -> None: + """ Test the :func:`~lib.utils.get_backend` function + + Parameters + ---------- + monkeypatch: :class:`pytest.MonkeyPatch` + Monkey patching _FS_BACKEND + """ + monkeypatch.setattr(utils, "_FS_BACKEND", "apple-silicon") + assert get_backend() == "apple-silicon" + + +def test__backend(monkeypatch: pytest.MonkeyPatch) -> None: + """ Test the :class:`~lib.utils._Backend` class + + Parameters + ---------- + monkeypatch: :class:`pytest.MonkeyPatch` + Monkey patching :func:`os.environ`, :func:`os.path.isfile`, :func:`builtins.open` and + :func:`builtins.input` + """ + monkeypatch.setattr("os.environ", {"FACESWAP_BACKEND": "nvidia"}) # Environment variable set + backend = _Backend() + assert backend.backend == "nvidia" + + monkeypatch.setattr("os.environ", {}) # Environment variable not set, dummy in config file + monkeypatch.setattr("os.path.isfile", lambda x: True) + monkeypatch.setattr("builtins.open", lambda *args, **kwargs: StringIO('{"backend": "amd"}')) + backend = _Backend() + assert backend.backend == "amd" + + monkeypatch.setattr("os.path.isfile", lambda x: False) # no config file, dummy in user input + monkeypatch.setattr("builtins.input", lambda x: "3") + backend = _Backend() + assert backend._configure_backend() == "nvidia" + + +# Folder and path utils +def test_get_folder(tmp_path: str) -> None: + """ Unit test for :func:`~lib.utils.get_folder` + + Parameters + ---------- + tmp_path: str + pytest temporary path to generate folders + """ + # New folder + path = os.path.join(tmp_path, "test_new_folder") + expected_output = path + assert not os.path.isdir(path) + assert get_folder(path) == expected_output + assert os.path.isdir(path) + + # Test not creating a new folder when it already exists + path = os.path.join(tmp_path, "test_new_folder") + expected_output = path + assert os.path.isdir(path) + stats = os.stat(path) + assert get_folder(path) == expected_output + assert os.path.isdir(path) + assert stats == os.stat(path) + + # Test not creating a new folder when make_folder is False + path = os.path.join(tmp_path, "test_no_folder") + expected_output = "" + assert get_folder(path, make_folder=False) == expected_output + assert not os.path.isdir(path) + + +def test_get_image_paths(tmp_path: str) -> None: + """ Unit test for :func:`~lib.utils.test_get_image_paths` + + Parameters + ---------- + tmp_path: str + pytest temporary path to generate folders + """ + # Test getting image paths from a folder with no images + test_folder = os.path.join(tmp_path, "test_image_folder") + os.makedirs(test_folder) + assert not get_image_paths(test_folder) + + # Populate 2 different image files and 1 text file + test_jpg_path = os.path.join(test_folder, "test_image.jpg") + test_png_path = os.path.join(test_folder, "test_image.png") + test_txt_path = os.path.join(test_folder, "test_file.txt") + for fname in (test_jpg_path, test_png_path, test_txt_path): + with open(fname, "a", encoding="utf-8"): + pass + + # Test getting any image paths from a folder with images and random files + exists = [os.path.join(test_folder, img) + for img in os.listdir(test_folder) if os.path.splitext(img)[-1] != ".txt"] + assert get_image_paths(test_folder) == exists + + # Test getting image paths from a folder with images with a specific extension + exists = [os.path.join(test_folder, img) + for img in os.listdir(test_folder) if os.path.splitext(img)[-1] == ".png"] + assert get_image_paths(test_folder, extension=".png") == exists + + +_PARAMS = [("/path/to/file.txt", ["/", "path", "to", "file.txt"]), # Absolute + ("/path/to/directory/", ["/", "path", "to", "directory"]), + ("/path/to/directory", ["/", "path", "to", "directory"]), + ("path/to/file.txt", ["path", "to", "file.txt"]), # Relative + ("path/to/directory/", ["path", "to", "directory"]), + ("path/to/directory", ["path", "to", "directory"]), + ("", []), # Edge cases + ("/", ["/"]), + (".", ["."]), + ("..", [".."])] + + +@pytest.mark.parametrize("path,result", _PARAMS, ids=[f'"{p[0]}"' for p in _PARAMS]) +def test_full_path_split(path: str, result: List[str]) -> None: + """ Test the :func:`~lib.utils.full_path_split` function works correctly + + Parameters + ---------- + path: str + The path to test + result: list + The expected result from the path + """ + split = full_path_split(path) + assert isinstance(split, list) + assert split == result + + +_PARAMS = [("camelCase", ["camel", "Case"]), + ("camelCaseTest", ["camel", "Case", "Test"]), + ("camelCaseTestCase", ["camel", "Case", "Test", "Case"]), + ("CamelCase", ["Camel", "Case"]), + ("CamelCaseTest", ["Camel", "Case", "Test"]), + ("CamelCaseTestCase", ["Camel", "Case", "Test", "Case"]), + ("CAmelCASETestCase", ["C", "Amel", "CASE", "Test", "Case"]), + ("camelcasetestcase", ["camelcasetestcase"]), + ("CAMELCASETESTCASE", ["CAMELCASETESTCASE"]), + ("", [])] + + +@pytest.mark.parametrize("text, result", _PARAMS, ids=[f'"{p[0]}"' for p in _PARAMS]) +def test_camel_case_split(text: str, result: List[str]) -> None: + """ Test the :func:`~lib.utils.camel_case_spli` function works correctly + + Parameters + ---------- + text: str + The camel case text to test + result: list + The expected result from the path + """ + split = camel_case_split(text) + assert isinstance(split, list) + assert split == result + + +# General utils +def test_get_tf_version() -> None: + """ Test the :func:`~lib.utils.get_tf_version` function version returns correctly in range """ + tf_version = get_tf_version() + assert (2, 2) <= tf_version < (2, 11) + + +def test_get_dpi() -> None: + """ Test the :func:`~lib.utils.get_dpi` function version returns correctly in a sane + range """ + dpi = get_dpi() + assert isinstance(dpi, float) or dpi is None + if dpi is None: # No display detected + return + assert dpi > 0 + assert dpi < 600.0 + + +_SECPARAMS = [((1, ), 1), # 1 argument + ((10, ), 10), + ((0, 1), 1), + ((0, 60), 60), # 2 arguments + ((1, 0), 60), + ((1, 1), 61), + ((0, 0, 1), 1), + ((0, 0, 60), 60), # 3 arguments + ((0, 1, 0), 60), + ((1, 0, 0), 3600), + ((1, 1, 1), 3661)] + + +@pytest.mark.parametrize("args,result", _SECPARAMS, ids=[str(p[0]) for p in _SECPARAMS]) +def test_convert_to_secs(args: Tuple[int, ...], result: int) -> None: + """ Test the :func:`~lib.utils.convert_to_secs` function works correctly + + Parameters + ---------- + args: tuple + Tuple of 1, 2 or 3 integers to pass to the function + result: int + The expected results for the args tuple + """ + secs = convert_to_secs(*args) + assert isinstance(secs, int) + assert secs == result + + +@pytest.mark.parametrize("log_level", ["DEBUG", "INFO", "WARNING", "ERROR"]) +def test_set_system_verbosity(log_level: str) -> None: + """ Test the :func:`~lib.utils.set_system_verbosity` function works correctly + + Parameters + ---------- + log_level: str + The logging loglevel in upper text format + """ + # Set TF Env Variable + tf_set_level = "0" if log_level == "DEBUG" else "3" + set_system_verbosity(log_level) + tf_get_level = os.environ["TF_CPP_MIN_LOG_LEVEL"] + assert tf_get_level == tf_set_level + warn_filters = [filt for filt in warnings.filters + if filt[0] == "ignore" + and filt[2] in (FutureWarning, DeprecationWarning, UserWarning)] + # Python Warnings + # DeprecationWarning is already ignored by default, so there should be 1 warning for debug + # warning. 3 for the rest + num_warnings = 1 if log_level == "DEBUG" else 3 + warn_count = len(warn_filters) + assert warn_count == num_warnings + + +@pytest.mark.parametrize("additional_info", [None, "additional information"]) +def test_deprecation_warning(caplog: pytest.LogCaptureFixture, additional_info: str) -> None: + """ Test the :func:`~lib.utils.deprecation_warning` function works correctly + + Parameters + ---------- + caplog: :class:`pytest.LogCaptureFixture` + Pytest's log capturing fixture + additional_info: str + Additional information to pass to the warning function + """ + func_name = "function_name" + test = f"{func_name} has been deprecated and will be removed from a future update." + if additional_info: + test = f"{test} {additional_info}" + deprecation_warning(func_name, additional_info=additional_info) + assert test in caplog.text + + +@pytest.mark.parametrize("got_error", [True, False]) +def test_safe_shutdown(caplog: pytest.LogCaptureFixture, got_error: bool) -> None: + """ Test the :func:`~lib.utils.safe_shutdown` function works correctly + + Parameters + ---------- + caplog: :class:`pytest.LogCaptureFixture` + Pytest's log capturing fixture + got_error: bool + The got_error parameter to pass to safe_shutdown + """ + caplog.set_level("DEBUG") + with pytest.raises(SystemExit) as wrapped_exit: + safe_shutdown(got_error=got_error) + + exit_value = 1 if got_error else 0 + assert wrapped_exit.typename == "SystemExit" + assert wrapped_exit.value.code == exit_value + assert "Safely shutting down" in caplog.messages + assert "Cleanup complete. Shutting down queue manager and exiting" in caplog.messages + + +def test_faceswap_error(): + """ Test the :class:`~lib.utils.FaceswapError` raises correctly """ + with pytest.raises(Exception): + raise FaceswapError + + +# GetModel class +@pytest.fixture(name="get_model_instance") +def fixture_get_model_instance(monkeypatch: pytest.MonkeyPatch, + tmp_path: pytest.TempdirFactory, + request: pytest.FixtureRequest) -> GetModel: + """ Create a fixture of the :class:`~lib.utils.GetModel` object, prevent _get() from running at + __init__ and point the cache_dir at our local test folder """ + cache_dir = os.path.join(str(tmp_path), "get_model") + os.mkdir(cache_dir) + + model_filename = "test_model_file_v1.h5" + git_model_id = 123 + + original_get = GetModel._get + # Patch out _get() so it is not called from __init__() + monkeypatch.setattr(utils.GetModel, "_get", lambda x: None) + model_instance = GetModel(model_filename, git_model_id) + # Reinsert _get() so we can test it + monkeypatch.setattr(model_instance, "_get", original_get) + model_instance._cache_dir = cache_dir + + def teardown(): + rmtree(cache_dir) + + request.addfinalizer(teardown) + return model_instance + + +_INPUT = ("test_model_file_v3.h5", + ["test_multi_model_file_v1.1.npy", "test_multi_model_file_v1.2.npy"]) +_EXPECTED = ((["test_model_file_v3.h5"], "test_model_file_v3", "test_model_file", 3), + (["test_multi_model_file_v1.1.npy", "test_multi_model_file_v1.2.npy"], + "test_multi_model_file_v1", "test_multi_model_file", 1)) + + +@pytest.mark.parametrize("filename,results", zip(_INPUT, _EXPECTED), ids=[str(i) for i in _INPUT]) +def test_get_model_model_filename_input( + get_model_instance: GetModel, # pylint:disable=unused-argument + filename: Union[str, List[str]], + results: Union[str, List[str]]) -> None: + """ Test :class:`~lib.utils.GetModel` filename parsing works + + Parameters + --------- + get_model_instance: `~lib.utils.GetModel` + The patched instance of the class + filename: list or str + The test filenames + results: tuple + The expected results for :attr:`_model_filename`, :attr:`_model_full_name`, + :attr:`_model_name`, :attr:`_model_version` respectively + """ + model = GetModel(filename, 123) + assert model._model_filename == results[0] + assert model._model_full_name == results[1] + assert model._model_name == results[2] + assert model._model_version == results[3] + + +def test_get_model_attributes(get_model_instance: GetModel) -> None: + """ Test :class:`~lib.utils.GetModel` private attributes set correctly + + Parameters + --------- + get_model_instance: `~lib.utils.GetModel` + The patched instance of the class + """ + model = get_model_instance + assert model._git_model_id == 123 + assert model._url_base == ("https://github.com/deepfakes-models/faceswap-models" + "/releases/download") + assert model._chunk_size == 1024 + assert model._retries == 6 + + +def test_get_model_properties(get_model_instance: GetModel) -> None: + """ Test :class:`~lib.utils.GetModel` calculated attributes return correctly + + Parameters + --------- + get_model_instance: `~lib.utils.GetModel` + The patched instance of the class + """ + model = get_model_instance + assert model.model_path == os.path.join(model._cache_dir, "test_model_file_v1.h5") + assert model._model_zip_path == os.path.join(model._cache_dir, "test_model_file_v1.zip") + assert not model._model_exists + assert model._url_download == ("https://github.com/deepfakes-models/faceswap-models/releases/" + "download/v123.1/test_model_file_v1.zip") + assert model._url_partial_size == 0 + + +@pytest.mark.parametrize("model_exists", (True, False)) +def test_get_model__get(mocker: pytest_mock.MockerFixture, + get_model_instance: GetModel, + model_exists: bool) -> None: + """ Test :func:`~lib.utils.GetModel._get` executes logic correctly + + Parameters + --------- + mocker: :class:`pytest_mock.MockerFixture` + Mocker for dummying in function calls + get_model_instance: `~lib.utils.GetModel` + The patched instance of the class + model_exists: bool + For testing the function when a model exists and when it does not + """ + model = get_model_instance + model._download_model = cast(MagicMock, mocker.MagicMock()) # type:ignore + model._unzip_model = cast(MagicMock, mocker.MagicMock()) # type:ignore + os_remove = mocker.patch("os.remove") + + if model_exists: # Dummy in a model file + assert isinstance(model.model_path, str) + with open(model.model_path, "a", encoding="utf-8"): + pass + + model._get(model) # type:ignore + + assert (model_exists and not model._download_model.called) or ( + not model_exists and model._download_model.called) + assert (model_exists and not model._unzip_model.called) or ( + not model_exists and model._unzip_model.called) + assert model_exists or not (model_exists and os_remove.called) + os_remove.reset_mock() + + +_DLPARAMS = [(None, None), + (socket_error, ()), + (socket_timeout, ()), + (urlliberror.URLError, ("test_reason", )), + (urlliberror.HTTPError, ("test_uri", 400, "", "", 0))] + + +@pytest.mark.parametrize("error_type,error_args", _DLPARAMS, ids=[str(p[0]) for p in _DLPARAMS]) +def test_get_model__download_model(mocker: pytest_mock.MockerFixture, + get_model_instance: GetModel, + error_type: Any, + error_args: Tuple[Union[str, int], ...]) -> None: + """ Test :func:`~lib.utils.GetModel._download_model` executes its logic correctly + + Parameters + --------- + mocker: :class:`pytest_mock.MockerFixture` + Mocker for dummying in function calls + get_model_instance: `~lib.utils.GetModel` + The patched instance of the class + error_type: connection error type or ``None`` + Connection error type to mock, or ``None`` for succesful download + error_args: tuple + The arguments to be passed to the exception to be raised + """ + mock_urlopen = mocker.patch("urllib.request.urlopen") + if not error_type: # Model download is successful + get_model_instance._write_zipfile = cast(MagicMock, mocker.MagicMock()) # type:ignore + get_model_instance._download_model() + assert mock_urlopen.called + assert get_model_instance._write_zipfile.called + else: # Test that the process exits on download errors + mock_urlopen.side_effect = error_type(*error_args) + with pytest.raises(SystemExit): + get_model_instance._download_model() + mock_urlopen.reset_mock() + + +@pytest.mark.parametrize("dl_type", ["complete", "new", "continue"]) +def test_get_model__write_zipfile(mocker: pytest_mock.MockerFixture, + get_model_instance: GetModel, + dl_type: str) -> None: + """ Test :func:`~lib.utils.GetModel._write_zipfile` executes its logic correctly + + Parameters + --------- + mocker: :class:`pytest_mock.MockerFixture` + Mocker for dummying in function calls + get_model_instance: `~lib.utils.GetModel` + The patched instance of the class + dl_type: str + The type of read to attemp + """ + response = mocker.MagicMock() + assert not os.path.isfile(get_model_instance._model_zip_path) + + downloaded = 10 if dl_type == "complete" else 0 + response.getheader.return_value = 0 + + if dl_type in ("new", "continue"): + chunks = [32, 64, 128, 256, 512, 1024] + data = [b"\x00" * size for size in chunks] + [b""] + response.getheader.return_value = sum(chunks) + response.read.side_effect = data + + if dl_type == "continue": # Write a partial download of the correct size + with open(get_model_instance._model_zip_path, "wb") as partial: + partial.write(b"\x00" * sum(chunks)) + downloaded = os.path.getsize(get_model_instance._model_zip_path) + + get_model_instance._write_zipfile(response, downloaded) + + if dl_type == "complete": # Already downloaded. No more tests + assert not response.read.called + return + + assert response.read.call_count == len(data) # all data read + assert os.path.isfile(get_model_instance._model_zip_path) + downloaded_size = os.path.getsize(get_model_instance._model_zip_path) + downloaded_size = downloaded_size if dl_type == "new" else downloaded_size // 2 + assert downloaded_size == sum(chunks) + + +def test_get_model__unzip_model(mocker: pytest_mock.MockerFixture, + get_model_instance: GetModel) -> None: + """ Test :func:`~lib.utils.GetModel._unzip_model` executes its logic correctly + + Parameters + --------- + mocker: :class:`pytest_mock.MockerFixture` + Mocker for dummying in function calls + get_model_instance: `~lib.utils.GetModel` + The patched instance of the class + """ + mock_zipfile = mocker.patch("zipfile.ZipFile") + # Successful + get_model_instance._unzip_model() + assert mock_zipfile.called + mock_zipfile.reset_mock() + # Error + mock_zipfile.side_effect = zipfile.BadZipFile() + with pytest.raises(SystemExit): + get_model_instance._unzip_model() + mock_zipfile.reset_mock() + + +def test_get_model__write_model(mocker: pytest_mock.MockerFixture, + get_model_instance: GetModel) -> None: + """ Test :func:`~lib.utils.GetModel._write_model` executes its logic correctly + + Parameters + --------- + mocker: :class:`pytest_mock.MockerFixture` + Mocker for dummying in function calls + get_model_instance: `~lib.utils.GetModel` + The patched instance of the class + """ + out_file = os.path.join(get_model_instance._cache_dir, get_model_instance._model_filename[0]) + chunks = [8, 16, 32, 64, 128, 256, 512, 1024] + data = [b"\x00" * size for size in chunks] + [b""] + assert not os.path.isfile(out_file) + mock_zipfile = mocker.patch("zipfile.ZipFile") + mock_zipfile.namelist.return_value = get_model_instance._model_filename + mock_zipfile.open.return_value = mock_zipfile + mock_zipfile.read.side_effect = data + get_model_instance._write_model(mock_zipfile) + assert mock_zipfile.read.call_count == len(data) + assert os.path.isfile(out_file) + assert os.path.getsize(out_file) == sum(chunks) + + +# DebugTimes class +def test_debug_times(): + """ Test :class:`~lib.utils.DebugTimes` executes its logic correctly """ + debug_times = DebugTimes() + + debug_times.step_start("Test1") + time.sleep(0.1) + debug_times.step_end("Test1") + + debug_times.step_start("Test2") + time.sleep(0.2) + debug_times.step_end("Test2") + + debug_times.step_start("Test1") + time.sleep(0.1) + debug_times.step_end("Test1") + + debug_times.summary() + + # Ensure that the summary method prints the min, mean, and max times for each step + assert debug_times._display["min"] is True + assert debug_times._display["mean"] is True + assert debug_times._display["max"] is True + + # Ensure that the summary method includes the correct number of items for each step + assert len(debug_times._times["Test1"]) == 2 + assert len(debug_times._times["Test2"]) == 1 + + # Ensure that the summary method includes the correct min, mean, and max times for each step + assert min(debug_times._times["Test1"]) == pytest.approx(0.1, abs=1e-1) + assert min(debug_times._times["Test2"]) == pytest.approx(0.2, abs=1e-1) + assert max(debug_times._times["Test1"]) == pytest.approx(0.1, abs=1e-1) + assert max(debug_times._times["Test2"]) == pytest.approx(0.2, abs=1e-1) + assert (sum(debug_times._times["Test1"]) / + len(debug_times._times["Test1"])) == pytest.approx(0.1, abs=1e-1) + assert (sum(debug_times._times["Test2"]) / + len(debug_times._times["Test2"]) == pytest.approx(0.2, abs=1e-1)) diff --git a/tests/startup_test.py b/tests/startup_test.py index ece5af1e2a..8f7eb5d0e1 100644 --- a/tests/startup_test.py +++ b/tests/startup_test.py @@ -24,12 +24,14 @@ def test_backend(dummy): # pylint:disable=unused-argument """ Sanity check to ensure that Keras backend is returning the correct object type. """ test_var = K.variable((1, 1, 4, 4)) lib = inspect.getmodule(test_var).__name__.split(".")[0] - assert (_BACKEND == "cpu" and lib == "tensorflow") or (_BACKEND == "amd" and lib == "plaidml") + assert ((_BACKEND in ("cpu", "directml") and lib == "tensorflow") + or (_BACKEND == "amd" and lib == "plaidml")) @pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) def test_keras(dummy): # pylint:disable=unused-argument """ Sanity check to ensure that tensorflow keras is being used for CPU and standard keras for AMD. """ - assert ((_BACKEND == "cpu" and keras.__version__ in ("2.7.0", "2.8.0", "2.9.0", "2.10.0")) or - (_BACKEND == "amd" and keras.__version__ == "2.2.4")) + assert ((_BACKEND in ("cpu", "directml") + and keras.__version__ in ("2.7.0", "2.8.0", "2.9.0", "2.10.0")) + or (_BACKEND == "amd" and keras.__version__ == "2.2.4")) diff --git a/tools/alignments/media.py b/tools/alignments/media.py index bdaffc7627..b2dc52ce09 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -29,7 +29,7 @@ class AlignmentData(Alignments): """ Class to hold the alignment data - Paramaters + Parameters ---------- alignments_file: str Full path to an alignments file @@ -47,7 +47,7 @@ def __init__(self, alignments_file: str) -> None: def check_file_exists(alignments_file: str) -> Tuple[str, str]: """ Check the alignments file exists - Paramaters + Parameters ---------- alignments_file: str Full path to an alignments file diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 1d4148d74d..967ef9cdcf 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -157,7 +157,7 @@ def __init__(self, arguments): def _set_output_folder(self, arguments): """ Set the output folder correctly if it has not been provided - Parameters: + Parameters ---------- arguments: :class:`argparse.Namespace` The command line arguments passed to the sort process @@ -192,7 +192,7 @@ def _set_output_folder(self, arguments): def _parse_arguments(self, arguments): """ Parse the arguments and update/format relevant choices - Parameters: + Parameters ---------- arguments: :class:`argparse.Namespace` The command line arguments passed to the sort process diff --git a/tools/sort/sort_methods.py b/tools/sort/sort_methods.py index a661c6a5ba..eec4638775 100644 --- a/tools/sort/sort_methods.py +++ b/tools/sort/sort_methods.py @@ -208,7 +208,7 @@ def update_png_header(self, filename: str, alignments: "PNGHeaderAlignmentsDict" class SortMethod(): """ Parent class for sort methods. All sort methods should inherit from this class - Parameters: + Parameters ---------- arguments: :class:`argparse.Namespace` The command line arguments passed to the sort process @@ -567,7 +567,7 @@ def binning(self) -> List[List[str]]: class SortBlur(SortMethod): """ Sort images by blur or blur-fft amount - Parameters: + Parameters ---------- arguments: :class:`argparse.Namespace` The command line arguments passed to the sort process @@ -691,7 +691,7 @@ def binning(self) -> List[List[str]]: class SortColor(SortMethod): """ Score by channel average intensity or black pixels. - Parameters: + Parameters ---------- arguments: :class:`argparse.Namespace` The command line arguments passed to the sort process @@ -827,7 +827,7 @@ def _sort_black_pixels(self) -> None: class SortFace(SortMethod): """ Sort by identity similarity using VGG Face 2 - Parameters: + Parameters ---------- arguments: :class:`argparse.Namespace` The command line arguments passed to the sort process @@ -935,7 +935,7 @@ def binning(self) -> List[List[str]]: class SortHistogram(SortMethod): """ Sort by image histogram similarity or dissimilarity - Parameters: + Parameters ---------- arguments: :class:`argparse.Namespace` The command line arguments passed to the sort process diff --git a/tools/sort/sort_methods_aligned.py b/tools/sort/sort_methods_aligned.py index d3bcbcf20c..d597def85e 100644 --- a/tools/sort/sort_methods_aligned.py +++ b/tools/sort/sort_methods_aligned.py @@ -26,7 +26,7 @@ class SortAlignedMetric(SortMethod): # pylint:disable=too-few-public-methods """ Sort by comparison of metrics stored in an Aligned Face objects. This is a parent class for sort by aligned metrics methods. Individual methods should inherit from this class - Parameters: + Parameters ---------- arguments: :class:`argparse.Namespace` The command line arguments passed to the sort process @@ -239,7 +239,7 @@ def binning(self) -> List[List[str]]: class SortFaceCNN(SortAlignedMetric): """ Sort by landmark similarity or dissimilarity - Parameters: + Parameters ---------- arguments: :class:`argparse.Namespace` The command line arguments passed to the sort process From 2f16f8aa3bfb30ce99c395b87a250518b2687f1a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 7 Jan 2023 19:19:07 +0000 Subject: [PATCH 780/981] Bugfix - Phaze-A, monkeypatch EfficientNet for MixedPrecision --- plugins/train/model/phaze_a.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 5829c562d9..589c26c586 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -27,7 +27,6 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name - if get_backend() == "amd": from keras import applications as kapp, backend as K from keras.layers import ( @@ -740,6 +739,7 @@ def __call__(self) -> keras.models.Model: var_x = input_ scaling = self._selected_model[0].scaling + if scaling: # Some models expect different scaling. logger.debug("Scaling to %s for '%s'", scaling, self._config["enc_architecture"]) @@ -751,6 +751,19 @@ def __call__(self) -> keras.models.Model: var_x = var_x * 2. var_x = var_x - 1.0 + if (self._config["enc_architecture"].startswith("efficientnet_b") + and self._config["mixed_precision"]): + # There is a bug in EfficientNet pre-processing where the normalized mean for the + # imagenet rgb values are not cast to float16 when mixed precision is enabled. + # We monkeypatch in a cast constant until the issue is resolved + # TODO revert if/when applying Imagenet Normalization works with mixed precision + # confirmed bugged: TF2.10 + logger.debug("Patching efficientnet.IMAGENET_STDDEV_RGB to float16 constant") + from keras.applications import efficientnet # pylint:disable=import-outside-toplevel + setattr(efficientnet, + "IMAGENET_STDDEV_RGB", + K.constant(efficientnet.IMAGENET_STDDEV_RGB, dtype="float16")) + var_x = self._get_encoder_model()(var_x) if self._config["bottleneck_in_encoder"]: From 0dbeafb195df629f386e79ae7e8f9f24fda50353 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 9 Jan 2023 12:29:18 +0000 Subject: [PATCH 781/981] bugfix: sysinfo - Fix getting free Vram on Nvidia - Don't halt sysinfo on GPU stats error - lib.sysinfo unit test --- docs/full/tests/lib.rst | 14 +- lib/gpu_stats/__init__.py | 2 +- lib/gpu_stats/nvidia.py | 8 + lib/sysinfo.py | 23 +- tests/lib/sysinfo_test.py | 438 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 481 insertions(+), 4 deletions(-) create mode 100644 tests/lib/sysinfo_test.py diff --git a/docs/full/tests/lib.rst b/docs/full/tests/lib.rst index 62b8842cba..244594f749 100644 --- a/docs/full/tests/lib.rst +++ b/docs/full/tests/lib.rst @@ -14,9 +14,21 @@ Subpackages gpu_stats +sysinfo module +************** +Unit tests for :class:`~lib.sysinfo` module + +.. rubric:: Module + +.. automodule:: tests.lib.sysinfo + :members: + :undoc-members: + :show-inheritance: + + utils_test module ***************** -Unit tests for lib.utils +Unit tests for :class:`~lib.utils` module .. rubric:: Module diff --git a/lib/gpu_stats/__init__.py b/lib/gpu_stats/__init__.py index 8fe016963e..d42401453f 100644 --- a/lib/gpu_stats/__init__.py +++ b/lib/gpu_stats/__init__.py @@ -6,7 +6,7 @@ from lib.utils import get_backend -from ._base import set_exclude_devices +from ._base import set_exclude_devices, GPUInfo backend = get_backend() diff --git a/lib/gpu_stats/nvidia.py b/lib/gpu_stats/nvidia.py index 959c9e42c5..8f8e8cef58 100644 --- a/lib/gpu_stats/nvidia.py +++ b/lib/gpu_stats/nvidia.py @@ -167,7 +167,15 @@ def _get_free_vram(self) -> List[int]: List of `float`s containing the amount of VRAM available, in Megabytes, for each connected GPU as corresponding to the values in :attr:`_handles """ + is_initialized = self._is_initialized + if not is_initialized: + self._initialize() + self._handles = self._get_handles() + vram = [pynvml.nvmlDeviceGetMemoryInfo(handle).free / (1024 * 1024) for handle in self._handles] + if not is_initialized: + self._shutdown() + self._log("debug", f"GPU VRAM free: {vram}") return vram diff --git a/lib/sysinfo.py b/lib/sysinfo.py index 7b0bd6d544..4dbfde2695 100644 --- a/lib/sysinfo.py +++ b/lib/sysinfo.py @@ -11,7 +11,7 @@ import psutil -from lib.gpu_stats import GPUStats +from lib.gpu_stats import GPUStats, GPUInfo from lib.utils import get_backend from setup import CudaCheck @@ -29,7 +29,7 @@ def __init__(self) -> None: cpu_count=os.cpu_count()) self._python = dict(implementation=platform.python_implementation(), version=platform.python_version()) - self._gpu = GPUStats(log=False).sys_info + self._gpu = self._get_gpu_info() self._cuda_check = CudaCheck() @property @@ -168,6 +168,25 @@ def _cudnn_version(self) -> str: retval += ". Check Conda packages for Conda cuDNN" return retval + def _get_gpu_info(self) -> GPUInfo: + """ Obtain GPU Stats. If an error is raised, swallow the error, and add to GPUInfo output + + Returns + ------- + :class:`~lib.gpu_stats.GPUInfo` + The information on connected GPUs + """ + try: + retval = GPUStats(log=False).sys_info + except Exception as err: # pylint:disable=broad-except + err_string = f"{type(err)}: {err}" + retval = GPUInfo(vram=[], + vram_free=[], + driver="N/A", + devices=[f"Error obtaining GPU Stats: '{err_string}'"], + devices_active=[]) + return retval + def full_info(self) -> str: """ Obtain extensive system information stats, formatted into a human readable format. diff --git a/tests/lib/sysinfo_test.py b/tests/lib/sysinfo_test.py new file mode 100644 index 0000000000..7f292841bb --- /dev/null +++ b/tests/lib/sysinfo_test.py @@ -0,0 +1,438 @@ +#!/usr/bin python3 +""" Pytest unit tests for :mod:`lib.sysinfo` """ + +import locale +import os +import platform +import sys + +from collections import namedtuple +from io import StringIO +from typing import cast +from unittest.mock import MagicMock + +import pytest +import pytest_mock + +from lib.gpu_stats import GPUInfo +from lib.sysinfo import _Configs, _State, _SysInfo, CudaCheck, get_sysinfo + +# pylint:disable=protected-access + + +# _SysInfo +@pytest.fixture(name="sys_info_instance") +def sys_info_fixture() -> _SysInfo: + """ Single :class:~`lib.utils._SysInfo` object for tests + + Returns + ------- + :class:`~lib.utils.sysinfo._SysInfo` + The class instance for testing + """ + return _SysInfo() + + +def test_init(sys_info_instance: _SysInfo) -> None: + """ Test :class:`~lib.utils.sysinfo._SysInfo` __init__ and attributes + + Parameters + ---------- + sys_info_instance: :class:`~lib.utils.sysinfo._SysInfo` + The class instance to test + """ + assert isinstance(sys_info_instance, _SysInfo) + + assert hasattr(sys_info_instance, "_state_file") + assert isinstance(sys_info_instance._state_file, str) + + assert hasattr(sys_info_instance, "_configs") + assert isinstance(sys_info_instance._configs, str) + + assert hasattr(sys_info_instance, "_system") + assert isinstance(sys_info_instance._system, dict) + assert sys_info_instance._system == dict(platform=platform.platform(), + system=platform.system().lower(), + machine=platform.machine(), + release=platform.release(), + processor=platform.processor(), + cpu_count=os.cpu_count()) + + assert hasattr(sys_info_instance, "_python") + assert isinstance(sys_info_instance._python, dict) + assert sys_info_instance._python == dict(implementation=platform.python_implementation(), + version=platform.python_version()) + + assert hasattr(sys_info_instance, "_gpu") + assert isinstance(sys_info_instance._gpu, GPUInfo) + + assert hasattr(sys_info_instance, "_cuda_check") + assert isinstance(sys_info_instance._cuda_check, CudaCheck) + + +def test_properties(sys_info_instance: _SysInfo) -> None: + """ Test :class:`~lib.utils.sysinfo._SysInfo` properties + + Parameters + ---------- + sys_info_instance: :class:`~lib.utils.sysinfo._SysInfo` + The class instance to test + """ + assert hasattr(sys_info_instance, "_encoding") + assert isinstance(sys_info_instance._encoding, str) + assert sys_info_instance._encoding == locale.getpreferredencoding() + + assert hasattr(sys_info_instance, "_is_conda") + assert isinstance(sys_info_instance._is_conda, bool) + assert sys_info_instance._is_conda == ("conda" in sys.version.lower() or + os.path.exists(os.path.join(sys.prefix, "conda-meta"))) + + assert hasattr(sys_info_instance, "_is_linux") + assert isinstance(sys_info_instance._is_linux, bool) + if platform.system().lower() == "linux": + assert sys_info_instance._is_linux and sys_info_instance._system["system"] == "linux" + assert not sys_info_instance._is_macos + assert not sys_info_instance._is_windows + + assert hasattr(sys_info_instance, "_is_macos") + assert isinstance(sys_info_instance._is_macos, bool) + if platform.system().lower() == "darwin": + assert sys_info_instance._is_macos and sys_info_instance._system["system"] == "darwin" + assert not sys_info_instance._is_linux + assert not sys_info_instance._is_windows + + assert hasattr(sys_info_instance, "_is_windows") + assert isinstance(sys_info_instance._is_windows, bool) + if platform.system().lower() == "windows": + assert sys_info_instance._is_windows and sys_info_instance._system["system"] == "windows" + assert not sys_info_instance._is_linux + assert not sys_info_instance._is_macos + + assert hasattr(sys_info_instance, "_is_virtual_env") + assert isinstance(sys_info_instance._is_virtual_env, bool) + + assert hasattr(sys_info_instance, "_ram_free") + assert isinstance(sys_info_instance._ram_free, int) + + assert hasattr(sys_info_instance, "_ram_total") + assert isinstance(sys_info_instance._ram_total, int) + + assert hasattr(sys_info_instance, "_ram_available") + assert isinstance(sys_info_instance._ram_available, int) + + assert hasattr(sys_info_instance, "_ram_used") + assert isinstance(sys_info_instance._ram_used, int) + + assert hasattr(sys_info_instance, "_fs_command") + assert isinstance(sys_info_instance._fs_command, str) + + assert hasattr(sys_info_instance, "_installed_pip") + assert isinstance(sys_info_instance._installed_pip, str) + + assert hasattr(sys_info_instance, "_installed_conda") + assert isinstance(sys_info_instance._installed_conda, str) + + assert hasattr(sys_info_instance, "_conda_version") + assert isinstance(sys_info_instance._conda_version, str) + + +def test_full_info(sys_info_instance: _SysInfo) -> None: + """ Test the sys_info method of :class:`~lib.utils.sysinfo._SysInfo` returns as expected + + Parameters + ---------- + sys_info_instance: :class:`~lib.utils.sysinfo._SysInfo` + The class instance to test + """ + assert hasattr(sys_info_instance, "full_info") + sys_info = sys_info_instance.full_info() + assert isinstance(sys_info, str) + assert "backend:" in sys_info + assert "os_platform:" in sys_info + assert "os_machine:" in sys_info + assert "os_release:" in sys_info + assert "py_conda_version:" in sys_info + assert "py_implementation:" in sys_info + assert "py_version:" in sys_info + assert "py_command:" in sys_info + assert "py_virtual_env:" in sys_info + assert "sys_cores:" in sys_info + assert "sys_processor:" in sys_info + assert "sys_ram:" in sys_info + assert "encoding:" in sys_info + assert "git_branch:" in sys_info + assert "git_commits:" in sys_info + assert "gpu_cuda:" in sys_info + assert "gpu_cudnn:" in sys_info + assert "gpu_driver:" in sys_info + assert "gpu_devices:" in sys_info + assert "gpu_vram:" in sys_info + assert "gpu_devices_active:" in sys_info + + +def test__format_ram(sys_info_instance: _SysInfo, monkeypatch: pytest.MonkeyPatch) -> None: + """ Test the _format_ram method of :class:`~lib.utils.sysinfo._SysInfo` returns as expected + + Parameters + ---------- + sys_info_instance: :class:`~lib.utils.sysinfo._SysInfo` + The class instance to test + monkeypatch: :class:`pytest.MonkeyPatch` + Monkey patching psutil.virtual_memory to be consistent + """ + assert hasattr(sys_info_instance, "_format_ram") + svmem = namedtuple("svmem", ["available", "free", "total", "used"]) + data = svmem(12345678, 1234567, 123456789, 123456) + monkeypatch.setattr("psutil.virtual_memory", lambda *args, **kwargs: data) + ram_info = sys_info_instance._format_ram() + + assert isinstance(ram_info, str) + assert ram_info == "Total: 117MB, Available: 11MB, Used: 0MB, Free: 1MB" + + +# get_sys_info +def test_get_sys_info(mocker: pytest_mock.MockerFixture) -> None: + """ Thest that the :func:`~lib.utils.sysinfo.get_sysinfo` function executes correctly + + Parameters + ---------- + mocker: :class:`pytest_mock.MockerFixture` + Mocker for checking full_info called from _SysInfo + """ + sys_info = get_sysinfo() + assert isinstance(sys_info, str) + full_info = mocker.patch("lib.sysinfo._SysInfo.full_info") + get_sysinfo() + assert full_info.called + + +# _Configs +@pytest.fixture(name="configs_instance") +def configs_fixture(): + """ Pytest fixture for :class:`~lib.utils.sysinfo._Configs` + + Returns + ------- + :class:`~lib.utils.sysinfo._Configs` + The class instance for testing + """ + return _Configs() + + +def test__configs__init__(configs_instance: _Configs) -> None: + """ Test __init__ and attributes for :class:`~lib.utils.sysinfo._Configs` + + Parameters + ---------- + configs_instance: :class:`~lib.utils.sysinfo._Configs` + The class instance to test + """ + assert hasattr(configs_instance, "config_dir") + assert isinstance(configs_instance.config_dir, str) + assert hasattr(configs_instance, "configs") + assert isinstance(configs_instance.configs, str) + + +def test__configs__get_configs(configs_instance: _Configs) -> None: + """ Test __init__ and attributes for :class:`~lib.utils.sysinfo._Configs` + + Parameters + ---------- + configs_instance: :class:`~lib.utils.sysinfo._Configs` + The class instance to test + """ + assert hasattr(configs_instance, "_get_configs") + assert isinstance(configs_instance._get_configs(), str) + + +def test__configs__parse_configs(configs_instance: _Configs, + mocker: pytest_mock.MockerFixture) -> None: + """ Test _parse_configs function for :class:`~lib.utils.sysinfo._Configs` + + Parameters + ---------- + configs_instance: :class:`~lib.utils.sysinfo._Configs` + The class instance to test + mocker: :class:`pytest_mock.MockerFixture` + Mocker for dummying in function calls + """ + assert hasattr(configs_instance, "_parse_configs") + assert isinstance(configs_instance._parse_configs([]), str) + configs_instance._parse_ini = cast(MagicMock, mocker.MagicMock()) # type:ignore + configs_instance._parse_json = cast(MagicMock, mocker.MagicMock()) # type:ignore + configs_instance._parse_configs(config_files=["test.ini", ".faceswap"]) + assert configs_instance._parse_ini.called + assert configs_instance._parse_json.called + + +def test__configs__parse_ini(configs_instance: _Configs, + monkeypatch: pytest.MonkeyPatch) -> None: + """ Test _parse_ini function for :class:`~lib.utils.sysinfo._Configs` + + Parameters + ---------- + configs_instance: :class:`~lib.utils.sysinfo._Configs` + The class instance to test + monkeypatch: :class:`pytest.MonkeyPatch` + Monkey patching :func:`builtins.open` to dummy in ini file + """ + assert hasattr(configs_instance, "_parse_ini") + + file = ("[test.ini_header]\n" + "# Test Header\n\n" + "param = value") + monkeypatch.setattr("builtins.open", lambda *args, **kwargs: StringIO(file)) + + converted = configs_instance._parse_ini("test.ini") + assert isinstance(converted, str) + assert converted == ("\n[test.ini_header]\n" + "param: value\n") + + +def test__configs__parse_json(configs_instance: _Configs, + monkeypatch: pytest.MonkeyPatch) -> None: + """ Test _parse_json function for :class:`~lib.utils.sysinfo._Configs` + + Parameters + ---------- + configs_instance: :class:`~lib.utils.sysinfo._Configs` + The class instance to test + monkeypatch: :class:`pytest.MonkeyPatch` + Monkey patching :func:`builtins.open` to dummy in json file + + """ + assert hasattr(configs_instance, "_parse_json") + file = ('{"test": "param"}') + monkeypatch.setattr("builtins.open", lambda *args, **kwargs: StringIO(file)) + + converted = configs_instance._parse_json(".file") + assert isinstance(converted, str) + assert converted == ("test: param\n") + + +def test__configs__format_text(configs_instance: _Configs) -> None: + """ Test _format_text function for :class:`~lib.utils.sysinfo._Configs` + + Parameters + ---------- + configs_instance: :class:`~lib.utils.sysinfo._Configs` + The class instance to test + """ + assert hasattr(configs_instance, "_format_text") + key, val = " test_key ", "test_val " + formatted = configs_instance._format_text(key, val) + assert isinstance(formatted, str) + assert formatted == "test_key: test_val\n" + + +# _State +@pytest.fixture(name="state_instance") +def state_fixture(): + """ Pytest fixture for :class:`~lib.utils.sysinfo._State` + + Returns + ------- + :class:`~lib.utils.sysinfo._State` + The class instance for testing + """ + return _State() + + +def test__state__init__(state_instance: _State) -> None: + """ Test __init__ and attributes for :class:`~lib.utils.sysinfo._State` + + Parameters + ---------- + state_instance: :class:`~lib.utils.sysinfo._State` + The class instance to test + """ + assert hasattr(state_instance, '_model_dir') + assert state_instance._model_dir is None + assert hasattr(state_instance, '_trainer') + assert state_instance._trainer is None + assert hasattr(state_instance, 'state_file') + assert isinstance(state_instance.state_file, str) + + +def test__state__is_training(state_instance: _State, + monkeypatch: pytest.MonkeyPatch) -> None: + """ Test _is_training function for :class:`~lib.utils.sysinfo._State` + + Parameters + ---------- + state_instance: :class:`~lib.utils.sysinfo._State` + The class instance to test + monkeypatch: :class:`pytest.MonkeyPatch` + Monkey patching :func:`sys.argv` to dummy in commandline args + + """ + assert hasattr(state_instance, '_is_training') + assert isinstance(state_instance._is_training, bool) + assert not state_instance._is_training + monkeypatch.setattr("sys.argv", ["faceswap.py", "train"]) + assert state_instance._is_training + monkeypatch.setattr("sys.argv", ["faceswap.py", "extract"]) + assert not state_instance._is_training + + +def test__state__get_arg(state_instance: _State, + monkeypatch: pytest.MonkeyPatch) -> None: + """ Test _get_arg function for :class:`~lib.utils.sysinfo._State` + + Parameters + ---------- + state_instance: :class:`~lib.utils.sysinfo._State` + The class instance to test + monkeypatch: :class:`pytest.MonkeyPatch` + Monkey patching :func:`sys.argv` to dummy in commandline args + :func:`builtins.input` + """ + assert hasattr(state_instance, '_get_arg') + assert state_instance._get_arg("-t", "--test_arg") is None + monkeypatch.setattr("sys.argv", ["test", "command", "-t", "test_option"]) + assert state_instance._get_arg("-t", "--test_arg") == "test_option" + + +def test__state__get_state_file(state_instance: _State, + mocker: pytest_mock.MockerFixture, + monkeypatch: pytest.MonkeyPatch) -> None: + """ Test _get_state_file function for :class:`~lib.utils.sysinfo._State` + + Parameters + ---------- + state_instance: :class:`~lib.utils.sysinfo._State` + The class instance to test + mocker: :class:`pytest_mock.MockerFixture` + Mocker for dummying in function calls + monkeypatch: :class:`pytest.MonkeyPatch` + Monkey patching :func:`sys.argv` to dummy in commandline args + :func:`builtins.input` +` """ + assert hasattr(state_instance, '_get_state_file') + assert isinstance(state_instance._get_state_file(), str) + + mock_is_training = mocker.patch("lib.sysinfo._State._is_training") + + # Not training or missing training arguments + mock_is_training.return_value = False + assert state_instance._get_state_file() == "" + mock_is_training.return_value = False + + monkeypatch.setattr(state_instance, "_model_dir", None) + assert state_instance._get_state_file() == "" + monkeypatch.setattr(state_instance, "_model_dir", "test_dir") + + monkeypatch.setattr(state_instance, "_trainer", None) + assert state_instance._get_state_file() == "" + monkeypatch.setattr(state_instance, "_trainer", "test_trainer") + + # Training but file not found + assert state_instance._get_state_file() == "" + + # State file is just a json dump + file = ('{\n' + ' "test": "json",\n' + '}') + monkeypatch.setattr("os.path.isfile", lambda *args, **kwargs: True) + monkeypatch.setattr("builtins.open", lambda *args, **kwargs: StringIO(file)) + assert state_instance._get_state_file().endswith(file) From edd7e524082b51f76786566fa2bd956e55f6a4bf Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 9 Jan 2023 12:46:29 +0000 Subject: [PATCH 782/981] docs: typofix --- docs/full/tests/lib.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/full/tests/lib.rst b/docs/full/tests/lib.rst index 244594f749..f7bf51e719 100644 --- a/docs/full/tests/lib.rst +++ b/docs/full/tests/lib.rst @@ -11,7 +11,7 @@ Subpackages .. toctree:: :maxdepth: 1 - gpu_stats + lib.gpu_stats sysinfo module @@ -20,7 +20,7 @@ Unit tests for :class:`~lib.sysinfo` module .. rubric:: Module -.. automodule:: tests.lib.sysinfo +.. automodule:: tests.lib.sysinfo_test :members: :undoc-members: :show-inheritance: From c455601ed254c15648944781a02a13db1a887f27 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 11 Jan 2023 13:09:32 +0000 Subject: [PATCH 783/981] Linux - ROCm (AMD) support --- .install/linux/faceswap_setup_x64.sh | 31 +- INSTALL.md | 12 +- docs/full/lib/gpu_stats.rst | 8 + lib/gpu_stats/__init__.py | 4 +- lib/gpu_stats/rocm.py | 451 ++++++++++++++++++++ lib/utils.py | 5 +- requirements/requirements_apple_silicon.txt | 3 + requirements/requirements_rocm.txt | 4 + setup.py | 163 ++++++- 9 files changed, 657 insertions(+), 24 deletions(-) create mode 100644 lib/gpu_stats/rocm.py create mode 100644 requirements/requirements_rocm.txt diff --git a/.install/linux/faceswap_setup_x64.sh b/.install/linux/faceswap_setup_x64.sh index 49e3450c85..d29c8bf95f 100644 --- a/.install/linux/faceswap_setup_x64.sh +++ b/.install/linux/faceswap_setup_x64.sh @@ -35,6 +35,13 @@ info () { done <<< "$(echo "$1" | fmt -cu -w 70)" } +warn () { + # output warning message + while read -r line ; do + echo -e "\e[33mWARNING\e[97m $line" + done <<< "$(echo "$1" | fmt -cu -w 70)" +} + error () { # output error message. while read -r line ; do @@ -127,12 +134,13 @@ ask_version() { # Ask which version of faceswap to install while true; do default=1 - read -rp $'\e[36m'"Select: 1 (NVIDIA), 2 (AMD), 3 (CPU) [default: $default]: "$'\e[97m' vers + read -rp $'\e[36mSelect:\t1: NVIDIA\n\t2: AMD (ROCm)\n\t3: CPU\n\t4: AMD (PlaidML) - deprecated\n'"[default: $default]: "$'\e[97m' vers vers="${vers:-${default}}" case $vers in 1) VERSION="nvidia" ; break ;; - 2) VERSION="amd" ; PYENV_VERSION="3.8" ; break ;; + 2) VERSION="rocm" ; break ;; 3) VERSION="cpu" ; break ;; + 4) VERSION="amd" ; PYENV_VERSION="3.8" ; break ;; * ) echo "Invalid selection." ;; esac done @@ -273,6 +281,17 @@ faceswap_opts () { latest graphics card drivers installed from the relevant vendor. Please select the version\ of Faceswap you wish to install." ask_version + if [ $VERSION == "amd" ] ; then + warn "PlaidML support is deprecated and will be removed in a future update. If possible \ + please consider using the ROCm version" + sleep 2 + fi + if [ $VERSION == "rocm" ] ; then + warn "ROCm support is experimental. Please make sure that your GPU is supported by ROCm and that \ + ROCm has been installed on your system before proceeding. Installation instructions: \ + https://docs.amd.com/bundle/ROCm_Installation_Guidev5.0/page/Overview_of_ROCm_Installation_Methods.html" + sleep 2 + fi } post_install_opts() { @@ -309,6 +328,14 @@ review() { fi echo " - Faceswap will be installed in '$DIR_FACESWAP'" echo " - Installing for '$VERSION'" + if [ $VERSION == "amd" ] ; then + echo -e " \e[33m- Note: '$VERSION' is deprecated and will be removed in a\e[97m" + echo -e " \e[33m future update. Consider using the ROCm version.\e[97m" + fi + if [ $VERSION == "rocm" ] ; then + echo -e " \e[33m- Note: Please ensure that ROCm is supported by your GPU\e[97m" + echo -e " \e[33m and is installed prior to proceeding.\e[97m" + fi if $DESKTOP ; then echo " - A Desktop shortcut will be created" ; fi if ! ask_yesno "Do you wish to continue?" "No" ; then exit ; fi } diff --git a/INSTALL.md b/INSTALL.md index b9df7e2ece..542a00eab1 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -58,15 +58,21 @@ The type of computations that the process does are well suited for graphics card - **A powerful CPU** - Laptop CPUs can often run the software, but will not be fast enough to train at reasonable speeds - **A powerful GPU** - - Currently, Nvidia GPUs are fully supported. and AMD graphics cards are partially supported through plaidML. + - Currently, Nvidia GPUs are fully supported + - DirectX 12 AMD GPUs are supported on Windows through DirectML. + - More modern AMD GPUs are supported on Linux through ROCm. + - M-series Macs are supported through Tensorflow-Metal + - OpenCL 1.2 support through PlaidML is deprecated and will be removed in a future update - If using an Nvidia GPU, then it needs to support at least CUDA Compute Capability 3.5. (Release 1.0 will work on Compute Capability 3.0) To see which version your GPU supports, consult this list: https://developer.nvidia.com/cuda-gpus Desktop cards later than the 7xx series are most likely supported. - **A lot of patience** ## Supported operating systems -- **Windows 10** - Windows 7 and 8 might work. Your mileage may vary. Windows has an installer which will set up everything you need. See: https://github.com/deepfakes/faceswap/releases +- **Windows 10/11** + Windows 7 and 8 might work for Nvidia. Your mileage may vary. + DirectML support is only available in Windows 10 onwards. + Windows has an installer which will set up everything you need. See: https://github.com/deepfakes/faceswap/releases - **Linux** Most Ubuntu/Debian or CentOS based Linux distributions will work. There is a Linux install script that will install and set up everything you need. See: https://github.com/deepfakes/faceswap/releases - **macOS** diff --git a/docs/full/lib/gpu_stats.rst b/docs/full/lib/gpu_stats.rst index 4f8dcfc5c1..9ba5ce3c97 100755 --- a/docs/full/lib/gpu_stats.rst +++ b/docs/full/lib/gpu_stats.rst @@ -61,3 +61,11 @@ gpu_stats.nvidia module :members: :undoc-members: :show-inheritance: + +gpu_stats.rocm module +---------------------- + +.. automodule:: lib.gpu_stats.rocm + :members: + :undoc-members: + :show-inheritance: diff --git a/lib/gpu_stats/__init__.py b/lib/gpu_stats/__init__.py index d42401453f..15b1f42cda 100644 --- a/lib/gpu_stats/__init__.py +++ b/lib/gpu_stats/__init__.py @@ -20,5 +20,7 @@ from .apple_silicon import AppleSiliconStats as GPUStats # type:ignore elif backend == "directml": from .directml import DirectML as GPUStats # type:ignore -elif backend == "cpu": +elif backend == "rocm": + from .rocm import ROCm as GPUStats # type:ignore +else: from .cpu import CPUStats as GPUStats # type:ignore diff --git a/lib/gpu_stats/rocm.py b/lib/gpu_stats/rocm.py new file mode 100644 index 0000000000..17db3d6059 --- /dev/null +++ b/lib/gpu_stats/rocm.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +""" Collects and returns Information about connected AMD GPUs for ROCm using sysfs and from +modinfo + +As no ROCm compatible hardware was available for testing, this just returns information on all AMD +GPUs discovered on the system regardless of ROCm compatibility. + +It is a good starting point but may need to be refined over time +""" +import os +import re +from subprocess import run +from typing import List + +from ._base import _GPUStats + +_DEVICE_LOOKUP = { # ref: https://gist.github.com/roalercon/51f13a387f3754615cce + int("0x130F", 0): "AMD Radeon(TM) R7 Graphics", + int("0x1313", 0): "AMD Radeon(TM) R7 Graphics", + int("0x1316", 0): "AMD Radeon(TM) R5 Graphics", + int("0x6600", 0): "AMD Radeon HD 8600/8700M", + int("0x6601", 0): "AMD Radeon (TM) HD 8500M/8700M", + int("0x6604", 0): "AMD Radeon R7 M265 Series", + int("0x6605", 0): "AMD Radeon R7 M260 Series", + int("0x6606", 0): "AMD Radeon HD 8790M", + int("0x6607", 0): "AMD Radeon (TM) HD8530M", + int("0x6610", 0): "AMD Radeon HD 8670 Graphics", + int("0x6611", 0): "AMD Radeon HD 8570 Graphics", + int("0x6613", 0): "AMD Radeon R7 200 Series", + int("0x6640", 0): "AMD Radeon HD 8950", + int("0x6658", 0): "AMD Radeon R7 200 Series", + int("0x665C", 0): "AMD Radeon HD 7700 Series", + int("0x665D", 0): "AMD Radeon R7 200 Series", + int("0x6660", 0): "AMD Radeon HD 8600M Series", + int("0x6663", 0): "AMD Radeon HD 8500M Series", + int("0x6664", 0): "AMD Radeon R5 M200 Series", + int("0x6665", 0): "AMD Radeon R5 M230 Series", + int("0x6667", 0): "AMD Radeon R5 M200 Series", + int("0x666F", 0): "AMD Radeon HD 8500M", + int("0x6704", 0): "AMD FirePro V7900 (FireGL V)", + int("0x6707", 0): "AMD FirePro V5900 (FireGL V)", + int("0x6718", 0): "AMD Radeon HD 6900 Series", + int("0x6719", 0): "AMD Radeon HD 6900 Series", + int("0x671D", 0): "AMD Radeon HD 6900 Series", + int("0x671F", 0): "AMD Radeon HD 6900 Series", + int("0x6720", 0): "AMD Radeon HD 6900M Series", + int("0x6738", 0): "AMD Radeon HD 6800 Series", + int("0x6739", 0): "AMD Radeon HD 6800 Series", + int("0x673E", 0): "AMD Radeon HD 6700 Series", + int("0x6740", 0): "AMD Radeon HD 6700M Series", + int("0x6741", 0): "AMD Radeon 6600M and 6700M Series", + int("0x6742", 0): "AMD Radeon HD 5570", + int("0x6743", 0): "AMD Radeon E6760", + int("0x6749", 0): "AMD FirePro V4900 (FireGL V)", + int("0x674A", 0): "AMD FirePro V3900 (ATI FireGL)", + int("0x6750", 0): "AMD Radeon HD 6500 series", + int("0x6751", 0): "AMD Radeon HD 7600A Series", + int("0x6758", 0): "AMD Radeon HD 6670", + int("0x6759", 0): "AMD Radeon HD 6570 Graphics", + int("0x675B", 0): "AMD Radeon HD 7600 Series", + int("0x675D", 0): "AMD Radeon HD 7500 Series", + int("0x675F", 0): "AMD Radeon HD 5500 Series", + int("0x6760", 0): "AMD Radeon HD 6400M Series", + int("0x6761", 0): "AMD Radeon HD 6430M", + int("0x6763", 0): "AMD Radeon E6460", + int("0x6770", 0): "AMD Radeon HD 6400 Series", + int("0x6771", 0): "AMD Radeon R5 235X", + int("0x6772", 0): "AMD Radeon HD 7400A Series", + int("0x6778", 0): "AMD Radeon HD 7000 series", + int("0x6779", 0): "AMD Radeon HD 6450", + int("0x677B", 0): "AMD Radeon HD 7400 Series", + int("0x6780", 0): "AMD FirePro W9000 (FireGL V)", + int("0x678A", 0): "AMD FirePro S10000 (FireGL V)", + int("0x6798", 0): "AMD Radeon HD 7900 Series", + int("0x679A", 0): "AMD Radeon HD 7900 Series", + int("0x679B", 0): "AMD Radeon HD 7900 Series", + int("0x679E", 0): "AMD Radeon HD 7800 Series", + int("0x67B0", 0): "AMD Radeon R9 200 Series", + int("0x67B1", 0): "AMD Radeon R9 200 Series", + int("0x6800", 0): "AMD Radeon HD 7970M", + int("0x6801", 0): "AMD Radeon(TM) HD8970M", + int("0x6808", 0): "AMD FirePro S7000 (FireGL V)", + int("0x6809", 0): "AMD FirePro R5000 (FireGL V)", + int("0x6810", 0): "AMD Radeon R9 200 Series", + int("0x6811", 0): "AMD Radeon R9 200 Series", + int("0x6818", 0): "AMD Radeon HD 7800 Series", + int("0x6819", 0): "AMD Radeon HD 7800 Series", + int("0x6820", 0): "AMD Radeon HD 8800M Series", + int("0x6821", 0): "AMD Radeon HD 8800M Series", + int("0x6822", 0): "AMD Radeon E8860", + int("0x6823", 0): "AMD Radeon HD 8800M Series", + int("0x6825", 0): "AMD Radeon HD 7800M Series", + int("0x6827", 0): "AMD Radeon HD 7800M Series", + int("0x6828", 0): "AMD FirePro W600", + int("0x682B", 0): "AMD Radeon HD 8800M Series", + int("0x682D", 0): "AMD Radeon HD 7700M Series", + int("0x682F", 0): "AMD Radeon HD 7700M Series", + int("0x6835", 0): "AMD Radeon R7 Series / HD 9000 Series", + int("0x6837", 0): "AMD Radeon HD 6570", + int("0x683D", 0): "AMD Radeon HD 7700 Series", + int("0x683F", 0): "AMD Radeon HD 7700 Series", + int("0x6840", 0): "AMD Radeon HD 7600M Series", + int("0x6841", 0): "AMD Radeon HD 7500M/7600M Series", + int("0x6842", 0): "AMD Radeon HD 7000M Series", + int("0x6843", 0): "AMD Radeon HD 7670M", + int("0x6858", 0): "AMD Radeon HD 7400 Series", + int("0x6859", 0): "AMD Radeon HD 7400 Series", + int("0x6888", 0): "ATI FirePro V8800 (FireGL V)", + int("0x6889", 0): "ATI FirePro V7800 (FireGL V)", + int("0x688A", 0): "ATI FirePro V9800 (FireGL V)", + int("0x688C", 0): "AMD FireStream 9370", + int("0x688D", 0): "AMD FireStream 9350", + int("0x6898", 0): "AMD Radeon HD 5800 Series", + int("0x6899", 0): "AMD Radeon HD 5800 Series", + int("0x689B", 0): "AMD Radeon HD 6800 Series", + int("0x689C", 0): "AMD Radeon HD 5900 Series", + int("0x689E", 0): "AMD Radeon HD 5800 Series", + int("0x68A0", 0): "AMD Mobility Radeon HD 5800 Series", + int("0x68A1", 0): "AMD Mobility Radeon HD 5800 Series", + int("0x68A8", 0): "AMD Radeon HD 6800M Series", + int("0x68A9", 0): "ATI FirePro V5800 (FireGL V)", + int("0x68B8", 0): "AMD Radeon HD 5700 Series", + int("0x68B9", 0): "AMD Radeon HD 5600/5700", + int("0x68BA", 0): "AMD Radeon HD 6700 Series", + int("0x68BE", 0): "AMD Radeon HD 5700 Series", + int("0x68BF", 0): "AMD Radeon HD 6700 Green Edition", + int("0x68C0", 0): "AMD Mobility Radeon HD 5000", + int("0x68C1", 0): "AMD Mobility Radeon HD 5000 Series", + int("0x68C7", 0): "AMD Mobility Radeon HD 5570", + int("0x68C8", 0): "ATI FirePro V4800 (FireGL V)", + int("0x68C9", 0): "ATI FirePro 3800 (FireGL) Graphics Adapter", + int("0x68D8", 0): "AMD Radeon HD 5670", + int("0x68D9", 0): "AMD Radeon HD 5570", + int("0x68DA", 0): "AMD Radeon HD 5500 Series", + int("0x68E0", 0): "AMD Mobility Radeon HD 5000 Series", + int("0x68E1", 0): "AMD Mobility Radeon HD 5000 Series", + int("0x68E4", 0): "AMD Radeon HD 5450", + int("0x68E5", 0): "AMD Radeon HD 6300M Series", + int("0x68F1", 0): "AMD FirePro 2460", + int("0x68F2", 0): "AMD FirePro 2270 (ATI FireGL)", + int("0x68F9", 0): "AMD Radeon HD 5450", + int("0x68FA", 0): "AMD Radeon HD 7300 Series", + int("0x9640", 0): "AMD Radeon HD 6550D", + int("0x9641", 0): "AMD Radeon HD 6620G", + int("0x9642", 0): "AMD Radeon HD 6370D", + int("0x9643", 0): "AMD Radeon HD 6380G", + int("0x9644", 0): "AMD Radeon HD 6410D", + int("0x9645", 0): "AMD Radeon HD 6410D", + int("0x9647", 0): "AMD Radeon HD 6520G", + int("0x9648", 0): "AMD Radeon HD 6480G", + int("0x9649", 0): "AMD Radeon(TM) HD 6480G", + int("0x964A", 0): "AMD Radeon HD 6530D", + int("0x9802", 0): "AMD Radeon HD 6310 Graphics", + int("0x9803", 0): "AMD Radeon HD 6250 Graphics", + int("0x9804", 0): "AMD Radeon HD 6250 Graphics", + int("0x9805", 0): "AMD Radeon HD 6250 Graphics", + int("0x9806", 0): "AMD Radeon HD 6320 Graphics", + int("0x9807", 0): "AMD Radeon HD 6290 Graphics", + int("0x9808", 0): "AMD Radeon HD 7340 Graphics", + int("0x9809", 0): "AMD Radeon HD 7310 Graphics", + int("0x980A", 0): "AMD Radeon HD 7290 Graphics", + int("0x9830", 0): "AMD Radeon HD 8400", + int("0x9831", 0): "AMD Radeon(TM) HD 8400E", + int("0x9832", 0): "AMD Radeon HD 8330", + int("0x9833", 0): "AMD Radeon(TM) HD 8330E", + int("0x9834", 0): "AMD Radeon HD 8210", + int("0x9835", 0): "AMD Radeon(TM) HD 8210E", + int("0x9836", 0): "AMD Radeon HD 8280", + int("0x9837", 0): "AMD Radeon(TM) HD 8280E", + int("0x9838", 0): "AMD Radeon HD 8240", + int("0x9839", 0): "AMD Radeon HD 8180", + int("0x983D", 0): "AMD Radeon HD 8250", + int("0x9900", 0): "AMD Radeon HD 7660G", + int("0x9901", 0): "AMD Radeon HD 7660D", + int("0x9903", 0): "AMD Radeon HD 7640G", + int("0x9904", 0): "AMD Radeon HD 7560D", + int("0x9906", 0): "AMD FirePro A300 Series (FireGL V) Graphics Adapter", + int("0x9907", 0): "AMD Radeon HD 7620G", + int("0x9908", 0): "AMD Radeon HD 7600G", + int("0x990A", 0): "AMD Radeon HD 7500G", + int("0x990B", 0): "AMD Radeon HD 8650G", + int("0x990C", 0): "AMD Radeon HD 8670D", + int("0x990D", 0): "AMD Radeon HD 8550G", + int("0x990E", 0): "AMD Radeon HD 8570D", + int("0x990F", 0): "AMD Radeon HD 8610G", + int("0x9910", 0): "AMD Radeon HD 7660G", + int("0x9913", 0): "AMD Radeon HD 7640G", + int("0x9917", 0): "AMD Radeon HD 7620G", + int("0x9918", 0): "AMD Radeon HD 7600G", + int("0x9919", 0): "AMD Radeon HD 7500G", + int("0x9990", 0): "AMD Radeon HD 7520G", + int("0x9991", 0): "AMD Radeon HD 7540D", + int("0x9992", 0): "AMD Radeon HD 7420G", + int("0x9993", 0): "AMD Radeon HD 7480D", + int("0x9994", 0): "AMD Radeon HD 7400G", + int("0x9995", 0): "AMD Radeon HD 8450G", + int("0x9996", 0): "AMD Radeon HD 8470D", + int("0x9997", 0): "AMD Radeon HD 8350G", + int("0x9998", 0): "AMD Radeon HD 8370D", + int("0x9999", 0): "AMD Radeon HD 8510G", + int("0x999A", 0): "AMD Radeon HD 8410G", + int("0x999B", 0): "AMD Radeon HD 8310G", + int("0x999C", 0): "AMD Radeon HD 8650D", + int("0x999D", 0): "AMD Radeon HD 8550D", + int("0x99A0", 0): "AMD Radeon HD 7520G", + int("0x99A2", 0): "AMD Radeon HD 7420G", + int("0x99A4", 0): "AMD Radeon HD 7400G"} + + +class ROCm(_GPUStats): + """ Holds information and statistics about GPUs connected using sysfs + + Parameters + ---------- + log: bool, optional + Whether the class should output information to the logger. There may be occasions where the + logger has not yet been set up when this class is queried. Attempting to log in these + instances will raise an error. If GPU stats are being queried prior to the logger being + available then this parameter should be set to ``False``. Otherwise set to ``True``. + Default: ``True`` + """ + def __init__(self, log: bool = True) -> None: + self._vendor_id = "0x1002" # AMD VendorID + self._sysfs_paths: List[str] = [] + super().__init__(log=log) + + def _from_sysfs_file(self, path: str) -> str: + """ Obtain the value from a sysfs file. On permission error or file doesn't exist, log and + return empty value + + Parameters + ---------- + path: str + The path to a sysfs file to obtain the value from + + Returns + ------- + str + The obtained value from the given path + """ + if not os.path.isfile(path): + self._log("debug", f"File '{path}' does not exist. Returning empty string") + return "" + try: + with open(path, "r", encoding="utf-8", errors="ignore") as sysfile: + val = sysfile.read().strip() + except PermissionError: + self._log("debug", f"Permission error accessing file '{path}'. Returning empty string") + val = "" + return val + + def _get_sysfs_paths(self) -> List[str]: + """ Obtain a list of sysfs paths to AMD branded GPUs connected to the system + + Returns + ------- + list[str] + List of full paths to the sysfs entries for connected AMD GPUs + """ + base_dir = "/sys/class/drm/" + + retval: list[str] = [] + if not os.path.exists(base_dir): + self._log("warning", f"sysfs not found at '{base_dir}'") + return retval + + for folder in sorted(os.listdir(base_dir)): + folder_path = os.path.join(base_dir, folder, "device") + vendor_path = os.path.join(folder_path, "vendor") + if not os.path.isdir(vendor_path) and not re.match(r"^card\d+$", folder): + self._log("debug", f"skipping path '{folder_path}'") + continue + + vendor_id = self._from_sysfs_file(vendor_path) + if vendor_id != self._vendor_id: + self._log("debug", f"Skipping non AMD Vendor '{vendor_id}' for device: '{folder}'") + continue + + retval.append(folder_path) + + self._log("debug", f"sysfs AMD devices: {retval}") + return retval + + def _initialize(self) -> None: + """ Initialize sysfs for ROCm backend. + + If :attr:`_is_initialized` is ``True`` then this function just returns performing no + action. + + if ``False`` then the location of AMD cards within sysfs is collected + """ + if self._is_initialized: + return + self._log("debug", "Initializing sysfs for AMDGPU (ROCm).") + self._sysfs_paths = self._get_sysfs_paths() + super()._initialize() + + def _get_device_count(self) -> int: + """ The number of AMD cards found in sysfs + + Returns + ------- + int + The total number of GPUs available + """ + retval = len(self._sysfs_paths) + self._log("debug", f"GPU Device count: {retval}") + return retval + + def _get_handles(self) -> list: + """ The sysfs doesn't use device handles, so we just return the list of the sysfs locations + per card + + Returns + ------- + list + The list of all discovered GPUs + """ + handles = self._sysfs_paths + self._log("debug", f"sysfs GPU Handles found: {handles}") + return handles + + def _get_driver(self) -> str: + """ Obtain the driver versions currently in use from modinfo + + Returns + ------- + str + The current AMDGPU driver versions + """ + retval = "" + cmd = ["modinfo", "amdgpu"] + try: + proc = run(cmd, + check=True, + timeout=5, + capture_output=True, + encoding="utf-8", + errors="ignore") + for line in proc.stdout.split("\n"): + if line.startswith("version:"): + retval = line.split()[-1] + break + except Exception as err: # pylint:disable=broad-except + self._log("debug", f"Error reading modinfo: '{str(err)}'") + + self._log("debug", f"GPU Drivers: {retval}") + return retval + + def _get_device_names(self) -> List[str]: + """ Obtain the list of names of connected GPUs as identified in :attr:`_handles`. + + Returns + ------- + list + The list of connected AMD GPU names + """ + retval = [] + for device in self._sysfs_paths: + name = self._from_sysfs_file(os.path.join(device, "product_name")) + number = self._from_sysfs_file(os.path.join(device, "product_number")) + if name or number: # product_name or product_number populated + self._log("debug", f"Got name from product_name: '{name}', product_number: " + f"'{number}'") + retval.append(f"{name + ' ' if name else ''}{number}") + continue + + device_id = self._from_sysfs_file(os.path.join(device, "device")) + self._log("debug", f"Got device_id: '{device_id}'") + + if not device_id: # Can't get device name + retval.append("Not found") + continue + try: + lookup = int(device_id, 0) + except ValueError: + retval.append(device_id) + continue + + device_name = _DEVICE_LOOKUP.get(lookup, device_id) + retval.append(device_name) + + self._log("debug", f"Device names: {retval}") + return retval + + def _get_active_devices(self) -> List[int]: + """ Obtain the indices of active GPUs (those that have not been explicitly excluded by + HIP_VISIBLE_DEVICES environment variable or explicitly excluded in the command line + arguments). + + Returns + ------- + list + The list of device indices that are available for Faceswap to use + """ + devices = super()._get_active_devices() + env_devices = os.environ.get("HIP_VISIBLE_DEVICES ") + if env_devices: + new_devices = [int(i) for i in env_devices.split(",")] + devices = [idx for idx in devices if idx in new_devices] + self._log("debug", f"Active GPU Devices: {devices}") + return devices + + def _get_vram(self) -> List[int]: + """ Obtain the VRAM in Megabytes for each connected AMD GPU as identified in + :attr:`_handles`. + + Returns + ------- + list + The VRAM in Megabytes for each connected Nvidia GPU + """ + retval = [] + for device in self._sysfs_paths: + query = self._from_sysfs_file(os.path.join(device, "mem_info_vram_total")) + try: + vram = int(query) + except ValueError: + self._log("debug", f"Couldn't extract VRAM from string: '{query}'", ) + vram = 0 + retval.append(int(vram / (1024 * 1024))) + + self._log("debug", f"GPU VRAM: {retval}") + return retval + + def _get_free_vram(self) -> List[int]: + """ Obtain the amount of VRAM that is available, in Megabytes, for each connected AMD + GPU. + + Returns + ------- + list + List of `float`s containing the amount of VRAM available, in Megabytes, for each + connected GPU as corresponding to the values in :attr:`_handles + """ + retval = [] + total_vram = self._get_vram() + for device, vram in zip(self._sysfs_paths, total_vram): + if not vram: + retval.append(0) + continue + query = self._from_sysfs_file(os.path.join(device, "mem_info_vram_used")) + try: + used = int(query) + except ValueError: + self._log("debug", f"Couldn't extract used VRAM from string: '{query}'") + used = 0 + + retval.append(vram - int(used / (1024 * 1024))) + self._log("debug", f"GPU VRAM free: {retval}") + return retval diff --git a/lib/utils.py b/lib/utils.py index 296786280a..727728bf84 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -35,7 +35,7 @@ ".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv", ".ts", ".vob"] _TF_VERS: Optional[Tuple[int, int]] = None -ValidBackends = Literal["amd", "nvidia", "cpu", "apple_silicon", "directml"] +ValidBackends = Literal["amd", "nvidia", "cpu", "apple_silicon", "directml", "rocm"] class _Backend(): # pylint:disable=too-few-public-methods @@ -48,7 +48,8 @@ def __init__(self) -> None: "2": "directml", "3": "nvidia", "4": "apple_silicon", - "5": "amd"} + "5": "rocm", + "6": "amd"} self._valid_backends = list(self._backends.values()) self._config_file = self._get_config_file() self.backend = self._get_backend() diff --git a/requirements/requirements_apple_silicon.txt b/requirements/requirements_apple_silicon.txt index 2e2c5d521d..f819f4f62c 100644 --- a/requirements/requirements_apple_silicon.txt +++ b/requirements/requirements_apple_silicon.txt @@ -5,3 +5,6 @@ tensorflow-macos>=2.8.0,<2.11.0 tensorflow-deps>=2.8.0,<2.11.0 tensorflow-metal>=0.4.0,<0.6.0 libblas # Conda only +# These next 2 should have been installed, but some users complain of errors +decorator +cloudpickle diff --git a/requirements/requirements_rocm.txt b/requirements/requirements_rocm.txt new file mode 100644 index 0000000000..fd6b265ebd --- /dev/null +++ b/requirements/requirements_rocm.txt @@ -0,0 +1,4 @@ +-r _requirements_base.txt +numpy>=1.21.0; python_version < '3.8' +numpy>=1.22.0; python_version >= '3.8' +tensorflow-rocm>=2.10.0,<2.11.0 diff --git a/setup.py b/setup.py index 7e0b0c3a59..f10ae74fc3 100755 --- a/setup.py +++ b/setup.py @@ -30,6 +30,9 @@ # Revisions of tensorflow GPU and cuda/cudnn requirements. These relate specifically to the # Tensorflow builds available from pypi _TENSORFLOW_REQUIREMENTS = {">=2.7.0,<2.11.0": ["11.2", "8.1"]} +# ROCm min/max version requirements for Tensorflow +_TENSORFLOW_ROCM_REQUIREMENTS = {">=2.10.0,<2.11.0": ((5, 2, 0), (5, 4, 0))} +# TODO tensorflow-metal versioning # Packages that are explicitly required for setup.py _INSTALLER_REQUIREMENTS = [("pexpect>=4.8.0", "!Windows"), ("pywinpty==2.0.2", "Windows")] @@ -56,19 +59,21 @@ class Environment(): setup is running. Default: ``False`` """ - _backends = (("nvidia", "amd", "apple_silicon", "directml", "cpu")) + _backends = (("nvidia", "amd", "apple_silicon", "directml", "rocm", "cpu")) def __init__(self, updater: bool = False) -> None: self.conda_required_packages: List[Tuple[str, ...]] = [("tk", )] self.updater = updater # Flag that setup is being run by installer so steps can be skipped self.is_installer: bool = False - self.backend: Optional[Literal["nvidia", "amd", "apple_silicon", "directml", "cpu"]] = None + self.backend: Optional[Literal["nvidia", "amd", "apple_silicon", + "directml", "cpu", "rocm"]] = None self.enable_docker: bool = False self.required_packages: List[Tuple[str, List[Tuple[str, str]]]] = [] self.missing_packages: List[Tuple[str, List[Tuple[str, str]]]] = [] self.conda_missing_packages: List[Tuple[str, ...]] = [] self.cuda_cudnn = ["", ""] + self.rocm_version: Tuple[int, ...] = (0, 0, 0) self._process_arguments() self._check_permission() @@ -274,12 +279,10 @@ def get_installed_conda_packages(self) -> Dict[str, str]: logger.debug(retval) return retval - def update_tf_dep(self) -> None: - """ Update Tensorflow Dependency """ - if self.is_conda or self.backend != "nvidia": - # CPU/AMD doesn't need Cuda and Conda handles Cuda and cuDNN so nothing to do here + def _update_tf_dep_nvidia(self) -> None: + """ Update the Tensorflow dependency for global Cuda installs """ + if self.is_conda: # Conda handles Cuda and cuDNN so nothing to do here return - tf_ver = None cudnn_inst = self.cudnn_version.split(".") for key, val in _TENSORFLOW_REQUIREMENTS.items(): @@ -302,7 +305,7 @@ def update_tf_dep(self) -> None: return logger.warning( - "The minimum Tensorflow requirement is 2.4 \n" + "The minimum Tensorflow requirement is 2.8 \n" "Tensorflow currently has no official prebuild for your CUDA, cuDNN combination.\n" "Either install a combination that Tensorflow supports or build and install your own " "tensorflow-gpu.\r\n" @@ -330,6 +333,42 @@ def update_tf_dep(self) -> None: elif custom_tf: self.required_packages.append((custom_tf, [(custom_tf, "")])) + def _update_tf_dep_rocm(self) -> None: + """ Update the Tensorflow dependency for global ROCm installs """ + if not any(self.rocm_version): # ROCm was not found and the install will be aborted + return + + global _INSTALL_FAILED # pylint:disable=global-statement + candidates = [key for key, val in _TENSORFLOW_ROCM_REQUIREMENTS.items() + if val[0] <= self.rocm_version <= val[1]] + + if not candidates: + _INSTALL_FAILED = True + logger.error("No matching Tensorflow candidates found for ROCm %s in %s", + ".".join(str(v) for v in self.rocm_version), + _TENSORFLOW_ROCM_REQUIREMENTS) + return + + # set tf_ver to the minimum and maximum compatible range + tf_ver = f"{candidates[0].split(',')[0]},{candidates[-1].split(',')[-1]}" + # Remove the version of tensorflow-rocm in requirements file and add the correct version + # that corresponds to the installed ROCm version + self.required_packages = [pkg for pkg in self.required_packages + if not pkg[0].startswith("tensorflow-rocm")] + tf_ver = f"tensorflow-rocm{tf_ver}" + self.required_packages.append(("tensorflow-rocm", + next(parse_requirements(tf_ver)).specs)) + + def update_tf_dep(self) -> None: + """ Update Tensorflow Dependency. + + Selects a compatible version of Tensorflow for a globally installed GPU library + """ + if self.backend == "nvidia": + self._update_tf_dep_nvidia() + if self.backend == "rocm": + self._update_tf_dep_rocm() + def set_config(self) -> None: """ Set the backend in the faceswap config file """ config = {"backend": self.backend} @@ -426,10 +465,21 @@ def __init__(self, environment: Environment) -> None: return self._user_input() self._check_cuda() - self._env.update_tf_dep() + self._check_rocm() if self._env.os_version[0] == "Windows": self._tips.pip() + def _rocm_ask_enable(self) -> None: + """ Set backend to 'rocm' if OS is Linux and ROCm support required """ + if self._env.os_version[0] != "Linux": + return + logger.info("ROCm support:\r\nIf you are using an AMD GPU, then select 'yes'." + "\r\nCPU/non-AMD GPU users should answer 'no'.\r\n") + i = input("Enable ROCm Support? [y/N] ") + if i in ("Y", "y"): + logger.info("ROCm Support Enabled") + self._env.backend = "rocm" + def _directml_ask_enable(self) -> None: """ Set backend to 'directml' if OS is Windows and DirectML support required """ if self._env.os_version[0] != "Windows": @@ -443,18 +493,24 @@ def _directml_ask_enable(self) -> None: def _amd_ask_enable(self) -> None: """ Set backend to 'amd' to use plaidML if AMD support required """ + msg = "" + if self._env.os_version[0] == "Windows": + msg = "AMD users should select 'DirectML support' if possible.\r\n" + if self._env.os_version[0] == "Linux": + msg = "AMD users should select 'ROCm support' if possible.\r\n" + logger.info("AMD Support:\r\nThis version is deprecated and will be removed from a future " - "update.\r\n" - "AMD users should select 'DirectML support' if possible.\r\n" - "Nvidia Users MUST answer 'no' to this option.") + "update.\r\n%s" + "Nvidia Users MUST answer 'no' to this option.", msg) i = input("Enable AMD Support? [y/N] ") if i in ("Y", "y"): logger.info("AMD Support Enabled") self._env.backend = "amd" def _user_input(self) -> None: - """ Get user input for AMD/Cuda/Docker """ + """ Get user input for AMD/DirectML/ROCm/Cuda/Docker """ self._directml_ask_enable() + self._rocm_ask_enable() if not self._env.backend: self._amd_ask_enable() if not self._env.backend: @@ -540,6 +596,74 @@ def _check_cuda(self) -> None: logger.warning("Cannot find CUDA on macOS") self._env.cuda_cudnn[0] = input("Manually specify CUDA version: ") + def _check_rocm(self) -> None: + """ Check for ROCm version """ + if self._env.backend != "rocm" or self._env.os_version[0] != "Linux": + logger.info("Skipping ROCm checks as not enabled") + global _INSTALL_FAILED # pylint:disable=global-statement + check = ROCmCheck() + + str_min = ".".join(str(v) for v in check.version_min) + str_max = ".".join(str(v) for v in check.version_max) + + if check.is_valid: + self._env.rocm_version = check.rocm_version + logger.info("ROCm version: %s", ".".join(str(v) for v in self._env.rocm_version)) + else: + if check.rocm_version: + msg = f"Incompatible ROCm version: {'.'.join(str(v) for v in check.rocm_version)}" + else: + msg = "ROCm not found" + logger.error("%s.\n" + "A compatible version of ROCm must be installed to proceed.\n" + "ROCm versions between %s and %s are supported.\n" + "ROCm install guide: https://docs.amd.com/bundle/ROCm_Installation_Guide" + "v5.0/page/Overview_of_ROCm_Installation_Methods.html", + msg, + str_min, + str_max) + _INSTALL_FAILED = True + + +class ROCmCheck(): # pylint:disable=too-few-public-methods + """ Find the location of system installed ROCm on Linux """ + def __init__(self) -> None: + self.version_min = min(v[0] for v in _TENSORFLOW_ROCM_REQUIREMENTS.values()) + self.version_max = max(v[1] for v in _TENSORFLOW_ROCM_REQUIREMENTS.values()) + self.rocm_version: Tuple[int, ...] = (0, 0, 0) + if platform.system() == "Linux": + self._rocm_check() + + @property + def is_valid(self): + """ bool: `True` if ROCm has been detected and is between the minimum and maximum + compatible versions otherwise ``False`` """ + return self.version_min <= self.rocm_version <= self.version_max + + def _rocm_check(self) -> None: + """ Attempt to locate the installed ROCm version from the dynamic link loader. If not found + with ldconfig then attempt to find it in LD_LIBRARY_PATH. If found, set the + :attr:`rocm_version` to the discovered version + """ + chk = os.popen("ldconfig -p | grep -P \"librocm-core.so.\\d+\" | head -n 1").read() + if not chk and os.environ.get("LD_LIBRARY_PATH"): + for path in os.environ["LD_LIBRARY_PATH"].split(":"): + chk = os.popen(f"ls {path} | grep -P -o \"librocmcore.so.\\d+\" | " + "head -n 1").read() + if chk: + break + if not chk: + return + + rocm_vers = chk.strip() + version = re.search(r"rocm\-(\d+\.\d+\.\d+)", rocm_vers) + if version is None: + return + try: + self.rocm_version = tuple(int(v) for v in version.groups()[0].split(".")) + except ValueError: + return + class CudaCheck(): # pylint:disable=too-few-public-methods """ Find the location of system installed Cuda and cuDNN on Windows and Linux. """ @@ -697,6 +821,7 @@ def __init__(self, environment: Environment, is_gui: bool = False) -> None: "<": operator.lt} self._env = environment self._is_gui = is_gui + if self._env.os_version[0] == "Windows": self._installer: Type[Installer] = WinPTYInstaller else: @@ -705,6 +830,8 @@ def __init__(self, environment: Environment, is_gui: bool = False) -> None: if not self._env.is_installer and not self._env.updater: self._ask_continue() self._env.get_required_packages() + self._env.update_tf_dep() + self._check_missing_dep() self._check_conda_missing_dep() if (self._env.updater and @@ -726,10 +853,14 @@ def __init__(self, environment: Environment, is_gui: bool = False) -> None: "these packages manually.") sys.exit(1) - @classmethod - def _ask_continue(cls) -> None: + def _ask_continue(self) -> None: """ Ask Continue with Install """ - inp = input("Please ensure your System Dependencies are met. Continue? [y/N] ") + text = "Please ensure your System Dependencies are met" + if self._env.backend == "rocm": + text += ("\r\nROCm users: Please ensure that your AMD GPU is supported by the " + "installed ROCm version before proceeding.") + text += "\r\nContinue? [y/N] " + inp = input(text) if inp in ("", "N", "n"): logger.error("Please install system dependencies to continue") sys.exit(1) From 28cb2fcffb66802dfaf3441db56c89e1c5d42ae4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 11 Jan 2023 14:34:30 +0000 Subject: [PATCH 784/981] Unhide options for ROCm --- lib/cli/args.py | 4 ++-- lib/cli/launcher.py | 6 +++++- plugins/extract/detect/mtcnn_defaults.py | 2 +- plugins/extract/mask/bisenet_fp_defaults.py | 2 +- plugins/extract/pipeline.py | 2 +- plugins/extract/recognition/vgg_face2_defaults.py | 2 +- plugins/train/_config.py | 4 ++-- plugins/train/model/_base/settings.py | 4 ++-- 8 files changed, 15 insertions(+), 11 deletions(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index 5835098270..c59c3567ba 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -606,7 +606,7 @@ def get_optional_arguments() -> List[Dict[str, Any]]: opts=("-sp", "--singleprocess"), action="store_true", default=False, - backend=("nvidia", "directml"), + backend=("nvidia", "directml", "rocm", "apple_silicon"), group=_("settings"), help=_("Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the smae time. " @@ -1049,7 +1049,7 @@ def get_argument_list() -> List[Dict[str, Any]]: type=str.lower, choices=["default", "central-storage", "mirrored"], default="default", - backend=("nvidia", "directml"), + backend=("nvidia", "directml", "rocm", "apple_silicon"), group=_("training"), help=_("R|Select the distribution stategy to use." "\nL|default: Use Tensorflow's default distribution strategy." diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 53fe7e2da3..4fdb13d9b6 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -88,7 +88,7 @@ def _test_for_tf_version(self) -> None: If Tensorflow is not found, or is not between versions 2.4 and 2.9 """ amd_ver = (2, 2) - directml_ver = (2, 10) + directml_ver = rocm_ver = (2, 10) min_ver = (2, 7) max_ver = (2, 10) try: @@ -126,6 +126,10 @@ def _test_for_tf_version(self) -> None: msg = (f"The supported Tensorflow version for DirectML cards is {directml_ver} but " f"you have version {tf_ver} installed. Please install the correct version.") self._handle_import_error(msg) + if backend == "rocm" and tf_ver != rocm_ver: + msg = (f"The supported Tensorflow version for ROCm cards is {rocm_ver} but " + f"you have version {tf_ver} installed. Please install the correct version.") + self._handle_import_error(msg) logger.debug("Installed Tensorflow Version: %s", tf_ver) @classmethod diff --git a/plugins/extract/detect/mtcnn_defaults.py b/plugins/extract/detect/mtcnn_defaults.py index 1881463ef0..ace3230c3c 100755 --- a/plugins/extract/detect/mtcnn_defaults.py +++ b/plugins/extract/detect/mtcnn_defaults.py @@ -90,7 +90,7 @@ ), "cpu": dict( default=True, - info="[Not AMD] MTCNN detector still runs fairly quickly on CPU on some setups. " + info="[Not PlaidML] MTCNN detector still runs fairly quickly on CPU on some setups. " "Enable CPU mode here to use the CPU for this detector to save some VRAM at a speed " "cost.", datatype=bool, diff --git a/plugins/extract/mask/bisenet_fp_defaults.py b/plugins/extract/mask/bisenet_fp_defaults.py index 70187c6bd7..ef9a828ea7 100644 --- a/plugins/extract/mask/bisenet_fp_defaults.py +++ b/plugins/extract/mask/bisenet_fp_defaults.py @@ -65,7 +65,7 @@ fixed=True), "cpu": dict( default=False, - info="[Not AMD] BiseNet mask still runs fairly quickly on CPU on some setups. Enable " + info="[Not PlaidML] BiseNet mask still runs fairly quickly on CPU on some setups. Enable " "CPU mode here to use the CPU for this masker to save some VRAM at a speed cost.", datatype=bool, group="settings"), diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 9b9179b465..3c60c10dfc 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -709,7 +709,7 @@ def _set_extractor_batchsize(self) -> None: only. """ backend = get_backend() - if backend not in ("nvidia", "directml"): + if backend not in ("nvidia", "directml", "rocm"): logger.debug("Not updating batchsize requirements for backend: '%s'", backend) return if sum(plugin.vram for plugin in self._active_plugins) == 0: diff --git a/plugins/extract/recognition/vgg_face2_defaults.py b/plugins/extract/recognition/vgg_face2_defaults.py index 16af834922..51d168dc16 100644 --- a/plugins/extract/recognition/vgg_face2_defaults.py +++ b/plugins/extract/recognition/vgg_face2_defaults.py @@ -66,7 +66,7 @@ fixed=True), "cpu": dict( default=False, - info="[Not AMD] VGG Face2 still runs fairly quickly on CPU on some setups. Enable " + info="[Not PlaidML] VGG Face2 still runs fairly quickly on CPU on some setups. Enable " "CPU mode here to use the CPU for this plugin to save some VRAM at a speed cost.", datatype=bool, group="settings"), diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 7f8cd0f62f..b0122d2bd7 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -239,7 +239,7 @@ def _set_globals(self) -> None: title="autoclip", datatype=bool, default=False, - info="[Not AMD] Apply AutoClipping to the gradients. AutoClip analyzes the " + info="[Not PlaidML] Apply AutoClipping to the gradients. AutoClip analyzes the " "gradient weights and adjusts the normalization value dynamically to fit the " "data. Can help prevent NaNs and improve model optimization at the expense of " "VRAM. Ref: AutoClip: Adaptive Gradient Clipping for Source Separation Networks " @@ -277,7 +277,7 @@ def _set_globals(self) -> None: default=False, fixed=False, group="network", - info="[Not AMD], NVIDIA GPUs can run operations in float16 faster than in " + info="[Not PlaidML], NVIDIA GPUs can run operations in float16 faster than in " "float32. Mixed precision allows you to use a mix of float16 with float32, to " "get the performance benefits from float16 and the numeric stability benefits " "from float32.\n\nThis is untested on DirectML backend, but will run on most " diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 0baf91f464..ead7350009 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -536,7 +536,7 @@ def _get_strategy(self, The request Tensorflow Strategy if the backend is Nvidia and the strategy is not `"Default"` otherwise ``None`` """ - if get_backend() not in ("nvidia", "directml"): + if get_backend() not in ("nvidia", "directml", "rocm"): retval = None elif strategy == "mirrored": retval = self._get_mirrored_strategy() @@ -589,7 +589,7 @@ def _get_central_storage_strategy(cls) -> tf.distribute.experimental.CentralStor # `Optimizer.apply_gradients`, but it is a lot more code to check, so we just switch # the `experimental_aggregate_gradients` back to `True`. In brief testing this does not # appear to have a negative impact. - func = lambda s, grads, wvars, name: s._optimizer.apply_gradients( # noqa pylint:disable=protected-access + func = lambda s, grads, wvars, name: s._optimizer.apply_gradients( # noqa pylint:disable=protected-access,unnecessary-lambda-assignment list(zip(grads, wvars.value)), name, experimental_aggregate_gradients=True) loss_scale_optimizer.LossScaleOptimizer._apply_gradients = func # noqa pylint:disable=protected-access From 80f63280ca40978a85e98a9422e30e54ce5355e1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 13 Jan 2023 23:29:51 +0000 Subject: [PATCH 785/981] Bugfixes - plugin.train.unbalanced - decoder b - gui.stats - crash when log folder deleted - setup.py - install gcc=12.1.0 on linux (scipy dep) - setup.py - don't test ROCm unless valid - typofix - cli_args extract singleprocess help typing - lib.gui.analysis.event_reader - lib.gpu_stats.directml - make platform specific unit tests - lib.gui.analysis.event_reader --- docs/full/tests/lib.gui.rst | 15 + docs/full/tests/lib.rst | 1 + lib/cli/args.py | 2 +- lib/gpu_stats/directml.py | 3 + lib/gui/analysis/event_reader.py | 333 ++++++---- lib/gui/analysis/stats.py | 29 +- locales/es/LC_MESSAGES/lib.cli.args.po | 2 +- locales/lib.cli.args.pot | 2 +- locales/ru/LC_MESSAGES/lib.cli.args.po | 2 +- plugins/train/model/unbalanced.py | 4 +- setup.cfg | 4 +- setup.py | 468 ++++++++------ tests/lib/gui/__init__.py | 0 tests/lib/gui/stats/__init__.py | 0 tests/lib/gui/stats/event_reader_test.py | 783 +++++++++++++++++++++++ 15 files changed, 1317 insertions(+), 331 deletions(-) create mode 100644 docs/full/tests/lib.gui.rst create mode 100644 tests/lib/gui/__init__.py create mode 100644 tests/lib/gui/stats/__init__.py create mode 100644 tests/lib/gui/stats/event_reader_test.py diff --git a/docs/full/tests/lib.gui.rst b/docs/full/tests/lib.gui.rst new file mode 100644 index 0000000000..4ec4258e7b --- /dev/null +++ b/docs/full/tests/lib.gui.rst @@ -0,0 +1,15 @@ +*********** +gui package +*********** + +.. contents:: Contents + :local: + +gui.analysis.event_reader module +******************************** +Unittests for the :class:`~lib.gui.analysis.event_reader` module + +.. automodule:: tests.lib.gui.analysis.event_reader_test + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/full/tests/lib.rst b/docs/full/tests/lib.rst index f7bf51e719..a020342f8b 100644 --- a/docs/full/tests/lib.rst +++ b/docs/full/tests/lib.rst @@ -12,6 +12,7 @@ Subpackages :maxdepth: 1 lib.gpu_stats + lib.gui sysinfo module diff --git a/lib/cli/args.py b/lib/cli/args.py index c59c3567ba..2b9143a836 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -609,7 +609,7 @@ def get_optional_arguments() -> List[Dict[str, Any]]: backend=("nvidia", "directml", "rocm", "apple_silicon"), group=_("settings"), help=_("Don't run extraction in parallel. Will run each part of the extraction " - "process separately (one after the other) rather than all at the smae time. " + "process separately (one after the other) rather than all at the same time. " "Useful if VRAM is at a premium."))) argument_list.append(dict( opts=("-s", "--skip-existing"), diff --git a/lib/gpu_stats/directml.py b/lib/gpu_stats/directml.py index 1dc907351d..f17705640e 100644 --- a/lib/gpu_stats/directml.py +++ b/lib/gpu_stats/directml.py @@ -1,6 +1,9 @@ #!/usr/bin/env python3 """ Collects and returns Information on DirectX 12 hardware devices for DirectML. """ import os +import sys +assert sys.platform == "win32" + import ctypes from ctypes import POINTER, Structure, windll from dataclasses import dataclass diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index 3fdf596b32..ef2c9d6e6f 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -3,8 +3,12 @@ import logging import os +import sys import zlib +from dataclasses import dataclass, field +from typing import Any, cast, Dict, Iterator, Generator, List, Optional, Tuple, Union + import numpy as np import tensorflow as tf from tensorflow.core.util import event_pb2 # pylint:disable=no-name-in-module @@ -14,9 +18,30 @@ from lib.serializer import get_serializer from lib.utils import get_backend +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + + logger = logging.getLogger(__name__) # pylint: disable=invalid-name +@dataclass +class EventData: + """ Holds data collected from Tensorflow Event Files + + Parameters + ---------- + timestamp: float + The timestamp of the event step (iteration) + loss: list[float] + The loss values collected for A and B sides for the event step + """ + timestamp: float = 0.0 + loss: List[float] = field(default_factory=list) + + class _LogFiles(): """ Holds the filenames of the Tensorflow Event logs that require parsing. @@ -25,27 +50,27 @@ class _LogFiles(): logs_folder: str The folder that contains the Tensorboard log files """ - def __init__(self, logs_folder): + def __init__(self, logs_folder: str) -> None: logger.debug("Initializing: %s: (logs_folder: '%s')", self.__class__.__name__, logs_folder) self._logs_folder = logs_folder self._filenames = self._get_log_filenames() logger.debug("Initialized: %s", self.__class__.__name__) @property - def session_ids(self): - """ list: Sorted list of `ints` of available session ids. """ + def session_ids(self) -> List[int]: + """ list[int]: Sorted list of `ints` of available session ids. """ return list(sorted(self._filenames)) - def _get_log_filenames(self): + def _get_log_filenames(self) -> Dict[int, str]: """ Get the Tensorflow event filenames for all existing sessions. Returns ------- - dict + dict[int, str] The full path of each log file for each training session id that has been run """ logger.debug("Loading log filenames. base_dir: '%s'", self._logs_folder) - retval = {} + retval: Dict[int, str] = {} for dirpath, _, filenames in os.walk(self._logs_folder): if not any(filename.startswith("events.out.tfevents") for filename in filenames): continue @@ -58,7 +83,7 @@ def _get_log_filenames(self): return retval @classmethod - def _get_session_id(cls, folder): + def _get_session_id(cls, folder: str) -> Optional[int]: """ Obtain the session id for the given folder. Parameters @@ -68,7 +93,7 @@ def _get_session_id(cls, folder): Returns ------- - int + int or ``None`` The session ID for the given folder. If no session id can be determined, return ``None`` """ @@ -79,7 +104,7 @@ def _get_session_id(cls, folder): return retval @classmethod - def _get_log_filename(cls, folder, filenames): + def _get_log_filename(cls, folder: str, filenames: List[str]) -> str: """ Obtain the session log file for the given folder. If multiple log files exist for the given folder, then the most recent log file is used, as earlier files are assumed to be obsolete. @@ -88,25 +113,25 @@ def _get_log_filename(cls, folder, filenames): ---------- folder: str The full path to the folder that contains the session's Tensorflow Event Log - filenames: list + filenames: list[str] List of filenames that exist within the given folder Returns ------- str - The full path the the selected log file + The full path of the selected log file """ logfiles = [fname for fname in filenames if fname.startswith("events.out.tfevents")] retval = os.path.join(folder, sorted(logfiles)[-1]) # Take last item if multi matches logger.debug("logfiles: %s, selected: '%s'", logfiles, retval) return retval - def refresh(self): + def refresh(self) -> None: """ Refresh the list of log filenames. """ logger.debug("Refreshing log filenames") self._filenames = self._get_log_filenames() - def get(self, session_id): + def get(self, session_id: int) -> str: """ Obtain the log filename for the given session id. Parameters @@ -119,40 +144,117 @@ def get(self, session_id): str The full path to the log file for the requested session id """ - retval = self._filenames.get(session_id) + retval = self._filenames.get(session_id, "") logger.debug("session_id: %s, log_filename: '%s'", session_id, retval) return retval -class _Cache(): - """ Holds parsed Tensorflow log event data in a compressed cache in memory. +class _CacheData(): + """ Holds cached data that has been retrieved from Tensorflow Event Files and is compressed + in memory for a single or live training session Parameters ---------- - session_ids: list - List of `ints` pertaining to the session ids that exist in the Tensorflow events folder + labels: list[str] + The labels for the loss values + timestamps: :class:`np.ndarray` + The timestamp of the event step (iteration) + loss: :class:`np.ndarray` + The loss values collected for A and B sides for the session """ - def __init__(self, session_ids): - logger.debug("Initializing: %s: (session_ids: %s)", self.__class__.__name__, session_ids) - self._data = {idx: None for idx in session_ids} - self._carry_over = {} - self._loss_labels = [] + def __init__(self, labels: List[str], timestamps: np.ndarray, loss: np.ndarray) -> None: + self.labels = labels + self._loss = zlib.compress(cast(bytes, loss)) + self._timestamps = zlib.compress(cast(bytes, timestamps)) + self._timestamps_shape = timestamps.shape + self._loss_shape = loss.shape + + @property + def loss(self) -> np.ndarray: + """ :class:`numpy.ndarray`: The loss values for this session """ + retval: np.ndarray = np.frombuffer(zlib.decompress(self._loss), dtype="float32") + if len(self._loss_shape) > 1: + retval = retval.reshape(-1, *self._loss_shape[1:]) + return retval + + @property + def timestamps(self) -> np.ndarray: + """ :class:`numpy.ndarray`: The timestamps for this session """ + retval: np.ndarray = np.frombuffer(zlib.decompress(self._timestamps), dtype="float64") + if len(self._timestamps_shape) > 1: + retval = retval.reshape(-1, *self._timestamps_shape[1:]) + return retval + + def add_live_data(self, timestamps: np.ndarray, loss: np.ndarray) -> None: + """ Add live data to the end of the stored data + + loss: :class:`numpy.ndarray` + The latest loss values to add to the cache + timestamps: :class:`numpy.ndarray` + The latest timestamps to add to the cache + """ + new_buffer: List[bytes] = [] + new_shapes: List[Tuple[int, ...]] = [] + for data, buffer, dtype, shape in zip([timestamps, loss], + [self._timestamps, self._loss], + ["float64", "float32"], + [self._timestamps_shape, self._loss_shape]): + + old = np.frombuffer(zlib.decompress(buffer), dtype=dtype) + if data.ndim > 1: + old = old.reshape(-1, *data.shape[1:]) + + new = np.concatenate((old, data)) + + logger.debug("old_shape: %s new_shape: %s", shape, new.shape) + new_buffer.append(zlib.compress(new)) + new_shapes.append(new.shape) + del old + + self._timestamps = new_buffer[0] + self._loss = new_buffer[1] + self._timestamps_shape = new_shapes[0] + self._loss_shape = new_shapes[1] + + +class _Cache(): + """ Holds parsed Tensorflow log event data in a compressed cache in memory. """ + def __init__(self) -> None: + logger.debug("Initializing: %s", self.__class__.__name__) + self._data: Dict[int, _CacheData] = {} + self._carry_over: Dict[int, EventData] = {} + self._loss_labels: List[str] = [] logger.debug("Initialized: %s", self.__class__.__name__) - def is_cached(self, session_id): - """ bool: ``True`` if the data already exists in the cache otherwise ``False``. """ + def is_cached(self, session_id: int) -> bool: + """ Check if the given session_id's data is already cached + + Parameters + ---------- + session_id: int + The session ID to check + + Returns + ------- + bool + ``True`` if the data already exists in the cache otherwise ``False``. + """ return self._data.get(session_id) is not None - def cache_data(self, session_id, data, labels, is_live=False): + def cache_data(self, + session_id: int, + data: Dict[int, EventData], + labels: List[str], + is_live: bool = False) -> None: """ Add a full session's worth of event data to :attr:`_data`. Parameters ---------- session_id: int The session id to add the data for - data: dict + data[int, :class:`EventData`] The extracted event data dictionary generated from :class:`_EventParser` - labels: list + labels: list[str] List of `str` for the labels of each loss value output is_live: bool, optional ``True`` if the data to be cached is from a live training session otherwise ``False``. @@ -171,16 +273,14 @@ def cache_data(self, session_id, data, labels, is_live=False): timestamps, loss = self._to_numpy(data, is_live) - if not is_live or (is_live and not self._data.get(session_id, None)): - self._data[session_id] = dict(labels=self._loss_labels, - loss=zlib.compress(loss), - loss_shape=loss.shape, - timestamps=zlib.compress(timestamps), - timestamps_shape=timestamps.shape) + if not is_live or (is_live and not self._data.get(session_id)): + self._data[session_id] = _CacheData(self._loss_labels, timestamps, loss) else: self._add_latest_live(session_id, loss, timestamps) - def _to_numpy(self, data, is_live): + def _to_numpy(self, + data: Dict[int, EventData], + is_live: bool) -> Tuple[np.ndarray, np.ndarray]: """ Extract each individual step data into separate numpy arrays for loss and timestamps. Timestamps are stored float64 as the extra accuracy is needed for correct timings. Arrays @@ -206,9 +306,7 @@ def _to_numpy(self, data, is_live): logger.debug("Processing carry over: %s", self._carry_over) self._collect_carry_over(data) - times, loss = zip(*[(data[idx].get("timestamp"), data[idx].get("loss", [])) - for idx in sorted(data)]) - times, loss = self._process_data(data, times, loss, is_live) + times, loss = self._process_data(data, is_live) if is_live and not all(len(val) == len(self._loss_labels) for val in loss): # TODO Many attempts have been made to fix this for live graph logging, and the issue @@ -230,19 +328,19 @@ def _to_numpy(self, data, is_live): del loss[idx] del times[idx] - times, loss = (np.array(times, dtype="float64"), np.array(loss, dtype="float32")) + n_times, n_loss = (np.array(times, dtype="float64"), np.array(loss, dtype="float32")) logger.debug("Converted to numpy: (data points: %s, timestamps shape: %s, loss shape: %s)", - len(data), times.shape, loss.shape) + len(data), n_times.shape, n_loss.shape) - return times, loss + return n_times, n_loss - def _collect_carry_over(self, data): + def _collect_carry_over(self, data: Dict[int, EventData]) -> None: """ For live data, collect carried over data from the previous update and merge into the current data dictionary. Parameters ---------- - data: dict + data: dict[int, :class:`EventData`] The latest raw data dictionary """ logger.debug("Carry over keys: %s, data keys: %s", list(self._carry_over), list(data)) @@ -254,12 +352,14 @@ def _collect_carry_over(self, data): carry_over = self._carry_over.pop(key) update = data[key] logger.debug("Merging carry over data: %s in to %s", carry_over, update) - timestamp = update.get("timestamp") - update["timestamp"] = carry_over["timestamp"] if timestamp is None else timestamp - update.setdefault("loss", []).extend(carry_over.get("loss", [])) + timestamp = update.timestamp + update.timestamp = carry_over.timestamp if not timestamp else timestamp + update.loss = carry_over.loss + update.loss logger.debug("Merged carry over data: %s", update) - def _process_data(self, data, timestamps, loss, is_live): + def _process_data(self, + data: Dict[int, EventData], + is_live: bool) -> Tuple[List[float], List[List[float]]]: """ Process live update data. Live data requires different processing as often we will only have partial data for the @@ -271,10 +371,6 @@ def _process_data(self, data, timestamps, loss, is_live): ---------- data: dict The incoming tensorflow event data in dictionary form per step - timestamps: tuple - The raw timestamps for for the latest live query, including any partial reads - loss: tuple - The raw loss for for the latest live query, including any partial reads is_live: bool ``True`` if the data to be cached is from a live training session otherwise ``False``. @@ -285,23 +381,26 @@ def _process_data(self, data, timestamps, loss, is_live): loss: list Cleaned list of complete loss for the latest live query """ - loss = list(loss) - timestamps = list(timestamps) + timestamps, loss = zip(*[(data[idx].timestamp, data[idx].loss) + for idx in sorted(data)]) - if len(loss[-1]) != len(self._loss_labels): - logger.debug("Truncated loss found. loss count: %s", len(loss)) + l_loss: List[List[float]] = list(loss) + l_timestamps: List[float] = list(timestamps) + + if len(l_loss[-1]) != len(self._loss_labels): + logger.debug("Truncated loss found. loss count: %s", len(l_loss)) idx = sorted(data)[-1] if is_live: logger.debug("Setting carried over data: %s", data[idx]) self._carry_over[idx] = data[idx] logger.debug("Removing truncated loss: (timestamp: %s, loss: %s)", - timestamps[-1], loss[-1]) - del loss[-1] - del timestamps[-1] + l_timestamps[-1], loss[-1]) + del l_loss[-1] + del l_timestamps[-1] - return timestamps, loss + return l_timestamps, l_loss - def _add_latest_live(self, session_id, loss, timestamps): + def _add_latest_live(self, session_id: int, loss: np.ndarray, timestamps: np.ndarray) -> None: """ Append the latest received live training data to the cached data. Parameters @@ -318,25 +417,10 @@ def _add_latest_live(self, session_id, loss, timestamps): if not np.any(loss) and not np.any(timestamps): return - cache = self._data[session_id] - for metric in ("loss", "timestamps"): - data = locals()[metric] - dtype = "float32" if metric == "loss" else "float64" - - old = np.frombuffer(zlib.decompress(cache[metric]), dtype=dtype) - if data.ndim > 1: - old = old.reshape(-1, *data.shape[1:]) - - new = np.concatenate((old, data)) - - logger.debug("'%s' old_shape: %s new_shape: %s", - metric, cache[f"{metric}_shape"], new.shape) - cache[f"{metric}_shape"] = new.shape - cache[metric] = zlib.compress(new) + self._data[session_id].add_live_data(timestamps, loss) - del old - - def get_data(self, session_id, metric): + def get_data(self, session_id: int, metric: Literal["loss", "timestamps"] + ) -> Optional[Dict[int, Dict[str, Union[np.ndarray, List[str]]]]]: """ Retrieve the decompressed cached data from the cache for the given session id. Parameters @@ -349,9 +433,10 @@ def get_data(self, session_id, metric): Returns ------- - dict - The `session_id` (s) as key, the values are a dictionary containing the requested - metric information for each session returned + dict or ``None`` + The `session_id`(s) as key, the values are a dictionary containing the requested + metric information for each session returned. ``None`` if no data is stored for the + given session_id """ if session_id is None: raw = self._data @@ -361,17 +446,12 @@ def get_data(self, session_id, metric): return None raw = {session_id: data} - dtype = "float32" if metric == "loss" else "float64" - - retval = {} + retval: Dict[int, Dict[str, Union[np.ndarray, List[str]]]] = {} for idx, data in raw.items(): - buff = np.frombuffer(zlib.decompress(data[metric]), dtype=dtype) - shape = data[f"{metric}_shape"] - if len(shape) > 1: - buff = buff.reshape(-1, *shape[1:]) - val = {metric: buff} + array = data.loss if metric == "loss" else data.timestamps + val: Dict[str, Union[np.ndarray, List[str]]] = {str(metric): array} if metric == "loss": - val["labels"] = data["labels"] + val["labels"] = data.labels retval[idx] = val logger.debug("Obtained cached data: %s", @@ -395,7 +475,7 @@ class TensorBoardLogs(): is_training: bool ``True`` if the events are being read whilst Faceswap is training otherwise ``False`` """ - def __init__(self, logs_folder, is_training): + def __init__(self, logs_folder: str, is_training: bool) -> None: logger.debug("Initializing: %s: (logs_folder: %s, is_training: %s)", self.__class__.__name__, logs_folder, is_training) self._is_training = False @@ -404,16 +484,16 @@ def __init__(self, logs_folder, is_training): self._log_files = _LogFiles(logs_folder) self.set_training(is_training) - self._cache = _Cache(self.session_ids) + self._cache = _Cache() logger.debug("Initialized: %s", self.__class__.__name__) @property - def session_ids(self): - """ list: Sorted list of integers of available session ids. """ + def session_ids(self) -> List[int]: + """ list[int]: Sorted list of integers of available session ids. """ return self._log_files.session_ids - def set_training(self, is_training): + def set_training(self, is_training: bool) -> None: """ Set the internal training flag to the given `is_training` value. If a new training session is being instigated, refresh the log filenames @@ -440,7 +520,7 @@ def set_training(self, is_training): del self._training_iterator self._training_iterator = None - def _cache_data(self, session_id): + def _cache_data(self, session_id: int) -> None: """ Cache TensorBoard logs for the given session ID on first access. Populates :attr:`_cache` with timestamps and loss data. @@ -456,10 +536,11 @@ def _cache_data(self, session_id): live_data = self._is_training and session_id == max(self.session_ids) iterator = self._training_iterator if live_data else tf.compat.v1.io.tf_record_iterator( self._log_files.get(session_id)) + assert iterator is not None parser = _EventParser(iterator, self._cache, live_data) parser.cache_events(session_id) - def _check_cache(self, session_id=None): + def _check_cache(self, session_id: Optional[int] = None) -> None: """ Check if the given session_id has been cached and if not, cache it. Parameters @@ -477,7 +558,7 @@ def _check_cache(self, session_id=None): if not self._cache.is_cached(idx): self._cache_data(idx) - def get_loss(self, session_id=None): + def get_loss(self, session_id: Optional[int] = None) -> Dict[int, Dict[str, np.ndarray]]: """ Read the loss from the TensorBoard event logs Parameters @@ -493,19 +574,22 @@ def get_loss(self, session_id=None): and list of loss values for each step """ logger.debug("Getting loss: (session_id: %s)", session_id) - retval = {} + retval: Dict[int, Dict[str, np.ndarray]] = {} for idx in [session_id] if session_id else self.session_ids: self._check_cache(idx) - data = self._cache.get_data(idx, "loss") - if not data: + full_data = self._cache.get_data(idx, "loss") + if not full_data: continue - data = data[idx] - retval[idx] = {title: data["loss"][:, idx] for idx, title in enumerate(data["labels"])} + data = full_data[idx] + loss = data["loss"] + assert isinstance(loss, np.ndarray) + retval[idx] = {title: loss[:, idx] for idx, title in enumerate(data["labels"])} + logger.debug({key: {k: v.shape for k, v in val.items()} for key, val in retval.items()}) return retval - def get_timestamps(self, session_id=None): + def get_timestamps(self, session_id: Optional[int] = None) -> Dict[int, np.ndarray]: """ Read the timestamps from the TensorBoard logs. As loss timestamps are slightly different for each loss, we collect the timestamp from the @@ -525,13 +609,15 @@ def get_timestamps(self, session_id=None): logger.debug("Getting timestamps: (session_id: %s, is_training: %s)", session_id, self._is_training) - retval = {} + retval: Dict[int, np.ndarray] = {} for idx in [session_id] if session_id else self.session_ids: self._check_cache(idx) data = self._cache.get_data(idx, "timestamps") if not data: continue - retval[idx] = data[idx]["timestamps"] + timestamps = data[idx]["timestamps"] + assert isinstance(timestamps, np.ndarray) + retval[idx] = timestamps logger.debug({k: v.shape for k, v in retval.items()}) return retval @@ -549,17 +635,17 @@ class _EventParser(): # pylint:disable=too-few-public-methods ``True`` if the iterator to be loaded is a training iterator for reading live data otherwise ``False`` """ - def __init__(self, iterator, cache, live_data): + def __init__(self, iterator: Iterator[bytes], cache: _Cache, live_data: bool) -> None: logger.debug("Initializing: %s: (iterator: %s, cache: %s, live_data: %s)", self.__class__.__name__, iterator, cache, live_data) self._live_data = live_data self._cache = cache self._iterator = self._get_latest_live(iterator) if live_data else iterator - self._loss_labels = [] + self._loss_labels: List[str] = [] logger.debug("Initialized: %s", self.__class__.__name__) @classmethod - def _get_latest_live(cls, iterator): + def _get_latest_live(cls, iterator: Iterator[bytes]) -> Generator[bytes, None, None]: """ Obtain the latest event logs for live training data. The live data iterator remains open so that it can be re-queried @@ -589,7 +675,7 @@ def _get_latest_live(cls, iterator): break logger.debug("Collected %s records from live log file", i) - def cache_events(self, session_id): + def cache_events(self, session_id: int) -> None: """ Parse the Tensorflow events logs and add to :attr:`_cache`. Parameters @@ -597,7 +683,8 @@ def cache_events(self, session_id): session_id: int The session id that the data is being cached for """ - data = {} + assert self._iterator is not None + data: Dict[int, EventData] = {} try: for record in self._iterator: event = event_pb2.Event.FromString(record) # pylint:disable=no-member @@ -609,7 +696,8 @@ def cache_events(self, session_id): # No model is logged for AMD so need to get loss labels from state file self._add_amd_loss_labels(session_id) if event.summary.value[0].tag.startswith("batch_"): - data[event.step] = self._process_event(event, data.get(event.step, {})) + data[event.step] = self._process_event(event, + data.get(event.step, EventData())) except tf_errors.DataLossError as err: logger.warning("The logs for Session %s are corrupted and cannot be displayed. " @@ -618,7 +706,7 @@ def cache_events(self, session_id): self._cache.cache_data(session_id, data, self._loss_labels, is_live=self._live_data) - def _parse_outputs(self, event): + def _parse_outputs(self, event: event_pb2.Event) -> None: """ Parse the outputs from the stored model structure for mapping loss names to model outputs. @@ -659,7 +747,7 @@ def _parse_outputs(self, event): logger.debug("Collated loss labels: %s", self._loss_labels) @classmethod - def _get_outputs(cls, model_config): + def _get_outputs(cls, model_config: Dict[str, Any]) -> np.ndarray: """ Obtain the output names, instance index and output index for the given model. If there is only a single output, the shape of the array is expanded to remain consistent @@ -683,7 +771,7 @@ def _get_outputs(cls, model_config): outputs, outputs.shape) return outputs - def _add_amd_loss_labels(self, session_id): + def _add_amd_loss_labels(self, session_id: int) -> None: """ It is not possible to store the model config in the Tensorboard logs for AMD so we need to obtain the loss labels from the model's state file. This is called now so we know event data is being written, and therefore the most current loss label data is available @@ -706,7 +794,7 @@ def _add_amd_loss_labels(self, session_id): logger.debug("Collated loss labels: %s", self._loss_labels) @classmethod - def _process_event(cls, event, step): + def _process_event(cls, event: event_pb2.Event, step: EventData) -> EventData: """ Process a single Tensorflow event. Adds timestamp to the step `dict` if a total loss value is received, process the labels for @@ -716,18 +804,19 @@ def _process_event(cls, event, step): ---------- event: :class:`tensorflow.core.util.event_pb2` The event data to be processed - step: dict - The dictionary to populated with the extracted data from the tensorflow event + step: :class:`EventData` + The currently processing dictionary to be populated with the extracted data from the + tensorflow event for this step Returns ------- - dict - The given step `dict` with the given event data added to it. + :class:`EventData` + The given step :class:`EventData` with the given event data added to it. """ summary = event.summary.value[0] if summary.tag in ("batch_loss", "batch_total"): # Pre tf2.3 totals were "batch_total" - step["timestamp"] = event.wall_time + step.timestamp = event.wall_time return step loss = summary.simple_value @@ -738,6 +827,6 @@ def _process_event(cls, event, step): # https://github.com/keras-team/keras/issues/16173 loss = float(tf.make_ndarray(summary.tensor)) - step.setdefault("loss", []).append(loss) + step.loss.append(loss) return step diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index 62cd7261d1..159c1d09bf 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -13,7 +13,7 @@ from math import ceil from threading import Event -from typing import Any, cast, Dict, List, Optional, Tuple, Union +from typing import Any, cast, Dict, List, Optional, overload, Tuple, Union import numpy as np @@ -80,12 +80,14 @@ def logging_disabled(self) -> bool: ``False``. """ if not self._state: return True - return self._state["sessions"][str(self.session_ids[-1])]["no_logs"] + max_id = str(max(int(idx) for idx in self._state["sessions"])) + return self._state["sessions"][max_id]["no_logs"] @property def session_ids(self) -> List[int]: """ list: The sorted list of all existing session ids in the state file """ - assert self._tb_logs is not None + if self._tb_logs is None: + return [] return self._tb_logs.session_ids def _load_state_file(self) -> None: @@ -135,8 +137,10 @@ def initialize_session(self, self._model_dir = model_folder self._model_name = model_name self._load_state_file() - self._tb_logs = TensorBoardLogs(os.path.join(self._model_dir, f"{self._model_name}_logs"), - is_training) + if not self.logging_disabled: + self._tb_logs = TensorBoardLogs(os.path.join(self._model_dir, + f"{self._model_name}_logs"), + is_training) self._summary = SessionsSummary(self) logger.debug("Initialized session. Session_IDS: %s", self.session_ids) @@ -196,8 +200,15 @@ def get_loss(self, session_id: Optional[int]) -> Dict[str, np.ndarray]: self._is_querying.clear() return retval - def get_timestamps(self, session_id: Optional[int]) -> Union[Dict[int, np.ndarray], - np.ndarray]: + @overload + def get_timestamps(self, session_id: None) -> Dict[int, np.ndarray]: + ... + + @overload + def get_timestamps(self, session_id: int) -> np.ndarray: + ... + + def get_timestamps(self, session_id): """ Obtain the time stamps keys for the given session_id. Parameters @@ -208,7 +219,7 @@ def get_timestamps(self, session_id: Optional[int]) -> Union[Dict[int, np.ndarra Returns ------- - dict or :class:`numpy.ndarray` + dict[int] or :class:`numpy.ndarray` If a session ID has been given then a single :class:`numpy.ndarray` will be returned with the session's time stamps. Otherwise a 'dict' will be returned with the session IDs as key with :class:`numpy.ndarray` of timestamps as values @@ -750,7 +761,7 @@ def _calc_rate_total(cls) -> np.ndarray: """ logger.debug("Calculating totals rate") batchsizes = _SESSION.batch_sizes - total_timestamps = cast(Dict[int, np.ndarray], _SESSION.get_timestamps(None)) + total_timestamps = _SESSION.get_timestamps(None) rate: List[float] = [] for sess_id in sorted(total_timestamps.keys()): batchsize = batchsizes[sess_id] diff --git a/locales/es/LC_MESSAGES/lib.cli.args.po b/locales/es/LC_MESSAGES/lib.cli.args.po index 6f738b7f39..87e031fe8b 100755 --- a/locales/es/LC_MESSAGES/lib.cli.args.po +++ b/locales/es/LC_MESSAGES/lib.cli.args.po @@ -387,7 +387,7 @@ msgstr "ajustes" #: lib/cli/args.py:611 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " -"process separately (one after the other) rather than all at the smae time. " +"process separately (one after the other) rather than all at the same time. " "Useful if VRAM is at a premium." msgstr "" "No ejecute la extracción en paralelo. Ejecutará cada parte del proceso de " diff --git a/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index 7045caeb07..690d56ad12 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -263,7 +263,7 @@ msgstr "" #: lib/cli/args.py:611 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " -"process separately (one after the other) rather than all at the smae time. " +"process separately (one after the other) rather than all at the same time. " "Useful if VRAM is at a premium." msgstr "" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.po b/locales/ru/LC_MESSAGES/lib.cli.args.po index 368e736817..e9a92b0ec1 100755 --- a/locales/ru/LC_MESSAGES/lib.cli.args.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args.po @@ -376,7 +376,7 @@ msgstr "настройки" #: lib/cli/args.py:611 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " -"process separately (one after the other) rather than all at the smae time. " +"process separately (one after the other) rather than all at the same time. " "Useful if VRAM is at a premium." msgstr "" "Не проводить параллельное извлечение. Вместо одновременного запуска, каждая " diff --git a/plugins/train/model/unbalanced.py b/plugins/train/model/unbalanced.py index b68535d2e6..933e25537f 100644 --- a/plugins/train/model/unbalanced.py +++ b/plugins/train/model/unbalanced.py @@ -98,8 +98,8 @@ def decoder_a(self): def decoder_b(self): """ Decoder for side B """ kwargs = dict(kernel_size=5, kernel_initializer=self.kernel_initializer) - dense_dim = 384 if self.low_mem else self.config["complexity_decoder_b"] - decoder_complexity = 384 if self.low_mem else 512 + decoder_complexity = 384 if self.low_mem else self.config["complexity_decoder_b"] + dense_dim = 384 if self.low_mem else 512 decoder_shape = self.input_shape[0] // 16 input_ = Input(shape=(decoder_shape, decoder_shape, dense_dim)) diff --git a/setup.cfg b/setup.cfg index 9c6103dc47..facbaafe1c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,7 +4,9 @@ max-complexity=10 statistics = True count = True exclude = .git, __pycache__ -per-file-ignores = __init__.py:F401 +per-file-ignores = + __init__.py:F401 + lib/gpu_stats/directml.py:E402 [mypy] [mypy-comtypes.*] diff --git a/setup.py b/setup.py index f10ae74fc3..d77adeb2ad 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,8 @@ _TENSORFLOW_ROCM_REQUIREMENTS = {">=2.10.0,<2.11.0": ((5, 2, 0), (5, 4, 0))} # TODO tensorflow-metal versioning # Packages that are explicitly required for setup.py -_INSTALLER_REQUIREMENTS = [("pexpect>=4.8.0", "!Windows"), ("pywinpty==2.0.2", "Windows")] +_INSTALLER_REQUIREMENTS: List[Tuple[str, str]] = [("pexpect>=4.8.0", "!Windows"), + ("pywinpty==2.0.2", "Windows")] # Mapping of Python packages to their conda names if different from pip or in non-default channel _CONDA_MAPPING: Dict[str, Tuple[str, str]] = { @@ -62,16 +63,12 @@ class Environment(): _backends = (("nvidia", "amd", "apple_silicon", "directml", "rocm", "cpu")) def __init__(self, updater: bool = False) -> None: - self.conda_required_packages: List[Tuple[str, ...]] = [("tk", )] self.updater = updater # Flag that setup is being run by installer so steps can be skipped self.is_installer: bool = False self.backend: Optional[Literal["nvidia", "amd", "apple_silicon", "directml", "cpu", "rocm"]] = None self.enable_docker: bool = False - self.required_packages: List[Tuple[str, List[Tuple[str, str]]]] = [] - self.missing_packages: List[Tuple[str, List[Tuple[str, str]]]] = [] - self.conda_missing_packages: List[Tuple[str, ...]] = [] self.cuda_cudnn = ["", ""] self.rocm_version: Tuple[int, ...] = (0, 0, 0) @@ -83,9 +80,7 @@ def __init__(self, updater: bool = False) -> None: self._check_pip() self._upgrade_pip() self._set_env_vars() - - self.installed_packages = self.get_installed_packages() - self.installed_packages.update(self.get_installed_conda_packages()) + self._packages = Packages(self) @property def encoding(self) -> str: @@ -153,29 +148,6 @@ def _process_arguments(self) -> None: arg.replace("--", "") in self._backends): self.backend = arg.replace("--", "").lower() # type:ignore - def get_required_packages(self) -> None: - """ Load requirements list """ - req_files = ["_requirements_base.txt", f"requirements_{self.backend}.txt"] - pypath = os.path.dirname(os.path.realpath(__file__)) - requirements = [] - for req_file in req_files: - requirements_file = os.path.join(pypath, "requirements", req_file) - with open(requirements_file, encoding="utf8") as req: - for package in req.readlines(): - package = package.strip() - if package and (not package.startswith(("#", "-r"))): - requirements.append(package) - - # Add required installer packages - for pkg, plat in _INSTALLER_REQUIREMENTS: - if self.os_version[0] == plat or (plat[0] == "!" and self.os_version[0] != plat[1:]): - requirements.insert(0, pkg) - - self.required_packages = [(pkg.unsafe_name, pkg.specs) - for pkg in parse_requirements(requirements) - if pkg.marker is None or pkg.marker.evaluate()] - logger.debug(self.required_packages) - def _check_permission(self) -> None: """ Check for Admin permissions """ if self.updater: @@ -251,11 +223,191 @@ def _upgrade_pip(self) -> None: pip_version = pip.__version__ logger.info("Installed pip: %s", pip_version) - def get_installed_packages(self) -> Dict[str, str]: - """ Get currently installed packages """ + def set_config(self) -> None: + """ Set the backend in the faceswap config file """ + config = {"backend": self.backend} + pypath = os.path.dirname(os.path.realpath(__file__)) + config_file = os.path.join(pypath, "config", ".faceswap") + with open(config_file, "w", encoding="utf8") as cnf: + json.dump(config, cnf) + logger.info("Faceswap config written to: %s", config_file) + + def _set_env_vars(self) -> None: + """ There are some foibles under Conda which need to be worked around in different + situations. + + Linux: + Update the LD_LIBRARY_PATH environment variable when activating a conda environment + and revert it when deactivating. + + Windows + AMD + Python 3.8: + Add CONDA_DLL_SEARCH_MODIFICATION_ENABLE=1 environment variable to get around a bug which + prevents SciPy from loading in this config: https://github.com/scipy/scipy/issues/14002 + + Notes + ----- + From Tensorflow 2.7, installing Cuda Toolkit from conda-forge and tensorflow from pip + causes tensorflow to not be able to locate shared libs and hence not use the GPU. + We update the environment variable for all instances using Conda as it shouldn't hurt + anything and may help avoid conflicts with globally installed Cuda + """ + if not self.is_conda: + return + + linux_update = self.os_version[0].lower() == "linux" and self.backend == "nvidia" + windows_update = (self.os_version[0].lower() == "windows" and + self.backend == "amd" and (3, 8) <= sys.version_info < (3, 9)) + + if not linux_update and not windows_update: + return + + conda_prefix = os.environ["CONDA_PREFIX"] + activate_folder = os.path.join(conda_prefix, "etc", "conda", "activate.d") + deactivate_folder = os.path.join(conda_prefix, "etc", "conda", "deactivate.d") + os.makedirs(activate_folder, exist_ok=True) + os.makedirs(deactivate_folder, exist_ok=True) + + ext = ".bat" if windows_update else ".sh" + activate_script = os.path.join(conda_prefix, activate_folder, f"env_vars{ext}") + deactivate_script = os.path.join(conda_prefix, deactivate_folder, f"env_vars{ext}") + + if os.path.isfile(activate_script): + # Only create file if it does not already exist. There may be instances where people + # have created their own scripts, but these should be few and far between and those + # people should already know what they are doing. + return + + if linux_update: + conda_libs = os.path.join(conda_prefix, "lib") + activate = ["#!/bin/sh\n\n", + "export OLD_LD_LIBRARY_PATH=${LD_LIBRARY_PATH}\n", + f"export LD_LIBRARY_PATH='{conda_libs}':${{LD_LIBRARY_PATH}}\n"] + deactivate = ["#!/bin/sh\n\n", + "export LD_LIBRARY_PATH=${OLD_LD_LIBRARY_PATH}\n", + "unset OLD_LD_LIBRARY_PATH\n"] + logger.info("Cuda search path set to '%s'", conda_libs) + + if windows_update: + activate = ["@ECHO OFF\n", + "set CONDA_DLL_SEARCH_MODIFICATION_ENABLE=1\n"] + deactivate = ["@ECHO OFF\n", + "set CONDA_DLL_SEARCH_MODIFICATION_ENABLE=\n"] + logger.verbose("CONDA_DLL_SEARCH_MODIFICATION_ENABLE set to 1") # type: ignore + + with open(activate_script, "w", encoding="utf8") as afile: + afile.writelines(activate) + with open(deactivate_script, "w", encoding="utf8") as afile: + afile.writelines(deactivate) + + +class Packages(): + """ Holds information about installed and required packages. + Handles updating dependencies based on running platform/backend + + Parameters + ---------- + environment: :class:`Environment` + Environment class holding information about the running system + """ + def __init__(self, environment: Environment) -> None: + self._env = environment + self._conda_required_packages: List[Tuple[str, ...]] = [("tk", )] + if self._env.os_version[0] == "Linux": + # TODO Put these kind of dependencies somewhere more visible or remove when not needed + # conda-forge scipy requires GLIBCXX_3.4.30. Some Linux install do not have the + # specific version, so we install it just in case. + # Ref: https://forum.faceswap.dev/viewtopic.php?f=7&t=2247 + self._conda_required_packages.append(("gcc=12.1.0", "conda-forge")) + + self._installed_packages = self._get_installed_packages() + self._conda_installed_packages = self._get_installed_conda_packages() + self._required_packages: List[Tuple[str, List[Tuple[str, str]]]] = [] + self._missing_packages: List[Tuple[str, List[Tuple[str, str]]]] = [] + self._conda_missing_packages: List[Tuple[str, ...]] = [] + + @property + def prerequisites(self) -> List[Tuple[str, List[Tuple[str, str]]]]: + """ list: Any required packages that the installer needs prior to installing the faceswap + environment on the specific platform that are not already installed """ + all_installed = self._all_installed_packages + candidates = self._format_requirements( + [pkg for pkg, plat in _INSTALLER_REQUIREMENTS + if self._env.os_version[0] == plat or (plat[0] == "!" and + self._env.os_version[0] != plat[1:])]) + retval = [(pkg, spec) for pkg, spec in candidates + if pkg not in all_installed or ( + pkg in all_installed and + not self._validate_spec(spec, all_installed.get(pkg, "")) + )] + return retval + + @property + def packages_need_install(self) -> bool: + """bool: ``True`` if there are packages available that need to be installed """ + return bool(self._missing_packages or self._conda_missing_packages) + + @property + def to_install(self) -> List[Tuple[str, List[Tuple[str, str]]]]: + """ list: The required packages that need to be installed """ + return self._missing_packages + + @property + def to_install_conda(self) -> List[Tuple[str, ...]]: + """ list: The required conda packages that need to be installed """ + return self._conda_missing_packages + + @property + def _all_installed_packages(self) -> Dict[str, str]: + """ dict[str, str]: The package names and version string for all installed packages across + pip and conda """ + return {**self._installed_packages, **self._conda_installed_packages} + + @classmethod + def _format_requirements(cls, packages: List[str]) -> List[Tuple[str, List[Tuple[str, str]]]]: + """ Parse a list of requirements.txt formatted package strings to a list of pkgresource + formatted requirements """ + return [(package.unsafe_name, package.specs) + for package in parse_requirements(packages) + if package.marker is None or package.marker.evaluate()] + + @classmethod + def _validate_spec(cls, + required: List[Tuple[str, str]], + existing: str) -> bool: + """ Validate whether the required specification for a package is met by the installed + version. + + required: list[tuple[str, str]] + The required package version spec to check + existing: str + The version of the installed package + + Returns + ------- + bool + ``True`` if the required specification is met by the existing specification + """ + ops = {"==": operator.eq, ">=": operator.ge, "<=": operator.le, + ">": operator.gt, "<": operator.lt} + if not required: + return True + + return all(ops[spec[0]]([int(s) for s in existing.split(".")], + [int(s) for s in spec[1].split(".")]) + for spec in required) + + def _get_installed_packages(self) -> Dict[str, str]: + """ Get currently installed packages and add to :attr:`_installed_packages` + + Returns + ------- + dict[str, str] + The installed package name and version string + """ installed_packages = {} with Popen(f"\"{sys.executable}\" -m pip freeze --local", shell=True, stdout=PIPE) as chk: - installed = chk.communicate()[0].decode(self.encoding, errors="ignore").splitlines() + installed = chk.communicate()[0].decode(self._env.encoding, + errors="ignore").splitlines() for pkg in installed: if "==" not in pkg: @@ -265,9 +417,15 @@ def get_installed_packages(self) -> Dict[str, str]: logger.debug(installed_packages) return installed_packages - def get_installed_conda_packages(self) -> Dict[str, str]: - """ Get currently installed conda packages """ - if not self.is_conda: + def _get_installed_conda_packages(self) -> Dict[str, str]: + """ Get currently installed conda packages + + Returns + ------- + dict[str, str] + The installed package name and version string + """ + if not self._env.is_conda: return {} chk = os.popen("conda list").read() installed = [re.sub(" +", " ", line.strip()) @@ -279,28 +437,44 @@ def get_installed_conda_packages(self) -> Dict[str, str]: logger.debug(retval) return retval + def get_required_packages(self) -> None: + """ Load the requirements from the backend specific requirements list """ + req_files = ["_requirements_base.txt", f"requirements_{self._env.backend}.txt"] + pypath = os.path.dirname(os.path.realpath(__file__)) + requirements = [] + for req_file in req_files: + requirements_file = os.path.join(pypath, "requirements", req_file) + with open(requirements_file, encoding="utf8") as req: + for package in req.readlines(): + package = package.strip() + if package and (not package.startswith(("#", "-r"))): + requirements.append(package) + + self._required_packages = self._format_requirements(requirements) + logger.debug(self._required_packages) + def _update_tf_dep_nvidia(self) -> None: """ Update the Tensorflow dependency for global Cuda installs """ - if self.is_conda: # Conda handles Cuda and cuDNN so nothing to do here + if self._env.is_conda: # Conda handles Cuda and cuDNN so nothing to do here return tf_ver = None - cudnn_inst = self.cudnn_version.split(".") + cudnn_inst = self._env.cudnn_version.split(".") for key, val in _TENSORFLOW_REQUIREMENTS.items(): cuda_req = val[0] cudnn_req = val[1].split(".") - if cuda_req == self.cuda_version and (cudnn_req[0] == cudnn_inst[0] and - cudnn_req[1] <= cudnn_inst[1]): + if cuda_req == self._env.cuda_version and (cudnn_req[0] == cudnn_inst[0] and + cudnn_req[1] <= cudnn_inst[1]): tf_ver = key break if tf_ver: # Remove the version of tensorflow in requirements file and add the correct version # that corresponds to the installed Cuda/cuDNN versions - self.required_packages = [pkg for pkg in self.required_packages - if not pkg[0].startswith("tensorflow-gpu")] + self._required_packages = [pkg for pkg in self._required_packages + if not pkg[0].startswith("tensorflow-gpu")] tf_ver = f"tensorflow-gpu{tf_ver}" tf_ver = f"tensorflow-gpu{tf_ver}" - self.required_packages.append(("tensorflow-gpu", + self._required_packages.append(("tensorflow-gpu", next(parse_requirements(tf_ver)).specs)) return @@ -315,7 +489,7 @@ def _update_tf_dep_nvidia(self) -> None: "Building Tensorflow: https://www.tensorflow.org/install/install_sources\r\n" "Tensorflow supported versions: " "https://www.tensorflow.org/install/source#tested_build_configurations", - self.cuda_version, self.cudnn_version) + self._env.cuda_version, self._env.cudnn_version) custom_tf = input("Location of custom tensorflow-gpu wheel (leave " "blank to manually install): ") @@ -331,21 +505,21 @@ def _update_tf_dep_nvidia(self) -> None: logger.error("%s is not a valid pip wheel", custom_tf) _INSTALL_FAILED = True elif custom_tf: - self.required_packages.append((custom_tf, [(custom_tf, "")])) + self._required_packages.append((custom_tf, [(custom_tf, "")])) def _update_tf_dep_rocm(self) -> None: """ Update the Tensorflow dependency for global ROCm installs """ - if not any(self.rocm_version): # ROCm was not found and the install will be aborted + if not any(self._env.rocm_version): # ROCm was not found and the install will be aborted return global _INSTALL_FAILED # pylint:disable=global-statement candidates = [key for key, val in _TENSORFLOW_ROCM_REQUIREMENTS.items() - if val[0] <= self.rocm_version <= val[1]] + if val[0] <= self._env.rocm_version <= val[1]] if not candidates: _INSTALL_FAILED = True logger.error("No matching Tensorflow candidates found for ROCm %s in %s", - ".".join(str(v) for v in self.rocm_version), + ".".join(str(v) for v in self._env.rocm_version), _TENSORFLOW_ROCM_REQUIREMENTS) return @@ -353,97 +527,60 @@ def _update_tf_dep_rocm(self) -> None: tf_ver = f"{candidates[0].split(',')[0]},{candidates[-1].split(',')[-1]}" # Remove the version of tensorflow-rocm in requirements file and add the correct version # that corresponds to the installed ROCm version - self.required_packages = [pkg for pkg in self.required_packages - if not pkg[0].startswith("tensorflow-rocm")] + self._required_packages = [pkg for pkg in self._required_packages + if not pkg[0].startswith("tensorflow-rocm")] tf_ver = f"tensorflow-rocm{tf_ver}" - self.required_packages.append(("tensorflow-rocm", - next(parse_requirements(tf_ver)).specs)) + self._required_packages.append(("tensorflow-rocm", + next(parse_requirements(tf_ver)).specs)) def update_tf_dep(self) -> None: """ Update Tensorflow Dependency. Selects a compatible version of Tensorflow for a globally installed GPU library """ - if self.backend == "nvidia": + if self._env.backend == "nvidia": self._update_tf_dep_nvidia() - if self.backend == "rocm": + if self._env.backend == "rocm": self._update_tf_dep_rocm() - def set_config(self) -> None: - """ Set the backend in the faceswap config file """ - config = {"backend": self.backend} - pypath = os.path.dirname(os.path.realpath(__file__)) - config_file = os.path.join(pypath, "config", ".faceswap") - with open(config_file, "w", encoding="utf8") as cnf: - json.dump(config, cnf) - logger.info("Faceswap config written to: %s", config_file) - - def _set_env_vars(self) -> None: - """ There are some foibles under Conda which need to be worked around in different - situations. - - Linux: - Update the LD_LIBRARY_PATH environment variable when activating a conda environment - and revert it when deactivating. - - Windows + AMD + Python 3.8: - Add CONDA_DLL_SEARCH_MODIFICATION_ENABLE=1 environment variable to get around a bug which - prevents SciPy from loading in this config: https://github.com/scipy/scipy/issues/14002 - - Notes - ----- - From Tensorflow 2.7, installing Cuda Toolkit from conda-forge and tensorflow from pip - causes tensorflow to not be able to locate shared libs and hence not use the GPU. - We update the environment variable for all instances using Conda as it shouldn't hurt - anything and may help avoid conflicts with globally installed Cuda - """ - if not self.is_conda: - return - - linux_update = self.os_version[0].lower() == "linux" and self.backend == "nvidia" - windows_update = (self.os_version[0].lower() == "windows" and - self.backend == "amd" and (3, 8) <= sys.version_info < (3, 9)) - - if not linux_update and not windows_update: + def _check_conda_missing_dependencies(self) -> None: + """ Check for conda missing dependencies and add to :attr:`_conda_missing_packages` """ + if not self._env.is_conda: return + for pkg in self._conda_required_packages: + key = pkg[0].split("==", maxsplit=1)[0] + if key not in self._conda_installed_packages: + self._conda_missing_packages.append(pkg) + continue + if len(pkg[0].split("==")) > 1: + if pkg[0].split("==")[1] != self._conda_installed_packages.get(key): + self._conda_missing_packages.append(pkg) + continue + logger.debug(self._conda_missing_packages) - conda_prefix = os.environ["CONDA_PREFIX"] - activate_folder = os.path.join(conda_prefix, "etc", "conda", "activate.d") - deactivate_folder = os.path.join(conda_prefix, "etc", "conda", "deactivate.d") - os.makedirs(activate_folder, exist_ok=True) - os.makedirs(deactivate_folder, exist_ok=True) - - ext = ".bat" if windows_update else ".sh" - activate_script = os.path.join(conda_prefix, activate_folder, f"env_vars{ext}") - deactivate_script = os.path.join(conda_prefix, deactivate_folder, f"env_vars{ext}") + def check_missing_dependencies(self) -> None: + """ Check for missing dependencies and add to :attr:`_missing_packages` """ + for key, specs in self._required_packages: - if os.path.isfile(activate_script): - # Only create file if it does not already exist. There may be instances where people - # have created their own scripts, but these should be few and far between and those - # people should already know what they are doing. - return + if self._env.is_conda: # Get Conda alias for Key + key = _CONDA_MAPPING.get(key, (key, None))[0] - if linux_update: - conda_libs = os.path.join(conda_prefix, "lib") - activate = ["#!/bin/sh\n\n", - "export OLD_LD_LIBRARY_PATH=${LD_LIBRARY_PATH}\n", - f"export LD_LIBRARY_PATH='{conda_libs}':${{LD_LIBRARY_PATH}}\n"] - deactivate = ["#!/bin/sh\n\n", - "export LD_LIBRARY_PATH=${OLD_LD_LIBRARY_PATH}\n", - "unset OLD_LD_LIBRARY_PATH\n"] - logger.info("Cuda search path set to '%s'", conda_libs) + if key not in self._all_installed_packages: + # Add not installed packages to missing packages list + self._missing_packages.append((key, specs)) + continue - if windows_update: - activate = ["@ECHO OFF\n", - "set CONDA_DLL_SEARCH_MODIFICATION_ENABLE=1\n"] - deactivate = ["@ECHO OFF\n", - "set CONDA_DLL_SEARCH_MODIFICATION_ENABLE=\n"] - logger.verbose("CONDA_DLL_SEARCH_MODIFICATION_ENABLE set to 1") # type: ignore + if not self._validate_spec(specs, self._all_installed_packages.get(key, "")): + self._missing_packages.append((key, specs)) - with open(activate_script, "w", encoding="utf8") as afile: - afile.writelines(activate) - with open(deactivate_script, "w", encoding="utf8") as afile: - afile.writelines(deactivate) + for priority in reversed(_PRIORITY): + # Put priority packages at beginning of list + package = next((pkg for pkg in self._missing_packages if pkg[0] == priority), None) + if package: + idx = self._missing_packages.index(package) + self._missing_packages.insert(0, self._missing_packages.pop(idx)) + logger.debug(self._missing_packages) + self._check_conda_missing_dependencies() class Checks(): # pylint:disable=too-few-public-methods @@ -600,6 +737,8 @@ def _check_rocm(self) -> None: """ Check for ROCm version """ if self._env.backend != "rocm" or self._env.os_version[0] != "Linux": logger.info("Skipping ROCm checks as not enabled") + return + global _INSTALL_FAILED # pylint:disable=global-statement check = ROCmCheck() @@ -814,12 +953,8 @@ class Install(): # pylint:disable=too-few-public-methods which get scrambled in the GUI """ def __init__(self, environment: Environment, is_gui: bool = False) -> None: - self._operators = {"==": operator.eq, - ">=": operator.ge, - "<=": operator.le, - ">": operator.gt, - "<": operator.lt} self._env = environment + self._packages = environment._packages self._is_gui = is_gui if self._env.os_version[0] == "Windows": @@ -829,15 +964,15 @@ def __init__(self, environment: Environment, is_gui: bool = False) -> None: if not self._env.is_installer and not self._env.updater: self._ask_continue() - self._env.get_required_packages() - self._env.update_tf_dep() - self._check_missing_dep() - self._check_conda_missing_dep() - if (self._env.updater and - not self._env.missing_packages and not self._env.conda_missing_packages): + self._packages.get_required_packages() + self._packages.update_tf_dep() + self._packages.check_missing_dependencies() + + if self._env.updater and not self._packages.packages_need_install: logger.info("All Dependencies are up to date") return + logger.info("Installing Required Python Packages. This may take some time...") self._install_setup_packages() self._install_missing_dep() @@ -865,50 +1000,6 @@ def _ask_continue(self) -> None: logger.error("Please install system dependencies to continue") sys.exit(1) - def _check_missing_dep(self) -> None: - """ Check for missing dependencies """ - for key, specs in self._env.required_packages: - - if self._env.is_conda: # Get Conda alias for Key - key = _CONDA_MAPPING.get(key, (key, None))[0] - - if key not in self._env.installed_packages: - # Add not installed packages to missing packages list - self._env.missing_packages.append((key, specs)) - continue - - installed_vers = self._env.installed_packages.get(key, "") - - if specs and not all(self._operators[spec[0]]( - [int(s) for s in installed_vers.split(".")], - [int(s) for s in spec[1].split(".")]) - for spec in specs): - self._env.missing_packages.append((key, specs)) - - for priority in reversed(_PRIORITY): - # Put priority packages at beginning of list - package = next((pkg for pkg in self._env.missing_packages if pkg[0] == priority), None) - if package: - idx = self._env.missing_packages.index(package) - self._env.missing_packages.insert(0, self._env.missing_packages.pop(idx)) - logger.debug(self._env.missing_packages) - - def _check_conda_missing_dep(self) -> None: - """ Check for conda missing dependencies """ - if not self._env.is_conda: - return - installed_conda_packages = self._env.get_installed_conda_packages() - for pkg in self._env.conda_required_packages: - key = pkg[0].split("==")[0] - if key not in self._env.installed_packages: - self._env.conda_missing_packages.append(pkg) - continue - if len(pkg[0].split("==")) > 1: - if pkg[0].split("==")[1] != installed_conda_packages.get(key): - self._env.conda_missing_packages.append(pkg) - continue - logger.debug(self._env.conda_missing_packages) - @classmethod def _format_package(cls, package: str, version: List[Tuple[str, str]]) -> str: """ Format a parsed requirement package and version string to a format that can be used by @@ -934,13 +1025,7 @@ def _install_setup_packages(self) -> None: Subprocess is used as we do not currently have pexpect """ - pkgs = [pkg[0] for pkg in _INSTALLER_REQUIREMENTS] - setup_packages = [(pkg.unsafe_name, pkg.specs) for pkg in parse_requirements(pkgs)] - - for pkg in setup_packages: - if pkg not in self._env.missing_packages: - continue - self._env.missing_packages.pop(self._env.missing_packages.index(pkg)) + for pkg in self._packages.prerequisites: pkg_str = self._format_package(*pkg) if self._env.is_conda: cmd = ["conda", "install", "-y"] @@ -960,16 +1045,13 @@ def _install_setup_packages(self) -> None: def _install_missing_dep(self) -> None: """ Install missing dependencies """ - # Install conda packages first - if self._env.conda_missing_packages: - self._install_conda_packages() - if self._env.missing_packages: - self._install_python_packages() + self._install_conda_packages() # Install conda packages first + self._install_python_packages() def _install_python_packages(self) -> None: """ Install required pip packages """ conda_only = False - for pkg, version in self._env.missing_packages: + for pkg, version in self._packages.to_install: if self._env.is_conda: mapping = _CONDA_MAPPING.get(pkg, (pkg, "")) channel = None if mapping[1] == "" else mapping[1] @@ -999,7 +1081,7 @@ def _install_python_packages(self) -> None: def _install_conda_packages(self) -> None: """ Install required conda packages """ logger.info("Installing Required Conda Packages. This may take some time...") - for pkg in self._env.conda_missing_packages: + for pkg in self._packages.to_install_conda: channel = None if len(pkg) != 2 else pkg[1] self._from_conda(pkg[0], channel=channel, conda_only=True) diff --git a/tests/lib/gui/__init__.py b/tests/lib/gui/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/gui/stats/__init__.py b/tests/lib/gui/stats/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/gui/stats/event_reader_test.py b/tests/lib/gui/stats/event_reader_test.py new file mode 100644 index 0000000000..ad1a4894ce --- /dev/null +++ b/tests/lib/gui/stats/event_reader_test.py @@ -0,0 +1,783 @@ +#!/usr/bin python3 +""" Pytest unit tests for :mod:`lib.gui.stats.event_reader` """ +# pylint:disable=protected-access + +import json +import os + +from shutil import rmtree +from time import time +from typing import cast, Iterator +from unittest.mock import MagicMock + +import numpy as np +import pytest +import pytest_mock + +import tensorflow as tf +from tensorflow.core.util import event_pb2 # pylint:disable=no-name-in-module + +from lib.gui.analysis.event_reader import (_Cache, _CacheData, _EventParser, + _LogFiles, EventData, TensorBoardLogs) + + +def test__logfiles(tmp_path: str): + """ Test the _LogFiles class operates correctly + + Parameters + ---------- + tmp_path: :class:`pathlib.Path` + """ + # dummy logfiles + junk data + sess_1 = os.path.join(tmp_path, "session_1", "train") + sess_2 = os.path.join(tmp_path, "session_2", "train") + os.makedirs(sess_1) + os.makedirs(sess_2) + + test_log_1 = os.path.join(sess_1, "events.out.tfevents.123.456.v2") + test_log_2 = os.path.join(sess_2, "events.out.tfevents.789.012.v2") + test_log_junk = os.path.join(sess_2, "test_file.txt") + + for fname in (test_log_1, test_log_2, test_log_junk): + with open(fname, "a", encoding="utf-8"): + pass + + log_files = _LogFiles(tmp_path) + # Test all correct + assert isinstance(log_files._filenames, dict) + assert len(log_files._filenames) == 2 + assert log_files._filenames == {1: test_log_1, 2: test_log_2} + + assert log_files.session_ids == [1, 2] + + assert log_files.get(1) == test_log_1 + assert log_files.get(2) == test_log_2 + + # Remove a file, refresh and check again + rmtree(sess_1) + log_files.refresh() + assert log_files._filenames == {2: test_log_2} + assert log_files.get(2) == test_log_2 + assert log_files.get(3) == "" + + +def test__cachedata(): + """ Test the _CacheData class operates correctly """ + labels = ["label_a", "label_b"] + timestamps = np.array([1.23, 4.56], dtype="float64") + loss = np.array([[2.34, 5.67], [3.45, 6.78]], dtype="float32") + + # Initial test + cache = _CacheData(labels, timestamps, loss) + assert cache.labels == labels + assert cache._timestamps_shape == timestamps.shape + assert cache._loss_shape == loss.shape + np.testing.assert_array_equal(cache.timestamps, timestamps) + np.testing.assert_array_equal(cache.loss, loss) + + # Add data test + new_timestamps = np.array([2.34, 6.78], dtype="float64") + new_loss = np.array([[3.45, 7.89], [8.90, 1.23]], dtype="float32") + + expected_timestamps = np.concatenate([timestamps, new_timestamps]) + expected_loss = np.concatenate([loss, new_loss]) + + cache.add_live_data(new_timestamps, new_loss) + assert cache.labels == labels + assert cache._timestamps_shape == expected_timestamps.shape + assert cache._loss_shape == expected_loss.shape + np.testing.assert_array_equal(cache.timestamps, expected_timestamps) + np.testing.assert_array_equal(cache.loss, expected_loss) + + +# _Cache tests +class Test_Cache: # pylint:disable=invalid-name + """ Test that :class:`lib.gui.analysis.event_reader._Cache` works correctly """ + @staticmethod + def test_init() -> None: + """ Test __init__ """ + cache = _Cache() + assert isinstance(cache._data, dict) + assert isinstance(cache._carry_over, dict) + assert isinstance(cache._loss_labels, list) + assert not cache._data + assert not cache._carry_over + assert not cache._loss_labels + + @staticmethod + def test_is_cached() -> None: + """ Test is_cached function works """ + cache = _Cache() + + data = _CacheData(["test_1", "test_2"], + np.array([1.23, ], dtype="float64"), + np.array([[2.34, ], [4.56]], dtype="float32")) + cache._data[1] = data + assert cache.is_cached(1) + assert not cache.is_cached(2) + + @staticmethod + def test_cache_data(mocker: pytest_mock.MockerFixture) -> None: + """ Test cache_data function works + + Parameters + ---------- + mocker: :class:`pytest_mock.MockerFixture` + Mocker for checking full_info called from _SysInfo + """ + cache = _Cache() + + session_id = 1 + data = {1: EventData(4., [1., 2.]), 2: EventData(5., [3., 4.])} + labels = ['label1', 'label2'] + is_live = False + + cache.cache_data(session_id, data, labels, is_live) + assert cache._loss_labels == labels + assert cache.is_cached(session_id) + np.testing.assert_array_equal(cache._data[session_id].timestamps, np.array([4., 5.])) + np.testing.assert_array_equal(cache._data[session_id].loss, np.array([[1., 2.], [3., 4.]])) + + add_live = mocker.patch("lib.gui.analysis.event_reader._Cache._add_latest_live") + is_live = True + cache.cache_data(session_id, data, labels, is_live) + assert add_live.called + + @staticmethod + def test__to_numpy() -> None: + """ Test _to_numpy function works """ + cache = _Cache() + cache._loss_labels = ['label1', 'label2'] + data = {1: EventData(4., [1., 2.]), 2: EventData(5., [3., 4.])} + + # Non-live + is_live = False + times, loss = cache._to_numpy(data, is_live) + np.testing.assert_array_equal(times, np.array([4., 5.])) + np.testing.assert_array_equal(loss, np.array([[1., 2.], [3., 4.]])) + + # Correctly collected live + is_live = True + times, loss = cache._to_numpy(data, is_live) + np.testing.assert_array_equal(times, np.array([4., 5.])) + np.testing.assert_array_equal(loss, np.array([[1., 2.], [3., 4.]])) + + # Incorrectly collected live + live_data = {1: EventData(4., [1., 2.]), + 2: EventData(5., [3.]), + 3: EventData(6., [4., 5., 6.])} + times, loss = cache._to_numpy(live_data, is_live) + np.testing.assert_array_equal(times, np.array([4.])) + np.testing.assert_array_equal(loss, np.array([[1., 2.]])) + + @staticmethod + def test__collect_carry_over() -> None: + """ Test _collect_carry_over function works """ + data = {1: EventData(3., [4., 5.]), 2: EventData(6., [7., 8.])} + carry_over = {1: EventData(3., [2., 3.])} + expected = {1: EventData(3., [2., 3., 4., 5.]), 2: EventData(6., [7., 8.])} + + cache = _Cache() + cache._carry_over = carry_over + cache._collect_carry_over(data) + assert data == expected + + @staticmethod + def test__process_data() -> None: + """ Test _process_data function works """ + cache = _Cache() + cache._loss_labels = ['label1', 'label2'] + + data = {1: EventData(4., [5., 6.]), + 2: EventData(5., [7., 8.]), + 3: EventData(6., [9.])} + is_live = False + expected_timestamps = np.array([4., 5.]) + expected_loss = np.array([[5., 6.], [7., 8.]]) + expected_carry_over = {3: EventData(6., [9.])} + + timestamps, loss = cache._process_data(data, is_live) + np.testing.assert_array_equal(timestamps, expected_timestamps) + np.testing.assert_array_equal(loss, expected_loss) + assert not cache._carry_over + + is_live = True + timestamps, loss = cache._process_data(data, is_live) + np.testing.assert_array_equal(timestamps, expected_timestamps) + np.testing.assert_array_equal(loss, expected_loss) + assert cache._carry_over == expected_carry_over + + @staticmethod + def test__add_latest_live() -> None: + """ Test _add_latest_live function works """ + session_id = 1 + labels = ['label1', 'label2'] + data = {1: EventData(3., [5., 6.]), 2: EventData(4., [7., 8.])} + new_timestamp = np.array([5.], dtype="float64") + new_loss = np.array([[8., 9.]], dtype="float32") + expected_timestamps = np.array([3., 4., 5.]) + expected_loss = np.array([[5., 6.], [7., 8.], [8., 9.]]) + + cache = _Cache() + cache.cache_data(session_id, data, labels) # Initial data + cache._add_latest_live(session_id, new_loss, new_timestamp) + + assert cache.is_cached(session_id) + assert cache._loss_labels == labels + np.testing.assert_array_equal(cache._data[session_id].timestamps, expected_timestamps) + np.testing.assert_array_equal(cache._data[session_id].loss, expected_loss) + + @staticmethod + def test_get_data() -> None: + """ Test get_data function works """ + session_id = 1 + + cache = _Cache() + assert cache.get_data(session_id, "loss") is None + assert cache.get_data(session_id, "timestamps") is None + + labels = ['label1', 'label2'] + data = {1: EventData(3., [5., 6.]), 2: EventData(4., [7., 8.])} + expected_timestamps = np.array([3., 4.]) + expected_loss = np.array([[5., 6.], [7., 8.]]) + + cache.cache_data(session_id, data, labels, is_live=False) + get_timestamps = cache.get_data(session_id, "timestamps") + get_loss = cache.get_data(session_id, "loss") + + assert isinstance(get_timestamps, dict) + assert len(get_timestamps) == 1 + assert list(get_timestamps) == [session_id] + result = get_timestamps[session_id] + assert list(result) == ["timestamps"] + np.testing.assert_array_equal(result["timestamps"], expected_timestamps) + + assert isinstance(get_loss, dict) + assert len(get_loss) == 1 + assert list(get_loss) == [session_id] + result = get_loss[session_id] + assert list(result) == ["loss", "labels"] + np.testing.assert_array_equal(result["loss"], expected_loss) + + +# TensorBoardLogs +class TestTensorBoardLogs: + """ Test that :class:`lib.gui.analysis.event_reader.TensorBoardLogs` works correctly """ + + @pytest.fixture(name="tensorboardlogs_instance") + def tensorboardlogs_fixture(self, + tmp_path: str, + request: pytest.FixtureRequest) -> TensorBoardLogs: + """ Pytest fixture for :class:`lib.gui.analysis.event_reader.TensorBoardLogs` + + Parameters + ---------- + tmp_path: :class:`pathlib.Path` + Temporary folder for dummy data + + Returns + ------- + :class::class:`lib.gui.analysis.event_reader.TensorBoardLogs` + The class instance for testing + """ + sess_1 = os.path.join(tmp_path, "session_1", "train") + sess_2 = os.path.join(tmp_path, "session_2", "train") + os.makedirs(sess_1) + os.makedirs(sess_2) + + test_log_1 = os.path.join(sess_1, "events.out.tfevents.123.456.v2") + test_log_2 = os.path.join(sess_2, "events.out.tfevents.789.012.v2") + + for fname in (test_log_1, test_log_2): + with open(fname, "a", encoding="utf-8"): + pass + + tblogs_instance = TensorBoardLogs(tmp_path, False) + + def teardown(): + rmtree(tmp_path) + + request.addfinalizer(teardown) + return tblogs_instance + + @staticmethod + def test_init(tensorboardlogs_instance: TensorBoardLogs) -> None: + """ Test __init__ works correctly + + Parameters + ---------- + tensorboadlogs_instance: :class:`lib.gui.analysis.event_reader.TensorBoardLogs` + The class instance to test + """ + tb_logs = tensorboardlogs_instance + assert isinstance(tb_logs._log_files, _LogFiles) + assert isinstance(tb_logs._cache, _Cache) + assert not tb_logs._is_training + + is_training = True + folder = tb_logs._log_files._logs_folder + tb_logs = TensorBoardLogs(folder, is_training) + assert tb_logs._is_training + + @staticmethod + def test_session_ids(tensorboardlogs_instance: TensorBoardLogs) -> None: + """ Test session_ids property works correctly + + Parameters + ---------- + tensorboadlogs_instance: :class:`lib.gui.analysis.event_reader.TensorBoardLogs` + The class instance to test + """ + tb_logs = tensorboardlogs_instance + assert tb_logs.session_ids == [1, 2] + + @staticmethod + def test_set_training(tensorboardlogs_instance: TensorBoardLogs) -> None: + """ Test set_training works correctly + + Parameters + ---------- + tensorboadlogs_instance: :class:`lib.gui.analysis.event_reader.TensorBoardLogs` + The class instance to test + """ + tb_logs = tensorboardlogs_instance + assert not tb_logs._is_training + assert tb_logs._training_iterator is None + tb_logs.set_training(True) + assert tb_logs._is_training + assert tb_logs._training_iterator is not None + tb_logs.set_training(False) + assert not tb_logs._is_training + assert tb_logs._training_iterator is None + + @staticmethod + def test__cache_data(tensorboardlogs_instance: TensorBoardLogs, + mocker: pytest_mock.MockerFixture) -> None: + """ Test _cache_data works correctly + + Parameters + ---------- + tensorboadlogs_instance: :class:`lib.gui.analysis.event_reader.TensorBoardLogs` + The class instance to test + mocker: :class:`pytest_mock.MockerFixture` + Mocker for checking event parser caching is called + """ + tb_logs = tensorboardlogs_instance + session_id = 1 + cacher = mocker.patch("lib.gui.analysis.event_reader._EventParser.cache_events") + tb_logs._cache_data(session_id) + assert cacher.called + cacher.reset_mock() + + tb_logs.set_training(True) + tb_logs._cache_data(session_id) + assert cacher.called + + @staticmethod + def test__check_cache(tensorboardlogs_instance: TensorBoardLogs, + mocker: pytest_mock.MockerFixture) -> None: + """ Test _check_cache works correctly + + Parameters + ---------- + tensorboadlogs_instance: :class:`lib.gui.analysis.event_reader.TensorBoardLogs` + The class instance to test + mocker: :class:`pytest_mock.MockerFixture` + Mocker for checking _cache_data is called + """ + is_cached = mocker.patch("lib.gui.analysis.event_reader._Cache.is_cached") + cache_data = mocker.patch("lib.gui.analysis.event_reader.TensorBoardLogs._cache_data") + tb_logs = tensorboardlogs_instance + + # Session ID not training + is_cached.return_value = False + tb_logs._check_cache(1) + assert is_cached.called + assert cache_data.called + is_cached.reset_mock() + cache_data.reset_mock() + + is_cached.return_value = True + tb_logs._check_cache(1) + assert is_cached.called + assert not cache_data.called + is_cached.reset_mock() + cache_data.reset_mock() + + # Session ID and training + tb_logs.set_training(True) + tb_logs._check_cache(1) + assert not cache_data.called + cache_data.reset_mock() + + tb_logs._check_cache(2) + assert cache_data.called + cache_data.reset_mock() + + # No session id + tb_logs.set_training(False) + is_cached.return_value = False + + tb_logs._check_cache(None) + assert is_cached.called + assert cache_data.called + is_cached.reset_mock() + cache_data.reset_mock() + + is_cached.return_value = True + tb_logs._check_cache(None) + assert is_cached.called + assert not cache_data.called + is_cached.reset_mock() + cache_data.reset_mock() + + @staticmethod + def test_get_loss(tensorboardlogs_instance: TensorBoardLogs, + mocker: pytest_mock.MockerFixture) -> None: + """ Test get_loss works correctly + + Parameters + ---------- + tensorboadlogs_instance: :class:`lib.gui.analysis.event_reader.TensorBoardLogs` + The class instance to test + mocker: :class:`pytest_mock.MockerFixture` + Mocker for checking _cache_data is called + """ + tb_logs = tensorboardlogs_instance + + with pytest.raises(tf.errors.NotFoundError): # Invalid session id + tb_logs.get_loss(3) + + check_cache = mocker.patch("lib.gui.analysis.event_reader.TensorBoardLogs._check_cache") + get_data = mocker.patch("lib.gui.analysis.event_reader._Cache.get_data") + get_data.return_value = None + + assert isinstance(tb_logs.get_loss(None), dict) + assert check_cache.call_count == 2 + assert get_data.call_count == 2 + check_cache.reset_mock() + get_data.reset_mock() + + assert isinstance(tb_logs.get_loss(1), dict) + assert check_cache.call_count == 1 + assert get_data.call_count == 1 + check_cache.reset_mock() + get_data.reset_mock() + + @staticmethod + def test_get_timestamps(tensorboardlogs_instance: TensorBoardLogs, + mocker: pytest_mock.MockerFixture) -> None: + """ Test get_timestamps works correctly + + Parameters + ---------- + tensorboadlogs_instance: :class:`lib.gui.analysis.event_reader.TensorBoardLogs` + The class instance to test + mocker: :class:`pytest_mock.MockerFixture` + Mocker for checking _cache_data is called + """ + tb_logs = tensorboardlogs_instance + with pytest.raises(tf.errors.NotFoundError): # invalid session_id + tb_logs.get_timestamps(3) + + check_cache = mocker.patch("lib.gui.analysis.event_reader.TensorBoardLogs._check_cache") + get_data = mocker.patch("lib.gui.analysis.event_reader._Cache.get_data") + get_data.return_value = None + + assert isinstance(tb_logs.get_timestamps(None), dict) + assert check_cache.call_count == 2 + assert get_data.call_count == 2 + check_cache.reset_mock() + get_data.reset_mock() + + assert isinstance(tb_logs.get_timestamps(1), dict) + assert check_cache.call_count == 1 + assert get_data.call_count == 1 + check_cache.reset_mock() + get_data.reset_mock() + + +# EventParser +class Test_EventParser: # pylint:disable=invalid-name + """ Test that :class:`lib.gui.analysis.event_reader.TensorBoardLogs` works correctly """ + def _create_example_event(self, + step: int, + loss_value: float, + timestamp: float, + serialize: bool = True) -> bytes: + """ Generate a test TensorBoard event + + Parameters + ---------- + step: int + The step value to use + loss_value: float + The loss value to store + timestamp: float + The timestamp to store + serialize: bool, optional + ``True`` to serialize the event to bytes, ``False`` to return the Event object + """ + tags = {0: "keras", 1: "batch_loss", 2: "batch_face_a", 3: "batch_face_b"} + event = event_pb2.Event(step=step) + event.summary.value.add(tag=tags[step], # pylint:disable=no-member + simple_value=loss_value) + event.wall_time = timestamp + retval = event.SerializeToString() if serialize else event + return retval + + @pytest.fixture(name="mock_iterator") + def iterator(self) -> Iterator[bytes]: + """ Dummy iterator for generating test events + + Yields + ------ + bytes + A serialized test Tensorboard Event + """ + return iter([self._create_example_event(i, 1 + (i / 10), time()) for i in range(4)]) + + @pytest.fixture(name="mock_cache") + def mock_cache(self): + """ Dummy :class:`_Cache` for testing""" + class _CacheMock: + def __init__(self): + self.data = {} + self._loss_labels = [] + + def is_cached(self, session_id): + """ Dummy is_cached method""" + return session_id in self.data + + def cache_data(self, session_id, data, labels, + is_live=False): # pylint:disable=unused-argument + """ Dummy cache_data method""" + self.data[session_id] = {'data': data, 'labels': labels} + + return _CacheMock() + + @pytest.fixture(name="event_parser_instance") + def event_parser_fixture(self, + mock_iterator: Iterator[bytes], + mock_cache: _Cache) -> _EventParser: + """ Pytest fixture for :class:`lib.gui.analysis.event_reader._EventParser` + + Parameters + ---------- + mock_iterator: Iterator[bytes] + Dummy iterator for generating TF Event data + mock_cache: :class:'_CacheMock' + Dummy _Cache object + + Returns + ------- + :class::class:`lib.gui.analysis.event_reader._EventParser` + The class instance for testing + """ + event_parser = _EventParser(mock_iterator, mock_cache, live_data=False) + return event_parser + + def test__init_(self, + event_parser_instance: _EventParser, + mock_iterator: Iterator[bytes], + mock_cache: _Cache) -> None: + """ Test __init__ works correctly + + Parameters + ---------- + event_parser_instance: :class:`lib.gui.analysis.event_reader._EventParser` + The class instance to test + mock_iterator: Iterator[bytes] + Dummy iterator for generating TF Event data + mock_cache: :class:'_CacheMock' + Dummy _Cache object + """ + event_parse = event_parser_instance + assert not hasattr(event_parse._iterator, "__name__") + evp_live = _EventParser(mock_iterator, mock_cache, live_data=True) + assert evp_live._iterator.__name__ == "_get_latest_live" # type:ignore[attr-defined] + + def test__get_latest_live(self, event_parser_instance: _EventParser) -> None: + """ Test _get_latest_live works correctly + + Parameters + ---------- + event_parser_instance: :class:`lib.gui.analysis.event_reader._EventParser` + The class instance to test + """ + event_parse = event_parser_instance + test = list(event_parse._get_latest_live(event_parse._iterator)) + assert len(test) == 4 + + def test_cache_events(self, + event_parser_instance: _EventParser, + mocker: pytest_mock.MockerFixture, + monkeypatch: pytest.MonkeyPatch) -> None: + """ Test cache_events works correctly + + Parameters + ---------- + event_parser_instance: :class:`lib.gui.analysis.event_reader._EventParser` + The class instance to test + mocker: :class:`pytest_mock.MockerFixture` + Mocker for capturing method calls + monkeypatch: :class:`pytest.MonkeyPatch` + For patching different iterators for testing output + """ + monkeypatch.setattr("lib.utils._FS_BACKEND", "cpu") # We'll test AMD separately + + event_parse = event_parser_instance + event_parse._parse_outputs = cast(MagicMock, mocker.MagicMock()) # type:ignore + event_parse._add_amd_loss_labels = cast(MagicMock, mocker.MagicMock()) # type:ignore + event_parse._process_event = cast(MagicMock, mocker.MagicMock()) # type:ignore + event_parse._cache.cache_data = cast(MagicMock, mocker.MagicMock()) # type:ignore + + # keras model + monkeypatch.setattr(event_parse, + "_iterator", + iter([self._create_example_event(0, 1., time())])) + event_parse.cache_events(1) + assert event_parse._parse_outputs.called + assert not event_parse._add_amd_loss_labels.called + assert not event_parse._process_event.called + assert event_parse._cache.cache_data.called + event_parse._parse_outputs.reset_mock() + event_parse._add_amd_loss_labels.reset_mock() + event_parse._process_event.reset_mock() + event_parse._cache.cache_data.reset_mock() + + # Batch item + monkeypatch.setattr(event_parse, + "_iterator", + iter([self._create_example_event(1, 1., time())])) + event_parse.cache_events(1) + assert not event_parse._parse_outputs.called + assert not event_parse._add_amd_loss_labels.called + assert event_parse._process_event.called + assert event_parse._cache.cache_data.called + event_parse._parse_outputs.reset_mock() + event_parse._add_amd_loss_labels.reset_mock() + event_parse._process_event.reset_mock() + event_parse._cache.cache_data.reset_mock() + + # No summary value + monkeypatch.setattr(event_parse, + "_iterator", + iter([event_pb2.Event(step=1).SerializeToString()])) + assert not event_parse._parse_outputs.called + assert not event_parse._add_amd_loss_labels.called + assert not event_parse._process_event.called + assert not event_parse._cache.cache_data.called + event_parse._parse_outputs.reset_mock() + event_parse._add_amd_loss_labels.reset_mock() + event_parse._process_event.reset_mock() + event_parse._cache.cache_data.reset_mock() + + # AMD + batch item 2 + monkeypatch.setattr("lib.utils._FS_BACKEND", "amd") + monkeypatch.setattr(event_parse, + "_iterator", + iter([self._create_example_event(2, 1., time())])) + event_parse.cache_events(1) + assert not event_parse._parse_outputs.called + assert event_parse._add_amd_loss_labels.called + assert event_parse._process_event.called + assert event_parse._cache.cache_data.called + + def test__parse_outputs(self, + event_parser_instance: _EventParser, + mocker: pytest_mock.MockerFixture) -> None: + """ Test _parse_outputs works correctly + + Parameters + ---------- + event_parser_instance: :class:`lib.gui.analysis.event_reader._EventParser` + The class instance to test + mocker: :class:`pytest_mock.MockerFixture` + Mocker for event object + """ + event_parse = event_parser_instance + model = {"config": {"layers": [{"name": "decoder_a", + "config": {"output_layers": [["face_out_a", 0, 0]]}}, + {"name": "decoder_b", + "config": {"output_layers": [["face_out_b", 0, 0]]}}], + "output_layers": [["decoder_a", 1, 0], ["decoder_b", 1, 0]]}} + data = json.dumps(model).encode("utf-8") + + event = mocker.MagicMock() + event.summary.value.__getitem__ = lambda self, x: event + event.tensor.string_val.__getitem__ = lambda self, x: data + + assert not event_parse._loss_labels + event_parse._parse_outputs(event) + assert event_parse._loss_labels == ["face_out_a", "face_out_b"] + + def test__get_outputs(self, event_parser_instance: _EventParser) -> None: + """ Test _get_outputs works correctly + + Parameters + ---------- + event_parser_instance: :class:`lib.gui.analysis.event_reader._EventParser` + The class instance to test + """ + outputs = [["decoder_a", 1, 0], ["decoder_b", 1, 0]] + model_config = {"output_layers": outputs} + + expected = np.array([[out] for out in outputs]) + actual = event_parser_instance._get_outputs(model_config) + assert isinstance(actual, np.ndarray) + assert actual.shape == (2, 1, 3) + np.testing.assert_equal(expected, actual) + + def test__add_amd_loss_labels(self, + event_parser_instance: _EventParser, + mocker: pytest_mock.MockerFixture) -> None: + """ Test _add_amd_loss_labels works correctly + + Parameters + ---------- + event_parser_instance: :class:`lib.gui.analysis.event_reader._EventParser` + The class instance to test + mocker: :class:`pytest_mock.MockerFixture` + Mocker for checking Session data + """ + event_parse = event_parser_instance + + # Already collected + assert not event_parse._cache._loss_labels + event_parse._cache._loss_labels.extend(["label_a", "label_b"]) + event_parse._add_amd_loss_labels(1) + assert not event_parse._loss_labels + + # New labels + event_parse._cache._loss_labels = [] + mock_session = mocker.patch("lib.gui.analysis.Session") + mock_session.get_loss_keys.return_value = ["label_c", "label_d"] + assert not event_parse._cache._loss_labels + event_parse._add_amd_loss_labels(1) + assert event_parse._loss_labels == ["label_c", "label_d"] + + def test__process_event(self, event_parser_instance: _EventParser) -> None: + """ Test _process_event works correctly + + Parameters + ---------- + event_parser_instance: :class:`lib.gui.analysis.event_reader._EventParser` + The class instance to test + """ + event_parse = event_parser_instance + event_data = EventData() + assert not event_data.timestamp + assert not event_data.loss + timestamp = time() + loss = [1.1, 2.2] + event = self._create_example_event(1, 1.0, timestamp, serialize=False) # batch_total + event_parse._process_event(event, event_data) + event = self._create_example_event(2, loss[0], time(), serialize=False) # face A + event_parse._process_event(event, event_data) + event = self._create_example_event(3, loss[1], time(), serialize=False) # face B + event_parse._process_event(event, event_data) + + # Original timestamp and both loss values collected + assert event_data.timestamp == timestamp + np.testing.assert_almost_equal(event_data.loss, loss) # float rounding From 34b558426e516cd08004a69fd4261a0f7086a32c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 17 Jan 2023 15:03:29 +0000 Subject: [PATCH 786/981] bugfix: - preview tool - Prevent tkinter variables from exiting main thread refactor: - preview tool - Split module to smaller sub-modules + docs, locales Typing: - tools.preview.cli Unit test: - tools.preview.viewer --- docs/full/tests/tests.rst | 1 + docs/full/tests/tools.preview.rst | 15 + docs/full/tests/tools.rst | 14 + docs/full/tools/preview.rst | 44 + docs/full/tools/tools.rst | 26 +- lib/gui/utils/config.py | 35 +- locales/tools.preview.pot | 60 +- tests/tools/__init__.py | 0 tests/tools/preview/__init__.py | 0 tests/tools/preview/viewer_test.py | 480 ++++++++++ tools/preview/cli.py | 22 +- tools/preview/control_panels.py | 667 ++++++++++++++ tools/preview/preview.py | 1308 +++++----------------------- tools/preview/viewer.py | 295 +++++++ 14 files changed, 1789 insertions(+), 1178 deletions(-) create mode 100644 docs/full/tests/tools.preview.rst create mode 100644 docs/full/tests/tools.rst create mode 100644 docs/full/tools/preview.rst create mode 100644 tests/tools/__init__.py create mode 100644 tests/tools/preview/__init__.py create mode 100644 tests/tools/preview/viewer_test.py create mode 100644 tools/preview/control_panels.py create mode 100644 tools/preview/viewer.py diff --git a/docs/full/tests/tests.rst b/docs/full/tests/tests.rst index 0785dd7f82..a24f36aa6f 100644 --- a/docs/full/tests/tests.rst +++ b/docs/full/tests/tests.rst @@ -14,3 +14,4 @@ Subpackages :maxdepth: 1 lib + tools diff --git a/docs/full/tests/tools.preview.rst b/docs/full/tests/tools.preview.rst new file mode 100644 index 0000000000..7c744b12f0 --- /dev/null +++ b/docs/full/tests/tools.preview.rst @@ -0,0 +1,15 @@ +*************** +preview package +*************** + +.. contents:: Contents + :local: + +viewer_test module +****************** +Unittests for the :class:`~tools.preview.viewer` module + +.. automodule:: tests.tools.preview.viewer_test + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/full/tests/tools.rst b/docs/full/tests/tools.rst new file mode 100644 index 0000000000..881e7c7702 --- /dev/null +++ b/docs/full/tests/tools.rst @@ -0,0 +1,14 @@ +************* +tools package +************* + +.. contents:: Contents + :local: + +Subpackages +=========== + +.. toctree:: + :maxdepth: 1 + + tools.preview diff --git a/docs/full/tools/preview.rst b/docs/full/tools/preview.rst new file mode 100644 index 0000000000..5c0c76d790 --- /dev/null +++ b/docs/full/tools/preview.rst @@ -0,0 +1,44 @@ +*************** +preview package +*************** + +.. contents:: Contents + :local: + + +preview module +============== +The Preview Module is the main entry point into the Preview Tool. + +.. automodule:: tools.preview.preview + :members: + :undoc-members: + :show-inheritance: + + +cli module +========== + +.. automodule:: tools.preview.cli + :members: + :undoc-members: + :show-inheritance: + + +control_panels module +===================== + +.. automodule:: tools.preview.control_panels + :members: + :undoc-members: + :show-inheritance: + + +viewer module +============= + +.. automodule:: tools.preview.viewer + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/full/tools/tools.rst b/docs/full/tools/tools.rst index d14a72aa0d..0381c4b9c3 100644 --- a/docs/full/tools/tools.rst +++ b/docs/full/tools/tools.rst @@ -15,6 +15,7 @@ Subpackages alignments manual + preview sort mask module @@ -32,28 +33,3 @@ model module :members: :undoc-members: :show-inheritance: - -preview module -=============== - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~tools.preview.preview.ActionFrame - ~tools.preview.preview.ConfigFrame - ~tools.preview.preview.ConfigTools - ~tools.preview.preview.FacesDisplay - ~tools.preview.preview.ImagesCanvas - ~tools.preview.preview.OptionsBook - ~tools.preview.preview.Patch - ~tools.preview.preview.Preview - ~tools.preview.preview.Samples - -.. rubric:: Module - -.. automodule:: tools.preview.preview - :members: - :undoc-members: - :show-inheritance: diff --git a/lib/gui/utils/config.py b/lib/gui/utils/config.py index 1c8dd191da..3d4096a34f 100644 --- a/lib/gui/utils/config.py +++ b/lib/gui/utils/config.py @@ -26,8 +26,8 @@ def initialize_config(root: tk.Tk, - cli_opts: "CliOptions", - statusbar: "StatusBar") -> Optional["Config"]: + cli_opts: Optional["CliOptions"], + statusbar: Optional["StatusBar"]) -> Optional["Config"]: """ Initialize the GUI Master :class:`Config` and add to global constant. This should only be called once on first GUI startup. Future access to :class:`Config` @@ -37,10 +37,10 @@ def initialize_config(root: tk.Tk, ---------- root: :class:`tkinter.Tk` The root Tkinter object - cli_opts: :class:`lib.gui.options.CliOptions` - The command line options object - statusbar: :class:`lib.gui.custom_widgets.StatusBar` - The GUI Status bar + cli_opts: :class:`lib.gui.options.CliOptions` or ``None`` + The command line options object. Must be provided for main GUI. Must be ``None`` for tools + statusbar: :class:`lib.gui.custom_widgets.StatusBar` or ``None`` + The GUI Status bar. Must be provided for main GUI. Must be ``None`` for tools Returns ------- @@ -145,11 +145,11 @@ def _initialize_variables(self) -> None: @dataclass class _GuiObjects: """ Data class for commonly accessed GUI Objects """ - cli_opts: "CliOptions" + cli_opts: Optional["CliOptions"] tk_vars: GlobalVariables project: Project tasks: Tasks - status_bar: "StatusBar" + status_bar: Optional["StatusBar"] default_options: Dict[str, Dict[str, Any]] = field(default_factory=dict) command_notebook: Optional["CommandNotebook"] = None @@ -165,12 +165,15 @@ class Config(): ---------- root: :class:`tkinter.Tk` The root Tkinter object - cli_opts: :class:`lib.gui.options.CliOpts` - The command line options object - statusbar: :class:`lib.gui.custom_widgets.StatusBar` - The GUI Status bar + cli_opts: :class:`lib.gui.options.CliOptions` or ``None`` + The command line options object. Must be provided for main GUI. Must be ``None`` for tools + statusbar: :class:`lib.gui.custom_widgets.StatusBar` or ``None`` + The GUI Status bar. Must be provided for main GUI. Must be ``None`` for tools """ - def __init__(self, root: tk.Tk, cli_opts: "CliOptions", statusbar: "StatusBar") -> None: + def __init__(self, + root: tk.Tk, + cli_opts: Optional["CliOptions"], + statusbar: Optional["StatusBar"]) -> None: logger.debug("Initializing %s: (root %s, cli_opts: %s, statusbar: %s)", self.__class__.__name__, root, cli_opts, statusbar) self._default_font = cast(dict, tk.font.nametofont("TkDefaultFont").configure())["family"] @@ -210,6 +213,9 @@ def pathcache(self) -> str: @property def cli_opts(self) -> "CliOptions": """ :class:`lib.gui.options.CliOptions`: The command line options for this GUI Session. """ + # This should only be None when a separate tool (not main GUI) is used, at which point + # cli_opts do not exist + assert self._gui_objects.cli_opts is not None return self._gui_objects.cli_opts @property @@ -236,6 +242,9 @@ def default_options(self) -> Dict[str, Dict[str, Any]]: def statusbar(self) -> "StatusBar": """ :class:`lib.gui.custom_widgets.StatusBar`: The GUI StatusBar :class:`tkinter.ttk.Frame`. """ + # This should only be None when a separate tool (not main GUI) is used, at which point + # this statusbar does not exist + assert self._gui_objects.status_bar is not None return self._gui_objects.status_bar @property diff --git a/locales/tools.preview.pot b/locales/tools.preview.pot index 3d98ad6363..aa2e650ef4 100644 --- a/locales/tools.preview.pot +++ b/locales/tools.preview.pot @@ -1,72 +1,80 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR ORGANIZATION +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # +#, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-03-10 16:51-0000\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-01-16 12:27+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" +"Language: \n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=cp1252\n" +"Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" - -#: ./tools/preview\cli.py:13 +#: tools/preview/cli.py:14 msgid "This command allows you to preview swaps to tweak convert settings." msgstr "" -#: ./tools/preview\cli.py:22 +#: tools/preview/cli.py:29 msgid "" "Preview tool\n" "Allows you to configure your convert settings with a live preview" msgstr "" -#: ./tools/preview\cli.py:32 ./tools/preview\cli.py:41 -#: ./tools/preview\cli.py:48 +#: tools/preview/cli.py:46 tools/preview/cli.py:55 tools/preview/cli.py:62 msgid "data" msgstr "" -#: ./tools/preview\cli.py:34 -msgid "Input directory or video. Either a directory containing the image files you wish to process or path to a video file." +#: tools/preview/cli.py:48 +msgid "" +"Input directory or video. Either a directory containing the image files you " +"wish to process or path to a video file." msgstr "" -#: ./tools/preview\cli.py:43 -msgid "Path to the alignments file for the input, if not at the default location" +#: tools/preview/cli.py:57 +msgid "" +"Path to the alignments file for the input, if not at the default location" msgstr "" -#: ./tools/preview\cli.py:50 -msgid "Model directory. A directory containing the trained model you wish to process." +#: tools/preview/cli.py:64 +msgid "" +"Model directory. A directory containing the trained model you wish to " +"process." msgstr "" -#: ./tools/preview\cli.py:57 +#: tools/preview/cli.py:71 msgid "Swap the model. Instead of A -> B, swap B -> A" msgstr "" -#: ./tools/preview\preview.py:1303 +#: tools/preview/control_panels.py:496 msgid "Save full config" msgstr "" -#: ./tools/preview\preview.py:1306 +#: tools/preview/control_panels.py:499 msgid "Reset full config to default values" msgstr "" -#: ./tools/preview\preview.py:1309 +#: tools/preview/control_panels.py:502 msgid "Reset full config to saved values" msgstr "" -#: ./tools/preview\preview.py:1453 -msgid "Save {} config" +#: tools/preview/control_panels.py:653 +#, python-brace-format +msgid "Save {title} config" msgstr "" -#: ./tools/preview\preview.py:1456 -msgid "Reset {} config to default values" +#: tools/preview/control_panels.py:656 +#, python-brace-format +msgid "Reset {title} config to default values" msgstr "" -#: ./tools/preview\preview.py:1459 -msgid "Reset {} config to saved values" +#: tools/preview/control_panels.py:659 +#, python-brace-format +msgid "Reset {title} config to saved values" msgstr "" - diff --git a/tests/tools/__init__.py b/tests/tools/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/tools/preview/__init__.py b/tests/tools/preview/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/tools/preview/viewer_test.py b/tests/tools/preview/viewer_test.py new file mode 100644 index 0000000000..96a9bb7c36 --- /dev/null +++ b/tests/tools/preview/viewer_test.py @@ -0,0 +1,480 @@ +#!/usr/bin python3 +""" Pytest unit tests for :mod:`tools.preview.viewer` """ +import tkinter as tk +from tkinter import ttk + +from typing import cast, TYPE_CHECKING +from unittest.mock import MagicMock + +import pytest +import pytest_mock +import numpy as np +from PIL import ImageTk + +from lib.logger import log_setup +log_setup("DEBUG", "", "PyTest, False") # Need to setup logging to avoid trace/verbose errors + +from lib.utils import get_backend # pylint:disable=wrong-import-position # noqa +from tools.preview.viewer import _Faces, FacesDisplay, ImagesCanvas # pylint:disable=wrong-import-position # noqa + +if TYPE_CHECKING: + from lib.align.aligned_face import CenteringType + + +# pylint:disable=protected-access + + +def test__faces(): + """ Test the :class:`~tools.preview.viewer._Faces dataclass initializes correctly """ + faces = _Faces() + assert faces.filenames == [] + assert faces.matrix == [] + assert faces.src == [] + assert faces.dst == [] + + +_PARAMS = [(3, 448), (4, 333), (5, 254), (6, 128)] # columns/face_size +_IDS = [f"cols:{c},size:{s}[{get_backend().upper()}]" for c, s in _PARAMS] + + +class TestFacesDisplay(): + """ Test :class:`~tools.preview.viewer.FacesDisplay """ + _padding = 64 + + def get_faces_display_instance(self, columns: int = 5, face_size: int = 256) -> FacesDisplay: + """ Obtain an instance of :class:`~tools.preview.viewer.FacesDisplay` with the given column + and face size layout. + + Parameters + ---------- + columns: int, optional + The number of columns to display in the viewer, default: 5 + face_size: int, optional + The size of each face image to be displayed in the viewer, default: 256 + + Returns + ------- + :class:`~tools.preview.viewer.FacesDisplay` + An instance of the FacesDisplay class at the given settings + """ + app = MagicMock() + retval = FacesDisplay(app, face_size, self._padding) + retval._faces = _Faces( + matrix=[np.random.rand(2, 3) for _ in range(columns)], + src=[np.random.rand(face_size, face_size, 3) for _ in range(columns)], + dst=[np.random.rand(face_size, face_size, 3) for _ in range(columns)]) + return retval + + def test_init(self) -> None: + """ Test :class:`~tools.preview.viewer.FacesDisplay` __init__ method """ + f_display = self.get_faces_display_instance(face_size=256) + assert f_display._size == 256 + assert f_display._padding == self._padding + assert isinstance(f_display._app, MagicMock) + + assert f_display._display_dims == (1, 1) + assert isinstance(f_display._faces, _Faces) + + assert f_display._centering is None + assert f_display._faces_source.size == 0 + assert f_display._faces_dest.size == 0 + assert f_display._tk_image is None + assert f_display.update_source is False + assert not f_display.source and isinstance(f_display.source, list) + assert not f_display.destination and isinstance(f_display.destination, list) + + @pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS) + def test__total_columns(self, columns: int, face_size: int) -> None: + """ Test :class:`~tools.preview.viewer.FacesDisplay` _total_columns property is correctly + calculated + + Parameters + ---------- + columns: int + The number of columns to display in the viewer + face_size: int + The size of each face image to be displayed in the viewer + """ + f_display = self.get_faces_display_instance(columns, face_size) + f_display.source = [None for _ in range(columns)] # type:ignore + assert f_display._total_columns == columns + + def test_set_centering(self) -> None: + """ Test :class:`~tools.preview.viewer.FacesDisplay` set_centering method """ + f_display = self.get_faces_display_instance() + assert f_display._centering is None + centering: "CenteringType" = "legacy" + f_display.set_centering(centering) + assert f_display._centering == centering + + def test_set_display_dimensions(self) -> None: + """ Test :class:`~tools.preview.viewer.FacesDisplay` set_display_dimensions method """ + f_display = self.get_faces_display_instance() + assert f_display._display_dims == (1, 1) + dimensions = (800, 600) + f_display.set_display_dimensions(dimensions) + assert f_display._display_dims == dimensions + + @pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS) + def test_update_tk_image(self, + columns: int, + face_size: int, + mocker: pytest_mock.MockerFixture) -> None: + """ Test :class:`~tools.preview.viewer.FacesDisplay` update_tk_image method + + Parameters + ---------- + columns: int + The number of columns to display in the viewer + face_size: int + The size of each face image to be displayed in the viewer + mocker: :class:`pytest_mock.MockerFixture` + Mocker for checking _build_faces_image method called + """ + f_display = self.get_faces_display_instance(columns, face_size) + f_display._build_faces_image = cast(MagicMock, mocker.MagicMock()) # type:ignore + f_display._get_scale_size = cast(MagicMock, # type:ignore + mocker.MagicMock(return_value=(128, 128))) + f_display._faces_source = np.zeros((face_size, face_size, 3), dtype=np.uint8) + f_display._faces_dest = np.zeros((face_size, face_size, 3), dtype=np.uint8) + + tk.Tk() # tkinter instance needed for image creation + f_display.update_tk_image() + + f_display._build_faces_image.assert_called_once() + f_display._get_scale_size.assert_called_once() + assert isinstance(f_display._tk_image, ImageTk.PhotoImage) + assert f_display._tk_image.width() == 128 + assert f_display._tk_image.height() == 128 + assert f_display.tk_image == f_display._tk_image # public property test + + @pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS) + def test_get_scale_size(self, columns: int, face_size: int) -> None: + """ Test :class:`~tools.preview.viewer.FacesDisplay` get_scale_size method + + Parameters + ---------- + columns: int + The number of columns to display in the viewer + face_size: int + The size of each face image to be displayed in the viewer + """ + f_display = self.get_faces_display_instance(columns, face_size) + f_display.set_display_dimensions((800, 600)) + + img = np.zeros((face_size, face_size, 3), dtype=np.uint8) + size = f_display._get_scale_size(img) + assert size == (600, 600) + + @pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS) + def test__build_faces_image(self, + columns: int, + face_size: int, + mocker: pytest_mock.MockerFixture) -> None: + """ Test :class:`~tools.preview.viewer.FacesDisplay` _build_faces_image method + + Parameters + ---------- + columns: int + The number of columns to display in the viewer + face_size: int + The size of each face image to be displayed in the viewer + mocker: :class:`pytest_mock.MockerFixture` + Mocker for checking internal methods called + """ + header_size = 32 + + f_display = self.get_faces_display_instance(columns, face_size) + f_display._faces_from_frames = cast(MagicMock, mocker.MagicMock()) # type:ignore + f_display._header_text = cast( # type:ignore + MagicMock, + mocker.MagicMock(return_value=np.random.rand(header_size, face_size * columns, 3))) + f_display._draw_rect = cast(MagicMock, # type:ignore + mocker.MagicMock(side_effect=lambda x: x)) + + # Test full update + f_display.update_source = True + f_display._build_faces_image() + + f_display._faces_from_frames.assert_called_once() + f_display._header_text.assert_called_once() + assert f_display._draw_rect.call_count == columns * 2 # src + dst + assert f_display._faces_source.shape == (face_size + header_size, face_size * columns, 3) + assert f_display._faces_dest.shape == (face_size, face_size * columns, 3) + + f_display._faces_from_frames.reset_mock() + f_display._header_text.reset_mock() + f_display._draw_rect.reset_mock() + + # Test dst update only + f_display.update_source = False + f_display._build_faces_image() + + f_display._faces_from_frames.assert_called_once() + assert not f_display._header_text.called + assert f_display._draw_rect.call_count == columns # dst only + assert f_display._faces_dest.shape == (face_size, face_size * columns, 3) + + @pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS) + def test_faces__from_frames(self, + columns, + face_size, + mocker: pytest_mock.MockerFixture) -> None: + """ Test :class:`~tools.preview.viewer.FacesDisplay` _from_frames method + + Parameters + ---------- + columns: int + The number of columns to display in the viewer + face_size: int + The size of each face image to be displayed in the viewer + mocker: :class:`pytest_mock.MockerFixture` + Mocker for checking _build_faces_image method called + """ + f_display = self.get_faces_display_instance(columns, face_size) + f_display.source = [mocker.MagicMock() for _ in range(3)] + f_display.destination = [np.random.rand(face_size, face_size, 3) for _ in range(3)] + f_display._crop_source_faces = cast(MagicMock, mocker.MagicMock()) # type:ignore + f_display._crop_destination_faces = cast(MagicMock, mocker.MagicMock()) # type:ignore + + # Both src + dst + f_display.update_source = True + f_display._faces_from_frames() + f_display._crop_source_faces.assert_called_once() + f_display._crop_destination_faces.assert_called_once() + + f_display._crop_source_faces.reset_mock() + f_display._crop_destination_faces.reset_mock() + + # Just dst + f_display.update_source = False + f_display._faces_from_frames() + assert not f_display._crop_source_faces.called + f_display._crop_destination_faces.assert_called_once() + + @pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS) + def test__crop_source_faces(self, + columns: int, + face_size: int, + monkeypatch: pytest.MonkeyPatch, + mocker: pytest_mock.MockerFixture) -> None: + """ Test :class:`~tools.preview.viewer.FacesDisplay` _crop_source_faces method + + Parameters + ---------- + columns: int + The number of columns to display in the viewer + face_size: int + The size of each face image to be displayed in the viewer + monkeypatch: :class:`pytest.MonkeyPatch` + For patching the transform_image function + mocker: :class:`pytest_mock.MockerFixture` + Mocker for mocking various internal methods + """ + f_display = self.get_faces_display_instance(columns, face_size) + f_display._centering = "face" + f_display.update_source = True + f_display._faces.src = [] + + transform_image_mock = mocker.MagicMock() + monkeypatch.setattr("tools.preview.viewer.transform_image", transform_image_mock) + + f_display.source = [mocker.MagicMock() for _ in range(columns)] + for idx, mock in enumerate(f_display.source): + assert isinstance(mock, MagicMock) + mock.inbound.detected_faces.__getitem__ = lambda self, x, y=mock: y + mock.aligned.matrix = f"test_matrix_{idx}" + mock.inbound.filename = f"test_filename_{idx}.txt" + + f_display._crop_source_faces() + + assert len(f_display._faces.filenames) == columns + assert len(f_display._faces.matrix) == columns + assert len(f_display._faces.src) == columns + assert not f_display.update_source + assert transform_image_mock.call_count == columns + + for idx in range(columns): + assert f_display._faces.filenames[idx] == f"test_filename_{idx}" + assert f_display._faces.matrix[idx] == f"test_matrix_{idx}" + + @pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS) + def test__crop_destination_faces(self, + columns: int, + face_size: int, + mocker: pytest_mock.MockerFixture) -> None: + """ Test :class:`~tools.preview.viewer.FacesDisplay` _crop_destination_faces method + + Parameters + ---------- + columns: int + The number of columns to display in the viewer + face_size: int + The size of each face image to be displayed in the viewer + mocker: :class:`pytest_mock.MockerFixture` + Mocker for dummying in full frames + """ + f_display = self.get_faces_display_instance(columns, face_size) + f_display._centering = "face" + f_display._faces.dst = [] # empty object and test populated correctly + + f_display.source = [mocker.MagicMock() for _ in range(columns)] + for item in f_display.source: # type ignore + item.inbound.image = np.random.rand(1280, 720, 3) # type:ignore + + f_display._crop_destination_faces() + assert len(f_display._faces.dst) == columns + assert all(f.shape == (face_size, face_size, 3) for f in f_display._faces.dst) + + @pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS) + def test__header_text(self, + columns: int, + face_size: int, + mocker: pytest_mock.MockerFixture) -> None: + """ Test :class:`~tools.preview.viewer.FacesDisplay` _header_text method + + Parameters + ---------- + columns: int + The number of columns to display in the viewer + face_size: int + The size of each face image to be displayed in the viewer + mocker: :class:`pytest_mock.MockerFixture` + Mocker for dummying in cv2 calls + """ + f_display = self.get_faces_display_instance(columns, face_size) + f_display.source = [None for _ in range(columns)] # type:ignore + f_display._faces.filenames = [f"filename_{idx}.png" for idx in range(columns)] + + cv2_mock = mocker.patch("tools.preview.viewer.cv2") + text_width, text_height = (100, 32) + cv2_mock.getTextSize.return_value = [(text_width, text_height), ] + + header_box = f_display._header_text() + assert cv2_mock.getTextSize.call_count == columns + assert cv2_mock.putText.call_count == columns + assert header_box.shape == (face_size // 8, face_size * columns, 3) + + @pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS) + def test__draw_rect_text(self, + columns: int, + face_size: int, + mocker: pytest_mock.MockerFixture) -> None: + """ Test :class:`~tools.preview.viewer.FacesDisplay` _draw_rect method + + Parameters + ---------- + columns: int + The number of columns to display in the viewer + face_size: int + The size of each face image to be displayed in the viewer + mocker: :class:`pytest_mock.MockerFixture` + Mocker for dummying in cv2 calls + """ + f_display = self.get_faces_display_instance(columns, face_size) + cv2_mock = mocker.patch("tools.preview.viewer.cv2") + + image = (np.random.rand(face_size, face_size, 3) * 255.0) + 50 + assert image.max() > 255.0 + output = f_display._draw_rect(image) + cv2_mock.rectangle.assert_called_once() + assert output.max() == 255.0 # np.clip + + +class TestImagesCanvas: + """ Test :class:`~tools.preview.viewer.ImagesCanvas` """ + + @pytest.fixture + def parent(self) -> MagicMock: + """ Mock object to act as the parent widget to the ImagesCanvas + + Returns + -------- + :class:`unittest.mock.MagicMock` + The mocked ttk.PanedWindow widget + """ + retval = MagicMock(spec=ttk.PanedWindow) + retval.tk = retval + retval._w = "mock_ttkPanedWindow" + retval.children = {} + retval.call = retval + retval.createcommand = retval + retval.preview_display = MagicMock(spec=FacesDisplay) + return retval + + @pytest.fixture(name="images_canvas_instance") + def images_canvas_fixture(self, parent) -> ImagesCanvas: + """ Fixture for creating a testing :class:`~tools.preview.viewer.ImagesCanvas` instance + + Parameters + ---------- + parent: :class:`unittest.mock.MagicMock` + The mocked ttk.PanedWindow parent + + Returns + ------- + :class:`~tools.preview.viewer.ImagesCanvas` + The class instance for testing + """ + app = MagicMock() + return ImagesCanvas(app, parent) + + def test_init(self, images_canvas_instance: ImagesCanvas, parent: MagicMock) -> None: + """ Test :class:`~tools.preview.viewer.ImagesCanvas` __init__ method + + Parameters + ---------- + images_canvas_instance: :class:`~tools.preview.viewer.ImagesCanvas` + The class instance to test + parent: :class:`unittest.mock.MagicMock` + The mocked parent ttk.PanedWindow + """ + assert images_canvas_instance._display == parent.preview_display + assert isinstance(images_canvas_instance._canvas, tk.Canvas) + assert images_canvas_instance._canvas.master == images_canvas_instance + assert images_canvas_instance._canvas.winfo_ismapped() + + def test_resize(self, + images_canvas_instance: ImagesCanvas, + parent: MagicMock, + mocker: pytest_mock.MockerFixture) -> None: + """ Test :class:`~tools.preview.viewer.ImagesCanvas` resize method + + Parameters + ---------- + images_canvas_instance: :class:`~tools.preview.viewer.ImagesCanvas` + The class instance to test + parent: :class:`unittest.mock.MagicMock` + The mocked parent ttk.PanedWindow + mocker: :class:`pytest_mock.MockerFixture` + Mocker for dummying in tk calls + """ + event_mock = mocker.MagicMock(spec=tk.Event, width=100, height=200) + images_canvas_instance.reload = cast(MagicMock, mocker.MagicMock()) # type:ignore + + images_canvas_instance._resize(event_mock) + + parent.preview_display.set_display_dimensions.assert_called_once_with((100, 200)) + images_canvas_instance.reload.assert_called_once() + + def test_reload(self, + images_canvas_instance: ImagesCanvas, + parent: MagicMock, + mocker: pytest_mock.MockerFixture) -> None: + """ Test :class:`~tools.preview.viewer.ImagesCanvas` reload method + + Parameters + ---------- + images_canvas_instance: :class:`~tools.preview.viewer.ImagesCanvas` + The class instance to test + parent: :class:`unittest.mock.MagicMock` + The mocked parent ttk.PanedWindow + mocker: :class:`pytest_mock.MockerFixture` + Mocker for dummying in tk calls + """ + itemconfig_mock = mocker.patch.object(tk.Canvas, "itemconfig") + + images_canvas_instance.reload() + + parent.preview_display.update_tk_image.assert_called_once() + itemconfig_mock.assert_called_once() diff --git a/tools/preview/cli.py b/tools/preview/cli.py index 3ee985d30d..7c324751f0 100644 --- a/tools/preview/cli.py +++ b/tools/preview/cli.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ import gettext +from typing import Any, List, Dict from lib.cli.args import FaceSwapArgs from lib.cli.actions import DirOrFileFullPaths, DirFullPaths, FileFullPaths @@ -17,13 +18,26 @@ class PreviewArgs(FaceSwapArgs): """ Class to parse the command line arguments for Preview (Convert Settings) tool """ @staticmethod - def get_info(): - """ Return command information """ + def get_info() -> str: + """ Return command information + + Returns + ------- + str + Top line information about the Preview tool + """ return _("Preview tool\nAllows you to configure your convert settings with a live preview") - def get_argument_list(self): + @staticmethod + def get_argument_list() -> List[Dict[str, Any]]: + """ Put the arguments in a list so that they are accessible from both argparse and gui - argument_list = list() + Returns + ------- + list[dict[str, Any]] + Top command line options for the preview tool + """ + argument_list = [] argument_list.append(dict( opts=("-i", "--input-dir"), action=DirOrFileFullPaths, diff --git a/tools/preview/control_panels.py b/tools/preview/control_panels.py new file mode 100644 index 0000000000..9b811d2945 --- /dev/null +++ b/tools/preview/control_panels.py @@ -0,0 +1,667 @@ +#!/usr/bin/env python3 +""" Manages the widgets that hold the bottom 'control' area of the preview tool """ +import gettext +import logging +import tkinter as tk + +from tkinter import ttk +from configparser import ConfigParser +from typing import Any, Callable, cast, Dict, List, Optional, TYPE_CHECKING, Union + +from lib.gui.custom_widgets import Tooltip +from lib.gui.control_helper import ControlPanel, ControlPanelOption +from lib.gui.utils import get_images +from plugins.plugin_loader import PluginLoader +from plugins.convert._config import Config + +if TYPE_CHECKING: + from .preview import Preview + +logger = logging.getLogger(__name__) + +# LOCALES +_LANG = gettext.translation("tools.preview", localedir="locales", fallback=True) +_ = _LANG.gettext + + +class ConfigTools(): + """ Tools for loading, saving, setting and retrieving configuration file values. + + Attributes + ---------- + tk_vars: dict + Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` + """ + def __init__(self) -> None: + self._config = Config(None) + self.tk_vars: Dict[str, Dict[str, Union[tk.BooleanVar, + tk.StringVar, + tk.IntVar, + tk.DoubleVar]]] = {} + self._config_dicts = self._get_config_dicts() # Holds currently saved config + + @property + def config(self) -> Config: + """ :class:`plugins.convert._config.Config` The convert configuration """ + return self._config + + @property + def config_dicts(self) -> Dict[str, Any]: + """ dict: The convert configuration options in dictionary form.""" + return self._config_dicts + + @property + def sections(self) -> List[str]: + """ list: The sorted section names that exist within the convert Configuration options. """ + return sorted(set(plugin.split(".")[0] for plugin in self._config.config.sections() + if plugin.split(".")[0] != "writer")) + + @property + def plugins_dict(self) -> Dict[str, List[str]]: + """ dict: Dictionary of configuration option sections as key with a list of containing + plugins as the value """ + return {section: sorted([plugin.split(".")[1] for plugin in self._config.config.sections() + if plugin.split(".")[0] == section]) + for section in self.sections} + + def update_config(self) -> None: + """ Update :attr:`config` with the currently selected values from the GUI. """ + for section, items in self.tk_vars.items(): + for item, value in items.items(): + try: + new_value = str(value.get()) + except tk.TclError as err: + # When manually filling in text fields, blank values will + # raise an error on numeric data types so return 0 + logger.debug("Error getting value. Defaulting to 0. Error: %s", str(err)) + new_value = str(0) + old_value = self._config.config[section][item] + if new_value != old_value: + logger.trace("Updating config: %s, %s from %s to %s", # type: ignore + section, item, old_value, new_value) + self._config.config[section][item] = new_value + + def _get_config_dicts(self) -> Dict[str, Dict[str, Any]]: + """ Obtain a custom configuration dictionary for convert configuration items in use + by the preview tool formatted for control helper. + + Returns + ------- + dict + Each configuration section as keys, with the values as a dict of option: + :class:`lib.gui.control_helper.ControlOption` pairs. """ + logger.debug("Formatting Config for GUI") + config_dicts: Dict[str, Dict[str, Any]] = {} + for section in self._config.config.sections(): + if section.startswith("writer."): + continue + for key, val in self._config.defaults[section].items(): + if key == "helptext": + config_dicts.setdefault(section, {})[key] = val + continue + cp_option = ControlPanelOption(title=key, + dtype=val["type"], + group=val["group"], + default=val["default"], + initial_value=self._config.get(section, key), + choices=val["choices"], + is_radio=val["gui_radio"], + rounding=val["rounding"], + min_max=val["min_max"], + helptext=val["helptext"]) + self.tk_vars.setdefault(section, {})[key] = cp_option.tk_var + config_dicts.setdefault(section, {})[key] = cp_option + logger.debug("Formatted Config for GUI: %s", config_dicts) + return config_dicts + + def reset_config_to_saved(self, section: Optional[str] = None) -> None: + """ Reset the GUI parameters to their saved values within the configuration file. + + Parameters + ---------- + section: str, optional + The configuration section to reset the values for, If ``None`` provided then all + sections are reset. Default: ``None`` + """ + logger.debug("Resetting to saved config: %s", section) + sections = [section] if section is not None else list(self.tk_vars.keys()) + for config_section in sections: + for item, options in self._config_dicts[config_section].items(): + if item == "helptext": + continue + val = options.value + if val != self.tk_vars[config_section][item].get(): + self.tk_vars[config_section][item].set(val) + logger.debug("Setting %s - %s to saved value %s", config_section, item, val) + logger.debug("Reset to saved config: %s", section) + + def reset_config_to_default(self, section: Optional[str] = None) -> None: + """ Reset the GUI parameters to their default configuration values. + + Parameters + ---------- + section: str, optional + The configuration section to reset the values for, If ``None`` provided then all + sections are reset. Default: ``None`` + """ + logger.debug("Resetting to default: %s", section) + sections = [section] if section is not None else list(self.tk_vars.keys()) + for config_section in sections: + for item, options in self._config_dicts[config_section].items(): + if item == "helptext": + continue + default = options.default + if default != self.tk_vars[config_section][item].get(): + self.tk_vars[config_section][item].set(default) + logger.debug("Setting %s - %s to default value %s", + config_section, item, default) + logger.debug("Reset to default: %s", section) + + def save_config(self, section: Optional[str] = None) -> None: + """ Save the configuration ``.ini`` file with the currently stored values. + + Notes + ----- + We cannot edit the existing saved config as comments tend to get removed, so we create + a new config and populate that. + + Parameters + ---------- + section: str, optional + The configuration section to save, If ``None`` provided then all sections are saved. + Default: ``None`` + """ + logger.debug("Saving %s config", section) + + new_config = ConfigParser(allow_no_value=True) + + for config_section, items in self._config.defaults.items(): + logger.debug("Adding section: '%s')", config_section) + self._config.insert_config_section(config_section, + items["helptext"], + config=new_config) + for item, options in items.items(): + if item == "helptext": + continue # helptext already written at top + if ((section is not None and config_section != section) + or config_section not in self.tk_vars): + # retain saved values that have not been updated + new_opt = self._config.get(config_section, item) + logger.debug("Retaining option: (item: '%s', value: '%s')", item, new_opt) + else: + new_opt = self.tk_vars[config_section][item].get() + logger.debug("Setting option: (item: '%s', value: '%s')", item, new_opt) + + # Set config_dicts value to new saved value + self._config_dicts[config_section][item].set_initial_value(new_opt) + + helptext = self._config.format_help(options["helptext"], is_section=False) + new_config.set(config_section, helptext) + new_config.set(config_section, item, str(new_opt)) + + self._config.config = new_config + self._config.save_config() + logger.info("Saved config: '%s'", self._config.configfile) + + +class BusyProgressBar(): + """ An infinite progress bar for when a thread is running to swap/patch a group of samples """ + def __init__(self, parent: ttk.Frame) -> None: + self._progress_bar = self._add_busy_indicator(parent) + + def _add_busy_indicator(self, parent: ttk.Frame) -> ttk.Progressbar: + """ Place progress bar into bottom bar to indicate when processing. + + Parameters + ---------- + parent: tkinter object + The tkinter object that holds the busy indicator + + Returns + ------- + ttk.Progressbar + A Progress bar to indicate that the Preview tool is busy + """ + logger.debug("Placing busy indicator") + pbar = ttk.Progressbar(parent, mode="indeterminate") + pbar.pack(side=tk.LEFT) + pbar.pack_forget() + return pbar + + def stop(self) -> None: + """ Stop and hide progress bar """ + logger.debug("Stopping busy indicator") + if not self._progress_bar.winfo_ismapped(): + logger.debug("busy indicator already hidden") + return + self._progress_bar.stop() + self._progress_bar.pack_forget() + + def start(self) -> None: + """ Start and display progress bar """ + logger.debug("Starting busy indicator") + if self._progress_bar.winfo_ismapped(): + logger.debug("busy indicator already started") + return + + self._progress_bar.pack(side=tk.LEFT, padx=5, pady=(5, 10), fill=tk.X, expand=True) + self._progress_bar.start(25) + + +class ActionFrame(ttk.Frame): # pylint: disable=too-many-ancestors + """ Frame that holds the left hand side options panel containing the command line options. + + Parameters + ---------- + app: :class:`Preview` + The main tkinter Preview app + parent: tkinter object + The parent tkinter object that holds the Action Frame + """ + def __init__(self, app: 'Preview', parent: ttk.Frame) -> None: + logger.debug("Initializing %s: (app: %s, parent: %s)", + self.__class__.__name__, app, parent) + self._app = app + + super().__init__(parent) + self.pack(side=tk.LEFT, anchor=tk.N, fill=tk.Y) + self._tk_vars: Dict[str, tk.StringVar] = {} + + self._options = dict( + color=app._patch.converter.cli_arguments.color_adjustment.replace("-", "_"), + mask_type=app._patch.converter.cli_arguments.mask_type.replace("-", "_")) + defaults = {opt: self._format_to_display(val) + for opt, val in self._options.items()} + self._busy_bar = self._build_frame(defaults, + app._samples.generate, + app._refresh, + app._samples.available_masks, + app._samples.predictor.has_predicted_mask) + + @property + def convert_args(self) -> Dict[str, Any]: + """ dict: Currently selected Command line arguments from the :class:`ActionFrame`. """ + return {opt if opt != "color" else "color_adjustment": + self._format_from_display(self._tk_vars[opt].get()) + for opt in self._options} + + @property + def busy_progress_bar(self) -> BusyProgressBar: + """ :class:`BusyProgressBar`: The progress bar that appears on the left hand side whilst a + swap/patch is being applied """ + return self._busy_bar + + @staticmethod + def _format_from_display(var: str) -> str: + """ Format a variable from the display version to the command line action version. + + Parameters + ---------- + var: str + The variable name to format + + Returns + ------- + str + The formatted variable name + """ + return var.replace(" ", "_").lower() + + @staticmethod + def _format_to_display(var: str) -> str: + """ Format a variable from the command line action version to the display version. + Parameters + ---------- + var: str + The variable name to format + + Returns + ------- + str + The formatted variable name + """ + return var.replace("_", " ").replace("-", " ").title() + + def _build_frame(self, + defaults: Dict[str, Any], + refresh_callback: Callable[[], None], + patch_callback: Callable[[], None], + available_masks: List[str], + has_predicted_mask: bool) -> BusyProgressBar: + """ Build the :class:`ActionFrame`. + + Parameters + ---------- + defaults: dict + The default command line options + patch_callback: python function + The function to execute when a patch callback is received + refresh_callback: python function + The function to execute when a refresh callback is received + available_masks: list + The available masks that exist within the alignments file + has_predicted_mask: bool + Whether the model was trained with a mask + + Returns + ------- + ttk.Progressbar + A Progress bar to indicate that the Preview tool is busy + """ + logger.debug("Building Action frame") + + bottom_frame = ttk.Frame(self) + bottom_frame.pack(side=tk.BOTTOM, fill=tk.X, anchor=tk.S) + top_frame = ttk.Frame(self) + top_frame.pack(side=tk.TOP, fill=tk.BOTH, anchor=tk.N, expand=True) + + self._add_cli_choices(top_frame, defaults, available_masks, has_predicted_mask) + + busy_indicator = BusyProgressBar(bottom_frame) + self._add_refresh_button(bottom_frame, refresh_callback) + self._add_patch_callback(patch_callback) + self._add_actions(bottom_frame) + logger.debug("Built Action frame") + return busy_indicator + + def _add_cli_choices(self, + parent: ttk.Frame, + defaults: Dict[str, Any], + available_masks: List[str], + has_predicted_mask: bool) -> None: + """ Create :class:`lib.gui.control_helper.ControlPanel` object for the command + line options. + + parent: :class:`ttk.Frame` + The frame to hold the command line choices + defaults: dict + The default command line options + available_masks: list + The available masks that exist within the alignments file + has_predicted_mask: bool + Whether the model was trained with a mask + """ + cp_options = self._get_control_panel_options(defaults, available_masks, has_predicted_mask) + panel_kwargs = dict(blank_nones=False, label_width=10, style="CPanel") + ControlPanel(parent, cp_options, header_text=None, **panel_kwargs) + + def _get_control_panel_options(self, + defaults: Dict[str, Any], + available_masks: List[str], + has_predicted_mask: bool) -> List[ControlPanelOption]: + """ Create :class:`lib.gui.control_helper.ControlPanelOption` objects for the command + line options. + + defaults: dict + The default command line options + available_masks: list + The available masks that exist within the alignments file + has_predicted_mask: bool + Whether the model was trained with a mask + + Returns + ------- + list + The list of `lib.gui.control_helper.ControlPanelOption` objects for the Action Frame + """ + cp_options: List[ControlPanelOption] = [] + for opt in self._options: + if opt == "mask_type": + choices = self._create_mask_choices(defaults, available_masks, has_predicted_mask) + else: + choices = PluginLoader.get_available_convert_plugins(opt, True) + cp_option = ControlPanelOption(title=opt, + dtype=str, + default=defaults[opt], + initial_value=defaults[opt], + choices=choices, + group="Command Line Choices", + is_radio=False) + self._tk_vars[opt] = cp_option.tk_var + cp_options.append(cp_option) + return cp_options + + def _create_mask_choices(self, + defaults: Dict[str, Any], + available_masks: List[str], + has_predicted_mask: bool) -> List[str]: + """ Set the mask choices and default mask based on available masks. + + Parameters + ---------- + defaults: dict + The default command line options + available_masks: list + The available masks that exist within the alignments file + has_predicted_mask: bool + Whether the model was trained with a mask + + Returns + ------- + list + The masks that are available to use from the alignments file + """ + logger.debug("Initial mask choices: %s", available_masks) + if has_predicted_mask: + available_masks += ["predicted"] + if "none" not in available_masks: + available_masks += ["none"] + if self._format_from_display(defaults["mask_type"]) not in available_masks: + logger.debug("Setting default mask to first available: %s", available_masks[0]) + defaults["mask_type"] = available_masks[0] + logger.debug("Final mask choices: %s", available_masks) + return available_masks + + @classmethod + def _add_refresh_button(cls, + parent: ttk.Frame, + refresh_callback: Callable[[], None]) -> None: + """ Add a button to refresh the images. + + Parameters + ---------- + refresh_callback: python function + The function to execute when the refresh button is pressed + """ + btn = ttk.Button(parent, text="Update Samples", command=refresh_callback) + btn.pack(padx=5, pady=5, side=tk.TOP, fill=tk.X, anchor=tk.N) + + def _add_patch_callback(self, patch_callback: Callable[[], None]) -> None: + """ Add callback to re-patch images on action option change. + + Parameters + ---------- + patch_callback: python function + The function to execute when the images require patching + """ + for tk_var in self._tk_vars.values(): + tk_var.trace("w", patch_callback) + + def _add_actions(self, parent: ttk.Frame) -> None: + """ Add Action Buttons to the :class:`ActionFrame` + + Parameters + ---------- + parent: tkinter object + The tkinter object that holds the action buttons + """ + logger.debug("Adding util buttons") + frame = ttk.Frame(parent) + frame.pack(padx=5, pady=(5, 10), side=tk.RIGHT, fill=tk.X, anchor=tk.E) + + for utl in ("save", "clear", "reload"): + logger.debug("Adding button: '%s'", utl) + img = get_images().icons[utl] + if utl == "save": + text = _("Save full config") + action = self._app.config_tools.save_config + elif utl == "clear": + text = _("Reset full config to default values") + action = self._app.config_tools.reset_config_to_default + elif utl == "reload": + text = _("Reset full config to saved values") + action = self._app.config_tools.reset_config_to_saved + + btnutl = ttk.Button(frame, + image=img, + command=action) + btnutl.pack(padx=2, side=tk.RIGHT) + Tooltip(btnutl, text=text, wrap_length=200) + logger.debug("Added util buttons") + + +class OptionsBook(ttk.Notebook): # pylint:disable=too-many-ancestors + """ The notebook that holds the Convert configuration options. + + Parameters + ---------- + parent: tkinter object + The parent tkinter object that holds the Options book + config_tools: :class:`ConfigTools` + Tools for loading and saving configuration files + patch_callback: python function + The function to execute when a patch callback is received + + Attributes + ---------- + config_tools: :class:`ConfigTools` + Tools for loading and saving configuration files + """ + def __init__(self, + parent: ttk.Frame, + config_tools: ConfigTools, + patch_callback: Callable[[], None]) -> None: + logger.debug("Initializing %s: (parent: %s, config: %s)", + self.__class__.__name__, parent, config_tools) + super().__init__(parent) + self.pack(side=tk.RIGHT, anchor=tk.N, fill=tk.BOTH, expand=True) + self.config_tools = config_tools + + self._tabs: Dict[str, Dict[str, Union[ttk.Notebook, ConfigFrame]]] = {} + self._build_tabs() + self._build_sub_tabs() + self._add_patch_callback(patch_callback) + logger.debug("Initialized %s", self.__class__.__name__) + + def _build_tabs(self) -> None: + """ Build the notebook tabs for the each configuration section. """ + logger.debug("Build Tabs") + for section in self.config_tools.sections: + tab = ttk.Notebook(self) + self._tabs[section] = {"tab": tab} + self.add(tab, text=section.replace("_", " ").title()) + + def _build_sub_tabs(self) -> None: + """ Build the notebook sub tabs for each convert section's plugin. """ + for section, plugins in self.config_tools.plugins_dict.items(): + for plugin in plugins: + config_key = ".".join((section, plugin)) + config_dict = self.config_tools.config_dicts[config_key] + tab = ConfigFrame(self, config_key, config_dict) + self._tabs[section][plugin] = tab + text = plugin.replace("_", " ").title() + cast(ttk.Notebook, self._tabs[section]["tab"]).add(tab, text=text) + + def _add_patch_callback(self, patch_callback: Callable[[], None]) -> None: + """ Add callback to re-patch images on configuration option change. + + Parameters + ---------- + patch_callback: python function + The function to execute when the images require patching + """ + for plugins in self.config_tools.tk_vars.values(): + for tk_var in plugins.values(): + tk_var.trace("w", patch_callback) + + +class ConfigFrame(ttk.Frame): # pylint: disable=too-many-ancestors + """ Holds the configuration options for a convert plugin inside the :class:`OptionsBook`. + + Parameters + ---------- + parent: tkinter object + The tkinter object that will hold this configuration frame + config_key: str + The section/plugin key for these configuration options + options: dict + The options for this section/plugin + """ + + def __init__(self, + parent: OptionsBook, + config_key: str, + options: Dict[str, Any]): + logger.debug("Initializing %s", self.__class__.__name__) + super().__init__(parent) + self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + + self._options = options + + self._action_frame = ttk.Frame(self) + self._action_frame.pack(padx=0, pady=(0, 5), side=tk.BOTTOM, fill=tk.X, anchor=tk.E) + self._add_frame_separator() + + self._build_frame(parent, config_key) + logger.debug("Initialized %s", self.__class__.__name__) + + def _build_frame(self, parent: OptionsBook, config_key: str) -> None: + """ Build the options frame for this command + + Parameters + ---------- + parent: tkinter object + The tkinter object that will hold this configuration frame + config_key: str + The section/plugin key for these configuration options + """ + logger.debug("Add Config Frame") + panel_kwargs = dict(columns=2, option_columns=2, blank_nones=False, style="CPanel") + frame = ttk.Frame(self) + frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + cp_options = [opt for key, opt in self._options.items() if key != "helptext"] + ControlPanel(frame, cp_options, header_text=None, **panel_kwargs) + self._add_actions(parent, config_key) + logger.debug("Added Config Frame") + + def _add_frame_separator(self) -> None: + """ Add a separator between top and bottom frames. """ + logger.debug("Add frame seperator") + sep = ttk.Frame(self._action_frame, height=2, relief=tk.RIDGE) + sep.pack(fill=tk.X, pady=5, side=tk.TOP) + logger.debug("Added frame seperator") + + def _add_actions(self, parent: OptionsBook, config_key: str) -> None: + """ Add Action Buttons. + + Parameters + ---------- + parent: tkinter object + The tkinter object that will hold this configuration frame + config_key: str + The section/plugin key for these configuration options + """ + logger.debug("Adding util buttons") + + title = config_key.split(".")[1].replace("_", " ").title() + btn_frame = ttk.Frame(self._action_frame) + btn_frame.pack(padx=5, side=tk.BOTTOM, fill=tk.X) + for utl in ("save", "clear", "reload"): + logger.debug("Adding button: '%s'", utl) + img = get_images().icons[utl] + if utl == "save": + text = _(f"Save {title} config") + action = parent.config_tools.save_config + elif utl == "clear": + text = _(f"Reset {title} config to default values") + action = parent.config_tools.reset_config_to_default + elif utl == "reload": + text = _(f"Reset {title} config to saved values") + action = parent.config_tools.reset_config_to_saved + + btnutl = ttk.Button(btn_frame, + image=img, + command=lambda cmd=action: cmd(config_key)) # type: ignore + btnutl.pack(padx=2, side=tk.RIGHT) + Tooltip(btnutl, text=text, wrap_length=200) + logger.debug("Added util buttons") diff --git a/tools/preview/preview.py b/tools/preview/preview.py index 712b08ad3c..a5e83df556 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -1,38 +1,35 @@ #!/usr/bin/env python3 """ Tool to preview swaps and tweak configuration prior to running a convert """ -from dataclasses import dataclass, field import gettext import logging import random import tkinter as tk from tkinter import ttk -from typing import Any, Callable, cast, Dict, List, Optional, Tuple, TYPE_CHECKING, Union +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING, Union import os import sys -from configparser import ConfigParser + from threading import Event, Lock, Thread -import cv2 import numpy as np -from PIL import Image, ImageTk -from lib.align import DetectedFace, transform_image +from lib.align import DetectedFace from lib.cli.args import ConvertArgs from lib.gui.utils import get_images, get_config, initialize_config, initialize_images -from lib.gui.custom_widgets import Tooltip -from lib.gui.control_helper import ControlPanel, ControlPanelOption from lib.convert import Converter from lib.utils import FaceswapError from lib.queue_manager import queue_manager from scripts.fsmedia import Alignments, Images from scripts.convert import Predict, ConvertItem -from plugins.plugin_loader import PluginLoader -from plugins.convert._config import Config from plugins.extract.pipeline import ExtractMedia +from .control_panels import ActionFrame, ConfigTools, OptionsBook +from .viewer import FacesDisplay, ImagesCanvas + + if sys.version_info < (3, 8): from typing_extensions import Literal else: @@ -40,8 +37,8 @@ if TYPE_CHECKING: from argparse import Namespace - from lib.align.aligned_face import CenteringType from lib.queue_manager import EventQueue + from .control_panels import BusyProgressBar logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -70,23 +67,10 @@ def __init__(self, arguments: "Namespace") -> None: super().__init__() self._config_tools = ConfigTools() self._lock = Lock() - - self._tk_vars: Dict[Literal["refresh", "busy"], - tk.BooleanVar] = dict(refresh=tk.BooleanVar(), busy=tk.BooleanVar()) - for val in self._tk_vars.values(): - val.set(False) - self._display = FacesDisplay(256, 64, self._tk_vars) - - trigger_patch = Event() - self._samples = Samples(arguments, 5, self._display, self._lock, trigger_patch) - self._patch = Patch(arguments, - self._available_masks, - self._samples, - self._display, - self._lock, - trigger_patch, - self._config_tools, - self._tk_vars) + self._dispatcher = Dispatcher(self) + self._display = FacesDisplay(self, 256, 64) + self._samples = Samples(self, arguments, 5) + self._patch = Patch(self, arguments) self._initialize_tkinter() self._image_canvas: Optional[ImagesCanvas] = None @@ -95,12 +79,41 @@ def __init__(self, arguments: "Namespace") -> None: logger.debug("Initialized %s", self.__class__.__name__) @property - def _available_masks(self) -> List[str]: - """ list: The mask names that are available for every face in the alignments file """ - retval = [key - for key, val in self._samples.alignments.mask_summary.items() - if val == self._samples.alignments.faces_count] - return retval + def config_tools(self) -> "ConfigTools": + """ :class:`ConfigTools`: The object responsible for parsing configuration options and + updating to/from the GUI """ + return self._config_tools + + @property + def dispatcher(self) -> "Dispatcher": + """ :class:`Dispatcher`: The object responsible for triggering events and variables and + handling global GUI state """ + return self._dispatcher + + @property + def display(self) -> FacesDisplay: + """ :class:`~tools.preview.viewer.FacesDisplay`: The object that holds the sample, + converted and patched faces """ + return self._display + + @property + def lock(self) -> Lock: + """ :class:`threading.Lock`: The threading lock object for the Preview GUI """ + return self._lock + + @property + def progress_bar(self) -> "BusyProgressBar": + """ :class:`~tools.preview.control_panels.BusyProgressBar`: The progress bar that indicates + a swap/patch thread is running """ + assert self._cli_frame is not None + return self._cli_frame.busy_progress_bar + + def update_display(self): + """ Update the images in the canvas and redraw """ + if not hasattr(self, "_image_canvas"): # On first call object not yet created + return + assert self._image_canvas is not None + self._image_canvas.reload() def _initialize_tkinter(self) -> None: """ Initialize a standalone tkinter instance. """ @@ -125,22 +138,22 @@ def process(self) -> None: self.mainloop() def _refresh(self, *args) -> None: - """ Load new faces to display in preview. + """ Patch faces with current convert settings. Parameters ---------- *args: tuple Unused, but required for tkinter callback. """ - logger.trace("Refreshing swapped faces. args: %s", args) # type: ignore - self._tk_vars["busy"].set(True) + logger.debug("Patching swapped faces. args: %s", args) + self._dispatcher.set_busy() self._config_tools.update_config() with self._lock: assert self._cli_frame is not None self._patch.converter_arguments = self._cli_frame.convert_args - self._patch.current_config = self._config_tools.config - self._patch.trigger.set() - logger.trace("Refreshed swapped faces") # type: ignore + + self._dispatcher.set_needs_patch() + logger.debug("Patched swapped faces") def _build_ui(self) -> None: """ Build the elements for displaying preview images and options panels. """ @@ -148,20 +161,11 @@ def _build_ui(self) -> None: orient=tk.VERTICAL) container.pack(fill=tk.BOTH, expand=True) setattr(container, "preview_display", self._display) # TODO subclass not setattr - self._image_canvas = ImagesCanvas(container, self._tk_vars) + self._image_canvas = ImagesCanvas(self, container) container.add(self._image_canvas, weight=3) options_frame = ttk.Frame(container) - self._cli_frame = ActionFrame( - options_frame, - self._available_masks, - self._samples.predictor.has_predicted_mask, - self._patch.converter.cli_arguments.color_adjustment.replace("-", "_"), - self._patch.converter.cli_arguments.mask_type.replace("-", "_"), - self._config_tools, - self._refresh, - self._samples.generate, - self._tk_vars) + self._cli_frame = ActionFrame(self, options_frame) self._opts_book = OptionsBook(options_frame, self._config_tools, self._refresh) @@ -170,6 +174,90 @@ def _build_ui(self) -> None: container.sashpos(0, int(400 * get_config().scaling_factor)) +class Dispatcher(): + """ Handles the app level tk.Variables and the threading events. Dispatches events to the + correct location and handles GUI state whilst events are handled + + Parameters + ---------- + app: :class:`Preview` + The main tkinter Preview app + """ + def __init__(self, app: Preview): + logger.debug("Initializing %s: (app: %s)", self.__class__.__name__, app) + self._app = app + self._tk_busy = tk.BooleanVar(value=False) + self._evnt_needs_patch = Event() + self._is_updating = False + self._stacked_event = False + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def needs_patch(self) -> Event: + """:class:`threading.Event`. Set by the parent and cleared by the child. Informs the child + patching thread that a run needs to be processed """ + return self._evnt_needs_patch + + # TKInter Variables + def set_busy(self) -> None: + """ Set the tkinter busy variable to ``True`` and display the busy progress bar """ + if self._tk_busy.get(): + logger.debug("Busy event is already set. Doing nothing") + return + if not hasattr(self._app, "progress_bar"): + logger.debug("Not setting busy during initial startup") + return + + logger.debug("Setting busy event to True") + self._tk_busy.set(True) + self._app.progress_bar.start() + self._app.update_idletasks() + + def _unset_busy(self) -> None: + """ Set the tkinter busy variable to ``False`` and hide the busy progress bar """ + self._is_updating = False + if not self._tk_busy.get(): + logger.debug("busy unset when already unset. Doing nothing") + return + logger.debug("Setting busy event to False") + self._tk_busy.set(False) + self._app.progress_bar.stop() + self._app.update_idletasks() + + # Threading Events + def _wait_for_patch(self) -> None: + """ Wait for a patch thread to complete before triggering a display refresh and unsetting + the busy indicators """ + logger.debug("Checking for patch completion...") + if self._evnt_needs_patch.is_set(): + logger.debug("Samples not patched. Waiting...") + self._app.after(1000, self._wait_for_patch) + return + + logger.debug("Patch completion detected") + self._app.update_display() + self._unset_busy() + + if self._stacked_event: + logger.debug("Processing last stacked event") + self.set_busy() + self._stacked_event = False + self.set_needs_patch() + return + + def set_needs_patch(self) -> None: + """ Sends a trigger to the patching thread that it needs to be run. Waits for the patching + to complete prior to triggering a display refresh and unsetting the busy indicators """ + if self._is_updating: + logger.debug("Request to run patch when it is already running. Adding stacked event.") + self._stacked_event = True + return + self._is_updating = True + logger.debug("Triggering patch") + self._evnt_needs_patch.set() + self._wait_for_patch() + + class Samples(): """ The display samples. @@ -182,31 +270,19 @@ class Samples(): Parameters ---------- + app: :class:`Preview` + The main tkinter Preview app arguments: :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in from :mod:`tools.py` sample_size: int The number of samples to take from the input video/images - display: :class:`FacesDisplay` - The display section of the Preview GUI. - lock: :class:`threading.Lock` - A threading lock to prevent multiple GUI updates at the same time. - trigger_patch: :class:`threading.Event` - An event to indicate that a converter patch should be run """ - def __init__(self, - arguments: "Namespace", - sample_size: int, - display: "FacesDisplay", - lock: Lock, - trigger_patch: Event) -> None: - logger.debug("Initializing %s: (arguments: '%s', sample_size: %s, display: %s, lock: %s, " - "trigger_patch: %s)", self.__class__.__name__, arguments, sample_size, - display, lock, trigger_patch) + def __init__(self, app: Preview, arguments: "Namespace", sample_size: int) -> None: + logger.debug("Initializing %s: (app: %s, arguments: '%s', sample_size: %s)", + self.__class__.__name__, app, arguments, sample_size) self._sample_size = sample_size - self._display = display - self._lock = lock - self._trigger_patch = trigger_patch + self._app = app self._input_images: List[ConvertItem] = [] self._predicted_images: List[Tuple[ConvertItem, np.ndarray]] = [] @@ -228,11 +304,19 @@ def __init__(self, self._predictor = Predict(queue_manager.get_queue("preview_predict_in"), sample_size, arguments) - self._display.set_centering(self._predictor.centering) + self._app._display.set_centering(self._predictor.centering) self.generate() logger.debug("Initialized %s", self.__class__.__name__) + @property + def available_masks(self) -> List[str]: + """ list: The mask names that are available for every face in the alignments file """ + retval = [key + for key, val in self.alignments.mask_summary.items() + if val == self.alignments.faces_count] + return retval + @property def sample_size(self) -> int: """ int: The number of samples to take from the input video/images """ @@ -319,9 +403,12 @@ def generate(self) -> None: Selects :attr:`sample_size` random faces. Runs them through prediction to obtain the swap, then trigger the patch event to run the faces through patching. """ + logger.debug("Generating new random samples") + self._app.dispatcher.set_busy() self._load_frames() self._predict() - self._trigger_patch.set() + self._app.dispatcher.set_needs_patch() + logger.debug("Generated new random samples") def _load_frames(self) -> None: """ Load a sample of random frames. @@ -344,8 +431,8 @@ def _load_frames(self) -> None: detected_face.from_alignment(face, image=image) inbound = ExtractMedia(filename=filename, image=image, detected_faces=[detected_face]) self._input_images.append(ConvertItem(inbound=inbound)) - self._display.source = self._input_images - self._display.update_source = True + self._app.display.source = self._input_images + self._app.display.update_source = True logger.debug("Selected frames: %s", [frame.inbound.filename for frame in self._input_images]) @@ -355,7 +442,7 @@ def _predict(self) -> None: With a threading lock (to prevent stacking), run the selected faces through the Faceswap model predict function and add the output to :attr:`predicted` """ - with self._lock: + with self._app.lock: self._predicted_images = [] for frame in self._input_images: self._predictor.in_queue.put(frame) @@ -375,7 +462,7 @@ def _predict(self) -> None: logger.debug("Predicted faces") -class Patch(): +class Patch(): # pylint:disable=too-few-public-methods """ The Patch pipeline Runs in it's own thread. Takes the output from the Faceswap model predictor and runs the faces @@ -383,79 +470,42 @@ class Patch(): Parameters ---------- + app: :class:`Preview` + The main tkinter Preview app arguments: :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in from :mod:`tools.py` - available_masks: list - The masks that are available for convert - samples: :class:`Samples` - The Samples for display. - display: :class:`FacesDisplay` - The display section of the Preview GUI. - lock: :class:`threading.Lock` - A threading lock to prevent multiple GUI updates at the same time. - trigger: :class:`threading.Event` - An event to indicate that a converter patch should be run - config_tools: :class:`ConfigTools` - Tools for loading and saving configuration files - tk_vars: dict - Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` Attributes ---------- converter_arguments: dict The currently selected converter command line arguments for the patch queue - current_config::class:`lib.config.FaceswapConfig` - The currently set configuration for the patch queue """ - def __init__(self, - arguments: "Namespace", - available_masks: List[str], - samples: Samples, - display: "FacesDisplay", - lock: Lock, - trigger: Event, - config_tools: "ConfigTools", - tk_vars: Dict[Literal["refresh", "busy"], tk.BooleanVar]) -> None: - logger.debug("Initializing %s: (arguments: '%s', available_masks: %s, samples: %s, " - "display: %s, lock: %s, trigger: %s, config_tools: %s, tk_vars %s)", - self.__class__.__name__, arguments, available_masks, samples, display, lock, - trigger, config_tools, tk_vars) - self._samples = samples + def __init__(self, app: Preview, arguments: "Namespace") -> None: + logger.debug("Initializing %s: (app: %s, arguments: '%s')", + self.__class__.__name__, app, arguments) + self._app = app self._queue_patch_in = queue_manager.get_queue("preview_patch_in") - self._display = display - self._lock = lock - self._trigger = trigger - self.current_config = config_tools.config self.converter_arguments: Optional[Dict[str, Any]] = None # Updated converter args dict configfile = arguments.configfile if hasattr(arguments, "configfile") else None - self._converter = Converter(output_size=self._samples.predictor.output_size, - coverage_ratio=self._samples.predictor.coverage_ratio, - centering=self._samples.predictor.centering, + self._converter = Converter(output_size=app._samples.predictor.output_size, + coverage_ratio=app._samples.predictor.coverage_ratio, + centering=app._samples.predictor.centering, draw_transparent=False, pre_encode=None, - arguments=self._generate_converter_arguments(arguments, - available_masks), + arguments=self._generate_converter_arguments( + arguments, + app._samples.available_masks), configfile=configfile) - self._shutdown = Event() - self._thread = Thread(target=self._process, name="patch_thread", - args=(self._trigger, - self._shutdown, - self._queue_patch_in, - self._samples, - tk_vars), + args=(self._queue_patch_in, + self._app.dispatcher.needs_patch, + app._samples), daemon=True) self._thread.start() logger.debug("Initializing %s", self.__class__.__name__) - @property - def trigger(self) -> Event: - """ :class:`threading.Event`: The trigger to indicate that a patching run should - commence. """ - return self._trigger - @property def converter(self) -> Converter: """ :class:`lib.convert.Converter`: The converter to use for patching the images. """ @@ -499,11 +549,9 @@ def _generate_converter_arguments(arguments: "Namespace", return arguments def _process(self, - trigger_event: Event, - shutdown_event: Event, patch_queue_in: "EventQueue", - samples: Samples, - tk_vars: Dict[Literal["refresh", "busy"], tk.BooleanVar]) -> None: + trigger_event: Event, + samples: Samples) -> None: """ The face patching process. Runs in a thread, and waits for an event to be set. Once triggered, runs a patching @@ -511,40 +559,32 @@ def _process(self, Parameters ---------- - trigger_event: :class:`threading.Event` - Set by parent process when a patching run should be executed - shutdown_event :class:`threading.Event` - Set by parent process if a shutdown has been requested patch_queue_in: :class:`~lib.queue_manager.EventQueue` The input queue for the patching process + trigger_event: :class:`threading.Event` + The event that indicates a patching run needs to be processed samples: :class:`Samples` The Samples for display. - tk_vars: dict - Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` """ - logger.debug("Launching patch process thread: (trigger_event: %s, shutdown_event: %s, " - "patch_queue_in: %s, samples: %s, tk_vars: %s)", trigger_event, - shutdown_event, patch_queue_in, samples, tk_vars) + logger.debug("Launching patch process thread: (patch_queue_in: %s, trigger_event: %s, " + "samples: %s)", patch_queue_in, trigger_event, samples) patch_queue_out = queue_manager.get_queue("preview_patch_out") while True: trigger = trigger_event.wait(1) - if shutdown_event.is_set(): - logger.debug("Shutdown received") - break if not trigger: continue - # Clear trigger so calling process can set it during this run - trigger_event.clear() + logger.debug("Patch Triggered") queue_manager.flush_queue("preview_patch_in") self._feed_swapped_faces(patch_queue_in, samples) - with self._lock: + with self._app.lock: self._update_converter_arguments() - self._converter.reinitialize(config=self.current_config) + self._converter.reinitialize(config=self._app.config_tools.config) swapped = self._patch_faces(patch_queue_in, patch_queue_out, samples.sample_size) - with self._lock: - self._display.destination = swapped - tk_vars["refresh"].set(True) - tk_vars["busy"].set(False) + with self._app.lock: + self._app.display.destination = swapped + + logger.debug("Patch complete") + trigger_event.clear() logger.debug("Closed patch process thread") @@ -570,12 +610,12 @@ def _feed_swapped_faces(patch_queue_in: "EventQueue", samples: Samples) -> None: samples: :class:`Samples` The Samples for display. """ - logger.trace("feeding swapped faces to converter") # type: ignore + logger.debug("feeding swapped faces to converter") for item in samples.predicted_images: patch_queue_in.put(item) - logger.trace("fed %s swapped faces to converter", # type: ignore + logger.debug("fed %s swapped faces to converter", len(samples.predicted_images)) - logger.trace("Putting EOF to converter") # type: ignore + logger.debug("Putting EOF to converter") patch_queue_in.put("EOF") def _patch_faces(self, @@ -598,967 +638,15 @@ def _patch_faces(self, list The swapped faces patched with the selected convert settings """ - logger.trace("Patching faces") # type: ignore + logger.debug("Patching faces") self._converter.process(queue_in, queue_out) swapped = [] idx = 0 while idx < sample_size: - logger.trace("Patching image %s of %s", idx + 1, sample_size) # type: ignore + logger.debug("Patching image %s of %s", idx + 1, sample_size) item = queue_out.get() swapped.append(item[1]) - logger.trace("Patched image %s of %s", idx + 1, sample_size) # type: ignore + logger.debug("Patched image %s of %s", idx + 1, sample_size) idx += 1 - logger.trace("Patched faces") # type: ignore + logger.debug("Patched faces") return swapped - - -@dataclass -class _Faces: - """ Dataclass for holding faces """ - filenames: List[str] = field(default_factory=list) - matrix: List[np.ndarray] = field(default_factory=list) - src: List[np.ndarray] = field(default_factory=list) - dst: List[np.ndarray] = field(default_factory=list) - - -class FacesDisplay(): - """ Compiles the 2 rows of sample faces (original and swapped) into a single image - - Parameters - ---------- - size: int - The size of each individual face sample in pixels - padding: int - The amount of extra padding to apply to the outside of the face - tk_vars: dict - Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` - - Attributes - ---------- - update_source: bool - Flag to indicate that the source images for the preview have been updated, so the preview - should be recompiled. - source: list - The list of :class:`numpy.ndarray` source preview images for top row of display - destination: list - The list of :class:`numpy.ndarray` swapped and patched preview images for bottom row of - display - """ - def __init__(self, - size: int, - padding: int, - tk_vars: Dict[Literal["refresh", "busy"], tk.BooleanVar]) -> None: - logger.trace("Initializing %s: (size: %s, padding: %s, tk_vars: %s)", # type: ignore - self.__class__.__name__, size, padding, tk_vars) - self._size = size - self._display_dims = (1, 1) - self._tk_vars = tk_vars - self._padding = padding - - self._faces = _Faces() - self._centering: Optional["CenteringType"] = None - self._faces_source: np.ndarray = np.array([]) - self._faces_dest: np.ndarray = np.array([]) - self._tk_image: Optional[ImageTk.PhotoImage] = None - - # Set from Samples - self.update_source = False - self.source: List[ConvertItem] = [] # Source images, filenames + detected faces - # Set from Patch - self.destination: List[np.ndarray] = [] # Swapped + patched images - - logger.trace("Initialized %s", self.__class__.__name__) # type: ignore - - @property - def tk_image(self) -> Optional[ImageTk.PhotoImage]: - """ :class:`PIL.ImageTk.PhotoImage`: The compiled preview display in tkinter display - format """ - return self._tk_image - - @property - def _total_columns(self) -> int: - """ int: The total number of images that are being displayed """ - return len(self.source) - - def set_centering(self, centering: "CenteringType") -> None: - """ The centering that the model uses is not known at initialization time. - Set :attr:`_centering` when the model has been loaded. - - Parameters - ---------- - centering: str - The centering that the model was trained on - """ - self._centering = centering - - def set_display_dimensions(self, dimensions: Tuple[int, int]) -> None: - """ Adjust the size of the frame that will hold the preview samples. - - Parameters - ---------- - dimensions: tuple - The (`width`, `height`) of the frame that holds the preview - """ - self._display_dims = dimensions - - def update_tk_image(self) -> None: - """ Build the full preview images and compile :attr:`tk_image` for display. """ - logger.trace("Updating tk image") # type: ignore - self._build_faces_image() - img = np.vstack((self._faces_source, self._faces_dest)) - size = self._get_scale_size(img) - img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) - pilimg = Image.fromarray(img) - pilimg = pilimg.resize(size, Image.ANTIALIAS) - self._tk_image = ImageTk.PhotoImage(pilimg) - self._tk_vars["refresh"].set(False) - logger.trace("Updated tk image") # type: ignore - - def _get_scale_size(self, image: np.ndarray) -> Tuple[int, int]: - """ Get the size that the full preview image should be resized to fit in the - display window. - - Parameters - ---------- - image: :class:`numpy.ndarray` - The full sized compiled preview image - - Returns - ------- - tuple - The (`width`, `height`) that the display image should be sized to fit in the display - window - """ - frameratio = float(self._display_dims[0]) / float(self._display_dims[1]) - imgratio = float(image.shape[1]) / float(image.shape[0]) - - if frameratio <= imgratio: - scale = self._display_dims[0] / float(image.shape[1]) - size = (self._display_dims[0], max(1, int(image.shape[0] * scale))) - else: - scale = self._display_dims[1] / float(image.shape[0]) - size = (max(1, int(image.shape[1] * scale)), self._display_dims[1]) - logger.trace("scale: %s, size: %s", scale, size) # type: ignore - return size - - def _build_faces_image(self) -> None: - """ Compile the source and destination rows of the preview image. """ - logger.trace("Building Faces Image") # type: ignore - update_all = self.update_source - self._faces_from_frames() - if update_all: - header = self._header_text() - source = np.hstack([self._draw_rect(face) for face in self._faces.src]) - self._faces_source = np.vstack((header, source)) - self._faces_dest = np.hstack([self._draw_rect(face) for face in self._faces.dst]) - logger.debug("source row shape: %s, swapped row shape: %s", - self._faces_dest.shape, self._faces_source.shape) - - def _faces_from_frames(self) -> None: - """ Extract the preview faces from the source frames and apply the requisite padding. """ - logger.debug("Extracting faces from frames: Number images: %s", len(self.source)) - if self.update_source: - self._crop_source_faces() - self._crop_destination_faces() - logger.debug("Extracted faces from frames: %s", - {k: len(v) for k, v in self._faces.__dict__.items()}) - - def _crop_source_faces(self) -> None: - """ Extract the source faces from the source frames, along with their filenames and the - transformation matrix used to extract the faces. """ - logger.debug("Updating source faces") - self._faces = _Faces() # Init new class - for item in self.source: - detected_face = item.inbound.detected_faces[0] - src_img = item.inbound.image - detected_face.load_aligned(src_img, - size=self._size, - centering=cast("CenteringType", self._centering)) - matrix = detected_face.aligned.matrix - self._faces.filenames.append(os.path.splitext(item.inbound.filename)[0]) - self._faces.matrix.append(matrix) - self._faces.src.append(transform_image(src_img, matrix, self._size, self._padding)) - self.update_source = False - logger.debug("Updated source faces") - - def _crop_destination_faces(self) -> None: - """ Extract the swapped faces from the swapped frames using the source face destination - matrices. """ - logger.debug("Updating destination faces") - self._faces.dst = [] - destination = self.destination if self.destination else [np.ones_like(src.inbound.image) - for src in self.source] - for idx, image in enumerate(destination): - self._faces.dst.append(transform_image(image, - self._faces.matrix[idx], - self._size, - self._padding)) - logger.debug("Updated destination faces") - - def _header_text(self) -> np.ndarray: - """ Create the header text displaying the frame name for each preview column. - - Returns - ------- - :class:`numpy.ndarray` - The header row of the preview image containing the frame names for each column - """ - font_scale = self._size / 640 - height = self._size // 8 - font = cv2.FONT_HERSHEY_SIMPLEX - # Get size of placed text for positioning - text_sizes = [cv2.getTextSize(self._faces.filenames[idx], - font, - font_scale, - 1)[0] - for idx in range(self._total_columns)] - # Get X and Y co-ordinates for each text item - text_y = int((height + text_sizes[0][1]) / 2) - text_x = [int((self._size - text_sizes[idx][0]) / 2) + self._size * idx - for idx in range(self._total_columns)] - logger.debug("filenames: %s, text_sizes: %s, text_x: %s, text_y: %s", - self._faces.filenames, text_sizes, text_x, text_y) - header_box = np.ones((height, self._size * self._total_columns, 3), np.uint8) * 255 - for idx, text in enumerate(self._faces.filenames): - cv2.putText(header_box, - text, - (text_x[idx], text_y), - font, - font_scale, - (0, 0, 0), - 1, - lineType=cv2.LINE_AA) - logger.debug("header_box.shape: %s", header_box.shape) - return header_box - - def _draw_rect(self, image: np.ndarray) -> np.ndarray: - """ Place a white border around a given image. - - Parameters - ---------- - image: :class:`numpy.ndarray` - The image to place a border on to - Returns - ------- - :class:`numpy.ndarray` - The given image with a border drawn around the outside - """ - cv2.rectangle(image, (0, 0), (self._size - 1, self._size - 1), (255, 255, 255), 1) - image = np.clip(image, 0.0, 255.0) - return image.astype("uint8") - - -class ConfigTools(): - """ Tools for loading, saving, setting and retrieving configuration file values. - - Attributes - ---------- - tk_vars: dict - Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` - """ - def __init__(self) -> None: - self._config = Config(None) - self.tk_vars: Dict[str, Dict[str, Union[tk.BooleanVar, - tk.StringVar, - tk.IntVar, - tk.DoubleVar]]] = {} - self._config_dicts = self._get_config_dicts() # Holds currently saved config - - @property - def config(self) -> Config: - """ :class:`plugins.convert._config.Config` The convert configuration """ - return self._config - - @property - def config_dicts(self) -> Dict[str, Any]: - """ dict: The convert configuration options in dictionary form.""" - return self._config_dicts - - @property - def sections(self) -> List[str]: - """ list: The sorted section names that exist within the convert Configuration options. """ - return sorted(set(plugin.split(".")[0] for plugin in self._config.config.sections() - if plugin.split(".")[0] != "writer")) - - @property - def plugins_dict(self) -> Dict[str, List[str]]: - """ dict: Dictionary of configuration option sections as key with a list of containing - plugins as the value """ - return {section: sorted([plugin.split(".")[1] for plugin in self._config.config.sections() - if plugin.split(".")[0] == section]) - for section in self.sections} - - def update_config(self) -> None: - """ Update :attr:`config` with the currently selected values from the GUI. """ - for section, items in self.tk_vars.items(): - for item, value in items.items(): - try: - new_value = str(value.get()) - except tk.TclError as err: - # When manually filling in text fields, blank values will - # raise an error on numeric data types so return 0 - logger.debug("Error getting value. Defaulting to 0. Error: %s", str(err)) - new_value = str(0) - old_value = self._config.config[section][item] - if new_value != old_value: - logger.trace("Updating config: %s, %s from %s to %s", # type: ignore - section, item, old_value, new_value) - self._config.config[section][item] = new_value - - def _get_config_dicts(self) -> Dict[str, Dict[str, Any]]: - """ Obtain a custom configuration dictionary for convert configuration items in use - by the preview tool formatted for control helper. - - Returns - ------- - dict - Each configuration section as keys, with the values as a dict of option: - :class:`lib.gui.control_helper.ControlOption` pairs. """ - logger.debug("Formatting Config for GUI") - config_dicts: Dict[str, Dict[str, Any]] = {} - for section in self._config.config.sections(): - if section.startswith("writer."): - continue - for key, val in self._config.defaults[section].items(): - if key == "helptext": - config_dicts.setdefault(section, {})[key] = val - continue - cp_option = ControlPanelOption(title=key, - dtype=val["type"], - group=val["group"], - default=val["default"], - initial_value=self._config.get(section, key), - choices=val["choices"], - is_radio=val["gui_radio"], - rounding=val["rounding"], - min_max=val["min_max"], - helptext=val["helptext"]) - self.tk_vars.setdefault(section, {})[key] = cp_option.tk_var - config_dicts.setdefault(section, {})[key] = cp_option - logger.debug("Formatted Config for GUI: %s", config_dicts) - return config_dicts - - def reset_config_to_saved(self, section: Optional[str] = None) -> None: - """ Reset the GUI parameters to their saved values within the configuration file. - - Parameters - ---------- - section: str, optional - The configuration section to reset the values for, If ``None`` provided then all - sections are reset. Default: ``None`` - """ - logger.debug("Resetting to saved config: %s", section) - sections = [section] if section is not None else list(self.tk_vars.keys()) - for config_section in sections: - for item, options in self._config_dicts[config_section].items(): - if item == "helptext": - continue - val = options.value - if val != self.tk_vars[config_section][item].get(): - self.tk_vars[config_section][item].set(val) - logger.debug("Setting %s - %s to saved value %s", config_section, item, val) - logger.debug("Reset to saved config: %s", section) - - def reset_config_to_default(self, section: Optional[str] = None) -> None: - """ Reset the GUI parameters to their default configuration values. - - Parameters - ---------- - section: str, optional - The configuration section to reset the values for, If ``None`` provided then all - sections are reset. Default: ``None`` - """ - logger.debug("Resetting to default: %s", section) - sections = [section] if section is not None else list(self.tk_vars.keys()) - for config_section in sections: - for item, options in self._config_dicts[config_section].items(): - if item == "helptext": - continue - default = options.default - if default != self.tk_vars[config_section][item].get(): - self.tk_vars[config_section][item].set(default) - logger.debug("Setting %s - %s to default value %s", - config_section, item, default) - logger.debug("Reset to default: %s", section) - - def save_config(self, section: Optional[str] = None) -> None: - """ Save the configuration ``.ini`` file with the currently stored values. - - Notes - ----- - We cannot edit the existing saved config as comments tend to get removed, so we create - a new config and populate that. - - Parameters - ---------- - section: str, optional - The configuration section to save, If ``None`` provided then all sections are saved. - Default: ``None`` - """ - logger.debug("Saving %s config", section) - - new_config = ConfigParser(allow_no_value=True) - - for config_section, items in self._config.defaults.items(): - logger.debug("Adding section: '%s')", config_section) - self._config.insert_config_section(config_section, - items["helptext"], - config=new_config) - for item, options in items.items(): - if item == "helptext": - continue # helptext already written at top - if ((section is not None and config_section != section) - or config_section not in self.tk_vars): - # retain saved values that have not been updated - new_opt = self._config.get(config_section, item) - logger.debug("Retaining option: (item: '%s', value: '%s')", item, new_opt) - else: - new_opt = self.tk_vars[config_section][item].get() - logger.debug("Setting option: (item: '%s', value: '%s')", item, new_opt) - - # Set config_dicts value to new saved value - self._config_dicts[config_section][item].set_initial_value(new_opt) - - helptext = self._config.format_help(options["helptext"], is_section=False) - new_config.set(config_section, helptext) - new_config.set(config_section, item, str(new_opt)) - - self._config.config = new_config - self._config.save_config() - logger.info("Saved config: '%s'", self._config.configfile) - - -class ImagesCanvas(ttk.Frame): # pylint:disable=too-many-ancestors - """ tkinter Canvas that holds the preview images. - - Parameters - ---------- - parent: tkinter object - The parent tkinter object that holds the canvas - tk_vars: dict - Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` - """ - def __init__(self, - parent: ttk.PanedWindow, - tk_vars: Dict[Literal["refresh", "busy"], tk.BooleanVar]) -> None: - logger.debug("Initializing %s: (parent: %s, tk_vars: %s)", - self.__class__.__name__, parent, tk_vars) - super().__init__(parent) - self.pack(expand=True, fill=tk.BOTH, padx=2, pady=2) - - self._refresh_display_trigger = tk_vars["refresh"] - self._refresh_display_trigger.trace("w", self._refresh_display_callback) - self._display: FacesDisplay = parent.preview_display # type: ignore - self._canvas = tk.Canvas(self, bd=0, highlightthickness=0) - self._canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True) - self._displaycanvas = self._canvas.create_image(0, 0, - image=self._display.tk_image, - anchor=tk.NW) - self.bind("", self._resize) - logger.debug("Initialized %s", self.__class__.__name__) - - def _refresh_display_callback(self, *args) -> None: - """ Add a trace to refresh display on callback """ - if not self._refresh_display_trigger.get(): - return - logger.trace("Refresh display trigger received: %s", args) # type: ignore - self._reload() - - def _resize(self, event: tk.Event) -> None: - """ Resize the image to fit the frame, maintaining aspect ratio """ - logger.trace("Resizing preview image") # type: ignore - framesize = (event.width, event.height) - self._display.set_display_dimensions(framesize) - self._reload() - - def _reload(self) -> None: - """ Reload the preview image """ - logger.trace("Reloading preview image") # type: ignore - self._display.update_tk_image() - self._canvas.itemconfig(self._displaycanvas, image=self._display.tk_image) - - -class ActionFrame(ttk.Frame): # pylint: disable=too-many-ancestors - """ Frame that holds the left hand side options panel containing the command line options. - - Parameters - ---------- - parent: tkinter object - The parent tkinter object that holds the Action Frame - available_masks: list - The available masks that exist within the alignments file - has_predicted_mask: bool - Whether the model was trained with a mask - selected_color: str - The selected color adjustment type - selected_mask_type: str - The selected mask type - config_tools: :class:`ConfigTools` - Tools for loading and saving configuration files - patch_callback: python function - The function to execute when a patch callback is received - refresh_callback: python function - The function to execute when a refresh callback is received - tk_vars: dict - Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` - """ - def __init__(self, - parent: ttk.Frame, - available_masks: List[str], - has_predicted_mask: bool, - selected_color: str, - selected_mask_type: str, - config_tools: ConfigTools, - patch_callback: Callable[[], None], - refresh_callback: Callable[[], None], - tk_vars: Dict[Literal["refresh", "busy"], tk.BooleanVar]) -> None: - logger.debug("Initializing %s: (available_masks: %s, has_predicted_mask: %s, " - "selected_color: %s, selected_mask_type: %s, patch_callback: %s, " - "refresh_callback: %s, tk_vars: %s)", - self.__class__.__name__, available_masks, has_predicted_mask, selected_color, - selected_mask_type, patch_callback, refresh_callback, tk_vars) - self._config_tools = config_tools - - super().__init__(parent) - self.pack(side=tk.LEFT, anchor=tk.N, fill=tk.Y) - self._options = ["color", "mask_type"] - self._busy_tkvar = tk_vars["busy"] - self._tk_vars: Dict[str, tk.StringVar] = {} - - d_locals = locals() - defaults = {opt: self._format_to_display(d_locals[f"selected_{opt}"]) - for opt in self._options} - self._busy_indicator = self._build_frame(defaults, - refresh_callback, - patch_callback, - available_masks, - has_predicted_mask) - - @property - def convert_args(self) -> Dict[str, Any]: - """ dict: Currently selected Command line arguments from the :class:`ActionFrame`. """ - return {opt if opt != "color" else "color_adjustment": - self._format_from_display(self._tk_vars[opt].get()) - for opt in self._options} - - @staticmethod - def _format_from_display(var: str) -> str: - """ Format a variable from the display version to the command line action version. - - Parameters - ---------- - var: str - The variable name to format - - Returns - ------- - str - The formatted variable name - """ - return var.replace(" ", "_").lower() - - @staticmethod - def _format_to_display(var: str) -> str: - """ Format a variable from the command line action version to the display version. - Parameters - ---------- - var: str - The variable name to format - - Returns - ------- - str - The formatted variable name - """ - return var.replace("_", " ").replace("-", " ").title() - - def _build_frame(self, - defaults: Dict[str, Any], - refresh_callback: Callable[[], None], - patch_callback: Callable[[], None], - available_masks: List[str], - has_predicted_mask: bool) -> ttk.Progressbar: - """ Build the :class:`ActionFrame`. - - Parameters - ---------- - defaults: dict - The default command line options - patch_callback: python function - The function to execute when a patch callback is received - refresh_callback: python function - The function to execute when a refresh callback is received - available_masks: list - The available masks that exist within the alignments file - has_predicted_mask: bool - Whether the model was trained with a mask - - Returns - ------- - ttk.Progressbar - A Progress bar to indicate that the Preview tool is busy - """ - logger.debug("Building Action frame") - - bottom_frame = ttk.Frame(self) - bottom_frame.pack(side=tk.BOTTOM, fill=tk.X, anchor=tk.S) - top_frame = ttk.Frame(self) - top_frame.pack(side=tk.TOP, fill=tk.BOTH, anchor=tk.N, expand=True) - - self._add_cli_choices(top_frame, defaults, available_masks, has_predicted_mask) - - busy_indicator = self._add_busy_indicator(bottom_frame) - self._add_refresh_button(bottom_frame, refresh_callback) - self._add_patch_callback(patch_callback) - self._add_actions(bottom_frame) - logger.debug("Built Action frame") - return busy_indicator - - def _add_cli_choices(self, - parent: ttk.Frame, - defaults: Dict[str, Any], - available_masks: List[str], - has_predicted_mask: bool) -> None: - """ Create :class:`lib.gui.control_helper.ControlPanel` object for the command - line options. - - parent: :class:`ttk.Frame` - The frame to hold the command line choices - defaults: dict - The default command line options - available_masks: list - The available masks that exist within the alignments file - has_predicted_mask: bool - Whether the model was trained with a mask - """ - cp_options = self._get_control_panel_options(defaults, available_masks, has_predicted_mask) - panel_kwargs = dict(blank_nones=False, label_width=10, style="CPanel") - ControlPanel(parent, cp_options, header_text=None, **panel_kwargs) - - def _get_control_panel_options(self, - defaults: Dict[str, Any], - available_masks: List[str], - has_predicted_mask: bool) -> List[ControlPanelOption]: - """ Create :class:`lib.gui.control_helper.ControlPanelOption` objects for the command - line options. - - defaults: dict - The default command line options - available_masks: list - The available masks that exist within the alignments file - has_predicted_mask: bool - Whether the model was trained with a mask - - Returns - ------- - list - The list of `lib.gui.control_helper.ControlPanelOption` objects for the Action Frame - """ - cp_options: List[ControlPanelOption] = [] - for opt in self._options: - if opt == "mask_type": - choices = self._create_mask_choices(defaults, available_masks, has_predicted_mask) - else: - choices = PluginLoader.get_available_convert_plugins(opt, True) - cp_option = ControlPanelOption(title=opt, - dtype=str, - default=defaults[opt], - initial_value=defaults[opt], - choices=choices, - group="Command Line Choices", - is_radio=False) - self._tk_vars[opt] = cp_option.tk_var - cp_options.append(cp_option) - return cp_options - - @classmethod - def _create_mask_choices(cls, - defaults: Dict[str, Any], - available_masks: List[str], - has_predicted_mask: bool) -> List[str]: - """ Set the mask choices and default mask based on available masks. - - Parameters - ---------- - defaults: dict - The default command line options - available_masks: list - The available masks that exist within the alignments file - has_predicted_mask: bool - Whether the model was trained with a mask - - Returns - ------- - list - The masks that are available to use from the alignments file - """ - logger.debug("Initial mask choices: %s", available_masks) - if has_predicted_mask: - available_masks += ["predicted"] - if "none" not in available_masks: - available_masks += ["none"] - if defaults["mask_type"] not in available_masks: - logger.debug("Setting default mask to first available: %s", available_masks[0]) - defaults["mask_type"] = available_masks[0] - logger.debug("Final mask choices: %s", available_masks) - return available_masks - - @classmethod - def _add_refresh_button(cls, - parent: ttk.Frame, - refresh_callback: Callable[[], None]) -> None: - """ Add a button to refresh the images. - - Parameters - ---------- - refresh_callback: python function - The function to execute when the refresh button is pressed - """ - btn = ttk.Button(parent, text="Update Samples", command=refresh_callback) - btn.pack(padx=5, pady=5, side=tk.TOP, fill=tk.X, anchor=tk.N) - - def _add_patch_callback(self, patch_callback: Callable[[], None]) -> None: - """ Add callback to re-patch images on action option change. - - Parameters - ---------- - patch_callback: python function - The function to execute when the images require patching - """ - for tk_var in self._tk_vars.values(): - tk_var.trace("w", patch_callback) - - def _add_busy_indicator(self, parent: ttk.Frame) -> ttk.Progressbar: - """ Place progress bar into bottom bar to indicate when processing. - - Parameters - ---------- - parent: tkinter object - The tkinter object that holds the busy indicator - - Returns - ------- - ttk.Progressbar - A Progress bar to indicate that the Preview tool is busy - """ - logger.debug("Placing busy indicator") - pbar = ttk.Progressbar(parent, mode="indeterminate") - pbar.pack(side=tk.LEFT) - pbar.pack_forget() - self._busy_tkvar.trace("w", self._busy_indicator_trace) - return pbar - - def _busy_indicator_trace(self, *args) -> None: - """ Show or hide busy indicator based on whether the preview is updating. - - Parameters - ---------- - args: unused - Required for tkinter event, but unused - """ - logger.trace("Busy indicator trace: %s", args) # type: ignore - if self._busy_tkvar.get(): - self._start_busy_indicator() - else: - self._stop_busy_indicator() - - def _stop_busy_indicator(self) -> None: - """ Stop and hide progress bar """ - logger.debug("Stopping busy indicator") - self._busy_indicator.stop() - self._busy_indicator.pack_forget() - - def _start_busy_indicator(self) -> None: - """ Start and display progress bar """ - logger.debug("Starting busy indicator") - self._busy_indicator.pack(side=tk.LEFT, padx=5, pady=(5, 10), fill=tk.X, expand=True) - self._busy_indicator.start() - - def _add_actions(self, parent: ttk.Frame) -> None: - """ Add Action Buttons to the :class:`ActionFrame` - - Parameters - ---------- - parent: tkinter object - The tkinter object that holds the action buttons - """ - logger.debug("Adding util buttons") - frame = ttk.Frame(parent) - frame.pack(padx=5, pady=(5, 10), side=tk.RIGHT, fill=tk.X, anchor=tk.E) - - for utl in ("save", "clear", "reload"): - logger.debug("Adding button: '%s'", utl) - img = get_images().icons[utl] - if utl == "save": - text = _("Save full config") - action = self._config_tools.save_config - elif utl == "clear": - text = _("Reset full config to default values") - action = self._config_tools.reset_config_to_default - elif utl == "reload": - text = _("Reset full config to saved values") - action = self._config_tools.reset_config_to_saved - - btnutl = ttk.Button(frame, - image=img, - command=action) - btnutl.pack(padx=2, side=tk.RIGHT) - Tooltip(btnutl, text=text, wrap_length=200) - logger.debug("Added util buttons") - - -class OptionsBook(ttk.Notebook): # pylint:disable=too-many-ancestors - """ The notebook that holds the Convert configuration options. - - Parameters - ---------- - parent: tkinter object - The parent tkinter object that holds the Options book - config_tools: :class:`ConfigTools` - Tools for loading and saving configuration files - patch_callback: python function - The function to execute when a patch callback is received - - Attributes - ---------- - config_tools: :class:`ConfigTools` - Tools for loading and saving configuration files - """ - def __init__(self, - parent: ttk.Frame, - config_tools: ConfigTools, - patch_callback: Callable[[], None]) -> None: - logger.debug("Initializing %s: (parent: %s, config: %s)", - self.__class__.__name__, parent, config_tools) - super().__init__(parent) - self.pack(side=tk.RIGHT, anchor=tk.N, fill=tk.BOTH, expand=True) - self.config_tools = config_tools - - self._tabs: Dict[str, Dict[str, Union[ttk.Notebook, ConfigFrame]]] = {} - self._build_tabs() - self._build_sub_tabs() - self._add_patch_callback(patch_callback) - logger.debug("Initialized %s", self.__class__.__name__) - - def _build_tabs(self) -> None: - """ Build the notebook tabs for the each configuration section. """ - logger.debug("Build Tabs") - for section in self.config_tools.sections: - tab = ttk.Notebook(self) - self._tabs[section] = {"tab": tab} - self.add(tab, text=section.replace("_", " ").title()) - - def _build_sub_tabs(self) -> None: - """ Build the notebook sub tabs for each convert section's plugin. """ - for section, plugins in self.config_tools.plugins_dict.items(): - for plugin in plugins: - config_key = ".".join((section, plugin)) - config_dict = self.config_tools.config_dicts[config_key] - tab = ConfigFrame(self, config_key, config_dict) - self._tabs[section][plugin] = tab - text = plugin.replace("_", " ").title() - cast(ttk.Notebook, self._tabs[section]["tab"]).add(tab, text=text) - - def _add_patch_callback(self, patch_callback: Callable[[], None]) -> None: - """ Add callback to re-patch images on configuration option change. - - Parameters - ---------- - patch_callback: python function - The function to execute when the images require patching - """ - for plugins in self.config_tools.tk_vars.values(): - for tk_var in plugins.values(): - tk_var.trace("w", patch_callback) - - -class ConfigFrame(ttk.Frame): # pylint: disable=too-many-ancestors - """ Holds the configuration options for a convert plugin inside the :class:`OptionsBook`. - - Parameters - ---------- - parent: tkinter object - The tkinter object that will hold this configuration frame - config_key: str - The section/plugin key for these configuration options - options: dict - The options for this section/plugin - """ - - def __init__(self, - parent: OptionsBook, - config_key: str, - options: Dict[str, Any]): - logger.debug("Initializing %s", self.__class__.__name__) - super().__init__(parent) - self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) - - self._options = options - - self._action_frame = ttk.Frame(self) - self._action_frame.pack(padx=0, pady=(0, 5), side=tk.BOTTOM, fill=tk.X, anchor=tk.E) - self._add_frame_separator() - - self._build_frame(parent, config_key) - logger.debug("Initialized %s", self.__class__.__name__) - - def _build_frame(self, parent: OptionsBook, config_key: str) -> None: - """ Build the options frame for this command - - Parameters - ---------- - parent: tkinter object - The tkinter object that will hold this configuration frame - config_key: str - The section/plugin key for these configuration options - """ - logger.debug("Add Config Frame") - panel_kwargs = dict(columns=2, option_columns=2, blank_nones=False, style="CPanel") - frame = ttk.Frame(self) - frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) - cp_options = [opt for key, opt in self._options.items() if key != "helptext"] - ControlPanel(frame, cp_options, header_text=None, **panel_kwargs) - self._add_actions(parent, config_key) - logger.debug("Added Config Frame") - - def _add_frame_separator(self) -> None: - """ Add a separator between top and bottom frames. """ - logger.debug("Add frame seperator") - sep = ttk.Frame(self._action_frame, height=2, relief=tk.RIDGE) - sep.pack(fill=tk.X, pady=5, side=tk.TOP) - logger.debug("Added frame seperator") - - def _add_actions(self, parent: OptionsBook, config_key: str) -> None: - """ Add Action Buttons. - - Parameters - ---------- - parent: tkinter object - The tkinter object that will hold this configuration frame - config_key: str - The section/plugin key for these configuration options - """ - logger.debug("Adding util buttons") - - title = config_key.split(".")[1].replace("_", " ").title() - btn_frame = ttk.Frame(self._action_frame) - btn_frame.pack(padx=5, side=tk.BOTTOM, fill=tk.X) - for utl in ("save", "clear", "reload"): - logger.debug("Adding button: '%s'", utl) - img = get_images().icons[utl] - if utl == "save": - text = _(f"Save {title} config") - action = parent.config_tools.save_config - elif utl == "clear": - text = _(f"Reset {title} config to default values") - action = parent.config_tools.reset_config_to_default - elif utl == "reload": - text = _(f"Reset {title} config to saved values") - action = parent.config_tools.reset_config_to_saved - - btnutl = ttk.Button(btn_frame, - image=img, - command=lambda cmd=action: cmd(config_key)) # type: ignore - btnutl.pack(padx=2, side=tk.RIGHT) - Tooltip(btnutl, text=text, wrap_length=200) - logger.debug("Added util buttons") diff --git a/tools/preview/viewer.py b/tools/preview/viewer.py new file mode 100644 index 0000000000..b187603028 --- /dev/null +++ b/tools/preview/viewer.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +""" Manages the widgets that hold the top 'viewer' area of the preview tool """ +import logging +import os +import tkinter as tk +from tkinter import ttk + +from dataclasses import dataclass, field +from typing import cast, List, Optional, Tuple, TYPE_CHECKING + +import cv2 +import numpy as np +from PIL import Image, ImageTk + +from lib.align import transform_image +from lib.align.aligned_face import CenteringType +from scripts.convert import ConvertItem + + +if TYPE_CHECKING: + from .preview import Preview + +logger = logging.getLogger(__name__) + + +@dataclass +class _Faces: + """ Dataclass for holding faces """ + filenames: List[str] = field(default_factory=list) + matrix: List[np.ndarray] = field(default_factory=list) + src: List[np.ndarray] = field(default_factory=list) + dst: List[np.ndarray] = field(default_factory=list) + + +class FacesDisplay(): + """ Compiles the 2 rows of sample faces (original and swapped) into a single image + + Parameters + ---------- + app: :class:`Preview` + The main tkinter Preview app + size: int + The size of each individual face sample in pixels + padding: int + The amount of extra padding to apply to the outside of the face + + Attributes + ---------- + update_source: bool + Flag to indicate that the source images for the preview have been updated, so the preview + should be recompiled. + source: list + The list of :class:`numpy.ndarray` source preview images for top row of display + destination: list + The list of :class:`numpy.ndarray` swapped and patched preview images for bottom row of + display + """ + def __init__(self, app: 'Preview', size: int, padding: int) -> None: + logger.trace("Initializing %s: (app: %s, size: %s, padding: %s)", # type: ignore + self.__class__.__name__, app, size, padding) + self._size = size + self._display_dims = (1, 1) + self._app = app + self._padding = padding + + self._faces = _Faces() + self._centering: Optional[CenteringType] = None + self._faces_source: np.ndarray = np.array([]) + self._faces_dest: np.ndarray = np.array([]) + self._tk_image: Optional[ImageTk.PhotoImage] = None + + # Set from Samples + self.update_source = False + self.source: List[ConvertItem] = [] # Source images, filenames + detected faces + # Set from Patch + self.destination: List[np.ndarray] = [] # Swapped + patched images + + logger.trace("Initialized %s", self.__class__.__name__) # type: ignore + + @property + def tk_image(self) -> Optional[ImageTk.PhotoImage]: + """ :class:`PIL.ImageTk.PhotoImage`: The compiled preview display in tkinter display + format """ + return self._tk_image + + @property + def _total_columns(self) -> int: + """ int: The total number of images that are being displayed """ + return len(self.source) + + def set_centering(self, centering: CenteringType) -> None: + """ The centering that the model uses is not known at initialization time. + Set :attr:`_centering` when the model has been loaded. + + Parameters + ---------- + centering: str + The centering that the model was trained on + """ + self._centering = centering + + def set_display_dimensions(self, dimensions: Tuple[int, int]) -> None: + """ Adjust the size of the frame that will hold the preview samples. + + Parameters + ---------- + dimensions: tuple + The (`width`, `height`) of the frame that holds the preview + """ + self._display_dims = dimensions + + def update_tk_image(self) -> None: + """ Build the full preview images and compile :attr:`tk_image` for display. """ + logger.trace("Updating tk image") # type: ignore + self._build_faces_image() + img = np.vstack((self._faces_source, self._faces_dest)) + size = self._get_scale_size(img) + img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + pilimg = Image.fromarray(img) + pilimg = pilimg.resize(size, Image.ANTIALIAS) + self._tk_image = ImageTk.PhotoImage(pilimg) + logger.trace("Updated tk image") # type: ignore + + def _get_scale_size(self, image: np.ndarray) -> Tuple[int, int]: + """ Get the size that the full preview image should be resized to fit in the + display window. + + Parameters + ---------- + image: :class:`numpy.ndarray` + The full sized compiled preview image + + Returns + ------- + tuple + The (`width`, `height`) that the display image should be sized to fit in the display + window + """ + frameratio = float(self._display_dims[0]) / float(self._display_dims[1]) + imgratio = float(image.shape[1]) / float(image.shape[0]) + + if frameratio <= imgratio: + scale = self._display_dims[0] / float(image.shape[1]) + size = (self._display_dims[0], max(1, int(image.shape[0] * scale))) + else: + scale = self._display_dims[1] / float(image.shape[0]) + size = (max(1, int(image.shape[1] * scale)), self._display_dims[1]) + logger.trace("scale: %s, size: %s", scale, size) # type: ignore + return size + + def _build_faces_image(self) -> None: + """ Compile the source and destination rows of the preview image. """ + logger.trace("Building Faces Image") # type: ignore + update_all = self.update_source + self._faces_from_frames() + if update_all: + header = self._header_text() + source = np.hstack([self._draw_rect(face) for face in self._faces.src]) + self._faces_source = np.vstack((header, source)) + self._faces_dest = np.hstack([self._draw_rect(face) for face in self._faces.dst]) + logger.debug("source row shape: %s, swapped row shape: %s", + self._faces_dest.shape, self._faces_source.shape) + + def _faces_from_frames(self) -> None: + """ Extract the preview faces from the source frames and apply the requisite padding. """ + logger.debug("Extracting faces from frames: Number images: %s", len(self.source)) + if self.update_source: + self._crop_source_faces() + self._crop_destination_faces() + logger.debug("Extracted faces from frames: %s", + {k: len(v) for k, v in self._faces.__dict__.items()}) + + def _crop_source_faces(self) -> None: + """ Extract the source faces from the source frames, along with their filenames and the + transformation matrix used to extract the faces. """ + logger.debug("Updating source faces") + self._faces = _Faces() # Init new class + for item in self.source: + detected_face = item.inbound.detected_faces[0] + src_img = item.inbound.image + detected_face.load_aligned(src_img, + size=self._size, + centering=cast(CenteringType, self._centering)) + matrix = detected_face.aligned.matrix + self._faces.filenames.append(os.path.splitext(item.inbound.filename)[0]) + self._faces.matrix.append(matrix) + self._faces.src.append(transform_image(src_img, matrix, self._size, self._padding)) + self.update_source = False + logger.debug("Updated source faces") + + def _crop_destination_faces(self) -> None: + """ Extract the swapped faces from the swapped frames using the source face destination + matrices. """ + logger.debug("Updating destination faces") + self._faces.dst = [] + destination = self.destination if self.destination else [np.ones_like(src.inbound.image) + for src in self.source] + for idx, image in enumerate(destination): + self._faces.dst.append(transform_image(image, + self._faces.matrix[idx], + self._size, + self._padding)) + logger.debug("Updated destination faces") + + def _header_text(self) -> np.ndarray: + """ Create the header text displaying the frame name for each preview column. + + Returns + ------- + :class:`numpy.ndarray` + The header row of the preview image containing the frame names for each column + """ + font_scale = self._size / 640 + height = self._size // 8 + font = cv2.FONT_HERSHEY_SIMPLEX + # Get size of placed text for positioning + text_sizes = [cv2.getTextSize(self._faces.filenames[idx], + font, + font_scale, + 1)[0] + for idx in range(self._total_columns)] + # Get X and Y co-ordinates for each text item + text_y = int((height + text_sizes[0][1]) / 2) + text_x = [int((self._size - text_sizes[idx][0]) / 2) + self._size * idx + for idx in range(self._total_columns)] + logger.debug("filenames: %s, text_sizes: %s, text_x: %s, text_y: %s", + self._faces.filenames, text_sizes, text_x, text_y) + header_box = np.ones((height, self._size * self._total_columns, 3), np.uint8) * 255 + for idx, text in enumerate(self._faces.filenames): + cv2.putText(header_box, + text, + (text_x[idx], text_y), + font, + font_scale, + (0, 0, 0), + 1, + lineType=cv2.LINE_AA) + logger.debug("header_box.shape: %s", header_box.shape) + return header_box + + def _draw_rect(self, image: np.ndarray) -> np.ndarray: + """ Place a white border around a given image. + + Parameters + ---------- + image: :class:`numpy.ndarray` + The image to place a border on to + Returns + ------- + :class:`numpy.ndarray` + The given image with a border drawn around the outside + """ + cv2.rectangle(image, (0, 0), (self._size - 1, self._size - 1), (255, 255, 255), 1) + image = np.clip(image, 0.0, 255.0) + return image.astype("uint8") + + +class ImagesCanvas(ttk.Frame): # pylint:disable=too-many-ancestors + """ tkinter Canvas that holds the preview images. + + Parameters + ---------- + app: :class:`Preview` + The main tkinter Preview app + parent: tkinter object + The parent tkinter object that holds the canvas + """ + def __init__(self, app: 'Preview', parent: ttk.PanedWindow) -> None: + logger.debug("Initializing %s: (app: %s, parent: %s)", + self.__class__.__name__, app, parent) + super().__init__(parent) + self.pack(expand=True, fill=tk.BOTH, padx=2, pady=2) + + self._display: FacesDisplay = parent.preview_display # type: ignore + self._canvas = tk.Canvas(self, bd=0, highlightthickness=0) + self._canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + self._displaycanvas = self._canvas.create_image(0, 0, + image=self._display.tk_image, + anchor=tk.NW) + self.bind("", self._resize) + logger.debug("Initialized %s", self.__class__.__name__) + + def _resize(self, event: tk.Event) -> None: + """ Resize the image to fit the frame, maintaining aspect ratio """ + logger.debug("Resizing preview image") + framesize = (event.width, event.height) + self._display.set_display_dimensions(framesize) + self.reload() + + def reload(self) -> None: + """ Update the images in the canvas and redraw """ + logger.debug("Reloading preview image") + self._display.update_tk_image() + self._canvas.itemconfig(self._displaycanvas, image=self._display.tk_image) + logger.debug("Reloaded preview image") From 110f53d5a41123a32b41e9f489f1310b4a9e5907 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 17 Jan 2023 15:24:14 +0000 Subject: [PATCH 787/981] bugfix - skip tkinter unit tests --- tests/lib/utils_test.py | 3 ++- tests/tools/preview/viewer_test.py | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/lib/utils_test.py b/tests/lib/utils_test.py index 4419f6f903..95cc8ae926 100644 --- a/tests/lib/utils_test.py +++ b/tests/lib/utils_test.py @@ -22,7 +22,8 @@ safe_shutdown, set_backend, set_system_verbosity) from lib.logger import log_setup -log_setup("DEBUG", "", "PyTest, False") # Need to setup logging to avoid trace/verbose errors +# Need to setup logging to avoid trace/verbose errors +log_setup("DEBUG", "pytest_utils.log", "PyTest, False") # pylint:disable=protected-access diff --git a/tests/tools/preview/viewer_test.py b/tests/tools/preview/viewer_test.py index 96a9bb7c36..038d59703b 100644 --- a/tests/tools/preview/viewer_test.py +++ b/tests/tools/preview/viewer_test.py @@ -12,7 +12,8 @@ from PIL import ImageTk from lib.logger import log_setup -log_setup("DEBUG", "", "PyTest, False") # Need to setup logging to avoid trace/verbose errors +# Need to setup logging to avoid trace/verbose errors +log_setup("DEBUG", "pytest_viewer.log", "PyTest, False") from lib.utils import get_backend # pylint:disable=wrong-import-position # noqa from tools.preview.viewer import _Faces, FacesDisplay, ImagesCanvas # pylint:disable=wrong-import-position # noqa @@ -115,6 +116,7 @@ def test_set_display_dimensions(self) -> None: f_display.set_display_dimensions(dimensions) assert f_display._display_dims == dimensions + @pytest.mark.skip(reason="Headless tkinter will error") @pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS) def test_update_tk_image(self, columns: int, @@ -131,6 +133,8 @@ def test_update_tk_image(self, mocker: :class:`pytest_mock.MockerFixture` Mocker for checking _build_faces_image method called """ + # TODO find out how we can test this on a headless system + # Launching tk.Tk() will result in an error because a display is not found f_display = self.get_faces_display_instance(columns, face_size) f_display._build_faces_image = cast(MagicMock, mocker.MagicMock()) # type:ignore f_display._get_scale_size = cast(MagicMock, # type:ignore From 798e59192c374ea6f7e8ffd849edc5727001696b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 17 Jan 2023 16:20:16 +0000 Subject: [PATCH 788/981] unit test updates - Add XVFB (virtual display) - Re-activate viewer gui test --- .github/workflows/pytest.yml | 2 +- tests/tools/preview/viewer_test.py | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 121ef5b19f..3b39a18eee 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -35,7 +35,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 pylint mypy pytest pytest-mock wheel + pip install flake8 pylint mypy pytest pytest-mock pytest-xvfb wheel pip install -r ./requirements/requirements_${{ matrix.backend }}.txt - name: Lint with flake8 run: | diff --git a/tests/tools/preview/viewer_test.py b/tests/tools/preview/viewer_test.py index 038d59703b..05fb072eec 100644 --- a/tests/tools/preview/viewer_test.py +++ b/tests/tools/preview/viewer_test.py @@ -116,7 +116,6 @@ def test_set_display_dimensions(self) -> None: f_display.set_display_dimensions(dimensions) assert f_display._display_dims == dimensions - @pytest.mark.skip(reason="Headless tkinter will error") @pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS) def test_update_tk_image(self, columns: int, @@ -133,8 +132,6 @@ def test_update_tk_image(self, mocker: :class:`pytest_mock.MockerFixture` Mocker for checking _build_faces_image method called """ - # TODO find out how we can test this on a headless system - # Launching tk.Tk() will result in an error because a display is not found f_display = self.get_faces_display_instance(columns, face_size) f_display._build_faces_image = cast(MagicMock, mocker.MagicMock()) # type:ignore f_display._get_scale_size = cast(MagicMock, # type:ignore From e2d84bec50884c6d1ae4bf94fee7e4f10e836d69 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 18 Jan 2023 03:17:09 +0000 Subject: [PATCH 789/981] bugfix: - Alignment tool - remove faces: - Only process faces that exist in alignments file --- tools/alignments/jobs_faces.py | 6 +----- tools/alignments/media.py | 20 ++++++++++++++++---- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/tools/alignments/jobs_faces.py b/tools/alignments/jobs_faces.py index 3a74735776..6be619f99a 100644 --- a/tools/alignments/jobs_faces.py +++ b/tools/alignments/jobs_faces.py @@ -337,11 +337,7 @@ def __init__(self, alignments: "AlignmentData", arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self._alignments = alignments - kwargs = {} - if alignments.version < 2.1: - # Update headers of faces generated with hash based alignments - kwargs["alignments"] = alignments - self._items = Faces(arguments.faces_dir, **kwargs) # type:ignore # needs TypedDict :/ + self._items = Faces(arguments.faces_dir, alignments=alignments) logger.debug("Initialized %s", self.__class__.__name__) def process(self) -> None: diff --git a/tools/alignments/media.py b/tools/alignments/media.py index b2dc52ce09..b6cd29a8d6 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -260,9 +260,10 @@ class Faces(MediaLoader): folder: str The folder to load faces from alignments: :class:`lib.align.Alignments`, optional - The alignments object that contains the faces. Used to update legacy hash based faces - for None: self._alignments = alignments @@ -278,8 +279,10 @@ def process_folder(self) -> Generator[Tuple[str, "PNGHeaderDict"], None, None]: :class:`lib.image.read_image_meta_batch` """ logger.info("Loading file list from %s", self.folder) + is_legacy = self._alignments is not None and self._alignments.version < 2.1 + filter_count = 0 - if self._alignments is not None: # Legacy updating + if is_legacy: # Legacy updating filelist = [os.path.join(self.folder, face) for face in os.listdir(self.folder) if self.valid_extension(face)] @@ -314,9 +317,18 @@ def process_folder(self) -> Generator[Tuple[str, "PNGHeaderDict"], None, None]: else: sub_dict = cast("PNGHeaderDict", metadata["itxt"]) + if (self._alignments is not None and # filter existing + not self._alignments.frame_exists(sub_dict["source"]["source_filename"])): + filter_count += 1 + continue + retval = (os.path.basename(fullpath), sub_dict) yield retval + if self._alignments is not None: + logger.debug("Faces filtered out that did not exist in alignments file: %s", + filter_count) + def load_items(self) -> Dict[str, List[int]]: """ Load the face names into dictionary. From 1d19aea2817969e9f019d9ae64e569f505008643 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 18 Jan 2023 15:39:05 +0000 Subject: [PATCH 790/981] bugfix - numexpr. ignore OMP_NUM_THREADS EnvVar --- lib/cli/launcher.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 4fdb13d9b6..ce298d77c6 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -57,6 +57,10 @@ def _set_environment_variables(self) -> None: # Allocate a decent number of threads to numexpr to suppress warnings cpu_count = os.cpu_count() allocate = cpu_count - cpu_count // 3 if cpu_count is not None else 1 + if "OMP_NUM_THREADS" in os.environ: + # If this is set above NUMEXPR_MAX_THREADS, numexpr will error. + # ref: https://github.com/pydata/numexpr/issues/322 + os.environ.pop("OMP_NUM_THREADS") os.environ["NUMEXPR_MAX_THREADS"] = str(max(1, allocate)) # Ensure tensorflow doesn't pin all threads to one core when using Math Kernel Library From c4748582659737fd89bf59db3db3eb1aff961722 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 18 Jan 2023 20:13:29 +0000 Subject: [PATCH 791/981] bugfix: Alignments tool - remove faces: - Handle duplicate face images --- tools/alignments/media.py | 108 +++++++++++++++++++++++++++++++------- 1 file changed, 89 insertions(+), 19 deletions(-) diff --git a/tools/alignments/media.py b/tools/alignments/media.py index b6cd29a8d6..c3c49559c3 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -269,6 +269,81 @@ def __init__(self, folder: str, alignments: Optional[Alignments] = None) -> None self._alignments = alignments super().__init__(folder) + def _handle_legacy(self, fullpath: str, log: bool = False) -> "PNGHeaderDict": + """Handle facesets that are legacy (i.e. do not contain alignment information in the + header data) + + Parameters + ---------- + fullpath : str + The full path to the extracted face image + log : bool, optional + Whether to log a message that legacy updating is occurring + + Returns + ------- + :class:`~lib.align.alignments.PNGHeaderDict` + The Alignments information from the face in PNG Header dict format + + Raises + ------ + FaceswapError + If legacy faces can't be updated because the alignments file does not exist or some of + the faces do not appear in the provided alignments file + """ + if self._alignments is None: # Can't update legacy + raise FaceswapError(f"The folder '{self.folder}' contains images that do not include " + "Faceswap metadata.\nAll images in the provided folder should " + "contain faces generated from Faceswap's extraction process.\n" + "Please double check the source and try again.") + if log: + logger.warning("Legacy faces discovered. These faces will be updated") + + data = update_legacy_png_header(fullpath, self._alignments) + if not data: + raise FaceswapError( + f"Some of the faces being passed in from '{self.folder}' could not be " + f"matched to the alignments file '{self._alignments.file}'\nPlease double " + "check your sources and try again.") + return data + + def _handle_duplicate(self, + fullpath: str, + header_dict: "PNGHeaderDict", + seen: Dict[str, List[int]]) -> bool: + """ Check whether the given face has already been seen for the source frame and face index + from an existing face. Can happen when filenames have changed due to sorting etc. and users + have done multiple extractions/copies and placed all of the faces in the same folder + + Parameters + ---------- + fullpath : str + The full path to the face image that is being checked + header_dict : class:`~lib.align.alignments.PNGHeaderDict` + The PNG header dictionary for the given face + seen : Dict[str, List[int]] + Dictionary of original source filename and face indices that have already been seen and + will be updated with the face processing now + + Returns + ------- + bool + ``True`` if the face was a duplicate and has been removed, otherwise ``False`` + """ + src_filename = header_dict["source"]["source_filename"] + face_index = header_dict["source"]["face_index"] + + if src_filename in seen and face_index in seen[src_filename]: + dupe_dir = os.path.join(self.folder, "_duplicates") + os.makedirs(dupe_dir, exist_ok=True) + filename = os.path.basename(fullpath) + logger.trace("Moving duplicate: %s", filename) # type:ignore + os.rename(fullpath, os.path.join(dupe_dir, filename)) + return True + + seen.setdefault(src_filename, []).append(face_index) + return False + def process_folder(self) -> Generator[Tuple[str, "PNGHeaderDict"], None, None]: """ Iterate through the faces folder pulling out various information for each face. @@ -279,10 +354,11 @@ def process_folder(self) -> Generator[Tuple[str, "PNGHeaderDict"], None, None]: :class:`lib.image.read_image_meta_batch` """ logger.info("Loading file list from %s", self.folder) - is_legacy = self._alignments is not None and self._alignments.version < 2.1 filter_count = 0 + dupe_count = 0 + seen: dict[str, list[int]] = {} - if is_legacy: # Legacy updating + if self._alignments is not None and self._alignments.version < 2.1: # Legacy updating filelist = [os.path.join(self.folder, face) for face in os.listdir(self.folder) if self.valid_extension(face)] @@ -297,26 +373,15 @@ def process_folder(self) -> Generator[Tuple[str, "PNGHeaderDict"], None, None]: desc="Reading Face Data"): if "itxt" not in metadata or "source" not in metadata["itxt"]: - if self._alignments is None: # Can't update legacy - raise FaceswapError( - f"The folder '{self.folder}' contains images that do not include Faceswap " - "metadata.\nAll images in the provided folder should contain faces " - "generated from Faceswap's extraction process.\nPlease double check the " - "source and try again.") - - if not log_once: - logger.warning("Legacy faces discovered. These faces will be updated") - log_once = True - data = update_legacy_png_header(fullpath, self._alignments) - if not data: - raise FaceswapError( - f"Some of the faces being passed in from '{self.folder}' could not be " - f"matched to the alignments file '{self._alignments.file}'\nPlease double " - "check your sources and try again.") - sub_dict = data + sub_dict = self._handle_legacy(fullpath, not log_once) + log_once = True else: sub_dict = cast("PNGHeaderDict", metadata["itxt"]) + if self._handle_duplicate(fullpath, sub_dict, seen): + dupe_count += 1 + continue + if (self._alignments is not None and # filter existing not self._alignments.frame_exists(sub_dict["source"]["source_filename"])): filter_count += 1 @@ -329,6 +394,11 @@ def process_folder(self) -> Generator[Tuple[str, "PNGHeaderDict"], None, None]: logger.debug("Faces filtered out that did not exist in alignments file: %s", filter_count) + if dupe_count > 0: + logger.warning("%s Duplicate face images were found. These files have been moved to " + "'%s' from where they can be safely deleted", + dupe_count, os.path.join(self.folder, "_duplicates")) + def load_items(self) -> Dict[str, List[int]]: """ Load the face names into dictionary. From eaa33ae28ee77175e5bff91d150dee61b51c4f3c Mon Sep 17 00:00:00 2001 From: DonOhhhh <96936113+DonOhhhh@users.noreply.github.com> Date: Thu, 19 Jan 2023 07:12:29 +0900 Subject: [PATCH 792/981] Korean translations added (#1287) --- locales/kr/LC_MESSAGES/faceswap.mo | Bin 0 -> 886 bytes locales/kr/LC_MESSAGES/faceswap.po | 34 + locales/kr/LC_MESSAGES/gui.tooltips.mo | Bin 0 -> 5856 bytes locales/kr/LC_MESSAGES/gui.tooltips.po | 261 +++++ locales/kr/LC_MESSAGES/lib.cli.args.mo | Bin 0 -> 47827 bytes locales/kr/LC_MESSAGES/lib.cli.args.po | 990 ++++++++++++++++++ .../kr/LC_MESSAGES/tools.alignments.cli.mo | Bin 0 -> 9269 bytes .../kr/LC_MESSAGES/tools.alignments.cli.po | 208 ++++ locales/kr/LC_MESSAGES/tools.effmpeg.cli.mo | Bin 0 -> 6735 bytes locales/kr/LC_MESSAGES/tools.effmpeg.cli.po | 184 ++++ locales/kr/LC_MESSAGES/tools.manual.mo | Bin 0 -> 8143 bytes locales/kr/LC_MESSAGES/tools.manual.po | 282 +++++ locales/kr/LC_MESSAGES/tools.mask.cli.mo | Bin 0 -> 8406 bytes locales/kr/LC_MESSAGES/tools.mask.cli.po | 193 ++++ locales/kr/LC_MESSAGES/tools.model.cli.mo | Bin 0 -> 2967 bytes locales/kr/LC_MESSAGES/tools.model.cli.po | 78 ++ locales/kr/LC_MESSAGES/tools.preview.mo | Bin 0 -> 2080 bytes locales/kr/LC_MESSAGES/tools.preview.po | 83 ++ locales/kr/LC_MESSAGES/tools.sort.cli.mo | Bin 0 -> 15578 bytes locales/kr/LC_MESSAGES/tools.sort.cli.po | 363 +++++++ locales/tools.manual.pot | 8 +- locales/tools.sort.cli.pot | 8 +- 22 files changed, 2684 insertions(+), 8 deletions(-) create mode 100644 locales/kr/LC_MESSAGES/faceswap.mo create mode 100644 locales/kr/LC_MESSAGES/faceswap.po create mode 100644 locales/kr/LC_MESSAGES/gui.tooltips.mo create mode 100644 locales/kr/LC_MESSAGES/gui.tooltips.po create mode 100644 locales/kr/LC_MESSAGES/lib.cli.args.mo create mode 100644 locales/kr/LC_MESSAGES/lib.cli.args.po create mode 100644 locales/kr/LC_MESSAGES/tools.alignments.cli.mo create mode 100644 locales/kr/LC_MESSAGES/tools.alignments.cli.po create mode 100644 locales/kr/LC_MESSAGES/tools.effmpeg.cli.mo create mode 100644 locales/kr/LC_MESSAGES/tools.effmpeg.cli.po create mode 100644 locales/kr/LC_MESSAGES/tools.manual.mo create mode 100644 locales/kr/LC_MESSAGES/tools.manual.po create mode 100644 locales/kr/LC_MESSAGES/tools.mask.cli.mo create mode 100644 locales/kr/LC_MESSAGES/tools.mask.cli.po create mode 100644 locales/kr/LC_MESSAGES/tools.model.cli.mo create mode 100644 locales/kr/LC_MESSAGES/tools.model.cli.po create mode 100644 locales/kr/LC_MESSAGES/tools.preview.mo create mode 100644 locales/kr/LC_MESSAGES/tools.preview.po create mode 100644 locales/kr/LC_MESSAGES/tools.sort.cli.mo create mode 100644 locales/kr/LC_MESSAGES/tools.sort.cli.po diff --git a/locales/kr/LC_MESSAGES/faceswap.mo b/locales/kr/LC_MESSAGES/faceswap.mo new file mode 100644 index 0000000000000000000000000000000000000000..4613eb73456015589896887cdc25ddfaf2212402 GIT binary patch literal 886 zcmZXS&1(}u7{*tv2z&G_`W_TP-Izqhx}`$D3APwYE8b+>ousSD%rZM^np4afiL}m~ldvVe*Y^=YrUch?(lwsV)dKW87x(m16ys$x; zF%WLXI9sGbLcnvvq2POt%n2F*7qIBLl!N3zfYCf~Mj`LX1&|9AQUt|8p7=g>jfo{G zh$D{<0!Rs-{m+Z^+>B|GV@}Uv5-mapm=eTa@EnqX`G5*YFiAz|Z_EkeF-*^Lmu4V^ zBZbj2&%5yfi~wOSj2cP7pHN3y3D-EN#Q!V^5mNOiqxDHOWOb zuz~sAHW-c#L2o-89WtjVqXOUKTB8LU{6d<_Ffig5AQri0KC})6;Zu^Fx}Jm^k+^B- z&8pgIf!^9xUt9W95mddZp4UMAD5+Xa*PH4?8}xenU~d;Spk6iAx0)(eL9dk6`-XnI zd;G4hFYA?M-Dsl#)K0OpQSYpMR+X}wDrE%mXjUN?^=={HqfY8-`?K=0S|exrZ2 z|7*-Ax?@$Eo%PlJd2h_ndPHv&VH6EPy(o6paEdS6>SarBm7ud(R`0fTrO|n|cR12t DBB)c; literal 0 HcmV?d00001 diff --git a/locales/kr/LC_MESSAGES/faceswap.po b/locales/kr/LC_MESSAGES/faceswap.po new file mode 100644 index 0000000000..c4829dac52 --- /dev/null +++ b/locales/kr/LC_MESSAGES/faceswap.po @@ -0,0 +1,34 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2021-02-18 23:48-0000\n" +"PO-Revision-Date: 2022-11-24 12:21+0900\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ko_KR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.2\n" + +#: faceswap.py:43 +msgid "Extract the faces from pictures or a video" +msgstr "그림들 또는 비디오에서 얼굴을 추출합니다" + +#: faceswap.py:44 +msgid "Train a model for the two faces A and B" +msgstr "얼굴들 A와 B에 대한 모델을 훈련시킵니다" + +#: faceswap.py:47 +msgid "Convert source pictures or video to a new one with the face swapped" +msgstr "원본 이미지 또는 비디오를 얼굴이 뒤바뀐 새로운 이미지 또는 영상으로 변환합니다" + +#: faceswap.py:48 +msgid "Launch the Faceswap Graphical User Interface" +msgstr "Faceswap GUI를 실행합니다" diff --git a/locales/kr/LC_MESSAGES/gui.tooltips.mo b/locales/kr/LC_MESSAGES/gui.tooltips.mo new file mode 100644 index 0000000000000000000000000000000000000000..56822b5a2d2a860560da5cf7380580992f298283 GIT binary patch literal 5856 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 literal 0 HcmV?d00001 diff --git a/locales/kr/LC_MESSAGES/gui.tooltips.po b/locales/kr/LC_MESSAGES/gui.tooltips.po new file mode 100644 index 0000000000..59f1f5a0e7 --- /dev/null +++ b/locales/kr/LC_MESSAGES/gui.tooltips.po @@ -0,0 +1,261 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2021-03-22 18:37+0000\n" +"PO-Revision-Date: 2022-11-26 16:12+0900\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ko_KR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.2\n" + +#: lib/gui/command.py:184 +msgid "Output command line options to the console" +msgstr "명령어 옵션들을 콘솔에 출력" + +#: lib/gui/command.py:195 +msgid "Run the {} script" +msgstr "{} 스크립트 실행" + +#: lib/gui/control_helper.py:1234 +msgid "Select a folder..." +msgstr "폴더 선택." + +#: lib/gui/control_helper.py:1235 lib/gui/control_helper.py:1236 +msgid "Select a file..." +msgstr "파일 선택." + +#: lib/gui/control_helper.py:1237 +msgid "Select a folder of images..." +msgstr "이미지들의 폴더 선택." + +#: lib/gui/control_helper.py:1238 +msgid "Select a video..." +msgstr "비디오 선택." + +#: lib/gui/control_helper.py:1239 +msgid "Select a model folder..." +msgstr "모델 폴더 선택하기." + +#: lib/gui/control_helper.py:1240 +msgid "Select one or more files..." +msgstr "하나 이상의 파일들 선택." + +#: lib/gui/control_helper.py:1241 +msgid "Select a file or folder..." +msgstr "파일 또는 폴더 선택." + +#: lib/gui/control_helper.py:1242 +msgid "Select a save location..." +msgstr "저장 위치 선택." + +#: lib/gui/display.py:71 +msgid "Summary statistics for each training session" +msgstr "각 훈련 세션들에 대한 통계 요약" + +#: lib/gui/display.py:113 +msgid "Preview updates every 5 seconds" +msgstr "5초마다 미리보기를 업데이트하기" + +#: lib/gui/display.py:122 +msgid "Graph showing Loss vs Iterations" +msgstr "반복에 따른 손실율 그래프" + +#: lib/gui/display.py:125 +msgid "Training preview. Updated on every save iteration" +msgstr "훈련 미리보기. 매 저장된 반복마다 업데이트됩니다" + +#: lib/gui/display_analysis.py:342 +msgid "Load/Refresh stats for the currently training session" +msgstr "현재 훈련 세션에 대한 통계 가져오기/새로고침" + +#: lib/gui/display_analysis.py:344 +msgid "Clear currently displayed session stats" +msgstr "현재 보여지는 세션 통계 지우기" + +#: lib/gui/display_analysis.py:346 +msgid "Save session stats to csv" +msgstr "세션 통계 csv로 저장하기" + +#: lib/gui/display_analysis.py:348 +msgid "Load saved session stats" +msgstr "저장된 세션 통계 가져오기" + +#: lib/gui/display_command.py:94 +msgid "Preview updates at every model save. Click to refresh now." +msgstr "" +"모델을 저장할 때마다 미리보기를 업데이트합니다. 지금 새로고침하기 위해 누르세" +"요." + +#: lib/gui/display_command.py:261 +msgid "Graph updates at every model save. Click to refresh now." +msgstr "" +"모델을 저장할 때마다 그래프를 업데이트합니다. 지금 새로고침하기 위해 누르세" +"요." + +#: lib/gui/display_command.py:275 +msgid "Display the raw loss data" +msgstr "원시 손실 데이터 보이기" + +#: lib/gui/display_command.py:287 +msgid "Display the smoothed loss data" +msgstr "매끄러운 손실 데이터 보이기" + +#: lib/gui/display_command.py:294 +msgid "Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing." +msgstr "" +"매끄러움 정도를 설정합니다. 0이면 매끄러움이 없고, 0.99이면 최대로 매끄러워집" +"니다." + +#: lib/gui/display_command.py:324 +msgid "Set the number of iterations to display. 0 displays the full session." +msgstr "" +"화면에 보여질 반복 횟수를 설정합니다. 0 displays는 모든 세션에서 보여줍니다." + +#: lib/gui/display_page.py:238 +msgid "Save {}(s) to file" +msgstr "{}(s)를 파일에 저장합니다" + +#: lib/gui/display_page.py:250 +msgid "Enable or disable {} display" +msgstr "{} display를 활성화 또는 비활성화" + +#: lib/gui/menu.py:32 +msgid "faceswap.dev - Guides and Forum" +msgstr "faceswap.dev - Guides and Forum" + +#: lib/gui/menu.py:33 +msgid "Patreon - Support this project" +msgstr "Patreon - Support this project" + +#: lib/gui/menu.py:34 +msgid "Discord - The FaceSwap Discord server" +msgstr "Discord - The FaceSwap Discord server" + +#: lib/gui/menu.py:35 +msgid "Github - Our Source Code" +msgstr "Github - Our Source Code" + +#: lib/gui/menu.py:527 +msgid "Configure {} settings..." +msgstr "{} 세팅 설정하기." + +#: lib/gui/menu.py:535 +msgid "Project" +msgstr "프로젝트" + +#: lib/gui/menu.py:535 +msgid "currently selected Task" +msgstr "현재 선택된 작업" + +#: lib/gui/menu.py:537 +msgid "Reload {} from disk" +msgstr "디스크에서 {}를 다시 가져옵니다" + +#: lib/gui/menu.py:539 +msgid "Create a new {}..." +msgstr "새로운 {}를 만들기." + +#: lib/gui/menu.py:541 +msgid "Reset {} to default" +msgstr "{} 기본으로 재설정" + +#: lib/gui/menu.py:543 +msgid "Save {}" +msgstr "{} 저장" + +#: lib/gui/menu.py:545 +msgid "Save {} as..." +msgstr "{}를 다른 이름으로 저장." + +#: lib/gui/menu.py:549 +msgid " from a task or project file" +msgstr " 작업 또는 프로젝트 파일에서" + +#: lib/gui/menu.py:550 +msgid "Load {}..." +msgstr "{} 가져오기." + +#: lib/gui/popup_configure.py:209 +msgid "Close without saving" +msgstr "저장하지 않고 닫기" + +#: lib/gui/popup_configure.py:210 +msgid "Save this page's config" +msgstr "이 페이지의 설정을 저장" + +#: lib/gui/popup_configure.py:211 +msgid "Reset this page's config to default values" +msgstr "이 페이지의 설정을 기본값으로 재설정" + +#: lib/gui/popup_configure.py:213 +msgid "Save all settings for the currently selected config" +msgstr "현재 선택된 모든 설정을 저장" + +#: lib/gui/popup_configure.py:216 +msgid "Reset all settings for the currently selected config to default values" +msgstr "현재 선택된 모든 설정을 기본값으로 재설정" + +#: lib/gui/popup_configure.py:538 +msgid "Select a plugin to configure:" +msgstr "구성할 플러그인 선택:" + +#: lib/gui/popup_session.py:191 +msgid "Display {}" +msgstr "{} 보이기" + +#: lib/gui/popup_session.py:342 +msgid "Refresh graph" +msgstr "그래프 새로고침" + +#: lib/gui/popup_session.py:344 +msgid "Save display data to csv" +msgstr "디스플레이 데이터를 csv로 저장" + +#: lib/gui/popup_session.py:346 +msgid "Number of data points to sample for rolling average" +msgstr "샘플의 이동평균 데이터 포인트 개수" + +#: lib/gui/popup_session.py:348 +msgid "Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing" +msgstr "" +"매끄러움 정도를 설정합니다. 0이면 매끄러움이 없고, 0.99이면 최대로 매끄러워집" +"니다" + +#: lib/gui/popup_session.py:350 +msgid "" +"Flatten data points that fall more than 1 standard deviation from the mean " +"to the mean value." +msgstr "평균에서 값까지 1 표준 편차보다 더 멀리 떨어진 데이터들 펴기." + +#: lib/gui/popup_session.py:353 +msgid "Display rolling average of the data" +msgstr "데이터의 이동평균 보이기" + +#: lib/gui/popup_session.py:355 +msgid "Smooth the data" +msgstr "데이터 매끄럽게 하기" + +#: lib/gui/popup_session.py:357 +msgid "Display raw data" +msgstr "원시 데이터 보이기" + +#: lib/gui/popup_session.py:359 +msgid "Display polynormal data trend" +msgstr "다항 데이터 트렌드 보이기" + +#: lib/gui/popup_session.py:361 +msgid "Set the data to display" +msgstr "데이터를 display에 설정하기" + +#: lib/gui/popup_session.py:363 +msgid "Change y-axis scale" +msgstr "변경합니다 y축의 범위를" diff --git a/locales/kr/LC_MESSAGES/lib.cli.args.mo b/locales/kr/LC_MESSAGES/lib.cli.args.mo new file mode 100644 index 0000000000000000000000000000000000000000..71f44264ea19f12d037837bf41b78bb6e6b862c7 GIT binary patch literal 47827 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 literal 0 HcmV?d00001 diff --git a/locales/kr/LC_MESSAGES/lib.cli.args.po b/locales/kr/LC_MESSAGES/lib.cli.args.po new file mode 100644 index 0000000000..d99a0948f3 --- /dev/null +++ b/locales/kr/LC_MESSAGES/lib.cli.args.po @@ -0,0 +1,990 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-11-20 01:34+0000\n" +"PO-Revision-Date: 2022-11-26 16:11+0900\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ko_KR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Poedit 3.2\n" + +#: lib/cli/args.py:193 lib/cli/args.py:203 lib/cli/args.py:211 +#: lib/cli/args.py:221 +msgid "Global Options" +msgstr "전역 옵션들" + +#: lib/cli/args.py:194 +msgid "" +"R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " +"to any GPU(s) that you do not wish to be made available to Faceswap. " +"Selecting all GPUs here will force Faceswap into CPU mode.\n" +"L|{}" +msgstr "" +"R|Faceswap에서 사용되는 GPUs를 제외합니다. Faceswap에서 사용되게 하고 싶지 않" +"은 GPU(s)에 해당하는 번호를 선택하세요. 모든 GPUs를 선택하면 Faceswap으로 하" +"여금 CPU mode를 강제로 사용하게 합니다.\n" +"L|{}" + +#: lib/cli/args.py:204 +msgid "" +"Optionally overide the saved config with the path to a custom config file." +msgstr "선택적으로 저장된 설정을 경로와 함께 개인 설정 파일에 덮어씌웁니다." + +#: lib/cli/args.py:212 +msgid "" +"Log level. Stick with INFO or VERBOSE unless you need to file an error " +"report. Be careful with TRACE as it will generate a lot of data" +msgstr "" +"로그 레벨. 오류 리포트가 필요하지 않다면 INFO와 VERBOSE를 사용하세요. 단, 굉" +"장히 많은 데이터를 생성할 수 있는 TRACE는 조심하세요" + +#: lib/cli/args.py:222 +msgid "Path to store the logfile. Leave blank to store in the faceswap folder" +msgstr "로그파일을 저장할 경로. faceswap 폴더에 저장하고 싶으면 비워두세요" + +#: lib/cli/args.py:320 lib/cli/args.py:329 lib/cli/args.py:337 +#: lib/cli/args.py:386 lib/cli/args.py:677 lib/cli/args.py:686 +msgid "Data" +msgstr "데이터" + +#: lib/cli/args.py:321 +msgid "" +"Input directory or video. Either a directory containing the image files you " +"wish to process or path to a video file. NB: This should be the source video/" +"frames NOT the source faces." +msgstr "" +"폴더나 비디오를 입력하세요. 당신이 사용하고 싶은 이미지 파일들을 가진 폴더 또" +"는 비디오 파일의 경로여야 합니다. NB: 이 폴더는 원본 비디오여야 합니다." + +#: lib/cli/args.py:330 +msgid "Output directory. This is where the converted files will be saved." +msgstr "출력 폴더. 변환된 파일들이 저장될 곳입니다." + +#: lib/cli/args.py:338 +msgid "" +"Optional path to an alignments file. Leave blank if the alignments file is " +"at the default location." +msgstr "" +"(선택적) alignments 파일의 경로. 비워두면 alignments 파일이 기본 위치에 저장" +"됩니다." + +#: lib/cli/args.py:361 +msgid "" +"Extract faces from image or video sources.\n" +"Extraction plugins can be configured in the 'Settings' Menu" +msgstr "" +"얼굴들을 이미지 또는 비디오에서 추출합니다.\n" +"추출 플러그인은 '설정' 메뉴에서 설정할 수 있습니다" + +#: lib/cli/args.py:387 +msgid "" +"R|If selected then the input_dir should be a parent folder containing " +"multiple videos and/or folders of images you wish to extract from. The faces " +"will be output to separate sub-folders in the output_dir." +msgstr "" +"R|만약 선택된다면 input_dir은 당신이 추출하고자 하는 여러개의 비디오 그리고/" +"또는 이미지들을 가진 부모 폴더가 되야 합니다. 얼굴들은 output_dir에 분리된 하" +"위 폴더에 저장됩니다." + +#: lib/cli/args.py:396 lib/cli/args.py:412 lib/cli/args.py:424 +#: lib/cli/args.py:463 lib/cli/args.py:481 lib/cli/args.py:493 +#: lib/cli/args.py:502 lib/cli/args.py:511 lib/cli/args.py:696 +#: lib/cli/args.py:723 lib/cli/args.py:761 +msgid "Plugins" +msgstr "플러그인들" + +#: lib/cli/args.py:397 +msgid "" +"R|Detector to use. Some of these have configurable settings in '/config/" +"extract.ini' or 'Settings > Configure Extract 'Plugins':\n" +"L|cv2-dnn: A CPU only extractor which is the least reliable and least " +"resource intensive. Use this if not using a GPU and time is important.\n" +"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " +"than other GPU detectors but can often return more false positives.\n" +"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " +"fewer false positives than other GPU detectors, but is a lot more resource " +"intensive." +msgstr "" +"R|사용할 감지기. 몇몇 감지기들은 '/config/extract.ini' 또는 '설정 > 추출 플러" +"그인 설정'에서 설정이 가능합니다:\n" +"L|cv2-dnn: 가장 믿을 수 없고 가장 자원을 덜 사용하며 CPU만을 사용하는 추출기" +"입니다. 만약 GPU를 사용하지 않고 시간이 중요하다면 사용하세요.\n" +"L|mtcnn: 좋은 감지기. CPU에서도 빠르고 GPU에서도 빠릅니다. 다른 GPU 감지기들" +"보다 더 적은 자원을 사용하지만 가끔 더 많은 false positives를 돌려줄 수 있습" +"니다.\n" +"L|s3fd: 가장 좋은 감지기. CPU에선 느리고 GPU에선 빠릅니다. 다른 GPU 감지기들" +"보다 더 많은 얼굴들을 감지할 수 있고 과 더 적은 false positives를 돌려주지만 " +"자원을 굉장히 많이 사용합니다." + +#: lib/cli/args.py:413 +msgid "" +"R|Aligner to use.\n" +"L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " +"but less accurate. Only use this if not using a GPU and time is important.\n" +"L|fan: Best aligner. Fast on GPU, slow on CPU." +msgstr "" +"R|사용할 Aligner.\n" +"L|cv2-dnn: CPU만을 사용하는 특징점 감지기. 빠르고 자원을 덜 사용하지만 부정확" +"합니다. GPU를 사용하지 않고 시간이 중요할 때에만 사용하세요.\n" +"L|fan: 가장 좋은 aligner. GPU에선 빠르고 CPU에선 느립니다." + +#: lib/cli/args.py:425 +msgid "" +"R|Additional Masker(s) to use. The masks generated here will all take up GPU " +"RAM. You can select none, one or multiple masks, but the extraction may take " +"longer the more you select. NB: The Extended and Components (landmark based) " +"masks are automatically generated on extraction.\n" +"L|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).\n" +"L|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 so will not use any additional VRAM.\n" +"L|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.\n" +"L|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.\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.\n" +"The auto generated masks are as follows:\n" +"L|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.\n" +"L|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.\n" +"(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" +msgstr "" +"R|사용할 추가 Mask입니다. 여기서 생성된 마스크는 모두 GPU RAM을 차지합니다. " +"마스크를 0개, 1개 또는 여러 개 선택할 수 있지만 더 많이 선택할수록 추출에 시" +"간이 더 걸릴 수 있습니다. NB: 확장 및 구성 요소(특징점 기반) 마스크는 추출 " +"시 자동으로 생성됩니다.\n" +"L|bisnet-fp: 전체 헤드 마스킹(마스크 설정에서 구성 가능)을 포함하여 마스킹할 " +"영역에 대한 보다 정교한 제어를 제공하는 비교적 가벼운 NN 기반 마스크입니다.\n" +"L|custom: 마스크 영역을 모든 1 또는 0으로 채우는 dummy 마스크입니다(설정에서 " +"구성 가능). 수동 도구에서 사용자 정의 마스크를 직접 수동으로 편집하려는 경우" +"에만 필요합니다. 이 마스크는 GPU를 사용하지 않으므로 추가 VRAM을 사용하지 않" +"습니다.\n" +"L|vgg-clear: 대부분의 정면에 장애물이 없는 스마트한 분할을 제공하도록 설계된 " +"마스크입니다. 프로필 얼굴들 및 장애물들로 인해 성능이 저하될 수 있습니다.\n" +"L|vgg-obstructed: 대부분의 정면 얼굴을 스마트하게 분할할 수 있도록 설계된 마" +"스크입니다. 마스크 모델은 일부 안면 장애물(손과 안경)을 인식하도록 특별히 훈" +"련되었습니다. 프로필 얼굴들은 평균 이하의 성능을 초래할 수 있습니다.\n" +"L|unet-dfl: 대부분 정면 얼굴을 스마트하게 분할하도록 설계된 마스크. 마스크 모" +"델은 커뮤니티 구성원들에 의해 훈련되었으며 추가 설명을 위해 테스트가 필요하" +"다. 프로필 얼굴들은 평균 이하의 성능을 초래할 수 있습니다.\n" +"자동 생성 마스크는 다음과 같습니다.\n" +"L|components: 특징점 위치의 위치를 기반으로 얼굴 분할을 제공하도록 설계된 마" +"스크입니다. 특징점의 외부에는 마스크를 만들기 위해 convex hull가 형성되어 있" +"습니다.\n" +"L|extended: 특징점 위치의 위치를 기반으로 얼굴 분할을 제공하도록 설계된 마스" +"크입니다. 특징점의 외부에는 convex hull가 형성되어 있으며, 마스크는 이마 위" +"로 뻗어 있습ㄴ다.\n" +"(예: '-M unet-dfl vgg-clear', '--masker vgg-obstructed')" + +#: lib/cli/args.py:464 +msgid "" +"R|Performing normalization can help the aligner better align faces with " +"difficult lighting conditions at an extraction speed cost. Different methods " +"will yield different results on different sets. NB: This does not impact the " +"output face, just the input to the aligner.\n" +"L|none: Don't perform normalization on the face.\n" +"L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " +"face.\n" +"L|hist: Equalize the histograms on the RGB channels.\n" +"L|mean: Normalize the face colors to the mean." +msgstr "" +"R|정규화를 수행하면 aligner가 추출 속도 비용으로 어려운 조명 조건의 얼굴을 " +"더 잘 정렬할 수 있습니다. 방법이 다르면 세트마다 결과가 다릅니다. NB: 출력 얼" +"굴에는 영향을 주지 않으며 aligner에 대한 입력에만 영향을 줍니다.\n" +"L|none: 얼굴에 정규화를 수행하지 마십시오.\n" +"L|clahe: 얼굴에 Contrast Limited Adaptive Histogram Equalization를 수행합니" +"다.\n" +"L|hist: RGB 채널의 히스토그램을 동일하게 합니다.\n" +"L|mean: 얼굴 색상을 평균으로 정규화합니다." + +#: lib/cli/args.py:482 +msgid "" +"The number of times to re-feed the detected face into the aligner. Each time " +"the face is re-fed into the aligner the bounding box is adjusted by a small " +"amount. The final landmarks are then averaged from each iteration. Helps to " +"remove 'micro-jitter' but at the cost of slower extraction speed. The more " +"times the face is re-fed into the aligner, the less micro-jitter should " +"occur but the longer extraction will take." +msgstr "" +"검출된 얼굴을 aligner에 다시 공급하는 횟수입니다. 얼굴이 aligner에 다시 공급" +"될 때마다 경계 상자가 소량 조정됩니다. 그런 다음 각 반복에서 최종 특징점의 평" +"균을 구한다. 'micro-jitter'를 제거하는 데 도움이 되지만 추출 속도가 느려집니" +"다. 얼굴이 aligner에 다시 공급되는 횟수가 많을수록 micro-jitter 적게 발생하지" +"만 추출에 더 오랜 시간이 걸립니다." + +#: lib/cli/args.py:494 +msgid "" +"Re-feed the initially found aligned face through the aligner. Can help " +"produce better alignments for faces that are rotated beyond 45 degrees in " +"the frame or are at extreme angles. Slows down extraction." +msgstr "" +"_aligner를 통해 처음 발견된 정렬된 얼굴을 재공급합니다. 프레임에서 45도 이상 " +"회전하거나 극단적인 각도에 있는 얼굴을 더 잘 정렬할 수 있습니다. 추출 속도가 " +"느려집니다." + +#: lib/cli/args.py:503 +msgid "" +"If a face isn't found, rotate the images to try to find a face. Can find " +"more faces at the cost of extraction speed. Pass in a single number to use " +"increments of that size up to 360, or pass in a list of numbers to enumerate " +"exactly what angles to check." +msgstr "" +"얼굴이 발견되지 않으면 이미지를 회전하여 얼굴을 찾습니다. 추출 속도를 희생하" +"면서 더 많은 얼굴을 찾을 수 있습니다. 단일 숫자를 입력하여 해당 크기의 증분" +"을 360까지 사용하거나 숫자 목록을 입력하여 확인할 각도를 정확하게 열거합니다." + +#: lib/cli/args.py:512 +msgid "" +"Obtain and store face identity encodings from VGGFace2. Slows down extract a " +"little, but will save time if using 'sort by face'" +msgstr "" +"VGGFace2에서 얼굴 식별 인코딩을 가져와 저장합니다. 추출 속도를 약간 늦추지만 " +"'얼굴별로 정렬'을 사용하면 시간을 절약할 수 있습니다." + +#: lib/cli/args.py:522 lib/cli/args.py:532 lib/cli/args.py:544 +#: lib/cli/args.py:557 lib/cli/args.py:798 lib/cli/args.py:812 +#: lib/cli/args.py:825 lib/cli/args.py:839 +msgid "Face Processing" +msgstr "얼굴 처리" + +#: lib/cli/args.py:523 +msgid "" +"Filters out faces detected below this size. Length, in pixels across the " +"diagonal of the bounding box. Set to 0 for off" +msgstr "" +"이 크기 미만으로 탐지된 얼굴을 필터링합니다. 길이, 경계 상자의 대각선에 걸친 " +"픽셀 단위입니다. 0으로 설정하면 꺼집니다" + +#: lib/cli/args.py:533 +msgid "" +"Optionally filter out people who you do not wish to extract by passing in " +"images of those people. Should be a small variety of images at different " +"angles and in different conditions. A folder containing the required images " +"or multiple image files, space separated, can be selected." +msgstr "" +"선택적으로 추출하지 않을 사람의 이미지들을 전달하여 그 사람들을 제외합니다. " +"각도와 조건이 다른 작은 다양한 이미지여야 합니다. 추출되지 않는데 필요한 이미" +"지들 또는 공백으로 구분된 여러 이미지 파일이 들어 있는 폴더를 선택할 수 있습" +"니다." + +#: lib/cli/args.py:545 +msgid "" +"Optionally select people you wish to extract by passing in images of that " +"person. Should be a small variety of images at different angles and in " +"different conditions A folder containing the required images or multiple " +"image files, space separated, can be selected." +msgstr "" +"선택적으로 추출하고 싶은 사람의 이미지를 전달하여 그 사람을 선택합니다. 각도" +"와 조건이 다른 작은 다양한 이미지여야 합니다. 추출할 때 필요한 이미지들 또는 " +"공백으로 구분된 여러 이미지 파일이 들어 있는 폴더를 선택할 수 있습니다." + +#: lib/cli/args.py:558 +msgid "" +"For use with the optional nfilter/filter files. Threshold for positive face " +"recognition. Higher values are stricter." +msgstr "" +"옵션인 nfilter/filter 파일과 함께 사용합니다. 긍정적인 얼굴 인식을 위한 임계" +"값. 값이 높을수록 엄격합니다." + +#: lib/cli/args.py:567 lib/cli/args.py:579 lib/cli/args.py:591 +#: lib/cli/args.py:603 +msgid "output" +msgstr "출력" + +#: lib/cli/args.py:568 +msgid "" +"The output size of extracted faces. Make sure that the model you intend to " +"train supports your required size. This will only need to be changed for hi-" +"res models." +msgstr "" +"추출된 얼굴의 출력 크기입니다. 훈련하려는 모델이 필요한 크기를 지원하는지 꼭 " +"확인하세요. 이것은 고해상도 모델에 대해서만 변경하면 됩니다." + +#: lib/cli/args.py:580 +msgid "" +"Extract every 'nth' frame. This option will skip frames when extracting " +"faces. For example a value of 1 will extract faces from every frame, a value " +"of 10 will extract faces from every 10th frame." +msgstr "" +"모든 'n번째' 프레임을 추출합니다. 이 옵션은 얼굴을 추출할 때 건너뛸 프레임을 " +"설정합니다. 예를 들어, 값이 1이면 모든 프레임에서 얼굴이 추출되고, 값이 10이" +"면 모든 10번째 프레임에서 얼굴이 추출됩니다." + +#: lib/cli/args.py:592 +msgid "" +"Automatically save the alignments file after a set amount of frames. By " +"default the alignments file is only saved at the end of the extraction " +"process. NB: If extracting in 2 passes then the alignments file will only " +"start to be saved out during the second pass. WARNING: Don't interrupt the " +"script when writing the file because it might get corrupted. Set to 0 to " +"turn off" +msgstr "" +"프레임 수가 설정된 후 alignments 파일을 자동으로 저장합니다. 기본적으로 " +"alignments 파일은 추출 프로세스가 끝날 때만 저장됩니다. NB: 2번째 추출에서 성" +"공하면 두 번째 추출 중에만 alignments 파일이 저장되기 시작합니다. 경고: 파일" +"을 쓸 때 스크립트가 손상될 수 있으므로 스크립트를 중단하지 마십시오. 해제하려" +"면 0으로 설정" + +#: lib/cli/args.py:604 +msgid "Draw landmarks on the ouput faces for debugging purposes." +msgstr "디버깅을 위해 출력 얼굴에 특징점을 그립니다." + +#: lib/cli/args.py:610 lib/cli/args.py:619 lib/cli/args.py:627 +#: lib/cli/args.py:634 lib/cli/args.py:852 lib/cli/args.py:863 +#: lib/cli/args.py:871 lib/cli/args.py:890 lib/cli/args.py:896 +msgid "settings" +msgstr "설정" + +#: lib/cli/args.py:611 +msgid "" +"Don't run extraction in parallel. Will run each part of the extraction " +"process separately (one after the other) rather than all at the smae time. " +"Useful if VRAM is at a premium." +msgstr "" +"추출을 병렬로 실행하지 마십시오. 추출 프로세스의 각 부분을 동시에 모두 실행하" +"는 것이 아니라 개별적으로(하나씩) 실행합니다. VRAM이 프리미엄인 경우 유용합니" +"다." + +#: lib/cli/args.py:620 +msgid "" +"Skips frames that have already been extracted and exist in the alignments " +"file" +msgstr "이미 추출되었거나 alignments 파일에 존재하는 프레임들을 스킵합니다" + +#: lib/cli/args.py:628 +msgid "Skip frames that already have detected faces in the alignments file" +msgstr "이미 얼굴을 탐지하여 alignments 파일에 존재하는 프레임들을 스킵합니다" + +#: lib/cli/args.py:635 +msgid "Skip saving the detected faces to disk. Just create an alignments file" +msgstr "" +"탐지된 얼굴을 디스크에 저장하지 않습니다. 그저 alignments 파일을 만듭니다" + +#: lib/cli/args.py:657 +msgid "" +"Swap the original faces in a source video/images to your final faces.\n" +"Conversion plugins can be configured in the 'Settings' Menu" +msgstr "" +"원본 비디오/이미지의 원래 얼굴을 최종 얼굴으로 바꿉니다.\n" +"변환 플러그인은 '설정' 메뉴에서 구성할 수 있습니다" + +#: lib/cli/args.py:678 +msgid "" +"Only required if converting from images to video. Provide The original video " +"that the source frames were extracted from (for extracting the fps and " +"audio)." +msgstr "" +"이미지에서 비디오로 변환하는 경우에만 필요합니다. 소스 프레임이 추출된 원본 " +"비디오(fps 및 오디오 추출용)를 입력하세요." + +#: lib/cli/args.py:687 +msgid "" +"Model directory. The directory containing the trained model you wish to use " +"for conversion." +msgstr "" +"모델 폴더. 당신이 변환에 사용하고자 하는 훈련된 모델을 가진 폴더입니다." + +#: lib/cli/args.py:697 +msgid "" +"R|Performs color adjustment to the swapped face. Some of these options have " +"configurable settings in '/config/convert.ini' or 'Settings > Configure " +"Convert Plugins':\n" +"L|avg-color: Adjust the mean of each color channel in the swapped " +"reconstruction to equal the mean of the masked area in the original image.\n" +"L|color-transfer: Transfers the color distribution from the source to the " +"target image using the mean and standard deviations of the L*a*b* color " +"space.\n" +"L|manual-balance: Manually adjust the balance of the image in a variety of " +"color spaces. Best used with the Preview tool to set correct values.\n" +"L|match-hist: Adjust the histogram of each color channel in the swapped " +"reconstruction to equal the histogram of the masked area in the original " +"image.\n" +"L|seamless-clone: Use cv2's seamless clone function to remove extreme " +"gradients at the mask seam by smoothing colors. Generally does not give very " +"satisfactory results.\n" +"L|none: Don't perform color adjustment." +msgstr "" +"R|스왑된 얼굴의 색상 조정을 수행합니다. 이러한 옵션 중 일부에는 '/config/" +"convert.ini' 또는 '설정 > 변환 플러그인 구성'에서 구성 가능한 설정이 있습니" +"다.\n" +"L|avg-color: 스왑된 재구성에서 각 색상 채널의 평균이 원본 영상에서 마스킹된 " +"영역의 평균과 동일하도록 조정합니다.\n" +"L|color-transfer: L*a*b* 색 공간의 평균 및 표준 편차를 사용하여 소스에서 대" +"상 이미지로 색 분포를 전송합니다.\n" +"L|manual-balance: 다양한 색 공간에서 이미지의 밸런스를 수동으로 조정합니다. " +"올바른 값을 설정하려면 미리 보기 도구와 함께 사용하는 것이 좋습니다.\n" +"L|match-hist: 스왑된 재구성에서 각 색상 채널의 히스토그램을 조정하여 원래 영" +"상에서 마스킹된 영역의 히스토그램과 동일하게 만듭니다.\n" +"L|seamless-clone: cv2의 원활한 복제 기능을 사용하여 색상을 평활화하여 마스크 " +"심에서 극단적인 gradients을 제거합니다. 일반적으로 매우 만족스러운 결과를 제" +"공하지 않습니다.\n" +"L|none: 색상 조정을 수행하지 않습니다." + +#: lib/cli/args.py:724 +msgid "" +"R|Masker to use. NB: The mask you require must exist within the alignments " +"file. You can add additional masks with the Mask Tool.\n" +"L|none: Don't use a mask.\n" +"L|bisenet-fp_face: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'face' or " +"'legacy' centering.\n" +"L|bisenet-fp_head: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'head' " +"centering.\n" +"L|custom_face: Custom user created, face centered mask.\n" +"L|custom_head: Custom user created, head centered mask.\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\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.\n" +"L|predicted: If the 'Learn Mask' option was enabled during training, this " +"will use the mask that was created by the trained model." +msgstr "" +"R|사용할 마스크. NB: 필요한 마스크는 alignments 파일 내에 있어야 합니다. 마스" +"크 도구를 사용하여 마스크를 추가할 수 있습니다.\n" +"L|none: 마스크 쓰지 마세요.\n" +"L|bisnet-fp_face: 마스크할 영역을 보다 정교하게 제어할 수 있는 비교적 가벼운 " +"NN 기반 마스크입니다(마스크 설정에서 구성 가능). 모델이 '얼굴' 또는 '레거시' " +"중심으로 훈련된 경우 이 버전의 bisnet-fp를 사용하십시오.\n" +"L|bisnet-fp_head: 마스크할 영역을 보다 정교하게 제어할 수 있는 비교적 가벼운 " +"NN 기반 마스크입니다(마스크 설정에서 구성 가능). 모델이 '헤드' 중심으로 훈련" +"된 경우 이 버전의 bisnet-fp를 사용하십시오.\n" +"L|custom_face: 사용자 지정 사용자가 생성한 얼굴 중심 마스크입니다.\n" +"L|custom_head: 사용자 지정 사용자가 생성한 머리 중심 마스크입니다.\n" +"L|components: 특징점 위치의 배치를 기반으로 얼굴 분할을 제공하도록 설계된 마" +"스크입니다. 특징점의 외부에는 마스크를 만들기 위해 convex hull가 형성되어 있" +"습니다.\n" +"L|extended: 특징점 위치의 배치를 기반으로 얼굴 분할을 제공하도록 설계된 마스" +"크입니다. 지형지물의 외부에는 convex hull가 형성되어 있으며, 마스크는 이마 위" +"로 뻗어 있습니다.\n" +"L|vgg-clear: 대부분의 정면에 장애물이 없는 스마트한 분할을 제공하도록 설계된 " +"마스크입니다. 옆 얼굴 및 장애물로 인해 성능이 저하될 수 있습니다.\n" +"L|vgg-obstructed: 대부분의 정면 얼굴을 스마트하게 분할할 수 있도록 설계된 마" +"스크입니다. 마스크 모델은 일부 안면 장애물(손과 안경)을 인식하도록 특별히 훈" +"련되었습니다. 옆 얼굴은 평균 이하의 성능을 초래할 수 있습니다.\n" +"L|unet-dfl: 대부분 정면 얼굴을 스마트하게 분할하도록 설계된 마스크. 마스크 모" +"델은 커뮤니티 구성원들에 의해 훈련되었으며 추가 설명을 위해 테스트가 필요하" +"다. 옆 얼굴은 평균 이하의 성능을 초래할 수 있습니다.\n" +"L|predicted: 교육 중에 'Learn Mask(마스크 학습)' 옵션이 활성화된 경우에는 교" +"육을 받은 모델이 만든 마스크가 사용됩니다." + +#: lib/cli/args.py:762 +msgid "" +"R|The plugin to use to output the converted images. The writers are " +"configurable in '/config/convert.ini' or 'Settings > Configure Convert " +"Plugins:'\n" +"L|ffmpeg: [video] Writes out the convert straight to video. When the input " +"is a series of images then the '-ref' (--reference-video) parameter must be " +"set.\n" +"L|gif: [animated image] Create an animated gif.\n" +"L|opencv: [images] The fastest image writer, but less options and formats " +"than other plugins.\n" +"L|pillow: [images] Slower than opencv, but has more options and supports " +"more formats." +msgstr "" +"R|변환된 이미지를 출력하는 데 사용할 플러그인입니다. 기록 장치는 '/config/" +"convert.ini' 또는 '설정 > 변환 플러그인 구성:'에서 구성할 수 있습니다.\n" +"L|ffmpeg: [video] 변환된 결과를 바로 video로 씁니다. 입력이 영상 시리즈인 경" +"우 '-ref'(--reference-video) 파라미터를 설정해야 합니다.\n" +"L|gif : [애니메이션 이미지] 애니메이션 gif를 만듭니다.\n" +"L|opencv: [이미지] 가장 빠른 이미지 작성기이지만 다른 플러그인에 비해 옵션과 " +"형식이 적습니다.\n" +"L|pillow: [images] opencv보다 느리지만 더 많은 옵션이 있고 더 많은 형식을 지" +"원합니다." + +#: lib/cli/args.py:781 lib/cli/args.py:788 lib/cli/args.py:882 +msgid "Frame Processing" +msgstr "프레임 처리" + +#: lib/cli/args.py:782 +#, python-format +msgid "" +"Scale the final output frames by this amount. 100%% will output the frames " +"at source dimensions. 50%% at half size 200%% at double size" +msgstr "" +"최종 출력 프레임의 크기를 이 양만큼 조정합니다. 100%%는 원본의 차원에서 프레" +"임을 출력합니다. 50%%는 절반 크기에서, 200%%는 두 배 크기에서" + +#: lib/cli/args.py:789 +msgid "" +"Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " +"--frame-ranges 10-50 90-100. Frames falling outside of the selected range " +"will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " +"converting from images, then the filenames must end with the frame-number!" +msgstr "" +"예를 들어 전송을 적용할 프레임 범위 프레임 10 - 50 및 90 - 100의 경우 --" +"frame-ranges 10-50 90-100을 사용합니다. '-k'(--keep-unchanged)를 선택하지 않" +"으면 선택한 범위를 벗어나는 프레임이 삭제됩니다. NB: 이미지에서 변환하는 경" +"우 파일 이름은 프레임 번호로 끝나야 합니다!" + +#: lib/cli/args.py:799 +msgid "" +"If you have not cleansed your alignments file, then you can filter out faces " +"by defining a folder here that contains the faces extracted from your input " +"files/video. If this folder is defined, then only faces that exist within " +"your alignments file and also exist within the specified folder will be " +"converted. Leaving this blank will convert all faces that exist within the " +"alignments file." +msgstr "" +"만약 alignments 파일을 지우지 않은 경우 입력 파일/비디오에서 추출된 얼굴이 포" +"함된 폴더를 정의하여 얼굴을 걸러낼 수 있습니다. 이 폴더가 정의된 경우 " +"alignments 파일 내에 존재하거나 지정된 폴더 내에 존재하는 얼굴만 변환됩니다. " +"이 항목을 공백으로 두면 alignments 파일 내에 있는 모든 얼굴이 변환됩니다." + +#: lib/cli/args.py:813 +msgid "" +"Optionally filter out people who you do not wish to process by passing in an " +"image of that person. Should be a front portrait with a single person in the " +"image. Multiple images can be added space separated. NB: Using face filter " +"will significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"선택적으로 처리하고 싶지 않은 사람의 이미지를 전달하여 그 사람을 걸러낼 수 있" +"습니다. 이미지는 한 사람의 정면 모습이여야 합니다. 여러 이미지를 공백으로 구" +"분하여 추가할 수 있습니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감소" +"하므로 정확성을 보장할 수 없습니다." + +#: lib/cli/args.py:826 +msgid "" +"Optionally select people you wish to process by passing in an image of that " +"person. Should be a front portrait with a single person in the image. " +"Multiple images can be added space separated. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"선택적으로 해당 사용자의 이미지를 전달하여 처리할 사용자를 선택합니다. 이미지" +"에 한 사람이 있는 정면 초상화여야 합니다. 여러 이미지를 공백으로 구분하여 추" +"가할 수 있습니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감소하므로 정" +"확성을 보장할 수 없습니다." + +#: lib/cli/args.py:840 +msgid "" +"For use with the optional nfilter/filter files. Threshold for positive face " +"recognition. Lower values are stricter. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"옵션인 nfilter/filter 파일을 함께 사용합니다. 긍정적인 얼굴 인식을 위한 임계" +"값. 낮은 값이 더 엄격합니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감" +"소하므로 정확성을 보장할 수 없습니다." + +#: lib/cli/args.py:853 +msgid "" +"The maximum number of parallel processes for performing conversion. " +"Converting images is system RAM heavy so it is possible to run out of memory " +"if you have a lot of processes and not enough RAM to accommodate them all. " +"Setting this to 0 will use the maximum available. No matter what you set " +"this to, it will never attempt to use more processes than are available on " +"your system. If singleprocess is enabled this setting will be ignored." +msgstr "" +"변환을 수행하기 위한 최대 병렬 프로세스 수입니다. 이미지 변환은 시스템 RAM에 " +"부담이 크기 때문에 프로세스가 많고 모든 프로세스를 수용할 RAM이 충분하지 않" +"은 경우 메모리가 부족할 수 있습니다. 이것을 0으로 설정하면 사용 가능한 최대값" +"을 사용합니다. 얼마를 설정하든 시스템에서 사용 가능한 것보다 더 많은 프로세스" +"를 사용하려고 시도하지 않습니다. 단일 프로세스가 활성화된 경우 이 설정은 무시" +"됩니다." + +#: lib/cli/args.py:864 +msgid "" +"[LEGACY] This only needs to be selected if a legacy model is being loaded or " +"if there are multiple models in the model folder" +msgstr "" +"[LEGACY] 이것은 레거시 모델을 로드 중이거나 모델 폴더에 여러 모델이 있는 경우" +"에만 선택되어야 합니다" + +#: lib/cli/args.py:872 +msgid "" +"Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " +"alignments file for your destination video. However, if you wish you can " +"generate the alignments on-the-fly by enabling this option. This will use an " +"inferior extraction pipeline and will lead to substandard results. If an " +"alignments file is found, this option will be ignored." +msgstr "" +"실시간 변환을 활성화합니다. 권장하지 않습니다. 당신은 변환 비디오에 대한 깨끗" +"한 alignments 파일을 생성해야 합니다. 그러나 원하는 경우 이 옵션을 활성화하" +"여 즉시 alignments 파일을 생성할 수 있습니다. 이것은 안좋은 추출 과정을 사용" +"하고 표준 이하의 결과로 이어질 것입니다. alignments 파일이 발견되면 이 옵션" +"은 무시됩니다." + +#: lib/cli/args.py:883 +msgid "" +"When used with --frame-ranges outputs the unchanged frames that are not " +"processed instead of discarding them." +msgstr "" +"사용시 --frame-ranges 인자를 사용하면 변경되지 않은 프레임을 버리지 않은 결과" +"가 출력됩니다." + +#: lib/cli/args.py:891 +msgid "Swap the model. Instead converting from of A -> B, converts B -> A" +msgstr "모델을 바꿉니다. A -> B에서 변환하는 대신 B -> A로 변환" + +#: lib/cli/args.py:897 +msgid "Disable multiprocessing. Slower but less resource intensive." +msgstr "멀티프로세싱을 쓰지 않습니다. 느리지만 자원을 덜 소모합니다." + +#: lib/cli/args.py:913 +msgid "" +"Train a model on extracted original (A) and swap (B) faces.\n" +"Training models can take a long time. Anything from 24hrs to over a week\n" +"Model plugins can be configured in the 'Settings' Menu" +msgstr "" +"추출된 원래(A) 얼굴과 스왑(B) 얼굴에 대한 모델을 훈련합니다.\n" +"모델을 훈련하는 데 시간이 오래 걸릴 수 있습니다. 24시간에서 일주일 이상의 시" +"간이 필요합니다.\n" +"모델 플러그인은 '설정' 메뉴에서 구성할 수 있습니다" + +#: lib/cli/args.py:932 lib/cli/args.py:941 +msgid "faces" +msgstr "얼굴들" + +#: lib/cli/args.py:933 +msgid "" +"Input directory. A directory containing training images for face A. This is " +"the original face, i.e. the face that you want to remove and replace with " +"face B." +msgstr "" +"입력 디렉토리. 얼굴 A에 대한 훈련 이미지가 포함된 디렉토리입니다. 이것은 원" +"래 얼굴, 즉 제거하고 B 얼굴로 대체하려는 얼굴입니다." + +#: lib/cli/args.py:942 +msgid "" +"Input directory. A directory containing training images for face B. This is " +"the swap face, i.e. the face that you want to place onto the head of person " +"A." +msgstr "" +"입력 디렉터리. 얼굴 B에 대한 훈련 이미지를 포함하는 디렉토리. 이것은 대체 얼" +"굴, 즉 사람 A의 얼굴 앞에 배치하려는 얼굴이다." + +#: lib/cli/args.py:950 lib/cli/args.py:962 lib/cli/args.py:978 +#: lib/cli/args.py:1003 lib/cli/args.py:1013 +msgid "model" +msgstr "모델" + +#: lib/cli/args.py:951 +msgid "" +"Model directory. This is where the training data will be stored. You should " +"always specify a new folder for new models. If starting a new model, select " +"either an empty folder, or a folder which does not exist (which will be " +"created). If continuing to train an existing model, specify the location of " +"the existing model." +msgstr "" +"모델 디렉토리. 여기에 훈련 데이터가 저장됩니다. 새 모델의 경우 항상 새 폴더" +"를 지정해야 합니다. 새 모델을 시작할 경우 빈 폴더 또는 존재하지 않는 폴더(생" +"성될 폴더)를 선택합니다. 기존 모델을 계속 학습하는 경우 기존 모델의 위치를 지" +"정합니다." + +#: lib/cli/args.py:963 +msgid "" +"R|Load the weights from a pre-existing model into a newly created model. For " +"most models this will load weights from the Encoder of the given model into " +"the encoder of the newly created model. Some plugins may have specific " +"configuration options allowing you to load weights from other layers. " +"Weights will only be loaded when creating a new model. This option will be " +"ignored if you are resuming an existing model. Generally you will also want " +"to 'freeze-weights' whilst the rest of your model catches up with your " +"Encoder.\n" +"NB: Weights can only be loaded from models of the same plugin as you intend " +"to train." +msgstr "" +"R|기존 모델의 가중치를 새로 생성된 모델로 로드합니다. 대부분의 모델에서는 주" +"어진 모델의 인코더에서 새로 생성된 모델의 인코더로 가중치를 로드합니다. 일부 " +"플러그인에는 다른 층에서 가중치를 로드할 수 있는 특정 구성 옵션이 있을 수 있" +"습니다. 가중치는 새 모델을 생성할 때만 로드됩니다. 기존 모델을 재개하는 경우 " +"이 옵션은 무시됩니다. 일반적으로 나머지 모델이 인코더를 따라잡는 동안에도 '가" +"중치 동결'이 필요합니다.\n" +"주의: 가중치는 훈련하려는 플러그인 모델에서만 로드할 수 있습니다." + +#: lib/cli/args.py:979 +msgid "" +"R|Select which trainer to use. Trainers can be configured from the Settings " +"menu or the config folder.\n" +"L|original: The original model created by /u/deepfakes.\n" +"L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " +"for full dfaker method.\n" +"L|dfl-h128: 128px in/out model from deepfacelab\n" +"L|dfl-sae: Adaptable model from deepfacelab\n" +"L|dlight: A lightweight, high resolution DFaker variant.\n" +"L|iae: A model that uses intermediate layers to try to get better details\n" +"L|lightweight: A lightweight model for low-end cards. Don't expect great " +"results. Can train as low as 1.6GB with batch size 8.\n" +"L|realface: A high detail, dual density model based on DFaker, with " +"customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " +"won't work so well. By andenixa et al. Very configurable.\n" +"L|unbalanced: 128px in/out model from andenixa. The autoencoders are " +"unbalanced so B>A swaps won't work so well. Very configurable.\n" +"L|villain: 128px in/out model from villainguy. Very resource hungry (You " +"will require a GPU with a fair amount of VRAM). Good for details, but more " +"susceptible to color differences." +msgstr "" +"R|사용할 훈련 모델을 선택합니다. 훈련 모델은 설정 메뉴 또는 구성 폴더에서 구" +"성할 수 있습니다.\n" +"L|original: /u/deepfakes로 만든 원래 모델입니다.\n" +"L|dfaker: 64px in/128px out 모델 from dfaker. Full dfaker 메서드에 대해 '특징" +"점으로 변환'를 활성화합니다.\n" +"L|dfl-h128: Deepfake lab의 128px in/out 모델\n" +"L|dfl-sae: Deepface Lab의 적응형 모델\n" +"L|dlight: 경량, 고해상도 DFaker 변형입니다.\n" +"L|iae: 중간 층들을 사용하여 더 나은 세부 정보를 얻기 위해 노력하는 모델.\n" +"L|lightweight: 저가형 카드용 경량 모델. 좋은 결과를 기대하지 마세요. 최대한 " +"낮게 잡아서 배치 사이즈 8에 1.6GB까지 훈련이 가능합니다.\n" +"L|realface: DFaker를 기반으로 한 높은 디테일의 이중 밀도 모델로, 사용자 정의 " +"가능한 입/출력 해상도를 제공합니다. 오토인코더가 불균형하여 B>A 스왑이 잘 작" +"동하지 않습니다. Andenixa 등에 의해. 매우 구성 가능합니다.\n" +"L|unbalanced: andenixa의 128px in/out 모델. 오토인코더가 불균형하여 B>A 스왑" +"이 잘 작동하지 않습니다. 매우 구성 가능합니다.\n" +"L|villain : villainguy의 128px in/out 모델. 리소스가 매우 부족합니다( 상당한 " +"양의 VRAM이 있는 GPU가 필요합니다). 세부 사항에는 좋지만 색상 차이에 더 취약" +"합니다." + +#: lib/cli/args.py:1004 +msgid "" +"Output a summary of the model and exit. If a model folder is provided then a " +"summary of the saved model is displayed. Otherwise a summary of the model " +"that would be created by the chosen plugin and configuration settings is " +"displayed." +msgstr "" +"모델 요약을 출력하고 종료합니다. 모델 폴더가 제공되면 저장된 모델의 요약이 표" +"시됩니다. 그렇지 않으면 선택한 플러그인 및 구성 설정에 의해 생성되는 모델 요" +"약이 표시됩니다." + +#: lib/cli/args.py:1014 +msgid "" +"Freeze the weights of the model. Freezing weights means that some of the " +"parameters in the model will no longer continue to learn, but those that are " +"not frozen will continue to learn. For most models, this will freeze the " +"encoder, but some models may have configuration options for freezing other " +"layers." +msgstr "" +"모델의 가중치를 동결합니다. 가중치를 고정하면 모델의 일부 매개변수가 더 이상 " +"학습되지 않지만 고정되지 않은 매개변수는 계속 학습됩니다. 대부분의 모델에서 " +"이렇게 하면 인코더가 고정되지만 일부 모델에는 다른 레이어를 고정하기 위한 구" +"성 옵션이 있을 수 있습니다." + +#: lib/cli/args.py:1027 lib/cli/args.py:1039 lib/cli/args.py:1050 +#: lib/cli/args.py:1061 lib/cli/args.py:1144 +msgid "training" +msgstr "훈련" + +#: lib/cli/args.py:1028 +msgid "" +"Batch size. This is the number of images processed through the model for " +"each side per iteration. NB: As the model is fed 2 sides at a time, the " +"actual number of images within the model at any one time is double the " +"number that you set here. Larger batches require more GPU RAM." +msgstr "" +"배치 크기. 반복당 각 측면에 대해 모델을 통해 처리되는 이미지 수입니다. NB: " +"한 번에 모델에게 2개의 측면이 공급되므로 한 번에 모델 내의 실제 이미지 수는 " +"여기에서 설정한 수의 두 배입니다. 더 큰 배치에는 더 많은 GPU RAM이 필요합니" +"다." + +#: lib/cli/args.py:1040 +msgid "" +"Length of training in iterations. This is only really used for automation. " +"There is no 'correct' number of iterations a model should be trained for. " +"You should stop training when you are happy with the previews. However, if " +"you want the model to stop automatically at a set number of iterations, you " +"can set that value here." +msgstr "" +"반복에서 훈련 길이. 이것은 실제로 자동화에만 사용됩니다. 모델을 훈련해야 하" +"는 '올바른' 반복 횟수는 없습니다. 미리 보기에 만족하면 훈련을 중단해야 합니" +"다. 그러나 설정된 반복 횟수에서 모델이 자동으로 중지되도록 하려면 여기에서 해" +"당 값을 설정할 수 있습니다." + +#: lib/cli/args.py:1051 +msgid "" +"[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " +"Mirrored Distrubution Strategy to train on multiple GPUs." +msgstr "" +"[Deprecated - 대신 '-D, --distribution-strategy' 사용] Tensorflow 미러 분산 " +"전략을 사용하여 여러 GPU에서 훈련합니다." + +#: lib/cli/args.py:1062 +msgid "" +"R|Select the distribution stategy to use.\n" +"L|default: Use Tensorflow's default distribution strategy.\n" +"L|central-storage: Centralizes variables on the CPU whilst operations are " +"performed on 1 or more local GPUs. This can help save some VRAM at the cost " +"of some speed by not storing variables on the GPU. Note: Mixed-Precision is " +"not supported on multi-GPU setups.\n" +"L|mirrored: Supports synchronous distributed training across multiple local " +"GPUs. A copy of the model and all variables are loaded onto each GPU with " +"batches distributed to each GPU at each iteration." +msgstr "" +"R|사용할 배포 상태를 선택합니다.\n" +"L|default: Tensorflow의 기본 배포 전략을 사용합니다.\n" +"L|central-storage: 작업이 1개 이상의 로컬 GPU에서 수행되는 동안 CPU의 변수를 " +"중앙 집중화합니다. 이렇게 하면 GPU에 변수를 저장하지 않음으로써 약간의 속도" +"를 희생하여 일부 VRAM을 절약할 수 있습니다. 참고: 다중 정밀도는 다중 GPU 설정" +"에서 지원되지 않습니다.\n" +"L|mirrored: 여러 로컬 GPU에서 동기화 분산 훈련을 지원합니다. 모델의 복사본과 " +"모든 변수는 각 반복에서 각 GPU에 배포된 배치들와 함께 각 GPU에 로드됩니다." + +#: lib/cli/args.py:1079 lib/cli/args.py:1089 +msgid "Saving" +msgstr "저장" + +#: lib/cli/args.py:1080 +msgid "Sets the number of iterations between each model save." +msgstr "각 모델 저장 사이의 반복 횟수를 설정합니다." + +#: lib/cli/args.py:1090 +msgid "" +"Sets the number of iterations before saving a backup snapshot of the model " +"in it's current state. Set to 0 for off." +msgstr "" +"현재 상태에서 모델의 백업 스냅샷을 저장하기 전에 반복할 횟수를 설정합니다. 0" +"으로 설정하면 꺼집니다." + +#: lib/cli/args.py:1097 lib/cli/args.py:1108 lib/cli/args.py:1119 +msgid "timelapse" +msgstr "타임랩스" + +#: lib/cli/args.py:1098 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. " +"This should be the input folder of 'A' faces that you would like to use for " +"creating the timelapse. You must also supply a --timelapse-output and a --" +"timelapse-input-B parameter." +msgstr "" +"타임랩스를 만드는 옵션입니다. Timelapse(시간 경과)는 저장을 반복할 때마다 선" +"택한 얼굴의 이미지를 Timelapse-output(시간 경과 출력) 폴더에 저장합니다. 타임" +"랩스를 만드는 데 사용할 'A' 얼굴의 입력 폴더여야 합니다. 또한 사용자는 --" +"timelapse-output 및 --timelapse-input-B 매개 변수를 제공해야 합니다." + +#: lib/cli/args.py:1109 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. " +"This should be the input folder of 'B' faces that you would like to use for " +"creating the timelapse. You must also supply a --timelapse-output and a --" +"timelapse-input-A parameter." +msgstr "" +"타임 랩스를 만드는 데 선택적입니다. Timelapse(시간 경과)는 저장을 반복할 때마" +"다 선택한 얼굴의 이미지를 Timelapse-output(시간 경과 출력) 폴더에 저장합니" +"다. 타임 랩스를 만드는 데 사용할 'B' 얼굴의 입력 폴더여야 합니다. 또한 사용자" +"는 --timelapse-output 및 --timelapse-input-A 매개 변수를 제공해야 합니다." + +#: lib/cli/args.py:1120 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. If " +"the input folders are supplied but no output folder, it will default to your " +"model folder /timelapse/" +msgstr "" +"타임랩스를 만드는 데 선택적입니다. Timelapse(시간 경과)는 저장을 반복할 때마" +"다 선택한 얼굴의 이미지를 Timelapse-output(시간 경과 출력) 폴더에 저장합니" +"다. 입력 폴더가 제공되었지만 출력 폴더가 없는 경우 모델 폴더에 /timelapse/로 " +"기본 설정됩니다" + +#: lib/cli/args.py:1129 lib/cli/args.py:1136 +msgid "preview" +msgstr "미리보기" + +#: lib/cli/args.py:1130 +msgid "Show training preview output. in a separate window." +msgstr "훈련 미리보기 결과를 각기 다른 창에서 보여줍니다." + +#: lib/cli/args.py:1137 +msgid "" +"Writes the training result to a file. The image will be stored in the root " +"of your FaceSwap folder." +msgstr "" +"훈련 결과를 파일에 씁니다. 이미지는 Faceswap 폴더의 최상위 폴더에 저장됩니다." + +#: lib/cli/args.py:1145 +msgid "" +"Disables TensorBoard logging. NB: Disabling logs means that you will not be " +"able to use the graph or analysis for this session in the GUI." +msgstr "" +"텐서보드 로깅을 비활성화합니다. 주의: 로그를 비활성화하면 GUI에서 이 세션에 " +"대한 그래프 또는 분석을 사용할 수 없습니다." + +#: lib/cli/args.py:1152 lib/cli/args.py:1161 lib/cli/args.py:1170 +#: lib/cli/args.py:1179 +msgid "augmentation" +msgstr "보정" + +#: lib/cli/args.py:1153 +msgid "" +"Warps training faces to closely matched Landmarks from the opposite face-set " +"rather than randomly warping the face. This is the 'dfaker' way of doing " +"warping." +msgstr "" +"무작위로 얼굴을 변환하지 않고 반대쪽 얼굴 세트에서 특징점과 밀접하게 일치하도" +"록 훈련 얼굴을 변환해줍니다. 이것은 변환하는 'dfaker' 방식이다." + +#: lib/cli/args.py:1162 +msgid "" +"To effectively learn, a random set of images are flipped horizontally. " +"Sometimes it is desirable for this not to occur. Generally this should be " +"left off except for during 'fit training'." +msgstr "" +"효과적으로 학습하기 위해 임의의 이미지 세트를 수평으로 뒤집습니다. 때때로 이" +"런 일이 일어나지 않는 것이 바람직합니다. 일반적으로 'fit training' 중을 제외" +"하고는 이 작업을 중단해야 합니다." + +#: lib/cli/args.py:1171 +msgid "" +"Color augmentation helps make the model less susceptible to color " +"differences between the A and B sets, at an increased training time cost. " +"Enable this option to disable color augmentation." +msgstr "" +"색상 보정은 모델이 A와 B 세트 사이의 색상 차이에 덜 민감하게 만드는 데 도움" +"이 되며, 훈련 시간 비용이 증가합니다. 색상 보저를 사용하지 않으려면 이 옵션" +"을 사용합니다." + +#: lib/cli/args.py:1180 +msgid "" +"Warping is integral to training the Neural Network. This option should only " +"be enabled towards the very end of training to try to bring out more detail. " +"Think of it as 'fine-tuning'. Enabling this option from the beginning is " +"likely to kill a model and lead to terrible results." +msgstr "" +"변환은 신경망을 훈련하는 데 필수적입니다. 이 옵션은 보다 세부적인 것들을 뽑아" +"내위하여 훈련 막바지까지 활성화하여야 합니다. 이것은 '미세 조정'이라고 생각하" +"면 됩니다. 처음부터 이 옵션을 활성화하면 모델이 죽을 수있고 끔찍한 결과를 초" +"래할 수 있습니다." + +#: lib/cli/args.py:1205 +msgid "Output to Shell console instead of GUI console" +msgstr "결과를 GUI 콘솔이 아닌 쉘 콘솔에 출력합니다" diff --git a/locales/kr/LC_MESSAGES/tools.alignments.cli.mo b/locales/kr/LC_MESSAGES/tools.alignments.cli.mo new file mode 100644 index 0000000000000000000000000000000000000000..9f5b8d8890649d1e4ef5888bf3a1c675051eb51c GIT binary patch literal 9269 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, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-09-14 18:45+0100\n" +"PO-Revision-Date: 2022-11-26 16:43+0900\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ko_KR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Poedit 3.2\n" + +#: tools/alignments/cli.py:17 +msgid "" +"This command lets you perform various tasks pertaining to an alignments file." +msgstr "" +"이 명령을 사용하여 alignments 파일과 관련된 다양한 작ㅇ를 수행할 수 있습니다." + +#: tools/alignments/cli.py:32 +msgid "" +"Alignments tool\n" +"This tool allows you to perform numerous actions on or using an alignments " +"file against its corresponding faceset/frame source." +msgstr "" +"_alignments 도구\n" +"이 도구를 사용하면 해당 얼굴 세트/프레임 원본에 해당하는 alignments 파일을 사" +"용하거나 여러 작업을 수행할 수 있습니다." + +#: tools/alignments/cli.py:44 +msgid " Must Pass in a frames folder/source video file (-fr)." +msgstr "" +" 프레임들이 저장된 폴더나 원본 비디오 파일을 무조건 전달해야 합니다 (-fr)." + +#: tools/alignments/cli.py:45 +msgid " Must Pass in a faces folder (-fc)." +msgstr " 얼굴 폴더를 무조건 전달해야 합니다 (-fc)." + +#: tools/alignments/cli.py:46 +msgid "" +" Must Pass in either a frames folder/source video file OR afaces folder (-fr " +"or -fc)." +msgstr "" +" 프레임 폴더나 원본 비디오 파일 또는 얼굴 폴더중 하나를 무조건 전달해야 합니" +"다 (-fr and -fc)." + +#: tools/alignments/cli.py:48 +msgid "" +" Must Pass in a frames folder/source video file AND a faces folder (-fr and -" +"fc)." +msgstr "" +" 프레임 폴더나 원본 비디오 파일 그리고 얼굴 폴더를 무조건 전달해야 합니다 (-" +"fr and -fc)." + +#: tools/alignments/cli.py:50 +msgid " Use the output option (-o) to process results." +msgstr " 결과를 진행하려면 (-o) 출력 옵션을 사용하세요." + +#: tools/alignments/cli.py:58 tools/alignments/cli.py:97 +msgid "processing" +msgstr "처리" + +#: tools/alignments/cli.py:60 +#, python-brace-format +msgid "" +"R|Choose which action you want to perform. NB: All actions require an " +"alignments file (-a) to be passed in.\n" +"L|'draw': Draw landmarks on frames in the selected folder/video. A subfolder " +"will be created within the frames folder to hold the output.{0}\n" +"L|'extract': Re-extract faces from the source frames/video based on " +"alignment data. This is a lot quicker than re-detecting faces. Can pass in " +"the '-een' (--extract-every-n) parameter to only extract every nth frame." +"{1}\n" +"L|'from-faces': Generate alignment file(s) from a folder of extracted faces. " +"if the folder of faces comes from multiple sources, then multiple alignments " +"files will be created. NB: for faces which have been extracted folders of " +"source images, rather than a video, a single alignments file will be created " +"as there is no way for the process to know how many folders of images were " +"originally used. You do not need to provide an alignments file path to run " +"this job. {3}\n" +"L|'missing-alignments': Identify frames that do not exist in the alignments " +"file.{2}{0}\n" +"L|'missing-frames': Identify frames in the alignments file that do not " +"appear within the frames folder/video.{2}{0}\n" +"L|'multi-faces': Identify where multiple faces exist within the alignments " +"file.{2}{4}\n" +"L|'no-faces': Identify frames that exist within the alignment file but no " +"faces were detected.{2}{0}\n" +"L|'remove-faces': Remove deleted faces from an alignments file. The original " +"alignments file will be backed up.{3}\n" +"L|'rename' - Rename faces to correspond with their parent frame and position " +"index in the alignments file (i.e. how they are named after running extract)." +"{3}\n" +"L|'sort': Re-index the alignments from left to right. For alignments with " +"multiple faces this will ensure that the left-most face is at index 0.\n" +"L|'spatial': Perform spatial and temporal filtering to smooth alignments " +"(EXPERIMENTAL!)" +msgstr "" +"R|실행할 작업을 선택합니다. 주의: 모든 작업을 수행하려면 alignments 파일(-a)" +"을 전달해야 합니다.\n" +"L|'draw': 선택한 폴더/비디오의 프레임에 특징점을 그립니다. 출력을 저장할 하" +"위 폴더가 프레임 폴더 내에 생성됩니다.{0}\n" +"L|'extract': alignments 데이터를 기반으로 소스 프레임/비디오에서 얼굴을 재추" +"출합니다. 이것은 얼굴을 재감지하는 것보다 훨씬 더 빠릅니다. '-een'(--extract-" +"every-n) 매개 변수를 전달하여 모든 n번째 프레임을 추출할 수 있습니다.{1}\n" +"L|'from-faces': 추출된 얼굴 폴더에서 alignments 파일을 생성합니다. 폴더 내의 " +"얼굴들을 여러 소스에서 가져온 경우 여러 alignments 파일이 생성됩니다. 참고: " +"비디오가 아닌 원본 이미지의 폴더를 추출한 얼굴의 경우, 원래 사용된 이미지의 " +"폴더 수를 알 수 없으므로 단일 alignments 파일이 생성됩니다. 이 작업을 실행하" +"기 위해 alignments 파일 경로를 제공할 필요는 없습니다. {3}\n" +"L|'missing-alignments': alignments 파일에 없는 프레임을 식별합니다.{2}{0}\n" +"L|'missing-frames': alignments 파일에서 [프레임 폴더/비디오] 내에 나타나지 않" +"는 프레임을 식별합니다.{2}{0}\n" +"L|'multi-faces': alignments 파일 내에서 여러 얼굴이 있는 위치를 식별합니다." +"{2}{4}\n" +"L|'no faces': alignments 파일 내에 있지만 얼굴이 탐지되지 않은 프레임을 식별" +"합니다.{2}{0}\n" +"L|'removes-faces': alignments 파일에서 삭제된 얼굴을 제거합니다. 원래 " +"alignments 파일은 백업됩니다.{3}\n" +"L|'rename' : alignments 파일의 상위 프레임 및 위치 색인에 해당하도록 얼굴 이" +"름을 바꿉니다(즉, 추출을 실행한 후에 얼굴 이름을 짓는 방법).{3}\n" +"L|'sort': alignments을 왼쪽에서 오른쪽으로 다시 인덱싱합니다. 얼굴이 여러 개" +"인 alignments의 경우 맨 왼쪽 얼굴이 색인 0에 있습니다.\n" +"L| 'spatial': 공간 및 시간 필터링을 수행하여 alignments를 원활하게 수행합니다" +"(실험적!)." + +#: tools/alignments/cli.py:99 +msgid "" +"R|How to output discovered items ('faces' and 'frames' only):\n" +"L|'console': Print the list of frames to the screen. (DEFAULT)\n" +"L|'file': Output the list of frames to a text file (stored within the source " +"directory).\n" +"L|'move': Move the discovered items to a sub-folder within the source " +"directory." +msgstr "" +"R|검색된 항목을 출력하는 방법('얼굴' 및 '프레임'만 해당):\n" +"L|'console': 프레임 목록을 화면에 인쇄합니다. (기본값)\n" +"L|'파일': 프레임 목록을 텍스트 파일(소스 디렉토리에 저장)로 출력합니다.\n" +"L|'이동': 검색된 항목을 원본 디렉토리 내의 하위 폴더로 이동합니다." + +#: tools/alignments/cli.py:110 tools/alignments/cli.py:123 +#: tools/alignments/cli.py:130 +msgid "data" +msgstr "데이터" + +#: tools/alignments/cli.py:114 +msgid "" +"Full path to the alignments file to be processed. If you have input a " +"'frames_dir' and don't provide this option, the process will try to find the " +"alignments file at the default location. All jobs require an alignments file " +"with the exception of 'from-faces' when the alignments file will be " +"generated in the specified faces folder." +msgstr "" +"처리할 alignments 파일의 전체 경로입니다. 'frames_dir'을 입력했는데 이 옵션" +"을 제공하지 않으면 프로세스는 기본 위치에서 alignments 파일을 찾으려고 합니" +"다. 지정된 얼굴 폴더에 alignments 파일이 생성될 때 모든 작업은 'from-" +"faces'를 제외한 alignments 파일이 필요로 합니다." + +#: tools/alignments/cli.py:124 +msgid "Directory containing extracted faces." +msgstr "추출된 얼굴들이 저장된 디렉토리." + +#: tools/alignments/cli.py:131 +msgid "Directory containing source frames that faces were extracted from." +msgstr "얼굴 추출의 소스로 쓰인 원본 프레임이 저장된 디렉토리." + +#: tools/alignments/cli.py:140 tools/alignments/cli.py:151 +#: tools/alignments/cli.py:161 +msgid "extract" +msgstr "추출" + +#: tools/alignments/cli.py:141 +msgid "" +"[Extract only] Extract every 'nth' frame. This option will skip frames when " +"extracting faces. For example a value of 1 will extract faces from every " +"frame, a value of 10 will extract faces from every 10th frame." +msgstr "" +"[Extract only] 모든 'n번째' 프레임을 추출합니다. 이 옵션은 얼굴을 추출할 때 " +"프레임을 건너뜁니다. 예를 들어, 값이 1이면 모든 프레임에서 얼굴이 추출되고, " +"값이 10이면 모든 10번째 프레임에서 얼굴이 추출됩니다." + +#: tools/alignments/cli.py:152 +msgid "[Extract only] The output size of extracted faces." +msgstr "[Extract only] 추출된 얼굴들의 결과 크기입니다." + +#: tools/alignments/cli.py:162 +msgid "" +"[Extract only] Only extract faces that have been resized by this percent or " +"more to meet the specified extract size (`-sz`, `--size`). Useful for " +"excluding low-res images from a training set. Set to 0 to extract all faces. " +"Eg: For an extract size of 512px, A setting of 50 will only include faces " +"that have been resized from 256px or above. Setting to 100 will only extract " +"faces that have been resized from 512px or above. A setting of 200 will only " +"extract faces that have been downscaled from 1024px or above." +msgstr "" +"[Extract only] 지정된 추출 크기('-sz', '--size')를 맞추기 위하여 크기가 이 비" +"율 이상 resize된 얼굴들만 추출합니다. 훈련 세트에서 저해상도 이미지를 제외하" +"는 데 유용합니다. 모든 얼굴을 추출하려면 0으로 설정합니다. 예: 추출 크기가 " +"512px인 경우, 50으로 설정하면 크기가 256px 이상인 면만 포함됩니다. 100으로 설" +"정하면 512px 이상에서 크기가 조정된 얼굴만 추출됩니다. 200으로 설정하면 " +"1024px 이상에서 축소된 얼굴만 추출됩니다." diff --git a/locales/kr/LC_MESSAGES/tools.effmpeg.cli.mo b/locales/kr/LC_MESSAGES/tools.effmpeg.cli.mo new file mode 100644 index 0000000000000000000000000000000000000000..088d8326c2817181f6dc827ec440a81f02135284 GIT binary patch literal 6735 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 AZ~y=R literal 0 HcmV?d00001 diff --git a/locales/kr/LC_MESSAGES/tools.effmpeg.cli.po b/locales/kr/LC_MESSAGES/tools.effmpeg.cli.po new file mode 100644 index 0000000000..cfd72bb51d --- /dev/null +++ b/locales/kr/LC_MESSAGES/tools.effmpeg.cli.po @@ -0,0 +1,184 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2021-02-18 23:34-0000\n" +"PO-Revision-Date: 2022-11-26 21:19+0900\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ko_KR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.2\n" + +#: tools/effmpeg/cli.py:15 +msgid "This command allows you to easily execute common ffmpeg tasks." +msgstr "" +"이 명령어는 사용자에게 일반 ffmpeg 작업을 쉽게 실행할 수 있도록 해줍니다." + +#: tools/effmpeg/cli.py:24 +msgid "A wrapper for ffmpeg for performing image <> video converting." +msgstr "이미지 <> 비디오 변환을 수행하기 위한 ffmpeg용 wrapper입니다." + +#: tools/effmpeg/cli.py:51 +msgid "" +"R|Choose which action you want ffmpeg ffmpeg to do.\n" +"L|'extract': turns videos into images \n" +"L|'gen-vid': turns images into videos \n" +"L|'get-fps' returns the chosen video's fps.\n" +"L|'get-info' returns information about a video.\n" +"L|'mux-audio' add audio from one video to another.\n" +"L|'rescale' resize video.\n" +"L|'rotate' rotate video.\n" +"L|'slice' cuts a portion of the video into a separate video file." +msgstr "" +"R|ffmpeg ffmpeg에서 수행할 작업을 선택합니다.\n" +"L|'extraction': 비디오를 이미지로 바꿉니다.\n" +"L|'gen-vid': 이미지를 비디오로 바꿉니다.\n" +"L|'get-fps'는 선택한 비디오의 fps를 반환합니다.\n" +"L|'get-info'는 동영상에 대한 정보를 반환합니다.\n" +"L|'mux-audio'는 한 비디오에서 다른 비디오로 오디오를 추가합니다.\n" +"L|'rescale' 크기 조정 비디오.\n" +"L|'rotate' 비디오 회전.\n" +"L| 'slice'는 동영상의 일부를 별도의 동영상 파일로 잘라냅니다." + +#: tools/effmpeg/cli.py:65 +msgid "Input file." +msgstr "입력 파일." + +#: tools/effmpeg/cli.py:66 tools/effmpeg/cli.py:73 tools/effmpeg/cli.py:87 +msgid "data" +msgstr "데이터" + +#: tools/effmpeg/cli.py:76 +msgid "" +"Output file. If no output is specified then: if the output is meant to be a " +"video then a video called 'out.mkv' will be created in the input directory; " +"if the output is meant to be a directory then a directory called 'out' will " +"be created inside the input directory. Note: the chosen output file " +"extension will determine the file encoding." +msgstr "" +"출력 파일. 출력이 지정되지 않은 경우: 출력이 비디오여야 한다면 입력 디렉토리" +"에 'out.mkv'라는 비디오가 생성됩니다. 출력이 디렉토리여야 한다면 입력 디렉토" +"리 내에 'out'이라는 디렉터리가 생성됩니다. 참고: 선택한 출력 파일 확장자가 파" +"일 인코딩을 결정합니다." + +#: tools/effmpeg/cli.py:89 +msgid "Path to reference video if 'input' was not a video." +msgstr "만약 input이 비디오가 아닐 경우 참고 비디으의 경로." + +#: tools/effmpeg/cli.py:95 tools/effmpeg/cli.py:105 tools/effmpeg/cli.py:142 +#: tools/effmpeg/cli.py:171 +msgid "output" +msgstr "출력" + +#: tools/effmpeg/cli.py:97 +msgid "" +"Provide video fps. Can be an integer, float or fraction. Negative values " +"will will make the program try to get the fps from the input or reference " +"videos." +msgstr "" +"비디오 fps를 제공합니다. 정수, 부동 또는 분수가 될 수 있습니다. 음수 값을 지" +"정하면 프로그램이 입력 또는 참조 비디오에서 fps를 가져오려고 합니다." + +#: tools/effmpeg/cli.py:107 +msgid "" +"Image format that extracted images should be saved as. '.bmp' will offer the " +"fastest extraction speed, but will take the most storage space. '.png' will " +"be slower but will take less storage." +msgstr "" +"추출된 이미지의 확장자는 '.bmp'로 저장되어야 합니다. '.bmp'는 가장 빠른 추출 " +"속도를 제공하지만 가장 많은 저장 공간을 차지합니다. '.png'은 속도는 더 느리지" +"만 저장 공간은 더 적게 차지합니다." + +#: tools/effmpeg/cli.py:114 tools/effmpeg/cli.py:123 tools/effmpeg/cli.py:132 +msgid "clip" +msgstr "클립" + +#: tools/effmpeg/cli.py:116 +msgid "" +"Enter the start time from which an action is to be applied. Default: " +"00:00:00, in HH:MM:SS format. You can also enter the time with or without " +"the colons, e.g. 00:0000 or 026010." +msgstr "" +"작업을 적용할 시작 시간을 입력합니다. 기본값: 00:00:00, HH:MM:SS 형식입니다. " +"콜론을 포함하거나 포함하지 않은 시간(예: 00:0000 또는 026010)을 입력할 수도 " +"있습니다." + +#: tools/effmpeg/cli.py:125 +msgid "" +"Enter the end time to which an action is to be applied. If both an end time " +"and duration are set, then the end time will be used and the duration will " +"be ignored. Default: 00:00:00, in HH:MM:SS." +msgstr "" +"적용된 작업의 종료 시간을 입력합니다. 종료 시간과 기간이 모두 설정된 경우 종" +"료 시간이 사용되고 기간이 무시됩니다. 기본값: 00:00:00, HH:MM:SS." + +#: tools/effmpeg/cli.py:134 +msgid "" +"Enter the duration of the chosen action, for example if you enter 00:00:10 " +"for slice, then the first 10 seconds after and including the start time will " +"be cut out into a new video. Default: 00:00:00, in HH:MM:SS format. You can " +"also enter the time with or without the colons, e.g. 00:0000 or 026010." +msgstr "" +"선택한 작업의 지속 시간을 입력합니다. 예를 들어 슬라이스에 00:00:10을 입력하" +"면 시작 시간 이후의 첫 10초가 새 비디오로 잘라집니다. 기본값: 00:00:00, HH:" +"MM:SS 형식입니다. 콜론을 포함하거나 포함하지 않은 시간(예: 00:0000 또는 " +"026010)을 입력할 수도 있습니다." + +#: tools/effmpeg/cli.py:144 +msgid "" +"Mux the audio from the reference video into the input video. This option is " +"only used for the 'gen-vid' action. 'mux-audio' action has this turned on " +"implicitly." +msgstr "" +"참조 비디오의 오디오를 입력 비디오에 병합합니다. 이 옵션은 'gen-vid' 작업에" +"만 사용됩니다. 'mux-timeout' 작업은 이 작업을 암시적으로 활성화했습니다." + +#: tools/effmpeg/cli.py:155 tools/effmpeg/cli.py:165 +msgid "rotate" +msgstr "회전" + +#: tools/effmpeg/cli.py:157 +msgid "" +"Transpose the video. If transpose is set, then degrees will be ignored. For " +"cli you can enter either the number or the long command name, e.g. to use " +"(1, 90Clockwise) -tr 1 or -tr 90Clockwise" +msgstr "" +"비디오를 전치합니다. 전치를 설정하면 각도가 무시됩니다. cli의 경우 숫자 또는 " +"긴 명령 이름을 입력할 수 있습니다(예: (1, 90Clockwise) (-tr 1 또는 -tr " +"90Clockwise)" + +#: tools/effmpeg/cli.py:166 +msgid "Rotate the video clockwise by the given number of degrees." +msgstr "비디오를 주어진 입력 각도에 따라 시계방향으로 회전합니다." + +#: tools/effmpeg/cli.py:173 +msgid "Set the new resolution scale if the chosen action is 'rescale'." +msgstr "선택한 작업이 'rescale'이라면 새로운 해상도 크기를 설정합니다." + +#: tools/effmpeg/cli.py:178 tools/effmpeg/cli.py:186 +msgid "settings" +msgstr "설정" + +#: tools/effmpeg/cli.py:180 +msgid "" +"Reduces output verbosity so that only serious errors are printed. If both " +"quiet and verbose are set, verbose will override quiet." +msgstr "" +"출력 상세도를 줄여 심각한 오류만 출력합니다. quiet와 verbose가 모두 설정된 경" +"우 verbose가 quiet를 재정의합니다." + +#: tools/effmpeg/cli.py:188 +msgid "" +"Increases output verbosity. If both quiet and verbose are set, verbose will " +"override quiet." +msgstr "" +"출력 상세도를 높입니다. quiet와 verbose가 모두 설정된 경우 verbose가 quiet를 " +"재정의합니다." diff --git a/locales/kr/LC_MESSAGES/tools.manual.mo b/locales/kr/LC_MESSAGES/tools.manual.mo new file mode 100644 index 0000000000000000000000000000000000000000..74eaf489c6f5ad13c1af0559805fab758880c11d GIT binary patch literal 8143 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 Xet{cPVNVdERG_H98-TJ1{4V|j8~g|O literal 0 HcmV?d00001 diff --git a/locales/kr/LC_MESSAGES/tools.manual.po b/locales/kr/LC_MESSAGES/tools.manual.po new file mode 100644 index 0000000000..0d4f83d414 --- /dev/null +++ b/locales/kr/LC_MESSAGES/tools.manual.po @@ -0,0 +1,282 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2022-11-24 14:17+0900\n" +"PO-Revision-Date: 2022-11-26 23:49+0900\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ko_KR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.2\n" + +#: tools/manual\cli.py:13 +msgid "" +"This command lets you perform various actions on frames, faces and " +"alignments files using visual tools." +msgstr "" +"이 명령어는 visual 도구들을 사용하여 프레임, 얼굴, alignments 파일들에 대한 " +"다양한 작업을 수행할 수 있도록 해줍니다." + +#: tools/manual\cli.py:23 +msgid "" +"A tool to perform various actions on frames, faces and alignments files " +"using visual tools" +msgstr "" +"프레임, 얼굴, alignments 파일들에 대한 다양한 작업을 수행할 수 있도록 해주는 " +"도구" + +#: tools/manual\cli.py:35 tools/manual\cli.py:43 +msgid "data" +msgstr "데이터" + +#: tools/manual\cli.py:37 +msgid "" +"Path to the alignments file for the input, if not at the default location" +msgstr "" +"입력에 대한 alignments 파일의 경로, 만약 설정되지 않았다면 기본 경로입니다" + +#: tools/manual\cli.py:44 +msgid "" +"Video file or directory containing source frames that faces were extracted " +"from." +msgstr "얼굴이 추출된 소스 프레임을 가지고 있는 비디오 파일 또는 디렉토리." + +#: tools/manual\cli.py:51 tools/manual\cli.py:59 +msgid "options" +msgstr "설정" + +#: tools/manual\cli.py:52 +msgid "" +"Force regeneration of the low resolution jpg thumbnails in the alignments " +"file." +msgstr "_alignments 파일에서 저해상도 jpg 미리 보기를 강제로 재생성합니다." + +#: tools/manual\cli.py:60 +msgid "" +"The process attempts to speed up generation of thumbnails by extracting from " +"the video in parallel threads. For some videos, this causes the caching " +"process to hang. If this happens, then set this option to generate the " +"thumbnails in a slower, but more stable single thread." +msgstr "" +"프로세스는 병렬 스레드에서 비디오를 추출하여 썸네일 생성 속도를 높이려고 시도" +"합니다. 일부 비디오의 경우 캐싱 프로세스가 중단될 수 있습니다. 이런 경우 이 " +"옵션을 설정하여 더 느리지만 안정적인 단일 스레드에서 썸네일를 생성하십시오." + +#: tools/manual\faceviewer\frame.py:163 +msgid "Display the landmarks mesh" +msgstr "특징점 망 보이기" + +#: tools/manual\faceviewer\frame.py:164 +msgid "Display the mask" +msgstr "마스크 보이기" + +#: tools/manual\frameviewer\editor\_base.py:628 +#: tools/manual\frameviewer\editor\landmarks.py:44 +#: tools/manual\frameviewer\editor\mask.py:75 +msgid "Magnify/Demagnify the View" +msgstr "보기를 확대/축소 합니다" + +#: tools/manual\frameviewer\editor\bounding_box.py:33 +#: tools/manual\frameviewer\editor\extract_box.py:32 +msgid "Delete Face" +msgstr "얼굴 삭제" + +#: tools/manual\frameviewer\editor\bounding_box.py:36 +msgid "" +"Bounding Box Editor\n" +"Edit the bounding box being fed into the aligner to recalculate the " +"landmarks.\n" +"\n" +" - Grab the corner anchors to resize the bounding box.\n" +" - Click and drag the bounding box to relocate.\n" +" - Click in empty space to create a new bounding box.\n" +" - Right click a bounding box to delete a face." +msgstr "" +"경계 상자 편집기\n" +"aligner 에 공급되는 경계 상자를 편집하여 특징점을 다시 계산합니다.\n" +"\n" +"- corner anchors를 사용하여 경계 상자의 크기를 재조정합니다.\n" +"- 경계 상자를 클릭하고 끌어서 재배치합니다.\n" +"- 빈 공간을 클릭하여 새 경계 상자를 만듭니다.\n" +"- 경계 상자를 마우스 오른쪽 단추로 클릭하여 얼굴을 삭제합니다." + +#: tools/manual\frameviewer\editor\bounding_box.py:70 +msgid "" +"Aligner to use. FAN will obtain better alignments, but cv2-dnn can be useful " +"if FAN cannot get decent alignments and you want to set a base to edit from." +msgstr "" +"사용할 aligner. FAN은 더 나은 alignments을 얻을 수 있지만, 만약 FAN이 적절한 " +"alignments을 얻을 수 없고 편집을 시작할 기준점을 설정하려는 경우 cv2-dnn이 유" +"용할 수 있습니다." + +#: tools/manual\frameviewer\editor\bounding_box.py:83 +msgid "" +"Normalization method to use for feeding faces to the aligner. This can help " +"the aligner better align faces with difficult lighting conditions. Different " +"methods will yield different results on different sets. NB: This does not " +"impact the output face, just the input to the aligner.\n" +"\tnone: Don't perform normalization on the face.\n" +"\tclahe: Perform Contrast Limited Adaptive Histogram Equalization on the " +"face.\n" +"\thist: Equalize the histograms on the RGB channels.\n" +"\tmean: Normalize the face colors to the mean." +msgstr "" +"_aligner에 얼굴을 공급하는 데 사용할 정규화 방법입니다. 이렇게 하면 aligner" +"가 어려운 조명 조건에서 얼굴을 더 잘 정렬할 수 있습니다. 방법이 다르면 세트마" +"다 결과가 다릅니다. NB: 출력 얼굴에는 영향을 주지 않으며 aligner에게 주는 입" +"력에만 영향을 줍니다.\n" +"\tnone: 얼굴에 정규화를 수행하지 않습니다.\n" +"\tclahe: 얼굴에 Contrast Limited Adaptive Histogram Equalization를 수행합니" +"다.\n" +"\thist: RGB 채널의 히스토그램을 균등화합니다.\n" +"\tmean: 얼굴 색상을 평균으로 정규화합니다." + +#: tools/manual\frameviewer\editor\extract_box.py:35 +msgid "" +"Extract Box Editor\n" +"Move the extract box that has been generated by the aligner. Click and " +"drag:\n" +"\n" +" - Inside the bounding box to relocate the landmarks.\n" +" - The corner anchors to resize the landmarks.\n" +" - Outside of the corners to rotate the landmarks." +msgstr "" +"Box Editor 추출\n" +"aligner에서 생성한 추출 box를 이동합니다. click & drag:\n" +"\n" +"- bouding box 내부에서 특징점을 재배치.\n" +"- 특징점들의 크기를 조정하는 corner anchors.\n" +"- 모서리를 벗어나 특징점을 회전합니다." + +#: tools/manual\frameviewer\editor\landmarks.py:27 +msgid "" +"Landmark Point Editor\n" +"Edit the individual landmark points.\n" +"\n" +" - Click and drag individual points to relocate.\n" +" - Draw a box to select multiple points to relocate." +msgstr "" +"특징점 편집기\n" +"개별 특징점들을 편집합니다.\n" +"\n" +" - 개별 특징점들을 클릭 & 드래그 하여 재배치합니다.\n" +" - 재배치할 여러개의 점들을 박스를 그려서 선택합니다." + +#: tools/manual\frameviewer\editor\mask.py:33 +msgid "" +"Mask Editor\n" +"Edit the mask.\n" +" - NB: For Landmark based masks (e.g. components/extended) it is better to " +"make sure the landmarks are correct rather than editing the mask directly. " +"Any change to the landmarks after editing the mask will override your manual " +"edits." +msgstr "" +"마스크 편집기\n" +"마스크를 편집합니다.\n" +"- 주의: 특징점 기반 마스크(예: 구성 요소/확장)의 경우 마스크를 직접 편집하기" +"보다는 특징점이 올바른지 확인하는 것이 좋습니다. 마스크를 편집한 후 특징점들 " +"변경하면 변경된 특징점들이 수동으로 편집한 마스크에 덮어 씌워집니다." + +#: tools/manual\frameviewer\editor\mask.py:77 +msgid "Draw Tool" +msgstr "그리기 도구" + +#: tools/manual\frameviewer\editor\mask.py:78 +msgid "Erase Tool" +msgstr "지우개 도구" + +#: tools/manual\frameviewer\editor\mask.py:97 +msgid "Select which mask to edit" +msgstr "편집할 마스크를 선택" + +#: tools/manual\frameviewer\editor\mask.py:104 +msgid "Set the brush size. ([ - decrease, ] - increase)" +msgstr "붓 크기 설정. ([ - decrease, ] - increase)" + +#: tools/manual\frameviewer\editor\mask.py:111 +msgid "Select the brush cursor color." +msgstr "붓 커서 색깔 선택." + +#: tools/manual\frameviewer\frame.py:78 +msgid "Play/Pause (SPACE)" +msgstr "재생/멈춤 (스페이스 바)" + +#: tools/manual\frameviewer\frame.py:79 +msgid "Go to First Frame (HOME)" +msgstr "첫 번째 프레임으로 이동 (HOME)" + +#: tools/manual\frameviewer\frame.py:80 +msgid "Go to Previous Frame (Z)" +msgstr "이전 프레임으로 이동 (Z)" + +#: tools/manual\frameviewer\frame.py:81 +msgid "Go to Next Frame (X)" +msgstr "다음 프레임으로 이동 (X)" + +#: tools/manual\frameviewer\frame.py:82 +msgid "Go to Last Frame (END)" +msgstr "마지막 프레임으로 이동 (END)" + +#: tools/manual\frameviewer\frame.py:83 +msgid "Extract the faces to a folder... (Ctrl+E)" +msgstr "폴더에 얼굴 추출... (Ctrl+E)" + +#: tools/manual\frameviewer\frame.py:84 +msgid "Save the Alignments file (Ctrl+S)" +msgstr "_Alignments file 저장 (Ctrl + S" + +#: tools/manual\frameviewer\frame.py:85 +msgid "Filter Frames to only those Containing the Selected Item (F)" +msgstr "오로지 선택된 아이템들을 가지고 있는 필터 프레임 (F)" + +#: tools/manual\frameviewer\frame.py:86 +msgid "" +"Set the distance from an 'average face' to be considered misaligned. Higher " +"distances are more restrictive" +msgstr "" +"'평균 얼굴'로부터의 거리를 잘못 정렬된 것으로 간주하도록 설정. 먼 거리에서 조" +"금 더 제한적입니다" + +#: tools/manual\frameviewer\frame.py:391 +msgid "View alignments" +msgstr "보기 정렬" + +#: tools/manual\frameviewer\frame.py:392 +msgid "Bounding box editor" +msgstr "경계 상자 편집기" + +#: tools/manual\frameviewer\frame.py:393 +msgid "Location editor" +msgstr "위치 편집기" + +#: tools/manual\frameviewer\frame.py:394 +msgid "Mask editor" +msgstr "마스크 편집기" + +#: tools/manual\frameviewer\frame.py:395 +msgid "Landmark point editor" +msgstr "특징점 편집기" + +#: tools/manual\frameviewer\frame.py:470 +msgid "Next" +msgstr "다음" + +#: tools/manual\frameviewer\frame.py:470 +msgid "Previous" +msgstr "이전" + +#: tools/manual\frameviewer\frame.py:481 +msgid "Revert to saved Alignments ({})" +msgstr "저장된 Alignments로 돌아가기 ({})" + +#: tools/manual\frameviewer\frame.py:487 +msgid "Copy {} Alignments ({})" +msgstr "{} Alignments를 복사 ({})" diff --git a/locales/kr/LC_MESSAGES/tools.mask.cli.mo b/locales/kr/LC_MESSAGES/tools.mask.cli.mo new file mode 100644 index 0000000000000000000000000000000000000000..02d16aa4c7a0e2ceb13d8ff74e964251ebda311a GIT binary patch literal 8406 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 Qs&_S|s)k!ec>c8ZAEiL)2LJ#7 literal 0 HcmV?d00001 diff --git a/locales/kr/LC_MESSAGES/tools.mask.cli.po b/locales/kr/LC_MESSAGES/tools.mask.cli.po new file mode 100644 index 0000000000..fc629f3521 --- /dev/null +++ b/locales/kr/LC_MESSAGES/tools.mask.cli.po @@ -0,0 +1,193 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-08-05 14:00+0100\n" +"PO-Revision-Date: 2022-11-27 01:28+0900\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ko_KR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Poedit 3.2\n" + +#: /home/matt/faceswap/tools/mask/cli.py:15 +msgid "This command lets you generate masks for existing alignments." +msgstr "이 명령어는 이미 존재하는 alignments로부터 마스크를 생성하게 해줍니다." + +#: /home/matt/faceswap/tools/mask/cli.py:24 +msgid "" +"Mask tool\n" +"Generate masks for existing alignments files." +msgstr "" +"마스크 도구\n" +"존재하는 alignments 파일들로부터 마스크를 생성합니다." + +#: /home/matt/faceswap/tools/mask/cli.py:33 +#: /home/matt/faceswap/tools/mask/cli.py:42 +#: /home/matt/faceswap/tools/mask/cli.py:52 +msgid "data" +msgstr "데이터" + +#: /home/matt/faceswap/tools/mask/cli.py:36 +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." +msgstr "" +"마스크를 추가할 alignments 파일의 전체 경로입니다. 주의: alignments 파일에 마" +"스크가 이미 있으면 alignments 파일이 덮어 씌워집니다." + +#: /home/matt/faceswap/tools/mask/cli.py:45 +msgid "Directory containing extracted faces, source frames, or a video file." +msgstr "추출된 얼굴들, 원본 프레임들, 또는 비디오 파일이 존재하는 디렉토리." + +#: /home/matt/faceswap/tools/mask/cli.py:54 +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 "" +"R|'입력'이 얼굴의 폴더인지 아니면 폴더 프레임/비디오인지\n" +"L|faces: 입력은 추출된 얼굴을 포함된 폴더입니다.\n" +"L|frames: 입력이 프레임을 포함된 폴더이거나 비디오입니다" + +#: /home/matt/faceswap/tools/mask/cli.py:63 +#: /home/matt/faceswap/tools/mask/cli.py:95 +msgid "process" +msgstr "진행" + +#: /home/matt/faceswap/tools/mask/cli.py:64 +msgid "" +"R|Masker to use.\n" +"L|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).\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\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." +msgstr "" +"R|사용할 마스크.\n" +"L|bisnet-fp: 전체 얼굴 마스킹(마스크 설정에서 구성 가능)을 포함하여 마스킹할 " +"영역에 대한 보다 정교한 제어를 제공하는 비교적 가벼운 NN 기반 마스크입니다.\n" +"L|components: 특징점 위치를 기반으로 얼굴 분할을 제공하도록 설계된 마스크입니" +"다. 특징점의 외부에는 마스크를 만들기 위해 convex hull이가 형성되어 있습니" +"다.\n" +"L|custom: 마스크 영역을 모든 1 또는 0으로 채우는 더미 마스크입니다(설정에서 " +"구성 가능). 수동 도구에서 사용자 정의 마스크를 직접 수동으로 편집하려는 경우" +"에만 필요합니다. 이 마스크는 GPU를 사용하지 않습니다.\n" +"L|extended: 특징점 위치를 기반으로 얼굴 분할을 제공하도록 설계된 마스크입니" +"다. 지형지물의 외부에는 convex hull이 형성되어 있으며, 마스크는 이마 위로 뻗" +"어 있습니다.\n" +"L|vgg-clear: 대부분의 정면에 장애물이 없는 스마트한 분할을 제공하도록 설계된 " +"마스크입니다. 프로필 면 및 장애물로 인해 성능이 저하될 수 있습니다.\n" +"L|vgg-obstructed: 대부분의 정면 얼굴을 스마트하게 분할할 수 있도록 설계된 마" +"스크입니다. 마스크 모델은 일부 안면 장애물(손과 안경)을 인식하도록 특별히 훈" +"련되었습니다. 옆 얼굴은 평균 이하의 성능을 초래할 수 있습니다.\n" +"L|unet-dfl: 대부분 정면 얼굴을 스마트하게 분할하도록 설계된 마스크. 마스크 모" +"델은 커뮤니티 구성원들에 의해 훈련되었으며 추가 설명을 위해 테스트가 필요합니" +"다. 옆 얼굴은 평균 이하의 성능을 초래할 수 있습니다." + +#: /home/matt/faceswap/tools/mask/cli.py:96 +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: 마스크를 업데이트하지 말고 지정된 출력 폴더에서 검토할 수 있도록 출" +"력하십시오." + +#: /home/matt/faceswap/tools/mask/cli.py:109 +#: /home/matt/faceswap/tools/mask/cli.py:116 +#: /home/matt/faceswap/tools/mask/cli.py:129 +#: /home/matt/faceswap/tools/mask/cli.py:142 +#: /home/matt/faceswap/tools/mask/cli.py:151 +msgid "output" +msgstr "출력" + +#: /home/matt/faceswap/tools/mask/cli.py:110 +msgid "" +"Optional output location. If provided, a preview of the masks created will " +"be output in the given folder." +msgstr "" +"선택적 출력 위치. 만약 값이 제공된다면 생성된 마스크 미리 보기가 주어진 폴더" +"에 출력됩니다." + +#: /home/matt/faceswap/tools/mask/cli.py:120 +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 " +"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" +msgstr "" +"마스크 출력에 gaussian blur를 적용합니다. 마스크의 가장자리를 매끄럽게 하여 " +"단단한 가장자리를 덜 제공하는 효과가 있습니다. 크기는 픽셀 단위입니다. 이 값" +"은 홀수여야 하며 짝수가 전달되면 다음 홀수로 반올림됩니다. NB: 출력 미리 보기" +"에만 영향을 줍니다. 0으로 설정하면 꺼집니다" + +#: /home/matt/faceswap/tools/mask/cli.py:133 +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 "" +"밝은 색조를 흰색으로, 어두운 색조를 검은색으로 만들어 일부 마스크의 '흐림'을 " +"줄이는 데 도움이 됩니다. 값이 클수록 마스크에 더 많은 영향을 미칩니다. NB: 출" +"력 미리 보기에만 영향을 줍니다. 0으로 설정하면 꺼집니다" + +#: /home/matt/faceswap/tools/mask/cli.py:143 +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" +"L|masked: Output the face/frame as rgba image with the face masked.\n" +"L|mask: Only output the mask as a single channel image." +msgstr "" +"R|처리가 'output'으로 설정되어 있을 때 출력을 구성하는 방법.\n" +"L|combined: 이미지에는 얼굴/프레임, 얼굴 마스크 및 마스크된 얼굴이 포함됩니" +"다.\n" +"L|masked: 마스크된 얼굴/프레임을 Rgba 이미지로 출력합니다.\n" +"L|mask: 마스크를 단일 채널 이미지로만 출력합니다." + +#: /home/matt/faceswap/tools/mask/cli.py:152 +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." +msgstr "" +"R|출력 처리를 사용할 때 전체 프레임을 출력할지 또는 페이스 박스만 출력할지 여" +"부. 프레임을 입력으로 사용할 때만 효과가 있습니다." diff --git a/locales/kr/LC_MESSAGES/tools.model.cli.mo b/locales/kr/LC_MESSAGES/tools.model.cli.mo new file mode 100644 index 0000000000000000000000000000000000000000..a6a953c94c46151d127e1aa0c4b9029d823cadcf GIT binary patch literal 2967 zcmaJ?U2hXd6y3J8wC-D<`p}1~z8F-kNrF&Y)3hQ$C?bIbGIKwBR{zB?l*<^8f|M_Ao@A=M`xbS8>WPWwIisjS~W~qlB0*r(LkE z3X~aUlAyAtJx*O}7ibnPIb%6ELv893kE$~}i!myU=mY(8#9`XZqr?Db93SvaUrrmo zda~#^jKWmWEEMCM3K6lr6rx;a;A%Rt4kukIb0X3tr6YkBoKi*R=v0`45zjSC4EI*R zA{FW~7;nJVV$Y5BnVi_sR!TG*Cj22A>_?1l1pG%JGJH|tP$0JLcrZpowx>+5i08z5V;wXkIV)X3yev2+ zd8h)zO0-~(SDr=Vl)X~flk2!$acC1;XP74oI*j0)B0(BR9d(|ZN29qCDT70%eT7)2 z0S58NVReQ}8F~xl*I_vP2_%+V@;XKem&+xo?kKt}OfI!##Lawr4IokFIRE|l^2WGJ}wh~fa*%E3VBfhp6nz@{iO zVnfa?hZs_GSwhUYTtYcj*%H2?E9?@?D7Bg6j$6)DmcfWzgYfc2Y{SX$wxRDZPYOU{ zFFaf(O=*CFG1EXBj&~sBH03$f6fEj7kk=#X4C%I@;B}%xJy=e|#Nln#hFs?ob_M;c zp?{5c0IA4BW|&G3>w|2C9M6pF-U)a(6fv|s-u*(cLsQTl1S;23E5JbW@{dYK`s>=H}w zN1<6B?2>w9z1_zl{Mp-eJoBv{bt{Q=$f1Vm!O>h#M!VQiJh(H@{SDEo0&m|Dt!dy7 zH~5WR2$q&aYfr4!M70TG?Uq=blbghRBe;D%s5ka^+aQ|Npxy+2v%0^tsYQW&Wrh3K zgKIwjZ8P_hNI%LTTHC;v*TwB^+%(S@Rv;ZI*5>%)JYQVTX<6}fh2QnF@mZ@iet!wX zay!`*by(m_(_+z&Ixu3FFC@b7gic~CW40x#kAwPyILYu;vAC`(^W!|S+NvMSwvufx zwB@j7(Q3pggkKfEJ^(SZ$+vbDtM`}q@}nqCj38t+qCTuAsAJ%Yq7wyJMwa|}qzLKZ z_x3>e&Hcyi1c++RxV-;lR%#}G+2v1hYTc5_gOw`3+uZ+sm#=Na0?usWN?JA~UkR-+ z&TR9oZ9cP|5>=ku(V$bEqOB7-o!mbRel8XAIFGcfUK0yD&z6s;HMxHef<|2|wv?;x zPYb_}@VLLuA8bRX+AEP1;NL_d{Z> z!5{v?Yv|e=Z6q+Lwhv2!_n%CQS@d;m2&r_c@$>d$c>zM*Qp9cTh�3jYI=Md442 zwR=I`j|)xF+1}mX^7HTtYb&vMEsZ7oM+j~;#X9P|H_h>GF09M{gpftNX(~#r)j%jV z69b`p>QZ0jqYzUA_kgG`q_hd+6Aj`H5f`1dBif5EH-q}NsP92iK9UC6=l(Z>IoUyi zl_iW(_e8Ox1W^9#;E(e~W{%S)CXI47RI9kr#=Otnn54|$=4MdeNPuao*{C+9Scq?f dSlvW&Bqb712SKFat9oDL?6vuXJuU1o+J9WVFMI$1 literal 0 HcmV?d00001 diff --git a/locales/kr/LC_MESSAGES/tools.model.cli.po b/locales/kr/LC_MESSAGES/tools.model.cli.po new file mode 100644 index 0000000000..f2b52dc3c1 --- /dev/null +++ b/locales/kr/LC_MESSAGES/tools.model.cli.po @@ -0,0 +1,78 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-06-28 14:05+0100\n" +"PO-Revision-Date: 2022-11-27 01:32+0900\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ko_KR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Poedit 3.2\n" + +#: tools/model/cli.py:13 +msgid "This tool lets you perform actions on saved Faceswap models." +msgstr "" +"이 도구를 사용하여 저장된 Faceswap 모델에서 작업을 수행할 수 있습니다." + +#: tools/model/cli.py:22 +msgid "A tool for performing actions on Faceswap trained model files" +msgstr "_Faceswap 훈련을 받은 모델 파일에서 작업을 수행하기 위한 도구" + +#: tools/model/cli.py:33 +msgid "" +"Model directory. A directory containing the model you wish to perform an " +"action on." +msgstr "모델 디렉토리. 작업을 수행할 모델이 들어 있는 디렉토리입니다." + +#: tools/model/cli.py:41 +msgid "" +"R|Choose which action you want to perform.\n" +"L|'inference' - Create an inference only copy of the model. Strips any " +"layers from the model which are only required for training. NB: This is for " +"exporting the model for use in external applications. Inference generated " +"models cannot be used within Faceswap. See the 'format' option for " +"specifying the model output format.\n" +"L|'nan-scan' - Scan the model file for NaNs or Infs (invalid data).\n" +"L|'restore' - Restore a model from backup." +msgstr "" +"R|실행할 작업을 선택합니다.\n" +"L|'inference' - 모델의 추론 전용 사본을 만듭니다. 모델에서 훈련에만 필요한 " +"모든 레이어를 제거합니다. NB: 이것은 외부 응용 프로그램에서 사용하기 위해 모" +"델을 내보내기 위한 것입니다. 추론 생성 모델은 Faceswap 내에서 사용할 수 없습" +"니다. 모델 출력 형식을 지정하려면 'format' 옵션을 참조하십시오.\n" +"L|'nan-scan' - 모델 파일에서 NaN 또는 Infs(잘못된 데이터)를 검색합니다.\n" +"L|'restore' - 백업에서 모델을 복원합니다." + +#: tools/model/cli.py:55 tools/model/cli.py:66 +msgid "inference" +msgstr "추론" + +#: tools/model/cli.py:56 +msgid "" +"R|The format to save the model as. Note: Only used for 'inference' job.\n" +"L|'h5' - Standard Keras H5 format. Does not store any custom layer " +"information. Layers will need to be loaded from Faceswap to use.\n" +"L|'saved-model' - Tensorflow's Saved Model format. Contains all information " +"required to load the model outside of Faceswap." +msgstr "" +"R|모델을 저장할 형식입니다. 참고: '추론' 작업에만 사용됩니다.\n" +"L|'h5' - 표준 케라스 H5 형식. 사용자 지정 레이어 정보를 저장하지 않습니다. " +"사용하려면 Faceswap에서 레이어를 로드해야 합니다.\n" +"L| 'saved-model' - 텐서플로의 저장된 모델 형식. Faceswap 외부에서 모델을 로" +"드하는 데 필요한 모든 정보를 포함합니다." + +#: tools/model/cli.py:67 +msgid "" +"Only used for 'inference' job. Generate the inference model for B -> A " +"instead of A -> B." +msgstr "" +"'추론' 작업에만 쓰입니다. A -> B 대신 B -> A에 대한 추론 모델을 생성합니다." diff --git a/locales/kr/LC_MESSAGES/tools.preview.mo b/locales/kr/LC_MESSAGES/tools.preview.mo new file mode 100644 index 0000000000000000000000000000000000000000..20492ddcbfedf6526af0a8bf85c28120b7a4ab14 GIT binary patch literal 2080 zcma)6ZBJZ96dtv{t+p|m=ob@@FNuleUKiA~U4jNGRbr`x;>$NPa%Y!o@7}w)cXpAO zCd8};VM$S0vnb#;ftEHz5vYNLn)t;J`V&n20XwsQ!1%#u?k?}7I?3>GX3ja!InSKE ze?5Av%&<;iKZ#w!ehm8`Y_s0Hi!ll80bT(90_*`Ezq?_-2YeRxHQ>|0-!AiPloBtr!Vtzl z5%+-5cpb-IQ~Sfl>hW`VNsb$eK^cT26ogc89TC{n?aCn$65n$<82FOAzUyZQcjRVy zMo`-IL_{M&o`&6Mh-5&yFc=h3WOC)WjE{IDYwT>Z-oO#wZeE)@cMUo~B113=hq7_Z z5i~aIYm%6nxaVg4tng(Nb5R;a#I>3}TgXk5A4uXdK6FHy=RHYYFvz7F_^dA!1y>BC ziNH&AdR{Qx=nPGvthAfShr+ZP8nYlmNs*8edPHa*opSQrf`9;V8rCmxP3gSnHN%ZM z+oT2VN0>XS$FbfO zVT2b*QKIi{s~_?i%X*zlk)pO_TbtF|YPFrBWNYf>GbfX0lF7t*9!YB;ENLmAeXZP4Cx)glyR)3Wuni^hupKy1z|Qyk)MmrsH3%=8vk}A+Y@gH zUxet#vARZ5lpD!lazJS3MyS<(CGo!1bTF;-1;TM9y=1o~n4bSkRToK3Ppj$|_0Oy7 z+A^uilp3EVwOPcmt}NK3S1Y)vm#d`b*42+2`uZr$G1)J2@3;LUN((_|+*qu`C zL{+G^dRtwcHT)A}YGT1=>gJM~+}KwNm_J?C-`>#kQ?-@RoiZx5HB>V3Gg#~8iXp9k zH>RpfYHF4mSxi|+)MIb=yXS|z-N$xCEu^lObon;v>TGT8mYS-VZgqJaJg8IMSf|>? zidx$sJzLb96$U*Zj#^vCF{vxX+O6e;ij`3tCA3I;9n|6$!4+9_5jSh2^_dFk;)1T0 z^<0q*Xc$?KWQsQl(Kn zQEh2{BjOgfeONq#H!;SWn1^@*52^~8F>M$X^FnyFdU;3sgKvW|Q{#)qb+xolb, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2021-03-10 16:51-0000\n" +"PO-Revision-Date: 2022-11-27 01:49+0900\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ko_KR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.2\n" + +#: tools/preview\cli.py:13 +msgid "This command allows you to preview swaps to tweak convert settings." +msgstr "" +"이 명령어는 변환 설정을 변경하기 위한 변환 미리보기를 가능하게 해줍니다." + +#: tools/preview\cli.py:22 +msgid "" +"Preview tool\n" +"Allows you to configure your convert settings with a live preview" +msgstr "" +"미리보기 도구\n" +"라이브로 미리보기를 보면서 변환 설정을 구성할 수 있도록 해줍니다" + +#: tools/preview\cli.py:32 tools/preview\cli.py:41 tools/preview\cli.py:48 +msgid "data" +msgstr "데이터" + +#: tools/preview\cli.py:34 +msgid "" +"Input directory or video. Either a directory containing the image files you " +"wish to process or path to a video file." +msgstr "" +"입력 디렉토리 또는 비디오. 처리할 이미지 파일이 들어 있는 디렉토리 또는 비디" +"오 파일의 경로입니다." + +#: tools/preview\cli.py:43 +msgid "" +"Path to the alignments file for the input, if not at the default location" +msgstr "입력 alignments 파일의 경로, 만약 제공되지 않는다면 기본 위치" + +#: tools/preview\cli.py:50 +msgid "" +"Model directory. A directory containing the trained model you wish to " +"process." +msgstr "" +"모델 디렉토리. 사용자가 처리하고 싶어하는 훈련된 모델이 있는 디렉토리." + +#: tools/preview\cli.py:57 +msgid "Swap the model. Instead of A -> B, swap B -> A" +msgstr "모델을 스왑함. A -> B 대신, B -> A로 스왑함" + +#: tools/preview\preview.py:1303 +msgid "Save full config" +msgstr "전체 설정을 저장" + +#: tools/preview\preview.py:1306 +msgid "Reset full config to default values" +msgstr "전체 설정을 기본 값으로 초기화" + +#: tools/preview\preview.py:1309 +msgid "Reset full config to saved values" +msgstr "전체 설정을 저장된 값으로 초기화" + +#: tools/preview\preview.py:1453 +msgid "Save {} config" +msgstr "{} 설정 저장" + +#: tools/preview\preview.py:1456 +msgid "Reset {} config to default values" +msgstr "{} 설정을 기본 값으로 초기화" + +#: tools/preview\preview.py:1459 +msgid "Reset {} config to saved values" +msgstr "{} 설정을 저장된 값으로 초기화" diff --git a/locales/kr/LC_MESSAGES/tools.sort.cli.mo b/locales/kr/LC_MESSAGES/tools.sort.cli.mo new file mode 100644 index 0000000000000000000000000000000000000000..979f13a2fe820d03c6212533594ee31b73121f03 GIT binary patch literal 15578 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 ON{>ca$G7Q@{q*09b!fc+ literal 0 HcmV?d00001 diff --git a/locales/kr/LC_MESSAGES/tools.sort.cli.po b/locales/kr/LC_MESSAGES/tools.sort.cli.po new file mode 100644 index 0000000000..813a6e29b6 --- /dev/null +++ b/locales/kr/LC_MESSAGES/tools.sort.cli.po @@ -0,0 +1,363 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-11-24 14:19+0900\n" +"PO-Revision-Date: 2022-11-27 03:43+0900\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ko_KR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Poedit 3.2\n" + +#: tools/sort/cli.py:14 +msgid "This command lets you sort images using various methods." +msgstr "이 명령어는 다양한 메소드를 이용하여 이미지를 정렬해줍니다." + +#: tools/sort/cli.py:20 +msgid "" +" Adjust the '-t' ('--threshold') parameter to control the strength of " +"grouping." +msgstr " 그룹화의 강도를 제어하기 위해 '-t' ('--threshold') 인자를 조정하세요." + +#: tools/sort/cli.py:21 +msgid "" +" Adjust the '-b' ('--bins') parameter to control the number of bins for " +"grouping. Each image is allocated to a bin by the percentage of color pixels " +"that appear in the image." +msgstr "" +" '-b'('--bins') 매개 변수를 조정하여 그룹화할 bins의 수를 제어합니다. 각 이미" +"지는 이미지에 나타나는 색상 픽셀의 백분율에 따라 bin에 할당됩니다." + +#: tools/sort/cli.py:24 +msgid "" +" Adjust the '-b' ('--bins') parameter to control the number of bins for " +"grouping. Each image is allocated to a bin by the number of degrees the face " +"is orientated from center." +msgstr "" +" '-b'('--bins') 매개 변수를 조정하여 그룹화할 bins의 수를 제어합니다. 각 이미" +"지는 얼굴이 이미지 중심에서 떨어진 각도에 따라 bin에 할당됩니다." + +#: tools/sort/cli.py:27 +msgid "" +" Adjust the '-b' ('--bins') parameter to control the number of bins for " +"grouping. The minimum and maximum values are taken for the chosen sort " +"metric. The bins are then populated with the results from the group sorting." +msgstr "" +" '-b'('--bins') 매개 변수를 조정하여 그룹화할 bins의 수를 제어합니다. 선택한 " +"정렬 방법에 대해 최소값과 최대값이 사용됩니다. 그런 다음 bins가 그룹 정렬의 " +"결과로 채워집니다." + +#: tools/sort/cli.py:31 +msgid "faces by blurriness." +msgstr "흐릿한 얼굴." + +#: tools/sort/cli.py:32 +msgid "faces by fft filtered blurriness." +msgstr "fft 필터링된 흐릿한 얼굴." + +#: tools/sort/cli.py:33 +msgid "" +"faces by the estimated distance of the alignments from an 'average' face. " +"This can be useful for eliminating misaligned faces. Sorts from most like an " +"average face to least like an average face." +msgstr "" +"'평균' 얼굴에서 alignments의 추정 거리를 기준으로 하는 얼굴. 이는 잘못 정렬" +"된 얼굴을 제거하는 데 유용할 수 있습니다. 가장 평균 얼굴에서 가장 덜 평균 얼" +"굴순으로 정렬합니다." + +#: tools/sort/cli.py:36 +msgid "" +"faces using VGG Face2 by face similarity. This uses a pairwise clustering " +"algorithm to check the distances between 512 features on every face in your " +"set and order them appropriately." +msgstr "" +"얼굴 유사성에 따라 VGG Face2를 사용하는 얼굴. 이 알고리즘은 쌍별 클러스터링 " +"알고리즘을 사용하여 세트의 모든 얼굴에서 512개의 특징 사이의 거리를 확인하고 " +"적절하게 정렬합니다." + +#: tools/sort/cli.py:39 +msgid "faces by their landmarks." +msgstr "특징점이 있는 얼굴." + +#: tools/sort/cli.py:40 +msgid "Like 'face-cnn' but sorts by dissimilarity." +msgstr "'face-cnn'과 비슷하지만 비유사성에 따라 정렬된." + +#: tools/sort/cli.py:41 +msgid "faces by Yaw (rotation left to right)." +msgstr "yaw (왼쪽에서 오른쪽으로 회전)에 의한 얼굴." + +#: tools/sort/cli.py:42 +msgid "faces by Pitch (rotation up and down)." +msgstr "pitch (위에서 아래로 회전)에 의한 얼굴." + +#: tools/sort/cli.py:43 +msgid "" +"faces by Roll (rotation). Aligned faces should have a roll value close to " +"zero. The further the Roll value from zero the higher liklihood the face is " +"misaligned." +msgstr "" +"이동 (회전)에 의한 얼굴. 정렬된 얼굴들은 0에 가까운 이동 값을 가져야 한다. 이" +"동 값이 0에서 멀수록 얼굴들이 잘못 정렬되었을 가능성이 높습니다." + +#: tools/sort/cli.py:45 +msgid "faces by their color histogram." +msgstr "색상 히스토그램에 의한 얼굴." + +#: tools/sort/cli.py:46 +msgid "Like 'hist' but sorts by dissimilarity." +msgstr "'hist' 같지만 비유사성에 따라 정렬된." + +#: tools/sort/cli.py:47 +msgid "" +"images by the average intensity of the converted grayscale color channel." +msgstr "변환된 회색 계열 색상 채널의 평균 강도에 따른 이미지." + +#: tools/sort/cli.py:48 +msgid "" +"images by their number of black pixels. Useful when faces are near borders " +"and a large part of the image is black." +msgstr "" +"검은색 픽셀의 개수에 따른 이미지들. 얼굴이 테두리 근처에 있고 이미지의 대부분" +"이 검은색일 때 유용합니다." + +#: tools/sort/cli.py:50 +msgid "" +"images by the average intensity of the converted Y color channel. Bright " +"lighting and oversaturated images will be ranked first." +msgstr "" +"변환된 Y 색상 채널의 평균 강도를 기준으로 한 이미지. 밝은 조명과 과포화 이미" +"지가 1위를 차지할 것이다." + +#: tools/sort/cli.py:52 +msgid "" +"images by the average intensity of the converted Cg color channel. Green " +"images will be ranked first and red images will be last." +msgstr "" +"변환된 Cg 컬러 채널의 평균 강도를 기준으로 한 이미지. 녹색 이미지가 먼저 순위" +"가 매겨지고 빨간색 이미지가 마지막 순위가 됩니다." + +#: tools/sort/cli.py:54 +msgid "" +"images by the average intensity of the converted Co color channel. Orange " +"images will be ranked first and blue images will be last." +msgstr "" +"변환된 Co 색상 채널의 평균 강도를 기준으로 한 이미지. 주황색 이미지가 먼저 순" +"위가 매겨지고 파란색 이미지가 마지막 순위가 됩니다." + +#: tools/sort/cli.py:56 +msgid "" +"images by their size in the original frame. Faces further from the camera " +"and from lower resolution sources will be sorted first, whilst faces closer " +"to the camera and from higher resolution sources will be sorted last." +msgstr "" +"이미지를 원래 프레임의 크기별로 표시합니다. 카메라에서 더 멀리 떨어져 있고 저" +"해상도 원본에서 온 얼굴이 먼저 정렬되고, 카메라에 더 가까이 있고 고해상도 원" +"본에서 온 얼굴이 마지막으로 정렬됩니다." + +#: tools/sort/cli.py:59 +msgid " option is deprecated. Use 'yaw'" +msgstr " 이 옵션은 더 이상 사용되지 않습니다. 'yaw'를 사용하세요" + +#: tools/sort/cli.py:60 +msgid " option is deprecated. Use 'color-black'" +msgstr " 이 옵션은 더 이상 사용되지 않습니다. 'color-black'을 사용하세요" + +#: tools/sort/cli.py:82 +msgid "Sort faces using a number of different techniques" +msgstr "얼굴을 정렬하는데 사용되는 서로 다른 기술들의 개수" + +#: tools/sort/cli.py:92 tools/sort/cli.py:99 tools/sort/cli.py:110 +#: tools/sort/cli.py:148 +msgid "data" +msgstr "데이터" + +#: tools/sort/cli.py:93 +msgid "Input directory of aligned faces." +msgstr "정렬된 얼굴들의 입력 디렉토리." + +#: tools/sort/cli.py:100 +msgid "" +"Output directory for sorted aligned faces. If not provided and 'keep' is " +"selected then a new folder called 'sorted' will be created within the input " +"folder to house the output. If not provided and 'keep' is not selected then " +"the images will be sorted in-place, overwriting the original contents of the " +"'input_dir'" +msgstr "" +"정렬된 aligned 얼굴의 출력 디렉토리입니다. 제공되지 않은 상태에서 'keep'을 선" +"택하면 출력을 저장하기 위해 입력 폴더 내에 'sorted'라는 새 폴더가 생성됩니" +"다. 제공되지 않고 'keep'을 선택하지 않으면 이미지가 제자리에 정렬되어 " +"'input_dir'의 원래 내용을 덮어씁니다." + +#: tools/sort/cli.py:111 +msgid "" +"R|If selected then the input_dir should be a parent folder containing " +"multiple folders of faces you wish to sort. The faces will be output to " +"separate sub-folders in the output_dir" +msgstr "" +"R|선택되면 input_dir는 정렬할 여러 개의 얼굴 폴더를 포함하는 상위 폴더여야 합" +"니다. 얼굴은 output_dir의 별도 하위 폴더로 출력됩니다" + +#: tools/sort/cli.py:120 +msgid "sort settings" +msgstr "정렬 설정" + +#: tools/sort/cli.py:122 +msgid "" +"R|Choose how images are sorted. Selecting a sort method gives the images a " +"new filename based on the order the image appears within the given method.\n" +"L|'none': Don't sort the images. When a 'group-by' method is selected, " +"selecting 'none' means that the files will be moved/copied into their " +"respective bins, but the files will keep their original filenames. Selecting " +"'none' for both 'sort-by' and 'group-by' will do nothing" +msgstr "" +"R|이미지 정렬 방법을 선택합니다. 정렬 방법을 선택하면 이미지가 주어진 방법 내" +"에 나타나는 순서에 따라 이미지에 새 파일 이름이 지정됩니다.\n" +"L|'none': 이미지를 정렬하지 않습니다. 'group-by' 메서드를 선택한 경우 " +"'none'을 선택하면 파일이 각 bin으로 이동/복사되지만 파일은 원래 파일 이름을 " +"유지합니다. 'sort-by' 및 'group-by' 모두에 대해 'none'을 선택해도 아무 효과" +"가 없습니다" + +#: tools/sort/cli.py:135 tools/sort/cli.py:162 tools/sort/cli.py:191 +msgid "group settings" +msgstr "그룹 설정" + +#: tools/sort/cli.py:137 +msgid "" +"R|Selecting a group by method will move/copy files into numbered bins based " +"on the selected method.\n" +"L|'none': Don't bin the images. Folders will be sorted by the selected 'sort-" +"by' but will not be binned, instead they will be sorted into a single " +"folder. Selecting 'none' for both 'sort-by' and 'group-by' will do nothing" +msgstr "" +"R|방법별로 그룹을 선택하면 선택한 방법에 따라 파일이 번호가 매겨진 빈으로 이" +"동/복사됩니다.\n" +"L|'none': 이미지를 버리지 않습니다. 폴더는 선택한 '정렬 기준'에 따라 정렬되지" +"만 버려지진 않고 단일 폴더로 정렬됩니다. 'sort-by' 및 'group-by' 모두에 대해 " +"'none'을 선택해도 아무 효과가 없습니다" + +#: tools/sort/cli.py:149 +msgid "" +"Whether to keep the original files in their original location. Choosing a " +"'sort-by' method means that the files have to be renamed. Selecting 'keep' " +"means that the original files will be kept, and the renamed files will be " +"created in the specified output folder. Unselecting keep means that the " +"original files will be moved and renamed based on the selected sort/group " +"criteria." +msgstr "" +"원본 파일을 원래 위치에 유지할지 여부입니다. '정렬 기준' 방법을 선택하면 파" +"일 이름을 변경해야 합니다. 'keep'을 선택하면 원래 파일이 유지되고 이름이 변경" +"된 파일이 지정된 출력 폴더에 생성됩니다. keep을 선택취소하면 선택한 정렬/그" +"룹 기준에 따라 원래 파일이 이동되고 이름이 변경됩니다." + +#: tools/sort/cli.py:164 +msgid "" +"R|Float value. Minimum threshold to use for grouping comparison with 'face-" +"cnn' 'hist' and 'face' methods.\n" +"The lower the value the more discriminating the grouping is. Leaving -1.0 " +"will allow Faceswap to choose the default value.\n" +"L|For 'face-cnn' 7.2 should be enough, with 4 being very discriminating. \n" +"L|For 'hist' 0.3 should be enough, with 0.2 being very discriminating. \n" +"L|For 'face' between 0.1 (more bins) to 0.5 (fewer bins) should be about " +"right.\n" +"Be careful setting a value that's too extrene in a directory with many " +"images, as this could result in a lot of folders being created. Defaults: " +"face-cnn 7.2, hist 0.3, face 0.25" +msgstr "" +"R|float 값. 'face-cnn', 'hist' 및 'face' 메서드와의 그룹 비교에 사용할 최소 " +"임계값입니다.\n" +"값이 낮을수록 그룹을 더 잘 구별할 수 있습니다. -1.0을 그대로 두면 Faceswap에" +"서 기본값을 선택할 수 있습니다.\n" +"L|'face-cnn'의 경우 7.2이면 충분하며, 4는 매우 많이 구별된다. \n" +"L|'hist'의 경우 0.3이면 충분하며, 0.2는 매우 많이 구별된다. \n" +"L|0.1(더 많은 빈)에서 0.5(더 적은 빈) 사이의 '얼굴'의 경우는 거의 오른쪽이어" +"야 합니다.\n" +"이미지가 많은 디렉터리에서 너무 극단적인 값을 설정하면 폴더가 많이 생성될 수 " +"있으므로 주의하십시오. 기본값: face-cnn 7.2, hist 0.3, face 0.25" + +#: tools/sort/cli.py:181 +msgid "output" +msgstr "출력" + +#: tools/sort/cli.py:182 +msgid "" +"Deprecated and no longer used. The final processing will be dictated by the " +"sort/group by methods and whether 'keep_original' is selected." +msgstr "" +"폐기되었고 더 이상 사용되지 않습니다. 최종 처리는 sort/group-by 메서드와 " +"'keep_original'이 선택되었는지 여부에 의해 결정됩니다." + +#: tools/sort/cli.py:193 +#, python-format +msgid "" +"R|Integer value. Used to control the number of bins created for grouping by: " +"any 'blur' methods, 'color' methods or 'face metric' methods ('distance', " +"'size') and 'orientation; methods ('yaw', 'pitch'). For any other grouping " +"methods see the '-t' ('--threshold') option.\n" +"L|For 'face metric' methods the bins are filled, according the the " +"distribution of faces between the minimum and maximum chosen metric.\n" +"L|For 'color' methods the number of bins represents the divider of the " +"percentage of colored pixels. Eg. For a bin number of '5': The first folder " +"will have the faces with 0%% to 20%% colored pixels, second 21%% to 40%%, " +"etc. Any empty bins will be deleted, so you may end up with fewer bins than " +"selected.\n" +"L|For 'blur' methods folder 0 will be the least blurry, while the last " +"folder will be the blurriest.\n" +"L|For 'orientation' methods the number of bins is dictated by how much 180 " +"degrees is divided. Eg. If 18 is selected, then each folder will be a 10 " +"degree increment. Folder 0 will contain faces looking the most to the left/" +"down whereas the last folder will contain the faces looking the most to the " +"right/up. NB: Some bins may be empty if faces do not fit the criteria.\n" +"Default value: 5" +msgstr "" +"R| 정수 값. 그룹화를 위해 생성된 bins의 수를 제어하는 데 사용됩니다. 임의의 " +"'blur' 방법, 'color' 방법 또는 'face metric' 방법('거리', '크기'), " +"'orientation' 방법('yaw', 'pitch'). 다른 그룹화 방법은 '-t'('--임계값') 옵션" +"을 참조하십시오.\n" +"L|'face metric' 방법의 경우 선택한 최소 메트릭과 최대 메트릭 사이의 얼굴 분포" +"에 따라 bins가 채워집니다.\n" +"L|'color' 방법의 경우 bins의 수는 색상 픽셀의 백분율을 나눈 값을 나타냅니다. " +"예: bin 번호가 '5'인 경우: 첫 번째 폴더는 0%%에서 20%%의 색상 픽셀을 가진 얼" +"굴을 가질 것이고, 두 번째는 21%%에서 40%% 등을 가질 것이다. 텅 빈 bins는 삭제" +"되므로 선택한 bins보다 더 적은 bins을 가질 수 있습니다.\n" +"L|'blur' 메서드의 경우 폴더 0이 가장 흐림이 적으며 마지막 폴더가 가장 흐림이 " +"많습니다.\n" +"L|'orientation' 방법의 경우 bins의 수는 180도를 얼마나 나누느냐에 따라 결정됩" +"니다. 예: 18을 선택하면 각 폴더가 10도씩 증가합니다. 폴더 0은 왼쪽/아래쪽 얼" +"굴을 가장 많이 포함하는 반면, 마지막 폴더는 오른쪽/위 얼굴을 가장 많이 포함합" +"니다. 주의: 얼굴이 기준에 맞지 않으면 일부 bins가 비어 있을 수 있습니다.\n" +"기본값: 5" + +#: tools/sort/cli.py:215 tools/sort/cli.py:225 +msgid "settings" +msgstr "설정" + +#: tools/sort/cli.py:217 +msgid "" +"Logs file renaming changes if grouping by renaming, or it logs the file " +"copying/movement if grouping by folders. If no log file is specified with " +"'--log-file', then a 'sort_log.json' file will be created in the input " +"directory." +msgstr "" +"만약 renaming별로 그룹화하면 로그 파일에서 renaming이 변경됩니다. 또는 폴더별" +"로 그룹화하는 경우 파일 복사/이동을 기록합니다. '--log-file'로 로그 파일을 지" +"정하지 않으면 'sort_log.json' 파일이 입력 디렉토리에 생성됩니다." + +#: tools/sort/cli.py:228 +msgid "" +"Specify a log file to use for saving the renaming or grouping information. " +"If specified extension isn't 'json' or 'yaml', then json will be used as the " +"serializer, with the supplied filename. Default: sort_log.json" +msgstr "" +"_renaming 또는 grouping 정보를 저장하는 데 사용할 로그 파일을 지정합니다. 지" +"정된 확장자가 'json' 또는 'yaml'이 아니면 json이 제공된 파일 이름과 함께 직렬" +"화기로 사용됩니다. 기본값: sort_log.json" diff --git a/locales/tools.manual.pot b/locales/tools.manual.pot index a6cff24f13..39ef3f5a1e 100644 --- a/locales/tools.manual.pot +++ b/locales/tools.manual.pot @@ -2,10 +2,11 @@ # Copyright (C) YEAR ORGANIZATION # FIRST AUTHOR , YEAR. # +#, fuzzy msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-06-08 19:24+0100\n" +"Project-Id-Version: \n" +"POT-Creation-Date: 2022-11-24 14:17+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -13,7 +14,7 @@ msgstr "" "Content-Type: text/plain; charset=cp1252\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: pygettext.py 1.5\n" - +"X-Generator: Poedit 3.2\n" #: tools/manual\cli.py:13 msgid "This command lets you perform various actions on frames, faces and alignments files using visual tools." @@ -207,4 +208,3 @@ msgstr "" #: tools/manual\frameviewer\frame.py:487 msgid "Copy {} Alignments ({})" msgstr "" - diff --git a/locales/tools.sort.cli.pot b/locales/tools.sort.cli.pot index 275fc6d226..97e1dff6a2 100644 --- a/locales/tools.sort.cli.pot +++ b/locales/tools.sort.cli.pot @@ -6,16 +6,16 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" +"Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-09-23 14:16+0100\n" +"POT-Creation-Date: 2022-11-24 14:19+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" -"Language: \n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=CHARSET\n" +"Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.2\n" #: tools/sort/cli.py:14 msgid "This command lets you sort images using various methods." From 4f79ea47e647b527c043e09a3813540e427c7d42 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 19 Jan 2023 18:02:50 +0000 Subject: [PATCH 793/981] bugfix: macos - raise tensorflow-metal max version typofix: usage.md --- USAGE.md | 36 ++++++++++----------- requirements/requirements_apple_silicon.txt | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/USAGE.md b/USAGE.md index 49476007ad..de78f5abf8 100755 --- a/USAGE.md +++ b/USAGE.md @@ -2,24 +2,24 @@ **Before attempting any of this, please make sure you have read, understood and completed the [installation instructions](../master/INSTALL.md). If you are experiencing issues, please raise them in the [faceswap Forum](https://faceswap.dev/forum) or the [FaceSwap Discord server](https://discord.gg/FdEwxXd) instead of the main repo.** -- [Workflow](#Workflow) -- [Introduction](#Introduction) - - [Disclaimer](#Disclaimer) - - [Getting Started](#Getting-Started) -- [Extract](#Extract) - - [Gathering raw data](#Gathering-raw-data) - - [Extracting Faces](#Extracting-Faces) - - [General Tips](#General-Tips) -- [Training a model](#Training-a-model) - - [General Tips](#General-Tips-1) -- [Converting a video](#Converting-a-video) - - [General Tips](#General-Tips-2) -- [GUI](#GUI) -- [Video's](#Videos) -- [EFFMPEG](#EFFMPEG) -- [Extracting video frames with FFMPEG](#Extracting-video-frames-with-FFMPEG) -- [Generating a video](#Generating-a-video) -- [Notes](#Notes) +- [Workflow](#workflow) +- [Introduction](#introduction) + - [Disclaimer](#disclaimer) + - [Getting Started](#getting-started) +- [Extract](#extract) + - [Gathering raw data](#gathering-raw-data) + - [Extracting Faces](#extracting-faces) + - [General Tips](#general-tips) +- [Training a model](#training-a-model) + - [General Tips](#general-tips-1) +- [Converting a video](#converting-a-video) + - [General Tips](#general-tips-2) +- [GUI](#gui) +- [Video's](#videos) +- [EFFMPEG](#effmpeg) +- [Extracting video frames with FFMPEG](#extracting-video-frames-with-ffmpeg) +- [Generating a video](#generating-a-video) +- [Notes](#notes) # Introduction diff --git a/requirements/requirements_apple_silicon.txt b/requirements/requirements_apple_silicon.txt index f819f4f62c..efcae7ce54 100644 --- a/requirements/requirements_apple_silicon.txt +++ b/requirements/requirements_apple_silicon.txt @@ -3,7 +3,7 @@ numpy>=1.21.0; python_version < '3.8' numpy>=1.22.0; python_version >= '3.8' tensorflow-macos>=2.8.0,<2.11.0 tensorflow-deps>=2.8.0,<2.11.0 -tensorflow-metal>=0.4.0,<0.6.0 +tensorflow-metal>=0.4.0,<0.7.0 libblas # Conda only # These next 2 should have been installed, but some users complain of errors decorator From a1ef5edd3917f055694a06d1b3250f49a1fb63c0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 21 Jan 2023 00:41:18 +0000 Subject: [PATCH 794/981] tests: - unit test: tools.alignments.media - Add mypy test - Typing fixes --- .github/workflows/pytest.yml | 10 + docs/full/tests/tools.alignments.rst | 15 + docs/full/tests/tools.rst | 1 + lib/gpu_stats/rocm.py | 2 +- lib/gui/popup_configure.py | 2 +- lib/gui/utils/misc.py | 2 +- lib/image.py | 2 +- lib/multithreading.py | 2 +- lib/training/preview_cv.py | 6 +- plugins/convert/writer/ffmpeg.py | 11 +- plugins/convert/writer/gif.py | 5 +- scripts/fsmedia.py | 27 +- setup.cfg | 5 +- tests/lib/model/layers_test.py | 2 +- tests/lib/model/normalization_test.py | 2 +- tests/tools/alignments/media_test.py | 856 ++++++++++++++++++++++++++ tools/alignments/jobs_frames.py | 20 +- tools/alignments/media.py | 8 +- tools/sort/sort.py | 6 +- tools/sort/sort_methods.py | 6 +- 20 files changed, 940 insertions(+), 50 deletions(-) create mode 100644 docs/full/tests/tools.alignments.rst create mode 100644 tests/tools/alignments/media_test.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 3b39a18eee..5cc2e7c296 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -36,6 +36,7 @@ jobs: run: | python -m pip install --upgrade pip pip install flake8 pylint mypy pytest pytest-mock pytest-xvfb wheel + pip install types-attrs types-cryptography types-pyOpenSSL types-PyYAML types-setuptools pip install -r ./requirements/requirements_${{ matrix.backend }}.txt - name: Lint with flake8 run: | @@ -43,6 +44,10 @@ jobs: flake8 . --select=E9,F63,F7,F82 --show-source # exit-zero treats all errors as warnings. flake8 . --exit-zero + - name: MyPy Typing + continue-on-error: true + run: | + mypy . - name: Simple Tests run: | if [ "${{ matrix.backend }}" == "amd" ] ; then echo "{\"PLAIDML_DEVICE_IDS\":[\"llvm_cpu.0\"],\"PLAIDML_EXPERIMENTAL\":true}" > ~/.plaidml; fi ; @@ -75,6 +80,7 @@ jobs: run: | python -m pip install --upgrade pip pip install flake8 pylint mypy pytest pytest-mock wheel + pip install types-attrs types-cryptography types-pyOpenSSL types-PyYAML types-setuptools pip install -r ./requirements/requirements_${{ matrix.backend }}.txt - name: Set Backend EnvVar run: echo "FACESWAP_BACKEND=${{ matrix.backend }}" | Out-File -FilePath $env:GITHUB_ENV -Append @@ -84,6 +90,10 @@ jobs: flake8 . --select=E9,F63,F7,F82 --show-source # exit-zero treats all errors as warnings. flake8 . --exit-zero + - name: MyPy Typing + continue-on-error: true + run: | + mypy . - name: Simple Tests run: py.test -v tests - name: End to End Tests diff --git a/docs/full/tests/tools.alignments.rst b/docs/full/tests/tools.alignments.rst new file mode 100644 index 0000000000..cb5e3d1018 --- /dev/null +++ b/docs/full/tests/tools.alignments.rst @@ -0,0 +1,15 @@ +****************** +alignments package +****************** + +.. contents:: Contents + :local: + +media_test module +***************** +Unittests for the :class:`~tools.alignments.media` module + +.. automodule:: tests.tools.alignments.media_test + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/tests/tools.rst b/docs/full/tests/tools.rst index 881e7c7702..d19ad80daf 100644 --- a/docs/full/tests/tools.rst +++ b/docs/full/tests/tools.rst @@ -11,4 +11,5 @@ Subpackages .. toctree:: :maxdepth: 1 + tools.alignments tools.preview diff --git a/lib/gpu_stats/rocm.py b/lib/gpu_stats/rocm.py index 17db3d6059..c41e96b283 100644 --- a/lib/gpu_stats/rocm.py +++ b/lib/gpu_stats/rocm.py @@ -259,7 +259,7 @@ def _get_sysfs_paths(self) -> List[str]: """ base_dir = "/sys/class/drm/" - retval: list[str] = [] + retval: List[str] = [] if not os.path.exists(base_dir): self._log("warning", f"sysfs not found at '{base_dir}'") return retval diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index bc49316a9f..2cdb5fec5c 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -580,7 +580,7 @@ def _create_links_page(self, key): cursor="hand2") lbl.pack(side=tk.TOP, fill=tk.X, padx=10, pady=(0, 5)) bind = f"{key}|{link}" - lbl.bind("", lambda e, l=bind: self._link_callback(l)) + lbl.bind("", lambda e, x=bind: self._link_callback(x)) return frame diff --git a/lib/gui/utils/misc.py b/lib/gui/utils/misc.py index 142cb2623f..52a6d4e8fd 100644 --- a/lib/gui/utils/misc.py +++ b/lib/gui/utils/misc.py @@ -66,7 +66,7 @@ def complete(self) -> Event: def run(self) -> None: """ Commence the given task in a background thread. """ try: - if self._target: + if self._target is not None: retval = self._target(*self._args, **self._kwargs) self._queue.put(retval) except Exception: # pylint: disable=broad-except diff --git a/lib/image.py b/lib/image.py index 0cfe9eeb06..f85ff99540 100644 --- a/lib/image.py +++ b/lib/image.py @@ -147,7 +147,7 @@ def _previous_keyframe_info(self, index=0): logger.trace("keyframe pts_time: %s, keyframe: %s", prev_pts_time, prev_keyframe) return prev_pts_time, prev_keyframe - def _initialize(self, index=0): + def _initialize(self, index=0): # noqa:C901 """ Replace ImageIO _initialize with a version that explictly uses keyframes. Notes diff --git a/lib/multithreading.py b/lib/multithreading.py index 3ebf4938b3..a06f3993d2 100644 --- a/lib/multithreading.py +++ b/lib/multithreading.py @@ -92,7 +92,7 @@ def check_and_raise_error(self) -> None: def run(self) -> None: """ Runs the target, reraising any errors from within the thread in the caller. """ try: - if self._target: + if self._target is not None: self._target(*self._args, **self._kwargs) except Exception as err: # pylint: disable=broad-except self.err = sys.exc_info() diff --git a/lib/training/preview_cv.py b/lib/training/preview_cv.py index 6e1a18f89e..948edfff19 100644 --- a/lib/training/preview_cv.py +++ b/lib/training/preview_cv.py @@ -31,12 +31,12 @@ class PreviewBuffer(): """ A thread safe class for holding preview images """ - def __init__(self): - logger.debug("Initializing: %s", __class__.__name__) + def __init__(self) -> None: + logger.debug("Initializing: %s", self.__class__.__name__) self._images: Dict[str, "np.ndarray"] = {} self._lock = Lock() self._updated = Event() - logger.debug("Initialized: %s", __class__.__name__) + logger.debug("Initialized: %s", self.__class__.__name__) @property def is_updated(self) -> bool: diff --git a/plugins/convert/writer/ffmpeg.py b/plugins/convert/writer/ffmpeg.py index de9db06c8f..283a581fa5 100644 --- a/plugins/convert/writer/ffmpeg.py +++ b/plugins/convert/writer/ffmpeg.py @@ -56,7 +56,7 @@ def _valid_tunes(self) -> dict: @property def _video_fps(self) -> float: """ float: The fps of the source video. """ - reader = imageio.get_reader(self._source_video, "ffmpeg") + reader = imageio.get_reader(self._source_video, "ffmpeg") # type:ignore[arg-type] retval = reader.get_meta_data()["fps"] reader.close() logger.debug(retval) @@ -235,7 +235,7 @@ def write(self, filename: str, image: np.ndarray) -> None: image: :class:`numpy.ndarray` The converted image to be written """ - logger.trace("Received frame: (filename: '%s', shape: %s", # type: ignore + logger.trace("Received frame: (filename: '%s', shape: %s", # type:ignore[attr-defined] filename, image.shape) if not self._output_dimensions: input_dims = cast(Tuple[int, int], image.shape[:2]) @@ -265,13 +265,14 @@ def _save_from_cache(self) -> None: assert self._writer is not None while self.frame_order: if self.frame_order[0] not in self.cache: - logger.trace("Next frame not ready. Continuing") # type: ignore + logger.trace("Next frame not ready. Continuing") # type:ignore[attr-defined] break save_no = self.frame_order.pop(0) save_image = self.cache.pop(save_no) - logger.trace("Rendering from cache. Frame no: %s", save_no) # type: ignore + logger.trace("Rendering from cache. Frame no: %s", # type:ignore[attr-defined] + save_no) self._writer.send(np.ascontiguousarray(save_image[:, :, ::-1])) - logger.trace("Current cache size: %s", len(self.cache)) # type: ignore + logger.trace("Current cache size: %s", len(self.cache)) # type:ignore[attr-defined] def close(self) -> None: """ Close the ffmpeg writer and mux the audio """ diff --git a/plugins/convert/writer/gif.py b/plugins/convert/writer/gif.py index f31090faf2..3727ee7cd9 100644 --- a/plugins/convert/writer/gif.py +++ b/plugins/convert/writer/gif.py @@ -9,7 +9,7 @@ from ._base import Output, logger if TYPE_CHECKING: - from imageio.plugins.pillowmulti import GIFFormat + from imageio.core import format as im_format # noqa:F401 class Writer(Output): @@ -75,7 +75,7 @@ def _set_frame_order(total_count: int, logger.debug("frame_order: %s", retval) return retval - def _get_writer(self) -> "GIFFormat.Writer": + def _get_writer(self) -> "im_format.Format.Writer": """ Obtain the GIF writer with the requested GIF encoding options. Returns @@ -84,6 +84,7 @@ def _get_writer(self) -> "GIFFormat.Writer": The imageio GIF writer """ logger.debug("writer config: %s", self.config) + assert self._gif_file is not None return imageio.get_writer(self._gif_file, mode="i", **self._gif_params) diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 02339d58dc..92e95eac58 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -9,7 +9,8 @@ import logging import os import sys -from typing import Any, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union +from typing import (Any, cast, Dict, Generator, Iterator, List, + Optional, Tuple, TYPE_CHECKING, Union) import cv2 import numpy as np @@ -154,7 +155,8 @@ def _load(self) -> Dict[str, Any]: logger.debug("Frames with no faces selected for redetection: %s", len(del_keys)) for key in del_keys: if key in data: - logger.trace("Selected for redetection: '%s'", key) # type: ignore + logger.trace("Selected for redetection: '%s'", # type:ignore[attr-defined] + key) del data[key] return data @@ -284,12 +286,12 @@ def _load_video_frames(self) -> Generator[Tuple[str, np.ndarray], None, None]: """ logger.debug("Input is video. Capturing frames") vidname = os.path.splitext(os.path.basename(self._args.input_dir))[0] - reader = imageio.get_reader(self._args.input_dir, "ffmpeg") - for i, frame in enumerate(reader): + reader = imageio.get_reader(self._args.input_dir, "ffmpeg") # type:ignore[arg-type] + for i, frame in enumerate(cast(Iterator[np.ndarray], reader)): # Convert to BGR for cv2 compatibility frame = frame[:, :, ::-1] filename = f"{vidname}_{i + 1:06d}.png" - logger.trace("Loading video frame: '%s'", filename) # type: ignore + logger.trace("Loading video frame: '%s'", filename) # type:ignore[attr-defined] yield filename, frame reader.close() @@ -307,14 +309,14 @@ def load_one_image(self, filename) -> np.ndarray: The image for the requested filename, """ - logger.trace("Loading image: '%s'", filename) # type: ignore + logger.trace("Loading image: '%s'", filename) # type:ignore[attr-defined] if self._is_video: if filename.isdigit(): frame_no = filename else: frame_no = os.path.splitext(filename)[0][filename.rfind("_") + 1:] - logger.trace("Extracted frame_no %s from filename '%s'", # type: ignore - frame_no, filename) + logger.trace( # type:ignore[attr-defined] + "Extracted frame_no %s from filename '%s'", frame_no, filename) retval = self._load_one_video_frame(int(frame_no)) else: retval = read_image(filename, raise_error=True) @@ -333,10 +335,10 @@ def _load_one_video_frame(self, frame_no: int) -> np.ndarray: :class:`numpy.ndarray` The image for the requested frame index, """ - logger.trace("Loading video frame: %s", frame_no) # type: ignore - reader = imageio.get_reader(self._args.input_dir, "ffmpeg") + logger.trace("Loading video frame: %s", frame_no) # type:ignore[attr-defined] + reader = imageio.get_reader(self._args.input_dir, "ffmpeg") # type:ignore[arg-type] reader.set_image_index(frame_no - 1) - frame = reader.get_next_data()[:, :, ::-1] + frame = reader.get_next_data()[:, :, ::-1] # type:ignore[index] reader.close() return frame @@ -593,7 +595,8 @@ def process(self, extract_media: "ExtractMedia") -> None: if not self._font_scale: self._initialize_font(face.aligned.size) - logger.trace("Drawing Landmarks. Frame: '%s'. Face: %s", frame, idx) # type: ignore + logger.trace("Drawing Landmarks. Frame: '%s'. Face: %s", # type:ignore[attr-defined] + frame, idx) # Landmarks for (pos_x, pos_y) in face.aligned.landmarks.astype("int32"): cv2.circle(face.aligned.face, (pos_x, pos_y), 1, (0, 255, 255), -1) diff --git a/setup.cfg b/setup.cfg index facbaafe1c..7f97f73cfc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,6 +27,8 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-numexpr.*] ignore_missing_imports = True +[mypy-numpy.*] +ignore_missing_imports = True [mypy-numpy.core._multiarray_umath.*] ignore_missing_imports = True [mypy-pexpect.*] @@ -41,9 +43,6 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-pynvx.*] ignore_missing_imports = True -[mypy-pyparsing.*] # Remove this when fixed https://github.com/pyparsing/pyparsing/issues/385 -follow_imports = skip -ignore_missing_imports = True [mypy-pytest.*] ignore_missing_imports = True [mypy-scipy.*] diff --git a/tests/lib/model/layers_test.py b/tests/lib/model/layers_test.py index 12caf96bc6..4b83708dfe 100644 --- a/tests/lib/model/layers_test.py +++ b/tests/lib/model/layers_test.py @@ -25,7 +25,7 @@ CONV_ID = get_backend().upper() -def layer_test(layer_cls, kwargs={}, input_shape=None, input_dtype=None, +def layer_test(layer_cls, kwargs={}, input_shape=None, input_dtype=None, # noqa:C901 input_data=None, expected_output=None, expected_output_dtype=None, fixed_batch_size=False): """Test routine for a layer with a single input tensor diff --git a/tests/lib/model/normalization_test.py b/tests/lib/model/normalization_test.py index 6674f6e955..925088cc61 100644 --- a/tests/lib/model/normalization_test.py +++ b/tests/lib/model/normalization_test.py @@ -104,7 +104,7 @@ def test_layer_normalization(center, scale): _PARAMS = ["partial", "bias"] -_VALUES = [(0.0, False), (0.25, False), (0.5, True), (0.75, False), (1.0, True)] +_VALUES = [(0.0, False), (0.25, False), (0.5, True), (0.75, False), (1.0, True)] # type:ignore _IDS = [f"partial={v[0]}|bias={v[1]}[{get_backend().upper()}]" for v in _VALUES] diff --git a/tests/tools/alignments/media_test.py b/tests/tools/alignments/media_test.py new file mode 100644 index 0000000000..8831a54919 --- /dev/null +++ b/tests/tools/alignments/media_test.py @@ -0,0 +1,856 @@ +#!/usr/bin python3 +""" Pytest unit tests for :mod:`tools.alignments.media` """ +import os +from typing import cast, Dict, Generator, List, Tuple +from unittest.mock import MagicMock + +import cv2 +import numpy as np +import pytest +import pytest_mock + +from lib.logger import log_setup +# Need to setup logging to avoid trace/verbose errors +log_setup("DEBUG", f"{__name__}.log", "PyTest, False") + +# pylint:disable=wrong-import-position,protected-access +from lib.utils import FaceswapError # noqa:E402 +from tools.alignments.media import (AlignmentData, Faces, ExtractedFaces, # noqa:E402 + Frames, MediaLoader) + + +class TestAlignmentData: + """ Test for :class:`~tools.alignments.media.AlignmentData` """ + + @pytest.fixture + def alignments_file(self, tmp_path: str) -> Generator[str, None, None]: + """ Fixture for creating dummy alignments files + + Parameters + ---------- + tmp_path: str + pytest temporary path to generate folders + + Yields + ------ + str + Path to a dummy alignments file + """ + alignments_file = os.path.join(tmp_path, "alignments.fsa") + with open(alignments_file, "w", encoding="utf8") as afile: + afile.write("test") + yield alignments_file + os.remove(alignments_file) + + def test_init(self, + alignments_file: str, + mocker: pytest_mock.MockerFixture) -> None: + """ Test for :class:`~tools.alignments.media.AlignmentData` __init__ method + + Parameters + ---------- + alignments_file: str + The temporarily generated alignments file + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking the superclass __init__ + """ + alignments_parent_init = mocker.patch("tools.alignments.media.Alignments.__init__") + mocker.patch("tools.alignments.media.Alignments.frames_count", + new_callable=mocker.PropertyMock(return_value=20)) + + AlignmentData(alignments_file) + folder, filename = os.path.split(alignments_file) + alignments_parent_init.assert_called_once_with(folder, filename=filename) + + def test_check_file_exists(self, alignments_file: str) -> None: + """ Test for :class:`~tools.alignments.media.AlignmentData` _check_file_exists method + + Parameters + ---------- + alignments_file: str + The temporarily generated alignments file + """ + assert AlignmentData.check_file_exists(alignments_file) == os.path.split(alignments_file) + fake_file = "/not/possibly/a/real/path/alignments.fsa" + with pytest.raises(SystemExit): + AlignmentData.check_file_exists(fake_file) + + def test_save(self, + alignments_file: str, + mocker: pytest_mock.MockerFixture) -> None: + """ Test for :class:`~tools.alignments.media.AlignmentData`save method + + Parameters + ---------- + alignments_file: str + The temporarily generated alignments file + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking the superclass calls + """ + mocker.patch("tools.alignments.media.Alignments.__init__") + mocker.patch("tools.alignments.media.Alignments.frames_count", + new_callable=mocker.PropertyMock(return_value=20)) + alignments_parent_backup = mocker.patch("tools.alignments.media.Alignments.backup") + alignments_parent_save = mocker.patch("tools.alignments.media.Alignments.save") + align_data = AlignmentData(alignments_file) + align_data.save() + alignments_parent_backup.assert_called_once() + alignments_parent_save.assert_called_once() + + +@pytest.fixture(name="folder") +def folder_fixture(tmp_path: str) -> Generator[str, None, None]: + """ Fixture for creating dummy folders + + Parameters + ---------- + tmp_path: str + pytest temporary path to generate folders + + Yields + ------ + str + Path to a dummy folder + """ + folder = os.path.join(tmp_path, "images") + os.mkdir(folder) + for fname in (["a.png", "b.png"]): + with open(os.path.join(folder, fname), "wb"): + pass + yield folder + for fname in (["a.png", "b.png"]): + os.remove(os.path.join(folder, fname)) + os.rmdir(folder) + + +class TestMediaLoader: + """ Test for :class:`~tools.alignments.media.MediaLoader` """ + + @pytest.fixture(name="media_loader_instance") + def media_loader_fixture(self, + folder: str, + mocker: pytest_mock.MockerFixture) -> MediaLoader: + """ An instance of :class:`~tools.alignments.media.MediaLoader` with unimplemented + child methods patched out of __init__ and initialized with a dummy folder containing + 2 images + + Parameters + ---------- + folder : str + Dummy media folder + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking subclass calls + + Returns + ------- + :class:`~tools.alignments.media.MediaLoader` + Initialized instance for testing + """ + mocker.patch("tools.alignments.media.MediaLoader.sorted_items", + return_value=os.listdir(folder)) + mocker.patch("tools.alignments.media.MediaLoader.load_items") + loader = MediaLoader(folder) + return loader + + def test_init(self, + folder: str, + mocker: pytest_mock.MockerFixture) -> None: + """ Test for :class:`~tools.alignments.media.MediaLoader`__init__ method + + Parameters + ---------- + folder : str + Dummy media folder + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking subclass calls + """ + sort_patch = mocker.patch("tools.alignments.media.MediaLoader.sorted_items", + return_value=os.listdir(folder)) + load_patch = mocker.patch("tools.alignments.media.MediaLoader.load_items") + loader = MediaLoader(folder) + sort_patch.assert_called_once() + load_patch.assert_called_once() + assert loader.folder == folder + assert loader._count == 2 + assert loader.count == 2 + assert not loader.is_video + + def test_check_input_folder(self, media_loader_instance: MediaLoader) -> None: + """ Test for :class:`~tools.alignments.media.MediaLoader` check_input_folder method + + Parameters + ---------- + media_loader_instance: :class:`~tools.alignments.media.MediaLoader` + The class instance for testing + """ + media_loader = media_loader_instance + assert media_loader.check_input_folder() is None + media_loader.folder = "" + with pytest.raises(SystemExit): + media_loader.check_input_folder() + media_loader.folder = "/this/path/does/not/exist" + with pytest.raises(SystemExit): + media_loader.check_input_folder() + + def test_valid_extension(self, media_loader_instance: MediaLoader) -> None: + """ Test for :class:`~tools.alignments.media.MediaLoader` valid_extension method + + Parameters + ---------- + media_loader_instance: :class:`~tools.alignments.media.MediaLoader` + The class instance for testing + """ + media_loader = media_loader_instance + assert media_loader.valid_extension("test.png") + assert media_loader.valid_extension("test.PNG") + assert media_loader.valid_extension("test.jpg") + assert media_loader.valid_extension("test.JPG") + assert not media_loader.valid_extension("test.doc") + assert not media_loader.valid_extension("test.txt") + assert not media_loader.valid_extension("test.mp4") + + def test_load_image(self, + media_loader_instance: MediaLoader, + mocker: pytest_mock.MockerFixture) -> None: + """ Test for :class:`~tools.alignments.media.MediaLoader` load_image method + + Parameters + ---------- + media_loader_instance: :class:`~tools.alignments.media.MediaLoader` + The class instance for testing + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking loader specific calls + """ + media_loader = media_loader_instance + expected = np.random.rand(256, 256, 3) + media_loader.load_video_frame = cast(MagicMock, # type:ignore + mocker.MagicMock(return_value=expected)) + read_image_patch = mocker.patch("tools.alignments.media.read_image", return_value=expected) + filename = "test.png" + output = media_loader.load_image(filename) + np.testing.assert_equal(expected, output) + read_image_patch.assert_called_once_with(os.path.join(media_loader.folder, filename), + raise_error=True) + + mocker.patch("tools.alignments.media.MediaLoader.is_video", + new_callable=mocker.PropertyMock(return_value=True)) + filename = "test.mp4" + output = media_loader.load_image(filename) + np.testing.assert_equal(expected, output) + media_loader.load_video_frame.assert_called_once_with(filename) + + def test_load_video_frame(self, + media_loader_instance: MediaLoader, + mocker: pytest_mock.MockerFixture) -> None: + """ Test for :class:`~tools.alignments.media.MediaLoader` load_video_frame method + + Parameters + ---------- + media_loader_instance: :class:`~tools.alignments.media.MediaLoader` + The class instance for testing + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking cv2 calls + """ + media_loader = media_loader_instance + filename = "test_0001.png" + with pytest.raises(AssertionError): + media_loader.load_video_frame(filename) + + mocker.patch("tools.alignments.media.MediaLoader.is_video", + new_callable=mocker.PropertyMock(return_value=True)) + expected = np.random.rand(256, 256, 3) + vid_cap = mocker.MagicMock(cv2.VideoCapture) + vid_cap.read.side_effect = ((1, expected), ) + + media_loader._vid_reader = cast(MagicMock, vid_cap) # type:ignore + output = media_loader.load_video_frame(filename) + vid_cap.set.assert_called_once() + np.testing.assert_equal(output, expected) + + def test_stream(self, + media_loader_instance: MediaLoader, + mocker: pytest_mock.MockerFixture) -> None: + """ Test for :class:`~tools.alignments.media.MediaLoader` stream method + + Parameters + ---------- + media_loader_instance: :class:`~tools.alignments.media.MediaLoader` + The class instance for testing + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking loader specific calls + """ + media_loader = media_loader_instance + + loader = mocker.patch("tools.alignments.media.ImagesLoader.load") + expected = [(fname, np.random.rand(256, 256, 3)) + for fname in os.listdir(media_loader.folder)] + loader.side_effect = [expected] + output = list(media_loader.stream()) + assert output == expected + + loader.reset_mock() + + skip_list = [0] + expected = [expected[1]] + loader.side_effect = [expected] + output = list(media_loader.stream(skip_list)) + assert output == expected + assert loader.add_skip_list.called_once_with(skip_list) + + def test_save_image(self, + media_loader_instance: MediaLoader, + mocker: pytest_mock.MockerFixture) -> None: + """ Test for :class:`~tools.alignments.media.MediaLoader` save_image method + + Parameters + ---------- + media_loader_instance: :class:`~tools.alignments.media.MediaLoader` + The class instance for testing + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking saver specific calls + """ + media_loader = media_loader_instance + out_folder = media_loader.folder + filename = "test_out.jpg" + expected_filename = os.path.join(media_loader.folder, "test_out.png") + img = np.random.rand(256, 256, 3) + metadata = {"test": "data"} + + cv2_write_mock = mocker.patch("cv2.imwrite") + cv2_encode_mock = mocker.patch("cv2.imencode") + png_write_meta_mock = mocker.patch("tools.alignments.media.png_write_meta") + open_mock = mocker.patch("builtins.open") + + media_loader.save_image(out_folder, filename, img, metadata=None) + cv2_write_mock.assert_called_once_with(expected_filename, img) + cv2_encode_mock.assert_not_called() + png_write_meta_mock.assert_not_called() + + cv2_write_mock.reset_mock() + + media_loader.save_image(out_folder, filename, img, metadata=metadata) # type:ignore + cv2_write_mock.assert_not_called() + cv2_encode_mock.assert_called_once_with(".png", img) + png_write_meta_mock.assert_called_once() + open_mock.assert_called_once() + + +class TestFaces: + """ Test for :class:`~tools.alignments.media.Faces` """ + + @pytest.fixture(name="faces_instance") + def faces_fixture(self, + folder: str, + mocker: pytest_mock.MockerFixture) -> Faces: + """ An instance of :class:`~tools.alignments.media.Faces` patching out + read_image_meta_batch so nothing is loaded + + Parameters + ---------- + folder : str + Dummy media folder + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking read_image_meta_batch calls + + Returns + ------- + :class:`~tools.alignments.media.Faces` + Initialized instance for testing + """ + mocker.patch("tools.alignments.media.read_image_meta_batch") + loader = Faces(folder, None) + return loader + + def test_init(self, + folder: str, + mocker: pytest_mock.MockerFixture) -> None: + """ Test for :class:`~tools.alignments.media.Faces`__init__ method + + Parameters + ---------- + folder : str + Dummy media folder + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking superclass calls + """ + parent_mock = mocker.patch("tools.alignments.media.super") + alignments_mock = mocker.patch("tools.alignments.media.AlignmentData") + Faces(folder, alignments_mock) + parent_mock.assert_called_once() + + def test__handle_legacy(self, + faces_instance: Faces, + mocker: pytest_mock.MockerFixture, + caplog: pytest.LogCaptureFixture) -> None: + """ Test for :class:`~tools.alignments.media.Faces` _handle_legacy method + + Parameters + ---------- + faces_instance: :class:`~tools.alignments.media.Faces` + Test class instance + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking various objects + caplog: :class:`pytest.LogCaptureFixture + For capturing logging messages + """ + faces = faces_instance + folder = faces.folder + legacy_file = os.path.join(folder, "a.png") + + # No alignments file + with pytest.raises(FaceswapError): + faces._handle_legacy(legacy_file) + + # No returned metadata + alignments_mock = mocker.patch("tools.alignments.media.AlignmentData") + alignments_mock.version = 2.1 + update_mock = mocker.patch("tools.alignments.media.update_legacy_png_header", + return_value={}) + faces = Faces(folder, alignments_mock) + faces.folder = folder + with pytest.raises(FaceswapError): + faces._handle_legacy(legacy_file) + update_mock.assert_called_once_with(legacy_file, alignments_mock) + + # Correct data with logging + caplog.clear() + update_mock.reset_mock() + update_mock.return_value = {"test": "data"} + faces._handle_legacy(legacy_file, log=True) + assert "Legacy faces discovered" in caplog.text + + # Correct data without logging + caplog.clear() + update_mock.reset_mock() + update_mock.return_value = {"test": "data"} + faces._handle_legacy(legacy_file, log=False) + assert "Legacy faces discovered" not in caplog.text + + def test__handle_duplicate(self, faces_instance: Faces) -> None: + """ Test for :class:`~tools.alignments.media.Faces` _handle_duplicate method + + Parameters + ---------- + faces_instance: :class:`~tools.alignments.media.Faces` + The class instance for testing + """ + faces = faces_instance + dupe_dir = os.path.join(faces.folder, "_duplicates") + src_filename = "test_0001.png" + src_face_idx = 0 + paths = [os.path.join(faces.folder, fname) for fname in os.listdir(faces.folder)] + data = dict(source=dict(source_filename=src_filename, + face_index=src_face_idx)) + seen: Dict[str, List[int]] = {} + + # New item + is_dupe = faces._handle_duplicate(paths[0], data, seen) # type:ignore + assert src_filename in seen and seen[src_filename] == [src_face_idx] + assert not os.path.exists(dupe_dir) + assert not is_dupe + + # Dupe item + is_dupe = faces._handle_duplicate(paths[1], data, seen) # type:ignore + assert src_filename in seen and seen[src_filename] == [src_face_idx] + assert len(seen) == 1 + assert os.path.exists(dupe_dir) + assert not os.path.exists(paths[1]) + assert is_dupe + + # Move everything back for fixture cleanup + os.rename(os.path.join(dupe_dir, os.path.basename(paths[1])), paths[1]) + os.rmdir(dupe_dir) + + def test_process_folder(self, + faces_instance: Faces, + mocker: pytest_mock.MockerFixture) -> None: + """ Test for :class:`~tools.alignments.media.Faces` process_folder method + + Parameters + ---------- + faces_instance: :class:`~tools.alignments.media.Faces` + The class instance for testing + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking various logic calls + """ + faces = faces_instance + read_image_meta_mock = mocker.patch("tools.alignments.media.read_image_meta_batch") + img_sources = [os.path.join(faces.folder, fname) for fname in os.listdir(faces.folder)] + meta_data = dict(itxt=dict(source=(dict(source_filename="data.png")))) + expected = [(fname, meta_data["itxt"]) for fname in os.listdir(faces.folder)] + read_image_meta_mock.side_effect = [[(src, meta_data) for src in img_sources]] + + legacy_mock = mocker.patch("tools.alignments.media.Faces._handle_legacy", + return_value=meta_data["itxt"]) + dupe_mock = mocker.patch("tools.alignments.media.Faces._handle_duplicate", + return_value=False) + + # valid itxt + output = list(faces.process_folder()) + assert read_image_meta_mock.call_count == 1 + assert dupe_mock.call_count == 2 + assert not legacy_mock.called + assert output == expected + + dupe_mock.reset_mock() + read_image_meta_mock.reset_mock() + + # valid itxt with alignemnts data + read_image_meta_mock.side_effect = [[(src, meta_data) for src in img_sources]] + faces._alignments = mocker.MagicMock(AlignmentData) + faces._alignments.version = 2.1 # type:ignore + output = list(faces.process_folder()) + assert faces._alignments.frame_exists.call_count == 2 # type:ignore + assert read_image_meta_mock.call_count == 1 + assert dupe_mock.call_count == 2 + + dupe_mock.reset_mock() + read_image_meta_mock.reset_mock() + faces._alignments = None + + # invalid itxt + read_image_meta_mock.side_effect = [[(src, {}) for src in img_sources]] + output = list(faces.process_folder()) + assert read_image_meta_mock.call_count == 1 + assert legacy_mock.call_count == 2 + assert dupe_mock.call_count == 2 + assert output == expected + + def test_load_items(self, + faces_instance: Faces) -> None: + """ Test for :class:`~tools.alignments.media.Faces` load_items method + + Parameters + ---------- + faces_instance: :class:`~tools.alignments.media.Faces` + The class instance for testing + """ + faces = faces_instance + data = [(f"file{idx}.png", dict(source=dict(source_filename=f"src{idx}.png", + face_index=0))) + for idx in range(4)] + faces.file_list_sorted = data # type: ignore + expected = {"src0.png": [0], "src1.png": [0], "src2.png": [0], "src3.png": [0]} + result = faces.load_items() + assert result == expected + + data = [(f"file{idx}.png", dict(source=dict(source_filename=f"src{idx // 2}.png", + face_index=0 if idx % 2 == 0 else 1))) + for idx in range(4)] + faces.file_list_sorted = data # type: ignore + expected = {"src0.png": [0, 1], "src1.png": [0, 1]} + result = faces.load_items() + assert result == expected + + def test_sorted_items(self, + faces_instance: Faces, + mocker: pytest_mock.MockerFixture) -> None: + """ Test for :class:`~tools.alignments.media.Faces` sorted_items method + + Parameters + ---------- + faces_instance: :class:`~tools.alignments.media.Faces` + The class instance for testing + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking various logic calls + """ + faces = faces_instance + data: List[Tuple[str, dict]] = [("file4.png", {}), ("file3.png", {}), + ("file1.png", {}), ("file2.png", {})] + expected = sorted(data) + process_folder_mock = mocker.patch("tools.alignments.media.Faces.process_folder", + side_effect=[data]) + result = faces.sorted_items() + assert process_folder_mock.called + assert result == expected + + +class TestFrames: + """ Test for :class:`~tools.alignments.media.Frames` """ + + def test_process_folder(self, + folder: str, + mocker: pytest_mock.MockerFixture) -> None: + """ Test for :class:`~tools.alignments.media.Frames` process_folder method + + Parameters + ---------- + folder : str + Dummy media folder + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking superclass calls + """ + process_video_mock = mocker.patch("tools.alignments.media.Frames.process_video") + process_frames_mock = mocker.patch("tools.alignments.media.Frames.process_frames") + + frames = Frames(folder, None) + frames.process_folder() + process_frames_mock.assert_called_once() + process_video_mock.assert_not_called() + + process_frames_mock.reset_mock() + mocker.patch("tools.alignments.media.Frames.is_video", + new_callable=mocker.PropertyMock(return_value=True)) + frames = Frames(folder, None) + frames.process_folder() + process_frames_mock.assert_not_called() + process_video_mock.assert_called_once() + + def test_process_frames(self, folder: str) -> None: + """ Test for :class:`~tools.alignments.media.Frames` process_frames method + + Parameters + ---------- + folder : str + Dummy media folder + """ + expected = [dict(frame_fullname="a.png", frame_name="a", frame_extension=".png"), + dict(frame_fullname="b.png", frame_name="b", frame_extension=".png")] + + frames = Frames(folder, None) + returned = list(frames.process_frames()) + assert returned == expected + + def test_process_video(self, folder: str) -> None: + """ Test for :class:`~tools.alignments.media.Frames` process_video method + + Parameters + ---------- + folder : str + Dummy media folder + """ + expected = [dict(frame_fullname="images_000001.png", + frame_name="images_000001", + frame_extension=".png"), + dict(frame_fullname="images_000002.png", + frame_name="images_000002", + frame_extension=".png")] + + frames = Frames(folder, None) + returned = list(frames.process_video()) + assert returned == expected + + def test_load_items(self, folder: str) -> None: + """ Test for :class:`~tools.alignments.media.Frames` load_items method + + Parameters + ---------- + folder : str + Dummy media folder + """ + expected = {"a.png": ("a", ".png"), "b.png": ("b", ".png")} + frames = Frames(folder, None) + result = frames.load_items() + assert result == expected + + def test_sorted_items(self, + folder: str, + mocker: pytest_mock.MockerFixture) -> None: + """ Test for :class:`~tools.alignments.media.Frames` sorted_items method + + Parameters + ---------- + folder : str + Dummy media folder + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking process_folder call + """ + frames = Frames(folder, None) + data = [dict(frame_fullname="c.png", frame_name="c", frame_extension=".png"), + dict(frame_fullname="d.png", frame_name="d", frame_extension=".png"), + dict(frame_fullname="b.jpg", frame_name="b", frame_extension=".jpg"), + dict(frame_fullname="a.png", frame_name="a", frame_extension=".png")] + expected = [dict(frame_fullname="a.png", frame_name="a", frame_extension=".png"), + dict(frame_fullname="b.jpg", frame_name="b", frame_extension=".jpg"), + dict(frame_fullname="c.png", frame_name="c", frame_extension=".png"), + dict(frame_fullname="d.png", frame_name="d", frame_extension=".png")] + process_folder_mock = mocker.patch("tools.alignments.media.Frames.process_folder", + side_effect=[data]) + result = frames.sorted_items() + + assert process_folder_mock.called + assert result == expected + + +class TestExtractedFaces: + """ Test for :class:`~tools.alignments.media.ExtractedFaces` """ + + @pytest.fixture(name="extracted_faces_instance") + def extracted_faces_fixture(self, mocker: pytest_mock.MockerFixture) -> ExtractedFaces: + """ An instance of :class:`~tools.alignments.media.ExtractedFaces` patching out Frames and + AlignmentData parameters + + Parameters + ---------- + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking read_image_meta_batch calls + + Returns + ------- + :class:`~tools.alignments.media.ExtractedFaces` + Initialized instance for testing + """ + frames_mock = mocker.MagicMock(Frames) + alignments_mock = mocker.MagicMock(AlignmentData) + return ExtractedFaces(frames_mock, alignments_mock, size=512) + + def test_init(self, extracted_faces_instance: ExtractedFaces) -> None: + """ Test for :class:`~tools.alignments.media.ExtractedFace` __init__ method + + Parameters + ---------- + extracted_faces_instance: :class:`~tools.alignments.media.ExtractedFace` + The class instance for testing + """ + faces = extracted_faces_instance + assert faces.size == 512 + assert faces.padding == int(512 * 0.1875) + assert faces.current_frame is None + assert faces.faces == [] + + def test_get_faces(self, + extracted_faces_instance: ExtractedFaces, + mocker: pytest_mock.MockerFixture) -> None: + """ Test for :class:`~tools.alignments.media.ExtractedFace` get_faces method + + Parameters + ---------- + extracted_faces_instance: :class:`~tools.alignments.media.ExtractedFace` + The class instance for testing + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking Frames and AlignmentData classes + """ + extract_face_mock = mocker.patch("tools.alignments.media.ExtractedFaces.extract_one_face") + faces = extracted_faces_instance + + frame = "test_frame" + img = np.random.rand(256, 256, 3) + + # No alignment data + faces.alignments.get_faces_in_frame.return_value = [] # type:ignore + faces.get_faces(frame, img) + faces.alignments.get_faces_in_frame.assert_called_once_with(frame) # type:ignore + faces.frames.load_image.assert_not_called() # type:ignore + extract_face_mock.assert_not_called() + assert faces.current_frame is None + + faces.alignments.reset_mock() # type:ignore + + # Alignment data + image + faces.alignments.get_faces_in_frame.return_value = [1, 2, 3] # type:ignore + faces.get_faces(frame, img) + faces.alignments.get_faces_in_frame.assert_called_once_with(frame) # type:ignore + faces.frames.load_image.assert_not_called() # type:ignore + assert extract_face_mock.call_count == 3 + assert faces.current_frame == frame + + faces.alignments.reset_mock() # type:ignore + extract_face_mock.reset_mock() + + # Alignment data + no image + faces.alignments.get_faces_in_frame.return_value = ["data1"] # type:ignore + faces.get_faces(frame, None) + faces.alignments.get_faces_in_frame.assert_called_once_with(frame) # type:ignore + faces.frames.load_image.assert_called_once_with(frame) # type:ignore + assert extract_face_mock.call_count == 1 + assert faces.current_frame == frame + + def test_extract_one_face(self, + extracted_faces_instance: ExtractedFaces, + mocker: pytest_mock.MockerFixture) -> None: + """ Test for :class:`~tools.alignments.media.ExtractedFace` extract_one_face method + + Parameters + ---------- + extracted_faces_instance: :class:`~tools.alignments.media.ExtractedFace` + The class instance for testing + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking DetectedFace object + """ + detected_face = mocker.patch("tools.alignments.media.DetectedFace") + thumbnail_mock = mocker.patch("tools.alignments.media.generate_thumbnail") + faces = extracted_faces_instance + alignment = {"test"} + img = np.random.rand(256, 256, 3) + returned = faces.extract_one_face(alignment, img) # type:ignore + detected_face.assert_called_once() + detected_face.return_value.from_alignment.assert_called_once_with(alignment, + image=img) + detected_face.return_value.load_aligned.assert_called_once_with(img, + size=512, + centering="head") + thumbnail_mock.assert_called_once() + assert isinstance(returned, MagicMock) + + def test_get_faces_in_frame(self, + extracted_faces_instance: ExtractedFaces, + mocker: pytest_mock.MockerFixture) -> None: + """ Test for :class:`~tools.alignments.media.ExtractedFace` get_faces_in_frame method + + Parameters + ---------- + extracted_faces_instance: :class:`~tools.alignments.media.ExtractedFace` + The class instance for testing + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking get_faces method + """ + faces = extracted_faces_instance + faces.get_faces = cast(MagicMock, mocker.MagicMock()) # type:ignore + + frame = "test_frame" + img = None + + faces.get_faces_in_frame(frame, update=False, image=img) + faces.get_faces.assert_called_once_with(frame, image=img) + + faces.get_faces.reset_mock() + + faces.current_frame = frame + faces.get_faces_in_frame(frame, update=False, image=img) + faces.get_faces.assert_not_called() + + faces.get_faces_in_frame(frame, update=True, image=img) + faces.get_faces.assert_called_once_with(frame, image=img) + + _params = [(np.array(([[25, 47], [32, 232], [244, 237], [240, 21]])), 216), + (np.array(([[127, 392], [403, 510], [32, 237], [19, 210]])), 211), + (np.array(([[26, 1927], [112, 1234], [1683, 1433], [78, 1155]])), 773)] + + @pytest.mark.parametrize("roi,expected", _params) + def test_get_roi_size_for_frame(self, + extracted_faces_instance: ExtractedFaces, + mocker: pytest_mock.MockerFixture, + roi: np.ndarray, + expected: int) -> None: + """ Test for :class:`~tools.alignments.media.ExtractedFace` get_roi_size_for_frame method + + Parameters + ---------- + extracted_faces_instance: :class:`~tools.alignments.media.ExtractedFace` + The class instance for testing + mocker: :class:`pytest_mock.MockerFixture` + Fixture for mocking get_faces method and DetectedFace object + roi: :class:`numpy.ndarray` + Test ROI box to feed into the function + expected: int + The expected output for the given ROI box + """ + faces = extracted_faces_instance + faces.get_faces = cast(MagicMock, mocker.MagicMock()) # type:ignore + + frame = "test_frame" + faces.get_roi_size_for_frame(frame) + faces.get_faces.assert_called_once_with(frame) + + faces.get_faces.reset_mock() + + faces.current_frame = frame + faces.get_roi_size_for_frame(frame) + faces.get_faces.assert_not_called() + + detected_face = mocker.MagicMock("tools.alignments.media.DetectedFace") + detected_face.aligned = detected_face + detected_face.original_roi = roi + faces.faces = [detected_face] + result = faces.get_roi_size_for_frame(frame) + assert result == [expected] diff --git a/tools/alignments/jobs_frames.py b/tools/alignments/jobs_frames.py index 950f1e8b12..8df193e325 100644 --- a/tools/alignments/jobs_frames.py +++ b/tools/alignments/jobs_frames.py @@ -4,14 +4,14 @@ import os import sys from datetime import datetime -from typing import cast, Dict, List, Optional, TYPE_CHECKING, Union +from typing import cast, Dict, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 import numpy as np from tqdm import tqdm from lib.align import DetectedFace, _EXTRACT_RATIOS -from lib.align.alignments import _VERSION +from lib.align.alignments import _VERSION, PNGHeaderDict from lib.image import encode_image, generate_thumbnail, ImagesSaver from plugins.extract.pipeline import Extractor, ExtractMedia from .media import ExtractedFaces, Frames @@ -370,13 +370,15 @@ def _output_faces(self, filename: str, image: np.ndarray) -> int: for idx, face in enumerate(faces): output = f"{frame_name}_{idx}.png" - meta = dict(alignments=face.to_png_meta(), - source=dict(alignments_version=self._alignments.version, - original_filename=output, - face_index=idx, - source_filename=filename, - source_is_video=self._frames.is_video, - source_frame_dims=image.shape[:2])) + meta: PNGHeaderDict = dict( + alignments=face.to_png_meta(), + source=dict(alignments_version=self._alignments.version, + original_filename=output, + face_index=idx, + source_filename=filename, + source_is_video=self._frames.is_video, + source_frame_dims=cast(Tuple[int, int], image.shape[:2]))) + assert face.aligned.face is not None self._saver.save(output, encode_image(face.aligned.face, ".png", metadata=meta)) if self._min_size == 0 and self._is_legacy: face.thumbnail = generate_thumbnail(face.aligned.face, size=96, quality=60) diff --git a/tools/alignments/media.py b/tools/alignments/media.py index c3c49559c3..ee471e2f3e 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -45,7 +45,7 @@ def __init__(self, alignments_file: str) -> None: @staticmethod def check_file_exists(alignments_file: str) -> Tuple[str, str]: - """ Check the alignments file exists + """ Check if the alignments file exists, and returns a tuple of the folder and filename. Parameters ---------- @@ -83,6 +83,7 @@ class MediaLoader(): count: int or ``None``, optional If the total frame count is known it can be passed in here which will skip analyzing a video file. If the count is not passed in, it will be calculated. + Default: ``None`` """ def __init__(self, folder: str, count: Optional[int] = None): logger.debug("Initializing %s: (folder: '%s')", self.__class__.__name__, folder) @@ -112,7 +113,7 @@ def count(self) -> int: return self._count def check_input_folder(self) -> Optional[cv2.VideoCapture]: - """ makes sure that the frames or faces folder exists + """ Ensure that the frames or faces folder exists and is valid. If frames folder contains a video file return imageio reader object Returns @@ -204,6 +205,7 @@ def load_video_frame(self, filename: str) -> "np.ndarray": logger.trace("Loading video frame: '%s'", frame) # type: ignore frame_no = int(frame[frame.rfind("_") + 1:]) - 1 self._vid_reader.set(cv2.CAP_PROP_POS_FRAMES, frame_no) # pylint: disable=no-member + _, image = self._vid_reader.read() # TODO imageio single frame seek seems slow. Look into this # self._vid_reader.set_image_index(frame_no) @@ -356,7 +358,7 @@ def process_folder(self) -> Generator[Tuple[str, "PNGHeaderDict"], None, None]: logger.info("Loading file list from %s", self.folder) filter_count = 0 dupe_count = 0 - seen: dict[str, list[int]] = {} + seen: Dict[str, List[int]] = {} if self._alignments is not None and self._alignments.version < 2.1: # Legacy updating filelist = [os.path.join(self.folder, face) diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 967ef9cdcf..715100455d 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -9,7 +9,7 @@ from argparse import Namespace from shutil import copyfile, rmtree -from typing import List, Optional, TYPE_CHECKING +from typing import Dict, List, Optional, TYPE_CHECKING from tqdm import tqdm @@ -125,7 +125,7 @@ def process(self) -> None: class _Sort(): # pylint:disable=too-few-public-methods """ Sorts folders of faces based on input criteria """ - def __init__(self, arguments): + def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: arguments: %s", self.__class__.__name__, arguments) self._processes = dict(blur=SortBlur, blur_fft=SortBlur, @@ -146,7 +146,7 @@ def __init__(self, arguments): color_orange=SortColor) self._args = self._parse_arguments(arguments) - self._changes = {} + self._changes: Dict[str, str] = {} self.serializer: Optional[Serializer] = None if arguments.log_changes: diff --git a/tools/sort/sort_methods.py b/tools/sort/sort_methods.py index eec4638775..a2f7c2f1e0 100644 --- a/tools/sort/sort_methods.py +++ b/tools/sort/sort_methods.py @@ -305,19 +305,19 @@ def _sort_filelist(self) -> None: [r[0] if isinstance(r, (tuple, list)) else r for r in self._result]) @classmethod - def _get_unique_labels(cls, numbers: List[float]) -> List[str]: + def _get_unique_labels(cls, numbers: np.ndarray) -> List[str]: """ For a list of threshold values for displaying in the bin name, get the lowest number of decimal figures (down to int) required to have a unique set of folder names and return the formatted numbers. Parameters ---------- - numbers: list + numbers: :class:`numpy.ndarray` The list of floating point threshold numbers being used as boundary points Returns ------- - list + list[str] The string formatted numbers at the lowest precision possible to represent them uniquely """ From b4212dedc955cdd8ee008e22e0413f6e511b8efa Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 3 Feb 2023 12:20:42 +0000 Subject: [PATCH 795/981] bugfix: pin numpy --- requirements/requirements_apple_silicon.txt | 5 +++-- requirements/requirements_cpu.txt | 5 +++-- requirements/requirements_directml.txt | 5 +++-- requirements/requirements_nvidia.txt | 5 +++-- requirements/requirements_rocm.txt | 5 +++-- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/requirements/requirements_apple_silicon.txt b/requirements/requirements_apple_silicon.txt index efcae7ce54..6125112039 100644 --- a/requirements/requirements_apple_silicon.txt +++ b/requirements/requirements_apple_silicon.txt @@ -1,6 +1,7 @@ protobuf>= 3.19.0,<3.20.0 # TF has started pulling in incompatible protobuf -numpy>=1.21.0; python_version < '3.8' -numpy>=1.22.0; python_version >= '3.8' +# Pinned TF probability doesn't work with numpy >= 1.24 +numpy>=1.21.0,<1.24.0; python_version < '3.8' +numpy>=1.22.0,<1.24.0; python_version >= '3.8' tensorflow-macos>=2.8.0,<2.11.0 tensorflow-deps>=2.8.0,<2.11.0 tensorflow-metal>=0.4.0,<0.7.0 diff --git a/requirements/requirements_cpu.txt b/requirements/requirements_cpu.txt index 9eee198cd1..52b3315fb6 100644 --- a/requirements/requirements_cpu.txt +++ b/requirements/requirements_cpu.txt @@ -1,4 +1,5 @@ -r _requirements_base.txt -numpy>=1.21.0; python_version < '3.8' -numpy>=1.22.0; python_version >= '3.8' +# Pinned TF probability doesn't work with numpy >= 1.24 +numpy>=1.21.0,<1.24.0; python_version < '3.8' +numpy>=1.22.0,<1.24.0; python_version >= '3.8' tensorflow-cpu>=2.7.0,<2.11.0 diff --git a/requirements/requirements_directml.txt b/requirements/requirements_directml.txt index 940b5b3a22..9c4319caff 100644 --- a/requirements/requirements_directml.txt +++ b/requirements/requirements_directml.txt @@ -1,6 +1,7 @@ -r _requirements_base.txt -numpy>=1.21.0; python_version < '3.8' -numpy>=1.22.0; python_version >= '3.8' +# Pinned TF probability doesn't work with numpy >= 1.24 +numpy>=1.21.0,<1.24.0; python_version < '3.8' +numpy>=1.22.0,<1.24.0; python_version >= '3.8' tensorflow-cpu>=2.10.0,<2.11.0 tensorflow-directml-plugin comtypes diff --git a/requirements/requirements_nvidia.txt b/requirements/requirements_nvidia.txt index a5b2dbe152..829b3a7ac1 100644 --- a/requirements/requirements_nvidia.txt +++ b/requirements/requirements_nvidia.txt @@ -1,5 +1,6 @@ -r _requirements_base.txt -numpy>=1.21.0; python_version < '3.8' -numpy>=1.22.0; python_version >= '3.8' +# Pinned TF probability doesn't work with numpy >= 1.24 +numpy>=1.21.0,<1.24.0; python_version < '3.8' +numpy>=1.22.0,<1.24.0; python_version >= '3.8' tensorflow-gpu>=2.7.0,<2.11.0 pynvx==1.0.0 ; sys_platform == "darwin" diff --git a/requirements/requirements_rocm.txt b/requirements/requirements_rocm.txt index fd6b265ebd..e7bfc6c0a0 100644 --- a/requirements/requirements_rocm.txt +++ b/requirements/requirements_rocm.txt @@ -1,4 +1,5 @@ -r _requirements_base.txt -numpy>=1.21.0; python_version < '3.8' -numpy>=1.22.0; python_version >= '3.8' +# Pinned TF probability doesn't work with numpy >= 1.24 +numpy>=1.21.0,<1.24.0; python_version < '3.8' +numpy>=1.22.0,<1.24.0; python_version >= '3.8' tensorflow-rocm>=2.10.0,<2.11.0 From 8a5ee6d9bfb21c896e24513fd3c111c729219e26 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 10 Feb 2023 15:52:34 +0000 Subject: [PATCH 796/981] Bugfix: Patch extract memory leak in batch mode --- lib/image.py | 8 ++++++-- lib/multithreading.py | 7 ++++++- plugins/extract/_base.py | 1 - scripts/extract.py | 17 ++++++++++++++--- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/lib/image.py b/lib/image.py index f85ff99540..dd9910fa05 100644 --- a/lib/image.py +++ b/lib/image.py @@ -895,9 +895,10 @@ def _check_location_exists(self): def _set_thread(self): """ Set the background thread for the load and save iterators and launch it. """ - logger.debug("Setting thread") + logger.trace("Setting thread") # type:ignore[attr-defined] if self._thread is not None and self._thread.is_alive(): - logger.debug("Thread pre-exists and is alive: %s", self._thread) + logger.trace("Thread pre-exists and is alive: %s", # type:ignore[attr-defined] + self._thread) return self._thread = MultiThread(self._process, self._queue, @@ -921,6 +922,7 @@ def close(self): logger.debug("Received Close") if self._thread is not None: self._thread.join() + del self._thread self._thread = None logger.debug("Closed") @@ -1461,6 +1463,8 @@ def _save(self, logger.trace("Saved image: '%s'", filename) # type:ignore except Exception as err: # pylint: disable=broad-except logger.error("Failed to save image '%s'. Original Error: %s", filename, str(err)) + del image + del filename def save(self, filename: str, diff --git a/lib/multithreading.py b/lib/multithreading.py index a06f3993d2..e85685a893 100644 --- a/lib/multithreading.py +++ b/lib/multithreading.py @@ -206,7 +206,10 @@ def completed(self) -> bool: return retval def join(self) -> None: - """ Join the running threads, catching and re-raising any errors """ + """ Join the running threads, catching and re-raising any errors + + Clear the list of threads for class instance re-use + """ logger.debug("Joining Threads: '%s'", self._name) for thread in self._threads: logger.debug("Joining Thread: '%s'", thread._name) # pylint: disable=protected-access @@ -215,6 +218,8 @@ def join(self) -> None: logger.error("Caught exception in thread: '%s'", thread._name) # pylint: disable=protected-access raise thread.err[1].with_traceback(thread.err[2]) + del self._threads + self._threads = [] logger.debug("Joined all Threads: '%s'", self._name) diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 188eb67a29..efb95b7e08 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -394,7 +394,6 @@ def join(self) -> None: """ for thread in self._threads: thread.join() - del thread def check_and_raise_error(self) -> None: """ Check all threads for errors diff --git a/scripts/extract.py b/scripts/extract.py index 4e9c189d2d..35b5343c8f 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -7,6 +7,7 @@ import os import sys from argparse import Namespace +from multiprocessing import Process from typing import List, Dict, Optional, Tuple, TYPE_CHECKING, Union import numpy as np @@ -150,19 +151,29 @@ def process(self) -> None: Should only be called from :class:`lib.cli.launcher.ScriptExecutor` """ logger.info('Starting, this may take a while...') - inputs = self._input_locations if self._args.batch_mode: logger.info("Batch mode selected processing: %s", self._input_locations) for job_no, location in enumerate(self._input_locations): if self._args.batch_mode: - logger.info("Processing job %s of %s: '%s'", job_no + 1, len(inputs), location) + logger.info("Processing job %s of %s: '%s'", + job_no + 1, len(self._input_locations), location) arguments = Namespace(**self._args.__dict__) arguments.input_dir = location arguments.output_dir = self._output_for_input(location) else: arguments = self._args extract = _Extract(self._extractor, arguments) - extract.process() + if len(self._input_locations) > 1: + # TODO - Running this in a process is hideously hacky. However, there is a memory + # leak in some instances when running in batch mode. Many days have been spent + # trying to track this down to no avail (most likely coming from C-code.) Running + # the extract job inside a process prevents the memory leak in testing. This should + # be replaced if/when the memory leak is found + proc = Process(target=extract.process) + proc.start() + proc.join() + else: + extract.process() self._extractor.reset_phase_index() From 4a5a10977b33593882ca3035b16e1b46571951f0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 10 Feb 2023 18:02:14 +0000 Subject: [PATCH 797/981] Bugfix: Disable batch mode patch for non-Linux machines --- scripts/extract.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/extract.py b/scripts/extract.py index 35b5343c8f..f20dbb8f77 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -163,12 +163,14 @@ def process(self) -> None: else: arguments = self._args extract = _Extract(self._extractor, arguments) - if len(self._input_locations) > 1: + if sys.platform == "linux" and len(self._input_locations) > 1: # TODO - Running this in a process is hideously hacky. However, there is a memory # leak in some instances when running in batch mode. Many days have been spent # trying to track this down to no avail (most likely coming from C-code.) Running # the extract job inside a process prevents the memory leak in testing. This should # be replaced if/when the memory leak is found + # Only done for Linux as not reported elsewhere and this new process won't work in + # Windows because it can't fork. proc = Process(target=extract.process) proc.start() proc.join() From 757088f68b6d2e86f626dbf3fb0f0e1a8e04e787 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 11 Feb 2023 23:58:45 +0000 Subject: [PATCH 798/981] bugfix: Linux installer -cpu mode --- .install/linux/faceswap_setup_x64.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.install/linux/faceswap_setup_x64.sh b/.install/linux/faceswap_setup_x64.sh index d29c8bf95f..66bb7f766f 100644 --- a/.install/linux/faceswap_setup_x64.sh +++ b/.install/linux/faceswap_setup_x64.sh @@ -411,8 +411,7 @@ clone_faceswap() { setup_faceswap() { # Run faceswap setup script info "Setting up Faceswap..." - if [ $VERSION != "cpu" ] ; then args="--$VERSION" ; else args="" ; fi - python -u "$DIR_FACESWAP/setup.py" --installer $args + python -u "$DIR_FACESWAP/setup.py" --installer --$VERSION } create_gui_launcher () { From 2028579756c5b7d9e2f9b1ee4b2bb565916f1a3f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 13 Feb 2023 15:03:34 +0000 Subject: [PATCH 799/981] Preview - Allow mask color/opacity configuration - typing: lib.config - docs: lib.config - bugfix: popup_configure - keep assets on top --- docs/full/lib/config.rst | 7 + lib/config.py | 477 ++++++++++++++------- lib/gui/_config.py | 6 +- lib/gui/control_helper.py | 18 +- lib/gui/display_command.py | 20 +- lib/gui/popup_configure.py | 118 +++-- lib/gui/utils/file_handler.py | 25 +- lib/training/augmentation.py | 41 +- lib/training/cache.py | 16 +- lib/training/generator.py | 11 +- plugins/extract/_config.py | 2 +- plugins/train/_config.py | 12 +- plugins/train/model/_base/model.py | 9 +- plugins/train/trainer/_base.py | 87 ++-- plugins/train/trainer/original_defaults.py | 14 + tools/manual/manual.py | 2 +- tools/preview/control_panels.py | 44 +- 17 files changed, 592 insertions(+), 317 deletions(-) create mode 100755 docs/full/lib/config.rst diff --git a/docs/full/lib/config.rst b/docs/full/lib/config.rst new file mode 100755 index 0000000000..dcd5dcb8b8 --- /dev/null +++ b/docs/full/lib/config.rst @@ -0,0 +1,7 @@ +config module +============= + +.. automodule:: lib.config + :members: + :undoc-members: + :show-inheritance: diff --git a/lib/config.py b/lib/config.py index b5981797e6..a5cfc58843 100644 --- a/lib/config.py +++ b/lib/config.py @@ -7,48 +7,110 @@ import os import sys import textwrap + from collections import OrderedDict from configparser import ConfigParser +from dataclasses import dataclass from importlib import import_module +from typing import Dict, List, Optional, Tuple, Union from lib.utils import full_path_split logger = logging.getLogger(__name__) # pylint: disable=invalid-name +ConfigValueType = Union[bool, int, float, List[str], str, None] + + +@dataclass +class ConfigItem: + """ Dataclass for holding information about configuration items + + Parameters + ---------- + default: any + The default value for the configuration item + helptext: str + The helptext to be displayed for the configuration item + datatype: type + The type of the configuration item + rounding: int + The decimal places for floats or the step interval for ints for slider updates + min_max: tuple + The minumum and maximum value for the GUI slider for the configuration item + gui_radio: bool + ``True`` to display the configuration item in a Radio Box + fixed: bool + ``True`` if the item cannot be changed for existing models (training only) + group: str + The group that this configuration item belongs to in the GUI + """ + default: ConfigValueType + helptext: str + datatype: type + rounding: int + min_max: Optional[Union[Tuple[int, int], Tuple[float, float]]] + choices: Union[str, List[str]] + gui_radio: bool + fixed: bool + group: Optional[str] + + +@dataclass +class ConfigSection: + """ Dataclass for holding information about configuration sections + + Parameters + ---------- + helptext: str + The helptext to be displayed for the configuration section + items: :class:`collections.OrderedDict` + Dictionary of configuration items for the section + """ + helptext: str + items: OrderedDict[str, ConfigItem] class FaceswapConfig(): """ Config Items """ - def __init__(self, section, configfile=None): - """ Init Configuration """ + def __init__(self, section: Optional[str], configfile: Optional[str] = None) -> None: + """ Init Configuration + + Parameters + ---------- + section: str or ``None`` + The configuration section. ``None`` for all sections + configfile: str, optional + Optional path to a config file. ``None`` for default location. Default: ``None`` + """ logger.debug("Initializing: %s", self.__class__.__name__) - self.configfile = self.get_config_file(configfile) + self.configfile = self._get_config_file(configfile) self.config = ConfigParser(allow_no_value=True) - self.defaults = OrderedDict() - self.config.optionxform = str + self.defaults: OrderedDict[str, ConfigSection] = OrderedDict() + self.config.optionxform = str # type:ignore self.section = section self.set_defaults() - self.handle_config() + self._handle_config() logger.debug("Initialized: %s", self.__class__.__name__) @property - def changeable_items(self): + def changeable_items(self) -> Dict[str, ConfigValueType]: """ Training only. Return a dict of config items with their set values for items that can be altered after the model has been created """ - retval = dict() + retval: Dict[str, ConfigValueType] = {} sections = [sect for sect in self.config.sections() if sect.startswith("global")] - for sect in sections + [self.section]: + all_sections = sections if self.section is None else sections + [self.section] + for sect in all_sections: if sect not in self.defaults: continue - for key, val in self.defaults[sect].items(): - if key == "helptext" or val["fixed"]: + for key, val in self.defaults[sect].items.items(): + if val.fixed: continue retval[key] = self.get(sect, key) logger.debug("Alterable for existing models: %s", retval) return retval - def set_defaults(self): + def set_defaults(self) -> None: """ Override for plugin specific config defaults Should be a series of self.add_section() and self.add_item() calls @@ -56,8 +118,8 @@ def set_defaults(self): e.g: section = "sect_1" - self.add_section(title=section, - info="Section 1 Information") + self.add_section(section, + "Section 1 Information") self.add_item(section=section, title="option_1", @@ -67,7 +129,7 @@ def set_defaults(self): """ raise NotImplementedError - def _defaults_from_plugin(self, plugin_folder): + def _defaults_from_plugin(self, plugin_folder: str) -> None: """ Scan the given plugins folder for config defaults.py files and update the default configuration. @@ -83,11 +145,14 @@ def _defaults_from_plugin(self, plugin_folder): base_path = os.path.dirname(os.path.realpath(sys.argv[0])) # Can't use replace as there is a bug on some Windows installs that lowers some paths import_path = ".".join(full_path_split(dirpath[len(base_path):])[1:]) - plugin_type = import_path.split(".")[-1] + plugin_type = import_path.rsplit(".", maxsplit=1)[-1] for filename in default_files: self._load_defaults_from_module(filename, import_path, plugin_type) - def _load_defaults_from_module(self, filename, module_path, plugin_type): + def _load_defaults_from_module(self, + filename: str, + module_path: str, + plugin_type: str) -> None: """ Load the plugin's defaults module, extract defaults and add to default configuration. Parameters @@ -104,19 +169,20 @@ def _load_defaults_from_module(self, filename, module_path, plugin_type): module = os.path.splitext(filename)[0] section = ".".join((plugin_type, module.replace("_defaults", ""))) logger.debug("Importing defaults module: %s.%s", module_path, module) - mod = import_module("{}.{}".format(module_path, module)) - self.add_section(title=section, info=mod._HELPTEXT) # pylint:disable=protected-access - for key, val in mod._DEFAULTS.items(): # pylint:disable=protected-access + mod = import_module(f"{module_path}.{module}") + self.add_section(section, mod._HELPTEXT) # type:ignore[attr-defined] # pylint:disable=protected-access # noqa:E501 + for key, val in mod._DEFAULTS.items(): # type:ignore[attr-defined] # pylint:disable=protected-access # noqa:E501 self.add_item(section=section, title=key, **val) logger.debug("Added defaults: %s", section) @property - def config_dict(self): - """ Collate global options and requested section into a dictionary with the correct + def config_dict(self) -> Dict[str, ConfigValueType]: + """ dict: Collate global options and requested section into a dictionary with the correct data types """ - conf = dict() + conf: Dict[str, ConfigValueType] = {} sections = [sect for sect in self.config.sections() if sect.startswith("global")] - sections.append(self.section) + if self.section is not None: + sections.append(self.section) for sect in sections: if sect not in self.config.sections(): continue @@ -126,7 +192,7 @@ def config_dict(self): conf[key] = self.get(sect, key) return conf - def get(self, section, option): + def get(self, section: str, option: str) -> ConfigValueType: """ Return a config item in it's correct format. Parameters @@ -142,24 +208,26 @@ def get(self, section, option): The selected configuration option in the correct data format """ logger.debug("Getting config item: (section: '%s', option: '%s')", section, option) - datatype = self.defaults[section][option]["type"] + datatype = self.defaults[section].items[option].datatype + + retval: ConfigValueType if datatype == bool: - func = self.config.getboolean + retval = self.config.getboolean(section, option) elif datatype == int: - func = self.config.getint + retval = self.config.getint(section, option) elif datatype == float: - func = self.config.getfloat + retval = self.config.getfloat(section, option) elif datatype == list: - func = self._parse_list + retval = self._parse_list(section, option) else: - func = self.config.get - retval = func(section, option) + retval = self.config.get(section, option) + if isinstance(retval, str) and retval.lower() == "none": retval = None logger.debug("Returning item: (type: %s, value: %s)", datatype, retval) return retval - def _parse_list(self, section, option): + def _parse_list(self, section: str, option: str) -> List[str]: """ Parse options that are stored as lists in the config file. These can be space or comma-separated items in the config file. They will be returned as a list of strings, regardless of what the final data type should be, so conversion from strings to other @@ -187,32 +255,56 @@ def _parse_list(self, section, option): raw_option, retval, section, option) return retval - def get_config_file(self, configfile): - """ Return the config file from the calling folder or the provided file """ + def _get_config_file(self, configfile: Optional[str]) -> str: + """ Return the config file from the calling folder or the provided file + + Parameters + ---------- + configfile: str or ``None`` + Path to a config file. ``None`` for default location. + + Returns + ------- + str + The full path to the configuration file + """ if configfile is not None: if not os.path.isfile(configfile): - err = "Config file does not exist at: {}".format(configfile) + err = f"Config file does not exist at: {configfile}" logger.error(err) raise ValueError(err) return configfile dirname = os.path.dirname(sys.modules[self.__module__].__file__) folder, fname = os.path.split(dirname) - retval = os.path.join(os.path.dirname(folder), "config", "{}.ini".format(fname)) + retval = os.path.join(os.path.dirname(folder), "config", f"{fname}.ini") logger.debug("Config File location: '%s'", retval) return retval - def add_section(self, title=None, info=None): - """ Add a default section to config file """ + def add_section(self, title: str, info: str) -> None: + """ Add a default section to config file + + Parameters + ---------- + title: str + The title for the section + info: str + The helptext for the section + """ logger.debug("Add section: (title: '%s', info: '%s')", title, info) - if None in (title, info): - raise ValueError("Default config sections must have a title and " - "information text") - self.defaults[title] = OrderedDict() - self.defaults[title]["helptext"] = info - - def add_item(self, section=None, title=None, datatype=str, default=None, info=None, - rounding=None, min_max=None, choices=None, gui_radio=False, fixed=True, - group=None): + self.defaults[title] = ConfigSection(helptext=info, items=OrderedDict()) + + def add_item(self, + section: Optional[str] = None, + title: Optional[str] = None, + datatype: type = str, + default: ConfigValueType = None, + info: Optional[str] = None, + rounding: Optional[int] = None, + min_max: Optional[Union[Tuple[int, int], Tuple[float, float]]] = None, + choices: Optional[Union[str, List[str]]] = None, + gui_radio: bool = False, + fixed: bool = True, + group: Optional[str] = None) -> None: """ Add a default item to a config section For int or float values, rounding and min_max must be set @@ -243,36 +335,43 @@ def add_item(self, section=None, title=None, datatype=str, default=None, info=No "fixed: %s, group: %s)", section, title, datatype, default, info, rounding, min_max, choices, gui_radio, fixed, group) - choices = list() if not choices else choices + choices = [] if not choices else choices - if None in (section, title, default, info): - raise ValueError("Default config items must have a section, title, defult and " - "information text") + assert (section is not None and + title is not None and + default is not None and + info is not None), ("Default config items must have a section, title, defult and " + "information text") if not self.defaults.get(section, None): - raise ValueError("Section does not exist: {}".format(section)) - if datatype not in (str, bool, float, int, list): - raise ValueError("'datatype' must be one of str, bool, float or " - "int: {} - {}".format(section, title)) + raise ValueError(f"Section does not exist: {section}") + assert datatype in (str, bool, float, int, list), ( + f"'datatype' must be one of str, bool, float or int: {section} - {title}") if datatype in (float, int) and (rounding is None or min_max is None): raise ValueError("'rounding' and 'min_max' must be set for numerical options") if isinstance(datatype, list) and not choices: raise ValueError("'choices' must be defined for list based configuration items") - if not isinstance(choices, (list, tuple)): - raise ValueError("'choices' must be a list or tuple") - - info = self.expand_helptext(info, choices, default, datatype, min_max, fixed) - self.defaults[section][title] = {"default": default, - "helptext": info, - "type": datatype, - "rounding": rounding, - "min_max": min_max, - "choices": choices, - "gui_radio": gui_radio, - "fixed": fixed, - "group": group} - - @staticmethod - def expand_helptext(helptext, choices, default, datatype, min_max, fixed): + if choices != "colorchooser" and not isinstance(choices, (list, tuple)): + raise ValueError("'choices' must be a list or tuple or 'colorchooser") + + info = self._expand_helptext(info, choices, default, datatype, min_max, fixed) + self.defaults[section].items[title] = ConfigItem(default=default, + helptext=info, + datatype=datatype, + rounding=rounding or 0, + min_max=min_max, + choices=choices, + gui_radio=gui_radio, + fixed=fixed, + group=group) + + @classmethod + def _expand_helptext(cls, + helptext: str, + choices: Union[str, List[str]], + default: ConfigValueType, + datatype: type, + min_max: Optional[Union[Tuple[int, int], Tuple[float, float]]], + fixed: bool) -> str: """ Add extra helptext info from parameters """ helptext += "\n" if not fixed: @@ -280,70 +379,120 @@ def expand_helptext(helptext, choices, default, datatype, min_max, fixed): if datatype == list: helptext += ("\nIf selecting multiple options then each option should be separated " "by a space or a comma (e.g. item1, item2, item3)\n") - if choices: - helptext += "\nChoose from: {}".format(choices) + if choices and choices != "colorchooser": + helptext += f"\nChoose from: {choices}" elif datatype == bool: helptext += "\nChoose from: True, False" elif datatype == int: + assert min_max is not None cmin, cmax = min_max - helptext += "\nSelect an integer between {} and {}".format(cmin, cmax) + helptext += f"\nSelect an integer between {cmin} and {cmax}" elif datatype == float: + assert min_max is not None cmin, cmax = min_max - helptext += "\nSelect a decimal number between {} and {}".format(cmin, cmax) - helptext += "\n[Default: {}]".format(default) + helptext += f"\nSelect a decimal number between {cmin} and {cmax}" + helptext += f"\n[Default: {default}]" return helptext - def check_exists(self): - """ Check that a config file exists """ + def _check_exists(self) -> bool: + """ Check that a config file exists + + Returns + ------- + bool + ``True`` if the given configuration file exists + """ if not os.path.isfile(self.configfile): logger.debug("Config file does not exist: '%s'", self.configfile) return False logger.debug("Config file exists: '%s'", self.configfile) return True - def create_default(self): + def _create_default(self) -> None: """ Generate a default config if it does not exist """ logger.debug("Creating default Config") - for section, items in self.defaults.items(): - logger.debug("Adding section: '%s')", section) - self.insert_config_section(section, items["helptext"]) - for item, opt in items.items(): + for name, section in self.defaults.items(): + logger.debug("Adding section: '%s')", name) + self.insert_config_section(name, section.helptext) + for item, opt in section.items.items(): logger.debug("Adding option: (item: '%s', opt: '%s')", item, opt) - if item == "helptext": - continue - self.insert_config_item(section, - item, - opt["default"], - opt) + self._insert_config_item(name, item, opt.default, opt) self.save_config() - def insert_config_section(self, section, helptext, config=None): - """ Insert a section into the config """ + def insert_config_section(self, + section: str, + helptext: str, + config: Optional[ConfigParser] = None) -> None: + """ Insert a section into the config + + Parameters + ---------- + section: str + The section title to insert + helptext: str + The help text for the config section + config: :class:`configparser.ConfigParser`, optional + The config parser object to insert the section into. ``None`` to insert it into the + default config. Default: ``None`` + """ logger.debug("Inserting section: (section: '%s', helptext: '%s', config: '%s')", section, helptext, config) config = self.config if config is None else config - config.optionxform = str + config.optionxform = str # type:ignore helptext = self.format_help(helptext, is_section=True) config.add_section(section) config.set(section, helptext) logger.debug("Inserted section: '%s'", section) - def insert_config_item(self, section, item, default, option, - config=None): - """ Insert an item into a config section """ + def _insert_config_item(self, + section: str, + item: str, + default: ConfigValueType, + option: ConfigItem, + config: Optional[ConfigParser] = None) -> None: + """ Insert an item into a config section + + Parameters + ---------- + section: str + The section to insert the item into + item: str + The name of the item to insert + default: ConfigValueType + The default value for the item + option: :class:`ConfigItem` + The configuration option to insert + config: :class:`configparser.ConfigParser`, optional + The config parser object to insert the section into. ``None`` to insert it into the + default config. Default: ``None`` + """ logger.debug("Inserting item: (section: '%s', item: '%s', default: '%s', helptext: '%s', " - "config: '%s')", section, item, default, option["helptext"], config) + "config: '%s')", section, item, default, option.helptext, config) config = self.config if config is None else config - config.optionxform = str - helptext = option["helptext"] + config.optionxform = str # type:ignore + helptext = option.helptext helptext = self.format_help(helptext, is_section=False) config.set(section, helptext) config.set(section, item, str(default)) logger.debug("Inserted item: '%s'", item) - @staticmethod - def format_help(helptext, is_section=False): - """ Format comments for default ini file """ + @classmethod + def format_help(cls, helptext: str, is_section: bool = False) -> str: + """ Format comments for default ini file + + Parameters + ---------- + helptext: str + The help text to be formatted + is_section: bool, optional + ``True`` if the help text pertains to a section. ``False`` if it pertains to an item. + Default: ``True`` + + Returns + ------- + str + The formatted help text + """ logger.debug("Formatting help: (helptext: '%s', is_section: '%s')", helptext, is_section) formatted = "" for hlp in helptext.split("\n"): @@ -357,94 +506,101 @@ def format_help(helptext, is_section=False): if is_section: helptext = helptext.upper() else: - helptext = "\n{}".format(helptext) + helptext = f"\n{helptext}" logger.debug("formatted help: '%s'", helptext) return helptext - def load_config(self): + def _load_config(self) -> None: """ Load values from config """ - logger.verbose("Loading config: '%s'", self.configfile) + logger.verbose("Loading config: '%s'", self.configfile) # type:ignore[attr-defined] self.config.read(self.configfile) - def save_config(self): + def save_config(self) -> None: """ Save a config file """ logger.info("Updating config at: '%s'", self.configfile) - with open(self.configfile, "w") as f_cfgfile: + with open(self.configfile, "w", encoding="utf-8", errors="replace") as f_cfgfile: self.config.write(f_cfgfile) logger.debug("Updated config at: '%s'", self.configfile) - def validate_config(self): + def _validate_config(self) -> None: """ Check for options in default config against saved config and add/remove as appropriate """ logger.debug("Validating config") - if self.check_config_change(): - self.add_new_config_items() - self.check_config_choices() + if self._check_config_change(): + self._add_new_config_items() + self._check_config_choices() logger.debug("Validated config") - def add_new_config_items(self): + def _add_new_config_items(self) -> None: """ Add new items to the config file """ logger.debug("Updating config") new_config = ConfigParser(allow_no_value=True) - for section, items in self.defaults.items(): - self.insert_config_section(section, items["helptext"], new_config) - for item, opt in items.items(): - if item == "helptext": - continue - if section not in self.config.sections(): - logger.debug("Adding new config section: '%s'", section) - opt_value = opt["default"] + for section_name, section in self.defaults.items(): + self.insert_config_section(section_name, section.helptext, new_config) + for item, opt in section.items.items(): + if section_name not in self.config.sections(): + logger.debug("Adding new config section: '%s'", section_name) + opt_value = opt.default else: - opt_value = self.config[section].get(item, opt["default"]) - self.insert_config_item(section, - item, - opt_value, - opt, - new_config) + opt_value = self.config[section_name].get(item, str(opt.default)) + self._insert_config_item(section_name, + item, + opt_value, + opt, + new_config) self.config = new_config - self.config.optionxform = str + self.config.optionxform = str # type:ignore self.save_config() logger.debug("Updated config") - def check_config_choices(self): + def _check_config_choices(self) -> None: """ Check that config items are valid choices """ logger.debug("Checking config choices") - for section, items in self.defaults.items(): - for item, opt in items.items(): - if item == "helptext" or not opt["choices"]: + for section_name, section in self.defaults.items(): + for item, opt in section.items.items(): + if not opt.choices: continue - if opt["type"] == list: # Multi-select items - opt_value = self._parse_list(section, item) - if not opt_value: # No option selected + if opt.datatype == list: # Multi-select items + opt_values = self._parse_list(section_name, item) + if not opt_values: # No option selected continue - if not all(val in opt["choices"] for val in opt_value): - invalid = [val for val in opt_value if val not in opt["choices"]] - valid = ", ".join(val for val in opt_value if val in opt["choices"]) + if not all(val in opt.choices for val in opt_values): + invalid = [val for val in opt_values if val not in opt.choices] + valid = ", ".join(val for val in opt_values if val in opt.choices) logger.warning("The option(s) %s are not valid selections for '%s': '%s'. " - "setting to: '%s'", invalid, section, item, valid) - self.config.set(section, item, valid) + "setting to: '%s'", invalid, section_name, item, valid) + self.config.set(section_name, item, valid) else: # Single-select items - opt_value = self.config.get(section, item) + if opt.choices == "colorchooser": + continue + opt_value = self.config.get(section_name, item) if opt_value.lower() == "none" and any(choice.lower() == "none" - for choice in opt["choices"]): + for choice in opt.choices): continue - if opt_value not in opt["choices"]: - default = str(opt["default"]) + if opt_value not in opt.choices: + default = str(opt.default) logger.warning("'%s' is not a valid config choice for '%s': '%s'. " - "Defaulting to: '%s'", opt_value, section, item, default) - self.config.set(section, item, default) + "Defaulting to: '%s'", + opt_value, section_name, item, default) + self.config.set(section_name, item, default) logger.debug("Checked config choices") - def check_config_change(self): - """ Check whether new default items have been added or removed - from the config file compared to saved version """ + def _check_config_change(self) -> bool: + """ Check whether new default items have been added or removed from the config file + compared to saved version + + Returns + ------- + bool + ``True`` if a config option has been added or removed + """ if set(self.config.sections()) != set(self.defaults.keys()): logger.debug("Default config has new section(s)") return True - for section, items in self.defaults.items(): - opts = [opt for opt in items.keys() if opt != "helptext"] - exists = [opt for opt in self.config[section].keys() + for section_name, section in self.defaults.items(): + opts = list(section.items) + exists = [opt for opt in self.config[section_name].keys() if not opt.startswith(("# ", "\n# "))] if set(exists) != set(opts): logger.debug("Default config has new item(s)") @@ -452,7 +608,7 @@ def check_config_change(self): logger.debug("Default config has not changed") return False - def handle_config(self): + def _handle_config(self) -> None: """ Handle the config. Checks whether a config file exists for this section. If not then a default is created. @@ -461,27 +617,26 @@ def handle_config(self): """ logger.debug("Handling config: (section: %s, configfile: '%s')", self.section, self.configfile) - if not self.check_exists(): - self.create_default() - self.load_config() - self.validate_config() + if not self._check_exists(): + self._create_default() + self._load_config() + self._validate_config() logger.debug("Handled config") -def generate_configs(): +def generate_configs() -> None: """ Generate config files if they don't exist. This script is run prior to anything being set up, so don't use logging Generates the default config files for plugins in the faceswap config folder """ - base_path = os.path.realpath(os.path.dirname(sys.argv[0])) plugins_path = os.path.join(base_path, "plugins") configs_path = os.path.join(base_path, "config") for dirpath, _, filenames in os.walk(plugins_path): if "_config.py" in filenames: section = os.path.split(dirpath)[-1] - config_file = os.path.join(configs_path, "{}.ini".format(section)) + config_file = os.path.join(configs_path, f"{section}.ini") if not os.path.exists(config_file): - mod = import_module("plugins.{}.{}".format(section, "_config")) - mod.Config(None) + mod = import_module(f"plugins.{section}._config") + mod.Config(None) # type:ignore[attr-defined] diff --git a/lib/gui/_config.py b/lib/gui/_config.py index d34b2f186d..7fb1037cd8 100644 --- a/lib/gui/_config.py +++ b/lib/gui/_config.py @@ -26,9 +26,9 @@ def set_globals(self): """ logger.debug("Setting global config") section = "global" - self.add_section(title=section, - info="Faceswap GUI Options.\nConfigure the appearance and behaviour of " - "the GUI") + self.add_section(section, + "Faceswap GUI Options.\nConfigure the appearance and behaviour of " + "the GUI") self.add_item( section=section, title="fullscreen", datatype=bool, default=False, group="startup", info="Start Faceswap maximized.") diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 7ee99d5cb4..f86dfd1046 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -1179,21 +1179,21 @@ def _color_control(self): logger.debug("Add control to Options Frame: (widget: '%s', control: %s, choices: %s)", self.option.name, self.option.control, self.option.choices) frame = ttk.Frame(self.frame, style=f"{self._style}Group.TFrame") + lbl = ttk.Label(frame, + text=self.option.title, + width=self.label_width, + anchor=tk.W, + style=f"{self._style}Group.TLabel") ctl = tk.Frame(frame, - bg=self.option.default, + bg=self.option.value, bd=2, cursor="hand2", relief=tk.SUNKEN, width=round(int(20 * get_config().scaling_factor)), height=round(int(12 * get_config().scaling_factor))) ctl.bind("", lambda *e, c=ctl, t=self.option.title: self._ask_color(c, t)) - ctl.pack(side=tk.LEFT, anchor=tk.W) - lbl = ttk.Label(frame, - text=self.option.title, - width=self.label_width, - anchor=tk.W, - style=f"{self._style}Group.TLabel") - lbl.pack(padx=2, pady=5, side=tk.RIGHT, anchor=tk.N) + lbl.pack(padx=2, pady=5, side=tk.LEFT, anchor=tk.N) + ctl.pack(side=tk.RIGHT, anchor=tk.W) frame.pack(side=tk.LEFT, anchor=tk.W) if self.option.helptext is not None: _get_tooltip(lbl, text=self.option.helptext) @@ -1203,7 +1203,7 @@ def _color_control(self): def _ask_color(self, frame, title): """ Pop ask color dialog set to variable and change frame color """ color = self.option.tk_var.get() - chosen = colorchooser.askcolor(color=color, title=f"{title} Color")[1] + chosen = colorchooser.askcolor(parent=frame, color=color, title=f"{title} Color")[1] if chosen is None: return frame.config(bg=chosen) diff --git a/lib/gui/display_command.py b/lib/gui/display_command.py index ae761e7074..b973d34b6b 100644 --- a/lib/gui/display_command.py +++ b/lib/gui/display_command.py @@ -42,19 +42,19 @@ def __init__(self, *args, **kwargs) -> None: def display_item_set(self) -> None: """ Load the latest preview if available """ - logger.trace("Loading latest preview") # type:ignore + logger.trace("Loading latest preview") # type:ignore[attr-defined] size = int(256 if self.command == "convert" else 128 * get_config().scaling_factor) if not self._preview.load_latest_preview(thumbnail_size=size, frame_dims=(self.winfo_width(), self.winfo_height())): - logger.trace("Preview not updated") # type:ignore + logger.trace("Preview not updated") # type:ignore[attr-defined] return logger.debug("Preview loaded") self.display_item = True def display_item_process(self) -> None: """ Display the preview """ - logger.trace("Displaying preview") # type:ignore + logger.trace("Displaying preview") # type:ignore[attr-defined] if not self.subnotebook.children: self.add_child() else: @@ -70,7 +70,7 @@ def add_child(self) -> None: def update_child(self) -> None: """ Update the preview image on the label """ - logger.trace("Updating preview") # type:ignore + logger.trace("Updating preview") # type:ignore[attr-defined] for widget in self.subnotebook_get_widgets(): widget.configure(image=self._preview.image) @@ -142,9 +142,9 @@ def _add_option_mask_toggle(self) -> None: def display_item_set(self) -> None: """ Load the latest preview if available """ # TODO This seems to be triggering faster than the waittime - logger.trace("Loading latest preview") # type:ignore + logger.trace("Loading latest preview") # type:ignore[attr-defined] if not self._preview.load(): - logger.trace("Preview not updated") # type:ignore + logger.trace("Preview not updated") # type:ignore[attr-defined] return logger.debug("Preview loaded") self.display_item = True @@ -332,18 +332,18 @@ def _add_option_iterations(self) -> None: def display_item_set(self) -> None: """ Load the graph(s) if available """ if Session.is_training and Session.logging_disabled: - logger.trace("Logs disabled. Hiding graph") # type:ignore + logger.trace("Logs disabled. Hiding graph") # type:ignore[attr-defined] self.set_info("Graph is disabled as 'no-logs' has been selected") self.display_item = None self._clear_trace_variables() elif Session.is_training and self.display_item is None: - logger.trace("Loading graph") # type:ignore + logger.trace("Loading graph") # type:ignore[attr-defined] self.display_item = Session self._add_trace_variables() elif Session.is_training and self.display_item is not None: - logger.trace("Graph already displayed. Nothing to do.") # type:ignore + logger.trace("Graph already displayed. Nothing to do.") # type:ignore[attr-defined] else: - logger.trace("Clearing graph") # type:ignore + logger.trace("Clearing graph") # type:ignore[attr-defined] self.display_item = None self._clear_trace_variables() diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 2cdb5fec5c..7cd810dd58 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -9,6 +9,7 @@ import sys import tkinter as tk from tkinter import ttk +from typing import Dict, TYPE_CHECKING from importlib import import_module from lib.serializer import get_serializer @@ -17,6 +18,9 @@ from .custom_widgets import Tooltip from .utils import FileHandler, get_config, get_images, PATHCACHE +if TYPE_CHECKING: + from lib.config import FaceswapConfig + logger = logging.getLogger(__name__) # pylint: disable=invalid-name # LOCALES @@ -120,6 +124,7 @@ def __init__(self, name, configurations): super().__init__() self._root = get_config().root self._set_geometry() + self.attributes('-topmost', 'true') self._tk_vars = dict(header=tk.StringVar()) theme = {**get_config().user_theme["group_panel"], @@ -292,7 +297,7 @@ def _fix_styles(cls, theme): style = ttk.Style() # Fix a bug in Tree-view that doesn't show alternate foreground on selection - fix_map = lambda o: [elm for elm in style.map("Treeview", query_opt=o) # noqa + fix_map = lambda o: [elm for elm in style.map("Treeview", query_opt=o) # noqa[E731] # pylint:disable=C3001 if elm[:2] != ("!disabled", "!selected")] # Remove the Borders @@ -398,7 +403,7 @@ class DisplayArea(ttk.Frame): # pylint:disable=too-many-ancestors """ def __init__(self, top_level, parent, configurations, tree, theme): super().__init__(parent) - self._configs = configurations + self._configs: Dict[str, "FaceswapConfig"] = configurations self._theme = theme self._tree = tree self._vars = {} @@ -441,28 +446,26 @@ def _get_config(self): key = plugin if sect == "global" else f"{plugin}|{category}|{sect}" retval[key] = dict(helptext=None, options=OrderedDict()) - for option, params in conf.defaults[section].items(): - if option == "helptext": - retval[key]["helptext"] = params - continue + retval[key]["helptext"] = conf.defaults[section].helptext + for option, params in conf.defaults[section].items.items(): initial_value = conf.config_dict[option] initial_value = "none" if initial_value is None else initial_value - if params["type"] == list and isinstance(initial_value, list): + if params.datatype == list and isinstance(initial_value, list): # Split multi-select lists into space separated strings for tk variables initial_value = " ".join(initial_value) retval[key]["options"][option] = ControlPanelOption( title=option, - dtype=params["type"], - group=params["group"], - default=params["default"], + dtype=params.datatype, + group=params.group, + default=params.default, initial_value=initial_value, - choices=params["choices"], - is_radio=params["gui_radio"], - is_multi_option=params["type"] == list, - rounding=params["rounding"], - min_max=params["min_max"], - helptext=params["helptext"]) + choices=params.choices, + is_radio=params.gui_radio, + is_multi_option=params.datatype == list, + rounding=params.rounding, + min_max=params.min_max, + helptext=params.helptext) logger.debug("Formatted Config for GUI: %s", retval) return retval @@ -628,6 +631,59 @@ def reset(self, page_only=False): item.set(item.default) logger.debug("Reset config") + def _get_new_config(self, + page_only: bool, + config: "FaceswapConfig", + category: str, + lookup: str) -> ConfigParser: + """ Obtain a new configuration file for saving + + Parameters + ---------- + page_only: bool + ``True`` saves just the currently selected page's options, ``False`` saves all the + plugins options within the currently selected config. + config: :class:`~lib.config.FaceswapConfig` + The original config that is to be addressed + category: str + The configuration category to update + lookup: str + The section of the configuration to update + + Returns + ------- + :class:`configparse.ConfigParser` + The newly created configuration object for saving + """ + new_config = ConfigParser(allow_no_value=True) + for section_name, section in config.defaults.items(): + logger.debug("Adding section: '%s')", section_name) + config.insert_config_section(section_name, section.helptext, config=new_config) + for item, options in section.items.items(): + if item == "helptext": + continue + if page_only and section_name != lookup: + # Keep existing values for pages we are not updating + new_opt = config.get(section_name, item) + logger.debug("Retain existing value '%s' for %s", + new_opt, ".".join([section_name, item])) + else: + # Get currently selected value + key = category + if section_name != "global": + key += f"|{section_name.replace('.', '|')}" + new_opt = self._config_cpanel_dict[key]["options"][item].get() + logger.debug("Updating value to '%s' for %s", + new_opt, ".".join([section_name, item])) + helptext = config.format_help(options.helptext, is_section=False) + new_config.set(section_name, helptext) + if options.datatype == list: # Comma separated multi select options + assert isinstance(new_opt, (list, str)) + new_opt = ", ".join(new_opt if isinstance(new_opt, list) else new_opt.split()) + new_config.set(section_name, item, str(new_opt)) + + return new_config + def save(self, page_only=False): """ Save the configuration file to disk. @@ -642,7 +698,6 @@ def save(self, page_only=False): category = selection.split("|")[0] config = self._configs[category] # Create a new config to pull through any defaults change - new_config = ConfigParser(allow_no_value=True) if "|" in selection: lookup = ".".join(selection.split("|")[1:]) @@ -653,31 +708,7 @@ def save(self, page_only=False): logger.info("No settings to save for the current page") return - for section, items in config.defaults.items(): - logger.debug("Adding section: '%s')", section) - config.insert_config_section(section, items["helptext"], config=new_config) - for item, options in items.items(): - if item == "helptext": - continue - if page_only and section != lookup: - # Keep existing values for pages we are not updating - new_opt = config.get(section, item) - logger.debug("Retain existing value '%s' for %s", - new_opt, ".".join([section, item])) - else: - # Get currently selected value - key = category - if section != "global": - key += f"|{section.replace('.', '|')}" - new_opt = self._config_cpanel_dict[key]["options"][item].get() - logger.debug("Updating value to '%s' for %s", - new_opt, ".".join([section, item])) - helptext = config.format_help(options["helptext"], is_section=False) - new_config.set(section, helptext) - if options["type"] == list: # Comma separated multi select options - new_opt = ", ".join(new_opt if isinstance(new_opt, list) else new_opt.split()) - new_config.set(section, item, str(new_opt)) - config.config = new_config + config.config = self._get_new_config(page_only, config, category, lookup) config.save_config() logger.info("Saved config: '%s'", config.configfile) @@ -783,7 +814,8 @@ def _get_filename(self, action): args = ("save_filename", "json") if action == "save" else ("filename", "json") kwargs = dict(title=f"{action.title()} Preset...", - initial_folder=self._preset_path) + initial_folder=self._preset_path, + parent=self._parent) if action == "save": kwargs["initial_file"] = self._get_initial_filename() diff --git a/lib/gui/utils/file_handler.py b/lib/gui/utils/file_handler.py index c3826072e9..59485c5c63 100644 --- a/lib/gui/utils/file_handler.py +++ b/lib/gui/utils/file_handler.py @@ -54,6 +54,8 @@ class FileHandler(): # pylint:disable=too-few-public-methods variable: str, optional Required for context handling file dialog, otherwise unused. The variable to associate with this file dialog. Default: ``None`` + parent: :class:`tkinter.Frame`, optional + The parent that is launching the file dialog. ``None`` sets this to root. Default: ``None`` Attributes ---------- @@ -76,11 +78,12 @@ def __init__(self, initial_file: Optional[str] = None, command: Optional[str] = None, action: Optional[str] = None, - variable: Optional[str] = None) -> None: + variable: Optional[str] = None, + parent: Optional[tk.Frame] = None) -> None: logger.debug("Initializing %s: (handle_type: '%s', file_type: '%s', title: '%s', " "initial_folder: '%s', initial_file: '%s', command: '%s', action: '%s', " - "variable: %s)", self.__class__.__name__, handle_type, file_type, title, - initial_folder, initial_file, command, action, variable) + "variable: %s, parent: %s)", self.__class__.__name__, handle_type, file_type, + title, initial_folder, initial_file, command, action, variable, parent) self._handletype = handle_type self._dummy_master = self._set_dummy_master() self._defaults = self._set_defaults() @@ -90,7 +93,8 @@ def __init__(self, file_type, command, action, - variable) + variable, + parent) self.return_file = getattr(self, f"_{self._handletype.lower()}")() self._remove_dummy_master() @@ -217,7 +221,8 @@ def _set_kwargs(self, file_type: Optional[_FILETYPE], command: Optional[str], action: Optional[str], - variable: Optional[str] = None + variable: Optional[str], + parent: Optional[tk.Frame] ) -> Dict[str, Union[None, tk.Frame, str, List[Tuple[str, str]]]]: """ Generate the required kwargs for the requested file dialog browser. @@ -241,6 +246,8 @@ def _set_kwargs(self, variable: str, optional Required for context handling file dialog, otherwise unused. The variable to associate with this file dialog. Default: ``None`` + parent: :class:`tkinter.Frame` + The parent that is launching the file dialog. ``None`` sets this to root Returns ------- @@ -248,8 +255,9 @@ def _set_kwargs(self, The key word arguments for the file dialog to be launched """ logger.debug("Setting Kwargs: (title: %s, initial_folder: %s, initial_file: '%s', " - "file_type: '%s', command: '%s': action: '%s', variable: '%s')", - title, initial_folder, initial_file, file_type, command, action, variable) + "file_type: '%s', command: '%s': action: '%s', variable: '%s', parent: %s)", + title, initial_folder, initial_file, file_type, command, action, variable, + parent) kwargs: Dict[str, Union[None, tk.Frame, str, List[Tuple[str, str]]]] = dict(master=self._dummy_master) @@ -267,6 +275,9 @@ def _set_kwargs(self, if initial_file is not None: kwargs["initialfile"] = initial_file + if parent is not None: + kwargs["parent"] = parent + if self._handletype.lower() in ( "open", "save", "filename", "filename_multi", "save_filename"): assert file_type is not None diff --git a/lib/training/augmentation.py b/lib/training/augmentation.py index 1eec9e8bf2..6fff6e085c 100644 --- a/lib/training/augmentation.py +++ b/lib/training/augmentation.py @@ -2,7 +2,7 @@ """ Processes the augmentation of images for feeding into a Faceswap model. """ from dataclasses import dataclass import logging -from typing import Tuple, TYPE_CHECKING +from typing import Dict, Tuple, TYPE_CHECKING import cv2 import numexpr as ne @@ -12,7 +12,7 @@ from lib.image import batch_convert_color if TYPE_CHECKING: - from plugins.train.trainer._base import ConfigType + from lib.config import ConfigValueType logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -79,7 +79,7 @@ class ImageAugmentation(): def __init__(self, batchsize: int, processing_size: int, - config: "ConfigType") -> None: + config: Dict[str, "ConfigValueType"]) -> None: logger.debug("Initializing %s: (batchsize: %s, processing_size: %s, " "config: %s)", self.__class__.__name__, batchsize, processing_size, config) @@ -105,12 +105,29 @@ def _get_constants(self) -> AugConstants: """ logger.debug("Initializing constants.") + # Config variables typing check + shift_range = self._config.get("shift_range", 5) + color_lightness = self._config.get("color_lightness", 30) + color_ab = self._config.get("color_ab", 8) + color_clahe_chance = self._config.get("color_clahe_chance", 50) + color_clahe_max_size = self._config.get("color_clahe_max_size", 4) + rotation_range = self._config.get("rotation_range", 10) + zoom_amount = self._config.get("zoom_amount", 5) + + assert isinstance(shift_range, int) + assert isinstance(color_lightness, int) + assert isinstance(color_ab, int) + assert isinstance(color_clahe_chance, int) + assert isinstance(color_clahe_max_size, int) + assert isinstance(rotation_range, int) + assert isinstance(zoom_amount, int) + # Transform - tform_shift = (int(self._config.get("shift_range", 5)) / 100) * self._processing_size + tform_shift = (shift_range / 100) * self._processing_size # Color Aug - amount_l = int(self._config.get("color_lightness", 30)) / 100 - amount_ab = int(self._config.get("color_ab", 8)) / 100 + amount_l = int(color_lightness) / 100 + amount_ab = int(color_ab) / 100 lab_adjust = np.array([amount_l, amount_ab, amount_ab], dtype="float32") # Random Warp @@ -128,11 +145,11 @@ def _get_constants(self) -> AugConstants: grids = np.mgrid[0: p_mx: complex(self._processing_size), # type: ignore 0: p_mx: complex(self._processing_size)] # type: ignore retval = AugConstants(clahe_base_contrast=max(2, self._processing_size // 128), - clahe_chance=int(self._config.get("color_clahe_chance", 50)) / 100, - clahe_max_size=int(self._config.get("color_clahe_max_size", 4)), + clahe_chance=color_clahe_chance / 100, + clahe_max_size=color_clahe_max_size, lab_adjust=lab_adjust, - transform_rotation=int(self._config.get("rotation_range", 10)), - transform_zoom=int(self._config.get("zoom_amount", 5)) / 100, + transform_rotation=rotation_range, + transform_zoom=zoom_amount / 100, transform_shift=tform_shift, warp_maps=np.stack((warp_mapx, warp_mapy), axis=1), warp_pad=(warp_pad, warp_pad), @@ -259,7 +276,9 @@ def random_flip(self, batch: np.ndarray): """ logger.trace("Randomly flipping image") # type: ignore randoms = np.random.rand(self._batchsize) - indices = np.where(randoms > int(self._config.get("random_flip", 50)) / 100)[0] + flip_chance = self._config.get("random_flip", 50) + assert isinstance(flip_chance, int) + indices = np.where(randoms > flip_chance / 100)[0] batch[indices] = batch[indices, :, ::-1] logger.trace("Randomly flipped %s images of %s", # type: ignore len(indices), self._batchsize) diff --git a/lib/training/cache.py b/lib/training/cache.py index 94014ef410..8bbd5431cf 100644 --- a/lib/training/cache.py +++ b/lib/training/cache.py @@ -22,8 +22,8 @@ from typing import get_args, Literal if TYPE_CHECKING: - from .generator import ConfigType from lib.align.alignments import PNGHeaderAlignmentsDict, PNGHeaderDict + from lib.config import ConfigValueType logger = logging.getLogger(__name__) @@ -32,7 +32,7 @@ def get_cache(side: Literal["a", "b"], filenames: Optional[List[str]] = None, - config: Optional["ConfigType"] = None, + config: Optional[Dict[str, "ConfigValueType"]] = None, size: Optional[int] = None, coverage_ratio: Optional[float] = None) -> "_Cache": """ Obtain a :class:`_Cache` object for the given side. If the object does not pre-exist then @@ -121,7 +121,7 @@ class _Cache(): """ def __init__(self, filenames: List[str], - config: "ConfigType", + config: Dict[str, "ConfigValueType"], size: int, coverage_ratio: float) -> None: logger.debug("Initializing: %s (filenames: %s, size: %s, coverage_ratio: %s)", @@ -425,8 +425,10 @@ def _get_face_mask(self, filename: str, detected_face: DetectedFace) -> Optional f"The masks that exist for this face are: {list(detected_face.mask)}") mask = detected_face.mask[str(self._config["mask_type"])] - mask.set_blur_and_threshold(blur_kernel=int(self._config["mask_blur_kernel"]), - threshold=int(self._config["mask_threshold"])) + assert isinstance(self._config["mask_blur_kernel"], int) + assert isinstance(self._config["mask_threshold"], int) + mask.set_blur_and_threshold(blur_kernel=self._config["mask_blur_kernel"], + threshold=self._config["mask_threshold"]) pose = detected_face.aligned.pose mask.set_sub_crop(pose.offset[mask.stored_centering], @@ -458,7 +460,9 @@ def _get_localized_mask(self, area: str `"eye"` or `"mouth"`. The area of the face to obtain the mask for """ - if not self._config["penalized_mask_loss"] or int(self._config[f"{area}_multiplier"]) <= 1: + multiplier = self._config[f"{area}_multiplier"] + assert isinstance(multiplier, int) + if not self._config["penalized_mask_loss"] or multiplier <= 1: return None mask = detected_face.get_landmark_mask(area, self._size // 16, self._size // 32) logger.trace("Caching localized '%s' mask for: %s %s", # type: ignore diff --git a/lib/training/generator.py b/lib/training/generator.py index 9970206249..0dd6601a12 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -7,7 +7,7 @@ from concurrent import futures from random import shuffle, choice -from typing import cast, Dict, Generator, List, Tuple, TYPE_CHECKING, Union +from typing import cast, Dict, Generator, List, Tuple, TYPE_CHECKING import cv2 import numpy as np @@ -27,11 +27,11 @@ from typing import get_args, Literal if TYPE_CHECKING: + from lib.config import ConfigValueType from plugins.train.model._base import ModelBase from .cache import _Cache logger = logging.getLogger(__name__) -ConfigType = Dict[str, Union[bool, int, float, str]] # TODO Dataclass BatchType = Tuple[np.ndarray, List[np.ndarray]] @@ -57,7 +57,7 @@ class DataGenerator(): objects of this size from the iterator. """ def __init__(self, - config: ConfigType, + config: Dict[str, "ConfigValueType"], model: "ModelBase", side: Literal["a", "b"], images: List[str], @@ -99,7 +99,8 @@ def _total_channels(self) -> int: self._config["penalized_mask_loss"]): channels += 1 - mults = [area for area in ["eye", "mouth"] if int(self._config[f"{area}_multiplier"]) > 1] + mults = [area for area in ["eye", "mouth"] + if cast(int, self._config[f"{area}_multiplier"]) > 1] if self._config["penalized_mask_loss"] and mults: channels += len(mults) return channels @@ -416,7 +417,7 @@ class TrainingDataGenerator(DataGenerator): # pylint:disable=too-few-public-met objects of this size from the iterator. """ def __init__(self, - config: ConfigType, + config: Dict[str, "ConfigValueType"], model: "ModelBase", side: Literal["a", "b"], images: List[str], diff --git a/plugins/extract/_config.py b/plugins/extract/_config.py index 2ec15be51d..fc135c6b20 100644 --- a/plugins/extract/_config.py +++ b/plugins/extract/_config.py @@ -24,7 +24,7 @@ def set_globals(self): """ logger.debug("Setting global config") section = "global" - self.add_section(title=section, info="Options that apply to all extraction plugins") + self.add_section(section, "Options that apply to all extraction plugins") self.add_item( section=section, title="allow_growth", diff --git a/plugins/train/_config.py b/plugins/train/_config.py index b0122d2bd7..ece9a48475 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -104,8 +104,8 @@ def _set_globals(self) -> None: """ Set the global options for training """ logger.debug("Setting global config") section = "global" - self.add_section(title=section, - info="Options that apply to all models" + ADDITIONAL_INFO) + self.add_section(section, + "Options that apply to all models" + ADDITIONAL_INFO) self.add_item( section=section, title="centering", @@ -325,10 +325,10 @@ def _set_loss(self) -> None: # pylint:enable=line-too-long logger.debug("Setting Loss config") section = "global.loss" - self.add_section(title=section, - info="Loss configuration options\n" - "Loss is the mechanism by which a Neural Network judges how well it " - "thinks that it is recreating a face." + ADDITIONAL_INFO) + self.add_section(section, + "Loss configuration options\n" + "Loss is the mechanism by which a Neural Network judges how well it " + "thinks that it is recreating a face." + ADDITIONAL_INFO) self.add_item( section=section, title="loss_function", diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 472110fc73..b2d742daa6 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -41,9 +41,10 @@ if TYPE_CHECKING: import argparse + from lib.config import ConfigValueType logger = logging.getLogger(__name__) # pylint: disable=invalid-name -_CONFIG: Dict[str, Union[str, float, int, bool, None]] = {} +_CONFIG: Dict[str, "ConfigValueType"] = {} def KerasModel(inputs: list, outputs: list, name: str) -> keras.models.Model: # noqa, pylint:disable=invalid-name @@ -412,7 +413,7 @@ def _output_summary(self) -> None: print_fn = None # Print straight to stdout else: # print to logger - print_fn = lambda x: logger.verbose("%s", x) # type: ignore # noqa + print_fn = lambda x: logger.verbose("%s", x) #type:ignore[attr-defined] # noqa[E731] # pylint:disable=C3001 for idx, model in enumerate(get_all_sub_models(self.model)): if idx == 0: parent = model @@ -547,7 +548,7 @@ def __init__(self, self._rebuild_model = False self._sessions: Dict[int, dict] = {} self._lowest_avg_loss: Dict[str, float] = {} - self._config: Dict[str, Union[str, float, int, bool, None]] = {} + self._config: Dict[str, "ConfigValueType"] = {} self._load(config_changeable_items) self._session_id = self._new_session_id() self._create_new_session(no_logs, config_changeable_items) @@ -723,7 +724,7 @@ def _replace_config(self, config_changeable_items) -> None: mixed_precision=False) for key, val in _CONFIG.items(): if key not in self._config.keys(): - setting = legacy_defaults.get(key, val) + setting: "ConfigValueType" = legacy_defaults.get(key, val) logger.info("Adding new config item to state file: '%s': '%s'", key, setting) self._config[key] = setting self._update_changed_config_items(config_changeable_items) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 8daa162843..04df930b72 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -20,13 +20,15 @@ from tensorflow.python.framework import ( # pylint:disable=no-name-in-module errors_impl as tf_errors) +from lib.image import hex_to_rgb from lib.training import PreviewDataGenerator, TrainingDataGenerator -from lib.training.generator import BatchType, ConfigType, DataGenerator +from lib.training.generator import BatchType, DataGenerator from lib.utils import FaceswapError, get_backend, get_folder, get_image_paths, get_tf_version from plugins.train._config import Config if TYPE_CHECKING: from plugins.train.model._base import ModelBase + from lib.config import ConfigValueType if sys.version_info < (3, 8): from typing_extensions import get_args, Literal @@ -36,7 +38,8 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name -def _get_config(plugin_name: str, configfile: Optional[str] = None) -> ConfigType: +def _get_config(plugin_name: str, + configfile: Optional[str] = None) -> Dict[str, "ConfigValueType"]: """ Return the configuration for the requested trainer. Parameters @@ -92,15 +95,23 @@ def __init__(self, self._feeder = _Feeder(images, self._model, batch_size, self._config) self._tensorboard = self._set_tensorboard() - self._samples = _Samples(self._model, self._model.coverage_ratio) + self._samples = _Samples(self._model, + self._model.coverage_ratio, + cast(int, self._config["mask_opacity"]), + cast(str, self._config["mask_color"])) + + num_images = self._config.get("preview_images", 14) + assert isinstance(num_images, int) self._timelapse = _Timelapse(self._model, self._model.coverage_ratio, - int(self._config.get("preview_images", 14)), + num_images, + cast(int, self._config["mask_opacity"]), + cast(str, self._config["mask_color"]), self._feeder, self._images) logger.debug("Initialized %s", self.__class__.__name__) - def _get_config(self, configfile: Optional[str]) -> ConfigType: + def _get_config(self, configfile: Optional[str]) -> Dict[str, "ConfigValueType"]: """ Get the saved training config options. Override any global settings with the setting provided from the model's saved config. @@ -376,14 +387,14 @@ class _Feeder(): The selected model that will be running this trainer batch_size: int The size of the batch to be processed for each side at each iteration - config: :class:`lib.config.FaceswapConfig` + config: dict The configuration for this trainer """ def __init__(self, images: Dict[Literal["a", "b"], List[str]], model: 'ModelBase', batch_size: int, - config: ConfigType) -> None: + config: Dict[str, "ConfigValueType"]) -> None: logger.debug("Initializing %s: num_images: %s, batch_size: %s, config: %s)", self.__class__.__name__, {k: len(v) for k, v in images.items()}, batch_size, config) @@ -446,10 +457,11 @@ def _set_preview_feed(self) -> Dict[Literal["a", "b"], Generator[BatchType, None value. """ retval: Dict[Literal["a", "b"], Generator[BatchType, None, None]] = {} + num_images = self._config.get("preview_images", 14) + assert isinstance(num_images, int) for side in get_args(Literal["a", "b"]): logger.debug("Setting preview feed: (side: '%s')", side) - preview_images = int(self._config.get("preview_images", 14)) - preview_images = min(max(preview_images, 2), 16) + preview_images = min(max(num_images, 2), 16) batchsize = min(len(self._images[side]), preview_images) retval[side] = self._load_generator(side, True, @@ -547,7 +559,9 @@ def compile_sample(self, The list of samples, targets and masks as :class:`numpy.ndarrays` for creating a preview image """ - num_images = min(image_count, int(self._config.get("preview_images", 14))) + num_images = self._config.get("preview_images", 14) + assert isinstance(num_images, int) + num_images = min(image_count, num_images) retval: Dict[Literal["a", "b"], List[np.ndarray]] = {} for side in get_args(Literal["a", "b"]): logger.debug("Compiling samples: (side: '%s', samples: %s)", side, num_images) @@ -599,6 +613,10 @@ class _Samples(): # pylint:disable=too-few-public-methods The selected model that will be running this trainer coverage_ratio: float Ratio of face to be cropped out of the training image. + mask_opacity: int + The opacity (as a percentage) to use for the mask overlay + mask_color: str + The hex RGB value to use the mask overlay Attributes ---------- @@ -607,13 +625,20 @@ class _Samples(): # pylint:disable=too-few-public-methods dictionary should contain 2 keys ("a" and "b") with the values being the training images for generating samples corresponding to each side. """ - def __init__(self, model: "ModelBase", coverage_ratio: float) -> None: - logger.debug("Initializing %s: model: '%s', coverage_ratio: %s)", - self.__class__.__name__, model, coverage_ratio) + def __init__(self, + model: "ModelBase", + coverage_ratio: float, + mask_opacity: int, + mask_color: str) -> None: + logger.debug("Initializing %s: model: '%s', coverage_ratio: %s, mask_opacity: %s, " + "mask_color: %s)", + self.__class__.__name__, model, coverage_ratio, mask_opacity, mask_color) self._model = model self._display_mask = model.config["learn_mask"] or model.config["penalized_mask_loss"] self.images: Dict[Literal["a", "b"], List[np.ndarray]] = {} self._coverage_ratio = coverage_ratio + self._mask_opacity = mask_opacity / 100.0 + self._mask_color = np.array(hex_to_rgb(mask_color))[..., 2::-1] / 255. logger.debug("Initialized %s", self.__class__.__name__) def toggle_mask_display(self) -> None: @@ -849,8 +874,7 @@ def _process_full(self, logger.debug("Overlayed background. Shape: %s", images.shape) return images - @classmethod - def _compile_masked(cls, faces: List[np.ndarray], masks: np.ndarray) -> List[np.ndarray]: + def _compile_masked(self, faces: List[np.ndarray], masks: np.ndarray) -> List[np.ndarray]: """ Add the mask to the faces for masked preview. Places an opaque red layer over areas of the face that are masked out. @@ -868,22 +892,24 @@ def _compile_masked(cls, faces: List[np.ndarray], masks: np.ndarray) -> List[np. list List of :class:`numpy.ndarray` faces with the opaque mask layer applied """ - orig_masks = np.tile(1 - np.rint(masks), 3) - orig_masks[np.where((orig_masks == [1., 1., 1.]).all(axis=3))] = [0., 0., 1.] + orig_masks = 1 - np.rint(masks) masks3: Union[List[np.ndarray], np.ndarray] = [] if faces[-1].shape[-1] == 4: # Mask contained in alpha channel of predictions - pred_masks = [np.tile(1 - np.rint(face[..., -1])[..., None], 3) for face in faces[-2:]] - for swap_masks in pred_masks: - swap_masks[np.where((swap_masks == [1., 1., 1.]).all(axis=3))] = [0., 0., 1.] + pred_masks = [1 - np.rint(face[..., -1])[..., None] for face in faces[-2:]] faces[-2:] = [face[..., :-1] for face in faces[-2:]] masks3 = [orig_masks, *pred_masks] else: masks3 = np.repeat(np.expand_dims(orig_masks, axis=0), 3, axis=0) - retval = [np.array([cv2.addWeighted(img, 1.0, mask, 0.3, 0) - for img, mask in zip(previews, compiled_masks)]) - for previews, compiled_masks in zip(faces, masks3)] + retval: List[np.ndarray] = [] + alpha = 1.0 - self._mask_opacity + for previews, compiled_masks in zip(faces, masks3): + overlays = previews.copy() + overlays[np.where((compiled_masks == 1.).all(axis=3))] = self._mask_color + retval.append(np.array([cv2.addWeighted(img, alpha, ovl, self._mask_opacity, 0) + for img, ovl in zip(previews, overlays)])) + logger.debug("masked shapes: %s", [faces.shape for faces in retval]) return retval @@ -991,10 +1017,12 @@ class _Timelapse(): # pylint:disable=too-few-public-methods The selected model that will be running this trainer coverage_ratio: float Ratio of face to be cropped out of the training image. - scaling: float, optional - The amount to scale the final preview image by. Default: `1.0` image_count: int The number of preview images to be displayed in the time-lapse + mask_opacity: int + The opacity (as a percentage) to use for the mask overlay + mask_color: str + The hex RGB value to use the mask overlay feeder: :class:`_Feeder` The feeder for generating the time-lapse images. image_paths: dict @@ -1004,13 +1032,16 @@ def __init__(self, model: "ModelBase", coverage_ratio: float, image_count: int, + mask_opacity: int, + mask_color: str, feeder: _Feeder, image_paths: Dict[Literal["a", "b"], List[str]]) -> None: logger.debug("Initializing %s: model: %s, coverage_ratio: %s, image_count: %s, " - "feeder: '%s', image_paths: %s)", self.__class__.__name__, model, - coverage_ratio, image_count, feeder, len(image_paths)) + "mask_opacity: %s, mask_color: %s, feeder: %s, image_paths: %s)", + self.__class__.__name__, model, coverage_ratio, image_count, mask_opacity, + mask_color, feeder, len(image_paths)) self._num_images = image_count - self._samples = _Samples(model, coverage_ratio) + self._samples = _Samples(model, coverage_ratio, mask_opacity, mask_color) self._model = model self._feeder = feeder self._image_paths = image_paths diff --git a/plugins/train/trainer/original_defaults.py b/plugins/train/trainer/original_defaults.py index 943cd40b4d..1cc45e07b1 100755 --- a/plugins/train/trainer/original_defaults.py +++ b/plugins/train/trainer/original_defaults.py @@ -54,6 +54,20 @@ rounding=2, min_max=(2, 16), group="evaluation"), + mask_opacity=dict( + default=30, + info="The opacity of the mask overlay in the training preview. Lower values are more " + "transparent.", + datatype=int, + rounding=2, + min_max=(0, 100), + group="evaluation"), + mask_color=dict( + default="#ff0000", + choices="colorchooser", + info="The RGB hex color to use for the mask overlay in the training preview.", + datatype=str, + group="evaluation"), zoom_amount=dict( default=5, info="Percentage amount to randomly zoom each training image in and out.", diff --git a/tools/manual/manual.py b/tools/manual/manual.py index ded172fe69..313cab697a 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -325,7 +325,7 @@ def _initialize(self): max_columns=1, header_text=controls["header"], blank_nones=False, - label_width=18, + label_width=12, style="CPanel", scrollbar=False) panel.pack_forget() diff --git a/tools/preview/control_panels.py b/tools/preview/control_panels.py index 9b811d2945..9b214bfe4e 100644 --- a/tools/preview/control_panels.py +++ b/tools/preview/control_panels.py @@ -95,20 +95,20 @@ def _get_config_dicts(self) -> Dict[str, Dict[str, Any]]: for section in self._config.config.sections(): if section.startswith("writer."): continue - for key, val in self._config.defaults[section].items(): + for key, val in self._config.defaults[section].items.items(): if key == "helptext": config_dicts.setdefault(section, {})[key] = val continue cp_option = ControlPanelOption(title=key, - dtype=val["type"], - group=val["group"], - default=val["default"], + dtype=val.datatype, + group=val.group, + default=val.default, initial_value=self._config.get(section, key), - choices=val["choices"], - is_radio=val["gui_radio"], - rounding=val["rounding"], - min_max=val["min_max"], - helptext=val["helptext"]) + choices=val.choices, + is_radio=val.gui_radio, + rounding=val.rounding, + min_max=val.min_max, + helptext=val.helptext) self.tk_vars.setdefault(section, {})[key] = cp_option.tk_var config_dicts.setdefault(section, {})[key] = cp_option logger.debug("Formatted Config for GUI: %s", config_dicts) @@ -175,29 +175,29 @@ def save_config(self, section: Optional[str] = None) -> None: new_config = ConfigParser(allow_no_value=True) - for config_section, items in self._config.defaults.items(): - logger.debug("Adding section: '%s')", config_section) - self._config.insert_config_section(config_section, - items["helptext"], + for section_name, sect in self._config.defaults.items(): + logger.debug("Adding section: '%s')", section_name) + self._config.insert_config_section(section_name, + sect.helptext, config=new_config) - for item, options in items.items(): + for item, options in sect.items.items(): if item == "helptext": continue # helptext already written at top - if ((section is not None and config_section != section) - or config_section not in self.tk_vars): + if ((section is not None and section_name != section) + or section_name not in self.tk_vars): # retain saved values that have not been updated - new_opt = self._config.get(config_section, item) + new_opt = self._config.get(section_name, item) logger.debug("Retaining option: (item: '%s', value: '%s')", item, new_opt) else: - new_opt = self.tk_vars[config_section][item].get() + new_opt = self.tk_vars[section_name][item].get() logger.debug("Setting option: (item: '%s', value: '%s')", item, new_opt) # Set config_dicts value to new saved value - self._config_dicts[config_section][item].set_initial_value(new_opt) + self._config_dicts[section_name][item].set_initial_value(new_opt) - helptext = self._config.format_help(options["helptext"], is_section=False) - new_config.set(config_section, helptext) - new_config.set(config_section, item, str(new_opt)) + helptext = self._config.format_help(options.helptext, is_section=False) + new_config.set(section_name, helptext) + new_config.set(section_name, item, str(new_opt)) self._config.config = new_config self._config.save_config() From 6fd57f0d7457544577adc67b0fd3e9a354026b71 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 13 Feb 2023 15:27:48 +0000 Subject: [PATCH 800/981] bugfix: OrderedDict type for python<3.9 --- lib/config.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/config.py b/lib/config.py index a5cfc58843..6dca167894 100644 --- a/lib/config.py +++ b/lib/config.py @@ -16,6 +16,14 @@ from lib.utils import full_path_split +# Can't type OrderedDict fully on Python 3.8 or lower +if sys.version_info < (3, 9): + OrderedDictSectionType = OrderedDict + OrderedDictItemType = OrderedDict +else: + OrderedDictSectionType = OrderedDict[str, "ConfigSection"] + OrderedDictItemType = OrderedDict[str, "ConfigItem"] + logger = logging.getLogger(__name__) # pylint: disable=invalid-name ConfigValueType = Union[bool, int, float, List[str], str, None] @@ -66,7 +74,7 @@ class ConfigSection: Dictionary of configuration items for the section """ helptext: str - items: OrderedDict[str, ConfigItem] + items: OrderedDictItemType class FaceswapConfig(): @@ -84,7 +92,7 @@ def __init__(self, section: Optional[str], configfile: Optional[str] = None) -> logger.debug("Initializing: %s", self.__class__.__name__) self.configfile = self._get_config_file(configfile) self.config = ConfigParser(allow_no_value=True) - self.defaults: OrderedDict[str, ConfigSection] = OrderedDict() + self.defaults: OrderedDictSectionType = OrderedDict() self.config.optionxform = str # type:ignore self.section = section From 7887703ac760c748aebaa6af16c99d1dade25e42 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 13 Feb 2023 19:05:34 +0000 Subject: [PATCH 801/981] bugfix - Colorchooser fixes --- lib/gui/control_helper.py | 13 +++++++------ lib/gui/display_graph.py | 16 ++++++++-------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index f86dfd1046..0436109f3c 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -1185,18 +1185,20 @@ def _color_control(self): anchor=tk.W, style=f"{self._style}Group.TLabel") ctl = tk.Frame(frame, - bg=self.option.value, + bg=self.option.tk_var.get(), bd=2, cursor="hand2", relief=tk.SUNKEN, width=round(int(20 * get_config().scaling_factor)), - height=round(int(12 * get_config().scaling_factor))) + height=round(int(14 * get_config().scaling_factor))) ctl.bind("", lambda *e, c=ctl, t=self.option.title: self._ask_color(c, t)) - lbl.pack(padx=2, pady=5, side=tk.LEFT, anchor=tk.N) + lbl.pack(side=tk.LEFT, anchor=tk.N) ctl.pack(side=tk.RIGHT, anchor=tk.W) - frame.pack(side=tk.LEFT, anchor=tk.W) + frame.pack(padx=5, side=tk.LEFT, anchor=tk.W) if self.option.helptext is not None: - _get_tooltip(lbl, text=self.option.helptext) + _get_tooltip(frame, text=self.option.helptext) + # Callback to set the color chooser background on an update (e.g. reset) + self.option.tk_var.trace("w", lambda *e: ctl.config(bg=self.option.tk_var.get())) logger.debug("Added control to Options Frame: %s", self.option.name) return ctl @@ -1206,7 +1208,6 @@ def _ask_color(self, frame, title): chosen = colorchooser.askcolor(parent=frame, color=color, title=f"{title} Color")[1] if chosen is None: return - frame.config(bg=chosen) self.option.tk_var.set(chosen) def control_to_checkframe(self): diff --git a/lib/gui/display_graph.py b/lib/gui/display_graph.py index 503cce5221..8a503e5ce7 100755 --- a/lib/gui/display_graph.py +++ b/lib/gui/display_graph.py @@ -87,7 +87,7 @@ def _update_plot(self, initiate: bool = True) -> None: Whether the graph should be initialized for the first time (``True``) or data is being updated for an existing graph (``False``). Default: ``True`` """ - logger.trace("Updating plot") # type:ignore + logger.trace("Updating plot") # type:ignore[attr-defined] if initiate: logger.debug("Initializing plot") self._lines = [] @@ -115,7 +115,7 @@ def _update_plot(self, initiate: bool = True) -> None: if initiate: self._legend_place() - logger.trace("Updated plot") # type:ignore + logger.trace("Updated plot") # type:ignore[attr-defined] def _axes_labels_set(self) -> None: """ Set the X and Y axes labels. """ @@ -148,7 +148,7 @@ def _axes_limits_set(self, data: List[float]) -> None: ymin, ymax = self._axes_data_get_min_max(data) self._ax1.set_ylim(ymin, ymax) self._ax1.set_xlim(xmin, xmax) - logger.trace("axes ranges: (y: (%s, %s), x:(0, %s)", # type:ignore + logger.trace("axes ranges: (y: (%s, %s), x:(0, %s)", # type:ignore[attr-defined] ymin, ymax, xmax) else: self._axes_limits_set_default() @@ -174,7 +174,7 @@ def _axes_data_get_min_max(data: List[float]) -> Tuple[float, float]: ymaxs.append(np.nanmax(item) * 1000) ymin = floor(min(ymins)) / 1000 ymax = ceil(max(ymaxs)) / 1000 - logger.trace("ymin: %s, ymax: %s", ymin, ymax) # type:ignore + logger.trace("ymin: %s, ymax: %s", ymin, ymax) # type:ignore[attr-defined] return ymin, ymax def _axes_set_yscale(self, scale: str) -> None: @@ -201,7 +201,7 @@ def _lines_sort(self, keys: List[str]) -> List[List[Union[str, int, Tuple[float] list A list of loss keys with their corresponding line formatting and color information """ - logger.trace("Sorting lines") # type:ignore + logger.trace("Sorting lines") # type:ignore[attr-defined] raw_lines: List[List[str]] = [] sorted_lines: List[List[str]] = [] for key in sorted(keys): @@ -242,7 +242,7 @@ def _lines_groupsize(raw_lines: List[List[str]], sorted_lines: List[List[str]]) keys = [key[0][:key[0].find("_")] for key in sorted_lines] distinct_keys = set(keys) groupsize = len(keys) // len(distinct_keys) - logger.trace(groupsize) # type:ignore + logger.trace(groupsize) # type:ignore[attr-defined] return groupsize def _lines_style(self, @@ -262,7 +262,7 @@ def _lines_style(self, list A list of loss keys with their corresponding line formatting and color information """ - logger.trace("Setting lines style") # type:ignore + logger.trace("Setting lines style") # type:ignore[attr-defined] groups = int(len(lines) / groupsize) colours = self._lines_create_colors(groupsize, groups) widths = list(range(1, groups + 1)) @@ -293,7 +293,7 @@ def _lines_create_colors(self, groupsize: int, groups: int) -> List[Tuple[float] cmap = matplotlib.cm.get_cmap(colour) cpoint = 1 - (i / 5) colours.append(cmap(cpoint)) - logger.trace(colours) # type:ignore + logger.trace(colours) # type:ignore[attr-defined] return colours def _legend_place(self) -> None: From 0c1d1e325386a27b49742aabb50c45456f9363fb Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 14 Feb 2023 13:09:46 +0000 Subject: [PATCH 802/981] Bugfix: Color order on preview with learn_mask --- plugins/train/trainer/_base.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 04df930b72..eac0605191 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -813,8 +813,7 @@ def _to_full_frame(self, if self._model.color_order.lower() == "rgb": # Switch color order for RGB model display full = full[..., ::-1] faces = faces[..., ::-1] - predictions = [pred[..., ::-1] if pred.shape[-1] == 3 else pred - for pred in predictions] + predictions = [pred[..., 2::-1] for pred in predictions] full = self._process_full(side, full, predictions[0].shape[1], (0., 0., 1.0)) images = [faces] + predictions From 74f4b6f9c4415d5340b62aedd116f0d20d7c4e73 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 16 Feb 2023 19:18:52 +0000 Subject: [PATCH 803/981] Bugfix: Preview fail on input size > output size --- lib/training/generator.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/training/generator.py b/lib/training/generator.py index 0dd6601a12..cdc40134fc 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -696,7 +696,7 @@ def process_batch(self, Returns ------- feed: :class:`numpy.ndarray` - List of 4-dimensional :class:`numpy.ndarray` objects at model input size for feeding + List of 4-dimensional :class:`numpy.ndarray` objects at model output size for feeding the model's predict function. The first 3 channels are (rgb/bgr). The 4th channel is the face mask. samples: list @@ -718,6 +718,13 @@ def process_batch(self, feed = self._to_float32(batch[..., :4]) # Don't resize here: we want masks at output res. + # If user sets model input size as larger than output size, the preview will error, so + # resize in these rare instances + out_size = max(self._output_sizes) + if self._process_size > out_size: + feed = np.array([cv2.resize(img, (out_size, out_size), interpolation=cv2.INTER_AREA) + for img in feed]) + samples = self._create_samples(images, detected_faces) return feed, samples From f2c1086f94d6353795802386c72d9ce640993e9d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 19 Feb 2023 13:02:37 +0000 Subject: [PATCH 804/981] bugfix: Prevent settings popup from stealing focus --- lib/gui/popup_configure.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 7cd810dd58..e525b639ad 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -124,7 +124,6 @@ def __init__(self, name, configurations): super().__init__() self._root = get_config().root self._set_geometry() - self.attributes('-topmost', 'true') self._tk_vars = dict(header=tk.StringVar()) theme = {**get_config().user_theme["group_panel"], From 637dc3b55c39db9dceccf124e529b8bec61b75d4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 20 Feb 2023 12:12:51 +0000 Subject: [PATCH 805/981] bugfix - utils unit test --- tests/lib/utils_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/lib/utils_test.py b/tests/lib/utils_test.py index 95cc8ae926..a6f8e01fe3 100644 --- a/tests/lib/utils_test.py +++ b/tests/lib/utils_test.py @@ -139,12 +139,12 @@ def test_get_image_paths(tmp_path: str) -> None: # Test getting any image paths from a folder with images and random files exists = [os.path.join(test_folder, img) for img in os.listdir(test_folder) if os.path.splitext(img)[-1] != ".txt"] - assert get_image_paths(test_folder) == exists + assert sorted(get_image_paths(test_folder)) == sorted(exists) # Test getting image paths from a folder with images with a specific extension exists = [os.path.join(test_folder, img) for img in os.listdir(test_folder) if os.path.splitext(img)[-1] == ".png"] - assert get_image_paths(test_folder, extension=".png") == exists + assert sorted(get_image_paths(test_folder, extension=".png")) == sorted(exists) _PARAMS = [("/path/to/file.txt", ["/", "path", "to", "file.txt"]), # Absolute From cd929e0df36d71b23803e929c5b9cbfc658bb674 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 20 Feb 2023 12:28:02 +0000 Subject: [PATCH 806/981] bugfix: preview tool unit test --- tests/tools/alignments/media_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/tools/alignments/media_test.py b/tests/tools/alignments/media_test.py index 8831a54919..52124a4469 100644 --- a/tests/tools/alignments/media_test.py +++ b/tests/tools/alignments/media_test.py @@ -1,6 +1,7 @@ #!/usr/bin python3 """ Pytest unit tests for :mod:`tools.alignments.media` """ import os +from operator import itemgetter from typing import cast, Dict, Generator, List, Tuple from unittest.mock import MagicMock @@ -608,8 +609,8 @@ def test_process_frames(self, folder: str) -> None: dict(frame_fullname="b.png", frame_name="b", frame_extension=".png")] frames = Frames(folder, None) - returned = list(frames.process_frames()) - assert returned == expected + returned = sorted(list(frames.process_frames()), key=itemgetter("frame_fullname")) + assert returned == sorted(expected, key=itemgetter("frame_fullname")) def test_process_video(self, folder: str) -> None: """ Test for :class:`~tools.alignments.media.Frames` process_video method From cd94b7e046ded7a4969e1adebc5f869444230901 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 20 Feb 2023 15:05:23 +0000 Subject: [PATCH 807/981] Mask tool update: - Allow creation of masks on face sets without an alignments file - Auto-detect alignments file location when not provided --- locales/es/LC_MESSAGES/tools.mask.cli.mo | Bin 8462 -> 8735 bytes locales/es/LC_MESSAGES/tools.mask.cli.po | 63 +++++++++-------- locales/kr/LC_MESSAGES/tools.mask.cli.mo | Bin 8406 -> 8636 bytes locales/kr/LC_MESSAGES/tools.mask.cli.po | 62 +++++++++-------- locales/tools.mask.cli.pot | 46 ++++++------ tools/mask/cli.py | 8 ++- tools/mask/mask.py | 85 +++++++++++++++++++---- 7 files changed, 164 insertions(+), 100 deletions(-) diff --git a/locales/es/LC_MESSAGES/tools.mask.cli.mo b/locales/es/LC_MESSAGES/tools.mask.cli.mo index 950caebd86b9ef42136361841bc9ebcb7767ba85..ad5e651490c81b2b7db90bd0e8d9173cda783a68 100644 GIT binary patch delta 805 zcmZ{hO>0v@6oyaIsG#(61ByLg4u!qD|=2d7}H!AJBE=v%^HUG3nJ2qR)sgE)ji# z{)AQ|{4t_Sc)vSN1Www&JOP-4Mu3IoCr2wwi@+tI5<1EcFlc7?cdgj0e5?#vx7pah zB5>#`PE=duY2egK5wta@xV5pym)m7hn=B8FFvt{Z&ut-K=(xHleo8Dn0<99BMzM+{n|@@=e?dR!O~tGL(ov=NDzIE4n4 za?n}11lKyGFo5L{^E^jXudqyp90+%@5B2}!`pZF#f@g`s>2NBuE~2A5H_1M~g`#R*<9XE}IxtwLw%;CK`*jDL$N9@p>9{03(6;xGUJ delta 488 zcmYk$JxC)#7zW@s@vkucMiC;)clBK)5k(0o7zC}Xw75cLqNC2;W`jHHUm=PhSjj;+ z5G*A2g0j7cIg5i6x#J44IPGl@#7f_djSIsw^UXKkzDJ|`*>8PYI{+S|z&UlRfB~AU z1`O4-Is)9#iyGiBSJ?Nx9@wRmF(Bam9UbC%zmfg)7pZ7SIRMqZeF{rEEq?N@ny|8j^wlyR<*b08Q<4#@K|+K-`tx_4JK3llJ3i-`ah=9 z!9~-LXz@}y#tN>s{DCa5Xt7#U`Ap3luUryGNxodNbIMapRPa?sta)FR4fU2ti*}2N YcxU`mIKdWjp0;zI$%SKrD zf{(#I_@0BW!O2dXQQz;PQR+nDSvS!YP(O=d5Q;rS(_jE5P&dvI{l>(d^F({7FZB}D zz)T+zBmAX{L@#h3=_fh`u7hEKuZOJn7cWHmdJ&fpmC(_A5QEwe&#msG$ZlkcwKN;1 zU`4PPQ#qxk6-yVan@L4zrZLEgyTg1h-4rz=Yl*0s$f~U6IjtnsJS#?uCrpc9Y56f0 zCR^E*BGjfmY36cjK5H5&%@}1x*G7$uY6x^{x_ULV7@P=uGge-3HfN4&DU}r-S9D9| zF*S!vzB%bA4JQ>N4Eqv_CCm&WCKX+uKy0BVg_?5pmip<=Yth(HbSTF08^f{qKrB{_ zckH&r2IKC2$6n-?w^jAsEtcgOxn1(A(=6>7e_@fm?>q8SmHo{*IlmsfsrVZWmOqx^ z2yBO?WBX2}xZiboD*PjIZPs@-FvJN;wp^{T{Lt{~HJm)x;lQr>3)_c194s!%)rMT1 zVYxEvFI8a*=C=a7tOL+g(GCk}(60x8AG|w#)d+Bpp6>=c*0<;c-oc{eIvQ@}B%#5~`Xjc}rZOc_9*KerILPoo;a=ftpQzV**#pAKW zxQxY`x0i*8#(VZC6MUN!H$3ttCfK3D#(EjFvq%Xt|pzvw?QWnKoR z_9V$A_MU4Ni>4Ns$0gaD<&c<~AAbBI=2^k42H_ijpOKm`;qnLb$QM&7nx{omU-6~~ HF82Hfv22+d diff --git a/locales/kr/LC_MESSAGES/tools.mask.cli.po b/locales/kr/LC_MESSAGES/tools.mask.cli.po index fc629f3521..c96d38aa3b 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: 2022-08-05 14:00+0100\n" -"PO-Revision-Date: 2022-11-27 01:28+0900\n" +"POT-Creation-Date: 2023-02-20 14:55+0000\n" +"PO-Revision-Date: 2023-02-20 15:01+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ko_KR\n" @@ -16,13 +16,13 @@ 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.2\n" +"X-Generator: Poedit 3.0.1\n" -#: /home/matt/faceswap/tools/mask/cli.py:15 +#: tools/mask/cli.py:15 msgid "This command lets you generate masks for existing alignments." msgstr "이 명령어는 이미 존재하는 alignments로부터 마스크를 생성하게 해줍니다." -#: /home/matt/faceswap/tools/mask/cli.py:24 +#: tools/mask/cli.py:24 msgid "" "Mask tool\n" "Generate masks for existing alignments files." @@ -30,25 +30,26 @@ msgstr "" "마스크 도구\n" "존재하는 alignments 파일들로부터 마스크를 생성합니다." -#: /home/matt/faceswap/tools/mask/cli.py:33 -#: /home/matt/faceswap/tools/mask/cli.py:42 -#: /home/matt/faceswap/tools/mask/cli.py:52 +#: tools/mask/cli.py:33 tools/mask/cli.py:44 tools/mask/cli.py:54 msgid "data" msgstr "데이터" -#: /home/matt/faceswap/tools/mask/cli.py:36 +#: tools/mask/cli.py:36 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." +"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." msgstr "" -"마스크를 추가할 alignments 파일의 전체 경로입니다. 주의: alignments 파일에 마" -"스크가 이미 있으면 alignments 파일이 덮어 씌워집니다." +"기본 위치가 아닌 경우 마스크를 추가할 정렬 파일의 전체 경로입니다. NB: 입력 " +"유형이 얼굴이고 해당 정렬 파일을 업데이트하려는 경우 위치를 자동으로 감지할 " +"수 없으므로 여기에 값을 제공해야 합니다." -#: /home/matt/faceswap/tools/mask/cli.py:45 +#: tools/mask/cli.py:47 msgid "Directory containing extracted faces, source frames, or a video file." msgstr "추출된 얼굴들, 원본 프레임들, 또는 비디오 파일이 존재하는 디렉토리." -#: /home/matt/faceswap/tools/mask/cli.py:54 +#: tools/mask/cli.py:56 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" @@ -58,12 +59,11 @@ msgstr "" "L|faces: 입력은 추출된 얼굴을 포함된 폴더입니다.\n" "L|frames: 입력이 프레임을 포함된 폴더이거나 비디오입니다" -#: /home/matt/faceswap/tools/mask/cli.py:63 -#: /home/matt/faceswap/tools/mask/cli.py:95 +#: tools/mask/cli.py:65 tools/mask/cli.py:97 msgid "process" msgstr "진행" -#: /home/matt/faceswap/tools/mask/cli.py:64 +#: tools/mask/cli.py:66 msgid "" "R|Masker to use.\n" "L|bisenet-fp: Relatively lightweight NN based mask that provides more " @@ -113,7 +113,7 @@ msgstr "" "델은 커뮤니티 구성원들에 의해 훈련되었으며 추가 설명을 위해 테스트가 필요합니" "다. 옆 얼굴은 평균 이하의 성능을 초래할 수 있습니다." -#: /home/matt/faceswap/tools/mask/cli.py:96 +#: tools/mask/cli.py:98 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 " @@ -133,15 +133,12 @@ msgstr "" "L|output: 마스크를 업데이트하지 말고 지정된 출력 폴더에서 검토할 수 있도록 출" "력하십시오." -#: /home/matt/faceswap/tools/mask/cli.py:109 -#: /home/matt/faceswap/tools/mask/cli.py:116 -#: /home/matt/faceswap/tools/mask/cli.py:129 -#: /home/matt/faceswap/tools/mask/cli.py:142 -#: /home/matt/faceswap/tools/mask/cli.py:151 +#: tools/mask/cli.py:111 tools/mask/cli.py:118 tools/mask/cli.py:131 +#: tools/mask/cli.py:144 tools/mask/cli.py:153 msgid "output" msgstr "출력" -#: /home/matt/faceswap/tools/mask/cli.py:110 +#: tools/mask/cli.py:112 msgid "" "Optional output location. If provided, a preview of the masks created will " "be output in the given folder." @@ -149,7 +146,7 @@ msgstr "" "선택적 출력 위치. 만약 값이 제공된다면 생성된 마스크 미리 보기가 주어진 폴더" "에 출력됩니다." -#: /home/matt/faceswap/tools/mask/cli.py:120 +#: tools/mask/cli.py:122 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 " @@ -161,7 +158,7 @@ msgstr "" "은 홀수여야 하며 짝수가 전달되면 다음 홀수로 반올림됩니다. NB: 출력 미리 보기" "에만 영향을 줍니다. 0으로 설정하면 꺼집니다" -#: /home/matt/faceswap/tools/mask/cli.py:133 +#: tools/mask/cli.py:135 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 " @@ -171,7 +168,7 @@ msgstr "" "줄이는 데 도움이 됩니다. 값이 클수록 마스크에 더 많은 영향을 미칩니다. NB: 출" "력 미리 보기에만 영향을 줍니다. 0으로 설정하면 꺼집니다" -#: /home/matt/faceswap/tools/mask/cli.py:143 +#: tools/mask/cli.py:145 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" @@ -184,10 +181,17 @@ msgstr "" "L|masked: 마스크된 얼굴/프레임을 Rgba 이미지로 출력합니다.\n" "L|mask: 마스크를 단일 채널 이미지로만 출력합니다." -#: /home/matt/faceswap/tools/mask/cli.py:152 +#: tools/mask/cli.py:154 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." msgstr "" "R|출력 처리를 사용할 때 전체 프레임을 출력할지 또는 페이스 박스만 출력할지 여" "부. 프레임을 입력으로 사용할 때만 효과가 있습니다." + +#~ 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." +#~ msgstr "" +#~ "마스크를 추가할 alignments 파일의 전체 경로입니다. 주의: alignments 파일" +#~ "에 마스크가 이미 있으면 alignments 파일이 덮어 씌워집니다." diff --git a/locales/tools.mask.cli.pot b/locales/tools.mask.cli.pot index a1ed571387..009ab13a05 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: 2022-08-05 14:00+0100\n" +"POT-Creation-Date: 2023-02-20 14:55+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,45 +17,44 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: /home/matt/faceswap/tools/mask/cli.py:15 +#: tools/mask/cli.py:15 msgid "This command lets you generate masks for existing alignments." msgstr "" -#: /home/matt/faceswap/tools/mask/cli.py:24 +#: tools/mask/cli.py:24 msgid "" "Mask tool\n" "Generate masks for existing alignments files." msgstr "" -#: /home/matt/faceswap/tools/mask/cli.py:33 -#: /home/matt/faceswap/tools/mask/cli.py:42 -#: /home/matt/faceswap/tools/mask/cli.py:52 +#: tools/mask/cli.py:33 tools/mask/cli.py:44 tools/mask/cli.py:54 msgid "data" msgstr "" -#: /home/matt/faceswap/tools/mask/cli.py:36 +#: tools/mask/cli.py:36 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." +"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." msgstr "" -#: /home/matt/faceswap/tools/mask/cli.py:45 +#: tools/mask/cli.py:47 msgid "Directory containing extracted faces, source frames, or a video file." msgstr "" -#: /home/matt/faceswap/tools/mask/cli.py:54 +#: tools/mask/cli.py:56 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 "" -#: /home/matt/faceswap/tools/mask/cli.py:63 -#: /home/matt/faceswap/tools/mask/cli.py:95 +#: tools/mask/cli.py:65 tools/mask/cli.py:97 msgid "process" msgstr "" -#: /home/matt/faceswap/tools/mask/cli.py:64 +#: tools/mask/cli.py:66 msgid "" "R|Masker to use.\n" "L|bisenet-fp: Relatively lightweight NN based mask that provides more " @@ -85,7 +84,7 @@ msgid "" "performance." msgstr "" -#: /home/matt/faceswap/tools/mask/cli.py:96 +#: tools/mask/cli.py:98 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 " @@ -97,21 +96,18 @@ msgid "" "output folder." msgstr "" -#: /home/matt/faceswap/tools/mask/cli.py:109 -#: /home/matt/faceswap/tools/mask/cli.py:116 -#: /home/matt/faceswap/tools/mask/cli.py:129 -#: /home/matt/faceswap/tools/mask/cli.py:142 -#: /home/matt/faceswap/tools/mask/cli.py:151 +#: tools/mask/cli.py:111 tools/mask/cli.py:118 tools/mask/cli.py:131 +#: tools/mask/cli.py:144 tools/mask/cli.py:153 msgid "output" msgstr "" -#: /home/matt/faceswap/tools/mask/cli.py:110 +#: tools/mask/cli.py:112 msgid "" "Optional output location. If provided, a preview of the masks created will " "be output in the given folder." msgstr "" -#: /home/matt/faceswap/tools/mask/cli.py:120 +#: tools/mask/cli.py:122 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 " @@ -119,14 +115,14 @@ msgid "" "to the next odd number. NB: Only effects the output preview. Set to 0 for off" msgstr "" -#: /home/matt/faceswap/tools/mask/cli.py:133 +#: tools/mask/cli.py:135 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 "" -#: /home/matt/faceswap/tools/mask/cli.py:143 +#: tools/mask/cli.py:145 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" @@ -134,7 +130,7 @@ msgid "" "L|mask: Only output the mask as a single channel image." msgstr "" -#: /home/matt/faceswap/tools/mask/cli.py:152 +#: tools/mask/cli.py:154 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/mask/cli.py b/tools/mask/cli.py index a0934cccc7..3408467d8e 100644 --- a/tools/mask/cli.py +++ b/tools/mask/cli.py @@ -31,10 +31,12 @@ def get_argument_list(): action=FileFullPaths, type=str, group=_("data"), - required=True, + required=False, filetypes="alignments", - help=_("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."))) + 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, diff --git a/tools/mask/mask.py b/tools/mask/mask.py index e0a33d5238..d5b85778db 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -55,9 +55,7 @@ def __init__(self, arguments: "Namespace") -> None: self._loader = loader(arguments.input) self._faces_saver: Optional[ImagesSaver] = None - self._alignments = Alignments(os.path.dirname(arguments.alignments), - filename=os.path.basename(arguments.alignments)) - + 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() @@ -107,6 +105,44 @@ def _set_saver(self, arguments: "Namespace") -> Optional[ImagesSaver]: logger.debug(saver) return saver + def _get_alignments(self, arguments: "Namespace") -> Optional[Alignments]: + """ 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` + + Returns + ------- + ``None`` or :class:`lib.align.alignments.Alignments`: + 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": + logger.debug("No alignments file provided for faces. Using PNG Header for output") + return None + if self._input_is_faces: + logger.warning("Faces input selected without an alignments file. Masks wil only " + "be updated in the faces' PNG Header") + return None + + folder = arguments.input + if self._loader.is_video: + logger.debug("Alignments from Video File: '%s'", folder) + folder, filename = os.path.split(folder) + filename = f"{os.path.splitext(filename)[0]}_alignments.fsa" + else: + logger.debug("Alignments from Input Folder: '%s'", folder) + filename = "alignments" + + return Alignments(folder, filename=filename) + def _get_extractor(self, exclude_gpus: List[int]) -> Optional[Extractor]: """ Obtain a Mask extractor plugin and launch it @@ -188,12 +224,19 @@ def _process_face(self, """ frame_name = metadata["source"]["source_filename"] face_index = metadata["source"]["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[face_index] + + if self._alignments is None: # mask from PNG header + lookup_index = 0 + alignments = [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): @@ -225,15 +268,22 @@ def _input_faces(self, *args: Union[tuple, Tuple["EventQueue"]]) -> None: queue = 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.") @@ -256,6 +306,7 @@ def _input_frames(self, *args: Union[tuple, Tuple["EventQueue"]]) -> None: 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 = cast("EventQueue", args[0]) @@ -333,8 +384,8 @@ def _get_output_suffix(self, arguments: "Namespace") -> str: sfx += f"{arguments.output_type}.png" return sfx - @staticmethod - def _get_detected_face(alignment: "AlignmentFileDict") -> DetectedFace: + @classmethod + def _get_detected_face(cls, alignment: "AlignmentFileDict") -> DetectedFace: """ Convert an alignment dict item to a detected_face object Parameters @@ -362,9 +413,11 @@ def process(self) -> None: for extractor_output in self._extractor.detected_faces(): self._extractor_input_thread.check_and_raise_error() updater(extractor_output) - if self._counts["update"] != 0: + + 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() @@ -401,7 +454,9 @@ def _update_faces(self, extractor_output: ExtractMedia) -> None: logger.trace("Saving face: (frame: %s, face index: %s)", # type: ignore frame_name, face_index) - self._alignments.update_face(frame_name, face_index, face.to_alignment()) + if self._alignments is not None: + self._alignments.update_face(frame_name, face_index, face.to_alignment()) + metadata: "PNGHeaderDict" = dict(alignments=face.to_png_meta(), source=extractor_output.frame_metadata) self._faces_saver.save(extractor_output.filename, @@ -421,6 +476,7 @@ def _update_frames(self, extractor_output: ExtractMedia) -> None: 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()) @@ -492,7 +548,8 @@ def _create_image(self, detected_face: DetectedFace, mask_type: str) -> np.ndarr size=detected_face.image.shape[0], is_aligned=True).face else: - centering: "CenteringType" = ("legacy" if self._alignments.version == 1.0 + 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 From d0a8d5981251ae091f1182fa42f392ffa932987a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 20 Feb 2023 23:51:20 +0000 Subject: [PATCH 808/981] mask tool: Add batch-mode --- locales/es/LC_MESSAGES/tools.mask.cli.mo | Bin 8735 -> 10110 bytes locales/es/LC_MESSAGES/tools.mask.cli.po | 50 ++++++-- locales/kr/LC_MESSAGES/tools.mask.cli.mo | Bin 8636 -> 9966 bytes locales/kr/LC_MESSAGES/tools.mask.cli.po | 48 ++++++-- locales/tools.mask.cli.pot | 37 ++++-- tools/mask/cli.py | 16 +++ tools/mask/mask.py | 140 ++++++++++++++++++----- 7 files changed, 226 insertions(+), 65 deletions(-) diff --git a/locales/es/LC_MESSAGES/tools.mask.cli.mo b/locales/es/LC_MESSAGES/tools.mask.cli.mo index ad5e651490c81b2b7db90bd0e8d9173cda783a68..df48b2c1dd051017dd387e7c9b64e99cbf33415c 100644 GIT binary patch delta 1750 zcmZ`(&x;&I6n>LT6xNtEnkex{c)^uPbh3eH5EgO>7=J*BAyLGGftv1`or={}ovP~D zBrLQSK@Xmy~$J`V#92tYHr~Tpz){fb}ufSv-HUi>QbFU$Ecf`S0CCFT;M@Co-^i zVf#40yocxqtl|M858tJ|o&Tr%h(3hhrA4Ayk6QW-o6~sl2@oKbeuPB~{S5mc?60uz zA>r?^@4}vb49W5S)&Zj9c>WXiE}oYjCwd$95YT@B>tWB}{14bGNObZL(H#K!oQZzH z`TDa&Khcor%_X8;IC$ncqStZ210nz?11ms0EaGW8xcOx7@TrHf1qVU|Enoo_fkM6n zzqrZ;zVuMu5j`@idS5NH7aPZEqPbG>eNNt6_KvH@CR1C@(Kn&cahcy3bJSKBNtfc@ z#dz!j`#Kr#BFBkuY{?@;>BK7X*jww@;>yCgb>r$Lt?(JHRnRWUQHjvPw=OB;j9X=G zayDgwV4NXMO{qPPjnxszByk-)&_rDh*-P|haBN1$yPQ#?$3ay(o){~^j`F@qs6Y2u z5i-am>C1N5(b=A0%>VuUygl`@J_ zj}?Zu1@Wk)>-uqQnP1(RyOq|XV>14jnergFZ6FiGL^>9nuGf0rD(~#|d~)IZYdq0P z2Dz<8`dSJq>m=vdq}SA?baR<5uD>(UdDghij_kd)d(X)iS6(_b`)+ZxuRVK37fsS^ z6>k7Q5q^JeMHA2t58hL z6dUC2)GLmt(@j!^joXg>z0pwKJimk;!6ZWA;|XEV7Y`n6MVdL;z0ocOKj+kp zOrZ+1F>_q164<440vU)y?#>jlL3^@pVF9@iNAw}U?$oWeIIilPUbWUZNW5z+=w;~& zpsoyj=Y;e0JlBw&^?6d~cC-na-b&R?C=1%)Bf^)acpDKwEp^hNf@%fo(M+mq7{q-c zi{rkvh2ei1iqZ-mQis#ix-%zDz`2`%me%33Egi!FwDHRk=?(2oP4*JAQUm`32Kd#eRFvw9#<+m71KHIsoQC`G zfW;$Ng|8$$b+j9H9q43G*oBCSphMP~2NV;64b{i4XB^?>WpL;L#N)bWD8{vH4*_C&q_`K)Dw9W(HdB4B+d7tNb zzs^gy-^l)apuZ^aJ&5rHMh)XpjFCS4@XaGWi*XU-WvsXF5~2_DKM}vg`ma4goIvdK z3t=Muf!L4pclQeMHAduaA#B_|v@g70xL=4l+w!pjX@%%)qmm=({$MJ)KFC8<&(YoDaygZHw$AB{G(4rYuR0ok>Te zGfJ<;$dQ(exH8Ju!yFs4G}$TFw37}waXVv0X&hP7CBvbZZCUn|GdA%0w3$q0+%bBQ zQbt-PT|SB;HDYIzu3;w4u3We?N@vic9h|N!iHzftJZs`_xB-i(j2UR&4#mR^7y5BzlwP?LJImI?F>b#T zD5c@RwqfodIufcN_gqSLt+JEWx!~l@Q?F878r;ZT7Rj@2+6dlBfYXzgNk!SsQE__u z?YInjjbHPreIM`H``q(m$B#EZ+yC2ORQc_z zuB(j>>BXAAdeJXlQ;YLzu{KINSM@Jc{9>8B8(-+l4Z^P4Xp=5&>$#Gyd9eCMNqt)Pi&awBuc{SNjO$;* zjjMjKtd zqW=GMb+zm@x7Bh_rPr7xPN;6cEK>Oyc0hgJ25nN!me+1@HJ-I_RKl823Ytl8&Okjw zVME_4kviYfS%Cb9tJjzTy^79lspWsYzT-*52CtQ)(5tXzfhd4i>p;t?l`_MqdP^^s zcVLLi^Np^PGvu|i4b?=?0z!v-M(WGgj<7tvKCf4Ibt`OqpdL9@zf|P54u`f6>q>j) z=>%_PH&Li$mfgpqZx-}Ta61Y&f~Ey-pmF@j{k0l*Z8J}LZB8vUP{dzoLr8P($rJtm E02K?%R{#J2 delta 373 zcmXYtKTASU9ERVU*Cg%w=VrCkEgc#ZK?ezfCTWT!XozUAHK^u-$`(f##im9bglIXn zRn#YFa4(3qurJWkbIc#S_xb%hhxgo{t;b6J-FG`8l4t?lpn01+e27z+M`vh*x;G&? zgg-}9t(sV*D*R9-8V NOu_&3%xkvb{sEHJESvxU diff --git a/locales/kr/LC_MESSAGES/tools.mask.cli.po b/locales/kr/LC_MESSAGES/tools.mask.cli.po index c96d38aa3b..fb1554f97b 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 14:55+0000\n" -"PO-Revision-Date: 2023-02-20 15:01+0000\n" +"POT-Creation-Date: 2023-02-20 23:42+0000\n" +"PO-Revision-Date: 2023-02-20 23:44+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ko_KR\n" @@ -31,6 +31,7 @@ msgstr "" "존재하는 alignments 파일들로부터 마스크를 생성합니다." #: tools/mask/cli.py:33 tools/mask/cli.py:44 tools/mask/cli.py:54 +#: tools/mask/cli.py:64 msgid "data" msgstr "데이터" @@ -59,11 +60,34 @@ msgstr "" "L|faces: 입력은 추출된 얼굴을 포함된 폴더입니다.\n" "L|frames: 입력이 프레임을 포함된 폴더이거나 비디오입니다" -#: tools/mask/cli.py:65 tools/mask/cli.py:97 +#: tools/mask/cli.py:65 +msgid "" +"R|Run the mask tool on multiple sources. If selected then the other options " +"should be set as follows:\n" +"L|input: A parent folder containing either all of the video files to be " +"processed, or containing sub-folders of frames/faces.\n" +"L|output-folder: If provided, then sub-folders will be created within the " +"given location to hold the previews for each input.\n" +"L|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." +msgstr "" +"R|여러 소스에서 마스크 도구를 실행합니다. 선택한 경우 다른 옵션을 다음과 같" +"이 설정해야 합니다.\n" +"L|input: 처리할 모든 비디오 파일을 포함하거나 프레임/얼굴의 하위 폴더를 포함" +"하는 상위 폴더입니다.\n" +"L|output-folder: 제공된 경우 각 입력에 대한 미리 보기를 보관하기 위해 지정된 " +"위치 내에 하위 폴더가 생성됩니다.\n" +"L|alignments: 일괄 처리에서는 정렬 필드가 무시됩니다. 정렬 파일은 기본 위치" +"(프레임용)에 있어야 합니다. 입력 유형이 '얼굴'인 마스크를 일괄 처리하는 경우 " +"추출된 얼굴 내의 PNG 헤더만 업데이트됩니다." + +#: tools/mask/cli.py:81 tools/mask/cli.py:113 msgid "process" msgstr "진행" -#: tools/mask/cli.py:66 +#: tools/mask/cli.py:82 msgid "" "R|Masker to use.\n" "L|bisenet-fp: Relatively lightweight NN based mask that provides more " @@ -113,7 +137,7 @@ msgstr "" "델은 커뮤니티 구성원들에 의해 훈련되었으며 추가 설명을 위해 테스트가 필요합니" "다. 옆 얼굴은 평균 이하의 성능을 초래할 수 있습니다." -#: tools/mask/cli.py:98 +#: tools/mask/cli.py:114 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 " @@ -133,12 +157,12 @@ msgstr "" "L|output: 마스크를 업데이트하지 말고 지정된 출력 폴더에서 검토할 수 있도록 출" "력하십시오." -#: tools/mask/cli.py:111 tools/mask/cli.py:118 tools/mask/cli.py:131 -#: tools/mask/cli.py:144 tools/mask/cli.py:153 +#: 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 msgid "output" msgstr "출력" -#: tools/mask/cli.py:112 +#: tools/mask/cli.py:128 msgid "" "Optional output location. If provided, a preview of the masks created will " "be output in the given folder." @@ -146,7 +170,7 @@ msgstr "" "선택적 출력 위치. 만약 값이 제공된다면 생성된 마스크 미리 보기가 주어진 폴더" "에 출력됩니다." -#: tools/mask/cli.py:122 +#: tools/mask/cli.py:138 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 " @@ -158,7 +182,7 @@ msgstr "" "은 홀수여야 하며 짝수가 전달되면 다음 홀수로 반올림됩니다. NB: 출력 미리 보기" "에만 영향을 줍니다. 0으로 설정하면 꺼집니다" -#: tools/mask/cli.py:135 +#: tools/mask/cli.py:151 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 " @@ -168,7 +192,7 @@ msgstr "" "줄이는 데 도움이 됩니다. 값이 클수록 마스크에 더 많은 영향을 미칩니다. NB: 출" "력 미리 보기에만 영향을 줍니다. 0으로 설정하면 꺼집니다" -#: tools/mask/cli.py:145 +#: tools/mask/cli.py:161 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" @@ -181,7 +205,7 @@ msgstr "" "L|masked: 마스크된 얼굴/프레임을 Rgba 이미지로 출력합니다.\n" "L|mask: 마스크를 단일 채널 이미지로만 출력합니다." -#: tools/mask/cli.py:154 +#: tools/mask/cli.py:170 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/locales/tools.mask.cli.pot b/locales/tools.mask.cli.pot index 009ab13a05..6cd8348e93 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 14:55+0000\n" +"POT-Creation-Date: 2023-02-20 23:42+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -28,6 +28,7 @@ msgid "" msgstr "" #: tools/mask/cli.py:33 tools/mask/cli.py:44 tools/mask/cli.py:54 +#: tools/mask/cli.py:64 msgid "data" msgstr "" @@ -50,11 +51,25 @@ msgid "" "L|frames: The input is a folder containing frames or is a video" msgstr "" -#: tools/mask/cli.py:65 tools/mask/cli.py:97 +#: tools/mask/cli.py:65 +msgid "" +"R|Run the mask tool on multiple sources. If selected then the other options " +"should be set as follows:\n" +"L|input: A parent folder containing either all of the video files to be " +"processed, or containing sub-folders of frames/faces.\n" +"L|output-folder: If provided, then sub-folders will be created within the " +"given location to hold the previews for each input.\n" +"L|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." +msgstr "" + +#: tools/mask/cli.py:81 tools/mask/cli.py:113 msgid "process" msgstr "" -#: tools/mask/cli.py:66 +#: tools/mask/cli.py:82 msgid "" "R|Masker to use.\n" "L|bisenet-fp: Relatively lightweight NN based mask that provides more " @@ -84,7 +99,7 @@ msgid "" "performance." msgstr "" -#: tools/mask/cli.py:98 +#: tools/mask/cli.py:114 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 " @@ -96,18 +111,18 @@ msgid "" "output folder." msgstr "" -#: tools/mask/cli.py:111 tools/mask/cli.py:118 tools/mask/cli.py:131 -#: tools/mask/cli.py:144 tools/mask/cli.py:153 +#: 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 msgid "output" msgstr "" -#: tools/mask/cli.py:112 +#: tools/mask/cli.py:128 msgid "" "Optional output location. If provided, a preview of the masks created will " "be output in the given folder." msgstr "" -#: tools/mask/cli.py:122 +#: tools/mask/cli.py:138 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 " @@ -115,14 +130,14 @@ msgid "" "to the next odd number. NB: Only effects the output preview. Set to 0 for off" msgstr "" -#: tools/mask/cli.py:135 +#: tools/mask/cli.py:151 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:145 +#: tools/mask/cli.py:161 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" @@ -130,7 +145,7 @@ msgid "" "L|mask: Only output the mask as a single channel image." msgstr "" -#: tools/mask/cli.py:154 +#: tools/mask/cli.py:170 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/mask/cli.py b/tools/mask/cli.py index 3408467d8e..e691870604 100644 --- a/tools/mask/cli.py +++ b/tools/mask/cli.py @@ -56,6 +56,22 @@ def get_argument_list(): 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, diff --git a/tools/mask/mask.py b/tools/mask/mask.py index d5b85778db..92a5aa0360 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -3,6 +3,7 @@ import logging import os import sys +from argparse import Namespace from typing import cast, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 @@ -13,11 +14,10 @@ from lib.image import FacesLoader, ImagesLoader, ImagesSaver, encode_image from lib.multithreading import MultiThread -from lib.utils import get_folder +from lib.utils import get_folder, _video_extensions from plugins.extract.pipeline import Extractor, ExtractMedia if TYPE_CHECKING: - from argparse import Namespace from lib.align.aligned_face import CenteringType from lib.align.alignments import AlignmentFileDict, PNGHeaderDict from lib.queue_manager import EventQueue @@ -32,13 +32,116 @@ class Mask(): # pylint:disable=too-few-public-methods Faceswap Masks tool. Generate masks from existing alignments files, and output masks for preview. + Wrapper for the mask process to run in either batch mode or single use mode + Parameters ---------- arguments: :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in from :mod:`tools.py` """ - def __init__(self, arguments: "Namespace") -> None: + def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: %s", self.__class__.__name__, arguments) + self._args = arguments + self._input_locations = self._get_input_locations() + + def _get_input_locations(self) -> List[str]: + """ Obtain the full path to input locations. Will be a list of locations if batch mode is + selected, or containing a single location if batch mode is not selected. + + Returns + ------- + list: + The list of input location paths + """ + if not self._args.batch_mode: + return [self._args.input] + + retval = [os.path.join(self._args.input, fname) + for fname in os.listdir(self._args.input) + if os.path.isdir(os.path.join(self._args.input, fname)) + or os.path.splitext(fname)[-1].lower() in _video_extensions] + logger.info("Batch mode selected. Processing locations: %s", retval) + return retval + + def _get_output_location(self, input_location: str) -> str: + """ Obtain the path to an output folder for faces for a given input location. + + A sub-folder within the user supplied output location will be returned based on + the input filename + + Parameters + ---------- + input_location: str + The full path to an input video or folder of images + """ + retval = os.path.join(self._args.output, + os.path.splitext(os.path.basename(input_location))[0]) + logger.debug("Returning output: '%s' for input: '%s'", retval, input_location) + return retval + + def _get_extractor(self) -> Optional[Extractor]: + """ Obtain a Mask extractor plugin and launch it + + Returns + ------- + :class:`plugins.extract.pipeline.Extractor`: + The launched Extractor + """ + if self._args.processing == "output": + logger.debug("Update type `output` selected. Not launching extractor") + return None + logger.debug("masker: %s", self._args.masker) + extractor = Extractor(None, None, self._args.masker, exclude_gpus=self._args.exclude_gpus) + logger.debug(extractor) + return extractor + + def process(self) -> None: + """ The entry point for triggering the Extraction Process. + + Should only be called from :class:`lib.cli.launcher.ScriptExecutor` + """ + extractor = self._get_extractor() + for idx, location in enumerate(self._input_locations): + if self._args.batch_mode: + logger.info("Processing job %s of %s: %s", + idx + 1, len(self._input_locations), location) + arguments = Namespace(**self._args.__dict__) + arguments.input = location + # Due to differences in how alignments are handled for frames/faces, only default + # locations allowed + arguments.alignments = None + if self._args.output: + arguments.output = self._get_output_location(location) + else: + arguments = self._args + + if extractor is not None: + extractor.launch() + + mask = _Mask(arguments, extractor) + mask.process() + + if extractor is not None: + extractor.reset_phase_index() + + +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. + + Faceswap Masks tool. Generate masks from existing alignments files, and output masks + for preview. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + extractor: :class:`plugins.extract.pipeline.Extractor`: + The launched Extractor + """ + def __init__(self, arguments: Namespace, extractor: Optional[Extractor]) -> None: + logger.debug("Initializing %s: (arguments: %s, extractor: %s)", + self.__class__.__name__, arguments, extractor) self._update_type = arguments.processing self._input_is_faces = arguments.input_type == "faces" self._mask_type = arguments.masker @@ -56,7 +159,7 @@ def __init__(self, arguments: "Namespace") -> None: self._faces_saver: Optional[ImagesSaver] = None self._alignments = self._get_alignments(arguments) - self._extractor = self._get_extractor(arguments.exclude_gpus) + self._extractor = extractor self._set_correct_mask_type() self._extractor_input_thread = self._feed_extractor() @@ -79,7 +182,7 @@ 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") -> Optional[ImagesSaver]: + def _set_saver(self, arguments: Namespace) -> Optional[ImagesSaver]: """ set the saver in a background thread Parameters @@ -105,7 +208,7 @@ def _set_saver(self, arguments: "Namespace") -> Optional[ImagesSaver]: logger.debug(saver) return saver - def _get_alignments(self, arguments: "Namespace") -> Optional[Alignments]: + def _get_alignments(self, arguments: Namespace) -> Optional[Alignments]: """ Obtain the alignments from either the given alignments location or the default location. @@ -143,29 +246,6 @@ def _get_alignments(self, arguments: "Namespace") -> Optional[Alignments]: return Alignments(folder, filename=filename) - def _get_extractor(self, exclude_gpus: List[int]) -> Optional[Extractor]: - """ 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 @@ -366,7 +446,7 @@ def _check_for_missing(self, frame: str, idx: int, alignment: "AlignmentFileDict logger.debug("Mask pre-exists for face: '%s' - %s", frame, idx) return retval - def _get_output_suffix(self, arguments: "Namespace") -> str: + def _get_output_suffix(self, arguments: Namespace) -> str: """ The filename suffix, based on selected output options. Parameters From a076afa910da3b626cd241d608bf2fdf9cffe914 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 21 Feb 2023 12:47:18 +0000 Subject: [PATCH 809/981] bugfix: mask tool memory leak in batch-mode --- tools/mask/mask.py | 73 ++++++++++++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 28 deletions(-) diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 92a5aa0360..97ae49b239 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -4,6 +4,7 @@ import os import sys from argparse import Namespace +from multiprocessing import Process from typing import cast, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 @@ -79,28 +80,28 @@ def _get_output_location(self, input_location: str) -> str: logger.debug("Returning output: '%s' for input: '%s'", retval, input_location) return retval - def _get_extractor(self) -> Optional[Extractor]: - """ Obtain a Mask extractor plugin and launch it + @staticmethod + def _run_mask_process(arguments: Namespace) -> None: + """ The mask process to be run in a spawned process. - Returns - ------- - :class:`plugins.extract.pipeline.Extractor`: - The launched Extractor + In some instances, batch-mode memory leaks. Launching each job in a separate process + prevents this leak. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments to be used for the given job """ - if self._args.processing == "output": - logger.debug("Update type `output` selected. Not launching extractor") - return None - logger.debug("masker: %s", self._args.masker) - extractor = Extractor(None, None, self._args.masker, exclude_gpus=self._args.exclude_gpus) - logger.debug(extractor) - return extractor + logger.debug("Starting process: (arguments: %s)", arguments) + mask = _Mask(arguments) + mask.process() + logger.debug("Finished process: (arguments: %s)", arguments) def process(self) -> None: """ The entry point for triggering the Extraction Process. Should only be called from :class:`lib.cli.launcher.ScriptExecutor` """ - extractor = self._get_extractor() for idx, location in enumerate(self._input_locations): if self._args.batch_mode: logger.info("Processing job %s of %s: %s", @@ -115,14 +116,12 @@ def process(self) -> None: else: arguments = self._args - if extractor is not None: - extractor.launch() - - mask = _Mask(arguments, extractor) - mask.process() - - if extractor is not None: - extractor.reset_phase_index() + if len(self._input_locations) > 1: + proc = Process(target=self._run_mask_process, args=(arguments, )) + proc.start() + proc.join() + else: + self._run_mask_process(arguments) class _Mask(): # pylint:disable=too-few-public-methods @@ -136,12 +135,9 @@ class _Mask(): # pylint:disable=too-few-public-methods ---------- arguments: :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in from :mod:`tools.py` - extractor: :class:`plugins.extract.pipeline.Extractor`: - The launched Extractor """ - def __init__(self, arguments: Namespace, extractor: Optional[Extractor]) -> None: - logger.debug("Initializing %s: (arguments: %s, extractor: %s)", - self.__class__.__name__, arguments, extractor) + 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 @@ -159,7 +155,7 @@ def __init__(self, arguments: Namespace, extractor: Optional[Extractor]) -> None self._faces_saver: Optional[ImagesSaver] = None self._alignments = self._get_alignments(arguments) - self._extractor = extractor + self._extractor = self._get_extractor(arguments.exclude_gpus) self._set_correct_mask_type() self._extractor_input_thread = self._feed_extractor() @@ -246,6 +242,27 @@ def _get_alignments(self, arguments: Namespace) -> Optional[Alignments]: return Alignments(folder, filename=filename) + def _get_extractor(self, exclude_gpus: List[int]) -> Optional[Extractor]: + """ 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 From ec95c1953461285345461afca29dd8e5a2823828 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 24 Feb 2023 00:38:28 +0000 Subject: [PATCH 810/981] alignments tool - Add batch mode --- .../es/LC_MESSAGES/tools.alignments.cli.mo | Bin 9459 -> 11950 bytes .../es/LC_MESSAGES/tools.alignments.cli.po | 84 ++++--- .../kr/LC_MESSAGES/tools.alignments.cli.mo | Bin 9269 -> 11673 bytes .../kr/LC_MESSAGES/tools.alignments.cli.po | 68 ++++-- locales/tools.alignments.cli.pot | 50 +++-- tools/alignments/alignments.py | 208 +++++++++++++++++- tools/alignments/cli.py | 32 ++- 7 files changed, 377 insertions(+), 65 deletions(-) diff --git a/locales/es/LC_MESSAGES/tools.alignments.cli.mo b/locales/es/LC_MESSAGES/tools.alignments.cli.mo index 53299df4381cac0047cf803f5ebc3f38ce8b3d28..9d0b80ace08586820dfc198757b39eefb0887f8a 100644 GIT binary patch delta 2855 zcmbVNPiP!f7=KMRwoO|5H`PXkheg-g-EB-HO29*e3dIOE#y=E8c{}rV_r;lcCW3hIBnaY7#NYSc?Czw7Y8;sS-b~(n-}n3d zzW43#=RaS|e>_^b%5WXWeJ}1WaNmdfs#O{;7#BH@DJbxU~P=C7l3a7&jP;( zz7L!jXY2vsRp67r8^C3J^B3@uy^K9Q!PtX%e*FMrafRjV!r1r|x2G9r#j#ec%&#Zrsh-*T4W+ z2QHjsY!&&LkHr=OT?c-(kFoiC7<&=Vf1bh`)vkihMSSq~!;Jk3{2BNr3cPuSu_ZjO zG{7480dNZVA&?6F1c)%iWq5&;B||T0?>?%St~(vr5079?4DY~Aw$Qa7Hxy%s=>Zps zbO<*khJXy}GSKv17d(V~vS%qr=`Wdq17XSFWsN5l=gki$Jx*NJeSGQ>_E8 zBOYc!s^UQML}$j6NrSIqV@n5t-c(VWuj^*QlPr$4NqJMGUWbR;my46jS9~KjYg}GV zjquVMZy{m5B@m;=LzN`>x=so6D5Q*|mn|bg+7#)*_5{IeJc&iBM9`Q#-zsI|3i)Lq zy;S-{!ZFcU8Ls10=?MSt=q&Jg6D(xPMN-tCP?h2$YH%f7YN#{xm>JRt0;v^R<~FfP@t$jvU)m7g^Iwh%l98&A}uL(Y&5th(r?%dB7nZON-Lp_ z?dl~)C`lXX*Qf%Dk|%H?Nuj2~UxCaWsSNjJOTZu=Xb;-JE+YdhHsqi(ZB$!DB7piN zYEk0Z4dqMS0DJ4lC106>_Q<5|baN08d>hYNi6}atS$Fgku3H1s;fPIOz2}=9h3qNBKV&Z))0t;`*| zt5CGzG8o3vfO}}$lTKM7GkOiD+8yH3Yf^vE+2s}+Hdyzg%H(M>=RdV=@7;r6jvt<{ z&(7879_6#Mi}MR-W@meAN8&@6Hs{u*m%;r&aQFvJ6%LO;Dw;nL+*18s#Bq=}gi&-@ zGyI>LV_D<)sdh&DiKP)uv5t4b4^@i&GN9Q5?x-`+*s{YE%10eOnm8jRM(_T03O%PV zP2A}5&{pD~>1&{fOQ8$-%RUTH$F6RE#hP0_qTBNR5Yr4v3vjL+gs+gd->h z(`joon`$;J7jXtD;S$C3go!1Djg5*t6=Mjzn#n19YM~%fcHcQUlhaEgvLPZm;dV#~ z)8Nliq7-r_&@w^zuzzi&Ip%tT(8P+&!uNJ!3BF}_3*&XvhW%`4DzPuf+ZMz4f)06- z@vN!5!u1$KPo~m@7+O9gbra1cfn+6GZibO`EuHE%+K5yx#vQt4`*3rzjl9R9v;=$) zZlHX_GdQ@TVpcF2wP8@!EOrnP+KrUI1(3$Rg(AnzgCWSP^ z^taFM>j(iME{!wOzXqX8C0I_LmC63bI+Y4Lr8{IpAM;%rdF#A$0{-uTxAi=)4(HuK z``eQzBJ@*gr7t&>8-bd%=teUq8n!eD6sXtG`WOPmU?7hM6U8>4#Na#EU7DUbKxfeX WmMYWzVfy|B>&~vbz4r}&sPZ?H-jIy| delta 495 zcmX}oy-Pw-7{~FSdd=RoTRIupoAf5BMVJ^=TTnw#1c9^A5G@T&HaIlZ5Yb-{K}0Xy zrj`mCa%hOApqE95h@hx3sP7f?fpb6Sxt!;m^PHpf*Hre|GRHDXYx}>7Ip;D2h)S zr8v`5pJZSGsnaXg;wPrr;0Jrj7flYp94_Hq*%Td(k?&(3ix|P<7HJNjaLtkBFu!zD zEgc4=1vXx7V^bE~hPXBMv`gz;EYKlMkneU$G@%Eq!xywK_=c3*JygP8XH+tYV@hH7 zYS(&Fe7IPtvnu?L8YaAuyyYKF$itL^#>H#O+|D~btaInM+xqqFxh=nW;qt{J(a}h3 WI221HhZ6nKm{stvc&&}Tu<-}v89a>u diff --git a/locales/es/LC_MESSAGES/tools.alignments.cli.po b/locales/es/LC_MESSAGES/tools.alignments.cli.po index 5407e4e795..c93ce17abb 100644 --- a/locales/es/LC_MESSAGES/tools.alignments.cli.po +++ b/locales/es/LC_MESSAGES/tools.alignments.cli.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-09-14 18:36+0100\n" -"PO-Revision-Date: 2022-09-14 18:38+0100\n" +"POT-Creation-Date: 2023-02-24 00:27+0000\n" +"PO-Revision-Date: 2023-02-24 00:36+0000\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es_ES\n" @@ -47,8 +47,8 @@ msgstr " Debe indicar una carpeta de caras (-fc)." #: tools/alignments/cli.py:46 msgid "" -" Must Pass in either a frames folder/source video file OR afaces folder (-fr " -"or -fc)." +" Must Pass in either a frames folder/source video file OR a faces folder (-" +"fr or -fc)." msgstr "" " Debe indicar una carpeta de fotogramas o archivo de vídeo de origen, o una " "carpeta de caras (-fr o -fc)." @@ -82,11 +82,11 @@ msgid "" "{1}\n" "L|'from-faces': Generate alignment file(s) from a folder of extracted faces. " "if the folder of faces comes from multiple sources, then multiple alignments " -"files will be created. NB: for faces which have been extracted folders of " -"source images, rather than a video, a single alignments file will be created " -"as there is no way for the process to know how many folders of images were " -"originally used. You do not need to provide an alignments file path to run " -"this job. {3}\n" +"files will be created. NB: for faces which have been extracted from folders " +"of source images, rather than a video, a single alignments file will be " +"created as there is no way for the process to know how many folders of " +"images were originally used. You do not need to provide an alignments file " +"path to run this job. {3}\n" "L|'missing-alignments': Identify frames that do not exist in the alignments " "file.{2}{0}\n" "L|'missing-frames': Identify frames in the alignments file that do not " @@ -159,7 +159,7 @@ msgstr "" "directorio de origen." #: tools/alignments/cli.py:110 tools/alignments/cli.py:123 -#: tools/alignments/cli.py:130 tools/alignments/cli.py:149 +#: tools/alignments/cli.py:130 tools/alignments/cli.py:137 msgid "data" msgstr "datos" @@ -187,12 +187,55 @@ msgstr "" "Directorio que contiene los fotogramas de origen de los que se extrajeron " "las caras." -#: tools/alignments/cli.py:140 tools/alignments/cli.py:164 -#: tools/alignments/cli.py:174 +#: tools/alignments/cli.py:138 +msgid "" +"R|Run the aligmnents tool on multiple sources. The following jobs support " +"batch mode:\n" +"L|draw, extract, from-faces, missing-alignments, missing-frames, no-faces, " +"sort, spatial.\n" +"If batch mode is selected then the other options should be set as follows:\n" +"L|alignments_file: For 'sort' and 'spatial' this should point to the parent " +"folder containing the alignments files to be processed. For all other jobs " +"this option is ignored, and the alignments files must exist at their default " +"location relative to the original frames folder/video.\n" +"L|faces_dir: For 'from-faces' this should be a parent folder, containing sub-" +"folders of extracted faces from which to generate alignments files. For " +"'extract' this should be a parent folder where sub-folders will be created " +"for each extraction to be run. For all other jobs this option is ignored.\n" +"L|frames_dir: For 'draw', 'extract', 'missing-alignments', 'missing-frames' " +"and 'no-faces' this should be a parent folder containing video files or sub-" +"folders of images to perform the alignments job on. The alignments file " +"should exist at the default location. For all other jobs this option is " +"ignored." +msgstr "" +"R|Ejecute la herramienta de alineación en varias fuentes. Los siguientes " +"trabajos admiten el modo por lotes:\n" +"L|draw, extract, from-faces, missing-alignments, missing-frames, no-faces, " +"sort, spatial.\n" +"Si se selecciona el modo por lotes, las otras opciones deben configurarse de " +"la siguiente manera:\n" +"L|alignments_file: para 'sort' y 'spatial', debe apuntar a la carpeta " +"principal que contiene los archivos de alineación que se van a procesar. " +"Para todos los demás trabajos, esta opción se ignora y los archivos de " +"alineaciones deben existir en su ubicación predeterminada en relación con la " +"carpeta/video de fotogramas originales.\n" +"L|faces_dir: para 'from-faces', esta debe ser una carpeta principal que " +"contenga subcarpetas de caras extraídas desde las cuales generar archivos de " +"alineación. Para 'extraer', esta debe ser una carpeta principal donde se " +"crearán subcarpetas para cada extracción que se ejecute. Para todos los " +"demás trabajos, esta opción se ignora.\n" +"L|frames_dir: para 'draw', 'extract', 'missing-alignments', 'missing-frames' " +"y 'no-faces', esta debe ser una carpeta principal que contenga archivos de " +"video o subcarpetas de imágenes para realizar el trabajo de alineaciones en. " +"El archivo de alineaciones debe existir en la ubicación predeterminada. Para " +"todos los demás trabajos, esta opción se ignora." + +#: tools/alignments/cli.py:164 tools/alignments/cli.py:175 +#: tools/alignments/cli.py:185 msgid "extract" msgstr "extracción" -#: tools/alignments/cli.py:141 +#: tools/alignments/cli.py:165 msgid "" "[Extract only] Extract every 'nth' frame. This option will skip frames when " "extracting faces. For example a value of 1 will extract faces from every " @@ -203,22 +246,11 @@ msgstr "" "caras de cada fotograma, un valor de 10 extraerá las caras de cada 10 " "fotogramas." -#: tools/alignments/cli.py:150 -msgid "" -"R|If selected then:\n" -"L|'frames_folder' should be a parent folder containing multiple videos/" -"folders of images you need to work on.\n" -"L|'faces_folder' should be a parent folder containing multiple folders of " -"faces you wish to manage.\n" -"L|'alignments_file'. should be a parent folder containing multiple alignment " -"files." -msgstr "" - -#: tools/alignments/cli.py:165 +#: tools/alignments/cli.py:176 msgid "[Extract only] The output size of extracted faces." msgstr "[Sólo extracción] El tamaño de salida de las caras extraídas." -#: tools/alignments/cli.py:175 +#: tools/alignments/cli.py:186 msgid "" "[Extract only] Only extract faces that have been resized by this percent or " "more to meet the specified extract size (`-sz`, `--size`). Useful for " diff --git a/locales/kr/LC_MESSAGES/tools.alignments.cli.mo b/locales/kr/LC_MESSAGES/tools.alignments.cli.mo index 9f5b8d8890649d1e4ef5888bf3a1c675051eb51c..092983e8578d7697534a0d59ab3dfffed4a2b5e2 100644 GIT binary patch delta 2884 zcmbVN?{5=j9DjgKU^;{W3Q^2AL2U?K89#!G@dav#Mu>qJNyLQQ+Us_Dy=(4nj2GZ- zp^(XDHtouawxcmaz$~HA4o4_|fbTTMm>A<*lRi%qP4unt^W0t6+h!ti$@O#h?0KH= z_w(bP&AoB?g!^k(TaMw{g!^gSHQd{9zrO-MxV{5!2YwIS5Bv*w6c}B}*t@`wfV+S{ z0>^>vs~CF@m;=5FyazmtHGczluV(D6cE+B^``O1BOSdtX&2%z$1QR!RG4=y64rJKu zg^i5u!Q+dY8EXT62*i4p26g~X1E0Z0=YTKa{mUm9>jVA*90b1pB$@-(fCqsuZ((c> zcorA~?%gWux{USW;UqRH0}riXZ2eP=9mo6e?Tr15$~T{7Ov8eUI~d!F@jrq4(BQhA zjJ<;K*SZ;73mgVM0vrwSGa#l*E(;epd1U0kqqx@`mQT3W`QS0!fM>~2ZYFJ!mn;Cq z*n0VbON#V3Zm7vRaYJ3kvUgYfo;|v9W98n;Sobg0o8GZDD*=v(MkWQPHL%oU<@28ZR zP!Dt*K9jJN!6>Px9ZQKjQA(m6TV3P>{POP&IBEj-nahm!-9v z4oWR$>9l2nyR9Z-UIj&mzFwD}gx=nMzmgGJGA%U`l^w`kTpLc<4%F1}tDthfs#%m! zlL`zX-HbyU*k!3Y9!{yv&P+?|(+ow2`cl-O#a*YgglfjX-t*&RLbC$eEt3}0bwNO( zMLeS+qDVk*w@=?DGubp9j(7z;?`g1KLpmTvpK7R<;`n*$$h2 z1MH|;@HA9=P=l=KHEyXWfb#HCRZuqAQ)=YfVGS7n!wAsa1JrqnoBY~%`5zkOePUY3pXtMerCbK-nnxJ7L3QK?sIR4-o^#VU!+4Eku~ zsv=jTMFd)o1xJ;CO!4w~>y#43e6yCgod@4WuGHk!7#kDCnyk?n$M|*bPC)E)!=jME zNR@vwDV3|2hpmzEzQIKwOR)NmIB z_RH_$cQbr)QsiY4WM_PANR);}ah_nCT!L2Nv-j8O5pij`Mxhfe5<1PoviK%TFmT*Etr`E+H{df3`iP-&0^`| z&(np4#L~sWA`un_gM_4`gztN4o#edFyEpfqd(S;usmvB%04AmMC^nyY zr8K|ie#wPz$Uc2wD}G~`0za5%+!LS}F5)I$;}&MD~n1AItS}-yoHDSDN5?OBb@E~^NSntYpV!\n" "Language-Team: LANGUAGE \n" @@ -39,8 +39,8 @@ msgstr "" #: tools/alignments/cli.py:46 msgid "" -" Must Pass in either a frames folder/source video file OR afaces folder (-fr " -"or -fc)." +" Must Pass in either a frames folder/source video file OR a faces folder (-" +"fr or -fc)." msgstr "" #: tools/alignments/cli.py:48 @@ -70,11 +70,11 @@ msgid "" "{1}\n" "L|'from-faces': Generate alignment file(s) from a folder of extracted faces. " "if the folder of faces comes from multiple sources, then multiple alignments " -"files will be created. NB: for faces which have been extracted folders of " -"source images, rather than a video, a single alignments file will be created " -"as there is no way for the process to know how many folders of images were " -"originally used. You do not need to provide an alignments file path to run " -"this job. {3}\n" +"files will be created. NB: for faces which have been extracted from folders " +"of source images, rather than a video, a single alignments file will be " +"created as there is no way for the process to know how many folders of " +"images were originally used. You do not need to provide an alignments file " +"path to run this job. {3}\n" "L|'missing-alignments': Identify frames that do not exist in the alignments " "file.{2}{0}\n" "L|'missing-frames': Identify frames in the alignments file that do not " @@ -105,7 +105,7 @@ msgid "" msgstr "" #: tools/alignments/cli.py:110 tools/alignments/cli.py:123 -#: tools/alignments/cli.py:130 +#: tools/alignments/cli.py:130 tools/alignments/cli.py:137 msgid "data" msgstr "" @@ -126,23 +126,45 @@ msgstr "" msgid "Directory containing source frames that faces were extracted from." msgstr "" -#: tools/alignments/cli.py:140 tools/alignments/cli.py:151 -#: tools/alignments/cli.py:161 +#: tools/alignments/cli.py:138 +msgid "" +"R|Run the aligmnents tool on multiple sources. The following jobs support " +"batch mode:\n" +"L|draw, extract, from-faces, missing-alignments, missing-frames, no-faces, " +"sort, spatial.\n" +"If batch mode is selected then the other options should be set as follows:\n" +"L|alignments_file: For 'sort' and 'spatial' this should point to the parent " +"folder containing the alignments files to be processed. For all other jobs " +"this option is ignored, and the alignments files must exist at their default " +"location relative to the original frames folder/video.\n" +"L|faces_dir: For 'from-faces' this should be a parent folder, containing sub-" +"folders of extracted faces from which to generate alignments files. For " +"'extract' this should be a parent folder where sub-folders will be created " +"for each extraction to be run. For all other jobs this option is ignored.\n" +"L|frames_dir: For 'draw', 'extract', 'missing-alignments', 'missing-frames' " +"and 'no-faces' this should be a parent folder containing video files or sub-" +"folders of images to perform the alignments job on. The alignments file " +"should exist at the default location. For all other jobs this option is " +"ignored." +msgstr "" + +#: tools/alignments/cli.py:164 tools/alignments/cli.py:175 +#: tools/alignments/cli.py:185 msgid "extract" msgstr "" -#: tools/alignments/cli.py:141 +#: tools/alignments/cli.py:165 msgid "" "[Extract only] Extract every 'nth' frame. This option will skip frames when " "extracting faces. For example a value of 1 will extract faces from every " "frame, a value of 10 will extract faces from every 10th frame." msgstr "" -#: tools/alignments/cli.py:152 +#: tools/alignments/cli.py:176 msgid "[Extract only] The output size of extracted faces." msgstr "" -#: tools/alignments/cli.py:162 +#: tools/alignments/cli.py:186 msgid "" "[Extract only] Only extract faces that have been resized by this percent or " "more to meet the specified extract size (`-sz`, `--size`). Useful for " diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index 2d23cce28e..eaf2eaa31f 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -4,16 +4,15 @@ import os import sys -from typing import Any, TYPE_CHECKING +from argparse import Namespace +from typing import Any, cast, List, Dict, Optional -from lib.utils import _video_extensions +from lib.utils import _video_extensions, FaceswapError from .media import AlignmentData from .jobs import Check, Sort, Spatial # noqa pylint: disable=unused-import from .jobs_faces import FromFaces, RemoveFaces, Rename # noqa pylint: disable=unused-import from .jobs_frames import Draw, Extract # noqa pylint: disable=unused-import -if TYPE_CHECKING: - from argparse import Namespace logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -24,12 +23,211 @@ class Alignments(): # pylint:disable=too-few-public-methods The tool allows for manipulation, and working with Faceswap alignments files. + This parent class handles creating the individual job arguments when running in batch-mode or + triggers the job when not running in batch mode + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py` + """ + def __init__(self, arguments: Namespace) -> None: + logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) + self._requires_alignments = ["sort", "spatial"] + self._requires_faces = ["extract", "from-faces"] + self._requires_frames = ["draw", + "extract", + "missing-alignments", + "missing-frames", + "no-faces"] + + self._args = arguments + self._batch_mode = self._validate_batch_mode() + self._locations = self._get_locations() + + def _validate_batch_mode(self) -> bool: + """ Validate that the selected job supports batch processing + + Returns + ------- + bool + ``True`` if batch mode has been selected otherwise ``False`` + """ + batch_mode: bool = self._args.batch_mode + if not batch_mode: + logger.debug("Running in standard mode") + return batch_mode + valid = self._requires_alignments + self._requires_faces + self._requires_frames + if self._args.job not in valid: + logger.error("Job '%s' does not support batch mode. Please select a job from %s or " + "disable batch mode", self._args.job, valid) + sys.exit(1) + logger.debug("Running in batch mode") + return batch_mode + + def _get_alignments_locations(self) -> Dict[str, List[Optional[str]]]: + """ Obtain the full path to alignments files in a parent (batch) location + + These are jobs that only require an alignments file as input, so frames and face locations + are returned as a list of ``None`` values corresponding to the number of alignments files + detected + + Returns + ------- + dict[str, list[Optional[str]]]: + The list of alignments location paths and None lists for frames and faces locations + """ + if not self._args.alignments_file: + logger.error("Please provide an 'alignments_file' location for '%s' job", + self._args.job) + sys.exit(1) + + alignments = [os.path.join(self._args.alignments_file, fname) + for fname in os.listdir(self._args.alignments_file) + if os.path.splitext(fname)[-1].lower() == ".fsa" + and os.path.splitext(fname)[0].endswith("alignments")] + if not alignments: + logger.error("No alignment files found in '%s'", self._args.alignments_file) + sys.exit(1) + + logger.info("Batch mode selected. Processing alignments: %s", alignments) + retval = dict(alignments_file=alignments, + faces_dir=[None for _ in range(len(alignments))], + frames_dir=[None for _ in range(len(alignments))]) + return retval + + def _get_frames_locations(self) -> Dict[str, List[Optional[str]]]: + """ Obtain the full path to frame locations along with corresponding alignments file + locations contained within the parent (batch) location + + Returns + ------- + dict[str, list[Optional[str]]]: + list of frames and alignments location paths. If the job requires an output faces + location then the faces folders are also returned, otherwise the faces will be a list + of ``Nones`` corresponding to the number of jobs to run + """ + if not self._args.frames_dir: + logger.error("Please provide a 'frames_dir' location for '%s' job", self._args.job) + sys.exit(1) + + frames: list[str] = [] + alignments: list[str] = [] + candidates = [os.path.join(self._args.frames_dir, fname) + for fname in os.listdir(self._args.frames_dir) + if os.path.isdir(os.path.join(self._args.frames_dir, fname)) + or os.path.splitext(fname)[-1].lower() in _video_extensions] + logger.debug("Frame candidates: %s", candidates) + + for candidate in candidates: + fname = os.path.join(candidate, "alignments.fsa") + if os.path.isdir(candidate) and os.path.exists(fname): + frames.append(candidate) + alignments.append(fname) + continue + fname = f"{os.path.splitext(candidate)[0]}_alignments.fsa" + if os.path.isfile(candidate) and os.path.exists(fname): + frames.append(candidate) + alignments.append(fname) + continue + logger.warning("Can't locate alignments file for '%s'. Skipping.", candidate) + + if not frames: + logger.error("No valid videos or frames folders found in '%s'", self._args.frames_dir) + sys.exit(1) + + if self._args.job not in self._requires_faces: # faces not required for frames input + faces: list[Optional[str]] = [None for _ in range(len(frames))] + else: + if not self._args.faces_dir: + logger.error("Please provide a 'faces_dir' location for '%s' job", self._args.job) + sys.exit(1) + faces = [os.path.join(self._args.faces_dir, os.path.basename(os.path.splitext(frm)[0])) + for frm in frames] + + logger.info("Batch mode selected. Processing frames: %s", + [os.path.basename(frame) for frame in frames]) + + return dict(alignments_file=cast(List[Optional[str]], alignments), + frames_dir=cast(List[Optional[str]], frames), + faces_dir=faces) + + def _get_locations(self) -> Dict[str, List[Optional[str]]]: + """ Obtain the full path to any frame, face and alignments input locations for the + selected job when running in batch mode. If not running in batch mode, then the original + passed in values are returned in lists + + Returns + ------- + dict[str, list[Optional[str]]] + A dictionary corresponding to the alignments, frames_dir and faces_dir arguments + with a list of full paths for each job + """ + job: str = self._args.job + if not self._batch_mode: # handle with given arguments + retval = dict(alignments_file=[self._args.alignments_file], + faces_dir=[self._args.faces_dir], + frames_dir=[self._args.frames_dir]) + + elif job in self._requires_alignments: # Jobs only requiring an alignments file location + retval = self._get_alignments_locations() + + elif job in self._requires_frames: # Jobs that require a frames folder + retval = self._get_frames_locations() + + elif job in self._requires_faces and job not in self._requires_frames: + # Jobs that require faces as input + faces = [os.path.join(self._args.faces_dir, folder) + for folder in os.listdir(self._args.faces_dir) + if os.path.isdir(os.path.join(self._args.faces_dir, folder))] + if not faces: + logger.error("No folders found in '%s'", self._args.faces_dir) + sys.exit(1) + + retval = dict(faces_dir=faces, + frames_dir=[None for _ in range(len(faces))], + alignments_file=[None for _ in range(len(faces))]) + logger.info("Batch mode selected. Processing faces: %s", + [os.path.basename(folder) for folder in faces]) + else: + raise FaceswapError(f"Unhandled job: {self._args.job}. This is a bug. Please report " + "to the developers") + + logger.debug("File locations: %s", retval) + return retval + + def process(self): + """ The entry point for the Alignments tool from :mod:`lib.tools.alignments.cli`. + + Launches the selected alignments job. + """ + num_jobs = len(self._locations["frames_dir"]) + for idx, (frames, faces, alignments) in enumerate(zip(self._locations["frames_dir"], + self._locations["faces_dir"], + self._locations["alignments_file"])): + if num_jobs > 1: + logger.info("Processing job %s of %s", idx + 1, num_jobs) + + args = Namespace(**self._args.__dict__) + args.frames_dir = frames + args.faces_dir = faces + args.alignments_file = alignments + tool = _Alignments(args) + tool.process() + + +class _Alignments(): # pylint:disable=too-few-public-methods + """ The main entry point for Faceswap's Alignments Tool. This tool is part of the Faceswap + Tools suite and should be called from the ``python tools.py alignments`` command. + + The tool allows for manipulation, and working with Faceswap alignments files. + Parameters ---------- arguments: :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in from :mod:`tools.py` """ - def __init__(self, arguments: "Namespace") -> None: + def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) self._args = arguments job = self._args.job diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py index 58e4b259fe..8569870698 100644 --- a/tools/alignments/cli.py +++ b/tools/alignments/cli.py @@ -43,7 +43,7 @@ def get_argument_list() -> List[Dict[str, Any]]: """ frames_dir = _(" Must Pass in a frames folder/source video file (-fr).") faces_dir = _(" Must Pass in a faces folder (-fc).") - frames_or_faces_dir = _(" Must Pass in either a frames folder/source video file OR a" + frames_or_faces_dir = _(" Must Pass in either a frames folder/source video file OR a " "faces folder (-fr or -fc).") frames_and_faces_dir = _(" Must Pass in a frames folder/source video file AND a faces " "folder (-fr and -fc).") @@ -67,9 +67,9 @@ def get_argument_list() -> List[Dict[str, Any]]: "\nL|'from-faces': Generate alignment file(s) from a folder of extracted " "faces. if the folder of faces comes from multiple sources, then multiple " "alignments files will be created. NB: for faces which have been extracted " - "folders of source images, rather than a video, a single alignments file will " - "be created as there is no way for the process to know how many folders of " - "images were originally used. You do not need to provide an alignments file " + "from folders of source images, rather than a video, a single alignments file " + "will be created as there is no way for the process to know how many folders " + "of images were originally used. You do not need to provide an alignments file " "path to run this job. {3}" "\nL|'missing-alignments': Identify frames that do not exist in the alignments " "file.{2}{0}" @@ -129,6 +129,30 @@ def get_argument_list() -> List[Dict[str, Any]]: filetypes="video", group=_("data"), help=_("Directory containing source frames that faces were extracted from."))) + argument_list.append(dict( + opts=("-B", "--batch-mode"), + action="store_true", + dest="batch_mode", + default=False, + group=_("data"), + help=_("R|Run the aligmnents tool on multiple sources. The following jobs support " + "batch mode:" + "\nL|draw, extract, from-faces, missing-alignments, missing-frames, no-faces, " + "sort, spatial." + "\nIf batch mode is selected then the other options should be set as follows:" + "\nL|alignments_file: For 'sort' and 'spatial' this should point to the parent " + "folder containing the alignments files to be processed. For all other jobs " + "this option is ignored, and the alignments files must exist at their default " + "location relative to the original frames folder/video." + "\nL|faces_dir: For 'from-faces' this should be a parent folder, containing " + "sub-folders of extracted faces from which to generate alignments files. For " + "'extract' this should be a parent folder where sub-folders will be created " + "for each extraction to be run. For all other jobs this option is ignored." + "\nL|frames_dir: For 'draw', 'extract', 'missing-alignments', 'missing-frames' " + "and 'no-faces' this should be a parent folder containing video files or sub-" + "folders of images to perform the alignments job on. The alignments file " + "should exist at the default location. For all other jobs this option is " + "ignored."))) argument_list.append(dict( opts=("-een", "--extract-every-n"), type=int, From 216ef387636eb7b84819c1b77d9a2f631ed97ab5 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 24 Feb 2023 01:29:10 +0000 Subject: [PATCH 811/981] alignments tool - batch jobs to run in process --- tools/alignments/alignments.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index eaf2eaa31f..610e885701 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -5,6 +5,7 @@ import sys from argparse import Namespace +from multiprocessing import Process from typing import Any, cast, List, Dict, Optional from lib.utils import _video_extensions, FaceswapError @@ -196,6 +197,23 @@ def _get_locations(self) -> Dict[str, List[Optional[str]]]: logger.debug("File locations: %s", retval) return retval + @staticmethod + def _run_process(arguments) -> None: + """ The alignements tool process to be run in a spawned process. + + In some instances, batch-mode memory leaks. Launching each job in a separate process + prevents this leak. + + Parameters + ---------- + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments to be used for the given job + """ + logger.debug("Starting process: (arguments: %s)", arguments) + tool = _Alignments(arguments) + tool.process() + logger.debug("Finished process: (arguments: %s)", arguments) + def process(self): """ The entry point for the Alignments tool from :mod:`lib.tools.alignments.cli`. @@ -212,8 +230,13 @@ def process(self): args.frames_dir = frames args.faces_dir = faces args.alignments_file = alignments - tool = _Alignments(args) - tool.process() + + if num_jobs > 1: + proc = Process(target=self._run_process, args=(args, )) + proc.start() + proc.join() + else: + self._run_process(args) class _Alignments(): # pylint:disable=too-few-public-methods From e2ad3e271b09f69515cb1a39d120dc9a220c71d3 Mon Sep 17 00:00:00 2001 From: andentze <66969439+andentze@users.noreply.github.com> Date: Thu, 27 Apr 2023 21:38:09 +0700 Subject: [PATCH 812/981] add ru locale (#1311) --- locales/ru/LC_MESSAGES/faceswap.mo | Bin 1043 -> 1025 bytes locales/ru/LC_MESSAGES/faceswap.po | 16 +- locales/ru/LC_MESSAGES/gui.tooltips.mo | Bin 0 -> 7538 bytes locales/ru/LC_MESSAGES/gui.tooltips.po | 266 +++++++ locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 61680 -> 63457 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 704 +++++++++--------- .../ru/LC_MESSAGES/tools.alignments.cli.mo | Bin 0 -> 15272 bytes .../ru/LC_MESSAGES/tools.alignments.cli.po | 264 +++++++ locales/ru/LC_MESSAGES/tools.effmpeg.cli.mo | Bin 0 -> 8647 bytes locales/ru/LC_MESSAGES/tools.effmpeg.cli.po | 190 +++++ locales/ru/LC_MESSAGES/tools.manual.mo | Bin 0 -> 10909 bytes locales/ru/LC_MESSAGES/tools.manual.po | 293 ++++++++ locales/ru/LC_MESSAGES/tools.mask.cli.mo | Bin 0 -> 12823 bytes locales/ru/LC_MESSAGES/tools.mask.cli.po | 226 ++++++ locales/ru/LC_MESSAGES/tools.model.cli.mo | Bin 0 -> 3819 bytes locales/ru/LC_MESSAGES/tools.model.cli.po | 88 +++ locales/ru/LC_MESSAGES/tools.preview.mo | Bin 0 -> 2891 bytes locales/ru/LC_MESSAGES/tools.preview.po | 93 +++ locales/ru/LC_MESSAGES/tools.sort.cli.mo | Bin 0 -> 20597 bytes locales/ru/LC_MESSAGES/tools.sort.cli.po | 388 ++++++++++ 20 files changed, 2168 insertions(+), 360 deletions(-) create mode 100644 locales/ru/LC_MESSAGES/gui.tooltips.mo create mode 100644 locales/ru/LC_MESSAGES/gui.tooltips.po create mode 100644 locales/ru/LC_MESSAGES/tools.alignments.cli.mo create mode 100644 locales/ru/LC_MESSAGES/tools.alignments.cli.po create mode 100644 locales/ru/LC_MESSAGES/tools.effmpeg.cli.mo create mode 100644 locales/ru/LC_MESSAGES/tools.effmpeg.cli.po create mode 100644 locales/ru/LC_MESSAGES/tools.manual.mo create mode 100644 locales/ru/LC_MESSAGES/tools.manual.po create mode 100644 locales/ru/LC_MESSAGES/tools.mask.cli.mo create mode 100644 locales/ru/LC_MESSAGES/tools.mask.cli.po create mode 100644 locales/ru/LC_MESSAGES/tools.model.cli.mo create mode 100644 locales/ru/LC_MESSAGES/tools.model.cli.po create mode 100644 locales/ru/LC_MESSAGES/tools.preview.mo create mode 100644 locales/ru/LC_MESSAGES/tools.preview.po create mode 100644 locales/ru/LC_MESSAGES/tools.sort.cli.mo create mode 100644 locales/ru/LC_MESSAGES/tools.sort.cli.po diff --git a/locales/ru/LC_MESSAGES/faceswap.mo b/locales/ru/LC_MESSAGES/faceswap.mo index f02a1b0ff3d143ec0fd570bb15b0e55e19a4c3e6..db2449a78958be4cb2f22063d25e4953b5102813 100644 GIT binary patch delta 251 zcmbQt(a15uN4A!Mf#Dz%1A{Y=wr7UW%|O}`$UnX@laJBSSl7Tr*U(VG(8$WvOxwWR zz<|pqvA9Gxq$n}3I47|rzsO1fD3X_6nwXxd8IRzklyTXNC z7dBjMxYz{b?7gt>!mh~`%n_FJE^NHm4AijWV$;PQg$sLt5?g?}cY`=vfK)$-+5}Y6 N3Ny&3qUa6v$+K}-_RdNV|&p6 diff --git a/locales/ru/LC_MESSAGES/faceswap.po b/locales/ru/LC_MESSAGES/faceswap.po index 2e0f297baf..7c37585784 100644 --- a/locales/ru/LC_MESSAGES/faceswap.po +++ b/locales/ru/LC_MESSAGES/faceswap.po @@ -6,28 +6,28 @@ msgid "" msgstr "" "Project-Id-Version: \n" "POT-Creation-Date: 2021-02-18 23:48-0000\n" -"PO-Revision-Date: 2021-02-19 21:30+0300\n" +"PO-Revision-Date: 2023-04-11 12:56+0700\n" +"Last-Translator: \n" "Language-Team: \n" +"Language: ru\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 2.4.2\n" -"Last-Translator: \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" -"Language: ru\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.2.2\n" #: faceswap.py:43 msgid "Extract the faces from pictures or a video" -msgstr "Извлечь лица из фотографий или видео" +msgstr "Извлечение лиц из картинок или видео" #: faceswap.py:44 msgid "Train a model for the two faces A and B" -msgstr "Обучить модель при помощи лиц A и B" +msgstr "Обучить модель для двух лиц A и B" #: faceswap.py:47 msgid "Convert source pictures or video to a new one with the face swapped" -msgstr "Преобразование исходных изображений или видео в новое с замененным лицом" +msgstr "Преобразование исходных изображений или видео в новое с заменой лиц" #: faceswap.py:48 msgid "Launch the Faceswap Graphical User Interface" diff --git a/locales/ru/LC_MESSAGES/gui.tooltips.mo b/locales/ru/LC_MESSAGES/gui.tooltips.mo new file mode 100644 index 0000000000000000000000000000000000000000..5b03b33216ade317cdb32559c90e15499ccbec54 GIT binary patch literal 7538 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 literal 0 HcmV?d00001 diff --git a/locales/ru/LC_MESSAGES/gui.tooltips.po b/locales/ru/LC_MESSAGES/gui.tooltips.po new file mode 100644 index 0000000000..13fc1fe87d --- /dev/null +++ b/locales/ru/LC_MESSAGES/gui.tooltips.po @@ -0,0 +1,266 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2021-03-22 18:37+0000\n" +"PO-Revision-Date: 2023-04-11 16:07+0700\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"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" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.2.2\n" + +#: lib/gui/command.py:184 +msgid "Output command line options to the console" +msgstr "Вывод опций командной строки в консоль" + +#: lib/gui/command.py:195 +msgid "Run the {} script" +msgstr "Запуск сценария {}" + +#: lib/gui/control_helper.py:1234 +msgid "Select a folder..." +msgstr "Выбрать папку..." + +#: lib/gui/control_helper.py:1235 lib/gui/control_helper.py:1236 +msgid "Select a file..." +msgstr "Выбрать файл..." + +#: lib/gui/control_helper.py:1237 +msgid "Select a folder of images..." +msgstr "Выбрать папку с изображениями..." + +#: lib/gui/control_helper.py:1238 +msgid "Select a video..." +msgstr "Выбрать видео..." + +#: lib/gui/control_helper.py:1239 +msgid "Select a model folder..." +msgstr "Выбрать папку с моделью..." + +#: lib/gui/control_helper.py:1240 +msgid "Select one or more files..." +msgstr "Выбрать один или несколько файлов..." + +#: lib/gui/control_helper.py:1241 +msgid "Select a file or folder..." +msgstr "Выбрать файл или папку..." + +#: lib/gui/control_helper.py:1242 +msgid "Select a save location..." +msgstr "Выбрать место сохранения..." + +#: lib/gui/display.py:71 +msgid "Summary statistics for each training session" +msgstr "Сводная статистика для каждой тренировки" + +#: lib/gui/display.py:113 +msgid "Preview updates every 5 seconds" +msgstr "Предпросмотр обновляется каждые 5 секунд" + +#: lib/gui/display.py:122 +msgid "Graph showing Loss vs Iterations" +msgstr "График зависимости потерь от количества итераций" + +#: lib/gui/display.py:125 +msgid "Training preview. Updated on every save iteration" +msgstr "Предпросмотр тренировки. Обновляется каждую сохраняющую итерацию" + +#: lib/gui/display_analysis.py:342 +msgid "Load/Refresh stats for the currently training session" +msgstr "Загрузить/обновить статистику для текущей тренировки" + +#: lib/gui/display_analysis.py:344 +msgid "Clear currently displayed session stats" +msgstr "Очистить отображаемую статистику сессии" + +#: lib/gui/display_analysis.py:346 +msgid "Save session stats to csv" +msgstr "Сохранить статистику сессии в csv файл" + +#: lib/gui/display_analysis.py:348 +msgid "Load saved session stats" +msgstr "Загрузить сохраненную статистику" + +#: lib/gui/display_command.py:94 +msgid "Preview updates at every model save. Click to refresh now." +msgstr "" +"Предпросмотр обновляется при каждом сохранении модели. Нажмите, чтобы " +"обновить сейчас." + +#: lib/gui/display_command.py:261 +msgid "Graph updates at every model save. Click to refresh now." +msgstr "" +"График обновляется при каждом сохранении модели. Нажмите, чтобы обновить " +"сейчас." + +#: lib/gui/display_command.py:275 +msgid "Display the raw loss data" +msgstr "Показать необработанные данные о потерях" + +#: lib/gui/display_command.py:287 +msgid "Display the smoothed loss data" +msgstr "Показать сглаженные данные о потерях" + +#: lib/gui/display_command.py:294 +msgid "Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing." +msgstr "" +"Установите величину сглаживания. 0 - нет сглаживания, 0.99 - максимальное " +"сглаживание." + +#: lib/gui/display_command.py:324 +msgid "Set the number of iterations to display. 0 displays the full session." +msgstr "" +"Установите количество итераций для отображения. 0 отображает полный сеанс." + +#: lib/gui/display_page.py:238 +msgid "Save {}(s) to file" +msgstr "Сохранить {}(ы) в файл" + +#: lib/gui/display_page.py:250 +msgid "Enable or disable {} display" +msgstr "Включить или выключить отображение {}" + +#: lib/gui/menu.py:32 +msgid "faceswap.dev - Guides and Forum" +msgstr "faceswap.dev - Руководства и Форум" + +#: lib/gui/menu.py:33 +msgid "Patreon - Support this project" +msgstr "Patreon - Поддержите этот проект" + +#: lib/gui/menu.py:34 +msgid "Discord - The FaceSwap Discord server" +msgstr "Discord - Discord сервер Faceswap" + +#: lib/gui/menu.py:35 +msgid "Github - Our Source Code" +msgstr "Github - Наш исходный код" + +#: lib/gui/menu.py:527 +msgid "Configure {} settings..." +msgstr "Настройка параметров {}..." + +#: lib/gui/menu.py:535 +msgid "Project" +msgstr "Проект" + +#: lib/gui/menu.py:535 +msgid "currently selected Task" +msgstr "текущая выбранная задача" + +#: lib/gui/menu.py:537 +msgid "Reload {} from disk" +msgstr "Перезагрузить {} из диска" + +#: lib/gui/menu.py:539 +msgid "Create a new {}..." +msgstr "Создать новый {}..." + +#: lib/gui/menu.py:541 +msgid "Reset {} to default" +msgstr "Сбросить {} по умолчанию" + +#: lib/gui/menu.py:543 +msgid "Save {}" +msgstr "Сохранить {}" + +#: lib/gui/menu.py:545 +msgid "Save {} as..." +msgstr "Сохранить {} как..." + +#: lib/gui/menu.py:549 +msgid " from a task or project file" +msgstr " из файла задачи или проекта" + +#: lib/gui/menu.py:550 +msgid "Load {}..." +msgstr "Загрузить {}..." + +#: lib/gui/popup_configure.py:209 +msgid "Close without saving" +msgstr "Закрыть без сохранения" + +#: lib/gui/popup_configure.py:210 +msgid "Save this page's config" +msgstr "Сохранить конфигурацию этой страницы" + +#: lib/gui/popup_configure.py:211 +msgid "Reset this page's config to default values" +msgstr "Сбросить конфигурацию этой страницы до заводских значений" + +#: lib/gui/popup_configure.py:213 +msgid "Save all settings for the currently selected config" +msgstr "Сохранить все настройки для текущей выбранной конфигурации" + +#: lib/gui/popup_configure.py:216 +msgid "Reset all settings for the currently selected config to default values" +msgstr "" +"Сбросить все настройки для текущей выбранной конфигурации до заводских " +"значений" + +#: lib/gui/popup_configure.py:538 +msgid "Select a plugin to configure:" +msgstr "Выбрать плагин для настройки:" + +#: lib/gui/popup_session.py:191 +msgid "Display {}" +msgstr "Показать {}" + +#: lib/gui/popup_session.py:342 +msgid "Refresh graph" +msgstr "Обновить график" + +#: lib/gui/popup_session.py:344 +msgid "Save display data to csv" +msgstr "Сохранить данные дисплея в csv файл" + +#: lib/gui/popup_session.py:346 +msgid "Number of data points to sample for rolling average" +msgstr "Количество точек данных для выборки среднего значения" + +#: lib/gui/popup_session.py:348 +msgid "Set the smoothing amount. 0 is no smoothing, 0.99 is maximum smoothing" +msgstr "" +"Установите величину сглаживания. 0 - нет сглаживания, 0.99 - максимальное " +"сглаживание" + +#: lib/gui/popup_session.py:350 +msgid "" +"Flatten data points that fall more than 1 standard deviation from the mean " +"to the mean value." +msgstr "" +"Сглаживание точек данных, которые отклоняются от среднего значения более чем " +"на 1 стандартное отклонение, до среднего значения." + +#: lib/gui/popup_session.py:353 +msgid "Display rolling average of the data" +msgstr "Показать среднее значение данных" + +#: lib/gui/popup_session.py:355 +msgid "Smooth the data" +msgstr "Сгладить данные" + +#: lib/gui/popup_session.py:357 +msgid "Display raw data" +msgstr "Показать необработанные данные" + +#: lib/gui/popup_session.py:359 +msgid "Display polynormal data trend" +msgstr "Отображение полинормальной тенденции данных" + +#: lib/gui/popup_session.py:361 +msgid "Set the data to display" +msgstr "Указать данные для отображения" + +#: lib/gui/popup_session.py:363 +msgid "Change y-axis scale" +msgstr "Изменить масштаб оси y" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo index 9c2081e2646c7ea35fc80ec87c3e3bf693a18ef3..f933b592f23f0e694abdb47de8fd2a752f2b59c2 100755 GIT binary patch delta 17383 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 delta 17141 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 diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.po b/locales/ru/LC_MESSAGES/lib.cli.args.po index e9a92b0ec1..43973cc689 100755 --- a/locales/ru/LC_MESSAGES/lib.cli.args.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args.po @@ -1,5 +1,6 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR ORGANIZATION +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" @@ -7,7 +8,7 @@ msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2022-11-20 01:34+0000\n" -"PO-Revision-Date: 2022-11-20 01:35+0000\n" +"PO-Revision-Date: 2023-04-11 15:06+0700\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -16,13 +17,12 @@ 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" -"Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 3.0.1\n" +"X-Generator: Poedit 3.2.2\n" #: lib/cli/args.py:193 lib/cli/args.py:203 lib/cli/args.py:211 #: lib/cli/args.py:221 msgid "Global Options" -msgstr "Общие настройки" +msgstr "Глобальные Настройки" #: lib/cli/args.py:194 msgid "" @@ -31,31 +31,32 @@ msgid "" "Selecting all GPUs here will force Faceswap into CPU mode.\n" "L|{}" msgstr "" -"R|Не использовать GPU для Faceswap. Выберите номер(а), которые соответствуют " -"тем GPU, которые вы не хотите использовать в Faceswap. При отключении всех " -"GPU Faceswap будет работать в режиме CPU.\n" +"R|Исключить GPU из использования Faceswap. Выберите номер (номера), " +"соответствующие любому GPU, который вы не хотите предоставлять Faceswap. " +"Если выбрать здесь все GPU, Faceswap перейдет в режим CPU.\n" "L|{}" #: lib/cli/args.py:204 msgid "" "Optionally overide the saved config with the path to a custom config file." msgstr "" -"Переназначить путь к файлу конфигурации пользовательским. (Необязательно)" +"Опционально переопределите сохраненную конфигурацию, указав путь к " +"пользовательскому файлу конфигурации." #: lib/cli/args.py:212 msgid "" "Log level. Stick with INFO or VERBOSE unless you need to file an error " "report. Be careful with TRACE as it will generate a lot of data" msgstr "" -"Уровень записи журнала. Придерживайтесь уровней INFO или VERBOSE, кроме " -"случаев когда вам нужно отправить отчёт об ошибке. Будьте осторожнее при " -"указании уровня TRACE, так как будет сгенерировано очень много данных" +"Уровень логирования. Придерживайтесь INFO или VERBOSE, если только вам не " +"нужно отправить отчет об ошибке. Будьте осторожны с TRACE, поскольку он " +"генерирует много данных" #: lib/cli/args.py:222 msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "" -"Путь для сохранения файла журнала. Оставьте пустым, чтобы сохранить в папке " -"с faceswap" +"Путь для хранения файла журнала. Оставьте пустым, чтобы хранить в папке " +"faceswap" #: lib/cli/args.py:320 lib/cli/args.py:329 lib/cli/args.py:337 #: lib/cli/args.py:386 lib/cli/args.py:677 lib/cli/args.py:686 @@ -68,27 +69,29 @@ msgid "" "wish to process or path to a video file. NB: This should be the source video/" "frames NOT the source faces." msgstr "" -"Входная папка либо видео файл. Папка с набором фотографий для обработки либо " -"видео файл. Примечание: должно указывать на исходное видео либо набор " -"извлеченных кадров, а НЕ уже извлеченных лица." +"Входная папка или видео. Либо каталог, содержащий файлы изображений, которые " +"вы хотите обработать, либо путь к видеофайлу. ПРИМЕЧАНИЕ: Это должно быть " +"исходное видео/кадры, а не исходные лица." #: lib/cli/args.py:330 msgid "Output directory. This is where the converted files will be saved." -msgstr "Папка для сохранения преобразованных файлов." +msgstr "Выходная папка. Здесь будут сохранены преобразованные файлы." #: lib/cli/args.py:338 msgid "" "Optional path to an alignments file. Leave blank if the alignments file is " "at the default location." -msgstr "Путь к файлу выравнивания. Оставьте пустым, для пути по умолчанию." +msgstr "" +"Необязательный путь к файлу выравниваний. Оставьте пустым, если файл " +"выравнивания находится в месте по умолчанию." #: lib/cli/args.py:361 msgid "" "Extract faces from image or video sources.\n" "Extraction plugins can be configured in the 'Settings' Menu" msgstr "" -"Извлечь лица из изображений или видео источников.\n" -"Плагины извлечения можно настроить в меню 'Настройки'" +"Извлечение лиц из источников изображений или видео.\n" +"Плагины извлечения можно настроить в меню \"Настройки\"" #: lib/cli/args.py:387 msgid "" @@ -96,9 +99,9 @@ msgid "" "multiple videos and/or folders of images you wish to extract from. The faces " "will be output to separate sub-folders in the output_dir." msgstr "" -"R|Если выбрано, то input_dir должна быть родительской папкой, содержащей " -"несколько видео и/или папок изображений, из которых вы хотите извлечь. Лица " -"будут выводиться в отдельные подпапки в output_dir." +"R|Если выбрано, то input_dir должен быть родительской папкой, содержащей " +"несколько видео и/или папок с изображениями, из которых вы хотите извлечь " +"изображение. Лица будут выведены в отдельные вложенные папки в output_dir." #: lib/cli/args.py:396 lib/cli/args.py:412 lib/cli/args.py:424 #: lib/cli/args.py:463 lib/cli/args.py:481 lib/cli/args.py:493 @@ -119,17 +122,17 @@ msgid "" "fewer false positives than other GPU detectors, but is a lot more resource " "intensive." msgstr "" -"R|Тип детектора. Некоторые могут быть настроенны через '/config/extract.ini' " -"либо 'Settings > Configure Extract 'Plugins':\n" -"L|cv2-dnn: Работает только на CPU, наименее надежный и наименее требователен " -"к ресурсам. Используйте если для вас очень важна скорость, а также не " -"использовать GPU .\n" -"L|mtcnn: Хороший детектор. Быстрый на CPU, ещё быстрее на GPU. Использует " -"меньше ресурсов, нежели другие GPU детекторы, но может производить больше " -"ложных положительных детектирований.\n" -"L|s3fd: Лучший детектор. Медленный на CPU, быстре на GPU. Может " -"детектировать лицо в большем кол-ве ситуация и меньшим кол-вом ошибок, чем " -"другие GPU, но значительно более требователен к ресурсам." +"R|Детектор для использования. Некоторые из них имеют настраиваемые параметры " +"в '/config/extract.ini' или 'Settings > Configure Extract 'Plugins':\n" +"L|cv2-dnn: Экстрактор только для процессора, который является наименее " +"надежным и наименее ресурсоемким. Используйте его, если не используется GPU " +"и важно время.\n" +"L|mtcnn: Хороший детектор. Быстрый на CPU, еще быстрее на GPU. Использует " +"меньше ресурсов, чем другие детекторы на GPU, но часто может давать больше " +"ложных срабатываний.\n" +"L|s3fd: Лучший детектор. Медленный на CPU, более быстрый на GPU. Может " +"обнаружить больше лиц и меньше ложных срабатываний, чем другие детекторы на " +"GPU, но требует гораздо больше ресурсов." #: lib/cli/args.py:413 msgid "" @@ -138,10 +141,10 @@ msgid "" "but less accurate. Only use this if not using a GPU and time is important.\n" "L|fan: Best aligner. Fast on GPU, slow on CPU." msgstr "" -"R|Выравнивание лица.\n" -"L|cv2-dnn: Детектор меток лица, только для CPU. Быстрый, не требователен к " -"ресурсам, но менее точный. Используйте только если вам необходимо не " -"использовать GPU.\n" +"R|Выравниватель для использования.\n" +"L|cv2-dnn: Детектор ориентиров только для процессора. Быстрее, менее " +"ресурсоемкий, но менее точный. Используйте его, только если не используется " +"GPU и важно время.\n" "L|fan: Лучший выравниватель. Быстрый на GPU, медленный на CPU." #: lib/cli/args.py:425 @@ -178,37 +181,38 @@ msgid "" "forehead.\n" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" msgstr "" -"R|Создание доп. масок. Генерация масок требует дополнительной памяти GPU. Вы " -"можете выбрать none, одну, или несколько масок, но процес извлечение может " -"занять больше времени в зависимости от выбора. Прим.: Маски Extended и " -"Components (на основе меток лица) всегда создаются автоматически при " -"извлечении лиц.\n" +"R|Дополнительный маскер(ы) для использования. Все маски, созданные здесь, " +"будут занимать оперативную память GPU. Вы можете выбрать ни одной, одну или " +"несколько масок, но извлечение может занять больше времени, чем больше масок " +"вы выберете. Примечание: Расширенные маски и маски компонентов (на основе " +"ориентиров) генерируются автоматически при извлечении.\n" "L|bisenet-fp: Относительно легкая маска на основе NN, которая обеспечивает " "более точный контроль над маскируемой областью, включая полное маскирование " "головы (настраивается в настройках маски).\n" -"L|custom: Пустая маска, которая заполняет область маски всеми единицами или " -"нулями (настраивается в настройках). Это требуется только в том случае, если " -"вы намерены вручную редактировать пользовательские маски в ручном " -"инструменте. Эта маска не использует графический процессор, поэтому не будет " -"использовать дополнительную видеопамять..\n" -"L|vgg-clear: Маска предназначена для умной сегментации преимущественно " -"фронтальных лиц без препятствий. Фотографии в профиль могут быть обработаны " -"посредственно.\n" -"L|vgg-obstructed: Маска предназначена для умной сегментации преимущественно " -"фронтальных лиц. Эта маска была обучена распознавать некоторые препятствия, " -"такие как руки и очки. Фотографии в профиль могут быть обработаны " -"посредственно.\n" -"L|unet-dfl: Маска предназначена для умной сегментации преимущественно " -"фронтальных лиц. Маска была обучена силами участников сообщества и нуждается " -"в тестировании. Фотографии в профиль могут быть обработаны посредственно.\n" -"Следующие маски создаются автоматически:\n" -"L|components: Маска предназначена для сегментации лица на основе ориентиров " -"лица. Маска создается путем построения выпуклого полигона вокруг внешних " -"ориентиров лица.\n" -"L|extended: Маска предназначена для сегментации лица на основе ориентиров " -"лица. Маска создается путем построения выпуклого полигона вокруг внешних " -"ориентиров лица и расширяется вверх на лоб.\n" -"(пример: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" +"L|custom: Фиктивная маска, которая заполняет область маски всеми 1 или 0 " +"(настраивается в настройках). Она необходима только в том случае, если вы " +"собираетесь вручную редактировать пользовательские маски в ручном " +"инструменте. Эта маска не задействует GPU, поэтому не будет использовать " +"дополнительную память VRAM.\n" +"L|vgg-clear: Маска предназначена для интеллектуальной сегментации " +"преимущественно фронтальных лиц без препятствий. Профильные лица и " +"препятствия могут привести к снижению производительности.\n" +"L|vgg-obstructed: Маска, разработанная для интеллектуальной сегментации " +"преимущественно фронтальных лиц. Модель маски была специально обучена " +"распознавать некоторые препятствия на лице (руки и очки). Лица в профиль " +"могут иметь низкую производительность.\n" +"L|unet-dfl: Маска, разработанная для интеллектуальной сегментации " +"преимущественно фронтальных лиц. Модель маски была обучена членами " +"сообщества и для дальнейшего описания нуждается в тестировании. Профильные " +"лица могут привести к низкой производительности.\n" +"Автоматически сгенерированные маски выглядят следующим образом:\n" +"L|components: Маска, разработанная для сегментации лица на основе " +"расположения ориентиров. Для создания маски вокруг внешних ориентиров " +"строится выпуклая оболочка.\n" +"L|extended: Маска, предназначенная для сегментации лица на основе " +"расположения ориентиров. Выпуклый корпус строится вокруг внешних ориентиров, " +"и маска расширяется вверх на лоб.\n" +"(например: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" #: lib/cli/args.py:464 msgid "" @@ -222,14 +226,15 @@ msgid "" "L|hist: Equalize the histograms on the RGB channels.\n" "L|mean: Normalize the face colors to the mean." msgstr "" -"R|Нормализация может помочь выравниванию лиц при сложных условиях освещения, " -"ценой снижения скорости. Различные методы дают разные результаты в " -"зависимости от набора лиц. Прим.: Не влияет на вывод лица, только на " -"выравнивание.\n" -"L|none: Не производить нормализацию картинки лица.\n" -"L|clahe: Производить нормализацию методом CLAHE.\n" -"L|hist: Выравнивание гистограммы каналов RGB каналов.\n" -"L|mean: Усреднение цветов лица." +"R|Проведение нормализации может помочь выравнивателю лучше выравнивать лица " +"со сложными условиями освещения при затратах на скорость извлечения. " +"Различные методы дают разные результаты на разных наборах. NB: Это не влияет " +"на выходное лицо, только на вход выравнивателя.\n" +"L|none: Не выполнять нормализацию лица.\n" +"L|clahe: Выполнить для лица адаптивную гистограммную эквализацию с " +"ограничением контраста.\n" +"L|hist: Уравнять гистограммы в каналах RGB.\n" +"L|mean: Нормализовать цвета лица к среднему значению." #: lib/cli/args.py:482 msgid "" @@ -240,11 +245,13 @@ msgid "" "times the face is re-fed into the aligner, the less micro-jitter should " "occur but the longer extraction will take." msgstr "" -"Кол-во проходов выравнивания после обнаружения лица. Каждый раз при " -"повторном выравнивании рамка лица немного корректируется. Окончательные " -"ориентиры затем усредняются. Помогает устранить «микроджиттер», но за счет " -"замедления скорости извлечения. Чем больше проходов выравнивания, тем меньше " -"микродрожание, но тем дольше идет извлечение." +"Количество повторных подач обнаруженной области лица в выравниватель. При " +"каждой повторной подаче лица в выравниватель ограничивающая рамка " +"корректируется на небольшую величину. Затем конечные ориентиры усредняются " +"по результатам каждой итерации. Это помогает устранить \"микро-дрожание\", " +"но ценой снижения скорости извлечения. Чем больше раз лицо повторно подается " +"в выравниватель, тем меньше микро-дрожание, но тем больше времени займет " +"извлечение." #: lib/cli/args.py:494 msgid "" @@ -252,9 +259,10 @@ msgid "" "produce better alignments for faces that are rotated beyond 45 degrees in " "the frame or are at extreme angles. Slows down extraction." msgstr "" -"Повторно подайте первоначально найденное выровненное лицо через элайнер. " -"Может помочь улучшить выравнивание лиц, которые повернуты в кадре более чем " -"на 45 градусов или находятся под экстремальными углами. Замедляет извлечение." +"Повторная подача первоначально найденной выровненной области лица через " +"выравниватель. Может помочь получить лучшее выравнивание для лиц, повернутых " +"в кадре более чем на 45 градусов или расположенных под экстремальными " +"углами. Замедляет извлечение." #: lib/cli/args.py:503 msgid "" @@ -263,18 +271,19 @@ msgid "" "increments of that size up to 360, or pass in a list of numbers to enumerate " "exactly what angles to check." msgstr "" -"Если лицо не найдено, поворачивает картинку, чтобы попытаться найти лицо. " -"Может найти больше лиц ценой скорости извлечения. Укажите число, чтобы " -"использовать приращения этого размера до 360, либо передайте список чисел, " -"чтобы точно указать, какие углы проверять." +"Если лицо не найдено, поворачивает изображения, чтобы попытаться найти лицо. " +"Может найти больше лиц ценой снижения скорости извлечения. Передайте одно " +"число, чтобы использовать приращения этого размера до 360, или передайте " +"список чисел, чтобы перечислить, какие именно углы нужно проверить." #: lib/cli/args.py:512 msgid "" "Obtain and store face identity encodings from VGGFace2. Slows down extract a " "little, but will save time if using 'sort by face'" msgstr "" -"Получите и сохраните кодировку идентификации лица от VGGFace2. Немного " -"замедляет извлечение, но сэкономит время при использовании «sort by face»" +"Получение и хранение кодировок идентификации лица из VGGFace2. Немного " +"замедляет извлечение, но экономит время при использовании \"сортировки по " +"лицам\"." #: lib/cli/args.py:522 lib/cli/args.py:532 lib/cli/args.py:544 #: lib/cli/args.py:557 lib/cli/args.py:798 lib/cli/args.py:812 @@ -287,8 +296,8 @@ msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" msgstr "" -"Отбрасывает лица ниже указанного размера. Длина указывается в пикселях по " -"диагонали. Установите в 0 для отключения" +"Отфильтровывает лица, обнаруженные ниже этого размера. Длина в пикселях по " +"диагонали ограничивающего поля. Установите значение 0, чтобы выключить" #: lib/cli/args.py:533 msgid "" @@ -297,10 +306,11 @@ msgid "" "angles and in different conditions. A folder containing the required images " "or multiple image files, space separated, can be selected." msgstr "" -"При желании отфильтруйте людей, которых вы не хотите извлекать, передав " -"изображения этих людей. Должно быть небольшое разнообразие снимков под " +"По желанию отфильтруйте людей, которых вы не хотите извлекать, передав " +"изображения этих людей. Должно быть небольшое разнообразие изображений под " "разными углами и в разных условиях. Можно выбрать папку, содержащую " -"требуемые изображения или несколько файлов изображений, разделенных пробелом." +"необходимые изображения, или несколько файлов изображений, разделенных " +"пробелами." #: lib/cli/args.py:545 msgid "" @@ -309,18 +319,18 @@ msgid "" "different conditions A folder containing the required images or multiple " "image files, space separated, can be selected." msgstr "" -"При желании выберите людей, которых вы хотите извлечь, передав изображения " -"этого человека. Должно быть небольшое количество изображений под разными " +"По желанию выберите людей, которых вы хотите извлечь, передав изображения " +"этого человека. Должно быть небольшое разнообразие изображений под разными " "углами и в разных условиях. Можно выбрать папку, содержащую необходимые " -"изображения или несколько файлов изображений, разделенных пробелом." +"изображения, или несколько файлов изображений, разделенных пробелами." #: lib/cli/args.py:558 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." msgstr "" -"Для использования с дополнительными файлами nfilter/filter. Порог " -"положительного распознавания лиц. Более высокие значения являются более " +"Для использования с дополнительными файлами nfilter/filter. Порог для " +"положительного распознавания лица. Более высокие значения являются более " "строгими." #: lib/cli/args.py:567 lib/cli/args.py:579 lib/cli/args.py:591 @@ -334,9 +344,9 @@ msgid "" "train supports your required size. This will only need to be changed for hi-" "res models." msgstr "" -"Размер извлекаемых лиц в пикселях. Убедитесь, что выбранная Вами модель " -"поддерживает такой входной размер. Стоит изменять только для моделей " -"высокого разрешения." +"Выходной размер извлеченных лиц. Убедитесь, что модель, которую вы " +"собираетесь тренировать, поддерживает требуемый размер. Это необходимо " +"изменить только для моделей высокого разрешения." #: lib/cli/args.py:580 msgid "" @@ -344,9 +354,9 @@ msgid "" "faces. For example a value of 1 will extract faces from every frame, a value " "of 10 will extract faces from every 10th frame." msgstr "" -"Обрабатывать каждые N кадров. Эта опция будет пропускать лица при " -"извлечении. Например, значение 1 будет искать лица в каждом кадре, а " -"значение 10 в каждом 10том кадре." +"Извлекать каждый 'n-й' кадр. Этот параметр пропускает кадры при извлечении " +"лиц. Например, значение 1 будет извлекать лица из каждого кадра, значение 10 " +"будет извлекать лица из каждого 10-го кадра." #: lib/cli/args.py:592 msgid "" @@ -357,15 +367,16 @@ msgid "" "script when writing the file because it might get corrupted. Set to 0 to " "turn off" msgstr "" -"Автоматически сохранять файл выравнивания после указанного кол-ва кадров. По " -"умолчанию файл выравнивания сохраняется только в конце процедуры извлечения. " -"Прим.: При извлечении в 2 прохода, файл выравниваний начнёт сохранение " -"только во время второго прохода. ВНИМАНИЕ: Не прерывайте выполнение во время " -"записи, так как это может повлечь порчу файла. Установите в 0 для выключения" +"Автоматическое сохранение файла выравнивания после заданного количества " +"кадров. По умолчанию файл выравнивания сохраняется только в конце процесса " +"извлечения. Примечание: Если извлечение выполняется в 2 прохода, то файл " +"выравнивания начнет сохраняться только во время второго прохода. " +"ПРЕДУПРЕЖДЕНИЕ: Не прерывайте работу скрипта при записи файла, так как он " +"может быть поврежден. Установите значение 0, чтобы отключить" #: lib/cli/args.py:604 msgid "Draw landmarks on the ouput faces for debugging purposes." -msgstr "Рисовать ландмарки на выходных лицах для нужд отладки." +msgstr "Нарисуйте ориентиры на выходящих гранях для отладки." #: lib/cli/args.py:610 lib/cli/args.py:619 lib/cli/args.py:627 #: lib/cli/args.py:634 lib/cli/args.py:852 lib/cli/args.py:863 @@ -379,34 +390,35 @@ msgid "" "process separately (one after the other) rather than all at the same time. " "Useful if VRAM is at a premium." msgstr "" -"Не проводить параллельное извлечение. Вместо одновременного запуска, каждая " -"стадия извлечения будет запущена отдельно (одна, за другой). Полезно при " -"нехватке VRAM." +"Не запускать извлечение параллельно. Каждая часть процесса извлечения будет " +"выполняться отдельно (одна за другой), а не одновременно. Полезно, если " +"память VRAM ограничена." #: lib/cli/args.py:620 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" msgstr "" -"Пропускать кадры, которые уже были извлечены и существуют в файле " +"Пропускает кадры, которые уже были извлечены и существуют в файле " "выравнивания" #: lib/cli/args.py:628 msgid "Skip frames that already have detected faces in the alignments file" -msgstr "Пропускать кадры, для которых в файле выравнивания есть найденные лица" +msgstr "" +"Пропустить кадры, в которых уже есть обнаруженные лица в файле выравнивания" #: lib/cli/args.py:635 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" -"Не сохранять найденные лица на носитель. Просто создать файл выравнивания" +"Не сохранять обнаруженные лица на диск. Просто создать файл выравнивания" #: lib/cli/args.py:657 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" msgstr "" -"Заменить оригиналы лица в исходном видео/фотографиях новыми.\n" -"Плагины конвертации могут быть настроены в меню 'Настройки'" +"Поменять исходные лица в исходном видео/изображении на ваши конечные лица.\n" +"Плагины конвертирования можно настроить в меню \"Настройки\"" #: lib/cli/args.py:678 msgid "" @@ -414,16 +426,16 @@ msgid "" "that the source frames were extracted from (for extracting the fps and " "audio)." msgstr "" -"Нужно указывать лишь при конвертации из набора картинок в видео. " -"Предоставьте исходное видео, из которого были извлечены кадры (для настройки " -"частоты кадров, а также аудио)." +"Требуется только при преобразовании из изображений в видео. Предоставьте " +"исходное видео, из которого были извлечены исходные кадры (для извлечения " +"кадров в секунду и звука)." #: lib/cli/args.py:687 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." msgstr "" -"Папка с моделью. Папка, содержащая обученную модель, которую вы хотите " +"Папка модели. Папка, содержащая обученную модель, которую вы хотите " "использовать для преобразования." #: lib/cli/args.py:697 @@ -446,24 +458,25 @@ msgid "" "satisfactory results.\n" "L|none: Don't perform color adjustment." msgstr "" -"R|Производит подгонку цветов в измененном лице. Некоторые из этих опций " -"имеют настройки в файле '/config/convert.ini' либо 'Настройки > Настроить " -"Плагины Конверсии':\n" -"L|avg-color: Подогнать среднее значение каждого цветового канала в " -"замененном лице так, чтобы оно равнялось среднему значению области маски " -"исходного изображения.\n" -"L|color-transfer: Переносит распределение цвета от источника к целевому " -"изображению с использованием среднего и стандартного отклонения цветового " -"пространства L * a * b *.\n" +"R|Производит корректировку цвета поменявшегося лица. Некоторые из этих " +"параметров настраиваются в '/config/convert.ini' или 'Настройки > Настроить " +"плагины конвертации':\n" +"L|avg-color: корректирует среднее значение каждого цветового канала в " +"реконструкции, чтобы оно было равно среднему значению маскированной области " +"в исходном изображении.\n" +"L|color-transfer: Переносит распределение цветов с исходного изображения на " +"целевое, используя среднее и стандартные отклонения цветового пространства " +"L*a*b*.\n" "L|manual-balance: Ручная настройка баланса изображения в различных цветовых " "пространствах. Лучше всего использовать с инструментом предварительного " "просмотра для установки правильных значений.\n" -"L|match-hist: Подгонять гистограмму каждого цветового канала нового лица, " -"гистограммой области маски исходного изображения\n" -"L|seamless-clone: Исп. фунцю cv2's незаметного переноса чтобы убрать " -"экстремальные градиенты на краях маски путём сглаживания цветов. Обычно не " -"дает удовлетворительных результатов.\n" -"L|none: Не производить подгонку цвета." +"L|match-hist: Настроить гистограмму каждого цветового канала в измененном " +"восстановлении так, чтобы она соответствовала гистограмме маскированной " +"области исходного изображения.\n" +"L|seamless-clone: Используйте функцию бесшовного клонирования cv2 для " +"удаления экстремальных градиентов на шве маски путем сглаживания цветов. " +"Обычно дает не очень удовлетворительные результаты.\n" +"L|none: Не выполнять коррекцию цвета." #: lib/cli/args.py:724 msgid "" @@ -501,38 +514,40 @@ msgid "" "L|predicted: If the 'Learn Mask' option was enabled during training, this " "will use the mask that was created by the trained model." msgstr "" -"R|Использовать маску. Прим.: Требуемая маска должна наличествовать в файле " -"выравнивания. Доп. маски можно добавить через Инструмент Создания Масок.\n" +"R|Маскер для использования. Примечание: Нужная маска должна существовать в " +"файле выравнивания. Вы можете добавить дополнительные маски с помощью " +"инструмента Mask Tool.\n" "L|none: Не использовать маску.\n" -"L|bisenet-fp-face: Относительно легкая маска на основе NN, которая " +"L|bisenet-fp_face: Относительно легкая маска на основе NN, которая " "обеспечивает более точный контроль над маскируемой областью (настраивается в " "настройках маски). Используйте эту версию bisenet-fp, если ваша модель " -"обучена с центрированием «face» или «legacy» центрирование.\n" -"L|bisenet-fp-head: Относительно легкая маска на основе NN, которая " +"обучена с центрированием 'face' или 'legacy'.\n" +"L|bisenet-fp_head: Относительно легкая маска на основе NN, которая " "обеспечивает более точный контроль над маскируемой областью (настраивается в " "настройках маски). Используйте эту версию bisenet-fp, если ваша модель " -"обучена с центрированием «head».\n" -"L| components: маска, предназначенная для сегментации лица на основе " -"найденных ориентиров. Маска создается построением выпуклого многоугольника " -"вокруг внешних ориентиров лица.\n" -"L|custom_face: Маска, созданная пользователем, по центру лица.\n" -"L|custom_head: Маска, созданная пользователем, по центру головы.\n" -"L| extended: маска, предназначенная для сегментации лица на основе " -"расположения ориентиров. Маска создается построением выпуклого " -"многоугольника вокруг внешних ориентиров лица и продолжается вверх на лоб.\n" -"L| vgg-clear: маска, предназначенная для умной сегментации преимущественно " -"фронтальных лиц без препятствий. Лица в профиль и препятствия могут привести " -"к некачественным результатам.\n" -"L| vgg-obstructed: маска, предназначенная для умной сегментации " -"преимущественно фронтальных лиц. Модель маски специально обучена " -"распознавать некоторые лицевые препятствия (руки и очки). Лица в профиль " -"могут привести к некачественным результатам..\n" -"L| unet-dfl: маска, предназначенная для умной сегментации преимущественно " -"фронтальных лиц. Модель маски была обучена членами сообщества и потребует " -"тестирования для дальнейшего описания. Лица в профиль могут привести к " -"некачественным результатам..\n" -"L| predicted: Если во время обучения была включена опция «Learn Mask», будет " -"использоваться маска, созданная обученной моделью." +"обучена с центрированием по \"голове\".\n" +"L|custom_face: Пользовательская маска, созданная пользователем и " +"центрированная по лицу.\n" +"L|custom_head: Созданная пользователем маска, центрированная по голове.\n" +"L|components: Маска, разработанная для сегментации лица на основе " +"расположения ориентиров. Для создания маски вокруг внешних ориентиров " +"строится выпуклая оболочка.\n" +"L|extended: Маска, предназначенная для сегментации лица на основе " +"расположения ориентиров. Выпуклый корпус строится вокруг внешних ориентиров, " +"и маска расширяется вверх на лоб.\n" +"L|vgg-clear: Маска предназначена для интеллектуальной сегментации " +"преимущественно фронтальных лиц без препятствий. Профильные лица и " +"препятствия могут привести к снижению производительности.\n" +"L|vgg-obstructed: Маска, разработанная для интеллектуальной сегментации " +"преимущественно фронтальных лиц. Модель маски была специально обучена " +"распознавать некоторые препятствия на лице (руки и очки). Лица в профиль " +"могут иметь низкую производительность.\n" +"L|unet-dfl: Маска, разработанная для интеллектуальной сегментации " +"преимущественно фронтальных лиц. Модель маски была обучена членами " +"сообщества и для дальнейшего описания нуждается в тестировании. Профильные " +"лица могут привести к низкой производительности.\n" +"L|predicted: Если во время обучения была включена опция 'Изучить Маску', то " +"будет использоваться маска, созданная обученной моделью." #: lib/cli/args.py:762 msgid "" @@ -548,21 +563,21 @@ msgid "" "L|pillow: [images] Slower than opencv, but has more options and supports " "more formats." msgstr "" -"R|Тип плагина для вывода конвертированных изображений. Записывающие плагины " -"можно настроить в '/config/convert.ini' либо 'Настройки > Настроить Плагины " -"Конверсии:'\n" -"L|ffmpeg: [видео] Записывает результат конверсии сразу в видео файл. Если " -"входом является серий изображений, то нужно также указать параметр '-ref' (--" +"R|Плагин, который нужно использовать для вывода преобразованных изображений. " +"Записи настраиваются в '/config/convert.ini' или 'Настройки > Настроить " +"плагины конвертации:'\n" +"L|ffmpeg: [видео] Записывает конвертацию прямо в видео. Если на вход " +"подается серия изображений, необходимо установить параметр '-ref' (--" "reference-video).\n" "L|gif: [анимированное изображение] Создает анимированный gif.\n" -"L|opencv: [изображения] Наибыстрейший способ записи, но с меньшим кол-вом " -"опций и форматов вывода.\n" -"L|pillow: [изображения] Более медленный, чем opencv, но имеет больше опций и " +"L|opencv: [изображения] Самый быстрый редактор изображений, но имеет меньше " +"опций и форматов, чем другие плагины.\n" +"L|pillow: [изображения] Медленнее, чем opencv, но имеет больше опций и " "поддерживает больше форматов." #: lib/cli/args.py:781 lib/cli/args.py:788 lib/cli/args.py:882 msgid "Frame Processing" -msgstr "Обработка кадров" +msgstr "Обработка лиц" #: lib/cli/args.py:782 #, python-format @@ -570,8 +585,8 @@ msgid "" "Scale the final output frames by this amount. 100%% will output the frames " "at source dimensions. 50%% at half size 200%% at double size" msgstr "" -"Масштабировать оконечные кадры до указанного процента. 100%% будет выводить " -"кадры в исходном размере. 50%% половина от размера, а 200%% в удвоенном " +"Масштабирование конечных выходных кадров на эту величину. 100%% выводит " +"кадры в исходном размере. 50%% при половинном размере 200%% при двойном " "размере" #: lib/cli/args.py:789 @@ -581,10 +596,10 @@ msgid "" "will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " "converting from images, then the filenames must end with the frame-number!" msgstr "" -"Диапазон кадров к которым применять перенос, например, для кадров от 10 до " -"50, и 90 до 100 укажите: --frame-ranges 10-50 90-100. Кадры попадающие вне " -"выбранного диапазона будут отброшены если не указано '-k' (--keep-" -"unchanged). Прим.: Если при конверсии используются изображения, то имена " +"Диапазоны кадров для применения переноса, например, для кадров с 10 по 50 и " +"с 90 по 100 используйте --frame-ranges 10-50 90-100. Кадры, выходящие за " +"пределы выбранного диапазона, будут отброшены, если не выбрана опция '-k' (--" +"keep-unchanged). Примечание: Если вы конвертируете из изображений, то имена " "файлов должны заканчиваться номером кадра!" #: lib/cli/args.py:799 @@ -596,12 +611,12 @@ msgid "" "converted. Leaving this blank will convert all faces that exist within the " "alignments file." msgstr "" -"Если вы не вычистили ваш файл выравниваний, то вы можете отфильтровать лица " -"указав здесь папку, которая содержит лица извлеченные из входных файлов/" -"видео. Если эта папка указана, то, только лица, которые существуют в файле " -"выравниваний и ТАКЖЕ существуют в указанной папке будут сконвертированы. " -"Если оставить это поле пустым, то все лица, которые существуют в файле " -"выравниваний будут сконвертированы." +"Если вы не очистили свой файл выравнивания, то вы можете отфильтровать лица, " +"определив здесь папку, содержащую лица, извлеченные из ваших входных файлов/" +"видео. Если эта папка определена, то будут преобразованы только те лица, " +"которые существуют в вашем файле выравнивания, а также в указанной папке. " +"Если оставить этот параметр пустым, будут преобразованы все лица, " +"существующие в файле выравнивания." #: lib/cli/args.py:813 msgid "" @@ -611,11 +626,11 @@ msgid "" "will significantly decrease extraction speed and its accuracy cannot be " "guaranteed." msgstr "" -"Дополнительно вы можете отфильтровать лица людей, которых вы не хотите " -"обрабатывать указав изображение этого человека. На изображении должен быть " -"фронтальный портрет одного человека . Можно указать несколько файлов через " -"пробел. Прим.: Фильтрация лиц существенно снижает скорость извлечения, при " -"этом точность не гарантируется." +"По желанию отфильтровать людей, которых вы не хотите обрабатывать, передав " +"изображение этого человека. Это должен быть фронтальный портрет с " +"изображением одного человека. Можно добавить несколько изображений, " +"разделенных пробелами. Примечание: Использование фильтра лиц значительно " +"снизит скорость извлечения, а его точность не гарантируется." #: lib/cli/args.py:826 msgid "" @@ -625,11 +640,11 @@ msgid "" "significantly decrease extraction speed and its accuracy cannot be " "guaranteed." msgstr "" -"Дополнительно вы можете выбрать людей, которых вы хотели бы включить в " -"обработку путем указания изображения этого человека. Должен быть фронтальный " -"портрет с лишь одним человеком на картинке. Можно выбрать несколько " -"изображений через пробел. Прим.: Использование фильтра существенно замедлит " -"скорость извлечения. Также точность не гарантируется." +"По желанию выберите людей, которых вы хотите обработать, передав изображение " +"этого человека. Это должен быть фронтальный портрет с изображением одного " +"человека. Можно добавить несколько изображений, разделенных пробелами. " +"Примечание: Использование фильтра лиц значительно снизит скорость " +"извлечения, а его точность не гарантируется." #: lib/cli/args.py:840 msgid "" @@ -638,9 +653,10 @@ msgid "" "significantly decrease extraction speed and its accuracy cannot be " "guaranteed." msgstr "" -"Только при использовании файлов nfilter/filter. Порог для распознавания " -"лица. Чем ниже значения, тем строже. Прим.: Использование фильтра лиц " -"существенно замедлит скорость извлечения. Также точность не гарантируется." +"Для использования с дополнительными файлами nfilter/filter. Порог для " +"положительного распознавания лиц. Более низкие значения являются более " +"строгими. Примечание: Использование фильтра лиц значительно снизит скорость " +"извлечения, а его точность не гарантируется." #: lib/cli/args.py:853 msgid "" @@ -651,21 +667,23 @@ msgid "" "this to, it will never attempt to use more processes than are available on " "your system. If singleprocess is enabled this setting will be ignored." msgstr "" -"Максимальное количество параллельных процессов для выполнения " -"преобразования. Преобразование изображений требует большого объема системной " -"памяти, поэтому возможна ее нехватка, если у вас много процессов и не " -"хватает памяти для их всех. Установка этого значения на 0 будет использовать " -"максимально доступное значение. Независимо от ваших установок, никогда не " -"будет использоваться больше процессов, чем доступно в вашей системе. Если " -"включен одиночный процесс, этот параметр будет проигнорирован." +"Максимальное количество параллельных процессов для выполнения конвертации. " +"Конвертирование изображений занимает много системной оперативной памяти, " +"поэтому может закончиться память, если у вас много процессов и недостаточно " +"оперативной памяти для их размещения. Если установить значение 0, будет " +"использован максимум доступной памяти. Независимо от того, какое значение вы " +"установите, программа никогда не будет пытаться использовать больше " +"процессов, чем доступно в вашей системе. Если включена однопоточная " +"обработка, этот параметр будет проигнорирован." #: lib/cli/args.py:864 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" msgstr "" -"[СОВМЕСТИМОСТЬ] Это нужно выбирать только в том случае, если загружается " -"устаревшая модель или если в папке сохранения есть несколько моделей" +"[ОТБРОШЕН] Этот параметр необходимо выбрать только в том случае, если " +"загружается устаревшая модель или если в папке моделей имеется несколько " +"моделей" #: lib/cli/args.py:872 msgid "" @@ -675,28 +693,30 @@ msgid "" "inferior extraction pipeline and will lead to substandard results. If an " "alignments file is found, this option will be ignored." msgstr "" -"Включить преобразование на лету. НЕ рекомендуется. Вам стоит создать чистый " -"файл выравнивания для вашего целевого видео. Однако, если вы хотите, вы " -"можете сгенерировать выравнивания на лету, включив эту опцию. Это приведет к " -"использованию улучшенного конвейера экстракции и некачественных результатов. " -"Если файл выравниваний найден, этот параметр будет проигнорирован." +"Включить преобразование \"на лету\". НЕ рекомендуется. Вы должны " +"сгенерировать чистый файл выравнивания для конечного видео. Однако при " +"желании вы можете генерировать выравнивания \"на лету\", включив эту опцию. " +"При этом будет использоваться некачественный конвейер извлечения, что " +"приведет к некачественным результатам. Если файл выравнивания найден, этот " +"параметр будет проигнорирован." #: lib/cli/args.py:883 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." msgstr "" -"При использовании с --frame-range кадры не попавшие в диапазон выводятся " -"неизменными, вместо их пропуска." +"При использовании с --frame-ranges выводит неизмененные кадры, которые не " +"были обработаны, вместо того, чтобы отбрасывать их." #: lib/cli/args.py:891 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" -"Поменять модели местами. Вместо преобразования из A -> B, преобразует B -> A" +"Поменять модель местами. Вместо преобразования из A -> B, преобразуется B -> " +"A" #: lib/cli/args.py:897 msgid "Disable multiprocessing. Slower but less resource intensive." -msgstr "Отключить многопроцессорность. Медленнее, но менее ресурсоемко." +msgstr "Отключите многопоточную обработку. Медленнее, но менее ресурсоемко." #: lib/cli/args.py:913 msgid "" @@ -704,10 +724,9 @@ msgid "" "Training models can take a long time. Anything from 24hrs to over a week\n" "Model plugins can be configured in the 'Settings' Menu" msgstr "" -"Начать обучение модели используя наборы лиц: (A) - исходное лицо и (B) - " -"новое лицо.\n" -"Обучение моделей может занять долгое время: от 24 часов до недели\n" -"Каждую модель можно отдельно настроить в меню «Настройки»" +"Обучить модель на извлеченных оригинальных (A) и подмененных (B) лицах.\n" +"Обучение моделей может занять много времени. От 24 часов до недели.\n" +"Плагины для моделей можно настроить в меню \"Настройки\"" #: lib/cli/args.py:932 lib/cli/args.py:941 msgid "faces" @@ -719,8 +738,8 @@ msgid "" "the original face, i.e. the face that you want to remove and replace with " "face B." msgstr "" -"Входная папка. Папка содержащая изображения для тренировки лица A. Это " -"исходное лицо т.е. лицо, которое вы хотите убрать, заменив лицом B." +"Входная папка. Папка, содержащая обучающие изображения для лица A. Это " +"исходное лицо, т.е. лицо, которое вы хотите удалить и заменить лицом B." #: lib/cli/args.py:942 msgid "" @@ -728,8 +747,8 @@ msgid "" "the swap face, i.e. the face that you want to place onto the head of person " "A." msgstr "" -"Входная папка. Папка содержащая изображения для тренировки лица B. Это новое " -"лицо т.е. лицо, которое вы хотите поместить на голову человека A." +"Входная папка. Папка, содержащая обучающие изображения для лица B. Это " +"подменное лицо, т.е. лицо, которое вы хотите поместить на голову человека A." #: lib/cli/args.py:950 lib/cli/args.py:962 lib/cli/args.py:978 #: lib/cli/args.py:1003 lib/cli/args.py:1013 @@ -744,11 +763,11 @@ msgid "" "created). If continuing to train an existing model, specify the location of " "the existing model." msgstr "" -"Папка сохранений модели. Здесь сохраняется прогресс тренировки. Следует " -"всегда создавать новую папку для новых моделей. При начале тренировки новой " -"модели, выберите пустую либо несуществующую папку (во втором случае она " -"будет создана). Если вы хотите продолжить тренировку, выберите папку с уже " -"существующими сохранениями." +"Папка модели. Здесь будут храниться данные для обучения. Для новых моделей " +"всегда следует указывать новую папку. Если вы начинаете новую модель, " +"выберите либо пустую папку, либо несуществующую папку (которая будет " +"создана). Если вы продолжаете обучение существующей модели, укажите " +"местоположение существующей модели." #: lib/cli/args.py:963 msgid "" @@ -763,16 +782,16 @@ msgid "" "NB: Weights can only be loaded from models of the same plugin as you intend " "to train." msgstr "" -"R|Загрузите веса из уже существующей модели во вновь созданную модель. Для " -"большинства моделей это загрузит веса из кодировщика данной модели в " -"кодировщик вновь созданной модели. Некоторые плагины могут иметь " -"определенные параметры конфигурации, позволяющие загружать веса из других " -"слоев. Вес будет загружен только при создании новой модели. Этот параметр " -"будет проигнорирован, если вы возобновите работу с существующей моделью. Как " -"правило, вы также захотите «заморозить вес», пока остальная часть вашей " -"модели догонит ваш кодировщик.\n" -"NB: Вес можно загружать только из моделей того же плагина, который вы " -"собираетесь тренировать." +"R|Загрузить веса из уже существующей модели во вновь созданную модель. Для " +"большинства моделей это означает загрузку весов из кодировщика данной модели " +"в кодировщик вновь создаваемой модели. Некоторые плагины могут иметь " +"специальные параметры конфигурации, позволяющие загружать веса из других " +"слоев. Веса будут загружаться только при создании новой модели. Эта опция " +"будет проигнорирована, если вы возобновляете существующую модель. Обычно " +"также требуется \"заморозить\" веса, пока остальная часть модели догоняет " +"кодировщик.\n" +"Примечание: Веса могут быть загружены только из моделей того же плагина, " +"который вы собираетесь обучать." #: lib/cli/args.py:979 msgid "" @@ -796,29 +815,28 @@ msgid "" "will require a GPU with a fair amount of VRAM). Good for details, but more " "susceptible to color differences." msgstr "" -"R|Выберите тренера для использования. Тренеры могут быть настроенны через " -"меню Настройки либо в папке config.\n" -"L|original: Оригинальная модель созданная /u/deepfakes.\n" -"L|dfaker: модель с 64px вход/128px выходом от dfaker. Включите 'warp-to-" -"landmarks' для полного соответствия методу dfaker.\n" -"L|dfl-h128: 128px вход/выход модель от deepfacelab\n" -"L|dfl-sae: Адаптивная модель от deepfacelab\n" -"L|dlight: Легковесная модель высокого разрешения. Один из вариантов DFaker.\n" -"L|iae: Модель использующая промежуточные слои, для достижения лучшей " -"детализции\n" -"L|lightweight: Легковесная модель для младшей линейки видеокарт. Не ожидайте " -"хороших результатов. Может тренировать на картах с 1.6Гб памяти при размере " -"серии 8.\n" -"L|realface: Модель повышенной детализации, с двумя сложносоставными слоями, " -"базированная на DFaker, с настраиваемым разрешением входа/выхода. " -"Автоэнкодеры не сбалансированы, поэтому свапы B>A не дадут хорошего " -"качества. andenixa и другие. Очень настраиваемая.\n" -"L|unbalanced: Модель 128px вход/выход от andenixa. Автоэнкодеры не " -"сбалансированы, поэтому свапы B>A не будут очень хорошими. Очень " -"настраеваемая.\n" -"L|villain: Модель 128px вход/выход от villainguy. Очень требовательна к " -"ресурсам (Вам потребуется GPU с хорошим количеством видеопамяти). Хороша для " -"деталей, но подвержена к неправильной передаче цвета." +"R|Выберите, какой тренажер использовать. Тренажеры можно настроить в меню " +"\"Настройки\" или в папке config.\n" +"L|original: Оригинальная модель, созданная /u/deepfakes.\n" +"L|dfaker: модель 64px вход/ 128px выход от dfaker. Включите 'warp-to-" +"landmarks' для полного метода dfaker.\n" +"L|dfl-h128: модель 128px вход/выход от deepfacelab\n" +"L|dfl-sae: Адаптируемая модель от deepfacelab\n" +"L|dlight: Легкий вариант DFaker с высоким разрешением.\n" +"L|iae: Модель, использующая промежуточные слои для получения лучших " +"деталей.\n" +"L|lightweight: Облегченная модель для карт низкого класса. Не ожидайте " +"высоких результатов. Может обучаться на 1,6 ГБ при размере пачки 8.\n" +"L|realface: Модель с высокой детализацией и двойной плотностью, основанная " +"на DFaker, с настраиваемым разрешением входа/выхода. Автоэнкодеры " +"несбалансированы, поэтому замены B>A не будут работать так хорошо. Автор " +"andenixa и др. Очень настраиваемая.\n" +"L|unbalanced: модель 128px вход/выход от andenixa. Автокодировщики " +"несбалансированы, поэтому замены B>A не будут работать так хорошо. Очень " +"настраиваемая.\n" +"L|villain: модель 128px вход/выход от villainguy. Очень требовательна к " +"ресурсам (вам потребуется GPU с достаточным количеством VRAM). Хороша для " +"детализации, но более восприимчива к цветовым различиям." #: lib/cli/args.py:1004 msgid "" @@ -827,10 +845,9 @@ msgid "" "that would be created by the chosen plugin and configuration settings is " "displayed." msgstr "" -"Выведите сводку модели и выйдите. Если предоставлена папка модели, " -"отображается сводка сохраненной модели. В противном случае отображается " -"сводная информация о модели, которая будет создана выбранным плагином, и " -"параметрами конфигурации." +"Вывести сводку модели и выйти. Если указана папка модели, то выводится " +"сводка сохраненной модели. В противном случае отображается сводка модели, " +"которая будет создана выбранным плагином и настройками конфигурации." #: lib/cli/args.py:1014 msgid "" @@ -840,11 +857,11 @@ msgid "" "encoder, but some models may have configuration options for freezing other " "layers." msgstr "" -"Зафиксируйте веса модели. Замораживание весов означает, что некоторые " -"параметры в модели больше не будут изучаться, но те, которые не заморожены, " -"продолжат обучение. Для большинства моделей это заморозит кодировщик, но " -"некоторые модели могут иметь параметры конфигурации для замораживания других " -"слоев." +"Заморозить веса модели. Замораживание весов означает, что некоторые " +"параметры в модели больше не будут продолжать обучение, но те, которые не " +"заморожены, будут продолжать обучение. Для большинства моделей это означает " +"замораживание кодера, но некоторые модели могут иметь опции конфигурации для " +"замораживания других слоев." #: lib/cli/args.py:1027 lib/cli/args.py:1039 lib/cli/args.py:1050 #: lib/cli/args.py:1061 lib/cli/args.py:1144 @@ -858,11 +875,11 @@ msgid "" "actual number of images within the model at any one time is double the " "number that you set here. Larger batches require more GPU RAM." msgstr "" -"Размер партии. Это количество изображений для каждой стороны, которые " -"обрабатываются моделью за одну итерацию. Примечание: Поскольку в модель " -"передается сразу две стороны за раз, реальное количество загружаемых " -"изображений в два раза больше этого числа. Увеличение размера партии требует " -"больше памяти GPU." +"Размер пачки. Это количество изображений, обрабатываемых моделью для каждой " +"стороны за итерацию. Примечание: Поскольку модель обрабатывает 2 стороны " +"одновременно, фактическое количество изображений в модели в любой момент " +"времени будет вдвое больше, чем заданное здесь. Большие партии требуют " +"больше оперативной памяти GPU." #: lib/cli/args.py:1040 msgid "" @@ -872,20 +889,21 @@ msgid "" "you want the model to stop automatically at a set number of iterations, you " "can set that value here." msgstr "" -"Кол-во итераций для тренировки. Используется только для автоматизирования. " -"Не существует \"правильного\" кол-ва итераций для любой выбранной модели. " -"Тренировку стоит завершать только когда вы довольны кадрами на превью. " -"Однако, если вы хотите, чтобы тренировка прервалась после указанного кол-ва " -"итерация, вы можете ввести это здесь." +"Продолжительность обучения в итерациях. Этот параметр действительно " +"используется только для автоматизации. Не существует \"правильного\" " +"количества итераций, за которое следует обучить модель. Вы должны прекратить " +"обучение, когда будете удовлетворены предварительным просмотром. Однако если " +"вы хотите, чтобы модель автоматически останавливалась при определенном " +"количестве итераций, вы можете задать это значение здесь." #: lib/cli/args.py:1051 msgid "" "[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " "Mirrored Distrubution Strategy to train on multiple GPUs." msgstr "" -"[Устарело — вместо этого используйте ‘-D, --distribution-strategy’] " -"Используйте стратегию зеркального распространения Tensorflow для обучения на " -"нескольких графических процессорах." +"[Устарело - Используйте '-D, --distribution-strategy' вместо этого] " +"Используйте стратегию Tensorflow Mirrored Distrubution Strategy(Стратегия " +"Зеркального Распределения Tensorflow) для обучения на нескольких GPU." #: lib/cli/args.py:1062 msgid "" @@ -900,17 +918,15 @@ msgid "" "batches distributed to each GPU at each iteration." msgstr "" "R|Выберите стратегию распределения для использования.\n" -"L|default: использовать стратегию распространения Tensorflow по умолчанию.\n" -"L|central-storage: централизует переменные в ЦП, в то время как операции " -"выполняются на 1 или нескольких локальных графических процессорах. Это может " -"помочь сэкономить часть видеопамяти за счет некоторой скорости за счет " -"отказа от хранения переменных в графическом процессоре. Примечание. " -"Смешанная точность не поддерживается в конфигурациях с несколькими " -"графическими процессорами.\n" -"L|mirrored: поддерживает синхронное распределенное обучение на нескольких " -"локальных графических процессорах. Копия модели и все переменные загружаются " -"в каждый GPU, причем пакеты распределяются между каждым GPU на каждой " -"итерации." +"L|default: Использовать стратегию распространения Tensorflow по умолчанию.\n" +"L|central-storage: Централизует переменные на CPU, в то время как операции " +"выполняются на 1 или более локальных GPU. Это может помочь сэкономить " +"немного VRAM за счет некоторой скорости, поскольку переменные не хранятся на " +"GPU. Примечание: Mixed-Precision не поддерживается на многопроцессорных " +"установках.\n" +"L|mirrored: Поддерживает синхронное распределенное обучение на нескольких " +"локальных GPU. Копия модели и все переменные загружаются на каждый GPU с " +"распределением партий на каждый GPU на каждой итерации." #: lib/cli/args.py:1079 lib/cli/args.py:1089 msgid "Saving" @@ -918,15 +934,16 @@ msgstr "Сохранение" #: lib/cli/args.py:1080 msgid "Sets the number of iterations between each model save." -msgstr "Установка количества итераций между сохранениями модели." +msgstr "Устанавливает количество итераций между каждым сохранением модели." #: lib/cli/args.py:1090 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." msgstr "" -"Устанавливает кол-во итераций перед созданием резервной копии модели. " -"Установите в 0 для отключения." +"Устанавливает количество итераций между каждым сохранением модели. " +"Устанавливает количество итераций перед сохранением резервного снимка модели " +"в текущем состоянии. Установите значение 0 для выключения." #: lib/cli/args.py:1097 lib/cli/args.py:1108 lib/cli/args.py:1119 msgid "timelapse" @@ -940,10 +957,11 @@ msgid "" "creating the timelapse. You must also supply a --timelapse-output and a --" "timelapse-input-B parameter." msgstr "" -"Только при создании таймлапсов. Сохраняет предварительный просмотр выбранных " -"лиц в папку timelapse-output при каждом сохранении. Следует указать входную " -"папку лиц набора 'A' для использования при создании таймлапса. Вам также " -"нужно указать параметры--timelapse-output и --timelapse-input-B." +"Опционально для создания таймлапса. Timelapse будет сохранять изображение " +"выбранных лиц в папку timelapse-output на каждой итерации сохранения. Это " +"должна быть входная папка с лицами 'A', которые вы хотите использовать для " +"создания timelapse. Вы также должны указать параметры --timelapse-output и --" +"timelapse-input-B." #: lib/cli/args.py:1109 msgid "" @@ -953,11 +971,11 @@ msgid "" "creating the timelapse. You must also supply a --timelapse-output and a --" "timelapse-input-A parameter." msgstr "" -"Только при создании таймлапса. Таймлапс будет сохранять изображения " -"выбранных лиц в папке таймлапсов при каждой итерации сохранения. Это должна " -"быть папка для ввода лиц из набора 'B', для использования в создании " -"таймлапса. Вы также должны указать параметр --timelapse-output и --timelapse-" -"input-A." +"Опционально для создания таймлапса. Timelapse будет сохранять изображение " +"выбранных лиц в папку timelapse-output на каждой итерации сохранения. Это " +"должна быть входная папка с лицами 'B', которые вы хотите использовать для " +"создания timelapse. Вы также должны указать параметры --timelapse-output и --" +"timelapse-input-A." #: lib/cli/args.py:1120 msgid "" @@ -966,34 +984,35 @@ msgid "" "the input folders are supplied but no output folder, it will default to your " "model folder /timelapse/" msgstr "" -"Опционально, при создании таймлапса. Создаст картинку текущего таймлапса " -"выбранных лиц в папке timelapse-output при каждом сохранении модели. Если " -"указаны только входные папки, то по умолчанию вывод будет сохранен вместе с " -"моделью в подкаталог /timelapse/" +"Опционально для создания таймлапса. Timelapse будет сохранять изображение " +"выбранных лиц в папку timelapse-output на каждой итерации сохранения. Если " +"указаны входные папки, но нет выходной папки, то по умолчанию будет выбрана " +"папка модели /timelapse/" #: lib/cli/args.py:1129 lib/cli/args.py:1136 msgid "preview" -msgstr "предварительный просмотр" +msgstr "предпросмотр" #: lib/cli/args.py:1130 msgid "Show training preview output. in a separate window." -msgstr "Показывать предварительный просмотр в отдельном окне." +msgstr "Показать вывод предварительного просмотра тренировки в отдельном окне." #: lib/cli/args.py:1137 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." msgstr "" -"Записывает результат тренировки в файл. Файл будет сохранен в коренной папке " -"FaceSwap." +"Записывает результат обучения в файл. Изображение будет сохранено в корне " +"папки Faceswap." #: lib/cli/args.py:1145 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." msgstr "" -"Отключает журнал TensorBoard. Примечание: Отключение журналов означает, что " -"вы не сможете использовать графики или анализ сессии внутри GUI." +"Отключает ведение журналов TensorBoard. Примечание: Отключение ведения " +"журналов означает, что вы не сможете использовать график или анализ для этой " +"сессии в графическом интерфейсе." #: lib/cli/args.py:1152 lib/cli/args.py:1161 lib/cli/args.py:1170 #: lib/cli/args.py:1179 @@ -1006,9 +1025,9 @@ msgid "" "rather than randomly warping the face. This is the 'dfaker' way of doing " "warping." msgstr "" -"Вместо случайного искажения лица, деформирует лица в соответствии с " -"Ориентирами/Landmarks противоположного набора лиц. Этот способ используется " -"пакетом \"dfaker\"." +"Искажает обучаемые лица до близко подходящих ориентиров из противоположного " +"набора лиц вместо случайного искажения лица. Это способ выполнения искажения " +"от \"dfaker\" ." #: lib/cli/args.py:1162 msgid "" @@ -1016,10 +1035,9 @@ msgid "" "Sometimes it is desirable for this not to occur. Generally this should be " "left off except for during 'fit training'." msgstr "" -"Для повышения эффективности обучения, некоторые изображения случайным " -"образом переворачивается по горизонтали. Иногда желательно, чтобы этого не " -"происходило. Как правило, эту настройку не стоит трогать, за исключением " -"периода «финальной шлифовки»." +"Для эффективного обучения случайный набор изображений переворачивается по " +"горизонтали. Иногда желательно, чтобы этого не происходило. Как правило, это " +"не нужно делать, за исключением случаев \"тренировки подгонки\"." #: lib/cli/args.py:1171 msgid "" @@ -1027,9 +1045,10 @@ msgid "" "differences between the A and B sets, at an increased training time cost. " "Enable this option to disable color augmentation." msgstr "" -"Цветовая аугментация помогает модели быть менее чувствительной к разнице " -"цвета между наборами A and B ценой некоторого замедления скорости " -"тренировки. Включите эту опцию для отключения цветовой аугментации." +"Аугментация цвета помогает сделать модель менее восприимчивой к цветовым " +"различиям между наборами A и B, что влечет за собой увеличение затрат " +"времени на обучение. Включите этот параметр для отключения цветовой " +"аугментации." #: lib/cli/args.py:1180 msgid "" @@ -1038,30 +1057,11 @@ msgid "" "Think of it as 'fine-tuning'. Enabling this option from the beginning is " "likely to kill a model and lead to terrible results." msgstr "" -"Внесение случайных искажение является неотъемлемой частью обучения нейронной " -"сети. Эту опцию следует включать только в самом конце обучения, чтобы " -"попытаться выявить больше деталей. Думайте об этом как о «стадии шлифовки». " -"Включение этой опции с самого начала может убить модель и привести к ужасным " -"результатам." +"Искажение является неотъемлемой частью обучения нейронной сети. Эту опцию " +"следует включать только в самом конце обучения, чтобы попытаться получить " +"больше деталей. Считайте это \"тонкой настройкой\". Включение этой опции в " +"самом начале, скорее всего, погубит модель и приведет к ужасным результатам." #: lib/cli/args.py:1205 msgid "Output to Shell console instead of GUI console" -msgstr "Вывод в системную консоль вместо GUI" - -#~ msgid "" -#~ "DEPRECATED - This option will be removed in a future update. Path to " -#~ "alignments file for training set A. Defaults to /alignments.json " -#~ "if not provided." -#~ msgstr "" -#~ "УСТАРЕЛО - Эта настройка будет удалена в будущих обновлениях. Путь к " -#~ "файлу выравнивания для обучающего набора A. По умолчанию используется " -#~ " /alignments.json, если он не указан." - -#~ msgid "" -#~ "DEPRECATED - This option will be removed in a future update. Path to " -#~ "alignments file for training set B. Defaults to /alignments.json " -#~ "if not provided." -#~ msgstr "" -#~ "УСТАРЕЛО - Эта настройка будет удалена в будущих обновлениях. Путь к " -#~ "файлу выравнивания для обучающего набора B. По умолчанию используется " -#~ " /alignments.json, если он не указан." +msgstr "Вывод в консоль Shell вместо консоли GUI" diff --git a/locales/ru/LC_MESSAGES/tools.alignments.cli.mo b/locales/ru/LC_MESSAGES/tools.alignments.cli.mo new file mode 100644 index 0000000000000000000000000000000000000000..1c47a8cb014258a22d74e709ffd6c39cf6122b44 GIT binary patch literal 15272 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 Ht>}LNiJilj literal 0 HcmV?d00001 diff --git a/locales/ru/LC_MESSAGES/tools.alignments.cli.po b/locales/ru/LC_MESSAGES/tools.alignments.cli.po new file mode 100644 index 0000000000..43b88bf54c --- /dev/null +++ b/locales/ru/LC_MESSAGES/tools.alignments.cli.po @@ -0,0 +1,264 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-02-24 00:27+0000\n" +"PO-Revision-Date: 2023-04-11 15:11+0700\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"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" + +#: tools/alignments/cli.py:17 +msgid "" +"This command lets you perform various tasks pertaining to an alignments file." +msgstr "" +"Эта команда позволяет выполнять различные задачи, относящиеся к файлу " +"выравнивания." + +#: tools/alignments/cli.py:32 +msgid "" +"Alignments tool\n" +"This tool allows you to perform numerous actions on or using an alignments " +"file against its corresponding faceset/frame source." +msgstr "" +"Инструмент выравнивания\n" +"Этот инструмент позволяет выполнять многочисленные действия с файлом " +"выравнивания или с его использованием против соответствующего набора лиц/" +"кадров." + +#: tools/alignments/cli.py:44 +msgid " Must Pass in a frames folder/source video file (-fr)." +msgstr " Должен проходить в папке с кадрами/исходным видеофайлом (-fr)." + +#: tools/alignments/cli.py:45 +msgid " Must Pass in a faces folder (-fc)." +msgstr " Должен проходить в папке с лицами (-fc)." + +#: tools/alignments/cli.py:46 +msgid "" +" Must Pass in either a frames folder/source video file OR a faces folder (-" +"fr or -fc)." +msgstr "" +" Должно передаваться либо в папку с кадрами/исходным видеофайлом, либо в " +"папку с лицами (-fr или -fc)." + +#: tools/alignments/cli.py:48 +msgid "" +" Must Pass in a frames folder/source video file AND a faces folder (-fr and -" +"fc)." +msgstr "" +" Должно передаваться либо в папку с кадрами/исходным видеофайлом И в папку с " +"лицами (-fr и -fc)." + +#: tools/alignments/cli.py:50 +msgid " Use the output option (-o) to process results." +msgstr " Используйте опцию вывода (-o) для обработки результатов." + +#: tools/alignments/cli.py:58 tools/alignments/cli.py:97 +msgid "processing" +msgstr "обработка" + +#: tools/alignments/cli.py:60 +#, python-brace-format +msgid "" +"R|Choose which action you want to perform. NB: All actions require an " +"alignments file (-a) to be passed in.\n" +"L|'draw': Draw landmarks on frames in the selected folder/video. A subfolder " +"will be created within the frames folder to hold the output.{0}\n" +"L|'extract': Re-extract faces from the source frames/video based on " +"alignment data. This is a lot quicker than re-detecting faces. Can pass in " +"the '-een' (--extract-every-n) parameter to only extract every nth frame." +"{1}\n" +"L|'from-faces': Generate alignment file(s) from a folder of extracted faces. " +"if the folder of faces comes from multiple sources, then multiple alignments " +"files will be created. NB: for faces which have been extracted from folders " +"of source images, rather than a video, a single alignments file will be " +"created as there is no way for the process to know how many folders of " +"images were originally used. You do not need to provide an alignments file " +"path to run this job. {3}\n" +"L|'missing-alignments': Identify frames that do not exist in the alignments " +"file.{2}{0}\n" +"L|'missing-frames': Identify frames in the alignments file that do not " +"appear within the frames folder/video.{2}{0}\n" +"L|'multi-faces': Identify where multiple faces exist within the alignments " +"file.{2}{4}\n" +"L|'no-faces': Identify frames that exist within the alignment file but no " +"faces were detected.{2}{0}\n" +"L|'remove-faces': Remove deleted faces from an alignments file. The original " +"alignments file will be backed up.{3}\n" +"L|'rename' - Rename faces to correspond with their parent frame and position " +"index in the alignments file (i.e. how they are named after running extract)." +"{3}\n" +"L|'sort': Re-index the alignments from left to right. For alignments with " +"multiple faces this will ensure that the left-most face is at index 0.\n" +"L|'spatial': Perform spatial and temporal filtering to smooth alignments " +"(EXPERIMENTAL!)" +msgstr "" +"R|Выберите действие, которое вы хотите выполнить. Примечание: Все действия " +"требуют передачи файла выравнивания (-a).\n" +"L|'draw': Нарисовать ориентиры на кадрах в выбранной папке/видео. В папке " +"frames будет создана подпапка для хранения результатов.\n" +"L|'extract': Повторное извлечение лиц из исходных кадров/видео на основе " +"данных о выравнивании. Это намного быстрее, чем повторное обнаружение лиц. " +"Можно передать параметр '-een' (--extract-every-n), чтобы извлекать только " +"каждый n-й кадр.{1}\n" +"L|'from-faces': Создать файл(ы) выравнивания из папки с извлеченными лицами. " +"Если папка с лицами получена из нескольких источников, то будет создано " +"несколько файлов выравнивания. Примечание: для лиц, которые были извлечены " +"из папок с исходными изображениями, а не из видео, будет создан один файл " +"выравнивания, поскольку процесс не может знать, сколько папок с " +"изображениями было использовано изначально. Для выполнения этого задания не " +"нужно указывать путь к файлу выравнивания. {3}\n" +"L|'missing-alignments': Определить кадры, которых нет в файле выравнивания." +"{2}{0}\n" +"L|'missing-frames': Определить кадры в файле выравнивания, которые не " +"появляются в папке frames/video.{2}{0}\n" +"L|'multi-faces': Определить, где в файле выравнивания существует несколько " +"лиц.{2}{4}\n" +"L|'no-faces': Идентифицировать кадры, которые существуют в файле " +"выравнивания, но лица не были обнаружены.{2}{0}\n" +"L|'remove-faces': Удалить удаленные лица из файла выравнивания. Оригинальный " +"файл выравнивания будет сохранен.{3}\n" +"L|'rename' - Переименовать лица в соответствии с их родительским кадром и " +"индексом позиции в файле выравниваний (т.е. как они будут названы после " +"запуска extract).{3}\n" +"L|'sort': Переиндексирует выравнивания слева направо. Для выравниваний с " +"несколькими гранями это гарантирует, что самое левое лицо будет иметь индекс " +"0.\n" +"L|'spatial': Выполнить пространственную и временную фильтрацию для " +"сглаживания выравниваний (ЭКСПЕРИМЕНТАЛЬНО!)." + +#: tools/alignments/cli.py:99 +msgid "" +"R|How to output discovered items ('faces' and 'frames' only):\n" +"L|'console': Print the list of frames to the screen. (DEFAULT)\n" +"L|'file': Output the list of frames to a text file (stored within the source " +"directory).\n" +"L|'move': Move the discovered items to a sub-folder within the source " +"directory." +msgstr "" +"R|Как вывести обнаруженные элементы (только \"лица\" и \"кадры\"):\n" +"L|'console': Вывести список рамок на экран. (DEFAULT)\n" +"L|'file': Вывести список кадров в текстовый файл (хранящийся в исходном " +"каталоге).\n" +"L|'move': Переместить обнаруженные элементы в подпапку в исходном каталоге." + +#: tools/alignments/cli.py:110 tools/alignments/cli.py:123 +#: tools/alignments/cli.py:130 tools/alignments/cli.py:137 +msgid "data" +msgstr "данные" + +#: tools/alignments/cli.py:114 +msgid "" +"Full path to the alignments file to be processed. If you have input a " +"'frames_dir' and don't provide this option, the process will try to find the " +"alignments file at the default location. All jobs require an alignments file " +"with the exception of 'from-faces' when the alignments file will be " +"generated in the specified faces folder." +msgstr "" +"Полный путь к обрабатываемому файлу выравниваний. Если вы ввели 'frames_dir' " +"и не указали этот параметр, процесс попытается найти файл выравнивания в " +"месте по умолчанию. Все задания требуют файл выравнивания, за исключением " +"задания 'from-faces', когда файл выравнивания будет создан в указанной папке " +"с лицами." + +#: tools/alignments/cli.py:124 +msgid "Directory containing extracted faces." +msgstr "Папка, содержащая извлеченные лица." + +#: tools/alignments/cli.py:131 +msgid "Directory containing source frames that faces were extracted from." +msgstr "Папка, содержащая исходные кадры, из которых были извлечены лица." + +#: tools/alignments/cli.py:138 +msgid "" +"R|Run the aligmnents tool on multiple sources. The following jobs support " +"batch mode:\n" +"L|draw, extract, from-faces, missing-alignments, missing-frames, no-faces, " +"sort, spatial.\n" +"If batch mode is selected then the other options should be set as follows:\n" +"L|alignments_file: For 'sort' and 'spatial' this should point to the parent " +"folder containing the alignments files to be processed. For all other jobs " +"this option is ignored, and the alignments files must exist at their default " +"location relative to the original frames folder/video.\n" +"L|faces_dir: For 'from-faces' this should be a parent folder, containing sub-" +"folders of extracted faces from which to generate alignments files. For " +"'extract' this should be a parent folder where sub-folders will be created " +"for each extraction to be run. For all other jobs this option is ignored.\n" +"L|frames_dir: For 'draw', 'extract', 'missing-alignments', 'missing-frames' " +"and 'no-faces' this should be a parent folder containing video files or sub-" +"folders of images to perform the alignments job on. The alignments file " +"should exist at the default location. For all other jobs this option is " +"ignored." +msgstr "" +"R|Запуск инструмента выравнивания на нескольких источниках. Следующие " +"задания поддерживают пакетный режим:\n" +"L|draw, extract, from-faces, missing-alignments, missing-frames, no-faces, " +"sort, spatial.\n" +"Если выбран пакетный режим, то остальные опции должны быть установлены " +"следующим образом:\n" +"L|alignments_file: Для заданий 'sort' и 'spatial' этот параметр должен " +"указывать на родительскую папку, содержащую файлы выравниваний, которые " +"будут обрабатываться. Для всех остальных заданий этот параметр игнорируется, " +"и файлы выравнивания должны существовать в их расположении по умолчанию " +"относительно исходной папки кадров/видео.\n" +"L|faces_dir: Для 'from-faces' это должна быть родительская папка, содержащая " +"вложенные папки с извлеченными лицами, из которых будут сгенерированы файлы " +"выравнивания. Для 'extract' это должна быть родительская папка, в которой " +"будут создаваться вложенные папки для каждой выполняемой экстракции. Для " +"всех остальных заданий этот параметр игнорируется.\n" +"L|frames_dir: Для 'draw', 'extract', 'missing-alignments', 'missing-frames' " +"и 'no-faces' это должна быть родительская папка, содержащая видеофайлы или " +"вложенные папки изображений для выполнения задания выравнивания. Файл " +"выравнивания должен существовать в месте по умолчанию. Для всех остальных " +"заданий этот параметр игнорируется." + +#: tools/alignments/cli.py:164 tools/alignments/cli.py:175 +#: tools/alignments/cli.py:185 +msgid "extract" +msgstr "извлечение" + +#: tools/alignments/cli.py:165 +msgid "" +"[Extract only] Extract every 'nth' frame. This option will skip frames when " +"extracting faces. For example a value of 1 will extract faces from every " +"frame, a value of 10 will extract faces from every 10th frame." +msgstr "" +"[Только извлечение] Извлекать каждый \"n-й\" кадр. Этот параметр пропускает " +"кадры при извлечении лиц. Например, значение 1 будет извлекать лица из " +"каждого кадра, значение 10 будет извлекать лица из каждого 10-го кадра." + +#: tools/alignments/cli.py:176 +msgid "[Extract only] The output size of extracted faces." +msgstr "[Только извлечение] Выходной размер извлеченных лиц." + +#: tools/alignments/cli.py:186 +msgid "" +"[Extract only] Only extract faces that have been resized by this percent or " +"more to meet the specified extract size (`-sz`, `--size`). Useful for " +"excluding low-res images from a training set. Set to 0 to extract all faces. " +"Eg: For an extract size of 512px, A setting of 50 will only include faces " +"that have been resized from 256px or above. Setting to 100 will only extract " +"faces that have been resized from 512px or above. A setting of 200 will only " +"extract faces that have been downscaled from 1024px or above." +msgstr "" +"[Только извлечение] Извлекать только те лица, размер которых был изменен на " +"данный процент или более, чтобы соответствовать заданному размеру извлечения " +"(`-sz`, `--size`). Полезно для исключения изображений с низким разрешением " +"из обучающего набора. Установите значение 0, чтобы извлечь все лица. " +"Например: Для размера экстракта 512px, при установке значения 50 будут " +"извлечены только лица, размер которых был изменен с 256px или выше. При " +"значении 100 будут извлечены только лица, размер которых был изменен с 512px " +"или выше. При значении 200 будут извлечены только лица, уменьшенные с 1024px " +"или выше." diff --git a/locales/ru/LC_MESSAGES/tools.effmpeg.cli.mo b/locales/ru/LC_MESSAGES/tools.effmpeg.cli.mo new file mode 100644 index 0000000000000000000000000000000000000000..b14684eb32b24b0a77f3242d2dcf295b10e7a35f GIT binary patch literal 8647 zcmd5=TZ|-C89pcoj8_mZsL{iNG=%AJ-m`t}#$ALMRD@|JP^moF>T!uowAaTD@?z)HWKxzm?U}axiCehS^@FzdyI$Mq`}XJ~e$#}y z5e7$0l;BB4JrpF^nk*XK>_=YWhk*`Ta@|-AV-skv!JD(9(ky%3UdL#^rB}kfHnO)` zt>a&4L^lN9on%Vo*v$EGQ1qoeu8?5W=l z!ic6WpP6C^g@m`Fu=_s3xV-NFS=RRncx_ElCNI%uIf(#EBt(E6>lh{MG@0|*JHkB0 z6anKF11ZihH%ND9GU3$m&;--yQddDK%bhDeiWb#NUjgQ@Ed;8S*fVU zZ2aObdm;$4lwb?0(C}Mm7n%#|q!`+DIfFpgcmdkfc9siAAlD3bIPGB72^)u({MhIPgoIey$UvZjes=+#k6GC??Z_B9e{}C+h?$vDsbozC3f0LmOSlrMIC6D&Jnnh@x;>4MW9(6(qIF%g*=Q{e9@vD-Zh0U_h!UqiN~?Pz4l3}*9ZIYb3EIQR?0Gn|WTq_-9(0i7aREld zPG9zm8(FJQ9c?(UOPQY)B~;i5yIoFb^mn)vOAQeiXxs-enq>p$cwFdq;hC#z;>Cvn zf@cz1eUe>1>m7J(E1?7PLH;oMqtWrjG|w>htYvUoag!_qpyg*A zT-xFgy$-xC&R}wif!E-1eOql-f4#algYxaVoJ6|D?&cqbCki%us_7-3vXrE3eH2{g zfiWg~`~b{Z-q$RD$3x}G`kSov{t#OGD{K~$u!=c;qN%GJBdwVJNoU9a7HNA;d+ zb>>lw-?F5r}3Itllz@Q0~`N4RKuWnV-uZ-8X+1|CAlo`)YIg=ux|6PwlST zv89GBbGPrF`KAdB;o2-euu|8(6_6tXsPtBJt#bFw6J;mByc`Hk(@*rS%3Nh`My0Q( zXVby>*!V>HZn~<|^XdBdOa?r6m9C9nNVn2ax}I*P1N;sl0`U?4TyvXO$Iqn0bg1#) z1w1%AK9;Vg?^x;dB3}?7I)uVOI;yDjC2qvk5OxPT-GcZCmo)K?y$L@~LH}rce0*Yj z3cKNh)w;>s>A7@7ZwKi`N>KUu%&f+vRnay~H|QDe493Ty6`nx@ov?-nVmN=nfF{Px z?Qms)7~V~Xvl>FMvPDhez<}=?&^Jim9iN2oc|L@TFn}ny`B~UsXCi43O4i4xd5^l+ zMDs>wCYL-5Rp-Yi$0sQAf-}FGZi>6qI)bJV4NNkq(>LH5Hf}IyVwGh%U3_mz*Yx-) zcy|m!t2WPXrEk^K*V5PO>6_`B6`j5W8+ft`M^P&++VF=JmQ+b{C3#E>ogW`(-o+!P z2eyZN!pcjU0jKl=-5#(;JC#JTj`tt8BYOwh4vIp)Miy;(oUAm^A|Mne2p>U&B$E+X zPzel>$}lX9h@SNS}SPV zHd%gaQw7>jl#)2i#t8gg;V#Qc=qESZZ6u)A3{(6CAy^JvPd5tO;&;9T#AD0$p zFT%`8iA5X%+ECB&83-aQlnT?U5Je#;1xn{c-6?OPivtv=9*aabk3LT7h(twOuc^ssMDXhcGk1=PU_4p zRHi?KNm_BfnexZxoQOiQ0sqT3v;|y|^`@t_Bmi&oW%-y!l0S>Y{ju%d z*pELYCcsv6!taQO=ouBi*+FdbYRuQWEX~r**$!w!;K;pYe|$gyM8R`;0kcU#%5cQR zDDj*KW=F{Bk9+vijM;bh0GDusdI|Pk8jj zR|K{p!Ox>IX;BUp8coOpFiA($=j~xBj39p=fx~M@G)T!XYaEsSLbAV!e&t3wEJMYT za3Li-G8T!BQ~~!cRX}BIcVb+p1!f?D7QSX&{@wFs25Q+ROGAO9_`WS~;84kL96NuN z08IhL@HHQMAffCg^2$H$=OSbYCys3f$1gyfr)(Y>?16+|jNCzX>eLGt`1&Jtci%kU z2gPPzL!lh5xG$pbW1m9w^nFVa91MYjMoAOMX;w*5ffg2Zd_pk_$L>fXbEp0d^&de7 literal 0 HcmV?d00001 diff --git a/locales/ru/LC_MESSAGES/tools.effmpeg.cli.po b/locales/ru/LC_MESSAGES/tools.effmpeg.cli.po new file mode 100644 index 0000000000..28228b3098 --- /dev/null +++ b/locales/ru/LC_MESSAGES/tools.effmpeg.cli.po @@ -0,0 +1,190 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2021-02-18 23:34-0000\n" +"PO-Revision-Date: 2023-04-11 15:18+0700\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"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" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.2.2\n" + +#: tools/effmpeg/cli.py:15 +msgid "This command allows you to easily execute common ffmpeg tasks." +msgstr "Эта команда позволяет легко выполнять общие задачи ffmpeg." + +#: tools/effmpeg/cli.py:24 +msgid "A wrapper for ffmpeg for performing image <> video converting." +msgstr "Обертка для ffmpeg для выполнения конвертации изображений <> видео." + +#: tools/effmpeg/cli.py:51 +msgid "" +"R|Choose which action you want ffmpeg ffmpeg to do.\n" +"L|'extract': turns videos into images \n" +"L|'gen-vid': turns images into videos \n" +"L|'get-fps' returns the chosen video's fps.\n" +"L|'get-info' returns information about a video.\n" +"L|'mux-audio' add audio from one video to another.\n" +"L|'rescale' resize video.\n" +"L|'rotate' rotate video.\n" +"L|'slice' cuts a portion of the video into a separate video file." +msgstr "" +"R|Выберите, какое действие вы хотите, чтобы выполнял ffmpeg.\n" +"L|'extract': превращает видео в изображения \n" +"L|'gen-vid': превращает изображения в видео. \n" +"L|'get-fps' возвращает частоту кадров в секунду выбранного видео.\n" +"L|'get-info': возвращает информацию о видео.\n" +"L|'mux-audio' добавляет звук из одного видео в другое.\n" +"L|'rescale' изменить размер видео.\n" +"L|'rotate' вращение видео.\n" +"L|'slice' вырезает часть видео в отдельный видеофайл." + +#: tools/effmpeg/cli.py:65 +msgid "Input file." +msgstr "Входной файл." + +#: tools/effmpeg/cli.py:66 tools/effmpeg/cli.py:73 tools/effmpeg/cli.py:87 +msgid "data" +msgstr "данные" + +#: tools/effmpeg/cli.py:76 +msgid "" +"Output file. If no output is specified then: if the output is meant to be a " +"video then a video called 'out.mkv' will be created in the input directory; " +"if the output is meant to be a directory then a directory called 'out' will " +"be created inside the input directory. Note: the chosen output file " +"extension will determine the file encoding." +msgstr "" +"Выходной файл. Если выходной файл не указан, то: если выходным файлом " +"является видео, то в каталоге ввода будет создан видеофайл с именем 'out." +"mkv'; если выходным файлом является каталог, то внутри каталога ввода будет " +"создан каталог с именем 'out'. Примечание: выбранное расширение выходного " +"файла определяет кодировку файла." + +#: tools/effmpeg/cli.py:89 +msgid "Path to reference video if 'input' was not a video." +msgstr "Путь к опорному видео, если 'input' не является видео." + +#: tools/effmpeg/cli.py:95 tools/effmpeg/cli.py:105 tools/effmpeg/cli.py:142 +#: tools/effmpeg/cli.py:171 +msgid "output" +msgstr "выход" + +#: tools/effmpeg/cli.py:97 +msgid "" +"Provide video fps. Can be an integer, float or fraction. Negative values " +"will will make the program try to get the fps from the input or reference " +"videos." +msgstr "" +"Предоставляет количество кадров в секунду. Может быть целым числом, " +"плавающей цифрой или дробью. Отрицательные значения заставят программу " +"попытаться получить fps из входного или опорного видео." + +#: tools/effmpeg/cli.py:107 +msgid "" +"Image format that extracted images should be saved as. '.bmp' will offer the " +"fastest extraction speed, but will take the most storage space. '.png' will " +"be slower but will take less storage." +msgstr "" +"Формат изображения, в котором должны быть сохранены извлеченные изображения. " +"'.bmp' обеспечивает самую высокую скорость извлечения, но занимает больше " +"всего места в памяти. '.png' будет медленнее, но займет меньше места." + +#: tools/effmpeg/cli.py:114 tools/effmpeg/cli.py:123 tools/effmpeg/cli.py:132 +msgid "clip" +msgstr "клип" + +#: tools/effmpeg/cli.py:116 +msgid "" +"Enter the start time from which an action is to be applied. Default: " +"00:00:00, in HH:MM:SS format. You can also enter the time with or without " +"the colons, e.g. 00:0000 or 026010." +msgstr "" +"Введите время начала, с которого будет применяться действие. По умолчанию: " +"00:00:00, в формате ЧЧ:ММ:СС. Вы также можете ввести время с двоеточием или " +"без него, например, 00:0000 или 026010." + +#: tools/effmpeg/cli.py:125 +msgid "" +"Enter the end time to which an action is to be applied. If both an end time " +"and duration are set, then the end time will be used and the duration will " +"be ignored. Default: 00:00:00, in HH:MM:SS." +msgstr "" +"Введите время окончания, до которого будет применяться действие. Если заданы " +"и время окончания, и продолжительность, то будет использоваться время " +"окончания, а продолжительность будет игнорироваться. По умолчанию: 00:00:00, " +"в формате ЧЧ:ММ:СС." + +#: tools/effmpeg/cli.py:134 +msgid "" +"Enter the duration of the chosen action, for example if you enter 00:00:10 " +"for slice, then the first 10 seconds after and including the start time will " +"be cut out into a new video. Default: 00:00:00, in HH:MM:SS format. You can " +"also enter the time with or without the colons, e.g. 00:0000 or 026010." +msgstr "" +"Введите продолжительность выбранного действия, например, если вы введете " +"00:00:10 для нарезки, то первые 10 секунд после начала и включая время " +"начала будут вырезаны в новое видео. По умолчанию: 00:00:00, в формате ЧЧ:ММ:" +"СС. Вы также можете ввести время с двоеточием или без него, например, " +"00:0000 или 026010." + +#: tools/effmpeg/cli.py:144 +msgid "" +"Mux the audio from the reference video into the input video. This option is " +"only used for the 'gen-vid' action. 'mux-audio' action has this turned on " +"implicitly." +msgstr "" +"Mux аудио из опорного видео во входное видео. Эта опция используется только " +"для действия 'gen-vid'. Действие 'mux-audio' включает эту опцию неявно." + +#: tools/effmpeg/cli.py:155 tools/effmpeg/cli.py:165 +msgid "rotate" +msgstr "поворот" + +#: tools/effmpeg/cli.py:157 +msgid "" +"Transpose the video. If transpose is set, then degrees will be ignored. For " +"cli you can enter either the number or the long command name, e.g. to use " +"(1, 90Clockwise) -tr 1 or -tr 90Clockwise" +msgstr "" +"Транспонировать видео. Если задано транспонирование, то градусы будут " +"игнорироваться. Для командой строки вы можете ввести либо число, либо " +"длинное имя команды, например, для использования (1, 90 по часовой стрелке) -" +"tr 1 или -tr 90 по часовой стрелке" + +#: tools/effmpeg/cli.py:166 +msgid "Rotate the video clockwise by the given number of degrees." +msgstr "Поверните видео по часовой стрелке на заданное количество градусов." + +#: tools/effmpeg/cli.py:173 +msgid "Set the new resolution scale if the chosen action is 'rescale'." +msgstr "Установите новый масштаб разрешения, если выбрано действие 'rescale'." + +#: tools/effmpeg/cli.py:178 tools/effmpeg/cli.py:186 +msgid "settings" +msgstr "настройки" + +#: tools/effmpeg/cli.py:180 +msgid "" +"Reduces output verbosity so that only serious errors are printed. If both " +"quiet and verbose are set, verbose will override quiet." +msgstr "" +"Уменьшает многословность вывода, чтобы выводились только серьезные ошибки. " +"Если заданы и quiet, и verbose, то verbose будет преобладать над quiet." + +#: tools/effmpeg/cli.py:188 +msgid "" +"Increases output verbosity. If both quiet and verbose are set, verbose will " +"override quiet." +msgstr "" +"Повышает точность вывода. Если заданы и quiet, и verbose, то verbose будет " +"преобладать над quiet." diff --git a/locales/ru/LC_MESSAGES/tools.manual.mo b/locales/ru/LC_MESSAGES/tools.manual.mo new file mode 100644 index 0000000000000000000000000000000000000000..a3dfff23e2d2799883efa16739b5b7c91c07e4a4 GIT binary patch literal 10909 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 Q2$HD~5RLbMG97F1U+K>*J^%m! literal 0 HcmV?d00001 diff --git a/locales/ru/LC_MESSAGES/tools.manual.po b/locales/ru/LC_MESSAGES/tools.manual.po new file mode 100644 index 0000000000..0f6aad8c86 --- /dev/null +++ b/locales/ru/LC_MESSAGES/tools.manual.po @@ -0,0 +1,293 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2022-11-24 14:17+0900\n" +"PO-Revision-Date: 2023-04-11 15:30+0700\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"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" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.2.2\n" + +#: tools/manual\cli.py:13 +msgid "" +"This command lets you perform various actions on frames, faces and " +"alignments files using visual tools." +msgstr "" +"Эта команда позволяет выполнять различные действия с кадрами, гранями и " +"файлами выравнивания с помощью визуальных инструментов." + +#: tools/manual\cli.py:23 +msgid "" +"A tool to perform various actions on frames, faces and alignments files " +"using visual tools" +msgstr "" +"Инструмент для выполнения различных действий с кадрами, лицами и файлами " +"выравнивания с помощью визуальных инструментов" + +#: tools/manual\cli.py:35 tools/manual\cli.py:43 +msgid "data" +msgstr "данные" + +#: tools/manual\cli.py:37 +msgid "" +"Path to the alignments file for the input, if not at the default location" +msgstr "" +"Путь к файлу выравниваний для входных данных, если он не находится в месте " +"по умолчанию" + +#: tools/manual\cli.py:44 +msgid "" +"Video file or directory containing source frames that faces were extracted " +"from." +msgstr "" +"Видеофайл или папка, содержащая исходные кадры, из которых были извлечены " +"лица." + +#: tools/manual\cli.py:51 tools/manual\cli.py:59 +msgid "options" +msgstr "опции" + +#: tools/manual\cli.py:52 +msgid "" +"Force regeneration of the low resolution jpg thumbnails in the alignments " +"file." +msgstr "" +"Принудительное восстановление миниатюр jpg низкого разрешения в файле " +"выравнивания." + +#: tools/manual\cli.py:60 +msgid "" +"The process attempts to speed up generation of thumbnails by extracting from " +"the video in parallel threads. For some videos, this causes the caching " +"process to hang. If this happens, then set this option to generate the " +"thumbnails in a slower, but more stable single thread." +msgstr "" +"Процесс пытается ускорить генерацию эскизов путем извлечения из видео в " +"параллельных потоках. Для некоторых видео это приводит к зависанию процесса " +"кэширования. Если это происходит, установите этот параметр, чтобы " +"генерировать эскизы в более медленном, но более стабильном одном потоке." + +#: tools/manual\faceviewer\frame.py:163 +msgid "Display the landmarks mesh" +msgstr "Отображение сетки ориентиров" + +#: tools/manual\faceviewer\frame.py:164 +msgid "Display the mask" +msgstr "Отображение маски" + +#: tools/manual\frameviewer\editor\_base.py:628 +#: tools/manual\frameviewer\editor\landmarks.py:44 +#: tools/manual\frameviewer\editor\mask.py:75 +msgid "Magnify/Demagnify the View" +msgstr "Увеличение/уменьшение изображения" + +#: tools/manual\frameviewer\editor\bounding_box.py:33 +#: tools/manual\frameviewer\editor\extract_box.py:32 +msgid "Delete Face" +msgstr "Удалить лицо" + +#: tools/manual\frameviewer\editor\bounding_box.py:36 +msgid "" +"Bounding Box Editor\n" +"Edit the bounding box being fed into the aligner to recalculate the " +"landmarks.\n" +"\n" +" - Grab the corner anchors to resize the bounding box.\n" +" - Click and drag the bounding box to relocate.\n" +" - Click in empty space to create a new bounding box.\n" +" - Right click a bounding box to delete a face." +msgstr "" +"Редактор ограничительных рамок\n" +"Отредактируйте ограничивающую рамку, подаваемую в выравниватель, чтобы " +"пересчитать ориентиры.\n" +"\n" +"- Захватите угловые опоры, чтобы изменить размер ограничивающей рамки.\n" +" - Щелкните и перетащите ограничивающую рамку для перемещения.\n" +" - Щелкните в пустом пространстве, чтобы создать новую ограничивающую " +"рамку.\n" +"- Щелкните правой кнопкой мыши ограничительную рамку, чтобы удалить лицо." + +#: tools/manual\frameviewer\editor\bounding_box.py:70 +msgid "" +"Aligner to use. FAN will obtain better alignments, but cv2-dnn can be useful " +"if FAN cannot get decent alignments and you want to set a base to edit from." +msgstr "" +"Выравниватель для использования. FAN получит лучшие выравнивания, но cv2-dnn " +"может быть полезен, если FAN не может получить достойные выравнивания, и вы " +"хотите установить базу для редактирования." + +#: tools/manual\frameviewer\editor\bounding_box.py:83 +msgid "" +"Normalization method to use for feeding faces to the aligner. This can help " +"the aligner better align faces with difficult lighting conditions. Different " +"methods will yield different results on different sets. NB: This does not " +"impact the output face, just the input to the aligner.\n" +"\tnone: Don't perform normalization on the face.\n" +"\tclahe: Perform Contrast Limited Adaptive Histogram Equalization on the " +"face.\n" +"\thist: Equalize the histograms on the RGB channels.\n" +"\tmean: Normalize the face colors to the mean." +msgstr "" +"Метод нормализации, используемый для подачи лиц в выравниватель. Это может " +"помочь выравнивателю лучше выравнивать лица при сложных условиях освещения. " +"Различные методы дают разные результаты на разных наборах. Примечание: Это " +"не влияет на выходное лицо, только на входное в выравниватель.\n" +"\tnone: Не выполнять нормализацию лица.\n" +"\tclahe: Выполнить для лица адаптивную гистограммную эквализацию с " +"ограничением контраста.\n" +"\thist: Выравнивание гистограмм по каналам RGB.\n" +"\tmean: Нормализовать цвета лица к среднему значению." + +#: tools/manual\frameviewer\editor\extract_box.py:35 +msgid "" +"Extract Box Editor\n" +"Move the extract box that has been generated by the aligner. Click and " +"drag:\n" +"\n" +" - Inside the bounding box to relocate the landmarks.\n" +" - The corner anchors to resize the landmarks.\n" +" - Outside of the corners to rotate the landmarks." +msgstr "" +"Редактор поля извлечения\n" +"Переместите поле извлечения, созданное выравнивателем. Нажмите и " +"перетащите:\n" +"\n" +" - Внутри ограничивающей рамки для перемещения опорных точек.\n" +"- По угловым опорам для изменения размера опорных точек.\n" +"- За пределами углов, чтобы повернуть опорные точки." + +#: tools/manual\frameviewer\editor\landmarks.py:27 +msgid "" +"Landmark Point Editor\n" +"Edit the individual landmark points.\n" +"\n" +" - Click and drag individual points to relocate.\n" +" - Draw a box to select multiple points to relocate." +msgstr "" +"Редактор точек ориентира\n" +"Редактирование отдельных опорных точек.\n" +"\n" +" - Щелкните и перетащите отдельные точки для перемещения.\n" +" - Нарисуйте рамку, чтобы выбрать несколько точек для перемещения." + +#: tools/manual\frameviewer\editor\mask.py:33 +msgid "" +"Mask Editor\n" +"Edit the mask.\n" +" - NB: For Landmark based masks (e.g. components/extended) it is better to " +"make sure the landmarks are correct rather than editing the mask directly. " +"Any change to the landmarks after editing the mask will override your manual " +"edits." +msgstr "" +"Редактор маски\n" +"Отредактировать маску.\n" +" - Примечание: Для масок, основанных на ориентирах (например, компоненты/" +"расширенные), лучше убедиться в правильности ориентиров, а не редактировать " +"маску напрямую. Любое изменение ориентиров после редактирования маски " +"отменит ваши ручные правки." + +#: tools/manual\frameviewer\editor\mask.py:77 +msgid "Draw Tool" +msgstr "Инструмент рисования" + +#: tools/manual\frameviewer\editor\mask.py:78 +msgid "Erase Tool" +msgstr "Инструмент \"Ластик\"" + +#: tools/manual\frameviewer\editor\mask.py:97 +msgid "Select which mask to edit" +msgstr "Выбрать, какую маску редактировать" + +#: tools/manual\frameviewer\editor\mask.py:104 +msgid "Set the brush size. ([ - decrease, ] - increase)" +msgstr "Установить размер кисти. ([ - уменьшение, ] - увеличение)" + +#: tools/manual\frameviewer\editor\mask.py:111 +msgid "Select the brush cursor color." +msgstr "Установить цвет курсора кисти." + +#: tools/manual\frameviewer\frame.py:78 +msgid "Play/Pause (SPACE)" +msgstr "Воспроизвести/Приостановить (ПРОБЕЛ)" + +#: tools/manual\frameviewer\frame.py:79 +msgid "Go to First Frame (HOME)" +msgstr "Перейти к первому кадру (HOME)" + +#: tools/manual\frameviewer\frame.py:80 +msgid "Go to Previous Frame (Z)" +msgstr "Перейти к предыдущему кадру (Z/Я)" + +#: tools/manual\frameviewer\frame.py:81 +msgid "Go to Next Frame (X)" +msgstr "Перейти к следующему кадру (X/Ч)" + +#: tools/manual\frameviewer\frame.py:82 +msgid "Go to Last Frame (END)" +msgstr "Перейти к последнему кадру (END)" + +#: tools/manual\frameviewer\frame.py:83 +msgid "Extract the faces to a folder... (Ctrl+E)" +msgstr "Извлечь лица в папку... (Ctrl+E)" + +#: tools/manual\frameviewer\frame.py:84 +msgid "Save the Alignments file (Ctrl+S)" +msgstr "Сохранить файл выравнивания (Ctrl+S)" + +#: tools/manual\frameviewer\frame.py:85 +msgid "Filter Frames to only those Containing the Selected Item (F)" +msgstr "Отфильтровать кадры, содержащие только выбранный элемент (F/А)" + +#: tools/manual\frameviewer\frame.py:86 +msgid "" +"Set the distance from an 'average face' to be considered misaligned. Higher " +"distances are more restrictive" +msgstr "" +"Установить расстояние от \"среднего лица\", на котором оно будет считаться " +"смещенным. Большие расстояния являются более ограничительными" + +#: tools/manual\frameviewer\frame.py:391 +msgid "View alignments" +msgstr "Просмотреть выравнивания" + +#: tools/manual\frameviewer\frame.py:392 +msgid "Bounding box editor" +msgstr "Редактор ограничительных рамок" + +#: tools/manual\frameviewer\frame.py:393 +msgid "Location editor" +msgstr "Редактор расположения" + +#: tools/manual\frameviewer\frame.py:394 +msgid "Mask editor" +msgstr "Редактор маски" + +#: tools/manual\frameviewer\frame.py:395 +msgid "Landmark point editor" +msgstr "Редактор точек ориентира" + +#: tools/manual\frameviewer\frame.py:470 +msgid "Next" +msgstr "Следующий" + +#: tools/manual\frameviewer\frame.py:470 +msgid "Previous" +msgstr "Предыдущий" + +#: tools/manual\frameviewer\frame.py:481 +msgid "Revert to saved Alignments ({})" +msgstr "Откатить до сохраненных выравниваний ({})" + +#: tools/manual\frameviewer\frame.py:487 +msgid "Copy {} Alignments ({})" +msgstr "Копировать {} выравнивания ({})" diff --git a/locales/ru/LC_MESSAGES/tools.mask.cli.mo b/locales/ru/LC_MESSAGES/tools.mask.cli.mo new file mode 100644 index 0000000000000000000000000000000000000000..2256ac5ec942747c4251abb050b70fdf4fe51d99 GIT binary patch literal 12823 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 literal 0 HcmV?d00001 diff --git a/locales/ru/LC_MESSAGES/tools.mask.cli.po b/locales/ru/LC_MESSAGES/tools.mask.cli.po new file mode 100644 index 0000000000..f741cc1741 --- /dev/null +++ b/locales/ru/LC_MESSAGES/tools.mask.cli.po @@ -0,0 +1,226 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +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" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"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" + +#: tools/mask/cli.py:15 +msgid "This command lets you generate masks for existing alignments." +msgstr "" +"Эта команда позволяет генерировать маски для существующих выравниваний." + +#: tools/mask/cli.py:24 +msgid "" +"Mask tool\n" +"Generate 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 +msgid "data" +msgstr "данные" + +#: tools/mask/cli.py:36 +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 " +"corresponding alignments file, then you must provide a value here as the " +"location cannot be automatically detected." +msgstr "" +"Полный путь к файлу выравниваний для добавления маски, если он не находится " +"в месте по умолчанию. Примечание: Если input-type - лица, и вы хотите " +"обновить соответствующий файл выравнивания, то вы должны указать значение " +"здесь, так как местоположение не может быть определено автоматически." + +#: tools/mask/cli.py:47 +msgid "Directory containing extracted faces, source frames, or a video file." +msgstr "Папка, содержащая извлеченные лица, исходные кадры или видеофайл." + +#: tools/mask/cli.py:56 +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 "" +"R|Выбирается ли \"вход\" как папка лиц или как папка кадров/видео\n" +"L|faces: Входом является папка, содержащая извлеченные лица.\n" +"L|frames: Входом является папка с кадрами или видео" + +#: tools/mask/cli.py:65 +msgid "" +"R|Run the mask tool on multiple sources. If selected then the other options " +"should be set as follows:\n" +"L|input: A parent folder containing either all of the video files to be " +"processed, or containing sub-folders of frames/faces.\n" +"L|output-folder: If provided, then sub-folders will be created within the " +"given location to hold the previews for each input.\n" +"L|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." +msgstr "" +"R|Запустить инструмент маски на нескольких источниках. Если выбрано, то " +"остальные параметры должны быть установлены следующим образом:\n" +"L|input: Родительская папка, содержащая либо все видеофайлы для обработки, " +"либо содержащая вложенные папки кадров/лиц.\n" +"L|output-folder: Если указано, то в заданном месте будут созданы вложенные " +"папки для хранения превью для каждого входа.\n" +"L|alignments: Поле выравнивания будет игнорироваться при пакетной обработке. " +"Файлы выравнивания должны существовать в месте по умолчанию (для кадров). " +"При пакетной обработке масок с типом входа \"лица\" будут обновлены только " +"заголовки PNG в извлеченных лицах." + +#: tools/mask/cli.py:81 tools/mask/cli.py:113 +msgid "process" +msgstr "обработка" + +#: tools/mask/cli.py:82 +msgid "" +"R|Masker to use.\n" +"L|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).\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\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." +msgstr "" +"R|Маскер для использования.\n" +"L|bisenet-fp: Относительно легкая маска на основе NN, которая обеспечивает " +"более точный контроль над маскируемой областью, включая полное маскирование " +"головы (настраивается в настройках маски).\n" +"L|components: Маска, разработанная для сегментации лица на основе " +"расположения ориентиров. Для создания маски вокруг внешних ориентиров " +"строится выпуклая оболочка.\n" +"L|custom (пользовательская): Фиктивная маска, которая заполняет область " +"маски всеми 1 или 0 (настраивается в настройках). Она необходима только в " +"том случае, если вы собираетесь вручную редактировать пользовательские маски " +"в ручном инструменте. Эта маска не использует GPU.\n" +"L|extended: Маска предназначена для сегментации лица на основе расположения " +"ориентиров. Выпуклая оболочка строится вокруг внешних ориентиров, и маска " +"расширяется вверх на лоб.\n" +"L|vgg-clear: Маска предназначена для интеллектуальной сегментации " +"преимущественно фронтальных лиц без препятствий. Профильные лица и " +"препятствия могут привести к снижению производительности.\n" +"L|vgg-obstructed: Маска, разработанная для интеллектуальной сегментации " +"преимущественно фронтальных лиц. Модель маски была специально обучена " +"распознавать некоторые препятствия на лице (руки и очки). Лица в профиль " +"могут иметь низкую производительность.\n" +"L|unet-dfl: Маска, разработанная для интеллектуальной сегментации " +"преимущественно фронтальных лиц. Модель маски была обучена членами " +"сообщества и для дальнейшего описания нуждается в тестировании. Профильные " +"лица могут иметь низкую производительность." + +#: tools/mask/cli.py:114 +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: Не обновлять маски, а просто вывести их для просмотра в указанную " +"выходную папку." + +#: 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 +msgid "output" +msgstr "вывод" + +#: tools/mask/cli.py:128 +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 +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 " +"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" +msgstr "" +"Применяет гауссово размытие к выходу маски. Сглаживает края маски, делая их " +"менее жесткими. размер в пикселях. Это значение должно быть нечетным, если " +"передано четное число, то оно будет округлено до следующего нечетного числа. " +"Примечание: влияет только на предварительный просмотр. Установите значение 0 " +"для выключения" + +#: tools/mask/cli.py:151 +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 "" +"Помогает уменьшить \"пятнистость\" на некоторых масках, делая светлые " +"оттенки белыми, а темные - черными. Более высокие значения влияют на большую " +"часть маски. Примечание: влияет только на предварительный просмотр. " +"Установите значение 0 для выключения" + +#: tools/mask/cli.py:161 +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" +"L|masked: Output the face/frame as rgba image with the face masked.\n" +"L|mask: Only output the mask as a single channel image." +msgstr "" +"R|Как форматировать вывод, когда обработка установлена на 'output'.\n" +"L|combined: Изображение содержит лицо/кадр, маску лица и маскированное " +"лицо.\n" +"L|masked: Вывести лицо/кадр как изображение rgba с маскированным лицом.\n" +"L|mask: Выводить только маску как одноканальное изображение." + +#: tools/mask/cli.py:170 +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." +msgstr "" +"R|Выводить ли весь кадр или только поле лица при использовании выходной " +"обработки. Имеет значение только при использовании кадров в качестве входных " +"данных." diff --git a/locales/ru/LC_MESSAGES/tools.model.cli.mo b/locales/ru/LC_MESSAGES/tools.model.cli.mo new file mode 100644 index 0000000000000000000000000000000000000000..226dd0c52b6f3d5456757e050f654a44b6e378ae GIT binary patch literal 3819 zcmb_e-ES0C6dx5u*Th7heDL9DB8AATy9JaiEQazW5wL-3H2QM8d)pn@&a5-D)FyqP zMMOg_38L`vw+V-1+0V zZC_dVdlKUXj1MuM#<+#Sf4^W#vMyl#CCsyUpMS!#Zeo6RyJdZi`A5t@L#7JDqmNqFUod(d>%-4jRsr*$m?3UK z+=8h1hvOF1XMa#(LDc*mrqJ`6H0Jr#(mtjM#~mkC3mwl5$aCqqs_9@-wJ7wJO($v^>`xU)MsqV=}h zoT7FBOYGEucsJ5ST!s>Wn%AqWSD;Ksz1xqG~#I zs;f}#5*@w{z%8WTrnBaeRGMzabyU?Zv|F6r5wOe{=UfKrqVeAlRt1363w4G5onV~$ z#y)X4RBm1Qb$Sbg3+T;#X=HTN(*ZGd;+bT+*2eRMNjP&vFaTObI%PO8=`@?YQ^9A;p}xkWLAk-LKo?J3BL?-N5t5n%?9xGKeX?%9 ziy8nF6)K$7%whXr!X8#EO-0m)O!9dD~~ZI5U*k$bO@-!7a!dHPu2c)3_AFi6^kj;R)m zgs7QUTTS$+15|4$KhRV$>iX4_Xy`#7>3RLE_~ zYf#;DfXricr~7ibRIQe2#}4Ah(u-Au@&=U%$MJrsI+$)9tnTGUktiK357P9s5jj-e zy9**^hz#yJPDY_S}#MdcW zjITuxqqS%yTIHRn$BXyyxXdEaV$_S43F`~dJ&HfUw2Y$*P}3tw-H9*9S4D0$z7}60 ztgngm3Twy+U`5|S=3#U{TF4*i6{)|mo8lQG9AAo-qJ{W#4EU01jOTWzjzG~GoLj@m zHFgg{P<%z!dRXtQ4+~52+{U0fMc@L95_>!sb%9L>2WMcDRRf(lHj@F>We6|~M7jQt zAa3J)S9X_;`JB_nmqiH;(Hq#4TL|zX9|H7R?jDM6ASrjSaS7>=aMmZJ10eDUn0WsS zfz8)e-*1BubOMiFJfA}ydEOF^hl2eg(#*aYP_VRsU_b~FSp+iQ3o2k5uMx=9U9TId zn?b(tvjXG2d|0V-2Yt#bH6Y7&2!OnmEzC8Ytln0R+ll)(B9DpH8QrF#H0 zTFeR*)^A4-@CyP6IP;X<>q(SA4&@7&(h@*7-~xOA)C3vsBpJ=K0*HE>u6jukqm&RF z#hG@N4frA>rsM$$Qquzn@;xB%^^$J_1{XfE=a;3FkbTpPOu0&D+Vrsy-GKKqa-IWX zcalDlxz0_42`iv?#aqs#beyDA5FNfOq96K5q@>{d#aAH8+S3Bbv*#bF`ag9ge5s_L zD$#Au5c~ij!ei#sGJ~EaF#C#uTiZEUA5rzctE58YZbME{feZ_J7x&6daK!Vb9O3pIzLpRGbMi7D zPFhCa`7VRAf%nod`jJ@DzHs9iTbr(O#=R@Gmo?hlwGO;D-+v5!a;a}5Dw9L5_Y4Sh PZ(hidwdAWoYTf!9`9FDb literal 0 HcmV?d00001 diff --git a/locales/ru/LC_MESSAGES/tools.model.cli.po b/locales/ru/LC_MESSAGES/tools.model.cli.po new file mode 100644 index 0000000000..c3b43f4193 --- /dev/null +++ b/locales/ru/LC_MESSAGES/tools.model.cli.po @@ -0,0 +1,88 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-06-28 14:05+0100\n" +"PO-Revision-Date: 2023-04-11 16:02+0700\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"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" + +#: tools/model/cli.py:13 +msgid "This tool lets you perform actions on saved Faceswap models." +msgstr "" +"Этот инструмент позволяет выполнять действия над сохраненными моделями " +"Faceswap." + +#: tools/model/cli.py:22 +msgid "A tool for performing actions on Faceswap trained model files" +msgstr "" +"Инструмент для выполнения действий над файлами обученных моделей Faceswap" + +#: tools/model/cli.py:33 +msgid "" +"Model directory. A directory containing the model you wish to perform an " +"action on." +msgstr "" +"Папка модели. Папка, содержащая модель, над которой вы хотите выполнить " +"действие." + +#: tools/model/cli.py:41 +msgid "" +"R|Choose which action you want to perform.\n" +"L|'inference' - Create an inference only copy of the model. Strips any " +"layers from the model which are only required for training. NB: This is for " +"exporting the model for use in external applications. Inference generated " +"models cannot be used within Faceswap. See the 'format' option for " +"specifying the model output format.\n" +"L|'nan-scan' - Scan the model file for NaNs or Infs (invalid data).\n" +"L|'restore' - Restore a model from backup." +msgstr "" +"R|Выберите действие, которое вы хотите выполнить.\n" +"L|'inference' - Создать копию модели только для проведения расчетов. " +"Удаляет из модели все слои, которые нужны только для обучения. Примечание: " +"Эта функция предназначена для экспорта модели для использования во внешних " +"приложениях. Модели, созданные в режиме вывода, не могут быть использованы " +"в Faceswap. См. опцию 'format' для указания формата вывода модели.\n" +"L|'nan-scan' - Проверить файл модели на наличие NaNs или Infs (недопустимых " +"данных).\n" +"L|'restore' - Восстановить модель из резервной копии." + +#: tools/model/cli.py:55 tools/model/cli.py:66 +msgid "inference" +msgstr "вывод" + +#: tools/model/cli.py:56 +msgid "" +"R|The format to save the model as. Note: Only used for 'inference' job.\n" +"L|'h5' - Standard Keras H5 format. Does not store any custom layer " +"information. Layers will need to be loaded from Faceswap to use.\n" +"L|'saved-model' - Tensorflow's Saved Model format. Contains all information " +"required to load the model outside of Faceswap." +msgstr "" +"R|Формат для сохранения модели. Примечание: Используется только для задания " +"'inference'.\n" +"L||'h5' - Стандартный формат Keras H5. Не хранит никакой информации о " +"пользовательских слоях. Для использования слои должны быть загружены из " +"Faceswap.\n" +"L|'saved-model' - формат сохраненной модели Tensorflow. Содержит всю " +"информацию, необходимую для загрузки модели вне Faceswap." + +#: tools/model/cli.py:67 +msgid "" +"Only used for 'inference' job. Generate the inference model for B -> A " +"instead of A -> B." +msgstr "" +"Используется только для задания 'inference'. Создайте модель вывода для B -" +"> A вместо A -> B." diff --git a/locales/ru/LC_MESSAGES/tools.preview.mo b/locales/ru/LC_MESSAGES/tools.preview.mo new file mode 100644 index 0000000000000000000000000000000000000000..d8ebe91405f0647ac86613fe01997a9e2a358ccc GIT binary patch literal 2891 zcmcIlO>Y}T7#?W(G89$x%Hgemii)hUov1`YJkSOe~P4*P-Ufuq1bfy2Q486iFfo&qw@Prw&|0X0SN-`1gN(j*3NvQp1o27}tfOa|2Av~%I z)$N{#xXFsCo)gw2J-6o9m9Hb~i>eS|wQ`(8~Oe-u?wdr}i z?VO#y(>duiRrGM1NX{YT9Ts0|S9|KKeXQ|{z zTFD9pRTRn@rxQaIvB?P@Oo*9TH-fBsUHTQ|e|yq;iLmAo2e0RqJpVxQvZ5ljMD~Nv z(S}Z`;(VaO2n89V%(Q9*p>`&tnp<&BG;5JF6JUDkJ!cw8W+Ug6)M|_hxx%oM8*%b^ z${!ia9eE@7RxX!0D{z)3k^8Y>I?m5!$V6RrShx{QQzYGGiRL9fgSs*s?a&@mxBsgV~j>Wb9H9QYlhNu z&IDmS8Y919hf#6(D5amp*ZtS>xneO-hYk^c<_;DS%5lmO%rPD>7WzxaiX;4KcjS)c z3v}T^x@9~+au{3k*itxrH1m=3w(?b&20j(2imPcjTgVnN!dy=-o7k+HP18zN$lNt8 zymw5COxLWNE}1qyR!zsmSu#K3`=*JvnGI2L(eCdgi{>|QT(LaK*WkHr)|0Euvj&Ts zSZ~3i15aIZdx+p9PA=hHOs<*^VH!SQmdfdx4ffOD2dA)YC6|(A?1ZkKjAV(Yz_m^1 zmhYvkFgFJAfY1hl-3VoC;Smv7*GtK_R`4eLTqBm0GFTM@{dTd9SXn%DcJU&jhlpoI z1I+`ake^(&8EV`7v?1H(iQsUZ3)Ak`33iuxmJK#}fP{2V1Y4}wY}!onPph9wr0Alh z;0R!vs=JH)CQCM^b%bgCCD(-c0jw}VS$GlUN-m+^(AI%d#u(0!2{tjU3=$cmz-|0O zC+K0#zAXhSOK<4#{mn}8dXPe}{YxB<70y}dZDv#ni@dw+vzEA)EGGW7lt z&96+7cKL>_BaUs_W4lhw;9T)pL4yxY4YygU4(FwJ84M0sj9V7X$E18Ptf^&)(f+%R UR~H7DwnDne3htt, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-01-16 12:27+0000\n" +"PO-Revision-Date: 2023-04-11 16:06+0700\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"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" + +#: tools/preview/cli.py:14 +msgid "This command allows you to preview swaps to tweak convert settings." +msgstr "" +"Эта команда позволяет просматривать замены для настройки параметров " +"конвертирования." + +#: tools/preview/cli.py:29 +msgid "" +"Preview tool\n" +"Allows you to configure your convert settings with a live preview" +msgstr "" +"Инструмент предпросмотра\n" +"Позволяет настраивать параметры конвертации с помощью предварительного " +"просмотра в реальном времени" + +#: tools/preview/cli.py:46 tools/preview/cli.py:55 tools/preview/cli.py:62 +msgid "data" +msgstr "данные" + +#: tools/preview/cli.py:48 +msgid "" +"Input directory or video. Either a directory containing the image files you " +"wish to process or path to a video file." +msgstr "" +"Входная папка или видео. Либо папка, содержащая файлы изображений, которые " +"необходимо обработать, либо путь к видеофайлу." + +#: tools/preview/cli.py:57 +msgid "" +"Path to the alignments file for the input, if not at the default location" +msgstr "" +"Путь к файлу выравниваний для входных данных, если он не находится в месте " +"по умолчанию" + +#: tools/preview/cli.py:64 +msgid "" +"Model directory. A directory containing the trained model you wish to " +"process." +msgstr "" +"Папка модели. Папка, содержащая обученную модель, которую вы хотите " +"обработать." + +#: tools/preview/cli.py:71 +msgid "Swap the model. Instead of A -> B, swap B -> A" +msgstr "Поменять местами модели. Вместо A -> B заменить B -> A" + +#: tools/preview/control_panels.py:496 +msgid "Save full config" +msgstr "Сохранить полную конфигурацию" + +#: tools/preview/control_panels.py:499 +msgid "Reset full config to default values" +msgstr "Сбросить полную конфигурацию до заводских значений" + +#: tools/preview/control_panels.py:502 +msgid "Reset full config to saved values" +msgstr "Сбросить полную конфигурацию до сохраненных значений" + +#: tools/preview/control_panels.py:653 +#, python-brace-format +msgid "Save {title} config" +msgstr "Сохранить конфигурацию {title}" + +#: tools/preview/control_panels.py:656 +#, python-brace-format +msgid "Reset {title} config to default values" +msgstr "Сбросить полную конфигурацию {title} до заводских значений" + +#: tools/preview/control_panels.py:659 +#, python-brace-format +msgid "Reset {title} config to saved values" +msgstr "Сбросить полную конфигурацию {title} до сохраненных значений" diff --git a/locales/ru/LC_MESSAGES/tools.sort.cli.mo b/locales/ru/LC_MESSAGES/tools.sort.cli.mo new file mode 100644 index 0000000000000000000000000000000000000000..32cc570ea893ff14f1446855ca2c12a1355eb209 GIT binary patch literal 20597 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 K=9tNlE&4x5HqIab literal 0 HcmV?d00001 diff --git a/locales/ru/LC_MESSAGES/tools.sort.cli.po b/locales/ru/LC_MESSAGES/tools.sort.cli.po new file mode 100644 index 0000000000..8a64293d84 --- /dev/null +++ b/locales/ru/LC_MESSAGES/tools.sort.cli.po @@ -0,0 +1,388 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-11-24 14:19+0900\n" +"PO-Revision-Date: 2023-04-11 16:24+0700\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"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" + +#: tools/sort/cli.py:14 +msgid "This command lets you sort images using various methods." +msgstr "Эта команда позволяет сортировать изображения различными методами." + +#: tools/sort/cli.py:20 +msgid "" +" Adjust the '-t' ('--threshold') parameter to control the strength of " +"grouping." +msgstr "" +" Настройте параметр '-t' ('--threshold') для контроля силы группировки." + +#: tools/sort/cli.py:21 +msgid "" +" Adjust the '-b' ('--bins') parameter to control the number of bins for " +"grouping. Each image is allocated to a bin by the percentage of color pixels " +"that appear in the image." +msgstr "" +" Настройте параметр '-b' ('--bins') для управления количеством корзинок для " +"группировки. Каждое изображение распределяется по корзинкам в зависимости от " +"процента цветных пикселей, присутствующих в изображении." + +#: tools/sort/cli.py:24 +msgid "" +" Adjust the '-b' ('--bins') parameter to control the number of bins for " +"grouping. Each image is allocated to a bin by the number of degrees the face " +"is orientated from center." +msgstr "" +" Настройте параметр '-b' ('--bins') для управления количеством корзинок для " +"группировки. Каждое изображение распределяется по корзинам по количеству " +"градусов, на которые лицо ориентировано от центра." + +#: tools/sort/cli.py:27 +msgid "" +" Adjust the '-b' ('--bins') parameter to control the number of bins for " +"grouping. The minimum and maximum values are taken for the chosen sort " +"metric. The bins are then populated with the results from the group sorting." +msgstr "" +" Настройте параметр '-b' ('--bins') для управления количеством корзинок для " +"группировки. Для выбранной метрики сортировки берутся минимальное и " +"максимальное значения. Затем корзины заполняются результатами групповой " +"сортировки." + +#: tools/sort/cli.py:31 +msgid "faces by blurriness." +msgstr "лица по размытости." + +#: tools/sort/cli.py:32 +msgid "faces by fft filtered blurriness." +msgstr "лица по размытости с фильтрацией fft." + +#: tools/sort/cli.py:33 +msgid "" +"faces by the estimated distance of the alignments from an 'average' face. " +"This can be useful for eliminating misaligned faces. Sorts from most like an " +"average face to least like an average face." +msgstr "" +"лица по оценочному расстоянию выравнивания от \"среднего\" лица. Это может " +"быть полезно для устранения неправильно расположенных лиц. Сортирует от " +"наиболее похожего на среднее лицо к наименее похожему на среднее лицо." + +#: tools/sort/cli.py:36 +msgid "" +"faces using VGG Face2 by face similarity. This uses a pairwise clustering " +"algorithm to check the distances between 512 features on every face in your " +"set and order them appropriately." +msgstr "" +"лиц с помощью VGG Face2 по сходству лиц. При этом используется алгоритм " +"парной кластеризации для проверки расстояний между 512 признаками на каждом " +"лице в вашем наборе и их упорядочивания соответствующим образом." + +#: tools/sort/cli.py:39 +msgid "faces by their landmarks." +msgstr "лица по их ориентирам." + +#: tools/sort/cli.py:40 +msgid "Like 'face-cnn' but sorts by dissimilarity." +msgstr "Как 'face-cnn', но сортирует по непохожести." + +#: tools/sort/cli.py:41 +msgid "faces by Yaw (rotation left to right)." +msgstr "лица по Yaw (вращение слева направо)." + +#: tools/sort/cli.py:42 +msgid "faces by Pitch (rotation up and down)." +msgstr "лица по Pitch (вращение вверх и вниз)." + +#: tools/sort/cli.py:43 +msgid "" +"faces by Roll (rotation). Aligned faces should have a roll value close to " +"zero. The further the Roll value from zero the higher liklihood the face is " +"misaligned." +msgstr "" +"грани по Roll (повороту). Выровненные грани должны иметь значение Roll, " +"близкое к нулю. Чем дальше значение Roll от нуля, тем выше вероятность того, " +"что лицо неправильно выровнено." + +#: tools/sort/cli.py:45 +msgid "faces by their color histogram." +msgstr "лица по их цветовой гистограмме." + +#: tools/sort/cli.py:46 +msgid "Like 'hist' but sorts by dissimilarity." +msgstr "Как 'hist', но сортирует по непохожести." + +#: tools/sort/cli.py:47 +msgid "" +"images by the average intensity of the converted grayscale color channel." +msgstr "" +"изображения по средней интенсивности преобразованного полутонового цветового " +"канала." + +#: tools/sort/cli.py:48 +msgid "" +"images by their number of black pixels. Useful when faces are near borders " +"and a large part of the image is black." +msgstr "" +"изображения по количеству черных пикселей. Полезно, когда лица находятся " +"вблизи границ и большая часть изображения черная." + +#: tools/sort/cli.py:50 +msgid "" +"images by the average intensity of the converted Y color channel. Bright " +"lighting and oversaturated images will be ranked first." +msgstr "" +"изображений по средней интенсивности преобразованного цветового канала Y. " +"Яркое освещение и перенасыщенные изображения будут ранжироваться в первую " +"очередь." + +#: tools/sort/cli.py:52 +msgid "" +"images by the average intensity of the converted Cg color channel. Green " +"images will be ranked first and red images will be last." +msgstr "" +"изображений по средней интенсивности преобразованного цветового канала Cg. " +"Зеленые изображения занимают первое место, а красные - последнее." + +#: tools/sort/cli.py:54 +msgid "" +"images by the average intensity of the converted Co color channel. Orange " +"images will be ranked first and blue images will be last." +msgstr "" +"изображений по средней интенсивности преобразованного цветового канала Co. " +"Оранжевые изображения занимают первое место, а синие - последнее." + +#: tools/sort/cli.py:56 +msgid "" +"images by their size in the original frame. Faces further from the camera " +"and from lower resolution sources will be sorted first, whilst faces closer " +"to the camera and from higher resolution sources will be sorted last." +msgstr "" +"изображения по их размеру в исходном кадре. Лица, расположенные дальше от " +"камеры и полученные из источников с низким разрешением, будут отсортированы " +"первыми, а лица, расположенные ближе к камере и полученные из источников с " +"высоким разрешением, будут отсортированы последними." + +#: tools/sort/cli.py:59 +msgid " option is deprecated. Use 'yaw'" +msgstr " является устаревшей. Используйте 'yaw'" + +#: tools/sort/cli.py:60 +msgid " option is deprecated. Use 'color-black'" +msgstr " является устаревшей. Используйте 'color-black'" + +#: tools/sort/cli.py:82 +msgid "Sort faces using a number of different techniques" +msgstr "Сортировка лиц с использованием различных методов" + +#: tools/sort/cli.py:92 tools/sort/cli.py:99 tools/sort/cli.py:110 +#: tools/sort/cli.py:148 +msgid "data" +msgstr "данные" + +#: tools/sort/cli.py:93 +msgid "Input directory of aligned faces." +msgstr "Входная папка соотнесенных лиц." + +#: tools/sort/cli.py:100 +msgid "" +"Output directory for sorted aligned faces. If not provided and 'keep' is " +"selected then a new folder called 'sorted' will be created within the input " +"folder to house the output. If not provided and 'keep' is not selected then " +"the images will be sorted in-place, overwriting the original contents of the " +"'input_dir'" +msgstr "" +"Выходная папка для отсортированных выровненных лиц. Если не указано и " +"выбрано 'keep', то в папке input будет создана новая папка под названием " +"'sorted' для размещения выходных данных. Если не указано и не выбрано " +"'keep', то изображения будут отсортированы на месте, перезаписывая исходное " +"содержимое 'input_dir'." + +#: tools/sort/cli.py:111 +msgid "" +"R|If selected then the input_dir should be a parent folder containing " +"multiple folders of faces you wish to sort. The faces will be output to " +"separate sub-folders in the output_dir" +msgstr "" +"R|Если выбрано, то input_dir должен быть родительской папкой, содержащей " +"несколько папок с лицами, которые вы хотите отсортировать. Лица будут " +"выведены в отдельные вложенные папки в output_dir" + +#: tools/sort/cli.py:120 +msgid "sort settings" +msgstr "настройки сортировки" + +#: tools/sort/cli.py:122 +msgid "" +"R|Choose how images are sorted. Selecting a sort method gives the images a " +"new filename based on the order the image appears within the given method.\n" +"L|'none': Don't sort the images. When a 'group-by' method is selected, " +"selecting 'none' means that the files will be moved/copied into their " +"respective bins, but the files will keep their original filenames. Selecting " +"'none' for both 'sort-by' and 'group-by' will do nothing" +msgstr "" +"R|Выбор способа сортировки изображений. При выборе метода сортировки " +"изображениям присваивается новое имя файла, основанное на порядке появления " +"изображения в данном методе.\n" +"L|'none': Не сортировать изображения. Если выбран метод 'group-by', выбор " +"'none' означает, что файлы будут перемещены/скопированы в соответствующие " +"корзины, но файлы сохранят свои оригинальные имена. Выбор значения 'none' " +"как для 'sort-by', так и для 'group-by' ничего не даст" + +#: tools/sort/cli.py:135 tools/sort/cli.py:162 tools/sort/cli.py:191 +msgid "group settings" +msgstr "настройки группировки" + +#: tools/sort/cli.py:137 +msgid "" +"R|Selecting a group by method will move/copy files into numbered bins based " +"on the selected method.\n" +"L|'none': Don't bin the images. Folders will be sorted by the selected 'sort-" +"by' but will not be binned, instead they will be sorted into a single " +"folder. Selecting 'none' for both 'sort-by' and 'group-by' will do nothing" +msgstr "" +"R|Выбор группы по методу приведет к перемещению/копированию файлов в " +"пронумерованные корзины в соответствии с выбранным методом.\n" +"L|'none': Не сортировать изображения. Папки будут отсортированы по " +"выбранному \"sort-by\", но не будут разбиты на папки, вместо этого они будут " +"отсортированы в одну папку. Выбор значения 'none' как для 'sort-by', так и " +"для 'group-by' ничего не даст" + +#: tools/sort/cli.py:149 +msgid "" +"Whether to keep the original files in their original location. Choosing a " +"'sort-by' method means that the files have to be renamed. Selecting 'keep' " +"means that the original files will be kept, and the renamed files will be " +"created in the specified output folder. Unselecting keep means that the " +"original files will be moved and renamed based on the selected sort/group " +"criteria." +msgstr "" +"Сохранять ли исходные файлы в их первоначальном расположении. Выбор метода " +"\"сортировать по\" означает, что файлы должны быть переименованы. Выбор " +"'keep' означает, что исходные файлы будут сохранены, а переименованные файлы " +"будут созданы в указанной выходной папке. Отмена выбора \"keep\" означает, " +"что исходные файлы будут перемещены и переименованы в соответствии с " +"выбранными критериями сортировки/группировки." + +#: tools/sort/cli.py:164 +msgid "" +"R|Float value. Minimum threshold to use for grouping comparison with 'face-" +"cnn' 'hist' and 'face' methods.\n" +"The lower the value the more discriminating the grouping is. Leaving -1.0 " +"will allow Faceswap to choose the default value.\n" +"L|For 'face-cnn' 7.2 should be enough, with 4 being very discriminating. \n" +"L|For 'hist' 0.3 should be enough, with 0.2 being very discriminating. \n" +"L|For 'face' between 0.1 (more bins) to 0.5 (fewer bins) should be about " +"right.\n" +"Be careful setting a value that's too extrene in a directory with many " +"images, as this could result in a lot of folders being created. Defaults: " +"face-cnn 7.2, hist 0.3, face 0.25" +msgstr "" +"R|Плавающее значение. Минимальный порог, используемый для сравнения " +"группировок с методами 'face-cnn' 'hist' и 'face'.\n" +"Чем меньше значение, тем более дискриминационной является группировка. Если " +"оставить значение -1.0, Faceswap сможет выбрать значение по умолчанию.\n" +"L|Для 'face-cnn' 7,2 должно быть достаточно, при этом 4 будет очень " +"дискриминационным. \n" +"L|Для 'hist' 0.3 должно быть достаточно, при этом 0.2 очень хорошо " +"различает. \n" +"L|For 'face' от 0,1 (больше бинов) до 0,5 (меньше бинов) должно быть " +"достаточно.\n" +"Будьте осторожны, устанавливая слишком большое значение в каталоге с большим " +"количеством изображений, так как это может привести к созданию большого " +"количества папок. По умолчанию: face-cnn 7.2, hist 0.3, face 0.25" + +#: tools/sort/cli.py:181 +msgid "output" +msgstr "вывод" + +#: tools/sort/cli.py:182 +msgid "" +"Deprecated and no longer used. The final processing will be dictated by the " +"sort/group by methods and whether 'keep_original' is selected." +msgstr "" +"Устарело и больше не используется. Окончательная обработка будет диктоваться " +"методами sort/group by и тем, выбрана ли опция 'keep_original'." + +#: tools/sort/cli.py:193 +#, python-format +msgid "" +"R|Integer value. Used to control the number of bins created for grouping by: " +"any 'blur' methods, 'color' methods or 'face metric' methods ('distance', " +"'size') and 'orientation; methods ('yaw', 'pitch'). For any other grouping " +"methods see the '-t' ('--threshold') option.\n" +"L|For 'face metric' methods the bins are filled, according the the " +"distribution of faces between the minimum and maximum chosen metric.\n" +"L|For 'color' methods the number of bins represents the divider of the " +"percentage of colored pixels. Eg. For a bin number of '5': The first folder " +"will have the faces with 0%% to 20%% colored pixels, second 21%% to 40%%, " +"etc. Any empty bins will be deleted, so you may end up with fewer bins than " +"selected.\n" +"L|For 'blur' methods folder 0 will be the least blurry, while the last " +"folder will be the blurriest.\n" +"L|For 'orientation' methods the number of bins is dictated by how much 180 " +"degrees is divided. Eg. If 18 is selected, then each folder will be a 10 " +"degree increment. Folder 0 will contain faces looking the most to the left/" +"down whereas the last folder will contain the faces looking the most to the " +"right/up. NB: Some bins may be empty if faces do not fit the criteria.\n" +"Default value: 5" +msgstr "" +"R| Целочисленное значение. Используется для управления количеством бинов, " +"создаваемых для группировки: любыми методами 'размытия', 'цвета' или " +"методами 'метрики лица' ('расстояние', 'размер') и 'ориентации; методы " +"('yaw', 'pitch'). Для любых других методов группировки смотрите опцию '-" +"t' ('--threshold').\n" +"L|Для методов 'face metric' бины заполняются в соответствии с распределением " +"лиц между минимальной и максимальной выбранной метрикой.\n" +"L|Для методов 'color' количество бинов представляет собой делитель процента " +"цветных пикселей. Например, для числа бинов \"5\": В первой папке будут лица " +"с 0%% - 20%% цветных пикселей, во второй 21%% - 40%% и т.д. Все пустые папки " +"будут удалены, поэтому в итоге у вас может оказаться меньше папок, чем было " +"выбрано.\n" +"L|Для методов 'blur' папка 0 будет наименее размытой, а последняя папка " +"будет самой размытой.\n" +"L|Для методов \"orientation\" количество бинов диктуется тем, на сколько " +"делится 180 градусов. Например, если выбрано 18, то каждая папка будет иметь " +"шаг в 10 градусов. Папка 0 будет содержать лица, направленные больше всего " +"влево/вниз, а последняя папка будет содержать лица, направленные больше " +"всего вправо/вверх. Примечание: Некоторые папки могут быть пустыми, если " +"лица не соответствуют критериям.\n" +"Значение по умолчанию: 5" + +#: tools/sort/cli.py:215 tools/sort/cli.py:225 +msgid "settings" +msgstr "настройки" + +#: tools/sort/cli.py:217 +msgid "" +"Logs file renaming changes if grouping by renaming, or it logs the file " +"copying/movement if grouping by folders. If no log file is specified with " +"'--log-file', then a 'sort_log.json' file will be created in the input " +"directory." +msgstr "" +"Ведет журнал изменений переименования файлов при группировке по " +"переименованию, или журнал копирования/перемещения файлов при группировке по " +"папкам. Если файл журнала не указан с помощью '--log-file', то в каталоге " +"ввода будет создан файл 'sort_log.json'." + +#: tools/sort/cli.py:228 +msgid "" +"Specify a log file to use for saving the renaming or grouping information. " +"If specified extension isn't 'json' or 'yaml', then json will be used as the " +"serializer, with the supplied filename. Default: sort_log.json" +msgstr "" +"Укажите файл журнала, который будет использоваться для сохранения информации " +"о переименовании или группировке. Если указанное расширение не 'json' или " +"'yaml', то в качестве сериализатора будет использоваться json, с указанным " +"именем файла. По умолчанию: sort_log.json" From 013f3016f1bf81d502d52fad79a716aabf6b649a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 6 Jun 2023 19:09:45 +0100 Subject: [PATCH 813/981] bugfix: Locales for Windows --- .pylintrc | 4 ++-- faceswap.py | 17 +++++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.pylintrc b/.pylintrc index 67b3264633..3d669e8927 100644 --- a/.pylintrc +++ b/.pylintrc @@ -488,5 +488,5 @@ known-third-party=enchant # Exceptions that will emit a warning when being caught. Defaults to # "BaseException, Exception". -overgeneral-exceptions=BaseException, - Exception +overgeneral-exceptions=builtins.BaseException, + builtins.Exception diff --git a/faceswap.py b/faceswap.py index b8857eb697..c644e9cddd 100755 --- a/faceswap.py +++ b/faceswap.py @@ -1,22 +1,27 @@ #!/usr/bin/env python3 """ The master faceswap.py script """ import gettext +import locale +import os import sys -from lib.cli import args as cli_args -from lib.config import generate_configs -from lib.utils import get_backend +# Translations don't work by default in Windows, so hack in environment variable +if sys.platform.startswith("win"): + os.environ["LANG"], _ = locale.getdefaultlocale() +from lib.cli import args as cli_args # pylint:disable=wrong-import-position +from lib.config import generate_configs # pylint:disable=wrong-import-position +from lib.utils import get_backend # pylint:disable=wrong-import-position # LOCALES _LANG = gettext.translation("faceswap", localedir="locales", fallback=True) _ = _LANG.gettext - if sys.version_info < (3, 7): - raise Exception("This program requires at least python3.7") + raise ValueError("This program requires at least python3.7") if get_backend() == "amd" and sys.version_info >= (3, 9): - raise Exception("The AMD version of Faceswap cannot run on versions of Python higher than 3.8") + raise ValueError("The AMD version of Faceswap cannot run on versions of Python higher " + "than 3.8") _PARSER = cli_args.FullHelpArgumentParser() From 50a5113690204e4134c0cabe1cf28e22a6f1f09a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 7 Jun 2023 14:16:27 +0100 Subject: [PATCH 814/981] locales: Add GUI menu translations --- docs/full/lib/gui.rst | 7 + lib/gui/menu.py | 578 +++++++++++++++---------- locales/es/LC_MESSAGES/gui.menu.mo | Bin 0 -> 1383 bytes locales/es/LC_MESSAGES/gui.menu.po | 155 +++++++ locales/es/LC_MESSAGES/gui.tooltips.mo | Bin 5282 -> 4330 bytes locales/es/LC_MESSAGES/gui.tooltips.po | 62 +-- locales/gui.menu.pot | 154 +++++++ locales/gui.tooltips.pot | 56 --- locales/kr/LC_MESSAGES/gui.menu.mo | Bin 0 -> 1370 bytes locales/kr/LC_MESSAGES/gui.menu.po | 155 +++++++ locales/kr/LC_MESSAGES/gui.tooltips.mo | Bin 5856 -> 4871 bytes locales/kr/LC_MESSAGES/gui.tooltips.po | 60 +-- locales/ru/LC_MESSAGES/gui.menu.mo | Bin 0 -> 1604 bytes locales/ru/LC_MESSAGES/gui.menu.po | 156 +++++++ locales/ru/LC_MESSAGES/gui.tooltips.mo | Bin 7538 -> 6394 bytes locales/ru/LC_MESSAGES/gui.tooltips.po | 60 +-- 16 files changed, 974 insertions(+), 469 deletions(-) create mode 100644 locales/es/LC_MESSAGES/gui.menu.mo create mode 100644 locales/es/LC_MESSAGES/gui.menu.po create mode 100644 locales/gui.menu.pot create mode 100644 locales/kr/LC_MESSAGES/gui.menu.mo create mode 100644 locales/kr/LC_MESSAGES/gui.menu.po create mode 100644 locales/ru/LC_MESSAGES/gui.menu.mo create mode 100644 locales/ru/LC_MESSAGES/gui.menu.po diff --git a/docs/full/lib/gui.rst b/docs/full/lib/gui.rst index cce3d1b8e6..69a5a9ceaf 100755 --- a/docs/full/lib/gui.rst +++ b/docs/full/lib/gui.rst @@ -104,6 +104,13 @@ display\_graph module :undoc-members: :show-inheritance: +menu module +=========== +.. automodule:: lib.gui.menu + :members: + :undoc-members: + :show-inheritance: + popup_configure module ====================== .. automodule:: lib.gui.popup_configure diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 019010c880..0677c31f43 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -7,13 +7,14 @@ import os import sys import tkinter as tk +import typing as T from tkinter import ttk import webbrowser from subprocess import Popen, PIPE, STDOUT from lib.multithreading import MultiThread -from lib.serializer import get_serializer +from lib.serializer import get_serializer, Serializer from lib.utils import FaceswapError import update_deps @@ -21,23 +22,33 @@ from .custom_widgets import Tooltip from .utils import get_config, get_images +if T.TYPE_CHECKING: + from scripts.gui import FaceswapGui + logger = logging.getLogger(__name__) # pylint: disable=invalid-name # LOCALES -_LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) +_LANG = gettext.translation("gui.menu", localedir="locales", fallback=True) _ = _LANG.gettext _WORKING_DIR = os.path.dirname(os.path.realpath(sys.argv[0])) -_RESOURCES = [(_("faceswap.dev - Guides and Forum"), "https://www.faceswap.dev"), - (_("Patreon - Support this project"), "https://www.patreon.com/faceswap"), - (_("Discord - The FaceSwap Discord server"), "https://discord.gg/VasFUAy"), - (_("Github - Our Source Code"), "https://github.com/deepfakes/faceswap")] +_RESOURCES: T.List[T.Tuple[str, str]] = [ + (_("faceswap.dev - Guides and Forum"), "https://www.faceswap.dev"), + (_("Patreon - Support this project"), "https://www.patreon.com/faceswap"), + (_("Discord - The FaceSwap Discord server"), "https://discord.gg/VasFUAy"), + (_("Github - Our Source Code"), "https://github.com/deepfakes/faceswap")] class MainMenuBar(tk.Menu): # pylint:disable=too-many-ancestors - """ GUI Main Menu Bar """ - def __init__(self, master=None): + """ GUI Main Menu Bar + + Parameters + ---------- + master: :class:`tkinter.Tk` + The root tkinter object + """ + def __init__(self, master: "FaceswapGui") -> None: logger.debug("Initializing %s", self.__class__.__name__) super().__init__(master) self.root = master @@ -46,99 +57,130 @@ def __init__(self, master=None): self.settings_menu = SettingsMenu(self) self.help_menu = HelpMenu(self) - self.add_cascade(label="File", menu=self.file_menu, underline=0) - self.add_cascade(label="Settings", menu=self.settings_menu, underline=0) - self.add_cascade(label="Help", menu=self.help_menu, underline=0) + self.add_cascade(label=_("File"), menu=self.file_menu, underline=0) + self.add_cascade(label=_("Settings"), menu=self.settings_menu, underline=0) + self.add_cascade(label=_("Help"), menu=self.help_menu, underline=0) logger.debug("Initialized %s", self.__class__.__name__) class SettingsMenu(tk.Menu): # pylint:disable=too-many-ancestors - """ Settings menu items and functions """ - def __init__(self, parent): + """ Settings menu items and functions + + Parameters + ---------- + parent: :class:`tkinter.Menu` + The main menu bar to hold this menu item + """ + def __init__(self, parent: MainMenuBar) -> None: logger.debug("Initializing %s", self.__class__.__name__) super().__init__(parent, tearoff=0) self.root = parent.root - self.build() + self._build() logger.debug("Initialized %s", self.__class__.__name__) - def build(self): + def _build(self) -> None: """ Add the settings menu to the menu bar """ # pylint: disable=cell-var-from-loop logger.debug("Building settings menu") - self.add_command(label="Configure Settings...", + self.add_command(label=_("Configure Settings..."), underline=0, command=open_popup) logger.debug("Built settings menu") class FileMenu(tk.Menu): # pylint:disable=too-many-ancestors - """ File menu items and functions """ - def __init__(self, parent): + """ File menu items and functions + + Parameters + ---------- + parent: :class:`tkinter.Menu` + The main menu bar to hold this menu item + """ + def __init__(self, parent: MainMenuBar) -> None: logger.debug("Initializing %s", self.__class__.__name__) super().__init__(parent, tearoff=0) self.root = parent.root self._config = get_config() - self.recent_menu = tk.Menu(self, tearoff=0, postcommand=self.refresh_recent_menu) - self.build() + self.recent_menu = tk.Menu(self, tearoff=0, postcommand=self._refresh_recent_menu) + self._build() logger.debug("Initialized %s", self.__class__.__name__) - def build(self): + def _refresh_recent_menu(self) -> None: + """ Refresh recent menu on save/load of files """ + self.recent_menu.delete(0, "end") + self._build_recent_menu() + + def _build(self) -> None: """ Add the file menu to the menu bar """ logger.debug("Building File menu") - self.add_command(label="New Project...", + self.add_command(label=_("New Project..."), underline=0, accelerator="Ctrl+N", command=self._config.project.new) self.root.bind_all("", self._config.project.new) - self.add_command(label="Open Project...", + self.add_command(label=_("Open Project..."), underline=0, accelerator="Ctrl+O", command=self._config.project.load) self.root.bind_all("", self._config.project.load) - self.add_command(label="Save Project", + self.add_command(label=_("Save Project"), underline=0, accelerator="Ctrl+S", command=lambda: self._config.project.save(save_as=False)) self.root.bind_all("", lambda e: self._config.project.save(e, save_as=False)) - self.add_command(label="Save Project as...", + self.add_command(label=_("Save Project as..."), underline=13, accelerator="Ctrl+Alt+S", command=lambda: self._config.project.save(save_as=True)) self.root.bind_all("", lambda e: self._config.project.save(e, save_as=True)) - self.add_command(label="Reload Project from Disk", + self.add_command(label=_("Reload Project from Disk"), underline=0, accelerator="F5", command=self._config.project.reload) self.root.bind_all("", self._config.project.reload) - self.add_command(label="Close Project", + self.add_command(label=_("Close Project"), underline=0, accelerator="Ctrl+W", command=self._config.project.close) self.root.bind_all("", self._config.project.close) self.add_separator() - self.add_command(label="Open Task...", + self.add_command(label=_("Open Task..."), underline=5, accelerator="Ctrl+Alt+T", command=lambda: self._config.tasks.load(current_tab=False)) self.root.bind_all("", lambda e: self._config.tasks.load(e, current_tab=False)) self.add_separator() - self.add_cascade(label="Open recent", underline=6, menu=self.recent_menu) + self.add_cascade(label=_("Open recent"), underline=6, menu=self.recent_menu) self.add_separator() - self.add_command(label="Quit", + self.add_command(label=_("Quit"), underline=0, accelerator="Alt+F4", command=self.root.close_app) self.root.bind_all("", self.root.close_app) logger.debug("Built File menu") - def build_recent_menu(self): + @classmethod + def _clear_recent_files(cls, serializer: Serializer, menu_file: str) -> None: + """ Creates or clears recent file list + + Parameters + ---------- + serializer: :class:`~lib.serializer.Serializer` + The serializer to use for storing files + menu_file: str + The file name holding the recent files + """ + logger.debug("clearing recent files list: '%s'", menu_file) + serializer.save(menu_file, []) + + def _build_recent_menu(self) -> None: """ Load recent files into menu bar """ logger.debug("Building Recent Files menu") serializer = get_serializer("json") menu_file = os.path.join(self._config.pathcache, ".recent.json") if not os.path.isfile(menu_file) or os.path.getsize(menu_file) == 0: - self.clear_recent_files(serializer, menu_file) + self._clear_recent_files(serializer, menu_file) try: recent_files = serializer.load(menu_file) except FaceswapError as err: @@ -146,7 +188,7 @@ def build_recent_menu(self): # Some reports of corruption breaking menus logger.warning("There was an error opening the recent files list so it has been " "reset.") - self.clear_recent_files(serializer, menu_file) + self._clear_recent_files(serializer, menu_file) recent_files = [] logger.debug("Loaded recent files: %s", recent_files) @@ -163,14 +205,14 @@ def build_recent_menu(self): if command.lower() == "project": load_func = self._config.project.load lbl = command - kwargs = dict(filename=filename) + kwargs = {"filename": filename} else: - load_func = self._config.tasks.load - lbl = f"{command} Task" - kwargs = dict(filename=filename, current_tab=False) + load_func = self._config.tasks.load # type:ignore + lbl = _("{} Task").format(command) + kwargs = {"filename": filename, "current_tab": False} self.recent_menu.add_command( label=f"{filename} ({lbl.title()})", - command=lambda kw=kwargs, fn=load_func: fn(**kw)) + command=lambda kw=kwargs, fn=load_func: fn(**kw)) # type:ignore if removed_files: for recent_item in removed_files: logger.debug("Removing from recent files: `%s`", recent_item[0]) @@ -178,57 +220,193 @@ def build_recent_menu(self): serializer.save(menu_file, recent_files) self.recent_menu.add_separator() self.recent_menu.add_command( - label="Clear recent files", + label=_("Clear recent files"), underline=0, - command=lambda srl=serializer, mnu=menu_file: self.clear_recent_files(srl, mnu)) + command=lambda srl=serializer, mnu=menu_file: self._clear_recent_files( # type:ignore + srl, mnu)) logger.debug("Built Recent Files menu") - @staticmethod - def clear_recent_files(serializer, menu_file): - """ Creates or clears recent file list """ - logger.debug("clearing recent files list: '%s'", menu_file) - serializer.save(menu_file, []) - - def refresh_recent_menu(self): - """ Refresh recent menu on save/load of files """ - self.recent_menu.delete(0, "end") - self.build_recent_menu() - class HelpMenu(tk.Menu): # pylint:disable=too-many-ancestors - """ Help menu items and functions """ - def __init__(self, parent): + """ Help menu items and functions + + Parameters + ---------- + parent: :class:`tkinter.Menu` + The main menu bar to hold this menu item + """ + def __init__(self, parent: MainMenuBar) -> None: logger.debug("Initializing %s", self.__class__.__name__) super().__init__(parent, tearoff=0) self.root = parent.root self.recources_menu = tk.Menu(self, tearoff=0) self._branches_menu = tk.Menu(self, tearoff=0) - self.build() + self._build() logger.debug("Initialized %s", self.__class__.__name__) - def build(self): + def _in_thread(self, action: str): + """ Perform selected action inside a thread + + Parameters + ---------- + action: str + The action to be performed. The action corresponds to the function name to be called + """ + logger.debug("Performing help action: %s", action) + thread = MultiThread(getattr(self, action), thread_count=1) + thread.start() + logger.debug("Performed help action: %s", action) + + def _output_sysinfo(self): + """ Output system information to console """ + logger.debug("Obtaining system information") + self.root.config(cursor="watch") + self._clear_console() + try: + from lib.sysinfo import sysinfo # pylint:disable=import-outside-toplevel + info = sysinfo + except Exception as err: # pylint:disable=broad-except + info = f"Error obtaining system info: {str(err)}" + self._clear_console() + logger.debug("Obtained system information: %s", info) + print(info) + self.root.config(cursor="") + + @classmethod + def _check_for_updates(cls, encoding: str, check: bool = False) -> bool: + """ Check whether an update is required + + Parameters + ---------- + encoding: str + The encoding to use for decoding process returns + check: bool + ``True`` if we are just checking for updates ``False`` if a check and update is to be + performed. Default: ``False`` + + Returns + ------- + bool + ``True`` if an update is required + """ + # Do the check + logger.info("Checking for updates...") + update = False + msg = "" + gitcmd = "git remote update && git status -uno" + with Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, cwd=_WORKING_DIR) as cmd: + stdout, _ = cmd.communicate() + retcode = cmd.poll() + if retcode != 0: + msg = ("Git is not installed or you are not running a cloned repo. " + "Unable to check for updates") + else: + chk = stdout.decode(encoding, errors="replace").splitlines() + for line in chk: + if line.lower().startswith("your branch is ahead"): + msg = "Your branch is ahead of the remote repo. Not updating" + break + if line.lower().startswith("your branch is up to date"): + msg = "Faceswap is up to date." + break + if line.lower().startswith("your branch is behind"): + msg = "There are updates available" + update = True + break + if "have diverged" in line.lower(): + msg = "Your branch has diverged from the remote repo. Not updating" + break + if not update or check: + logger.info(msg) + logger.debug("Checked for update. Update required: %s", update) + return update + + def _check(self) -> None: + """ Check for updates and clone repository """ + logger.debug("Checking for updates...") + self.root.config(cursor="watch") + encoding = locale.getpreferredencoding() + logger.debug("Encoding: %s", encoding) + self._check_for_updates(encoding, check=True) + self.root.config(cursor="") + + @classmethod + def _do_update(cls, encoding: str) -> bool: + """ Update Faceswap + + Parameters + ---------- + encoding: str + The encoding to use for decoding process returns + + Returns + ------- + bool + ``True`` if update was successful + """ + logger.info("A new version is available. Updating...") + gitcmd = "git pull" + with Popen(gitcmd, + shell=True, + stdout=PIPE, + stderr=STDOUT, + bufsize=1, + cwd=_WORKING_DIR) as cmd: + while True: + out = cmd.stdout + output = "" if out is None else out.readline().decode(encoding, errors="replace") + if output == "" and cmd.poll() is not None: + break + if output: + logger.debug("'%s' output: '%s'", gitcmd, output.strip()) + print(output.strip()) + retcode = cmd.poll() + logger.debug("'%s' returncode: %s", gitcmd, retcode) + if retcode != 0: + logger.info("An error occurred during update. return code: %s", retcode) + retval = False + else: + retval = True + return retval + + def _update(self) -> None: + """ Check for updates and clone repository """ + logger.debug("Updating Faceswap...") + self.root.config(cursor="watch") + encoding = locale.getpreferredencoding() + logger.debug("Encoding: %s", encoding) + success = False + if self._check_for_updates(encoding): + success = self._do_update(encoding) + update_deps.main(is_gui=True) + if success: + logger.info("Please restart Faceswap to complete the update.") + self.root.config(cursor="") + + def _build(self) -> None: """ Build the help menu """ logger.debug("Building Help menu") - self.add_command(label="Check for updates...", + self.add_command(label=_("Check for updates..."), underline=0, - command=lambda action="check": self.in_thread(action)) - self.add_command(label="Update Faceswap...", + command=lambda action="_check": self._in_thread(action)) # type:ignore + self.add_command(label=_("Update Faceswap..."), underline=0, - command=lambda action="update": self.in_thread(action)) + command=lambda action="_update": self._in_thread(action)) # type:ignore if self._build_branches_menu(): - self.add_cascade(label="Switch Branch", underline=7, menu=self._branches_menu) + self.add_cascade(label=_("Switch Branch"), underline=7, menu=self._branches_menu) self.add_separator() self._build_recources_menu() - self.add_cascade(label="Resources", underline=0, menu=self.recources_menu) + self.add_cascade(label=_("Resources"), underline=0, menu=self.recources_menu) self.add_separator() - self.add_command(label="Output System Information", - underline=0, - command=lambda action="output_sysinfo": self.in_thread(action)) + self.add_command( + label=_("Output System Information"), + underline=0, + command=lambda action="_output_sysinfo": self._in_thread(action)) # type:ignore logger.debug("Built help menu") - def _build_branches_menu(self): + def _build_branches_menu(self) -> bool: """ Build branch selection menu. Queries git for available branches and builds a menu based on output. @@ -249,16 +427,16 @@ def _build_branches_menu(self): for branch in branches: self._branches_menu.add_command( label=branch, - command=lambda b=branch: self._switch_branch(b)) + command=lambda b=branch: self._switch_branch(b)) # type:ignore return True - @staticmethod - def _get_branches(): + @classmethod + def _get_branches(cls) -> T.Optional[str]: """ Get the available github branches Returns ------- - str + str or ``None`` The list of branches available. If no branches were found or there was an error then `None` is returned """ @@ -274,8 +452,8 @@ def _get_branches(): return None return stdout.decode(locale.getpreferredencoding(), errors="replace") - @staticmethod - def _filter_branches(stdout): + @classmethod + def _filter_branches(cls, stdout: str) -> T.List[str]: """ Filter the branches, remove duplicates and the current branch and return a sorted list. @@ -286,7 +464,7 @@ def _filter_branches(stdout): Returns ------- - list + list[str] Unique list of available branches sorted in alphabetical order """ current = None @@ -303,12 +481,12 @@ def _filter_branches(stdout): logger.debug("Removing current branch from output: %s", current) branches.remove(current) - branches = sorted(list(branches), key=str.casefold) - logger.debug("Final branches: %s", branches) - return branches + retval = sorted(list(branches), key=str.casefold) + logger.debug("Final branches: %s", retval) + return retval - @staticmethod - def _switch_branch(branch): + @classmethod + def _switch_branch(cls, branch: str) -> None: """ Change the currently checked out branch, and return a notification. Parameters @@ -324,139 +502,38 @@ def _switch_branch(branch): if retcode != 0: logger.error("Unable to switch branch. return code: %s, message: %s", retcode, - stdout.decode(locale.getdefaultlocale(), + stdout.decode(T.cast(str, locale.getdefaultlocale()), errors="replace").strip().replace("\n", " - ")) return logger.info("Succesfully switched to '%s'. You may want to check for updates to make sure " "that you have the latest code.", branch) logger.info("Please restart Faceswap to complete the switch.") - def _build_recources_menu(self): + def _build_recources_menu(self) -> None: """ Build resources menu """ # pylint: disable=cell-var-from-loop logger.debug("Building Resources Files menu") for resource in _RESOURCES: self.recources_menu.add_command( label=resource[0], - command=lambda link=resource[1]: webbrowser.open_new(link)) + command=lambda link=resource[1]: webbrowser.open_new(link)) # type:ignore logger.debug("Built resources menu") - def in_thread(self, action): - """ Perform selected action inside a thread """ - logger.debug("Performing help action: %s", action) - thread = MultiThread(getattr(self, action), thread_count=1) - thread.start() - logger.debug("Performed help action: %s", action) - - @staticmethod - def clear_console(): + @classmethod + def _clear_console(cls) -> None: """ Clear the console window """ get_config().tk_vars.console_clear.set(True) - def output_sysinfo(self): - """ Output system information to console """ - logger.debug("Obtaining system information") - self.root.config(cursor="watch") - self.clear_console() - try: - from lib.sysinfo import sysinfo # pylint:disable=import-outside-toplevel - info = sysinfo - except Exception as err: # pylint:disable=broad-except - info = f"Error obtaining system info: {str(err)}" - self.clear_console() - logger.debug("Obtained system information: %s", info) - print(info) - self.root.config(cursor="") - - def check(self): - """ Check for updates and clone repository """ - logger.debug("Checking for updates...") - self.root.config(cursor="watch") - encoding = locale.getpreferredencoding() - logger.debug("Encoding: %s", encoding) - self.check_for_updates(encoding, check=True) - self.root.config(cursor="") - - def update(self): - """ Check for updates and clone repository """ - logger.debug("Updating Faceswap...") - self.root.config(cursor="watch") - encoding = locale.getpreferredencoding() - logger.debug("Encoding: %s", encoding) - success = False - if self.check_for_updates(encoding): - success = self.do_update(encoding) - update_deps.main(is_gui=True) - if success: - logger.info("Please restart Faceswap to complete the update.") - self.root.config(cursor="") - - @staticmethod - def check_for_updates(encoding, check=False): - """ Check whether an update is required """ - # Do the check - logger.info("Checking for updates...") - update = False - msg = "" - gitcmd = "git remote update && git status -uno" - with Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, cwd=_WORKING_DIR) as cmd: - stdout, _ = cmd.communicate() - retcode = cmd.poll() - if retcode != 0: - msg = ("Git is not installed or you are not running a cloned repo. " - "Unable to check for updates") - else: - chk = stdout.decode(encoding, errors="replace").splitlines() - for line in chk: - if line.lower().startswith("your branch is ahead"): - msg = "Your branch is ahead of the remote repo. Not updating" - break - if line.lower().startswith("your branch is up to date"): - msg = "Faceswap is up to date." - break - if line.lower().startswith("your branch is behind"): - msg = "There are updates available" - update = True - break - if "have diverged" in line.lower(): - msg = "Your branch has diverged from the remote repo. Not updating" - break - if not update or check: - logger.info(msg) - logger.debug("Checked for update. Update required: %s", update) - return update - - @staticmethod - def do_update(encoding): - """ Update Faceswap """ - logger.info("A new version is available. Updating...") - gitcmd = "git pull" - with Popen(gitcmd, - shell=True, - stdout=PIPE, - stderr=STDOUT, - bufsize=1, - cwd=_WORKING_DIR) as cmd: - while True: - output = cmd.stdout.readline().decode(encoding, errors="replace") - if output == "" and cmd.poll() is not None: - break - if output: - logger.debug("'%s' output: '%s'", gitcmd, output.strip()) - print(output.strip()) - retcode = cmd.poll() - logger.debug("'%s' returncode: %s", gitcmd, retcode) - if retcode != 0: - logger.info("An error occurred during update. return code: %s", retcode) - retval = False - else: - retval = True - return retval - class TaskBar(ttk.Frame): # pylint: disable=too-many-ancestors - """ Task bar buttons """ - def __init__(self, parent): + """ Task bar buttons + + Parameters + ---------- + parent: :class:`tkinter.ttk.Frame` + The frame that holds the task bar + """ + def __init__(self, parent: ttk.Frame) -> None: super().__init__(parent) self._config = get_config() self.pack(side=tk.TOP, anchor=tk.W, fill=tk.X, expand=False) @@ -470,7 +547,65 @@ def __init__(self, parent): self._settings_btns() self._section_separator() - def _project_btns(self): + @classmethod + def _loader_and_kwargs(cls, btntype: str) -> T.Tuple[str, T.Dict[str, bool]]: + """ Get the loader name and key word arguments for the given button type + + Parameters + ---------- + btntype: str + The button type to obtain the information for + + Returns + ------- + loader: str + The name of the loader to use for the given button type + kwargs: dict[str, bool] + The keyword arguments to use for the returned loader + """ + if btntype == "save": + loader = btntype + kwargs = {"save_as": False} + elif btntype == "save_as": + loader = "save" + kwargs = {"save_as": True} + else: + loader = btntype + kwargs = {} + logger.debug("btntype: %s, loader: %s, kwargs: %s", btntype, loader, kwargs) + return loader, kwargs + + @classmethod + def _set_help(cls, btntype: str) -> str: + """ Set the helptext for option buttons + + Parameters + ---------- + btntype: str + The button type to set the help text for + """ + logger.debug("Setting help") + hlp = "" + task = _("currently selected Task") if btntype[-1] == "2" else _("Project") + if btntype.startswith("reload"): + hlp = _("Reload {} from disk").format(task) + if btntype == "new": + hlp = _("Create a new {}...").format(task) + if btntype.startswith("clear"): + hlp = _("Reset {} to default").format(task) + elif btntype.startswith("save") and "_" not in btntype: + hlp = _("Save {}").format(task) + elif btntype.startswith("save_as"): + hlp = _("Save {} as...").format(task) + elif btntype.startswith("load"): + msg = task + if msg.endswith("Task"): + msg += _(" from a task or project file") + hlp = _("Load {}...").format(msg) + return hlp + + def _project_btns(self) -> None: + """ Place the project buttons """ frame = ttk.Frame(self._btn_frame) frame.pack(side=tk.LEFT, anchor=tk.W, expand=False, padx=2) @@ -481,12 +616,13 @@ def _project_btns(self): cmd = getattr(self._config.project, loader) btn = ttk.Button(frame, image=get_images().icons[btntype], - command=lambda fn=cmd, kw=kwargs: fn(**kw)) + command=lambda fn=cmd, kw=kwargs: fn(**kw)) # type:ignore btn.pack(side=tk.LEFT, anchor=tk.W) - hlp = self.set_help(btntype) + hlp = self._set_help(btntype) Tooltip(btn, text=hlp, wrap_length=200) - def _task_btns(self): + def _task_btns(self) -> None: + """ Place the task buttons """ frame = ttk.Frame(self._btn_frame) frame.pack(side=tk.LEFT, anchor=tk.W, expand=False, padx=2) @@ -501,26 +637,13 @@ def _task_btns(self): btn = ttk.Button( frame, image=get_images().icons[btntype], - command=lambda fn=cmd, kw=kwargs: fn(**kw)) + command=lambda fn=cmd, kw=kwargs: fn(**kw)) # type:ignore btn.pack(side=tk.LEFT, anchor=tk.W) - hlp = self.set_help(btntype) + hlp = self._set_help(btntype) Tooltip(btn, text=hlp, wrap_length=200) - @staticmethod - def _loader_and_kwargs(btntype): - if btntype == "save": - loader = btntype - kwargs = dict(save_as=False) - elif btntype == "save_as": - loader = "save" - kwargs = dict(save_as=True) - else: - loader = btntype - kwargs = {} - logger.debug("btntype: %s, loader: %s, kwargs: %s", btntype, loader, kwargs) - return loader, kwargs - - def _settings_btns(self): + def _settings_btns(self) -> None: + """ Place the settings buttons """ # pylint: disable=cell-var-from-loop frame = ttk.Frame(self._btn_frame) frame.pack(side=tk.LEFT, anchor=tk.W, expand=False, padx=2) @@ -531,39 +654,18 @@ def _settings_btns(self): btn = ttk.Button( frame, image=get_images().icons[btntype], - command=lambda n=name: open_popup(name=n)) + command=lambda n=name: open_popup(name=n)) # type:ignore btn.pack(side=tk.LEFT, anchor=tk.W) hlp = _("Configure {} settings...").format(name.title()) Tooltip(btn, text=hlp, wrap_length=200) - @staticmethod - def set_help(btntype): - """ Set the helptext for option buttons """ - logger.debug("Setting help") - hlp = "" - task = _("currently selected Task") if btntype[-1] == "2" else _("Project") - if btntype.startswith("reload"): - hlp = _("Reload {} from disk").format(task) - if btntype == "new": - hlp = _("Create a new {}...").format(task) - if btntype.startswith("clear"): - hlp = _("Reset {} to default").format(task) - elif btntype.startswith("save") and "_" not in btntype: - hlp = _("Save {}").format(task) - elif btntype.startswith("save_as"): - hlp = _("Save {} as...").format(task) - elif btntype.startswith("load"): - msg = task - if msg.endswith("Task"): - msg += _(" from a task or project file") - hlp = _("Load {}...").format(msg) - return hlp - - def _group_separator(self): + def _group_separator(self) -> None: + """ Place a group separator """ separator = ttk.Separator(self._btn_frame, orient="vertical") separator.pack(padx=(2, 1), fill=tk.Y, side=tk.LEFT) - def _section_separator(self): + def _section_separator(self) -> None: + """ Place a section separator """ frame = ttk.Frame(self) frame.pack(side=tk.BOTTOM, fill=tk.X) separator = ttk.Separator(frame, orient="horizontal") diff --git a/locales/es/LC_MESSAGES/gui.menu.mo b/locales/es/LC_MESSAGES/gui.menu.mo new file mode 100644 index 0000000000000000000000000000000000000000..f31697bbbc42dff9904dc9796aa991269757166d GIT binary patch literal 1383 zcmZXTzi%8x6vqb$1eOG(03zb&0|WwvUG8ik(ONFRv8{lOPreJHL1?n`_HN?6nbrKb zumW2C0w|D>s3~aRPe?&aNkvIdMMJ@NZhbyPMwfE00sIF%4?cUp5Kn-woboD|);|O< zf@3h{li-VB2Y!m!@4%Pw{`Uj@`ZMQ*coOd=_$GJ+ru|=n7r^hq=fNMrhrwULr@-I9 z*T6r)r0?Q`z5dH!(s>0;^?d*$t-v;c)XV!w`jCF9_fc>)QoeK+Lhc!lVI}2H{Rvq|t+The3RLTc#f~+#o<_&?#aHAx1Z}3iClNr;VtayA!wzOLI>=x|Az7Df^3!9Fjqp^*yV%o8a#etRT^rVptj;+C_aco=b z0)?6OD`jHTD-_4v^f7%2O8XP6L1hvfEGe#KY=RieMbf__$z=sqbdF7EI@HymU{*B2 z6^I(bJ%ksE7f`StwPH`wDB80w&R5y<8!G>po!8csbQa@VX|$hZ$DCy6hkmM6{(hYL zd}2$=Mo0NEFLVmZujBqo+8AtX=7V?h!BrZ*T@Hs=2E)N1JCHu)6DN&tWUvmA2hvQV zoN_*4IWJM&iL9+r_Uw<6*nhS=&JJ%J?yiLmi$R7S1{9i4x)uooe;(dy8>!6}RWs>u zUE4P&d-=8XzLd9Sm+zX&Dm1*LYqxaBMoo0G$#DyFUy^B;uYP-Di~7gy*A2b2O~W^~ zvVAt}P#)_(otj4ncL9^bO>8}A0}(Mbcl(JBmQsB-3ASJ3$5UF#H{Rz$CYK*9{nh? z56)6`_ZOw7mg8|!7s`%yZz+$7eIRN1*rA6|b$vI, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: faceswap.spanish\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-06-07 13:54+0100\n" +"PO-Revision-Date: 2023-06-07 14:11+0100\n" +"Last-Translator: \n" +"Language-Team: tokafondo\n" +"Language: es_ES\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.3.1\n" + +#: lib/gui/menu.py:37 +msgid "faceswap.dev - Guides and Forum" +msgstr "faceswap.dev - Guías y foro" + +#: lib/gui/menu.py:38 +msgid "Patreon - Support this project" +msgstr "Patreon - Apoya este proyecto" + +#: lib/gui/menu.py:39 +msgid "Discord - The FaceSwap Discord server" +msgstr "Discord - El servidor de Discord de FaceSwap" + +#: lib/gui/menu.py:40 +msgid "Github - Our Source Code" +msgstr "Github - Nuestro código fuente" + +#: lib/gui/menu.py:60 +msgid "File" +msgstr "" + +#: lib/gui/menu.py:61 +msgid "Settings" +msgstr "" + +#: lib/gui/menu.py:62 +msgid "Help" +msgstr "" + +#: lib/gui/menu.py:85 +msgid "Configure Settings..." +msgstr "" + +#: lib/gui/menu.py:116 +msgid "New Project..." +msgstr "" + +#: lib/gui/menu.py:121 +msgid "Open Project..." +msgstr "" + +#: lib/gui/menu.py:126 +msgid "Save Project" +msgstr "" + +#: lib/gui/menu.py:131 +msgid "Save Project as..." +msgstr "" + +#: lib/gui/menu.py:136 +msgid "Reload Project from Disk" +msgstr "" + +#: lib/gui/menu.py:141 +msgid "Close Project" +msgstr "" + +#: lib/gui/menu.py:147 +msgid "Open Task..." +msgstr "" + +#: lib/gui/menu.py:154 +msgid "Open recent" +msgstr "" + +#: lib/gui/menu.py:156 +msgid "Quit" +msgstr "" + +#: lib/gui/menu.py:211 +msgid "{} Task" +msgstr "" + +#: lib/gui/menu.py:223 +msgid "Clear recent files" +msgstr "" + +#: lib/gui/menu.py:391 +msgid "Check for updates..." +msgstr "" + +#: lib/gui/menu.py:394 +msgid "Update Faceswap..." +msgstr "" + +#: lib/gui/menu.py:398 +msgid "Switch Branch" +msgstr "" + +#: lib/gui/menu.py:401 +msgid "Resources" +msgstr "" + +#: lib/gui/menu.py:404 +msgid "Output System Information" +msgstr "" + +#: lib/gui/menu.py:589 +msgid "currently selected Task" +msgstr "tarea actualmente seleccionada" + +#: lib/gui/menu.py:589 +msgid "Project" +msgstr "Proyecto" + +#: lib/gui/menu.py:591 +msgid "Reload {} from disk" +msgstr "Recargar {} del disco" + +#: lib/gui/menu.py:593 +msgid "Create a new {}..." +msgstr "Crear un nuevo {}..." + +#: lib/gui/menu.py:595 +msgid "Reset {} to default" +msgstr "Reiniciar {} a los ajustes por defecto" + +#: lib/gui/menu.py:597 +msgid "Save {}" +msgstr "Guardar {}" + +#: lib/gui/menu.py:599 +msgid "Save {} as..." +msgstr "Guardar {} como..." + +#: lib/gui/menu.py:603 +msgid " from a task or project file" +msgstr " de un archivo de tarea o proyecto" + +#: lib/gui/menu.py:604 +msgid "Load {}..." +msgstr "Cargar {}..." + +#: lib/gui/menu.py:659 +msgid "Configure {} settings..." +msgstr "Configurar los ajustes de {}..." diff --git a/locales/es/LC_MESSAGES/gui.tooltips.mo b/locales/es/LC_MESSAGES/gui.tooltips.mo index c1fdf1a8083c5e1bbe4bc09e40aff7e4721d900c..9df32251816b704758e92d56a2aa68b13403771e 100644 GIT binary patch delta 967 zcmZY7-%FEW6u|Mb-0#h~&9a#-XBwu{xwV{Sde@eO5DX&dhh#NfR8Z5Bx@gc3$h+t% zis+(6w?Q!sLad;_p#q60x`;+pbkl9$^KKVGhn@HHJny#WeV=olM}4)f^2f0Kju1WM zMsmz1GK`1Ua3PXSBCVLhUd-ZJJcCiZf4 zPhmga#5m8FWd?lYE0-=us1;c#~F+<=)+4mf{z>X57@@s$@X=JQQV6=@dRGODg1`(aDs!NFEoYw z@IF!?p@3HKTr0HvjVKG%vO-Iw2TDWxr4{KSwPaLS^gOb$|3V+^AhqYZTkWwPWQ~}0 z6`^p}257m}O5MFin`9^_gKkoLrm>maKgowv-a z>tJ)fFjFeb6=Sod>9a*sbIqHQ`);+J;iM^oG4$&~TB2{J*9k#$Zgj&_BWF-NPw~PR^WXW_D-heSg1q zm$u#Lp8dU}>58Hi=w0-08k8D_hZ^~#J=;`Ue-1v(ya;!~x8Qns19rk&umdi_9qmNbc_Z55! zeg_BOUvM|S_U_U$#W#ju$ zBL5tYV$VE$oOx~y>cDQe7mh%=FoE*^B`6ks4SV2in1@Rc>D6YUoPC19AcNiTB-{fp zLGDoB!yRxQ8h97V!7sGc4mt~uGrtC9-#?JERTtSDf!**REZ^r%C^hjXd<{0WqrXIU znhHTZbs35kAHxmsCVU0{2t~S1e0UO`f|A)q_%yr*yWy{JKU{$fG9QAHNB~6{%il(L z0g7^0JJA1m20yS6K)L-CFi-;c2}%aP!J}{^w~BN{D8+ga;)udQwVE!rNR`)Hfq^JO z{MlC{Dv6?!wA7B&pV~#2uT3H&j^!p&Nz2oF=~8rjRjQ9(Zz!2%D02!wtCwxCpN?CZ zniB9Mbg@o?7OSODMe%7Y%cRs$n#fXbsF@X3Gn6{1w-xsXBC#}4N}5=sbkW&q?djNy z&T8v)#o05)k99FDnY6EQ{N-U=E{2ndGy3iGIx=w_mM5cpKCgzI@nXZ)vN^{ySvnF% zzIB1_)yJlc9`$@Pan7sg`f_Ak)wsEL8rL^$3gfBdbzVH0I6Yw#=Nmn21CyprZ{}*J zrMc!$ws^5Kw#<7INu^?4tmCOLst1{V)_h{`OSOR2xG6dlP3cMJRi>DgtQSbgS$;tn zon?*i)62QN>4Drc9Ws&UvDJYodPylxtGTOfYQn2#sd|#+TuV!(VZWa^XUcJDhWty2 zV*-5)xm1w^BNEI9rb>uoNf?+&d*winT9-_x)s+X^`+NHadJ6^JKR7gSU{7CPdS~UQ z>3gjM&4v8Fd?9_cwP(8`0G*Vz=lrR#D&gP;;%Y02&TyekZ?~TI>P~o0mn`?3Nurqh zc)g~+7~>54CTnt@zix?ry`O hnwp*;Cthh, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-06-07 13:54+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#: ./lib/gui/menu.py:37 +msgid "faceswap.dev - Guides and Forum" +msgstr "" + +#: ./lib/gui/menu.py:38 +msgid "Patreon - Support this project" +msgstr "" + +#: ./lib/gui/menu.py:39 +msgid "Discord - The FaceSwap Discord server" +msgstr "" + +#: ./lib/gui/menu.py:40 +msgid "Github - Our Source Code" +msgstr "" + +#: ./lib/gui/menu.py:60 +msgid "File" +msgstr "" + +#: ./lib/gui/menu.py:61 +msgid "Settings" +msgstr "" + +#: ./lib/gui/menu.py:62 +msgid "Help" +msgstr "" + +#: ./lib/gui/menu.py:85 +msgid "Configure Settings..." +msgstr "" + +#: ./lib/gui/menu.py:116 +msgid "New Project..." +msgstr "" + +#: ./lib/gui/menu.py:121 +msgid "Open Project..." +msgstr "" + +#: ./lib/gui/menu.py:126 +msgid "Save Project" +msgstr "" + +#: ./lib/gui/menu.py:131 +msgid "Save Project as..." +msgstr "" + +#: ./lib/gui/menu.py:136 +msgid "Reload Project from Disk" +msgstr "" + +#: ./lib/gui/menu.py:141 +msgid "Close Project" +msgstr "" + +#: ./lib/gui/menu.py:147 +msgid "Open Task..." +msgstr "" + +#: ./lib/gui/menu.py:154 +msgid "Open recent" +msgstr "" + +#: ./lib/gui/menu.py:156 +msgid "Quit" +msgstr "" + +#: ./lib/gui/menu.py:211 +msgid "{} Task" +msgstr "" + +#: ./lib/gui/menu.py:223 +msgid "Clear recent files" +msgstr "" + +#: ./lib/gui/menu.py:391 +msgid "Check for updates..." +msgstr "" + +#: ./lib/gui/menu.py:394 +msgid "Update Faceswap..." +msgstr "" + +#: ./lib/gui/menu.py:398 +msgid "Switch Branch" +msgstr "" + +#: ./lib/gui/menu.py:401 +msgid "Resources" +msgstr "" + +#: ./lib/gui/menu.py:404 +msgid "Output System Information" +msgstr "" + +#: ./lib/gui/menu.py:589 +msgid "currently selected Task" +msgstr "" + +#: ./lib/gui/menu.py:589 +msgid "Project" +msgstr "" + +#: ./lib/gui/menu.py:591 +msgid "Reload {} from disk" +msgstr "" + +#: ./lib/gui/menu.py:593 +msgid "Create a new {}..." +msgstr "" + +#: ./lib/gui/menu.py:595 +msgid "Reset {} to default" +msgstr "" + +#: ./lib/gui/menu.py:597 +msgid "Save {}" +msgstr "" + +#: ./lib/gui/menu.py:599 +msgid "Save {} as..." +msgstr "" + +#: ./lib/gui/menu.py:603 +msgid " from a task or project file" +msgstr "" + +#: ./lib/gui/menu.py:604 +msgid "Load {}..." +msgstr "" + +#: ./lib/gui/menu.py:659 +msgid "Configure {} settings..." +msgstr "" diff --git a/locales/gui.tooltips.pot b/locales/gui.tooltips.pot index be16034bd2..f6973d6152 100644 --- a/locales/gui.tooltips.pot +++ b/locales/gui.tooltips.pot @@ -119,62 +119,6 @@ msgstr "" msgid "Enable or disable {} display" msgstr "" -#: ./lib/gui/menu.py:32 -msgid "faceswap.dev - Guides and Forum" -msgstr "" - -#: ./lib/gui/menu.py:33 -msgid "Patreon - Support this project" -msgstr "" - -#: ./lib/gui/menu.py:34 -msgid "Discord - The FaceSwap Discord server" -msgstr "" - -#: ./lib/gui/menu.py:35 -msgid "Github - Our Source Code" -msgstr "" - -#: ./lib/gui/menu.py:527 -msgid "Configure {} settings..." -msgstr "" - -#: ./lib/gui/menu.py:535 -msgid "Project" -msgstr "" - -#: ./lib/gui/menu.py:535 -msgid "currently selected Task" -msgstr "" - -#: ./lib/gui/menu.py:537 -msgid "Reload {} from disk" -msgstr "" - -#: ./lib/gui/menu.py:539 -msgid "Create a new {}..." -msgstr "" - -#: ./lib/gui/menu.py:541 -msgid "Reset {} to default" -msgstr "" - -#: ./lib/gui/menu.py:543 -msgid "Save {}" -msgstr "" - -#: ./lib/gui/menu.py:545 -msgid "Save {} as..." -msgstr "" - -#: ./lib/gui/menu.py:549 -msgid " from a task or project file" -msgstr "" - -#: ./lib/gui/menu.py:550 -msgid "Load {}..." -msgstr "" - #: ./lib/gui/popup_configure.py:209 msgid "Close without saving" msgstr "" diff --git a/locales/kr/LC_MESSAGES/gui.menu.mo b/locales/kr/LC_MESSAGES/gui.menu.mo new file mode 100644 index 0000000000000000000000000000000000000000..2bab76f5b032bd36748c1177dee9e37c5ef2f1be GIT binary patch literal 1370 zcmbV~&2Jk;7{&)EUrYH=duaI(ynrep!7O$|KjbJE(k7~!s8;MEIH9R`Y;UvKSoE~?@jf^gJ;l46hpmr7JnBp>pBzyb9S=&lc3IC0<^uT3j)sKiL)pXc3~d7fux z?jAa@#n7Hde+9jZegysCBltmUg0}91N5L<^L*TdIG4SVc{dds8_)qXv@Ne)*@a0Dt zI}E-#<|)wD&x6l`70~Jl@HMan&S34=;9D5~_E`R%9boJwjE{m=cM7zAJ_26=HE4C$ zz=PoT;5*X(CU^z$XXc&>Ct&ue~=&feFmH~yT8qfCI4y9 zq4LzY`ox%4mr-4-6%u4byi7_{q}63vGgS8j$!1kp_Zx|p^yz05OJn@75xcI-X0;SX zVp%A!;F~R-@#C7(9&xHRCC!PNtXvThP0F#sw7dQv?0^<itm+*#kUK^Lcv)OvEfxM z!Z;8{X{=ljVIvU@$*WSVJTNZNvikV^lCyYj@$7y5qFZoqRR(!@wG~0f$WP3>Q6T(q znrcm$PUOmUj>zcYnt zXI_R zx1D~sll69o>jN5YY-GFp*^SMty~A*YS$ikYE)e7}c0!w0>ywA;`LL{d8|Y+3ub6hxTOlXoAaz+ifhw S@$`mkyXmIg#vbiMAMii#j, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-06-07 13:54+0100\n" +"PO-Revision-Date: 2023-06-07 14:11+0100\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ko_KR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.3.1\n" + +#: lib/gui/menu.py:37 +msgid "faceswap.dev - Guides and Forum" +msgstr "faceswap.dev - Guides and Forum" + +#: lib/gui/menu.py:38 +msgid "Patreon - Support this project" +msgstr "Patreon - Support this project" + +#: lib/gui/menu.py:39 +msgid "Discord - The FaceSwap Discord server" +msgstr "Discord - The FaceSwap Discord server" + +#: lib/gui/menu.py:40 +msgid "Github - Our Source Code" +msgstr "Github - Our Source Code" + +#: lib/gui/menu.py:60 +msgid "File" +msgstr "" + +#: lib/gui/menu.py:61 +msgid "Settings" +msgstr "" + +#: lib/gui/menu.py:62 +msgid "Help" +msgstr "" + +#: lib/gui/menu.py:85 +msgid "Configure Settings..." +msgstr "" + +#: lib/gui/menu.py:116 +msgid "New Project..." +msgstr "" + +#: lib/gui/menu.py:121 +msgid "Open Project..." +msgstr "" + +#: lib/gui/menu.py:126 +msgid "Save Project" +msgstr "" + +#: lib/gui/menu.py:131 +msgid "Save Project as..." +msgstr "" + +#: lib/gui/menu.py:136 +msgid "Reload Project from Disk" +msgstr "" + +#: lib/gui/menu.py:141 +msgid "Close Project" +msgstr "" + +#: lib/gui/menu.py:147 +msgid "Open Task..." +msgstr "" + +#: lib/gui/menu.py:154 +msgid "Open recent" +msgstr "" + +#: lib/gui/menu.py:156 +msgid "Quit" +msgstr "" + +#: lib/gui/menu.py:211 +msgid "{} Task" +msgstr "" + +#: lib/gui/menu.py:223 +msgid "Clear recent files" +msgstr "" + +#: lib/gui/menu.py:391 +msgid "Check for updates..." +msgstr "" + +#: lib/gui/menu.py:394 +msgid "Update Faceswap..." +msgstr "" + +#: lib/gui/menu.py:398 +msgid "Switch Branch" +msgstr "" + +#: lib/gui/menu.py:401 +msgid "Resources" +msgstr "" + +#: lib/gui/menu.py:404 +msgid "Output System Information" +msgstr "" + +#: lib/gui/menu.py:589 +msgid "currently selected Task" +msgstr "현재 선택된 작업" + +#: lib/gui/menu.py:589 +msgid "Project" +msgstr "프로젝트" + +#: lib/gui/menu.py:591 +msgid "Reload {} from disk" +msgstr "디스크에서 {}를 다시 가져옵니다" + +#: lib/gui/menu.py:593 +msgid "Create a new {}..." +msgstr "새로운 {}를 만들기." + +#: lib/gui/menu.py:595 +msgid "Reset {} to default" +msgstr "{} 기본으로 재설정" + +#: lib/gui/menu.py:597 +msgid "Save {}" +msgstr "{} 저장" + +#: lib/gui/menu.py:599 +msgid "Save {} as..." +msgstr "{}를 다른 이름으로 저장." + +#: lib/gui/menu.py:603 +msgid " from a task or project file" +msgstr " 작업 또는 프로젝트 파일에서" + +#: lib/gui/menu.py:604 +msgid "Load {}..." +msgstr "{} 가져오기." + +#: lib/gui/menu.py:659 +msgid "Configure {} settings..." +msgstr "{} 세팅 설정하기." diff --git a/locales/kr/LC_MESSAGES/gui.tooltips.mo b/locales/kr/LC_MESSAGES/gui.tooltips.mo index 56822b5a2d2a860560da5cf7380580992f298283..bce4cb21488a5a29fb0022fbea39ca89a03d05ea 100644 GIT binary patch delta 1167 zcmX}rOGuPa6u|K_$i#2;F_H;Vkt=^7r&zqH*hQNcF?dAU3dJ6T3u=`ZUBcAFZXz=yiyI@F0Sp+2Ywcj7(NiC^L=oJL)kBTeKq zHexm!EXNlZ;Q6w^g_pG3$x|Wyk}V=-*oeBY8@L~b68%ZkRDVXj|0nt|KSP9%)bUk@ zO?V!AQUAYyow$+cx01fT6AawvB7pO#6aB#`7H7rl;xXpYpT*<2h*em?c69tK9>7-A zBN#+o*oTB)aUcD^sF^J!%}gP+F7mHM(v%qJKuzUM)bSDAhVM{Q{RwrVMbsVH7>uA7 zyKoS7;dXcYsJgM7{xIs1%pi#tYR{^nbUpvgAdA-OH~U)k8f5E#=y7Q1q`EMz0}Wl2 z)`k|EW{?w=5}F2OU@f*7^;mQx)Mz|5pReiC6lyyC@t=e@%}NueVFjDE3P>~Ku}0$Q zbaJg()7O%hMZ7ev4=qN0i`leDeI#_PE$r#_hkC9=J<-ddjfpjyG*&b`psxpT|d7ig2Ikh_aXnnZfvv%w5>iE-@*x1_p VSLTbm$XswYn_2gp*m$lp=^rsljMe}E delta 2103 zcmajfTWl0n7{KvUT3RUdLIqou%MnWply18P%Eb#0#rA=yMwJjt2g&Y77r z-}jv}`$EaJ^3JOT8D|yke)@9y%`~NU;}_{1Xba{i#~0!qoR?rJZpWo~2#fG27GNK) z!E?9*KgWFh5%0s_@h)7RsnklPI#o44vKe?3C4htY6dpsF_zG&giCb}5R&x9il;@go zGg`P2CvZJ}i@ZQ(Eg)Dd!BuD=m(&T&;r;4Gen@5mSc~tVOz=x;e8obgmT(?Kc|i^O z8KY#rhH|5M|=$QC>WV_uwST zi$2AN@hVEd#fx|rTQCo&a07mfLA-(T-0H>23T($>miYe zx$zSQq!L$fJ!Y^WB$JAidy(Z+cIx~XuIBu8l(n2el2zZM1om_4Jcmz2mTo!9_*Pto z2awoQTPHtcB2hAb6Pxifw6K!9Z9IpvHwuYXGTMx?2M(bu(FCr-50RzyzYGbO6};6b zntu;P_RB9{MQ0@k`Ftc>DM3%!aSp!2!Nm--n$CXnlaY!_ zaI!a~3bF^JRV0z6lKr3DTv9%1eCB>*CHz*xQA#WuZ<{~z_d)YBNT=FL{;$^2DS(c()Z>0C?S zx3?zJ-WYC*S*CvWn6}MmG~C`~2Lb_AW0^+O+i_4sqk|n5F9@F{{DUHIb0%)XaZ6GdY{{-u!_TM$|GR z?c7%%>*$DBQ5|g#+jBuWyE31w*pm!c?K2xK({9#HmeJA7S!=`yNyvVFp|IV;7~wk) zW>q*pW$q}D6S*FZ=#bfH#9E_HN!Ba5s@^!_r)o%&QqLK-`^JzrI-KZ^>qKv_H$3eP^m$!F&e}zXA16$&E1u{* z1!Lam*~Hnw+4zNk@=l(Xr(V5CBJTLKcHi!GoiYE?T+MI46lWIgNGq!QueJDk@pL|~ zp1p9~8;yH|{=`mq&TA|8+-L;>d&Zpaa$}if=1#?(hw^6DX>atnH!`kLiT8}T<8kc`Uv%H< n@`k3VO6rEd)E{}->01+nUEauf$~T%gIqddv)i3}5Eus1g{))d4 diff --git a/locales/kr/LC_MESSAGES/gui.tooltips.po b/locales/kr/LC_MESSAGES/gui.tooltips.po index 59f1f5a0e7..16c1631fcc 100644 --- a/locales/kr/LC_MESSAGES/gui.tooltips.po +++ b/locales/kr/LC_MESSAGES/gui.tooltips.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "POT-Creation-Date: 2021-03-22 18:37+0000\n" -"PO-Revision-Date: 2022-11-26 16:12+0900\n" +"PO-Revision-Date: 2023-06-07 14:13+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: ko_KR\n" @@ -15,7 +15,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 3.2\n" +"X-Generator: Poedit 3.3.1\n" #: lib/gui/command.py:184 msgid "Output command line options to the console" @@ -128,62 +128,6 @@ msgstr "{}(s)를 파일에 저장합니다" msgid "Enable or disable {} display" msgstr "{} display를 활성화 또는 비활성화" -#: lib/gui/menu.py:32 -msgid "faceswap.dev - Guides and Forum" -msgstr "faceswap.dev - Guides and Forum" - -#: lib/gui/menu.py:33 -msgid "Patreon - Support this project" -msgstr "Patreon - Support this project" - -#: lib/gui/menu.py:34 -msgid "Discord - The FaceSwap Discord server" -msgstr "Discord - The FaceSwap Discord server" - -#: lib/gui/menu.py:35 -msgid "Github - Our Source Code" -msgstr "Github - Our Source Code" - -#: lib/gui/menu.py:527 -msgid "Configure {} settings..." -msgstr "{} 세팅 설정하기." - -#: lib/gui/menu.py:535 -msgid "Project" -msgstr "프로젝트" - -#: lib/gui/menu.py:535 -msgid "currently selected Task" -msgstr "현재 선택된 작업" - -#: lib/gui/menu.py:537 -msgid "Reload {} from disk" -msgstr "디스크에서 {}를 다시 가져옵니다" - -#: lib/gui/menu.py:539 -msgid "Create a new {}..." -msgstr "새로운 {}를 만들기." - -#: lib/gui/menu.py:541 -msgid "Reset {} to default" -msgstr "{} 기본으로 재설정" - -#: lib/gui/menu.py:543 -msgid "Save {}" -msgstr "{} 저장" - -#: lib/gui/menu.py:545 -msgid "Save {} as..." -msgstr "{}를 다른 이름으로 저장." - -#: lib/gui/menu.py:549 -msgid " from a task or project file" -msgstr " 작업 또는 프로젝트 파일에서" - -#: lib/gui/menu.py:550 -msgid "Load {}..." -msgstr "{} 가져오기." - #: lib/gui/popup_configure.py:209 msgid "Close without saving" msgstr "저장하지 않고 닫기" diff --git a/locales/ru/LC_MESSAGES/gui.menu.mo b/locales/ru/LC_MESSAGES/gui.menu.mo new file mode 100644 index 0000000000000000000000000000000000000000..97a98fc3c9de51ba647c28213af0dc3a7fa10811 GIT binary patch literal 1604 zcmZvc&u<$=6vqcB6wHsJA_Ni$9)cFCYL{InrIk|`iKK}t64k_}>UHWJ+e@8YYj!uq zqKGu5%Z=8_$6S#2V!1t}!7z!(mKRa*cz4v`@X8r5I zeV+?F2QZ$*_zdGQj0f((51tmtb{~8kybc}&SHMGHKezt^7BK%EduGZSXgc^E`Zy5J)Q!Mj&<857u`V2J5{aWB71fNNQbUIeBq^5BMG7@_bohp<$JFrKN=$}9!*3>9(OYj*q~h2QnvreWVp1y^ zE4T&f64ux>xw2rPl?%3 zdU>RZjI{~_Y^o-mPN-vwTYfZDCMpAkVqUceK7$fGKe`ATRI=DOBu_PD(vC${F0%e1 zNoEz)6RlMcx4WpTje?a&HMBr9AUuL_+f$2BFq8P6ibw_?O@}&J7-qN5c-A?kBR>pE zRG3#R)0&H#zGuCXG$X4P!dy9P&8tP9H(68YSBZ*Fam;dFvYg}Oj+dNcM;+I33bQhb zt(ulW)Ru9m;W;aVW+I!)swuhfk9mo7QkXk4cY2iEwVeXaDMm3?t=mD8SiKp)*l9~Y zn4o$~YP6*MdTrV|G1|wu8cJKIgL>%UP)l^;f*%(u?L^DAg-REq5(S-X8kNT;Xz*Ho zAvokZ<+4kM4->yS&y-!4UM7dI9rIJ=;&AC?d7NJ}59g#?q|28FE~nh_BXDuyQamzI zm{Eb!Xbu|JEm5c2#5F?!cBf0OeXMZa$~SOdDxvcHn8xfe+bsw(8)lu-cTLZ1nr+h~ zvyRtQ(@Wno8@#y9xRoxMJ+o%E(q+>V<|`~;NtZL@rrBbzTQDubbH}XZ3;p~ins4B` zo`qaaZ;;u=w4Z)tHuLr2<+lF5=IIp#Su%b8%{~IYOQTz#rGFpVpy3*Vt(x5|9E4yZ z%omXHL%Nh+Mb-_7xsfLteG+CRb6d^Q{K%Wk8l@itd+GAOsugBsR5~V^$6D5-+fxiW z2{q8gJrOAUdv?ZjD1-a7M^Lr{0o!PC5AE1UKjFQ5>9wqbY#50Ce=Bitv&DZGtQ%)? vHT?kH|CF+}a!QW6J1~t-vv>PvX}*RK?pdF^!3T;r93{^CTd3g%?ufqt#`{Hg literal 0 HcmV?d00001 diff --git a/locales/ru/LC_MESSAGES/gui.menu.po b/locales/ru/LC_MESSAGES/gui.menu.po new file mode 100644 index 0000000000..66fba0add9 --- /dev/null +++ b/locales/ru/LC_MESSAGES/gui.menu.po @@ -0,0 +1,156 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-06-07 13:54+0100\n" +"PO-Revision-Date: 2023-06-07 14:05+0100\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"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" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.3.1\n" + +#: ./lib/gui/menu.py:37 +msgid "faceswap.dev - Guides and Forum" +msgstr "faceswap.dev - Руководства и Форум" + +#: ./lib/gui/menu.py:38 +msgid "Patreon - Support this project" +msgstr "Patreon - Поддержите этот проект" + +#: ./lib/gui/menu.py:39 +msgid "Discord - The FaceSwap Discord server" +msgstr "Discord - Discord сервер Faceswap" + +#: ./lib/gui/menu.py:40 +msgid "Github - Our Source Code" +msgstr "Github - Наш исходный код" + +#: ./lib/gui/menu.py:60 +msgid "File" +msgstr "" + +#: ./lib/gui/menu.py:61 +msgid "Settings" +msgstr "" + +#: ./lib/gui/menu.py:62 +msgid "Help" +msgstr "" + +#: ./lib/gui/menu.py:85 +msgid "Configure Settings..." +msgstr "" + +#: ./lib/gui/menu.py:116 +msgid "New Project..." +msgstr "" + +#: ./lib/gui/menu.py:121 +msgid "Open Project..." +msgstr "" + +#: ./lib/gui/menu.py:126 +msgid "Save Project" +msgstr "" + +#: ./lib/gui/menu.py:131 +msgid "Save Project as..." +msgstr "" + +#: ./lib/gui/menu.py:136 +msgid "Reload Project from Disk" +msgstr "" + +#: ./lib/gui/menu.py:141 +msgid "Close Project" +msgstr "" + +#: ./lib/gui/menu.py:147 +msgid "Open Task..." +msgstr "" + +#: ./lib/gui/menu.py:154 +msgid "Open recent" +msgstr "" + +#: ./lib/gui/menu.py:156 +msgid "Quit" +msgstr "" + +#: ./lib/gui/menu.py:211 +msgid "{} Task" +msgstr "" + +#: ./lib/gui/menu.py:223 +msgid "Clear recent files" +msgstr "" + +#: ./lib/gui/menu.py:391 +msgid "Check for updates..." +msgstr "" + +#: ./lib/gui/menu.py:394 +msgid "Update Faceswap..." +msgstr "" + +#: ./lib/gui/menu.py:398 +msgid "Switch Branch" +msgstr "" + +#: ./lib/gui/menu.py:401 +msgid "Resources" +msgstr "" + +#: ./lib/gui/menu.py:404 +msgid "Output System Information" +msgstr "" + +#: ./lib/gui/menu.py:589 +msgid "currently selected Task" +msgstr "текущая выбранная задача" + +#: ./lib/gui/menu.py:589 +msgid "Project" +msgstr "Проект" + +#: ./lib/gui/menu.py:591 +msgid "Reload {} from disk" +msgstr "Перезагрузить {} из диска" + +#: ./lib/gui/menu.py:593 +msgid "Create a new {}..." +msgstr "Создать новый {}..." + +#: ./lib/gui/menu.py:595 +msgid "Reset {} to default" +msgstr "Сбросить {} по умолчанию" + +#: ./lib/gui/menu.py:597 +msgid "Save {}" +msgstr "Сохранить {}" + +#: ./lib/gui/menu.py:599 +msgid "Save {} as..." +msgstr "Сохранить {} как..." + +#: ./lib/gui/menu.py:603 +msgid " from a task or project file" +msgstr " из файла задачи или проекта" + +#: ./lib/gui/menu.py:604 +msgid "Load {}..." +msgstr "Загрузить {}..." + +#: ./lib/gui/menu.py:659 +msgid "Configure {} settings..." +msgstr "Настройка параметров {}..." diff --git a/locales/ru/LC_MESSAGES/gui.tooltips.mo b/locales/ru/LC_MESSAGES/gui.tooltips.mo index 5b03b33216ade317cdb32559c90e15499ccbec54..025c3fd7db9c42d7cb39120e385b9f75404d28fb 100644 GIT binary patch delta 1145 zcmX}rOGs2<6u|Lw#~B^psWX$7>9yBrI^y+J4k(pYiwvR`kx?+&q=gRQlvpih(L16= z*H8#jj0#+Kk#Zw#B?xz|dLh`mMIeC$^*?v8501b4-MM~`^PTTkbj3=&EB4+OT7)Pk zt{Nik_}#-F+J_9068wl^T*d%y@DkXFYw#d$!Wfp}HLSo1%)vR-@#pb4E}KF22ff!nYuQzVMrScx|q6fH3NgccKn-0(GJhT#vU=2Y!Nka1M20-fWS5xE~AA z#yWh27W2yj4@uJQAy3uxhjaNDCs23v4G&`|FP(w2s7Le|yKxb_uz_^wQC&do-@>o> z8js;@fygKZSw#mjFFc&W18iSc`V1#=1$7}0*rx9M6Kb*eibV$WJ(}oe)&$m| z4m^&!fH$a_D5qey;7P2*o2VI|36Ot1(*<5=%5#Gv^;nIqcmyrHi~9RZ+=Xwk4J$+J z5|82-F5);|<{Wgv0S;a>)QFcbg(Os=kJw7+Rn%hCTg)kmboR#*eR1<*BsOv;X(oGP199mbh}-SHs59>? zG3@2cVS701o*nl0hHAk)H z+6K!R&1*5dP4!leZ4^v7Wrb57XR&CvVRw}LwTA*f?ZIG$GaJk>oX??z;oK>`;{68* Cgo3vK delta 2212 zcmZ9MYiv|S6vt;vA5fsB+X59qM}#8KE^H|ji-nLP{eS_a7!x0n%kFKvvg|f@cUd2S zmZDJvwaJA5i3oy;2EWv9OADotXksEUW^SU1iH4XM#K#vxFd_QI-?`g@ILYj99(T^1 z^FL?y@9?dr?rXuqql&hMK9_zwpwt%l%_M$klZ*1}Q(z_Y8n_5H!78{DR>Ld|!aleV zo`v(^*Kh{B0Uw2T-~(`Ou~H8y)vZ=BC}rUpC=Tp^FT%Z04F3oxHY~$Z;DbC%)?MF zXn}sjP$KDrv*9@?hQEN*;Z-P#`~c^`KcOg5jMozAY#8Kz)xbauwnC9?2Nc8mpj><$ zJ_yf2x#%nSB)kU2;RRDU3wObCn1i+OOBjYXp`4pPEw4ZmOp)g2V7El{E$%)B3(Az* z1ed}kFbnJ887R^jxCq{aZLpf#MA|rPhM&Nv;B~kG))THMn}sqz1pj~rik;C4^naeg zFNApnu0yZ2@H;4qRB}23Yhe`jL5cJ)*Z`ZUz|+uy;&|mur3gyB1Vza>lmOm=N8t#J z!_Bh@5{}M7f6d?q3kf(Goy37G6pznCk@_FVS+yRoM2TnNLYRfM@FbKRUxlK`btpO3 z;)xlO12(CfL24pOy4S==h)t>5cSyx)?5^OB1Z^nTVW} zgOdNN#dI>M3g{C}3Qd}*Ckja#C1sQx&lOkJFpwq{KVJsA-AV4&mQGu#T{`LLE+_f2 z9ZBo-6?cTW_=?c3al!(i6)m+u36~xo}}sV0J9snc2yQTQZJrOJDthU+hj-Be%9gfx2$t<3kF{RwkZyYf4U(sWxkmAF3rm=7vi)6*NXNj$dS|>11}hfzr_QhPrTAhgUT%Z(O>( zG5192%E0RSP<^S=X2c9@?+w#$E|^i%ugx&SLDTQOWkzIkRB*-XF_+Ddx#;zpem67q zO!rB?*zfiF>le&LIe3Mo9u8kJL*p9*tf~`*5pj?_9Pkbj^84D?5kdTeWBH|f8SMXmZJ5*CC87*S*pfj; O=2 && " "n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 3.2.2\n" +"X-Generator: Poedit 3.3.1\n" #: lib/gui/command.py:184 msgid "Output command line options to the console" @@ -129,62 +129,6 @@ msgstr "Сохранить {}(ы) в файл" msgid "Enable or disable {} display" msgstr "Включить или выключить отображение {}" -#: lib/gui/menu.py:32 -msgid "faceswap.dev - Guides and Forum" -msgstr "faceswap.dev - Руководства и Форум" - -#: lib/gui/menu.py:33 -msgid "Patreon - Support this project" -msgstr "Patreon - Поддержите этот проект" - -#: lib/gui/menu.py:34 -msgid "Discord - The FaceSwap Discord server" -msgstr "Discord - Discord сервер Faceswap" - -#: lib/gui/menu.py:35 -msgid "Github - Our Source Code" -msgstr "Github - Наш исходный код" - -#: lib/gui/menu.py:527 -msgid "Configure {} settings..." -msgstr "Настройка параметров {}..." - -#: lib/gui/menu.py:535 -msgid "Project" -msgstr "Проект" - -#: lib/gui/menu.py:535 -msgid "currently selected Task" -msgstr "текущая выбранная задача" - -#: lib/gui/menu.py:537 -msgid "Reload {} from disk" -msgstr "Перезагрузить {} из диска" - -#: lib/gui/menu.py:539 -msgid "Create a new {}..." -msgstr "Создать новый {}..." - -#: lib/gui/menu.py:541 -msgid "Reset {} to default" -msgstr "Сбросить {} по умолчанию" - -#: lib/gui/menu.py:543 -msgid "Save {}" -msgstr "Сохранить {}" - -#: lib/gui/menu.py:545 -msgid "Save {} as..." -msgstr "Сохранить {} как..." - -#: lib/gui/menu.py:549 -msgid " from a task or project file" -msgstr " из файла задачи или проекта" - -#: lib/gui/menu.py:550 -msgid "Load {}..." -msgstr "Загрузить {}..." - #: lib/gui/popup_configure.py:209 msgid "Close without saving" msgstr "Закрыть без сохранения" From a66214c58ea14a3fa1b0718eb5c9f04da938ef9d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 8 Jun 2023 16:45:18 +0100 Subject: [PATCH 815/981] locales: Add global extract settings --- locales/plugins.extract._config.pot | 117 ++++++++++++++++++++++++++++ plugins/extract/_config.py | 111 +++++++++++++------------- 2 files changed, 175 insertions(+), 53 deletions(-) create mode 100644 locales/plugins.extract._config.pot diff --git a/locales/plugins.extract._config.pot b/locales/plugins.extract._config.pot new file mode 100644 index 0000000000..0c909e292c --- /dev/null +++ b/locales/plugins.extract._config.pot @@ -0,0 +1,117 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-06-08 16:43+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#: plugins/extract/_config.py:32 +msgid "Options that apply to all extraction plugins" +msgstr "" + +#: plugins/extract/_config.py:38 +msgid "settings" +msgstr "" + +#: plugins/extract/_config.py:39 +msgid "" +"[Nvidia Only]. Enable the Tensorflow GPU `allow_growth` configuration " +"option. This option prevents Tensorflow from allocating all of the GPU VRAM " +"at launch but can lead to higher VRAM fragmentation and slower performance. " +"Should only be enabled if you are having problems running extraction." +msgstr "" + +#: plugins/extract/_config.py:50 plugins/extract/_config.py:64 +#: plugins/extract/_config.py:78 plugins/extract/_config.py:89 +#: plugins/extract/_config.py:99 plugins/extract/_config.py:108 +#: plugins/extract/_config.py:119 +msgid "filters" +msgstr "" + +#: plugins/extract/_config.py:51 +msgid "" +"Filters out faces below this size. This is a multiplier of the minimum " +"dimension of the frame (i.e. 1280x720 = 720). If the original face extract " +"box is smaller than the minimum dimension times this multiplier, it is " +"considered a false positive and discarded. Faces which are found to be " +"unusually smaller than the frame tend to be misaligned images, except in " +"extreme long-shots. These can be usually be safely discarded." +msgstr "" + +#: plugins/extract/_config.py:65 +msgid "" +"Filters out faces above this size. This is a multiplier of the minimum " +"dimension of the frame (i.e. 1280x720 = 720). If the original face extract " +"box is larger than the minimum dimension times this multiplier, it is " +"considered a false positive and discarded. Faces which are found to be " +"unusually larger than the frame tend to be misaligned images except in " +"extreme close-ups. These can be usually be safely discarded." +msgstr "" + +#: plugins/extract/_config.py:79 +msgid "" +"Filters out faces who's landmarks are above this distance from an 'average' " +"face. Values above 15 tend to be fairly safe. Values above 10 will remove " +"more false positives, but may also filter out some faces at extreme angles." +msgstr "" + +#: plugins/extract/_config.py:90 +msgid "" +"Filters out faces who's calculated roll is greater than zero +/- this value " +"in degrees. Aligned faces should have a roll value close to zero. Values " +"that are a significant distance from 0 degrees tend to be misaligned images. " +"These can usually be safely disgarded." +msgstr "" + +#: plugins/extract/_config.py:100 +msgid "" +"Filters out faces where the lowest point of the aligned face's eye or " +"eyebrow is lower than the highest point of the aligned face's mouth. Any " +"faces where this occurs are misaligned and can be safely disgarded." +msgstr "" + +#: plugins/extract/_config.py:109 +msgid "" +"If enabled, and 're-feed' has been selected for extraction, then interim " +"alignments will be filtered prior to averaging the final landmarks. This can " +"help improve the final alignments by removing any obvious misaligns from the " +"interim results, and may also help pick up difficult alignments. If " +"disabled, then all re-feed results will be averaged." +msgstr "" + +#: plugins/extract/_config.py:120 +msgid "" +"If enabled, saves any filtered out images into a sub-folder during the " +"extraction process. If disabled, filtered faces are deleted. Note: The faces " +"will always be filtered out of the alignments file, regardless of whether " +"you keep the faces or not." +msgstr "" + +#: plugins/extract/_config.py:129 plugins/extract/_config.py:138 +msgid "re-align" +msgstr "" + +#: plugins/extract/_config.py:130 +msgid "" +"If enabled, and 're-align' has been selected for extraction, then all re-" +"feed iterations are re-aligned. If disabled, then only the final averaged " +"output from re-feed will be re-aligned." +msgstr "" + +#: plugins/extract/_config.py:139 +msgid "" +"If enabled, and 're-align' has been selected for extraction, then any " +"alignments which would be filtered out will not be re-aligned." +msgstr "" diff --git a/plugins/extract/_config.py b/plugins/extract/_config.py index fc135c6b20..570d12aaa4 100644 --- a/plugins/extract/_config.py +++ b/plugins/extract/_config.py @@ -1,40 +1,45 @@ #!/usr/bin/env python3 """ Default configurations for extract """ +import gettext import logging import os from lib.config import FaceswapConfig +# LOCALES +_LANG = gettext.translation("plugins.extract._config", localedir="locales", fallback=True) +_ = _LANG.gettext + logger = logging.getLogger(__name__) # pylint: disable=invalid-name class Config(FaceswapConfig): """ Config File for Extraction """ - def set_defaults(self): + def set_defaults(self) -> None: """ Set the default values for config """ logger.debug("Setting defaults") self.set_globals() self._defaults_from_plugin(os.path.dirname(__file__)) - def set_globals(self): + def set_globals(self) -> None: """ Set the global options for extract """ logger.debug("Setting global config") section = "global" - self.add_section(section, "Options that apply to all extraction plugins") + self.add_section(section, _("Options that apply to all extraction plugins")) self.add_item( section=section, title="allow_growth", datatype=bool, default=False, - group="settings", - info="[Nvidia Only]. Enable the Tensorflow GPU `allow_growth` configuration option. " - "This option prevents Tensorflow from allocating all of the GPU VRAM at launch " - "but can lead to higher VRAM fragmentation and slower performance. Should only " - "be enabled if you are having problems running extraction.") + group=_("settings"), + info=_("[Nvidia Only]. Enable the Tensorflow GPU `allow_growth` configuration option. " + "This option prevents Tensorflow from allocating all of the GPU VRAM at launch " + "but can lead to higher VRAM fragmentation and slower performance. Should only " + "be enabled if you are having problems running extraction.")) self.add_item( section=section, title="aligner_min_scale", @@ -42,13 +47,13 @@ def set_globals(self): min_max=(0.0, 1.0), rounding=2, default=0.07, - group="filters", - info="Filters out faces below this size. This is a multiplier of the minimum " - "dimension of the frame (i.e. 1280x720 = 720). If the original face extract " - "box is smaller than the minimum dimension times this multiplier, it is " - "considered a false positive and discarded. Faces which are found to be " - "unusually smaller than the frame tend to be misaligned images, except in " - "extreme long-shots. These can be usually be safely discarded.") + group=_("filters"), + info=_("Filters out faces below this size. This is a multiplier of the minimum " + "dimension of the frame (i.e. 1280x720 = 720). If the original face extract " + "box is smaller than the minimum dimension times this multiplier, it is " + "considered a false positive and discarded. Faces which are found to be " + "unusually smaller than the frame tend to be misaligned images, except in " + "extreme long-shots. These can be usually be safely discarded.")) self.add_item( section=section, title="aligner_max_scale", @@ -56,13 +61,13 @@ def set_globals(self): min_max=(0.0, 10.0), rounding=2, default=2.00, - group="filters", - info="Filters out faces above this size. This is a multiplier of the minimum " - "dimension of the frame (i.e. 1280x720 = 720). If the original face extract " - "box is larger than the minimum dimension times this multiplier, it is " - "considered a false positive and discarded. Faces which are found to be " - "unusually larger than the frame tend to be misaligned images except in extreme " - "close-ups. These can be usually be safely discarded.") + group=_("filters"), + info=_("Filters out faces above this size. This is a multiplier of the minimum " + "dimension of the frame (i.e. 1280x720 = 720). If the original face extract " + "box is larger than the minimum dimension times this multiplier, it is " + "considered a false positive and discarded. Faces which are found to be " + "unusually larger than the frame tend to be misaligned images except in " + "extreme close-ups. These can be usually be safely discarded.")) self.add_item( section=section, title="aligner_distance", @@ -70,10 +75,10 @@ def set_globals(self): min_max=(0.0, 45.0), rounding=1, default=22.5, - group="filters", - info="Filters out faces who's landmarks are above this distance from an 'average' " - "face. Values above 15 tend to be fairly safe. Values above 10 will remove more " - "false positives, but may also filter out some faces at extreme angles.") + group=_("filters"), + info=_("Filters out faces who's landmarks are above this distance from an 'average' " + "face. Values above 15 tend to be fairly safe. Values above 10 will remove " + "more false positives, but may also filter out some faces at extreme angles.")) self.add_item( section=section, title="aligner_roll", @@ -81,55 +86,55 @@ def set_globals(self): min_max=(0.0, 90.0), rounding=1, default=45.0, - group="filters", - info="Filters out faces who's calculated roll is greater than zero +/- this value in " - "degrees. Aligned faces should have a roll value close to zero. Values that are a " - "significant distance from 0 degrees tend to be misaligned images. These can usually " - "be safely disgarded.") + group=_("filters"), + info=_("Filters out faces who's calculated roll is greater than zero +/- this value " + "in degrees. Aligned faces should have a roll value close to zero. Values that " + "are a significant distance from 0 degrees tend to be misaligned images. These " + "can usually be safely disgarded.")) self.add_item( section=section, title="aligner_features", datatype=bool, default=True, - group="filters", - info="Filters out faces where the lowest point of the aligned face's eye or eyebrow " - "is lower than the highest point of the aligned face's mouth. Any faces where this " - "occurs are misaligned and can be safely disgarded.") + group=_("filters"), + info=_("Filters out faces where the lowest point of the aligned face's eye or eyebrow " + "is lower than the highest point of the aligned face's mouth. Any faces where " + "this occurs are misaligned and can be safely disgarded.")) self.add_item( section=section, title="filter_refeed", datatype=bool, default=True, - group="filters", - info="If enabled, and 're-feed' has been selected for extraction, then interim " - "alignments will be filtered prior to averaging the final landmarks. This can " - "help improve the final alignments by removing any obvious misaligns from the " - "interim results, and may also help pick up difficult alignments. If disabled, " - "then all re-feed results will be averaged.") + group=_("filters"), + info=_("If enabled, and 're-feed' has been selected for extraction, then interim " + "alignments will be filtered prior to averaging the final landmarks. This can " + "help improve the final alignments by removing any obvious misaligns from the " + "interim results, and may also help pick up difficult alignments. If disabled, " + "then all re-feed results will be averaged.")) self.add_item( section=section, title="save_filtered", datatype=bool, default=False, - group="filters", - info="If enabled, saves any filtered out images into a sub-folder during the " - "extraction process. If disabled, filtered faces are deleted. Note: The faces " - "will always be filtered out of the alignments file, regardless of whether you " - "keep the faces or not.") + group=_("filters"), + info=_("If enabled, saves any filtered out images into a sub-folder during the " + "extraction process. If disabled, filtered faces are deleted. Note: The faces " + "will always be filtered out of the alignments file, regardless of whether you " + "keep the faces or not.")) self.add_item( section=section, title="realign_refeeds", datatype=bool, default=True, - group="re-align", - info="If enabled, and 're-align' has been selected for extraction, then all re-feed " - "iterations are re-aligned. If disabled, then only the final averaged output " - "from re-feed will be re-aligned.") + group=_("re-align"), + info=_("If enabled, and 're-align' has been selected for extraction, then all re-feed " + "iterations are re-aligned. If disabled, then only the final averaged output " + "from re-feed will be re-aligned.")) self.add_item( section=section, title="filter_realign", datatype=bool, default=True, - group="re-align", - info="If enabled, and 're-align' has been selected for extraction, then any " - "alignments which would be filtered out will not be re-aligned.") + group=_("re-align"), + info=_("If enabled, and 're-align' has been selected for extraction, then any " + "alignments which would be filtered out will not be re-aligned.")) From 02239d4183322c6a7ba3b5716523e236fc0735b9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 11 Jun 2023 23:30:05 +0100 Subject: [PATCH 816/981] locales: Train settings --- lib/config.py | 25 +- locales/lib.config.pot | 61 ++++ locales/plugins.train._config.pot | 552 ++++++++++++++++++++++++++++ plugins/extract/_config.py | 2 +- plugins/train/_config.py | 585 ++++++++++++++++-------------- 5 files changed, 938 insertions(+), 287 deletions(-) create mode 100644 locales/lib.config.pot create mode 100644 locales/plugins.train._config.pot diff --git a/lib/config.py b/lib/config.py index 6dca167894..eb7cb19194 100644 --- a/lib/config.py +++ b/lib/config.py @@ -3,6 +3,7 @@ Extends out :class:`configparser.ConfigParser` functionality by checking for default configuration updates and returning data in it's correct format """ +import gettext import logging import os import sys @@ -16,6 +17,10 @@ from lib.utils import full_path_split +# LOCALES +_LANG = gettext.translation("lib.config", localedir="locales", fallback=True) +_ = _LANG.gettext + # Can't type OrderedDict fully on Python 3.8 or lower if sys.version_info < (3, 9): OrderedDictSectionType = OrderedDict @@ -282,7 +287,9 @@ def _get_config_file(self, configfile: Optional[str]) -> str: logger.error(err) raise ValueError(err) return configfile - dirname = os.path.dirname(sys.modules[self.__module__].__file__) + filepath = sys.modules[self.__module__].__file__ + assert filepath is not None + dirname = os.path.dirname(filepath) folder, fname = os.path.split(dirname) retval = os.path.join(os.path.dirname(folder), "config", f"{fname}.ini") logger.debug("Config File location: '%s'", retval) @@ -383,23 +390,23 @@ def _expand_helptext(cls, """ Add extra helptext info from parameters """ helptext += "\n" if not fixed: - helptext += "\nThis option can be updated for existing models.\n" + helptext += _("\nThis option can be updated for existing models.\n") if datatype == list: - helptext += ("\nIf selecting multiple options then each option should be separated " - "by a space or a comma (e.g. item1, item2, item3)\n") + helptext += _("\nIf selecting multiple options then each option should be separated " + "by a space or a comma (e.g. item1, item2, item3)\n") if choices and choices != "colorchooser": - helptext += f"\nChoose from: {choices}" + helptext += _("\nChoose from: {}").format(choices) elif datatype == bool: - helptext += "\nChoose from: True, False" + helptext += _("\nChoose from: True, False") elif datatype == int: assert min_max is not None cmin, cmax = min_max - helptext += f"\nSelect an integer between {cmin} and {cmax}" + helptext += _("\nSelect an integer between {} and {}").format(cmin, cmax) elif datatype == float: assert min_max is not None cmin, cmax = min_max - helptext += f"\nSelect a decimal number between {cmin} and {cmax}" - helptext += f"\n[Default: {default}]" + helptext += _("\nSelect a decimal number between {} and {}").format(cmin, cmax) + helptext += _("\n[Default: {}]").format(default) return helptext def _check_exists(self) -> bool: diff --git a/locales/lib.config.pot b/locales/lib.config.pot new file mode 100644 index 0000000000..b1144f809c --- /dev/null +++ b/locales/lib.config.pot @@ -0,0 +1,61 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-06-11 23:28+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#: lib/config.py:393 +msgid "" +"\n" +"This option can be updated for existing models.\n" +msgstr "" + +#: lib/config.py:395 +msgid "" +"\n" +"If selecting multiple options then each option should be separated by a " +"space or a comma (e.g. item1, item2, item3)\n" +msgstr "" + +#: lib/config.py:398 +msgid "" +"\n" +"Choose from: {}" +msgstr "" + +#: lib/config.py:400 +msgid "" +"\n" +"Choose from: True, False" +msgstr "" + +#: lib/config.py:404 +msgid "" +"\n" +"Select an integer between {} and {}" +msgstr "" + +#: lib/config.py:408 +msgid "" +"\n" +"Select a decimal number between {} and {}" +msgstr "" + +#: lib/config.py:409 +msgid "" +"\n" +"[Default: {}]" +msgstr "" diff --git a/locales/plugins.train._config.pot b/locales/plugins.train._config.pot new file mode 100644 index 0000000000..45d53c2254 --- /dev/null +++ b/locales/plugins.train._config.pot @@ -0,0 +1,552 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-06-11 23:20+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#: plugins/train/_config.py:17 +msgid "" +"\n" +"NB: Unless specifically stated, values changed here will only take effect " +"when creating a new model." +msgstr "" + +#: plugins/train/_config.py:22 +msgid "" +"Focal Frequency Loss. Analyzes the frequency spectrum of the images rather " +"than the images themselves. This loss function can be used on its own, but " +"the original paper found increased benefits when using it as a complementary " +"loss to another spacial loss function (e.g. MSE). Ref: Focal Frequency Loss " +"for Image Reconstruction and Synthesis https://arxiv.org/pdf/2012.12821.pdf " +"NB: This loss does not currently work on AMD cards." +msgstr "" + +#: plugins/train/_config.py:29 +msgid "" +"Nvidia FLIP. A perceptual loss measure that approximates the difference " +"perceived by humans as they alternate quickly (or flip) between two images. " +"Used on its own and this loss function creates a distinct grid on the " +"output. However it can be helpful when used as a complimentary loss " +"function. Ref: FLIP: A Difference Evaluator for Alternating Images: https://" +"research.nvidia.com/sites/default/files/node/3260/FLIP_Paper.pdf" +msgstr "" + +#: plugins/train/_config.py:36 +msgid "" +"Gradient Magnitude Similarity Deviation seeks to match the global standard " +"deviation of the pixel to pixel differences between two images. Similar in " +"approach to SSIM. Ref: Gradient Magnitude Similarity Deviation: An Highly " +"Efficient Perceptual Image Quality Index https://arxiv.org/ftp/arxiv/" +"papers/1308/1308.3052.pdf" +msgstr "" + +#: plugins/train/_config.py:41 +msgid "" +"The L_inf norm will reduce the largest individual pixel error in an image. " +"As each largest error is minimized sequentially, the overall error is " +"improved. This loss will be extremely focused on outliers." +msgstr "" + +#: plugins/train/_config.py:45 +msgid "" +"Laplacian Pyramid Loss. Attempts to improve results by focussing on edges " +"using Laplacian Pyramids. As this loss function gives priority to edges over " +"other low-frequency information, like color, it should not be used on its " +"own. The original implementation uses this loss as a complimentary function " +"to MSE. Ref: Optimizing the Latent Space of Generative Networks https://" +"arxiv.org/abs/1707.05776" +msgstr "" + +#: plugins/train/_config.py:52 +msgid "" +"LPIPS is a perceptual loss that uses the feature outputs of other pretrained " +"models as a loss metric. Be aware that this loss function will use more " +"VRAM. Used on its own and this loss will create a distinct moire pattern on " +"the output, however it can be helpful as a complimentary loss function. The " +"output of this function is strong, so depending on your chosen primary loss " +"function, you are unlikely going to want to set the weight above about 25%. " +"Ref: The Unreasonable Effectiveness of Deep Features as a Perceptual Metric " +"http://arxiv.org/abs/1801.03924\n" +"This variant uses the AlexNet backbone. A fairly light and old model which " +"performed best in the paper's original implementation.\n" +"NB: For AMD Users the final linear layer is not implemented." +msgstr "" + +#: plugins/train/_config.py:62 +msgid "" +"Same as lpips_alex, but using the SqueezeNet backbone. A more lightweight " +"version of AlexNet.\n" +"NB: For AMD Users the final linear layer is not implemented." +msgstr "" + +#: plugins/train/_config.py:65 +msgid "" +"Same as lpips_alex, but using the VGG16 backbone. A more heavyweight model.\n" +"NB: For AMD Users the final linear layer is not implemented." +msgstr "" + +#: plugins/train/_config.py:68 +msgid "" +"log(cosh(x)) acts similar to MSE for small errors and to MAE for large " +"errors. Like MSE, it is very stable and prevents overshoots when errors are " +"near zero. Like MAE, it is robust to outliers." +msgstr "" + +#: plugins/train/_config.py:72 +msgid "" +"Mean absolute error will guide reconstructions of each pixel towards its " +"median value in the training dataset. Robust to outliers but as a median, it " +"can potentially ignore some infrequent image types in the dataset." +msgstr "" + +#: plugins/train/_config.py:76 +msgid "" +"Mean squared error will guide reconstructions of each pixel towards its " +"average value in the training dataset. As an avg, it will be susceptible to " +"outliers and typically produces slightly blurrier results. Ref: Multi-Scale " +"Structural Similarity for Image Quality Assessment https://www.cns.nyu.edu/" +"pub/eero/wang03b.pdf" +msgstr "" + +#: plugins/train/_config.py:81 +msgid "" +"Multiscale Structural Similarity Index Metric is similar to SSIM except that " +"it performs the calculations along multiple scales of the input image." +msgstr "" + +#: plugins/train/_config.py:84 +msgid "" +"Smooth_L1 is a modification of the MAE loss to correct two of its " +"disadvantages. This loss has improved stability and guidance for small " +"errors. Ref: A General and Adaptive Robust Loss Function https://arxiv.org/" +"pdf/1701.03077.pdf" +msgstr "" + +#: plugins/train/_config.py:88 +msgid "" +"Structural Similarity Index Metric is a perception-based loss that considers " +"changes in texture, luminance, contrast, and local spatial statistics of an " +"image. Potentially delivers more realistic looking images. Ref: Image " +"Quality Assessment: From Error Visibility to Structural Similarity http://" +"www.cns.nyu.edu/pub/eero/wang03-reprint.pdf" +msgstr "" + +#: plugins/train/_config.py:93 +msgid "" +"Instead of minimizing the difference between the absolute value of each " +"pixel in two reference images, compute the pixel to pixel spatial difference " +"in each image and then minimize that difference between two images. Allows " +"for large color shifts, but maintains the structure of the image." +msgstr "" + +#: plugins/train/_config.py:97 +msgid "Do not use an additional loss function." +msgstr "" + +#: plugins/train/_config.py:117 +msgid "Options that apply to all models" +msgstr "" + +#: plugins/train/_config.py:126 plugins/train/_config.py:150 +msgid "face" +msgstr "" + +#: plugins/train/_config.py:128 +msgid "" +"How to center the training image. The extracted images are centered on the " +"middle of the skull based on the face's estimated pose. A subsection of " +"these images are used for training. The centering used dictates how this " +"subsection will be cropped from the aligned images.\n" +"\tface: Centers the training image on the center of the face, adjusting for " +"pitch and yaw.\n" +"\thead: Centers the training image on the center of the head, adjusting for " +"pitch and yaw. NB: You should only select head centering if you intend to " +"include the full head (including hair) in the final swap. This may give " +"mixed results. Additionally, it is only worth choosing head centering if you " +"are training with a mask that includes the hair (e.g. BiSeNet-FP-Head).\n" +"\tlegacy: The 'original' extraction technique. Centers the training image " +"near the tip of the nose with no adjustment. Can result in the edges of the " +"face appearing outside of the training area." +msgstr "" + +#: plugins/train/_config.py:152 +msgid "" +"How much of the extracted image to train on. A lower coverage will limit the " +"model's scope to a zoomed-in central area while higher amounts can include " +"the entire face. A trade-off exists between lower amounts given more detail " +"versus higher amounts avoiding noticeable swap transitions. For 'Face' " +"centering you will want to leave this above 75%. For Head centering you will " +"most likely want to set this to 100%. Sensible values for 'Legacy' centering " +"are:\n" +"\t62.5% spans from eyebrow to eyebrow.\n" +"\t75.0% spans from temple to temple.\n" +"\t87.5% spans from ear to ear.\n" +"\t100.0% is a mugshot." +msgstr "" + +#: plugins/train/_config.py:168 plugins/train/_config.py:179 +msgid "initialization" +msgstr "" + +#: plugins/train/_config.py:170 +msgid "" +"Use ICNR to tile the default initializer in a repeating pattern. This " +"strategy is designed for pairing with sub-pixel / pixel shuffler to reduce " +"the 'checkerboard effect' in image reconstruction. \n" +"\t https://arxiv.org/ftp/arxiv/papers/1707/1707.02937.pdf" +msgstr "" + +#: plugins/train/_config.py:181 +msgid "" +"Use Convolution Aware Initialization for convolutional layers. This can help " +"eradicate the vanishing and exploding gradient problem as well as lead to " +"higher accuracy, lower loss and faster convergence.\n" +"NB:\n" +"\t This can use more VRAM when creating a new model so you may want to lower " +"the batch size for the first run. The batch size can be raised again when " +"reloading the model. \n" +"\t Multi-GPU is not supported for this option, so you should start the model " +"on a single GPU. Once training has started, you can stop training, enable " +"multi-GPU and resume.\n" +"\t Building the model will likely take several minutes as the calculations " +"for this initialization technique are expensive. This will only impact " +"starting a new model." +msgstr "" + +#: plugins/train/_config.py:198 plugins/train/_config.py:223 +#: plugins/train/_config.py:238 plugins/train/_config.py:265 +msgid "optimizer" +msgstr "" + +#: plugins/train/_config.py:202 +msgid "" +"The optimizer to use.\n" +"\t adabelief - Adapting Stepsizes by the Belief in Observed Gradients. An " +"optimizer with the aim to converge faster, generalize better and remain more " +"stable. (https://arxiv.org/abs/2010.07468). NB: Epsilon for AdaBelief needs " +"to be set to a smaller value than other Optimizers. Generally setting the " +"'Epsilon Exponent' to around '-16' should work.\n" +"\t adam - Adaptive Moment Optimization. A stochastic gradient descent method " +"that is based on adaptive estimation of first-order and second-order " +"moments.\n" +"\t nadam - Adaptive Moment Optimization with Nesterov Momentum. Much like " +"Adam but uses a different formula for calculating momentum.\n" +"\t rms-prop - Root Mean Square Propagation. Maintains a moving (discounted) " +"average of the square of the gradients. Divides the gradient by the root of " +"this average." +msgstr "" + +#: plugins/train/_config.py:225 +msgid "" +"Learning rate - how fast your network will learn (how large are the " +"modifications to the model weights after one batch of training). Values that " +"are too large might result in model crashes and the inability of the model " +"to find the best solution. Values that are too small might be unable to " +"escape from dead-ends and find the best global minimum." +msgstr "" + +#: plugins/train/_config.py:240 +msgid "" +"The epsilon adds a small constant to weight updates to attempt to avoid " +"'divide by zero' errors. Unless you are using the AdaBelief Optimizer, then " +"Generally this option should be left at default value, For AdaBelief, " +"setting this to around '-16' should work.\n" +"In all instances if you are getting 'NaN' loss values, and have been unable " +"to resolve the issue any other way (for example, increasing batch size, or " +"lowering learning rate), then raising the epsilon can lead to a more stable " +"model. It may, however, come at the cost of slower training and a less " +"accurate final result.\n" +"NB: The value given here is the 'exponent' to the epsilon. For example, " +"choosing '-7' will set the epsilon to 1e-7. Choosing '-3' will set the " +"epsilon to 0.001 (1e-3)." +msgstr "" + +#: plugins/train/_config.py:258 +msgid "" +"[Not PlaidML] Apply AutoClipping to the gradients. AutoClip analyzes the " +"gradient weights and adjusts the normalization value dynamically to fit the " +"data. Can help prevent NaNs and improve model optimization at the expense of " +"VRAM. Ref: AutoClip: Adaptive Gradient Clipping for Source Separation " +"Networks https://arxiv.org/abs/2007.14469" +msgstr "" + +#: plugins/train/_config.py:271 plugins/train/_config.py:283 +#: plugins/train/_config.py:297 plugins/train/_config.py:314 +msgid "network" +msgstr "" + +#: plugins/train/_config.py:273 +msgid "" +"Use reflection padding rather than zero padding with convolutions. Each " +"convolution must pad the image boundaries to maintain the proper sizing. " +"More complex padding schemes can reduce artifacts at the border of the " +"image.\n" +"\t http://www-cs.engr.ccny.cuny.edu/~wolberg/cs470/hw/hw2_pad.txt" +msgstr "" + +#: plugins/train/_config.py:286 +msgid "" +"[Nvidia Only]. Enable the Tensorflow GPU 'allow_growth' configuration " +"option. This option prevents Tensorflow from allocating all of the GPU VRAM " +"at launch but can lead to higher VRAM fragmentation and slower performance. " +"Should only be enabled if you are receiving errors regarding 'cuDNN fails to " +"initialize' when commencing training." +msgstr "" + +#: plugins/train/_config.py:299 +msgid "" +"[Not PlaidML], NVIDIA GPUs can run operations in float16 faster than in " +"float32. Mixed precision allows you to use a mix of float16 with float32, to " +"get the performance benefits from float16 and the numeric stability benefits " +"from float32.\n" +"\n" +"This is untested on DirectML backend, but will run on most Nvidia models. it " +"will only speed up training on more recent GPUs. Those with compute " +"capability 7.0 or higher will see the greatest performance benefit from " +"mixed precision because they have Tensor Cores. Older GPUs offer no math " +"performance benefit for using mixed precision, however memory and bandwidth " +"savings can enable some speedups. Generally RTX GPUs and later will offer " +"the most benefit." +msgstr "" + +#: plugins/train/_config.py:316 +msgid "" +"If a 'NaN' is generated in the model, this means that the model has " +"corrupted and the model is likely to start deteriorating from this point on. " +"Enabling NaN protection will stop training immediately in the event of a " +"NaN. The last save will not contain the NaN, so you may still be able to " +"rescue your model." +msgstr "" + +#: plugins/train/_config.py:329 +msgid "convert" +msgstr "" + +#: plugins/train/_config.py:331 +msgid "" +"[GPU Only]. The number of faces to feed through the model at once when " +"running the Convert process.\n" +"\n" +"NB: Increasing this figure is unlikely to improve convert speed, however, if " +"you are getting Out of Memory errors, then you may want to reduce the batch " +"size." +msgstr "" + +#: plugins/train/_config.py:350 +msgid "" +"Loss configuration options\n" +"Loss is the mechanism by which a Neural Network judges how well it thinks " +"that it is recreating a face." +msgstr "" + +#: plugins/train/_config.py:357 plugins/train/_config.py:369 +#: plugins/train/_config.py:382 plugins/train/_config.py:402 +#: plugins/train/_config.py:414 plugins/train/_config.py:434 +#: plugins/train/_config.py:446 plugins/train/_config.py:466 +#: plugins/train/_config.py:482 plugins/train/_config.py:498 +#: plugins/train/_config.py:515 +msgid "loss" +msgstr "" + +#: plugins/train/_config.py:361 +msgid "The loss function to use." +msgstr "" + +#: plugins/train/_config.py:373 +msgid "" +"The second loss function to use. If using a structural based loss (such as " +"SSIM, MS-SSIM or GMSD) it is common to add an L1 regularization(MAE) or L2 " +"regularization (MSE) function. You can adjust the weighting of this loss " +"function with the loss_weight_2 option." +msgstr "" + +#: plugins/train/_config.py:388 +msgid "" +"The amount of weight to apply to the second loss function.\n" +"\n" +"\n" +"\n" +"The value given here is as a percentage denoting how much the selected " +"function should contribute to the overall loss cost of the model. For " +"example:\n" +"\t 100 - The loss calculated for the second loss function will be applied at " +"its full amount towards the overall loss score. \n" +"\t 25 - The loss calculated for the second loss function will be reduced by " +"a quarter prior to adding to the overall loss score. \n" +"\t 400 - The loss calculated for the second loss function will be mulitplied " +"4 times prior to adding to the overall loss score. \n" +"\t 0 - Disables the second loss function altogether." +msgstr "" + +#: plugins/train/_config.py:406 +msgid "" +"The third loss function to use. You can adjust the weighting of this loss " +"function with the loss_weight_3 option." +msgstr "" + +#: plugins/train/_config.py:420 +msgid "" +"The amount of weight to apply to the third loss function.\n" +"\n" +"\n" +"\n" +"The value given here is as a percentage denoting how much the selected " +"function should contribute to the overall loss cost of the model. For " +"example:\n" +"\t 100 - The loss calculated for the third loss function will be applied at " +"its full amount towards the overall loss score. \n" +"\t 25 - The loss calculated for the third loss function will be reduced by a " +"quarter prior to adding to the overall loss score. \n" +"\t 400 - The loss calculated for the third loss function will be mulitplied " +"4 times prior to adding to the overall loss score. \n" +"\t 0 - Disables the third loss function altogether." +msgstr "" + +#: plugins/train/_config.py:438 +msgid "" +"The fourth loss function to use. You can adjust the weighting of this loss " +"function with the loss_weight_3 option." +msgstr "" + +#: plugins/train/_config.py:452 +msgid "" +"The amount of weight to apply to the fourth loss function.\n" +"\n" +"\n" +"\n" +"The value given here is as a percentage denoting how much the selected " +"function should contribute to the overall loss cost of the model. For " +"example:\n" +"\t 100 - The loss calculated for the fourth loss function will be applied at " +"its full amount towards the overall loss score. \n" +"\t 25 - The loss calculated for the fourth loss function will be reduced by " +"a quarter prior to adding to the overall loss score. \n" +"\t 400 - The loss calculated for the fourth loss function will be mulitplied " +"4 times prior to adding to the overall loss score. \n" +"\t 0 - Disables the fourth loss function altogether." +msgstr "" + +#: plugins/train/_config.py:471 +msgid "" +"The loss function to use when learning a mask.\n" +"\t MAE - Mean absolute error will guide reconstructions of each pixel " +"towards its median value in the training dataset. Robust to outliers but as " +"a median, it can potentially ignore some infrequent image types in the " +"dataset.\n" +"\t MSE - Mean squared error will guide reconstructions of each pixel towards " +"its average value in the training dataset. As an average, it will be " +"susceptible to outliers and typically produces slightly blurrier results." +msgstr "" + +#: plugins/train/_config.py:488 +msgid "" +"The amount of priority to give to the eyes.\n" +"\n" +"The value given here is as a multiplier of the main loss score. For " +"example:\n" +"\t 1 - The eyes will receive the same priority as the rest of the face. \n" +"\t 10 - The eyes will be given a score 10 times higher than the rest of the " +"face.\n" +"\n" +"NB: Penalized Mask Loss must be enable to use this option." +msgstr "" + +#: plugins/train/_config.py:504 +msgid "" +"The amount of priority to give to the mouth.\n" +"\n" +"The value given here is as a multiplier of the main loss score. For " +"Example:\n" +"\t 1 - The mouth will receive the same priority as the rest of the face. \n" +"\t 10 - The mouth will be given a score 10 times higher than the rest of the " +"face.\n" +"\n" +"NB: Penalized Mask Loss must be enable to use this option." +msgstr "" + +#: plugins/train/_config.py:517 +msgid "" +"Image loss function is weighted by mask presence. For areas of the image " +"without the facial mask, reconstruction errors will be ignored while the " +"masked face area is prioritized. May increase overall quality by focusing " +"attention on the core face area." +msgstr "" + +#: plugins/train/_config.py:528 plugins/train/_config.py:570 +#: plugins/train/_config.py:584 plugins/train/_config.py:593 +msgid "mask" +msgstr "" + +#: plugins/train/_config.py:531 +msgid "" +"The mask to be used for training. If you have selected 'Learn Mask' or " +"'Penalized Mask Loss' you must select a value other than 'none'. The " +"required mask should have been selected as part of the Extract process. If " +"it does not exist in the alignments file then it will be generated prior to " +"training commencing.\n" +"\tnone: Don't use a mask.\n" +"\tbisenet-fp_face: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'face' or " +"'legacy' centering.\n" +"\tbisenet-fp_head: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'head' " +"centering.\n" +"\tcomponents: 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.\n" +"\tcustom_face: Custom user created, face centered mask.\n" +"\tcustom_head: Custom user created, head centered mask.\n" +"\textended: 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.\n" +"\tvgg-clear: Mask designed to provide smart segmentation of mostly frontal " +"faces clear of obstructions. Profile faces and obstructions may result in " +"sub-par performance.\n" +"\tvgg-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.\n" +"\tunet-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." +msgstr "" + +#: plugins/train/_config.py:572 +msgid "" +"Apply gaussian blur to the mask input. This has the effect of smoothing the " +"edges of the mask, which can help with poorly calculated masks and give less " +"of a hard edge to the predicted mask. The size is in pixels (calculated from " +"a 128px mask). Set to 0 to not apply gaussian blur. This value should be " +"odd, if an even number is passed in then it will be rounded to the next odd " +"number." +msgstr "" + +#: plugins/train/_config.py:586 +msgid "" +"Sets pixels that are near white to white and near black to black. Set to 0 " +"for off." +msgstr "" + +#: plugins/train/_config.py:595 +msgid "" +"Dedicate a portion of the model to learning how to duplicate the input mask. " +"Increases VRAM usage in exchange for learning a quick ability to try to " +"replicate more complex mask models." +msgstr "" diff --git a/plugins/extract/_config.py b/plugins/extract/_config.py index 570d12aaa4..a966f78f72 100644 --- a/plugins/extract/_config.py +++ b/plugins/extract/_config.py @@ -11,7 +11,7 @@ _LANG = gettext.translation("plugins.extract._config", localedir="locales", fallback=True) _ = _LANG.gettext -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class Config(FaceswapConfig): diff --git a/plugins/train/_config.py b/plugins/train/_config.py index ece9a48475..44d9ce627a 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -1,46 +1,54 @@ #!/usr/bin/env python3 """ Default configurations for models """ +import gettext import logging import os from lib.config import FaceswapConfig from plugins.plugin_loader import PluginLoader -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +# LOCALES +_LANG = gettext.translation("plugins.train._config", localedir="locales", fallback=True) +_ = _LANG.gettext -ADDITIONAL_INFO = ("\nNB: Unless specifically stated, values changed here will only take effect " - "when creating a new model.") +logger = logging.getLogger(__name__) -_LOSS_HELP = dict( - ffl="Focal Frequency Loss. Analyzes the frequency spectrum of the images rather than the " +ADDITIONAL_INFO = _("\nNB: Unless specifically stated, values changed here will only take effect " + "when creating a new model.") + +_LOSS_HELP = { + "ffl": _( + "Focal Frequency Loss. Analyzes the frequency spectrum of the images rather than the " "images themselves. This loss function can be used on its own, but the original paper " "found increased benefits when using it as a complementary loss to another spacial loss " "function (e.g. MSE). Ref: Focal Frequency Loss for Image Reconstruction and Synthesis " - "https://arxiv.org/pdf/2012.12821.pdf NB: This loss does not currently work on AMD cards.", - flip="Nvidia FLIP. A perceptual loss measure that approximates the difference perceived by " - "humans as they alternate quickly (or flip) between two images. Used on its own and this " - "loss function creates a distinct grid on the output. However it can be helpful when " - "used as a complimentary loss function. Ref: FLIP: A Difference Evaluator for " - "Alternating Images: " - "https://research.nvidia.com/sites/default/files/node/3260/FLIP_Paper.pdf", - gmsd=( + "https://arxiv.org/pdf/2012.12821.pdf NB: This loss does not currently work on AMD " + "cards."), + "flip": _( + "Nvidia FLIP. A perceptual loss measure that approximates the difference perceived by " + "humans as they alternate quickly (or flip) between two images. Used on its own and this " + "loss function creates a distinct grid on the output. However it can be helpful when " + "used as a complimentary loss function. Ref: FLIP: A Difference Evaluator for " + "Alternating Images: " + "https://research.nvidia.com/sites/default/files/node/3260/FLIP_Paper.pdf"), + "gmsd": _( "Gradient Magnitude Similarity Deviation seeks to match the global standard deviation of " "the pixel to pixel differences between two images. Similar in approach to SSIM. Ref: " "Gradient Magnitude Similarity Deviation: An Highly Efficient Perceptual Image Quality " "Index https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf"), - l_inf_norm=( + "l_inf_norm": _( "The L_inf norm will reduce the largest individual pixel error in an image. As " "each largest error is minimized sequentially, the overall error is improved. This loss " "will be extremely focused on outliers."), - laploss=( + "laploss": _( "Laplacian Pyramid Loss. Attempts to improve results by focussing on edges using " "Laplacian Pyramids. As this loss function gives priority to edges over other low-" "frequency information, like color, it should not be used on its own. The original " "implementation uses this loss as a complimentary function to MSE. " "Ref: Optimizing the Latent Space of Generative Networks " "https://arxiv.org/abs/1707.05776"), - lpips_alex=( + "lpips_alex": _( "LPIPS is a perceptual loss that uses the feature outputs of other pretrained models as a " "loss metric. Be aware that this loss function will use more VRAM. Used on its own and " "this loss will create a distinct moire pattern on the output, however it can be helpful " @@ -50,42 +58,43 @@ "Metric http://arxiv.org/abs/1801.03924\nThis variant uses the AlexNet backbone. A fairly " "light and old model which performed best in the paper's original implementation.\nNB: " "For AMD Users the final linear layer is not implemented."), - lpips_squeeze=( + "lpips_squeeze": _( "Same as lpips_alex, but using the SqueezeNet backbone. A more lightweight " "version of AlexNet.\nNB: For AMD Users the final linear layer is not implemented."), - lpips_vgg16="Same as lpips_alex, but using the VGG16 backbone. A more heavyweight model.\n" - "NB: For AMD Users the final linear layer is not implemented.", - logcosh=( + "lpips_vgg16": _( + "Same as lpips_alex, but using the VGG16 backbone. A more heavyweight model.\n" + "NB: For AMD Users the final linear layer is not implemented."), + "logcosh": _( "log(cosh(x)) acts similar to MSE for small errors and to MAE for large errors. Like " "MSE, it is very stable and prevents overshoots when errors are near zero. Like MAE, it " "is robust to outliers."), - mae=( + "mae": _( "Mean absolute error will guide reconstructions of each pixel towards its median value in " "the training dataset. Robust to outliers but as a median, it can potentially ignore some " "infrequent image types in the dataset."), - mse=( + "mse": _( "Mean squared error will guide reconstructions of each pixel towards its average value in " "the training dataset. As an avg, it will be susceptible to outliers and typically " "produces slightly blurrier results. Ref: Multi-Scale Structural Similarity for Image " "Quality Assessment https://www.cns.nyu.edu/pub/eero/wang03b.pdf"), - ms_ssim=( + "ms_ssim": _( "Multiscale Structural Similarity Index Metric is similar to SSIM except that it " "performs the calculations along multiple scales of the input image."), - smooth_loss=( + "smooth_loss": _( "Smooth_L1 is a modification of the MAE loss to correct two of its disadvantages. " "This loss has improved stability and guidance for small errors. Ref: A General and " "Adaptive Robust Loss Function https://arxiv.org/pdf/1701.03077.pdf"), - ssim=( + "ssim": _( "Structural Similarity Index Metric is a perception-based loss that considers changes in " "texture, luminance, contrast, and local spatial statistics of an image. Potentially " "delivers more realistic looking images. Ref: Image Quality Assessment: From Error " "Visibility to Structural Similarity http://www.cns.nyu.edu/pub/eero/wang03-reprint.pdf"), - pixel_gradient_diff=( + "pixel_gradient_diff": _( "Instead of minimizing the difference between the absolute value of each " "pixel in two reference images, compute the pixel to pixel spatial difference in each " "image and then minimize that difference between two images. Allows for large color " "shifts, but maintains the structure of the image."), - none="Do not use an additional loss function.") + "none": _("Do not use an additional loss function.")} _NON_PRIMARY_LOSS = ["flip", "lpips_alex", "lpips_squeeze", "lpips_vgg16", "none"] @@ -105,7 +114,7 @@ def _set_globals(self) -> None: logger.debug("Setting global config") section = "global" self.add_section(section, - "Options that apply to all models" + ADDITIONAL_INFO) + _("Options that apply to all models") + ADDITIONAL_INFO) self.add_item( section=section, title="centering", @@ -114,21 +123,22 @@ def _set_globals(self) -> None: default="face", choices=["face", "head", "legacy"], fixed=True, - group="face", - info="How to center the training image. The extracted images are centered on the " - "middle of the skull based on the face's estimated pose. A subsection of these " - "images are used for training. The centering used dictates how this subsection " - "will be cropped from the aligned images." - "\n\tface: Centers the training image on the center of the face, adjusting for " - "pitch and yaw." - "\n\thead: Centers the training image on the center of the head, adjusting for " - "pitch and yaw. NB: You should only select head centering if you intend to " - "include the full head (including hair) in the final swap. This may give mixed " - "results. Additionally, it is only worth choosing head centering if you are " - "training with a mask that includes the hair (e.g. BiSeNet-FP-Head)." - "\n\tlegacy: The 'original' extraction technique. Centers the training image " - "near the tip of the nose with no adjustment. Can result in the edges of the " - "face appearing outside of the training area.") + group=_("face"), + info=_( + "How to center the training image. The extracted images are centered on the " + "middle of the skull based on the face's estimated pose. A subsection of these " + "images are used for training. The centering used dictates how this subsection " + "will be cropped from the aligned images." + "\n\tface: Centers the training image on the center of the face, adjusting for " + "pitch and yaw." + "\n\thead: Centers the training image on the center of the head, adjusting for " + "pitch and yaw. NB: You should only select head centering if you intend to " + "include the full head (including hair) in the final swap. This may give mixed " + "results. Additionally, it is only worth choosing head centering if you are " + "training with a mask that includes the hair (e.g. BiSeNet-FP-Head)." + "\n\tlegacy: The 'original' extraction technique. Centers the training image " + "near the tip of the nose with no adjustment. Can result in the edges of the " + "face appearing outside of the training area.")) self.add_item( section=section, title="coverage", @@ -137,68 +147,71 @@ def _set_globals(self) -> None: min_max=(62.5, 100.0), rounding=2, fixed=True, - group="face", - info="How much of the extracted image to train on. A lower coverage will limit the " - "model's scope to a zoomed-in central area while higher amounts can include the " - "entire face. A trade-off exists between lower amounts given more detail " - "versus higher amounts avoiding noticeable swap transitions. For 'Face' " - "centering you will want to leave this above 75%. For Head centering you will " - "most likely want to set this to 100%. Sensible values for 'Legacy' " - "centering are:" - "\n\t62.5% spans from eyebrow to eyebrow." - "\n\t75.0% spans from temple to temple." - "\n\t87.5% spans from ear to ear." - "\n\t100.0% is a mugshot.") - + group=_("face"), + info=_( + "How much of the extracted image to train on. A lower coverage will limit the " + "model's scope to a zoomed-in central area while higher amounts can include the " + "entire face. A trade-off exists between lower amounts given more detail " + "versus higher amounts avoiding noticeable swap transitions. For 'Face' " + "centering you will want to leave this above 75%. For Head centering you will " + "most likely want to set this to 100%. Sensible values for 'Legacy' " + "centering are:" + "\n\t62.5% spans from eyebrow to eyebrow." + "\n\t75.0% spans from temple to temple." + "\n\t87.5% spans from ear to ear." + "\n\t100.0% is a mugshot.")) self.add_item( section=section, title="icnr_init", datatype=bool, default=False, - group="initialization", - info="Use ICNR to tile the default initializer in a repeating pattern. " - "This strategy is designed for pairing with sub-pixel / pixel shuffler " - "to reduce the 'checkerboard effect' in image reconstruction. " - "\n\t https://arxiv.org/ftp/arxiv/papers/1707/1707.02937.pdf") + group=_("initialization"), + info=_( + "Use ICNR to tile the default initializer in a repeating pattern. " + "This strategy is designed for pairing with sub-pixel / pixel shuffler " + "to reduce the 'checkerboard effect' in image reconstruction. " + "\n\t https://arxiv.org/ftp/arxiv/papers/1707/1707.02937.pdf")) self.add_item( section=section, title="conv_aware_init", datatype=bool, default=False, - group="initialization", - info="Use Convolution Aware Initialization for convolutional layers. " - "This can help eradicate the vanishing and exploding gradient problem " - "as well as lead to higher accuracy, lower loss and faster convergence.\nNB:" - "\n\t This can use more VRAM when creating a new model so you may want to " - "lower the batch size for the first run. The batch size can be raised " - "again when reloading the model. " - "\n\t Multi-GPU is not supported for this option, so you should start the model " - "on a single GPU. Once training has started, you can stop training, enable " - "multi-GPU and resume." - "\n\t Building the model will likely take several minutes as the calculations " - "for this initialization technique are expensive. This will only impact starting " - "a new model.") + group=_("initialization"), + info=_( + "Use Convolution Aware Initialization for convolutional layers. " + "This can help eradicate the vanishing and exploding gradient problem " + "as well as lead to higher accuracy, lower loss and faster convergence.\nNB:" + "\n\t This can use more VRAM when creating a new model so you may want to " + "lower the batch size for the first run. The batch size can be raised " + "again when reloading the model. " + "\n\t Multi-GPU is not supported for this option, so you should start the model " + "on a single GPU. Once training has started, you can stop training, enable " + "multi-GPU and resume." + "\n\t Building the model will likely take several minutes as the calculations " + "for this initialization technique are expensive. This will only impact starting " + "a new model.")) self.add_item( section=section, title="optimizer", datatype=str, gui_radio=True, - group="optimizer", + group=_("optimizer"), default="adam", choices=["adabelief", "adam", "nadam", "rms-prop"], - info="The optimizer to use." - "\n\t adabelief - Adapting Stepsizes by the Belief in Observed Gradients. An " - "optimizer with the aim to converge faster, generalize better and remain more " - "stable. (https://arxiv.org/abs/2010.07468). NB: Epsilon for AdaBelief needs to " - "be set to a smaller value than other Optimizers. Generally setting the 'Epsilon " - "Exponent' to around '-16' should work." - "\n\t adam - Adaptive Moment Optimization. A stochastic gradient descent method " - "that is based on adaptive estimation of first-order and second-order moments." - "\n\t nadam - Adaptive Moment Optimization with Nesterov Momentum. Much like " - "Adam but uses a different formula for calculating momentum." - "\n\t rms-prop - Root Mean Square Propagation. Maintains a moving (discounted) " - "average of the square of the gradients. Divides the gradient by the root of " - "this average.") + info=_( + "The optimizer to use." + "\n\t adabelief - Adapting Stepsizes by the Belief in Observed Gradients. An " + "optimizer with the aim to converge faster, generalize better and remain more " + "stable. (https://arxiv.org/abs/2010.07468). NB: Epsilon for AdaBelief needs to " + "be set to a smaller value than other Optimizers. Generally setting the 'Epsilon " + "Exponent' to around '-16' should work." + "\n\t adam - Adaptive Moment Optimization. A stochastic gradient descent method " + "that is based on adaptive estimation of first-order and second-order moments." + "\n\t nadam - Adaptive Moment Optimization with Nesterov Momentum. Much like " + "Adam but uses a different formula for calculating momentum." + "\n\t rms-prop - Root Mean Square Propagation. Maintains a moving (discounted) " + "average of the square of the gradients. Divides the gradient by the root of " + "this average.")) self.add_item( section=section, title="learning_rate", @@ -207,12 +220,13 @@ def _set_globals(self) -> None: min_max=(1e-6, 1e-4), rounding=6, fixed=False, - group="optimizer", - info="Learning rate - how fast your network will learn (how large are the " - "modifications to the model weights after one batch of training). Values that " - "are too large might result in model crashes and the inability of the model to " - "find the best solution. Values that are too small might be unable to escape " - "from dead-ends and find the best global minimum.") + group=_("optimizer"), + info=_( + "Learning rate - how fast your network will learn (how large are the " + "modifications to the model weights after one batch of training). Values that " + "are too large might result in model crashes and the inability of the model to " + "find the best solution. Values that are too small might be unable to escape " + "from dead-ends and find the best global minimum.")) self.add_item( section=section, title="epsilon_exponent", @@ -221,82 +235,88 @@ def _set_globals(self) -> None: min_max=(-20, 0), rounding=1, fixed=False, - group="optimizer", - info="The epsilon adds a small constant to weight updates to attempt to avoid 'divide " - "by zero' errors. Unless you are using the AdaBelief Optimizer, then Generally " - "this option should be left at default value, For AdaBelief, setting this to " - "around '-16' should work.\n" - "In all instances if you are getting 'NaN' loss values, and have been unable to " - "resolve the issue any other way (for example, increasing batch size, or " - "lowering learning rate), then raising the epsilon can lead to a more stable " - "model. It may, however, come at the cost of slower training and a less accurate " - "final result.\n" - "NB: The value given here is the 'exponent' to the epsilon. For example, " - "choosing '-7' will set the epsilon to 1e-7. Choosing '-3' will set the epsilon " - "to 0.001 (1e-3).") + group=_("optimizer"), + info=_( + "The epsilon adds a small constant to weight updates to attempt to avoid 'divide " + "by zero' errors. Unless you are using the AdaBelief Optimizer, then Generally " + "this option should be left at default value, For AdaBelief, setting this to " + "around '-16' should work.\n" + "In all instances if you are getting 'NaN' loss values, and have been unable to " + "resolve the issue any other way (for example, increasing batch size, or " + "lowering learning rate), then raising the epsilon can lead to a more stable " + "model. It may, however, come at the cost of slower training and a less accurate " + "final result.\n" + "NB: The value given here is the 'exponent' to the epsilon. For example, " + "choosing '-7' will set the epsilon to 1e-7. Choosing '-3' will set the epsilon " + "to 0.001 (1e-3).")) self.add_item( section=section, title="autoclip", datatype=bool, default=False, - info="[Not PlaidML] Apply AutoClipping to the gradients. AutoClip analyzes the " - "gradient weights and adjusts the normalization value dynamically to fit the " - "data. Can help prevent NaNs and improve model optimization at the expense of " - "VRAM. Ref: AutoClip: Adaptive Gradient Clipping for Source Separation Networks " - "https://arxiv.org/abs/2007.14469", + info=_( + "[Not PlaidML] Apply AutoClipping to the gradients. AutoClip analyzes the " + "gradient weights and adjusts the normalization value dynamically to fit the " + "data. Can help prevent NaNs and improve model optimization at the expense of " + "VRAM. Ref: AutoClip: Adaptive Gradient Clipping for Source Separation Networks " + "https://arxiv.org/abs/2007.14469"), fixed=False, gui_radio=True, - group="optimizer") + group=_("optimizer")) self.add_item( section=section, title="reflect_padding", datatype=bool, default=False, - group="network", - info="Use reflection padding rather than zero padding with convolutions. " - "Each convolution must pad the image boundaries to maintain the proper " - "sizing. More complex padding schemes can reduce artifacts at the " - "border of the image." - "\n\t http://www-cs.engr.ccny.cuny.edu/~wolberg/cs470/hw/hw2_pad.txt") + group=_("network"), + info=_( + "Use reflection padding rather than zero padding with convolutions. " + "Each convolution must pad the image boundaries to maintain the proper " + "sizing. More complex padding schemes can reduce artifacts at the " + "border of the image." + "\n\t http://www-cs.engr.ccny.cuny.edu/~wolberg/cs470/hw/hw2_pad.txt")) self.add_item( section=section, title="allow_growth", datatype=bool, default=False, - group="network", + group=_("network"), fixed=False, - info="[Nvidia Only]. Enable the Tensorflow GPU 'allow_growth' configuration option. " - "This option prevents Tensorflow from allocating all of the GPU VRAM at launch " - "but can lead to higher VRAM fragmentation and slower performance. Should only " - "be enabled if you are receiving errors regarding 'cuDNN fails to initialize' " - "when commencing training.") + info=_( + "[Nvidia Only]. Enable the Tensorflow GPU 'allow_growth' configuration option. " + "This option prevents Tensorflow from allocating all of the GPU VRAM at launch " + "but can lead to higher VRAM fragmentation and slower performance. Should only " + "be enabled if you are receiving errors regarding 'cuDNN fails to initialize' " + "when commencing training.")) self.add_item( section=section, title="mixed_precision", datatype=bool, default=False, fixed=False, - group="network", - info="[Not PlaidML], NVIDIA GPUs can run operations in float16 faster than in " - "float32. Mixed precision allows you to use a mix of float16 with float32, to " - "get the performance benefits from float16 and the numeric stability benefits " - "from float32.\n\nThis is untested on DirectML backend, but will run on most " - "Nvidia models. it will only speed up training on more recent GPUs. Those with " - "compute capability 7.0 or higher will see the greatest performance benefit from " - "mixed precision because they have Tensor Cores. Older GPUs offer no math " - "performance benefit for using mixed precision, however memory and bandwidth " - "savings can enable some speedups. Generally RTX GPUs and later will offer the " - "most benefit.") + group=_("network"), + info=_( + "[Not PlaidML], NVIDIA GPUs can run operations in float16 faster than in " + "float32. Mixed precision allows you to use a mix of float16 with float32, to " + "get the performance benefits from float16 and the numeric stability benefits " + "from float32.\n\nThis is untested on DirectML backend, but will run on most " + "Nvidia models. it will only speed up training on more recent GPUs. Those with " + "compute capability 7.0 or higher will see the greatest performance benefit from " + "mixed precision because they have Tensor Cores. Older GPUs offer no math " + "performance benefit for using mixed precision, however memory and bandwidth " + "savings can enable some speedups. Generally RTX GPUs and later will offer the " + "most benefit.")) self.add_item( section=section, title="nan_protection", datatype=bool, default=True, - group="network", - info="If a 'NaN' is generated in the model, this means that the model has corrupted " - "and the model is likely to start deteriorating from this point on. Enabling NaN " - "protection will stop training immediately in the event of a NaN. The last save " - "will not contain the NaN, so you may still be able to rescue your model.", + group=_("network"), + info=_( + "If a 'NaN' is generated in the model, this means that the model has corrupted " + "and the model is likely to start deteriorating from this point on. Enabling NaN " + "protection will stop training immediately in the event of a NaN. The last save " + "will not contain the NaN, so you may still be able to rescue your model."), fixed=False) self.add_item( section=section, @@ -306,11 +326,12 @@ def _set_globals(self) -> None: min_max=(1, 32), rounding=1, fixed=False, - group="convert", - info="[GPU Only]. The number of faces to feed through the model at once when running " - "the Convert process.\n\nNB: Increasing this figure is unlikely to improve " - "convert speed, however, if you are getting Out of Memory errors, then you may " - "want to reduce the batch size.") + group=_("convert"), + info=_( + "[GPU Only]. The number of faces to feed through the model at once when running " + "the Convert process.\n\nNB: Increasing this figure is unlikely to improve " + "convert speed, however, if you are getting Out of Memory errors, then you may " + "want to reduce the batch size.")) def _set_loss(self) -> None: # pylint:disable=line-too-long @@ -326,171 +347,177 @@ def _set_loss(self) -> None: logger.debug("Setting Loss config") section = "global.loss" self.add_section(section, - "Loss configuration options\n" - "Loss is the mechanism by which a Neural Network judges how well it " - "thinks that it is recreating a face." + ADDITIONAL_INFO) + _("Loss configuration options\n" + "Loss is the mechanism by which a Neural Network judges how well it " + "thinks that it is recreating a face.") + ADDITIONAL_INFO) self.add_item( section=section, title="loss_function", datatype=str, - group="loss", + group=_("loss"), default="ssim", fixed=False, choices=[x for x in sorted(_LOSS_HELP) if x not in _NON_PRIMARY_LOSS], - info="The loss function to use.\n\n\t" + - "\n\n\t".join(f"{k}: {v}" - for k, v in sorted(_LOSS_HELP.items()) - if k not in _NON_PRIMARY_LOSS)) + info=(_("The loss function to use.") + + "\n\n\t" + "\n\n\t".join(f"{k}: {v}" + for k, v in sorted(_LOSS_HELP.items()) + if k not in _NON_PRIMARY_LOSS))) self.add_item( section=section, title="loss_function_2", datatype=str, - group="loss", + group=_("loss"), default="mse", fixed=False, choices=list(sorted(_LOSS_HELP)), - info="The second loss function to use. If using a structural based loss (such as " - "SSIM, MS-SSIM or GMSD) it is common to add an L1 regularization(MAE) or L2 " - "regularization (MSE) function. You can adjust the weighting of this loss " - "function with the loss_weight_2 option.\n\n\t" + - "\n\n\t".join(f"{k}: {v}" - for k, v in sorted(_LOSS_HELP.items()))) + info=(_("The second loss function to use. If using a structural based loss (such as " + "SSIM, MS-SSIM or GMSD) it is common to add an L1 regularization(MAE) or L2 " + "regularization (MSE) function. You can adjust the weighting of this loss " + "function with the loss_weight_2 option.") + + "\n\n\t" + "\n\n\t".join(f"{k}: {v}" for k, v in sorted(_LOSS_HELP.items())))) self.add_item( section=section, title="loss_weight_2", datatype=int, - group="loss", + group=_("loss"), min_max=(0, 400), rounding=1, default=100, fixed=False, - info="The amount of weight to apply to the second loss function.\n\n" - "\n\nThe value given here is as a percentage denoting how much the selected " - "function should contribute to the overall loss cost of the model. For example:" - "\n\t 100 - The loss calculated for the second loss function will be applied at " - "its full amount towards the overall loss score. " - "\n\t 25 - The loss calculated for the second loss function will be reduced by a " - "quarter prior to adding to the overall loss score. " - "\n\t 400 - The loss calculated for the second loss function will be mulitplied " - "4 times prior to adding to the overall loss score. " - "\n\t 0 - Disables the second loss function altogether.") + info=_( + "The amount of weight to apply to the second loss function.\n\n" + "\n\nThe value given here is as a percentage denoting how much the selected " + "function should contribute to the overall loss cost of the model. For example:" + "\n\t 100 - The loss calculated for the second loss function will be applied at " + "its full amount towards the overall loss score. " + "\n\t 25 - The loss calculated for the second loss function will be reduced by a " + "quarter prior to adding to the overall loss score. " + "\n\t 400 - The loss calculated for the second loss function will be mulitplied " + "4 times prior to adding to the overall loss score. " + "\n\t 0 - Disables the second loss function altogether.")) self.add_item( section=section, title="loss_function_3", datatype=str, - group="loss", + group=_("loss"), default="none", fixed=False, choices=list(sorted(_LOSS_HELP)), - info="The third loss function to use. You can adjust the weighting of this loss " - "function with the loss_weight_3 option.\n\n\t" + - "\n\n\t".join(f"{k}: {v}" - for k, v in sorted(_LOSS_HELP.items()))) + info=(_("The third loss function to use. You can adjust the weighting of this loss " + "function with the loss_weight_3 option.") + + "\n\n\t" + + "\n\n\t".join(f"{k}: {v}" for k, v in sorted(_LOSS_HELP.items())))) self.add_item( section=section, title="loss_weight_3", datatype=int, - group="loss", + group=_("loss"), min_max=(0, 400), rounding=1, default=0, fixed=False, - info="The amount of weight to apply to the third loss function.\n\n" - "\n\nThe value given here is as a percentage denoting how much the selected " - "function should contribute to the overall loss cost of the model. For example:" - "\n\t 100 - The loss calculated for the third loss function will be applied at " - "its full amount towards the overall loss score. " - "\n\t 25 - The loss calculated for the third loss function will be reduced by a " - "quarter prior to adding to the overall loss score. " - "\n\t 400 - The loss calculated for the third loss function will be mulitplied 4 " - "times prior to adding to the overall loss score. " - "\n\t 0 - Disables the third loss function altogether.") + info=_( + "The amount of weight to apply to the third loss function.\n\n" + "\n\nThe value given here is as a percentage denoting how much the selected " + "function should contribute to the overall loss cost of the model. For example:" + "\n\t 100 - The loss calculated for the third loss function will be applied at " + "its full amount towards the overall loss score. " + "\n\t 25 - The loss calculated for the third loss function will be reduced by a " + "quarter prior to adding to the overall loss score. " + "\n\t 400 - The loss calculated for the third loss function will be mulitplied 4 " + "times prior to adding to the overall loss score. " + "\n\t 0 - Disables the third loss function altogether.")) self.add_item( section=section, title="loss_function_4", datatype=str, - group="loss", + group=_("loss"), default="none", fixed=False, choices=list(sorted(_LOSS_HELP)), - info="The fourth loss function to use. You can adjust the weighting of this loss " - "function with the loss_weight_3 option.\n\n\t" + - "\n\n\t".join(f"{k}: {v}" - for k, v in sorted(_LOSS_HELP.items()))) + info=(_("The fourth loss function to use. You can adjust the weighting of this loss " + "function with the loss_weight_3 option.") + + "\n\n\t" + + "\n\n\t".join(f"{k}: {v}" for k, v in sorted(_LOSS_HELP.items())))) self.add_item( section=section, title="loss_weight_4", datatype=int, - group="loss", + group=_("loss"), min_max=(0, 400), rounding=1, default=0, fixed=False, - info="The amount of weight to apply to the fourth loss function.\n\n" - "\n\nThe value given here is as a percentage denoting how much the selected " - "function should contribute to the overall loss cost of the model. For example:" - "\n\t 100 - The loss calculated for the fourth loss function will be applied at " - "its full amount towards the overall loss score. " - "\n\t 25 - The loss calculated for the fourth loss function will be reduced by a " - "quarter prior to adding to the overall loss score. " - "\n\t 400 - The loss calculated for the fourth loss function will be mulitplied " - "4 times prior to adding to the overall loss score. " - "\n\t 0 - Disables the fourth loss function altogether.") + info=_( + "The amount of weight to apply to the fourth loss function.\n\n" + "\n\nThe value given here is as a percentage denoting how much the selected " + "function should contribute to the overall loss cost of the model. For example:" + "\n\t 100 - The loss calculated for the fourth loss function will be applied at " + "its full amount towards the overall loss score. " + "\n\t 25 - The loss calculated for the fourth loss function will be reduced by a " + "quarter prior to adding to the overall loss score. " + "\n\t 400 - The loss calculated for the fourth loss function will be mulitplied " + "4 times prior to adding to the overall loss score. " + "\n\t 0 - Disables the fourth loss function altogether.")) self.add_item( section=section, title="mask_loss_function", datatype=str, - group="loss", + group=_("loss"), default="mse", fixed=False, choices=["mae", "mse"], - info="The loss function to use when learning a mask." - "\n\t MAE - Mean absolute error will guide reconstructions of each pixel " - "towards its median value in the training dataset. Robust to outliers but as " - "a median, it can potentially ignore some infrequent image types in the dataset." - "\n\t MSE - Mean squared error will guide reconstructions of each pixel " - "towards its average value in the training dataset. As an average, it will be " - "susceptible to outliers and typically produces slightly blurrier results.") + info=_( + "The loss function to use when learning a mask." + "\n\t MAE - Mean absolute error will guide reconstructions of each pixel " + "towards its median value in the training dataset. Robust to outliers but as " + "a median, it can potentially ignore some infrequent image types in the dataset." + "\n\t MSE - Mean squared error will guide reconstructions of each pixel " + "towards its average value in the training dataset. As an average, it will be " + "susceptible to outliers and typically produces slightly blurrier results.")) self.add_item( section=section, title="eye_multiplier", datatype=int, - group="loss", + group=_("loss"), min_max=(1, 40), rounding=1, default=3, fixed=False, - info="The amount of priority to give to the eyes.\n\nThe value given here is as a " - "multiplier of the main loss score. For example:" - "\n\t 1 - The eyes will receive the same priority as the rest of the face. " - "\n\t 10 - The eyes will be given a score 10 times higher than the rest of the " - "face." - "\n\nNB: Penalized Mask Loss must be enable to use this option.") + info=_( + "The amount of priority to give to the eyes.\n\nThe value given here is as a " + "multiplier of the main loss score. For example:" + "\n\t 1 - The eyes will receive the same priority as the rest of the face. " + "\n\t 10 - The eyes will be given a score 10 times higher than the rest of the " + "face." + "\n\nNB: Penalized Mask Loss must be enable to use this option.")) self.add_item( section=section, title="mouth_multiplier", datatype=int, - group="loss", + group=_("loss"), min_max=(1, 40), rounding=1, default=2, fixed=False, - info="The amount of priority to give to the mouth.\n\nThe value given here is as a " - "multiplier of the main loss score. For Example:" - "\n\t 1 - The mouth will receive the same priority as the rest of the face. " - "\n\t 10 - The mouth will be given a score 10 times higher than the rest of the " - "face." - "\n\nNB: Penalized Mask Loss must be enable to use this option.") + info=_( + "The amount of priority to give to the mouth.\n\nThe value given here is as a " + "multiplier of the main loss score. For Example:" + "\n\t 1 - The mouth will receive the same priority as the rest of the face. " + "\n\t 10 - The mouth will be given a score 10 times higher than the rest of the " + "face." + "\n\nNB: Penalized Mask Loss must be enable to use this option.")) self.add_item( section=section, title="penalized_mask_loss", datatype=bool, default=True, - group="loss", - info="Image loss function is weighted by mask presence. For areas of " - "the image without the facial mask, reconstruction errors will be " - "ignored while the masked face area is prioritized. May increase " - "overall quality by focusing attention on the core face area.") + group=_("loss"), + info=_( + "Image loss function is weighted by mask presence. For areas of " + "the image without the facial mask, reconstruction errors will be " + "ignored while the masked face area is prioritized. May increase " + "overall quality by focusing attention on the core face area.")) self.add_item( section=section, title="mask_type", @@ -498,40 +525,41 @@ def _set_loss(self) -> None: default="extended", choices=PluginLoader.get_available_extractors("mask", add_none=True, extend_plugin=True), - group="mask", + group=_("mask"), gui_radio=True, - info="The mask to be used for training. If you have selected 'Learn Mask' or " - "'Penalized Mask Loss' you must select a value other than 'none'. The required " - "mask should have been selected as part of the Extract process. If it does not " - "exist in the alignments file then it will be generated prior to training " - "commencing." - "\n\tnone: Don't use a mask." - "\n\tbisenet-fp_face: Relatively lightweight NN based mask that provides more " - "refined control over the area to be masked (configurable in mask settings). " - "Use this version of bisenet-fp if your model is trained with 'face' or " - "'legacy' centering." - "\n\tbisenet-fp_head: Relatively lightweight NN based mask that provides more " - "refined control over the area to be masked (configurable in mask settings). " - "Use this version of bisenet-fp if your model is trained with 'head' centering." - "\n\tcomponents: 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." - "\n\tcustom_face: Custom user created, face centered mask." - "\n\tcustom_head: Custom user created, head centered mask." - "\n\textended: 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." - "\n\tvgg-clear: Mask designed to provide smart segmentation of mostly frontal " - "faces clear of obstructions. Profile faces and obstructions may result in " - "sub-par performance." - "\n\tvgg-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." - "\n\tunet-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.") + info=_( + "The mask to be used for training. If you have selected 'Learn Mask' or " + "'Penalized Mask Loss' you must select a value other than 'none'. The required " + "mask should have been selected as part of the Extract process. If it does not " + "exist in the alignments file then it will be generated prior to training " + "commencing." + "\n\tnone: Don't use a mask." + "\n\tbisenet-fp_face: Relatively lightweight NN based mask that provides more " + "refined control over the area to be masked (configurable in mask settings). " + "Use this version of bisenet-fp if your model is trained with 'face' or " + "'legacy' centering." + "\n\tbisenet-fp_head: Relatively lightweight NN based mask that provides more " + "refined control over the area to be masked (configurable in mask settings). " + "Use this version of bisenet-fp if your model is trained with 'head' centering." + "\n\tcomponents: 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." + "\n\tcustom_face: Custom user created, face centered mask." + "\n\tcustom_head: Custom user created, head centered mask." + "\n\textended: 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." + "\n\tvgg-clear: Mask designed to provide smart segmentation of mostly frontal " + "faces clear of obstructions. Profile faces and obstructions may result in " + "sub-par performance." + "\n\tvgg-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." + "\n\tunet-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.")) self.add_item( section=section, title="mask_blur_kernel", @@ -539,12 +567,13 @@ def _set_loss(self) -> None: min_max=(0, 9), rounding=1, default=3, - group="mask", - info="Apply gaussian blur to the mask input. This has the effect of smoothing the " - "edges of the mask, which can help with poorly calculated masks and give less " - "of a hard edge to the predicted mask. The size is in pixels (calculated from " - "a 128px mask). Set to 0 to not apply gaussian blur. This value should be odd, " - "if an even number is passed in then it will be rounded to the next odd number.") + group=_("mask"), + info=_( + "Apply gaussian blur to the mask input. This has the effect of smoothing the " + "edges of the mask, which can help with poorly calculated masks and give less " + "of a hard edge to the predicted mask. The size is in pixels (calculated from " + "a 128px mask). Set to 0 to not apply gaussian blur. This value should be odd, " + "if an even number is passed in then it will be rounded to the next odd number.")) self.add_item( section=section, title="mask_threshold", @@ -552,15 +581,17 @@ def _set_loss(self) -> None: default=4, min_max=(0, 50), rounding=1, - group="mask", - info="Sets pixels that are near white to white and near black to black. Set to 0 for " - "off.") + group=_("mask"), + info=_( + "Sets pixels that are near white to white and near black to black. Set to 0 for " + "off.")) self.add_item( section=section, title="learn_mask", datatype=bool, default=False, - group="mask", - info="Dedicate a portion of the model to learning how to duplicate the input " - "mask. Increases VRAM usage in exchange for learning a quick ability to try " - "to replicate more complex mask models.") + group=_("mask"), + info=_( + "Dedicate a portion of the model to learning how to duplicate the input " + "mask. Increases VRAM usage in exchange for learning a quick ability to try " + "to replicate more complex mask models.")) From 77723a6b66cf9c3d5feee8e5d5d8e45fb7830c9f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 12 Jun 2023 17:29:47 +0100 Subject: [PATCH 817/981] bugfix: config encoding - force to utf-8 --- lib/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/config.py b/lib/config.py index eb7cb19194..c588026db0 100644 --- a/lib/config.py +++ b/lib/config.py @@ -528,7 +528,7 @@ def format_help(cls, helptext: str, is_section: bool = False) -> str: def _load_config(self) -> None: """ Load values from config """ logger.verbose("Loading config: '%s'", self.configfile) # type:ignore[attr-defined] - self.config.read(self.configfile) + self.config.read(self.configfile, encoding="utf-8") def save_config(self) -> None: """ Save a config file """ From 4e58bcdde30a265956ad65f7dd545f94b0470379 Mon Sep 17 00:00:00 2001 From: Bryan Lyon <3223233+bryanlyon@users.noreply.github.com> Date: Wed, 14 Jun 2023 02:40:24 -0700 Subject: [PATCH 818/981] ClipFaker (#4) * Initial working CLIP/FARL implementation. Visual only, Clipfaker uses FaRL weights for training. * Added missing files. * Added automatic download and loading of weights. * Fix bug with clipfaker loading Farl weights * Added missing reference to clip_tf2 * Added some docstrings * adding docstrings * Docstring * Added some docstrings * updated setup.py tensorflow requirements * Last changes * Last changes * Last changes * Changes to docstrings * Last changes over the Docstrings --------- Co-authored-by: Bryan Lyon Co-authored-by: Tianaco Co-authored-by: andresca94 --- lib/model/layers.py | 46 +- lib/model/networks/clip/__init__.py | 1 + lib/model/networks/clip/clip.py | 330 +++++++++++++ lib/model/networks/clip/layers.py | 12 + lib/model/networks/clip/model.py | 86 ++++ lib/model/networks/clip/resnet.py | 451 ++++++++++++++++++ lib/model/networks/clip/transformer.py | 109 +++++ lib/model/networks/clip/visual_transformer.py | 165 +++++++ plugins/train/model/clipfaker.py | 111 +++++ plugins/train/model/clipfaker_defaults.py | 57 +++ 10 files changed, 1367 insertions(+), 1 deletion(-) create mode 100644 lib/model/networks/clip/__init__.py create mode 100644 lib/model/networks/clip/clip.py create mode 100644 lib/model/networks/clip/layers.py create mode 100644 lib/model/networks/clip/model.py create mode 100644 lib/model/networks/clip/resnet.py create mode 100644 lib/model/networks/clip/transformer.py create mode 100644 lib/model/networks/clip/visual_transformer.py create mode 100644 plugins/train/model/clipfaker.py create mode 100644 plugins/train/model/clipfaker_defaults.py diff --git a/lib/model/layers.py b/lib/model/layers.py index daebb453b8..7994188262 100644 --- a/lib/model/layers.py +++ b/lib/model/layers.py @@ -19,7 +19,8 @@ # Ignore linting errors from Tensorflow's thoroughly broken import system from tensorflow.keras.utils import get_custom_objects # noqa pylint:disable=no-name-in-module,import-error from tensorflow.keras import backend as K # pylint:disable=import-error - from tensorflow.keras.layers import InputSpec, Layer # noqa pylint:disable=no-name-in-module,import-error + from tensorflow.keras import Sequential # pylint:disable=import-error + from tensorflow.keras.layers import InputSpec, Layer, Dense, MultiHeadAttention, LayerNormalization # noqa pylint:disable=no-name-in-module,import-error from tensorflow import pad # type:ignore from tensorflow.python.keras.utils import conv_utils # pylint:disable=no-name-in-module @@ -800,6 +801,49 @@ def get_config(self): config["beta"] = self.beta return config +class QuickGELU(Layer): + def __init__(self, name="QuickGELU"): + super(QuickGELU, self).__init__(name=name) + + def call(self, var_x: tf.Tensor): + return var_x * K.sigmoid(1.702 * var_x) + +class ResidualAttentionBlock(Layer): + def __init__(self, d_model: int, n_head: int, attn_mask: tf.Tensor = None, name="ResidualAttentionBlock", idx=0): + super().__init__(name=name) + self.idx = idx + + self.d_model = d_model + self.n_head = n_head + + self.attn = MultiHeadAttention(num_heads=n_head, key_dim=d_model // n_head, name="attn") + self.ln_1 = LayerNormalization(epsilon=1e-05, name="ln_1") + self.mlp = Sequential([ + Dense(d_model * 4, name=name + "/mlp/c_fc"), + QuickGELU(name=name + "/mlp/gelu"), + Dense(d_model, name=name + "/mlp/c_proj") + ], name="mlp") + self.ln_2 = LayerNormalization(epsilon=1e-05, name="ln_2") + self.attn_mask = attn_mask + + def attention(self, x: tf.Tensor): + return self.attn(x, x, x, attention_mask=self.attn_mask) + + def get_config(self): + return { + "d_model": self.d_model, + "n_head": self.n_head, + "name": self.name + } + + @classmethod + def from_config(cls, config): + return cls(**config) + + def call(self, x: tf.Tensor): + x = x + self.attention(self.ln_1(x)) + x = x + self.mlp(self.ln_2(x)) + return x # Update layers into Keras custom objects for name, obj in inspect.getmembers(sys.modules[__name__]): diff --git a/lib/model/networks/clip/__init__.py b/lib/model/networks/clip/__init__.py new file mode 100644 index 0000000000..87cb7367b0 --- /dev/null +++ b/lib/model/networks/clip/__init__.py @@ -0,0 +1 @@ +from .model import * diff --git a/lib/model/networks/clip/clip.py b/lib/model/networks/clip/clip.py new file mode 100644 index 0000000000..0d6245c913 --- /dev/null +++ b/lib/model/networks/clip/clip.py @@ -0,0 +1,330 @@ +# Clip implmented in TF2 from https://github.com/RobertBiehl/CLIP-tf2 + +from collections import OrderedDict +from typing import Tuple, Union + +import numpy as np +from tensorflow import keras +import tensorflow as tf +from .resnet import ModifiedResNet +from .transformer import Transformer +from .visual_transformer import VisualTransformer + + +class CLIP(keras.Model): + """ + A Convolutional Language-Image Pre-Training (CLIP) model that encodes images and text into a shared latent space. + + Parameters + ---------- + embed_dim: int + Dimensionality of the final shared embedding space. + image_resolution: int + Spatial resolution of the input images. + vision_layers: Union[Tuple[int, int, int, int], int] + Number of layers in the visual encoder, or a tuple of + layer configurations for a custom ResNet visual encoder. + vision_width: int + Width of the visual encoder layers. + vision_patch_size: int + Size of the patches to be extracted from the images. + context_length: int + Length of the input text sequences. + vocab_size :int + Size of the vocabulary. + transformer_width: int + Width of the transformer layers. + transformer_heads: int + Number of heads in the transformer attention mechanism. + transformer_layers: int + Number of transformer layers. + + Attributes: + ---------- + image_resolution: int + Spatial resolution of the input images. + context_length: int + Length of the input text sequences. + visual: keras.Model + Visual encoder module. + transformer: Transformer + Transformer module for the text encoder. + vocab_size: int + Size of the vocabulary. + token_embedding: tf.Variable + Embedding layer for the text encoder. + positional_embedding: tf.Variable + Positional encoding layer for the text encoder. + ln_final: keras.layers.LayerNormalization + Layer normalization for the final transformer output. + text_projection: tf.Variable + Projection layer for the final text embedding. + logit_scale: tf.Variable + Scaling factor for the final cosine similarity scores. + + """ + def __init__(self, + embed_dim: inzt, + # vision + image_resolution: int, + vision_layers: Union[Tuple[int, int, int, int], int], + vision_width: int, + vision_patch_size: int, + # text + context_length: int, + vocab_size: int, + transformer_width: int, + transformer_heads: int, + transformer_layers: int + ): + """ + Initializes the CLIP model. + + Parameters + ---------- + embed_dim: int + Dimensionality of the final shared embedding space. + image_resolution: int + Spatial resolution of the input images. + vision_layers: Union[Tuple[int, int, int, int], int] + Number of layers in the visual + encoder, or a tuple of layer configurations for a custom ResNet visual encoder. + vision_width: int + Width of the visual encoder layers. + vision_patch_size: int + Size of the patches to be extracted from the images. + context_length: int + Length of the input text sequences. + vocab_size: int + Size of the vocabulary. + transformer_width: int + Width of the transformer layers. + transformer_heads: int + Number of heads in the transformer attention mechanism. + transformer_layers: int + Number of transformer layers. + + Returns: + ------- + None + """ + super().__init__() + + self.image_resolution = image_resolution + self.context_length = context_length + + if isinstance(vision_layers, (tuple, list)): + vision_heads = vision_width * 32 // 64 + self.visual = ModifiedResNet( + layers=vision_layers, + output_dim=embed_dim, + heads=vision_heads, + input_resolution=image_resolution, + width=vision_width, + name="visual" + ) + else: + vision_heads = vision_width // 64 + self.visual = VisualTransformer( + input_resolution=image_resolution, + patch_size=vision_patch_size, + width=vision_width, + layers=vision_layers, + heads=vision_heads, + output_dim=embed_dim, + name="visual" + ) + + self.transformer = Transformer( + width=transformer_width, + layers=transformer_layers, + heads=transformer_heads, + attn_mask=self.build_attention_mask(), + name="transformer" + ) + + self.vocab_size = vocab_size + self.token_embedding = tf.Variable( + tf.zeros((vocab_size, transformer_width)), name="token_embedding") + self.positional_embedding = tf.Variable( + tf.zeros((self.context_length, transformer_width)), name="positional_embedding") + self.ln_final = keras.layers.LayerNormalization(epsilon=1e-05, name="ln_final") + + self.text_projection = tf.Variable( + tf.zeros((transformer_width, embed_dim)), name="text_projection") + self.logit_scale = tf.Variable(np.ones([]) * np.log(1 / 0.07), + dtype=tf.float32, name="logit_scale") + + def initialize_parameters(self): + """ + Initializes the parameters of the CLIP model. + + Returns: + ------- + None. + """ + # TODO: convert to tf, for model initialization (not needed for pretrained weights) + self.token_embedding.assign(tf.random.normal(self.token_embedding.shape, stddev=0.02)) + self.positional_embedding.assign(tf.random.normal( + self.positional_embedding.shape, stddev=0.01)) + + from resnet import ModifiedResNet + if isinstance(self.visual, ModifiedResNet): + if self.visual.attnpool is not None: + std = self.visual.attnpool.c_proj.in_features ** -0.5 + self.visual.attnpool.q_proj.weight.assign(tf.random.normal( + self.visual.attnpool.q_proj.weight.shape, stddev=std)) + self.visual.attnpool.k_proj.weight.assign(tf.random.normal( + self.visual.attnpool.k_proj.weight.shape, stddev=std)) + self.visual.attnpool.v_proj.weight.assign(tf.random.normal( + self.visual.attnpool.v_proj.weight.shape, stddev=std)) + self.visual.attnpool.c_proj.weight.assign(tf.random.normal( + self.visual.attnpool.c_proj.weight.shape, stddev=std)) + + for resnet_block in [self.visual.layer1, self.visual.layer2, self.visual.layer3, self.visual.layer4]: + for name, param in resnet_block.named_parameters(): + if name.endswith("bn3.weight"): + param.assign(tf.zeros_like(param)) + + proj_std = (self.transformer.width ** -0.5) * ((2 * self.transformer.layers) ** -0.5) + attn_std = self.transformer.width ** -0.5 + fc_std = (2 * self.transformer.width) ** -0.5 + for block in self.transformer.resblocks: + block.attn.in_proj.weight.assign( + tf.random.normal(block.attn.in_proj.weight.shape, stddev=attn_std)) + block.attn.out_proj.weight.assign( + tf.random.normal(block.attn.out_proj.weight.shape, stddev=proj_std)) + block.mlp.c_fc.weight.assign( + tf.random.normal(block.mlp.c_fc.weight.shape, stddev=fc_std)) + block.mlp.c_proj.weight.assign( + tf.random.normal(block.mlp.c_proj.weight.shape, stddev=proj_std)) + + if self.text_projection is not None: + std = self.transformer.width ** -0.5 + self.text_projection.assign( + tf.random.normal(self.text_projection.shape, stddev=std)) + + def build_attention_mask(self): + """ + Builds an attention mask tensor for the CLIP model. + + Returns: + ------- + tf.Tensor: of shape [batch_size, context_length, context_length] with boolean values + indicating which tokens should be attended to. + """ + n_dest = self.context_length + n_src = self.context_length + dtype = tf.bool + batch_size = 1 + + i = tf.range(n_dest)[:, None] + j = tf.range(n_src) + mask = i >= j - n_src + n_dest + mask = tf.cast(mask, dtype) + mask = tf.reshape(mask, [1, n_dest, n_src]) + mult = tf.concat( + [tf.expand_dims(batch_size, -1), tf.constant([1, 1], dtype=tf.int32)], 0 + ) + return tf.tile(mask, mult) + + @property + def dtype(self): + """ + Returns the dtype of the weights. + + Returns: + ------- + tf.Tensor: The dtype of the weights. + """ + return self.visual.conv1.weight.dtype + + @tf.function(input_signature=[tf.TensorSpec(shape=(None, None, None, 3), dtype=tf.float32, name="image")]) + def encode_image(self, image: tf.Tensor): + """ + Encodes an image tensor using the visual function. + + Parameters + ---------- + image: tf.Tensor + A tensor of shape [batch_size, image_resolution, image_resolution, channels] + (Note: Channels could be 1 -monochrome or 3 -RGB color) + containing the image to be encoded. + + Returns: + ------- + tf.Tensor: A tensor of shape[batch_size, embed_size] containing the encoded representation of the image. + """ + return self.visual(image) + + @tf.function(input_signature=[tf.TensorSpec(shape=(None, None), dtype=tf.int32, name="text")]) + def encode_text(self, text: tf.Tensor) -> tf.Tensor: + """ + Encodes the input text using the pretrained model. + + Parameters + ---------- + text: (tf.Tensor) + of shape [batch_size, n_ctx], containing the tokenized text. + + Returns: + ------- + tf.Tensor: of shape [batch_size, embed_size], containing the encoded representation + of the input text. + """ + var_x = tf.nn.embedding_lookup(self.token_embedding, text) + var_x = var_x + self.positional_embedding + var_x = self.transformer(var_x) + var_x = self.ln_final(var_x) + + # x.shape = [batch_size, n_ctx, transformer.width] + x_shape = tf.shape(var_x) + # take features from the eot embedding (eot_token is the highest number in each sequence) + eot_token = tf.argmax(text, axis=-1) + + # TODO check if dtype is correct + idx = tf.transpose( + tf.stack((tf.range(0, x_shape[0], dtype=tf.int64), eot_token), axis=0, name='take_features_idx')) + var_x = tf.gather_nd(var_x, idx) @ self.text_projection + + return var_x + + @tf.function(input_signature=[( + tf.TensorSpec(shape=(None, None, None, 3), dtype=tf.float32, name="image"), + tf.TensorSpec(shape=(None, None, None), dtype=tf.int32, name="text") + )]) + def call(self, input: Tuple[tf.Tensor, tf.Tensor]): + """ + Finds the embeddings for a batch of images and texts and then computes the + cosine similarity between the embeddings. + + Parameters + ---------- + input: Tuple[tf.Tensor, tf.Tensor] + A tuple of two tensors containing the input + images and texts. The image tensor should have shape [batch_size, + image_resolution, image_resolution, channels], and the text tensor should have + shape [batch_size, sequence_length]. + Returns: + ------- + Tuple[tf.Tensor, tf.Tensor]: A tuple of two tensors containing the logits for the + image and text inputs. Both tensors will have shape [batch_size, embed_dim]. + """ + image, text = input + image_features = self.encode_image(image) + + # TODO: find another way to feed data, but keras requires that all input tensors have to have the same batch size + text = tf.squeeze(text, axis=0) + text_features = self.encode_text(text) + + # normalized features + image_features = image_features / tf.norm(image_features, axis=-1, keepdims=True) + text_features = text_features / tf.norm(text_features, axis=-1, keepdims=True) + + # cosine similarity as logits + logit_scale = tf.exp(self.logit_scale) + logits_per_image = logit_scale * image_features @ tf.transpose(text_features) + logits_per_text = logit_scale * text_features @ tf.transpose(image_features) + + # shape = [global_batch_size, global_batch_size] + return logits_per_image, logits_per_text diff --git a/lib/model/networks/clip/layers.py b/lib/model/networks/clip/layers.py new file mode 100644 index 0000000000..a92c21ad50 --- /dev/null +++ b/lib/model/networks/clip/layers.py @@ -0,0 +1,12 @@ +import tensorflow as tf +from tensorflow.keras import layers as klayers + + +class LayerNorm(klayers.LayerNormalization): + """Subclass LayerNorm to override epsolon to torch default.""" + + def __init__(self, name="LayerNorm"): + super(LayerNorm, self).__init__(epsilon=1e-05, name=name) + + def call(self, x: tf.Tensor): + return super().call(x) diff --git a/lib/model/networks/clip/model.py b/lib/model/networks/clip/model.py new file mode 100644 index 0000000000..dd8bfc73bd --- /dev/null +++ b/lib/model/networks/clip/model.py @@ -0,0 +1,86 @@ +from .clip import CLIP +from dataclasses import dataclass +from typing import Union +import numpy as np +from tensorflow import keras +from .visual_transformer import VisualTransformer + +@dataclass +class ClipConfig(): # Defaults set to match ViT-B/16 + embed_dim: int = 512 + image_resolution: int = 224 + vision_layers: Union[int, tuple[int, int, int, int]] = 12 + vision_width: int = 768 + vision_patch_size: int = 16 + context_length: int = 77 + vocab_size: int = 49408 + transformer_width: int = 512 + transformer_layers: int = 12 + + @property + def transformer_heads(self): + return self.transformer_width // 64 + + +_Models: dict[str, ClipConfig] = { # Each model has a different set of parameters + 'RN50': ClipConfig(embed_dim = 1024, vision_layers = (3, 4, 6, 3), vision_width = 64, vision_patch_size = None), + 'RN101': ClipConfig(vision_layers = (3, 4, 23, 3), vision_width = 64, vision_patch_size = None), + 'RN50x4': ClipConfig(embed_dim = 640, image_resolution = 288, vision_layers = (4, 6, 10, 6), vision_width = 80, vision_patch_size = None, transformer_width = 640), + 'RN50x16': ClipConfig(embed_dim = 768, image_resolution = 384, vision_layers = (6, 8, 18, 8), vision_width = 96, vision_patch_size = None, transformer_width = 768), + 'RN50x64': ClipConfig(embed_dim = 1024, image_resolution = 448, vision_layers = (3, 15, 36, 10), vision_width = 128, vision_patch_size = None, transformer_width = 1024), + 'ViT-B_32': ClipConfig(vision_patch_size = 32), + 'ViT-B_16': ClipConfig(), # Default so no need to pass anything + 'ViT-L_14': ClipConfig(embed_dim = 768, vision_layers = 24, vision_width = 1024, vision_patch_size = 14, transformer_width = 768), + 'ViT-L_14@336px': ClipConfig(embed_dim = 768, image_resolution = 336, vision_layers = 24, vision_width = 1024, vision_patch_size = 14, transformer_width = 768), + 'FaRL-B_16-64': ClipConfig(), # Duplicate of ViT-B/16 just different weights +} + + +def build_model(model_name: str, visual: bool = True, text: bool = True) -> keras.Model: + """ + Builds and returns a CLIP model + + Args: + @param model_name (str): The name of the model configuration to use It takes one of these Model types %s % _Models + visual (bool): Whether to build the visual CLIP model. Defaults to True. + text (bool): Whether to build the text CLIP model. Defaults to True. + + Returns: + keras.Model: The CLIP model with the specified configuration and weights loaded. + """ + config = _Models[model_name] + + model = CLIP(config.embed_dim, config.image_resolution, config.vision_layers, config.vision_width, config.vision_patch_size, + config.context_length, config.vocab_size, config.transformer_width, config.transformer_heads, config.transformer_layers) + + #Model must be built to load weights + empty_image = np.ones((1, config.image_resolution, config.image_resolution, 3), np.float32) + empty_text = np.ones((1, 4, config.context_length), np.int32) + model.predict((empty_image, empty_text)) + + # model.load_weights(f'/home/nikkelitous/Documents/Projects/CLIP-tf2/models/CLIP_{model_name}.h5') #TODO replace with model download + cache + model.config = config + + return model + +def build_visual_model(model_name: str) -> keras.Model: + """ + Builds and returns the visual CLIP model. + + Args: + @param model_name (str): The name of the model configuration to use It takes one of these Model types %s % _Models + + Returns: + keras.Model: The visual CLIP model with the specified configuration. + """ + config = _Models[model_name] + model = VisualTransformer( + input_resolution=config.image_resolution, + patch_size=config.vision_patch_size, + width=config.vision_width, + layers=config.vision_layers, + heads=config.vision_layers//64, + output_dim=config.embed_dim, + name="visual" + ) + return model \ No newline at end of file diff --git a/lib/model/networks/clip/resnet.py b/lib/model/networks/clip/resnet.py new file mode 100644 index 0000000000..8df2d27b07 --- /dev/null +++ b/lib/model/networks/clip/resnet.py @@ -0,0 +1,451 @@ +from tensorflow import keras +import tensorflow as tf +from tensorflow.keras import layers as klayers + + +class Bottleneck(klayers.Layer): + """ + A ResNet bottleneck block that performs a sequence of convolutions, batch normalization, and ReLU activation + operations on an input tensor. + + Parameters + ---------- + inplanes: int + The number of input channels. + planes: int + The number of output channels. + stride: int + The stride of the bottleneck block. + name: str + The name of the bottleneck block. + + Attributes: + ---------- + expansion: int + The factor by which the number of input channels is expanded to get the number of output channels. + conv1: keras.layers.Conv2D + The first 1x1 convolution layer in the bottleneck block. + bn1: keras.layers.BatchNormalization + The first batch normalization layer in the bottleneck block. + conv2_padding: keras.layers.ZeroPadding2D + The zero padding layer applied before the second convolution in the bottleneck block. + conv2: keras.layers.Conv2D + The second 3x3 convolution layer in the bottleneck block. + bn2: keras.layers.BatchNormalization + The second batch normalization layer in the bottleneck block. + avgpool: keras.layers.AveragePooling2D + The average pooling layer that is applied after the second convolution, if the stride is greater than 1. + conv3: keras.layers.Conv2D + The third 1x1 convolution layer in the bottleneck block. + bn3: keras.layers.BatchNormalization + The third batch normalization layer in the bottleneck block. + relu: keras.layers.ReLU + The ReLU activation layer in the bottleneck block. + downsample: keras.Sequential + A downsampling block consisting of an average pooling layer, followed by a 1x1 convolution layer and a batch normalization layer. This block is used to match the dimensions of the input tensor with the output tensor of the bottleneck block when the stride is greater than 1 or the number of input channels is different from the number of output channels. + stride: int + The stride of the bottleneck block. + """ + expansion = 4 + + def __init__(self, inplanes, planes, stride=1, name: str = "bottleneck"): + """ + Initializes a Bottleneck block. + """ + super().__init__(name=name) + + with tf.name_scope(name): + # all conv layers have stride 1. an avgpool is performed after the second convolution when stride > 1 + self.conv1 = klayers.Conv2D(planes, 1, use_bias=False, name="conv1") + self.bn1 = klayers.BatchNormalization(name="bn1", epsilon=1e-5) + + self.conv2_padding = klayers.ZeroPadding2D(padding=((1, 1), (1, 1))) + self.conv2 = klayers.Conv2D(planes, 3, use_bias=False, name="conv2") + self.bn2 = klayers.BatchNormalization(name="bn2", epsilon=1e-5) + + self.avgpool = klayers.AveragePooling2D(stride) if stride > 1 else None + + self.conv3 = klayers.Conv2D(planes * self.expansion, 1, use_bias=False, name="conv3") + self.bn3 = klayers.BatchNormalization(name="bn3", epsilon=1e-5) + + self.relu = klayers.ReLU() + self.downsample = None + self.stride = stride + + self.inplanes = inplanes + self.planes = planes + + if stride > 1 or inplanes != planes * Bottleneck.expansion: + # downsampling layer is prepended with an avgpool, and the subsequent convolution has stride 1 + self.downsample = keras.Sequential([ + klayers.AveragePooling2D(stride, name=name + "/downsample/avgpool"), + klayers.Conv2D(planes * self.expansion, 1, strides=1, use_bias=False, name=name + "/downsample/0"), + klayers.BatchNormalization(name=name + "/downsample/1", epsilon=1e-5) + ], name="downsample") + + def get_config(self): + """ + Returns the configuration dictionary for a Bottleneck block. + + Returns + ------- + dict: containing the configuration of a Bottleneck block. + """ + return { + "inplanes": self.inplanes, + "planes": self.planes, + "stride": self.stride, + "name": self.name + } + + @classmethod + def from_config(cls, config): + """ + Creates a Bottleneck block from its configuration dictionary. + + Parameters + ---------- + dict: The configuration dictionary for the Bottleneck block. + + Returns + ------- + klayers.Layer: A Bottleneck block class created from the configuration dictionary. + """ + return cls(**config) + + def call(self, x: tf.Tensor): + """ + Performs the forward pass for a Bottleneck block. + + Parameters + ---------- + x (tf.Tensor): The input tensor to the Bottleneck block. + + Returns + ------- + tf.Tensor: The result of the forward pass through the Bottleneck block. + """ + identity = x + + out = self.relu(self.bn1(self.conv1(x))) + out = self.relu(self.bn2(self.conv2(self.conv2_padding(out)))) + if self.avgpool is not None: + out = self.avgpool(out) + out = self.bn3(self.conv3(out)) + + if self.downsample is not None: + # x = tf.nn.avg_pool(x, (1, 2, 2, 1), strides=(1, 2, 2, 1), padding='VALID') + identity = self.downsample(x) + + out += identity + out = self.relu(out) + return out + + +class AttentionPool2d(klayers.Layer): + """ + An Attention Pooling layer that applies a multi-head self-attention mechanism over a spatial grid of features. + + Parameters + ---------- + spatial_dim: int + The dimensionality of the spatial grid of features. + embed_dim: int + The dimensionality of the feature embeddings. + num_heads: int + The number of attention heads. + output_dim: int + The output dimensionality of the attention layer. If None, it defaults to embed_dim. + name: str + The name of the layer. + + Attributes: + ---------- + spatial_dim: int + The dimensionality of the spatial grid of features. + embed_dim: int + The dimensionality of the feature embeddings. + num_heads: int + The number of attention heads. + output_dim: int + The output dimensionality of the attention layer. + positional_embedding: tf.Variable + The positional embedding used in the attention layer. + _key_dim: int + The dimensionality of the attention keys. + multi_head_attention: klayers.MultiHeadAttention + The multi-head attention layer used in the attention pooling. + + """ + def __init__(self, spatial_dim: int, embed_dim: int, num_heads: int, output_dim: int = None, + name="AttentionPool2d"): + """ + Initializes the AttentionPool2d layer. + + Parameters + ---------- + spatial_dim: int + The dimensionality of the spatial grid of features. + embed_dim: int + The dimensionality of the feature embeddings. + num_heads: int + The number of attention heads. + output_dim: int + The output dimensionality of the attention layer. If None, it defaults to embed_dim. + name: str + The name of the layer. + """ + super().__init__(name=name) + + self.spatial_dim = spatial_dim + self.embed_dim = embed_dim + self.num_heads = num_heads + self.output_dim = output_dim + + with tf.name_scope(name): + self.positional_embedding = tf.Variable( + tf.random.normal((spatial_dim ** 2 + 1, embed_dim)) / embed_dim ** 0.5, + name="positional_embedding" + ) + + self.num_heads = num_heads + self._key_dim = embed_dim + + self.multi_head_attention = klayers.MultiHeadAttention( + num_heads=num_heads, + key_dim=embed_dim // num_heads, + output_shape=output_dim or embed_dim, + name="mha" + ) + + def get_config(self): + """ + Returns the configuration dictionary for an AttentionPool2d layer. + + Returns + ------- + dict: containing the configuration of an AttentionPool2d layer. + """ + return { + "spatial_dim": self.spatial_dim, + "embed_dim": self.embed_dim, + "num_heads": self.num_heads, + "output_dim": self.output_dim, + "name": self.name + } + + @classmethod + def from_config(cls, config): + """ + Creates an AttentionPool2d layer from its configuration dictionary. + Parameters + ---------- + dict: The configuration dictionary for the AttentionPool2d layer. + + Returns + ------- + klayers.Layer: An AttentionPool2d layer created from the configuration dictionary. + """ + return cls(**config) + + def call(self, x, training=None): + """Performs the attention pooling operation on the input tensor. + + Parameters + ---------- + x: tf.Tensor + The input tensor of shape [batch_size, height, width, embed_dim]. + bool: Whether the layer is in training mode. Defaults to None. + + Returns + ------- + tf.Tensor: The result of the attention pooling operation.""" + x_shape = tf.shape(x) + x = tf.reshape(x, (x_shape[0], x_shape[1] * x_shape[2], x_shape[3])) # NHWC -> N(HW)C + + x = tf.concat([tf.reduce_mean(x, axis=1, keepdims=True), x], axis=1) # N(HW+1)C + x = x + tf.cast(self.positional_embedding[None, :, :], x.dtype) # N(HW+1)C + + query, key, value = x, x, x + x = self.multi_head_attention(query, value, key) + + # only return the first element in the sequence + return x[:, 0, ...] + + +class ModifiedResNet(keras.Model): + """ + A ResNet class that is similar to torchvision's but contains the following changes: + - There are now 3 "stem" convolutions as opposed to 1, with an average pool instead of a max pool. + - Performs anti-aliasing strided convolutions, where an avgpool is prepended to convolutions with stride > 1 + - The final pooling layer is a QKV attention instead of an average pool + """ + + def __init__(self, layers, output_dim, heads, input_resolution=224, width=64, name="ModifiedResNet"): + """ + Initializes a ModifiedResNet model with the given configuration. + + Parameters + ---------- + layers: list + A list containing the number of Bottleneck blocks for each layer. + output_dim: int + The output dimension of the model. + heads: int + The number of heads for the QKV attention. + input_resolution: int + The input resolution of the model. Default is 224. + width: int + The width of the model. Default is 64. + name: str + The name of the model. Default is "ModifiedResNet". + + Attributes: + ---------- + layers_config: list + A list containing the number of Bottleneck blocks for each layer. + output_dim: int + The output dimension of the model. + heads: int + The number of heads for the QKV attention. + input_resolution: int + The input resolution of the model. + width: int + The width of the model. + conv1_padding, conv1, bn1, conv2_padding, conv2, bn2, conv3_padding, conv3, bn3, avgpool, relu: + Stem layers. + _inplanes: int + A mutable variable used during construction, initialized to the width of the model. + layer1, layer2, layer3, layer4: + Residual layers. + attnpool: AttentionPool2d + The QKV attention pooling layer. + """ + super().__init__(name=name) + self.layers_config = layers + self.output_dim = output_dim + self.heads = heads + self.input_resolution = input_resolution + self.width = width + + # the 3-layer stem + self.conv1_padding = klayers.ZeroPadding2D(padding=((1, 1), (1, 1)), name="conv1_padding") + self.conv1 = klayers.Conv2D(width // 2, 3, strides=2, use_bias=False, name="conv1") + self.bn1 = klayers.BatchNormalization(name="bn1", epsilon=1e-5) + self.conv2_padding = klayers.ZeroPadding2D(padding=((1, 1), (1, 1)), name="conv2_padding") + self.conv2 = klayers.Conv2D(width // 2, 3, use_bias=False, name="conv2") + self.bn2 = klayers.BatchNormalization(name="bn2", epsilon=1e-5) + self.conv3_padding = klayers.ZeroPadding2D(padding=((1, 1), (1, 1)), name="conv3_padding") + self.conv3 = klayers.Conv2D(width, 3, use_bias=False, name="conv3") + self.bn3 = klayers.BatchNormalization(name="bn3", epsilon=1e-5) + self.avgpool = klayers.AveragePooling2D(2, name="avgpool") + self.relu = klayers.ReLU() + + # residual layers + self._inplanes = width # this is a *mutable* variable used during construction + self.layer1 = self._make_layer(width, layers[0], name=name + "/layer1") + self.layer2 = self._make_layer(width * 2, layers[1], stride=2, name=name + "/layer2") + self.layer3 = self._make_layer(width * 4, layers[2], stride=2, name=name + "/layer3") + self.layer4 = self._make_layer(width * 8, layers[3], stride=2, name=name + "/layer4") + + embed_dim = width * 32 # the ResNet feature dimension + with tf.name_scope(name): + self.attnpool = AttentionPool2d(input_resolution // 32, embed_dim, heads, output_dim, name="attnpool") + + def get_config(self): + """ + Returns a dictionary containing the configuration of the ModifiedResNet model containing + the following key-value pairs: + - "layers": the configuration of layers + - "output_dim": the output dimension + - "heads": the number of heads for the QKV attention + - "input_resolution": the input resolution of the model + - "width": the width of the model + - "name": the name of the model + """ + return { + "layers": self.layers_config, + "output_dim": self.output_dim, + "heads": self.heads, + "input_resolution": self.input_resolution, + "width": self.width, + "name": self.name + } + + @classmethod + def from_config(cls, config): + return cls(**config) + + def _make_layer(self, planes, blocks, stride=1, name="layer"): + """ + A private method that creates a sequential layer of Bottleneck blocks for the ModifiedResNet model. + + Parameters + ---------- + planes: int + The number of output channels for the layer. + blocks: int + The number of Bottleneck blocks in the layer. + stride: int + The stride for the first Bottleneck block in the layer. Default is 1. + name: str + The name of the layer. Default is "layer". + + Returns + ------- + keras.Sequential: A sequential layer of Bottleneck blocks. + """ + with tf.name_scope(name): + layers = [Bottleneck(self._inplanes, planes, stride, name=name + "/0")] + + self._inplanes = planes * Bottleneck.expansion + for i in range(1, blocks): + layers.append(Bottleneck(self._inplanes, planes, name=name + f"/{i}")) + + return keras.Sequential(layers, name="bla") + + def stem(self, x): + """ + Applies the stem operation to the input tensor, which consists of 3 convolutional + layers with BatchNormalization and ReLU activation, followed by an average pooling layer. + + Parameters + ---------- + x: tf.Tensor + The input tensor of shape [batch_size, height, width, channels]. + + Returns + ------- + tf.Tensor: The output tensor after applying the stem operation. + """ + for conv_pad, conv, bn in [ + (self.conv1_padding, self.conv1, self.bn1), + (self.conv2_padding, self.conv2, self.bn2), + (self.conv3_padding, self.conv3, self.bn3) + ]: + x = self.relu(bn(conv(conv_pad(x)))) + x = self.avgpool(x) + return x + + def call(self, x): + """ + Implements the forward pass of the ModifiedResNet model. + + Parameters + ---------- + x (tf.Tensor): The input tensor of shape [batch_size, height, width, channels]. + + Returns + ------- + tf.Tensor: The output tensor after passing through the ModifiedResNet model. + """ + + # x = x.type(self.conv1.weight.dtype) + x = stem(x) + x = self.layer1(x) + x = self.layer2(x) + x = self.layer3(x) + x = self.layer4(x) + x = self.attnpool(x) + + return x diff --git a/lib/model/networks/clip/transformer.py b/lib/model/networks/clip/transformer.py new file mode 100644 index 0000000000..d1873e9f6b --- /dev/null +++ b/lib/model/networks/clip/transformer.py @@ -0,0 +1,109 @@ +import sys + +from tensorflow import keras +import tensorflow as tf +from tensorflow.keras.layers import Input +from tensorflow.keras.models import Model +import keras.backend as K +from lib.model.layers import ResidualAttentionBlock + +class Transformer(): + """ + A class representing a Transformer model with attention mechanism and residual connections. + + Parameters + ---------- + width : int + The dimension of the input and output vectors. + layers : int + The number of layers in the Transformer. + heads : int + The number of attention heads. + attn_mask : tf.Tensor, optional + The attention mask, by default None. + name : str, optional + The name of the Transformer model, by default "transformer". + + Attributes + ---------- + width : int + The dimension of the input and output vectors. + num_layers : int + The number of layers in the Transformer. + heads : int + The number of attention heads. + attn_mask : tf.Tensor, optional + The attention mask, by default None. + name : str, optional + The name of the Transformer model, by default "transformer". + resblocks : keras.Sequential + The sequence of residual attention blocks. + + Methods + ------- + get_config() -> Dict[str, Union[int, str]]: + Returns a dictionary containing the Transformer configuration. + from_config(cls, config: Dict[str, Union[int, str]]) -> 'Transformer': + Returns a new Transformer instance from the given configuration dictionary. + __call__() -> Model: + Builds and returns the Transformer model. + """ + def __init__(self, width: int, layers: int, heads: int, attn_mask: tf.Tensor = None, name="transformer"): + """ + Initializes a new instance of the Transformer class. + """ + self.width = width + self.num_layers = layers + self.heads = heads + self.attn_mask = attn_mask + self.name = name + self.resblocks = keras.Sequential([ + ResidualAttentionBlock(width, heads, attn_mask, name=f"{name}.resblocks.{i}", idx=i) + for i in range(layers) + ], name=name + ".resblocks") + + def get_config(self): + """ + Returns a dictionary containing the Transformer configuration. + + Returns + ------- + Dict[str, Union[int, str]] + The Transformer configuration dictionary. + """ + return { + "width": self.width, + "layers": self.num_layers, + "heads": self.heads, + "name": self.name + } + + @classmethod + def from_config(cls, config): + """ + Returns a new Transformer instance from the given configuration dictionary. + + Parameters + ---------- + config : Dict[str, Union[int, str]] + The configuration dictionary. + + Returns + ------- + Transformer + A new Transformer instance with the given configuration. + """ + return cls(**config) + + def __call__(self): + """ + Builds and returns the Transformer model. + + Returns + ------- + Model + The Transformer model. + """ + inputs = Input([197, self.width]) + var_x = self.resblocks(inputs) + return Model(inputs=inputs, outputs=[var_x], name=self.name) diff --git a/lib/model/networks/clip/visual_transformer.py b/lib/model/networks/clip/visual_transformer.py new file mode 100644 index 0000000000..3016dd8123 --- /dev/null +++ b/lib/model/networks/clip/visual_transformer.py @@ -0,0 +1,165 @@ +import tensorflow as tf +from tensorflow import keras +from tensorflow.keras import layers as klayers +from .transformer import Transformer +from tensorflow.keras.models import Model +from tensorflow.keras import backend as K +from tensorflow.keras.layers import LayerNormalization, Dense, Input, Concatenate, Reshape +from tensorflow.keras.initializers import RandomNormal +import numpy as np + +class VisualTransformer(): + """ + A class representing a Visual Transformer model for image classification tasks. + + Attributes + ---------- + input_resolution : int + The input resolution of the images. + patch_size : int + The size of the patches to be extracted from the images. + width : int + The dimension of the input and output vectors. + num_layers : int + The number of layers in the Transformer. + heads : int + The number of attention heads. + output_dim : int + The dimension of the output vector. + name : str, optional + The name of the Visual Transformer model, by default "VisualTransformer". + conv1 : keras.layers.Conv2D + The 2D convolution layer. + transformer : Transformer + The Transformer model. + class_embedding : K.constant + The class embedding vector. + positional_embedding : K.constant + The positional embedding matrix. + ln_pre : keras.layers.LayerNormalization + The layer normalization applied before the Transformer. + ln_post : keras.layers.LayerNormalization + The layer normalization applied after the Transformer. + proj : K.constant + The projection matrix. + + Methods + ------- + get_config() -> Dict[str, Union[int, str]]: + Returns a dictionary containing the Visual Transformer configuration. + from_config(cls, config: Dict[str, Union[int, str]]) -> 'VisualTransformer': + Returns a new VisualTransformer instance from the given configuration dictionary. + __call__() -> Model: + Builds and returns the Visual Transformer model. + """ + def __init__(self, input_resolution: int, patch_size: int, width: int, layers: int, heads: int, output_dim: int, name="VisualTransformer"): + """ + Initializes a new instance of the VisualTransformer class. + + Parameters + ---------- + input_resolution : int + The input resolution of the images. + patch_size : int + The size of the patches to be extracted from the images. + width : int + The dimension of the input and output vectors. + layers : int + The number of layers in the Transformer. + heads : int + The number of attention heads. + output_dim : int + The dimension of the output vector. + name : str, optional + The name of the Visual Transformer model, by default "VisualTransformer". + """ + self.input_resolution: int = input_resolution + self.patch_size: int = patch_size + self.width: int = width + self.num_layers: int = layers + self.heads: int = heads + self.output_dim: int = output_dim + self.name = name + + self.conv1 = klayers.Conv2D(width, patch_size, strides=patch_size, use_bias=False, name=f"{name}/conv1") + + scale = width ** -0.5 + + self.transformer = Transformer(width, layers, heads, name=f"{name}//transformer")() + + self.class_embedding = K.constant(scale * np.random.random((width,)), name=f"{name}/class_embedding") + self.positional_embedding = K.constant(scale * tf.random.normal(((input_resolution // patch_size) ** 2 + 1, width)), name=f"{name}/positional_embedding") + self.ln_pre = keras.layers.LayerNormalization(epsilon=1e-05, name=f"{name}/ln_pre") + + self.ln_post = keras.layers.LayerNormalization(epsilon=1e-05, name=f"{name}/ln_post") + self.proj = K.constant(scale * np.random.random((width, output_dim)), name=f"{name}/proj") + + def get_config(self): + """ + Returns a dictionary containing the Visual Transformer configuration. + + Returns + ------- + Dict[str, Union[int, str]] + The Visual Transformer configuration dictionary. + """ + return { + "input_resolution": self.input_resolution, + "patch_size": self.patch_size, + "width": self.width, + "layers": self.num_layers, + "heads": self.heads, + "output_dim": self.output_dim, + "name": self.name + } + + @classmethod + def from_config(cls, config): + """ + Returns a new VisualTransformer instance from the given configuration dictionary. + + Parameters + ---------- + config : Dict[str, Union[int, str]] + The configuration dictionary. + + Returns + ------- + VisualTransformer + A new VisualTransformer instance with the given configuration. + """ + return cls(**config) + + def __call__(self): + """ + Builds and returns the Visual Transformer model. + + Returns + ------- + Model + The Visual Transformer model. + """ + inputs = Input([self.input_resolution, self.input_resolution, 3]) + var_x = self.conv1(inputs) # shape = [*, grid, grid, width] + + x_shape = var_x.shape + var_x = Reshape((196, self.width))(var_x) # shape = [*, grid ** 2, width] + + x_shape = K.shape(var_x) + class_embedding = K.expand_dims(K.expand_dims(K.cast(self.class_embedding, var_x.dtype),0),0) + class_embedding_tiled = K.tile(class_embedding, [x_shape[0], 1, 1]) + var_x = Concatenate(axis=1)([class_embedding_tiled, var_x]) + var_x = var_x + K.cast(self.positional_embedding, var_x.dtype) + var_x = self.ln_pre(var_x) + var_x = self.transformer(var_x) + var_x = self.ln_post(var_x[:, 0, :]) + + if self.proj is not None: + if var_x.dtype == tf.float16: + var_x = K.cast(var_x, tf.float32) #TODO: remove this when tf.matmul supports float16 with float32 + var_x = var_x @ self.proj + var_x = K.cast(var_x, tf.float16) + else: + var_x = var_x @ self.proj + return Model(inputs=inputs, outputs=[var_x], name=self.name) + diff --git a/plugins/train/model/clipfaker.py b/plugins/train/model/clipfaker.py new file mode 100644 index 0000000000..9e0cf80cd9 --- /dev/null +++ b/plugins/train/model/clipfaker.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" DFaker Model + Based on the dfaker model: https://github.com/dfaker """ +import logging +import sys + +from lib.model.nn_blocks import Conv2DOutput, UpscaleBlock, ResidualBlock +from tensorflow import keras +from tensorflow.keras.layers import Dense, Reshape, Input +from lib.utils import get_backend +from .original import Model as OriginalModel, KerasModel +from lib.model.networks.clip.model import _Models, ClipConfig +from lib.model.networks.clip.visual_transformer import VisualTransformer +from lib.utils import GetModel +import numpy as np + +if get_backend() == "amd": + from keras.initializers import RandomNormal # pylint:disable=no-name-in-module + from keras.layers import Input, LeakyReLU +else: + # Ignore linting errors from Tensorflow's thoroughly broken import system + from tensorflow.keras.initializers import RandomNormal # noqa pylint:disable=import-error,no-name-in-module + from tensorflow.keras.layers import Input, LeakyReLU # noqa pylint:disable=import-error,no-name-in-module + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + +class Model(OriginalModel): + """ Clipfaker Model """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._output_size: int = self.config["output_size"] + if self._output_size not in (128, 256): + logger.error("Clipfaker output shape should be 128 or 256 px") + sys.exit(1) + self.input_shape: tuple [int, int, int] = (224, 224, 3) + self.encoder_dim: int = 512 + self.kernel_initializer = RandomNormal(0, 0.02) + clipconfig: ClipConfig = _Models['FaRL-B_16-64'] + self.visualtransformer = VisualTransformer( + input_resolution=clipconfig.image_resolution, + patch_size=clipconfig.vision_patch_size, + width=clipconfig.vision_width, + layers=clipconfig.vision_layers, + heads=clipconfig.vision_width//64, + output_dim=clipconfig.embed_dim, + name="visual")() + # Used to temporarily load FaRL weights + empty_image = np.zeros((1, 224, 224, 3)) + self.visualtransformer(empty_image) + # self.visualtransformer.summary() + + model_downloader = GetModel("s3fd_keras_v2.h5", 11) + model_downloader._model_filename = ['FaRL_v1.h5'] + model_downloader._git_model_id = 1 + model_downloader._url_base = "https://github.com/Arkavian/faceswap-models/releases/download" + model_downloader._get() + self.visualtransformer.load_weights(model_downloader.model_path, by_name=True) + + # model_downloader._change_base("https://github.com/Arkavian/faceswap-models/releases/download") + + # model_downloader._change_base("Arkavian","faceswap-models") + + # self.visualtransformer.load_weights('/home/nikkelitous/FaRL_Visual.h5', by_name=True, skip_mismatch=True) + # self.visualtransformer.load_weights('/home/nikkelitous/FaRL_Visual_update.h5', by_name=True) + self.visualtransformer.trainable = False + # self.visualtransformer.save_weights('/home/nikkelitous/FaRL_Visual_output.h5') + + def decoder(self, side): + """ Decoder Network """ + input_ = Input(shape=(8, 8, 512)) + var_x = input_ + + if self._output_size == 256: + var_x = UpscaleBlock(1024, activation=None)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = ResidualBlock(1024, kernel_initializer=self.kernel_initializer)(var_x) + var_x = UpscaleBlock(512, activation=None)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = ResidualBlock(512, kernel_initializer=self.kernel_initializer)(var_x) + var_x = UpscaleBlock(256, activation=None)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = ResidualBlock(256, kernel_initializer=self.kernel_initializer)(var_x) + var_x = UpscaleBlock(128, activation=None)(var_x) + var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = ResidualBlock(128, kernel_initializer=self.kernel_initializer)(var_x) + var_x = UpscaleBlock(64, activation="leakyrelu")(var_x) + var_x = Conv2DOutput(3, 5, name=f"face_out_{side}")(var_x) + outputs = [var_x] + + if self.config.get("learn_mask", False): + var_y = input_ + if self._output_size == 256: + var_y = UpscaleBlock(1024, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(512, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(256, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(128, activation="leakyrelu")(var_y) + var_y = UpscaleBlock(64, activation="leakyrelu")(var_y) + var_y = Conv2DOutput(1, 5, name=f"mask_out_{side}")(var_y) + outputs.append(var_y) + return KerasModel([input_], outputs=outputs, name=f"decoder_{side}") + + def encoder(self): + input_ = Input(shape=(224, 224, 3)) + var_x = input_ + + var_x = self.visualtransformer(var_x) + var_x = Dense(4 * 4 * 1024)(var_x) + var_x = Reshape((4, 4, 1024))(var_x) + var_x = UpscaleBlock(512, activation=None)(var_x) + outputs = [var_x] + return KerasModel([input_], outputs=outputs, name=f"encoder") diff --git a/plugins/train/model/clipfaker_defaults.py b/plugins/train/model/clipfaker_defaults.py new file mode 100644 index 0000000000..de0ac444bb --- /dev/null +++ b/plugins/train/model/clipfaker_defaults.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +""" + The default options for the faceswap Dfl_SAE Model plugin. + + Defaults files should be named _defaults.py + Any items placed into this file will automatically get added to the relevant config .ini files + within the faceswap/config folder. + + The following variables should be defined: + _HELPTEXT: A string describing what this plugin does + _DEFAULTS: A dictionary containing the options, defaults and meta information. The + dictionary should be defined as: + {: {}} + + should always be lower text. + dictionary requirements are listed below. + + The following keys are expected for the _DEFAULTS dict: + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid data types are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and data types this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and data types this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. +""" + + +_HELPTEXT = "Dfaker Model (Adapted from https://github.com/dfaker/df)" + + +_DEFAULTS = dict( + output_size=dict( + default=256, + info="Resolution (in pixels) of the output image to generate on.\n" + "BE AWARE Larger resolution will dramatically increase VRAM requirements.\n" + "Must be 128 or 256.", + datatype=int, + rounding=128, + min_max=(128, 256), + group="size", + fixed=True)) From ac8206ed6e2061eaea2795a23fb3287e21026372 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 15 Jun 2023 18:28:37 +0100 Subject: [PATCH 819/981] bugfix: Extract - rotation in detection --- plugins/extract/detect/_base.py | 51 ++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index d401de1952..156d97bbdb 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -174,7 +174,7 @@ def get_batch(self, queue: "Queue") -> Tuple[bool, DetectorBatch]: {k: len(v) if isinstance(v, (list, np.ndarray)) else v for k, v in batch.__dict__.items()}) else: - logger.trace(item) # type:ignore + logger.trace(item) # type:ignore[attr-defined] if not exhausted and not batch.filename: # This occurs when face filter is fed aligned faces. @@ -201,7 +201,7 @@ def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: for the detected faces found in the frame. """ assert isinstance(batch, DetectorBatch) - logger.trace("Item out: %s", # type:ignore + logger.trace("Item out: %s", # type:ignore[attr-defined] {k: len(v) if isinstance(v, (list, np.ndarray)) else v for k, v in batch.__dict__.items()}) @@ -235,13 +235,13 @@ def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: output = self._extract_media.pop(filename) output.add_detected_faces(batch.detected_faces[idx]) - logger.trace("final output: (filename: '%s', image shape: %s, " # type:ignore - "detected_faces: %s, item: %s", output.filename, output.image_shape, - output.detected_faces, output) + logger.trace("final output: (filename: '%s', " # type:ignore[attr-defined] + "image shape: %s, detected_faces: %s, item: %s", + output.filename, output.image_shape, output.detected_faces, output) yield output @staticmethod - def _to_detected_face(left: float, top: float, right: float, bottom: float): + def _to_detected_face(left: float, top: float, right: float, bottom: float) -> DetectedFace: """ Convert a bounding box to a detected face object Parameters @@ -276,9 +276,15 @@ def _predict(self, batch: BatchType) -> DetectorBatch: # Rotate the batch and insert placeholders for already found faces self._rotate_batch(batch, angle) try: - batch.prediction = self.predict(batch.feed) - logger.trace("angle: %s, filenames: %s, prediction: %s", # type:ignore - angle, batch.filename, batch.prediction) + pred = self.predict(batch.feed) + if angle == 0: + batch.prediction = pred + else: + batch.prediction = np.array([b if b.any() else p + for b, p in zip(batch.prediction, pred)]) + logger.trace("angle: %s, filenames: %s, " # type:ignore[attr-defined] + "prediction: %s", + angle, batch.filename, pred) except tf_errors.ResourceExhaustedError as err: msg = ("You do not have enough GPU memory available to run detection at the " "selected batch size. You can try a number of things:" @@ -308,7 +314,8 @@ def _predict(self, batch: BatchType) -> DetectorBatch: raise if angle != 0 and any(face.any() for face in batch.prediction): - logger.verbose("found face(s) by rotating image %s degrees", # type:ignore + logger.verbose("found face(s) by rotating image %s " # type:ignore[attr-defined] + "degrees", angle) found_faces = cast(List[np.ndarray], ([face if not found.any() else found @@ -316,12 +323,13 @@ def _predict(self, batch: BatchType) -> DetectorBatch: found_faces)])) if all(face.any() for face in found_faces): - logger.trace("Faces found for all images") # type:ignore + logger.trace("Faces found for all images") # type:ignore[attr-defined] break batch.prediction = np.array(found_faces, dtype="object") - logger.trace("detect_prediction output: (filenames: %s, prediction: %s, " # type:ignore - "rotmat: %s)", batch.filename, batch.prediction, batch.rotation_matrix) + logger.trace("detect_prediction output: (filenames: %s, " # type:ignore[attr-defined] + "prediction: %s, rotmat: %s)", + batch.filename, batch.prediction, batch.rotation_matrix) return batch # <<< DETECTION IMAGE COMPILATION METHODS >>> # @@ -349,7 +357,8 @@ def _compile_detection_image(self, item: ExtractMedia image = self._scale_image(image, item.image_size, scale) image = self._pad_image(image) - logger.trace("compiled: (images shape: %s, scale: %s, pad: %s)", # type:ignore + logger.trace("compiled: (images shape: %s, " # type:ignore[attr-defined] + "scale: %s, pad: %s)", image.shape, scale, pad) return image, scale, pad @@ -367,7 +376,7 @@ def _set_scale(self, image_size: Tuple[int, int]) -> float: The scaling factor from original image size to model input size """ scale = self.input_size / max(image_size) - logger.trace("Detector scale: %s", scale) # type:ignore + logger.trace("Detector scale: %s", scale) # type:ignore[attr-defined] return scale def _set_padding(self, image_size: Tuple[int, int], scale: float) -> Tuple[int, int]: @@ -410,11 +419,12 @@ def _scale_image(image: np.ndarray, image_size: Tuple[int, int], scale: float) - interpln = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA if scale != 1.0: dims = (int(image_size[1] * scale), int(image_size[0] * scale)) - logger.trace("Resizing detection image from %s to %s. Scale=%s", # type:ignore + logger.trace("Resizing detection image from %s to %s. " # type:ignore[attr-defined] + "Scale=%s", "x".join(str(i) for i in reversed(image_size)), "x".join(str(i) for i in dims), scale) image = cv2.resize(image, dims, interpolation=interpln) - logger.trace("Resized image shape: %s", image.shape) # type:ignore + logger.trace("Resized image shape: %s", image.shape) # type:ignore[attr-defined] return image def _pad_image(self, image: np.ndarray) -> np.ndarray: @@ -442,7 +452,7 @@ def _pad_image(self, image: np.ndarray) -> np.ndarray: pad_l, pad_r, cv2.BORDER_CONSTANT) - logger.trace("Padded image shape: %s", image.shape) # type:ignore + logger.trace("Padded image shape: %s", image.shape) # type:ignore[attr-defined] return image # <<< FINALIZE METHODS >>> # @@ -634,7 +644,7 @@ def _rotate_image_by_angle(self, https://stackoverflow.com/questions/22041699 """ - logger.trace("Rotating image: (image: %s, angle: %s)", # type:ignore + logger.trace("Rotating image: (image: %s, angle: %s)", # type:ignore[attr-defined] image.shape, angle) channels_first = image.shape[0] <= 4 if channels_first: @@ -645,7 +655,8 @@ def _rotate_image_by_angle(self, rotation_matrix = cv2.getRotationMatrix2D(image_center, -1.*angle, 1.) rotation_matrix[0, 2] += self.input_size / 2 - image_center[0] rotation_matrix[1, 2] += self.input_size / 2 - image_center[1] - logger.trace("Rotated image: (rotation_matrix: %s", rotation_matrix) # type:ignore + logger.trace("Rotated image: (rotation_matrix: %s", # type:ignore[attr-defined] + rotation_matrix) image = cv2.warpAffine(image, rotation_matrix, (self.input_size, self.input_size)) if channels_first: image = np.moveaxis(image, 2, 0) From e0eda1cf80b3392af794dbc16faaf38eb6a7fa84 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 16 Jun 2023 13:31:45 +0100 Subject: [PATCH 820/981] bugfix: Extract memory leak --- lib/cli/launcher.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index ce298d77c6..f78276f7b5 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -83,6 +83,14 @@ def _set_environment_variables(self) -> None: logger.debug("Setting `KMP_DUPLICATE_LIB_OK` environment variable to `TRUE`") os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE" + # There is a memory leak in TF2.10+ predict function. This fix will work for tf2.10 but not + # for later versions. This issue has been patched recently, but we'll probably need to + # skip some TF versions + # ref: https://github.com/tensorflow/tensorflow/issues/58676 + # TODO remove this fix post TF2.10 and check memleak is fixed + logger.debug("Setting TF_RUN_EAGER_OP_AS_FUNCTION env var to False") + os.environ["TF_RUN_EAGER_OP_AS_FUNCTION"] = "false" + def _test_for_tf_version(self) -> None: """ Check that the required Tensorflow version is installed. From 8f08832c0ff034865632e33c66c8f7e32b38da36 Mon Sep 17 00:00:00 2001 From: Ching-Yuan Huang <47504492+che3000@users.noreply.github.com> Date: Fri, 16 Jun 2023 22:30:05 +0800 Subject: [PATCH 821/981] Fixed UnicodeDecodeError (#1316) * Update image.py use encoding="utf8", in line303, 413, 524, 803, 1459 fix UnicodeDecodeError: 'cp950' codec can't decode byte 0x83 in position 52: illegal multibyte sequence * Update image.py fix : ValueError: binary mode doesn't take an encoding argument --------- Co-authored-by: Ching-Yuan Huang <47504492+che610369@users.noreply.github.com> --- lib/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/image.py b/lib/image.py index dd9910fa05..06c13c5d68 100644 --- a/lib/image.py +++ b/lib/image.py @@ -800,7 +800,7 @@ def count_frames(filename, fast=False): process = subprocess.Popen(cmd, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, - universal_newlines=True) + universal_newlines=True, encoding="utf8") pbar = None duration = None init_tqdm = False From e8a30168821ba6ce7399e56452c2f6f73ee5cf3a Mon Sep 17 00:00:00 2001 From: andentze <66969439+andentze@users.noreply.github.com> Date: Sat, 17 Jun 2023 18:09:59 +0700 Subject: [PATCH 822/981] create even more RU translations (#1322) * create translations for GUI menus * locale: Add RU translations to extract global config * locales: general refactor + extract, train and general config translations * add more translations --- locales/ru/LC_MESSAGES/gui.menu.mo | Bin 1604 -> 2891 bytes locales/ru/LC_MESSAGES/gui.menu.po | 112 +-- locales/ru/LC_MESSAGES/gui.tooltips.mo | Bin 6394 -> 6394 bytes locales/ru/LC_MESSAGES/gui.tooltips.po | 2 +- locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 63457 -> 63448 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 10 +- locales/ru/LC_MESSAGES/lib.config.mo | Bin 0 -> 1311 bytes locales/ru/LC_MESSAGES/lib.config.po | 76 ++ .../ru/LC_MESSAGES/plugins.extract._config.mo | Bin 0 -> 9371 bytes .../ru/LC_MESSAGES/plugins.extract._config.po | 166 ++++ .../ru/LC_MESSAGES/plugins.train._config.mo | Bin 0 -> 53511 bytes .../ru/LC_MESSAGES/plugins.train._config.po | 876 ++++++++++++++++++ 12 files changed, 1180 insertions(+), 62 deletions(-) create mode 100644 locales/ru/LC_MESSAGES/lib.config.mo create mode 100644 locales/ru/LC_MESSAGES/lib.config.po create mode 100644 locales/ru/LC_MESSAGES/plugins.extract._config.mo create mode 100644 locales/ru/LC_MESSAGES/plugins.extract._config.po create mode 100644 locales/ru/LC_MESSAGES/plugins.train._config.mo create mode 100644 locales/ru/LC_MESSAGES/plugins.train._config.po diff --git a/locales/ru/LC_MESSAGES/gui.menu.mo b/locales/ru/LC_MESSAGES/gui.menu.mo index 97a98fc3c9de51ba647c28213af0dc3a7fa10811..15df09a5cdb3086c27673201d30b318ea0b2b1a0 100644 GIT binary patch literal 2891 zcmai#Piz!b9LFDu0;{6ne^7i#KoMuzZHwTR#e_nUSZHMzL!y^q=k4y;-I?*tOtAqH zXhp?hO-zg@5E49jDYdnwrRCy5J-yM3iHQeIH1Q@!FZ%txncXfF;!9^g^Zxzb@Av)v z=JnUrOFv_18}K}Zr(+3YTfv^Y@PoGe?s$9!_z3!&z|~+U_yBkid=z{Od;*lo{0O)a z{R`ky@Y`hm`=tLHn8Ns<;7afY_#n6hi|+?lf%k#yz$d{Mz;)mra2Z$v@yGh{L+5(n z!{FJ3=fM@|UjfP9EJ%KT2h#puK=S{0a5?yQ(*Fmfb<6Kz>~U}{_zc(%(!K)t5j1)e zY)Ai(d*K_n29qy>d%?A!1WA`s&;T!kWamflIq(KZ=dQsI^*4az=W8Iva|B!iz5`O+ z=MsJelARyGZQx&E2gngD?LQ2Xu1CS`U>&6MzXZ2{KY^t8zaZIJiN%}2G)Vp)0cqR; zNrzF8^uGjBeawPs@MnhWYUd%eiqLvJmhDiQ4Xm+jfZq6A1QCtP*M!(Ve9cw?LsOY8&b?3!b6R;-as$B zEZsUcd0_he+?Cvu?%Sdi@UmSKtg9+Y{k)9f(6h`y_~~?-b=8C^xfCVg#LIjPxxV0i z@mAL5I%T^OO2G>v2yCYkZ)=VoKgs>p2`OM2E;!;CKYo&q*kk)8S6bZQ#j4=lW=Rx| znI3Np`$7%~$-2qKeRfa{k78wSD0#sRWl8WZ*AnbiQS(@jYg&oTgRq`>NJsQ~!dcWW zLWJgI93krs11}7CVbBjmo$q&`M%@f-*I|8TAcgDT>_X^yt_*lkwf%;ItRctip&hV8 zqDHPHW3lK6r@t`)O>`oxcntc$Z63JX5@j>2kpW)^=d*%2u%LBohnrexJ#_qJybeZ^ z(L`}pIA#Z>D&H+lr&MK!bzZf^J|w2#OQDoVZEX-)s-YBw#Yq=dMjG@QOAH|9eW7g$ zpPP=wyImR98IIEHk{FHsmhpy=K9tJw)FD9v83+7|Z5g}6ifgN*ZlL&#%v~^&+<*1IK5`p=d)S<3eRvXM}Jqo ztueJT-$Ac>M`mZXjh{FXZ`qaY*o-Y%Y-!uvnc62DAt4Tg8_aQUu!2+~fV4Nrv*~TA zBSx}-bm?=2We2=H-JZ^-7+2G3ibwCMF?ChVsxhvn@H(N!q7T$GP0kXoN2k=hnp88< zkQ!s^3*8-8li+kT6rJOky`tvuJB|&Lc$Q#3D7>JUSX- z>T`_GM5m&W*k;SAOr3{IFr!bts%F%*Hh=3V{J)Ncr(ozB+>4iwC*QZa1P7;daN1|> zZ#1H=Ci9KU!)RV3(P2zjIAlW2X}jF_2HG}Xg56cgq3V_-fSx~X^?rK2lJpBu|X z3eo0CU41u;lT@4DLcUFLoCHUpEtTRO_u;ZxwEB{|f>Y*oBvb;l<~oHoOzBaxDAqAu zyrWEA)Pf8nQKzXkQ9IEYY>wx3{j?S)t7ddI7F)tqd>i}Z+`_I~D}9=_AP?GI z?G9y-s$>{nUGr;<3yl6hHZE_<4y{5l4pU;O;z$(=Y%U&Zsh?Z#bafG0QB{xQna~w8 XPPYawiOWY8uMLA-QtJ z4HvV~q!i^&>6|-P%AerIl?&gq9#?)(zwZLk0cGCnmB{ zosAImB{+wmcjb@ZhQy4NH+CWaLN<_Ji>?2n78Scu#gUb8$dNAU+8R~qrkYr#noc)# zZH2X&oh)IF2F8{*my&a|k4`0Z8q<>ce4K{0g*chi)CIb;t0)Q!Te?}^((R3@TXB?# zAypI1XB}a))^YnlaJWCz+dojcwrVDJQJrJ~dynsw7kEjeMV6QOy~v0|<3PV7!qy$l t?8!08W}G2KWcVF_5LuDu1#v`qQ5^6ak;d*vJe5)zi(5?hc!Akmp6HS^Dst6rM51_B&>@J*1=5ZK;0xk7ac&Q#a1b+=paFuRCt-#1f(@`(_Ui^bywhRwbh^RQGw??NrHzoSLd(xWj!6xhlqh@sc^H=8aRGKH zuTayc8apq-X7Q94?unsR$dbSIa+S+Ld!?E zB>ojs1GbJso0#_rbQgNWl)dsZ{1QWAw)%K_zra>C2*#mC1(TDSP^?O%pXAk4dS&)+ z`aY!Z{jP1PfcFR77We;zP10w@e6d2+3#9Lw*3*e~qE$R0TEu2?iP$DCO#S>iERZlL zrfxVE?l8~nwv?CI*A&|~RIb|??y9zBnAfhhTku8i+QMTs(^n+a delta 782 zcmXAmSxA&&6o#L1(y_@(T3n)wDTE*~QcFZ=v2Kd)T4+RE$`adLD9UV^8m(|atEniW zpc@fO)23)NQqZ!S3H~2~63Vhd3Q-5OBwOEU;D_fw?>Xl^=li}lW7fejYum3d*D0VS zA2wPbaTlbCCA&etG!{T_7)0)Y9O?c-=#jp>2;#*NF-p2^AIudO?1z3a;Xo+==OC<8 zf7~J1COS)CnfmUPsaEl;GF-gX&)37D0ewf|w+a%$S0jTMbYo zKE4DsGICsjQswVng$?53YfvlZiF3qZ(XYB`F;T*c>+nqRL?h(R0ACYqQ5@R>Zn2;h zs-+Lzhb5{nbv=Y~o$zuG+_u52KG>{+=9lnO@ub%Nt9+neSEhl1cVL%M_yAOh(eEMn zgtZ@_N}M?uTCiR`uXxc%&{?=62IJIE@LlW_ZJOh1{|xIjpyLZ<$#8mD8;aXUbUot0 zXz0o^0+6IQXIyVAgW3t*mbiBk)=0O0({qTKa*vU|b4pJq7K>5hG0`qoiE(0sI4}4$ z=_gvk6ES!~zqdO4$5#8Qq~v5r^6HG0Y2G?#yv3HXBE^nz(KGvIthYC7D#GeB-Jb6J z_*k>jtalh)M!R{&XfxW4F4Jvvnw8$M{FCW1T}HR@D43AwGaeXE|5tmD+$h>%@p&@y L=6lCSha&$0`N+N) diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.po b/locales/ru/LC_MESSAGES/lib.cli.args.po index 43973cc689..f831c38f29 100755 --- a/locales/ru/LC_MESSAGES/lib.cli.args.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2022-11-20 01:34+0000\n" -"PO-Revision-Date: 2023-04-11 15:06+0700\n" +"PO-Revision-Date: 2023-06-12 17:49+0700\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -17,7 +17,7 @@ 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.3.1\n" #: lib/cli/args.py:193 lib/cli/args.py:203 lib/cli/args.py:211 #: lib/cli/args.py:221 @@ -182,7 +182,7 @@ msgid "" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" msgstr "" "R|Дополнительный маскер(ы) для использования. Все маски, созданные здесь, " -"будут занимать оперативную память GPU. Вы можете выбрать ни одной, одну или " +"будут занимать видеопамять GPU. Вы можете выбрать ни одной, одну или " "несколько масок, но извлечение может занять больше времени, чем больше масок " "вы выберете. Примечание: Расширенные маски и маски компонентов (на основе " "ориентиров) генерируются автоматически при извлечении.\n" @@ -716,7 +716,7 @@ msgstr "" #: lib/cli/args.py:897 msgid "Disable multiprocessing. Slower but less resource intensive." -msgstr "Отключите многопоточную обработку. Медленнее, но менее ресурсоемко." +msgstr "Отключение многопоточной обработки. Медленнее, но менее ресурсоемко." #: lib/cli/args.py:913 msgid "" @@ -724,7 +724,7 @@ msgid "" "Training models can take a long time. Anything from 24hrs to over a week\n" "Model plugins can be configured in the 'Settings' Menu" msgstr "" -"Обучить модель на извлеченных оригинальных (A) и подмененных (B) лицах.\n" +"Обучение модели на извлеченных оригинальных (A) и подмененных (B) лицах.\n" "Обучение моделей может занять много времени. От 24 часов до недели.\n" "Плагины для моделей можно настроить в меню \"Настройки\"" diff --git a/locales/ru/LC_MESSAGES/lib.config.mo b/locales/ru/LC_MESSAGES/lib.config.mo new file mode 100644 index 0000000000000000000000000000000000000000..49787224ffce77a9f242a699f65ad52b23ebf072 GIT binary patch literal 1311 zcmah|Noy2A7%ep}v>@WmAX=b@sOfPQqG7~~StO8Ph!YW1q%vJI)1jw(=tUDkz)Vn) zgbWDrB=`%2BqpY_O|E*Y`U74(2_D6RUVK%f8I6h!k5^US_P(#G-ydt4C-@ElCxAP^ zVc-RzzPG?};2m%j_y9z}XW$$#x`&W6;CXNgybeAB{sjIC{suk`{<4>lR&eV+LOy~! zz&+qoErcY&9BhN1gHM9VenJpRu>I{r7&!nO1e9i^Rjz~>T_w*8I4$^IIZJbX#M@~v za{|80GBHVvz5)%n!}Fo-7HK(hLc8K{>QzG9a|0TdxJx<9mwuekpyWl4MMpTID$Hjg z$7Y-|8dO*waXyxLuUuxdjVFo;YKOd>YS)x*Q0A;bj2pTnd@Y{0%gmu}R370z@`evM zN={5t=31bpt4nP+UMr&r(0SH#BSv^bPbh z>!%V)qsv3jF4BxwP$%SL;f0FBZ1*zFmzW>$@XBzm*X(Tisd5GGn>}vcvv8$Z+Bsr} z#x3(2cR8+0=@|65WrsAA$Rtvjgm@-r#G+V{4@E4iVugxWtnPX?|L{@wBj)9l*bp%l zOFB=CJPXLCGi_lpCDuh$hH9XhmMnBp=hX4U`O6QK{6J)MqdRWAIBIp$rR8w+VJ!p(b&ujO%{~PcM+R;3O zqgYk9xu)i&<}s~?AgfC1q86~!oX9eUUZaSamQS_NDz0c+&S4kJM@n6NXM}6|pAau` TL&&Kt8_>0Yl(C#^{3G81R_ZQO literal 0 HcmV?d00001 diff --git a/locales/ru/LC_MESSAGES/lib.config.po b/locales/ru/LC_MESSAGES/lib.config.po new file mode 100644 index 0000000000..f9c3d0d252 --- /dev/null +++ b/locales/ru/LC_MESSAGES/lib.config.po @@ -0,0 +1,76 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-06-11 23:28+0100\n" +"PO-Revision-Date: 2023-06-12 21:25+0700\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ru_RU\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.3.1\n" + +#: lib/config.py:393 +msgid "" +"\n" +"This option can be updated for existing models.\n" +msgstr "" +"\n" +"Эта настройка будет обновлена для существующих моделей.\n" + +#: lib/config.py:395 +msgid "" +"\n" +"If selecting multiple options then each option should be separated by a " +"space or a comma (e.g. item1, item2, item3)\n" +msgstr "" +"\n" +"Если выбираете несколько опций, тогда каждая опция должна быть " +"разделена пробелом или запятой (например: опция1, опция2, опция3)\n" + +#: lib/config.py:398 +msgid "" +"\n" +"Choose from: {}" +msgstr "" +"\n" +"Выберите из: {}" + +#: lib/config.py:400 +msgid "" +"\n" +"Choose from: True, False" +msgstr "" +"\n" +"Выберите из: True, False" + +#: lib/config.py:404 +msgid "" +"\n" +"Select an integer between {} and {}" +msgstr "" +"\n" +"Выберите число между {} и {}" + +#: lib/config.py:408 +msgid "" +"\n" +"Select a decimal number between {} and {}" +msgstr "" +"\n" +"Выберите десятичное число между {} и {}" + +#: lib/config.py:409 +msgid "" +"\n" +"[Default: {}]" +msgstr "" +"\n" +"[По умолчанию: {}]" diff --git a/locales/ru/LC_MESSAGES/plugins.extract._config.mo b/locales/ru/LC_MESSAGES/plugins.extract._config.mo new file mode 100644 index 0000000000000000000000000000000000000000..4ec5cb72f3d62948298e1f0f60a0dbfc0c34ea6a GIT binary patch literal 9371 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, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-06-08 16:43+0100\n" +"PO-Revision-Date: 2023-06-12 19:42+0700\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ru_RU\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.3.1\n" + +#: plugins/extract/_config.py:32 +msgid "Options that apply to all extraction plugins" +msgstr "Параметры, применимые ко всем плагинам извлечения" + +#: plugins/extract/_config.py:38 +msgid "settings" +msgstr "настройки" + +#: plugins/extract/_config.py:39 +msgid "" +"[Nvidia Only]. Enable the Tensorflow GPU `allow_growth` configuration " +"option. This option prevents Tensorflow from allocating all of the GPU VRAM " +"at launch but can lead to higher VRAM fragmentation and slower performance. " +"Should only be enabled if you are having problems running extraction." +msgstr "" +"[Только для Nvidia]. Включите опцию конфигурации Tensorflow GPU " +"`allow_growth`. Эта опция не позволяет Tensorflow выделять всю видеопамять " +"видеокарты при запуске, но может привести к повышенной фрагментации " +"видеопамяти и снижению производительности. Следует включать только в том " +"случае, если у вас есть проблемы с запуском извлечения." + +#: plugins/extract/_config.py:50 plugins/extract/_config.py:64 +#: plugins/extract/_config.py:78 plugins/extract/_config.py:89 +#: plugins/extract/_config.py:99 plugins/extract/_config.py:108 +#: plugins/extract/_config.py:119 +msgid "filters" +msgstr "фильтры" + +#: plugins/extract/_config.py:51 +msgid "" +"Filters out faces below this size. This is a multiplier of the minimum " +"dimension of the frame (i.e. 1280x720 = 720). If the original face extract " +"box is smaller than the minimum dimension times this multiplier, it is " +"considered a false positive and discarded. Faces which are found to be " +"unusually smaller than the frame tend to be misaligned images, except in " +"extreme long-shots. These can be usually be safely discarded." +msgstr "" +"Отфильтровывает лица меньше этого размера. Это множитель минимального " +"размера кадра (т.е. 1280x720 = 720). Если исходное поле извлечения лица " +"меньше минимального размера, умноженного на этот множитель, оно считается " +"ложным срабатыванием и отбрасывается. Лица, которые оказываются необычно " +"меньшего размера, чем кадр, как правило, являются неправильно выровненными " +"изображениями, за исключением экстремально длинных снимков. Обычно их можно " +"смело отбрасывать." + +#: plugins/extract/_config.py:65 +msgid "" +"Filters out faces above this size. This is a multiplier of the minimum " +"dimension of the frame (i.e. 1280x720 = 720). If the original face extract " +"box is larger than the minimum dimension times this multiplier, it is " +"considered a false positive and discarded. Faces which are found to be " +"unusually larger than the frame tend to be misaligned images except in " +"extreme close-ups. These can be usually be safely discarded." +msgstr "" +"Отфильтровывает лица, превышающие этот размер. Это множитель минимального " +"размера кадра (т.е. 1280x720 = 720). Если исходный блок извлечения лица " +"больше, чем минимальный размер кадра, умноженный на этот множитель, он " +"считается ложным срабатыванием и отбрасывается. Лица, размер которых " +"необычно превышает размер кадра, как правило, являются несогласованными " +"изображениями, за исключением экстремальных крупных планов. Обычно их можно " +"смело отбрасывать." + +#: plugins/extract/_config.py:79 +msgid "" +"Filters out faces who's landmarks are above this distance from an 'average' " +"face. Values above 15 tend to be fairly safe. Values above 10 will remove " +"more false positives, but may also filter out some faces at extreme angles." +msgstr "" +"Отфильтровывает лица, ориентиры которых находятся на расстоянии, превышающем " +"это расстояние от 'среднего' лица. Значения выше 15, как правило, достаточно " +"безопасны. Значения выше 10 устраняют больше ложных срабатываний, но также " +"могут отфильтровать некоторые лица под экстремальными углами." + +#: plugins/extract/_config.py:90 +msgid "" +"Filters out faces who's calculated roll is greater than zero +/- this value " +"in degrees. Aligned faces should have a roll value close to zero. Values " +"that are a significant distance from 0 degrees tend to be misaligned images. " +"These can usually be safely disgarded." +msgstr "" +"Отфильтровывает лица, у которых расчетный угол наклона больше нуля +/- это " +"значение в градусах. Выровненные лица должны иметь значение угла наклона, " +"близкое к нулю. Значения, которые значительно удалены от 0 градусов, как " +"правило, представляют собой неправильно выровненные изображения. Обычно их " +"можно смело отбрасывать." + +#: plugins/extract/_config.py:100 +msgid "" +"Filters out faces where the lowest point of the aligned face's eye or " +"eyebrow is lower than the highest point of the aligned face's mouth. Any " +"faces where this occurs are misaligned and can be safely disgarded." +msgstr "" +"Отфильтровывает лица, у которых нижняя точка глаза или брови выровненного " +"лица находится ниже, чем верхняя точка рта выровненного лица. Все лица, на " +"которых это происходит, являются неправильно выровненными и могут быть смело " +"отброшены." + +#: plugins/extract/_config.py:109 +msgid "" +"If enabled, and 're-feed' has been selected for extraction, then interim " +"alignments will be filtered prior to averaging the final landmarks. This can " +"help improve the final alignments by removing any obvious misaligns from the " +"interim results, and may also help pick up difficult alignments. If " +"disabled, then all re-feed results will be averaged." +msgstr "" +"Если эта функция включена, и для извлечения выбрана 'повторная подача'('re-" +"feed'), то промежуточные выравнивания будут отфильтрованы перед усреднением " +"окончательных ориентиров. Это может помочь улучшить окончательное " +"выравнивание, удалив любые очевидные несоответствия из промежуточных " +"результатов, а также может помочь выявить сложные выравнивания. Если эта " +"функция отключена, то все результаты повторной подачи будут усреднены." + +#: plugins/extract/_config.py:120 +msgid "" +"If enabled, saves any filtered out images into a sub-folder during the " +"extraction process. If disabled, filtered faces are deleted. Note: The faces " +"will always be filtered out of the alignments file, regardless of whether " +"you keep the faces or not." +msgstr "" +"Если включена, то в процессе извлечения отфильтрованные изображения " +"сохраняются в подпапке. Если отключено, отфильтрованные лица удаляются. " +"Примечание: Лица всегда будут отфильтрованы из файла выравнивания, " +"независимо от того, сохраняете вы эти лица или нет." + +#: plugins/extract/_config.py:129 plugins/extract/_config.py:138 +msgid "re-align" +msgstr "повторное выравнивание" + +#: plugins/extract/_config.py:130 +msgid "" +"If enabled, and 're-align' has been selected for extraction, then all re-" +"feed iterations are re-aligned. If disabled, then only the final averaged " +"output from re-feed will be re-aligned." +msgstr "" +"Если включено, и для извлечения выбрано 'повторное выравнивание'('re-" +"align'), то все итерации повторной подачи выравниваются повторно. Если " +"отключено, то выравнивается только конечный усредненный результат повторной " +"подачи." + +#: plugins/extract/_config.py:139 +msgid "" +"If enabled, and 're-align' has been selected for extraction, then any " +"alignments which would be filtered out will not be re-aligned." +msgstr "" +"Если эта функция включена, и для извлечения выбрано 'повторное " +"выравнивание'('re-align'), то все выравнивания, которые будут отфильтрованы, " +"не будут повторно выравниваться." diff --git a/locales/ru/LC_MESSAGES/plugins.train._config.mo b/locales/ru/LC_MESSAGES/plugins.train._config.mo new file mode 100644 index 0000000000000000000000000000000000000000..d739c561287f94b873aa91f69395b97768acf0db GIT binary patch literal 53511 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, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-06-11 23:20+0100\n" +"PO-Revision-Date: 2023-06-12 21:23+0700\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ru_RU\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.3.1\n" + +#: plugins/train/_config.py:17 +msgid "" +"\n" +"NB: Unless specifically stated, values changed here will only take effect " +"when creating a new model." +msgstr "" +"\n" +"Примечание: До тех пор, пока об этом не сказано, значения, измененные здесь, " +"будут применены при создании новой модели." + +#: plugins/train/_config.py:22 +msgid "" +"Focal Frequency Loss. Analyzes the frequency spectrum of the images rather " +"than the images themselves. This loss function can be used on its own, but " +"the original paper found increased benefits when using it as a complementary " +"loss to another spacial loss function (e.g. MSE). Ref: Focal Frequency Loss " +"for Image Reconstruction and Synthesis https://arxiv.org/pdf/2012.12821.pdf " +"NB: This loss does not currently work on AMD cards." +msgstr "" +"Потеря фокальной частоты. Анализирует частотный спектр изображений, а не " +"сами изображения. Эта функция потерь может использоваться сама по себе, но в " +"оригинальной статье было обнаружено, что она дает больше преимуществ при " +"использовании в качестве дополнительной потери к другой пространственной " +"функции потерь (например, MSE). Ссылка: Focal Frequency Loss for Image " +"Reconstruction and Synthesis [ТОЛЬКО на английском] https://arxiv.org/" +"pdf/2012.12821.pdf NB: Эта потеря в настоящее время не работает на картах " +"AMD." + +#: plugins/train/_config.py:29 +msgid "" +"Nvidia FLIP. A perceptual loss measure that approximates the difference " +"perceived by humans as they alternate quickly (or flip) between two images. " +"Used on its own and this loss function creates a distinct grid on the " +"output. However it can be helpful when used as a complimentary loss " +"function. Ref: FLIP: A Difference Evaluator for Alternating Images: https://" +"research.nvidia.com/sites/default/files/node/3260/FLIP_Paper.pdf" +msgstr "" +"Nvidia FLIP. Мера потерь восприятия, которая приближает разницу, " +"воспринимаемую человеком при быстром чередовании (или перелистывании) двух " +"изображений. Используемая сама по себе, эта функция потерь создает на выходе " +"отчетливую сетку. Однако она может быть полезна при использовании в качестве " +"дополнительной функции потерь. Ссылка: FLIP: A Difference Evaluator for " +"Alternating Images [ТОЛЬКО на английском]: https://research.nvidia.com/sites/" +"default/files/node/3260/FLIP_Paper.pdf" + +#: plugins/train/_config.py:36 +msgid "" +"Gradient Magnitude Similarity Deviation seeks to match the global standard " +"deviation of the pixel to pixel differences between two images. Similar in " +"approach to SSIM. Ref: Gradient Magnitude Similarity Deviation: An Highly " +"Efficient Perceptual Image Quality Index https://arxiv.org/ftp/arxiv/" +"papers/1308/1308.3052.pdf" +msgstr "" +"Отклонение Схожести Магнитуды Градиентов(Gradient Magnitude Similarity " +"Deviation) пытается совместить глобальную стандартную девиацию различий " +"пикселя к пикселю между двумя изображениями. Подход похож на SSIM. Ссылка: " +"Gradient Magnitude Similarity Deviation: An Highly Efficient Perceptual " +"Image Quality Index [ТОЛЬКО на английском] https://arxiv.org/ftp/arxiv/" +"papers/1308/1308.3052.pdf" + +#: plugins/train/_config.py:41 +msgid "" +"The L_inf norm will reduce the largest individual pixel error in an image. " +"As each largest error is minimized sequentially, the overall error is " +"improved. This loss will be extremely focused on outliers." +msgstr "" +"Норма L_inf уменьшает наибольшую ошибку отдельного пикселя в изображении. По " +"мере последовательной минимизации каждой наибольшей ошибки улучшается общая " +"ошибка. Эта потеря будет чрезвычайно сосредоточена на выбросах." + +#: plugins/train/_config.py:45 +msgid "" +"Laplacian Pyramid Loss. Attempts to improve results by focussing on edges " +"using Laplacian Pyramids. As this loss function gives priority to edges over " +"other low-frequency information, like color, it should not be used on its " +"own. The original implementation uses this loss as a complimentary function " +"to MSE. Ref: Optimizing the Latent Space of Generative Networks https://" +"arxiv.org/abs/1707.05776" +msgstr "" +"Потеря пирамиды Лапласиана. Пытается улучшить результаты, концентрируясь на " +"краях с помощью пирамид Лапласиана. Поскольку эта функция потерь отдает " +"приоритет краям, а не другой низкочастотной информации, например, цвету, ее " +"не следует использовать самостоятельно. В оригинальной реализации эта потеря " +"используется как дополнительная функция к MSE. Ссылка: Optimizing the Latent " +"Space of Generative Networks [ТОЛЬКО на английском] https://arxiv.org/" +"abs/1707.05776" + +#: plugins/train/_config.py:52 +msgid "" +"LPIPS is a perceptual loss that uses the feature outputs of other pretrained " +"models as a loss metric. Be aware that this loss function will use more " +"VRAM. Used on its own and this loss will create a distinct moire pattern on " +"the output, however it can be helpful as a complimentary loss function. The " +"output of this function is strong, so depending on your chosen primary loss " +"function, you are unlikely going to want to set the weight above about 25%. " +"Ref: The Unreasonable Effectiveness of Deep Features as a Perceptual Metric " +"http://arxiv.org/abs/1801.03924\n" +"This variant uses the AlexNet backbone. A fairly light and old model which " +"performed best in the paper's original implementation.\n" +"NB: For AMD Users the final linear layer is not implemented." +msgstr "" +"LPIPS - это перцептивная потеря, которая использует в качестве метрики " +"потерь выходные характеристики других предварительно обученных моделей. " +"Имейте в виду, что эта функция потерь использует больше VRAM. При " +"самостоятельном использовании эта потеря создает на выходе отчетливый " +"муаровый рисунок, однако она может быть полезна как дополнительная функция " +"потерь. Вывод этой функции является сильным, поэтому, в зависимости от " +"выбранной вами основной функции потерь, вы вряд ли захотите устанавливать " +"вес выше 25%. Ссылка: The Unreasonable Effectiveness of Deep Features as a " +"Perceptual Metric [ТОЛЬКО на английском] http://arxiv.org/abs/1801.03924.\n" +"Этот вариант использует основу AlexNet. Это довольно легкая и старая модель, " +"которая лучше всего показала себя в оригинальной реализации.\n" +"NB: Для пользователей AMD последний линейный слой не реализован." + +#: plugins/train/_config.py:62 +msgid "" +"Same as lpips_alex, but using the SqueezeNet backbone. A more lightweight " +"version of AlexNet.\n" +"NB: For AMD Users the final linear layer is not implemented." +msgstr "" +"То же, что и lpips_alex, но использует основу SqueezeNet. Более облегченная " +"версия AlexNet.\n" +"NB: Для пользователей AMD последний линейный слой не реализован." + +#: plugins/train/_config.py:65 +msgid "" +"Same as lpips_alex, but using the VGG16 backbone. A more heavyweight model.\n" +"NB: For AMD Users the final linear layer is not implemented." +msgstr "" +"То же, что и lpips_alex, но использует основу VGG16. Более тяжелая модель.\n" +"NB: Для пользователей AMD последний линейный слой не реализован." + +#: plugins/train/_config.py:68 +msgid "" +"log(cosh(x)) acts similar to MSE for small errors and to MAE for large " +"errors. Like MSE, it is very stable and prevents overshoots when errors are " +"near zero. Like MAE, it is robust to outliers." +msgstr "" +"log(cosh(x)) действует аналогично MSE для малых ошибок и MAE для больших " +"ошибок. Как и MSE, он очень стабилен и предотвращает переборы, когда ошибки " +"близки к нулю. Как и MAE, он устойчив к выбросам." + +#: plugins/train/_config.py:72 +msgid "" +"Mean absolute error will guide reconstructions of each pixel towards its " +"median value in the training dataset. Robust to outliers but as a median, it " +"can potentially ignore some infrequent image types in the dataset." +msgstr "" +"Средняя абсолютная погрешность направляет реконструкцию каждого пикселя к " +"его медианному значению в обучающем наборе данных. Устойчив к выбросам, но в " +"качестве медианы может игнорировать некоторые редкие типы изображений в " +"наборе данных." + +#: plugins/train/_config.py:76 +msgid "" +"Mean squared error will guide reconstructions of each pixel towards its " +"average value in the training dataset. As an avg, it will be susceptible to " +"outliers and typically produces slightly blurrier results. Ref: Multi-Scale " +"Structural Similarity for Image Quality Assessment https://www.cns.nyu.edu/" +"pub/eero/wang03b.pdf" +msgstr "" +"Средняя квадратичная погрешность направляет реконструкцию каждого пикселя к " +"его среднему значению в наборе данных для обучения. Как среднее значение, " +"оно будет чувствительно к выбросам и обычно дает немного более размытые " +"результаты. Ссылка: Multi-Scale Structural Similarity for Image Quality " +"Assessment [ТОЛЬКО на английском]https://www.cns.nyu.edu/pub/eero/wang03b.pdf" + +#: plugins/train/_config.py:81 +msgid "" +"Multiscale Structural Similarity Index Metric is similar to SSIM except that " +"it performs the calculations along multiple scales of the input image." +msgstr "" +"Метрика Индекса Многомасштабного Структурного Сходства (Multiscale " +"Structural Similarity Index Metric) похожа на SSIM, за исключением того, что " +"она выполняет вычисления по нескольким масштабам входного изображения." + +#: plugins/train/_config.py:84 +msgid "" +"Smooth_L1 is a modification of the MAE loss to correct two of its " +"disadvantages. This loss has improved stability and guidance for small " +"errors. Ref: A General and Adaptive Robust Loss Function https://arxiv.org/" +"pdf/1701.03077.pdf" +msgstr "" +"Smooth_L1 - это модификация потери MAE для исправления двух ее недостатков. " +"Эта потеря улучшает стабильность и ориентирование при небольших " +"погрешностях. Ссылка: A General and Adaptive Robust Loss Function [ТОЛЬКО на " +"английском] https://arxiv.org/pdf/1701.03077.pdf" + +#: plugins/train/_config.py:88 +msgid "" +"Structural Similarity Index Metric is a perception-based loss that considers " +"changes in texture, luminance, contrast, and local spatial statistics of an " +"image. Potentially delivers more realistic looking images. Ref: Image " +"Quality Assessment: From Error Visibility to Structural Similarity http://" +"www.cns.nyu.edu/pub/eero/wang03-reprint.pdf" +msgstr "" +"Метрика индекса структурного сходства ('Structural Similarity Index Metric') " +"- это основанная на восприятии потеря, которая учитывает изменения в " +"текстуре, яркости, контрасте и локальной пространственной статистике " +"изображения. Потенциально обеспечивает более реалистичный вид изображений. " +"Ссылка: Image Quality Assessment: From Error Visibility to Structural " +"Similarity [ТОЛЬКО на английском] http://www.cns.nyu.edu/pub/eero/wang03-" +"reprint.pdf" + +#: plugins/train/_config.py:93 +msgid "" +"Instead of minimizing the difference between the absolute value of each " +"pixel in two reference images, compute the pixel to pixel spatial difference " +"in each image and then minimize that difference between two images. Allows " +"for large color shifts, but maintains the structure of the image." +msgstr "" +"Вместо того чтобы минимизировать разницу между абсолютным значением каждого " +"пикселя в двух образцовых изображениях, вычислить пространственную разницу " +"между пикселями в каждом изображении и затем минимизировать эту разницу " +"между двумя изображениями. Это позволяет получить большие цветовые сдвиги, " +"но сохраняет структуру изображения." + +#: plugins/train/_config.py:97 +msgid "Do not use an additional loss function." +msgstr "Не использовать функцию дополнительных потерь." + +#: plugins/train/_config.py:117 +msgid "Options that apply to all models" +msgstr "Настройки, применимые ко всем моделям" + +#: plugins/train/_config.py:126 plugins/train/_config.py:150 +msgid "face" +msgstr "лицо" + +#: plugins/train/_config.py:128 +msgid "" +"How to center the training image. The extracted images are centered on the " +"middle of the skull based on the face's estimated pose. A subsection of " +"these images are used for training. The centering used dictates how this " +"subsection will be cropped from the aligned images.\n" +"\tface: Centers the training image on the center of the face, adjusting for " +"pitch and yaw.\n" +"\thead: Centers the training image on the center of the head, adjusting for " +"pitch and yaw. NB: You should only select head centering if you intend to " +"include the full head (including hair) in the final swap. This may give " +"mixed results. Additionally, it is only worth choosing head centering if you " +"are training with a mask that includes the hair (e.g. BiSeNet-FP-Head).\n" +"\tlegacy: The 'original' extraction technique. Centers the training image " +"near the tip of the nose with no adjustment. Can result in the edges of the " +"face appearing outside of the training area." +msgstr "" +"Как централизовывать тренировочное изображение. Центр в извлеченных " +"изображениях находится в середине черепа, основанный на примерной позе лица. " +"Подсекция этих изображений используется для тренировки. Используемый центр " +"диктует то, как эта подсекция будет обрезана из выравненных изображений.\n" +"\tface: Центрирует учебное изображение по центру лица, регулируя угол " +"наклона и поворота.\n" +"\thead: Централизует тренировочное изображение в центре головы, регулируя " +"угол наклона и поворота. Примечание: Следует выбирать централизацию головы, " +"если вы планируете включать голову полностью (включая волосы) в финальную " +"замену. Может дать смешанные результаты. В дополнении, оно стоит того только " +"если вы тренируете с маской, что включает в себя волосы (к примеру: BiSeNet-" +"FP-Head).\n" +"\tlegacy: 'оригинальная' техника извлечения. Централизует тренировочное " +"изображение ближе к кончику носа без правок. Может привести к тому, что края " +"лица будут вне тренировочной зоны." + +#: plugins/train/_config.py:152 +msgid "" +"How much of the extracted image to train on. A lower coverage will limit the " +"model's scope to a zoomed-in central area while higher amounts can include " +"the entire face. A trade-off exists between lower amounts given more detail " +"versus higher amounts avoiding noticeable swap transitions. For 'Face' " +"centering you will want to leave this above 75%. For Head centering you will " +"most likely want to set this to 100%. Sensible values for 'Legacy' centering " +"are:\n" +"\t62.5% spans from eyebrow to eyebrow.\n" +"\t75.0% spans from temple to temple.\n" +"\t87.5% spans from ear to ear.\n" +"\t100.0% is a mugshot." +msgstr "" +"Сколько извлеченного изображения тренировать. Низкая покрытость ограничит " +"прицел модели к приближенной центральной зоне, в то время как большие " +"значения могут включать в себя целое лицо. Существует компромисс между " +"меньшими объемами, дающими больше деталей, и большими объемами, позволяющими " +"избежать заметных переходов замены. Для централизации 'Face', вам нужно " +"будет оставить значение выше 75%. Для централизации 'Head', вам скорее всего " +"нужно будет поставить значение 100%. Адекватные значения для 'Legacy':\n" +"\t62.5% охватывает от бровей до бровей.\n" +"\t75% охватывает от виска до виска.\n" +"\t87.5% охватывает от уха до уха.\n" +"\t100% - полный снимок." + +#: plugins/train/_config.py:168 plugins/train/_config.py:179 +msgid "initialization" +msgstr "инициализация" + +#: plugins/train/_config.py:170 +msgid "" +"Use ICNR to tile the default initializer in a repeating pattern. This " +"strategy is designed for pairing with sub-pixel / pixel shuffler to reduce " +"the 'checkerboard effect' in image reconstruction. \n" +"\t https://arxiv.org/ftp/arxiv/papers/1707/1707.02937.pdf" +msgstr "" +"Использовать ICNR для чередования инициализатора по умолчанию в " +"повторяющемся шаблоне. Эта стратегия предназначена для использования в паре " +"с субпиксельным/пиксельным перетасовщиком для уменьшения \"эффекта шахматной " +"доски\" при реконструкции изображения. \n" +"\t [ТОЛЬКО на английском] https://arxiv.org/ftp/arxiv/papers/1707/1707.02937." +"pdf" + +#: plugins/train/_config.py:181 +msgid "" +"Use Convolution Aware Initialization for convolutional layers. This can help " +"eradicate the vanishing and exploding gradient problem as well as lead to " +"higher accuracy, lower loss and faster convergence.\n" +"NB:\n" +"\t This can use more VRAM when creating a new model so you may want to lower " +"the batch size for the first run. The batch size can be raised again when " +"reloading the model. \n" +"\t Multi-GPU is not supported for this option, so you should start the model " +"on a single GPU. Once training has started, you can stop training, enable " +"multi-GPU and resume.\n" +"\t Building the model will likely take several minutes as the calculations " +"for this initialization technique are expensive. This will only impact " +"starting a new model." +msgstr "" +"Использовать инициализацию с учетом свертки для сверточных слоев. Это " +"поможет устранить проблему исчезающего и взрывающегося градиента, а также " +"повысить точность, снизить потери и ускорить сходимость.\n" +"Примечание:\n" +"\tПри создании новой модели может потребоваться больше видеопамяти, поэтому " +"для первого запуска лучше уменьшить размер пачки. Размер пачки может быть " +"увеличен при перезагрузке модели. \n" +"\tИспользование нескольких видеокарт не поддерживается, поэтому модель " +"следует запускать на одной видеокарте. После начала обучения вы можете " +"остановить обучение, включить несколько видеокарт и возобновить его.\n" +"\t Построение модели, скорее всего, займет несколько минут, поскольку " +"вычисления для этой техники инициализации являются дорогостоящими. Это " +"повлияет только на запуск новой модели." + +#: plugins/train/_config.py:198 plugins/train/_config.py:223 +#: plugins/train/_config.py:238 plugins/train/_config.py:265 +msgid "optimizer" +msgstr "оптимизатор" + +#: plugins/train/_config.py:202 +msgid "" +"The optimizer to use.\n" +"\t adabelief - Adapting Stepsizes by the Belief in Observed Gradients. An " +"optimizer with the aim to converge faster, generalize better and remain more " +"stable. (https://arxiv.org/abs/2010.07468). NB: Epsilon for AdaBelief needs " +"to be set to a smaller value than other Optimizers. Generally setting the " +"'Epsilon Exponent' to around '-16' should work.\n" +"\t adam - Adaptive Moment Optimization. A stochastic gradient descent method " +"that is based on adaptive estimation of first-order and second-order " +"moments.\n" +"\t nadam - Adaptive Moment Optimization with Nesterov Momentum. Much like " +"Adam but uses a different formula for calculating momentum.\n" +"\t rms-prop - Root Mean Square Propagation. Maintains a moving (discounted) " +"average of the square of the gradients. Divides the gradient by the root of " +"this average." +msgstr "" +"Используемый оптимизатор.\n" +"\t adabelief - Адаптация размеров шагов по убеждению в наблюдаемых " +"градиентах('Adapting Stepsizes by the Belief in Observed Gradients'). " +"Оптимизатор, цель которого - быстрее сходиться, лучше обобщаться и " +"оставаться более стабильным. ([ТОЛЬКО на английском] https://arxiv.org/" +"abs/2010.07468). Примечание: значение Epsilon для AdaBelief должно быть " +"меньше, чем для других оптимизаторов. Как правило, значение 'Epsilon " +"Exponent' должно быть около '-16'.\n" +"\t adam - Адаптивная оптимизация моментов('Adaptive Moment Optimization'). " +"Стохастический метод градиентного спуска, основанный на адаптивной оценке " +"моментов первого и второго порядка.\n" +"\t nadam - Адаптивная оптимизация моментов с моментумом Нестерова ('Adaptive " +"Moment Optimization with Nesterov Momentum'). Похож на Adam, но использует " +"другую формулу для вычисления момента.\n" +"rms-prop - Распространение корневого среднего квадрата ('Root Mean Square " +"Propagation'). Поддерживает скользящее (дисконтированное) среднее квадрата " +"градиентов. Делит градиент на корень из этого среднего." + +#: plugins/train/_config.py:225 +msgid "" +"Learning rate - how fast your network will learn (how large are the " +"modifications to the model weights after one batch of training). Values that " +"are too large might result in model crashes and the inability of the model " +"to find the best solution. Values that are too small might be unable to " +"escape from dead-ends and find the best global minimum." +msgstr "" +"Скорость обучения - насколько быстро ваша модель будет обучаться (насколько " +"огромны изменения весов модели после одной пачки тренировки). Слишком " +"большие значения могут привести к крахам модели и невозможности модели найти " +"лучшее решение. Слишком маленькие значения могут привести к невозможности " +"выбраться из тупиков и найти лучший глобальный минимум." + +#: plugins/train/_config.py:240 +msgid "" +"The epsilon adds a small constant to weight updates to attempt to avoid " +"'divide by zero' errors. Unless you are using the AdaBelief Optimizer, then " +"Generally this option should be left at default value, For AdaBelief, " +"setting this to around '-16' should work.\n" +"In all instances if you are getting 'NaN' loss values, and have been unable " +"to resolve the issue any other way (for example, increasing batch size, or " +"lowering learning rate), then raising the epsilon can lead to a more stable " +"model. It may, however, come at the cost of slower training and a less " +"accurate final result.\n" +"NB: The value given here is the 'exponent' to the epsilon. For example, " +"choosing '-7' will set the epsilon to 1e-7. Choosing '-3' will set the " +"epsilon to 0.001 (1e-3)." +msgstr "" +"Эпсилон добавляет небольшую константу к обновлениям веса, чтобы попытаться " +"избежать ошибок \"деления на ноль\". Если вы не используете оптимизатор " +"AdaBelief, то, как правило, этот параметр следует оставить по умолчанию. Для " +"AdaBelief подойдет значение около '-16'.\n" +"Во всех случаях, если вы получаете значения потерь 'NaN' и не смогли решить " +"проблему другим способом (например, увеличив размер пачки или уменьшив " +"скорость обучения), то увеличение эпсилона может привести к более стабильной " +"модели. Однако это может стоить более медленного обучения и менее точного " +"конечного результата.\n" +"Примечание: Значение, указанное здесь, является \"экспонентой\" к эпсилону. " +"Например, при выборе значения '-7' эпсилон будет равен 1e-7. При выборе " +"значения \"-3\" эпсилон будет равен 0,001 (1e-3)." + +#: plugins/train/_config.py:258 +msgid "" +"[Not PlaidML] Apply AutoClipping to the gradients. AutoClip analyzes the " +"gradient weights and adjusts the normalization value dynamically to fit the " +"data. Can help prevent NaNs and improve model optimization at the expense of " +"VRAM. Ref: AutoClip: Adaptive Gradient Clipping for Source Separation " +"Networks https://arxiv.org/abs/2007.14469" +msgstr "" +"[Не для PlaidML] Применить AutoClipping к градиентам. AutoClip анализирует " +"веса градиентов и динамически корректирует значение нормализации, чтобы оно " +"подходило к данным. Может помочь избежать NaN('не число') и улучшить " +"оптимизацию модели ценой видеопамяти. Ссылка: AutoClip: Adaptive Gradient " +"Clipping for Source Separation Networks [ТОЛЬКО на английском] https://arxiv." +"org/abs/2007.14469" + +#: plugins/train/_config.py:271 plugins/train/_config.py:283 +#: plugins/train/_config.py:297 plugins/train/_config.py:314 +msgid "network" +msgstr "сеть" + +#: plugins/train/_config.py:273 +msgid "" +"Use reflection padding rather than zero padding with convolutions. Each " +"convolution must pad the image boundaries to maintain the proper sizing. " +"More complex padding schemes can reduce artifacts at the border of the " +"image.\n" +"\t http://www-cs.engr.ccny.cuny.edu/~wolberg/cs470/hw/hw2_pad.txt" +msgstr "" +"Используйте для сверток не нулевую, а отражающую подкладку. Каждая свертка " +"должна заполнять границы изображения для поддержания правильного размера. " +"Более сложные схемы вставки могут уменьшить артефакты на границе " +"изображения.\n" +"\t http://www-cs.engr.ccny.cuny.edu/~wolberg/cs470/hw/hw2_pad.txt" + +#: plugins/train/_config.py:286 +msgid "" +"[Nvidia Only]. Enable the Tensorflow GPU 'allow_growth' configuration " +"option. This option prevents Tensorflow from allocating all of the GPU VRAM " +"at launch but can lead to higher VRAM fragmentation and slower performance. " +"Should only be enabled if you are receiving errors regarding 'cuDNN fails to " +"initialize' when commencing training." +msgstr "" +"[Только для Nvidia]. Включите опцию конфигурации Tensorflow GPU " +"`allow_growth`. Эта опция не позволяет Tensorflow выделять всю видеопамять " +"видеокарты при запуске, но может привести к повышенной фрагментации " +"видеопамяти и снижению производительности. Следует включать только в том " +"случае, если у вас появляются ошибки, рода 'cuDNN fails to initialize'(cuDNN " +"не может инициализироваться) при начале тренировки." + +#: plugins/train/_config.py:299 +msgid "" +"[Not PlaidML], NVIDIA GPUs can run operations in float16 faster than in " +"float32. Mixed precision allows you to use a mix of float16 with float32, to " +"get the performance benefits from float16 and the numeric stability benefits " +"from float32.\n" +"\n" +"This is untested on DirectML backend, but will run on most Nvidia models. it " +"will only speed up training on more recent GPUs. Those with compute " +"capability 7.0 or higher will see the greatest performance benefit from " +"mixed precision because they have Tensor Cores. Older GPUs offer no math " +"performance benefit for using mixed precision, however memory and bandwidth " +"savings can enable some speedups. Generally RTX GPUs and later will offer " +"the most benefit." +msgstr "" +"[Не для PlaidML], Видеокарты от NVIDIA могут оперировать в 'float16' " +"быстрее, чем в 'float32'. Смешанная точность позволяет вам использовать микс " +"float16 с float32, чтобы получить улучшение производительности от float16 и " +"числовую стабильность от float32.\n" +"\n" +"Это не было проверено на DirectML, но будет работать на большенстве моделей " +"Nvidia. Оно только ускорит тренировку на более недавних видеокартах. Те, что " +"имеют возможность вычислений('Compute Capability') 7.0 и выше, получат самое " +"большое ускорение от смешанной точности, потому что у них имеются тензор " +"ядра. Старые видеокарты предлагают никакого ускорения от смешанной точности, " +"однако экономия памяти и (хз, честно, словаря нет) могут дать небольшое " +"ускорение. В основном RTX видеокарты и позже предлагают самое большое " +"ускорение." + +#: plugins/train/_config.py:316 +msgid "" +"If a 'NaN' is generated in the model, this means that the model has " +"corrupted and the model is likely to start deteriorating from this point on. " +"Enabling NaN protection will stop training immediately in the event of a " +"NaN. The last save will not contain the NaN, so you may still be able to " +"rescue your model." +msgstr "" +"Если 'Не число'(далее, NaN) сгенерировано в модели - это значит, что модель " +"повреждена и с этого момента, скорее всего, начнет деградировать. Включение " +"защиты от NaN немедленно остановит тренировку, в случае, если был обнаружен " +"NaN. Последнее сохранение не будет содержать в себе NaN, так что у вас будет " +"возможность спасти вашу модель." + +#: plugins/train/_config.py:329 +msgid "convert" +msgstr "конвертирование" + +#: plugins/train/_config.py:331 +msgid "" +"[GPU Only]. The number of faces to feed through the model at once when " +"running the Convert process.\n" +"\n" +"NB: Increasing this figure is unlikely to improve convert speed, however, if " +"you are getting Out of Memory errors, then you may want to reduce the batch " +"size." +msgstr "" +"[Только для видеокарт] Количество лиц, проходящих через модель в одно время " +"во время конвертирования\n" +"\n" +"Примечание: Увеличение этого значения вряд ли повлечет за собой ускорение " +"конвертирования, однако, если у вас появляются ошибки 'Out of Memory', тогда " +"стоит снизить размер пачки." + +#: plugins/train/_config.py:350 +msgid "" +"Loss configuration options\n" +"Loss is the mechanism by which a Neural Network judges how well it thinks " +"that it is recreating a face." +msgstr "" +"Настройки потерь\n" +"Потеря - механизм, по которому Нейронная Сеть судит, насколько хорошо она " +"воспроизводит лицо." + +#: plugins/train/_config.py:357 plugins/train/_config.py:369 +#: plugins/train/_config.py:382 plugins/train/_config.py:402 +#: plugins/train/_config.py:414 plugins/train/_config.py:434 +#: plugins/train/_config.py:446 plugins/train/_config.py:466 +#: plugins/train/_config.py:482 plugins/train/_config.py:498 +#: plugins/train/_config.py:515 +msgid "loss" +msgstr "потери" + +#: plugins/train/_config.py:361 +msgid "The loss function to use." +msgstr "Какую функцию потерь стоит использовать." + +#: plugins/train/_config.py:373 +msgid "" +"The second loss function to use. If using a structural based loss (such as " +"SSIM, MS-SSIM or GMSD) it is common to add an L1 regularization(MAE) or L2 " +"regularization (MSE) function. You can adjust the weighting of this loss " +"function with the loss_weight_2 option." +msgstr "" +"Вторая используемая функция потерь. При использовании потерь, основанных на " +"структуре (таких как SSIM, MS-SSIM или GMSD), обычно добавляется функция " +"регуляризации L1 (MAE) или регуляризации L2 (MSE). Вы можете настроить вес " +"этой функции потерь с помощью параметра loss_weight_2." + +#: plugins/train/_config.py:388 +msgid "" +"The amount of weight to apply to the second loss function.\n" +"\n" +"\n" +"\n" +"The value given here is as a percentage denoting how much the selected " +"function should contribute to the overall loss cost of the model. For " +"example:\n" +"\t 100 - The loss calculated for the second loss function will be applied at " +"its full amount towards the overall loss score. \n" +"\t 25 - The loss calculated for the second loss function will be reduced by " +"a quarter prior to adding to the overall loss score. \n" +"\t 400 - The loss calculated for the second loss function will be mulitplied " +"4 times prior to adding to the overall loss score. \n" +"\t 0 - Disables the second loss function altogether." +msgstr "" +"Величина веса, применяемая ко второй функции потерь.\n" +"\n" +"\n" +"\n" +"Значение задается в процентах и показывает, какой вклад выбранная функция " +"должна внести в общую стоимость потерь модели. Например:\n" +"\t 100 - Потери, рассчитанные для четвертой функции потерь, будут применены " +"в полном объеме к общей стоимости потерь. \n" +"\t25 - Потери, рассчитанные для четвертой функции потерь, будут уменьшены на " +"четверть перед добавлением к общей стоимости потерь. \n" +"\t400 - Потери, рассчитанные для четвертой функции потерь, будут умножены в " +"4 раза перед добавлением к общей оценке потерь. \n" +"\t 0 - Полностью отключает четвертую функцию потерь." + +#: plugins/train/_config.py:406 +msgid "" +"The third loss function to use. You can adjust the weighting of this loss " +"function with the loss_weight_3 option." +msgstr "" +"Третья используемая функция потерь. Вы можете настроить вес этой функции " +"потерь с помощью параметра loss_weight_3." + +#: plugins/train/_config.py:420 +msgid "" +"The amount of weight to apply to the third loss function.\n" +"\n" +"\n" +"\n" +"The value given here is as a percentage denoting how much the selected " +"function should contribute to the overall loss cost of the model. For " +"example:\n" +"\t 100 - The loss calculated for the third loss function will be applied at " +"its full amount towards the overall loss score. \n" +"\t 25 - The loss calculated for the third loss function will be reduced by a " +"quarter prior to adding to the overall loss score. \n" +"\t 400 - The loss calculated for the third loss function will be mulitplied " +"4 times prior to adding to the overall loss score. \n" +"\t 0 - Disables the third loss function altogether." +msgstr "" +"Величина веса, применяемая к третьей функции потерь.\n" +"\n" +"\n" +"\n" +"Значение задается в процентах и показывает, какой вклад выбранная функция " +"должна внести в общую стоимость потерь модели. Например:\n" +"\t 100 - Потери, рассчитанные для четвертой функции потерь, будут применены " +"в полном объеме к общей стоимости потерь. \n" +"\t25 - Потери, рассчитанные для четвертой функции потерь, будут уменьшены на " +"четверть перед добавлением к общей стоимости потерь. \n" +"\t400 - Потери, рассчитанные для четвертой функции потерь, будут умножены в " +"4 раза перед добавлением к общей оценке потерь. \n" +"\t 0 - Полностью отключает четвертую функцию потерь." + +#: plugins/train/_config.py:438 +msgid "" +"The fourth loss function to use. You can adjust the weighting of this loss " +"function with the loss_weight_3 option." +msgstr "" +"Четвертая используемая функция потерь. Вы можете настроить вес этой функции " +"потерь с помощью параметра 'loss_weight_4'." + +#: plugins/train/_config.py:452 +msgid "" +"The amount of weight to apply to the fourth loss function.\n" +"\n" +"\n" +"\n" +"The value given here is as a percentage denoting how much the selected " +"function should contribute to the overall loss cost of the model. For " +"example:\n" +"\t 100 - The loss calculated for the fourth loss function will be applied at " +"its full amount towards the overall loss score. \n" +"\t 25 - The loss calculated for the fourth loss function will be reduced by " +"a quarter prior to adding to the overall loss score. \n" +"\t 400 - The loss calculated for the fourth loss function will be mulitplied " +"4 times prior to adding to the overall loss score. \n" +"\t 0 - Disables the fourth loss function altogether." +msgstr "" +"Величина веса, применяемая к четвертой функции потерь.\n" +"\n" +"\n" +"\n" +"Значение задается в процентах и показывает, какой вклад выбранная функция " +"должна внести в общую стоимость потерь модели. Например:\n" +"\t 100 - Потери, рассчитанные для четвертой функции потерь, будут применены " +"в полном объеме к общей стоимости потерь. \n" +"\t25 - Потери, рассчитанные для четвертой функции потерь, будут уменьшены на " +"четверть перед добавлением к общей стоимости потерь. \n" +"\t400 - Потери, рассчитанные для четвертой функции потерь, будут умножены в " +"4 раза перед добавлением к общей оценке потерь. \n" +"\t 0 - Полностью отключает четвертую функцию потерь." + +#: plugins/train/_config.py:471 +msgid "" +"The loss function to use when learning a mask.\n" +"\t MAE - Mean absolute error will guide reconstructions of each pixel " +"towards its median value in the training dataset. Robust to outliers but as " +"a median, it can potentially ignore some infrequent image types in the " +"dataset.\n" +"\t MSE - Mean squared error will guide reconstructions of each pixel towards " +"its average value in the training dataset. As an average, it will be " +"susceptible to outliers and typically produces slightly blurrier results." +msgstr "" +"Функция потерь, используемая при обучении маски.\n" +"\tMAE - средняя абсолютная погрешность('Mean absolute error') направляет " +"реконструкцию каждого пикселя к его срединному значению в обучающем наборе " +"данных. Устойчива к выбросам, но как медиана может игнорировать некоторые " +"редкие типы изображений в наборе данных.\n" +"\tMSE - средняя квадратичная погрешность('Mean squared error') направляет " +"реконструкцию каждого пикселя к его срединному значению в обучающем наборе " +"данных. Как среднее значение, оно чувствительно к выбросам и обычно дает " +"немного более размытые результаты." + +#: plugins/train/_config.py:488 +msgid "" +"The amount of priority to give to the eyes.\n" +"\n" +"The value given here is as a multiplier of the main loss score. For " +"example:\n" +"\t 1 - The eyes will receive the same priority as the rest of the face. \n" +"\t 10 - The eyes will be given a score 10 times higher than the rest of the " +"face.\n" +"\n" +"NB: Penalized Mask Loss must be enable to use this option." +msgstr "" +"Величина приоритета, которую следует придать глазам.\n" +"\n" +"Значение дается как множитель основного показателя потерь. Например:\n" +"\t 1 - Глаза получат тот же приоритет, что и остальное лицо. \n" +"\t 10 - глаза получат оценку в 10 раз выше, чем остальные части лица.\n" +"\n" +"NB: Penalized Mask Loss должен быть включен, чтобы использовать эту опцию." + +#: plugins/train/_config.py:504 +msgid "" +"The amount of priority to give to the mouth.\n" +"\n" +"The value given here is as a multiplier of the main loss score. For " +"Example:\n" +"\t 1 - The mouth will receive the same priority as the rest of the face. \n" +"\t 10 - The mouth will be given a score 10 times higher than the rest of the " +"face.\n" +"\n" +"NB: Penalized Mask Loss must be enable to use this option." +msgstr "" +"Величина приоритета, которую следует придать рту.\n" +"\n" +"Значение дается как множитель основного показателя потерь. Например:\n" +"\t 1 - Рот получит тот же приоритет, что и остальное лицо. \n" +"\t 10 - Рот получит оценку в 10 раз выше, чем остальные части лица.\n" +"\n" +"NB: Penalized Mask Loss должен быть включен, чтобы использовать эту опцию." + +#: plugins/train/_config.py:517 +msgid "" +"Image loss function is weighted by mask presence. For areas of the image " +"without the facial mask, reconstruction errors will be ignored while the " +"masked face area is prioritized. May increase overall quality by focusing " +"attention on the core face area." +msgstr "" +"Функция потерь изображения взвешивается по наличию маски. Для областей " +"изображения без маски лица погрешности реконструкции игнорируются, в то " +"время как область лица с маской является приоритетной. Может повысить общее " +"качество за счет концентрации внимания на основной области лица." + +#: plugins/train/_config.py:528 plugins/train/_config.py:570 +#: plugins/train/_config.py:584 plugins/train/_config.py:593 +msgid "mask" +msgstr "маска" + +#: plugins/train/_config.py:531 +msgid "" +"The mask to be used for training. If you have selected 'Learn Mask' or " +"'Penalized Mask Loss' you must select a value other than 'none'. The " +"required mask should have been selected as part of the Extract process. If " +"it does not exist in the alignments file then it will be generated prior to " +"training commencing.\n" +"\tnone: Don't use a mask.\n" +"\tbisenet-fp_face: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'face' or " +"'legacy' centering.\n" +"\tbisenet-fp_head: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'head' " +"centering.\n" +"\tcomponents: 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.\n" +"\tcustom_face: Custom user created, face centered mask.\n" +"\tcustom_head: Custom user created, head centered mask.\n" +"\textended: 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.\n" +"\tvgg-clear: Mask designed to provide smart segmentation of mostly frontal " +"faces clear of obstructions. Profile faces and obstructions may result in " +"sub-par performance.\n" +"\tvgg-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.\n" +"\tunet-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." +msgstr "" +"Маска, которая будет использоваться для обучения. Если вы выбрали 'Learn " +"Mask' или 'Penalized Mask Loss', вы должны выбрать значение, отличное от " +"'none'. Необходимая маска должна быть выбрана в процессе извлечения. Если " +"она не существует в файле выравниваний, то она будет создана до начала " +"обучения.\n" +"\tnone: Не использовать маску.\n" +"\tbisenet-fp_face: Относительно легкая маска на основе NN, которая " +"обеспечивает более точный контроль над маскируемой областью (настраивается в " +"настройках маски). Используйте эту версию bisenet-fp, если ваша модель " +"обучена с центрированием 'face' или 'legacy'.\n" +"\tbisenet-fp_head: Относительно легкая маска на основе NN, которая " +"обеспечивает более точный контроль над маскируемой областью (настраивается в " +"параметрах маски). Используйте эту версию bisenet-fp, если ваша модель " +"обучена с центрированием 'head'.\n" +"\tcomponents: Маска, разработанная для сегментации лица на основе " +"расположения ориентиров. Для создания маски вокруг внешних ориентиров " +"строится выпуклая оболочка.\n" +"\tcustom_face: Пользовательская маска, созданная пользователем и " +"центрированная по лицу.\n" +"\tcustom_head: Созданная пользователем маска, центрированная по голове.\n" +"\textended: Маска, разработанная для сегментации лица на основе расположения " +"ориентиров. Выпуклый корпус строится вокруг внешних ориентиров, и маска " +"расширяется вверх на лоб.\n" +"\tvgg-clear: Маска предназначена для интеллектуальной сегментации " +"преимущественно фронтальных лиц без препятствий. Профильные лица и " +"препятствия могут привести к снижению производительности.\n" +"\tvgg-obstructed: Маска, разработанная для интеллектуальной сегментации " +"преимущественно фронтальных лиц. Модель маски была специально обучена " +"распознавать некоторые препятствия на лице (руки и очки). Профильные лица " +"могут иметь низкую производительность.\n" +"\tunet-dfl: Маска, разработанная для интеллектуальной сегментации " +"преимущественно фронтальных лиц. Модель маски была обучена членами " +"сообщества и для дальнейшего описания нуждается в тестировании. Профильные " +"лица могут иметь низкую производительность." + +#: plugins/train/_config.py:572 +msgid "" +"Apply gaussian blur to the mask input. This has the effect of smoothing the " +"edges of the mask, which can help with poorly calculated masks and give less " +"of a hard edge to the predicted mask. The size is in pixels (calculated from " +"a 128px mask). Set to 0 to not apply gaussian blur. This value should be " +"odd, if an even number is passed in then it will be rounded to the next odd " +"number." +msgstr "" +"Применить размытие по Гауссу на входную маску. Дает эффект сглаживания краев " +"маски, что может помочь с плохо вычисленными масками и дает менее резкий " +"край предугаданной маске. Размер в пикселях (вычисленно из маски на 128 " +"пикселей). Установите 0, чтобы не применять размытие по Гауссу. Это значение " +"должно быть нечетным, если передано четное число, то оно будет округлено до " +"следующего нечетного числа." + +#: plugins/train/_config.py:586 +msgid "" +"Sets pixels that are near white to white and near black to black. Set to 0 " +"for off." +msgstr "" +"Устанавливает пиксели, которые почти белые - в белые и которые почти черные " +"- в черные. Установите 0, чтобы выключить." + +#: plugins/train/_config.py:595 +msgid "" +"Dedicate a portion of the model to learning how to duplicate the input mask. " +"Increases VRAM usage in exchange for learning a quick ability to try to " +"replicate more complex mask models." +msgstr "" +"Выделить частичку модели обучению тому, как дублировать входную маску. " +"Увеличивает использование видеопамяти в обмен на обучение быстрой " +"способности попытки переделывать более сложные маски." From 82e927d3e7ec58c2a102ee2883f4c5801bec41c6 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 18 Jun 2023 11:38:43 +0100 Subject: [PATCH 823/981] bugfix: Preview tool Don't error if too few images --- scripts/convert.py | 3 +-- tools/preview/preview.py | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/convert.py b/scripts/convert.py index 4956172517..9f07bfb02d 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -791,7 +791,7 @@ def _get_io_sizes(self) -> Dict[str, int]: input_shape = [input_shape] if not isinstance(input_shape, list) else input_shape output_shape = self._model.model.output_shape output_shape = [output_shape] if not isinstance(output_shape, list) else output_shape - retval = dict(input=input_shape[0][1], output=output_shape[-1][1]) + retval = {"input": input_shape[0][1], "output": output_shape[-1][1]} logger.debug(retval) return retval @@ -833,7 +833,6 @@ def _get_batchsize(self, queue_size: int) -> int: is_cpu = GPUStats().device_count == 0 batchsize = 1 if is_cpu else self._model.config["convert_batchsize"] batchsize = min(queue_size, batchsize) - logger.debug("Batchsize: %s", batchsize) logger.debug("Got batchsize: %s", batchsize) return batchsize diff --git a/tools/preview/preview.py b/tools/preview/preview.py index a5e83df556..f6ab16636e 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -302,7 +302,7 @@ def __init__(self, app: Preview, arguments: "Namespace", sample_size: int) -> No self._indices = self._get_indices() self._predictor = Predict(queue_manager.get_queue("preview_predict_in"), - sample_size, + self._sample_size, arguments) self._app._display.set_centering(self._predictor.centering) self.generate() @@ -385,6 +385,7 @@ def _get_indices(self) -> List[List[int]]: """ # Remove start and end values to get a list divisible by self.sample_size no_files = len(self._filelist) + self._sample_size = min(self._sample_size, no_files) crop = no_files % self._sample_size top_tail = list(range(no_files))[ crop // 2:no_files - (crop - (crop // 2))] From 03f5c671bc15bf2cd3db918777c856e89451b136 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 21 Jun 2023 12:57:33 +0100 Subject: [PATCH 824/981] Remove plaidML support (#1325) * Remove PlaidML reference from readme files * Remove AMD option from installers * remove amd requirements and update setup.py * remove plaidml test from CI workflow * gpustats: remove plaidml backend * plaid removals: - faceswap.py - python version check - setup.cfg - plaidml typing ignore - lib.keras_utils - All plaid code - lib.launcher.py - All plaidml checks and configuration * remove tf2.2 specific code from GUI event reader * lib.model - remove all plaidml implementations * plugins.extract - remove plaidml code * plugins.train remove plaidml code * lib.convert - remove plaidml code * tools.model: remove plaidml code * Remove plaidML tests from unit tests * remove plaidml_utils and docsting cleanups * Remove plaidML refs from configs * fix keras imports --- .github/workflows/pytest.yml | 18 +- .install/linux/faceswap_setup_x64.sh | 12 +- .install/windows/install.nsi | 24 +- INSTALL.md | 2 - README.md | 2 +- faceswap.py | 4 - lib/cli/launcher.py | 46 +- lib/gpu_stats/__init__.py | 2 - lib/gpu_stats/amd.py | 400 ----- lib/gpu_stats/directml.py | 12 +- lib/gui/analysis/event_reader.py | 28 +- lib/gui/analysis/stats.py | 51 +- lib/keras_utils.py | 46 +- lib/model/__init__.py | 13 - lib/model/autoclip.py | 5 +- lib/model/initializers.py | 50 +- lib/model/layers.py | 82 +- lib/model/loss/__init__.py | 9 - lib/model/loss/feature_loss_plaid.py | 381 ----- lib/model/loss/loss_plaid.py | 562 ------- lib/model/loss/perceptual_loss_plaid.py | 849 ----------- lib/model/losses/__init__.py | 7 + .../feature_loss.py} | 44 +- lib/model/{loss/loss_tf.py => losses/loss.py} | 7 +- .../perceptual_loss.py} | 8 +- lib/model/nets.py | 74 +- lib/model/nn_blocks.py | 62 +- ...rmalization_common.py => normalization.py} | 566 ++++--- lib/model/normalization/__init__.py | 13 - .../normalization/normalization_plaid.py | 384 ----- lib/model/normalization/normalization_tf.py | 171 --- lib/model/{optimizers_tf.py => optimizers.py} | 33 +- lib/model/optimizers_plaid.py | 156 -- lib/model/session.py | 66 +- lib/plaidml_utils.py | 59 - lib/utils.py | 11 +- locales/plugins.extract._config.pot | 2 +- locales/plugins.train._config.pot | 6 +- .../ru/LC_MESSAGES/plugins.extract._config.po | 4 +- .../ru/LC_MESSAGES/plugins.train._config.mo | Bin 53511 -> 53422 bytes .../ru/LC_MESSAGES/plugins.train._config.po | 49 +- plugins/extract/_config.py | 4 +- plugins/extract/align/_base/aligner.py | 19 +- plugins/extract/detect/_base.py | 19 +- plugins/extract/detect/mtcnn.py | 72 +- plugins/extract/detect/mtcnn_defaults.py | 2 +- plugins/extract/detect/s3fd.py | 44 +- plugins/extract/mask/_base.py | 19 +- plugins/extract/mask/bisenet_fp.py | 35 +- plugins/extract/mask/bisenet_fp_defaults.py | 2 +- plugins/extract/mask/vgg_clear.py | 28 +- plugins/extract/mask/vgg_obstructed.py | 31 +- plugins/extract/pipeline.py | 19 +- plugins/extract/recognition/_base.py | 62 +- .../extract/recognition/vgg_face2_defaults.py | 2 +- plugins/train/_config.py | 6 +- plugins/train/model/_base/__init__.py | 2 +- plugins/train/model/_base/io.py | 38 +- plugins/train/model/_base/model.py | 186 +-- plugins/train/model/_base/settings.py | 180 +-- plugins/train/model/dfaker.py | 19 +- plugins/train/model/dfl_h128.py | 17 +- plugins/train/model/dfl_sae.py | 40 +- plugins/train/model/dlight.py | 38 +- plugins/train/model/iae.py | 22 +- plugins/train/model/lightweight.py | 8 +- plugins/train/model/original.py | 28 +- plugins/train/model/phaze_a.py | 312 ++-- plugins/train/model/phaze_a_defaults.py | 1320 +++++++++-------- plugins/train/model/realface.py | 24 +- plugins/train/model/unbalanced.py | 31 +- plugins/train/model/villain.py | 24 +- plugins/train/trainer/_base.py | 178 +-- requirements/requirements_amd.txt | 6 - scripts/convert.py | 79 +- setup.cfg | 2 - setup.py | 65 +- tests/lib/gui/stats/event_reader_test.py | 48 +- tests/lib/model/initializers_test.py | 19 +- tests/lib/model/layers_test.py | 13 +- tests/lib/model/losses_test.py | 30 +- tests/lib/model/nn_blocks_test.py | 28 +- tests/lib/model/normalization_test.py | 20 +- tests/lib/model/optimizers_test.py | 24 +- tests/lib/sysinfo_test.py | 18 +- tests/lib/utils_test.py | 8 +- tests/simple_tests.py | 9 +- tests/startup_test.py | 25 +- tests/utils.py | 28 +- tools/model/model.py | 31 +- 90 files changed, 2050 insertions(+), 5554 deletions(-) delete mode 100644 lib/gpu_stats/amd.py delete mode 100644 lib/model/loss/__init__.py delete mode 100644 lib/model/loss/feature_loss_plaid.py delete mode 100644 lib/model/loss/loss_plaid.py delete mode 100644 lib/model/loss/perceptual_loss_plaid.py create mode 100644 lib/model/losses/__init__.py rename lib/model/{loss/feature_loss_tf.py => losses/feature_loss.py} (92%) rename lib/model/{loss/loss_tf.py => losses/loss.py} (98%) rename lib/model/{loss/perceptual_loss_tf.py => losses/perceptual_loss.py} (99%) rename lib/model/{normalization/normalization_common.py => normalization.py} (68%) delete mode 100644 lib/model/normalization/__init__.py delete mode 100644 lib/model/normalization/normalization_plaid.py delete mode 100644 lib/model/normalization/normalization_tf.py rename lib/model/{optimizers_tf.py => optimizers.py} (92%) delete mode 100644 lib/model/optimizers_plaid.py delete mode 100644 lib/plaidml_utils.py delete mode 100644 requirements/requirements_amd.txt diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 5cc2e7c296..7fc82d3c5a 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -3,7 +3,7 @@ name: ci/build on: push: pull_request: - paths-ignore: + paths-ignore: - docs/** - "**/README.md" @@ -15,18 +15,13 @@ jobs: fail-fast: false matrix: python-version: ["3.7", "3.8", "3.9"] - backend: ["amd", "cpu"] + backend: ["cpu"] include: - - kbackend: "plaidml.keras.backend" - backend: "amd" - kbackend: "tensorflow" backend: "cpu" - exclude: - - python-version: 3.9 - backend: amd steps: - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} @@ -50,14 +45,11 @@ jobs: mypy . - name: Simple Tests run: | - if [ "${{ matrix.backend }}" == "amd" ] ; then echo "{\"PLAIDML_DEVICE_IDS\":[\"llvm_cpu.0\"],\"PLAIDML_EXPERIMENTAL\":true}" > ~/.plaidml; fi ; - echo "{\"PLAIDML_DEVICE_IDS\":[\"llvm_cpu.0\"],\"PLAIDML_EXPERIMENTAL\":true}" > ~/.plaidml; FACESWAP_BACKEND="${{ matrix.backend }}" KERAS_BACKEND="${{ matrix.kbackend }}" py.test -v tests/; - name: End to End Tests run: | FACESWAP_BACKEND="${{ matrix.backend }}" KERAS_BACKEND="${{ matrix.kbackend }}" python tests/simple_tests.py; - if [ "${{ matrix.backend }}" == "amd" ] ; then rm -f ~/.plaidml; fi ; - + build_windows: runs-on: windows-latest strategy: @@ -70,7 +62,7 @@ jobs: - backend: "directml" steps: - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} diff --git a/.install/linux/faceswap_setup_x64.sh b/.install/linux/faceswap_setup_x64.sh index 66bb7f766f..1e60b41c38 100644 --- a/.install/linux/faceswap_setup_x64.sh +++ b/.install/linux/faceswap_setup_x64.sh @@ -134,13 +134,12 @@ ask_version() { # Ask which version of faceswap to install while true; do default=1 - read -rp $'\e[36mSelect:\t1: NVIDIA\n\t2: AMD (ROCm)\n\t3: CPU\n\t4: AMD (PlaidML) - deprecated\n'"[default: $default]: "$'\e[97m' vers + read -rp $'\e[36mSelect:\t1: NVIDIA\n\t2: AMD (ROCm)\n\t3: CPU\n'"[default: $default]: "$'\e[97m' vers vers="${vers:-${default}}" case $vers in 1) VERSION="nvidia" ; break ;; 2) VERSION="rocm" ; break ;; 3) VERSION="cpu" ; break ;; - 4) VERSION="amd" ; PYENV_VERSION="3.8" ; break ;; * ) echo "Invalid selection." ;; esac done @@ -281,11 +280,6 @@ faceswap_opts () { latest graphics card drivers installed from the relevant vendor. Please select the version\ of Faceswap you wish to install." ask_version - if [ $VERSION == "amd" ] ; then - warn "PlaidML support is deprecated and will be removed in a future update. If possible \ - please consider using the ROCm version" - sleep 2 - fi if [ $VERSION == "rocm" ] ; then warn "ROCm support is experimental. Please make sure that your GPU is supported by ROCm and that \ ROCm has been installed on your system before proceeding. Installation instructions: \ @@ -328,10 +322,6 @@ review() { fi echo " - Faceswap will be installed in '$DIR_FACESWAP'" echo " - Installing for '$VERSION'" - if [ $VERSION == "amd" ] ; then - echo -e " \e[33m- Note: '$VERSION' is deprecated and will be removed in a\e[97m" - echo -e " \e[33m future update. Consider using the ROCm version.\e[97m" - fi if [ $VERSION == "rocm" ] ; then echo -e " \e[33m- Note: Please ensure that ROCm is supported by your GPU\e[97m" echo -e " \e[33m and is installed prior to proceeding.\e[97m" diff --git a/.install/windows/install.nsi b/.install/windows/install.nsi index 56addc9331..1a7a54610e 100644 --- a/.install/windows/install.nsi +++ b/.install/windows/install.nsi @@ -22,7 +22,7 @@ InstallDir $PROFILE\faceswap # Install cli flags !define flagsConda "/S /RegisterPython=0 /AddToPath=0 /D=$PROFILE\MiniConda3" !define flagsRepo "--depth 1 --no-single-branch ${wwwRepo}" -!define flagsEnv "-y python=3." +!define flagsEnv "-y python=3.9" # Folders Var ProgramData @@ -129,32 +129,25 @@ Function pgPrereqCreate ${NSD_CreateLabel} 10% $lblPos% 80% 14u "Faceswap" Pop $0 - StrCpy $lblPos 50 + StrCpy $lblPos 46 # Info Custom Options - ${NSD_CreateGroupBox} 5% 40% 90% 120% "Custom Items" + ${NSD_CreateGroupBox} 5% 40% 90% 60% "Custom Items" Pop $0 ${NSD_CreateRadioButton} 10% $lblPos% 27% 11u "Setup for NVIDIA GPU" Pop $ctlRadio ${NSD_AddStyle} $ctlRadio ${WS_GROUP} nsDialogs::SetUserData $ctlRadio "nvidia" ${NSD_OnClick} $ctlRadio RadioClick - ${NSD_CreateRadioButton} 50% $lblPos% 30% 11u "Setup for DirectML" + ${NSD_CreateRadioButton} 40% $lblPos% 25% 11u "Setup for DirectML" Pop $ctlRadio nsDialogs::SetUserData $ctlRadio "directml" ${NSD_OnClick} $ctlRadio RadioClick - - intOp $lblPos $lblPos + 10 - - ${NSD_CreateRadioButton} 10% $lblPos% 25% 11u "Setup for CPU" + ${NSD_CreateRadioButton} 70% $lblPos% 20% 11u "Setup for CPU" Pop $ctlRadio nsDialogs::SetUserData $ctlRadio "cpu" ${NSD_OnClick} $ctlRadio RadioClick - ${NSD_CreateRadioButton} 50% $lblPos% 40% 11u "Setup for AMD (deprecated)" - Pop $ctlRadio - nsDialogs::SetUserData $ctlRadio "amd" - ${NSD_OnClick} $ctlRadio RadioClick - intOp $lblPos $lblPos + 12 + intOp $lblPos $lblPos + 10 ${NSD_CreateLabel} 10% $lblPos% 80% 10u "Environment Name (NB: Existing envs with this name will be deleted):" pop $0 @@ -404,11 +397,6 @@ Function SetEnvironment CreateEnv: SetDetailsPrint listonly - ${If} $setupType == "amd" - StrCpy $0 "${flagsEnv}8" - ${else} - StrCpy $0 "${flagsEnv}9" - ${EndIf} ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda create $0 -n $\"$envName$\" && conda deactivate" pop $0 ExecDos::wait $0 diff --git a/INSTALL.md b/INSTALL.md index 542a00eab1..cae8acdd9e 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -62,7 +62,6 @@ The type of computations that the process does are well suited for graphics card - DirectX 12 AMD GPUs are supported on Windows through DirectML. - More modern AMD GPUs are supported on Linux through ROCm. - M-series Macs are supported through Tensorflow-Metal - - OpenCL 1.2 support through PlaidML is deprecated and will be removed in a future update - If using an Nvidia GPU, then it needs to support at least CUDA Compute Capability 3.5. (Release 1.0 will work on Compute Capability 3.0) To see which version your GPU supports, consult this list: https://developer.nvidia.com/cuda-gpus Desktop cards later than the 7xx series are most likely supported. @@ -142,7 +141,6 @@ If you are using an Nvidia card make sure you have the correct versions of Cuda/ - Install tkinter (required for the GUI) by typing: `conda install tk` - Install requirements: - For Nvidia GPU users: `pip install -r ./requirements/requirements_nvidia.txt` - - For AMD GPU users: `pip install -r ./requirements/requirements_amd.txt` - For CPU users: `pip install -r ./requirements/requirements_cpu.txt` ## Running faceswap diff --git a/README.md b/README.md index c2b248b222..2b1e829214 100755 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ We are very troubled by the fact that FaceSwap can be used for unethical and dis # How To setup and run the project FaceSwap is a Python program that will run on multiple Operating Systems including Windows, Linux, and MacOS. -See [INSTALL.md](INSTALL.md) for full installation instructions. You will need a modern GPU with CUDA support for best performance. AMD GPUs are partially supported. +See [INSTALL.md](INSTALL.md) for full installation instructions. You will need a modern GPU with CUDA support for best performance. Many AMD GPUs are supported through DirectML (Windows) and ROCm (Linux). # Overview The project has multiple entry points. You will have to: diff --git a/faceswap.py b/faceswap.py index c644e9cddd..3b6777e86f 100755 --- a/faceswap.py +++ b/faceswap.py @@ -11,7 +11,6 @@ from lib.cli import args as cli_args # pylint:disable=wrong-import-position from lib.config import generate_configs # pylint:disable=wrong-import-position -from lib.utils import get_backend # pylint:disable=wrong-import-position # LOCALES _LANG = gettext.translation("faceswap", localedir="locales", fallback=True) @@ -19,9 +18,6 @@ if sys.version_info < (3, 7): raise ValueError("This program requires at least python3.7") -if get_backend() == "amd" and sys.version_info >= (3, 9): - raise ValueError("The AMD version of Faceswap cannot run on versions of Python higher " - "than 3.8") _PARSER = cli_args.FullHelpArgumentParser() diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index f78276f7b5..9219346401 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -10,7 +10,7 @@ from lib.gpu_stats import set_exclude_devices, GPUStats from lib.logger import crash_log, log_setup -from lib.utils import (deprecation_warning, FaceswapError, get_backend, get_tf_version, +from lib.utils import (FaceswapError, get_backend, get_tf_version, safe_shutdown, set_backend, set_system_verbosity) if TYPE_CHECKING: @@ -99,7 +99,6 @@ def _test_for_tf_version(self) -> None: FaceswapError If Tensorflow is not found, or is not between versions 2.4 and 2.9 """ - amd_ver = (2, 2) directml_ver = rocm_ver = (2, 10) min_ver = (2, 7) max_ver = (2, 10) @@ -122,18 +121,14 @@ def _test_for_tf_version(self) -> None: tf_ver = get_tf_version() backend = get_backend() - if backend != "amd" and tf_ver < min_ver: + if tf_ver < min_ver: msg = (f"The minimum supported Tensorflow is version {min_ver} but you have version " f"{tf_ver} installed. Please upgrade Tensorflow.") self._handle_import_error(msg) - if backend != "amd" and tf_ver > max_ver: + if tf_ver > max_ver: msg = (f"The maximum supported Tensorflow is version {max_ver} but you have version " f"{tf_ver} installed. Please downgrade Tensorflow.") self._handle_import_error(msg) - if backend == "amd" and tf_ver != amd_ver: - msg = (f"The supported Tensorflow version for AMD cards is {amd_ver} but you have " - f"version {tf_ver} installed. Please install the correct version.") - self._handle_import_error(msg) if backend == "directml" and tf_ver != directml_ver: msg = (f"The supported Tensorflow version for DirectML cards is {directml_ver} but " f"you have version {tf_ver} installed. Please install the correct version.") @@ -283,42 +278,7 @@ def _configure_backend(self, arguments: "argparse.Namespace") -> None: if GPUStats().exclude_all_devices: msg = "Switching backend to CPU" - if get_backend() == "amd": - msg += (". Using Tensorflow for CPU operations.") - os.environ["KERAS_BACKEND"] = "tensorflow" set_backend("cpu") logger.info(msg) logger.debug("Executing: %s. PID: %s", self._command, os.getpid()) - - if get_backend() == "amd" and not self._setup_amd(arguments): - safe_shutdown(got_error=True) - - @classmethod - def _setup_amd(cls, arguments: "argparse.Namespace") -> bool: - """ Test for plaidml and perform setup for AMD. - - Parameters - ---------- - arguments: :class:`argparse.Namespace` - The command line arguments passed to Faceswap. - - Returns - ------- - bool - ``True`` if AMD was set up succesfully otherwise ``False`` - """ - logger.debug("Setting up for AMD") - if platform.system() == "Windows": - deprecation_warning("The AMD backend", - additional_info="Please consider re-installing using the " - "'DirectML' backend") - try: - import plaidml # noqa pylint:disable=unused-import,import-outside-toplevel - except ImportError: - logger.error("PlaidML not found. Run `pip install plaidml-keras` for AMD support") - return False - from lib.gpu_stats import setup_plaidml # pylint:disable=import-outside-toplevel - setup_plaidml(arguments.loglevel, arguments.exclude_gpus) - logger.debug("setup up for PlaidML") - return True diff --git a/lib/gpu_stats/__init__.py b/lib/gpu_stats/__init__.py index 15b1f42cda..070a53d057 100644 --- a/lib/gpu_stats/__init__.py +++ b/lib/gpu_stats/__init__.py @@ -14,8 +14,6 @@ from .nvidia_apple import NvidiaAppleStats as GPUStats # type:ignore elif backend == "nvidia": from .nvidia import NvidiaStats as GPUStats # type:ignore -elif backend == "amd": - from .amd import AMDStats as GPUStats, setup_plaidml # type:ignore elif backend == "apple_silicon": from .apple_silicon import AppleSiliconStats as GPUStats # type:ignore elif backend == "directml": diff --git a/lib/gpu_stats/amd.py b/lib/gpu_stats/amd.py deleted file mode 100644 index f9e513f690..0000000000 --- a/lib/gpu_stats/amd.py +++ /dev/null @@ -1,400 +0,0 @@ -#!/usr/bin/env python3 -""" Collects and returns Information on available AMD GPUs. """ -import json -import logging -import os -import sys - -from typing import List, Optional - -import plaidml - -from ._base import _GPUStats, _EXCLUDE_DEVICES - - -_PLAIDML_INITIALIZED: bool = False - - -def setup_plaidml(log_level: str, exclude_devices: List[int]) -> None: - """ Setup PlaidML for AMD Cards. - - Sets the Keras backend to PlaidML, loads the plaidML backend and makes GPU Device information - from PlaidML available to :class:`AMDStats`. - - Parameters - ---------- - log_level: str - Faceswap's log level. Used for setting the log level inside PlaidML - exclude_devices: list - A list of integers of device IDs that should not be used by Faceswap - """ - logger = logging.getLogger(__name__) # pylint:disable=invalid-name - logger.info("Setting up for PlaidML") - logger.verbose("Setting Keras Backend to PlaidML") # type:ignore - # Add explicitly excluded devices to list. The contents are checked in AMDstats - if exclude_devices: - _EXCLUDE_DEVICES.extend(int(idx) for idx in exclude_devices) - os.environ["KERAS_BACKEND"] = "plaidml.keras.backend" - stats = AMDStats(log_level=log_level) - logger.info("Using GPU(s): %s", [stats.names[i] for i in stats.active_devices]) - logger.info("Successfully set up for PlaidML") - - -class AMDStats(_GPUStats): - """ Holds information and statistics about AMD GPU(s) available on the currently - running system. - - Notes - ----- - The quality of data that returns is very much dependent on the OpenCL implementation used - for a particular OS. Some data is just not available at all, so assumptions and substitutions - are made where required. PlaidML is used as an interface into OpenCL to obtain the required - information. - - PlaidML is explicitly initialized inside this class, as it can be called from the command line - arguments to list available GPUs. PlaidML needs to be set up and configured to obtain reliable - information. As the function :func:`setup_plaidml` is called very early within the Faceswap - and launch process and it references this class, initial PlaidML setup can all be handled here. - - Parameters - ---------- - log: bool, optional - Whether the class should output information to the logger. There may be occasions where the - logger has not yet been set up when this class is queried. Attempting to log in these - instances will raise an error. If GPU stats are being queried prior to the logger being - available then this parameter should be set to ``False``. Otherwise set to ``True``. - Default: ``True`` - """ - def __init__(self, log: bool = True, log_level: str = "INFO") -> None: - - self._log_level: str = log_level.upper() - - # Following attributes are set in :func:``_initialize`` - self._ctx: Optional[plaidml.Context] = None - self._supported_devices: List[plaidml._DeviceConfig] = [] - self._all_devices: List[plaidml._DeviceConfig] = [] - self._device_details: List[dict] = [] - - super().__init__(log=log) - - @property - def active_devices(self) -> List[int]: - """ list: The active device ids in use. """ - return self._active_devices - - @property - def _plaid_ids(self) -> List[str]: - """ list: The device identification for each GPU device that PlaidML has discovered. """ - return [device.id.decode("utf-8", errors="replace") for device in self._all_devices] - - @property - def _experimental_indices(self) -> List[int]: - """ list: The indices corresponding to :attr:`_ids` of GPU devices marked as - "experimental". """ - retval = [idx for idx, device in enumerate(self._all_devices) - if device not in self._supported_indices] - return retval - - @property - def _supported_indices(self) -> List[int]: - """ list: The indices corresponding to :attr:`_ids` of GPU devices marked as - "supported". """ - retval = [idx for idx, device in enumerate(self._all_devices) - if device in self._supported_devices] - return retval - - @property - def _all_vram(self) -> List[int]: - """ list: The VRAM of each GPU device that PlaidML has discovered. """ - return [int(int(device.get("globalMemSize", 0)) / (1024 * 1024)) - for device in self._device_details] - - @property - def names(self) -> List[str]: - """ list: The name of each GPU device that PlaidML has discovered. """ - return [f"{device.get('vendor', 'unknown')} - {device.get('name', 'unknown')} " - f"({ 'supported' if idx in self._supported_indices else 'experimental'})" - for idx, device in enumerate(self._device_details)] - - def _initialize(self) -> None: - """ Initialize PlaidML for AMD GPUs. - - If :attr:`_is_initialized` is ``True`` then this function just returns performing no - action. - - if ``False`` then PlaidML is setup, if not already, and GPU information is extracted - from the PlaidML context. - """ - if self._is_initialized: - return - self._log("debug", "Initializing PlaidML for AMD GPU.") - - self._initialize_plaidml() - - self._ctx = plaidml.Context() - self._supported_devices = self._get_supported_devices() - self._all_devices = self._get_all_devices() - self._device_details = self._get_device_details() - self._select_device() - - super()._initialize() - - def _initialize_plaidml(self) -> None: - """ Initialize PlaidML on first call to this class and set global - :attr:``_PLAIDML_INITIALIZED`` to ``True``. If PlaidML has already been initialized then - return performing no action. """ - global _PLAIDML_INITIALIZED # pylint:disable=global-statement - - if _PLAIDML_INITIALIZED: - return - - self._log("debug", "Performing first time PlaidML setup.") - self._set_plaidml_logger() - - _PLAIDML_INITIALIZED = True - - def _set_plaidml_logger(self) -> None: - """ Set PlaidMLs default logger to Faceswap Logger, prevent propagation and set the correct - log level. """ - self._log("debug", "Setting PlaidML Default Logger") - - plaidml.DEFAULT_LOG_HANDLER = logging.getLogger("plaidml_root") - plaidml.DEFAULT_LOG_HANDLER.propagate = False - - numeric_level = getattr(logging, self._log_level, None) - assert numeric_level is not None - if numeric_level < 10: # DEBUG Logging - plaidml._internal_set_vlog(1) # pylint:disable=protected-access - elif numeric_level < 20: # INFO Logging - plaidml._internal_set_vlog(0) # pylint:disable=protected-access - else: # WARNING LOGGING - plaidml.quiet() - - def _get_supported_devices(self) -> List[plaidml._DeviceConfig]: - """ Obtain GPU devices from PlaidML that are marked as "supported". - - Returns - ------- - list_LOGGER. - The :class:`plaidml._DeviceConfig` objects for all supported GPUs that PlaidML has - discovered. - """ - experimental_setting = plaidml.settings.experimental - - plaidml.settings.experimental = False - devices = plaidml.devices(self._ctx, limit=100, return_all=True)[0] - plaidml.settings.experimental = experimental_setting - - supported = [d for d in devices - if d.details - and json.loads( - d.details.decode("utf-8", - errors="replace")).get("type", "cpu").lower() == "gpu"] - - self._log("debug", f"Obtained supported devices: {supported}") - return supported - - def _get_all_devices(self) -> List[plaidml._DeviceConfig]: - """ Obtain all available (experimental and supported) GPU devices from PlaidML. - - Returns - ------- - list - The :class:`pladml._DeviceConfig` objects for GPUs that PlaidML has discovered. - """ - experimental_setting = plaidml.settings.experimental - plaidml.settings.experimental = True - devices = plaidml.devices(self._ctx, limit=100, return_all=True)[0] - plaidml.settings.experimental = experimental_setting - - experi = [d for d in devices - if d.details - and json.loads( - d.details.decode("utf-8", - errors="replace")).get("type", "cpu").lower() == "gpu"] - - self._log("debug", f"Obtained experimental Devices: {experi}") - - all_devices = experi + self._supported_devices - all_devices = all_devices if all_devices else self._get_fallback_devices() # Use CPU - - self._log("debug", f"Obtained all Devices: {all_devices}") - return all_devices - - def _get_fallback_devices(self) -> List[plaidml._DeviceConfig]: - """ Called if a GPU has not been discovered. Return any devices we can run on. - - Returns - ------- - list: - The :class:`pladml._DeviceConfig` fallaback objects that PlaidML has discovered. - """ - # Try get a supported device - experimental_setting = plaidml.settings.experimental - plaidml.settings.experimental = False - devices = plaidml.devices(self._ctx, limit=100, return_all=True)[0] - - # Try get any device - if not devices: - plaidml.settings.experimental = True - devices = plaidml.devices(self._ctx, limit=100, return_all=True)[0] - - plaidml.settings.experimental = experimental_setting - - if not devices: - raise RuntimeError("No valid devices could be found for plaidML.") - - self._log("warning", f"PlaidML could not find a GPU. Falling back to: " - f"{[d.id.decode('utf-8', errors='replace') for d in devices]}") - return devices - - def _get_device_details(self) -> List[dict]: - """ Obtain the device details for all connected AMD GPUS. - - Returns - ------- - list - The `dict` device detail for all GPUs that PlaidML has discovered. - """ - details = [] - for dev in self._all_devices: - if dev.details: - details.append(json.loads(dev.details.decode("utf-8", errors="replace"))) - else: - details.append(dict(vendor=dev.id.decode("utf-8", errors="replace"), - name=dev.description.decode("utf-8", errors="replace"), - globalMemSize=4 * 1024 * 1024 * 1024)) # 4GB dummy ram - self._log("debug", f"Obtained Device details: {details}") - return details - - def _select_device(self) -> None: - """ - If the plaidml user configuration settings exist, then set the default GPU from the - settings file, Otherwise set the GPU to be the one with most VRAM. """ - if os.path.exists(plaidml.settings.user_settings): # pylint:disable=no-member - self._log("debug", "Setting PlaidML devices from user_settings") - else: - self._select_largest_gpu() - - def _select_largest_gpu(self) -> None: - """ Set the default GPU to be a supported device with the most available VRAM. If no - supported device is available, then set the GPU to be an experimental device with the - most VRAM available. """ - category = "supported" if self._supported_devices else "experimental" - self._log("debug", f"Obtaining largest {category} device") - - indices = getattr(self, f"_{category}_indices") - if not indices: - self._log("error", "Failed to automatically detect your GPU.") - self._log("error", "Please run `plaidml-setup` to set up your GPU.") - sys.exit(1) - - max_vram = max(self._all_vram[idx] for idx in indices) - self._log("debug", f"Max VRAM: {max_vram}") - - gpu_idx = min(idx for idx, vram in enumerate(self._all_vram) - if vram == max_vram and idx in indices) - self._log("debug", f"GPU IDX: {gpu_idx}") - - selected_gpu = self._plaid_ids[gpu_idx] - self._log("info", f"Setting GPU to largest available {category} device. If you want to " - "override this selection, run `plaidml-setup` from the command line.") - - plaidml.settings.experimental = category == "experimental" - plaidml.settings.device_ids = [selected_gpu] - - def _get_device_count(self) -> int: - """ Detect the number of AMD GPUs available from PlaidML. - - Returns - ------- - int - The total number of AMD GPUs available - """ - retval = len(self._all_devices) - self._log("debug", f"GPU Device count: {retval}") - return retval - - def _get_active_devices(self) -> List[int]: - """ Obtain the indices of active GPUs (those that have not been explicitly excluded by - PlaidML or explicitly excluded in the command line arguments). - - Returns - ------- - list - The list of device indices that are available for Faceswap to use - """ - devices = [idx for idx, d_id in enumerate(self._plaid_ids) - if d_id in plaidml.settings.device_ids and idx not in _EXCLUDE_DEVICES] - self._log("debug", f"Active GPU Devices: {devices}") - return devices - - def _get_handles(self) -> list: - """ AMD Doesn't really use device handles, so we just return the all devices list - - Returns - ------- - list - The list of all AMD discovered GPUs - """ - handles = self._all_devices - self._log("debug", f"AMD GPU Handles found: {handles}") - return handles - - def _get_driver(self) -> str: - """ Obtain the AMD driver version currently in use. - - Returns - ------- - str - The current AMD GPU driver versions - """ - drivers = "|".join([device.get("driverVersion", "No Driver Found") - for device in self._device_details]) - self._log("debug", f"GPU Drivers: {drivers}") - return drivers - - def _get_device_names(self) -> List[str]: - """ Obtain the list of names of connected AMD GPUs as identified in :attr:`_handles`. - - Returns - ------- - list - The list of connected Nvidia GPU names - """ - names = self.names - self._log("debug", f"GPU Devices: {names}") - return names - - def _get_vram(self) -> List[int]: - """ Obtain the VRAM in Megabytes for each connected AMD GPU as identified in - :attr:`_handles`. - - Returns - ------- - list - The VRAM in Megabytes for each connected Nvidia GPU - """ - vram = self._all_vram - self._log("debug", f"GPU VRAM: {vram}") - return vram - - def _get_free_vram(self) -> List[int]: - """ Obtain the amount of VRAM that is available, in Megabytes, for each connected AMD - GPU. - - Notes - ----- - There is no useful way to get free VRAM on PlaidML. OpenCL loads and unloads VRAM as - required, so this returns the total memory available per card for AMD GPUs, which is - not particularly useful. - - Returns - ------- - list - List of `float`s containing the amount of VRAM available, in Megabytes, for each - connected GPU as corresponding to the values in :attr:`_handles - """ - vram = self._all_vram - self._log("debug", f"GPU VRAM free: {vram}") - return vram diff --git a/lib/gpu_stats/directml.py b/lib/gpu_stats/directml.py index f17705640e..d29fac0415 100644 --- a/lib/gpu_stats/directml.py +++ b/lib/gpu_stats/directml.py @@ -10,7 +10,7 @@ from enum import Enum, IntEnum from typing import Any, Callable, cast, List -from comtypes import COMError, IUnknown, GUID, STDMETHOD, HRESULT +from comtypes import COMError, IUnknown, GUID, STDMETHOD, HRESULT # pylint:disable=import-error from ._base import _GPUStats @@ -168,7 +168,7 @@ class DXGIQueryVideoMemoryInfo(StructureRepr): # pylint:disable=too-few-public- # COM OBjects -class IDXObject(IUnknown): +class IDXObject(IUnknown): # pylint:disable=too-few-public-methods """ Base interface for all DXGI objects. Reference @@ -184,7 +184,7 @@ class IDXObject(IUnknown): STDMETHOD(HRESULT, "GetParent", [GUID, POINTER(POINTER(ctypes.c_void_p))])] -class IDXGIFactory6(IDXObject): +class IDXGIFactory6(IDXObject): # pylint:disable=too-few-public-methods """ Implements methods for generating DXGI objects Reference @@ -224,7 +224,7 @@ class IDXGIFactory6(IDXObject): POINTER(ctypes.c_void_p)])] -class IDXGIAdapter3(IDXObject): +class IDXGIAdapter3(IDXObject): # pylint:disable=too-few-public-methods """ Represents a display sub-system (including one or more GPU's, DACs and video memory). Reference @@ -536,8 +536,8 @@ def _initialize(self) -> None: If :attr:`_is_initialized` is ``True`` then this function just returns performing no action. - if ``False`` then PlaidML is setup, if not already, and GPU information is extracted - from the PlaidML context. + if ``False`` then DirectML is setup, if not already, and GPU information is extracted + from the DirectML context. """ if self._is_initialized: return diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index ef2c9d6e6f..a99b6a7887 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -16,7 +16,6 @@ errors_impl as tf_errors) from lib.serializer import get_serializer -from lib.utils import get_backend if sys.version_info < (3, 8): from typing_extensions import Literal @@ -692,9 +691,6 @@ def cache_events(self, session_id: int) -> None: continue if event.summary.value[0].tag == "keras": self._parse_outputs(event) - if get_backend() == "amd": - # No model is logged for AMD so need to get loss labels from state file - self._add_amd_loss_labels(session_id) if event.summary.value[0].tag.startswith("batch_"): data[event.step] = self._process_event(event, data.get(event.step, EventData())) @@ -771,28 +767,6 @@ def _get_outputs(cls, model_config: Dict[str, Any]) -> np.ndarray: outputs, outputs.shape) return outputs - def _add_amd_loss_labels(self, session_id: int) -> None: - """ It is not possible to store the model config in the Tensorboard logs for AMD so we - need to obtain the loss labels from the model's state file. This is called now so we know - event data is being written, and therefore the most current loss label data is available - in the state file. - - Loss names are added to :attr:`_loss_labels` - - Parameters - ---------- - session_id: int - The session id that the data is being cached for - - """ - if self._cache._loss_labels: # pylint:disable=protected-access - return - # Import global session here to prevent circular import - from . import Session # pylint:disable=import-outside-toplevel - loss_labels = sorted(Session.get_loss_keys(session_id=session_id)) - self._loss_labels = loss_labels - logger.debug("Collated loss labels: %s", self._loss_labels) - @classmethod def _process_event(cls, event: event_pb2.Event, step: EventData) -> EventData: """ Process a single Tensorflow event. @@ -815,7 +789,7 @@ def _process_event(cls, event: event_pb2.Event, step: EventData) -> EventData: """ summary = event.summary.value[0] - if summary.tag in ("batch_loss", "batch_total"): # Pre tf2.3 totals were "batch_total" + if summary.tag == "batch_loss": step.timestamp = event.wall_time return step diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index 159c1d09bf..f3d7273eed 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -18,7 +18,6 @@ import numpy as np from lib.serializer import get_serializer -from lib.utils import get_backend from .event_reader import TensorBoardLogs @@ -263,15 +262,10 @@ def get_loss_keys(self, session_id: Optional[int]) -> List[str]: The loss keys for the given session. If ``None`` is passed as session_id then a unique list of all loss keys for all sessions is returned """ - if get_backend() == "amd": - # We can't log the graph in Tensorboard logs for AMD so need to obtain from state file - loss_keys = {int(sess_id): [name for name in session["loss_names"] if name != "total"] - for sess_id, session in self._state["sessions"].items()} - else: - assert self._tb_logs is not None - loss_keys = {sess_id: list(logs.keys()) - for sess_id, logs - in self._tb_logs.get_loss(session_id=session_id).items()} + assert self._tb_logs is not None + loss_keys = {sess_id: list(logs.keys()) + for sess_id, logs + in self._tb_logs.get_loss(session_id=session_id).items()} if session_id is None: retval: List[str] = list(set(loss_key @@ -339,9 +333,9 @@ def _get_time_stats(self) -> None: logger.debug("Collating summary time stamps") self._time_stats = { - sess_id: dict(start_time=np.min(timestamps) if np.any(timestamps) else 0, - end_time=np.max(timestamps) if np.any(timestamps) else 0, - iterations=timestamps.shape[0] if np.any(timestamps) else 0) + sess_id: {"start_time": np.min(timestamps) if np.any(timestamps) else 0, + "end_time": np.max(timestamps) if np.any(timestamps) else 0, + "iterations": timestamps.shape[0] if np.any(timestamps) else 0} for sess_id, timestamps in cast(Dict[int, np.ndarray], self._session.get_timestamps(None)).items()} @@ -351,10 +345,10 @@ def _get_time_stats(self) -> None: session_id = _SESSION.session_ids[-1] latest = cast(np.ndarray, self._session.get_timestamps(session_id)) - self._time_stats[session_id] = dict( - start_time=np.min(latest) if np.any(latest) else 0, - end_time=np.max(latest) if np.any(latest) else 0, - iterations=latest.shape[0] if np.any(latest) else 0) + self._time_stats[session_id] = { + "start_time": np.min(latest) if np.any(latest) else 0, + "end_time": np.max(latest) if np.any(latest) else 0, + "iterations": latest.shape[0] if np.any(latest) else 0} logger.debug("time_stats: %s", self._time_stats) @@ -416,14 +410,15 @@ def _collate_stats(self, session_id: int) -> Dict[str, Union[int, float]]: end = np.nan_to_num(timestamps["end_time"]) elapsed = int(end - start) batchsize = self._session.batch_sizes.get(session_id, 0) - retval = dict( - session=session_id, - start=start, - end=end, - elapsed=elapsed, - rate=(((batchsize * 2) * timestamps["iterations"]) / elapsed if elapsed != 0 else 0), - batch=batchsize, - iterations=timestamps["iterations"]) + retval = { + "session": session_id, + "start": start, + "end": end, + "elapsed": elapsed, + "rate": (((batchsize * 2) * timestamps["iterations"]) / elapsed + if elapsed != 0 else 0), + "batch": batchsize, + "iterations": timestamps["iterations"]} logger.debug(retval) return retval @@ -557,9 +552,9 @@ def __init__(self, session_id, self._loss_keys = loss_keys if isinstance(loss_keys, list) else [loss_keys] self._selections = selections if isinstance(selections, list) else [selections] self._is_totals = session_id is None - self._args: Dict[str, Union[int, float]] = dict(avg_samples=avg_samples, - smooth_amount=smooth_amount, - flatten_outliers=flatten_outliers) + self._args: Dict[str, Union[int, float]] = {"avg_samples": avg_samples, + "smooth_amount": smooth_amount, + "flatten_outliers": flatten_outliers} self._iterations = 0 self._limit = 0 self._start_iteration = 0 diff --git a/lib/keras_utils.py b/lib/keras_utils.py index aa472ad832..0f00196245 100644 --- a/lib/keras_utils.py +++ b/lib/keras_utils.py @@ -1,17 +1,14 @@ #!/usr/bin/env python3 """ Common multi-backend Keras utilities """ -from typing import Optional, Tuple +from __future__ import annotations +import typing as T import numpy as np -from lib.utils import get_backend +import tensorflow.keras.backend as K # pylint:disable=import-error -if get_backend() == "amd": - from plaidml.tile import Value as Tensor # pylint:disable=import-error - from keras import backend as K -else: +if T.TYPE_CHECKING: from tensorflow import Tensor - from tensorflow.keras import backend as K # pylint:disable=import-error def frobenius_norm(matrix: Tensor, @@ -46,7 +43,7 @@ def replicate_pad(image: Tensor, padding: int) -> Tensor: ----- At the time of writing Keras/Tensorflow does not have a native replication padding method. The implementation here is probably not the most efficient, but it is a pure keras method - which should work on both TF and Plaid. + which should work on TF. Parameters ---------- @@ -91,28 +88,22 @@ class ColorSpaceConvert(): # pylint:disable=too-few-public-methods One of `"srgb"`, `"rgb"`, `"xyz"` to_space: str One of `"lab"`, `"rgb"`, `"ycxcz"`, `"xyz"` - batch_shape: Tuple, optional - Shape tuple (b, h, w, c) if the image being processed. Required for PlaidML backend. - Optional. Default = ``None`` Raises ------ ValueError If the requested color space conversion is not defined """ - def __init__(self, - from_space: str, - to_space: str, - batch_shape: Optional[Tuple[int, int, int, int]] = None) -> None: - functions = dict(rgb_lab=self._rgb_to_lab, - rgb_xyz=self._rgb_to_xyz, - srgb_rgb=self._srgb_to_rgb, - srgb_ycxcz=self._srgb_to_ycxcz, - xyz_ycxcz=self._xyz_to_ycxcz, - xyz_lab=self._xyz_to_lab, - xyz_to_rgb=self._xyz_to_rgb, - ycxcz_rgb=self._ycxcz_to_rgb, - ycxcz_xyz=self._ycxcz_to_xyz) + def __init__(self, from_space: str, to_space: str) -> None: + functions = {"rgb_lab": self._rgb_to_lab, + "rgb_xyz": self._rgb_to_xyz, + "srgb_rgb": self._srgb_to_rgb, + "srgb_ycxcz": self._srgb_to_ycxcz, + "xyz_ycxcz": self._xyz_to_ycxcz, + "xyz_lab": self._xyz_to_lab, + "xyz_to_rgb": self._xyz_to_rgb, + "ycxcz_rgb": self._ycxcz_to_rgb, + "ycxcz_xyz": self._ycxcz_to_xyz} func_name = f"{from_space.lower()}_{to_space.lower()}" if func_name not in functions: raise ValueError(f"The color transform {from_space} to {to_space} is not defined.") @@ -124,10 +115,9 @@ def __init__(self, self._rgb_xyz_map = self._get_rgb_xyz_map() self._xyz_multipliers = K.constant([116, 500, 200], dtype="float32") - self._batch_shape = batch_shape @classmethod - def _get_rgb_xyz_map(cls) -> Tuple[Tensor, Tensor]: + def _get_rgb_xyz_map(cls) -> T.Tuple[Tensor, Tensor]: """ Obtain the mapping and inverse mapping for rgb to xyz color space conversion. Returns @@ -198,7 +188,7 @@ def _rgb_xyz_rgb(self, image: Tensor, mapping: Tensor) -> Tensor: Tensor The image tensor in XYZ format """ - dim = K.int_shape(image) if self._batch_shape is None else self._batch_shape + dim = K.int_shape(image) image = K.permute_dimensions(image, (0, 3, 1, 2)) image = K.reshape(image, (dim[0], dim[3], dim[1] * dim[2])) converted = K.permute_dimensions(K.dot(mapping, image), (1, 2, 0)) @@ -278,7 +268,7 @@ def _xyz_to_lab(self, image: Tensor) -> Tensor: factor = 1 / (3 * (delta ** 2)) clamped_term = K.pow(K.clip(image, delta_cube, None), 1.0 / 3.0) - div = (factor * image + (4 / 29)) + div = factor * image + (4 / 29) image = K.switch(image > delta_cube, clamped_term, div) return K.concatenate([self._xyz_multipliers[0] * image[..., 1:2] - 16., diff --git a/lib/model/__init__.py b/lib/model/__init__.py index 6962d814f3..e69de29bb2 100644 --- a/lib/model/__init__.py +++ b/lib/model/__init__.py @@ -1,13 +0,0 @@ -#!/usr/bin/env python3 -""" Conditional imports depending on whether the AMD version is installed or not """ - -from lib.utils import get_backend - -from .normalization import (AdaInstanceNormalization, GroupNormalization, # noqa - InstanceNormalization, LayerNormalization, RMSNormalization) -from .loss import losses # noqa - -if get_backend() == "amd": - from . import optimizers_plaid as optimizers # noqa -else: - from . import optimizers_tf as optimizers #type:ignore # noqa diff --git a/lib/model/autoclip.py b/lib/model/autoclip.py index 2041308b11..e1750ae2b1 100644 --- a/lib/model/autoclip.py +++ b/lib/model/autoclip.py @@ -1,7 +1,4 @@ -""" Auto clipper for clipping gradients. - -Non AMD Only -""" +""" Auto clipper for clipping gradients. """ from typing import List import tensorflow as tf diff --git a/lib/model/initializers.py b/lib/model/initializers.py index c1d0204710..8085de3e04 100644 --- a/lib/model/initializers.py +++ b/lib/model/initializers.py @@ -8,16 +8,10 @@ import numpy as np import tensorflow as tf -from lib.utils import get_backend +# Fix intellisense/linting for tf.keras' thoroughly broken import system +keras = tf.keras +K = keras.backend -if get_backend() == "amd": - from keras.utils import get_custom_objects # pylint:disable=no-name-in-module - from keras import backend as K - from keras import initializers -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras.utils import get_custom_objects # noqa pylint:disable=no-name-in-module,import-error - from tensorflow.keras import initializers, backend as K # noqa pylint:disable=no-name-in-module,import-error logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -70,7 +64,7 @@ def compute_fans(shape, data_format='channels_last'): return fan_in, fan_out -class ICNR(initializers.Initializer): # pylint: disable=invalid-name,no-member +class ICNR(keras.initializers.Initializer): # type:ignore[name-defined] """ ICNR initializer for checkerboard artifact free sub pixel convolution Parameters @@ -100,7 +94,7 @@ def __init__(self, initializer, scale=2): self.scale = scale self.initializer = initializer - def __call__(self, shape, dtype="float32"): + def __call__(self, shape, dtype="float32", **kwargs): """ Call function for the ICNR initializer. Parameters @@ -120,7 +114,7 @@ def __call__(self, shape, dtype="float32"): return self.initializer(shape) new_shape = shape[:3] + [shape[3] // (self.scale ** 2)] if isinstance(self.initializer, dict): - self.initializer = initializers.deserialize(self.initializer) + self.initializer = keras.initializers.deserialize(self.initializer) var_x = self.initializer(new_shape, dtype) var_x = K.permute_dimensions(var_x, [2, 0, 1, 3]) var_x = K.resize_images(var_x, @@ -136,9 +130,6 @@ def __call__(self, shape, dtype="float32"): def _space_to_depth(self, input_tensor): """ Space to depth implementation. - PlaidML does not have a space to depth operation, so calculate if backend is amd - otherwise returns the :func:`tensorflow.space_to_depth` operation. - Parameters ---------- input_tensor: tensor @@ -149,16 +140,7 @@ def _space_to_depth(self, input_tensor): tensor The manipulated input tensor """ - if get_backend() == "amd": - batch, height, width, depth = input_tensor.shape.dims - new_height = height // self.scale - new_width = width // self.scale - reshaped = K.reshape(input_tensor, - (batch, new_height, self.scale, new_width, self.scale, depth)) - retval = K.reshape(K.permute_dimensions(reshaped, [0, 1, 3, 2, 4, 5]), - (batch, new_height, new_width, -1)) - else: - retval = tf.nn.space_to_depth(input_tensor, block_size=self.scale, data_format="NHWC") + retval = tf.nn.space_to_depth(input_tensor, block_size=self.scale, data_format="NHWC") logger.debug("Input shape: %s, Output shape: %s", input_tensor.shape, retval.shape) return retval @@ -177,7 +159,7 @@ def get_config(self): return dict(list(base_config.items()) + list(config.items())) -class ConvolutionAware(initializers.Initializer): # pylint: disable=no-member +class ConvolutionAware(keras.initializers.Initializer): # type:ignore[name-defined] """ Initializer that generates orthogonal convolution filters in the Fourier space. If this initializer is passed a shape that is not 3D or 4D, orthogonal initialization will be used. @@ -210,11 +192,11 @@ class ConvolutionAware(initializers.Initializer): # pylint: disable=no-member def __init__(self, eps_std=0.05, seed=None, initialized=False): self.eps_std = eps_std self.seed = seed - self.orthogonal = initializers.Orthogonal() # pylint:disable=no-member - self.he_uniform = initializers.he_uniform() # pylint:disable=no-member + self.orthogonal = keras.initializers.Orthogonal() + self.he_uniform = keras.initializers.he_uniform() self.initialized = initialized - def __call__(self, shape, dtype=None): + def __call__(self, shape, dtype=None, **kwargs): """ Call function for the ICNR initializer. Parameters @@ -248,7 +230,7 @@ def __call__(self, shape, dtype=None): transpose_dimensions = (2, 1, 0) kernel_shape = (row,) - correct_ifft = lambda shape, s=[None]: np.fft.irfft(shape, s[0]) # noqa + correct_ifft = lambda shape, s=[None]: np.fft.irfft(shape, s[0]) # noqa:E501,E731 # pylint:disable=unnecessary-lambda-assignment correct_fft = np.fft.rfft elif rank == 4: @@ -317,12 +299,12 @@ def get_config(self): dict The configuration for ICNR Initialization """ - return dict(eps_std=self.eps_std, - seed=self.seed, - initialized=self.initialized) + return {"eps_std": self.eps_std, + "seed": self.seed, + "initialized": self.initialized} # Update initializers into Keras custom objects for name, obj in inspect.getmembers(sys.modules[__name__]): if inspect.isclass(obj) and obj.__module__ == __name__: - get_custom_objects().update({name: obj}) + keras.utils.get_custom_objects().update({name: obj}) diff --git a/lib/model/layers.py b/lib/model/layers.py index daebb453b8..42348b3202 100644 --- a/lib/model/layers.py +++ b/lib/model/layers.py @@ -8,23 +8,14 @@ import tensorflow as tf -from lib.utils import get_backend - -if get_backend() == "amd": - from lib.plaidml_utils import pad - from keras.utils import get_custom_objects, conv_utils # pylint:disable=no-name-in-module - import keras.backend as K - from keras.layers import InputSpec, Layer -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras.utils import get_custom_objects # noqa pylint:disable=no-name-in-module,import-error - from tensorflow.keras import backend as K # pylint:disable=import-error - from tensorflow.keras.layers import InputSpec, Layer # noqa pylint:disable=no-name-in-module,import-error - from tensorflow import pad # type:ignore - from tensorflow.python.keras.utils import conv_utils # pylint:disable=no-name-in-module - - -class PixelShuffler(Layer): +# Fix intellisense/linting for tf.keras' thoroughly broken import system +from tensorflow.python.keras.utils import conv_utils # pylint:disable=no-name-in-module +keras = tf.keras +layers = keras.layers +K = keras.backend + + +class PixelShuffler(keras.layers.Layer): # type:ignore[name-defined] """ PixelShuffler layer for Keras. This layer requires a Convolution2D prior to it, having output filters computed according to @@ -65,10 +56,7 @@ class PixelShuffler(Layer): """ def __init__(self, size=(2, 2), data_format=None, **kwargs): super().__init__(**kwargs) - if get_backend() == "amd": - self.data_format = K.normalize_data_format(data_format) # pylint:disable=no-member - else: - self.data_format = conv_utils.normalize_data_format(data_format) + self.data_format = conv_utils.normalize_data_format(data_format) self.size = conv_utils.normalize_tuple(size, 2, 'size') def call(self, inputs, *args, **kwargs): @@ -195,7 +183,7 @@ class name. These are handled by `Network` (one layer of abstraction above). return dict(list(base_config.items()) + list(config.items())) -class KResizeImages(Layer): +class KResizeImages(keras.layers.Layer): # type:ignore[name-defined] """ A custom upscale function that uses :class:`keras.backend.resize_images` to upsample. Parameters @@ -238,10 +226,7 @@ def call(self, inputs, *args, **kwargs): else: # Arbitrary resizing size = int(round(K.int_shape(inputs)[1] * self.size)) - if get_backend() != "amd": - retval = tf.image.resize(inputs, (size, size), method=self.interpolation) - else: - raise NotImplementedError + retval = tf.image.resize(inputs, (size, size), method=self.interpolation) return retval def compute_output_shape(self, input_shape): @@ -271,12 +256,12 @@ def get_config(self): dict A python dictionary containing the layer configuration """ - config = dict(size=self.size, interpolation=self.interpolation) + config = {"size": self.size, "interpolation": self.interpolation} base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) -class SubPixelUpscaling(Layer): +class SubPixelUpscaling(keras.layers.Layer): # type:ignore[name-defined] """ Sub-pixel convolutional up-scaling layer. This layer requires a Convolution2D prior to it, having output filters computed according to @@ -325,10 +310,7 @@ def __init__(self, scale_factor=2, data_format=None, **kwargs): super().__init__(**kwargs) self.scale_factor = scale_factor - if get_backend() == "amd": - self.data_format = K.normalize_data_format(data_format) # pylint:disable=no-member - else: - self.data_format = conv_utils.normalize_data_format(data_format) + self.data_format = conv_utils.normalize_data_format(data_format) def build(self, input_shape): """Creates the layer weights. @@ -472,7 +454,7 @@ class name. These are handled by `Network` (one layer of abstraction above). return dict(list(base_config.items()) + list(config.items())) -class ReflectionPadding2D(Layer): +class ReflectionPadding2D(keras.layers.Layer): # type:ignore[name-defined] """Reflection-padding layer for 2D input (e.g. picture). This layer can add rows and columns at the top, bottom, left and right side of an image tensor. @@ -506,7 +488,7 @@ def build(self, input_shape): Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to reference for weight shape computations. """ - self.input_spec = [InputSpec(shape=input_shape)] + self.input_spec = [keras.layers.InputSpec(shape=input_shape)] super().build(input_shape) def compute_output_shape(self, input_shape): @@ -543,7 +525,7 @@ def compute_output_shape(self, input_shape): input_shape[2] + padding_width, input_shape[3]) - def call(self, var_x, mask=None): # pylint:disable=unused-argument,arguments-differ + def call(self, inputs, *args, **kwargs): """This is where the layer's logic lives. Parameters @@ -576,12 +558,12 @@ def call(self, var_x, mask=None): # pylint:disable=unused-argument,arguments-di padding_left = padding_width // 2 padding_right = padding_width - padding_left - return pad(var_x, - [[0, 0], - [padding_top, padding_bot], - [padding_left, padding_right], - [0, 0]], - 'REFLECT') + return tf.pad(inputs, + [[0, 0], + [padding_top, padding_bot], + [padding_left, padding_right], + [0, 0]], + 'REFLECT') def get_config(self): """Returns the config of the layer. @@ -604,18 +586,15 @@ class name. These are handled by `Network` (one layer of abstraction above). return dict(list(base_config.items()) + list(config.items())) -class _GlobalPooling2D(Layer): +class _GlobalPooling2D(keras.layers.Layer): # type:ignore[name-defined] """Abstract class for different global pooling 2D layers. From keras as access to pooling is trickier in tensorflow.keras """ def __init__(self, data_format=None, **kwargs): super().__init__(**kwargs) - if get_backend() == "amd": - self.data_format = K.normalize_data_format(data_format) # pylint:disable=no-member - else: - self.data_format = conv_utils.normalize_data_format(data_format) - self.input_spec = InputSpec(ndim=4) + self.data_format = conv_utils.normalize_data_format(data_format) + self.input_spec = keras.layers.InputSpec(ndim=4) def compute_output_shape(self, input_shape): """ Compute the output shape based on the input shape. @@ -704,7 +683,7 @@ def call(self, inputs, *args, **kwargs): return pooled -class L2_normalize(Layer): # pylint:disable=invalid-name +class L2_normalize(keras.layers.Layer): # type:ignore[name-defined] # pylint:disable=invalid-name """ Normalizes a tensor w.r.t. the L2 norm alongside the specified axis. Parameters @@ -755,7 +734,7 @@ class name. These are handled by `Network` (one layer of abstraction above). return config -class Swish(Layer): +class Swish(keras.layers.Layer): # type:ignore[name-defined] """ Swish Activation Layer implementation for Keras. Parameters @@ -781,9 +760,6 @@ def call(self, inputs): # pylint:disable=arguments-differ inputs: tensor Input tensor, or list/tuple of input tensors """ - if get_backend() == "amd": - return inputs * K.sigmoid(inputs * self.beta) - # Native TF Implementation has more memory-efficient gradients return tf.nn.swish(inputs * self.beta) def get_config(self): @@ -804,4 +780,4 @@ def get_config(self): # Update layers into Keras custom objects for name, obj in inspect.getmembers(sys.modules[__name__]): if inspect.isclass(obj) and obj.__module__ == __name__: - get_custom_objects().update({name: obj}) + keras.utils.get_custom_objects().update({name: obj}) diff --git a/lib/model/loss/__init__.py b/lib/model/loss/__init__.py deleted file mode 100644 index d7c0bb2d16..0000000000 --- a/lib/model/loss/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env python3 -""" Conditional imports depending on whether the AMD version is installed or not """ - -from lib.utils import get_backend - -if get_backend() == "amd": - from . import loss_plaid as losses # noqa -else: - from . import loss_tf as losses # type:ignore # noqa diff --git a/lib/model/loss/feature_loss_plaid.py b/lib/model/loss/feature_loss_plaid.py deleted file mode 100644 index af7b4eebe8..0000000000 --- a/lib/model/loss/feature_loss_plaid.py +++ /dev/null @@ -1,381 +0,0 @@ -#!/usr/bin/env python3 -""" Custom Feature Map Loss Functions for faceswap.py """ -from dataclasses import dataclass, field -import logging - -from typing import Any, Callable, Dict, Optional, List, Tuple - -import plaidml -from keras import applications as kapp -from keras.layers import Dropout, Conv2D, Input, Layer -from keras.models import Model -import keras.backend as K - -import numpy as np - -from lib.model.nets import AlexNet, SqueezeNet -from lib.utils import GetModel - -logger = logging.getLogger(__name__) - - -@dataclass -class NetInfo: - """ Data class for holding information about Trunk and Linear Layer nets. - - Parameters - ---------- - model_id: int - The model ID for the model stored in the deepfakes Model repo - model_name: str - The filename of the decompressed model/weights file - net: callable, Optional - The net definition to load, if any. Default:``None`` - init_kwargs: dict, optional - Keyword arguments to initialize any :attr:`net`. Default: empty ``dict`` - needs_init: bool, optional - True if the net needs initializing otherwise False. Default: ``True`` - """ - model_id: int = 0 - model_name: str = "" - net: Optional[Callable] = None - init_kwargs: Dict[str, Any] = field(default_factory=dict) - needs_init: bool = True - outputs: List[Layer] = field(default_factory=list) - - -class _TrunkNormLayer(Layer): - """ Create a layer for normalizing the output of the trunk model. - - Parameters - ---------- - epsilon: float, optional - A small number to add to the normalization. Default=`1e-10` - """ - def __init__(self, epsilon: float = 1e-10, **kwargs): - super().__init__(*kwargs) - self._epsilon = epsilon - - def call(self, inputs: plaidml.tile.Value, **kwargs) -> plaidml.tile.Value: - """ Call the trunk normalization layer. - - Parameters - ---------- - inputs: :class:`plaidml.tile.Value` - Input to the trunk output normalization layer - - Returns - ------- - :class:`plaidml.tile.Value` - The output from the layer - """ - norm_factor = K.sqrt(K.sum(K.square(inputs), axis=-1, keepdims=True)) - return inputs / (norm_factor + self._epsilon) - - -class _LPIPSTrunkNet(): # pylint:disable=too-few-public-methods - """ Trunk neural network loader for LPIPS Loss function. - - Parameters - ---------- - net_name: str - The name of the trunk network to load. One of "alex", "squeeze" or "vgg16" - """ - def __init__(self, net_name: str) -> None: - logger.debug("Initializing: %s (net_name '%s')", - self.__class__.__name__, net_name) - self._net = self._nets[net_name] - logger.debug("Initialized: %s ", self.__class__.__name__) - - @property - def _nets(self) -> Dict[str, NetInfo]: - """ :class:`NetInfo`: The Information about the requested net.""" - return dict( - alex=NetInfo(model_id=15, - model_name="alexnet_imagenet_no_top_v1.h5", - net=AlexNet, - outputs=[f"features.{idx}" for idx in (0, 3, 6, 8, 10)]), - squeeze=NetInfo(model_id=16, - model_name="squeezenet_imagenet_no_top_v1.h5", - net=SqueezeNet, - outputs=[f"features.{idx}" for idx in (0, 4, 7, 9, 10, 11, 12)]), - vgg16=NetInfo(model_id=17, - model_name="vgg16_imagenet_no_top_v1.h5", - net=kapp.vgg16.VGG16, - init_kwargs=dict(include_top=False, weights=None), - outputs=[f"block{i + 1}_conv{2 if i < 2 else 3}" for i in range(5)])) - - def _process_weights(self, model: Model) -> Model: - """ Save and lock weights if requested. - - Parameters - ---------- - model :class:`keras.models.Model` - The loaded trunk or linear network - - layers: list, optional - A list of layer names to explicitly load/freeze. If ``None`` then all model - layers will be processed - - Returns - ------- - :class:`keras.models.Model` - The network with weights loaded/not loaded and layers locked/unlocked - """ - weights = GetModel(self._net.model_name, self._net.model_id).model_path - model.load_weights(weights) - model.trainable = False - for layer in model.layers: - layer.trainable = False - return model - - def __call__(self) -> Model: - """ Load the Trunk net, add normalization to feature outputs, load weights and set - trainable state. - - Returns - ------- - :class:`tensorflow.keras.models.Model` - The trunk net with normalized feature output layers - """ - if self._net.net is None: - raise ValueError("No net loaded") - - model = self._net.net(**self._net.init_kwargs) - model = model if self._net.init_kwargs else model() # Non vgg need init - out_layers = [_TrunkNormLayer()(model.get_layer(name).output) - for name in self._net.outputs] - model = Model(inputs=model.input, outputs=out_layers) - model = self._process_weights(model) - return model - - -class _LinearLayer(Layer): - """ Create a layer for normalizing the output of the trunk model. - - Parameters - ---------- - use_dropout: bool, optional - Apply a dropout layer prior to the linear layer. Default: ``False`` - """ - def __init__(self, use_dropout: float = False, **kwargs): - self._use_dropout = use_dropout - super().__init__(**kwargs) - - def call(self, inputs: plaidml.tile.Value, **kwargs) -> plaidml.tile.Value: - """ Call the trunk normalization layer. - - Parameters - ---------- - inputs: :class:`plaidml.tile.Value` - Input to the trunk output normalization layer - - Returns - ------- - :class:`plaidml.tile.Value` - The output from the layer - """ - input_ = Input(K.int_shape(inputs)[1:]) - var_x = Dropout(rate=0.5)(input_) if self._use_dropout else input_ - var_x = Conv2D(1, 1, strides=1, padding="valid", use_bias=False)(var_x) - return var_x - - -class _LPIPSLinearNet(_LPIPSTrunkNet): # pylint:disable=too-few-public-methods - """ The Linear Network to be applied to the difference between the true and predicted outputs - of the trunk network. - - Parameters - ---------- - net_name: str - The name of the trunk network in use. One of "alex", "squeeze" or "vgg16" - trunk_net: :class:`keras.models.Model` - The trunk net to place the linear layer on. - use_dropout: bool - ``True`` if a dropout layer should be used in the Linear network otherwise ``False`` - """ - def __init__(self, - net_name: str, - trunk_net: Model, - use_dropout: bool) -> None: - logger.debug( - "Initializing: %s (trunk_net: %s, use_dropout: %s)", self.__class__.__name__, - trunk_net, use_dropout) - super().__init__(net_name=net_name) - - self._trunk = trunk_net - self._use_dropout = use_dropout - - logger.debug("Initialized: %s", self.__class__.__name__) - - @property - def _nets(self) -> Dict[str, NetInfo]: - """ :class:`NetInfo`: The Information about the requested net.""" - return dict( - alex=NetInfo(model_id=18, - model_name="alexnet_lpips_v1.h5",), - squeeze=NetInfo(model_id=19, - model_name="squeezenet_lpips_v1.h5"), - vgg16=NetInfo(model_id=20, - model_name="vgg16_lpips_v1.h5")) - - def _linear_block(self, net_output_layer: plaidml.tile.Value) -> Tuple[plaidml.tile.Value, - plaidml.tile.Value]: - """ Build a linear block for a trunk network output. - - Parameters - ---------- - net_output_layer: :class:`plaidml.tile.Value` - An output from the selected trunk network - - Returns - ------- - :class:`plaidml.tile.Value` - The input to the linear block - :class:`plaidml.tile.Value` - The output from the linear block - """ - in_shape = K.int_shape(net_output_layer)[1:] - input_ = Input(in_shape) - var_x = Dropout(rate=0.5)(input_) if self._use_dropout else input_ - var_x = Conv2D(1, 1, strides=1, padding="valid", use_bias=False)(var_x) - return input_, var_x - - def __call__(self) -> Model: - """ Build the linear network for the given trunk network's outputs. Load in trained weights - and set the model's trainable parameters. - - Returns - ------- - :class:`tensorflow.keras.models.Model` - The compiled Linear Net model - """ - inputs = [] - outputs = [] - for layer in self._trunk.outputs: - inp, out = self._linear_block(layer) - inputs.append(inp) - outputs.append(out) - - linear_model = Model(inputs=inputs, outputs=outputs) - linear_model = self._process_weights(linear_model) - - return linear_model - - -class LPIPSLoss(): # pylint:disable=too-few-public-methods - """ LPIPS Loss Function. - - A perceptual loss function that uses linear outputs from pretrained CNNs feature layers. - - Notes - ----- - Channels Last implementation. All trunks implemented from the original paper. - - References - ---------- - https://richzhang.github.io/PerceptualSimilarity/ - - Parameters - ---------- - trunk_network: str - The name of the trunk network to use. One of "alex", "squeeze" or "vgg16" - linear_use_dropout: bool, optional - ``True`` if a dropout layer should be used in the Linear network otherwise ``False``. - Default: ``True`` - lpips: bool, optional - ``True`` to use linear network on top of the trunk network. ``False`` to just average the - output from the trunk network. Default ``True`` - normalize: bool, optional - ``True`` if the input Tensor needs to be normalized from the 0. to 1. range to the -1. to - 1. range. Default: ``True`` - ret_per_layer: bool, optional - ``True`` to return the loss value per feature output layer otherwise ``False``. - Default: ``False`` - """ - def __init__(self, - trunk_network: str, - linear_use_dropout: bool = True, - lpips: bool = False, # TODO This should be True - normalize: bool = True, - ret_per_layer: bool = False) -> None: - logger.debug( - "Initializing: %s (trunk_network '%s', linear_use_dropout: %s, lpips: %s, " - "normalize: %s, ret_per_layer: %s)", self.__class__.__name__, trunk_network, - linear_use_dropout, lpips, normalize, ret_per_layer) - - self._use_lpips = lpips - self._normalize = normalize - self._ret_per_layer = ret_per_layer - self._shift = K.constant(np.array([-.030, -.088, -.188], - dtype="float32")[None, None, None, :]) - self._scale = K.constant(np.array([.458, .448, .450], - dtype="float32")[None, None, None, :]) - - self._trunk_net = _LPIPSTrunkNet(trunk_network)() - self._linear_net = _LPIPSLinearNet(trunk_network, self._trunk_net, linear_use_dropout)() - - logger.debug("Initialized: %s", self.__class__.__name__) - - def _process_diffs(self, inputs: List[plaidml.tile.Value]) -> List[plaidml.tile.Value]: - """ Perform processing on the Trunk Network outputs. - - If :attr:`use_ldip` is enabled, process the diff values through the linear network, - otherwise return the diff values summed on the channels axis. - - Parameters - ---------- - inputs: list - List of the squared difference of the true and predicted outputs from the trunk network - - Returns - ------- - list - List of either the linear network outputs (when using lpips) or summed network outputs - """ - if self._use_lpips: - # TODO Fix. Whilst the linear layer compiles and the weights load, PlaidML will - # error out as the graph is disconnected. - # The trunk output can be plugged straight into Linear input, but then weights for - # linear cannot be loaded, and this input would be incorrect (as linear input should - # be the diff between y_true and y_pred) - raise NotImplementedError - return self._linear_net(inputs) # pylint:disable=unreachable - return [K.sum(x, axis=-1) for x in inputs] - - def __call__(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Perform the LPIPS Loss Function. - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The ground truth batch of images - y_pred: :class:`plaidml.tile.Value` - The predicted batch of images - - Returns - ------- - :class:`plaidml.tile.Value` - The final loss value - """ - if self._normalize: - y_true = (y_true * 2.0) - 1.0 - y_pred = (y_pred * 2.0) - 1.0 - - y_true = (y_true - self._shift) / self._scale - y_pred = (y_pred - self._shift) / self._scale - - net_true = self._trunk_net(y_true) - net_pred = self._trunk_net(y_pred) - - diffs = [K.pow((out_true - out_pred), 2) - for out_true, out_pred in zip(net_true, net_pred)] - - res = [K.mean(diff, axis=(1, 2), keepdims=True) for diff in self._process_diffs(diffs)] - - val = K.sum(K.concatenate(res), axis=None) - - retval = (val, res) if self._ret_per_layer else val - return retval / 10.0 # Reduce by factor of 10 'cos this loss is STRONG diff --git a/lib/model/loss/loss_plaid.py b/lib/model/loss/loss_plaid.py deleted file mode 100644 index 8718613865..0000000000 --- a/lib/model/loss/loss_plaid.py +++ /dev/null @@ -1,562 +0,0 @@ -#!/usr/bin/env python3 -""" Custom Loss Functions for faceswap.py """ - -from __future__ import absolute_import - -import logging -from typing import Callable, List, Tuple - -import numpy as np -import plaidml - -from keras import backend as K -from lib.plaidml_utils import pad -from lib.utils import FaceswapError - -from .feature_loss_plaid import LPIPSLoss #pylint:disable=unused-import # noqa -from .perceptual_loss_plaid import DSSIMObjective, GMSDLoss, LDRFLIPLoss, MSSIMLoss #pylint:disable=unused-import # noqa - -logger = logging.getLogger(__name__) # pylint:disable=invalid-name - - -class FocalFrequencyLoss(): # pylint:disable=too-few-public-methods - """ Focal Frequencey Loss Function. - - A channels last implementation. - - Notes - ----- - There is a bug in this implementation that will do an incorrect FFT if - :attr:`patch_factor` > ``1``, which means incorrect loss will be returned, so keep - patch factor at 1. - - Parameters - ---------- - alpha: float, Optional - Scaling factor of the spectrum weight matrix for flexibility. Default: ``1.0`` - patch_factor: int, Optional - Factor to crop image patches for patch-based focal frequency loss. - Default: ``1`` - ave_spectrum: bool, Optional - ``True`` to use minibatch average spectrum otherwise ``False``. Default: ``False`` - log_matrix: bool, Optional - ``True`` to adjust the spectrum weight matrix by logarithm otherwise ``False``. - Default: ``False`` - batch_matrix: bool, Optional - ``True`` to calculate the spectrum weight matrix using batch-based statistics otherwise - ``False``. Default: ``False`` - - References - ---------- - https://arxiv.org/pdf/2012.12821.pdf - https://github.com/EndlessSora/focal-frequency-loss - """ - - def __init__(self, - alpha: float = 1.0, - patch_factor: int = 1, - ave_spectrum: bool = False, - log_matrix: bool = False, - batch_matrix: bool = False) -> None: - self._alpha = alpha - self._patch_factor = patch_factor - self._ave_spectrum = ave_spectrum - self._log_matrix = log_matrix - self._batch_matrix = batch_matrix - self._dims: Tuple[int, int] = (0, 0) - - def __call__(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Call the Focal Frequency Loss Function. - - # TODO Not implemented as: - - We need a PlaidML replacement for tf.signal - - The dimensions do not appear to be readable for y_pred - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The ground truth batch of images - y_pred: :class:`plaidml.tile.Value` - The predicted batch of images - - Returns - ------- - :class:`plaidml.tile.Value` - The loss for this batch of images - """ - raise FaceswapError("Focal Frequency Loss is not currently compatible with PlaidML. " - "Please select a different Loss method.") - - -class GeneralizedLoss(): # pylint:disable=too-few-public-methods - """ Generalized function used to return a large variety of mathematical loss functions. - - The primary benefit is a smooth, differentiable version of L1 loss. - - References - ---------- - Barron, J. A More General Robust Loss Function - https://arxiv.org/pdf/1701.03077.pdf - - Example - ------- - >>> a=1.0, x>>c , c=1.0/255.0 # will give a smoothly differentiable version of L1 / MAE loss - >>> a=1.999999 (limit as a->2), beta=1.0/255.0 # will give L2 / RMSE loss - - Parameters - ---------- - alpha: float, optional - Penalty factor. Larger number give larger weight to large deviations. Default: `1.0` - beta: float, optional - Scale factor used to adjust to the input scale (i.e. inputs of mean `1e-4` or `256`). - Default: `1.0/255.0` - """ - def __init__(self, alpha: float = 1.0, beta: float = 1.0/255.0) -> None: - self._alpha = alpha - self._beta = beta - - def __call__(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Call the Generalized Loss Function - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The ground truth value - y_pred: :class:`plaidml.tile.Value` - The predicted value - - Returns - ------- - :class:`plaidml.tile.Value` - The loss value from the results of function(y_pred - y_true) - """ - diff = y_pred - y_true - second = (K.pow(K.pow(diff/self._beta, 2.) / K.abs(2. - self._alpha) + 1., - (self._alpha / 2.)) - 1.) - loss = (K.abs(2. - self._alpha)/self._alpha) * second - loss = K.mean(loss, axis=-1) * self._beta - return loss - - -class GradientLoss(): # pylint:disable=too-few-public-methods - """ Gradient Loss Function. - - Calculates the first and second order gradient difference between pixels of an image in the x - and y dimensions. These gradients are then compared between the ground truth and the predicted - image and the difference is taken. When used as a loss, its minimization will result in - predicted images approaching the same level of sharpness / blurriness as the ground truth. - - References - ---------- - TV+TV2 Regularization with Non-Convex Sparseness-Inducing Penalty for Image Restoration, - Chengwu Lu & Hua Huang, 2014 - http://downloads.hindawi.com/journals/mpe/2014/790547.pdf - """ - def __init__(self): - self.generalized_loss = GeneralizedLoss(alpha=1.9999) - - def __call__(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Call the gradient loss function. - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The ground truth value - y_pred: tensor or variable - :class:`plaidml.tile.Value` - - Returns - ------- - :class:`plaidml.tile.Value` - The loss value - """ - tv_weight = 1.0 - tv2_weight = 1.0 - loss = 0.0 - loss += tv_weight * (self.generalized_loss(self._diff_x(y_true), self._diff_x(y_pred)) + - self.generalized_loss(self._diff_y(y_true), self._diff_y(y_pred))) - loss += tv2_weight * (self.generalized_loss(self._diff_xx(y_true), self._diff_xx(y_pred)) + - self.generalized_loss(self._diff_yy(y_true), self._diff_yy(y_pred)) + - self.generalized_loss(self._diff_xy(y_true), self._diff_xy(y_pred)) - * 2.) - loss = loss / (tv_weight + tv2_weight) - # TODO simplify to use MSE instead - return loss - - @classmethod - def _diff_x(cls, img): - """ X Difference """ - x_left = img[:, :, 1:2, :] - img[:, :, 0:1, :] - x_inner = img[:, :, 2:, :] - img[:, :, :-2, :] - x_right = img[:, :, -1:, :] - img[:, :, -2:-1, :] - x_out = K.concatenate([x_left, x_inner, x_right], axis=2) - return x_out * 0.5 - - @classmethod - def _diff_y(cls, img): - """ Y Difference """ - y_top = img[:, 1:2, :, :] - img[:, 0:1, :, :] - y_inner = img[:, 2:, :, :] - img[:, :-2, :, :] - y_bot = img[:, -1:, :, :] - img[:, -2:-1, :, :] - y_out = K.concatenate([y_top, y_inner, y_bot], axis=1) - return y_out * 0.5 - - @classmethod - def _diff_xx(cls, img): - """ X-X Difference """ - x_left = img[:, :, 1:2, :] + img[:, :, 0:1, :] - x_inner = img[:, :, 2:, :] + img[:, :, :-2, :] - x_right = img[:, :, -1:, :] + img[:, :, -2:-1, :] - x_out = K.concatenate([x_left, x_inner, x_right], axis=2) - return x_out - 2.0 * img - - @classmethod - def _diff_yy(cls, img): - """ Y-Y Difference """ - y_top = img[:, 1:2, :, :] + img[:, 0:1, :, :] - y_inner = img[:, 2:, :, :] + img[:, :-2, :, :] - y_bot = img[:, -1:, :, :] + img[:, -2:-1, :, :] - y_out = K.concatenate([y_top, y_inner, y_bot], axis=1) - return y_out - 2.0 * img - - @classmethod - def _diff_xy(cls, img: plaidml.tile.Value) -> plaidml.tile.Value: - """ X-Y Difference """ - # xout1 - # Left - top = img[:, 1:2, 1:2, :] + img[:, 0:1, 0:1, :] - inner = img[:, 2:, 1:2, :] + img[:, :-2, 0:1, :] - bottom = img[:, -1:, 1:2, :] + img[:, -2:-1, 0:1, :] - xy_left = K.concatenate([top, inner, bottom], axis=1) - # Mid - top = img[:, 1:2, 2:, :] + img[:, 0:1, :-2, :] - mid = img[:, 2:, 2:, :] + img[:, :-2, :-2, :] - bottom = img[:, -1:, 2:, :] + img[:, -2:-1, :-2, :] - xy_mid = K.concatenate([top, mid, bottom], axis=1) - # Right - top = img[:, 1:2, -1:, :] + img[:, 0:1, -2:-1, :] - inner = img[:, 2:, -1:, :] + img[:, :-2, -2:-1, :] - bottom = img[:, -1:, -1:, :] + img[:, -2:-1, -2:-1, :] - xy_right = K.concatenate([top, inner, bottom], axis=1) - - # Xout2 - # Left - top = img[:, 0:1, 1:2, :] + img[:, 1:2, 0:1, :] - inner = img[:, :-2, 1:2, :] + img[:, 2:, 0:1, :] - bottom = img[:, -2:-1, 1:2, :] + img[:, -1:, 0:1, :] - xy_left = K.concatenate([top, inner, bottom], axis=1) - # Mid - top = img[:, 0:1, 2:, :] + img[:, 1:2, :-2, :] - mid = img[:, :-2, 2:, :] + img[:, 2:, :-2, :] - bottom = img[:, -2:-1, 2:, :] + img[:, -1:, :-2, :] - xy_mid = K.concatenate([top, mid, bottom], axis=1) - # Right - top = img[:, 0:1, -1:, :] + img[:, 1:2, -2:-1, :] - inner = img[:, :-2, -1:, :] + img[:, 2:, -2:-1, :] - bottom = img[:, -2:-1, -1:, :] + img[:, -1:, -2:-1, :] - xy_right = K.concatenate([top, inner, bottom], axis=1) - - xy_out1 = K.concatenate([xy_left, xy_mid, xy_right], axis=2) - xy_out2 = K.concatenate([xy_left, xy_mid, xy_right], axis=2) - return (xy_out1 - xy_out2) * 0.25 - - -class LaplacianPyramidLoss(): # pylint:disable=too-few-public-methods - """ Laplacian Pyramid Loss Function - - Notes - ----- - Channels last implementation on square images only. - - Parameters - ---------- - max_levels: int, Optional - The max number of laplacian pyramid levels to use. Default: `5` - gaussian_size: int, Optional - The size of the gaussian kernel. Default: `5` - gaussian_sigma: float, optional - The gaussian sigma. Default: 2.0 - - References - ---------- - https://arxiv.org/abs/1707.05776 - https://github.com/nathanaelbosch/generative-latent-optimization/blob/master/utils.py - """ - def __init__(self, - max_levels: int = 5, - gaussian_size: int = 5, - gaussian_sigma: float = 1.0) -> None: - self._max_levels = max_levels - self._weights = K.constant([np.power(2., -2 * idx) for idx in range(max_levels + 1)]) - self._gaussian_kernel = self._get_gaussian_kernel(gaussian_size, gaussian_sigma) - self._shape: Tuple[int, ...] = () - - @classmethod - def _get_gaussian_kernel(cls, size: int, sigma: float) -> plaidml.tile.Value: - """ Obtain the base gaussian kernel for the Laplacian Pyramid. - - Parameters - ---------- - size: int, Optional - The size of the gaussian kernel - sigma: float - The gaussian sigma - - Returns - ------- - :class:`plaidml.tile.Value` - The base single channel Gaussian kernel - """ - assert size % 2 == 1, ("kernel size must be uneven") - x_1 = np.linspace(- (size // 2), size // 2, size, dtype="float32") - x_1 /= np.sqrt(2)*sigma - x_2 = x_1 ** 2 - kernel = np.exp(- x_2[:, None] - x_2[None, :]) - kernel /= kernel.sum() - kernel = np.reshape(kernel, (size, size, 1, 1)) - return K.constant(kernel) - - def _conv_gaussian(self, inputs: plaidml.tile.Value) -> plaidml.tile.Value: - """ Perform Gaussian convolution on a batch of images. - - Parameters - ---------- - inputs: :class:`plaidml.tile.Value` - The input batch of images to perform Gaussian convolution on. - - Returns - ------- - :class:`plaidml.tile.Value` - The convolved images - """ - channels = self._shape[-1] - gauss = K.tile(self._gaussian_kernel, (1, 1, 1, channels)) - - # PlaidML doesn't implement replication padding like pytorch. This is an inefficient way to - # implement it for a square guassian kernel - size = K.int_shape(self._gaussian_kernel)[1] // 2 - padded_inputs = inputs - for _ in range(size): - padded_inputs = pad(padded_inputs, # noqa,pylint:disable=no-value-for-parameter,unexpected-keyword-arg - ([0, 0], [1, 1], [1, 1], [0, 0]), - mode="REFLECT") - - retval = K.conv2d(padded_inputs, gauss, strides=(1, 1), padding="valid") - return retval - - def _get_laplacian_pyramid(self, inputs: plaidml.tile.Value) -> List[plaidml.tile.Value]: - """ Obtain the Laplacian Pyramid. - - Parameters - ---------- - inputs: :class:`plaidml.tile.Value` - The input batch of images to run through the Laplacian Pyramid - - Returns - ------- - list - The tensors produced from the Laplacian Pyramid - """ - pyramid = [] - current = inputs - for _ in range(self._max_levels): - gauss = self._conv_gaussian(current) - diff = current - gauss - pyramid.append(diff) - current = K.pool2d(gauss, (2, 2), strides=(2, 2), padding="valid", pool_mode="avg") - pyramid.append(current) - return pyramid - - def __call__(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Calculate the Laplacian Pyramid Loss. - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The ground truth value - y_pred: :class:`plaidml.tile.Value` - The predicted value - - Returns - ------- - :class: `plaidml.tile.Value` - The loss value - """ - if not self._shape: - self._shape = K.int_shape(y_pred) - pyramid_true = self._get_laplacian_pyramid(y_true) - pyramid_pred = self._get_laplacian_pyramid(y_pred) - - losses = K.stack([K.sum(K.abs(ppred - ptrue)) / K.cast(K.prod(K.shape(ptrue)), "float32") - for ptrue, ppred in zip(pyramid_true, pyramid_pred)]) - loss = K.sum(losses * self._weights) - return loss - - -class LInfNorm(): # pylint:disable=too-few-public-methods - """ Calculate the L-inf norm as a loss function. """ - - def __call__(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Call the L-inf norm loss function. - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The ground truth value - y_pred: :class:`plaidml.tile.Value` - The predicted value - - Returns - ------- - :class:`plaidml.tile.Value` - The loss value - """ - diff = K.abs(y_true - y_pred) - max_loss = K.max(diff, axis=(1, 2), keepdims=True) - loss = K.mean(max_loss, axis=-1) - return loss - - -class LogCosh(): # pylint:disable=too-few-public-methods - """Logarithm of the hyperbolic cosine of the prediction error. - - `log(cosh(x))` is approximately equal to `(x ** 2) / 2` for small `x` and to `abs(x) - log(2)` - for large `x`. This means that 'logcosh' works mostly like the mean squared error, but will not - be so strongly affected by the occasional wildly incorrect prediction. - """ - def __call__(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Call the LogCosh loss function. - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The ground truth value - y_pred: :class:`plaidml.tile.Value` - The predicted value - - Returns - ------- - :class:`plaidml.tile.Value` - The loss value - """ - diff = y_pred - y_true - loss = diff + K.softplus(-2. * diff) - K.log(K.constant(2., dtype="float32")) - return K.mean(loss, axis=-1) - - -class LossWrapper(): # pylint:disable=too-few-public-methods - """ A wrapper class for multiple keras losses to enable multiple weighted loss functions on a - single output and masking. - """ - def __init__(self) -> None: - self.__name__ = "LossWrapper" - logger.debug("Initializing: %s", self.__class__.__name__) - self._loss_functions: List[Callable] = [] - self._loss_weights: List[float] = [] - self._mask_channels: List[int] = [] - logger.debug("Initialized: %s", self.__class__.__name__) - - def add_loss(self, - function, - weight: float = 1.0, - mask_channel: int = -1) -> None: - """ Add the given loss function with the given weight to the loss function chain. - - Parameters - ---------- - function: :class:`keras.losses.Loss` - The loss function to add to the loss chain - weight: float, optional - The weighting to apply to the loss function. Default: `1.0` - mask_channel: int, optional - The channel in the `y_true` image that the mask exists in. Set to `-1` if there is no - mask for the given loss function. Default: `-1` - """ - logger.debug("Adding loss: (function: %s, weight: %s, mask_channel: %s)", - function, weight, mask_channel) - self._loss_functions.append(function) - self._loss_weights.append(weight) - self._mask_channels.append(mask_channel) - - def __call__(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Call the sub loss functions for the loss wrapper. - - Weights are returned as the weighted sum of the chosen losses. - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The ground truth value - y_pred: :class:`plaidml.tile.Value` - The predicted value - - Returns - ------- - :class:`plaidml.tile.Value` - The final loss value - """ - loss = 0.0 - for func, weight, mask_channel in zip(self._loss_functions, - self._loss_weights, - self._mask_channels): - logger.debug("Processing loss function: (func: %s, weight: %s, mask_channel: %s)", - func, weight, mask_channel) - n_true, n_pred = self._apply_mask(y_true, y_pred, mask_channel) - # Some loss functions requires that y_pred be of a known shape, so specifically - # reshape the tensor. - n_pred = K.reshape(n_pred, K.int_shape(y_pred)) - this_loss = func(n_true, n_pred) - loss_dims = K.ndim(this_loss) - loss += (K.mean(this_loss, axis=list(range(1, loss_dims))) * weight) - return loss - - @classmethod - def _apply_mask(cls, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value, - mask_channel: int, - mask_prop: float = 1.0) -> Tuple[plaidml.tile.Value, plaidml.tile.Value]: - """ Apply the mask to the input y_true and y_pred. If a mask is not required then - return the unmasked inputs. - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The ground truth value - y_pred: :class:`plaidml.tile.Value` - The predicted value - mask_channel: int - The channel within y_true that the required mask resides in - mask_prop: float, optional - The amount of mask propagation. Default: `1.0` - - Returns - ------- - tuple - (n_true, n_pred): The ground truth and predicted value tensors with the mask applied - """ - if mask_channel == -1: - logger.debug("No mask to apply") - return y_true[..., :3], y_pred[..., :3] - - logger.debug("Applying mask from channel %s", mask_channel) - - mask = K.tile(K.expand_dims(y_true[..., mask_channel], axis=-1), (1, 1, 1, 3)) - mask_as_k_inv_prop = 1 - mask_prop - mask = (mask * mask_prop) + mask_as_k_inv_prop - - m_true = y_true[..., :3] * mask - m_pred = y_pred[..., :3] * mask - - return m_true, m_pred diff --git a/lib/model/loss/perceptual_loss_plaid.py b/lib/model/loss/perceptual_loss_plaid.py deleted file mode 100644 index be52822b13..0000000000 --- a/lib/model/loss/perceptual_loss_plaid.py +++ /dev/null @@ -1,849 +0,0 @@ -#!/usr/bin/env python3 -""" PlaidML Keras implementation of Perceptual Loss Functions for faceswap.py """ - -import logging -import sys - -from typing import Dict, List, Optional, Tuple - -import numpy as np -import plaidml - -from keras import backend as K - -from lib.keras_utils import ColorSpaceConvert, frobenius_norm, replicate_pad -from lib.plaidml_utils import pad -from lib.utils import FaceswapError - -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - - -logger = logging.getLogger(__name__) - - -class DSSIMObjective(): # pylint:disable=too-few-public-methods - """ DSSIM Loss Function - - Difference of Structural Similarity (DSSIM loss function). - - Adapted from :func:`tensorflow.image.ssim` for a pure keras implentation. - - Notes - ----- - Channels last only. Assumes all input images are the same size and square - - Parameters - ---------- - k_1: float, optional - Parameter of the SSIM. Default: `0.01` - k_2: float, optional - Parameter of the SSIM. Default: `0.03` - filter_size: int, optional - size of gaussian filter Default: `11` - filter_sigma: float, optional - Width of gaussian filter Default: `1.5` - max_value: float, optional - Max value of the output. Default: `1.0` - - Notes - ------ - You should add a regularization term like a l2 loss in addition to this one. - """ - def __init__(self, - k_1: float = 0.01, - k_2: float = 0.03, - filter_size: int = 11, - filter_sigma: float = 1.5, - max_value: float = 1.0) -> None: - self._filter_size = filter_size - self._filter_sigma = filter_sigma - self._kernel = self._get_kernel() - - compensation = 1.0 - self._c1 = (k_1 * max_value) ** 2 - self._c2 = ((k_2 * max_value) ** 2) * compensation - - def _get_kernel(self) -> plaidml.tile.Value: - """ Obtain the base kernel for performing depthwise convolution. - - Returns - ------- - :class:`plaidml.tile.Value` - The gaussian kernel based on selected size and sigma - """ - coords = np.arange(self._filter_size, dtype="float32") - coords -= (self._filter_size - 1) / 2. - - kernel = np.square(coords) - kernel *= -0.5 / np.square(self._filter_sigma) - kernel = np.reshape(kernel, (1, -1)) + np.reshape(kernel, (-1, 1)) - kernel = K.constant(np.reshape(kernel, (1, -1))) - kernel = K.softmax(kernel) - kernel = K.reshape(kernel, (self._filter_size, self._filter_size, 1, 1)) - return kernel - - @classmethod - def _depthwise_conv2d(cls, - image: plaidml.tile.Value, - kernel: plaidml.tile.Value) -> plaidml.tile.Value: - """ Perform a standardized depthwise convolution. - - Parameters - ---------- - image: :class:`plaidml.tile.Value` - Batch of images, channels last, to perform depthwise convolution - kernel: :class:`plaidml.tile.Value` - convolution kernel - - Returns - ------- - :class:`plaidml.tile.Value` - The output from the convolution - """ - return K.depthwise_conv2d(image, kernel, strides=(1, 1), padding="valid") - - def _get_ssim(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> Tuple[plaidml.tile.Value, plaidml.tile.Value]: - """ Obtain the structural similarity between a batch of true and predicted images. - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The input batch of ground truth images - y_pred: :class:`plaidml.tile.Value` - The input batch of predicted images - - Returns - ------- - :class:`plaidml.tile.Value` - The SSIM for the given images - :class:`plaidml.tile.Value` - The Contrast for the given images - """ - channels = K.int_shape(y_pred)[-1] - kernel = K.tile(self._kernel, (1, 1, channels, 1)) - - # SSIM luminance measure is (2 * mu_x * mu_y + c1) / (mu_x ** 2 + mu_y ** 2 + c1) - mean_true = self._depthwise_conv2d(y_true, kernel) - mean_pred = self._depthwise_conv2d(y_pred, kernel) - num_lum = mean_true * mean_pred * 2.0 - den_lum = K.square(mean_true) + K.square(mean_pred) - luminance = (num_lum + self._c1) / (den_lum + self._c1) - - # SSIM contrast-structure measure is (2 * cov_{xy} + c2) / (cov_{xx} + cov_{yy} + c2) - num_con = self._depthwise_conv2d(y_true * y_pred, kernel) * 2.0 - den_con = self._depthwise_conv2d(K.square(y_true) + K.square(y_pred), kernel) - - contrast = (num_con - num_lum + self._c2) / (den_con - den_lum + self._c2) - - # Average over the height x width dimensions - axes = (-3, -2) - ssim = K.mean(luminance * contrast, axis=axes) - contrast = K.mean(contrast, axis=axes) - - return ssim, contrast - - def __call__(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Call the DSSIM or MS-DSSIM Loss Function. - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The input batch of ground truth images - y_pred: :class:`plaidml.tile.Value` - The input batch of predicted images - - Returns - ------- - :class:`plaidml.tile.Value` - The DSSIM or MS-DSSIM for the given images - """ - ssim = self._get_ssim(y_true, y_pred)[0] - retval = (1. - ssim) / 2.0 - return K.mean(retval) - - -class GMSDLoss(): # pylint:disable=too-few-public-methods - """ Gradient Magnitude Similarity Deviation Loss. - - Improved image quality metric over MS-SSIM with easier calculations - - References - ---------- - http://www4.comp.polyu.edu.hk/~cslzhang/IQA/GMSD/GMSD.htm - https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf - """ - def __call__(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Return the Gradient Magnitude Similarity Deviation Loss. - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The ground truth value - y_pred: :class:`plaidml.tile.Value` - The predicted value - - Returns - ------- - :class:`plaidml.tile.Value` - The loss value - """ - image_shape = K.int_shape(y_pred) - true_edge = self._scharr_edges(y_true, True, image_shape) - pred_edge = self._scharr_edges(y_pred, True, image_shape) - ephsilon = 0.0025 - upper = 2.0 * true_edge * pred_edge - lower = K.square(true_edge) + K.square(pred_edge) - gms = (upper + ephsilon) / (lower + ephsilon) - gmsd = K.std(gms, axis=(1, 2, 3), keepdims=True) - gmsd = K.squeeze(gmsd, axis=-1) - return gmsd - - @classmethod - def _scharr_edges(cls, - image: plaidml.tile.Value, - magnitude: bool, - image_shape: Tuple[None, int, int, int]) -> plaidml.tile.Value: - """ Returns a tensor holding modified Scharr edge maps. - - Parameters - ---------- - image: :class:`plaidml.tile.Value` - Image tensor with shape [batch_size, h, w, d] and type float32. The image(s) must be - 2x2 or larger. - magnitude: bool - Boolean to determine if the edge magnitude or edge direction is returned - image_shape: tuple - The shape of the incoming image - - Returns - ------- - :class:`plaidml.tile.Value` - Tensor holding edge maps for each channel. Returns a tensor with shape `[batch_size, h, - w, d, 2]` where the last two dimensions hold `[[dy[0], dx[0]], [dy[1], dx[1]], ..., - [dy[d-1], dx[d-1]]]` calculated using the Scharr filter. - """ - # Define vertical and horizontal Scharr filters. - # 5x5 modified Scharr kernel ( reshape to (5,5,1,2) ) - matrix = np.array([[[[0.00070, 0.00070]], - [[0.00520, 0.00370]], - [[0.03700, 0.00000]], - [[0.00520, -0.0037]], - [[0.00070, -0.0007]]], - [[[0.00370, 0.00520]], - [[0.11870, 0.11870]], - [[0.25890, 0.00000]], - [[0.11870, -0.1187]], - [[0.00370, -0.0052]]], - [[[0.00000, 0.03700]], - [[0.00000, 0.25890]], - [[0.00000, 0.00000]], - [[0.00000, -0.2589]], - [[0.00000, -0.0370]]], - [[[-0.0037, 0.00520]], - [[-0.1187, 0.11870]], - [[-0.2589, 0.00000]], - [[-0.1187, -0.1187]], - [[-0.0037, -0.0052]]], - [[[-0.0007, 0.00070]], - [[-0.0052, 0.00370]], - [[-0.0370, 0.00000]], - [[-0.0052, -0.0037]], - [[-0.0007, -0.0007]]]]) - # num_kernels = [2] - kernels = K.constant(matrix, dtype='float32') - kernels = K.tile(kernels, [1, 1, image_shape[-1], 1]) - - # Use depth-wise convolution to calculate edge maps per channel. - # Output tensor has shape [batch_size, h, w, d * num_kernels]. - pad_sizes = [[0, 0], [2, 2], [2, 2], [0, 0]] - padded = pad(image, pad_sizes, mode='REFLECT') - output = K.depthwise_conv2d(padded, kernels) - - # TODO magnitude not implemented for plaidml - if not magnitude: # direction of edges - raise FaceswapError("Magnitude for GMSD Loss is not implemented in PlaidML") - # # Reshape to [batch_size, h, w, d, num_kernels]. - # shape = K.concatenate([image_shape, num_kernels], axis=0) - # output = K.reshape(output, shape=shape) - # output.set_shape(static_image_shape.concatenate(num_kernels)) - # output = tf.atan(K.squeeze(output[:, :, :, :, 0] / output[:, :, :, :, 1], axis=None)) - # magnitude of edges -- unified x & y edges don't work well with Neural Networks - return output - - -class LDRFLIPLoss(): # pylint:disable=too-few-public-methods - """ Computes the LDR-FLIP error map between two LDR images, assuming the images are observed - at a certain number of pixels per degree of visual angle. - - References - ---------- - https://research.nvidia.com/sites/default/files/node/3260/FLIP_Paper.pdf - https://github.com/NVlabs/flip - - License - ------- - BSD 3-Clause License - Copyright (c) 2020-2022, NVIDIA Corporation & AFFILIATES. All rights reserved. - Redistribution and use in source and binary forms, with or without modification, are permitted - provided that the following conditions are met: - Redistributions of source code must retain the above copyright notice, this list of conditions - and the following disclaimer. - Redistributions in binary form must reproduce the above copyright notice, this list of - conditions and the following disclaimer in the documentation and/or other materials provided - with the distribution. - Neither the name of the copyright holder nor the names of its contributors may be used to - endorse or promote products derived from this software without specific prior written - permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR - IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY - AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR - CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR - SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR - OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - POSSIBILITY OF SUCH DAMAGE. - - Parameters - ---------- - computed_distance_exponent: float, Optional - The computed distance exponent to apply to Hunt adjusted, filtered colors. - (`qc` in original paper). Default: `0.7` - feature_exponent: float, Optional - The feature exponent to apply for increasing the impact of feature difference on the - final loss value. (`qf` in original paper). Default: `0.5` - lower_threshold_exponent: float, Optional - The `pc` exponent for the color pipeline as described in the original paper: Default: `0.4` - upper_threshold_exponent: float, Optional - The `pt` exponent for the color pipeline as described in the original paper. - Default: `0.95` - epsilon: float - A small value to improve training stability. Default: `1e-15` - pixels_per_degree: float, Optional - The estimated number of pixels per degree of visual angle of the observer. This effectively - impacts the tolerance when calculating loss. The default corresponds to viewing images on a - 0.7m wide 4K monitor at 0.7m from the display. Default: ``None`` - color_order: str - The `"BGR"` or `"RGB"` color order of the incoming images - """ - def __init__(self, - computed_distance_exponent: float = 0.7, - feature_exponent: float = 0.5, - lower_threshold_exponent: float = 0.4, - upper_threshold_exponent: float = 0.95, - epsilon: float = 1e-15, - pixels_per_degree: Optional[float] = None, - color_order: Literal["bgr", "rgb"] = "bgr") -> None: - logger.debug("Initializing: %s (computed_distance_exponent '%s', feature_exponent: %s, " - "lower_threshold_exponent: %s, upper_threshold_exponent: %s, epsilon: %s, " - "pixels_per_degree: %s, color_order: %s)", self.__class__.__name__, - computed_distance_exponent, feature_exponent, lower_threshold_exponent, - upper_threshold_exponent, epsilon, pixels_per_degree, color_order) - - self._computed_distance_exponent = computed_distance_exponent - self._feature_exponent = feature_exponent - self._pc = lower_threshold_exponent - self._pt = upper_threshold_exponent - self._epsilon = epsilon - self._color_order = color_order.lower() - - if pixels_per_degree is None: - pixels_per_degree = (0.7 * 3840 / 0.7) * np.pi / 180 - self._pixels_per_degree = pixels_per_degree - self._spatial_filters = _SpatialFilters(pixels_per_degree) - self._feature_detector = _FeatureDetection(pixels_per_degree) - logger.debug("Initialized: %s ", self.__class__.__name__) - - def __call__(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Call the LDR Flip Loss Function - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The ground truth batch of images - y_pred: :class:`plaidml.tile.Value` - The predicted batch of images - - Returns - ------- - :class::class:`plaidml.tile.Value` - The calculated Flip loss value - """ - # TODO Fix for AMD. This loss function runs fine under plaidML end to end, but the output - # is NaN when tested on CPU. I cannot find a way to debug the values in plaidML tensors - # so cannot investigate where the NaNs are getting introduced. - # This may be a CPU issue (I cannot get plaidML to detect my Nvidia GPU) so currently this - # loss is enabled. If reports of NaNs then raise a NotImplementedError until issue can be - # properly addressed - if self._color_order == "bgr": # Switch models training in bgr order to rgb - y_true = y_true[..., 2::-1] - y_pred = y_pred[..., 2::-1] - - y_true = K.clip(y_true, 0, 1.) - y_pred = K.clip(y_pred, 0, 1.) - - rgb2ycxcz = ColorSpaceConvert("srgb", "ycxcz", batch_shape=K.int_shape(y_pred)) - true_ycxcz = rgb2ycxcz(y_true) - pred_ycxcz = rgb2ycxcz(y_pred) - - delta_e_color = self._color_pipeline(true_ycxcz, pred_ycxcz) - delta_e_features = self._process_features(true_ycxcz, pred_ycxcz) - - loss = K.pow(delta_e_color, 1 - delta_e_features) - return loss - - def _color_pipeline(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Perform the color processing part of the FLIP loss function - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The ground truth batch of images in YCxCz color space - y_pred: :class:`plaidml.tile.Value` - The predicted batch of images in YCxCz color space - - Returns - ------- - :class:`plaidml.tile.Value` - The exponentiated, maximum HyAB difference between two colors in Hunt-adjusted - L*A*B* space - """ - filtered_true = self._spatial_filters(y_true) - filtered_pred = self._spatial_filters(y_pred) - - rgb2lab = ColorSpaceConvert(from_space="rgb", - to_space="lab", - batch_shape=K.int_shape(filtered_pred)) - preprocessed_true = self._hunt_adjustment(rgb2lab(filtered_true)) - preprocessed_pred = self._hunt_adjustment(rgb2lab(filtered_pred)) - hunt_adjusted_green = self._hunt_adjustment( - rgb2lab(K.constant(np.array([[[[0.0, 1.0, 0.0]]]]), dtype="float32"))) - hunt_adjusted_blue = self._hunt_adjustment( - rgb2lab(K.constant(np.array([[[[0.0, 0.0, 1.0]]]]), dtype="float32"))) - - delta = self._hyab(preprocessed_true, preprocessed_pred) - power_delta = K.pow(delta, self._computed_distance_exponent) - cmax = K.pow(self._hyab(hunt_adjusted_green, hunt_adjusted_blue), - self._computed_distance_exponent) - return self._redistribute_errors(power_delta, cmax) - - def _process_features(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Perform the color processing part of the FLIP loss function - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The ground truth batch of images in YCxCz color space - y_pred: :class:`plaidml.tile.Value` - The predicted batch of images in YCxCz color space - - Returns - ------- - :class:`plaidml.tile.Value` - The exponentiated features delta - """ - col_y_true = (y_true[..., 0:1] + 16) / 116. - col_y_pred = (y_pred[..., 0:1] + 16) / 116. - - edges_true = self._feature_detector(col_y_true, "edge") - points_true = self._feature_detector(col_y_true, "point") - edges_pred = self._feature_detector(col_y_pred, "edge") - points_pred = self._feature_detector(col_y_pred, "point") - - delta = K.maximum(K.abs(frobenius_norm(edges_true) - frobenius_norm(edges_pred)), - K.abs(frobenius_norm(points_pred) - frobenius_norm(points_true))) - - delta = K.clip(delta, self._epsilon, None) - return K.pow(((1 / np.sqrt(2)) * delta), self._feature_exponent) - - @classmethod - def _hunt_adjustment(cls, image: plaidml.tile.Value) -> plaidml.tile.Value: - """ Apply Hunt-adjustment to an image in L*a*b* color space - - Parameters - ---------- - image: :class:`plaidml.tile.Value` - The batch of images in L*a*b* to adjust - - Returns - ------- - :class:`plaidml.tile.Value` - The hunt adjusted batch of images in L*a*b color space - """ - ch_l = image[..., 0:1] - adjusted = K.concatenate([ch_l, image[..., 1:] * (ch_l * 0.01)], axis=-1) - return adjusted - - def _hyab(self, y_true, y_pred): - """ Compute the HyAB distance between true and predicted images. - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The ground truth batch of images in standard or Hunt-adjusted L*A*B* color space - y_pred: :class:`plaidml.tile.Value` - The predicted batch of images in in standard or Hunt-adjusted L*A*B* color space - - Returns - ------- - :class:`plaidml.tile.Value` - image tensor containing the per-pixel HyAB distances between true and predicted images - """ - delta = y_true - y_pred - root = K.sqrt(K.clip(K.pow(delta[..., 0:1], 2), self._epsilon, None)) - delta_norm = frobenius_norm(delta[..., 1:3]) - return root + delta_norm - - def _redistribute_errors(self, power_delta_e_hyab, cmax): - """ Redistribute exponentiated HyAB errors to the [0,1] range - - Parameters - ---------- - power_delta_e_hyab: :class:`plaidml.tile.Value` - The exponentiated HyAb distance - cmax: :class:`plaidml.tile.Value` - The exponentiated, maximum HyAB difference between two colors in Hunt-adjusted - L*A*B* space - - Returns - ------- - :class:`plaidml.tile.Value` - The redistributed per-pixel HyAB distances (in range [0,1]) - """ - pccmax = self._pc * cmax - delta_e_c = K.switch( - power_delta_e_hyab < pccmax, - (self._pt / pccmax) * power_delta_e_hyab, - self._pt + ((power_delta_e_hyab - pccmax) / (cmax - pccmax)) * (1.0 - self._pt)) - return delta_e_c - - -class _SpatialFilters(): # pylint:disable=too-few-public-methods - """ Filters an image with channel specific spatial contrast sensitivity functions and clips - result to the unit cube in linear RGB. - - For use with LDRFlipLoss. - - Parameters - ---------- - pixels_per_degree: float - The estimated number of pixels per degree of visual angle of the observer. This effectively - impacts the tolerance when calculating loss. - """ - def __init__(self, pixels_per_degree: float) -> None: - self._pixels_per_degree = pixels_per_degree - self._spatial_filters, self._radius = self._generate_spatial_filters() - self._ycxcz2rgb = ColorSpaceConvert(from_space="ycxcz", to_space="rgb") - - def _generate_spatial_filters(self) -> Tuple[plaidml.tile.Value, int]: - """ Generates spatial contrast sensitivity filters with width depending on the number of - pixels per degree of visual angle of the observer for channels "A", "RG" and "BY" - - Returns - ------- - dict - the channels ("A" (Achromatic CSF), "RG" (Red-Green CSF) or "BY" (Blue-Yellow CSF)) as - key with the Filter kernel corresponding to the spatial contrast sensitivity function - of channel and kernel's radius - """ - mapping = dict(A=dict(a1=1, b1=0.0047, a2=0, b2=1e-5), - RG=dict(a1=1, b1=0.0053, a2=0, b2=1e-5), - BY=dict(a1=34.1, b1=0.04, a2=13.5, b2=0.025)) - - domain, radius = self._get_evaluation_domain(mapping["A"]["b1"], - mapping["A"]["b2"], - mapping["RG"]["b1"], - mapping["RG"]["b2"], - mapping["BY"]["b1"], - mapping["BY"]["b2"]) - - weights = np.array([self._generate_weights(mapping[channel], domain) - for channel in ("A", "RG", "BY")]) - weights = K.constant(np.moveaxis(weights, 0, -1), dtype="float32") - - return weights, radius - - def _get_evaluation_domain(self, - b1_a: float, - b2_a: float, - b1_rg: float, - b2_rg: float, - b1_by: float, - b2_by: float) -> Tuple[np.ndarray, int]: - """ TODO docstring """ - max_scale_parameter = max([b1_a, b2_a, b1_rg, b2_rg, b1_by, b2_by]) - delta_x = 1.0 / self._pixels_per_degree - radius = int(np.ceil(3 * np.sqrt(max_scale_parameter / (2 * np.pi**2)) - * self._pixels_per_degree)) - ax_x, ax_y = np.meshgrid(range(-radius, radius + 1), range(-radius, radius + 1)) - domain = (ax_x * delta_x) ** 2 + (ax_y * delta_x) ** 2 - return domain, radius - - @classmethod - def _generate_weights(cls, - channel: Dict[str, float], - domain: np.ndarray) -> plaidml.tile.Value: - """ TODO docstring """ - a_1, b_1, a_2, b_2 = channel["a1"], channel["b1"], channel["a2"], channel["b2"] - grad = (a_1 * np.sqrt(np.pi / b_1) * np.exp(-np.pi ** 2 * domain / b_1) + - a_2 * np.sqrt(np.pi / b_2) * np.exp(-np.pi ** 2 * domain / b_2)) - grad = grad / np.sum(grad) - grad = np.reshape(grad, (*grad.shape, 1)) - return grad - - def __call__(self, image: plaidml.tile.Value) -> plaidml.tile.Value: - """ Call the spacial filtering. - - Parameters - ---------- - image: Tensor - Image tensor to filter in YCxCz color space - - Returns - ------- - Tensor - The input image transformed to linear RGB after filtering with spatial contrast - sensitivity functions - """ - padded_image = replicate_pad(image, self._radius) - image_tilde_opponent = K.conv2d(padded_image, - self._spatial_filters, - strides=(1, 1), - padding="valid") - rgb = K.clip(self._ycxcz2rgb(image_tilde_opponent), 0., 1.) - return rgb - - -class _FeatureDetection(): # pylint:disable=too-few-public-methods - """ Detect features (i.e. edges amd points) in an achromatic YCxCz image. - - For use with LDRFlipLoss. - - Parameters - ---------- - pixels_per_degree: float - The number of pixels per degree of visual angle of the observer - """ - def __init__(self, pixels_per_degree: float) -> None: - width = 0.082 - self._std = 0.5 * width * pixels_per_degree - self._radius = int(np.ceil(3 * self._std)) - self._grid = np.meshgrid(range(-self._radius, self._radius + 1), - range(-self._radius, self._radius + 1)) - self._gradient = np.exp(-(self._grid[0] ** 2 + self._grid[1] ** 2) - / (2 * (self._std ** 2))) - - def __call__(self, image: plaidml.tile.Value, feature_type: str) -> plaidml.tile.Value: - """ Run the feature detection - - Parameters - ---------- - image: Tensor - Batch of images in YCxCz color space with normalized Y values - feature_type: str - Type of features to detect (`"edge"` or `"point"`) - - Returns - ------- - Tensor - Detected features in the 0-1 range - """ - feature_type = feature_type.lower() - - if feature_type == 'edge': - grad_x = np.multiply(-self._grid[0], self._gradient) - else: - grad_x = np.multiply(self._grid[0] ** 2 / (self._std ** 2) - 1, self._gradient) - - negative_weights_sum = -np.sum(grad_x[grad_x < 0]) - positive_weights_sum = np.sum(grad_x[grad_x > 0]) - - grad_x = K.constant(grad_x) - grad_x = K.switch(grad_x < 0, grad_x / negative_weights_sum, grad_x / positive_weights_sum) - kernel = K.expand_dims(K.expand_dims(grad_x, axis=-1), axis=-1) - - features_x = K.conv2d(replicate_pad(image, self._radius), - kernel, - strides=(1, 1), - padding="valid") - kernel = K.permute_dimensions(kernel, (1, 0, 2, 3)) - features_y = K.conv2d(replicate_pad(image, self._radius), - kernel, - strides=(1, 1), - padding="valid") - features = K.concatenate([features_x, features_y], axis=-1) - return features - - -class MSSIMLoss(DSSIMObjective): # pylint:disable=too-few-public-methods - """ Multiscale Structural Similarity Loss Function - - Parameters - ---------- - k_1: float, optional - Parameter of the SSIM. Default: `0.01` - k_2: float, optional - Parameter of the SSIM. Default: `0.03` - filter_size: int, optional - size of gaussian filter Default: `11` - filter_sigma: float, optional - Width of gaussian filter Default: `1.5` - max_value: float, optional - Max value of the output. Default: `1.0` - power_factors: tuple, optional - Iterable of weights for each of the scales. The number of scales used is the length of the - list. Index 0 is the unscaled resolution's weight and each increasing scale corresponds to - the image being downsampled by 2. Defaults to the values obtained in the original paper. - Default: (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) - - Notes - ------ - You should add a regularization term like a l2 loss in addition to this one. - """ - def __init__(self, - k_1: float = 0.01, - k_2: float = 0.03, - filter_size: int = 11, - filter_sigma: float = 1.5, - max_value: float = 1.0, - power_factors: Tuple[float, ...] = (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) - ) -> None: - super().__init__(k_1=k_1, - k_2=k_2, - filter_size=filter_size, - filter_sigma=filter_sigma, - max_value=max_value) - self._power_factors = K.constant(power_factors) - - def _get_smallest_size(self, size: int, idx: int) -> int: - """ Recursive function to obtain the smallest size that the image will be scaled to. - for MS-SSIM - - Parameters - ---------- - size: int - The current scaled size to iterate through - idx: int - The current iteration to be performed. When iteration hits zero the value will - be returned - - Returns - ------- - int - The smallest size the image will be scaled to based on the original image size and - the amount of scaling factors that will occur - """ - logger.debug("scale id: %s, size: %s", idx, size) - if idx > 0: - size = self._get_smallest_size(size // 2, idx - 1) - return size - - @classmethod - def _shrink_images(cls, images: List[plaidml.tile.Value]) -> List[plaidml.tile.Value]: - """ Reduce the dimensional space of a batch of images in half. If the images are an odd - number of pixels then pad them to an even dimension prior to shrinking - - All incoming images are assumed square. - - Parameters - ---------- - images: list - The y_true, y_pred batch of images to be shrunk - - Returns - ------- - list - The y_true, y_pred batch shrunk by half - """ - if any(x % 2 != 0 for x in K.int_shape(images[1])[1:2]): - images = [pad(img, - [[0, 0], [0, 1], [0, 1], [0, 0]], - mode="REFLECT") - for img in images] - - images = [K.pool2d(img, (2, 2), strides=(2, 2), padding="valid", pool_mode="avg") - for img in images] - - return images - - def _get_ms_ssim(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Obtain the Multiscale Stuctural Similarity metric. - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The input batch of ground truth images - y_pred: :class:`plaidml.tile.Value` - The input batch of predicted images - - Returns - ------- - :class:`plaidml.tile.Value` - The MS-SSIM for the given images - """ - im_size = K.int_shape(y_pred)[1] - # filter size cannot be larger than the smallest scale - recursions = K.int_shape(self._power_factors)[0] - smallest_scale = self._get_smallest_size(im_size, recursions - 1) - if smallest_scale < self._filter_size: - self._filter_size = smallest_scale - self._kernel = self._get_kernel() - - images = [y_true, y_pred] - contrasts = [] - - for idx in range(recursions): - images = self._shrink_images(images) if idx > 0 else images - ssim, contrast = self._get_ssim(*images) - - if idx < recursions - 1: - contrasts.append(K.relu(K.expand_dims(contrast, axis=-1))) - - contrasts.append(K.relu(K.expand_dims(ssim, axis=-1))) - mcs_and_ssim = K.concatenate(contrasts, axis=-1) - ms_ssim = K.pow(mcs_and_ssim, self._power_factors) - - # K.prod does not work in plaidml so slow recursion it is - out = ms_ssim[..., 0] - for idx in range(1, recursions): - out *= ms_ssim[..., idx] - return out - - def __call__(self, - y_true: plaidml.tile.Value, - y_pred: plaidml.tile.Value) -> plaidml.tile.Value: - """ Call the MS-SSIM Loss Function. - - Parameters - ---------- - y_true: :class:`plaidml.tile.Value` - The ground truth value - y_pred: :class:`plaidml.tile.Value` - The predicted value - - Returns - ------- - :class:`plaidml.tile.Value` - The MS-SSIM Loss value - """ - ms_ssim = self._get_ms_ssim(y_true, y_pred) - retval = 1. - ms_ssim - return K.mean(retval) diff --git a/lib/model/losses/__init__.py b/lib/model/losses/__init__.py new file mode 100644 index 0000000000..751e791011 --- /dev/null +++ b/lib/model/losses/__init__.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +""" Custom Loss Functions for Faceswap """ + +from .feature_loss import LPIPSLoss +from .loss import (FocalFrequencyLoss, GeneralizedLoss, GradientLoss, + LaplacianPyramidLoss, LInfNorm, LossWrapper) +from .perceptual_loss import DSSIMObjective, GMSDLoss, LDRFLIPLoss, MSSIMLoss diff --git a/lib/model/loss/feature_loss_tf.py b/lib/model/losses/feature_loss.py similarity index 92% rename from lib/model/loss/feature_loss_tf.py rename to lib/model/losses/feature_loss.py index 2601455b61..80d206b708 100644 --- a/lib/model/loss/feature_loss_tf.py +++ b/lib/model/losses/feature_loss.py @@ -69,20 +69,20 @@ def __init__(self, net_name: str, eval_mode: bool, load_weights: bool) -> None: @property def _nets(self) -> Dict[str, NetInfo]: """ :class:`NetInfo`: The Information about the requested net.""" - return dict( - alex=NetInfo(model_id=15, - model_name="alexnet_imagenet_no_top_v1.h5", - net=AlexNet, - outputs=[f"features.{idx}" for idx in (0, 3, 6, 8, 10)]), - squeeze=NetInfo(model_id=16, - model_name="squeezenet_imagenet_no_top_v1.h5", - net=SqueezeNet, - outputs=[f"features.{idx}" for idx in (0, 4, 7, 9, 10, 11, 12)]), - vgg16=NetInfo(model_id=17, - model_name="vgg16_imagenet_no_top_v1.h5", - net=kapp.vgg16.VGG16, - init_kwargs=dict(include_top=False, weights=None), - outputs=[f"block{i + 1}_conv{2 if i < 2 else 3}" for i in range(5)])) + return { + "alex": NetInfo(model_id=15, + model_name="alexnet_imagenet_no_top_v1.h5", + net=AlexNet, + outputs=[f"features.{idx}" for idx in (0, 3, 6, 8, 10)]), + "squeeze": NetInfo(model_id=16, + model_name="squeezenet_imagenet_no_top_v1.h5", + net=SqueezeNet, + outputs=[f"features.{idx}" for idx in (0, 4, 7, 9, 10, 11, 12)]), + "vgg16": NetInfo(model_id=17, + model_name="vgg16_imagenet_no_top_v1.h5", + net=kapp.vgg16.VGG16, + init_kwargs={"include_top": False, "weights": None}, + outputs=[f"block{i + 1}_conv{2 if i < 2 else 3}" for i in range(5)])} @classmethod def _normalize_output(cls, inputs: tf.Tensor, epsilon: float = 1e-10) -> tf.Tensor: @@ -178,13 +178,13 @@ def __init__(self, @property def _nets(self) -> Dict[str, NetInfo]: """ :class:`NetInfo`: The Information about the requested net.""" - return dict( - alex=NetInfo(model_id=18, - model_name="alexnet_lpips_v1.h5",), - squeeze=NetInfo(model_id=19, - model_name="squeezenet_lpips_v1.h5"), - vgg16=NetInfo(model_id=20, - model_name="vgg16_lpips_v1.h5")) + return { + "alex": NetInfo(model_id=18, + model_name="alexnet_lpips_v1.h5",), + "squeeze": NetInfo(model_id=19, + model_name="squeezenet_lpips_v1.h5"), + "vgg16": NetInfo(model_id=20, + model_name="vgg16_lpips_v1.h5")} def _linear_block(self, net_output_layer: tf.Tensor) -> Tuple[tf.Tensor, tf.Tensor]: """ Build a linear block for a trunk network output. @@ -275,7 +275,7 @@ class LPIPSLoss(): # pylint:disable=too-few-public-methods ``True`` to return the loss value per feature output layer otherwise ``False``. Default: ``False`` """ - def __init__(self, + def __init__(self, # pylint:disable=too-many-arguments trunk_network: str, trunk_pretrained: bool = True, trunk_eval_mode: bool = True, diff --git a/lib/model/loss/loss_tf.py b/lib/model/losses/loss.py similarity index 98% rename from lib/model/loss/loss_tf.py rename to lib/model/losses/loss.py index 4b94f8f058..7e1828141c 100644 --- a/lib/model/loss/loss_tf.py +++ b/lib/model/losses/loss.py @@ -10,12 +10,9 @@ import tensorflow as tf # Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.python.keras.engine import compile_utils # noqa pylint:disable=no-name-in-module,import-error +from tensorflow.python.keras.engine import compile_utils # pylint:disable=no-name-in-module from tensorflow.keras import backend as K # pylint:disable=import-error -from .feature_loss_tf import LPIPSLoss #pylint:disable=unused-import # noqa -from .perceptual_loss_tf import DSSIMObjective, GMSDLoss, LDRFLIPLoss, MSSIMLoss #pylint:disable=unused-import # noqa - logger = logging.getLogger(__name__) @@ -523,7 +520,7 @@ def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: class LInfNorm(): # pylint:disable=too-few-public-methods """ Calculate the L-inf norm as a loss function. """ - def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: # noqa,pylint:disable=no-self-use + def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: """ Call the L-inf norm loss function. Parameters diff --git a/lib/model/loss/perceptual_loss_tf.py b/lib/model/losses/perceptual_loss.py similarity index 99% rename from lib/model/loss/perceptual_loss_tf.py rename to lib/model/losses/perceptual_loss.py index a2936e586e..ccb2cfa4e7 100644 --- a/lib/model/loss/perceptual_loss_tf.py +++ b/lib/model/losses/perceptual_loss.py @@ -536,9 +536,9 @@ def _generate_spatial_filters(self) -> Tuple[tf.Tensor, int]: key with the Filter kernel corresponding to the spatial contrast sensitivity function of channel and kernel's radius """ - mapping = dict(A=dict(a1=1, b1=0.0047, a2=0, b2=1e-5), - RG=dict(a1=1, b1=0.0053, a2=0, b2=1e-5), - BY=dict(a1=34.1, b1=0.04, a2=13.5, b2=0.025)) + mapping = {"A": {"a1": 1, "b1": 0.0047, "a2": 0, "b2": 1e-5}, + "RG": {"a1": 1, "b1": 0.0053, "a2": 0, "b2": 1e-5}, + "BY": {"a1": 34.1, "b1": 0.04, "a2": 13.5, "b2": 0.025}} domain, radius = self._get_evaluation_domain(mapping["A"]["b1"], mapping["A"]["b2"], @@ -603,7 +603,7 @@ def __call__(self, image: tf.Tensor) -> tf.Tensor: class _FeatureDetection(): # pylint:disable=too-few-public-methods - """ Detect features (i.e. edges amd points) in an achromatic YCxCz image. + """ Detect features (i.e. edges and points) in an achromatic YCxCz image. For use with LDRFlipLoss. diff --git a/lib/model/nets.py b/lib/model/nets.py index b4c01bc42d..87e740b72e 100644 --- a/lib/model/nets.py +++ b/lib/model/nets.py @@ -1,18 +1,17 @@ #!/usr/bin/env python3 """ Ports of existing NN Architecture for use in faceswap.py """ +from __future__ import annotations import logging -from typing import Optional, Tuple - -from lib.utils import get_backend - -if get_backend() == "amd": - from keras.layers import Concatenate, Conv2D, Input, MaxPool2D, ZeroPadding2D - from keras.models import Model - from plaidml.tile import Value as Tensor -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras.layers import Concatenate, Conv2D, Input, MaxPool2D, ZeroPadding2D # noqa pylint:disable=no-name-in-module,import-error - from tensorflow.keras.models import Model # noqa pylint:disable=no-name-in-module,import-error +import typing as T + +import tensorflow as tf + +# Fix intellisense/linting for tf.keras' thoroughly broken import system +keras = tf.keras +layers = keras.layers +Model = keras.models.Model + +if T.TYPE_CHECKING: from tensorflow import Tensor @@ -32,7 +31,7 @@ class _net(): # pylint:disable=too-few-public-methods The input shape for the model. Default: ``None`` """ def __init__(self, - input_shape: Optional[Tuple[int, int, int]] = None) -> None: + input_shape: T.Optional[T.Tuple[int, int, int]] = None) -> None: logger.debug("Initializing: %s (input_shape: %s)", self.__class__.__name__, input_shape) self._input_shape = (None, None, 3) if input_shape is None else input_shape assert len(self._input_shape) == 3 and self._input_shape[-1] == 3, ( @@ -57,7 +56,7 @@ class AlexNet(_net): # pylint:disable=too-few-public-methods input_shape, Tuple, optional The input shape for the model. Default: ``None`` """ - def __init__(self, input_shape: Optional[Tuple[int, int, int]] = None) -> None: + def __init__(self, input_shape: T.Optional[T.Tuple[int, int, int]] = None) -> None: super().__init__(input_shape) self._feature_indices = [0, 3, 6, 8, 10] # For naming equivalent to PyTorch self._filters = [64, 192, 384, 256, 256] # Filters at each block @@ -76,7 +75,7 @@ def _conv_block(cls, Parameters ---------- - inputs: :class:`plaidml.tile.Value` or :class:`tf.Tensor` + inputs: :class:`tf.Tensor` The input tensor to the block padding: int The amount of zero paddin to apply prior to convolution @@ -93,20 +92,20 @@ def _conv_block(cls, Returns ------- - :class:`plaidml.tile.Value` or :class:`tf.Tensor` + :class:`tf.Tensor` The output of the Convolutional block """ name = f"features.{block_idx}" var_x = inputs if max_pool: - var_x = MaxPool2D(pool_size=3, strides=2, name=f"{name}.pool")(var_x) - var_x = ZeroPadding2D(padding=padding, name=f"{name}.pad")(var_x) - var_x = Conv2D(filters, - kernel_size=kernel_size, - strides=strides, - padding="valid", - activation="relu", - name=name)(var_x) + var_x = layers.MaxPool2D(pool_size=3, strides=2, name=f"{name}.pool")(var_x) + var_x = layers.ZeroPadding2D(padding=padding, name=f"{name}.pad")(var_x) + var_x = layers.Conv2D(filters, + kernel_size=kernel_size, + strides=strides, + padding="valid", + activation="relu", + name=name)(var_x) return var_x def __call__(self) -> Model: @@ -117,7 +116,7 @@ def __call__(self) -> Model: :class:`keras.models.Model` The compiled AlexNet model """ - inputs = Input(self._input_shape) + inputs = layers.Input(self._input_shape) var_x = inputs kernel_size = 11 strides = 4 @@ -164,7 +163,7 @@ def _fire(cls, Parameters ---------- - inputs: :class:`plaidml.tile.Value` or :class:`tf.Tensor` + inputs: :class:`tf.Tensor` The input to the fire block squeeze_planes: int The number of filters for the squeeze convolution @@ -175,15 +174,20 @@ def _fire(cls, Returns ------- - :class:`plaidml.tile.Value` or :class:`tf.Tensor` + :class:`tf.Tensor` The output of the SqueezeNet fire block """ name = f"features.{block_idx}" - squeezed = Conv2D(squeeze_planes, 1, activation="relu", name=f"{name}.squeeze")(inputs) - expand1 = Conv2D(expand_planes, 1, activation="relu", name=f"{name}.expand1x1")(squeezed) - expand3 = Conv2D(expand_planes, 3, - activation="relu", padding="same", name=f"{name}.expand3x3")(squeezed) - return Concatenate(axis=-1, name=name)([expand1, expand3]) + squeezed = layers.Conv2D(squeeze_planes, 1, + activation="relu", name=f"{name}.squeeze")(inputs) + expand1 = layers.Conv2D(expand_planes, 1, + activation="relu", name=f"{name}.expand1x1")(squeezed) + expand3 = layers.Conv2D(expand_planes, + 3, + activation="relu", + padding="same", + name=f"{name}.expand3x3")(squeezed) + return layers.Concatenate(axis=-1, name=name)([expand1, expand3]) def __call__(self) -> Model: """ Create the SqueezeNet Model @@ -193,15 +197,15 @@ def __call__(self) -> Model: :class:`keras.models.Model` The compiled SqueezeNet model """ - inputs = Input(self._input_shape) - var_x = Conv2D(64, 3, strides=2, activation="relu", name="features.0")(inputs) + inputs = layers.Input(self._input_shape) + var_x = layers.Conv2D(64, 3, strides=2, activation="relu", name="features.0")(inputs) block_idx = 2 squeeze = 16 expand = 64 for idx in range(4): if idx < 3: - var_x = MaxPool2D(pool_size=3, strides=2)(var_x) + var_x = layers.MaxPool2D(pool_size=3, strides=2)(var_x) block_idx += 1 var_x = self._fire(var_x, squeeze, expand, block_idx) block_idx += 1 diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index 5cb92c2a22..96cc0e8fe9 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -1,30 +1,20 @@ #!/usr/bin/env python3 """ Neural Network Blocks for faceswap.py. """ - +from __future__ import annotations import logging -from typing import Dict, Optional, Tuple, Union +import typing as T -from lib.utils import get_backend +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras.layers import ( # pylint:disable=import-error + Activation, Add, BatchNormalization, Concatenate, Conv2D as KConv2D, Conv2DTranspose, + DepthwiseConv2D as KDepthwiseConv2d, LeakyReLU, PReLU, SeparableConv2D, UpSampling2D) +from tensorflow.keras.initializers import he_uniform, VarianceScaling # noqa:E501 # pylint:disable=import-error from .initializers import ICNR, ConvolutionAware from .layers import PixelShuffler, ReflectionPadding2D, Swish, KResizeImages from .normalization import InstanceNormalization -if get_backend() == "amd": - from keras.layers import ( - Activation, Add, BatchNormalization, Concatenate, Conv2D as KConv2D, Conv2DTranspose, - DepthwiseConv2D as KDepthwiseConv2d, LeakyReLU, PReLU, SeparableConv2D, UpSampling2D) - from keras.initializers import he_uniform, VarianceScaling # pylint:disable=no-name-in-module - # type checking: - import keras - from plaidml.tile import Value as Tensor # pylint:disable=import-error -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras.layers import ( # noqa pylint:disable=no-name-in-module,import-error - Activation, Add, BatchNormalization, Concatenate, Conv2D as KConv2D, Conv2DTranspose, - DepthwiseConv2D as KDepthwiseConv2d, LeakyReLU, PReLU, SeparableConv2D, UpSampling2D) - from tensorflow.keras.initializers import he_uniform, VarianceScaling # noqa pylint:disable=no-name-in-module,import-error - # type checking: +if T.TYPE_CHECKING: from tensorflow import keras from tensorflow import Tensor @@ -33,7 +23,7 @@ _CONFIG: dict = {} -_NAMES: Dict[str, int] = {} +_NAMES: T.Dict[str, int] = {} def set_config(configuration: dict) -> None: @@ -199,7 +189,7 @@ class Conv2DOutput(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: Union[int, Tuple[int]], + kernel_size: T.Union[int, T.Tuple[int]], activation: str = "sigmoid", padding: str = "same", **kwargs) -> None: self._name = kwargs.pop("name") if "name" in kwargs else _get_name( @@ -275,11 +265,11 @@ class Conv2DBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: Union[int, Tuple[int, int]] = 5, - strides: Union[int, Tuple[int, int]] = 2, + kernel_size: T.Union[int, T.Tuple[int, int]] = 5, + strides: T.Union[int, T.Tuple[int, int]] = 2, padding: str = "same", - normalization: Optional[str] = None, - activation: Optional[str] = "leakyrelu", + normalization: T.Optional[str] = None, + activation: T.Optional[str] = "leakyrelu", use_depthwise: bool = False, relu_alpha: float = 0.1, **kwargs) -> None: @@ -372,8 +362,8 @@ class SeparableConv2DBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: Union[int, Tuple[int, int]] = 5, - strides: Union[int, Tuple[int, int]] = 2, **kwargs) -> None: + kernel_size: T.Union[int, T.Tuple[int, int]] = 5, + strides: T.Union[int, T.Tuple[int, int]] = 2, **kwargs) -> None: self._name = _get_name(f"separableconv2d_{filters}") logger.debug("name: %s, filters: %s, kernel_size: %s, strides: %s, kwargs: %s)", self._name, filters, kernel_size, strides, kwargs) @@ -444,11 +434,11 @@ class UpscaleBlock(): # pylint:disable=too-few-public-methods def __init__(self, filters: int, - kernel_size: Union[int, Tuple[int, int]] = 3, + kernel_size: T.Union[int, T.Tuple[int, int]] = 3, padding: str = "same", scale_factor: int = 2, - normalization: Optional[str] = None, - activation: Optional[str] = "leakyrelu", + normalization: T.Optional[str] = None, + activation: T.Optional[str] = "leakyrelu", **kwargs) -> None: self._name = _get_name(f"upscale_{filters}") logger.debug("name: %s. filters: %s, kernel_size: %s, padding: %s, scale_factor: %s, " @@ -531,9 +521,9 @@ class Upscale2xBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: Union[int, Tuple[int, int]] = 3, + kernel_size: T.Union[int, T.Tuple[int, int]] = 3, padding: str = "same", - activation: Optional[str] = "leakyrelu", + activation: T.Optional[str] = "leakyrelu", interpolation: str = "bilinear", sr_ratio: float = 0.5, scale_factor: int = 2, @@ -625,9 +615,9 @@ class UpscaleResizeImagesBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: Union[int, Tuple[int, int]] = 3, + kernel_size: T.Union[int, T.Tuple[int, int]] = 3, padding: str = "same", - activation: Optional[str] = "leakyrelu", + activation: T.Optional[str] = "leakyrelu", scale_factor: int = 2, interpolation: str = "bilinear") -> None: self._name = _get_name(f"upscale_ri_{filters}") @@ -710,9 +700,9 @@ class UpscaleDNYBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: Union[int, Tuple[int, int]] = 3, + kernel_size: T.Union[int, T.Tuple[int, int]] = 3, padding: str = "same", - activation: Optional[str] = "leakyrelu", + activation: T.Optional[str] = "leakyrelu", size: int = 2, interpolation: str = "bilinear", **kwargs) -> None: @@ -767,7 +757,7 @@ class ResidualBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: Union[int, Tuple[int, int]] = 3, + kernel_size: T.Union[int, T.Tuple[int, int]] = 3, padding: str = "same", **kwargs) -> None: self._name = _get_name(f"residual_{filters}") diff --git a/lib/model/normalization/normalization_common.py b/lib/model/normalization.py similarity index 68% rename from lib/model/normalization/normalization_common.py rename to lib/model/normalization.py index 22e8419d5b..fcef640fe9 100644 --- a/lib/model/normalization/normalization_common.py +++ b/lib/model/normalization.py @@ -1,205 +1,18 @@ #!/usr/bin/env python3 -""" Normalization methods for faceswap.py common to both Plaid and Tensorflow Backends """ - -import sys +""" Normalization methods for faceswap.py specific to Tensorflow backend """ import inspect +import sys -from lib.utils import get_backend - -if get_backend() == "amd": - from keras.utils import get_custom_objects # pylint:disable=no-name-in-module - from keras.layers import Layer, InputSpec - from keras import initializers, regularizers, constraints, backend as K - from keras.backend import normalize_data_format # pylint:disable=no-name-in-module -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras.utils import get_custom_objects # noqa pylint:disable=no-name-in-module,import-error - from tensorflow.keras.layers import Layer, InputSpec # noqa pylint:disable=no-name-in-module,import-error - from tensorflow.keras import initializers, regularizers, constraints, backend as K # noqa pylint:disable=no-name-in-module,import-error - from tensorflow.python.keras.utils.conv_utils import normalize_data_format # noqa pylint:disable=no-name-in-module - - -class InstanceNormalization(Layer): - """Instance normalization layer (Lei Ba et al, 2016, Ulyanov et al., 2016). - - Normalize the activations of the previous layer at each step, i.e. applies a transformation - that maintains the mean activation close to 0 and the activation standard deviation close to 1. - - Parameters - ---------- - axis: int, optional - The axis that should be normalized (typically the features axis). For instance, after a - `Conv2D` layer with `data_format="channels_first"`, set `axis=1` in - :class:`InstanceNormalization`. Setting `axis=None` will normalize all values in each - instance of the batch. Axis 0 is the batch dimension. `axis` cannot be set to 0 to avoid - errors. Default: ``None`` - epsilon: float, optional - Small float added to variance to avoid dividing by zero. Default: `1e-3` - center: bool, optional - If ``True``, add offset of `beta` to normalized tensor. If ``False``, `beta` is ignored. - Default: ``True`` - scale: bool, optional - If ``True``, multiply by `gamma`. If ``False``, `gamma` is not used. When the next layer - is linear (also e.g. `relu`), this can be disabled since the scaling will be done by - the next layer. Default: ``True`` - beta_initializer: str, optional - Initializer for the beta weight. Default: `"zeros"` - gamma_initializer: str, optional - Initializer for the gamma weight. Default: `"ones"` - beta_regularizer: str, optional - Optional regularizer for the beta weight. Default: ``None`` - gamma_regularizer: str, optional - Optional regularizer for the gamma weight. Default: ``None`` - beta_constraint: float, optional - Optional constraint for the beta weight. Default: ``None`` - gamma_constraint: float, optional - Optional constraint for the gamma weight. Default: ``None`` - - References - ---------- - - Layer Normalization - https://arxiv.org/abs/1607.06450 - - - Instance Normalization: The Missing Ingredient for Fast Stylization - \ - https://arxiv.org/abs/1607.08022 - """ - # pylint:disable=too-many-instance-attributes,too-many-arguments - def __init__(self, - axis=None, - epsilon=1e-3, - center=True, - scale=True, - beta_initializer="zeros", - gamma_initializer="ones", - beta_regularizer=None, - gamma_regularizer=None, - beta_constraint=None, - gamma_constraint=None, - **kwargs): - self.beta = None - self.gamma = None - super().__init__(**kwargs) - self.supports_masking = True - self.axis = axis - self.epsilon = epsilon - self.center = center - self.scale = scale - self.beta_initializer = initializers.get(beta_initializer) - self.gamma_initializer = initializers.get(gamma_initializer) - self.beta_regularizer = regularizers.get(beta_regularizer) - self.gamma_regularizer = regularizers.get(gamma_regularizer) - self.beta_constraint = constraints.get(beta_constraint) - self.gamma_constraint = constraints.get(gamma_constraint) - - def build(self, input_shape): - """Creates the layer weights. - - Parameters - ---------- - input_shape: tensor - Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to - reference for weight shape computations. - """ - ndim = len(input_shape) - if self.axis == 0: - raise ValueError("Axis cannot be zero") - - if (self.axis is not None) and (ndim == 2): - raise ValueError("Cannot specify axis for rank 1 tensor") - - self.input_spec = InputSpec(ndim=ndim) # pylint:disable=attribute-defined-outside-init - - if self.axis is None: - shape = (1,) - else: - shape = (input_shape[self.axis],) - - if self.scale: - self.gamma = self.add_weight(shape=shape, - name="gamma", - initializer=self.gamma_initializer, - regularizer=self.gamma_regularizer, - constraint=self.gamma_constraint) - else: - self.gamma = None - if self.center: - self.beta = self.add_weight(shape=shape, - name="beta", - initializer=self.beta_initializer, - regularizer=self.beta_regularizer, - constraint=self.beta_constraint) - else: - self.beta = None - self.built = True # pylint:disable=attribute-defined-outside-init - - def call(self, inputs, training=None): # pylint:disable=arguments-differ,unused-argument - """This is where the layer's logic lives. - - Parameters - ---------- - inputs: tensor - Input tensor, or list/tuple of input tensors - - Returns - ------- - tensor - A tensor or list/tuple of tensors - """ - input_shape = K.int_shape(inputs) - reduction_axes = list(range(0, len(input_shape))) - - if self.axis is not None: - del reduction_axes[self.axis] - - del reduction_axes[0] - - mean = K.mean(inputs, reduction_axes, keepdims=True) - stddev = K.std(inputs, reduction_axes, keepdims=True) + self.epsilon - normed = (inputs - mean) / stddev - - broadcast_shape = [1] * len(input_shape) - if self.axis is not None: - broadcast_shape[self.axis] = input_shape[self.axis] - - if self.scale: - broadcast_gamma = K.reshape(self.gamma, broadcast_shape) - normed = normed * broadcast_gamma - if self.center: - broadcast_beta = K.reshape(self.beta, broadcast_shape) - normed = normed + broadcast_beta - return normed - - def get_config(self): - """Returns the config of the layer. - - A layer config is a Python dictionary (serializable) containing the configuration of a - layer. The same layer can be reinstated later (without its trained weights) from this - configuration. - - The configuration of a layer does not include connectivity information, nor the layer - class name. These are handled by `Network` (one layer of abstraction above). +import tensorflow as tf - Returns - -------- - dict - A python dictionary containing the layer configuration - """ - config = { - "axis": self.axis, - "epsilon": self.epsilon, - "center": self.center, - "scale": self.scale, - "beta_initializer": initializers.serialize(self.beta_initializer), - "gamma_initializer": initializers.serialize(self.gamma_initializer), - "beta_regularizer": regularizers.serialize(self.beta_regularizer), - "gamma_regularizer": regularizers.serialize(self.gamma_regularizer), - "beta_constraint": constraints.serialize(self.beta_constraint), - "gamma_constraint": constraints.serialize(self.gamma_constraint) - } - base_config = super().get_config() - return dict(list(base_config.items()) + list(config.items())) +# Fix intellisense/linting for tf.keras' thoroughly broken import system +from tensorflow.python.keras.utils.conv_utils import normalize_data_format # noqa:E501 # pylint:disable=no-name-in-module +keras = tf.keras +layers = keras.layers +K = keras.backend -class AdaInstanceNormalization(Layer): +class AdaInstanceNormalization(layers.Layer): # type:ignore[name-defined] """ Adaptive Instance Normalization Layer for Keras. Parameters @@ -302,7 +115,7 @@ def get_config(self): base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) - def compute_output_shape(self, input_shape): # pylint:disable=no-self-use + def compute_output_shape(self, input_shape): """ Calculate the output shape from this layer. Parameters @@ -318,7 +131,7 @@ def compute_output_shape(self, input_shape): # pylint:disable=no-self-use return input_shape[0] -class GroupNormalization(Layer): +class GroupNormalization(layers.Layer): # type:ignore[name-defined] """ Group Normalization Parameters @@ -357,10 +170,10 @@ def __init__(self, axis=-1, gamma_init='one', beta_init='zero', gamma_regularize self.gamma = None super().__init__(**kwargs) self.axis = axis if isinstance(axis, (list, tuple)) else [axis] - self.gamma_init = initializers.get(gamma_init) - self.beta_init = initializers.get(beta_init) - self.gamma_regularizer = regularizers.get(gamma_regularizer) - self.beta_regularizer = regularizers.get(beta_regularizer) + self.gamma_init = keras.initializers.get(gamma_init) + self.beta_init = keras.initializers.get(beta_init) + self.gamma_regularizer = keras.regularizers.get(gamma_regularizer) + self.beta_regularizer = keras.regularizers.get(beta_regularizer) self.epsilon = epsilon self.group = group self.data_format = normalize_data_format(data_format) @@ -376,7 +189,7 @@ def build(self, input_shape): Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to reference for weight shape computations. """ - input_spec = [InputSpec(shape=input_shape)] + input_spec = [layers.InputSpec(shape=input_shape)] self.input_spec = input_spec # pylint:disable=attribute-defined-outside-init shape = [1 for _ in input_shape] if self.data_format == 'channels_last': @@ -397,7 +210,7 @@ def build(self, input_shape): name='beta') self.built = True # pylint:disable=attribute-defined-outside-init - def call(self, inputs, mask=None): # pylint:disable=unused-argument,arguments-differ + def call(self, inputs, *args, **kwargs): # noqa:C901 """This is where the layer's logic lives. Parameters @@ -486,16 +299,351 @@ def get_config(self): """ config = {'epsilon': self.epsilon, 'axis': self.axis, - 'gamma_init': initializers.serialize(self.gamma_init), - 'beta_init': initializers.serialize(self.beta_init), - 'gamma_regularizer': regularizers.serialize(self.gamma_regularizer), - 'beta_regularizer': regularizers.serialize(self.gamma_regularizer), + 'gamma_init': keras.initializers.serialize(self.gamma_init), + 'beta_init': keras.initializers.serialize(self.beta_init), + 'gamma_regularizer': keras.regularizers.serialize(self.gamma_regularizer), + 'beta_regularizer': keras.regularizers.serialize(self.gamma_regularizer), 'group': self.group} base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) +class InstanceNormalization(layers.Layer): # type:ignore[name-defined] + """Instance normalization layer (Lei Ba et al, 2016, Ulyanov et al., 2016). + + Normalize the activations of the previous layer at each step, i.e. applies a transformation + that maintains the mean activation close to 0 and the activation standard deviation close to 1. + + Parameters + ---------- + axis: int, optional + The axis that should be normalized (typically the features axis). For instance, after a + `Conv2D` layer with `data_format="channels_first"`, set `axis=1` in + :class:`InstanceNormalization`. Setting `axis=None` will normalize all values in each + instance of the batch. Axis 0 is the batch dimension. `axis` cannot be set to 0 to avoid + errors. Default: ``None`` + epsilon: float, optional + Small float added to variance to avoid dividing by zero. Default: `1e-3` + center: bool, optional + If ``True``, add offset of `beta` to normalized tensor. If ``False``, `beta` is ignored. + Default: ``True`` + scale: bool, optional + If ``True``, multiply by `gamma`. If ``False``, `gamma` is not used. When the next layer + is linear (also e.g. `relu`), this can be disabled since the scaling will be done by + the next layer. Default: ``True`` + beta_initializer: str, optional + Initializer for the beta weight. Default: `"zeros"` + gamma_initializer: str, optional + Initializer for the gamma weight. Default: `"ones"` + beta_regularizer: str, optional + Optional regularizer for the beta weight. Default: ``None`` + gamma_regularizer: str, optional + Optional regularizer for the gamma weight. Default: ``None`` + beta_constraint: float, optional + Optional constraint for the beta weight. Default: ``None`` + gamma_constraint: float, optional + Optional constraint for the gamma weight. Default: ``None`` + + References + ---------- + - Layer Normalization - https://arxiv.org/abs/1607.06450 + + - Instance Normalization: The Missing Ingredient for Fast Stylization - \ + https://arxiv.org/abs/1607.08022 + """ + # pylint:disable=too-many-instance-attributes,too-many-arguments + def __init__(self, + axis=None, + epsilon=1e-3, + center=True, + scale=True, + beta_initializer="zeros", + gamma_initializer="ones", + beta_regularizer=None, + gamma_regularizer=None, + beta_constraint=None, + gamma_constraint=None, + **kwargs): + self.beta = None + self.gamma = None + super().__init__(**kwargs) + self.supports_masking = True + self.axis = axis + self.epsilon = epsilon + self.center = center + self.scale = scale + self.beta_initializer = keras.initializers.get(beta_initializer) + self.gamma_initializer = keras.initializers.get(gamma_initializer) + self.beta_regularizer = keras.regularizers.get(beta_regularizer) + self.gamma_regularizer = keras.regularizers.get(gamma_regularizer) + self.beta_constraint = keras.constraints.get(beta_constraint) + self.gamma_constraint = keras.constraints.get(gamma_constraint) + + def build(self, input_shape): + """Creates the layer weights. + + Parameters + ---------- + input_shape: tensor + Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to + reference for weight shape computations. + """ + ndim = len(input_shape) + if self.axis == 0: + raise ValueError("Axis cannot be zero") + + if (self.axis is not None) and (ndim == 2): + raise ValueError("Cannot specify axis for rank 1 tensor") + + self.input_spec = layers.InputSpec(ndim=ndim) # noqa:E501 pylint:disable=attribute-defined-outside-init + + if self.axis is None: + shape = (1,) + else: + shape = (input_shape[self.axis],) + + if self.scale: + self.gamma = self.add_weight(shape=shape, + name="gamma", + initializer=self.gamma_initializer, + regularizer=self.gamma_regularizer, + constraint=self.gamma_constraint) + else: + self.gamma = None + if self.center: + self.beta = self.add_weight(shape=shape, + name="beta", + initializer=self.beta_initializer, + regularizer=self.beta_regularizer, + constraint=self.beta_constraint) + else: + self.beta = None + self.built = True # pylint:disable=attribute-defined-outside-init + + def call(self, inputs, training=None): # pylint:disable=arguments-differ,unused-argument + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: tensor + Input tensor, or list/tuple of input tensors + + Returns + ------- + tensor + A tensor or list/tuple of tensors + """ + input_shape = K.int_shape(inputs) + reduction_axes = list(range(0, len(input_shape))) + + if self.axis is not None: + del reduction_axes[self.axis] + + del reduction_axes[0] + + mean = K.mean(inputs, reduction_axes, keepdims=True) + stddev = K.std(inputs, reduction_axes, keepdims=True) + self.epsilon + normed = (inputs - mean) / stddev + + broadcast_shape = [1] * len(input_shape) + if self.axis is not None: + broadcast_shape[self.axis] = input_shape[self.axis] + + if self.scale: + broadcast_gamma = K.reshape(self.gamma, broadcast_shape) + normed = normed * broadcast_gamma + if self.center: + broadcast_beta = K.reshape(self.beta, broadcast_shape) + normed = normed + broadcast_beta + return normed + + def get_config(self): + """Returns the config of the layer. + + A layer config is a Python dictionary (serializable) containing the configuration of a + layer. The same layer can be reinstated later (without its trained weights) from this + configuration. + + The configuration of a layer does not include connectivity information, nor the layer + class name. These are handled by `Network` (one layer of abstraction above). + + Returns + -------- + dict + A python dictionary containing the layer configuration + """ + config = { + "axis": self.axis, + "epsilon": self.epsilon, + "center": self.center, + "scale": self.scale, + "beta_initializer": keras.initializers.serialize(self.beta_initializer), + "gamma_initializer": keras.initializers.serialize(self.gamma_initializer), + "beta_regularizer": keras.regularizers.serialize(self.beta_regularizer), + "gamma_regularizer": keras.regularizers.serialize(self.gamma_regularizer), + "beta_constraint": keras.constraints.serialize(self.beta_constraint), + "gamma_constraint": keras.constraints.serialize(self.gamma_constraint) + } + base_config = super().get_config() + return dict(list(base_config.items()) + list(config.items())) + + +class RMSNormalization(layers.Layer): # type:ignore[name-defined] + """ Root Mean Square Layer Normalization (Biao Zhang, Rico Sennrich, 2019) + + RMSNorm is a simplification of the original layer normalization (LayerNorm). LayerNorm is a + regularization technique that might handle the internal covariate shift issue so as to + stabilize the layer activations and improve model convergence. It has been proved quite + successful in NLP-based model. In some cases, LayerNorm has become an essential component + to enable model optimization, such as in the SOTA NMT model Transformer. + + RMSNorm simplifies LayerNorm by removing the mean-centering operation, or normalizing layer + activations with RMS statistic. + + Parameters + ---------- + axis: int + The axis to normalize across. Typically this is the features axis. The left-out axes are + typically the batch axis/axes. This argument defaults to `-1`, the last dimension in the + input. + epsilon: float, optional + Small float added to variance to avoid dividing by zero. Default: `1e-8` + partial: float, optional + Partial multiplier for calculating pRMSNorm. Valid values are between `0.0` and `1.0`. + Setting to `0.0` or `1.0` disables. Default: `0.0` + bias: bool, optional + Whether to use a bias term for RMSNorm. Disabled by default because RMSNorm does not + enforce re-centering invariance. Default ``False`` + kwargs: dict + Standard keras layer kwargs + + References + ---------- + - RMS Normalization - https://arxiv.org/abs/1910.07467 + - Official implementation - https://github.com/bzhangGo/rmsnorm + """ + def __init__(self, axis=-1, epsilon=1e-8, partial=0.0, bias=False, **kwargs): + self.scale = None + self.offset = 0 + super().__init__(**kwargs) + + # Checks + if not isinstance(axis, int): + raise TypeError(f"Expected an int for the argument 'axis', but received: {axis}") + + if not 0.0 <= partial <= 1.0: + raise ValueError(f"partial must be between 0.0 and 1.0, but received {partial}") + + self.axis = axis + self.epsilon = epsilon + self.partial = partial + self.bias = bias + self.offset = 0. + + def build(self, input_shape): + """ Validate and populate :attr:`axis` + + Parameters + ---------- + input_shape: tensor + Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to + reference for weight shape computations. + """ + ndims = len(input_shape) + if ndims is None: + raise ValueError(f"Input shape {input_shape} has undefined rank.") + + # Resolve negative axis + if self.axis < 0: + self.axis += ndims + + # Validate axes + if self.axis < 0 or self.axis >= ndims: + raise ValueError(f"Invalid axis: {self.axis}") + + param_shape = [input_shape[self.axis]] + self.scale = self.add_weight( + name="scale", + shape=param_shape, + initializer="ones") + if self.bias: + self.offset = self.add_weight( + name="offset", + shape=param_shape, + initializer="zeros") + + self.built = True # pylint:disable=attribute-defined-outside-init + + def call(self, inputs, *args, **kwargs): + """ Call Root Mean Square Layer Normalization + + Parameters + ---------- + inputs: tensor + Input tensor, or list/tuple of input tensors + + Returns + ------- + tensor + A tensor or list/tuple of tensors + """ + # Compute the axes along which to reduce the mean / variance + input_shape = K.int_shape(inputs) + layer_size = input_shape[self.axis] + + if self.partial in (0.0, 1.0): + mean_square = K.mean(K.square(inputs), axis=self.axis, keepdims=True) + else: + partial_size = int(layer_size * self.partial) + partial_x, _ = tf.split( # pylint:disable=redundant-keyword-arg,no-value-for-parameter + inputs, + [partial_size, layer_size - partial_size], + axis=self.axis) + mean_square = K.mean(K.square(partial_x), axis=self.axis, keepdims=True) + + recip_square_root = tf.math.rsqrt(mean_square + self.epsilon) + output = self.scale * inputs * recip_square_root + self.offset + return output + + def compute_output_shape(self, input_shape): + """ The output shape of the layer is the same as the input shape. + + Parameters + ---------- + input_shape: tuple + The input shape to the layer + + Returns + ------- + tuple + The output shape to the layer + """ + return input_shape + + def get_config(self): + """Returns the config of the layer. + + A layer config is a Python dictionary (serializable) containing the configuration of a + layer. The same layer can be reinstated later (without its trained weights) from this + configuration. + + The configuration of a layer does not include connectivity information, nor the layer + class name. These are handled by `Network` (one layer of abstraction above). + + Returns + -------- + dict + A python dictionary containing the layer configuration + """ + base_config = super().get_config() + config = {"axis": self.axis, + "epsilon": self.epsilon, + "partial": self.partial, + "bias": self.bias} + return dict(list(base_config.items()) + list(config.items())) + + # Update normalization into Keras custom objects for name, obj in inspect.getmembers(sys.modules[__name__]): if inspect.isclass(obj) and obj.__module__ == __name__: - get_custom_objects().update({name: obj}) + keras.utils.get_custom_objects().update({name: obj}) diff --git a/lib/model/normalization/__init__.py b/lib/model/normalization/__init__.py deleted file mode 100644 index c6a31d2e02..0000000000 --- a/lib/model/normalization/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env python3 -""" Conditional imports depending on whether the AMD version is installed or not """ - -from lib.utils import get_backend -from .normalization_common import AdaInstanceNormalization # noqa -from .normalization_common import GroupNormalization # noqa -from .normalization_common import InstanceNormalization # noqa - - -if get_backend() == "amd": - from .normalization_plaid import LayerNormalization, RMSNormalization # noqa -else: - from .normalization_tf import LayerNormalization, RMSNormalization # noqa diff --git a/lib/model/normalization/normalization_plaid.py b/lib/model/normalization/normalization_plaid.py deleted file mode 100644 index d9e28a5700..0000000000 --- a/lib/model/normalization/normalization_plaid.py +++ /dev/null @@ -1,384 +0,0 @@ -#!/usr/bin/env python3 -""" Normalization methods for faceswap.py. """ - -import sys -import inspect - -from plaidml.op import slice_tensor -from keras.layers import Layer -from keras import initializers, regularizers, constraints -from keras import backend as K -from keras.utils import get_custom_objects - - -class LayerNormalization(Layer): - """Instance normalization layer (Lei Ba et al, 2016). Implementation adapted from - tensorflow.keras implementation and https://github.com/CyberZHG/keras-layer-normalization - - Normalize the activations of the previous layer for each given example in a batch - independently, rather than across a batch like Batch Normalization. i.e. applies a - transformation that maintains the mean activation within each example close to 0 and the - activation standard deviation close to 1. - - Parameters - ---------- - axis: int or list/tuple - The axis or axes to normalize across. Typically this is the features axis/axes. - The left-out axes are typically the batch axis/axes. This argument defaults to `-1`, the - last dimension in the input. - epsilon: float, optional - Small float added to variance to avoid dividing by zero. Default: `1e-3` - center: bool, optional - If ``True``, add offset of `beta` to normalized tensor. If ``False``, `beta` is ignored. - Default: ``True`` - scale: bool, optional - If ``True``, multiply by `gamma`. If ``False``, `gamma` is not used. When the next layer - is linear (also e.g. `relu`), this can be disabled since the scaling will be done by - the next layer. Default: ``True`` - beta_initializer: str, optional - Initializer for the beta weight. Default: `"zeros"` - gamma_initializer: str, optional - Initializer for the gamma weight. Default: `"ones"` - beta_regularizer: str, optional - Optional regularizer for the beta weight. Default: ``None`` - gamma_regularizer: str, optional - Optional regularizer for the gamma weight. Default: ``None`` - beta_constraint: float, optional - Optional constraint for the beta weight. Default: ``None`` - gamma_constraint: float, optional - Optional constraint for the gamma weight. Default: ``None`` - kwargs: dict - Standard keras layer kwargs - - References - ---------- - - Layer Normalization - https://arxiv.org/abs/1607.06450 - - Keras implementation - https://github.com/CyberZHG/keras-layer-normalization - """ - def __init__(self, - axis=-1, - epsilon=1e-3, - center=True, - scale=True, - beta_initializer="zeros", - gamma_initializer="ones", - beta_regularizer=None, - gamma_regularizer=None, - beta_constraint=None, - gamma_constraint=None, - **kwargs): - - self.gamma = None - self.beta = None - super().__init__(**kwargs) - - if isinstance(axis, (list, tuple)): - self.axis = axis[:] - elif isinstance(axis, int): - self.axis = axis - else: - raise TypeError("Expected an int or a list/tuple of ints for the argument 'axis', " - f"but received: {axis}") - - self.epsilon = epsilon - self.center = center - self.scale = scale - self.beta_initializer = initializers.get(beta_initializer) - self.gamma_initializer = initializers.get(gamma_initializer) - self.beta_regularizer = regularizers.get(beta_regularizer) - self.gamma_regularizer = regularizers.get(gamma_regularizer) - self.beta_constraint = constraints.get(beta_constraint) - self.gamma_constraint = constraints.get(gamma_constraint) - self.supports_masking = True - - def build(self, input_shape): - """Creates the layer weights. - - Parameters - ---------- - input_shape: tensor - Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to - reference for weight shape computations. - """ - ndims = len(input_shape) - if ndims is None: - raise ValueError(f"Input shape {input_shape} has undefined rank.") - - # Convert axis to list and resolve negatives - if isinstance(self.axis, int): - self.axis = [self.axis] - elif isinstance(self.axis, tuple): - self.axis = list(self.axis) - for idx, axs in enumerate(self.axis): - if axs < 0: - self.axis[idx] = ndims + axs - - # Validate axes - for axs in self.axis: - if axs < 0 or axs >= ndims: - raise ValueError(f"Invalid axis: {axs}") - if len(self.axis) != len(set(self.axis)): - raise ValueError("Duplicate axis: {}".format(tuple(self.axis))) - - param_shape = [input_shape[dim] for dim in self.axis] - if self.scale: - self.gamma = self.add_weight( - name="gamma", - shape=param_shape, - initializer=self.gamma_initializer, - regularizer=self.gamma_regularizer, - constraint=self.gamma_constraint) - if self.center: - self.beta = self.add_weight( - name='beta', - shape=param_shape, - initializer=self.beta_initializer, - regularizer=self.beta_regularizer, - constraint=self.beta_constraint) - - self.built = True # pylint:disable=attribute-defined-outside-init - - def call(self, inputs, **kwargs): # pylint:disable=unused-argument - """This is where the layer's logic lives. - - Parameters - ---------- - inputs: tensor - Input tensor, or list/tuple of input tensors - - Returns - ------- - tensor - A tensor or list/tuple of tensors - """ - # Compute the axes along which to reduce the mean / variance - input_shape = K.int_shape(inputs) - ndims = len(input_shape) - - # Broadcasting only necessary for norm when the axis is not just the last dimension - broadcast_shape = [1] * ndims - for dim in self.axis: - broadcast_shape[dim] = input_shape[dim] - - def _broadcast(var): - if (var is not None and len(var.shape) != ndims and self.axis != [ndims - 1]): - return K.reshape(var, broadcast_shape) - return var - - # Calculate the moments on the last axis (layer activations). - mean = K.mean(inputs, self.axis, keepdims=True) - variance = K.mean(K.square(inputs - mean), axis=self.axis, keepdims=True) - std = K.sqrt(variance + self.epsilon) - outputs = (inputs - mean) / std - - scale, offset = _broadcast(self.gamma), _broadcast(self.beta) - if self.scale: - outputs *= scale - if self.center: - outputs *= offset - - return outputs - - def compute_output_shape(self, input_shape): # pylint:disable=no-self-use - """ The output shape of the layer is the same as the input shape. - - Parameters - ---------- - input_shape: tuple - The input shape to the layer - - Returns - ------- - tuple - The output shape to the layer - """ - return input_shape - - def get_config(self): - """Returns the config of the layer. - - A layer config is a Python dictionary (serializable) containing the configuration of a - layer. The same layer can be reinstated later (without its trained weights) from this - configuration. - - The configuration of a layer does not include connectivity information, nor the layer - class name. These are handled by `Network` (one layer of abstraction above). - - Returns - -------- - dict - A python dictionary containing the layer configuration - """ - base_config = super().get_config() - config = dict(axis=self.axis, - epsilon=self.epsilon, - center=self.center, - scale=self.scale, - beta_initializer=initializers.serialize(self.beta_initializer), - gamma_initializer=initializers.serialize(self.gamma_initializer), - beta_regularizer=regularizers.serialize(self.beta_regularizer), - gamma_regularizer=regularizers.serialize(self.gamma_regularizer), - beta_constraint=constraints.serialize(self.beta_constraint), - gamma_constraint=constraints.serialize(self.gamma_constraint)) - return dict(list(base_config.items()) + list(config.items())) - - -class RMSNormalization(Layer): - """ Root Mean Square Layer Normalization (Biao Zhang, Rico Sennrich, 2019) - - RMSNorm is a simplification of the original layer normalization (LayerNorm). LayerNorm is a - regularization technique that might handle the internal covariate shift issue so as to - stabilize the layer activations and improve model convergence. It has been proved quite - successful in NLP-based model. In some cases, LayerNorm has become an essential component - to enable model optimization, such as in the SOTA NMT model Transformer. - - RMSNorm simplifies LayerNorm by removing the mean-centering operation, or normalizing layer - activations with RMS statistic. - - Parameters - ---------- - axis: int - The axis to normalize across. Typically this is the features axis. The left-out axes are - typically the batch axis/axes. This argument defaults to `-1`, the last dimension in the - input. - epsilon: float, optional - Small float added to variance to avoid dividing by zero. Default: `1e-8` - partial: float, optional - Partial multiplier for calculating pRMSNorm. Valid values are between `0.0` and `1.0`. - Setting to `0.0` or `1.0` disables. Default: `0.0` - bias: bool, optional - Whether to use a bias term for RMSNorm. Disabled by default because RMSNorm does not - enforce re-centering invariance. Default ``False`` - kwargs: dict - Standard keras layer kwargs - - References - ---------- - - RMS Normalization - https://arxiv.org/abs/1910.07467 - - Official implementation - https://github.com/bzhangGo/rmsnorm - """ - def __init__(self, axis=-1, epsilon=1e-8, partial=0.0, bias=False, **kwargs): - self.scale = None - self.offset = 0 - super().__init__(**kwargs) - - # Checks - if not isinstance(axis, int): - raise TypeError(f"Expected an int for the argument 'axis', but received: {axis}") - - if not 0.0 <= partial <= 1.0: - raise ValueError(f"partial must be between 0.0 and 1.0, but received {partial}") - - self.axis = axis - self.epsilon = epsilon - self.partial = partial - self.bias = bias - self.offset = 0. - - def build(self, input_shape): - """ Validate and populate :attr:`axis` - - Parameters - ---------- - input_shape: tensor - Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to - reference for weight shape computations. - """ - ndims = len(input_shape) - if ndims is None: - raise ValueError(f"Input shape {input_shape} has undefined rank.") - - # Resolve negative axis - if self.axis < 0: - self.axis += ndims - - # Validate axes - if self.axis < 0 or self.axis >= ndims: - raise ValueError(f"Invalid axis: {self.axis}") - - param_shape = [input_shape[self.axis]] - self.scale = self.add_weight( - name="scale", - shape=param_shape, - initializer="ones") - if self.bias: - self.offset = self.add_weight( - name="offset", - shape=param_shape, - initializer="zeros") - - self.built = True # pylint:disable=attribute-defined-outside-init - - def call(self, inputs, **kwargs): # pylint:disable=unused-argument - """ Call Root Mean Square Layer Normalization - - Parameters - ---------- - inputs: tensor - Input tensor, or list/tuple of input tensors - - Returns - ------- - tensor - A tensor or list/tuple of tensors - """ - # Compute the axes along which to reduce the mean / variance - input_shape = K.int_shape(inputs) - layer_size = input_shape[self.axis] - - if self.partial in (0.0, 1.0): - mean_square = K.mean(K.square(inputs), axis=self.axis, keepdims=True) - else: - partial_size = int(layer_size * self.partial) - partial_x = slice_tensor(inputs, - axes=[self.axis], - starts=[0], - ends=[partial_size]) - mean_square = K.mean(K.square(partial_x), axis=self.axis, keepdims=True) - - recip_square_root = 1. / K.sqrt(mean_square + self.epsilon) - output = self.scale * inputs * recip_square_root + self.offset - return output - - def compute_output_shape(self, input_shape): # pylint:disable=no-self-use - """ The output shape of the layer is the same as the input shape. - - Parameters - ---------- - input_shape: tuple - The input shape to the layer - - Returns - ------- - tuple - The output shape to the layer - """ - return input_shape - - def get_config(self): - """Returns the config of the layer. - - A layer config is a Python dictionary (serializable) containing the configuration of a - layer. The same layer can be reinstated later (without its trained weights) from this - configuration. - - The configuration of a layer does not include connectivity information, nor the layer - class name. These are handled by `Network` (one layer of abstraction above). - - Returns - -------- - dict - A python dictionary containing the layer configuration - """ - base_config = super().get_config() - config = dict(axis=self.axis, - epsilon=self.epsilon, - partial=self.partial, - bias=self.bias) - return dict(list(base_config.items()) + list(config.items())) - - -# Update normalization into Keras custom objects -for name, obj in inspect.getmembers(sys.modules[__name__]): - if inspect.isclass(obj) and obj.__module__ == __name__: - get_custom_objects().update({name: obj}) diff --git a/lib/model/normalization/normalization_tf.py b/lib/model/normalization/normalization_tf.py deleted file mode 100644 index b7a4abd028..0000000000 --- a/lib/model/normalization/normalization_tf.py +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env python3 -""" Normalization methods for faceswap.py specific to Tensorflow backend """ -import inspect -import sys - -import tensorflow as tf -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras import backend as K # pylint:disable=import-error -from tensorflow.keras.layers import Layer, LayerNormalization # noqa pylint:disable=no-name-in-module,unused-import,import-error -from tensorflow.keras.utils import get_custom_objects # noqa pylint:disable=no-name-in-module,import-error - - -class RMSNormalization(Layer): - """ Root Mean Square Layer Normalization (Biao Zhang, Rico Sennrich, 2019) - - RMSNorm is a simplification of the original layer normalization (LayerNorm). LayerNorm is a - regularization technique that might handle the internal covariate shift issue so as to - stabilize the layer activations and improve model convergence. It has been proved quite - successful in NLP-based model. In some cases, LayerNorm has become an essential component - to enable model optimization, such as in the SOTA NMT model Transformer. - - RMSNorm simplifies LayerNorm by removing the mean-centering operation, or normalizing layer - activations with RMS statistic. - - Parameters - ---------- - axis: int - The axis to normalize across. Typically this is the features axis. The left-out axes are - typically the batch axis/axes. This argument defaults to `-1`, the last dimension in the - input. - epsilon: float, optional - Small float added to variance to avoid dividing by zero. Default: `1e-8` - partial: float, optional - Partial multiplier for calculating pRMSNorm. Valid values are between `0.0` and `1.0`. - Setting to `0.0` or `1.0` disables. Default: `0.0` - bias: bool, optional - Whether to use a bias term for RMSNorm. Disabled by default because RMSNorm does not - enforce re-centering invariance. Default ``False`` - kwargs: dict - Standard keras layer kwargs - - References - ---------- - - RMS Normalization - https://arxiv.org/abs/1910.07467 - - Official implementation - https://github.com/bzhangGo/rmsnorm - """ - def __init__(self, axis=-1, epsilon=1e-8, partial=0.0, bias=False, **kwargs): - self.scale = None - self.offset = 0 - super().__init__(**kwargs) - - # Checks - if not isinstance(axis, int): - raise TypeError(f"Expected an int for the argument 'axis', but received: {axis}") - - if not 0.0 <= partial <= 1.0: - raise ValueError(f"partial must be between 0.0 and 1.0, but received {partial}") - - self.axis = axis - self.epsilon = epsilon - self.partial = partial - self.bias = bias - self.offset = 0. - - def build(self, input_shape): - """ Validate and populate :attr:`axis` - - Parameters - ---------- - input_shape: tensor - Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to - reference for weight shape computations. - """ - ndims = len(input_shape) - if ndims is None: - raise ValueError(f"Input shape {input_shape} has undefined rank.") - - # Resolve negative axis - if self.axis < 0: - self.axis += ndims - - # Validate axes - if self.axis < 0 or self.axis >= ndims: - raise ValueError(f"Invalid axis: {self.axis}") - - param_shape = [input_shape[self.axis]] - self.scale = self.add_weight( - name="scale", - shape=param_shape, - initializer="ones") - if self.bias: - self.offset = self.add_weight( - name="offset", - shape=param_shape, - initializer="zeros") - - self.built = True # pylint:disable=attribute-defined-outside-init - - def call(self, inputs, **kwargs): # pylint:disable=unused-argument - """ Call Root Mean Square Layer Normalization - - Parameters - ---------- - inputs: tensor - Input tensor, or list/tuple of input tensors - - Returns - ------- - tensor - A tensor or list/tuple of tensors - """ - # Compute the axes along which to reduce the mean / variance - input_shape = K.int_shape(inputs) - layer_size = input_shape[self.axis] - - if self.partial in (0.0, 1.0): - mean_square = K.mean(K.square(inputs), axis=self.axis, keepdims=True) - else: - partial_size = int(layer_size * self.partial) - partial_x, _ = tf.split( # pylint:disable=redundant-keyword-arg,no-value-for-parameter - inputs, - [partial_size, layer_size - partial_size], - axis=self.axis) - mean_square = K.mean(K.square(partial_x), axis=self.axis, keepdims=True) - - recip_square_root = tf.math.rsqrt(mean_square + self.epsilon) - output = self.scale * inputs * recip_square_root + self.offset - return output - - def compute_output_shape(self, input_shape): # pylint:disable=no-self-use - """ The output shape of the layer is the same as the input shape. - - Parameters - ---------- - input_shape: tuple - The input shape to the layer - - Returns - ------- - tuple - The output shape to the layer - """ - return input_shape - - def get_config(self): - """Returns the config of the layer. - - A layer config is a Python dictionary (serializable) containing the configuration of a - layer. The same layer can be reinstated later (without its trained weights) from this - configuration. - - The configuration of a layer does not include connectivity information, nor the layer - class name. These are handled by `Network` (one layer of abstraction above). - - Returns - -------- - dict - A python dictionary containing the layer configuration - """ - base_config = super().get_config() - config = dict(axis=self.axis, - epsilon=self.epsilon, - partial=self.partial, - bias=self.bias) - return dict(list(base_config.items()) + list(config.items())) - - -# Update normalization into Keras custom objects -for name, obj in inspect.getmembers(sys.modules[__name__]): - if inspect.isclass(obj) and obj.__module__ == __name__: - get_custom_objects().update({name: obj}) diff --git a/lib/model/optimizers_tf.py b/lib/model/optimizers.py similarity index 92% rename from lib/model/optimizers_tf.py rename to lib/model/optimizers.py index 9b028a55fa..33efe7c8bd 100644 --- a/lib/model/optimizers_tf.py +++ b/lib/model/optimizers.py @@ -1,8 +1,5 @@ #!/usr/bin/env python3 """ Custom Optimizers for TensorFlow 2.x/tf.keras """ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function import inspect import sys @@ -10,8 +7,8 @@ import tensorflow as tf # Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras.optimizers import (Adam, Nadam, RMSprop) # noqa pylint:disable=no-name-in-module,unused-import,import-error -from tensorflow.keras.utils import get_custom_objects # noqa pylint:disable=no-name-in-module,import-error +from tensorflow.keras.optimizers import Adam, Nadam, RMSprop # noqa:E501,F401 pylint:disable=import-error,unused-import +keras = tf.keras class AdaBelief(tf.keras.optimizers.Optimizer): @@ -381,22 +378,22 @@ def get_config(self): The optimizer configuration. """ config = super().get_config() - config.update(dict(learning_rate=self._serialize_hyperparameter("learning_rate"), - beta_1=self._serialize_hyperparameter("beta_1"), - beta_2=self._serialize_hyperparameter("beta_2"), - decay=self._serialize_hyperparameter("decay"), - weight_decay=self._serialize_hyperparameter("weight_decay"), - sma_threshold=self._serialize_hyperparameter("sma_threshold"), - epsilon=self.epsilon, - amsgrad=self.amsgrad, - rectify=self.rectify, - total_steps=self._serialize_hyperparameter("total_steps"), - warmup_proportion=self._serialize_hyperparameter("warmup_proportion"), - min_lr=self._serialize_hyperparameter("min_lr"))) + config.update({"learning_rate": self._serialize_hyperparameter("learning_rate"), + "beta_1": self._serialize_hyperparameter("beta_1"), + "beta_2": self._serialize_hyperparameter("beta_2"), + "decay": self._serialize_hyperparameter("decay"), + "weight_decay": self._serialize_hyperparameter("weight_decay"), + "sma_threshold": self._serialize_hyperparameter("sma_threshold"), + "epsilon": self.epsilon, + "amsgrad": self.amsgrad, + "rectify": self.rectify, + "total_steps": self._serialize_hyperparameter("total_steps"), + "warmup_proportion": self._serialize_hyperparameter("warmup_proportion"), + "min_lr": self._serialize_hyperparameter("min_lr")}) return config # Update layers into Keras custom objects for _name, obj in inspect.getmembers(sys.modules[__name__]): if inspect.isclass(obj) and obj.__module__ == __name__: - get_custom_objects().update({_name: obj}) + keras.utils.get_custom_objects().update({_name: obj}) diff --git a/lib/model/optimizers_plaid.py b/lib/model/optimizers_plaid.py deleted file mode 100644 index 848cabff3b..0000000000 --- a/lib/model/optimizers_plaid.py +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python3 -""" Custom Optimizers for PlaidML/Keras 2.2. """ -import inspect -import sys - -from keras import backend as K -from keras.optimizers import Optimizer, Adam, Nadam, RMSprop # noqa pylint:disable=unused-import -from keras.utils import get_custom_objects - - -class AdaBelief(Optimizer): - """AdaBelief optimizer. - - Default parameters follow those provided in the original paper. - - Parameters - ---------- - learning_rate: float - The learning rate. - beta_1: float - The exponential decay rate for the 1st moment estimates. - beta_2: float - The exponential decay rate for the 2nd moment estimates. - epsilon: float, optional - A small constant for numerical stability. Default: `K.epsilon()`. - amsgrad: bool - Whether to apply AMSGrad variant of this algorithm from the paper "On the Convergence - of Adam and beyond". - - References - ---------- - AdaBelief - A Method for Stochastic Optimization - https://arxiv.org/abs/1412.6980v8 - On the Convergence of AdaBelief and Beyond - https://openreview.net/forum?id=ryQu7f-RZ - - Adapted from https://github.com/liaoxuanzhi/adabelief - - BSD 2-Clause License - - Copyright (c) 2021, Juntang Zhuang - All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are met: - - 1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE - FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL - DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR - SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER - CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, - OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - """ - - def __init__(self, lr=0.001, beta_1=0.9, beta_2=0.999, - epsilon=None, decay=0., weight_decay=0.0, **kwargs): - super().__init__(**kwargs) - with K.name_scope(self.__class__.__name__): - self.iterations = K.variable(0, dtype='int64', name='iterations') - self.lr = K.variable(lr, name='lr') - self.beta_1 = K.variable(beta_1, name='beta_1') - self.beta_2 = K.variable(beta_2, name='beta_2') - self.decay = K.variable(decay, name='decay') - if epsilon is None: - epsilon = K.epsilon() - self.epsilon = float(epsilon) - self.initial_decay = decay - self.weight_decay = float(weight_decay) - - def get_updates(self, loss, params): # pylint:disable=too-many-locals - """ Get the weight updates - - Parameters - ---------- - loss: list - The loss to update - params: list - The variables - """ - grads = self.get_gradients(loss, params) - self.updates = [K.update_add(self.iterations, 1)] - - l_r = self.lr - if self.initial_decay > 0: - l_r = l_r * (1. / (1. + self.decay * K.cast(self.iterations, - K.dtype(self.decay)))) - - var_t = K.cast(self.iterations, K.floatx()) + 1 - # bias correction - bias_correction1 = 1. - K.pow(self.beta_1, var_t) - bias_correction2 = 1. - K.pow(self.beta_2, var_t) - - m_s = [K.zeros(K.int_shape(p), dtype=K.dtype(p)) for p in params] - v_s = [K.zeros(K.int_shape(p), dtype=K.dtype(p)) for p in params] - - self.weights = [self.iterations] + m_s + v_s - - for param, grad, var_m, var_v in zip(params, grads, m_s, v_s): - if self.weight_decay != 0.: - grad += self.weight_decay * K.stop_gradient(param) - - m_t = (self.beta_1 * var_m) + (1. - self.beta_1) * grad - m_corr_t = m_t / bias_correction1 - - v_t = (self.beta_2 * var_v) + (1. - self.beta_2) * K.square(grad - m_t) + self.epsilon - v_corr_t = K.sqrt(v_t / bias_correction2) - - p_t = param - l_r * m_corr_t / (v_corr_t + self.epsilon) - - self.updates.append(K.update(var_m, m_t)) - self.updates.append(K.update(var_v, v_t)) - new_param = p_t - - # Apply constraints. - if getattr(param, 'constraint', None) is not None: - new_param = param.constraint(new_param) - - self.updates.append(K.update(param, new_param)) - return self.updates - - def get_config(self): - """ Returns the config of the optimizer. - - An optimizer config is a Python dictionary (serializable) containing the configuration of - an optimizer. The same optimizer can be re-instantiated later (without any saved state) - from this configuration. - - Returns - ------- - dict - The optimizer configuration. - """ - config = dict(lr=float(K.get_value(self.lr)), - beta_1=float(K.get_value(self.beta_1)), - beta_2=float(K.get_value(self.beta_2)), - decay=float(K.get_value(self.decay)), - epsilon=self.epsilon, - weight_decay=self.weight_decay) - base_config = super().get_config() - return dict(list(base_config.items()) + list(config.items())) - - -# Update layers into Keras custom objects -for name, obj in inspect.getmembers(sys.modules[__name__]): - if inspect.isclass(obj) and obj.__module__ == __name__: - get_custom_objects().update({name: obj}) diff --git a/lib/model/session.py b/lib/model/session.py index eb66e8bacf..a7450d7400 100644 --- a/lib/model/session.py +++ b/lib/model/session.py @@ -8,15 +8,11 @@ import numpy as np import tensorflow as tf -from lib.utils import get_backend +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras.layers import Activation # pylint:disable=import-error +from tensorflow.keras.models import load_model as k_load_model, Model # noqa:E501 # pylint:disable=import-error -if get_backend() == "amd": - from keras.layers import Activation - from keras.models import load_model as k_load_model, Model -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras.layers import Activation # noqa pylint:disable=no-name-in-module,import-error - from tensorflow.keras.models import load_model as k_load_model, Model # noqa pylint:disable=no-name-in-module,import-error +from lib.utils import get_backend logger = logging.getLogger(__name__) # pylint:disable=invalid-name @@ -28,8 +24,7 @@ class KSession(): actions performed on a model are handled consistently and can be performed in parallel in separate threads. - This is an early implementation of this class, and should be expanded out over time - with relevant `AMD`, `CPU` and `NVIDIA` backend methods. + This is an early implementation of this class, and should be expanded out over time. Notes ----- @@ -81,9 +76,7 @@ def predict(self, """ Get predictions from the model. This method is a wrapper for :func:`keras.predict()` function. For Tensorflow backends - this is a straight call to the predict function. For PlaidML backends, this attempts - to optimize the inference batch sizes to reduce the number of kernels that need to be - compiled. + this is a straight call to the predict function. Parameters ---------- @@ -100,53 +93,14 @@ def predict(self, """ assert self._model is not None with self._context: - if self._backend == "amd" and batch_size is not None: - return self._amd_predict_with_optimized_batchsizes(feed, batch_size) return self._model.predict(feed, verbose=0, batch_size=batch_size) - def _amd_predict_with_optimized_batchsizes( - self, - feed: Union[List[np.ndarray], np.ndarray], - batch_size: int) -> Union[List[np.ndarray], np.ndarray]: - """ Minimizes the amount of kernels to be compiled when using the ``amd`` backend with - varying batch sizes while trying to keep the batchsize as high as possible. - - Parameters - ---------- - feed: numpy.ndarray or list - The feed to be provided to the model as input. This should be a ``numpy.ndarray`` - for single inputs or a ``list`` of ``numpy.ndarray`` objects for multiple inputs. - batch_size: int - The upper batchsize to use. - """ - assert self._model is not None - if isinstance(feed, np.ndarray): - feed = [feed] - items = feed[0].shape[0] - done_items = 0 - results = [] - while done_items < items: - if batch_size < 4: # Not much difference in BS < 4 - batch_size = 1 - batch_items = ((items - done_items) // batch_size) * batch_size - if batch_items: - pred_data = [x[done_items:done_items + batch_items] for x in feed] - pred = self._model.predict(pred_data, batch_size=batch_size) - done_items += batch_items - results.append(pred) - batch_size //= 2 - if isinstance(results[0], np.ndarray): - return np.concatenate(results) - return [np.concatenate(x) for x in zip(*results)] - def _set_session(self, allow_growth: bool, exclude_gpus: list, cpu_mode: bool) -> ContextManager: """ Sets the backend session options. - For AMD backend this does nothing. - For CPU backends, this hides any GPUs from Tensorflow. For Nvidia backends, this hides any GPUs that Tensorflow should not use and applies @@ -165,8 +119,6 @@ def _set_session(self, ``True`` run the model on CPU. Default: ``False`` """ retval = nullcontext() - if self._backend == "amd": - return retval if self._backend == "cpu": logger.verbose("Hiding GPUs from Tensorflow") # type:ignore tf.config.set_visible_devices([], "GPU") @@ -201,8 +153,7 @@ def load_model(self) -> None: logger.verbose("Initializing plugin model: %s", self._name) # type:ignore with self._context: self._model = k_load_model(self._model_path, compile=False, **self._model_kwargs) - if self._backend != "amd": - self._model.make_predict_function() + self._model.make_predict_function() def define_model(self, function: Callable) -> None: """ Defines a model from the given function. @@ -233,8 +184,7 @@ def load_model_weights(self) -> None: assert self._model is not None with self._context: self._model.load_weights(self._model_path) - if self._backend != "amd": - self._model.make_predict_function() + self._model.make_predict_function() def append_softmax_activation(self, layer_index: int = -1) -> None: """ Append a softmax activation layer to a model diff --git a/lib/plaidml_utils.py b/lib/plaidml_utils.py deleted file mode 100644 index 7af2f63df8..0000000000 --- a/lib/plaidml_utils.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python3 -""" PlaidML helper Utilities """ -from typing import Optional - -import plaidml - - -def pad(data: plaidml.tile.Value, - paddings, - mode: str = "CONSTANT", - name: Optional[str] = None, # pylint:disable=unused-argument - constant_value: int = 0) -> plaidml.tile.Value: - """ PlaidML Pad - - Notes - ----- - Currently only Reflect padding is supported. - - Parameters - ---------- - data :class:`plaidm.tile.Value` - The tensor to pad - mode: str, optional - The padding mode to use. Default: `"CONSTANT"` - name: str, optional - The name for the operation. Unused but kept for consistency with tf.pad. Default: ``None`` - constant_value: int, optional - The value to pad the Tensor with. Default: `0` - - Returns - ------- - :class:`plaidm.tile.Value` - The padded tensor - """ - # TODO: use / implement other padding method when required - # CONSTANT -> SpatialPadding ? | Doesn't support first and last axis + - # no support for constant_value - # SYMMETRIC -> Requires implement ? - if mode.upper() != "REFLECT": - raise NotImplementedError("pad only supports mode == 'REFLECT'") - if constant_value != 0: - raise NotImplementedError("pad does not support constant_value != 0") - return plaidml.op.reflection_padding(data, paddings) - - -def is_plaidml_error(error: Exception) -> bool: - """ Test whether the given exception is a plaidml Exception. - - Parameters - ---------- - error: :class:`Exception` - The generated error - - Returns - ------- - bool - ``True`` if the given error has been generated from plaidML otherwise ``False`` - """ - return isinstance(error, plaidml.exceptions.PlaidMLError) diff --git a/lib/utils.py b/lib/utils.py index 727728bf84..ad232a6bf1 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -35,7 +35,7 @@ ".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv", ".ts", ".vob"] _TF_VERS: Optional[Tuple[int, int]] = None -ValidBackends = Literal["amd", "nvidia", "cpu", "apple_silicon", "directml", "rocm"] +ValidBackends = Literal["nvidia", "cpu", "apple_silicon", "directml", "rocm"] class _Backend(): # pylint:disable=too-few-public-methods @@ -48,8 +48,7 @@ def __init__(self) -> None: "2": "directml", "3": "nvidia", "4": "apple_silicon", - "5": "rocm", - "6": "amd"} + "5": "rocm"} self._valid_backends = list(self._backends.values()) self._config_file = self._get_config_file() self.backend = self._get_backend() @@ -138,7 +137,7 @@ def get_backend() -> ValidBackends: Returns ------- str - The backend configuration in use by Faceswap. One of ["amd", "cpu", "directml", "nvidia", + The backend configuration in use by Faceswap. One of ["cpu", "directml", "nvidia", "rocm", "apple_silicon"] Example @@ -155,7 +154,7 @@ def set_backend(backend: str) -> None: Parameters ---------- - backend: ["amd", "cpu", "directml", "nvidia", "apple_silicon"] + backend: ["cpu", "directml", "nvidia", "rocm", "apple_silicon"] The backend to set faceswap to Example @@ -766,7 +765,7 @@ def __init__(self, self._times: Dict[str, List[float]] = {} self._steps: Dict[str, float] = {} self._interval = 1 - self._display = dict(min=show_min, mean=show_mean, max=show_max) + self._display = {"min": show_min, "mean": show_mean, "max": show_max} def step_start(self, name: str, record: bool = True) -> None: """ Start the timer for the given step name. diff --git a/locales/plugins.extract._config.pot b/locales/plugins.extract._config.pot index 0c909e292c..a65b8fda60 100644 --- a/locales/plugins.extract._config.pot +++ b/locales/plugins.extract._config.pot @@ -27,7 +27,7 @@ msgstr "" #: plugins/extract/_config.py:39 msgid "" -"[Nvidia Only]. Enable the Tensorflow GPU `allow_growth` configuration " +"Enable the Tensorflow GPU `allow_growth` configuration " "option. This option prevents Tensorflow from allocating all of the GPU VRAM " "at launch but can lead to higher VRAM fragmentation and slower performance. " "Should only be enabled if you are having problems running extraction." diff --git a/locales/plugins.train._config.pot b/locales/plugins.train._config.pot index 45d53c2254..90ff2866a8 100644 --- a/locales/plugins.train._config.pot +++ b/locales/plugins.train._config.pot @@ -276,7 +276,7 @@ msgstr "" #: plugins/train/_config.py:258 msgid "" -"[Not PlaidML] Apply AutoClipping to the gradients. AutoClip analyzes the " +"Apply AutoClipping to the gradients. AutoClip analyzes the " "gradient weights and adjusts the normalization value dynamically to fit the " "data. Can help prevent NaNs and improve model optimization at the expense of " "VRAM. Ref: AutoClip: Adaptive Gradient Clipping for Source Separation " @@ -299,7 +299,7 @@ msgstr "" #: plugins/train/_config.py:286 msgid "" -"[Nvidia Only]. Enable the Tensorflow GPU 'allow_growth' configuration " +"Enable the Tensorflow GPU 'allow_growth' configuration " "option. This option prevents Tensorflow from allocating all of the GPU VRAM " "at launch but can lead to higher VRAM fragmentation and slower performance. " "Should only be enabled if you are receiving errors regarding 'cuDNN fails to " @@ -308,7 +308,7 @@ msgstr "" #: plugins/train/_config.py:299 msgid "" -"[Not PlaidML], NVIDIA GPUs can run operations in float16 faster than in " +"NVIDIA GPUs can run operations in float16 faster than in " "float32. Mixed precision allows you to use a mix of float16 with float32, to " "get the performance benefits from float16 and the numeric stability benefits " "from float32.\n" diff --git a/locales/ru/LC_MESSAGES/plugins.extract._config.po b/locales/ru/LC_MESSAGES/plugins.extract._config.po index 8ba2e9c4dd..0500d72cda 100644 --- a/locales/ru/LC_MESSAGES/plugins.extract._config.po +++ b/locales/ru/LC_MESSAGES/plugins.extract._config.po @@ -27,12 +27,12 @@ msgstr "настройки" #: plugins/extract/_config.py:39 msgid "" -"[Nvidia Only]. Enable the Tensorflow GPU `allow_growth` configuration " +"Enable the Tensorflow GPU `allow_growth` configuration " "option. This option prevents Tensorflow from allocating all of the GPU VRAM " "at launch but can lead to higher VRAM fragmentation and slower performance. " "Should only be enabled if you are having problems running extraction." msgstr "" -"[Только для Nvidia]. Включите опцию конфигурации Tensorflow GPU " +"Включите опцию конфигурации Tensorflow GPU " "`allow_growth`. Эта опция не позволяет Tensorflow выделять всю видеопамять " "видеокарты при запуске, но может привести к повышенной фрагментации " "видеопамяти и снижению производительности. Следует включать только в том " diff --git a/locales/ru/LC_MESSAGES/plugins.train._config.mo b/locales/ru/LC_MESSAGES/plugins.train._config.mo index d739c561287f94b873aa91f69395b97768acf0db..7cbd6379b30ee71b819b9fb10f9eb9530932a00e 100644 GIT binary patch delta 1262 zcmYk53ux747>A#8&gM4HY~9vUXWQD`UC(Blqav4=Hk+9YW1|F_DMUqN1wxHDV&&3o ziN=)uG*!%?_UB>IDTlUo4USi8T_q(vBS3G zKXpPvChrE9Q`BYFvP51bOVc*qv(Yd$aB+&4WmxdLERg@mE3#k(nH$Tw$EPviSMtjc zconQS(3NiJb>3R9<)HnCWse75*8_F>NA8D5Ji)hX;2obky|T|MbRhl^%#b|~!{c(p zqh@7YeDav5cY`fY!4CTeH^FHG%zhe<8gNpE_51qaLH%E!h54@Q^=9L)+xk4*<@{{} z*_)Y=-|L_A{XRB6hqu9NF5JG|fNoT|176YpXwdi9U$+yg<T}G1Kja=Wo;(Vlx&P)9aLV}fp0v8%pzM9P)j;Pz zbdnSD&zOz8bQY{fzB_N9Oc=!ircj3Bs*#2In=Zg~`J4>%2Bn41uyh);Rj?bCKMJ0a zV&ng3DO>fbLWPYyOUCB=4K)ygSbv$|b7iv%R<6oIL#xSxk|_q5H0DQ@s#L+QQ}a~% z>frs){B`jsph-2TYgD!J56xJ(iC~RcWU9E%wtp`EESc(FC}*l$RIMsfEh@!oK9sXD`eoUoSX7d`As+p?d`T?o&ub};+B-($(RgX9HoCp*&RB9mQ+adS yZA}Xsnwq1nD~4jxoDWj*s9@!APPFUnSUei({;f7ze=3z54L!5AE4pLibnIWkxV`NF delta 1360 zcmZvb3v7*N7{~wL=|wN7+tI46{Z1)ITkYv-OAy`Et*Vp|G6zfPEN2mhk|3O>=}eYM zMct+1780Szhq%SkMTk)j5@AsyVH??ES%gF^+wVQ65fX3me82a(e9!Z|x0byz-|vgr zm=~(Iq|`X+jHI3M(m*(^lhlHJH7tcWiPCtN6ikwuW26(Er4sB_-K4j~f7e~|px5`1 zUJ!Snr?dfmb8jgCv(oSpx3-UT4_fKcXV`yDS4d45(m#<7V`zak`WG2A16RTI(49$B za5wx7{sX5If8Um_q335wlhNP6sc=Gnnxc_i*-?7DQXTrC9O+N!9V{*7KesO=J?Dq@ zK8fbE%r6~h)f-?5D=i%^#pACXDJ{Xjx>RZ-!C&xS^z);nO#Eldq!@UAbhLr?G158W z3dTuOus<)CNUISQQY;(|yTbAilU61(I~;u`MguaS`Jo?V_mvCZz!GS-&{xU!Q6O=W zG@k-bs>p%8e5y2q#`nNM_+L+#zM-NA=ShzD#{fHtebhXu3k+?kkrp%Azd$<5J?I&f zl1b3Ih+yn(Ec+J{&#GmW6v|#6P4H-iG!MOKrQ{-h>ndq1aXZ#TcjgiN1HFB%bO&B$ zr?(TovyM9CEvlCqEzbXOgH()R$vSBOJjj7p&~e)j(m0sb80D8-lmHDL+$P;5p{rSX zM&a9#P0@wD97`7|VINJf(pSf%yA<4bTDpS&>oeRh{x3Ky4WrJ(i`*rPuF_himjwRz zGh2w`BWC3T5VIpC2qz+5S7hZy& None: datatype=bool, default=False, group=_("settings"), - info=_("[Nvidia Only]. Enable the Tensorflow GPU `allow_growth` configuration option. " + info=_("Enable the Tensorflow GPU `allow_growth` configuration option. " "This option prevents Tensorflow from allocating all of the GPU VRAM at launch " "but can lead to higher VRAM fragmentation and slower performance. Should only " "be enabled if you are having problems running extraction.")) diff --git a/plugins/extract/align/_base/aligner.py b/plugins/extract/align/_base/aligner.py index caac79011f..9f15dbef2c 100644 --- a/plugins/extract/align/_base/aligner.py +++ b/plugins/extract/align/_base/aligner.py @@ -24,7 +24,7 @@ from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa -from lib.utils import get_backend, FaceswapError +from lib.utils import FaceswapError from plugins.extract._base import BatchType, Extractor, ExtractMedia, ExtractorBatch from .processing import AlignedFilter, ReAlign @@ -547,23 +547,6 @@ def _predict(self, batch: BatchType) -> AlignerBatch: "CLI: Edit the file faceswap/config/extract.ini)." "\n3) Enable 'Single Process' mode.") raise FaceswapError(msg) from err - except Exception as err: - if get_backend() == "amd": - # pylint:disable=import-outside-toplevel - from lib.plaidml_utils import is_plaidml_error - if (is_plaidml_error(err) and ( - "CL_MEM_OBJECT_ALLOCATION_FAILURE" in str(err).upper() or - "enough memory for the current schedule" in str(err).lower())): - msg = ("You do not have enough GPU memory available to run detection at " - "the selected batch size. You can try a number of things:" - "\n1) Close any other application that is using your GPU (web " - "browsers are particularly bad for this)." - "\n2) Lower the batchsize (the amount of images fed into the " - "model) by editing the plugin settings (GUI: Settings > Configure " - "extract settings, CLI: Edit the file " - "faceswap/config/extract.ini).") - raise FaceswapError(msg) from err - raise def _process_refeeds(self, batch: AlignerBatch) -> List[AlignerBatch]: """ Process the output for each selected re-feed diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index 156d97bbdb..10488ed52b 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -25,7 +25,7 @@ from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa from lib.align import DetectedFace -from lib.utils import get_backend, FaceswapError +from lib.utils import FaceswapError from plugins.extract._base import BatchType, Extractor, ExtractorBatch from plugins.extract.pipeline import ExtractMedia @@ -295,23 +295,6 @@ def _predict(self, batch: BatchType) -> DetectorBatch: "CLI: Edit the file faceswap/config/extract.ini)." "\n3) Enable 'Single Process' mode.") raise FaceswapError(msg) from err - except Exception as err: - if get_backend() == "amd": - # pylint:disable=import-outside-toplevel - from lib.plaidml_utils import is_plaidml_error - if (is_plaidml_error(err) and ( - "CL_MEM_OBJECT_ALLOCATION_FAILURE" in str(err).upper() or - "enough memory for the current schedule" in str(err).lower())): - msg = ("You do not have enough GPU memory available to run detection at " - "the selected batch size. You can try a number of things:" - "\n1) Close any other application that is using your GPU (web " - "browsers are particularly bad for this)." - "\n2) Lower the batchsize (the amount of images fed into the " - "model) by editing the plugin settings (GUI: Settings > Configure " - "extract settings, CLI: Edit the file " - "faceswap/config/extract.ini).") - raise FaceswapError(msg) from err - raise if angle != 0 and any(face.any() for face in batch.prediction): logger.verbose("found face(s) by rotating image %s " # type:ignore[attr-defined] diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py index eb1d0e5599..4c14d1221a 100644 --- a/plugins/extract/detect/mtcnn.py +++ b/plugins/extract/detect/mtcnn.py @@ -1,22 +1,19 @@ #!/usr/bin/env python3 """ MTCNN Face detection plugin """ -from __future__ import absolute_import, division, print_function +from __future__ import annotations import logging -from typing import Dict, List, Optional, Tuple, Union +import typing as T import cv2 import numpy as np +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras.layers import Conv2D, Dense, Flatten, Input, MaxPool2D, Permute, PReLU # noqa:E501 # pylint:disable=import-error + from lib.model.session import KSession -from lib.utils import get_backend from ._base import BatchType, Detector -if get_backend() == "amd": - from keras.layers import Conv2D, Dense, Flatten, Input, MaxPool2D, Permute, PReLU - from plaidml.tile import Value as Tensor # pylint:disable=import-error -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras.layers import Conv2D, Dense, Flatten, Input, MaxPool2D, Permute, PReLU # noqa pylint:disable=no-name-in-module,import-error +if T.TYPE_CHECKING: from tensorflow import Tensor logger = logging.getLogger(__name__) @@ -37,7 +34,7 @@ def __init__(self, **kwargs) -> None: self.kwargs = self._validate_kwargs() self.color_format = "RGB" - def _validate_kwargs(self) -> Dict[str, Union[int, float, List[float]]]: + def _validate_kwargs(self) -> T.Dict[str, T.Union[int, float, T.List[float]]]: """ Validate that config options are correct. If not reset to default """ valid = True threshold = [self.config["threshold_1"], @@ -167,7 +164,7 @@ class PNet(KSession): def __init__(self, model_path: str, allow_growth: bool, - exclude_gpus: Optional[List[int]], + exclude_gpus: T.Optional[T.List[int]], cpu_mode: bool, input_size: int, min_size: int, @@ -188,10 +185,10 @@ def __init__(self, self._pnet_scales = self._calculate_scales(min_size, factor) self._pnet_sizes = [(int(input_size * scale), int(input_size * scale)) for scale in self._pnet_scales] - self._pnet_input: Optional[List[np.ndarray]] = None + self._pnet_input: T.Optional[T.List[np.ndarray]] = None @staticmethod - def model_definition() -> Tuple[List[Tensor], List[Tensor]]: + def model_definition() -> T.Tuple[T.List[Tensor], T.List[Tensor]]: """ Keras P-Network Definition for MTCNN """ input_ = Input(shape=(None, None, 3)) var_x = Conv2D(10, (3, 3), strides=1, padding='valid', name='conv1')(input_) @@ -207,7 +204,7 @@ def model_definition() -> Tuple[List[Tensor], List[Tensor]]: def _calculate_scales(self, minsize: int, - factor: float) -> List[float]: + factor: float) -> T.List[float]: """ Calculate multi-scale Parameters @@ -234,7 +231,7 @@ def _calculate_scales(self, logger.trace(scales) # type:ignore return scales - def __call__(self, images: np.ndarray) -> List[np.ndarray]: + def __call__(self, images: np.ndarray) -> T.List[np.ndarray]: """ first stage - fast proposal network (p-net) to obtain face candidates Parameters @@ -248,8 +245,8 @@ def __call__(self, images: np.ndarray) -> List[np.ndarray]: List of face candidates from P-Net """ batch_size = images.shape[0] - rectangles: List[List[List[Union[int, float]]]] = [[] for _ in range(batch_size)] - scores: List[List[np.ndarray]] = [[] for _ in range(batch_size)] + rectangles: T.List[T.List[T.List[T.Union[int, float]]]] = [[] for _ in range(batch_size)] + scores: T.List[T.List[np.ndarray]] = [[] for _ in range(batch_size)] if self._pnet_input is None: self._pnet_input = [np.empty((batch_size, rheight, rwidth, 3), dtype="float32") @@ -281,7 +278,7 @@ def _detect_face_12net(self, class_probabilities: np.ndarray, roi: np.ndarray, size: int, - scale: float) -> Tuple[np.ndarray, np.ndarray]: + scale: float) -> T.Tuple[np.ndarray, np.ndarray]: """ Detect face position and calibrate bounding box on 12net feature map(matrix version) Parameters @@ -347,7 +344,7 @@ class RNet(KSession): def __init__(self, model_path: str, allow_growth: bool, - exclude_gpus: Optional[List[int]], + exclude_gpus: T.Optional[T.List[int]], cpu_mode: bool, input_size: int, threshold: float) -> None: @@ -363,7 +360,7 @@ def __init__(self, self._threshold = threshold @staticmethod - def model_definition() -> Tuple[List[Tensor], List[Tensor]]: + def model_definition() -> T.Tuple[T.List[Tensor], T.List[Tensor]]: """ Keras R-Network Definition for MTCNN """ input_ = Input(shape=(24, 24, 3)) var_x = Conv2D(28, (3, 3), strides=1, padding='valid', name='conv1')(input_) @@ -386,8 +383,8 @@ def model_definition() -> Tuple[List[Tensor], List[Tensor]]: def __call__(self, images: np.ndarray, - rectangle_batch: List[np.ndarray], - ) -> List[np.ndarray]: + rectangle_batch: T.List[np.ndarray], + ) -> T.List[np.ndarray]: """ second stage - refinement of face candidates with r-net Parameters @@ -402,7 +399,7 @@ def __call__(self, List List of :class:`numpy.ndarray` refined face candidates from R-Net """ - ret: List[np.ndarray] = [] + ret: T.List[np.ndarray] = [] for idx, (rectangles, image) in enumerate(zip(rectangle_batch, images)): if not np.any(rectangles): ret.append(np.array([])) @@ -415,8 +412,7 @@ def __call__(self, dst=feed_batch[idx]) for idx, rect in enumerate(rectangles)] - cls_prob, roi_prob = self.predict(feed_batch, - batch_size=128 if get_backend() == "amd" else None) + cls_prob, roi_prob = self.predict(feed_batch) ret.append(self._filter_face_24net(cls_prob, roi_prob, rectangles)) return ret @@ -478,7 +474,7 @@ class ONet(KSession): def __init__(self, model_path: str, allow_growth: bool, - exclude_gpus: Optional[List[int]], + exclude_gpus: T.Optional[T.List[int]], cpu_mode: bool, input_size: int, threshold: float) -> None: @@ -494,7 +490,7 @@ def __init__(self, self._threshold = threshold @staticmethod - def model_definition() -> Tuple[List[Tensor], List[Tensor]]: + def model_definition() -> T.Tuple[T.List[Tensor], T.List[Tensor]]: """ Keras O-Network for MTCNN """ input_ = Input(shape=(48, 48, 3)) var_x = Conv2D(32, (3, 3), strides=1, padding='valid', name='conv1')(input_) @@ -520,8 +516,8 @@ def model_definition() -> Tuple[List[Tensor], List[Tensor]]: def __call__(self, images: np.ndarray, - rectangle_batch: List[np.ndarray] - ) -> List[Tuple[np.ndarray, np.ndarray]]: + rectangle_batch: T.List[np.ndarray] + ) -> T.List[T.Tuple[np.ndarray, np.ndarray]]: """ Third stage - further refinement and facial landmarks positions with o-net Parameters @@ -536,7 +532,7 @@ def __call__(self, List List of refined final candidates, scores and landmark points from O-Net """ - ret: List[Tuple[np.ndarray, np.ndarray]] = [] + ret: T.List[T.Tuple[np.ndarray, np.ndarray]] = [] for idx, rectangles in enumerate(rectangle_batch): if not np.any(rectangles): ret.append((np.empty((0, 5)), np.empty(0))) @@ -549,16 +545,14 @@ def __call__(self, dst=feed_batch[idx]) for idx, rect in enumerate(rectangles)] - cls_probs, roi_probs, pts_probs = self.predict( - feed_batch, - batch_size=128 if get_backend() == "amd" else None) + cls_probs, roi_probs, pts_probs = self.predict(feed_batch) ret.append(self._filter_face_48net(cls_probs, roi_probs, pts_probs, rectangles)) return ret def _filter_face_48net(self, class_probabilities: np.ndarray, roi: np.ndarray, points: np.ndarray, - rectangles: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + rectangles: np.ndarray) -> T.Tuple[np.ndarray, np.ndarray]: """ Filter face position and calibrate bounding box on 12net's output Parameters @@ -629,13 +623,13 @@ class MTCNN(): # pylint: disable=too-few-public-methods Default: `0.709` """ def __init__(self, - model_path: List[str], + model_path: T.List[str], allow_growth: bool, - exclude_gpus: Optional[List[int]], + exclude_gpus: T.Optional[T.List[int]], cpu_mode: bool, input_size: int = 640, minsize: int = 20, - threshold: Optional[List[float]] = None, + threshold: T.Optional[T.List[float]] = None, factor: float = 0.709) -> None: logger.debug("Initializing: %s: (model_path: '%s', allow_growth: %s, exclude_gpus: %s, " "input_size: %s, minsize: %s, threshold: %s, factor: %s)", @@ -666,7 +660,7 @@ def __init__(self, logger.debug("Initialized: %s", self.__class__.__name__) - def detect_faces(self, batch: np.ndarray) -> Tuple[np.ndarray, Tuple[np.ndarray]]: + def detect_faces(self, batch: np.ndarray) -> T.Tuple[np.ndarray, T.Tuple[np.ndarray]]: """Detects faces in an image, and returns bounding boxes and points for them. Parameters @@ -690,7 +684,7 @@ def detect_faces(self, batch: np.ndarray) -> Tuple[np.ndarray, Tuple[np.ndarray] def nms(rectangles: np.ndarray, scores: np.ndarray, threshold: float, - method: str = "iom") -> Tuple[np.ndarray, np.ndarray]: + method: str = "iom") -> T.Tuple[np.ndarray, np.ndarray]: """ apply non-maximum suppression on ROIs in same scale(matrix version) Parameters diff --git a/plugins/extract/detect/mtcnn_defaults.py b/plugins/extract/detect/mtcnn_defaults.py index ace3230c3c..ea4f3fa2df 100755 --- a/plugins/extract/detect/mtcnn_defaults.py +++ b/plugins/extract/detect/mtcnn_defaults.py @@ -90,7 +90,7 @@ ), "cpu": dict( default=True, - info="[Not PlaidML] MTCNN detector still runs fairly quickly on CPU on some setups. " + info="MTCNN detector still runs fairly quickly on CPU on some setups. " "Enable CPU mode here to use the CPU for this detector to save some VRAM at a speed " "cost.", datatype=bool, diff --git a/plugins/extract/detect/s3fd.py b/plugins/extract/detect/s3fd.py index 2d1241c48a..853eeed5bf 100644 --- a/plugins/extract/detect/s3fd.py +++ b/plugins/extract/detect/s3fd.py @@ -5,27 +5,23 @@ Adapted from S3FD Port in FAN: https://github.com/1adrianb/face-alignment """ +from __future__ import annotations import logging -from typing import List, Optional, Tuple +import typing as T from scipy.special import logsumexp import numpy as np +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow import keras +from tensorflow.keras import backend as K # pylint:disable=import-error +from tensorflow.keras.layers import ( # pylint:disable=import-error + Concatenate, Conv2D, Input, Maximum, MaxPooling2D, ZeroPadding2D) + from lib.model.session import KSession -from lib.utils import get_backend from ._base import BatchType, Detector -if get_backend() == "amd": - import keras - from keras import backend as K - from keras.layers import Concatenate, Conv2D, Input, Maximum, MaxPooling2D, ZeroPadding2D - from plaidml.tile import Value as Tensor # pylint:disable=import-error -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow import keras - from tensorflow.keras import backend as K # pylint:disable=import-error - from tensorflow.keras.layers import ( # pylint:disable=no-name-in-module,import-error - Concatenate, Conv2D, Input, Maximum, MaxPooling2D, ZeroPadding2D) +if T.TYPE_CHECKING: from tensorflow import Tensor logger = logging.getLogger(__name__) @@ -48,7 +44,7 @@ def init_model(self) -> None: """ Initialize S3FD Model""" assert isinstance(self.model_path, str) confidence = self.config["confidence"] / 100 - model_kwargs = dict(custom_objects=dict(L2Norm=L2Norm, SliceO2K=SliceO2K)) + model_kwargs = {"custom_objects": {"L2Norm": L2Norm, "SliceO2K": SliceO2K}} self.model = S3fd(self.model_path, model_kwargs, self.config["allow_growth"], @@ -129,10 +125,10 @@ def get_config(self) -> dict: class SliceO2K(keras.layers.Layer): """ Custom Keras Slice layer generated by onnx2keras. """ def __init__(self, - starts: List[int], - ends: List[int], - axes: Optional[List[int]] = None, - steps: Optional[List[int]] = None, + starts: T.List[int], + ends: T.List[int], + axes: T.Optional[T.List[int]] = None, + steps: T.Optional[T.List[int]] = None, **kwargs) -> None: self._starts = starts self._ends = ends @@ -140,7 +136,7 @@ def __init__(self, self._steps = steps super().__init__(**kwargs) - def _get_slices(self, dimensions: int) -> List[Tuple[int, ...]]: + def _get_slices(self, dimensions: int) -> T.List[T.Tuple[int, ...]]: """ Obtain slices for the given number of dimensions. Parameters @@ -158,7 +154,7 @@ def _get_slices(self, dimensions: int) -> List[Tuple[int, ...]]: assert len(axes) == len(steps) == len(self._starts) == len(self._ends) return list(zip(axes, self._starts, self._ends, steps)) - def compute_output_shape(self, input_shape: Tuple[int, ...]) -> Tuple[int, ...]: + def compute_output_shape(self, input_shape: T.Tuple[int, ...]) -> T.Tuple[int, ...]: """Computes the output shape of the layer. Assumes that the layer will be built to match that input shape provided. @@ -234,7 +230,7 @@ def __init__(self, model_path: str, model_kwargs: dict, allow_growth: bool, - exclude_gpus: Optional[List[int]], + exclude_gpus: T.Optional[T.List[int]], confidence: float) -> None: logger.debug("Initializing: %s: (model_path: '%s', model_kwargs: %s, allow_growth: %s, " "exclude_gpus: %s, confidence: %s)", self.__class__.__name__, model_path, @@ -250,7 +246,7 @@ def __init__(self, self.average_img = np.array([104.0, 117.0, 123.0]) logger.debug("Initialized: %s", self.__class__.__name__) - def model_definition(self) -> Tuple[List[Tensor], List[Tensor]]: + def model_definition(self) -> T.Tuple[T.List[Tensor], T.List[Tensor]]: """ Keras S3FD Model Definition, adapted from FAN pytorch implementation. """ input_ = Input(shape=(640, 640, 3)) var_x = self.conv_block(input_, 64, 1, 2) @@ -400,7 +396,7 @@ def prepare_batch(self, batch: np.ndarray) -> np.ndarray: batch = batch - self.average_img return batch - def finalize_predictions(self, bounding_boxes_scales: List[np.ndarray]) -> np.ndarray: + def finalize_predictions(self, bounding_boxes_scales: T.List[np.ndarray]) -> np.ndarray: """ Process the output from the model to obtain faces Parameters @@ -417,7 +413,7 @@ def finalize_predictions(self, bounding_boxes_scales: List[np.ndarray]) -> np.nd ret.append(finallist) return np.array(ret, dtype="object") - def _post_process(self, bboxlist: List[np.ndarray]) -> np.ndarray: + def _post_process(self, bboxlist: T.List[np.ndarray]) -> np.ndarray: """ Perform post processing on output TODO: do this on the batch. """ diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index de9ab9ee07..34cd0c0651 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -22,7 +22,7 @@ from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa from lib.align import AlignedFace, transform_image -from lib.utils import get_backend, FaceswapError +from lib.utils import FaceswapError from plugins.extract._base import BatchType, Extractor, ExtractorBatch, ExtractMedia if TYPE_CHECKING: @@ -222,23 +222,6 @@ def _predict(self, batch: BatchType) -> MaskerBatch: "CLI: Edit the file faceswap/config/extract.ini)." "\n3) Enable 'Single Process' mode.") raise FaceswapError(msg) from err - except Exception as err: - if get_backend() == "amd": - # pylint:disable=import-outside-toplevel - from lib.plaidml_utils import is_plaidml_error - if (is_plaidml_error(err) and ( - "CL_MEM_OBJECT_ALLOCATION_FAILURE" in str(err).upper() or - "enough memory for the current schedule" in str(err).lower())): - msg = ("You do not have enough GPU memory available to run detection at " - "the selected batch size. You can try a number of things:" - "\n1) Close any other application that is using your GPU (web " - "browsers are particularly bad for this)." - "\n2) Lower the batchsize (the amount of images fed into the " - "model) by editing the plugin settings (GUI: Settings > Configure " - "extract settings, CLI: Edit the file " - "faceswap/config/extract.ini).") - raise FaceswapError(msg) from err - raise def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: """ Finalize the output from Masker diff --git a/plugins/extract/mask/bisenet_fp.py b/plugins/extract/mask/bisenet_fp.py index 6326ced393..79781de35b 100644 --- a/plugins/extract/mask/bisenet_fp.py +++ b/plugins/extract/mask/bisenet_fp.py @@ -4,28 +4,23 @@ Architecture and Pre-Trained Model ported from PyTorch to Keras by TorzDF from https://github.com/zllrunning/face-parsing.PyTorch """ +from __future__ import annotations import logging -from typing import cast, List, Optional, Tuple +import typing as T import numpy as np +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras import backend as K # pylint:disable=import-error +from tensorflow.keras.layers import ( # pylint:disable=import-error + Activation, Add, BatchNormalization, Concatenate, Conv2D, GlobalAveragePooling2D, Input, + MaxPooling2D, Multiply, Reshape, UpSampling2D, ZeroPadding2D) + from lib.model.session import KSession -from lib.utils import get_backend from plugins.extract._base import _get_config from ._base import BatchType, Masker, MaskerBatch -if get_backend() == "amd": - from keras import backend as K - from keras.layers import ( - Activation, Add, BatchNormalization, Concatenate, Conv2D, GlobalAveragePooling2D, Input, - MaxPooling2D, Multiply, Reshape, UpSampling2D, ZeroPadding2D) - from plaidml.tile import Value as Tensor # pylint:disable=import-error -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras import backend as K # pylint:disable=import-error - from tensorflow.keras.layers import ( # pylint:disable=no-name-in-module,import-error - Activation, Add, BatchNormalization, Concatenate, Conv2D, GlobalAveragePooling2D, Input, - MaxPooling2D, Multiply, Reshape, UpSampling2D, ZeroPadding2D) +if T.TYPE_CHECKING: from tensorflow import Tensor logger = logging.getLogger(__name__) @@ -54,7 +49,7 @@ def __init__(self, **kwargs) -> None: # Separate storage for face and head masks self._storage_name = f"{self._storage_name}_{self._storage_centering}" - def _check_weights_selection(self, configfile: Optional[str]) -> Tuple[bool, int]: + def _check_weights_selection(self, configfile: T.Optional[str]) -> T.Tuple[bool, int]: """ Check which weights have been selected. This is required for passing along the correct file name for the corresponding weights @@ -78,7 +73,7 @@ def _check_weights_selection(self, configfile: Optional[str]) -> Tuple[bool, int version = 1 if not is_faceswap else 2 if config.get("include_hair") else 3 return is_faceswap, version - def _get_segment_indices(self) -> List[int]: + def _get_segment_indices(self) -> T.List[int]: """ Obtain the segment indices to include within the face mask area based on user configuration settings. @@ -129,7 +124,7 @@ def process_input(self, batch: BatchType) -> None: mean = (0.384, 0.314, 0.279) if self._is_faceswap else (0.485, 0.456, 0.406) std = (0.324, 0.286, 0.275) if self._is_faceswap else (0.229, 0.224, 0.225) - batch.feed = ((np.array([cast(np.ndarray, feed.face)[..., :3] + batch.feed = ((np.array([T.cast(np.ndarray, feed.face)[..., :3] for feed in batch.feed_faces], dtype="float32") / 255.0) - mean) / std logger.trace("feed shape: %s", batch.feed.shape) # type:ignore @@ -168,7 +163,7 @@ def process_output(self, batch: BatchType) -> None: # SOFTWARE. -_NAME_TRACKER = set() +_NAME_TRACKER: T.Set[str] = set() def _get_name(name: str, start_idx: int = 1) -> str: @@ -559,7 +554,7 @@ class BiSeNet(KSession): def __init__(self, model_path: str, allow_growth: bool, - exclude_gpus: Optional[List[int]], + exclude_gpus: T.Optional[T.List[int]], input_size: int, num_classes: int, cpu_mode: bool) -> None: @@ -574,7 +569,7 @@ def __init__(self, self.define_model(self._model_definition) self.load_model_weights() - def _model_definition(self) -> Tuple[Tensor, List[Tensor]]: + def _model_definition(self) -> T.Tuple[Tensor, T.List[Tensor]]: """ Definition of the VGG Obstructed Model. Returns diff --git a/plugins/extract/mask/bisenet_fp_defaults.py b/plugins/extract/mask/bisenet_fp_defaults.py index ef9a828ea7..51b4b3540a 100644 --- a/plugins/extract/mask/bisenet_fp_defaults.py +++ b/plugins/extract/mask/bisenet_fp_defaults.py @@ -65,7 +65,7 @@ fixed=True), "cpu": dict( default=False, - info="[Not PlaidML] BiseNet mask still runs fairly quickly on CPU on some setups. Enable " + info="BiseNet mask still runs fairly quickly on CPU on some setups. Enable " "CPU mode here to use the CPU for this masker to save some VRAM at a speed cost.", datatype=bool, group="settings"), diff --git a/plugins/extract/mask/vgg_clear.py b/plugins/extract/mask/vgg_clear.py index 7bde0c84d4..9ab009e1a3 100644 --- a/plugins/extract/mask/vgg_clear.py +++ b/plugins/extract/mask/vgg_clear.py @@ -1,24 +1,20 @@ #!/usr/bin/env python3 """ VGG Clear face mask plugin. """ +from __future__ import annotations import logging -from typing import cast, List, Optional, Tuple +import typing as T import numpy as np +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras.layers import ( # pylint:disable=import-error + Add, Conv2D, Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, MaxPooling2D, + ZeroPadding2D) + from lib.model.session import KSession -from lib.utils import get_backend from ._base import BatchType, Masker, MaskerBatch -if get_backend() == "amd": - from keras.layers import ( - Add, Conv2D, Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, MaxPooling2D, - ZeroPadding2D) - from plaidml.tile import Value as Tensor # pylint:disable=import-error -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras.layers import ( # pylint:disable=no-name-in-module,import-error - Add, Conv2D, Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, MaxPooling2D, - ZeroPadding2D) +if T.TYPE_CHECKING: from tensorflow import Tensor logger = logging.getLogger(__name__) @@ -51,7 +47,7 @@ def init_model(self) -> None: def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction """ assert isinstance(batch, MaskerBatch) - input_ = np.array([cast(np.ndarray, feed.face)[..., :3] + input_ = np.array([T.cast(np.ndarray, feed.face)[..., :3] for feed in batch.feed_faces], dtype="float32") batch.feed = input_ - np.mean(input_, axis=(1, 2))[:, None, None, :] logger.trace("feed shape: %s", batch.feed.shape) # type: ignore @@ -98,7 +94,7 @@ class VGGClear(KSession): def __init__(self, model_path: str, allow_growth: bool, - exclude_gpus: Optional[List[int]]): + exclude_gpus: T.Optional[T.List[int]]): super().__init__("VGG Obstructed", model_path, allow_growth=allow_growth, @@ -107,7 +103,7 @@ def __init__(self, self.load_model_weights() @classmethod - def _model_definition(cls) -> Tuple[Tensor, Tensor]: + def _model_definition(cls) -> T.Tuple[Tensor, Tensor]: """ Definition of the VGG Obstructed Model. Returns @@ -214,7 +210,7 @@ class _ScorePool(): # pylint:disable=too-few-public-methods crop: tuple The amount of 2D cropping to apply. Tuple of `ints` """ - def __init__(self, level: int, scale: float, crop: Tuple[int, int]): + def __init__(self, level: int, scale: float, crop: T.Tuple[int, int]): self._name = f"_pool{level}" self._cropping = (crop, crop) self._scale = scale diff --git a/plugins/extract/mask/vgg_obstructed.py b/plugins/extract/mask/vgg_obstructed.py index 37a0b312f3..e7a5fa80c4 100644 --- a/plugins/extract/mask/vgg_obstructed.py +++ b/plugins/extract/mask/vgg_obstructed.py @@ -1,29 +1,24 @@ #!/usr/bin/env python3 """ VGG Obstructed face mask plugin """ +from __future__ import annotations import logging -from typing import cast, List, Optional, Tuple +import typing as T import numpy as np +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras.layers import ( # pylint:disable=import-error + Add, Conv2D, Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, MaxPooling2D, + ZeroPadding2D) + from lib.model.session import KSession -from lib.utils import get_backend from ._base import BatchType, Masker, MaskerBatch -logger = logging.getLogger(__name__) - - -if get_backend() == "amd": - from keras.layers import ( - Add, Conv2D, Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, MaxPooling2D, - ZeroPadding2D) - from plaidml.tile import Value as Tensor # pylint:disable=import-error -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras.layers import ( # pylint:disable=no-name-in-module,import-error - Add, Conv2D, Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, MaxPooling2D, - ZeroPadding2D) +if T.TYPE_CHECKING: from tensorflow import Tensor +logger = logging.getLogger(__name__) + class Mask(Masker): """ Neural network to process face image into a segmentation mask of the face """ @@ -52,7 +47,7 @@ def init_model(self) -> None: def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction """ assert isinstance(batch, MaskerBatch) - input_ = [cast(np.ndarray, feed.face)[..., :3] for feed in batch.feed_faces] + input_ = [T.cast(np.ndarray, feed.face)[..., :3] for feed in batch.feed_faces] batch.feed = input_ - np.mean(input_, axis=(1, 2))[:, None, None, :] logger.trace("feed shape: %s", batch.feed.shape) # type:ignore @@ -95,7 +90,7 @@ class VGGObstructed(KSession): def __init__(self, model_path: str, allow_growth: bool, - exclude_gpus: Optional[List[int]]) -> None: + exclude_gpus: T.Optional[T.List[int]]) -> None: super().__init__("VGG Obstructed", model_path, allow_growth=allow_growth, @@ -104,7 +99,7 @@ def __init__(self, self.load_model_weights() @classmethod - def _model_definition(cls) -> Tuple[Tensor, Tensor]: + def _model_definition(cls) -> T.Tuple[Tensor, Tensor]: """ Definition of the VGG Obstructed Model. Returns diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 3c60c10dfc..70b3722921 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -494,10 +494,10 @@ def _get_vram_stats() -> Dict[str, Union[int, str]]: vram_buffer = 256 # Leave a buffer for VRAM allocation gpu_stats = GPUStats() stats = gpu_stats.get_card_most_free() - retval: Dict[str, Union[int, str]] = dict(count=gpu_stats.device_count, - device=stats.device, - vram_free=int(stats.free - vram_buffer), - vram_total=int(stats.total)) + retval: Dict[str, Union[int, str]] = {"count": gpu_stats.device_count, + "device": stats.device, + "vram_free": int(stats.free - vram_buffer), + "vram_total": int(stats.total)} logger.debug(retval) return retval @@ -517,10 +517,6 @@ def _set_parallel_processing(self, multiprocess: bool) -> bool: logger.debug("No GPU detected. Enabling parallel processing.") return True - if get_backend() == "amd": - logger.debug("Parallel processing disabled by amd") - return False - logger.verbose("%s - %sMB free of %sMB", # type: ignore self._vram_stats["device"], self._vram_stats["vram_free"], @@ -545,7 +541,6 @@ def _set_phases(self, multiprocess: bool) -> List[List[str]]: list: The jobs to be undertaken split into phases that fit into GPU RAM """ - force_single_process = not multiprocess or get_backend() == "amd" phases: List[List[str]] = [] current_phase: List[str] = [] available = cast(int, self._vram_stats["vram_free"]) @@ -556,11 +551,11 @@ def _set_phases(self, multiprocess: bool) -> List[List[str]]: required = sum(self._vram_per_phase[p] for p in current_phase + [phase]) * scaling logger.debug("Num plugins for phase: %s, scaling: %s, vram required: %s", num_plugins, scaling, required) - if required <= available and not force_single_process: + if required <= available and multiprocess: logger.debug("Required: %s, available: %s. Adding phase '%s' to current phase: %s", required, available, phase, current_phase) current_phase.append(phase) - elif len(current_phase) == 0 or force_single_process: + elif len(current_phase) == 0 or not multiprocess: # Amount of VRAM required to run a single plugin is greater than available. We add # it anyway, and hope it will run with warnings, as the alternative is to not run # at all. @@ -692,7 +687,7 @@ def _launch_plugin(self, phase: str) -> None: next_phase = self._flow[self._flow.index(phase) + 1] out_qname = f"extract{self._instance}_{next_phase}_in" logger.debug("in_qname: %s, out_qname: %s", in_qname, out_qname) - kwargs = dict(in_queue=self._queues[in_qname], out_queue=self._queues[out_qname]) + kwargs = {"in_queue": self._queues[in_qname], "out_queue": self._queues[out_qname]} plugin_type, idx = self._get_plugin_type_and_index(phase) plugin = getattr(self, f"_{plugin_type}") diff --git a/plugins/extract/recognition/_base.py b/plugins/extract/recognition/_base.py index 00ef95c8c9..bf5a7372bc 100644 --- a/plugins/extract/recognition/_base.py +++ b/plugins/extract/recognition/_base.py @@ -15,18 +15,19 @@ >>> face = self.to_detected_face(, , , ) """ +from __future__ import annotations import logging import sys +import typing as T from dataclasses import dataclass, field -from typing import Generator, List, Optional, Tuple, TYPE_CHECKING import numpy as np from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa from lib.align import AlignedFace, DetectedFace from lib.image import read_image_meta -from lib.utils import FaceswapError, get_backend +from lib.utils import FaceswapError from plugins.extract._base import BatchType, Extractor, ExtractorBatch from plugins.extract.pipeline import ExtractMedia @@ -36,7 +37,7 @@ from typing import get_args, Literal -if TYPE_CHECKING: +if T.TYPE_CHECKING: from queue import Queue from lib.align.aligned_face import CenteringType @@ -49,8 +50,8 @@ class RecogBatch(ExtractorBatch): Inherits from :class:`~plugins.extract._base.ExtractorBatch` """ - detected_faces: List["DetectedFace"] = field(default_factory=list) - feed_faces: List[AlignedFace] = field(default_factory=list) + detected_faces: T.List["DetectedFace"] = field(default_factory=list) + feed_faces: T.List[AlignedFace] = field(default_factory=list) class Identity(Extractor): # pylint:disable=abstract-method @@ -81,9 +82,9 @@ class Identity(Extractor): # pylint:disable=abstract-method """ def __init__(self, - git_model_id: Optional[int] = None, - model_filename: Optional[str] = None, - configfile: Optional[str] = None, + git_model_id: T.Optional[int] = None, + model_filename: T.Optional[str] = None, + configfile: T.Optional[str] = None, instance: int = 0, **kwargs): logger.debug("Initializing %s", self.__class__.__name__) @@ -93,7 +94,7 @@ def __init__(self, instance=instance, **kwargs) self.input_size = 256 # Override for model specific input_size - self.centering: "CenteringType" = "legacy" # Override for model specific centering + self.centering: CenteringType = "legacy" # Override for model specific centering self.coverage_ratio = 1.0 # Override for model specific coverage_ratio self._plugin_type = "recognition" @@ -118,7 +119,7 @@ def _get_detected_from_aligned(self, item: ExtractMedia) -> None: logger.debug("Obtained detected face: (filename: %s, detected_face: %s)", item.filename, item.detected_faces) - def get_batch(self, queue: "Queue") -> Tuple[bool, RecogBatch]: + def get_batch(self, queue: Queue) -> T.Tuple[bool, RecogBatch]: """ Get items for inputting into the recognition from the queue in batches Items are returned from the ``queue`` in batches of @@ -224,25 +225,8 @@ def _predict(self, batch: BatchType) -> RecogBatch: "CLI: Edit the file faceswap/config/extract.ini)." "\n3) Enable 'Single Process' mode.") raise FaceswapError(msg) from err - except Exception as err: - if get_backend() == "amd": - # pylint:disable=import-outside-toplevel - from lib.plaidml_utils import is_plaidml_error - if (is_plaidml_error(err) and ( - "CL_MEM_OBJECT_ALLOCATION_FAILURE" in str(err).upper() or - "enough memory for the current schedule" in str(err).lower())): - msg = ("You do not have enough GPU memory available to run detection at " - "the selected batch size. You can try a number of things:" - "\n1) Close any other application that is using your GPU (web " - "browsers are particularly bad for this)." - "\n2) Lower the batchsize (the amount of images fed into the " - "model) by editing the plugin settings (GUI: Settings > Configure " - "extract settings, CLI: Edit the file " - "faceswap/config/extract.ini).") - raise FaceswapError(msg) from err - raise - - def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: + + def finalize(self, batch: BatchType) -> T.Generator[ExtractMedia, None, None]: """ Finalize the output from Masker This should be called as the final task of each `plugin`. @@ -317,8 +301,8 @@ class IdentityFilter(): def __init__(self, save_output: bool) -> None: logger.debug("Initializing %s: (save_output: %s)", self.__class__.__name__, save_output) self._save_output = save_output - self._filter: Optional[np.ndarray] = None - self._nfilter: Optional[np.ndarray] = None + self._filter: T.Optional[np.ndarray] = None + self._nfilter: T.Optional[np.ndarray] = None self._threshold = 0.0 self._filter_enabled: bool = False self._nfilter_enabled: bool = False @@ -402,9 +386,9 @@ def _get_matches(self, return retval def _filter_faces(self, - faces: List[DetectedFace], - sub_folders: List[Optional[str]], - should_filter: List[bool]) -> List[DetectedFace]: + faces: T.List[DetectedFace], + sub_folders: T.List[T.Optional[str]], + should_filter: T.List[bool]) -> T.List[DetectedFace]: """ Filter the detected faces, either removing filtered faces from the list of detected faces or setting the output subfolder to `"_identity_filt"` for any filtered faces if saving output is enabled. @@ -426,7 +410,7 @@ def _filter_faces(self, The filtered list of detected face objects, if saving filtered faces has not been selected or the full list of detected faces """ - retval: List[DetectedFace] = [] + retval: T.List[DetectedFace] = [] self._counts += sum(should_filter) for idx, face in enumerate(faces): fldr = sub_folders[idx] @@ -445,8 +429,8 @@ def _filter_faces(self, return retval def __call__(self, - faces: List[DetectedFace], - sub_folders: List[Optional[str]]) -> List[DetectedFace]: + faces: T.List[DetectedFace], + sub_folders: T.List[T.Optional[str]]) -> T.List[DetectedFace]: """ Call the identity filter function Parameters @@ -475,14 +459,14 @@ def __call__(self, logger.trace("All faces already filtered: %s", sub_folders) # type: ignore return faces - should_filter: List[np.ndarray] = [] + should_filter: T.List[np.ndarray] = [] for f_type in get_args(Literal["filter", "nfilter"]): if not getattr(self, f"_{f_type}_enabled"): continue should_filter.append(self._get_matches(f_type, identities)) # If any of the filter or nfilter evaluate to 'should filter' then filter out face - final_filter: List[bool] = np.array(should_filter).max(axis=0).tolist() + final_filter: T.List[bool] = np.array(should_filter).max(axis=0).tolist() logger.trace("should_filter: %s, final_filter: %s", # type: ignore should_filter, final_filter) return self._filter_faces(faces, sub_folders, final_filter) diff --git a/plugins/extract/recognition/vgg_face2_defaults.py b/plugins/extract/recognition/vgg_face2_defaults.py index 51d168dc16..cde066285b 100644 --- a/plugins/extract/recognition/vgg_face2_defaults.py +++ b/plugins/extract/recognition/vgg_face2_defaults.py @@ -66,7 +66,7 @@ fixed=True), "cpu": dict( default=False, - info="[Not PlaidML] VGG Face2 still runs fairly quickly on CPU on some setups. Enable " + info="VGG Face2 still runs fairly quickly on CPU on some setups. Enable " "CPU mode here to use the CPU for this plugin to save some VRAM at a speed cost.", datatype=bool, group="settings"), diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 44d9ce627a..0e8efe3766 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -255,7 +255,7 @@ def _set_globals(self) -> None: datatype=bool, default=False, info=_( - "[Not PlaidML] Apply AutoClipping to the gradients. AutoClip analyzes the " + "Apply AutoClipping to the gradients. AutoClip analyzes the " "gradient weights and adjusts the normalization value dynamically to fit the " "data. Can help prevent NaNs and improve model optimization at the expense of " "VRAM. Ref: AutoClip: Adaptive Gradient Clipping for Source Separation Networks " @@ -283,7 +283,7 @@ def _set_globals(self) -> None: group=_("network"), fixed=False, info=_( - "[Nvidia Only]. Enable the Tensorflow GPU 'allow_growth' configuration option. " + "Enable the Tensorflow GPU 'allow_growth' configuration option. " "This option prevents Tensorflow from allocating all of the GPU VRAM at launch " "but can lead to higher VRAM fragmentation and slower performance. Should only " "be enabled if you are receiving errors regarding 'cuDNN fails to initialize' " @@ -296,7 +296,7 @@ def _set_globals(self) -> None: fixed=False, group=_("network"), info=_( - "[Not PlaidML], NVIDIA GPUs can run operations in float16 faster than in " + "NVIDIA GPUs can run operations in float16 faster than in " "float32. Mixed precision allows you to use a mix of float16 with float32, to " "get the performance benefits from float16 and the numeric stability benefits " "from float32.\n\nThis is untested on DirectML backend, but will run on most " diff --git a/plugins/train/model/_base/__init__.py b/plugins/train/model/_base/__init__.py index 84c77ef96c..c26c15103e 100644 --- a/plugins/train/model/_base/__init__.py +++ b/plugins/train/model/_base/__init__.py @@ -1,4 +1,4 @@ #!/usr/bin/env python3 """ Base class for Models plugins ALL Models should at least inherit from this class. """ -from .model import get_all_sub_models, KerasModel, ModelBase # noqa +from .model import get_all_sub_models, ModelBase diff --git a/plugins/train/model/_base/io.py b/plugins/train/model/_base/io.py index 193e84a05d..d8d8515713 100644 --- a/plugins/train/model/_base/io.py +++ b/plugins/train/model/_base/io.py @@ -9,29 +9,25 @@ - The loading, saving and backing up of keras models to and from disk. - The loading and freezing of weights for model plugins. """ +from __future__ import annotations import logging import os import sys +import typing as T -from typing import List, Optional, TYPE_CHECKING +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras.models import load_model, Model as KModel # noqa:E501 # pylint:disable=import-error from lib.model.backup_restore import Backup -from lib.utils import FaceswapError, get_backend +from lib.utils import FaceswapError if sys.version_info < (3, 8): from typing_extensions import Literal else: from typing import Literal -if get_backend() == "amd": - import keras - from keras.models import load_model, Model as KModel -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow import keras # pylint:disable=import-error,no-name-in-module - from tensorflow.keras.models import load_model, Model as KModel # noqa pylint:disable=import-error,no-name-in-module - -if TYPE_CHECKING: +if T.TYPE_CHECKING: + from tensorflow import keras from .model import ModelBase logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -39,7 +35,7 @@ def get_all_sub_models( model: keras.models.Model, - models: Optional[List[keras.models.Model]] = None) -> List[keras.models.Model]: + models: T.Optional[T.List[keras.models.Model]] = None) -> T.List[keras.models.Model]: """ For a given model, return all sub-models that occur (recursively) as children. Parameters @@ -86,7 +82,7 @@ class IO(): request. """ def __init__(self, - plugin: "ModelBase", + plugin: ModelBase, model_dir: str, is_predict: bool, save_optimizer: Literal["never", "always", "exit"]) -> None: @@ -94,7 +90,7 @@ def __init__(self, self._is_predict = is_predict self._model_dir = model_dir self._save_optimizer = save_optimizer - self._history: List[List[float]] = [[], []] # Loss histories per save iteration + self._history: T.List[T.List[float]] = [[], []] # Loss histories per save iteration self._backup = Backup(self._model_dir, self._plugin.name) @property @@ -110,12 +106,12 @@ def model_exists(self) -> bool: return os.path.isfile(self._filename) @property - def history(self) -> List[List[float]]: + def history(self) -> T.List[T.List[float]]: """ list: list of loss histories per side for the current save iteration. """ return self._history @property - def multiple_models_in_folder(self) -> Optional[List[str]]: + def multiple_models_in_folder(self) -> T.Optional[T.List[str]]: """ :list: or ``None`` If there are multiple model types in the requested folder, or model types that don't correspond to the requested plugin type, then returns the list of plugin names that exist in the folder, otherwise returns ``None`` """ @@ -214,7 +210,7 @@ def save(self, is_exit: bool = False, force_save_optimizer: bool = False) -> Non msg += f" - Average loss since last save: {', '.join(lossmsg)}" logger.info(msg) - def _get_save_averages(self) -> List[float]: + def _get_save_averages(self) -> T.List[float]: """ Return the average loss since the last save iteration and reset historical loss """ logger.debug("Getting save averages") if not all(loss for loss in self._history): @@ -226,7 +222,7 @@ def _get_save_averages(self) -> List[float]: logger.debug("Average losses since last save: %s", retval) return retval - def _should_backup(self, save_averages: List[float]) -> bool: + def _should_backup(self, save_averages: T.List[float]) -> bool: """ Check whether the loss averages for this save iteration is the lowest that has been seen. @@ -291,7 +287,7 @@ class Weights(): plugin: :class:`Model` The parent plugin class that owns the IO functions. """ - def __init__(self, plugin: "ModelBase") -> None: + def __init__(self, plugin: ModelBase) -> None: logger.debug("Initializing %s: (plugin: %s)", self.__class__.__name__, plugin) self._model = plugin.model self._name = plugin.model_name @@ -305,7 +301,7 @@ def __init__(self, plugin: "ModelBase") -> None: logger.debug("Initialized %s", self.__class__.__name__) @classmethod - def _check_weights_file(cls, weights_file: str) -> Optional[str]: + def _check_weights_file(cls, weights_file: str) -> T.Optional[str]: """ Validate that we have a valid path to a .h5 file. Parameters @@ -407,7 +403,7 @@ def load(self, model_exists: bool) -> None: "different settings than you have set for your current model.", skipped_ops) - def _get_weights_model(self) -> List[keras.models.Model]: + def _get_weights_model(self) -> T.List[keras.models.Model]: """ Obtain a list of all sub-models contained within the weights model. Returns diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index b2d742daa6..6eba5f332a 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -4,78 +4,43 @@ See :mod:`~plugins.train.model.original` for an annotated example for how to create model plugins. """ +from __future__ import annotations import logging import os import sys import time +import typing as T from collections import OrderedDict -from typing import cast, Dict, List, Optional, Tuple, TYPE_CHECKING, Union import numpy as np +import tensorflow as tf + +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras import backend as K # pylint:disable=import-error +from tensorflow.keras.layers import Input # pylint:disable=import-error +from tensorflow.keras.models import load_model, Model as KModel # noqa:E501 # pylint:disable=import-error from lib.serializer import get_serializer from lib.model.nn_blocks import set_config as set_nnblock_config -from lib.utils import get_backend, FaceswapError +from lib.utils import FaceswapError from plugins.train._config import Config from .io import IO, get_all_sub_models, Weights from .settings import Loss, Optimizer, Settings -if get_backend() == "amd": - import keras - from keras import backend as K - from keras.layers import Input - from keras.models import load_model, Model as KModel -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow import keras # pylint:disable=import-error - from tensorflow.keras import backend as K # pylint:disable=import-error - from tensorflow.keras.layers import Input # pylint:disable=import-error,no-name-in-module - from tensorflow.keras.models import load_model, Model as KModel # noqa pylint:disable=import-error,no-name-in-module if sys.version_info < (3, 8): from typing_extensions import Literal else: from typing import Literal -if TYPE_CHECKING: +if T.TYPE_CHECKING: import argparse from lib.config import ConfigValueType logger = logging.getLogger(__name__) # pylint: disable=invalid-name -_CONFIG: Dict[str, "ConfigValueType"] = {} - - -def KerasModel(inputs: list, outputs: list, name: str) -> keras.models.Model: # noqa, pylint:disable=invalid-name - """ wrapper for :class:`keras.models.Model`. - - There are some minor foibles between Keras 2.2 and the Tensorflow version of Keras, so this - catches potential issues and fixes prior to returning the requested model. - - All models created within plugins should use this method, and should not call keras directly - for a model. - - Parameters - ---------- - inputs: a keras.Input object or list of keras.Input objects. - The input(s) of the model - outputs: keras objects - The output(s) of the model. - name: str - The name of the model. - - Returns - ------- - :class:`keras.models.Model` - A Keras Model - """ - if get_backend() == "amd": - logger.debug("Flattening inputs (%s) and outputs (%s) for AMD", inputs, outputs) - inputs = np.array(inputs).flatten().tolist() - outputs = np.array(outputs).flatten().tolist() - logger.debug("Flattened inputs (%s) and outputs (%s)", inputs, outputs) - return KModel(inputs, outputs, name=name) +_CONFIG: T.Dict[str, ConfigValueType] = {} class ModelBase(): @@ -108,19 +73,19 @@ class ModelBase(): """ def __init__(self, model_dir: str, - arguments: "argparse.Namespace", + arguments: argparse.Namespace, predict: bool = False) -> None: logger.debug("Initializing ModelBase (%s): (model_dir: '%s', arguments: %s, predict: %s)", self.__class__.__name__, model_dir, arguments, predict) # Input shape must be set within the plugin after initializing - self.input_shape: Tuple[int, ...] = () + self.input_shape: T.Tuple[int, ...] = () self.trainer = "original" # Override for plugin specific trainer self.color_order: Literal["bgr", "rgb"] = "bgr" # Override for image color channel order self._args = arguments self._is_predict = predict - self._model: Optional[keras.models.Model] = None + self._model: T.Optional[tf.keras.models.Model] = None self._configfile = arguments.configfile if hasattr(arguments, "configfile") else None self._load_config() @@ -134,7 +99,7 @@ def __init__(self, raise FaceswapError("'Learn Mask' has been selected but you have not chosen a Mask to " "use. Please select a mask or disable 'Learn Mask'.") - self._mixed_precision = self.config["mixed_precision"] and get_backend() != "amd" + self._mixed_precision = self.config["mixed_precision"] # self._io = IO(self, model_dir, self._is_predict, self.config["save_optimizer"]) # TODO - Re-enable saving of optimizer once this bug is fixed: # File "h5py/_objects.pyx", line 54, in h5py._objects.with_phil.wrapper @@ -158,12 +123,12 @@ def __init__(self, logger.debug("Initialized ModelBase (%s)", self.__class__.__name__) @property - def model(self) -> keras.models.Model: + def model(self) -> tf.keras.models.Model: """:class:`Keras.models.Model`: The compiled model for this plugin. """ return self._model @property - def command_line_arguments(self) -> "argparse.Namespace": + def command_line_arguments(self) -> argparse.Namespace: """ :class:`argparse.Namespace`: The command line arguments passed to the model plugin from either the train or convert script """ return self._args @@ -210,16 +175,16 @@ def model_name(self) -> str: return self.name @property - def input_shapes(self) -> List[Tuple[None, int, int, int]]: + def input_shapes(self) -> T.List[T.Tuple[None, int, int, int]]: """ list: A flattened list corresponding to all of the inputs to the model. """ - shapes = [cast(Tuple[None, int, int, int], K.int_shape(inputs)) + shapes = [T.cast(T.Tuple[None, int, int, int], K.int_shape(inputs)) for inputs in self.model.inputs] return shapes @property - def output_shapes(self) -> List[Tuple[None, int, int, int]]: + def output_shapes(self) -> T.List[T.Tuple[None, int, int, int]]: """ list: A flattened list corresponding to all of the outputs of the model. """ - shapes = [cast(Tuple[None, int, int, int], K.int_shape(output)) + shapes = [T.cast(T.Tuple[None, int, int, int], K.int_shape(output)) for output in self.model.outputs] return shapes @@ -342,13 +307,13 @@ def _update_legacy_models(self) -> None: os.mkdir(self.model_dir) new_model = self.build_model(self._get_inputs()) for model_name, layer_name in legacy_mapping.items(): - old_model: keras.models.Model = load_model(os.path.join(archive_dir, model_name), - compile=False) + old_model: tf.keras.models.Model = load_model(os.path.join(archive_dir, model_name), + compile=False) layer = [layer for layer in new_model.layers if layer.name == layer_name] if not layer: logger.warning("Skipping legacy weights from '%s'...", model_name) continue - klayer: keras.layers.Layer = layer[0] + klayer: tf.keras.layers.Layer = layer[0] logger.info("Updating legacy weights from '%s'...", model_name) klayer.set_weights(old_model.get_weights()) filename = self._io._filename # pylint:disable=protected-access @@ -368,7 +333,7 @@ def _validate_input_shape(self) -> None: a list of 2 shape tuples of 3 dimensions. """ assert len(self.input_shape) == 3, "Input shape should be a 3 dimensional shape tuple" - def _get_inputs(self) -> List[keras.layers.Input]: + def _get_inputs(self) -> T.List[tf.keras.layers.Input]: """ Obtain the standardized inputs for the model. The inputs will be returned for the "A" and "B" sides in the shape as defined by @@ -387,7 +352,7 @@ def _get_inputs(self) -> List[keras.layers.Input]: logger.debug("inputs: %s", inputs) return inputs - def build_model(self, inputs: List[keras.layers.Input]) -> keras.models.Model: + def build_model(self, inputs: T.List[tf.keras.layers.Input]) -> tf.keras.models.Model: """ Override for Model Specific autoencoder builds. Parameters @@ -399,11 +364,9 @@ def build_model(self, inputs: List[keras.layers.Input]) -> keras.models.Model: Returns ------- :class:`keras.models.Model` - The output of this function must be a keras model generated from - :class:`plugins.train.model._base.KerasModel`. See Keras documentation for the correct - structure, but note that parameter :attr:`name` is a required rather than an optional - argument in Faceswap. You should assign this to the attribute ``self.name`` that is - automatically generated from the plugin's filename. + See Keras documentation for the correct structure, but note that parameter :attr:`name` + is a required rather than an optional argument in Faceswap. You should assign this to + the attribute ``self.name`` that is automatically generated from the plugin's filename. """ raise NotImplementedError @@ -448,15 +411,12 @@ def _compile_model(self) -> None: if self.state.model_needs_rebuild: self._model = self._settings.check_model_precision(self._model, self._state) - autoclip = get_backend() != "amd" and self.config["autoclip"] optimizer = Optimizer(self.config["optimizer"], self.config["learning_rate"], - autoclip, + self.config["autoclip"], 10 ** int(self.config["epsilon_exponent"])).optimizer if self._settings.use_mixed_precision: optimizer = self._settings.loss_scale_optimizer(optimizer) - if get_backend() == "amd": - self._rewrite_plaid_outputs() weights = Weights(self) weights.load(self._io.model_exists) @@ -467,29 +427,7 @@ def _compile_model(self) -> None: self._state.add_session_loss_names(self._loss.names) logger.debug("Compiled Model: %s", self.model) - def _rewrite_plaid_outputs(self) -> None: - """ Rewrite the output names for models using the PlaidML (Keras 2.2.4) backend - - Keras 2.2.4 duplicates model output names if any of the models have multiple outputs - so we need to rename the outputs so we can successfully map the loss dictionaries. - - This is a bit of a hack, but it does work. - """ - # TODO Remove this rewrite code if PlaidML updates to a version of Keras where this is - # no longer necessary - if len(self.model.output_names) == len(set(self.model.output_names)): - logger.debug("Output names are unique, not rewriting: %s", self.model.output_names) - return - seen = {name: 0 for name in set(self.model.output_names)} - new_names = [] - for name in self.model.output_names: - new_names.append(f"{name}_{seen[name]}") - seen[name] += 1 - logger.debug("Output names rewritten: (old: %s, new: %s)", - self.model.output_names, new_names) - self.model.output_names = new_names - - def _legacy_mapping(self) -> Optional[dict]: + def _legacy_mapping(self) -> T.Optional[dict]: """ The mapping of separate model files to single model layers for transferring of legacy weights. @@ -501,7 +439,7 @@ def _legacy_mapping(self) -> Optional[dict]: """ return None - def add_history(self, loss: List[float]) -> None: + def add_history(self, loss: T.List[float]) -> None: """ Add the current iteration's loss history to :attr:`_io.history`. Called from the trainer after each iteration, for tracking loss drop over time between @@ -544,18 +482,18 @@ def __init__(self, self._filename = os.path.join(model_dir, filename) self._name = model_name self._iterations = 0 - self._mixed_precision_layers: List[str] = [] + self._mixed_precision_layers: T.List[str] = [] self._rebuild_model = False - self._sessions: Dict[int, dict] = {} - self._lowest_avg_loss: Dict[str, float] = {} - self._config: Dict[str, "ConfigValueType"] = {} + self._sessions: T.Dict[int, dict] = {} + self._lowest_avg_loss: T.Dict[str, float] = {} + self._config: T.Dict[str, ConfigValueType] = {} self._load(config_changeable_items) self._session_id = self._new_session_id() self._create_new_session(no_logs, config_changeable_items) logger.debug("Initialized %s:", self.__class__.__name__) @property - def loss_names(self) -> List[str]: + def loss_names(self) -> T.List[str]: """ list: The loss names for the current session """ return self._sessions[self._session_id]["loss_names"] @@ -580,7 +518,7 @@ def session_id(self) -> int: return self._session_id @property - def mixed_precision_layers(self) -> List[str]: + def mixed_precision_layers(self) -> T.List[str]: """list: Layers that can be switched between mixed-float16 and float32. """ return self._mixed_precision_layers @@ -619,14 +557,14 @@ def _create_new_session(self, no_logs: bool, config_changeable_items: dict) -> N values """ logger.debug("Creating new session. id: %s", self._session_id) - self._sessions[self._session_id] = dict(timestamp=time.time(), - no_logs=no_logs, - loss_names=[], - batchsize=0, - iterations=0, - config=config_changeable_items) - - def add_session_loss_names(self, loss_names: List[str]) -> None: + self._sessions[self._session_id] = {"timestamp": time.time(), + "no_logs": no_logs, + "loss_names": [], + "batchsize": 0, + "iterations": 0, + "config": config_changeable_items} + + def add_session_loss_names(self, loss_names: T.List[str]) -> None: """ Add the session loss names to the sessions dictionary. The loss names are used for Tensorboard logging @@ -655,7 +593,7 @@ def increment_iterations(self) -> None: self._iterations += 1 self._sessions[self._session_id]["iterations"] += 1 - def add_mixed_precision_layers(self, layers: List[str]) -> None: + def add_mixed_precision_layers(self, layers: T.List[str]) -> None: """ Add the list of model's layers that are compatible for mixed precision to the state dictionary """ logger.debug("Storing mixed precision layers: %s", layers) @@ -717,14 +655,14 @@ def _replace_config(self, config_changeable_items) -> None: legacy_update = self._update_legacy_config() # Add any new items to state config for legacy purposes where the new default may be # detrimental to an existing model. - legacy_defaults: Dict[str, Union[str, int, bool]] = dict(centering="legacy", - mask_loss_function="mse", - l2_reg_term=100, - optimizer="adam", - mixed_precision=False) + legacy_defaults: T.Dict[str, T.Union[str, int, bool]] = {"centering": "legacy", + "mask_loss_function": "mse", + "l2_reg_term": 100, + "optimizer": "adam", + "mixed_precision": False} for key, val in _CONFIG.items(): if key not in self._config.keys(): - setting: "ConfigValueType" = legacy_defaults.get(key, val) + setting: ConfigValueType = legacy_defaults.get(key, val) logger.info("Adding new config item to state file: '%s': '%s'", key, setting) self._config[key] = setting self._update_changed_config_items(config_changeable_items) @@ -852,7 +790,7 @@ class _Inference(): # pylint:disable=too-few-public-methods ``True`` if the swap should be performed "B" > "A" ``False`` if the swap should be "A" > "B" """ - def __init__(self, saved_model: keras.models.Model, switch_sides: bool) -> None: + def __init__(self, saved_model: tf.keras.models.Model, switch_sides: bool) -> None: logger.debug("Initializing: %s (saved_model: %s, switch_sides: %s)", self.__class__.__name__, saved_model, switch_sides) self._config = saved_model.get_config() @@ -865,11 +803,11 @@ def __init__(self, saved_model: keras.models.Model, switch_sides: bool) -> None: logger.debug("Initialized: %s", self.__class__.__name__) @property - def model(self) -> keras.models.Model: + def model(self) -> tf.keras.models.Model: """ :class:`keras.models.Model`: The Faceswap model, compiled for inference. """ return self._model - def _get_nodes(self, nodes: np.ndarray) -> List[Tuple[str, int]]: + def _get_nodes(self, nodes: np.ndarray) -> T.List[T.Tuple[str, int]]: """ Given in input list of nodes from a :attr:`keras.models.Model.get_config` dictionary, filters the layer name(s) and output index of the node, splitting to the correct output index in the event of multiple inputs. @@ -895,7 +833,7 @@ def _get_nodes(self, nodes: np.ndarray) -> List[Tuple[str, int]]: retval = [(node[0], node[2]) for node in anodes] return retval - def _make_inference_model(self, saved_model: keras.models.Model) -> keras.models.Model: + def _make_inference_model(self, saved_model: tf.keras.models.Model) -> tf.keras.models.Model: """ Extract the sub-models from the saved model that are required for inference. Parameters @@ -911,7 +849,7 @@ def _make_inference_model(self, saved_model: keras.models.Model) -> keras.models logger.debug("Compiling inference model. saved_model: %s", saved_model) struct = self._get_filtered_structure() model_inputs = self._get_inputs(saved_model.inputs) - compiled_layers: Dict[str, keras.layers.Layer] = {} + compiled_layers: T.Dict[str, tf.keras.layers.Layer] = {} for layer in saved_model.layers: if layer.name not in struct: logger.debug("Skipping unused layer: '%s'", layer.name) @@ -936,16 +874,12 @@ def _make_inference_model(self, saved_model: keras.models.Model) -> keras.models else: next_input = inbound_layer - if get_backend() == "amd" and isinstance(next_input, list): - # tensorflow.keras and keras 2.2 behave differently for layer inputs - layer_inputs.extend(next_input) - else: - layer_inputs.append(next_input) + layer_inputs.append(next_input) logger.debug("Compiling layer '%s': layer inputs: %s", layer.name, layer_inputs) model = layer(layer_inputs) compiled_layers[layer.name] = model - retval = KerasModel(model_inputs, model, name=f"{saved_model.name}_inference") + retval = KModel(model_inputs, model, name=f"{saved_model.name}_inference") logger.debug("Compiled inference model '%s': %s", retval.name, retval) return retval diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index ead7350009..84ac102b0e 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -10,42 +10,36 @@ - Optimizer settings - General global model configuration settings """ +from __future__ import annotations from dataclasses import dataclass, field import logging import platform import sys +import typing as T from contextlib import nullcontext -from typing import Any, Callable, ContextManager, Dict, List, Optional, TYPE_CHECKING, Union import tensorflow as tf +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras import losses as k_losses # pylint:disable=import-error +import tensorflow.keras.mixed_precision as mixedprecision # noqa pylint:disable=import-error from lib.model import losses, optimizers +from lib.model.autoclip import AutoClipper from lib.utils import get_backend -if get_backend() == "amd": - import keras - from keras import losses as k_losses - from keras import backend as K - import tensorflow.keras.mixed_precision.experimental as mixedprecision # noqa pylint:disable=import-error,no-name-in-module,ungrouped-imports -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow import keras - from tensorflow.keras import losses as k_losses # pylint:disable=import-error - from tensorflow.keras import backend as K # pylint:disable=import-error - import tensorflow.keras.mixed_precision as mixedprecision # noqa pylint:disable=import-error,no-name-in-module - from lib.model.autoclip import AutoClipper # pylint:disable=ungrouped-imports - - if sys.version_info < (3, 8): from typing_extensions import Literal else: from typing import Literal -if TYPE_CHECKING: +if T.TYPE_CHECKING: from argparse import Namespace from .model import State +keras = tf.keras +K = keras.backend + logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -64,9 +58,9 @@ class LossClass: kwargs: dict Any keyword arguments to supply to the loss function at initialization. """ - function: Union[Callable[[tf.Tensor, tf.Tensor], tf.Tensor], Any] = k_losses.mae + function: T.Union[T.Callable[[tf.Tensor, tf.Tensor], tf.Tensor], T.Any] = k_losses.mae init: bool = True - kwargs: Dict[str, Any] = field(default_factory=dict) + kwargs: T.Dict[str, T.Any] = field(default_factory=dict) class Loss(): @@ -83,38 +77,34 @@ def __init__(self, config: dict, color_order: Literal["bgr", "rgb"]) -> None: logger.debug("Initializing %s: (color_order: %s)", self.__class__.__name__, color_order) self._config = config self._mask_channels = self._get_mask_channels() - self._inputs: List[keras.layers.Layer] = [] - self._names: List[str] = [] - self._funcs: Dict[str, Callable] = {} - - logcosh = losses.LogCosh() if get_backend() == "amd" else k_losses.logcosh - self._loss_dict = dict(ffl=LossClass(function=losses.FocalFrequencyLoss), - flip=LossClass(function=losses.LDRFLIPLoss, - kwargs=dict(color_order=color_order)), - gmsd=LossClass(function=losses.GMSDLoss), - l_inf_norm=LossClass(function=losses.LInfNorm), - laploss=LossClass(function=losses.LaplacianPyramidLoss), - logcosh=LossClass(function=logcosh, - init=False), - lpips_alex=LossClass(function=losses.LPIPSLoss, - kwargs=dict(trunk_network="alex")), - lpips_squeeze=LossClass(function=losses.LPIPSLoss, - kwargs=dict(trunk_network="squeeze")), - lpips_vgg16=LossClass(function=losses.LPIPSLoss, - kwargs=dict(trunk_network="vgg16")), - ms_ssim=LossClass(function=losses.MSSIMLoss), - mae=LossClass(function=k_losses.mean_absolute_error, - init=False), - mse=LossClass(function=k_losses.mean_squared_error, - init=False), - pixel_gradient_diff=LossClass(function=losses.GradientLoss), - ssim=LossClass(function=losses.DSSIMObjective), - smooth_loss=LossClass(function=losses.GeneralizedLoss)) + self._inputs: T.List[tf.keras.layers.Layer] = [] + self._names: T.List[str] = [] + self._funcs: T.Dict[str, T.Callable] = {} + + self._loss_dict = {"ffl": LossClass(function=losses.FocalFrequencyLoss), + "flip": LossClass(function=losses.LDRFLIPLoss, + kwargs={"color_order": color_order}), + "gmsd": LossClass(function=losses.GMSDLoss), + "l_inf_norm": LossClass(function=losses.LInfNorm), + "laploss": LossClass(function=losses.LaplacianPyramidLoss), + "logcosh": LossClass(function=k_losses.logcosh, init=False), + "lpips_alex": LossClass(function=losses.LPIPSLoss, + kwargs={"trunk_network": "alex"}), + "lpips_squeeze": LossClass(function=losses.LPIPSLoss, + kwargs={"trunk_network": "squeeze"}), + "lpips_vgg16": LossClass(function=losses.LPIPSLoss, + kwargs={"trunk_network": "vgg16"}), + "ms_ssim": LossClass(function=losses.MSSIMLoss), + "mae": LossClass(function=k_losses.mean_absolute_error, init=False), + "mse": LossClass(function=k_losses.mean_squared_error, init=False), + "pixel_gradient_diff": LossClass(function=losses.GradientLoss), + "ssim": LossClass(function=losses.DSSIMObjective), + "smooth_loss": LossClass(function=losses.GeneralizedLoss)} logger.debug("Initialized: %s", self.__class__.__name__) @property - def names(self) -> List[str]: + def names(self) -> T.List[str]: """ list: The list of loss names for the model. """ return self._names @@ -124,21 +114,21 @@ def functions(self) -> dict: return self._funcs @property - def _mask_inputs(self) -> Optional[list]: + def _mask_inputs(self) -> T.Optional[list]: """ list: The list of input tensors to the model that contain the mask. Returns ``None`` if there is no mask input to the model. """ mask_inputs = [inp for inp in self._inputs if inp.name.startswith("mask")] return None if not mask_inputs else mask_inputs @property - def _mask_shapes(self) -> Optional[List[tuple]]: + def _mask_shapes(self) -> T.Optional[T.List[tuple]]: """ list: The list of shape tuples for the mask input tensors for the model. Returns ``None`` if there is no mask input. """ if self._mask_inputs is None: return None return [K.int_shape(mask_input) for mask_input in self._mask_inputs] - def configure(self, model: keras.models.Model) -> None: + def configure(self, model: tf.keras.models.Model) -> None: """ Configure the loss functions for the given inputs and outputs. Parameters @@ -151,7 +141,7 @@ def configure(self, model: keras.models.Model) -> None: self._set_loss_functions(model.output_names) self._names.insert(0, "total") - def _set_loss_names(self, outputs: List[tf.Tensor]) -> None: + def _set_loss_names(self, outputs: T.List[tf.Tensor]) -> None: """ Name the losses based on model output. This is used for correct naming in the state file, for display purposes only. @@ -183,7 +173,7 @@ def _set_loss_names(self, outputs: List[tf.Tensor]) -> None: self._names.append(f"{name}_{side}{suffix}") logger.debug(self._names) - def _get_function(self, name: str) -> Callable[[tf.Tensor, tf.Tensor], tf.Tensor]: + def _get_function(self, name: str) -> T.Callable[[tf.Tensor, tf.Tensor], tf.Tensor]: """ Obtain the requested Loss function Parameters @@ -201,7 +191,7 @@ def _get_function(self, name: str) -> Callable[[tf.Tensor, tf.Tensor], tf.Tensor logger.debug("Obtained loss function `%s` (%s)", name, retval) return retval - def _set_loss_functions(self, output_names: List[str]): + def _set_loss_functions(self, output_names: T.List[str]): """ Set the loss functions and their associated weights. Adds the loss functions to the :attr:`functions` dictionary. @@ -261,7 +251,7 @@ def _add_face_loss_function(self, mask_channel=mask_channel) channel_idx += 1 - def _get_mask_channels(self) -> List[int]: + def _get_mask_channels(self) -> T.List[int]: """ Obtain the channels from the face targets that the masks reside in from the training data generator. @@ -314,22 +304,22 @@ def __init__(self, ", epsilon: %s)", self.__class__.__name__, optimizer, learning_rate, autoclip, epsilon) valid_optimizers = {"adabelief": (optimizers.AdaBelief, - dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), + {"beta_1": 0.5, "beta_2": 0.99, "epsilon": epsilon}), "adam": (optimizers.Adam, - dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), + {"beta_1": 0.5, "beta_2": 0.99, "epsilon": epsilon}), "nadam": (optimizers.Nadam, - dict(beta_1=0.5, beta_2=0.99, epsilon=epsilon)), - "rms-prop": (optimizers.RMSprop, dict(epsilon=epsilon))} + {"beta_1": 0.5, "beta_2": 0.99, "epsilon": epsilon}), + "rms-prop": (optimizers.RMSprop, {"epsilon": epsilon})} optimizer_info = valid_optimizers[optimizer] - self._optimizer: Callable = optimizer_info[0] - self._kwargs: Dict[str, Any] = optimizer_info[1] + self._optimizer: T.Callable = optimizer_info[0] + self._kwargs: T.Dict[str, T.Any] = optimizer_info[1] self._configure(learning_rate, autoclip) - logger.verbose("Using %s optimizer", optimizer.title()) # type:ignore + logger.verbose("Using %s optimizer", optimizer.title()) # type:ignore[attr-defined] logger.debug("Initialized: %s", self.__class__.__name__) @property - def optimizer(self) -> keras.optimizers.Optimizer: + def optimizer(self) -> tf.keras.optimizers.Optimizer: """ :class:`keras.optimizers.Optimizer`: The requested optimizer. """ return self._optimizer(**self._kwargs) @@ -344,19 +334,8 @@ def _configure(self, The selected learning rate to use autoclip: bool ``True`` if AutoClip should be enabled otherwise ``False`` - - Notes - ----- - Clip-norm is ballooning VRAM usage, which is not expected behavior and may be a bug in - Keras/Tensorflow. - - PlaidML has a bug regarding the clip-norm parameter See: - https://github.com/plaidml/plaidml/issues/228. We workaround by simply not adding this - parameter for AMD backend users. """ - lr_key = "lr" if get_backend() == "amd" else "learning_rate" - self._kwargs[lr_key] = learning_rate - + self._kwargs["learning_rate"] = learning_rate if not autoclip: return @@ -371,9 +350,7 @@ class Settings(): Sets backend tensorflow settings prior to launching the model. Tensorflow 2 uses distribution strategies for multi-GPU/system training. These are context - managers. To enable the code to be more readable, we handle strategies the same way for Nvidia - and AMD backends. PlaidML does not support strategies, but we need to still create a context - manager so that we don't need branching logic. + managers. Parameters ---------- @@ -389,7 +366,7 @@ class Settings(): for training. Default: ``False`` """ def __init__(self, - arguments: "Namespace", + arguments: Namespace, mixed_precision: bool, allow_growth: bool, is_predict: bool) -> None: @@ -418,7 +395,7 @@ def use_mixed_precision(self) -> bool: @classmethod def loss_scale_optimizer( cls, - optimizer: keras.optimizers.Optimizer) -> mixedprecision.LossScaleOptimizer: + optimizer: tf.keras.optimizers.Optimizer) -> mixedprecision.LossScaleOptimizer: """ Optimize loss scaling for mixed precision training. Parameters @@ -431,10 +408,10 @@ def loss_scale_optimizer( :class:`tf.keras.mixed_precision.loss_scale_optimizer.LossScaleOptimizer` The original optimizer with loss scaling applied """ - return mixedprecision.LossScaleOptimizer(optimizer) + return mixedprecision.LossScaleOptimizer(optimizer) # pylint:disable=no-member @classmethod - def _set_tf_settings(cls, allow_growth: bool, exclude_devices: List[int]) -> None: + def _set_tf_settings(cls, allow_growth: bool, exclude_devices: T.List[int]) -> None: """ Specify Devices to place operations on and Allow TensorFlow to manage VRAM growth. Enables the Tensorflow allow_growth option if requested in the command line arguments @@ -448,10 +425,8 @@ def _set_tf_settings(cls, allow_growth: bool, exclude_devices: List[int]) -> Non ``None`` if all devices should be made available """ backend = get_backend() - if backend == "amd": - return # No settings for AMD if backend == "cpu": - logger.verbose("Hiding GPUs from Tensorflow") # type:ignore + logger.verbose("Hiding GPUs from Tensorflow") # type:ignore[attr-defined] tf.config.set_visible_devices([], "GPU") return @@ -491,27 +466,22 @@ def _set_keras_mixed_precision(cls, use_mixed_precision: bool) -> bool: ``True`` if mixed precision has been enabled otherwise ``False`` """ logger.debug("use_mixed_precision: %s", use_mixed_precision) - if get_backend() == "amd": - logger.debug("No action to perform for 'mixed_precision' on backend '%s': " - "use_mixed_precision: %s)", get_backend(), use_mixed_precision) - return False - if not use_mixed_precision: - policy = mixedprecision.Policy('float32') - mixedprecision.set_global_policy(policy) + policy = mixedprecision.Policy('float32') # pylint:disable=no-member + mixedprecision.set_global_policy(policy) # pylint:disable=no-member logger.debug("Disabling mixed precision. (Compute dtype: %s, variable_dtype: %s)", policy.compute_dtype, policy.variable_dtype) return False - policy = mixedprecision.Policy('mixed_float16') - mixedprecision.set_global_policy(policy) + policy = mixedprecision.Policy('mixed_float16') # pylint:disable=no-member + mixedprecision.set_global_policy(policy) # pylint:disable=no-member logger.debug("Enabled mixed precision. (Compute dtype: %s, variable_dtype: %s)", policy.compute_dtype, policy.variable_dtype) return True def _get_strategy(self, strategy: Literal["default", "central-storage", "mirrored"] - ) -> Optional[tf.distribute.Strategy]: + ) -> T.Optional[tf.distribute.Strategy]: """ If we are running on Nvidia backend and the strategy is not ``None`` then return the correct tensorflow distribution strategy, otherwise return ``None``. @@ -595,7 +565,7 @@ def _get_central_storage_strategy(cls) -> tf.distribute.experimental.CentralStor return tf.distribute.experimental.CentralStorageStrategy(parameter_device="/cpu:0") - def _get_mixed_precision_layers(self, layers: List[dict]) -> List[str]: + def _get_mixed_precision_layers(self, layers: T.List[dict]) -> T.List[str]: """ Obtain the names of the layers in a mixed precision model that have their dtype policy explicitly set to mixed-float16. @@ -625,7 +595,7 @@ def _get_mixed_precision_layers(self, layers: List[dict]) -> List[str]: logger.debug("Skipping unsupported layer: %s %s", layer["name"], dtype) return retval - def _switch_precision(self, layers: List[dict], compatible: List[str]) -> None: + def _switch_precision(self, layers: T.List[dict], compatible: T.List[str]) -> None: """ Switch a model's datatype between mixed-float16 and float32. Parameters @@ -636,7 +606,7 @@ def _switch_precision(self, layers: List[dict], compatible: List[str]) -> None: A list of layer names that are compatible to have their datatype switched """ dtype = "mixed_float16" if self.use_mixed_precision else "float32" - policy = dict(class_name="Policy", config=dict(name=dtype)) + policy = {"class_name": "Policy", "config": {"name": dtype}} for layer in layers: config = layer["config"] @@ -654,9 +624,9 @@ def _switch_precision(self, layers: List[dict], compatible: List[str]) -> None: config["dtype"] = policy def get_mixed_precision_layers(self, - build_func: Callable[[List[keras.layers.Layer]], - keras.models.Model], - inputs: List[keras.layers.Layer]) -> List[str]: + build_func: T.Callable[[T.List[tf.keras.layers.Layer]], + tf.keras.models.Model], + inputs: T.List[tf.keras.layers.Layer]) -> T.List[str]: """ Get and store the mixed precision layers from a full precision enabled model. Parameters @@ -674,9 +644,6 @@ def get_mixed_precision_layers(self, """ logger.info("Storing Mixed Precision compatible layers. Please ignore any following " "warnings about using mixed precision.") - if get_backend() == "amd": - logger.debug("Mixed Precision not supported for AMD. Returning empty list") - return [] self._set_keras_mixed_precision(True) model = build_func(inputs) layers = self._get_mixed_precision_layers(model.get_config()["layers"]) @@ -685,8 +652,8 @@ def get_mixed_precision_layers(self, return layers def check_model_precision(self, - model: keras.models.Model, - state: "State") -> keras.models.Model: + model: tf.keras.models.Model, + state: "State") -> tf.keras.models.Model: """ Check the model's precision. If this is a new model, then @@ -709,9 +676,6 @@ def check_model_precision(self, :class:`keras.models.Model` The original model with the datatype updated """ - if get_backend() == "amd": # Mixed precision not supported on amd - return model - if self.use_mixed_precision and not state.mixed_precision_layers: # Switching to mixed precision on a model which was started in FP32 prior to the # ability to switch between precisions on a saved model is not supported as we @@ -735,7 +699,7 @@ def check_model_precision(self, del model return new_model - def strategy_scope(self) -> ContextManager: + def strategy_scope(self) -> T.ContextManager: """ Return the strategy scope if we have set a strategy, otherwise return a null context. diff --git a/plugins/train/model/dfaker.py b/plugins/train/model/dfaker.py index 3621227b99..784178c94c 100644 --- a/plugins/train/model/dfaker.py +++ b/plugins/train/model/dfaker.py @@ -4,18 +4,13 @@ import logging import sys -from lib.model.nn_blocks import Conv2DOutput, UpscaleBlock, ResidualBlock -from lib.utils import get_backend -from .original import Model as OriginalModel, KerasModel - -if get_backend() == "amd": - from keras.initializers import RandomNormal # pylint:disable=no-name-in-module - from keras.layers import Input, LeakyReLU -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras.initializers import RandomNormal # noqa pylint:disable=import-error,no-name-in-module - from tensorflow.keras.layers import Input, LeakyReLU # noqa pylint:disable=import-error,no-name-in-module +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras.initializers import RandomNormal # pylint:disable=import-error +from tensorflow.keras.layers import Input, LeakyReLU # pylint:disable=import-error +from tensorflow.keras.models import Model as KModel # pylint:disable=import-error +from lib.model.nn_blocks import Conv2DOutput, UpscaleBlock, ResidualBlock +from .original import Model as OriginalModel logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -64,4 +59,4 @@ def decoder(self, side): var_y = UpscaleBlock(64, activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, name=f"mask_out_{side}")(var_y) outputs.append(var_y) - return KerasModel([input_], outputs=outputs, name=f"decoder_{side}") + return KModel([input_], outputs=outputs, name=f"decoder_{side}") diff --git a/plugins/train/model/dfl_h128.py b/plugins/train/model/dfl_h128.py index 7d159c6e77..2bc1e61709 100644 --- a/plugins/train/model/dfl_h128.py +++ b/plugins/train/model/dfl_h128.py @@ -3,15 +3,12 @@ Based on https://github.com/iperov/DeepFaceLab """ -from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock -from lib.utils import get_backend -from .original import Model as OriginalModel, KerasModel +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras.layers import Dense, Flatten, Input, Reshape # noqa:E501 # pylint:disable=import-error +from tensorflow.keras.models import Model as KModel # pylint:disable=import-error -if get_backend() == "amd": - from keras.layers import Dense, Flatten, Input, Reshape -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras.layers import Dense, Flatten, Input, Reshape # noqa pylint:disable=import-error,no-name-in-module +from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock +from .original import Model as OriginalModel class Model(OriginalModel): @@ -32,7 +29,7 @@ def encoder(self): var_x = Dense(8 * 8 * self.encoder_dim)(var_x) var_x = Reshape((8, 8, self.encoder_dim))(var_x) var_x = UpscaleBlock(self.encoder_dim, activation="leakyrelu")(var_x) - return KerasModel(input_, var_x, name="encoder") + return KModel(input_, var_x, name="encoder") def decoder(self, side): """ DFL H128 Decoder """ @@ -51,4 +48,4 @@ def decoder(self, side): var_y = UpscaleBlock(self.encoder_dim // 4, activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, name=f"mask_out_{side}")(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs, name=f"decoder_{side}") + return KModel(input_, outputs=outputs, name=f"decoder_{side}") diff --git a/plugins/train/model/dfl_sae.py b/plugins/train/model/dfl_sae.py index 093dd393e9..6daf91ecc2 100644 --- a/plugins/train/model/dfl_sae.py +++ b/plugins/train/model/dfl_sae.py @@ -3,18 +3,16 @@ Based on https://github.com/iperov/DeepFaceLab """ import logging + import numpy as np -from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock -from lib.utils import get_backend +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras.layers import Concatenate, Dense, Flatten, Input, LeakyReLU, Reshape # noqa:E501 # pylint:disable=import-error +from tensorflow.keras.models import Model as KModel # pylint:disable=import-error -from ._base import ModelBase, KerasModel +from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock -if get_backend() == "amd": - from keras.layers import Concatenate, Dense, Flatten, Input, LeakyReLU, Reshape -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras.layers import Concatenate, Dense, Flatten, Input, LeakyReLU, Reshape # noqa pylint:disable=import-error,no-name-in-module +from ._base import ModelBase logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -75,9 +73,7 @@ def build_model(self, inputs): else: outputs = [self.decoder("a", enc_output_shape)(encoder_a), self.decoder("b", enc_output_shape)(encoder_b)] - autoencoder = KerasModel(inputs, - outputs, - name=self.model_name) + autoencoder = KModel(inputs, outputs, name=self.model_name) return autoencoder def encoder_df(self): @@ -93,7 +89,7 @@ def encoder_df(self): var_x = Dense(lowest_dense_res * lowest_dense_res * self.ae_dims)(var_x) var_x = Reshape((lowest_dense_res, lowest_dense_res, self.ae_dims))(var_x) var_x = UpscaleBlock(self.ae_dims, activation="leakyrelu")(var_x) - return KerasModel(input_, var_x, name="encoder_df") + return KModel(input_, var_x, name="encoder_df") def encoder_liae(self): """ DFL SAE LIAE Encoder Network """ @@ -104,7 +100,7 @@ def encoder_liae(self): var_x = Conv2DBlock(dims * 4, activation="leakyrelu")(var_x) var_x = Conv2DBlock(dims * 8, activation="leakyrelu")(var_x) var_x = Flatten()(var_x) - return KerasModel(input_, var_x, name="encoder_liae") + return KModel(input_, var_x, name="encoder_liae") def inter_liae(self, side, input_shape): """ DFL SAE LIAE Intermediate Network """ @@ -115,7 +111,7 @@ def inter_liae(self, side, input_shape): var_x = Dense(lowest_dense_res * lowest_dense_res * self.ae_dims * 2)(var_x) var_x = Reshape((lowest_dense_res, lowest_dense_res, self.ae_dims * 2))(var_x) var_x = UpscaleBlock(self.ae_dims * 2, activation="leakyrelu")(var_x) - return KerasModel(input_, var_x, name=f"intermediate_{side}") + return KModel(input_, var_x, name=f"intermediate_{side}") def decoder(self, side, input_shape): """ DFL SAE Decoder Network""" @@ -153,15 +149,15 @@ def decoder(self, side, input_shape): var_y = UpscaleBlock(self.decoder_dim * 2, activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, name=f"mask_out_{side}")(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs, name=f"decoder_{side}") + return KModel(input_, outputs=outputs, name=f"decoder_{side}") def _legacy_mapping(self): """ The mapping of legacy separate model names to single model names """ - mappings = dict(df={f"{self.name}_encoder.h5": "encoder_df", - f"{self.name}_decoder_A.h5": "decoder_a", - f"{self.name}_decoder_B.h5": "decoder_b"}, - liae={f"{self.name}_encoder.h5": "encoder_liae", - f"{self.name}_intermediate_B.h5": "intermediate_both", - f"{self.name}_intermediate.h5": "intermediate_b", - f"{self.name}_decoder.h5": "decoder_both"}) + mappings = {"df": {f"{self.name}_encoder.h5": "encoder_df", + f"{self.name}_decoder_A.h5": "decoder_a", + f"{self.name}_decoder_B.h5": "decoder_b"}, + "liae": {f"{self.name}_encoder.h5": "encoder_liae", + f"{self.name}_intermediate_B.h5": "intermediate_both", + f"{self.name}_intermediate.h5": "intermediate_b", + f"{self.name}_decoder.h5": "decoder_both"}} return mappings[self.config["architecture"]] diff --git a/plugins/train/model/dlight.py b/plugins/train/model/dlight.py index 421edc0cf5..48e39d51ad 100644 --- a/plugins/train/model/dlight.py +++ b/plugins/train/model/dlight.py @@ -9,21 +9,18 @@ """ import logging +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras.layers import ( # pylint:disable=import-error + AveragePooling2D, BatchNormalization, Concatenate, Dense, Dropout, Flatten, Input, Reshape, + LeakyReLU, UpSampling2D) +from tensorflow.keras.models import Model as KModel # pylint:disable=import-error + from lib.model.nn_blocks import (Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock, Upscale2xBlock) -from lib.utils import FaceswapError, get_backend +from lib.utils import FaceswapError -from ._base import ModelBase, KerasModel +from ._base import ModelBase -if get_backend() == "amd": - from keras.layers import ( - AveragePooling2D, BatchNormalization, Concatenate, Dense, Dropout, Flatten, Input, Reshape, - LeakyReLU, UpSampling2D) -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras.layers import ( # pylint:disable=import-error,no-name-in-module - AveragePooling2D, BatchNormalization, Concatenate, Dense, Dropout, Flatten, Input, Reshape, - LeakyReLU, UpSampling2D) logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -35,21 +32,22 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.input_shape = (128, 128, 3) - self.features = dict(lowmem=0, fair=1, best=2)[self.config["features"]] + self.features = {"lowmem": 0, "fair": 1, "best": 2}[self.config["features"]] self.encoder_filters = 64 if self.features > 0 else 48 bonum_fortunam = 128 self.encoder_dim = {0: 512 + bonum_fortunam, 1: 1024 + bonum_fortunam, 2: 1536 + bonum_fortunam}[self.features] - self.details = dict(fast=0, good=1)[self.config["details"]] + self.details = {"fast": 0, "good": 1}[self.config["details"]] try: self.upscale_ratio = {128: 2, 256: 4, 384: 6}[self.config["output_size"]] - except KeyError: + except KeyError as err: logger.error("Config error: output_size must be one of: 128, 256, or 384.") - raise FaceswapError("Config error: output_size must be one of: 128, 256, or 384.") + raise FaceswapError("Config error: output_size must be one of: " + "128, 256, or 384.") from err logger.debug("output_size: %s, features: %s, encoder_filters: %s, encoder_dim: %s, " " details: %s, upscale_ratio: %s", self.config["output_size"], self.features, @@ -65,7 +63,7 @@ def build_model(self, inputs): outputs = [self.decoder_a()(encoder_a), decoder_b()(encoder_b)] - autoencoder = KerasModel(inputs, outputs, name=self.model_name) + autoencoder = KModel(inputs, outputs, name=self.model_name) return autoencoder def encoder(self): @@ -104,7 +102,7 @@ def encoder(self): var_x = Dropout(0.05)(var_x) var_x = Reshape((4, 4, 1024))(var_x) - return KerasModel(input_, var_x, name="encoder") + return KModel(input_, var_x, name="encoder") def decoder_a(self): """ DeLight Decoder A(old face) Network """ @@ -136,7 +134,7 @@ def decoder_a(self): outputs.append(var_y) - return KerasModel([input_], outputs=outputs, name="decoder_a") + return KModel([input_], outputs=outputs, name="decoder_a") def decoder_b_fast(self): """ DeLight Fast Decoder B(new face) Network """ @@ -171,7 +169,7 @@ def decoder_b_fast(self): outputs.append(var_y) - return KerasModel([input_], outputs=outputs, name="decoder_b_fast") + return KModel([input_], outputs=outputs, name="decoder_b_fast") def decoder_b(self): """ DeLight Decoder B(new face) Network """ @@ -223,7 +221,7 @@ def decoder_b(self): outputs.append(var_y) - return KerasModel([input_], outputs=outputs, name="decoder_b") + return KModel([input_], outputs=outputs, name="decoder_b") def _legacy_mapping(self): """ The mapping of legacy separate model names to single model names """ diff --git a/plugins/train/model/iae.py b/plugins/train/model/iae.py index dbbb982e30..d2690dd3be 100644 --- a/plugins/train/model/iae.py +++ b/plugins/train/model/iae.py @@ -1,17 +1,13 @@ #!/usr/bin/env python3 """ Improved autoencoder for faceswap """ -from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock -from lib.utils import get_backend - -from ._base import ModelBase, KerasModel +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras.layers import Concatenate, Dense, Flatten, Input, Reshape # noqa:E501 # pylint:disable=import-error +from tensorflow.keras.models import Model as KModel # pylint:disable=import-error -if get_backend() == "amd": - from keras.layers import Concatenate, Dense, Flatten, Input, Reshape +from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras.layers import Concatenate, Dense, Flatten, Input, Reshape # noqa pylint:disable=import-error,no-name-in-module +from ._base import ModelBase class Model(ModelBase): @@ -35,7 +31,7 @@ def build_model(self, inputs): outputs = [decoder(Concatenate()([inter_a(encoder_a), inter_both(encoder_a)])), decoder(Concatenate()([inter_b(encoder_b), inter_both(encoder_b)]))] - autoencoder = KerasModel(inputs, outputs, name=self.model_name) + autoencoder = KModel(inputs, outputs, name=self.model_name) return autoencoder def encoder(self): @@ -47,7 +43,7 @@ def encoder(self): var_x = Conv2DBlock(512, activation="leakyrelu")(var_x) var_x = Conv2DBlock(1024, activation="leakyrelu")(var_x) var_x = Flatten()(var_x) - return KerasModel(input_, var_x, name="encoder") + return KModel(input_, var_x, name="encoder") def intermediate(self, side): """ Intermediate Network """ @@ -55,7 +51,7 @@ def intermediate(self, side): var_x = Dense(self.encoder_dim)(input_) var_x = Dense(4 * 4 * int(self.encoder_dim/2))(var_x) var_x = Reshape((4, 4, int(self.encoder_dim/2)))(var_x) - return KerasModel(input_, var_x, name=f"inter_{side}") + return KModel(input_, var_x, name=f"inter_{side}") def decoder(self): """ Decoder Network """ @@ -76,7 +72,7 @@ def decoder(self): var_y = UpscaleBlock(64, activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, name="mask_out")(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs, name="decoder") + return KModel(input_, outputs=outputs, name="decoder") def _legacy_mapping(self): """ The mapping of legacy separate model names to single model names """ diff --git a/plugins/train/model/lightweight.py b/plugins/train/model/lightweight.py index 7dc0c69880..4feca05ab1 100644 --- a/plugins/train/model/lightweight.py +++ b/plugins/train/model/lightweight.py @@ -4,8 +4,10 @@ Based on the original https://www.reddit.com/r/deepfakes/ code sample + contributions """ +from tensorflow.keras.models import Model as KModel # pylint:disable=import-error + from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock -from .original import Model as OriginalModel, KerasModel, Dense, Flatten, Input, Reshape +from .original import Model as OriginalModel, Dense, Flatten, Input, Reshape class Model(OriginalModel): @@ -25,7 +27,7 @@ def encoder(self): var_x = Dense(4 * 4 * 512)(var_x) var_x = Reshape((4, 4, 512))(var_x) var_x = UpscaleBlock(256, activation="leakyrelu")(var_x) - return KerasModel(input_, var_x, name="encoder") + return KModel(input_, var_x, name="encoder") def decoder(self, side): """ Decoder Network """ @@ -46,4 +48,4 @@ def decoder(self, side): activation="sigmoid", name=f"mask_out_{side}")(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs, name=f"decoder_{side}") + return KModel(input_, outputs=outputs, name=f"decoder_{side}") diff --git a/plugins/train/model/original.py b/plugins/train/model/original.py index d23b59c532..0613a5d55e 100644 --- a/plugins/train/model/original.py +++ b/plugins/train/model/original.py @@ -6,15 +6,12 @@ from. """ -from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock -from lib.utils import get_backend -from ._base import KerasModel, ModelBase +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras.layers import Dense, Flatten, Reshape, Input # noqa:E501 # pylint:disable=import-error +from tensorflow.keras.models import Model as KModel # pylint:disable=import-error -if get_backend() == "amd": - from keras.layers import Dense, Flatten, Reshape, Input -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras.layers import Dense, Flatten, Reshape, Input # noqa pylint:disable=import-error,no-name-in-module +from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock +from ._base import ModelBase class Model(ModelBase): @@ -66,12 +63,6 @@ def build_model(self, inputs): 2 Decoders are then defined (one for each side) with the encoder instances passed in as input to the corresponding decoders. - It is important to note that any models and sub-models should not call - :class:`keras.models.Model` directly, but rather call - :class:`plugins.train.model._base.KerasModel`. This acts as a wrapper for Keras' Model - class, but handles some minor differences which need to be handled between Nvidia and AMD - backends. - The final output of the model should always call :class:`lib.model.nn_blocks.Conv2DOutput` so that the correct data type is set for the final activation, to support Mixed Precision Training. Failure to do so is likely to lead to issues when Mixed Precision is enabled. @@ -85,8 +76,7 @@ def build_model(self, inputs): Returns ------- :class:`keras.models.Model` - The output of this function must be a keras model generated from - :class:`plugins.train.model._base.KerasModel`. See Keras documentation for the correct + See Keras documentation for the correct structure, but note that parameter :attr:`name` is a required rather than an optional argument in Faceswap. You should assign this to the attribute ``self.name`` that is automatically generated from the plugin's filename. @@ -100,7 +90,7 @@ def build_model(self, inputs): outputs = [self.decoder("a")(encoder_a), self.decoder("b")(encoder_b)] - autoencoder = KerasModel(inputs, outputs, name=self.model_name) + autoencoder = KModel(inputs, outputs, name=self.model_name) return autoencoder def encoder(self): @@ -127,7 +117,7 @@ def encoder(self): var_x = Dense(4 * 4 * 1024)(var_x) var_x = Reshape((4, 4, 1024))(var_x) var_x = UpscaleBlock(512, activation="leakyrelu")(var_x) - return KerasModel(input_, var_x, name="encoder") + return KModel(input_, var_x, name="encoder") def decoder(self, side): """ The original Faceswap Decoder Network. @@ -160,7 +150,7 @@ def decoder(self, side): var_y = UpscaleBlock(64, activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, name=f"mask_out_{side}")(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs, name=f"decoder_{side}") + return KModel(input_, outputs=outputs, name=f"decoder_{side}") def _legacy_mapping(self): """ The mapping of legacy separate model names to single model names """ diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 589c26c586..689b9c6206 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -2,54 +2,44 @@ """ Phaze-A Model by TorzDF with thanks to BirbFakes and the myriad of testers. """ # pylint: disable=too-many-lines +from __future__ import annotations import logging import sys +import typing as T from dataclasses import dataclass -from typing import Dict, List, Optional, Tuple, Union - import numpy as np +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras.layers import LayerNormalization # pylint:disable=import-error +from tensorflow.keras import applications as kapp, backend as K # noqa:E501 # pylint:disable=import-error +from tensorflow.keras.layers import ( # pylint:disable=import-error + Add, BatchNormalization, Concatenate, Dense, Dropout, Flatten, GaussianNoise, MaxPool2D, + GlobalAveragePooling2D, GlobalMaxPooling2D, Input, LeakyReLU, Reshape, UpSampling2D, + Conv2D as KConv2D) +from tensorflow.keras.models import clone_model, Model as KModel # noqa:E501 # pylint:disable=import-error from lib.model.nn_blocks import ( Conv2D, Conv2DBlock, Conv2DOutput, ResidualBlock, UpscaleBlock, Upscale2xBlock, UpscaleResizeImagesBlock, UpscaleDNYBlock) from lib.model.normalization import ( - AdaInstanceNormalization, GroupNormalization, InstanceNormalization, LayerNormalization, - RMSNormalization) -from lib.utils import get_backend, get_tf_version, FaceswapError + AdaInstanceNormalization, GroupNormalization, InstanceNormalization, RMSNormalization) +from lib.utils import get_tf_version, FaceswapError -from ._base import KerasModel, ModelBase, get_all_sub_models +from ._base import ModelBase, get_all_sub_models if sys.version_info < (3, 8): from typing_extensions import Literal else: from typing import Literal -logger = logging.getLogger(__name__) # pylint: disable=invalid-name - -if get_backend() == "amd": - from keras import applications as kapp, backend as K - from keras.layers import ( - Add, BatchNormalization, Concatenate, Dense, Dropout, Flatten, GaussianNoise, MaxPool2D, - GlobalAveragePooling2D, GlobalMaxPooling2D, Input, LeakyReLU, Reshape, UpSampling2D, - Conv2D as KConv2D) - from keras.models import clone_model - # typing checks - import keras - from plaidml.tile import Value as Tensor # pylint:disable=import-error -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras import applications as kapp, backend as K # pylint:disable=import-error - from tensorflow.keras.layers import ( # pylint:disable=import-error,no-name-in-module - Add, BatchNormalization, Concatenate, Dense, Dropout, Flatten, GaussianNoise, MaxPool2D, - GlobalAveragePooling2D, GlobalMaxPooling2D, Input, LeakyReLU, Reshape, UpSampling2D, - Conv2D as KConv2D) - from tensorflow.keras.models import clone_model # noqa pylint:disable=import-error,no-name-in-module - # typing checks +if T.TYPE_CHECKING: from tensorflow import keras from tensorflow import Tensor +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + @dataclass class _EncoderInfo: """ Contains model configuration options for various Phaze-A Encoders. @@ -61,9 +51,6 @@ class _EncoderInfo: exist in Keras Applications default_size: int The default input size of the encoder - no_amd: bool, optional - ``True`` if the encoder is not compatible with the PlaidML backend otherwise ``False``. - Default: ``False`` tf_min: float, optional The lowest version of Tensorflow that the encoder can be used for. Default: `2.0` scaling: tuple, optional @@ -78,104 +65,86 @@ class _EncoderInfo: """ keras_name: str default_size: int - no_amd: bool = False - tf_min: Tuple[int, int] = (2, 0) - scaling: Tuple[int, int] = (0, 1) + tf_min: T.Tuple[int, int] = (2, 0) + scaling: T.Tuple[int, int] = (0, 1) min_size: int = 32 enforce_for_weights: bool = False color_order: Literal["bgr", "rgb"] = "rgb" -_MODEL_MAPPING: Dict[str, _EncoderInfo] = dict( - densenet121=_EncoderInfo( +_MODEL_MAPPING: T.Dict[str, _EncoderInfo] = { + "densenet121": _EncoderInfo( keras_name="DenseNet121", default_size=224), - densenet169=_EncoderInfo( + "densenet169": _EncoderInfo( keras_name="DenseNet169", default_size=224), - densenet201=_EncoderInfo( + "densenet201": _EncoderInfo( keras_name="DenseNet201", default_size=224), - efficientnet_b0=_EncoderInfo( - keras_name="EfficientNetB0", - no_amd=True, tf_min=(2, 3), scaling=(0, 255), default_size=224), - efficientnet_b1=_EncoderInfo( - keras_name="EfficientNetB1", - no_amd=True, tf_min=(2, 3), scaling=(0, 255), default_size=240), - efficientnet_b2=_EncoderInfo( - keras_name="EfficientNetB2", - no_amd=True, tf_min=(2, 3), scaling=(0, 255), default_size=260), - efficientnet_b3=_EncoderInfo( - keras_name="EfficientNetB3", - no_amd=True, tf_min=(2, 3), scaling=(0, 255), default_size=300), - efficientnet_b4=_EncoderInfo( - keras_name="EfficientNetB4", - no_amd=True, tf_min=(2, 3), scaling=(0, 255), default_size=380), - efficientnet_b5=_EncoderInfo( - keras_name="EfficientNetB5", - no_amd=True, tf_min=(2, 3), scaling=(0, 255), default_size=456), - efficientnet_b6=_EncoderInfo( - keras_name="EfficientNetB6", - no_amd=True, tf_min=(2, 3), scaling=(0, 255), default_size=528), - efficientnet_b7=_EncoderInfo( - keras_name="EfficientNetB7", - no_amd=True, tf_min=(2, 3), scaling=(0, 255), default_size=600), - efficientnet_v2_b0=_EncoderInfo( - keras_name="EfficientNetV2B0", - no_amd=True, tf_min=(2, 8), scaling=(-1, 1), default_size=224), - efficientnet_v2_b1=_EncoderInfo( - keras_name="EfficientNetV2B1", - no_amd=True, tf_min=(2, 8), scaling=(-1, 1), default_size=240), - efficientnet_v2_b2=_EncoderInfo( - keras_name="EfficientNetV2B2", - no_amd=True, tf_min=(2, 8), scaling=(-1, 1), default_size=260), - efficientnet_v2_b3=_EncoderInfo( - keras_name="EfficientNetV2B3", - no_amd=True, tf_min=(2, 8), scaling=(-1, 1), default_size=300), - efficientnet_v2_s=_EncoderInfo( - keras_name="EfficientNetV2S", - no_amd=True, tf_min=(2, 8), scaling=(-1, 1), default_size=384), - efficientnet_v2_m=_EncoderInfo( - keras_name="EfficientNetV2M", - no_amd=True, tf_min=(2, 8), scaling=(-1, 1), default_size=480), - efficientnet_v2_l=_EncoderInfo( - keras_name="EfficientNetV2L", - no_amd=True, tf_min=(2, 8), scaling=(-1, 1), default_size=480), - inception_resnet_v2=_EncoderInfo( + "efficientnet_b0": _EncoderInfo( + keras_name="EfficientNetB0", tf_min=(2, 3), scaling=(0, 255), default_size=224), + "efficientnet_b1": _EncoderInfo( + keras_name="EfficientNetB1", tf_min=(2, 3), scaling=(0, 255), default_size=240), + "efficientnet_b2": _EncoderInfo( + keras_name="EfficientNetB2", tf_min=(2, 3), scaling=(0, 255), default_size=260), + "efficientnet_b3": _EncoderInfo( + keras_name="EfficientNetB3", tf_min=(2, 3), scaling=(0, 255), default_size=300), + "efficientnet_b4": _EncoderInfo( + keras_name="EfficientNetB4", tf_min=(2, 3), scaling=(0, 255), default_size=380), + "efficientnet_b5": _EncoderInfo( + keras_name="EfficientNetB5", tf_min=(2, 3), scaling=(0, 255), default_size=456), + "efficientnet_b6": _EncoderInfo( + keras_name="EfficientNetB6", tf_min=(2, 3), scaling=(0, 255), default_size=528), + "efficientnet_b7": _EncoderInfo( + keras_name="EfficientNetB7", tf_min=(2, 3), scaling=(0, 255), default_size=600), + "efficientnet_v2_b0": _EncoderInfo( + keras_name="EfficientNetV2B0", tf_min=(2, 8), scaling=(-1, 1), default_size=224), + "efficientnet_v2_b1": _EncoderInfo( + keras_name="EfficientNetV2B1", tf_min=(2, 8), scaling=(-1, 1), default_size=240), + "efficientnet_v2_b2": _EncoderInfo( + keras_name="EfficientNetV2B2", tf_min=(2, 8), scaling=(-1, 1), default_size=260), + "efficientnet_v2_b3": _EncoderInfo( + keras_name="EfficientNetV2B3", tf_min=(2, 8), scaling=(-1, 1), default_size=300), + "efficientnet_v2_s": _EncoderInfo( + keras_name="EfficientNetV2S", tf_min=(2, 8), scaling=(-1, 1), default_size=384), + "efficientnet_v2_m": _EncoderInfo( + keras_name="EfficientNetV2M", tf_min=(2, 8), scaling=(-1, 1), default_size=480), + "efficientnet_v2_l": _EncoderInfo( + keras_name="EfficientNetV2L", tf_min=(2, 8), scaling=(-1, 1), default_size=480), + "inception_resnet_v2": _EncoderInfo( keras_name="InceptionResNetV2", scaling=(-1, 1), min_size=75, default_size=299), - inception_v3=_EncoderInfo( + "inception_v3": _EncoderInfo( keras_name="InceptionV3", scaling=(-1, 1), min_size=75, default_size=299), - mobilenet=_EncoderInfo( + "mobilenet": _EncoderInfo( keras_name="MobileNet", scaling=(-1, 1), default_size=224), - mobilenet_v2=_EncoderInfo( + "mobilenet_v2": _EncoderInfo( keras_name="MobileNetV2", scaling=(-1, 1), default_size=224), - mobilenet_v3_large=_EncoderInfo( - keras_name="MobileNetV3Large", - no_amd=True, tf_min=(2, 4), scaling=(-1, 1), default_size=224), - mobilenet_v3_small=_EncoderInfo( - keras_name="MobileNetV3Small", - no_amd=True, tf_min=(2, 4), scaling=(-1, 1), default_size=224), - nasnet_large=_EncoderInfo( + "mobilenet_v3_large": _EncoderInfo( + keras_name="MobileNetV3Large", tf_min=(2, 4), scaling=(-1, 1), default_size=224), + "mobilenet_v3_small": _EncoderInfo( + keras_name="MobileNetV3Small", tf_min=(2, 4), scaling=(-1, 1), default_size=224), + "nasnet_large": _EncoderInfo( keras_name="NASNetLarge", scaling=(-1, 1), default_size=331, enforce_for_weights=True), - nasnet_mobile=_EncoderInfo( + "nasnet_mobile": _EncoderInfo( keras_name="NASNetMobile", scaling=(-1, 1), default_size=224, enforce_for_weights=True), - resnet50=_EncoderInfo( + "resnet50": _EncoderInfo( keras_name="ResNet50", scaling=(-1, 1), min_size=32, default_size=224), - resnet50_v2=_EncoderInfo( - keras_name="ResNet50V2", no_amd=True, scaling=(-1, 1), default_size=224), - resnet101=_EncoderInfo( - keras_name="ResNet101", no_amd=True, scaling=(-1, 1), default_size=224), - resnet101_v2=_EncoderInfo( - keras_name="ResNet101V2", no_amd=True, scaling=(-1, 1), default_size=224), - resnet152=_EncoderInfo( - keras_name="ResNet152", no_amd=True, scaling=(-1, 1), default_size=224), - resnet152_v2=_EncoderInfo( - keras_name="ResNet152V2", no_amd=True, scaling=(-1, 1), default_size=224), - vgg16=_EncoderInfo( + "resnet50_v2": _EncoderInfo( + keras_name="ResNet50V2", scaling=(-1, 1), default_size=224), + "resnet101": _EncoderInfo( + keras_name="ResNet101", scaling=(-1, 1), default_size=224), + "resnet101_v2": _EncoderInfo( + keras_name="ResNet101V2", scaling=(-1, 1), default_size=224), + "resnet152": _EncoderInfo( + keras_name="ResNet152", scaling=(-1, 1), default_size=224), + "resnet152_v2": _EncoderInfo( + keras_name="ResNet152V2", scaling=(-1, 1), default_size=224), + "vgg16": _EncoderInfo( keras_name="VGG16", color_order="bgr", scaling=(0, 255), default_size=224), - vgg19=_EncoderInfo( + "vgg19": _EncoderInfo( keras_name="VGG19", color_order="bgr", scaling=(0, 255), default_size=224), - xception=_EncoderInfo( + "xception": _EncoderInfo( keras_name="Xception", scaling=(-1, 1), min_size=71, default_size=299), - fs_original=_EncoderInfo( - keras_name="", color_order="bgr", min_size=32, default_size=1024)) + "fs_original": _EncoderInfo( + keras_name="", color_order="bgr", min_size=32, default_size=1024)} class Model(ModelBase): @@ -239,8 +208,8 @@ def _update_dropouts(self, model: keras.models.Model) -> keras.models.Model: :class:`keras.models.Model` The loaded Keras Model with the dropout rates updated """ - dropouts = dict(fc=self.config["fc_dropout"], - gblock=self.config["fc_gblock_dropout"]) + dropouts = {"fc": self.config["fc_dropout"], + "gblock": self.config["fc_gblock_dropout"]} logger.debug("Config dropouts: %s", dropouts) updated = False for mod in get_all_sub_models(model): @@ -269,7 +238,7 @@ def _update_dropouts(self, model: keras.models.Model) -> keras.models.Model: model = new_model return model - def _select_freeze_layers(self) -> List[str]: + def _select_freeze_layers(self) -> T.List[str]: """ Process the selected frozen layers and replace the `keras_encoder` option with the actual keras model name @@ -293,7 +262,7 @@ def _select_freeze_layers(self) -> List[str]: logger.debug("Removing 'keras_encoder' for '%s'", arch) return retval - def _get_input_shape(self) -> Tuple[int, int, int]: + def _get_input_shape(self) -> T.Tuple[int, int, int]: """ Obtain the input shape for the model. Input shape is calculated from the selected Encoder's input size, scaled to the user @@ -340,19 +309,14 @@ def _validate_encoder_architecture(self) -> None: raise FaceswapError(f"'{arch}' is not a valid choice for encoder architecture. Choose " f"one of {list(_MODEL_MAPPING.keys())}.") - if get_backend() == "amd" and model.no_amd: - valid = [k for k, v in _MODEL_MAPPING.items() if not v.no_amd] - raise FaceswapError(f"'{arch}' is not compatible with the AMD backend. Choose one of " - f"{valid}.") - tf_ver = get_tf_version() tf_min = model.tf_min - if get_backend() != "amd" and tf_ver < tf_min: + if tf_ver < tf_min: raise FaceswapError(f"{arch}' is not compatible with your version of Tensorflow. The " f"minimum version required is {tf_min} whilst you have version " f"{tf_ver} installed.") - def build_model(self, inputs: List[Tensor]) -> keras.models.Model: + def build_model(self, inputs: T.List[Tensor]) -> keras.models.Model: """ Create the model's structure. Parameters @@ -364,11 +328,7 @@ def build_model(self, inputs: List[Tensor]) -> keras.models.Model: Returns ------- :class:`keras.models.Model` - The output of this function must be a keras model generated from - :class:`plugins.train.model._base.KerasModel`. See Keras documentation for the correct - structure, but note that parameter :attr:`name` is a required rather than an optional - argument in Faceswap. You should assign this to the attribute ``self.name`` that is - automatically generated from the plugin's filename. + The generated model """ # Create sub-Models encoders = self._build_encoders(inputs) @@ -378,10 +338,10 @@ def build_model(self, inputs: List[Tensor]) -> keras.models.Model: # Create Autoencoder outputs = [decoders["a"], decoders["b"]] - autoencoder = KerasModel(inputs, outputs, name=self.model_name) + autoencoder = KModel(inputs, outputs, name=self.model_name) return autoencoder - def _build_encoders(self, inputs: List[Tensor]) -> Dict[str, keras.models.Model]: + def _build_encoders(self, inputs: T.List[Tensor]) -> T.Dict[str, keras.models.Model]: """ Build the encoders for Phaze-A Parameters @@ -396,13 +356,13 @@ def _build_encoders(self, inputs: List[Tensor]) -> Dict[str, keras.models.Model] side as key ('a' or 'b'), encoder for side as value """ encoder = Encoder(self.input_shape, self.config)() - retval = dict(a=encoder(inputs[0]), b=encoder(inputs[1])) + retval = {"a": encoder(inputs[0]), "b": encoder(inputs[1])} logger.debug("Encoders: %s", retval) return retval def _build_fully_connected( self, - inputs: Dict[str, keras.models.Model]) -> Dict[str, List[keras.models.Model]]: + inputs: T.Dict[str, keras.models.Model]) -> T.Dict[str, T.List[keras.models.Model]]: """ Build the fully connected layers for Phaze-A Parameters @@ -441,14 +401,14 @@ def _build_fully_connected( inter_a.append(fc_gblock(inputs["a"])) inter_b.append(fc_gblock(inputs["b"])) - retval = dict(a=inter_a, b=inter_b) + retval = {"a": inter_a, "b": inter_b} logger.debug("Fully Connected: %s", retval) return retval def _build_g_blocks( self, - inputs: Dict[str, List[keras.models.Model]] - ) -> Dict[str, Union[List[keras.models.Model], keras.models.Model]]: + inputs: T.Dict[str, T.List[keras.models.Model]] + ) -> T.Dict[str, T.Union[T.List[keras.models.Model], keras.models.Model]]: """ Build the g-block layers for Phaze-A. If a g-block has not been selected for this model, then the original `inters` models are @@ -471,19 +431,19 @@ def _build_g_blocks( input_shapes = [K.int_shape(inter)[1:] for inter in inputs["a"]] if self.config["split_gblock"]: - retval = dict(a=GBlock("a", input_shapes, self.config)()(inputs["a"]), - b=GBlock("b", input_shapes, self.config)()(inputs["b"])) + retval = {"a": GBlock("a", input_shapes, self.config)()(inputs["a"]), + "b": GBlock("b", input_shapes, self.config)()(inputs["b"])} else: g_block = GBlock("both", input_shapes, self.config)() - retval = dict(a=g_block((inputs["a"])), b=g_block((inputs["b"]))) + retval = {"a": g_block((inputs["a"])), "b": g_block((inputs["b"]))} logger.debug("G-Blocks: %s", retval) return retval def _build_decoders( self, - inputs: Dict[str, Union[List[keras.models.Model], keras.models.Model]] - ) -> Dict[str, keras.models.Model]: + inputs: T.Dict[str, T.Union[T.List[keras.models.Model], keras.models.Model]] + ) -> T.Dict[str, keras.models.Model]: """ Build the encoders for Phaze-A Parameters @@ -511,11 +471,11 @@ def _build_decoders( input_shape = K.int_shape(input_)[1:] if self.config["split_decoders"]: - retval = dict(a=Decoder("a", input_shape, self.config)()(inputs["a"]), - b=Decoder("b", input_shape, self.config)()(inputs["b"])) + retval = {"a": Decoder("a", input_shape, self.config)()(inputs["a"]), + "b": Decoder("b", input_shape, self.config)()(inputs["b"])} else: decoder = Decoder("both", input_shape, self.config)() - retval = dict(a=decoder(inputs["a"]), b=decoder(inputs["b"])) + retval = {"a": decoder(inputs["a"]), "b": decoder(inputs["b"])} logger.debug("Decoders: %s", retval) return retval @@ -540,12 +500,12 @@ def _bottleneck(inputs: Tensor, bottleneck: str, size: int, normalization: str) tensor The output from the bottleneck """ - norms = dict(layer=LayerNormalization, - rms=RMSNormalization, - instance=InstanceNormalization) - bottlenecks = dict(average_pooling=GlobalAveragePooling2D(), - dense=Dense(size), - max_pooling=GlobalMaxPooling2D()) + norms = {"layer": LayerNormalization, + "rms": RMSNormalization, + "instance": InstanceNormalization} + bottlenecks = {"average_pooling": GlobalAveragePooling2D(), + "dense": Dense(size), + "max_pooling": GlobalMaxPooling2D()} var_x = inputs if normalization: var_x = norms[normalization]()(var_x) @@ -562,9 +522,9 @@ def _bottleneck(inputs: Tensor, bottleneck: str, size: int, normalization: str) def _get_upscale_layer(method: Literal["resize_images", "subpixel", "upscale_dny", "upscale_fast", "upscale_hybrid", "upsample2d"], filters: int, - activation: Optional[str] = None, - upsamples: Optional[int] = None, - interpolation: Optional[str] = None) -> keras.layers.Layer: + activation: T.Optional[str] = None, + upsamples: T.Optional[int] = None, + interpolation: T.Optional[str] = None) -> keras.layers.Layer: """ Obtain an instance of the requested upscale method. Parameters @@ -590,7 +550,7 @@ def _get_upscale_layer(method: Literal["resize_images", "subpixel", "upscale_dny The selected configured upscale layer """ if method == "upsample2d": - kwargs: Dict[str, Union[str, int]] = {} + kwargs: T.Dict[str, T.Union[str, int]] = {} if upsamples: kwargs["size"] = upsamples if interpolation: @@ -611,7 +571,7 @@ def _get_curve(start_y: int, end_y: int, num_points: int, scale: float, - mode: Literal["full", "cap_max", "cap_min"] = "full") -> List[int]: + mode: Literal["full", "cap_max", "cap_min"] = "full") -> T.List[int]: """ Obtain a curve. For the given start and end y values, return the y co-ordinates of a curve for the given @@ -700,24 +660,24 @@ class Encoder(): # pylint:disable=too-few-public-methods config: dict The model configuration options """ - def __init__(self, input_shape: Tuple[int, ...], config: dict) -> None: + def __init__(self, input_shape: T.Tuple[int, ...], config: dict) -> None: self.input_shape = input_shape self._config = config self._input_shape = input_shape @property - def _model_kwargs(self) -> Dict[str, Dict[str, Union[str, bool]]]: + def _model_kwargs(self) -> T.Dict[str, T.Dict[str, T.Union[str, bool]]]: """ dict: Configuration option for architecture mapped to optional kwargs. """ - return dict(mobilenet=dict(alpha=self._config["mobilenet_width"], - depth_multiplier=self._config["mobilenet_depth"], - dropout=self._config["mobilenet_dropout"]), - mobilenet_v2=dict(alpha=self._config["mobilenet_width"]), - mobilenet_v3=dict(alpha=self._config["mobilenet_width"], - minimalist=self._config["mobilenet_minimalistic"], - include_preprocessing=False)) + return {"mobilenet": {"alpha": self._config["mobilenet_width"], + "depth_multiplier": self._config["mobilenet_depth"], + "dropout": self._config["mobilenet_dropout"]}, + "mobilenet_v2": {"alpha": self._config["mobilenet_width"]}, + "mobilenet_v3": {"alpha": self._config["mobilenet_width"], + "minimalist": self._config["mobilenet_minimalistic"], + "include_preprocessing": False}} @property - def _selected_model(self) -> Tuple[_EncoderInfo, dict]: + def _selected_model(self) -> T.Tuple[_EncoderInfo, dict]: """ tuple(dict, :class:`_EncoderInfo`): The selected encoder model and it's associated keyword arguments """ arch = self._config["enc_architecture"] @@ -772,7 +732,7 @@ def __call__(self) -> keras.models.Model: self._config["bottleneck_size"], self._config["bottleneck_norm"]) - return KerasModel(input_, var_x, name="encoder") + return KModel(input_, var_x, name="encoder") def _get_encoder_model(self) -> keras.models.Model: """ Return the model defined by the selected architecture. @@ -1007,7 +967,7 @@ def __call__(self) -> keras.models.Model: self._config, layer_indicies=(0, num_upscales))(var_x) - return KerasModel(input_, var_x, name=f"fc_{self._side}") + return KModel(input_, var_x, name=f"fc_{self._side}") class UpscaleBlocks(): # pylint: disable=too-few-public-methods @@ -1032,12 +992,12 @@ class UpscaleBlocks(): # pylint: disable=too-few-public-methods and the Decoder. ``None`` will generate the full Upscale chain. An end index of -1 will generate the layers from the starting index to the final upscale. Default: ``None`` """ - _filters: List[int] = [] + _filters: T.List[int] = [] def __init__(self, side: Literal["a", "b", "both", "shared"], config: dict, - layer_indicies: Optional[Tuple[int, int]] = None) -> None: + layer_indicies: T.Optional[T.Tuple[int, int]] = None) -> None: logger.debug("Initializing: %s (side: %s, layer_indicies: %s)", self.__class__.__name__, side, layer_indicies) self._side = side @@ -1134,11 +1094,11 @@ def _normalization(self, inputs: Tensor) -> Tensor: """ if not self._config["dec_norm"]: return inputs - norms = dict(batch=BatchNormalization, - group=GroupNormalization, - instance=InstanceNormalization, - layer=LayerNormalization, - rms=RMSNormalization) + norms = {"batch": BatchNormalization, + "group": GroupNormalization, + "instance": InstanceNormalization, + "layer": LayerNormalization, + "rms": RMSNormalization} return norms[self._config["dec_norm"]]()(inputs) def _dny_entry(self, inputs: Tensor) -> Tensor: @@ -1166,7 +1126,7 @@ def _dny_entry(self, inputs: Tensor) -> Tensor: relu_alpha=0.2)(var_x) return var_x - def __call__(self, inputs: Union[Tensor, List[Tensor]]) -> Union[Tensor, List[Tensor]]: + def __call__(self, inputs: T.Union[Tensor, T.List[Tensor]]) -> T.Union[Tensor, T.List[Tensor]]: """ Upscale Network. Parameters @@ -1244,7 +1204,7 @@ class GBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, side: Literal["a", "b", "both"], - input_shapes: Union[list, tuple], + input_shapes: T.Union[list, tuple], config: dict) -> None: logger.debug("Initializing: %s (side: %s, input_shapes: %s)", self.__class__.__name__, side, input_shapes) @@ -1308,7 +1268,7 @@ def __call__(self) -> keras.models.Model: var_x = Conv2D(g_filts, 3, strides=1, padding="same")(var_x) var_x = GaussianNoise(1.0)(var_x) var_x = self._g_block(var_x, style, g_filts) - return KerasModel(self._inputs, var_x, name=f"g_block_{self._side}") + return KModel(self._inputs, var_x, name=f"g_block_{self._side}") class Decoder(): # pylint:disable=too-few-public-methods @@ -1325,7 +1285,7 @@ class Decoder(): # pylint:disable=too-few-public-methods """ def __init__(self, side: Literal["a", "b", "both"], - input_shape: Tuple[int, int, int], + input_shape: T.Tuple[int, int, int], config: dict) -> None: logger.debug("Initializing: %s (side: %s, input_shape: %s)", self.__class__.__name__, side, input_shape) @@ -1366,4 +1326,4 @@ def __call__(self) -> keras.models.Model: self._config["dec_output_kernel"], name="mask_out")(var_y)) - return KerasModel(inputs, outputs=outputs, name=f"decoder_{self._side}") + return KModel(inputs, outputs=outputs, name=f"decoder_{self._side}") diff --git a/plugins/train/model/phaze_a_defaults.py b/plugins/train/model/phaze_a_defaults.py index b930f27eba..c741ae09c9 100644 --- a/plugins/train/model/phaze_a_defaults.py +++ b/plugins/train/model/phaze_a_defaults.py @@ -7,41 +7,40 @@ within the faceswap/config folder. The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} + "_HELPTEXT: A string describing what this plugin does + "_DEFAULTS: A dictionary containing the options, defaults and meta information. The + " dictionary should be defined as: + " {: {}} - should always be lower text. - dictionary requirements are listed below. + " should always be lower text. + " dictionary requirements are listed below. The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid data types are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and data types this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and data types this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. + "datatype: [required] A python type class. This limits the type of data that can be + " provided in the .ini file and ensures that the value is returned in the + " correct type to faceswap. Valid data types are: , , + " , . + "default: [required] The default value for this option. + "info: [required] A string describing what this option does. + "choices: [optional] If this option's datatype is of then valid + " selections can be defined here. This validates the option and also enables + " a combobox / radio option in the GUI. + "gui_radio: [optional] If are defined, this indicates that the GUI should use + " radio buttons rather than a combobox to display this option. + "min_max: [partial] For and data types this is required + " otherwise it is ignored. Should be a tuple of min and max accepted values. + " This is used for controlling the GUI slider range. Values are not enforced. + "rounding: [partial] For and data types this is + " required otherwise it is ignored. Used for the GUI slider. For floats, this + " is the number of decimal places to display. For ints this is the step size. + "fixed: [optional] [train only]. Training configurations are fixed when the model is + " created, and then reloaded from the state file. Marking an item as fixed=False + " indicates that this value can be changed for existing models, and will override + " the value saved in the state file with the updated value in config. If not + " provided this will default to True. """ from typing import List -from lib.utils import get_backend _HELPTEXT: str = ( "Phaze-A Model by TorzDF, with thanks to BirbFakes.\n" @@ -49,634 +48,673 @@ "inspiration from Nvidia's StyleGAN for the Decoder. It is highly recommended to research to " "understand the parameters better.") -_ENCODERS: List[str] = [ - "densenet121", "densenet169", "densenet201", "inception_resnet_v2", "inception_v3", - "mobilenet", "mobilenet_v2", "nasnet_large", "nasnet_mobile", "resnet50", "vgg16", "vgg19", - "xception", "fs_original"] +_ENCODERS: List[str] = sorted([ + "densenet121", "densenet169", "densenet201", "efficientnet_b0", "efficientnet_b1", + "efficientnet_b2", "efficientnet_b3", "efficientnet_b4", "efficientnet_b5", "efficientnet_b6", + "efficientnet_b7", "efficientnet_v2_b0", "efficientnet_v2_b1", "efficientnet_v2_b2", + "efficientnet_v2_b3", "efficientnet_v2_l", "efficientnet_v2_m", "efficientnet_v2_s", + "inception_resnet_v2", "inception_v3", "mobilenet", "mobilenet_v2", "mobilenet_v3_large", + "mobilenet_v3_small", "nasnet_large", "nasnet_mobile", "resnet50", "resnet50_v2", "resnet101", + "resnet101_v2", "resnet152", "resnet152_v2", "vgg16", "vgg19", "xception", "fs_original"]) -if get_backend() != "amd": - _ENCODERS.extend(["efficientnet_b0", "efficientnet_b1", "efficientnet_b2", "efficientnet_b3", - "efficientnet_b4", "efficientnet_b5", "efficientnet_b6", "efficientnet_b7", - "efficientnet_v2_b0", "efficientnet_v2_b1", "efficientnet_v2_b2", - "efficientnet_v2_b3", "efficientnet_v2_l", "efficientnet_v2_m", - "efficientnet_v2_s", "mobilenet_v3_large", "mobilenet_v3_small", - "resnet50_v2", "resnet101", "resnet101_v2", "resnet152", "resnet152_v2"]) -_ENCODERS = sorted(_ENCODERS) - - -_DEFAULTS = dict( +_DEFAULTS = { # General - output_size=dict( - default=128, - info="Resolution (in pixels) of the output image to generate.\n" - "BE AWARE Larger resolution will dramatically increase VRAM requirements.", - datatype=int, - rounding=64, - min_max=(64, 2048), - group="general", - fixed=True), - shared_fc=dict( - default="none", - info="Whether to create a shared fully connected layer. This layer will have the same " - "structure as the fully connected layers used for each side of the model. A shared " - "fully connected layer looks for patterns that are common to both sides. NB: " - "Enabling this option only makes sense if 'split fc' is selected." - "\n\tnone - Do not create a Fully Connected layer for shared data. (Original method)" - "\n\tfull - Create an exclusive Fully Connected layer for shared data. (IAE method)" - "\n\thalf - Use the 'fc_a' layer for shared data. This saves VRAM by re-using the " - "'A' side's fully connected model for the shared data. However, this will lead to " - "an 'unbalanced' model and can lead to more identity bleed (DFL method)", - datatype=str, - choices=["none", "full", "half"], - gui_radio=True, - group="general", - fixed=True), - enable_gblock=dict( - default=True, - info="Whether to enable the G-Block. If enabled, this will create a shared fully " - "connected layer (configurable in the 'G-Block hidden layers' section) to look for " - "patterns in the combined data, before feeding a block prior to the decoder for " - "merging this shared and combined data." - "\n\tTrue - Use the G-Block in the Decoder. A combined fully connected layer will be " - "created to feed this block which can be configured below." - "\n\tFalse - Don't use the G-Block in the decoder. No combined fully connected layer " - "will be created.", - datatype=bool, - group="general", - fixed=True), - split_fc=dict( - default=True, - info="Whether to use a single shared Fully Connected layer or separate Fully Connected " - "layers for each side." - "\n\tTrue - Use separate Fully Connected layers for Face A and Face B. This is more " - "similar to the 'IAE' style of model." - "\n\tFalse - Use combined Fully Connected layers for both sides. This is more " - "similar to the original Faceswap architecture.", - datatype=bool, - group="general", - fixed=True), - split_gblock=dict( - default=False, - info="If the G-Block is enabled, Whether to use a single G-Block shared between both " - "sides, or whether to have a separate G-Block (one for each side). NB: The Fully " - "Connected layer that feeds the G-Block will always be shared." - "\n\tTrue - Use separate G-Blocks for Face A and Face B." - "\n\tFalse - Use a combined G-Block layers for both sides.", - datatype=bool, - group="general", - fixed=True), - split_decoders=dict( - default=False, - info="Whether to use a single decoder or split decoders." - "\n\tTrue - Use a separate decoder for Face A and Face B. This is more similar to " - "the original Faceswap architecture." - "\n\tFalse - Use a combined Decoder. This is more similar to 'IAE' style " - "architecture.", - datatype=bool, - group="general", - fixed=True), + "output_size": { + "default": 128, + "info": ( + "Resolution (in pixels) of the output image to generate.\n" + "BE AWARE Larger resolution will dramatically increase VRAM requirements."), + "datatype": int, + "rounding": 64, + "min_max": (64, 2048), + "group": "general", + "fixed": True}, + "shared_fc": { + "default": "none", + "info": ( + "Whether to create a shared fully connected layer. This layer will have the same " + "structure as the fully connected layers used for each side of the model. A shared " + "fully connected layer looks for patterns that are common to both sides. NB: " + "Enabling this option only makes sense if 'split fc' is selected." + "\n\tnone - Do not create a Fully Connected layer for shared data. (Original method)" + "\n\tfull - Create an exclusive Fully Connected layer for shared data. (IAE method)" + "\n\thalf - Use the 'fc_a' layer for shared data. This saves VRAM by re-using the " + "'A' side's fully connected model for the shared data. However, this will lead to " + "an 'unbalanced' model and can lead to more identity bleed (DFL method)"), + "datatype": str, + "choices": ["none", "full", "half"], + "gui_radio": True, + "group": "general", + "fixed": True}, + "enable_gblock": { + "default": True, + "info": ( + "Whether to enable the G-Block. If enabled, this will create a shared fully " + "connected layer (configurable in the 'G-Block hidden layers' section) to look for " + "patterns in the combined data, before feeding a block prior to the decoder for " + "merging this shared and combined data." + "\n\tTrue - Use the G-Block in the Decoder. A combined fully connected layer will be " + "created to feed this block which can be configured below." + "\n\tFalse - Don't use the G-Block in the decoder. No combined fully connected layer " + "will be created."), + "datatype": bool, + "group": "general", + "fixed": True}, + "split_fc": { + "default": True, + "info": ( + "Whether to use a single shared Fully Connected layer or separate Fully Connected " + "layers for each side." + "\n\tTrue - Use separate Fully Connected layers for Face A and Face B. This is more " + "similar to the 'IAE' style of model." + "\n\tFalse - Use combined Fully Connected layers for both sides. This is more " + "similar to the original Faceswap architecture."), + "datatype": bool, + "group": "general", + "fixed": True}, + "split_gblock": { + "default": False, + "info": ( + "If the G-Block is enabled, Whether to use a single G-Block shared between both " + "sides, or whether to have a separate G-Block (one for each side). NB: The Fully " + "Connected layer that feeds the G-Block will always be shared." + "\n\tTrue - Use separate G-Blocks for Face A and Face B." + "\n\tFalse - Use a combined G-Block layers for both sides."), + "datatype": bool, + "group": "general", + "fixed": True}, + "split_decoders": { + "default": False, + "info": ( + "Whether to use a single decoder or split decoders." + "\n\tTrue - Use a separate decoder for Face A and Face B. This is more similar to " + "the original Faceswap architecture." + "\n\tFalse - Use a combined Decoder. This is more similar to 'IAE' style " + "architecture."), + "datatype": bool, + "group": "general", + "fixed": True}, # Encoder - enc_architecture=dict( - default="fs_original", - info="The encoder architecture to use. See the relevant config sections for specific " - "architecture tweaking.\nNB: For keras based pre-built models, the global " - "initializers and padding options will be ignored for the selected encoder." - "\n\n\tdensenet: (32px -224px). Ref: Densely Connected Convolutional Networks " - "(2016): https://arxiv.org/abs/1608.06993?source=post_page" - "\n\n\tefficientnet: [Tensorflow 2.3+ only] EfficientNet has numerous variants (B0 - " - "B8) that increases the model width, depth and dimensional space at each step. The " - "minimum input resolution is 32px for all variants. The maximum input resolution for " - "each variant is: b0: 224px, b1: 240px, b2: 260px, b3: 300px, b4: 380px, b5: 456px, " - "b6: 528px, b7 600px. Ref: Rethinking Model Scaling for Convolutional Neural " - "Networks (2020): https://arxiv.org/abs/1905.11946" - "\n\n\tefficientnet_v2: [Tensorflow 2.8+ only] EfficientNetV2 is the follow up to " - "efficientnet. It has numerous variants (B0 - B3 and Small, Medium and Large) that " - "increases the model width, depth and dimensional space at each step. The minimum " - "input resolution is 32px for all variants. The maximum input resolution for each " - "variant is: b0: 224px, b1: 240px, b2: 260px, b3: 300px, s: 384px, m: 480px, l: " - "480px. Ref: EfficientNetV2: Smaller Models and Faster Training (2021): " - "https://arxiv.org/abs/2104.00298" - "\n\n\tfs_original: (32px - 1024px). A configurable variant of the original facewap " - "encoder. ImageNet weights cannot be loaded for this model. Additional parameters " - "can be configured with the 'fs_enc' options. A version of this encoder is used in " - "the following models: Original, Original (lowmem), Dfaker, DFL-H128, DFL-SAE, IAE, " - "Lightweight." - "\n\n\tinception_resnet_v2: (75px - 299px). Ref: Inception-ResNet and the Impact of " - "Residual Connections on Learning (2016): https://arxiv.org/abs/1602.07261" - "\n\n\tinceptionV3: (75px - 299px). Ref: Rethinking the Inception Architecture for " - "Computer Vision (2015): https://arxiv.org/abs/1512.00567" - "\n\n\tmobilenet: (32px - 224px). Additional MobileNet parameters can be set with " - "the 'mobilenet' options. Ref: MobileNets: Efficient Convolutional Neural Networks " - "for Mobile Vision Applications (2017): https://arxiv.org/abs/1704.04861" - "\n\n\tmobilenet_v2: (32px - 224px). Additional MobileNet parameters can be set with " - "the 'mobilenet' options. Ref: MobileNetV2: Inverted Residuals and Linear " - "Bottlenecks (2018): https://arxiv.org/abs/1801.04381" - "\n\n\tmobilenet_v3: (32px - 224px). Additional MobileNet parameters can be set with " - "the 'mobilenet' options. Ref: Searching for MobileNetV3 (2019): " - "https://arxiv.org/pdf/1905.02244.pdf" - "\n\n\tnasnet: (32px - 331px (large) or 224px (mobile)). Ref: Learning Transferable " - "Architectures for Scalable Image Recognition (2017): " - "https://arxiv.org/abs/1707.07012" - "\n\n\tresnet: (32px - 224px). Deep Residual Learning for Image Recognition (2015): " - "https://arxiv.org/abs/1512.03385" - "\n\n\tvgg: (32px - 224px). Very Deep Convolutional Networks for Large-Scale Image " - "Recognition (2014): https://arxiv.org/abs/1409.1556" - "\n\n\txception: (71px - 229px). Ref: Deep Learning with Depthwise Separable " - "Convolutions (2017): https://arxiv.org/abs/1409.1556.\n", - datatype=str, - choices=_ENCODERS, - gui_radio=False, - group="encoder", - fixed=True), - enc_scaling=dict( - default=7, - info="Input scaling for the encoder. Some of the encoders have large input sizes, which " - "often are not helpful for Faceswap. This setting scales the dimensional space that " - "the encoder works in. For example an encoder with a maximum input size of 224px " - "will be input an image of 112px at 50%% scaling. See the Architecture tooltip for " - "the minimum and maximum sizes for each encoder. NB: The input size will be rounded " - "down to the nearest 16 pixels.", - datatype=int, - min_max=(0, 100), - rounding=1, - group="encoder", - fixed=True), - enc_load_weights=dict( - default=True, - info="Load pre-trained weights trained on ImageNet data. Only available for non-Faceswap " - "encoders (i.e. those not beginning with 'fs'). NB: If you use the global 'load " - "weights' option and have selected to load weights from a previous model's 'encoder' " - "or 'keras_encoder' then the weights loaded here will be replaced by the weights " - "loaded from your saved model.", - datatype=bool, - group="encoder", - fixed=True), + "enc_architecture": { + "default": "fs_original", + "info": ( + "The encoder architecture to use. See the relevant config sections for specific " + "architecture tweaking.\nNB: For keras based pre-built models, the global " + "initializers and padding options will be ignored for the selected encoder." + "\n\n\tdensenet: (32px -224px). Ref: Densely Connected Convolutional Networks " + "(2016): https://arxiv.org/abs/1608.06993?source=post_page" + "\n\n\tefficientnet: [Tensorflow 2.3+ only] EfficientNet has numerous variants (B0 - " + "B8) that increases the model width, depth and dimensional space at each step. The " + "minimum input resolution is 32px for all variants. The maximum input resolution for " + "each variant is: b0: 224px, b1: 240px, b2: 260px, b3: 300px, b4: 380px, b5: 456px, " + "b6: 528px, b7 600px. Ref: Rethinking Model Scaling for Convolutional Neural " + "Networks (2020): https://arxiv.org/abs/1905.11946" + "\n\n\tefficientnet_v2: [Tensorflow 2.8+ only] EfficientNetV2 is the follow up to " + "efficientnet. It has numerous variants (B0 - B3 and Small, Medium and Large) that " + "increases the model width, depth and dimensional space at each step. The minimum " + "input resolution is 32px for all variants. The maximum input resolution for each " + "variant is: b0: 224px, b1: 240px, b2: 260px, b3: 300px, s: 384px, m: 480px, l: " + "480px. Ref: EfficientNetV2: Smaller Models and Faster Training (2021): " + "https://arxiv.org/abs/2104.00298" + "\n\n\tfs_original: (32px - 1024px). A configurable variant of the original facewap " + "encoder. ImageNet weights cannot be loaded for this model. Additional parameters " + "can be configured with the 'fs_enc' options. A version of this encoder is used in " + "the following models: Original, Original (lowmem), Dfaker, DFL-H128, DFL-SAE, IAE, " + "Lightweight." + "\n\n\tinception_resnet_v2: (75px - 299px). Ref: Inception-ResNet and the Impact of " + "Residual Connections on Learning (2016): https://arxiv.org/abs/1602.07261" + "\n\n\tinceptionV3: (75px - 299px). Ref: Rethinking the Inception Architecture for " + "Computer Vision (2015): https://arxiv.org/abs/1512.00567" + "\n\n\tmobilenet: (32px - 224px). Additional MobileNet parameters can be set with " + "the 'mobilenet' options. Ref: MobileNets: Efficient Convolutional Neural Networks " + "for Mobile Vision Applications (2017): https://arxiv.org/abs/1704.04861" + "\n\n\tmobilenet_v2: (32px - 224px). Additional MobileNet parameters can be set with " + "the 'mobilenet' options. Ref: MobileNetV2: Inverted Residuals and Linear " + "Bottlenecks (2018): https://arxiv.org/abs/1801.04381" + "\n\n\tmobilenet_v3: (32px - 224px). Additional MobileNet parameters can be set with " + "the 'mobilenet' options. Ref: Searching for MobileNetV3 (2019): " + "https://arxiv.org/pdf/1905.02244.pdf" + "\n\n\tnasnet: (32px - 331px (large) or 224px (mobile)). Ref: Learning Transferable " + "Architectures for Scalable Image Recognition (2017): " + "https://arxiv.org/abs/1707.07012" + "\n\n\tresnet: (32px - 224px). Deep Residual Learning for Image Recognition (2015): " + "https://arxiv.org/abs/1512.03385" + "\n\n\tvgg: (32px - 224px). Very Deep Convolutional Networks for Large-Scale Image " + "Recognition (2014): https://arxiv.org/abs/1409.1556" + "\n\n\txception: (71px - 229px). Ref: Deep Learning with Depthwise Separable " + "Convolutions (2017): https://arxiv.org/abs/1409.1556.\n"), + "datatype": str, + "choices": _ENCODERS, + "gui_radio": False, + "group": "encoder", + "fixed": True}, + "enc_scaling": { + "default": 7, + "info": ( + "Input scaling for the encoder. Some of the encoders have large input sizes, which " + "often are not helpful for Faceswap. This setting scales the dimensional space that " + "the encoder works in. For example an encoder with a maximum input size of 224px " + "will be input an image of 112px at 50%% scaling. See the Architecture tooltip for " + "the minimum and maximum sizes for each encoder. NB: The input size will be rounded " + "down to the nearest 16 pixels."), + "datatype": int, + "min_max": (0, 100), + "rounding": 1, + "group": "encoder", + "fixed": True}, + "enc_load_weights": { + "default": True, + "info": ( + "Load pre-trained weights trained on ImageNet data. Only available for non-" + "Faceswap encoders (i.e. those not beginning with 'fs'). NB: If you use the global " + "'load weights' option and have selected to load weights from a previous model's " + "'encoder' or 'keras_encoder' then the weights loaded here will be replaced by the " + "weights loaded from your saved model."), + "datatype": bool, + "group": "encoder", + "fixed": True}, # Bottleneck - bottleneck_type=dict( - default="dense", - info="The type of layer to use for the bottleneck." - "\n\taverage_pooling: Use a Global Average Pooling 2D layer for the bottleneck." - "\n\tdense: Use a Dense layer for the bottleneck (the traditional Faceswap method). " - "You can set the size of the Dense layer with the 'bottleneck_size' parameter." - "\n\tmax_pooling: Use a Global Max Pooling 2D layer for the bottleneck.", - datatype=str, - group="bottleneck", - gui_radio=True, - choices=["average_pooling", "dense", "max_pooling"], - fixed=True), - bottleneck_norm=dict( - default="none", - info="Apply a normalization layer after encoder output and prior to the bottleneck." - "\n\tnone - Do not apply a normalization layer" - "\n\tinstance - Apply Instance Normalization" - "\n\tlayer - Apply Layer Normalization (Ba et al., 2016)" - "\n\trms - Apply Root Mean Squared Layer Normalization (Zhang et al., 2019). A " - "simplified version of Layer Normalization with reduced overhead.", - datatype=str, - gui_radio=True, - choices=["none", "instance", "layer", "rms"], - group="bottleneck", - fixed=True), - bottleneck_size=dict( - default=1024, - info="If using a Dense layer for the bottleneck, then this is the number of nodes to use.", - datatype=int, - rounding=128, - min_max=(128, 4096), - group="bottleneck", - fixed=True), - bottleneck_in_encoder=dict( - default=True, - info="Whether to place the bottleneck in the Encoder or to place it with the other hidden " - "layers. Placing the bottleneck in the encoder means that both sides will share the " - "same bottleneck. Placing it with the other fully connected layers means that each " - "fully connected layer will each get their own bottleneck. This may be combined or " - "split depending on your overall architecture configuration settings.", - datatype=bool, - group="bottleneck", - fixed=True), + "bottleneck_type": { + "default": "dense", + "info": ( + "The type of layer to use for the bottleneck." + "\n\taverage_pooling: Use a Global Average Pooling 2D layer for the bottleneck." + "\n\tdense: Use a Dense layer for the bottleneck (the traditional Faceswap method). " + "You can set the size of the Dense layer with the 'bottleneck_size' parameter." + "\n\tmax_pooling: Use a Global Max Pooling 2D layer for the bottleneck."), + "datatype": str, + "group": "bottleneck", + "gui_radio": True, + "choices": ["average_pooling", "dense", "max_pooling"], + "fixed": True}, + "bottleneck_norm": { + "default": "none", + "info": ( + "Apply a normalization layer after encoder output and prior to the bottleneck." + "\n\tnone - Do not apply a normalization layer" + "\n\tinstance - Apply Instance Normalization" + "\n\tlayer - Apply Layer Normalization (Ba et al., 2016)" + "\n\trms - Apply Root Mean Squared Layer Normalization (Zhang et al., 2019). A " + "simplified version of Layer Normalization with reduced overhead."), + "datatype": str, + "gui_radio": True, + "choices": ["none", "instance", "layer", "rms"], + "group": "bottleneck", + "fixed": True}, + "bottleneck_size": { + "default": 1024, + "info": ( + "If using a Dense layer for the bottleneck, then this is the number of nodes to " + "use."), + "datatype": int, + "rounding": 128, + "min_max": (128, 4096), + "group": "bottleneck", + "fixed": True}, + "bottleneck_in_encoder": { + "default": True, + "info": ( + "Whether to place the bottleneck in the Encoder or to place it with the other " + "hidden layers. Placing the bottleneck in the encoder means that both sides will " + "share the same bottleneck. Placing it with the other fully connected layers means " + "that each fully connected layer will each get their own bottleneck. This may be " + "combined or split depending on your overall architecture configuration settings."), + "datatype": bool, + "group": "bottleneck", + "fixed": True}, # Intermediate Layers - fc_depth=dict( - default=1, - info="The number of consecutive Dense (fully connected) layers to include in each side's " - "intermediate layer.", - datatype=int, - rounding=1, - min_max=(0, 16), - group="hidden layers", - fixed=True), - fc_min_filters=dict( - default=1024, - info="The number of filters to use for the initial fully connected layer. The number of " - "nodes actually used is: fc_min_filters x fc_dimensions x fc_dimensions.\nNB: This " - "value may be scaled down, depending on output resolution.", - datatype=int, - rounding=16, - min_max=(16, 5120), - group="hidden layers", - fixed=True), - fc_max_filters=dict( - default=1024, - info="This is the number of filters to be used in the final reshape layer at the end of " - "the fully connected layers. The actual number of nodes used for the final fully " - "connected layer is: fc_min_filters x fc_dimensions x fc_dimensions.\nNB: This value " - "may be scaled down, depending on output resolution.", - datatype=int, - rounding=64, - min_max=(128, 5120), - group="hidden layers", - fixed=True), - fc_dimensions=dict( - default=4, - info="The height and width dimension for the final reshape layer at the end of the fully " - "connected layers.\nNB: The total number of nodes within the final fully connected " - "layer will be: fc_dimensions x fc_dimensions x fc_max_filters.", - datatype=int, - rounding=1, - min_max=(1, 16), - group="hidden layers", - fixed=True), - fc_filter_slope=dict( - default=-0.5, - info="The rate that the filters move from the minimum number of filters to the maximum " - "number of filters. EG:\n" - "Negative numbers will change the number of filters quicker at first and slow down " - "each layer.\n" - "Positive numbers will change the number of filters slower at first but then speed " - "up each layer.\n" - "0.0 - This will change at a linear rate (i.e. the same number of filters will be " - "changed at each layer).", - datatype=float, - min_max=(-.99, .99), - rounding=2, - group="hidden layers", - fixed=True), - fc_dropout=dict( - default=0.0, - info="Dropout is a form of regularization that can prevent a model from over-fitting and " - "help to keep neurons 'alive'. 0.5 will dropout half the connections between each " - "fully connected layer, 0.25 will dropout a quarter of the connections etc. Set to " - "0.0 to disable.", - datatype=float, - rounding=2, - min_max=(0.0, 0.99), - group="hidden layers", - fixed=False), - fc_upsampler=dict( - default="upsample2d", - info="The type of dimensional upsampling to perform at the end of the fully connected " - "layers, if upsamples > 0. The number of filters used for the upscale layers will be " - "the value given in 'fc_upsample_filters'." - "\n\tupsample2d - A lightweight and VRAM friendly method. 'quick and dirty' but does " - "not learn any parameters" - "\n\tsubpixel - Sub-pixel upscaler using depth-to-space which may require more " - "VRAM." - "\n\tresize_images - Uses the Keras resize_image function to save about half as much " - "vram as the heaviest methods." - "\n\tupscale_fast - Developed by Andenixa. Focusses on speed to upscale, but " - "requires more VRAM." - "\n\tupscale_hybrid - Developed by Andenixa. Uses a combination of PixelShuffler and " - "Upsampling2D to upscale, saving about 1/3rd of VRAM of the heaviest methods.", - datatype=str, - choices=["resize_images", "subpixel", "upscale_fast", "upscale_hybrid", "upsample2d"], - group="hidden layers", - gui_radio=False, - fixed=True), - fc_upsamples=dict( - default=1, - info="Some upsampling can occur within the Fully Connected layers rather than in the " - "Decoder to increase the dimensional space. Set how many upscale layers should occur " - "within the Fully Connected layers.", - datatype=int, - min_max=(0, 4), - rounding=1, - group="hidden layers", - fixed=True), - fc_upsample_filters=dict( - default=512, - info="If you have selected an upsampler which requires filters (i.e. any upsampler with " - "the exception of Upsampling2D), then this is the number of filters to be used for " - "the upsamplers within the fully connected layers, NB: This value may be scaled " - "down, depending on output resolution. Also note, that this figure will dictate the " - "number of filters used for the G-Block, if selected.", - datatype=int, - rounding=64, - min_max=(128, 5120), - group="hidden layers", - fixed=True), + "fc_depth": { + "default": 1, + "info": ( + "The number of consecutive Dense (fully connected) layers to include in each " + "side's intermediate layer."), + "datatype": int, + "rounding": 1, + "min_max": (0, 16), + "group": "hidden layers", + "fixed": True}, + "fc_min_filters": { + "default": 1024, + "info": ( + "The number of filters to use for the initial fully connected layer. The number of " + "nodes actually used is: fc_min_filters x fc_dimensions x fc_dimensions.\nNB: This " + "value may be scaled down, depending on output resolution."), + "datatype": int, + "rounding": 16, + "min_max": (16, 5120), + "group": "hidden layers", + "fixed": True}, + "fc_max_filters": { + "default": 1024, + "info": ( + "This is the number of filters to be used in the final reshape layer at the end of " + "the fully connected layers. The actual number of nodes used for the final fully " + "connected layer is: fc_min_filters x fc_dimensions x fc_dimensions.\nNB: This value " + "may be scaled down, depending on output resolution."), + "datatype": int, + "rounding": 64, + "min_max": (128, 5120), + "group": "hidden layers", + "fixed": True}, + "fc_dimensions": { + "default": 4, + "info": ( + "The height and width dimension for the final reshape layer at the end of the " + "fully connected layers.\nNB: The total number of nodes within the final fully " + "connected layer will be: fc_dimensions x fc_dimensions x fc_max_filters."), + "datatype": int, + "rounding": 1, + "min_max": (1, 16), + "group": "hidden layers", + "fixed": True}, + "fc_filter_slope": { + "default": -0.5, + "info": ( + "The rate that the filters move from the minimum number of filters to the maximum " + "number of filters. EG:\n" + "Negative numbers will change the number of filters quicker at first and slow down " + "each layer.\n" + "Positive numbers will change the number of filters slower at first but then speed " + "up each layer.\n" + "0.0 - This will change at a linear rate (i.e. the same number of filters will be " + "changed at each layer)."), + "datatype": float, + "min_max": (-.99, .99), + "rounding": 2, + "group": "hidden layers", + "fixed": True}, + "fc_dropout": { + "default": 0.0, + "info": ( + "Dropout is a form of regularization that can prevent a model from over-fitting " + "and help to keep neurons 'alive'. 0.5 will dropout half the connections between each " + "fully connected layer, 0.25 will dropout a quarter of the connections etc. Set to " + "0.0 to disable."), + "datatype": float, + "rounding": 2, + "min_max": (0.0, 0.99), + "group": "hidden layers", + "fixed": False}, + "fc_upsampler": { + "default": "upsample2d", + "info": ( + "The type of dimensional upsampling to perform at the end of the fully connected " + "layers, if upsamples > 0. The number of filters used for the upscale layers will be " + "the value given in 'fc_upsample_filters'." + "\n\tupsample2d - A lightweight and VRAM friendly method. 'quick and dirty' but does " + "not learn any parameters" + "\n\tsubpixel - Sub-pixel upscaler using depth-to-space which may require more " + "VRAM." + "\n\tresize_images - Uses the Keras resize_image function to save about half as much " + "vram as the heaviest methods." + "\n\tupscale_fast - Developed by Andenixa. Focusses on speed to upscale, but " + "requires more VRAM." + "\n\tupscale_hybrid - Developed by Andenixa. Uses a combination of PixelShuffler and " + "Upsampling2D to upscale, saving about 1/3rd of VRAM of the heaviest methods."), + "datatype": str, + "choices": ["resize_images", "subpixel", "upscale_fast", "upscale_hybrid", "upsample2d"], + "group": "hidden layers", + "gui_radio": False, + "fixed": True}, + "fc_upsamples": { + "default": 1, + "info": ( + "Some upsampling can occur within the Fully Connected layers rather than in the " + "Decoder to increase the dimensional space. Set how many upscale layers should occur " + "within the Fully Connected layers."), + "datatype": int, + "min_max": (0, 4), + "rounding": 1, + "group": "hidden layers", + "fixed": True}, + "fc_upsample_filters": { + "default": 512, + "info": ( + "If you have selected an upsampler which requires filters (i.e. any upsampler with " + "the exception of Upsampling2D), then this is the number of filters to be used for " + "the upsamplers within the fully connected layers, NB: This value may be scaled " + "down, depending on output resolution. Also note, that this figure will dictate the " + "number of filters used for the G-Block, if selected."), + "datatype": int, + "rounding": 64, + "min_max": (128, 5120), + "group": "hidden layers", + "fixed": True}, # G-Block - fc_gblock_depth=dict( - default=3, - info="The number of consecutive Dense (fully connected) layers to include in the G-Block " - "shared layer.", - datatype=int, - rounding=1, - min_max=(1, 16), - group="g-block hidden layers", - fixed=True), - fc_gblock_min_nodes=dict( - default=512, - info="The number of nodes to use for the initial G-Block shared fully connected layer.", - datatype=int, - rounding=64, - min_max=(128, 5120), - group="g-block hidden layers", - fixed=True), - fc_gblock_max_nodes=dict( - default=512, - info="The number of nodes to use for the final G-Block shared fully connected layer.", - datatype=int, - rounding=64, - min_max=(128, 5120), - group="g-block hidden layers", - fixed=True), - fc_gblock_filter_slope=dict( - default=-0.5, - info="The rate that the filters move from the minimum number of filters to the maximum " - "number of filters for the G-Block shared layers. EG:\n" - "Negative numbers will change the number of filters quicker at first and slow down " - "each layer.\n" - "Positive numbers will change the number of filters slower at first but then speed " - "up each layer.\n" - "0.0 - This will change at a linear rate (i.e. the same number of filters will be " - "changed at each layer).", - datatype=float, - min_max=(-.99, .99), - rounding=2, - group="g-block hidden layers", - fixed=True), - fc_gblock_dropout=dict( - default=0.0, - info="Dropout is a regularization technique that can prevent a model from over-fitting " - "and help to keep neurons 'alive'. 0.5 will dropout half the connections between " - "each fully connected layer, 0.25 will dropout a quarter of the connections etc. Set " - "to 0.0 to disable.", - datatype=float, - rounding=2, - min_max=(0.0, 0.99), - group="g-block hidden layers", - fixed=False), + "fc_gblock_depth": { + "default": 3, + "info": ( + "The number of consecutive Dense (fully connected) layers to include in the " + "G-Block shared layer."), + "datatype": int, + "rounding": 1, + "min_max": (1, 16), + "group": "g-block hidden layers", + "fixed": True}, + "fc_gblock_min_nodes": { + "default": 512, + "info": "The number of nodes to use for the initial G-Block shared fully connected layer.", + "datatype": int, + "rounding": 64, + "min_max": (128, 5120), + "group": "g-block hidden layers", + "fixed": True}, + "fc_gblock_max_nodes": { + "default": 512, + "info": "The number of nodes to use for the final G-Block shared fully connected layer.", + "datatype": int, + "rounding": 64, + "min_max": (128, 5120), + "group": "g-block hidden layers", + "fixed": True}, + "fc_gblock_filter_slope": { + "default": -0.5, + "info": ( + "The rate that the filters move from the minimum number of filters to the maximum " + "number of filters for the G-Block shared layers. EG:\n" + "Negative numbers will change the number of filters quicker at first and slow down " + "each layer.\n" + "Positive numbers will change the number of filters slower at first but then speed " + "up each layer.\n" + "0.0 - This will change at a linear rate (i.e. the same number of filters will be " + "changed at each layer)."), + "datatype": float, + "min_max": (-.99, .99), + "rounding": 2, + "group": "g-block hidden layers", + "fixed": True}, + "fc_gblock_dropout": { + "default": 0.0, + "info": ( + "Dropout is a regularization technique that can prevent a model from over-fitting " + "and help to keep neurons 'alive'. 0.5 will dropout half the connections between " + "each fully connected layer, 0.25 will dropout a quarter of the connections etc. Set " + "to 0.0 to disable."), + "datatype": float, + "rounding": 2, + "min_max": (0.0, 0.99), + "group": "g-block hidden layers", + "fixed": False}, # Decoder - dec_upscale_method=dict( - default="subpixel", - info="The method to use for the upscales within the decoder. Images are upscaled multiple " - "times within the decoder as the network learns to reconstruct the face." - "\n\tsubpixel - Sub-pixel upscaler using depth-to-space which requires more " - "VRAM." - "\n\tresize_images - Uses the Keras resize_image function to save about half as much " - "vram as the heaviest methods." - "\n\tupscale_fast - Developed by Andenixa. Focusses on speed to upscale, but " - "requires more VRAM." - "\n\tupscale_hybrid - Developed by Andenixa. Uses a combination of PixelShuffler and " - "Upsampling2D to upscale, saving about 1/3rd of VRAM of the heaviest methods." - "\n\tupscale_dny - An alternative upscale implementation using Upsampling2D to " - "upsale.", - datatype=str, - choices=["subpixel", "resize_images", "upscale_fast", "upscale_hybrid", "upscale_dny"], - gui_radio=True, - group="decoder", - fixed=True), - dec_upscales_in_fc=dict( - default=0, - min_max=(0, 6), - rounding=1, - info="It is possible to place some of the upscales at the end of the fully connected " - "model. For models with split decoders, but a shared fully connected layer, this would " - "have the effect of saving some VRAM but possibly at the cost of introducing artefacts. " - "For models with a shared decoder but split fully connected layers, this would have the " - "effect of increasing VRAM usage by processing some of the upscales for each side rather " - "than together.", - datatype=int, - group="decoder", - fixed=True), - dec_norm=dict( - default="none", - info="Normalization to apply to apply after each upscale." - "\n\tnone - Do not apply a normalization layer" - "\n\tbatch - Apply Batch Normalization" - "\n\tgroup - Apply Group Normalization" - "\n\tinstance - Apply Instance Normalization" - "\n\tlayer - Apply Layer Normalization (Ba et al., 2016)" - "\n\trms - Apply Root Mean Squared Layer Normalization (Zhang et al., 2019). A " - "simplified version of Layer Normalization with reduced overhead.", - datatype=str, - gui_radio=True, - choices=["none", "batch", "group", "instance", "layer", "rms"], - group="decoder", - fixed=True), - dec_min_filters=dict( - default=64, - info="The minimum number of filters to use in decoder upscalers (i.e. the number of " - "filters to use for the final upscale layer).", - datatype=int, - min_max=(16, 512), - rounding=16, - group="decoder", - fixed=True), - dec_max_filters=dict( - default=512, - info="The maximum number of filters to use in decoder upscalers (i.e. the number of " - "filters to use for the first upscale layer).", - datatype=int, - min_max=(256, 5120), - rounding=64, - group="decoder", - fixed=True), - dec_slope_mode=dict( - default="full", - info="Alters the action of the filter slope.\n" - "\n\tfull: The number of filters at each upscale layer will reduce from the chosen " - "max_filters at the first layer to the chosen min_filters at the last layer as " - "dictated by the dec_filter_slope." - "\n\tcap_max: The filters will decline at a fixed rate from each upscale to the next " - "based on the filter_slope setting. If there are more upscales than filters, " - "then the earliest upscales will be capped at the max_filter value until the filters " - "can reduce to the min_filters value at the final upscale. (EG: 512 -> 512 -> 512 -> " - "256 -> 128 -> 64)." - "\n\tcap_min: The filters will decline at a fixed rate from each upscale to the next " - "based on the filter_slope setting. If there are more upscales than filters, then " - "the earliest upscales will drop their filters until the min_filter value is met and " - "repeat the min_filter value for the remaining upscales. (EG: 512 -> 256 -> 128 -> " - "64 -> 64 -> 64).", - choices=["full", "cap_max", "cap_min"], - group="decoder", - fixed=True, - gui_radio=True), - dec_filter_slope=dict( - default=-0.45, - info="The rate that the filters reduce at each upscale layer.\n" - "\n\tFull Slope Mode: Negative numbers will drop the number of filters quicker at " - "first and slow down each upscale. Positive numbers will drop the number of filters " - "slower at first but then speed up each upscale. A value of 0.0 will reduce at a " - "linear rate (i.e. the same number of filters will be reduced at each upscale).\n" - "\n\tCap Min/Max Slope Mode: Only positive values will work here. Negative values " - "will automatically be converted to their positive counterpart. A value of 0.5 will " - "halve the number of filters at each upscale until the minimum value is reached. A " - "value of 0.33 will be reduce the number of filters by a third until the minimum " - "value is reached etc.", - datatype=float, - min_max=(-.99, .99), - rounding=2, - group="decoder", - fixed=True), - dec_res_blocks=dict( - default=1, - info="The number of Residual Blocks to apply to each upscale layer. Set to 0 to disable " - "residual blocks entirely.", - datatype=int, - rounding=1, - min_max=(0, 8), - group="decoder", - fixed=True), - dec_output_kernel=dict( - default=5, - info="The kernel size to apply to the final Convolution layer.", - datatype=int, - rounding=2, - min_max=(1, 9), - group="decoder", - fixed=True), - dec_gaussian=dict( - default=True, - info="Gaussian Noise acts as a regularization technique for preventing overfitting of " - "data." - "\n\tTrue - Apply a Gaussian Noise layer to each upscale." - "\n\tFalse - Don't apply a Gaussian Noise layer to each upscale.", - datatype=bool, - group="decoder", - fixed=True), - dec_skip_last_residual=dict( - default=True, - info="If Residual blocks have been enabled, enabling this option will not apply a " - "Residual block to the final upscaler." - "\n\tTrue - Don't apply a Residual block to the final upscale." - "\n\tFalse - Apply a Residual block to all upscale layers.", - datatype=bool, - group="decoder", - fixed=True), + "dec_upscale_method": { + "default": "subpixel", + "info": ( + "The method to use for the upscales within the decoder. Images are upscaled " + "multiple times within the decoder as the network learns to reconstruct the face." + "\n\tsubpixel - Sub-pixel upscaler using depth-to-space which requires more " + "VRAM." + "\n\tresize_images - Uses the Keras resize_image function to save about half as much " + "vram as the heaviest methods." + "\n\tupscale_fast - Developed by Andenixa. Focusses on speed to upscale, but " + "requires more VRAM." + "\n\tupscale_hybrid - Developed by Andenixa. Uses a combination of PixelShuffler and " + "Upsampling2D to upscale, saving about 1/3rd of VRAM of the heaviest methods." + "\n\tupscale_dny - An alternative upscale implementation using Upsampling2D to " + "upsale."), + "datatype": str, + "choices": ["subpixel", "resize_images", "upscale_fast", "upscale_hybrid", "upscale_dny"], + "gui_radio": True, + "group": "decoder", + "fixed": True}, + "dec_upscales_in_fc": { + "default": 0, + "min_max": (0, 6), + "rounding": 1, + "info": ( + "It is possible to place some of the upscales at the end of the fully connected " + "model. For models with split decoders, but a shared fully connected layer, this " + "would have the effect of saving some VRAM but possibly at the cost of introducing " + "artefacts. For models with a shared decoder but split fully connected layers, this " + "would have the effect of increasing VRAM usage by processing some of the upscales " + "for each side rather than together."), + "datatype": int, + "group": "decoder", + "fixed": True}, + "dec_norm": { + "default": "none", + "info": ( + "Normalization to apply to apply after each upscale." + "\n\tnone - Do not apply a normalization layer" + "\n\tbatch - Apply Batch Normalization" + "\n\tgroup - Apply Group Normalization" + "\n\tinstance - Apply Instance Normalization" + "\n\tlayer - Apply Layer Normalization (Ba et al., 2016)" + "\n\trms - Apply Root Mean Squared Layer Normalization (Zhang et al., 2019). A " + "simplified version of Layer Normalization with reduced overhead."), + "datatype": str, + "gui_radio": True, + "choices": ["none", "batch", "group", "instance", "layer", "rms"], + "group": "decoder", + "fixed": True}, + "dec_min_filters": { + "default": 64, + "info": ( + "The minimum number of filters to use in decoder upscalers (i.e. the number of " + "filters to use for the final upscale layer)."), + "datatype": int, + "min_max": (16, 512), + "rounding": 16, + "group": "decoder", + "fixed": True}, + "dec_max_filters": { + "default": 512, + "info": ( + "The maximum number of filters to use in decoder upscalers (i.e. the number of " + "filters to use for the first upscale layer)."), + "datatype": int, + "min_max": (256, 5120), + "rounding": 64, + "group": "decoder", + "fixed": True}, + "dec_slope_mode": { + "default": "full", + "info": ( + "Alters the action of the filter slope.\n" + "\n\tfull: The number of filters at each upscale layer will reduce from the chosen " + "max_filters at the first layer to the chosen min_filters at the last layer as " + "dictated by the dec_filter_slope." + "\n\tcap_max: The filters will decline at a fixed rate from each upscale to the next " + "based on the filter_slope setting. If there are more upscales than filters, " + "then the earliest upscales will be capped at the max_filter value until the filters " + "can reduce to the min_filters value at the final upscale. (EG: 512 -> 512 -> 512 -> " + "256 -> 128 -> 64)." + "\n\tcap_min: The filters will decline at a fixed rate from each upscale to the next " + "based on the filter_slope setting. If there are more upscales than filters, then " + "the earliest upscales will drop their filters until the min_filter value is met and " + "repeat the min_filter value for the remaining upscales. (EG: 512 -> 256 -> 128 -> " + "64 -> 64 -> 64)."), + "choices": ["full", "cap_max", "cap_min"], + "group": "decoder", + "fixed": True, + "gui_radio": True}, + "dec_filter_slope": { + "default": -0.45, + "info": ( + "The rate that the filters reduce at each upscale layer.\n" + "\n\tFull Slope Mode: Negative numbers will drop the number of filters quicker at " + "first and slow down each upscale. Positive numbers will drop the number of filters " + "slower at first but then speed up each upscale. A value of 0.0 will reduce at a " + "linear rate (i.e. the same number of filters will be reduced at each upscale).\n" + "\n\tCap Min/Max Slope Mode: Only positive values will work here. Negative values " + "will automatically be converted to their positive counterpart. A value of 0.5 will " + "halve the number of filters at each upscale until the minimum value is reached. A " + "value of 0.33 will be reduce the number of filters by a third until the minimum " + "value is reached etc."), + "datatype": float, + "min_max": (-.99, .99), + "rounding": 2, + "group": "decoder", + "fixed": True}, + "dec_res_blocks": { + "default": 1, + "info": ( + "The number of Residual Blocks to apply to each upscale layer. Set to 0 to disable " + "residual blocks entirely."), + "datatype": int, + "rounding": 1, + "min_max": (0, 8), + "group": "decoder", + "fixed": True}, + "dec_output_kernel": { + "default": 5, + "info": "The kernel size to apply to the final Convolution layer.", + "datatype": int, + "rounding": 2, + "min_max": (1, 9), + "group": "decoder", + "fixed": True}, + "dec_gaussian": { + "default": True, + "info": ( + "Gaussian Noise acts as a regularization technique for preventing overfitting of " + "data." + "\n\tTrue - Apply a Gaussian Noise layer to each upscale." + "\n\tFalse - Don't apply a Gaussian Noise layer to each upscale."), + "datatype": bool, + "group": "decoder", + "fixed": True}, + "dec_skip_last_residual": { + "default": True, + "info": ( + "If Residual blocks have been enabled, enabling this option will not apply a " + "Residual block to the final upscaler." + "\n\tTrue - Don't apply a Residual block to the final upscale." + "\n\tFalse - Apply a Residual block to all upscale layers."), + "datatype": bool, + "group": "decoder", + "fixed": True}, # Weight management - freeze_layers=dict( - default="keras_encoder", - info="If the command line option 'freeze-weights' is enabled, then the layers indicated " - "here will be frozen the next time the model starts up. NB: Not all architectures " - "contain all of the layers listed here, so any layers marked for freezing that are " - "not within your chosen architecture will be ignored. EG:\n If 'split fc' has " - "been selected, then 'fc_a' and 'fc_b' are available for freezing. If it has " - "not been selected then 'fc_both' is available for freezing.", - datatype=list, - choices=["encoder", "keras_encoder", "fc_a", "fc_b", "fc_both", "fc_shared", "fc_gblock", - "g_block_a", "g_block_b", "g_block_both", "decoder_a", "decoder_b", - "decoder_both"], - group="weights", - fixed=False), - load_layers=dict( - default="encoder", - info="If the command line option 'load-weights' is populated, then the layers indicated " - "here will be loaded from the given weights file if starting a new model. NB Not all " - "architectures contain all of the layers listed here, so any layers marked for " - "loading that are not within your chosen architecture will be ignored. EG:\n If " - "'split fc' has been selected, then 'fc_a' and 'fc_b' are available for loading. If " - "it has not been selected then 'fc_both' is available for loading.", - datatype=list, - choices=["encoder", "fc_a", "fc_b", "fc_both", "fc_shared", "fc_gblock", "g_block_a", - "g_block_b", "g_block_both", "decoder_a", "decoder_b", "decoder_both"], - group="weights", - fixed=True), + "freeze_layers": { + "default": "keras_encoder", + "info": ( + "If the command line option 'freeze-weights' is enabled, then the layers indicated " + "here will be frozen the next time the model starts up. NB: Not all architectures " + "contain all of the layers listed here, so any layers marked for freezing that are " + "not within your chosen architecture will be ignored. EG:\n If 'split fc' has " + "been selected, then 'fc_a' and 'fc_b' are available for freezing. If it has " + "not been selected then 'fc_both' is available for freezing."), + "datatype": list, + "choices": ["encoder", "keras_encoder", "fc_a", "fc_b", "fc_both", "fc_shared", + "fc_gblock", "g_block_a", "g_block_b", "g_block_both", "decoder_a", + "decoder_b", "decoder_both"], + "group": "weights", + "fixed": False}, + "load_layers": { + "default": "encoder", + "info": ( + "If the command line option 'load-weights' is populated, then the layers indicated " + "here will be loaded from the given weights file if starting a new model. NB Not all " + "architectures contain all of the layers listed here, so any layers marked for " + "loading that are not within your chosen architecture will be ignored. EG:\n If " + "'split fc' has been selected, then 'fc_a' and 'fc_b' are available for loading. If " + "it has not been selected then 'fc_both' is available for loading."), + "datatype": list, + "choices": ["encoder", "fc_a", "fc_b", "fc_both", "fc_shared", "fc_gblock", "g_block_a", + "g_block_b", "g_block_both", "decoder_a", "decoder_b", "decoder_both"], + "group": "weights", + "fixed": True}, # # SPECIFIC ENCODER SETTINGS # # # Faceswap Original - fs_original_depth=dict( - default=4, - info="Faceswap Encoder only: The number of convolutions to perform within the encoder.", - datatype=int, - min_max=(2, 10), - rounding=1, - group="faceswap encoder configuration", - fixed=True), - fs_original_min_filters=dict( - default=128, - info="Faceswap Encoder only: The minumum number of filters to use for encoder " - "convolutions. (i.e. the number of filters to use for the first encoder layer).", - datatype=int, - min_max=(16, 2048), - rounding=64, - group="faceswap encoder configuration", - fixed=True), - fs_original_max_filters=dict( - default=1024, - info="Faceswap Encoder only: The maximum number of filters to use for encoder " - "convolutions. (i.e. the number of filters to use for the final encoder layer).", - datatype=int, - min_max=(256, 8192), - rounding=128, - group="faceswap encoder configuration", - fixed=True), - fs_original_use_alt=dict( - default=False, - info="Use a slightly alternate version of the Faceswap Encoder." - "\n\tTrue - Use the alternate variation of the Faceswap Encoder." - "\n\tFalse - Use the original Faceswap Encoder.", - datatype=bool, - group="faceswap encoder configuration", - fixed=True), + "fs_original_depth": { + "default": 4, + "info": "Faceswap Encoder only: The number of convolutions to perform within the encoder.", + "datatype": int, + "min_max": (2, 10), + "rounding": 1, + "group": "faceswap encoder configuration", + "fixed": True}, + "fs_original_min_filters": { + "default": 128, + "info": ( + "Faceswap Encoder only: The minumum number of filters to use for encoder " + "convolutions. (i.e. the number of filters to use for the first encoder layer)."), + "datatype": int, + "min_max": (16, 2048), + "rounding": 64, + "group": "faceswap encoder configuration", + "fixed": True}, + "fs_original_max_filters": { + "default": 1024, + "info": ( + "Faceswap Encoder only: The maximum number of filters to use for encoder " + "convolutions. (i.e. the number of filters to use for the final encoder layer)."), + "datatype": int, + "min_max": (256, 8192), + "rounding": 128, + "group": "faceswap encoder configuration", + "fixed": True}, + "fs_original_use_alt": { + "default": False, + "info": ( + "Use a slightly alternate version of the Faceswap Encoder." + "\n\tTrue - Use the alternate variation of the Faceswap Encoder." + "\n\tFalse - Use the original Faceswap Encoder."), + "datatype": bool, + "group": "faceswap encoder configuration", + "fixed": True}, # MobileNet - mobilenet_width=dict( - default=1.0, - info="The width multiplier for mobilenet encoders. Controls the width of the " - "network. Values less than 1.0 proportionally decrease the number of filters within " - "each layer. Values greater than 1.0 proportionally increase the number of filters " - "within each layer. 1.0 is the default number of layers used within the paper.\n" - "NB: This option is ignored for any non-mobilenet encoders.\n" - "NB: If loading ImageNet weights, then for MobilenetV1 only values of '0.25', " - "'0.5', '0.75' or '1.0 can be selected. For MobilenetV2 only values of '0.35', " - "'0.50', '0.75', '1.0', '1.3' or '1.4' can be selected. For mobilenet_v3 only values " - "of '0.75' or '1.0' can be selected", - datatype=float, - min_max=(0.1, 2.0), - rounding=2, - group="mobilenet encoder configuration", - fixed=True), - mobilenet_depth=dict( - default=1, - info="The depth multiplier for MobilenetV1 encoder. This is the depth multiplier " - "for depthwise convolution (known as the resolution multiplier within the original " - "paper).\n" - "NB: This option is only used for MobilenetV1 and is ignored for all other " - "encoders.\n" - "NB: If loading ImageNet weights, this must be set to 1.", - datatype=int, - min_max=(1, 10), - rounding=1, - group="mobilenet encoder configuration", - fixed=True), - mobilenet_dropout=dict( - default=0.001, - info="The dropout rate for MobilenetV1 encoder.\n" - "NB: This option is only used for MobilenetV1 and is ignored for all other " - "encoders.", - datatype=float, - min_max=(0.001, 2.0), - rounding=3, - group="mobilenet encoder configuration", - fixed=True), - mobilenet_minimalistic=dict( - default=False, - info="Use a minimilist version of MobilenetV3.\n" - "In addition to large and small models MobilenetV3 also contains so-called " - "minimalistic models, these models have the same per-layer dimensions characteristic " - "as MobilenetV3 however, they don't utilize any of the advanced blocks " - "(squeeze-and-excite units, hard-swish, and 5x5 convolutions). While these models " - "are less efficient on CPU, they are much more performant on GPU/DSP.\n" - "NB: This option is only used for MobilenetV3 and is ignored for all other " - "encoders.\n", - datatype=bool, - group="mobilenet encoder configuration", - fixed=True), - ) + "mobilenet_width": { + "default": 1.0, + "info": ( + "The width multiplier for mobilenet encoders. Controls the width of the " + "network. Values less than 1.0 proportionally decrease the number of filters within " + "each layer. Values greater than 1.0 proportionally increase the number of filters " + "within each layer. 1.0 is the default number of layers used within the paper.\n" + "NB: This option is ignored for any non-mobilenet encoders.\n" + "NB: If loading ImageNet weights, then for MobilenetV1 only values of '0.25', " + "'0.5', '0.75' or '1.0 can be selected. For MobilenetV2 only values of '0.35', " + "'0.50', '0.75', '1.0', '1.3' or '1.4' can be selected. For mobilenet_v3 only values " + "of '0.75' or '1.0' can be selected"), + "datatype": float, + "min_max": (0.1, 2.0), + "rounding": 2, + "group": "mobilenet encoder configuration", + "fixed": True}, + "mobilenet_depth": { + "default": 1, + "info": ( + "The depth multiplier for MobilenetV1 encoder. This is the depth multiplier " + "for depthwise convolution (known as the resolution multiplier within the original " + "paper).\n" + "NB: This option is only used for MobilenetV1 and is ignored for all other " + "encoders.\n" + "NB: If loading ImageNet weights, this must be set to 1."), + "datatype": int, + "min_max": (1, 10), + "rounding": 1, + "group": "mobilenet encoder configuration", + "fixed": True}, + "mobilenet_dropout": { + "default": 0.001, + "info": ( + "The dropout rate for MobilenetV1 encoder.\n" + "NB: This option is only used for MobilenetV1 and is ignored for all other " + "encoders."), + "datatype": float, + "min_max": (0.001, 2.0), + "rounding": 3, + "group": "mobilenet encoder configuration", + "fixed": True}, + "mobilenet_minimalistic": { + "default": False, + "info": ( + "Use a minimilist version of MobilenetV3.\n" + "In addition to large and small models MobilenetV3 also contains so-called " + "minimalistic models, these models have the same per-layer dimensions characteristic " + "as MobilenetV3 however, they don't utilize any of the advanced blocks " + "(squeeze-and-excite units, hard-swish, and 5x5 convolutions). While these models " + "are less efficient on CPU, they are much more performant on GPU/DSP.\n" + "NB: This option is only used for MobilenetV3 and is ignored for all other " + "encoders.\n"), + "datatype": bool, + "group": "mobilenet encoder configuration", + "fixed": True}, + } diff --git a/plugins/train/model/realface.py b/plugins/train/model/realface.py index 25e7ccd7a8..2c7e22d190 100644 --- a/plugins/train/model/realface.py +++ b/plugins/train/model/realface.py @@ -10,17 +10,13 @@ import logging import sys -from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock -from lib.utils import get_backend -from ._base import ModelBase, KerasModel +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras.initializers import RandomNormal # pylint:disable=import-error +from tensorflow.keras.layers import Dense, Flatten, Input, LeakyReLU, Reshape # noqa:E501 # pylint:disable=import-error +from tensorflow.keras.models import Model as KModel # pylint:disable=import-error -if get_backend() == "amd": - from keras.initializers import RandomNormal # pylint:disable=no-name-in-module - from keras.layers import Dense, Flatten, Input, LeakyReLU, Reshape -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras.initializers import RandomNormal # noqa pylint:disable=import-error,no-name-in-module - from tensorflow.keras.layers import Dense, Flatten, Input, LeakyReLU, Reshape # noqa pylint:disable=import-error,no-name-in-module +from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock +from ._base import ModelBase logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -80,7 +76,7 @@ def build_model(self, inputs): outputs = [self.decoder_a()(encoder_a), self.decoder_b()(encoder_b)] - autoencoder = KerasModel(inputs, outputs, name=self.model_name) + autoencoder = KModel(inputs, outputs, name=self.model_name) return autoencoder def encoder(self): @@ -98,7 +94,7 @@ def encoder(self): var_x = Conv2DBlock(encoder_complexity * 2**(idx + 1), activation="leakyrelu")(var_x) - return KerasModel(input_, var_x, name="encoder") + return KModel(input_, var_x, name="encoder") def decoder_b(self): """ RealFace Decoder Network """ @@ -142,7 +138,7 @@ def decoder_b(self): outputs += [var_y] - return KerasModel(input_, outputs=outputs, name="decoder_b") + return KModel(input_, outputs=outputs, name="decoder_b") def decoder_a(self): """ RealFace Decoder (A) Network """ @@ -187,7 +183,7 @@ def decoder_a(self): outputs += [var_y] - return KerasModel(input_, outputs=outputs, name="decoder_a") + return KModel(input_, outputs=outputs, name="decoder_a") def _legacy_mapping(self): """ The mapping of legacy separate model names to single model names """ diff --git a/plugins/train/model/unbalanced.py b/plugins/train/model/unbalanced.py index 933e25537f..6f83166305 100644 --- a/plugins/train/model/unbalanced.py +++ b/plugins/train/model/unbalanced.py @@ -3,17 +3,14 @@ Based on the original https://www.reddit.com/r/deepfakes/ code sample + contributions """ -from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock -from lib.utils import get_backend -from ._base import ModelBase, KerasModel +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras.initializers import RandomNormal # pylint:disable=import-error +from tensorflow.keras.layers import ( # pylint:disable=import-error + Dense, Flatten, Input, LeakyReLU, Reshape, SpatialDropout2D) +from tensorflow.keras.models import Model as KModel # pylint:disable=import-error -if get_backend() == "amd": - from keras.initializers import RandomNormal # pylint:disable=no-name-in-module - from keras.layers import Dense, Flatten, Input, LeakyReLU, Reshape, SpatialDropout2D -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras.initializers import RandomNormal # noqa pylint:disable=import-error,no-name-in-module - from tensorflow.keras.layers import Dense, Flatten, Input, LeakyReLU, Reshape, SpatialDropout2D # noqa pylint:disable=import-error,no-name-in-module +from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock +from ._base import ModelBase class Model(ModelBase): @@ -33,12 +30,12 @@ def build_model(self, inputs): outputs = [self.decoder_a()(encoder_a), self.decoder_b()(encoder_b)] - autoencoder = KerasModel(inputs, outputs, name=self.model_name) + autoencoder = KModel(inputs, outputs, name=self.model_name) return autoencoder def encoder(self): """ Unbalanced Encoder """ - kwargs = dict(kernel_initializer=self.kernel_initializer) + kwargs = {"kernel_initializer": self.kernel_initializer} encoder_complexity = 128 if self.low_mem else self.config["complexity_encoder"] dense_dim = 384 if self.low_mem else 512 dense_shape = self.input_shape[0] // 16 @@ -61,11 +58,11 @@ def encoder(self): var_x = Dense(dense_shape * dense_shape * dense_dim, kernel_initializer=self.kernel_initializer)(var_x) var_x = Reshape((dense_shape, dense_shape, dense_dim))(var_x) - return KerasModel(input_, var_x, name="encoder") + return KModel(input_, var_x, name="encoder") def decoder_a(self): """ Decoder for side A """ - kwargs = dict(kernel_size=5, kernel_initializer=self.kernel_initializer) + kwargs = {"kernel_size": 5, "kernel_initializer": self.kernel_initializer} decoder_complexity = 320 if self.low_mem else self.config["complexity_decoder_a"] dense_dim = 384 if self.low_mem else 512 decoder_shape = self.input_shape[0] // 16 @@ -93,11 +90,11 @@ def decoder_a(self): var_y = UpscaleBlock(decoder_complexity // 4, activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, name="mask_out_a")(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs, name="decoder_a") + return KModel(input_, outputs=outputs, name="decoder_a") def decoder_b(self): """ Decoder for side B """ - kwargs = dict(kernel_size=5, kernel_initializer=self.kernel_initializer) + kwargs = {"kernel_size": 5, "kernel_initializer": self.kernel_initializer} decoder_complexity = 384 if self.low_mem else self.config["complexity_decoder_b"] dense_dim = 384 if self.low_mem else 512 decoder_shape = self.input_shape[0] // 16 @@ -137,7 +134,7 @@ def decoder_b(self): var_y = UpscaleBlock(decoder_complexity // 8, activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, name="mask_out_b")(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs, name="decoder_b") + return KModel(input_, outputs=outputs, name="decoder_b") def _legacy_mapping(self): """ The mapping of legacy separate model names to single model names """ diff --git a/plugins/train/model/villain.py b/plugins/train/model/villain.py index 863a782ad7..1d6bfc7f10 100644 --- a/plugins/train/model/villain.py +++ b/plugins/train/model/villain.py @@ -3,20 +3,16 @@ Based on the original https://www.reddit.com/r/deepfakes/ code sample + contributions Adapted from a model by VillainGuy (https://github.com/VillainGuy) """ +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras.initializers import RandomNormal # pylint:disable=import-error +from tensorflow.keras.layers import add, Dense, Flatten, Input, LeakyReLU, Reshape # noqa:E501 # pylint:disable=import-error +from tensorflow.keras.models import Model as KModel # pylint:disable=import-error + from lib.model.layers import PixelShuffler from lib.model.nn_blocks import (Conv2DOutput, Conv2DBlock, ResidualBlock, SeparableConv2DBlock, UpscaleBlock) -from lib.utils import get_backend - -from .original import Model as OriginalModel, KerasModel -if get_backend() == "amd": - from keras.initializers import RandomNormal # pylint:disable=no-name-in-module - from keras.layers import add, Dense, Flatten, Input, LeakyReLU, Reshape -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras.initializers import RandomNormal # noqa pylint:disable=import-error,no-name-in-module - from tensorflow.keras.layers import add, Dense, Flatten, Input, LeakyReLU, Reshape # noqa pylint:disable=import-error,no-name-in-module +from .original import Model as OriginalModel class Model(OriginalModel): @@ -29,7 +25,7 @@ def __init__(self, *args, **kwargs): def encoder(self): """ Encoder Network """ - kwargs = dict(kernel_initializer=self.kernel_initializer) + kwargs = {"kernel_initializer": self.kernel_initializer} input_ = Input(shape=self.input_shape) in_conv_filters = self.input_shape[0] if self.input_shape[0] > 128: @@ -61,11 +57,11 @@ def encoder(self): var_x = Dense(dense_shape * dense_shape * 1024, **kwargs)(var_x) var_x = Reshape((dense_shape, dense_shape, 1024))(var_x) var_x = UpscaleBlock(512, activation="leakyrelu", **kwargs)(var_x) - return KerasModel(input_, var_x, name="encoder") + return KModel(input_, var_x, name="encoder") def decoder(self, side): """ Decoder Network """ - kwargs = dict(kernel_initializer=self.kernel_initializer) + kwargs = {"kernel_initializer": self.kernel_initializer} decoder_shape = self.input_shape[0] // 8 input_ = Input(shape=(decoder_shape, decoder_shape, 512)) @@ -89,4 +85,4 @@ def decoder(self, side): var_y = UpscaleBlock(self.input_shape[0], activation="leakyrelu")(var_y) var_y = Conv2DOutput(1, 5, name=f"mask_out_{side}")(var_y) outputs.append(var_y) - return KerasModel(input_, outputs=outputs, name=f"decoder_{side}") + return KModel(input_, outputs=outputs, name=f"decoder_{side}") diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index eac0605191..f0feebb4e8 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -6,12 +6,12 @@ inherits from this class. If further plugins are developed, then common code should be kept here, with "original" unique code split out to the original plugin. """ - +from __future__ import annotations import logging import os import sys import time -from typing import Callable, cast, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union +import typing as T import cv2 import numpy as np @@ -23,10 +23,10 @@ from lib.image import hex_to_rgb from lib.training import PreviewDataGenerator, TrainingDataGenerator from lib.training.generator import BatchType, DataGenerator -from lib.utils import FaceswapError, get_backend, get_folder, get_image_paths, get_tf_version +from lib.utils import FaceswapError, get_folder, get_image_paths, get_tf_version from plugins.train._config import Config -if TYPE_CHECKING: +if T.TYPE_CHECKING: from plugins.train.model._base import ModelBase from lib.config import ConfigValueType @@ -39,7 +39,7 @@ def _get_config(plugin_name: str, - configfile: Optional[str] = None) -> Dict[str, "ConfigValueType"]: + configfile: T.Optional[str] = None) -> T.Dict[str, ConfigValueType]: """ Return the configuration for the requested trainer. Parameters @@ -79,10 +79,10 @@ class TrainerBase(): """ def __init__(self, - model: "ModelBase", - images: Dict[Literal["a", "b"], List[str]], + model: ModelBase, + images: T.Dict[Literal["a", "b"], T.List[str]], batch_size: int, - configfile: Optional[str]) -> None: + configfile: T.Optional[str]) -> None: logger.debug("Initializing %s: (model: '%s', batch_size: %s)", self.__class__.__name__, model, batch_size) self._model = model @@ -97,21 +97,21 @@ def __init__(self, self._tensorboard = self._set_tensorboard() self._samples = _Samples(self._model, self._model.coverage_ratio, - cast(int, self._config["mask_opacity"]), - cast(str, self._config["mask_color"])) + T.cast(int, self._config["mask_opacity"]), + T.cast(str, self._config["mask_color"])) num_images = self._config.get("preview_images", 14) assert isinstance(num_images, int) self._timelapse = _Timelapse(self._model, self._model.coverage_ratio, num_images, - cast(int, self._config["mask_opacity"]), - cast(str, self._config["mask_color"]), + T.cast(int, self._config["mask_opacity"]), + T.cast(str, self._config["mask_color"]), self._feeder, self._images) logger.debug("Initialized %s", self.__class__.__name__) - def _get_config(self, configfile: Optional[str]) -> Dict[str, "ConfigValueType"]: + def _get_config(self, configfile: T.Optional[str]) -> T.Dict[str, ConfigValueType]: """ Get the saved training config options. Override any global settings with the setting provided from the model's saved config. @@ -157,7 +157,7 @@ def _set_tensorboard(self) -> tf.keras.callbacks.TensorBoard: f"session_{self._model.state.session_id}") tensorboard = tf.keras.callbacks.TensorBoard(log_dir=log_dir, histogram_freq=0, # Must be 0 or hangs - write_graph=get_backend() != "amd", + write_graph=True, write_images=False, update_freq="batch", profile_batch=0, @@ -173,10 +173,10 @@ def toggle_mask(self) -> None: self._samples.toggle_mask_display() def train_one_step(self, - viewer: Optional[Callable[[np.ndarray, str], None]], - timelapse_kwargs: Optional[Dict[Literal["input_a", - "input_b", - "output"], str]]) -> None: + viewer: T.Optional[T.Callable[[np.ndarray, str], None]], + timelapse_kwargs: T.Optional[T.Dict[Literal["input_a", + "input_b", + "output"], str]]) -> None: """ Running training on a batch of images for each side. Triggered from the training cycle in :class:`scripts.train.Train`. @@ -215,12 +215,9 @@ def train_one_step(self, (self._model.iterations - 1) % snapshot_interval == 0) model_inputs, model_targets = self._feeder.get_batch() - if get_backend() == "amd": # Expand out AMD inputs + targets - model_inputs = [inp for side in model_inputs for inp in side] # type: ignore - model_targets = [tgt for side in model_targets for tgt in side] # type: ignore try: - loss: List[float] = self._model.model.train_on_batch(model_inputs, y=model_targets) + loss: T.List[float] = self._model.model.train_on_batch(model_inputs, y=model_targets) except tf_errors.ResourceExhaustedError as err: msg = ("You do not have enough GPU memory available to train the selected model at " "the selected settings. You can try a number of things:" @@ -232,23 +229,6 @@ def train_one_step(self, "\n4) Use a more lightweight model, or select the model's 'LowMem' option " "(in config) if it has one.") raise FaceswapError(msg) from err - except Exception as err: - if get_backend() == "amd": - # pylint:disable=import-outside-toplevel - from lib.plaidml_utils import is_plaidml_error - if (is_plaidml_error(err) and ( - "CL_MEM_OBJECT_ALLOCATION_FAILURE" in str(err).upper() or - "enough memory for the current schedule" in str(err).lower())): - msg = ("You do not have enough GPU memory available to train the selected " - "model at the selected settings. You can try a number of things:" - "\n1) Close any other application that is using your GPU (web browsers " - "are particularly bad for this)." - "\n2) Lower the batchsize (the amount of images fed into the model " - "each iteration)." - "\n3) Use a more lightweight model, or select the model's 'LowMem' " - "option (in config) if it has one.") - raise FaceswapError(msg) from err - raise self._log_tensorboard(loss) loss = self._collate_and_store_loss(loss[1:]) self._print_loss(loss) @@ -256,7 +236,7 @@ def train_one_step(self, self._model.snapshot() self._update_viewers(viewer, timelapse_kwargs) - def _log_tensorboard(self, loss: List[float]) -> None: + def _log_tensorboard(self, loss: T.List[float]) -> None: """ Log current loss to Tensorboard log files Parameters @@ -282,7 +262,7 @@ def _log_tensorboard(self, loss: List[float]) -> None: else: self._tensorboard.on_train_batch_end(self._model.iterations, logs=logs) - def _collate_and_store_loss(self, loss: List[float]) -> List[float]: + def _collate_and_store_loss(self, loss: T.List[float]) -> T.List[float]: """ Collate the loss into totals for each side. The losses are summed into a total for each side. Loss totals are added to @@ -318,7 +298,7 @@ def _collate_and_store_loss(self, loss: List[float]) -> List[float]: logger.trace("original loss: %s, combined_loss: %s", loss, combined_loss) # type: ignore return combined_loss - def _print_loss(self, loss: List[float]) -> None: + def _print_loss(self, loss: T.List[float]) -> None: """ Outputs the loss for the current iteration to the console. Parameters @@ -338,10 +318,10 @@ def _print_loss(self, loss: List[float]) -> None: "line: %s, error: %s", output, str(err)) def _update_viewers(self, - viewer: Optional[Callable[[np.ndarray, str], None]], - timelapse_kwargs: Optional[Dict[Literal["input_a", - "input_b", - "output"], str]]) -> None: + viewer: T.Optional[T.Callable[[np.ndarray, str], None]], + timelapse_kwargs: T.Optional[T.Dict[Literal["input_a", + "input_b", + "output"], str]]) -> None: """ Update the preview viewer and timelapse output Parameters @@ -391,10 +371,10 @@ class _Feeder(): The configuration for this trainer """ def __init__(self, - images: Dict[Literal["a", "b"], List[str]], - model: 'ModelBase', + images: T.Dict[Literal["a", "b"], T.List[str]], + model: ModelBase, batch_size: int, - config: Dict[str, "ConfigValueType"]) -> None: + config: T.Dict[str, ConfigValueType]) -> None: logger.debug("Initializing %s: num_images: %s, batch_size: %s, config: %s)", self.__class__.__name__, {k: len(v) for k, v in images.items()}, batch_size, config) @@ -405,14 +385,14 @@ def __init__(self, self._feeds = {side: self._load_generator(side, False).minibatch_ab() for side in get_args(Literal["a", "b"])} - self._display_feeds = dict(preview=self._set_preview_feed(), timelapse={}) + self._display_feeds = {"preview": self._set_preview_feed(), "timelapse": {}} logger.debug("Initialized %s:", self.__class__.__name__) def _load_generator(self, side: Literal["a", "b"], is_display: bool, - batch_size: Optional[int] = None, - images: Optional[List[str]] = None) -> DataGenerator: + batch_size: T.Optional[int] = None, + images: T.Optional[T.List[str]] = None) -> DataGenerator: """ Load the :class:`~lib.training_data.TrainingDataGenerator` for this feeder. Parameters @@ -444,7 +424,7 @@ def _load_generator(self, self._batch_size if batch_size is None else batch_size) return retval - def _set_preview_feed(self) -> Dict[Literal["a", "b"], Generator[BatchType, None, None]]: + def _set_preview_feed(self) -> T.Dict[Literal["a", "b"], T.Generator[BatchType, None, None]]: """ Set the preview feed for this feeder. Creates a generator from :class:`lib.training_data.PreviewDataGenerator` specifically @@ -456,7 +436,7 @@ def _set_preview_feed(self) -> Dict[Literal["a", "b"], Generator[BatchType, None The side ("a" or "b") as key, :class:`~lib.training_data.PreviewDataGenerator` as value. """ - retval: Dict[Literal["a", "b"], Generator[BatchType, None, None]] = {} + retval: T.Dict[Literal["a", "b"], T.Generator[BatchType, None, None]] = {} num_images = self._config.get("preview_images", 14) assert isinstance(num_images, int) for side in get_args(Literal["a", "b"]): @@ -468,7 +448,7 @@ def _set_preview_feed(self) -> Dict[Literal["a", "b"], Generator[BatchType, None batch_size=batchsize).minibatch_ab() return retval - def get_batch(self) -> Tuple[List[List[np.ndarray]], ...]: + def get_batch(self) -> T.Tuple[T.List[T.List[np.ndarray]], ...]: """ Get the feed data and the targets for each training side for feeding into the model's train function. @@ -479,8 +459,8 @@ def get_batch(self) -> Tuple[List[List[np.ndarray]], ...]: model_targets: list The targets for the model for each side A and B """ - model_inputs: List[List[np.ndarray]] = [] - model_targets: List[List[np.ndarray]] = [] + model_inputs: T.List[T.List[np.ndarray]] = [] + model_targets: T.List[T.List[np.ndarray]] = [] for side in ("a", "b"): side_feed, side_targets = next(self._feeds[side]) if self._model.config["learn_mask"]: # Add the face mask as it's own target @@ -492,8 +472,8 @@ def get_batch(self) -> Tuple[List[List[np.ndarray]], ...]: return model_inputs, model_targets - def generate_preview(self, - is_timelapse: bool = False) -> Dict[Literal["a", "b"], List[np.ndarray]]: + def generate_preview(self, is_timelapse: bool = False + ) -> T.Dict[Literal["a", "b"], T.List[np.ndarray]]: """ Generate the images for preview window or timelapse Parameters @@ -510,14 +490,14 @@ def generate_preview(self, """ logger.debug("Generating preview (is_timelapse: %s)", is_timelapse) - batchsizes: List[int] = [] - feed: Dict[Literal["a", "b"], np.ndarray] = {} - samples: Dict[Literal["a", "b"], np.ndarray] = {} - masks: Dict[Literal["a", "b"], np.ndarray] = {} + batchsizes: T.List[int] = [] + feed: T.Dict[Literal["a", "b"], np.ndarray] = {} + samples: T.Dict[Literal["a", "b"], np.ndarray] = {} + masks: T.Dict[Literal["a", "b"], np.ndarray] = {} # MyPy can't recurse into nested dicts to get the type :( - iterator = cast(Dict[Literal["a", "b"], Generator[BatchType, None, None]], - self._display_feeds["timelapse" if is_timelapse else "preview"]) + iterator = T.cast(T.Dict[Literal["a", "b"], T.Generator[BatchType, None, None]], + self._display_feeds["timelapse" if is_timelapse else "preview"]) for side in get_args(Literal["a", "b"]): side_feed, side_samples = next(iterator[side]) batchsizes.append(len(side_samples[0])) @@ -533,10 +513,10 @@ def generate_preview(self, def compile_sample(self, image_count: int, - feed: Dict[Literal["a", "b"], np.ndarray], - samples: Dict[Literal["a", "b"], np.ndarray], - masks: Dict[Literal["a", "b"], np.ndarray] - ) -> Dict[Literal["a", "b"], List[np.ndarray]]: + feed: T.Dict[Literal["a", "b"], np.ndarray], + samples: T.Dict[Literal["a", "b"], np.ndarray], + masks: T.Dict[Literal["a", "b"], np.ndarray] + ) -> T.Dict[Literal["a", "b"], T.List[np.ndarray]]: """ Compile the preview samples for display. Parameters @@ -562,7 +542,7 @@ def compile_sample(self, num_images = self._config.get("preview_images", 14) assert isinstance(num_images, int) num_images = min(image_count, num_images) - retval: Dict[Literal["a", "b"], List[np.ndarray]] = {} + retval: T.Dict[Literal["a", "b"], T.List[np.ndarray]] = {} for side in get_args(Literal["a", "b"]): logger.debug("Compiling samples: (side: '%s', samples: %s)", side, num_images) retval[side] = [feed[side][0:num_images], @@ -572,7 +552,7 @@ def compile_sample(self, return retval def set_timelapse_feed(self, - images: Dict[Literal["a", "b"], List[str]], + images: T.Dict[Literal["a", "b"], T.List[str]], batch_size: int) -> None: """ Set the time-lapse feed for this feeder. @@ -590,8 +570,8 @@ def set_timelapse_feed(self, images, batch_size) # MyPy can't recurse into nested dicts to get the type :( - iterator = cast(Dict[Literal["a", "b"], Generator[BatchType, None, None]], - self._display_feeds["timelapse"]) + iterator = T.cast(T.Dict[Literal["a", "b"], T.Generator[BatchType, None, None]], + self._display_feeds["timelapse"]) for side in get_args(Literal["a", "b"]): imgs = images[side] @@ -626,7 +606,7 @@ class _Samples(): # pylint:disable=too-few-public-methods for generating samples corresponding to each side. """ def __init__(self, - model: "ModelBase", + model: ModelBase, coverage_ratio: float, mask_opacity: int, mask_color: str) -> None: @@ -635,7 +615,7 @@ def __init__(self, self.__class__.__name__, model, coverage_ratio, mask_opacity, mask_color) self._model = model self._display_mask = model.config["learn_mask"] or model.config["penalized_mask_loss"] - self.images: Dict[Literal["a", "b"], List[np.ndarray]] = {} + self.images: T.Dict[Literal["a", "b"], T.List[np.ndarray]] = {} self._coverage_ratio = coverage_ratio self._mask_opacity = mask_opacity / 100.0 self._mask_color = np.array(hex_to_rgb(mask_color))[..., 2::-1] / 255. @@ -659,7 +639,7 @@ def show_sample(self) -> np.ndarray: A compiled preview image ready for display or saving """ logger.debug("Showing sample") - feeds: Dict[Literal["a", "b"], np.ndarray] = {} + feeds: T.Dict[Literal["a", "b"], np.ndarray] = {} for idx, side in enumerate(get_args(Literal["a", "b"])): feed = self.images[side][0] input_shape = self._model.model.input_shape[idx][1:] @@ -704,7 +684,7 @@ def _resize_sample(cls, logger.debug("Resized sample: (side: '%s' shape: %s)", side, retval.shape) return retval - def _get_predictions(self, feed_a: np.ndarray, feed_b: np.ndarray) -> Dict[str, np.ndarray]: + def _get_predictions(self, feed_a: np.ndarray, feed_b: np.ndarray) -> T.Dict[str, np.ndarray]: """ Feed the samples to the model and return predictions Parameters @@ -720,16 +700,10 @@ def _get_predictions(self, feed_a: np.ndarray, feed_b: np.ndarray) -> Dict[str, List of :class:`numpy.ndarray` of predictions received from the model """ logger.debug("Getting Predictions") - preds: Dict[str, np.ndarray] = {} + preds: T.Dict[str, np.ndarray] = {} standard = self._model.model.predict([feed_a, feed_b], verbose=0) swapped = self._model.model.predict([feed_b, feed_a], verbose=0) - if self._model.config["learn_mask"] and get_backend() == "amd": - # Ravel results for plaidml - split = len(standard) // 2 - standard = [standard[:split], standard[split:]] - swapped = [swapped[:split], swapped[split:]] - if self._model.config["learn_mask"]: # Add mask to 4th channel of final output standard = [np.concatenate(side[-2:], axis=-1) for side in standard] swapped = [np.concatenate(side[-2:], axis=-1) for side in swapped] @@ -745,7 +719,7 @@ def _get_predictions(self, feed_a: np.ndarray, feed_b: np.ndarray) -> Dict[str, logger.debug("Returning predictions: %s", {key: val.shape for key, val in preds.items()}) return preds - def _compile_preview(self, predictions: Dict[str, np.ndarray]) -> np.ndarray: + def _compile_preview(self, predictions: T.Dict[str, np.ndarray]) -> np.ndarray: """ Compile predictions and images into the final preview image. Parameters @@ -758,8 +732,8 @@ def _compile_preview(self, predictions: Dict[str, np.ndarray]) -> np.ndarray: :class:`numpy.ndarry` A compiled preview image ready for display or saving """ - figures: Dict[Literal["a", "b"], np.ndarray] = {} - headers: Dict[Literal["a", "b"], np.ndarray] = {} + figures: T.Dict[Literal["a", "b"], np.ndarray] = {} + headers: T.Dict[Literal["a", "b"], np.ndarray] = {} for side, samples in self.images.items(): other_side = "a" if side == "b" else "b" @@ -788,8 +762,8 @@ def _compile_preview(self, predictions: Dict[str, np.ndarray]) -> np.ndarray: def _to_full_frame(self, side: Literal["a", "b"], - samples: List[np.ndarray], - predictions: List[np.ndarray]) -> List[np.ndarray]: + samples: T.List[np.ndarray], + predictions: T.List[np.ndarray]) -> T.List[np.ndarray]: """ Patch targets and prediction images into images of model output size. Parameters @@ -832,7 +806,7 @@ def _process_full(self, side: Literal["a", "b"], images: np.ndarray, prediction_size: int, - color: Tuple[float, float, float]) -> np.ndarray: + color: T.Tuple[float, float, float]) -> np.ndarray: """ Add a frame overlay to preview images indicating the region of interest. This applies the red border that appears in the preview images. @@ -873,7 +847,7 @@ def _process_full(self, logger.debug("Overlayed background. Shape: %s", images.shape) return images - def _compile_masked(self, faces: List[np.ndarray], masks: np.ndarray) -> List[np.ndarray]: + def _compile_masked(self, faces: T.List[np.ndarray], masks: np.ndarray) -> T.List[np.ndarray]: """ Add the mask to the faces for masked preview. Places an opaque red layer over areas of the face that are masked out. @@ -892,7 +866,7 @@ def _compile_masked(self, faces: List[np.ndarray], masks: np.ndarray) -> List[np List of :class:`numpy.ndarray` faces with the opaque mask layer applied """ orig_masks = 1 - np.rint(masks) - masks3: Union[List[np.ndarray], np.ndarray] = [] + masks3: T.Union[T.List[np.ndarray], np.ndarray] = [] if faces[-1].shape[-1] == 4: # Mask contained in alpha channel of predictions pred_masks = [1 - np.rint(face[..., -1])[..., None] for face in faces[-2:]] @@ -901,7 +875,7 @@ def _compile_masked(self, faces: List[np.ndarray], masks: np.ndarray) -> List[np else: masks3 = np.repeat(np.expand_dims(orig_masks, axis=0), 3, axis=0) - retval: List[np.ndarray] = [] + retval: T.List[np.ndarray] = [] alpha = 1.0 - self._mask_opacity for previews, compiled_masks in zip(faces, masks3): overlays = previews.copy() @@ -984,8 +958,8 @@ def _get_headers(cls, side: Literal["a", "b"], width: int) -> np.ndarray: @classmethod def _duplicate_headers(cls, - headers: Dict[Literal["a", "b"], np.ndarray], - columns: int) -> Dict[Literal["a", "b"], np.ndarray]: + headers: T.Dict[Literal["a", "b"], np.ndarray], + columns: int) -> T.Dict[Literal["a", "b"], np.ndarray]: """ Duplicate headers for the number of columns displayed for each side. Parameters @@ -1028,13 +1002,13 @@ class _Timelapse(): # pylint:disable=too-few-public-methods The full paths to the training images for each side of the model """ def __init__(self, - model: "ModelBase", + model: ModelBase, coverage_ratio: float, image_count: int, mask_opacity: int, mask_color: str, feeder: _Feeder, - image_paths: Dict[Literal["a", "b"], List[str]]) -> None: + image_paths: T.Dict[Literal["a", "b"], T.List[str]]) -> None: logger.debug("Initializing %s: model: %s, coverage_ratio: %s, image_count: %s, " "mask_opacity: %s, mask_color: %s, feeder: %s, image_paths: %s)", self.__class__.__name__, model, coverage_ratio, image_count, mask_opacity, @@ -1068,7 +1042,7 @@ def _setup(self, input_a: str, input_b: str, output: str) -> None: logger.debug("Time-lapse output set to '%s'", self._output_file) # Rewrite paths to pull from the training images so mask and face data can be accessed - images: Dict[Literal["a", "b"], List[str]] = {} + images: T.Dict[Literal["a", "b"], T.List[str]] = {} for side, input_ in zip(get_args(Literal["a", "b"]), (input_a, input_b)): training_path = os.path.dirname(self._image_paths[side][0]) images[side] = [os.path.join(training_path, os.path.basename(pth)) @@ -1080,9 +1054,9 @@ def _setup(self, input_a: str, input_b: str, output: str) -> None: self._feeder.set_timelapse_feed(images, batchsize) logger.debug("Set up time-lapse") - def output_timelapse(self, timelapse_kwargs: Dict[Literal["input_a", - "input_b", - "output"], str]) -> None: + def output_timelapse(self, timelapse_kwargs: T.Dict[Literal["input_a", + "input_b", + "output"], str]) -> None: """ Generate the time-lapse samples and output the created time-lapse to the specified output folder. @@ -1094,7 +1068,7 @@ def output_timelapse(self, timelapse_kwargs: Dict[Literal["input_a", """ logger.debug("Ouputting time-lapse") if not self._output_file: - self._setup(**cast(Dict[str, str], timelapse_kwargs)) + self._setup(**T.cast(T.Dict[str, str], timelapse_kwargs)) logger.debug("Getting time-lapse samples") self._samples.images = self._feeder.generate_preview(is_timelapse=True) diff --git a/requirements/requirements_amd.txt b/requirements/requirements_amd.txt deleted file mode 100644 index 87c28448d2..0000000000 --- a/requirements/requirements_amd.txt +++ /dev/null @@ -1,6 +0,0 @@ --r _requirements_base.txt -# tf2.2 is last version that tensorboard logging works with old Keras -numpy>=1.18.0,<1.19.0 # TF Will uninstall anything equal or over 1.19.0 -protobuf>= 3.19.0,<3.20.0 # TF has started pulling in incompatible protobuf -tensorflow>=2.2.0,<2.3.0 -plaidml-keras==0.7.0 diff --git a/scripts/convert.py b/scripts/convert.py index 9f07bfb02d..374bf7fa52 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -1,14 +1,14 @@ #!/usr/bin python3 """ Main entry point to the convert process of FaceSwap """ - +from __future__ import annotations from dataclasses import dataclass, field import logging import re import os import sys +import typing as T from threading import Event from time import sleep -from typing import Callable, cast, Dict, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 import numpy as np @@ -22,7 +22,7 @@ from lib.image import read_image_meta_batch, ImagesLoader from lib.multithreading import MultiThread, total_cpus from lib.queue_manager import queue_manager -from lib.utils import FaceswapError, get_backend, get_folder, get_image_paths +from lib.utils import FaceswapError, get_folder, get_image_paths from plugins.extract.pipeline import Extractor, ExtractMedia from plugins.plugin_loader import PluginLoader @@ -31,7 +31,7 @@ else: from typing import get_args, Literal -if TYPE_CHECKING: +if T.TYPE_CHECKING: from argparse import Namespace from plugins.convert.writer._base import Output from plugins.train.model._base import ModelBase @@ -61,8 +61,8 @@ class ConvertItem: The swapped faces returned from the model's predict function """ inbound: ExtractMedia - feed_faces: List[AlignedFace] = field(default_factory=list) - reference_faces: List[AlignedFace] = field(default_factory=list) + feed_faces: T.List[AlignedFace] = field(default_factory=list) + reference_faces: T.List[AlignedFace] = field(default_factory=list) swapped_faces: np.ndarray = np.array([]) @@ -84,7 +84,7 @@ class Convert(): # pylint:disable=too-few-public-methods The arguments to be passed to the convert process as generated from Faceswap's command line arguments """ - def __init__(self, arguments: "Namespace") -> None: + def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: (args: %s)", self.__class__.__name__, arguments) self._args = arguments @@ -290,7 +290,7 @@ class DiskIO(): """ def __init__(self, - alignments: Alignments, images: ImagesLoader, arguments: "Namespace") -> None: + alignments: Alignments, images: ImagesLoader, arguments: Namespace) -> None: logger.debug("Initializing %s: (alignments: %s, images: %s, arguments: %s)", self.__class__.__name__, alignments, images, arguments) self._alignments = alignments @@ -307,8 +307,8 @@ def __init__(self, # Extractor for on the fly detection self._extractor = self._load_extractor() - self._queues: Dict[Literal["load", "save"], "EventQueue"] = {} - self._threads: Dict[Literal["load", "save"], MultiThread] = {} + self._queues: T.Dict[Literal["load", "save"], EventQueue] = {} + self._threads: T.Dict[Literal["load", "save"], MultiThread] = {} self._init_threads() logger.debug("Initialized %s", self.__class__.__name__) @@ -324,13 +324,13 @@ def draw_transparent(self) -> bool: return self._writer.config.get("draw_transparent", False) @property - def pre_encode(self) -> Optional[Callable[[np.ndarray], List[bytes]]]: + def pre_encode(self) -> T.Optional[T.Callable[[np.ndarray], T.List[bytes]]]: """ python function: Selected writer's pre-encode function, if it has one, otherwise ``None`` """ dummy = np.zeros((20, 20, 3), dtype="uint8") test = self._writer.pre_encode(dummy) - retval: Optional[Callable[[np.ndarray], - List[bytes]]] = None if test is None else self._writer.pre_encode + retval: T.Optional[T.Callable[[np.ndarray], + T.List[bytes]]] = None if test is None else self._writer.pre_encode logger.debug("Writer pre_encode function: %s", retval) return retval @@ -347,7 +347,7 @@ def load_thread(self) -> MultiThread: return self._threads["load"] @property - def load_queue(self) -> "EventQueue": + def load_queue(self) -> EventQueue: """ :class:`~lib.queue_manager.EventQueue`: The queue that images and detected faces are " "loaded into. """ return self._queues["load"] @@ -363,7 +363,7 @@ def _total_count(self) -> int: return retval # Initialization - def _get_writer(self) -> "Output": + def _get_writer(self) -> Output: """ Load the selected writer plugin. Returns @@ -384,7 +384,7 @@ def _get_writer(self) -> "Output": return PluginLoader.get_converter("writer", self._args.writer)(*args, configfile=configfile) - def _get_frame_ranges(self) -> Optional[List[Tuple[int, int]]]: + def _get_frame_ranges(self) -> T.Optional[T.List[T.Tuple[int, int]]]: """ Obtain the frame ranges that are to be converted. If frame ranges have been specified, then split the command line formatted arguments into @@ -422,7 +422,7 @@ def _get_frame_ranges(self) -> Optional[List[Tuple[int, int]]]: logger.debug("frame ranges: %s", retval) return retval - def _load_extractor(self) -> Optional[Extractor]: + def _load_extractor(self) -> T.Optional[Extractor]: """ Load the CV2-DNN Face Extractor Chain. For On-The-Fly conversion we use a CPU based extractor to avoid stacking the GPU. @@ -571,7 +571,7 @@ def _check_skipframe(self, filename: str) -> bool: logger.trace("idx: %s, skipframe: %s", idx, skipframe) # type: ignore return skipframe - def _get_detected_faces(self, filename: str, image: np.ndarray) -> List[DetectedFace]: + def _get_detected_faces(self, filename: str, image: np.ndarray) -> T.List[DetectedFace]: """ Return the detected faces for the given image. If we have an alignments file, then the detected faces are created from that file. If @@ -597,7 +597,7 @@ def _get_detected_faces(self, filename: str, image: np.ndarray) -> List[Detected logger.trace("Got %s faces for: '%s'", len(detected_faces), filename) # type:ignore return detected_faces - def _alignments_faces(self, frame_name: str, image: np.ndarray) -> List[DetectedFace]: + def _alignments_faces(self, frame_name: str, image: np.ndarray) -> T.List[DetectedFace]: """ Return detected faces from an alignments file. Parameters @@ -644,7 +644,7 @@ def _check_alignments(self, frame_name: str) -> bool: tqdm.write(f"No alignment found for {frame_name}, skipping") return have_alignments - def _detect_faces(self, filename: str, image: np.ndarray) -> List[DetectedFace]: + def _detect_faces(self, filename: str, image: np.ndarray) -> T.List[DetectedFace]: """ Extract the face from a frame for On-The-Fly conversion. Pulls detected faces out of the Extraction pipeline. @@ -714,7 +714,7 @@ class Predict(): The arguments that were passed to the convert process as generated from Faceswap's command line arguments """ - def __init__(self, in_queue: "EventQueue", queue_size: int, arguments: "Namespace") -> None: + def __init__(self, in_queue: EventQueue, queue_size: int, arguments: Namespace) -> None: logger.debug("Initializing %s: (args: %s, queue_size: %s, in_queue: %s)", self.__class__.__name__, arguments, queue_size, in_queue) self._args = arguments @@ -740,12 +740,12 @@ def thread(self) -> MultiThread: return self._thread @property - def in_queue(self) -> "EventQueue": + def in_queue(self) -> EventQueue: """ :class:`~lib.queue_manager.EventQueue`: The input queue to the predictor. """ return self._in_queue @property - def out_queue(self) -> "EventQueue": + def out_queue(self) -> EventQueue: """ :class:`~lib.queue_manager.EventQueue`: The output queue from the predictor. """ return self._out_queue @@ -765,7 +765,7 @@ def coverage_ratio(self) -> float: return self._coverage_ratio @property - def centering(self) -> "CenteringType": + def centering(self) -> CenteringType: """ str: The centering that the model was trained on (`"head", "face"` or `"legacy"`) """ return self._centering @@ -779,7 +779,7 @@ def output_size(self) -> int: """ int: The size in pixels of the Faceswap model output. """ return self._sizes["output"] - def _get_io_sizes(self) -> Dict[str, int]: + def _get_io_sizes(self) -> T.Dict[str, int]: """ Obtain the input size and output size of the model. Returns @@ -795,7 +795,7 @@ def _get_io_sizes(self) -> Dict[str, int]: logger.debug(retval) return retval - def _load_model(self) -> "ModelBase": + def _load_model(self) -> ModelBase: """ Load the Faceswap model. Returns @@ -896,9 +896,9 @@ def _predict_faces(self) -> None: """ faces_seen = 0 consecutive_no_faces = 0 - batch: List[ConvertItem] = [] + batch: T.List[ConvertItem] = [] while True: - item: Union[Literal["EOF"], ConvertItem] = self._in_queue.get() + item: T.Union[Literal["EOF"], ConvertItem] = self._in_queue.get() if item == "EOF": logger.debug("EOF Received") if batch: # Process out any remaining items @@ -938,7 +938,7 @@ def _predict_faces(self) -> None: self._out_queue.put("EOF") logger.debug("Load queue complete") - def _process_batch(self, batch: List[ConvertItem], faces_seen: int): + def _process_batch(self, batch: T.List[ConvertItem], faces_seen: int): """ Predict faces on the given batch of images and queue out to patch thread Parameters @@ -959,9 +959,6 @@ def _process_batch(self, batch: List[ConvertItem], faces_seen: int): if faces_seen != 0: feed_faces = self._compile_feed_faces(feed_batch) batch_size = None - if get_backend() == "amd" and feed_faces.shape[0] != self._batchsize: - logger.verbose("Fallback to BS=1") # type:ignore - batch_size = 1 predicted = self._predict(feed_faces, batch_size) else: predicted = np.array([]) @@ -1004,7 +1001,7 @@ def load_aligned(self, item: ConvertItem) -> None: logger.trace("Loaded aligned faces: '%s'", item.inbound.filename) # type:ignore @staticmethod - def _compile_feed_faces(feed_faces: List[AlignedFace]) -> np.ndarray: + def _compile_feed_faces(feed_faces: T.List[AlignedFace]) -> np.ndarray: """ Compile a batch of faces for feeding into the Predictor. Parameters @@ -1018,12 +1015,12 @@ def _compile_feed_faces(feed_faces: List[AlignedFace]) -> np.ndarray: A batch of faces ready for feeding into the Faceswap model. """ logger.trace("Compiling feed face. Batchsize: %s", len(feed_faces)) # type:ignore - retval = np.stack([cast(np.ndarray, feed_face.face)[..., :3] + retval = np.stack([T.cast(np.ndarray, feed_face.face)[..., :3] for feed_face in feed_faces]) / 255.0 logger.trace("Compiled Feed faces. Shape: %s", retval.shape) # type:ignore return retval - def _predict(self, feed_faces: np.ndarray, batch_size: Optional[int] = None) -> np.ndarray: + def _predict(self, feed_faces: np.ndarray, batch_size: T.Optional[int] = None) -> np.ndarray: """ Run the Faceswap models' prediction function. Parameters @@ -1048,7 +1045,7 @@ def _predict(self, feed_faces: np.ndarray, batch_size: Optional[int] = None) -> logger.trace("Input shape(s): %s", [item.shape for item in feed]) # type:ignore inbound = self._model.model.predict(feed, verbose=0, batch_size=batch_size) - predicted: List[np.ndarray] = inbound if isinstance(inbound, list) else [inbound] + predicted: T.List[np.ndarray] = inbound if isinstance(inbound, list) else [inbound] if self._model.color_order.lower() == "rgb": predicted[0] = predicted[0][..., ::-1] @@ -1065,7 +1062,7 @@ def _predict(self, feed_faces: np.ndarray, batch_size: Optional[int] = None) -> logger.trace("Final shape: %s", retval.shape) # type:ignore return retval - def _queue_out_frames(self, batch: List[ConvertItem], swapped_faces: np.ndarray) -> None: + def _queue_out_frames(self, batch: T.List[ConvertItem], swapped_faces: np.ndarray) -> None: """ Compile the batch back to original frames and put to the Out Queue. For batching, faces are split away from their frames. This compiles all detected faces @@ -1110,8 +1107,8 @@ class OptionalActions(): # pylint:disable=too-few-public-methods The alignments file for this conversion """ def __init__(self, - arguments: "Namespace", - input_images: List[np.ndarray], + arguments: Namespace, + input_images: T.List[np.ndarray], alignments: Alignments) -> None: logger.debug("Initializing %s", self.__class__.__name__) self._args = arguments @@ -1134,7 +1131,7 @@ def _remove_skipped_faces(self) -> None: self._alignments.filter_faces(accept_dict, filter_out=False) logger.info("Faces filtered out: %s", pre_face_count - self._alignments.faces_count) - def _get_face_metadata(self) -> Dict[str, List[int]]: + def _get_face_metadata(self) -> T.Dict[str, T.List[int]]: """ Check for the existence of an aligned directory for identifying which faces in the target frames should be swapped. If it exists, scan the folder for face's metadata @@ -1143,7 +1140,7 @@ def _get_face_metadata(self) -> Dict[str, List[int]]: dict Dictionary of source frame names with a list of associated face indices to be skipped """ - retval: Dict[str, List[int]] = {} + retval: T.Dict[str, T.List[int]] = {} input_aligned_dir = self._args.input_aligned_dir if input_aligned_dir is None: diff --git a/setup.cfg b/setup.cfg index 7f97f73cfc..7dc0260826 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,8 +35,6 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-PIL.*] ignore_missing_imports = True -[mypy-plaidml.*] -ignore_missing_imports = True [mypy-psutil.*] ignore_missing_imports = True [mypy-pynvml.*] diff --git a/setup.py b/setup.py index d77adeb2ad..e711f0d815 100755 --- a/setup.py +++ b/setup.py @@ -60,13 +60,13 @@ class Environment(): setup is running. Default: ``False`` """ - _backends = (("nvidia", "amd", "apple_silicon", "directml", "rocm", "cpu")) + _backends = (("nvidia", "apple_silicon", "directml", "rocm", "cpu")) def __init__(self, updater: bool = False) -> None: self.updater = updater # Flag that setup is being run by installer so steps can be skipped self.is_installer: bool = False - self.backend: Optional[Literal["nvidia", "amd", "apple_silicon", + self.backend: Optional[Literal["nvidia", "apple_silicon", "directml", "cpu", "rocm"]] = None self.enable_docker: bool = False self.cuda_cudnn = ["", ""] @@ -130,7 +130,7 @@ def is_virtualenv(self) -> bool: (hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix)) else: prefix = os.path.dirname(sys.prefix) - retval = (os.path.basename(prefix) == "envs") + retval = os.path.basename(prefix) == "envs" return retval def _process_arguments(self) -> None: @@ -185,10 +185,6 @@ def _check_python(self) -> None: logger.error("Please run this script with Python version 3.7 to 3.9 64bit and try " "again.") sys.exit(1) - if self.backend == "amd" and sys.version_info >= (3, 9): - logger.error("The AMD version of Faceswap cannot be installed on versions of Python " - "higher than 3.8") - sys.exit(1) def _output_runtime_info(self) -> None: """ Output run time info """ @@ -240,10 +236,6 @@ def _set_env_vars(self) -> None: Update the LD_LIBRARY_PATH environment variable when activating a conda environment and revert it when deactivating. - Windows + AMD + Python 3.8: - Add CONDA_DLL_SEARCH_MODIFICATION_ENABLE=1 environment variable to get around a bug which - prevents SciPy from loading in this config: https://github.com/scipy/scipy/issues/14002 - Notes ----- From Tensorflow 2.7, installing Cuda Toolkit from conda-forge and tensorflow from pip @@ -255,10 +247,8 @@ def _set_env_vars(self) -> None: return linux_update = self.os_version[0].lower() == "linux" and self.backend == "nvidia" - windows_update = (self.os_version[0].lower() == "windows" and - self.backend == "amd" and (3, 8) <= sys.version_info < (3, 9)) - if not linux_update and not windows_update: + if not linux_update: return conda_prefix = os.environ["CONDA_PREFIX"] @@ -267,9 +257,8 @@ def _set_env_vars(self) -> None: os.makedirs(activate_folder, exist_ok=True) os.makedirs(deactivate_folder, exist_ok=True) - ext = ".bat" if windows_update else ".sh" - activate_script = os.path.join(conda_prefix, activate_folder, f"env_vars{ext}") - deactivate_script = os.path.join(conda_prefix, deactivate_folder, f"env_vars{ext}") + activate_script = os.path.join(conda_prefix, activate_folder, "env_vars.sh") + deactivate_script = os.path.join(conda_prefix, deactivate_folder, "env_vars.sh") if os.path.isfile(activate_script): # Only create file if it does not already exist. There may be instances where people @@ -277,22 +266,14 @@ def _set_env_vars(self) -> None: # people should already know what they are doing. return - if linux_update: - conda_libs = os.path.join(conda_prefix, "lib") - activate = ["#!/bin/sh\n\n", - "export OLD_LD_LIBRARY_PATH=${LD_LIBRARY_PATH}\n", - f"export LD_LIBRARY_PATH='{conda_libs}':${{LD_LIBRARY_PATH}}\n"] - deactivate = ["#!/bin/sh\n\n", - "export LD_LIBRARY_PATH=${OLD_LD_LIBRARY_PATH}\n", - "unset OLD_LD_LIBRARY_PATH\n"] - logger.info("Cuda search path set to '%s'", conda_libs) - - if windows_update: - activate = ["@ECHO OFF\n", - "set CONDA_DLL_SEARCH_MODIFICATION_ENABLE=1\n"] - deactivate = ["@ECHO OFF\n", - "set CONDA_DLL_SEARCH_MODIFICATION_ENABLE=\n"] - logger.verbose("CONDA_DLL_SEARCH_MODIFICATION_ENABLE set to 1") # type: ignore + conda_libs = os.path.join(conda_prefix, "lib") + activate = ["#!/bin/sh\n\n", + "export OLD_LD_LIBRARY_PATH=${LD_LIBRARY_PATH}\n", + f"export LD_LIBRARY_PATH='{conda_libs}':${{LD_LIBRARY_PATH}}\n"] + deactivate = ["#!/bin/sh\n\n", + "export LD_LIBRARY_PATH=${OLD_LD_LIBRARY_PATH}\n", + "unset OLD_LD_LIBRARY_PATH\n"] + logger.info("Cuda search path set to '%s'", conda_libs) with open(activate_script, "w", encoding="utf8") as afile: afile.writelines(activate) @@ -628,28 +609,10 @@ def _directml_ask_enable(self) -> None: logger.info("DirectML Support Enabled") self._env.backend = "directml" - def _amd_ask_enable(self) -> None: - """ Set backend to 'amd' to use plaidML if AMD support required """ - msg = "" - if self._env.os_version[0] == "Windows": - msg = "AMD users should select 'DirectML support' if possible.\r\n" - if self._env.os_version[0] == "Linux": - msg = "AMD users should select 'ROCm support' if possible.\r\n" - - logger.info("AMD Support:\r\nThis version is deprecated and will be removed from a future " - "update.\r\n%s" - "Nvidia Users MUST answer 'no' to this option.", msg) - i = input("Enable AMD Support? [y/N] ") - if i in ("Y", "y"): - logger.info("AMD Support Enabled") - self._env.backend = "amd" - def _user_input(self) -> None: """ Get user input for AMD/DirectML/ROCm/Cuda/Docker """ self._directml_ask_enable() self._rocm_ask_enable() - if not self._env.backend: - self._amd_ask_enable() if not self._env.backend: self._docker_ask_enable() self._cuda_ask_enable() diff --git a/tests/lib/gui/stats/event_reader_test.py b/tests/lib/gui/stats/event_reader_test.py index ad1a4894ce..960e3c6b5a 100644 --- a/tests/lib/gui/stats/event_reader_test.py +++ b/tests/lib/gui/stats/event_reader_test.py @@ -624,11 +624,10 @@ def test_cache_events(self, monkeypatch: :class:`pytest.MonkeyPatch` For patching different iterators for testing output """ - monkeypatch.setattr("lib.utils._FS_BACKEND", "cpu") # We'll test AMD separately + monkeypatch.setattr("lib.utils._FS_BACKEND", "cpu") event_parse = event_parser_instance event_parse._parse_outputs = cast(MagicMock, mocker.MagicMock()) # type:ignore - event_parse._add_amd_loss_labels = cast(MagicMock, mocker.MagicMock()) # type:ignore event_parse._process_event = cast(MagicMock, mocker.MagicMock()) # type:ignore event_parse._cache.cache_data = cast(MagicMock, mocker.MagicMock()) # type:ignore @@ -638,11 +637,9 @@ def test_cache_events(self, iter([self._create_example_event(0, 1., time())])) event_parse.cache_events(1) assert event_parse._parse_outputs.called - assert not event_parse._add_amd_loss_labels.called assert not event_parse._process_event.called assert event_parse._cache.cache_data.called event_parse._parse_outputs.reset_mock() - event_parse._add_amd_loss_labels.reset_mock() event_parse._process_event.reset_mock() event_parse._cache.cache_data.reset_mock() @@ -652,11 +649,9 @@ def test_cache_events(self, iter([self._create_example_event(1, 1., time())])) event_parse.cache_events(1) assert not event_parse._parse_outputs.called - assert not event_parse._add_amd_loss_labels.called assert event_parse._process_event.called assert event_parse._cache.cache_data.called event_parse._parse_outputs.reset_mock() - event_parse._add_amd_loss_labels.reset_mock() event_parse._process_event.reset_mock() event_parse._cache.cache_data.reset_mock() @@ -665,25 +660,12 @@ def test_cache_events(self, "_iterator", iter([event_pb2.Event(step=1).SerializeToString()])) assert not event_parse._parse_outputs.called - assert not event_parse._add_amd_loss_labels.called assert not event_parse._process_event.called assert not event_parse._cache.cache_data.called event_parse._parse_outputs.reset_mock() - event_parse._add_amd_loss_labels.reset_mock() event_parse._process_event.reset_mock() event_parse._cache.cache_data.reset_mock() - # AMD + batch item 2 - monkeypatch.setattr("lib.utils._FS_BACKEND", "amd") - monkeypatch.setattr(event_parse, - "_iterator", - iter([self._create_example_event(2, 1., time())])) - event_parse.cache_events(1) - assert not event_parse._parse_outputs.called - assert event_parse._add_amd_loss_labels.called - assert event_parse._process_event.called - assert event_parse._cache.cache_data.called - def test__parse_outputs(self, event_parser_instance: _EventParser, mocker: pytest_mock.MockerFixture) -> None: @@ -729,34 +711,6 @@ def test__get_outputs(self, event_parser_instance: _EventParser) -> None: assert actual.shape == (2, 1, 3) np.testing.assert_equal(expected, actual) - def test__add_amd_loss_labels(self, - event_parser_instance: _EventParser, - mocker: pytest_mock.MockerFixture) -> None: - """ Test _add_amd_loss_labels works correctly - - Parameters - ---------- - event_parser_instance: :class:`lib.gui.analysis.event_reader._EventParser` - The class instance to test - mocker: :class:`pytest_mock.MockerFixture` - Mocker for checking Session data - """ - event_parse = event_parser_instance - - # Already collected - assert not event_parse._cache._loss_labels - event_parse._cache._loss_labels.extend(["label_a", "label_b"]) - event_parse._add_amd_loss_labels(1) - assert not event_parse._loss_labels - - # New labels - event_parse._cache._loss_labels = [] - mock_session = mocker.patch("lib.gui.analysis.Session") - mock_session.get_loss_keys.return_value = ["label_c", "label_d"] - assert not event_parse._cache._loss_labels - event_parse._add_amd_loss_labels(1) - assert event_parse._loss_labels == ["label_c", "label_d"] - def test__process_event(self, event_parser_instance: _EventParser) -> None: """ Test _process_event works correctly diff --git a/tests/lib/model/initializers_test.py b/tests/lib/model/initializers_test.py index 2e4921586c..ff52a3369c 100644 --- a/tests/lib/model/initializers_test.py +++ b/tests/lib/model/initializers_test.py @@ -7,18 +7,12 @@ import pytest import numpy as np +from tensorflow.keras import backend as K # pylint:disable=import-error +from tensorflow.keras import initializers as k_initializers # noqa:E501 # pylint:disable=import-error + from lib.model import initializers from lib.utils import get_backend -if get_backend() == "amd": - from keras import backend as K - from keras import initializers as k_initializers -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras import backend as K # pylint:disable=import-error - from tensorflow.keras import initializers as k_initializers # pylint:disable=import-error - - CONV_SHAPE = (3, 3, 256, 2048) CONV_ID = get_backend().upper() @@ -49,8 +43,11 @@ def test_icnr(tensor_shape): """ fan_in, _ = initializers.compute_fans(tensor_shape) std = np.sqrt(2. / fan_in) - _runner(initializers.ICNR(initializer=k_initializers.he_uniform(), scale=2), tensor_shape, - target_mean=0, target_std=std) + _runner(initializers.ICNR(initializer=k_initializers.he_uniform(), # pylint:disable=no-member + scale=2), + tensor_shape, + target_mean=0, + target_std=std) @pytest.mark.parametrize('tensor_shape', [CONV_SHAPE], ids=[CONV_ID]) diff --git a/tests/lib/model/layers_test.py b/tests/lib/model/layers_test.py index 4b83708dfe..a6beb6310a 100644 --- a/tests/lib/model/layers_test.py +++ b/tests/lib/model/layers_test.py @@ -10,17 +10,13 @@ from numpy.testing import assert_allclose +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras import Input, Model, backend as K # pylint:disable=import-error + from lib.model import layers from lib.utils import get_backend from tests.utils import has_arg -if get_backend() == "amd": - from keras import Input, Model, backend as K -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras import Input, Model, backend as K # pylint:disable=import-error - - CONV_SHAPE = (3, 3, 256, 2048) CONV_ID = get_backend().upper() @@ -40,7 +36,7 @@ def layer_test(layer_cls, kwargs={}, input_shape=None, input_dtype=None, # noqa for i, var_e in enumerate(input_data_shape): if var_e is None: input_data_shape[i] = np.random.randint(1, 4) - input_data = (10 * np.random.random(input_data_shape)) + input_data = 10 * np.random.random(input_data_shape) input_data = input_data.astype(input_dtype) else: if input_shape is None: @@ -111,7 +107,6 @@ def test_pixel_shuffler(dummy): # pylint:disable=unused-argument layer_test(layers.PixelShuffler, input_shape=(2, 4, 4, 1024)) -@pytest.mark.skipif(get_backend() == "amd", reason="amd does not support this layer") @pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) def test_subpixel_upscaling(dummy): # pylint:disable=unused-argument """ Sub Pixel up-scaling layer test """ diff --git a/tests/lib/model/losses_test.py b/tests/lib/model/losses_test.py index 3d9d65ad11..b1b77c6bfe 100644 --- a/tests/lib/model/losses_test.py +++ b/tests/lib/model/losses_test.py @@ -7,15 +7,12 @@ import pytest import numpy as np -from lib.model import losses -from lib.utils import get_backend +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras import backend as K, losses as k_losses # noqa:E501 # pylint:disable=import-error -if get_backend() == "amd": - from keras import backend as K, losses as k_losses -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras import backend as K, losses as k_losses # pylint:disable=import-error +from lib.model import losses +from lib.utils import get_backend _PARAMS = [(losses.GeneralizedLoss(), (2, 16, 16)), (losses.GradientLoss(), (2, 16, 16)), @@ -33,11 +30,8 @@ def test_loss_output(loss_func, output_shape): y_a = K.variable(np.random.random((2, 16, 16, 3))) y_b = K.variable(np.random.random((2, 16, 16, 3))) objective_output = loss_func(y_a, y_b) - if get_backend() == "amd": - assert K.eval(objective_output).shape == output_shape - else: - output = objective_output.numpy() - assert output.dtype == "float32" and not np.any(np.isnan(output)) + output = objective_output.numpy() + assert output.dtype == "float32" and not np.any(np.isnan(output)) _LWPARAMS = [losses.DSSIMObjective(), @@ -48,7 +42,7 @@ def test_loss_output(loss_func, output_shape): losses.LaplacianPyramidLoss(), losses.LDRFLIPLoss(), losses.LInfNorm(), - losses.LogCosh() if get_backend() == "amd" else k_losses.logcosh, + k_losses.logcosh, # pylint:disable=no-member k_losses.mean_absolute_error, k_losses.mean_squared_error, losses.MSSIMLoss()] @@ -60,17 +54,11 @@ def test_loss_output(loss_func, output_shape): @pytest.mark.parametrize("loss_func", _LWPARAMS, ids=_LWIDS) def test_loss_wrapper(loss_func): """ Test penalized loss wrapper works as expected """ - if get_backend() == "amd": - if isinstance(loss_func, losses.FocalFrequencyLoss): - pytest.skip("FocalFrequencyLoss Loss is not currently compatible with PlaidML") y_a = K.variable(np.random.random((2, 64, 64, 4))) y_b = K.variable(np.random.random((2, 64, 64, 3))) p_loss = losses.LossWrapper() p_loss.add_loss(loss_func, 1.0, -1) p_loss.add_loss(k_losses.mean_squared_error, 2.0, 3) output = p_loss(y_a, y_b) - if get_backend() == "amd": - assert K.dtype(output) == "float32" and K.eval(output).shape == (2, ) - else: - output = output.numpy() - assert output.dtype == "float32" and not np.any(np.isnan(output)) + output = output.numpy() + assert output.dtype == "float32" and not np.any(np.isnan(output)) diff --git a/tests/lib/model/nn_blocks_test.py b/tests/lib/model/nn_blocks_test.py index 7244e855ae..4793b95ea7 100644 --- a/tests/lib/model/nn_blocks_test.py +++ b/tests/lib/model/nn_blocks_test.py @@ -9,25 +9,17 @@ import pytest import numpy as np - from numpy.testing import assert_allclose +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras import Input, Model, backend as K # pylint:disable=import-error + from lib.model import nn_blocks from lib.utils import get_backend -if get_backend() == "amd": - from keras import Input, Model, backend as K -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras import Input, Model, backend as K # pylint:disable=import-error - def block_test(layer_func, kwargs={}, input_shape=None): - """Test routine for faceswap neural network blocks. - - Tests are simple and are to ensure that the blocks compile on both tensorflow - and plaidml backends - """ + """Test routine for faceswap neural network blocks. """ # generate input data assert input_shape input_dtype = K.floatx() @@ -35,7 +27,7 @@ def block_test(layer_func, kwargs={}, input_shape=None): for i, var_e in enumerate(input_data_shape): if var_e is None: input_data_shape[i] = np.random.randint(1, 4) - input_data = (10 * np.random.random(input_data_shape)) + input_data = 10 * np.random.random(input_data_shape) input_data = input_data.astype(input_dtype) expected_output_dtype = input_dtype @@ -64,16 +56,16 @@ def block_test(layer_func, kwargs={}, input_shape=None): _PARAMS = ["use_icnr_init", "use_convaware_init", "use_reflect_padding"] _VALUES = list(product([True, False], repeat=len(_PARAMS))) -_IDS = ["{}[{}]".format("|".join([_PARAMS[idx] for idx, b in enumerate(v) if b]), - get_backend().upper()) for v in _VALUES] +_IDS = [f"{'|'.join([_PARAMS[idx] for idx, b in enumerate(v) if b])}[{get_backend().upper()}]" + for v in _VALUES] @pytest.mark.parametrize(_PARAMS, _VALUES, ids=_IDS) def test_blocks(use_icnr_init, use_convaware_init, use_reflect_padding): """ Test for all blocks contained within the NNBlocks Class """ - config = dict(icnr_init=use_icnr_init, - conv_aware_init=use_convaware_init, - reflect_padding=use_reflect_padding) + config = {"icnr_init": use_icnr_init, + "conv_aware_init": use_convaware_init, + "reflect_padding": use_reflect_padding} nn_blocks.set_config(config) block_test(nn_blocks.Conv2DOutput(64, 3), input_shape=(2, 8, 8, 32)) block_test(nn_blocks.Conv2DBlock(64), input_shape=(2, 8, 8, 32)) diff --git a/tests/lib/model/normalization_test.py b/tests/lib/model/normalization_test.py index 925088cc61..c447c6e480 100644 --- a/tests/lib/model/normalization_test.py +++ b/tests/lib/model/normalization_test.py @@ -8,17 +8,13 @@ import numpy as np import pytest +from tensorflow.keras import regularizers, models, layers # noqa:E501 # pylint:disable=import-error + from lib.model import normalization from lib.utils import get_backend from tests.lib.model.layers_test import layer_test -if get_backend() == "amd": - from keras import regularizers, models, layers -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras import regularizers, models, layers # pylint:disable=import-error - @pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) def test_instance_normalization(dummy): # pylint:disable=unused-argument @@ -69,8 +65,8 @@ def test_group_normalization(dummy): # pylint:disable=unused-argument _PARAMS = ["center", "scale"] _VALUES = list(product([True, False], repeat=len(_PARAMS))) -_IDS = ["{}[{}]".format("|".join([_PARAMS[idx] for idx, b in enumerate(v) if b]), - get_backend().upper()) for v in _VALUES] +_IDS = [f"{'|'.join([_PARAMS[idx] for idx, b in enumerate(v) if b])}[{get_backend().upper()}]" + for v in _VALUES] @pytest.mark.parametrize(_PARAMS, _VALUES, ids=_IDS) @@ -95,14 +91,6 @@ def test_adain_normalization(center, scale): assert expected_dim == actual_dim -@pytest.mark.parametrize(_PARAMS, _VALUES, ids=_IDS) -def test_layer_normalization(center, scale): - """ Basic test for layer normalization. """ - layer_test(normalization.LayerNormalization, - kwargs={"center": center, "scale": scale}, - input_shape=(4, 512)) - - _PARAMS = ["partial", "bias"] _VALUES = [(0.0, False), (0.25, False), (0.5, True), (0.75, False), (1.0, True)] # type:ignore _IDS = [f"partial={v[0]}|bias={v[1]}[{get_backend().upper()}]" for v in _VALUES] diff --git a/tests/lib/model/optimizers_test.py b/tests/lib/model/optimizers_test.py index c86aae5918..3a34a60503 100644 --- a/tests/lib/model/optimizers_test.py +++ b/tests/lib/model/optimizers_test.py @@ -7,22 +7,16 @@ import numpy as np from numpy.testing import assert_allclose +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow.keras import optimizers as k_optimizers # pylint:disable=import-error +from tensorflow.keras.layers import Dense, Activation # pylint:disable=import-error +from tensorflow.keras.models import Sequential # pylint:disable=import-error from lib.model import optimizers from lib.utils import get_backend from tests.utils import generate_test_data, to_categorical -if get_backend() == "amd": - from keras import optimizers as k_optimizers - from keras.layers import Dense, Activation - from keras.models import Sequential -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow.keras import optimizers as k_optimizers # pylint:disable=import-error - from tensorflow.keras.layers import Dense, Activation # noqa pylint:disable=import-error,no-name-in-module - from tensorflow.keras.models import Sequential # pylint:disable=import-error,no-name-in-module - def get_test_data(): """ Obtain randomized test data for training """ @@ -49,8 +43,7 @@ def _test_optimizer(optimizer, target=0.75): metrics=["accuracy"]) history = model.fit(x_train, y_train, epochs=2, batch_size=16, verbose=0) - accuracy = "acc" if get_backend() == "amd" else "accuracy" - assert history.history[accuracy][-1] >= target + assert history.history["accuracy"][-1] >= target config = k_optimizers.serialize(optimizer) optim = k_optimizers.deserialize(config) new_config = k_optimizers.serialize(optim) @@ -59,9 +52,6 @@ def _test_optimizer(optimizer, target=0.75): assert config == new_config # Test constraints. - if get_backend() == "amd": - # NB: PlaidML does not support constraints, so this test skipped for AMD backends - return model = Sequential() dense = Dense(10, input_shape=(x_train.shape[1],), @@ -83,8 +73,8 @@ def _test_optimizer(optimizer, target=0.75): @pytest.mark.parametrize("dummy", [None], ids=[get_backend().upper()]) def test_adam(dummy): # pylint:disable=unused-argument """ Test for custom Adam optimizer """ - _test_optimizer(k_optimizers.Adam(), target=0.45) - _test_optimizer(k_optimizers.Adam(decay=1e-3), target=0.45) + _test_optimizer(k_optimizers.Adam(), target=0.45) # pylint:disable=no-member + _test_optimizer(k_optimizers.Adam(decay=1e-3), target=0.45) # pylint:disable=no-member @pytest.mark.parametrize("dummy", [None], ids=[get_backend().upper()]) diff --git a/tests/lib/sysinfo_test.py b/tests/lib/sysinfo_test.py index 7f292841bb..5cdce9773c 100644 --- a/tests/lib/sysinfo_test.py +++ b/tests/lib/sysinfo_test.py @@ -51,17 +51,17 @@ def test_init(sys_info_instance: _SysInfo) -> None: assert hasattr(sys_info_instance, "_system") assert isinstance(sys_info_instance._system, dict) - assert sys_info_instance._system == dict(platform=platform.platform(), - system=platform.system().lower(), - machine=platform.machine(), - release=platform.release(), - processor=platform.processor(), - cpu_count=os.cpu_count()) + assert sys_info_instance._system == {"platform": platform.platform(), + "system": platform.system().lower(), + "machine": platform.machine(), + "release": platform.release(), + "processor": platform.processor(), + "cpu_count": os.cpu_count()} assert hasattr(sys_info_instance, "_python") assert isinstance(sys_info_instance._python, dict) - assert sys_info_instance._python == dict(implementation=platform.python_implementation(), - version=platform.python_version()) + assert sys_info_instance._python == {"implementation": platform.python_implementation(), + "version": platform.python_version()} assert hasattr(sys_info_instance, "_gpu") assert isinstance(sys_info_instance._gpu, GPUInfo) @@ -302,7 +302,7 @@ def test__configs__parse_json(configs_instance: _Configs, """ assert hasattr(configs_instance, "_parse_json") - file = ('{"test": "param"}') + file = '{"test": "param"}' monkeypatch.setattr("builtins.open", lambda *args, **kwargs: StringIO(file)) converted = configs_instance._parse_json(".file") diff --git a/tests/lib/utils_test.py b/tests/lib/utils_test.py index a6f8e01fe3..092c3a5f1e 100644 --- a/tests/lib/utils_test.py +++ b/tests/lib/utils_test.py @@ -42,8 +42,8 @@ def test_set_backend(monkeypatch: pytest.MonkeyPatch) -> None: set_backend("directml") assert utils._FS_BACKEND == "directml" monkeypatch.delattr(utils, "_FS_BACKEND") # _FS_BACKEND is not already defined - set_backend("amd") - assert utils._FS_BACKEND == "amd" + set_backend("rocm") + assert utils._FS_BACKEND == "rocm" def test_get_backend(monkeypatch: pytest.MonkeyPatch) -> None: @@ -73,9 +73,9 @@ def test__backend(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("os.environ", {}) # Environment variable not set, dummy in config file monkeypatch.setattr("os.path.isfile", lambda x: True) - monkeypatch.setattr("builtins.open", lambda *args, **kwargs: StringIO('{"backend": "amd"}')) + monkeypatch.setattr("builtins.open", lambda *args, **kwargs: StringIO('{"backend": "cpu"}')) backend = _Backend() - assert backend.backend == "amd" + assert backend.backend == "cpu" monkeypatch.setattr("os.path.isfile", lambda x: False) # no config file, dummy in user input monkeypatch.setattr("builtins.input", lambda x: "3") diff --git a/tests/simple_tests.py b/tests/simple_tests.py index cd0e547b59..2b3be20887 100644 --- a/tests/simple_tests.py +++ b/tests/simple_tests.py @@ -14,7 +14,6 @@ import os from os.path import join as pathjoin, expanduser -_TRAIN_ARGS = (1, 1) if os.environ.get("FACESWAP_BACKEND", "cpu").lower() == "amd" else (4, 4) FAIL_COUNT = 0 TEST_COUNT = 0 _COLORS = { @@ -202,8 +201,8 @@ def main(): train_args("lightweight", pathjoin(vid_base, "model"), pathjoin(vid_base, "faces"), - iterations=_TRAIN_ARGS[0], - batchsize=_TRAIN_ARGS[1], + iterations=1, + batchsize=1, extra_args="-wl")) set_train_config(False) @@ -212,8 +211,8 @@ def main(): train_args("lightweight", pathjoin(vid_base, "model"), pathjoin(vid_base, "faces"), - iterations=_TRAIN_ARGS[0], - batchsize=_TRAIN_ARGS[1], + iterations=1, + batchsize=1, extra_args="-wl")) if was_trained: diff --git a/tests/startup_test.py b/tests/startup_test.py index 8f7eb5d0e1..704a96edcf 100644 --- a/tests/startup_test.py +++ b/tests/startup_test.py @@ -2,19 +2,13 @@ """ Sanity checks for Faceswap. """ import inspect - import pytest -from lib.utils import get_backend - -if get_backend() == "amd": - import keras - from keras import backend as K -else: - # Ignore linting errors from Tensorflow's thoroughly broken import system - from tensorflow import keras - from tensorflow.keras import backend as K # pylint:disable=import-error +# Ignore linting errors from Tensorflow's thoroughly broken import system +from tensorflow import keras +from tensorflow.keras import backend as K # pylint:disable=import-error +from lib.utils import get_backend _BACKEND = get_backend() @@ -24,14 +18,11 @@ def test_backend(dummy): # pylint:disable=unused-argument """ Sanity check to ensure that Keras backend is returning the correct object type. """ test_var = K.variable((1, 1, 4, 4)) lib = inspect.getmodule(test_var).__name__.split(".")[0] - assert ((_BACKEND in ("cpu", "directml") and lib == "tensorflow") - or (_BACKEND == "amd" and lib == "plaidml")) + assert _BACKEND in ("cpu", "directml") and lib == "tensorflow" @pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) def test_keras(dummy): # pylint:disable=unused-argument - """ Sanity check to ensure that tensorflow keras is being used for CPU and standard - keras for AMD. """ - assert ((_BACKEND in ("cpu", "directml") - and keras.__version__ in ("2.7.0", "2.8.0", "2.9.0", "2.10.0")) - or (_BACKEND == "amd" and keras.__version__ == "2.2.4")) + """ Sanity check to ensure that tensorflow keras is being used for CPU """ + assert (_BACKEND in ("cpu", "directml") + and keras.__version__ in ("2.7.0", "2.8.0", "2.9.0", "2.10.0")) diff --git a/tests/utils.py b/tests/utils.py index 248ec0a25b..26379587cd 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -101,25 +101,19 @@ def has_arg(func, name, accept_all=False): bool Whether `func` accepts a `name` keyword argument. """ - if sys.version_info < (3,): - arg_spec = inspect.getargspec(func) - if accept_all and arg_spec.keywords is not None: - return True - return (name in arg_spec.args) - elif sys.version_info < (3, 3): + if sys.version_info < (3, 3): arg_spec = inspect.getfullargspec(func) if accept_all and arg_spec.varkw is not None: return True return (name in arg_spec.args or name in arg_spec.kwonlyargs) - else: - signature = inspect.signature(func) - parameter = signature.parameters.get(name) - if parameter is None: - if accept_all: - for param in signature.parameters.values(): - if param.kind == inspect.Parameter.VAR_KEYWORD: - return True - return False - return (parameter.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, - inspect.Parameter.KEYWORD_ONLY)) + signature = inspect.signature(func) + parameter = signature.parameters.get(name) + if parameter is None: + if accept_all: + for param in signature.parameters.values(): + if param.kind == inspect.Parameter.VAR_KEYWORD: + return True + return False + return (parameter.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY)) diff --git a/tools/model/model.py b/tools/model/model.py index 99d49a02d5..f8ec6da26d 100644 --- a/tools/model/model.py +++ b/tools/model/model.py @@ -1,28 +1,23 @@ #!/usr/bin/env python3 """ Tool to restore models from backup """ - +from __future__ import annotations import logging import os import sys -from typing import Any, Tuple, TYPE_CHECKING, Union +import typing as T import numpy as np import tensorflow as tf +from tensorflow import keras from lib.model.backup_restore import Backup -from lib.utils import get_backend # Import the following libs for custom objects from lib.model import initializers, layers, normalization # noqa # pylint:disable=unused-import from plugins.train.model._base.model import _Inference -if get_backend() == "amd": - import keras -else: - from tensorflow import keras - -if TYPE_CHECKING: +if T.TYPE_CHECKING: import argparse logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -36,7 +31,7 @@ class Model(): # pylint:disable=too-few-public-methods :class:`argparse.Namespace` The command line arguments calling the model tool """ - def __init__(self, arguments: 'argparse.Namespace') -> None: + def __init__(self, arguments: argparse.Namespace) -> None: logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) self._configure_tensorflow() self._model_dir = self._check_folder(arguments.model_dir) @@ -45,13 +40,11 @@ def __init__(self, arguments: 'argparse.Namespace') -> None: @classmethod def _configure_tensorflow(cls) -> None: """ Disable eager execution and force Tensorflow into CPU mode. """ - if get_backend() == "amd": - return tf.config.set_visible_devices([], device_type="GPU") tf.compat.v1.disable_eager_execution() @classmethod - def _get_job(cls, arguments: "argparse.Namespace") -> Any: + def _get_job(cls, arguments: argparse.Namespace) -> T.Any: """ Get the correct object that holds the selected job. Parameters @@ -120,12 +113,12 @@ class Inference(): # pylint:disable=too-few-public-methods :class:`argparse.Namespace` The command line arguments calling the model tool """ - def __init__(self, arguments: "argparse.Namespace") -> None: + def __init__(self, arguments: argparse.Namespace) -> None: self._switch = arguments.swap_model self._format = arguments.format self._input_file, self._output_file = self._get_output_file(arguments.model_dir) - def _get_output_file(self, model_dir: str) -> Tuple[str, str]: + def _get_output_file(self, model_dir: str) -> T.Tuple[str, str]: """ Obtain the full path for the output model file/folder Parameters @@ -168,7 +161,7 @@ class NaNScan(): # pylint:disable=too-few-public-methods :class:`argparse.Namespace` The command line arguments calling the model tool """ - def __init__(self, arguments: "argparse.Namespace") -> None: + def __init__(self, arguments: argparse.Namespace) -> None: logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) self._model_file = self._get_model_filename(arguments.model_dir) @@ -190,7 +183,7 @@ def _get_model_filename(cls, model_dir: str) -> str: return os.path.join(model_dir, model_file) def _parse_weights(self, - layer: Union[keras.models.Model, keras.layers.Layer]) -> dict: + layer: T.Union[keras.models.Model, keras.layers.Layer]) -> dict: """ Recursively pass through sub-models to scan layer weights""" weights = layer.get_weights() logger.debug("Processing weights for layer '%s', length: '%s'", @@ -214,7 +207,7 @@ def _parse_weights(self, if nans + infs == 0: return {} - return dict(nans=nans, infs=infs) + return {"nans": nans, "infs": infs} def _parse_output(self, errors: dict, indent: int = 0) -> None: """ Parse the output of the errors dictionary and print a pretty summary. @@ -260,7 +253,7 @@ class Restore(): # pylint:disable=too-few-public-methods :class:`argparse.Namespace` The command line arguments calling the model tool """ - def __init__(self, arguments: "argparse.Namespace") -> None: + def __init__(self, arguments: argparse.Namespace) -> None: logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) self._model_dir = arguments.model_dir self._model_name = self._get_model_name() From c3f38bf80ba386ea0df7045cdd00385089983e0d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 21 Jun 2023 17:41:25 +0100 Subject: [PATCH 825/981] bugfix: Windows installer - venv creation --- .install/windows/install.nsi | 1 + 1 file changed, 1 insertion(+) diff --git a/.install/windows/install.nsi b/.install/windows/install.nsi index 1a7a54610e..d70ddb5b11 100644 --- a/.install/windows/install.nsi +++ b/.install/windows/install.nsi @@ -397,6 +397,7 @@ Function SetEnvironment CreateEnv: SetDetailsPrint listonly + StrCpy $0 "${flagsEnv}" ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda create $0 -n $\"$envName$\" && conda deactivate" pop $0 ExecDos::wait $0 From a903a558378563251dca49a2973c36a2e5cf3cc5 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 21 Jun 2023 22:45:40 +0100 Subject: [PATCH 826/981] bugfix: Correctly read event data in GUI --- lib/gui/analysis/event_reader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index a99b6a7887..c2376b3b25 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -789,7 +789,7 @@ def _process_event(cls, event: event_pb2.Event, step: EventData) -> EventData: """ summary = event.summary.value[0] - if summary.tag == "batch_loss": + if summary.tag == "batch_total": step.timestamp = event.wall_time return step From e4ba12ad2a8e4d5dc6d56b9ec256ca474a58cadf Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 21 Jun 2023 22:51:59 +0100 Subject: [PATCH 827/981] tests: Fix event-reader test --- tests/lib/gui/stats/event_reader_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/lib/gui/stats/event_reader_test.py b/tests/lib/gui/stats/event_reader_test.py index 960e3c6b5a..618d1e226f 100644 --- a/tests/lib/gui/stats/event_reader_test.py +++ b/tests/lib/gui/stats/event_reader_test.py @@ -518,7 +518,7 @@ def _create_example_event(self, serialize: bool, optional ``True`` to serialize the event to bytes, ``False`` to return the Event object """ - tags = {0: "keras", 1: "batch_loss", 2: "batch_face_a", 3: "batch_face_b"} + tags = {0: "keras", 1: "batch_total", 2: "batch_face_a", 3: "batch_face_b"} event = event_pb2.Event(step=step) event.summary.value.add(tag=tags[step], # pylint:disable=no-member simple_value=loss_value) From 6a3b674bef8b585347a51ffdd8765d0a1eaa15fc Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 27 Jun 2023 11:27:47 +0100 Subject: [PATCH 828/981] Rebase code (#1326) * Remove tensorflow_probability requirement * setup.py - fix progress bars * requirements.txt: Remove pre python 3.9 packages * update apple requirements.txt * update INSTALL.md * Remove python<3.9 code * setup.py - fix Windows Installer * typing: python3.9 compliant * Update pytest and readthedocs python versions * typing fixes * Python Version updates - Reduce max version to 3.10 - Default to 3.10 in installers - Remove incompatible 3.11 tests * Update dependencies * Downgrade imageio dep for Windows * typing: merge optional unions and fixes * Updates - min python version 3.10 - typing to python 3.10 spec - remove pre-tf2.10 code - Add conda tests * train: re-enable optimizer saving * Update dockerfiles * Update setup.py - Apple Conda deps to setup.py - Better Cuda + dependency handling * bugfix: Patch logging to prevent Autograph errors * Update dockerfiles * Setup.py - Setup.py - stdout to utf-8 * Add more OSes to github Actions * suppress mac-os end to end test --- .github/workflows/pytest.yml | 87 ++- .install/linux/faceswap_setup_x64.sh | 4 +- .install/windows/install.nsi | 2 +- .readthedocs.yml | 4 +- Dockerfile.cpu | 24 +- Dockerfile.gpu | 42 +- INSTALL.md | 223 +++---- docs/conf.py | 2 +- docs/sphinx_requirements.txt | 36 +- faceswap.py | 5 +- lib/align/aligned_face.py | 364 ++++++----- lib/align/alignments.py | 114 ++-- lib/align/detected_face.py | 116 ++-- lib/cli/actions.py | 18 +- lib/cli/args.py | 41 +- lib/cli/launcher.py | 22 +- lib/config.py | 54 +- lib/convert.py | 61 +- lib/gpu_stats/_base.py | 33 +- lib/gpu_stats/apple_silicon.py | 10 +- lib/gpu_stats/cpu.py | 16 +- lib/gpu_stats/directml.py | 36 +- lib/gpu_stats/nvidia.py | 9 +- lib/gpu_stats/nvidia_apple.py | 8 +- lib/gpu_stats/rocm.py | 15 +- lib/gui/analysis/event_reader.py | 84 ++- lib/gui/analysis/stats.py | 79 +-- lib/gui/control_helper.py | 64 +- lib/gui/display_command.py | 18 +- lib/gui/display_graph.py | 45 +- lib/gui/menu.py | 12 +- lib/gui/popup_configure.py | 21 +- lib/gui/popup_session.py | 63 +- lib/gui/utils/config.py | 63 +- lib/gui/utils/file_handler.py | 171 +++-- lib/gui/utils/image.py | 62 +- lib/gui/utils/misc.py | 26 +- lib/image.py | 16 +- lib/keras_utils.py | 2 +- lib/logger.py | 24 +- lib/model/autoclip.py | 55 +- lib/model/losses/feature_loss.py | 21 +- lib/model/losses/loss.py | 20 +- lib/model/losses/perceptual_loss.py | 23 +- lib/model/nets.py | 8 +- lib/model/nn_blocks.py | 36 +- lib/model/session.py | 19 +- lib/multithreading.py | 39 +- lib/queue_manager.py | 3 +- lib/sysinfo.py | 24 +- lib/training/__init__.py | 8 +- lib/training/augmentation.py | 11 +- lib/training/cache.py | 60 +- lib/training/generator.py | 108 ++-- lib/training/preview_cv.py | 38 +- lib/training/preview_tk.py | 47 +- lib/utils.py | 53 +- locales/plugins.train._config.pot | 118 ++-- .../ru/LC_MESSAGES/plugins.train._config.mo | Bin 53422 -> 56243 bytes .../ru/LC_MESSAGES/plugins.train._config.po | 107 ++-- plugins/convert/mask/mask_blend.py | 29 +- plugins/convert/writer/_base.py | 13 +- plugins/convert/writer/ffmpeg.py | 31 +- plugins/convert/writer/gif.py | 22 +- plugins/convert/writer/opencv.py | 10 +- plugins/convert/writer/pillow.py | 10 +- plugins/extract/_base.py | 87 ++- plugins/extract/align/_base/aligner.py | 50 +- plugins/extract/align/_base/processing.py | 68 +- plugins/extract/align/cv2_dnn.py | 33 +- plugins/extract/align/fan.py | 21 +- plugins/extract/detect/_base.py | 57 +- plugins/extract/detect/mtcnn.py | 50 +- plugins/extract/detect/s3fd.py | 20 +- plugins/extract/mask/_base.py | 23 +- plugins/extract/mask/bisenet_fp.py | 10 +- plugins/extract/mask/components.py | 9 +- plugins/extract/mask/extended.py | 9 +- plugins/extract/mask/unet_dfl.py | 4 +- plugins/extract/mask/vgg_clear.py | 6 +- plugins/extract/mask/vgg_obstructed.py | 4 +- plugins/extract/pipeline.py | 158 +++-- plugins/extract/recognition/_base.py | 46 +- plugins/extract/recognition/vgg_face2.py | 30 +- plugins/plugin_loader.py | 34 +- plugins/train/_config.py | 25 + plugins/train/model/_base/io.py | 25 +- plugins/train/model/_base/model.py | 69 +- plugins/train/model/_base/settings.py | 56 +- plugins/train/model/phaze_a.py | 74 +-- plugins/train/model/phaze_a_defaults.py | 4 +- plugins/train/trainer/_base.py | 168 +++-- requirements/_requirements_base.txt | 25 +- requirements/requirements_apple_silicon.txt | 12 +- requirements/requirements_cpu.txt | 5 +- requirements/requirements_directml.txt | 3 - requirements/requirements_nvidia.txt | 7 +- requirements/requirements_rocm.txt | 3 - scripts/convert.py | 58 +- scripts/extract.py | 76 +-- scripts/fsmedia.py | 56 +- scripts/train.py | 75 ++- setup.cfg | 2 - setup.py | 602 ++++++++++-------- tests/lib/gpu_stats/_base_test.py | 7 +- tests/lib/gui/stats/event_reader_test.py | 13 +- tests/lib/model/optimizers_test.py | 7 - tests/lib/sysinfo_test.py | 6 +- tests/lib/utils_test.py | 39 +- tests/simple_tests.py | 7 +- tests/tools/alignments/media_test.py | 67 +- tests/tools/preview/viewer_test.py | 28 +- tests/utils.py | 7 - tools.py | 8 +- tools/alignments/alignments.py | 36 +- tools/alignments/cli.py | 5 +- tools/alignments/jobs.py | 69 +- tools/alignments/jobs_faces.py | 84 ++- tools/alignments/jobs_frames.py | 66 +- tools/alignments/media.py | 87 ++- tools/mask/mask.py | 54 +- tools/model/cli.py | 4 +- tools/model/model.py | 4 +- tools/preview/cli.py | 4 +- tools/preview/control_panels.py | 73 +-- tools/preview/preview.py | 63 +- tools/preview/viewer.py | 35 +- tools/sort/sort.py | 48 +- tools/sort/sort_methods.py | 127 ++-- tools/sort/sort_methods_aligned.py | 32 +- 130 files changed, 3030 insertions(+), 3023 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 7fc82d3c5a..badd8f4fc8 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -8,17 +8,85 @@ on: - "**/README.md" jobs: - build_linux: + build_conda: + name: conda (${{ matrix.os }}, ${{ matrix.backend }}) + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash -el {0} + strategy: + fail-fast: false + matrix: + os: ["ubuntu-latest", "macos-latest", "windows-latest"] + backend: ["nvidia", "cpu"] + include: + - os: "ubuntu-latest" + backend: "rocm" + - os: "windows-latest" + backend: "directml" + exclude: + # pynvx does not currently build for Python3.10 and without CUDA it may not build at all + - os: "macos-latest" + backend: "nvidia" + steps: + - uses: actions/checkout@v3 + - name: Set cache date + run: echo "DATE=$(date +'%Y%m%d')" >> $GITHUB_ENV + - name: Cache conda + uses: actions/cache@v3 + env: + # Increase this value to manually reset cache + CACHE_NUMBER: 1 + REQ_FILE: ./requirements/requirements_${{ matrix.backend }}.txt + with: + path: ~/conda_pkgs_dir + key: ${{ runner.os }}-${{ matrix.backend }}-conda-${{ env.CACHE_NUMBER }}-${{ env.DATE }}-${{ hashFiles('./requirements/requirements.txt', env.REQ_FILE) }} + - name: Set up Conda + uses: conda-incubator/setup-miniconda@v2 + with: + python-version: "3.10" + auto-update-conda: true + activate-environment: faceswap + - name: Conda info + run: conda info && conda list + - name: Install + run: | + python setup.py --installer --${{ matrix.backend }} + pip install flake8 pylint mypy pytest pytest-mock wheel pytest-xvfb + pip install types-attrs types-cryptography types-pyOpenSSL types-PyYAML types-setuptools + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --select=E9,F63,F7,F82 --show-source + flake8 . --exit-zero + - name: MyPy Typing + continue-on-error: true + run: | + mypy . + - name: SysInfo + run: python -c "from lib.sysinfo import sysinfo ; print(sysinfo)" + - name: Simple Tests + # These backends will fail as GPU drivers not available + if: matrix.backend != 'rocm' && matrix.backend != 'nvidia' && matrix.backend != 'directml' + run: | + FACESWAP_BACKEND="${{ matrix.backend }}" py.test -v tests/; + - name: End to End Tests + # These backends will fail as GPU drivers not available + # macOS fails on first extract test with 'died with ' + if: matrix.backend != 'rocm' && matrix.backend != 'nvidia' && matrix.backend != 'directml' && matrix.os != 'macos-latest' + run: | + FACESWAP_BACKEND="${{ matrix.backend }}" python tests/simple_tests.py; + build_linux: + name: "pip (ubuntu-latest, ${{ matrix.backend }})" runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9"] + python-version: ["3.10"] backend: ["cpu"] include: - - kbackend: "tensorflow" - backend: "cpu" + - backend: "cpu" steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} @@ -33,6 +101,8 @@ jobs: pip install flake8 pylint mypy pytest pytest-mock pytest-xvfb wheel pip install types-attrs types-cryptography types-pyOpenSSL types-PyYAML types-setuptools pip install -r ./requirements/requirements_${{ matrix.backend }}.txt + - name: List installed packages + run: pip freeze - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names @@ -45,17 +115,18 @@ jobs: mypy . - name: Simple Tests run: | - FACESWAP_BACKEND="${{ matrix.backend }}" KERAS_BACKEND="${{ matrix.kbackend }}" py.test -v tests/; + FACESWAP_BACKEND="${{ matrix.backend }}" py.test -v tests/; - name: End to End Tests run: | - FACESWAP_BACKEND="${{ matrix.backend }}" KERAS_BACKEND="${{ matrix.kbackend }}" python tests/simple_tests.py; + FACESWAP_BACKEND="${{ matrix.backend }}" python tests/simple_tests.py; build_windows: + name: "pip (windows-latest, ${{ matrix.backend }})" runs-on: windows-latest strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9"] + python-version: ["3.10"] backend: ["cpu", "directml"] include: - backend: "cpu" @@ -74,6 +145,8 @@ jobs: pip install flake8 pylint mypy pytest pytest-mock wheel pip install types-attrs types-cryptography types-pyOpenSSL types-PyYAML types-setuptools pip install -r ./requirements/requirements_${{ matrix.backend }}.txt + - name: List installed packages + run: pip freeze - name: Set Backend EnvVar run: echo "FACESWAP_BACKEND=${{ matrix.backend }}" | Out-File -FilePath $env:GITHUB_ENV -Append - name: Lint with flake8 diff --git a/.install/linux/faceswap_setup_x64.sh b/.install/linux/faceswap_setup_x64.sh index 1e60b41c38..21bbec6310 100644 --- a/.install/linux/faceswap_setup_x64.sh +++ b/.install/linux/faceswap_setup_x64.sh @@ -12,7 +12,7 @@ DIR_CONDA="$HOME/miniconda3" CONDA_EXECUTABLE="${DIR_CONDA}/bin/conda" CONDA_TO_PATH=false ENV_NAME="faceswap" -PYENV_VERSION="3.9" +PYENV_VERSION="3.10" DIR_FACESWAP="$HOME/faceswap" VERSION="nvidia" @@ -363,7 +363,7 @@ delete_env() { } create_env() { - # Create Python 3.8 env for faceswap + # Create Python 3.10 env for faceswap delete_env info "Creating Conda Virtual Environment..." yellow ; "$CONDA_EXECUTABLE" create -n "$ENV_NAME" -q python="$PYENV_VERSION" -y diff --git a/.install/windows/install.nsi b/.install/windows/install.nsi index d70ddb5b11..d407807872 100644 --- a/.install/windows/install.nsi +++ b/.install/windows/install.nsi @@ -22,7 +22,7 @@ InstallDir $PROFILE\faceswap # Install cli flags !define flagsConda "/S /RegisterPython=0 /AddToPath=0 /D=$PROFILE\MiniConda3" !define flagsRepo "--depth 1 --no-single-branch ${wwwRepo}" -!define flagsEnv "-y python=3.9" +!define flagsEnv "-y python=3.10" # Folders Var ProgramData diff --git a/.readthedocs.yml b/.readthedocs.yml index 2aa3c9934b..8d199514eb 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -7,9 +7,9 @@ version: 2 # Set the version of Python and other tools you might need build: - os: ubuntu-20.04 + os: ubuntu-22.04 tools: - python: "3.8" + python: "3.10" # Build documentation in the docs/ directory with Sphinx sphinx: diff --git a/Dockerfile.cpu b/Dockerfile.cpu index 8b9d297737..0c27ec9b69 100755 --- a/Dockerfile.cpu +++ b/Dockerfile.cpu @@ -1,19 +1,19 @@ -FROM tensorflow/tensorflow:2.8.2 +FROM ubuntu:22.04 # To disable tzdata and others from asking for input ENV DEBIAN_FRONTEND noninteractive +ENV FACESWAP_BACKEND cpu -RUN apt-get update -qq -y \ - && apt-get install -y software-properties-common \ - && add-apt-repository -y ppa:jonathonf/ffmpeg-4 \ - && apt-get update -qq -y \ - && apt-get install -y libsm6 libxrender1 libxext-dev python3-tk ffmpeg git \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* +RUN apt-get update -qq -y +RUN apt-get upgrade -y +RUN apt-get install -y libgl1 libglib2.0-0 python3 python3-pip python3-tk git -COPY ./requirements/_requirements_base.txt /opt/ -RUN pip3 install --upgrade pip -RUN pip3 --no-cache-dir install -r /opt/_requirements_base.txt && rm /opt/_requirements_base.txt +RUN ln -s $(which python3) /usr/local/bin/python + +RUN git clone --depth 1 --no-single-branch https://github.com/deepfakes/faceswap.git +WORKDIR "/faceswap" + +RUN python -m pip install --upgrade pip +RUN python -m pip --no-cache-dir install -r ./requirements/requirements_cpu.txt -WORKDIR "/srv" CMD ["/bin/bash"] diff --git a/Dockerfile.gpu b/Dockerfile.gpu index 078875f5ed..d62e010fa7 100755 --- a/Dockerfile.gpu +++ b/Dockerfile.gpu @@ -1,29 +1,19 @@ -FROM nvidia/cuda:11.7.0-runtime-ubuntu18.04 -ARG DEBIAN_FRONTEND=noninteractive +FROM nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04 -#install python3.8 -RUN apt-get update -RUN apt-get install software-properties-common -y -RUN add-apt-repository ppa:deadsnakes/ppa -y -RUN apt-get update -RUN apt-get install python3.8 -y -RUN apt-get install python3.8-distutils -y -RUN apt-get install python3.8-tk -y -RUN apt-get install curl -y -RUN curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py -RUN python3.8 get-pip.py -RUN rm get-pip.py +ENV DEBIAN_FRONTEND=noninteractive +ENV FACESWAP_BACKEND nvidia -# install requirements -RUN apt-get install ffmpeg git -y -COPY ./requirements/_requirements_base.txt /opt/ -COPY ./requirements/requirements_nvidia.txt /opt/ -RUN python3.8 -m pip --no-cache-dir install -r /opt/requirements_nvidia.txt && rm /opt/_requirements_base.txt && rm /opt/requirements_nvidia.txt +RUN apt-get update -qq -y +RUN apt-get upgrade -y +RUN apt-get install -y libgl1 libglib2.0-0 python3 python3-pip python3-tk git -RUN python3.8 -m pip install jupyter matplotlib tqdm -RUN python3.8 -m pip install jupyter_http_over_ws -RUN jupyter serverextension enable --py jupyter_http_over_ws -RUN alias python=python3.8 -RUN echo "alias python=python3.8" >> /root/.bashrc -WORKDIR "/notebooks" -CMD ["jupyter-notebook", "--allow-root" ,"--port=8888" ,"--no-browser" ,"--ip=0.0.0.0"] +RUN ln -s $(which python3) /usr/local/bin/python + +RUN git clone --depth 1 --no-single-branch https://github.com/deepfakes/faceswap.git +WORKDIR "/faceswap" + +RUN python -m pip install --upgrade pip +RUN python -m pip install --upgrade pip +RUN python -m pip --no-cache-dir install -r ./requirements/requirements_nvidia.txt + +CMD ["/bin/bash"] diff --git a/INSTALL.md b/INSTALL.md index cae8acdd9e..24807eb793 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -39,12 +39,9 @@ - [Setup](#setup-2) - [About some of the options](#about-some-of-the-options) - [Docker Install Guide](#docker-install-guide) - - [Docker General](#docker-general) - - [CUDA with Docker in 20 minutes.](#cuda-with-docker-in-20-minutes) - - [CUDA with Docker on Arch Linux](#cuda-with-docker-on-arch-linux) - - [Install docker](#install-docker) - - [A successful setup log, without docker.](#a-successful-setup-log-without-docker) - - [Run the project](#run-the-project) + - [Docker CPU](#docker-cpu) + - [Docker Nvidia](#docker-nvidia) +- [Run the project](#run-the-project) - [Notes](#notes) # Prerequisites @@ -115,7 +112,7 @@ Reboot your PC, so that everything you have just installed gets registered. - Select "Create" at the bottom - In the pop up: - Give it the name: faceswap - - **IMPORTANT**: Select python version 3.8 + - **IMPORTANT**: Select python version 3.10 - Hit "Create" (NB: This may take a while as it will need to download Python) ![Anaconda virtual env setup](https://i.imgur.com/CLIDDfa.png) @@ -195,7 +192,7 @@ $ source ~/miniforge3/bin/activate ## Setup ### Create and Activate the Environment ```sh -$ conda create --name faceswap python=3.9 +$ conda create --name faceswap python=3.10 $ conda activate faceswap ``` @@ -225,7 +222,7 @@ Obtain git for your distribution from the [git website](https://git-scm.com/down The recommended install method is to use a Conda3 Environment as this will handle the installation of Nvidia's CUDA and cuDNN straight into your Conda Environment. This is by far the easiest and most reliable way to setup the project. - MiniConda3 is recommended: [MiniConda3](https://docs.conda.io/en/latest/miniconda.html) -Alternatively you can install Python (>= 3.7-3.9 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Tensorflow yourself, make sure you install the correct Cuda and cuDNN package for the currently installed version of Tensorflow (Current release: Tensorflow 2.9. Release v1.0: Tensorflow 1.15). You can check for the compatible versions here: (https://www.tensorflow.org/install/source#gpu). +Alternatively you can install Python (3.10 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Tensorflow yourself, make sure you install the correct Cuda and cuDNN package for the currently installed version of Tensorflow (Current release: Tensorflow 2.9. Release v1.0: Tensorflow 1.15). You can check for the compatible versions here: (https://www.tensorflow.org/install/source#gpu). - Python distributions: - apt/yum install python3 (Linux) - [Installer](https://www.python.org/downloads/release/python-368/) (Windows) @@ -260,153 +257,83 @@ If setup fails for any reason you can still manually install the packages listed # Docker Install Guide -## Docker General -

- Click to expand! +This Faceswap repo contains Docker build scripts for CPU and Nvidia backends. The scripts will set up a Docker container for you and install the latest version of the Faceswap software. - ### CUDA with Docker in 20 minutes. - - 1. Install Docker - https://www.docker.com/community-edition +You must first ensure that Docker is installed and running on your system. Follow the guide for downloading and installing Docker from their website: - 2. Install Nvidia-Docker & Restart Docker Service - https://github.com/NVIDIA/nvidia-docker + - https://www.docker.com/get-started - 3. Build Docker Image For faceswap - - ```bash - docker build -t deepfakes-gpu -f Dockerfile.gpu . - ``` +Once Docker is installed and running, follow the relevant steps for your chosen backend +## Docker CPU +To run the CPU version of Faceswap follow these steps: - 4. Mount faceswap volume and Run it - a). without `gui.tools.py` gui not working. - - ```bash - nvidia-docker run --rm -it -p 8888:8888 \ - --hostname faceswap-gpu --name faceswap-gpu \ - -v /opt/faceswap:/srv \ - deepfakes-gpu - ``` - - b). with gui. tools.py gui working. - -Enable local access to X11 server - -```bash -xhost +local: -``` - -Enable nvidia device if working under bumblebee - -```bash -echo ON > /proc/acpi/bbswitch -``` - -Create container -```bash -nvidia-docker run -p 8888:8888 \ - --hostname faceswap-gpu --name faceswap-gpu \ - -v /opt/faceswap:/srv \ - -v /tmp/.X11-unix:/tmp/.X11-unix \ - -e DISPLAY=unix$DISPLAY \ - -e AUDIO_GID=`getent group audio | cut -d: -f3` \ - -e VIDEO_GID=`getent group video | cut -d: -f3` \ - -e GID=`id -g` \ - -e UID=`id -u` \ - deepfakes-gpu - -``` - -Open a new terminal to interact with the project - -```bash -docker exec -it deepfakes-gpu /bin/bash -``` - -Launch deepfakes gui (Answer 3 for NVIDIA at the prompt) - -```bash -python3.8 /srv/faceswap.py gui +1. Build the Docker image For faceswap: ``` -
- -## CUDA with Docker on Arch Linux - -
- Click to expand! - -### Install docker - -```bash -sudo pacman -S docker +docker build \ +-t faceswap-cpu \ +https://raw.githubusercontent.com/deepfakes/faceswap/master/Dockerfile.cpu ``` - -The steps are same but Arch linux doesn't use nvidia-docker - -create container - -```bash -docker run -p 8888:8888 --gpus all --privileged -v /dev:/dev \ - --hostname faceswap-gpu --name faceswap-gpu \ - -v /mnt/hdd2/faceswap:/srv \ - -v /tmp/.X11-unix:/tmp/.X11-unix \ - -e DISPLAY=unix$DISPLAY \ - -e AUDIO_GID=`getent group audio | cut -d: -f3` \ - -e VIDEO_GID=`getent group video | cut -d: -f3` \ - -e GID=`id -g` \ - -e UID=`id -u` \ - deepfakes-gpu +2. Launch and enter the Faceswap container: + + a. For the **headless/command line** version of Faceswap run: + ``` + docker run --rm -it faceswap-cpu + ``` + You can then execute faceswap the standard way: + ``` + python faceswap.py --help + ``` + b. For the **GUI** version of Faceswap run: + ``` + xhost +local: && \ + docker run --rm -it \ + -v /tmp/.X11-unix:/tmp/.X11-unix \ + -e DISPLAY=${DISPLAY} \ + faceswap-cpu + ``` + You can then launch the GUI with + ``` + python faceswap.py gui + ``` + ## Docker Nvidia +To build the NVIDIA GPU version of Faceswap, follow these steps: + +1. Nvidia Docker builds need extra resources to provide the Docker container with access to your GPU. + + a. Follow the instructions to install and apply the `Nvidia Container Toolkit` for your distribution from: + - https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html + + b. If Docker is already running, restart it to pick up the changes made by the Nvidia Container Toolkit. + +2. Build the Docker image For faceswap ``` - -Open a new terminal to interact with the project - -```bash -docker exec -it deepfakes-gpu /bin/bash -``` - -Launch deepfakes gui (Answer 3 for NVIDIA at the prompt) - -**With `gui.tools.py` gui working.** - Enable local access to X11 server - - ```bash -xhost +local: +docker build \ +-t faceswap-gpu \ +https://raw.githubusercontent.com/deepfakes/faceswap/master/Dockerfile.gpu ``` - - ```bash - python3.8 /srv/faceswap.py gui - ``` - -
- ---- -## A successful setup log, without docker. -``` -INFO The tool provides tips for installation - and installs required python packages -INFO Setup in Linux 4.14.39-1-MANJARO -INFO Installed Python: 3.7.5 64bit -INFO Installed PIP: 10.0.1 -Enable Docker? [Y/n] n -INFO Docker Disabled -Enable CUDA? [Y/n] -INFO CUDA Enabled -INFO CUDA version: 9.1 -INFO cuDNN version: 7 -WARNING Tensorflow has no official prebuild for CUDA 9.1 currently. - To continue, You have to build your own tensorflow-gpu. - Help: https://www.tensorflow.org/install/install_sources -Are System Dependencies met? [y/N] y -INFO Installing Missing Python Packages... -INFO Installing tensorflow-gpu -...... -INFO Installing tqdm -INFO Installing matplotlib -INFO All python3 dependencies are met. - You are good to go. -``` - -## Run the project +1. Launch and enter the Faceswap container: + + a. For the **headless/command line** version of Faceswap run: + ``` + docker run --runtime=nvidia --rm -it faceswap-gpu + ``` + You can then execute faceswap the standard way: + ``` + python faceswap.py --help + ``` + b. For the **GUI** version of Faceswap run: + ``` + xhost +local: && \ + docker run --runtime=nvidia --rm -it \ + -v /tmp/.X11-unix:/tmp/.X11-unix \ + -e DISPLAY=${DISPLAY} \ + faceswap-gpu + ``` + You can then launch the GUI with + ``` + python faceswap.py gui + ``` +# Run the project Once all these requirements are installed, you can attempt to run the faceswap tools. Use the `-h` or `--help` options for a list of options. ```bash diff --git a/docs/conf.py b/docs/conf.py index 35c7a22696..f547655839 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,7 +19,7 @@ sys.setrecursionlimit(1500) -MOCK_MODULES = ["plaidml", "pynvx", "ctypes.windll", "comtypes"] +MOCK_MODULES = ["pynvx", "ctypes.windll", "comtypes"] for mod_name in MOCK_MODULES: sys.modules[mod_name] = mock.Mock() diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index b4cb179ced..5c28e7c793 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -1,25 +1,21 @@ # NB Do not install from this requirements file # It is for documentation purposes only -sphinx==5.0.2 -sphinx_rtd_theme==1.0.0 -tqdm==4.64 -psutil==5.8.0 -numexpr>=2.8.3 -numpy>=1.18.0 -opencv-python>=4.5.5.0 -pillow==8.3.1 -scikit-learn>=1.0.2 -fastcluster>=1.2.4 -matplotlib==3.5.1 -numexpr -imageio==2.9.0 -imageio-ffmpeg==0.4.7 -ffmpy==0.2.3 -nvidia-ml-py<11.515 -plaidml==0.7.0 +sphinx==7.0.1 +sphinx_rtd_theme==1.2.2 +tqdm==4.65 +psutil==5.9.0 +numexpr>=2.8.4 +numpy>=1.25.0 +opencv-python>=4.7.0.0 +pillow==9.4.0 +scikit-learn>=1.2.2 +fastcluster>=1.2.6 +matplotlib==3.7.1 +imageio==2.31.1 +imageio-ffmpeg==0.4.8 +ffmpy==0.3.0 +nvidia-ml-py==11.525 pytest==7.2.0 pytest-mock==3.10.0 -tensorflow>=2.8.0,<2.9.0 -tensorflow_probability<0.17 -typing-extensions>=4.0.0 +tensorflow>=2.10.0,<2.11.0 diff --git a/faceswap.py b/faceswap.py index 3b6777e86f..1189f2e580 100755 --- a/faceswap.py +++ b/faceswap.py @@ -16,9 +16,8 @@ _LANG = gettext.translation("faceswap", localedir="locales", fallback=True) _ = _LANG.gettext -if sys.version_info < (3, 7): - raise ValueError("This program requires at least python3.7") - +if sys.version_info < (3, 10): + raise ValueError("This program requires at least python 3.10") _PARSER = cli_args.FullHelpArgumentParser() diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 5c9ff88a6f..a8e997feaa 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -3,22 +3,14 @@ from dataclasses import dataclass, field import logging -import sys +import typing as T from threading import Lock -from typing import cast, Dict, Optional, Tuple - import cv2 import numpy as np -if sys.version_info < (3, 8): - from typing_extensions import get_args, Literal -else: - from typing import get_args, Literal - logger = logging.getLogger(__name__) # pylint: disable=invalid-name - -CenteringType = Literal["face", "head", "legacy"] +CenteringType = T.Literal["face", "head", "legacy"] _MEAN_FACE = np.array([[0.010086, 0.106454], [0.085135, 0.038915], [0.191003, 0.018748], [0.300643, 0.034489], [0.403270, 0.077391], [0.596729, 0.077391], @@ -65,10 +57,10 @@ [0.0, -8.601736, 6.097667], # 45 mouth bottom C [0.589441, -8.443925, 6.109526]]) # 44 mouth bottom L -_EXTRACT_RATIOS = dict(legacy=0.375, face=0.5, head=0.625) +_EXTRACT_RATIOS = {"legacy": 0.375, "face": 0.5, "head": 0.625} -def get_matrix_scaling(matrix: np.ndarray) -> Tuple[int, int]: +def get_matrix_scaling(matrix: np.ndarray) -> tuple[int, int]: """ Given a matrix, return the cv2 Interpolation method and inverse interpolation method for applying the matrix on an image. @@ -213,6 +205,149 @@ def get_centered_size(source_centering: CenteringType, return retval +class PoseEstimate(): + """ Estimates pose from a generic 3D head model for the given 2D face landmarks. + + Parameters + ---------- + landmarks: :class:`numpy.ndarry` + The original 68 point landmarks aligned to 0.0 - 1.0 range + + References + ---------- + Head Pose Estimation using OpenCV and Dlib - https://www.learnopencv.com/tag/solvepnp/ + 3D Model points - http://aifi.isr.uc.pt/Downloads/OpenGL/glAnthropometric3DModel.cpp + """ + def __init__(self, landmarks: np.ndarray) -> None: + self._distortion_coefficients = np.zeros((4, 1)) # Assuming no lens distortion + self._xyz_2d: np.ndarray | None = None + + self._camera_matrix = self._get_camera_matrix() + self._rotation, self._translation = self._solve_pnp(landmarks) + self._offset = self._get_offset() + self._pitch_yaw_roll: tuple[float, float, float] = (0, 0, 0) + + @property + def xyz_2d(self) -> np.ndarray: + """ :class:`numpy.ndarray` projected (x, y) coordinates for each x, y, z point at a + constant distance from adjusted center of the skull (0.5, 0.5) in the 2D space. """ + if self._xyz_2d is None: + xyz = cv2.projectPoints(np.array([[6., 0., -2.3], + [0., 6., -2.3], + [0., 0., 3.7]]).astype("float32"), + self._rotation, + self._translation, + self._camera_matrix, + self._distortion_coefficients)[0].squeeze() + self._xyz_2d = xyz - self._offset["head"] + return self._xyz_2d + + @property + def offset(self) -> dict[CenteringType, np.ndarray]: + """ dict: The amount to offset a standard 0.0 - 1.0 umeyama transformation matrix for a + from the center of the face (between the eyes) or center of the head (middle of skull) + rather than the nose area. """ + return self._offset + + @property + def pitch(self) -> float: + """ float: The pitch of the aligned face in eular angles """ + if not any(self._pitch_yaw_roll): + self._get_pitch_yaw_roll() + return self._pitch_yaw_roll[0] + + @property + def yaw(self) -> float: + """ float: The yaw of the aligned face in eular angles """ + if not any(self._pitch_yaw_roll): + self._get_pitch_yaw_roll() + return self._pitch_yaw_roll[1] + + @property + def roll(self) -> float: + """ float: The roll of the aligned face in eular angles """ + if not any(self._pitch_yaw_roll): + self._get_pitch_yaw_roll() + return self._pitch_yaw_roll[2] + + def _get_pitch_yaw_roll(self) -> None: + """ Obtain the yaw, roll and pitch from the :attr:`_rotation` in eular angles. """ + proj_matrix = np.zeros((3, 4), dtype="float32") + proj_matrix[:3, :3] = cv2.Rodrigues(self._rotation)[0] + euler = cv2.decomposeProjectionMatrix(proj_matrix)[-1] + self._pitch_yaw_roll = T.cast(tuple[float, float, float], tuple(euler.squeeze())) + logger.trace("yaw_pitch: %s", self._pitch_yaw_roll) # type: ignore + + @classmethod + def _get_camera_matrix(cls) -> np.ndarray: + """ Obtain an estimate of the camera matrix based off the original frame dimensions. + + Returns + ------- + :class:`numpy.ndarray` + An estimated camera matrix + """ + focal_length = 4 + camera_matrix = np.array([[focal_length, 0, 0.5], + [0, focal_length, 0.5], + [0, 0, 1]], dtype="double") + logger.trace("camera_matrix: %s", camera_matrix) # type: ignore + return camera_matrix + + def _solve_pnp(self, landmarks: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """ Solve the Perspective-n-Point for the given landmarks. + + Takes 2D landmarks in world space and estimates the rotation and translation vectors + in 3D space. + + Parameters + ---------- + landmarks: :class:`numpy.ndarry` + The original 68 point landmark co-ordinates relating to the original frame + + Returns + ------- + rotation: :class:`numpy.ndarray` + The solved rotation vector + translation: :class:`numpy.ndarray` + The solved translation vector + """ + points = landmarks[[6, 7, 8, 9, 10, 17, 21, 22, 26, 31, 32, 33, 34, + 35, 36, 39, 42, 45, 48, 50, 51, 52, 54, 56, 57, 58]] + _, rotation, translation = cv2.solvePnP(_MEAN_FACE_3D, + points, + self._camera_matrix, + self._distortion_coefficients, + flags=cv2.SOLVEPNP_ITERATIVE) + logger.trace("points: %s, rotation: %s, translation: %s", # type: ignore + points, rotation, translation) + return rotation, translation + + def _get_offset(self) -> dict[CenteringType, np.ndarray]: + """ Obtain the offset between the original center of the extracted face to the new center + of the head in 2D space. + + Returns + ------- + :class:`numpy.ndarray` + The x, y offset of the new center from the old center. + """ + offset: dict[CenteringType, np.ndarray] = {"legacy": np.array([0.0, 0.0])} + points: dict[T.Literal["face", "head"], tuple[float, ...]] = {"head": (0.0, 0.0, -2.3), + "face": (0.0, -1.5, 4.2)} + + for key, pnts in points.items(): + center = cv2.projectPoints(np.array([pnts]).astype("float32"), + self._rotation, + self._translation, + self._camera_matrix, + self._distortion_coefficients)[0].squeeze() + logger.trace("center %s: %s", key, center) # type: ignore + offset[key] = center - (0.5, 0.5) + logger.trace("offset: %s", offset) # type: ignore + return offset + + @dataclass class _FaceCache: # pylint:disable=too-many-instance-attributes """ Cache for storing items related to a single aligned face. @@ -251,19 +386,19 @@ class _FaceCache: # pylint:disable=too-many-instance-attributes cropped_slices: dict, optional The slices for an input full head image and output cropped image. Default: `{}` """ - pose: Optional["PoseEstimate"] = None - original_roi: Optional[np.ndarray] = None - landmarks: Optional[np.ndarray] = None - landmarks_normalized: Optional[np.ndarray] = None + pose: PoseEstimate | None = None + original_roi: np.ndarray | None = None + landmarks: np.ndarray | None = None + landmarks_normalized: np.ndarray | None = None average_distance: float = 0.0 relative_eye_mouth_position: float = 0.0 - adjusted_matrix: Optional[np.ndarray] = None - interpolators: Tuple[int, int] = (0, 0) - cropped_roi: Dict[CenteringType, np.ndarray] = field(default_factory=dict) - cropped_slices: Dict[CenteringType, Dict[Literal["in", "out"], - Tuple[slice, slice]]] = field(default_factory=dict) + adjusted_matrix: np.ndarray | None = None + interpolators: tuple[int, int] = (0, 0) + cropped_roi: dict[CenteringType, np.ndarray] = field(default_factory=dict) + cropped_slices: dict[CenteringType, dict[T.Literal["in", "out"], + tuple[slice, slice]]] = field(default_factory=dict) - _locks: Dict[str, Lock] = field(default_factory=dict) + _locks: dict[str, Lock] = field(default_factory=dict) def __post_init__(self): """ Initialize the locks for the class parameters """ @@ -322,11 +457,11 @@ class AlignedFace(): """ def __init__(self, landmarks: np.ndarray, - image: Optional[np.ndarray] = None, + image: np.ndarray | None = None, centering: CenteringType = "face", size: int = 64, coverage_ratio: float = 1.0, - dtype: Optional[str] = None, + dtype: str | None = None, is_aligned: bool = False, is_legacy: bool = False) -> None: logger.trace("Initializing: %s (image shape: %s, centering: '%s', " # type: ignore @@ -340,9 +475,9 @@ def __init__(self, self._dtype = dtype self._is_aligned = is_aligned self._source_centering: CenteringType = "legacy" if is_legacy and is_aligned else "head" - self._matrices = dict(legacy=_umeyama(landmarks[17:], _MEAN_FACE, True)[0:2], - face=np.array([]), - head=np.array([])) + self._matrices = {"legacy": _umeyama(landmarks[17:], _MEAN_FACE, True)[0:2], + "face": np.array([]), + "head": np.array([])} self._padding = self._padding_from_coverage(size, coverage_ratio) self._cache = _FaceCache() @@ -353,7 +488,7 @@ def __init__(self, self._face if self._face is None else self._face.shape) @property - def centering(self) -> Literal["legacy", "head", "face"]: + def centering(self) -> T.Literal["legacy", "head", "face"]: """ str: The centering of the Aligned Face. One of `"legacy"`, `"head"`, `"face"`. """ return self._centering @@ -382,7 +517,7 @@ def matrix(self) -> np.ndarray: return self._matrices[self._centering] @property - def pose(self) -> "PoseEstimate": + def pose(self) -> PoseEstimate: """ :class:`lib.align.PoseEstimate`: The estimated pose in 3D space. """ with self._cache.lock("pose"): if self._cache.pose is None: @@ -405,7 +540,7 @@ def adjusted_matrix(self) -> np.ndarray: return self._cache.adjusted_matrix @property - def face(self) -> Optional[np.ndarray]: + def face(self) -> np.ndarray | None: """ :class:`numpy.ndarray`: The aligned face at the given :attr:`size` at the specified :attr:`coverage` in the given :attr:`dtype`. If an :attr:`image` has not been provided then an the attribute will return ``None``. """ @@ -450,7 +585,7 @@ def normalized_landmarks(self) -> np.ndarray: return self._cache.landmarks_normalized @property - def interpolators(self) -> Tuple[int, int]: + def interpolators(self) -> tuple[int, int]: """ tuple: (`interpolator` and `reverse interpolator`) for the :attr:`adjusted matrix`. """ with self._cache.lock("interpolators"): if not any(self._cache.interpolators): @@ -487,7 +622,7 @@ def relative_eye_mouth_position(self) -> float: return self._cache.relative_eye_mouth_position @classmethod - def _padding_from_coverage(cls, size: int, coverage_ratio: float) -> Dict[CenteringType, int]: + def _padding_from_coverage(cls, size: int, coverage_ratio: float) -> dict[CenteringType, int]: """ Return the image padding for a face from coverage_ratio set against a pre-padded training image. @@ -504,7 +639,7 @@ def _padding_from_coverage(cls, size: int, coverage_ratio: float) -> Dict[Center The padding required, in pixels for 'head', 'face' and 'legacy' face types """ retval = {_type: round((size * (coverage_ratio - (1 - _EXTRACT_RATIOS[_type]))) / 2) - for _type in get_args(Literal["legacy", "face", "head"])} + for _type in T.get_args(T.Literal["legacy", "face", "head"])} logger.trace(retval) # type: ignore return retval @@ -532,7 +667,7 @@ def transform_points(self, points: np.ndarray, invert: bool = False) -> np.ndarr invert, points, retval) return retval - def extract_face(self, image: Optional[np.ndarray]) -> Optional[np.ndarray]: + def extract_face(self, image: np.ndarray | None) -> np.ndarray | None: """ Extract the face from a source image and populate :attr:`face`. If an image is not provided then ``None`` is returned. @@ -605,7 +740,7 @@ def _convert_centering(self, image: np.ndarray) -> np.ndarray: def _get_cropped_slices(self, image_size: int, target_size: int, - ) -> Dict[Literal["in", "out"], Tuple[slice, slice]]: + ) -> dict[T.Literal["in", "out"], tuple[slice, slice]]: """ Obtain the slices to turn a full head extract into an alternatively centered extract. Parameters @@ -676,149 +811,6 @@ def get_cropped_roi(self, return self._cache.cropped_roi[centering] -class PoseEstimate(): - """ Estimates pose from a generic 3D head model for the given 2D face landmarks. - - Parameters - ---------- - landmarks: :class:`numpy.ndarry` - The original 68 point landmarks aligned to 0.0 - 1.0 range - - References - ---------- - Head Pose Estimation using OpenCV and Dlib - https://www.learnopencv.com/tag/solvepnp/ - 3D Model points - http://aifi.isr.uc.pt/Downloads/OpenGL/glAnthropometric3DModel.cpp - """ - def __init__(self, landmarks: np.ndarray) -> None: - self._distortion_coefficients = np.zeros((4, 1)) # Assuming no lens distortion - self._xyz_2d: Optional[np.ndarray] = None - - self._camera_matrix = self._get_camera_matrix() - self._rotation, self._translation = self._solve_pnp(landmarks) - self._offset = self._get_offset() - self._pitch_yaw_roll: Tuple[float, float, float] = (0, 0, 0) - - @property - def xyz_2d(self) -> np.ndarray: - """ :class:`numpy.ndarray` projected (x, y) coordinates for each x, y, z point at a - constant distance from adjusted center of the skull (0.5, 0.5) in the 2D space. """ - if self._xyz_2d is None: - xyz = cv2.projectPoints(np.array([[6., 0., -2.3], - [0., 6., -2.3], - [0., 0., 3.7]]).astype("float32"), - self._rotation, - self._translation, - self._camera_matrix, - self._distortion_coefficients)[0].squeeze() - self._xyz_2d = xyz - self._offset["head"] - return self._xyz_2d - - @property - def offset(self) -> Dict[CenteringType, np.ndarray]: - """ dict: The amount to offset a standard 0.0 - 1.0 umeyama transformation matrix for a - from the center of the face (between the eyes) or center of the head (middle of skull) - rather than the nose area. """ - return self._offset - - @property - def pitch(self) -> float: - """ float: The pitch of the aligned face in eular angles """ - if not any(self._pitch_yaw_roll): - self._get_pitch_yaw_roll() - return self._pitch_yaw_roll[0] - - @property - def yaw(self) -> float: - """ float: The yaw of the aligned face in eular angles """ - if not any(self._pitch_yaw_roll): - self._get_pitch_yaw_roll() - return self._pitch_yaw_roll[1] - - @property - def roll(self) -> float: - """ float: The roll of the aligned face in eular angles """ - if not any(self._pitch_yaw_roll): - self._get_pitch_yaw_roll() - return self._pitch_yaw_roll[2] - - def _get_pitch_yaw_roll(self) -> None: - """ Obtain the yaw, roll and pitch from the :attr:`_rotation` in eular angles. """ - proj_matrix = np.zeros((3, 4), dtype="float32") - proj_matrix[:3, :3] = cv2.Rodrigues(self._rotation)[0] - euler = cv2.decomposeProjectionMatrix(proj_matrix)[-1] - self._pitch_yaw_roll = cast(Tuple[float, float, float], tuple(euler.squeeze())) - logger.trace("yaw_pitch: %s", self._pitch_yaw_roll) # type: ignore - - @classmethod - def _get_camera_matrix(cls) -> np.ndarray: - """ Obtain an estimate of the camera matrix based off the original frame dimensions. - - Returns - ------- - :class:`numpy.ndarray` - An estimated camera matrix - """ - focal_length = 4 - camera_matrix = np.array([[focal_length, 0, 0.5], - [0, focal_length, 0.5], - [0, 0, 1]], dtype="double") - logger.trace("camera_matrix: %s", camera_matrix) # type: ignore - return camera_matrix - - def _solve_pnp(self, landmarks: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: - """ Solve the Perspective-n-Point for the given landmarks. - - Takes 2D landmarks in world space and estimates the rotation and translation vectors - in 3D space. - - Parameters - ---------- - landmarks: :class:`numpy.ndarry` - The original 68 point landmark co-ordinates relating to the original frame - - Returns - ------- - rotation: :class:`numpy.ndarray` - The solved rotation vector - translation: :class:`numpy.ndarray` - The solved translation vector - """ - points = landmarks[[6, 7, 8, 9, 10, 17, 21, 22, 26, 31, 32, 33, 34, - 35, 36, 39, 42, 45, 48, 50, 51, 52, 54, 56, 57, 58]] - _, rotation, translation = cv2.solvePnP(_MEAN_FACE_3D, - points, - self._camera_matrix, - self._distortion_coefficients, - flags=cv2.SOLVEPNP_ITERATIVE) - logger.trace("points: %s, rotation: %s, translation: %s", # type: ignore - points, rotation, translation) - return rotation, translation - - def _get_offset(self) -> Dict[CenteringType, np.ndarray]: - """ Obtain the offset between the original center of the extracted face to the new center - of the head in 2D space. - - Returns - ------- - :class:`numpy.ndarray` - The x, y offset of the new center from the old center. - """ - offset: Dict[CenteringType, np.ndarray] = dict(legacy=np.array([0.0, 0.0])) - points: Dict[Literal["face", "head"], Tuple[float, ...]] = dict(head=(0.0, 0.0, -2.3), - face=(0.0, -1.5, 4.2)) - - for key, pnts in points.items(): - center = cv2.projectPoints(np.array([pnts]).astype("float32"), - self._rotation, - self._translation, - self._camera_matrix, - self._distortion_coefficients)[0].squeeze() - logger.trace("center %s: %s", key, center) # type: ignore - offset[key] = center - (0.5, 0.5) - logger.trace("offset: %s", offset) # type: ignore - return offset - - def _umeyama(source: np.ndarray, destination: np.ndarray, estimate_scale: bool) -> np.ndarray: """Estimate N-D similarity transformation with or without scaling. @@ -866,24 +858,24 @@ def _umeyama(source: np.ndarray, destination: np.ndarray, estimate_scale: bool) if np.linalg.det(A) < 0: d[dim - 1] = -1 - T = np.eye(dim + 1, dtype=np.double) + retval = np.eye(dim + 1, dtype=np.double) U, S, V = np.linalg.svd(A) # Eq. (40) and (43). rank = np.linalg.matrix_rank(A) if rank == 0: - return np.nan * T + return np.nan * retval if rank == dim - 1: if np.linalg.det(U) * np.linalg.det(V) > 0: - T[:dim, :dim] = U @ V + retval[:dim, :dim] = U @ V else: s = d[dim - 1] d[dim - 1] = -1 - T[:dim, :dim] = U @ np.diag(d) @ V + retval[:dim, :dim] = U @ np.diag(d) @ V d[dim - 1] = s else: - T[:dim, :dim] = U @ np.diag(d) @ V + retval[:dim, :dim] = U @ np.diag(d) @ V if estimate_scale: # Eq. (41) and (42). @@ -891,7 +883,7 @@ def _umeyama(source: np.ndarray, destination: np.ndarray, estimate_scale: bool) else: scale = 1.0 - T[:dim, dim] = dst_mean - scale * (T[:dim, :dim] @ src_mean.T) - T[:dim, :dim] *= scale + retval[:dim, dim] = dst_mean - scale * (retval[:dim, :dim] @ src_mean.T) + retval[:dim, :dim] *= scale - return T + return retval diff --git a/lib/align/alignments.py b/lib/align/alignments.py index cc1c52ea9e..33ff2f6123 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -1,24 +1,19 @@ #!/usr/bin/env python3 """ Alignments file functions for reading, writing and manipulating the data stored in a serialized alignments file. """ - +from __future__ import annotations import logging import os -import sys +import typing as T from datetime import datetime -from typing import cast, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union import numpy as np from lib.serializer import get_serializer, get_serializer_from_filename from lib.utils import FaceswapError -if sys.version_info < (3, 8): - from typing_extensions import TypedDict -else: - from typing import TypedDict - -if TYPE_CHECKING: +if T.TYPE_CHECKING: + from collections.abc import Generator from .aligned_face import CenteringType logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -35,49 +30,49 @@ # TODO Convert these to Dataclasses -class MaskAlignmentsFileDict(TypedDict): +class MaskAlignmentsFileDict(T.TypedDict): """ Typed Dictionary for storing Masks. """ mask: bytes - affine_matrix: Union[List[float], np.ndarray] + affine_matrix: list[float] | np.ndarray interpolator: int stored_size: int - stored_centering: "CenteringType" + stored_centering: CenteringType -class PNGHeaderAlignmentsDict(TypedDict): +class PNGHeaderAlignmentsDict(T.TypedDict): """ Base Dictionary for storing a single faces' Alignment Information in Alignments files and PNG Headers. """ x: int y: int w: int h: int - landmarks_xy: Union[List[float], np.ndarray] - mask: Dict[str, MaskAlignmentsFileDict] - identity: Dict[str, List[float]] + landmarks_xy: list[float] | np.ndarray + mask: dict[str, MaskAlignmentsFileDict] + identity: dict[str, list[float]] class AlignmentFileDict(PNGHeaderAlignmentsDict): """ Typed Dictionary for storing a single faces' Alignment Information in alignments files. """ - thumb: Optional[np.ndarray] + thumb: np.ndarray | None -class PNGHeaderSourceDict(TypedDict): +class PNGHeaderSourceDict(T.TypedDict): """ Dictionary for storing additional meta information in PNG headers """ alignments_version: float original_filename: str face_index: int source_filename: str source_is_video: bool - source_frame_dims: Optional[Tuple[int, int]] + source_frame_dims: tuple[int, int] | None -class AlignmentDict(TypedDict): +class AlignmentDict(T.TypedDict): """ Dictionary for holding all of the alignment information within a single alignment file """ - faces: List[AlignmentFileDict] - video_meta: Dict[str, Union[float, int]] + faces: list[AlignmentFileDict] + video_meta: dict[str, float | int] -class PNGHeaderDict(TypedDict): +class PNGHeaderDict(T.TypedDict): """ Dictionary for storing all alignment and meta information in PNG Headers """ alignments: PNGHeaderAlignmentsDict source: PNGHeaderSourceDict @@ -135,7 +130,7 @@ def file(self) -> str: return self._io.file @property - def data(self) -> Dict[str, AlignmentDict]: + def data(self) -> dict[str, AlignmentDict]: """ dict: The loaded alignments :attr:`file` in dictionary form. """ return self._data @@ -146,7 +141,7 @@ def have_alignments_file(self) -> bool: return self._io.have_alignments_file @property - def hashes_to_frame(self) -> Dict[str, Dict[str, int]]: + def hashes_to_frame(self) -> dict[str, dict[str, int]]: """ dict: The SHA1 hash of the face mapped to the frame(s) and face index within the frame that the hash corresponds to. @@ -158,7 +153,7 @@ def hashes_to_frame(self) -> Dict[str, Dict[str, int]]: return self._legacy.hashes_to_frame @property - def hashes_to_alignment(self) -> Dict[str, AlignmentFileDict]: + def hashes_to_alignment(self) -> dict[str, AlignmentFileDict]: """ dict: The SHA1 hash of the face mapped to the alignment for the face that the hash corresponds to. The structure of the dictionary is: @@ -170,10 +165,10 @@ def hashes_to_alignment(self) -> Dict[str, AlignmentFileDict]: return self._legacy.hashes_to_alignment @property - def mask_summary(self) -> Dict[str, int]: + def mask_summary(self) -> dict[str, int]: """ dict: The mask type names stored in the alignments :attr:`data` as key with the number of faces which possess the mask type as value. """ - masks: Dict[str, int] = {} + masks: dict[str, int] = {} for val in self._data.values(): for face in val["faces"]: if face.get("mask", None) is None: @@ -183,21 +178,20 @@ def mask_summary(self) -> Dict[str, int]: return masks @property - def video_meta_data(self) -> Dict[str, Optional[Union[List[int], List[float]]]]: + def video_meta_data(self) -> dict[str, list[int] | list[float] | None]: """ dict: The frame meta data stored in the alignments file. If data does not exist in the alignments file then ``None`` is returned for each Key """ - retval: Dict[str, Optional[Union[List[int], - List[float]]]] = dict(pts_time=None, keyframes=None) - pts_time: List[float] = [] - keyframes: List[int] = [] + retval: dict[str, list[int] | list[float] | None] = {"pts_time": None, "keyframes": None} + pts_time: list[float] = [] + keyframes: list[int] = [] for idx, key in enumerate(sorted(self.data)): if not self.data[key].get("video_meta", {}): return retval meta = self.data[key]["video_meta"] - pts_time.append(cast(float, meta["pts_time"])) + pts_time.append(T.cast(float, meta["pts_time"])) if meta["keyframe"]: keyframes.append(idx) - retval = dict(pts_time=pts_time, keyframes=keyframes) + retval = {"pts_time": pts_time, "keyframes": keyframes} return retval @property @@ -211,7 +205,7 @@ def version(self) -> float: """ float: The alignments file version number. """ return self._io.version - def _load(self) -> Dict[str, AlignmentDict]: + def _load(self) -> dict[str, AlignmentDict]: """ Load the alignments data from the serialized alignments :attr:`file`. Populates :attr:`_version` with the alignment file's loaded version as well as returning @@ -238,7 +232,7 @@ def backup(self) -> None: """ return self._io.backup() - def save_video_meta_data(self, pts_time: List[float], keyframes: List[int]) -> None: + def save_video_meta_data(self, pts_time: list[float], keyframes: list[int]) -> None: """ Save video meta data to the alignments file. If the alignments file does not have an entry for every frame (e.g. if Extract Every N @@ -262,10 +256,10 @@ def save_video_meta_data(self, pts_time: List[float], keyframes: List[int]) -> N logger.info("Saving video meta information to Alignments file") for idx, pts in enumerate(pts_time): - meta: Dict[str, Union[float, int]] = dict(pts_time=pts, keyframe=idx in keyframes) + meta: dict[str, float | int] = {"pts_time": pts, "keyframe": idx in keyframes} key = f"{basename}_{idx + 1:06d}.png" if key not in self.data: - self.data[key] = dict(video_meta=meta, faces=[]) + self.data[key] = {"video_meta": meta, "faces": []} else: self.data[key]["video_meta"] = meta @@ -285,8 +279,8 @@ def save_video_meta_data(self, pts_time: List[float], keyframes: List[int]) -> N self._io.save() @classmethod - def _pad_leading_frames(cls, pts_time: List[float], keyframes: List[int]) -> Tuple[List[float], - List[int]]: + def _pad_leading_frames(cls, pts_time: list[float], keyframes: list[int]) -> tuple[list[float], + list[int]]: """ Calculate the number of frames to pad the video by when the first frame is not a key frame. @@ -310,7 +304,7 @@ def _pad_leading_frames(cls, pts_time: List[float], keyframes: List[int]) -> Tup """ start_pts = pts_time[0] logger.debug("Video not cut on keyframe. Start pts: %s", start_pts) - gaps: List[float] = [] + gaps: list[float] = [] prev_time = None for item in pts_time: if prev_time is not None: @@ -360,7 +354,7 @@ def frame_has_faces(self, frame_name: str) -> bool: ``True`` if the given frame_name exists within the alignments :attr:`data` and has at least 1 face associated with it, otherwise ``False`` """ - frame_data = self._data.get(frame_name, cast(AlignmentDict, {})) + frame_data = self._data.get(frame_name, T.cast(AlignmentDict, {})) retval = bool(frame_data.get("faces", [])) logger.trace("'%s': %s", frame_name, retval) # type:ignore return retval @@ -384,7 +378,7 @@ def frame_has_multiple_faces(self, frame_name: str) -> bool: if not frame_name: retval = False else: - frame_data = self._data.get(frame_name, cast(AlignmentDict, {})) + frame_data = self._data.get(frame_name, T.cast(AlignmentDict, {})) retval = bool(len(frame_data.get("faces", [])) > 1) logger.trace("'%s': %s", frame_name, retval) # type:ignore return retval @@ -414,7 +408,7 @@ def mask_is_valid(self, mask_type: str) -> bool: return retval # << DATA >> # - def get_faces_in_frame(self, frame_name: str) -> List[AlignmentFileDict]: + def get_faces_in_frame(self, frame_name: str) -> list[AlignmentFileDict]: """ Obtain the faces from :attr:`data` associated with a given frame_name. Parameters @@ -429,8 +423,8 @@ def get_faces_in_frame(self, frame_name: str) -> List[AlignmentFileDict]: The list of face dictionaries that appear within the requested frame_name """ logger.trace("Getting faces for frame_name: '%s'", frame_name) # type:ignore - frame_data = self._data.get(frame_name, cast(AlignmentDict, {})) - return frame_data.get("faces", cast(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: """ Return number of faces that appear within :attr:`data` for the given frame_name. @@ -446,7 +440,7 @@ def _count_faces_in_frame(self, frame_name: str) -> int: int The number of faces that appear in the given frame_name """ - frame_data = self._data.get(frame_name, cast(AlignmentDict, {})) + frame_data = self._data.get(frame_name, T.cast(AlignmentDict, {})) retval = len(frame_data.get("faces", [])) logger.trace(retval) # type:ignore return retval @@ -497,7 +491,7 @@ def add_face(self, frame_name: str, face: AlignmentFileDict) -> int: """ logger.debug("Adding face to frame_name: '%s'", frame_name) if frame_name not in self._data: - self._data[frame_name] = dict(faces=[], video_meta={}) + self._data[frame_name] = {"faces": [], "video_meta": {}} self._data[frame_name]["faces"].append(face) retval = self._count_faces_in_frame(frame_name) - 1 logger.debug("Returning new face index: %s", retval) @@ -520,7 +514,7 @@ def update_face(self, frame_name: str, face_index: int, face: AlignmentFileDict) logger.debug("Updating face %s for frame_name '%s'", face_index, frame_name) self._data[frame_name]["faces"][face_index] = face - def filter_faces(self, filter_dict: Dict[str, List[int]], filter_out: bool = False) -> None: + def filter_faces(self, filter_dict: dict[str, list[int]], filter_out: bool = False) -> None: """ Remove faces from :attr:`data` based on a given filter list. Parameters @@ -549,7 +543,7 @@ def filter_faces(self, filter_dict: Dict[str, List[int]], filter_out: bool = Fal del frame_data["faces"][face_idx] # << GENERATORS >> # - def yield_faces(self) -> Generator[Tuple[str, List[AlignmentFileDict], int, str], None, None]: + 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 are yielded by frame. @@ -715,7 +709,7 @@ def update_legacy(self) -> None: logger.info("Updating alignments file to version %s", self._version) self.save() - def load(self) -> Dict[str, AlignmentDict]: + def load(self) -> dict[str, AlignmentDict]: """ Load the alignments data from the serialized alignments :attr:`file`. Populates :attr:`_version` with the alignment file's loaded version as well as returning @@ -732,7 +726,7 @@ def load(self) -> Dict[str, AlignmentDict]: logger.info("Reading alignments from: '%s'", self._file) data = self._serializer.load(self._file) - meta = data.get("__meta__", dict(version=1.0)) + meta = data.get("__meta__", {"version": 1.0}) self._version = meta["version"] data = data.get("__data__", data) logger.debug("Loaded alignments") @@ -743,8 +737,8 @@ def save(self) -> None: the location :attr:`file`. """ logger.debug("Saving alignments") logger.info("Writing alignments to: '%s'", self._file) - data = dict(__meta__=dict(version=self._version), - __data__=self._alignments.data) + data = {"__meta__": {"version": self._version}, + "__data__": self._alignments.data} self._serializer.save(self._file, data) logger.debug("Saved alignments") @@ -928,7 +922,7 @@ def update(self) -> int: for key, val in self._alignments.data.items(): if not isinstance(val, list): continue - self._alignments.data[key] = dict(faces=val) + self._alignments.data[key] = {"faces": val} updated += 1 return updated @@ -1078,11 +1072,11 @@ class _Legacy(): """ def __init__(self, alignments: Alignments) -> None: self._alignments = alignments - self._hashes_to_frame: Dict[str, Dict[str, int]] = {} - self._hashes_to_alignment: Dict[str, AlignmentFileDict] = {} + self._hashes_to_frame: dict[str, dict[str, int]] = {} + self._hashes_to_alignment: dict[str, AlignmentFileDict] = {} @property - def hashes_to_frame(self) -> Dict[str, Dict[str, int]]: + def hashes_to_frame(self) -> dict[str, dict[str, int]]: """ dict: The SHA1 hash of the face mapped to the frame(s) and face index within the frame that the hash corresponds to. The structure of the dictionary is: @@ -1105,7 +1099,7 @@ def hashes_to_frame(self) -> Dict[str, Dict[str, int]]: return self._hashes_to_frame @property - def hashes_to_alignment(self) -> Dict[str, AlignmentFileDict]: + def hashes_to_alignment(self) -> dict[str, AlignmentFileDict]: """ dict: The SHA1 hash of the face mapped to the alignment for the face that the hash corresponds to. The structure of the dictionary is: diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index 74ca9a9553..bf48dfb050 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -1,12 +1,11 @@ #!/usr/bin python3 """ Face and landmarks detection for faceswap.py """ - +from __future__ import annotations import logging -import sys import os +import typing as T from hashlib import sha1 -from typing import cast, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING, Union from zlib import compress, decompress import cv2 @@ -18,14 +17,10 @@ PNGHeaderAlignmentsDict, PNGHeaderDict, PNGHeaderSourceDict) from . import AlignedFace, get_adjusted_center, get_centered_size -if TYPE_CHECKING: +if T.TYPE_CHECKING: + from collections.abc import Callable from .aligned_face import CenteringType -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -85,14 +80,14 @@ class DetectedFace(): dict of {**name** (`str`): :class:`Mask`}. """ def __init__(self, - image: Optional[np.ndarray] = None, - left: Optional[int] = None, - width: Optional[int] = None, - top: Optional[int] = None, - height: Optional[int] = None, - landmarks_xy: Optional[np.ndarray] = None, - mask: Optional[Dict[str, "Mask"]] = None, - filename: Optional[str] = None) -> None: + image: np.ndarray | None = None, + left: int | None = None, + width: int | None = None, + top: int | None = None, + height: int | None = None, + landmarks_xy: np.ndarray | None = None, + mask: dict[str, "Mask"] | None = None, + filename: str | None = None) -> None: logger.trace("Initializing %s: (image: %s, left: %s, width: %s, top: %s, " # type: ignore "height: %s, landmarks_xy: %s, mask: %s, filename: %s)", self.__class__.__name__, @@ -104,12 +99,12 @@ def __init__(self, self.top = top self.height = height self._landmarks_xy = landmarks_xy - self._identity: Dict[str, np.ndarray] = {} - self.thumbnail: Optional[np.ndarray] = None + self._identity: dict[str, np.ndarray] = {} + self.thumbnail: np.ndarray | None = None self.mask = {} if mask is None else mask - self._training_masks: Optional[Tuple[bytes, Tuple[int, int, int]]] = None + self._training_masks: tuple[bytes, tuple[int, int, int]] | None = None - self._aligned: Optional[AlignedFace] = None + self._aligned: AlignedFace | None = None logger.trace("Initialized %s", self.__class__.__name__) # type: ignore @property @@ -137,7 +132,7 @@ def bottom(self) -> int: return self.top + self.height @property - def identity(self) -> Dict[str, np.ndarray]: + def identity(self) -> dict[str, np.ndarray]: """ dict: Identity mechanism as key, identity embedding as value. """ return self._identity @@ -147,7 +142,7 @@ def add_mask(self, affine_matrix: np.ndarray, interpolator: int, storage_size: int = 128, - storage_centering: "CenteringType" = "face") -> None: + storage_centering: CenteringType = "face") -> None: """ Add a :class:`Mask` to this detected face The mask should be the original output from :mod:`plugins.extract.mask` @@ -209,7 +204,7 @@ def add_identity(self, name: str, embedding: np.ndarray, ) -> None: self._identity[name] = embedding def get_landmark_mask(self, - area: Literal["eye", "face", "mouth"], + area: T.Literal["eye", "face", "mouth"], blur_kernel: int, dilation: int) -> np.ndarray: """ Add a :class:`LandmarksMask` to this detected face @@ -235,7 +230,7 @@ def get_landmark_mask(self, """ # TODO Face mask generation from landmarks logger.trace("area: %s, dilation: %s", area, dilation) # type: ignore - areas = dict(mouth=[slice(48, 60)], eye=[slice(36, 42), slice(42, 48)]) + areas = {"mouth": [slice(48, 60)], "eye": [slice(36, 42), slice(42, 48)]} points = [self.aligned.landmarks[zone] for zone in areas[area]] @@ -250,7 +245,7 @@ def get_landmark_mask(self, return lmmask.mask def store_training_masks(self, - masks: List[Optional[np.ndarray]], + masks: list[np.ndarray | None], delete_masks: bool = False) -> None: """ Concatenate and compress the given training masks and store for retrieval. @@ -273,7 +268,7 @@ def store_training_masks(self, combined = np.concatenate(valid, axis=-1) self._training_masks = (compress(combined), combined.shape) - def get_training_masks(self) -> Optional[np.ndarray]: + def get_training_masks(self) -> np.ndarray | None: """ Obtain the decompressed combined training masks. Returns @@ -312,7 +307,7 @@ def to_alignment(self) -> AlignmentFileDict: return alignment def from_alignment(self, alignment: AlignmentFileDict, - image: Optional[np.ndarray] = None, with_thumb: bool = False) -> None: + image: np.ndarray | None = None, with_thumb: bool = False) -> None: """ Set the attributes of this class from an alignments file and optionally load the face into the ``image`` attribute. @@ -342,7 +337,7 @@ def from_alignment(self, alignment: AlignmentFileDict, landmarks = alignment["landmarks_xy"] if not isinstance(landmarks, np.ndarray): landmarks = np.array(landmarks, dtype="float32") - self._identity = {cast(Literal["vggface2"], k): np.array(v, dtype="float32") + self._identity = {T.cast(T.Literal["vggface2"], k): np.array(v, dtype="float32") for k, v in alignment.get("identity", {}).items()} self._landmarks_xy = landmarks.copy() @@ -403,7 +398,7 @@ def from_png_meta(self, alignment: PNGHeaderAlignmentsDict) -> None: self._identity = {} for key, val in alignment.get("identity", {}).items(): assert key in ["vggface2"] - self._identity[cast(Literal["vggface2"], key)] = np.array(val, dtype="float32") + self._identity[T.cast(T.Literal["vggface2"], key)] = np.array(val, dtype="float32") logger.trace("Created from png exif header: (left: %s, width: %s, top: %s " # type: ignore " height: %s, landmarks: %s, mask: %s, identity: %s)", self.left, self.width, self.top, self.height, self.landmarks_xy, self.mask, @@ -417,10 +412,10 @@ def _image_to_face(self, image: np.ndarray) -> None: # <<< Aligned Face methods and properties >>> # def load_aligned(self, - image: Optional[np.ndarray], + image: np.ndarray | None, size: int = 256, - dtype: Optional[str] = None, - centering: "CenteringType" = "head", + dtype: str | None = None, + centering: CenteringType = "head", coverage_ratio: float = 1.0, force: bool = False, is_aligned: bool = False, @@ -507,22 +502,22 @@ class Mask(): """ def __init__(self, storage_size: int = 128, - storage_centering: "CenteringType" = "face") -> None: + storage_centering: CenteringType = "face") -> None: logger.trace("Initializing: %s (storage_size: %s, storage_centering: %s)", # type: ignore self.__class__.__name__, storage_size, storage_centering) self.stored_size = storage_size self.stored_centering = storage_centering - self._mask: Optional[bytes] = None - self._affine_matrix: Optional[np.ndarray] = None - self._interpolator: Optional[int] = None + self._mask: bytes | None = None + self._affine_matrix: np.ndarray | None = None + self._interpolator: int | None = None - self._blur_type: Optional[Literal["gaussian", "normalized"]] = None + self._blur_type: T.Literal["gaussian", "normalized"] | None = None self._blur_passes: int = 0 - self._blur_kernel: Union[float, int] = 0 + self._blur_kernel: float | int = 0 self._threshold = 0.0 self._sub_crop_size = 0 - self._sub_crop_slices: Dict[Literal["in", "out"], List[slice]] = {} + self._sub_crop_slices: dict[T.Literal["in", "out"], list[slice]] = {} self.set_blur_and_threshold() logger.trace("Initialized: %s", self.__class__.__name__) # type: ignore @@ -648,7 +643,7 @@ def replace_mask(self, mask: np.ndarray) -> None: def set_blur_and_threshold(self, blur_kernel: int = 0, - blur_type: Optional[Literal["gaussian", "normalized"]] = "gaussian", + blur_type: T.Literal["gaussian", "normalized"] | None = "gaussian", blur_passes: int = 1, threshold: int = 0) -> None: """ Set the internal blur kernel and threshold amount for returned masks @@ -679,7 +674,7 @@ def set_blur_and_threshold(self, def set_sub_crop(self, source_offset: np.ndarray, target_offset: np.ndarray, - centering: "CenteringType", + centering: CenteringType, coverage_ratio: float = 1.0) -> None: """ Set the internal crop area of the mask to be returned. @@ -831,9 +826,9 @@ class LandmarksMask(Mask): The amount of dilation to apply to the mask. `0` for none. Default: `0` """ def __init__(self, - points: List[np.ndarray], + points: list[np.ndarray], storage_size: int = 128, - storage_centering: "CenteringType" = "face", + storage_centering: CenteringType = "face", dilation: int = 0) -> None: super().__init__(storage_size=storage_size, storage_centering=storage_centering) self._points = points @@ -907,9 +902,9 @@ class BlurMask(): # pylint:disable=too-few-public-methods (128, 128, 1) """ def __init__(self, - blur_type: Literal["gaussian", "normalized"], + blur_type: T.Literal["gaussian", "normalized"], mask: np.ndarray, - kernel: Union[int, float], + kernel: int | float, is_ratio: bool = False, passes: int = 1) -> None: logger.trace("Initializing %s: (blur_type: '%s', mask_shape: %s, " # type: ignore @@ -943,33 +938,30 @@ def blurred(self) -> np.ndarray: def _multipass_factor(self) -> float: """ For multiple passes the kernel must be scaled down. This value is different for box filter and gaussian """ - factor = dict(gaussian=0.8, normalized=0.5) + factor = {"gaussian": 0.8, "normalized": 0.5} return factor[self._blur_type] @property - def _sigma(self) -> Literal[0]: + def _sigma(self) -> T.Literal[0]: """ int: The Sigma for Gaussian Blur. Returns 0 to force calculation from kernel size. """ return 0 @property - def _func_mapping(self) -> Dict[Literal["gaussian", "normalized"], Callable]: + def _func_mapping(self) -> dict[T.Literal["gaussian", "normalized"], Callable]: """ dict: :attr:`_blur_type` mapped to cv2 Function name. """ - return dict(gaussian=cv2.GaussianBlur, # pylint: disable = no-member - normalized=cv2.blur) # pylint: disable = no-member + return {"gaussian": cv2.GaussianBlur, "normalized": cv2.blur} @property - def _kwarg_requirements(self) -> Dict[Literal["gaussian", "normalized"], List[str]]: + def _kwarg_requirements(self) -> dict[T.Literal["gaussian", "normalized"], list[str]]: """ dict: :attr:`_blur_type` mapped to cv2 Function required keyword arguments. """ - return dict(gaussian=["ksize", "sigmaX"], - normalized=["ksize"]) + return {"gaussian": ['ksize', 'sigmaX'], "normalized": ['ksize']} @property - def _kwarg_mapping(self) -> Dict[str, Union[int, Tuple[int, int]]]: + def _kwarg_mapping(self) -> dict[str, int | tuple[int, int]]: """ dict: cv2 function keyword arguments mapped to their parameters. """ - return dict(ksize=self._kernel_size, - sigmaX=self._sigma) + return {"ksize": self._kernel_size, "sigmaX": self._sigma} - def _get_kernel_size(self, kernel: Union[int, float], is_ratio: bool) -> int: + def _get_kernel_size(self, kernel: int | float, is_ratio: bool) -> int: """ Set the kernel size to absolute value. If :attr:`is_ratio` is ``True`` then the kernel size is calculated from the given ratio and @@ -999,7 +991,7 @@ def _get_kernel_size(self, kernel: Union[int, float], is_ratio: bool) -> int: return kernel_size @staticmethod - def _get_kernel_tuple(kernel_size: int) -> Tuple[int, int]: + def _get_kernel_tuple(kernel_size: int) -> tuple[int, int]: """ Make sure kernel_size is odd and return it as a tuple. Parameters @@ -1017,7 +1009,7 @@ def _get_kernel_tuple(kernel_size: int) -> Tuple[int, int]: logger.trace(retval) # type: ignore return retval - def _get_kwargs(self) -> Dict[str, Union[int, Tuple[int, int]]]: + def _get_kwargs(self) -> dict[str, int | tuple[int, int]]: """ dict: the valid keyword arguments for the requested :attr:`_blur_type` """ retval = {kword: self._kwarg_mapping[kword] for kword in self._kwarg_requirements[self._blur_type]} @@ -1025,11 +1017,11 @@ def _get_kwargs(self) -> Dict[str, Union[int, Tuple[int, int]]]: return retval -_HASHES_SEEN: Dict[str, Dict[str, int]] = {} +_HASHES_SEEN: dict[str, dict[str, int]] = {} def update_legacy_png_header(filename: str, alignments: Alignments - ) -> Optional[PNGHeaderDict]: + ) -> PNGHeaderDict | None: """ Update a legacy extracted face from pre v2.1 alignments by placing the alignment data for the face in the png exif header for the given filename with the given alignment data. diff --git a/lib/cli/actions.py b/lib/cli/actions.py index 7c03caed1c..4b89b35c3a 100644 --- a/lib/cli/actions.py +++ b/lib/cli/actions.py @@ -7,7 +7,7 @@ import argparse import os -from typing import Any, List, Optional, Tuple, Union +import typing as T # << FILE HANDLING >> @@ -69,7 +69,7 @@ class FileFullPaths(_FullPaths): >>> filetypes="video))" """ # pylint: disable=too-few-public-methods - def __init__(self, *args, filetypes: Optional[str] = None, **kwargs) -> None: + def __init__(self, *args, filetypes: str | None = None, **kwargs) -> None: super().__init__(*args, **kwargs) self.filetypes = filetypes @@ -111,7 +111,7 @@ class FilesFullPaths(FileFullPaths): # pylint: disable=too-few-public-methods >>> filetypes="image", >>> nargs="+")) """ - def __init__(self, *args, filetypes: Optional[str] = None, **kwargs) -> None: + def __init__(self, *args, filetypes: str | None = None, **kwargs) -> None: if kwargs.get("nargs", None) is None: opt = kwargs["option_strings"] raise ValueError(f"nargs must be provided for FilesFullPaths: {opt}") @@ -250,8 +250,8 @@ class ContextFullPaths(FileFullPaths): # pylint: disable=too-few-public-methods, too-many-arguments def __init__(self, *args, - filetypes: Optional[str] = None, - action_option: Optional[str] = None, + filetypes: str | None = None, + action_option: str | None = None, **kwargs) -> None: opt = kwargs["option_strings"] if kwargs.get("nargs", None) is not None: @@ -263,7 +263,7 @@ def __init__(self, super().__init__(*args, filetypes=filetypes, **kwargs) self.action_option = action_option - def _get_kwargs(self) -> List[Tuple[str, Any]]: + def _get_kwargs(self) -> list[tuple[str, T.Any]]: names = ["option_strings", "dest", "nargs", @@ -382,8 +382,8 @@ class Slider(argparse.Action): # pylint: disable=too-few-public-methods """ def __init__(self, *args, - min_max: Optional[Union[Tuple[int, int], Tuple[float, float]]] = None, - rounding: Optional[int] = None, + min_max: tuple[int, int] | tuple[float, float] | None = None, + rounding: int | None = None, **kwargs) -> None: opt = kwargs["option_strings"] if kwargs.get("nargs", None) is not None: @@ -401,7 +401,7 @@ def __init__(self, self.min_max = min_max self.rounding = rounding - def _get_kwargs(self) -> List[Tuple[str, Any]]: + def _get_kwargs(self) -> list[tuple[str, T.Any]]: names = ["option_strings", "dest", "nargs", diff --git a/lib/cli/args.py b/lib/cli/args.py index 2b9143a836..0d2de626e7 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -8,8 +8,7 @@ import re import sys import textwrap - -from typing import Any, Dict, List, NoReturn, Optional +import typing as T from lib.utils import get_backend from lib.gpu_stats import GPUStats @@ -30,7 +29,7 @@ class FullHelpArgumentParser(argparse.ArgumentParser): """ Extends :class:`argparse.ArgumentParser` to output full help on bad arguments. """ - def error(self, message: str) -> NoReturn: + def error(self, message: str) -> T.NoReturn: self.print_help(sys.stderr) self.exit(2, f"{self.prog}: error: {message}\n") @@ -51,11 +50,11 @@ def __init__(self, prog: str, indent_increment: int = 2, max_help_position: int = 24, - width: Optional[int] = None) -> None: + width: int | None = None) -> None: super().__init__(prog, indent_increment, max_help_position, width) self._whitespace_matcher_limited = re.compile(r'[ \r\f\v]+', re.ASCII) - def _split_lines(self, text: str, width: int) -> List[str]: + def _split_lines(self, text: str, width: int) -> list[str]: """ Split the given text by the given display width. If the text is not prefixed with "R|" then the standard @@ -138,7 +137,7 @@ def get_info() -> str: return "" @staticmethod - def get_argument_list() -> List[Dict[str, Any]]: + def get_argument_list() -> list[dict[str, T.Any]]: """ Returns the argument list for the current command. The argument list should be a list of dictionaries pertaining to each option for a command. @@ -152,11 +151,11 @@ def get_argument_list() -> List[Dict[str, Any]]: list The list of command line options for the given command """ - argument_list: List[Dict[str, Any]] = [] + argument_list: list[dict[str, T.Any]] = [] return argument_list @staticmethod - def get_optional_arguments() -> List[Dict[str, Any]]: + def get_optional_arguments() -> list[dict[str, T.Any]]: """ Returns the optional argument list for the current command. The optional arguments list is not always required, but is used when there are shared @@ -167,11 +166,11 @@ def get_optional_arguments() -> List[Dict[str, Any]]: list The list of optional command line options for the given command """ - argument_list: List[Dict[str, Any]] = [] + argument_list: list[dict[str, T.Any]] = [] return argument_list @staticmethod - def _get_global_arguments() -> List[Dict[str, Any]]: + def _get_global_arguments() -> list[dict[str, T.Any]]: """ Returns the global Arguments list that are required for ALL commands in Faceswap. This method should NOT be overridden. @@ -181,7 +180,7 @@ def _get_global_arguments() -> List[Dict[str, Any]]: list The list of global command line options for all Faceswap commands. """ - global_args: List[Dict[str, Any]] = [] + global_args: list[dict[str, T.Any]] = [] if _GPUS: global_args.append(dict( opts=("-X", "--exclude-gpus"), @@ -302,7 +301,7 @@ class ExtractConvertArgs(FaceSwapArgs): """ @staticmethod - def get_argument_list() -> List[Dict[str, Any]]: + def get_argument_list() -> list[dict[str, T.Any]]: """ Returns the argument list for shared Extract and Convert arguments. Returns @@ -310,7 +309,7 @@ def get_argument_list() -> List[Dict[str, Any]]: list The list of command line options for the given Extract and Convert """ - argument_list: List[Dict[str, Any]] = [] + argument_list: list[dict[str, T.Any]] = [] argument_list.append(dict( opts=("-i", "--input-dir"), action=DirOrFileFullPaths, @@ -362,7 +361,7 @@ def get_info() -> str: "Extraction plugins can be configured in the 'Settings' Menu") @staticmethod - def get_optional_arguments() -> List[Dict[str, Any]]: + def get_optional_arguments() -> list[dict[str, T.Any]]: """ Returns the argument list unique to the Extract command. Returns @@ -377,7 +376,7 @@ def get_optional_arguments() -> List[Dict[str, Any]]: default_detector = "s3fd" default_aligner = "fan" - argument_list: List[Dict[str, Any]] = [] + argument_list: list[dict[str, T.Any]] = [] argument_list.append(dict( opts=("-b", "--batch-mode"), action="store_true", @@ -658,7 +657,7 @@ def get_info() -> str: "Conversion plugins can be configured in the 'Settings' Menu") @staticmethod - def get_optional_arguments() -> List[Dict[str, Any]]: + def get_optional_arguments() -> list[dict[str, T.Any]]: """ Returns the argument list unique to the Convert command. Returns @@ -667,7 +666,7 @@ def get_optional_arguments() -> List[Dict[str, Any]]: The list of optional command line options for the Convert command """ - argument_list: List[Dict[str, Any]] = [] + argument_list: list[dict[str, T.Any]] = [] argument_list.append(dict( opts=("-ref", "--reference-video"), action=FileFullPaths, @@ -915,7 +914,7 @@ def get_info() -> str: "Model plugins can be configured in the 'Settings' Menu") @staticmethod - def get_argument_list() -> List[Dict[str, Any]]: + def get_argument_list() -> list[dict[str, T.Any]]: """ Returns the argument list for Train arguments. Returns @@ -923,7 +922,7 @@ def get_argument_list() -> List[Dict[str, Any]]: list The list of command line options for training """ - argument_list: List[Dict[str, Any]] = [] + argument_list: list[dict[str, T.Any]] = [] argument_list.append(dict( opts=("-A", "--input-A"), action=DirFullPaths, @@ -1180,7 +1179,7 @@ class GuiArgs(FaceSwapArgs): """ Creates the command line arguments for the GUI. """ @staticmethod - def get_argument_list() -> List[Dict[str, Any]]: + def get_argument_list() -> list[dict[str, T.Any]]: """ Returns the argument list for GUI arguments. Returns @@ -1188,7 +1187,7 @@ def get_argument_list() -> List[Dict[str, Any]]: list The list of command line options for the GUI """ - argument_list: List[Dict[str, Any]] = [] + argument_list: list[dict[str, T.Any]] = [] argument_list.append(dict( opts=("-d", "--debug"), action="store_true", diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 9219346401..9dfa3c132f 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -1,20 +1,22 @@ #!/usr/bin/env python3 """ Launches the correct script with the given Command Line Arguments """ +from __future__ import annotations import logging import os import platform import sys +import typing as T from importlib import import_module -from typing import Callable, TYPE_CHECKING from lib.gpu_stats import set_exclude_devices, GPUStats from lib.logger import crash_log, log_setup from lib.utils import (FaceswapError, get_backend, get_tf_version, safe_shutdown, set_backend, set_system_verbosity) -if TYPE_CHECKING: +if T.TYPE_CHECKING: import argparse + from collections.abc import Callable logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -99,8 +101,7 @@ def _test_for_tf_version(self) -> None: FaceswapError If Tensorflow is not found, or is not between versions 2.4 and 2.9 """ - directml_ver = rocm_ver = (2, 10) - min_ver = (2, 7) + min_ver = (2, 10) max_ver = (2, 10) try: import tensorflow as tf # noqa pylint:disable=import-outside-toplevel,unused-import @@ -120,7 +121,6 @@ def _test_for_tf_version(self) -> None: self._handle_import_error(msg) tf_ver = get_tf_version() - backend = get_backend() if tf_ver < min_ver: msg = (f"The minimum supported Tensorflow is version {min_ver} but you have version " f"{tf_ver} installed. Please upgrade Tensorflow.") @@ -129,14 +129,6 @@ def _test_for_tf_version(self) -> None: msg = (f"The maximum supported Tensorflow is version {max_ver} but you have version " f"{tf_ver} installed. Please downgrade Tensorflow.") self._handle_import_error(msg) - if backend == "directml" and tf_ver != directml_ver: - msg = (f"The supported Tensorflow version for DirectML cards is {directml_ver} but " - f"you have version {tf_ver} installed. Please install the correct version.") - self._handle_import_error(msg) - if backend == "rocm" and tf_ver != rocm_ver: - msg = (f"The supported Tensorflow version for ROCm cards is {rocm_ver} but " - f"you have version {tf_ver} installed. Please install the correct version.") - self._handle_import_error(msg) logger.debug("Installed Tensorflow Version: %s", tf_ver) @classmethod @@ -209,7 +201,7 @@ def _check_display(cls) -> None: "See https://support.apple.com/en-gb/HT201341") raise FaceswapError("No display detected. GUI mode has been disabled.") - def execute_script(self, arguments: "argparse.Namespace") -> None: + def execute_script(self, arguments: argparse.Namespace) -> None: """ Performs final set up and launches the requested :attr:`_command` with the given command line arguments. @@ -250,7 +242,7 @@ def execute_script(self, arguments: "argparse.Namespace") -> None: finally: safe_shutdown(got_error=not success) - def _configure_backend(self, arguments: "argparse.Namespace") -> None: + def _configure_backend(self, arguments: argparse.Namespace) -> None: """ Configure the backend. Exclude any GPUs for use by Faceswap when requested. diff --git a/lib/config.py b/lib/config.py index c588026db0..3619166f6c 100644 --- a/lib/config.py +++ b/lib/config.py @@ -13,7 +13,6 @@ from configparser import ConfigParser from dataclasses import dataclass from importlib import import_module -from typing import Dict, List, Optional, Tuple, Union from lib.utils import full_path_split @@ -21,16 +20,11 @@ _LANG = gettext.translation("lib.config", localedir="locales", fallback=True) _ = _LANG.gettext -# Can't type OrderedDict fully on Python 3.8 or lower -if sys.version_info < (3, 9): - OrderedDictSectionType = OrderedDict - OrderedDictItemType = OrderedDict -else: - OrderedDictSectionType = OrderedDict[str, "ConfigSection"] - OrderedDictItemType = OrderedDict[str, "ConfigItem"] +OrderedDictSectionType = OrderedDict[str, "ConfigSection"] +OrderedDictItemType = OrderedDict[str, "ConfigItem"] logger = logging.getLogger(__name__) # pylint: disable=invalid-name -ConfigValueType = Union[bool, int, float, List[str], str, None] +ConfigValueType = bool | int | float | list[str] | str | None @dataclass @@ -60,11 +54,11 @@ class ConfigItem: helptext: str datatype: type rounding: int - min_max: Optional[Union[Tuple[int, int], Tuple[float, float]]] - choices: Union[str, List[str]] + min_max: tuple[int, int] | tuple[float, float] | None + choices: str | list[str] gui_radio: bool fixed: bool - group: Optional[str] + group: str | None @dataclass @@ -84,7 +78,7 @@ class ConfigSection: class FaceswapConfig(): """ Config Items """ - def __init__(self, section: Optional[str], configfile: Optional[str] = None) -> None: + def __init__(self, section: str | None, configfile: str | None = None) -> None: """ Init Configuration Parameters @@ -106,11 +100,11 @@ def __init__(self, section: Optional[str], configfile: Optional[str] = None) -> logger.debug("Initialized: %s", self.__class__.__name__) @property - def changeable_items(self) -> Dict[str, ConfigValueType]: + def changeable_items(self) -> dict[str, ConfigValueType]: """ Training only. Return a dict of config items with their set values for items that can be altered after the model has been created """ - retval: Dict[str, ConfigValueType] = {} + retval: dict[str, ConfigValueType] = {} sections = [sect for sect in self.config.sections() if sect.startswith("global")] all_sections = sections if self.section is None else sections + [self.section] for sect in all_sections: @@ -189,10 +183,10 @@ def _load_defaults_from_module(self, logger.debug("Added defaults: %s", section) @property - def config_dict(self) -> Dict[str, ConfigValueType]: + def config_dict(self) -> dict[str, ConfigValueType]: """ dict: Collate global options and requested section into a dictionary with the correct data types """ - conf: Dict[str, ConfigValueType] = {} + conf: dict[str, ConfigValueType] = {} sections = [sect for sect in self.config.sections() if sect.startswith("global")] if self.section is not None: sections.append(self.section) @@ -240,7 +234,7 @@ def get(self, section: str, option: str) -> ConfigValueType: logger.debug("Returning item: (type: %s, value: %s)", datatype, retval) return retval - def _parse_list(self, section: str, option: str) -> List[str]: + def _parse_list(self, section: str, option: str) -> list[str]: """ Parse options that are stored as lists in the config file. These can be space or comma-separated items in the config file. They will be returned as a list of strings, regardless of what the final data type should be, so conversion from strings to other @@ -268,7 +262,7 @@ def _parse_list(self, section: str, option: str) -> List[str]: raw_option, retval, section, option) return retval - def _get_config_file(self, configfile: Optional[str]) -> str: + def _get_config_file(self, configfile: str | None) -> str: """ Return the config file from the calling folder or the provided file Parameters @@ -309,17 +303,17 @@ def add_section(self, title: str, info: str) -> None: self.defaults[title] = ConfigSection(helptext=info, items=OrderedDict()) def add_item(self, - section: Optional[str] = None, - title: Optional[str] = None, + section: str | None = None, + title: str | None = None, datatype: type = str, default: ConfigValueType = None, - info: Optional[str] = None, - rounding: Optional[int] = None, - min_max: Optional[Union[Tuple[int, int], Tuple[float, float]]] = None, - choices: Optional[Union[str, List[str]]] = None, + info: str | None = None, + rounding: int | None = None, + min_max: tuple[int, int] | tuple[float, float] | None = None, + choices: str | list[str] | None = None, gui_radio: bool = False, fixed: bool = True, - group: Optional[str] = None) -> None: + group: str | None = None) -> None: """ Add a default item to a config section For int or float values, rounding and min_max must be set @@ -382,10 +376,10 @@ def add_item(self, @classmethod def _expand_helptext(cls, helptext: str, - choices: Union[str, List[str]], + choices: str | list[str], default: ConfigValueType, datatype: type, - min_max: Optional[Union[Tuple[int, int], Tuple[float, float]]], + min_max: tuple[int, int] | tuple[float, float] | None, fixed: bool) -> str: """ Add extra helptext info from parameters """ helptext += "\n" @@ -437,7 +431,7 @@ def _create_default(self) -> None: def insert_config_section(self, section: str, helptext: str, - config: Optional[ConfigParser] = None) -> None: + config: ConfigParser | None = None) -> None: """ Insert a section into the config Parameters @@ -464,7 +458,7 @@ def _insert_config_item(self, item: str, default: ConfigValueType, option: ConfigItem, - config: Optional[ConfigParser] = None) -> None: + config: ConfigParser | None = None) -> None: """ Insert an item into a config section Parameters diff --git a/lib/convert.py b/lib/convert.py index 885e4df1d7..fddecf20a8 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -1,23 +1,18 @@ #!/usr/bin/env python3 """ Converter for Faceswap """ - +from __future__ import annotations import logging -import sys +import typing as T from dataclasses import dataclass -from typing import Callable, cast, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 import numpy as np from plugins.plugin_loader import PluginLoader -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - -if TYPE_CHECKING: +if T.TYPE_CHECKING: from argparse import Namespace + from collections.abc import Callable from lib.align.aligned_face import AlignedFace, CenteringType from lib.align.detected_face import DetectedFace from lib.config import FaceswapConfig @@ -46,10 +41,10 @@ class Adjustments: sharpening: :class:`~plugins.scaling._base.Adjustment`, Optional The selected mask processing plugin. Default: `None` """ - color: Optional["ColorAdjust"] = None - mask: Optional["MaskAdjust"] = None - seamless: Optional["SeamlessAdjust"] = None - sharpening: Optional["ScalingAdjust"] = None + color: ColorAdjust | None = None + mask: MaskAdjust | None = None + seamless: SeamlessAdjust | None = None + sharpening: ScalingAdjust | None = None class Converter(): @@ -81,11 +76,11 @@ class Converter(): def __init__(self, output_size: int, coverage_ratio: float, - centering: "CenteringType", + centering: CenteringType, draw_transparent: bool, - pre_encode: Optional[Callable[[np.ndarray], List[bytes]]], - arguments: "Namespace", - configfile: Optional[str] = None) -> None: + pre_encode: Callable[[np.ndarray], list[bytes]] | None, + arguments: Namespace, + configfile: str | None = None) -> None: logger.debug("Initializing %s: (output_size: %s, coverage_ratio: %s, centering: %s, " "draw_transparent: %s, pre_encode: %s, arguments: %s, configfile: %s)", self.__class__.__name__, output_size, coverage_ratio, centering, @@ -105,12 +100,12 @@ def __init__(self, logger.debug("Initialized %s", self.__class__.__name__) @property - def cli_arguments(self) -> "Namespace": + def cli_arguments(self) -> Namespace: """:class:`argparse.Namespace`: The command line arguments passed to the convert process """ return self._args - def reinitialize(self, config: "FaceswapConfig") -> None: + def reinitialize(self, config: FaceswapConfig) -> None: """ Reinitialize this :class:`Converter`. Called as part of the :mod:`~tools.preview` tool. Resets all adjustments then loads the @@ -127,7 +122,7 @@ def reinitialize(self, config: "FaceswapConfig") -> None: logger.debug("Reinitialized converter") def _load_plugins(self, - config: Optional["FaceswapConfig"] = None, + config: FaceswapConfig | None = None, disable_logging: bool = False) -> None: """ Load the requested adjustment plugins. @@ -169,7 +164,7 @@ def _load_plugins(self, self._adjustments.sharpening = sharpening logger.debug("Loaded plugins: %s", self._adjustments) - def process(self, in_queue: "EventQueue", out_queue: "EventQueue"): + def process(self, in_queue: EventQueue, out_queue: EventQueue): """ Main convert process. Takes items from the in queue, runs the relevant adjustments, patches faces to final frame @@ -188,7 +183,7 @@ def process(self, in_queue: "EventQueue", out_queue: "EventQueue"): in_queue, out_queue) log_once = False while True: - inbound: Union[Literal["EOF"], "ConvertItem", List["ConvertItem"]] = in_queue.get() + inbound: T.Literal["EOF"] | ConvertItem | list[ConvertItem] = in_queue.get() if inbound == "EOF": logger.debug("EOF Received") logger.debug("Patch queue finished") @@ -218,7 +213,7 @@ def process(self, in_queue: "EventQueue", out_queue: "EventQueue"): out_queue.put((item.inbound.filename, image)) logger.debug("Completed convert process") - def _patch_image(self, predicted: "ConvertItem") -> Union[np.ndarray, List[bytes]]: + def _patch_image(self, predicted: ConvertItem) -> np.ndarray | list[bytes]: """ Patch a swapped face onto a frame. Run selected adjustments and swap the faces in a frame. @@ -246,15 +241,15 @@ def _patch_image(self, predicted: "ConvertItem") -> Union[np.ndarray, List[bytes out=np.empty(patched_face.shape, dtype="uint8"), casting='unsafe') if self._writer_pre_encode is None: - retval: Union[np.ndarray, List[bytes]] = patched_face + retval: np.ndarray | list[bytes] = patched_face else: retval = self._writer_pre_encode(patched_face) logger.trace("Patched image: '%s'", predicted.inbound.filename) # type: ignore return retval def _get_new_image(self, - predicted: "ConvertItem", - frame_size: Tuple[int, int]) -> Tuple[np.ndarray, np.ndarray]: + predicted: ConvertItem, + frame_size: tuple[int, int]) -> tuple[np.ndarray, np.ndarray]: """ Get the new face from the predictor and apply pre-warp manipulations. Applies any requested adjustments to the raw output of the Faceswap model @@ -308,9 +303,9 @@ def _get_new_image(self, def _pre_warp_adjustments(self, new_face: np.ndarray, - detected_face: "DetectedFace", - reference_face: "AlignedFace", - predicted_mask: Optional[np.ndarray]) -> np.ndarray: + detected_face: DetectedFace, + reference_face: AlignedFace, + predicted_mask: np.ndarray | None) -> np.ndarray: """ Run any requested adjustments that can be performed on the raw output from the Faceswap model. @@ -337,7 +332,7 @@ def _pre_warp_adjustments(self, """ logger.trace("new_face shape: %s, predicted_mask shape: %s", # type: ignore new_face.shape, predicted_mask.shape if predicted_mask is not None else None) - old_face = cast(np.ndarray, reference_face.face)[..., :3] / 255.0 + old_face = T.cast(np.ndarray, reference_face.face)[..., :3] / 255.0 new_face, raw_mask = self._get_image_mask(new_face, detected_face, predicted_mask, @@ -351,9 +346,9 @@ def _pre_warp_adjustments(self, def _get_image_mask(self, new_face: np.ndarray, - detected_face: "DetectedFace", - predicted_mask: Optional[np.ndarray], - reference_face: "AlignedFace") -> Tuple[np.ndarray, np.ndarray]: + detected_face: DetectedFace, + predicted_mask: np.ndarray | None, + reference_face: AlignedFace) -> tuple[np.ndarray, np.ndarray]: """ Return any selected image mask Places the requested mask into the new face's Alpha channel. diff --git a/lib/gpu_stats/_base.py b/lib/gpu_stats/_base.py index b45bd011aa..8953f134ae 100644 --- a/lib/gpu_stats/_base.py +++ b/lib/gpu_stats/_base.py @@ -5,11 +5,10 @@ import logging from dataclasses import dataclass -from typing import List, Optional from lib.utils import get_backend -_EXCLUDE_DEVICES: List[int] = [] +_EXCLUDE_DEVICES: list[int] = [] @dataclass @@ -29,11 +28,11 @@ class GPUInfo(): devices_active: list[int] List of integers representing the indices of the active GPU devices. """ - vram: List[int] - vram_free: List[int] + vram: list[int] + vram_free: list[int] driver: str - devices: List[str] - devices_active: List[int] + devices: list[str] + devices_active: list[int] @dataclass @@ -57,7 +56,7 @@ class BiggestGPUInfo(): total: float -def set_exclude_devices(devices: List[int]) -> None: +def set_exclude_devices(devices: list[int]) -> None: """ Add any explicitly selected GPU devices to the global list of devices to be excluded from use by Faceswap. @@ -89,19 +88,19 @@ class _GPUStats(): def __init__(self, log: bool = True) -> None: # Logger is held internally, as we don't want to log when obtaining system stats on crash # or when querying the backend for command line options - self._logger: Optional[logging.Logger] = logging.getLogger(__name__) if log else None + self._logger: logging.Logger | None = logging.getLogger(__name__) if log else None self._log("debug", f"Initializing {self.__class__.__name__}") self._is_initialized = False self._initialize() self._device_count: int = self._get_device_count() - self._active_devices: List[int] = self._get_active_devices() + self._active_devices: list[int] = self._get_active_devices() self._handles: list = self._get_handles() self._driver: str = self._get_driver() - self._device_names: List[str] = self._get_device_names() - self._vram: List[int] = self._get_vram() - self._vram_free: List[int] = self._get_free_vram() + self._device_names: list[str] = self._get_device_names() + self._vram: list[int] = self._get_vram() + self._vram_free: list[int] = self._get_free_vram() if get_backend() != "cpu" and not self._active_devices: self._log("warning", "No GPU detected") @@ -115,7 +114,7 @@ def device_count(self) -> int: return self._device_count @property - def cli_devices(self) -> List[str]: + def cli_devices(self) -> list[str]: """ list[str]: Formatted index: name text string for each GPU """ return [f"{idx}: {device}" for idx, device in enumerate(self._device_names)] @@ -167,7 +166,7 @@ def _get_device_count(self) -> int: """ raise NotImplementedError() - def _get_active_devices(self) -> List[int]: + def _get_active_devices(self) -> list[int]: """ Obtain the indices of active GPUs (those that have not been explicitly excluded in the command line arguments). @@ -204,7 +203,7 @@ def _get_driver(self) -> str: """ raise NotImplementedError() - def _get_device_names(self) -> List[str]: + def _get_device_names(self) -> list[str]: """ Override to obtain the names of all connected GPUs. The quality of this information depends on the backend and OS being used, but it should be sufficient for identifying cards. @@ -217,7 +216,7 @@ def _get_device_names(self) -> List[str]: """ raise NotImplementedError() - def _get_vram(self) -> List[int]: + def _get_vram(self) -> list[int]: """ Override to obtain the total VRAM in Megabytes for each connected GPU. Returns @@ -228,7 +227,7 @@ def _get_vram(self) -> List[int]: """ raise NotImplementedError() - def _get_free_vram(self) -> List[int]: + def _get_free_vram(self) -> list[int]: """ Override to obtain the amount of VRAM that is available, in Megabytes, for each connected GPU. diff --git a/lib/gpu_stats/apple_silicon.py b/lib/gpu_stats/apple_silicon.py index 11fcfab1d3..a8b0815015 100644 --- a/lib/gpu_stats/apple_silicon.py +++ b/lib/gpu_stats/apple_silicon.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ Collects and returns Information on available Apple Silicon SoCs in Apple Macs. """ -from typing import Any, List +import typing as T import os import psutil @@ -35,7 +35,7 @@ class AppleSiliconStats(_GPUStats): """ def __init__(self, log: bool = True) -> None: # Following attribute set in :func:``_initialize`` - self._tf_devices: List[Any] = [] + self._tf_devices: list[T.Any] = [] super().__init__(log=log) @@ -142,7 +142,7 @@ def _get_driver(self) -> str: self._log("debug", f"GPU Driver: {driver}") return driver - def _get_device_names(self) -> List[str]: + def _get_device_names(self) -> list[str]: """ Obtain the list of names of available Apple Silicon SoC(s) as identified in :attr:`_handles`. @@ -155,7 +155,7 @@ def _get_device_names(self) -> List[str]: self._log("debug", f"GPU Devices: {names}") return names - def _get_vram(self) -> List[int]: + def _get_vram(self) -> list[int]: """ Obtain the VRAM in Megabytes for each available Apple Silicon SoC(s) as identified in :attr:`_handles`. @@ -175,7 +175,7 @@ def _get_vram(self) -> List[int]: self._log("debug", f"SoC RAM: {vram}") return vram - def _get_free_vram(self) -> List[int]: + def _get_free_vram(self) -> list[int]: """ Obtain the amount of VRAM that is available, in Megabytes, for each available Apple Silicon SoC. diff --git a/lib/gpu_stats/cpu.py b/lib/gpu_stats/cpu.py index 23090828e1..ae20c96c09 100644 --- a/lib/gpu_stats/cpu.py +++ b/lib/gpu_stats/cpu.py @@ -1,9 +1,5 @@ #!/usr/bin/env python3 """ Dummy functions for running faceswap on CPU. """ - - -from typing import List - from ._base import _GPUStats @@ -65,7 +61,7 @@ def _get_driver(self) -> str: self._log("debug", f"GPU Driver: {driver}") return driver - def _get_device_names(self) -> List[str]: + def _get_device_names(self) -> list[str]: """ Obtain the list of names of connected GPUs as identified in :attr:`_handles`. Returns @@ -73,11 +69,11 @@ def _get_device_names(self) -> List[str]: list An empty list for CPU backends """ - names: List[str] = [] + names: list[str] = [] self._log("debug", f"GPU Devices: {names}") return names - def _get_vram(self) -> List[int]: + def _get_vram(self) -> list[int]: """ Obtain the RAM in Megabytes for the running system. Returns @@ -85,11 +81,11 @@ def _get_vram(self) -> List[int]: list An empty list for CPU backends """ - vram: List[int] = [] + vram: list[int] = [] self._log("debug", f"GPU VRAM: {vram}") return vram - def _get_free_vram(self) -> List[int]: + def _get_free_vram(self) -> list[int]: """ Obtain the amount of RAM that is available, in Megabytes, for the running system. Returns @@ -97,6 +93,6 @@ def _get_free_vram(self) -> List[int]: list An empty list for CPU backends """ - vram: List[int] = [] + vram: list[int] = [] self._log("debug", f"GPU VRAM free: {vram}") return vram diff --git a/lib/gpu_stats/directml.py b/lib/gpu_stats/directml.py index d29fac0415..17364bbb34 100644 --- a/lib/gpu_stats/directml.py +++ b/lib/gpu_stats/directml.py @@ -1,19 +1,23 @@ #!/usr/bin/env python3 """ Collects and returns Information on DirectX 12 hardware devices for DirectML. """ +from __future__ import annotations import os import sys +import typing as T assert sys.platform == "win32" import ctypes from ctypes import POINTER, Structure, windll from dataclasses import dataclass from enum import Enum, IntEnum -from typing import Any, Callable, cast, List from comtypes import COMError, IUnknown, GUID, STDMETHOD, HRESULT # pylint:disable=import-error from ._base import _GPUStats +if T.TYPE_CHECKING: + from collections.abc import Callable + # Monkey patch default ctypes.c_uint32 value to Enum ctypes property for easier tracking of types # We can't just subclass as the attribute will be assumed to be part of the Enumeration, so we # attach it directly and suck up the typing errors. @@ -314,7 +318,7 @@ def __init__(self, log_func: Callable[[str, str], None]) -> None: self._adapters = self._get_adapters() self._devices = self._process_adapters() - self._valid_adaptors: List[Device] = [] + self._valid_adaptors: list[Device] = [] self._log("debug", f"Initialized {self.__class__.__name__}") def _get_factory(self) -> ctypes._Pointer: @@ -334,12 +338,12 @@ def _get_factory(self) -> ctypes._Pointer: factory_func.restype = HRESULT handle = ctypes.c_void_p(0) factory_func(IDXGIFactory6._iid_, ctypes.byref(handle)) # pylint:disable=protected-access - retval = ctypes.POINTER(IDXGIFactory6)(cast(IDXGIFactory6, handle.value)) + retval = ctypes.POINTER(IDXGIFactory6)(T.cast(IDXGIFactory6, handle.value)) self._log("debug", f"factory: {retval}") return retval @property - def valid_adapters(self) -> List[Device]: + def valid_adapters(self) -> list[Device]: """ list[:class:`Device`]: DirectX 12 compatible hardware :class:`Device` objects """ if self._valid_adaptors: return self._valid_adaptors @@ -354,7 +358,7 @@ def valid_adapters(self) -> List[Device]: self._log("debug", f"valid_adaptors: {self._valid_adaptors}") return self._valid_adaptors - def _get_adapters(self) -> List[ctypes._Pointer]: + def _get_adapters(self) -> list[ctypes._Pointer]: """ Obtain DirectX 12 supporting hardware adapter objects and add a Device class for obtaining details @@ -376,7 +380,7 @@ def _get_adapters(self) -> List[ctypes._Pointer]: if success != 0: raise AttributeError("Error calling EnumAdapterByGpuPreference. Result: " f"{hex(ctypes.c_ulong(success).value)}") - adapter = POINTER(IDXGIAdapter3)(cast(IDXGIAdapter3, handle.value)) + adapter = POINTER(IDXGIAdapter3)(T.cast(IDXGIAdapter3, handle.value)) self._log("debug", f"found adapter: {adapter}") retval.append(adapter) except COMError as err: @@ -392,7 +396,7 @@ def _get_adapters(self) -> List[ctypes._Pointer]: self._log("debug", f"adapters: {retval}") return retval - def _query_adapter(self, func: Callable[[Any], Any], *args: Any) -> None: + def _query_adapter(self, func: Callable[[T.Any], T.Any], *args: T.Any) -> None: """ Query an adapter function, logging if the HRESULT is not a success Parameters @@ -430,7 +434,7 @@ def _test_d3d12(self, adapter: ctypes._Pointer) -> bool: LookupGUID.ID3D12Device) return success in (0, 1) - def _process_adapters(self) -> List[Device]: + def _process_adapters(self) -> list[Device]: """ Process the adapters to add discovered information. Returns @@ -485,21 +489,21 @@ class DirectML(_GPUStats): Default: ``True`` """ def __init__(self, log: bool = True) -> None: - self._devices: List[Device] = [] + self._devices: list[Device] = [] super().__init__(log=log) @property - def _all_vram(self) -> List[int]: + def _all_vram(self) -> list[int]: """ list: The VRAM of each GPU device that the DX API has discovered. """ return [int(device.description.DedicatedVideoMemory / (1024 * 1024)) for device in self._devices] @property - def names(self) -> List[str]: + def names(self) -> list[str]: """ list: The name of each GPU device that the DX API has discovered. """ return [device.description.Description for device in self._devices] - def _get_active_devices(self) -> List[int]: + def _get_active_devices(self) -> list[int]: """ Obtain the indices of active GPUs (those that have not been explicitly excluded by DML_VISIBLE_DEVICES environment variable or explicitly excluded in the command line arguments). @@ -517,7 +521,7 @@ def _get_active_devices(self) -> List[int]: self._log("debug", f"Active GPU Devices: {devices}") return devices - def _get_devices(self) -> List[Device]: + def _get_devices(self) -> list[Device]: """ Obtain all detected DX API devices. Returns @@ -582,7 +586,7 @@ def _get_driver(self) -> str: self._log("debug", f"GPU Drivers: {drivers}") return drivers - def _get_device_names(self) -> List[str]: + def _get_device_names(self) -> list[str]: """ Obtain the list of names of connected GPUs as identified in :attr:`_handles`. Returns @@ -594,7 +598,7 @@ def _get_device_names(self) -> List[str]: self._log("debug", f"GPU Devices: {names}") return names - def _get_vram(self) -> List[int]: + def _get_vram(self) -> list[int]: """ Obtain the VRAM in Megabytes for each connected DirectML GPU as identified in :attr:`_handles`. @@ -607,7 +611,7 @@ def _get_vram(self) -> List[int]: self._log("debug", f"GPU VRAM: {vram}") return vram - def _get_free_vram(self) -> List[int]: + def _get_free_vram(self) -> list[int]: """ Obtain the amount of VRAM that is available, in Megabytes, for each connected DirectX 12 supporting GPU. diff --git a/lib/gpu_stats/nvidia.py b/lib/gpu_stats/nvidia.py index 8f8e8cef58..67038e9c6b 100644 --- a/lib/gpu_stats/nvidia.py +++ b/lib/gpu_stats/nvidia.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 """ Collects and returns Information on available Nvidia GPUs. """ import os -from typing import List import pynvml @@ -83,7 +82,7 @@ def _get_device_count(self) -> int: self._log("debug", f"GPU Device count: {retval}") return retval - def _get_active_devices(self) -> List[int]: + def _get_active_devices(self) -> list[int]: """ Obtain the indices of active GPUs (those that have not been explicitly excluded by CUDA_VISIBLE_DEVICES environment variable or explicitly excluded in the command line arguments). @@ -130,7 +129,7 @@ def _get_driver(self) -> str: self._log("debug", f"GPU Driver: {driver}") return driver - def _get_device_names(self) -> List[str]: + def _get_device_names(self) -> list[str]: """ Obtain the list of names of connected Nvidia GPUs as identified in :attr:`_handles`. Returns @@ -143,7 +142,7 @@ def _get_device_names(self) -> List[str]: self._log("debug", f"GPU Devices: {names}") return names - def _get_vram(self) -> List[int]: + def _get_vram(self) -> list[int]: """ Obtain the VRAM in Megabytes for each connected Nvidia GPU as identified in :attr:`_handles`. @@ -157,7 +156,7 @@ def _get_vram(self) -> List[int]: self._log("debug", f"GPU VRAM: {vram}") return vram - def _get_free_vram(self) -> List[int]: + def _get_free_vram(self) -> list[int]: """ Obtain the amount of VRAM that is available, in Megabytes, for each connected Nvidia GPU. diff --git a/lib/gpu_stats/nvidia_apple.py b/lib/gpu_stats/nvidia_apple.py index ae6cb74c5c..acbcd93f58 100644 --- a/lib/gpu_stats/nvidia_apple.py +++ b/lib/gpu_stats/nvidia_apple.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 """ Collects and returns Information on available Nvidia GPUs connected to Apple Macs. """ -from typing import List - import pynvx from lib.utils import FaceswapError @@ -92,7 +90,7 @@ def _get_driver(self) -> str: self._log("debug", f"GPU Driver: {driver}") return driver - def _get_device_names(self) -> List[str]: + def _get_device_names(self) -> list[str]: """ Obtain the list of names of connected Nvidia GPUs as identified in :attr:`_handles`. Returns @@ -105,7 +103,7 @@ def _get_device_names(self) -> List[str]: self._log("debug", f"GPU Devices: {names}") return names - def _get_vram(self) -> List[int]: + def _get_vram(self) -> list[int]: """ Obtain the VRAM in Megabytes for each connected Nvidia GPU as identified in :attr:`_handles`. @@ -120,7 +118,7 @@ def _get_vram(self) -> List[int]: self._log("debug", f"GPU VRAM: {vram}") return vram - def _get_free_vram(self) -> List[int]: + def _get_free_vram(self) -> list[int]: """ Obtain the amount of VRAM that is available, in Megabytes, for each connected Nvidia GPU. diff --git a/lib/gpu_stats/rocm.py b/lib/gpu_stats/rocm.py index c41e96b283..dca43b3818 100644 --- a/lib/gpu_stats/rocm.py +++ b/lib/gpu_stats/rocm.py @@ -10,7 +10,6 @@ import os import re from subprocess import run -from typing import List from ._base import _GPUStats @@ -221,7 +220,7 @@ class ROCm(_GPUStats): """ def __init__(self, log: bool = True) -> None: self._vendor_id = "0x1002" # AMD VendorID - self._sysfs_paths: List[str] = [] + self._sysfs_paths: list[str] = [] super().__init__(log=log) def _from_sysfs_file(self, path: str) -> str: @@ -249,7 +248,7 @@ def _from_sysfs_file(self, path: str) -> str: val = "" return val - def _get_sysfs_paths(self) -> List[str]: + def _get_sysfs_paths(self) -> list[str]: """ Obtain a list of sysfs paths to AMD branded GPUs connected to the system Returns @@ -259,7 +258,7 @@ def _get_sysfs_paths(self) -> List[str]: """ base_dir = "/sys/class/drm/" - retval: List[str] = [] + retval: list[str] = [] if not os.path.exists(base_dir): self._log("warning", f"sysfs not found at '{base_dir}'") return retval @@ -347,7 +346,7 @@ def _get_driver(self) -> str: self._log("debug", f"GPU Drivers: {retval}") return retval - def _get_device_names(self) -> List[str]: + def _get_device_names(self) -> list[str]: """ Obtain the list of names of connected GPUs as identified in :attr:`_handles`. Returns @@ -383,7 +382,7 @@ def _get_device_names(self) -> List[str]: self._log("debug", f"Device names: {retval}") return retval - def _get_active_devices(self) -> List[int]: + def _get_active_devices(self) -> list[int]: """ Obtain the indices of active GPUs (those that have not been explicitly excluded by HIP_VISIBLE_DEVICES environment variable or explicitly excluded in the command line arguments). @@ -401,7 +400,7 @@ def _get_active_devices(self) -> List[int]: self._log("debug", f"Active GPU Devices: {devices}") return devices - def _get_vram(self) -> List[int]: + def _get_vram(self) -> list[int]: """ Obtain the VRAM in Megabytes for each connected AMD GPU as identified in :attr:`_handles`. @@ -423,7 +422,7 @@ def _get_vram(self) -> List[int]: self._log("debug", f"GPU VRAM: {retval}") return retval - def _get_free_vram(self) -> List[int]: + def _get_free_vram(self) -> list[int]: """ Obtain the amount of VRAM that is available, in Megabytes, for each connected AMD GPU. diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index c2376b3b25..2a6fcc2745 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -1,13 +1,12 @@ #!/usr/bin/env python3 """ Handles the loading and collation of events from Tensorflow event log files. """ - +from __future__ import annotations import logging import os -import sys +import typing as T import zlib from dataclasses import dataclass, field -from typing import Any, cast, Dict, Iterator, Generator, List, Optional, Tuple, Union import numpy as np import tensorflow as tf @@ -17,11 +16,8 @@ from lib.serializer import get_serializer -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - +if T.TYPE_CHECKING: + from collections.abc import Generator, Iterator logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -38,7 +34,7 @@ class EventData: The loss values collected for A and B sides for the event step """ timestamp: float = 0.0 - loss: List[float] = field(default_factory=list) + loss: list[float] = field(default_factory=list) class _LogFiles(): @@ -56,11 +52,11 @@ def __init__(self, logs_folder: str) -> None: logger.debug("Initialized: %s", self.__class__.__name__) @property - def session_ids(self) -> List[int]: + def session_ids(self) -> list[int]: """ list[int]: Sorted list of `ints` of available session ids. """ return list(sorted(self._filenames)) - def _get_log_filenames(self) -> Dict[int, str]: + def _get_log_filenames(self) -> dict[int, str]: """ Get the Tensorflow event filenames for all existing sessions. Returns @@ -69,7 +65,7 @@ def _get_log_filenames(self) -> Dict[int, str]: The full path of each log file for each training session id that has been run """ logger.debug("Loading log filenames. base_dir: '%s'", self._logs_folder) - retval: Dict[int, str] = {} + retval: dict[int, str] = {} for dirpath, _, filenames in os.walk(self._logs_folder): if not any(filename.startswith("events.out.tfevents") for filename in filenames): continue @@ -82,7 +78,7 @@ def _get_log_filenames(self) -> Dict[int, str]: return retval @classmethod - def _get_session_id(cls, folder: str) -> Optional[int]: + def _get_session_id(cls, folder: str) -> int | None: """ Obtain the session id for the given folder. Parameters @@ -103,7 +99,7 @@ def _get_session_id(cls, folder: str) -> Optional[int]: return retval @classmethod - def _get_log_filename(cls, folder: str, filenames: List[str]) -> str: + def _get_log_filename(cls, folder: str, filenames: list[str]) -> str: """ Obtain the session log file for the given folder. If multiple log files exist for the given folder, then the most recent log file is used, as earlier files are assumed to be obsolete. @@ -161,10 +157,10 @@ class _CacheData(): loss: :class:`np.ndarray` The loss values collected for A and B sides for the session """ - def __init__(self, labels: List[str], timestamps: np.ndarray, loss: np.ndarray) -> None: + def __init__(self, labels: list[str], timestamps: np.ndarray, loss: np.ndarray) -> None: self.labels = labels - self._loss = zlib.compress(cast(bytes, loss)) - self._timestamps = zlib.compress(cast(bytes, timestamps)) + self._loss = zlib.compress(T.cast(bytes, loss)) + self._timestamps = zlib.compress(T.cast(bytes, timestamps)) self._timestamps_shape = timestamps.shape self._loss_shape = loss.shape @@ -192,8 +188,8 @@ def add_live_data(self, timestamps: np.ndarray, loss: np.ndarray) -> None: timestamps: :class:`numpy.ndarray` The latest timestamps to add to the cache """ - new_buffer: List[bytes] = [] - new_shapes: List[Tuple[int, ...]] = [] + new_buffer: list[bytes] = [] + new_shapes: list[tuple[int, ...]] = [] for data, buffer, dtype, shape in zip([timestamps, loss], [self._timestamps, self._loss], ["float64", "float32"], @@ -220,9 +216,9 @@ class _Cache(): """ Holds parsed Tensorflow log event data in a compressed cache in memory. """ def __init__(self) -> None: logger.debug("Initializing: %s", self.__class__.__name__) - self._data: Dict[int, _CacheData] = {} - self._carry_over: Dict[int, EventData] = {} - self._loss_labels: List[str] = [] + self._data: dict[int, _CacheData] = {} + self._carry_over: dict[int, EventData] = {} + self._loss_labels: list[str] = [] logger.debug("Initialized: %s", self.__class__.__name__) def is_cached(self, session_id: int) -> bool: @@ -242,8 +238,8 @@ def is_cached(self, session_id: int) -> bool: def cache_data(self, session_id: int, - data: Dict[int, EventData], - labels: List[str], + data: dict[int, EventData], + labels: list[str], is_live: bool = False) -> None: """ Add a full session's worth of event data to :attr:`_data`. @@ -278,8 +274,8 @@ def cache_data(self, self._add_latest_live(session_id, loss, timestamps) def _to_numpy(self, - data: Dict[int, EventData], - is_live: bool) -> Tuple[np.ndarray, np.ndarray]: + data: dict[int, EventData], + is_live: bool) -> tuple[np.ndarray, np.ndarray]: """ Extract each individual step data into separate numpy arrays for loss and timestamps. Timestamps are stored float64 as the extra accuracy is needed for correct timings. Arrays @@ -333,7 +329,7 @@ def _to_numpy(self, return n_times, n_loss - def _collect_carry_over(self, data: Dict[int, EventData]) -> None: + def _collect_carry_over(self, data: dict[int, EventData]) -> None: """ For live data, collect carried over data from the previous update and merge into the current data dictionary. @@ -357,8 +353,8 @@ def _collect_carry_over(self, data: Dict[int, EventData]) -> None: logger.debug("Merged carry over data: %s", update) def _process_data(self, - data: Dict[int, EventData], - is_live: bool) -> Tuple[List[float], List[List[float]]]: + data: dict[int, EventData], + is_live: bool) -> tuple[list[float], list[list[float]]]: """ Process live update data. Live data requires different processing as often we will only have partial data for the @@ -383,8 +379,8 @@ def _process_data(self, timestamps, loss = zip(*[(data[idx].timestamp, data[idx].loss) for idx in sorted(data)]) - l_loss: List[List[float]] = list(loss) - l_timestamps: List[float] = list(timestamps) + l_loss: list[list[float]] = list(loss) + l_timestamps: list[float] = list(timestamps) if len(l_loss[-1]) != len(self._loss_labels): logger.debug("Truncated loss found. loss count: %s", len(l_loss)) @@ -418,8 +414,8 @@ def _add_latest_live(self, session_id: int, loss: np.ndarray, timestamps: np.nda self._data[session_id].add_live_data(timestamps, loss) - def get_data(self, session_id: int, metric: Literal["loss", "timestamps"] - ) -> Optional[Dict[int, Dict[str, Union[np.ndarray, List[str]]]]]: + def get_data(self, session_id: int, metric: T.Literal["loss", "timestamps"] + ) -> dict[int, dict[str, np.ndarray | list[str]]] | None: """ Retrieve the decompressed cached data from the cache for the given session id. Parameters @@ -445,10 +441,10 @@ def get_data(self, session_id: int, metric: Literal["loss", "timestamps"] return None raw = {session_id: data} - retval: Dict[int, Dict[str, Union[np.ndarray, List[str]]]] = {} + retval: dict[int, dict[str, np.ndarray | list[str]]] = {} for idx, data in raw.items(): array = data.loss if metric == "loss" else data.timestamps - val: Dict[str, Union[np.ndarray, List[str]]] = {str(metric): array} + val: dict[str, np.ndarray | list[str]] = {str(metric): array} if metric == "loss": val["labels"] = data.labels retval[idx] = val @@ -488,7 +484,7 @@ def __init__(self, logs_folder: str, is_training: bool) -> None: logger.debug("Initialized: %s", self.__class__.__name__) @property - def session_ids(self) -> List[int]: + def session_ids(self) -> list[int]: """ list[int]: Sorted list of integers of available session ids. """ return self._log_files.session_ids @@ -539,7 +535,7 @@ def _cache_data(self, session_id: int) -> None: parser = _EventParser(iterator, self._cache, live_data) parser.cache_events(session_id) - def _check_cache(self, session_id: Optional[int] = None) -> None: + def _check_cache(self, session_id: int | None = None) -> None: """ Check if the given session_id has been cached and if not, cache it. Parameters @@ -557,7 +553,7 @@ def _check_cache(self, session_id: Optional[int] = None) -> None: if not self._cache.is_cached(idx): self._cache_data(idx) - def get_loss(self, session_id: Optional[int] = None) -> Dict[int, Dict[str, np.ndarray]]: + def get_loss(self, session_id: int | None = None) -> dict[int, dict[str, np.ndarray]]: """ Read the loss from the TensorBoard event logs Parameters @@ -573,7 +569,7 @@ def get_loss(self, session_id: Optional[int] = None) -> Dict[int, Dict[str, np.n and list of loss values for each step """ logger.debug("Getting loss: (session_id: %s)", session_id) - retval: Dict[int, Dict[str, np.ndarray]] = {} + retval: dict[int, dict[str, np.ndarray]] = {} for idx in [session_id] if session_id else self.session_ids: self._check_cache(idx) full_data = self._cache.get_data(idx, "loss") @@ -588,7 +584,7 @@ def get_loss(self, session_id: Optional[int] = None) -> Dict[int, Dict[str, np.n for key, val in retval.items()}) return retval - def get_timestamps(self, session_id: Optional[int] = None) -> Dict[int, np.ndarray]: + def get_timestamps(self, session_id: int | None = None) -> dict[int, np.ndarray]: """ Read the timestamps from the TensorBoard logs. As loss timestamps are slightly different for each loss, we collect the timestamp from the @@ -608,7 +604,7 @@ def get_timestamps(self, session_id: Optional[int] = None) -> Dict[int, np.ndarr logger.debug("Getting timestamps: (session_id: %s, is_training: %s)", session_id, self._is_training) - retval: Dict[int, np.ndarray] = {} + retval: dict[int, np.ndarray] = {} for idx in [session_id] if session_id else self.session_ids: self._check_cache(idx) data = self._cache.get_data(idx, "timestamps") @@ -640,7 +636,7 @@ def __init__(self, iterator: Iterator[bytes], cache: _Cache, live_data: bool) -> self._live_data = live_data self._cache = cache self._iterator = self._get_latest_live(iterator) if live_data else iterator - self._loss_labels: List[str] = [] + self._loss_labels: list[str] = [] logger.debug("Initialized: %s", self.__class__.__name__) @classmethod @@ -683,7 +679,7 @@ def cache_events(self, session_id: int) -> None: The session id that the data is being cached for """ assert self._iterator is not None - data: Dict[int, EventData] = {} + data: dict[int, EventData] = {} try: for record in self._iterator: event = event_pb2.Event.FromString(record) # pylint:disable=no-member @@ -743,7 +739,7 @@ def _parse_outputs(self, event: event_pb2.Event) -> None: logger.debug("Collated loss labels: %s", self._loss_labels) @classmethod - def _get_outputs(cls, model_config: Dict[str, Any]) -> np.ndarray: + def _get_outputs(cls, model_config: dict[str, T.Any]) -> np.ndarray: """ Obtain the output names, instance index and output index for the given model. If there is only a single output, the shape of the array is expanded to remain consistent diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index f3d7273eed..a1874d874a 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -5,15 +5,15 @@ the analysis tab) or the currently training session. """ - +from __future__ import annotations import logging -import time import os +import time +import typing as T import warnings from math import ceil from threading import Event -from typing import Any, cast, Dict, List, Optional, overload, Tuple, Union import numpy as np @@ -31,12 +31,12 @@ class GlobalSession(): """ def __init__(self) -> None: logger.debug("Initializing %s", self.__class__.__name__) - self._state: Dict[str, Any] = {} + self._state: dict[str, T.Any] = {} self._model_dir = "" self._model_name = "" - self._tb_logs: Optional[TensorBoardLogs] = None - self._summary: Optional[SessionsSummary] = None + self._tb_logs: TensorBoardLogs | None = None + self._summary: SessionsSummary | None = None self._is_training = False self._is_querying = Event() @@ -60,7 +60,7 @@ def model_filename(self) -> str: return os.path.join(self._model_dir, self._model_name) @property - def batch_sizes(self) -> Dict[int, int]: + def batch_sizes(self) -> dict[int, int]: """ dict: The batch sizes for each session_id for the model. """ if not self._state: return {} @@ -68,7 +68,7 @@ def batch_sizes(self) -> Dict[int, int]: for sess_id, sess in self._state.get("sessions", {}).items()} @property - def full_summary(self) -> List[dict]: + def full_summary(self) -> list[dict]: """ list: List of dictionaries containing summary statistics for each session id. """ assert self._summary is not None return self._summary.get_summary_stats() @@ -83,7 +83,7 @@ def logging_disabled(self) -> bool: return self._state["sessions"][max_id]["no_logs"] @property - def session_ids(self) -> List[int]: + def session_ids(self) -> list[int]: """ list: The sorted list of all existing session ids in the state file """ if self._tb_logs is None: return [] @@ -164,7 +164,7 @@ def clear(self) -> None: self._is_training = False - def get_loss(self, session_id: Optional[int]) -> Dict[str, np.ndarray]: + def get_loss(self, session_id: int | None) -> dict[str, np.ndarray]: """ Obtain the loss values for the given session_id. Parameters @@ -186,11 +186,11 @@ def get_loss(self, session_id: Optional[int]) -> Dict[str, np.ndarray]: assert self._tb_logs is not None loss_dict = self._tb_logs.get_loss(session_id=session_id) if session_id is None: - all_loss: Dict[str, List[float]] = {} + all_loss: dict[str, list[float]] = {} for key in sorted(loss_dict): for loss_key, loss in loss_dict[key].items(): all_loss.setdefault(loss_key, []).extend(loss) - retval: Dict[str, np.ndarray] = {key: np.array(val, dtype="float32") + retval: dict[str, np.ndarray] = {key: np.array(val, dtype="float32") for key, val in all_loss.items()} else: retval = loss_dict.get(session_id, {}) @@ -199,11 +199,11 @@ def get_loss(self, session_id: Optional[int]) -> Dict[str, np.ndarray]: self._is_querying.clear() return retval - @overload - def get_timestamps(self, session_id: None) -> Dict[int, np.ndarray]: + @T.overload + def get_timestamps(self, session_id: None) -> dict[int, np.ndarray]: ... - @overload + @T.overload def get_timestamps(self, session_id: int) -> np.ndarray: ... @@ -247,7 +247,7 @@ def _wait_for_thread(self) -> None: continue break - def get_loss_keys(self, session_id: Optional[int]) -> List[str]: + def get_loss_keys(self, session_id: int | None) -> list[str]: """ Obtain the loss keys for the given session_id. Parameters @@ -268,7 +268,7 @@ def get_loss_keys(self, session_id: Optional[int]) -> List[str]: in self._tb_logs.get_loss(session_id=session_id).items()} if session_id is None: - retval: List[str] = list(set(loss_key + retval: list[str] = list(set(loss_key for session in loss_keys.values() for loss_key in session)) else: @@ -293,11 +293,11 @@ def __init__(self, session: GlobalSession) -> None: self._session = session self._state = session._state - self._time_stats: Dict[int, Dict[str, Union[float, int]]] = {} - self._per_session_stats: List[Dict[str, Any]] = [] + self._time_stats: dict[int, dict[str, float | int]] = {} + self._per_session_stats: list[dict[str, T.Any]] = [] logger.debug("Initialized %s", self.__class__.__name__) - def get_summary_stats(self) -> List[dict]: + def get_summary_stats(self) -> list[dict]: """ Compile the individual session statistics and calculate the total. Format the stats for display @@ -336,14 +336,14 @@ def _get_time_stats(self) -> None: sess_id: {"start_time": np.min(timestamps) if np.any(timestamps) else 0, "end_time": np.max(timestamps) if np.any(timestamps) else 0, "iterations": timestamps.shape[0] if np.any(timestamps) else 0} - for sess_id, timestamps in cast(Dict[int, np.ndarray], - self._session.get_timestamps(None)).items()} + for sess_id, timestamps in T.cast(dict[int, np.ndarray], + self._session.get_timestamps(None)).items()} elif _SESSION.is_training: logger.debug("Updating summary time stamps for training session") session_id = _SESSION.session_ids[-1] - latest = cast(np.ndarray, self._session.get_timestamps(session_id)) + latest = T.cast(np.ndarray, self._session.get_timestamps(session_id)) self._time_stats[session_id] = { "start_time": np.min(latest) if np.any(latest) else 0, @@ -392,7 +392,7 @@ def _get_per_session_stats(self) -> None: / stats["elapsed"] if stats["elapsed"] > 0 else 0) logger.debug("per_session_stats: %s", self._per_session_stats) - def _collate_stats(self, session_id: int) -> Dict[str, Union[int, float]]: + def _collate_stats(self, session_id: int) -> dict[str, int | float]: """ Collate the session summary statistics for the given session ID. Parameters @@ -422,7 +422,7 @@ def _collate_stats(self, session_id: int) -> Dict[str, Union[int, float]]: logger.debug(retval) return retval - def _total_stats(self) -> Dict[str, Union[str, int, float]]: + def _total_stats(self) -> dict[str, str | int | float]: """ Compile the Totals stats. Totals are fully calculated each time as they will change on the basis of the training session. @@ -459,7 +459,7 @@ def _total_stats(self) -> Dict[str, Union[str, int, float]]: logger.debug(totals) return totals - def _format_stats(self, compiled_stats: List[dict]) -> List[dict]: + def _format_stats(self, compiled_stats: list[dict]) -> list[dict]: """ Format for the incoming list of statistics for display. Parameters @@ -489,7 +489,7 @@ def _format_stats(self, compiled_stats: List[dict]) -> List[dict]: return retval @classmethod - def _convert_time(cls, timestamp: float) -> Tuple[str, str, str]: + def _convert_time(cls, timestamp: float) -> tuple[str, str, str]: """ Convert time stamp to total hours, minutes and seconds. Parameters @@ -534,8 +534,8 @@ class Calculations(): """ def __init__(self, session_id, display: str = "loss", - loss_keys: Union[List[str], str] = "loss", - selections: Union[List[str], str] = "raw", + loss_keys: list[str] | str = "loss", + selections: list[str] | str = "raw", avg_samples: int = 500, smooth_amount: float = 0.90, flatten_outliers: bool = False) -> None: @@ -552,13 +552,13 @@ def __init__(self, session_id, self._loss_keys = loss_keys if isinstance(loss_keys, list) else [loss_keys] self._selections = selections if isinstance(selections, list) else [selections] self._is_totals = session_id is None - self._args: Dict[str, Union[int, float]] = {"avg_samples": avg_samples, - "smooth_amount": smooth_amount, - "flatten_outliers": flatten_outliers} + self._args: dict[str, int | float] = {"avg_samples": avg_samples, + "smooth_amount": smooth_amount, + "flatten_outliers": flatten_outliers} self._iterations = 0 self._limit = 0 self._start_iteration = 0 - self._stats: Dict[str, np.ndarray] = {} + self._stats: dict[str, np.ndarray] = {} self.refresh() logger.debug("Initialized %s", self.__class__.__name__) @@ -573,11 +573,11 @@ def start_iteration(self) -> int: return self._start_iteration @property - def stats(self) -> Dict[str, np.ndarray]: + def stats(self) -> dict[str, np.ndarray]: """ dict: The final calculated statistics """ return self._stats - def refresh(self) -> Optional["Calculations"]: + def refresh(self) -> Calculations | None: """ Refresh the stats """ logger.debug("Refreshing") if not _SESSION.is_loaded: @@ -736,7 +736,8 @@ def _calc_rate(self) -> np.ndarray: """ logger.debug("Calculating rate") batch_size = _SESSION.batch_sizes[self._session_id] * 2 - retval = batch_size / np.diff(cast(np.ndarray, _SESSION.get_timestamps(self._session_id))) + retval = batch_size / np.diff(T.cast(np.ndarray, + _SESSION.get_timestamps(self._session_id))) logger.debug("Calculated rate: Item_count: %s", len(retval)) return retval @@ -757,7 +758,7 @@ def _calc_rate_total(cls) -> np.ndarray: logger.debug("Calculating totals rate") batchsizes = _SESSION.batch_sizes total_timestamps = _SESSION.get_timestamps(None) - rate: List[float] = [] + rate: list[float] = [] for sess_id in sorted(total_timestamps.keys()): batchsize = batchsizes[sess_id] timestamps = total_timestamps[sess_id] @@ -797,7 +798,7 @@ def _calc_avg(self, data: np.ndarray) -> np.ndarray: The moving average for the given data """ logger.debug("Calculating Average. Data points: %s", len(data)) - window = cast(int, self._args["avg_samples"]) + window = T.cast(int, self._args["avg_samples"]) pad = ceil(window / 2) datapoints = data.shape[0] @@ -953,7 +954,7 @@ def _ewma_vectorized_safe(self) -> None: def _ewma_vectorized(self, data: np.ndarray, out: np.ndarray, - offset: Optional[float] = None) -> None: + offset: float | None = None) -> None: """ Calculates the exponential moving average over a vector. Will fail for large inputs. The result is processed in place into the array passed to the `out` parameter diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 0436109f3c..3906370081 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -5,10 +5,10 @@ import re import tkinter as tk +import typing as T from tkinter import colorchooser, ttk from itertools import zip_longest from functools import partial -from typing import Any, Dict from _tkinter import Tcl_Obj, TclError @@ -24,7 +24,9 @@ # We store Tooltips, ContextMenus and Commands globally when they are created # Because we need to add them back to newly cloned widgets (they are not easily accessible from # original config or are prone to getting destroyed when the original widget is destroyed) -_RECREATE_OBJECTS: Dict[str, Dict[str, Any]] = dict(tooltips={}, commands={}, contextmenus={}) +_RECREATE_OBJECTS: dict[str, dict[str, T.Any]] = {"tooltips": {}, + "commands": {}, + "contextmenus": {}} def _get_tooltip(widget, text=None, text_variable=None): @@ -154,17 +156,17 @@ def __init__(self, title, dtype, # pylint:disable=too-many-arguments self.dtype = dtype self.sysbrowser = sysbrowser self._command = command - self._options = dict(title=title, - subgroup=subgroup, - group=group, - default=default, - initial_value=initial_value, - choices=choices, - is_radio=is_radio, - is_multi_option=is_multi_option, - rounding=rounding, - min_max=min_max, - helptext=helptext) + self._options = {"title": title, + "subgroup": subgroup, + "group": group, + "default": default, + "initial_value": initial_value, + "choices": choices, + "is_radio": is_radio, + "is_multi_option": is_multi_option, + "rounding": rounding, + "min_max": min_max, + "helptext": helptext} self.control = self.get_control() self.tk_var = self.get_tk_var(initial_value, track_modified) logger.debug("Initialized %s", self.__class__.__name__) @@ -421,7 +423,7 @@ def __init__(self, parent, options, # pylint:disable=too-many-arguments self.group_frames = {} self._sub_group_frames = {} - canvas_kwargs = dict(bd=0, highlightthickness=0, bg=self._theme["panel_background"]) + canvas_kwargs = {"bd": 0, "highlightthickness": 0, "bg": self._theme["panel_background"]} self._canvas = tk.Canvas(self, **canvas_kwargs) self._canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) @@ -525,8 +527,8 @@ def get_group_frame(self, group): group_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5, anchor=tk.NW) - self.group_frames[group] = dict(frame=retval, - chkbtns=self.checkbuttons_frame(retval)) + self.group_frames[group] = {"frame": retval, + "chkbtns": self.checkbuttons_frame(retval)} group_frame = self.group_frames[group] return group_frame @@ -720,12 +722,12 @@ def _custom_kwargs(cls, widget): """ retval = {} if widget.__class__.__name__ == "MultiOption": - retval = dict(value=widget._value, # pylint:disable=protected-access - variable=widget._master_variable) # pylint:disable=protected-access + retval = {"value": widget._value, # pylint:disable=protected-access + "variable": widget._master_variable} # pylint:disable=protected-access elif widget.__class__.__name__ == "ToggledFrame": # Toggled Frames need to have their variable tracked - retval = dict(text=widget._text, # pylint:disable=protected-access - toggle_var=widget._toggle_var) # pylint:disable=protected-access + retval = {"text": widget._text, # pylint:disable=protected-access + "toggle_var": widget._toggle_var} # pylint:disable=protected-access return retval def get_all_children_config(self, widget, child_list): @@ -988,7 +990,7 @@ def build_one_control(self): if self.option.control != ttk.Checkbutton: ctl.pack(padx=5, pady=5, fill=tk.X, expand=True) if self.option.helptext is not None and not self.helpset: - tooltip_kwargs = dict(text=self.option.helptext) + tooltip_kwargs = {"text": self.option.helptext} if self.option.sysbrowser is not None: tooltip_kwargs["text_variable"] = self.option.tk_var _get_tooltip(ctl, **tooltip_kwargs) @@ -1071,7 +1073,7 @@ def slider_control(self): "rounding: %s, min_max: %s)", self.option.name, self.option.dtype, self.option.rounding, self.option.min_max) validate = self.slider_check_int if self.option.dtype == int else self.slider_check_float - vcmd = (self.frame.register(validate)) + vcmd = self.frame.register(validate) tbox = tk.Entry(self.frame, width=8, textvariable=self.option.tk_var, @@ -1246,15 +1248,15 @@ def __init__(self, opt_name, tk_var, control_frame, sysbrowser_dict, style): @property def helptext(self): """ Dict containing tooltip text for buttons """ - retval = dict(folder=_("Select a folder..."), - load=_("Select a file..."), - load2=_("Select a file..."), - picture=_("Select a folder of images..."), - video=_("Select a video..."), - model=_("Select a model folder..."), - multi_load=_("Select one or more files..."), - context=_("Select a file or folder..."), - save_as=_("Select a save location...")) + retval = {"folder": _("Select a folder..."), + "load": _("Select a file..."), + "load2": _("Select a file..."), + "picture": _("Select a folder of images..."), + "video": _("Select a video..."), + "model": _("Select a model folder..."), + "multi_load": _("Select one or more files..."), + "context": _("Select a file or folder..."), + "save_as": _("Select a save location...")} return retval @staticmethod diff --git a/lib/gui/display_command.py b/lib/gui/display_command.py index b973d34b6b..ab129349c2 100644 --- a/lib/gui/display_command.py +++ b/lib/gui/display_command.py @@ -4,11 +4,10 @@ import gettext import logging import os -import sys import tkinter as tk +import typing as T from tkinter import ttk -from typing import Dict, Optional, Tuple from lib.training.preview_tk import PreviewTk @@ -19,11 +18,6 @@ from .control_helper import set_slider_rounding from .utils import FileHandler, get_config, get_images, preview_trigger -if sys.version_info < (3, 8): - from typing_extensions import get_args, Literal -else: - from typing import get_args, Literal - logger = logging.getLogger(__name__) # pylint: disable=invalid-name # LOCALES @@ -92,7 +86,7 @@ def __init__(self, *args, **kwargs) -> None: logger.debug("Initializing %s (args: %s, kwargs: %s)", self.__class__.__name__, args, kwargs) self._preview = get_images().preview_train - self._display: Optional[PreviewTk] = None + self._display: PreviewTk | None = None super().__init__(*args, **kwargs) logger.debug("Initialized %s", self.__class__.__name__) @@ -177,9 +171,9 @@ def __init__(self, tab_name: str, helptext: str, wait_time: int, - command: Optional[str] = None) -> None: - self._trace_vars: Dict[Literal["smoothgraph", "display_iterations"], - Tuple[tk.BooleanVar, str]] = {} + command: str | None = None) -> None: + self._trace_vars: dict[T.Literal["smoothgraph", "display_iterations"], + tuple[tk.BooleanVar, str]] = {} super().__init__(parent, tab_name, helptext, wait_time, command) def set_vars(self) -> None: @@ -446,7 +440,7 @@ def save_items(self) -> None: def _add_trace_variables(self) -> None: """ Add tracing for when the option sliders are updated, for updating the graph. """ - for name, action in zip(get_args(Literal["smoothgraph", "display_iterations"]), + for name, action in zip(T.get_args(T.Literal["smoothgraph", "display_iterations"]), (self._smooth_amount_callback, self._iteration_limit_callback)): var = self.vars[name] if name not in self._trace_vars: diff --git a/lib/gui/display_graph.py b/lib/gui/display_graph.py index 8a503e5ce7..3dd1511f5e 100755 --- a/lib/gui/display_graph.py +++ b/lib/gui/display_graph.py @@ -1,12 +1,13 @@ #!/usr/bin python3 """ Graph functions for Display Frame area of the Faceswap GUI """ +from __future__ import annotations import datetime import logging import os import tkinter as tk +import typing as T from tkinter import ttk -from typing import cast, Union, List, Optional, Tuple, TYPE_CHECKING from math import ceil, floor import numpy as np @@ -20,7 +21,7 @@ from .custom_widgets import Tooltip from .utils import get_config, get_images, LongRunningTask -if TYPE_CHECKING: +if T.TYPE_CHECKING: from matplotlib.lines import Line2D matplotlib.use("TkAgg") @@ -49,8 +50,8 @@ def __init__(self, parent: ttk.Frame, data, ylabel: str) -> None: self._ylabel = ylabel self._colourmaps = ["Reds", "Blues", "Greens", "Purples", "Oranges", "Greys", "copper", "summer", "bone", "hot", "cool", "pink", "Wistia", "spring", "winter"] - self._lines: List["Line2D"] = [] - self._toolbar: Optional["NavigationToolbar"] = None + self._lines: list[Line2D] = [] + self._toolbar: "NavigationToolbar" | None = None self._fig = Figure(figsize=(4, 4), dpi=75) self._ax1 = self._fig.add_subplot(1, 1, 1) @@ -129,7 +130,7 @@ def _axes_limits_set_default(self) -> None: self._ax1.set_ylim(0.00, 100.0) self._ax1.set_xlim(0, 1) - def _axes_limits_set(self, data: List[float]) -> None: + def _axes_limits_set(self, data: list[float]) -> None: """ Set the axes limits. Parameters @@ -154,7 +155,7 @@ def _axes_limits_set(self, data: List[float]) -> None: self._axes_limits_set_default() @staticmethod - def _axes_data_get_min_max(data: List[float]) -> Tuple[float, float]: + def _axes_data_get_min_max(data: list[float]) -> tuple[float, float]: """ Obtain the minimum and maximum values for the y-axis from the given data points. Parameters @@ -188,7 +189,7 @@ def _axes_set_yscale(self, scale: str) -> None: logger.debug("yscale: '%s'", scale) self._ax1.set_yscale(scale) - def _lines_sort(self, keys: List[str]) -> List[List[Union[str, int, Tuple[float]]]]: + def _lines_sort(self, keys: list[str]) -> list[list[str | int | tuple[float]]]: """ Sort the data keys into consistent order and set line color map and line width. Parameters @@ -202,8 +203,8 @@ def _lines_sort(self, keys: List[str]) -> List[List[Union[str, int, Tuple[float] A list of loss keys with their corresponding line formatting and color information """ logger.trace("Sorting lines") # type:ignore[attr-defined] - raw_lines: List[List[str]] = [] - sorted_lines: List[List[str]] = [] + raw_lines: list[list[str]] = [] + sorted_lines: list[list[str]] = [] for key in sorted(keys): title = key.replace("_", " ").title() if key.startswith("raw"): @@ -217,7 +218,7 @@ def _lines_sort(self, keys: List[str]) -> List[List[Union[str, int, Tuple[float] return lines @staticmethod - def _lines_groupsize(raw_lines: List[List[str]], sorted_lines: List[List[str]]) -> int: + def _lines_groupsize(raw_lines: list[list[str]], sorted_lines: list[list[str]]) -> int: """ Get the number of items in each group. If raw data isn't selected, then check the length of remaining groups until something is @@ -246,8 +247,8 @@ def _lines_groupsize(raw_lines: List[List[str]], sorted_lines: List[List[str]]) return groupsize def _lines_style(self, - lines: List[List[str]], - groupsize: int) -> List[List[Union[str, int, Tuple[float]]]]: + lines: list[list[str]], + groupsize: int) -> list[list[str | int | tuple[float]]]: """ Obtain the color map and line width for each group. Parameters @@ -266,13 +267,13 @@ def _lines_style(self, groups = int(len(lines) / groupsize) colours = self._lines_create_colors(groupsize, groups) widths = list(range(1, groups + 1)) - retval = cast(List[List[Union[str, int, Tuple[float]]]], lines) + retval = T.cast(list[list[str | int | tuple[float]]], lines) for idx, item in enumerate(retval): linewidth = widths[idx // groupsize] item.extend((linewidth, colours[idx])) return retval - def _lines_create_colors(self, groupsize: int, groups: int) -> List[Tuple[float]]: + def _lines_create_colors(self, groupsize: int, groups: int) -> list[tuple[float]]: """ Create the color maps. Parameters @@ -336,8 +337,8 @@ class TrainingGraph(GraphBase): # pylint: disable=too-many-ancestors def __init__(self, parent: ttk.Frame, data, ylabel: str) -> None: super().__init__(parent, data, ylabel) - self._thread: Optional[LongRunningTask] = None # Thread for LongRunningTask - self._displayed_keys: List[str] = [] + self._thread: LongRunningTask | None = None # Thread for LongRunningTask + self._displayed_keys: list[str] = [] self._add_callback() def _add_callback(self) -> None: @@ -352,7 +353,7 @@ def build(self) -> None: def refresh(self, *args) -> None: # pylint: disable=unused-argument """ Read the latest loss data and apply to current graph """ - refresh_var = cast(tk.BooleanVar, get_config().tk_vars.refresh_graph) + refresh_var = T.cast(tk.BooleanVar, get_config().tk_vars.refresh_graph) if not refresh_var.get() and self._thread is None: return @@ -533,7 +534,7 @@ def _Button(frame: ttk.Frame, # pylint:disable=arguments-differ,arguments-renam text: str, image_file: str, toggle: bool, - command) -> Union[ttk.Button, ttk.Checkbutton]: + command) -> ttk.Button | ttk.Checkbutton: """ Override the default button method to use our icons and ttk widgets for consistent GUI layout. @@ -563,10 +564,10 @@ def _Button(frame: ttk.Frame, # pylint:disable=arguments-differ,arguments-renam img = get_images().icons[icon] if not toggle: - btn: Union[ttk.Button, ttk.Checkbutton] = ttk.Button(frame, - text=text, - image=img, - command=command) + btn: ttk.Button | ttk.Checkbutton = ttk.Button(frame, + text=text, + image=img, + command=command) else: var = tk.IntVar(master=frame) btn = ttk.Checkbutton(frame, text=text, image=img, command=command, variable=var) diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 0677c31f43..694655b021 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -1,6 +1,6 @@ #!/usr/bin python3 """ The Menu Bars for faceswap GUI """ - +from __future__ import annotations import gettext import locale import logging @@ -33,7 +33,7 @@ _WORKING_DIR = os.path.dirname(os.path.realpath(sys.argv[0])) -_RESOURCES: T.List[T.Tuple[str, str]] = [ +_RESOURCES: list[tuple[str, str]] = [ (_("faceswap.dev - Guides and Forum"), "https://www.faceswap.dev"), (_("Patreon - Support this project"), "https://www.patreon.com/faceswap"), (_("Discord - The FaceSwap Discord server"), "https://discord.gg/VasFUAy"), @@ -48,7 +48,7 @@ class MainMenuBar(tk.Menu): # pylint:disable=too-many-ancestors master: :class:`tkinter.Tk` The root tkinter object """ - def __init__(self, master: "FaceswapGui") -> None: + def __init__(self, master: FaceswapGui) -> None: logger.debug("Initializing %s", self.__class__.__name__) super().__init__(master) self.root = master @@ -431,7 +431,7 @@ def _build_branches_menu(self) -> bool: return True @classmethod - def _get_branches(cls) -> T.Optional[str]: + def _get_branches(cls) -> str | None: """ Get the available github branches Returns @@ -453,7 +453,7 @@ def _get_branches(cls) -> T.Optional[str]: return stdout.decode(locale.getpreferredencoding(), errors="replace") @classmethod - def _filter_branches(cls, stdout: str) -> T.List[str]: + def _filter_branches(cls, stdout: str) -> list[str]: """ Filter the branches, remove duplicates and the current branch and return a sorted list. @@ -548,7 +548,7 @@ def __init__(self, parent: ttk.Frame) -> None: self._section_separator() @classmethod - def _loader_and_kwargs(cls, btntype: str) -> T.Tuple[str, T.Dict[str, bool]]: + def _loader_and_kwargs(cls, btntype: str) -> tuple[str, dict[str, bool]]: """ Get the loader name and key word arguments for the given button type Parameters diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index e525b639ad..ea361a7d39 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -1,6 +1,6 @@ #!/usr/bin python3 """ The pop-up window of the Faceswap GUI for the setting of configuration options. """ - +from __future__ import annotations from collections import OrderedDict from configparser import ConfigParser import gettext @@ -9,7 +9,8 @@ import sys import tkinter as tk from tkinter import ttk -from typing import Dict, TYPE_CHECKING +import typing as T + from importlib import import_module from lib.serializer import get_serializer @@ -18,7 +19,7 @@ from .custom_widgets import Tooltip from .utils import FileHandler, get_config, get_images, PATHCACHE -if TYPE_CHECKING: +if T.TYPE_CHECKING: from lib.config import FaceswapConfig logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -124,7 +125,7 @@ def __init__(self, name, configurations): super().__init__() self._root = get_config().root self._set_geometry() - self._tk_vars = dict(header=tk.StringVar()) + self._tk_vars = {"header": tk.StringVar()} theme = {**get_config().user_theme["group_panel"], **get_config().user_theme["group_settings"]} @@ -402,7 +403,7 @@ class DisplayArea(ttk.Frame): # pylint:disable=too-many-ancestors """ def __init__(self, top_level, parent, configurations, tree, theme): super().__init__(parent) - self._configs: Dict[str, "FaceswapConfig"] = configurations + self._configs: dict[str, FaceswapConfig] = configurations self._theme = theme self._tree = tree self._vars = {} @@ -443,7 +444,7 @@ def _get_config(self): sect = section.split(".")[-1] # Elevate global to root key = plugin if sect == "global" else f"{plugin}|{category}|{sect}" - retval[key] = dict(helptext=None, options=OrderedDict()) + retval[key] = {"helptext": None, "options": OrderedDict()} retval[key]["helptext"] = conf.defaults[section].helptext for option, params in conf.defaults[section].items.items(): @@ -632,7 +633,7 @@ def reset(self, page_only=False): def _get_new_config(self, page_only: bool, - config: "FaceswapConfig", + config: FaceswapConfig, category: str, lookup: str) -> ConfigParser: """ Obtain a new configuration file for saving @@ -812,9 +813,9 @@ def _get_filename(self, action): return None args = ("save_filename", "json") if action == "save" else ("filename", "json") - kwargs = dict(title=f"{action.title()} Preset...", - initial_folder=self._preset_path, - parent=self._parent) + kwargs = {"title": f"{action.title()} Preset...", + "initial_folder": self._preset_path, + "parent": self._parent} if action == "save": kwargs["initial_file"] = self._get_initial_filename() diff --git a/lib/gui/popup_session.py b/lib/gui/popup_session.py index d1511e599c..2d0162f02c 100644 --- a/lib/gui/popup_session.py +++ b/lib/gui/popup_session.py @@ -8,7 +8,6 @@ from dataclasses import dataclass, field from tkinter import ttk -from typing import Dict, List, Optional, Tuple, Type, Union from .control_helper import ControlBuilder, ControlPanelOption from .custom_widgets import Tooltip @@ -66,7 +65,7 @@ class SessionTKVars: outliers: tk.BooleanVar avgiterations: tk.IntVar smoothamount: tk.DoubleVar - loss_keys: Dict[str, tk.BooleanVar] = field(default_factory=dict) + loss_keys: dict[str, tk.BooleanVar] = field(default_factory=dict) class SessionPopUp(tk.Toplevel): @@ -82,13 +81,13 @@ def __init__(self, session_id: int, data_points: int) -> None: logger.debug("Initializing: %s: (session_id: %s, data_points: %s)", self.__class__.__name__, session_id, data_points) super().__init__() - self._thread: Optional[LongRunningTask] = None # Thread for loading data in background + self._thread: LongRunningTask | None = None # Thread for loading data in background self._default_view = "avg" if data_points > 1000 else "smoothed" self._session_id = None if session_id == "Total" else int(session_id) self._graph_frame = ttk.Frame(self) - self._graph: Optional[SessionGraph] = None - self._display_data: Optional[Calculations] = None + self._graph: SessionGraph | None = None + self._display_data: Calculations | None = None self._vars = self._set_vars() @@ -172,7 +171,7 @@ def _opts_combobox(self, frame: ttk.Frame) -> None: The frame that the options reside in """ logger.debug("Building Combo boxes") - choices = dict(Display=("Loss", "Rate"), Scale=("Linear", "Log")) + choices = {"Display": ("Loss", "Rate"), "Scale": ("Linear", "Log")} for item in ["Display", "Scale"]: var: tk.StringVar = getattr(self._vars, item.lower()) @@ -273,11 +272,11 @@ def _opts_slider(self, frame: ttk.Frame) -> None: logger.debug("Building Slider Controls") for item in ("avgiterations", "smoothamount"): if item == "avgiterations": - dtype: Union[Type[int], Type[float]] = int + dtype: type[int] | type[float] = int text = "Iterations to Average:" - default: Union[int, float] = 500 + default: int | float = 500 rounding = 25 - min_max: Tuple[int, Union[int, float]] = (25, 2500) + min_max: tuple[int, int | float] = (25, 2500) elif item == "smoothamount": dtype = float text = "Smoothing Amount:" @@ -404,20 +403,20 @@ def _set_help(cls, action: str) -> str: str The help text for the given action """ - lookup = dict( - reload=_("Refresh graph"), - save=_("Save display data to csv"), - avgiterations=_("Number of data points to sample for rolling average"), - smoothamount=_("Set the smoothing amount. 0 is no smoothing, 0.99 is maximum " - "smoothing"), - outliers=_("Flatten data points that fall more than 1 standard deviation from the " - "mean to the mean value."), - avg=_("Display rolling average of the data"), - smoothed=_("Smooth the data"), - raw=_("Display raw data"), - trend=_("Display polynormal data trend"), - display=_("Set the data to display"), - scale=_("Change y-axis scale")) + lookup = { + "reload": _("Refresh graph"), + "save": _("Save display data to csv"), + "avgiterations": _("Number of data points to sample for rolling average"), + "smoothamount": _("Set the smoothing amount. 0 is no smoothing, 0.99 is maximum " + "smoothing"), + "outliers": _("Flatten data points that fall more than 1 standard deviation from the " + "mean to the mean value."), + "avg": _("Display rolling average of the data"), + "smoothed": _("Smooth the data"), + "raw": _("Display raw data"), + "trend": _("Display polynormal data trend"), + "display": _("Set the data to display"), + "scale": _("Change y-axis scale")} return lookup.get(action.lower(), "") def _compile_display_data(self) -> bool: @@ -446,13 +445,13 @@ def _compile_display_data(self) -> bool: self._lbl_loading.pack(fill=tk.BOTH, expand=True) self.update_idletasks() - kwargs = dict(session_id=self._session_id, - display=self._vars.display.get(), - loss_keys=loss_keys, - selections=selections, - avg_samples=self._vars.avgiterations.get(), - smooth_amount=self._vars.smoothamount.get(), - flatten_outliers=self._vars.outliers.get()) + kwargs = {"session_id": self._session_id, + "display": self._vars.display.get(), + "loss_keys": loss_keys, + "selections": selections, + "avg_samples": self._vars.avgiterations.get(), + "smooth_amount": self._vars.smoothamount.get(), + "flatten_outliers": self._vars.outliers.get()} self._thread = LongRunningTask(target=self._get_display_data, kwargs=kwargs, widget=self) @@ -491,7 +490,7 @@ def _get_display_data(cls, **kwargs) -> Calculations: """ return Calculations(**kwargs) - def _check_valid_selection(self, loss_keys: List[str], selections: List[str]) -> bool: + def _check_valid_selection(self, loss_keys: list[str], selections: list[str]) -> bool: """ Check that there will be data to display. Parameters @@ -530,7 +529,7 @@ def _check_valid_data(self) -> bool: return False return True - def _selections_to_list(self) -> List[str]: + def _selections_to_list(self) -> list[str]: """ Compile checkbox selections to a list. Returns diff --git a/lib/gui/utils/config.py b/lib/gui/utils/config.py index 3d4096a34f..58e8152cee 100644 --- a/lib/gui/utils/config.py +++ b/lib/gui/utils/config.py @@ -1,19 +1,20 @@ #!/usr/bin python3 """ Global configuration optiopns for the Faceswap GUI """ +from __future__ import annotations import logging import os import sys import tkinter as tk +import typing as T from dataclasses import dataclass, field -from typing import Any, cast, Dict, Optional, Tuple, TYPE_CHECKING from lib.gui._config import Config as UserConfig from lib.gui.project import Project, Tasks from lib.gui.theme import Style from .file_handler import FileHandler -if TYPE_CHECKING: +if T.TYPE_CHECKING: from lib.gui.options import CliOptions from lib.gui.custom_widgets import StatusBar from lib.gui.command import CommandNotebook @@ -22,12 +23,12 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name PATHCACHE = os.path.join(os.path.realpath(os.path.dirname(sys.argv[0])), "lib", "gui", ".cache") -_CONFIG: Optional["Config"] = None +_CONFIG: Config | None = None def initialize_config(root: tk.Tk, - cli_opts: Optional["CliOptions"], - statusbar: Optional["StatusBar"]) -> Optional["Config"]: + cli_opts: CliOptions | None, + statusbar: StatusBar | None) -> Config | None: """ Initialize the GUI Master :class:`Config` and add to global constant. This should only be called once on first GUI startup. Future access to :class:`Config` @@ -145,13 +146,13 @@ def _initialize_variables(self) -> None: @dataclass class _GuiObjects: """ Data class for commonly accessed GUI Objects """ - cli_opts: Optional["CliOptions"] + cli_opts: CliOptions | None tk_vars: GlobalVariables project: Project tasks: Tasks - status_bar: Optional["StatusBar"] - default_options: Dict[str, Dict[str, Any]] = field(default_factory=dict) - command_notebook: Optional["CommandNotebook"] = None + status_bar: StatusBar | None + default_options: dict[str, dict[str, T.Any]] = field(default_factory=dict) + command_notebook: CommandNotebook | None = None class Config(): @@ -172,15 +173,15 @@ class Config(): """ def __init__(self, root: tk.Tk, - cli_opts: Optional["CliOptions"], - statusbar: Optional["StatusBar"]) -> None: + cli_opts: CliOptions | None, + statusbar: StatusBar | None) -> None: logger.debug("Initializing %s: (root %s, cli_opts: %s, statusbar: %s)", self.__class__.__name__, root, cli_opts, statusbar) - self._default_font = cast(dict, tk.font.nametofont("TkDefaultFont").configure())["family"] - self._constants = dict( - root=root, - scaling_factor=self._get_scaling(root), - default_font=self._default_font) + self._default_font = T.cast(dict, + tk.font.nametofont("TkDefaultFont").configure())["family"] + self._constants = {"root": root, + "scaling_factor": self._get_scaling(root), + "default_font": self._default_font} self._gui_objects = _GuiObjects( cli_opts=cli_opts, tk_vars=GlobalVariables(), @@ -211,7 +212,7 @@ def pathcache(self) -> str: # GUI Objects @property - def cli_opts(self) -> "CliOptions": + def cli_opts(self) -> CliOptions: """ :class:`lib.gui.options.CliOptions`: The command line options for this GUI Session. """ # This should only be None when a separate tool (not main GUI) is used, at which point # cli_opts do not exist @@ -234,12 +235,12 @@ def tasks(self) -> Tasks: return self._gui_objects.tasks @property - def default_options(self) -> Dict[str, Dict[str, Any]]: + def default_options(self) -> dict[str, dict[str, T.Any]]: """ dict: The default options for all tabs """ return self._gui_objects.default_options @property - def statusbar(self) -> "StatusBar": + def statusbar(self) -> StatusBar: """ :class:`lib.gui.custom_widgets.StatusBar`: The GUI StatusBar :class:`tkinter.ttk.Frame`. """ # This should only be None when a separate tool (not main GUI) is used, at which point @@ -248,31 +249,31 @@ def statusbar(self) -> "StatusBar": return self._gui_objects.status_bar @property - def command_notebook(self) -> Optional["CommandNotebook"]: + def command_notebook(self) -> CommandNotebook | None: """ :class:`lib.gui.command.CommandNotebook`: The main Faceswap Command Notebook. """ return self._gui_objects.command_notebook # Convenience GUI Objects @property - def tools_notebook(self) -> "ToolsNotebook": + def tools_notebook(self) -> ToolsNotebook: """ :class:`lib.gui.command.ToolsNotebook`: The Faceswap Tools sub-Notebook. """ assert self.command_notebook is not None return self.command_notebook.tools_notebook @property - def modified_vars(self) -> Dict[str, "tk.BooleanVar"]: + def modified_vars(self) -> dict[str, tk.BooleanVar]: """ dict: The command notebook modified tkinter variables. """ assert self.command_notebook is not None return self.command_notebook.modified_vars @property - def _command_tabs(self) -> Dict[str, int]: + def _command_tabs(self) -> dict[str, int]: """ dict: Command tab titles with their IDs. """ assert self.command_notebook is not None return self.command_notebook.tab_names @property - def _tools_tabs(self) -> Dict[str, int]: + def _tools_tabs(self) -> dict[str, int]: """ dict: Tools command tab titles with their IDs. """ assert self.command_notebook is not None return self.command_notebook.tools_tab_names @@ -284,17 +285,17 @@ def user_config(self) -> UserConfig: return self._user_config @property - def user_config_dict(self) -> Dict[str, Any]: # TODO Dataclass + def user_config_dict(self) -> dict[str, T.Any]: # TODO Dataclass """ dict: The GUI config in dict form. """ return self._user_config.config_dict @property - def user_theme(self) -> Dict[str, Any]: # TODO Dataclass + def user_theme(self) -> dict[str, T.Any]: # TODO Dataclass """ dict: The GUI theme selection options. """ return self._user_theme @property - def default_font(self) -> Tuple[str, int]: + def default_font(self) -> tuple[str, int]: """ tuple: The selected font as configured in user settings. First item is the font (`str`) second item the font size (`int`). """ font = self.user_config_dict["font"] @@ -328,7 +329,7 @@ def set_default_options(self) -> None: self._gui_objects.default_options = default self.project.set_default_options() - def set_command_notebook(self, notebook: "CommandNotebook") -> None: + def set_command_notebook(self, notebook: CommandNotebook) -> None: """ Set the command notebook to the :attr:`command_notebook` attribute and enable the modified callback for :attr:`project`. @@ -385,7 +386,7 @@ def refresh_config(self) -> None: """ Reload the user config from file. """ self._user_config = UserConfig(None) - def set_cursor_busy(self, widget: Optional[tk.Widget] = None) -> None: + def set_cursor_busy(self, widget: tk.Widget | None = None) -> None: """ Set the root or widget cursor to busy. Parameters @@ -399,7 +400,7 @@ def set_cursor_busy(self, widget: Optional[tk.Widget] = None) -> None: component.config(cursor="watch") # type: ignore component.update_idletasks() - def set_cursor_default(self, widget: Optional[tk.Widget] = None) -> None: + def set_cursor_default(self, widget: tk.Widget | None = None) -> None: """ Set the root or widget cursor to default. Parameters @@ -413,7 +414,7 @@ def set_cursor_default(self, widget: Optional[tk.Widget] = None) -> None: component.config(cursor="") # type: ignore component.update_idletasks() - def set_root_title(self, text: Optional[str] = None) -> None: + def set_root_title(self, text: str | None = None) -> None: """ Set the main title text for Faceswap. The title will always begin with 'Faceswap.py'. Additional text can be appended. diff --git a/lib/gui/utils/file_handler.py b/lib/gui/utils/file_handler.py index 59485c5c63..45ff8c5fa9 100644 --- a/lib/gui/utils/file_handler.py +++ b/lib/gui/utils/file_handler.py @@ -2,24 +2,15 @@ """ File browser utility functions for the Faceswap GUI. """ import logging import platform -import sys import tkinter as tk from tkinter import filedialog - -from typing import cast, Dict, IO, List, Optional, Tuple, Union - -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - +import typing as T logger = logging.getLogger(__name__) # pylint: disable=invalid-name - -_FILETYPE = Literal["default", "alignments", "config_project", "config_task", - "config_all", "csv", "image", "ini", "state", "log", "video"] -_HANDLETYPE = Literal["open", "save", "filename", "filename_multi", "save_filename", - "context", "dir"] +_FILETYPE = T.Literal["default", "alignments", "config_project", "config_task", + "config_all", "csv", "image", "ini", "state", "log", "video"] +_HANDLETYPE = T.Literal["open", "save", "filename", "filename_multi", "save_filename", + "context", "dir"] class FileHandler(): # pylint:disable=too-few-public-methods @@ -72,14 +63,14 @@ class FileHandler(): # pylint:disable=too-few-public-methods def __init__(self, handle_type: _HANDLETYPE, - file_type: Optional[_FILETYPE], - title: Optional[str] = None, - initial_folder: Optional[str] = None, - initial_file: Optional[str] = None, - command: Optional[str] = None, - action: Optional[str] = None, - variable: Optional[str] = None, - parent: Optional[tk.Frame] = None) -> None: + file_type: _FILETYPE | None, + title: str | None = None, + initial_folder: str | None = None, + initial_file: str | None = None, + command: str | None = None, + action: str | None = None, + variable: str | None = None, + parent: tk.Frame | None = None) -> None: logger.debug("Initializing %s: (handle_type: '%s', file_type: '%s', title: '%s', " "initial_folder: '%s', initial_file: '%s', command: '%s', action: '%s', " "variable: %s, parent: %s)", self.__class__.__name__, handle_type, file_type, @@ -101,35 +92,35 @@ def __init__(self, logger.debug("Initialized %s", self.__class__.__name__) @property - def _filetypes(self) -> Dict[str, List[Tuple[str, str]]]: + def _filetypes(self) -> dict[str, list[tuple[str, str]]]: """ dict: The accepted extensions for each file type for opening/saving """ all_files = ("All files", "*.*") - filetypes = dict( - default=[all_files], - alignments=[("Faceswap Alignments", "*.fsa"), all_files], - config_project=[("Faceswap Project files", "*.fsw"), all_files], - config_task=[("Faceswap Task files", "*.fst"), all_files], - config_all=[("Faceswap Project and Task files", "*.fst *.fsw"), all_files], - csv=[("Comma separated values", "*.csv"), all_files], - image=[("Bitmap", "*.bmp"), - ("JPG", "*.jpeg *.jpg"), - ("PNG", "*.png"), - ("TIFF", "*.tif *.tiff"), - all_files], - ini=[("Faceswap config files", "*.ini"), all_files], - json=[("JSON file", "*.json"), all_files], - model=[("Keras model files", "*.h5"), all_files], - state=[("State files", "*.json"), all_files], - log=[("Log files", "*.log"), all_files], - video=[("Audio Video Interleave", "*.avi"), - ("Flash Video", "*.flv"), - ("Matroska", "*.mkv"), - ("MOV", "*.mov"), - ("MP4", "*.mp4"), - ("MPEG", "*.mpeg *.mpg *.ts *.vob"), - ("WebM", "*.webm"), - ("Windows Media Video", "*.wmv"), - all_files]) + filetypes = { + "default": [all_files], + "alignments": [("Faceswap Alignments", "*.fsa"), all_files], + "config_project": [("Faceswap Project files", "*.fsw"), all_files], + "config_task": [("Faceswap Task files", "*.fst"), all_files], + "config_all": [("Faceswap Project and Task files", "*.fst *.fsw"), all_files], + "csv": [("Comma separated values", "*.csv"), all_files], + "image": [("Bitmap", "*.bmp"), + ("JPG", "*.jpeg *.jpg"), + ("PNG", "*.png"), + ("TIFF", "*.tif *.tiff"), + all_files], + "ini": [("Faceswap config files", "*.ini"), all_files], + "json": [("JSON file", "*.json"), all_files], + "model": [("Keras model files", "*.h5"), all_files], + "state": [("State files", "*.json"), all_files], + "log": [("Log files", "*.log"), all_files], + "video": [("Audio Video Interleave", "*.avi"), + ("Flash Video", "*.flv"), + ("Matroska", "*.mkv"), + ("MOV", "*.mov"), + ("MP4", "*.mp4"), + ("MPEG", "*.mpeg *.mpg *.ts *.vob"), + ("WebM", "*.webm"), + ("Windows Media Video", "*.wmv"), + all_files]} # Add in multi-select options and upper case extensions for Linux for key in filetypes: @@ -142,32 +133,32 @@ def _filetypes(self) -> Dict[str, List[Tuple[str, str]]]: multi = [f"{key.title()} Files"] multi.append(" ".join([ftype[1] for ftype in filetypes[key] if ftype[0] != "All files"])) - filetypes[key].insert(0, cast(Tuple[str, str], tuple(multi))) + filetypes[key].insert(0, T.cast(tuple[str, str], tuple(multi))) return filetypes @property - def _contexts(self) -> Dict[str, Dict[str, Union[str, Dict[str, str]]]]: + def _contexts(self) -> dict[str, dict[str, str | dict[str, str]]]: """dict: Mapping of commands, actions and their corresponding file dialog for context handle types. """ - return dict(effmpeg=dict(input={"extract": "filename", - "gen-vid": "dir", - "get-fps": "filename", - "get-info": "filename", - "mux-audio": "filename", - "rescale": "filename", - "rotate": "filename", - "slice": "filename"}, - output={"extract": "dir", - "gen-vid": "save_filename", - "get-fps": "nothing", - "get-info": "nothing", - "mux-audio": "save_filename", - "rescale": "save_filename", - "rotate": "save_filename", - "slice": "save_filename"})) + return {"effmpeg": {"input": {"extract": "filename", + "gen-vid": "dir", + "get-fps": "filename", + "get-info": "filename", + "mux-audio": "filename", + "rescale": "filename", + "rotate": "filename", + "slice": "filename"}, + "output": {"extract": "dir", + "gen-vid": "save_filename", + "get-fps": "nothing", + "get-info": "nothing", + "mux-audio": "save_filename", + "rescale": "save_filename", + "rotate": "save_filename", + "slice": "save_filename"}}} @classmethod - def _set_dummy_master(cls) -> Optional[tk.Frame]: + def _set_dummy_master(cls) -> tk.Frame | None: """ Add an option to force black font on Linux file dialogs KDE issue that displays light font on white background). @@ -183,7 +174,7 @@ def _set_dummy_master(cls) -> Optional[tk.Frame]: if platform.system().lower() == "linux": frame = tk.Frame() frame.option_add("*foreground", "black") - retval: Optional[tk.Frame] = frame + retval: tk.Frame | None = frame else: retval = None return retval @@ -196,7 +187,7 @@ def _remove_dummy_master(self) -> None: del self._dummy_master self._dummy_master = None - def _set_defaults(self) -> Dict[str, Optional[str]]: + def _set_defaults(self) -> dict[str, str | None]: """ Set the default file type for the file dialog. Generally the first found file type will be used, but this is overridden if it is not appropriate. @@ -205,7 +196,7 @@ def _set_defaults(self) -> Dict[str, Optional[str]]: dict: The default file extension for each file type """ - defaults: Dict[str, Optional[str]] = { + defaults: dict[str, str | None] = { key: next(ext for ext in val[0][1].split(" ")).replace("*", "") for key, val in self._filetypes.items()} defaults["default"] = None @@ -215,15 +206,15 @@ def _set_defaults(self) -> Dict[str, Optional[str]]: return defaults def _set_kwargs(self, - title: Optional[str], - initial_folder: Optional[str], - initial_file: Optional[str], - file_type: Optional[_FILETYPE], - command: Optional[str], - action: Optional[str], - variable: Optional[str], - parent: Optional[tk.Frame] - ) -> Dict[str, Union[None, tk.Frame, str, List[Tuple[str, str]]]]: + title: str | None, + initial_folder: str | None, + initial_file: str | None, + file_type: _FILETYPE | None, + command: str | None, + action: str | None, + variable: str | None, + parent: tk.Frame | None + ) -> dict[str, None | tk.Frame | str | list[tuple[str, str]]]: """ Generate the required kwargs for the requested file dialog browser. Parameters @@ -259,8 +250,8 @@ def _set_kwargs(self, title, initial_folder, initial_file, file_type, command, action, variable, parent) - kwargs: Dict[str, Union[None, tk.Frame, str, - List[Tuple[str, str]]]] = dict(master=self._dummy_master) + kwargs: dict[str, None | tk.Frame | str | list[tuple[str, str]]] = { + "master": self._dummy_master} if self._handletype.lower() == "context": assert command is not None and action is not None and variable is not None @@ -304,20 +295,20 @@ def _set_context_handletype(self, command: str, action: str, variable: str) -> N The variable associated with this file dialog """ if self._contexts[command].get(variable, None) is not None: - handletype = cast(Dict[str, Dict[str, Dict[str, str]]], - self._contexts)[command][variable][action] + handletype = T.cast(dict[str, dict[str, dict[str, str]]], + self._contexts)[command][variable][action] else: - handletype = cast(Dict[str, Dict[str, str]], - self._contexts)[command][action] + handletype = T.cast(dict[str, dict[str, str]], + self._contexts)[command][action] logger.debug(handletype) - self._handletype = cast(_HANDLETYPE, handletype) + self._handletype = T.cast(_HANDLETYPE, handletype) - def _open(self) -> Optional[IO]: + def _open(self) -> T.IO | None: """ Open a file. """ logger.debug("Popping Open browser") return filedialog.askopenfile(**self._kwargs) # type: ignore - def _save(self) -> Optional[IO]: + def _save(self) -> T.IO | None: """ Save a file. """ logger.debug("Popping Save browser") return filedialog.asksaveasfile(**self._kwargs) # type: ignore @@ -337,7 +328,7 @@ def _filename(self) -> str: logger.debug("Popping Filename browser") return filedialog.askopenfilename(**self._kwargs) # type: ignore - def _filename_multi(self) -> Tuple[str, ...]: + def _filename_multi(self) -> tuple[str, ...]: """ Get multiple existing file locations. """ logger.debug("Popping Filename browser") return filedialog.askopenfilenames(**self._kwargs) # type: ignore diff --git a/lib/gui/utils/image.py b/lib/gui/utils/image.py index 3163774445..37eb05283c 100644 --- a/lib/gui/utils/image.py +++ b/lib/gui/utils/image.py @@ -1,10 +1,9 @@ #!/usr/bin python3 """ Utilities for handling images in the Faceswap GUI """ - +from __future__ import annotations import logging import os -import sys -from typing import cast, Dict, List, Optional, Sequence, Tuple +import typing as T import cv2 import numpy as np @@ -14,15 +13,12 @@ from .config import get_config, PATHCACHE -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal +if T.TYPE_CHECKING: + from collections.abc import Sequence logger = logging.getLogger(__name__) # pylint: disable=invalid-name - -_IMAGES: Optional["Images"] = None -_PREVIEW_TRIGGER: Optional["PreviewTrigger"] = None +_IMAGES: "Images" | None = None +_PREVIEW_TRIGGER: "PreviewTrigger" | None = None TRAININGPREVIEW = ".gui_training_preview.png" @@ -51,7 +47,7 @@ def get_images() -> "Images": return _IMAGES -def _get_previews(image_path: str) -> List[str]: +def _get_previews(image_path: str) -> list[str]: """ Get the images stored within the given directory. Parameters @@ -164,12 +160,12 @@ def __init__(self, cache_path: str) -> None: self._output_path = "" self._modified: float = 0.0 - self._filenames: List[str] = [] - self._images: Optional[np.ndarray] = None - self._placeholder: Optional[np.ndarray] = None + self._filenames: list[str] = [] + self._images: np.ndarray | None = None + self._placeholder: np.ndarray | None = None - self._preview_image: Optional[Image.Image] = None - self._preview_image_tk: Optional[ImageTk.PhotoImage] = None + self._preview_image: Image.Image | None = None + self._preview_image_tk: ImageTk.PhotoImage | None = None logger.debug("Initialized %s", self.__class__.__name__) @@ -228,7 +224,7 @@ def _get_newest_folder(self) -> str: logger.debug("sorted folders: %s, return value: %s", folders, retval) return retval - def _get_newest_filenames(self, image_files: List[str]) -> List[str]: + def _get_newest_filenames(self, image_files: list[str]) -> list[str]: """ Return image filenames that have been modified since the last check. Parameters @@ -281,8 +277,8 @@ def _pad_and_border(self, image: Image.Image, size: int) -> np.ndarray: return retval def _process_samples(self, - samples: List[np.ndarray], - filenames: List[str], + samples: list[np.ndarray], + filenames: list[str], num_images: int) -> bool: """ Process the latest sample images into a displayable image. @@ -321,8 +317,8 @@ def _process_samples(self, return True def _load_images_to_cache(self, - image_files: List[str], - frame_dims: Tuple[int, int], + image_files: list[str], + frame_dims: tuple[int, int], thumbnail_size: int) -> bool: """ Load preview images to the image cache. @@ -349,7 +345,7 @@ def _load_images_to_cache(self, logger.debug("num_images: %s", num_images) if num_images == 0: return False - samples: List[np.ndarray] = [] + samples: list[np.ndarray] = [] start_idx = len(image_files) - num_images if len(image_files) > num_images else 0 show_files = sorted(image_files, key=os.path.getctime)[start_idx:] dropped_files = [] @@ -405,7 +401,7 @@ def _create_placeholder(self, thumbnail_size: int) -> None: self._placeholder = placeholder logger.debug("Created placeholder. shape: %s", placeholder.shape) - def _place_previews(self, frame_dims: Tuple[int, int]) -> Image.Image: + def _place_previews(self, frame_dims: tuple[int, int]) -> Image.Image: """ Format the preview thumbnails stored in the cache into a grid fitting the display panel. @@ -441,12 +437,12 @@ def _place_previews(self, frame_dims: Tuple[int, int]) -> Image.Image: placeholder = np.concatenate([np.expand_dims(self._placeholder, 0)] * remainder) samples = np.concatenate((samples, placeholder)) - display = np.vstack([np.hstack(cast(Sequence, samples[row * cols: (row + 1) * cols])) + display = np.vstack([np.hstack(T.cast("Sequence", samples[row * cols: (row + 1) * cols])) for row in range(rows)]) logger.debug("display shape: %s", display.shape) return Image.fromarray(display) - def load_latest_preview(self, thumbnail_size: int, frame_dims: Tuple[int, int]) -> bool: + def load_latest_preview(self, thumbnail_size: int, frame_dims: tuple[int, int]) -> bool: """ Load the latest preview image for extract and convert. Retrieves the latest preview images from the faceswap output folder, resizes to thumbnails @@ -524,7 +520,7 @@ class Images(): def __init__(self) -> None: logger.debug("Initializing %s", self.__class__.__name__) self._pathpreview = os.path.join(PATHCACHE, "preview") - self._pathoutput: Optional[str] = None + self._pathoutput: str | None = None self._batch_mode = False self._preview_train = PreviewTrain(self._pathpreview) self._preview_extract = PreviewExtract(self._pathpreview) @@ -542,7 +538,7 @@ def preview_extract(self) -> PreviewExtract: return self._preview_extract @property - def icons(self) -> Dict[str, ImageTk.PhotoImage]: + def icons(self) -> dict[str, ImageTk.PhotoImage]: """ dict: The faceswap icons for all parts of the GUI. The dictionary key is the icon name (`str`) the value is the icon sized and formatted for display (:class:`PIL.ImageTK.PhotoImage`). @@ -557,7 +553,7 @@ def icons(self) -> Dict[str, ImageTk.PhotoImage]: return self._icons @staticmethod - def _load_icons() -> Dict[str, ImageTk.PhotoImage]: + def _load_icons() -> dict[str, ImageTk.PhotoImage]: """ Scan the icons cache folder and load the icons into :attr:`icons` for retrieval throughout the GUI. @@ -569,7 +565,7 @@ def _load_icons() -> Dict[str, ImageTk.PhotoImage]: """ size = get_config().user_config_dict.get("icon_size", 16) size = int(round(size * get_config().scaling_factor)) - icons: Dict[str, ImageTk.PhotoImage] = {} + icons: dict[str, ImageTk.PhotoImage] = {} pathicons = os.path.join(PATHCACHE, "icons") for fname in os.listdir(pathicons): name, ext = os.path.splitext(fname) @@ -609,12 +605,12 @@ class PreviewTrigger(): """ def __init__(self) -> None: logger.debug("Initializing: %s", self.__class__.__name__) - self._trigger_files = dict(update=os.path.join(PATHCACHE, ".preview_trigger"), - mask_toggle=os.path.join(PATHCACHE, ".preview_mask_toggle")) + self._trigger_files = {"update": os.path.join(PATHCACHE, ".preview_trigger"), + "mask_toggle": os.path.join(PATHCACHE, ".preview_mask_toggle")} logger.debug("Initialized: %s (trigger_files: %s)", self.__class__.__name__, self._trigger_files) - def set(self, trigger_type: Literal["update", "mask_toggle"]): + def set(self, trigger_type: T.Literal["update", "mask_toggle"]): """ Place the trigger file into the cache folder Parameters @@ -629,7 +625,7 @@ def set(self, trigger_type: Literal["update", "mask_toggle"]): pass logger.debug("Set preview trigger: %s", trigger) - def clear(self, trigger_type: Optional[Literal["update", "mask_toggle"]] = None) -> None: + def clear(self, trigger_type: T.Literal["update", "mask_toggle"] | None = None) -> None: """ Remove the trigger file from the cache folder. Parameters diff --git a/lib/gui/utils/misc.py b/lib/gui/utils/misc.py index 52a6d4e8fd..2506af3799 100644 --- a/lib/gui/utils/misc.py +++ b/lib/gui/utils/misc.py @@ -1,15 +1,17 @@ #!/usr/bin/env python3 """ Miscellaneous Utility functions for the GUI. Includes LongRunningTask object """ +from __future__ import annotations import logging import sys +import typing as T from threading import Event, Thread -from typing import (Any, Callable, cast, Dict, Optional, Tuple, Type, TYPE_CHECKING) from queue import Queue from .config import get_config -if TYPE_CHECKING: +if T.TYPE_CHECKING: + from collections.abc import Callable from types import TracebackType from lib.multithreading import _ErrorType @@ -31,15 +33,15 @@ class LongRunningTask(Thread): cursor in the correct location. Default: ``None``. """ _target: Callable - _args: Tuple - _kwargs: Dict[str, Any] + _args: tuple + _kwargs: dict[str, T.Any] _name: str def __init__(self, - target: Optional[Callable] = None, - name: Optional[str] = None, - args: Tuple = (), - kwargs: Optional[Dict[str, Any]] = None, + target: Callable | None = None, + name: str | None = None, + args: tuple = (), + kwargs: dict[str, T.Any] | None = None, *, daemon: bool = True, widget=None): @@ -48,7 +50,7 @@ def __init__(self, daemon) super().__init__(target=target, name=name, args=args, kwargs=kwargs, daemon=daemon) - self.err: "_ErrorType" = None + self.err: _ErrorType = None self._widget = widget self._config = get_config() self._config.set_cursor_busy(widget=self._widget) @@ -70,8 +72,8 @@ def run(self) -> None: retval = self._target(*self._args, **self._kwargs) self._queue.put(retval) except Exception: # pylint: disable=broad-except - self.err = cast(Tuple[Type[BaseException], BaseException, "TracebackType"], - sys.exc_info()) + self.err = T.cast(tuple[type[BaseException], BaseException, "TracebackType"], + sys.exc_info()) assert self.err is not None logger.debug("Error in thread (%s): %s", self._name, self.err[1].with_traceback(self.err[2])) @@ -81,7 +83,7 @@ def run(self) -> None: # an argument that has a member that points to the thread. del self._target, self._args, self._kwargs - def get_result(self) -> Any: + def get_result(self) -> T.Any: """ Return the result from the given task. Returns diff --git a/lib/image.py b/lib/image.py index 06c13c5d68..8713335eae 100644 --- a/lib/image.py +++ b/lib/image.py @@ -1,17 +1,17 @@ #!/usr/bin python3 """ Utilities for working with images and videos """ - +from __future__ import annotations import logging import re import subprocess import os import struct import sys +import typing as T from ast import literal_eval from bisect import bisect from concurrent import futures -from typing import Optional, TYPE_CHECKING, Union from zlib import crc32 import cv2 @@ -24,7 +24,7 @@ from lib.queue_manager import queue_manager, QueueEmpty from lib.utils import convert_to_secs, FaceswapError, _video_extensions, get_image_paths -if TYPE_CHECKING: +if T.TYPE_CHECKING: from lib.align.alignments import PNGHeaderDict logger = logging.getLogger(__name__) # pylint:disable=invalid-name @@ -558,7 +558,7 @@ def update_existing_metadata(filename, metadata): def encode_image(image: np.ndarray, extension: str, - metadata: Optional["PNGHeaderDict"] = None) -> bytes: + metadata: PNGHeaderDict | None = None) -> bytes: """ Encode an image. Parameters @@ -1433,8 +1433,8 @@ def _process(self, queue): def _save(self, filename: str, - image: Union[bytes, np.ndarray], - sub_folder: Optional[str]) -> None: + image: bytes | np.ndarray, + sub_folder: str | None) -> None: """ Save a single image inside a ThreadPoolExecutor Parameters @@ -1468,8 +1468,8 @@ def _save(self, def save(self, filename: str, - image: Union[bytes, np.ndarray], - sub_folder: Optional[str] = None) -> None: + image: bytes | np.ndarray, + sub_folder: str | None = None) -> None: """ Save the given image in the background thread Ensure that :func:`close` is called once all save operations are complete. diff --git a/lib/keras_utils.py b/lib/keras_utils.py index 0f00196245..9f27898620 100644 --- a/lib/keras_utils.py +++ b/lib/keras_utils.py @@ -117,7 +117,7 @@ def __init__(self, from_space: str, to_space: str) -> None: self._xyz_multipliers = K.constant([116, 500, 200], dtype="float32") @classmethod - def _get_rgb_xyz_map(cls) -> T.Tuple[Tensor, Tensor]: + def _get_rgb_xyz_map(cls) -> tuple[Tensor, Tensor]: """ Obtain the mapping and inverse mapping for rgb to xyz color space conversion. Returns diff --git a/lib/logger.py b/lib/logger.py index c7b34f5d94..623af04c39 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -11,7 +11,17 @@ import traceback from datetime import datetime -from typing import Union + + +# TODO - Remove this monkey patch when TF autograph fixed to handle newer logging lib +def _patched_format(self, record): + """ Autograph tf-2.10 has a bug with the 3.10 version of logging.PercentStyle._format(). It is + non-critical but spits out warnings. This is the Python 3.9 version of the function and should + be removed once fixed """ + return self._fmt % record.__dict__ # pylint:disable=protected-access + + +setattr(logging.PercentStyle, "_format", _patched_format) class FaceswapLogger(logging.Logger): @@ -76,11 +86,11 @@ class ColoredFormatter(logging.Formatter): def __init__(self, fmt: str, pad_newlines: bool = False, **kwargs) -> None: super().__init__(fmt, **kwargs) self._use_color = self._get_color_compatibility() - self._level_colors = dict(CRITICAL="\033[31m", # red - ERROR="\033[31m", # red - WARNING="\033[33m", # yellow - INFO="\033[32m", # green - VERBOSE="\033[34m") # blue + self._level_colors = {"CRITICAL": "\033[31m", # red + "ERROR": "\033[31m", # red + "WARNING": "\033[33m", # yellow + "INFO": "\033[32m", # green + "VERBOSE": "\033[34m"} # blue self._default_color = "\033[0m" self._newline_padding = self._get_newline_padding(pad_newlines, fmt) @@ -412,7 +422,7 @@ def _file_handler(loglevel, return handler -def _stream_handler(loglevel: int, is_gui: bool) -> Union[logging.StreamHandler, TqdmHandler]: +def _stream_handler(loglevel: int, is_gui: bool) -> logging.StreamHandler | TqdmHandler: """ Add a stream handler for the current Faceswap session. The stream handler will only ever output at a maximum of VERBOSE level to avoid spamming the console. diff --git a/lib/model/autoclip.py b/lib/model/autoclip.py index e1750ae2b1..a9ccfe888d 100644 --- a/lib/model/autoclip.py +++ b/lib/model/autoclip.py @@ -1,8 +1,6 @@ """ Auto clipper for clipping gradients. """ -from typing import List - +import numpy as np import tensorflow as tf -import tensorflow_probability as tfp class AutoClipper(): # pylint:disable=too-few-public-methods @@ -22,12 +20,56 @@ class AutoClipper(): # pylint:disable=too-few-public-methods original paper: https://arxiv.org/abs/2007.14469 """ def __init__(self, clip_percentile: int, history_size: int = 10000): - self._clip_percentile = clip_percentile + self._clip_percentile = tf.cast(clip_percentile, tf.float64) self._grad_history = tf.Variable(tf.zeros(history_size), trainable=False) self._index = tf.Variable(0, trainable=False) self._history_size = history_size - def __call__(self, grads_and_vars: List[tf.Tensor]) -> List[tf.Tensor]: + def _percentile(self, grad_history: tf.Tensor) -> tf.Tensor: + """ Compute the clip percentile of the gradient history + + Parameters + ---------- + grad_history: :class:`tensorflow.Tensor` + Tge gradient history to calculate the clip percentile for + + Returns + ------- + :class:`tensorflow.Tensor` + A rank(:attr:`clip_percentile`) `Tensor` + + Notes + ----- + Adapted from + https://github.com/tensorflow/probability/blob/r0.14/tensorflow_probability/python/stats/quantiles.py + to remove reliance on full tensorflow_probability libraray + """ + with tf.name_scope("percentile"): + frac_at_q_or_below = self._clip_percentile / 100. + sorted_hist = tf.sort(grad_history, axis=-1, direction="ASCENDING") + + num = tf.cast(tf.shape(grad_history)[-1], tf.float64) + + # get indices + indices = tf.round((num - 1) * frac_at_q_or_below) + indices = tf.clip_by_value(tf.cast(indices, tf.int32), + 0, + tf.shape(grad_history)[-1] - 1) + gathered_hist = tf.gather(sorted_hist, indices, axis=-1) + + # Propagate NaNs. Apparently tf.is_nan doesn't like other dtypes + nan_batch_members = tf.reduce_any(tf.math.is_nan(grad_history), axis=None) + right_rank_matched_shape = tf.pad(tf.shape(nan_batch_members), + paddings=[[0, tf.rank(self._clip_percentile)]], + constant_values=1) + nan_batch_members = tf.reshape(nan_batch_members, shape=right_rank_matched_shape) + + nan = np.array(np.nan, gathered_hist.dtype.as_numpy_dtype) + gathered_hist = tf.where(nan_batch_members, nan, gathered_hist) + + return gathered_hist + + def __call__(self, grads_and_vars: list[tf.Tensor]) -> list[tf.Tensor]: """ Call the AutoClip function. Parameters @@ -40,8 +82,7 @@ def __call__(self, grads_and_vars: List[tf.Tensor]) -> List[tf.Tensor]: assign_idx = tf.math.mod(self._index, self._history_size) self._grad_history = self._grad_history[assign_idx].assign(total_norm) self._index = self._index.assign_add(1) - clip_value = tfp.stats.percentile(self._grad_history[: self._index], - q=self._clip_percentile) + clip_value = self._percentile(self._grad_history[: self._index]) return [(tf.clip_by_norm(g, clip_value), v) for g, v in grads_and_vars] @classmethod diff --git a/lib/model/losses/feature_loss.py b/lib/model/losses/feature_loss.py index 80d206b708..9898ae1b4a 100644 --- a/lib/model/losses/feature_loss.py +++ b/lib/model/losses/feature_loss.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """ Custom Feature Map Loss Functions for faceswap.py """ +from __future__ import annotations from dataclasses import dataclass, field import logging - -from typing import Any, Callable, Dict, Optional, List, Tuple +import typing as T # Ignore linting errors from Tensorflow's thoroughly broken import system import tensorflow as tf @@ -17,6 +17,9 @@ from lib.model.nets import AlexNet, SqueezeNet from lib.utils import GetModel +if T.TYPE_CHECKING: + from collections.abc import Callable + logger = logging.getLogger(__name__) @@ -39,10 +42,10 @@ class NetInfo: """ model_id: int = 0 model_name: str = "" - net: Optional[Callable] = None - init_kwargs: Dict[str, Any] = field(default_factory=dict) + net: Callable | None = None + init_kwargs: dict[str, T.Any] = field(default_factory=dict) needs_init: bool = True - outputs: List[Layer] = field(default_factory=list) + outputs: list[Layer] = field(default_factory=list) class _LPIPSTrunkNet(): # pylint:disable=too-few-public-methods @@ -67,7 +70,7 @@ def __init__(self, net_name: str, eval_mode: bool, load_weights: bool) -> None: logger.debug("Initialized: %s ", self.__class__.__name__) @property - def _nets(self) -> Dict[str, NetInfo]: + def _nets(self) -> dict[str, NetInfo]: """ :class:`NetInfo`: The Information about the requested net.""" return { "alex": NetInfo(model_id=15, @@ -176,7 +179,7 @@ def __init__(self, logger.debug("Initialized: %s", self.__class__.__name__) @property - def _nets(self) -> Dict[str, NetInfo]: + def _nets(self) -> dict[str, NetInfo]: """ :class:`NetInfo`: The Information about the requested net.""" return { "alex": NetInfo(model_id=18, @@ -186,7 +189,7 @@ def _nets(self) -> Dict[str, NetInfo]: "vgg16": NetInfo(model_id=20, model_name="vgg16_lpips_v1.h5")} - def _linear_block(self, net_output_layer: tf.Tensor) -> Tuple[tf.Tensor, tf.Tensor]: + def _linear_block(self, net_output_layer: tf.Tensor) -> tuple[tf.Tensor, tf.Tensor]: """ Build a linear block for a trunk network output. Parameters @@ -319,7 +322,7 @@ def __init__(self, # pylint:disable=too-many-arguments tf.keras.mixed_precision.set_global_policy("mixed_float16") logger.debug("Initialized: %s", self.__class__.__name__) - def _process_diffs(self, inputs: List[tf.Tensor]) -> List[tf.Tensor]: + def _process_diffs(self, inputs: list[tf.Tensor]) -> list[tf.Tensor]: """ Perform processing on the Trunk Network outputs. If :attr:`use_ldip` is enabled, process the diff values through the linear network, diff --git a/lib/model/losses/loss.py b/lib/model/losses/loss.py index 7e1828141c..ab03ff53fd 100644 --- a/lib/model/losses/loss.py +++ b/lib/model/losses/loss.py @@ -1,10 +1,9 @@ #!/usr/bin/env python3 """ Custom Loss Functions for faceswap.py """ -from __future__ import absolute_import - +from __future__ import annotations import logging -from typing import Callable, List, Tuple +import typing as T import numpy as np import tensorflow as tf @@ -13,6 +12,9 @@ from tensorflow.python.keras.engine import compile_utils # pylint:disable=no-name-in-module from tensorflow.keras import backend as K # pylint:disable=import-error +if T.TYPE_CHECKING: + from collections.abc import Callable + logger = logging.getLogger(__name__) @@ -61,7 +63,7 @@ def __init__(self, self._ave_spectrum = ave_spectrum self._log_matrix = log_matrix self._batch_matrix = batch_matrix - self._dims: Tuple[int, int] = (0, 0) + self._dims: tuple[int, int] = (0, 0) def _get_patches(self, inputs: tf.Tensor) -> tf.Tensor: """ Crop the incoming batch of images into patches as defined by :attr:`_patch_factor. @@ -470,7 +472,7 @@ def _conv_gaussian(self, inputs: tf.Tensor) -> tf.Tensor: retval = K.conv2d(padded_inputs, gauss, strides=1, padding="valid") return retval - def _get_laplacian_pyramid(self, inputs: tf.Tensor) -> List[tf.Tensor]: + def _get_laplacian_pyramid(self, inputs: tf.Tensor) -> list[tf.Tensor]: """ Obtain the Laplacian Pyramid. Parameters @@ -564,9 +566,9 @@ class LossWrapper(tf.keras.losses.Loss): def __init__(self) -> None: logger.debug("Initializing: %s", self.__class__.__name__) super().__init__(name="LossWrapper") - self._loss_functions: List[compile_utils.LossesContainer] = [] - self._loss_weights: List[float] = [] - self._mask_channels: List[int] = [] + self._loss_functions: list[compile_utils.LossesContainer] = [] + self._loss_weights: list[float] = [] + self._mask_channels: list[int] = [] logger.debug("Initialized: %s", self.__class__.__name__) def add_loss(self, @@ -628,7 +630,7 @@ def _apply_mask(cls, y_true: tf.Tensor, y_pred: tf.Tensor, mask_channel: int, - mask_prop: float = 1.0) -> Tuple[tf.Tensor, tf.Tensor]: + mask_prop: float = 1.0) -> tuple[tf.Tensor, tf.Tensor]: """ Apply the mask to the input y_true and y_pred. If a mask is not required then return the unmasked inputs. diff --git a/lib/model/losses/perceptual_loss.py b/lib/model/losses/perceptual_loss.py index ccb2cfa4e7..0fc09b81d7 100644 --- a/lib/model/losses/perceptual_loss.py +++ b/lib/model/losses/perceptual_loss.py @@ -2,9 +2,7 @@ """ TF Keras implementation of Perceptual Loss Functions for faceswap.py """ import logging -import sys - -from typing import Dict, Optional, Tuple +import typing as T import numpy as np import tensorflow as tf @@ -14,11 +12,6 @@ from lib.keras_utils import ColorSpaceConvert, frobenius_norm, replicate_pad -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - logger = logging.getLogger(__name__) @@ -101,7 +94,7 @@ def _depthwise_conv2d(cls, image: tf.Tensor, kernel: tf.Tensor) -> tf.Tensor: """ return K.depthwise_conv2d(image, kernel, strides=(1, 1), padding="valid") - def _get_ssim(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> Tuple[tf.Tensor, tf.Tensor]: + def _get_ssim(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tuple[tf.Tensor, tf.Tensor]: """ Obtain the structural similarity between a batch of true and predicted images. Parameters @@ -330,8 +323,8 @@ def __init__(self, lower_threshold_exponent: float = 0.4, upper_threshold_exponent: float = 0.95, epsilon: float = 1e-15, - pixels_per_degree: Optional[float] = None, - color_order: Literal["bgr", "rgb"] = "bgr") -> None: + pixels_per_degree: float | None = None, + color_order: T.Literal["bgr", "rgb"] = "bgr") -> None: logger.debug("Initializing: %s (computed_distance_exponent '%s', feature_exponent: %s, " "lower_threshold_exponent: %s, upper_threshold_exponent: %s, epsilon: %s, " "pixels_per_degree: %s, color_order: %s)", self.__class__.__name__, @@ -525,7 +518,7 @@ def __init__(self, pixels_per_degree: float) -> None: self._spatial_filters, self._radius = self._generate_spatial_filters() self._ycxcz2rgb = ColorSpaceConvert(from_space="ycxcz", to_space="rgb") - def _generate_spatial_filters(self) -> Tuple[tf.Tensor, int]: + def _generate_spatial_filters(self) -> tuple[tf.Tensor, int]: """ Generates spatial contrast sensitivity filters with width depending on the number of pixels per degree of visual angle of the observer for channels "A", "RG" and "BY" @@ -559,7 +552,7 @@ def _get_evaluation_domain(self, b1_rg: float, b2_rg: float, b1_by: float, - b2_by: float) -> Tuple[np.ndarray, int]: + b2_by: float) -> tuple[np.ndarray, int]: """ TODO docstring """ max_scale_parameter = max([b1_a, b2_a, b1_rg, b2_rg, b1_by, b2_by]) delta_x = 1.0 / self._pixels_per_degree @@ -570,7 +563,7 @@ def _get_evaluation_domain(self, return domain, radius @classmethod - def _generate_weights(cls, channel: Dict[str, float], domain: np.ndarray) -> tf.Tensor: + def _generate_weights(cls, channel: dict[str, float], domain: np.ndarray) -> tf.Tensor: """ TODO docstring """ a_1, b_1, a_2, b_2 = channel["a1"], channel["b1"], channel["a2"], channel["b2"] grad = (a_1 * np.sqrt(np.pi / b_1) * np.exp(-np.pi ** 2 * domain / b_1) + @@ -694,7 +687,7 @@ def __init__(self, filter_size: int = 11, filter_sigma: float = 1.5, max_value: float = 1.0, - power_factors: Tuple[float, ...] = (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) + power_factors: tuple[float, ...] = (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) ) -> None: self.filter_size = filter_size self.filter_sigma = filter_sigma diff --git a/lib/model/nets.py b/lib/model/nets.py index 87e740b72e..4fa8294d15 100644 --- a/lib/model/nets.py +++ b/lib/model/nets.py @@ -31,7 +31,7 @@ class _net(): # pylint:disable=too-few-public-methods The input shape for the model. Default: ``None`` """ def __init__(self, - input_shape: T.Optional[T.Tuple[int, int, int]] = None) -> None: + input_shape: tuple[int, int, int] | None = None) -> None: logger.debug("Initializing: %s (input_shape: %s)", self.__class__.__name__, input_shape) self._input_shape = (None, None, 3) if input_shape is None else input_shape assert len(self._input_shape) == 3 and self._input_shape[-1] == 3, ( @@ -56,7 +56,7 @@ class AlexNet(_net): # pylint:disable=too-few-public-methods input_shape, Tuple, optional The input shape for the model. Default: ``None`` """ - def __init__(self, input_shape: T.Optional[T.Tuple[int, int, int]] = None) -> None: + def __init__(self, input_shape: tuple[int, int, int] | None = None) -> None: super().__init__(input_shape) self._feature_indices = [0, 3, 6, 8, 10] # For naming equivalent to PyTorch self._filters = [64, 192, 384, 256, 256] # Filters at each block @@ -108,7 +108,7 @@ def _conv_block(cls, name=name)(var_x) return var_x - def __call__(self) -> Model: + def __call__(self) -> tf.keras.models.Model: """ Create the AlexNet Model Returns @@ -189,7 +189,7 @@ def _fire(cls, name=f"{name}.expand3x3")(squeezed) return layers.Concatenate(axis=-1, name=name)([expand1, expand3]) - def __call__(self) -> Model: + def __call__(self) -> tf.keras.models.Model: """ Create the SqueezeNet Model Returns diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index 96cc0e8fe9..9ffa1702b9 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -23,7 +23,7 @@ _CONFIG: dict = {} -_NAMES: T.Dict[str, int] = {} +_NAMES: dict[str, int] = {} def set_config(configuration: dict) -> None: @@ -189,7 +189,7 @@ class Conv2DOutput(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: T.Union[int, T.Tuple[int]], + kernel_size: int | tuple[int], activation: str = "sigmoid", padding: str = "same", **kwargs) -> None: self._name = kwargs.pop("name") if "name" in kwargs else _get_name( @@ -265,11 +265,11 @@ class Conv2DBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: T.Union[int, T.Tuple[int, int]] = 5, - strides: T.Union[int, T.Tuple[int, int]] = 2, + kernel_size: int | tuple[int, int] = 5, + strides: int | tuple[int, int] = 2, padding: str = "same", - normalization: T.Optional[str] = None, - activation: T.Optional[str] = "leakyrelu", + normalization: str | None = None, + activation: str | None = "leakyrelu", use_depthwise: bool = False, relu_alpha: float = 0.1, **kwargs) -> None: @@ -362,8 +362,8 @@ class SeparableConv2DBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: T.Union[int, T.Tuple[int, int]] = 5, - strides: T.Union[int, T.Tuple[int, int]] = 2, **kwargs) -> None: + kernel_size: int | tuple[int, int] = 5, + strides: int | tuple[int, int] = 2, **kwargs) -> None: self._name = _get_name(f"separableconv2d_{filters}") logger.debug("name: %s, filters: %s, kernel_size: %s, strides: %s, kwargs: %s)", self._name, filters, kernel_size, strides, kwargs) @@ -434,11 +434,11 @@ class UpscaleBlock(): # pylint:disable=too-few-public-methods def __init__(self, filters: int, - kernel_size: T.Union[int, T.Tuple[int, int]] = 3, + kernel_size: int | tuple[int, int] = 3, padding: str = "same", scale_factor: int = 2, - normalization: T.Optional[str] = None, - activation: T.Optional[str] = "leakyrelu", + normalization: str | None = None, + activation: str | None = "leakyrelu", **kwargs) -> None: self._name = _get_name(f"upscale_{filters}") logger.debug("name: %s. filters: %s, kernel_size: %s, padding: %s, scale_factor: %s, " @@ -521,9 +521,9 @@ class Upscale2xBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: T.Union[int, T.Tuple[int, int]] = 3, + kernel_size: int | tuple[int, int] = 3, padding: str = "same", - activation: T.Optional[str] = "leakyrelu", + activation: str | None = "leakyrelu", interpolation: str = "bilinear", sr_ratio: float = 0.5, scale_factor: int = 2, @@ -615,9 +615,9 @@ class UpscaleResizeImagesBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: T.Union[int, T.Tuple[int, int]] = 3, + kernel_size: int | tuple[int, int] = 3, padding: str = "same", - activation: T.Optional[str] = "leakyrelu", + activation: str | None = "leakyrelu", scale_factor: int = 2, interpolation: str = "bilinear") -> None: self._name = _get_name(f"upscale_ri_{filters}") @@ -700,9 +700,9 @@ class UpscaleDNYBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: T.Union[int, T.Tuple[int, int]] = 3, + kernel_size: int | tuple[int, int] = 3, padding: str = "same", - activation: T.Optional[str] = "leakyrelu", + activation: str | None = "leakyrelu", size: int = 2, interpolation: str = "bilinear", **kwargs) -> None: @@ -757,7 +757,7 @@ class ResidualBlock(): # pylint:disable=too-few-public-methods """ def __init__(self, filters: int, - kernel_size: T.Union[int, T.Tuple[int, int]] = 3, + kernel_size: int | tuple[int, int] = 3, padding: str = "same", **kwargs) -> None: self._name = _get_name(f"residual_{filters}") diff --git a/lib/model/session.py b/lib/model/session.py index a7450d7400..9cc1de1390 100644 --- a/lib/model/session.py +++ b/lib/model/session.py @@ -1,9 +1,9 @@ #!/usr/bin python3 """ Settings manager for Keras Backend """ - +from __future__ import annotations from contextlib import nullcontext import logging -from typing import Callable, ContextManager, List, Optional, Union +import typing as T import numpy as np import tensorflow as tf @@ -14,6 +14,9 @@ from lib.utils import get_backend +if T.TYPE_CHECKING: + from collections.abc import Callable + logger = logging.getLogger(__name__) # pylint:disable=invalid-name @@ -52,9 +55,9 @@ class KSession(): def __init__(self, name: str, model_path: str, - model_kwargs: Optional[dict] = None, + model_kwargs: dict | None = None, allow_growth: bool = False, - exclude_gpus: Optional[List[int]] = None, + exclude_gpus: list[int] | None = None, cpu_mode: bool = False) -> None: logger.trace("Initializing: %s (name: %s, model_path: %s, " # type:ignore "model_kwargs: %s, allow_growth: %s, exclude_gpus: %s, cpu_mode: %s)", @@ -67,12 +70,12 @@ def __init__(self, cpu_mode) self._model_path = model_path self._model_kwargs = {} if not model_kwargs else model_kwargs - self._model: Optional[Model] = None + self._model: Model | None = None logger.trace("Initialized: %s", self.__class__.__name__,) # type:ignore def predict(self, - feed: Union[List[np.ndarray], np.ndarray], - batch_size: Optional[int] = None) -> Union[List[np.ndarray], np.ndarray]: + feed: list[np.ndarray] | np.ndarray, + batch_size: int | None = None) -> list[np.ndarray] | np.ndarray: """ Get predictions from the model. This method is a wrapper for :func:`keras.predict()` function. For Tensorflow backends @@ -98,7 +101,7 @@ def predict(self, def _set_session(self, allow_growth: bool, exclude_gpus: list, - cpu_mode: bool) -> ContextManager: + cpu_mode: bool) -> T.ContextManager: """ Sets the backend session options. For CPU backends, this hides any GPUs from Tensorflow. diff --git a/lib/multithreading.py b/lib/multithreading.py index e85685a893..88599aae89 100644 --- a/lib/multithreading.py +++ b/lib/multithreading.py @@ -1,19 +1,22 @@ #!/usr/bin/env python3 """ Multithreading/processing utils for faceswap """ - +from __future__ import annotations import logging +import typing as T from multiprocessing import cpu_count import queue as Queue import sys import threading from types import TracebackType -from typing import Any, Callable, Dict, Generator, List, Tuple, Type, Optional, Set, Union + +if T.TYPE_CHECKING: + from collections.abc import Callable, Generator logger = logging.getLogger(__name__) # pylint: disable=invalid-name -_ErrorType = Optional[Union[Tuple[Type[BaseException], BaseException, TracebackType], - Tuple[Any, Any, Any]]] -_THREAD_NAMES: Set[str] = set() +_ErrorType: T.TypeAlias = (tuple[type[BaseException], BaseException, TracebackType] | + tuple[T.Any, T.Any, T.Any] | None) +_THREAD_NAMES: set[str] = set() def total_cpus(): @@ -62,17 +65,17 @@ class FSThread(threading.Thread): keyword arguments for the target invocation. Default: {}. """ _target: Callable - _args: Tuple - _kwargs: Dict[str, Any] + _args: tuple + _kwargs: dict[str, T.Any] _name: str def __init__(self, - target: Optional[Callable] = None, - name: Optional[str] = None, - args: Tuple = (), - kwargs: Optional[Dict[str, Any]] = None, + target: Callable | None = None, + name: str | None = None, + args: tuple = (), + kwargs: dict[str, T.Any] | None = None, *, - daemon: Optional[bool] = None) -> None: + daemon: bool | None = None) -> None: super().__init__(target=target, name=name, args=args, kwargs=kwargs, daemon=daemon) self.err: _ErrorType = None @@ -124,7 +127,7 @@ def __init__(self, target: Callable, *args, thread_count: int = 1, - name: Optional[str] = None, + name: str | None = None, **kwargs) -> None: self._name = _get_name(name if name else target.__name__) logger.debug("Initializing %s: (target: '%s', thread_count: %s)", @@ -132,7 +135,7 @@ def __init__(self, logger.trace("args: %s, kwargs: %s", args, kwargs) # type:ignore self.daemon = True self._thread_count = thread_count - self._threads: List[FSThread] = [] + self._threads: list[FSThread] = [] self._target = target self._args = args self._kwargs = kwargs @@ -144,7 +147,7 @@ def has_error(self) -> bool: return any(thread.err for thread in self._threads) @property - def errors(self) -> List[_ErrorType]: + def errors(self) -> list[_ErrorType]: """ list: List of thread error values """ return [thread.err for thread in self._threads if thread.err] @@ -253,9 +256,9 @@ class BackgroundGenerator(MultiThread): def __init__(self, generator: Callable, prefetch: int = 1, - name: Optional[str] = None, - args: Optional[Tuple] = None, - kwargs: Optional[Dict[str, Any]] = None) -> None: + name: str | None = None, + args: tuple | None = None, + kwargs: dict[str, T.Any] | None = None) -> None: super().__init__(name=name, target=self._run) self.queue: Queue.Queue = Queue.Queue(prefetch) self.generator = generator diff --git a/lib/queue_manager.py b/lib/queue_manager.py index d34dd5fa6a..7eeacc14f4 100644 --- a/lib/queue_manager.py +++ b/lib/queue_manager.py @@ -6,7 +6,6 @@ import logging import threading -from typing import Dict from queue import Queue, Empty as QueueEmpty # pylint: disable=unused-import; # noqa from time import sleep @@ -45,7 +44,7 @@ def __init__(self) -> None: logger.debug("Initializing %s", self.__class__.__name__) self.shutdown = threading.Event() - self.queues: Dict[str, EventQueue] = {} + self.queues: dict[str, EventQueue] = {} logger.debug("Initialized %s", self.__class__.__name__) def add_queue(self, name: str, maxsize: int = 0, create_new: bool = False) -> str: diff --git a/lib/sysinfo.py b/lib/sysinfo.py index 4dbfde2695..6d40d1783f 100644 --- a/lib/sysinfo.py +++ b/lib/sysinfo.py @@ -6,8 +6,8 @@ import os import platform import sys + from subprocess import PIPE, Popen -from typing import List, Optional import psutil @@ -21,14 +21,14 @@ class _SysInfo(): # pylint:disable=too-few-public-methods def __init__(self) -> None: self._state_file = _State().state_file self._configs = _Configs().configs - self._system = dict(platform=platform.platform(), - system=platform.system().lower(), - machine=platform.machine(), - release=platform.release(), - processor=platform.processor(), - cpu_count=os.cpu_count()) - self._python = dict(implementation=platform.python_implementation(), - version=platform.python_version()) + self._system = {"platform": platform.platform(), + "system": platform.system().lower(), + "machine": platform.machine(), + "release": platform.release(), + "processor": platform.processor(), + "cpu_count": os.cpu_count()} + self._python = {"implementation": platform.python_implementation(), + "version": platform.python_version()} self._gpu = self._get_gpu_info() self._cuda_check = CudaCheck() @@ -66,7 +66,7 @@ def _is_virtual_env(self) -> bool: (hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix)) else: prefix = os.path.dirname(sys.prefix) - retval = (os.path.basename(prefix) == "envs") + retval = os.path.basename(prefix) == "envs" return retval @property @@ -295,7 +295,7 @@ def _get_configs(self) -> str: except FileNotFoundError: return "" - def _parse_configs(self, config_files: List[str]) -> str: + def _parse_configs(self, config_files: list[str]) -> str: """ Parse the given list of config files into a human readable format. Parameters @@ -399,7 +399,7 @@ def _is_training(self) -> bool: return len(sys.argv) > 1 and sys.argv[1].lower() == "train" @staticmethod - def _get_arg(*args: str) -> Optional[str]: + def _get_arg(*args: str) -> str | None: """ Obtain the value for a given command line option from sys.argv. Returns diff --git a/lib/training/__init__.py b/lib/training/__init__.py index 2990579392..e35d3e1961 100644 --- a/lib/training/__init__.py +++ b/lib/training/__init__.py @@ -1,16 +1,16 @@ #!/usr/bin/env python3 """ Package for handling alignments files, detected faces and aligned faces along with their associated objects. """ - -from typing import Type, TYPE_CHECKING +from __future__ import annotations +import typing as T from .augmentation import ImageAugmentation from .generator import PreviewDataGenerator, TrainingDataGenerator from .preview_cv import PreviewBuffer, TriggerType -if TYPE_CHECKING: +if T.TYPE_CHECKING: from .preview_cv import PreviewBase - Preview: Type[PreviewBase] + Preview: type[PreviewBase] try: from .preview_tk import PreviewTk as Preview diff --git a/lib/training/augmentation.py b/lib/training/augmentation.py index 6fff6e085c..c559a621c1 100644 --- a/lib/training/augmentation.py +++ b/lib/training/augmentation.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """ Processes the augmentation of images for feeding into a Faceswap model. """ +from __future__ import annotations from dataclasses import dataclass import logging -from typing import Dict, Tuple, TYPE_CHECKING +import typing as T import cv2 import numexpr as ne @@ -11,7 +12,7 @@ from lib.image import batch_convert_color -if TYPE_CHECKING: +if T.TYPE_CHECKING: from lib.config import ConfigValueType logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -56,7 +57,7 @@ class AugConstants: transform_zoom: float transform_shift: float warp_maps: np.ndarray - warp_pad: Tuple[int, int] + warp_pad: tuple[int, int] warp_slices: slice warp_lm_edge_anchors: np.ndarray warp_lm_grids: np.ndarray @@ -79,7 +80,7 @@ class ImageAugmentation(): def __init__(self, batchsize: int, processing_size: int, - config: Dict[str, "ConfigValueType"]) -> None: + config: dict[str, ConfigValueType]) -> None: logger.debug("Initializing %s: (batchsize: %s, processing_size: %s, " "config: %s)", self.__class__.__name__, batchsize, processing_size, config) @@ -332,7 +333,7 @@ def _random_warp(self, batch: np.ndarray) -> np.ndarray: slices = self._constants.warp_slices rands = np.random.normal(size=(self._batchsize, 2, 5, 5), scale=self._warp_scale).astype("float32") - batch_maps = ne.evaluate("m + r", local_dict=dict(m=self._constants.warp_maps, r=rands)) + batch_maps = ne.evaluate("m + r", local_dict={"m": self._constants.warp_maps, "r": rands}) batch_interp = np.array([[cv2.resize(map_, self._constants.warp_pad)[slices, slices] for map_ in maps] for maps in batch_maps]) diff --git a/lib/training/cache.py b/lib/training/cache.py index 8bbd5431cf..3b391a028e 100644 --- a/lib/training/cache.py +++ b/lib/training/cache.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 """ Holds the data cache for training data generators """ +from __future__ import annotations import logging import os -import sys +import typing as T from threading import Lock -from typing import cast, Dict, List, Optional, Tuple, TYPE_CHECKING import cv2 import numpy as np @@ -16,25 +16,19 @@ from lib.image import read_image_batch, read_image_meta_batch from lib.utils import FaceswapError -if sys.version_info < (3, 8): - from typing_extensions import get_args, Literal -else: - from typing import get_args, Literal - -if TYPE_CHECKING: +if T.TYPE_CHECKING: from lib.align.alignments import PNGHeaderAlignmentsDict, PNGHeaderDict from lib.config import ConfigValueType logger = logging.getLogger(__name__) - -_FACE_CACHES: Dict[str, "_Cache"] = {} +_FACE_CACHES: dict[str, "_Cache"] = {} -def get_cache(side: Literal["a", "b"], - filenames: Optional[List[str]] = None, - config: Optional[Dict[str, "ConfigValueType"]] = None, - size: Optional[int] = None, - coverage_ratio: Optional[float] = None) -> "_Cache": +def get_cache(side: T.Literal["a", "b"], + filenames: list[str] | None = None, + config: dict[str, ConfigValueType] | None = None, + size: int | None = None, + coverage_ratio: float | None = None) -> "_Cache": """ Obtain a :class:`_Cache` object for the given side. If the object does not pre-exist then create it. @@ -120,24 +114,24 @@ class _Cache(): The coverage ratio that the model is using. """ def __init__(self, - filenames: List[str], - config: Dict[str, "ConfigValueType"], + filenames: list[str], + config: dict[str, ConfigValueType], size: int, coverage_ratio: float) -> None: logger.debug("Initializing: %s (filenames: %s, size: %s, coverage_ratio: %s)", self.__class__.__name__, len(filenames), size, coverage_ratio) self._lock = Lock() - self._cache_info = dict(cache_full=False, has_reset=False) - self._partially_loaded: List[str] = [] + self._cache_info = {"cache_full": False, "has_reset": False} + self._partially_loaded: list[str] = [] self._image_count = len(filenames) - self._cache: Dict[str, DetectedFace] = {} - self._aligned_landmarks: Dict[str, np.ndarray] = {} + self._cache: dict[str, DetectedFace] = {} + self._aligned_landmarks: dict[str, np.ndarray] = {} self._extract_version = 0.0 self._size = size - assert config["centering"] in get_args(CenteringType) - self._centering: CenteringType = cast(CenteringType, config["centering"]) + assert config["centering"] in T.get_args(CenteringType) + self._centering: CenteringType = T.cast(CenteringType, config["centering"]) self._config = config self._coverage_ratio = coverage_ratio @@ -153,7 +147,7 @@ def cache_full(self) -> bool: return self._cache_info["cache_full"] @property - def aligned_landmarks(self) -> Dict[str, np.ndarray]: + def aligned_landmarks(self) -> dict[str, np.ndarray]: """ dict: The filename as key, aligned landmarks as value. """ # Note: Aligned landmarks are only used for warp-to-landmarks, so this can safely populate # all of the aligned landmarks for the entire cache. @@ -185,7 +179,7 @@ def check_reset(self) -> bool: self._cache_info["has_reset"] = False return retval - def get_items(self, filenames: List[str]) -> List[DetectedFace]: + def get_items(self, filenames: list[str]) -> list[DetectedFace]: """ Obtain the cached items for a list of filenames. The returned list is in the same order as the provided filenames. @@ -202,7 +196,7 @@ def get_items(self, filenames: List[str]) -> List[DetectedFace]: """ return [self._cache[os.path.basename(filename)] for filename in filenames] - def cache_metadata(self, filenames: List[str]) -> np.ndarray: + def cache_metadata(self, filenames: list[str]) -> np.ndarray: """ Obtain the batch with metadata for items that need caching and cache DetectedFace objects to :attr:`_cache`. @@ -267,7 +261,7 @@ def cache_metadata(self, filenames: List[str]) -> np.ndarray: return batch - def pre_fill(self, filenames: List[str], side: Literal["a", "b"]) -> None: + def pre_fill(self, filenames: list[str], side: T.Literal["a", "b"]) -> None: """ When warp to landmarks is enabled, the cache must be pre-filled, as each side needs access to the other side's alignments. @@ -294,7 +288,7 @@ def pre_fill(self, filenames: List[str], side: Literal["a", "b"]) -> None: self._cache[key] = detected_face self._partially_loaded.append(key) - def _validate_version(self, png_meta: "PNGHeaderDict", filename: str) -> None: + def _validate_version(self, png_meta: PNGHeaderDict, filename: str) -> None: """ Validate that there are not a mix of v1.0 extracted faces and v2.x faces. Parameters @@ -350,7 +344,7 @@ def _reset_cache(self, set_flag: bool) -> None: def _load_detected_face(self, filename: str, - alignments: "PNGHeaderAlignmentsDict") -> DetectedFace: + alignments: PNGHeaderAlignmentsDict) -> DetectedFace: """ Load a :class:`DetectedFace` object and load its associated `aligned` property. Parameters @@ -387,13 +381,13 @@ def _prepare_masks(self, filename: str, detected_face: DetectedFace) -> None: The detected face object that holds the masks """ masks = [(self._get_face_mask(filename, detected_face))] - for area in get_args(Literal["eye", "mouth"]): + for area in T.get_args(T.Literal["eye", "mouth"]): masks.append(self._get_localized_mask(filename, detected_face, area)) detected_face.store_training_masks(masks, delete_masks=True) logger.trace("Stored masks for filename: %s)", filename) # type: ignore - def _get_face_mask(self, filename: str, detected_face: DetectedFace) -> Optional[np.ndarray]: + def _get_face_mask(self, filename: str, detected_face: DetectedFace) -> np.ndarray | None: """ Obtain the training sized face mask from the :class:`DetectedFace` for the requested mask type. @@ -448,7 +442,7 @@ def _get_face_mask(self, filename: str, detected_face: DetectedFace) -> Optional def _get_localized_mask(self, filename: str, detected_face: DetectedFace, - area: Literal["eye", "mouth"]) -> Optional[np.ndarray]: + area: T.Literal["eye", "mouth"]) -> np.ndarray | None: """ Obtain a localized mask for the given area if it is required for training. Parameters @@ -486,7 +480,7 @@ class RingBuffer(): # pylint: disable=too-few-public-methods """ def __init__(self, batch_size: int, - image_shape: Tuple[int, int, int], + image_shape: tuple[int, int, int], buffer_size: int = 2, dtype: str = "uint8") -> None: logger.debug("Initializing: %s (batch_size: %s, image_shape: %s, buffer_size: %s, " diff --git a/lib/training/generator.py b/lib/training/generator.py index cdc40134fc..8507a1cdf5 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -1,13 +1,12 @@ #!/usr/bin/env python3 """ Handles Data Augmentation for feeding Faceswap Models """ - +from __future__ import annotations import logging import os -import sys -from concurrent import futures +import typing as T +from concurrent import futures from random import shuffle, choice -from typing import cast, Dict, Generator, List, Tuple, TYPE_CHECKING import cv2 import numpy as np @@ -21,18 +20,14 @@ from . import ImageAugmentation from .cache import get_cache, RingBuffer -if sys.version_info < (3, 8): - from typing_extensions import get_args, Literal -else: - from typing import get_args, Literal - -if TYPE_CHECKING: +if T.TYPE_CHECKING: + from collections.abc import Generator from lib.config import ConfigValueType from plugins.train.model._base import ModelBase from .cache import _Cache logger = logging.getLogger(__name__) -BatchType = Tuple[np.ndarray, List[np.ndarray]] +BatchType = tuple[np.ndarray, list[np.ndarray]] class DataGenerator(): @@ -57,10 +52,10 @@ class DataGenerator(): objects of this size from the iterator. """ def __init__(self, - config: Dict[str, "ConfigValueType"], - model: "ModelBase", - side: Literal["a", "b"], - images: List[str], + config: dict[str, ConfigValueType], + model: ModelBase, + side: T.Literal["a", "b"], + images: list[str], batch_size: int) -> None: logger.debug("Initializing %s: (model: %s, side: %s, images: %s , " # type: ignore "batch_size: %s, config: %s)", self.__class__.__name__, model.name, side, @@ -83,11 +78,11 @@ def __init__(self, self._buffer = RingBuffer(batch_size, (self._process_size, self._process_size, self._total_channels), dtype="uint8") - self._face_cache: "_Cache" = get_cache(side, - filenames=images, - config=self._config, - size=self._process_size, - coverage_ratio=self._coverage_ratio) + self._face_cache: _Cache = get_cache(side, + filenames=images, + config=self._config, + size=self._process_size, + coverage_ratio=self._coverage_ratio) logger.debug("Initialized %s", self.__class__.__name__) @property @@ -100,12 +95,12 @@ def _total_channels(self) -> int: channels += 1 mults = [area for area in ["eye", "mouth"] - if cast(int, self._config[f"{area}_multiplier"]) > 1] + if T.cast(int, self._config[f"{area}_multiplier"]) > 1] if self._config["penalized_mask_loss"] and mults: channels += len(mults) return channels - def _get_output_sizes(self, model: "ModelBase") -> List[int]: + def _get_output_sizes(self, model: ModelBase) -> list[int]: """ Obtain the size of each output tensor for the model. Parameters @@ -222,7 +217,7 @@ def _img_iter(imgs): retval = self._process_batch(img_paths) yield retval - def _get_images_with_meta(self, filenames: List[str]) -> Tuple[np.ndarray, List[DetectedFace]]: + def _get_images_with_meta(self, filenames: list[str]) -> tuple[np.ndarray, list[DetectedFace]]: """ Obtain the raw face images with associated :class:`DetectedFace` objects for this batch. @@ -253,9 +248,9 @@ def _get_images_with_meta(self, filenames: List[str]) -> Tuple[np.ndarray, List[ return raw_faces, detected_faces def _crop_to_coverage(self, - filenames: List[str], + filenames: list[str], images: np.ndarray, - detected_faces: List[DetectedFace], + detected_faces: list[DetectedFace], batch: np.ndarray) -> None: """ Crops the training image out of the full extract image based on the centering and coveage used in the user's configuration settings. @@ -286,7 +281,7 @@ def _crop_to_coverage(self, for future in futures.as_completed(proc): batch[proc[future], ..., :3] = future.result() - def _apply_mask(self, detected_faces: List[DetectedFace], batch: np.ndarray) -> None: + def _apply_mask(self, detected_faces: list[DetectedFace], batch: np.ndarray) -> None: """ Applies the masks to the 4th channel of the batch. If the configuration options `eye_multiplier` and/or `mouth_multiplier` are greater than 1 @@ -312,7 +307,7 @@ def _apply_mask(self, detected_faces: List[DetectedFace], batch: np.ndarray) -> logger.trace("side: %s, masks: %s, batch: %s", # type: ignore self._side, masks.shape, batch.shape) - def _process_batch(self, filenames: List[str]) -> BatchType: + def _process_batch(self, filenames: list[str]) -> BatchType: """ Prepares data for feeding through subclassed methods. If this is the first time a face has been loaded, then it's meta data is extracted from the @@ -345,9 +340,9 @@ def _process_batch(self, filenames: List[str]) -> BatchType: return feed, targets def process_batch(self, - filenames: List[str], + filenames: list[str], images: np.ndarray, - detected_faces: List[DetectedFace], + detected_faces: list[DetectedFace], batch: np.ndarray) -> BatchType: """ Override for processing the batch for the current generator. @@ -391,7 +386,7 @@ def _to_float32(self, in_array: np.ndarray) -> np.ndarray: The input uint8 array """ return ne.evaluate("x / c", - local_dict=dict(x=in_array, c=np.float32(255)), + local_dict={"x": in_array, "c": np.float32(255)}, casting="unsafe") @@ -417,10 +412,10 @@ class TrainingDataGenerator(DataGenerator): # pylint:disable=too-few-public-met objects of this size from the iterator. """ def __init__(self, - config: Dict[str, "ConfigValueType"], - model: "ModelBase", - side: Literal["a", "b"], - images: List[str], + config: dict[str, ConfigValueType], + model: ModelBase, + side: T.Literal["a", "b"], + images: list[str], batch_size: int) -> None: super().__init__(config, model, side, images, batch_size) self._augment_color = not model.command_line_arguments.no_augment_color @@ -434,10 +429,10 @@ def __init__(self, self._processing = ImageAugmentation(batch_size, self._process_size, self._config) - self._nearest_landmarks: Dict[str, Tuple[str, ...]] = {} + self._nearest_landmarks: dict[str, tuple[str, ...]] = {} logger.debug("Initialized %s", self.__class__.__name__) - def _create_targets(self, batch: np.ndarray) -> List[np.ndarray]: + def _create_targets(self, batch: np.ndarray) -> list[np.ndarray]: """ Compile target images, with masks, for the model output sizes. Parameters @@ -467,9 +462,9 @@ def _create_targets(self, batch: np.ndarray) -> List[np.ndarray]: return retval def process_batch(self, - filenames: List[str], + filenames: list[str], images: np.ndarray, - detected_faces: List[DetectedFace], + detected_faces: list[DetectedFace], batch: np.ndarray) -> BatchType: """ Performs the augmentation and compiles target images and samples. @@ -525,7 +520,7 @@ def process_batch(self, if self._warp_to_landmarks: landmarks = np.array([face.aligned.landmarks for face in detected_faces]) batch_dst_pts = self._get_closest_match(filenames, landmarks) - warp_kwargs = dict(batch_src_points=landmarks, batch_dst_points=batch_dst_pts) + warp_kwargs = {"batch_src_points": landmarks, "batch_dst_points": batch_dst_pts} else: warp_kwargs = {} @@ -545,7 +540,7 @@ def process_batch(self, return feed, targets - def _get_closest_match(self, filenames: List[str], batch_src_points: np.ndarray) -> np.ndarray: + def _get_closest_match(self, filenames: list[str], batch_src_points: np.ndarray) -> np.ndarray: """ Only called if the :attr:`_warp_to_landmarks` is ``True``. Gets the closest matched 68 point landmarks from the opposite training set. @@ -563,7 +558,7 @@ def _get_closest_match(self, filenames: List[str], batch_src_points: np.ndarray) """ logger.trace("Retrieving closest matched landmarks: (filenames: '%s', " # type: ignore "src_points: '%s')", filenames, batch_src_points) - lm_side: Literal["a", "b"] = "a" if self._side == "b" else "b" + lm_side: T.Literal["a", "b"] = "a" if self._side == "b" else "b" other_cache = get_cache(lm_side) landmarks = other_cache.aligned_landmarks @@ -584,9 +579,9 @@ def _get_closest_match(self, filenames: List[str], batch_src_points: np.ndarray) return batch_dst_points def _cache_closest_matches(self, - filenames: List[str], + filenames: list[str], batch_src_points: np.ndarray, - landmarks: Dict[str, np.ndarray]) -> List[Tuple[str, ...]]: + landmarks: dict[str, np.ndarray]) -> list[tuple[str, ...]]: """ Cache the nearest landmarks for this batch Parameters @@ -602,7 +597,7 @@ def _cache_closest_matches(self, logger.trace("Caching closest matches") # type:ignore dst_landmarks = list(landmarks.items()) dst_points = np.array([lm[1] for lm in dst_landmarks]) - batch_closest_matches: List[Tuple[str, ...]] = [] + batch_closest_matches: list[tuple[str, ...]] = [] for filename, src_points in zip(filenames, batch_src_points): closest = (np.mean(np.square(src_points - dst_points), axis=(1, 2))).argsort()[:10] @@ -637,7 +632,7 @@ class PreviewDataGenerator(DataGenerator): """ def _create_samples(self, images: np.ndarray, - detected_faces: List[DetectedFace]) -> List[np.ndarray]: + detected_faces: list[DetectedFace]) -> list[np.ndarray]: """ Compile the 'sample' images. These are the 100% coverage images which hold the model output in the preview window. @@ -658,24 +653,25 @@ def _create_samples(self, output_size = self._output_sizes[-1] full_size = 2 * int(np.rint((output_size / self._coverage_ratio) / 2)) - assert self._config["centering"] in get_args(CenteringType) + assert self._config["centering"] in T.get_args(CenteringType) retval = np.empty((full_size, full_size, 3), dtype="float32") - retval = self._to_float32(np.array([AlignedFace(face.landmarks_xy, - image=images[idx], - centering=cast(CenteringType, - self._config["centering"]), - size=full_size, - dtype="uint8", - is_aligned=True).face - for idx, face in enumerate(detected_faces)])) + retval = self._to_float32(np.array([ + AlignedFace(face.landmarks_xy, + image=images[idx], + centering=T.cast(CenteringType, + self._config["centering"]), + size=full_size, + dtype="uint8", + is_aligned=True).face + for idx, face in enumerate(detected_faces)])) logger.trace("Processed samples: %s", retval.shape) # type: ignore return [retval] def process_batch(self, - filenames: List[str], + filenames: list[str], images: np.ndarray, - detected_faces: List[DetectedFace], + detected_faces: list[DetectedFace], batch: np.ndarray) -> BatchType: """ Creates the full size preview images and the sub-cropped images for feeding the model's predict function. diff --git a/lib/training/preview_cv.py b/lib/training/preview_cv.py index 948edfff19..c0a6458af2 100644 --- a/lib/training/preview_cv.py +++ b/lib/training/preview_cv.py @@ -4,36 +4,30 @@ If Tkinter is installed, then this will be used to manage the preview image, otherwise we fallback to opencv's imshow """ +from __future__ import annotations import logging -import sys +import typing as T from threading import Event, Lock from time import sleep -from typing import Dict, Generator, List, Optional, Tuple, TYPE_CHECKING - import cv2 -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - - -if TYPE_CHECKING: +if T.TYPE_CHECKING: + from collections.abc import Generator import numpy as np logger = logging.getLogger(__name__) -TriggerType = Dict[Literal["toggle_mask", "refresh", "save", "quit", "shutdown"], Event] -TriggerKeysType = Literal["m", "r", "s", "enter"] -TriggerNamesType = Literal["toggle_mask", "refresh", "save", "quit"] +TriggerType = dict[T.Literal["toggle_mask", "refresh", "save", "quit", "shutdown"], Event] +TriggerKeysType = T.Literal["m", "r", "s", "enter"] +TriggerNamesType = T.Literal["toggle_mask", "refresh", "save", "quit"] class PreviewBuffer(): """ A thread safe class for holding preview images """ def __init__(self) -> None: logger.debug("Initializing: %s", self.__class__.__name__) - self._images: Dict[str, "np.ndarray"] = {} + self._images: dict[str, np.ndarray] = {} self._lock = Lock() self._updated = Event() logger.debug("Initialized: %s", self.__class__.__name__) @@ -43,7 +37,7 @@ def is_updated(self) -> bool: """ bool: ``True`` when new images have been loaded into the preview buffer """ return self._updated.is_set() - def add_image(self, name: str, image: "np.ndarray") -> None: + def add_image(self, name: str, image: np.ndarray) -> None: """ Add an image to the preview buffer in a thread safe way """ logger.debug("Adding image: (name: '%s', shape: %s)", name, image.shape) with self._lock: @@ -51,7 +45,7 @@ def add_image(self, name: str, image: "np.ndarray") -> None: logger.debug("Added images: %s", list(self._images)) self._updated.set() - def get_images(self) -> Generator[Tuple[str, "np.ndarray"], None, None]: + def get_images(self) -> Generator[tuple[str, np.ndarray], None, None]: """ Get the latest images from the preview buffer. When iterator is exhausted clears the :attr:`updated` event. @@ -86,15 +80,15 @@ class PreviewBase(): # pylint:disable=too-few-public-methods """ def __init__(self, preview_buffer: PreviewBuffer, - triggers: Optional[TriggerType] = None) -> None: + triggers: TriggerType | None = None) -> None: logger.debug("Initializing %s parent (triggers: %s)", self.__class__.__name__, triggers) self._triggers = triggers self._buffer = preview_buffer - self._keymaps: Dict[TriggerKeysType, TriggerNamesType] = dict(m="toggle_mask", - r="refresh", - s="save", - enter="quit") + self._keymaps: dict[TriggerKeysType, TriggerNamesType] = {"m": "toggle_mask", + "r": "refresh", + "s": "save", + "enter": "quit"} self._title = "" logger.debug("Initialized %s parent", self.__class__.__name__) @@ -141,7 +135,7 @@ def __init__(self, logger.debug("Unable to import Tkinter. Falling back to OpenCV") super().__init__(preview_buffer, triggers=triggers) self._triggers: TriggerType = self._triggers - self._windows: List[str] = [] + self._windows: list[str] = [] self._lookup = {ord(key): val for key, val in self._keymaps.items() if key != "enter"} diff --git a/lib/training/preview_tk.py b/lib/training/preview_tk.py index 3b567ba1e3..a22fd3c0b9 100644 --- a/lib/training/preview_tk.py +++ b/lib/training/preview_tk.py @@ -4,24 +4,25 @@ If Tkinter is installed, then this will be used to manage the preview image, otherwise we fallback to opencv's imshow """ +from __future__ import annotations import logging import os import sys import tkinter as tk +import typing as T from datetime import datetime from platform import system from tkinter import ttk from math import ceil, floor -from typing import cast, List, Optional, Tuple, TYPE_CHECKING from PIL import Image, ImageTk import cv2 from .preview_cv import PreviewBase, TriggerKeysType -if TYPE_CHECKING: +if T.TYPE_CHECKING: import numpy as np from .preview_cv import PreviewBuffer, TriggerType @@ -38,18 +39,18 @@ class _Taskbar(): taskbar: :class:`tkinter.ttk.Frame` or ``None`` None if preview is a pop-up window otherwise ttk.Frame if taskbar is managed by the GUI """ - def __init__(self, parent: tk.Frame, taskbar: Optional[ttk.Frame]) -> None: + def __init__(self, parent: tk.Frame, taskbar: ttk.Frame | None) -> None: logger.debug("Initializing %s (parent: '%s', taskbar: %s)", self.__class__.__name__, parent, taskbar) self._is_standalone = taskbar is None - self._gui_mapped: List[tk.Widget] = [] + self._gui_mapped: list[tk.Widget] = [] self._frame = tk.Frame(parent) if taskbar is None else taskbar self._min_max_scales = (20, 400) - self._vars = dict(save=tk.BooleanVar(), - scale=tk.StringVar(), - slider=tk.IntVar(), - interpolator=tk.IntVar()) + self._vars = {"save": tk.BooleanVar(), + "scale": tk.StringVar(), + "slider": tk.IntVar(), + "interpolator": tk.IntVar()} self._interpolators = [("nearest_neighbour", cv2.INTER_NEAREST), ("bicubic", cv2.INTER_CUBIC)] self._scale = self._add_scale_combo() @@ -261,7 +262,7 @@ class _PreviewCanvas(tk.Canvas): # pylint:disable=too-many-ancestors def __init__(self, parent: tk.Frame, scale_var: tk.StringVar, - screen_dimensions: Tuple[int, int], + screen_dimensions: tuple[int, int], is_standalone: bool) -> None: logger.debug("Initializing %s (parent: '%s', scale_var: %s, screen_dimensions: %s)", self.__class__.__name__, parent, scale_var, screen_dimensions) @@ -272,7 +273,7 @@ def __init__(self, self._screen_dimensions = screen_dimensions self._var_scale = scale_var self._configure_scrollbars(frame) - self._image: Optional[ImageTk.PhotoImage] = None + self._image: ImageTk.PhotoImage | None = None self._image_id = self.create_image(self.width / 2, self.height / 2, anchor=tk.CENTER, @@ -400,8 +401,8 @@ def __init__(self, save_variable: tk.BooleanVar, is_standalone: bool) -> None: logger.debug("Initializing %s: (save_variable: %s, is_standalone: %s)", self.__class__.__name__, save_variable, is_standalone) self._is_standalone = is_standalone - self._source: Optional["np.ndarray"] = None - self._display: Optional[ImageTk.PhotoImage] = None + self._source: np.ndarray | None = None + self._display: ImageTk.PhotoImage | None = None self._scale = 1.0 self._interpolation = cv2.INTER_NEAREST @@ -416,7 +417,7 @@ def display_image(self) -> ImageTk.PhotoImage: return self._display @property - def source(self) -> "np.ndarray": + def source(self) -> np.ndarray: """ :class:`PIL.Image.Image`: The current source preview image """ assert self._source is not None return self._source @@ -426,7 +427,7 @@ def scale(self) -> int: """int: The current display scale as a percentage of original image size """ return int(self._scale * 100) - def set_source_image(self, name: str, image: "np.ndarray") -> None: + def set_source_image(self, name: str, image: np.ndarray) -> None: """ Set the source image to :attr:`source` Parameters @@ -542,7 +543,7 @@ def __init__(self, self._taskbar = taskbar self._image = image - self._drag_data: List[float] = [0., 0.] + self._drag_data: list[float] = [0., 0.] self._set_mouse_bindings() self._set_key_bindings(is_standalone) logger.debug("Initialized %s", self.__class__.__name__,) @@ -604,7 +605,7 @@ def _on_key_move(self, event: tk.Event) -> None: The key press event """ move_axis = self._canvas.xview if event.keysym in ("Left", "Right") else self._canvas.yview - visible = (move_axis()[1] - move_axis()[0]) + visible = move_axis()[1] - move_axis()[0] amount = -visible / 25 if event.keysym in ("Up", "Left") else visible / 25 logger.trace("Key move event: (event: %s, move_axis: %s, visible: %s, " # type: ignore "amount: %s)", move_axis, visible, amount) @@ -671,10 +672,10 @@ class PreviewTk(PreviewBase): # pylint:disable=too-few-public-methods Default: `None` """ def __init__(self, - preview_buffer: "PreviewBuffer", - parent: Optional[tk.Widget] = None, - taskbar: Optional[ttk.Frame] = None, - triggers: Optional["TriggerType"] = None) -> None: + preview_buffer: PreviewBuffer, + parent: tk.Widget | None = None, + taskbar: ttk.Frame | None = None, + triggers: TriggerType | None = None) -> None: logger.debug("Initializing %s (parent: '%s')", self.__class__.__name__, parent) super().__init__(preview_buffer, triggers=triggers) self._is_standalone = parent is None @@ -745,7 +746,7 @@ def _output_helptext(self) -> None: logger.info(" Save Preview: Ctrl+s") logger.info("---------------------------------------------------") - def _get_geometry(self) -> Tuple[int, int]: + def _get_geometry(self) -> tuple[int, int]: """ Obtain the geometry of the current screen (standalone) or the dimensions of the widget holding the preview window (GUI). @@ -780,7 +781,7 @@ def _set_min_max_scales(self) -> None: half_screen = tuple(x // 2 for x in self._screen_dimensions) min_scales = (half_screen[0] / self._image.source.shape[1], half_screen[1] / self._image.source.shape[0]) - min_scale = min(1.0, min(min_scales)) + min_scale = min(1.0, *min_scales) min_scale = (ceil(min_scale * 10)) * 10 eight_screen = tuple(x * 8 for x in self._screen_dimensions) @@ -884,7 +885,7 @@ def _on_keypress(self, event: tk.Event) -> None: if self._triggers is None: # Don't need triggers for GUI return keypress = "enter" if event.keysym == "Return" else event.keysym - key = cast(TriggerKeysType, keypress) + key = T.cast(TriggerKeysType, keypress) logger.debug("Processing keypress '%s'", key) if key == "r": print("") # Let log print on different line from loss output diff --git a/lib/utils.py b/lib/utils.py index ad232a6bf1..a81000e681 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -1,11 +1,12 @@ #!/usr/bin python3 """ Utilities available across all scripts """ - +from __future__ import annotations import json import logging import os import sys import tkinter as tk +import typing as T import warnings import zipfile @@ -14,18 +15,12 @@ from socket import timeout as socket_timeout, error as socket_error from threading import get_ident from time import time -from typing import cast, Dict, List, Optional, Union, Tuple, TYPE_CHECKING from urllib import request, error as urlliberror import numpy as np from tqdm import tqdm -if sys.version_info < (3, 8): - from typing_extensions import get_args, Literal -else: - from typing import get_args, Literal - -if TYPE_CHECKING: +if T.TYPE_CHECKING: from http.client import HTTPResponse # Global variables @@ -34,8 +29,8 @@ _video_extensions = [ # pylint:disable=invalid-name ".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv", ".ts", ".vob"] -_TF_VERS: Optional[Tuple[int, int]] = None -ValidBackends = Literal["nvidia", "cpu", "apple_silicon", "directml", "rocm"] +_TF_VERS: tuple[int, int] | None = None +ValidBackends = T.Literal["nvidia", "cpu", "apple_silicon", "directml", "rocm"] class _Backend(): # pylint:disable=too-few-public-methods @@ -44,7 +39,7 @@ class _Backend(): # pylint:disable=too-few-public-methods If file doesn't exist and a variable hasn't been set, create the config file. """ def __init__(self) -> None: - self._backends: Dict[str, ValidBackends] = {"1": "cpu", + self._backends: dict[str, ValidBackends] = {"1": "cpu", "2": "directml", "3": "nvidia", "4": "apple_silicon", @@ -78,9 +73,9 @@ def _get_backend(self) -> ValidBackends: """ # Check if environment variable is set, if so use that if "FACESWAP_BACKEND" in os.environ: - fs_backend = cast(ValidBackends, os.environ["FACESWAP_BACKEND"].lower()) - assert fs_backend in get_args(ValidBackends), ( - f"Faceswap backend must be one of {get_args(ValidBackends)}") + fs_backend = T.cast(ValidBackends, os.environ["FACESWAP_BACKEND"].lower()) + assert fs_backend in T.get_args(ValidBackends), ( + f"Faceswap backend must be one of {T.get_args(ValidBackends)}") print(f"Setting Faceswap backend from environment variable to {fs_backend.upper()}") return fs_backend # Intercept for sphinx docs build @@ -163,11 +158,11 @@ def set_backend(backend: str) -> None: >>> set_backend("nvidia") """ global _FS_BACKEND # pylint:disable=global-statement - backend = cast(ValidBackends, backend.lower()) + backend = T.cast(ValidBackends, backend.lower()) _FS_BACKEND = backend -def get_tf_version() -> Tuple[int, int]: +def get_tf_version() -> tuple[int, int]: """ Obtain the major. minor version of currently installed Tensorflow. Returns @@ -179,7 +174,7 @@ def get_tf_version() -> Tuple[int, int]: ------- >>> from lib.utils import get_tf_version >>> get_tf_version() - (2, 9) + (2, 10) """ global _TF_VERS # pylint:disable=global-statement if _TF_VERS is None: @@ -225,7 +220,7 @@ def get_folder(path: str, make_folder: bool = True) -> str: return path -def get_image_paths(directory: str, extension: Optional[str] = None) -> List[str]: +def get_image_paths(directory: str, extension: str | None = None) -> list[str]: """ Gets the image paths from a given directory. The function searches for files with the specified extension(s) in the given directory, and @@ -274,7 +269,7 @@ def get_image_paths(directory: str, extension: Optional[str] = None) -> List[str return dir_contents -def get_dpi() -> Optional[float]: +def get_dpi() -> float | None: """ Gets the DPI (dots per inch) of the display screen. Returns @@ -338,7 +333,7 @@ def convert_to_secs(*args: int) -> int: return retval -def full_path_split(path: str) -> List[str]: +def full_path_split(path: str) -> list[str]: """ Split a file path into all of its parts. Parameters @@ -360,7 +355,7 @@ def full_path_split(path: str) -> List[str]: ['relative', 'path', 'to', 'file.txt']] """ logger = logging.getLogger(__name__) - allparts: List[str] = [] + allparts: list[str] = [] while True: parts = os.path.split(path) if parts[0] == path: # sentinel for absolute paths @@ -410,7 +405,7 @@ def set_system_verbosity(log_level: str): warnings.simplefilter(action='ignore', category=warncat) -def deprecation_warning(function: str, additional_info: Optional[str] = None) -> None: +def deprecation_warning(function: str, additional_info: str | None = None) -> None: """ Log a deprecation warning message. This function logs a warning message to indicate that the specified function has been @@ -436,7 +431,7 @@ def deprecation_warning(function: str, additional_info: Optional[str] = None) -> logger.warning(msg) -def camel_case_split(identifier: str) -> List[str]: +def camel_case_split(identifier: str) -> list[str]: """ Split a camelCase string into a list of its individual parts Parameters @@ -541,7 +536,7 @@ class GetModel(): # pylint:disable=too-few-public-methods >>> model_downloader = GetModel("s3fd_keras_v2.h5", 11) """ - def __init__(self, model_filename: Union[str, List[str]], git_model_id: int) -> None: + def __init__(self, model_filename: str | list[str], git_model_id: int) -> None: self.logger = logging.getLogger(__name__) if not isinstance(model_filename, list): model_filename = [model_filename] @@ -576,7 +571,7 @@ def _model_version(self) -> int: return retval @property - def model_path(self) -> Union[str, List[str]]: + def model_path(self) -> str | list[str]: """ str or list[str]: The model path(s) in the cache folder. Example @@ -587,7 +582,7 @@ def model_path(self) -> Union[str, List[str]]: '/path/to/s3fd_keras_v2.h5' """ paths = [os.path.join(self._cache_dir, fname) for fname in self._model_filename] - retval: Union[str, List[str]] = paths[0] if len(paths) == 1 else paths + retval: str | list[str] = paths[0] if len(paths) == 1 else paths self.logger.trace(retval) # type:ignore[attr-defined] return retval @@ -662,7 +657,7 @@ def _download_model(self) -> None: self._url_download, self._cache_dir) sys.exit(1) - def _write_zipfile(self, response: "HTTPResponse", downloaded_size: int) -> None: + def _write_zipfile(self, response: HTTPResponse, downloaded_size: int) -> None: """ Write the model zip file to disk. Parameters @@ -762,8 +757,8 @@ class DebugTimes(): """ def __init__(self, show_min: bool = True, show_mean: bool = True, show_max: bool = True) -> None: - self._times: Dict[str, List[float]] = {} - self._steps: Dict[str, float] = {} + self._times: dict[str, list[float]] = {} + self._steps: dict[str, float] = {} self._interval = 1 self._display = {"min": show_min, "mean": show_mean, "max": show_max} diff --git a/locales/plugins.train._config.pot b/locales/plugins.train._config.pot index 90ff2866a8..26558b6e37 100644 --- a/locales/plugins.train._config.pot +++ b/locales/plugins.train._config.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-06-11 23:20+0100\n" +"POT-Creation-Date: 2023-06-25 13:39+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -227,7 +227,8 @@ msgid "" msgstr "" #: plugins/train/_config.py:198 plugins/train/_config.py:223 -#: plugins/train/_config.py:238 plugins/train/_config.py:265 +#: plugins/train/_config.py:238 plugins/train/_config.py:256 +#: plugins/train/_config.py:290 msgid "optimizer" msgstr "" @@ -274,21 +275,40 @@ msgid "" "epsilon to 0.001 (1e-3)." msgstr "" -#: plugins/train/_config.py:258 +#: plugins/train/_config.py:262 msgid "" -"Apply AutoClipping to the gradients. AutoClip analyzes the " -"gradient weights and adjusts the normalization value dynamically to fit the " -"data. Can help prevent NaNs and improve model optimization at the expense of " -"VRAM. Ref: AutoClip: Adaptive Gradient Clipping for Source Separation " -"Networks https://arxiv.org/abs/2007.14469" +"When to save the Optimizer Weights. Saving the optimizer weights is not " +"necessary and will increase the model file size 3x (and by extension the " +"amount of time it takes to save the model). However, it can be useful to " +"save these weights if you want to guarantee that a resumed model carries off " +"exactly from where it left off, rather than spending a few hundred " +"iterations catching up.\n" +"\t never - Don't save optimizer weights.\n" +"\t always - Save the optimizer weights at every save iteration. Model saving " +"will take longer, due to the increased file size, but you will always have " +"the last saved optimizer state in your model file.\n" +"\t exit - Only save the optimizer weights when explicitly terminating a " +"model. This can be when the model is actively stopped or when the target " +"iterations are met. Note: If the training session ends because of another " +"reason (e.g. power outage, Out of Memory Error, NaN detected) then the " +"optimizer weights will NOT be saved." msgstr "" -#: plugins/train/_config.py:271 plugins/train/_config.py:283 -#: plugins/train/_config.py:297 plugins/train/_config.py:314 +#: plugins/train/_config.py:283 +msgid "" +"Apply AutoClipping to the gradients. AutoClip analyzes the gradient weights " +"and adjusts the normalization value dynamically to fit the data. Can help " +"prevent NaNs and improve model optimization at the expense of VRAM. Ref: " +"AutoClip: Adaptive Gradient Clipping for Source Separation Networks https://" +"arxiv.org/abs/2007.14469" +msgstr "" + +#: plugins/train/_config.py:296 plugins/train/_config.py:308 +#: plugins/train/_config.py:322 plugins/train/_config.py:339 msgid "network" msgstr "" -#: plugins/train/_config.py:273 +#: plugins/train/_config.py:298 msgid "" "Use reflection padding rather than zero padding with convolutions. Each " "convolution must pad the image boundaries to maintain the proper sizing. " @@ -297,21 +317,21 @@ msgid "" "\t http://www-cs.engr.ccny.cuny.edu/~wolberg/cs470/hw/hw2_pad.txt" msgstr "" -#: plugins/train/_config.py:286 +#: plugins/train/_config.py:311 msgid "" -"Enable the Tensorflow GPU 'allow_growth' configuration " -"option. This option prevents Tensorflow from allocating all of the GPU VRAM " -"at launch but can lead to higher VRAM fragmentation and slower performance. " -"Should only be enabled if you are receiving errors regarding 'cuDNN fails to " -"initialize' when commencing training." +"Enable the Tensorflow GPU 'allow_growth' configuration option. This option " +"prevents Tensorflow from allocating all of the GPU VRAM at launch but can " +"lead to higher VRAM fragmentation and slower performance. Should only be " +"enabled if you are receiving errors regarding 'cuDNN fails to initialize' " +"when commencing training." msgstr "" -#: plugins/train/_config.py:299 +#: plugins/train/_config.py:324 msgid "" -"NVIDIA GPUs can run operations in float16 faster than in " -"float32. Mixed precision allows you to use a mix of float16 with float32, to " -"get the performance benefits from float16 and the numeric stability benefits " -"from float32.\n" +"NVIDIA GPUs can run operations in float16 faster than in float32. Mixed " +"precision allows you to use a mix of float16 with float32, to get the " +"performance benefits from float16 and the numeric stability benefits from " +"float32.\n" "\n" "This is untested on DirectML backend, but will run on most Nvidia models. it " "will only speed up training on more recent GPUs. Those with compute " @@ -322,7 +342,7 @@ msgid "" "the most benefit." msgstr "" -#: plugins/train/_config.py:316 +#: plugins/train/_config.py:341 msgid "" "If a 'NaN' is generated in the model, this means that the model has " "corrupted and the model is likely to start deteriorating from this point on. " @@ -331,11 +351,11 @@ msgid "" "rescue your model." msgstr "" -#: plugins/train/_config.py:329 +#: plugins/train/_config.py:354 msgid "convert" msgstr "" -#: plugins/train/_config.py:331 +#: plugins/train/_config.py:356 msgid "" "[GPU Only]. The number of faces to feed through the model at once when " "running the Convert process.\n" @@ -345,27 +365,27 @@ msgid "" "size." msgstr "" -#: plugins/train/_config.py:350 +#: plugins/train/_config.py:375 msgid "" "Loss configuration options\n" "Loss is the mechanism by which a Neural Network judges how well it thinks " "that it is recreating a face." msgstr "" -#: plugins/train/_config.py:357 plugins/train/_config.py:369 -#: plugins/train/_config.py:382 plugins/train/_config.py:402 -#: plugins/train/_config.py:414 plugins/train/_config.py:434 -#: plugins/train/_config.py:446 plugins/train/_config.py:466 -#: plugins/train/_config.py:482 plugins/train/_config.py:498 -#: plugins/train/_config.py:515 +#: plugins/train/_config.py:382 plugins/train/_config.py:394 +#: plugins/train/_config.py:407 plugins/train/_config.py:427 +#: plugins/train/_config.py:439 plugins/train/_config.py:459 +#: plugins/train/_config.py:471 plugins/train/_config.py:491 +#: plugins/train/_config.py:507 plugins/train/_config.py:523 +#: plugins/train/_config.py:540 msgid "loss" msgstr "" -#: plugins/train/_config.py:361 +#: plugins/train/_config.py:386 msgid "The loss function to use." msgstr "" -#: plugins/train/_config.py:373 +#: plugins/train/_config.py:398 msgid "" "The second loss function to use. If using a structural based loss (such as " "SSIM, MS-SSIM or GMSD) it is common to add an L1 regularization(MAE) or L2 " @@ -373,7 +393,7 @@ msgid "" "function with the loss_weight_2 option." msgstr "" -#: plugins/train/_config.py:388 +#: plugins/train/_config.py:413 msgid "" "The amount of weight to apply to the second loss function.\n" "\n" @@ -391,13 +411,13 @@ msgid "" "\t 0 - Disables the second loss function altogether." msgstr "" -#: plugins/train/_config.py:406 +#: plugins/train/_config.py:431 msgid "" "The third loss function to use. You can adjust the weighting of this loss " "function with the loss_weight_3 option." msgstr "" -#: plugins/train/_config.py:420 +#: plugins/train/_config.py:445 msgid "" "The amount of weight to apply to the third loss function.\n" "\n" @@ -415,13 +435,13 @@ msgid "" "\t 0 - Disables the third loss function altogether." msgstr "" -#: plugins/train/_config.py:438 +#: plugins/train/_config.py:463 msgid "" "The fourth loss function to use. You can adjust the weighting of this loss " "function with the loss_weight_3 option." msgstr "" -#: plugins/train/_config.py:452 +#: plugins/train/_config.py:477 msgid "" "The amount of weight to apply to the fourth loss function.\n" "\n" @@ -439,7 +459,7 @@ msgid "" "\t 0 - Disables the fourth loss function altogether." msgstr "" -#: plugins/train/_config.py:471 +#: plugins/train/_config.py:496 msgid "" "The loss function to use when learning a mask.\n" "\t MAE - Mean absolute error will guide reconstructions of each pixel " @@ -451,7 +471,7 @@ msgid "" "susceptible to outliers and typically produces slightly blurrier results." msgstr "" -#: plugins/train/_config.py:488 +#: plugins/train/_config.py:513 msgid "" "The amount of priority to give to the eyes.\n" "\n" @@ -464,7 +484,7 @@ msgid "" "NB: Penalized Mask Loss must be enable to use this option." msgstr "" -#: plugins/train/_config.py:504 +#: plugins/train/_config.py:529 msgid "" "The amount of priority to give to the mouth.\n" "\n" @@ -477,7 +497,7 @@ msgid "" "NB: Penalized Mask Loss must be enable to use this option." msgstr "" -#: plugins/train/_config.py:517 +#: plugins/train/_config.py:542 msgid "" "Image loss function is weighted by mask presence. For areas of the image " "without the facial mask, reconstruction errors will be ignored while the " @@ -485,12 +505,12 @@ msgid "" "attention on the core face area." msgstr "" -#: plugins/train/_config.py:528 plugins/train/_config.py:570 -#: plugins/train/_config.py:584 plugins/train/_config.py:593 +#: plugins/train/_config.py:553 plugins/train/_config.py:595 +#: plugins/train/_config.py:609 plugins/train/_config.py:618 msgid "mask" msgstr "" -#: plugins/train/_config.py:531 +#: plugins/train/_config.py:556 msgid "" "The mask to be used for training. If you have selected 'Learn Mask' or " "'Penalized Mask Loss' you must select a value other than 'none'. The " @@ -528,7 +548,7 @@ msgid "" "performance." msgstr "" -#: plugins/train/_config.py:572 +#: plugins/train/_config.py:597 msgid "" "Apply gaussian blur to the mask input. This has the effect of smoothing the " "edges of the mask, which can help with poorly calculated masks and give less " @@ -538,13 +558,13 @@ msgid "" "number." msgstr "" -#: plugins/train/_config.py:586 +#: plugins/train/_config.py:611 msgid "" "Sets pixels that are near white to white and near black to black. Set to 0 " "for off." msgstr "" -#: plugins/train/_config.py:595 +#: plugins/train/_config.py:620 msgid "" "Dedicate a portion of the model to learning how to duplicate the input mask. " "Increases VRAM usage in exchange for learning a quick ability to try to " diff --git a/locales/ru/LC_MESSAGES/plugins.train._config.mo b/locales/ru/LC_MESSAGES/plugins.train._config.mo index 7cbd6379b30ee71b819b9fb10f9eb9530932a00e..a709331fd7fae5284c882b07c5763d67ace196dc 100644 GIT binary patch delta 3822 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 diff --git a/locales/ru/LC_MESSAGES/plugins.train._config.po b/locales/ru/LC_MESSAGES/plugins.train._config.po index fba3a9d526..02fc12a2a0 100644 --- a/locales/ru/LC_MESSAGES/plugins.train._config.po +++ b/locales/ru/LC_MESSAGES/plugins.train._config.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-06-11 23:20+0100\n" -"PO-Revision-Date: 2023-06-20 17:06+0100\n" +"POT-Creation-Date: 2023-06-25 13:39+0100\n" +"PO-Revision-Date: 2023-06-25 13:42+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru_RU\n" @@ -354,7 +354,8 @@ msgstr "" "повлияет только на запуск новой модели." #: plugins/train/_config.py:198 plugins/train/_config.py:223 -#: plugins/train/_config.py:238 plugins/train/_config.py:265 +#: plugins/train/_config.py:238 plugins/train/_config.py:256 +#: plugins/train/_config.py:290 msgid "optimizer" msgstr "оптимизатор" @@ -435,7 +436,41 @@ msgstr "" "Например, при выборе значения '-7' эпсилон будет равен 1e-7. При выборе " "значения \"-3\" эпсилон будет равен 0,001 (1e-3)." -#: plugins/train/_config.py:258 +#: plugins/train/_config.py:262 +msgid "" +"When to save the Optimizer Weights. Saving the optimizer weights is not " +"necessary and will increase the model file size 3x (and by extension the " +"amount of time it takes to save the model). However, it can be useful to " +"save these weights if you want to guarantee that a resumed model carries off " +"exactly from where it left off, rather than spending a few hundred " +"iterations catching up.\n" +"\t never - Don't save optimizer weights.\n" +"\t always - Save the optimizer weights at every save iteration. Model saving " +"will take longer, due to the increased file size, but you will always have " +"the last saved optimizer state in your model file.\n" +"\t exit - Only save the optimizer weights when explicitly terminating a " +"model. This can be when the model is actively stopped or when the target " +"iterations are met. Note: If the training session ends because of another " +"reason (e.g. power outage, Out of Memory Error, NaN detected) then the " +"optimizer weights will NOT be saved." +msgstr "" +"Когда сохранять веса оптимизатора. Сохранение весов оптимизатора не является " +"необходимым и увеличит размер файла модели в 3 раза (и соответственно время, " +"необходимое для сохранения модели). Однако может быть полезно сохранить эти " +"веса, если вы хотите гарантировать, что возобновленная модель продолжит " +"работу именно с того места, где она остановилась, а не тратит несколько " +"сотен итераций на догонялки.\n" +"\t never - не сохранять веса оптимизатора.\n" +"\t always - сохранять веса оптимизатора при каждой итерации сохранения. " +"Сохранение модели займет больше времени из-за увеличенного размера файла, но " +"в файле модели всегда будет последнее сохраненное состояние оптимизатора.\n" +"\t exit - сохранять веса оптимизатора только при явном завершении модели. " +"Это может быть, когда модель активно останавливается или когда выполняются " +"целевые итерации. Примечание. Если сеанс обучения завершается по другой " +"причине (например, отключение питания, ошибка нехватки памяти, обнаружение " +"NaN), веса оптимизатора НЕ будут сохранены." + +#: plugins/train/_config.py:283 msgid "" "Apply AutoClipping to the gradients. AutoClip analyzes the gradient weights " "and adjusts the normalization value dynamically to fit the data. Can help " @@ -449,12 +484,12 @@ msgstr "" "ценой видеопамяти. Ссылка: AutoClip: Adaptive Gradient Clipping for Source " "Separation Networks [ТОЛЬКО на английском] https://arxiv.org/abs/2007.14469" -#: plugins/train/_config.py:271 plugins/train/_config.py:283 -#: plugins/train/_config.py:297 plugins/train/_config.py:314 +#: plugins/train/_config.py:296 plugins/train/_config.py:308 +#: plugins/train/_config.py:322 plugins/train/_config.py:339 msgid "network" msgstr "сеть" -#: plugins/train/_config.py:273 +#: plugins/train/_config.py:298 msgid "" "Use reflection padding rather than zero padding with convolutions. Each " "convolution must pad the image boundaries to maintain the proper sizing. " @@ -468,7 +503,7 @@ msgstr "" "изображения.\n" "\t http://www-cs.engr.ccny.cuny.edu/~wolberg/cs470/hw/hw2_pad.txt" -#: plugins/train/_config.py:286 +#: plugins/train/_config.py:311 msgid "" "Enable the Tensorflow GPU 'allow_growth' configuration option. This option " "prevents Tensorflow from allocating all of the GPU VRAM at launch but can " @@ -483,7 +518,7 @@ msgstr "" "случае, если у вас появляются ошибки, рода 'cuDNN fails to initialize'(cuDNN " "не может инициализироваться) при начале тренировки." -#: plugins/train/_config.py:299 +#: plugins/train/_config.py:324 msgid "" "NVIDIA GPUs can run operations in float16 faster than in float32. Mixed " "precision allows you to use a mix of float16 with float32, to get the " @@ -512,7 +547,7 @@ msgstr "" "ускорение. В основном RTX видеокарты и позже предлагают самое большое " "ускорение." -#: plugins/train/_config.py:316 +#: plugins/train/_config.py:341 msgid "" "If a 'NaN' is generated in the model, this means that the model has " "corrupted and the model is likely to start deteriorating from this point on. " @@ -526,11 +561,11 @@ msgstr "" "NaN. Последнее сохранение не будет содержать в себе NaN, так что у вас будет " "возможность спасти вашу модель." -#: plugins/train/_config.py:329 +#: plugins/train/_config.py:354 msgid "convert" msgstr "конвертирование" -#: plugins/train/_config.py:331 +#: plugins/train/_config.py:356 msgid "" "[GPU Only]. The number of faces to feed through the model at once when " "running the Convert process.\n" @@ -546,7 +581,7 @@ msgstr "" "конвертирования, однако, если у вас появляются ошибки 'Out of Memory', тогда " "стоит снизить размер пачки." -#: plugins/train/_config.py:350 +#: plugins/train/_config.py:375 msgid "" "Loss configuration options\n" "Loss is the mechanism by which a Neural Network judges how well it thinks " @@ -556,20 +591,20 @@ msgstr "" "Потеря - механизм, по которому Нейронная Сеть судит, насколько хорошо она " "воспроизводит лицо." -#: plugins/train/_config.py:357 plugins/train/_config.py:369 -#: plugins/train/_config.py:382 plugins/train/_config.py:402 -#: plugins/train/_config.py:414 plugins/train/_config.py:434 -#: plugins/train/_config.py:446 plugins/train/_config.py:466 -#: plugins/train/_config.py:482 plugins/train/_config.py:498 -#: plugins/train/_config.py:515 +#: plugins/train/_config.py:382 plugins/train/_config.py:394 +#: plugins/train/_config.py:407 plugins/train/_config.py:427 +#: plugins/train/_config.py:439 plugins/train/_config.py:459 +#: plugins/train/_config.py:471 plugins/train/_config.py:491 +#: plugins/train/_config.py:507 plugins/train/_config.py:523 +#: plugins/train/_config.py:540 msgid "loss" msgstr "потери" -#: plugins/train/_config.py:361 +#: plugins/train/_config.py:386 msgid "The loss function to use." msgstr "Какую функцию потерь стоит использовать." -#: plugins/train/_config.py:373 +#: plugins/train/_config.py:398 msgid "" "The second loss function to use. If using a structural based loss (such as " "SSIM, MS-SSIM or GMSD) it is common to add an L1 regularization(MAE) or L2 " @@ -581,7 +616,7 @@ msgstr "" "регуляризации L1 (MAE) или регуляризации L2 (MSE). Вы можете настроить вес " "этой функции потерь с помощью параметра loss_weight_2." -#: plugins/train/_config.py:388 +#: plugins/train/_config.py:413 msgid "" "The amount of weight to apply to the second loss function.\n" "\n" @@ -612,7 +647,7 @@ msgstr "" "4 раза перед добавлением к общей оценке потерь. \n" "\t 0 - Полностью отключает четвертую функцию потерь." -#: plugins/train/_config.py:406 +#: plugins/train/_config.py:431 msgid "" "The third loss function to use. You can adjust the weighting of this loss " "function with the loss_weight_3 option." @@ -620,7 +655,7 @@ msgstr "" "Третья используемая функция потерь. Вы можете настроить вес этой функции " "потерь с помощью параметра loss_weight_3." -#: plugins/train/_config.py:420 +#: plugins/train/_config.py:445 msgid "" "The amount of weight to apply to the third loss function.\n" "\n" @@ -651,7 +686,7 @@ msgstr "" "4 раза перед добавлением к общей оценке потерь. \n" "\t 0 - Полностью отключает четвертую функцию потерь." -#: plugins/train/_config.py:438 +#: plugins/train/_config.py:463 msgid "" "The fourth loss function to use. You can adjust the weighting of this loss " "function with the loss_weight_3 option." @@ -659,7 +694,7 @@ msgstr "" "Четвертая используемая функция потерь. Вы можете настроить вес этой функции " "потерь с помощью параметра 'loss_weight_4'." -#: plugins/train/_config.py:452 +#: plugins/train/_config.py:477 msgid "" "The amount of weight to apply to the fourth loss function.\n" "\n" @@ -690,7 +725,7 @@ msgstr "" "4 раза перед добавлением к общей оценке потерь. \n" "\t 0 - Полностью отключает четвертую функцию потерь." -#: plugins/train/_config.py:471 +#: plugins/train/_config.py:496 msgid "" "The loss function to use when learning a mask.\n" "\t MAE - Mean absolute error will guide reconstructions of each pixel " @@ -711,7 +746,7 @@ msgstr "" "данных. Как среднее значение, оно чувствительно к выбросам и обычно дает " "немного более размытые результаты." -#: plugins/train/_config.py:488 +#: plugins/train/_config.py:513 msgid "" "The amount of priority to give to the eyes.\n" "\n" @@ -731,7 +766,7 @@ msgstr "" "\n" "NB: Penalized Mask Loss должен быть включен, чтобы использовать эту опцию." -#: plugins/train/_config.py:504 +#: plugins/train/_config.py:529 msgid "" "The amount of priority to give to the mouth.\n" "\n" @@ -751,7 +786,7 @@ msgstr "" "\n" "NB: Penalized Mask Loss должен быть включен, чтобы использовать эту опцию." -#: plugins/train/_config.py:517 +#: plugins/train/_config.py:542 msgid "" "Image loss function is weighted by mask presence. For areas of the image " "without the facial mask, reconstruction errors will be ignored while the " @@ -763,12 +798,12 @@ msgstr "" "время как область лица с маской является приоритетной. Может повысить общее " "качество за счет концентрации внимания на основной области лица." -#: plugins/train/_config.py:528 plugins/train/_config.py:570 -#: plugins/train/_config.py:584 plugins/train/_config.py:593 +#: plugins/train/_config.py:553 plugins/train/_config.py:595 +#: plugins/train/_config.py:609 plugins/train/_config.py:618 msgid "mask" msgstr "маска" -#: plugins/train/_config.py:531 +#: plugins/train/_config.py:556 msgid "" "The mask to be used for training. If you have selected 'Learn Mask' or " "'Penalized Mask Loss' you must select a value other than 'none'. The " @@ -840,7 +875,7 @@ msgstr "" "сообщества и для дальнейшего описания нуждается в тестировании. Профильные " "лица могут иметь низкую производительность." -#: plugins/train/_config.py:572 +#: plugins/train/_config.py:597 msgid "" "Apply gaussian blur to the mask input. This has the effect of smoothing the " "edges of the mask, which can help with poorly calculated masks and give less " @@ -856,7 +891,7 @@ msgstr "" "должно быть нечетным, если передано четное число, то оно будет округлено до " "следующего нечетного числа." -#: plugins/train/_config.py:586 +#: plugins/train/_config.py:611 msgid "" "Sets pixels that are near white to white and near black to black. Set to 0 " "for off." @@ -864,7 +899,7 @@ msgstr "" "Устанавливает пиксели, которые почти белые - в белые и которые почти черные " "- в черные. Установите 0, чтобы выключить." -#: plugins/train/_config.py:595 +#: plugins/train/_config.py:620 msgid "" "Dedicate a portion of the model to learning how to duplicate the input mask. " "Increases VRAM usage in exchange for learning a quick ability to try to " diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index bcc1ac13ca..3753340415 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 """ Plugin to blend the edges of the face between the swap and the original face. """ import logging -import sys -from typing import List, Optional, Tuple +import typing as T import cv2 import numpy as np @@ -11,12 +10,6 @@ from lib.config import FaceswapConfig from plugins.convert._config import Config -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - - logger = logging.getLogger(__name__) @@ -44,8 +37,8 @@ def __init__(self, mask_type: str, output_size: int, coverage_ratio: float, - configfile: Optional[str] = None, - config: Optional[FaceswapConfig] = None) -> None: + configfile: str | None = None, + config: FaceswapConfig | None = None) -> None: logger.debug("Initializing %s: (mask_type: '%s', output_size: %s, coverage_ratio: %s, " "configfile: %s, config: %s)", self.__class__.__name__, mask_type, coverage_ratio, output_size, configfile, config) @@ -61,8 +54,8 @@ def __init__(self, self._do_erode = any(amount != 0 for amount in self._erodes) def _set_config(self, - configfile: Optional[str], - config: Optional[FaceswapConfig]) -> dict: + configfile: str | None, + config: FaceswapConfig | None) -> dict: """ Set the correct configuration for the plugin based on whether a config file or pre-loaded config has been passed in. @@ -123,8 +116,8 @@ def run(self, detected_face: DetectedFace, source_offset: np.ndarray, target_offset: np.ndarray, - centering: Literal["legacy", "face", "head"], - predicted_mask: Optional[np.ndarray] = None) -> Tuple[np.ndarray, np.ndarray]: + centering: T.Literal["legacy", "face", "head"], + predicted_mask: np.ndarray | None = None) -> tuple[np.ndarray, np.ndarray]: """ Obtain the requested mask type and perform any defined mask manipulations. Parameters @@ -171,8 +164,8 @@ def run(self, def _get_mask(self, detected_face: DetectedFace, - predicted_mask: Optional[np.ndarray], - centering: Literal["legacy", "face", "head"], + predicted_mask: np.ndarray | None, + centering: T.Literal["legacy", "face", "head"], source_offset: np.ndarray, target_offset: np.ndarray) -> np.ndarray: """ Return the requested mask with any requested blurring applied. @@ -229,7 +222,7 @@ def _process_predicted_mask(self, mask: np.ndarray) -> np.ndarray: def _get_stored_mask(self, detected_face: DetectedFace, - centering: Literal["legacy", "face", "head"], + centering: T.Literal["legacy", "face", "head"], source_offset: np.ndarray, target_offset: np.ndarray) -> np.ndarray: """ get the requested stored mask from the detected face object. @@ -303,7 +296,7 @@ def _erode(self, mask: np.ndarray) -> np.ndarray: return eroded[..., None] - def _get_erosion_kernels(self, mask: np.ndarray) -> List[np.ndarray]: + def _get_erosion_kernels(self, mask: np.ndarray) -> list[np.ndarray]: """ Get the erosion kernels for each of the center, left, top right and bottom erosions. An approximation is made based on the number of positive pixels within the mask to create diff --git a/plugins/convert/writer/_base.py b/plugins/convert/writer/_base.py index c68fae9399..d283e1009d 100644 --- a/plugins/convert/writer/_base.py +++ b/plugins/convert/writer/_base.py @@ -4,8 +4,7 @@ import logging import os import re - -from typing import Any, List, Optional +import typing as T import numpy as np @@ -14,7 +13,7 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name -def get_config(plugin_name: str, configfile: Optional[str] = None) -> dict: +def get_config(plugin_name: str, configfile: str | None = None) -> dict: """ Obtain the configuration settings for the writer plugin. Parameters @@ -44,7 +43,7 @@ class Output(): The full path to a custom configuration ini file. If ``None`` is passed then the file is loaded from the default location. Default: ``None``. """ - def __init__(self, output_folder: str, configfile: Optional[str] = None) -> None: + def __init__(self, output_folder: str, configfile: str | None = None) -> None: logger.debug("Initializing %s: (output_folder: '%s')", self.__class__.__name__, output_folder) self.config: dict = get_config(".".join(self.__module__.split(".")[-2:]), @@ -69,7 +68,7 @@ def is_stream(self) -> bool: retval = hasattr(self, "frame_order") return retval - def output_filename(self, filename: str, separate_mask: bool = False) -> List[str]: + def output_filename(self, filename: str, separate_mask: bool = False) -> list[str]: """ Obtain the full path for the output file, including the correct extension, for the given input filename. @@ -124,7 +123,7 @@ def cache_frame(self, filename: str, image: np.ndarray) -> None: logger.trace("Added to cache. Frame no: %s", frame_no) # type: ignore logger.trace("Current cache: %s", sorted(self.cache.keys())) # type:ignore - def write(self, filename: str, image: Any) -> None: + def write(self, filename: str, image: T.Any) -> None: """ Override for specific frame writing method. Parameters @@ -137,7 +136,7 @@ def write(self, filename: str, image: Any) -> None: """ raise NotImplementedError - def pre_encode(self, image: np.ndarray) -> Any: # pylint: disable=unused-argument + def pre_encode(self, image: np.ndarray) -> T.Any: # pylint: disable=unused-argument """ Some writer plugins support the pre-encoding of images prior to saving out. As patching is done in multiple threads, but writing is done in a single thread, it can speed up the process to do any pre-encoding as part of the converter process. diff --git a/plugins/convert/writer/ffmpeg.py b/plugins/convert/writer/ffmpeg.py index 283a581fa5..8e3e4b16a0 100644 --- a/plugins/convert/writer/ffmpeg.py +++ b/plugins/convert/writer/ffmpeg.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 """ Video output writer for faceswap.py converter """ +from __future__ import annotations import os +import typing as T + from math import ceil from subprocess import CalledProcessError, check_output, STDOUT -from typing import cast, Generator, List, Optional, Tuple import imageio import imageio_ffmpeg as im_ffm @@ -11,6 +13,9 @@ from ._base import Output, logger +if T.TYPE_CHECKING: + from collections.abc import Generator + class Writer(Output): """ Video output writer using imageio-ffmpeg. @@ -32,7 +37,7 @@ class Writer(Output): def __init__(self, output_folder: str, total_count: int, - frame_ranges: Optional[List[Tuple[int, int]]], + frame_ranges: list[tuple[int, int]] | None, source_video: str, **kwargs) -> None: super().__init__(output_folder, **kwargs) @@ -40,11 +45,11 @@ def __init__(self, total_count, frame_ranges, source_video) self._source_video: str = source_video self._output_filename: str = self._get_output_filename() - self._frame_ranges: Optional[List[Tuple[int, int]]] = frame_ranges - self.frame_order: List[int] = self._set_frame_order(total_count) - self._output_dimensions: Optional[str] = None # Fix dims on 1st received frame + self._frame_ranges: list[tuple[int, int]] | None = frame_ranges + self.frame_order: list[int] = self._set_frame_order(total_count) + self._output_dimensions: str | None = None # Fix dims on 1st received frame # Need to know dimensions of first frame, so set writer then - self._writer: Optional[Generator[None, np.ndarray, None]] = None + self._writer: Generator[None, np.ndarray, None] | None = None @property def _valid_tunes(self) -> dict: @@ -63,7 +68,7 @@ def _video_fps(self) -> float: return retval @property - def _output_params(self) -> List[str]: + def _output_params(self) -> list[str]: """ list: The FFMPEG Output parameters """ codec = self.config["codec"] tune = self.config["tune"] @@ -86,11 +91,11 @@ def _output_params(self) -> List[str]: return output_args @property - def _audio_codec(self) -> Optional[str]: + def _audio_codec(self) -> str | None: """ str or ``None``: The audio codec to use. This will either be ``"copy"`` (the default) or ``None`` if skip muxing has been selected in configuration options, or if frame ranges have been passed in the command line arguments. """ - retval: Optional[str] = "copy" + retval: str | None = "copy" if self.config["skip_mux"]: logger.info("Skipping audio muxing due to configuration settings.") retval = None @@ -169,7 +174,7 @@ def _get_output_filename(self) -> str: logger.info("Outputting to: '%s'", retval) return retval - def _set_frame_order(self, total_count: int) -> List[int]: + def _set_frame_order(self, total_count: int) -> list[int]: """ Obtain the full list of frames to be converted in order. Parameters @@ -191,7 +196,7 @@ def _set_frame_order(self, total_count: int) -> List[int]: logger.debug("frame_order: %s", retval) return retval - def _get_writer(self, frame_dims: Tuple[int, int]) -> Generator[None, np.ndarray, None]: + def _get_writer(self, frame_dims: tuple[int, int]) -> Generator[None, np.ndarray, None]: """ Add the requested encoding options and return the writer. Parameters @@ -238,13 +243,13 @@ def write(self, filename: str, image: np.ndarray) -> None: logger.trace("Received frame: (filename: '%s', shape: %s", # type:ignore[attr-defined] filename, image.shape) if not self._output_dimensions: - input_dims = cast(Tuple[int, int], image.shape[:2]) + input_dims = T.cast(tuple[int, int], image.shape[:2]) self._set_dimensions(input_dims) self._writer = self._get_writer(input_dims) self.cache_frame(filename, image) self._save_from_cache() - def _set_dimensions(self, frame_dims: Tuple[int, int]) -> None: + def _set_dimensions(self, frame_dims: tuple[int, int]) -> None: """ Set the attribute :attr:`_output_dimensions` based on the first frame received. This protects against different sized images coming in and ensures all images are written to ffmpeg at the same size. Dimensions are mapped to a macro block size 8. diff --git a/plugins/convert/writer/gif.py b/plugins/convert/writer/gif.py index 3727ee7cd9..bfa813201d 100644 --- a/plugins/convert/writer/gif.py +++ b/plugins/convert/writer/gif.py @@ -1,14 +1,15 @@ #!/usr/bin/env python3 """ Animated GIF writer for faceswap.py converter """ +from __future__ import annotations import os -from typing import Optional, List, Tuple, TYPE_CHECKING +import typing as T import cv2 import imageio from ._base import Output, logger -if TYPE_CHECKING: +if T.TYPE_CHECKING: from imageio.core import format as im_format # noqa:F401 @@ -31,15 +32,16 @@ class Writer(Output): def __init__(self, output_folder: str, total_count: int, - frame_ranges: Optional[List[Tuple[int, int]]], + frame_ranges: list[tuple[int, int]] | None, **kwargs) -> None: logger.debug("total_count: %s, frame_ranges: %s", total_count, frame_ranges) super().__init__(output_folder, **kwargs) - self.frame_order: List[int] = self._set_frame_order(total_count, frame_ranges) - self._output_dimensions: Optional[Tuple[int, int]] = None # Fix dims on 1st received frame + self.frame_order: list[int] = self._set_frame_order(total_count, frame_ranges) + # Fix dims on 1st received frame + self._output_dimensions: tuple[int, int] | None = None # Need to know dimensions of first frame, so set writer then - self._writer: Optional[imageio.plugins.pillowmulti.GIFFormat.Writer] = None - self._gif_file: Optional[str] = None # Set filename based on first file seen + self._writer: imageio.plugins.pillowmulti.GIFFormat.Writer | None = None + self._gif_file: str | None = None # Set filename based on first file seen @property def _gif_params(self) -> dict: @@ -50,7 +52,7 @@ def _gif_params(self) -> dict: @staticmethod def _set_frame_order(total_count: int, - frame_ranges: Optional[List[Tuple[int, int]]]) -> List[int]: + frame_ranges: list[tuple[int, int]] | None) -> list[int]: """ Obtain the full list of frames to be converted in order. Parameters @@ -75,7 +77,7 @@ def _set_frame_order(total_count: int, logger.debug("frame_order: %s", retval) return retval - def _get_writer(self) -> "im_format.Format.Writer": + def _get_writer(self) -> im_format.Format.Writer: """ Obtain the GIF writer with the requested GIF encoding options. Returns @@ -145,7 +147,7 @@ def _set_gif_filename(self, filename: str) -> None: self._gif_file = retval logger.info("Outputting to: '%s'", self._gif_file) - def _set_dimensions(self, frame_dims: Tuple[int, int]) -> None: + def _set_dimensions(self, frame_dims: tuple[int, int]) -> None: """ Set the attribute :attr:`_output_dimensions` based on the first frame received. This protects against different sized images coming in and ensure all images get written to the Gif at the sema dimensions. """ diff --git a/plugins/convert/writer/opencv.py b/plugins/convert/writer/opencv.py index 2f3b91ec9e..d18d71a6cd 100644 --- a/plugins/convert/writer/opencv.py +++ b/plugins/convert/writer/opencv.py @@ -2,8 +2,6 @@ """ Image output writer for faceswap.py converter Uses cv2 for writing as in testing this was a lot faster than both Pillow and ImageIO """ -from typing import List, Tuple - import cv2 import numpy as np @@ -37,7 +35,7 @@ def _check_transparency_format(self) -> None: "transparency. Changing output format to 'png'") self.config["format"] = "png" - def _get_save_args(self) -> Tuple[int, ...]: + def _get_save_args(self) -> tuple[int, ...]: """ Obtain the save parameters for the file format. Returns @@ -46,7 +44,7 @@ def _get_save_args(self) -> Tuple[int, ...]: The OpenCV specific arguments for the selected file format """ filetype = self.config["format"] - args: Tuple[int, ...] = tuple() + args: tuple[int, ...] = tuple() if filetype == "jpg" and self.config["jpg_quality"] > 0: args = (cv2.IMWRITE_JPEG_QUALITY, self.config["jpg_quality"]) @@ -56,7 +54,7 @@ def _get_save_args(self) -> Tuple[int, ...]: logger.debug(args) return args - def write(self, filename: str, image: List[bytes]) -> None: + def write(self, filename: str, image: list[bytes]) -> None: """ Write out the pre-encoded image to disk. If separate mask has been selected, write out the encoded mask to a sub-folder in the output directory. @@ -77,7 +75,7 @@ def write(self, filename: str, image: List[bytes]) -> None: except Exception as err: # pylint: disable=broad-except logger.error("Failed to save image '%s'. Original Error: %s", filename, err) - def pre_encode(self, image: np.ndarray) -> List[bytes]: + def pre_encode(self, image: np.ndarray) -> list[bytes]: """ Pre_encode the image in lib/convert.py threads as it is a LOT quicker. Parameters diff --git a/plugins/convert/writer/pillow.py b/plugins/convert/writer/pillow.py index 92eb4a081d..a9ffb0aa88 100644 --- a/plugins/convert/writer/pillow.py +++ b/plugins/convert/writer/pillow.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 """ Image output writer for faceswap.py converter """ - -from typing import Dict, List, Union from io import BytesIO from PIL import Image @@ -25,7 +23,7 @@ def __init__(self, output_folder: str, **kwargs) -> None: super().__init__(output_folder, **kwargs) self._check_transparency_format() # Correct format namings for writing to byte stream - self._format_dict = dict(jpg="JPEG", jp2="JPEG 2000", tif="TIFF") + self._format_dict = {"jpg": "JPEG", "jp2": "JPEG 2000", "tif": "TIFF"} self._separate_mask = self.config["draw_transparent"] and self.config["separate_mask"] self._kwargs = self._get_save_kwargs() @@ -38,7 +36,7 @@ def _check_transparency_format(self) -> None: "transparency. Changing output format to 'png'") self.config["format"] = "png" - def _get_save_kwargs(self) -> Dict[str, Union[bool, int, str]]: + def _get_save_kwargs(self) -> dict[str, bool | int | str]: """ Return the save parameters for the file format Returns @@ -59,7 +57,7 @@ def _get_save_kwargs(self) -> Dict[str, Union[bool, int, str]]: logger.debug(kwargs) return kwargs - def write(self, filename: str, image: List[BytesIO]) -> None: + def write(self, filename: str, image: list[BytesIO]) -> None: """ Write out the pre-encoded image to disk. If separate mask has been selected, write out the encoded mask to a sub-folder in the output directory. @@ -80,7 +78,7 @@ def write(self, filename: str, image: List[BytesIO]) -> None: except Exception as err: # pylint: disable=broad-except logger.error("Failed to save image '%s'. Original Error: %s", filename, err) - def pre_encode(self, image: np.ndarray) -> List[BytesIO]: + def pre_encode(self, image: np.ndarray) -> list[BytesIO]: """ Pre_encode the image in lib/convert.py threads as it is a LOT quicker Parameters diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index efb95b7e08..4abe8a5e9d 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -2,12 +2,11 @@ """ Base class for Faceswap :mod:`~plugins.extract.detect`, :mod:`~plugins.extract.align` and :mod:`~plugins.extract.mask` Plugins """ +from __future__ import annotations import logging -import sys +import typing as T from dataclasses import dataclass, field -from typing import (Any, Callable, Dict, Generator, List, Optional, - Sequence, Union, Tuple, TYPE_CHECKING) import numpy as np from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa @@ -18,12 +17,8 @@ from ._config import Config from .pipeline import ExtractMedia -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - -if TYPE_CHECKING: +if T.TYPE_CHECKING: + from collections.abc import Callable, Generator, Sequence from queue import Queue import cv2 from lib.align import DetectedFace @@ -37,7 +32,7 @@ # TODO Run with warnings mode -def _get_config(plugin_name: str, configfile: Optional[str] = None) -> Dict[str, Any]: +def _get_config(plugin_name: str, configfile: str | None = None) -> dict[str, T.Any]: """ Return the configuration for the requested model Parameters @@ -56,7 +51,7 @@ def _get_config(plugin_name: str, configfile: Optional[str] = None) -> Dict[str, return Config(plugin_name, configfile=configfile).config_dict -BatchType = Union["DetectorBatch", "AlignerBatch", "MaskerBatch", "RecogBatch"] +BatchType = T.Union["DetectorBatch", "AlignerBatch", "MaskerBatch", "RecogBatch"] @dataclass @@ -84,13 +79,12 @@ class ExtractorBatch: data: dict Any specific data required during the processing phase for a particular plugin """ - image: List[np.ndarray] = field(default_factory=list) - detected_faces: Sequence[Union["DetectedFace", - List["DetectedFace"]]] = field(default_factory=list) - filename: List[str] = field(default_factory=list) + image: list[np.ndarray] = field(default_factory=list) + detected_faces: Sequence[DetectedFace | list[DetectedFace]] = field(default_factory=list) + filename: list[str] = field(default_factory=list) feed: np.ndarray = np.array([]) prediction: np.ndarray = np.array([]) - data: List[Dict[str, Any]] = field(default_factory=list) + data: list[dict[str, T.Any]] = field(default_factory=list) class Extractor(): @@ -157,10 +151,10 @@ class Extractor(): """ def __init__(self, - git_model_id: Optional[int] = None, - model_filename: Optional[Union[str, List[str]]] = None, - exclude_gpus: Optional[List[int]] = None, - configfile: Optional[str] = None, + git_model_id: int | None = None, + model_filename: str | list[str] | None = None, + exclude_gpus: list[int] | None = None, + configfile: str | None = None, instance: int = 0) -> None: logger.debug("Initializing %s: (git_model_id: %s, model_filename: %s, exclude_gpus: %s, " "configfile: %s, instance: %s, )", self.__class__.__name__, git_model_id, @@ -176,9 +170,9 @@ def __init__(self, be a list of strings """ # << SET THE FOLLOWING IN PLUGINS __init__ IF DIFFERENT FROM DEFAULT >> # - self.name: Optional[str] = None + self.name: str | None = None self.input_size = 0 - self.color_format: Literal["BGR", "RGB", "GRAY"] = "BGR" + self.color_format: T.Literal["BGR", "RGB", "GRAY"] = "BGR" self.vram = 0 self.vram_warnings = 0 # Will run at this with warnings self.vram_per_batch = 0 @@ -187,7 +181,7 @@ def __init__(self, self.queue_size = 1 """ int: Queue size for all internal queues. Set in :func:`initialize()` """ - self.model: Optional[Union["KSession", "cv2.dnn.Net"]] = None + self.model: KSession | cv2.dnn.Net | None = None """varies: The model for this plugin. Set in the plugin's :func:`init_model()` method """ # For detectors that support batching, this should be set to the calculated batch size @@ -196,26 +190,26 @@ def __init__(self, """ int: Batchsize for feeding this model. The number of images the model should feed through at once. """ - self._queues: Dict[str, "Queue"] = {} + self._queues: dict[str, Queue] = {} """ dict: in + out queues and internal queues for this plugin, """ - self._threads: List[MultiThread] = [] + self._threads: list[MultiThread] = [] """ list: Internal threads for this plugin """ - self._extract_media: Dict[str, ExtractMedia] = {} + self._extract_media: dict[str, ExtractMedia] = {} """ dict: The :class:`plugins.extract.pipeline.ExtractMedia` objects currently being processed. Stored at input for pairing back up on output of extractor process """ # << THE FOLLOWING PROTECTED ATTRIBUTES ARE SET IN PLUGIN TYPE _base.py >>> # - self._plugin_type: Optional[Literal["align", "detect", "recognition", "mask"]] = None + self._plugin_type: T.Literal["align", "detect", "recognition", "mask"] | None = None """ str: Plugin type. ``detect`, ``align``, ``recognise`` or ``mask`` set in ``._base`` """ # << Objects for splitting frame's detected faces and rejoining them >> # << for post-detector pliugins >> - self._faces_per_filename: Dict[str, int] = {} # Tracking for recompiling batches - self._rollover: Optional[ExtractMedia] = None # batch rollover items - self._output_faces: List["DetectedFace"] = [] # Recompiled output faces from plugin + self._faces_per_filename: dict[str, int] = {} # Tracking for recompiling batches + self._rollover: ExtractMedia | None = None # batch rollover items + self._output_faces: list[DetectedFace] = [] # Recompiled output faces from plugin logger.debug("Initialized _base %s", self.__class__.__name__) @@ -361,7 +355,7 @@ def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: """ raise NotImplementedError - def get_batch(self, queue: "Queue") -> Tuple[bool, BatchType]: + def get_batch(self, queue: Queue) -> tuple[bool, BatchType]: """ **Override method** (at `` level) This method should be overridden at the `` level (IE. @@ -403,7 +397,7 @@ def check_and_raise_error(self) -> None: for thread in self._threads: thread.check_and_raise_error() - def rollover_collector(self, queue: "Queue") -> Union[Literal["EOF"], ExtractMedia]: + def rollover_collector(self, queue: Queue) -> T.Literal["EOF"] | ExtractMedia: """ For extractors after the Detectors, the number of detected faces per frame vs extractor batch size mean that faces will need to be split/re-joined with frames. The rollover collector can be used to rollover items that don't fit in a batch. @@ -425,7 +419,7 @@ def rollover_collector(self, queue: "Queue") -> Union[Literal["EOF"], ExtractMed if self._rollover is not None: logger.trace("Getting from _rollover: (filename: `%s`, faces: %s)", # type:ignore self._rollover.filename, len(self._rollover.detected_faces)) - item: Union[Literal["EOF"], ExtractMedia] = self._rollover + item: T.Literal["EOF"] | ExtractMedia = self._rollover self._rollover = None else: next_item = self._get_item(queue) @@ -442,9 +436,8 @@ def rollover_collector(self, queue: "Queue") -> Union[Literal["EOF"], ExtractMed # <<< INIT METHODS >>> # @classmethod def _get_model(cls, - git_model_id: Optional[int], - model_filename: Optional[Union[str, List[str]]] - ) -> Optional[Union[str, List[str]]]: + git_model_id: int | None, + model_filename: str | list[str] | None) -> str | list[str] | None: """ Check if model is available, if not, download and unzip it """ if model_filename is None: logger.debug("No model_filename specified. Returning None") @@ -496,9 +489,9 @@ def initialize(self, *args, **kwargs) -> None: self.name, self._plugin_type.title(), self.batchsize) def _add_queues(self, - in_queue: "Queue", - out_queue: "Queue", - queues: List[str]) -> None: + in_queue: Queue, + out_queue: Queue, + queues: list[str]) -> None: """ Add the queues in_queue and out_queue should be previously created queue manager queues. queues should be a list of queue names """ @@ -533,8 +526,8 @@ def _compile_threads(self) -> None: def _add_thread(self, name: str, function: Callable[[BatchType], BatchType], - in_queue: "Queue", - out_queue: "Queue") -> None: + in_queue: Queue, + out_queue: Queue) -> None: """ Add a MultiThread thread to self._threads """ logger.debug("Adding thread: (name: %s, function: %s, in_queue: %s, out_queue: %s)", name, function, in_queue, out_queue) @@ -546,8 +539,8 @@ def _add_thread(self, logger.debug("Added thread: %s", name) def _obtain_batch_item(self, function: Callable[[BatchType], BatchType], - in_queue: "Queue", - out_queue: "Queue") -> Optional[BatchType]: + in_queue: Queue, + out_queue: Queue) -> BatchType | None: """ Obtain the batch item from the in queue for the current process. Parameters @@ -564,7 +557,7 @@ def _obtain_batch_item(self, function: Callable[[BatchType], BatchType], :class:`ExtractorBatch` or ``None`` The batch, if one exists, or ``None`` if queue is exhausted """ - batch: Union[Literal["EOF"], BatchType, ExtractMedia] + batch: T.Literal["EOF"] | BatchType | ExtractMedia if function.__name__ == "_process_input": # Process input items to batches exhausted, batch = self.get_batch(in_queue) if exhausted: @@ -585,8 +578,8 @@ def _obtain_batch_item(self, function: Callable[[BatchType], BatchType], def _thread_process(self, function: Callable[[BatchType], BatchType], - in_queue: "Queue", - out_queue: "Queue") -> None: + in_queue: Queue, + out_queue: Queue) -> None: """ Perform a plugin function in a thread Parameters @@ -629,7 +622,7 @@ def _thread_process(self, out_queue.put("EOF") # <<< QUEUE METHODS >>> # - def _get_item(self, queue: "Queue") -> Union[Literal["EOF"], ExtractMedia, BatchType]: + def _get_item(self, queue: Queue) -> T.Literal["EOF"] | ExtractMedia | BatchType: """ Yield one item from a queue """ item = queue.get() if isinstance(item, ExtractMedia): diff --git a/plugins/extract/align/_base/aligner.py b/plugins/extract/align/_base/aligner.py index 9f15dbef2c..75dae9bbf7 100644 --- a/plugins/extract/align/_base/aligner.py +++ b/plugins/extract/align/_base/aligner.py @@ -12,12 +12,12 @@ >>> "landmarks": [list of 68 point face landmarks] >>> "detected_faces": []} """ +from __future__ import annotations import logging -import sys +import typing as T from dataclasses import dataclass, field from time import sleep -from typing import cast, Generator, List, Optional, Tuple, TYPE_CHECKING import cv2 import numpy as np @@ -28,12 +28,8 @@ from plugins.extract._base import BatchType, Extractor, ExtractMedia, ExtractorBatch from .processing import AlignedFilter, ReAlign -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - -if TYPE_CHECKING: +if T.TYPE_CHECKING: + from collections.abc import Generator from queue import Queue from lib.align import DetectedFace from lib.align.aligned_face import CenteringType @@ -77,9 +73,9 @@ class AlignerBatch(ExtractorBatch): The masks used to filter out re-feed values for passing to the re-aligner. """ batch_id: int = 0 - detected_faces: List["DetectedFace"] = field(default_factory=list) + detected_faces: list[DetectedFace] = field(default_factory=list) landmarks: np.ndarray = np.array([]) - refeeds: List[np.ndarray] = field(default_factory=list) + refeeds: list[np.ndarray] = field(default_factory=list) second_pass: bool = False second_pass_masks: np.ndarray = np.array([]) @@ -142,11 +138,11 @@ class Aligner(Extractor): # pylint:disable=abstract-method """ def __init__(self, - git_model_id: Optional[int] = None, - model_filename: Optional[str] = None, - configfile: Optional[str] = None, + git_model_id: int | None = None, + model_filename: str | None = None, + configfile: str | None = None, instance: int = 0, - normalize_method: Optional[Literal["none", "clahe", "hist", "mean"]] = None, + normalize_method: T.Literal["none", "clahe", "hist", "mean"] | None = None, re_feed: int = 0, re_align: bool = False, disable_filter: bool = False, @@ -160,9 +156,9 @@ def __init__(self, instance=instance, **kwargs) self._plugin_type = "align" - self.realign_centering: "CenteringType" = "face" # overide for plugin specific centering + self.realign_centering: CenteringType = "face" # overide for plugin specific centering self._eof_seen = False - self._normalize_method: Optional[Literal["clahe", "hist", "mean"]] = None + self._normalize_method: T.Literal["clahe", "hist", "mean"] | None = None self._re_feed = re_feed self._filter = AlignedFilter(feature_filter=self.config["aligner_features"], min_scale=self.config["aligner_min_scale"], @@ -181,8 +177,8 @@ def __init__(self, logger.debug("Initialized %s", self.__class__.__name__) - def set_normalize_method(self, - method: Optional[Literal["none", "clahe", "hist", "mean"]]) -> None: + def set_normalize_method(self, method: T.Literal["none", "clahe", "hist", "mean"] | None + ) -> None: """ Set the normalization method for feeding faces into the aligner. Parameters @@ -191,14 +187,14 @@ def set_normalize_method(self, The normalization method to apply to faces prior to feeding into the model """ method = None if method is None or method.lower() == "none" else method - self._normalize_method = cast(Optional[Literal["clahe", "hist", "mean"]], method) + self._normalize_method = T.cast(T.Literal["clahe", "hist", "mean"] | None, method) def initialize(self, *args, **kwargs) -> None: """ Add a call to add model input size to the re-aligner """ self._re_align.set_input_size_and_centering(self.input_size, self.realign_centering) super().initialize(*args, **kwargs) - def _handle_realigns(self, queue: "Queue") -> Optional[Tuple[bool, AlignerBatch]]: + def _handle_realigns(self, queue: Queue) -> tuple[bool, AlignerBatch] | None: """ Handle any items waiting for a second pass through the aligner. If EOF has been recieved and items are still being processed through the first pass @@ -242,7 +238,7 @@ def _handle_realigns(self, queue: "Queue") -> Optional[Tuple[bool, AlignerBatch] return None - def get_batch(self, queue: "Queue") -> Tuple[bool, AlignerBatch]: + def get_batch(self, queue: Queue) -> tuple[bool, AlignerBatch]: """ Get items for inputting into the aligner from the queue in batches Items are returned from the ``queue`` in batches of @@ -548,7 +544,7 @@ def _predict(self, batch: BatchType) -> AlignerBatch: "\n3) Enable 'Single Process' mode.") raise FaceswapError(msg) from err - def _process_refeeds(self, batch: AlignerBatch) -> List[AlignerBatch]: + def _process_refeeds(self, batch: AlignerBatch) -> list[AlignerBatch]: """ Process the output for each selected re-feed Parameters @@ -562,7 +558,7 @@ def _process_refeeds(self, batch: AlignerBatch) -> List[AlignerBatch]: List of :class:`AlignerBatch` objects. Each object in the list contains the results for each selected re-feed """ - retval: List[AlignerBatch] = [] + retval: list[AlignerBatch] = [] if batch.second_pass: # Re-insert empty sub-patches for re-population in ReAlign for filtered out batches selected_idx = 0 @@ -605,8 +601,8 @@ def _process_refeeds(self, batch: AlignerBatch) -> List[AlignerBatch]: return retval def _get_refeed_filter_masks(self, - subbatches: List[AlignerBatch], - original_masks: Optional[np.ndarray] = None) -> np.ndarray: + subbatches: list[AlignerBatch], + original_masks: np.ndarray | None = None) -> np.ndarray: """ Obtain the boolean mask array for masking out failed re-feed results if filter refeed has been selected @@ -663,7 +659,7 @@ def _get_mean_landmarks(self, landmarks: np.ndarray, masks: np.ndarray) -> np.nd landmarks.shape) return np.ma.array(landmarks, mask=masks).mean(axis=0).data.astype("float32") - def _process_output_first_pass(self, subbatches: List[AlignerBatch]) -> Tuple[np.ndarray, + def _process_output_first_pass(self, subbatches: list[AlignerBatch]) -> tuple[np.ndarray, np.ndarray]: """ Process the output from the aligner if this is the first or only pass. @@ -696,7 +692,7 @@ def _process_output_first_pass(self, subbatches: List[AlignerBatch]) -> Tuple[np return all_landmarks, masks def _process_output_second_pass(self, - subbatches: List[AlignerBatch], + subbatches: list[AlignerBatch], masks: np.ndarray) -> np.ndarray: """ Process the output from the aligner if this is the first or only pass. diff --git a/plugins/extract/align/_base/processing.py b/plugins/extract/align/_base/processing.py index 8c13171c40..efdeec9468 100644 --- a/plugins/extract/align/_base/processing.py +++ b/plugins/extract/align/_base/processing.py @@ -1,21 +1,16 @@ #!/usr/bin/env python3 """ Processing methods for aligner plugins """ +from __future__ import annotations import logging -import sys +import typing as T from threading import Lock -from typing import Dict, List, Optional, Tuple, TYPE_CHECKING, Union import numpy as np from lib.align import AlignedFace -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - -if TYPE_CHECKING: +if T.TYPE_CHECKING: from lib.align import DetectedFace from .aligner import AlignerBatch from lib.align.aligned_face import CenteringType @@ -72,16 +67,16 @@ def __init__(self, min_scale > 0.0 or distance > 0.0 or roll > 0.0) - self._counts: Dict[str, int] = dict(features=0, - min_scale=0, - max_scale=0, - distance=0, - roll=0) + self._counts: dict[str, int] = {"features": 0, + "min_scale": 0, + "max_scale": 0, + "distance": 0, + "roll": 0} logger.debug("Initialized %s: ", self.__class__.__name__) def _scale_test(self, face: AlignedFace, - minimum_dimension: int) -> Optional[Literal["min", "max"]]: + minimum_dimension: int) -> T.Literal["min", "max"] | None: """ Test if a face is below or above the min/max size thresholds. Returns as soon as a test fails. @@ -116,9 +111,9 @@ def _scale_test(self, def _handle_filtered(self, key: str, - face: "DetectedFace", - faces: List["DetectedFace"], - sub_folders: List[Optional[str]], + face: DetectedFace, + faces: list[DetectedFace], + sub_folders: list[str | None], sub_folder_index: int) -> None: """ Add the filtered item to the filter counts. @@ -145,8 +140,8 @@ def _handle_filtered(self, faces.append(face) sub_folders[sub_folder_index] = f"_align_filt_{key}" - def __call__(self, faces: List["DetectedFace"], minimum_dimension: int - ) -> Tuple[List["DetectedFace"], List[Optional[str]]]: + def __call__(self, faces: list[DetectedFace], minimum_dimension: int + ) -> tuple[list[DetectedFace], list[str | None]]: """ Apply the filter to the incoming batch Parameters @@ -165,11 +160,11 @@ def __call__(self, faces: List["DetectedFace"], minimum_dimension: int List of ``Nones`` if saving filtered faces has not been selected or list of ``Nones`` and sub folder names corresponding the filtered face location """ - sub_folders: List[Optional[str]] = [None for _ in range(len(faces))] + sub_folders: list[str | None] = [None for _ in range(len(faces))] if not self._active: return faces, sub_folders - retval: List["DetectedFace"] = [] + retval: list[DetectedFace] = [] for idx, face in enumerate(faces): aligned = AlignedFace(landmarks=face.landmarks_xy, centering="face") @@ -194,8 +189,8 @@ def __call__(self, faces: List["DetectedFace"], minimum_dimension: int return retval, sub_folders def filtered_mask(self, - batch: "AlignerBatch", - skip: Optional[Union[np.ndarray, List[int]]] = None) -> np.ndarray: + batch: AlignerBatch, + skip: np.ndarray | list[int] | None = None) -> np.ndarray: """ Obtain a list of boolean values for the given batch indicating whether they pass the filter test. @@ -262,13 +257,14 @@ def __init__(self, active: bool, do_refeeds: bool, do_filter: bool) -> None: self._active = active self._do_refeeds = do_refeeds self._do_filter = do_filter - self._centering: "CenteringType" = "face" + self._centering: CenteringType = "face" self._size = 0 self._tracked_lock = Lock() - self._tracked_batchs: Dict[int, Dict[Literal["filtered_landmarks"], List[np.ndarray]]] = {} + self._tracked_batchs: dict[int, + dict[T.Literal["filtered_landmarks"], list[np.ndarray]]] = {} # TODO. Probably does not need to be a list, just alignerbatch self._queue_lock = Lock() - self._queued: List["AlignerBatch"] = [] + self._queued: list[AlignerBatch] = [] logger.debug("Initialized %s", self.__class__.__name__) @property @@ -301,7 +297,7 @@ def items_tracked(self) -> bool: with self._tracked_lock: return bool(self._tracked_batchs) - def set_input_size_and_centering(self, input_size: int, centering: "CenteringType") -> None: + def set_input_size_and_centering(self, input_size: int, centering: CenteringType) -> None: """ Set the input size of the loaded plugin once the model has been loaded Parameters @@ -344,7 +340,7 @@ def untrack_batch(self, batch_id: int) -> None: with self._tracked_lock: del self._tracked_batchs[batch_id] - def add_batch(self, batch: "AlignerBatch") -> None: + def add_batch(self, batch: AlignerBatch) -> None: """ Add first pass alignments to the queue for picking up for re-alignment, update their :attr:`second_pass` attribute to ``True`` and clear attributes not required. @@ -362,7 +358,7 @@ def add_batch(self, batch: "AlignerBatch") -> None: batch.data = [] self._queued.append(batch) - def get_batch(self) -> "AlignerBatch": + def get_batch(self) -> AlignerBatch: """ Retrieve the next batch currently queued for re-alignment Returns @@ -376,7 +372,7 @@ def get_batch(self) -> "AlignerBatch": retval.filename) return retval - def process_batch(self, batch: "AlignerBatch") -> List[np.ndarray]: + def process_batch(self, batch: AlignerBatch) -> list[np.ndarray]: """ Pre process a batch object for re-aligning through the aligner. Parameters @@ -391,8 +387,8 @@ def process_batch(self, batch: "AlignerBatch") -> List[np.ndarray]: """ logger.trace("Processing batch: %s, landmarks: %s", # type: ignore[attr-defined] batch.filename, [b.shape for b in batch.landmarks]) - retval: List[np.ndarray] = [] - filtered_landmarks: List[np.ndarray] = [] + retval: list[np.ndarray] = [] + filtered_landmarks: list[np.ndarray] = [] for landmarks, masks in zip(batch.landmarks, batch.second_pass_masks): if not np.all(masks): # At least one face has not already been filtered aligned_faces = [AlignedFace(lms, @@ -415,7 +411,7 @@ def process_batch(self, batch: "AlignerBatch") -> List[np.ndarray]: batch.landmarks = np.array([]) # Clear the old landmarks return retval - def _transform_to_frame(self, batch: "AlignerBatch") -> np.ndarray: + def _transform_to_frame(self, batch: AlignerBatch) -> np.ndarray: """ Transform the predicted landmarks from the aligned face image back into frame co-ordinates @@ -430,14 +426,14 @@ def _transform_to_frame(self, batch: "AlignerBatch") -> np.ndarray: :class:`numpy.ndarray` The landmarks transformed to frame space """ - faces: List[AlignedFace] = batch.data[0]["aligned_faces"] + faces: list[AlignedFace] = batch.data[0]["aligned_faces"] retval = np.array([aligned.transform_points(landmarks, invert=True) for landmarks, aligned in zip(batch.landmarks, faces)]) logger.trace("Transformed points: original max: %s, " # type: ignore[attr-defined] "new max: %s", batch.landmarks.max(), retval.max()) return retval - def _re_insert_filtered(self, batch: "AlignerBatch", masks: np.ndarray) -> np.ndarray: + def _re_insert_filtered(self, batch: AlignerBatch, masks: np.ndarray) -> np.ndarray: """ Re-insert landmarks that were filtered out from the re-align process back into the landmark results @@ -473,7 +469,7 @@ def _re_insert_filtered(self, batch: "AlignerBatch", masks: np.ndarray) -> np.nd return retval - def process_output(self, subbatches: List["AlignerBatch"], batch_masks: np.ndarray) -> None: + def process_output(self, subbatches: list[AlignerBatch], batch_masks: np.ndarray) -> None: """ Process the output from the re-align pass. - Transform landmarks from aligned face space to face space diff --git a/plugins/extract/align/cv2_dnn.py b/plugins/extract/align/cv2_dnn.py index 9b883c2ff6..44c41fb62a 100644 --- a/plugins/extract/align/cv2_dnn.py +++ b/plugins/extract/align/cv2_dnn.py @@ -23,15 +23,16 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations import logging -from typing import cast, List, Tuple, TYPE_CHECKING +import typing as T import cv2 import numpy as np from ._base import Aligner, AlignerBatch, BatchType -if TYPE_CHECKING: +if T.TYPE_CHECKING: from lib.align.detected_face import DetectedFace logger = logging.getLogger(__name__) @@ -89,9 +90,9 @@ def process_input(self, batch: BatchType) -> None: assert isinstance(batch, AlignerBatch) lfaces, roi, offsets = self.align_image(batch) batch.feed = np.array(lfaces)[..., :3] - batch.data.append(dict(roi=roi, offsets=offsets)) + batch.data.append({"roi": roi, "offsets": offsets}) - def _get_box_and_offset(self, face: "DetectedFace") -> Tuple[List[int], int]: + def _get_box_and_offset(self, face: DetectedFace) -> tuple[list[int], int]: """Obtain the bounding box and offset from a detected face. @@ -108,17 +109,17 @@ def _get_box_and_offset(self, face: "DetectedFace") -> Tuple[List[int], int]: The offset of the box (difference between half width vs height) """ - box = cast(List[int], [face.left, - face.top, - face.right, - face.bottom]) - diff_height_width = cast(int, face.height) - cast(int, face.width) + box = T.cast(list[int], [face.left, + face.top, + face.right, + face.bottom]) + diff_height_width = T.cast(int, face.height) - T.cast(int, face.width) offset = int(abs(diff_height_width / 2)) return box, offset - def align_image(self, batch: AlignerBatch) -> Tuple[List[np.ndarray], - List[List[int]], - List[Tuple[int, int]]]: + def align_image(self, batch: AlignerBatch) -> tuple[list[np.ndarray], + list[list[int]], + list[tuple[int, int]]]: """ Align the incoming image for prediction Parameters @@ -159,8 +160,8 @@ def align_image(self, batch: AlignerBatch) -> Tuple[List[np.ndarray], @classmethod def move_box(cls, - box: List[int], - offset: Tuple[int, int]) -> List[int]: + box: list[int], + offset: tuple[int, int]) -> list[int]: """Move the box to direction specified by vector offset Parameters @@ -182,7 +183,7 @@ def move_box(cls, return [left, top, right, bottom] @staticmethod - def get_square_box(box: List[int]) -> List[int]: + def get_square_box(box: list[int]) -> list[int]: """Get a square box out of the given box, by expanding it. Parameters @@ -226,7 +227,7 @@ def get_square_box(box: List[int]) -> List[int]: return [left, top, right, bottom] @classmethod - def pad_image(cls, box: List[int], image: np.ndarray) -> Tuple[np.ndarray, Tuple[int, int]]: + def pad_image(cls, box: list[int], image: np.ndarray) -> tuple[np.ndarray, tuple[int, int]]: """Pad image if face-box falls outside of boundaries Parameters diff --git a/plugins/extract/align/fan.py b/plugins/extract/align/fan.py index 5a12610acf..a829f3bcac 100644 --- a/plugins/extract/align/fan.py +++ b/plugins/extract/align/fan.py @@ -3,8 +3,9 @@ Code adapted and modified from: https://github.com/1adrianb/face-alignment """ +from __future__ import annotations import logging -from typing import cast, List, TYPE_CHECKING +import typing as T import cv2 import numpy as np @@ -12,7 +13,7 @@ from lib.model.session import KSession from ._base import Aligner, AlignerBatch, BatchType -if TYPE_CHECKING: +if T.TYPE_CHECKING: from lib.align import DetectedFace logger = logging.getLogger(__name__) @@ -76,10 +77,10 @@ def process_input(self, batch: BatchType) -> None: logger.trace("Aligning faces around center") # type:ignore[attr-defined] center_scale = self.get_center_scale(batch.detected_faces) batch.feed = np.array(self.crop(batch, center_scale))[..., :3] - batch.data.append(dict(center_scale=center_scale)) + batch.data.append({"center_scale": center_scale}) logger.trace("Aligned image around center") # type:ignore[attr-defined] - def get_center_scale(self, detected_faces: List["DetectedFace"]) -> np.ndarray: + def get_center_scale(self, detected_faces: list[DetectedFace]) -> np.ndarray: """ Get the center and set scale of bounding box Parameters @@ -95,11 +96,11 @@ def get_center_scale(self, detected_faces: List["DetectedFace"]) -> np.ndarray: logger.trace("Calculating center and scale") # type:ignore[attr-defined] center_scale = np.empty((len(detected_faces), 68, 3), dtype='float32') for index, face in enumerate(detected_faces): - x_center = (cast(int, face.left) + face.right) / 2.0 - y_center = (cast(int, face.top) + face.bottom) / 2.0 - cast(int, face.height) * 0.12 - scale = (cast(int, face.width) + cast(int, face.height)) * self.reference_scale - center_scale[index, :, 0] = np.full(68, x_center, dtype='float32') - center_scale[index, :, 1] = np.full(68, y_center, dtype='float32') + x_ctr = (T.cast(int, face.left) + face.right) / 2.0 + y_ctr = (T.cast(int, face.top) + face.bottom) / 2.0 - T.cast(int, face.height) * 0.12 + scale = (T.cast(int, face.width) + T.cast(int, face.height)) * self.reference_scale + center_scale[index, :, 0] = np.full(68, x_ctr, dtype='float32') + center_scale[index, :, 1] = np.full(68, y_ctr, dtype='float32') center_scale[index, :, 2] = np.full(68, scale, dtype='float32') logger.trace("Calculated center and scale: %s", center_scale) # type:ignore[attr-defined] return center_scale @@ -144,7 +145,7 @@ def _crop_image(self, dsize=(self.input_size, self.input_size), interpolation=interp) - def crop(self, batch: AlignerBatch, center_scale: np.ndarray) -> List[np.ndarray]: + def crop(self, batch: AlignerBatch, center_scale: np.ndarray) -> list[np.ndarray]: """ Crop image around the center point Parameters diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index 10488ed52b..c85f75776b 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -15,9 +15,11 @@ >>> face = self._to_detected_face(, , , ) """ +from __future__ import annotations import logging +import typing as T + from dataclasses import dataclass, field -from typing import cast, Generator, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 import numpy as np @@ -30,7 +32,8 @@ from plugins.extract._base import BatchType, Extractor, ExtractorBatch from plugins.extract.pipeline import ExtractMedia -if TYPE_CHECKING: +if T.TYPE_CHECKING: + from collections.abc import Generator from queue import Queue logger = logging.getLogger(__name__) @@ -53,10 +56,10 @@ class DetectorBatch(ExtractorBatch): initial_feed: :class:`numpy.ndarray` Used to hold the initial :attr:`feed` when rotate images is enabled """ - detected_faces: List[List["DetectedFace"]] = field(default_factory=list) - rotation_matrix: List[np.ndarray] = field(default_factory=list) - scale: List[float] = field(default_factory=list) - pad: List[Tuple[int, int]] = field(default_factory=list) + detected_faces: list[list["DetectedFace"]] = field(default_factory=list) + rotation_matrix: list[np.ndarray] = field(default_factory=list) + scale: list[float] = field(default_factory=list) + pad: list[tuple[int, int]] = field(default_factory=list) initial_feed: np.ndarray = np.array([]) @@ -95,11 +98,11 @@ class Detector(Extractor): # pylint:disable=abstract-method """ def __init__(self, - git_model_id: Optional[int] = None, - model_filename: Optional[Union[str, List[str]]] = None, - configfile: Optional[str] = None, + git_model_id: int | None = None, + model_filename: str | list[str] | None = None, + configfile: str | None = None, instance: int = 0, - rotation: Optional[str] = None, + rotation: str | None = None, min_size: int = 0, **kwargs) -> None: logger.debug("Initializing %s: (rotation: %s, min_size: %s)", self.__class__.__name__, @@ -117,7 +120,7 @@ def __init__(self, logger.debug("Initialized _base %s", self.__class__.__name__) # <<< QUEUE METHODS >>> # - def get_batch(self, queue: "Queue") -> Tuple[bool, DetectorBatch]: + def get_batch(self, queue: Queue) -> tuple[bool, DetectorBatch]: """ Get items for inputting to the detector plugin in batches Items are received as :class:`~plugins.extract.pipeline.ExtractMedia` objects and converted @@ -271,7 +274,7 @@ def _predict(self, batch: BatchType) -> DetectorBatch: """ Wrap models predict function in rotations """ assert isinstance(batch, DetectorBatch) batch.rotation_matrix = [np.array([]) for _ in range(len(batch.feed))] - found_faces: List[np.ndarray] = [np.array([]) for _ in range(len(batch.feed))] + found_faces: list[np.ndarray] = [np.array([]) for _ in range(len(batch.feed))] for angle in self.rotation: # Rotate the batch and insert placeholders for already found faces self._rotate_batch(batch, angle) @@ -301,9 +304,9 @@ def _predict(self, batch: BatchType) -> DetectorBatch: "degrees", angle) - found_faces = cast(List[np.ndarray], ([face if not found.any() else found - for face, found in zip(batch.prediction, - found_faces)])) + found_faces = T.cast(list[np.ndarray], ([face if not found.any() else found + for face, found in zip(batch.prediction, + found_faces)])) if all(face.any() for face in found_faces): logger.trace("Faces found for all images") # type:ignore[attr-defined] @@ -317,7 +320,7 @@ def _predict(self, batch: BatchType) -> DetectorBatch: # <<< DETECTION IMAGE COMPILATION METHODS >>> # def _compile_detection_image(self, item: ExtractMedia - ) -> Tuple[np.ndarray, float, Tuple[int, int]]: + ) -> tuple[np.ndarray, float, tuple[int, int]]: """ Compile the detection image for feeding into the model Parameters @@ -345,7 +348,7 @@ def _compile_detection_image(self, item: ExtractMedia image.shape, scale, pad) return image, scale, pad - def _set_scale(self, image_size: Tuple[int, int]) -> float: + def _set_scale(self, image_size: tuple[int, int]) -> float: """ Set the scale factor for incoming image Parameters @@ -362,7 +365,7 @@ def _set_scale(self, image_size: Tuple[int, int]) -> float: logger.trace("Detector scale: %s", scale) # type:ignore[attr-defined] return scale - def _set_padding(self, image_size: Tuple[int, int], scale: float) -> Tuple[int, int]: + def _set_padding(self, image_size: tuple[int, int], scale: float) -> tuple[int, int]: """ Set the image padding for non-square images Parameters @@ -382,7 +385,7 @@ def _set_padding(self, image_size: Tuple[int, int], scale: float) -> Tuple[int, return pad_left, pad_top @staticmethod - def _scale_image(image: np.ndarray, image_size: Tuple[int, int], scale: float) -> np.ndarray: + def _scale_image(image: np.ndarray, image_size: tuple[int, int], scale: float) -> np.ndarray: """ Scale the image and optional pad to given size Parameters @@ -439,8 +442,8 @@ def _pad_image(self, image: np.ndarray) -> np.ndarray: return image # <<< FINALIZE METHODS >>> # - def _remove_zero_sized_faces(self, batch_faces: List[List[DetectedFace]] - ) -> List[List[DetectedFace]]: + def _remove_zero_sized_faces(self, batch_faces: list[list[DetectedFace]] + ) -> list[list[DetectedFace]]: """ Remove items from batch_faces where detected face is of zero size or face falls entirely outside of image @@ -463,8 +466,8 @@ def _remove_zero_sized_faces(self, batch_faces: List[List[DetectedFace]] logger.trace("Output sizes: %s", [len(face) for face in retval]) # type: ignore return retval - def _filter_small_faces(self, detected_faces: List[List[DetectedFace]] - ) -> List[List[DetectedFace]]: + def _filter_small_faces(self, detected_faces: list[list[DetectedFace]] + ) -> list[list[DetectedFace]]: """ Filter out any faces smaller than the min size threshold Parameters @@ -493,7 +496,7 @@ def _filter_small_faces(self, detected_faces: List[List[DetectedFace]] # <<< IMAGE ROTATION METHODS >>> # @staticmethod - def _get_rotation_angles(rotation: Optional[str]) -> List[int]: + def _get_rotation_angles(rotation: str | None) -> list[int]: """ Set the rotation angles. Parameters @@ -544,8 +547,8 @@ def _rotate_batch(self, batch: DetectorBatch, angle: int) -> None: batch.initial_feed = batch.feed.copy() return - feeds: List[np.ndarray] = [] - rotmats: List[np.ndarray] = [] + feeds: list[np.ndarray] = [] + rotmats: list[np.ndarray] = [] for img, faces, rotmat in zip(batch.initial_feed, batch.prediction, batch.rotation_matrix): @@ -605,7 +608,7 @@ def _rotate_face(face: DetectedFace, rotation_matrix: np.ndarray) -> DetectedFac def _rotate_image_by_angle(self, image: np.ndarray, - angle: int) -> Tuple[np.ndarray, np.ndarray]: + angle: int) -> tuple[np.ndarray, np.ndarray]: """ Rotate an image by a given angle. Parameters diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py index 4c14d1221a..8af8a41bef 100644 --- a/plugins/extract/detect/mtcnn.py +++ b/plugins/extract/detect/mtcnn.py @@ -34,7 +34,7 @@ def __init__(self, **kwargs) -> None: self.kwargs = self._validate_kwargs() self.color_format = "RGB" - def _validate_kwargs(self) -> T.Dict[str, T.Union[int, float, T.List[float]]]: + def _validate_kwargs(self) -> dict[str, int | float | list[float]]: """ Validate that config options are correct. If not reset to default """ valid = True threshold = [self.config["threshold_1"], @@ -164,7 +164,7 @@ class PNet(KSession): def __init__(self, model_path: str, allow_growth: bool, - exclude_gpus: T.Optional[T.List[int]], + exclude_gpus: list[int] | None, cpu_mode: bool, input_size: int, min_size: int, @@ -185,10 +185,10 @@ def __init__(self, self._pnet_scales = self._calculate_scales(min_size, factor) self._pnet_sizes = [(int(input_size * scale), int(input_size * scale)) for scale in self._pnet_scales] - self._pnet_input: T.Optional[T.List[np.ndarray]] = None + self._pnet_input: list[np.ndarray] | None = None @staticmethod - def model_definition() -> T.Tuple[T.List[Tensor], T.List[Tensor]]: + def model_definition() -> tuple[list[Tensor], list[Tensor]]: """ Keras P-Network Definition for MTCNN """ input_ = Input(shape=(None, None, 3)) var_x = Conv2D(10, (3, 3), strides=1, padding='valid', name='conv1')(input_) @@ -204,7 +204,7 @@ def model_definition() -> T.Tuple[T.List[Tensor], T.List[Tensor]]: def _calculate_scales(self, minsize: int, - factor: float) -> T.List[float]: + factor: float) -> list[float]: """ Calculate multi-scale Parameters @@ -231,7 +231,7 @@ def _calculate_scales(self, logger.trace(scales) # type:ignore return scales - def __call__(self, images: np.ndarray) -> T.List[np.ndarray]: + def __call__(self, images: np.ndarray) -> list[np.ndarray]: """ first stage - fast proposal network (p-net) to obtain face candidates Parameters @@ -245,8 +245,8 @@ def __call__(self, images: np.ndarray) -> T.List[np.ndarray]: List of face candidates from P-Net """ batch_size = images.shape[0] - rectangles: T.List[T.List[T.List[T.Union[int, float]]]] = [[] for _ in range(batch_size)] - scores: T.List[T.List[np.ndarray]] = [[] for _ in range(batch_size)] + rectangles: list[list[list[int | float]]] = [[] for _ in range(batch_size)] + scores: list[list[np.ndarray]] = [[] for _ in range(batch_size)] if self._pnet_input is None: self._pnet_input = [np.empty((batch_size, rheight, rwidth, 3), dtype="float32") @@ -278,7 +278,7 @@ def _detect_face_12net(self, class_probabilities: np.ndarray, roi: np.ndarray, size: int, - scale: float) -> T.Tuple[np.ndarray, np.ndarray]: + scale: float) -> tuple[np.ndarray, np.ndarray]: """ Detect face position and calibrate bounding box on 12net feature map(matrix version) Parameters @@ -344,7 +344,7 @@ class RNet(KSession): def __init__(self, model_path: str, allow_growth: bool, - exclude_gpus: T.Optional[T.List[int]], + exclude_gpus: list[int] | None, cpu_mode: bool, input_size: int, threshold: float) -> None: @@ -360,7 +360,7 @@ def __init__(self, self._threshold = threshold @staticmethod - def model_definition() -> T.Tuple[T.List[Tensor], T.List[Tensor]]: + def model_definition() -> tuple[list[Tensor], list[Tensor]]: """ Keras R-Network Definition for MTCNN """ input_ = Input(shape=(24, 24, 3)) var_x = Conv2D(28, (3, 3), strides=1, padding='valid', name='conv1')(input_) @@ -383,8 +383,8 @@ def model_definition() -> T.Tuple[T.List[Tensor], T.List[Tensor]]: def __call__(self, images: np.ndarray, - rectangle_batch: T.List[np.ndarray], - ) -> T.List[np.ndarray]: + rectangle_batch: list[np.ndarray], + ) -> list[np.ndarray]: """ second stage - refinement of face candidates with r-net Parameters @@ -399,7 +399,7 @@ def __call__(self, List List of :class:`numpy.ndarray` refined face candidates from R-Net """ - ret: T.List[np.ndarray] = [] + ret: list[np.ndarray] = [] for idx, (rectangles, image) in enumerate(zip(rectangle_batch, images)): if not np.any(rectangles): ret.append(np.array([])) @@ -474,7 +474,7 @@ class ONet(KSession): def __init__(self, model_path: str, allow_growth: bool, - exclude_gpus: T.Optional[T.List[int]], + exclude_gpus: list[int] | None, cpu_mode: bool, input_size: int, threshold: float) -> None: @@ -490,7 +490,7 @@ def __init__(self, self._threshold = threshold @staticmethod - def model_definition() -> T.Tuple[T.List[Tensor], T.List[Tensor]]: + def model_definition() -> tuple[list[Tensor], list[Tensor]]: """ Keras O-Network for MTCNN """ input_ = Input(shape=(48, 48, 3)) var_x = Conv2D(32, (3, 3), strides=1, padding='valid', name='conv1')(input_) @@ -516,8 +516,8 @@ def model_definition() -> T.Tuple[T.List[Tensor], T.List[Tensor]]: def __call__(self, images: np.ndarray, - rectangle_batch: T.List[np.ndarray] - ) -> T.List[T.Tuple[np.ndarray, np.ndarray]]: + rectangle_batch: list[np.ndarray] + ) -> list[tuple[np.ndarray, np.ndarray]]: """ Third stage - further refinement and facial landmarks positions with o-net Parameters @@ -532,7 +532,7 @@ def __call__(self, List List of refined final candidates, scores and landmark points from O-Net """ - ret: T.List[T.Tuple[np.ndarray, np.ndarray]] = [] + ret: list[tuple[np.ndarray, np.ndarray]] = [] for idx, rectangles in enumerate(rectangle_batch): if not np.any(rectangles): ret.append((np.empty((0, 5)), np.empty(0))) @@ -552,7 +552,7 @@ def __call__(self, def _filter_face_48net(self, class_probabilities: np.ndarray, roi: np.ndarray, points: np.ndarray, - rectangles: np.ndarray) -> T.Tuple[np.ndarray, np.ndarray]: + rectangles: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """ Filter face position and calibrate bounding box on 12net's output Parameters @@ -623,13 +623,13 @@ class MTCNN(): # pylint: disable=too-few-public-methods Default: `0.709` """ def __init__(self, - model_path: T.List[str], + model_path: list[str], allow_growth: bool, - exclude_gpus: T.Optional[T.List[int]], + exclude_gpus: list[int] | None, cpu_mode: bool, input_size: int = 640, minsize: int = 20, - threshold: T.Optional[T.List[float]] = None, + threshold: list[float] | None = None, factor: float = 0.709) -> None: logger.debug("Initializing: %s: (model_path: '%s', allow_growth: %s, exclude_gpus: %s, " "input_size: %s, minsize: %s, threshold: %s, factor: %s)", @@ -660,7 +660,7 @@ def __init__(self, logger.debug("Initialized: %s", self.__class__.__name__) - def detect_faces(self, batch: np.ndarray) -> T.Tuple[np.ndarray, T.Tuple[np.ndarray]]: + def detect_faces(self, batch: np.ndarray) -> tuple[np.ndarray, tuple[np.ndarray]]: """Detects faces in an image, and returns bounding boxes and points for them. Parameters @@ -684,7 +684,7 @@ def detect_faces(self, batch: np.ndarray) -> T.Tuple[np.ndarray, T.Tuple[np.ndar def nms(rectangles: np.ndarray, scores: np.ndarray, threshold: float, - method: str = "iom") -> T.Tuple[np.ndarray, np.ndarray]: + method: str = "iom") -> tuple[np.ndarray, np.ndarray]: """ apply non-maximum suppression on ROIs in same scale(matrix version) Parameters diff --git a/plugins/extract/detect/s3fd.py b/plugins/extract/detect/s3fd.py index 853eeed5bf..89d538b76f 100644 --- a/plugins/extract/detect/s3fd.py +++ b/plugins/extract/detect/s3fd.py @@ -125,10 +125,10 @@ def get_config(self) -> dict: class SliceO2K(keras.layers.Layer): """ Custom Keras Slice layer generated by onnx2keras. """ def __init__(self, - starts: T.List[int], - ends: T.List[int], - axes: T.Optional[T.List[int]] = None, - steps: T.Optional[T.List[int]] = None, + starts: list[int], + ends: list[int], + axes: list[int] | None = None, + steps: list[int] | None = None, **kwargs) -> None: self._starts = starts self._ends = ends @@ -136,7 +136,7 @@ def __init__(self, self._steps = steps super().__init__(**kwargs) - def _get_slices(self, dimensions: int) -> T.List[T.Tuple[int, ...]]: + def _get_slices(self, dimensions: int) -> list[tuple[int, ...]]: """ Obtain slices for the given number of dimensions. Parameters @@ -154,7 +154,7 @@ def _get_slices(self, dimensions: int) -> T.List[T.Tuple[int, ...]]: assert len(axes) == len(steps) == len(self._starts) == len(self._ends) return list(zip(axes, self._starts, self._ends, steps)) - def compute_output_shape(self, input_shape: T.Tuple[int, ...]) -> T.Tuple[int, ...]: + def compute_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]: """Computes the output shape of the layer. Assumes that the layer will be built to match that input shape provided. @@ -230,7 +230,7 @@ def __init__(self, model_path: str, model_kwargs: dict, allow_growth: bool, - exclude_gpus: T.Optional[T.List[int]], + exclude_gpus: list[int] | None, confidence: float) -> None: logger.debug("Initializing: %s: (model_path: '%s', model_kwargs: %s, allow_growth: %s, " "exclude_gpus: %s, confidence: %s)", self.__class__.__name__, model_path, @@ -246,7 +246,7 @@ def __init__(self, self.average_img = np.array([104.0, 117.0, 123.0]) logger.debug("Initialized: %s", self.__class__.__name__) - def model_definition(self) -> T.Tuple[T.List[Tensor], T.List[Tensor]]: + def model_definition(self) -> tuple[list[Tensor], list[Tensor]]: """ Keras S3FD Model Definition, adapted from FAN pytorch implementation. """ input_ = Input(shape=(640, 640, 3)) var_x = self.conv_block(input_, 64, 1, 2) @@ -396,7 +396,7 @@ def prepare_batch(self, batch: np.ndarray) -> np.ndarray: batch = batch - self.average_img return batch - def finalize_predictions(self, bounding_boxes_scales: T.List[np.ndarray]) -> np.ndarray: + def finalize_predictions(self, bounding_boxes_scales: list[np.ndarray]) -> np.ndarray: """ Process the output from the model to obtain faces Parameters @@ -413,7 +413,7 @@ def finalize_predictions(self, bounding_boxes_scales: T.List[np.ndarray]) -> np. ret.append(finallist) return np.array(ret, dtype="object") - def _post_process(self, bboxlist: T.List[np.ndarray]) -> np.ndarray: + def _post_process(self, bboxlist: list[np.ndarray]) -> np.ndarray: """ Perform post processing on output TODO: do this on the batch. """ diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 34cd0c0651..837b6812e7 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -12,9 +12,11 @@ >>> {"filename": , >>> "detected_faces": } """ +from __future__ import annotations import logging +import typing as T + from dataclasses import dataclass, field -from typing import Generator, List, Optional, Tuple, TYPE_CHECKING import cv2 import numpy as np @@ -25,7 +27,8 @@ from lib.utils import FaceswapError from plugins.extract._base import BatchType, Extractor, ExtractorBatch, ExtractMedia -if TYPE_CHECKING: +if T.TYPE_CHECKING: + from collections.abc import Generator from queue import Queue from lib.align import DetectedFace from lib.align.aligned_face import CenteringType @@ -44,9 +47,9 @@ class MaskerBatch(ExtractorBatch): roi_masks: list The region of interest masks for the batch """ - detected_faces: List["DetectedFace"] = field(default_factory=list) - roi_masks: List[np.ndarray] = field(default_factory=list) - feed_faces: List[AlignedFace] = field(default_factory=list) + detected_faces: list[DetectedFace] = field(default_factory=list) + roi_masks: list[np.ndarray] = field(default_factory=list) + feed_faces: list[AlignedFace] = field(default_factory=list) class Masker(Extractor): # pylint:disable=abstract-method @@ -77,9 +80,9 @@ class Masker(Extractor): # pylint:disable=abstract-method """ def __init__(self, - git_model_id: Optional[int] = None, - model_filename: Optional[str] = None, - configfile: Optional[str] = None, + git_model_id: int | None = None, + model_filename: str | None = None, + configfile: str | None = None, instance: int = 0, **kwargs) -> None: logger.debug("Initializing %s: (configfile: %s)", self.__class__.__name__, configfile) @@ -93,11 +96,11 @@ def __init__(self, self._plugin_type = "mask" self._storage_name = self.__module__.rsplit(".", maxsplit=1)[-1].replace("_", "-") - self._storage_centering: "CenteringType" = "face" # Centering to store the mask at + self._storage_centering: CenteringType = "face" # Centering to store the mask at self._storage_size = 128 # Size to store masks at. Leave this at default logger.debug("Initialized %s", self.__class__.__name__) - def get_batch(self, queue: "Queue") -> Tuple[bool, MaskerBatch]: + def get_batch(self, queue: Queue) -> tuple[bool, MaskerBatch]: """ Get items for inputting into the masker from the queue in batches Items are returned from the ``queue`` in batches of diff --git a/plugins/extract/mask/bisenet_fp.py b/plugins/extract/mask/bisenet_fp.py index 79781de35b..cf8a177fe6 100644 --- a/plugins/extract/mask/bisenet_fp.py +++ b/plugins/extract/mask/bisenet_fp.py @@ -49,7 +49,7 @@ def __init__(self, **kwargs) -> None: # Separate storage for face and head masks self._storage_name = f"{self._storage_name}_{self._storage_centering}" - def _check_weights_selection(self, configfile: T.Optional[str]) -> T.Tuple[bool, int]: + def _check_weights_selection(self, configfile: str | None) -> tuple[bool, int]: """ Check which weights have been selected. This is required for passing along the correct file name for the corresponding weights @@ -73,7 +73,7 @@ def _check_weights_selection(self, configfile: T.Optional[str]) -> T.Tuple[bool, version = 1 if not is_faceswap else 2 if config.get("include_hair") else 3 return is_faceswap, version - def _get_segment_indices(self) -> T.List[int]: + def _get_segment_indices(self) -> list[int]: """ Obtain the segment indices to include within the face mask area based on user configuration settings. @@ -163,7 +163,7 @@ def process_output(self, batch: BatchType) -> None: # SOFTWARE. -_NAME_TRACKER: T.Set[str] = set() +_NAME_TRACKER: set[str] = set() def _get_name(name: str, start_idx: int = 1) -> str: @@ -554,7 +554,7 @@ class BiSeNet(KSession): def __init__(self, model_path: str, allow_growth: bool, - exclude_gpus: T.Optional[T.List[int]], + exclude_gpus: list[int] | None, input_size: int, num_classes: int, cpu_mode: bool) -> None: @@ -569,7 +569,7 @@ def __init__(self, self.define_model(self._model_definition) self.load_model_weights() - def _model_definition(self) -> T.Tuple[Tensor, T.List[Tensor]]: + def _model_definition(self) -> tuple[Tensor, list[Tensor]]: """ Definition of the VGG Obstructed Model. Returns diff --git a/plugins/extract/mask/components.py b/plugins/extract/mask/components.py index 0dc35e5e24..6ba0b540d7 100644 --- a/plugins/extract/mask/components.py +++ b/plugins/extract/mask/components.py @@ -1,14 +1,15 @@ #!/usr/bin/env python3 """ Components Mask for faceswap.py """ +from __future__ import annotations import logging -from typing import List, Tuple, TYPE_CHECKING +import typing as T import cv2 import numpy as np from ._base import BatchType, Masker -if TYPE_CHECKING: +if T.TYPE_CHECKING: from lib.align.aligned_face import AlignedFace logger = logging.getLogger(__name__) @@ -36,7 +37,7 @@ def process_input(self, batch: BatchType) -> None: def predict(self, feed: np.ndarray) -> np.ndarray: """ Run model to get predictions """ - faces: List["AlignedFace"] = feed[1] + faces: list[AlignedFace] = feed[1] feed = feed[0] for mask, face in zip(feed, faces): parts = self.parse_parts(np.array(face.landmarks)) @@ -51,7 +52,7 @@ def process_output(self, batch: BatchType) -> None: return @staticmethod - def parse_parts(landmarks: np.ndarray) -> List[Tuple[np.ndarray, ...]]: + def parse_parts(landmarks: np.ndarray) -> list[tuple[np.ndarray, ...]]: """ Component face hull mask """ r_jaw = (landmarks[0:9], landmarks[17:18]) l_jaw = (landmarks[8:17], landmarks[26:27]) diff --git a/plugins/extract/mask/extended.py b/plugins/extract/mask/extended.py index fa253ba15f..0755e794f8 100644 --- a/plugins/extract/mask/extended.py +++ b/plugins/extract/mask/extended.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """ Extended Mask for faceswap.py """ +from __future__ import annotations import logging -from typing import List, Tuple, TYPE_CHECKING +import typing as T import cv2 import numpy as np @@ -9,7 +10,7 @@ logger = logging.getLogger(__name__) -if TYPE_CHECKING: +if T.TYPE_CHECKING: from lib.align.aligned_face import AlignedFace @@ -35,7 +36,7 @@ def process_input(self, batch: BatchType) -> None: def predict(self, feed: np.ndarray) -> np.ndarray: """ Run model to get predictions """ - faces: List["AlignedFace"] = feed[1] + faces: list[AlignedFace] = feed[1] feed = feed[0] for mask, face in zip(feed, faces): parts = self.parse_parts(np.array(face.landmarks)) @@ -78,7 +79,7 @@ def _adjust_mask_top(cls, landmarks: np.ndarray) -> None: landmarks[17:22] = top_l + ((top_l - bot_l) // 2) landmarks[22:27] = top_r + ((top_r - bot_r) // 2) - def parse_parts(self, landmarks: np.ndarray) -> List[Tuple[np.ndarray, ...]]: + def parse_parts(self, landmarks: np.ndarray) -> list[tuple[np.ndarray, ...]]: """ Extended face hull mask """ self._adjust_mask_top(landmarks) diff --git a/plugins/extract/mask/unet_dfl.py b/plugins/extract/mask/unet_dfl.py index 930b074cec..4ca2f3dc07 100644 --- a/plugins/extract/mask/unet_dfl.py +++ b/plugins/extract/mask/unet_dfl.py @@ -13,7 +13,7 @@ https://github.com/iperov/DeepFaceLab/blob/master/nnlib/FANSeg_256_full_face.h5 """ import logging -from typing import cast +import typing as T import numpy as np from lib.model.session import KSession @@ -52,7 +52,7 @@ def init_model(self) -> None: def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction """ assert isinstance(batch, MaskerBatch) - batch.feed = np.array([cast(np.ndarray, feed.face)[..., :3] + batch.feed = np.array([T.cast(np.ndarray, feed.face)[..., :3] for feed in batch.feed_faces], dtype="float32") / 255.0 logger.trace("feed shape: %s", batch.feed.shape) # type: ignore diff --git a/plugins/extract/mask/vgg_clear.py b/plugins/extract/mask/vgg_clear.py index 9ab009e1a3..50165f8015 100644 --- a/plugins/extract/mask/vgg_clear.py +++ b/plugins/extract/mask/vgg_clear.py @@ -94,7 +94,7 @@ class VGGClear(KSession): def __init__(self, model_path: str, allow_growth: bool, - exclude_gpus: T.Optional[T.List[int]]): + exclude_gpus: list[int] | None): super().__init__("VGG Obstructed", model_path, allow_growth=allow_growth, @@ -103,7 +103,7 @@ def __init__(self, self.load_model_weights() @classmethod - def _model_definition(cls) -> T.Tuple[Tensor, Tensor]: + def _model_definition(cls) -> tuple[Tensor, Tensor]: """ Definition of the VGG Obstructed Model. Returns @@ -210,7 +210,7 @@ class _ScorePool(): # pylint:disable=too-few-public-methods crop: tuple The amount of 2D cropping to apply. Tuple of `ints` """ - def __init__(self, level: int, scale: float, crop: T.Tuple[int, int]): + def __init__(self, level: int, scale: float, crop: tuple[int, int]): self._name = f"_pool{level}" self._cropping = (crop, crop) self._scale = scale diff --git a/plugins/extract/mask/vgg_obstructed.py b/plugins/extract/mask/vgg_obstructed.py index e7a5fa80c4..a3f543d7e8 100644 --- a/plugins/extract/mask/vgg_obstructed.py +++ b/plugins/extract/mask/vgg_obstructed.py @@ -90,7 +90,7 @@ class VGGObstructed(KSession): def __init__(self, model_path: str, allow_growth: bool, - exclude_gpus: T.Optional[T.List[int]]) -> None: + exclude_gpus: list[int] | None) -> None: super().__init__("VGG Obstructed", model_path, allow_growth=allow_growth, @@ -99,7 +99,7 @@ def __init__(self, self.load_model_weights() @classmethod - def _model_definition(cls) -> T.Tuple[Tensor, Tensor]: + def _model_definition(cls) -> tuple[Tensor, Tensor]: """ Definition of the VGG Obstructed Model. Returns diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 70b3722921..80f598acc9 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -8,10 +8,9 @@ This module sets up a pipeline for the extraction workflow, loading detect, align and mask plugins either in parallel or in series, giving easy access to input and output. """ - +from __future__ import annotations import logging -import sys -from typing import cast, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union +import typing as T import cv2 @@ -20,13 +19,9 @@ from lib.utils import get_backend from plugins.plugin_loader import PluginLoader -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - -if TYPE_CHECKING: +if T.TYPE_CHECKING: import numpy as np + from collections.abc import Generator from lib.align.alignments import PNGHeaderSourceDict from lib.align.detected_face import DetectedFace from plugins.extract._base import Extractor as PluginExtractor @@ -102,16 +97,16 @@ class Extractor(): :attr:`final_pass` to indicate to the caller which phase is being processed """ def __init__(self, - detector: Optional[str], - aligner: Optional[str], - masker: Optional[Union[str, List[str]]], - recognition: Optional[str] = None, - configfile: Optional[str] = None, + detector: str | None, + aligner: str | None, + masker: str | list[str] | None, + recognition: str | None = None, + configfile: str | None = None, multiprocess: bool = False, - exclude_gpus: Optional[List[int]] = None, - rotate_images: Optional[str] = None, + exclude_gpus: list[int] | None = None, + rotate_images: str | None = None, min_size: int = 0, - normalize_method: Optional[Literal["none", "clahe", "hist", "mean"]] = None, + normalize_method: T.Literal["none", "clahe", "hist", "mean"] | None = None, re_feed: int = 0, re_align: bool = False, disable_filter: bool = False) -> None: @@ -122,8 +117,9 @@ def __init__(self, recognition, configfile, multiprocess, exclude_gpus, rotate_images, min_size, normalize_method, re_feed, re_align, disable_filter) self._instance = _get_instance() - maskers = [cast(Optional[str], - masker)] if not isinstance(masker, list) else cast(List[Optional[str]], masker) + maskers = [T.cast(str | None, + masker)] if not isinstance(masker, list) else T.cast(list[str | None], + masker) self._flow = self._set_flow(detector, aligner, maskers, recognition) self._exclude_gpus = exclude_gpus # We only ever need 1 item in each queue. This is 2 items cached (1 in queue 1 waiting @@ -220,13 +216,13 @@ def final_pass(self) -> bool: return retval @property - def aligner(self) -> "Aligner": + def aligner(self) -> Aligner: """ The currently selected aligner plugin """ assert self._align is not None return self._align @property - def recognition(self) -> "Identity": + def recognition(self) -> Identity: """ The currently selected recognition plugin """ assert self._recognition is not None return self._recognition @@ -237,7 +233,7 @@ def reset_phase_index(self) -> None: self._phase_index = 0 def set_batchsize(self, - plugin_type: Literal["align", "detect"], + plugin_type: T.Literal["align", "detect"], batchsize: int) -> None: """ Set the batch size of a given :attr:`plugin_type` to the given :attr:`batchsize`. @@ -311,7 +307,7 @@ def detected_faces(self) -> Generator["ExtractMedia", None, None]: # <<< INTERNAL METHODS >>> # @property - def _parallel_scaling(self) -> Dict[int, float]: + def _parallel_scaling(self) -> dict[int, float]: """ dict: key is number of parallel plugins being loaded, value is the scaling factor that the total base vram for those plugins should be scaled by @@ -335,7 +331,7 @@ def _parallel_scaling(self) -> Dict[int, float]: return retval @property - def _vram_per_phase(self) -> Dict[str, float]: + def _vram_per_phase(self) -> dict[str, float]: """ dict: The amount of vram required for each phase in :attr:`_flow`. """ retval = {} for phase in self._flow: @@ -359,7 +355,7 @@ def _total_vram_required(self) -> float: return retval @property - def _current_phase(self) -> List[str]: + def _current_phase(self) -> list[str]: """ list: The current phase from :attr:`_phases` that is running through the extractor. """ retval = self._phases[self._phase_index] logger.trace(retval) # type: ignore @@ -384,7 +380,7 @@ def _output_queue(self) -> EventQueue: return retval @property - def _all_plugins(self) -> List["PluginExtractor"]: + def _all_plugins(self) -> list[PluginExtractor]: """ Return list of all plugin objects in this pipeline """ retval = [] for phase in self._flow: @@ -396,7 +392,7 @@ def _all_plugins(self) -> List["PluginExtractor"]: return retval @property - def _active_plugins(self) -> List["PluginExtractor"]: + def _active_plugins(self) -> list[PluginExtractor]: """ Return the plugins that are currently active based on pass """ retval = [] for phase in self._current_phase: @@ -407,10 +403,10 @@ def _active_plugins(self) -> List["PluginExtractor"]: return retval @staticmethod - def _set_flow(detector: Optional[str], - aligner: Optional[str], - masker: List[Optional[str]], - recognition: Optional[str]) -> List[str]: + def _set_flow(detector: str | None, + aligner: str | None, + masker: list[str | None], + recognition: str | None) -> list[str]: """ Set the flow list based on the input plugins Parameters @@ -441,7 +437,7 @@ def _set_flow(detector: Optional[str], return retval @staticmethod - def _get_plugin_type_and_index(flow_phase: str) -> Tuple[str, Optional[int]]: + def _get_plugin_type_and_index(flow_phase: str) -> tuple[str, int | None]: """ Obtain the plugin type and index for the plugin for the given flow phase. When multiple plugins for the same phase are allowed (e.g. Mask) this will return @@ -463,14 +459,14 @@ def _get_plugin_type_and_index(flow_phase: str) -> Tuple[str, Optional[int]]: """ sidx = flow_phase.split("_")[-1] if sidx.isdigit(): - idx: Optional[int] = int(sidx) + idx: int | None = int(sidx) plugin_type = "_".join(flow_phase.split("_")[:-1]) else: plugin_type = flow_phase idx = None return plugin_type, idx - def _add_queues(self) -> Dict[str, EventQueue]: + def _add_queues(self) -> dict[str, EventQueue]: """ Add the required processing queues to Queue Manager """ queues = {} tasks = [f"extract{self._instance}_{phase}_in" for phase in self._flow] @@ -483,7 +479,7 @@ def _add_queues(self) -> Dict[str, EventQueue]: return queues @staticmethod - def _get_vram_stats() -> Dict[str, Union[int, str]]: + def _get_vram_stats() -> dict[str, int | str]: """ Obtain statistics on available VRAM and subtract a constant buffer from available vram. Returns @@ -494,10 +490,10 @@ def _get_vram_stats() -> Dict[str, Union[int, str]]: vram_buffer = 256 # Leave a buffer for VRAM allocation gpu_stats = GPUStats() stats = gpu_stats.get_card_most_free() - retval: Dict[str, Union[int, str]] = {"count": gpu_stats.device_count, - "device": stats.device, - "vram_free": int(stats.free - vram_buffer), - "vram_total": int(stats.total)} + retval: dict[str, int | str] = {"count": gpu_stats.device_count, + "device": stats.device, + "vram_free": int(stats.free - vram_buffer), + "vram_total": int(stats.total)} logger.debug(retval) return retval @@ -521,13 +517,13 @@ def _set_parallel_processing(self, multiprocess: bool) -> bool: self._vram_stats["device"], self._vram_stats["vram_free"], self._vram_stats["vram_total"]) - if cast(int, self._vram_stats["vram_free"]) <= self._total_vram_required: + if T.cast(int, self._vram_stats["vram_free"]) <= self._total_vram_required: logger.warning("Not enough free VRAM for parallel processing. " "Switching to serial") return False return True - def _set_phases(self, multiprocess: bool) -> List[List[str]]: + def _set_phases(self, multiprocess: bool) -> list[list[str]]: """ If not enough VRAM is available, then chunk :attr:`_flow` up into phases that will fit into VRAM, otherwise return the single flow. @@ -541,9 +537,9 @@ def _set_phases(self, multiprocess: bool) -> List[List[str]]: list: The jobs to be undertaken split into phases that fit into GPU RAM """ - phases: List[List[str]] = [] - current_phase: List[str] = [] - available = cast(int, self._vram_stats["vram_free"]) + phases: list[list[str]] = [] + current_phase: list[str] = [] + available = T.cast(int, self._vram_stats["vram_free"]) for phase in self._flow: num_plugins = len([p for p in current_phase if self._vram_per_phase[p] > 0]) num_plugins += 1 if self._vram_per_phase[phase] > 0 else 0 @@ -576,12 +572,12 @@ def _set_phases(self, multiprocess: bool) -> List[List[str]]: # << INTERNAL PLUGIN HANDLING >> # def _load_align(self, - aligner: Optional[str], - configfile: Optional[str], - normalize_method: Optional[Literal["none", "clahe", "hist", "mean"]], + aligner: str | None, + configfile: str | None, + normalize_method: T.Literal["none", "clahe", "hist", "mean"] | None, re_feed: int, re_align: bool, - disable_filter: bool) -> Optional["Aligner"]: + disable_filter: bool) -> Aligner | None: """ Set global arguments and load aligner plugin Parameters @@ -619,10 +615,10 @@ def _load_align(self, return plugin def _load_detect(self, - detector: Optional[str], - rotation: Optional[str], + detector: str | None, + rotation: str | None, min_size: int, - configfile: Optional[str]) -> Optional["Detector"]: + configfile: str | None) -> Detector | None: """ Set global arguments and load detector plugin """ if detector is None or detector.lower() == "none": logger.debug("No detector selected. Returning None") @@ -637,8 +633,8 @@ def _load_detect(self, return plugin def _load_mask(self, - masker: Optional[str], - configfile: Optional[str]) -> Optional["Masker"]: + masker: str | None, + configfile: str | None) -> Masker | None: """ Set global arguments and load masker plugin Parameters @@ -664,8 +660,8 @@ def _load_mask(self, return plugin def _load_recognition(self, - recognition: Optional[str], - configfile: Optional[str]) -> Optional["Identity"]: + recognition: str | None, + configfile: str | None) -> Identity | None: """ Set global arguments and load recognition plugin """ if recognition is None or recognition.lower() == "none": logger.debug("No recognition selected. Returning None") @@ -716,16 +712,16 @@ def _set_extractor_batchsize(self) -> None: gpu_plugins = [p for p in self._current_phase if self._vram_per_phase[p] > 0] scaling = self._parallel_scaling.get(len(gpu_plugins), self._scaling_fallback) plugins_required = sum(self._vram_per_phase[p] for p in gpu_plugins) * scaling - if plugins_required + batch_required <= cast(int, self._vram_stats["vram_free"]): + if plugins_required + batch_required <= T.cast(int, self._vram_stats["vram_free"]): logger.debug("Plugin requirements within threshold: (plugins_required: %sMB, " "vram_free: %sMB)", plugins_required, self._vram_stats["vram_free"]) return # Hacky split across plugins that use vram - available_vram = (cast(int, self._vram_stats["vram_free"]) + available_vram = (T.cast(int, self._vram_stats["vram_free"]) - plugins_required) // len(gpu_plugins) self._set_plugin_batchsize(gpu_plugins, available_vram) - def _set_plugin_batchsize(self, gpu_plugins: List[str], available_vram: float) -> None: + def _set_plugin_batchsize(self, gpu_plugins: list[str], available_vram: float) -> None: """ Set the batch size for the given plugin based on given available vram. Do not update plugins which have a vram_per_batch of 0 (CPU plugins) due to zero division error. @@ -802,20 +798,20 @@ class ExtractMedia(): def __init__(self, filename: str, - image: "np.ndarray", - detected_faces: Optional[List["DetectedFace"]] = None, + image: np.ndarray, + detected_faces: list[DetectedFace] | None = None, is_aligned: bool = False) -> None: logger.trace("Initializing %s: (filename: '%s', image shape: %s, " # type: ignore "detected_faces: %s, is_aligned: %s)", self.__class__.__name__, filename, image.shape, detected_faces, is_aligned) self._filename = filename - self._image: Optional["np.ndarray"] = image - self._image_shape = cast(Tuple[int, int, int], image.shape) - self._detected_faces: List["DetectedFace"] = ([] if detected_faces is None - else detected_faces) + self._image: np.ndarray | None = image + self._image_shape = T.cast(tuple[int, int, int], image.shape) + self._detected_faces: list[DetectedFace] = ([] if detected_faces is None + else detected_faces) self._is_aligned = is_aligned - self._frame_metadata: Optional["PNGHeaderSourceDict"] = None - self._sub_folders: List[Optional[str]] = [] + self._frame_metadata: PNGHeaderSourceDict | None = None + self._sub_folders: list[str | None] = [] @property def filename(self) -> str: @@ -823,23 +819,23 @@ def filename(self) -> str: return self._filename @property - def image(self) -> "np.ndarray": + def image(self) -> np.ndarray: """ :class:`numpy.ndarray`: The source frame for this object. """ assert self._image is not None return self._image @property - def image_shape(self) -> Tuple[int, int, int]: + def image_shape(self) -> tuple[int, int, int]: """ tuple: The shape of the stored :attr:`image`. """ return self._image_shape @property - def image_size(self) -> Tuple[int, int]: + def image_size(self) -> tuple[int, int]: """ tuple: The (`height`, `width`) of the stored :attr:`image`. """ return self._image_shape[:2] @property - def detected_faces(self) -> List["DetectedFace"]: + def detected_faces(self) -> list[DetectedFace]: """list: A list of :class:`~lib.align.DetectedFace` objects in the :attr:`image`. """ return self._detected_faces @@ -849,7 +845,7 @@ def is_aligned(self) -> bool: return self._is_aligned @property - def frame_metadata(self) -> "PNGHeaderSourceDict": + def frame_metadata(self) -> PNGHeaderSourceDict: """ dict: The frame metadata that has been added from an aligned image. This property should only be called after :func:`add_frame_metadata` has been called when processing an aligned face. For all other instances an assertion error will be raised. @@ -863,13 +859,13 @@ def frame_metadata(self) -> "PNGHeaderSourceDict": return self._frame_metadata @property - def sub_folders(self) -> List[Optional[str]]: + def sub_folders(self) -> list[str | None]: """ list: The sub_folders that the faces should be output to. Used when binning filter output is enabled. The list corresponds to the list of detected faces """ return self._sub_folders - def get_image_copy(self, color_format: Literal["BGR", "RGB", "GRAY"]) -> "np.ndarray": + def get_image_copy(self, color_format: T.Literal["BGR", "RGB", "GRAY"]) -> np.ndarray: """ Get a copy of the image in the requested color format. Parameters @@ -887,7 +883,7 @@ def get_image_copy(self, color_format: Literal["BGR", "RGB", "GRAY"]) -> "np.nda image = getattr(self, f"_image_as_{color_format.lower()}")() return image - def add_detected_faces(self, faces: List["DetectedFace"]) -> None: + def add_detected_faces(self, faces: list[DetectedFace]) -> None: """ Add detected faces to the object. Called at the end of each extraction phase. Parameters @@ -900,7 +896,7 @@ def add_detected_faces(self, faces: List["DetectedFace"]) -> None: [(face.left, face.right, face.top, face.bottom) for face in faces]) self._detected_faces = faces - def add_sub_folders(self, folders: List[Optional[str]]) -> None: + def add_sub_folders(self, folders: list[str | None]) -> None: """ Add detected faces to the object. Called at the end of each extraction phase. Parameters @@ -922,7 +918,7 @@ def remove_image(self) -> None: del self._image self._image = None - def set_image(self, image: "np.ndarray") -> None: + def set_image(self, image: np.ndarray) -> None: """ Add the image back into :attr:`image` Required for multi-phase extraction adds the image back to this object. @@ -936,7 +932,7 @@ def set_image(self, image: "np.ndarray") -> None: self._filename, image.shape) self._image = image - def add_frame_metadata(self, metadata: "PNGHeaderSourceDict") -> None: + def add_frame_metadata(self, metadata: PNGHeaderSourceDict) -> None: """ Add the source frame metadata from an aligned PNG's header data. metadata: dict @@ -944,11 +940,11 @@ def add_frame_metadata(self, metadata: "PNGHeaderSourceDict") -> None: """ logger.trace("Adding PNG Source data for '%s': %s", # type:ignore self._filename, metadata) - dims = cast(Tuple[int, int], metadata["source_frame_dims"]) + dims = T.cast(tuple[int, int], metadata["source_frame_dims"]) self._image_shape = (*dims, 3) self._frame_metadata = metadata - def _image_as_bgr(self) -> "np.ndarray": + def _image_as_bgr(self) -> np.ndarray: """ Get a copy of the source frame in BGR format. Returns @@ -957,7 +953,7 @@ def _image_as_bgr(self) -> "np.ndarray": A copy of :attr:`image` in BGR color format """ return self.image[..., :3].copy() - def _image_as_rgb(self) -> "np.ndarray": + def _image_as_rgb(self) -> np.ndarray: """ Get a copy of the source frame in RGB format. Returns @@ -966,7 +962,7 @@ def _image_as_rgb(self) -> "np.ndarray": A copy of :attr:`image` in RGB color format """ return self.image[..., 2::-1].copy() - def _image_as_gray(self) -> "np.ndarray": + def _image_as_gray(self) -> np.ndarray: """ Get a copy of the source frame in gray-scale format. Returns diff --git a/plugins/extract/recognition/_base.py b/plugins/extract/recognition/_base.py index bf5a7372bc..3630607b98 100644 --- a/plugins/extract/recognition/_base.py +++ b/plugins/extract/recognition/_base.py @@ -17,7 +17,6 @@ """ from __future__ import annotations import logging -import sys import typing as T from dataclasses import dataclass, field @@ -31,13 +30,8 @@ from plugins.extract._base import BatchType, Extractor, ExtractorBatch from plugins.extract.pipeline import ExtractMedia -if sys.version_info < (3, 8): - from typing_extensions import get_args, Literal -else: - from typing import get_args, Literal - - if T.TYPE_CHECKING: + from collections.abc import Generator from queue import Queue from lib.align.aligned_face import CenteringType @@ -50,8 +44,8 @@ class RecogBatch(ExtractorBatch): Inherits from :class:`~plugins.extract._base.ExtractorBatch` """ - detected_faces: T.List["DetectedFace"] = field(default_factory=list) - feed_faces: T.List[AlignedFace] = field(default_factory=list) + detected_faces: list[DetectedFace] = field(default_factory=list) + feed_faces: list[AlignedFace] = field(default_factory=list) class Identity(Extractor): # pylint:disable=abstract-method @@ -82,9 +76,9 @@ class Identity(Extractor): # pylint:disable=abstract-method """ def __init__(self, - git_model_id: T.Optional[int] = None, - model_filename: T.Optional[str] = None, - configfile: T.Optional[str] = None, + git_model_id: int | None = None, + model_filename: str | None = None, + configfile: str | None = None, instance: int = 0, **kwargs): logger.debug("Initializing %s", self.__class__.__name__) @@ -119,7 +113,7 @@ def _get_detected_from_aligned(self, item: ExtractMedia) -> None: logger.debug("Obtained detected face: (filename: %s, detected_face: %s)", item.filename, item.detected_faces) - def get_batch(self, queue: Queue) -> T.Tuple[bool, RecogBatch]: + def get_batch(self, queue: Queue) -> tuple[bool, RecogBatch]: """ Get items for inputting into the recognition from the queue in batches Items are returned from the ``queue`` in batches of @@ -226,7 +220,7 @@ def _predict(self, batch: BatchType) -> RecogBatch: "\n3) Enable 'Single Process' mode.") raise FaceswapError(msg) from err - def finalize(self, batch: BatchType) -> T.Generator[ExtractMedia, None, None]: + def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: """ Finalize the output from Masker This should be called as the final task of each `plugin`. @@ -301,8 +295,8 @@ class IdentityFilter(): def __init__(self, save_output: bool) -> None: logger.debug("Initializing %s: (save_output: %s)", self.__class__.__name__, save_output) self._save_output = save_output - self._filter: T.Optional[np.ndarray] = None - self._nfilter: T.Optional[np.ndarray] = None + self._filter: np.ndarray | None = None + self._nfilter: np.ndarray | None = None self._threshold = 0.0 self._filter_enabled: bool = False self._nfilter_enabled: bool = False @@ -357,7 +351,7 @@ def _find_cosine_similiarity(cls, return retval def _get_matches(self, - filter_type: Literal["filter", "nfilter"], + filter_type: T.Literal["filter", "nfilter"], identities: np.ndarray) -> np.ndarray: """ Obtain the average and minimum distances for each face against the source identities to test against @@ -386,9 +380,9 @@ def _get_matches(self, return retval def _filter_faces(self, - faces: T.List[DetectedFace], - sub_folders: T.List[T.Optional[str]], - should_filter: T.List[bool]) -> T.List[DetectedFace]: + faces: list[DetectedFace], + sub_folders: list[str | None], + should_filter: list[bool]) -> list[DetectedFace]: """ Filter the detected faces, either removing filtered faces from the list of detected faces or setting the output subfolder to `"_identity_filt"` for any filtered faces if saving output is enabled. @@ -410,7 +404,7 @@ def _filter_faces(self, The filtered list of detected face objects, if saving filtered faces has not been selected or the full list of detected faces """ - retval: T.List[DetectedFace] = [] + retval: list[DetectedFace] = [] self._counts += sum(should_filter) for idx, face in enumerate(faces): fldr = sub_folders[idx] @@ -429,8 +423,8 @@ def _filter_faces(self, return retval def __call__(self, - faces: T.List[DetectedFace], - sub_folders: T.List[T.Optional[str]]) -> T.List[DetectedFace]: + faces: list[DetectedFace], + sub_folders: list[str | None]) -> list[DetectedFace]: """ Call the identity filter function Parameters @@ -459,14 +453,14 @@ def __call__(self, logger.trace("All faces already filtered: %s", sub_folders) # type: ignore return faces - should_filter: T.List[np.ndarray] = [] - for f_type in get_args(Literal["filter", "nfilter"]): + should_filter: list[np.ndarray] = [] + for f_type in T.get_args(T.Literal["filter", "nfilter"]): if not getattr(self, f"_{f_type}_enabled"): continue should_filter.append(self._get_matches(f_type, identities)) # If any of the filter or nfilter evaluate to 'should filter' then filter out face - final_filter: T.List[bool] = np.array(should_filter).max(axis=0).tolist() + final_filter: list[bool] = np.array(should_filter).max(axis=0).tolist() logger.trace("should_filter: %s, final_filter: %s", # type: ignore should_filter, final_filter) return self._filter_faces(faces, sub_folders, final_filter) diff --git a/plugins/extract/recognition/vgg_face2.py b/plugins/extract/recognition/vgg_face2.py index f7c2642714..ae717c75a2 100644 --- a/plugins/extract/recognition/vgg_face2.py +++ b/plugins/extract/recognition/vgg_face2.py @@ -1,10 +1,9 @@ #!/usr/bin python3 """ VGG_Face2 inference and sorting """ +from __future__ import annotations import logging -import sys - -from typing import cast, Dict, Generator, List, Tuple, Optional +import typing as T import numpy as np import psutil @@ -15,11 +14,8 @@ from lib.utils import FaceswapError from ._base import BatchType, RecogBatch, Identity - -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal +if T.TYPE_CHECKING: + from collections.abc import Generator logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -64,7 +60,7 @@ def __init__(self, *args, **kwargs) -> None: # pylint:disable=unused-argument def init_model(self) -> None: """ Initialize VGG Face 2 Model. """ assert isinstance(self.model_path, str) - model_kwargs = dict(custom_objects={'L2_normalize': L2_normalize}) + model_kwargs = {"custom_objects": {"L2_normalize": L2_normalize}} self.model = KSession(self.name, self.model_path, model_kwargs=model_kwargs, @@ -76,7 +72,7 @@ def init_model(self) -> None: def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction """ assert isinstance(batch, RecogBatch) - batch.feed = np.array([cast(np.ndarray, feed.face)[..., :3] + batch.feed = np.array([T.cast(np.ndarray, feed.face)[..., :3] for feed in batch.feed_faces], dtype="float32") - self._average_img logger.trace("feed shape: %s", batch.feed.shape) # type:ignore @@ -121,15 +117,15 @@ class Cluster(): # pylint: disable=too-few-public-methods def __init__(self, predictions: np.ndarray, - method: Literal["single", "centroid", "median", "ward"], - threshold: Optional[float] = None) -> None: + method: T.Literal["single", "centroid", "median", "ward"], + threshold: float | None = None) -> None: logger.debug("Initializing: %s (predictions: %s, method: %s, threshold: %s)", self.__class__.__name__, predictions.shape, method, threshold) self._num_predictions = predictions.shape[0] self._should_output_bins = threshold is not None self._threshold = 0.0 if threshold is None else threshold - self._bins: Dict[int, int] = {} + self._bins: dict[int, int] = {} self._iterator = self._integer_iterator() self._result_linkage = self._do_linkage(predictions, method) @@ -192,7 +188,7 @@ def _use_vector_linkage(self, dims: int) -> bool: def _do_linkage(self, predictions: np.ndarray, - method: Literal["single", "centroid", "median", "ward"]) -> np.ndarray: + method: T.Literal["single", "centroid", "median", "ward"]) -> np.ndarray: """ Use FastCluster to perform vector or standard linkage Parameters @@ -218,7 +214,7 @@ def _do_linkage(self, def _process_leaf_node(self, current_index: int, - current_bin: int) -> List[Tuple[int, int]]: + current_bin: int) -> list[tuple[int, int]]: """ Process the output when we have hit a leaf node """ if not self._should_output_bins: return [(current_index, 0)] @@ -263,7 +259,7 @@ def _seriation(self, tree: np.ndarray, points: int, current_index: int, - current_bin: int = 0) -> List[Tuple[int, int]]: + current_bin: int = 0) -> list[tuple[int, int]]: """ Seriation method for sorted similarity. Seriation computes the order implied by a hierarchical tree (dendrogram). @@ -298,7 +294,7 @@ def _seriation(self, return serate_left + serate_right # type: ignore - def __call__(self) -> List[Tuple[int, int]]: + def __call__(self) -> list[tuple[int, int]]: """ Process the linkages. Transforms a distance matrix into a sorted distance matrix according to the order implied diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 6c60b3595d..30b9762177 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -1,13 +1,14 @@ #!/usr/bin/env python3 """ Plugin loader for Faceswap extract, training and convert tasks """ - +from __future__ import annotations import logging import os -import sys +import typing as T + from importlib import import_module -from typing import Callable, List, Type, TYPE_CHECKING -if TYPE_CHECKING: +if T.TYPE_CHECKING: + from collections.abc import Callable from plugins.extract.detect._base import Detector from plugins.extract.align._base import Aligner from plugins.extract.mask._base import Masker @@ -15,11 +16,6 @@ from plugins.train.model._base import ModelBase from plugins.train.trainer._base import TrainerBase -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -36,7 +32,7 @@ class PluginLoader(): >>> aligner = PluginLoader.get_aligner('cv2-dnn') """ @staticmethod - def get_detector(name: str, disable_logging: bool = False) -> Type["Detector"]: + def get_detector(name: str, disable_logging: bool = False) -> type[Detector]: """ Return requested detector plugin Parameters @@ -55,7 +51,7 @@ def get_detector(name: str, disable_logging: bool = False) -> Type["Detector"]: return PluginLoader._import("extract.detect", name, disable_logging) @staticmethod - def get_aligner(name: str, disable_logging: bool = False) -> Type["Aligner"]: + def get_aligner(name: str, disable_logging: bool = False) -> type[Aligner]: """ Return requested aligner plugin Parameters @@ -74,7 +70,7 @@ def get_aligner(name: str, disable_logging: bool = False) -> Type["Aligner"]: return PluginLoader._import("extract.align", name, disable_logging) @staticmethod - def get_masker(name: str, disable_logging: bool = False) -> Type["Masker"]: + def get_masker(name: str, disable_logging: bool = False) -> type[Masker]: """ Return requested masker plugin Parameters @@ -93,7 +89,7 @@ def get_masker(name: str, disable_logging: bool = False) -> Type["Masker"]: return PluginLoader._import("extract.mask", name, disable_logging) @staticmethod - def get_recognition(name: str, disable_logging: bool = False) -> Type["Identity"]: + def get_recognition(name: str, disable_logging: bool = False) -> type[Identity]: """ Return requested recognition plugin Parameters @@ -112,7 +108,7 @@ def get_recognition(name: str, disable_logging: bool = False) -> Type["Identity" return PluginLoader._import("extract.recognition", name, disable_logging) @staticmethod - def get_model(name: str, disable_logging: bool = False) -> Type["ModelBase"]: + def get_model(name: str, disable_logging: bool = False) -> type[ModelBase]: """ Return requested training model plugin Parameters @@ -131,7 +127,7 @@ def get_model(name: str, disable_logging: bool = False) -> Type["ModelBase"]: return PluginLoader._import("train.model", name, disable_logging) @staticmethod - def get_trainer(name: str, disable_logging: bool = False) -> Type["TrainerBase"]: + def get_trainer(name: str, disable_logging: bool = False) -> type[TrainerBase]: """ Return requested training trainer plugin Parameters @@ -198,9 +194,9 @@ def _import(attr: str, name: str, disable_logging: bool): return getattr(module, ttl) @staticmethod - def get_available_extractors(extractor_type: Literal["align", "detect", "mask"], + def get_available_extractors(extractor_type: T.Literal["align", "detect", "mask"], add_none: bool = False, - extend_plugin: bool = False) -> List[str]: + extend_plugin: bool = False) -> list[str]: """ Return a list of available extractors of the given type Parameters @@ -243,7 +239,7 @@ def get_available_extractors(extractor_type: Literal["align", "detect", "mask"], return extractors @staticmethod - def get_available_models() -> List[str]: + def get_available_models() -> list[str]: """ Return a list of available training models Returns @@ -273,7 +269,7 @@ def get_default_model() -> str: return 'original' if 'original' in models else models[0] @staticmethod - def get_available_convert_plugins(convert_category: str, add_none: bool = True) -> List[str]: + def get_available_convert_plugins(convert_category: str, add_none: bool = True) -> list[str]: """ Return a list of available converter plugins in the given category Parameters diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 0e8efe3766..dbfc0ffe1f 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -249,6 +249,31 @@ def _set_globals(self) -> None: "NB: The value given here is the 'exponent' to the epsilon. For example, " "choosing '-7' will set the epsilon to 1e-7. Choosing '-3' will set the epsilon " "to 0.001 (1e-3).")) + self.add_item( + section=section, + title="save_optimizer", + datatype=str, + group=_("optimizer"), + default="exit", + fixed=False, + gui_radio=True, + choices=["never", "always", "exit"], + info=_( + "When to save the Optimizer Weights. Saving the optimizer weights is not " + "necessary and will increase the model file size 3x (and by extension the amount " + "of time it takes to save the model). However, it can be useful to save these " + "weights if you want to guarantee that a resumed model carries off exactly from " + "where it left off, rather than spending a few hundred iterations catching up." + "\n\t never - Don't save optimizer weights." + "\n\t always - Save the optimizer weights at every save iteration. Model saving " + "will take longer, due to the increased file size, but you will always have the " + "last saved optimizer state in your model file." + "\n\t exit - Only save the optimizer weights when explicitly terminating a " + "model. This can be when the model is actively stopped or when the target " + "iterations are met. Note: If the training session ends because of another " + "reason (e.g. power outage, Out of Memory Error, NaN detected) then the " + "optimizer weights will NOT be saved.")) + self.add_item( section=section, title="autoclip", diff --git a/plugins/train/model/_base/io.py b/plugins/train/model/_base/io.py index d8d8515713..6bbd0c44ad 100644 --- a/plugins/train/model/_base/io.py +++ b/plugins/train/model/_base/io.py @@ -21,11 +21,6 @@ from lib.model.backup_restore import Backup from lib.utils import FaceswapError -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - if T.TYPE_CHECKING: from tensorflow import keras from .model import ModelBase @@ -35,7 +30,7 @@ def get_all_sub_models( model: keras.models.Model, - models: T.Optional[T.List[keras.models.Model]] = None) -> T.List[keras.models.Model]: + models: list[keras.models.Model] | None = None) -> list[keras.models.Model]: """ For a given model, return all sub-models that occur (recursively) as children. Parameters @@ -85,12 +80,12 @@ def __init__(self, plugin: ModelBase, model_dir: str, is_predict: bool, - save_optimizer: Literal["never", "always", "exit"]) -> None: + save_optimizer: T.Literal["never", "always", "exit"]) -> None: self._plugin = plugin self._is_predict = is_predict self._model_dir = model_dir self._save_optimizer = save_optimizer - self._history: T.List[T.List[float]] = [[], []] # Loss histories per save iteration + self._history: list[list[float]] = [[], []] # Loss histories per save iteration self._backup = Backup(self._model_dir, self._plugin.name) @property @@ -106,12 +101,12 @@ def model_exists(self) -> bool: return os.path.isfile(self._filename) @property - def history(self) -> T.List[T.List[float]]: + def history(self) -> list[list[float]]: """ list: list of loss histories per side for the current save iteration. """ return self._history @property - def multiple_models_in_folder(self) -> T.Optional[T.List[str]]: + def multiple_models_in_folder(self) -> list[str] | None: """ :list: or ``None`` If there are multiple model types in the requested folder, or model types that don't correspond to the requested plugin type, then returns the list of plugin names that exist in the folder, otherwise returns ``None`` """ @@ -210,7 +205,7 @@ def save(self, is_exit: bool = False, force_save_optimizer: bool = False) -> Non msg += f" - Average loss since last save: {', '.join(lossmsg)}" logger.info(msg) - def _get_save_averages(self) -> T.List[float]: + def _get_save_averages(self) -> list[float]: """ Return the average loss since the last save iteration and reset historical loss """ logger.debug("Getting save averages") if not all(loss for loss in self._history): @@ -222,7 +217,7 @@ def _get_save_averages(self) -> T.List[float]: logger.debug("Average losses since last save: %s", retval) return retval - def _should_backup(self, save_averages: T.List[float]) -> bool: + def _should_backup(self, save_averages: list[float]) -> bool: """ Check whether the loss averages for this save iteration is the lowest that has been seen. @@ -301,7 +296,7 @@ def __init__(self, plugin: ModelBase) -> None: logger.debug("Initialized %s", self.__class__.__name__) @classmethod - def _check_weights_file(cls, weights_file: str) -> T.Optional[str]: + def _check_weights_file(cls, weights_file: str) -> str | None: """ Validate that we have a valid path to a .h5 file. Parameters @@ -403,7 +398,7 @@ def load(self, model_exists: bool) -> None: "different settings than you have set for your current model.", skipped_ops) - def _get_weights_model(self) -> T.List[keras.models.Model]: + def _get_weights_model(self) -> list[keras.models.Model]: """ Obtain a list of all sub-models contained within the weights model. Returns @@ -429,7 +424,7 @@ def _get_weights_model(self) -> T.List[keras.models.Model]: def _load_layer_weights(self, layer: keras.layers.Layer, sub_weights: keras.layers.Layer, - model_name: str) -> Literal[-1, 0, 1]: + model_name: str) -> T.Literal[-1, 0, 1]: """ Load the weights for a single layer. Parameters diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 6eba5f332a..21f0cca65d 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -29,18 +29,12 @@ from .io import IO, get_all_sub_models, Weights from .settings import Loss, Optimizer, Settings - -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - if T.TYPE_CHECKING: import argparse from lib.config import ConfigValueType logger = logging.getLogger(__name__) # pylint: disable=invalid-name -_CONFIG: T.Dict[str, ConfigValueType] = {} +_CONFIG: dict[str, ConfigValueType] = {} class ModelBase(): @@ -79,13 +73,13 @@ def __init__(self, self.__class__.__name__, model_dir, arguments, predict) # Input shape must be set within the plugin after initializing - self.input_shape: T.Tuple[int, ...] = () + self.input_shape: tuple[int, ...] = () self.trainer = "original" # Override for plugin specific trainer - self.color_order: Literal["bgr", "rgb"] = "bgr" # Override for image color channel order + self.color_order: T.Literal["bgr", "rgb"] = "bgr" # Override for image color channel order self._args = arguments self._is_predict = predict - self._model: T.Optional[tf.keras.models.Model] = None + self._model: tf.keras.models.Model | None = None self._configfile = arguments.configfile if hasattr(arguments, "configfile") else None self._load_config() @@ -100,14 +94,7 @@ def __init__(self, "use. Please select a mask or disable 'Learn Mask'.") self._mixed_precision = self.config["mixed_precision"] - # self._io = IO(self, model_dir, self._is_predict, self.config["save_optimizer"]) - # TODO - Re-enable saving of optimizer once this bug is fixed: - # File "h5py/_objects.pyx", line 54, in h5py._objects.with_phil.wrapper - # File "h5py/_objects.pyx", line 55, in h5py._objects.with_phil.wrapper - # File "h5py/h5d.pyx", line 87, in h5py.h5d.create - # ValueError: Unable to create dataset (name already exists) - - self._io = IO(self, model_dir, self._is_predict, "never") + self._io = IO(self, model_dir, self._is_predict, self.config["save_optimizer"]) self._check_multiple_models() self._state = State(model_dir, @@ -175,16 +162,16 @@ def model_name(self) -> str: return self.name @property - def input_shapes(self) -> T.List[T.Tuple[None, int, int, int]]: + def input_shapes(self) -> list[tuple[None, int, int, int]]: """ list: A flattened list corresponding to all of the inputs to the model. """ - shapes = [T.cast(T.Tuple[None, int, int, int], K.int_shape(inputs)) + shapes = [T.cast(tuple[None, int, int, int], K.int_shape(inputs)) for inputs in self.model.inputs] return shapes @property - def output_shapes(self) -> T.List[T.Tuple[None, int, int, int]]: + def output_shapes(self) -> list[tuple[None, int, int, int]]: """ list: A flattened list corresponding to all of the outputs of the model. """ - shapes = [T.cast(T.Tuple[None, int, int, int], K.int_shape(output)) + shapes = [T.cast(tuple[None, int, int, int], K.int_shape(output)) for output in self.model.outputs] return shapes @@ -333,7 +320,7 @@ def _validate_input_shape(self) -> None: a list of 2 shape tuples of 3 dimensions. """ assert len(self.input_shape) == 3, "Input shape should be a 3 dimensional shape tuple" - def _get_inputs(self) -> T.List[tf.keras.layers.Input]: + def _get_inputs(self) -> list[tf.keras.layers.Input]: """ Obtain the standardized inputs for the model. The inputs will be returned for the "A" and "B" sides in the shape as defined by @@ -352,7 +339,7 @@ def _get_inputs(self) -> T.List[tf.keras.layers.Input]: logger.debug("inputs: %s", inputs) return inputs - def build_model(self, inputs: T.List[tf.keras.layers.Input]) -> tf.keras.models.Model: + def build_model(self, inputs: list[tf.keras.layers.Input]) -> tf.keras.models.Model: """ Override for Model Specific autoencoder builds. Parameters @@ -427,7 +414,7 @@ def _compile_model(self) -> None: self._state.add_session_loss_names(self._loss.names) logger.debug("Compiled Model: %s", self.model) - def _legacy_mapping(self) -> T.Optional[dict]: + def _legacy_mapping(self) -> dict | None: """ The mapping of separate model files to single model layers for transferring of legacy weights. @@ -439,7 +426,7 @@ def _legacy_mapping(self) -> T.Optional[dict]: """ return None - def add_history(self, loss: T.List[float]) -> None: + def add_history(self, loss: list[float]) -> None: """ Add the current iteration's loss history to :attr:`_io.history`. Called from the trainer after each iteration, for tracking loss drop over time between @@ -482,18 +469,18 @@ def __init__(self, self._filename = os.path.join(model_dir, filename) self._name = model_name self._iterations = 0 - self._mixed_precision_layers: T.List[str] = [] + self._mixed_precision_layers: list[str] = [] self._rebuild_model = False - self._sessions: T.Dict[int, dict] = {} - self._lowest_avg_loss: T.Dict[str, float] = {} - self._config: T.Dict[str, ConfigValueType] = {} + self._sessions: dict[int, dict] = {} + self._lowest_avg_loss: dict[str, float] = {} + self._config: dict[str, ConfigValueType] = {} self._load(config_changeable_items) self._session_id = self._new_session_id() self._create_new_session(no_logs, config_changeable_items) logger.debug("Initialized %s:", self.__class__.__name__) @property - def loss_names(self) -> T.List[str]: + def loss_names(self) -> list[str]: """ list: The loss names for the current session """ return self._sessions[self._session_id]["loss_names"] @@ -518,7 +505,7 @@ def session_id(self) -> int: return self._session_id @property - def mixed_precision_layers(self) -> T.List[str]: + def mixed_precision_layers(self) -> list[str]: """list: Layers that can be switched between mixed-float16 and float32. """ return self._mixed_precision_layers @@ -564,7 +551,7 @@ def _create_new_session(self, no_logs: bool, config_changeable_items: dict) -> N "iterations": 0, "config": config_changeable_items} - def add_session_loss_names(self, loss_names: T.List[str]) -> None: + def add_session_loss_names(self, loss_names: list[str]) -> None: """ Add the session loss names to the sessions dictionary. The loss names are used for Tensorboard logging @@ -593,7 +580,7 @@ def increment_iterations(self) -> None: self._iterations += 1 self._sessions[self._session_id]["iterations"] += 1 - def add_mixed_precision_layers(self, layers: T.List[str]) -> None: + def add_mixed_precision_layers(self, layers: list[str]) -> None: """ Add the list of model's layers that are compatible for mixed precision to the state dictionary """ logger.debug("Storing mixed precision layers: %s", layers) @@ -655,11 +642,11 @@ def _replace_config(self, config_changeable_items) -> None: legacy_update = self._update_legacy_config() # Add any new items to state config for legacy purposes where the new default may be # detrimental to an existing model. - legacy_defaults: T.Dict[str, T.Union[str, int, bool]] = {"centering": "legacy", - "mask_loss_function": "mse", - "l2_reg_term": 100, - "optimizer": "adam", - "mixed_precision": False} + legacy_defaults: dict[str, str | int | bool] = {"centering": "legacy", + "mask_loss_function": "mse", + "l2_reg_term": 100, + "optimizer": "adam", + "mixed_precision": False} for key, val in _CONFIG.items(): if key not in self._config.keys(): setting: ConfigValueType = legacy_defaults.get(key, val) @@ -807,7 +794,7 @@ def model(self) -> tf.keras.models.Model: """ :class:`keras.models.Model`: The Faceswap model, compiled for inference. """ return self._model - def _get_nodes(self, nodes: np.ndarray) -> T.List[T.Tuple[str, int]]: + def _get_nodes(self, nodes: np.ndarray) -> list[tuple[str, int]]: """ Given in input list of nodes from a :attr:`keras.models.Model.get_config` dictionary, filters the layer name(s) and output index of the node, splitting to the correct output index in the event of multiple inputs. @@ -849,7 +836,7 @@ def _make_inference_model(self, saved_model: tf.keras.models.Model) -> tf.keras. logger.debug("Compiling inference model. saved_model: %s", saved_model) struct = self._get_filtered_structure() model_inputs = self._get_inputs(saved_model.inputs) - compiled_layers: T.Dict[str, tf.keras.layers.Layer] = {} + compiled_layers: dict[str, tf.keras.layers.Layer] = {} for layer in saved_model.layers: if layer.name not in struct: logger.debug("Skipping unused layer: '%s'", layer.name) diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 84ac102b0e..0513ff5742 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -14,7 +14,6 @@ from dataclasses import dataclass, field import logging import platform -import sys import typing as T from contextlib import nullcontext @@ -28,12 +27,9 @@ from lib.model.autoclip import AutoClipper from lib.utils import get_backend -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - if T.TYPE_CHECKING: + from collections.abc import Callable + from contextlib import AbstractContextManager as ContextManager from argparse import Namespace from .model import State @@ -58,9 +54,9 @@ class LossClass: kwargs: dict Any keyword arguments to supply to the loss function at initialization. """ - function: T.Union[T.Callable[[tf.Tensor, tf.Tensor], tf.Tensor], T.Any] = k_losses.mae + function: Callable[[tf.Tensor, tf.Tensor], tf.Tensor] | T.Any = k_losses.mae init: bool = True - kwargs: T.Dict[str, T.Any] = field(default_factory=dict) + kwargs: dict[str, T.Any] = field(default_factory=dict) class Loss(): @@ -73,13 +69,13 @@ class Loss(): color_order: str Color order of the model. One of `"BGR"` or `"RGB"` """ - def __init__(self, config: dict, color_order: Literal["bgr", "rgb"]) -> None: + def __init__(self, config: dict, color_order: T.Literal["bgr", "rgb"]) -> None: logger.debug("Initializing %s: (color_order: %s)", self.__class__.__name__, color_order) self._config = config self._mask_channels = self._get_mask_channels() - self._inputs: T.List[tf.keras.layers.Layer] = [] - self._names: T.List[str] = [] - self._funcs: T.Dict[str, T.Callable] = {} + self._inputs: list[tf.keras.layers.Layer] = [] + self._names: list[str] = [] + self._funcs: dict[str, Callable] = {} self._loss_dict = {"ffl": LossClass(function=losses.FocalFrequencyLoss), "flip": LossClass(function=losses.LDRFLIPLoss, @@ -104,7 +100,7 @@ def __init__(self, config: dict, color_order: Literal["bgr", "rgb"]) -> None: logger.debug("Initialized: %s", self.__class__.__name__) @property - def names(self) -> T.List[str]: + def names(self) -> list[str]: """ list: The list of loss names for the model. """ return self._names @@ -114,14 +110,14 @@ def functions(self) -> dict: return self._funcs @property - def _mask_inputs(self) -> T.Optional[list]: + def _mask_inputs(self) -> list | None: """ list: The list of input tensors to the model that contain the mask. Returns ``None`` if there is no mask input to the model. """ mask_inputs = [inp for inp in self._inputs if inp.name.startswith("mask")] return None if not mask_inputs else mask_inputs @property - def _mask_shapes(self) -> T.Optional[T.List[tuple]]: + def _mask_shapes(self) -> list[tuple] | None: """ list: The list of shape tuples for the mask input tensors for the model. Returns ``None`` if there is no mask input. """ if self._mask_inputs is None: @@ -141,7 +137,7 @@ def configure(self, model: tf.keras.models.Model) -> None: self._set_loss_functions(model.output_names) self._names.insert(0, "total") - def _set_loss_names(self, outputs: T.List[tf.Tensor]) -> None: + def _set_loss_names(self, outputs: list[tf.Tensor]) -> None: """ Name the losses based on model output. This is used for correct naming in the state file, for display purposes only. @@ -173,7 +169,7 @@ def _set_loss_names(self, outputs: T.List[tf.Tensor]) -> None: self._names.append(f"{name}_{side}{suffix}") logger.debug(self._names) - def _get_function(self, name: str) -> T.Callable[[tf.Tensor, tf.Tensor], tf.Tensor]: + def _get_function(self, name: str) -> Callable[[tf.Tensor, tf.Tensor], tf.Tensor]: """ Obtain the requested Loss function Parameters @@ -191,7 +187,7 @@ def _get_function(self, name: str) -> T.Callable[[tf.Tensor, tf.Tensor], tf.Tens logger.debug("Obtained loss function `%s` (%s)", name, retval) return retval - def _set_loss_functions(self, output_names: T.List[str]): + def _set_loss_functions(self, output_names: list[str]): """ Set the loss functions and their associated weights. Adds the loss functions to the :attr:`functions` dictionary. @@ -251,7 +247,7 @@ def _add_face_loss_function(self, mask_channel=mask_channel) channel_idx += 1 - def _get_mask_channels(self) -> T.List[int]: + def _get_mask_channels(self) -> list[int]: """ Obtain the channels from the face targets that the masks reside in from the training data generator. @@ -311,8 +307,8 @@ def __init__(self, {"beta_1": 0.5, "beta_2": 0.99, "epsilon": epsilon}), "rms-prop": (optimizers.RMSprop, {"epsilon": epsilon})} optimizer_info = valid_optimizers[optimizer] - self._optimizer: T.Callable = optimizer_info[0] - self._kwargs: T.Dict[str, T.Any] = optimizer_info[1] + self._optimizer: Callable = optimizer_info[0] + self._kwargs: dict[str, T.Any] = optimizer_info[1] self._configure(learning_rate, autoclip) logger.verbose("Using %s optimizer", optimizer.title()) # type:ignore[attr-defined] @@ -411,7 +407,7 @@ def loss_scale_optimizer( return mixedprecision.LossScaleOptimizer(optimizer) # pylint:disable=no-member @classmethod - def _set_tf_settings(cls, allow_growth: bool, exclude_devices: T.List[int]) -> None: + def _set_tf_settings(cls, allow_growth: bool, exclude_devices: list[int]) -> None: """ Specify Devices to place operations on and Allow TensorFlow to manage VRAM growth. Enables the Tensorflow allow_growth option if requested in the command line arguments @@ -480,8 +476,8 @@ def _set_keras_mixed_precision(cls, use_mixed_precision: bool) -> bool: return True def _get_strategy(self, - strategy: Literal["default", "central-storage", "mirrored"] - ) -> T.Optional[tf.distribute.Strategy]: + strategy: T.Literal["default", "central-storage", "mirrored"] + ) -> tf.distribute.Strategy | None: """ If we are running on Nvidia backend and the strategy is not ``None`` then return the correct tensorflow distribution strategy, otherwise return ``None``. @@ -565,7 +561,7 @@ def _get_central_storage_strategy(cls) -> tf.distribute.experimental.CentralStor return tf.distribute.experimental.CentralStorageStrategy(parameter_device="/cpu:0") - def _get_mixed_precision_layers(self, layers: T.List[dict]) -> T.List[str]: + def _get_mixed_precision_layers(self, layers: list[dict]) -> list[str]: """ Obtain the names of the layers in a mixed precision model that have their dtype policy explicitly set to mixed-float16. @@ -595,7 +591,7 @@ def _get_mixed_precision_layers(self, layers: T.List[dict]) -> T.List[str]: logger.debug("Skipping unsupported layer: %s %s", layer["name"], dtype) return retval - def _switch_precision(self, layers: T.List[dict], compatible: T.List[str]) -> None: + def _switch_precision(self, layers: list[dict], compatible: list[str]) -> None: """ Switch a model's datatype between mixed-float16 and float32. Parameters @@ -624,9 +620,9 @@ def _switch_precision(self, layers: T.List[dict], compatible: T.List[str]) -> No config["dtype"] = policy def get_mixed_precision_layers(self, - build_func: T.Callable[[T.List[tf.keras.layers.Layer]], - tf.keras.models.Model], - inputs: T.List[tf.keras.layers.Layer]) -> T.List[str]: + build_func: Callable[[list[tf.keras.layers.Layer]], + tf.keras.models.Model], + inputs: list[tf.keras.layers.Layer]) -> list[str]: """ Get and store the mixed precision layers from a full precision enabled model. Parameters @@ -699,7 +695,7 @@ def check_model_precision(self, del model return new_model - def strategy_scope(self) -> T.ContextManager: + def strategy_scope(self) -> ContextManager: """ Return the strategy scope if we have set a strategy, otherwise return a null context. diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 689b9c6206..402c071aab 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -4,7 +4,6 @@ # pylint: disable=too-many-lines from __future__ import annotations import logging -import sys import typing as T from dataclasses import dataclass @@ -27,16 +26,10 @@ from ._base import ModelBase, get_all_sub_models -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - if T.TYPE_CHECKING: from tensorflow import keras from tensorflow import Tensor - logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -65,14 +58,14 @@ class _EncoderInfo: """ keras_name: str default_size: int - tf_min: T.Tuple[int, int] = (2, 0) - scaling: T.Tuple[int, int] = (0, 1) + tf_min: tuple[int, int] = (2, 0) + scaling: tuple[int, int] = (0, 1) min_size: int = 32 enforce_for_weights: bool = False - color_order: Literal["bgr", "rgb"] = "rgb" + color_order: T.Literal["bgr", "rgb"] = "rgb" -_MODEL_MAPPING: T.Dict[str, _EncoderInfo] = { +_MODEL_MAPPING: dict[str, _EncoderInfo] = { "densenet121": _EncoderInfo( keras_name="DenseNet121", default_size=224), "densenet169": _EncoderInfo( @@ -238,7 +231,7 @@ def _update_dropouts(self, model: keras.models.Model) -> keras.models.Model: model = new_model return model - def _select_freeze_layers(self) -> T.List[str]: + def _select_freeze_layers(self) -> list[str]: """ Process the selected frozen layers and replace the `keras_encoder` option with the actual keras model name @@ -262,7 +255,7 @@ def _select_freeze_layers(self) -> T.List[str]: logger.debug("Removing 'keras_encoder' for '%s'", arch) return retval - def _get_input_shape(self) -> T.Tuple[int, int, int]: + def _get_input_shape(self) -> tuple[int, int, int]: """ Obtain the input shape for the model. Input shape is calculated from the selected Encoder's input size, scaled to the user @@ -316,7 +309,7 @@ def _validate_encoder_architecture(self) -> None: f"minimum version required is {tf_min} whilst you have version " f"{tf_ver} installed.") - def build_model(self, inputs: T.List[Tensor]) -> keras.models.Model: + def build_model(self, inputs: list[Tensor]) -> keras.models.Model: """ Create the model's structure. Parameters @@ -341,7 +334,7 @@ def build_model(self, inputs: T.List[Tensor]) -> keras.models.Model: autoencoder = KModel(inputs, outputs, name=self.model_name) return autoencoder - def _build_encoders(self, inputs: T.List[Tensor]) -> T.Dict[str, keras.models.Model]: + def _build_encoders(self, inputs: list[Tensor]) -> dict[str, keras.models.Model]: """ Build the encoders for Phaze-A Parameters @@ -362,7 +355,7 @@ def _build_encoders(self, inputs: T.List[Tensor]) -> T.Dict[str, keras.models.Mo def _build_fully_connected( self, - inputs: T.Dict[str, keras.models.Model]) -> T.Dict[str, T.List[keras.models.Model]]: + inputs: dict[str, keras.models.Model]) -> dict[str, list[keras.models.Model]]: """ Build the fully connected layers for Phaze-A Parameters @@ -407,8 +400,8 @@ def _build_fully_connected( def _build_g_blocks( self, - inputs: T.Dict[str, T.List[keras.models.Model]] - ) -> T.Dict[str, T.Union[T.List[keras.models.Model], keras.models.Model]]: + inputs: dict[str, list[keras.models.Model]] + ) -> dict[str, list[keras.models.Model] | keras.models.Model]: """ Build the g-block layers for Phaze-A. If a g-block has not been selected for this model, then the original `inters` models are @@ -440,10 +433,9 @@ def _build_g_blocks( logger.debug("G-Blocks: %s", retval) return retval - def _build_decoders( - self, - inputs: T.Dict[str, T.Union[T.List[keras.models.Model], keras.models.Model]] - ) -> T.Dict[str, keras.models.Model]: + def _build_decoders(self, + inputs: dict[str, list[keras.models.Model] | keras.models.Model] + ) -> dict[str, keras.models.Model]: """ Build the encoders for Phaze-A Parameters @@ -519,12 +511,12 @@ def _bottleneck(inputs: Tensor, bottleneck: str, size: int, normalization: str) return var_x -def _get_upscale_layer(method: Literal["resize_images", "subpixel", "upscale_dny", "upscale_fast", - "upscale_hybrid", "upsample2d"], +def _get_upscale_layer(method: T.Literal["resize_images", "subpixel", "upscale_dny", + "upscale_fast", "upscale_hybrid", "upsample2d"], filters: int, - activation: T.Optional[str] = None, - upsamples: T.Optional[int] = None, - interpolation: T.Optional[str] = None) -> keras.layers.Layer: + activation: str | None = None, + upsamples: int | None = None, + interpolation: str | None = None) -> keras.layers.Layer: """ Obtain an instance of the requested upscale method. Parameters @@ -550,7 +542,7 @@ def _get_upscale_layer(method: Literal["resize_images", "subpixel", "upscale_dny The selected configured upscale layer """ if method == "upsample2d": - kwargs: T.Dict[str, T.Union[str, int]] = {} + kwargs: dict[str, str | int] = {} if upsamples: kwargs["size"] = upsamples if interpolation: @@ -571,7 +563,7 @@ def _get_curve(start_y: int, end_y: int, num_points: int, scale: float, - mode: Literal["full", "cap_max", "cap_min"] = "full") -> T.List[int]: + mode: T.Literal["full", "cap_max", "cap_min"] = "full") -> list[int]: """ Obtain a curve. For the given start and end y values, return the y co-ordinates of a curve for the given @@ -660,13 +652,13 @@ class Encoder(): # pylint:disable=too-few-public-methods config: dict The model configuration options """ - def __init__(self, input_shape: T.Tuple[int, ...], config: dict) -> None: + def __init__(self, input_shape: tuple[int, ...], config: dict) -> None: self.input_shape = input_shape self._config = config self._input_shape = input_shape @property - def _model_kwargs(self) -> T.Dict[str, T.Dict[str, T.Union[str, bool]]]: + def _model_kwargs(self) -> dict[str, dict[str, str | bool]]: """ dict: Configuration option for architecture mapped to optional kwargs. """ return {"mobilenet": {"alpha": self._config["mobilenet_width"], "depth_multiplier": self._config["mobilenet_depth"], @@ -677,7 +669,7 @@ def _model_kwargs(self) -> T.Dict[str, T.Dict[str, T.Union[str, bool]]]: "include_preprocessing": False}} @property - def _selected_model(self) -> T.Tuple[_EncoderInfo, dict]: + def _selected_model(self) -> tuple[_EncoderInfo, dict]: """ tuple(dict, :class:`_EncoderInfo`): The selected encoder model and it's associated keyword arguments """ arch = self._config["enc_architecture"] @@ -832,7 +824,7 @@ class FullyConnected(): # pylint:disable=too-few-public-methods The user configuration dictionary """ def __init__(self, - side: Literal["a", "b", "both", "gblock", "shared"], + side: T.Literal["a", "b", "both", "gblock", "shared"], input_shape: tuple, config: dict) -> None: logger.debug("Initializing: %s (side: %s, input_shape: %s)", @@ -992,12 +984,12 @@ class UpscaleBlocks(): # pylint: disable=too-few-public-methods and the Decoder. ``None`` will generate the full Upscale chain. An end index of -1 will generate the layers from the starting index to the final upscale. Default: ``None`` """ - _filters: T.List[int] = [] + _filters: list[int] = [] def __init__(self, - side: Literal["a", "b", "both", "shared"], + side: T.Literal["a", "b", "both", "shared"], config: dict, - layer_indicies: T.Optional[T.Tuple[int, int]] = None) -> None: + layer_indicies: tuple[int, int] | None = None) -> None: logger.debug("Initializing: %s (side: %s, layer_indicies: %s)", self.__class__.__name__, side, layer_indicies) self._side = side @@ -1126,7 +1118,7 @@ def _dny_entry(self, inputs: Tensor) -> Tensor: relu_alpha=0.2)(var_x) return var_x - def __call__(self, inputs: T.Union[Tensor, T.List[Tensor]]) -> T.Union[Tensor, T.List[Tensor]]: + def __call__(self, inputs: Tensor | list[Tensor]) -> Tensor | list[Tensor]: """ Upscale Network. Parameters @@ -1203,8 +1195,8 @@ class GBlock(): # pylint:disable=too-few-public-methods The user configuration dictionary """ def __init__(self, - side: Literal["a", "b", "both"], - input_shapes: T.Union[list, tuple], + side: T.Literal["a", "b", "both"], + input_shapes: list | tuple, config: dict) -> None: logger.debug("Initializing: %s (side: %s, input_shapes: %s)", self.__class__.__name__, side, input_shapes) @@ -1284,8 +1276,8 @@ class Decoder(): # pylint:disable=too-few-public-methods The user configuration dictionary """ def __init__(self, - side: Literal["a", "b", "both"], - input_shape: T.Tuple[int, int, int], + side: T.Literal["a", "b", "both"], + input_shape: tuple[int, int, int], config: dict) -> None: logger.debug("Initializing: %s (side: %s, input_shape: %s)", self.__class__.__name__, side, input_shape) diff --git a/plugins/train/model/phaze_a_defaults.py b/plugins/train/model/phaze_a_defaults.py index c741ae09c9..9468609d2e 100644 --- a/plugins/train/model/phaze_a_defaults.py +++ b/plugins/train/model/phaze_a_defaults.py @@ -39,8 +39,6 @@ " the value saved in the state file with the updated value in config. If not " provided this will default to True. """ -from typing import List - _HELPTEXT: str = ( "Phaze-A Model by TorzDF, with thanks to BirbFakes.\n" @@ -48,7 +46,7 @@ "inspiration from Nvidia's StyleGAN for the Decoder. It is highly recommended to research to " "understand the parameters better.") -_ENCODERS: List[str] = sorted([ +_ENCODERS: list[str] = sorted([ "densenet121", "densenet169", "densenet201", "efficientnet_b0", "efficientnet_b1", "efficientnet_b2", "efficientnet_b3", "efficientnet_b4", "efficientnet_b5", "efficientnet_b6", "efficientnet_b7", "efficientnet_v2_b0", "efficientnet_v2_b1", "efficientnet_v2_b2", diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index f0feebb4e8..146f7b70de 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -9,7 +9,6 @@ from __future__ import annotations import logging import os -import sys import time import typing as T @@ -23,23 +22,19 @@ from lib.image import hex_to_rgb from lib.training import PreviewDataGenerator, TrainingDataGenerator from lib.training.generator import BatchType, DataGenerator -from lib.utils import FaceswapError, get_folder, get_image_paths, get_tf_version +from lib.utils import FaceswapError, get_folder, get_image_paths from plugins.train._config import Config if T.TYPE_CHECKING: + from collections.abc import Callable, Generator from plugins.train.model._base import ModelBase from lib.config import ConfigValueType -if sys.version_info < (3, 8): - from typing_extensions import get_args, Literal -else: - from typing import get_args, Literal - logger = logging.getLogger(__name__) # pylint: disable=invalid-name def _get_config(plugin_name: str, - configfile: T.Optional[str] = None) -> T.Dict[str, ConfigValueType]: + configfile: str | None = None) -> dict[str, ConfigValueType]: """ Return the configuration for the requested trainer. Parameters @@ -80,9 +75,9 @@ class TrainerBase(): def __init__(self, model: ModelBase, - images: T.Dict[Literal["a", "b"], T.List[str]], + images: dict[T.Literal["a", "b"], list[str]], batch_size: int, - configfile: T.Optional[str]) -> None: + configfile: str | None) -> None: logger.debug("Initializing %s: (model: '%s', batch_size: %s)", self.__class__.__name__, model, batch_size) self._model = model @@ -111,7 +106,7 @@ def __init__(self, self._images) logger.debug("Initialized %s", self.__class__.__name__) - def _get_config(self, configfile: T.Optional[str]) -> T.Dict[str, ConfigValueType]: + def _get_config(self, configfile: str | None) -> dict[str, ConfigValueType]: """ Get the saved training config options. Override any global settings with the setting provided from the model's saved config. @@ -173,10 +168,9 @@ def toggle_mask(self) -> None: self._samples.toggle_mask_display() def train_one_step(self, - viewer: T.Optional[T.Callable[[np.ndarray, str], None]], - timelapse_kwargs: T.Optional[T.Dict[Literal["input_a", - "input_b", - "output"], str]]) -> None: + viewer: Callable[[np.ndarray, str], None] | None, + timelapse_kwargs: dict[T.Literal["input_a", "input_b", "output"], + str] | None) -> None: """ Running training on a batch of images for each side. Triggered from the training cycle in :class:`scripts.train.Train`. @@ -217,7 +211,7 @@ def train_one_step(self, model_inputs, model_targets = self._feeder.get_batch() try: - loss: T.List[float] = self._model.model.train_on_batch(model_inputs, y=model_targets) + loss: list[float] = self._model.model.train_on_batch(model_inputs, y=model_targets) except tf_errors.ResourceExhaustedError as err: msg = ("You do not have enough GPU memory available to train the selected model at " "the selected settings. You can try a number of things:" @@ -236,7 +230,7 @@ def train_one_step(self, self._model.snapshot() self._update_viewers(viewer, timelapse_kwargs) - def _log_tensorboard(self, loss: T.List[float]) -> None: + def _log_tensorboard(self, loss: list[float]) -> None: """ Log current loss to Tensorboard log files Parameters @@ -250,19 +244,18 @@ def _log_tensorboard(self, loss: T.List[float]) -> None: logs = {log[0]: log[1] for log in zip(self._model.state.loss_names, loss)} - if get_tf_version() > (2, 7): - # Bug in TF 2.8/2.9/2.10 where batch recording got deleted. - # ref: https://github.com/keras-team/keras/issues/16173 - with tf.summary.record_if(True), self._tensorboard._train_writer.as_default(): # noqa pylint:disable=protected-access,not-context-manager - for name, value in logs.items(): - tf.summary.scalar( - "batch_" + name, - value, - step=self._tensorboard._train_step) # pylint:disable=protected-access - else: - self._tensorboard.on_train_batch_end(self._model.iterations, logs=logs) - - def _collate_and_store_loss(self, loss: T.List[float]) -> T.List[float]: + # Bug in TF 2.8/2.9/2.10 where batch recording got deleted. + # ref: https://github.com/keras-team/keras/issues/16173 + with tf.summary.record_if(True), self._tensorboard._train_writer.as_default(): # noqa:E501 pylint:disable=protected-access,not-context-manager + for name, value in logs.items(): + tf.summary.scalar( + "batch_" + name, + value, + step=self._tensorboard._train_step) # pylint:disable=protected-access + # TODO revert this code if fixed in tensorflow + # self._tensorboard.on_train_batch_end(self._model.iterations, logs=logs) + + def _collate_and_store_loss(self, loss: list[float]) -> list[float]: """ Collate the loss into totals for each side. The losses are summed into a total for each side. Loss totals are added to @@ -298,7 +291,7 @@ def _collate_and_store_loss(self, loss: T.List[float]) -> T.List[float]: logger.trace("original loss: %s, combined_loss: %s", loss, combined_loss) # type: ignore return combined_loss - def _print_loss(self, loss: T.List[float]) -> None: + def _print_loss(self, loss: list[float]) -> None: """ Outputs the loss for the current iteration to the console. Parameters @@ -318,10 +311,9 @@ def _print_loss(self, loss: T.List[float]) -> None: "line: %s, error: %s", output, str(err)) def _update_viewers(self, - viewer: T.Optional[T.Callable[[np.ndarray, str], None]], - timelapse_kwargs: T.Optional[T.Dict[Literal["input_a", - "input_b", - "output"], str]]) -> None: + viewer: Callable[[np.ndarray, str], None] | None, + timelapse_kwargs: dict[T.Literal["input_a", "input_b", "output"], + str] | None) -> None: """ Update the preview viewer and timelapse output Parameters @@ -371,10 +363,10 @@ class _Feeder(): The configuration for this trainer """ def __init__(self, - images: T.Dict[Literal["a", "b"], T.List[str]], + images: dict[T.Literal["a", "b"], list[str]], model: ModelBase, batch_size: int, - config: T.Dict[str, ConfigValueType]) -> None: + config: dict[str, ConfigValueType]) -> None: logger.debug("Initializing %s: num_images: %s, batch_size: %s, config: %s)", self.__class__.__name__, {k: len(v) for k, v in images.items()}, batch_size, config) @@ -383,16 +375,16 @@ def __init__(self, self._batch_size = batch_size self._config = config self._feeds = {side: self._load_generator(side, False).minibatch_ab() - for side in get_args(Literal["a", "b"])} + for side in T.get_args(T.Literal["a", "b"])} self._display_feeds = {"preview": self._set_preview_feed(), "timelapse": {}} logger.debug("Initialized %s:", self.__class__.__name__) def _load_generator(self, - side: Literal["a", "b"], + side: T.Literal["a", "b"], is_display: bool, - batch_size: T.Optional[int] = None, - images: T.Optional[T.List[str]] = None) -> DataGenerator: + batch_size: int | None = None, + images: list[str] | None = None) -> DataGenerator: """ Load the :class:`~lib.training_data.TrainingDataGenerator` for this feeder. Parameters @@ -424,7 +416,7 @@ def _load_generator(self, self._batch_size if batch_size is None else batch_size) return retval - def _set_preview_feed(self) -> T.Dict[Literal["a", "b"], T.Generator[BatchType, None, None]]: + def _set_preview_feed(self) -> dict[T.Literal["a", "b"], Generator[BatchType, None, None]]: """ Set the preview feed for this feeder. Creates a generator from :class:`lib.training_data.PreviewDataGenerator` specifically @@ -436,10 +428,10 @@ def _set_preview_feed(self) -> T.Dict[Literal["a", "b"], T.Generator[BatchType, The side ("a" or "b") as key, :class:`~lib.training_data.PreviewDataGenerator` as value. """ - retval: T.Dict[Literal["a", "b"], T.Generator[BatchType, None, None]] = {} + retval: dict[T.Literal["a", "b"], Generator[BatchType, None, None]] = {} num_images = self._config.get("preview_images", 14) assert isinstance(num_images, int) - for side in get_args(Literal["a", "b"]): + for side in T.get_args(T.Literal["a", "b"]): logger.debug("Setting preview feed: (side: '%s')", side) preview_images = min(max(num_images, 2), 16) batchsize = min(len(self._images[side]), preview_images) @@ -448,7 +440,7 @@ def _set_preview_feed(self) -> T.Dict[Literal["a", "b"], T.Generator[BatchType, batch_size=batchsize).minibatch_ab() return retval - def get_batch(self) -> T.Tuple[T.List[T.List[np.ndarray]], ...]: + def get_batch(self) -> tuple[list[list[np.ndarray]], ...]: """ Get the feed data and the targets for each training side for feeding into the model's train function. @@ -459,8 +451,8 @@ def get_batch(self) -> T.Tuple[T.List[T.List[np.ndarray]], ...]: model_targets: list The targets for the model for each side A and B """ - model_inputs: T.List[T.List[np.ndarray]] = [] - model_targets: T.List[T.List[np.ndarray]] = [] + model_inputs: list[list[np.ndarray]] = [] + model_targets: list[list[np.ndarray]] = [] for side in ("a", "b"): side_feed, side_targets = next(self._feeds[side]) if self._model.config["learn_mask"]: # Add the face mask as it's own target @@ -473,7 +465,7 @@ def get_batch(self) -> T.Tuple[T.List[T.List[np.ndarray]], ...]: return model_inputs, model_targets def generate_preview(self, is_timelapse: bool = False - ) -> T.Dict[Literal["a", "b"], T.List[np.ndarray]]: + ) -> dict[T.Literal["a", "b"], list[np.ndarray]]: """ Generate the images for preview window or timelapse Parameters @@ -490,15 +482,15 @@ def generate_preview(self, is_timelapse: bool = False """ logger.debug("Generating preview (is_timelapse: %s)", is_timelapse) - batchsizes: T.List[int] = [] - feed: T.Dict[Literal["a", "b"], np.ndarray] = {} - samples: T.Dict[Literal["a", "b"], np.ndarray] = {} - masks: T.Dict[Literal["a", "b"], np.ndarray] = {} + batchsizes: list[int] = [] + feed: dict[T.Literal["a", "b"], np.ndarray] = {} + samples: dict[T.Literal["a", "b"], np.ndarray] = {} + masks: dict[T.Literal["a", "b"], np.ndarray] = {} # MyPy can't recurse into nested dicts to get the type :( - iterator = T.cast(T.Dict[Literal["a", "b"], T.Generator[BatchType, None, None]], + iterator = T.cast(dict[T.Literal["a", "b"], "Generator[BatchType, None, None]"], self._display_feeds["timelapse" if is_timelapse else "preview"]) - for side in get_args(Literal["a", "b"]): + for side in T.get_args(T.Literal["a", "b"]): side_feed, side_samples = next(iterator[side]) batchsizes.append(len(side_samples[0])) samples[side] = side_samples[0] @@ -513,10 +505,10 @@ def generate_preview(self, is_timelapse: bool = False def compile_sample(self, image_count: int, - feed: T.Dict[Literal["a", "b"], np.ndarray], - samples: T.Dict[Literal["a", "b"], np.ndarray], - masks: T.Dict[Literal["a", "b"], np.ndarray] - ) -> T.Dict[Literal["a", "b"], T.List[np.ndarray]]: + feed: dict[T.Literal["a", "b"], np.ndarray], + samples: dict[T.Literal["a", "b"], np.ndarray], + masks: dict[T.Literal["a", "b"], np.ndarray] + ) -> dict[T.Literal["a", "b"], list[np.ndarray]]: """ Compile the preview samples for display. Parameters @@ -542,8 +534,8 @@ def compile_sample(self, num_images = self._config.get("preview_images", 14) assert isinstance(num_images, int) num_images = min(image_count, num_images) - retval: T.Dict[Literal["a", "b"], T.List[np.ndarray]] = {} - for side in get_args(Literal["a", "b"]): + retval: dict[T.Literal["a", "b"], list[np.ndarray]] = {} + for side in T.get_args(T.Literal["a", "b"]): logger.debug("Compiling samples: (side: '%s', samples: %s)", side, num_images) retval[side] = [feed[side][0:num_images], samples[side][0:num_images], @@ -552,7 +544,7 @@ def compile_sample(self, return retval def set_timelapse_feed(self, - images: T.Dict[Literal["a", "b"], T.List[str]], + images: dict[T.Literal["a", "b"], list[str]], batch_size: int) -> None: """ Set the time-lapse feed for this feeder. @@ -570,10 +562,10 @@ def set_timelapse_feed(self, images, batch_size) # MyPy can't recurse into nested dicts to get the type :( - iterator = T.cast(T.Dict[Literal["a", "b"], T.Generator[BatchType, None, None]], + iterator = T.cast(dict[T.Literal["a", "b"], "Generator[BatchType, None, None]"], self._display_feeds["timelapse"]) - for side in get_args(Literal["a", "b"]): + for side in T.get_args(T.Literal["a", "b"]): imgs = images[side] logger.debug("Setting preview feed: (side: '%s', images: %s)", side, len(imgs)) @@ -615,7 +607,7 @@ def __init__(self, self.__class__.__name__, model, coverage_ratio, mask_opacity, mask_color) self._model = model self._display_mask = model.config["learn_mask"] or model.config["penalized_mask_loss"] - self.images: T.Dict[Literal["a", "b"], T.List[np.ndarray]] = {} + self.images: dict[T.Literal["a", "b"], list[np.ndarray]] = {} self._coverage_ratio = coverage_ratio self._mask_opacity = mask_opacity / 100.0 self._mask_color = np.array(hex_to_rgb(mask_color))[..., 2::-1] / 255. @@ -639,8 +631,8 @@ def show_sample(self) -> np.ndarray: A compiled preview image ready for display or saving """ logger.debug("Showing sample") - feeds: T.Dict[Literal["a", "b"], np.ndarray] = {} - for idx, side in enumerate(get_args(Literal["a", "b"])): + feeds: dict[T.Literal["a", "b"], np.ndarray] = {} + for idx, side in enumerate(T.get_args(T.Literal["a", "b"])): feed = self.images[side][0] input_shape = self._model.model.input_shape[idx][1:] if input_shape[0] / feed.shape[1] != 1.0: @@ -653,7 +645,7 @@ def show_sample(self) -> np.ndarray: @classmethod def _resize_sample(cls, - side: Literal["a", "b"], + side: T.Literal["a", "b"], sample: np.ndarray, target_size: int) -> np.ndarray: """ Resize a given image to the target size. @@ -684,7 +676,7 @@ def _resize_sample(cls, logger.debug("Resized sample: (side: '%s' shape: %s)", side, retval.shape) return retval - def _get_predictions(self, feed_a: np.ndarray, feed_b: np.ndarray) -> T.Dict[str, np.ndarray]: + def _get_predictions(self, feed_a: np.ndarray, feed_b: np.ndarray) -> dict[str, np.ndarray]: """ Feed the samples to the model and return predictions Parameters @@ -700,7 +692,7 @@ def _get_predictions(self, feed_a: np.ndarray, feed_b: np.ndarray) -> T.Dict[str List of :class:`numpy.ndarray` of predictions received from the model """ logger.debug("Getting Predictions") - preds: T.Dict[str, np.ndarray] = {} + preds: dict[str, np.ndarray] = {} standard = self._model.model.predict([feed_a, feed_b], verbose=0) swapped = self._model.model.predict([feed_b, feed_a], verbose=0) @@ -719,7 +711,7 @@ def _get_predictions(self, feed_a: np.ndarray, feed_b: np.ndarray) -> T.Dict[str logger.debug("Returning predictions: %s", {key: val.shape for key, val in preds.items()}) return preds - def _compile_preview(self, predictions: T.Dict[str, np.ndarray]) -> np.ndarray: + def _compile_preview(self, predictions: dict[str, np.ndarray]) -> np.ndarray: """ Compile predictions and images into the final preview image. Parameters @@ -732,8 +724,8 @@ def _compile_preview(self, predictions: T.Dict[str, np.ndarray]) -> np.ndarray: :class:`numpy.ndarry` A compiled preview image ready for display or saving """ - figures: T.Dict[Literal["a", "b"], np.ndarray] = {} - headers: T.Dict[Literal["a", "b"], np.ndarray] = {} + figures: dict[T.Literal["a", "b"], np.ndarray] = {} + headers: dict[T.Literal["a", "b"], np.ndarray] = {} for side, samples in self.images.items(): other_side = "a" if side == "b" else "b" @@ -761,9 +753,9 @@ def _compile_preview(self, predictions: T.Dict[str, np.ndarray]) -> np.ndarray: return np.clip(figure * 255, 0, 255).astype('uint8') def _to_full_frame(self, - side: Literal["a", "b"], - samples: T.List[np.ndarray], - predictions: T.List[np.ndarray]) -> T.List[np.ndarray]: + side: T.Literal["a", "b"], + samples: list[np.ndarray], + predictions: list[np.ndarray]) -> list[np.ndarray]: """ Patch targets and prediction images into images of model output size. Parameters @@ -803,10 +795,10 @@ def _to_full_frame(self, return images def _process_full(self, - side: Literal["a", "b"], + side: T.Literal["a", "b"], images: np.ndarray, prediction_size: int, - color: T.Tuple[float, float, float]) -> np.ndarray: + color: tuple[float, float, float]) -> np.ndarray: """ Add a frame overlay to preview images indicating the region of interest. This applies the red border that appears in the preview images. @@ -847,7 +839,7 @@ def _process_full(self, logger.debug("Overlayed background. Shape: %s", images.shape) return images - def _compile_masked(self, faces: T.List[np.ndarray], masks: np.ndarray) -> T.List[np.ndarray]: + def _compile_masked(self, faces: list[np.ndarray], masks: np.ndarray) -> list[np.ndarray]: """ Add the mask to the faces for masked preview. Places an opaque red layer over areas of the face that are masked out. @@ -866,7 +858,7 @@ def _compile_masked(self, faces: T.List[np.ndarray], masks: np.ndarray) -> T.Lis List of :class:`numpy.ndarray` faces with the opaque mask layer applied """ orig_masks = 1 - np.rint(masks) - masks3: T.Union[T.List[np.ndarray], np.ndarray] = [] + masks3: list[np.ndarray] | np.ndarray = [] if faces[-1].shape[-1] == 4: # Mask contained in alpha channel of predictions pred_masks = [1 - np.rint(face[..., -1])[..., None] for face in faces[-2:]] @@ -875,7 +867,7 @@ def _compile_masked(self, faces: T.List[np.ndarray], masks: np.ndarray) -> T.Lis else: masks3 = np.repeat(np.expand_dims(orig_masks, axis=0), 3, axis=0) - retval: T.List[np.ndarray] = [] + retval: list[np.ndarray] = [] alpha = 1.0 - self._mask_opacity for previews, compiled_masks in zip(faces, masks3): overlays = previews.copy() @@ -910,7 +902,7 @@ def _overlay_foreground(cls, backgrounds: np.ndarray, foregrounds: np.ndarray) - return backgrounds @classmethod - def _get_headers(cls, side: Literal["a", "b"], width: int) -> np.ndarray: + def _get_headers(cls, side: T.Literal["a", "b"], width: int) -> np.ndarray: """ Set header row for the final preview frame Parameters @@ -958,8 +950,8 @@ def _get_headers(cls, side: Literal["a", "b"], width: int) -> np.ndarray: @classmethod def _duplicate_headers(cls, - headers: T.Dict[Literal["a", "b"], np.ndarray], - columns: int) -> T.Dict[Literal["a", "b"], np.ndarray]: + headers: dict[T.Literal["a", "b"], np.ndarray], + columns: int) -> dict[T.Literal["a", "b"], np.ndarray]: """ Duplicate headers for the number of columns displayed for each side. Parameters @@ -1008,7 +1000,7 @@ def __init__(self, mask_opacity: int, mask_color: str, feeder: _Feeder, - image_paths: T.Dict[Literal["a", "b"], T.List[str]]) -> None: + image_paths: dict[T.Literal["a", "b"], list[str]]) -> None: logger.debug("Initializing %s: model: %s, coverage_ratio: %s, image_count: %s, " "mask_opacity: %s, mask_color: %s, feeder: %s, image_paths: %s)", self.__class__.__name__, model, coverage_ratio, image_count, mask_opacity, @@ -1042,8 +1034,8 @@ def _setup(self, input_a: str, input_b: str, output: str) -> None: logger.debug("Time-lapse output set to '%s'", self._output_file) # Rewrite paths to pull from the training images so mask and face data can be accessed - images: T.Dict[Literal["a", "b"], T.List[str]] = {} - for side, input_ in zip(get_args(Literal["a", "b"]), (input_a, input_b)): + images: dict[T.Literal["a", "b"], list[str]] = {} + for side, input_ in zip(T.get_args(T.Literal["a", "b"]), (input_a, input_b)): training_path = os.path.dirname(self._image_paths[side][0]) images[side] = [os.path.join(training_path, os.path.basename(pth)) for pth in get_image_paths(input_)] @@ -1054,7 +1046,7 @@ def _setup(self, input_a: str, input_b: str, output: str) -> None: self._feeder.set_timelapse_feed(images, batchsize) logger.debug("Set up time-lapse") - def output_timelapse(self, timelapse_kwargs: T.Dict[Literal["input_a", + def output_timelapse(self, timelapse_kwargs: dict[T.Literal["input_a", "input_b", "output"], str]) -> None: """ Generate the time-lapse samples and output the created time-lapse to the specified @@ -1068,7 +1060,7 @@ def output_timelapse(self, timelapse_kwargs: T.Dict[Literal["input_a", """ logger.debug("Ouputting time-lapse") if not self._output_file: - self._setup(**T.cast(T.Dict[str, str], timelapse_kwargs)) + self._setup(**T.cast(dict[str, str], timelapse_kwargs)) logger.debug("Getting time-lapse samples") self._samples.images = self._feeder.generate_preview(is_timelapse=True) diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index 4054ee3658..3d9b36f624 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -1,19 +1,14 @@ -tqdm>=4.64 +# TESTED WITH PY3.10 +tqdm>=4.65 psutil>=5.9.0 -numexpr>=2.7.3; python_version < '3.9' # >=2.8.0 conflicts in Conda -numexpr>=2.8.3; python_version >= '3.9' -opencv-python>=4.6.0.0 -pillow>=9.2.0 -scikit-learn==1.0.2; python_version < '3.9' # AMD needs version 1.0.2 and 1.1.0 not available in Python 3.7 -scikit-learn>=1.1.0; python_version >= '3.9' +numexpr>=2.8.4 +numpy>=1.25.0 +opencv-python>=4.7.0.0 +pillow>=9.4.0 +scikit-learn>=1.2.2 fastcluster>=1.2.6 -matplotlib>=3.4.3,<3.6.0; python_version < '3.9' # >=3.5.0 conflicts in Conda -matplotlib>=3.5.1,<3.6.0; python_version >= '3.9' -imageio>=2.19.3 -imageio-ffmpeg>=0.4.7 +matplotlib>=3.7.1 +imageio>=2.26.0 +imageio-ffmpeg>=0.4.8 ffmpy>=0.3.0 -# Exclude badly numbered Python2 version of nvidia-ml-py -nvidia-ml-py>=11.515,<300 -tensorflow-probability<0.17 -typing-extensions>=4.0.0 pywin32>=228 ; sys_platform == "win32" diff --git a/requirements/requirements_apple_silicon.txt b/requirements/requirements_apple_silicon.txt index 6125112039..5732337ea2 100644 --- a/requirements/requirements_apple_silicon.txt +++ b/requirements/requirements_apple_silicon.txt @@ -1,11 +1,7 @@ -protobuf>= 3.19.0,<3.20.0 # TF has started pulling in incompatible protobuf -# Pinned TF probability doesn't work with numpy >= 1.24 -numpy>=1.21.0,<1.24.0; python_version < '3.8' -numpy>=1.22.0,<1.24.0; python_version >= '3.8' -tensorflow-macos>=2.8.0,<2.11.0 -tensorflow-deps>=2.8.0,<2.11.0 -tensorflow-metal>=0.4.0,<0.7.0 -libblas # Conda only +-r _requirements_base.txt +tensorflow-macos>=2.10.0,<2.11.0 +tensorflow-deps>=2.10.0,<2.11.0 +tensorflow-metal>=0.6.0,<0.7.0 # These next 2 should have been installed, but some users complain of errors decorator cloudpickle diff --git a/requirements/requirements_cpu.txt b/requirements/requirements_cpu.txt index 52b3315fb6..873e3d3561 100644 --- a/requirements/requirements_cpu.txt +++ b/requirements/requirements_cpu.txt @@ -1,5 +1,2 @@ -r _requirements_base.txt -# Pinned TF probability doesn't work with numpy >= 1.24 -numpy>=1.21.0,<1.24.0; python_version < '3.8' -numpy>=1.22.0,<1.24.0; python_version >= '3.8' -tensorflow-cpu>=2.7.0,<2.11.0 +tensorflow-cpu>=2.10.0,<2.11.0 diff --git a/requirements/requirements_directml.txt b/requirements/requirements_directml.txt index 9c4319caff..d7e0dbc227 100644 --- a/requirements/requirements_directml.txt +++ b/requirements/requirements_directml.txt @@ -1,7 +1,4 @@ -r _requirements_base.txt -# Pinned TF probability doesn't work with numpy >= 1.24 -numpy>=1.21.0,<1.24.0; python_version < '3.8' -numpy>=1.22.0,<1.24.0; python_version >= '3.8' tensorflow-cpu>=2.10.0,<2.11.0 tensorflow-directml-plugin comtypes diff --git a/requirements/requirements_nvidia.txt b/requirements/requirements_nvidia.txt index 829b3a7ac1..f3a0bc933f 100644 --- a/requirements/requirements_nvidia.txt +++ b/requirements/requirements_nvidia.txt @@ -1,6 +1,5 @@ -r _requirements_base.txt -# Pinned TF probability doesn't work with numpy >= 1.24 -numpy>=1.21.0,<1.24.0; python_version < '3.8' -numpy>=1.22.0,<1.24.0; python_version >= '3.8' -tensorflow-gpu>=2.7.0,<2.11.0 +# Exclude badly numbered Python2 version of nvidia-ml-py +nvidia-ml-py>=11.525,<300 pynvx==1.0.0 ; sys_platform == "darwin" +tensorflow>=2.10.0,<2.11.0 diff --git a/requirements/requirements_rocm.txt b/requirements/requirements_rocm.txt index e7bfc6c0a0..b23ce01590 100644 --- a/requirements/requirements_rocm.txt +++ b/requirements/requirements_rocm.txt @@ -1,5 +1,2 @@ -r _requirements_base.txt -# Pinned TF probability doesn't work with numpy >= 1.24 -numpy>=1.21.0,<1.24.0; python_version < '3.8' -numpy>=1.22.0,<1.24.0; python_version >= '3.8' tensorflow-rocm>=2.10.0,<2.11.0 diff --git a/scripts/convert.py b/scripts/convert.py index 374bf7fa52..89cc56bf90 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -26,13 +26,9 @@ from plugins.extract.pipeline import Extractor, ExtractMedia from plugins.plugin_loader import PluginLoader -if sys.version_info < (3, 8): - from typing_extensions import get_args, Literal -else: - from typing import get_args, Literal - if T.TYPE_CHECKING: from argparse import Namespace + from collections.abc import Callable from plugins.convert.writer._base import Output from plugins.train.model._base import ModelBase from lib.align.aligned_face import CenteringType @@ -61,8 +57,8 @@ class ConvertItem: The swapped faces returned from the model's predict function """ inbound: ExtractMedia - feed_faces: T.List[AlignedFace] = field(default_factory=list) - reference_faces: T.List[AlignedFace] = field(default_factory=list) + feed_faces: list[AlignedFace] = field(default_factory=list) + reference_faces: list[AlignedFace] = field(default_factory=list) swapped_faces: np.ndarray = np.array([]) @@ -307,8 +303,8 @@ def __init__(self, # Extractor for on the fly detection self._extractor = self._load_extractor() - self._queues: T.Dict[Literal["load", "save"], EventQueue] = {} - self._threads: T.Dict[Literal["load", "save"], MultiThread] = {} + self._queues: dict[T.Literal["load", "save"], EventQueue] = {} + self._threads: dict[T.Literal["load", "save"], MultiThread] = {} self._init_threads() logger.debug("Initialized %s", self.__class__.__name__) @@ -324,13 +320,13 @@ def draw_transparent(self) -> bool: return self._writer.config.get("draw_transparent", False) @property - def pre_encode(self) -> T.Optional[T.Callable[[np.ndarray], T.List[bytes]]]: + def pre_encode(self) -> Callable[[np.ndarray], list[bytes]] | None: """ python function: Selected writer's pre-encode function, if it has one, otherwise ``None`` """ dummy = np.zeros((20, 20, 3), dtype="uint8") test = self._writer.pre_encode(dummy) - retval: T.Optional[T.Callable[[np.ndarray], - T.List[bytes]]] = None if test is None else self._writer.pre_encode + retval: Callable[[np.ndarray], + list[bytes]] | None = None if test is None else self._writer.pre_encode logger.debug("Writer pre_encode function: %s", retval) return retval @@ -384,7 +380,7 @@ def _get_writer(self) -> Output: return PluginLoader.get_converter("writer", self._args.writer)(*args, configfile=configfile) - def _get_frame_ranges(self) -> T.Optional[T.List[T.Tuple[int, int]]]: + def _get_frame_ranges(self) -> list[tuple[int, int]] | None: """ Obtain the frame ranges that are to be converted. If frame ranges have been specified, then split the command line formatted arguments into @@ -422,7 +418,7 @@ def _get_frame_ranges(self) -> T.Optional[T.List[T.Tuple[int, int]]]: logger.debug("frame ranges: %s", retval) return retval - def _load_extractor(self) -> T.Optional[Extractor]: + def _load_extractor(self) -> Extractor | None: """ Load the CV2-DNN Face Extractor Chain. For On-The-Fly conversion we use a CPU based extractor to avoid stacking the GPU. @@ -467,12 +463,12 @@ def _init_threads(self) -> None: Creates the load and save queues and the load and save threads. Starts the threads. """ logger.debug("Initializing DiskIO Threads") - for task in get_args(Literal["load", "save"]): + for task in T.get_args(T.Literal["load", "save"]): self._add_queue(task) self._start_thread(task) logger.debug("Initialized DiskIO Threads") - def _add_queue(self, task: Literal["load", "save"]) -> None: + def _add_queue(self, task: T.Literal["load", "save"]) -> None: """ Add the queue to queue_manager and to :attr:`self._queues` for the given task. Parameters @@ -490,7 +486,7 @@ def _add_queue(self, task: Literal["load", "save"]) -> None: self._queues[task] = queue_manager.get_queue(q_name) logger.debug("Added queue for task: '%s'", task) - def _start_thread(self, task: Literal["load", "save"]) -> None: + def _start_thread(self, task: T.Literal["load", "save"]) -> None: """ Create the thread for the given task, add it it :attr:`self._threads` and start it. Parameters @@ -571,7 +567,7 @@ def _check_skipframe(self, filename: str) -> bool: logger.trace("idx: %s, skipframe: %s", idx, skipframe) # type: ignore return skipframe - def _get_detected_faces(self, filename: str, image: np.ndarray) -> T.List[DetectedFace]: + def _get_detected_faces(self, filename: str, image: np.ndarray) -> list[DetectedFace]: """ Return the detected faces for the given image. If we have an alignments file, then the detected faces are created from that file. If @@ -597,7 +593,7 @@ def _get_detected_faces(self, filename: str, image: np.ndarray) -> T.List[Detect logger.trace("Got %s faces for: '%s'", len(detected_faces), filename) # type:ignore return detected_faces - def _alignments_faces(self, frame_name: str, image: np.ndarray) -> T.List[DetectedFace]: + def _alignments_faces(self, frame_name: str, image: np.ndarray) -> list[DetectedFace]: """ Return detected faces from an alignments file. Parameters @@ -644,7 +640,7 @@ def _check_alignments(self, frame_name: str) -> bool: tqdm.write(f"No alignment found for {frame_name}, skipping") return have_alignments - def _detect_faces(self, filename: str, image: np.ndarray) -> T.List[DetectedFace]: + def _detect_faces(self, filename: str, image: np.ndarray) -> list[DetectedFace]: """ Extract the face from a frame for On-The-Fly conversion. Pulls detected faces out of the Extraction pipeline. @@ -779,7 +775,7 @@ def output_size(self) -> int: """ int: The size in pixels of the Faceswap model output. """ return self._sizes["output"] - def _get_io_sizes(self) -> T.Dict[str, int]: + def _get_io_sizes(self) -> dict[str, int]: """ Obtain the input size and output size of the model. Returns @@ -896,9 +892,9 @@ def _predict_faces(self) -> None: """ faces_seen = 0 consecutive_no_faces = 0 - batch: T.List[ConvertItem] = [] + batch: list[ConvertItem] = [] while True: - item: T.Union[Literal["EOF"], ConvertItem] = self._in_queue.get() + item: T.Literal["EOF"] | ConvertItem = self._in_queue.get() if item == "EOF": logger.debug("EOF Received") if batch: # Process out any remaining items @@ -938,7 +934,7 @@ def _predict_faces(self) -> None: self._out_queue.put("EOF") logger.debug("Load queue complete") - def _process_batch(self, batch: T.List[ConvertItem], faces_seen: int): + def _process_batch(self, batch: list[ConvertItem], faces_seen: int): """ Predict faces on the given batch of images and queue out to patch thread Parameters @@ -1001,7 +997,7 @@ def load_aligned(self, item: ConvertItem) -> None: logger.trace("Loaded aligned faces: '%s'", item.inbound.filename) # type:ignore @staticmethod - def _compile_feed_faces(feed_faces: T.List[AlignedFace]) -> np.ndarray: + def _compile_feed_faces(feed_faces: list[AlignedFace]) -> np.ndarray: """ Compile a batch of faces for feeding into the Predictor. Parameters @@ -1020,7 +1016,7 @@ def _compile_feed_faces(feed_faces: T.List[AlignedFace]) -> np.ndarray: logger.trace("Compiled Feed faces. Shape: %s", retval.shape) # type:ignore return retval - def _predict(self, feed_faces: np.ndarray, batch_size: T.Optional[int] = None) -> np.ndarray: + def _predict(self, feed_faces: np.ndarray, batch_size: int | None = None) -> np.ndarray: """ Run the Faceswap models' prediction function. Parameters @@ -1045,7 +1041,7 @@ def _predict(self, feed_faces: np.ndarray, batch_size: T.Optional[int] = None) - logger.trace("Input shape(s): %s", [item.shape for item in feed]) # type:ignore inbound = self._model.model.predict(feed, verbose=0, batch_size=batch_size) - predicted: T.List[np.ndarray] = inbound if isinstance(inbound, list) else [inbound] + predicted: list[np.ndarray] = inbound if isinstance(inbound, list) else [inbound] if self._model.color_order.lower() == "rgb": predicted[0] = predicted[0][..., ::-1] @@ -1062,7 +1058,7 @@ def _predict(self, feed_faces: np.ndarray, batch_size: T.Optional[int] = None) - logger.trace("Final shape: %s", retval.shape) # type:ignore return retval - def _queue_out_frames(self, batch: T.List[ConvertItem], swapped_faces: np.ndarray) -> None: + def _queue_out_frames(self, batch: list[ConvertItem], swapped_faces: np.ndarray) -> None: """ Compile the batch back to original frames and put to the Out Queue. For batching, faces are split away from their frames. This compiles all detected faces @@ -1108,7 +1104,7 @@ class OptionalActions(): # pylint:disable=too-few-public-methods """ def __init__(self, arguments: Namespace, - input_images: T.List[np.ndarray], + input_images: list[np.ndarray], alignments: Alignments) -> None: logger.debug("Initializing %s", self.__class__.__name__) self._args = arguments @@ -1131,7 +1127,7 @@ def _remove_skipped_faces(self) -> None: self._alignments.filter_faces(accept_dict, filter_out=False) logger.info("Faces filtered out: %s", pre_face_count - self._alignments.faces_count) - def _get_face_metadata(self) -> T.Dict[str, T.List[int]]: + def _get_face_metadata(self) -> dict[str, list[int]]: """ Check for the existence of an aligned directory for identifying which faces in the target frames should be swapped. If it exists, scan the folder for face's metadata @@ -1140,7 +1136,7 @@ def _get_face_metadata(self) -> T.Dict[str, T.List[int]]: dict Dictionary of source frame names with a list of associated face indices to be skipped """ - retval: T.Dict[str, T.List[int]] = {} + retval: dict[str, list[int]] = {} input_aligned_dir = self._args.input_aligned_dir if input_aligned_dir is None: diff --git a/scripts/extract.py b/scripts/extract.py index f20dbb8f77..44a7434bf6 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -2,13 +2,13 @@ """ Main entry point to the extract process of FaceSwap """ from __future__ import annotations - import logging import os import sys +import typing as T + from argparse import Namespace from multiprocessing import Process -from typing import List, Dict, Optional, Tuple, TYPE_CHECKING, Union import numpy as np from tqdm import tqdm @@ -20,7 +20,7 @@ from plugins.extract.pipeline import Extractor, ExtractMedia from scripts.fsmedia import Alignments, PostProcess, finalize -if TYPE_CHECKING: +if T.TYPE_CHECKING: from lib.align.alignments import PNGHeaderAlignmentsDict # tqdm.monitor_interval = 0 # workaround for TqdmSynchronisationWarning # TODO? @@ -75,7 +75,7 @@ def __init__(self, arguments: Namespace) -> None: self._args.nfilter, self._extractor) - def _get_input_locations(self) -> List[str]: + def _get_input_locations(self) -> list[str]: """ Obtain the full path to input locations. Will be a list of locations if batch mode is selected, or a containing a single location if batch mode is not selected. @@ -194,8 +194,8 @@ class Filter(): """ def __init__(self, threshold: float, - filter_files: Optional[List[str]], - nfilter_files: Optional[List[str]], + filter_files: list[str] | None, + nfilter_files: list[str] | None, extractor: Extractor) -> None: logger.debug("Initializing %s: (threshold: %s, filter_files: %s, nfilter_files: %s " "extractor: %s)", self.__class__.__name__, threshold, filter_files, @@ -208,8 +208,8 @@ def __init__(self, logger.debug("Filter not selected. Exiting %s", self.__class__.__name__) return - self._embeddings: List[np.ndarray] = [np.array([]) for _ in self._filter_files] - self._nembeddings: List[np.ndarray] = [np.array([]) for _ in self._nfilter_files] + self._embeddings: list[np.ndarray] = [np.array([]) for _ in self._filter_files] + self._nembeddings: list[np.ndarray] = [np.array([]) for _ in self._nfilter_files] self._extractor = extractor self._get_embeddings() @@ -243,7 +243,7 @@ def n_embeddings(self) -> np.ndarray: return retval @classmethod - def _files_from_folder(cls, input_location: List[str]) -> List[str]: + def _files_from_folder(cls, input_location: list[str]) -> list[str]: """ Test whether the input location is a folder and if so, return the list of contained image files, otherwise return the original input location @@ -274,8 +274,8 @@ def _files_from_folder(cls, input_location: List[str]) -> List[str]: return retval def _validate_inputs(self, - filter_files: Optional[List[str]], - nfilter_files: Optional[List[str]]) -> Tuple[List[str], List[str]]: + filter_files: list[str] | None, + nfilter_files: list[str] | None) -> tuple[list[str], list[str]]: """ Validates that the given filter/nfilter files exist, are image files and are unique Parameters @@ -293,7 +293,7 @@ def _validate_inputs(self, List of full paths to nfilter files """ error = False - retval: List[List[str]] = [] + retval: list[list[str]] = [] for files in (filter_files, nfilter_files): filt_files = [] if files is None else self._files_from_folder(files) @@ -322,7 +322,7 @@ def _validate_inputs(self, return filters, nfilters @classmethod - def _identity_from_extracted(cls, filename) -> Tuple[np.ndarray, bool]: + def _identity_from_extracted(cls, filename) -> tuple[np.ndarray, bool]: """ Test whether the given image is a faceswap extracted face and contains identity information. If so, return the identity embedding @@ -404,7 +404,7 @@ def _process_extracted(self, item: ExtractMedia) -> None: embeddings[idx] = identities return - def _identity_from_extractor(self, file_list: List[str], aligned: List[str]) -> None: + def _identity_from_extractor(self, file_list: list[str], aligned: list[str]) -> None: """ Obtain the identity embeddings from the extraction pipeline Parameters @@ -425,7 +425,7 @@ def _identity_from_extractor(self, file_list: List[str], aligned: List[str]) -> for phase in range(self._extractor.passes): is_final = self._extractor.final_pass - detected_faces: Dict[str, ExtractMedia] = {} + detected_faces: dict[str, ExtractMedia] = {} self._extractor.launch() desc = "Obtaining reference face Identity" if self._extractor.passes > 1: @@ -450,8 +450,8 @@ def _identity_from_extractor(self, file_list: List[str], aligned: List[str]) -> def _get_embeddings(self) -> None: """ Obtain the embeddings for the given filter lists """ - needs_extraction: List[str] = [] - aligned: List[str] = [] + needs_extraction: list[str] = [] + aligned: list[str] = [] for files, embed in zip((self._filter_files, self._nfilter_files), (self._embeddings, self._nembeddings)): @@ -494,14 +494,14 @@ class PipelineLoader(): image files that exist in :attr:`path` that are aligned faceswap images """ def __init__(self, - path: Union[str, List[str]], + path: str | list[str], extractor: Extractor, - aligned_filenames: Optional[List[str]] = None) -> None: + aligned_filenames: list[str] | None = None) -> None: logger.debug("Initializing %s: (path: %s, extractor: %s, aligned_filenames: %s)", self.__class__.__name__, path, extractor, aligned_filenames) self._images = ImagesLoader(path, fast_count=True) self._extractor = extractor - self._threads: List[MultiThread] = [] + self._threads: list[MultiThread] = [] self._aligned_filenames = [] if aligned_filenames is None else aligned_filenames logger.debug("Initialized %s", self.__class__.__name__) @@ -512,7 +512,7 @@ def is_video(self) -> bool: return self._images.is_video @property - def file_list(self) -> List[str]: + def file_list(self) -> list[str]: """ list: A full list of files in the source location. If the input is a video then this is a list of dummy filenames as corresponding to an alignments file """ return self._images.file_list @@ -523,7 +523,7 @@ def process_count(self) -> int: items that are to be skipped from the :attr:`skip_list`)""" return self._images.process_count - def add_skip_list(self, skip_list: List[int]) -> None: + def add_skip_list(self, skip_list: list[int]) -> None: """ Add a skip list to the :class:`ImagesLoader` Parameters @@ -538,7 +538,7 @@ def launch(self) -> None: """ Launch the image loading pipeline """ self._threaded_redirector("load") - def reload(self, detected_faces: Dict[str, ExtractMedia]) -> None: + def reload(self, detected_faces: dict[str, ExtractMedia]) -> None: """ Reload images for multiple pipeline passes """ self._threaded_redirector("reload", (detected_faces, )) @@ -552,7 +552,7 @@ def join(self) -> None: for thread in self._threads: thread.join() - def _threaded_redirector(self, task: str, io_args: Optional[tuple] = None) -> None: + def _threaded_redirector(self, task: str, io_args: tuple | None = None) -> None: """ Redirect image input/output tasks to relevant queues in background thread Parameters @@ -587,7 +587,7 @@ def _load(self) -> None: load_queue.put("EOF") logger.debug("Load Images: Complete") - def _reload(self, detected_faces: Dict[str, ExtractMedia]) -> None: + def _reload(self, detected_faces: dict[str, ExtractMedia]) -> None: """ Reload the images and pair to detected face When the extraction pipeline is running in serial mode, images are reloaded from disk, @@ -652,7 +652,7 @@ def __init__(self, logger.debug("Initialized %s", self.__class__.__name__) @property - def _save_interval(self) -> Optional[int]: + def _save_interval(self) -> int | None: """ int: The number of frames to be processed between each saving of the alignments file if it has been provided, otherwise ``None`` """ if hasattr(self._args, "save_interval"): @@ -718,7 +718,7 @@ def _run_extraction(self) -> None: as_bytes=True) for phase in range(self._extractor.passes): is_final = self._extractor.final_pass - detected_faces: Dict[str, ExtractMedia] = {} + detected_faces: dict[str, ExtractMedia] = {} self._extractor.launch() self._loader.check_thread_error() ph_desc = "Extraction" if self._extractor.passes == 1 else self._extractor.phase_text @@ -774,7 +774,7 @@ def _output_processing(self, extract_media: ExtractMedia, size: int) -> None: if not self._verify_output and faces_count > 1: self._verify_output = True - def _output_faces(self, saver: Optional[ImagesSaver], extract_media: ExtractMedia) -> None: + def _output_faces(self, saver: ImagesSaver | None, extract_media: ExtractMedia) -> None: """ Output faces to save thread Set the face filename based on the frame name and put the face to the @@ -798,14 +798,14 @@ def _output_faces(self, saver: Optional[ImagesSaver], extract_media: ExtractMedi output_filename = f"{filename}_{real_face_id}.png" aligned = face.aligned.face assert aligned is not None - meta: PNGHeaderDict = dict( - alignments=face.to_png_meta(), - source=dict(alignments_version=self._alignments.version, - original_filename=output_filename, - face_index=real_face_id, - source_filename=os.path.basename(extract_media.filename), - source_is_video=self._loader.is_video, - source_frame_dims=extract_media.image_size)) + meta: PNGHeaderDict = { + "alignments": face.to_png_meta(), + "source": {"alignments_version": self._alignments.version, + "original_filename": output_filename, + "face_index": real_face_id, + "source_filename": os.path.basename(extract_media.filename), + "source_is_video": self._loader.is_video, + "source_frame_dims": extract_media.image_size}} image = encode_image(aligned, ".png", metadata=meta) sub_folder = extract_media.sub_folders[face_id] @@ -820,6 +820,6 @@ def _output_faces(self, saver: Optional[ImagesSaver], extract_media: ExtractMedi continue final_faces.append(face.to_alignment()) - self._alignments.data[os.path.basename(extract_media.filename)] = dict(faces=final_faces, - video_meta={}) + self._alignments.data[os.path.basename(extract_media.filename)] = {"faces": final_faces, + "video_meta": {}} del extract_media diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 92e95eac58..0b4ea078f8 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -5,12 +5,13 @@ Holds optional pre/post processing functions for convert and extract. """ - +from __future__ import annotations import logging import os import sys -from typing import (Any, cast, Dict, Generator, Iterator, List, - Optional, Tuple, TYPE_CHECKING, Union) +import typing as T + +from collections.abc import Iterator import cv2 import numpy as np @@ -20,7 +21,8 @@ from lib.image import count_frames, read_image from lib.utils import (camel_case_split, get_image_paths, _video_extensions) -if TYPE_CHECKING: +if T.TYPE_CHECKING: + from collections.abc import Generator from argparse import Namespace from lib.align import AlignedFace from plugins.extract.pipeline import ExtractMedia @@ -69,7 +71,7 @@ class Alignments(AlignmentsBase): Default: False """ def __init__(self, - arguments: "Namespace", + arguments: Namespace, is_extract: bool, input_is_video: bool = False) -> None: logger.debug("Initializing %s: (is_extract: %s, input_is_video: %s)", @@ -80,7 +82,7 @@ def __init__(self, super().__init__(folder, filename=filename) logger.debug("Initialized %s", self.__class__.__name__) - def _set_folder_filename(self, input_is_video: bool) -> Tuple[str, str]: + def _set_folder_filename(self, input_is_video: bool) -> tuple[str, str]: """ Return the folder and the filename for the alignments file. If the input is a video, the alignments file will be stored in the same folder @@ -115,7 +117,7 @@ def _set_folder_filename(self, input_is_video: bool) -> Tuple[str, str]: logger.debug("Setting Alignments: (folder: '%s' filename: '%s')", folder, filename) return folder, filename - def _load(self) -> Dict[str, Any]: + def _load(self) -> dict[str, T.Any]: """ Override the parent :func:`~lib.align.Alignments._load` to handle skip existing frames and faces on extract. @@ -128,7 +130,7 @@ def _load(self) -> Dict[str, Any]: Any alignments that have already been extracted if skip existing has been selected otherwise an empty dictionary """ - data: Dict[str, Any] = {} + data: dict[str, T.Any] = {} if not self._is_extract and not self.have_alignments_file: return data if not self._is_extract: @@ -170,7 +172,7 @@ class Images(): arguments: :class:`argparse.Namespace` The command line arguments that were passed to Faceswap """ - def __init__(self, arguments: "Namespace") -> None: + def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s", self.__class__.__name__) self._args = arguments self._is_video = self._check_input_folder() @@ -184,7 +186,7 @@ def is_video(self) -> bool: return self._is_video @property - def input_images(self) -> Union[str, List[str]]: + def input_images(self) -> str | list[str]: """str or list: Path to the video file if the input is a video otherwise list of image paths. """ return self._input_images @@ -228,7 +230,7 @@ def _check_input_folder(self) -> bool: retval = False return retval - def _get_input_images(self) -> Union[str, List[str]]: + def _get_input_images(self) -> str | list[str]: """ Return the list of images or path to video file that is to be processed. Returns @@ -243,7 +245,7 @@ def _get_input_images(self) -> Union[str, List[str]]: return input_images - def load(self) -> Generator[Tuple[str, np.ndarray], None, None]: + def load(self) -> Generator[tuple[str, np.ndarray], None, None]: """ Generator to load frames from a folder of images or from a video file. Yields @@ -257,7 +259,7 @@ def load(self) -> Generator[Tuple[str, np.ndarray], None, None]: for filename, image in iterator(): yield filename, image - def _load_disk_frames(self) -> Generator[Tuple[str, np.ndarray], None, None]: + def _load_disk_frames(self) -> Generator[tuple[str, np.ndarray], None, None]: """ Generator to load frames from a folder of images. Yields @@ -274,7 +276,7 @@ def _load_disk_frames(self) -> Generator[Tuple[str, np.ndarray], None, None]: continue yield filename, image - def _load_video_frames(self) -> Generator[Tuple[str, np.ndarray], None, None]: + def _load_video_frames(self) -> Generator[tuple[str, np.ndarray], None, None]: """ Generator to load frames from a video file. Yields @@ -287,7 +289,7 @@ def _load_video_frames(self) -> Generator[Tuple[str, np.ndarray], None, None]: logger.debug("Input is video. Capturing frames") vidname = os.path.splitext(os.path.basename(self._args.input_dir))[0] reader = imageio.get_reader(self._args.input_dir, "ffmpeg") # type:ignore[arg-type] - for i, frame in enumerate(cast(Iterator[np.ndarray], reader)): + for i, frame in enumerate(T.cast(Iterator[np.ndarray], reader)): # Convert to BGR for cv2 compatibility frame = frame[:, :, ::-1] filename = f"{vidname}_{i + 1:06d}.png" @@ -354,13 +356,13 @@ class PostProcess(): # pylint:disable=too-few-public-methods arguments: :class:`argparse.Namespace` The command line arguments that were passed to Faceswap """ - def __init__(self, arguments: "Namespace") -> None: + def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s", self.__class__.__name__) self._args = arguments self._actions = self._set_actions() logger.debug("Initialized %s", self.__class__.__name__) - def _set_actions(self) -> List["PostProcessAction"]: + def _set_actions(self) -> list[PostProcessAction]: """ Compile the requested actions to be performed into a list Returns @@ -369,7 +371,7 @@ def _set_actions(self) -> List["PostProcessAction"]: The list of :class:`PostProcessAction` to be performed """ postprocess_items = self._get_items() - actions: List["PostProcessAction"] = [] + actions: list["PostProcessAction"] = [] for action, options in postprocess_items.items(): options = {} if options is None else options args = options.get("args", tuple()) @@ -387,7 +389,7 @@ def _set_actions(self) -> List["PostProcessAction"]: return actions - def _get_items(self) -> Dict[str, Optional[Dict[str, Union[tuple, dict]]]]: + def _get_items(self) -> dict[str, dict[str, tuple | dict] | None]: """ Check the passed in command line arguments for requested actions, For any requested actions, add the item to the actions list along with @@ -399,7 +401,7 @@ def _get_items(self) -> Dict[str, Optional[Dict[str, Union[tuple, dict]]]]: The name of the action to be performed as the key. Any action specific arguments and keyword arguments as the value. """ - postprocess_items: Dict[str, Optional[Dict[str, Union[tuple, dict]]]] = {} + postprocess_items: dict[str, dict[str, tuple | dict] | None] = {} # Debug Landmarks if (hasattr(self._args, 'debug_landmarks') and self._args.debug_landmarks): postprocess_items["DebugLandmarks"] = None @@ -407,7 +409,7 @@ def _get_items(self) -> Dict[str, Optional[Dict[str, Union[tuple, dict]]]]: logger.debug("Postprocess Items: %s", postprocess_items) return postprocess_items - def do_actions(self, extract_media: "ExtractMedia") -> None: + def do_actions(self, extract_media: ExtractMedia) -> None: """ Perform the requested optional post-processing actions on the given image. Parameters @@ -451,7 +453,7 @@ def valid(self) -> bool: otherwise ``False`` """ return self._valid - def process(self, extract_media: "ExtractMedia") -> None: + def process(self, extract_media: ExtractMedia) -> None: """ Override for specific post processing action Parameters @@ -487,8 +489,8 @@ def _initialize_font(self, size: int) -> None: def _border_text(self, image: np.ndarray, text: str, - color: Tuple[int, int, int], - position: Tuple[int, int]) -> None: + color: tuple[int, int, int], + position: tuple[int, int]) -> None: """ Create text on an image with a black border Parameters @@ -515,7 +517,7 @@ def _border_text(self, lineType=cv2.LINE_AA) thickness //= 2 - def _annotate_face_box(self, face: "AlignedFace") -> None: + def _annotate_face_box(self, face: AlignedFace) -> None: """ Annotate the face extract box and print the original size in pixels face: :class:`~lib.align.AlignedFace` @@ -543,7 +545,7 @@ def _annotate_face_box(self, face: "AlignedFace") -> None: self._border_text(text_img, text, color, (pos_x, pos_y)) cv2.addWeighted(text_img, 0.75, face.face, 0.25, 0, face.face) - def _print_stats(self, face: "AlignedFace") -> None: + def _print_stats(self, face: AlignedFace) -> None: """ Print various metrics on the output face images Parameters @@ -571,7 +573,7 @@ def _print_stats(self, face: "AlignedFace") -> None: # Apply text to face cv2.addWeighted(text_image, 0.75, face.face, 0.25, 0, face.face) - def process(self, extract_media: "ExtractMedia") -> None: + def process(self, extract_media: ExtractMedia) -> None: """ Draw landmarks on a face. Parameters diff --git a/scripts/train.py b/scripts/train.py index 62be0aa041..ddadba0757 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -1,13 +1,13 @@ #!/usr/bin python3 """ Main entry point to the training process of FaceSwap """ - +from __future__ import annotations import logging import os import sys +import typing as T from time import sleep from threading import Event -from typing import cast, Callable, Dict, List, Optional, TYPE_CHECKING import cv2 import numpy as np @@ -21,13 +21,9 @@ FaceswapError, _image_extensions) from plugins.plugin_loader import PluginLoader -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - -if TYPE_CHECKING: +if T.TYPE_CHECKING: import argparse + from collections.abc import Callable from plugins.train.model._base import ModelBase from plugins.train.trainer._base import TrainerBase @@ -50,7 +46,7 @@ class Train(): # pylint:disable=too-few-public-methods The arguments to be passed to the training process as generated from Faceswap's command line arguments """ - def __init__(self, arguments: "argparse.Namespace") -> None: + def __init__(self, arguments: argparse.Namespace) -> None: logger.debug("Initializing %s: (args: %s", self.__class__.__name__, arguments) self._args = arguments self._handle_deprecations() @@ -63,9 +59,9 @@ def __init__(self, arguments: "argparse.Namespace") -> None: self._timelapse = self._set_timelapse() gui_cache = os.path.join( os.path.realpath(os.path.dirname(sys.argv[0])), "lib", "gui", ".cache") - self._gui_triggers: Dict[Literal["mask", "refresh"], str] = dict( - mask=os.path.join(gui_cache, ".preview_mask_toggle"), - refresh=os.path.join(gui_cache, ".preview_trigger")) + self._gui_triggers: dict[T.Literal["mask", "refresh"], str] = { + "mask": os.path.join(gui_cache, ".preview_mask_toggle"), + "refresh": os.path.join(gui_cache, ".preview_trigger")} self._stop: bool = False self._save_now: bool = False self._preview = PreviewInterface(self._args.preview) @@ -76,7 +72,7 @@ def _handle_deprecations(self) -> None: """ Handle the update of deprecated arguments and output warnings. """ return - def _get_images(self) -> Dict[Literal["a", "b"], List[str]]: + def _get_images(self) -> dict[T.Literal["a", "b"], list[str]]: """ Check the image folders exist and contains valid extracted faces. Obtain image paths. Returns @@ -88,7 +84,7 @@ def _get_images(self) -> Dict[Literal["a", "b"], List[str]]: logger.debug("Getting image paths") images = {} for side in ("a", "b"): - side = cast(Literal["a", "b"], side) + side = T.cast(T.Literal["a", "b"], side) image_dir = getattr(self._args, f"input_{side}") if not os.path.isdir(image_dir): logger.error("Error: '%s' does not exist", image_dir) @@ -117,7 +113,7 @@ def _get_images(self) -> Dict[Literal["a", "b"], List[str]]: return images @classmethod - def _validate_image_counts(cls, images: Dict[Literal["a", "b"], List[str]]) -> None: + def _validate_image_counts(cls, images: dict[T.Literal["a", "b"], list[str]]) -> None: """ Validate that there are sufficient images to commence training without raising an error. @@ -145,7 +141,7 @@ def _validate_image_counts(cls, images: Dict[Literal["a", "b"], List[str]]) -> N "Results are likely to be poor.") logger.warning(msg) - def _set_timelapse(self) -> Dict[Literal["input_a", "input_b", "output"], str]: + def _set_timelapse(self) -> dict[T.Literal["input_a", "input_b", "output"], str]: """ Set time-lapse paths if requested. Returns @@ -168,7 +164,7 @@ def _set_timelapse(self) -> Dict[Literal["input_a", "input_b", "output"], str]: timelapse_output = get_folder(self._args.timelapse_output) for side in ("a", "b"): - side = cast(Literal["a", "b"], side) + side = T.cast(T.Literal["a", "b"], side) folder = getattr(self._args, f"timelapse_input_{side}") if folder is not None and not os.path.isdir(folder): raise FaceswapError(f"The Timelapse path '{folder}' does not exist") @@ -190,10 +186,10 @@ def _set_timelapse(self) -> Dict[Literal["input_a", "input_b", "output"], str]: raise FaceswapError(f"All images in the Timelapse folder '{folder}' must exist in " f"the training folder '{training_folder}'") - TKey = Literal["input_a", "input_b", "output"] - kwargs = {cast(TKey, "input_a"): self._args.timelapse_input_a, - cast(TKey, "input_b"): self._args.timelapse_input_b, - cast(TKey, "output"): timelapse_output} + TKey = T.Literal["input_a", "input_b", "output"] + kwargs = {T.cast(TKey, "input_a"): self._args.timelapse_input_a, + T.cast(TKey, "input_b"): self._args.timelapse_input_b, + T.cast(TKey, "output"): timelapse_output} logger.debug("Timelapse enabled: %s", kwargs) return kwargs @@ -274,7 +270,7 @@ def _training(self) -> None: except Exception as err: raise err - def _load_model(self) -> "ModelBase": + def _load_model(self) -> ModelBase: """ Load the model requested for training. Returns @@ -284,7 +280,7 @@ def _load_model(self) -> "ModelBase": """ logger.debug("Loading Model") model_dir = get_folder(self._args.model_dir) - model: "ModelBase" = PluginLoader.get_model(self._args.trainer)( + model: ModelBase = PluginLoader.get_model(self._args.trainer)( model_dir, self._args, predict=False) @@ -292,7 +288,7 @@ def _load_model(self) -> "ModelBase": logger.debug("Loaded Model") return model - def _load_trainer(self, model: "ModelBase") -> "TrainerBase": + def _load_trainer(self, model: ModelBase) -> TrainerBase: """ Load the trainer requested for training. Parameters @@ -307,14 +303,14 @@ def _load_trainer(self, model: "ModelBase") -> "TrainerBase": """ logger.debug("Loading Trainer") base = PluginLoader.get_trainer(model.trainer) - trainer: "TrainerBase" = base(model, - self._images, - self._args.batch_size, - self._args.configfile) + trainer: TrainerBase = base(model, + self._images, + self._args.batch_size, + self._args.configfile) logger.debug("Loaded Trainer") return trainer - def _run_training_cycle(self, model: "ModelBase", trainer: "TrainerBase") -> None: + def _run_training_cycle(self, model: ModelBase, trainer: TrainerBase) -> None: """ Perform the training cycle. Handles the background training, updating previews/time-lapse on each save interval, @@ -330,7 +326,7 @@ def _run_training_cycle(self, model: "ModelBase", trainer: "TrainerBase") -> Non logger.debug("Running Training Cycle") update_preview_images = False if self._args.write_image or self._args.redirect_gui or self._args.preview: - display_func: Optional[Callable] = self._show + display_func: Callable | None = self._show else: display_func = None @@ -411,7 +407,7 @@ def _check_keypress(self, keypress: KBHit) -> bool: self._save_now = True return retval - def _process_gui_triggers(self) -> Dict[Literal["mask", "refresh"], bool]: + def _process_gui_triggers(self) -> dict[T.Literal["mask", "refresh"], bool]: """ Check whether a file drop has occurred from the GUI to manually update the preview. Returns @@ -419,7 +415,8 @@ def _process_gui_triggers(self) -> Dict[Literal["mask", "refresh"], bool]: dict The trigger name as key and boolean as value """ - retval: Dict[Literal["mask", "refresh"], bool] = {key: False for key in self._gui_triggers} + retval: dict[T.Literal["mask", "refresh"], bool] = {key: False + for key in self._gui_triggers} if not self._args.redirect_gui: return retval @@ -527,11 +524,11 @@ class PreviewInterface(): """ def __init__(self, use_preview: bool) -> None: self._active = use_preview - self._triggers: TriggerType = dict(toggle_mask=Event(), - refresh=Event(), - save=Event(), - quit=Event(), - shutdown=Event()) + self._triggers: TriggerType = {"toggle_mask": Event(), + "refresh": Event(), + "save": Event(), + "quit": Event(), + "shutdown": Event()} self._buffer = PreviewBuffer() self._thread = self._launch_thread() @@ -596,7 +593,7 @@ def should_quit(self) -> bool: logger.debug("Sending should stop") return retval - def _launch_thread(self) -> Optional[FSThread]: + def _launch_thread(self) -> FSThread | None: """ Launch the preview viewer in it's own thread if preview has been selected Returns @@ -609,7 +606,7 @@ def _launch_thread(self) -> Optional[FSThread]: thread = FSThread(target=Preview, name="preview", args=(self._buffer, ), - kwargs=dict(triggers=self._triggers)) + kwargs={"triggers": self._triggers}) thread.start() return thread diff --git a/setup.cfg b/setup.cfg index 7dc0260826..6427fca936 100644 --- a/setup.cfg +++ b/setup.cfg @@ -49,8 +49,6 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-tensorflow.*] ignore_missing_imports = True -[mypy-tensorflow_probability.*] -ignore_missing_imports = True [mypy-tqdm.*] ignore_missing_imports = True [mypy-win32console.*] diff --git a/setup.py b/setup.py index e711f0d815..d17f4c8adb 100755 --- a/setup.py +++ b/setup.py @@ -11,43 +11,44 @@ import os import re import sys +import typing as T from shutil import which from subprocess import list2cmdline, PIPE, Popen, run, STDOUT -from typing import Any, Dict, List, Optional, Set, Tuple, Type -from pkg_resources import parse_requirements, Requirement +from pkg_resources import parse_requirements from lib.logger import log_setup -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - - logger = logging.getLogger(__name__) # pylint: disable=invalid-name +backend_type: T.TypeAlias = T.Literal['nvidia', 'apple_silicon', 'directml', 'cpu', 'rocm'] + _INSTALL_FAILED = False +# Packages that are explicitly required for setup.py +_INSTALLER_REQUIREMENTS: list[tuple[str, str]] = [("pexpect>=4.8.0", "!Windows"), + ("pywinpty==2.0.2", "Windows")] +# Conda packages that are required for a specific backend +_BACKEND_SPECIFIC_CONDA: dict[backend_type, list[str]] = {"nvidia": ["cudatoolkit", "cudnn"], + "apple_silicon": ["libblas"]} +# Packages that should only be installed through pip +_FORCE_PIP: dict[backend_type, list[str]] = {"nvidia": ["tensorflow"]} # Revisions of tensorflow GPU and cuda/cudnn requirements. These relate specifically to the # Tensorflow builds available from pypi -_TENSORFLOW_REQUIREMENTS = {">=2.7.0,<2.11.0": ["11.2", "8.1"]} +_TENSORFLOW_REQUIREMENTS = {">=2.10.0,<2.11.0": [">=11.0,<12.0", ">=8.0,<9.0"]} # ROCm min/max version requirements for Tensorflow _TENSORFLOW_ROCM_REQUIREMENTS = {">=2.10.0,<2.11.0": ((5, 2, 0), (5, 4, 0))} # TODO tensorflow-metal versioning -# Packages that are explicitly required for setup.py -_INSTALLER_REQUIREMENTS: List[Tuple[str, str]] = [("pexpect>=4.8.0", "!Windows"), - ("pywinpty==2.0.2", "Windows")] # Mapping of Python packages to their conda names if different from pip or in non-default channel -_CONDA_MAPPING: Dict[str, Tuple[str, str]] = { - # "opencv-python": ("opencv", "conda-forge"), # Periodic issues with conda-forge opencv +_CONDA_MAPPING: dict[str, tuple[str, str]] = { "fastcluster": ("fastcluster", "conda-forge"), + "ffmpy": ("ffmpy", "conda-forge"), "imageio-ffmpeg": ("imageio-ffmpeg", "conda-forge"), - "scikit-learn": ("scikit-learn", "conda-forge"), # Exists in Default but is dependency hell + "nvidia-ml-py": ("nvidia-ml-py", "conda-forge"), "tensorflow-deps": ("tensorflow-deps", "apple"), "libblas": ("libblas", "conda-forge")} -# Packages that should be installed first to prevent version conflicts -_PRIORITY = ["numpy"] +# Force output to utf-8 +sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type:ignore[attr-defined] class Environment(): @@ -66,11 +67,10 @@ def __init__(self, updater: bool = False) -> None: self.updater = updater # Flag that setup is being run by installer so steps can be skipped self.is_installer: bool = False - self.backend: Optional[Literal["nvidia", "apple_silicon", - "directml", "cpu", "rocm"]] = None + self.backend: backend_type | None = None self.enable_docker: bool = False self.cuda_cudnn = ["", ""] - self.rocm_version: Tuple[int, ...] = (0, 0, 0) + self.rocm_version: tuple[int, ...] = (0, 0, 0) self._process_arguments() self._check_permission() @@ -88,12 +88,12 @@ def encoding(self) -> str: return locale.getpreferredencoding() @property - def os_version(self) -> Tuple[str, str]: + def os_version(self) -> tuple[str, str]: """ Get OS Version """ return platform.system(), platform.release() @property - def py_version(self) -> Tuple[str, str]: + def py_version(self) -> tuple[str, str]: """ Get Python Version """ return platform.python_version(), platform.architecture()[0] @@ -181,8 +181,8 @@ def _check_python(self) -> None: if self.updater: return - if not ((3, 7) <= sys.version_info < (3, 10) and self.py_version[1] == "64bit"): - logger.error("Please run this script with Python version 3.7 to 3.9 64bit and try " + if not ((3, 10) <= sys.version_info < (3, 11) and self.py_version[1] == "64bit"): + logger.error("Please run this script with Python version 3.10 64bit and try " "again.") sys.exit(1) @@ -292,22 +292,16 @@ class Packages(): """ def __init__(self, environment: Environment) -> None: self._env = environment - self._conda_required_packages: List[Tuple[str, ...]] = [("tk", )] - if self._env.os_version[0] == "Linux": - # TODO Put these kind of dependencies somewhere more visible or remove when not needed - # conda-forge scipy requires GLIBCXX_3.4.30. Some Linux install do not have the - # specific version, so we install it just in case. - # Ref: https://forum.faceswap.dev/viewtopic.php?f=7&t=2247 - self._conda_required_packages.append(("gcc=12.1.0", "conda-forge")) - + self._conda_required_packages: list[tuple[str, ...]] = [("tk", ), ("git", )] + self._update_backend_specific_conda() self._installed_packages = self._get_installed_packages() self._conda_installed_packages = self._get_installed_conda_packages() - self._required_packages: List[Tuple[str, List[Tuple[str, str]]]] = [] - self._missing_packages: List[Tuple[str, List[Tuple[str, str]]]] = [] - self._conda_missing_packages: List[Tuple[str, ...]] = [] + self._required_packages: list[tuple[str, list[tuple[str, str]]]] = [] + self._missing_packages: list[tuple[str, list[tuple[str, str]]]] = [] + self._conda_missing_packages: list[tuple[str, ...]] = [] @property - def prerequisites(self) -> List[Tuple[str, List[Tuple[str, str]]]]: + def prerequisites(self) -> list[tuple[str, list[tuple[str, str]]]]: """ list: Any required packages that the installer needs prior to installing the faceswap environment on the specific platform that are not already installed """ all_installed = self._all_installed_packages @@ -328,23 +322,46 @@ def packages_need_install(self) -> bool: return bool(self._missing_packages or self._conda_missing_packages) @property - def to_install(self) -> List[Tuple[str, List[Tuple[str, str]]]]: + def to_install(self) -> list[tuple[str, list[tuple[str, str]]]]: """ list: The required packages that need to be installed """ return self._missing_packages @property - def to_install_conda(self) -> List[Tuple[str, ...]]: + def to_install_conda(self) -> list[tuple[str, ...]]: """ list: The required conda packages that need to be installed """ return self._conda_missing_packages @property - def _all_installed_packages(self) -> Dict[str, str]: + def _all_installed_packages(self) -> dict[str, str]: """ dict[str, str]: The package names and version string for all installed packages across pip and conda """ return {**self._installed_packages, **self._conda_installed_packages} + def _update_backend_specific_conda(self) -> None: + """ Add backend specific packages to Conda required packages """ + assert self._env.backend is not None + to_add = _BACKEND_SPECIFIC_CONDA.get(self._env.backend) + if not to_add: + logger.debug("No backend packages to add for '%s'. All optional packages: %s", + self._env.backend, _BACKEND_SPECIFIC_CONDA) + return + for pkg in to_add: + pkg, channel = _CONDA_MAPPING.get(pkg, (pkg, "")) + if pkg in ("cudatoolkit", "cudnn"): # TODO Handle multiple cuda/cudnn requirements + idx = 0 if pkg == "cudatoolkit" else 1 + pkg = f"{pkg}{list(_TENSORFLOW_REQUIREMENTS.values())[0][idx]}" + if pkg.startswith("cudnn"): + # We add cudnn first so that dependency resolver does not need to re-download cuda + # if an incompatible version was installed + self._conda_required_packages.insert(0, (pkg, channel)) + else: + self._conda_required_packages.append((pkg, channel)) + logger.debug("Adding conda required package '%s' for backend '%s')", + pkg, self._env.backend) + @classmethod - def _format_requirements(cls, packages: List[str]) -> List[Tuple[str, List[Tuple[str, str]]]]: + def _format_requirements(cls, packages: list[str] + ) -> list[tuple[str, list[tuple[str, str]]]]: """ Parse a list of requirements.txt formatted package strings to a list of pkgresource formatted requirements """ return [(package.unsafe_name, package.specs) @@ -353,7 +370,7 @@ def _format_requirements(cls, packages: List[str]) -> List[Tuple[str, List[Tuple @classmethod def _validate_spec(cls, - required: List[Tuple[str, str]], + required: list[tuple[str, str]], existing: str) -> bool: """ Validate whether the required specification for a package is met by the installed version. @@ -377,7 +394,7 @@ def _validate_spec(cls, [int(s) for s in spec[1].split(".")]) for spec in required) - def _get_installed_packages(self) -> Dict[str, str]: + def _get_installed_packages(self) -> dict[str, str]: """ Get currently installed packages and add to :attr:`_installed_packages` Returns @@ -398,7 +415,7 @@ def _get_installed_packages(self) -> Dict[str, str]: logger.debug(installed_packages) return installed_packages - def _get_installed_conda_packages(self) -> Dict[str, str]: + def _get_installed_conda_packages(self) -> dict[str, str]: """ Get currently installed conda packages Returns @@ -439,31 +456,32 @@ def _update_tf_dep_nvidia(self) -> None: if self._env.is_conda: # Conda handles Cuda and cuDNN so nothing to do here return tf_ver = None - cudnn_inst = self._env.cudnn_version.split(".") + cuda_inst = self._env.cuda_version + cudnn_inst = self._env.cudnn_version + if len(cudnn_inst) == 1: # Sometimes only major version is reported + cudnn_inst = f"{cudnn_inst}.0" for key, val in _TENSORFLOW_REQUIREMENTS.items(): - cuda_req = val[0] - cudnn_req = val[1].split(".") - if cuda_req == self._env.cuda_version and (cudnn_req[0] == cudnn_inst[0] and - cudnn_req[1] <= cudnn_inst[1]): + cuda_req = next(parse_requirements(f"cuda{val[0]}")).specs + cudnn_req = next(parse_requirements(f"cudnn{val[1]}")).specs + if (self._validate_spec(cuda_req, cuda_inst) + and self._validate_spec(cudnn_req, cudnn_inst)): tf_ver = key break + if tf_ver: # Remove the version of tensorflow in requirements file and add the correct version # that corresponds to the installed Cuda/cuDNN versions self._required_packages = [pkg for pkg in self._required_packages - if not pkg[0].startswith("tensorflow-gpu")] - tf_ver = f"tensorflow-gpu{tf_ver}" - - tf_ver = f"tensorflow-gpu{tf_ver}" - self._required_packages.append(("tensorflow-gpu", - next(parse_requirements(tf_ver)).specs)) + if pkg[0] != "tensorflow"] + tf_ver = f"tensorflow{tf_ver}" + self._required_packages.append(("tensorflow", next(parse_requirements(tf_ver)).specs)) return logger.warning( - "The minimum Tensorflow requirement is 2.8 \n" + "The minimum Tensorflow requirement is 2.10 \n" "Tensorflow currently has no official prebuild for your CUDA, cuDNN combination.\n" "Either install a combination that Tensorflow supports or build and install your own " - "tensorflow-gpu.\r\n" + "tensorflow.\r\n" "CUDA Version: %s\r\n" "cuDNN Version: %s\r\n" "Help:\n" @@ -472,8 +490,8 @@ def _update_tf_dep_nvidia(self) -> None: "https://www.tensorflow.org/install/source#tested_build_configurations", self._env.cuda_version, self._env.cudnn_version) - custom_tf = input("Location of custom tensorflow-gpu wheel (leave " - "blank to manually install): ") + custom_tf = input("Location of custom tensorflow wheel (leave blank to manually " + "install): ") if not custom_tf: return @@ -529,14 +547,16 @@ def _check_conda_missing_dependencies(self) -> None: if not self._env.is_conda: return for pkg in self._conda_required_packages: - key = pkg[0].split("==", maxsplit=1)[0] + reqs = next(parse_requirements(pkg[0])) # TODO Handle '=' vs '==' for conda + key = reqs.unsafe_name + specs = reqs.specs + if key not in self._conda_installed_packages: self._conda_missing_packages.append(pkg) continue - if len(pkg[0].split("==")) > 1: - if pkg[0].split("==")[1] != self._conda_installed_packages.get(key): - self._conda_missing_packages.append(pkg) - continue + + if not self._validate_spec(specs, self._conda_installed_packages[key]): + self._conda_missing_packages.append(pkg) logger.debug(self._conda_missing_packages) def check_missing_dependencies(self) -> None: @@ -554,12 +574,6 @@ def check_missing_dependencies(self) -> None: if not self._validate_spec(specs, self._all_installed_packages.get(key, "")): self._missing_packages.append((key, specs)) - for priority in reversed(_PRIORITY): - # Put priority packages at beginning of list - package = next((pkg for pkg in self._missing_packages if pkg[0] == priority), None) - if package: - idx = self._missing_packages.index(package) - self._missing_packages.insert(0, self._missing_packages.pop(idx)) logger.debug(self._missing_packages) self._check_conda_missing_dependencies() @@ -732,7 +746,7 @@ class ROCmCheck(): # pylint:disable=too-few-public-methods def __init__(self) -> None: self.version_min = min(v[0] for v in _TENSORFLOW_ROCM_REQUIREMENTS.values()) self.version_max = max(v[1] for v in _TENSORFLOW_ROCM_REQUIREMENTS.values()) - self.rocm_version: Tuple[int, ...] = (0, 0, 0) + self.rocm_version: tuple[int, ...] = (0, 0, 0) if platform.system() == "Linux": self._rocm_check() @@ -771,15 +785,15 @@ class CudaCheck(): # pylint:disable=too-few-public-methods """ Find the location of system installed Cuda and cuDNN on Windows and Linux. """ def __init__(self) -> None: - self.cuda_path: Optional[str] = None - self.cuda_version: Optional[str] = None - self.cudnn_version: Optional[str] = None + self.cuda_path: str | None = None + self.cuda_version: str | None = None + self.cudnn_version: str | None = None self._os: str = platform.system().lower() - self._cuda_keys: List[str] = [key + self._cuda_keys: list[str] = [key for key in os.environ if key.lower().startswith("cuda_path_v")] - self._cudnn_header_files: List[str] = ["cudnn_version.h", "cudnn.h"] + self._cudnn_header_files: list[str] = ["cudnn_version.h", "cudnn.h"] logger.debug("cuda keys: %s, cudnn header files: %s", self._cuda_keys, self._cudnn_header_files) if self._os in ("windows", "linux"): @@ -839,13 +853,14 @@ def _cuda_check_windows(self) -> None: self.cuda_version = self._cuda_keys[0].lower().replace("cuda_path_v", "").replace("_", ".") self.cuda_path = os.environ[self._cuda_keys[0][0]] - def _cudnn_check(self): - """ Check Linux or Windows cuDNN Version from cudnn.h and add to :attr:`cudnn_version`. """ + def _cudnn_check_files(self) -> bool: + """ Check header files for cuDNN version """ cudnn_checkfiles = getattr(self, f"_get_checkfiles_{self._os}")() cudnn_checkfile = next((hdr for hdr in cudnn_checkfiles if os.path.isfile(hdr)), None) logger.debug("cudnn checkfiles: %s", cudnn_checkfile) if not cudnn_checkfile: - return + return False + found = 0 with open(cudnn_checkfile, "r", encoding="utf8") as ofile: for line in ofile: @@ -860,12 +875,31 @@ def _cudnn_check(self): found += 1 if found == 3: break - if found != 3: # Full version could not be determined - return + if found != 3: # Full version not determined + return False + self.cudnn_version = ".".join([str(major), str(minor), str(patchlevel)]) logger.debug("cudnn version: %s", self.cudnn_version) + return True + + def _cudnn_check(self) -> None: + """ Check Linux or Windows cuDNN Version from cudnn.h and add to :attr:`cudnn_version`. """ + if self._cudnn_check_files(): + return + if self._os == "windows": + return + + chk = os.popen("ldconfig -p | grep -P \"libcudnn.so.\" | head -n 1").read() + if not chk: + return + cudnnvers = chk.strip().replace("libcudnn.so.", "").split()[0] + if not cudnnvers: + return + + self.cudnn_version = cudnnvers + logger.debug("cudnn version: %s", self.cudnn_version) - def _get_checkfiles_linux(self) -> List[str]: + def _get_checkfiles_linux(self) -> list[str]: """ Return the the files to check for cuDNN locations for Linux by querying the dynamic link loader. @@ -887,7 +921,7 @@ def _get_checkfiles_linux(self) -> List[str]: cudnn_checkfiles = [os.path.join(cudnn_path, header) for header in header_files] return cudnn_checkfiles - def _get_checkfiles_windows(self) -> List[str]: + def _get_checkfiles_windows(self) -> list[str]: """ Return the check-file locations for Windows. Just looks inside the include folder of the discovered :attr:`cuda_path` @@ -921,7 +955,7 @@ def __init__(self, environment: Environment, is_gui: bool = False) -> None: self._is_gui = is_gui if self._env.os_version[0] == "Windows": - self._installer: Type[Installer] = WinPTYInstaller + self._installer: type[Installer] = WinPTYInstaller else: self._installer = PexpectInstaller @@ -964,7 +998,7 @@ def _ask_continue(self) -> None: sys.exit(1) @classmethod - def _format_package(cls, package: str, version: List[Tuple[str, str]]) -> str: + def _format_package(cls, package: str, version: list[tuple[str, str]]) -> str: """ Format a parsed requirement package and version string to a format that can be used by the installer. @@ -1006,51 +1040,37 @@ def _install_setup_packages(self) -> None: logger.error("Unable to install package: %s. Process aborted", clean_pkg) sys.exit(1) - def _install_missing_dep(self) -> None: - """ Install missing dependencies """ - self._install_conda_packages() # Install conda packages first - self._install_python_packages() + def _install_conda_packages(self) -> None: + """ Install required conda packages """ + logger.info("Installing Required Conda Packages. This may take some time...") + for pkg in self._packages.to_install_conda: + channel = "" if len(pkg) != 2 else pkg[1] + self._from_conda(pkg[0], channel=channel, conda_only=True) def _install_python_packages(self) -> None: """ Install required pip packages """ conda_only = False + assert self._env.backend is not None for pkg, version in self._packages.to_install: if self._env.is_conda: mapping = _CONDA_MAPPING.get(pkg, (pkg, "")) - channel = None if mapping[1] == "" else mapping[1] + channel = "" if mapping[1] is None else mapping[1] pkg = mapping[0] + pip_only = pkg in _FORCE_PIP.get(self._env.backend, []) pkg = self._format_package(pkg, version) if version else pkg - if self._env.is_conda: - if pkg.startswith("tensorflow-gpu"): - # From TF 2.4 onwards, Anaconda Tensorflow becomes a mess. The version of 2.5 - # installed by Anaconda is compiled against an incorrect numpy version which - # breaks Tensorflow. Coupled with this the versions of cudatoolkit and cudnn - # available in the default Anaconda channel are not compatible with the - # official PyPi versions of Tensorflow. With this in mind we will pull in the - # required Cuda/cuDNN from conda-forge, and install Tensorflow with pip - # TODO Revert to Conda if they get their act together - - # Rewrite tensorflow requirement to versions from highest available cuda/cudnn - highest_cuda = sorted(_TENSORFLOW_REQUIREMENTS.values())[-1] - compat_tf = next(k for k, v in _TENSORFLOW_REQUIREMENTS.items() - if v == highest_cuda) - pkg = f"tensorflow-gpu{compat_tf}" - conda_only = True - + if self._env.is_conda and not pip_only: if self._from_conda(pkg, channel=channel, conda_only=conda_only): continue self._from_pip(pkg) - def _install_conda_packages(self) -> None: - """ Install required conda packages """ - logger.info("Installing Required Conda Packages. This may take some time...") - for pkg in self._packages.to_install_conda: - channel = None if len(pkg) != 2 else pkg[1] - self._from_conda(pkg[0], channel=channel, conda_only=True) + def _install_missing_dep(self) -> None: + """ Install missing dependencies """ + self._install_conda_packages() # Install conda packages first + self._install_python_packages() def _from_conda(self, package: str, - channel: Optional[str] = None, + channel: str = "", conda_only: bool = False) -> bool: """ Install a conda package @@ -1059,8 +1079,8 @@ def _from_conda(self, package: str The full formatted package, with version, to be installed channel: str, optional - The Conda channel to install from. Select ``None`` for default channel. - Default: ``None`` + The Conda channel to install from. Select empty string for default channel. + Default: ``""`` (empty string) conda_only: bool, optional ``True`` if the package is only available in Conda. Default: ``False`` @@ -1075,23 +1095,9 @@ def _from_conda(self, if channel: condaexe.extend(["-c", channel]) - if package.startswith("tensorflow-gpu"): - # Here we will install the cuda/cudnn toolkits, currently only available from - # conda-forge, but fail tensorflow itself so that it can be handled by pip. - specs = Requirement.parse(package).specs - for key, val in _TENSORFLOW_REQUIREMENTS.items(): - req_specs = Requirement.parse("foobar" + key).specs - if all(item in req_specs for item in specs): - cuda, cudnn = val - break - condaexe.extend(["-c", "conda-forge", f"cudatoolkit={cuda}", f"cudnn={cudnn}"]) - package = "Cuda Toolkit" - success = False - - if package != "Cuda Toolkit": - if any(char in package for char in (" ", "<", ">", "*", "|")): - package = f"\"{package}\"" - condaexe.append(package) + if any(char in package for char in (" ", "<", ">", "*", "|")): + package = f"\"{package}\"" + condaexe.append(package) clean_pkg = package.replace("\"", "") installer = self._installer(self._env, clean_pkg, condaexe, self._is_gui) @@ -1127,6 +1133,104 @@ def _from_pip(self, package: str) -> None: _INSTALL_FAILED = True +class ProgressBar(): + """ Simple progress bar using STDLib for intercepting Conda installs and keeping the + terminal from getting jumbled """ + def __init__(self): + self._width_desc = 21 + self._width_size = 9 + self._width_bar = 37 + self._width_pct = 4 + self._marker = "█" + + self._cursor_visible = True + self._current_pos = 0 + self._bars = [] + + @classmethod + def _display_cursor(cls, visible: bool) -> None: + """ Sends ANSI code to display or hide the cursor + + Parameters + ---------- + visible: bool + ``True`` to display the cursor. ``False`` to hide the cursor + """ + code = "\x1b[?25h" if visible else "\x1b[?25l" + print(code, end="\r") + + def _format_bar(self, description: str, size: str, percent: int) -> str: + """ Format the progress bar for display + + Parameters + ---------- + description: str + The description to display for the progress bar + size: str + The size of the download, including units + percent: int + The percentage progress of the bar + """ + size = size[:self._width_size].ljust(self._width_size) + bar_len = int(self._width_bar * (percent / 100)) + progress = f"{self._marker * bar_len}"[:self._width_bar].ljust(self._width_bar) + pct = f"{percent}%"[:self._width_pct].rjust(self._width_pct) + return f" {description}| {size} | {progress} | {pct}" + + def _move_cursor(self, position: int) -> str: + """ Generate ANSI code for moving the cursor to the given progress bar's position + + Parameters + ---------- + position: int + The progress bar position to move to + + Returns + ------- + str + The ansi code to move to the given position + """ + move = position - self._current_pos + retval = "\x1b[A" if move < 0 else "\x1b[B" if move > 0 else "" + retval *= abs(move) + return retval + + def __call__(self, description: str, size: str, percent: int) -> None: + """ Create or update a progress bar + + Parameters + ---------- + description: str + The description to display for the progress bar + size: str + The size of the download, including units + percent: int + The percentage progress of the bar + """ + if self._cursor_visible: + self._display_cursor(visible=False) + + desc = description[:self._width_desc].ljust(self._width_desc) + if desc not in self._bars: + self._bars.append(desc) + + position = self._bars.index(desc) + pbar = self._format_bar(desc, size, percent) + + output = f"{self._move_cursor(position)} {pbar}" + + print(output) + self._current_pos = position + 1 + + def close(self) -> None: + """ Reset all progress bars and re-enable the cursor """ + print(self._move_cursor(len(self._bars)), end="\r") + self._display_cursor(True) + self._cursor_visible = True + self._current_pos = 0 + self._bars = [] + + class Installer(): """ Parent class for package installers. @@ -1150,16 +1254,23 @@ class Installer(): def __init__(self, environment: Environment, package: str, - command: List[str], + command: list[str], is_gui: bool) -> None: logger.info("Installing %s", package) logger.debug("argv: %s", command) self._env = environment self._package = package self._command = command + self._is_conda = "conda" in command self._is_gui = is_gui - self._last_line_cr = False - self._seen_lines: Set[str] = set() + + self._progess_bar = ProgressBar() + self._re_conda = re.compile( + rb"(?P^\S+)\s+\|\s+(?P\d+\.?\d*\s\w+).*\|\s+(?P\d+%)") + self._re_pip_pkg = re.compile(rb"^\s*Downloading\s(?P\w+-.+?)-") + self._re_pip = re.compile(rb"(?P\d+\.?\d*)/(?P\d+\.?\d*\s\w+)") + self._pip_pkg = "" + self._seen_lines: set[str] = set() def __call__(self) -> int: """ Call the subclassed call function @@ -1174,9 +1285,11 @@ def __call__(self) -> int: except Exception as err: # pylint:disable=broad-except logger.debug("Failed to install with %s. Falling back to subprocess. Error: %s", self.__class__.__name__, str(err)) + self._progess_bar.close() returncode = SubProcInstaller(self._env, self._package, self._command, self._is_gui)() logger.debug("Package: %s, returncode: %s", self._package, returncode) + self._progess_bar.close() return returncode def call(self) -> int: @@ -1189,19 +1302,57 @@ def call(self) -> int: """ raise NotImplementedError() - def _non_gui_print(self, text: str, end: Optional[str] = None) -> None: + def _print_conda(self, text: bytes) -> None: + """ Output progress for Conda installs + + Parameters + ---------- + text: bytes + The text to print + """ + data = self._re_conda.match(text) + if not data: + return + lib = data.groupdict()["lib"].decode("utf-8", errors="replace") + size = data.groupdict()["tot"].decode("utf-8", errors="replace") + progress = int(data.groupdict()["prg"].decode("utf-8", errors="replace")[:-1]) + self._progess_bar(lib, size, progress) + + def _print_pip(self, text: bytes) -> None: + """ Output progress for Pip installs + + Parameters + ---------- + text: bytes + The text to print + """ + pkg = self._re_pip_pkg.match(text) + if pkg: + logger.debug("Collected pip package '%s'", pkg) + self._pip_pkg = pkg.groupdict()["lib"].decode("utf-8", errors="replace") + return + data = self._re_pip.search(text) + if not data: + return + done = float(data.groupdict()["done"].decode("utf-8", errors="replace")) + size = data.groupdict()["tot"].decode("utf-8", errors="replace") + progress = int(round(done / float(size.split()[0]) * 100, 0)) + self._progess_bar(self._pip_pkg, size, progress) + + def _non_gui_print(self, text: bytes) -> None: """ Print output to console if not running in the GUI Parameters ---------- - text: str + text: bytes The text to print - end: str, optional - The line ending to use. Default: ``None`` (new line) """ if self._is_gui: return - print(text, end=end) + if self._is_conda: + self._print_conda(text) + else: + self._print_pip(text) def _seen_line_log(self, text: str) -> None: """ Output gets spammed to the log file when conda is waiting/processing. Only log each @@ -1214,7 +1365,7 @@ def _seen_line_log(self, text: str) -> None: """ if text in self._seen_lines: return - logger.verbose(text) # type:ignore + logger.debug(text) self._seen_lines.add(text) @@ -1243,22 +1394,13 @@ def call(self) -> int: The return code of the package install process """ import pexpect # pylint:disable=import-outside-toplevel,import-error - proc = pexpect.spawn(" ".join(self._command), - encoding=self._env.encoding, codec_errors="replace", timeout=None) + proc = pexpect.spawn(" ".join(self._command), timeout=None) while True: try: - idx = proc.expect(["\r\n", "\r"]) - line = proc.before.rstrip() - if line and idx == 0: - if self._last_line_cr: - self._last_line_cr = False - # Output last line of progress bar and go to next line - self._non_gui_print(line) - self._seen_line_log(line) - elif line and idx == 1: - self._last_line_cr = True - logger.debug(line) - self._non_gui_print(line, end="\r") + proc.expect([b"\r\n", b"\r"]) + line: bytes = proc.before + self._seen_line_log(line.decode("utf-8", errors="replace").rstrip()) + self._non_gui_print(line) except pexpect.EOF: break proc.close() @@ -1284,7 +1426,7 @@ class WinPTYInstaller(Installer): # pylint: disable=too-few-public-methods def __init__(self, environment: Environment, package: str, - command: List[str], + command: list[str], is_gui: bool) -> None: super().__init__(environment, package, command, is_gui) self._cmd = which(command[0], path=os.environ.get('PATH', os.defpath)) @@ -1295,10 +1437,10 @@ def __init__(self, self._eof = False self._read_bytes = 1024 - self._lines: List[str] = [] + self._lines: list[str] = [] self._out = "" - def _read_from_pty(self, proc: Any, winpty_error: Any) -> None: + def _read_from_pty(self, proc: T.Any, winpty_error: T.Any) -> None: """ Read :attr:`_num_bytes` from WinPTY. If there is an error reading, recursively halve the number of bytes read until we get a succesful read. If we get down to 1 byte without a succesful read, assume we are at EOF. @@ -1350,25 +1492,6 @@ def _out_to_lines(self) -> None: self._out = self._lines[-1] self._lines = self._lines[:-1] - def _parse_lines(self) -> None: - """ Process the latest batch of lines that have been received from winPTY. """ - for line in self._lines: # Dump the output to log - line = line.rstrip() - is_cr = bool(self._pbar.search(line)) - if line and not is_cr: - if self._last_line_cr: - self._last_line_cr = False - if not self._env.is_installer: - # Go to next line - self._non_gui_print("") - self._seen_line_log(line) - elif line: - self._last_line_cr = True - logger.debug(line) - # NSIS only updates on line endings, so force new line for installer - self._non_gui_print(line, end=None if self._env.is_installer else "\r") - self._lines = [] - def call(self) -> int: """ Install a package using the PyWinPTY module @@ -1380,7 +1503,7 @@ def call(self) -> int: import winpty # pylint:disable=import-outside-toplevel,import-error # For some reason with WinPTY we need to pass in the full command. Probably a bug proc = winpty.PTY( - 80 if self._env.is_installer else 100, + 100, 24, backend=winpty.enums.Backend.WinPTY, # ConPTY hangs and has lots of Ansi Escapes agent_config=winpty.enums.AgentConfig.WINPTY_FLAG_PLAIN_OUTPUT) # Strip all Ansi @@ -1392,7 +1515,10 @@ def call(self) -> int: while True: self._read_from_pty(proc, winpty.WinptyError) self._out_to_lines() - self._parse_lines() + for line in self._lines: + self._seen_line_log(line.rstrip()) + self._non_gui_print(line.encode("utf-8", errors="replace")) + self._lines = [] if self._eof: returncode = proc.get_exitstatus() @@ -1422,7 +1548,7 @@ class SubProcInstaller(Installer): def __init__(self, environment: Environment, package: str, - command: List[str], + command: list[str], is_gui: bool) -> None: super().__init__(environment, package, command, is_gui) self._shell = self._env.os_version[0] == "Windows" and command[0] == "conda" @@ -1445,24 +1571,15 @@ def call(self) -> int: bufsize=0, stdout=PIPE, stderr=STDOUT, shell=self._shell) as proc: while True: if proc.stdout is not None: - line = proc.stdout.readline().decode(self._env.encoding, errors="replace") + lines = proc.stdout.readline() returncode = proc.poll() - if line == "" and returncode is not None: + if lines == b"" and returncode is not None: break - is_cr = line.startswith("\r") - line = line.rstrip() - - if line and not is_cr: - if self._last_line_cr: - self._last_line_cr = False - # Go to next line - self._non_gui_print("") - self._seen_line_log(line) - elif line: - self._last_line_cr = True - logger.debug(line) - self._non_gui_print("", end="\r") + for line in lines.split(b"\r"): + self._seen_line_log(line.decode("utf-8", errors="replace").rstrip()) + self._non_gui_print(line) + return returncode @@ -1471,74 +1588,45 @@ class Tips(): @classmethod def docker_no_cuda(cls) -> None: """ Output Tips for Docker without Cuda """ - path = os.path.dirname(os.path.realpath(__file__)) logger.info( - "1. Install Docker\n" - "https://www.docker.com/community-edition\n\n" - "2. Build Docker Image For Faceswap\n" - "docker build -t deepfakes-cpu -f Dockerfile.cpu .\n\n" - "3. Mount faceswap volume and Run it\n" - "# without GUI\n" - "docker run -tid -p 8888:8888 \\ \n" - "\t--hostname deepfakes-cpu --name deepfakes-cpu \\ \n" - "\t-v %s:/srv \\ \n" - "\tdeepfakes-cpu\n\n" - "# with gui. tools.py gui working.\n" - "## enable local access to X11 server\n" - "xhost +local:\n" - "## create container\n" - "nvidia-docker run -tid -p 8888:8888 \\ \n" - "\t--hostname deepfakes-cpu --name deepfakes-cpu \\ \n" - "\t-v %s:/srv \\ \n" - "\t-v /tmp/.X11-unix:/tmp/.X11-unix \\ \n" - "\t-e DISPLAY=unix$DISPLAY \\ \n" - "\t-e AUDIO_GID=`getent group audio | cut -d: -f3` \\ \n" - "\t-e VIDEO_GID=`getent group video | cut -d: -f3` \\ \n" - "\t-e GID=`id -g` \\ \n" - "\t-e UID=`id -u` \\ \n" - "\tdeepfakes-cpu \n\n" - "4. Open a new terminal to run faceswap.py in /srv\n" - "docker exec -it deepfakes-cpu bash", path, path) - logger.info("That's all you need to do with a docker. Have fun.") + "1. Install Docker from: https://www.docker.com/get-started\n\n" + "2. Enter the Faceswap folder and build the Docker Image For Faceswap:\n" + " docker build -t faceswap-cpu -f Dockerfile.cpu .\n\n" + "3. Launch and enter the Faceswap container:\n" + " a. Headless:\n" + " docker run --rm -it -v ./:/srv faceswap-cpu\n\n" + " b. GUI:\n" + " xhost +local: && \\ \n" + " docker run --rm -it \\ \n" + " -v ./:/srv \\ \n" + " -v /tmp/.X11-unix:/tmp/.X11-unix \\ \n" + " -e DISPLAY=${DISPLAY} \\ \n" + " faceswap-cpu \n") + logger.info("That's all you need to do with docker. Have fun.") @classmethod def docker_cuda(cls) -> None: """ Output Tips for Docker with Cuda""" - path = os.path.dirname(os.path.realpath(__file__)) logger.info( - "1. Install Docker\n" - "https://www.docker.com/community-edition\n\n" - "2. Install latest CUDA\n" - "CUDA: https://developer.nvidia.com/cuda-downloads\n\n" - "3. Install Nvidia-Docker & Restart Docker Service\n" - "https://github.com/NVIDIA/nvidia-docker\n\n" - "4. Build Docker Image For Faceswap\n" - "docker build -t deepfakes-gpu -f Dockerfile.gpu .\n\n" - "5. Mount faceswap volume and Run it\n" - "# without gui \n" - "docker run -tid -p 8888:8888 \\ \n" - "\t--hostname deepfakes-gpu --name deepfakes-gpu \\ \n" - "\t-v %s:/srv \\ \n" - "\tdeepfakes-gpu\n\n" - "# with gui.\n" - "## enable local access to X11 server\n" - "xhost +local:\n" - "## enable nvidia device if working under bumblebee\n" - "echo ON > /proc/acpi/bbswitch\n" - "## create container\n" - "nvidia-docker run -tid -p 8888:8888 \\ \n" - "\t--hostname deepfakes-gpu --name deepfakes-gpu \\ \n" - "\t-v %s:/srv \\ \n" - "\t-v /tmp/.X11-unix:/tmp/.X11-unix \\ \n" - "\t-e DISPLAY=unix$DISPLAY \\ \n" - "\t-e AUDIO_GID=`getent group audio | cut -d: -f3` \\ \n" - "\t-e VIDEO_GID=`getent group video | cut -d: -f3` \\ \n" - "\t-e GID=`id -g` \\ \n" - "\t-e UID=`id -u` \\ \n" - "\tdeepfakes-gpu\n\n" - "6. Open a new terminal to interact with the project\n" - "docker exec deepfakes-gpu python /srv/faceswap.py gui\n", - path, path) + "1. Install Docker from: https://www.docker.com/get-started\n\n" + "2. Install latest CUDA 11 and cuDNN 8 from: https://developer.nvidia.com/cuda-" + "downloads\n\n" + "3. Install the the Nvidia Container Toolkit from https://docs.nvidia.com/datacenter/" + "cloud-native/container-toolkit/latest/install-guide\n\n" + "4. Restart Docker Service\n\n" + "5. Enter the Faceswap folder and build the Docker Image For Faceswap:\n" + " docker build -t faceswap-gpu -f Dockerfile.gpu .\n\n" + "6. Launch and enter the Faceswap container:\n" + " a. Headless:\n" + " docker run --runtime=nvidia --rm -it -v ./:/srv faceswap-gpu\n\n" + " b. GUI:\n" + " xhost +local: && \\ \n" + " docker run --runtime=nvidia --rm -it \\ \n" + " -v ./:/srv \\ \n" + " -v /tmp/.X11-unix:/tmp/.X11-unix \\ \n" + " -e DISPLAY=${DISPLAY} \\ \n" + " faceswap-gpu \n") + logger.info("That's all you need to do with docker. Have fun.") @classmethod def macos(cls) -> None: diff --git a/tests/lib/gpu_stats/_base_test.py b/tests/lib/gpu_stats/_base_test.py index 97485f513e..225d11f48b 100644 --- a/tests/lib/gpu_stats/_base_test.py +++ b/tests/lib/gpu_stats/_base_test.py @@ -1,7 +1,8 @@ #!/usr/bin python3 """ Pytest unit tests for :mod:`lib.gpu_stats._base` """ +import typing as T + from dataclasses import dataclass -from typing import cast from unittest.mock import MagicMock import pytest @@ -71,8 +72,8 @@ def test__gpu_stats_init_(gpu_stats_instance: _GPUStats) -> None: """ # Ensure that the object is initialized and shutdown correctly assert gpu_stats_instance._is_initialized is False - assert cast(MagicMock, gpu_stats_instance._initialize).call_count == 1 - assert cast(MagicMock, gpu_stats_instance._shutdown).call_count == 1 + assert T.cast(MagicMock, gpu_stats_instance._initialize).call_count == 1 + assert T.cast(MagicMock, gpu_stats_instance._shutdown).call_count == 1 # Ensure that the object correctly gets and stores the device count, active devices, # handles, driver, device names, and VRAM information diff --git a/tests/lib/gui/stats/event_reader_test.py b/tests/lib/gui/stats/event_reader_test.py index 618d1e226f..216790550b 100644 --- a/tests/lib/gui/stats/event_reader_test.py +++ b/tests/lib/gui/stats/event_reader_test.py @@ -1,13 +1,13 @@ #!/usr/bin python3 """ Pytest unit tests for :mod:`lib.gui.stats.event_reader` """ # pylint:disable=protected-access - +from __future__ import annotations import json import os +import typing as T from shutil import rmtree from time import time -from typing import cast, Iterator from unittest.mock import MagicMock import numpy as np @@ -20,6 +20,9 @@ from lib.gui.analysis.event_reader import (_Cache, _CacheData, _EventParser, _LogFiles, EventData, TensorBoardLogs) +if T.TYPE_CHECKING: + from collections.abc import Iterator + def test__logfiles(tmp_path: str): """ Test the _LogFiles class operates correctly @@ -627,9 +630,9 @@ def test_cache_events(self, monkeypatch.setattr("lib.utils._FS_BACKEND", "cpu") event_parse = event_parser_instance - event_parse._parse_outputs = cast(MagicMock, mocker.MagicMock()) # type:ignore - event_parse._process_event = cast(MagicMock, mocker.MagicMock()) # type:ignore - event_parse._cache.cache_data = cast(MagicMock, mocker.MagicMock()) # type:ignore + event_parse._parse_outputs = T.cast(MagicMock, mocker.MagicMock()) # type:ignore + event_parse._process_event = T.cast(MagicMock, mocker.MagicMock()) # type:ignore + event_parse._cache.cache_data = T.cast(MagicMock, mocker.MagicMock()) # type:ignore # keras model monkeypatch.setattr(event_parse, diff --git a/tests/lib/model/optimizers_test.py b/tests/lib/model/optimizers_test.py index 3a34a60503..34d5335824 100644 --- a/tests/lib/model/optimizers_test.py +++ b/tests/lib/model/optimizers_test.py @@ -70,13 +70,6 @@ def _test_optimizer(optimizer, target=0.75): assert_allclose(bias, 2.) -@pytest.mark.parametrize("dummy", [None], ids=[get_backend().upper()]) -def test_adam(dummy): # pylint:disable=unused-argument - """ Test for custom Adam optimizer """ - _test_optimizer(k_optimizers.Adam(), target=0.45) # pylint:disable=no-member - _test_optimizer(k_optimizers.Adam(decay=1e-3), target=0.45) # pylint:disable=no-member - - @pytest.mark.parametrize("dummy", [None], ids=[get_backend().upper()]) def test_adabelief(dummy): # pylint:disable=unused-argument """ Test for custom Adam optimizer """ diff --git a/tests/lib/sysinfo_test.py b/tests/lib/sysinfo_test.py index 5cdce9773c..215e8c9f46 100644 --- a/tests/lib/sysinfo_test.py +++ b/tests/lib/sysinfo_test.py @@ -5,10 +5,10 @@ import os import platform import sys +import typing as T from collections import namedtuple from io import StringIO -from typing import cast from unittest.mock import MagicMock import pytest @@ -258,8 +258,8 @@ def test__configs__parse_configs(configs_instance: _Configs, """ assert hasattr(configs_instance, "_parse_configs") assert isinstance(configs_instance._parse_configs([]), str) - configs_instance._parse_ini = cast(MagicMock, mocker.MagicMock()) # type:ignore - configs_instance._parse_json = cast(MagicMock, mocker.MagicMock()) # type:ignore + configs_instance._parse_ini = T.cast(MagicMock, mocker.MagicMock()) # type:ignore + configs_instance._parse_json = T.cast(MagicMock, mocker.MagicMock()) # type:ignore configs_instance._parse_configs(config_files=["test.ini", ".faceswap"]) assert configs_instance._parse_ini.called assert configs_instance._parse_json.called diff --git a/tests/lib/utils_test.py b/tests/lib/utils_test.py index 092c3a5f1e..34f9be5f4f 100644 --- a/tests/lib/utils_test.py +++ b/tests/lib/utils_test.py @@ -1,14 +1,15 @@ #!/usr/bin python3 """ Pytest unit tests for :mod:`lib.utils` """ import os +import platform import time +import typing as T import warnings import zipfile from io import StringIO from socket import timeout as socket_timeout, error as socket_error from shutil import rmtree -from typing import Any, cast, List, Tuple, Union from unittest.mock import MagicMock from urllib import error as urlliberror @@ -160,7 +161,7 @@ def test_get_image_paths(tmp_path: str) -> None: @pytest.mark.parametrize("path,result", _PARAMS, ids=[f'"{p[0]}"' for p in _PARAMS]) -def test_full_path_split(path: str, result: List[str]) -> None: +def test_full_path_split(path: str, result: list[str]) -> None: """ Test the :func:`~lib.utils.full_path_split` function works correctly Parameters @@ -188,7 +189,7 @@ def test_full_path_split(path: str, result: List[str]) -> None: @pytest.mark.parametrize("text, result", _PARAMS, ids=[f'"{p[0]}"' for p in _PARAMS]) -def test_camel_case_split(text: str, result: List[str]) -> None: +def test_camel_case_split(text: str, result: list[str]) -> None: """ Test the :func:`~lib.utils.camel_case_spli` function works correctly Parameters @@ -207,7 +208,7 @@ def test_camel_case_split(text: str, result: List[str]) -> None: def test_get_tf_version() -> None: """ Test the :func:`~lib.utils.get_tf_version` function version returns correctly in range """ tf_version = get_tf_version() - assert (2, 2) <= tf_version < (2, 11) + assert (2, 10) <= tf_version < (2, 11) def test_get_dpi() -> None: @@ -235,7 +236,7 @@ def test_get_dpi() -> None: @pytest.mark.parametrize("args,result", _SECPARAMS, ids=[str(p[0]) for p in _SECPARAMS]) -def test_convert_to_secs(args: Tuple[int, ...], result: int) -> None: +def test_convert_to_secs(args: tuple[int, ...], result: int) -> None: """ Test the :func:`~lib.utils.convert_to_secs` function works correctly Parameters @@ -360,8 +361,8 @@ def teardown(): @pytest.mark.parametrize("filename,results", zip(_INPUT, _EXPECTED), ids=[str(i) for i in _INPUT]) def test_get_model_model_filename_input( get_model_instance: GetModel, # pylint:disable=unused-argument - filename: Union[str, List[str]], - results: Union[str, List[str]]) -> None: + filename: str | list[str], + results: str | list[str]) -> None: """ Test :class:`~lib.utils.GetModel` filename parsing works Parameters @@ -430,8 +431,8 @@ def test_get_model__get(mocker: pytest_mock.MockerFixture, For testing the function when a model exists and when it does not """ model = get_model_instance - model._download_model = cast(MagicMock, mocker.MagicMock()) # type:ignore - model._unzip_model = cast(MagicMock, mocker.MagicMock()) # type:ignore + model._download_model = T.cast(MagicMock, mocker.MagicMock()) # type:ignore + model._unzip_model = T.cast(MagicMock, mocker.MagicMock()) # type:ignore os_remove = mocker.patch("os.remove") if model_exists: # Dummy in a model file @@ -459,8 +460,8 @@ def test_get_model__get(mocker: pytest_mock.MockerFixture, @pytest.mark.parametrize("error_type,error_args", _DLPARAMS, ids=[str(p[0]) for p in _DLPARAMS]) def test_get_model__download_model(mocker: pytest_mock.MockerFixture, get_model_instance: GetModel, - error_type: Any, - error_args: Tuple[Union[str, int], ...]) -> None: + error_type: T.Any, + error_args: tuple[str | int, ...]) -> None: """ Test :func:`~lib.utils.GetModel._download_model` executes its logic correctly Parameters @@ -476,7 +477,7 @@ def test_get_model__download_model(mocker: pytest_mock.MockerFixture, """ mock_urlopen = mocker.patch("urllib.request.urlopen") if not error_type: # Model download is successful - get_model_instance._write_zipfile = cast(MagicMock, mocker.MagicMock()) # type:ignore + get_model_instance._write_zipfile = T.cast(MagicMock, mocker.MagicMock()) # type:ignore get_model_instance._download_model() assert mock_urlopen.called assert get_model_instance._write_zipfile.called @@ -609,11 +610,13 @@ def test_debug_times(): assert len(debug_times._times["Test2"]) == 1 # Ensure that the summary method includes the correct min, mean, and max times for each step - assert min(debug_times._times["Test1"]) == pytest.approx(0.1, abs=1e-1) - assert min(debug_times._times["Test2"]) == pytest.approx(0.2, abs=1e-1) - assert max(debug_times._times["Test1"]) == pytest.approx(0.1, abs=1e-1) - assert max(debug_times._times["Test2"]) == pytest.approx(0.2, abs=1e-1) + # Github workflow for macos-latest can swing out a fair way + threshold = 2e-1 if platform.system() == "Darwin" else 1e-1 + assert min(debug_times._times["Test1"]) == pytest.approx(0.1, abs=threshold) + assert min(debug_times._times["Test2"]) == pytest.approx(0.2, abs=threshold) + assert max(debug_times._times["Test1"]) == pytest.approx(0.1, abs=threshold) + assert max(debug_times._times["Test2"]) == pytest.approx(0.2, abs=threshold) assert (sum(debug_times._times["Test1"]) / - len(debug_times._times["Test1"])) == pytest.approx(0.1, abs=1e-1) + len(debug_times._times["Test1"])) == pytest.approx(0.1, abs=threshold) assert (sum(debug_times._times["Test2"]) / - len(debug_times._times["Test2"]) == pytest.approx(0.2, abs=1e-1)) + len(debug_times._times["Test2"]) == pytest.approx(0.2, abs=threshold)) diff --git a/tests/simple_tests.py b/tests/simple_tests.py index 2b3be20887..92237f6803 100644 --- a/tests/simple_tests.py +++ b/tests/simple_tests.py @@ -108,11 +108,10 @@ def convert_args(in_path, out_path, model_path, writer, args=None): return conv_args.split() # Don't use pathes with spaces ;) -def sort_args(in_path, out_path, sortby="face", groupby="hist", method="rename"): +def sort_args(in_path, out_path, sortby="face", groupby="hist"): """ Sort command """ py_exe = sys.executable - _sort_args = (f"{py_exe} tools.py sort -i {in_path} -o {out_path} -s {sortby} -fp {method} " - f"-g {groupby} -k") + _sort_args = (f"{py_exe} tools.py sort -i {in_path} -o {out_path} -s {sortby} -g {groupby} -k") return _sort_args.split() @@ -183,7 +182,7 @@ def main(): "Sort faces.", sort_args( pathjoin(vid_base, "faces"), pathjoin(vid_base, "faces_sorted"), - sortby="face", method="rename" + sortby="face" ) ) diff --git a/tests/tools/alignments/media_test.py b/tests/tools/alignments/media_test.py index 52124a4469..5f603ee3a3 100644 --- a/tests/tools/alignments/media_test.py +++ b/tests/tools/alignments/media_test.py @@ -1,8 +1,10 @@ #!/usr/bin python3 """ Pytest unit tests for :mod:`tools.alignments.media` """ +from __future__ import annotations import os +import typing as T + from operator import itemgetter -from typing import cast, Dict, Generator, List, Tuple from unittest.mock import MagicMock import cv2 @@ -19,6 +21,9 @@ from tools.alignments.media import (AlignmentData, Faces, ExtractedFaces, # noqa:E402 Frames, MediaLoader) +if T.TYPE_CHECKING: + from collections.abc import Generator + class TestAlignmentData: """ Test for :class:`~tools.alignments.media.AlignmentData` """ @@ -224,8 +229,8 @@ def test_load_image(self, """ media_loader = media_loader_instance expected = np.random.rand(256, 256, 3) - media_loader.load_video_frame = cast(MagicMock, # type:ignore - mocker.MagicMock(return_value=expected)) + media_loader.load_video_frame = T.cast(MagicMock, # type:ignore + mocker.MagicMock(return_value=expected)) read_image_patch = mocker.patch("tools.alignments.media.read_image", return_value=expected) filename = "test.png" output = media_loader.load_image(filename) @@ -263,7 +268,7 @@ def test_load_video_frame(self, vid_cap = mocker.MagicMock(cv2.VideoCapture) vid_cap.read.side_effect = ((1, expected), ) - media_loader._vid_reader = cast(MagicMock, vid_cap) # type:ignore + media_loader._vid_reader = T.cast(MagicMock, vid_cap) # type:ignore output = media_loader.load_video_frame(filename) vid_cap.set.assert_called_once() np.testing.assert_equal(output, expected) @@ -440,9 +445,9 @@ def test__handle_duplicate(self, faces_instance: Faces) -> None: src_filename = "test_0001.png" src_face_idx = 0 paths = [os.path.join(faces.folder, fname) for fname in os.listdir(faces.folder)] - data = dict(source=dict(source_filename=src_filename, - face_index=src_face_idx)) - seen: Dict[str, List[int]] = {} + data = {"source": {"source_filename": src_filename, + "face_index": src_face_idx}} + seen: dict[str, list[int]] = {} # New item is_dupe = faces._handle_duplicate(paths[0], data, seen) # type:ignore @@ -477,7 +482,7 @@ def test_process_folder(self, faces = faces_instance read_image_meta_mock = mocker.patch("tools.alignments.media.read_image_meta_batch") img_sources = [os.path.join(faces.folder, fname) for fname in os.listdir(faces.folder)] - meta_data = dict(itxt=dict(source=(dict(source_filename="data.png")))) + meta_data = {"itxt": {"source": ({"source_filename": "data.png"})}} expected = [(fname, meta_data["itxt"]) for fname in os.listdir(faces.folder)] read_image_meta_mock.side_effect = [[(src, meta_data) for src in img_sources]] @@ -527,16 +532,16 @@ def test_load_items(self, The class instance for testing """ faces = faces_instance - data = [(f"file{idx}.png", dict(source=dict(source_filename=f"src{idx}.png", - face_index=0))) + data = [(f"file{idx}.png", {"source": {"source_filename": f"src{idx}.png", + "face_index": 0}}) for idx in range(4)] faces.file_list_sorted = data # type: ignore expected = {"src0.png": [0], "src1.png": [0], "src2.png": [0], "src3.png": [0]} result = faces.load_items() assert result == expected - data = [(f"file{idx}.png", dict(source=dict(source_filename=f"src{idx // 2}.png", - face_index=0 if idx % 2 == 0 else 1))) + data = [(f"file{idx}.png", {"source": {"source_filename": f"src{idx // 2}.png", + "face_index": 0 if idx % 2 == 0 else 1}}) for idx in range(4)] faces.file_list_sorted = data # type: ignore expected = {"src0.png": [0, 1], "src1.png": [0, 1]} @@ -556,7 +561,7 @@ def test_sorted_items(self, Fixture for mocking various logic calls """ faces = faces_instance - data: List[Tuple[str, dict]] = [("file4.png", {}), ("file3.png", {}), + data: list[tuple[str, dict]] = [("file4.png", {}), ("file3.png", {}), ("file1.png", {}), ("file2.png", {})] expected = sorted(data) process_folder_mock = mocker.patch("tools.alignments.media.Faces.process_folder", @@ -605,8 +610,8 @@ def test_process_frames(self, folder: str) -> None: folder : str Dummy media folder """ - expected = [dict(frame_fullname="a.png", frame_name="a", frame_extension=".png"), - dict(frame_fullname="b.png", frame_name="b", frame_extension=".png")] + expected = [{"frame_fullname": "a.png", "frame_name": "a", "frame_extension": ".png"}, + {"frame_fullname": "b.png", "frame_name": "b", "frame_extension": ".png"}] frames = Frames(folder, None) returned = sorted(list(frames.process_frames()), key=itemgetter("frame_fullname")) @@ -620,12 +625,12 @@ def test_process_video(self, folder: str) -> None: folder : str Dummy media folder """ - expected = [dict(frame_fullname="images_000001.png", - frame_name="images_000001", - frame_extension=".png"), - dict(frame_fullname="images_000002.png", - frame_name="images_000002", - frame_extension=".png")] + expected = [{"frame_fullname": "images_000001.png", + "frame_name": "images_000001", + "frame_extension": ".png"}, + {"frame_fullname": "images_000002.png", + "frame_name": "images_000002", + "frame_extension": ".png"}] frames = Frames(folder, None) returned = list(frames.process_video()) @@ -657,14 +662,14 @@ def test_sorted_items(self, Fixture for mocking process_folder call """ frames = Frames(folder, None) - data = [dict(frame_fullname="c.png", frame_name="c", frame_extension=".png"), - dict(frame_fullname="d.png", frame_name="d", frame_extension=".png"), - dict(frame_fullname="b.jpg", frame_name="b", frame_extension=".jpg"), - dict(frame_fullname="a.png", frame_name="a", frame_extension=".png")] - expected = [dict(frame_fullname="a.png", frame_name="a", frame_extension=".png"), - dict(frame_fullname="b.jpg", frame_name="b", frame_extension=".jpg"), - dict(frame_fullname="c.png", frame_name="c", frame_extension=".png"), - dict(frame_fullname="d.png", frame_name="d", frame_extension=".png")] + data = [{"frame_fullname": "c.png", "frame_name": "c", "frame_extension": ".png"}, + {"frame_fullname": "d.png", "frame_name": "d", "frame_extension": ".png"}, + {"frame_fullname": "b.jpg", "frame_name": "b", "frame_extension": ".jpg"}, + {"frame_fullname": "a.png", "frame_name": "a", "frame_extension": ".png"}] + expected = [{"frame_fullname": "a.png", "frame_name": "a", "frame_extension": ".png"}, + {"frame_fullname": "b.jpg", "frame_name": "b", "frame_extension": ".jpg"}, + {"frame_fullname": "c.png", "frame_name": "c", "frame_extension": ".png"}, + {"frame_fullname": "d.png", "frame_name": "d", "frame_extension": ".png"}] process_folder_mock = mocker.patch("tools.alignments.media.Frames.process_folder", side_effect=[data]) result = frames.sorted_items() @@ -796,7 +801,7 @@ def test_get_faces_in_frame(self, Fixture for mocking get_faces method """ faces = extracted_faces_instance - faces.get_faces = cast(MagicMock, mocker.MagicMock()) # type:ignore + faces.get_faces = T.cast(MagicMock, mocker.MagicMock()) # type:ignore frame = "test_frame" img = None @@ -837,7 +842,7 @@ def test_get_roi_size_for_frame(self, The expected output for the given ROI box """ faces = extracted_faces_instance - faces.get_faces = cast(MagicMock, mocker.MagicMock()) # type:ignore + faces.get_faces = T.cast(MagicMock, mocker.MagicMock()) # type:ignore frame = "test_frame" faces.get_roi_size_for_frame(frame) diff --git a/tests/tools/preview/viewer_test.py b/tests/tools/preview/viewer_test.py index 05fb072eec..18fd84d404 100644 --- a/tests/tools/preview/viewer_test.py +++ b/tests/tools/preview/viewer_test.py @@ -1,9 +1,11 @@ #!/usr/bin python3 """ Pytest unit tests for :mod:`tools.preview.viewer` """ +from __future__ import annotations import tkinter as tk +import typing as T + from tkinter import ttk -from typing import cast, TYPE_CHECKING from unittest.mock import MagicMock import pytest @@ -18,7 +20,7 @@ from lib.utils import get_backend # pylint:disable=wrong-import-position # noqa from tools.preview.viewer import _Faces, FacesDisplay, ImagesCanvas # pylint:disable=wrong-import-position # noqa -if TYPE_CHECKING: +if T.TYPE_CHECKING: from lib.align.aligned_face import CenteringType @@ -104,7 +106,7 @@ def test_set_centering(self) -> None: """ Test :class:`~tools.preview.viewer.FacesDisplay` set_centering method """ f_display = self.get_faces_display_instance() assert f_display._centering is None - centering: "CenteringType" = "legacy" + centering: CenteringType = "legacy" f_display.set_centering(centering) assert f_display._centering == centering @@ -133,9 +135,9 @@ def test_update_tk_image(self, Mocker for checking _build_faces_image method called """ f_display = self.get_faces_display_instance(columns, face_size) - f_display._build_faces_image = cast(MagicMock, mocker.MagicMock()) # type:ignore - f_display._get_scale_size = cast(MagicMock, # type:ignore - mocker.MagicMock(return_value=(128, 128))) + f_display._build_faces_image = T.cast(MagicMock, mocker.MagicMock()) # type:ignore + f_display._get_scale_size = T.cast(MagicMock, # type:ignore + mocker.MagicMock(return_value=(128, 128))) f_display._faces_source = np.zeros((face_size, face_size, 3), dtype=np.uint8) f_display._faces_dest = np.zeros((face_size, face_size, 3), dtype=np.uint8) @@ -186,12 +188,12 @@ def test__build_faces_image(self, header_size = 32 f_display = self.get_faces_display_instance(columns, face_size) - f_display._faces_from_frames = cast(MagicMock, mocker.MagicMock()) # type:ignore - f_display._header_text = cast( # type:ignore + f_display._faces_from_frames = T.cast(MagicMock, mocker.MagicMock()) # type:ignore + f_display._header_text = T.cast( # type:ignore MagicMock, mocker.MagicMock(return_value=np.random.rand(header_size, face_size * columns, 3))) - f_display._draw_rect = cast(MagicMock, # type:ignore - mocker.MagicMock(side_effect=lambda x: x)) + f_display._draw_rect = T.cast(MagicMock, # type:ignore + mocker.MagicMock(side_effect=lambda x: x)) # Test full update f_display.update_source = True @@ -235,8 +237,8 @@ def test_faces__from_frames(self, f_display = self.get_faces_display_instance(columns, face_size) f_display.source = [mocker.MagicMock() for _ in range(3)] f_display.destination = [np.random.rand(face_size, face_size, 3) for _ in range(3)] - f_display._crop_source_faces = cast(MagicMock, mocker.MagicMock()) # type:ignore - f_display._crop_destination_faces = cast(MagicMock, mocker.MagicMock()) # type:ignore + f_display._crop_source_faces = T.cast(MagicMock, mocker.MagicMock()) # type:ignore + f_display._crop_destination_faces = T.cast(MagicMock, mocker.MagicMock()) # type:ignore # Both src + dst f_display.update_source = True @@ -451,7 +453,7 @@ def test_resize(self, Mocker for dummying in tk calls """ event_mock = mocker.MagicMock(spec=tk.Event, width=100, height=200) - images_canvas_instance.reload = cast(MagicMock, mocker.MagicMock()) # type:ignore + images_canvas_instance.reload = T.cast(MagicMock, mocker.MagicMock()) # type:ignore images_canvas_instance._resize(event_mock) diff --git a/tests/utils.py b/tests/utils.py index 26379587cd..b357dc13c4 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -2,7 +2,6 @@ """ Utils imported from Keras as their location changes between Tensorflow Keras and standard Keras. Also ensures testing consistency """ import inspect -import sys import numpy as np @@ -101,12 +100,6 @@ def has_arg(func, name, accept_all=False): bool Whether `func` accepts a `name` keyword argument. """ - if sys.version_info < (3, 3): - arg_spec = inspect.getfullargspec(func) - if accept_all and arg_spec.varkw is not None: - return True - return (name in arg_spec.args or - name in arg_spec.kwonlyargs) signature = inspect.signature(func) parameter = signature.parameters.get(name) if parameter is None: diff --git a/tools.py b/tools.py index 326e15be25..c47798c7d0 100755 --- a/tools.py +++ b/tools.py @@ -9,17 +9,13 @@ # Importing the various tools from lib.cli.args import FullHelpArgumentParser - # LOCALES _LANG = gettext.translation("tools", localedir="locales", fallback=True) _ = _LANG.gettext - # Python version check -if sys.version_info[0] < 3: - raise Exception("This program requires at least python3.7") -if sys.version_info[0] == 3 and sys.version_info[1] < 7: - raise Exception("This program requires at least python3.7") +if sys.version_info < (3, 10): + raise ValueError("This program requires at least python 3.10") def bad_args(*args): # pylint:disable=unused-argument diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index 610e885701..6ffc3f84a7 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -3,10 +3,10 @@ import logging import os import sys +import typing as T from argparse import Namespace from multiprocessing import Process -from typing import Any, cast, List, Dict, Optional from lib.utils import _video_extensions, FaceswapError from .media import AlignmentData @@ -66,7 +66,7 @@ def _validate_batch_mode(self) -> bool: logger.debug("Running in batch mode") return batch_mode - def _get_alignments_locations(self) -> Dict[str, List[Optional[str]]]: + def _get_alignments_locations(self) -> dict[str, list[str | None]]: """ Obtain the full path to alignments files in a parent (batch) location These are jobs that only require an alignments file as input, so frames and face locations @@ -92,12 +92,12 @@ def _get_alignments_locations(self) -> Dict[str, List[Optional[str]]]: sys.exit(1) logger.info("Batch mode selected. Processing alignments: %s", alignments) - retval = dict(alignments_file=alignments, - faces_dir=[None for _ in range(len(alignments))], - frames_dir=[None for _ in range(len(alignments))]) + retval = {"alignments_file": alignments, + "faces_dir": [None for _ in range(len(alignments))], + "frames_dir": [None for _ in range(len(alignments))]} return retval - def _get_frames_locations(self) -> Dict[str, List[Optional[str]]]: + def _get_frames_locations(self) -> dict[str, list[str | None]]: """ Obtain the full path to frame locations along with corresponding alignments file locations contained within the parent (batch) location @@ -138,7 +138,7 @@ def _get_frames_locations(self) -> Dict[str, List[Optional[str]]]: sys.exit(1) if self._args.job not in self._requires_faces: # faces not required for frames input - faces: list[Optional[str]] = [None for _ in range(len(frames))] + faces: list[str | None] = [None for _ in range(len(frames))] else: if not self._args.faces_dir: logger.error("Please provide a 'faces_dir' location for '%s' job", self._args.job) @@ -149,11 +149,11 @@ def _get_frames_locations(self) -> Dict[str, List[Optional[str]]]: logger.info("Batch mode selected. Processing frames: %s", [os.path.basename(frame) for frame in frames]) - return dict(alignments_file=cast(List[Optional[str]], alignments), - frames_dir=cast(List[Optional[str]], frames), - faces_dir=faces) + return {"alignments_file": T.cast(list[str | None], alignments), + "frames_dir": T.cast(list[str | None], frames), + "faces_dir": faces} - def _get_locations(self) -> Dict[str, List[Optional[str]]]: + def _get_locations(self) -> dict[str, list[str | None]]: """ Obtain the full path to any frame, face and alignments input locations for the selected job when running in batch mode. If not running in batch mode, then the original passed in values are returned in lists @@ -166,9 +166,9 @@ def _get_locations(self) -> Dict[str, List[Optional[str]]]: """ job: str = self._args.job if not self._batch_mode: # handle with given arguments - retval = dict(alignments_file=[self._args.alignments_file], - faces_dir=[self._args.faces_dir], - frames_dir=[self._args.frames_dir]) + retval = {"alignments_file": [self._args.alignments_file], + "faces_dir": [self._args.faces_dir], + "frames_dir": [self._args.frames_dir]} elif job in self._requires_alignments: # Jobs only requiring an alignments file location retval = self._get_alignments_locations() @@ -185,9 +185,9 @@ def _get_locations(self) -> Dict[str, List[Optional[str]]]: logger.error("No folders found in '%s'", self._args.faces_dir) sys.exit(1) - retval = dict(faces_dir=faces, - frames_dir=[None for _ in range(len(faces))], - alignments_file=[None for _ in range(len(faces))]) + retval = {"faces_dir": faces, + "frames_dir": [None for _ in range(len(faces))], + "alignments_file": [None for _ in range(len(faces))]} logger.info("Batch mode selected. Processing faces: %s", [os.path.basename(folder) for folder in faces]) else: @@ -306,7 +306,7 @@ def process(self) -> None: Launches the selected alignments job. """ if self._args.job in ("missing-alignments", "missing-frames", "multi-faces", "no-faces"): - job: Any = Check + job: T.Any = Check else: job = globals()[self._args.job.title().replace("-", "")] job = job(self.alignments, self._args) diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py index 8569870698..d41b4df481 100644 --- a/tools/alignments/cli.py +++ b/tools/alignments/cli.py @@ -2,8 +2,7 @@ """ Command Line Arguments for tools """ import sys import gettext - -from typing import Any, List, Dict +import typing as T from lib.cli.args import FaceSwapArgs from lib.cli.actions import DirOrFileFullPaths, DirFullPaths, FileFullPaths, Radio, Slider @@ -33,7 +32,7 @@ def get_info() -> str: "an alignments file against its corresponding faceset/frame source.") @staticmethod - def get_argument_list() -> List[Dict[str, Any]]: + def get_argument_list() -> list[dict[str, T.Any]]: """ Collect the argparse argument options. Returns diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index 36d1d685c5..2dfdc9a75b 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 """ Tools for manipulating the alignments serialized file """ - +from __future__ import annotations import logging import os import sys +import typing as T + from datetime import datetime -from typing import cast, Dict, Generator, List, Tuple, TYPE_CHECKING, Optional, Union import numpy as np from scipy import signal @@ -15,12 +16,8 @@ from .media import Faces, Frames from .jobs_faces import FaceToFile -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - -if TYPE_CHECKING: +if T.TYPE_CHECKING: + from collections.abc import Generator from argparse import Namespace from lib.align.alignments import PNGHeaderDict from .media import AlignmentData @@ -38,11 +35,11 @@ class Check(): arguments: :class:`argparse.Namespace` The command line arguments that have called this job """ - def __init__(self, alignments: "AlignmentData", arguments: "Namespace") -> None: + def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self._alignments = alignments self._job = arguments.job - self._type: Optional[Literal["faces", "frames"]] = None + self._type: T.Literal["faces", "frames"] | None = None self._is_video = False # Set when getting items self._output = arguments.output self._source_dir = self._get_source_dir(arguments) @@ -52,7 +49,7 @@ def __init__(self, alignments: "AlignmentData", arguments: "Namespace") -> None: self.output_message = "" logger.debug("Initialized %s", self.__class__.__name__) - def _get_source_dir(self, arguments: "Namespace") -> str: + def _get_source_dir(self, arguments: Namespace) -> str: """ Set the correct source folder Parameters @@ -81,7 +78,7 @@ def _get_source_dir(self, arguments: "Namespace") -> str: logger.debug("type: '%s', source_dir: '%s'", self._type, source_dir) return source_dir - def _get_items(self) -> Union[List[Dict[str, str]], List[Tuple[str, "PNGHeaderDict"]]]: + def _get_items(self) -> list[dict[str, str]] | list[tuple[str, PNGHeaderDict]]: """ Set the correct items to process Returns @@ -92,10 +89,10 @@ def _get_items(self) -> Union[List[Dict[str, str]], List[Tuple[str, "PNGHeaderDi the dictionaries will contain the keys 'frame_fullname', 'frame_name', 'extension'. """ assert self._type is not None - items: Union[Frames, Faces] = globals()[self._type.title()](self._source_dir) + items: Frames | Faces = globals()[self._type.title()](self._source_dir) self._is_video = items.is_video - return cast(Union[List[Dict[str, str]], List[Tuple[str, "PNGHeaderDict"]]], - items.file_list_sorted) + return T.cast(list[dict[str, str]] | list[tuple[str, "PNGHeaderDict"]], + items.file_list_sorted) def process(self) -> None: """ Process the frames check against the alignments file """ @@ -104,7 +101,7 @@ def process(self) -> None: items_output = self._compile_output() if self._type == "faces": - filelist = cast(List[Tuple[str, "PNGHeaderDict"]], self._items) + filelist = T.cast(list[tuple[str, "PNGHeaderDict"]], self._items) check_update = FaceToFile(self._alignments, [val[1] for val in filelist]) if check_update(): self._alignments.save() @@ -122,7 +119,7 @@ def _validate(self) -> None: "supported for 'multi-faces'") sys.exit(1) - def _compile_output(self) -> Union[List[str], List[Tuple[str, int]]]: + def _compile_output(self) -> list[str] | list[tuple[str, int]]: """ Compile list of frames that meet criteria Returns @@ -144,7 +141,7 @@ def _get_no_faces(self) -> Generator[str, None, None]: The frame name of any frames which have no faces """ self.output_message = "Frames with no faces" - for frame in tqdm(cast(List[Dict[str, str]], self._items), + for frame in tqdm(T.cast(list[dict[str, str]], self._items), desc=self.output_message, leave=False): logger.trace(frame) # type:ignore @@ -153,8 +150,8 @@ def _get_no_faces(self) -> Generator[str, None, None]: logger.debug("Returning: '%s'", frame_name) yield frame_name - def _get_multi_faces(self) -> Union[Generator[str, None, None], - Generator[Tuple[str, int], None, None]]: + def _get_multi_faces(self) -> (Generator[str, None, None] | + Generator[tuple[str, int], None, None]): """ yield each frame or face that has multiple faces matched in alignments file Yields @@ -175,7 +172,7 @@ def _get_multi_faces_frames(self) -> Generator[str, None, None]: The frame name of any frames which have multiple faces """ self.output_message = "Frames with multiple faces" - for item in tqdm(cast(List[Dict[str, str]], self._items), + for item in tqdm(T.cast(list[dict[str, str]], self._items), desc=self.output_message, leave=False): filename = item["frame_fullname"] @@ -184,7 +181,7 @@ def _get_multi_faces_frames(self) -> Generator[str, None, None]: logger.trace("Returning: '%s'", filename) # type:ignore yield filename - def _get_multi_faces_faces(self) -> Generator[Tuple[str, int], None, None]: + def _get_multi_faces_faces(self) -> Generator[tuple[str, int], None, None]: """ Return Faces when there are multiple faces in a frame Yields @@ -193,7 +190,7 @@ def _get_multi_faces_faces(self) -> Generator[Tuple[str, int], None, None]: The frame name and the face id of any frames which have multiple faces """ self.output_message = "Multiple faces in frame" - for item in tqdm(cast(List[Tuple[str, "PNGHeaderDict"]], self._items), + for item in tqdm(T.cast(list[tuple[str, "PNGHeaderDict"]], self._items), desc=self.output_message, leave=False): src = item[1]["source"] @@ -213,7 +210,7 @@ def _get_missing_alignments(self) -> Generator[str, None, None]: """ self.output_message = "Frames missing from alignments file" exclude_filetypes = set(["yaml", "yml", "p", "json", "txt"]) - for frame in tqdm(cast(Dict[str, str], self._items), + for frame in tqdm(T.cast(dict[str, str], self._items), desc=self.output_message, leave=False): frame_name = frame["frame_fullname"] @@ -231,13 +228,13 @@ def _get_missing_frames(self) -> Generator[str, None, None]: The frame name of any frames in alignments with no matching file """ self.output_message = "Missing frames that are in alignments file" - frames = set(item["frame_fullname"] for item in cast(List[Dict[str, str]], self._items)) + frames = set(item["frame_fullname"] for item in T.cast(list[dict[str, str]], self._items)) for frame in tqdm(self._alignments.data.keys(), desc=self.output_message, leave=False): if frame not in frames: logger.debug("Returning: '%s'", frame) yield frame - def _output_results(self, items_output: Union[List[str], List[Tuple[str, int]]]) -> None: + def _output_results(self, items_output: list[str] | list[tuple[str, int]]) -> None: """ Output the results in the requested format Parameters @@ -261,7 +258,7 @@ def _output_results(self, items_output: Union[List[str], List[Tuple[str, int]]]) # Strip the index for printed/file output final_output = [item[0] for item in items_output] else: - final_output = cast(List[str], items_output) + final_output = T.cast(list[str], items_output) output_message = "-----------------------------------------------\r\n" output_message += f" {self.output_message} ({len(final_output)})\r\n" output_message += "-----------------------------------------------\r\n" @@ -316,7 +313,7 @@ def output_file(self, output_message: str, items_discovered: int) -> None: with open(output_file, "w", encoding="utf8") as f_output: f_output.write(output_message) - def _move_file(self, items_output: Union[List[str], List[Tuple[str, int]]]) -> None: + def _move_file(self, items_output: list[str] | list[tuple[str, int]]) -> None: """ Move the identified frames to a new sub folder Parameters @@ -335,7 +332,7 @@ def _move_file(self, items_output: Union[List[str], List[Tuple[str, int]]]) -> N logger.debug("Move function: %s", move) move(output_folder, items_output) - def _move_frames(self, output_folder: str, items_output: List[str]) -> None: + def _move_frames(self, output_folder: str, items_output: list[str]) -> None: """ Move frames into single sub folder Parameters @@ -352,7 +349,7 @@ def _move_frames(self, output_folder: str, items_output: List[str]) -> None: logger.debug("Moving: '%s' to '%s'", src, dst) os.rename(src, dst) - def _move_faces(self, output_folder: str, items_output: List[Tuple[str, int]]) -> None: + def _move_faces(self, output_folder: str, items_output: list[tuple[str, int]]) -> None: """ Make additional sub folders for each face that appears Enables easier manual sorting Parameters @@ -384,7 +381,7 @@ class Sort(): arguments: :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in from :mod:`tools.py` """ - def __init__(self, alignments: "AlignmentData", arguments: "Namespace") -> None: + def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self._alignments = alignments logger.debug("Initialized %s", self.__class__.__name__) @@ -435,13 +432,13 @@ class Spatial(): # pylint:disable=too-few-public-methods --------- https://www.kaggle.com/selfishgene/animating-and-smoothing-3d-facial-keypoints/notebook """ - def __init__(self, alignments: "AlignmentData", arguments: "Namespace") -> None: + def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self.arguments = arguments self._alignments = alignments - self._mappings: Dict[int, str] = {} - self._normalized: Dict[str, np.ndarray] = {} - self._shapes_model: Optional[decomposition.PCA] = None + self._mappings: dict[int, str] = {} + self._normalized: dict[str, np.ndarray] = {} + self._shapes_model: decomposition.PCA | None = None logger.debug("Initialized %s", self.__class__.__name__) def process(self) -> None: @@ -464,7 +461,7 @@ def process(self) -> None: # Define shape normalization utility functions @staticmethod def _normalize_shapes(shapes_im_coords: np.ndarray - ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ Normalize a 2D or 3D shape Parameters diff --git a/tools/alignments/jobs_faces.py b/tools/alignments/jobs_faces.py index 6be619f99a..06fcf85673 100644 --- a/tools/alignments/jobs_faces.py +++ b/tools/alignments/jobs_faces.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 """ Tools for manipulating the alignments using extracted Faces as a source """ +from __future__ import annotations import logging import os -import sys +import typing as T + from argparse import Namespace from operator import itemgetter -from typing import cast, Dict, List, Optional, Tuple, TYPE_CHECKING import numpy as np from tqdm import tqdm @@ -16,12 +17,7 @@ from .media import Faces -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - -if TYPE_CHECKING: +if T.TYPE_CHECKING: from .media import AlignmentData from lib.align.alignments import (AlignmentDict, AlignmentFileDict, PNGHeaderDict, PNGHeaderAlignmentsDict) @@ -50,9 +46,9 @@ def process(self) -> None: """ Run the job to read faces from a folder to create alignments file(s). """ logger.info("[CREATE ALIGNMENTS FROM FACES]") # Tidy up cli output - all_versions: Dict[str, List[float]] = {} - d_align: Dict[str, Dict[str, List[Tuple[int, "AlignmentFileDict", str, dict]]]] = {} - filelist = cast(List[Tuple[str, "PNGHeaderDict"]], self._faces.file_list_sorted) + all_versions: dict[str, list[float]] = {} + d_align: dict[str, dict[str, list[tuple[int, AlignmentFileDict, str, dict]]]] = {} + filelist = T.cast(list[tuple[str, "PNGHeaderDict"]], self._faces.file_list_sorted) for filename, meta in tqdm(filelist, desc="Generating Alignments", total=len(filelist), @@ -93,7 +89,7 @@ def _get_alignments_filename(cls, source_data: dict) -> str: logger.trace("Extracted alignments file filename: '%s'", retval) # type:ignore return retval - def _extract_alignment(self, metadata: dict) -> Tuple[str, int, "AlignmentFileDict"]: + def _extract_alignment(self, metadata: dict) -> tuple[str, int, AlignmentFileDict]: """ Extract alignment data from a PNG image's itxt header. Formats the landmarks into a numpy array and adds in mask centering information if it is @@ -123,11 +119,11 @@ def _extract_alignment(self, metadata: dict) -> Tuple[str, int, "AlignmentFileDi return frame_name, face_index, alignment def _sort_alignments(self, - alignments: Dict[str, Dict[str, List[Tuple[int, - "AlignmentFileDict", + alignments: dict[str, dict[str, list[tuple[int, + AlignmentFileDict, str, dict]]]] - ) -> Dict[str, Dict[str, "AlignmentDict"]]: + ) -> dict[str, dict[str, AlignmentDict]]: """ Sort the faces into face index order as they appeared in the original alignments file. If the face index stored in the png header does not match it's position in the alignments @@ -147,11 +143,11 @@ def _sort_alignments(self, The alignments file dictionaries sorted into the correct face order, ready for saving """ logger.info("Sorting and checking faces...") - aln_sorted: Dict[str, Dict[str, "AlignmentDict"]] = {} + aln_sorted: dict[str, dict[str, AlignmentDict]] = {} for fname, frames in alignments.items(): - this_file: Dict[str, "AlignmentDict"] = {} + this_file: dict[str, AlignmentDict] = {} for frame in tqdm(sorted(frames), desc=f"Sorting {fname}", leave=False): - this_file[frame] = dict(video_meta={}, faces=[]) + this_file[frame] = {"video_meta": {}, "faces": []} for real_idx, (f_id, almt, f_path, f_src) in enumerate(sorted(frames[frame], key=itemgetter(0))): if real_idx != f_id: @@ -165,7 +161,7 @@ def _sort_alignments(self, def _update_png_header(cls, face_path: str, new_index: int, - alignment: "AlignmentFileDict", + alignment: AlignmentFileDict, source_info: dict) -> None: """ Update the PNG header for faces where the stored index does not correspond with the alignments file. This can occur when frames with multiple faces have had some faces deleted @@ -194,12 +190,12 @@ def _update_png_header(cls, source_info["face_index"] = new_index source_info["original_filename"] = new_filename - meta = dict(alignments=face.to_png_meta(), source=source_info) + meta = {"alignments": face.to_png_meta(), "source": source_info} update_existing_metadata(face_path, meta) def _save_alignments(self, - all_alignments: Dict[str, Dict[str, "AlignmentDict"]], - versions: Dict[str, float]) -> None: + all_alignments: dict[str, dict[str, AlignmentDict]], + versions: dict[str, float]) -> None: """ Save the newely generated alignments file(s). If an alignments file already exists in the source faces folder, back it up rather than @@ -240,9 +236,9 @@ class Rename(): # pylint:disable=too-few-public-methods Default: ``None`` """ def __init__(self, - alignments: "AlignmentData", - arguments: Optional[Namespace], - faces: Optional[Faces] = None) -> None: + alignments: AlignmentData, + arguments: Namespace | None, + faces: Faces | None = None) -> None: logger.debug("Initializing %s: (arguments: %s, faces: %s)", self.__class__.__name__, arguments, faces) self._alignments = alignments @@ -261,7 +257,7 @@ def __init__(self, def process(self) -> None: """ Process the face renaming """ logger.info("[RENAME FACES]") # Tidy up cli output - filelist = cast(List[Tuple[str, "PNGHeaderDict"]], self._faces.file_list_sorted) + filelist = T.cast(list[tuple[str, "PNGHeaderDict"]], self._faces.file_list_sorted) rename_mappings = sorted([(face[0], face[1]["source"]["original_filename"]) for face in filelist if face[0] != face[1]["source"]["original_filename"]], @@ -269,12 +265,12 @@ def process(self) -> None: rename_count = self._rename_faces(rename_mappings) logger.info("%s faces renamed", rename_count) - filelist = cast(List[Tuple[str, "PNGHeaderDict"]], self._faces.file_list_sorted) + filelist = T.cast(list[tuple[str, "PNGHeaderDict"]], self._faces.file_list_sorted) copyback = FaceToFile(self._alignments, [val[1] for val in filelist]) if copyback(): self._alignments.save() - def _rename_faces(self, filename_mappings: List[Tuple[str, str]]) -> int: + def _rename_faces(self, filename_mappings: list[tuple[str, str]]) -> int: """ Rename faces back to their original name as exists in the alignments file. If the source and destination filename are the same then skip that file. @@ -333,7 +329,7 @@ class RemoveFaces(): # pylint:disable=too-few-public-methods arguments: :class:`argparse.Namespace` The command line arguments that have called this job """ - def __init__(self, alignments: "AlignmentData", arguments: Namespace) -> None: + def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self._alignments = alignments @@ -350,7 +346,7 @@ def process(self) -> None: "faces from your alignments file. Process aborted.") return - items = cast(Dict[str, List[int]], self._items.items) + items = T.cast(dict[str, list[int]], self._items.items) pre_face_count = self._alignments.faces_count self._alignments.filter_faces(items, filter_out=False) del_count = pre_face_count - self._alignments.faces_count @@ -375,9 +371,9 @@ def _update_png_headers(self) -> None: to like this and has a tendency to throw permission errors, so this remains single threaded for now. """ - items = cast(Dict[str, List[int]], self._items.items) + items = T.cast(dict[str, list[int]], self._items.items) srcs = [(x[0], x[1]["source"]) - for x in cast(List[Tuple[str, "PNGHeaderDict"]], self._items.file_list_sorted)] + for x in T.cast(list[tuple[str, "PNGHeaderDict"]], self._items.file_list_sorted)] to_update = [ # Items whose face index has changed x for x in srcs if x[1]["face_index"] != items[x[1]["source_filename"]].index(x[1]["face_index"])] @@ -399,13 +395,13 @@ def _update_png_headers(self) -> None: face = DetectedFace() face.from_alignment(self._alignments.get_faces_in_frame(frame)[new_index]) - meta = dict(alignments=face.to_png_meta(), - source=dict(alignments_version=file_info["alignments_version"], - original_filename=orig_filename, - face_index=new_index, - source_filename=frame, - source_is_video=file_info["source_is_video"], - source_frame_dims=file_info.get("source_frame_dims"))) + meta = {"alignments": face.to_png_meta(), + "source": {"alignments_version": file_info["alignments_version"], + "original_filename": orig_filename, + "face_index": new_index, + "source_filename": frame, + "source_is_video": file_info["source_is_video"], + "source_frame_dims": file_info.get("source_frame_dims")}} update_existing_metadata(fullpath, meta) logger.info("%s Extracted face(s) had their header information updated", len(to_update)) @@ -422,18 +418,18 @@ class FaceToFile(): # pylint:disable=too-few-public-methods face_data: list List of :class:`PNGHeaderDict` objects """ - def __init__(self, alignments: "AlignmentData", face_data: List["PNGHeaderDict"]) -> None: + def __init__(self, alignments: AlignmentData, face_data: list[PNGHeaderDict]) -> None: logger.debug("Initializing %s: alignments: %s, face_data: %s", self.__class__.__name__, alignments, len(face_data)) self._alignments = alignments self._face_alignments = face_data - self._updatable_keys: List[Literal["identity", "mask"]] = ["identity", "mask"] - self._counts: Dict[str, int] = {} + self._updatable_keys: list[T.Literal["identity", "mask"]] = ["identity", "mask"] + self._counts: dict[str, int] = {} logger.debug("Initialized %s", self.__class__.__name__) def _check_and_update(self, - alignment: "PNGHeaderAlignmentsDict", - face: "AlignmentFileDict") -> None: + alignment: PNGHeaderAlignmentsDict, + face: AlignmentFileDict) -> None: """ Check whether the key requires updating and update it. alignment: dict diff --git a/tools/alignments/jobs_frames.py b/tools/alignments/jobs_frames.py index 8df193e325..62ce578df1 100644 --- a/tools/alignments/jobs_frames.py +++ b/tools/alignments/jobs_frames.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 """ Tools for manipulating the alignments using Frames as a source """ +from __future__ import annotations import logging import os import sys +import typing as T + from datetime import datetime -from typing import cast, Dict, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 import numpy as np @@ -16,12 +18,7 @@ from plugins.extract.pipeline import Extractor, ExtractMedia from .media import ExtractedFaces, Frames -if sys.version_info < (3, 8): - from typing_extensions import get_args, Literal -else: - from typing import get_args, Literal - -if TYPE_CHECKING: +if T.TYPE_CHECKING: from argparse import Namespace from .media import AlignmentData @@ -39,19 +36,19 @@ class Draw(): # pylint:disable=too-few-public-methods arguments: :class:`argparse.Namespace` The command line arguments that have called this job """ - def __init__(self, alignments: "AlignmentData", arguments: "Namespace") -> None: + def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self._alignments = alignments self._frames = Frames(arguments.frames_dir) self._output_folder = self._set_output() - self._mesh_areas = dict(mouth=(48, 68), - right_eyebrow=(17, 22), - left_eyebrow=(22, 27), - right_eye=(36, 42), - left_eye=(42, 48), - nose=(27, 36), - jaw=(0, 17), - chin=(8, 11)) + self._mesh_areas = {"mouth": (48, 68), + "right_eyebrow": (17, 22), + "left_eyebrow": (22, 27), + "right_eye": (36, 42), + "left_eye": (42, 48), + "nose": (27, 36), + "jaw": (0, 17), + "chin": (8, 11)} logger.debug("Initialized %s", self.__class__.__name__) def _set_output(self) -> str: @@ -145,7 +142,7 @@ def _annotate_extract_boxes(cls, image: np.ndarray, face: DetectedFace, index: i index: int The face index for the given face """ - for area in get_args(Literal["face", "head"]): + for area in T.get_args(T.Literal["face", "head"]): face.load_aligned(image, centering=area, force=True) color = (0, 255, 0) if area == "face" else (0, 0, 255) top_left = face.aligned.original_roi[0] @@ -184,12 +181,12 @@ class Extract(): # pylint:disable=too-few-public-methods arguments: :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in from :mod:`tools.py` """ - def __init__(self, alignments: "AlignmentData", arguments: "Namespace") -> None: + def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self._arguments = arguments self._alignments = alignments self._is_legacy = self._alignments.version == 1.0 # pylint:disable=protected-access - self._mask_pipeline: Optional[Extractor] = None + self._mask_pipeline: Extractor | None = None self._faces_dir = arguments.faces_dir self._min_size = self._get_min_size(arguments.size, arguments.min_size) @@ -197,7 +194,7 @@ def __init__(self, alignments: "AlignmentData", arguments: "Namespace") -> None: self._extracted_faces = ExtractedFaces(self._frames, self._alignments, size=arguments.size) - self._saver: Optional[ImagesSaver] = None + self._saver: ImagesSaver | None = None logger.debug("Initialized %s", self.__class__.__name__) @classmethod @@ -223,7 +220,7 @@ def _get_min_size(cls, extract_size: int, min_size: int) -> int: extract_size, min_size, retval) return retval - def _get_count(self) -> Optional[int]: + def _get_count(self) -> int | None: """ If the alignments file has been run through the manual tool, then it will hold video meta information, meaning that the count of frames in the alignment file can be relied on to be accurate. @@ -237,8 +234,7 @@ def _get_count(self) -> Optional[int]: meta = self._alignments.video_meta_data has_meta = all(val is not None for val in meta.values()) if has_meta: - retval: Optional[int] = len(cast(Dict[str, Union[List[int], List[float]]], - meta["pts_time"])) + retval: int | None = len(T.cast(dict[str, list[int] | list[float]], meta["pts_time"])) else: retval = None logger.debug("Frame count from alignments file: (has_meta: %s, %s", has_meta, retval) @@ -320,7 +316,7 @@ def _export_faces(self) -> None: self._alignments.save() logger.info("%s face(s) extracted", extracted_faces) - def _set_skip_list(self) -> Optional[List[int]]: + def _set_skip_list(self) -> list[int] | None: """ Set the indices for frames that should be skipped based on the `extract_every_n` command line option. @@ -335,7 +331,7 @@ def _set_skip_list(self) -> Optional[List[int]]: logger.debug("Not skipping any frames") return None skip_list = [] - for idx, item in enumerate(cast(List[Dict[str, str]], self._frames.file_list_sorted)): + for idx, item in enumerate(T.cast(list[dict[str, str]], self._frames.file_list_sorted)): if idx % skip_num != 0: logger.trace("Adding image '%s' to skip list due to " # type:ignore "extract_every_n = %s", item["frame_fullname"], skip_num) @@ -370,14 +366,14 @@ def _output_faces(self, filename: str, image: np.ndarray) -> int: for idx, face in enumerate(faces): output = f"{frame_name}_{idx}.png" - meta: PNGHeaderDict = dict( - alignments=face.to_png_meta(), - source=dict(alignments_version=self._alignments.version, - original_filename=output, - face_index=idx, - source_filename=filename, - source_is_video=self._frames.is_video, - source_frame_dims=cast(Tuple[int, int], image.shape[:2]))) + meta: PNGHeaderDict = { + "alignments": face.to_png_meta(), + "source": {"alignments_version": self._alignments.version, + "original_filename": output, + "face_index": idx, + "source_filename": filename, + "source_is_video": self._frames.is_video, + "source_frame_dims": T.cast(tuple[int, int], image.shape[:2])}} assert face.aligned.face is not None self._saver.save(output, encode_image(face.aligned.face, ".png", metadata=meta)) if self._min_size == 0 and self._is_legacy: @@ -387,7 +383,7 @@ def _output_faces(self, filename: str, image: np.ndarray) -> int: self._saver.close() return face_count - def _select_valid_faces(self, frame: str, image: np.ndarray) -> List[DetectedFace]: + def _select_valid_faces(self, frame: str, image: np.ndarray) -> list[DetectedFace]: """ Return the aligned faces from a frame that meet the selection criteria, Parameters @@ -416,7 +412,7 @@ def _select_valid_faces(self, frame: str, image: np.ndarray) -> List[DetectedFac def _process_legacy(self, filename: str, image: np.ndarray, - detected_faces: List[DetectedFace]) -> List[DetectedFace]: + detected_faces: list[DetectedFace]) -> list[DetectedFace]: """ Process legacy face extractions to new extraction method. Updates stored masks to new extract size diff --git a/tools/alignments/media.py b/tools/alignments/media.py index ee471e2f3e..ee6bcc21d2 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 """ Media items (Alignments, Faces, Frames) for alignments tool """ - +from __future__ import annotations import logging from operator import itemgetter import os import sys -from typing import cast, Generator, Dict, List, Optional, Tuple, TYPE_CHECKING, Union +import typing as T import cv2 from tqdm import tqdm @@ -19,7 +19,8 @@ png_write_meta, read_image, read_image_meta_batch) from lib.utils import _image_extensions, _video_extensions, FaceswapError -if TYPE_CHECKING: +if T.TYPE_CHECKING: + from collections.abc import Generator import numpy as np from lib.align.alignments import AlignmentFileDict, PNGHeaderDict @@ -44,7 +45,7 @@ def __init__(self, alignments_file: str) -> None: logger.debug("Initialized %s", self.__class__.__name__) @staticmethod - def check_file_exists(alignments_file: str) -> Tuple[str, str]: + def check_file_exists(alignments_file: str) -> tuple[str, str]: """ Check if the alignments file exists, and returns a tuple of the folder and filename. Parameters @@ -85,7 +86,7 @@ class MediaLoader(): analyzing a video file. If the count is not passed in, it will be calculated. Default: ``None`` """ - def __init__(self, folder: str, count: Optional[int] = None): + def __init__(self, folder: str, count: int | None = None): logger.debug("Initializing %s: (folder: '%s')", self.__class__.__name__, folder) logger.info("[%s DATA]", self.__class__.__name__.upper()) self._count = count @@ -112,7 +113,7 @@ def count(self) -> int: self._count = len(self.file_list_sorted) return self._count - def check_input_folder(self) -> Optional[cv2.VideoCapture]: + def check_input_folder(self) -> cv2.VideoCapture | None: """ Ensure that the frames or faces folder exists and is valid. If frames folder contains a video file return imageio reader object @@ -151,22 +152,20 @@ def valid_extension(filename) -> bool: logger.trace("Filename has valid extension: '%s': %s", filename, retval) # type: ignore return retval - def sorted_items(self) -> Union[List[Dict[str, str]], - List[Tuple[str, "PNGHeaderDict"]]]: + def sorted_items(self) -> list[dict[str, str]] | list[tuple[str, PNGHeaderDict]]: """ Override for specific folder processing """ raise NotImplementedError() - def process_folder(self) -> Union[Generator[Dict[str, str], None, None], - Generator[Tuple[str, "PNGHeaderDict"], None, None]]: + def process_folder(self) -> (Generator[dict[str, str], None, None] | + Generator[tuple[str, PNGHeaderDict], None, None]): """ Override for specific folder processing """ raise NotImplementedError() - def load_items(self) -> Union[Dict[str, List[int]], - Dict[str, Tuple[str, str]]]: + def load_items(self) -> dict[str, list[int]] | dict[str, tuple[str, str]]: """ Override for specific item loading """ raise NotImplementedError() - def load_image(self, filename: str) -> "np.ndarray": + def load_image(self, filename: str) -> np.ndarray: """ Load an image Parameters @@ -187,7 +186,7 @@ def load_image(self, filename: str) -> "np.ndarray": image = read_image(src, raise_error=True) return image - def load_video_frame(self, filename: str) -> "np.ndarray": + def load_video_frame(self, filename: str) -> np.ndarray: """ Load a requested frame from video Parameters @@ -212,8 +211,8 @@ def load_video_frame(self, filename: str) -> "np.ndarray": # image = self._vid_reader.get_next_data()[:, :, ::-1] return image - def stream(self, skip_list: Optional[List[int]] = None - ) -> Generator[Tuple[str, "np.ndarray"], None, None]: + def stream(self, skip_list: list[int] | None = None + ) -> Generator[tuple[str, np.ndarray], None, None]: """ Load the images in :attr:`folder` in the order they are received from :class:`lib.image.ImagesLoader` in a background thread. @@ -239,8 +238,8 @@ def stream(self, skip_list: Optional[List[int]] = None @staticmethod def save_image(output_folder: str, filename: str, - image: "np.ndarray", - metadata: Optional["PNGHeaderDict"] = None) -> None: + image: np.ndarray, + metadata: PNGHeaderDict | None = None) -> None: """ Save an image """ output_file = os.path.join(output_folder, filename) output_file = os.path.splitext(output_file)[0] + ".png" @@ -267,11 +266,11 @@ class Faces(MediaLoader): - When the remove-faces job is being run, when the process will only load faces that exist in the alignments file. Default: ``None`` """ - def __init__(self, folder: str, alignments: Optional[Alignments] = None) -> None: + def __init__(self, folder: str, alignments: Alignments | None = None) -> None: self._alignments = alignments super().__init__(folder) - def _handle_legacy(self, fullpath: str, log: bool = False) -> "PNGHeaderDict": + def _handle_legacy(self, fullpath: str, log: bool = False) -> PNGHeaderDict: """Handle facesets that are legacy (i.e. do not contain alignment information in the header data) @@ -311,8 +310,8 @@ def _handle_legacy(self, fullpath: str, log: bool = False) -> "PNGHeaderDict": def _handle_duplicate(self, fullpath: str, - header_dict: "PNGHeaderDict", - seen: Dict[str, List[int]]) -> bool: + header_dict: PNGHeaderDict, + seen: dict[str, list[int]]) -> bool: """ Check whether the given face has already been seen for the source frame and face index from an existing face. Can happen when filenames have changed due to sorting etc. and users have done multiple extractions/copies and placed all of the faces in the same folder @@ -323,7 +322,7 @@ def _handle_duplicate(self, The full path to the face image that is being checked header_dict : class:`~lib.align.alignments.PNGHeaderDict` The PNG header dictionary for the given face - seen : Dict[str, List[int]] + seen : dict[str, list[int]] Dictionary of original source filename and face indices that have already been seen and will be updated with the face processing now @@ -346,7 +345,7 @@ def _handle_duplicate(self, seen.setdefault(src_filename, []).append(face_index) return False - def process_folder(self) -> Generator[Tuple[str, "PNGHeaderDict"], None, None]: + def process_folder(self) -> Generator[tuple[str, PNGHeaderDict], None, None]: """ Iterate through the faces folder pulling out various information for each face. Yields @@ -358,7 +357,7 @@ def process_folder(self) -> Generator[Tuple[str, "PNGHeaderDict"], None, None]: logger.info("Loading file list from %s", self.folder) filter_count = 0 dupe_count = 0 - seen: Dict[str, List[int]] = {} + seen: dict[str, list[int]] = {} if self._alignments is not None and self._alignments.version < 2.1: # Legacy updating filelist = [os.path.join(self.folder, face) @@ -378,7 +377,7 @@ def process_folder(self) -> Generator[Tuple[str, "PNGHeaderDict"], None, None]: sub_dict = self._handle_legacy(fullpath, not log_once) log_once = True else: - sub_dict = cast("PNGHeaderDict", metadata["itxt"]) + sub_dict = T.cast("PNGHeaderDict", metadata["itxt"]) if self._handle_duplicate(fullpath, sub_dict, seen): dupe_count += 1 @@ -401,7 +400,7 @@ def process_folder(self) -> Generator[Tuple[str, "PNGHeaderDict"], None, None]: "'%s' from where they can be safely deleted", dupe_count, os.path.join(self.folder, "_duplicates")) - def load_items(self) -> Dict[str, List[int]]: + def load_items(self) -> dict[str, list[int]]: """ Load the face names into dictionary. Returns @@ -409,14 +408,14 @@ def load_items(self) -> Dict[str, List[int]]: dict The source filename as key with list of face indices for the frame as value """ - faces: Dict[str, List[int]] = {} - for face in cast(List[Tuple[str, "PNGHeaderDict"]], self.file_list_sorted): + faces: dict[str, list[int]] = {} + for face in T.cast(list[tuple[str, "PNGHeaderDict"]], self.file_list_sorted): src = face[1]["source"] faces.setdefault(src["source_filename"], []).append(src["face_index"]) logger.trace(faces) # type: ignore return faces - def sorted_items(self) -> List[Tuple[str, "PNGHeaderDict"]]: + def sorted_items(self) -> list[tuple[str, PNGHeaderDict]]: """ Return the items sorted by the saved file name. Returns @@ -432,7 +431,7 @@ def sorted_items(self) -> List[Tuple[str, "PNGHeaderDict"]]: class Frames(MediaLoader): """ Object to hold the frames that are to be checked against """ - def process_folder(self) -> Generator[Dict[str, str], None, None]: + def process_folder(self) -> Generator[dict[str, str], None, None]: """ Iterate through the frames folder pulling the base filename Yields @@ -444,7 +443,7 @@ def process_folder(self) -> Generator[Dict[str, str], None, None]: for item in iterator(): yield item - def process_frames(self) -> Generator[Dict[str, str], None, None]: + def process_frames(self) -> Generator[dict[str, str], None, None]: """ Process exported Frames Yields @@ -465,7 +464,7 @@ def process_frames(self) -> Generator[Dict[str, str], None, None]: logger.trace(retval) # type: ignore yield retval - def process_video(self) -> Generator[Dict[str, str], None, None]: + def process_video(self) -> Generator[dict[str, str], None, None]: """Dummy in frames for video Yields @@ -485,7 +484,7 @@ def process_video(self) -> Generator[Dict[str, str], None, None]: logger.trace(retval) # type: ignore yield retval - def load_items(self) -> Dict[str, Tuple[str, str]]: + def load_items(self) -> dict[str, tuple[str, str]]: """ Load the frame info into dictionary Returns @@ -493,14 +492,14 @@ def load_items(self) -> Dict[str, Tuple[str, str]]: dict Fullname as key, tuple of frame name and extension as value """ - frames: Dict[str, Tuple[str, str]] = {} - for frame in cast(List[Dict[str, str]], self.file_list_sorted): + frames: dict[str, tuple[str, str]] = {} + for frame in T.cast(list[dict[str, str]], self.file_list_sorted): frames[frame["frame_fullname"]] = (frame["frame_name"], frame["frame_extension"]) logger.trace(frames) # type: ignore return frames - def sorted_items(self) -> List[Dict[str, str]]: + def sorted_items(self) -> list[dict[str, str]]: """ Return the items sorted by filename Returns @@ -532,11 +531,11 @@ def __init__(self, frames: Frames, alignments: AlignmentData, size: int = 512) - self.padding = int(size * 0.1875) self.alignments = alignments self.frames = frames - self.current_frame: Optional[str] = None - self.faces: List[DetectedFace] = [] + self.current_frame: str | None = None + self.faces: list[DetectedFace] = [] logger.trace("Initialized %s", self.__class__.__name__) # type: ignore - def get_faces(self, frame: str, image: Optional["np.ndarray"] = None) -> None: + def get_faces(self, frame: str, image: np.ndarray | None = None) -> None: """ Obtain faces and transformed landmarks for each face in a given frame with its alignments @@ -561,8 +560,8 @@ def get_faces(self, frame: str, image: Optional["np.ndarray"] = None) -> None: self.current_frame = frame def extract_one_face(self, - alignment: "AlignmentFileDict", - image: "np.ndarray") -> DetectedFace: + alignment: AlignmentFileDict, + image: np.ndarray) -> DetectedFace: """ Extract one face from image Parameters @@ -588,7 +587,7 @@ def extract_one_face(self, def get_faces_in_frame(self, frame: str, update: bool = False, - image: Optional["np.ndarray"] = None) -> List[DetectedFace]: + image: np.ndarray | None = None) -> list[DetectedFace]: """ Return the faces for the selected frame Parameters @@ -613,7 +612,7 @@ def get_faces_in_frame(self, self.get_faces(frame, image=image) return self.faces - def get_roi_size_for_frame(self, frame: str) -> List[int]: + def get_roi_size_for_frame(self, frame: str) -> list[int]: """ Return the size of the original extract box for the selected frame. Parameters diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 97ae49b239..3153142f7f 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 """ Tool to generate masks and previews of masks for existing alignments file """ +from __future__ import annotations import logging import os import sys +import typing as T + from argparse import Namespace from multiprocessing import Process -from typing import cast, List, Optional, Tuple, TYPE_CHECKING, Union import cv2 import numpy as np @@ -18,7 +20,7 @@ from lib.utils import get_folder, _video_extensions from plugins.extract.pipeline import Extractor, ExtractMedia -if TYPE_CHECKING: +if T.TYPE_CHECKING: from lib.align.aligned_face import CenteringType from lib.align.alignments import AlignmentFileDict, PNGHeaderDict from lib.queue_manager import EventQueue @@ -45,7 +47,7 @@ def __init__(self, arguments: Namespace) -> None: self._args = arguments self._input_locations = self._get_input_locations() - def _get_input_locations(self) -> List[str]: + def _get_input_locations(self) -> list[str]: """ Obtain the full path to input locations. Will be a list of locations if batch mode is selected, or containing a single location if batch mode is not selected. @@ -141,18 +143,18 @@ def __init__(self, arguments: Namespace) -> None: self._update_type = arguments.processing self._input_is_faces = arguments.input_type == "faces" self._mask_type = arguments.masker - self._output = dict(opts=dict(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 = dict(face=0, skip=0, update=0) + 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: Optional[ImagesSaver] = None + self._faces_saver: ImagesSaver | None = None self._alignments = self._get_alignments(arguments) self._extractor = self._get_extractor(arguments.exclude_gpus) @@ -178,7 +180,7 @@ 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) -> Optional[ImagesSaver]: + def _set_saver(self, arguments: Namespace) -> ImagesSaver | None: """ set the saver in a background thread Parameters @@ -204,7 +206,7 @@ def _set_saver(self, arguments: Namespace) -> Optional[ImagesSaver]: logger.debug(saver) return saver - def _get_alignments(self, arguments: Namespace) -> Optional[Alignments]: + def _get_alignments(self, arguments: Namespace) -> Alignments | None: """ Obtain the alignments from either the given alignments location or the default location. @@ -242,7 +244,7 @@ def _get_alignments(self, arguments: Namespace) -> Optional[Alignments]: return Alignments(folder, filename=filename) - def _get_extractor(self, exclude_gpus: List[int]) -> Optional[Extractor]: + def _get_extractor(self, exclude_gpus: list[int]) -> Extractor | None: """ Obtain a Mask extractor plugin and launch it Parameters ---------- @@ -303,7 +305,7 @@ def _feed_extractor(self) -> MultiThread: def _process_face(self, filename: str, image: np.ndarray, - metadata: "PNGHeaderDict") -> Optional["ExtractMedia"]: + metadata: PNGHeaderDict) -> ExtractMedia | None: """ Process a single face when masking from face images filename: str @@ -324,7 +326,7 @@ def _process_face(self, if self._alignments is None: # mask from PNG header lookup_index = 0 - alignments = [cast("AlignmentFileDict", metadata["alignments"])] + alignments = [T.cast("AlignmentFileDict", metadata["alignments"])] else: # mask from Alignments file lookup_index = face_index alignments = self._alignments.get_faces_in_frame(frame_name) @@ -350,7 +352,7 @@ def _process_face(self, self._counts["update"] += 1 return media - def _input_faces(self, *args: Union[tuple, Tuple["EventQueue"]]) -> None: + def _input_faces(self, *args: tuple | tuple[EventQueue]) -> None: """ Input pre-aligned faces to the Extractor plugin inside a thread Parameters @@ -362,7 +364,7 @@ def _input_faces(self, *args: Union[tuple, Tuple["EventQueue"]]) -> None: log_once = False logger.debug("args: %s", args) if self._update_type != "output": - queue = cast("EventQueue", args[0]) + 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: @@ -394,7 +396,7 @@ def _input_faces(self, *args: Union[tuple, Tuple["EventQueue"]]) -> None: if self._update_type != "output": queue.put("EOF") - def _input_frames(self, *args: Union[tuple, Tuple["EventQueue"]]) -> None: + def _input_frames(self, *args: tuple | tuple[EventQueue]) -> None: """ Input frames to the Extractor plugin inside a thread Parameters @@ -406,7 +408,7 @@ def _input_frames(self, *args: Union[tuple, Tuple["EventQueue"]]) -> None: assert self._alignments is not None logger.debug("args: %s", args) if self._update_type != "output": - queue = cast("EventQueue", args[0]) + 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): @@ -438,7 +440,7 @@ def _input_frames(self, *args: Union[tuple, Tuple["EventQueue"]]) -> None: if self._update_type != "output": queue.put("EOF") - def _check_for_missing(self, frame: str, idx: int, alignment: "AlignmentFileDict") -> bool: + def _check_for_missing(self, frame: str, idx: int, alignment: AlignmentFileDict) -> bool: """ Check if the alignment is missing the requested mask_type Parameters @@ -482,7 +484,7 @@ def _get_output_suffix(self, arguments: Namespace) -> str: return sfx @classmethod - def _get_detected_face(cls, alignment: "AlignmentFileDict") -> DetectedFace: + def _get_detected_face(cls, alignment: AlignmentFileDict) -> DetectedFace: """ Convert an alignment dict item to a detected_face object Parameters @@ -554,8 +556,8 @@ def _update_faces(self, extractor_output: ExtractMedia) -> None: if self._alignments is not None: self._alignments.update_face(frame_name, face_index, face.to_alignment()) - metadata: "PNGHeaderDict" = dict(alignments=face.to_png_meta(), - source=extractor_output.frame_metadata) + 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)) @@ -645,9 +647,9 @@ def _create_image(self, detected_face: DetectedFace, mask_type: str) -> np.ndarr 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) + 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 diff --git a/tools/model/cli.py b/tools/model/cli.py index 939d6334b7..21117df531 100644 --- a/tools/model/cli.py +++ b/tools/model/cli.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ import gettext -from typing import Any, List, Dict +import typing as T from lib.cli.args import FaceSwapArgs from lib.cli.actions import DirFullPaths, Radio @@ -22,7 +22,7 @@ def get_info() -> str: return _("A tool for performing actions on Faceswap trained model files") @staticmethod - def get_argument_list() -> List[Dict[str, Any]]: + def get_argument_list() -> list[dict[str, T.Any]]: """ Put the arguments in a list so that they are accessible from both argparse and gui """ argument_list = [] argument_list.append(dict( diff --git a/tools/model/model.py b/tools/model/model.py index f8ec6da26d..9bdac28556 100644 --- a/tools/model/model.py +++ b/tools/model/model.py @@ -118,7 +118,7 @@ def __init__(self, arguments: argparse.Namespace) -> None: self._format = arguments.format self._input_file, self._output_file = self._get_output_file(arguments.model_dir) - def _get_output_file(self, model_dir: str) -> T.Tuple[str, str]: + def _get_output_file(self, model_dir: str) -> tuple[str, str]: """ Obtain the full path for the output model file/folder Parameters @@ -183,7 +183,7 @@ def _get_model_filename(cls, model_dir: str) -> str: return os.path.join(model_dir, model_file) def _parse_weights(self, - layer: T.Union[keras.models.Model, keras.layers.Layer]) -> dict: + layer: keras.models.Model | keras.layers.Layer) -> dict: """ Recursively pass through sub-models to scan layer weights""" weights = layer.get_weights() logger.debug("Processing weights for layer '%s', length: '%s'", diff --git a/tools/preview/cli.py b/tools/preview/cli.py index 7c324751f0..da327028bd 100644 --- a/tools/preview/cli.py +++ b/tools/preview/cli.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ import gettext -from typing import Any, List, Dict +import typing as T from lib.cli.args import FaceSwapArgs from lib.cli.actions import DirOrFileFullPaths, DirFullPaths, FileFullPaths @@ -29,7 +29,7 @@ def get_info() -> str: return _("Preview tool\nAllows you to configure your convert settings with a live preview") @staticmethod - def get_argument_list() -> List[Dict[str, Any]]: + def get_argument_list() -> list[dict[str, T.Any]]: """ Put the arguments in a list so that they are accessible from both argparse and gui Returns diff --git a/tools/preview/control_panels.py b/tools/preview/control_panels.py index 9b214bfe4e..f7379cca76 100644 --- a/tools/preview/control_panels.py +++ b/tools/preview/control_panels.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 """ Manages the widgets that hold the bottom 'control' area of the preview tool """ +from __future__ import annotations import gettext import logging +import typing as T + import tkinter as tk from tkinter import ttk from configparser import ConfigParser -from typing import Any, Callable, cast, Dict, List, Optional, TYPE_CHECKING, Union from lib.gui.custom_widgets import Tooltip from lib.gui.control_helper import ControlPanel, ControlPanelOption @@ -14,7 +16,8 @@ from plugins.plugin_loader import PluginLoader from plugins.convert._config import Config -if TYPE_CHECKING: +if T.TYPE_CHECKING: + from collections.abc import Callable from .preview import Preview logger = logging.getLogger(__name__) @@ -34,10 +37,8 @@ class ConfigTools(): """ def __init__(self) -> None: self._config = Config(None) - self.tk_vars: Dict[str, Dict[str, Union[tk.BooleanVar, - tk.StringVar, - tk.IntVar, - tk.DoubleVar]]] = {} + self.tk_vars: dict[str, dict[str, tk.BooleanVar | tk.StringVar | tk.IntVar | tk.DoubleVar] + ] = {} self._config_dicts = self._get_config_dicts() # Holds currently saved config @property @@ -46,18 +47,18 @@ def config(self) -> Config: return self._config @property - def config_dicts(self) -> Dict[str, Any]: + def config_dicts(self) -> dict[str, T.Any]: """ dict: The convert configuration options in dictionary form.""" return self._config_dicts @property - def sections(self) -> List[str]: + def sections(self) -> list[str]: """ list: The sorted section names that exist within the convert Configuration options. """ return sorted(set(plugin.split(".")[0] for plugin in self._config.config.sections() if plugin.split(".")[0] != "writer")) @property - def plugins_dict(self) -> Dict[str, List[str]]: + def plugins_dict(self) -> dict[str, list[str]]: """ dict: Dictionary of configuration option sections as key with a list of containing plugins as the value """ return {section: sorted([plugin.split(".")[1] for plugin in self._config.config.sections() @@ -81,7 +82,7 @@ def update_config(self) -> None: section, item, old_value, new_value) self._config.config[section][item] = new_value - def _get_config_dicts(self) -> Dict[str, Dict[str, Any]]: + def _get_config_dicts(self) -> dict[str, dict[str, T.Any]]: """ Obtain a custom configuration dictionary for convert configuration items in use by the preview tool formatted for control helper. @@ -91,7 +92,7 @@ def _get_config_dicts(self) -> Dict[str, Dict[str, Any]]: Each configuration section as keys, with the values as a dict of option: :class:`lib.gui.control_helper.ControlOption` pairs. """ logger.debug("Formatting Config for GUI") - config_dicts: Dict[str, Dict[str, Any]] = {} + config_dicts: dict[str, dict[str, T.Any]] = {} for section in self._config.config.sections(): if section.startswith("writer."): continue @@ -114,7 +115,7 @@ def _get_config_dicts(self) -> Dict[str, Dict[str, Any]]: logger.debug("Formatted Config for GUI: %s", config_dicts) return config_dicts - def reset_config_to_saved(self, section: Optional[str] = None) -> None: + def reset_config_to_saved(self, section: str | None = None) -> None: """ Reset the GUI parameters to their saved values within the configuration file. Parameters @@ -135,7 +136,7 @@ def reset_config_to_saved(self, section: Optional[str] = None) -> None: logger.debug("Setting %s - %s to saved value %s", config_section, item, val) logger.debug("Reset to saved config: %s", section) - def reset_config_to_default(self, section: Optional[str] = None) -> None: + def reset_config_to_default(self, section: str | None = None) -> None: """ Reset the GUI parameters to their default configuration values. Parameters @@ -157,7 +158,7 @@ def reset_config_to_default(self, section: Optional[str] = None) -> None: config_section, item, default) logger.debug("Reset to default: %s", section) - def save_config(self, section: Optional[str] = None) -> None: + def save_config(self, section: str | None = None) -> None: """ Save the configuration ``.ini`` file with the currently stored values. Notes @@ -258,18 +259,18 @@ class ActionFrame(ttk.Frame): # pylint: disable=too-many-ancestors parent: tkinter object The parent tkinter object that holds the Action Frame """ - def __init__(self, app: 'Preview', parent: ttk.Frame) -> None: + def __init__(self, app: Preview, parent: ttk.Frame) -> None: logger.debug("Initializing %s: (app: %s, parent: %s)", self.__class__.__name__, app, parent) self._app = app super().__init__(parent) self.pack(side=tk.LEFT, anchor=tk.N, fill=tk.Y) - self._tk_vars: Dict[str, tk.StringVar] = {} + self._tk_vars: dict[str, tk.StringVar] = {} - self._options = dict( - color=app._patch.converter.cli_arguments.color_adjustment.replace("-", "_"), - mask_type=app._patch.converter.cli_arguments.mask_type.replace("-", "_")) + self._options = { + "color": app._patch.converter.cli_arguments.color_adjustment.replace("-", "_"), + "mask_type": app._patch.converter.cli_arguments.mask_type.replace("-", "_")} defaults = {opt: self._format_to_display(val) for opt, val in self._options.items()} self._busy_bar = self._build_frame(defaults, @@ -279,7 +280,7 @@ def __init__(self, app: 'Preview', parent: ttk.Frame) -> None: app._samples.predictor.has_predicted_mask) @property - def convert_args(self) -> Dict[str, Any]: + def convert_args(self) -> dict[str, T.Any]: """ dict: Currently selected Command line arguments from the :class:`ActionFrame`. """ return {opt if opt != "color" else "color_adjustment": self._format_from_display(self._tk_vars[opt].get()) @@ -323,10 +324,10 @@ def _format_to_display(var: str) -> str: return var.replace("_", " ").replace("-", " ").title() def _build_frame(self, - defaults: Dict[str, Any], + defaults: dict[str, T.Any], refresh_callback: Callable[[], None], patch_callback: Callable[[], None], - available_masks: List[str], + available_masks: list[str], has_predicted_mask: bool) -> BusyProgressBar: """ Build the :class:`ActionFrame`. @@ -366,8 +367,8 @@ def _build_frame(self, def _add_cli_choices(self, parent: ttk.Frame, - defaults: Dict[str, Any], - available_masks: List[str], + defaults: dict[str, T.Any], + available_masks: list[str], has_predicted_mask: bool) -> None: """ Create :class:`lib.gui.control_helper.ControlPanel` object for the command line options. @@ -382,13 +383,13 @@ def _add_cli_choices(self, Whether the model was trained with a mask """ cp_options = self._get_control_panel_options(defaults, available_masks, has_predicted_mask) - panel_kwargs = dict(blank_nones=False, label_width=10, style="CPanel") + panel_kwargs = {"blank_nones": False, "label_width": 10, "style": "CPanel"} ControlPanel(parent, cp_options, header_text=None, **panel_kwargs) def _get_control_panel_options(self, - defaults: Dict[str, Any], - available_masks: List[str], - has_predicted_mask: bool) -> List[ControlPanelOption]: + defaults: dict[str, T.Any], + available_masks: list[str], + has_predicted_mask: bool) -> list[ControlPanelOption]: """ Create :class:`lib.gui.control_helper.ControlPanelOption` objects for the command line options. @@ -404,7 +405,7 @@ def _get_control_panel_options(self, list The list of `lib.gui.control_helper.ControlPanelOption` objects for the Action Frame """ - cp_options: List[ControlPanelOption] = [] + cp_options: list[ControlPanelOption] = [] for opt in self._options: if opt == "mask_type": choices = self._create_mask_choices(defaults, available_masks, has_predicted_mask) @@ -422,9 +423,9 @@ def _get_control_panel_options(self, return cp_options def _create_mask_choices(self, - defaults: Dict[str, Any], - available_masks: List[str], - has_predicted_mask: bool) -> List[str]: + defaults: dict[str, T.Any], + available_masks: list[str], + has_predicted_mask: bool) -> list[str]: """ Set the mask choices and default mask based on available masks. Parameters @@ -537,7 +538,7 @@ def __init__(self, self.pack(side=tk.RIGHT, anchor=tk.N, fill=tk.BOTH, expand=True) self.config_tools = config_tools - self._tabs: Dict[str, Dict[str, Union[ttk.Notebook, ConfigFrame]]] = {} + self._tabs: dict[str, dict[str, ttk.Notebook | ConfigFrame]] = {} self._build_tabs() self._build_sub_tabs() self._add_patch_callback(patch_callback) @@ -560,7 +561,7 @@ def _build_sub_tabs(self) -> None: tab = ConfigFrame(self, config_key, config_dict) self._tabs[section][plugin] = tab text = plugin.replace("_", " ").title() - cast(ttk.Notebook, self._tabs[section]["tab"]).add(tab, text=text) + T.cast(ttk.Notebook, self._tabs[section]["tab"]).add(tab, text=text) def _add_patch_callback(self, patch_callback: Callable[[], None]) -> None: """ Add callback to re-patch images on configuration option change. @@ -591,7 +592,7 @@ class ConfigFrame(ttk.Frame): # pylint: disable=too-many-ancestors def __init__(self, parent: OptionsBook, config_key: str, - options: Dict[str, Any]): + options: dict[str, T.Any]): logger.debug("Initializing %s", self.__class__.__name__) super().__init__(parent) self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) @@ -616,7 +617,7 @@ def _build_frame(self, parent: OptionsBook, config_key: str) -> None: The section/plugin key for these configuration options """ logger.debug("Add Config Frame") - panel_kwargs = dict(columns=2, option_columns=2, blank_nones=False, style="CPanel") + panel_kwargs = {"columns": 2, "option_columns": 2, "blank_nones": False, "style": "CPanel"} frame = ttk.Frame(self) frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) cp_options = [opt for key, opt in self._options.items() if key != "helptext"] diff --git a/tools/preview/preview.py b/tools/preview/preview.py index f6ab16636e..cdacc1ae3a 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -1,16 +1,16 @@ #!/usr/bin/env python3 """ Tool to preview swaps and tweak configuration prior to running a convert """ - +from __future__ import annotations import gettext import logging import random import tkinter as tk +import typing as T + from tkinter import ttk -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING, Union import os import sys - from threading import Event, Lock, Thread import numpy as np @@ -29,13 +29,7 @@ from .control_panels import ActionFrame, ConfigTools, OptionsBook from .viewer import FacesDisplay, ImagesCanvas - -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - -if TYPE_CHECKING: +if T.TYPE_CHECKING: from argparse import Namespace from lib.queue_manager import EventQueue from .control_panels import BusyProgressBar @@ -62,7 +56,7 @@ class Preview(tk.Tk): # pylint:disable=too-few-public-methods """ _w: str - def __init__(self, arguments: "Namespace") -> None: + def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) super().__init__() self._config_tools = ConfigTools() @@ -73,9 +67,9 @@ def __init__(self, arguments: "Namespace") -> None: self._patch = Patch(self, arguments) self._initialize_tkinter() - self._image_canvas: Optional[ImagesCanvas] = None - self._opts_book: Optional[OptionsBook] = None - self._cli_frame: Optional[ActionFrame] = None # cli frame holds cli options + self._image_canvas: ImagesCanvas | None = None + self._opts_book: OptionsBook | None = None + self._cli_frame: ActionFrame | None = None # cli frame holds cli options logger.debug("Initialized %s", self.__class__.__name__) @property @@ -102,7 +96,7 @@ def lock(self) -> Lock: return self._lock @property - def progress_bar(self) -> "BusyProgressBar": + def progress_bar(self) -> BusyProgressBar: """ :class:`~tools.preview.control_panels.BusyProgressBar`: The progress bar that indicates a swap/patch thread is running """ assert self._cli_frame is not None @@ -278,13 +272,13 @@ class Samples(): The number of samples to take from the input video/images """ - def __init__(self, app: Preview, arguments: "Namespace", sample_size: int) -> None: + def __init__(self, app: Preview, arguments: Namespace, sample_size: int) -> None: logger.debug("Initializing %s: (app: %s, arguments: '%s', sample_size: %s)", self.__class__.__name__, app, arguments, sample_size) self._sample_size = sample_size self._app = app - self._input_images: List[ConvertItem] = [] - self._predicted_images: List[Tuple[ConvertItem, np.ndarray]] = [] + self._input_images: list[ConvertItem] = [] + self._predicted_images: list[tuple[ConvertItem, np.ndarray]] = [] self._images = Images(arguments) self._alignments = Alignments(arguments, @@ -310,7 +304,7 @@ def __init__(self, app: Preview, arguments: "Namespace", sample_size: int) -> No logger.debug("Initialized %s", self.__class__.__name__) @property - def available_masks(self) -> List[str]: + def available_masks(self) -> list[str]: """ list: The mask names that are available for every face in the alignments file """ retval = [key for key, val in self.alignments.mask_summary.items() @@ -323,7 +317,7 @@ def sample_size(self) -> int: return self._sample_size @property - def predicted_images(self) -> List[Tuple[ConvertItem, np.ndarray]]: + def predicted_images(self) -> list[tuple[ConvertItem, np.ndarray]]: """ list: The predicted faces output from the Faceswap model """ return self._predicted_images @@ -338,13 +332,13 @@ def predictor(self) -> Predict: return self._predictor @property - def _random_choice(self) -> List[int]: + def _random_choice(self) -> list[int]: """ list: Random indices from the :attr:`_indices` group """ retval = [random.choice(indices) for indices in self._indices] logger.debug(retval) return retval - def _get_filelist(self) -> List[str]: + def _get_filelist(self) -> list[str]: """ Get a list of files for the input, filtering out those frames which do not contain faces. @@ -372,7 +366,7 @@ def _get_filelist(self) -> List[str]: raise FaceswapError(msg) from err return retval - def _get_indices(self) -> List[List[int]]: + def _get_indices(self) -> list[list[int]]: """ Get indices for each sample group. Obtain :attr:`self.sample_size` evenly sized groups of indices @@ -450,9 +444,8 @@ def _predict(self) -> None: idx = 0 while idx < self._sample_size: logger.debug("Predicting face %s of %s", idx + 1, self._sample_size) - items: Union[Literal["EOF"], - List[Tuple[ConvertItem, - np.ndarray]]] = self._predictor.out_queue.get() + items: (T.Literal["EOF"] | + list[tuple[ConvertItem, np.ndarray]]) = self._predictor.out_queue.get() if items == "EOF": logger.debug("Received EOF") break @@ -481,12 +474,12 @@ class Patch(): # pylint:disable=too-few-public-methods converter_arguments: dict The currently selected converter command line arguments for the patch queue """ - def __init__(self, app: Preview, arguments: "Namespace") -> None: + def __init__(self, app: Preview, arguments: Namespace) -> None: logger.debug("Initializing %s: (app: %s, arguments: '%s')", self.__class__.__name__, app, arguments) self._app = app self._queue_patch_in = queue_manager.get_queue("preview_patch_in") - self.converter_arguments: Optional[Dict[str, Any]] = None # Updated converter args dict + self.converter_arguments: dict[str, T.Any] | None = None # Updated converter args configfile = arguments.configfile if hasattr(arguments, "configfile") else None self._converter = Converter(output_size=app._samples.predictor.output_size, @@ -513,8 +506,8 @@ def converter(self) -> Converter: return self._converter @staticmethod - def _generate_converter_arguments(arguments: "Namespace", - available_masks: List[str]) -> "Namespace": + def _generate_converter_arguments(arguments: Namespace, + available_masks: list[str]) -> Namespace: """ Add the default converter arguments to the initial arguments. Ensure the mask selection is available. @@ -550,7 +543,7 @@ def _generate_converter_arguments(arguments: "Namespace", return arguments def _process(self, - patch_queue_in: "EventQueue", + patch_queue_in: EventQueue, trigger_event: Event, samples: Samples) -> None: """ The face patching process. @@ -601,7 +594,7 @@ def _update_converter_arguments(self) -> None: logger.debug("Updated Converter cli arguments") @staticmethod - def _feed_swapped_faces(patch_queue_in: "EventQueue", samples: Samples) -> None: + def _feed_swapped_faces(patch_queue_in: EventQueue, samples: Samples) -> None: """ Feed swapped faces to the converter's in-queue. Parameters @@ -620,9 +613,9 @@ def _feed_swapped_faces(patch_queue_in: "EventQueue", samples: Samples) -> None: patch_queue_in.put("EOF") def _patch_faces(self, - queue_in: "EventQueue", - queue_out: "EventQueue", - sample_size: int) -> List[np.ndarray]: + queue_in: EventQueue, + queue_out: EventQueue, + sample_size: int) -> list[np.ndarray]: """ Patch faces. Run the convert process on the swapped faces and return the patched faces. diff --git a/tools/preview/viewer.py b/tools/preview/viewer.py index b187603028..7abe11b96d 100644 --- a/tools/preview/viewer.py +++ b/tools/preview/viewer.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 """ Manages the widgets that hold the top 'viewer' area of the preview tool """ +from __future__ import annotations import logging import os import tkinter as tk -from tkinter import ttk +import typing as T +from tkinter import ttk from dataclasses import dataclass, field -from typing import cast, List, Optional, Tuple, TYPE_CHECKING import cv2 import numpy as np @@ -17,7 +18,7 @@ from scripts.convert import ConvertItem -if TYPE_CHECKING: +if T.TYPE_CHECKING: from .preview import Preview logger = logging.getLogger(__name__) @@ -26,10 +27,10 @@ @dataclass class _Faces: """ Dataclass for holding faces """ - filenames: List[str] = field(default_factory=list) - matrix: List[np.ndarray] = field(default_factory=list) - src: List[np.ndarray] = field(default_factory=list) - dst: List[np.ndarray] = field(default_factory=list) + filenames: list[str] = field(default_factory=list) + matrix: list[np.ndarray] = field(default_factory=list) + src: list[np.ndarray] = field(default_factory=list) + dst: list[np.ndarray] = field(default_factory=list) class FacesDisplay(): @@ -55,7 +56,7 @@ class FacesDisplay(): The list of :class:`numpy.ndarray` swapped and patched preview images for bottom row of display """ - def __init__(self, app: 'Preview', size: int, padding: int) -> None: + def __init__(self, app: Preview, size: int, padding: int) -> None: logger.trace("Initializing %s: (app: %s, size: %s, padding: %s)", # type: ignore self.__class__.__name__, app, size, padding) self._size = size @@ -64,21 +65,21 @@ def __init__(self, app: 'Preview', size: int, padding: int) -> None: self._padding = padding self._faces = _Faces() - self._centering: Optional[CenteringType] = None + self._centering: CenteringType | None = None self._faces_source: np.ndarray = np.array([]) self._faces_dest: np.ndarray = np.array([]) - self._tk_image: Optional[ImageTk.PhotoImage] = None + self._tk_image: ImageTk.PhotoImage | None = None # Set from Samples self.update_source = False - self.source: List[ConvertItem] = [] # Source images, filenames + detected faces + self.source: list[ConvertItem] = [] # Source images, filenames + detected faces # Set from Patch - self.destination: List[np.ndarray] = [] # Swapped + patched images + self.destination: list[np.ndarray] = [] # Swapped + patched images logger.trace("Initialized %s", self.__class__.__name__) # type: ignore @property - def tk_image(self) -> Optional[ImageTk.PhotoImage]: + def tk_image(self) -> ImageTk.PhotoImage | None: """ :class:`PIL.ImageTk.PhotoImage`: The compiled preview display in tkinter display format """ return self._tk_image @@ -99,7 +100,7 @@ def set_centering(self, centering: CenteringType) -> None: """ self._centering = centering - def set_display_dimensions(self, dimensions: Tuple[int, int]) -> None: + def set_display_dimensions(self, dimensions: tuple[int, int]) -> None: """ Adjust the size of the frame that will hold the preview samples. Parameters @@ -121,7 +122,7 @@ def update_tk_image(self) -> None: self._tk_image = ImageTk.PhotoImage(pilimg) logger.trace("Updated tk image") # type: ignore - def _get_scale_size(self, image: np.ndarray) -> Tuple[int, int]: + def _get_scale_size(self, image: np.ndarray) -> tuple[int, int]: """ Get the size that the full preview image should be resized to fit in the display window. @@ -180,7 +181,7 @@ def _crop_source_faces(self) -> None: src_img = item.inbound.image detected_face.load_aligned(src_img, size=self._size, - centering=cast(CenteringType, self._centering)) + centering=T.cast(CenteringType, self._centering)) matrix = detected_face.aligned.matrix self._faces.filenames.append(os.path.splitext(item.inbound.filename)[0]) self._faces.matrix.append(matrix) @@ -265,7 +266,7 @@ class ImagesCanvas(ttk.Frame): # pylint:disable=too-many-ancestors parent: tkinter object The parent tkinter object that holds the canvas """ - def __init__(self, app: 'Preview', parent: ttk.PanedWindow) -> None: + def __init__(self, app: Preview, parent: ttk.PanedWindow) -> None: logger.debug("Initializing %s: (app: %s, parent: %s)", self.__class__.__name__, app, parent) super().__init__(parent) diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 715100455d..17d141766d 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -2,14 +2,14 @@ """ A tool that allows for sorting and grouping images in different ways. """ +from __future__ import annotations import logging import os import sys - +import typing as T from argparse import Namespace from shutil import copyfile, rmtree -from typing import Dict, List, Optional, TYPE_CHECKING from tqdm import tqdm @@ -20,7 +20,7 @@ from .sort_methods import SortBlur, SortColor, SortFace, SortHistogram, SortMultiMethod from .sort_methods_aligned import SortDistance, SortFaceCNN, SortPitch, SortSize, SortYaw, SortRoll -if TYPE_CHECKING: +if T.TYPE_CHECKING: from .sort_methods import SortMethod logger = logging.getLogger(__name__) @@ -65,7 +65,7 @@ def _handle_deprecations(self): self._args.sort_method = "color-black" if sort_ == "black-pixels" else sort_ self._args.group_method = "color-black" if group_ == "black-pixels" else group_ - def _get_input_locations(self) -> List[str]: + def _get_input_locations(self) -> list[str]: """ Obtain the full path to input locations. Will be a list of locations if batch mode is selected, or a containing a single location if batch mode is not selected. @@ -127,27 +127,27 @@ class _Sort(): # pylint:disable=too-few-public-methods """ Sorts folders of faces based on input criteria """ def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: arguments: %s", self.__class__.__name__, arguments) - self._processes = dict(blur=SortBlur, - blur_fft=SortBlur, - distance=SortDistance, - yaw=SortYaw, - pitch=SortPitch, - roll=SortRoll, - size=SortSize, - face=SortFace, - face_cnn=SortFaceCNN, - face_cnn_dissim=SortFaceCNN, - hist=SortHistogram, - hist_dissim=SortHistogram, - color_black=SortColor, - color_gray=SortColor, - color_luma=SortColor, - color_green=SortColor, - color_orange=SortColor) + self._processes = {"blur": SortBlur, + "blur_fft": SortBlur, + "distance": SortDistance, + "yaw": SortYaw, + "pitch": SortPitch, + "roll": SortRoll, + "size": SortSize, + "face": SortFace, + "face_cnn": SortFaceCNN, + "face_cnn_dissim": SortFaceCNN, + "hist": SortHistogram, + "hist_dissim": SortHistogram, + "color_black": SortColor, + "color_gray": SortColor, + "color_luma": SortColor, + "color_green": SortColor, + "color_orange": SortColor} self._args = self._parse_arguments(arguments) - self._changes: Dict[str, str] = {} - self.serializer: Optional[Serializer] = None + self._changes: dict[str, str] = {} + self.serializer: Serializer | None = None if arguments.log_changes: self.serializer = get_serializer_from_filename(arguments.log_file_path) @@ -220,7 +220,7 @@ def _parse_arguments(self, arguments): logger.debug("Cleaned arguments: %s", arguments) return arguments - def _get_sorter(self) -> "SortMethod": + def _get_sorter(self) -> SortMethod: """ Obtain a sorter/grouper combo for the selected sort/group by options Returns diff --git a/tools/sort/sort_methods.py b/tools/sort/sort_methods.py index a2f7c2f1e0..507db4ae1f 100644 --- a/tools/sort/sort_methods.py +++ b/tools/sort/sort_methods.py @@ -4,11 +4,13 @@ All sorting methods inherit from :class:`SortMethod` and control functions for scorting one item, sorting a full list of scores and binning based on those sorted scores. """ +from __future__ import annotations import logging import operator import sys +import typing as T -from typing import Any, cast, Dict, Generator, List, Optional, Tuple, TYPE_CHECKING, Union +from collections.abc import Generator import cv2 import numpy as np @@ -19,21 +21,16 @@ from lib.utils import FaceswapError from plugins.extract.recognition.vgg_face2 import Cluster, Recognition as VGGFace -if sys.version_info < (3, 8): - from typing_extensions import Literal -else: - from typing import Literal - -if TYPE_CHECKING: +if T.TYPE_CHECKING: from argparse import Namespace from lib.align.alignments import PNGHeaderAlignmentsDict, PNGHeaderSourceDict logger = logging.getLogger(__name__) -ImgMetaType = Generator[Tuple[str, - Optional[np.ndarray], - Optional["PNGHeaderAlignmentsDict"]], None, None] +ImgMetaType: T.TypeAlias = Generator[tuple[str, + np.ndarray | None, + T.Union["PNGHeaderAlignmentsDict", None]], None, None] class InfoLoader(): @@ -50,14 +47,14 @@ class InfoLoader(): """ def __init__(self, input_dir: str, - info_type: Literal["face", "meta", "all"]) -> None: + info_type: T.Literal["face", "meta", "all"]) -> None: logger.debug("Initializing: %s (input_dir: %s, info_type: %s)", self.__class__.__name__, input_dir, info_type) self._info_type = info_type self._iterator = None self._description = "Reading image statistics..." self._loader = ImagesLoader(input_dir) if info_type == "face" else FacesLoader(input_dir) - self._cached_source_data: Dict[str, "PNGHeaderSourceDict"] = {} + self._cached_source_data: dict[str, PNGHeaderSourceDict] = {} if self._loader.count == 0: logger.error("No images to process in location: '%s'", input_dir) sys.exit(1) @@ -103,7 +100,7 @@ def __call__(self) -> ImgMetaType: def _get_alignments(self, filename: str, - metadata: Dict[str, Any]) -> Optional["PNGHeaderAlignmentsDict"]: + metadata: dict[str, T.Any]) -> PNGHeaderAlignmentsDict | None: """ Obtain the alignments from a PNG Header. The other image metadata is cached locally in case a sort method needs to write back to the @@ -182,7 +179,7 @@ def _image_data_reader(self) -> ImgMetaType: leave=False): yield filename, image, None - def update_png_header(self, filename: str, alignments: "PNGHeaderAlignmentsDict") -> None: + def update_png_header(self, filename: str, alignments: PNGHeaderAlignmentsDict) -> None: """ Update the PNG header of the given file with the given alignments. NB: Header information can only be updated if the face is already on at least alignment @@ -201,7 +198,7 @@ def update_png_header(self, filename: str, alignments: "PNGHeaderAlignmentsDict" return self._cached_source_data[filename]["alignments_version"] = 2.3 if vers == 2.2 else vers - header = dict(alignments=alignments, source=self._cached_source_data[filename]) + header = {"alignments": alignments, "source": self._cached_source_data[filename]} update_existing_metadata(filename, header) @@ -221,8 +218,8 @@ class SortMethod(): Default: ``False`` """ def __init__(self, - arguments: "Namespace", - loader_type: Literal["face", "meta", "all"] = "meta", + arguments: Namespace, + loader_type: T.Literal["face", "meta", "all"] = "meta", is_group: bool = False) -> None: logger.debug("Initializing %s: loader_type: '%s' is_group: %s, arguments: %s", self.__class__.__name__, loader_type, is_group, arguments) @@ -231,22 +228,22 @@ def __init__(self, self._method = arguments.group_method if self._is_group else arguments.sort_method self._num_bins: int = arguments.num_bins - self._bin_names: List[str] = [] + self._bin_names: list[str] = [] self._loader_type = loader_type self._iterator = self._get_file_iterator(arguments.input_dir) - self._result: List[Tuple[str, Union[float, np.ndarray]]] = [] - self._binned: List[List[str]] = [] + self._result: list[tuple[str, float | np.ndarray]] = [] + self._binned: list[list[str]] = [] logger.debug("Initialized %s", self.__class__.__name__) @property - def loader_type(self) -> Literal["face", "meta", "all"]: + def loader_type(self) -> T.Literal["face", "meta", "all"]: """ ["face", "meta", "all"]: The loader that this sorter uses """ return self._loader_type @property - def binned(self) -> List[List[str]]: + def binned(self) -> list[list[str]]: """ list: List of bins (list) containing the filenames belonging to the bin. The binning process is called when this property is first accessed""" if not self._binned: @@ -255,7 +252,7 @@ def binned(self) -> List[List[str]]: return self._binned @property - def sorted_filelist(self) -> List[str]: + def sorted_filelist(self) -> list[str]: """ list: List of sorted filenames for given sorter in a single list. The sort process is called when this property is first accessed """ if not self._result: @@ -267,7 +264,7 @@ def sorted_filelist(self) -> List[str]: return retval @property - def bin_names(self) -> List[str]: + def bin_names(self) -> list[str]: """ list: The name of each created bin, if they exist, otherwise an empty list """ return self._bin_names @@ -305,7 +302,7 @@ def _sort_filelist(self) -> None: [r[0] if isinstance(r, (tuple, list)) else r for r in self._result]) @classmethod - def _get_unique_labels(cls, numbers: np.ndarray) -> List[str]: + def _get_unique_labels(cls, numbers: np.ndarray) -> list[str]: """ For a list of threshold values for displaying in the bin name, get the lowest number of decimal figures (down to int) required to have a unique set of folder names and return the formatted numbers. @@ -338,7 +335,7 @@ def _get_unique_labels(cls, numbers: np.ndarray) -> List[str]: logger.debug("rounded values: %s, formatted labels: %s", rounded, retval) return retval - def _binning_linear_threshold(self, units: str = "", multiplier: int = 1) -> List[List[str]]: + def _binning_linear_threshold(self, units: str = "", multiplier: int = 1) -> list[list[str]]: """ Standard linear binning method for binning by threshold. The minimum and maximum result from :attr:`_result` are taken, A range is created between @@ -367,7 +364,7 @@ def _binning_linear_threshold(self, units: str = "", multiplier: int = 1) -> Lis f"{labels[idx]}{units}_to_{labels[idx + 1]}{units}" for idx in range(self._num_bins)] - bins: List[List[str]] = [[] for _ in range(self._num_bins)] + bins: list[list[str]] = [[] for _ in range(self._num_bins)] for filename, result in self._result: bin_idx = next(bin_id for bin_id, thresh in enumerate(thresholds) if result <= thresh) - 1 @@ -375,7 +372,7 @@ def _binning_linear_threshold(self, units: str = "", multiplier: int = 1) -> Lis return bins - def _binning(self) -> List[List[str]]: + def _binning(self) -> list[list[str]]: """ Called when :attr:`binning` is first accessed. Checks if sorting has been done, if not triggers it, then does binning @@ -404,8 +401,8 @@ def sort(self) -> None: def score_image(self, filename: str, - image: Optional[np.ndarray], - alignments: Optional["PNGHeaderAlignmentsDict"]) -> None: + image: np.ndarray | None, + alignments: PNGHeaderAlignmentsDict | None) -> None: """ Override for sort method's specificic logic. This method should be executed to get a single score from a single image and add the result to :attr:`_result` @@ -420,7 +417,7 @@ def score_image(self, """ raise NotImplementedError() - def binning(self) -> List[List[str]]: + def binning(self) -> list[list[str]]: """ Group into bins by their sorted score. Override for method specific binning techniques. Binning takes the results from :attr:`_result` compiled during :func:`_sort_filelist` and @@ -434,7 +431,7 @@ def binning(self) -> List[List[str]]: raise NotImplementedError() @classmethod - def _mask_face(cls, image: np.ndarray, alignments: "PNGHeaderAlignmentsDict") -> np.ndarray: + def _mask_face(cls, image: np.ndarray, alignments: PNGHeaderAlignmentsDict) -> np.ndarray: """ Function for applying the mask to an aligned face if both the face image and alignment data are available. @@ -481,7 +478,7 @@ class SortMultiMethod(SortMethod): A sort method object used for sorting and binning the images """ def __init__(self, - arguments: "Namespace", + arguments: Namespace, sort_method: SortMethod, group_method: SortMethod) -> None: self._sorter = sort_method @@ -515,8 +512,8 @@ def _get_file_iterator(self, input_dir: str) -> InfoLoader: def score_image(self, filename: str, - image: Optional[np.ndarray], - alignments: Optional["PNGHeaderAlignmentsDict"]) -> None: + image: np.ndarray | None, + alignments: PNGHeaderAlignmentsDict | None) -> None: """ Score a single image for sort method: "distance", "yaw" "pitch" or "size" and add the result to :attr:`_result` @@ -542,7 +539,7 @@ def sort(self) -> None: self._bin_names = self._grouper.bin_names logger.debug("Sorted") - def binning(self) -> List[List[str]]: + def binning(self) -> list[list[str]]: """ Override standard binning, to bin by the group-by method and sort by the sorting method. @@ -555,9 +552,9 @@ def binning(self) -> List[List[str]]: List of bins of filenames """ sorted_ = self._result - output: List[List[str]] = [] + output: list[list[str]] = [] for bin_ in tqdm(self._binned, desc="Binning and sorting", file=sys.stdout, leave=False): - indices: Dict[int, str] = {} + indices: dict[int, str] = {} for filename in bin_: indices[sorted_.index(filename)] = filename output.append([indices[idx] for idx in sorted(indices)]) @@ -575,7 +572,7 @@ class SortBlur(SortMethod): Set to ``True`` if this class is going to be called exclusively for binning. Default: ``False`` """ - def __init__(self, arguments: "Namespace", is_group: bool = False) -> None: + def __init__(self, arguments: Namespace, is_group: bool = False) -> None: super().__init__(arguments, loader_type="all", is_group=is_group) method = arguments.group_method if self._is_group else arguments.sort_method self._use_fft = method == "blur_fft" @@ -608,7 +605,7 @@ def estimate_blur(self, image: np.ndarray, alignments=None) -> float: def estimate_blur_fft(self, image: np.ndarray, - alignments: Optional["PNGHeaderAlignmentsDict"] = None) -> float: + alignments: PNGHeaderAlignmentsDict | None = None) -> float: """ Estimate the amount of blur a fft filtered image has. Parameters @@ -649,8 +646,8 @@ def estimate_blur_fft(self, def score_image(self, filename: str, - image: Optional[np.ndarray], - alignments: Optional["PNGHeaderAlignmentsDict"]) -> None: + image: np.ndarray | None, + alignments: PNGHeaderAlignmentsDict | None) -> None: """ Score a single image for blur or blur-fft and add the result to :attr:`_result` Parameters @@ -677,7 +674,7 @@ def sort(self) -> None: logger.info("Sorting...") self._result = sorted(self._result, key=operator.itemgetter(1), reverse=True) - def binning(self) -> List[List[str]]: + def binning(self) -> list[list[str]]: """ Create bins to split linearly from the lowest to the highest sample value Returns @@ -699,7 +696,7 @@ class SortColor(SortMethod): Set to ``True`` if this class is going to be called exclusively for binning. Default: ``False`` """ - def __init__(self, arguments: "Namespace", is_group: bool = False) -> None: + def __init__(self, arguments: Namespace, is_group: bool = False) -> None: super().__init__(arguments, loader_type="face", is_group=is_group) self._desired_channel = {'gray': 0, 'luma': 0, 'orange': 1, 'green': 2} @@ -728,7 +725,7 @@ def _convert_color(self, image: np.ndarray) -> np.ndarray: path = np.einsum_path(operation, image[..., :3], conversion, optimize='optimal')[0] return np.einsum(operation, image[..., :3], conversion, optimize=path).astype('float32') - def _near_split(self, bin_range: int) -> List[int]: + def _near_split(self, bin_range: int) -> list[int]: """ Obtain the split for the given number of bins for the given range Parameters @@ -750,14 +747,14 @@ def _near_split(self, bin_range: int) -> List[int]: uplimit += sep return bins - def binning(self) -> List[List[str]]: + def binning(self) -> list[list[str]]: """ Group into bins by percentage of black pixels """ # TODO. Only grouped by black pixels. Check color logger.info("Grouping by percentage of %s...", self._method) # Starting the binning process - bins: List[List[str]] = [[] for _ in range(self._num_bins)] + bins: list[list[str]] = [[] for _ in range(self._num_bins)] # Get edges of bins from 0 to 100 bins_edges = self._near_split(100) # Get the proper bin number for each img order @@ -772,8 +769,8 @@ def binning(self) -> List[List[str]]: def score_image(self, filename: str, - image: Optional[np.ndarray], - alignments: Optional["PNGHeaderAlignmentsDict"]) -> None: + image: np.ndarray | None, + alignments: PNGHeaderAlignmentsDict | None) -> None: """ Score a single image for color Parameters @@ -835,18 +832,18 @@ class SortFace(SortMethod): Set to ``True`` if this class is going to be called exclusively for binning. Default: ``False`` """ - def __init__(self, arguments: "Namespace", is_group: bool = False) -> None: + def __init__(self, arguments: Namespace, is_group: bool = False) -> None: super().__init__(arguments, loader_type="all", is_group=is_group) self._vgg_face = VGGFace(exclude_gpus=arguments.exclude_gpus) self._vgg_face.init_model() threshold = arguments.threshold self._output_update_info = True - self._threshold: Optional[float] = 0.25 if threshold < 0 else threshold + self._threshold: float | None = 0.25 if threshold < 0 else threshold def score_image(self, filename: str, - image: Optional[np.ndarray], - alignments: Optional["PNGHeaderAlignmentsDict"]) -> None: + image: np.ndarray | None, + alignments: PNGHeaderAlignmentsDict | None) -> None: """ Processing logic for sort by face method. Reads header information from the PNG file to look for VGGFace2 embedding. If it does not @@ -912,7 +909,7 @@ def sort(self) -> None: indices = Cluster(np.array(preds), "ward", threshold=self._threshold)() self._result = [(self._result[idx][0], float(score)) for idx, score in indices] - def binning(self) -> List[List[str]]: + def binning(self) -> list[list[str]]: """ Group into bins by their sorted score The bin ID has been output in the 2nd column of :attr:`_result` so use that for binnin @@ -924,7 +921,7 @@ def binning(self) -> List[List[str]]: """ num_bins = len(set(int(i[1]) for i in self._result)) logger.info("Grouping by %s...", self.__class__.__name__.replace("Sort", "")) - bins: List[List[str]] = [[] for _ in range(num_bins)] + bins: list[list[str]] = [[] for _ in range(num_bins)] for filename, bin_id in self._result: bins[int(bin_id)].append(filename) @@ -943,7 +940,7 @@ class SortHistogram(SortMethod): Set to ``True`` if this class is going to be called exclusively for binning. Default: ``False`` """ - def __init__(self, arguments: "Namespace", is_group: bool = False) -> None: + def __init__(self, arguments: Namespace, is_group: bool = False) -> None: super().__init__(arguments, loader_type="all", is_group=is_group) method = arguments.group_method if self._is_group else arguments.sort_method self._is_dissim = method == "hist-dissim" @@ -951,7 +948,7 @@ def __init__(self, arguments: "Namespace", is_group: bool = False) -> None: def _calc_histogram(self, image: np.ndarray, - alignments: Optional["PNGHeaderAlignmentsDict"]) -> np.ndarray: + alignments: PNGHeaderAlignmentsDict | None) -> np.ndarray: if alignments: image = self._mask_face(image, alignments) return cv2.calcHist([image], [0], None, [256], [0, 256]) @@ -994,7 +991,7 @@ def _sort_sim(self) -> None: self._result[i + 1]) @classmethod - def _get_avg_score(cls, image: np.ndarray, references: List[np.ndarray]) -> float: + def _get_avg_score(cls, image: np.ndarray, references: list[np.ndarray]) -> float: """ Return the average histogram score between a face and reference images Parameters @@ -1015,22 +1012,22 @@ def _get_avg_score(cls, image: np.ndarray, references: List[np.ndarray]) -> floa scores.append(score) return sum(scores) / len(scores) - def binning(self) -> List[List[str]]: + def binning(self) -> list[list[str]]: """ Group into bins by histogram """ msg = "dissimilarity" if self._is_dissim else "similarity" logger.info("Grouping by %s...", msg) # Groups are of the form: group_num -> reference histogram - reference_groups: Dict[int, List[np.ndarray]] = {} + reference_groups: dict[int, list[np.ndarray]] = {} # Bins array, where index is the group number and value is # an array containing the file paths to the images in that group - bins: List[List[str]] = [] + bins: list[list[str]] = [] threshold = self._threshold img_list_len = len(self._result) - reference_groups[0] = [cast(np.ndarray, self._result[0][1])] + reference_groups[0] = [T.cast(np.ndarray, self._result[0][1])] bins.append([self._result[0][0]]) for i in tqdm(range(1, img_list_len), @@ -1045,7 +1042,7 @@ def binning(self) -> List[List[str]]: current_key, current_score = key, score if current_score < threshold: - reference_groups[cast(int, current_key)].append(self._result[i][1]) + reference_groups[T.cast(int, current_key)].append(self._result[i][1]) bins[current_key].append(self._result[i][0]) else: reference_groups[len(reference_groups)] = [self._result[i][1]] @@ -1055,8 +1052,8 @@ def binning(self) -> List[List[str]]: def score_image(self, filename: str, - image: Optional[np.ndarray], - alignments: Optional["PNGHeaderAlignmentsDict"]) -> None: + image: np.ndarray | None, + alignments: PNGHeaderAlignmentsDict | None) -> None: """ Collect the histogram for the given face Parameters diff --git a/tools/sort/sort_methods_aligned.py b/tools/sort/sort_methods_aligned.py index d597def85e..ecf6dd4af4 100644 --- a/tools/sort/sort_methods_aligned.py +++ b/tools/sort/sort_methods_aligned.py @@ -2,11 +2,11 @@ """ Sorting methods that use the properties of a :class:`lib.align.AlignedFace` object to obtain their sorting metrics. """ +from __future__ import annotations import logging import operator import sys - -from typing import Dict, List, Optional, TYPE_CHECKING, Union +import typing as T import numpy as np from tqdm import tqdm @@ -15,7 +15,7 @@ from lib.utils import FaceswapError from .sort_methods import SortMethod -if TYPE_CHECKING: +if T.TYPE_CHECKING: from argparse import Namespace from lib.align.alignments import PNGHeaderAlignmentsDict @@ -36,7 +36,7 @@ class SortAlignedMetric(SortMethod): # pylint:disable=too-few-public-methods Set to ``True`` if this class is going to be called exclusively for binning. Default: ``False`` """ - def _get_metric(self, aligned_face: AlignedFace) -> Union[np.ndarray, float]: + def _get_metric(self, aligned_face: AlignedFace) -> np.ndarray | float: """ Obtain the correct metric for the given sort method" Parameters @@ -58,8 +58,8 @@ def sort(self) -> None: def score_image(self, filename: str, - image: Optional[np.ndarray], - alignments: Optional["PNGHeaderAlignmentsDict"]) -> None: + image: np.ndarray | None, + alignments: PNGHeaderAlignmentsDict | None) -> None: """ Score a single image for sort method: "distance", "yaw", "pitch" or "size" and add the result to :attr:`_result` @@ -110,7 +110,7 @@ def sort(self) -> None: logger.info("Sorting...") self._result = sorted(self._result, key=operator.itemgetter(1), reverse=False) - def binning(self) -> List[List[str]]: + def binning(self) -> list[list[str]]: """ Create bins to split linearly from the lowest to the highest sample value Returns @@ -138,7 +138,7 @@ def _get_metric(self, aligned_face: AlignedFace) -> float: """ return aligned_face.pose.pitch - def binning(self) -> List[List[str]]: + def binning(self) -> list[list[str]]: """ Create bins from 0 degrees to 180 degrees based on number of bins Allocate item to bin when it is in range of one of the pre-allocated bins @@ -148,7 +148,7 @@ def binning(self) -> List[List[str]]: list List of bins of filenames """ - thresholds = (np.linspace(90, -90, self._num_bins + 1)) + thresholds = np.linspace(90, -90, self._num_bins + 1) # Start bin names from 0 for more intuitive experience names = np.flip(thresholds.astype("int")) + 90 @@ -157,7 +157,7 @@ def binning(self) -> List[List[str]]: f"degs_to_{int(names[idx + 1])}degs" for idx in range(self._num_bins)] - bins: List[List[str]] = [[] for _ in range(self._num_bins)] + bins: list[list[str]] = [[] for _ in range(self._num_bins)] for filename, result in self._result: result = np.clip(result, -90.0, 90.0) bin_idx = next(bin_id for bin_id, thresh in enumerate(thresholds) @@ -223,7 +223,7 @@ def _get_metric(self, aligned_face: AlignedFace) -> float: size = ((roi[1][0] - roi[0][0]) ** 2 + (roi[1][1] - roi[0][1]) ** 2) ** 0.5 return size - def binning(self) -> List[List[str]]: + def binning(self) -> list[list[str]]: """ Create bins to split linearly from the lowest to the highest sample value Allocate item to bin when it is in range of one of the pre-allocated bins @@ -247,7 +247,7 @@ class SortFaceCNN(SortAlignedMetric): Set to ``True`` if this class is going to be called exclusively for binning. Default: ``False`` """ - def __init__(self, arguments: "Namespace", is_group: bool = False) -> None: + def __init__(self, arguments: Namespace, is_group: bool = False) -> None: super().__init__(arguments, is_group=is_group) self._is_dissim = self._method == "face-cnn-dissim" self._threshold: float = 7.2 if arguments.threshold < 1.0 else arguments.threshold @@ -308,7 +308,7 @@ def _sort_landmarks_dissim(self) -> None: logger.info("Sorting...") self._result = sorted(self._result, key=operator.itemgetter(2), reverse=True) - def binning(self) -> List[List[str]]: + def binning(self) -> list[list[str]]: """ Group into bins by CNN face similarity Returns @@ -320,11 +320,11 @@ def binning(self) -> List[List[str]]: logger.info("Grouping by face-cnn %s...", msg) # Groups are of the form: group_num -> reference faces - reference_groups: Dict[int, List[np.ndarray]] = {} + reference_groups: dict[int, list[np.ndarray]] = {} # Bins array, where index is the group number and value is # an array containing the file paths to the images in that group. - bins: List[List[str]] = [] + bins: list[list[str]] = [] # Comparison threshold used to decide how similar # faces have to be to be grouped together. @@ -362,7 +362,7 @@ def binning(self) -> List[List[str]]: return bins @classmethod - def _get_avg_score(cls, face: np.ndarray, references: List[np.ndarray]) -> float: + def _get_avg_score(cls, face: np.ndarray, references: list[np.ndarray]) -> float: """ Return the average CNN similarity score between a face and reference images Parameters From 4819d5b97d682c3a009b4114a927fc710c2fa115 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 27 Jun 2023 16:05:53 +0100 Subject: [PATCH 829/981] bugfixes: - Remove duplicate line from Dockerfile.gpu - Add more robust Conda checking --- Dockerfile.gpu | 1 - setup.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile.gpu b/Dockerfile.gpu index d62e010fa7..5b9c0abd0a 100755 --- a/Dockerfile.gpu +++ b/Dockerfile.gpu @@ -12,7 +12,6 @@ RUN ln -s $(which python3) /usr/local/bin/python RUN git clone --depth 1 --no-single-branch https://github.com/deepfakes/faceswap.git WORKDIR "/faceswap" -RUN python -m pip install --upgrade pip RUN python -m pip install --upgrade pip RUN python -m pip --no-cache-dir install -r ./requirements/requirements_nvidia.txt diff --git a/setup.py b/setup.py index d17f4c8adb..e9b5539147 100755 --- a/setup.py +++ b/setup.py @@ -101,7 +101,8 @@ def py_version(self) -> tuple[str, str]: def is_conda(self) -> bool: """ Check whether using Conda """ return ("conda" in sys.version.lower() or - os.path.exists(os.path.join(sys.prefix, 'conda-meta'))) + os.path.exists(os.path.join(sys.prefix, 'conda-meta')) + or os.environ.get("CONDA_DEFAULT_ENV")) @property def is_admin(self) -> bool: From dd03aa8573775bdb853ea195dd61cf5c9ee87463 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 27 Jun 2023 18:03:41 +0100 Subject: [PATCH 830/981] setup.py: Revert redundant conda check --- setup.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/setup.py b/setup.py index e9b5539147..d17f4c8adb 100755 --- a/setup.py +++ b/setup.py @@ -101,8 +101,7 @@ def py_version(self) -> tuple[str, str]: def is_conda(self) -> bool: """ Check whether using Conda """ return ("conda" in sys.version.lower() or - os.path.exists(os.path.join(sys.prefix, 'conda-meta')) - or os.environ.get("CONDA_DEFAULT_ENV")) + os.path.exists(os.path.join(sys.prefix, 'conda-meta'))) @property def is_admin(self) -> bool: From caa97d2d3686ec4afd2c8477139f6685ab36cc49 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 27 Jun 2023 18:28:37 +0100 Subject: [PATCH 831/981] bugfix: Sphjinx Requirements --- docs/sphinx_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index 5c28e7c793..a54b540277 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -15,7 +15,7 @@ matplotlib==3.7.1 imageio==2.31.1 imageio-ffmpeg==0.4.8 ffmpy==0.3.0 -nvidia-ml-py==11.525 +nvidia-ml-py=>11.525,<11.526 pytest==7.2.0 pytest-mock==3.10.0 tensorflow>=2.10.0,<2.11.0 From bb532c6e4ab8ca1432c832f9fc37caf21a502ffe Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 27 Jun 2023 18:32:26 +0100 Subject: [PATCH 832/981] typofix: sphinx_requirements.txt --- docs/sphinx_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index a54b540277..1b5f31b65a 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -15,7 +15,7 @@ matplotlib==3.7.1 imageio==2.31.1 imageio-ffmpeg==0.4.8 ffmpy==0.3.0 -nvidia-ml-py=>11.525,<11.526 +nvidia-ml-py>=11.525,<11.526 pytest==7.2.0 pytest-mock==3.10.0 tensorflow>=2.10.0,<2.11.0 From adc96b7d31d10dd4e3160203e7124608dd3051c8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 27 Jun 2023 18:34:43 +0100 Subject: [PATCH 833/981] bugfix: Sphinx Requirements --- docs/sphinx_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index 1b5f31b65a..2055f84fb6 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -1,7 +1,7 @@ # NB Do not install from this requirements file # It is for documentation purposes only -sphinx==7.0.1 +sphinx>=6.0.0,<7.0.0 sphinx_rtd_theme==1.2.2 tqdm==4.65 psutil==5.9.0 From efa4471243acb799cbb0757696612c1d0b20a54e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 28 Jun 2023 18:30:17 +0100 Subject: [PATCH 834/981] typing: - tools.manual.detected_faces - tools.manual.thumbnails --- docs/full/tools/manual.rst | 10 +- lib/align/detected_face.py | 4 + lib/multithreading.py | 5 +- tools/manual/detected_faces.py | 545 ++++++++++++--------------------- tools/manual/manual.py | 3 +- tools/manual/thumbnails.py | 297 ++++++++++++++++++ 6 files changed, 507 insertions(+), 357 deletions(-) create mode 100644 tools/manual/thumbnails.py diff --git a/docs/full/tools/manual.rst b/docs/full/tools/manual.rst index f2428395c3..9f3bed9d8f 100644 --- a/docs/full/tools/manual.rst +++ b/docs/full/tools/manual.rst @@ -47,7 +47,6 @@ detected_faces module ~tools.manual.detected_faces.DetectedFaces ~tools.manual.detected_faces.FaceUpdate ~tools.manual.detected_faces.Filter - ~tools.manual.detected_faces.ThumbsCreator .. rubric:: Module @@ -55,3 +54,12 @@ detected_faces module :members: :undoc-members: :show-inheritance: + +thumbnails module +================== + +.. automodule:: tools.manual.thumbnails + :members: + :undoc-members: + :show-inheritance: + diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index bf48dfb050..c1a7560350 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -203,6 +203,10 @@ def add_identity(self, name: str, embedding: np.ndarray, ) -> None: assert embedding.shape[0] == 512 self._identity[name] = embedding + def clear_all_identities(self) -> None: + """ Remove all stored identity embeddings """ + self._identity = {} + def get_landmark_mask(self, area: T.Literal["eye", "face", "mouth"], blur_kernel: int, diff --git a/lib/multithreading.py b/lib/multithreading.py index 88599aae89..f324080f04 100644 --- a/lib/multithreading.py +++ b/lib/multithreading.py @@ -14,8 +14,9 @@ from collections.abc import Callable, Generator logger = logging.getLogger(__name__) # pylint: disable=invalid-name -_ErrorType: T.TypeAlias = (tuple[type[BaseException], BaseException, TracebackType] | - tuple[T.Any, T.Any, T.Any] | None) +_ErrorType: T.TypeAlias = tuple[type[BaseException], + BaseException, + TracebackType] | tuple[T.Any, T.Any, T.Any] | None _THREAD_NAMES: set[str] = set() diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index 4850e89524..6245bf8932 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -2,31 +2,30 @@ """ Alignments handling for Faceswap's Manual Adjustments tool. Handles the conversion of alignments data to :class:`~lib.align.DetectedFace` objects, and the update of these faces when edits are made in the GUI. """ - +from __future__ import annotations import logging import os import sys import tkinter as tk +import typing as T from copy import deepcopy from queue import Queue, Empty -from time import sleep -from threading import Lock import cv2 -import imageio import numpy as np -from tqdm import tqdm from lib.align import Alignments, AlignedFace, DetectedFace from lib.gui.custom_widgets import PopupProgress from lib.gui.utils import FileHandler -from lib.image import (SingleFrameLoader, ImagesLoader, ImagesSaver, encode_image, - generate_thumbnail) +from lib.image import ImagesLoader, ImagesSaver, encode_image, generate_thumbnail from lib.multithreading import MultiThread from lib.utils import get_folder +if T.TYPE_CHECKING: + from . import manual + from lib.align.alignments import AlignmentFileDict, PNGHeaderDict -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class DetectedFaces(): @@ -46,102 +45,118 @@ class DetectedFaces(): extractor: :class:`~tools.manual.manual.Aligner` The pipeline for passing faces through the aligner and retrieving results """ - def __init__(self, tk_globals, alignments_path, input_location, extractor): + def __init__(self, + tk_globals: manual.TkGlobals, + alignments_path: str, + input_location: str, + extractor: manual.Aligner) -> None: logger.debug("Initializing %s: (tk_globals: %s. alignments_path: %s, input_location: %s " "extractor: %s)", self.__class__.__name__, tk_globals, alignments_path, input_location, extractor) self._globals = tk_globals - self._frame_faces = [] - self._updated_frame_indices = set() + self._frame_faces: list[list[DetectedFace]] = [] + self._updated_frame_indices: set[int] = set() - self._alignments = self._get_alignments(alignments_path, input_location) + self._alignments: Alignments = self._get_alignments(alignments_path, input_location) self._extractor = extractor self._tk_vars = self._set_tk_vars() - self._children = dict(io=_DiskIO(self, input_location), - update=FaceUpdate(self), - filter=Filter(self)) + + self._io = _DiskIO(self, input_location) + self._update = FaceUpdate(self) + self._filter = Filter(self) logger.debug("Initialized %s", self.__class__.__name__) # <<<< PUBLIC PROPERTIES >>>> # # << SUBCLASSES >> # @property - def extractor(self): + def extractor(self) -> manual.Aligner: """ :class:`~tools.manual.manual.Aligner`: The pipeline for passing faces through the aligner and retrieving results. """ return self._extractor @property - def filter(self): + def filter(self) -> Filter: """ :class:`Filter`: Handles returning of faces and stats based on the current user set navigation mode filter. """ - return self._children["filter"] + return self._filter @property - def update(self): + def update(self) -> FaceUpdate: """ :class:`FaceUpdate`: Handles the adding, removing and updating of :class:`~lib.align.DetectedFace` stored within the alignments file. """ - return self._children["update"] + return self._update # << TKINTER VARIABLES >> # @property - def tk_unsaved(self): + def tk_unsaved(self) -> tk.BooleanVar: """ :class:`tkinter.BooleanVar`: The variable indicating whether the alignments have been updated since the last save. """ return self._tk_vars["unsaved"] @property - def tk_edited(self): + def tk_edited(self) -> tk.BooleanVar: """ :class:`tkinter.BooleanVar`: The variable indicating whether an edit has occurred meaning a GUI redraw needs to be triggered. """ return self._tk_vars["edited"] @property - def tk_face_count_changed(self): + def tk_face_count_changed(self) -> tk.BooleanVar: """ :class:`tkinter.BooleanVar`: The variable indicating whether a face has been added or removed meaning the :class:`FaceViewer` grid redraw needs to be triggered. """ return self._tk_vars["face_count_changed"] # << STATISTICS >> # @property - def available_masks(self): - """ dict: The mask type names stored in the alignments; type as key with the number - of faces which possess the mask type as value. """ + def available_masks(self) -> dict[str, int]: + """ dict[str, int]: The mask type names stored in the alignments; type as key with the + number of faces which possess the mask type as value. """ return self._alignments.mask_summary @property - def current_faces(self): - """ list: The most up to date full list of :class:`~lib.align.DetectedFace` - objects. """ + def current_faces(self) -> list[list[DetectedFace]]: + """ list[list[:class:`~lib.align.DetectedFace`]]: The most up to date full list of detected + face objects. """ return self._frame_faces @property - def video_meta_data(self): - """ dict: The frame meta data stored in the alignments file. If data does not exist in the - alignments file then ``None`` is returned for each Key """ + def video_meta_data(self) -> dict[str, list[int] | list[float] | None]: + """ dict[str, list[int] | list[float] | None]: The frame meta data stored in the alignments + file. If data does not exist in the alignments file then ``None`` is returned for each + Key """ return self._alignments.video_meta_data @property - def face_count_per_index(self): - """ list: Count of faces for each frame. List is in frame index order. + def face_count_per_index(self) -> list[int]: + """ list[int]: Count of faces for each frame. List is in frame index order. The list needs to be calculated on the fly as the number of faces in a frame can change based on user actions. """ return [len(faces) for faces in self._frame_faces] # <<<< PUBLIC METHODS >>>> # - def is_frame_updated(self, frame_index): - """ bool: ``True`` if the given frame index has updated faces within it otherwise - ``False`` """ + def is_frame_updated(self, frame_index: int) -> bool: + """ Check whether the given frame index has been updated + + Parameters + ---------- + frame_index: int + The frame index to check + + Returns + ------- + bool: + ``True`` if the given frame index has updated faces within it otherwise ``False`` + """ return frame_index in self._updated_frame_indices - def load_faces(self): + def load_faces(self) -> None: """ Load the faces as :class:`~lib.align.DetectedFace` objects from the alignments file. """ - self._children["io"].load() + self._io.load() - def save(self): + def save(self) -> None: """ Save the alignments file with the latest edits. """ - self._children["io"].save() + self._io.save() def revert_to_saved(self, frame_index): """ Revert the frame's alignments to their saved version for the given frame index. @@ -151,23 +166,23 @@ def revert_to_saved(self, frame_index): frame_index: int The frame that should have their faces reverted to their saved version """ - self._children["io"].revert_to_saved(frame_index) + self._io.revert_to_saved(frame_index) - def extract(self): + def extract(self) -> None: """ Extract the faces in the current video to a user supplied folder. """ - self._children["io"].extract() + self._io.extract() - def save_video_meta_data(self, pts_time, keyframes): + def save_video_meta_data(self, pts_time: list[float], keyframes: list[int]) -> None: """ Save video meta data to the alignments file. This is executed if the video meta data does not already exist in the alignments file, so the video does not need to be scanned on every use of the Manual Tool. Parameters ---------- - pts_time: list - A list of presentation timestamps (`float`) in frame index order for every frame in - the input video - keyframes: list + pts_time: list[float] + A list of presentation timestamps in frame index order for every frame in the input + video + keyframes: list[int] A list of frame indices corresponding to the key frames in the input video. """ if self._globals.is_video: @@ -176,7 +191,8 @@ def save_video_meta_data(self, pts_time, keyframes): # <<<< PRIVATE METHODS >>> # # << INIT >> # @staticmethod - def _set_tk_vars(): + def _set_tk_vars() -> dict[T.Literal["unsaved", "edited", "face_count_changed"], + tk.BooleanVar]: """ Set the required tkinter variables. The alignments specific `unsaved` and `edited` are set here. @@ -189,14 +205,14 @@ def _set_tk_vars(): The internal variable name as key with the tkinter variable as value """ retval = {} - for name in ("unsaved", "edited", "face_count_changed"): + for name in T.get_args(T.Literal["unsaved", "edited", "face_count_changed"]): var = tk.BooleanVar() var.set(False) retval[name] = var logger.debug(retval) return retval - def _get_alignments(self, alignments_path, input_location): + def _get_alignments(self, alignments_path: str, input_location: str) -> Alignments: """ Get the :class:`~lib.align.Alignments` object for the given location. Parameters @@ -244,7 +260,7 @@ class _DiskIO(): # pylint:disable=too-few-public-methods input_location: str The location of the input folder of frames or video file """ - def __init__(self, detected_faces, input_location): + def __init__(self, detected_faces: DetectedFaces, input_location: str) -> None: logger.debug("Initializing %s: (detected_faces: %s, input_location: %s)", self.__class__.__name__, detected_faces, input_location) self._input_location = input_location @@ -257,14 +273,14 @@ def __init__(self, detected_faces, input_location): self._globals = detected_faces._globals # Must be populated after loading faces as video_meta_data may have increased frame count - self._sorted_frame_names = None + self._sorted_frame_names: list[str] = [] logger.debug("Initialized %s", self.__class__.__name__) - def load(self): + def load(self) -> None: """ Load the faces from the alignments file, convert to :class:`~lib.align.DetectedFace`. objects and add to :attr:`_frame_faces`. """ for key in sorted(self._alignments.data): - this_frame_faces = [] + this_frame_faces: list[DetectedFace] = [] for item in self._alignments.data[key]["faces"]: face = DetectedFace() face.from_alignment(item, with_thumb=True) @@ -274,14 +290,15 @@ def load(self): self._frame_faces.append(this_frame_faces) self._sorted_frame_names = sorted(self._alignments.data) - def save(self): + def save(self) -> None: """ Convert updated :class:`~lib.align.DetectedFace` objects to alignments format and save the alignments file. """ if not self._tk_unsaved.get(): logger.debug("Alignments not updated. Returning") return frames = list(self._updated_frame_indices) - logger.verbose("Saving alignments for %s updated frames", len(frames)) + logger.verbose("Saving alignments for %s updated frames", # type:ignore[attr-defined] + len(frames)) for idx, faces in zip(frames, np.array(self._frame_faces)[np.array(frames)]): frame = self._sorted_frame_names[idx] @@ -292,7 +309,7 @@ def save(self): self._updated_frame_indices.clear() self._tk_unsaved.set(False) - def revert_to_saved(self, frame_index): + def revert_to_saved(self, frame_index: int) -> None: """ Revert the frame's alignments to their saved version for the given frame index. Parameters @@ -303,7 +320,8 @@ def revert_to_saved(self, frame_index): if frame_index not in self._updated_frame_indices: logger.debug("Alignments not amended. Returning") return - logger.verbose("Reverting alignments for frame_index %s", frame_index) + logger.verbose("Reverting alignments for frame_index %s", # type:ignore[attr-defined] + frame_index) alignments = self._alignments.data[self._sorted_frame_names[frame_index]]["faces"] faces = self._frame_faces[frame_index] @@ -325,9 +343,25 @@ def revert_to_saved(self, frame_index): self._globals.tk_update.set(True) @classmethod - def _add_remove_faces(cls, alignments, faces): + def _add_remove_faces(cls, + alignments: list[AlignmentFileDict], + faces: list[DetectedFace]) -> bool: """ On a revert, ensure that the alignments and detected face object counts for each frame - are in sync. """ + are in sync. + + Parameters + ---------- + alignments: list[:class:`~lib.align.alignments.AlignmentFileDict`] + Alignments stored for a frame + + faces: list[:class:`~lib.align.DetectedFace`] + List of detected faces for a frame + + Returns + ------- + bool + ``True`` if a face was added or removed otherwise ``False`` + """ num_alignments = len(alignments) num_faces = len(faces) if num_alignments == num_faces: @@ -340,7 +374,7 @@ def _add_remove_faces(cls, alignments, faces): retval = True return retval - def extract(self): + def extract(self) -> None: """ Extract the current faces to a folder. To stop the GUI becoming completely unresponsive (particularly in Windows) the extract is @@ -354,24 +388,27 @@ def extract(self): return logger.debug(dirname) - queue = Queue() + queue: Queue = Queue() pbar = PopupProgress("Extracting Faces...", self._alignments.frames_count + 1) thread = MultiThread(self._background_extract, dirname, queue) thread.start() self._monitor_extract(thread, queue, pbar) - def _monitor_extract(self, thread, queue, progress_bar): + def _monitor_extract(self, + thread: MultiThread, + queue: Queue, + progress_bar: PopupProgress) -> None: """ Monitor the extraction thread, and update the progress bar. On completion, save alignments and clear progress bar. Parameters ---------- - thread: :class:`lib.multithreading.MultiThread` + thread: :class:`~lib.multithreading.MultiThread` The thread that is performing the extraction task queue: :class:`queue.Queue` The queue that the worker thread is putting it's incremental counts to - progress_bar: :class:`lib.gui.custom_widget.PopupProgress` + progress_bar: :class:`~lib.gui.custom_widget.PopupProgress` The popped up progress bar """ thread.check_and_raise_error() @@ -387,7 +424,7 @@ def _monitor_extract(self, thread, queue, progress_bar): break progress_bar.after(100, self._monitor_extract, thread, queue, progress_bar) - def _background_extract(self, output_folder, progress_queue): + def _background_extract(self, output_folder: str, progress_queue: Queue) -> None: """ Perform the background extraction in a thread so GUI doesn't become unresponsive. Parameters @@ -397,32 +434,32 @@ def _background_extract(self, output_folder, progress_queue): progress_queue: :class:`queue.Queue` The queue to place incremental counts to for updating the GUI's progress bar """ - _io = dict(saver=ImagesSaver(get_folder(output_folder), as_bytes=True), - loader=ImagesLoader(self._input_location, count=self._alignments.frames_count)) - - for frame_idx, (filename, image) in enumerate(_io["loader"].load()): - logger.trace("Outputting frame: %s: %s", frame_idx, filename) + saver = ImagesSaver(get_folder(output_folder), as_bytes=True) + loader = ImagesLoader(self._input_location, count=self._alignments.frames_count) + for frame_idx, (filename, image) in enumerate(loader.load()): + logger.trace("Outputting frame: %s: %s", # type:ignore[attr-defined] + frame_idx, filename) src_filename = os.path.basename(filename) - frame_name = os.path.splitext(src_filename)[0] progress_queue.put(1) for face_idx, face in enumerate(self._frame_faces[frame_idx]): - output = f"{frame_name}_{face_idx}.png" + output = f"{os.path.splitext(src_filename)[0]}_{face_idx}.png" aligned = AlignedFace(face.landmarks_xy, image=image, centering="head", size=512) # TODO user selectable size - meta = dict(alignments=face.to_png_meta(), - source=dict(alignments_version=self._alignments.version, - original_filename=output, - face_index=face_idx, - source_filename=src_filename, - source_is_video=self._globals.is_video, - source_frame_dims=image.shape[:2])) - + meta: PNGHeaderDict = {"alignments": face.to_png_meta(), + "source": {"alignments_version": self._alignments.version, + "original_filename": output, + "face_index": face_idx, + "source_filename": src_filename, + "source_is_video": self._globals.is_video, + "source_frame_dims": image.shape[:2]}} + + assert aligned.face is not None b_image = encode_image(aligned.face, ".png", metadata=meta) - _io["saver"].save(output, b_image) - _io["saver"].close() + saver.save(output, b_image) + saver.close() class Filter(): @@ -434,7 +471,7 @@ class Filter(): detected_faces: :class:`DetectedFaces` The parent :class:`DetectedFaces` object """ - def __init__(self, detected_faces): + def __init__(self, detected_faces: DetectedFaces) -> None: logger.debug("Initializing %s: (detected_faces: %s)", self.__class__.__name__, detected_faces) self._globals = detected_faces._globals @@ -442,12 +479,13 @@ def __init__(self, detected_faces): logger.debug("Initialized %s", self.__class__.__name__) @property - def frame_meets_criteria(self): + def frame_meets_criteria(self) -> bool: """ bool: ``True`` if the current frame meets the selected filter criteria otherwise ``False`` """ filter_mode = self._globals.filter_mode frame_faces = self._detected_faces.current_faces[self._globals.frame_index] distance = self._filter_distance + retval = ( filter_mode == "All Frames" or (filter_mode == "No Faces" and not frame_faces) or @@ -455,11 +493,13 @@ def frame_meets_criteria(self): (filter_mode == "Multiple Faces" and len(frame_faces) > 1) or (filter_mode == "Misaligned Faces" and any(face.aligned.average_distance > distance for face in frame_faces))) - logger.trace("filter_mode: %s, frame meets criteria: %s", filter_mode, retval) + assert isinstance(retval, bool) + logger.trace("filter_mode: %s, frame meets criteria: %s", # type:ignore[attr-defined] + filter_mode, retval) return retval @property - def _filter_distance(self): + def _filter_distance(self) -> float: """ float: The currently selected distance when Misaligned Faces filter is selected. """ try: retval = self._globals.tk_filter_distance.get() @@ -469,7 +509,7 @@ def _filter_distance(self): return retval / 100. @property - def count(self): + def count(self) -> int: """ int: The number of frames that meet the filter criteria returned by :attr:`~tools.manual.manual.TkGlobals.filter_mode`. """ face_count_per_index = self._detected_faces.face_count_per_index @@ -485,15 +525,16 @@ def count(self): if any(face.aligned.average_distance > distance for face in frame)) else: retval = len(face_count_per_index) - logger.trace("filter mode: %s, frame count: %s", self._globals.filter_mode, retval) + logger.trace("filter mode: %s, frame count: %s", # type:ignore[attr-defined] + self._globals.filter_mode, retval) return retval @property - def raw_indices(self): - """ dict: The frame and face indices that meet the current filter criteria for each - displayed face. """ - frame_indices = [] - face_indices = [] + def raw_indices(self) -> dict[T.Literal["frame", "face"], list[int]]: + """ dict[str, int]: The frame and face indices that meet the current filter criteria for + each displayed face. """ + frame_indices: list[int] = [] + face_indices: list[int] = [] face_counts = self._detected_faces.face_count_per_index # Copy to avoid recalculations for frame_idx in self.frames_list: @@ -501,13 +542,15 @@ def raw_indices(self): frame_indices.append(frame_idx) face_indices.append(face_idx) - retval = dict(frame=frame_indices, face=face_indices) - logger.trace("frame_indices: %s, face_indices: %s", frame_indices, face_indices) + retval: dict[T.Literal["frame", "face"], list[int]] = {"frame": frame_indices, + "face": face_indices} + logger.trace("frame_indices: %s, face_indices: %s", # type:ignore[attr-defined] + frame_indices, face_indices) return retval @property - def frames_list(self): - """ list: The list of frame indices that meet the filter criteria returned by + def frames_list(self) -> list[int]: + """ list[int]: The list of frame indices that meet the filter criteria returned by :attr:`~tools.manual.manual.TkGlobals.filter_mode`. """ face_count_per_index = self._detected_faces.face_count_per_index if self._globals.filter_mode == "No Faces": @@ -521,8 +564,9 @@ def frames_list(self): retval = [idx for idx, frame in enumerate(self._detected_faces.current_faces) if any(face.aligned.average_distance > distance for face in frame)] else: - retval = range(len(face_count_per_index)) - logger.trace("filter mode: %s, number_frames: %s", self._globals.filter_mode, len(retval)) + retval = list(range(len(face_count_per_index))) + logger.trace("filter mode: %s, number_frames: %s", # type:ignore[attr-defined] + self._globals.filter_mode, len(retval)) return retval @@ -535,7 +579,7 @@ class FaceUpdate(): detected_faces: :class:`DetectedFaces` The parent :class:`DetectedFaces` object """ - def __init__(self, detected_faces): + def __init__(self, detected_faces: DetectedFaces) -> None: logger.debug("Initializing %s: (detected_faces: %s)", self.__class__.__name__, detected_faces) self._detected_faces = detected_faces @@ -547,7 +591,7 @@ def __init__(self, detected_faces): logger.debug("Initialized %s", self.__class__.__name__) @property - def _tk_edited(self): + def _tk_edited(self) -> tk.BooleanVar: """ :class:`tkinter.BooleanVar`: The variable indicating whether an edit has occurred meaning a GUI redraw needs to be triggered. @@ -558,7 +602,7 @@ def _tk_edited(self): return self._detected_faces.tk_edited @property - def _tk_face_count_changed(self): + def _tk_face_count_changed(self) -> tk.BooleanVar: """ :class:`tkinter.BooleanVar`: The variable indicating whether an edit has occurred meaning a GUI redraw needs to be triggered. @@ -568,7 +612,7 @@ def _tk_face_count_changed(self): """ return self._detected_faces.tk_face_count_changed - def _faces_at_frame_index(self, frame_index): + def _faces_at_frame_index(self, frame_index: int) -> list[DetectedFace]: """ Checks whether the frame has already been added to :attr:`_updated_frame_indices` and adds it. Triggers the unsaved variable if this is the first edited frame. Returns the detected face objects for the given frame. @@ -589,7 +633,7 @@ def _faces_at_frame_index(self, frame_index): retval = self._frame_faces[frame_index] return retval - def add(self, frame_index, pnt_x, width, pnt_y, height): + def add(self, frame_index: int, pnt_x: int, width: int, pnt_y: int, height: int) -> None: """ Add a :class:`~lib.align.DetectedFace` object to the current frame with the given dimensions. @@ -615,7 +659,7 @@ def add(self, frame_index, pnt_x, width, pnt_y, height): face.load_aligned(None) self._tk_face_count_changed.set(True) - def delete(self, frame_index, face_index): + def delete(self, frame_index: int, face_index: int) -> None: """ Delete the :class:`~lib.align.DetectedFace` object for the given frame and face indices. @@ -632,7 +676,14 @@ def delete(self, frame_index, face_index): self._tk_face_count_changed.set(True) self._globals.tk_update.set(True) - def bounding_box(self, frame_index, face_index, pnt_x, width, pnt_y, height, aligner="FAN"): + def bounding_box(self, + frame_index: int, + face_index: int, + pnt_x: int, + width: int, + pnt_y: int, + height: int, + aligner: T.Literal["cv2-dnn", "FAN"] = "FAN") -> None: """ Update the bounding box for the :class:`~lib.align.DetectedFace` object at the given frame and face indices, with the given dimensions and update the 68 point landmarks from the :class:`~tools.manual.manual.Aligner` for the updated bounding box. @@ -654,17 +705,23 @@ def bounding_box(self, frame_index, face_index, pnt_x, width, pnt_y, height, ali aligner: ["cv2-dnn", "FAN"], optional The aligner to use to generate the landmarks. Default: "FAN" """ - logger.trace("frame_index: %s, face_index %s, pnt_x %s, width %s, pnt_y %s, height %s, " - "aligner: %s", frame_index, face_index, pnt_x, width, pnt_y, height, aligner) + logger.trace("frame_index: %s, face_index %s, pnt_x %s, " # type:ignore[attr-defined] + "width %s, pnt_y %s, height %s, aligner: %s", + frame_index, face_index, pnt_x, width, pnt_y, height, aligner) face = self._faces_at_frame_index(frame_index)[face_index] face.left = pnt_x face.width = width face.top = pnt_y face.height = height - face._landmarks_xy = self._extractor.get_landmarks(frame_index, face_index, aligner) + face.add_landmarks_xy(self._extractor.get_landmarks(frame_index, face_index, aligner)) self._globals.tk_update.set(True) - def landmark(self, frame_index, face_index, landmark_index, shift_x, shift_y, is_zoomed): + def landmark(self, + frame_index: int, face_index: int, + landmark_index: int, + shift_x: int, + shift_y: int, + is_zoomed: bool) -> None: """ Shift a single landmark point for the :class:`~lib.align.DetectedFace` object at the given frame and face indices by the given x and y values. @@ -698,7 +755,7 @@ def landmark(self, frame_index, face_index, landmark_index, shift_x, shift_y, is landmark = cv2.transform(landmark, matrix, landmark.shape).squeeze() face.landmarks_xy[landmark_index] = landmark else: - for lmk, idx in zip(landmark, landmark_index): + for lmk, idx in zip(landmark, landmark_index): # type:ignore[call-overload] lmk = np.reshape(lmk, (1, 1, 2)) lmk = cv2.transform(lmk, matrix, lmk.shape).squeeze() face.landmarks_xy[idx] = lmk @@ -706,7 +763,7 @@ def landmark(self, frame_index, face_index, landmark_index, shift_x, shift_y, is face.landmarks_xy[landmark_index] += (shift_x, shift_y) self._globals.tk_update.set(True) - def landmarks(self, frame_index, face_index, shift_x, shift_y): + def landmarks(self, frame_index: int, face_index: int, shift_x: int, shift_y: int) -> None: """ Shift all of the landmarks and bounding box for the :class:`~lib.align.DetectedFace` object at the given frame and face indices by the given x and y values and update the masks. @@ -728,12 +785,17 @@ def landmarks(self, frame_index, face_index, shift_x, shift_y): aligned with the newly adjusted landmarks. """ face = self._faces_at_frame_index(frame_index)[face_index] + assert face.left is not None and face.top is not None face.left += shift_x face.top += shift_y - face._landmarks_xy += (shift_x, shift_y) + face.add_landmarks_xy(face.landmarks_xy + (shift_x, shift_y)) self._globals.tk_update.set(True) - def landmarks_rotate(self, frame_index, face_index, angle, center): + def landmarks_rotate(self, + frame_index: int, + face_index: int, + angle: np.ndarray, + 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 given angle from the given center point. @@ -751,11 +813,15 @@ def landmarks_rotate(self, frame_index, face_index, angle, center): """ face = self._faces_at_frame_index(frame_index)[face_index] rot_mat = cv2.getRotationMatrix2D(tuple(center.astype("float32")), angle, 1.) - face._landmarks_xy = cv2.transform(np.expand_dims(face.landmarks_xy, axis=0), - rot_mat).squeeze() + face.add_landmarks_xy(cv2.transform(np.expand_dims(face.landmarks_xy, axis=0), + rot_mat).squeeze()) self._globals.tk_update.set(True) - def landmarks_scale(self, frame_index, face_index, scale, center): + def landmarks_scale(self, + frame_index: int, + face_index: int, + scale: np.ndarray, + center: np.ndarray) -> None: """ Scale the landmarks on an Extract Box resize for the :class:`~lib.align.DetectedFace` object at the given frame and face indices from the given center point. @@ -772,10 +838,10 @@ def landmarks_scale(self, frame_index, face_index, scale, center): The center point of the Landmark's Extract Box """ face = self._faces_at_frame_index(frame_index)[face_index] - face._landmarks_xy = ((face.landmarks_xy - center) * scale) + center + face.add_landmarks_xy(((face.landmarks_xy - center) * scale) + center) self._globals.tk_update.set(True) - def mask(self, frame_index, face_index, mask, mask_type): + def mask(self, frame_index: int, face_index: int, mask: np.ndarray, mask_type: str) -> None: """ Update the mask on an edit for the :class:`~lib.align.DetectedFace` object at the given frame and face indices, for the given mask and mask type. @@ -795,7 +861,7 @@ def mask(self, frame_index, face_index, mask, mask_type): self._tk_edited.set(True) self._globals.tk_update.set(True) - def copy(self, frame_index, direction): + def copy(self, frame_index: int, direction: T.Literal["prev", "next"]) -> None: """ Copy the alignments from the previous or next frame that has alignments to the current frame. @@ -836,7 +902,7 @@ def copy(self, frame_index, direction): self._tk_face_count_changed.set(True) self._globals.tk_update.set(True) - def post_edit_trigger(self, frame_index, face_index): + def post_edit_trigger(self, frame_index: int, face_index: int) -> None: """ Update the jpg thumbnail, the viewport thumbnail, the landmark masks and the aligned face on a face edit. @@ -850,241 +916,14 @@ def post_edit_trigger(self, frame_index, face_index): face = self._frame_faces[frame_index][face_index] face.load_aligned(None, force=True) # Update average distance face.mask = self._extractor.get_masks(frame_index, face_index) - face._identity = {} + face.clear_all_identities() aligned = AlignedFace(face.landmarks_xy, image=self._globals.current_frame["image"], centering="head", size=96) + assert aligned.face is not None face.thumbnail = generate_thumbnail(aligned.face, size=96) if self._globals.filter_mode == "Misaligned Faces": self._detected_faces.tk_face_count_changed.set(True) self._tk_edited.set(True) - - -class ThumbsCreator(): - """ Background loader to generate thumbnails for the alignments file. Generates low resolution - thumbnails in parallel threads for faster processing. - - Parameters - ---------- - detected_faces: :class:`~tool.manual.faces.DetectedFaces` - The :class:`~lib.align.DetectedFace` objects for this video - input_location: str - The location of the input folder of frames or video file - """ - def __init__(self, detected_faces, input_location, single_process): - logger.debug("Initializing %s: (detected_faces: %s, input_location: %s, " - "single_process: %s)", self.__class__.__name__, detected_faces, - input_location, single_process) - self._size = 80 - self._pbar = dict(pbar=None, lock=Lock()) - self._meta = dict(key_frames=detected_faces.video_meta_data.get("keyframes", None), - pts_times=detected_faces.video_meta_data.get("pts_time", None)) - self._location = input_location - self._alignments = detected_faces._alignments - self._frame_faces = detected_faces._frame_faces - - self._is_video = all(val is not None for val in self._meta.values()) - self._num_threads = os.cpu_count() - 2 - if self._is_video and single_process: - self._num_threads = 1 - elif self._is_video and not single_process: - self._num_threads = min(self._num_threads, len(self._meta["key_frames"])) - else: - self._num_threads = max(self._num_threads, 32) - self._threads = [] - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def has_thumbs(self): - """ bool: ``True`` if the underlying alignments file holds thumbnail images - otherwise ``False``. """ - return self._alignments.thumbnails.has_thumbnails - - def generate_cache(self): - """ Extract the face thumbnails from a video or folder of images into the - alignments file. """ - self._pbar["pbar"] = tqdm(desc="Caching Thumbnails", - leave=False, - total=len(self._frame_faces)) - if self._is_video: - self._launch_video() - else: - self._launch_folder() - while True: - self._check_and_raise_error() - if all(not thread.is_alive() for thread in self._threads): - break - sleep(1) - self._join_threads() - self._pbar["pbar"].close() - self._alignments.save() - - # << PRIVATE METHODS >> # - def _check_and_raise_error(self): - """ Monitor the loading threads for errors and raise if any occur. """ - for thread in self._threads: - thread.check_and_raise_error() - - def _join_threads(self): - """ Join the loading threads """ - logger.debug("Joining face viewer loading threads") - for thread in self._threads: - thread.join() - - def _launch_video(self): - """ Launch multiple :class:`lib.multithreading.MultiThread` objects to load faces from - a video file. - - Splits the video into segments and passes each of these segments to separate background - threads for some speed up. - """ - key_frame_split = len(self._meta["key_frames"]) // self._num_threads - key_frames = self._meta["key_frames"] - pts_times = self._meta["pts_times"] - for idx in range(self._num_threads): - is_final = idx == self._num_threads - 1 - start_idx = idx * key_frame_split - keyframe_idx = len(key_frames) - 1 if is_final else start_idx + key_frame_split - end_idx = key_frames[keyframe_idx] - start_pts = pts_times[key_frames[start_idx]] - end_pts = False if idx + 1 == self._num_threads else pts_times[end_idx] - starting_index = pts_times.index(start_pts) - if end_pts: - segment_count = len(pts_times[key_frames[start_idx]:end_idx]) - else: - segment_count = len(pts_times[key_frames[start_idx]:]) - logger.debug("thread index: %s, start_idx: %s, end_idx: %s, start_pts: %s, " - "end_pts: %s, starting_index: %s, segment_count: %s", idx, start_idx, - end_idx, start_pts, end_pts, starting_index, segment_count) - thread = MultiThread(self._load_from_video, - start_pts, - end_pts, - starting_index, - segment_count) - thread.start() - self._threads.append(thread) - - def _launch_folder(self): - """ Launch :class:`lib.multithreading.MultiThread` to retrieve faces from a - folder of images. - - Goes through the file list one at a time, passing each file to a separate background - thread for some speed up. - """ - reader = SingleFrameLoader(self._location) - num_threads = min(reader.count, self._num_threads) - frame_split = reader.count // self._num_threads - logger.debug("total images: %s, num_threads: %s, frames_per_thread: %s", - reader.count, num_threads, frame_split) - for idx in range(num_threads): - is_final = idx == num_threads - 1 - start_idx = idx * frame_split - end_idx = reader.count if is_final else start_idx + frame_split - thread = MultiThread(self._load_from_folder, reader, start_idx, end_idx) - thread.start() - self._threads.append(thread) - - def _load_from_video(self, pts_start, pts_end, start_index, segment_count): - """ Loads faces from video for the given segment of the source video. - - Each segment of the video is extracted from in a different background thread. - - Parameters - ---------- - pts_start: float - The start time to cut the segment out of the video - pts_end: float - The end time to cut the segment out of the video - start_index: int - The frame index that this segment starts from. Used for calculating the actual frame - index of each frame extracted - segment_count: int - The number of frames that appear in this segment. Used for ending early in case more - frames come out of the segment than should appear (sometimes more frames are picked up - at the end of the segment, so these are discarded) - """ - logger.debug("pts_start: %s, pts_end: %s, start_index: %s, segment_count: %s", - pts_start, pts_end, start_index, segment_count) - reader = self._get_reader(pts_start, pts_end) - idx = 0 - sample_filename = next(fname for fname in self._alignments.data) - vidname = sample_filename[:sample_filename.rfind("_")] - for idx, frame in enumerate(reader): - frame_idx = idx + start_index - filename = f"{vidname}_{frame_idx + 1:06d}.png" - self._set_thumbail(filename, frame[..., ::-1], frame_idx) - if idx == segment_count - 1: - # Sometimes extra frames are picked up at the end of a segment, so stop - # processing when segment frame count has been hit. - break - reader.close() - logger.debug("Segment complete: (starting_frame_index: %s, processed_count: %s)", - start_index, idx) - - def _get_reader(self, pts_start, pts_end): - """ Get an imageio iterator for this thread's segment. - - Parameters - ---------- - pts_start: float - The start time to cut the segment out of the video - pts_end: float - The end time to cut the segment out of the video - - Returns - ------- - :class:`imageio.Reader` - A reader iterator for the requested segment of video - """ - input_params = ["-ss", str(pts_start)] - if pts_end: - input_params.extend(["-to", str(pts_end)]) - logger.debug("pts_start: %s, pts_end: %s, input_params: %s", - pts_start, pts_end, input_params) - return imageio.get_reader(self._location, "ffmpeg", input_params=input_params) - - def _load_from_folder(self, reader, start_index, end_index): - """ Loads faces from the given range of frame indices from a folder of images. - - Each frame range is extracted in a different background thread. - - Parameters - ---------- - reader: :class:`lib.image.SingleFrameLoader` - The reader that is used to retrieve the requested frame - start_index: int - The starting frame index for the images to extract faces from - end_index: int - The end frame index for the images to extract faces from - """ - logger.debug("reader: %s, start_index: %s, end_index: %s", - reader, start_index, end_index) - for frame_index in range(start_index, end_index): - filename, frame = reader.image_from_index(frame_index) - self._set_thumbail(filename, frame, frame_index) - logger.debug("Segment complete: (start_index: %s, processed_count: %s)", - start_index, end_index - start_index) - - def _set_thumbail(self, filename, frame, frame_index): - """ Extracts the faces from the frame and adds to alignments file - - Parameters - ---------- - filename: str - The filename of the frame within the alignments file - frame: :class:`numpy.ndarray` - The frame that contains the faces - frame_index: int - The frame index of this frame in the :attr:`_frame_faces` - """ - for face_idx, face in enumerate(self._frame_faces[frame_index]): - aligned = AlignedFace(face.landmarks_xy, - image=frame, - centering="head", - size=96) - face.thumbnail = generate_thumbnail(aligned.face, size=96) - self._alignments.thumbnails.add_thumbnail(filename, face_idx, face.thumbnail) - with self._pbar["lock"]: - self._pbar["pbar"].update(1) diff --git a/tools/manual/manual.py b/tools/manual/manual.py index 313cab697a..a5a378fcdf 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -18,9 +18,10 @@ from lib.utils import _video_extensions from plugins.extract.pipeline import Extractor, ExtractMedia -from .detected_faces import DetectedFaces, ThumbsCreator +from .detected_faces import DetectedFaces from .faceviewer.frame import FacesFrame from .frameviewer.frame import DisplayFrame +from .thumbnails import ThumbsCreator logger = logging.getLogger(__name__) # pylint: disable=invalid-name diff --git a/tools/manual/thumbnails.py b/tools/manual/thumbnails.py new file mode 100644 index 0000000000..b877346c27 --- /dev/null +++ b/tools/manual/thumbnails.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +""" Thumbnail generator for the manual tool """ +from __future__ import annotations +import logging +import typing as T +import os + +from dataclasses import dataclass +from time import sleep +from threading import Lock + +import imageio +import numpy as np + +from tqdm import tqdm +from lib.align import AlignedFace +from lib.image import SingleFrameLoader, generate_thumbnail +from lib.multithreading import MultiThread + +if T.TYPE_CHECKING: + from .detected_faces import DetectedFaces + +logger = logging.getLogger(__name__) + + +@dataclass +class ProgressBar: + """ Thread-safe progress bar for tracking thumbnail generation progress """ + pbar: tqdm | None = None + lock = Lock() + + +@dataclass +class VideoMeta: + """ Holds meta information about a video file + + Parameters + ---------- + key_frames: list[int] + List of key frame indices for the video + pts_times: list[float] + List of presentation timestams for the video + """ + key_frames: list[int] | None = None + pts_times: list[float] | None = None + + +class ThumbsCreator(): + """ Background loader to generate thumbnails for the alignments file. Generates low resolution + thumbnails in parallel threads for faster processing. + + Parameters + ---------- + detected_faces: :class:`~tool.manual.faces.DetectedFaces` + The :class:`~lib.align.DetectedFace` objects for this video + input_location: str + The location of the input folder of frames or video file + single_process: bool + ``True`` to generated thumbs in a single process otherwise ``False`` + """ + def __init__(self, + detected_faces: DetectedFaces, + input_location: str, + single_process: bool) -> None: + logger.debug("Initializing %s: (detected_faces: %s, input_location: %s, " + "single_process: %s)", self.__class__.__name__, detected_faces, + input_location, single_process) + self._size = 80 + self._pbar = ProgressBar() + self._meta = VideoMeta( + key_frames=T.cast(list[int] | None, + detected_faces.video_meta_data.get("keyframes", None)), + pts_times=T.cast(list[float] | None, + detected_faces.video_meta_data.get("pts_time", None))) + self._location = input_location + self._alignments = detected_faces._alignments + self._frame_faces = detected_faces._frame_faces + + self._is_video = self._meta.pts_times is not None and self._meta.key_frames is not None + + cpu_count = os.cpu_count() + self._num_threads = 1 if cpu_count is None or cpu_count <= 2 else cpu_count - 2 + + if self._is_video and single_process: + self._num_threads = 1 + elif self._is_video and not single_process: + assert self._meta.key_frames is not None + self._num_threads = min(self._num_threads, len(self._meta.key_frames)) + else: + self._num_threads = max(self._num_threads, 32) + self._threads: list[MultiThread] = [] + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def has_thumbs(self) -> bool: + """ bool: ``True`` if the underlying alignments file holds thumbnail images + otherwise ``False``. """ + return self._alignments.thumbnails.has_thumbnails + + def generate_cache(self) -> None: + """ Extract the face thumbnails from a video or folder of images into the + alignments file. """ + self._pbar.pbar = tqdm(desc="Caching Thumbnails", + leave=False, + total=len(self._frame_faces)) + if self._is_video: + self._launch_video() + else: + self._launch_folder() + while True: + self._check_and_raise_error() + if all(not thread.is_alive() for thread in self._threads): + break + sleep(1) + self._join_threads() + self._pbar.pbar.close() + self._alignments.save() + + # << PRIVATE METHODS >> # + def _check_and_raise_error(self) -> None: + """ Monitor the loading threads for errors and raise if any occur. """ + for thread in self._threads: + thread.check_and_raise_error() + + def _join_threads(self) -> None: + """ Join the loading threads """ + logger.debug("Joining face viewer loading threads") + for thread in self._threads: + thread.join() + + def _launch_video(self) -> None: + """ Launch multiple :class:`lib.multithreading.MultiThread` objects to load faces from + a video file. + + Splits the video into segments and passes each of these segments to separate background + threads for some speed up. + """ + key_frames = self._meta.key_frames + pts_times = self._meta.pts_times + assert key_frames is not None and pts_times is not None + key_frame_split = len(key_frames) // self._num_threads + for idx in range(self._num_threads): + is_final = idx == self._num_threads - 1 + start_idx: int = idx * key_frame_split + keyframe_idx = len(key_frames) - 1 if is_final else start_idx + key_frame_split + end_idx = key_frames[keyframe_idx] + start_pts = pts_times[key_frames[start_idx]] + end_pts = False if idx + 1 == self._num_threads else pts_times[end_idx] + starting_index = pts_times.index(start_pts) + if end_pts: + segment_count = len(pts_times[key_frames[start_idx]:end_idx]) + else: + segment_count = len(pts_times[key_frames[start_idx]:]) + logger.debug("thread index: %s, start_idx: %s, end_idx: %s, start_pts: %s, " + "end_pts: %s, starting_index: %s, segment_count: %s", idx, start_idx, + end_idx, start_pts, end_pts, starting_index, segment_count) + thread = MultiThread(self._load_from_video, + start_pts, + end_pts, + starting_index, + segment_count) + thread.start() + self._threads.append(thread) + + def _launch_folder(self) -> None: + """ Launch :class:`lib.multithreading.MultiThread` to retrieve faces from a + folder of images. + + Goes through the file list one at a time, passing each file to a separate background + thread for some speed up. + """ + reader = SingleFrameLoader(self._location) + num_threads = min(reader.count, self._num_threads) + frame_split = reader.count // self._num_threads + logger.debug("total images: %s, num_threads: %s, frames_per_thread: %s", + reader.count, num_threads, frame_split) + for idx in range(num_threads): + is_final = idx == num_threads - 1 + start_idx = idx * frame_split + end_idx = reader.count if is_final else start_idx + frame_split + thread = MultiThread(self._load_from_folder, reader, start_idx, end_idx) + thread.start() + self._threads.append(thread) + + def _load_from_video(self, + pts_start: float, + pts_end: float, + start_index: int, + segment_count: int) -> None: + """ Loads faces from video for the given segment of the source video. + + Each segment of the video is extracted from in a different background thread. + + Parameters + ---------- + pts_start: float + The start time to cut the segment out of the video + pts_end: float + The end time to cut the segment out of the video + start_index: int + The frame index that this segment starts from. Used for calculating the actual frame + index of each frame extracted + segment_count: int + The number of frames that appear in this segment. Used for ending early in case more + frames come out of the segment than should appear (sometimes more frames are picked up + at the end of the segment, so these are discarded) + """ + logger.debug("pts_start: %s, pts_end: %s, start_index: %s, segment_count: %s", + pts_start, pts_end, start_index, segment_count) + reader = self._get_reader(pts_start, pts_end) + idx = 0 + sample_filename = next(fname for fname in self._alignments.data) + vidname = sample_filename[:sample_filename.rfind("_")] + for idx, frame in enumerate(reader): + frame_idx = idx + start_index + filename = f"{vidname}_{frame_idx + 1:06d}.png" + self._set_thumbail(filename, frame[..., ::-1], frame_idx) + if idx == segment_count - 1: + # Sometimes extra frames are picked up at the end of a segment, so stop + # processing when segment frame count has been hit. + break + reader.close() + logger.debug("Segment complete: (starting_frame_index: %s, processed_count: %s)", + start_index, idx) + + def _get_reader(self, pts_start: float, pts_end: float): + """ Get an imageio iterator for this thread's segment. + + Parameters + ---------- + pts_start: float + The start time to cut the segment out of the video + pts_end: float + The end time to cut the segment out of the video + + Returns + ------- + :class:`imageio.Reader` + A reader iterator for the requested segment of video + """ + input_params = ["-ss", str(pts_start)] + if pts_end: + input_params.extend(["-to", str(pts_end)]) + logger.debug("pts_start: %s, pts_end: %s, input_params: %s", + pts_start, pts_end, input_params) + return imageio.get_reader(self._location, + "ffmpeg", # type:ignore[arg-type] + input_params=input_params) + + def _load_from_folder(self, + reader: SingleFrameLoader, + start_index: int, + end_index: int) -> None: + """ Loads faces from the given range of frame indices from a folder of images. + + Each frame range is extracted in a different background thread. + + Parameters + ---------- + reader: :class:`lib.image.SingleFrameLoader` + The reader that is used to retrieve the requested frame + start_index: int + The starting frame index for the images to extract faces from + end_index: int + The end frame index for the images to extract faces from + """ + logger.debug("reader: %s, start_index: %s, end_index: %s", + reader, start_index, end_index) + for frame_index in range(start_index, end_index): + filename, frame = reader.image_from_index(frame_index) + self._set_thumbail(filename, frame, frame_index) + logger.debug("Segment complete: (start_index: %s, processed_count: %s)", + start_index, end_index - start_index) + + def _set_thumbail(self, filename: str, frame: np.ndarray, frame_index: int) -> None: + """ Extracts the faces from the frame and adds to alignments file + + Parameters + ---------- + filename: str + The filename of the frame within the alignments file + frame: :class:`numpy.ndarray` + The frame that contains the faces + frame_index: int + The frame index of this frame in the :attr:`_frame_faces` + """ + for face_idx, face in enumerate(self._frame_faces[frame_index]): + aligned = AlignedFace(face.landmarks_xy, + image=frame, + centering="head", + size=96) + face.thumbnail = generate_thumbnail(aligned.face, size=96) + assert face.thumbnail is not None + self._alignments.thumbnails.add_thumbnail(filename, face_idx, face.thumbnail) + with self._pbar.lock: + assert self._pbar.pbar is not None + self._pbar.pbar.update(1) From 4b20316975658e30213a3af44bdfa7da789554b4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 28 Jun 2023 18:43:37 +0100 Subject: [PATCH 835/981] bugfix: manual tool - alignments saving --- tools/manual/detected_faces.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index 6245bf8932..79283667cc 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -300,7 +300,8 @@ def save(self) -> None: logger.verbose("Saving alignments for %s updated frames", # type:ignore[attr-defined] len(frames)) - for idx, faces in zip(frames, np.array(self._frame_faces)[np.array(frames)]): + for idx, faces in zip(frames, + np.array(self._frame_faces, dtype="object")[np.array(frames)]): frame = self._sorted_frame_names[idx] self._alignments.data[frame]["faces"] = [face.to_alignment() for face in faces] From 99fd844bb3d6cfedd53a384b59d898e8531c0a96 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 28 Jun 2023 18:54:59 +0100 Subject: [PATCH 836/981] bugfix: plugins.aligner - predict dtype --- plugins/extract/align/_base/aligner.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/extract/align/_base/aligner.py b/plugins/extract/align/_base/aligner.py index 75dae9bbf7..7db036c40a 100644 --- a/plugins/extract/align/_base/aligner.py +++ b/plugins/extract/align/_base/aligner.py @@ -531,7 +531,8 @@ def _predict(self, batch: BatchType) -> AlignerBatch: """ assert isinstance(batch, AlignerBatch) try: - batch.prediction = np.array([self.predict(feed) for feed in batch.refeeds]) + batch.prediction = np.array([self.predict(feed) + for feed in batch.refeeds], dtype="object") return batch except tf_errors.ResourceExhaustedError as err: msg = ("You do not have enough GPU memory available to run detection at the " From 467ff6bf2049fa172cf516eda85a2152269a237d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 28 Jun 2023 19:16:03 +0100 Subject: [PATCH 837/981] bugfix: Handle dtype change for cv2-dnn aligner --- plugins/extract/align/_base/aligner.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/plugins/extract/align/_base/aligner.py b/plugins/extract/align/_base/aligner.py index 7db036c40a..bf9d8c2da0 100644 --- a/plugins/extract/align/_base/aligner.py +++ b/plugins/extract/align/_base/aligner.py @@ -531,8 +531,22 @@ def _predict(self, batch: BatchType) -> AlignerBatch: """ assert isinstance(batch, AlignerBatch) try: - batch.prediction = np.array([self.predict(feed) - for feed in batch.refeeds], dtype="object") + preds = [self.predict(feed) for feed in batch.refeeds] + try: + batch.prediction = np.array(preds) + except ValueError as err: + # If refeed batches are different sizes, Numpy will error, so we need to explicitly + # set the dtype to 'object' rather than let it infer + # numpy error: + # ValueError: setting an array element with a sequence. The requested array has an + # inhomogeneous shape after 1 dimensions. The detected shape was (9,) + + # inhomogeneous part + if "inhomogeneous" in str(err): + logger.trace( # type:ignore[attr-defined] + "Mismatched array sizes, setting dtype to object: %s", + [p.shape for p in preds]) + batch.prediction = np.array(preds, dtype="object") + return batch except tf_errors.ResourceExhaustedError as err: msg = ("You do not have enough GPU memory available to run detection at the " From b870c7d9bbabfd5ddedaac590e1bbc33eda5133f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 28 Jun 2023 19:17:43 +0100 Subject: [PATCH 838/981] typofix --- plugins/extract/align/_base/aligner.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/extract/align/_base/aligner.py b/plugins/extract/align/_base/aligner.py index bf9d8c2da0..3eec920c08 100644 --- a/plugins/extract/align/_base/aligner.py +++ b/plugins/extract/align/_base/aligner.py @@ -546,6 +546,8 @@ def _predict(self, batch: BatchType) -> AlignerBatch: "Mismatched array sizes, setting dtype to object: %s", [p.shape for p in preds]) batch.prediction = np.array(preds, dtype="object") + else: + raise return batch except tf_errors.ResourceExhaustedError as err: From ce86d091db8ae24bb0bfade1eb82e894c1250386 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 30 Jun 2023 02:45:19 +0100 Subject: [PATCH 839/981] bugfix: git communication fixes --- docs/full/lib/git.rst | 10 +++ lib/git.py | 157 +++++++++++++++++++++++++++++++++ lib/gui/menu.py | 197 ++++++++++++++++-------------------------- lib/sysinfo.py | 23 ++--- 4 files changed, 247 insertions(+), 140 deletions(-) create mode 100644 docs/full/lib/git.rst create mode 100644 lib/git.py diff --git a/docs/full/lib/git.rst b/docs/full/lib/git.rst new file mode 100644 index 0000000000..3f8d8de585 --- /dev/null +++ b/docs/full/lib/git.rst @@ -0,0 +1,10 @@ +********** +git module +********** + +Handles interfacing with the git executable + +.. automodule:: lib.git + :members: + :undoc-members: + :show-inheritance: diff --git a/lib/git.py b/lib/git.py new file mode 100644 index 0000000000..90cba0af3c --- /dev/null +++ b/lib/git.py @@ -0,0 +1,157 @@ +#!/usr/bin python3 +""" Handles command line calls to git """ +import logging +import os +import sys + +from subprocess import PIPE, Popen + +logger = logging.getLogger(__name__) + + +class Git(): + """ Handles calls to github """ + def __init__(self) -> None: + logger.debug("Initializing: %s", self.__class__.__name__) + self._working_dir = os.path.dirname(os.path.realpath(sys.argv[0])) + self._available = self._check_available() + logger.debug("Initialized: %s", self.__class__.__name__) + + def _from_git(self, command: str) -> tuple[bool, list[str]]: + """ Execute a git command + + Parameters + ---------- + command : str + The command to send to git + + Returns + ------- + success: bool + ``True`` if the command succesfully executed otherwise ``False`` + list[str] + The output lines from stdout if there was no error, otherwise from stderr + """ + logger.debug("command: '%s'", command) + cmd = f"git {command}" + with Popen(cmd, shell=True, stdout=PIPE, stderr=PIPE, cwd=self._working_dir) as proc: + stdout, stderr = proc.communicate() + retcode = proc.returncode + success = retcode == 0 + lines = stdout.decode("utf-8", errors="replace").splitlines() + if not lines: + lines = stderr.decode("utf-8", errors="replace").splitlines() + logger.debug("command: '%s', returncode: %s, success: %s, lines: %s", + cmd, retcode, success, lines) + return success, lines + + def _check_available(self) -> bool: + """ Check if git is available. Does a call to git status. If the process errors due to + folder ownership, attempts to add the folder to github safe folders list and tries + again + + Returns + ------- + bool + ``True`` if git is available otherwise ``False`` + + """ + success, msg = self._from_git("status") + if success: + return True + config = next((line.strip() for line in msg if "add safe.directory" in line), None) + if not config: + return False + success, _ = self._from_git(config.split("git ", 1)[-1]) + return True + + @property + def status(self) -> list[str]: + """ Obtain the output of git status for tracked files only """ + if not self._available: + return [] + success, status = self._from_git("status -uno") + if not success or not status: + return [] + return status + + @property + def branch(self) -> str: + """ str: The git branch that is currently being used to execute Faceswap. """ + status = next((line.strip() for line in self.status if "On branch" in line), "Not Found") + return status.replace("On branch ", "") + + @property + def branches(self) -> list[str]: + """ list[str]: List of all available branches. """ + if not self._available: + return [] + success, branches = self._from_git("branch -a") + if not success or not branches: + return [] + return branches + + def update_remote(self) -> bool: + """ Update all branches to track remote + + Returns + ------- + bool + ``True`` if update was succesful otherwise ``False`` + """ + if not self._available: + return False + return self._from_git("remote update")[0] + + def pull(self) -> bool: + """ Pull the current branch + + Returns + ------- + bool + ``True`` if pull is successful otherwise ``False`` + """ + if not self._available: + return False + return self._from_git("pull")[0] + + def checkout(self, branch: str) -> bool: + """ Checkout the requested branch + + Parameters + ---------- + branch : str + The branch to checkout + + Returns + ------- + bool + ``True`` if the branch was succesfully checkout out otherwise ``False`` + """ + if not self._available: + return False + return self._from_git(f"checkout {branch}")[0] + + def get_commits(self, count: int) -> list[str]: + """ Obtain the last commits to the repo + + Parameters + ---------- + count : int + The last number of commits to obtain + + Returns + ------- + list[str] + list of commits, or empty list if none found + """ + if not self._available: + return [] + success, commits = self._from_git(f"log --pretty=oneline --abbrev-commit -n {count}") + if not success or not commits: + return [] + return commits + + +git = Git() +""" :class:`Git`: Handles calls to github """ diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 694655b021..2a3026f43d 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -2,17 +2,14 @@ """ The Menu Bars for faceswap GUI """ from __future__ import annotations import gettext -import locale import logging import os -import sys import tkinter as tk import typing as T from tkinter import ttk import webbrowser -from subprocess import Popen, PIPE, STDOUT - +from lib.git import git from lib.multithreading import MultiThread from lib.serializer import get_serializer, Serializer from lib.utils import FaceswapError @@ -31,8 +28,6 @@ _LANG = gettext.translation("gui.menu", localedir="locales", fallback=True) _ = _LANG.gettext -_WORKING_DIR = os.path.dirname(os.path.realpath(sys.argv[0])) - _RESOURCES: list[tuple[str, str]] = [ (_("faceswap.dev - Guides and Forum"), "https://www.faceswap.dev"), (_("Patreon - Support this project"), "https://www.patreon.com/faceswap"), @@ -274,13 +269,40 @@ def _output_sysinfo(self): self.root.config(cursor="") @classmethod - def _check_for_updates(cls, encoding: str, check: bool = False) -> bool: + def _process_status_output(cls, status: list[str]) -> bool: + """ Process the output of a git status call and output information + + Parameters + ---------- + status : list[str] + The lines returned from a git status call + + Returns + ------- + bool + ``True`` if the repo can be updated otherwise ``False`` + """ + for line in status: + if line.lower().startswith("your branch is ahead"): + logger.warning("Your branch is ahead of the remote repo. Not updating") + return False + if line.lower().startswith("your branch is up to date"): + logger.info("Faceswap is up to date.") + return False + if "have diverged" in line.lower(): + logger.warning("Your branch has diverged from the remote repo. Not updating") + return False + if line.lower().startswith("your branch is behind"): + return True + + logger.warning("Unable to retrieve status of branch") + return False + + def _check_for_updates(self, check: bool = False) -> bool: """ Check whether an update is required Parameters ---------- - encoding: str - The encoding to use for decoding process returns check: bool ``True`` if we are just checking for updates ``False`` if a check and update is to be performed. Default: ``False`` @@ -292,93 +314,52 @@ def _check_for_updates(cls, encoding: str, check: bool = False) -> bool: """ # Do the check logger.info("Checking for updates...") - update = False - msg = "" - gitcmd = "git remote update && git status -uno" - with Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, cwd=_WORKING_DIR) as cmd: - stdout, _ = cmd.communicate() - retcode = cmd.poll() - if retcode != 0: - msg = ("Git is not installed or you are not running a cloned repo. " - "Unable to check for updates") - else: - chk = stdout.decode(encoding, errors="replace").splitlines() - for line in chk: - if line.lower().startswith("your branch is ahead"): - msg = "Your branch is ahead of the remote repo. Not updating" - break - if line.lower().startswith("your branch is up to date"): - msg = "Faceswap is up to date." - break - if line.lower().startswith("your branch is behind"): - msg = "There are updates available" - update = True - break - if "have diverged" in line.lower(): - msg = "Your branch has diverged from the remote repo. Not updating" - break - if not update or check: - logger.info(msg) - logger.debug("Checked for update. Update required: %s", update) - return update + msg = ("Git is not installed or you are not running a cloned repo. " + "Unable to check for updates") + + sync = git.update_remote() + if not sync: + logger.warning(msg) + return False + + status = git.status + if not status: + logger.warning(msg) + return False + + retval = self._process_status_output(status) + if retval and check: + logger.info("There are updates available") + return retval def _check(self) -> None: """ Check for updates and clone repository """ logger.debug("Checking for updates...") self.root.config(cursor="watch") - encoding = locale.getpreferredencoding() - logger.debug("Encoding: %s", encoding) - self._check_for_updates(encoding, check=True) + self._check_for_updates(check=True) self.root.config(cursor="") - @classmethod - def _do_update(cls, encoding: str) -> bool: + def _do_update(self) -> bool: """ Update Faceswap - Parameters - ---------- - encoding: str - The encoding to use for decoding process returns - Returns ------- bool ``True`` if update was successful """ logger.info("A new version is available. Updating...") - gitcmd = "git pull" - with Popen(gitcmd, - shell=True, - stdout=PIPE, - stderr=STDOUT, - bufsize=1, - cwd=_WORKING_DIR) as cmd: - while True: - out = cmd.stdout - output = "" if out is None else out.readline().decode(encoding, errors="replace") - if output == "" and cmd.poll() is not None: - break - if output: - logger.debug("'%s' output: '%s'", gitcmd, output.strip()) - print(output.strip()) - retcode = cmd.poll() - logger.debug("'%s' returncode: %s", gitcmd, retcode) - if retcode != 0: - logger.info("An error occurred during update. return code: %s", retcode) - retval = False - else: - retval = True - return retval + success = git.pull() + if not success: + logger.info("An error occurred during update") + return success def _update(self) -> None: """ Check for updates and clone repository """ logger.debug("Updating Faceswap...") self.root.config(cursor="watch") - encoding = locale.getpreferredencoding() - logger.debug("Encoding: %s", encoding) success = False - if self._check_for_updates(encoding): - success = self._do_update(encoding) + if self._check_for_updates(): + success = self._do_update() update_deps.main(is_gui=True) if success: logger.info("Please restart Faceswap to complete the update.") @@ -416,11 +397,11 @@ def _build_branches_menu(self) -> bool: bool ``True`` if menu was successfully built otherwise ``False`` """ - stdout = self._get_branches() - if stdout is None: + branches = git.branches + if not branches: return False - branches = self._filter_branches(stdout) + branches = self._filter_branches(branches) if not branches: return False @@ -431,36 +412,13 @@ def _build_branches_menu(self) -> bool: return True @classmethod - def _get_branches(cls) -> str | None: - """ Get the available github branches - - Returns - ------- - str or ``None`` - The list of branches available. If no branches were found or there was an - error then `None` is returned - """ - gitcmd = "git branch -a" - with Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, cwd=_WORKING_DIR) as cmd: - stdout, _ = cmd.communicate() - retcode = cmd.poll() - if retcode != 0: - logger.debug("Unable to list git branches. return code: %s, message: %s", - retcode, - stdout.decode(locale.getpreferredencoding(), - errors="replace").strip().replace("\n", " - ")) - return None - return stdout.decode(locale.getpreferredencoding(), errors="replace") - - @classmethod - def _filter_branches(cls, stdout: str) -> list[str]: - """ Filter the branches, remove duplicates and the current branch and return a sorted - list. + def _filter_branches(cls, branches: list[str]) -> list[str]: + """ Filter the branches, remove any non-local branches Parameters ---------- - stdout: str - The output from the git branch query converted to a string + branches: list[str] + list of available git branches Returns ------- @@ -468,20 +426,22 @@ def _filter_branches(cls, stdout: str) -> list[str]: Unique list of available branches sorted in alphabetical order """ current = None - branches = set() - for line in stdout.splitlines(): - branch = line[line.rfind("/") + 1:] if "/" in line else line.strip() + unique = set() + for line in branches: + branch = line.strip() + if branch.startswith("remotes"): + continue if branch.startswith("*"): branch = branch.replace("*", "").strip() current = branch continue - branches.add(branch) - logger.debug("Found branches: %s", branches) - if current in branches: + unique.add(branch) + logger.debug("Found branches: %s", unique) + if current in unique: logger.debug("Removing current branch from output: %s", current) - branches.remove(current) + unique.remove(current) - retval = sorted(list(branches), key=str.casefold) + retval = sorted(list(unique), key=str.casefold) logger.debug("Final branches: %s", retval) return retval @@ -495,15 +455,8 @@ def _switch_branch(cls, branch: str) -> None: The branch to switch to """ logger.info("Switching branch to '%s'...", branch) - gitcmd = f"git checkout {branch}" - with Popen(gitcmd, shell=True, stdout=PIPE, stderr=STDOUT, cwd=_WORKING_DIR) as cmd: - stdout, _ = cmd.communicate() - retcode = cmd.poll() - if retcode != 0: - logger.error("Unable to switch branch. return code: %s, message: %s", - retcode, - stdout.decode(T.cast(str, locale.getdefaultlocale()), - errors="replace").strip().replace("\n", " - ")) + if not git.checkout(branch): + logger.error("Unable to switch branch to '%s'", branch) return logger.info("Succesfully switched to '%s'. You may want to check for updates to make sure " "that you have the latest code.", branch) diff --git a/lib/sysinfo.py b/lib/sysinfo.py index 6d40d1783f..fa0e2f984a 100644 --- a/lib/sysinfo.py +++ b/lib/sysinfo.py @@ -11,6 +11,7 @@ import psutil +from lib.git import git from lib.gpu_stats import GPUStats, GPUInfo from lib.utils import get_backend from setup import CudaCheck @@ -125,27 +126,13 @@ def _conda_version(self) -> str: version = stdout.decode(self._encoding, errors="replace").splitlines() return "\n".join(version) - @property - def _git_branch(self) -> str: - """ str: The git branch that is currently being used to execute Faceswap. """ - with Popen("git status", shell=True, stdout=PIPE, stderr=PIPE) as git: - stdout, stderr = git.communicate() - if stderr: - return "Not Found" - branch = stdout.decode(self._encoding, - errors="replace").splitlines()[0].replace("On branch ", "") - return branch - @property def _git_commits(self) -> str: """ str: The last 5 git commits for the currently running Faceswap. """ - with Popen("git log --pretty=oneline --abbrev-commit -n 5", - shell=True, stdout=PIPE, stderr=PIPE) as git: - stdout, stderr = git.communicate() - if stderr: + commits = git.get_commits(3) + if not commits: return "Not Found" - commits = stdout.decode(self._encoding, errors="replace").splitlines() - return ". ".join(commits) + return " | ".join(commits) @property def _cuda_version(self) -> str: @@ -210,7 +197,7 @@ def full_info(self) -> str: "sys_processor": self._system["processor"], "sys_ram": self._format_ram(), "encoding": self._encoding, - "git_branch": self._git_branch, + "git_branch": git.branch, "git_commits": self._git_commits, "gpu_cuda": self._cuda_version, "gpu_cudnn": self._cudnn_version, From 4e7e5fe308dd384a8005099a11043b79f7c0e534 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 30 Jun 2023 09:30:21 +0100 Subject: [PATCH 840/981] bugfix: graph pop-up - log/linear switching --- lib/gui/display_graph.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/gui/display_graph.py b/lib/gui/display_graph.py index 3dd1511f5e..1ab5b6749c 100755 --- a/lib/gui/display_graph.py +++ b/lib/gui/display_graph.py @@ -464,6 +464,7 @@ def set_yscale_type(self, scale: str) -> None: scale: str Should be one of ``"log"`` or ``"linear"`` """ + scale = scale.lower() logger.debug("Updating scale type: '%s'", scale) self._scale = scale self._update_plot(initiate=True) From 8fb96b0d6effa799c4a02acfecc1b131c9ac39e1 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 30 Jun 2023 09:42:52 +0100 Subject: [PATCH 841/981] mask tool: log and exit if batch not using folder --- tools/mask/mask.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 3153142f7f..3ce0062f02 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -59,6 +59,10 @@ def _get_input_locations(self) -> list[str]: if not self._args.batch_mode: return [self._args.input] + if not os.path.isdir(self._args.input): + logger.error("Batch mode is selected but input '%s' is not a folder", self._args.input) + sys.exit(1) + retval = [os.path.join(self._args.input, fname) for fname in os.listdir(self._args.input) if os.path.isdir(os.path.join(self._args.input, fname)) From f4c912e2736b92b484c55a96e6dec355cc037992 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 30 Jun 2023 11:52:58 +0100 Subject: [PATCH 842/981] bugfix: GUI force stdout/stderr reading to utf-8 --- docs/full/lib/gui.rst | 18 +- lib/gui/wrapper.py | 534 +++++++++++++++++++++++++++--------------- 2 files changed, 365 insertions(+), 187 deletions(-) diff --git a/docs/full/lib/gui.rst b/docs/full/lib/gui.rst index 69a5a9ceaf..3d77e15674 100755 --- a/docs/full/lib/gui.rst +++ b/docs/full/lib/gui.rst @@ -19,7 +19,7 @@ stats module .. autosummary:: :nosignatures: - + ~lib.gui.analysis.stats.Calculations ~lib.gui.analysis.stats.GlobalSession ~lib.gui.analysis.stats.SessionsSummary @@ -47,7 +47,7 @@ custom\_widgets module .. autosummary:: :nosignatures: - + ~lib.gui.custom_widgets.ConsoleOut ~lib.gui.custom_widgets.ContextMenu ~lib.gui.custom_widgets.MultiOption @@ -77,7 +77,7 @@ display\_analysis module .. autosummary:: :nosignatures: - + ~lib.gui.display_analysis.Analysis ~lib.gui.display_analysis.StatsData @@ -132,7 +132,7 @@ project module .. autosummary:: :nosignatures: - + ~lib.gui.project.LastSession ~lib.gui.project.Project ~lib.gui.project.Tasks @@ -202,3 +202,13 @@ utils package :members: :undoc-members: :show-inheritance: + +wrapper module +============== + +.. rubric:: Module + +.. automodule:: lib.gui.wrapper + :members: + :undoc-members: + :show-inheritance: diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index 4a2fce86d6..fab09b9c39 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -1,11 +1,14 @@ #!/usr/bin python3 """ Process wrapper for underlying faceswap commands for the GUI """ +from __future__ import annotations import os import logging import re import signal -from subprocess import PIPE, Popen import sys +import typing as T + +from subprocess import PIPE, Popen from threading import Thread from time import time @@ -17,7 +20,6 @@ if os.name == "nt": import win32console # pylint: disable=import-error - logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -25,77 +27,133 @@ class ProcessWrapper(): """ Builds command, launches and terminates the underlying faceswap process. Updates GUI display depending on state """ - def __init__(self): + def __init__(self) -> None: logger.debug("Initializing %s", self.__class__.__name__) - self.tk_vars = get_config().tk_vars - self.set_callbacks() - self.pathscript = os.path.realpath(os.path.dirname(sys.argv[0])) - self.command = None - self.statusbar = get_config().statusbar - self._training_session_location = {} - self.task = FaceswapControl(self) + self._tk_vars = get_config().tk_vars + self._set_callbacks() + self._command: str | None = None + """ str | None: The currently executing command, when process running or ``None`` """ + + self._statusbar = get_config().statusbar + self._training_session_location: dict[T.Literal["model_name", "model_folder"], str] = {} + self._task = FaceswapControl(self) logger.debug("Initialized %s", self.__class__.__name__) - def set_callbacks(self): - """ Set the tkinter variable callbacks """ + @property + def task(self) -> FaceswapControl: + """ :class:`FaceswapControl`: The object that controls the underlying faceswap process """ + return self._task + + def _set_callbacks(self) -> None: + """ Set the tkinter variable callbacks for performing an action or generating a command """ logger.debug("Setting tk variable traces") - self.tk_vars.action_command.trace("w", self.action_command) - self.tk_vars.generate_command.trace("w", self.generate_command) + self._tk_vars.action_command.trace("w", self._action_command) + self._tk_vars.generate_command.trace("w", self._generate_command) + + def _action_command(self, *args: tuple[str, str, str]): # pylint:disable=unused-argument + """ Callback for when the Action button is pressed. Process command line options and + launches the action - def action_command(self, *args): - """ The action to perform when the action button is pressed """ - if not self.tk_vars.action_command.get(): + Parameters + ---------- + args: + tuple[str, str, str] + Tkinter variable callback args. Required but unused + """ + if not self._tk_vars.action_command.get(): return - category, command = self.tk_vars.action_command.get().split(",") + category, command = self._tk_vars.action_command.get().split(",") - if self.tk_vars.running_task.get(): - self.task.terminate() + if self._tk_vars.running_task.get(): + self._task.terminate() else: - self.command = command - args = self.prepare(category) - self.task.execute_script(command, args) - self.tk_vars.action_command.set("") - - def generate_command(self, *args): - """ Generate the command line arguments and output """ - if not self.tk_vars.generate_command.get(): + self._command = command + fs_args = self._prepare(T.cast(T.Literal["faceswap", "tools"], category)) + self._task.execute_script(command, fs_args) + self._tk_vars.action_command.set("") + + def _generate_command(self, # pylint:disable=unused-argument + *args: tuple[str, str, str]) -> None: + """ Callback for when the Generate button is pressed. Process command line options and + output the cli command + + Parameters + ---------- + args: + tuple[str, str, str] + Tkinter variable callback args. Required but unused + """ + if not self._tk_vars.generate_command.get(): return - category, command = self.tk_vars.generate_command.get().split(",") - args = self.build_args(category, command=command, generate=True) - self.tk_vars.console_clear.set(True) - logger.debug(" ".join(args)) - print(" ".join(args)) - self.tk_vars.generate_command.set("") - - def prepare(self, category): - """ Prepare the environment for execution """ + category, command = self._tk_vars.generate_command.get().split(",") + fs_args = self._build_args(category, command=command, generate=True) + self._tk_vars.console_clear.set(True) + logger.debug(" ".join(fs_args)) + print(" ".join(fs_args)) + self._tk_vars.generate_command.set("") + + def _prepare(self, category: T.Literal["faceswap", "tools"]) -> list[str]: + """ Prepare the environment for execution, Sets the 'running task' and 'console clear' + global tkinter variables. If training, sets the 'is training' variable + + Parameters + ---------- + category: str, ["faceswap", "tools"] + The script that is executing the command + + Returns + ------- + list[str] + The command line arguments to execute for the faceswap job + """ logger.debug("Preparing for execution") - self.tk_vars.running_task.set(True) - self.tk_vars.console_clear.set(True) - if self.command == "train": - self.tk_vars.is_training.set(True) + assert self._command is not None + self._tk_vars.running_task.set(True) + self._tk_vars.console_clear.set(True) + if self._command == "train": + self._tk_vars.is_training.set(True) print("Loading...") - self.statusbar.message.set(f"Executing - {self.command}.py") - mode = "indeterminate" if self.command in ("effmpeg", "train") else "determinate" - self.statusbar.start(mode) + self._statusbar.message.set(f"Executing - {self._command}.py") + mode = "indeterminate" if self._command in ("effmpeg", "train") else "determinate" + self._statusbar.start(mode) - args = self.build_args(category) - self.tk_vars.display.set(self.command) + args = self._build_args(category) + self._tk_vars.display.set(self._command) logger.debug("Prepared for execution") return args - def build_args(self, category, command=None, generate=False): + def _build_args(self, + category: str, + command: str | None = None, + generate: bool = False) -> list[str]: """ Build the faceswap command and arguments list. If training, pass the model folder and name to the training :class:`lib.gui.analysis.Session` for the GUI. + + Parameters + ---------- + category: str, ["faceswap", "tools"] + The script that is executing the command + command: str, optional + The main faceswap command to execute, if provided. The currently running task if + ``None``. Default: ``None`` + generate: bool, optional + ``True`` if the command is just to be generated for display. ``False`` if the command + is to be executed + + Returns + ------- + list[str] + The full faceswap command to be executed or displayed """ logger.debug("Build cli arguments: (category: %s, command: %s, generate: %s)", category, command, generate) - command = self.command if not command else command + command = self._command if not command else command + assert command is not None script = f"{category}.py" - pathexecscript = os.path.join(self.pathscript, script) + pathexecscript = os.path.join(os.path.realpath(os.path.dirname(sys.argv[0])), script) args = [sys.executable] if generate else [sys.executable, "-u"] args.extend([pathexecscript, command]) @@ -116,13 +174,13 @@ def build_args(self, category, command=None, generate=False): logger.debug("Built cli arguments: (%s)", args) return args - def _get_training_session_info(self, cli_option): + def _get_training_session_info(self, cli_option: list[str]) -> None: """ Set the model folder and model name to :`attr:_training_session_location` so the global session picks them up for logging to the graph and analysis tab. Parameters ---------- - cli_option: list + cli_option: list[str] The command line option to be checked for model folder or name """ if cli_option[0] == "-t": @@ -132,56 +190,83 @@ def _get_training_session_info(self, cli_option): self._training_session_location["model_folder"] = cli_option[1] logger.debug("model_folder: '%s'", self._training_session_location["model_folder"]) - def terminate(self, message): - """ Finalize wrapper when process has exited """ + def terminate(self, message: str) -> None: + """ Finalize wrapper when process has exited. Stops the progress bar, sets the status + message. If the terminating task is 'train', then triggers the training close down actions + + Parameters + ---------- + message: str + The message to display in the status bar + """ logger.debug("Terminating Faceswap processes") - self.tk_vars.running_task.set(False) - if self.task.command == "train": - self.tk_vars.is_training.set(False) + self._tk_vars.running_task.set(False) + if self._task.command == "train": + self._tk_vars.is_training.set(False) Session.stop_training() - self.statusbar.stop() - self.statusbar.message.set(message) - self.tk_vars.display.set("") + self._statusbar.stop() + self._statusbar.message.set(message) + self._tk_vars.display.set("") get_images().delete_preview() preview_trigger().clear(trigger_type=None) - self.command = None + self._command = None logger.debug("Terminated Faceswap processes") print("Process exited.") class FaceswapControl(): - """ Control the underlying Faceswap tasks """ - def __init__(self, wrapper): - logger.debug("Initializing %s", self.__class__.__name__) - self.wrapper = wrapper + """ Control the underlying Faceswap tasks. + + wrapper: :class:`ProcessWrapper` + The object responsible for managing this faceswap task + """ + def __init__(self, wrapper: ProcessWrapper) -> None: + logger.debug("Initializing %s (wrapper: %s)", self.__class__.__name__, wrapper) + self._wrapper = wrapper self._session_info = wrapper._training_session_location - self.config = get_config() - self.statusbar = self.config.statusbar - self.command = None - self.args = None - self.process = None - self.thread = None # Thread for LongRunningTask termination - self.train_stats = {"iterations": 0, "timestamp": None} - self.consoleregex = { + self._config = get_config() + self._statusbar = self._config.statusbar + self._command: str | None = None + self._process: Popen | None = None + self._thread: LongRunningTask | None = None + self._train_stats: dict[T.Literal["iterations", "timestamp"], + int | float | None] = {"iterations": 0, "timestamp": None} + self._consoleregex: dict[T.Literal["loss", "tqdm", "ffmpeg"], re.Pattern] = { "loss": re.compile(r"[\W]+(\d+)?[\W]+([a-zA-Z\s]*)[\W]+?(\d+\.\d+)"), "tqdm": re.compile(r"(?P.*?)(?P\d+%).*?(?P\S+/\S+)\W\[" r"(?P[\d+:]+<.*),\W(?P.*)[a-zA-Z/]*\]"), "ffmpeg": re.compile(r"([a-zA-Z]+)=\s*(-?[\d|N/A]\S+)")} logger.debug("Initialized %s", self.__class__.__name__) - def execute_script(self, command, args): - """ Execute the requested Faceswap Script """ + @property + def command(self) -> str | None: + """ str | None: The currently executing command, when process running or ``None`` """ + return self._command + + def execute_script(self, command: str, args: list[str]) -> None: + """ Execute the requested Faceswap Script + + Parameters + ---------- + command: str + The faceswap command that is to be run + args: list[str] + The full command line arguments to be executed + """ logger.debug("Executing Faceswap: (command: '%s', args: %s)", command, args) - self.thread = None - self.command = command - kwargs = {"stdout": PIPE, - "stderr": PIPE, - "bufsize": 1, - "universal_newlines": True} - - self.process = Popen(args, **kwargs, stdin=PIPE) - self.thread_stdout() - self.thread_stderr() + self._thread = None + self._command = command + + proc = Popen(args, # pylint:disable=consider-using-with + stdout=PIPE, + stderr=PIPE, + bufsize=1, + universal_newlines=True, + stdin=PIPE, + encoding="utf-8") + self._process = proc + self._thread_stdout() + self._thread_stderr() logger.debug("Executed Faceswap") def _process_progress_stdout(self, output: str) -> bool: @@ -197,13 +282,13 @@ def _process_progress_stdout(self, output: str) -> bool: bool ``True`` if all actions have been completed on the output line otherwise ``False`` """ - if self.command == "train" and self.capture_loss(output): + if self._command == "train" and self._capture_loss(output): return True - if self.command == "effmpeg" and self.capture_ffmpeg(output): + if self._command == "effmpeg" and self._capture_ffmpeg(output): return True - if self.command not in ("train", "effmpeg") and self.capture_tqdm(output): + if self._command not in ("train", "effmpeg") and self._capture_tqdm(output): return True return False @@ -217,35 +302,39 @@ def _process_training_stdout(self, output: str) -> None: output: str The output line read from stdout """ - if self.command != "train" or not self.wrapper.tk_vars.is_training.get(): + tk_vars = get_config().tk_vars + if self._command != "train" or not tk_vars.is_training.get(): return if "[saved models]" not in output.strip().lower(): return logger.debug("Trigger GUI Training update") - logger.trace("tk_vars: %s", {itm: var.get() # type:ignore - for itm, var in self.wrapper.tk_vars.__dict__.items()}) + logger.trace("tk_vars: %s", {itm: var.get() # type:ignore[attr-defined] + for itm, var in tk_vars.__dict__.items()}) if not Session.is_training: # Don't initialize session until after the first save as state file must exist first logger.debug("Initializing curret training session") Session.initialize_session(self._session_info["model_folder"], self._session_info["model_name"], is_training=True) - self.wrapper.tk_vars.refresh_graph.set(True) + tk_vars.refresh_graph.set(True) - def read_stdout(self) -> None: + def _read_stdout(self) -> None: """ Read stdout from the subprocess. """ logger.debug("Opening stdout reader") + assert self._process is not None while True: try: - output = self.process.stdout.readline() + buff = self._process.stdout + assert buff is not None + output: str = buff.readline() except ValueError as err: if str(err).lower().startswith("i/o operation on closed file"): break raise - if output == "" and self.process.poll() is not None: + if output == "" and self._process.poll() is not None: break if output and self._process_progress_stdout(output): @@ -255,28 +344,32 @@ def read_stdout(self) -> None: self._process_training_stdout(output) print(output.rstrip()) - returncode = self.process.poll() - message = self.set_final_status(returncode) - self.wrapper.terminate(message) + returncode = self._process.poll() + assert returncode is not None + message = self._set_final_status(returncode) + self._wrapper.terminate(message) logger.debug("Terminated stdout reader. returncode: %s", returncode) - def read_stderr(self): + def _read_stderr(self) -> None: """ Read stdout from the subprocess. If training, pass the loss values to Queue """ logger.debug("Opening stderr reader") + assert self._process is not None while True: try: - output = self.process.stderr.readline() + buff = self._process.stderr + assert buff is not None + output: str = buff.readline() except ValueError as err: if str(err).lower().startswith("i/o operation on closed file"): break raise - if output == "" and self.process.poll() is not None: + if output == "" and self._process.poll() is not None: break if output: - if self.command != "train" and self.capture_tqdm(output): + if self._command != "train" and self._capture_tqdm(output): continue - if self.command == "train" and output.startswith("Reading training images"): + if self._command == "train" and output.startswith("Reading training images"): print(output.strip(), file=sys.stdout) continue if os.name == "nt" and "Call to CreateProcess failed. Error code: 2" in output: @@ -286,83 +379,110 @@ def read_stderr(self): print(output.strip(), file=sys.stderr) logger.debug("Terminated stderr reader") - def thread_stdout(self): - """ Put the subprocess stdout so that it can be read without - blocking """ + def _thread_stdout(self) -> None: + """ Put the subprocess stdout so that it can be read without blocking """ logger.debug("Threading stdout") - thread = Thread(target=self.read_stdout) + thread = Thread(target=self._read_stdout) thread.daemon = True thread.start() logger.debug("Threaded stdout") - def thread_stderr(self): - """ Put the subprocess stderr so that it can be read without - blocking """ + def _thread_stderr(self) -> None: + """ Put the subprocess stderr so that it can be read without blocking """ logger.debug("Threading stderr") - thread = Thread(target=self.read_stderr) + thread = Thread(target=self._read_stderr) thread.daemon = True thread.start() logger.debug("Threaded stderr") - def capture_loss(self, string): - """ Capture loss values from stdout """ - logger.trace("Capturing loss") + def _capture_loss(self, string: str) -> bool: + """ Capture loss values from stdout + + Parameters + ---------- + string: str + An output line read from stdout + + Returns + ------- + bool + ``True`` if a loss line was captured from stdout, otherwise ``False`` + """ + logger.trace("Capturing loss") # type:ignore[attr-defined] if not str.startswith(string, "["): - logger.trace("Not loss message. Returning False") + logger.trace("Not loss message. Returning False") # type:ignore[attr-defined] return False - loss = self.consoleregex["loss"].findall(string) + loss = self._consoleregex["loss"].findall(string) if len(loss) != 2 or not all(len(itm) == 3 for itm in loss): - logger.trace("Not loss message. Returning False") + logger.trace("Not loss message. Returning False") # type:ignore[attr-defined] return False message = f"Total Iterations: {int(loss[0][0])} | " message += " ".join([f"{itm[1]}: {itm[2]}" for itm in loss]) if not message: - logger.trace("Error creating loss message. Returning False") + logger.trace( # type:ignore[attr-defined] + "Error creating loss message. Returning False") return False - iterations = self.train_stats["iterations"] + iterations = self._train_stats["iterations"] + assert isinstance(iterations, int) if iterations == 0: # Set initial timestamp - self.train_stats["timestamp"] = time() + self._train_stats["timestamp"] = time() iterations += 1 - self.train_stats["iterations"] = iterations + self._train_stats["iterations"] = iterations - elapsed = self.calc_elapsed() + elapsed = self._calculate_elapsed() message = (f"Elapsed: {elapsed} | " - f"Session Iterations: {self.train_stats['iterations']} {message}") - self.statusbar.progress_update(message, 0, False) - logger.trace("Succesfully captured loss: %s", message) + f"Session Iterations: {self._train_stats['iterations']} {message}") + self._statusbar.progress_update(message, 0, False) + logger.trace("Succesfully captured loss: %s", message) # type:ignore[attr-defined] return True - def calc_elapsed(self): - """ Calculate and format time since training started """ + def _calculate_elapsed(self) -> str: + """ Calculate and format time since training started + + Returns + ------- + str + The amount of time elapsed since training started in HH:mm:ss format + """ now = time() - elapsed_time = now - self.train_stats["timestamp"] + timestamp = self._train_stats["timestamp"] + assert isinstance(timestamp, float) + elapsed_time = now - timestamp try: - hrs = int(elapsed_time // 3600) - if hrs < 10: - hrs = f"{hrs:02d}" + i_hrs = int(elapsed_time // 3600) + hrs = f"{i_hrs:02d}" if i_hrs < 10 else str(i_hrs) mins = f"{(int(elapsed_time % 3600) // 60):02d}" secs = f"{(int(elapsed_time % 3600) % 60):02d}" except ZeroDivisionError: - hrs = "00" - mins = "00" - secs = "00" + hrs = mins = secs = "00" return f"{hrs}:{mins}:{secs}" - def capture_tqdm(self, string): - """ Capture tqdm output for progress bar """ - logger.trace("Capturing tqdm") - tqdm = self.consoleregex["tqdm"].match(string) - if not tqdm: + def _capture_tqdm(self, string: str) -> bool: + """ Capture tqdm output for progress bar + + Parameters + ---------- + string: str + An output line read from stdout + + Returns + ------- + bool + ``True`` if a tqdm line was captured from stdout, otherwise ``False`` + """ + logger.trace("Capturing tqdm") # type:ignore[attr-defined] + mtqdm = self._consoleregex["tqdm"].match(string) + if not mtqdm: return False - tqdm = tqdm.groupdict() + tqdm = mtqdm.groupdict() if any("?" in val for val in tqdm.values()): - logger.trace("tqdm initializing. Skipping") + logger.trace("tqdm initializing. Skipping") # type:ignore[attr-defined] return True description = tqdm["dsc"].strip() description = description if description == "" else f"{description[:-1]} | " @@ -373,53 +493,80 @@ def capture_tqdm(self, string): position = tqdm["pct"].replace("%", "") position = int(position) if position.isdigit() else 0 - self.statusbar.progress_update(msg, position, True) - logger.trace("Succesfully captured tqdm message: %s", msg) + self._statusbar.progress_update(msg, position, True) + logger.trace("Succesfully captured tqdm message: %s", msg) # type:ignore[attr-defined] return True - def capture_ffmpeg(self, string): - """ Capture tqdm output for progress bar """ - logger.trace("Capturing ffmpeg") - ffmpeg = self.consoleregex["ffmpeg"].findall(string) + def _capture_ffmpeg(self, string: str) -> bool: + """ Capture ffmpeg output for progress bar + + Parameters + ---------- + string: str + An output line read from stdout + + Returns + ------- + bool + ``True`` if an ffmpeg line was captured from stdout, otherwise ``False`` + """ + logger.trace("Capturing ffmpeg") # type:ignore[attr-defined] + ffmpeg = self._consoleregex["ffmpeg"].findall(string) if len(ffmpeg) < 7: - logger.trace("Not ffmpeg message. Returning False") + logger.trace("Not ffmpeg message. Returning False") # type:ignore[attr-defined] return False message = "" for item in ffmpeg: message += f"{item[0]}: {item[1]} " if not message: - logger.trace("Error creating ffmpeg message. Returning False") + logger.trace( # type:ignore[attr-defined] + "Error creating ffmpeg message. Returning False") return False - self.statusbar.progress_update(message, 0, False) - logger.trace("Succesfully captured ffmpeg message: %s", message) + self._statusbar.progress_update(message, 0, False) + logger.trace("Succesfully captured ffmpeg message: %s", # type:ignore[attr-defined] + message) return True - def terminate(self): - """ Terminate the running process in a LongRunningTask so we can still - output to console """ - if self.thread is None: + def terminate(self) -> None: + """ Terminate the running process in a LongRunningTask so console can still be updated + console """ + if self._thread is None: logger.debug("Terminating wrapper in LongRunningTask") - self.thread = LongRunningTask(target=self.terminate_in_thread, - args=(self.command, self.process)) - if self.command == "train": - self.wrapper.tk_vars.is_training.set(False) - self.thread.start() - self.config.root.after(1000, self.terminate) - elif not self.thread.complete.is_set(): + self._thread = LongRunningTask(target=self._terminate_in_thread, + args=(self._command, self._process)) + if self._command == "train": + get_config().tk_vars.is_training.set(False) + self._thread.start() + self._config.root.after(1000, self.terminate) + elif not self._thread.complete.is_set(): logger.debug("Not finished terminating") - self.config.root.after(1000, self.terminate) + self._config.root.after(1000, self.terminate) else: logger.debug("Termination Complete. Cleaning up") - _ = self.thread.get_result() # Terminate the LongRunningTask object - self.thread = None + _ = self._thread.get_result() # Terminate the LongRunningTask object + self._thread = None + + def _terminate_in_thread(self, command: str, process: Popen) -> bool: + """ Terminate the subprocess - def terminate_in_thread(self, command, process): - """ Terminate the subprocess """ + Parameters + ---------- + command: str + The command that is running + + process: :class:`subprocess.Popen` + The running process + + Returns + ------- + bool + ``True`` when this function exits + """ logger.debug("Terminating wrapper") if command == "train": - timeout = self.config.user_config_dict.get("timeout", 120) + timeout = self._config.user_config_dict.get("timeout", 120) logger.debug("Sending Exit Signal") print("Sending Exit Signal", flush=True) now = time() @@ -427,7 +574,7 @@ def terminate_in_thread(self, command, process): logger.debug("Sending carriage return to process") con_in = win32console.GetStdHandle( # pylint:disable=c-extension-no-member win32console.STD_INPUT_HANDLE) # pylint:disable=c-extension-no-member - keypress = self.generate_windows_keypress("\n") + keypress = self._generate_windows_keypress("\n") con_in.WriteConsoleInput([keypress]) else: logger.debug("Sending SIGINT to process") @@ -438,14 +585,25 @@ def terminate_in_thread(self, command, process): break if timeelapsed > timeout: logger.error("Timeout reached sending Exit Signal") - self.terminate_all_children() + self._terminate_all_children() else: - self.terminate_all_children() + self._terminate_all_children() return True - @staticmethod - def generate_windows_keypress(character): - """ Generate an 'Enter' key press to terminate Windows training """ + @classmethod + def _generate_windows_keypress(cls, character: str) -> bytes: + """ Generate a Windows keypress + + Parameters + ---------- + character: str + The caracter to generate the keypress for + + Returns + ------- + bytes + The generated Windows keypress + """ buf = win32console.PyINPUT_RECORDType( # pylint:disable=c-extension-no-member win32console.KEY_EVENT) # pylint:disable=c-extension-no-member buf.KeyDown = 1 @@ -453,8 +611,8 @@ def generate_windows_keypress(character): buf.Char = character return buf - @staticmethod - def terminate_all_children(): + @classmethod + def _terminate_all_children(cls) -> None: """ Terminates all children """ logger.debug("Terminating Process...") print("Terminating Process...", flush=True) @@ -481,20 +639,30 @@ def terminate_all_children(): logger.debug(msg) print(msg) - def set_final_status(self, returncode): - """ Set the status bar output based on subprocess return code - and reset training stats """ + def _set_final_status(self, returncode: int) -> str: + """ Set the status bar output based on subprocess return code and reset training stats + + Parameters + ---------- + returncode: int + The returncode from the terminated process + + Returns + ------- + str + The final statusbar text + """ logger.debug("Setting final status. returncode: %s", returncode) - self.train_stats = {"iterations": 0, "timestamp": None} + self._train_stats = {"iterations": 0, "timestamp": None} if returncode in (0, 3221225786): status = "Ready" elif returncode == -15: - status = f"Terminated - {self.command}.py" + status = f"Terminated - {self._command}.py" elif returncode == -9: - status = f"Killed - {self.command}.py" + status = f"Killed - {self._command}.py" elif returncode == -6: - status = f"Aborted - {self.command}.py" + status = f"Aborted - {self._command}.py" else: - status = f"Failed - {self.command}.py. Return Code: {returncode}" + status = f"Failed - {self._command}.py. Return Code: {returncode}" logger.debug("Set final status: %s", status) return status From 2bf529ad6528a1dd13f9f89291eecc698a12157a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 30 Jun 2023 12:14:23 +0100 Subject: [PATCH 843/981] bugfix: Optimizer save error on older models --- plugins/train/model/_base/io.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/plugins/train/model/_base/io.py b/plugins/train/model/_base/io.py index 6bbd0c44ad..ff6e2579be 100644 --- a/plugins/train/model/_base/io.py +++ b/plugins/train/model/_base/io.py @@ -195,7 +195,16 @@ def save(self, is_exit: bool = False, force_save_optimizer: bool = False) -> Non self._save_optimizer == "always" or (self._save_optimizer == "exit" and is_exit)) - self._plugin.model.save(self._filename, include_optimizer=include_optimizer) + try: + self._plugin.model.save(self._filename, include_optimizer=include_optimizer) + except ValueError as err: + if include_optimizer and "name already exists" in str(err): + logger.warning("Due to a bug in older versions of Tensorflow, optimizer state " + "cannot be saved for this model.") + self._plugin.model.save(self._filename, include_optimizer=False) + else: + raise + self._plugin.state.save() msg = "[Saved optimizer state for Snapshot]" if force_save_optimizer else "[Saved models]" @@ -263,13 +272,6 @@ def snapshot(self) -> None: the latest save, hence iteration being reduced by 1. """ logger.debug("Performing snapshot. Iterations: %s", self._plugin.iterations) - # self.save(force_save_optimizer=True) - # TODO Re-enable saving optimizer state when h5 bug fixed: - # File "h5py/_objects.pyx", line 54, in h5py._objects.with_phil.wrapper - # File "h5py/_objects.pyx", line 55, in h5py._objects.with_phil.wrapper - # File "h5py/h5d.pyx", line 87, in h5py.h5d.create - # ValueError: Unable to create dataset (name already exists) - self._backup.snapshot_models(self._plugin.iterations - 1) logger.debug("Performed snapshot") From c22e105f187ccec6df98e0efcfb4974a84d8118b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 30 Jun 2023 22:15:13 +0100 Subject: [PATCH 844/981] bugfix: setup.py - fix logic ordering --- setup.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/setup.py b/setup.py index d17f4c8adb..f1f18da9cb 100755 --- a/setup.py +++ b/setup.py @@ -80,7 +80,6 @@ def __init__(self, updater: bool = False) -> None: self._check_pip() self._upgrade_pip() self._set_env_vars() - self._packages = Packages(self) @property def encoding(self) -> str: @@ -951,7 +950,7 @@ class Install(): # pylint:disable=too-few-public-methods """ def __init__(self, environment: Environment, is_gui: bool = False) -> None: self._env = environment - self._packages = environment._packages + self._packages = Packages(environment) self._is_gui = is_gui if self._env.os_version[0] == "Windows": From 1bcc151cc53b6dfcae3b555871e97a0b5f7cc8f9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 1 Jul 2023 10:45:38 +0100 Subject: [PATCH 845/981] bugfix: lib.gui.wrapper - Allow errors in stdout --- lib/gui/wrapper.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index fab09b9c39..10b4d52065 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -261,9 +261,9 @@ def execute_script(self, command: str, args: list[str]) -> None: stdout=PIPE, stderr=PIPE, bufsize=1, - universal_newlines=True, + text=True, stdin=PIPE, - encoding="utf-8") + errors="backslashreplace") self._process = proc self._thread_stdout() self._thread_stderr() From 93d5a1a031b0878417fda63bce33efea00235d4d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 6 Jul 2023 11:34:03 +0100 Subject: [PATCH 846/981] bugfix: DirectML backend GPU stats --- lib/gpu_stats/directml.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/gpu_stats/directml.py b/lib/gpu_stats/directml.py index 17364bbb34..46932cd36c 100644 --- a/lib/gpu_stats/directml.py +++ b/lib/gpu_stats/directml.py @@ -427,11 +427,15 @@ def _test_d3d12(self, adapter: ctypes._Pointer) -> bool: factory_func = windll.d3d12.D3D12CreateDevice factory_func.argtypes = ( POINTER(IUnknown), - D3DFeatureLevel.ctype, GUID) # type:ignore[attr-defined] # pylint:disable=no-member + D3DFeatureLevel.ctype, # type:ignore[attr-defined] # pylint:disable=no-member + GUID, + POINTER(ctypes.c_void_p)) + handle = ctypes.c_void_p(0) factory_func.restype = HRESULT success = factory_func(adapter, D3DFeatureLevel.D3D_FEATURE_LEVEL_11_0.value, - LookupGUID.ID3D12Device) + LookupGUID.ID3D12Device, + ctypes.byref(handle)) return success in (0, 1) def _process_adapters(self) -> list[Device]: From efbbe5526741465060ad480ba0216fbe429ee295 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 6 Jul 2023 11:41:17 +0100 Subject: [PATCH 847/981] bugfix: Pillow version pinning --- requirements/_requirements_base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index 3d9b36f624..32eecc1ab6 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -4,7 +4,7 @@ psutil>=5.9.0 numexpr>=2.8.4 numpy>=1.25.0 opencv-python>=4.7.0.0 -pillow>=9.4.0 +pillow>=9.4.0,<10.0.0 scikit-learn>=1.2.2 fastcluster>=1.2.6 matplotlib>=3.7.1 From c39a77f63ef2996c6293613cd59469e25d023309 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 6 Jul 2023 13:33:30 +0100 Subject: [PATCH 848/981] bugfix: setup.py pull in zlib-wapi on nvidia windows --- setup.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index f1f18da9cb..cef8e2bf3e 100755 --- a/setup.py +++ b/setup.py @@ -27,8 +27,12 @@ _INSTALLER_REQUIREMENTS: list[tuple[str, str]] = [("pexpect>=4.8.0", "!Windows"), ("pywinpty==2.0.2", "Windows")] # Conda packages that are required for a specific backend -_BACKEND_SPECIFIC_CONDA: dict[backend_type, list[str]] = {"nvidia": ["cudatoolkit", "cudnn"], - "apple_silicon": ["libblas"]} +# TODO zlib-wapi is required on some Windows installs where cuDNN complains: +# Could not locate zlibwapi.dll. Please make sure it is in your library path! +# This only seems to occur on Anaconda cuDNN not conda-forge +_BACKEND_SPECIFIC_CONDA: dict[backend_type, list[str]] = { + "nvidia": ["cudatoolkit", "cudnn", "zlib-wapi"], + "apple_silicon": ["libblas"]} # Packages that should only be installed through pip _FORCE_PIP: dict[backend_type, list[str]] = {"nvidia": ["tensorflow"]} # Revisions of tensorflow GPU and cuda/cudnn requirements. These relate specifically to the @@ -45,7 +49,8 @@ "imageio-ffmpeg": ("imageio-ffmpeg", "conda-forge"), "nvidia-ml-py": ("nvidia-ml-py", "conda-forge"), "tensorflow-deps": ("tensorflow-deps", "apple"), - "libblas": ("libblas", "conda-forge")} + "libblas": ("libblas", "conda-forge"), + "zlib-wapi": ("zlib-wapi", "conda-forge")} # Force output to utf-8 sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type:ignore[attr-defined] @@ -346,6 +351,9 @@ def _update_backend_specific_conda(self) -> None: return for pkg in to_add: pkg, channel = _CONDA_MAPPING.get(pkg, (pkg, "")) + if pkg == "zlib-wapi" and self._env.os_version[0].lower() != "windows": + # TODO move this front and center + continue if pkg in ("cudatoolkit", "cudnn"): # TODO Handle multiple cuda/cudnn requirements idx = 0 if pkg == "cudatoolkit" else 1 pkg = f"{pkg}{list(_TENSORFLOW_REQUIREMENTS.values())[0][idx]}" From 6a3b4ed4b3ab6cf6ce3cce85441a0cc9763c80e0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 8 Jul 2023 16:47:25 +0100 Subject: [PATCH 849/981] Training fixes: - Fix mixed precision layer storing and switching - typofix in _requirements_base.txt - lib.model.layers - cleanup, docs and tests fixes - lib.model.nn_blocks - typing fixes --- docs/full/lib/model.rst | 20 +- lib/model/layers.py | 751 +++++++++++++------------- lib/model/nn_blocks.py | 16 +- plugins/train/model/_base/io.py | 33 +- plugins/train/model/_base/model.py | 11 +- plugins/train/model/_base/settings.py | 25 +- requirements/_requirements_base.txt | 1 - tests/lib/model/layers_test.py | 48 +- 8 files changed, 464 insertions(+), 441 deletions(-) diff --git a/docs/full/lib/model.rst b/docs/full/lib/model.rst index 3430d2d70a..2e4f355d8e 100755 --- a/docs/full/lib/model.rst +++ b/docs/full/lib/model.rst @@ -22,7 +22,7 @@ model.initializers module .. autosummary:: :nosignatures: - + ~lib.model.initializers.ConvolutionAware ~lib.model.initializers.ICNR ~lib.model.initializers.compute_fans @@ -39,14 +39,16 @@ model.layers module .. autosummary:: :nosignatures: - + ~lib.model.layers.GlobalMinPooling2D ~lib.model.layers.GlobalStdDevPooling2D + ~lib.model.layers.KResizeImages ~lib.model.layers.L2_normalize ~lib.model.layers.PixelShuffler ~lib.model.layers.ReflectionPadding2D ~lib.model.layers.SubPixelUpscaling - + ~lib.model.layers.Swish + .. automodule:: lib.model.layers :members: :undoc-members: @@ -55,10 +57,6 @@ model.layers module model.losses module =================== -The losses listed here are generated from the docstrings in :mod:`lib.model.losses_tf`, however -the functions are exactly the same for :mod:`lib.model.losses_plaid`. The correct loss module will -be imported as :mod:`lib.model.losses` depending on the backend in use. - .. rubric:: Module Summary .. autosummary:: @@ -137,9 +135,9 @@ model.normalization module .. autosummary:: :nosignatures: - + ~lib.model.normalization.InstanceNormalization - + .. automodule:: lib.model.normalization :members: :undoc-members: @@ -148,10 +146,6 @@ model.normalization module model.optimizers module ======================= -The optimizers listed here are generated from the docstrings in :mod:`lib.model.optimizers_tf`, however -the functions are excactly the same for :mod:`lib.model.optimizers_plaid`. The correct optimizers module will -be imported as :mod:`lib.model.optimizers` depending on the backend in use. - .. rubric:: Module Summary .. autosummary:: diff --git a/lib/model/layers.py b/lib/model/layers.py index 42348b3202..e96ccb3b53 100644 --- a/lib/model/layers.py +++ b/lib/model/layers.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 """ Custom Layers for faceswap.py. """ - -from __future__ import absolute_import +from __future__ import annotations import sys import inspect +import typing as T import tensorflow as tf @@ -15,175 +15,92 @@ K = keras.backend -class PixelShuffler(keras.layers.Layer): # type:ignore[name-defined] - """ PixelShuffler layer for Keras. - - This layer requires a Convolution2D prior to it, having output filters computed according to - the formula :math:`filters = k * (scale_factor * scale_factor)` where `k` is a user defined - number of filters (generally larger than 32) and `scale_factor` is the up-scaling factor - (generally 2). - - This layer performs the depth to space operation on the convolution filters, and returns a - tensor with the size as defined below. - - Notes - ----- - In practice, it is useful to have a second convolution layer after the - :class:`PixelShuffler` layer to speed up the learning process. However, if you are stacking - multiple :class:`PixelShuffler` blocks, it may increase the number of parameters greatly, - so the Convolution layer after :class:`PixelShuffler` layer can be removed. - - Example - ------- - >>> # A standard sub-pixel up-scaling block - >>> x = Convolution2D(256, 3, 3, padding="same", activation="relu")(...) - >>> u = PixelShuffler(size=(2, 2))(x) - [Optional] - >>> x = Convolution2D(256, 3, 3, padding="same", activation="relu")(u) - - Parameters - ---------- - size: tuple, optional - The (`h`, `w`) scaling factor for up-scaling. Default: `(2, 2)` - data_format: ["channels_first", "channels_last", ``None``], optional - The data format for the input. Default: ``None`` - kwargs: dict - The standard Keras Layer keyword arguments (if any) +class _GlobalPooling2D(tf.keras.layers.Layer): + """Abstract class for different global pooling 2D layers. - References - ---------- - https://gist.github.com/t-ae/6e1016cc188104d123676ccef3264981 + From keras as access to pooling is trickier in tensorflow.keras """ - def __init__(self, size=(2, 2), data_format=None, **kwargs): + def __init__(self, data_format: str | None = None, **kwargs) -> None: super().__init__(**kwargs) self.data_format = conv_utils.normalize_data_format(data_format) - self.size = conv_utils.normalize_tuple(size, 2, 'size') + self.input_spec = keras.layers.InputSpec(ndim=4) - def call(self, inputs, *args, **kwargs): - """This is where the layer's logic lives. + def compute_output_shape(self, input_shape): + """ Compute the output shape based on the input shape. Parameters ---------- - inputs: tensor - Input tensor, or list/tuple of input tensors - args: tuple - Additional standard keras Layer arguments - kwargs: dict - Additional standard keras Layer keyword arguments - - Returns - ------- - tensor - A tensor or list/tuple of tensors + input_shape: tuple + The input shape to the layer """ - input_shape = K.int_shape(inputs) - if len(input_shape) != 4: - raise ValueError('Inputs should have rank ' + - str(4) + - '; Received input shape:', str(input_shape)) + if self.data_format == 'channels_last': + return (input_shape[0], input_shape[3]) + return (input_shape[0], input_shape[1]) - if self.data_format == 'channels_first': - batch_size, channels, height, width = input_shape - if batch_size is None: - batch_size = -1 - r_height, r_width = self.size - o_height, o_width = height * r_height, width * r_width - o_channels = channels // (r_height * r_width) + def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + """ Override to call the layer. - out = K.reshape(inputs, (batch_size, r_height, r_width, o_channels, height, width)) - out = K.permute_dimensions(out, (0, 3, 4, 1, 5, 2)) - out = K.reshape(out, (batch_size, o_channels, o_height, o_width)) - elif self.data_format == 'channels_last': - batch_size, height, width, channels = input_shape - if batch_size is None: - batch_size = -1 - r_height, r_width = self.size - o_height, o_width = height * r_height, width * r_width - o_channels = channels // (r_height * r_width) + Parameters + ---------- + inputs: :class:`tf.Tensor` + The input to the layer + """ + raise NotImplementedError - out = K.reshape(inputs, (batch_size, height, width, r_height, r_width, o_channels)) - out = K.permute_dimensions(out, (0, 1, 3, 2, 4, 5)) - out = K.reshape(out, (batch_size, o_height, o_width, o_channels)) - return out + def get_config(self) -> dict[str, T.Any]: + """ Set the Keras config """ + config = {'data_format': self.data_format} + base_config = super().get_config() + return dict(list(base_config.items()) + list(config.items())) - def compute_output_shape(self, input_shape): - """Computes the output shape of the layer. - Assumes that the layer will be built to match that input shape provided. +class GlobalMinPooling2D(_GlobalPooling2D): + """Global minimum pooling operation for spatial data. """ + + def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + """This is where the layer's logic lives. Parameters ---------- - input_shape: tuple or list of tuples - Shape tuple (tuple of integers) or list of shape tuples (one per output tensor of the - layer). Shape tuples can include None for free dimensions, instead of an integer. + inputs: :class:`tf.Tensor` + Input tensor, or list/tuple of input tensors Returns ------- - tuple - An input shape tuple + tensor + A tensor or list/tuple of tensors """ - if len(input_shape) != 4: - raise ValueError('Inputs should have rank ' + - str(4) + - '; Received input shape:', str(input_shape)) - - if self.data_format == 'channels_first': - height = None - width = None - if input_shape[2] is not None: - height = input_shape[2] * self.size[0] - if input_shape[3] is not None: - width = input_shape[3] * self.size[1] - channels = input_shape[1] // self.size[0] // self.size[1] - - if channels * self.size[0] * self.size[1] != input_shape[1]: - raise ValueError('channels of input and size are incompatible') - - retval = (input_shape[0], - channels, - height, - width) - elif self.data_format == 'channels_last': - height = None - width = None - if input_shape[1] is not None: - height = input_shape[1] * self.size[0] - if input_shape[2] is not None: - width = input_shape[2] * self.size[1] - channels = input_shape[3] // self.size[0] // self.size[1] - - if channels * self.size[0] * self.size[1] != input_shape[3]: - raise ValueError('channels of input and size are incompatible') + if self.data_format == 'channels_last': + pooled = K.min(inputs, axis=[1, 2]) + else: + pooled = K.min(inputs, axis=[2, 3]) + return pooled - retval = (input_shape[0], - height, - width, - channels) - return retval - def get_config(self): - """Returns the config of the layer. +class GlobalStdDevPooling2D(_GlobalPooling2D): + """Global standard deviation pooling operation for spatial data. """ - A layer config is a Python dictionary (serializable) containing the configuration of a - layer. The same layer can be reinstated later (without its trained weights) from this - configuration. + def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + """This is where the layer's logic lives. - The configuration of a layer does not include connectivity information, nor the layer - class name. These are handled by `Network` (one layer of abstraction above). + Parameters + ---------- + inputs: tensor + Input tensor, or list/tuple of input tensors Returns - -------- - dict - A python dictionary containing the layer configuration + ------- + tensor + A tensor or list/tuple of tensors """ - config = {'size': self.size, - 'data_format': self.data_format} - base_config = super().get_config() - - return dict(list(base_config.items()) + list(config.items())) + if self.data_format == 'channels_last': + pooled = K.std(inputs, axis=[1, 2]) + else: + pooled = K.std(inputs, axis=[2, 3]) + return pooled -class KResizeImages(keras.layers.Layer): # type:ignore[name-defined] +class KResizeImages(tf.keras.layers.Layer): """ A custom upscale function that uses :class:`keras.backend.resize_images` to upsample. Parameters @@ -195,26 +112,25 @@ class KResizeImages(keras.layers.Layer): # type:ignore[name-defined] kwargs: dict The standard Keras Layer keyword arguments (if any) """ - def __init__(self, size=2, interpolation="nearest", **kwargs): + def __init__(self, + size: int = 2, + interpolation: T.Literal["nearest", "bilinear"] = "nearest", + **kwargs) -> None: super().__init__(**kwargs) self.size = size self.interpolation = interpolation - def call(self, inputs, *args, **kwargs): + def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: """ Call the upsample layer Parameters ---------- - inputs: tensor + inputs: :class:`tf.Tensor` Input tensor, or list/tuple of input tensors - args: tuple - Additional standard keras Layer arguments - kwargs: dict - Additional standard keras Layer keyword arguments Returns ------- - tensor + :class:`tf.Tensor` A tensor or list/tuple of tensors """ if isinstance(self.size, int): @@ -229,7 +145,7 @@ def call(self, inputs, *args, **kwargs): retval = tf.image.resize(inputs, (size, size), method=self.interpolation) return retval - def compute_output_shape(self, input_shape): + def compute_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]: """Computes the output shape of the layer. This is the input shape with size dimensions multiplied by :attr:`size` @@ -248,7 +164,7 @@ def compute_output_shape(self, input_shape): batch, height, width, channels = input_shape return (batch, height * self.size, width * self.size, channels) - def get_config(self): + def get_config(self) -> dict[str, T.Any]: """Returns the config of the layer. Returns @@ -261,8 +177,57 @@ def get_config(self): return dict(list(base_config.items()) + list(config.items())) -class SubPixelUpscaling(keras.layers.Layer): # type:ignore[name-defined] - """ Sub-pixel convolutional up-scaling layer. +class L2_normalize(tf.keras.layers.Layer): # pylint:disable=invalid-name + """ Normalizes a tensor w.r.t. the L2 norm alongside the specified axis. + + Parameters + ---------- + axis: int + The axis to perform normalization across + kwargs: dict + The standard Keras Layer keyword arguments (if any) + """ + def __init__(self, axis: int, **kwargs) -> None: + self.axis = axis + super().__init__(**kwargs) + + def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + """This is where the layer's logic lives. + + Parameters + ---------- + inputs: :class:`tf.Tensor` + Input tensor, or list/tuple of input tensors + + Returns + ------- + :class:`tf.Tensor` + A tensor or list/tuple of tensors + """ + return K.l2_normalize(inputs, self.axis) + + def get_config(self) -> dict[str, T.Any]: + """Returns the config of the layer. + + A layer config is a Python dictionary (serializable) containing the configuration of a + layer. The same layer can be reinstated later (without its trained weights) from this + configuration. + + The configuration of a layer does not include connectivity information, nor the layer + class name. These are handled by `Network` (one layer of abstraction above). + + Returns + -------- + dict + A python dictionary containing the layer configuration + """ + config = super().get_config() + config["axis"] = self.axis + return config + + +class PixelShuffler(tf.keras.layers.Layer): + """ PixelShuffler layer for Keras. This layer requires a Convolution2D prior to it, having output filters computed according to the formula :math:`filters = k * (scale_factor * scale_factor)` where `k` is a user defined @@ -274,27 +239,23 @@ class SubPixelUpscaling(keras.layers.Layer): # type:ignore[name-defined] Notes ----- - This method is deprecated as it just performs the same as :class:`PixelShuffler` - using explicit Tensorflow ops. The method is kept in the repository to support legacy - models that have been created with this layer. - In practice, it is useful to have a second convolution layer after the - :class:`SubPixelUpscaling` layer to speed up the learning process. However, if you are stacking - multiple :class:`SubPixelUpscaling` blocks, it may increase the number of parameters greatly, - so the Convolution layer after :class:`SubPixelUpscaling` layer can be removed. + :class:`PixelShuffler` layer to speed up the learning process. However, if you are stacking + multiple :class:`PixelShuffler` blocks, it may increase the number of parameters greatly, + so the Convolution layer after :class:`PixelShuffler` layer can be removed. Example ------- >>> # A standard sub-pixel up-scaling block >>> x = Convolution2D(256, 3, 3, padding="same", activation="relu")(...) - >>> u = SubPixelUpscaling(scale_factor=2)(x) + >>> u = PixelShuffler(size=(2, 2))(x) [Optional] >>> x = Convolution2D(256, 3, 3, padding="same", activation="relu")(u) Parameters ---------- - size: int, optional - The up-scaling factor. Default: `2` + size: tuple, optional + The (`h`, `w`) scaling factor for up-scaling. Default: `(2, 2)` data_format: ["channels_first", "channels_last", ``None``], optional The data format for the input. Default: ``None`` kwargs: dict @@ -302,138 +263,115 @@ class SubPixelUpscaling(keras.layers.Layer): # type:ignore[name-defined] References ---------- - based on the paper "Real-Time Single Image and Video Super-Resolution Using an Efficient - Sub-Pixel Convolutional Neural Network" (https://arxiv.org/abs/1609.05158). + https://gist.github.com/t-ae/6e1016cc188104d123676ccef3264981 """ - - def __init__(self, scale_factor=2, data_format=None, **kwargs): + def __init__(self, + size: int | tuple[int, int] = (2, 2), + data_format: str | None = None, + **kwargs) -> None: super().__init__(**kwargs) - - self.scale_factor = scale_factor self.data_format = conv_utils.normalize_data_format(data_format) + self.size = conv_utils.normalize_tuple(size, 2, 'size') - def build(self, input_shape): - """Creates the layer weights. - - Must be implemented on all layers that have weights. - - Parameters - ---------- - input_shape: tensor - Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to - reference for weight shape computations. - """ - pass # pylint: disable=unnecessary-pass - - def call(self, inputs, *args, **kwargs): + def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: """This is where the layer's logic lives. Parameters ---------- - inputs: tensor + inputs: :class:`tf.Tensor` Input tensor, or list/tuple of input tensors - args: tuple - Additional standard keras Layer arguments - kwargs: dict - Additional standard keras Layer keyword arguments Returns ------- - tensor + :class:`tf.Tensor` A tensor or list/tuple of tensors """ - retval = self._depth_to_space(inputs, self.scale_factor, self.data_format) - return retval - - def compute_output_shape(self, input_shape): - """Computes the output shape of the layer. - - Assumes that the layer will be built to match that input shape provided. + input_shape = K.int_shape(inputs) + if len(input_shape) != 4: + raise ValueError('Inputs should have rank ' + + str(4) + + '; Received input shape:', str(input_shape)) - Parameters - ---------- - input_shape: tuple or list of tuples - Shape tuple (tuple of integers) or list of shape tuples (one per output tensor of the - layer). Shape tuples can include None for free dimensions, instead of an integer. + if self.data_format == 'channels_first': + batch_size, channels, height, width = input_shape + if batch_size is None: + batch_size = -1 + r_height, r_width = self.size + o_height, o_width = height * r_height, width * r_width + o_channels = channels // (r_height * r_width) - Returns - ------- - tuple - An input shape tuple - """ - if self.data_format == "channels_first": - batch, channels, rows, columns = input_shape - return (batch, - channels // (self.scale_factor ** 2), - rows * self.scale_factor, - columns * self.scale_factor) - batch, rows, columns, channels = input_shape - return (batch, - rows * self.scale_factor, - columns * self.scale_factor, - channels // (self.scale_factor ** 2)) + out = K.reshape(inputs, (batch_size, r_height, r_width, o_channels, height, width)) + out = K.permute_dimensions(out, (0, 3, 4, 1, 5, 2)) + out = K.reshape(out, (batch_size, o_channels, o_height, o_width)) + elif self.data_format == 'channels_last': + batch_size, height, width, channels = input_shape + if batch_size is None: + batch_size = -1 + r_height, r_width = self.size + o_height, o_width = height * r_height, width * r_width + o_channels = channels // (r_height * r_width) - @classmethod - def _depth_to_space(cls, ipt, scale, data_format=None): - """ Uses phase shift algorithm to convert channels/depth for spatial resolution """ - if data_format is None: - data_format = K.image_data_format() - data_format = data_format.lower() - ipt = cls._preprocess_conv2d_input(ipt, data_format) - out = tf.nn.depth_to_space(ipt, scale) - out = cls._postprocess_conv2d_output(out, data_format) + out = K.reshape(inputs, (batch_size, height, width, r_height, r_width, o_channels)) + out = K.permute_dimensions(out, (0, 1, 3, 2, 4, 5)) + out = K.reshape(out, (batch_size, o_height, o_width, o_channels)) return out - @staticmethod - def _postprocess_conv2d_output(inputs, data_format): - """Transpose and cast the output from conv2d if needed. - - Parameters - ---------- - inputs: tensor - The input that requires transposing and casting - data_format: str - `"channels_last"` or `"channels_first"` - - Returns - ------- - tensor - The transposed and cast input tensor - """ - - if data_format == "channels_first": - inputs = tf.transpose(inputs, (0, 3, 1, 2)) - - if K.floatx() == "float64": - inputs = tf.cast(inputs, "float64") - return inputs + def compute_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]: + """Computes the output shape of the layer. - @staticmethod - def _preprocess_conv2d_input(inputs, data_format): - """Transpose and cast the input before the conv2d. + Assumes that the layer will be built to match that input shape provided. Parameters ---------- - inputs: tensor - The input that requires transposing and casting - data_format: str - `"channels_last"` or `"channels_first"` + input_shape: tuple or list of tuples + Shape tuple (tuple of integers) or list of shape tuples (one per output tensor of the + layer). Shape tuples can include None for free dimensions, instead of an integer. Returns ------- - tensor - The transposed and cast input tensor + tuple + An input shape tuple """ - if K.dtype(inputs) == "float64": - inputs = tf.cast(inputs, "float32") - if data_format == "channels_first": - # Tensorflow uses the last dimension as channel dimension, instead of the 2nd one. - # Theano input shape: (samples, input_depth, rows, cols) - # Tensorflow input shape: (samples, rows, cols, input_depth) - inputs = tf.transpose(inputs, (0, 2, 3, 1)) - return inputs + if len(input_shape) != 4: + raise ValueError('Inputs should have rank ' + + str(4) + + '; Received input shape:', str(input_shape)) - def get_config(self): + if self.data_format == 'channels_first': + height = None + width = None + if input_shape[2] is not None: + height = input_shape[2] * self.size[0] + if input_shape[3] is not None: + width = input_shape[3] * self.size[1] + channels = input_shape[1] // self.size[0] // self.size[1] + + if channels * self.size[0] * self.size[1] != input_shape[1]: + raise ValueError('channels of input and size are incompatible') + + retval = (input_shape[0], + channels, + height, + width) + elif self.data_format == 'channels_last': + height = None + width = None + if input_shape[1] is not None: + height = input_shape[1] * self.size[0] + if input_shape[2] is not None: + width = input_shape[2] * self.size[1] + channels = input_shape[3] // self.size[0] // self.size[1] + + if channels * self.size[0] * self.size[1] != input_shape[3]: + raise ValueError('channels of input and size are incompatible') + + retval = (input_shape[0], + height, + width, + channels) + return retval + + def get_config(self) -> dict[str, T.Any]: """Returns the config of the layer. A layer config is a Python dictionary (serializable) containing the configuration of a @@ -448,13 +386,14 @@ class name. These are handled by `Network` (one layer of abstraction above). dict A python dictionary containing the layer configuration """ - config = {"scale_factor": self.scale_factor, - "data_format": self.data_format} + config = {'size': self.size, + 'data_format': self.data_format} base_config = super().get_config() + return dict(list(base_config.items()) + list(config.items())) -class ReflectionPadding2D(keras.layers.Layer): # type:ignore[name-defined] +class ReflectionPadding2D(tf.keras.layers.Layer): """Reflection-padding layer for 2D input (e.g. picture). This layer can add rows and columns at the top, bottom, left and right side of an image tensor. @@ -468,30 +407,30 @@ class ReflectionPadding2D(keras.layers.Layer): # type:ignore[name-defined] kwargs: dict The standard Keras Layer keyword arguments (if any) """ - def __init__(self, stride=2, kernel_size=5, **kwargs): + def __init__(self, stride: int = 2, kernel_size: int = 5, **kwargs) -> None: if isinstance(stride, (tuple, list)): assert len(stride) == 2 and stride[0] == stride[1] stride = stride[0] self.stride = stride self.kernel_size = kernel_size - self.input_spec = None + self.input_spec: list[tf.Tensor] | None = None super().__init__(**kwargs) - def build(self, input_shape): + def build(self, input_shape: tf.Tensor) -> None: """Creates the layer weights. Must be implemented on all layers that have weights. Parameters ---------- - input_shape: tensor + input_shape: :class:`tf.Tensor` Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to reference for weight shape computations. """ self.input_spec = [keras.layers.InputSpec(shape=input_shape)] super().build(input_shape) - def compute_output_shape(self, input_shape): + def compute_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]: """Computes the output shape of the layer. Assumes that the layer will be built to match that input shape provided. @@ -507,6 +446,7 @@ def compute_output_shape(self, input_shape): tuple An input shape tuple """ + assert self.input_spec is not None input_shape = self.input_spec[0].shape in_width, in_height = input_shape[2], input_shape[1] kernel_width, kernel_height = self.kernel_size, self.kernel_size @@ -525,21 +465,20 @@ def compute_output_shape(self, input_shape): input_shape[2] + padding_width, input_shape[3]) - def call(self, inputs, *args, **kwargs): + def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: """This is where the layer's logic lives. Parameters ---------- - inputs: tensor + inputs: :class:`tf.Tensor` Input tensor, or list/tuple of input tensors - kwargs: dict - Additional keyword arguments Returns ------- - tensor + :class:`tf.Tensor` A tensor or list/tuple of tensors """ + assert self.input_spec is not None input_shape = self.input_spec[0].shape in_width, in_height = input_shape[2], input_shape[1] kernel_width, kernel_height = self.kernel_size, self.kernel_size @@ -565,7 +504,7 @@ def call(self, inputs, *args, **kwargs): [0, 0]], 'REFLECT') - def get_config(self): + def get_config(self) -> dict[str, T.Any]: """Returns the config of the layer. A layer config is a Python dictionary (serializable) containing the configuration of a @@ -586,135 +525,193 @@ class name. These are handled by `Network` (one layer of abstraction above). return dict(list(base_config.items()) + list(config.items())) -class _GlobalPooling2D(keras.layers.Layer): # type:ignore[name-defined] - """Abstract class for different global pooling 2D layers. +class SubPixelUpscaling(tf.keras.layers.Layer): + """ Sub-pixel convolutional up-scaling layer. - From keras as access to pooling is trickier in tensorflow.keras + This layer requires a Convolution2D prior to it, having output filters computed according to + the formula :math:`filters = k * (scale_factor * scale_factor)` where `k` is a user defined + number of filters (generally larger than 32) and `scale_factor` is the up-scaling factor + (generally 2). + + This layer performs the depth to space operation on the convolution filters, and returns a + tensor with the size as defined below. + + Notes + ----- + This method is deprecated as it just performs the same as :class:`PixelShuffler` + using explicit Tensorflow ops. The method is kept in the repository to support legacy + models that have been created with this layer. + + In practice, it is useful to have a second convolution layer after the + :class:`SubPixelUpscaling` layer to speed up the learning process. However, if you are stacking + multiple :class:`SubPixelUpscaling` blocks, it may increase the number of parameters greatly, + so the Convolution layer after :class:`SubPixelUpscaling` layer can be removed. + + Example + ------- + >>> # A standard sub-pixel up-scaling block + >>> x = Convolution2D(256, 3, 3, padding="same", activation="relu")(...) + >>> u = SubPixelUpscaling(scale_factor=2)(x) + [Optional] + >>> x = Convolution2D(256, 3, 3, padding="same", activation="relu")(u) + + Parameters + ---------- + size: int, optional + The up-scaling factor. Default: `2` + data_format: ["channels_first", "channels_last", ``None``], optional + The data format for the input. Default: ``None`` + kwargs: dict + The standard Keras Layer keyword arguments (if any) + + References + ---------- + based on the paper "Real-Time Single Image and Video Super-Resolution Using an Efficient + Sub-Pixel Convolutional Neural Network" (https://arxiv.org/abs/1609.05158). """ - def __init__(self, data_format=None, **kwargs): + + def __init__(self, scale_factor: int = 2, data_format: str | None = None, **kwargs) -> None: super().__init__(**kwargs) + + self.scale_factor = scale_factor self.data_format = conv_utils.normalize_data_format(data_format) - self.input_spec = keras.layers.InputSpec(ndim=4) - def compute_output_shape(self, input_shape): - """ Compute the output shape based on the input shape. + def build(self, input_shape: tuple[int, ...]) -> None: + """Creates the layer weights. + + Must be implemented on all layers that have weights. Parameters ---------- - input_shape: tuple - The input shape to the layer + input_shape: tensor + Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to + reference for weight shape computations. """ - if self.data_format == 'channels_last': - return (input_shape[0], input_shape[3]) - return (input_shape[0], input_shape[1]) + pass # pylint: disable=unnecessary-pass - def call(self, inputs, *args, **kwargs): - """ Override to call the layer. + def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + """This is where the layer's logic lives. Parameters ---------- - inputs: Tensor - The input to the layer - args: tuple - Additional standard keras Layer arguments - kwargs: dict - Additional standard keras Layer keyword arguments - """ - raise NotImplementedError - - def get_config(self): - """ Set the Keras config """ - config = {'data_format': self.data_format} - base_config = super().get_config() - return dict(list(base_config.items()) + list(config.items())) + inputs: :class:`tf.Tensor` + Input tensor, or list/tuple of input tensors + Returns + ------- + :class:`tf.Tensor` + A tensor or list/tuple of tensors + """ + retval = self._depth_to_space(inputs, self.scale_factor, self.data_format) + return retval -class GlobalMinPooling2D(_GlobalPooling2D): - """Global minimum pooling operation for spatial data. """ + def compute_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]: + """Computes the output shape of the layer. - def call(self, inputs, *args, **kwargs): - """This is where the layer's logic lives. + Assumes that the layer will be built to match that input shape provided. Parameters ---------- - inputs: tensor - Input tensor, or list/tuple of input tensors - args: tuple - Additional standard keras Layer arguments - kwargs: dict - Additional standard keras Layer keyword arguments + input_shape: tuple or list of tuples + Shape tuple (tuple of integers) or list of shape tuples (one per output tensor of the + layer). Shape tuples can include None for free dimensions, instead of an integer. Returns ------- - tensor - A tensor or list/tuple of tensors + tuple + An input shape tuple """ - if self.data_format == 'channels_last': - pooled = K.min(inputs, axis=[1, 2]) - else: - pooled = K.min(inputs, axis=[2, 3]) - return pooled + if self.data_format == "channels_first": + batch, channels, rows, columns = input_shape + return (batch, + channels // (self.scale_factor ** 2), + rows * self.scale_factor, + columns * self.scale_factor) + batch, rows, columns, channels = input_shape + return (batch, + rows * self.scale_factor, + columns * self.scale_factor, + channels // (self.scale_factor ** 2)) + @classmethod + def _depth_to_space(cls, + inputs: tf.Tensor, + scale: int, + data_format: str | None = None) -> tf.Tensor: + """ Uses phase shift algorithm to convert channels/depth for spatial resolution -class GlobalStdDevPooling2D(_GlobalPooling2D): - """Global standard deviation pooling operation for spatial data. """ + Parameters + ---------- + inputs : :class:`tf.Tensor` + The input Tensor + scale : int + Scale factor + data_format : str | None, optional + "channels_first" or "channels_last" - def call(self, inputs, *args, **kwargs): - """This is where the layer's logic lives. + Returns + ------- + :class:`tf.Tensor` + The output Tensor + """ + if data_format is None: + data_format = K.image_data_format() + data_format = data_format.lower() + inputs = cls._preprocess_conv2d_input(inputs, data_format) + out = tf.nn.depth_to_space(inputs, scale) + out = cls._postprocess_conv2d_output(out, data_format) + return out + + @staticmethod + def _postprocess_conv2d_output(inputs: tf.Tensor, data_format: str | None) -> tf.Tensor: + """Transpose and cast the output from conv2d if needed. Parameters ---------- - inputs: tensor - Input tensor, or list/tuple of input tensors - args: tuple - Additional standard keras Layer arguments - kwargs: dict - Additional standard keras Layer keyword arguments + inputs: :class:`tf.Tensor` + The input that requires transposing and casting + data_format: str + `"channels_last"` or `"channels_first"` Returns ------- - tensor - A tensor or list/tuple of tensors + :class:`tf.Tensor` + The transposed and cast input tensor """ - if self.data_format == 'channels_last': - pooled = K.std(inputs, axis=[1, 2]) - else: - pooled = K.std(inputs, axis=[2, 3]) - return pooled - -class L2_normalize(keras.layers.Layer): # type:ignore[name-defined] # pylint:disable=invalid-name - """ Normalizes a tensor w.r.t. the L2 norm alongside the specified axis. + if data_format == "channels_first": + inputs = tf.transpose(inputs, (0, 3, 1, 2)) - Parameters - ---------- - axis: int - The axis to perform normalization across - kwargs: dict - The standard Keras Layer keyword arguments (if any) - """ - def __init__(self, axis, **kwargs): - self.axis = axis - super().__init__(**kwargs) + if K.floatx() == "float64": + inputs = tf.cast(inputs, "float64") + return inputs - def call(self, inputs): # pylint:disable=arguments-differ - """This is where the layer's logic lives. + @staticmethod + def _preprocess_conv2d_input(inputs: tf.Tensor, data_format: str | None) -> tf.Tensor: + """Transpose and cast the input before the conv2d. Parameters ---------- - inputs: tensor - Input tensor, or list/tuple of input tensors - kwargs: dict - Additional keyword arguments + inputs: :class:`tf.Tensor` + The input that requires transposing and casting + data_format: str + `"channels_last"` or `"channels_first"` Returns ------- - tensor - A tensor or list/tuple of tensors + :class:`tf.Tensor` + The transposed and cast input tensor """ - return K.l2_normalize(inputs, self.axis) + if K.dtype(inputs) == "float64": + inputs = tf.cast(inputs, "float32") + if data_format == "channels_first": + # Tensorflow uses the last dimension as channel dimension, instead of the 2nd one. + # Theano input shape: (samples, input_depth, rows, cols) + # Tensorflow input shape: (samples, rows, cols, input_depth) + inputs = tf.transpose(inputs, (0, 2, 3, 1)) + return inputs - def get_config(self): + def get_config(self) -> dict[str, T.Any]: """Returns the config of the layer. A layer config is a Python dictionary (serializable) containing the configuration of a @@ -729,12 +726,13 @@ class name. These are handled by `Network` (one layer of abstraction above). dict A python dictionary containing the layer configuration """ - config = super().get_config() - config["axis"] = self.axis - return config + config = {"scale_factor": self.scale_factor, + "data_format": self.data_format} + base_config = super().get_config() + return dict(list(base_config.items()) + list(config.items())) -class Swish(keras.layers.Layer): # type:ignore[name-defined] +class Swish(tf.keras.layers.Layer): """ Swish Activation Layer implementation for Keras. Parameters @@ -748,17 +746,22 @@ class Swish(keras.layers.Layer): # type:ignore[name-defined] ----------- Swish: a Self-Gated Activation Function: https://arxiv.org/abs/1710.05941v1 """ - def __init__(self, beta=1.0, **kwargs): + def __init__(self, beta: float = 1.0, **kwargs) -> None: super().__init__(**kwargs) self.beta = beta - def call(self, inputs): # pylint:disable=arguments-differ + def call(self, inputs, *args, **kwargs): """ Call the Swish Activation function. Parameters ---------- inputs: tensor Input tensor, or list/tuple of input tensors + + Returns + ------- + :class:`tf.Tensor` + A tensor or list/tuple of tensors """ return tf.nn.swish(inputs * self.beta) @@ -778,6 +781,6 @@ def get_config(self): # Update layers into Keras custom objects -for name, obj in inspect.getmembers(sys.modules[__name__]): +for name_, obj in inspect.getmembers(sys.modules[__name__]): if inspect.isclass(obj) and obj.__module__ == __name__: - keras.utils.get_custom_objects().update({name: obj}) + keras.utils.get_custom_objects().update({name_: obj}) diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index 9ffa1702b9..7eccfa8998 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -282,8 +282,9 @@ def __init__(self, self._use_reflect_padding = _CONFIG["reflect_padding"] + kernel_size = (kernel_size, kernel_size) if isinstance(kernel_size, int) else kernel_size self._args = (kernel_size, ) if use_depthwise else (filters, kernel_size) - self._strides = strides + self._strides = (strides, strides) if isinstance(strides, int) else strides self._padding = "valid" if self._use_reflect_padding else padding self._kwargs = kwargs self._normalization = None if not normalization else normalization.lower() @@ -314,8 +315,8 @@ def __call__(self, inputs: Tensor) -> Tensor: The output tensor from the Convolution 2D Layer """ if self._use_reflect_padding: - inputs = ReflectionPadding2D(stride=self._strides, - kernel_size=self._args[-1], + inputs = ReflectionPadding2D(stride=self._strides[0], + kernel_size=self._args[-1][0], # type:ignore[index] name=f"{self._name}_reflectionpadding2d")(inputs) conv: keras.layers.Layer = DepthwiseConv2D if self._use_depthwise else Conv2D var_x = conv(*self._args, @@ -619,7 +620,7 @@ def __init__(self, padding: str = "same", activation: str | None = "leakyrelu", scale_factor: int = 2, - interpolation: str = "bilinear") -> None: + interpolation: T.Literal["nearest", "bilinear"] = "bilinear") -> None: self._name = _get_name(f"upscale_ri_{filters}") self._interpolation = interpolation self._size = scale_factor @@ -766,7 +767,8 @@ def __init__(self, self._use_reflect_padding = _CONFIG["reflect_padding"] self._filters = filters - self._kernel_size = kernel_size + self._kernel_size = (kernel_size, + kernel_size) if isinstance(kernel_size, int) else kernel_size self._padding = "valid" if self._use_reflect_padding else padding self._kwargs = kwargs @@ -786,7 +788,7 @@ def __call__(self, inputs: Tensor) -> Tensor: var_x = inputs if self._use_reflect_padding: var_x = ReflectionPadding2D(stride=1, - kernel_size=self._kernel_size, + kernel_size=self._kernel_size[0], name=f"{self._name}_reflectionpadding2d_0")(var_x) var_x = Conv2D(self._filters, kernel_size=self._kernel_size, @@ -796,7 +798,7 @@ def __call__(self, inputs: Tensor) -> Tensor: var_x = LeakyReLU(alpha=0.2, name=f"{self._name}_leakyrelu_1")(var_x) if self._use_reflect_padding: var_x = ReflectionPadding2D(stride=1, - kernel_size=self._kernel_size, + kernel_size=self._kernel_size[0], name=f"{self._name}_reflectionpadding2d_1")(var_x) kwargs = {key: val for key, val in self._kwargs.items() if key != "kernel_initializer"} diff --git a/plugins/train/model/_base/io.py b/plugins/train/model/_base/io.py index ff6e2579be..bcf4ad48d5 100644 --- a/plugins/train/model/_base/io.py +++ b/plugins/train/model/_base/io.py @@ -15,27 +15,26 @@ import sys import typing as T -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras.models import load_model, Model as KModel # noqa:E501 # pylint:disable=import-error +import tensorflow as tf from lib.model.backup_restore import Backup from lib.utils import FaceswapError if T.TYPE_CHECKING: - from tensorflow import keras from .model import ModelBase +kmodels = tf.keras.models logger = logging.getLogger(__name__) # pylint: disable=invalid-name def get_all_sub_models( - model: keras.models.Model, - models: list[keras.models.Model] | None = None) -> list[keras.models.Model]: + model: tf.keras.models.Model, + models: list[tf.keras.models.Model] | None = None) -> list[tf.keras.models.Model]: """ For a given model, return all sub-models that occur (recursively) as children. Parameters ---------- - model: :class:`keras.models.Model` + model: :class:`tensorflow.keras.models.Model` A Keras model to scan for sub models models: `None` Do not provide this parameter. It is used for recursion @@ -43,15 +42,15 @@ def get_all_sub_models( Returns ------- list - A list of all :class:`keras.models.Model` objects found within the given model. The - provided model will always be returned in the first position + A list of all :class:`tensorflow.keras.models.Model` objects found within the given model. + The provided model will always be returned in the first position """ if models is None: models = [model] else: models.append(model) for layer in model.layers: - if isinstance(layer, KModel): + if isinstance(layer, kmodels.Model): get_all_sub_models(layer, models=models) return models @@ -120,7 +119,7 @@ def multiple_models_in_folder(self) -> list[str] | None: self._plugin.name, plugins, test, retval) return retval - def _load(self) -> keras.models.Model: + def _load(self) -> tf.keras.models.Model: """ Loads the model from disk If the predict function is to be called and the model cannot be found in the model folder @@ -131,7 +130,7 @@ def _load(self) -> keras.models.Model: Returns ------- - :class:`keras.models.Model` + :class:`tensorflow.keras.models.Model` The saved model loaded from disk """ logger.debug("Loading model: %s", self._filename) @@ -140,7 +139,7 @@ def _load(self) -> keras.models.Model: sys.exit(1) try: - model = load_model(self._filename, compile=False) + model = kmodels.load_model(self._filename, compile=False) except RuntimeError as err: if "unable to get link info" in str(err).lower(): msg = (f"Unable to load the model from '{self._filename}'. This may be a " @@ -400,7 +399,7 @@ def load(self, model_exists: bool) -> None: "different settings than you have set for your current model.", skipped_ops) - def _get_weights_model(self) -> list[keras.models.Model]: + def _get_weights_model(self) -> list[tf.keras.models.Model]: """ Obtain a list of all sub-models contained within the weights model. Returns @@ -414,7 +413,7 @@ def _get_weights_model(self) -> list[keras.models.Model]: In the event of a failure to load the weights, or the weights belonging to a different model """ - retval = get_all_sub_models(load_model(self._weights_file, compile=False)) + retval = get_all_sub_models(kmodels.load_model(self._weights_file, compile=False)) if not retval: raise FaceswapError(f"Error loading weights file {self._weights_file}.") @@ -424,14 +423,14 @@ def _get_weights_model(self) -> list[keras.models.Model]: return retval def _load_layer_weights(self, - layer: keras.layers.Layer, - sub_weights: keras.layers.Layer, + layer: tf.keras.layers.Layer, + sub_weights: tf.keras.layers.Layer, model_name: str) -> T.Literal[-1, 0, 1]: """ Load the weights for a single layer. Parameters ---------- - layer: :class:`keras.layers.Layer` + layer: :class:`tensorflow.keras.layers.Layer` The layer to set the weights for sub_weights: list The list of layers in the weights model to load weights from diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 21f0cca65d..8629ed9dcd 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -264,9 +264,12 @@ def build(self) -> None: inputs = self._get_inputs() if not self._settings.use_mixed_precision and not is_summary: # Store layer names which can be switched to mixed precision - self._state.add_mixed_precision_layers( - self._settings.get_mixed_precision_layers(self.build_model, inputs)) - self._model = self.build_model(inputs) + model, mp_layers = self._settings.get_mixed_precision_layers(self.build_model, + inputs) + self._state.add_mixed_precision_layers(mp_layers) + self._model = model + else: + self._model = self.build_model(inputs) if not is_summary and not self._is_predict: self._compile_model() self._output_summary() @@ -763,7 +766,7 @@ def _update_changed_config_items(self, config_changeable_items: dict) -> None: continue self._config[key] = val logger.info("Config item: '%s' has been updated from '%s' to '%s'", key, old_val, val) - self._rebuild_model = not self._rebuild_model and key in rebuild_tasks + self._rebuild_model = self._rebuild_model or key in rebuild_tasks class _Inference(): # pylint:disable=too-few-public-methods diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 0513ff5742..e43705a5e5 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -579,7 +579,7 @@ def _get_mixed_precision_layers(self, layers: list[dict]) -> list[str]: for layer in layers: config = layer["config"] - if layer["class_name"] == "Functional": # Recurse into sub-models + if layer["class_name"] in ("Functional", "Sequential"): # Recurse into sub-models retval.extend(self._get_mixed_precision_layers(config["layers"])) continue @@ -588,7 +588,8 @@ def _get_mixed_precision_layers(self, layers: list[dict]) -> list[str]: logger.debug("Adding supported mixed precision layer: %s %s", layer["name"], dtype) retval.append(layer["name"]) else: - logger.debug("Skipping unsupported layer: %s %s", layer["name"], dtype) + logger.debug("Skipping unsupported layer: %s %s", + layer.get("name", f"class_name: {layer['class_name']}"), dtype) return retval def _switch_precision(self, layers: list[dict], compatible: list[str]) -> None: @@ -607,7 +608,7 @@ def _switch_precision(self, layers: list[dict], compatible: list[str]) -> None: for layer in layers: config = layer["config"] - if layer["class_name"] == "Functional": # Recurse into sub-models + if layer["class_name"] in ["Functional", "Sequential"]: # Recurse into sub-models self._switch_precision(config["layers"], compatible) continue @@ -622,7 +623,8 @@ def _switch_precision(self, layers: list[dict], compatible: list[str]) -> None: def get_mixed_precision_layers(self, build_func: Callable[[list[tf.keras.layers.Layer]], tf.keras.models.Model], - inputs: list[tf.keras.layers.Layer]) -> list[str]: + inputs: list[tf.keras.layers.Layer] + ) -> tuple[tf.keras.models.Model, list[str]]: """ Get and store the mixed precision layers from a full precision enabled model. Parameters @@ -634,6 +636,8 @@ def get_mixed_precision_layers(self, Returns ------- + model: :class:`tensorflow.keras.model` + The built model in fp32 list The list of layer names within the full precision model that can be switched to mixed precision @@ -641,11 +645,18 @@ def get_mixed_precision_layers(self, logger.info("Storing Mixed Precision compatible layers. Please ignore any following " "warnings about using mixed precision.") self._set_keras_mixed_precision(True) - model = build_func(inputs) - layers = self._get_mixed_precision_layers(model.get_config()["layers"]) + with tf.device("CPU"): + model = build_func(inputs) + layers = self._get_mixed_precision_layers(model.get_config()["layers"]) + + tf.keras.backend.clear_session() self._set_keras_mixed_precision(False) + + config = model.get_config() + self._switch_precision(config["layers"], layers) + new_model = model.from_config(config) del model - return layers + return new_model, layers def check_model_precision(self, model: tf.keras.models.Model, diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index 32eecc1ab6..b7fcc81145 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -1,4 +1,3 @@ -# TESTED WITH PY3.10 tqdm>=4.65 psutil>=5.9.0 numexpr>=2.8.4 diff --git a/tests/lib/model/layers_test.py b/tests/lib/model/layers_test.py index a6beb6310a..91195c2aed 100644 --- a/tests/lib/model/layers_test.py +++ b/tests/lib/model/layers_test.py @@ -101,24 +101,6 @@ def layer_test(layer_cls, kwargs={}, input_shape=None, input_dtype=None, # noqa return actual_output -@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) -def test_pixel_shuffler(dummy): # pylint:disable=unused-argument - """ Pixel Shuffler layer test """ - layer_test(layers.PixelShuffler, input_shape=(2, 4, 4, 1024)) - - -@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) -def test_subpixel_upscaling(dummy): # pylint:disable=unused-argument - """ Sub Pixel up-scaling layer test """ - layer_test(layers.SubPixelUpscaling, input_shape=(2, 4, 4, 1024)) - - -@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) -def test_reflection_padding_2d(dummy): # pylint:disable=unused-argument - """ Reflection Padding 2D layer test """ - layer_test(layers.ReflectionPadding2D, input_shape=(2, 4, 4, 512)) - - @pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) def test_global_min_pooling_2d(dummy): # pylint:disable=unused-argument """ Global Min Pooling 2D layer test """ @@ -131,7 +113,37 @@ def test_global_std_pooling_2d(dummy): # pylint:disable=unused-argument layer_test(layers.GlobalStdDevPooling2D, input_shape=(2, 4, 4, 1024)) +@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) +def test_k_resize_images(dummy): # pylint:disable=unused-argument + """ Global Standard Deviation Pooling 2D layer test """ + layer_test(layers.KResizeImages, input_shape=(2, 4, 4, 1024)) + + @pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) def test_l2_normalize(dummy): # pylint:disable=unused-argument """ L2 Normalize layer test """ layer_test(layers.L2_normalize, kwargs={"axis": 1}, input_shape=(2, 4, 4, 1024)) + + +@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) +def test_pixel_shuffler(dummy): # pylint:disable=unused-argument + """ Pixel Shuffler layer test """ + layer_test(layers.PixelShuffler, input_shape=(2, 4, 4, 1024)) + + +@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) +def test_reflection_padding_2d(dummy): # pylint:disable=unused-argument + """ Reflection Padding 2D layer test """ + layer_test(layers.ReflectionPadding2D, input_shape=(2, 4, 4, 512)) + + +@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) +def test_subpixel_upscaling(dummy): # pylint:disable=unused-argument + """ Sub Pixel up-scaling layer test """ + layer_test(layers.SubPixelUpscaling, input_shape=(2, 4, 4, 1024)) + + +@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) +def test_swish(dummy): # pylint:disable=unused-argument + """ Sub Pixel up-scaling layer test """ + layer_test(layers.Swish, input_shape=(2, 4, 4, 1024)) From c4204c7d8abf8d26758abc15dd195a1d7a706405 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 8 Jul 2023 17:20:39 +0100 Subject: [PATCH 850/981] Updates: - Remove clipfaker - Add clip to Phaze-A - streamline clip to vision only - update lib.model.networks - Docs and tests update - Phaze-A - add option to flatten bottleneck --- docs/full/lib/model.rst | 1 + lib/model/layers.py | 30 + lib/model/losses/feature_loss.py | 2 +- lib/model/networks/__init__.py | 4 + lib/model/networks/clip.py | 840 ++++++++++++++++++ .../{nets.py => networks/simple_nets.py} | 0 plugins/train/model/phaze_a.py | 188 ++-- plugins/train/model/phaze_a_defaults.py | 16 +- tests/lib/model/layers_test.py | 6 + 9 files changed, 998 insertions(+), 89 deletions(-) create mode 100644 lib/model/networks/__init__.py create mode 100644 lib/model/networks/clip.py rename lib/model/{nets.py => networks/simple_nets.py} (100%) diff --git a/docs/full/lib/model.rst b/docs/full/lib/model.rst index 2e4f355d8e..e01f9fd02f 100755 --- a/docs/full/lib/model.rst +++ b/docs/full/lib/model.rst @@ -45,6 +45,7 @@ model.layers module ~lib.model.layers.KResizeImages ~lib.model.layers.L2_normalize ~lib.model.layers.PixelShuffler + ~lib.model.layers.QuickGELU ~lib.model.layers.ReflectionPadding2D ~lib.model.layers.SubPixelUpscaling ~lib.model.layers.Swish diff --git a/lib/model/layers.py b/lib/model/layers.py index e96ccb3b53..6569625877 100644 --- a/lib/model/layers.py +++ b/lib/model/layers.py @@ -393,6 +393,36 @@ class name. These are handled by `Network` (one layer of abstraction above). return dict(list(base_config.items()) + list(config.items())) +class QuickGELU(tf.keras.layers.Layer): + """ Applies GELU approximation that is fast but somewhat inaccurate. + + Parameters + ---------- + name: str, optional + The name for the layer. Default: "QuickGELU" + kwargs: dict + The standard Keras Layer keyword arguments (if any) + """ + + def __init__(self, name: str = "QuickGELU", **kwargs) -> None: + super().__init__(name=name, **kwargs) + + def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + """ Call the QuickGELU layerr + + Parameters + ---------- + inputs : :class:`tf.Tensor` + The input Tensor + + Returns + ------- + :class:`tf.Tensor` + The output Tensor + """ + return inputs * K.sigmoid(1.702 * inputs) + + class ReflectionPadding2D(tf.keras.layers.Layer): """Reflection-padding layer for 2D input (e.g. picture). diff --git a/lib/model/losses/feature_loss.py b/lib/model/losses/feature_loss.py index 9898ae1b4a..83ba174c28 100644 --- a/lib/model/losses/feature_loss.py +++ b/lib/model/losses/feature_loss.py @@ -14,7 +14,7 @@ import numpy as np -from lib.model.nets import AlexNet, SqueezeNet +from lib.model.networks import AlexNet, SqueezeNet from lib.utils import GetModel if T.TYPE_CHECKING: diff --git a/lib/model/networks/__init__.py b/lib/model/networks/__init__.py new file mode 100644 index 0000000000..e2be872d73 --- /dev/null +++ b/lib/model/networks/__init__.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 +""" Pre-defined networks for use in faceswap """ +from .simple_nets import AlexNet, SqueezeNet +from .clip import ViT, ViTConfig, TypeModels as TypeModelsViT diff --git a/lib/model/networks/clip.py b/lib/model/networks/clip.py new file mode 100644 index 0000000000..6c4b452051 --- /dev/null +++ b/lib/model/networks/clip.py @@ -0,0 +1,840 @@ +#!/usr/bin/env python3 +""" CLIP: https://github.com/openai/CLIP. This implementation only ports the visual transformer +part of the model. +""" +# TODO Fix Resnet. It is correct until final MHA +from __future__ import annotations +import inspect +import logging +import typing as T +import sys + +from dataclasses import dataclass + +import tensorflow as tf + +from lib.model.layers import QuickGELU +from lib.utils import GetModel + +keras = tf.keras +layers = tf.keras.layers +K = tf.keras.backend + +logger = logging.getLogger(__name__) + +TypeModels = T.Literal["RN50", "RN101", "RN50x4", "RN50x16", "RN50x64", "ViT-B-16", + "ViT-B-32", "ViT-L-14", "ViT-L-14-336px", "FaRL-B-16-16", "FaRL-B-16-64"] + + +@dataclass +class ViTConfig: + """ Configuration settings for ViT + + Parameters + ---------- + embed_dim: int + Dimensionality of the final shared embedding space + resolution: int + Spatial resolution of the input images + layer_conf: tuple[int, int, int, int] | int + Number of layers in the visual encoder, or a tuple of layer configurations for a custom + ResNet visual encoder + width: int + Width of the visual encoder layers + patch: int + Size of the patches to be extracted from the images. Only used for Visual encoder. + git_id: int, optional + The id of the model weights file stored in deepfakes_models repo if they exist. Default: 0 + """ + embed_dim: int + resolution: int + layer_conf: int | tuple[int, int, int, int] + width: int + patch: int + git_id: int = 0 + + def __post_init__(self): + """ Validate that patch_size is given correctly """ + assert (isinstance(self.layer_conf, (tuple, list)) and self.patch == 0) or ( + isinstance(self.layer_conf, int) and self.patch > 0) + + +ModelConfig: dict[TypeModels, ViTConfig] = { # Each model has a different set of parameters + "RN50": ViTConfig( + embed_dim=1024, resolution=224, layer_conf=(3, 4, 6, 3), width=64, patch=0, git_id=21), + "RN101": ViTConfig( + embed_dim=512, resolution=224, layer_conf=(3, 4, 23, 3), width=64, patch=0, git_id=22), + "RN50x4": ViTConfig( + embed_dim=640, resolution=288, layer_conf=(4, 6, 10, 6), width=80, patch=0, git_id=23), + "RN50x16": ViTConfig( + embed_dim=768, resolution=384, layer_conf=(6, 8, 18, 8), width=96, patch=0, git_id=24), + "RN50x64": ViTConfig( + embed_dim=1024, resolution=448, layer_conf=(3, 15, 36, 10), width=128, patch=0, git_id=25), + "ViT-B-16": ViTConfig( + embed_dim=512, resolution=224, layer_conf=12, width=768, patch=16, git_id=26), + "ViT-B-32": ViTConfig( + embed_dim=512, resolution=224, layer_conf=12, width=768, patch=32, git_id=27), + "ViT-L-14": ViTConfig( + embed_dim=768, resolution=224, layer_conf=24, width=1024, patch=14, git_id=28), + "ViT-L-14-336px": ViTConfig( + embed_dim=768, resolution=336, layer_conf=24, width=1024, patch=14, git_id=29), + "FaRL-B-16-16": ViTConfig( + embed_dim=512, resolution=224, layer_conf=12, width=768, patch=16, git_id=30), + "FaRL-B-16-64": ViTConfig( + embed_dim=512, resolution=224, layer_conf=12, width=768, patch=16, git_id=31)} + + +# ################## # +# VISUAL TRANSFORMER # +# ################## # + +class Transformer(): # pylint:disable=too-few-public-methods + """ A class representing a Transformer model with attention mechanism and residual connections. + + Parameters + ---------- + width: int + The dimension of the input and output vectors. + num_layers: int + The number of layers in the Transformer. + heads: int + The number of attention heads. + attn_mask: tf.Tensor, optional + The attention mask, by default None. + name: str, optional + The name of the Transformer model, by default "transformer". + + Methods + ------- + __call__() -> Model: + Calls the Transformer layers. + """ + _layer_names: dict[str, int] = {} + """ dict[str, int] for tracking unique layer names""" + + def __init__(self, + width: int, + num_layers: int, + heads: int, + attn_mask: tf.Tensor = None, + name: str = "transformer") -> None: + logger.debug("Initializing: %s (width: %s, num_layers: %s, heads: %s, attn_mask: %s, " + "name: %s)", + self.__class__.__name__, width, num_layers, heads, attn_mask, name) + self._width = width + self._num_layers = num_layers + self._heads = heads + self._attn_mask = attn_mask + self._name = name + logger.debug("Initialized: %s ", self.__class__.__name__) + + @classmethod + def _get_name(cls, name: str) -> str: + """ Return unique layer name for requested block. + + As blocks can be used multiple times, auto appends an integer to the end of the requested + name to keep all block names unique + + Parameters + ---------- + name: str + The requested name for the layer + + Returns + ------- + str + The unique name for this layer + """ + cls._layer_names[name] = cls._layer_names.setdefault(name, -1) + 1 + name = f"{name}.{cls._layer_names[name]}" + logger.debug("Generating block name: %s", name) + return name + + @classmethod + def _mlp(cls, inputs: tf.Tensor, key_dim: int, name: str) -> tf.Tensor: + """" Multilayer Perecptron for Block Ateention + + Parameters + ---------- + inputs: :class:`tensorflow.Tensor` + The input to the MLP + key_dim: int + key dimension per head for MultiHeadAttention + name: str + The name to prefix on the layers + + Returns + ------- + :class:`tensorflow.Tensor` + The output from the MLP + """ + name = f"{name}.mlp" + var_x = layers.Dense(key_dim * 4, name=f"{name}.c_fc")(inputs) + var_x = QuickGELU(name=f"{name}.gelu")(var_x) + var_x = layers.Dense(key_dim, name=f"{name}.c_proj")(var_x) + return var_x + + def residual_attention_block(self, + inputs: tf.Tensor, + key_dim: int, + num_heads: int, + attn_mask: tf.Tensor, + name: str = "ResidualAttentionBlock") -> tf.Tensor: + """ Call the residual attention block + + Parameters + ---------- + inputs: :class:`tf.Tensor` + The input Tensor + key_dim: int + key dimension per head for MultiHeadAttention + num_heads: int + Number of heads for MultiHeadAttention + attn_mask: :class:`tensorflow.Tensor`, optional + Default: ``None`` + name: str, optional + The name for the layer. Default: "ResidualAttentionBlock" + + Returns + ------- + :class:`tf.Tensor` + The return Tensor + """ + name = self._get_name(name) + + var_x = layers.LayerNormalization(epsilon=1e-05, name=f"{name}.ln_1")(inputs) + var_x = layers.MultiHeadAttention( + num_heads=num_heads, + key_dim=key_dim // num_heads, + name=f"{name}.attn")(var_x, var_x, var_x, attention_mask=attn_mask) + var_x = layers.Add()([inputs, var_x]) + var_y = var_x + var_x = layers.LayerNormalization(epsilon=1e-05, name=f"{name}.ln_2")(var_x) + var_x = layers.Add()([var_y, self._mlp(var_x, key_dim, name)]) + return var_x + + def __call__(self, inputs: tf.Tensor) -> tf.Tensor: + """ Call the Transformer layers + + Parameters + ---------- + inputs: :class:`tf.Tensor` + The input Tensor + + Returns + ------- + :class:`tf.Tensor` + The return Tensor + """ + logger.debug("Calling %s with input: %s", self.__class__.__name__, inputs.shape) + var_x = inputs + for _ in range(self._num_layers): + var_x = self.residual_attention_block(var_x, + self._width, + self._heads, + self._attn_mask, + name=f"{self._name}.resblocks") + return var_x + + +class EmbeddingLayer(tf.keras.layers.Layer): + """ Parent class for trainable embedding variables + + Parameters + ---------- + input_shape: tuple[int, ...] + The shape of the variable + scale: int + Amount to scale the random initialization by + name: str + The name of the layer + """ + def __init__(self, + input_shape: tuple[int, ...], + scale: int, + name: str, + *args, **kwargs) -> None: + super().__init__(name=name, *args, **kwargs) + self._input_shape = input_shape + self._scale = scale + self._var: tf.Variable + + def build(self, input_shape: tuple[int, ...]) -> None: + """ Add the weights + + Parameters + ---------- + input_shape: tuple[int, ... + The input shape of the incoming tensor + """ + self._var = tf.Variable(self._scale * tf.random.normal(self._input_shape, + dtype=self.compute_dtype), + trainable=True, + dtype=self.compute_dtype) + super().build(input_shape) + + def get_config(self) -> dict[str, T.Any]: + """ Get the config dictionary for the layer + + Returns + ------- + dict[str, Any] + The config dictionary for the layer + """ + retval = super().get_config() + retval["input_shape"] = self._input_shape + retval["scale"] = self._scale + return retval + + +class ClassEmbedding(EmbeddingLayer): + """ Trainable Class Embedding layer """ + def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + """ Get the Class Embedding layer + + Parameters + ---------- + inputs: :class:`tensorflow.Tensor` + Input tensor to the embedding layer + + Returns + ------- + :class:`tensorflow.Tensor` + The class embedding layer shaped for the input tensor + """ + return K.tile(self._var[None, None], [K.shape(inputs)[0], 1, 1]) + + +class PositionalEmbedding(EmbeddingLayer): + """ Trainable Positional Embedding layer """ + def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + """ Get the Positional Embedding layer + + Parameters + ---------- + inputs: :class:`tensorflow.Tensor` + Input tensor to the embedding layer + + Returns + ------- + :class:`tensorflow.Tensor` + The positional embedding layer shaped for the input tensor + """ + return K.tile(self._var[None], [K.shape(inputs)[0], 1, 1]) + + +class Projection(EmbeddingLayer): + """ Trainable Projection Embedding Layer """ + def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + """ Get the Projection layer + + Parameters + ---------- + inputs: :class:`tensorflow.Tensor` + Input tensor to the embedding layer + + Returns + ------- + :class:`tensorflow.Tensor` + The Projection layer expanded to the batch dimension and transposed for matmul + """ + return K.tile(K.transpose(self._var)[None], [K.shape(inputs)[0], 1, 1]) + + +class VisualTransformer(): # pylint:disable=too-few-public-methods + """ A class representing a Visual Transformer model for image classification tasks. + + Parameters + ---------- + input_resolution: int + The input resolution of the images. + patch_size: int + The size of the patches to be extracted from the images. + width: int + The dimension of the input and output vectors. + num_layers: int + The number of layers in the Transformer. + heads: int + The number of attention heads. + output_dim: int + The dimension of the output vector. + name: str, optional + The name of the Visual Transformer model, Default: "VisualTransformer". + + Methods + ------- + __call__() -> Model: + Builds and returns the Visual Transformer model. + """ + def __init__(self, + input_resolution: int, + patch_size: int, + width: int, + num_layers: int, + heads: int, + output_dim: int, + name: str = "VisualTransformer") -> None: + logger.debug("Initializing: %s (input_resolution: %s, patch_size: %s, width: %s, " + "layers: %s, heads: %s, output_dim: %s, name: %s)", + self.__class__.__name__, input_resolution, patch_size, width, num_layers, + heads, output_dim, name) + self._input_resolution = input_resolution + self._patch_size = patch_size + self._width = width + self._num_layers = num_layers + self._heads = heads + self._output_dim = output_dim + self._name = name + logger.debug("Initialized: %s", self.__class__.__name__) + + def __call__(self) -> tf.keras.models.Model: + """ Builds and returns the Visual Transformer model. + + Returns + ------- + Model + The Visual Transformer model. + """ + inputs = layers.Input([self._input_resolution, self._input_resolution, 3]) + var_x: tf.Tensor = layers.Conv2D(self._width, # shape = [*, grid, grid, width] + self._patch_size, + strides=self._patch_size, + use_bias=False, + name=f"{self._name}.conv1")(inputs) + + var_x = layers.Reshape((-1, self._width))(var_x) # shape = [*, grid ** 2, width] + + class_embed = ClassEmbedding((self._width, ), + self._width ** -0.5, + name=f"{self._name}.class_embedding")(var_x) + var_x = layers.Concatenate(axis=1)([class_embed, var_x]) + + pos_embed = PositionalEmbedding(((self._input_resolution // self._patch_size) ** 2 + 1, + self._width), + self._width ** -0.5, + name=f"{self._name}.positional_embedding")(var_x) + var_x = layers.Add()([var_x, pos_embed]) + var_x = layers.LayerNormalization(epsilon=1e-05, name=f"{self._name}.ln_pre")(var_x) + var_x = Transformer(self._width, + self._num_layers, + self._heads, + name=f"{self._name}.transformer")(var_x) + var_x = layers.LayerNormalization(epsilon=1e-05, + name=f"{self._name}.ln_post")(var_x[:, 0, :]) + proj = Projection((self._width, self._output_dim), + self._width ** -0.5, + name=f"{self._name}.proj")(var_x) + var_x = layers.Dot(axes=-1)([var_x, proj]) + return keras.models.Model(inputs=inputs, outputs=[var_x], name=self._name) + + +# ################ # +# MODIEFIED RESNET # +# ################ # +class Bottleneck(): # pylint:disable=too-few-public-methods + """ A ResNet bottleneck block that performs a sequence of convolutions, batch normalization, + and ReLU activation operations on an input tensor. + + Parameters + ---------- + inplanes: int + The number of input channels. + planes: int + The number of output channels. + stride: int, optional + The stride of the bottleneck block. Default: 1 + name: str, optional + The name of the bottleneck block. Default: "bottleneck" + """ + expansion = 4 + """ int: The factor by which the number of input channels is expanded to get the number of + output channels.""" + + def __init__(self, + inplanes: int, + planes: int, + stride: int = 1, + name: str = "bottleneck") -> None: + logger.debug("Initializing: %s (inplanes: %s, planes: %s, stride: %s, name: %s)", + self.__class__.__name__, inplanes, planes, stride, name) + self._inplanes = inplanes + self._planes = planes + self._stride = stride + self._name = name + logger.debug("Initialized: %s", self.__class__.__name__) + + def _downsample(self, inputs: tf.Tensor) -> tf.Tensor: + """ Perform downsample if required + + Parameters + ---------- + inputs: :class:`tensorflow.Tensor` + The input the downsample + + Returns + ------- + :class:`tensorflow.Tensor` + The original tensor, if downsizing not required, otherwise the downsized tensor + """ + if self._stride <= 1 and self._inplanes == self._planes * self.expansion: + return inputs + + name = f"{self._name}.downsample" + out = layers.AveragePooling2D(self._stride, name=f"{name}.avgpool")(inputs) + out = layers.Conv2D(self._planes * self.expansion, + 1, + strides=1, + use_bias=False, + name=f"{name}.0")(out) + out = layers.BatchNormalization(name=f"{name}.1", epsilon=1e-5)(out) + return out + + def __call__(self, inputs: tf.Tensor) -> tf.Tensor: + """ Performs the forward pass for a Bottleneck block. + + All conv layers have stride 1. an avgpool is performed after the second convolution when + stride > 1 + + Parameters + ---------- + inputs: :class:`tensorflow.Tensor` + The input tensor to the Bottleneck block. + + Returns + ------- + :class:`tensorflow.Tensor` + The result of the forward pass through the Bottleneck block. + """ + out = layers.Conv2D(self._planes, 1, use_bias=False, name=f"{self._name}.conv1")(inputs) + out = layers.BatchNormalization(name=f"{self._name}.bn1", epsilon=1e-5)(out) + out = layers.ReLU()(out) + + out = layers.ZeroPadding2D(padding=((1, 1), (1, 1)))(out) + out = layers.Conv2D(self._planes, 3, use_bias=False, name=f"{self._name}.conv2")(out) + out = layers.BatchNormalization(name=f"{self._name}.bn2", epsilon=1e-5)(out) + out = layers.ReLU()(out) + + if self._stride > 1: + out = layers.AveragePooling2D(self._stride)(out) + + out = layers.Conv2D(self._planes * self.expansion, + 1, + use_bias=False, + name=f"{self._name}.conv3")(out) + out = layers.BatchNormalization(name=f"{self._name}.bn3", epsilon=1e-5)(out) + + identity = self._downsample(inputs) + + out += identity + out = layers.ReLU()(out) + return out + + +class AttentionPool2d(): # pylint:disable=too-few-public-methods + """ An Attention Pooling layer that applies a multi-head self-attention mechanism over a + spatial grid of features. + + Parameters + ---------- + spatial_dim: int + The dimensionality of the spatial grid of features. + embed_dim: int + The dimensionality of the feature embeddings. + num_heads: int + The number of attention heads. + output_dim: int + The output dimensionality of the attention layer. If None, it defaults to embed_dim. + name: str + The name of the layer. + """ + def __init__(self, + spatial_dim: int, + embed_dim: int, + num_heads: int, + output_dim: int | None = None, + name="AttentionPool2d"): + logger.debug("Initializing: %s (spatial_dim: %s, embed_dim: %s, num_heads: %s, " + "output_dim: %s, name: %s)", + self.__class__.__name__, spatial_dim, embed_dim, num_heads, output_dim, name) + + self._spatial_dim = spatial_dim + self._embed_dim = embed_dim + self._num_heads = num_heads + self._output_dim = output_dim + self._name = name + logger.debug("Initialized: %s", self.__class__.__name__) + + def __call__(self, inputs: tf.Tensor) -> tf.Tensor: + """Performs the attention pooling operation on the input tensor. + + Parameters + ---------- + inputs: :class:`tensorflow.Tensor`: + The input tensor of shape [batch_size, height, width, embed_dim]. + + Returns + ------- + :class:`tensorflow.Tensor`:: The result of the attention pooling operation + """ + var_x: tf.Tensor + var_x = layers.Reshape((-1, inputs.shape[-1]))(inputs) # NHWC -> N(HW)C + var_x = layers.Concatenate(axis=1)([K.mean(var_x, axis=1, # N(HW)C -> N(HW+1)C + keepdims=True), var_x]) + pos_embed = PositionalEmbedding((self._spatial_dim ** 2 + 1, self._embed_dim), # N(HW+1)C + self._embed_dim ** 0.5, + name=f"{self._name}.positional_embedding")(var_x) + var_x = layers.Add()([var_x, pos_embed]) + # TODO At this point torch + keras match. They mismatch after MHA + var_x = layers.MultiHeadAttention(num_heads=self._num_heads, + key_dim=self._embed_dim // self._num_heads, + output_shape=self._output_dim or self._embed_dim, + use_bias=True, + name=f"{self._name}.mha")(var_x[:, :1, ...], + var_x, + var_x) + # only return the first element in the sequence + return var_x[:, 0, ...] + + +class ModifiedResNet(): # pylint:disable=too-few-public-methods + """ A ResNet class that is similar to torchvision's but contains the following changes: + + - There are now 3 "stem" convolutions as opposed to 1, with an average pool instead of a max + pool. + - Performs anti-aliasing strided convolutions, where an avgpool is prepended to convolutions + with stride > 1 + - The final pooling layer is a QKV attention instead of an average pool + + Parameters + ---------- + input_resolution: int + The input resolution of the model. Default is 224. + width: int + The width of the model. Default is 64. + layer_config: list + A list containing the number of Bottleneck blocks for each layer. + output_dim: int + The output dimension of the model. + heads: int + The number of heads for the QKV attention. + name: str + The name of the model. Default is "ModifiedResNet". + """ + def __init__(self, + input_resolution: int, + width: int, + layer_config: tuple[int, int, int, int], + output_dim: int, + heads: int, + name="ModifiedResNet"): + self._input_resolution = input_resolution + self._width = width + self._layer_config = layer_config + self._heads = heads + self._output_dim = output_dim + self._name = name + + def _stem(self, inputs: tf.Tensor) -> tf.Tensor: + """ Applies the stem operation to the input tensor, which consists of 3 convolutional + layers with BatchNormalization and ReLU activation, followed by an average pooling + layer. + + Parameters + ---------- + inputs: :class:`tensorflow.Tensor` + The input tensor + + Returns + ------- + :class:`tensorflow.Tensor` + The output tensor after applying the stem operation. + """ + var_x = inputs + for i in range(1, 4): + width = self._width if i == 3 else self._width // 2 + strides = 2 if i == 1 else 1 + var_x = layers.ZeroPadding2D(padding=((1, 1), (1, 1)), name=f"conv{i}_padding")(var_x) + var_x = layers.Conv2D(width, + 3, + strides=strides, + use_bias=False, + name=f"conv{i}")(var_x) + var_x = layers.BatchNormalization(name=f"bn{i}", epsilon=1e-5)(var_x) + var_x = layers.ReLU()(var_x) + var_x = layers.AveragePooling2D(2, name="avgpool")(var_x) + return var_x + + def _bottleneck(self, + inputs: tf.Tensor, + planes: int, + blocks: int, + stride: int = 1, + name: str = "layer") -> tf.Tensor: + """ A private method that creates a sequential layer of Bottleneck blocks for the + ModifiedResNet model. + + Parameters + ---------- + inputs: :class:`tensorflow.Tensor` + The input tensor + planes: int + The number of output channels for the layer. + blocks: int + The number of Bottleneck blocks in the layer. + stride: int + The stride for the first Bottleneck block in the layer. Default is 1. + name: str + The name of the layer. Default is "layer". + + Returns + ------- + :class:`tensorflow.Tensor` + Sequential block of bottlenecks + """ + retval: tf.Tensor + retval = Bottleneck(planes, planes, stride, name=f"{name}.0")(inputs) + for i in range(1, blocks): + retval = Bottleneck(planes * Bottleneck.expansion, + planes, + name=f"{name}.{i}")(retval) + return retval + + def __call__(self) -> tf.keras.models.Model: + """ Implements the forward pass of the ModifiedResNet model. + + Returns + ------- + :class:`tensorflow.keras.models.Model` + The modified resnet model. + """ + inputs = layers.Input((self._input_resolution, self._input_resolution, 3)) + var_x = self._stem(inputs) + + for i in range(4): + stride = 1 if i == 0 else 2 + var_x = self._bottleneck(var_x, + self._width * (2 ** i), + self._layer_config[i], + stride=stride, + name=f"{self._name}.layer{i + 1}") + + var_x = AttentionPool2d(self._input_resolution // 32, + self._width * 32, # the ResNet feature dimension + self._heads, + self._output_dim, + name=f"{self._name}.attnpool")(var_x) + return keras.models.Model(inputs, outputs=[var_x], name=self._name) + + +# ### # +# VIT # +# ### # +class ViT(): # pylint:disable=too-few-public-methods + """ Visiual Transform from CLIP + + A Convolutional Language-Image Pre-Training (CLIP) model that encodes images and text into a + shared latent space. + + Reference + --------- + https://arxiv.org/abs/2103.00020 + + Parameters + ---------- + name: ["RN50", "RN101", "RN50x4", "RN50x16", "RN50x64", "ViT-B-32", + "ViT-B-16", "ViT-L-14", "ViT-L-14-336px", "FaRL-B_16-64"] + The model configuration to use + input_size: int, optional + The required resolution size for the model. ``None`` for default preset size + load_weights: bool, optional + ``True`` to load pretrained weights. Default: ``False`` + """ + def __init__(self, + name: TypeModels, + input_size: int | None = None, + load_weights: bool = False) -> None: + logger.debug("Initializing: %s (name: %s, input_size: %s, load_weights: %s)", + self.__class__.__name__, name, input_size, load_weights) + assert name in ModelConfig, ("Name must be one of %s", list(ModelConfig)) + + self._name = name + self._load_weights = load_weights + + config = ModelConfig[name] + self._git_id = config.git_id + + res = input_size if input_size is not None else config.resolution + self._net = self._get_vision_net(config.layer_conf, + config.width, + config.embed_dim, + res, + config.patch) + logger.debug("Initialized: %s", self.__class__.__name__) + + def _get_vision_net(self, + layer_config: int | tuple[int, int, int, int], + width: int, + embed_dim: int, + resolution: int, + patch_size: int) -> tf.keras.models.Model: + """ Obtain the network for the vision layets + + Parameters + ---------- + layer_config: tuple[int, int, int, int] | int + Number of layers in the visual encoder, or a tuple of layer configurations for a custom + ResNet visual encoder. + width: int + Width of the visual encoder layers. + embed_dim: int + Dimensionality of the final shared embedding space. + resolution: int + Spatial resolution of the input images. + patch_size: int + Size of the patches to be extracted from the images. + + Returns + ------- + :class:`tensorflow.keras.models.Model` + The :class:`ModifiedResNet` or :class:`VisualTransformer` vision model to use + """ + if isinstance(layer_config, (tuple, list)): + vision_heads = width * 32 // 64 + return ModifiedResNet(input_resolution=resolution, + width=width, + layer_config=layer_config, + output_dim=embed_dim, + heads=vision_heads, + name=self._name.lower()) + vision_heads = width // 64 + return VisualTransformer(input_resolution=resolution, + width=width, + num_layers=layer_config, + output_dim=embed_dim, + heads=vision_heads, + patch_size=patch_size, + name=self._name.lower()) + + def __call__(self) -> tf.keras.Model: + """ Get the configured ViT model + + Returns + ------- + :class:`tensorflow.keras.models.Model` + The requested Visual Transformer model + """ + net: tf.keras.models.Model = self._net() + if self._load_weights and not self._git_id: + logger.warning("Trained weights are not available for '%s'", self._name) + return net + if self._load_weights: + model_path = GetModel(f"CLIPv_{self._name}_v1.h5", self._git_id).model_path + net.load_weights(model_path, by_name=True, skip_mismatch=True) + return net + + +# Update layers into Keras custom objects +for name_, obj in inspect.getmembers(sys.modules[__name__]): + if (inspect.isclass(obj) and issubclass(obj, tf.keras.layers.Layer) + and obj.__module__ == __name__): + keras.utils.get_custom_objects().update({name_: obj}) diff --git a/lib/model/nets.py b/lib/model/networks/simple_nets.py similarity index 100% rename from lib/model/nets.py rename to lib/model/networks/simple_nets.py diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 402c071aab..8009b4a593 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -8,30 +8,25 @@ from dataclasses import dataclass import numpy as np -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras.layers import LayerNormalization # pylint:disable=import-error -from tensorflow.keras import applications as kapp, backend as K # noqa:E501 # pylint:disable=import-error -from tensorflow.keras.layers import ( # pylint:disable=import-error - Add, BatchNormalization, Concatenate, Dense, Dropout, Flatten, GaussianNoise, MaxPool2D, - GlobalAveragePooling2D, GlobalMaxPooling2D, Input, LeakyReLU, Reshape, UpSampling2D, - Conv2D as KConv2D) -from tensorflow.keras.models import clone_model, Model as KModel # noqa:E501 # pylint:disable=import-error +import tensorflow as tf from lib.model.nn_blocks import ( Conv2D, Conv2DBlock, Conv2DOutput, ResidualBlock, UpscaleBlock, Upscale2xBlock, UpscaleResizeImagesBlock, UpscaleDNYBlock) from lib.model.normalization import ( AdaInstanceNormalization, GroupNormalization, InstanceNormalization, RMSNormalization) +from lib.model.networks import ViT, TypeModelsViT from lib.utils import get_tf_version, FaceswapError from ._base import ModelBase, get_all_sub_models -if T.TYPE_CHECKING: - from tensorflow import keras - from tensorflow import Tensor - logger = logging.getLogger(__name__) # pylint: disable=invalid-name +K = tf.keras.backend +kapp = tf.keras.applications +kl = tf.keras.layers +keras = tf.keras + @dataclass class _EncoderInfo: @@ -66,6 +61,18 @@ class _EncoderInfo: _MODEL_MAPPING: dict[str, _EncoderInfo] = { + "clipv_farl-b-16-16": _EncoderInfo( + keras_name="FaRL-B-16-16", default_size=224), + "clipv_farl-b-16-64": _EncoderInfo( + keras_name="FaRL-B-16-64", default_size=224), + "clipv_vit-b-16": _EncoderInfo( + keras_name="ViT-B-16", default_size=224), + "clipv_vit-b-32": _EncoderInfo( + keras_name="ViT-B-32", default_size=224), + "clipv_vit-l-14": _EncoderInfo( + keras_name="ViT-L-14", default_size=224), + "clipv_vit-l-14-336px": _EncoderInfo( + keras_name="ViT-L-14-336px", default_size=336), "densenet121": _EncoderInfo( keras_name="DenseNet121", default_size=224), "densenet169": _EncoderInfo( @@ -162,7 +169,7 @@ def __init__(self, *args, **kwargs) -> None: self._validate_encoder_architecture() self.config["freeze_layers"] = self._select_freeze_layers() - self.input_shape = self._get_input_shape() + self.input_shape: tuple[int, int, int] = self._get_input_shape() self.color_order = _MODEL_MAPPING[self.config["enc_architecture"]].color_order def build(self) -> None: @@ -185,7 +192,7 @@ def build(self) -> None: self._compile_model() self._output_summary() - def _update_dropouts(self, model: keras.models.Model) -> keras.models.Model: + def _update_dropouts(self, model: tf.keras.models.Model) -> tf.keras.models.Model: """ Update the saved model with new dropout rates. Keras, annoyingly, does not actually change the dropout of the underlying layer, so we need @@ -212,7 +219,7 @@ def _update_dropouts(self, model: keras.models.Model) -> keras.models.Model: rate = dropouts[key] log_once = False for layer in mod.layers: - if not isinstance(layer, Dropout): + if not isinstance(layer, kl.Dropout): continue if layer.rate != rate: logger.debug("Updating dropout rate for %s from %s to %s", @@ -225,7 +232,7 @@ def _update_dropouts(self, model: keras.models.Model) -> keras.models.Model: updated = True if updated: logger.debug("Dropout rate updated. Cloning model") - new_model = clone_model(model) + new_model = keras.models.clone_model(model) new_model.set_weights(model.get_weights()) del model model = new_model @@ -309,7 +316,7 @@ def _validate_encoder_architecture(self) -> None: f"minimum version required is {tf_min} whilst you have version " f"{tf_ver} installed.") - def build_model(self, inputs: list[Tensor]) -> keras.models.Model: + def build_model(self, inputs: list[tf.Tensor]) -> tf.keras.models.Model: """ Create the model's structure. Parameters @@ -331,10 +338,10 @@ def build_model(self, inputs: list[Tensor]) -> keras.models.Model: # Create Autoencoder outputs = [decoders["a"], decoders["b"]] - autoencoder = KModel(inputs, outputs, name=self.model_name) + autoencoder = keras.models.Model(inputs, outputs, name=self.model_name) return autoencoder - def _build_encoders(self, inputs: list[Tensor]) -> dict[str, keras.models.Model]: + def _build_encoders(self, inputs: list[tf.Tensor]) -> dict[str, tf.keras.models.Model]: """ Build the encoders for Phaze-A Parameters @@ -355,7 +362,7 @@ def _build_encoders(self, inputs: list[Tensor]) -> dict[str, keras.models.Model] def _build_fully_connected( self, - inputs: dict[str, keras.models.Model]) -> dict[str, list[keras.models.Model]]: + inputs: dict[str, tf.keras.models.Model]) -> dict[str, list[tf.keras.models.Model]]: """ Build the fully connected layers for Phaze-A Parameters @@ -386,8 +393,8 @@ def _build_fully_connected( fc_shared = fc_a else: fc_shared = fc_both - inter_a = [Concatenate(name="inter_a")([inter_a[0], fc_shared(inputs["a"])])] - inter_b = [Concatenate(name="inter_b")([inter_b[0], fc_shared(inputs["b"])])] + inter_a = [kl.Concatenate(name="inter_a")([inter_a[0], fc_shared(inputs["a"])])] + inter_b = [kl.Concatenate(name="inter_b")([inter_b[0], fc_shared(inputs["b"])])] if self.config["enable_gblock"]: fc_gblock = FullyConnected("gblock", input_shapes, self.config)() @@ -400,8 +407,8 @@ def _build_fully_connected( def _build_g_blocks( self, - inputs: dict[str, list[keras.models.Model]] - ) -> dict[str, list[keras.models.Model] | keras.models.Model]: + inputs: dict[str, list[tf.keras.models.Model]] + ) -> dict[str, list[tf.keras.models.Model] | tf.keras.models.Model]: """ Build the g-block layers for Phaze-A. If a g-block has not been selected for this model, then the original `inters` models are @@ -434,8 +441,8 @@ def _build_g_blocks( return retval def _build_decoders(self, - inputs: dict[str, list[keras.models.Model] | keras.models.Model] - ) -> dict[str, keras.models.Model]: + inputs: dict[str, list[tf.keras.models.Model] | tf.keras.models.Model] + ) -> dict[str, tf.keras.models.Model]: """ Build the encoders for Phaze-A Parameters @@ -473,15 +480,15 @@ def _build_decoders(self, return retval -def _bottleneck(inputs: Tensor, bottleneck: str, size: int, normalization: str) -> Tensor: +def _bottleneck(inputs: tf.Tensor, bottleneck: str, size: int, normalization: str) -> tf.Tensor: """ The bottleneck fully connected layer. Can be called from Encoder or FullyConnected layers. Parameters ---------- inputs: tensor The input to the bottleneck layer - bottleneck: str - The type of layer to use for the bottleneck + bottleneck: str or ``None`` + The type of layer to use for the bottleneck. ``None`` to not use a bottleneck size: int The number of nodes for the dense layer (if selected) normalization: str @@ -492,22 +499,22 @@ def _bottleneck(inputs: Tensor, bottleneck: str, size: int, normalization: str) tensor The output from the bottleneck """ - norms = {"layer": LayerNormalization, + norms = {"layer": kl.LayerNormalization, "rms": RMSNormalization, "instance": InstanceNormalization} - bottlenecks = {"average_pooling": GlobalAveragePooling2D(), - "dense": Dense(size), - "max_pooling": GlobalMaxPooling2D()} + bottlenecks = {"average_pooling": kl.GlobalAveragePooling2D(), + "dense": kl.Dense(size), + "max_pooling": kl.GlobalMaxPooling2D()} var_x = inputs if normalization: var_x = norms[normalization]()(var_x) - if bottleneck == "dense" and len(K.int_shape(var_x)[1:]) > 1: - # Flatten non-1D inputs for dense bottleneck - var_x = Flatten()(var_x) - var_x = bottlenecks[bottleneck](var_x) - if len(K.int_shape(var_x)[1:]) > 1: + if bottleneck == "dense" and K.ndim(var_x) > 2: # Flatten non-1D inputs for dense + var_x = kl.Flatten()(var_x) + if bottleneck != "flatten": + var_x = bottlenecks[bottleneck](var_x) + if K.ndim(var_x) > 2: # Flatten prior to fc layers - var_x = Flatten()(var_x) + var_x = kl.Flatten()(var_x) return var_x @@ -516,7 +523,7 @@ def _get_upscale_layer(method: T.Literal["resize_images", "subpixel", "upscale_d filters: int, activation: str | None = None, upsamples: int | None = None, - interpolation: str | None = None) -> keras.layers.Layer: + interpolation: str | None = None) -> tf.keras.layers.Layer: """ Obtain an instance of the requested upscale method. Parameters @@ -547,7 +554,7 @@ def _get_upscale_layer(method: T.Literal["resize_images", "subpixel", "upscale_d kwargs["size"] = upsamples if interpolation: kwargs["interpolation"] = interpolation - return UpSampling2D(**kwargs) + return kl.UpSampling2D(**kwargs) if method == "subpixel": return UpscaleBlock(filters, activation=activation) if method == "upscale_fast": @@ -652,7 +659,7 @@ class Encoder(): # pylint:disable=too-few-public-methods config: dict The model configuration options """ - def __init__(self, input_shape: tuple[int, ...], config: dict) -> None: + def __init__(self, input_shape: tuple[int, int, int], config: dict) -> None: self.input_shape = input_shape self._config = config self._input_shape = input_shape @@ -679,7 +686,7 @@ def _selected_model(self) -> tuple[_EncoderInfo, dict]: kwargs["include_preprocessing"] = False return model, kwargs - def __call__(self) -> keras.models.Model: + def __call__(self) -> tf.keras.models.Model: """ Create the Phaze-A Encoder Model. Returns @@ -687,7 +694,7 @@ def __call__(self) -> keras.models.Model: :class:`keras.models.Model` The selected Encoder Model """ - input_ = Input(shape=self._input_shape) + input_ = kl.Input(shape=self._input_shape) var_x = input_ scaling = self._selected_model[0].scaling @@ -724,9 +731,9 @@ def __call__(self) -> keras.models.Model: self._config["bottleneck_size"], self._config["bottleneck_norm"]) - return KModel(input_, var_x, name="encoder") + return keras.models.Model(input_, var_x, name="encoder") - def _get_encoder_model(self) -> keras.models.Model: + def _get_encoder_model(self) -> tf.keras.models.Model: """ Return the model defined by the selected architecture. Returns @@ -735,7 +742,14 @@ def _get_encoder_model(self) -> keras.models.Model: The selected keras model for the chosen encoder architecture """ model, kwargs = self._selected_model - if model.keras_name: + if model.keras_name and self._config["enc_architecture"].startswith("clipv_"): + assert model.keras_name in T.get_args(TypeModelsViT) + kwargs["input_shape"] = self._input_shape + kwargs["load_weights"] = self._config["enc_load_weights"] + retval = ViT(T.cast(TypeModelsViT, model.keras_name), + input_size=self._input_shape[0], + load_weights=self._config["enc_load_weights"])() + elif model.keras_name: kwargs["input_shape"] = self._input_shape kwargs["include_top"] = False kwargs["weights"] = "imagenet" if self._config["enc_load_weights"] else None @@ -764,7 +778,7 @@ def __init__(self, config: dict) -> None: self._kernel_size = 3 if self._is_alt else 5 self._strides = 1 if self._is_alt else 2 - def __call__(self, inputs: Tensor) -> Tensor: + def __call__(self, inputs: tf.Tensor) -> tf.Tensor: """ Call the original Faceswap Encoder Parameters @@ -807,7 +821,7 @@ def __call__(self, inputs: Tensor) -> Tensor: strides=self._strides, relu_alpha=self._relu_alpha, name=f"{name}_convblk_{i}_1")(var_x) - var_x = MaxPool2D(2, name=f"{name}_pool_{i}")(var_x) + var_x = kl.MaxPool2D(2, name=f"{name}_pool_{i}")(var_x) return var_x @@ -888,7 +902,7 @@ def _scale_filters(self, original_filters: int) -> int: logger.debug("original_filters: %s, scaled_filters: %s", original_filters, retval) return retval - def _do_upsampling(self, inputs: Tensor) -> Tensor: + def _do_upsampling(self, inputs: tf.Tensor) -> tf.Tensor: """ Perform the upsampling at the end of the fully connected layers. Parameters @@ -918,10 +932,10 @@ def _do_upsampling(self, inputs: Tensor) -> Tensor: activation="leakyrelu") var_x = upscaler(var_x) if upsampler == "upsample2d": - var_x = LeakyReLU(alpha=0.1)(var_x) + var_x = kl.LeakyReLU(alpha=0.1)(var_x) return var_x - def __call__(self) -> keras.models.Model: + def __call__(self) -> tf.keras.models.Model: """ Call the intermediate layer. Returns @@ -929,7 +943,7 @@ def __call__(self) -> keras.models.Model: :class:`keras.models.Model` The Fully connected model """ - input_ = Input(shape=self._input_shape) + input_ = kl.Input(shape=self._input_shape) var_x = input_ node_curve = _get_curve(self._min_nodes, @@ -945,12 +959,12 @@ def __call__(self) -> keras.models.Model: dropout = f"{self._prefix}_dropout" for idx, nodes in enumerate(node_curve): - var_x = Dropout(self._config[dropout], name=f"{dropout}_{idx + 1}")(var_x) - var_x = Dense(nodes)(var_x) + var_x = kl.Dropout(self._config[dropout], name=f"{dropout}_{idx + 1}")(var_x) + var_x = kl.Dense(nodes)(var_x) if self._side != "gblock": dim = self._config["fc_dimensions"] - var_x = Reshape((dim, dim, int(self._max_nodes / (dim ** 2))))(var_x) + var_x = kl.Reshape((dim, dim, int(self._max_nodes / (dim ** 2))))(var_x) var_x = self._do_upsampling(var_x) num_upscales = self._config["dec_upscales_in_fc"] @@ -959,7 +973,7 @@ def __call__(self) -> keras.models.Model: self._config, layer_indicies=(0, num_upscales))(var_x) - return KModel(input_, var_x, name=f"fc_{self._side}") + return keras.models.Model(input_, var_x, name=f"fc_{self._side}") class UpscaleBlocks(): # pylint: disable=too-few-public-methods @@ -998,7 +1012,7 @@ def __init__(self, self._layer_indicies = layer_indicies logger.debug("Initialized: %s", self.__class__.__name__,) - def _reshape_for_output(self, inputs: Tensor) -> Tensor: + def _reshape_for_output(self, inputs: tf.Tensor) -> tf.Tensor: """ Reshape the input for arbitrary output sizes. The number of filters in the input will have been scaled to the model output size allowing @@ -1022,14 +1036,14 @@ def _reshape_for_output(self, inputs: Tensor) -> Tensor: new_shape = (new_dim, new_dim, np.prod(old_shape) // new_dim ** 2) logger.debug("Reshaping tensor from %s to %s for output size %s", K.int_shape(inputs)[1:], new_shape, self._config["output_size"]) - var_x = Reshape(new_shape)(var_x) + var_x = kl.Reshape(new_shape)(var_x) return var_x def _upscale_block(self, - inputs: Tensor, + inputs: tf.Tensor, filters: int, skip_residual: bool = False, - is_mask: bool = False) -> Tensor: + is_mask: bool = False) -> tf.Tensor: """ Upscale block for Phaze-A Decoder. Uses requested upscale method, adds requested regularization and activation function. @@ -1059,19 +1073,19 @@ def _upscale_block(self, var_x = upscaler(inputs) if not is_mask and self._config["dec_gaussian"]: - var_x = GaussianNoise(1.0)(var_x) + var_x = kl.GaussianNoise(1.0)(var_x) if not is_mask and self._config["dec_res_blocks"] and not skip_residual: var_x = self._normalization(var_x) - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = kl.LeakyReLU(alpha=0.2)(var_x) for _ in range(self._config["dec_res_blocks"]): var_x = ResidualBlock(filters)(var_x) else: var_x = self._normalization(var_x) if not self._is_dny: - var_x = LeakyReLU(alpha=0.1)(var_x) + var_x = kl.LeakyReLU(alpha=0.1)(var_x) return var_x - def _normalization(self, inputs: Tensor) -> Tensor: + def _normalization(self, inputs: tf.Tensor) -> tf.Tensor: """ Add a normalization layer if requested. Parameters @@ -1086,14 +1100,14 @@ def _normalization(self, inputs: Tensor) -> Tensor: """ if not self._config["dec_norm"]: return inputs - norms = {"batch": BatchNormalization, + norms = {"batch": kl.BatchNormalization, "group": GroupNormalization, "instance": InstanceNormalization, - "layer": LayerNormalization, + "layer": kl.LayerNormalization, "rms": RMSNormalization} return norms[self._config["dec_norm"]]()(inputs) - def _dny_entry(self, inputs: Tensor) -> Tensor: + def _dny_entry(self, inputs: tf.Tensor) -> tf.Tensor: """ Entry convolutions for using the upscale_dny method. Parameters @@ -1118,7 +1132,7 @@ def _dny_entry(self, inputs: Tensor) -> Tensor: relu_alpha=0.2)(var_x) return var_x - def __call__(self, inputs: Tensor | list[Tensor]) -> Tensor | list[Tensor]: + def __call__(self, inputs: tf.Tensor | list[tf.Tensor]) -> tf.Tensor | list[tf.Tensor]: """ Upscale Network. Parameters @@ -1202,13 +1216,17 @@ def __init__(self, self.__class__.__name__, side, input_shapes) self._side = side self._config = config - self._inputs = [Input(shape=shape) for shape in input_shapes] + self._inputs = [kl.Input(shape=shape) for shape in input_shapes] self._dense_nodes = 512 self._dense_recursions = 3 logger.debug("Initialized: %s", self.__class__.__name__) @classmethod - def _g_block(cls, inputs: Tensor, style: Tensor, filters: int, recursions: int = 2) -> Tensor: + def _g_block(cls, + inputs: tf.Tensor, + style: tf.Tensor, + filters: int, + recursions: int = 2) -> tf.Tensor: """ G_block adapted from ADAIN StyleGAN. Parameters @@ -1229,19 +1247,19 @@ def _g_block(cls, inputs: Tensor, style: Tensor, filters: int, recursions: int = """ var_x = inputs for i in range(recursions): - styles = [Reshape([1, 1, filters])(Dense(filters)(style)) for _ in range(2)] - noise = KConv2D(filters, 1, padding="same")(GaussianNoise(1.0)(var_x)) + styles = [kl.Reshape([1, 1, filters])(kl.Dense(filters)(style)) for _ in range(2)] + noise = kl.Conv2D(filters, 1, padding="same")(kl.GaussianNoise(1.0)(var_x)) if i == recursions - 1: - var_x = KConv2D(filters, 3, padding="same")(var_x) + var_x = kl.Conv2D(filters, 3, padding="same")(var_x) var_x = AdaInstanceNormalization(dtype="float32")([var_x, *styles]) - var_x = Add()([var_x, noise]) - var_x = LeakyReLU(0.2)(var_x) + var_x = kl.Add()([var_x, noise]) + var_x = kl.LeakyReLU(0.2)(var_x) return var_x - def __call__(self) -> keras.models.Model: + def __call__(self) -> tf.keras.models.Model: """ G-Block Network. Returns @@ -1251,16 +1269,16 @@ def __call__(self) -> keras.models.Model: """ var_x, style = self._inputs for i in range(self._dense_recursions): - style = Dense(self._dense_nodes, kernel_initializer="he_normal")(style) + style = kl.Dense(self._dense_nodes, kernel_initializer="he_normal")(style) if i != self._dense_recursions - 1: # Don't add leakyReLu to final output - style = LeakyReLU(0.1)(style) + style = kl.LeakyReLU(0.1)(style) # Scale g_block filters to side dense g_filts = K.int_shape(var_x)[-1] var_x = Conv2D(g_filts, 3, strides=1, padding="same")(var_x) - var_x = GaussianNoise(1.0)(var_x) + var_x = kl.GaussianNoise(1.0)(var_x) var_x = self._g_block(var_x, style, g_filts) - return KModel(self._inputs, var_x, name=f"g_block_{self._side}") + return keras.models.Model(self._inputs, var_x, name=f"g_block_{self._side}") class Decoder(): # pylint:disable=too-few-public-methods @@ -1286,7 +1304,7 @@ def __init__(self, self._config = config logger.debug("Initialized: %s", self.__class__.__name__,) - def __call__(self) -> keras.models.Model: + def __call__(self) -> tf.keras.models.Model: """ Decoder Network. Returns @@ -1294,13 +1312,13 @@ def __call__(self) -> keras.models.Model: :class:`keras.models.Model` The Decoder model """ - inputs = Input(shape=self._input_shape) + inputs = kl.Input(shape=self._input_shape) num_ups_in_fc = self._config["dec_upscales_in_fc"] if self._config["learn_mask"] and num_ups_in_fc: # Mask has already been created in FC and is an output of that model - inputs = [inputs, Input(shape=self._input_shape)] + inputs = [inputs, kl.Input(shape=self._input_shape)] indicies = None if not num_ups_in_fc else (num_ups_in_fc, -1) upscales = UpscaleBlocks(self._side, @@ -1318,4 +1336,4 @@ def __call__(self) -> keras.models.Model: self._config["dec_output_kernel"], name="mask_out")(var_y)) - return KModel(inputs, outputs=outputs, name=f"decoder_{self._side}") + return keras.models.Model(inputs, outputs=outputs, name=f"decoder_{self._side}") diff --git a/plugins/train/model/phaze_a_defaults.py b/plugins/train/model/phaze_a_defaults.py index 9468609d2e..19ac84c6f8 100644 --- a/plugins/train/model/phaze_a_defaults.py +++ b/plugins/train/model/phaze_a_defaults.py @@ -47,6 +47,8 @@ "understand the parameters better.") _ENCODERS: list[str] = sorted([ + "clipv_vit-b-16", "clipv_vit-b-32", "clipv_vit-l-14", "clipv_vit-l-14-336px", + "clipv_farl-b_16-16", "clipv_farl-b_16-64", "densenet121", "densenet169", "densenet201", "efficientnet_b0", "efficientnet_b1", "efficientnet_b2", "efficientnet_b3", "efficientnet_b4", "efficientnet_b5", "efficientnet_b6", "efficientnet_b7", "efficientnet_v2_b0", "efficientnet_v2_b1", "efficientnet_v2_b2", @@ -140,6 +142,11 @@ "The encoder architecture to use. See the relevant config sections for specific " "architecture tweaking.\nNB: For keras based pre-built models, the global " "initializers and padding options will be ignored for the selected encoder." + "\n\n\tCLIPv: This is an implementation of the Visual encoder from the CLIP " + "transformer. The ViT weights are trained on imagenet whilst the FaRL weights are " + "trained on face related tasks. All have a default input size of 224px except for " + "ViT-L-14-336px that has an input size of 336px. Ref: Learning Transferable Visual " + "Models From Natural Language Supervision (2021): https://arxiv.org/abs/2103.00020" "\n\n\tdensenet: (32px -224px). Ref: Densely Connected Convolutional Networks " "(2016): https://arxiv.org/abs/1608.06993?source=post_page" "\n\n\tefficientnet: [Tensorflow 2.3+ only] EfficientNet has numerous variants (B0 - " @@ -197,7 +204,7 @@ "the minimum and maximum sizes for each encoder. NB: The input size will be rounded " "down to the nearest 16 pixels."), "datatype": int, - "min_max": (0, 100), + "min_max": (0, 200), "rounding": 1, "group": "encoder", "fixed": True}, @@ -221,11 +228,14 @@ "\n\taverage_pooling: Use a Global Average Pooling 2D layer for the bottleneck." "\n\tdense: Use a Dense layer for the bottleneck (the traditional Faceswap method). " "You can set the size of the Dense layer with the 'bottleneck_size' parameter." - "\n\tmax_pooling: Use a Global Max Pooling 2D layer for the bottleneck."), + "\n\tmax_pooling: Use a Global Max Pooling 2D layer for the bottleneck." + "\n\flatten: Don't use a bottleneck at all. Some encoders output in a size that make " + "a bottleneck unnecessary. This option flattens the output from the encoder, with no " + "further operations"), "datatype": str, "group": "bottleneck", "gui_radio": True, - "choices": ["average_pooling", "dense", "max_pooling"], + "choices": ["average_pooling", "dense", "max_pooling", "flatten"], "fixed": True}, "bottleneck_norm": { "default": "none", diff --git a/tests/lib/model/layers_test.py b/tests/lib/model/layers_test.py index 91195c2aed..7b5ec6ebd7 100644 --- a/tests/lib/model/layers_test.py +++ b/tests/lib/model/layers_test.py @@ -131,6 +131,12 @@ def test_pixel_shuffler(dummy): # pylint:disable=unused-argument layer_test(layers.PixelShuffler, input_shape=(2, 4, 4, 1024)) +@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) +def test_quick_gelu(dummy): # pylint:disable=unused-argument + """ Global Standard Deviation Pooling 2D layer test """ + layer_test(layers.QuickGELU, input_shape=(2, 4, 4, 1024)) + + @pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) def test_reflection_padding_2d(dummy): # pylint:disable=unused-argument """ Reflection Padding 2D layer test """ From c34756c8280f5416c64c27442161982db49cb58a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 11 Jul 2023 23:11:47 +0100 Subject: [PATCH 851/981] fixes - Farl weight name fix - Embedding layers to fp32 - nn_blocks - unique names for conv_output - Phaze-A - Add clipfaker presets --- .../model_phaze_a_clipfaker128_preset.json | 52 +++++++++++++++++++ .../model_phaze_a_clipfaker256_preset.json | 52 +++++++++++++++++++ .../model_phaze_a_clipfaker448_preset.json | 52 +++++++++++++++++++ lib/model/networks/clip.py | 18 ++++--- lib/model/nn_blocks.py | 2 +- plugins/train/model/phaze_a.py | 3 ++ plugins/train/model/phaze_a_defaults.py | 2 +- 7 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 lib/gui/.cache/presets/train/model_phaze_a_clipfaker128_preset.json create mode 100644 lib/gui/.cache/presets/train/model_phaze_a_clipfaker256_preset.json create mode 100644 lib/gui/.cache/presets/train/model_phaze_a_clipfaker448_preset.json diff --git a/lib/gui/.cache/presets/train/model_phaze_a_clipfaker128_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_clipfaker128_preset.json new file mode 100644 index 0000000000..0afede8da7 --- /dev/null +++ b/lib/gui/.cache/presets/train/model_phaze_a_clipfaker128_preset.json @@ -0,0 +1,52 @@ +{ + "output_size": 128, + "shared_fc": "none", + "enable_gblock": false, + "split_fc": false, + "split_gblock": false, + "split_decoders": true, + "enc_architecture": "clipv_farl-b-16-64", + "enc_scaling": 29, + "enc_load_weights": true, + "bottleneck_type": "flatten", + "bottleneck_norm": "none", + "bottleneck_size": 1024, + "bottleneck_in_encoder": true, + "fc_depth": 1, + "fc_min_filters": 1024, + "fc_max_filters": 1024, + "fc_dimensions": 4, + "fc_filter_slope": -0.5, + "fc_dropout": 0.0, + "fc_upsampler": "subpixel", + "fc_upsamples": 1, + "fc_upsample_filters": 512, + "fc_gblock_depth": 3, + "fc_gblock_min_nodes": 512, + "fc_gblock_max_nodes": 512, + "fc_gblock_filter_slope": -0.5, + "fc_gblock_dropout": 0.0, + "dec_upscale_method": "subpixel", + "dec_upscales_in_fc": 0, + "dec_norm": "none", + "dec_min_filters": 64, + "dec_max_filters": 512, + "dec_slope_mode": "cap_min", + "dec_filter_slope": 0.5, + "dec_res_blocks": 1, + "dec_output_kernel": 5, + "dec_gaussian": false, + "dec_skip_last_residual": true, + "freeze_layers": "keras_encoder", + "load_layers": "encoder", + "fs_original_depth": 4, + "fs_original_min_filters": 128, + "fs_original_max_filters": 1024, + "fs_original_use_alt": false, + "mobilenet_width": 1.0, + "mobilenet_depth": 1, + "mobilenet_dropout": 0.001, + "mobilenet_minimalistic": false, + "__filetype": "faceswap_preset", + "__section": "train|model|phaze_a" +} \ No newline at end of file diff --git a/lib/gui/.cache/presets/train/model_phaze_a_clipfaker256_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_clipfaker256_preset.json new file mode 100644 index 0000000000..974b614b97 --- /dev/null +++ b/lib/gui/.cache/presets/train/model_phaze_a_clipfaker256_preset.json @@ -0,0 +1,52 @@ +{ + "output_size": 256, + "shared_fc": "none", + "enable_gblock": false, + "split_fc": false, + "split_gblock": false, + "split_decoders": true, + "enc_architecture": "clipv_farl-b-16-64", + "enc_scaling": 58, + "enc_load_weights": true, + "bottleneck_type": "flatten", + "bottleneck_norm": "none", + "bottleneck_size": 1024, + "bottleneck_in_encoder": true, + "fc_depth": 1, + "fc_min_filters": 1024, + "fc_max_filters": 1024, + "fc_dimensions": 4, + "fc_filter_slope": -0.5, + "fc_dropout": 0.0, + "fc_upsampler": "subpixel", + "fc_upsamples": 1, + "fc_upsample_filters": 512, + "fc_gblock_depth": 3, + "fc_gblock_min_nodes": 512, + "fc_gblock_max_nodes": 512, + "fc_gblock_filter_slope": -0.5, + "fc_gblock_dropout": 0.0, + "dec_upscale_method": "subpixel", + "dec_upscales_in_fc": 0, + "dec_norm": "none", + "dec_min_filters": 64, + "dec_max_filters": 1024, + "dec_slope_mode": "cap_min", + "dec_filter_slope": 0.5, + "dec_res_blocks": 1, + "dec_output_kernel": 5, + "dec_gaussian": false, + "dec_skip_last_residual": true, + "freeze_layers": "keras_encoder", + "load_layers": "encoder", + "fs_original_depth": 4, + "fs_original_min_filters": 128, + "fs_original_max_filters": 1024, + "fs_original_use_alt": false, + "mobilenet_width": 1.0, + "mobilenet_depth": 1, + "mobilenet_dropout": 0.001, + "mobilenet_minimalistic": false, + "__filetype": "faceswap_preset", + "__section": "train|model|phaze_a" +} \ No newline at end of file diff --git a/lib/gui/.cache/presets/train/model_phaze_a_clipfaker448_preset.json b/lib/gui/.cache/presets/train/model_phaze_a_clipfaker448_preset.json new file mode 100644 index 0000000000..59bfedce6b --- /dev/null +++ b/lib/gui/.cache/presets/train/model_phaze_a_clipfaker448_preset.json @@ -0,0 +1,52 @@ +{ + "output_size": 448, + "shared_fc": "none", + "enable_gblock": false, + "split_fc": false, + "split_gblock": false, + "split_decoders": true, + "enc_architecture": "clipv_farl-b-16-64", + "enc_scaling": 100, + "enc_load_weights": true, + "bottleneck_type": "flatten", + "bottleneck_norm": "none", + "bottleneck_size": 1024, + "bottleneck_in_encoder": true, + "fc_depth": 1, + "fc_min_filters": 384, + "fc_max_filters": 384, + "fc_dimensions": 7, + "fc_filter_slope": -0.5, + "fc_dropout": 0.0, + "fc_upsampler": "subpixel", + "fc_upsamples": 1, + "fc_upsample_filters": 1024, + "fc_gblock_depth": 3, + "fc_gblock_min_nodes": 512, + "fc_gblock_max_nodes": 512, + "fc_gblock_filter_slope": -0.5, + "fc_gblock_dropout": 0.0, + "dec_upscale_method": "subpixel", + "dec_upscales_in_fc": 0, + "dec_norm": "none", + "dec_min_filters": 64, + "dec_max_filters": 1024, + "dec_slope_mode": "cap_min", + "dec_filter_slope": 0.5, + "dec_res_blocks": 1, + "dec_output_kernel": 5, + "dec_gaussian": false, + "dec_skip_last_residual": true, + "freeze_layers": "keras_encoder", + "load_layers": "encoder", + "fs_original_depth": 4, + "fs_original_min_filters": 128, + "fs_original_max_filters": 1024, + "fs_original_use_alt": false, + "mobilenet_width": 1.0, + "mobilenet_depth": 1, + "mobilenet_dropout": 0.001, + "mobilenet_minimalistic": false, + "__filetype": "faceswap_preset", + "__section": "train|model|phaze_a" +} \ No newline at end of file diff --git a/lib/model/networks/clip.py b/lib/model/networks/clip.py index 6c4b452051..1b07e3fc76 100644 --- a/lib/model/networks/clip.py +++ b/lib/model/networks/clip.py @@ -248,13 +248,17 @@ class EmbeddingLayer(tf.keras.layers.Layer): Amount to scale the random initialization by name: str The name of the layer + dtype: str, optional + The datatype for the layer. Mixed precision can mess up the embeddings. Default: "float32" """ def __init__(self, input_shape: tuple[int, ...], scale: int, name: str, - *args, **kwargs) -> None: - super().__init__(name=name, *args, **kwargs) + *args, + dtype="float32", + **kwargs) -> None: + super().__init__(name=name, dtype=dtype, *args, **kwargs) self._input_shape = input_shape self._scale = scale self._var: tf.Variable @@ -268,9 +272,9 @@ def build(self, input_shape: tuple[int, ...]) -> None: The input shape of the incoming tensor """ self._var = tf.Variable(self._scale * tf.random.normal(self._input_shape, - dtype=self.compute_dtype), + dtype=self.dtype), trainable=True, - dtype=self.compute_dtype) + dtype=self.dtype) super().build(input_shape) def get_config(self) -> dict[str, T.Any]: @@ -805,7 +809,7 @@ def _get_vision_net(self, layer_config=layer_config, output_dim=embed_dim, heads=vision_heads, - name=self._name.lower()) + name="visual") vision_heads = width // 64 return VisualTransformer(input_resolution=resolution, width=width, @@ -813,7 +817,7 @@ def _get_vision_net(self, output_dim=embed_dim, heads=vision_heads, patch_size=patch_size, - name=self._name.lower()) + name="visual") def __call__(self) -> tf.keras.Model: """ Get the configured ViT model @@ -829,7 +833,9 @@ def __call__(self) -> tf.keras.Model: return net if self._load_weights: model_path = GetModel(f"CLIPv_{self._name}_v1.h5", self._git_id).model_path + logger.info("Loading CLIPv trained weights for '%s'", self._name) net.load_weights(model_path, by_name=True, skip_mismatch=True) + return net diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index 7eccfa8998..d805332aec 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -192,7 +192,7 @@ def __init__(self, kernel_size: int | tuple[int], activation: str = "sigmoid", padding: str = "same", **kwargs) -> None: - self._name = kwargs.pop("name") if "name" in kwargs else _get_name( + self._name = _get_name(kwargs.pop("name")) if "name" in kwargs else _get_name( f"conv_output_{filters}") self._filters = filters self._kernel_size = kernel_size diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 8009b4a593..97e034ddae 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -251,6 +251,8 @@ def _select_freeze_layers(self) -> list[str]: layers = self.config["freeze_layers"] # EfficientNetV2 is inconsistent with other model's naming conventions keras_name = _MODEL_MAPPING[arch].keras_name.replace("EfficientNetV2", "EfficientNetV2-") + # CLIPv model is always called 'visual' regardless of weights/format loaded + keras_name = "visual" if arch.startswith("clipv_") else keras_name if "keras_encoder" not in self.config["freeze_layers"]: retval = layers @@ -260,6 +262,7 @@ def _select_freeze_layers(self) -> list[str]: else: retval = [layer for layer in layers if layer != "keras_encoder"] logger.debug("Removing 'keras_encoder' for '%s'", arch) + return retval def _get_input_shape(self) -> tuple[int, int, int]: diff --git a/plugins/train/model/phaze_a_defaults.py b/plugins/train/model/phaze_a_defaults.py index 19ac84c6f8..5fb9e7a777 100644 --- a/plugins/train/model/phaze_a_defaults.py +++ b/plugins/train/model/phaze_a_defaults.py @@ -48,7 +48,7 @@ _ENCODERS: list[str] = sorted([ "clipv_vit-b-16", "clipv_vit-b-32", "clipv_vit-l-14", "clipv_vit-l-14-336px", - "clipv_farl-b_16-16", "clipv_farl-b_16-64", + "clipv_farl-b-16-16", "clipv_farl-b-16-64", "densenet121", "densenet169", "densenet201", "efficientnet_b0", "efficientnet_b1", "efficientnet_b2", "efficientnet_b3", "efficientnet_b4", "efficientnet_b5", "efficientnet_b6", "efficientnet_b7", "efficientnet_v2_b0", "efficientnet_v2_b1", "efficientnet_v2_b2", From 12814ce049283b00ed3e42c9a1451cd856c3f815 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 12 Jul 2023 08:23:48 +0100 Subject: [PATCH 852/981] bugfix: Manual tool - face filtering --- tools/manual/detected_faces.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index 79283667cc..cdc67849ae 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -490,10 +490,10 @@ def frame_meets_criteria(self) -> bool: retval = ( filter_mode == "All Frames" or (filter_mode == "No Faces" and not frame_faces) or - (filter_mode == "Has Face(s)" and frame_faces) or + (filter_mode == "Has Face(s)" and len(frame_faces) > 0) or (filter_mode == "Multiple Faces" and len(frame_faces) > 1) or (filter_mode == "Misaligned Faces" and any(face.aligned.average_distance > distance - for face in frame_faces))) + for face in frame_faces))) assert isinstance(retval, bool) logger.trace("filter_mode: %s, frame meets criteria: %s", # type:ignore[attr-defined] filter_mode, retval) From 31eff9c096082baa47bbce5f8ac8abe12810b5ec Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 14 Jul 2023 01:40:41 +0100 Subject: [PATCH 853/981] macos fixes - setup.py - reduce width of progress bar - macos bash install script - macos app launcher --- .github/workflows/pytest.yml | 4 - .install/macos/app.zip | Bin 0 -> 226275 bytes .install/macos/faceswap_setup_macos.sh | 491 +++++++++++++++++++++++++ INSTALL.md | 8 +- setup.py | 2 +- 5 files changed, 497 insertions(+), 8 deletions(-) create mode 100644 .install/macos/app.zip create mode 100644 .install/macos/faceswap_setup_macos.sh diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index badd8f4fc8..1172b91594 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -24,10 +24,6 @@ jobs: backend: "rocm" - os: "windows-latest" backend: "directml" - exclude: - # pynvx does not currently build for Python3.10 and without CUDA it may not build at all - - os: "macos-latest" - backend: "nvidia" steps: - uses: actions/checkout@v3 - name: Set cache date diff --git a/.install/macos/app.zip b/.install/macos/app.zip new file mode 100644 index 0000000000000000000000000000000000000000..9ce64629d624a522f5bf59be9477ddf078bce9c2 GIT binary patch literal 226275 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
  • olGH8ir`qb+BlmN)&v{CWOIf9>e9EMIb1H5%WS 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 )&65-HI%>OMB!qESkg|rP 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%0Du7j0}Kiv?3dTaV8EFJBKA+{r_g6% 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
  • x#5 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
    kEZpKqXpY(sHDsAN=A3#d0&GmA$l) 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 z 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 /dev/null ; then + error "'curl' is required for running the Faceswap installer, but could not be found. \ + Please install 'curl' before proceeding." + exit 1 + fi +} + +check_for_xcode() { + # Ensure that xcode command line tools are available on the system + if xcode-select -p 2>&1 | grep -q "xcode-select: error" ; then + error "Xcode is required to install faceswap. Please install Xcode Command Line Tools \ + before proceeding. If the Xcode installer does not automatically open, then \ + you can run the command:" + error "xcode-select --install" + echo "" + xcode-select --install + exit 1 + fi +} + +create_tmp_dir() { + TMP_DIR="$(mktemp -d)" + if [ -z "$TMP_DIR" -o ! -d "$TMP_DIR" ]; then + # This shouldn't happen, but just in case to prevent the tmp cleanup function to mess things up. + error "Failed creating the temporary install directory." + exit 2 + fi + trap cleanup_tmp_dir EXIT +} + +cleanup_tmp_dir() { + rm -rf "$TMP_DIR" +} + +ask () { + # Ask for input. First parameter: Display text, 2nd parameter variable name + default="${!2}" + read -rp $'\e[35m'"$1 [default: '$default']: "$'\e[39m' inp + inp="${inp:-${default}}" + if [ "$inp" == "\n" ] ; then inp=${!2} ; fi + printf -v $2 "$inp" +} + +ask_yesno () { + # Ask yes or no. First Param: Question, 2nd param: Default + # Returns True for yes, False for No + case $2 in + [Yy]* ) opts="[YES/no]" ;; + [Nn]* ) opts="[yes/NO]" ;; + esac + while true; do + read -rp $'\e[35m'"$1 $opts: "$'\e[39m' yn + yn="${yn:-${2}}" + case $yn in + [Yy]* ) retval=true ; break ;; + [Nn]* ) retval=false ; break ;; + * ) echo "Please answer yes or no." ;; + esac + done + $retval +} + + +ask_version() { + # Ask which version of faceswap to install + while true; do + default=1 + read -rp $'\e[35mSelect:\t1: Apple Silicon\n\t2: NVIDIA\n\t3: CPU\n'"[default: $default]: "$'\e[39m' vers + vers="${vers:-${default}}" + case $vers in + 1) VERSION="apple_silicon" ; break ;; + 2) VERSION="nvidia" ; break ;; + 3) VERSION="cpu" ; break ;; + * ) echo "Invalid selection." ;; + esac + done +} + +banner () { + echo $' \e[32m 001' + echo $' \e[32m 11 10 010' + echo $' \e[39m @@@@\e[32m 10' + echo $' \e[39m @@@@@@@@\e[32m 00 1' + echo $' \e[39m @@@@@@@@@@\e[32m 1 1 0' + echo $' \e[39m @@@@@@@@\e[32m 0000 01111' + echo $' \e[39m @@@@@@@@@@\e[32m 01 110 01 1' + echo $' \e[39m@@@@@@@@@@@@\e[32m 111 010 0' + echo $' \e[39m@@@@@@@@@@@@@@@@\e[32m 10 0' + echo $' \e[39m@@@@@@@@@@@@@\e[32m 0010 1' + echo $' \e[39m@@@@@@@@@ @@@\e[32m 100 1' + echo $' \e[39m@@@@@@@ .@@@@\e[32m 10 1' + echo $' \e[39m #@@@@@@@@@@@\e[32m 001 0' + echo $' \e[39m @@@@@@@@@@@ ,' + echo ' @@@@@@@@ @@@@@' + echo ' @@@@@@@@ @@@@@@@@ _' + echo ' @@@@@@@@@,@@@@@@@@ / _|' + echo ' %@@@@@@@@@@@@@@@@@ | |_ ___ ' + echo ' @@@@@@@@@@@@@@ | _|/ __|' + echo ' @@@@@@@@@@@@ | | \__ \' + echo ' @@@@@@@@@@( |_| |___/' + echo ' @@@@@@' + echo ' @@@@' + sleep 2 +} + +find_conda_install() { + if check_conda_path; + then true + elif check_conda_locations ; then true + else false + fi +} + +set_conda_dir_from_bin() { + # Set the DIR_CONDA variable from the bin file + pth="$(dirname "$1")/.." + DIR_CONDA=$(python -c "import os, sys; print(os.path.realpath('$pth'))") + info "Found existing conda install at: $DIR_CONDA" +} + +check_conda_path() { + # Check if conda is in PATH + conda_bin="$(which conda 2>/dev/null)" + if [[ "$?" == "0" ]]; then + set_conda_dir_from_bin "$conda_bin" + CONDA_EXECUTABLE="$conda_bin" + true + else + false + fi +} + +check_conda_locations() { + # Check common conda install locations + retval=false + for path in "${CONDA_PATHS[@]}"; do + for name in "${CONDA_NAMES[@]}" ; do + foldername="$path/$name" + for vers in "${CONDA_VERSIONS[@]}" ; do + for bin in "${CONDA_BINS[@]}" ; do + condabin="$foldername$vers$bin" + if check_file_exists "$condabin" ; then + set_conda_dir_from_bin "$condabin" + CONDA_EXECUTABLE="$condabin"; + retval=true + break 4 + fi + done + done + done + done + $retval +} + +user_input() { + # Get user options for install + header "Welcome to the macOS Faceswap Installer" + info "To get setup we need to gather some information about where you would like Faceswap\ + and Conda to be installed." + info "To accept the default values just hit the 'ENTER' key for each option. You will have\ + an opportunity to review your responses prior to commencing the install." + echo "" + info "IMPORTANT: Make sure that the user '$USER' has full permissions for all of the\ + destinations that you select." + read -rp $'\e[35m'"Press 'ENTER' to continue with the setup..."$'\e[39m' + apps_opts + conda_opts + faceswap_opts + post_install_opts +} + +apps_opts () { + # Options pertaining to additional apps that are required + if ! command -V xquartz &> /dev/null ; then + header "APPS" + info "XQuartz is required to use the Faceswap GUI but was not detected. " + if ask_yesno "Install XQuartz for GUI support?" "Yes" ; then + XQUARTZ=true + fi + fi +} + +conda_opts () { + # Options pertaining to the installation of conda + header "CONDA" + info "Faceswap uses Conda as it handles the installation of all prerequisites." + if find_conda_install && ask_yesno "Use the pre installed conda?" "Yes"; then + info "Using Conda install at $DIR_CONDA" + else + echo "" + info "If you have an existing Conda install then enter the location here,\ + otherwise Miniconda3 will be installed in the given location." + err_msg="The location for Conda must not contain spaces (this is a specific\ + limitation of Conda)." + tmp_dir_conda="$DIR_CONDA" + while true ; do + ask "Please specify a location for Conda." "DIR_CONDA" + case ${DIR_CONDA} in + *\ * ) error "$err_msg" ; DIR_CONDA=$tmp_dir_conda ;; + * ) break ;; + esac + CONDA_EXECUTABLE="${DIR_CONDA}/bin/conda" + done + fi + if ! check_file_exists "$CONDA_EXECUTABLE" ; then + echo "" + info "The Conda executable can be added to your PATH. This makes it easier to run Conda\ + commands directly. If you already have a pre-existing Conda install then you should\ + probably not enable this, otherwise this should be fine." + if ask_yesno "Add Conda executable to path?" "Yes" ; then CONDA_TO_PATH=true ; fi + fi + echo "" + info "Faceswap will be installed inside a Conda Environment. If an environment already\ + exists with the name specified then it will be deleted." + ask "Please specify a name for the Faceswap Conda Environmnet" "ENV_NAME" +} + +faceswap_opts () { + # Options pertaining to the installation of faceswap + header "FACESWAP" + info "Faceswap will be installed in the given location. If a folder exists at the\ + location you specify, then it will be deleted." + ask "Please specify a location for Faceswap" "DIR_FACESWAP" + echo "" + info "Faceswap can be run on Apple Silicon (M1, M2 etc.), compatible NVIDIA gpus, or on CPU. You should make sure that any \ + drivers are up to date. Please select the version of Faceswap you wish to install." + ask_version + if [ $VERSION == "apple_silicon" ] ; then + DL_CONDA="${URL_CONDA}arm64.sh" + fi +} + +post_install_opts() { + # Post installation options + header "POST INSTALLATION ACTIONS" + info "Launching Faceswap requires activating your Conda Environment and then running\ + Faceswap. The installer can simplify this by creating an Application Launcher file and placing it \ + on your desktop to launch straight into the Faceswap GUI" + if ask_yesno "Create FaceswapGUI Launcher?" "Yes" ; then + DESKTOP=true + fi +} + +review() { + # Review user options and ask continue + header "Review install options" + info "Please review the selected installation options before proceeding:" + echo "" + if $XQUARTZ ; then echo " - The XQuartz installer will be downloaded and launched" ; fi + if ! check_folder_exists "$DIR_CONDA" + then + echo " - MiniConda3 will be installed in '$DIR_CONDA'" + else + echo " - Existing Conda install at '$DIR_CONDA' will be used" + fi + if $CONDA_TO_PATH ; then echo " - MiniConda3 will be added to your PATH" ; fi + if check_env_exists ; then + echo $' \e[33m- Existing Conda Environment '$ENV_NAME $' will be removed\e[39m' + fi + echo " - Conda Environment '$ENV_NAME' will be created." + if check_folder_exists "$DIR_FACESWAP" ; then + echo $' \e[33m- Existing Faceswap folder '$DIR_FACESWAP $' will be removed\e[39m' + fi + echo " - Faceswap will be installed in '$DIR_FACESWAP'" + echo " - Installing for '$VERSION'" + if [ $VERSION == "nvidia" ] ; then + echo $' \e[33m- Note: Please ensure that Nvidia drivers are installed prior to proceeding\e[39m' + fi + if $DESKTOP ; then echo " - An Application Launcher will be created" ; fi + if ! ask_yesno "Do you wish to continue?" "No" ; then exit ; fi +} + +xquartz_install() { + # Download and install XQuartz + if $XQUARTZ ; then + info "Downloading XQuartz..." + yellow ; download_file $DL_XQUARTZ + echo "" + + info "Installing XQuartz..." + info "Admin password required to install XQuartz:" + fname="$(basename -- $DL_XQUARTZ)" + yellow ; sudo installer -pkg "$TMP_DIR/$fname" -target / + echo "" + fi +} + +conda_install() { + # Download and install Mini Conda3 + if ! check_folder_exists "$DIR_CONDA" ; then + info "Downloading Miniconda3..." + yellow ; download_file $DL_CONDA + info "Installing Miniconda3..." + yellow ; fname="$(basename -- $DL_CONDA)" + bash "$TMP_DIR/$fname" -b -p "$DIR_CONDA" + if $CONDA_TO_PATH ; then + info "Adding Miniconda3 to PATH..." + yellow ; "$CONDA_EXECUTABLE" init zsh bash + "$CONDA_EXECUTABLE" config --set auto_activate_base false + fi + fi +} + +check_env_exists() { + # Check if an environment with the given name exists + if check_file_exists "$CONDA_EXECUTABLE" ; then + "$CONDA_EXECUTABLE" env list | grep -qE "^${ENV_NAME}\W" + else false + fi +} + +delete_env() { + # Delete the env if it previously exists + if check_env_exists ; then + info "Removing pre-existing Virtual Environment" + yellow ; "$CONDA_EXECUTABLE" env remove -n "$ENV_NAME" + fi +} + +create_env() { + # Create Python 3.10 env for faceswap + delete_env + info "Creating Conda Virtual Environment..." + yellow ; "$CONDA_EXECUTABLE" create -n "$ENV_NAME" -q python="$PYENV_VERSION" -y +} + + +activate_env() { + # Activate the conda environment + # shellcheck source=/dev/null + source "$DIR_CONDA/etc/profile.d/conda.sh" activate + conda activate "$ENV_NAME" +} + +delete_faceswap() { + # Delete existing faceswap folder + if check_folder_exists "$DIR_FACESWAP" ; then + info "Removing Faceswap folder: '$DIR_FACESWAP'" + rm -rf "$DIR_FACESWAP" + fi +} + +clone_faceswap() { + # Clone the faceswap repo + delete_faceswap + info "Downloading Faceswap..." + yellow ; git clone --depth 1 --no-single-branch "$DL_FACESWAP" "$DIR_FACESWAP" +} + +setup_faceswap() { + # Run faceswap setup script + info "Setting up Faceswap..." + python -u "$DIR_FACESWAP/setup.py" --installer --$VERSION +} + +create_gui_launcher () { + # Create a shortcut to launch into the GUI + launcher="$DIR_FACESWAP/faceswap_gui_launcher.command" + launch_script="#!/bin/bash\n" + launch_script+="source \"$DIR_CONDA/etc/profile.d/conda.sh\" activate && \n" + launch_script+="conda activate '$ENV_NAME' && \n" + launch_script+="python \"$DIR_FACESWAP/faceswap.py\" gui" + printf "$launch_script" > "$launcher" + chmod +x "$launcher" +} + +create_app_on_desktop () { + # Create a simple .app wrapper to launch GUI + if $DESKTOP ; then + app_name="FaceswapGUI" + app_dir="$TMP_DIR/$app_name.app" + + unzip -qq "$DIR_FACESWAP/.install/macos/app.zip" -d "$TMP_DIR" + + script="#!/bin/bash\n" + script+="bash \"$DIR_FACESWAP/faceswap_gui_launcher.command\"" + printf "$script" > "$app_dir/Contents/Resources/script" + chmod +x "$app_dir/Contents/Resources/script" + + rm -rf "$HOME/Desktop/$app_name.app" + mv "$app_dir" "$HOME/Desktop" + fi ; +} + +check_for_sudo +check_for_curl +check_for_xcode +banner +user_input +review +create_tmp_dir +xquartz_install +conda_install +create_env +activate_env +clone_faceswap +setup_faceswap +create_gui_launcher +create_app_on_desktop +info "Faceswap installation is complete!" +if $CONDA_TO_PATH ; then + info "You should close the terminal before proceeding" ; fi +if $DESKTOP ; then info "You can launch Faceswap from the icon on your desktop" ; fi +if $XQUARTZ ; then + warn "XQuartz has been installed. You must log out and log in again to be able to use the GUI" ; fi diff --git a/INSTALL.md b/INSTALL.md index 24807eb793..752c272929 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -4,7 +4,7 @@ - [Hardware Requirements](#hardware-requirements) - [Supported operating systems](#supported-operating-systems) - [Important before you proceed](#important-before-you-proceed) -- [Linux and Windows Install Guide](#linux-and-windows-install-guide) +- [Linux, Windows and macOS Install Guide](#linux-windows-and-macos-install-guide) - [Installer](#installer) - [Manual Install](#manual-install) - [Prerequisites](#prerequisites-1) @@ -83,10 +83,10 @@ Alternatively, there is a docker image that is based on Debian. The developers are also not responsible for any damage you might cause to your own computer. -# Linux and Windows Install Guide +# Linux, Windows and macOS Install Guide ## Installer -Windows and Linux now both have an installer which installs everything for you and creates a desktop shortcut to launch straight into the GUI. You can download the installer from https://github.com/deepfakes/faceswap/releases. +Windows, Linux and macOS all have installers which set up everything for you. You can download the installer from https://github.com/deepfakes/faceswap/releases. If you have issues with the installer then read on for the more manual way to install faceswap on Windows. @@ -165,6 +165,8 @@ It's good to keep faceswap up to date as new features are added and bugs are fix # macOS (Apple Silicon) Install Guide +macOS now has [an installer](#linux-windows-and-macos-install-guide) which sets everything up for you, but if you run into difficulties and need to set things up manually, the steps are as follows: + ## Prerequisites ### OS diff --git a/setup.py b/setup.py index cef8e2bf3e..42bdb7a22b 100755 --- a/setup.py +++ b/setup.py @@ -1146,7 +1146,7 @@ class ProgressBar(): def __init__(self): self._width_desc = 21 self._width_size = 9 - self._width_bar = 37 + self._width_bar = 35 self._width_pct = 4 self._marker = "█" From 0bba6ffd8bacec7a51c4ac78319ee4198b24b60a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 6 Aug 2023 13:16:32 +0100 Subject: [PATCH 854/981] bugfix: train - fix occasional mem leak in preview --- plugins/train/trainer/_base.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 146f7b70de..2e92ff9f16 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -693,8 +693,11 @@ def _get_predictions(self, feed_a: np.ndarray, feed_b: np.ndarray) -> dict[str, """ logger.debug("Getting Predictions") preds: dict[str, np.ndarray] = {} - standard = self._model.model.predict([feed_a, feed_b], verbose=0) - swapped = self._model.model.predict([feed_b, feed_a], verbose=0) + + # Calling model.predict() can lead to both VRAM and system memory leaks, so call model + # directly + standard = [t.numpy() for t in self._model.model([feed_a, feed_b])] + swapped = [t.numpy() for t in self._model.model([feed_b, feed_a])] if self._model.config["learn_mask"]: # Add mask to 4th channel of final output standard = [np.concatenate(side[-2:], axis=-1) for side in standard] From 68a3322748e2319a5a6d7c7015d7d6df51d5b431 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 9 Aug 2023 19:37:24 +0100 Subject: [PATCH 855/981] bugfix: train - fix learn_mask training --- plugins/train/trainer/_base.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 2e92ff9f16..2614921cfb 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -696,15 +696,19 @@ def _get_predictions(self, feed_a: np.ndarray, feed_b: np.ndarray) -> dict[str, # Calling model.predict() can lead to both VRAM and system memory leaks, so call model # directly - standard = [t.numpy() for t in self._model.model([feed_a, feed_b])] - swapped = [t.numpy() for t in self._model.model([feed_b, feed_a])] + standard = self._model.model([feed_a, feed_b]) + swapped = self._model.model([feed_b, feed_a]) if self._model.config["learn_mask"]: # Add mask to 4th channel of final output - standard = [np.concatenate(side[-2:], axis=-1) for side in standard] - swapped = [np.concatenate(side[-2:], axis=-1) for side in swapped] + standard = [np.concatenate(side[-2:], axis=-1) + for side in [[s.numpy() for s in t] for t in standard]] + swapped = [np.concatenate(side[-2:], axis=-1) + for side in [[s.numpy() for s in t] for t in swapped]] else: # Retrieve final output - standard = [side[-1] if isinstance(side, list) else side for side in standard] - swapped = [side[-1] if isinstance(side, list) else side for side in swapped] + standard = [side[-1] if isinstance(side, list) else side + for side in [t.numpy() for t in standard]] + swapped = [side[-1] if isinstance(side, list) else side + for side in [t.numpy() for t in swapped]] preds["a_a"] = standard[0] preds["b_b"] = standard[1] From cf0efff6ba0f344072af1f345a5f1e26fe3d4c15 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 22 Aug 2023 17:07:36 +0100 Subject: [PATCH 856/981] Learning Rate finder (#1341) * Add LR Finder support structure * Move trainer.Feeder to lib.training.generator * Expose model.io * Add lr_finder * Update docs and locales * Pre-PR fixups - Fix training graph not displaying - CI fixes - Switch lr finder progress to tqdm - Exit lr finder early on NaN - Display lr finder progress in GUI --- docs/full/lib/training.rst | 8 +- lib/cli/args.py | 27 +- lib/gui/custom_widgets.py | 60 +++- lib/gui/display_graph.py | 3 +- lib/gui/wrapper.py | 39 ++- lib/training/__init__.py | 3 +- lib/training/generator.py | 233 ++++++++++++++ lib/training/lr_finder.py | 210 +++++++++++++ locales/lib.cli.args.pot | 223 +++++++------- locales/plugins.train._config.pot | 104 ++++--- locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 63448 -> 64186 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 250 +++++++-------- .../ru/LC_MESSAGES/plugins.train._config.mo | Bin 56243 -> 59441 bytes .../ru/LC_MESSAGES/plugins.train._config.po | 127 +++++--- plugins/train/_config.py | 47 +++ plugins/train/model/_base/io.py | 37 ++- plugins/train/model/_base/model.py | 88 +++--- plugins/train/model/phaze_a.py | 2 +- plugins/train/trainer/_base.py | 286 +++--------------- scripts/train.py | 9 +- tests/lib/model/losses_test.py | 2 +- tests/simple_tests.py | 3 +- 22 files changed, 1128 insertions(+), 633 deletions(-) create mode 100644 lib/training/lr_finder.py diff --git a/docs/full/lib/training.rst b/docs/full/lib/training.rst index 579e4751eb..f3cb797f40 100644 --- a/docs/full/lib/training.rst +++ b/docs/full/lib/training.rst @@ -32,6 +32,13 @@ training.generator module :undoc-members: :show-inheritance: +training.lr_finder module +========================= + +.. automodule:: lib.training.lr_finder + :members: + :undoc-members: + :show-inheritance: training.preview_cv module ========================== @@ -41,7 +48,6 @@ training.preview_cv module :undoc-members: :show-inheritance: - training.preview_tk module ========================== diff --git a/lib/cli/args.py b/lib/cli/args.py index 0d2de626e7..11c34edba2 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -1059,6 +1059,25 @@ def get_argument_list() -> list[dict[str, T.Any]]: "\nL|mirrored: Supports synchronous distributed training across multiple local " "GPUs. A copy of the model and all variables are loaded onto each GPU with " "batches distributed to each GPU at each iteration."))) + argument_list.append(dict( + opts=("-nl", "--no-logs"), + action="store_true", + dest="no_logs", + default=False, + group=_("training"), + help=_("Disables TensorBoard logging. NB: Disabling logs means that you will not be " + "able to use the graph or analysis for this session in the GUI."))) + argument_list.append(dict( + opts=("-r", "--use-lr-finder"), + action="store_true", + dest="use_lr_finder", + default=False, + group=_("training"), + help=_("Use the Learning Rate Finder to discover the optimal learning rate for " + "training. For new models, this will calculate the optimal learning rate for " + "the model. For existing models this will use the optimal learning rate that " + "was discovered when initializing the model. Setting this option will ignore " + "the manually configured learning rate (configurable in train settings)."))) argument_list.append(dict( opts=("-s", "--save-interval"), action=Slider, @@ -1127,14 +1146,6 @@ def get_argument_list() -> list[dict[str, T.Any]]: group=_("preview"), help=_("Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder."))) - argument_list.append(dict( - opts=("-nl", "--no-logs"), - action="store_true", - dest="no_logs", - default=False, - group=_("training"), - help=_("Disables TensorBoard logging. NB: Disabling logs means that you will not be " - "able to use the graph or analysis for this session in the GUI."))) argument_list.append(dict( opts=("-wl", "--warp-to-landmarks"), action="store_true", diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index 6eb015b57c..30c73a6554 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -5,6 +5,7 @@ import platform import re import sys +import typing as T import tkinter as tk from tkinter import ttk, TclError @@ -101,7 +102,7 @@ def __init__(self, labels, actions, hotkeys=None): def _create_menu(self): """ Create the menu based on :attr:`_labels` and :attr:`_actions`. """ for idx, (label, action) in enumerate(zip(self._labels, self._actions)): - kwargs = dict(label=label, command=action) + kwargs = {"label": label, "command": action} if isinstance(self._hotkeys, (list, tuple)) and self._hotkeys[idx]: kwargs["accelerator"] = self._hotkeys[idx] self.add_command(**kwargs) @@ -428,12 +429,13 @@ class StatusBar(ttk.Frame): # pylint: disable=too-many-ancestors frame otherwise ``False``. Default: ``False`` """ - def __init__(self, parent, hide_status=False): + def __init__(self, parent: ttk.Frame, hide_status: bool = False) -> None: super().__init__(parent) self._frame = ttk.Frame(self) self._message = tk.StringVar() self._pbar_message = tk.StringVar() self._pbar_position = tk.IntVar() + self._mode: T.Literal["indeterminate", "determinate"] = "determinate" self._message.set("Ready") @@ -443,12 +445,12 @@ def __init__(self, parent, hide_status=False): self._frame.pack(padx=10, pady=2, fill=tk.X, expand=False) @property - def message(self): + def message(self) -> tk.StringVar: """:class:`tkinter.StringVar`: The variable to hold the status bar message on the left hand side of the status bar. """ return self._message - def _status(self, hide_status): + def _status(self, hide_status: bool) -> None: """ Place Status label into left of the status bar. Parameters @@ -472,8 +474,14 @@ def _status(self, hide_status): anchor=tk.W) lblstatus.pack(side=tk.LEFT, anchor=tk.W, fill=tk.X, expand=True) - def _progress_bar(self): - """ Place progress bar into right of the status bar. """ + def _progress_bar(self) -> ttk.Progressbar: + """ Place progress bar into right of the status bar. + + Returns + ------- + :class:`tkinter.ttk.Progressbar` + The progress bar object + """ progressframe = ttk.Frame(self._frame) progressframe.pack(side=tk.RIGHT, anchor=tk.E, fill=tk.X) @@ -484,12 +492,12 @@ def _progress_bar(self): length=200, variable=self._pbar_position, maximum=100, - mode="determinate") + mode=self._mode) pbar.pack(side=tk.LEFT, padx=2, fill=tk.X, expand=True) pbar.pack_forget() return pbar - def start(self, mode): + def start(self, mode: T.Literal["indeterminate", "determinate"]) -> None: """ Set progress bar mode and display, Parameters @@ -500,16 +508,24 @@ def start(self, mode): self._set_mode(mode) self._pbar.pack() - def stop(self): + def stop(self) -> None: """ Reset progress bar and hide """ self._pbar_message.set("") self._pbar_position.set(0) - self._set_mode("determinate") + self._mode = "determinate" + self._set_mode(self._mode) self._pbar.pack_forget() - def _set_mode(self, mode): - """ Set the progress bar mode """ - self._pbar.config(mode=mode) + def _set_mode(self, mode: T.Literal["indeterminate", "determinate"]) -> None: + """ Set the progress bar mode + + Parameters + ---------- + mode: ["indeterminate", "determinate"] + The mode that the progress bar should be executed in + """ + self._mode = mode + self._pbar.config(mode=self._mode) if mode == "indeterminate": self._pbar.config(maximum=100) self._pbar.start() @@ -517,7 +533,23 @@ def _set_mode(self, mode): self._pbar.stop() self._pbar.config(maximum=100) - def progress_update(self, message, position, update_position=True): + def set_mode(self, mode: T.Literal["indeterminate", "determinate"]) -> None: + """ Set the mode of a currently displayed progress bar and reset position to 0. + + If the given mode is the same as the currently configured mode, returns without performing + any action. + + Parameters + ---------- + mode: ["indeterminate", "determinate"] + The mode that the progress bar should be set to + """ + if mode == self._mode: + return + self.stop() + self.start(mode) + + def progress_update(self, message: str, position: int, update_position: bool = True) -> None: """ Update the GUIs progress bar and position. Parameters diff --git a/lib/gui/display_graph.py b/lib/gui/display_graph.py index 1ab5b6749c..32686e1268 100755 --- a/lib/gui/display_graph.py +++ b/lib/gui/display_graph.py @@ -24,8 +24,6 @@ if T.TYPE_CHECKING: from matplotlib.lines import Line2D -matplotlib.use("TkAgg") - logger: logging.Logger = logging.getLogger(__name__) @@ -44,6 +42,7 @@ class GraphBase(ttk.Frame): # pylint: disable=too-many-ancestors def __init__(self, parent: ttk.Frame, data, ylabel: str) -> None: logger.debug("Initializing %s", self.__class__.__name__) super().__init__(parent) + matplotlib.use("TkAgg") # Can't be at module level as breaks Github CI style.use("ggplot") self._calcs = data diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index 10b4d52065..dfb2b82fce 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -115,7 +115,9 @@ def _prepare(self, category: T.Literal["faceswap", "tools"]) -> list[str]: print("Loading...") self._statusbar.message.set(f"Executing - {self._command}.py") - mode = "indeterminate" if self._command in ("effmpeg", "train") else "determinate" + mode: T.Literal["indeterminate", + "determinate"] = ("indeterminate" if self._command in ("effmpeg", "train") + else "determinate") self._statusbar.start(mode) args = self._build_args(category) @@ -236,6 +238,7 @@ def __init__(self, wrapper: ProcessWrapper) -> None: "tqdm": re.compile(r"(?P.*?)(?P\d+%).*?(?P\S+/\S+)\W\[" r"(?P[\d+:]+<.*),\W(?P.*)[a-zA-Z/]*\]"), "ffmpeg": re.compile(r"([a-zA-Z]+)=\s*(-?[\d|N/A]\S+)")} + self._first_loss_seen = False logger.debug("Initialized %s", self.__class__.__name__) @property @@ -269,6 +272,24 @@ def execute_script(self, command: str, args: list[str]) -> None: self._thread_stderr() logger.debug("Executed Faceswap") + def _process_training_determinate_function(self, output: str) -> bool: + """ Process an stdout/stderr message to check for determinate TQDM output when training + + Parameters + ---------- + output: str + The stdout/stderr string to test + + Returns + ------- + bool + ``True`` if a determinate TQDM line was parsed when training otherwise ``False`` + """ + if self._command == "train" and not self._first_loss_seen and self._capture_tqdm(output): + self._statusbar.set_mode("determinate") + return True + return False + def _process_progress_stdout(self, output: str) -> bool: """ Process stdout for any faceswap processes that update the status/progress bar(s) @@ -282,6 +303,9 @@ def _process_progress_stdout(self, output: str) -> bool: bool ``True`` if all actions have been completed on the output line otherwise ``False`` """ + if self._process_training_determinate_function(output): + return True + if self._command == "train" and self._capture_loss(output): return True @@ -306,7 +330,9 @@ def _process_training_stdout(self, output: str) -> None: if self._command != "train" or not tk_vars.is_training.get(): return - if "[saved models]" not in output.strip().lower(): + t_output = output.strip().lower() + if "[saved model]" not in t_output or t_output.endswith("[saved model]"): + # Not a saved model line or saving the model for a reason other than standard saving return logger.debug("Trigger GUI Training update") @@ -346,6 +372,7 @@ def _read_stdout(self) -> None: returncode = self._process.poll() assert returncode is not None + self._first_loss_seen = False message = self._set_final_status(returncode) self._wrapper.terminate(message) logger.debug("Terminated stdout reader. returncode: %s", returncode) @@ -369,8 +396,7 @@ def _read_stderr(self) -> None: if output: if self._command != "train" and self._capture_tqdm(output): continue - if self._command == "train" and output.startswith("Reading training images"): - print(output.strip(), file=sys.stdout) + if self._process_training_determinate_function(output): continue if os.name == "nt" and "Call to CreateProcess failed. Error code: 2" in output: # Suppress ptxas errors on Tensorflow for Windows @@ -438,6 +464,11 @@ def _capture_loss(self, string: str) -> bool: elapsed = self._calculate_elapsed() message = (f"Elapsed: {elapsed} | " f"Session Iterations: {self._train_stats['iterations']} {message}") + + if not self._first_loss_seen: + self._statusbar.set_mode("indeterminate") + self._first_loss_seen = True + self._statusbar.progress_update(message, 0, False) logger.trace("Succesfully captured loss: %s", message) # type:ignore[attr-defined] return True diff --git a/lib/training/__init__.py b/lib/training/__init__.py index e35d3e1961..0e30c90340 100644 --- a/lib/training/__init__.py +++ b/lib/training/__init__.py @@ -5,7 +5,8 @@ import typing as T from .augmentation import ImageAugmentation -from .generator import PreviewDataGenerator, TrainingDataGenerator +from .generator import Feeder +from .lr_finder import LearningRateFinder from .preview_cv import PreviewBuffer, TriggerType if T.TYPE_CHECKING: diff --git a/lib/training/generator.py b/lib/training/generator.py index 8507a1cdf5..455e96f11e 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -724,3 +724,236 @@ def process_batch(self, samples = self._create_samples(images, detected_faces) return feed, samples + + +class Feeder(): + """ Handles the processing of a Batch for training the model and generating samples. + + Parameters + ---------- + images: dict + The list of full paths to the training images for this :class:`_Feeder` for each side + model: plugin from :mod:`plugins.train.model` + The selected model that will be running this trainer + batch_size: int + The size of the batch to be processed for each side at each iteration + config: dict + The configuration for this trainer + include_preview: bool, optional + ``True`` to create a feeder for generating previews. Default: ``True`` + """ + def __init__(self, + images: dict[T.Literal["a", "b"], list[str]], + model: ModelBase, + batch_size: int, + config: dict[str, ConfigValueType], + include_preview: bool = True) -> None: + logger.debug("Initializing %s: num_images: %s, batch_size: %s, config: %s, " + "include_preview: %s)", self.__class__.__name__, + {k: len(v) for k, v in images.items()}, batch_size, config, include_preview) + self._model = model + self._images = images + self._batch_size = batch_size + self._config = config + self._feeds = { + side: self._load_generator(side, False).minibatch_ab() + for side in T.get_args(T.Literal["a", "b"])} + + self._display_feeds = {"preview": self._set_preview_feed() if include_preview else {}, + "timelapse": {}} + logger.debug("Initialized %s:", self.__class__.__name__) + + def _load_generator(self, + side: T.Literal["a", "b"], + is_display: bool, + batch_size: int | None = None, + images: list[str] | None = None) -> DataGenerator: + """ Load the :class:`~lib.training_data.TrainingDataGenerator` for this feeder. + + Parameters + ---------- + side: ["a", "b"] + The side of the model to load the generator for + is_display: bool + ``True`` if the generator is for creating preview/time-lapse images. ``False`` if it is + for creating training images + batch_size: int, optional + If ``None`` then the batch size selected in command line arguments is used, otherwise + the batch size provided here is used. + images: list, optional. Default: ``None`` + If provided then this will be used as the list of images for the generator. If ``None`` + then the training folder images for the side will be used. Default: ``None`` + + Returns + ------- + :class:`~lib.training_data.TrainingDataGenerator` + The training data generator + """ + logger.debug("Loading generator, side: %s, is_display: %s, batch_size: %s", + side, is_display, batch_size) + generator = PreviewDataGenerator if is_display else TrainingDataGenerator + retval = generator(self._config, + self._model, + side, + self._images[side] if images is None else images, + self._batch_size if batch_size is None else batch_size) + return retval + + def _set_preview_feed(self) -> dict[T.Literal["a", "b"], Generator[BatchType, None, None]]: + """ Set the preview feed for this feeder. + + Creates a generator from :class:`lib.training_data.PreviewDataGenerator` specifically + for previews for the feeder. + + Returns + ------- + dict + The side ("a" or "b") as key, :class:`~lib.training_data.PreviewDataGenerator` as + value. + """ + retval: dict[T.Literal["a", "b"], Generator[BatchType, None, None]] = {} + num_images = self._config.get("preview_images", 14) + assert isinstance(num_images, int) + for side in T.get_args(T.Literal["a", "b"]): + logger.debug("Setting preview feed: (side: '%s')", side) + preview_images = min(max(num_images, 2), 16) + batchsize = min(len(self._images[side]), preview_images) + retval[side] = self._load_generator(side, + True, + batch_size=batchsize).minibatch_ab() + return retval + + def get_batch(self) -> tuple[list[list[np.ndarray]], ...]: + """ Get the feed data and the targets for each training side for feeding into the model's + train function. + + Returns + ------- + model_inputs: list + The inputs to the model for each side A and B + model_targets: list + The targets for the model for each side A and B + """ + model_inputs: list[list[np.ndarray]] = [] + model_targets: list[list[np.ndarray]] = [] + for side in ("a", "b"): + side_feed, side_targets = next(self._feeds[side]) + if self._model.config["learn_mask"]: # Add the face mask as it's own target + side_targets += [side_targets[-1][..., 3][..., None]] + logger.trace("side: %s, input_shapes: %s, target_shapes: %s", # type: ignore + side, side_feed.shape, [i.shape for i in side_targets]) + model_inputs.append([side_feed]) + model_targets.append(side_targets) + + return model_inputs, model_targets + + def generate_preview(self, is_timelapse: bool = False + ) -> dict[T.Literal["a", "b"], list[np.ndarray]]: + """ Generate the images for preview window or timelapse + + Parameters + ---------- + is_timelapse, bool, optional + ``True`` if preview is to be generated for a Timelapse otherwise ``False``. + Default: ``False`` + + Returns + ------- + dict + Dictionary for side A and B of list of numpy arrays corresponding to the + samples, targets and masks for this preview + """ + logger.debug("Generating preview (is_timelapse: %s)", is_timelapse) + + batchsizes: list[int] = [] + feed: dict[T.Literal["a", "b"], np.ndarray] = {} + samples: dict[T.Literal["a", "b"], np.ndarray] = {} + masks: dict[T.Literal["a", "b"], np.ndarray] = {} + + # MyPy can't recurse into nested dicts to get the type :( + iterator = T.cast(dict[T.Literal["a", "b"], "Generator[BatchType, None, None]"], + self._display_feeds["timelapse" if is_timelapse else "preview"]) + for side in T.get_args(T.Literal["a", "b"]): + side_feed, side_samples = next(iterator[side]) + batchsizes.append(len(side_samples[0])) + samples[side] = side_samples[0] + feed[side] = side_feed[..., :3] + masks[side] = side_feed[..., 3][..., None] + + logger.debug("Generated samples: is_timelapse: %s, images: %s", is_timelapse, + {key: {k: v.shape for k, v in item.items()} + for key, item + in zip(("feed", "samples", "sides"), (feed, samples, masks))}) + return self.compile_sample(min(batchsizes), feed, samples, masks) + + def compile_sample(self, + image_count: int, + feed: dict[T.Literal["a", "b"], np.ndarray], + samples: dict[T.Literal["a", "b"], np.ndarray], + masks: dict[T.Literal["a", "b"], np.ndarray] + ) -> dict[T.Literal["a", "b"], list[np.ndarray]]: + """ Compile the preview samples for display. + + Parameters + ---------- + image_count: int + The number of images to limit the sample output to. + feed: dict + Dictionary for side "a", "b" of :class:`numpy.ndarray`. The images that should be fed + into the model for obtaining a prediction + samples: dict + Dictionary for side "a", "b" of :class:`numpy.ndarray`. The 100% coverage target images + that should be used for creating the preview. + masks: dict + Dictionary for side "a", "b" of :class:`numpy.ndarray`. The masks that should be used + for creating the preview. + + Returns + ------- + list + The list of samples, targets and masks as :class:`numpy.ndarrays` for creating a + preview image + """ + num_images = self._config.get("preview_images", 14) + assert isinstance(num_images, int) + num_images = min(image_count, num_images) + retval: dict[T.Literal["a", "b"], list[np.ndarray]] = {} + for side in T.get_args(T.Literal["a", "b"]): + logger.debug("Compiling samples: (side: '%s', samples: %s)", side, num_images) + retval[side] = [feed[side][0:num_images], + samples[side][0:num_images], + masks[side][0:num_images]] + logger.debug("Compiled Samples: %s", {k: [i.shape for i in v] for k, v in retval.items()}) + return retval + + def set_timelapse_feed(self, + images: dict[T.Literal["a", "b"], list[str]], + batch_size: int) -> None: + """ Set the time-lapse feed for this feeder. + + Creates a generator from :class:`lib.training_data.PreviewDataGenerator` specifically + for generating time-lapse previews for the feeder. + + Parameters + ---------- + images: dict + The list of full paths to the images for creating the time-lapse for each side + batch_size: int + The number of images to be used to create the time-lapse preview. + """ + logger.debug("Setting time-lapse feed: (input_images: '%s', batch_size: %s)", + images, batch_size) + + # MyPy can't recurse into nested dicts to get the type :( + iterator = T.cast(dict[T.Literal["a", "b"], "Generator[BatchType, None, None]"], + self._display_feeds["timelapse"]) + + for side in T.get_args(T.Literal["a", "b"]): + imgs = images[side] + logger.debug("Setting preview feed: (side: '%s', images: %s)", side, len(imgs)) + + iterator[side] = self._load_generator(side, + True, + batch_size=batch_size, + images=imgs).minibatch_ab(do_shuffle=False) + logger.debug("Set time-lapse feed: %s", self._display_feeds["timelapse"]) diff --git a/lib/training/lr_finder.py b/lib/training/lr_finder.py new file mode 100644 index 0000000000..d5e2412005 --- /dev/null +++ b/lib/training/lr_finder.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" Learning Rate Finder for faceswap.py. """ +from __future__ import annotations +import logging +import os +import shutil +import typing as T +from datetime import datetime +from enum import Enum + +import tensorflow as tf +import matplotlib +import matplotlib.pyplot as plt +import numpy as np +from tqdm import tqdm + +if T.TYPE_CHECKING: + from lib.config import ConfigValueType + from lib.training import Feeder + from plugins.train.model._base import ModelBase + +keras = tf.keras +K = keras.backend + +logger = logging.getLogger(__name__) + + +class LRStrength(Enum): + """ Enum for how aggressively to set the optimal learning rate """ + DEFAULT = 10 + AGGRESSIVE = 5 + EXTREME = 2.5 + + +class LearningRateFinder: # pylint:disable=too-few-public-methods + """ Learning Rate Finder + + Parameters + ---------- + model: :class:`tensorflow.keras.models.Model` + The keras model to find the optimal learning rate for + config: dict + The configuration options for the model + feeder: :class:`~lib.training.generator.Feeder` + The feeder for training the model + stop_factor: int + When to stop finding the optimal learning rate + beta: float + Amount to smooth loss by, for graphing purposes + """ + def __init__(self, + model: ModelBase, + config: dict[str, ConfigValueType], + feeder: Feeder, + stop_factor: int = 4, + beta: float = 0.98) -> None: + logger.debug("Initializing %s: (model: %s, config: %s, feeder: %s, stop_factor: %s, " + "beta: %s)", + self.__class__.__name__, model, config, feeder, stop_factor, beta) + + self._iterations = T.cast(int, config["lr_finder_iterations"]) + self._save_graph = config["lr_finder_mode"] in ("graph_and_set", "graph_and_exit") + self._strength = LRStrength[T.cast(str, config["lr_finder_strength"]).upper()].value + self._config = config + + self._start_lr = 1e-10 + end_lr = 1e+1 + + self._model = model + self._feeder = feeder + self._stop_factor = stop_factor + self._beta = beta + self._lr_multiplier: float = (end_lr / self._start_lr) ** (1.0 / self._iterations) + + self._metrics: dict[T.Literal["learning_rates", "losses"], list[float]] = { + "learning_rates": [], + "losses": []} + self._loss: dict[T.Literal["avg", "best"], float] = {"avg": 0.0, "best": 1e9} + + logger.debug("Initialized %s", self.__class__.__name__) + + def _on_batch_end(self, iteration: int, loss: float) -> None: + """ Learning rate actions to perform at the end of a batch + + Parameters + ---------- + iteration: int + The current iteration + loss: float + The loss value for the current batch + """ + learning_rate = K.get_value(self._model.model.optimizer.lr) + self._metrics["learning_rates"].append(learning_rate) + + self._loss["avg"] = (self._beta * self._loss["avg"]) + ((1 - self._beta) * loss) + smoothed = self._loss["avg"] / (1 - (self._beta ** iteration)) + self._metrics["losses"].append(smoothed) + + stop_loss = self._stop_factor * self._loss["best"] + + if iteration > 1 and smoothed > stop_loss: + self._model.model.stop_training = True + return + + if iteration == 1 or smoothed < self._loss["best"]: + self._loss["best"] = smoothed + + learning_rate *= self._lr_multiplier + + K.set_value(self._model.model.optimizer.lr, learning_rate) + + def _update_description(self, progress_bar: tqdm) -> None: + """ Update the description of the progress bar for the current iteration + + Parameters + ---------- + progress_bar: :class:`tqdm.tqdm` + The learning rate finder progress bar to update + """ + current = self._metrics['learning_rates'][-1] + best_idx = self._metrics["losses"].index(self._loss["best"]) + best = self._metrics["learning_rates"][best_idx] / self._strength + progress_bar.set_description(f"Current: {current:.1e} Best: {best:.1e}") + + def _train(self) -> None: + """ Train the model for the given number of iterations to find the optimal + learning rate and show progress""" + logger.info("Finding optimal learning rate...") + pbar = tqdm(range(1, self._iterations + 1), + desc="Current: N/A Best: N/A ", + leave=False) + for idx in pbar: + model_inputs, model_targets = self._feeder.get_batch() + loss: list[float] = self._model.model.train_on_batch(model_inputs, y=model_targets) + if np.isnan(loss[0]): + break + self._on_batch_end(idx, loss[0]) + self._update_description(pbar) + + def find(self) -> bool: + """ Find the optimal learning rate + + Returns + ------- + bool + ``True`` if the learning rate was succesfully discovered otherwise ``False`` + """ + if not self._model.io.model_exists: + self._model.io.save() + + original_lr = K.get_value(self._model.model.optimizer.lr) + K.set_value(self._model.model.optimizer.lr, self._start_lr) + + self._train() + print() + + best_idx = self._metrics["losses"].index(self._loss["best"]) + new_lr = self._metrics["learning_rates"][best_idx] / self._strength + if new_lr < 1e-9: + logger.error("The optimal learning rate could not be found. This is most likely " + "because you did not run the finder for enough iterations.") + shutil.rmtree(self._model.io.model_dir) + return False + + if self._save_graph: + self._plot_loss() + + if not self._config["lr_finder_mode"] == "graph_and_exit": + logger.info("Updating Learning Rate from %s to %s", + f"{original_lr:.1e}", f"{new_lr:.1e}") + self._model.model.load_weights(self._model.io.filename) + K.set_value(self._model.model.optimizer.lr, new_lr) + + self._model.state.update_session_config("learning_rate", new_lr) + self._model.state.save() + return True + + def _plot_loss(self, skip_begin: int = 10, skip_end: int = 1) -> None: + """ Plot a graph of loss vs learning rate and save to the training folder + + Parameters + ---------- + skip_begin: int, optional + Number of iterations to skip at the start. Default: `10` + skip_end: int, optional + Number of iterations to skip at the end. Default: `1` + """ + matplotlib.use("Agg") + lrs = self._metrics["learning_rates"][skip_begin:-skip_end] + losses = self._metrics["losses"][skip_begin:-skip_end] + plt.plot(lrs, losses, label="Learning Rate") + best_idx = self._metrics["losses"].index(self._loss["best"]) + best_lr = self._metrics["learning_rates"][best_idx] + for val, color in zip(LRStrength, ("g", "y", "r")): + l_r = best_lr / val.value + idx = lrs.index(next(r for r in lrs if r >= l_r)) + plt.plot(l_r, losses[idx], + f"{color}o", + label=f"{val.name.title()}: {l_r:.1e}") + + plt.xscale("log") + plt.xlabel("Learning Rate (Log Scale)") + plt.ylabel("Loss") + plt.title("Learning Rate Finder") + plt.legend() + + now = datetime.now().strftime("%Y-%m-%d_%H.%M.%S") + output = os.path.join(self._model.io.model_dir, f"learning_rate_finder_{now}.png") + logger.info("Saving Learning Rate Finder graph to: '%s'", output) + plt.savefig(output) diff --git a/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index 690d56ad12..e173bc5b30 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-11-20 01:34+0000\n" +"POT-Creation-Date: 2023-08-20 14:52+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,12 +17,12 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: lib/cli/args.py:193 lib/cli/args.py:203 lib/cli/args.py:211 -#: lib/cli/args.py:221 +#: lib/cli/args.py:192 lib/cli/args.py:202 lib/cli/args.py:210 +#: lib/cli/args.py:220 msgid "Global Options" msgstr "" -#: lib/cli/args.py:194 +#: lib/cli/args.py:193 msgid "" "R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " "to any GPU(s) that you do not wish to be made available to Faceswap. " @@ -30,64 +30,64 @@ msgid "" "L|{}" msgstr "" -#: lib/cli/args.py:204 +#: lib/cli/args.py:203 msgid "" "Optionally overide the saved config with the path to a custom config file." msgstr "" -#: lib/cli/args.py:212 +#: lib/cli/args.py:211 msgid "" "Log level. Stick with INFO or VERBOSE unless you need to file an error " "report. Be careful with TRACE as it will generate a lot of data" msgstr "" -#: lib/cli/args.py:222 +#: lib/cli/args.py:221 msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "" -#: lib/cli/args.py:320 lib/cli/args.py:329 lib/cli/args.py:337 -#: lib/cli/args.py:386 lib/cli/args.py:677 lib/cli/args.py:686 +#: lib/cli/args.py:319 lib/cli/args.py:328 lib/cli/args.py:336 +#: lib/cli/args.py:385 lib/cli/args.py:676 lib/cli/args.py:685 msgid "Data" msgstr "" -#: lib/cli/args.py:321 +#: lib/cli/args.py:320 msgid "" "Input directory or video. Either a directory containing the image files you " "wish to process or path to a video file. NB: This should be the source video/" "frames NOT the source faces." msgstr "" -#: lib/cli/args.py:330 +#: lib/cli/args.py:329 msgid "Output directory. This is where the converted files will be saved." msgstr "" -#: lib/cli/args.py:338 +#: lib/cli/args.py:337 msgid "" "Optional path to an alignments file. Leave blank if the alignments file is " "at the default location." msgstr "" -#: lib/cli/args.py:361 +#: lib/cli/args.py:360 msgid "" "Extract faces from image or video sources.\n" "Extraction plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:387 +#: lib/cli/args.py:386 msgid "" "R|If selected then the input_dir should be a parent folder containing " "multiple videos and/or folders of images you wish to extract from. The faces " "will be output to separate sub-folders in the output_dir." msgstr "" -#: lib/cli/args.py:396 lib/cli/args.py:412 lib/cli/args.py:424 -#: lib/cli/args.py:463 lib/cli/args.py:481 lib/cli/args.py:493 -#: lib/cli/args.py:502 lib/cli/args.py:511 lib/cli/args.py:696 -#: lib/cli/args.py:723 lib/cli/args.py:761 +#: lib/cli/args.py:395 lib/cli/args.py:411 lib/cli/args.py:423 +#: lib/cli/args.py:462 lib/cli/args.py:480 lib/cli/args.py:492 +#: lib/cli/args.py:501 lib/cli/args.py:510 lib/cli/args.py:695 +#: lib/cli/args.py:722 lib/cli/args.py:760 msgid "Plugins" msgstr "" -#: lib/cli/args.py:397 +#: lib/cli/args.py:396 msgid "" "R|Detector to use. Some of these have configurable settings in '/config/" "extract.ini' or 'Settings > Configure Extract 'Plugins':\n" @@ -100,7 +100,7 @@ msgid "" "intensive." msgstr "" -#: lib/cli/args.py:413 +#: lib/cli/args.py:412 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " @@ -108,7 +108,7 @@ msgid "" "L|fan: Best aligner. Fast on GPU, slow on CPU." msgstr "" -#: lib/cli/args.py:425 +#: lib/cli/args.py:424 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -143,7 +143,7 @@ msgid "" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" msgstr "" -#: lib/cli/args.py:464 +#: lib/cli/args.py:463 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -156,7 +156,7 @@ msgid "" "L|mean: Normalize the face colors to the mean." msgstr "" -#: lib/cli/args.py:482 +#: lib/cli/args.py:481 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -166,14 +166,14 @@ msgid "" "occur but the longer extraction will take." msgstr "" -#: lib/cli/args.py:494 +#: lib/cli/args.py:493 msgid "" "Re-feed the initially found aligned face through the aligner. Can help " "produce better alignments for faces that are rotated beyond 45 degrees in " "the frame or are at extreme angles. Slows down extraction." msgstr "" -#: lib/cli/args.py:503 +#: lib/cli/args.py:502 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -181,25 +181,25 @@ msgid "" "exactly what angles to check." msgstr "" -#: lib/cli/args.py:512 +#: lib/cli/args.py:511 msgid "" "Obtain and store face identity encodings from VGGFace2. Slows down extract a " "little, but will save time if using 'sort by face'" msgstr "" -#: lib/cli/args.py:522 lib/cli/args.py:532 lib/cli/args.py:544 -#: lib/cli/args.py:557 lib/cli/args.py:798 lib/cli/args.py:812 -#: lib/cli/args.py:825 lib/cli/args.py:839 +#: lib/cli/args.py:521 lib/cli/args.py:531 lib/cli/args.py:543 +#: lib/cli/args.py:556 lib/cli/args.py:797 lib/cli/args.py:811 +#: lib/cli/args.py:824 lib/cli/args.py:838 msgid "Face Processing" msgstr "" -#: lib/cli/args.py:523 +#: lib/cli/args.py:522 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" msgstr "" -#: lib/cli/args.py:533 +#: lib/cli/args.py:532 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -207,7 +207,7 @@ msgid "" "or multiple image files, space separated, can be selected." msgstr "" -#: lib/cli/args.py:545 +#: lib/cli/args.py:544 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -215,32 +215,32 @@ msgid "" "image files, space separated, can be selected." msgstr "" -#: lib/cli/args.py:558 +#: lib/cli/args.py:557 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." msgstr "" -#: lib/cli/args.py:567 lib/cli/args.py:579 lib/cli/args.py:591 -#: lib/cli/args.py:603 +#: lib/cli/args.py:566 lib/cli/args.py:578 lib/cli/args.py:590 +#: lib/cli/args.py:602 msgid "output" msgstr "" -#: lib/cli/args.py:568 +#: lib/cli/args.py:567 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" "res models." msgstr "" -#: lib/cli/args.py:580 +#: lib/cli/args.py:579 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " "of 10 will extract faces from every 10th frame." msgstr "" -#: lib/cli/args.py:592 +#: lib/cli/args.py:591 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -250,57 +250,57 @@ msgid "" "turn off" msgstr "" -#: lib/cli/args.py:604 +#: lib/cli/args.py:603 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" -#: lib/cli/args.py:610 lib/cli/args.py:619 lib/cli/args.py:627 -#: lib/cli/args.py:634 lib/cli/args.py:852 lib/cli/args.py:863 -#: lib/cli/args.py:871 lib/cli/args.py:890 lib/cli/args.py:896 +#: lib/cli/args.py:609 lib/cli/args.py:618 lib/cli/args.py:626 +#: lib/cli/args.py:633 lib/cli/args.py:851 lib/cli/args.py:862 +#: lib/cli/args.py:870 lib/cli/args.py:889 lib/cli/args.py:895 msgid "settings" msgstr "" -#: lib/cli/args.py:611 +#: lib/cli/args.py:610 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the same time. " "Useful if VRAM is at a premium." msgstr "" -#: lib/cli/args.py:620 +#: lib/cli/args.py:619 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" msgstr "" -#: lib/cli/args.py:628 +#: lib/cli/args.py:627 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" -#: lib/cli/args.py:635 +#: lib/cli/args.py:634 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" -#: lib/cli/args.py:657 +#: lib/cli/args.py:656 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:678 +#: lib/cli/args.py:677 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " "audio)." msgstr "" -#: lib/cli/args.py:687 +#: lib/cli/args.py:686 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." msgstr "" -#: lib/cli/args.py:697 +#: lib/cli/args.py:696 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -321,7 +321,7 @@ msgid "" "L|none: Don't perform color adjustment." msgstr "" -#: lib/cli/args.py:724 +#: lib/cli/args.py:723 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -358,7 +358,7 @@ msgid "" "will use the mask that was created by the trained model." msgstr "" -#: lib/cli/args.py:762 +#: lib/cli/args.py:761 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -373,18 +373,18 @@ msgid "" "more formats." msgstr "" -#: lib/cli/args.py:781 lib/cli/args.py:788 lib/cli/args.py:882 +#: lib/cli/args.py:780 lib/cli/args.py:787 lib/cli/args.py:881 msgid "Frame Processing" msgstr "" -#: lib/cli/args.py:782 +#: lib/cli/args.py:781 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " "at source dimensions. 50%% at half size 200%% at double size" msgstr "" -#: lib/cli/args.py:789 +#: lib/cli/args.py:788 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -392,7 +392,7 @@ msgid "" "converting from images, then the filenames must end with the frame-number!" msgstr "" -#: lib/cli/args.py:799 +#: lib/cli/args.py:798 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -402,7 +402,7 @@ msgid "" "alignments file." msgstr "" -#: lib/cli/args.py:813 +#: lib/cli/args.py:812 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -411,7 +411,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args.py:826 +#: lib/cli/args.py:825 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -420,7 +420,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args.py:840 +#: lib/cli/args.py:839 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -428,7 +428,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args.py:853 +#: lib/cli/args.py:852 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -438,13 +438,13 @@ msgid "" "your system. If singleprocess is enabled this setting will be ignored." msgstr "" -#: lib/cli/args.py:864 +#: lib/cli/args.py:863 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" msgstr "" -#: lib/cli/args.py:872 +#: lib/cli/args.py:871 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -453,51 +453,51 @@ msgid "" "alignments file is found, this option will be ignored." msgstr "" -#: lib/cli/args.py:883 +#: lib/cli/args.py:882 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." msgstr "" -#: lib/cli/args.py:891 +#: lib/cli/args.py:890 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" -#: lib/cli/args.py:897 +#: lib/cli/args.py:896 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "" -#: lib/cli/args.py:913 +#: lib/cli/args.py:912 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" "Model plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:932 lib/cli/args.py:941 +#: lib/cli/args.py:931 lib/cli/args.py:940 msgid "faces" msgstr "" -#: lib/cli/args.py:933 +#: lib/cli/args.py:932 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " "face B." msgstr "" -#: lib/cli/args.py:942 +#: lib/cli/args.py:941 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " "A." msgstr "" -#: lib/cli/args.py:950 lib/cli/args.py:962 lib/cli/args.py:978 -#: lib/cli/args.py:1003 lib/cli/args.py:1013 +#: lib/cli/args.py:949 lib/cli/args.py:961 lib/cli/args.py:977 +#: lib/cli/args.py:1002 lib/cli/args.py:1012 msgid "model" msgstr "" -#: lib/cli/args.py:951 +#: lib/cli/args.py:950 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -506,7 +506,7 @@ msgid "" "the existing model." msgstr "" -#: lib/cli/args.py:963 +#: lib/cli/args.py:962 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -520,7 +520,7 @@ msgid "" "to train." msgstr "" -#: lib/cli/args.py:979 +#: lib/cli/args.py:978 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -543,7 +543,7 @@ msgid "" "susceptible to color differences." msgstr "" -#: lib/cli/args.py:1004 +#: lib/cli/args.py:1003 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -551,7 +551,7 @@ msgid "" "displayed." msgstr "" -#: lib/cli/args.py:1014 +#: lib/cli/args.py:1013 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -560,12 +560,12 @@ msgid "" "layers." msgstr "" -#: lib/cli/args.py:1027 lib/cli/args.py:1039 lib/cli/args.py:1050 -#: lib/cli/args.py:1061 lib/cli/args.py:1144 +#: lib/cli/args.py:1026 lib/cli/args.py:1038 lib/cli/args.py:1052 +#: lib/cli/args.py:1067 lib/cli/args.py:1075 msgid "training" msgstr "" -#: lib/cli/args.py:1028 +#: lib/cli/args.py:1027 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -573,7 +573,7 @@ msgid "" "number that you set here. Larger batches require more GPU RAM." msgstr "" -#: lib/cli/args.py:1040 +#: lib/cli/args.py:1039 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -582,13 +582,7 @@ msgid "" "can set that value here." msgstr "" -#: lib/cli/args.py:1051 -msgid "" -"[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " -"Mirrored Distrubution Strategy to train on multiple GPUs." -msgstr "" - -#: lib/cli/args.py:1062 +#: lib/cli/args.py:1053 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -601,25 +595,40 @@ msgid "" "batches distributed to each GPU at each iteration." msgstr "" -#: lib/cli/args.py:1079 lib/cli/args.py:1089 +#: lib/cli/args.py:1068 +msgid "" +"Disables TensorBoard logging. NB: Disabling logs means that you will not be " +"able to use the graph or analysis for this session in the GUI." +msgstr "" + +#: lib/cli/args.py:1076 +msgid "" +"Use the Learning Rate Finder to discover the optimal learning rate for " +"training. For new models, this will calculate the optimal learning rate for " +"the model. For existing models this will use the optimal learning rate that " +"was discovered when initializing the model. Setting this option will ignore " +"the manually configured learning rate (configurable in train settings)." +msgstr "" + +#: lib/cli/args.py:1089 lib/cli/args.py:1099 msgid "Saving" msgstr "" -#: lib/cli/args.py:1080 +#: lib/cli/args.py:1090 msgid "Sets the number of iterations between each model save." msgstr "" -#: lib/cli/args.py:1090 +#: lib/cli/args.py:1100 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." msgstr "" -#: lib/cli/args.py:1097 lib/cli/args.py:1108 lib/cli/args.py:1119 +#: lib/cli/args.py:1107 lib/cli/args.py:1118 lib/cli/args.py:1129 msgid "timelapse" msgstr "" -#: lib/cli/args.py:1098 +#: lib/cli/args.py:1108 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -628,7 +637,7 @@ msgid "" "timelapse-input-B parameter." msgstr "" -#: lib/cli/args.py:1109 +#: lib/cli/args.py:1119 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -637,7 +646,7 @@ msgid "" "timelapse-input-A parameter." msgstr "" -#: lib/cli/args.py:1120 +#: lib/cli/args.py:1130 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -645,53 +654,47 @@ msgid "" "model folder /timelapse/" msgstr "" -#: lib/cli/args.py:1129 lib/cli/args.py:1136 +#: lib/cli/args.py:1139 lib/cli/args.py:1146 msgid "preview" msgstr "" -#: lib/cli/args.py:1130 +#: lib/cli/args.py:1140 msgid "Show training preview output. in a separate window." msgstr "" -#: lib/cli/args.py:1137 +#: lib/cli/args.py:1147 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." msgstr "" -#: lib/cli/args.py:1145 -msgid "" -"Disables TensorBoard logging. NB: Disabling logs means that you will not be " -"able to use the graph or analysis for this session in the GUI." -msgstr "" - -#: lib/cli/args.py:1152 lib/cli/args.py:1161 lib/cli/args.py:1170 -#: lib/cli/args.py:1179 +#: lib/cli/args.py:1154 lib/cli/args.py:1163 lib/cli/args.py:1172 +#: lib/cli/args.py:1181 msgid "augmentation" msgstr "" -#: lib/cli/args.py:1153 +#: lib/cli/args.py:1155 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " "warping." msgstr "" -#: lib/cli/args.py:1162 +#: lib/cli/args.py:1164 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " "left off except for during 'fit training'." msgstr "" -#: lib/cli/args.py:1171 +#: lib/cli/args.py:1173 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " "Enable this option to disable color augmentation." msgstr "" -#: lib/cli/args.py:1180 +#: lib/cli/args.py:1182 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -699,6 +702,6 @@ msgid "" "likely to kill a model and lead to terrible results." msgstr "" -#: lib/cli/args.py:1205 +#: lib/cli/args.py:1207 msgid "Output to Shell console instead of GUI console" msgstr "" diff --git a/locales/plugins.train._config.pot b/locales/plugins.train._config.pot index 26558b6e37..29a8ac7178 100644 --- a/locales/plugins.train._config.pot +++ b/locales/plugins.train._config.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-06-25 13:39+0100\n" +"POT-Creation-Date: 2023-08-20 14:54+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -228,7 +228,7 @@ msgstr "" #: plugins/train/_config.py:198 plugins/train/_config.py:223 #: plugins/train/_config.py:238 plugins/train/_config.py:256 -#: plugins/train/_config.py:290 +#: plugins/train/_config.py:337 msgid "optimizer" msgstr "" @@ -294,7 +294,41 @@ msgid "" "optimizer weights will NOT be saved." msgstr "" -#: plugins/train/_config.py:283 +#: plugins/train/_config.py:285 plugins/train/_config.py:297 +#: plugins/train/_config.py:314 +msgid "Learning Rate Finder" +msgstr "" + +#: plugins/train/_config.py:287 +msgid "" +"The number of iterations to process to find the optimal learning rate. " +"Higher values will take longer, but will be more accurate." +msgstr "" + +#: plugins/train/_config.py:299 +msgid "" +"The operation mode for the learning rate finder. Only applicable to new " +"models. For existing models this will always default to 'set'.\n" +"\tset - Train with the discovered optimal learning rate.\n" +"\tgraph_and_set - Output a graph in the training folder showing the " +"discovered learning rates and train with the optimal learning rate.\n" +"\tgraph_and_exit - Output a graph in the training folder with the discovered " +"learning rates and exit." +msgstr "" + +#: plugins/train/_config.py:316 +msgid "" +"How aggressively to set the Learning Rate. More aggressive can learn faster, " +"but is more likely to lead to exploding gradients.\n" +"\tdefault - The default optimal learning rate. A safe choice for nearly all " +"use cases.\n" +"\taggressive - Set's a higher learning rate than the default. May learn " +"faster but with a higher chance of exploding gradients.\n" +"\textreme - The highest optimal learning rate. A much higher risk of " +"exploding gradients." +msgstr "" + +#: plugins/train/_config.py:330 msgid "" "Apply AutoClipping to the gradients. AutoClip analyzes the gradient weights " "and adjusts the normalization value dynamically to fit the data. Can help " @@ -303,12 +337,12 @@ msgid "" "arxiv.org/abs/2007.14469" msgstr "" -#: plugins/train/_config.py:296 plugins/train/_config.py:308 -#: plugins/train/_config.py:322 plugins/train/_config.py:339 +#: plugins/train/_config.py:343 plugins/train/_config.py:355 +#: plugins/train/_config.py:369 plugins/train/_config.py:386 msgid "network" msgstr "" -#: plugins/train/_config.py:298 +#: plugins/train/_config.py:345 msgid "" "Use reflection padding rather than zero padding with convolutions. Each " "convolution must pad the image boundaries to maintain the proper sizing. " @@ -317,7 +351,7 @@ msgid "" "\t http://www-cs.engr.ccny.cuny.edu/~wolberg/cs470/hw/hw2_pad.txt" msgstr "" -#: plugins/train/_config.py:311 +#: plugins/train/_config.py:358 msgid "" "Enable the Tensorflow GPU 'allow_growth' configuration option. This option " "prevents Tensorflow from allocating all of the GPU VRAM at launch but can " @@ -326,7 +360,7 @@ msgid "" "when commencing training." msgstr "" -#: plugins/train/_config.py:324 +#: plugins/train/_config.py:371 msgid "" "NVIDIA GPUs can run operations in float16 faster than in float32. Mixed " "precision allows you to use a mix of float16 with float32, to get the " @@ -342,7 +376,7 @@ msgid "" "the most benefit." msgstr "" -#: plugins/train/_config.py:341 +#: plugins/train/_config.py:388 msgid "" "If a 'NaN' is generated in the model, this means that the model has " "corrupted and the model is likely to start deteriorating from this point on. " @@ -351,11 +385,11 @@ msgid "" "rescue your model." msgstr "" -#: plugins/train/_config.py:354 +#: plugins/train/_config.py:401 msgid "convert" msgstr "" -#: plugins/train/_config.py:356 +#: plugins/train/_config.py:403 msgid "" "[GPU Only]. The number of faces to feed through the model at once when " "running the Convert process.\n" @@ -365,27 +399,27 @@ msgid "" "size." msgstr "" -#: plugins/train/_config.py:375 +#: plugins/train/_config.py:422 msgid "" "Loss configuration options\n" "Loss is the mechanism by which a Neural Network judges how well it thinks " "that it is recreating a face." msgstr "" -#: plugins/train/_config.py:382 plugins/train/_config.py:394 -#: plugins/train/_config.py:407 plugins/train/_config.py:427 -#: plugins/train/_config.py:439 plugins/train/_config.py:459 -#: plugins/train/_config.py:471 plugins/train/_config.py:491 -#: plugins/train/_config.py:507 plugins/train/_config.py:523 -#: plugins/train/_config.py:540 +#: plugins/train/_config.py:429 plugins/train/_config.py:441 +#: plugins/train/_config.py:454 plugins/train/_config.py:474 +#: plugins/train/_config.py:486 plugins/train/_config.py:506 +#: plugins/train/_config.py:518 plugins/train/_config.py:538 +#: plugins/train/_config.py:554 plugins/train/_config.py:570 +#: plugins/train/_config.py:587 msgid "loss" msgstr "" -#: plugins/train/_config.py:386 +#: plugins/train/_config.py:433 msgid "The loss function to use." msgstr "" -#: plugins/train/_config.py:398 +#: plugins/train/_config.py:445 msgid "" "The second loss function to use. If using a structural based loss (such as " "SSIM, MS-SSIM or GMSD) it is common to add an L1 regularization(MAE) or L2 " @@ -393,7 +427,7 @@ msgid "" "function with the loss_weight_2 option." msgstr "" -#: plugins/train/_config.py:413 +#: plugins/train/_config.py:460 msgid "" "The amount of weight to apply to the second loss function.\n" "\n" @@ -411,13 +445,13 @@ msgid "" "\t 0 - Disables the second loss function altogether." msgstr "" -#: plugins/train/_config.py:431 +#: plugins/train/_config.py:478 msgid "" "The third loss function to use. You can adjust the weighting of this loss " "function with the loss_weight_3 option." msgstr "" -#: plugins/train/_config.py:445 +#: plugins/train/_config.py:492 msgid "" "The amount of weight to apply to the third loss function.\n" "\n" @@ -435,13 +469,13 @@ msgid "" "\t 0 - Disables the third loss function altogether." msgstr "" -#: plugins/train/_config.py:463 +#: plugins/train/_config.py:510 msgid "" "The fourth loss function to use. You can adjust the weighting of this loss " "function with the loss_weight_3 option." msgstr "" -#: plugins/train/_config.py:477 +#: plugins/train/_config.py:524 msgid "" "The amount of weight to apply to the fourth loss function.\n" "\n" @@ -459,7 +493,7 @@ msgid "" "\t 0 - Disables the fourth loss function altogether." msgstr "" -#: plugins/train/_config.py:496 +#: plugins/train/_config.py:543 msgid "" "The loss function to use when learning a mask.\n" "\t MAE - Mean absolute error will guide reconstructions of each pixel " @@ -471,7 +505,7 @@ msgid "" "susceptible to outliers and typically produces slightly blurrier results." msgstr "" -#: plugins/train/_config.py:513 +#: plugins/train/_config.py:560 msgid "" "The amount of priority to give to the eyes.\n" "\n" @@ -484,7 +518,7 @@ msgid "" "NB: Penalized Mask Loss must be enable to use this option." msgstr "" -#: plugins/train/_config.py:529 +#: plugins/train/_config.py:576 msgid "" "The amount of priority to give to the mouth.\n" "\n" @@ -497,7 +531,7 @@ msgid "" "NB: Penalized Mask Loss must be enable to use this option." msgstr "" -#: plugins/train/_config.py:542 +#: plugins/train/_config.py:589 msgid "" "Image loss function is weighted by mask presence. For areas of the image " "without the facial mask, reconstruction errors will be ignored while the " @@ -505,12 +539,12 @@ msgid "" "attention on the core face area." msgstr "" -#: plugins/train/_config.py:553 plugins/train/_config.py:595 -#: plugins/train/_config.py:609 plugins/train/_config.py:618 +#: plugins/train/_config.py:600 plugins/train/_config.py:642 +#: plugins/train/_config.py:656 plugins/train/_config.py:665 msgid "mask" msgstr "" -#: plugins/train/_config.py:556 +#: plugins/train/_config.py:603 msgid "" "The mask to be used for training. If you have selected 'Learn Mask' or " "'Penalized Mask Loss' you must select a value other than 'none'. The " @@ -548,7 +582,7 @@ msgid "" "performance." msgstr "" -#: plugins/train/_config.py:597 +#: plugins/train/_config.py:644 msgid "" "Apply gaussian blur to the mask input. This has the effect of smoothing the " "edges of the mask, which can help with poorly calculated masks and give less " @@ -558,13 +592,13 @@ msgid "" "number." msgstr "" -#: plugins/train/_config.py:611 +#: plugins/train/_config.py:658 msgid "" "Sets pixels that are near white to white and near black to black. Set to 0 " "for off." msgstr "" -#: plugins/train/_config.py:620 +#: plugins/train/_config.py:667 msgid "" "Dedicate a portion of the model to learning how to duplicate the input mask. " "Increases VRAM usage in exchange for learning a quick ability to try to " diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo index d62cd7215722e3e80f47f65598ae00112b37d7fc..21ee85e78e0731ef21e3e64df6394e3274413bfa 100755 GIT binary patch delta 1899 zcmaKqeN0t#7{|Yt*CLD(Fr>|XSxZe|z=}27s^x-GWQ7vgMt0<0;mpgq?A}WtL>Dgj z5`w75mnG&OwQ9AsE?!Yy;!;trKU_P9mQ%-SmgQKP(a0C7)%SM}6c%gj^7%c_^LxI} z%kP}I96j)BbjTSM@Hzl(9k75rUjiHfaP3*3nmkwvRFJV{KohBwiR48xmYkgC6d6Yw z>w$P8p1g@nB5x+srtQV#X4Y9w#zj;AIRsD~#iHdvHv;jz%7y%BEE9p<)2;`B^ zvm3gw-wBK`?U*hk^U=W5*fW2I7=qG?f4~5_bBt@HC9>6LqQF& zgo)i=Acgh_IYyrG+XZ^-fIk>d-ohjb-QK_nIl;n4=FwjKGSJWXD(XGSyqIRY@R}g- z2;=3gz`Nw(HcrlX!8V&kfgOO!#FBQ3FtB7N&`7T81U{wzWEYFj?$`}fk#Fq<+9{N@ zA7IZobO3mqyyYOkR1}f7lIO@%yf;oRqT~3hz$di7e4AU0Vct8yYTAM8?*Ty`o_HU4 zg@MmPz}-wJ?gi>Op?`$W!$-Yz5_pR7j?aPLXrCSh{$zag95=>-qhA3D)VWADkiUFw ze>2tR0S=8HNZDY`Hvosn@8ll&%B|npj?@c$F9r^i@hqf&2Rz0C-Q)bOC^+^b8zl37 zvY+I`%RnyUize*NJu?YpGVZ6yS?aug6?lz&=^BtqfBJQP6*-@5A#Wr1k#fQ}r}XyNdeN-QK$I+p26i5byY4+uv5RPxYY(#3shRoOjld$@>_+ho~5Wqvs+gyHILWs zyIRp|6}NkfsPJf;)XG{fRd3wK(wT^|(d}X}dpW{qi~E*kp;YnK$bnQ}xPDzAD%?~W z>zMaYW^T5~d0@qZdCRkN!d+!)j@aA;7`yd(-FJjX*82|V7mm-@YkE`e6UML+G5X9F zW55Ur{)?EMW}DeY*EXZi=rxASpb^?TaaZP$5i$v_cSJ_n_a>;_KoVnjyFprAs?Y~kN_ zY4RL2JLamnQ;N>fu--D6dgn1V#`3djW(i}T<Vf$bROL) delta 1308 zcma))eQ4Bm9LL|c?QS||hUT^;#xD_PxPvazmC*w=D)E8H&9>p?PWRJYZg=zURz7yy zY|56o>S_g1vjUM|+v#nfTV60r3M#&b5RHnm5)`rZU`gxob@%g+=#S{H*XQB;{ycoY z-?5+bKb^`?Hs!@jTvDh;dQDPyt<(w^u9G&w=V3Se1@3{nACZdTG<3tlI)}5M2Y)Us zg!ADHxB$+CORoA$;a2+H1wHxFTlI?T;XLW+dg&Ds1sj|OhZ;pj8ie-|_Xeb8aN9-+ zQ%9KY6uP1&$xr;_&C&$%%4X?$I0cJ|&kRblVaa3CBwV!B$!E8`8~yxNx~#=$2ul?R zhqg)A;eP@@hZmzxgZCeo{vf|-I~!7H;SM%rgUX%g@RxEuC&>GslpaJ^O5JSZ#!j|{ zZCz{&hxf8M`39~v%Rqd;ltI|s!*vl@-Ye~b{sYo`#3v6jG5$awXW)tFq+SXwc!8qi z2Va!#h2{Oy(=Y(9ftTQiockS?6Zqg|>0SKuhowgg&>fLh;>U{zr7n!RA*qMNXT#Es z2<#DQI~(qw;BRCf^|Z8t{J=-jZ}=CO`Y-b5Cb==v=f2=Ms51?BLid->pXvBYdW!s? zFgxJhv(i2|`)i3^;_Ao91{QzAM}xz#kVc2UmF}Uz(0Qqzf>Re65U#l7Jjt2woE!6f z&;KA^^CP!~zUwFHRk;4LbSLp?I1k=N@e<-|t~j65hQ+Gn_hS^1*agerJ~#&sz-!?# znBDNyl|--me%G2r#=XI{I^py1GZXEeJ6tOhM?Gg;t5V&C7gnXt*7oGZtL?Uky(tj0 zn=G$Y8?~(pZ*`^R^)>~gu}E-Zdn_0Zc`-S$Wk-b-3`Jvhpvj**8f&(#8aosXM>e;F zpRiU3Bav`~Le*JqyQ8(%Ue#K$u!R{6S>ce?+TIciwzb$+!2=J~MpIkrX1Hc8^ZIVF zd{xVDy?x2eRjJ+eWiGd`Naoo@cZ z#xn=ZNvD}*4$-31yptP!Bscyj$>Vb~egEiplVwKCcw%^B-VG;B(&7MfQ!AaTHO6$^ M7MGSP`}Gai-@vYV5C8xG diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.po b/locales/ru/LC_MESSAGES/lib.cli.args.po index f831c38f29..6c13783cbb 100755 --- a/locales/ru/LC_MESSAGES/lib.cli.args.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-11-20 01:34+0000\n" -"PO-Revision-Date: 2023-06-12 17:49+0700\n" +"POT-Creation-Date: 2023-08-20 14:52+0100\n" +"PO-Revision-Date: 2023-08-20 14:56+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -17,14 +17,14 @@ 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.3.1\n" +"X-Generator: Poedit 3.3.2\n" -#: lib/cli/args.py:193 lib/cli/args.py:203 lib/cli/args.py:211 -#: lib/cli/args.py:221 +#: lib/cli/args.py:192 lib/cli/args.py:202 lib/cli/args.py:210 +#: lib/cli/args.py:220 msgid "Global Options" msgstr "Глобальные Настройки" -#: lib/cli/args.py:194 +#: lib/cli/args.py:193 msgid "" "R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " "to any GPU(s) that you do not wish to be made available to Faceswap. " @@ -36,14 +36,14 @@ msgstr "" "Если выбрать здесь все GPU, Faceswap перейдет в режим CPU.\n" "L|{}" -#: lib/cli/args.py:204 +#: lib/cli/args.py:203 msgid "" "Optionally overide the saved config with the path to a custom config file." msgstr "" "Опционально переопределите сохраненную конфигурацию, указав путь к " "пользовательскому файлу конфигурации." -#: lib/cli/args.py:212 +#: lib/cli/args.py:211 msgid "" "Log level. Stick with INFO or VERBOSE unless you need to file an error " "report. Be careful with TRACE as it will generate a lot of data" @@ -52,18 +52,18 @@ msgstr "" "нужно отправить отчет об ошибке. Будьте осторожны с TRACE, поскольку он " "генерирует много данных" -#: lib/cli/args.py:222 +#: lib/cli/args.py:221 msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "" "Путь для хранения файла журнала. Оставьте пустым, чтобы хранить в папке " "faceswap" -#: lib/cli/args.py:320 lib/cli/args.py:329 lib/cli/args.py:337 -#: lib/cli/args.py:386 lib/cli/args.py:677 lib/cli/args.py:686 +#: lib/cli/args.py:319 lib/cli/args.py:328 lib/cli/args.py:336 +#: lib/cli/args.py:385 lib/cli/args.py:676 lib/cli/args.py:685 msgid "Data" msgstr "Данные" -#: lib/cli/args.py:321 +#: lib/cli/args.py:320 msgid "" "Input directory or video. Either a directory containing the image files you " "wish to process or path to a video file. NB: This should be the source video/" @@ -73,11 +73,11 @@ msgstr "" "вы хотите обработать, либо путь к видеофайлу. ПРИМЕЧАНИЕ: Это должно быть " "исходное видео/кадры, а не исходные лица." -#: lib/cli/args.py:330 +#: lib/cli/args.py:329 msgid "Output directory. This is where the converted files will be saved." msgstr "Выходная папка. Здесь будут сохранены преобразованные файлы." -#: lib/cli/args.py:338 +#: lib/cli/args.py:337 msgid "" "Optional path to an alignments file. Leave blank if the alignments file is " "at the default location." @@ -85,7 +85,7 @@ msgstr "" "Необязательный путь к файлу выравниваний. Оставьте пустым, если файл " "выравнивания находится в месте по умолчанию." -#: lib/cli/args.py:361 +#: lib/cli/args.py:360 msgid "" "Extract faces from image or video sources.\n" "Extraction plugins can be configured in the 'Settings' Menu" @@ -93,7 +93,7 @@ msgstr "" "Извлечение лиц из источников изображений или видео.\n" "Плагины извлечения можно настроить в меню \"Настройки\"" -#: lib/cli/args.py:387 +#: lib/cli/args.py:386 msgid "" "R|If selected then the input_dir should be a parent folder containing " "multiple videos and/or folders of images you wish to extract from. The faces " @@ -103,14 +103,14 @@ msgstr "" "несколько видео и/или папок с изображениями, из которых вы хотите извлечь " "изображение. Лица будут выведены в отдельные вложенные папки в output_dir." -#: lib/cli/args.py:396 lib/cli/args.py:412 lib/cli/args.py:424 -#: lib/cli/args.py:463 lib/cli/args.py:481 lib/cli/args.py:493 -#: lib/cli/args.py:502 lib/cli/args.py:511 lib/cli/args.py:696 -#: lib/cli/args.py:723 lib/cli/args.py:761 +#: lib/cli/args.py:395 lib/cli/args.py:411 lib/cli/args.py:423 +#: lib/cli/args.py:462 lib/cli/args.py:480 lib/cli/args.py:492 +#: lib/cli/args.py:501 lib/cli/args.py:510 lib/cli/args.py:695 +#: lib/cli/args.py:722 lib/cli/args.py:760 msgid "Plugins" msgstr "Плагины" -#: lib/cli/args.py:397 +#: lib/cli/args.py:396 msgid "" "R|Detector to use. Some of these have configurable settings in '/config/" "extract.ini' or 'Settings > Configure Extract 'Plugins':\n" @@ -134,7 +134,7 @@ msgstr "" "обнаружить больше лиц и меньше ложных срабатываний, чем другие детекторы на " "GPU, но требует гораздо больше ресурсов." -#: lib/cli/args.py:413 +#: lib/cli/args.py:412 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " @@ -147,7 +147,7 @@ msgstr "" "GPU и важно время.\n" "L|fan: Лучший выравниватель. Быстрый на GPU, медленный на CPU." -#: lib/cli/args.py:425 +#: lib/cli/args.py:424 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -214,7 +214,7 @@ msgstr "" "и маска расширяется вверх на лоб.\n" "(например: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -#: lib/cli/args.py:464 +#: lib/cli/args.py:463 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -236,7 +236,7 @@ msgstr "" "L|hist: Уравнять гистограммы в каналах RGB.\n" "L|mean: Нормализовать цвета лица к среднему значению." -#: lib/cli/args.py:482 +#: lib/cli/args.py:481 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -253,7 +253,7 @@ msgstr "" "в выравниватель, тем меньше микро-дрожание, но тем больше времени займет " "извлечение." -#: lib/cli/args.py:494 +#: lib/cli/args.py:493 msgid "" "Re-feed the initially found aligned face through the aligner. Can help " "produce better alignments for faces that are rotated beyond 45 degrees in " @@ -264,7 +264,7 @@ msgstr "" "в кадре более чем на 45 градусов или расположенных под экстремальными " "углами. Замедляет извлечение." -#: lib/cli/args.py:503 +#: lib/cli/args.py:502 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -276,7 +276,7 @@ msgstr "" "число, чтобы использовать приращения этого размера до 360, или передайте " "список чисел, чтобы перечислить, какие именно углы нужно проверить." -#: lib/cli/args.py:512 +#: lib/cli/args.py:511 msgid "" "Obtain and store face identity encodings from VGGFace2. Slows down extract a " "little, but will save time if using 'sort by face'" @@ -285,13 +285,13 @@ msgstr "" "замедляет извлечение, но экономит время при использовании \"сортировки по " "лицам\"." -#: lib/cli/args.py:522 lib/cli/args.py:532 lib/cli/args.py:544 -#: lib/cli/args.py:557 lib/cli/args.py:798 lib/cli/args.py:812 -#: lib/cli/args.py:825 lib/cli/args.py:839 +#: lib/cli/args.py:521 lib/cli/args.py:531 lib/cli/args.py:543 +#: lib/cli/args.py:556 lib/cli/args.py:797 lib/cli/args.py:811 +#: lib/cli/args.py:824 lib/cli/args.py:838 msgid "Face Processing" msgstr "Обработка лиц" -#: lib/cli/args.py:523 +#: lib/cli/args.py:522 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -299,7 +299,7 @@ msgstr "" "Отфильтровывает лица, обнаруженные ниже этого размера. Длина в пикселях по " "диагонали ограничивающего поля. Установите значение 0, чтобы выключить" -#: lib/cli/args.py:533 +#: lib/cli/args.py:532 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -312,7 +312,7 @@ msgstr "" "необходимые изображения, или несколько файлов изображений, разделенных " "пробелами." -#: lib/cli/args.py:545 +#: lib/cli/args.py:544 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -324,7 +324,7 @@ msgstr "" "углами и в разных условиях. Можно выбрать папку, содержащую необходимые " "изображения, или несколько файлов изображений, разделенных пробелами." -#: lib/cli/args.py:558 +#: lib/cli/args.py:557 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." @@ -333,12 +333,12 @@ msgstr "" "положительного распознавания лица. Более высокие значения являются более " "строгими." -#: lib/cli/args.py:567 lib/cli/args.py:579 lib/cli/args.py:591 -#: lib/cli/args.py:603 +#: lib/cli/args.py:566 lib/cli/args.py:578 lib/cli/args.py:590 +#: lib/cli/args.py:602 msgid "output" msgstr "вывод" -#: lib/cli/args.py:568 +#: lib/cli/args.py:567 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -348,7 +348,7 @@ msgstr "" "собираетесь тренировать, поддерживает требуемый размер. Это необходимо " "изменить только для моделей высокого разрешения." -#: lib/cli/args.py:580 +#: lib/cli/args.py:579 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -358,7 +358,7 @@ msgstr "" "лиц. Например, значение 1 будет извлекать лица из каждого кадра, значение 10 " "будет извлекать лица из каждого 10-го кадра." -#: lib/cli/args.py:592 +#: lib/cli/args.py:591 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -374,17 +374,17 @@ msgstr "" "ПРЕДУПРЕЖДЕНИЕ: Не прерывайте работу скрипта при записи файла, так как он " "может быть поврежден. Установите значение 0, чтобы отключить" -#: lib/cli/args.py:604 +#: lib/cli/args.py:603 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "Нарисуйте ориентиры на выходящих гранях для отладки." -#: lib/cli/args.py:610 lib/cli/args.py:619 lib/cli/args.py:627 -#: lib/cli/args.py:634 lib/cli/args.py:852 lib/cli/args.py:863 -#: lib/cli/args.py:871 lib/cli/args.py:890 lib/cli/args.py:896 +#: lib/cli/args.py:609 lib/cli/args.py:618 lib/cli/args.py:626 +#: lib/cli/args.py:633 lib/cli/args.py:851 lib/cli/args.py:862 +#: lib/cli/args.py:870 lib/cli/args.py:889 lib/cli/args.py:895 msgid "settings" msgstr "настройки" -#: lib/cli/args.py:611 +#: lib/cli/args.py:610 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the same time. " @@ -394,7 +394,7 @@ msgstr "" "выполняться отдельно (одна за другой), а не одновременно. Полезно, если " "память VRAM ограничена." -#: lib/cli/args.py:620 +#: lib/cli/args.py:619 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -402,17 +402,17 @@ msgstr "" "Пропускает кадры, которые уже были извлечены и существуют в файле " "выравнивания" -#: lib/cli/args.py:628 +#: lib/cli/args.py:627 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" "Пропустить кадры, в которых уже есть обнаруженные лица в файле выравнивания" -#: lib/cli/args.py:635 +#: lib/cli/args.py:634 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "Не сохранять обнаруженные лица на диск. Просто создать файл выравнивания" -#: lib/cli/args.py:657 +#: lib/cli/args.py:656 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -420,7 +420,7 @@ msgstr "" "Поменять исходные лица в исходном видео/изображении на ваши конечные лица.\n" "Плагины конвертирования можно настроить в меню \"Настройки\"" -#: lib/cli/args.py:678 +#: lib/cli/args.py:677 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -430,7 +430,7 @@ msgstr "" "исходное видео, из которого были извлечены исходные кадры (для извлечения " "кадров в секунду и звука)." -#: lib/cli/args.py:687 +#: lib/cli/args.py:686 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -438,7 +438,7 @@ msgstr "" "Папка модели. Папка, содержащая обученную модель, которую вы хотите " "использовать для преобразования." -#: lib/cli/args.py:697 +#: lib/cli/args.py:696 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -478,7 +478,7 @@ msgstr "" "Обычно дает не очень удовлетворительные результаты.\n" "L|none: Не выполнять коррекцию цвета." -#: lib/cli/args.py:724 +#: lib/cli/args.py:723 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -549,7 +549,7 @@ msgstr "" "L|predicted: Если во время обучения была включена опция 'Изучить Маску', то " "будет использоваться маска, созданная обученной моделью." -#: lib/cli/args.py:762 +#: lib/cli/args.py:761 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -575,11 +575,11 @@ msgstr "" "L|pillow: [изображения] Медленнее, чем opencv, но имеет больше опций и " "поддерживает больше форматов." -#: lib/cli/args.py:781 lib/cli/args.py:788 lib/cli/args.py:882 +#: lib/cli/args.py:780 lib/cli/args.py:787 lib/cli/args.py:881 msgid "Frame Processing" msgstr "Обработка лиц" -#: lib/cli/args.py:782 +#: lib/cli/args.py:781 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -589,7 +589,7 @@ msgstr "" "кадры в исходном размере. 50%% при половинном размере 200%% при двойном " "размере" -#: lib/cli/args.py:789 +#: lib/cli/args.py:788 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -602,7 +602,7 @@ msgstr "" "keep-unchanged). Примечание: Если вы конвертируете из изображений, то имена " "файлов должны заканчиваться номером кадра!" -#: lib/cli/args.py:799 +#: lib/cli/args.py:798 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -618,7 +618,7 @@ msgstr "" "Если оставить этот параметр пустым, будут преобразованы все лица, " "существующие в файле выравнивания." -#: lib/cli/args.py:813 +#: lib/cli/args.py:812 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -632,7 +632,7 @@ msgstr "" "разделенных пробелами. Примечание: Использование фильтра лиц значительно " "снизит скорость извлечения, а его точность не гарантируется." -#: lib/cli/args.py:826 +#: lib/cli/args.py:825 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -646,7 +646,7 @@ msgstr "" "Примечание: Использование фильтра лиц значительно снизит скорость " "извлечения, а его точность не гарантируется." -#: lib/cli/args.py:840 +#: lib/cli/args.py:839 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -658,7 +658,7 @@ msgstr "" "строгими. Примечание: Использование фильтра лиц значительно снизит скорость " "извлечения, а его точность не гарантируется." -#: lib/cli/args.py:853 +#: lib/cli/args.py:852 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -676,7 +676,7 @@ msgstr "" "процессов, чем доступно в вашей системе. Если включена однопоточная " "обработка, этот параметр будет проигнорирован." -#: lib/cli/args.py:864 +#: lib/cli/args.py:863 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -685,7 +685,7 @@ msgstr "" "загружается устаревшая модель или если в папке моделей имеется несколько " "моделей" -#: lib/cli/args.py:872 +#: lib/cli/args.py:871 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -700,7 +700,7 @@ msgstr "" "приведет к некачественным результатам. Если файл выравнивания найден, этот " "параметр будет проигнорирован." -#: lib/cli/args.py:883 +#: lib/cli/args.py:882 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -708,17 +708,17 @@ msgstr "" "При использовании с --frame-ranges выводит неизмененные кадры, которые не " "были обработаны, вместо того, чтобы отбрасывать их." -#: lib/cli/args.py:891 +#: lib/cli/args.py:890 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Поменять модель местами. Вместо преобразования из A -> B, преобразуется B -> " "A" -#: lib/cli/args.py:897 +#: lib/cli/args.py:896 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Отключение многопоточной обработки. Медленнее, но менее ресурсоемко." -#: lib/cli/args.py:913 +#: lib/cli/args.py:912 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -728,11 +728,11 @@ msgstr "" "Обучение моделей может занять много времени. От 24 часов до недели.\n" "Плагины для моделей можно настроить в меню \"Настройки\"" -#: lib/cli/args.py:932 lib/cli/args.py:941 +#: lib/cli/args.py:931 lib/cli/args.py:940 msgid "faces" msgstr "лица" -#: lib/cli/args.py:933 +#: lib/cli/args.py:932 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -741,7 +741,7 @@ msgstr "" "Входная папка. Папка, содержащая обучающие изображения для лица A. Это " "исходное лицо, т.е. лицо, которое вы хотите удалить и заменить лицом B." -#: lib/cli/args.py:942 +#: lib/cli/args.py:941 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -750,12 +750,12 @@ msgstr "" "Входная папка. Папка, содержащая обучающие изображения для лица B. Это " "подменное лицо, т.е. лицо, которое вы хотите поместить на голову человека A." -#: lib/cli/args.py:950 lib/cli/args.py:962 lib/cli/args.py:978 -#: lib/cli/args.py:1003 lib/cli/args.py:1013 +#: lib/cli/args.py:949 lib/cli/args.py:961 lib/cli/args.py:977 +#: lib/cli/args.py:1002 lib/cli/args.py:1012 msgid "model" msgstr "модель" -#: lib/cli/args.py:951 +#: lib/cli/args.py:950 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -769,7 +769,7 @@ msgstr "" "создана). Если вы продолжаете обучение существующей модели, укажите " "местоположение существующей модели." -#: lib/cli/args.py:963 +#: lib/cli/args.py:962 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -793,7 +793,7 @@ msgstr "" "Примечание: Веса могут быть загружены только из моделей того же плагина, " "который вы собираетесь обучать." -#: lib/cli/args.py:979 +#: lib/cli/args.py:978 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -838,7 +838,7 @@ msgstr "" "ресурсам (вам потребуется GPU с достаточным количеством VRAM). Хороша для " "детализации, но более восприимчива к цветовым различиям." -#: lib/cli/args.py:1004 +#: lib/cli/args.py:1003 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -849,7 +849,7 @@ msgstr "" "сводка сохраненной модели. В противном случае отображается сводка модели, " "которая будет создана выбранным плагином и настройками конфигурации." -#: lib/cli/args.py:1014 +#: lib/cli/args.py:1013 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -863,12 +863,12 @@ msgstr "" "замораживание кодера, но некоторые модели могут иметь опции конфигурации для " "замораживания других слоев." -#: lib/cli/args.py:1027 lib/cli/args.py:1039 lib/cli/args.py:1050 -#: lib/cli/args.py:1061 lib/cli/args.py:1144 +#: lib/cli/args.py:1026 lib/cli/args.py:1038 lib/cli/args.py:1052 +#: lib/cli/args.py:1067 lib/cli/args.py:1075 msgid "training" msgstr "тренировка" -#: lib/cli/args.py:1028 +#: lib/cli/args.py:1027 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -881,7 +881,7 @@ msgstr "" "времени будет вдвое больше, чем заданное здесь. Большие партии требуют " "больше оперативной памяти GPU." -#: lib/cli/args.py:1040 +#: lib/cli/args.py:1039 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -896,16 +896,7 @@ msgstr "" "вы хотите, чтобы модель автоматически останавливалась при определенном " "количестве итераций, вы можете задать это значение здесь." -#: lib/cli/args.py:1051 -msgid "" -"[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " -"Mirrored Distrubution Strategy to train on multiple GPUs." -msgstr "" -"[Устарело - Используйте '-D, --distribution-strategy' вместо этого] " -"Используйте стратегию Tensorflow Mirrored Distrubution Strategy(Стратегия " -"Зеркального Распределения Tensorflow) для обучения на нескольких GPU." - -#: lib/cli/args.py:1062 +#: lib/cli/args.py:1053 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -928,15 +919,39 @@ msgstr "" "локальных GPU. Копия модели и все переменные загружаются на каждый GPU с " "распределением партий на каждый GPU на каждой итерации." -#: lib/cli/args.py:1079 lib/cli/args.py:1089 +#: lib/cli/args.py:1068 +msgid "" +"Disables TensorBoard logging. NB: Disabling logs means that you will not be " +"able to use the graph or analysis for this session in the GUI." +msgstr "" +"Отключает ведение журналов TensorBoard. Примечание: Отключение ведения " +"журналов означает, что вы не сможете использовать график или анализ для этой " +"сессии в графическом интерфейсе." + +#: lib/cli/args.py:1076 +msgid "" +"Use the Learning Rate Finder to discover the optimal learning rate for " +"training. For new models, this will calculate the optimal learning rate for " +"the model. For existing models this will use the optimal learning rate that " +"was discovered when initializing the model. Setting this option will ignore " +"the manually configured learning rate (configurable in train settings)." +msgstr "" +"Используйте инструмент поиска коэффициента обучения, чтобы найти оптимальную " +"скорость обучения вашей модели. Для новых моделей это позволит рассчитать " +"оптимальный коэффициент обучения для модели. Для существующих моделей будет " +"использован оптимальный коэффициент обучения, найденный при инициализации " +"модели. Установка этой опции приведет к игнорированию вручную настроенного " +"коэффициента обучения (настраиваемого в параметрах обучения)." + +#: lib/cli/args.py:1089 lib/cli/args.py:1099 msgid "Saving" msgstr "Сохранение" -#: lib/cli/args.py:1080 +#: lib/cli/args.py:1090 msgid "Sets the number of iterations between each model save." msgstr "Устанавливает количество итераций между каждым сохранением модели." -#: lib/cli/args.py:1090 +#: lib/cli/args.py:1100 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -945,11 +960,11 @@ msgstr "" "Устанавливает количество итераций перед сохранением резервного снимка модели " "в текущем состоянии. Установите значение 0 для выключения." -#: lib/cli/args.py:1097 lib/cli/args.py:1108 lib/cli/args.py:1119 +#: lib/cli/args.py:1107 lib/cli/args.py:1118 lib/cli/args.py:1129 msgid "timelapse" msgstr "таймлапс" -#: lib/cli/args.py:1098 +#: lib/cli/args.py:1108 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -963,7 +978,7 @@ msgstr "" "создания timelapse. Вы также должны указать параметры --timelapse-output и --" "timelapse-input-B." -#: lib/cli/args.py:1109 +#: lib/cli/args.py:1119 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -977,7 +992,7 @@ msgstr "" "создания timelapse. Вы также должны указать параметры --timelapse-output и --" "timelapse-input-A." -#: lib/cli/args.py:1120 +#: lib/cli/args.py:1130 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -989,15 +1004,15 @@ msgstr "" "указаны входные папки, но нет выходной папки, то по умолчанию будет выбрана " "папка модели /timelapse/" -#: lib/cli/args.py:1129 lib/cli/args.py:1136 +#: lib/cli/args.py:1139 lib/cli/args.py:1146 msgid "preview" msgstr "предпросмотр" -#: lib/cli/args.py:1130 +#: lib/cli/args.py:1140 msgid "Show training preview output. in a separate window." msgstr "Показать вывод предварительного просмотра тренировки в отдельном окне." -#: lib/cli/args.py:1137 +#: lib/cli/args.py:1147 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -1005,21 +1020,12 @@ msgstr "" "Записывает результат обучения в файл. Изображение будет сохранено в корне " "папки Faceswap." -#: lib/cli/args.py:1145 -msgid "" -"Disables TensorBoard logging. NB: Disabling logs means that you will not be " -"able to use the graph or analysis for this session in the GUI." -msgstr "" -"Отключает ведение журналов TensorBoard. Примечание: Отключение ведения " -"журналов означает, что вы не сможете использовать график или анализ для этой " -"сессии в графическом интерфейсе." - -#: lib/cli/args.py:1152 lib/cli/args.py:1161 lib/cli/args.py:1170 -#: lib/cli/args.py:1179 +#: lib/cli/args.py:1154 lib/cli/args.py:1163 lib/cli/args.py:1172 +#: lib/cli/args.py:1181 msgid "augmentation" msgstr "аугментация" -#: lib/cli/args.py:1153 +#: lib/cli/args.py:1155 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -1029,7 +1035,7 @@ msgstr "" "набора лиц вместо случайного искажения лица. Это способ выполнения искажения " "от \"dfaker\" ." -#: lib/cli/args.py:1162 +#: lib/cli/args.py:1164 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -1039,7 +1045,7 @@ msgstr "" "горизонтали. Иногда желательно, чтобы этого не происходило. Как правило, это " "не нужно делать, за исключением случаев \"тренировки подгонки\"." -#: lib/cli/args.py:1171 +#: lib/cli/args.py:1173 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -1050,7 +1056,7 @@ msgstr "" "времени на обучение. Включите этот параметр для отключения цветовой " "аугментации." -#: lib/cli/args.py:1180 +#: lib/cli/args.py:1182 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -1062,6 +1068,14 @@ msgstr "" "больше деталей. Считайте это \"тонкой настройкой\". Включение этой опции в " "самом начале, скорее всего, погубит модель и приведет к ужасным результатам." -#: lib/cli/args.py:1205 +#: lib/cli/args.py:1207 msgid "Output to Shell console instead of GUI console" msgstr "Вывод в консоль Shell вместо консоли GUI" + +#~ msgid "" +#~ "[Deprecated - Use '-D, --distribution-strategy' instead] Use the " +#~ "Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." +#~ msgstr "" +#~ "[Устарело - Используйте '-D, --distribution-strategy' вместо этого] " +#~ "Используйте стратегию Tensorflow Mirrored Distrubution Strategy(Стратегия " +#~ "Зеркального Распределения Tensorflow) для обучения на нескольких GPU." diff --git a/locales/ru/LC_MESSAGES/plugins.train._config.mo b/locales/ru/LC_MESSAGES/plugins.train._config.mo index a709331fd7fae5284c882b07c5763d67ace196dc..0b922cd0d0d7543e45d7d687341a2114189dfb7a 100644 GIT binary patch delta 4273 zcmb`I3v5+)9mjvH9YPT*kHTOu{!CsQ^tvL*!)2@zyG4f`1E+3La(a7u@0H8F_1;@b zHSv^oJZ5>QcUu_SaK@++vU%6r(o*Pf&agN$caBqo%*Tvo(`D1dsEPCW`TftWv~Wq7 z@g%?VJ^$D5`ThM*aiHQUzry?Fw8{sC=VoLs@{e&Mo57z~@Zwo|wTKJe0B&II5ZDF2 zG+tzo`MW2GECH)0iqtc|8hjG$s1msaT>4ItC&1Wbk=w2ksi_uu4BelcDza*vc=God z7)3$dOpyg_jLZ`GBOC9$M&tnFTdx&ii~JDp?q&QNu#55f>qO3h-v!q*|HyoiJmZ&* z$Z^i67l<5V{@R5iOv%|rB1gd2z zR{H@m;E{@R8$`x)V9ISGpJ7AI?IJIra4+~z#vlE#$b8l}-yw36{n?Eo6^v^)iJ({N z?h>g4*Md{PTfk?)jivc&>Z$89K;1tZq)M^?^xooQ86TR;#apN$hW`zo2kSp362;Km zdqnt@&U^8k`E%giIMV$oc)|F*L-dR*n?*j2r6VzsKKOBU5~o=o%7{z`r?gQPaBe%C zfJgf~$p0h`uA>Z`m4+@R*>D_up7EO9M2rI!dqPFSdqtWUPyGTsK|!uZ#a4B5%U*nn962F|If((#ZN>!H3C21JV2x zgRl4^&#^x6Bf12Ao`-vDF&z0BBxFPUWs&PCQsXZ~{>(V@TM-B@u~#7;xCfjA9s-q| z$3Qwv{`Q(k6*!)-b>CEwhLSlT+k_?(_-fZ?fbT~1QU1yG_LEGMa0I58K1hUplw6Ql z-i6d4HzWEiL1rWK5Mq&8$TZ|8M4#ozjrL1qVF@RJTm_zGq|PlsG=fz+;GxUxa}DpM z$oOz*-3qNa zB8?^F&IzC5PbwQGcup*qakJUP4ma6ha%q!wb0!ye%^j|jNhMM-bC;8IBW7bdB0( z7N;$lGqq+5Hk;jWly1!>wmZqtfS$=%AL>okX>m<7o=!wv(~{1Z6dvHVlT4bnEGtgd zRkfr1tTmh6+>)$uOgs^byP2^%P0ouGh{jv8O{7l8n8<8k?TK9cf{rMM@F3lCnXufQ zxs1ErEk$JwXLF`(RLdi#-fVA+#z*|iB(mEsr@1(D^0SjCX+cawBGv3>iZ9N2VWF35 z+ulTExRam~PA-v7Wi{umnRJxW>SqhvZJ1@9h?v{M2i%0g-ttG>(Hixy*q*{iI6wo^1W+J9Br8R49O(vpFQ_@xcQf|9F znaoB^1BG>WCbBs_sOpl05QkdU0Vmn+bYw@$rDwe*aCQl0SL#BfjFU*Qmx9^4CbH4= z4maaAmoEV4U}S52tCMQp8fs{4%e6vgN2%HxH)vBuIn}E!wWO1z-elwHb{#??*mJ?k zn3y4+CVs3-|$Zi@AZfMr}N9&=6Ngz2ZH;8 z`~1OR4=ertaL{Eq`Bcyy?A0L)ha$%Rl4@hzUq)BZPsD)sEu#W}Q^>Vrs?+yiBy2YN^9~{gd{rG}Oz5YPZ ziPheqJ8w4Cth4vf$Tj32_0Mo-5FZLQ0mGqxWkbzB^ZaxN&rQ&6eLh9d1sn818+5-v zfLmv5LQ8sjQHQTVr}6vHf5=82>{XL^O@8}>-2}vvX0Sjsz4X35K~rvN~nx5|_qBiqCF+a!&Lx+_lWIwJb4NS*hoS z{6ZPmSS+fMvIPiFjA?{o`m^_J@Q!fo6k6#IHAsq3G02B9wqW~+JX|2h|Iq;@W#0ST z{D}i(=%jy|2Kefaud6NB60 zk!)A~_m~DT5>FT`N4R4O7*YGQJZLN9*`nK)v=@%kk<{>|NwO7k61dl?)eM_h=?>VT0>bKG8)2=z)s@;l9?N4Vlv`n&Q$fA=$KAdfC`N7|}Bv^at1q=eQ<#tG?4gPx0*V HoAdqyI!pvt delta 1314 zcmYk5ZA{fw7{Gs5#1NvJD5)rc2&M)KTu|skq_?COvlr=Qy|J0Cra8+mS>|ry+SpQS zU3Fx%)~YXPq5HR9K9p;@WLio^3dY)O&5~@b%{FW4{GEH5^Vy#BJI`}oo^zh_oPGT{ z(I+{npNq!sKxtB^tHUw4OLphjD1&3+2{|N_jG?rJeh zjvrYJ&pQ5N(mAegz1b`npW;?1bHc=1;WHN`Zi5`{KbM00@b{fy_W4&{B`?S?W#O`{ z|L7$@@J>@*&?* zd)K3|$`c%DhmU;s`72GO{$E#l2-&p8EXXBm;REaAW3O4@`jyYY7X8n>0EgWF{EJ@D zczqkQ>pN1u{|X()Ho-g>HgAS8E_})dyTt{oUvZJ#Cx4f{Ti^%TwN-W9)7uPafZW&M zRqg0?XwXh>hgov{PPpBJzuo2G~XL@IXz@y%DfX$ zA}7dDYt<<`SL)dC6XeTI878KsAAxS^yv*0Ekk;5GsT%Dy$hnui*-nipzs%=dn5X*L zGT$`}g<46?2tCEiFj@68WMqnMsX9#!2aBr~JlCsb>U=d6+108EV7A(%`e`ynIza{r zw+)g|!$lVaLkT}cd5j|4iJ5-33F#_O7pi7HBOxO`Uq*bTjMR=YpiV7TL&mDrAT!m< zs490!UNklSMPYPi`d5|Fa55dwUr None: "reason (e.g. power outage, Out of Memory Error, NaN detected) then the " "optimizer weights will NOT be saved.")) + self.add_item( + section=section, + title="lr_finder_iterations", + datatype=int, + default=1000, + min_max=(100, 10000), + rounding=100, + fixed=True, + group=_("Learning Rate Finder"), + info=_( + "The number of iterations to process to find the optimal learning rate. Higher " + "values will take longer, but will be more accurate.")) + self.add_item( + section=section, + title="lr_finder_mode", + datatype=str, + default="set", + fixed=True, + gui_radio=True, + choices=["set", "graph_and_set", "graph_and_exit"], + group=_("Learning Rate Finder"), + info=_( + "The operation mode for the learning rate finder. Only applicable to new models. " + "For existing models this will always default to 'set'." + "\n\tset - Train with the discovered optimal learning rate." + "\n\tgraph_and_set - Output a graph in the training folder showing the discovered " + "learning rates and train with the optimal learning rate." + "\n\tgraph_and_exit - Output a graph in the training folder with the discovered " + "learning rates and exit.")) + self.add_item( + section=section, + title="lr_finder_strength", + datatype=str, + default="default", + fixed=True, + gui_radio=True, + choices=["default", "aggressive", "extreme"], + group=_("Learning Rate Finder"), + info=_( + "How aggressively to set the Learning Rate. More aggressive can learn faster, but " + "is more likely to lead to exploding gradients." + "\n\tdefault - The default optimal learning rate. A safe choice for nearly all " + "use cases." + "\n\taggressive - Set's a higher learning rate than the default. May learn faster " + "but with a higher chance of exploding gradients." + "\n\textreme - The highest optimal learning rate. A much higher risk of exploding " + "gradients.")) self.add_item( section=section, title="autoclip", diff --git a/plugins/train/model/_base/io.py b/plugins/train/model/_base/io.py index bcf4ad48d5..47f1ea80d2 100644 --- a/plugins/train/model/_base/io.py +++ b/plugins/train/model/_base/io.py @@ -88,7 +88,12 @@ def __init__(self, self._backup = Backup(self._model_dir, self._plugin.name) @property - def _filename(self) -> str: + def model_dir(self) -> str: + """ str: The full path to the model folder """ + return self._model_dir + + @property + def filename(self) -> str: """str: The filename for this model.""" return os.path.join(self._model_dir, f"{self._plugin.name}.h5") @@ -97,7 +102,7 @@ def model_exists(self) -> bool: """ bool: ``True`` if a model of the type being loaded exists within the model folder location otherwise ``False``. """ - return os.path.isfile(self._filename) + return os.path.isfile(self.filename) @property def history(self) -> list[list[float]]: @@ -119,7 +124,7 @@ def multiple_models_in_folder(self) -> list[str] | None: self._plugin.name, plugins, test, retval) return retval - def _load(self) -> tf.keras.models.Model: + def load(self) -> tf.keras.models.Model: """ Loads the model from disk If the predict function is to be called and the model cannot be found in the model folder @@ -133,16 +138,16 @@ def _load(self) -> tf.keras.models.Model: :class:`tensorflow.keras.models.Model` The saved model loaded from disk """ - logger.debug("Loading model: %s", self._filename) + logger.debug("Loading model: %s", self.filename) if self._is_predict and not self.model_exists: logger.error("Model could not be found in folder '%s'. Exiting", self._model_dir) sys.exit(1) try: - model = kmodels.load_model(self._filename, compile=False) + model = kmodels.load_model(self.filename, compile=False) except RuntimeError as err: if "unable to get link info" in str(err).lower(): - msg = (f"Unable to load the model from '{self._filename}'. This may be a " + msg = (f"Unable to load the model from '{self.filename}'. This may be a " "temporary error but most likely means that your model has corrupted.\n" "You can try to load the model again but if the problem persists you " "should use the Restore Tool to restore your model from backup.\n" @@ -151,7 +156,7 @@ def _load(self) -> tf.keras.models.Model: raise err except KeyError as err: if "unable to open object" in str(err).lower(): - msg = (f"Unable to load the model from '{self._filename}'. This may be a " + msg = (f"Unable to load the model from '{self.filename}'. This may be a " "temporary error but most likely means that your model has corrupted.\n" "You can try to load the model again but if the problem persists you " "should use the Restore Tool to restore your model from backup.\n" @@ -159,10 +164,12 @@ def _load(self) -> tf.keras.models.Model: raise FaceswapError(msg) from err raise err - logger.info("Loaded model from disk: '%s'", self._filename) + logger.info("Loaded model from disk: '%s'", self.filename) return model - def save(self, is_exit: bool = False, force_save_optimizer: bool = False) -> None: + def save(self, + is_exit: bool = False, + force_save_optimizer: bool = False) -> None: """ Backup and save the model and state file. Parameters @@ -170,7 +177,6 @@ def save(self, is_exit: bool = False, force_save_optimizer: bool = False) -> Non is_exit: bool, optional ``True`` if the save request has come from an exit process request otherwise ``False``. Default: ``False`` - force_save_optimizer: bool, optional ``True`` to force saving the optimizer weights with the model, otherwise ``False``. Default:``False`` @@ -186,27 +192,26 @@ def save(self, is_exit: bool = False, force_save_optimizer: bool = False) -> Non print("") # Insert a new line to avoid spamming the same row as loss output save_averages = self._get_save_averages() if save_averages and self._should_backup(save_averages): - self._backup.backup_model(self._filename) - # pylint:disable=protected-access - self._backup.backup_model(self._plugin.state._filename) + self._backup.backup_model(self.filename) + self._backup.backup_model(self._plugin.state.filename) include_optimizer = (force_save_optimizer or self._save_optimizer == "always" or (self._save_optimizer == "exit" and is_exit)) try: - self._plugin.model.save(self._filename, include_optimizer=include_optimizer) + self._plugin.model.save(self.filename, include_optimizer=include_optimizer) except ValueError as err: if include_optimizer and "name already exists" in str(err): logger.warning("Due to a bug in older versions of Tensorflow, optimizer state " "cannot be saved for this model.") - self._plugin.model.save(self._filename, include_optimizer=False) + self._plugin.model.save(self.filename, include_optimizer=False) else: raise self._plugin.state.save() - msg = "[Saved optimizer state for Snapshot]" if force_save_optimizer else "[Saved models]" + msg = "[Saved optimizer state for Snapshot]" if force_save_optimizer else "[Saved model]" if save_averages: lossmsg = [f"face_{side}: {avg:.5f}" for side, avg in zip(("a", "b"), save_averages)] diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 8629ed9dcd..f46b08f30e 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -16,11 +16,6 @@ import numpy as np import tensorflow as tf -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras import backend as K # pylint:disable=import-error -from tensorflow.keras.layers import Input # pylint:disable=import-error -from tensorflow.keras.models import load_model, Model as KModel # noqa:E501 # pylint:disable=import-error - from lib.serializer import get_serializer from lib.model.nn_blocks import set_config as set_nnblock_config from lib.utils import FaceswapError @@ -33,6 +28,10 @@ import argparse from lib.config import ConfigValueType +keras = tf.keras +K = tf.keras.backend + + logger = logging.getLogger(__name__) # pylint: disable=invalid-name _CONFIG: dict[str, ConfigValueType] = {} @@ -133,9 +132,9 @@ def coverage_ratio(self) -> float: return self.config.get("coverage", 62.5) / 100 @property - def model_dir(self) -> str: - """str: The full path to the model folder location. """ - return self._io._model_dir # pylint:disable=protected-access + def io(self) -> IO: # pylint:disable=invalid-name + """ :class:`~plugins.train.model.io.IO`: Input/Output operations for the model """ + return self._io @property def config(self) -> dict: @@ -229,12 +228,12 @@ def _check_multiple_models(self) -> None: if len(multiple_models) == 1: msg = (f"You have requested to train with the '{self.name}' plugin, but a model file " f"for the '{multiple_models[0]}' plugin already exists in the folder " - f"'{self.model_dir}'.\nPlease select a different model folder.") + f"'{self.io.model_dir}'.\nPlease select a different model folder.") else: ptypes = "', '".join(multiple_models) msg = (f"There are multiple plugin types ('{ptypes}') stored in the model folder '" - f"{self.model_dir}'. This is not supported.\nPlease split the model files into " - "their own folders before proceeding") + f"{self.io.model_dir}'. This is not supported.\nPlease split the model files " + "into their own folders before proceeding") raise FaceswapError(msg) def build(self) -> None: @@ -253,7 +252,7 @@ def build(self) -> None: is_summary = hasattr(self._args, "summary") and self._args.summary with self._settings.strategy_scope(): if self._io.model_exists: - model = self._io._load() # pylint:disable=protected-access + model = self.io.load() if self._is_predict: inference = _Inference(model, self._args.swap_model) self._model = inference.model @@ -281,10 +280,10 @@ def _update_legacy_models(self) -> None: if legacy_mapping is None: return - if not all(os.path.isfile(os.path.join(self.model_dir, fname)) + if not all(os.path.isfile(os.path.join(self.io.model_dir, fname)) for fname in legacy_mapping): return - archive_dir = f"{self.model_dir}_TF1_Archived" + archive_dir = f"{self.io.model_dir}_TF1_Archived" if os.path.exists(archive_dir): raise FaceswapError("We need to update your model files for use with Tensorflow 2.x, " "but the archive folder already exists. Please remove the " @@ -293,12 +292,13 @@ def _update_legacy_models(self) -> None: logger.info("Updating legacy models for Tensorflow 2.x") logger.info("Your Tensorflow 1.x models will be archived in the following location: '%s'", archive_dir) - os.rename(self.model_dir, archive_dir) - os.mkdir(self.model_dir) + os.rename(self.io.model_dir, archive_dir) + os.mkdir(self.io.model_dir) new_model = self.build_model(self._get_inputs()) for model_name, layer_name in legacy_mapping.items(): - old_model: tf.keras.models.Model = load_model(os.path.join(archive_dir, model_name), - compile=False) + old_model: tf.keras.models.Model = keras.models.load_model( + os.path.join(archive_dir, model_name), + compile=False) layer = [layer for layer in new_model.layers if layer.name == layer_name] if not layer: logger.warning("Skipping legacy weights from '%s'...", model_name) @@ -306,7 +306,7 @@ def _update_legacy_models(self) -> None: klayer: tf.keras.layers.Layer = layer[0] logger.info("Updating legacy weights from '%s'...", model_name) klayer.set_weights(old_model.get_weights()) - filename = self._io._filename # pylint:disable=protected-access + filename = self._io.filename logger.info("Saving Tensorflow 2.x model to '%s'", filename) new_model.save(filename) # Penalized Loss and Learn Mask used to be disabled automatically if a mask wasn't @@ -337,7 +337,7 @@ def _get_inputs(self) -> list[tf.keras.layers.Input]: """ logger.debug("Getting inputs") input_shapes = [self.input_shape, self.input_shape] - inputs = [Input(shape=shape, name=f"face_in_{side}") + inputs = [keras.layers.Input(shape=shape, name=f"face_in_{side}") for side, shape in zip(("a", "b"), input_shapes)] logger.debug("inputs: %s", inputs) return inputs @@ -374,26 +374,6 @@ def _output_summary(self) -> None: model.summary(line_length=100, print_fn=print_fn) parent.summary(line_length=100, print_fn=print_fn) - def save(self, is_exit: bool = False) -> None: - """ Save the model to disk. - - Saves the serialized model, with weights, to the folder location specified when - initializing the plugin. If loss has dropped on both sides of the model, then - a backup is taken. - - Parameters - ---------- - is_exit: bool, optional - ``True`` if the save request has come from an exit process request otherwise ``False`` - Default: ``False`` - """ - self._io.save(is_exit=is_exit) - - def snapshot(self) -> None: - """ Creates a snapshot of the model folder to the models parent folder, with the number - of iterations completed appended to the end of the model name. """ - self._io.snapshot() - def _compile_model(self) -> None: """ Compile the model to include the Optimizer and Loss Function(s). """ logger.debug("Compiling Model") @@ -482,6 +462,11 @@ def __init__(self, self._create_new_session(no_logs, config_changeable_items) logger.debug("Initialized %s:", self.__class__.__name__) + @property + def filename(self) -> str: + """ str: Full path to the state filename """ + return self._filename + @property def loss_names(self) -> list[str]: """ list: The loss names for the current session """ @@ -507,6 +492,12 @@ def session_id(self) -> int: """ int: The current training session id. """ return self._session_id + @property + def sessions(self) -> dict[int, dict[str, T.Any]]: + """ dict[int, dict[str, Any]]: The session information for each session in the state + file """ + return {int(k): v for k, v in self._sessions.items()} + @property def mixed_precision_layers(self) -> list[str]: """list: Layers that can be switched between mixed-float16 and float32. """ @@ -554,6 +545,21 @@ def _create_new_session(self, no_logs: bool, config_changeable_items: dict) -> N "iterations": 0, "config": config_changeable_items} + def update_session_config(self, key: str, value: T.Any) -> None: + """ Update a configuration item of the currently loaded session. + + Parameters + ---------- + key: str + The configuration item to update for the current session + value: any + The value to update to + """ + old_val = self.current_session["config"][key] + assert isinstance(value, type(old_val)) + logger.debug("Updating configuration item '%s' from '%s' to '%s'", key, old_val, value) + self.current_session["config"][key] = value + def add_session_loss_names(self, loss_names: list[str]) -> None: """ Add the session loss names to the sessions dictionary. @@ -869,7 +875,7 @@ def _make_inference_model(self, saved_model: tf.keras.models.Model) -> tf.keras. logger.debug("Compiling layer '%s': layer inputs: %s", layer.name, layer_inputs) model = layer(layer_inputs) compiled_layers[layer.name] = model - retval = KModel(model_inputs, model, name=f"{saved_model.name}_inference") + retval = keras.models.Model(model_inputs, model, name=f"{saved_model.name}_inference") logger.debug("Compiled inference model '%s': %s", retval.name, retval) return retval diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 97e034ddae..87750b3ad9 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -186,7 +186,7 @@ def build(self) -> None: super().build() return with self._settings.strategy_scope(): - model = self._io._load() # pylint:disable=protected-access + model = self.io.load() model = self._update_dropouts(model) self._model = model self._compile_model() diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 2614921cfb..37dc47f85e 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -20,17 +20,16 @@ errors_impl as tf_errors) from lib.image import hex_to_rgb -from lib.training import PreviewDataGenerator, TrainingDataGenerator -from lib.training.generator import BatchType, DataGenerator +from lib.training import Feeder, LearningRateFinder from lib.utils import FaceswapError, get_folder, get_image_paths from plugins.train._config import Config if T.TYPE_CHECKING: - from collections.abc import Callable, Generator + from collections.abc import Callable from plugins.train.model._base import ModelBase from lib.config import ConfigValueType -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) def _get_config(plugin_name: str, @@ -83,12 +82,16 @@ def __init__(self, self._model = model self._config = self._get_config(configfile) + self._feeder = Feeder(images, model, batch_size, self._config) + + self._exit_early = self._handle_lr_finder() + if self._exit_early: + return + self._model.state.add_session_batchsize(batch_size) self._images = images self._sides = sorted(key for key in self._images.keys()) - self._feeder = _Feeder(images, self._model, batch_size, self._config) - self._tensorboard = self._set_tensorboard() self._samples = _Samples(self._model, self._model.coverage_ratio, @@ -106,6 +109,11 @@ def __init__(self, self._images) logger.debug("Initialized %s", self.__class__.__name__) + @property + def exit_early(self) -> bool: + """ True if the trainer should exit early, without perfoming any training steps """ + return self._exit_early + def _get_config(self, configfile: str | None) -> dict[str, ConfigValueType]: """ Get the saved training config options. Override any global settings with the setting provided from the model's saved config. @@ -131,6 +139,34 @@ def _get_config(self, configfile: str | None) -> dict[str, ConfigValueType]: config[key] = new_val return config + def _handle_lr_finder(self) -> bool: + """ Handle the learning rate finder. + + If this is a new model, then find the optimal learning rate and return ``True`` if user has + just requested the graph, otherwise return ``False`` to continue training + + If it as existing model, set the learning rate to the value found by the learing rate + finder and return ``False`` to continue training + + Returns + ------- + bool + ``True`` if the learning rate finder options dictate that training should not continue + after finding the optimal leaning rate + """ + if not self._model.command_line_arguments.use_lr_finder: + return False + + if self._model.state.iterations == 0 and self._model.state.session_id == 1: + lrf = LearningRateFinder(self._model, self._config, self._feeder) + success = lrf.find() + return self._config["lr_finder_mode"] == "graph_and_exit" or not success + + learning_rate = self._model.state.sessions[1]["config"]["learning_rate"] + logger.info("Setting learning rate from Learning Rate Finder to %s", + f"{learning_rate:.1e}") + return False + def _set_tensorboard(self) -> tf.keras.callbacks.TensorBoard: """ Set up Tensorboard callback for logging loss. @@ -147,7 +183,7 @@ def _set_tensorboard(self) -> tf.keras.callbacks.TensorBoard: logger.debug("Enabling TensorBoard Logging") logger.debug("Setting up TensorBoard Logging") - log_dir = os.path.join(str(self._model.model_dir), + log_dir = os.path.join(str(self._model.io.model_dir), f"{self._model.name}_logs", f"session_{self._model.state.session_id}") tensorboard = tf.keras.callbacks.TensorBoard(log_dir=log_dir, @@ -227,7 +263,7 @@ def train_one_step(self, loss = self._collate_and_store_loss(loss[1:]) self._print_loss(loss) if do_snapshot: - self._model.snapshot() + self._model.io.snapshot() self._update_viewers(viewer, timelapse_kwargs) def _log_tensorboard(self, loss: list[float]) -> None: @@ -348,234 +384,6 @@ def clear_tensorboard(self) -> None: self._tensorboard.on_train_end(None) -class _Feeder(): - """ Handles the processing of a Batch for training the model and generating samples. - - Parameters - ---------- - images: dict - The list of full paths to the training images for this :class:`_Feeder` for each side - model: plugin from :mod:`plugins.train.model` - The selected model that will be running this trainer - batch_size: int - The size of the batch to be processed for each side at each iteration - config: dict - The configuration for this trainer - """ - def __init__(self, - images: dict[T.Literal["a", "b"], list[str]], - model: ModelBase, - batch_size: int, - config: dict[str, ConfigValueType]) -> None: - logger.debug("Initializing %s: num_images: %s, batch_size: %s, config: %s)", - self.__class__.__name__, {k: len(v) for k, v in images.items()}, batch_size, - config) - self._model = model - self._images = images - self._batch_size = batch_size - self._config = config - self._feeds = {side: self._load_generator(side, False).minibatch_ab() - for side in T.get_args(T.Literal["a", "b"])} - - self._display_feeds = {"preview": self._set_preview_feed(), "timelapse": {}} - logger.debug("Initialized %s:", self.__class__.__name__) - - def _load_generator(self, - side: T.Literal["a", "b"], - is_display: bool, - batch_size: int | None = None, - images: list[str] | None = None) -> DataGenerator: - """ Load the :class:`~lib.training_data.TrainingDataGenerator` for this feeder. - - Parameters - ---------- - side: ["a", "b"] - The side of the model to load the generator for - is_display: bool - ``True`` if the generator is for creating preview/time-lapse images. ``False`` if it is - for creating training images - batch_size: int, optional - If ``None`` then the batch size selected in command line arguments is used, otherwise - the batch size provided here is used. - images: list, optional. Default: ``None`` - If provided then this will be used as the list of images for the generator. If ``None`` - then the training folder images for the side will be used. Default: ``None`` - - Returns - ------- - :class:`~lib.training_data.TrainingDataGenerator` - The training data generator - """ - logger.debug("Loading generator, side: %s, is_display: %s, batch_size: %s", - side, is_display, batch_size) - generator = PreviewDataGenerator if is_display else TrainingDataGenerator - retval = generator(self._config, - self._model, - side, - self._images[side] if images is None else images, - self._batch_size if batch_size is None else batch_size) - return retval - - def _set_preview_feed(self) -> dict[T.Literal["a", "b"], Generator[BatchType, None, None]]: - """ Set the preview feed for this feeder. - - Creates a generator from :class:`lib.training_data.PreviewDataGenerator` specifically - for previews for the feeder. - - Returns - ------- - dict - The side ("a" or "b") as key, :class:`~lib.training_data.PreviewDataGenerator` as - value. - """ - retval: dict[T.Literal["a", "b"], Generator[BatchType, None, None]] = {} - num_images = self._config.get("preview_images", 14) - assert isinstance(num_images, int) - for side in T.get_args(T.Literal["a", "b"]): - logger.debug("Setting preview feed: (side: '%s')", side) - preview_images = min(max(num_images, 2), 16) - batchsize = min(len(self._images[side]), preview_images) - retval[side] = self._load_generator(side, - True, - batch_size=batchsize).minibatch_ab() - return retval - - def get_batch(self) -> tuple[list[list[np.ndarray]], ...]: - """ Get the feed data and the targets for each training side for feeding into the model's - train function. - - Returns - ------- - model_inputs: list - The inputs to the model for each side A and B - model_targets: list - The targets for the model for each side A and B - """ - model_inputs: list[list[np.ndarray]] = [] - model_targets: list[list[np.ndarray]] = [] - for side in ("a", "b"): - side_feed, side_targets = next(self._feeds[side]) - if self._model.config["learn_mask"]: # Add the face mask as it's own target - side_targets += [side_targets[-1][..., 3][..., None]] - logger.trace("side: %s, input_shapes: %s, target_shapes: %s", # type: ignore - side, side_feed.shape, [i.shape for i in side_targets]) - model_inputs.append([side_feed]) - model_targets.append(side_targets) - - return model_inputs, model_targets - - def generate_preview(self, is_timelapse: bool = False - ) -> dict[T.Literal["a", "b"], list[np.ndarray]]: - """ Generate the images for preview window or timelapse - - Parameters - ---------- - is_timelapse, bool, optional - ``True`` if preview is to be generated for a Timelapse otherwise ``False``. - Default: ``False`` - - Returns - ------- - dict - Dictionary for side A and B of list of numpy arrays corresponding to the - samples, targets and masks for this preview - """ - logger.debug("Generating preview (is_timelapse: %s)", is_timelapse) - - batchsizes: list[int] = [] - feed: dict[T.Literal["a", "b"], np.ndarray] = {} - samples: dict[T.Literal["a", "b"], np.ndarray] = {} - masks: dict[T.Literal["a", "b"], np.ndarray] = {} - - # MyPy can't recurse into nested dicts to get the type :( - iterator = T.cast(dict[T.Literal["a", "b"], "Generator[BatchType, None, None]"], - self._display_feeds["timelapse" if is_timelapse else "preview"]) - for side in T.get_args(T.Literal["a", "b"]): - side_feed, side_samples = next(iterator[side]) - batchsizes.append(len(side_samples[0])) - samples[side] = side_samples[0] - feed[side] = side_feed[..., :3] - masks[side] = side_feed[..., 3][..., None] - - logger.debug("Generated samples: is_timelapse: %s, images: %s", is_timelapse, - {key: {k: v.shape for k, v in item.items()} - for key, item - in zip(("feed", "samples", "sides"), (feed, samples, masks))}) - return self.compile_sample(min(batchsizes), feed, samples, masks) - - def compile_sample(self, - image_count: int, - feed: dict[T.Literal["a", "b"], np.ndarray], - samples: dict[T.Literal["a", "b"], np.ndarray], - masks: dict[T.Literal["a", "b"], np.ndarray] - ) -> dict[T.Literal["a", "b"], list[np.ndarray]]: - """ Compile the preview samples for display. - - Parameters - ---------- - image_count: int - The number of images to limit the sample output to. - feed: dict - Dictionary for side "a", "b" of :class:`numpy.ndarray`. The images that should be fed - into the model for obtaining a prediction - samples: dict - Dictionary for side "a", "b" of :class:`numpy.ndarray`. The 100% coverage target images - that should be used for creating the preview. - masks: dict - Dictionary for side "a", "b" of :class:`numpy.ndarray`. The masks that should be used - for creating the preview. - - Returns - ------- - list - The list of samples, targets and masks as :class:`numpy.ndarrays` for creating a - preview image - """ - num_images = self._config.get("preview_images", 14) - assert isinstance(num_images, int) - num_images = min(image_count, num_images) - retval: dict[T.Literal["a", "b"], list[np.ndarray]] = {} - for side in T.get_args(T.Literal["a", "b"]): - logger.debug("Compiling samples: (side: '%s', samples: %s)", side, num_images) - retval[side] = [feed[side][0:num_images], - samples[side][0:num_images], - masks[side][0:num_images]] - logger.debug("Compiled Samples: %s", {k: [i.shape for i in v] for k, v in retval.items()}) - return retval - - def set_timelapse_feed(self, - images: dict[T.Literal["a", "b"], list[str]], - batch_size: int) -> None: - """ Set the time-lapse feed for this feeder. - - Creates a generator from :class:`lib.training_data.PreviewDataGenerator` specifically - for generating time-lapse previews for the feeder. - - Parameters - ---------- - images: dict - The list of full paths to the images for creating the time-lapse for each side - batch_size: int - The number of images to be used to create the time-lapse preview. - """ - logger.debug("Setting time-lapse feed: (input_images: '%s', batch_size: %s)", - images, batch_size) - - # MyPy can't recurse into nested dicts to get the type :( - iterator = T.cast(dict[T.Literal["a", "b"], "Generator[BatchType, None, None]"], - self._display_feeds["timelapse"]) - - for side in T.get_args(T.Literal["a", "b"]): - imgs = images[side] - logger.debug("Setting preview feed: (side: '%s', images: %s)", side, len(imgs)) - - iterator[side] = self._load_generator(side, - True, - batch_size=batch_size, - images=imgs).minibatch_ab(do_shuffle=False) - logger.debug("Set time-lapse feed: %s", self._display_feeds["timelapse"]) - - class _Samples(): # pylint:disable=too-few-public-methods """ Compile samples for display for preview and time-lapse @@ -995,7 +803,7 @@ class _Timelapse(): # pylint:disable=too-few-public-methods The opacity (as a percentage) to use for the mask overlay mask_color: str The hex RGB value to use the mask overlay - feeder: :class:`_Feeder` + feeder: :class:`~lib.training.generator.Feeder` The feeder for generating the time-lapse images. image_paths: dict The full paths to the training images for each side of the model @@ -1006,7 +814,7 @@ def __init__(self, image_count: int, mask_opacity: int, mask_color: str, - feeder: _Feeder, + feeder: Feeder, image_paths: dict[T.Literal["a", "b"], list[str]]) -> None: logger.debug("Initializing %s: model: %s, coverage_ratio: %s, image_count: %s, " "mask_opacity: %s, mask_color: %s, feeder: %s, image_paths: %s)", @@ -1035,7 +843,7 @@ def _setup(self, input_a: str, input_b: str, output: str) -> None: """ logger.debug("Setting up time-lapse") if not output: - output = get_folder(os.path.join(str(self._model.model_dir), + output = get_folder(os.path.join(str(self._model.io.model_dir), f"{self._model.name}_timelapse")) self._output_file = output logger.debug("Time-lapse output set to '%s'", self._output_file) diff --git a/scripts/train.py b/scripts/train.py index ddadba0757..1adca528ec 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -258,11 +258,14 @@ def _training(self) -> None: logger.info("Loading data, this may take a while...") model = self._load_model() trainer = self._load_trainer(model) + if trainer.exit_early: + self._stop = True + return self._run_training_cycle(model, trainer) except KeyboardInterrupt: try: logger.debug("Keyboard Interrupt Caught. Saving Weights and exiting") - model.save(is_exit=True) + model.io.save(is_exit=True) trainer.clear_tensorboard() except KeyboardInterrupt: logger.info("Saving model weights has been cancelled!") @@ -360,12 +363,12 @@ def _run_training_cycle(self, model: ModelBase, trainer: TrainerBase) -> None: if save_iteration or self._save_now: logger.debug("Saving (save_iterations: %s, save_now: %s) Iteration: " "(iteration: %s)", save_iteration, self._save_now, iteration) - model.save(is_exit=False) + model.io.save(is_exit=False) self._save_now = False update_preview_images = True logger.debug("Training cycle complete") - model.save(is_exit=True) + model.io.save(is_exit=True) trainer.clear_tensorboard() self._stop = True diff --git a/tests/lib/model/losses_test.py b/tests/lib/model/losses_test.py index b1b77c6bfe..ae59b38e10 100644 --- a/tests/lib/model/losses_test.py +++ b/tests/lib/model/losses_test.py @@ -46,7 +46,7 @@ def test_loss_output(loss_func, output_shape): k_losses.mean_absolute_error, k_losses.mean_squared_error, losses.MSSIMLoss()] -_LWIDS = ["DSSIMObjective", "FocalFrequencyLosse", "GeneralizedLoss", "GMSDLoss", "GradientLoss", +_LWIDS = ["DSSIMObjective", "FocalFrequencyLoss", "GeneralizedLoss", "GMSDLoss", "GradientLoss", "LaplacianPyramidLoss", "LInfNorm", "LDRFlipLoss", "logcosh", "mae", "mse", "MS-SSIM"] _LWIDS = [f"{loss}[{get_backend().upper()}]" for loss in _LWIDS] diff --git a/tests/simple_tests.py b/tests/simple_tests.py index 92237f6803..956117422a 100644 --- a/tests/simple_tests.py +++ b/tests/simple_tests.py @@ -211,8 +211,7 @@ def main(): pathjoin(vid_base, "model"), pathjoin(vid_base, "faces"), iterations=1, - batchsize=1, - extra_args="-wl")) + batchsize=1)) if was_trained: run_test( From 5d000258849b985f94f6906d52cbdd08fdcd4ab8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 27 Aug 2023 02:15:55 +0100 Subject: [PATCH 857/981] bugfix: error loading images with special chars --- lib/image.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/image.py b/lib/image.py index 8713335eae..14d4f74a08 100644 --- a/lib/image.py +++ b/lib/image.py @@ -295,16 +295,16 @@ def read_image(filename, raise_error=False, with_metadata=False): success = True image = None try: - if not with_metadata: - retval = cv2.imread(filename) - if retval is None: + with open(filename, "rb") as infile: + raw_file = infile.read() + image = cv2.imdecode(np.frombuffer(raw_file, dtype="uint8"), cv2.IMREAD_UNCHANGED) + if image is None: raise ValueError("Image is None") - else: - with open(filename, "rb") as infile: - raw_file = infile.read() + if with_metadata: metadata = png_read_meta(raw_file) - image = cv2.imdecode(np.frombuffer(raw_file, dtype="uint8"), cv2.IMREAD_UNCHANGED) - retval = (image, metadata) + retval = (image, metadata) + else: + retval = image except TypeError as err: success = False msg = "Error while reading image (TypeError): '{}'".format(filename) From 603fdc2a35bdc5f6c90f134beca5fba89c582ff9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 1 Sep 2023 09:50:47 +0100 Subject: [PATCH 858/981] Phaze A - Allow outputs divisible by 16 --- plugins/train/model/phaze_a.py | 4 ++-- plugins/train/model/phaze_a_defaults.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 87750b3ad9..aaabff3b62 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -163,8 +163,8 @@ class Model(ModelBase): """ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - if self.config["output_size"] % 64 != 0: - raise FaceswapError("Phaze-A output shape must be a multiple of 64") + if self.config["output_size"] % 16 != 0: + raise FaceswapError("Phaze-A output shape must be a multiple of 16") self._validate_encoder_architecture() self.config["freeze_layers"] = self._select_freeze_layers() diff --git a/plugins/train/model/phaze_a_defaults.py b/plugins/train/model/phaze_a_defaults.py index 5fb9e7a777..8cff3d6458 100644 --- a/plugins/train/model/phaze_a_defaults.py +++ b/plugins/train/model/phaze_a_defaults.py @@ -65,7 +65,7 @@ "Resolution (in pixels) of the output image to generate.\n" "BE AWARE Larger resolution will dramatically increase VRAM requirements."), "datatype": int, - "rounding": 64, + "rounding": 16, "min_max": (64, 2048), "group": "general", "fixed": True}, From 460a229e79410b929d84614acb41e9d5f3606838 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 1 Sep 2023 19:43:05 +0100 Subject: [PATCH 859/981] bugfix: edge case NaN in pose estimation --- lib/align/aligned_face.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index a8e997feaa..0a5b9ad141 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -314,11 +314,20 @@ def _solve_pnp(self, landmarks: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """ points = landmarks[[6, 7, 8, 9, 10, 17, 21, 22, 26, 31, 32, 33, 34, 35, 36, 39, 42, 45, 48, 50, 51, 52, 54, 56, 57, 58]] - _, rotation, translation = cv2.solvePnP(_MEAN_FACE_3D, - points, - self._camera_matrix, - self._distortion_coefficients, - flags=cv2.SOLVEPNP_ITERATIVE) + + try: + _, rotation, translation = cv2.solvePnP(_MEAN_FACE_3D, + points, + self._camera_matrix, + self._distortion_coefficients, + flags=cv2.SOLVEPNP_ITERATIVE) + except: + print("mean", _MEAN_FACE_3D) + print("lms", np.nan_to_num(landmarks)) + print("pts", points) + print("mtrx", self._camera_matrix) + print("dst", self._distortion_coefficients) + raise logger.trace("points: %s, rotation: %s, translation: %s", # type: ignore points, rotation, translation) return rotation, translation @@ -521,8 +530,8 @@ def pose(self) -> PoseEstimate: """ :class:`lib.align.PoseEstimate`: The estimated pose in 3D space. """ with self._cache.lock("pose"): if self._cache.pose is None: - lms = cv2.transform(np.expand_dims(self._frame_landmarks, axis=1), - self._matrices["legacy"]).squeeze() + lms = np.nan_to_num(cv2.transform(np.expand_dims(self._frame_landmarks, axis=1), + self._matrices["legacy"]).squeeze()) self._cache.pose = PoseEstimate(lms) return self._cache.pose From 4588adbd16013e90e095ff5a4cfe0c15caa6156c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 1 Sep 2023 19:44:57 +0100 Subject: [PATCH 860/981] remove debug code --- lib/align/aligned_face.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 0a5b9ad141..4043730172 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -314,20 +314,11 @@ def _solve_pnp(self, landmarks: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """ points = landmarks[[6, 7, 8, 9, 10, 17, 21, 22, 26, 31, 32, 33, 34, 35, 36, 39, 42, 45, 48, 50, 51, 52, 54, 56, 57, 58]] - - try: - _, rotation, translation = cv2.solvePnP(_MEAN_FACE_3D, - points, - self._camera_matrix, - self._distortion_coefficients, - flags=cv2.SOLVEPNP_ITERATIVE) - except: - print("mean", _MEAN_FACE_3D) - print("lms", np.nan_to_num(landmarks)) - print("pts", points) - print("mtrx", self._camera_matrix) - print("dst", self._distortion_coefficients) - raise + _, rotation, translation = cv2.solvePnP(_MEAN_FACE_3D, + points, + self._camera_matrix, + self._distortion_coefficients, + flags=cv2.SOLVEPNP_ITERATIVE) logger.trace("points: %s, rotation: %s, translation: %s", # type: ignore points, rotation, translation) return rotation, translation From 8388241db9bff56b5dcb19d643916402ba200a84 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 1 Sep 2023 22:36:31 +0100 Subject: [PATCH 861/981] bugfix: Always load images as 3 channel color --- lib/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/image.py b/lib/image.py index 14d4f74a08..c79bf9a350 100644 --- a/lib/image.py +++ b/lib/image.py @@ -297,7 +297,7 @@ def read_image(filename, raise_error=False, with_metadata=False): try: with open(filename, "rb") as infile: raw_file = infile.read() - image = cv2.imdecode(np.frombuffer(raw_file, dtype="uint8"), cv2.IMREAD_UNCHANGED) + image = cv2.imdecode(np.frombuffer(raw_file, dtype="uint8"), cv2.IMREAD_COLOR) if image is None: raise ValueError("Image is None") if with_metadata: From 48aca1462ab87d767a462671b53723af1fc16712 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 8 Sep 2023 09:48:09 +0100 Subject: [PATCH 862/981] bugfix: LR finder. Reset optimizer state --- lib/training/lr_finder.py | 44 +++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/lib/training/lr_finder.py b/lib/training/lr_finder.py index d5e2412005..62e2eff5c3 100644 --- a/lib/training/lr_finder.py +++ b/lib/training/lr_finder.py @@ -137,6 +137,34 @@ def _train(self) -> None: self._on_batch_end(idx, loss[0]) self._update_description(pbar) + def _reset_model(self, original_lr: float, new_lr: float) -> None: + """ Reset the model's weights to initial values, reset the model's optimizer and set the + learning rate + + Parameters + ---------- + original_lr: float + The model's original learning rate + new_lr: float + The discovered optimal learning rate + """ + self._model.state.update_session_config("learning_rate", new_lr) + self._model.state.save() + + logger.debug("Loading initial weights") + self._model.model.load_weights(self._model.io.filename) + + if self._config["lr_finder_mode"] == "graph_and_exit": + return + + opt_conf = self._model.model.optimizer.get_config() + logger.debug("Recompiling model to reset optimizer state. Optimizer config: %s", opt_conf) + new_opt = self._model.model.optimizer.__class__(**opt_conf) + self._model.model.compile(optimizer=new_opt, loss=self._model.model.loss) + + logger.info("Updating Learning Rate from %s to %s", f"{original_lr:.1e}", f"{new_lr:.1e}") + K.set_value(self._model.model.optimizer.lr, new_lr) + def find(self) -> bool: """ Find the optimal learning rate @@ -162,17 +190,8 @@ def find(self) -> bool: shutil.rmtree(self._model.io.model_dir) return False - if self._save_graph: - self._plot_loss() - - if not self._config["lr_finder_mode"] == "graph_and_exit": - logger.info("Updating Learning Rate from %s to %s", - f"{original_lr:.1e}", f"{new_lr:.1e}") - self._model.model.load_weights(self._model.io.filename) - K.set_value(self._model.model.optimizer.lr, new_lr) - - self._model.state.update_session_config("learning_rate", new_lr) - self._model.state.save() + self._plot_loss() + self._reset_model(original_lr, new_lr) return True def _plot_loss(self, skip_begin: int = 10, skip_end: int = 1) -> None: @@ -185,6 +204,9 @@ def _plot_loss(self, skip_begin: int = 10, skip_end: int = 1) -> None: skip_end: int, optional Number of iterations to skip at the end. Default: `1` """ + if not self._save_graph: + return + matplotlib.use("Agg") lrs = self._metrics["learning_rates"][skip_begin:-skip_end] losses = self._metrics["losses"][skip_begin:-skip_end] From a660eda8e1cdb513edc8d46adaed17bc8935e56a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 9 Sep 2023 18:40:36 +0100 Subject: [PATCH 863/981] Convert: Add face scale option --- lib/cli/args.py | 11 ++ lib/convert.py | 11 +- locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 47210 -> 48013 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 253 ++++++++++++++----------- locales/kr/LC_MESSAGES/lib.cli.args.mo | Bin 47827 -> 48585 bytes locales/kr/LC_MESSAGES/lib.cli.args.po | 252 ++++++++++++------------ locales/lib.cli.args.pot | 106 ++++++----- locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 64186 -> 64574 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 110 ++++++----- tools/preview/control_panels.py | 43 +++-- 10 files changed, 438 insertions(+), 348 deletions(-) diff --git a/lib/cli/args.py b/lib/cli/args.py index 11c34edba2..2db2c30705 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -789,6 +789,17 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " "will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " "converting from images, then the filenames must end with the frame-number!"))) + argument_list.append(dict( + opts=("-S", "--face-scale"), + action=Slider, + min_max=(-10.0, 10.0), + rounding=2, + dest="face_scale", + type=float, + default=0.0, + group=_("Face Processing"), + help=_("Scale the swapped face by this percentage. Positive values will enlarge the " + "face, Negative values will shrink the face."))) argument_list.append(dict( opts=("-a", "--input-aligned-dir"), action=DirFullPaths, diff --git a/lib/convert.py b/lib/convert.py index fddecf20a8..b8fb05270f 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -94,6 +94,7 @@ def __init__(self, self._configfile = configfile self._scale = arguments.output_scale / 100 + self._face_scale = 1.0 - self._args.face_scale / 100. self._adjustments = Adjustments() self._load_plugins() @@ -117,6 +118,7 @@ def reinitialize(self, config: FaceswapConfig) -> None: Pre-loaded :class:`lib.config.FaceswapConfig`. used over any configuration on disk. """ logger.debug("Reinitializing converter") + self._face_scale = 1.0 - self._args.face_scale / 100. self._adjustments = Adjustments() self._load_plugins(config=config, disable_logging=True) logger.debug("Reinitialized converter") @@ -289,8 +291,15 @@ def _get_new_image(self, predicted_mask) # Warp face with the mask + if self._face_scale == 1.0: + mat = reference_face.adjusted_matrix + else: + mat = reference_face.adjusted_matrix * self._face_scale + patch_center = (new_face.shape[1] / 2, new_face.shape[0] / 2) + mat[..., 2] += (1 - self._face_scale) * np.array(patch_center) + cv2.warpAffine(new_face, - reference_face.adjusted_matrix, + mat, frame_size, placeholder, flags=cv2.WARP_INVERSE_MAP | interpolator, diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo index 295cd69f56a74ec6bd374a1609102f5bb7839da2..f6c2c78ea7dd639d887ac02ef698c2cfb1ad2804 100755 GIT binary patch delta 2882 zcma)-X>1f_7=YhyX$w-JltVm#!AcP*6^aK`vE?ctE%%|;AYXT<+kxrqGBevk6&4VX zQy?or!CQYQC%ks+|~Dv{HS zUq47>JRDd`ATR=JUMPq zxltnbvcBkgksmqmfw81Gmoij|R4_kd0uEE7HIqboqo0^6lCSMfV;{_|#d)3j&AfPe zpbl?gE5x?!huDzgum+Y+7vWEu`56yigR9|R@DL8Ho+0uC96bv%{@HAiSK-Pz0W0T< zxXk|obuU@-ssAG^ShWD7Z~$ExhFjqZ_#>omBz7Bp0PoIn4b+irfFwY+!On0Oq(9}c ztiBiC&OvGTBC^lV_hz!AA0eS0j(*q}IvF`k0k8UJCa$X>YG6)_kuqptO^&JXxZ zBFJnWb+2YTc@HlOc{#RBWHK8|+Vr&$Spw(7eRpA$@euW(P2?wdEv#P2&43BWRhRGK zAMkfrObvb`v!~FzZWLJ?68RaPKyTZ^)q`p)0fnglko&l&Y&eMfx@+rp^MRmiS3N{Z z(N8`mGKU(if0BA5zJs)Z zjN30VMStoZ<^|hw0)9@!o1Uj$FnS>PVl}1%&xgH8RIDrJD|3YTu7`qJzIj;Wb;kR= zB0?4AV@UR;ng-eo(@-~Q6<0y`+GMDkw@z~qFLQa(%}@{Z!e&T9Bmpmm$yZaG@@jG< z^?qK@x_Jy3$TUPhvyh%hu2y&^A~zt_h&HCbOhrf;as@IJ(T~38Gg2e-d*;kcP0BCn z-5FgwQGw_>k3;6BR^$(!r;|1x(N7(6Gtw6skCY?*5$cus52AZrKQ|#)XWq{J^x{>A zbsaJ}bvD1G--7Jqlq@fSqqFa`Q)3DS46n&*ld`-KRw5ISDugedOhqn3rl;2xJk=?r zn$yDyXH_S|$~FzZ$uzuH6_1+{qfv!TqhUE?mS@CGH*7k-ikjud%$R5SR*Px0C_7<# zMyqAphUwVKjRqF<&N5@V8CCyT<~6yNvm|qlUR5r0OyARL>et%7={>a{79{6+ft6ZQ zxsK&TjoHdKjT*~|n6BZ+jELohV=ej}XJc{SYF4&kcPw`G;>H-WuCny@a-#;#F}jMYb5N!T+GxxP@@++(m>c-rtek|h?d3)|<}_N-gp1Yl z^Ko^@jB23BmJ=k$@B-uBkaAg|+_(ol`uyPePeL6%nH&`m7FdS_^;uxk!X5+D-a~9J%wJ}dO z4UW@MK^HNiqONix%58t#F|zi`4EtY8ouHE#VVdX?Vasg~J?^Bs>?=+0+1t`3InTFj zi*_|_V|>C3s|Y2JkR9bIO%-=dCt|g!#rUJ$jhJ3I(coIHws@gc?deQo1*!A&;*3A8jaHh?at5el+@PusN=uyvCT&hf%MyAL9d)m3fI@+qc hE_%?0&D!78BRfhv+jN=f3@J|+z4(17wd3$7e*=qLth4|C delta 2056 zcmai#d2Ccw6o-E;ZHLl=l$L#awn!BiYzqYhN?A(FR$AyHP(Y@gp^Q#v>YEwLCRITw zLStpbAOsLFaRUSfD8XoqF_?%6M$kZlB$}uZ8^91$3_-uwH-G3~-t;%$J?Ad(o_o%F zZT%zu#_jm#>%9_oiS{HKgf7QR&2UE|KeQHy6poX&!9MWsuF^;N`*f2M;AJ=&UWG4P z{uJp1JP4nF6{*q+*0sXVuy1ykR>Eix=^^V;1Nd#iF#?9*A^08a+f(9CcljC321#jB zF3j&Gu~zlz(gU~}ZowbUkWORY?=9_vC;LcyV0mAui1Ai(MKERH+ zVxY8_flKfn3~(JYSm+&W^XL$%4*zdMrT+M<3~2%kJS@$@|4ydV75ff+4BpR@Zr~sO zh%_Ch4JQy-35($-=&-K(3py9z*RYgp-k&XfgWbXG0@Cs6UKZHo-GI_#Qk2kC#LI6$_;g;qNh?tgvgxU&&thU#gNC;IL}) zUq@$WHBM^*d=74)8mSlk1+6-CEV9`hS`nl}EARo3dQ>J%h9hHkK3u|qrohASjm1(K zEU1yLGp}{2y`OIx_isgHE@Kir4bQ={<#xo~p5jel;bM3d|4%C@IQWUnF3$0_c0{w_ zEBJqgY0PgTD(X<%+|rBK2cUI;%$1DeH|y;h+1yNL7X!yu**PwH+CD(WGvu9#7hwzI zJAKk?*aQ9c)g6aR@ec_|ZTQzWO7FqcXC(uFJFJ0MOq*jv_RY41rK$LP5XExXT-GF& zu<+O#>vbRnuo{kAC*8*1x?Z9zv~dG@hZo>lXyqvdtb_OA7TAj%Y}zROg8lkdX>+{v zd<(aRow!~49CpAK{HJ#CdM12d%03<^8kfJ^3x7Ui?BU!+d2RE zT?&nj&cg_7B5bQzQ{LxYhh>nu)0>dG(;Z0KRdPg{1rNi+umfJR-v73vd`O76;RE{% zO@u(0v&}`Hb zjY5Oa2xPT!s5CMyDLt+%Qk9ftJ-71|c~*xu6;(u9ld`L<%d0|Gn~MriKQs-EMne#J z>imykZDSpMGI|vKyJcW>Mfs>WlI+MDTpb%OiLo!75bH09%ytZQ&Wu?_F;0RL(R4H= z=ChK>CtLHPyBu#N#5cI2lTsH$z{6;Wr_`>xrv-8N2 u1qY2W?r@{eTjz4S4EKM|-{>-3hQ}W=J${!-$5`z$y{@3a5hEwgUHb>UIB)R) diff --git a/locales/es/LC_MESSAGES/lib.cli.args.po b/locales/es/LC_MESSAGES/lib.cli.args.po index 87e031fe8b..a8485ba593 100755 --- a/locales/es/LC_MESSAGES/lib.cli.args.po +++ b/locales/es/LC_MESSAGES/lib.cli.args.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-11-20 01:34+0000\n" -"PO-Revision-Date: 2022-11-20 01:35+0000\n" +"POT-Creation-Date: 2023-09-08 22:10+0100\n" +"PO-Revision-Date: 2023-09-08 22:12+0100\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es\n" @@ -16,14 +16,14 @@ 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.3.2\n" -#: lib/cli/args.py:193 lib/cli/args.py:203 lib/cli/args.py:211 -#: lib/cli/args.py:221 +#: lib/cli/args.py:192 lib/cli/args.py:202 lib/cli/args.py:210 +#: lib/cli/args.py:220 msgid "Global Options" msgstr "Opciones Globales" -#: lib/cli/args.py:194 +#: lib/cli/args.py:193 msgid "" "R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " "to any GPU(s) that you do not wish to be made available to Faceswap. " @@ -35,12 +35,12 @@ msgstr "" "con Faceswap. Marcar todas las GPUs forzará a Faceswap a usar sólo la CPU,\n" "L|{}" -#: lib/cli/args.py:204 +#: lib/cli/args.py:203 msgid "" "Optionally overide the saved config with the path to a custom config file." msgstr "Usar un fichero alternativo de configuración, almacenado en esta ruta." -#: lib/cli/args.py:212 +#: lib/cli/args.py:211 msgid "" "Log level. Stick with INFO or VERBOSE unless you need to file an error " "report. Be careful with TRACE as it will generate a lot of data" @@ -48,18 +48,18 @@ msgstr "" "Nivel de registro. Dejarlo en INFO o VERBOSE, a menos que necesite informar " "de un error. Tenga en cuenta que TRACE generará muchísima información" -#: lib/cli/args.py:222 +#: lib/cli/args.py:221 msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "" "Ruta para almacenar el fichero de registro. Dejarlo en blanco para " "almacenarlo en la carpeta pde instalación de faceswap" -#: lib/cli/args.py:320 lib/cli/args.py:329 lib/cli/args.py:337 -#: lib/cli/args.py:386 lib/cli/args.py:677 lib/cli/args.py:686 +#: lib/cli/args.py:319 lib/cli/args.py:328 lib/cli/args.py:336 +#: lib/cli/args.py:385 lib/cli/args.py:676 lib/cli/args.py:685 msgid "Data" msgstr "Datos" -#: lib/cli/args.py:321 +#: lib/cli/args.py:320 msgid "" "Input directory or video. Either a directory containing the image files you " "wish to process or path to a video file. NB: This should be the source video/" @@ -69,12 +69,12 @@ msgstr "" "imagen que desea procesar o la ruta a un archivo de vídeo. NB: Debe ser el " "vídeo/los fotogramas de origen, NO las caras de origen." -#: lib/cli/args.py:330 +#: lib/cli/args.py:329 msgid "Output directory. This is where the converted files will be saved." msgstr "" "Directorio de salida. Aquí es donde se guardarán los archivos convertidos." -#: lib/cli/args.py:338 +#: lib/cli/args.py:337 msgid "" "Optional path to an alignments file. Leave blank if the alignments file is " "at the default location." @@ -82,7 +82,7 @@ msgstr "" "Ruta opcional a un archivo de alineaciones. Dejar en blanco si el archivo de " "alineaciones está en la ubicación por defecto." -#: lib/cli/args.py:361 +#: lib/cli/args.py:360 msgid "" "Extract faces from image or video sources.\n" "Extraction plugins can be configured in the 'Settings' Menu" @@ -90,7 +90,7 @@ msgstr "" "Extrae caras de fuentes de imagen o video.\n" "Los plugins de extracción pueden ser configuradas en el menú de 'Ajustes'" -#: lib/cli/args.py:387 +#: lib/cli/args.py:386 msgid "" "R|If selected then the input_dir should be a parent folder containing " "multiple videos and/or folders of images you wish to extract from. The faces " @@ -100,14 +100,14 @@ msgstr "" "varios videos y/o carpetas de imágenes de las que desea extraer. Las caras " "se enviarán a subcarpetas separadas en output_dir." -#: lib/cli/args.py:396 lib/cli/args.py:412 lib/cli/args.py:424 -#: lib/cli/args.py:463 lib/cli/args.py:481 lib/cli/args.py:493 -#: lib/cli/args.py:502 lib/cli/args.py:511 lib/cli/args.py:696 -#: lib/cli/args.py:723 lib/cli/args.py:761 +#: lib/cli/args.py:395 lib/cli/args.py:411 lib/cli/args.py:423 +#: lib/cli/args.py:462 lib/cli/args.py:480 lib/cli/args.py:492 +#: lib/cli/args.py:501 lib/cli/args.py:510 lib/cli/args.py:695 +#: lib/cli/args.py:722 lib/cli/args.py:760 msgid "Plugins" msgstr "Extensiones" -#: lib/cli/args.py:397 +#: lib/cli/args.py:396 msgid "" "R|Detector to use. Some of these have configurable settings in '/config/" "extract.ini' or 'Settings > Configure Extract 'Plugins':\n" @@ -130,7 +130,7 @@ msgstr "" "detectar más caras y tiene menos falsos positivos que otros detectores " "basados en GPU, pero uso muchos más recursos." -#: lib/cli/args.py:413 +#: lib/cli/args.py:412 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " @@ -142,7 +142,7 @@ msgstr "" "pero es menos preciso. Elegir este si necesita rapidez y no usar la GPU.\n" "L|fan: El mejor alineador. Rápido en la GPU, y lento en la CPU." -#: lib/cli/args.py:425 +#: lib/cli/args.py:424 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -212,7 +212,7 @@ msgstr "" "referencia y la máscara se extiende hacia arriba en la frente.\n" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -#: lib/cli/args.py:464 +#: lib/cli/args.py:463 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -235,7 +235,7 @@ msgstr "" "L|hist: Iguala los histogramas de los canales RGB.\n" "L|mean: Normalizar los colores de la cara a la media." -#: lib/cli/args.py:482 +#: lib/cli/args.py:481 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -252,7 +252,7 @@ msgstr "" "más veces se vuelva a introducir la cara en el alineador, menos " "microfluctuaciones se producirán, pero la extracción será más larga." -#: lib/cli/args.py:494 +#: lib/cli/args.py:493 msgid "" "Re-feed the initially found aligned face through the aligner. Can help " "produce better alignments for faces that are rotated beyond 45 degrees in " @@ -263,7 +263,7 @@ msgstr "" "se giran más de 45 grados en el marco o se encuentran en ángulos extremos. " "Ralentiza la extracción." -#: lib/cli/args.py:503 +#: lib/cli/args.py:502 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -275,7 +275,7 @@ msgstr "" "un solo número para usar incrementos de ese tamaño hasta 360, o pase una " "lista de números para enumerar exactamente qué ángulos comprobar." -#: lib/cli/args.py:512 +#: lib/cli/args.py:511 msgid "" "Obtain and store face identity encodings from VGGFace2. Slows down extract a " "little, but will save time if using 'sort by face'" @@ -283,13 +283,13 @@ msgstr "" "Obtenga y almacene codificaciones de identidad facial de VGGFace2. Ralentiza " "un poco la extracción, pero ahorrará tiempo si usa 'sort by face'" -#: lib/cli/args.py:522 lib/cli/args.py:532 lib/cli/args.py:544 -#: lib/cli/args.py:557 lib/cli/args.py:798 lib/cli/args.py:812 -#: lib/cli/args.py:825 lib/cli/args.py:839 +#: lib/cli/args.py:521 lib/cli/args.py:531 lib/cli/args.py:543 +#: lib/cli/args.py:556 lib/cli/args.py:797 lib/cli/args.py:812 +#: lib/cli/args.py:822 lib/cli/args.py:835 lib/cli/args.py:849 msgid "Face Processing" msgstr "Proceso de Caras" -#: lib/cli/args.py:523 +#: lib/cli/args.py:522 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -298,7 +298,7 @@ msgstr "" "a lo largo de la diagonal del cuadro delimitador. Establecer a 0 para " "desactivar" -#: lib/cli/args.py:533 +#: lib/cli/args.py:532 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -311,7 +311,7 @@ msgstr "" "contenga las imágenes requeridas o múltiples archivos de imágenes, separados " "por espacios." -#: lib/cli/args.py:545 +#: lib/cli/args.py:544 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -324,7 +324,7 @@ msgstr "" "contenga las imágenes requeridas o múltiples archivos de imágenes, separados " "por espacios." -#: lib/cli/args.py:558 +#: lib/cli/args.py:557 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." @@ -332,12 +332,12 @@ msgstr "" "Para usar con los archivos nfilter/filter opcionales. Umbral para el " "reconocimiento facial positivo. Los valores más altos son más estrictos." -#: lib/cli/args.py:567 lib/cli/args.py:579 lib/cli/args.py:591 -#: lib/cli/args.py:603 +#: lib/cli/args.py:566 lib/cli/args.py:578 lib/cli/args.py:590 +#: lib/cli/args.py:602 msgid "output" msgstr "salida" -#: lib/cli/args.py:568 +#: lib/cli/args.py:567 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -347,7 +347,7 @@ msgstr "" "pretende entrenar admite el tamaño deseado. Esto sólo tendrá que ser " "cambiado para los modelos de alta resolución." -#: lib/cli/args.py:580 +#: lib/cli/args.py:579 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -357,7 +357,7 @@ msgstr "" "extraer las caras. Por ejemplo, un valor de 1 extraerá las caras de cada " "fotograma, un valor de 10 extraerá las caras de cada 10 fotogramas." -#: lib/cli/args.py:592 +#: lib/cli/args.py:591 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -373,18 +373,18 @@ msgstr "" "ADVERTENCIA: No interrumpa el script al escribir el archivo porque podría " "corromperse. Poner a 0 para desactivar" -#: lib/cli/args.py:604 +#: lib/cli/args.py:603 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" "Dibujar puntos de referencia en las caras de salida para fines de depuración." -#: lib/cli/args.py:610 lib/cli/args.py:619 lib/cli/args.py:627 -#: lib/cli/args.py:634 lib/cli/args.py:852 lib/cli/args.py:863 -#: lib/cli/args.py:871 lib/cli/args.py:890 lib/cli/args.py:896 +#: lib/cli/args.py:609 lib/cli/args.py:618 lib/cli/args.py:626 +#: lib/cli/args.py:633 lib/cli/args.py:862 lib/cli/args.py:873 +#: lib/cli/args.py:881 lib/cli/args.py:900 lib/cli/args.py:906 msgid "settings" msgstr "ajustes" -#: lib/cli/args.py:611 +#: lib/cli/args.py:610 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the same time. " @@ -394,7 +394,7 @@ msgstr "" "extracción por separado (una tras otra) en lugar de hacerlo todo al mismo " "tiempo. Útil si la VRAM es escasa." -#: lib/cli/args.py:620 +#: lib/cli/args.py:619 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -402,19 +402,19 @@ msgstr "" "Omite los fotogramas que ya han sido extraídos y que existen en el archivo " "de alineaciones" -#: lib/cli/args.py:628 +#: lib/cli/args.py:627 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" "Omitir los fotogramas que ya tienen caras detectadas en el archivo de " "alineaciones" -#: lib/cli/args.py:635 +#: lib/cli/args.py:634 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "No guardar las caras detectadas en el disco. Crear sólo un archivo de " "alineaciones" -#: lib/cli/args.py:657 +#: lib/cli/args.py:656 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -424,7 +424,7 @@ msgstr "" "Los plugins de conversión pueden ser configurados en el menú " "\"Configuración\"" -#: lib/cli/args.py:678 +#: lib/cli/args.py:677 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -434,7 +434,7 @@ msgstr "" "original del que se extrajeron los fotogramas de origen (para extraer los " "fps y el audio)." -#: lib/cli/args.py:687 +#: lib/cli/args.py:686 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -442,7 +442,7 @@ msgstr "" "Directorio del modelo. El directorio que contiene el modelo entrenado que " "desea utilizar para la conversión." -#: lib/cli/args.py:697 +#: lib/cli/args.py:696 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -482,7 +482,7 @@ msgstr "" "colores. Generalmente no da resultados muy satisfactorios.\n" "L|none: No realice el ajuste de color." -#: lib/cli/args.py:724 +#: lib/cli/args.py:723 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -558,7 +558,7 @@ msgstr "" "L|predicted: Si la opción 'Learn Mask' se habilitó durante el entrenamiento, " "esto usará la máscara que fue creada por el modelo entrenado." -#: lib/cli/args.py:762 +#: lib/cli/args.py:761 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -584,11 +584,11 @@ msgstr "" "L|pillow: [images] Más lento que opencv, pero tiene más opciones y soporta " "más formatos." -#: lib/cli/args.py:781 lib/cli/args.py:788 lib/cli/args.py:882 +#: lib/cli/args.py:780 lib/cli/args.py:787 lib/cli/args.py:892 msgid "Frame Processing" msgstr "Proceso de fotogramas" -#: lib/cli/args.py:782 +#: lib/cli/args.py:781 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -598,7 +598,7 @@ msgstr "" "a los fotogramas a las dimensiones de origen. 50%% a la mitad de tamaño. " "200%% al doble de tamaño" -#: lib/cli/args.py:789 +#: lib/cli/args.py:788 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -612,7 +612,7 @@ msgstr "" "imágenes, ¡los nombres de los archivos deben terminar con el número de " "fotograma!" -#: lib/cli/args.py:799 +#: lib/cli/args.py:798 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -630,6 +630,14 @@ msgstr "" #: lib/cli/args.py:813 msgid "" +"Scale the swapped face by this percentage. Positive values will enlarge the " +"face, Negative values will shrink the face." +msgstr "" +"Escale la cara intercambiada según este porcentaje. Los valores positivos " +"agrandarán la cara, los valores negativos la reducirán." + +#: lib/cli/args.py:823 +msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " "image. Multiple images can be added space separated. NB: Using face filter " @@ -642,7 +650,7 @@ msgstr "" "uso del filtro de caras disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args.py:826 +#: lib/cli/args.py:836 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -656,7 +664,7 @@ msgstr "" "del filtro facial disminuirá significativamente la velocidad de extracción y " "no se puede garantizar su precisión." -#: lib/cli/args.py:840 +#: lib/cli/args.py:850 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -668,7 +676,7 @@ msgstr "" "NB: El uso del filtro facial disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args.py:853 +#: lib/cli/args.py:863 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -685,7 +693,7 @@ msgstr "" "procesos que los disponibles en su sistema. Si 'singleprocess' está " "habilitado, este ajuste será ignorado." -#: lib/cli/args.py:864 +#: lib/cli/args.py:874 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -693,7 +701,7 @@ msgstr "" "[LEGACY] Sólo es necesario seleccionar esta opción si se está cargando un " "modelo heredado si hay varios modelos en la carpeta de modelos" -#: lib/cli/args.py:872 +#: lib/cli/args.py:882 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -708,7 +716,7 @@ msgstr "" "de baja calidad. Si se encuentra un archivo de alineaciones, esta opción " "será ignorada." -#: lib/cli/args.py:883 +#: lib/cli/args.py:893 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -716,16 +724,16 @@ msgstr "" "Cuando se usa con --frame-ranges, la salida incluye los fotogramas no " "procesados en vez de descartarlos." -#: lib/cli/args.py:891 +#: lib/cli/args.py:901 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Intercambiar el modelo. En vez de convertir de A a B, convierte de B a A" -#: lib/cli/args.py:897 +#: lib/cli/args.py:907 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Desactiva el multiproceso. Es más lento, pero usa menos recursos." -#: lib/cli/args.py:913 +#: lib/cli/args.py:923 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -737,11 +745,11 @@ msgstr "" "hasta más de una semana.\n" "Los plugins de los modelos pueden configurarse en el menú \"Ajustes\"" -#: lib/cli/args.py:932 lib/cli/args.py:941 +#: lib/cli/args.py:942 lib/cli/args.py:951 msgid "faces" msgstr "caras" -#: lib/cli/args.py:933 +#: lib/cli/args.py:943 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -751,7 +759,7 @@ msgstr "" "para la cara A. Esta es la cara original, es decir, la cara que se quiere " "eliminar y sustituir por la cara B." -#: lib/cli/args.py:942 +#: lib/cli/args.py:952 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -761,12 +769,12 @@ msgstr "" "para la cara B. Esta es la cara de intercambio, es decir, la cara que se " "quiere colocar en la cabeza de la persona A." -#: lib/cli/args.py:950 lib/cli/args.py:962 lib/cli/args.py:978 -#: lib/cli/args.py:1003 lib/cli/args.py:1013 +#: lib/cli/args.py:960 lib/cli/args.py:972 lib/cli/args.py:988 +#: lib/cli/args.py:1013 lib/cli/args.py:1023 msgid "model" msgstr "modelo" -#: lib/cli/args.py:951 +#: lib/cli/args.py:961 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -780,7 +788,7 @@ msgstr "" "carpeta que no exista (que se creará). Si continúa entrenando un modelo " "existente, especifique la ubicación del modelo existente." -#: lib/cli/args.py:963 +#: lib/cli/args.py:973 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -804,7 +812,7 @@ msgstr "" "NB: Los pesos solo se pueden cargar desde modelos del mismo complemento que " "desea entrenar." -#: lib/cli/args.py:979 +#: lib/cli/args.py:989 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -849,7 +857,7 @@ msgstr "" "recursos (se necesita una GPU con una buena cantidad de VRAM). Bueno para " "los detalles, pero más susceptible a las diferencias de color." -#: lib/cli/args.py:1004 +#: lib/cli/args.py:1014 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -861,7 +869,7 @@ msgstr "" "muestra un resumen del modelo que crearía el complemento elegido y los " "ajustes de configuración." -#: lib/cli/args.py:1014 +#: lib/cli/args.py:1024 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -875,12 +883,12 @@ msgstr "" "congelará el codificador, pero algunos modelos pueden tener opciones de " "configuración para congelar otras capas." -#: lib/cli/args.py:1027 lib/cli/args.py:1039 lib/cli/args.py:1050 -#: lib/cli/args.py:1061 lib/cli/args.py:1144 +#: lib/cli/args.py:1037 lib/cli/args.py:1049 lib/cli/args.py:1063 +#: lib/cli/args.py:1078 lib/cli/args.py:1086 msgid "training" msgstr "entrenamiento" -#: lib/cli/args.py:1028 +#: lib/cli/args.py:1038 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -893,7 +901,7 @@ msgstr "" "momento es el doble del número que se establece aquí. Los lotes más grandes " "requieren más RAM de la GPU." -#: lib/cli/args.py:1040 +#: lib/cli/args.py:1050 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -908,15 +916,7 @@ msgstr "" "automáticamente en un número determinado de iteraciones, puede establecer " "ese valor aquí." -#: lib/cli/args.py:1051 -msgid "" -"[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " -"Mirrored Distrubution Strategy to train on multiple GPUs." -msgstr "" -"[Obsoleto: use '-D, --distribution-strategy' en su lugar] Use la estrategia " -"de distribución duplicada de Tensorflow para entrenar en varias GPU." - -#: lib/cli/args.py:1062 +#: lib/cli/args.py:1064 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -942,15 +942,38 @@ msgstr "" "locales. Se carga una copia del modelo y todas las variables en cada GPU con " "lotes distribuidos a cada GPU en cada iteración." -#: lib/cli/args.py:1079 lib/cli/args.py:1089 +#: lib/cli/args.py:1079 +msgid "" +"Disables TensorBoard logging. NB: Disabling logs means that you will not be " +"able to use the graph or analysis for this session in the GUI." +msgstr "" +"Desactiva el registro de TensorBoard. NB: Desactivar los registros significa " +"que no podrá utilizar el gráfico o el análisis de esta sesión en la GUI." + +#: lib/cli/args.py:1087 +msgid "" +"Use the Learning Rate Finder to discover the optimal learning rate for " +"training. For new models, this will calculate the optimal learning rate for " +"the model. For existing models this will use the optimal learning rate that " +"was discovered when initializing the model. Setting this option will ignore " +"the manually configured learning rate (configurable in train settings)." +msgstr "" +"Utilice el Buscador de tasa de aprendizaje para descubrir la tasa de " +"aprendizaje óptima para la capacitación. Para modelos nuevos, esto calculará " +"la tasa de aprendizaje óptima para el modelo. Para los modelos existentes, " +"esto utilizará la tasa de aprendizaje óptima que se descubrió al inicializar " +"el modelo. Configurar esta opción ignorará la tasa de aprendizaje " +"configurada manualmente (configurable en la configuración del tren)." + +#: lib/cli/args.py:1100 lib/cli/args.py:1110 msgid "Saving" msgstr "Guardar" -#: lib/cli/args.py:1080 +#: lib/cli/args.py:1101 msgid "Sets the number of iterations between each model save." msgstr "Establece el número de iteraciones entre cada guardado del modelo." -#: lib/cli/args.py:1090 +#: lib/cli/args.py:1111 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -958,11 +981,11 @@ msgstr "" "Establece el número de iteraciones antes de guardar una copia de seguridad " "del modelo en su estado actual. Establece 0 para que esté desactivado." -#: lib/cli/args.py:1097 lib/cli/args.py:1108 lib/cli/args.py:1119 +#: lib/cli/args.py:1118 lib/cli/args.py:1129 lib/cli/args.py:1140 msgid "timelapse" msgstr "intervalo" -#: lib/cli/args.py:1098 +#: lib/cli/args.py:1119 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -976,7 +999,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-B." -#: lib/cli/args.py:1109 +#: lib/cli/args.py:1130 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -990,7 +1013,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-A." -#: lib/cli/args.py:1120 +#: lib/cli/args.py:1141 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -1002,17 +1025,17 @@ msgstr "" "Si se suministran las carpetas de entrada pero no la carpeta de salida, se " "guardará por defecto en la carpeta del modelo /timelapse/" -#: lib/cli/args.py:1129 lib/cli/args.py:1136 +#: lib/cli/args.py:1150 lib/cli/args.py:1157 msgid "preview" msgstr "previsualización" -#: lib/cli/args.py:1130 +#: lib/cli/args.py:1151 msgid "Show training preview output. in a separate window." msgstr "" "Mostrar la salida de la vista previa del entrenamiento. en una ventana " "separada." -#: lib/cli/args.py:1137 +#: lib/cli/args.py:1158 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -1020,20 +1043,12 @@ msgstr "" "Escribe el resultado del entrenamiento en un archivo. La imagen se " "almacenará en la raíz de su carpeta FaceSwap." -#: lib/cli/args.py:1145 -msgid "" -"Disables TensorBoard logging. NB: Disabling logs means that you will not be " -"able to use the graph or analysis for this session in the GUI." -msgstr "" -"Desactiva el registro de TensorBoard. NB: Desactivar los registros significa " -"que no podrá utilizar el gráfico o el análisis de esta sesión en la GUI." - -#: lib/cli/args.py:1152 lib/cli/args.py:1161 lib/cli/args.py:1170 -#: lib/cli/args.py:1179 +#: lib/cli/args.py:1165 lib/cli/args.py:1174 lib/cli/args.py:1183 +#: lib/cli/args.py:1192 msgid "augmentation" msgstr "aumento" -#: lib/cli/args.py:1153 +#: lib/cli/args.py:1166 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -1043,7 +1058,7 @@ msgstr "" "conjunto de caras opuestas en lugar de deformar la cara al azar. Esta es la " "forma 'dfaker' de hacer la deformación." -#: lib/cli/args.py:1162 +#: lib/cli/args.py:1175 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -1054,7 +1069,7 @@ msgstr "" "general, esto debería dejarse sin efecto, excepto durante el 'entrenamiento " "de ajuste'." -#: lib/cli/args.py:1171 +#: lib/cli/args.py:1184 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -1064,7 +1079,7 @@ msgstr "" "diferencias de color entre los conjuntos A y B, con un mayor coste de tiempo " "de entrenamiento. Activa esta opción para desactivar el aumento de color." -#: lib/cli/args.py:1180 +#: lib/cli/args.py:1193 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -1077,10 +1092,18 @@ msgstr "" "esta opción desde el principio, es probable que arruine el modelo y se " "obtengan resultados terribles." -#: lib/cli/args.py:1205 +#: lib/cli/args.py:1218 msgid "Output to Shell console instead of GUI console" msgstr "Salida a la consola Shell en lugar de la consola GUI" +#~ msgid "" +#~ "[Deprecated - Use '-D, --distribution-strategy' instead] Use the " +#~ "Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." +#~ msgstr "" +#~ "[Obsoleto: use '-D, --distribution-strategy' en su lugar] Use la " +#~ "estrategia de distribución duplicada de Tensorflow para entrenar en " +#~ "varias GPU." + #~ msgid "" #~ "DEPRECATED - This option will be removed in a future update. Path to " #~ "alignments file for training set A. Defaults to /alignments.json " diff --git a/locales/kr/LC_MESSAGES/lib.cli.args.mo b/locales/kr/LC_MESSAGES/lib.cli.args.mo index 71f44264ea19f12d037837bf41b78bb6e6b862c7..953368f20786bf0937d1a6920c70bd795862bfd6 100644 GIT binary patch delta 2813 zcmZ{leQ;FO6~Iq6kYJ4^fIz5V&A!9zkwLe&-Si!T3Zzv{=4KeU3;6oB=1_Br?MI z{LhF?fbYQj;9uYoJ$|!DAMArGU}b^GF7|c7SJ3};i%2#6`Dd}EkGhL*BLgd85cb30 z!v%#R{K>>ABE=Y(0ZZVzsUqx^#%UOXhu|T`58f&=h+Z&*!{8;@33tsDd4Tzk;UVQG`FK<+BD}giUY^4&gx4CXpY) zGB)TeZ3_49Bx2PI(wzV;1~`{6U+6nU3*mmVkndc*l|iR^#_um|4! zgvgs(heT5%yC^O;UWK>9!@HegI)%5lFh8$Kx}xzd~L8tNkKB zf*-&K89zl{!|+|6)=}tbK$!mrdEba0a_^zS=?v}VsW7qhyZTv(JPT{#m8VGq<7N9P zMp#0`^n3Tg$6zm<3@06+z+e%)OfJUYZS22%m|W+IyvL724*J+pk@s1bLzqsx$p1Au zGs!{ZIgxv*(icvOe4na)yG!Iax}QpFAo=-cDJt~+=bhZod65Fd&_dV>{{*ouPZ1vN z=ol=At9~r<1Pnv6BJV)$`1%B*f9lTCi81g#T*Lt_eImcc(|+Rr3gb=0)rDR@YFZ=%(NK zrbtQNw4BY6b$N5A>*q5CDMc!fuOizb`|=iS-%3xv4c#^%_ajBf8l)JRjgYU*e~{@= zxBHNVncnpezt5Ss_GS3B$VYi|@8H#E+Jjlngv+!2Es+)ZcP`Nh)NP#u`3hW#tVPO@ zRmggTpK#fjJeYrGg3G8)-d*tU>QJTOF;%d}RDnjLuFkAd)kdYMcJF1(3aC2MUuk-S zhTAMwn|%Q*Xf>Fs!SK|Z0o7=EJj(QX48Pm4pm#1-8%?+If6D?je#`sq%sF~hv1~Pi z0j)+ptSCyJthkyV+8%JMRG5a}YkA#jixD(cx#g`g{VM2FRaT(V*P#12>#GY|wT4G| z#uxkb;%XnWe#6q+i&Z(A*KAa^zADobSnOOV<1}}!toP_0*KLJvXpEyW+Z}7>9xD*k zt~ny1}(#5eaG^K+!<%GrxBjaL760Iqdu<_ zZ_DlV`5nJ&4R5{S@$6NVK5w<d0zvHoRe8$*Kk}a2g(Cy0YX;cP}l8v_JA{a>t`ZTSIo&fJ!C$QbS=o-jg~V zSEGZ+?EZn&vA9Z|8?d`#D&5pOI-E$Iy{PQd3A;O*j-E@kwxn9m6f64&&)O|9HJW(N z?mB>WadeO^VU_NPrrHjqqp{Is_hMytwcT))JruPI4z*pkm#ppm`%R&Aw8K6!m^$07 z?8M9CCpY)no#!xUpXgQTlP#(49(|4-52vGXWe>*f?xu{d<9jo+iI?qYIF%f*!^3vx zaol~TMWxR7q>c{jwWIwbc4uOI;KVVNaXGF&&D>}*Y&R#;(a2bPQRsiSYi9;p@HX9X zJRR*;sc0KvjgR*C>PUW?gV|eMk0;5nV}tg=SjPYGnM}B2Btj9vRC|XVznICV5c{?l6e2z&A)QY^d-N5U&`lWBic zItq8gVK6^YdWm)Q@Kf}gZKU}y@R;Kfs}Zc+*M-@8klu{#WDDD*rjO~$^jhtwMVHtYo-q)FGY_k3C! z2ix`JB5)SWgX^Hx4D}ZbzJ%XGJ7M0HE`5g{!h00y_@uvd5nThN3*c)w4|~%f={5Y7 z#i96M?I=RrN?M+cEA!bo9GDz=$4ed2zn?@DaSu&l9lQ;zD;TVp8s0;23*@4fh<0JUqn4bv zk``~2V$f^rq+9rHs>jztk#45N6Ucw+R%r-^!8Ge;siN=+^y_ z+`ms)&a%-_$X;@{L=N}>=AjQiD3L9#fka=|pn2dt!Z!)-f!kpS_Ofu=^@pXeIN{-= zkzc4)#NUFR^MTY2<`G}>7tDD=`izC2pQJ!x-6`@36HYURKDY_SoQWK?4kn=cqk2=6 zdtoB>1E$`Q!KtX>=&halOA6)~@9 zR$gBu73qzbPZnbLkBv`?neKPQrR zP9xox+t;w!N?jE`uWMeV&*gF37~-*@D&26oD}2u4(xR}d?`5Yk%jvH0c;}aSmKt`K z*X!}JQLeeRGP2glkFGU*9)qFFZFtyus0iGe9T(Gfpe-ZAHZaS` z$QquJ(PzLge{A9Tz{SGk{J`Qve9m|e@`2%TOE46`F{=^TH6w8++XzYIfI8! z1>bHlf+uQ1YmXSA=9=KHi-DL<951wOOQ>e2abrhqFt&N$jRybC Hvp@U`#=nbZ diff --git a/locales/kr/LC_MESSAGES/lib.cli.args.po b/locales/kr/LC_MESSAGES/lib.cli.args.po index d99a0948f3..3acf173b08 100644 --- a/locales/kr/LC_MESSAGES/lib.cli.args.po +++ b/locales/kr/LC_MESSAGES/lib.cli.args.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-11-20 01:34+0000\n" -"PO-Revision-Date: 2022-11-26 16:11+0900\n" +"POT-Creation-Date: 2023-09-08 22:10+0100\n" +"PO-Revision-Date: 2023-09-08 22:14+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: ko_KR\n" @@ -16,14 +16,14 @@ 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.2\n" +"X-Generator: Poedit 3.3.2\n" -#: lib/cli/args.py:193 lib/cli/args.py:203 lib/cli/args.py:211 -#: lib/cli/args.py:221 +#: lib/cli/args.py:192 lib/cli/args.py:202 lib/cli/args.py:210 +#: lib/cli/args.py:220 msgid "Global Options" msgstr "전역 옵션들" -#: lib/cli/args.py:194 +#: lib/cli/args.py:193 msgid "" "R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " "to any GPU(s) that you do not wish to be made available to Faceswap. " @@ -35,12 +35,12 @@ msgstr "" "여금 CPU mode를 강제로 사용하게 합니다.\n" "L|{}" -#: lib/cli/args.py:204 +#: lib/cli/args.py:203 msgid "" "Optionally overide the saved config with the path to a custom config file." msgstr "선택적으로 저장된 설정을 경로와 함께 개인 설정 파일에 덮어씌웁니다." -#: lib/cli/args.py:212 +#: lib/cli/args.py:211 msgid "" "Log level. Stick with INFO or VERBOSE unless you need to file an error " "report. Be careful with TRACE as it will generate a lot of data" @@ -48,16 +48,16 @@ msgstr "" "로그 레벨. 오류 리포트가 필요하지 않다면 INFO와 VERBOSE를 사용하세요. 단, 굉" "장히 많은 데이터를 생성할 수 있는 TRACE는 조심하세요" -#: lib/cli/args.py:222 +#: lib/cli/args.py:221 msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "로그파일을 저장할 경로. faceswap 폴더에 저장하고 싶으면 비워두세요" -#: lib/cli/args.py:320 lib/cli/args.py:329 lib/cli/args.py:337 -#: lib/cli/args.py:386 lib/cli/args.py:677 lib/cli/args.py:686 +#: lib/cli/args.py:319 lib/cli/args.py:328 lib/cli/args.py:336 +#: lib/cli/args.py:385 lib/cli/args.py:676 lib/cli/args.py:685 msgid "Data" msgstr "데이터" -#: lib/cli/args.py:321 +#: lib/cli/args.py:320 msgid "" "Input directory or video. Either a directory containing the image files you " "wish to process or path to a video file. NB: This should be the source video/" @@ -66,11 +66,11 @@ msgstr "" "폴더나 비디오를 입력하세요. 당신이 사용하고 싶은 이미지 파일들을 가진 폴더 또" "는 비디오 파일의 경로여야 합니다. NB: 이 폴더는 원본 비디오여야 합니다." -#: lib/cli/args.py:330 +#: lib/cli/args.py:329 msgid "Output directory. This is where the converted files will be saved." msgstr "출력 폴더. 변환된 파일들이 저장될 곳입니다." -#: lib/cli/args.py:338 +#: lib/cli/args.py:337 msgid "" "Optional path to an alignments file. Leave blank if the alignments file is " "at the default location." @@ -78,7 +78,7 @@ msgstr "" "(선택적) alignments 파일의 경로. 비워두면 alignments 파일이 기본 위치에 저장" "됩니다." -#: lib/cli/args.py:361 +#: lib/cli/args.py:360 msgid "" "Extract faces from image or video sources.\n" "Extraction plugins can be configured in the 'Settings' Menu" @@ -86,7 +86,7 @@ msgstr "" "얼굴들을 이미지 또는 비디오에서 추출합니다.\n" "추출 플러그인은 '설정' 메뉴에서 설정할 수 있습니다" -#: lib/cli/args.py:387 +#: lib/cli/args.py:386 msgid "" "R|If selected then the input_dir should be a parent folder containing " "multiple videos and/or folders of images you wish to extract from. The faces " @@ -96,14 +96,14 @@ msgstr "" "또는 이미지들을 가진 부모 폴더가 되야 합니다. 얼굴들은 output_dir에 분리된 하" "위 폴더에 저장됩니다." -#: lib/cli/args.py:396 lib/cli/args.py:412 lib/cli/args.py:424 -#: lib/cli/args.py:463 lib/cli/args.py:481 lib/cli/args.py:493 -#: lib/cli/args.py:502 lib/cli/args.py:511 lib/cli/args.py:696 -#: lib/cli/args.py:723 lib/cli/args.py:761 +#: lib/cli/args.py:395 lib/cli/args.py:411 lib/cli/args.py:423 +#: lib/cli/args.py:462 lib/cli/args.py:480 lib/cli/args.py:492 +#: lib/cli/args.py:501 lib/cli/args.py:510 lib/cli/args.py:695 +#: lib/cli/args.py:722 lib/cli/args.py:760 msgid "Plugins" msgstr "플러그인들" -#: lib/cli/args.py:397 +#: lib/cli/args.py:396 msgid "" "R|Detector to use. Some of these have configurable settings in '/config/" "extract.ini' or 'Settings > Configure Extract 'Plugins':\n" @@ -126,7 +126,7 @@ msgstr "" "보다 더 많은 얼굴들을 감지할 수 있고 과 더 적은 false positives를 돌려주지만 " "자원을 굉장히 많이 사용합니다." -#: lib/cli/args.py:413 +#: lib/cli/args.py:412 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " @@ -138,7 +138,7 @@ msgstr "" "합니다. GPU를 사용하지 않고 시간이 중요할 때에만 사용하세요.\n" "L|fan: 가장 좋은 aligner. GPU에선 빠르고 CPU에선 느립니다." -#: lib/cli/args.py:425 +#: lib/cli/args.py:424 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -199,7 +199,7 @@ msgstr "" "로 뻗어 있습ㄴ다.\n" "(예: '-M unet-dfl vgg-clear', '--masker vgg-obstructed')" -#: lib/cli/args.py:464 +#: lib/cli/args.py:463 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -220,7 +220,7 @@ msgstr "" "L|hist: RGB 채널의 히스토그램을 동일하게 합니다.\n" "L|mean: 얼굴 색상을 평균으로 정규화합니다." -#: lib/cli/args.py:482 +#: lib/cli/args.py:481 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -235,7 +235,7 @@ msgstr "" "다. 얼굴이 aligner에 다시 공급되는 횟수가 많을수록 micro-jitter 적게 발생하지" "만 추출에 더 오랜 시간이 걸립니다." -#: lib/cli/args.py:494 +#: lib/cli/args.py:493 msgid "" "Re-feed the initially found aligned face through the aligner. Can help " "produce better alignments for faces that are rotated beyond 45 degrees in " @@ -245,7 +245,7 @@ msgstr "" "회전하거나 극단적인 각도에 있는 얼굴을 더 잘 정렬할 수 있습니다. 추출 속도가 " "느려집니다." -#: lib/cli/args.py:503 +#: lib/cli/args.py:502 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -256,7 +256,7 @@ msgstr "" "면서 더 많은 얼굴을 찾을 수 있습니다. 단일 숫자를 입력하여 해당 크기의 증분" "을 360까지 사용하거나 숫자 목록을 입력하여 확인할 각도를 정확하게 열거합니다." -#: lib/cli/args.py:512 +#: lib/cli/args.py:511 msgid "" "Obtain and store face identity encodings from VGGFace2. Slows down extract a " "little, but will save time if using 'sort by face'" @@ -264,13 +264,13 @@ msgstr "" "VGGFace2에서 얼굴 식별 인코딩을 가져와 저장합니다. 추출 속도를 약간 늦추지만 " "'얼굴별로 정렬'을 사용하면 시간을 절약할 수 있습니다." -#: lib/cli/args.py:522 lib/cli/args.py:532 lib/cli/args.py:544 -#: lib/cli/args.py:557 lib/cli/args.py:798 lib/cli/args.py:812 -#: lib/cli/args.py:825 lib/cli/args.py:839 +#: lib/cli/args.py:521 lib/cli/args.py:531 lib/cli/args.py:543 +#: lib/cli/args.py:556 lib/cli/args.py:797 lib/cli/args.py:812 +#: lib/cli/args.py:822 lib/cli/args.py:835 lib/cli/args.py:849 msgid "Face Processing" msgstr "얼굴 처리" -#: lib/cli/args.py:523 +#: lib/cli/args.py:522 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -278,7 +278,7 @@ msgstr "" "이 크기 미만으로 탐지된 얼굴을 필터링합니다. 길이, 경계 상자의 대각선에 걸친 " "픽셀 단위입니다. 0으로 설정하면 꺼집니다" -#: lib/cli/args.py:533 +#: lib/cli/args.py:532 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -290,7 +290,7 @@ msgstr "" "지들 또는 공백으로 구분된 여러 이미지 파일이 들어 있는 폴더를 선택할 수 있습" "니다." -#: lib/cli/args.py:545 +#: lib/cli/args.py:544 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -301,7 +301,7 @@ msgstr "" "와 조건이 다른 작은 다양한 이미지여야 합니다. 추출할 때 필요한 이미지들 또는 " "공백으로 구분된 여러 이미지 파일이 들어 있는 폴더를 선택할 수 있습니다." -#: lib/cli/args.py:558 +#: lib/cli/args.py:557 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." @@ -309,12 +309,12 @@ msgstr "" "옵션인 nfilter/filter 파일과 함께 사용합니다. 긍정적인 얼굴 인식을 위한 임계" "값. 값이 높을수록 엄격합니다." -#: lib/cli/args.py:567 lib/cli/args.py:579 lib/cli/args.py:591 -#: lib/cli/args.py:603 +#: lib/cli/args.py:566 lib/cli/args.py:578 lib/cli/args.py:590 +#: lib/cli/args.py:602 msgid "output" msgstr "출력" -#: lib/cli/args.py:568 +#: lib/cli/args.py:567 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -323,7 +323,7 @@ msgstr "" "추출된 얼굴의 출력 크기입니다. 훈련하려는 모델이 필요한 크기를 지원하는지 꼭 " "확인하세요. 이것은 고해상도 모델에 대해서만 변경하면 됩니다." -#: lib/cli/args.py:580 +#: lib/cli/args.py:579 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -333,7 +333,7 @@ msgstr "" "설정합니다. 예를 들어, 값이 1이면 모든 프레임에서 얼굴이 추출되고, 값이 10이" "면 모든 10번째 프레임에서 얼굴이 추출됩니다." -#: lib/cli/args.py:592 +#: lib/cli/args.py:591 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -348,42 +348,42 @@ msgstr "" "을 쓸 때 스크립트가 손상될 수 있으므로 스크립트를 중단하지 마십시오. 해제하려" "면 0으로 설정" -#: lib/cli/args.py:604 +#: lib/cli/args.py:603 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "디버깅을 위해 출력 얼굴에 특징점을 그립니다." -#: lib/cli/args.py:610 lib/cli/args.py:619 lib/cli/args.py:627 -#: lib/cli/args.py:634 lib/cli/args.py:852 lib/cli/args.py:863 -#: lib/cli/args.py:871 lib/cli/args.py:890 lib/cli/args.py:896 +#: lib/cli/args.py:609 lib/cli/args.py:618 lib/cli/args.py:626 +#: lib/cli/args.py:633 lib/cli/args.py:862 lib/cli/args.py:873 +#: lib/cli/args.py:881 lib/cli/args.py:900 lib/cli/args.py:906 msgid "settings" msgstr "설정" -#: lib/cli/args.py:611 +#: lib/cli/args.py:610 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " -"process separately (one after the other) rather than all at the smae time. " +"process separately (one after the other) rather than all at the same time. " "Useful if VRAM is at a premium." msgstr "" "추출을 병렬로 실행하지 마십시오. 추출 프로세스의 각 부분을 동시에 모두 실행하" "는 것이 아니라 개별적으로(하나씩) 실행합니다. VRAM이 프리미엄인 경우 유용합니" "다." -#: lib/cli/args.py:620 +#: lib/cli/args.py:619 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" msgstr "이미 추출되었거나 alignments 파일에 존재하는 프레임들을 스킵합니다" -#: lib/cli/args.py:628 +#: lib/cli/args.py:627 msgid "Skip frames that already have detected faces in the alignments file" msgstr "이미 얼굴을 탐지하여 alignments 파일에 존재하는 프레임들을 스킵합니다" -#: lib/cli/args.py:635 +#: lib/cli/args.py:634 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "탐지된 얼굴을 디스크에 저장하지 않습니다. 그저 alignments 파일을 만듭니다" -#: lib/cli/args.py:657 +#: lib/cli/args.py:656 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -391,7 +391,7 @@ msgstr "" "원본 비디오/이미지의 원래 얼굴을 최종 얼굴으로 바꿉니다.\n" "변환 플러그인은 '설정' 메뉴에서 구성할 수 있습니다" -#: lib/cli/args.py:678 +#: lib/cli/args.py:677 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -400,14 +400,14 @@ msgstr "" "이미지에서 비디오로 변환하는 경우에만 필요합니다. 소스 프레임이 추출된 원본 " "비디오(fps 및 오디오 추출용)를 입력하세요." -#: lib/cli/args.py:687 +#: lib/cli/args.py:686 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." msgstr "" "모델 폴더. 당신이 변환에 사용하고자 하는 훈련된 모델을 가진 폴더입니다." -#: lib/cli/args.py:697 +#: lib/cli/args.py:696 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -443,7 +443,7 @@ msgstr "" "공하지 않습니다.\n" "L|none: 색상 조정을 수행하지 않습니다." -#: lib/cli/args.py:724 +#: lib/cli/args.py:723 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -507,7 +507,7 @@ msgstr "" "L|predicted: 교육 중에 'Learn Mask(마스크 학습)' 옵션이 활성화된 경우에는 교" "육을 받은 모델이 만든 마스크가 사용됩니다." -#: lib/cli/args.py:762 +#: lib/cli/args.py:761 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -531,11 +531,11 @@ msgstr "" "L|pillow: [images] opencv보다 느리지만 더 많은 옵션이 있고 더 많은 형식을 지" "원합니다." -#: lib/cli/args.py:781 lib/cli/args.py:788 lib/cli/args.py:882 +#: lib/cli/args.py:780 lib/cli/args.py:787 lib/cli/args.py:892 msgid "Frame Processing" msgstr "프레임 처리" -#: lib/cli/args.py:782 +#: lib/cli/args.py:781 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -544,7 +544,7 @@ msgstr "" "최종 출력 프레임의 크기를 이 양만큼 조정합니다. 100%%는 원본의 차원에서 프레" "임을 출력합니다. 50%%는 절반 크기에서, 200%%는 두 배 크기에서" -#: lib/cli/args.py:789 +#: lib/cli/args.py:788 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -556,7 +556,7 @@ msgstr "" "으면 선택한 범위를 벗어나는 프레임이 삭제됩니다. NB: 이미지에서 변환하는 경" "우 파일 이름은 프레임 번호로 끝나야 합니다!" -#: lib/cli/args.py:799 +#: lib/cli/args.py:798 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -572,6 +572,14 @@ msgstr "" #: lib/cli/args.py:813 msgid "" +"Scale the swapped face by this percentage. Positive values will enlarge the " +"face, Negative values will shrink the face." +msgstr "" +"이 백분율로 교체된 면의 크기를 조정합니다. 양수 값은 얼굴을 확대하고, 음수 값" +"은 얼굴을 축소합니다." + +#: lib/cli/args.py:823 +msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " "image. Multiple images can be added space separated. NB: Using face filter " @@ -583,7 +591,7 @@ msgstr "" "분하여 추가할 수 있습니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감소" "하므로 정확성을 보장할 수 없습니다." -#: lib/cli/args.py:826 +#: lib/cli/args.py:836 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -596,7 +604,7 @@ msgstr "" "가할 수 있습니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감소하므로 정" "확성을 보장할 수 없습니다." -#: lib/cli/args.py:840 +#: lib/cli/args.py:850 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -607,7 +615,7 @@ msgstr "" "값. 낮은 값이 더 엄격합니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감" "소하므로 정확성을 보장할 수 없습니다." -#: lib/cli/args.py:853 +#: lib/cli/args.py:863 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -623,7 +631,7 @@ msgstr "" "를 사용하려고 시도하지 않습니다. 단일 프로세스가 활성화된 경우 이 설정은 무시" "됩니다." -#: lib/cli/args.py:864 +#: lib/cli/args.py:874 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -631,7 +639,7 @@ msgstr "" "[LEGACY] 이것은 레거시 모델을 로드 중이거나 모델 폴더에 여러 모델이 있는 경우" "에만 선택되어야 합니다" -#: lib/cli/args.py:872 +#: lib/cli/args.py:882 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -645,7 +653,7 @@ msgstr "" "하고 표준 이하의 결과로 이어질 것입니다. alignments 파일이 발견되면 이 옵션" "은 무시됩니다." -#: lib/cli/args.py:883 +#: lib/cli/args.py:893 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -653,15 +661,15 @@ msgstr "" "사용시 --frame-ranges 인자를 사용하면 변경되지 않은 프레임을 버리지 않은 결과" "가 출력됩니다." -#: lib/cli/args.py:891 +#: lib/cli/args.py:901 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "모델을 바꿉니다. A -> B에서 변환하는 대신 B -> A로 변환" -#: lib/cli/args.py:897 +#: lib/cli/args.py:907 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "멀티프로세싱을 쓰지 않습니다. 느리지만 자원을 덜 소모합니다." -#: lib/cli/args.py:913 +#: lib/cli/args.py:923 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -672,11 +680,11 @@ msgstr "" "간이 필요합니다.\n" "모델 플러그인은 '설정' 메뉴에서 구성할 수 있습니다" -#: lib/cli/args.py:932 lib/cli/args.py:941 +#: lib/cli/args.py:942 lib/cli/args.py:951 msgid "faces" msgstr "얼굴들" -#: lib/cli/args.py:933 +#: lib/cli/args.py:943 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -685,7 +693,7 @@ msgstr "" "입력 디렉토리. 얼굴 A에 대한 훈련 이미지가 포함된 디렉토리입니다. 이것은 원" "래 얼굴, 즉 제거하고 B 얼굴로 대체하려는 얼굴입니다." -#: lib/cli/args.py:942 +#: lib/cli/args.py:952 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -694,12 +702,12 @@ msgstr "" "입력 디렉터리. 얼굴 B에 대한 훈련 이미지를 포함하는 디렉토리. 이것은 대체 얼" "굴, 즉 사람 A의 얼굴 앞에 배치하려는 얼굴이다." -#: lib/cli/args.py:950 lib/cli/args.py:962 lib/cli/args.py:978 -#: lib/cli/args.py:1003 lib/cli/args.py:1013 +#: lib/cli/args.py:960 lib/cli/args.py:972 lib/cli/args.py:988 +#: lib/cli/args.py:1013 lib/cli/args.py:1023 msgid "model" msgstr "모델" -#: lib/cli/args.py:951 +#: lib/cli/args.py:961 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -712,7 +720,7 @@ msgstr "" "성될 폴더)를 선택합니다. 기존 모델을 계속 학습하는 경우 기존 모델의 위치를 지" "정합니다." -#: lib/cli/args.py:963 +#: lib/cli/args.py:973 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -733,7 +741,7 @@ msgstr "" "중치 동결'이 필요합니다.\n" "주의: 가중치는 훈련하려는 플러그인 모델에서만 로드할 수 있습니다." -#: lib/cli/args.py:979 +#: lib/cli/args.py:989 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -775,7 +783,7 @@ msgstr "" "양의 VRAM이 있는 GPU가 필요합니다). 세부 사항에는 좋지만 색상 차이에 더 취약" "합니다." -#: lib/cli/args.py:1004 +#: lib/cli/args.py:1014 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -786,7 +794,7 @@ msgstr "" "시됩니다. 그렇지 않으면 선택한 플러그인 및 구성 설정에 의해 생성되는 모델 요" "약이 표시됩니다." -#: lib/cli/args.py:1014 +#: lib/cli/args.py:1024 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -799,12 +807,12 @@ msgstr "" "이렇게 하면 인코더가 고정되지만 일부 모델에는 다른 레이어를 고정하기 위한 구" "성 옵션이 있을 수 있습니다." -#: lib/cli/args.py:1027 lib/cli/args.py:1039 lib/cli/args.py:1050 -#: lib/cli/args.py:1061 lib/cli/args.py:1144 +#: lib/cli/args.py:1037 lib/cli/args.py:1049 lib/cli/args.py:1063 +#: lib/cli/args.py:1078 lib/cli/args.py:1086 msgid "training" msgstr "훈련" -#: lib/cli/args.py:1028 +#: lib/cli/args.py:1038 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -816,7 +824,7 @@ msgstr "" "여기에서 설정한 수의 두 배입니다. 더 큰 배치에는 더 많은 GPU RAM이 필요합니" "다." -#: lib/cli/args.py:1040 +#: lib/cli/args.py:1050 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -829,15 +837,7 @@ msgstr "" "다. 그러나 설정된 반복 횟수에서 모델이 자동으로 중지되도록 하려면 여기에서 해" "당 값을 설정할 수 있습니다." -#: lib/cli/args.py:1051 -msgid "" -"[Deprecated - Use '-D, --distribution-strategy' instead] Use the Tensorflow " -"Mirrored Distrubution Strategy to train on multiple GPUs." -msgstr "" -"[Deprecated - 대신 '-D, --distribution-strategy' 사용] Tensorflow 미러 분산 " -"전략을 사용하여 여러 GPU에서 훈련합니다." - -#: lib/cli/args.py:1062 +#: lib/cli/args.py:1064 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -858,15 +858,36 @@ msgstr "" "L|mirrored: 여러 로컬 GPU에서 동기화 분산 훈련을 지원합니다. 모델의 복사본과 " "모든 변수는 각 반복에서 각 GPU에 배포된 배치들와 함께 각 GPU에 로드됩니다." -#: lib/cli/args.py:1079 lib/cli/args.py:1089 +#: lib/cli/args.py:1079 +msgid "" +"Disables TensorBoard logging. NB: Disabling logs means that you will not be " +"able to use the graph or analysis for this session in the GUI." +msgstr "" +"텐서보드 로깅을 비활성화합니다. 주의: 로그를 비활성화하면 GUI에서 이 세션에 " +"대한 그래프 또는 분석을 사용할 수 없습니다." + +#: lib/cli/args.py:1087 +msgid "" +"Use the Learning Rate Finder to discover the optimal learning rate for " +"training. For new models, this will calculate the optimal learning rate for " +"the model. For existing models this will use the optimal learning rate that " +"was discovered when initializing the model. Setting this option will ignore " +"the manually configured learning rate (configurable in train settings)." +msgstr "" +"학습률 찾기를 사용하여 훈련을 위한 최적의 학습률을 찾아보세요. 새 모델의 경" +"우 모델에 대한 최적의 학습률을 계산합니다. 기존 모델의 경우 모델을 초기화할 " +"때 발견된 최적의 학습률을 사용합니다. 이 옵션을 설정하면 수동으로 구성된 학습" +"률(기차 설정에서 구성 가능)이 무시됩니다." + +#: lib/cli/args.py:1100 lib/cli/args.py:1110 msgid "Saving" msgstr "저장" -#: lib/cli/args.py:1080 +#: lib/cli/args.py:1101 msgid "Sets the number of iterations between each model save." msgstr "각 모델 저장 사이의 반복 횟수를 설정합니다." -#: lib/cli/args.py:1090 +#: lib/cli/args.py:1111 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -874,11 +895,11 @@ msgstr "" "현재 상태에서 모델의 백업 스냅샷을 저장하기 전에 반복할 횟수를 설정합니다. 0" "으로 설정하면 꺼집니다." -#: lib/cli/args.py:1097 lib/cli/args.py:1108 lib/cli/args.py:1119 +#: lib/cli/args.py:1118 lib/cli/args.py:1129 lib/cli/args.py:1140 msgid "timelapse" msgstr "타임랩스" -#: lib/cli/args.py:1098 +#: lib/cli/args.py:1119 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -891,7 +912,7 @@ msgstr "" "랩스를 만드는 데 사용할 'A' 얼굴의 입력 폴더여야 합니다. 또한 사용자는 --" "timelapse-output 및 --timelapse-input-B 매개 변수를 제공해야 합니다." -#: lib/cli/args.py:1109 +#: lib/cli/args.py:1130 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -904,7 +925,7 @@ msgstr "" "다. 타임 랩스를 만드는 데 사용할 'B' 얼굴의 입력 폴더여야 합니다. 또한 사용자" "는 --timelapse-output 및 --timelapse-input-A 매개 변수를 제공해야 합니다." -#: lib/cli/args.py:1120 +#: lib/cli/args.py:1141 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -916,35 +937,27 @@ msgstr "" "다. 입력 폴더가 제공되었지만 출력 폴더가 없는 경우 모델 폴더에 /timelapse/로 " "기본 설정됩니다" -#: lib/cli/args.py:1129 lib/cli/args.py:1136 +#: lib/cli/args.py:1150 lib/cli/args.py:1157 msgid "preview" msgstr "미리보기" -#: lib/cli/args.py:1130 +#: lib/cli/args.py:1151 msgid "Show training preview output. in a separate window." msgstr "훈련 미리보기 결과를 각기 다른 창에서 보여줍니다." -#: lib/cli/args.py:1137 +#: lib/cli/args.py:1158 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." msgstr "" "훈련 결과를 파일에 씁니다. 이미지는 Faceswap 폴더의 최상위 폴더에 저장됩니다." -#: lib/cli/args.py:1145 -msgid "" -"Disables TensorBoard logging. NB: Disabling logs means that you will not be " -"able to use the graph or analysis for this session in the GUI." -msgstr "" -"텐서보드 로깅을 비활성화합니다. 주의: 로그를 비활성화하면 GUI에서 이 세션에 " -"대한 그래프 또는 분석을 사용할 수 없습니다." - -#: lib/cli/args.py:1152 lib/cli/args.py:1161 lib/cli/args.py:1170 -#: lib/cli/args.py:1179 +#: lib/cli/args.py:1165 lib/cli/args.py:1174 lib/cli/args.py:1183 +#: lib/cli/args.py:1192 msgid "augmentation" msgstr "보정" -#: lib/cli/args.py:1153 +#: lib/cli/args.py:1166 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -953,7 +966,7 @@ msgstr "" "무작위로 얼굴을 변환하지 않고 반대쪽 얼굴 세트에서 특징점과 밀접하게 일치하도" "록 훈련 얼굴을 변환해줍니다. 이것은 변환하는 'dfaker' 방식이다." -#: lib/cli/args.py:1162 +#: lib/cli/args.py:1175 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -963,7 +976,7 @@ msgstr "" "런 일이 일어나지 않는 것이 바람직합니다. 일반적으로 'fit training' 중을 제외" "하고는 이 작업을 중단해야 합니다." -#: lib/cli/args.py:1171 +#: lib/cli/args.py:1184 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -973,7 +986,7 @@ msgstr "" "이 되며, 훈련 시간 비용이 증가합니다. 색상 보저를 사용하지 않으려면 이 옵션" "을 사용합니다." -#: lib/cli/args.py:1180 +#: lib/cli/args.py:1193 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -985,6 +998,13 @@ msgstr "" "면 됩니다. 처음부터 이 옵션을 활성화하면 모델이 죽을 수있고 끔찍한 결과를 초" "래할 수 있습니다." -#: lib/cli/args.py:1205 +#: lib/cli/args.py:1218 msgid "Output to Shell console instead of GUI console" msgstr "결과를 GUI 콘솔이 아닌 쉘 콘솔에 출력합니다" + +#~ msgid "" +#~ "[Deprecated - Use '-D, --distribution-strategy' instead] Use the " +#~ "Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." +#~ msgstr "" +#~ "[Deprecated - 대신 '-D, --distribution-strategy' 사용] Tensorflow 미러 분" +#~ "산 전략을 사용하여 여러 GPU에서 훈련합니다." diff --git a/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index e173bc5b30..cdc1d8c538 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 14:52+0100\n" +"POT-Creation-Date: 2023-09-08 22:10+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -188,8 +188,8 @@ msgid "" msgstr "" #: lib/cli/args.py:521 lib/cli/args.py:531 lib/cli/args.py:543 -#: lib/cli/args.py:556 lib/cli/args.py:797 lib/cli/args.py:811 -#: lib/cli/args.py:824 lib/cli/args.py:838 +#: lib/cli/args.py:556 lib/cli/args.py:797 lib/cli/args.py:812 +#: lib/cli/args.py:822 lib/cli/args.py:835 lib/cli/args.py:849 msgid "Face Processing" msgstr "" @@ -255,8 +255,8 @@ msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" #: lib/cli/args.py:609 lib/cli/args.py:618 lib/cli/args.py:626 -#: lib/cli/args.py:633 lib/cli/args.py:851 lib/cli/args.py:862 -#: lib/cli/args.py:870 lib/cli/args.py:889 lib/cli/args.py:895 +#: lib/cli/args.py:633 lib/cli/args.py:862 lib/cli/args.py:873 +#: lib/cli/args.py:881 lib/cli/args.py:900 lib/cli/args.py:906 msgid "settings" msgstr "" @@ -373,7 +373,7 @@ msgid "" "more formats." msgstr "" -#: lib/cli/args.py:780 lib/cli/args.py:787 lib/cli/args.py:881 +#: lib/cli/args.py:780 lib/cli/args.py:787 lib/cli/args.py:892 msgid "Frame Processing" msgstr "" @@ -402,7 +402,13 @@ msgid "" "alignments file." msgstr "" -#: lib/cli/args.py:812 +#: lib/cli/args.py:813 +msgid "" +"Scale the swapped face by this percentage. Positive values will enlarge the " +"face, Negative values will shrink the face." +msgstr "" + +#: lib/cli/args.py:823 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -411,7 +417,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args.py:825 +#: lib/cli/args.py:836 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -420,7 +426,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args.py:839 +#: lib/cli/args.py:850 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -428,7 +434,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args.py:852 +#: lib/cli/args.py:863 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -438,13 +444,13 @@ msgid "" "your system. If singleprocess is enabled this setting will be ignored." msgstr "" -#: lib/cli/args.py:863 +#: lib/cli/args.py:874 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" msgstr "" -#: lib/cli/args.py:871 +#: lib/cli/args.py:882 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -453,51 +459,51 @@ msgid "" "alignments file is found, this option will be ignored." msgstr "" -#: lib/cli/args.py:882 +#: lib/cli/args.py:893 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." msgstr "" -#: lib/cli/args.py:890 +#: lib/cli/args.py:901 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" -#: lib/cli/args.py:896 +#: lib/cli/args.py:907 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "" -#: lib/cli/args.py:912 +#: lib/cli/args.py:923 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" "Model plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:931 lib/cli/args.py:940 +#: lib/cli/args.py:942 lib/cli/args.py:951 msgid "faces" msgstr "" -#: lib/cli/args.py:932 +#: lib/cli/args.py:943 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " "face B." msgstr "" -#: lib/cli/args.py:941 +#: lib/cli/args.py:952 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " "A." msgstr "" -#: lib/cli/args.py:949 lib/cli/args.py:961 lib/cli/args.py:977 -#: lib/cli/args.py:1002 lib/cli/args.py:1012 +#: lib/cli/args.py:960 lib/cli/args.py:972 lib/cli/args.py:988 +#: lib/cli/args.py:1013 lib/cli/args.py:1023 msgid "model" msgstr "" -#: lib/cli/args.py:950 +#: lib/cli/args.py:961 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -506,7 +512,7 @@ msgid "" "the existing model." msgstr "" -#: lib/cli/args.py:962 +#: lib/cli/args.py:973 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -520,7 +526,7 @@ msgid "" "to train." msgstr "" -#: lib/cli/args.py:978 +#: lib/cli/args.py:989 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -543,7 +549,7 @@ msgid "" "susceptible to color differences." msgstr "" -#: lib/cli/args.py:1003 +#: lib/cli/args.py:1014 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -551,7 +557,7 @@ msgid "" "displayed." msgstr "" -#: lib/cli/args.py:1013 +#: lib/cli/args.py:1024 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -560,12 +566,12 @@ msgid "" "layers." msgstr "" -#: lib/cli/args.py:1026 lib/cli/args.py:1038 lib/cli/args.py:1052 -#: lib/cli/args.py:1067 lib/cli/args.py:1075 +#: lib/cli/args.py:1037 lib/cli/args.py:1049 lib/cli/args.py:1063 +#: lib/cli/args.py:1078 lib/cli/args.py:1086 msgid "training" msgstr "" -#: lib/cli/args.py:1027 +#: lib/cli/args.py:1038 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -573,7 +579,7 @@ msgid "" "number that you set here. Larger batches require more GPU RAM." msgstr "" -#: lib/cli/args.py:1039 +#: lib/cli/args.py:1050 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -582,7 +588,7 @@ msgid "" "can set that value here." msgstr "" -#: lib/cli/args.py:1053 +#: lib/cli/args.py:1064 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -595,13 +601,13 @@ msgid "" "batches distributed to each GPU at each iteration." msgstr "" -#: lib/cli/args.py:1068 +#: lib/cli/args.py:1079 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." msgstr "" -#: lib/cli/args.py:1076 +#: lib/cli/args.py:1087 msgid "" "Use the Learning Rate Finder to discover the optimal learning rate for " "training. For new models, this will calculate the optimal learning rate for " @@ -610,25 +616,25 @@ msgid "" "the manually configured learning rate (configurable in train settings)." msgstr "" -#: lib/cli/args.py:1089 lib/cli/args.py:1099 +#: lib/cli/args.py:1100 lib/cli/args.py:1110 msgid "Saving" msgstr "" -#: lib/cli/args.py:1090 +#: lib/cli/args.py:1101 msgid "Sets the number of iterations between each model save." msgstr "" -#: lib/cli/args.py:1100 +#: lib/cli/args.py:1111 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." msgstr "" -#: lib/cli/args.py:1107 lib/cli/args.py:1118 lib/cli/args.py:1129 +#: lib/cli/args.py:1118 lib/cli/args.py:1129 lib/cli/args.py:1140 msgid "timelapse" msgstr "" -#: lib/cli/args.py:1108 +#: lib/cli/args.py:1119 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -637,7 +643,7 @@ msgid "" "timelapse-input-B parameter." msgstr "" -#: lib/cli/args.py:1119 +#: lib/cli/args.py:1130 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -646,7 +652,7 @@ msgid "" "timelapse-input-A parameter." msgstr "" -#: lib/cli/args.py:1130 +#: lib/cli/args.py:1141 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -654,47 +660,47 @@ msgid "" "model folder /timelapse/" msgstr "" -#: lib/cli/args.py:1139 lib/cli/args.py:1146 +#: lib/cli/args.py:1150 lib/cli/args.py:1157 msgid "preview" msgstr "" -#: lib/cli/args.py:1140 +#: lib/cli/args.py:1151 msgid "Show training preview output. in a separate window." msgstr "" -#: lib/cli/args.py:1147 +#: lib/cli/args.py:1158 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." msgstr "" -#: lib/cli/args.py:1154 lib/cli/args.py:1163 lib/cli/args.py:1172 -#: lib/cli/args.py:1181 +#: lib/cli/args.py:1165 lib/cli/args.py:1174 lib/cli/args.py:1183 +#: lib/cli/args.py:1192 msgid "augmentation" msgstr "" -#: lib/cli/args.py:1155 +#: lib/cli/args.py:1166 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " "warping." msgstr "" -#: lib/cli/args.py:1164 +#: lib/cli/args.py:1175 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " "left off except for during 'fit training'." msgstr "" -#: lib/cli/args.py:1173 +#: lib/cli/args.py:1184 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " "Enable this option to disable color augmentation." msgstr "" -#: lib/cli/args.py:1182 +#: lib/cli/args.py:1193 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -702,6 +708,6 @@ msgid "" "likely to kill a model and lead to terrible results." msgstr "" -#: lib/cli/args.py:1207 +#: lib/cli/args.py:1218 msgid "Output to Shell console instead of GUI console" msgstr "" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo index 21ee85e78e0731ef21e3e64df6394e3274413bfa..854a4bfbd44deaffd4df5492f426a6c32b54e4fb 100755 GIT binary patch delta 2071 zcmZXUeN5F=7{|YafD)-GidxD~P@JY1v>8&qBw(6|FPN{>alxzHxB+oNMAHkHR}!4H zyQW52`d4#HLDCrtZE17OoWE=8mS(=&dRdK@Z%Hqo!;e2|{dS-GInQ&>^PF>@^PJOq zC%pZBxc|F;J>C)DV&qBW+eaind@q7OzMWB0MVPb;4upMrNp0AZqNN`2CVU?L0Jj?Z zqf#?$h7(~yjFgAp`|t$%Uyn(v;PKwXGE>uCe8atN$1f!89j;PI6GcCgWfk)`U+l!`Pe^xM%u#oz7wPl zCfqrR6o+#RQ>0Y-=VY*8&Zss^8i0O#rW9%7&cY9dXR~;d`g$5ZW0=iqVI?HC_CsRm zI-CxZawL9wmA`557_5PR!^13~W{$KMrp<%cFU^;Z!i~8O(-%l(^xuW1mOKkN|J@9z zUPMqBPZbuxZEyqZfYgmjmQV-q^={Tevy(bV0<<0WggYShsSmsLeXxj$T3{rZiC-qo zClg64IBM)wdF0=>DXG$q>{1W}-Jr=_(Xs%vfg)_mVwbGvqXslv~1lqNZ z9kPQx>v2Tiw^90({G!w^IO8+7H zimxd&`j=k9#)M0+NYMl;fe+EwT}9B>T(U$8OKvy|{T-47Ro|4@o1$(Ro%HRZ zaTkMi8zU3Oea|DoMDagKO9+_v3oC`KzdA>g*}?UqfA4Qj3eVq>#?gQO9%oLRqzBFe zq(7)D?7Javkv?ISJ>e;6YVI729!cXGjY05d*bhc@IxpWKXm&cfGq^Qk>Fk$jTF5NK zeDje02utbuGWmZNnTn9Ft~bd%pkoo9GL1pZXKwhMU`k~Fu(`pk$nm|h(O*Co1UE#E z8l6jPA!0tRy(S}Oanq1wWEet{v^aP=@~N;FgAXDThb}@l-wX$egK6FE`N4@%@#Cg< zYgyfV4yGd+$P|Q^OT11Rg5RM@OWiB2RjvZJm0yL;yX=#{#zs;Y>nVYX>mxqY|@J`5&kzt`W$;=loSs8;eGHle95#=l|F`V z!+W4NT6&amZSZUKtG7yz!$6D_YcA#DzZ=6GSP#45S(r3U;?oU&^O(RMCoO=B<0Zyw zlT-Q^J_Vbx@0u?4pid@92jP)K$qy@%q(a&+!)ElHWa%OJ33S^ys?%ki*n6AQNy9I2 z9B#)Q1q`g4Y4K2sv=RGos+5eqI!(%h+h<9o*xyf=rl60&*>EyL`V)KR9a27w%j6)i z3Kqd8=rCRV9hL84AN1hnt}N*!`VhUB5{|FtOFyD(p>zWL1UFzmc$c)F{>o)jEZDLX z7l%m=Zo1O$E?~pNXy-~P0sT}7UZnd_8ROsx^gT(XadmJGU4zW2Ob;i9HLwVF!E$&3 z=EFrOd*N<)l!eD(H#}Scv3o0}WAJ8(N4#u;_H9*G4h>c_C+%ZviT_qA9c14|81aDg zEUbg%i!MSl53h$f0nHQWL0ktMkbG1ujD#~ndOF;~f^(rAk95>Xm3W}5mJnkfe#Dx; zZ9V=sL*QR;#n`n0Ptf6Ec7sXnk4e1@9EG*mk8L6x*purJH~^!u--NL+YzsF5I-juG zuW&jy;~m@Z3M}3sWihUEr!*bC*GFZ5%CDrpxj+wB;U79UpJotxTmvWM1j&sIL{EQC z`jqxkcD;&m;m=zOd-h4oXs>OOK7W&Rh3=626As^t+`e{6V^kkn3J|((UQfv0b5ODvoUhMF)%V%=K3E1lN5UZ diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.po b/locales/ru/LC_MESSAGES/lib.cli.args.po index 6c13783cbb..c52c17d760 100755 --- a/locales/ru/LC_MESSAGES/lib.cli.args.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 14:52+0100\n" -"PO-Revision-Date: 2023-08-20 14:56+0100\n" +"POT-Creation-Date: 2023-09-08 22:10+0100\n" +"PO-Revision-Date: 2023-09-08 22:11+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -286,8 +286,8 @@ msgstr "" "лицам\"." #: lib/cli/args.py:521 lib/cli/args.py:531 lib/cli/args.py:543 -#: lib/cli/args.py:556 lib/cli/args.py:797 lib/cli/args.py:811 -#: lib/cli/args.py:824 lib/cli/args.py:838 +#: lib/cli/args.py:556 lib/cli/args.py:797 lib/cli/args.py:812 +#: lib/cli/args.py:822 lib/cli/args.py:835 lib/cli/args.py:849 msgid "Face Processing" msgstr "Обработка лиц" @@ -379,8 +379,8 @@ msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "Нарисуйте ориентиры на выходящих гранях для отладки." #: lib/cli/args.py:609 lib/cli/args.py:618 lib/cli/args.py:626 -#: lib/cli/args.py:633 lib/cli/args.py:851 lib/cli/args.py:862 -#: lib/cli/args.py:870 lib/cli/args.py:889 lib/cli/args.py:895 +#: lib/cli/args.py:633 lib/cli/args.py:862 lib/cli/args.py:873 +#: lib/cli/args.py:881 lib/cli/args.py:900 lib/cli/args.py:906 msgid "settings" msgstr "настройки" @@ -575,7 +575,7 @@ msgstr "" "L|pillow: [изображения] Медленнее, чем opencv, но имеет больше опций и " "поддерживает больше форматов." -#: lib/cli/args.py:780 lib/cli/args.py:787 lib/cli/args.py:881 +#: lib/cli/args.py:780 lib/cli/args.py:787 lib/cli/args.py:892 msgid "Frame Processing" msgstr "Обработка лиц" @@ -618,7 +618,15 @@ msgstr "" "Если оставить этот параметр пустым, будут преобразованы все лица, " "существующие в файле выравнивания." -#: lib/cli/args.py:812 +#: lib/cli/args.py:813 +msgid "" +"Scale the swapped face by this percentage. Positive values will enlarge the " +"face, Negative values will shrink the face." +msgstr "" +"Увеличить масштаб нового лица на этот процент. Положительные значения " +"увеличат лицо, в то время как отрицательные значения уменьшат его." + +#: lib/cli/args.py:823 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -632,7 +640,7 @@ msgstr "" "разделенных пробелами. Примечание: Использование фильтра лиц значительно " "снизит скорость извлечения, а его точность не гарантируется." -#: lib/cli/args.py:825 +#: lib/cli/args.py:836 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -646,7 +654,7 @@ msgstr "" "Примечание: Использование фильтра лиц значительно снизит скорость " "извлечения, а его точность не гарантируется." -#: lib/cli/args.py:839 +#: lib/cli/args.py:850 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -658,7 +666,7 @@ msgstr "" "строгими. Примечание: Использование фильтра лиц значительно снизит скорость " "извлечения, а его точность не гарантируется." -#: lib/cli/args.py:852 +#: lib/cli/args.py:863 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -676,7 +684,7 @@ msgstr "" "процессов, чем доступно в вашей системе. Если включена однопоточная " "обработка, этот параметр будет проигнорирован." -#: lib/cli/args.py:863 +#: lib/cli/args.py:874 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -685,7 +693,7 @@ msgstr "" "загружается устаревшая модель или если в папке моделей имеется несколько " "моделей" -#: lib/cli/args.py:871 +#: lib/cli/args.py:882 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -700,7 +708,7 @@ msgstr "" "приведет к некачественным результатам. Если файл выравнивания найден, этот " "параметр будет проигнорирован." -#: lib/cli/args.py:882 +#: lib/cli/args.py:893 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -708,17 +716,17 @@ msgstr "" "При использовании с --frame-ranges выводит неизмененные кадры, которые не " "были обработаны, вместо того, чтобы отбрасывать их." -#: lib/cli/args.py:890 +#: lib/cli/args.py:901 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Поменять модель местами. Вместо преобразования из A -> B, преобразуется B -> " "A" -#: lib/cli/args.py:896 +#: lib/cli/args.py:907 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Отключение многопоточной обработки. Медленнее, но менее ресурсоемко." -#: lib/cli/args.py:912 +#: lib/cli/args.py:923 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -728,11 +736,11 @@ msgstr "" "Обучение моделей может занять много времени. От 24 часов до недели.\n" "Плагины для моделей можно настроить в меню \"Настройки\"" -#: lib/cli/args.py:931 lib/cli/args.py:940 +#: lib/cli/args.py:942 lib/cli/args.py:951 msgid "faces" msgstr "лица" -#: lib/cli/args.py:932 +#: lib/cli/args.py:943 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -741,7 +749,7 @@ msgstr "" "Входная папка. Папка, содержащая обучающие изображения для лица A. Это " "исходное лицо, т.е. лицо, которое вы хотите удалить и заменить лицом B." -#: lib/cli/args.py:941 +#: lib/cli/args.py:952 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -750,12 +758,12 @@ msgstr "" "Входная папка. Папка, содержащая обучающие изображения для лица B. Это " "подменное лицо, т.е. лицо, которое вы хотите поместить на голову человека A." -#: lib/cli/args.py:949 lib/cli/args.py:961 lib/cli/args.py:977 -#: lib/cli/args.py:1002 lib/cli/args.py:1012 +#: lib/cli/args.py:960 lib/cli/args.py:972 lib/cli/args.py:988 +#: lib/cli/args.py:1013 lib/cli/args.py:1023 msgid "model" msgstr "модель" -#: lib/cli/args.py:950 +#: lib/cli/args.py:961 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -769,7 +777,7 @@ msgstr "" "создана). Если вы продолжаете обучение существующей модели, укажите " "местоположение существующей модели." -#: lib/cli/args.py:962 +#: lib/cli/args.py:973 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -793,7 +801,7 @@ msgstr "" "Примечание: Веса могут быть загружены только из моделей того же плагина, " "который вы собираетесь обучать." -#: lib/cli/args.py:978 +#: lib/cli/args.py:989 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -838,7 +846,7 @@ msgstr "" "ресурсам (вам потребуется GPU с достаточным количеством VRAM). Хороша для " "детализации, но более восприимчива к цветовым различиям." -#: lib/cli/args.py:1003 +#: lib/cli/args.py:1014 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -849,7 +857,7 @@ msgstr "" "сводка сохраненной модели. В противном случае отображается сводка модели, " "которая будет создана выбранным плагином и настройками конфигурации." -#: lib/cli/args.py:1013 +#: lib/cli/args.py:1024 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -863,12 +871,12 @@ msgstr "" "замораживание кодера, но некоторые модели могут иметь опции конфигурации для " "замораживания других слоев." -#: lib/cli/args.py:1026 lib/cli/args.py:1038 lib/cli/args.py:1052 -#: lib/cli/args.py:1067 lib/cli/args.py:1075 +#: lib/cli/args.py:1037 lib/cli/args.py:1049 lib/cli/args.py:1063 +#: lib/cli/args.py:1078 lib/cli/args.py:1086 msgid "training" msgstr "тренировка" -#: lib/cli/args.py:1027 +#: lib/cli/args.py:1038 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -881,7 +889,7 @@ msgstr "" "времени будет вдвое больше, чем заданное здесь. Большие партии требуют " "больше оперативной памяти GPU." -#: lib/cli/args.py:1039 +#: lib/cli/args.py:1050 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -896,7 +904,7 @@ msgstr "" "вы хотите, чтобы модель автоматически останавливалась при определенном " "количестве итераций, вы можете задать это значение здесь." -#: lib/cli/args.py:1053 +#: lib/cli/args.py:1064 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -919,7 +927,7 @@ msgstr "" "локальных GPU. Копия модели и все переменные загружаются на каждый GPU с " "распределением партий на каждый GPU на каждой итерации." -#: lib/cli/args.py:1068 +#: lib/cli/args.py:1079 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -928,7 +936,7 @@ msgstr "" "журналов означает, что вы не сможете использовать график или анализ для этой " "сессии в графическом интерфейсе." -#: lib/cli/args.py:1076 +#: lib/cli/args.py:1087 msgid "" "Use the Learning Rate Finder to discover the optimal learning rate for " "training. For new models, this will calculate the optimal learning rate for " @@ -943,15 +951,15 @@ msgstr "" "модели. Установка этой опции приведет к игнорированию вручную настроенного " "коэффициента обучения (настраиваемого в параметрах обучения)." -#: lib/cli/args.py:1089 lib/cli/args.py:1099 +#: lib/cli/args.py:1100 lib/cli/args.py:1110 msgid "Saving" msgstr "Сохранение" -#: lib/cli/args.py:1090 +#: lib/cli/args.py:1101 msgid "Sets the number of iterations between each model save." msgstr "Устанавливает количество итераций между каждым сохранением модели." -#: lib/cli/args.py:1100 +#: lib/cli/args.py:1111 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -960,11 +968,11 @@ msgstr "" "Устанавливает количество итераций перед сохранением резервного снимка модели " "в текущем состоянии. Установите значение 0 для выключения." -#: lib/cli/args.py:1107 lib/cli/args.py:1118 lib/cli/args.py:1129 +#: lib/cli/args.py:1118 lib/cli/args.py:1129 lib/cli/args.py:1140 msgid "timelapse" msgstr "таймлапс" -#: lib/cli/args.py:1108 +#: lib/cli/args.py:1119 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -978,7 +986,7 @@ msgstr "" "создания timelapse. Вы также должны указать параметры --timelapse-output и --" "timelapse-input-B." -#: lib/cli/args.py:1119 +#: lib/cli/args.py:1130 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -992,7 +1000,7 @@ msgstr "" "создания timelapse. Вы также должны указать параметры --timelapse-output и --" "timelapse-input-A." -#: lib/cli/args.py:1130 +#: lib/cli/args.py:1141 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -1004,15 +1012,15 @@ msgstr "" "указаны входные папки, но нет выходной папки, то по умолчанию будет выбрана " "папка модели /timelapse/" -#: lib/cli/args.py:1139 lib/cli/args.py:1146 +#: lib/cli/args.py:1150 lib/cli/args.py:1157 msgid "preview" msgstr "предпросмотр" -#: lib/cli/args.py:1140 +#: lib/cli/args.py:1151 msgid "Show training preview output. in a separate window." msgstr "Показать вывод предварительного просмотра тренировки в отдельном окне." -#: lib/cli/args.py:1147 +#: lib/cli/args.py:1158 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -1020,12 +1028,12 @@ msgstr "" "Записывает результат обучения в файл. Изображение будет сохранено в корне " "папки Faceswap." -#: lib/cli/args.py:1154 lib/cli/args.py:1163 lib/cli/args.py:1172 -#: lib/cli/args.py:1181 +#: lib/cli/args.py:1165 lib/cli/args.py:1174 lib/cli/args.py:1183 +#: lib/cli/args.py:1192 msgid "augmentation" msgstr "аугментация" -#: lib/cli/args.py:1155 +#: lib/cli/args.py:1166 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -1035,7 +1043,7 @@ msgstr "" "набора лиц вместо случайного искажения лица. Это способ выполнения искажения " "от \"dfaker\" ." -#: lib/cli/args.py:1164 +#: lib/cli/args.py:1175 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -1045,7 +1053,7 @@ msgstr "" "горизонтали. Иногда желательно, чтобы этого не происходило. Как правило, это " "не нужно делать, за исключением случаев \"тренировки подгонки\"." -#: lib/cli/args.py:1173 +#: lib/cli/args.py:1184 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -1056,7 +1064,7 @@ msgstr "" "времени на обучение. Включите этот параметр для отключения цветовой " "аугментации." -#: lib/cli/args.py:1182 +#: lib/cli/args.py:1193 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -1068,7 +1076,7 @@ msgstr "" "больше деталей. Считайте это \"тонкой настройкой\". Включение этой опции в " "самом начале, скорее всего, погубит модель и приведет к ужасным результатам." -#: lib/cli/args.py:1207 +#: lib/cli/args.py:1218 msgid "Output to Shell console instead of GUI console" msgstr "Вывод в консоль Shell вместо консоли GUI" diff --git a/tools/preview/control_panels.py b/tools/preview/control_panels.py index f7379cca76..2318182f17 100644 --- a/tools/preview/control_panels.py +++ b/tools/preview/control_panels.py @@ -270,8 +270,9 @@ def __init__(self, app: Preview, parent: ttk.Frame) -> None: self._options = { "color": app._patch.converter.cli_arguments.color_adjustment.replace("-", "_"), - "mask_type": app._patch.converter.cli_arguments.mask_type.replace("-", "_")} - defaults = {opt: self._format_to_display(val) + "mask_type": app._patch.converter.cli_arguments.mask_type.replace("-", "_"), + "face_scale": app._patch.converter.cli_arguments.face_scale} + defaults = {opt: self._format_to_display(val) if opt != "face_scale" else val for opt, val in self._options.items()} self._busy_bar = self._build_frame(defaults, app._samples.generate, @@ -282,9 +283,11 @@ def __init__(self, app: Preview, parent: ttk.Frame) -> None: @property def convert_args(self) -> dict[str, T.Any]: """ dict: Currently selected Command line arguments from the :class:`ActionFrame`. """ - return {opt if opt != "color" else "color_adjustment": - self._format_from_display(self._tk_vars[opt].get()) - for opt in self._options} + retval = {opt if opt != "color" else "color_adjustment": + self._format_from_display(self._tk_vars[opt].get()) + for opt in self._options if opt != "face_scale"} + retval["face_scale"] = self._tk_vars["face_scale"].get() + return retval @property def busy_progress_bar(self) -> BusyProgressBar: @@ -407,17 +410,27 @@ def _get_control_panel_options(self, """ cp_options: list[ControlPanelOption] = [] for opt in self._options: - if opt == "mask_type": - choices = self._create_mask_choices(defaults, available_masks, has_predicted_mask) + if opt == "face_scale": + cp_option = ControlPanelOption(title=opt, + dtype=float, + default=0.0, + rounding=2, + min_max=(-10., 10.), + group="Command Line Choices") else: - choices = PluginLoader.get_available_convert_plugins(opt, True) - cp_option = ControlPanelOption(title=opt, - dtype=str, - default=defaults[opt], - initial_value=defaults[opt], - choices=choices, - group="Command Line Choices", - is_radio=False) + if opt == "mask_type": + choices = self._create_mask_choices(defaults, + available_masks, + has_predicted_mask) + else: + choices = PluginLoader.get_available_convert_plugins(opt, True) + cp_option = ControlPanelOption(title=opt, + dtype=str, + default=defaults[opt], + initial_value=defaults[opt], + choices=choices, + group="Command Line Choices", + is_radio=False) self._tk_vars[opt] = cp_option.tk_var cp_options.append(cp_option) return cp_options From e80ae046d5885aee0f620231325acd5b4f91cf71 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 28 Sep 2023 11:55:38 +0100 Subject: [PATCH 864/981] Convert - Patch writer (#1352) * Add cli-args and config opts for patch writer * Add supporting code to convert funcs * writers - move frame count functions to _base - Add kwargs for pre_encode * scripts/lib convert - add code for patch writer * lib.image.encode_image - Add cv2.imencode args * patch_defaults: Add face index location option * Add patch writer with PNG support * Send correct matrix to patch plugin * Add canvas origin option * Add Tiff format to Patch Writer * Docs and locales * Add ROI to output * convert: choose warp border by face count --- docs/full/plugins/convert.rst | 8 + lib/cli/args.py | 4 + lib/convert.py | 158 +++++++++---- lib/image.py | 139 +++++++++-- locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 48013 -> 48433 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 131 ++++++----- locales/kr/LC_MESSAGES/lib.cli.args.mo | Bin 48585 -> 48993 bytes locales/kr/LC_MESSAGES/lib.cli.args.po | 130 ++++++----- locales/lib.cli.args.pot | 122 +++++----- locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 64574 -> 65130 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 131 ++++++----- plugins/convert/writer/_base.py | 32 ++- plugins/convert/writer/ffmpeg.py | 30 +-- plugins/convert/writer/gif.py | 35 +-- plugins/convert/writer/opencv.py | 2 +- plugins/convert/writer/patch.py | 280 +++++++++++++++++++++++ plugins/convert/writer/patch_defaults.py | 182 +++++++++++++++ plugins/convert/writer/pillow.py | 2 +- scripts/convert.py | 67 +++--- 19 files changed, 1060 insertions(+), 393 deletions(-) create mode 100644 plugins/convert/writer/patch.py create mode 100755 plugins/convert/writer/patch_defaults.py diff --git a/docs/full/plugins/convert.rst b/docs/full/plugins/convert.rst index 251c6a4989..103d650ba7 100755 --- a/docs/full/plugins/convert.rst +++ b/docs/full/plugins/convert.rst @@ -53,6 +53,14 @@ writer.opencv module :undoc-members: :show-inheritance: +writer.patch module +-------------------- + +.. automodule:: plugins.convert.writer.patch + :members: + :undoc-members: + :show-inheritance: + writer.pillow module -------------------- diff --git a/lib/cli/args.py b/lib/cli/args.py index 2db2c30705..15b3377967 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -767,6 +767,10 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "\nL|gif: [animated image] Create an animated gif." "\nL|opencv: [images] The fastest image writer, but less options and formats " "than other plugins." + "\nL|patch: [images] Outputs the raw swapped face patch, along with the " + "transformation matrix required to re-insert the face back into the original " + "frame. Use this option if you wish to post-process and composite the final " + "face within external tools." "\nL|pillow: [images] Slower than opencv, but has more options and supports " "more formats."))) argument_list.append(dict( diff --git a/lib/convert.py b/lib/convert.py index b8fb05270f..a7439dcb8e 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -78,7 +78,7 @@ def __init__(self, coverage_ratio: float, centering: CenteringType, draw_transparent: bool, - pre_encode: Callable[[np.ndarray], list[bytes]] | None, + pre_encode: Callable | None, arguments: Namespace, configfile: str | None = None) -> None: logger.debug("Initializing %s: (output_size: %s, coverage_ratio: %s, centering: %s, " @@ -94,8 +94,9 @@ def __init__(self, self._configfile = configfile self._scale = arguments.output_scale / 100 - self._face_scale = 1.0 - self._args.face_scale / 100. + self._face_scale = 1.0 - arguments.face_scale / 100. self._adjustments = Adjustments() + self._full_frame_output: bool = arguments.writer != "patch" self._load_plugins() logger.debug("Initialized %s", self.__class__.__name__) @@ -183,7 +184,7 @@ def process(self, in_queue: EventQueue, out_queue: EventQueue): """ logger.debug("Starting convert process. (in_queue: %s, out_queue: %s)", in_queue, out_queue) - log_once = False + logged = False while True: inbound: T.Literal["EOF"] | ConvertItem | list[ConvertItem] = in_queue.get() if inbound == "EOF": @@ -196,7 +197,8 @@ def process(self, in_queue: EventQueue, out_queue: EventQueue): items = inbound if isinstance(inbound, list) else [inbound] for item in items: - logger.trace("Patch queue got: '%s'", item.inbound.filename) # type: ignore + logger.trace("Patch queue got: '%s'", # type: ignore[attr-defined] + item.inbound.filename) try: image = self._patch_image(item) except Exception as err: # pylint: disable=broad-except @@ -205,16 +207,42 @@ def process(self, in_queue: EventQueue, out_queue: EventQueue): item.inbound.filename, str(err)) image = item.inbound.image - loglevel = logger.trace if log_once else logger.warning # type: ignore - loglevel("Convert error traceback:", exc_info=True) - log_once = True + lvl = logger.trace if logged else logger.warning # type: ignore[attr-defined] + lvl("Convert error traceback:", exc_info=True) + logged = True # UNCOMMENT THIS CODE BLOCK TO PRINT TRACEBACK ERRORS # import sys; import traceback # exc_info = sys.exc_info(); traceback.print_exception(*exc_info) - logger.trace("Out queue put: %s", item.inbound.filename) # type: ignore + logger.trace("Out queue put: %s", # type: ignore[attr-defined] + item.inbound.filename) out_queue.put((item.inbound.filename, image)) logger.debug("Completed convert process") + def _get_warp_matrix(self, matrix: np.ndarray, size: int) -> np.ndarray: + """ Obtain the final scaled warp transformation matrix based on face scaling from the + original transformation matrix + + Parameters + ---------- + matrix: :class:`numpy.ndarray` + The transformation for patching the swapped face back onto the output frame + size: int + The size of the face patch, in pixels + + Returns + ------- + :class:`numpy.ndarray` + The final transformation matrix with any scaling applied + """ + if self._face_scale == 1.0: + mat = matrix + else: + mat = matrix * self._face_scale + patch_center = (size / 2, size / 2) + mat[..., 2] += (1 - self._face_scale) * np.array(patch_center) + + return mat + def _patch_image(self, predicted: ConvertItem) -> np.ndarray | list[bytes]: """ Patch a swapped face onto a frame. @@ -233,22 +261,67 @@ def _patch_image(self, predicted: ConvertItem) -> np.ndarray | list[bytes]: function (if it has one) """ - logger.trace("Patching image: '%s'", predicted.inbound.filename) # type: ignore + logger.trace("Patching image: '%s'", # type: ignore[attr-defined] + predicted.inbound.filename) frame_size = (predicted.inbound.image.shape[1], predicted.inbound.image.shape[0]) new_image, background = self._get_new_image(predicted, frame_size) - patched_face = self._post_warp_adjustments(background, new_image) - patched_face = self._scale_image(patched_face) - patched_face *= 255.0 - patched_face = np.rint(patched_face, - out=np.empty(patched_face.shape, dtype="uint8"), - casting='unsafe') + + if self._full_frame_output: + patched_face = self._post_warp_adjustments(background, new_image) + patched_face = self._scale_image(patched_face) + patched_face *= 255.0 + patched_face = np.rint(patched_face, + out=np.empty(patched_face.shape, dtype="uint8"), + casting='unsafe') + else: + patched_face = new_image + if self._writer_pre_encode is None: retval: np.ndarray | list[bytes] = patched_face else: - retval = self._writer_pre_encode(patched_face) - logger.trace("Patched image: '%s'", predicted.inbound.filename) # type: ignore + kwargs: dict[str, T.Any] = {} + if self.cli_arguments.writer == "patch": + kwargs["canvas_size"] = (background.shape[1], background.shape[0]) + kwargs["matrices"] = np.array([self._get_warp_matrix(face.adjusted_matrix, + patched_face.shape[1]) + for face in predicted.reference_faces], + dtype="float32") + retval = self._writer_pre_encode(patched_face, **kwargs) + logger.trace("Patched image: '%s'", # type: ignore[attr-defined] + predicted.inbound.filename) return retval + def _warp_to_frame(self, + reference: AlignedFace, + face: np.ndarray, + frame: np.ndarray, + multiple_faces: bool) -> None: + """ Perform affine transformation to place a face patch onto the given frame. + + Affine is done in place on the `frame` array, so this function does not return a value + + Parameters + ---------- + reference: :class:`lib.align.AlignedFace` + The object holding the original aligned face + face: :class:`numpy.ndarray` + The swapped face patch + frame: :class:`numpy.ndarray` + The frame to affine the face onto + multiple_faces: bool + Controls the border mode to use. Uses BORDER_CONSTANT if there is only 1 face in + the image, otherwise uses the inferior BORDER_TRANSPARENT + """ + # Warp face with the mask + mat = self._get_warp_matrix(reference.adjusted_matrix, face.shape[0]) + border = cv2.BORDER_TRANSPARENT if multiple_faces else cv2.BORDER_CONSTANT + cv2.warpAffine(face, + mat, + (frame.shape[1], frame.shape[0]), + frame, + flags=cv2.WARP_INVERSE_MAP | reference.interpolators[1], + borderMode=border) + def _get_new_image(self, predicted: ConvertItem, frame_size: tuple[int, int]) -> tuple[np.ndarray, np.ndarray]: @@ -271,41 +344,38 @@ def _get_new_image(self, background: :class: `numpy.ndarray` The original frame """ - logger.trace("Getting: (filename: '%s', faces: %s)", # type: ignore + logger.trace("Getting: (filename: '%s', faces: %s)", # type: ignore[attr-defined] predicted.inbound.filename, len(predicted.swapped_faces)) placeholder = np.zeros((frame_size[1], frame_size[0], 4), dtype="float32") - background = predicted.inbound.image / np.array(255.0, dtype="float32") - placeholder[:, :, :3] = background + if self._full_frame_output: + background = predicted.inbound.image / np.array(255.0, dtype="float32") + placeholder[:, :, :3] = background + else: + faces = [] # Collect the faces into final array + background = placeholder # Used for obtaining original frame dimensions for new_face, detected_face, reference_face in zip(predicted.swapped_faces, predicted.inbound.detected_faces, predicted.reference_faces): predicted_mask = new_face[:, :, -1] if new_face.shape[2] == 4 else None new_face = new_face[:, :, :3] - interpolator = reference_face.interpolators[1] - new_face = self._pre_warp_adjustments(new_face, detected_face, reference_face, predicted_mask) - # Warp face with the mask - if self._face_scale == 1.0: - mat = reference_face.adjusted_matrix + if self._full_frame_output: + self._warp_to_frame(reference_face, + new_face, placeholder, + len(predicted.swapped_faces) > 1) else: - mat = reference_face.adjusted_matrix * self._face_scale - patch_center = (new_face.shape[1] / 2, new_face.shape[0] / 2) - mat[..., 2] += (1 - self._face_scale) * np.array(patch_center) - - cv2.warpAffine(new_face, - mat, - frame_size, - placeholder, - flags=cv2.WARP_INVERSE_MAP | interpolator, - borderMode=cv2.BORDER_TRANSPARENT) - - logger.trace("Got filename: '%s'. (placeholders: %s)", # type: ignore + faces.append(new_face) + + if not self._full_frame_output: + placeholder = np.array(faces, dtype="float32") + + logger.trace("Got filename: '%s'. (placeholders: %s)", # type: ignore[attr-defined] predicted.inbound.filename, placeholder.shape) return placeholder, background @@ -339,7 +409,7 @@ def _pre_warp_adjustments(self, The face output from the Faceswap Model with any requested pre-warp adjustments performed. """ - logger.trace("new_face shape: %s, predicted_mask shape: %s", # type: ignore + logger.trace("new_face shape: %s, predicted_mask shape: %s", # type: ignore[attr-defined] new_face.shape, predicted_mask.shape if predicted_mask is not None else None) old_face = T.cast(np.ndarray, reference_face.face)[..., :3] / 255.0 new_face, raw_mask = self._get_image_mask(new_face, @@ -350,7 +420,7 @@ def _pre_warp_adjustments(self, new_face = self._adjustments.color.run(old_face, new_face, raw_mask) if self._adjustments.seamless is not None: new_face = self._adjustments.seamless.run(old_face, new_face, raw_mask) - logger.trace("returning: new_face shape %s", new_face.shape) # type: ignore + logger.trace("returning: new_face shape %s", new_face.shape) # type: ignore[attr-defined] return new_face def _get_image_mask(self, @@ -381,7 +451,7 @@ def _get_image_mask(self, :class:`numpy.ndarray` The raw mask with no erosion or blurring applied """ - logger.trace("Getting mask. Image shape: %s", new_face.shape) # type: ignore + logger.trace("Getting mask. Image shape: %s", new_face.shape) # type: ignore[attr-defined] if self._args.mask_type not in ("none", "predicted"): mask_centering = detected_face.mask[self._args.mask_type].stored_centering else: @@ -392,9 +462,9 @@ def _get_image_mask(self, reference_face.pose.offset[self._centering], self._centering, predicted_mask=predicted_mask) - logger.trace("Adding mask to alpha channel") # type: ignore + logger.trace("Adding mask to alpha channel") # type: ignore[attr-defined] new_face = np.concatenate((new_face, mask), -1) - logger.trace("Got mask. Image shape: %s", new_face.shape) # type: ignore + logger.trace("Got mask. Image shape: %s", new_face.shape) # type: ignore[attr-defined] return new_face, raw_mask def _post_warp_adjustments(self, background: np.ndarray, new_image: np.ndarray) -> np.ndarray: @@ -447,11 +517,11 @@ def _scale_image(self, frame: np.ndarray) -> np.ndarray: """ if self._scale == 1: return frame - logger.trace("source frame: %s", frame.shape) # type: ignore + logger.trace("source frame: %s", frame.shape) # type: ignore[attr-defined] interp = cv2.INTER_CUBIC if self._scale > 1 else cv2.INTER_AREA dims = (round((frame.shape[1] / 2 * self._scale) * 2), round((frame.shape[0] / 2 * self._scale) * 2)) frame = cv2.resize(frame, dims, interpolation=interp) - logger.trace("resized frame: %s", frame.shape) # type: ignore + logger.trace("resized frame: %s", frame.shape) # type: ignore[attr-defined] np.clip(frame, 0.0, 1.0, out=frame) return frame diff --git a/lib/image.py b/lib/image.py index c79bf9a350..3bcdd85e29 100644 --- a/lib/image.py +++ b/lib/image.py @@ -1,6 +1,7 @@ #!/usr/bin python3 """ Utilities for working with images and videos """ from __future__ import annotations +import json import logging import re import subprocess @@ -558,7 +559,8 @@ def update_existing_metadata(filename, metadata): def encode_image(image: np.ndarray, extension: str, - metadata: PNGHeaderDict | None = None) -> bytes: + encoding_args: tuple[int, ...] | None = None, + metadata: PNGHeaderDict | dict[str, T.Any] | bytes | None = None) -> bytes: """ Encode an image. Parameters @@ -567,9 +569,12 @@ def encode_image(image: np.ndarray, The image to be encoded in `BGR` channel order. extension: str A compatible `cv2` image file extension that the final image is to be saved to. - metadata: dict, optional - Metadata for the image. If provided, and the extension is png, this information will be - written to the PNG itxt header. Default:``None`` + encoding_args: tuple[int, ...], optional + Any encoding arguments to pass to cv2's imencode function + metadata: dict or bytes, optional + Metadata for the image. If provided, and the extension is png or tiff, this information + will be written to the PNG itxt header. Default:``None`` Can be provided as a python dict + or pre-encoded Returns ------- @@ -582,20 +587,23 @@ def encode_image(image: np.ndarray, >>> image = read_image(image_file) >>> encoded_image = encode_image(image, ".jpg") """ - if metadata and extension.lower() != ".png": - raise ValueError("Metadata is only supported for .png images") - retval = cv2.imencode(extension, image)[1] + if metadata and extension.lower() not in (".png", ".tif"): + raise ValueError("Metadata is only supported for .png and .tif images") + args = tuple() if encoding_args is None else encoding_args + + retval = cv2.imencode(extension, image, args)[1] if metadata: - retval = png_write_meta(retval.tobytes(), metadata) + func = {".png": png_write_meta, ".tif": tiff_write_meta}[extension] + retval = func(retval.tobytes(), metadata) # type:ignore[arg-type] return retval -def png_write_meta(png, data): +def png_write_meta(image: bytes, data: PNGHeaderDict | dict[str, T.Any] | bytes) -> bytes: """ Write Faceswap information to a png's iTXt field. Parameters ---------- - png: bytes + image: bytes The bytes encoded png file to write header data to data: dict or bytes The dictionary to write to the header. Can be pre-encoded as utf-8. @@ -611,17 +619,114 @@ def png_write_meta(png, data): PNG Specification: https://www.w3.org/TR/2003/REC-PNG-20031110/ """ - split = png.find(b"IDAT") - 4 - retval = png[:split] + pack_to_itxt(data) + png[split:] + split = image.find(b"IDAT") - 4 + retval = image[:split] + pack_to_itxt(data) + image[split:] return retval -def png_read_meta(png): - """ Read the Faceswap information stored in a png's iTXt field. +def tiff_write_meta(image: bytes, data: dict[str, T.Any] | bytes) -> bytes: + """ Write Faceswap information to a tiff's image_description field. Parameters ---------- png: bytes + The bytes encoded tiff file to write header data to + data: dict or bytes + The data to write to the image-description field. If provided as a dict, then it should be + a json serializable object, otherwise it should be data encoded as ascii bytes + + Notes + ----- + This handles a very specific task of adding, and populating, an ImageDescription field in a + Tiff file generated by OpenCV. For any other usecases it will likely fail + """ + if not isinstance(data, bytes): + data = json.dumps(data, ensure_ascii=True).encode("ascii") + + assert image[:2] == b"II", "Not a supported TIFF file" + assert struct.unpack(" 270: + insert_idx = i # Log insert location of image description + + if size <= 4: # value in offset column + ifd += tag + continue + + ifd += tag[:8] + tag_offset = struct.unpack(" dict[str, T.Any]: + """ Read information stored in a Tiff's Image Description field """ + assert image[:2] == b"II", "Not a supported TIFF file" + assert struct.unpack("I", png[pointer:pointer + 4])[0] + length = struct.unpack(">I", image[pointer:pointer + 4])[0] pointer += 8 - keyword, value = png[pointer:pointer + length].split(b"\0", 1) + keyword, value = image[pointer:pointer + length].split(b"\0", 1) if keyword == b"faceswap": retval = literal_eval(value[4:].decode("utf-8", errors="ignore")) break diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo index f6c2c78ea7dd639d887ac02ef698c2cfb1ad2804..ff51300b89505353c433d8f21c13873b7c084c53 100755 GIT binary patch delta 1434 zcmZ9~e`r=!9LMp`rcU!mxWFPtar*H`ksG;c#OQaKMb?HtGB>r3clX(@KF__6_ugke zocd@Pie^(ZD5F3pfveQ<`{bT&j}v+k9yY{()Wi!jn>z#MpqrQ_>yy7CwyM;G=kblf+Oi zK7{Y#CcKD46;e;Lv>$I@faJeiC>_VnMJkpD^^2vD3BTagXqQNDGNJn!DTmiAl^lE# z*W=GP9ot)^Nf?j27L$W)#L2h?r(hpmfv=9o`>}=pyxmfi`WaL$lbUc~x%3?q$5y0; zPd}TUXarv)f8%q~F&xI^#9dZ;iFGIOH1R@q*@?An(o8&n$w7`{A1-pGYUYpPEaKmb z8R>Zv)7#Sx^mQ!kDK$5_O3(b)~sop?7LWr=b)4S&Qx@mIWxJ2=0Q>m#19P1;<^{hwoShD5kS`V5!8 z%qc3R8|c?w77Xl?>WSyRE@dY2Cf=Z>#3TD?HATl zm3HH-L+PVEh|R?3Z~_0M8#^71rq#>Yb~jpXnhJ5g5Jx8N za7}2tOw?ubdAH4EtmB$|GI_SK*}!i%U0&Q#o)(AJk1|1+v#}TW212jfgzlPx7qT)A z7}t7!#R1O9~XmiE(pDL&$n5V32n};Gm9gaycd}uUoPrpOixhYpAr8I zOg@O>+I$!|ZWI~ow;3nMk@MnYm1{C-sbtH^EPX<9$-{jqh>-8+tY=ZB1v8?R3LiOj0^&mkpEU<$t-H TAxovu3CzFEDt$HZ!OVXE0eE2e delta 1009 zcmXZbZ%j>b9LMp`J=|_BTAF&|fwsm*9&p22W`!nASE-hATjWn6Ns)-UN~2V2i#2z& zR!^v;I%J8>tYx0?(EOQ=S<;4PN|9a zp5Sw0?=I;Wu41>L*oPf>A6qbYk2H_@Qy5PC$G2CiB{9Fi-avaH6L>JWPg+9$Ctkv+ z1JYCSBSrQG8V*Vii4(b=dY-!uNf(J*OY9q4beNBkZ^KsdpGu`3tSOTs$uFa>Ui5j6 z+Jo!m_SHHmd;$;7U>=Sgmy&ofs#1z4F2XGAJ;|cv4eCLg=nJmIglg#omSZq}#A*DE zVbtIUH~yYDxIwBjsQ*_66C^5|q(_+F%!v$6e?ju|!Vuec6MOtp!EAcs3Rg-z*(qgE z{IlIsHt~yVcFkjYr2*DigLOECZCKi8H?8xAlt!G4>3m;)1{owiAZ?&^{n949ima^( ze8q{+-=tnxILNJ#FEH(mN8jR9#9_B74bOvz?NW~4ksgqb7?G%=o*}od1RAIjO?0hg z5YKPH>f$7{y4N#fCR)AVMXQ^Okqc2d&PCs7o8LL^keMH_z~P89t6b5EW@k!%NZ|6= TNVhv~h1(4BK29^M`^?aPip9JG diff --git a/locales/es/LC_MESSAGES/lib.cli.args.po b/locales/es/LC_MESSAGES/lib.cli.args.po index a8485ba593..c6f83540b8 100755 --- a/locales/es/LC_MESSAGES/lib.cli.args.po +++ b/locales/es/LC_MESSAGES/lib.cli.args.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-08 22:10+0100\n" -"PO-Revision-Date: 2023-09-08 22:12+0100\n" +"POT-Creation-Date: 2023-09-25 16:09+0100\n" +"PO-Revision-Date: 2023-09-25 16:16+0100\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es\n" @@ -284,8 +284,8 @@ msgstr "" "un poco la extracción, pero ahorrará tiempo si usa 'sort by face'" #: lib/cli/args.py:521 lib/cli/args.py:531 lib/cli/args.py:543 -#: lib/cli/args.py:556 lib/cli/args.py:797 lib/cli/args.py:812 -#: lib/cli/args.py:822 lib/cli/args.py:835 lib/cli/args.py:849 +#: lib/cli/args.py:556 lib/cli/args.py:804 lib/cli/args.py:812 +#: lib/cli/args.py:826 lib/cli/args.py:839 lib/cli/args.py:853 msgid "Face Processing" msgstr "Proceso de Caras" @@ -379,8 +379,8 @@ msgstr "" "Dibujar puntos de referencia en las caras de salida para fines de depuración." #: lib/cli/args.py:609 lib/cli/args.py:618 lib/cli/args.py:626 -#: lib/cli/args.py:633 lib/cli/args.py:862 lib/cli/args.py:873 -#: lib/cli/args.py:881 lib/cli/args.py:900 lib/cli/args.py:906 +#: lib/cli/args.py:633 lib/cli/args.py:866 lib/cli/args.py:877 +#: lib/cli/args.py:885 lib/cli/args.py:904 lib/cli/args.py:910 msgid "settings" msgstr "ajustes" @@ -569,6 +569,10 @@ msgid "" "L|gif: [animated image] Create an animated gif.\n" "L|opencv: [images] The fastest image writer, but less options and formats " "than other plugins.\n" +"L|patch: [images] Outputs the raw swapped face patch, along with the " +"transformation matrix required to re-insert the face back into the original " +"frame. Use this option if you wish to post-process and composite the final " +"face within external tools.\n" "L|pillow: [images] Slower than opencv, but has more options and supports " "more formats." msgstr "" @@ -581,14 +585,17 @@ msgstr "" "L|gif: [imagen animada] Crea un gif animado.\n" "L|opencv: [images] El escritor de imágenes más rápido, pero con menos " "opciones y formatos que otros plugins.\n" +"L|patch: [images] Genera el parche de cara intercambiado sin formato, junto " +"con la matriz de transformación necesaria para volver a insertar la cara en " +"el marco original.\n" "L|pillow: [images] Más lento que opencv, pero tiene más opciones y soporta " "más formatos." -#: lib/cli/args.py:780 lib/cli/args.py:787 lib/cli/args.py:892 +#: lib/cli/args.py:784 lib/cli/args.py:791 lib/cli/args.py:896 msgid "Frame Processing" msgstr "Proceso de fotogramas" -#: lib/cli/args.py:781 +#: lib/cli/args.py:785 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -598,7 +605,7 @@ msgstr "" "a los fotogramas a las dimensiones de origen. 50%% a la mitad de tamaño. " "200%% al doble de tamaño" -#: lib/cli/args.py:788 +#: lib/cli/args.py:792 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -612,7 +619,15 @@ msgstr "" "imágenes, ¡los nombres de los archivos deben terminar con el número de " "fotograma!" -#: lib/cli/args.py:798 +#: lib/cli/args.py:805 +msgid "" +"Scale the swapped face by this percentage. Positive values will enlarge the " +"face, Negative values will shrink the face." +msgstr "" +"Escale la cara intercambiada según este porcentaje. Los valores positivos " +"agrandarán la cara, los valores negativos la reducirán." + +#: lib/cli/args.py:813 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -628,15 +643,7 @@ msgstr "" "especificada. Si se deja en blanco, se convertirán todas las caras que " "existan en el archivo de alineaciones." -#: lib/cli/args.py:813 -msgid "" -"Scale the swapped face by this percentage. Positive values will enlarge the " -"face, Negative values will shrink the face." -msgstr "" -"Escale la cara intercambiada según este porcentaje. Los valores positivos " -"agrandarán la cara, los valores negativos la reducirán." - -#: lib/cli/args.py:823 +#: lib/cli/args.py:827 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -650,7 +657,7 @@ msgstr "" "uso del filtro de caras disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args.py:836 +#: lib/cli/args.py:840 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -664,7 +671,7 @@ msgstr "" "del filtro facial disminuirá significativamente la velocidad de extracción y " "no se puede garantizar su precisión." -#: lib/cli/args.py:850 +#: lib/cli/args.py:854 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -676,7 +683,7 @@ msgstr "" "NB: El uso del filtro facial disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args.py:863 +#: lib/cli/args.py:867 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -693,7 +700,7 @@ msgstr "" "procesos que los disponibles en su sistema. Si 'singleprocess' está " "habilitado, este ajuste será ignorado." -#: lib/cli/args.py:874 +#: lib/cli/args.py:878 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -701,7 +708,7 @@ msgstr "" "[LEGACY] Sólo es necesario seleccionar esta opción si se está cargando un " "modelo heredado si hay varios modelos en la carpeta de modelos" -#: lib/cli/args.py:882 +#: lib/cli/args.py:886 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -716,7 +723,7 @@ msgstr "" "de baja calidad. Si se encuentra un archivo de alineaciones, esta opción " "será ignorada." -#: lib/cli/args.py:893 +#: lib/cli/args.py:897 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -724,16 +731,16 @@ msgstr "" "Cuando se usa con --frame-ranges, la salida incluye los fotogramas no " "procesados en vez de descartarlos." -#: lib/cli/args.py:901 +#: lib/cli/args.py:905 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Intercambiar el modelo. En vez de convertir de A a B, convierte de B a A" -#: lib/cli/args.py:907 +#: lib/cli/args.py:911 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Desactiva el multiproceso. Es más lento, pero usa menos recursos." -#: lib/cli/args.py:923 +#: lib/cli/args.py:927 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -745,11 +752,11 @@ msgstr "" "hasta más de una semana.\n" "Los plugins de los modelos pueden configurarse en el menú \"Ajustes\"" -#: lib/cli/args.py:942 lib/cli/args.py:951 +#: lib/cli/args.py:946 lib/cli/args.py:955 msgid "faces" msgstr "caras" -#: lib/cli/args.py:943 +#: lib/cli/args.py:947 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -759,7 +766,7 @@ msgstr "" "para la cara A. Esta es la cara original, es decir, la cara que se quiere " "eliminar y sustituir por la cara B." -#: lib/cli/args.py:952 +#: lib/cli/args.py:956 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -769,12 +776,12 @@ msgstr "" "para la cara B. Esta es la cara de intercambio, es decir, la cara que se " "quiere colocar en la cabeza de la persona A." -#: lib/cli/args.py:960 lib/cli/args.py:972 lib/cli/args.py:988 -#: lib/cli/args.py:1013 lib/cli/args.py:1023 +#: lib/cli/args.py:964 lib/cli/args.py:976 lib/cli/args.py:992 +#: lib/cli/args.py:1017 lib/cli/args.py:1027 msgid "model" msgstr "modelo" -#: lib/cli/args.py:961 +#: lib/cli/args.py:965 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -788,7 +795,7 @@ msgstr "" "carpeta que no exista (que se creará). Si continúa entrenando un modelo " "existente, especifique la ubicación del modelo existente." -#: lib/cli/args.py:973 +#: lib/cli/args.py:977 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -812,7 +819,7 @@ msgstr "" "NB: Los pesos solo se pueden cargar desde modelos del mismo complemento que " "desea entrenar." -#: lib/cli/args.py:989 +#: lib/cli/args.py:993 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -857,7 +864,7 @@ msgstr "" "recursos (se necesita una GPU con una buena cantidad de VRAM). Bueno para " "los detalles, pero más susceptible a las diferencias de color." -#: lib/cli/args.py:1014 +#: lib/cli/args.py:1018 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -869,7 +876,7 @@ msgstr "" "muestra un resumen del modelo que crearía el complemento elegido y los " "ajustes de configuración." -#: lib/cli/args.py:1024 +#: lib/cli/args.py:1028 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -883,12 +890,12 @@ msgstr "" "congelará el codificador, pero algunos modelos pueden tener opciones de " "configuración para congelar otras capas." -#: lib/cli/args.py:1037 lib/cli/args.py:1049 lib/cli/args.py:1063 -#: lib/cli/args.py:1078 lib/cli/args.py:1086 +#: lib/cli/args.py:1041 lib/cli/args.py:1053 lib/cli/args.py:1067 +#: lib/cli/args.py:1082 lib/cli/args.py:1090 msgid "training" msgstr "entrenamiento" -#: lib/cli/args.py:1038 +#: lib/cli/args.py:1042 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -901,7 +908,7 @@ msgstr "" "momento es el doble del número que se establece aquí. Los lotes más grandes " "requieren más RAM de la GPU." -#: lib/cli/args.py:1050 +#: lib/cli/args.py:1054 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -916,7 +923,7 @@ msgstr "" "automáticamente en un número determinado de iteraciones, puede establecer " "ese valor aquí." -#: lib/cli/args.py:1064 +#: lib/cli/args.py:1068 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -942,7 +949,7 @@ msgstr "" "locales. Se carga una copia del modelo y todas las variables en cada GPU con " "lotes distribuidos a cada GPU en cada iteración." -#: lib/cli/args.py:1079 +#: lib/cli/args.py:1083 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -950,7 +957,7 @@ msgstr "" "Desactiva el registro de TensorBoard. NB: Desactivar los registros significa " "que no podrá utilizar el gráfico o el análisis de esta sesión en la GUI." -#: lib/cli/args.py:1087 +#: lib/cli/args.py:1091 msgid "" "Use the Learning Rate Finder to discover the optimal learning rate for " "training. For new models, this will calculate the optimal learning rate for " @@ -965,15 +972,15 @@ msgstr "" "el modelo. Configurar esta opción ignorará la tasa de aprendizaje " "configurada manualmente (configurable en la configuración del tren)." -#: lib/cli/args.py:1100 lib/cli/args.py:1110 +#: lib/cli/args.py:1104 lib/cli/args.py:1114 msgid "Saving" msgstr "Guardar" -#: lib/cli/args.py:1101 +#: lib/cli/args.py:1105 msgid "Sets the number of iterations between each model save." msgstr "Establece el número de iteraciones entre cada guardado del modelo." -#: lib/cli/args.py:1111 +#: lib/cli/args.py:1115 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -981,11 +988,11 @@ msgstr "" "Establece el número de iteraciones antes de guardar una copia de seguridad " "del modelo en su estado actual. Establece 0 para que esté desactivado." -#: lib/cli/args.py:1118 lib/cli/args.py:1129 lib/cli/args.py:1140 +#: lib/cli/args.py:1122 lib/cli/args.py:1133 lib/cli/args.py:1144 msgid "timelapse" msgstr "intervalo" -#: lib/cli/args.py:1119 +#: lib/cli/args.py:1123 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -999,7 +1006,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-B." -#: lib/cli/args.py:1130 +#: lib/cli/args.py:1134 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -1013,7 +1020,7 @@ msgstr "" "para crear el timelapse. También debe suministrar un parámetro --timelapse-" "output y un parámetro --timelapse-input-A." -#: lib/cli/args.py:1141 +#: lib/cli/args.py:1145 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -1025,17 +1032,17 @@ msgstr "" "Si se suministran las carpetas de entrada pero no la carpeta de salida, se " "guardará por defecto en la carpeta del modelo /timelapse/" -#: lib/cli/args.py:1150 lib/cli/args.py:1157 +#: lib/cli/args.py:1154 lib/cli/args.py:1161 msgid "preview" msgstr "previsualización" -#: lib/cli/args.py:1151 +#: lib/cli/args.py:1155 msgid "Show training preview output. in a separate window." msgstr "" "Mostrar la salida de la vista previa del entrenamiento. en una ventana " "separada." -#: lib/cli/args.py:1158 +#: lib/cli/args.py:1162 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -1043,12 +1050,12 @@ msgstr "" "Escribe el resultado del entrenamiento en un archivo. La imagen se " "almacenará en la raíz de su carpeta FaceSwap." -#: lib/cli/args.py:1165 lib/cli/args.py:1174 lib/cli/args.py:1183 -#: lib/cli/args.py:1192 +#: lib/cli/args.py:1169 lib/cli/args.py:1178 lib/cli/args.py:1187 +#: lib/cli/args.py:1196 msgid "augmentation" msgstr "aumento" -#: lib/cli/args.py:1166 +#: lib/cli/args.py:1170 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -1058,7 +1065,7 @@ msgstr "" "conjunto de caras opuestas en lugar de deformar la cara al azar. Esta es la " "forma 'dfaker' de hacer la deformación." -#: lib/cli/args.py:1175 +#: lib/cli/args.py:1179 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -1069,7 +1076,7 @@ msgstr "" "general, esto debería dejarse sin efecto, excepto durante el 'entrenamiento " "de ajuste'." -#: lib/cli/args.py:1184 +#: lib/cli/args.py:1188 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -1079,7 +1086,7 @@ msgstr "" "diferencias de color entre los conjuntos A y B, con un mayor coste de tiempo " "de entrenamiento. Activa esta opción para desactivar el aumento de color." -#: lib/cli/args.py:1193 +#: lib/cli/args.py:1197 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -1092,7 +1099,7 @@ msgstr "" "esta opción desde el principio, es probable que arruine el modelo y se " "obtengan resultados terribles." -#: lib/cli/args.py:1218 +#: lib/cli/args.py:1222 msgid "Output to Shell console instead of GUI console" msgstr "Salida a la consola Shell en lugar de la consola GUI" diff --git a/locales/kr/LC_MESSAGES/lib.cli.args.mo b/locales/kr/LC_MESSAGES/lib.cli.args.mo index 953368f20786bf0937d1a6920c70bd795862bfd6..9b1d915197663bf5f9428d5299c5a03ee991509e 100644 GIT binary patch delta 1420 zcmXxjZ%kEn7{Kud2oRTLTeGEQzqVfFNS8FVSk@}XtTdWsC64fTIrnn#-g~a+90C+} z(J)d{*!bhkkP%8yF(vQCP#}9@ZB}b5*Nbwq*qRmRTwAS;HMi<}F1J^o^E~(W_j!Kz z(v94ie{#ou&WSI|6S?-ZNP&p_wMnE6Z(u#XzFEZfV_b{ob0QDnd-xRofMvM6T!c^2 z@kvbKKAgj|IUOk$wg=ydo08JGO}g z@BlXBZ+J6?Dn#d!<3U``J|9-ZMaKB}V!Oy=xVKW|7Y3ex zb$-HNmB>rPqd0!4?BvMIYFGlhEn)x3#){1N(-i8V6-zD-56V_3VBo;+EJx-&+?}jOIiuiJjHsJo< zBIV?N#c|??>Eke7$BpEB9g$Xy|5zt-n2%~29%jIw4fK+@s*%3osXZb)$lu?@jfoFp z1TWEtKgh46g6nZbi^!uGLW(IT@mBO7!ZlcoU(t&>T*my1hiG*k{r{<5B!|S@5s}{+ z_&0TWH;dKAluzr!l_m%BdV_9U9nKlu#uhmiGWC_T2}3N}>K{c8^T4E4N7{?3m@ za*3~^|CnYbC@0E!DuyjQ@y`fnI874{PjhIz?-LO&FCAFmmu~5W`NwuI`Zp-fT7v#7 zedP+HiGhE%=b)MSP^mKL>xrWMf~!;C7hj0R=tyXy>#nU2Z# zN+W9PmXjHuS!$;i*rkl96E`zC(=tLvR12%1rA72rYOAf2H*96bGK(5P)o9kSr_G+G zikY^vGG>_p-L{n$tyTduLe6mfS-J|&FXbQEKcx{>dV`}|{`!t-hV4}gH#NnKaM-Lb z?V222n4Ea_iCY$wtWm4imablt%&ACi`M)gQ@sxXd!W(X>RNgzCZhu0hyIb9XH@*H= z?`Ws;dZ%W`Q{M4b<+h*n+WVBZf77G92CR@^PGr#ws zJ1{z%oZ_dE*~x^zu0L~jywf|AaywarMVE}HRQjzE?_9zino{1`K6hX!-Sd9(gVB3# F`wuTxd#eBd delta 1009 zcmXZaSx8h-9LMqB9dpKLF~JrPS{{m`7ndj*GNVM2T+*V_GPTV{TyhFcQb$e7C6k&U zF%lKD&@yWrE6f+EAdtX%O9(7W-^>@Ig{JRq24?1Se&?Qh&YAN+Q-3XQe_IBp4cAJG z^esuUO8T>3x`si?QaLu`1v95e+p!#DZ~*t>XH3LRsS+O*@v{eCVlDb`*r1&zbz^Kg z(m$CYjbQabx&Aloha@Kh7H}0h4oe*js5!!v%F<(jblZS<)`Na$K5W;8?c*g}xIKd(jX+z$H1-Co^L_x^pEj z?|r~pvr@LXFc#xfzW>1ihm=6h!#35n5rdBD(V{X)ki5T}-%3*!g)1WMxMsw#$gM6_E+i(uI zut38T=^Yz=%K5*dzm{{gkQ0afmnrEP$4Nf-oD;*@7nH}GZ-fhqq3nt(>N@hD^#iTu zj_OAJm#q=4WOu-JG;euVAP)qxH;~6h-R;3|40-ab5r$#+RN1yBcsi5Qmxn~gu8)qk RM{V=`%o@t@qzr_I{R7|CzRCaq diff --git a/locales/kr/LC_MESSAGES/lib.cli.args.po b/locales/kr/LC_MESSAGES/lib.cli.args.po index 3acf173b08..365b326a66 100644 --- a/locales/kr/LC_MESSAGES/lib.cli.args.po +++ b/locales/kr/LC_MESSAGES/lib.cli.args.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-08 22:10+0100\n" -"PO-Revision-Date: 2023-09-08 22:14+0100\n" +"POT-Creation-Date: 2023-09-25 16:09+0100\n" +"PO-Revision-Date: 2023-09-25 16:15+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: ko_KR\n" @@ -265,8 +265,8 @@ msgstr "" "'얼굴별로 정렬'을 사용하면 시간을 절약할 수 있습니다." #: lib/cli/args.py:521 lib/cli/args.py:531 lib/cli/args.py:543 -#: lib/cli/args.py:556 lib/cli/args.py:797 lib/cli/args.py:812 -#: lib/cli/args.py:822 lib/cli/args.py:835 lib/cli/args.py:849 +#: lib/cli/args.py:556 lib/cli/args.py:804 lib/cli/args.py:812 +#: lib/cli/args.py:826 lib/cli/args.py:839 lib/cli/args.py:853 msgid "Face Processing" msgstr "얼굴 처리" @@ -353,8 +353,8 @@ msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "디버깅을 위해 출력 얼굴에 특징점을 그립니다." #: lib/cli/args.py:609 lib/cli/args.py:618 lib/cli/args.py:626 -#: lib/cli/args.py:633 lib/cli/args.py:862 lib/cli/args.py:873 -#: lib/cli/args.py:881 lib/cli/args.py:900 lib/cli/args.py:906 +#: lib/cli/args.py:633 lib/cli/args.py:866 lib/cli/args.py:877 +#: lib/cli/args.py:885 lib/cli/args.py:904 lib/cli/args.py:910 msgid "settings" msgstr "설정" @@ -518,6 +518,10 @@ msgid "" "L|gif: [animated image] Create an animated gif.\n" "L|opencv: [images] The fastest image writer, but less options and formats " "than other plugins.\n" +"L|patch: [images] Outputs the raw swapped face patch, along with the " +"transformation matrix required to re-insert the face back into the original " +"frame. Use this option if you wish to post-process and composite the final " +"face within external tools.\n" "L|pillow: [images] Slower than opencv, but has more options and supports " "more formats." msgstr "" @@ -528,14 +532,16 @@ msgstr "" "L|gif : [애니메이션 이미지] 애니메이션 gif를 만듭니다.\n" "L|opencv: [이미지] 가장 빠른 이미지 작성기이지만 다른 플러그인에 비해 옵션과 " "형식이 적습니다.\n" +"L|patch: [이미지] 원래 프레임에 얼굴을 다시 삽입하는 데 필요한 변환 행렬과 함" +"께 원시 교체된 얼굴 패치를 출력합니다.\n" "L|pillow: [images] opencv보다 느리지만 더 많은 옵션이 있고 더 많은 형식을 지" "원합니다." -#: lib/cli/args.py:780 lib/cli/args.py:787 lib/cli/args.py:892 +#: lib/cli/args.py:784 lib/cli/args.py:791 lib/cli/args.py:896 msgid "Frame Processing" msgstr "프레임 처리" -#: lib/cli/args.py:781 +#: lib/cli/args.py:785 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -544,7 +550,7 @@ msgstr "" "최종 출력 프레임의 크기를 이 양만큼 조정합니다. 100%%는 원본의 차원에서 프레" "임을 출력합니다. 50%%는 절반 크기에서, 200%%는 두 배 크기에서" -#: lib/cli/args.py:788 +#: lib/cli/args.py:792 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -556,7 +562,15 @@ msgstr "" "으면 선택한 범위를 벗어나는 프레임이 삭제됩니다. NB: 이미지에서 변환하는 경" "우 파일 이름은 프레임 번호로 끝나야 합니다!" -#: lib/cli/args.py:798 +#: lib/cli/args.py:805 +msgid "" +"Scale the swapped face by this percentage. Positive values will enlarge the " +"face, Negative values will shrink the face." +msgstr "" +"이 백분율로 교체된 면의 크기를 조정합니다. 양수 값은 얼굴을 확대하고, 음수 값" +"은 얼굴을 축소합니다." + +#: lib/cli/args.py:813 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -570,15 +584,7 @@ msgstr "" "alignments 파일 내에 존재하거나 지정된 폴더 내에 존재하는 얼굴만 변환됩니다. " "이 항목을 공백으로 두면 alignments 파일 내에 있는 모든 얼굴이 변환됩니다." -#: lib/cli/args.py:813 -msgid "" -"Scale the swapped face by this percentage. Positive values will enlarge the " -"face, Negative values will shrink the face." -msgstr "" -"이 백분율로 교체된 면의 크기를 조정합니다. 양수 값은 얼굴을 확대하고, 음수 값" -"은 얼굴을 축소합니다." - -#: lib/cli/args.py:823 +#: lib/cli/args.py:827 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -591,7 +597,7 @@ msgstr "" "분하여 추가할 수 있습니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감소" "하므로 정확성을 보장할 수 없습니다." -#: lib/cli/args.py:836 +#: lib/cli/args.py:840 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -604,7 +610,7 @@ msgstr "" "가할 수 있습니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감소하므로 정" "확성을 보장할 수 없습니다." -#: lib/cli/args.py:850 +#: lib/cli/args.py:854 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -615,7 +621,7 @@ msgstr "" "값. 낮은 값이 더 엄격합니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감" "소하므로 정확성을 보장할 수 없습니다." -#: lib/cli/args.py:863 +#: lib/cli/args.py:867 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -631,7 +637,7 @@ msgstr "" "를 사용하려고 시도하지 않습니다. 단일 프로세스가 활성화된 경우 이 설정은 무시" "됩니다." -#: lib/cli/args.py:874 +#: lib/cli/args.py:878 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" @@ -639,7 +645,7 @@ msgstr "" "[LEGACY] 이것은 레거시 모델을 로드 중이거나 모델 폴더에 여러 모델이 있는 경우" "에만 선택되어야 합니다" -#: lib/cli/args.py:882 +#: lib/cli/args.py:886 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -653,7 +659,7 @@ msgstr "" "하고 표준 이하의 결과로 이어질 것입니다. alignments 파일이 발견되면 이 옵션" "은 무시됩니다." -#: lib/cli/args.py:893 +#: lib/cli/args.py:897 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -661,15 +667,15 @@ msgstr "" "사용시 --frame-ranges 인자를 사용하면 변경되지 않은 프레임을 버리지 않은 결과" "가 출력됩니다." -#: lib/cli/args.py:901 +#: lib/cli/args.py:905 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "모델을 바꿉니다. A -> B에서 변환하는 대신 B -> A로 변환" -#: lib/cli/args.py:907 +#: lib/cli/args.py:911 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "멀티프로세싱을 쓰지 않습니다. 느리지만 자원을 덜 소모합니다." -#: lib/cli/args.py:923 +#: lib/cli/args.py:927 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -680,11 +686,11 @@ msgstr "" "간이 필요합니다.\n" "모델 플러그인은 '설정' 메뉴에서 구성할 수 있습니다" -#: lib/cli/args.py:942 lib/cli/args.py:951 +#: lib/cli/args.py:946 lib/cli/args.py:955 msgid "faces" msgstr "얼굴들" -#: lib/cli/args.py:943 +#: lib/cli/args.py:947 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -693,7 +699,7 @@ msgstr "" "입력 디렉토리. 얼굴 A에 대한 훈련 이미지가 포함된 디렉토리입니다. 이것은 원" "래 얼굴, 즉 제거하고 B 얼굴로 대체하려는 얼굴입니다." -#: lib/cli/args.py:952 +#: lib/cli/args.py:956 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -702,12 +708,12 @@ msgstr "" "입력 디렉터리. 얼굴 B에 대한 훈련 이미지를 포함하는 디렉토리. 이것은 대체 얼" "굴, 즉 사람 A의 얼굴 앞에 배치하려는 얼굴이다." -#: lib/cli/args.py:960 lib/cli/args.py:972 lib/cli/args.py:988 -#: lib/cli/args.py:1013 lib/cli/args.py:1023 +#: lib/cli/args.py:964 lib/cli/args.py:976 lib/cli/args.py:992 +#: lib/cli/args.py:1017 lib/cli/args.py:1027 msgid "model" msgstr "모델" -#: lib/cli/args.py:961 +#: lib/cli/args.py:965 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -720,7 +726,7 @@ msgstr "" "성될 폴더)를 선택합니다. 기존 모델을 계속 학습하는 경우 기존 모델의 위치를 지" "정합니다." -#: lib/cli/args.py:973 +#: lib/cli/args.py:977 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -741,7 +747,7 @@ msgstr "" "중치 동결'이 필요합니다.\n" "주의: 가중치는 훈련하려는 플러그인 모델에서만 로드할 수 있습니다." -#: lib/cli/args.py:989 +#: lib/cli/args.py:993 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -783,7 +789,7 @@ msgstr "" "양의 VRAM이 있는 GPU가 필요합니다). 세부 사항에는 좋지만 색상 차이에 더 취약" "합니다." -#: lib/cli/args.py:1014 +#: lib/cli/args.py:1018 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -794,7 +800,7 @@ msgstr "" "시됩니다. 그렇지 않으면 선택한 플러그인 및 구성 설정에 의해 생성되는 모델 요" "약이 표시됩니다." -#: lib/cli/args.py:1024 +#: lib/cli/args.py:1028 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -807,12 +813,12 @@ msgstr "" "이렇게 하면 인코더가 고정되지만 일부 모델에는 다른 레이어를 고정하기 위한 구" "성 옵션이 있을 수 있습니다." -#: lib/cli/args.py:1037 lib/cli/args.py:1049 lib/cli/args.py:1063 -#: lib/cli/args.py:1078 lib/cli/args.py:1086 +#: lib/cli/args.py:1041 lib/cli/args.py:1053 lib/cli/args.py:1067 +#: lib/cli/args.py:1082 lib/cli/args.py:1090 msgid "training" msgstr "훈련" -#: lib/cli/args.py:1038 +#: lib/cli/args.py:1042 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -824,7 +830,7 @@ msgstr "" "여기에서 설정한 수의 두 배입니다. 더 큰 배치에는 더 많은 GPU RAM이 필요합니" "다." -#: lib/cli/args.py:1050 +#: lib/cli/args.py:1054 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -837,7 +843,7 @@ msgstr "" "다. 그러나 설정된 반복 횟수에서 모델이 자동으로 중지되도록 하려면 여기에서 해" "당 값을 설정할 수 있습니다." -#: lib/cli/args.py:1064 +#: lib/cli/args.py:1068 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -858,7 +864,7 @@ msgstr "" "L|mirrored: 여러 로컬 GPU에서 동기화 분산 훈련을 지원합니다. 모델의 복사본과 " "모든 변수는 각 반복에서 각 GPU에 배포된 배치들와 함께 각 GPU에 로드됩니다." -#: lib/cli/args.py:1079 +#: lib/cli/args.py:1083 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -866,7 +872,7 @@ msgstr "" "텐서보드 로깅을 비활성화합니다. 주의: 로그를 비활성화하면 GUI에서 이 세션에 " "대한 그래프 또는 분석을 사용할 수 없습니다." -#: lib/cli/args.py:1087 +#: lib/cli/args.py:1091 msgid "" "Use the Learning Rate Finder to discover the optimal learning rate for " "training. For new models, this will calculate the optimal learning rate for " @@ -879,15 +885,15 @@ msgstr "" "때 발견된 최적의 학습률을 사용합니다. 이 옵션을 설정하면 수동으로 구성된 학습" "률(기차 설정에서 구성 가능)이 무시됩니다." -#: lib/cli/args.py:1100 lib/cli/args.py:1110 +#: lib/cli/args.py:1104 lib/cli/args.py:1114 msgid "Saving" msgstr "저장" -#: lib/cli/args.py:1101 +#: lib/cli/args.py:1105 msgid "Sets the number of iterations between each model save." msgstr "각 모델 저장 사이의 반복 횟수를 설정합니다." -#: lib/cli/args.py:1111 +#: lib/cli/args.py:1115 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -895,11 +901,11 @@ msgstr "" "현재 상태에서 모델의 백업 스냅샷을 저장하기 전에 반복할 횟수를 설정합니다. 0" "으로 설정하면 꺼집니다." -#: lib/cli/args.py:1118 lib/cli/args.py:1129 lib/cli/args.py:1140 +#: lib/cli/args.py:1122 lib/cli/args.py:1133 lib/cli/args.py:1144 msgid "timelapse" msgstr "타임랩스" -#: lib/cli/args.py:1119 +#: lib/cli/args.py:1123 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -912,7 +918,7 @@ msgstr "" "랩스를 만드는 데 사용할 'A' 얼굴의 입력 폴더여야 합니다. 또한 사용자는 --" "timelapse-output 및 --timelapse-input-B 매개 변수를 제공해야 합니다." -#: lib/cli/args.py:1130 +#: lib/cli/args.py:1134 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -925,7 +931,7 @@ msgstr "" "다. 타임 랩스를 만드는 데 사용할 'B' 얼굴의 입력 폴더여야 합니다. 또한 사용자" "는 --timelapse-output 및 --timelapse-input-A 매개 변수를 제공해야 합니다." -#: lib/cli/args.py:1141 +#: lib/cli/args.py:1145 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -937,27 +943,27 @@ msgstr "" "다. 입력 폴더가 제공되었지만 출력 폴더가 없는 경우 모델 폴더에 /timelapse/로 " "기본 설정됩니다" -#: lib/cli/args.py:1150 lib/cli/args.py:1157 +#: lib/cli/args.py:1154 lib/cli/args.py:1161 msgid "preview" msgstr "미리보기" -#: lib/cli/args.py:1151 +#: lib/cli/args.py:1155 msgid "Show training preview output. in a separate window." msgstr "훈련 미리보기 결과를 각기 다른 창에서 보여줍니다." -#: lib/cli/args.py:1158 +#: lib/cli/args.py:1162 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." msgstr "" "훈련 결과를 파일에 씁니다. 이미지는 Faceswap 폴더의 최상위 폴더에 저장됩니다." -#: lib/cli/args.py:1165 lib/cli/args.py:1174 lib/cli/args.py:1183 -#: lib/cli/args.py:1192 +#: lib/cli/args.py:1169 lib/cli/args.py:1178 lib/cli/args.py:1187 +#: lib/cli/args.py:1196 msgid "augmentation" msgstr "보정" -#: lib/cli/args.py:1166 +#: lib/cli/args.py:1170 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -966,7 +972,7 @@ msgstr "" "무작위로 얼굴을 변환하지 않고 반대쪽 얼굴 세트에서 특징점과 밀접하게 일치하도" "록 훈련 얼굴을 변환해줍니다. 이것은 변환하는 'dfaker' 방식이다." -#: lib/cli/args.py:1175 +#: lib/cli/args.py:1179 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -976,7 +982,7 @@ msgstr "" "런 일이 일어나지 않는 것이 바람직합니다. 일반적으로 'fit training' 중을 제외" "하고는 이 작업을 중단해야 합니다." -#: lib/cli/args.py:1184 +#: lib/cli/args.py:1188 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -986,7 +992,7 @@ msgstr "" "이 되며, 훈련 시간 비용이 증가합니다. 색상 보저를 사용하지 않으려면 이 옵션" "을 사용합니다." -#: lib/cli/args.py:1193 +#: lib/cli/args.py:1197 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -998,7 +1004,7 @@ msgstr "" "면 됩니다. 처음부터 이 옵션을 활성화하면 모델이 죽을 수있고 끔찍한 결과를 초" "래할 수 있습니다." -#: lib/cli/args.py:1218 +#: lib/cli/args.py:1222 msgid "Output to Shell console instead of GUI console" msgstr "결과를 GUI 콘솔이 아닌 쉘 콘솔에 출력합니다" diff --git a/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index cdc1d8c538..b2667c1f47 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-08 22:10+0100\n" +"POT-Creation-Date: 2023-09-25 16:09+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -188,8 +188,8 @@ msgid "" msgstr "" #: lib/cli/args.py:521 lib/cli/args.py:531 lib/cli/args.py:543 -#: lib/cli/args.py:556 lib/cli/args.py:797 lib/cli/args.py:812 -#: lib/cli/args.py:822 lib/cli/args.py:835 lib/cli/args.py:849 +#: lib/cli/args.py:556 lib/cli/args.py:804 lib/cli/args.py:812 +#: lib/cli/args.py:826 lib/cli/args.py:839 lib/cli/args.py:853 msgid "Face Processing" msgstr "" @@ -255,8 +255,8 @@ msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" #: lib/cli/args.py:609 lib/cli/args.py:618 lib/cli/args.py:626 -#: lib/cli/args.py:633 lib/cli/args.py:862 lib/cli/args.py:873 -#: lib/cli/args.py:881 lib/cli/args.py:900 lib/cli/args.py:906 +#: lib/cli/args.py:633 lib/cli/args.py:866 lib/cli/args.py:877 +#: lib/cli/args.py:885 lib/cli/args.py:904 lib/cli/args.py:910 msgid "settings" msgstr "" @@ -369,22 +369,26 @@ msgid "" "L|gif: [animated image] Create an animated gif.\n" "L|opencv: [images] The fastest image writer, but less options and formats " "than other plugins.\n" +"L|patch: [images] Outputs the raw swapped face patch, along with the " +"transformation matrix required to re-insert the face back into the original " +"frame. Use this option if you wish to post-process and composite the final " +"face within external tools.\n" "L|pillow: [images] Slower than opencv, but has more options and supports " "more formats." msgstr "" -#: lib/cli/args.py:780 lib/cli/args.py:787 lib/cli/args.py:892 +#: lib/cli/args.py:784 lib/cli/args.py:791 lib/cli/args.py:896 msgid "Frame Processing" msgstr "" -#: lib/cli/args.py:781 +#: lib/cli/args.py:785 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " "at source dimensions. 50%% at half size 200%% at double size" msgstr "" -#: lib/cli/args.py:788 +#: lib/cli/args.py:792 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -392,7 +396,13 @@ msgid "" "converting from images, then the filenames must end with the frame-number!" msgstr "" -#: lib/cli/args.py:798 +#: lib/cli/args.py:805 +msgid "" +"Scale the swapped face by this percentage. Positive values will enlarge the " +"face, Negative values will shrink the face." +msgstr "" + +#: lib/cli/args.py:813 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -402,13 +412,7 @@ msgid "" "alignments file." msgstr "" -#: lib/cli/args.py:813 -msgid "" -"Scale the swapped face by this percentage. Positive values will enlarge the " -"face, Negative values will shrink the face." -msgstr "" - -#: lib/cli/args.py:823 +#: lib/cli/args.py:827 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -417,7 +421,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args.py:836 +#: lib/cli/args.py:840 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -426,7 +430,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args.py:850 +#: lib/cli/args.py:854 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -434,7 +438,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args.py:863 +#: lib/cli/args.py:867 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -444,13 +448,13 @@ msgid "" "your system. If singleprocess is enabled this setting will be ignored." msgstr "" -#: lib/cli/args.py:874 +#: lib/cli/args.py:878 msgid "" "[LEGACY] This only needs to be selected if a legacy model is being loaded or " "if there are multiple models in the model folder" msgstr "" -#: lib/cli/args.py:882 +#: lib/cli/args.py:886 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -459,51 +463,51 @@ msgid "" "alignments file is found, this option will be ignored." msgstr "" -#: lib/cli/args.py:893 +#: lib/cli/args.py:897 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." msgstr "" -#: lib/cli/args.py:901 +#: lib/cli/args.py:905 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" -#: lib/cli/args.py:907 +#: lib/cli/args.py:911 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "" -#: lib/cli/args.py:923 +#: lib/cli/args.py:927 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" "Model plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args.py:942 lib/cli/args.py:951 +#: lib/cli/args.py:946 lib/cli/args.py:955 msgid "faces" msgstr "" -#: lib/cli/args.py:943 +#: lib/cli/args.py:947 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " "face B." msgstr "" -#: lib/cli/args.py:952 +#: lib/cli/args.py:956 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " "A." msgstr "" -#: lib/cli/args.py:960 lib/cli/args.py:972 lib/cli/args.py:988 -#: lib/cli/args.py:1013 lib/cli/args.py:1023 +#: lib/cli/args.py:964 lib/cli/args.py:976 lib/cli/args.py:992 +#: lib/cli/args.py:1017 lib/cli/args.py:1027 msgid "model" msgstr "" -#: lib/cli/args.py:961 +#: lib/cli/args.py:965 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -512,7 +516,7 @@ msgid "" "the existing model." msgstr "" -#: lib/cli/args.py:973 +#: lib/cli/args.py:977 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -526,7 +530,7 @@ msgid "" "to train." msgstr "" -#: lib/cli/args.py:989 +#: lib/cli/args.py:993 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -549,7 +553,7 @@ msgid "" "susceptible to color differences." msgstr "" -#: lib/cli/args.py:1014 +#: lib/cli/args.py:1018 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -557,7 +561,7 @@ msgid "" "displayed." msgstr "" -#: lib/cli/args.py:1024 +#: lib/cli/args.py:1028 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -566,12 +570,12 @@ msgid "" "layers." msgstr "" -#: lib/cli/args.py:1037 lib/cli/args.py:1049 lib/cli/args.py:1063 -#: lib/cli/args.py:1078 lib/cli/args.py:1086 +#: lib/cli/args.py:1041 lib/cli/args.py:1053 lib/cli/args.py:1067 +#: lib/cli/args.py:1082 lib/cli/args.py:1090 msgid "training" msgstr "" -#: lib/cli/args.py:1038 +#: lib/cli/args.py:1042 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -579,7 +583,7 @@ msgid "" "number that you set here. Larger batches require more GPU RAM." msgstr "" -#: lib/cli/args.py:1050 +#: lib/cli/args.py:1054 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -588,7 +592,7 @@ msgid "" "can set that value here." msgstr "" -#: lib/cli/args.py:1064 +#: lib/cli/args.py:1068 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -601,13 +605,13 @@ msgid "" "batches distributed to each GPU at each iteration." msgstr "" -#: lib/cli/args.py:1079 +#: lib/cli/args.py:1083 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." msgstr "" -#: lib/cli/args.py:1087 +#: lib/cli/args.py:1091 msgid "" "Use the Learning Rate Finder to discover the optimal learning rate for " "training. For new models, this will calculate the optimal learning rate for " @@ -616,25 +620,25 @@ msgid "" "the manually configured learning rate (configurable in train settings)." msgstr "" -#: lib/cli/args.py:1100 lib/cli/args.py:1110 +#: lib/cli/args.py:1104 lib/cli/args.py:1114 msgid "Saving" msgstr "" -#: lib/cli/args.py:1101 +#: lib/cli/args.py:1105 msgid "Sets the number of iterations between each model save." msgstr "" -#: lib/cli/args.py:1111 +#: lib/cli/args.py:1115 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." msgstr "" -#: lib/cli/args.py:1118 lib/cli/args.py:1129 lib/cli/args.py:1140 +#: lib/cli/args.py:1122 lib/cli/args.py:1133 lib/cli/args.py:1144 msgid "timelapse" msgstr "" -#: lib/cli/args.py:1119 +#: lib/cli/args.py:1123 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -643,7 +647,7 @@ msgid "" "timelapse-input-B parameter." msgstr "" -#: lib/cli/args.py:1130 +#: lib/cli/args.py:1134 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -652,7 +656,7 @@ msgid "" "timelapse-input-A parameter." msgstr "" -#: lib/cli/args.py:1141 +#: lib/cli/args.py:1145 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -660,47 +664,47 @@ msgid "" "model folder /timelapse/" msgstr "" -#: lib/cli/args.py:1150 lib/cli/args.py:1157 +#: lib/cli/args.py:1154 lib/cli/args.py:1161 msgid "preview" msgstr "" -#: lib/cli/args.py:1151 +#: lib/cli/args.py:1155 msgid "Show training preview output. in a separate window." msgstr "" -#: lib/cli/args.py:1158 +#: lib/cli/args.py:1162 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." msgstr "" -#: lib/cli/args.py:1165 lib/cli/args.py:1174 lib/cli/args.py:1183 -#: lib/cli/args.py:1192 +#: lib/cli/args.py:1169 lib/cli/args.py:1178 lib/cli/args.py:1187 +#: lib/cli/args.py:1196 msgid "augmentation" msgstr "" -#: lib/cli/args.py:1166 +#: lib/cli/args.py:1170 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " "warping." msgstr "" -#: lib/cli/args.py:1175 +#: lib/cli/args.py:1179 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " "left off except for during 'fit training'." msgstr "" -#: lib/cli/args.py:1184 +#: lib/cli/args.py:1188 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " "Enable this option to disable color augmentation." msgstr "" -#: lib/cli/args.py:1193 +#: lib/cli/args.py:1197 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -708,6 +712,6 @@ msgid "" "likely to kill a model and lead to terrible results." msgstr "" -#: lib/cli/args.py:1218 +#: lib/cli/args.py:1222 msgid "Output to Shell console instead of GUI console" msgstr "" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo index 854a4bfbd44deaffd4df5492f426a6c32b54e4fb..02559444d6ee745129ac0759ab816287e4008851 100755 GIT binary patch delta 1537 zcmYk4TWl0%7>3`KqoklQ3Rr~v8lx8iu|;}O;~|z1Nl{F(3KcYCJ8eh0JIl_rr5v(t zDKxdj25JnY!HZsC6r)R*Vr%W1D2O3SW<6Y}NIasLXfzTbg2ZQbnrPDx$Olo^f8iIFMLHwVtmBwMq<4PBPN~@(53BSR)(0WpOnS?Ftq&S?nUaEz= z;0|~m7Qu!ZX&Uq=xfOh|TuOq#Of8aUkX1HM!I`~`Qb#P~s^abo^ zt1uo6S*h5S^ zM*LK}^e}Z@9Z}5uk+#88FGwfw zuiVST*hTxKMtH}I((?%Qy(A@x7w?bmz84~)({K)~8IV3A&w-0@lnqM9v8!K|o}S9# z79W->akRZDb;1)Fsb?zF4@w4dzcVO2_eXR!$v>r1;-@b}Cm6dVEg^pU-`qJmRZMdbF22IM!G8+Q zy${DXTxj@8FT(Iu>na<7*>FCb4{v}a@OrochMQJj?LRQ>gIIain!X?wQ+CIUyH=zv zzqYa5Jmti#2HV?c)-?OcX5TY@qis@FtMOW`WYVrP^;WHIl40^P*n2Gi>JjfFHn zWhK0NHx;*h$4wZJa<-V1y}8**QR%zFLqaHA&a=mn2het?hZnO4ONJH;&Ko!z$aA(R?eAOy4;kMm6zO;jn!nkW=tMQf61P? z>5V4Xm+uXRgIsVl7|nN@U?Ld7IFfG+d-I9j`dj6s;xd& zl8Wd!LqxtseoU$Wldlvo+ D_@L4I delta 1009 zcmXZaZA^`E7{~G7aeC4$W|hoRVwoA!v9;D#OwF_sm7%OwVJjgIOHb-lLQ0D@OPy)e zE9*tE8=HrDArCLGCTb(5QrR$;kZLlY(bbclbO@E%s;U%X}Ed7IRM5lKjX zFj=~b69pi;b^vB$tQY$Z1>|#<3+AZzHdMv{UoQL_Tk~^N9wF+I0)SxFe zpcgjbTx^?-J28)Sj8r=VtSqocO6DX&Gb9Iv;!JY^zihEZDFkE4cVIY5jtU>@@2I3Mfsq}S*;Xx0nthoucn(10n}dqi5vz~!8~FL6A+ z#moY8fv6(s4|PW|9WqgK2_4cvdnx0HJIkfp)ce@=7RH^gFcD>QQF8tL7$~mCd~ZGM)A%!^V7smNH*#n-^~;3|0%7Z zKJ|-l&OD*h(iPNi$&Y+1`bQDpV4Gg}6kWIa6?)?^2H+Q5i0)2l0S2I}(^byK7Wc;% n8Cf1nES5;4%yV_D(Uy?p B, converts B -> A" msgstr "" "Поменять модель местами. Вместо преобразования из A -> B, преобразуется B -> " "A" -#: lib/cli/args.py:907 +#: lib/cli/args.py:911 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Отключение многопоточной обработки. Медленнее, но менее ресурсоемко." -#: lib/cli/args.py:923 +#: lib/cli/args.py:927 msgid "" "Train a model on extracted original (A) and swap (B) faces.\n" "Training models can take a long time. Anything from 24hrs to over a week\n" @@ -736,11 +743,11 @@ msgstr "" "Обучение моделей может занять много времени. От 24 часов до недели.\n" "Плагины для моделей можно настроить в меню \"Настройки\"" -#: lib/cli/args.py:942 lib/cli/args.py:951 +#: lib/cli/args.py:946 lib/cli/args.py:955 msgid "faces" msgstr "лица" -#: lib/cli/args.py:943 +#: lib/cli/args.py:947 msgid "" "Input directory. A directory containing training images for face A. This is " "the original face, i.e. the face that you want to remove and replace with " @@ -749,7 +756,7 @@ msgstr "" "Входная папка. Папка, содержащая обучающие изображения для лица A. Это " "исходное лицо, т.е. лицо, которое вы хотите удалить и заменить лицом B." -#: lib/cli/args.py:952 +#: lib/cli/args.py:956 msgid "" "Input directory. A directory containing training images for face B. This is " "the swap face, i.e. the face that you want to place onto the head of person " @@ -758,12 +765,12 @@ msgstr "" "Входная папка. Папка, содержащая обучающие изображения для лица B. Это " "подменное лицо, т.е. лицо, которое вы хотите поместить на голову человека A." -#: lib/cli/args.py:960 lib/cli/args.py:972 lib/cli/args.py:988 -#: lib/cli/args.py:1013 lib/cli/args.py:1023 +#: lib/cli/args.py:964 lib/cli/args.py:976 lib/cli/args.py:992 +#: lib/cli/args.py:1017 lib/cli/args.py:1027 msgid "model" msgstr "модель" -#: lib/cli/args.py:961 +#: lib/cli/args.py:965 msgid "" "Model directory. This is where the training data will be stored. You should " "always specify a new folder for new models. If starting a new model, select " @@ -777,7 +784,7 @@ msgstr "" "создана). Если вы продолжаете обучение существующей модели, укажите " "местоположение существующей модели." -#: lib/cli/args.py:973 +#: lib/cli/args.py:977 msgid "" "R|Load the weights from a pre-existing model into a newly created model. For " "most models this will load weights from the Encoder of the given model into " @@ -801,7 +808,7 @@ msgstr "" "Примечание: Веса могут быть загружены только из моделей того же плагина, " "который вы собираетесь обучать." -#: lib/cli/args.py:989 +#: lib/cli/args.py:993 msgid "" "R|Select which trainer to use. Trainers can be configured from the Settings " "menu or the config folder.\n" @@ -846,7 +853,7 @@ msgstr "" "ресурсам (вам потребуется GPU с достаточным количеством VRAM). Хороша для " "детализации, но более восприимчива к цветовым различиям." -#: lib/cli/args.py:1014 +#: lib/cli/args.py:1018 msgid "" "Output a summary of the model and exit. If a model folder is provided then a " "summary of the saved model is displayed. Otherwise a summary of the model " @@ -857,7 +864,7 @@ msgstr "" "сводка сохраненной модели. В противном случае отображается сводка модели, " "которая будет создана выбранным плагином и настройками конфигурации." -#: lib/cli/args.py:1024 +#: lib/cli/args.py:1028 msgid "" "Freeze the weights of the model. Freezing weights means that some of the " "parameters in the model will no longer continue to learn, but those that are " @@ -871,12 +878,12 @@ msgstr "" "замораживание кодера, но некоторые модели могут иметь опции конфигурации для " "замораживания других слоев." -#: lib/cli/args.py:1037 lib/cli/args.py:1049 lib/cli/args.py:1063 -#: lib/cli/args.py:1078 lib/cli/args.py:1086 +#: lib/cli/args.py:1041 lib/cli/args.py:1053 lib/cli/args.py:1067 +#: lib/cli/args.py:1082 lib/cli/args.py:1090 msgid "training" msgstr "тренировка" -#: lib/cli/args.py:1038 +#: lib/cli/args.py:1042 msgid "" "Batch size. This is the number of images processed through the model for " "each side per iteration. NB: As the model is fed 2 sides at a time, the " @@ -889,7 +896,7 @@ msgstr "" "времени будет вдвое больше, чем заданное здесь. Большие партии требуют " "больше оперативной памяти GPU." -#: lib/cli/args.py:1050 +#: lib/cli/args.py:1054 msgid "" "Length of training in iterations. This is only really used for automation. " "There is no 'correct' number of iterations a model should be trained for. " @@ -904,7 +911,7 @@ msgstr "" "вы хотите, чтобы модель автоматически останавливалась при определенном " "количестве итераций, вы можете задать это значение здесь." -#: lib/cli/args.py:1064 +#: lib/cli/args.py:1068 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -927,7 +934,7 @@ msgstr "" "локальных GPU. Копия модели и все переменные загружаются на каждый GPU с " "распределением партий на каждый GPU на каждой итерации." -#: lib/cli/args.py:1079 +#: lib/cli/args.py:1083 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -936,7 +943,7 @@ msgstr "" "журналов означает, что вы не сможете использовать график или анализ для этой " "сессии в графическом интерфейсе." -#: lib/cli/args.py:1087 +#: lib/cli/args.py:1091 msgid "" "Use the Learning Rate Finder to discover the optimal learning rate for " "training. For new models, this will calculate the optimal learning rate for " @@ -951,15 +958,15 @@ msgstr "" "модели. Установка этой опции приведет к игнорированию вручную настроенного " "коэффициента обучения (настраиваемого в параметрах обучения)." -#: lib/cli/args.py:1100 lib/cli/args.py:1110 +#: lib/cli/args.py:1104 lib/cli/args.py:1114 msgid "Saving" msgstr "Сохранение" -#: lib/cli/args.py:1101 +#: lib/cli/args.py:1105 msgid "Sets the number of iterations between each model save." msgstr "Устанавливает количество итераций между каждым сохранением модели." -#: lib/cli/args.py:1111 +#: lib/cli/args.py:1115 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -968,11 +975,11 @@ msgstr "" "Устанавливает количество итераций перед сохранением резервного снимка модели " "в текущем состоянии. Установите значение 0 для выключения." -#: lib/cli/args.py:1118 lib/cli/args.py:1129 lib/cli/args.py:1140 +#: lib/cli/args.py:1122 lib/cli/args.py:1133 lib/cli/args.py:1144 msgid "timelapse" msgstr "таймлапс" -#: lib/cli/args.py:1119 +#: lib/cli/args.py:1123 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -986,7 +993,7 @@ msgstr "" "создания timelapse. Вы также должны указать параметры --timelapse-output и --" "timelapse-input-B." -#: lib/cli/args.py:1130 +#: lib/cli/args.py:1134 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -1000,7 +1007,7 @@ msgstr "" "создания timelapse. Вы также должны указать параметры --timelapse-output и --" "timelapse-input-A." -#: lib/cli/args.py:1141 +#: lib/cli/args.py:1145 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -1012,15 +1019,15 @@ msgstr "" "указаны входные папки, но нет выходной папки, то по умолчанию будет выбрана " "папка модели /timelapse/" -#: lib/cli/args.py:1150 lib/cli/args.py:1157 +#: lib/cli/args.py:1154 lib/cli/args.py:1161 msgid "preview" msgstr "предпросмотр" -#: lib/cli/args.py:1151 +#: lib/cli/args.py:1155 msgid "Show training preview output. in a separate window." msgstr "Показать вывод предварительного просмотра тренировки в отдельном окне." -#: lib/cli/args.py:1158 +#: lib/cli/args.py:1162 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -1028,12 +1035,12 @@ msgstr "" "Записывает результат обучения в файл. Изображение будет сохранено в корне " "папки Faceswap." -#: lib/cli/args.py:1165 lib/cli/args.py:1174 lib/cli/args.py:1183 -#: lib/cli/args.py:1192 +#: lib/cli/args.py:1169 lib/cli/args.py:1178 lib/cli/args.py:1187 +#: lib/cli/args.py:1196 msgid "augmentation" msgstr "аугментация" -#: lib/cli/args.py:1166 +#: lib/cli/args.py:1170 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -1043,7 +1050,7 @@ msgstr "" "набора лиц вместо случайного искажения лица. Это способ выполнения искажения " "от \"dfaker\" ." -#: lib/cli/args.py:1175 +#: lib/cli/args.py:1179 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -1053,7 +1060,7 @@ msgstr "" "горизонтали. Иногда желательно, чтобы этого не происходило. Как правило, это " "не нужно делать, за исключением случаев \"тренировки подгонки\"." -#: lib/cli/args.py:1184 +#: lib/cli/args.py:1188 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -1064,7 +1071,7 @@ msgstr "" "времени на обучение. Включите этот параметр для отключения цветовой " "аугментации." -#: lib/cli/args.py:1193 +#: lib/cli/args.py:1197 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -1076,7 +1083,7 @@ msgstr "" "больше деталей. Считайте это \"тонкой настройкой\". Включение этой опции в " "самом начале, скорее всего, погубит модель и приведет к ужасным результатам." -#: lib/cli/args.py:1218 +#: lib/cli/args.py:1222 msgid "Output to Shell console instead of GUI console" msgstr "Вывод в консоль Shell вместо консоли GUI" diff --git a/plugins/convert/writer/_base.py b/plugins/convert/writer/_base.py index d283e1009d..3166c108fa 100644 --- a/plugins/convert/writer/_base.py +++ b/plugins/convert/writer/_base.py @@ -68,6 +68,36 @@ def is_stream(self) -> bool: retval = hasattr(self, "frame_order") return retval + @classmethod + def _set_frame_order(cls, + total_count: int, + frame_ranges: list[tuple[int, int]] | None) -> list[int]: + """ Obtain the full list of frames to be converted in order. + + Used for FFMPEG and Gif writers to ensure correct frame order + + Parameters + ---------- + total_count: int + The total number of frames to be converted + frame_ranges: list or ``None`` + List of tuples for starting and end values of each frame range to be converted or + ``None`` if all frames are to be converted + + Returns + ------- + list + Full list of all frame indices to be converted + """ + if frame_ranges is None: + retval = list(range(1, total_count + 1)) + else: + retval = [] + for rng in frame_ranges: + retval.extend(list(range(rng[0], rng[1] + 1))) + logger.debug("frame_order: %s", retval) + return retval + def output_filename(self, filename: str, separate_mask: bool = False) -> list[str]: """ Obtain the full path for the output file, including the correct extension, for the given input filename. @@ -136,7 +166,7 @@ def write(self, filename: str, image: T.Any) -> None: """ raise NotImplementedError - def pre_encode(self, image: np.ndarray) -> T.Any: # pylint: disable=unused-argument + def pre_encode(self, image: np.ndarray, **kwargs) -> T.Any: # pylint: disable=unused-argument """ Some writer plugins support the pre-encoding of images prior to saving out. As patching is done in multiple threads, but writing is done in a single thread, it can speed up the process to do any pre-encoding as part of the converter process. diff --git a/plugins/convert/writer/ffmpeg.py b/plugins/convert/writer/ffmpeg.py index 8e3e4b16a0..92143a13bd 100644 --- a/plugins/convert/writer/ffmpeg.py +++ b/plugins/convert/writer/ffmpeg.py @@ -46,7 +46,7 @@ def __init__(self, self._source_video: str = source_video self._output_filename: str = self._get_output_filename() self._frame_ranges: list[tuple[int, int]] | None = frame_ranges - self.frame_order: list[int] = self._set_frame_order(total_count) + self._frame_order: list[int] = self._set_frame_order(total_count, frame_ranges) self._output_dimensions: str | None = None # Fix dims on 1st received frame # Need to know dimensions of first frame, so set writer then self._writer: Generator[None, np.ndarray, None] | None = None @@ -174,28 +174,6 @@ def _get_output_filename(self) -> str: logger.info("Outputting to: '%s'", retval) return retval - def _set_frame_order(self, total_count: int) -> list[int]: - """ Obtain the full list of frames to be converted in order. - - Parameters - ---------- - total_count: int - The total number of frames to be converted - - Returns - ------- - list - Full list of all frame indices to be converted - """ - if self._frame_ranges is None: - retval = list(range(1, total_count + 1)) - else: - retval = [] - for rng in self._frame_ranges: - retval.extend(list(range(rng[0], rng[1] + 1))) - logger.debug("frame_order: %s", retval) - return retval - def _get_writer(self, frame_dims: tuple[int, int]) -> Generator[None, np.ndarray, None]: """ Add the requested encoding options and return the writer. @@ -268,11 +246,11 @@ def _save_from_cache(self) -> None: """ Writes any consecutive frames to the video container that are ready to be output from the cache. """ assert self._writer is not None - while self.frame_order: - if self.frame_order[0] not in self.cache: + while self._frame_order: + if self._frame_order[0] not in self.cache: logger.trace("Next frame not ready. Continuing") # type:ignore[attr-defined] break - save_no = self.frame_order.pop(0) + save_no = self._frame_order.pop(0) save_image = self.cache.pop(save_no) logger.trace("Rendering from cache. Frame no: %s", # type:ignore[attr-defined] save_no) diff --git a/plugins/convert/writer/gif.py b/plugins/convert/writer/gif.py index bfa813201d..eb5e0d2752 100644 --- a/plugins/convert/writer/gif.py +++ b/plugins/convert/writer/gif.py @@ -36,7 +36,7 @@ def __init__(self, **kwargs) -> None: logger.debug("total_count: %s, frame_ranges: %s", total_count, frame_ranges) super().__init__(output_folder, **kwargs) - self.frame_order: list[int] = self._set_frame_order(total_count, frame_ranges) + self._frame_order: list[int] = self._set_frame_order(total_count, frame_ranges) # Fix dims on 1st received frame self._output_dimensions: tuple[int, int] | None = None # Need to know dimensions of first frame, so set writer then @@ -50,33 +50,6 @@ def _gif_params(self) -> dict: logger.debug(kwargs) return kwargs - @staticmethod - def _set_frame_order(total_count: int, - frame_ranges: list[tuple[int, int]] | None) -> list[int]: - """ Obtain the full list of frames to be converted in order. - - Parameters - ---------- - total_count: int - The total number of frames to be converted - frame_ranges: list or ``None`` - List of tuples for starting and end values of each frame range to be converted or - ``None`` if all frames are to be converted - - Returns - ------- - list - Full list of all frame indices to be converted - """ - if frame_ranges is None: - retval = list(range(1, total_count + 1)) - else: - retval = [] - for rng in frame_ranges: - retval.extend(list(range(rng[0], rng[1] + 1))) - logger.debug("frame_order: %s", retval) - return retval - def _get_writer(self) -> im_format.Format.Writer: """ Obtain the GIF writer with the requested GIF encoding options. @@ -159,11 +132,11 @@ def _save_from_cache(self) -> None: """ Writes any consecutive frames to the GIF container that are ready to be output from the cache. """ assert self._writer is not None - while self.frame_order: - if self.frame_order[0] not in self.cache: + while self._frame_order: + if self._frame_order[0] not in self.cache: logger.trace("Next frame not ready. Continuing") # type: ignore break - save_no = self.frame_order.pop(0) + save_no = self._frame_order.pop(0) save_image = self.cache.pop(save_no) logger.trace("Rendering from cache. Frame no: %s", save_no) # type: ignore self._writer.append_data(save_image[:, :, ::-1]) diff --git a/plugins/convert/writer/opencv.py b/plugins/convert/writer/opencv.py index d18d71a6cd..caf5287eb9 100644 --- a/plugins/convert/writer/opencv.py +++ b/plugins/convert/writer/opencv.py @@ -75,7 +75,7 @@ def write(self, filename: str, image: list[bytes]) -> None: except Exception as err: # pylint: disable=broad-except logger.error("Failed to save image '%s'. Original Error: %s", filename, err) - def pre_encode(self, image: np.ndarray) -> list[bytes]: + def pre_encode(self, image: np.ndarray, **kwargs) -> list[bytes]: """ Pre_encode the image in lib/convert.py threads as it is a LOT quicker. Parameters diff --git a/plugins/convert/writer/patch.py b/plugins/convert/writer/patch.py new file mode 100644 index 0000000000..71c36e5d9e --- /dev/null +++ b/plugins/convert/writer/patch.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +""" Face patch output writer for faceswap.py converter + Extracts the swapped Face Patch from faceswap rather than the final composited frame along with + the transformation matrix for re-inserting the face into the origial frame +""" +import json +import logging + +import os +import cv2 +import numpy as np + +from lib.image import encode_image, png_read_meta, tiff_read_meta +from ._base import Output + +logger = logging.getLogger(__name__) + + +class Writer(Output): + """ Face patch writer for outputting swapped face patches and transformation matrices + + Parameters + ---------- + output_folder: str + The full path to the output folder where the face patches should besaved + patch_size: int + The size of the face patch output from the model + configfile: str, optional + The full path to a custom configuration ini file. If ``None`` is passed + then the file is loaded from the default location. Default: ``None``. + """ + def __init__(self, output_folder: str, patch_size: int, **kwargs) -> None: + logger.debug("patch_size: %s", patch_size) + super().__init__(output_folder, **kwargs) + self._extension = {"png": ".png", "tiff": ".tif"}[self.config["format"]] + self._separate_mask = self.config["separate_mask"] + + if self._extension == ".png" and self.config["bit_depth"] not in ("8", "16"): + logger.warning("Patch Writer: Bit Depth '%s' is unsupported for format '%s'. " + "Updating to '16'", self.config["bit_depth"], self.config["format"]) + self.config["bit_depth"] = "16" + + self._dtype = {"8": np.uint8, "16": np.uint16, "32": np.float32}[self.config["bit_depth"]] + self._multiplier = {"8": 255., "16": 65535., "32": 1.}[self.config["bit_depth"]] + + self._dummy_patch = np.zeros((1, patch_size, patch_size, 4), dtype=np.float32) + + tl_box = np.array([[0, 0], [128, 0], [128, 128], [0, 128]], dtype=np.float32) + self._patch_corner = {"top-left": tl_box[0], + "top-right": tl_box[1], + "bottom-right": tl_box[2], + "bottom-left": tl_box[3]}[self.config["origin"]].copy() + self._box = tl_box + if self.config["origin"] in ("top-right", "bottom-left"): + self._box[[1, 3], :] = self._box[[3, 1], :] # keep clockwise from 0,0 + + self._args = self._get_save_args() + self._matrices: dict[str, dict[str, list[list[float]]]] = {} + + def _get_save_args(self) -> tuple[int, ...]: + """ Obtain the save parameters for the file format. + + Returns + ------- + tuple + The OpenCV specific arguments for the selected file format + """ + args: tuple[int, ...] = tuple() + if self._extension == ".png" and self.config["png_compress_level"] > -1: + args = (cv2.IMWRITE_PNG_COMPRESSION, self.config["png_compress_level"]) + if self._extension == ".tif" and self.config["bit_depth"] != "32": + tiff_methods = {"none": 1, "lzw": 5, "deflate": 8} + method = self.config["tiff_compression_method"] + method = "none" if method is None else method + args = (cv2.IMWRITE_TIFF_COMPRESSION, tiff_methods[method]) + logger.debug(args) + return args + + def _get_new_filename(self, filename: str, face_index: int) -> str: + """ Obtain the filename for the output file based on the frame's filename and the user + selected naming options + + Parameters + ---------- + filename: str + The original frame's filename + face_index: int + The index of the face within the frame + + Returns + ------- + str + The new filename for naming the output face patch + """ + face_idx = str(face_index).rjust(2, "0") + fname, ext = os.path.splitext(filename) + split_fname = fname.rsplit("_", 1) + if split_fname[-1].isdigit(): + i_frame_no = (int(split_fname[-1]) + + (int(self.config["start_index"]) - 1) + + self.config["index_offset"]) + frame_no = f".{str(i_frame_no).rjust(self.config['number_padding'], '0')}" + else: + frame_no = "" + + retval = "" + if self.config["include_filename"]: + retval += f"{split_fname[0]}" + if self.config["face_index_location"] == "before": + retval = f"{retval}_{face_idx}" + retval += frame_no + if self.config["face_index_location"] == "after": + retval = f"{retval}.{face_idx}" + retval += ext + logger.trace("source filename: '%s', output filename: '%s'", # type:ignore[attr-defined] + filename, retval) + return retval + + def write(self, filename: str, image: list[list[bytes]]) -> None: + """ Write out the pre-encoded image to disk. If separate mask has been selected, write out + the encoded mask to a sub-folder in the output directory. + + Parameters + ---------- + filename: str + The full path to write out the image to. + image: list[list[bytes]] + List of list of :class:`bytes` objects of containing all swapped faces from a frame to + write out. The inner list will be of length 1 (mask included in the alpha channel) or + length 2 (mask to write out separately) + """ + logger.trace("Outputting: (filename: '%s')", filename) # type:ignore[attr-defined] + + read_func = png_read_meta if self._extension == ".png" else tiff_read_meta + for idx, face in enumerate(image): + new_filename = self._get_new_filename(filename, idx) + filenames = self.output_filename(new_filename, self._separate_mask) + for fname, img in zip(filenames, face): + try: + with open(fname, "wb") as outfile: + outfile.write(img) + except Exception as err: # pylint: disable=broad-except + logger.error("Failed to save image '%s'. Original Error: %s", filename, err) + if not self.config["json_output"]: + continue + mat = read_func(img) + self._matrices[os.path.splitext(os.path.basename(fname))[0]] = mat + + @classmethod + def _get_inverse_matrices(cls, matrices: np.ndarray) -> np.ndarray: + """ Obtain the inverse matrices for the given matrices. If ``None`` is supplied return a + dummy transformation matrix that performs no action + + Parameters + ---------- + matrices : :class:`numpy.ndarray` + The original transform matrices that the inverse needs to be calculated for + + Returns + ------- + :class:`numpy.ndarray` + The inverse transformation matrices + """ + if not np.any(matrices): + return np.array([[[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]]], dtype=np.float32) + + identity = np.array([[[0., 0., 1.]]], dtype=np.float32) + mat = np.concatenate([matrices, np.repeat(identity, matrices.shape[0], axis=0)], axis=1) + retval = np.linalg.inv(mat) + logger.trace("matrix: %s, inverse: %s", mat, retval) # type:ignore[attr-defined] + return retval + + def _adjust_to_origin(self, matrices: np.ndarray, canvas_size: tuple[int, int]) -> None: + """ Adjust the transformation matrix to use the correct target coordinates system. The + matrix adjustment is done in place, so this does not return a value + + Parameters + ---------- + matrices: :class:`numpy.ndarray` + The transformation matrices to be adjusted + canvas_size: tuple[int, int] + The size of the canvas width, height) that the transformation matrix applies to. + """ + if self.config["origin"] == "top-left": + return + + for mat in matrices: + og_cnr = cv2.transform(self._patch_corner[None, None], mat[:2, ...]).squeeze() + x_shift, y_shift = og_cnr + if self.config["origin"].split("-")[-1] == "right": + x_shift = canvas_size[0] - x_shift + if self.config["origin"].split("-")[0] == "bottom": + y_shift = canvas_size[1] - y_shift + mat[:2, 2] = [x_shift, y_shift] + + if self.config["origin"] in ("top-right", "bottom-left"): + matrices[..., :2, :2] *= [[[1, -1], [-1, 1]]] # switch shear + + def _get_roi(self, matrices: np.ndarray) -> np.ndarray: + """ Obtain the (x, y) ROI points of the patch in the original frame. Points are returned + in clockwise order from the origin location + + Parameters + ---------- + matrices: :class:`numpy.ndarray` + The transformation matrices for the current frame + + Returns + ------- + np.ndarray + The ROI of the patches in original frame co-ordinates in clockwise order from the + origin point + """ + retval = [cv2.transform(np.expand_dims(self._box, axis=1), mat[:2, ...]).squeeze() + for mat in matrices] + return np.array(retval, dtype=np.float32) + + def pre_encode(self, image: np.ndarray, **kwargs) -> list[list[bytes]]: + """ Pre_encode the image in lib/convert.py threads as it is a LOT quicker. + + Parameters + ---------- + image: :class:`numpy.ndarray` + A 3 or 4 channel BGR swapped face batch as float32 + canvas_size: tuple[int, int] + The size of the canvas (x, y) that the transformation matrix applies to. + matrices: :class:`numpy.ndarray`, optional + The transformation matrices for extracting the face patches from the original frame. + Must be provided if an image is provided, otherwise ``None`` to insert a dummy matrix + + Returns + ------- + list + List of :class:`bytes` objects ready for writing. The list will be of length 1 with + image bytes object as the only member unless separate mask has been requested, in which + case it will be length 2 with the image in position 0 and mask in position 1 + """ + logger.trace("Pre-encoding image") # type:ignore[attr-defined] + retval = [] + canvas_size: tuple[int, int] = kwargs.get("canvas_size", (1, 1)) + matrices: np.ndarray = kwargs.get("matrices", np.array([])) + + if not np.any(image) and self.config["empty_frames"] == "blank": + image = self._dummy_patch + + matrices = self._get_inverse_matrices(matrices) + self._adjust_to_origin(matrices, canvas_size) + rois = self._get_roi(matrices) + patches = (image * self._multiplier).astype(self._dtype) + + for patch, matrix, roi in zip(patches, matrices, rois): + this_face = [] + mat = json.dumps({"transform_matrix": matrix.tolist(), "roi": roi.tolist()}, + ensure_ascii=True).encode("ascii") + if self._separate_mask: + mask = patch[..., -1] + face = patch[..., :3] + + this_face.append(encode_image(mask, + self._extension, + encoding_args=self._args, + metadata=mat)) + else: + face = patch + + this_face.insert(0, encode_image(face, + self._extension, + encoding_args=self._args, + metadata=mat)) + retval.append(this_face) + return retval + + def close(self) -> None: + """ Outputs json file if requested """ + if not self.config["json_output"]: + return + fname = os.path.join(self.output_folder, "matrices.json") + with open(fname, "w", encoding="utf-8") as ofile: + json.dump(self._matrices, ofile, indent=2) + logger.info("Patch matrices written to: '%s'", fname) diff --git a/plugins/convert/writer/patch_defaults.py b/plugins/convert/writer/patch_defaults.py new file mode 100755 index 0000000000..76f3f4c6e2 --- /dev/null +++ b/plugins/convert/writer/patch_defaults.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +""" + The default options for the faceswap patch Writer plugin. + + Defaults files should be named _defaults.py + Any items placed into this file will automatically get added to the relevant config .ini files + within the faceswap/config folder. + + The following variables should be defined: + _HELPTEXT: A string describing what this plugin does + _DEFAULTS: A dictionary containing the options, defaults and meta information. The + dictionary should be defined as: + {: {}} + + should always be lower text. + dictionary requirements are listed below. + + The following keys are expected for the _DEFAULTS dict: + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid datatypes are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and datatypes this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and datatypes this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. +""" + +_HELPTEXT = ( + "Options for outputting the raw converted face patches from faceswap\n" + "The raw face patches are output along with the transformation matrix, per face, to " + "transform the face back into the original frame in external tools" +) + +_DEFAULTS = { + "start_index": { + "default": "0", + "info": "The starting frame number for the first output frame.", + "datatype": str, + "choices": ["0", "1"], + "group": "file_naming", + "gui_radio": True, + }, + "index_offset": { + "default": 0, + "info": "How much to offset the frame numbering by.", + "datatype": int, + "rounding": 1, + "min_max": (0, 1000), + "group": "file_naming", + }, + "number_padding": { + "default": 6, + "info": "Length to pad the frame numbers by.", + "datatype": int, + "rounding": 6, + "min_max": (0, 10), + "group": "file_naming", + }, + "include_filename": { + "default": True, + "info": "Prefix the filename of the original frame to each face patch's output filename.", + "datatype": bool, + "group": "file_naming", + }, + "face_index_location": { + "default": "before", + "info": "For frames that contain multiple faces, where the face index should appear in " + "the filename:" + "\n\t before: places the face index before the frame number." + "\n\t after: places the face index after the frame number.", + "datatype": str, + "choices": ["before", "after"], + "group": "file_naming", + "gui_radio": True, + }, + "origin": { + "default": "bottom-left", + "info": "The origin (0, 0) location of the software that patches will be imported into. " + "This impacts the transformation matrix that is supplied with the image patch. " + "Setting the correct origin here will make importing into the external tool " + "simpler." + "\n\t top-left: The origin (0, 0) of the external canvas is at the top left " + "corner." + "\n\t bottom-left: The origin (0, 0) of the external canvas is at the bottom " + "left corner." + "\n\t top-right: The origin (0, 0) of the external canvas is at the top right " + "corner." + "\n\t bottom-right: The origin (0, 0) of the external canvas is at the bottom " + "right corner.", + "datatype": str, + "choices": ["top-left", "bottom-left", "top-right", "bottom-right"], + "group": "output", + "gui_radio": True + }, + "empty_frames": { + "default": "blank", + "info": "How to handle the output of frames without faces:" + "\n\t skip: skips any frames that do not have a face within it. This will lead to " + "gaps within the final image sequence." + "\n\t blank: outputs a blank (empty) face patch for any frames without faces. " + "There will be no gaps within the final image sequence, as those gaps will be " + "padded with empty face patches", + "datatype": str, + "choices": ["skip", "blank"], + "group": "output", + "gui_radio": True, + }, + "json_output": { + "default": False, + "info": "The transformation matrix, and other associated metadata, is output within the " + "face images EXIF fields. Some external tools can read this data, others cannot." + "enable this option to output a json file which contains this same metadata " + "mapped to each output face patch's filename.", + "datatype": bool, + "group": "output" + }, + "separate_mask": { + "default": False, + "info": "Seperate the mask into its own single channel patch. If enabled, the RGB image " + "will be saved into the selected output folder whilst the masks will be saved " + "into a sub-folder named `masks`. If not enabled then the mask will be included " + "in the alpha-channel of the RGBA output.", + "datatype": bool, + "group": "output", + }, + "bit_depth": { + "default": "16", + "info": "The bit-depth for the output images:" + "\n\t 8: 8-bit unsigned - Supported by all formats." + "\n\t 16: 16-bit unsigned - Supported by all formats." + "\n\t 32: 32-bit float - Supported by Tiff only.", + "datatype": str, + "choices": ["8", "16", "32"], + "group": "format", + "gui_radio": True, + }, + "format": { + "default": "png", + "info": "File format to save as." + "\n\t png: PNG file format. Transformation matrix is written to the custom iTxt " + "header field 'faceswap'" + "\n\t tiff: TIFF file format. Transformation matrix is written to the " + "'image_description' header field", + "datatype": str, + "choices": ["png", "tiff"], + "group": "format", + "gui_radio": True + }, + "png_compress_level": { + "default": 3, + "info": "ZLIB compression level, 1 gives best speed, 9 gives best compression, 0 gives no " + "compression at all.", + "datatype": int, + "rounding": 1, + "min_max": (0, 9), + "group": "format", + }, + "tiff_compression_method": { + "default": "lzw", + "info": "The compression method to use for Tiff files. Note: For 32bit output, SGILOG " + "compression will always be used regardless of what is selected here.", + "datatype": str, + "choices": ["none", "lzw", "deflate"], + "group": "format", + "gui_radio": True + }, +} diff --git a/plugins/convert/writer/pillow.py b/plugins/convert/writer/pillow.py index a9ffb0aa88..751e5675a9 100644 --- a/plugins/convert/writer/pillow.py +++ b/plugins/convert/writer/pillow.py @@ -78,7 +78,7 @@ def write(self, filename: str, image: list[BytesIO]) -> None: except Exception as err: # pylint: disable=broad-except logger.error("Failed to save image '%s'. Original Error: %s", filename, err) - def pre_encode(self, image: np.ndarray) -> list[BytesIO]: + def pre_encode(self, image: np.ndarray, **kwargs) -> list[BytesIO]: """ Pre_encode the image in lib/convert.py threads as it is a LOT quicker Parameters diff --git a/scripts/convert.py b/scripts/convert.py index 89cc56bf90..38a5eebb72 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -95,8 +95,9 @@ def __init__(self, arguments: Namespace) -> None: self._opts = OptionalActions(self._args, self._images.file_list, self._alignments) self._add_queues() - self._disk_io = DiskIO(self._alignments, self._images, arguments) - self._predictor = Predict(self._disk_io.load_queue, self._queue_size, arguments) + self._predictor = Predict(self._queue_size, arguments) + self._disk_io = DiskIO(self._alignments, self._images, self._predictor, arguments) + self._predictor.launch(self._disk_io.load_queue) self._validate() get_folder(self._args.output_dir) @@ -280,15 +281,20 @@ class DiskIO(): The alignments for the input video images: :class:`lib.image.ImagesLoader` The input images + predictor: :class:`Predict` + The object for generating predictions from the model arguments: :class:`argparse.Namespace` The arguments that were passed to the convert process as generated from Faceswap's command line arguments """ def __init__(self, - alignments: Alignments, images: ImagesLoader, arguments: Namespace) -> None: - logger.debug("Initializing %s: (alignments: %s, images: %s, arguments: %s)", - self.__class__.__name__, alignments, images, arguments) + alignments: Alignments, + images: ImagesLoader, + predictor: Predict, + arguments: Namespace) -> None: + logger.debug("Initializing %s: (alignments: %s, images: %s, predictor: %s, arguments: %s)", + self.__class__.__name__, alignments, images, predictor, arguments) self._alignments = alignments self._images = images self._args = arguments @@ -298,7 +304,7 @@ def __init__(self, # For frame skipping self._imageidxre = re.compile(r"(\d+)(?!.*\d\.)(?=\.\w+$)") self._frame_ranges = self._get_frame_ranges() - self._writer = self._get_writer() + self._writer = self._get_writer(predictor) # Extractor for on the fly detection self._extractor = self._load_extractor() @@ -320,13 +326,12 @@ def draw_transparent(self) -> bool: return self._writer.config.get("draw_transparent", False) @property - def pre_encode(self) -> Callable[[np.ndarray], list[bytes]] | None: + def pre_encode(self) -> Callable[[np.ndarray, T.Any], list[bytes]] | None: """ python function: Selected writer's pre-encode function, if it has one, otherwise ``None`` """ dummy = np.zeros((20, 20, 3), dtype="uint8") test = self._writer.pre_encode(dummy) - retval: Callable[[np.ndarray], - list[bytes]] | None = None if test is None else self._writer.pre_encode + retval: Callable | None = None if test is None else self._writer.pre_encode logger.debug("Writer pre_encode function: %s", retval) return retval @@ -359,9 +364,14 @@ def _total_count(self) -> int: return retval # Initialization - def _get_writer(self) -> Output: + def _get_writer(self, predictor: Predict) -> Output: """ Load the selected writer plugin. + Parameters + ---------- + predictor: :class:`Predict` + The object for generating predictions from the model + Returns ------- :mod:`plugins.convert.writer` plugin @@ -375,6 +385,8 @@ def _get_writer(self) -> Output: args.append(self._args.input_dir) else: args.append(self._args.reference_video) + if self._args.writer == "patch": + args.append(predictor.output_size) logger.debug("Writer args: %s", args) configfile = self._args.configfile if hasattr(self._args, "configfile") else None return PluginLoader.get_converter("writer", self._args.writer)(*args, @@ -564,7 +576,7 @@ def _check_skipframe(self, filename: str) -> bool: return False idx = int(indices[0]) skipframe = not any(map(lambda b: b[0] <= idx <= b[1], self._frame_ranges)) - logger.trace("idx: %s, skipframe: %s", idx, skipframe) # type: ignore + logger.trace("idx: %s, skipframe: %s", idx, skipframe) # type: ignore[attr-defined] return skipframe def _get_detected_faces(self, filename: str, image: np.ndarray) -> list[DetectedFace]: @@ -682,7 +694,7 @@ def _save(self, completion_event: Event) -> None: if self._queues["save"].shutdown.is_set(): logger.debug("Save Queue: Stop signal received. Terminating") break - item = self._queues["save"].get() + item: tuple[str, np.ndarray | bytes] | T.Literal["EOF"] = self._queues["save"].get() if item == "EOF": logger.debug("EOF Received") break @@ -702,19 +714,17 @@ class Predict(): Parameters ---------- - in_queue: :class:`~lib.queue_manager.EventQueue` - The queue that contains images and detected faces for feeding the model queue_size: int The maximum size of the input queue arguments: :class:`argparse.Namespace` The arguments that were passed to the convert process as generated from Faceswap's command line arguments """ - def __init__(self, in_queue: EventQueue, queue_size: int, arguments: Namespace) -> None: - logger.debug("Initializing %s: (args: %s, queue_size: %s, in_queue: %s)", - self.__class__.__name__, arguments, queue_size, in_queue) + def __init__(self, queue_size: int, arguments: Namespace) -> None: + logger.debug("Initializing %s: (args: %s, queue_size: %s)", + self.__class__.__name__, arguments, queue_size) self._args = arguments - self._in_queue = in_queue + self._in_queue: EventQueue | None = None self._out_queue = queue_manager.get_queue("patch") self._serializer = get_serializer("json") self._faces_count = 0 @@ -726,18 +736,20 @@ def __init__(self, in_queue: EventQueue, queue_size: int, arguments: Namespace) self._coverage_ratio = self._model.coverage_ratio self._centering = self._model.config["centering"] - self._thread = self._launch_predictor() + self._thread: MultiThread | None = None logger.debug("Initialized %s: (out_queue: %s)", self.__class__.__name__, self._out_queue) @property def thread(self) -> MultiThread: """ :class:`~lib.multithreading.MultiThread`: The thread that is running the prediction function from the Faceswap model. """ + assert self._thread is not None return self._thread @property def in_queue(self) -> EventQueue: """ :class:`~lib.queue_manager.EventQueue`: The input queue to the predictor. """ + assert self._in_queue is not None return self._in_queue @property @@ -870,19 +882,19 @@ def _get_model_name(self, model_dir: str) -> str: logger.debug("Trainer from state file: '%s'", trainer) return trainer - def _launch_predictor(self) -> MultiThread: + def launch(self, load_queue: EventQueue) -> None: """ Launch the prediction process in a background thread. Starts the prediction thread and returns the thread. - Returns - ------- - :class:`~lib.multithreading.MultiThread` - The started Faceswap model prediction thread. + Parameters + ---------- + load_queue: :class:`~lib.queue_manager.EventQueue` + The queue that contains images and detected faces for feeding the model """ - thread = MultiThread(self._predict_faces, thread_count=1) - thread.start() - return thread + self._in_queue = load_queue + self._thread = MultiThread(self._predict_faces, thread_count=1) + self._thread.start() def _predict_faces(self) -> None: """ Run Prediction on the Faceswap model in a background thread. @@ -893,6 +905,7 @@ def _predict_faces(self) -> None: faces_seen = 0 consecutive_no_faces = 0 batch: list[ConvertItem] = [] + assert self._in_queue is not None while True: item: T.Literal["EOF"] | ConvertItem = self._in_queue.get() if item == "EOF": From f23c87c899e4e0935d2b5baac04e45a6e43fd9e5 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 29 Sep 2023 14:50:52 +0100 Subject: [PATCH 865/981] bugfix: Preview tool regression --- tools/preview/preview.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tools/preview/preview.py b/tools/preview/preview.py index cdacc1ae3a..945799f90f 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -295,9 +295,8 @@ def __init__(self, app: Preview, arguments: Namespace, sample_size: int) -> None self._filelist = self._get_filelist() self._indices = self._get_indices() - self._predictor = Predict(queue_manager.get_queue("preview_predict_in"), - self._sample_size, - arguments) + self._predictor = Predict(self._sample_size, arguments) + self._predictor.launch(queue_manager.get_queue("preview_predict_in")) self._app._display.set_centering(self._predictor.centering) self.generate() From 3336772ff139382a2565b8ffe477d7f7ae0540a2 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 30 Sep 2023 12:19:04 +0100 Subject: [PATCH 866/981] bugfix: patch writer - remove hard coded values --- plugins/convert/writer/patch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/convert/writer/patch.py b/plugins/convert/writer/patch.py index 71c36e5d9e..74671c6567 100644 --- a/plugins/convert/writer/patch.py +++ b/plugins/convert/writer/patch.py @@ -45,7 +45,8 @@ def __init__(self, output_folder: str, patch_size: int, **kwargs) -> None: self._dummy_patch = np.zeros((1, patch_size, patch_size, 4), dtype=np.float32) - tl_box = np.array([[0, 0], [128, 0], [128, 128], [0, 128]], dtype=np.float32) + tl_box = np.array([[0, 0], [patch_size, 0], [patch_size, patch_size], [0, patch_size]], + dtype=np.float32) self._patch_corner = {"top-left": tl_box[0], "top-right": tl_box[1], "bottom-right": tl_box[2], From 8e6c6c3500533135cd0f13974e2fdffe5e04231b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 4 Oct 2023 10:47:59 +0100 Subject: [PATCH 867/981] patch writer: Sort the json file by key --- plugins/convert/writer/patch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/convert/writer/patch.py b/plugins/convert/writer/patch.py index 74671c6567..2242323fd0 100644 --- a/plugins/convert/writer/patch.py +++ b/plugins/convert/writer/patch.py @@ -277,5 +277,5 @@ def close(self) -> None: return fname = os.path.join(self.output_folder, "matrices.json") with open(fname, "w", encoding="utf-8") as ofile: - json.dump(self._matrices, ofile, indent=2) + json.dump(self._matrices, ofile, indent=2, sort_keys=True) logger.info("Patch matrices written to: '%s'", fname) From 4557331cb9ad396414fa8e604353447f8bfde89f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 24 Oct 2023 23:47:08 +0100 Subject: [PATCH 868/981] bugfix: Preview in GUI when converting to vid/gif --- plugins/convert/writer/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/convert/writer/_base.py b/plugins/convert/writer/_base.py index 3166c108fa..2961683b91 100644 --- a/plugins/convert/writer/_base.py +++ b/plugins/convert/writer/_base.py @@ -65,7 +65,7 @@ def is_stream(self) -> bool: Writers that write to a stream have a frame_order paramater to dictate the order in which frames should be written out (eg. gif/ffmpeg) """ - retval = hasattr(self, "frame_order") + retval = hasattr(self, "_frame_order") return retval @classmethod From a62a85c0215c1d791dd5ca705ba5a3fef08f0ffd Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 17 Nov 2023 02:09:55 +0000 Subject: [PATCH 869/981] bugfix: setup.py - Force pip for imagio-ffmpeg --- setup.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index 42bdb7a22b..e1b8c191de 100755 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ from lib.logger import log_setup logger = logging.getLogger(__name__) # pylint: disable=invalid-name -backend_type: T.TypeAlias = T.Literal['nvidia', 'apple_silicon', 'directml', 'cpu', 'rocm'] +backend_type: T.TypeAlias = T.Literal['nvidia', 'apple_silicon', 'directml', 'cpu', 'rocm', "all"] _INSTALL_FAILED = False # Packages that are explicitly required for setup.py @@ -34,7 +34,9 @@ "nvidia": ["cudatoolkit", "cudnn", "zlib-wapi"], "apple_silicon": ["libblas"]} # Packages that should only be installed through pip -_FORCE_PIP: dict[backend_type, list[str]] = {"nvidia": ["tensorflow"]} +_FORCE_PIP: dict[backend_type, list[str]] = { + "nvidia": ["tensorflow"], + "all": ["imageio-ffmpeg"]} # 17/11/23 Conda forge uses incorrect ffmpeg, so fallback to pip # Revisions of tensorflow GPU and cuda/cudnn requirements. These relate specifically to the # Tensorflow builds available from pypi _TENSORFLOW_REQUIREMENTS = {">=2.10.0,<2.11.0": [">=11.0,<12.0", ">=8.0,<9.0"]} @@ -46,7 +48,7 @@ _CONDA_MAPPING: dict[str, tuple[str, str]] = { "fastcluster": ("fastcluster", "conda-forge"), "ffmpy": ("ffmpy", "conda-forge"), - "imageio-ffmpeg": ("imageio-ffmpeg", "conda-forge"), + # "imageio-ffmpeg": ("imageio-ffmpeg", "conda-forge"), "nvidia-ml-py": ("nvidia-ml-py", "conda-forge"), "tensorflow-deps": ("tensorflow-deps", "apple"), "libblas": ("libblas", "conda-forge"), @@ -1063,7 +1065,7 @@ def _install_python_packages(self) -> None: mapping = _CONDA_MAPPING.get(pkg, (pkg, "")) channel = "" if mapping[1] is None else mapping[1] pkg = mapping[0] - pip_only = pkg in _FORCE_PIP.get(self._env.backend, []) + pip_only = pkg in _FORCE_PIP.get(self._env.backend, []) or pkg in _FORCE_PIP["all"] pkg = self._format_package(pkg, version) if version else pkg if self._env.is_conda and not pip_only: if self._from_conda(pkg, channel=channel, conda_only=conda_only): From b72b730adb63b3428ab5178146fd3f5308a08797 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 5 Jan 2024 13:46:07 +0000 Subject: [PATCH 870/981] setup.py: - Don't try to call ldconfig when it doesn't exist - Adjust Cuda versions to TF compiled versions - Multiple package install handling - Use XFT version of tk under Linux --- setup.py | 82 ++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 59 insertions(+), 23 deletions(-) diff --git a/setup.py b/setup.py index e1b8c191de..67470ca61a 100755 --- a/setup.py +++ b/setup.py @@ -39,13 +39,15 @@ "all": ["imageio-ffmpeg"]} # 17/11/23 Conda forge uses incorrect ffmpeg, so fallback to pip # Revisions of tensorflow GPU and cuda/cudnn requirements. These relate specifically to the # Tensorflow builds available from pypi -_TENSORFLOW_REQUIREMENTS = {">=2.10.0,<2.11.0": [">=11.0,<12.0", ">=8.0,<9.0"]} +_TENSORFLOW_REQUIREMENTS = {">=2.10.0,<2.11.0": [">=11.2,<11.3", ">=8.1,<8.2"]} # ROCm min/max version requirements for Tensorflow _TENSORFLOW_ROCM_REQUIREMENTS = {">=2.10.0,<2.11.0": ((5, 2, 0), (5, 4, 0))} # TODO tensorflow-metal versioning # Mapping of Python packages to their conda names if different from pip or in non-default channel _CONDA_MAPPING: dict[str, tuple[str, str]] = { + "cudatoolkit": ("cudatoolkit", "conda-forge"), + "cudnn": ("cudnn", "conda-forge"), "fastcluster": ("fastcluster", "conda-forge"), "ffmpy": ("ffmpy", "conda-forge"), # "imageio-ffmpeg": ("imageio-ffmpeg", "conda-forge"), @@ -298,13 +300,18 @@ class Packages(): """ def __init__(self, environment: Environment) -> None: self._env = environment - self._conda_required_packages: list[tuple[str, ...]] = [("tk", ), ("git", )] + + # Default TK has bad fonts under Linux. There is a better build in Conda-Forge, so set + # channel accordingly + tk_channel = "conda-forge" if self._env.os_version[0].lower() == "linux" else "default" + self._conda_required_packages: list[tuple[list[str] | str, str]] = [("tk", tk_channel), + ("git", "default")] self._update_backend_specific_conda() self._installed_packages = self._get_installed_packages() self._conda_installed_packages = self._get_installed_conda_packages() self._required_packages: list[tuple[str, list[tuple[str, str]]]] = [] self._missing_packages: list[tuple[str, list[tuple[str, str]]]] = [] - self._conda_missing_packages: list[tuple[str, ...]] = [] + self._conda_missing_packages: list[tuple[list[str] | str, str]] = [] @property def prerequisites(self) -> list[tuple[str, list[tuple[str, str]]]]: @@ -333,7 +340,7 @@ def to_install(self) -> list[tuple[str, list[tuple[str, str]]]]: return self._missing_packages @property - def to_install_conda(self) -> list[tuple[str, ...]]: + def to_install_conda(self) -> list[tuple[list[str] | str, str]]: """ list: The required conda packages that need to be installed """ return self._conda_missing_packages @@ -351,6 +358,8 @@ def _update_backend_specific_conda(self) -> None: logger.debug("No backend packages to add for '%s'. All optional packages: %s", self._env.backend, _BACKEND_SPECIFIC_CONDA) return + + combined_cuda = [] for pkg in to_add: pkg, channel = _CONDA_MAPPING.get(pkg, (pkg, "")) if pkg == "zlib-wapi" and self._env.os_version[0].lower() != "windows": @@ -359,14 +368,18 @@ def _update_backend_specific_conda(self) -> None: if pkg in ("cudatoolkit", "cudnn"): # TODO Handle multiple cuda/cudnn requirements idx = 0 if pkg == "cudatoolkit" else 1 pkg = f"{pkg}{list(_TENSORFLOW_REQUIREMENTS.values())[0][idx]}" - if pkg.startswith("cudnn"): - # We add cudnn first so that dependency resolver does not need to re-download cuda - # if an incompatible version was installed - self._conda_required_packages.insert(0, (pkg, channel)) - else: - self._conda_required_packages.append((pkg, channel)) - logger.debug("Adding conda required package '%s' for backend '%s')", - pkg, self._env.backend) + + combined_cuda.append(pkg) + continue + + self._conda_required_packages.append((pkg, channel)) + logger.info("Adding conda required package '%s' for backend '%s')", + pkg, self._env.backend) + + if combined_cuda: + self._conda_required_packages.append((combined_cuda, channel)) + logger.info("Adding conda required package '%s' for backend '%s')", + combined_cuda, self._env.backend) @classmethod def _format_requirements(cls, packages: list[str] @@ -560,6 +573,14 @@ def _check_conda_missing_dependencies(self) -> None: key = reqs.unsafe_name specs = reqs.specs + if pkg[0] == "tk" and self._env.os_version[0].lower() == "linux": + # Default tk has bad fonts under Linux. We pull in an explicit build from + # Conda-Forge that is compiled with better fonts. + # Ref: https://github.com/ContinuumIO/anaconda-issues/issues/6833 + newpkg = (f"{pkg[0]}=*=xft_*", pkg[1]) # Swap out package for explicit XFT version + self._conda_missing_packages.append(newpkg) + continue + if key not in self._conda_installed_packages: self._conda_missing_packages.append(pkg) continue @@ -770,7 +791,10 @@ def _rocm_check(self) -> None: with ldconfig then attempt to find it in LD_LIBRARY_PATH. If found, set the :attr:`rocm_version` to the discovered version """ - chk = os.popen("ldconfig -p | grep -P \"librocm-core.so.\\d+\" | head -n 1").read() + ldconfig = os.popen("which ldconfig").read() + if not ldconfig: + return + chk = os.popen(f"{ldconfig} -p | grep -P \"librocm-core.so.\\d+\" | head -n 1").read() if not chk and os.environ.get("LD_LIBRARY_PATH"): for path in os.environ["LD_LIBRARY_PATH"].split(":"): chk = os.popen(f"ls {path} | grep -P -o \"librocmcore.so.\\d+\" | " @@ -841,7 +865,10 @@ def _cuda_check(self) -> None: def _cuda_check_linux(self) -> None: """ For Linux check the dynamic link loader for libcudart. If not found with ldconfig then attempt to find it in LD_LIBRARY_PATH. """ - chk = os.popen("ldconfig -p | grep -P \"libcudart.so.\\d+.\\d+\" | head -n 1").read() + ldconfig = os.popen("which ldconfig").read() + if not ldconfig: + return + chk = os.popen(f"{ldconfig} -p | grep -P \"libcudart.so.\\d+.\\d+\" | head -n 1").read() if not chk and os.environ.get("LD_LIBRARY_PATH"): for path in os.environ["LD_LIBRARY_PATH"].split(":"): chk = os.popen(f"ls {path} | grep -P -o \"libcudart.so.\\d+.\\d+\" | " @@ -898,7 +925,10 @@ def _cudnn_check(self) -> None: if self._os == "windows": return - chk = os.popen("ldconfig -p | grep -P \"libcudnn.so.\" | head -n 1").read() + ldconfig = os.popen("which ldconfig").read() + if not ldconfig: + return + chk = os.popen(f"{ldconfig} -p | grep -P \"libcudnn.so.\" | head -n 1").read() if not chk: return cudnnvers = chk.strip().replace("libcudnn.so.", "").split()[0] @@ -917,7 +947,10 @@ def _get_checkfiles_linux(self) -> list[str]: list List of header file locations to scan for cuDNN versions """ - chk = os.popen("ldconfig -p | grep -P \"libcudnn.so.\\d+\" | head -n 1").read() + ldconfig = os.popen("which ldconfig").read() + if not ldconfig: + return [] + chk = os.popen(f"{ldconfig} -p | grep -P \"libcudnn.so.\\d+\" | head -n 1").read() chk = chk.strip().replace("libcudnn.so.", "") if not chk: return [] @@ -1078,15 +1111,15 @@ def _install_missing_dep(self) -> None: self._install_python_packages() def _from_conda(self, - package: str, + package: list[str] | str, channel: str = "", conda_only: bool = False) -> bool: """ Install a conda package Parameters ---------- - package: str - The full formatted package, with version, to be installed + package: list[str] | str + The full formatted package(s), with version(s), to be installed channel: str, optional The Conda channel to install from. Select empty string for default channel. Default: ``""`` (empty string) @@ -1104,11 +1137,14 @@ def _from_conda(self, if channel: condaexe.extend(["-c", channel]) - if any(char in package for char in (" ", "<", ">", "*", "|")): - package = f"\"{package}\"" - condaexe.append(package) + pkgs = package if isinstance(package, list) else [package] + + for i, pkg in enumerate(pkgs): + if any(char in pkg for char in (" ", "<", ">", "*", "|")): + pkgs[i] = f"\"{pkg}\"" + condaexe.extend(pkgs) - clean_pkg = package.replace("\"", "") + clean_pkg = " ".join([p.replace("\"", "") for p in pkgs]) installer = self._installer(self._env, clean_pkg, condaexe, self._is_gui) retcode = installer() From 2a3845152b9db9bf6298c58529b761f715502604 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 7 Jan 2024 14:58:48 +0000 Subject: [PATCH 871/981] bugfix: Convert - patch writer. Fix TIFF metadata --- lib/image.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/image.py b/lib/image.py index 3bcdd85e29..19e161c8ef 100644 --- a/lib/image.py +++ b/lib/image.py @@ -697,8 +697,9 @@ def tiff_read_meta(image: bytes) -> dict[str, T.Any]: num_tags = struct.unpack(" Date: Wed, 10 Jan 2024 13:05:35 +0000 Subject: [PATCH 872/981] bugfix: Patch writer - handle filenames which are only numeric --- plugins/convert/writer/patch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/convert/writer/patch.py b/plugins/convert/writer/patch.py index 2242323fd0..da07279340 100644 --- a/plugins/convert/writer/patch.py +++ b/plugins/convert/writer/patch.py @@ -95,6 +95,8 @@ def _get_new_filename(self, filename: str, face_index: int) -> str: """ face_idx = str(face_index).rjust(2, "0") fname, ext = os.path.splitext(filename) + fname = os.path.basename(fname) + split_fname = fname.rsplit("_", 1) if split_fname[-1].isdigit(): i_frame_no = (int(split_fname[-1]) + From dea021cf825b9a722db64795e54eb7e874460b39 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 12 Jan 2024 15:14:56 +0000 Subject: [PATCH 873/981] bugfix - setup.py - Install xorg-libxft for Linux users - Force tensorflow-cpu from pip --- setup.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 67470ca61a..915ec490a5 100755 --- a/setup.py +++ b/setup.py @@ -36,7 +36,9 @@ # Packages that should only be installed through pip _FORCE_PIP: dict[backend_type, list[str]] = { "nvidia": ["tensorflow"], - "all": ["imageio-ffmpeg"]} # 17/11/23 Conda forge uses incorrect ffmpeg, so fallback to pip + "all": [ + "tensorflow-cpu", # conda-forge leads to flatbuffer errors because of mixed sources + "imageio-ffmpeg"]} # 17/11/23 Conda forge uses incorrect ffmpeg, so fallback to pip # Revisions of tensorflow GPU and cuda/cudnn requirements. These relate specifically to the # Tensorflow builds available from pypi _TENSORFLOW_REQUIREMENTS = {">=2.10.0,<2.11.0": [">=11.2,<11.3", ">=8.1,<8.2"]} @@ -54,7 +56,8 @@ "nvidia-ml-py": ("nvidia-ml-py", "conda-forge"), "tensorflow-deps": ("tensorflow-deps", "apple"), "libblas": ("libblas", "conda-forge"), - "zlib-wapi": ("zlib-wapi", "conda-forge")} + "zlib-wapi": ("zlib-wapi", "conda-forge"), + "xorg-libxft": ("xorg-libxft", "conda-forge")} # Force output to utf-8 sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type:ignore[attr-defined] @@ -579,6 +582,8 @@ def _check_conda_missing_dependencies(self) -> None: # Ref: https://github.com/ContinuumIO/anaconda-issues/issues/6833 newpkg = (f"{pkg[0]}=*=xft_*", pkg[1]) # Swap out package for explicit XFT version self._conda_missing_packages.append(newpkg) + # We also need to bring in xorg-libxft incase libXft does not exist on host system + self._conda_missing_packages.append(_CONDA_MAPPING["xorg-libxft"]) continue if key not in self._conda_installed_packages: From d1dfce8a13cacd56d3ee28ff13484c1451003583 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 1 Mar 2024 11:12:44 +0000 Subject: [PATCH 874/981] setup.py - fix ldconfig errors --- setup.py | 81 +++++++++++++++++++++++++++++++++----------------------- 1 file changed, 48 insertions(+), 33 deletions(-) diff --git a/setup.py b/setup.py index 915ec490a5..0b6c223291 100755 --- a/setup.py +++ b/setup.py @@ -776,6 +776,44 @@ def _check_rocm(self) -> None: _INSTALL_FAILED = True +def _check_ld_config(lib: str) -> str: + """ Locate a library in ldconfig + + Parameters + ---------- + lib: str The library to locate + + Returns + ------- + str + The library from ldconfig, or empty string if not found + """ + retval = "" + ldconfig = which("ldconfig") + if not ldconfig: + return retval + + retval = next((line.decode("utf-8", errors="replace").strip() + for line in run([ldconfig, "-p"], + capture_output=True, + check=False).stdout.splitlines() + if lib.encode("utf-8") in line), "") + + if retval or (not retval and not os.environ.get("LD_LIBRARY_PATH")): + return retval + + for path in os.environ["LD_LIBRARY_PATH"].split(":"): + if not path: + continue + + retval = next((fname.strip() for fname in reversed(os.listdir(path)) + if lib in fname), "") + if retval: + break + + return retval + + class ROCmCheck(): # pylint:disable=too-few-public-methods """ Find the location of system installed ROCm on Linux """ def __init__(self) -> None: @@ -796,16 +834,7 @@ def _rocm_check(self) -> None: with ldconfig then attempt to find it in LD_LIBRARY_PATH. If found, set the :attr:`rocm_version` to the discovered version """ - ldconfig = os.popen("which ldconfig").read() - if not ldconfig: - return - chk = os.popen(f"{ldconfig} -p | grep -P \"librocm-core.so.\\d+\" | head -n 1").read() - if not chk and os.environ.get("LD_LIBRARY_PATH"): - for path in os.environ["LD_LIBRARY_PATH"].split(":"): - chk = os.popen(f"ls {path} | grep -P -o \"librocmcore.so.\\d+\" | " - "head -n 1").read() - if chk: - break + chk = _check_ld_config("librocm-core.so.") if not chk: return @@ -852,8 +881,7 @@ def _cuda_check(self) -> None: stdout.decode(locale.getpreferredencoding(), errors="ignore")) if version is not None: self.cuda_version = version.groupdict().get("cuda", None) - locate = "where" if self._os == "windows" else "which" - path = os.popen(f"{locate} nvcc").read() + path = which("nvcc") if path: path = path.split("\n")[0] # Split multiple entries and take first found while True: # Get Cuda root folder @@ -870,22 +898,15 @@ def _cuda_check(self) -> None: def _cuda_check_linux(self) -> None: """ For Linux check the dynamic link loader for libcudart. If not found with ldconfig then attempt to find it in LD_LIBRARY_PATH. """ - ldconfig = os.popen("which ldconfig").read() - if not ldconfig: - return - chk = os.popen(f"{ldconfig} -p | grep -P \"libcudart.so.\\d+.\\d+\" | head -n 1").read() - if not chk and os.environ.get("LD_LIBRARY_PATH"): - for path in os.environ["LD_LIBRARY_PATH"].split(":"): - chk = os.popen(f"ls {path} | grep -P -o \"libcudart.so.\\d+.\\d+\" | " - "head -n 1").read() - if chk: - break + chk = _check_ld_config("libcudart.so.") if not chk: # Cuda not found return cudavers = chk.strip().replace("libcudart.so.", "") - self.cuda_version = cudavers[:cudavers.find(" ")] - self.cuda_path = chk[chk.find("=>") + 3:chk.find("targets") - 1] + self.cuda_version = cudavers[:cudavers.find(" ")] if " " in cudavers else cudavers + cuda_path = chk[chk.find("=>") + 3:chk.find("targets") - 1] + if os.path.exists(cuda_path): + self.cuda_path = cuda_path def _cuda_check_windows(self) -> None: """ Check Windows CUDA Version and path from Environment Variables""" @@ -930,10 +951,7 @@ def _cudnn_check(self) -> None: if self._os == "windows": return - ldconfig = os.popen("which ldconfig").read() - if not ldconfig: - return - chk = os.popen(f"{ldconfig} -p | grep -P \"libcudnn.so.\" | head -n 1").read() + chk = _check_ld_config("libcudnn.so.") if not chk: return cudnnvers = chk.strip().replace("libcudnn.so.", "").split()[0] @@ -952,10 +970,7 @@ def _get_checkfiles_linux(self) -> list[str]: list List of header file locations to scan for cuDNN versions """ - ldconfig = os.popen("which ldconfig").read() - if not ldconfig: - return [] - chk = os.popen(f"{ldconfig} -p | grep -P \"libcudnn.so.\\d+\" | head -n 1").read() + chk = _check_ld_config("libcudnn.so.") chk = chk.strip().replace("libcudnn.so.", "") if not chk: return [] @@ -978,7 +993,7 @@ def _get_checkfiles_windows(self) -> list[str]: List of header file locations to scan for cuDNN versions """ # TODO A more reliable way of getting the windows location - if not self.cuda_path: + if not self.cuda_path or not os.path.exists(self.cuda_path): return [] scandir = os.path.join(self.cuda_path, "include") cudnn_checkfiles = [os.path.join(scandir, header) for header in self._cudnn_header_files] From 7a16f753ccbe0fd2e21226418fffcbf41efed980 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 12 Mar 2024 13:17:35 +0000 Subject: [PATCH 875/981] Add Mask importing to the mask tool (#1376) * lib.align.alignments: expose count_faces_in_frame * tools.mask: refactor and fix frame output to display all masks in a single frame * tools.mask: add import mask process * manual tool: Remove NN masks on landmark edit --- lib/align/alignments.py | 18 +- locales/es/LC_MESSAGES/tools.mask.cli.mo | Bin 10110 -> 14916 bytes locales/es/LC_MESSAGES/tools.mask.cli.po | 196 ++++++-- locales/kr/LC_MESSAGES/tools.mask.cli.mo | Bin 9966 -> 14151 bytes locales/kr/LC_MESSAGES/tools.mask.cli.po | 181 +++++-- locales/ru/LC_MESSAGES/tools.mask.cli.mo | Bin 12823 -> 18221 bytes locales/ru/LC_MESSAGES/tools.mask.cli.po | 192 ++++++-- locales/tools.mask.cli.pot | 110 +++-- tools/alignments/jobs_faces.py | 2 +- tools/manual/detected_faces.py | 4 +- tools/manual/manual.py | 114 +++-- tools/mask/cli.py | 355 ++++++++------ tools/mask/loader.py | 222 +++++++++ tools/mask/mask.py | 592 +++++------------------ tools/mask/mask_generate.py | 269 ++++++++++ tools/mask/mask_import.py | 406 ++++++++++++++++ tools/mask/mask_output.py | 515 ++++++++++++++++++++ 17 files changed, 2339 insertions(+), 837 deletions(-) create mode 100644 tools/mask/loader.py create mode 100644 tools/mask/mask_generate.py create mode 100644 tools/mask/mask_import.py create mode 100644 tools/mask/mask_output.py 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 df48b2c1dd051017dd387e7c9b64e99cbf33415c..31203ae98853ab5fd68957cc96ac42d78e37fabc 100644 GIT binary patch literal 14916 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 delta 1389 zcmZWoO^6gn6n?$4YTRU_uI_9`*7(@0NN{J_%qEBfdk|R?LSn!lLJrX>yK8pK^mMgd z)uR&@#vDQtBstg#$t4LwPJx`7+(Ho6TM)&IV+Ud!@e5)MG3DV0<4fQH#8-%UtbZRM@-Tl4{1fZ{Dnti?e+&{S z-~-?w_D^jhx{B~O6KUj5zEtLa`!dnz$UDA`DEFvOe`7L-4_`w8&eC5%oS~b*Ex_Br zk3o0`_z`e!C&=;r`&~qJtRDg&U_DtSIti>n{Y9V#9>)HC;8_qgYD6m#@*@-7!v4~0 zMAvaYeK1KhfQ>!-h~B{l?8QYK`$0FqV0~L82LZm58e*VA_&+nO6~~r30s|Cs(j;(yk9PKIP|uRWFoB0 zPS;+|4{Yvv`4_{dHchjL0zR+8kRvHUAdOYg0=IR-?KA;j!htk?qGGFbl+{NX`Pfb~ zkl!6U;*B3$`fyPeIaYHT2cTyGDkmYkG5Sj1OtFr_9&~9Vxh;HYFc+2s&5^c9PNWEW zyePUjSxVRKqKpOAl@YhfYI?D3F(jQ$jYVy&QyZt4t(FT?T`XGVMvFu33#$g8_}9_7 z%&WGu&#JTAIz?TH>?(PlRg~SU*7nkgMP=C6osM%Nl-6)hr@XLk*S_phtu;JbpJ~(^ zGu)VMzWG-6sP?!rGyO*XaQ8|j`*-i2T@b%~#rPr-hGYCffw|lqtt{tJeui&(`PA$0 z4&^@`{MoA?TRN^7b_n05B2=G>2=W7*3lZxqsJ`UrgJX)#avtK5&!sYSdF4%5!AG_V zY9sCmPNQ`pc({-!tco>e&xj{D5O80w5<-WJy34;o#K!AU0qD*sU(EXo3U~q}26_WA zF+KF&65hGVhu2DQ$!pTxZR-Vz*N&R=AQ&Z=Abgn&zDfG!=SEb1ajBWT93xRr1WKwN?=(yK<;jCH#T3 Np^}K=MC0$G=_#@$l?wm> 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 992863ecf1302cba506d83a7bd16fb53e1998157..b400456c94c01de1483768af1f841dd261d418f2 100644 GIT binary patch delta 5605 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 delta 1523 zcma)4U1%It6h5;}V@++6reu@tk2#PQAF>;=i5jewfJK6+D6Jp})(pFMvtxH>!p>~! zLSUQ3RLj=nVJzx{g5Z-E6t9-_dYD&5867!E!H1LV?=@W>$v z{}}uo_|pKq5C0N?UfxIa9?mcJBaVG{oalSR1Zx`7HiK32Hl5 zjlNgg8>i4UEo@OTJ>kETcrqC2tQf%udrs{fWz){`8LLoWTL=IM*R$+d&N(H{l}o@Y zaaOpQk~Qa9j_s%V#)E-MDtOOPpcL_8-O*L-a=8ayjD^ zi@Fj8;kjIK$~^n1Tu1y`;`QE%)Y#Lhbehx89vzz;8Ji4#O~egjJcu3mtkXA#pYeYm ze$MY5j>ptmFmd>YSRm8Mp5XTJ*J96~Sdd@HZ{>z;$;)zGt*Iq-f#tevvb?MoWrO7n zS;yjfc*5#^S(n!kud5ZmXLz8h){?B2kiD)h;8II(kOTmAPS%sG&vaa!RW%)GsPo!w z=wB1q4YjJ4wmotcFI<6bgrovMZY?yDLUK3NS%5B9HMXW&1N|2Anr#8wNQciXhBr5x zFxvIX0;b_8Z&ANJRV_2%P?4w*Y9&&I{i8DYFHG&9)?h`;bupX({1QB_Y6aVv{0}&P zYUMAK-`7+8F+qpkmTb`z$Fhw3b&GH$J6 zL+MhX|4sdDL?*fi7U-rX5LRIs-K|4x-QRtr>uoeat%Ob3P|Io+;0WH7*VXbU$xFaQ z5=3bwppOFS8@g7yb0{=s0h16w7{uX?rfxBUVIeWXNsRDdH?X4V&;#XnPY?YI&~|uZ 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() From 23944c3c103cd77337fb129d9b37bb0f9ce86ac3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 12 Mar 2024 14:42:23 +0000 Subject: [PATCH 876/981] Update requirements --- docs/sphinx_requirements.txt | 16 ++++++++-------- requirements/_requirements_base.txt | 16 ++++++++-------- requirements/requirements_nvidia.txt | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index 2055f84fb6..59a4b9b8f6 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -5,17 +5,17 @@ sphinx>=6.0.0,<7.0.0 sphinx_rtd_theme==1.2.2 tqdm==4.65 psutil==5.9.0 -numexpr>=2.8.4 -numpy>=1.25.0 -opencv-python>=4.7.0.0 +numexpr>=2.8.7 +numpy>=1.26.0 +opencv-python>=4.9.0.0 pillow==9.4.0 -scikit-learn>=1.2.2 +scikit-learn>=1.3.0 fastcluster>=1.2.6 -matplotlib==3.7.1 -imageio==2.31.1 -imageio-ffmpeg==0.4.8 +matplotlib==3.8.0 +imageio==2.33.1 +imageio-ffmpeg==0.4.9 ffmpy==0.3.0 -nvidia-ml-py>=11.525,<11.526 +nvidia-ml-py>=12.535,<12.536 pytest==7.2.0 pytest-mock==3.10.0 tensorflow>=2.10.0,<2.11.0 diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index b7fcc81145..a78ebb1b88 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -1,13 +1,13 @@ tqdm>=4.65 psutil>=5.9.0 -numexpr>=2.8.4 -numpy>=1.25.0 -opencv-python>=4.7.0.0 +numexpr>=2.8.7 +numpy>=1.26.0 +opencv-python>=4.9.0.0 pillow>=9.4.0,<10.0.0 -scikit-learn>=1.2.2 +scikit-learn>=1.3.0 fastcluster>=1.2.6 -matplotlib>=3.7.1 -imageio>=2.26.0 -imageio-ffmpeg>=0.4.8 +matplotlib>=3.8.0 +imageio>=2.33.1 +imageio-ffmpeg>=0.4.9 ffmpy>=0.3.0 -pywin32>=228 ; sys_platform == "win32" +pywin32>=305 ; sys_platform == "win32" diff --git a/requirements/requirements_nvidia.txt b/requirements/requirements_nvidia.txt index f3a0bc933f..45a558911f 100644 --- a/requirements/requirements_nvidia.txt +++ b/requirements/requirements_nvidia.txt @@ -1,5 +1,5 @@ -r _requirements_base.txt # Exclude badly numbered Python2 version of nvidia-ml-py -nvidia-ml-py>=11.525,<300 +nvidia-ml-py>=12.535,<300 pynvx==1.0.0 ; sys_platform == "darwin" tensorflow>=2.10.0,<2.11.0 From af95dbb310438a5812491c68cd48e7413ced6b39 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 13 Mar 2024 18:21:04 +0000 Subject: [PATCH 877/981] Bugfix: mask tool. Prevent hangiing when input is folder of frames --- tools/mask/mask.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/mask/mask.py b/tools/mask/mask.py index f584bb5cdf..02e976c49f 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -149,6 +149,7 @@ def __init__(self, arguments: Namespace) -> None: self._loader = Loader(arguments.input, self._input_is_faces) self._alignments = self._get_alignments(arguments.alignments, arguments.input) + self._loader.add_alignments(self._alignments) self._output = Output(arguments, self._alignments, self._loader.file_list) @@ -231,7 +232,6 @@ def _get_alignments(self, alignments: str | None, input_location: str) -> Alignm filename = "alignments" retval = Alignments(folder, filename=filename) - self._loader.add_alignments(retval) return retval def _save_output(self, media: ExtractMedia) -> None: From 63b4d91281305d0c4c6a5574154536727cbe66e7 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 18 Mar 2024 19:38:10 +0000 Subject: [PATCH 878/981] Bugfix: Mask tool - correctly name imported mask --- tools/mask/mask_import.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/mask/mask_import.py b/tools/mask/mask_import.py index a2af5ec59f..48262c6299 100644 --- a/tools/mask/mask_import.py +++ b/tools/mask/mask_import.py @@ -294,7 +294,7 @@ def _store_mask(self, face: DetectedFace, mask: np.ndarray) -> None: is_aligned=self._is_faces, dtype="float32") assert aligned.face is not None - face.add_mask("custom", + face.add_mask(f"custom_{self._centering}", aligned.face / 255., aligned.adjusted_matrix, aligned.interpolators[1], From 1009254c57feee330dd34575be2673613363e839 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 20 Mar 2024 14:19:23 +0000 Subject: [PATCH 879/981] logging - Add standardized class __init__ log function - Import logger from lib.__init__ to prevent custom log level errors when running non-fs scripts --- lib/__init__.py | 4 ++++ lib/logger.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/lib/__init__.py b/lib/__init__.py index e69de29bb2..c87f4c4316 100644 --- a/lib/__init__.py +++ b/lib/__init__.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 +""" Initialization for faceswap's lib section """ +# Import logger here so our custom loglevels are set for when executing code outside of FS +from . import logger diff --git a/lib/logger.py b/lib/logger.py index 623af04c39..3b7a13802e 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -7,6 +7,7 @@ import platform import re import sys +import typing as T import time import traceback @@ -543,6 +544,23 @@ def crash_log() -> str: return filename +def parse_class_init(locals_dict: dict[str, T.Any]) -> str: + """ Parse a locals dict from a class and return in a format suitable for logging + Parameters + ---------- + locals_dict: dict[str, T.Any] + A locals() dictionary from a newly initialized class + Returns + ------- + str + The locals information suitable for logging + """ + delimit = {k: f"'{v}'" if isinstance(v, str) else v + for k, v in locals_dict.items() if k != "self"} + dsp = ", ".join(f"{k}: {v}" for k, v in delimit.items()) + return f"Initializing {locals_dict['self'].__class__.__name__} ({dsp})" + + _OLD_FACTORY = logging.getLogRecordFactory() From 1d3c59c3511987a2d9fb9221678ac59379bf5e27 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 20 Mar 2024 15:24:30 +0000 Subject: [PATCH 880/981] Bugfix: Extract error on rotate faces --- plugins/extract/detect/_base.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index c85f75776b..a256e67c45 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -283,8 +283,26 @@ def _predict(self, batch: BatchType) -> DetectorBatch: if angle == 0: batch.prediction = pred else: - batch.prediction = np.array([b if b.any() else p - for b, p in zip(batch.prediction, pred)]) + try: + batch.prediction = np.array([b if b.any() else p + for b, p in zip(batch.prediction, pred)]) + except ValueError as err: + # If batches are different sizes after rotation Numpy will error, so we + # need to explicitly set the dtype to 'object' rather than let it infer + # numpy error: + # ValueError: setting an array element with a sequence. The requested array + # has an inhomogeneous shape after 1 dimensions. The detected shape was + # (8,) + inhomogeneous part + if "inhomogeneous" in str(err): + batch.prediction = np.array([b if b.any() else p + for b, p in zip(batch.prediction, pred)], + dtype="object") + logger.trace( # type:ignore[attr-defined] + "Mismatched array sizes, setting dtype to object: %s", + [p.shape for p in batch.prediction]) + else: + raise + logger.trace("angle: %s, filenames: %s, " # type:ignore[attr-defined] "prediction: %s", angle, batch.filename, pred) @@ -307,7 +325,6 @@ def _predict(self, batch: BatchType) -> DetectorBatch: found_faces = T.cast(list[np.ndarray], ([face if not found.any() else found for face, found in zip(batch.prediction, found_faces)])) - if all(face.any() for face in found_faces): logger.trace("Faces found for all images") # type:ignore[attr-defined] break From 9ddc838e685c4daa9e1917e7d2eb2e08397f821c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 20 Mar 2024 17:08:39 +0000 Subject: [PATCH 881/981] Bugfix: Correct loss labels when graphing --- lib/gui/analysis/event_reader.py | 15 ++++++++------- lib/gui/analysis/stats.py | 13 ++++++------- lib/gui/display.py | 4 +++- lib/gui/display_analysis.py | 18 +++++++++--------- lib/gui/display_command.py | 12 +++++++----- lib/gui/display_graph.py | 11 ++++++++--- lib/gui/display_page.py | 10 ++-------- lib/logger.py | 29 +++++++++++++++++++++++++++-- 8 files changed, 70 insertions(+), 42 deletions(-) diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index 2a6fcc2745..e4da91a2cf 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import os +import re import typing as T import zlib @@ -14,6 +15,7 @@ from tensorflow.python.framework import ( # pylint:disable=no-name-in-module errors_impl as tf_errors) +from lib.logger import parse_class_init from lib.serializer import get_serializer if T.TYPE_CHECKING: @@ -46,7 +48,7 @@ class _LogFiles(): The folder that contains the Tensorboard log files """ def __init__(self, logs_folder: str) -> None: - logger.debug("Initializing: %s: (logs_folder: '%s')", self.__class__.__name__, logs_folder) + logger.debug(parse_class_init(locals())) self._logs_folder = logs_folder self._filenames = self._get_log_filenames() logger.debug("Initialized: %s", self.__class__.__name__) @@ -215,7 +217,7 @@ def add_live_data(self, timestamps: np.ndarray, loss: np.ndarray) -> None: class _Cache(): """ Holds parsed Tensorflow log event data in a compressed cache in memory. """ def __init__(self) -> None: - logger.debug("Initializing: %s", self.__class__.__name__) + logger.debug(parse_class_init(locals())) self._data: dict[int, _CacheData] = {} self._carry_over: dict[int, EventData] = {} self._loss_labels: list[str] = [] @@ -471,8 +473,7 @@ class TensorBoardLogs(): ``True`` if the events are being read whilst Faceswap is training otherwise ``False`` """ def __init__(self, logs_folder: str, is_training: bool) -> None: - logger.debug("Initializing: %s: (logs_folder: %s, is_training: %s)", - self.__class__.__name__, logs_folder, is_training) + logger.debug(parse_class_init(locals())) self._is_training = False self._training_iterator = None @@ -631,12 +632,12 @@ class _EventParser(): # pylint:disable=too-few-public-methods otherwise ``False`` """ def __init__(self, iterator: Iterator[bytes], cache: _Cache, live_data: bool) -> None: - logger.debug("Initializing: %s: (iterator: %s, cache: %s, live_data: %s)", - self.__class__.__name__, iterator, cache, live_data) + logger.debug(parse_class_init(locals())) self._live_data = live_data self._cache = cache self._iterator = self._get_latest_live(iterator) if live_data else iterator self._loss_labels: list[str] = [] + self._num_strip = re.compile(r"_\d+$") logger.debug("Initialized: %s", self.__class__.__name__) @classmethod @@ -728,7 +729,7 @@ def _parse_outputs(self, event: event_pb2.Event) -> None: if layer["name"] == layer_name)["config"] layer_outputs = self._get_outputs(output_config) for output in layer_outputs: # Drill into sub-model to get the actual output names - loss_name = output[0][0] + loss_name = self._num_strip.sub("", output[0][0]) # strip trailing numbers if loss_name[-2:] not in ("_a", "_b"): # Rename losses to reflect the side output new_name = f"{loss_name.replace('_both', '')}_{side}" logger.debug("Renaming loss output from '%s' to '%s'", loss_name, new_name) diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index a1874d874a..eda0e19ca2 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -17,6 +17,7 @@ import numpy as np +from lib.logger import parse_class_init from lib.serializer import get_serializer from .event_reader import TensorBoardLogs @@ -30,7 +31,7 @@ class GlobalSession(): :attr:`lib.gui.analysis.Session` """ def __init__(self) -> None: - logger.debug("Initializing %s", self.__class__.__name__) + logger.debug(parse_class_init(locals())) self._state: dict[str, T.Any] = {} self._model_dir = "" self._model_name = "" @@ -289,7 +290,7 @@ class SessionsSummary(): # pylint:disable=too-few-public-methods The loaded or currently training session """ def __init__(self, session: GlobalSession) -> None: - logger.debug("Initializing %s: (session: %s)", self.__class__.__name__, session) + logger.debug(parse_class_init(locals())) self._session = session self._state = session._state @@ -539,11 +540,7 @@ def __init__(self, session_id, avg_samples: int = 500, smooth_amount: float = 0.90, flatten_outliers: bool = False) -> None: - logger.debug("Initializing %s: (session_id: %s, display: %s, loss_keys: %s, " - "selections: %s, avg_samples: %s, smooth_amount: %s, flatten_outliers: %s)", - self.__class__.__name__, session_id, display, loss_keys, selections, - avg_samples, smooth_amount, flatten_outliers) - + logger.debug(parse_class_init(locals())) warnings.simplefilter("ignore", np.RankWarning) self._session_id = session_id @@ -872,6 +869,7 @@ class _ExponentialMovingAverage(): # pylint:disable=too-few-public-methods Adapted from: https://stackoverflow.com/questions/42869495 """ def __init__(self, data: np.ndarray, amount: float) -> None: + logger.debug(parse_class_init(locals())) assert data.ndim == 1 amount = min(max(amount, 0.001), 0.999) @@ -880,6 +878,7 @@ def __init__(self, data: np.ndarray, amount: float) -> None: self._dtype = "float32" if data.dtype == np.float32 else "float64" self._row_size = self._get_max_row_size() self._out = np.empty_like(data, dtype=self._dtype) + logger.debug("Initialized %s", self.__class__.__name__) def __call__(self) -> np.ndarray: """ Perform the exponential moving average calculation. diff --git a/lib/gui/display.py b/lib/gui/display.py index be309abc3c..dbc09b8992 100644 --- a/lib/gui/display.py +++ b/lib/gui/display.py @@ -10,6 +10,8 @@ import tkinter as tk from tkinter import ttk +from lib.logger import parse_class_init + from .display_analysis import Analysis from .display_command import GraphDisplay, PreviewExtract, PreviewTrain from .utils import get_config @@ -31,7 +33,7 @@ class DisplayNotebook(ttk.Notebook): # pylint: disable=too-many-ancestors """ def __init__(self, parent): - logger.debug("Initializing %s", self.__class__.__name__) + logger.debug(parse_class_init(locals())) super().__init__(parent) parent.add(self) tk_vars = get_config().tk_vars diff --git a/lib/gui/display_analysis.py b/lib/gui/display_analysis.py index 28c851b2e1..682170d18d 100644 --- a/lib/gui/display_analysis.py +++ b/lib/gui/display_analysis.py @@ -8,6 +8,8 @@ import tkinter as tk from tkinter import ttk +from lib.logger import parse_class_init + from .custom_widgets import Tooltip from .display_page import DisplayPage from .popup_session import SessionPopUp @@ -36,8 +38,7 @@ class Analysis(DisplayPage): # pylint: disable=too-many-ancestors The help text to display for the summary statistics page """ def __init__(self, parent, tab_name, helptext): - logger.debug("Initializing: %s: (parent, %s, tab_name: '%s', helptext: '%s')", - self.__class__.__name__, parent, tab_name, helptext) + logger.debug(parse_class_init(locals())) super().__init__(parent, tab_name, helptext) self._summary = None @@ -62,10 +63,10 @@ def set_vars(self): dict The dictionary of variable names to tkinter variables """ - return dict(selected_id=tk.StringVar(), - refresh_graph=get_config().tk_vars.refresh_graph, - is_training=get_config().tk_vars.is_training, - analysis_folder=get_config().tk_vars.analysis_folder) + return {"selected_id": tk.StringVar(), + "refresh_graph": get_config().tk_vars.refresh_graph, + "is_training": get_config().tk_vars.is_training, + "analysis_folder": get_config().tk_vars.analysis_folder} def on_tab_select(self): """ Callback for when the analysis tab is selected. @@ -299,7 +300,7 @@ class _Options(): # pylint:disable=too-few-public-methods The Analysis Display Tab that holds the options buttons """ def __init__(self, parent): - logger.debug("Initializing: %s (parent: %s)", self.__class__.__name__, parent) + logger.debug(parse_class_init(locals())) self._parent = parent self._buttons = self._add_buttons() self._add_training_callback() @@ -380,8 +381,7 @@ class StatsData(ttk.Frame): # pylint: disable=too-many-ancestors The help text to display for the summary statistics page """ def __init__(self, parent, selected_id, helptext): - logger.debug("Initializing: %s: (parent, %s, selected_id: %s, helptext: '%s')", - self.__class__.__name__, parent, selected_id, helptext) + logger.debug(parse_class_init(locals())) super().__init__(parent) self._selected_id = selected_id diff --git a/lib/gui/display_command.py b/lib/gui/display_command.py index ab129349c2..48d3565c41 100644 --- a/lib/gui/display_command.py +++ b/lib/gui/display_command.py @@ -9,6 +9,7 @@ from tkinter import ttk +from lib.logger import parse_class_init from lib.training.preview_tk import PreviewTk from .display_graph import TrainingGraph @@ -28,8 +29,7 @@ class PreviewExtract(DisplayOptionalPage): # pylint: disable=too-many-ancestors """ Tab to display output preview images for extract and convert """ def __init__(self, *args, **kwargs) -> None: - logger.debug("Initializing %s (args: %s, kwargs: %s)", - self.__class__.__name__, args, kwargs) + logger.debug(parse_class_init(locals())) self._preview = get_images().preview_extract super().__init__(*args, **kwargs) logger.debug("Initialized %s", self.__class__.__name__) @@ -83,8 +83,7 @@ def save_items(self) -> None: class PreviewTrain(DisplayOptionalPage): # pylint: disable=too-many-ancestors """ Training preview image(s) """ def __init__(self, *args, **kwargs) -> None: - logger.debug("Initializing %s (args: %s, kwargs: %s)", - self.__class__.__name__, args, kwargs) + logger.debug(parse_class_init(locals())) self._preview = get_images().preview_train self._display: PreviewTk | None = None super().__init__(*args, **kwargs) @@ -172,9 +171,11 @@ def __init__(self, helptext: str, wait_time: int, command: str | None = None) -> None: + logger.debug(parse_class_init(locals())) self._trace_vars: dict[T.Literal["smoothgraph", "display_iterations"], tuple[tk.BooleanVar, str]] = {} super().__init__(parent, tab_name, helptext, wait_time, command) + logger.debug("Initialized %s", self.__class__.__name__) def set_vars(self) -> None: """ Add graphing specific variables to the default variables. @@ -212,7 +213,8 @@ def on_tab_select(self) -> None: Pull latest data and run the tab's update code when the tab is selected. """ - logger.debug("Callback received for '%s' tab", self.tabname) + logger.debug("Callback received for '%s' tab (display_item: %s)", + self.tabname, self.display_item) if self.display_item is not None: get_config().tk_vars.refresh_graph.set(True) self._update_page() diff --git a/lib/gui/display_graph.py b/lib/gui/display_graph.py index 32686e1268..5ea61cad1d 100755 --- a/lib/gui/display_graph.py +++ b/lib/gui/display_graph.py @@ -18,6 +18,8 @@ NavigationToolbar2Tk) from matplotlib.backend_bases import NavigationToolbar2 +from lib.logger import parse_class_init + from .custom_widgets import Tooltip from .utils import get_config, get_images, LongRunningTask @@ -40,7 +42,6 @@ class GraphBase(ttk.Frame): # pylint: disable=too-many-ancestors The data label for the y-axis """ def __init__(self, parent: ttk.Frame, data, ylabel: str) -> None: - logger.debug("Initializing %s", self.__class__.__name__) super().__init__(parent) matplotlib.use("TkAgg") # Can't be at module level as breaks Github CI style.use("ggplot") @@ -58,7 +59,6 @@ def __init__(self, parent: ttk.Frame, data, ylabel: str) -> None: self._initiate_graph() self._update_plot(initiate=True) - logger.debug("Initialized %s", self.__class__.__name__) @property def calcs(self): @@ -335,10 +335,12 @@ class TrainingGraph(GraphBase): # pylint: disable=too-many-ancestors """ def __init__(self, parent: ttk.Frame, data, ylabel: str) -> None: + logger.debug(parse_class_init(locals())) super().__init__(parent, data, ylabel) self._thread: LongRunningTask | None = None # Thread for LongRunningTask self._displayed_keys: list[str] = [] self._add_callback() + logger.debug("Initialized %s", self.__class__.__name__) def _add_callback(self) -> None: """ Add the variable trace to update graph on refresh button press or save iteration. """ @@ -427,8 +429,10 @@ class SessionGraph(GraphBase): # pylint: disable=too-many-ancestors Should be one of ``"log"`` or ``"linear"`` """ def __init__(self, parent: ttk.Frame, data, ylabel: str, scale: str) -> None: + logger.debug(parse_class_init(locals())) super().__init__(parent, data, ylabel) self._scale = scale + logger.debug("Initialized %s", self.__class__.__name__) def build(self) -> None: """ Build the session graph """ @@ -494,7 +498,7 @@ def __init__(self, # pylint: disable=super-init-not-called window: ttk.Frame, *, pack_toolbar: bool = True) -> None: - + logger.debug(parse_class_init(locals())) # Avoid using self.window (prefer self.canvas.get_tk_widget().master), # so that Tool implementations can reuse the methods. @@ -528,6 +532,7 @@ def __init__(self, # pylint: disable=super-init-not-called NavigationToolbar2.__init__(self, canvas) # pylint:disable=non-parent-init-called if pack_toolbar: self.pack(side=tk.BOTTOM, fill=tk.X) + logger.debug("Initialized %s", self.__class__.__name__) @staticmethod def _Button(frame: ttk.Frame, # pylint:disable=arguments-differ,arguments-renamed diff --git a/lib/gui/display_page.py b/lib/gui/display_page.py index b45afc14d9..74eb38ff92 100644 --- a/lib/gui/display_page.py +++ b/lib/gui/display_page.py @@ -20,9 +20,7 @@ class DisplayPage(ttk.Frame): # pylint: disable=too-many-ancestors """ Parent frame holder for each tab. Defines uniform structure for each tab to inherit from """ def __init__(self, parent, tab_name, helptext): - logger.debug("Initializing %s: (tab_name: '%s', helptext: %s)", - self.__class__.__name__, tab_name, helptext) - ttk.Frame.__init__(self, parent) + super().__init__(parent) self._parent = parent self.running_task = parent.running_task @@ -42,8 +40,6 @@ def __init__(self, parent, tab_name, helptext): self.pack(fill=tk.BOTH, side=tk.TOP, anchor=tk.NW) parent.add(self, text=self.tabname.title()) - logger.debug("Initialized %s", self.__class__.__name__,) - @property def _tab_is_active(self): """ bool: ``True`` if the tab currently has focus otherwise ``False`` """ @@ -167,9 +163,7 @@ class DisplayOptionalPage(DisplayPage): # pylint: disable=too-many-ancestors """ Parent Context Sensitive Display Tab """ def __init__(self, parent, tab_name, helptext, wait_time, command=None): - logger.debug("%s: OptionalPage args: (wait_time: %s, command: %s)", - self.__class__.__name__, wait_time, command) - DisplayPage.__init__(self, parent, tab_name, helptext) + super().__init__(parent, tab_name, helptext) self._waittime = wait_time self.command = command diff --git a/lib/logger.py b/lib/logger.py index 3b7a13802e..d72330f09a 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -13,6 +13,8 @@ from datetime import datetime +import numpy as np + # TODO - Remove this monkey patch when TF autograph fixed to handle newer logging lib def _patched_format(self, record): @@ -544,6 +546,28 @@ def crash_log() -> str: return filename +def _process_value(value: T.Any) -> T.Any: + """ Process the values from a local dict and return in a loggable format + + Parameters + ---------- + value: Any + The dictionary value + + Returns + ------- + Any + The original or ammended value + """ + if isinstance(value, str): + return f'"{value}"' + if isinstance(value, np.ndarray) and np.prod(value.shape) > 10: + return f'[type: "{type(value).__name__}" shape: {value.shape}, dtype: "{value.dtype}"]' + if isinstance(value, (list, tuple, set)) and len(value) > 10: + return f'[type: "{type(value).__name__}" len: {len(value)}' + return value + + def parse_class_init(locals_dict: dict[str, T.Any]) -> str: """ Parse a locals dict from a class and return in a format suitable for logging Parameters @@ -555,10 +579,11 @@ def parse_class_init(locals_dict: dict[str, T.Any]) -> str: str The locals information suitable for logging """ - delimit = {k: f"'{v}'" if isinstance(v, str) else v + delimit = {k: _process_value(v) for k, v in locals_dict.items() if k != "self"} dsp = ", ".join(f"{k}: {v}" for k, v in delimit.items()) - return f"Initializing {locals_dict['self'].__class__.__name__} ({dsp})" + dsp = f" ({dsp})" if dsp else "" + return f"Initializing {locals_dict['self'].__class__.__name__}{dsp}" _OLD_FACTORY = logging.getLogRecordFactory() From aaa63ff896514514770f5758cbcb7cd2d862fe1c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 21 Mar 2024 11:56:01 +0000 Subject: [PATCH 882/981] bugfix: logger, don't import numpy unless required --- lib/logger.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/logger.py b/lib/logger.py index d72330f09a..c92a32164d 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -13,8 +13,6 @@ from datetime import datetime -import numpy as np - # TODO - Remove this monkey patch when TF autograph fixed to handle newer logging lib def _patched_format(self, record): @@ -561,10 +559,16 @@ def _process_value(value: T.Any) -> T.Any: """ if isinstance(value, str): return f'"{value}"' - if isinstance(value, np.ndarray) and np.prod(value.shape) > 10: - return f'[type: "{type(value).__name__}" shape: {value.shape}, dtype: "{value.dtype}"]' if isinstance(value, (list, tuple, set)) and len(value) > 10: return f'[type: "{type(value).__name__}" len: {len(value)}' + + try: + import numpy as np + except ImportError: + return value + + if isinstance(value, np.ndarray) and np.prod(value.shape) > 10: + return f'[type: "{type(value).__name__}" shape: {value.shape}, dtype: "{value.dtype}"]' return value From 180fe97a522fb6b0f9e840f5112320514afe45c2 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 22 Mar 2024 12:51:50 +0000 Subject: [PATCH 883/981] lib.training - Outpu useful error message if non-faceswap training images used --- lib/training/cache.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/training/cache.py b/lib/training/cache.py index 3b391a028e..875f10fa16 100644 --- a/lib/training/cache.py +++ b/lib/training/cache.py @@ -225,8 +225,17 @@ def cache_metadata(self, filenames: list[str]) -> np.ndarray: logger.debug("All metadata already cached for: %s", keys) return read_image_batch(filenames) - batch, metadata = read_image_batch(filenames, with_metadata=True) - + try: + batch, metadata = read_image_batch(filenames, with_metadata=True) + except ValueError as err: + if "inhomogeneous" in str(err): + raise FaceswapError( + "There was an error loading a batch of images. This is most likely due to " + "non-faceswap extracted faces in your training folder." + "\nAll training images should be Faceswap extracted faces." + "\nAll training images should be the same size." + f"\nThe files that caused this error are: {filenames}") from err + raise if len(batch.shape) == 1: folder = os.path.dirname(filenames[0]) details = [ From 2b08b8d2a64b752a051c6e97f864446843535df2 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 25 Mar 2024 18:55:29 +0000 Subject: [PATCH 884/981] typofix: tools.manual lower loglevel for mask removal --- tools/manual/manual.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/manual/manual.py b/tools/manual/manual.py index 0f05d37fb8..07d9f07253 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -798,7 +798,7 @@ def _remove_nn_masks(self, detected_face: DetectedFace) -> None: 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) + logger.debug("Removing masks after landmark update: %s", del_masks) for mask in del_masks: del detected_face.mask[mask] From 45028244812de1052ab58ef318a070b62b30a9d5 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 26 Mar 2024 18:06:48 +0000 Subject: [PATCH 885/981] Add mask dilation/erosion option for training --- lib/align/detected_face.py | 169 +++++++++++------- lib/training/cache.py | 4 +- locales/plugins.train._config.pot | 20 ++- .../ru/LC_MESSAGES/plugins.train._config.mo | Bin 59441 -> 59901 bytes .../ru/LC_MESSAGES/plugins.train._config.po | 26 ++- plugins/train/_config.py | 15 ++ 6 files changed, 156 insertions(+), 78 deletions(-) diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index c1a7560350..92e17d1dad 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -88,8 +88,8 @@ def __init__(self, landmarks_xy: np.ndarray | None = None, mask: dict[str, "Mask"] | None = None, filename: str | None = None) -> None: - logger.trace("Initializing %s: (image: %s, left: %s, width: %s, top: %s, " # type: ignore - "height: %s, landmarks_xy: %s, mask: %s, filename: %s)", + logger.trace("Initializing %s: (image: %s, left: %s, " # type:ignore[attr-defined] + "width: %s, top: %s, height: %s, landmarks_xy: %s, mask: %s, filename: %s)", self.__class__.__name__, image.shape if image is not None and image.any() else image, left, width, top, height, landmarks_xy, mask, filename) @@ -105,7 +105,7 @@ def __init__(self, self._training_masks: tuple[bytes, tuple[int, int, int]] | None = None self._aligned: AlignedFace | None = None - logger.trace("Initialized %s", self.__class__.__name__) # type: ignore + logger.trace("Initialized %s", self.__class__.__name__) # type:ignore[attr-defined] @property def aligned(self) -> AlignedFace: @@ -167,7 +167,7 @@ def add_mask(self, The centering to store the mask at. One of `"legacy"`, `"face"`, `"head"`. Default: `"face"` """ - logger.trace("name: '%s', mask shape: %s, affine_matrix: %s, " # type: ignore + logger.trace("name: '%s', mask shape: %s, affine_matrix: %s, " # type:ignore[attr-defined] "interpolator: %s, storage_size: %s, storage_centering: %s)", name, mask.shape, affine_matrix, interpolator, storage_size, storage_centering) fsmask = Mask(storage_size=storage_size, storage_centering=storage_centering) @@ -183,7 +183,7 @@ def add_landmarks_xy(self, landmarks: np.ndarray) -> None: landmarks: :class:`numpy.ndarray` The 68 point face landmarks to add for the face """ - logger.trace("landmarks shape: '%s'", landmarks.shape) # type: ignore + logger.trace("landmarks shape: '%s'", landmarks.shape) # type:ignore[attr-defined] self._landmarks_xy = landmarks def add_identity(self, name: str, embedding: np.ndarray, ) -> None: @@ -197,7 +197,7 @@ def add_identity(self, name: str, embedding: np.ndarray, ) -> None: embedding: numpy.ndarray The identity embedding """ - logger.trace("name: '%s', embedding shape: %s", # type: ignore + logger.trace("name: '%s', embedding shape: %s", # type:ignore[attr-defined] name, embedding.shape) assert name == "vggface2" assert embedding.shape[0] == 512 @@ -210,7 +210,7 @@ def clear_all_identities(self) -> None: def get_landmark_mask(self, area: T.Literal["eye", "face", "mouth"], blur_kernel: int, - dilation: int) -> np.ndarray: + dilation: float) -> np.ndarray: """ Add a :class:`LandmarksMask` to this detected face Landmark based masks are generated from face Aligned Face landmark points. An aligned @@ -224,8 +224,8 @@ def get_landmark_mask(self, specific areas blur_kernel: int The size of the kernel for blurring the mask edges - dilation: int - The amount of dilation to apply to the mask. `0` for none. Default: `0` + dilation: float + The amount of dilation to apply to the mask. as a percentage of the mask size Returns ------- @@ -233,7 +233,7 @@ def get_landmark_mask(self, The generated landmarks mask for the selected area """ # TODO Face mask generation from landmarks - logger.trace("area: %s, dilation: %s", area, dilation) # type: ignore + logger.trace("area: %s, dilation: %s", area, dilation) # type:ignore[attr-defined] areas = {"mouth": [slice(48, 60)], "eye": [slice(36, 42), slice(42, 48)]} points = [self.aligned.landmarks[zone] for zone in areas[area]] @@ -307,7 +307,7 @@ def to_alignment(self) -> AlignmentFileDict: for name, mask in self.mask.items()}, identity={k: v.tolist() for k, v in self._identity.items()}, thumb=self.thumbnail) - logger.trace("Returning: %s", alignment) # type: ignore + logger.trace("Returning: %s", alignment) # type:ignore[attr-defined] return alignment def from_alignment(self, alignment: AlignmentFileDict, @@ -332,8 +332,8 @@ def from_alignment(self, alignment: AlignmentFileDict, Default: ``False`` """ - logger.trace("Creating from alignment: (alignment: %s, has_image: %s)", # type: ignore - alignment, bool(image is not None)) + logger.trace("Creating from alignment: (alignment: %s," # type:ignore[attr-defined] + " has_image: %s)", alignment, bool(image is not None)) self.left = alignment["x"] self.width = alignment["w"] self.top = alignment["y"] @@ -358,9 +358,9 @@ def from_alignment(self, alignment: AlignmentFileDict, self.mask[name].from_dict(mask_dict) if image is not None and image.any(): self._image_to_face(image) - logger.trace("Created from alignment: (left: %s, width: %s, top: %s, " # type: ignore - "height: %s, landmarks: %s, mask: %s)", self.left, self.width, self.top, - self.height, self.landmarks_xy, self.mask) + logger.trace("Created from alignment: (left: %s, width: %s, " # type:ignore[attr-defined] + "top: %s, height: %s, landmarks: %s, mask: %s)", + self.left, self.width, self.top, self.height, self.landmarks_xy, self.mask) def to_png_meta(self) -> PNGHeaderAlignmentsDict: """ Return the detected face formatted for insertion into a png itxt header. @@ -403,14 +403,14 @@ def from_png_meta(self, alignment: PNGHeaderAlignmentsDict) -> None: for key, val in alignment.get("identity", {}).items(): assert key in ["vggface2"] self._identity[T.cast(T.Literal["vggface2"], key)] = np.array(val, dtype="float32") - logger.trace("Created from png exif header: (left: %s, width: %s, top: %s " # type: ignore - " height: %s, landmarks: %s, mask: %s, identity: %s)", self.left, self.width, - self.top, self.height, self.landmarks_xy, self.mask, + logger.trace("Created from png exif header: (left: %s, " # type:ignore[attr-defined] + "width: %s, top: %s height: %s, landmarks: %s, mask: %s, identity: %s)", + self.left, self.width, self.top, self.height, self.landmarks_xy, self.mask, {k: v.shape for k, v in self._identity.items()}) def _image_to_face(self, image: np.ndarray) -> None: """ set self.image to be the cropped face from detected bounding box """ - logger.trace("Cropping face from image") # type: ignore + logger.trace("Cropping face from image") # type:ignore[attr-defined] self.image = image[self.top: self.bottom, self.left: self.right] @@ -467,10 +467,11 @@ def load_aligned(self, """ if self._aligned and not force: # Don't reload an already aligned face - logger.trace("Skipping alignment calculation for already aligned face") # type: ignore + logger.trace("Skipping alignment calculation for already " # type:ignore[attr-defined] + "aligned face") else: - logger.trace("Loading aligned face: (size: %s, dtype: %s)", # type: ignore - size, dtype) + logger.trace("Loading aligned face: (size: %s, " # type:ignore[attr-defined] + "dtype: %s)", size, dtype) self._aligned = AlignedFace(self.landmarks_xy, image=image, centering=centering, @@ -507,7 +508,8 @@ class Mask(): def __init__(self, storage_size: int = 128, storage_centering: CenteringType = "face") -> None: - logger.trace("Initializing: %s (storage_size: %s, storage_centering: %s)", # type: ignore + logger.trace("Initializing: %s (storage_size: %s, " # type:ignore[attr-defined] + "storage_centering: %s)", self.__class__.__name__, storage_size, storage_centering) self.stored_size = storage_size self.stored_centering = storage_centering @@ -520,19 +522,21 @@ def __init__(self, self._blur_passes: int = 0 self._blur_kernel: float | int = 0 self._threshold = 0.0 + self._dilation: tuple[T.Literal["erode", "dilate"], np.ndarray | None] = ("erode", None) self._sub_crop_size = 0 self._sub_crop_slices: dict[T.Literal["in", "out"], list[slice]] = {} self.set_blur_and_threshold() - logger.trace("Initialized: %s", self.__class__.__name__) # type: ignore + logger.trace("Initialized: %s", self.__class__.__name__) # type:ignore[attr-defined] @property def mask(self) -> np.ndarray: """ :class:`numpy.ndarray`: The mask at the size of :attr:`stored_size` with any requested blurring, threshold amount and centering applied.""" mask = self.stored_mask - if self._threshold != 0.0 or self._blur_kernel != 0: + if self._dilation[-1] is not None or self._threshold != 0.0 or self._blur_kernel != 0: mask = mask.copy() + self._dilate_mask(mask) if self._threshold != 0.0: mask[mask < self._threshold] = 0.0 mask[mask > 255.0 - self._threshold] = 255.0 @@ -546,7 +550,7 @@ def mask(self) -> np.ndarray: slice_in, slice_out = self._sub_crop_slices["in"], self._sub_crop_slices["out"] out[slice_out[0], slice_out[1], :] = mask[slice_in[0], slice_in[1], :] mask = out - logger.trace("mask shape: %s", mask.shape) # type: ignore + logger.trace("mask shape: %s", mask.shape) # type:ignore[attr-defined] return mask @property @@ -556,7 +560,7 @@ def stored_mask(self) -> np.ndarray: assert self._mask is not None dims = (self.stored_size, self.stored_size, 1) mask = np.frombuffer(decompress(self._mask), dtype="uint8").reshape(dims) - logger.trace("stored mask shape: %s", mask.shape) # type: ignore + logger.trace("stored mask shape: %s", mask.shape) # type:ignore[attr-defined] return mask @property @@ -567,9 +571,9 @@ def original_roi(self) -> np.ndarray: [0, self.stored_size - 1], [self.stored_size - 1, self.stored_size - 1], [self.stored_size - 1, 0]], np.int32).reshape((-1, 1, 2)) - matrix = cv2.invertAffineTransform(self._affine_matrix) + matrix = cv2.invertAffineTransform(self.affine_matrix) roi = cv2.transform(points, matrix).reshape((4, 2)) - logger.trace("Returning: %s", roi) # type: ignore + logger.trace("Returning: %s", roi) # type:ignore[attr-defined] return roi @property @@ -584,6 +588,22 @@ def interpolator(self) -> int: assert self._interpolator is not None return self._interpolator + def _dilate_mask(self, mask: np.ndarray) -> None: + """ Erode/Dilate the mask. The action is performed in-place on the given mask. + + No action is performed if a dilation amount has not been set + + Parameters + ---------- + mask: :class:`numpy.ndarray` + The mask to be eroded/dilated + """ + if self._dilation[-1] is None: + return + + func = cv2.erode if self._dilation[0] == "erode" else cv2.dilate + func(mask, self._dilation[-1], dst=mask, iterations=1) + def get_full_frame_mask(self, width: int, height: int) -> np.ndarray: """ Return the stored mask in a full size frame of the given dimensions @@ -600,13 +620,13 @@ def get_full_frame_mask(self, width: int, height: int) -> np.ndarray: """ frame = np.zeros((width, height, 1), dtype="uint8") mask = cv2.warpAffine(self.mask, - self._affine_matrix, + self.affine_matrix, (width, height), frame, - flags=cv2.WARP_INVERSE_MAP | self._interpolator, + flags=cv2.WARP_INVERSE_MAP | self.interpolator, borderMode=cv2.BORDER_CONSTANT) - logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, mask max: %s", # type: ignore - mask.shape, mask.dtype, mask.min(), mask.max()) + logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, " # type:ignore[attr-defined] + "mask max: %s", mask.shape, mask.dtype, mask.min(), mask.max()) return mask def add(self, mask: np.ndarray, affine_matrix: np.ndarray, interpolator: int) -> None: @@ -624,9 +644,9 @@ def add(self, mask: np.ndarray, affine_matrix: np.ndarray, interpolator: int) -> interpolator, int: The CV2 interpolator required to transform this mask to it's original frame """ - logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, mask max: %s, " # type: ignore - "affine_matrix: %s, interpolator: %s)", mask.shape, mask.dtype, mask.min(), - affine_matrix, mask.max(), interpolator) + logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, " # type:ignore[attr-defined] + "mask max: %s, affine_matrix: %s, interpolator: %s)", + mask.shape, mask.dtype, mask.min(), affine_matrix, mask.max(), interpolator) self._affine_matrix = self._adjust_affine_matrix(mask.shape[0], affine_matrix) self._interpolator = interpolator self.replace_mask(mask) @@ -645,6 +665,26 @@ def replace_mask(self, mask: np.ndarray) -> None: interpolation=cv2.INTER_AREA) * 255.0).astype("uint8") self._mask = compress(mask.tobytes()) + def set_dilation(self, amount: float) -> None: + """ Set the internal dilation object for returned masks + + Parameters + ---------- + amount: float + The amount of erosion/dilation to apply as a percentage of the total mask size. + Negative values erode the mask. Positive values dilate the mask + """ + if amount == 0: + self._dilation = ("erode", None) + return + + action: T.Literal["erode", "dilate"] = "erode" if amount < 0 else "dilate" + kernel = int(round(self.stored_size * abs(amount / 100.), 0)) + self._dilation = (action, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel, kernel))) + + logger.trace("action: '%s', amount: %s, kernel: %s, ", # type:ignore[attr-defined] + action, amount, kernel) + def set_blur_and_threshold(self, blur_kernel: int = 0, blur_type: T.Literal["gaussian", "normalized"] | None = "gaussian", @@ -666,8 +706,9 @@ def set_blur_and_threshold(self, The threshold amount to minimize/maximize mask values to 0 and 100. Percentage value. Default: 0 """ - logger.trace("blur_kernel: %s, blur_type: %s, blur_passes: %s, " # type: ignore - "threshold: %s", blur_kernel, blur_type, blur_passes, threshold) + logger.trace("blur_kernel: %s, blur_type: %s, " # type:ignore[attr-defined] + "blur_passes: %s, threshold: %s", + blur_kernel, blur_type, blur_passes, threshold) if blur_type is not None: blur_kernel += 0 if blur_kernel == 0 or blur_kernel % 2 == 1 else 1 self._blur_kernel = blur_kernel @@ -719,9 +760,9 @@ def set_sub_crop(self, slice(max(roi[0] * -1, 0), crop_size - min(crop_size, max(0, roi[2] - self.stored_size)))] - logger.trace("src_size: %s, coverage_ratio: %s, sub_crop_size: %s, " # type: ignore - "sub_crop_slices: %s", roi, coverage_ratio, self._sub_crop_size, - self._sub_crop_slices) + logger.trace("src_size: %s, coverage_ratio: %s, " # type:ignore[attr-defined] + "sub_crop_size: %s, sub_crop_slices: %s", + roi, coverage_ratio, self._sub_crop_size, self._sub_crop_slices) def _adjust_affine_matrix(self, mask_size: int, affine_matrix: np.ndarray) -> np.ndarray: """ Adjust the affine matrix for the mask's storage size @@ -741,7 +782,7 @@ def _adjust_affine_matrix(self, mask_size: int, affine_matrix: np.ndarray) -> np zoom = self.stored_size / mask_size zoom_mat = np.array([[zoom, 0, 0.], [0, zoom, 0.]]) adjust_mat = np.dot(zoom_mat, np.concatenate((affine_matrix, np.array([[0., 0., 1.]])))) - logger.trace("storage_size: %s, mask_size: %s, zoom: %s, " # type: ignore + logger.trace("storage_size: %s, mask_size: %s, zoom: %s, " # type:ignore[attr-defined] "original matrix: %s, adjusted_matrix: %s", self.stored_size, mask_size, zoom, affine_matrix.shape, adjust_mat.shape) return adjust_mat @@ -768,7 +809,8 @@ def to_dict(self, is_png=False) -> MaskAlignmentsFileDict: interpolator=self.interpolator, stored_size=self.stored_size, stored_centering=self.stored_centering) - logger.trace({k: v if k != "mask" else type(v) for k, v in retval.items()}) # type: ignore + logger.trace({k: v if k != "mask" else type(v) # type:ignore[attr-defined] + for k, v in retval.items()}) return retval def to_png_meta(self) -> MaskAlignmentsFileDict: @@ -799,7 +841,7 @@ def from_dict(self, mask_dict: MaskAlignmentsFileDict) -> None: self.stored_size = mask_dict["stored_size"] centering = mask_dict.get("stored_centering") self.stored_centering = "face" if centering is None else centering - logger.trace({k: v if k != "mask" else type(v) # type: ignore + logger.trace({k: v if k != "mask" else type(v) # type:ignore[attr-defined] for k, v in mask_dict.items()}) @@ -826,17 +868,17 @@ class LandmarksMask(Mask): storage_centering, str (optional): The centering to store the mask at. One of `"legacy"`, `"face"`, `"head"`. Default: `"face"` - dilation: int, optional - The amount of dilation to apply to the mask. `0` for none. Default: `0` + dilation: float, optional + The amount of dilation to apply to the mask. as a percentage of the mask size. Default: 0.0 """ def __init__(self, points: list[np.ndarray], storage_size: int = 128, storage_centering: CenteringType = "face", - dilation: int = 0) -> None: + dilation: float = 0.0) -> None: super().__init__(storage_size=storage_size, storage_centering=storage_centering) self._points = points - self._dilation = dilation + self.set_dilation(dilation) @property def mask(self) -> np.ndarray: @@ -862,17 +904,15 @@ def generate_mask(self, affine_matrix: np.ndarray, interpolator: int) -> None: for landmarks in self._points: lms = np.rint(landmarks).astype("int") cv2.fillConvexPoly(mask, cv2.convexHull(lms), 1.0, lineType=cv2.LINE_AA) - if self._dilation != 0: - mask = cv2.dilate(mask, - cv2.getStructuringElement(cv2.MORPH_ELLIPSE, - (self._dilation, self._dilation)), - iterations=1) + if self._dilation[-1] is not None: + self._dilate_mask(mask) if self._blur_kernel != 0 and self._blur_type is not None: mask = BlurMask(self._blur_type, mask, self._blur_kernel, passes=self._blur_passes).blurred - logger.trace("mask: (shape: %s, dtype: %s)", mask.shape, mask.dtype) # type: ignore + logger.trace("mask: (shape: %s, dtype: %s)", # type:ignore[attr-defined] + mask.shape, mask.dtype) self.add(mask, affine_matrix, interpolator) @@ -911,15 +951,16 @@ def __init__(self, kernel: int | float, is_ratio: bool = False, passes: int = 1) -> None: - logger.trace("Initializing %s: (blur_type: '%s', mask_shape: %s, " # type: ignore - "kernel: %s, is_ratio: %s, passes: %s)", self.__class__.__name__, blur_type, + logger.trace("Initializing %s: (blur_type: '%s', " # type:ignore[attr-defined] + "mask_shape: %s, kernel: %s, is_ratio: %s, passes: %s)", + self.__class__.__name__, blur_type, mask.shape, kernel, is_ratio, passes) self._blur_type = blur_type self._mask = mask self._passes = passes kernel_size = self._get_kernel_size(kernel, is_ratio) self._kernel_size = self._get_kernel_tuple(kernel_size) - logger.trace("Initialized %s", self.__class__.__name__) # type: ignore + logger.trace("Initialized %s", self.__class__.__name__) # type:ignore[attr-defined] @property def blurred(self) -> np.ndarray: @@ -930,12 +971,14 @@ def blurred(self) -> np.ndarray: for i in range(self._passes): assert isinstance(kwargs["ksize"], tuple) ksize = int(kwargs["ksize"][0]) - logger.trace("Pass: %s, kernel_size: %s", i + 1, (ksize, ksize)) # type: ignore + logger.trace("Pass: %s, kernel_size: %s", # type:ignore[attr-defined] + i + 1, (ksize, ksize)) blurred = func(blurred, **kwargs) ksize = int(round(ksize * self._multipass_factor)) kwargs["ksize"] = self._get_kernel_tuple(ksize) blurred = blurred[..., None] - logger.trace("Returning blurred mask. Shape: %s", blurred.shape) # type: ignore + logger.trace("Returning blurred mask. Shape: %s", # type:ignore[attr-defined] + blurred.shape) return blurred @property @@ -991,7 +1034,7 @@ def _get_kernel_size(self, kernel: int | float, is_ratio: bool) -> int: mask_diameter = np.sqrt(np.sum(self._mask)) radius = round(max(1., mask_diameter * kernel / 100.)) kernel_size = int(radius * 2 + 1) - logger.trace("kernel_size: %s", kernel_size) # type: ignore + logger.trace("kernel_size: %s", kernel_size) # type:ignore[attr-defined] return kernel_size @staticmethod @@ -1010,14 +1053,14 @@ def _get_kernel_tuple(kernel_size: int) -> tuple[int, int]: """ kernel_size += 1 if kernel_size % 2 == 0 else 0 retval = (kernel_size, kernel_size) - logger.trace(retval) # type: ignore + logger.trace(retval) # type:ignore[attr-defined] return retval def _get_kwargs(self) -> dict[str, int | tuple[int, int]]: """ dict: the valid keyword arguments for the requested :attr:`_blur_type` """ retval = {kword: self._kwarg_mapping[kword] for kword in self._kwarg_requirements[self._blur_type]} - logger.trace("BlurMask kwargs: %s", retval) # type: ignore + logger.trace("BlurMask kwargs: %s", retval) # type:ignore[attr-defined] return retval diff --git a/lib/training/cache.py b/lib/training/cache.py index 875f10fa16..a2fa1d28d4 100644 --- a/lib/training/cache.py +++ b/lib/training/cache.py @@ -428,8 +428,10 @@ def _get_face_mask(self, filename: str, detected_face: DetectedFace) -> np.ndarr f"The masks that exist for this face are: {list(detected_face.mask)}") mask = detected_face.mask[str(self._config["mask_type"])] + assert isinstance(self._config["mask_dilation"], float) assert isinstance(self._config["mask_blur_kernel"], int) assert isinstance(self._config["mask_threshold"], int) + mask.set_dilation(self._config["mask_dilation"]) mask.set_blur_and_threshold(blur_kernel=self._config["mask_blur_kernel"], threshold=self._config["mask_threshold"]) @@ -467,7 +469,7 @@ def _get_localized_mask(self, assert isinstance(multiplier, int) if not self._config["penalized_mask_loss"] or multiplier <= 1: return None - mask = detected_face.get_landmark_mask(area, self._size // 16, self._size // 32) + mask = detected_face.get_landmark_mask(area, self._size // 16, 2.5) logger.trace("Caching localized '%s' mask for: %s %s", # type: ignore area, filename, mask.shape) return mask diff --git a/locales/plugins.train._config.pot b/locales/plugins.train._config.pot index 29a8ac7178..38e94f3152 100644 --- a/locales/plugins.train._config.pot +++ b/locales/plugins.train._config.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 14:54+0100\n" +"POT-Creation-Date: 2024-03-26 17:37+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -539,8 +539,9 @@ msgid "" "attention on the core face area." msgstr "" -#: plugins/train/_config.py:600 plugins/train/_config.py:642 -#: plugins/train/_config.py:656 plugins/train/_config.py:665 +#: plugins/train/_config.py:600 plugins/train/_config.py:643 +#: plugins/train/_config.py:656 plugins/train/_config.py:671 +#: plugins/train/_config.py:680 msgid "mask" msgstr "" @@ -582,7 +583,14 @@ msgid "" "performance." msgstr "" -#: plugins/train/_config.py:644 +#: plugins/train/_config.py:645 +msgid "" +"Dilate or erode the mask. Negative values erode the mask (make it smaller). " +"Positive values dilate the mask (make it larger). The value given is a " +"percentage of the total mask size." +msgstr "" + +#: plugins/train/_config.py:658 msgid "" "Apply gaussian blur to the mask input. This has the effect of smoothing the " "edges of the mask, which can help with poorly calculated masks and give less " @@ -592,13 +600,13 @@ msgid "" "number." msgstr "" -#: plugins/train/_config.py:658 +#: plugins/train/_config.py:673 msgid "" "Sets pixels that are near white to white and near black to black. Set to 0 " "for off." msgstr "" -#: plugins/train/_config.py:667 +#: plugins/train/_config.py:682 msgid "" "Dedicate a portion of the model to learning how to duplicate the input mask. " "Increases VRAM usage in exchange for learning a quick ability to try to " diff --git a/locales/ru/LC_MESSAGES/plugins.train._config.mo b/locales/ru/LC_MESSAGES/plugins.train._config.mo index 0b922cd0d0d7543e45d7d687341a2114189dfb7a..a1032a91d1a0e62524d07cf7f2125b2825715bd0 100644 GIT binary patch delta 1829 zcmai!3v82B6vzM2JwP_a@leFc$9Qc*=U^RdQ@}UKOM$7xX|QhJMx~?L)@hWa+dvp$ zNED|?6wv4r#p!Sq3pgSsMB|I^<6+DQ5;Z|Es6k9fh(v$49k`enZ*uy(=bm%VIrrTA zb-UxaaL5z;E+?&3jH$@ANLpWMAw2Ejhf$g?*{}@W)<@a`o8XTbQiAoGeo`Ec=r4_f zN8l~kFTnTU^a0W|_-mHb4!xI4GstoD3h6bEVj7hrm0;L6MEa8e=Y~on(f_zg`VGgS z!=!cS8B{kI`-{0!CHmX22|ay;^et?L(^>ah(gyU`M@rk+Uy>)i!}|B5B}{4=D{X@9 zu$latN!jMaE<6Fp>0{W4{`jpF7Q^8zlM>p!`F2i;es~5(9FESEz9Qh?svF8s^pNglI*zC>J|Tfp_fzaU*!8mHO^2SKxbq=((m_#cNcu1P;JISOCyRDj-ACAFeEJe)_# zToaIX@Lt?HI*ol*RLX|k_4Gf)0%dKI z*0ZqtRmq1wYqK;K#f+3D|8~~OX(Z(y&F3yVbhAkd@cl>eS~`@@%e5ZBM!b}QeGx^f1ED@ zeHt(HI{b?{PCvK+UIQzkd8G9)3w{47=`wf_8oy(Z4@)N?ZplxbcbmHZAxuUiW>BO? zC1*U!9$v)Yo(YXe4nAR(BUd6O_*BGW{gGBjb$Vg zK5_B?As=GeR*FnOCL&y;ZbHn+NkO@tx#?#;u|UutwXIOtvcsWj+lnr+tvY{XX`waG zuJuO)^|n>-4?bZ>{$;oF>-^8X#Y*@uwlM{FLxGheXyVi+Y&WBDHi>q1e!eRZTAntP}@TDMDzO%z@ zbUI=wOWb|lnAzsGP)wX2Ia{2)ck;$9MU8_lqmnbRP%sEmaO;Vi;`x;pU% zvlNzLTSX8SISk`QTF@W$hfz_916i{dq0ka5NDH#WKIeCD2fn}0dCqyx+jE}tyFMCw z_jD}z{fw+bSeB?V_3s2&BfpLLvD9S4<8q~iAC@ibq!4Yq3+HNa(gs0sSR#4T`1|377^_W3qA zJ_)YL9oi!u>5auZjX=^+jCDbd1M*kE79EQ3gfE?NkG!tE_AV&5f6d+Sf&N>2Af_E( z4bDsAKFE@7GG8v0AIqLhdx7^E`io@nFO}XBl`?tFO0AU;%IaS4(1mZvaoKS{Jmx}W z4}ev+uXpFRkI8jzWamTXh4%O(o?bg^BW!Y|p+0!e{K($yrtSZ*AEwFtr@a-viw>5(u*dl95uxcJMOKQT-f=SA2yM-e;ab)CL5l8#na2wYp~K2jKA(?WYv*0 zH@|K2xnSxTeC>nOe~{h|pPhhT&G6!r@P~Hn6s)rUFZr^O==L;cUGV&A_|*Or zXM82jmrT07HW#k@#w66?iE}W|OIr0ET-EOX2~2SME|_?7x11vn$dH^zr7w)XFG8-& z@z}wyQ2L;lBXz?^g!vlv70FvvC?Wrt7gdc!hW}$`reYwX5|Ya#X1Xd?OH?QeRH?d2 zc@}1?8LD1|a;sVt)nI;xdD34&GRnx%R;j`XnuB5Sl|`k*Z;i@Ho6$E!Ae_#KN2S_t zgQ`=lYOyL;3DvGbsZvIq3gyd=qS_d?9-cyrs#T$caNZu(3}c36sd)C4SnBMQ2UkvS rjMulu8|o_(P3_ICwe^X?&fX*0jdcyV3?59Cq_Wm_O-l7_IKKZs%x%3+ diff --git a/locales/ru/LC_MESSAGES/plugins.train._config.po b/locales/ru/LC_MESSAGES/plugins.train._config.po index 1439600ec9..04191e5e70 100644 --- a/locales/ru/LC_MESSAGES/plugins.train._config.po +++ b/locales/ru/LC_MESSAGES/plugins.train._config.po @@ -7,15 +7,15 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 14:54+0100\n" -"PO-Revision-Date: 2023-08-20 14:58+0100\n" +"POT-Creation-Date: 2024-03-26 17:37+0000\n" +"PO-Revision-Date: 2024-03-26 17:40+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru_RU\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Poedit 3.3.2\n" +"X-Generator: Poedit 3.4.2\n" #: plugins/train/_config.py:17 msgid "" @@ -851,8 +851,9 @@ msgstr "" "время как область лица с маской является приоритетной. Может повысить общее " "качество за счет концентрации внимания на основной области лица." -#: plugins/train/_config.py:600 plugins/train/_config.py:642 -#: plugins/train/_config.py:656 plugins/train/_config.py:665 +#: plugins/train/_config.py:600 plugins/train/_config.py:643 +#: plugins/train/_config.py:656 plugins/train/_config.py:671 +#: plugins/train/_config.py:680 msgid "mask" msgstr "маска" @@ -928,7 +929,16 @@ msgstr "" "сообщества и для дальнейшего описания нуждается в тестировании. Профильные " "лица могут иметь низкую производительность." -#: plugins/train/_config.py:644 +#: plugins/train/_config.py:645 +msgid "" +"Dilate or erode the mask. Negative values erode the mask (make it smaller). " +"Positive values dilate the mask (make it larger). The value given is a " +"percentage of the total mask size." +msgstr "" +"Расширяет или сужает маску. Отрицательные значения сужают маску (делают её " +"меньше). Положительные значения расширяют маску (делают её больше). " + +#: plugins/train/_config.py:658 msgid "" "Apply gaussian blur to the mask input. This has the effect of smoothing the " "edges of the mask, which can help with poorly calculated masks and give less " @@ -944,7 +954,7 @@ msgstr "" "должно быть нечетным, если передано четное число, то оно будет округлено до " "следующего нечетного числа." -#: plugins/train/_config.py:658 +#: plugins/train/_config.py:673 msgid "" "Sets pixels that are near white to white and near black to black. Set to 0 " "for off." @@ -952,7 +962,7 @@ msgstr "" "Устанавливает пиксели, которые почти белые - в белые и которые почти черные " "- в черные. Установите 0, чтобы выключить." -#: plugins/train/_config.py:667 +#: plugins/train/_config.py:682 msgid "" "Dedicate a portion of the model to learning how to duplicate the input mask. " "Increases VRAM usage in exchange for learning a quick ability to try to " diff --git a/plugins/train/_config.py b/plugins/train/_config.py index bb0b57f7e8..404e0d8e06 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -632,6 +632,19 @@ def _set_loss(self) -> None: "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.")) + self.add_item( + section=section, + title="mask_dilation", + datatype=float, + min_max=(-5.0, 5.0), + rounding=1, + default=0, + fixed=False, + group=_("mask"), + info=_( + "Dilate or erode the mask. Negative values erode the mask (make it smaller). " + "Positive values dilate the mask (make it larger). The value given is a " + "percentage of the total mask size.")) self.add_item( section=section, title="mask_blur_kernel", @@ -639,6 +652,7 @@ def _set_loss(self) -> None: min_max=(0, 9), rounding=1, default=3, + fixed=False, group=_("mask"), info=_( "Apply gaussian blur to the mask input. This has the effect of smoothing the " @@ -653,6 +667,7 @@ def _set_loss(self) -> None: default=4, min_max=(0, 50), rounding=1, + fixed=False, group=_("mask"), info=_( "Sets pixels that are near white to white and near black to black. Set to 0 for " From 97a842112c7f29c8777a33665fbd00a6aab08981 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 3 Apr 2024 13:57:19 +0100 Subject: [PATCH 886/981] lib.align.aligned_face - Add split_mask method --- lib/align/aligned_face.py | 21 ++++++++++++++++++++- plugins/extract/mask/_base.py | 3 +-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 4043730172..6f41cab106 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -9,7 +9,7 @@ import cv2 import numpy as np -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) # pylint:disable=invalid-name CenteringType = T.Literal["face", "head", "legacy"] _MEAN_FACE = np.array([[0.010086, 0.106454], [0.085135, 0.038915], [0.191003, 0.018748], @@ -810,6 +810,25 @@ def get_cropped_roi(self, self._cache.cropped_roi[centering] = roi return self._cache.cropped_roi[centering] + def split_mask(self) -> np.ndarray: + """ Remove the mask from the alpha channel of :attr:`face` and return the mask + + Returns + ------- + :class:`numpy.ndarray` + The mask that was stored in the :attr:`face`'s alpha channel + + Raises + ------ + AssertionError + If :attr:`face` does not contain a mask in the alpha channel + """ + assert self._face is not None + assert self._face.shape[-1] == 4, "No mask stored in the alpha channel" + mask = self._face[..., 3] + self._face = self._face[..., :3] + return mask + def _umeyama(source: np.ndarray, destination: np.ndarray, estimate_scale: bool) -> np.ndarray: """Estimate N-D similarity transformation with or without scaling. diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 837b6812e7..82a6e074f8 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -166,8 +166,7 @@ def get_batch(self, queue: Queue) -> tuple[bool, MaskerBatch]: assert feed_face.face is not None if not item.is_aligned: # Split roi mask from feed face alpha channel - roi_mask = feed_face.face[..., 3] - feed_face._face = feed_face.face[..., :3] # pylint:disable=protected-access + roi_mask = feed_face.split_mask() else: # We have to do the warp here as AlignedFace did not perform it roi_mask = transform_image(roi, From a9d87ae007aabf68c2cef0f7828fed4b56ca92fa Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 3 Apr 2024 14:03:54 +0100 Subject: [PATCH 887/981] linting: Remove pylint hint for logger --- .pylintrc | 2 +- lib/align/aligned_face.py | 2 +- lib/align/alignments.py | 2 +- lib/align/detected_face.py | 2 +- lib/cli/args.py | 2 +- lib/cli/launcher.py | 2 +- lib/config.py | 2 +- lib/convert.py | 2 +- lib/gui/_config.py | 2 +- lib/gui/analysis/event_reader.py | 2 +- lib/gui/analysis/stats.py | 2 +- lib/gui/command.py | 2 +- lib/gui/control_helper.py | 2 +- lib/gui/custom_widgets.py | 2 +- lib/gui/display.py | 2 +- lib/gui/display_analysis.py | 2 +- lib/gui/display_command.py | 2 +- lib/gui/display_page.py | 2 +- lib/gui/menu.py | 2 +- lib/gui/options.py | 2 +- lib/gui/popup_configure.py | 2 +- lib/gui/popup_session.py | 2 +- lib/gui/project.py | 2 +- lib/gui/theme.py | 2 +- lib/gui/utils/config.py | 2 +- lib/gui/utils/file_handler.py | 2 +- lib/gui/utils/image.py | 2 +- lib/gui/utils/misc.py | 2 +- lib/gui/wrapper.py | 2 +- lib/image.py | 2 +- lib/model/backup_restore.py | 2 +- lib/model/initializers.py | 2 +- lib/model/nn_blocks.py | 2 +- lib/model/session.py | 2 +- lib/multithreading.py | 2 +- lib/queue_manager.py | 2 +- lib/serializer.py | 2 +- lib/training/augmentation.py | 2 +- lib/vgg_face.py | 2 +- plugins/convert/_config.py | 2 +- plugins/convert/color/_base.py | 2 +- plugins/convert/scaling/_base.py | 2 +- plugins/convert/writer/_base.py | 2 +- plugins/extract/pipeline.py | 2 +- plugins/extract/recognition/vgg_face2.py | 2 +- plugins/plugin_loader.py | 2 +- plugins/train/model/_base/io.py | 2 +- plugins/train/model/_base/model.py | 2 +- plugins/train/model/_base/settings.py | 2 +- plugins/train/model/dfaker.py | 2 +- plugins/train/model/dfl_sae.py | 2 +- plugins/train/model/dlight.py | 2 +- plugins/train/model/phaze_a.py | 2 +- plugins/train/model/realface.py | 2 +- scripts/convert.py | 2 +- scripts/extract.py | 2 +- scripts/fsmedia.py | 2 +- scripts/gui.py | 2 +- setup.py | 2 +- tools/alignments/alignments.py | 2 +- tools/alignments/media.py | 2 +- tools/effmpeg/effmpeg.py | 2 +- tools/manual/faceviewer/frame.py | 2 +- tools/manual/faceviewer/viewport.py | 2 +- tools/manual/frameviewer/control.py | 2 +- tools/manual/frameviewer/editor/_base.py | 2 +- tools/manual/frameviewer/frame.py | 2 +- tools/manual/manual.py | 2 +- tools/model/model.py | 2 +- tools/preview/preview.py | 2 +- 70 files changed, 70 insertions(+), 70 deletions(-) diff --git a/.pylintrc b/.pylintrc index 3d669e8927..5f6e63247f 100644 --- a/.pylintrc +++ b/.pylintrc @@ -426,7 +426,7 @@ max-returns=6 max-statements=50 # Minimum number of public methods for a class (see R0903). -min-public-methods=2 +min-public-methods=1 [CLASSES] diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 6f41cab106..263b03cb8f 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -9,7 +9,7 @@ import cv2 import numpy as np -logger = logging.getLogger(__name__) # pylint:disable=invalid-name +logger = logging.getLogger(__name__) CenteringType = T.Literal["face", "head", "legacy"] _MEAN_FACE = np.array([[0.010086, 0.106454], [0.085135, 0.038915], [0.191003, 0.018748], diff --git a/lib/align/alignments.py b/lib/align/alignments.py index d3cb13bc03..7013a7bd4c 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -16,7 +16,7 @@ from collections.abc import Generator from .aligned_face import CenteringType -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) _VERSION = 2.3 # VERSION TRACKING # 1.0 - Never really existed. Basically any alignments file prior to version 2.0 diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index 92e17d1dad..5cec4bfc8f 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -21,7 +21,7 @@ from collections.abc import Callable from .aligned_face import CenteringType -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class DetectedFace(): diff --git a/lib/cli/args.py b/lib/cli/args.py index 15b3377967..8193acc4a8 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -19,7 +19,7 @@ FilesFullPaths, MultiOption, Radio, SaveFileFullPaths, Slider) from .launcher import ScriptExecutor -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) _GPUS = GPUStats().cli_devices # LOCALES diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 9dfa3c132f..4745c274d0 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -18,7 +18,7 @@ import argparse from collections.abc import Callable -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class ScriptExecutor(): # pylint:disable=too-few-public-methods diff --git a/lib/config.py b/lib/config.py index 3619166f6c..d4a2f1298b 100644 --- a/lib/config.py +++ b/lib/config.py @@ -23,7 +23,7 @@ OrderedDictSectionType = OrderedDict[str, "ConfigSection"] OrderedDictItemType = OrderedDict[str, "ConfigItem"] -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) ConfigValueType = bool | int | float | list[str] | str | None diff --git a/lib/convert.py b/lib/convert.py index a7439dcb8e..3b5c91b936 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -23,7 +23,7 @@ from plugins.convert.mask.mask_blend import Mask as MaskAdjust from plugins.convert.scaling._base import Adjustment as ScalingAdjust -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) @dataclass diff --git a/lib/gui/_config.py b/lib/gui/_config.py index 7fb1037cd8..e5a034b3f6 100644 --- a/lib/gui/_config.py +++ b/lib/gui/_config.py @@ -9,7 +9,7 @@ from lib.config import FaceswapConfig -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class Config(FaceswapConfig): diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index e4da91a2cf..8617150236 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -21,7 +21,7 @@ if T.TYPE_CHECKING: from collections.abc import Generator, Iterator -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) @dataclass diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index eda0e19ca2..7c6aab3d43 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -22,7 +22,7 @@ from .event_reader import TensorBoardLogs -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class GlobalSession(): diff --git a/lib/gui/command.py b/lib/gui/command.py index 56f4106aa6..9ac949d9c9 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -10,7 +10,7 @@ from .custom_widgets import Tooltip from .utils import get_images, get_config -logger = logging.getLogger(__name__) # pylint:disable=invalid-name +logger = logging.getLogger(__name__) # LOCALES _LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 3906370081..31c8436e47 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -15,7 +15,7 @@ from .custom_widgets import ContextMenu, MultiOption, ToggledFrame, Tooltip from .utils import FileHandler, get_config, get_images -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) # LOCALES _LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index 30c73a6554..5a39abdb8c 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -13,7 +13,7 @@ from .utils import get_config -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class ContextMenu(tk.Menu): # pylint: disable=too-many-ancestors diff --git a/lib/gui/display.py b/lib/gui/display.py index dbc09b8992..0d2b107f93 100644 --- a/lib/gui/display.py +++ b/lib/gui/display.py @@ -16,7 +16,7 @@ from .display_command import GraphDisplay, PreviewExtract, PreviewTrain from .utils import get_config -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) # LOCALES _LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) diff --git a/lib/gui/display_analysis.py b/lib/gui/display_analysis.py index 682170d18d..a04ec63843 100644 --- a/lib/gui/display_analysis.py +++ b/lib/gui/display_analysis.py @@ -16,7 +16,7 @@ from .analysis import Session from .utils import FileHandler, get_config, get_images, LongRunningTask -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) # LOCALES _LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) diff --git a/lib/gui/display_command.py b/lib/gui/display_command.py index 48d3565c41..ee2cd8231e 100644 --- a/lib/gui/display_command.py +++ b/lib/gui/display_command.py @@ -19,7 +19,7 @@ from .control_helper import set_slider_rounding from .utils import FileHandler, get_config, get_images, preview_trigger -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) # LOCALES _LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) diff --git a/lib/gui/display_page.py b/lib/gui/display_page.py index 74eb38ff92..e6bc3eaef0 100644 --- a/lib/gui/display_page.py +++ b/lib/gui/display_page.py @@ -9,7 +9,7 @@ from .custom_widgets import Tooltip from .utils import get_images -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) # LOCALES _LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 2a3026f43d..7b83af56e6 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -22,7 +22,7 @@ if T.TYPE_CHECKING: from scripts.gui import FaceswapGui -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) # LOCALES _LANG = gettext.translation("gui.menu", localedir="locales", fallback=True) diff --git a/lib/gui/options.py b/lib/gui/options.py index e4eaea8e27..3d1ac824ae 100644 --- a/lib/gui/options.py +++ b/lib/gui/options.py @@ -13,7 +13,7 @@ from .utils import get_images from .control_helper import ControlPanelOption -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class CliOptions(): diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index ea361a7d39..6bdb725db4 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -22,7 +22,7 @@ if T.TYPE_CHECKING: from lib.config import FaceswapConfig -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) # LOCALES _LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) diff --git a/lib/gui/popup_session.py b/lib/gui/popup_session.py index 2d0162f02c..f145e37e52 100644 --- a/lib/gui/popup_session.py +++ b/lib/gui/popup_session.py @@ -15,7 +15,7 @@ from .analysis import Calculations, Session from .utils import FileHandler, get_images, LongRunningTask -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) # LOCALES _LANG = gettext.translation("gui.tooltips", localedir="locales", fallback=True) diff --git a/lib/gui/project.py b/lib/gui/project.py index 508890fcd2..4fd61de074 100644 --- a/lib/gui/project.py +++ b/lib/gui/project.py @@ -8,7 +8,7 @@ from lib.serializer import get_serializer -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class _GuiSession(): # pylint:disable=too-few-public-methods diff --git a/lib/gui/theme.py b/lib/gui/theme.py index 777894b370..993a906fd6 100644 --- a/lib/gui/theme.py +++ b/lib/gui/theme.py @@ -11,7 +11,7 @@ from lib.utils import FaceswapError -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class Style(): # pylint:disable=too-few-public-methods diff --git a/lib/gui/utils/config.py b/lib/gui/utils/config.py index 58e8152cee..57bd42aa33 100644 --- a/lib/gui/utils/config.py +++ b/lib/gui/utils/config.py @@ -20,7 +20,7 @@ from lib.gui.command import CommandNotebook from lib.gui.command import ToolsNotebook -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) PATHCACHE = os.path.join(os.path.realpath(os.path.dirname(sys.argv[0])), "lib", "gui", ".cache") _CONFIG: Config | None = None diff --git a/lib/gui/utils/file_handler.py b/lib/gui/utils/file_handler.py index 45ff8c5fa9..e1e687e1c6 100644 --- a/lib/gui/utils/file_handler.py +++ b/lib/gui/utils/file_handler.py @@ -6,7 +6,7 @@ from tkinter import filedialog import typing as T -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) _FILETYPE = T.Literal["default", "alignments", "config_project", "config_task", "config_all", "csv", "image", "ini", "state", "log", "video"] _HANDLETYPE = T.Literal["open", "save", "filename", "filename_multi", "save_filename", diff --git a/lib/gui/utils/image.py b/lib/gui/utils/image.py index 37eb05283c..592ee98f64 100644 --- a/lib/gui/utils/image.py +++ b/lib/gui/utils/image.py @@ -16,7 +16,7 @@ if T.TYPE_CHECKING: from collections.abc import Sequence -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) _IMAGES: "Images" | None = None _PREVIEW_TRIGGER: "PreviewTrigger" | None = None TRAININGPREVIEW = ".gui_training_preview.png" diff --git a/lib/gui/utils/misc.py b/lib/gui/utils/misc.py index 2506af3799..58a4befa98 100644 --- a/lib/gui/utils/misc.py +++ b/lib/gui/utils/misc.py @@ -16,7 +16,7 @@ from lib.multithreading import _ErrorType -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class LongRunningTask(Thread): diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index dfb2b82fce..84fe5459e5 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -20,7 +20,7 @@ if os.name == "nt": import win32console # pylint: disable=import-error -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class ProcessWrapper(): diff --git a/lib/image.py b/lib/image.py index 19e161c8ef..96685e6cb4 100644 --- a/lib/image.py +++ b/lib/image.py @@ -28,7 +28,7 @@ if T.TYPE_CHECKING: from lib.align.alignments import PNGHeaderDict -logger = logging.getLogger(__name__) # pylint:disable=invalid-name +logger = logging.getLogger(__name__) # ################### # # <<< IMAGE UTILS >>> # diff --git a/lib/model/backup_restore.py b/lib/model/backup_restore.py index 0408040b85..e143266c9e 100644 --- a/lib/model/backup_restore.py +++ b/lib/model/backup_restore.py @@ -10,7 +10,7 @@ from lib.serializer import get_serializer from lib.utils import get_folder -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class Backup(): diff --git a/lib/model/initializers.py b/lib/model/initializers.py index 8085de3e04..41f7682371 100644 --- a/lib/model/initializers.py +++ b/lib/model/initializers.py @@ -13,7 +13,7 @@ K = keras.backend -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) def compute_fans(shape, data_format='channels_last'): diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index d805332aec..63e431d33e 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -19,7 +19,7 @@ from tensorflow import Tensor -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) _CONFIG: dict = {} diff --git a/lib/model/session.py b/lib/model/session.py index 9cc1de1390..a400b2fde6 100644 --- a/lib/model/session.py +++ b/lib/model/session.py @@ -17,7 +17,7 @@ if T.TYPE_CHECKING: from collections.abc import Callable -logger = logging.getLogger(__name__) # pylint:disable=invalid-name +logger = logging.getLogger(__name__) class KSession(): diff --git a/lib/multithreading.py b/lib/multithreading.py index f324080f04..73cdc983ee 100644 --- a/lib/multithreading.py +++ b/lib/multithreading.py @@ -13,7 +13,7 @@ if T.TYPE_CHECKING: from collections.abc import Callable, Generator -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) _ErrorType: T.TypeAlias = tuple[type[BaseException], BaseException, TracebackType] | tuple[T.Any, T.Any, T.Any] | None diff --git a/lib/queue_manager.py b/lib/queue_manager.py index 7eeacc14f4..6848636bd1 100644 --- a/lib/queue_manager.py +++ b/lib/queue_manager.py @@ -10,7 +10,7 @@ from queue import Queue, Empty as QueueEmpty # pylint: disable=unused-import; # noqa from time import sleep -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class EventQueue(Queue): diff --git a/lib/serializer.py b/lib/serializer.py index 4300e95f45..a468a4401d 100644 --- a/lib/serializer.py +++ b/lib/serializer.py @@ -21,7 +21,7 @@ except ImportError: _HAS_YAML = False -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class Serializer(): diff --git a/lib/training/augmentation.py b/lib/training/augmentation.py index c559a621c1..81f56e8fed 100644 --- a/lib/training/augmentation.py +++ b/lib/training/augmentation.py @@ -15,7 +15,7 @@ if T.TYPE_CHECKING: from lib.config import ConfigValueType -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) @dataclass diff --git a/lib/vgg_face.py b/lib/vgg_face.py index 9917fbac97..d10c957df9 100644 --- a/lib/vgg_face.py +++ b/lib/vgg_face.py @@ -14,7 +14,7 @@ from lib.utils import GetModel -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class VGGFace(): diff --git a/plugins/convert/_config.py b/plugins/convert/_config.py index 5f5ad26c0f..3deb1857f4 100644 --- a/plugins/convert/_config.py +++ b/plugins/convert/_config.py @@ -6,7 +6,7 @@ from lib.config import FaceswapConfig -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class Config(FaceswapConfig): diff --git a/plugins/convert/color/_base.py b/plugins/convert/color/_base.py index 1a5c4ebd72..7f58d45ead 100644 --- a/plugins/convert/color/_base.py +++ b/plugins/convert/color/_base.py @@ -6,7 +6,7 @@ from plugins.convert._config import Config -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) def get_config(plugin_name, configfile=None): diff --git a/plugins/convert/scaling/_base.py b/plugins/convert/scaling/_base.py index bee1321be9..036ddc1557 100644 --- a/plugins/convert/scaling/_base.py +++ b/plugins/convert/scaling/_base.py @@ -6,7 +6,7 @@ from plugins.convert._config import Config -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) def get_config(plugin_name, configfile=None): diff --git a/plugins/convert/writer/_base.py b/plugins/convert/writer/_base.py index 2961683b91..33389f7989 100644 --- a/plugins/convert/writer/_base.py +++ b/plugins/convert/writer/_base.py @@ -10,7 +10,7 @@ from plugins.convert._config import Config -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) def get_config(plugin_name: str, configfile: str | None = None) -> dict: diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 80f598acc9..0cca3e5092 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -30,7 +30,7 @@ from plugins.extract.mask._base import Masker from plugins.extract.recognition._base import Identity -logger = logging.getLogger(__name__) # pylint:disable=invalid-name +logger = logging.getLogger(__name__) _INSTANCES = -1 # Tracking for multiple instances of pipeline diff --git a/plugins/extract/recognition/vgg_face2.py b/plugins/extract/recognition/vgg_face2.py index ae717c75a2..aa25a4efa3 100644 --- a/plugins/extract/recognition/vgg_face2.py +++ b/plugins/extract/recognition/vgg_face2.py @@ -17,7 +17,7 @@ if T.TYPE_CHECKING: from collections.abc import Generator -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class Recognition(Identity): diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 30b9762177..7d47c20680 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -16,7 +16,7 @@ from plugins.train.model._base import ModelBase from plugins.train.trainer._base import TrainerBase -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class PluginLoader(): diff --git a/plugins/train/model/_base/io.py b/plugins/train/model/_base/io.py index 47f1ea80d2..c52bf53094 100644 --- a/plugins/train/model/_base/io.py +++ b/plugins/train/model/_base/io.py @@ -24,7 +24,7 @@ from .model import ModelBase kmodels = tf.keras.models -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) def get_all_sub_models( diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index f46b08f30e..c3c448d665 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -32,7 +32,7 @@ K = tf.keras.backend -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) _CONFIG: dict[str, ConfigValueType] = {} diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index e43705a5e5..f2a9aba321 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -36,7 +36,7 @@ keras = tf.keras K = keras.backend -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) @dataclass diff --git a/plugins/train/model/dfaker.py b/plugins/train/model/dfaker.py index 784178c94c..0ad08357fd 100644 --- a/plugins/train/model/dfaker.py +++ b/plugins/train/model/dfaker.py @@ -12,7 +12,7 @@ from lib.model.nn_blocks import Conv2DOutput, UpscaleBlock, ResidualBlock from .original import Model as OriginalModel -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class Model(OriginalModel): diff --git a/plugins/train/model/dfl_sae.py b/plugins/train/model/dfl_sae.py index 6daf91ecc2..0c54e0031d 100644 --- a/plugins/train/model/dfl_sae.py +++ b/plugins/train/model/dfl_sae.py @@ -14,7 +14,7 @@ from ._base import ModelBase -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class Model(ModelBase): diff --git a/plugins/train/model/dlight.py b/plugins/train/model/dlight.py index 48e39d51ad..154b090466 100644 --- a/plugins/train/model/dlight.py +++ b/plugins/train/model/dlight.py @@ -22,7 +22,7 @@ from ._base import ModelBase -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class Model(ModelBase): diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index aaabff3b62..28c0f31a69 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -20,7 +20,7 @@ from ._base import ModelBase, get_all_sub_models -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) K = tf.keras.backend kapp = tf.keras.applications diff --git a/plugins/train/model/realface.py b/plugins/train/model/realface.py index 2c7e22d190..30d0d7f8f1 100644 --- a/plugins/train/model/realface.py +++ b/plugins/train/model/realface.py @@ -18,7 +18,7 @@ from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock from ._base import ModelBase -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class Model(ModelBase): diff --git a/scripts/convert.py b/scripts/convert.py index 38a5eebb72..a5b11da59a 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -35,7 +35,7 @@ from lib.queue_manager import EventQueue -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) @dataclass diff --git a/scripts/extract.py b/scripts/extract.py index 44a7434bf6..2b16aaa4b9 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -24,7 +24,7 @@ from lib.align.alignments import PNGHeaderAlignmentsDict # tqdm.monitor_interval = 0 # workaround for TqdmSynchronisationWarning # TODO? -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class Extract(): # pylint:disable=too-few-public-methods diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 0b4ea078f8..0663a58349 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -27,7 +27,7 @@ from lib.align import AlignedFace from plugins.extract.pipeline import ExtractMedia -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) def finalize(images_found: int, num_faces_detected: int, verify_output: bool) -> None: diff --git a/scripts/gui.py b/scripts/gui.py index 63f8dbf59d..4417458ea9 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -10,7 +10,7 @@ get_images, initialize_images, initialize_config, LastSession, MainMenuBar, preview_trigger, ProcessWrapper, StatusBar) -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class FaceswapGui(tk.Tk): diff --git a/setup.py b/setup.py index 0b6c223291..3f4c29cb7e 100755 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ from lib.logger import log_setup -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) backend_type: T.TypeAlias = T.Literal['nvidia', 'apple_silicon', 'directml', 'cpu', 'rocm', "all"] _INSTALL_FAILED = False diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index 6ffc3f84a7..9e020730fc 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -15,7 +15,7 @@ from .jobs_frames import Draw, Extract # noqa pylint: disable=unused-import -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class Alignments(): # pylint:disable=too-few-public-methods diff --git a/tools/alignments/media.py b/tools/alignments/media.py index ee6bcc21d2..9fafdf0a21 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -24,7 +24,7 @@ import numpy as np from lib.align.alignments import AlignmentFileDict, PNGHeaderDict -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class AlignmentData(Alignments): diff --git a/tools/effmpeg/effmpeg.py b/tools/effmpeg/effmpeg.py index 025b7d4a31..724c555e4a 100644 --- a/tools/effmpeg/effmpeg.py +++ b/tools/effmpeg/effmpeg.py @@ -19,7 +19,7 @@ # faceswap imports from lib.utils import _image_extensions, _video_extensions -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class DataItem(): diff --git a/tools/manual/faceviewer/frame.py b/tools/manual/faceviewer/frame.py index 3c07b364b0..f1443d9b20 100644 --- a/tools/manual/faceviewer/frame.py +++ b/tools/manual/faceviewer/frame.py @@ -17,7 +17,7 @@ from .viewport import Viewport -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) # LOCALES _LANG = gettext.translation("tools.manual", localedir="locales", fallback=True) diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index 73c9205975..0ca4818d8f 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -10,7 +10,7 @@ from lib.align import AlignedFace -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class Viewport(): diff --git a/tools/manual/frameviewer/control.py b/tools/manual/frameviewer/control.py index 54737783b3..2112ad174f 100644 --- a/tools/manual/frameviewer/control.py +++ b/tools/manual/frameviewer/control.py @@ -11,7 +11,7 @@ from lib.align import AlignedFace -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class Navigation(): diff --git a/tools/manual/frameviewer/editor/_base.py b/tools/manual/frameviewer/editor/_base.py index 46c19b5bd2..3e5bf81b5e 100644 --- a/tools/manual/frameviewer/editor/_base.py +++ b/tools/manual/frameviewer/editor/_base.py @@ -11,7 +11,7 @@ from lib.gui.control_helper import ControlPanelOption -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) # LOCALES _LANG = gettext.translation("tools.manual", localedir="locales", fallback=True) diff --git a/tools/manual/frameviewer/frame.py b/tools/manual/frameviewer/frame.py index b4495ef111..b448265c78 100644 --- a/tools/manual/frameviewer/frame.py +++ b/tools/manual/frameviewer/frame.py @@ -16,7 +16,7 @@ from .editor import (BoundingBox, ExtractBox, Landmarks, Mask, # noqa pylint:disable=unused-import Mesh, View) -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) # LOCALES _LANG = gettext.translation("tools.manual", localedir="locales", fallback=True) diff --git a/tools/manual/manual.py b/tools/manual/manual.py index 07d9f07253..f2187dbcdc 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -31,7 +31,7 @@ from lib.align.detected_face import Mask from lib.queue_manager import EventQueue -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) TypeManualExtractor = T.Literal["FAN", "cv2-dnn", "mask"] diff --git a/tools/model/model.py b/tools/model/model.py index 9bdac28556..d179d8d9ae 100644 --- a/tools/model/model.py +++ b/tools/model/model.py @@ -20,7 +20,7 @@ if T.TYPE_CHECKING: import argparse -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) class Model(): # pylint:disable=too-few-public-methods diff --git a/tools/preview/preview.py b/tools/preview/preview.py index 945799f90f..caef0e7353 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -34,7 +34,7 @@ from lib.queue_manager import EventQueue from .control_panels import BusyProgressBar -logger = logging.getLogger(__name__) # pylint: disable=invalid-name +logger = logging.getLogger(__name__) # LOCALES _LANG = gettext.translation("tools.preview", localedir="locales", fallback=True) From 983901466fe8d77e4910d2392ea1f545aeef5dcf Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 3 Apr 2024 14:37:48 +0100 Subject: [PATCH 888/981] linting: Reduce Class min-public-methods and fix spacing --- lib/align/detected_face.py | 2 +- lib/cli/actions.py | 23 ++++++++----------- lib/cli/args.py | 2 +- lib/cli/launcher.py | 6 ++--- lib/convert.py | 2 +- lib/gpu_stats/directml.py | 4 ++-- lib/gpu_stats/nvidia.py | 2 +- lib/gui/_config.py | 2 +- lib/gui/analysis/event_reader.py | 2 +- lib/gui/analysis/stats.py | 4 ++-- lib/gui/control_helper.py | 4 ++-- lib/gui/custom_widgets.py | 18 +++++++-------- lib/gui/display.py | 4 ++-- lib/gui/display_analysis.py | 4 ++-- lib/gui/display_command.py | 6 ++--- lib/gui/display_graph.py | 18 +++++++-------- lib/gui/display_page.py | 4 ++-- lib/gui/menu.py | 8 +++---- lib/gui/popup_session.py | 4 ++-- lib/gui/project.py | 2 +- lib/gui/theme.py | 4 ++-- lib/gui/utils/config.py | 2 +- lib/gui/utils/file_handler.py | 2 +- lib/gui/utils/image.py | 2 +- lib/gui/utils/misc.py | 2 +- lib/gui/wrapper.py | 2 +- lib/image.py | 4 ++-- lib/keras_utils.py | 2 +- lib/keypress.py | 2 +- lib/model/autoclip.py | 2 +- lib/model/layers.py | 2 +- lib/model/losses/feature_loss.py | 6 ++--- lib/model/networks/simple_nets.py | 4 ++-- lib/multithreading.py | 6 ++--- lib/queue_manager.py | 4 ++-- lib/serializer.py | 4 ++-- lib/sysinfo.py | 6 ++--- lib/training/augmentation.py | 2 +- lib/training/cache.py | 2 +- lib/training/generator.py | 2 +- lib/training/lr_finder.py | 2 +- lib/training/preview_tk.py | 8 +++---- lib/utils.py | 2 +- plugins/convert/color/color_transfer.py | 18 +++++++-------- plugins/convert/color/manual_balance.py | 2 +- plugins/convert/color/seamless_clone.py | 4 ++-- plugins/convert/scaling/sharpen.py | 8 +++---- plugins/convert/writer/_base.py | 2 +- plugins/convert/writer/gif.py | 2 +- plugins/convert/writer/opencv.py | 2 +- plugins/convert/writer/patch.py | 2 +- plugins/convert/writer/pillow.py | 2 +- plugins/extract/align/cv2_dnn.py | 4 ++-- plugins/extract/detect/cv2_dnn.py | 6 ++--- plugins/extract/detect/mtcnn.py | 2 +- plugins/extract/mask/_base.py | 2 +- plugins/extract/recognition/vgg_face2.py | 2 +- plugins/train/_config.py | 2 +- plugins/train/model/_base/model.py | 6 ++--- plugins/train/model/phaze_a.py | 4 ++-- scripts/convert.py | 2 +- scripts/fsmedia.py | 4 ++-- scripts/gui.py | 4 ++-- scripts/train.py | 4 ++-- setup.py | 6 ++--- tools/alignments/media.py | 6 ++--- tools/manual/faceviewer/frame.py | 2 +- tools/manual/frameviewer/editor/_base.py | 2 +- .../manual/frameviewer/editor/bounding_box.py | 2 +- .../manual/frameviewer/editor/extract_box.py | 2 +- tools/manual/frameviewer/editor/landmarks.py | 2 +- tools/manual/frameviewer/frame.py | 4 ++-- tools/preview/control_panels.py | 4 ++-- tools/sort/sort_methods.py | 4 ++-- 74 files changed, 155 insertions(+), 158 deletions(-) diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index 5cec4bfc8f..15fbb5c393 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -916,7 +916,7 @@ def generate_mask(self, affine_matrix: np.ndarray, interpolator: int) -> None: self.add(mask, affine_matrix, interpolator) -class BlurMask(): # pylint:disable=too-few-public-methods +class BlurMask(): """ Factory class to return the correct blur object for requested blur type. Works for square images only. Currently supports Gaussian and Normalized Box Filters. diff --git a/lib/cli/actions.py b/lib/cli/actions.py index 4b89b35c3a..e3f36ae48c 100644 --- a/lib/cli/actions.py +++ b/lib/cli/actions.py @@ -12,7 +12,7 @@ # << FILE HANDLING >> -class _FullPaths(argparse.Action): # pylint: disable=too-few-public-methods +class _FullPaths(argparse.Action): """ Parent class for various file type and file path handling classes. Expands out given paths to their full absolute paths. This class should not be @@ -42,8 +42,7 @@ class DirFullPaths(_FullPaths): >>> opts=("-f", "--folder_location"), >>> action=DirFullPaths)), """ - # pylint: disable=too-few-public-methods,unnecessary-pass - pass + pass # pylint:disable=unnecessary-pass class FileFullPaths(_FullPaths): @@ -68,7 +67,6 @@ class FileFullPaths(_FullPaths): >>> action=FileFullPaths, >>> filetypes="video))" """ - # pylint: disable=too-few-public-methods def __init__(self, *args, filetypes: str | None = None, **kwargs) -> None: super().__init__(*args, **kwargs) self.filetypes = filetypes @@ -87,7 +85,7 @@ def _get_kwargs(self): return [(name, getattr(self, name)) for name in names] -class FilesFullPaths(FileFullPaths): # pylint: disable=too-few-public-methods +class FilesFullPaths(FileFullPaths): """ Adds support for a File browser to select multiple files in the GUI. This extends the standard :class:`argparse.Action` and adds an additional parameter @@ -118,7 +116,7 @@ def __init__(self, *args, filetypes: str | None = None, **kwargs) -> None: super().__init__(*args, **kwargs) -class DirOrFileFullPaths(FileFullPaths): # pylint: disable=too-few-public-methods +class DirOrFileFullPaths(FileFullPaths): """ Adds support to the GUI to launch either a file browser or a folder browser. Some inputs (for example source frames) can come from a folder of images or from a @@ -147,7 +145,7 @@ class DirOrFileFullPaths(FileFullPaths): # pylint: disable=too-few-public-metho """ -class DirOrFilesFullPaths(FileFullPaths): # pylint: disable=too-few-public-methods +class DirOrFilesFullPaths(FileFullPaths): """ Adds support to the GUI to launch either a file browser for selecting multiple files or a folder browser. @@ -213,8 +211,7 @@ class SaveFileFullPaths(FileFullPaths): >>> action=SaveFileFullPaths, >>> filetypes="video")) """ - # pylint: disable=too-few-public-methods,unnecessary-pass - pass + pass # pylint:disable=unnecessary-pass class ContextFullPaths(FileFullPaths): @@ -247,7 +244,7 @@ class ContextFullPaths(FileFullPaths): >>> filetypes="video", >>> action_option="-a")) """ - # pylint: disable=too-few-public-methods, too-many-arguments + # pylint:disable=too-many-arguments def __init__(self, *args, filetypes: str | None = None, @@ -280,7 +277,7 @@ def _get_kwargs(self) -> list[tuple[str, T.Any]]: # << GUI DISPLAY OBJECTS >> -class Radio(argparse.Action): # pylint: disable=too-few-public-methods +class Radio(argparse.Action): """ Adds support for a GUI Radio options box. This is a standard :class:`argparse.Action` (with stock parameters) which indicates to the GUI @@ -309,7 +306,7 @@ def __call__(self, parser, namespace, values, option_string=None) -> None: setattr(namespace, self.dest, values) -class MultiOption(argparse.Action): # pylint: disable=too-few-public-methods +class MultiOption(argparse.Action): """ Adds support for multiple option checkboxes in the GUI. This is a standard :class:`argparse.Action` (with stock parameters) which indicates to the GUI @@ -337,7 +334,7 @@ def __call__(self, parser, namespace, values, option_string=None) -> None: setattr(namespace, self.dest, values) -class Slider(argparse.Action): # pylint: disable=too-few-public-methods +class Slider(argparse.Action): """ Adds support for a slider in the GUI. The standard :class:`argparse.Action` is extended with the additional parameters listed below. diff --git a/lib/cli/args.py b/lib/cli/args.py index 8193acc4a8..d7a6626f4e 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -83,7 +83,7 @@ def _split_lines(self, text: str, width: int) -> list[str]: txt = f" - {txt[2:]}" output.extend(textwrap.wrap(txt, width, subsequent_indent=indent)) return output - return argparse.HelpFormatter._split_lines(self, # pylint: disable=protected-access + return argparse.HelpFormatter._split_lines(self, # pylint:disable=protected-access text, width) diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 4745c274d0..98ef321607 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) -class ScriptExecutor(): # pylint:disable=too-few-public-methods +class ScriptExecutor(): """ Loads the relevant script modules and executes the script. This class is initialized in each of the argparsers for the relevant @@ -227,11 +227,11 @@ def execute_script(self, arguments: argparse.Namespace) -> None: except FaceswapError as err: for line in str(err).splitlines(): logger.error(line) - except KeyboardInterrupt: # pylint: disable=try-except-raise + except KeyboardInterrupt: # pylint:disable=try-except-raise raise except SystemExit: pass - except Exception: # pylint: disable=broad-except + except Exception: # pylint:disable=broad-except crash_file = crash_log() logger.exception("Got Exception on main handler:") logger.critical("An unexpected crash has occurred. Crash report written to '%s'. " diff --git a/lib/convert.py b/lib/convert.py index 3b5c91b936..c96b759213 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -201,7 +201,7 @@ def process(self, in_queue: EventQueue, out_queue: EventQueue): item.inbound.filename) try: image = self._patch_image(item) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # pylint:disable=broad-except # Log error and output original frame logger.error("Failed to convert image: '%s'. Reason: %s", item.inbound.filename, str(err)) diff --git a/lib/gpu_stats/directml.py b/lib/gpu_stats/directml.py index 46932cd36c..10bb435b93 100644 --- a/lib/gpu_stats/directml.py +++ b/lib/gpu_stats/directml.py @@ -107,7 +107,7 @@ class VendorID(Enum): # STRUCTS -class StructureRepr(Structure): # pylint:disable=too-few-public-methods +class StructureRepr(Structure): """ Override the standard structure class to add a useful __repr__ for logging """ def __repr__(self) -> str: """ Output the class name and the structure contents """ @@ -155,7 +155,7 @@ class DXGIAdapterDesc1(StructureRepr): # pylint:disable=too-few-public-methods ("DedicatedSystemMemory", ctypes.c_size_t), ("SharedSystemMemory", ctypes.c_size_t), ("AdapterLuid", LUID), - ("Flags", DXGIAdapterFlag.ctype)] # type:ignore[attr-defined] # pylint: disable=no-member + ("Flags", DXGIAdapterFlag.ctype)] # type:ignore[attr-defined] # pylint:disable=no-member class DXGIQueryVideoMemoryInfo(StructureRepr): # pylint:disable=too-few-public-methods diff --git a/lib/gpu_stats/nvidia.py b/lib/gpu_stats/nvidia.py index 67038e9c6b..3347399d33 100644 --- a/lib/gpu_stats/nvidia.py +++ b/lib/gpu_stats/nvidia.py @@ -53,7 +53,7 @@ def _initialize(self) -> None: "remove and reinstall your Nvidia drivers before reporting. Original " f"Error: {str(err)}") raise FaceswapError(msg) from err - except Exception as err: # pylint: disable=broad-except + except Exception as err: # pylint:disable=broad-except msg = ("An unhandled exception occured reading from the Nvidia Machine Learning " f"Library. Original error: {str(err)}") raise FaceswapError(msg) from err diff --git a/lib/gui/_config.py b/lib/gui/_config.py index e5a034b3f6..9f87e3b775 100644 --- a/lib/gui/_config.py +++ b/lib/gui/_config.py @@ -14,7 +14,7 @@ class Config(FaceswapConfig): """ Config File for GUI """ - # pylint: disable=too-many-statements + # pylint:disable=too-many-statements def set_defaults(self): """ Set the default values for config """ logger.debug("Setting defaults") diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index 8617150236..60aa45e6e3 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -618,7 +618,7 @@ def get_timestamps(self, session_id: int | None = None) -> dict[int, np.ndarray] return retval -class _EventParser(): # pylint:disable=too-few-public-methods +class _EventParser(): """ Parses Tensorflow event and populates data to :class:`_Cache`. Parameters diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index 7c6aab3d43..b055cfa332 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -280,7 +280,7 @@ def get_loss_keys(self, session_id: int | None) -> list[str]: _SESSION = GlobalSession() -class SessionsSummary(): # pylint:disable=too-few-public-methods +class SessionsSummary(): """ Performs top level summary calculations for each session ID within the loaded or currently training Session for display in the Analysis tree view. @@ -853,7 +853,7 @@ def _calc_trend(cls, data: np.ndarray) -> np.ndarray: return trend -class _ExponentialMovingAverage(): # pylint:disable=too-few-public-methods +class _ExponentialMovingAverage(): """ Reshapes data before calculating exponential moving average, then iterates once over the rows to calculate the offset without precision issues. diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 31c8436e47..1b6571335f 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -543,7 +543,7 @@ def add_scrollbar(self): self.mainframe.bind("", self.update_scrollbar) logger.debug("Added Config Scrollbar") - def update_scrollbar(self, event): # pylint: disable=unused-argument + def update_scrollbar(self, event): # pylint:disable=unused-argument """ Update the options frame scrollbar """ self._canvas.configure(scrollregion=self._canvas.bbox("all")) @@ -908,7 +908,7 @@ class ControlBuilder(): blank_nones: bool Sets selected values to an empty string rather than None if this is true. """ - def __init__(self, parent, option, option_columns, # pylint: disable=too-many-arguments + def __init__(self, parent, option, option_columns, # pylint:disable=too-many-arguments label_width, checkbuttons_frame, style, blank_nones): logger.debug("Initializing %s: (parent: %s, option: %s, option_columns: %s, " "label_width: %s, checkbuttons_frame: %s, style: %s, blank_nones: %s)", diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index 5a39abdb8c..d18d832ed8 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) -class ContextMenu(tk.Menu): # pylint: disable=too-many-ancestors +class ContextMenu(tk.Menu): # pylint:disable=too-many-ancestors """ A Pop up menu to be triggered when right clicking on widgets that this menu has been applied to. @@ -72,7 +72,7 @@ def _select_all(self): self._widget.select_range(0, tk.END) -class RightClickMenu(tk.Menu): # pylint: disable=too-many-ancestors +class RightClickMenu(tk.Menu): # pylint:disable=too-many-ancestors """ A Pop up menu that can be bound to a right click mouse event to bring up a context menu Parameters @@ -118,7 +118,7 @@ def popup(self, event): self.tk_popup(event.x_root, event.y_root) -class ConsoleOut(ttk.Frame): # pylint: disable=too-many-ancestors +class ConsoleOut(ttk.Frame): # pylint:disable=too-many-ancestors """ The Console out section of the GUI. A Read only text box for displaying the output from stdout/stderr. @@ -195,7 +195,7 @@ def _redirect_console(self): sys.stderr = _SysOutRouter(self._console, "stderr") logger.debug("Redirected console") - def _clear(self, *args): # pylint: disable=unused-argument + def _clear(self, *args): # pylint:disable=unused-argument """ Clear the console output screen """ logger.debug("Clear console") if not self._console_clear.get(): @@ -206,7 +206,7 @@ def _clear(self, *args): # pylint: disable=unused-argument logger.debug("Cleared console") -class _ReadOnlyText(tk.Text): # pylint: disable=too-many-ancestors +class _ReadOnlyText(tk.Text): # pylint:disable=too-many-ancestors """ A read only text widget. Standard tkinter Text widgets are read/write by default. As we want to make the console @@ -417,7 +417,7 @@ def __call__(self, *args): return self.tk_call(self.orig_and_operation + args) -class StatusBar(ttk.Frame): # pylint: disable=too-many-ancestors +class StatusBar(ttk.Frame): # pylint:disable=too-many-ancestors """ Status Bar for displaying the Status Message and Progress Bar at the bottom of the GUI. Parameters @@ -725,7 +725,7 @@ def _hide(self): self._topwidget = None -class MultiOption(ttk.Checkbutton): # pylint: disable=too-many-ancestors +class MultiOption(ttk.Checkbutton): # pylint:disable=too-many-ancestors """ Similar to the standard :class:`ttk.Radio` widget, but with the ability to select multiple pre-defined options. Selected options are generated as `nargs` for the argument parser to consume. @@ -767,7 +767,7 @@ def _master_needs_update(self): logger.trace(retval) return retval - def _on_update(self, *args): # pylint: disable=unused-argument + def _on_update(self, *args): # pylint:disable=unused-argument """ Update the master variable on a check button change. The value for this checked option is added or removed from the :attr:`_master_variable` @@ -788,7 +788,7 @@ def _on_update(self, *args): # pylint: disable=unused-argument logger.trace("Setting master variable to: %s", val) self._master_variable.set(val) - def _on_master_update(self, *args): # pylint: disable=unused-argument + def _on_master_update(self, *args): # pylint:disable=unused-argument """ Update the check button on a master variable change (e.g. load .fsw file in the GUI). The value for this option is set to ``True`` or ``False`` depending on it's existence in diff --git a/lib/gui/display.py b/lib/gui/display.py index 0d2b107f93..3729e35437 100644 --- a/lib/gui/display.py +++ b/lib/gui/display.py @@ -23,7 +23,7 @@ _ = _LANG.gettext -class DisplayNotebook(ttk.Notebook): # pylint: disable=too-many-ancestors +class DisplayNotebook(ttk.Notebook): # pylint:disable=too-many-ancestors """ The tkinter Notebook that holds the display items. Parameters @@ -152,7 +152,7 @@ def _remove_tabs(self): child_object.close() # Call the OptionalDisplayPage close() method self.forget(child) - def _update_displaybook(self, *args): # pylint: disable=unused-argument + def _update_displaybook(self, *args): # pylint:disable=unused-argument """ Callback to be executed when the global tkinter variable `display` (:attr:`wrapper_var`) is updated when a Faceswap task is executed. diff --git a/lib/gui/display_analysis.py b/lib/gui/display_analysis.py index a04ec63843..9dcef89164 100644 --- a/lib/gui/display_analysis.py +++ b/lib/gui/display_analysis.py @@ -23,7 +23,7 @@ _ = _LANG.gettext -class Analysis(DisplayPage): # pylint: disable=too-many-ancestors +class Analysis(DisplayPage): # pylint:disable=too-many-ancestors """ Session Analysis Tab. The area of the GUI that holds the session summary stats for model training sessions. @@ -366,7 +366,7 @@ def _set_buttons_state(self, *args): # pylint:disable=unused-argument button.state([state]) -class StatsData(ttk.Frame): # pylint: disable=too-many-ancestors +class StatsData(ttk.Frame): # pylint:disable=too-many-ancestors """ Stats frame of analysis tab. Holds the tree-view containing the summarized session statistics in the Analysis tab. diff --git a/lib/gui/display_command.py b/lib/gui/display_command.py index ee2cd8231e..e7d59f2876 100644 --- a/lib/gui/display_command.py +++ b/lib/gui/display_command.py @@ -26,7 +26,7 @@ _ = _LANG.gettext -class PreviewExtract(DisplayOptionalPage): # pylint: disable=too-many-ancestors +class PreviewExtract(DisplayOptionalPage): # pylint:disable=too-many-ancestors """ Tab to display output preview images for extract and convert """ def __init__(self, *args, **kwargs) -> None: logger.debug(parse_class_init(locals())) @@ -80,7 +80,7 @@ def save_items(self) -> None: print(f"Saved preview to {filename}") -class PreviewTrain(DisplayOptionalPage): # pylint: disable=too-many-ancestors +class PreviewTrain(DisplayOptionalPage): # pylint:disable=too-many-ancestors """ Training preview image(s) """ def __init__(self, *args, **kwargs) -> None: logger.debug(parse_class_init(locals())) @@ -163,7 +163,7 @@ def save_items(self) -> None: self._display.save(location) -class GraphDisplay(DisplayOptionalPage): # pylint: disable=too-many-ancestors +class GraphDisplay(DisplayOptionalPage): # pylint:disable=too-many-ancestors """ The Graph Tab of the Display section """ def __init__(self, parent: ttk.Notebook, diff --git a/lib/gui/display_graph.py b/lib/gui/display_graph.py index 5ea61cad1d..c5f1304a81 100755 --- a/lib/gui/display_graph.py +++ b/lib/gui/display_graph.py @@ -29,7 +29,7 @@ logger: logging.Logger = logging.getLogger(__name__) -class GraphBase(ttk.Frame): # pylint: disable=too-many-ancestors +class GraphBase(ttk.Frame): # pylint:disable=too-many-ancestors """ Base class for matplotlib line graphs. Parameters @@ -321,7 +321,7 @@ def clear(self) -> None: del self._fig -class TrainingGraph(GraphBase): # pylint: disable=too-many-ancestors +class TrainingGraph(GraphBase): # pylint:disable=too-many-ancestors """ Live graph to be displayed during training. Parameters @@ -352,7 +352,7 @@ def build(self) -> None: self._plotcanvas.draw() logger.debug("Built training graph") - def refresh(self, *args) -> None: # pylint: disable=unused-argument + def refresh(self, *args) -> None: # pylint:disable=unused-argument """ Read the latest loss data and apply to current graph """ refresh_var = T.cast(tk.BooleanVar, get_config().tk_vars.refresh_graph) if not refresh_var.get() and self._thread is None: @@ -406,15 +406,15 @@ def save_fig(self, location: str) -> None: def _resize_fig(self) -> None: """ Resize the figure to the current canvas size. """ - class Event(): # pylint: disable=too-few-public-methods + class Event(): # pylint:disable=too-few-public-methods """ Event class that needs to be passed to plotcanvas.resize """ - pass # pylint: disable=unnecessary-pass + pass # pylint:disable=unnecessary-pass setattr(Event, "width", self.winfo_width()) setattr(Event, "height", self.winfo_height()) - self._plotcanvas.resize(Event) # pylint: disable=no-value-for-parameter + self._plotcanvas.resize(Event) # pylint:disable=no-value-for-parameter -class SessionGraph(GraphBase): # pylint: disable=too-many-ancestors +class SessionGraph(GraphBase): # pylint:disable=too-many-ancestors """ Session Graph for session pop-up. Parameters @@ -476,7 +476,7 @@ def set_yscale_type(self, scale: str) -> None: logger.debug("Updated scale type") -class NavigationToolbar(NavigationToolbar2Tk): # pylint: disable=too-many-ancestors +class NavigationToolbar(NavigationToolbar2Tk): # pylint:disable=too-many-ancestors """ Overrides the default Navigation Toolbar to provide only the buttons we require and to layout the items in a consistent manner with the rest of the GUI for the Analysis Session Graph pop up Window. @@ -493,7 +493,7 @@ class NavigationToolbar(NavigationToolbar2Tk): # pylint: disable=too-many-ances toolitems = [t for t in NavigationToolbar2Tk.toolitems if t[0] in ("Home", "Pan", "Zoom", "Save")] - def __init__(self, # pylint: disable=super-init-not-called + def __init__(self, # pylint:disable=super-init-not-called canvas: FigureCanvasTkAgg, window: ttk.Frame, *, diff --git a/lib/gui/display_page.py b/lib/gui/display_page.py index e6bc3eaef0..5602c22f52 100644 --- a/lib/gui/display_page.py +++ b/lib/gui/display_page.py @@ -16,7 +16,7 @@ _ = _LANG.gettext -class DisplayPage(ttk.Frame): # pylint: disable=too-many-ancestors +class DisplayPage(ttk.Frame): # pylint:disable=too-many-ancestors """ Parent frame holder for each tab. Defines uniform structure for each tab to inherit from """ def __init__(self, parent, tab_name, helptext): @@ -159,7 +159,7 @@ def subnotebook_page_from_id(self, tab_id): return self.subnotebook.children[tab_name] -class DisplayOptionalPage(DisplayPage): # pylint: disable=too-many-ancestors +class DisplayOptionalPage(DisplayPage): # pylint:disable=too-many-ancestors """ Parent Context Sensitive Display Tab """ def __init__(self, parent, tab_name, helptext, wait_time, command=None): diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 7b83af56e6..460e08fd58 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -75,7 +75,7 @@ def __init__(self, parent: MainMenuBar) -> None: def _build(self) -> None: """ Add the settings menu to the menu bar """ - # pylint: disable=cell-var-from-loop + # pylint:disable=cell-var-from-loop logger.debug("Building settings menu") self.add_command(label=_("Configure Settings..."), underline=0, @@ -464,7 +464,7 @@ def _switch_branch(cls, branch: str) -> None: def _build_recources_menu(self) -> None: """ Build resources menu """ - # pylint: disable=cell-var-from-loop + # pylint:disable=cell-var-from-loop logger.debug("Building Resources Files menu") for resource in _RESOURCES: self.recources_menu.add_command( @@ -478,7 +478,7 @@ def _clear_console(cls) -> None: get_config().tk_vars.console_clear.set(True) -class TaskBar(ttk.Frame): # pylint: disable=too-many-ancestors +class TaskBar(ttk.Frame): # pylint:disable=too-many-ancestors """ Task bar buttons Parameters @@ -597,7 +597,7 @@ def _task_btns(self) -> None: def _settings_btns(self) -> None: """ Place the settings buttons """ - # pylint: disable=cell-var-from-loop + # pylint:disable=cell-var-from-loop frame = ttk.Frame(self._btn_frame) frame.pack(side=tk.LEFT, anchor=tk.W, expand=False, padx=2) for name in ("extract", "train", "convert"): diff --git a/lib/gui/popup_session.py b/lib/gui/popup_session.py index f145e37e52..6ba9e2b4a6 100644 --- a/lib/gui/popup_session.py +++ b/lib/gui/popup_session.py @@ -355,7 +355,7 @@ def _option_button_save(self) -> None: csvout.writerow(fieldnames) csvout.writerows(zip(*[save_data[key] for key in fieldnames])) - def _option_button_reload(self, *args) -> None: # pylint: disable=unused-argument + def _option_button_reload(self, *args) -> None: # pylint:disable=unused-argument """ Action for reset button press and checkbox changes. Parameters @@ -376,7 +376,7 @@ def _option_button_reload(self, *args) -> None: # pylint: disable=unused-argume self._vars.scale.get()) logger.debug("Refreshed Graph") - def _graph_scale(self, *args) -> None: # pylint: disable=unused-argument + def _graph_scale(self, *args) -> None: # pylint:disable=unused-argument """ Action for changing graph scale. Parameters diff --git a/lib/gui/project.py b/lib/gui/project.py index 4fd61de074..1ff6c6b2dc 100644 --- a/lib/gui/project.py +++ b/lib/gui/project.py @@ -11,7 +11,7 @@ logger = logging.getLogger(__name__) -class _GuiSession(): # pylint:disable=too-few-public-methods +class _GuiSession(): """ Parent class for GUI Session Handlers. Parameters diff --git a/lib/gui/theme.py b/lib/gui/theme.py index 993a906fd6..1ea30f7e18 100644 --- a/lib/gui/theme.py +++ b/lib/gui/theme.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) -class Style(): # pylint:disable=too-few-public-methods +class Style(): """ Set the overarching theme and customize widgets. Parameters @@ -439,7 +439,7 @@ def slider(self, key, control_color, active_color, trough_color): troughcolor=trough_color) -class _TkImage(): # pylint:disable=too-few-public-methods +class _TkImage(): """ Create a tk image for a given pattern and shape. """ def __init__(self): diff --git a/lib/gui/utils/config.py b/lib/gui/utils/config.py index 57bd42aa33..a0c2aa72f0 100644 --- a/lib/gui/utils/config.py +++ b/lib/gui/utils/config.py @@ -49,7 +49,7 @@ def initialize_config(root: tk.Tk, ``None`` if the config has already been initialized otherwise the global configuration options """ - global _CONFIG # pylint: disable=global-statement + global _CONFIG # pylint:disable=global-statement if _CONFIG is not None: return None logger.debug("Initializing config: (root: %s, cli_opts: %s, " diff --git a/lib/gui/utils/file_handler.py b/lib/gui/utils/file_handler.py index e1e687e1c6..4364eece3e 100644 --- a/lib/gui/utils/file_handler.py +++ b/lib/gui/utils/file_handler.py @@ -339,7 +339,7 @@ def _save_filename(self) -> str: return filedialog.asksaveasfilename(**self._kwargs) # type: ignore @staticmethod - def _nothing() -> None: # pylint: disable=useless-return + def _nothing() -> None: # pylint:disable=useless-return """ Method that does nothing, used for disabling open/save pop up. """ logger.debug("Popping Nothing browser") return diff --git a/lib/gui/utils/image.py b/lib/gui/utils/image.py index 592ee98f64..816ba19257 100644 --- a/lib/gui/utils/image.py +++ b/lib/gui/utils/image.py @@ -28,7 +28,7 @@ def initialize_images() -> None: This should only be called once on first GUI startup. Future access to :class:`Images` handler should only be executed through :func:`get_images`. """ - global _IMAGES # pylint: disable=global-statement + global _IMAGES # pylint:disable=global-statement if _IMAGES is not None: return logger.debug("Initializing images") diff --git a/lib/gui/utils/misc.py b/lib/gui/utils/misc.py index 58a4befa98..4d60c80214 100644 --- a/lib/gui/utils/misc.py +++ b/lib/gui/utils/misc.py @@ -71,7 +71,7 @@ def run(self) -> None: if self._target is not None: retval = self._target(*self._args, **self._kwargs) self._queue.put(retval) - except Exception: # pylint: disable=broad-except + except Exception: # pylint:disable=broad-except self.err = T.cast(tuple[type[BaseException], BaseException, "TracebackType"], sys.exc_info()) assert self.err is not None diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index 84fe5459e5..88ee11f646 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -18,7 +18,7 @@ from .utils import get_config, get_images, LongRunningTask, preview_trigger if os.name == "nt": - import win32console # pylint: disable=import-error + import win32console # pylint:disable=import-error logger = logging.getLogger(__name__) diff --git a/lib/image.py b/lib/image.py index 96685e6cb4..d351b1a23e 100644 --- a/lib/image.py +++ b/lib/image.py @@ -159,7 +159,7 @@ def _initialize(self, index=0): # noqa:C901 correct frame for all videos. Navigating to the previous keyframe then discarding frames until the correct frame is reached appears to work well. """ - # pylint: disable-all + # pylint:disable-all if self._read_gen is not None: self._read_gen.close() @@ -1567,7 +1567,7 @@ def _save(self, else: cv2.imwrite(filename, image) logger.trace("Saved image: '%s'", filename) # type:ignore - except Exception as err: # pylint: disable=broad-except + except Exception as err: # pylint:disable=broad-except logger.error("Failed to save image '%s'. Original Error: %s", filename, str(err)) del image del filename diff --git a/lib/keras_utils.py b/lib/keras_utils.py index 9f27898620..af47a3e466 100644 --- a/lib/keras_utils.py +++ b/lib/keras_utils.py @@ -66,7 +66,7 @@ def replicate_pad(image: Tensor, padding: int) -> Tensor: return padded -class ColorSpaceConvert(): # pylint:disable=too-few-public-methods +class ColorSpaceConvert(): """ Transforms inputs between different color spaces on the GPU Notes diff --git a/lib/keypress.py b/lib/keypress.py index 98d1872005..439d1d2253 100644 --- a/lib/keypress.py +++ b/lib/keypress.py @@ -21,7 +21,7 @@ # Windows if os.name == "nt": - import msvcrt # pylint: disable=import-error + import msvcrt # pylint:disable=import-error # Posix (Linux, OS X) else: diff --git a/lib/model/autoclip.py b/lib/model/autoclip.py index a9ccfe888d..826959d66b 100644 --- a/lib/model/autoclip.py +++ b/lib/model/autoclip.py @@ -3,7 +3,7 @@ import tensorflow as tf -class AutoClipper(): # pylint:disable=too-few-public-methods +class AutoClipper(): """ AutoClip: Adaptive Gradient Clipping for Source Separation Networks Parameters diff --git a/lib/model/layers.py b/lib/model/layers.py index 6569625877..4f2c9824c6 100644 --- a/lib/model/layers.py +++ b/lib/model/layers.py @@ -617,7 +617,7 @@ def build(self, input_shape: tuple[int, ...]) -> None: Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to reference for weight shape computations. """ - pass # pylint: disable=unnecessary-pass + pass # pylint:disable=unnecessary-pass def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: """This is where the layer's logic lives. diff --git a/lib/model/losses/feature_loss.py b/lib/model/losses/feature_loss.py index 83ba174c28..a23060f3e5 100644 --- a/lib/model/losses/feature_loss.py +++ b/lib/model/losses/feature_loss.py @@ -48,7 +48,7 @@ class NetInfo: outputs: list[Layer] = field(default_factory=list) -class _LPIPSTrunkNet(): # pylint:disable=too-few-public-methods +class _LPIPSTrunkNet(): """ Trunk neural network loader for LPIPS Loss function. Parameters @@ -145,7 +145,7 @@ def __call__(self) -> Model: return model -class _LPIPSLinearNet(_LPIPSTrunkNet): # pylint:disable=too-few-public-methods +class _LPIPSLinearNet(_LPIPSTrunkNet): """ The Linear Network to be applied to the difference between the true and predicted outputs of the trunk network. @@ -232,7 +232,7 @@ def __call__(self) -> Model: return model -class LPIPSLoss(): # pylint:disable=too-few-public-methods +class LPIPSLoss(): """ LPIPS Loss Function. A perceptual loss function that uses linear outputs from pretrained CNNs feature layers. diff --git a/lib/model/networks/simple_nets.py b/lib/model/networks/simple_nets.py index 4fa8294d15..727161bcd8 100644 --- a/lib/model/networks/simple_nets.py +++ b/lib/model/networks/simple_nets.py @@ -40,7 +40,7 @@ def __init__(self, logger.debug("Initialized: %s", self.__class__.__name__) -class AlexNet(_net): # pylint:disable=too-few-public-methods +class AlexNet(_net): """ AlexNet ported from torchvision version. Notes @@ -136,7 +136,7 @@ def __call__(self) -> tf.keras.models.Model: return Model(inputs=inputs, outputs=[var_x]) -class SqueezeNet(_net): # pylint:disable=too-few-public-methods +class SqueezeNet(_net): """ SqueezeNet ported from torchvision version. Notes diff --git a/lib/multithreading.py b/lib/multithreading.py index 73cdc983ee..a2c4300d44 100644 --- a/lib/multithreading.py +++ b/lib/multithreading.py @@ -98,7 +98,7 @@ def run(self) -> None: try: if self._target is not None: self._target(*self._args, **self._kwargs) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # pylint:disable=broad-except self.err = sys.exc_info() logger.debug("Error in thread (%s): %s", self._name, str(err)) finally: @@ -216,11 +216,11 @@ def join(self) -> None: """ logger.debug("Joining Threads: '%s'", self._name) for thread in self._threads: - logger.debug("Joining Thread: '%s'", thread._name) # pylint: disable=protected-access + logger.debug("Joining Thread: '%s'", thread._name) # pylint:disable=protected-access thread.join() if thread.err: logger.error("Caught exception in thread: '%s'", - thread._name) # pylint: disable=protected-access + thread._name) # pylint:disable=protected-access raise thread.err[1].with_traceback(thread.err[2]) del self._threads self._threads = [] diff --git a/lib/queue_manager.py b/lib/queue_manager.py index 6848636bd1..9fd5122aa4 100644 --- a/lib/queue_manager.py +++ b/lib/queue_manager.py @@ -7,7 +7,7 @@ import logging import threading -from queue import Queue, Empty as QueueEmpty # pylint: disable=unused-import; # noqa +from queue import Queue, Empty as QueueEmpty # pylint:disable=unused-import; # noqa from time import sleep logger = logging.getLogger(__name__) @@ -174,4 +174,4 @@ def _debug_queue_sizes(self, update_interval) -> None: sleep(update_interval) -queue_manager = _QueueManager() # pylint: disable=invalid-name +queue_manager = _QueueManager() # pylint:disable=invalid-name diff --git a/lib/serializer.py b/lib/serializer.py index a468a4401d..ab48ec129f 100644 --- a/lib/serializer.py +++ b/lib/serializer.py @@ -252,13 +252,13 @@ def __init__(self): def _marshal(self, data): """ Pickle and compress data """ - data = self._child._marshal(data) # pylint: disable=protected-access + data = self._child._marshal(data) # pylint:disable=protected-access return zlib.compress(data) def _unmarshal(self, data): """ Decompress and unpicke data """ data = zlib.decompress(data) - return self._child._unmarshal(data) # pylint: disable=protected-access + return self._child._unmarshal(data) # pylint:disable=protected-access def get_serializer(serializer): diff --git a/lib/sysinfo.py b/lib/sysinfo.py index fa0e2f984a..7eda070361 100644 --- a/lib/sysinfo.py +++ b/lib/sysinfo.py @@ -17,7 +17,7 @@ from setup import CudaCheck -class _SysInfo(): # pylint:disable=too-few-public-methods +class _SysInfo(): """ Obtain information about the System, Python and GPU """ def __init__(self) -> None: self._state_file = _State().state_file @@ -251,7 +251,7 @@ def get_sysinfo() -> str: """ try: retval = _SysInfo().full_info() - except Exception as err: # pylint: disable=broad-except + except Exception as err: # pylint:disable=broad-except retval = f"Exception occured trying to retrieve sysinfo: {str(err)}" raise return retval @@ -420,4 +420,4 @@ def _get_state_file(self) -> str: return retval -sysinfo = get_sysinfo() # pylint: disable=invalid-name +sysinfo = get_sysinfo() # pylint:disable=invalid-name diff --git a/lib/training/augmentation.py b/lib/training/augmentation.py index 81f56e8fed..f9edf2a558 100644 --- a/lib/training/augmentation.py +++ b/lib/training/augmentation.py @@ -200,7 +200,7 @@ def _random_clahe(self, batch: np.ndarray) -> None: grid_sizes = (grid_bases * (base_contrast // 2)) + base_contrast logger.trace("Adjusting Contrast. Grid Sizes: %s", grid_sizes) # type: ignore - clahes = [cv2.createCLAHE(clipLimit=2.0, # pylint: disable=no-member + clahes = [cv2.createCLAHE(clipLimit=2.0, # pylint:disable=no-member tileGridSize=(grid_size, grid_size)) for grid_size in grid_sizes] diff --git a/lib/training/cache.py b/lib/training/cache.py index a2fa1d28d4..9813199949 100644 --- a/lib/training/cache.py +++ b/lib/training/cache.py @@ -475,7 +475,7 @@ def _get_localized_mask(self, return mask -class RingBuffer(): # pylint: disable=too-few-public-methods +class RingBuffer(): """ Rolling buffer for holding training/preview batches Parameters diff --git a/lib/training/generator.py b/lib/training/generator.py index 455e96f11e..538941adf0 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -390,7 +390,7 @@ def _to_float32(self, in_array: np.ndarray) -> np.ndarray: casting="unsafe") -class TrainingDataGenerator(DataGenerator): # pylint:disable=too-few-public-methods +class TrainingDataGenerator(DataGenerator): """ A Training Data Generator for compiling data for feeding to a model. This class is called from :mod:`plugins.train.trainer._base` and launches a background diff --git a/lib/training/lr_finder.py b/lib/training/lr_finder.py index 62e2eff5c3..d7c3298c69 100644 --- a/lib/training/lr_finder.py +++ b/lib/training/lr_finder.py @@ -32,7 +32,7 @@ class LRStrength(Enum): EXTREME = 2.5 -class LearningRateFinder: # pylint:disable=too-few-public-methods +class LearningRateFinder: """ Learning Rate Finder Parameters diff --git a/lib/training/preview_tk.py b/lib/training/preview_tk.py index a22fd3c0b9..633ead57e6 100644 --- a/lib/training/preview_tk.py +++ b/lib/training/preview_tk.py @@ -133,7 +133,7 @@ def _add_scale_combo(self) -> ttk.Combobox: logger.debug("Added scale combo: '%s'", scale) return scale - def _clear_combo_focus(self, *args) -> None: # pylint: disable=unused-argument + def _clear_combo_focus(self, *args) -> None: # pylint:disable=unused-argument """ Remove the highlighting and stealing of focus that the combobox annoyingly implements. """ logger.debug("Clearing scale combo focus") @@ -316,7 +316,7 @@ def _configure_scrollbars(self, frame: tk.Frame) -> None: self.configure(xscrollcommand=x_scrollbar.set, yscrollcommand=y_scrollbar.set) logger.debug("Configured scrollbars. x: '%s', y: '%s'", x_scrollbar, y_scrollbar) - def _resize(self, event: tk.Event) -> None: # pylint: disable=unused-argument + def _resize(self, event: tk.Event) -> None: # pylint:disable=unused-argument """ Place the image in center of canvas on resize event and move to top left Parameters @@ -518,7 +518,7 @@ def save_preview(self, *args) -> None: self._save_var.set(False) -class _Bindings(): # pylint: disable=too-few-public-methods +class _Bindings(): # pylint:disable=too-few-public-methods """ Handle Mouse and Keyboard bindings for the canvas. Parameters @@ -654,7 +654,7 @@ def _set_key_bindings(self, is_standalone: bool) -> None: logger.debug("Bound key events") -class PreviewTk(PreviewBase): # pylint:disable=too-few-public-methods +class PreviewTk(PreviewBase): """ Holds a preview window for displaying the pop out preview. Parameters diff --git a/lib/utils.py b/lib/utils.py index a81000e681..d4018b5986 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -506,7 +506,7 @@ class FaceswapError(Exception): pass # pylint:disable=unnecessary-pass -class GetModel(): # pylint:disable=too-few-public-methods +class GetModel(): """ Check for models in the cache path. If available, return the path, if not available, get, unzip and install model diff --git a/plugins/convert/color/color_transfer.py b/plugins/convert/color/color_transfer.py index 2425d9bfb5..4b8080a433 100644 --- a/plugins/convert/color/color_transfer.py +++ b/plugins/convert/color/color_transfer.py @@ -70,12 +70,12 @@ def process(self, old_face, new_face, raw_mask): # convert the images from the RGB to L*ab* color space, being # sure to utilizing the floating point data type (note: OpenCV # expects floats to be 32-bit, so use that instead of 64-bit) - source = cv2.cvtColor( # pylint: disable=no-member + source = cv2.cvtColor( # pylint:disable=no-member np.rint(old_face * raw_mask * 255.0).astype("uint8"), - cv2.COLOR_BGR2LAB).astype("float32") # pylint: disable=no-member - target = cv2.cvtColor( # pylint: disable=no-member + cv2.COLOR_BGR2LAB).astype("float32") # pylint:disable=no-member + target = cv2.cvtColor( # pylint:disable=no-member np.rint(new_face * raw_mask * 255.0).astype("uint8"), - cv2.COLOR_BGR2LAB).astype("float32") # pylint: disable=no-member + cv2.COLOR_BGR2LAB).astype("float32") # pylint:disable=no-member # compute color statistics for the source and target images (l_mean_src, l_std_src, a_mean_src, a_std_src, @@ -85,7 +85,7 @@ def process(self, old_face, new_face, raw_mask): b_mean_tar, b_std_tar) = self.image_stats(target) # subtract the means from the target image - (light, col_a, col_b) = cv2.split(target) # pylint: disable=no-member + (light, col_a, col_b) = cv2.split(target) # pylint:disable=no-member light -= l_mean_tar col_a -= a_mean_tar col_b -= b_mean_tar @@ -115,10 +115,10 @@ def process(self, old_face, new_face, raw_mask): # merge the channels together and convert back to the RGB color # space, being sure to utilize the 8-bit unsigned integer data # type - transfer = cv2.merge([light, col_a, col_b]) # pylint: disable=no-member - transfer = cv2.cvtColor( # pylint: disable=no-member + transfer = cv2.merge([light, col_a, col_b]) # pylint:disable=no-member + transfer = cv2.cvtColor( # pylint:disable=no-member transfer.astype("uint8"), - cv2.COLOR_LAB2BGR).astype("float32") / 255.0 # pylint: disable=no-member + cv2.COLOR_LAB2BGR).astype("float32") / 255.0 # pylint:disable=no-member background = new_face * (1 - raw_mask) merged = transfer + background # return the color transferred image @@ -139,7 +139,7 @@ def image_stats(image): channels, respectively """ # compute the mean and standard deviation of each channel - (light, col_a, col_b) = cv2.split(image) # pylint: disable=no-member + (light, col_a, col_b) = cv2.split(image) # pylint:disable=no-member (l_mean, l_std) = (light.mean(), light.std()) (a_mean, a_std) = (col_a.mean(), col_a.std()) (b_mean, b_std) = (col_b.mean(), col_b.std()) diff --git a/plugins/convert/color/manual_balance.py b/plugins/convert/color/manual_balance.py index dfd0ceb199..7dc6950bb6 100644 --- a/plugins/convert/color/manual_balance.py +++ b/plugins/convert/color/manual_balance.py @@ -44,6 +44,6 @@ def convert_colorspace(self, new_face, to_bgr=False): mode = self.config["colorspace"].lower() colorspace = "YCrCb" if mode == "ycrcb" else mode.upper() conversion = f"{colorspace}2BGR" if to_bgr else f"BGR2{colorspace}" - image = cv2.cvtColor(new_face.astype("uint8"), # pylint: disable=no-member + image = cv2.cvtColor(new_face.astype("uint8"), # pylint:disable=no-member getattr(cv2, f"COLOR_{conversion}")).astype("float32") / 255.0 return image diff --git a/plugins/convert/color/seamless_clone.py b/plugins/convert/color/seamless_clone.py index 09e8bc73de..dc2f1fe21d 100644 --- a/plugins/convert/color/seamless_clone.py +++ b/plugins/convert/color/seamless_clone.py @@ -34,11 +34,11 @@ def process(self, old_face, new_face, raw_mask): ((height, height), (width, width), (0, 0)), 'constant')).astype("uint8") - blended = cv2.seamlessClone(insertion, # pylint: disable=no-member + blended = cv2.seamlessClone(insertion, # pylint:disable=no-member prior, insertion_mask, (x_center, y_center), - cv2.NORMAL_CLONE) # pylint: disable=no-member + cv2.NORMAL_CLONE) # pylint:disable=no-member blended = blended[height:-height, width:-width] return blended.astype("float32") / 255.0 diff --git a/plugins/convert/scaling/sharpen.py b/plugins/convert/scaling/sharpen.py index 1ecca3faad..0179de9fd6 100644 --- a/plugins/convert/scaling/sharpen.py +++ b/plugins/convert/scaling/sharpen.py @@ -34,15 +34,15 @@ def box(new_face, kernel_center, amount): kernel[center, center] = 1.0 box_filter = np.ones(kernel_size, dtype="float32") / kernel_size[0]**2 kernel = kernel + (kernel - box_filter) * amount - new_face = cv2.filter2D(new_face, -1, kernel) # pylint: disable=no-member + new_face = cv2.filter2D(new_face, -1, kernel) # pylint:disable=no-member return new_face @staticmethod def gaussian(new_face, kernel_center, amount): """ Sharpen using gaussian filter """ kernel_size = kernel_center[0] - blur = cv2.GaussianBlur(new_face, kernel_size, 0) # pylint: disable=no-member - new_face = cv2.addWeighted(new_face, # pylint: disable=no-member + blur = cv2.GaussianBlur(new_face, kernel_size, 0) # pylint:disable=no-member + new_face = cv2.addWeighted(new_face, # pylint:disable=no-member 1.0 + (0.5 * amount), blur, -(0.5 * amount), @@ -53,7 +53,7 @@ def unsharp_mask(self, new_face, kernel_center, amount): """ Sharpen using unsharp mask """ kernel_size = kernel_center[0] threshold = self.config["threshold"] / 255.0 - blur = cv2.GaussianBlur(new_face, kernel_size, 0) # pylint: disable=no-member + blur = cv2.GaussianBlur(new_face, kernel_size, 0) # pylint:disable=no-member low_contrast_mask = (abs(new_face - blur) < threshold).astype("float32") sharpened = (new_face * (1.0 + amount)) + (blur * -amount) new_face = (new_face * (1.0 - low_contrast_mask)) + (sharpened * low_contrast_mask) diff --git a/plugins/convert/writer/_base.py b/plugins/convert/writer/_base.py index 33389f7989..a67ebee28e 100644 --- a/plugins/convert/writer/_base.py +++ b/plugins/convert/writer/_base.py @@ -166,7 +166,7 @@ def write(self, filename: str, image: T.Any) -> None: """ raise NotImplementedError - def pre_encode(self, image: np.ndarray, **kwargs) -> T.Any: # pylint: disable=unused-argument + def pre_encode(self, image: np.ndarray, **kwargs) -> T.Any: # pylint:disable=unused-argument """ Some writer plugins support the pre-encoding of images prior to saving out. As patching is done in multiple threads, but writing is done in a single thread, it can speed up the process to do any pre-encoding as part of the converter process. diff --git a/plugins/convert/writer/gif.py b/plugins/convert/writer/gif.py index eb5e0d2752..7a75ec93f3 100644 --- a/plugins/convert/writer/gif.py +++ b/plugins/convert/writer/gif.py @@ -82,7 +82,7 @@ def write(self, filename: str, image) -> None: self._set_dimensions(image.shape[:2]) self._writer = self._get_writer() if (image.shape[1], image.shape[0]) != self._output_dimensions: - image = cv2.resize(image, self._output_dimensions) # pylint: disable=no-member + image = cv2.resize(image, self._output_dimensions) # pylint:disable=no-member self.cache_frame(filename, image) self._save_from_cache() diff --git a/plugins/convert/writer/opencv.py b/plugins/convert/writer/opencv.py index caf5287eb9..17b025bfd9 100644 --- a/plugins/convert/writer/opencv.py +++ b/plugins/convert/writer/opencv.py @@ -72,7 +72,7 @@ def write(self, filename: str, image: list[bytes]) -> None: try: with open(fname, "wb") as outfile: outfile.write(img) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # pylint:disable=broad-except logger.error("Failed to save image '%s'. Original Error: %s", filename, err) def pre_encode(self, image: np.ndarray, **kwargs) -> list[bytes]: diff --git a/plugins/convert/writer/patch.py b/plugins/convert/writer/patch.py index da07279340..5f569677f5 100644 --- a/plugins/convert/writer/patch.py +++ b/plugins/convert/writer/patch.py @@ -142,7 +142,7 @@ def write(self, filename: str, image: list[list[bytes]]) -> None: try: with open(fname, "wb") as outfile: outfile.write(img) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # pylint:disable=broad-except logger.error("Failed to save image '%s'. Original Error: %s", filename, err) if not self.config["json_output"]: continue diff --git a/plugins/convert/writer/pillow.py b/plugins/convert/writer/pillow.py index 751e5675a9..a0bf113f62 100644 --- a/plugins/convert/writer/pillow.py +++ b/plugins/convert/writer/pillow.py @@ -75,7 +75,7 @@ def write(self, filename: str, image: list[BytesIO]) -> None: for fname, img in zip(filenames, image): with open(fname, "wb") as outfile: outfile.write(img.read()) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # pylint:disable=broad-except logger.error("Failed to save image '%s'. Original Error: %s", filename, err) def pre_encode(self, image: np.ndarray, **kwargs) -> list[BytesIO]: diff --git a/plugins/extract/align/cv2_dnn.py b/plugins/extract/align/cv2_dnn.py index 44c41fb62a..0b9bc16824 100644 --- a/plugins/extract/align/cv2_dnn.py +++ b/plugins/extract/align/cv2_dnn.py @@ -56,8 +56,8 @@ def __init__(self, **kwargs) -> None: def init_model(self) -> None: """ Initialize CV2 DNN Detector Model""" - self.model = cv2.dnn.readNetFromTensorflow(self.model_path) # pylint: disable=no-member - self.model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) # pylint: disable=no-member + self.model = cv2.dnn.readNetFromTensorflow(self.model_path) # pylint:disable=no-member + self.model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) # pylint:disable=no-member def faces_to_feed(self, faces: np.ndarray) -> np.ndarray: """ Convert a batch of face images from UINT8 (0-255) to fp32 (0.0-255.0) diff --git a/plugins/extract/detect/cv2_dnn.py b/plugins/extract/detect/cv2_dnn.py index fd3e39142c..8d78747b80 100644 --- a/plugins/extract/detect/cv2_dnn.py +++ b/plugins/extract/detect/cv2_dnn.py @@ -26,14 +26,14 @@ def __init__(self, **kwargs) -> None: def init_model(self) -> None: """ Initialize CV2 DNN Detector Model""" assert isinstance(self.model_path, list) - self.model = cv2.dnn.readNetFromCaffe(self.model_path[1], # pylint: disable=no-member + self.model = cv2.dnn.readNetFromCaffe(self.model_path[1], # pylint:disable=no-member self.model_path[0]) - self.model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) # pylint: disable=no-member + self.model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) # pylint:disable=no-member def process_input(self, batch: BatchType) -> None: """ Compile the detection image(s) for prediction """ assert isinstance(batch, DetectorBatch) - batch.feed = cv2.dnn.blobFromImages(batch.image, # pylint: disable=no-member + batch.feed = cv2.dnn.blobFromImages(batch.image, # pylint:disable=no-member scalefactor=1.0, size=(self.input_size, self.input_size), mean=[104, 117, 123], diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py index 8af8a41bef..78859ca249 100644 --- a/plugins/extract/detect/mtcnn.py +++ b/plugins/extract/detect/mtcnn.py @@ -596,7 +596,7 @@ def _filter_face_48net(self, class_probabilities: np.ndarray, return np.concatenate([results[..., :4], scores[..., None]], axis=-1), results[..., 4:].T -class MTCNN(): # pylint: disable=too-few-public-methods +class MTCNN(): # pylint:disable=too-few-public-methods """ MTCNN Detector for face alignment Parameters diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 82a6e074f8..0284a604fb 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -283,7 +283,7 @@ def _resize(cls, image: np.ndarray, target_size: int) -> np.ndarray: scale = target_size / image_size if scale == 1.: return image - method = cv2.INTER_CUBIC if scale > 1. else cv2.INTER_AREA # pylint: disable=no-member + method = cv2.INTER_CUBIC if scale > 1. else cv2.INTER_AREA # pylint:disable=no-member resized = cv2.resize(image, (0, 0), fx=scale, fy=scale, interpolation=method) resized = resized if channels > 1 else resized[..., None] return resized diff --git a/plugins/extract/recognition/vgg_face2.py b/plugins/extract/recognition/vgg_face2.py index aa25a4efa3..acf268bfd4 100644 --- a/plugins/extract/recognition/vgg_face2.py +++ b/plugins/extract/recognition/vgg_face2.py @@ -99,7 +99,7 @@ def process_output(self, batch: BatchType) -> None: return -class Cluster(): # pylint: disable=too-few-public-methods +class Cluster(): # pylint:disable=too-few-public-methods """ Cluster the outputs from a VGG-Face 2 Model Parameters diff --git a/plugins/train/_config.py b/plugins/train/_config.py index 404e0d8e06..1a354d928c 100644 --- a/plugins/train/_config.py +++ b/plugins/train/_config.py @@ -101,7 +101,7 @@ class Config(FaceswapConfig): """ Config File for Models """ - # pylint: disable=too-many-statements + # pylint:disable=too-many-statements def set_defaults(self) -> None: """ Set the default values for config """ logger.debug("Setting defaults") diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index c3c448d665..5bc1161b17 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -140,7 +140,7 @@ def io(self) -> IO: # pylint:disable=invalid-name def config(self) -> dict: """ dict: The configuration dictionary for current plugin, as set by the user's configuration settings. """ - global _CONFIG # pylint: disable=global-statement + global _CONFIG # pylint:disable=global-statement if not _CONFIG: model_name = self._config_section logger.debug("Loading config for: %s", model_name) @@ -200,7 +200,7 @@ def state(self) -> "State": def _load_config(self) -> None: """ Load the global config for reference in :attr:`config` and set the faceswap blocks configuration options in `lib.model.nn_blocks` """ - global _CONFIG # pylint: disable=global-statement + global _CONFIG # pylint:disable=global-statement if not _CONFIG: model_name = self._config_section logger.debug("Loading config for: %s", model_name) @@ -645,7 +645,7 @@ def _replace_config(self, config_changeable_items) -> None: Configuration options that can be altered when resuming a model, and their current values """ - global _CONFIG # pylint: disable=global-statement + global _CONFIG # pylint:disable=global-statement if _CONFIG is None: return legacy_update = self._update_legacy_config() diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 28c0f31a69..d6169fe252 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Phaze-A Model by TorzDF with thanks to BirbFakes and the myriad of testers. """ -# pylint: disable=too-many-lines +# pylint:disable=too-many-lines from __future__ import annotations import logging import typing as T @@ -979,7 +979,7 @@ def __call__(self) -> tf.keras.models.Model: return keras.models.Model(input_, var_x, name=f"fc_{self._side}") -class UpscaleBlocks(): # pylint: disable=too-few-public-methods +class UpscaleBlocks(): # pylint:disable=too-few-public-methods """ Obtain a block of upscalers. This class exists outside of the :class:`Decoder` model, as it is possible to place some of diff --git a/scripts/convert.py b/scripts/convert.py index a5b11da59a..72829cba87 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -515,7 +515,7 @@ def _start_thread(self, task: T.Literal["load", "save"]) -> None: logger.debug("Started thread: '%s'", task) # Loading tasks - def _load(self, *args) -> None: # pylint: disable=unused-argument + def _load(self, *args) -> None: # pylint:disable=unused-argument """ Load frames from disk. In a background thread: diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 0663a58349..4bfd9a8a0b 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -428,7 +428,7 @@ def do_actions(self, extract_media: ExtractMedia) -> None: action.process(extract_media) -class PostProcessAction(): # pylint: disable=too-few-public-methods +class PostProcessAction(): # pylint:disable=too-few-public-methods """ Parent class for Post Processing Actions. Usable in Extract or Convert or both depending on context. Any post-processing actions should @@ -465,7 +465,7 @@ def process(self, extract_media: ExtractMedia) -> None: raise NotImplementedError -class DebugLandmarks(PostProcessAction): # pylint: disable=too-few-public-methods +class DebugLandmarks(PostProcessAction): # pylint:disable=too-few-public-methods """ Draw debug landmarks on face output. Extract Only """ def __init__(self, *args, **kwargs) -> None: super().__init__(self, *args, **kwargs) diff --git a/scripts/gui.py b/scripts/gui.py index 4417458ea9..b79cf30b31 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -133,7 +133,7 @@ def rebuild(self): self._last_session.from_dict(session_state) logger.debug("GUI Redrawn") - def close_app(self, *args): # pylint: disable=unused-argument + def close_app(self, *args): # pylint:disable=unused-argument """ Close Python. This is here because the graph animation function continues to run even when tkinter has gone away """ @@ -173,7 +173,7 @@ def _confirm_close_on_running_task(self): return True -class Gui(): # pylint: disable=too-few-public-methods +class Gui(): # pylint:disable=too-few-public-methods """ The GUI process. """ def __init__(self, arguments): self.root = FaceswapGui(arguments.debug) diff --git a/scripts/train.py b/scripts/train.py index 1adca528ec..66c271dd0b 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -499,13 +499,13 @@ def _show(self, image: np.ndarray, name: str = "") -> None: logger.debug("Saving preview to disk") img = "training_preview.png" imgfile = os.path.join(scriptpath, img) - cv2.imwrite(imgfile, image) # pylint: disable=no-member + cv2.imwrite(imgfile, image) # pylint:disable=no-member logger.debug("Saved preview to: '%s'", img) if self._args.redirect_gui: logger.debug("Generating preview for GUI") img = TRAININGPREVIEW imgfile = os.path.join(scriptpath, "lib", "gui", ".cache", "preview", img) - cv2.imwrite(imgfile, image) # pylint: disable=no-member + cv2.imwrite(imgfile, image) # pylint:disable=no-member logger.debug("Generated preview for GUI: '%s'", imgfile) if self._args.preview: logger.debug("Generating preview for display: '%s'", name) diff --git a/setup.py b/setup.py index 3f4c29cb7e..a7a86fe1e4 100755 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ Install packages for faceswap.py """ -# pylint: disable=too-many-lines +# pylint:disable=too-many-lines import logging import ctypes @@ -1434,7 +1434,7 @@ def _seen_line_log(self, text: str) -> None: self._seen_lines.add(text) -class PexpectInstaller(Installer): # pylint: disable=too-few-public-methods +class PexpectInstaller(Installer): # pylint:disable=too-few-public-methods """ Package installer for Linux/macOS using Pexpect Uses Pexpect for installing packages allowing access to realtime feedback @@ -1472,7 +1472,7 @@ def call(self) -> int: return proc.exitstatus -class WinPTYInstaller(Installer): # pylint: disable=too-few-public-methods +class WinPTYInstaller(Installer): # pylint:disable=too-few-public-methods """ Package installer for Windows using WinPTY Spawns a pseudo PTY for installing packages allowing access to realtime feedback diff --git a/tools/alignments/media.py b/tools/alignments/media.py index 9fafdf0a21..da59992fcd 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -136,7 +136,7 @@ def check_input_folder(self) -> cv2.VideoCapture | None: os.path.isfile(self.folder) and os.path.splitext(self.folder)[1].lower() in _video_extensions): logger.verbose("Video exists at: '%s'", self.folder) # type: ignore - retval = cv2.VideoCapture(self.folder) # pylint: disable=no-member + retval = cv2.VideoCapture(self.folder) # pylint:disable=no-member # TODO ImageIO single frame seek seems slow. Look into this # retval = imageio.get_reader(self.folder, "ffmpeg") else: @@ -203,7 +203,7 @@ def load_video_frame(self, filename: str) -> np.ndarray: frame = os.path.splitext(filename)[0] logger.trace("Loading video frame: '%s'", frame) # type: ignore frame_no = int(frame[frame.rfind("_") + 1:]) - 1 - self._vid_reader.set(cv2.CAP_PROP_POS_FRAMES, frame_no) # pylint: disable=no-member + self._vid_reader.set(cv2.CAP_PROP_POS_FRAMES, frame_no) # pylint:disable=no-member _, image = self._vid_reader.read() # TODO imageio single frame seek seems slow. Look into this @@ -250,7 +250,7 @@ def save_image(output_folder: str, with open(output_file, "wb") as out_file: out_file.write(encoded_image) else: - cv2.imwrite(output_file, image) # pylint: disable=no-member + cv2.imwrite(output_file, image) # pylint:disable=no-member class Faces(MediaLoader): diff --git a/tools/manual/faceviewer/frame.py b/tools/manual/faceviewer/frame.py index f1443d9b20..f2f77ea39b 100644 --- a/tools/manual/faceviewer/frame.py +++ b/tools/manual/faceviewer/frame.py @@ -83,7 +83,7 @@ def _on_scroll(self, *event): self._canvas.yview(*event) self._canvas.viewport.update() - def _update_viewport(self, event): # pylint: disable=unused-argument + def _update_viewport(self, event): # pylint:disable=unused-argument """ Update the faces viewport and scrollbar. Parameters diff --git a/tools/manual/frameviewer/editor/_base.py b/tools/manual/frameviewer/editor/_base.py index 3e5bf81b5e..c4e9ebb6bf 100644 --- a/tools/manual/frameviewer/editor/_base.py +++ b/tools/manual/frameviewer/editor/_base.py @@ -389,7 +389,7 @@ def bind_mouse_motion(self): """ self._canvas.bind("", self._update_cursor) - def _update_cursor(self, event): # pylint: disable=unused-argument + def _update_cursor(self, event): # pylint:disable=unused-argument """ The mouse cursor display as bound to the mouse's event.. The default is to always return a standard cursor, so this method should be overridden for diff --git a/tools/manual/frameviewer/editor/bounding_box.py b/tools/manual/frameviewer/editor/bounding_box.py index 9eeef016fe..f3bbd78d00 100644 --- a/tools/manual/frameviewer/editor/bounding_box.py +++ b/tools/manual/frameviewer/editor/bounding_box.py @@ -291,7 +291,7 @@ def _drag_start(self, event): self._update_cursor(event) self._drag_start(event) - def _drag_stop(self, event): # pylint: disable=unused-argument + def _drag_stop(self, event): # pylint:disable=unused-argument """ Trigger a viewport thumbnail update on click + drag release Parameters diff --git a/tools/manual/frameviewer/editor/extract_box.py b/tools/manual/frameviewer/editor/extract_box.py index eb739545a2..f49acc055b 100644 --- a/tools/manual/frameviewer/editor/extract_box.py +++ b/tools/manual/frameviewer/editor/extract_box.py @@ -229,7 +229,7 @@ def _drag_start(self, event): callback = dict(anchor=self._resize, rotate=self._rotate, box=self._move) self._drag_callback = callback[self._mouse_location[0]] - def _drag_stop(self, event): # pylint: disable=unused-argument + def _drag_stop(self, event): # pylint:disable=unused-argument """ Trigger a viewport thumbnail update on click + drag release Parameters diff --git a/tools/manual/frameviewer/editor/landmarks.py b/tools/manual/frameviewer/editor/landmarks.py index bc2896212d..49c9c17d86 100644 --- a/tools/manual/frameviewer/editor/landmarks.py +++ b/tools/manual/frameviewer/editor/landmarks.py @@ -275,7 +275,7 @@ def _drag_start(self, event): self._drag_callback = None self._reset_selection(event) - def _drag_stop(self, event): # pylint: disable=unused-argument + def _drag_stop(self, event): # pylint:disable=unused-argument """ In select mode, call the select mode callback. In point mode: trigger a viewport thumbnail update on click + drag release diff --git a/tools/manual/frameviewer/frame.py b/tools/manual/frameviewer/frame.py index b448265c78..f42d53f79a 100644 --- a/tools/manual/frameviewer/frame.py +++ b/tools/manual/frameviewer/frame.py @@ -498,7 +498,7 @@ def _add_static_buttons(self): self._globals.tk_update.trace("w", self._disable_enable_reload_button) return buttons - def _disable_enable_copy_buttons(self, *args): # pylint: disable=unused-argument + def _disable_enable_copy_buttons(self, *args): # pylint:disable=unused-argument """ Disable or enable the static buttons """ position = self._globals.frame_index face_count_per_index = self._det_faces.face_count_per_index @@ -511,7 +511,7 @@ def _disable_enable_copy_buttons(self, *args): # pylint: disable=unused-argumen for direction in ("prev", "next"): self._static_buttons["copy_{}".format(direction)].state(states[direction]) - def _disable_enable_reload_button(self, *args): # pylint: disable=unused-argument + def _disable_enable_reload_button(self, *args): # pylint:disable=unused-argument """ Disable or enable the static buttons """ position = self._globals.frame_index state = ["!disabled"] if (position != -1 and diff --git a/tools/preview/control_panels.py b/tools/preview/control_panels.py index 2318182f17..3dc55ba2e2 100644 --- a/tools/preview/control_panels.py +++ b/tools/preview/control_panels.py @@ -249,7 +249,7 @@ def start(self) -> None: self._progress_bar.start(25) -class ActionFrame(ttk.Frame): # pylint: disable=too-many-ancestors +class ActionFrame(ttk.Frame): # pylint:disable=too-many-ancestors """ Frame that holds the left hand side options panel containing the command line options. Parameters @@ -589,7 +589,7 @@ def _add_patch_callback(self, patch_callback: Callable[[], None]) -> None: tk_var.trace("w", patch_callback) -class ConfigFrame(ttk.Frame): # pylint: disable=too-many-ancestors +class ConfigFrame(ttk.Frame): # pylint:disable=too-many-ancestors """ Holds the configuration options for a convert plugin inside the :class:`OptionsBook`. Parameters diff --git a/tools/sort/sort_methods.py b/tools/sort/sort_methods.py index 507db4ae1f..4a1501022d 100644 --- a/tools/sort/sort_methods.py +++ b/tools/sort/sort_methods.py @@ -506,8 +506,8 @@ def _get_file_iterator(self, input_dir: str) -> InfoLoader: retval = InfoLoader(input_dir, self._sorter.loader_type) else: retval = InfoLoader(input_dir, "all") - self._sorter._iterator = retval # pylint: disable=protected-access - self._grouper._iterator = retval # pylint: disable=protected-access + self._sorter._iterator = retval # pylint:disable=protected-access + self._grouper._iterator = retval # pylint:disable=protected-access return retval def score_image(self, From 70c064ca7d7f592eb94f3ff0cb17cab4d0a9de70 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 3 Apr 2024 15:14:32 +0100 Subject: [PATCH 889/981] Minor fixups and linting --- lib/gui/display_command.py | 2 +- lib/gui/options.py | 6 +- lib/gui/project.py | 12 +- lib/gui/theme.py | 4 +- lib/image.py | 4 +- lib/keypress.py | 2 +- lib/utils.py | 6 +- plugins/convert/mask/mask_blend.py | 2 +- plugins/extract/align/cv2_dnn.py | 14 +- plugins/extract/align/fan_defaults.py | 30 ++-- plugins/extract/detect/cv2_dnn.py | 10 +- plugins/extract/detect/cv2_dnn_defaults.py | 28 ++-- plugins/extract/detect/mtcnn_defaults.py | 157 +++++++++--------- plugins/extract/detect/s3fd_defaults.py | 56 +++---- plugins/extract/mask/bisenet_fp_defaults.py | 106 ++++++------ plugins/extract/mask/components.py | 6 +- plugins/extract/mask/custom_defaults.py | 52 +++--- plugins/extract/mask/extended.py | 6 +- plugins/extract/mask/unet_dfl_defaults.py | 28 ++-- plugins/extract/mask/vgg_clear_defaults.py | 28 ++-- .../extract/mask/vgg_obstructed_defaults.py | 28 ++-- .../extract/recognition/vgg_face2_defaults.py | 40 ++--- scripts/extract.py | 10 +- scripts/fsmedia.py | 10 +- scripts/gui.py | 2 +- scripts/train.py | 6 +- tools/alignments/alignments.py | 6 +- tools/alignments/media.py | 6 +- tools/effmpeg/cli.py | 4 +- tools/effmpeg/effmpeg.py | 6 +- tools/manual/manual.py | 4 +- tools/mask/mask.py | 4 +- 32 files changed, 347 insertions(+), 338 deletions(-) diff --git a/lib/gui/display_command.py b/lib/gui/display_command.py index e7d59f2876..3cc2e31887 100644 --- a/lib/gui/display_command.py +++ b/lib/gui/display_command.py @@ -350,7 +350,6 @@ def display_item_process(self) -> None: self.after(1000, self.display_item_process) return - logger.debug("Adding graph") existing = list(self.subnotebook_get_titles_ids().keys()) loss_keys = self.display_item.get_loss_keys(Session.session_ids[-1]) @@ -367,6 +366,7 @@ def display_item_process(self) -> None: tabname = loss_key.replace("_", " ").title() if tabname in existing: continue + logger.debug("Adding graph '%s'", tabname) display_keys = [key for key in loss_keys if key.startswith(loss_key)] data = Calculations(session_id=Session.session_ids[-1], diff --git a/lib/gui/options.py b/lib/gui/options.py index 3d1ac824ae..a60792563a 100644 --- a/lib/gui/options.py +++ b/lib/gui/options.py @@ -129,9 +129,9 @@ def process_options(self, command_options, command): helptext=opt["help"], track_modified=True, command=command) - gui_options[title] = dict(cpanel_option=cpanel_option, - opts=opt["opts"], - nargs=opt.get("nargs", None)) + gui_options[title] = {"cpanel_option": cpanel_option, + "opts": opt["opts"], + "nargs": opt.get("nargs", None)} logger.trace("Processed: %s", gui_options) return gui_options diff --git a/lib/gui/project.py b/lib/gui/project.py index 1ff6c6b2dc..83d1320718 100644 --- a/lib/gui/project.py +++ b/lib/gui/project.py @@ -90,8 +90,8 @@ def _stored_tab_name(self): def _selected_to_choices(self): """ dict: The selected value and valid choices for multi-option, radio or combo options. """ - valid_choices = {cmd: {opt: dict(choices=val["cpanel_option"].choices, - is_multi=val["cpanel_option"].is_multi_option) + valid_choices = {cmd: {opt: {"choices": val["cpanel_option"].choices, + "is_multi": val["cpanel_option"].is_multi_option} for opt, val in data.items() if isinstance(val, dict) and "cpanel_option" in val and val["cpanel_option"].choices is not None} @@ -600,9 +600,9 @@ def _add_task(self, command): The tab that pertains to the currently active task """ - self._tasks[command] = dict(filename=self._filename, - options=self._options, - is_project=self._is_project) + self._tasks[command] = {"filename": self._filename, + "options": self._options, + "is_project": self._is_project} def clear_tasks(self): """ Clears all of the stored tasks. @@ -629,7 +629,7 @@ def add_project_task(self, filename, command, options): options: dict The options for this task loaded from the project """ - self._tasks[command] = dict(filename=filename, options=options, is_project=True) + self._tasks[command] = {"filename": filename, "options": options, "is_project": True} def _set_active_task(self, command=None): """ Set the active :attr:`_filename` and :attr:`_options` to currently selected tab's diff --git a/lib/gui/theme.py b/lib/gui/theme.py index 1ea30f7e18..cdb42cbdba 100644 --- a/lib/gui/theme.py +++ b/lib/gui/theme.py @@ -370,7 +370,7 @@ def scrollbar(self, key, trough_color, border_color, control_backgrounds, contro ("disabled", images[f"img_{lookup}_disabled"]), ("pressed !disabled", images[f"img_{lookup}_active"]), ("active !disabled", images[f"img_{lookup}_active"])) - kwargs = dict(border=1, sticky="ns") if element == "thumb" else {} + kwargs = {"border": 1, "sticky": "ns"} if element == "thumb" else {} self._style.element_create(*args, **kwargs) # Get a configurable trough @@ -487,7 +487,7 @@ def _get_arrow(cls, dimensions, thickness, direction): crop_size = (square_size // 16) * 16 draw_rows = int(6 * crop_size / 16) start_row = dimensions[1] // 2 - draw_rows // 2 - initial_indent = (2 * (crop_size // 16) + (dimensions[0] - crop_size) // 2) + initial_indent = 2 * (crop_size // 16) + (dimensions[0] - crop_size) // 2 retval = np.zeros((dimensions[1], dimensions[0]), dtype="uint8") for i in range(start_row, start_row + draw_rows): diff --git a/lib/image.py b/lib/image.py index d351b1a23e..7a9d1f9ad1 100644 --- a/lib/image.py +++ b/lib/image.py @@ -23,7 +23,7 @@ from lib.multithreading import MultiThread from lib.queue_manager import queue_manager, QueueEmpty -from lib.utils import convert_to_secs, FaceswapError, _video_extensions, get_image_paths +from lib.utils import convert_to_secs, FaceswapError, VIDEO_EXTENSIONS, get_image_paths if T.TYPE_CHECKING: from lib.align.alignments import PNGHeaderDict @@ -1148,7 +1148,7 @@ def _check_for_video(self): """ if not isinstance(self.location, str) or os.path.isdir(self.location): retval = False - elif os.path.splitext(self.location)[1].lower() in _video_extensions: + elif os.path.splitext(self.location)[1].lower() in VIDEO_EXTENSIONS: retval = True else: raise FaceswapError("The input file '{}' is not a valid video".format(self.location)) diff --git a/lib/keypress.py b/lib/keypress.py index 439d1d2253..c5c2030216 100644 --- a/lib/keypress.py +++ b/lib/keypress.py @@ -43,7 +43,7 @@ def __init__(self, is_gui=False): self.old_term = termios.tcgetattr(self.file_desc) # New terminal setting unbuffered - self.new_term[3] = (self.new_term[3] & ~termios.ICANON & ~termios.ECHO) + self.new_term[3] = self.new_term[3] & ~termios.ICANON & ~termios.ECHO termios.tcsetattr(self.file_desc, termios.TCSAFLUSH, self.new_term) # Support normal-terminal reset at exit diff --git a/lib/utils.py b/lib/utils.py index d4018b5986..0a8e0d148f 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -24,9 +24,9 @@ from http.client import HTTPResponse # Global variables -_image_extensions = [ # pylint:disable=invalid-name +IMAGE_EXTENSIONS = [ # pylint:disable=invalid-name ".bmp", ".jpeg", ".jpg", ".png", ".tif", ".tiff"] -_video_extensions = [ # pylint:disable=invalid-name +VIDEO_EXTENSIONS = [ # pylint:disable=invalid-name ".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv", ".ts", ".vob"] _TF_VERS: tuple[int, int] | None = None @@ -249,7 +249,7 @@ def get_image_paths(directory: str, extension: str | None = None) -> list[str]: ['/path/to/directory/image1.jpg'] """ logger = logging.getLogger(__name__) - image_extensions = _image_extensions if extension is None else [extension] + image_extensions = IMAGE_EXTENSIONS if extension is None else [extension] dir_contents = [] if not os.path.exists(directory): diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index 3753340415..6683bdf10b 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) -class Mask(): # pylint:disable=too-few-public-methods +class Mask(): """ Manipulations to perform to the mask that is to be applied to the output of the Faceswap model. diff --git a/plugins/extract/align/cv2_dnn.py b/plugins/extract/align/cv2_dnn.py index 0b9bc16824..f646b7cdc2 100644 --- a/plugins/extract/align/cv2_dnn.py +++ b/plugins/extract/align/cv2_dnn.py @@ -46,6 +46,7 @@ def __init__(self, **kwargs) -> None: super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.model: cv2.dnn.Net + self.model_path: str self.name = "cv2-DNN Aligner" self.input_size = 128 self.color_format = "RGB" @@ -56,8 +57,8 @@ def __init__(self, **kwargs) -> None: def init_model(self) -> None: """ Initialize CV2 DNN Detector Model""" - self.model = cv2.dnn.readNetFromTensorflow(self.model_path) # pylint:disable=no-member - self.model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) # pylint:disable=no-member + self.model = cv2.dnn.readNetFromTensorflow(self.model_path) + self.model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) def faces_to_feed(self, faces: np.ndarray) -> np.ndarray: """ Convert a batch of face images from UINT8 (0-255) to fp32 (0.0-255.0) @@ -136,7 +137,7 @@ def align_image(self, batch: AlignerBatch) -> tuple[list[np.ndarray], offsets: list List of offsets for the faces """ - logger.trace("Aligning image around center") # type:ignore + logger.trace("Aligning image around center") # type:ignore[attr-defined] sizes = (self.input_size, self.input_size) rois = [] faces = [] @@ -247,7 +248,7 @@ def pad_image(cls, box: list[int], image: np.ndarray) -> tuple[np.ndarray, tuple pad_t = 1 - box[1] if box[1] < 0 else 0 pad_r = box[2] - width if box[2] > width else 0 pad_b = box[3] - height if box[3] > height else 0 - logger.trace("Padding: (l: %s, t: %s, r: %s, b: %s)", # type:ignore + logger.trace("Padding: (l: %s, t: %s, r: %s, b: %s)", # type:ignore[attr-defined] pad_l, pad_t, pad_r, pad_b) padded_image = cv2.copyMakeBorder(image.copy(), pad_t, @@ -257,7 +258,8 @@ def pad_image(cls, box: list[int], image: np.ndarray) -> tuple[np.ndarray, tuple cv2.BORDER_CONSTANT, value=(0, 0, 0)) offsets = (pad_l - pad_r, pad_t - pad_b) - logger.trace("image_shape: %s, Padded shape: %s, box: %s, offsets: %s", # type:ignore + logger.trace("image_shape: %s, Padded shape: %s, box: %s, " # type:ignore[attr-defined] + "offsets: %s", image.shape, padded_image.shape, box, offsets) return padded_image, offsets @@ -311,4 +313,4 @@ def get_pts_from_predict(self, batch: AlignerBatch): points[:, 1] += (roi[1] - offset[1]) landmarks.append(points) batch.landmarks = np.array(landmarks) - logger.trace("Predicted Landmarks: %s", batch.landmarks) # type:ignore + logger.trace("Predicted Landmarks: %s", batch.landmarks) # type:ignore[attr-defined] diff --git a/plugins/extract/align/fan_defaults.py b/plugins/extract/align/fan_defaults.py index 7c6425827c..90d5bc4b3a 100644 --- a/plugins/extract/align/fan_defaults.py +++ b/plugins/extract/align/fan_defaults.py @@ -50,19 +50,19 @@ _DEFAULTS = { - "batch-size": dict( - default=12, - info="The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered." - "\n\tAMD users: A batchsize of 8 requires about 4 GB vram.", - datatype=int, - rounding=1, - min_max=(1, 64), - choices=[], - group="settings", - gui_radio=False, - fixed=True, - ) + "batch-size": { + "default": 12, + "info": "The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered." + "\n\tAMD users: A batchsize of 8 requires about 4 GB vram.", + "datatype": int, + "rounding": 1, + "min_max": (1, 64), + "choices": [], + "group": "settings", + "gui_radio": False, + "fixed": True, + } } diff --git a/plugins/extract/detect/cv2_dnn.py b/plugins/extract/detect/cv2_dnn.py index 8d78747b80..9f98918e06 100644 --- a/plugins/extract/detect/cv2_dnn.py +++ b/plugins/extract/detect/cv2_dnn.py @@ -26,14 +26,14 @@ def __init__(self, **kwargs) -> None: def init_model(self) -> None: """ Initialize CV2 DNN Detector Model""" assert isinstance(self.model_path, list) - self.model = cv2.dnn.readNetFromCaffe(self.model_path[1], # pylint:disable=no-member + self.model = cv2.dnn.readNetFromCaffe(self.model_path[1], self.model_path[0]) - self.model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) # pylint:disable=no-member + self.model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) def process_input(self, batch: BatchType) -> None: """ Compile the detection image(s) for prediction """ assert isinstance(batch, DetectorBatch) - batch.feed = cv2.dnn.blobFromImages(batch.image, # pylint:disable=no-member + batch.feed = cv2.dnn.blobFromImages(batch.image, scalefactor=1.0, size=(self.input_size, self.input_size), mean=[104, 117, 123], @@ -53,13 +53,13 @@ def finalize_predictions(self, predictions: np.ndarray) -> np.ndarray: for i in range(predictions.shape[2]): confidence = predictions[0, 0, i, 2] if confidence >= self.confidence: - logger.trace("Accepting due to confidence %s >= %s", # type:ignore + logger.trace("Accepting due to confidence %s >= %s", # type:ignore[attr-defined] confidence, self.confidence) faces.append([(predictions[0, 0, i, 3] * self.input_size), (predictions[0, 0, i, 4] * self.input_size), (predictions[0, 0, i, 5] * self.input_size), (predictions[0, 0, i, 6] * self.input_size)]) - logger.trace("faces: %s", faces) # type:ignore + logger.trace("faces: %s", faces) # type:ignore[attr-defined] return np.array(faces)[None, ...] def process_output(self, batch: BatchType) -> None: diff --git a/plugins/extract/detect/cv2_dnn_defaults.py b/plugins/extract/detect/cv2_dnn_defaults.py index 762100a66b..e50c0ecc49 100755 --- a/plugins/extract/detect/cv2_dnn_defaults.py +++ b/plugins/extract/detect/cv2_dnn_defaults.py @@ -50,17 +50,17 @@ ) -_DEFAULTS = dict( - confidence=dict( - default=50, - info="The confidence level at which the detector has succesfully found a face.\nHigher " - "levels will be more discriminating, lower levels will have more false positives.", - datatype=int, - rounding=5, - min_max=(25, 100), - choices=[], - group="settings", - gui_radio=False, - fixed=True, - ), -) +_DEFAULTS = { + "confidence": { + "default": 50, + "info": "The confidence level at which the detector has succesfully found a face.\nHigher " + "levels will be more discriminating, lower levels will have more false positives.", + "datatype": int, + "rounding": 5, + "min_max": (25, 100), + "choices": [], + "group": "settings", + "gui_radio": False, + "fixed": True, + }, +} diff --git a/plugins/extract/detect/mtcnn_defaults.py b/plugins/extract/detect/mtcnn_defaults.py index ea4f3fa2df..17396669a1 100755 --- a/plugins/extract/detect/mtcnn_defaults.py +++ b/plugins/extract/detect/mtcnn_defaults.py @@ -51,82 +51,83 @@ _DEFAULTS = { - "minsize": dict( - default=20, - info="The minimum size of a face (in pixels) to be accepted as a positive match.\nLower " - "values use significantly more VRAM and will detect more false positives.", - datatype=int, - rounding=10, - min_max=(20, 1000), - choices=[], - group="settings", - gui_radio=False, - fixed=True, - ), - "scalefactor": dict( - default=0.709, - info="The scale factor for the image pyramid.", - datatype=float, - rounding=3, - min_max=(0.1, 0.9), - choices=[], - group="settings", - gui_radio=False, - fixed=True, - ), - "batch-size": dict( - default=8, - info="The batch size to use. To a point, higher batch sizes equal better performance, but " - "setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can accomodate " - "then this will automatically be lowered.", - datatype=int, - rounding=1, - min_max=(1, 64), - choices=[], - group="settings", - gui_radio=False, - fixed=True, - ), - "cpu": dict( - default=True, - info="MTCNN detector still runs fairly quickly on CPU on some setups. " - "Enable CPU mode here to use the CPU for this detector to save some VRAM at a speed " - "cost.", - datatype=bool, - group="settings"), - "threshold_1": dict( - default=0.6, - info="First stage threshold for face detection. This stage obtains face candidates.", - datatype=float, - rounding=2, - min_max=(0.1, 0.9), - choices=[], - group="threshold", - gui_radio=False, - fixed=True, - ), - "threshold_2": dict( - default=0.7, - info="Second stage threshold for face detection. This stage refines face candidates.", - datatype=float, - rounding=2, - min_max=(0.1, 0.9), - choices=[], - group="threshold", - gui_radio=False, - fixed=True, - ), - "threshold_3": dict( - default=0.7, - info="Third stage threshold for face detection. This stage further refines face " - "candidates.", - datatype=float, - rounding=2, - min_max=(0.1, 0.9), - choices=[], - group="threshold", - gui_radio=False, - fixed=True, - ), + "minsize": { + "default": 20, + "info": "The minimum size of a face (in pixels) to be accepted as a positive match." + "\nLower values use significantly more VRAM and will detect more false positives.", + "datatype": int, + "rounding": 10, + "min_max": (20, 1000), + "choices": [], + "group": "settings", + "gui_radio": False, + "fixed": True, + }, + "scalefactor": { + "default": 0.709, + "info": "The scale factor for the image pyramid.", + "datatype": float, + "rounding": 3, + "min_max": (0.1, 0.9), + "choices": [], + "group": "settings", + "gui_radio": False, + "fixed": True, + }, + "batch-size": { + "default": 8, + "info": "The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered.", + "datatype": int, + "rounding": 1, + "min_max": (1, 64), + "choices": [], + "group": "settings", + "gui_radio": False, + "fixed": True, + }, + "cpu": { + "default": True, + "info": "MTCNN detector still runs fairly quickly on CPU on some setups. " + "Enable CPU mode here to use the CPU for this detector to save some VRAM at a " + "speed cost.", + "datatype": bool, + "group": "settings" + }, + "threshold_1": { + "default": 0.6, + "info": "First stage threshold for face detection. This stage obtains face candidates.", + "datatype": float, + "rounding": 2, + "min_max": (0.1, 0.9), + "choices": [], + "group": "threshold", + "gui_radio": False, + "fixed": True, + }, + "threshold_2": { + "default": 0.7, + "info": "Second stage threshold for face detection. This stage refines face candidates.", + "datatype": float, + "rounding": 2, + "min_max": (0.1, 0.9), + "choices": [], + "group": "threshold", + "gui_radio": False, + "fixed": True, + }, + "threshold_3": { + "default": 0.7, + "info": "Third stage threshold for face detection. This stage further refines face " + "candidates.", + "datatype": float, + "rounding": 2, + "min_max": (0.1, 0.9), + "choices": [], + "group": "threshold", + "gui_radio": False, + "fixed": True, + }, } diff --git a/plugins/extract/detect/s3fd_defaults.py b/plugins/extract/detect/s3fd_defaults.py index 6c17bba95b..5e219766f4 100755 --- a/plugins/extract/detect/s3fd_defaults.py +++ b/plugins/extract/detect/s3fd_defaults.py @@ -51,32 +51,32 @@ _DEFAULTS = { - "confidence": dict( - default=70, - info="The confidence level at which the detector has succesfully found a face.\n" - "Higher levels will be more discriminating, lower levels will have more false " - "positives.", - datatype=int, - rounding=5, - min_max=(25, 100), - choices=[], - group="settings", - gui_radio=False, - fixed=True, - ), - "batch-size": dict( - default=4, - info="The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered." - "\n\tAMD users: A batchsize of 8 requires about 2 GB vram.", - datatype=int, - rounding=1, - min_max=(1, 64), - choices=[], - group="settings", - gui_radio=False, - fixed=True, - ) + "confidence": { + "default": 70, + "info": "The confidence level at which the detector has succesfully found a face.\n" + "Higher levels will be more discriminating, lower levels will have more false " + "positives.", + "datatype": int, + "rounding": 5, + "min_max": (25, 100), + "choices": [], + "group": "settings", + "gui_radio": False, + "fixed": True, + }, + "batch-size": { + "default": 4, + "info": "The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered." + "\n\tAMD users: A batchsize of 8 requires about 2 GB vram.", + "datatype": int, + "rounding": 1, + "min_max": (1, 64), + "choices": [], + "group": "settings", + "gui_radio": False, + "fixed": True, + } } diff --git a/plugins/extract/mask/bisenet_fp_defaults.py b/plugins/extract/mask/bisenet_fp_defaults.py index 51b4b3540a..3b0ae79b92 100644 --- a/plugins/extract/mask/bisenet_fp_defaults.py +++ b/plugins/extract/mask/bisenet_fp_defaults.py @@ -50,56 +50,58 @@ _DEFAULTS = { - "batch-size": dict( - default=8, - info="The batch size to use. To a point, higher batch sizes equal better performance, but " - "setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can accomodate " - "then this will automatically be lowered.", - datatype=int, - rounding=1, - min_max=(1, 64), - choices=[], - group="settings", - gui_radio=False, - fixed=True), - "cpu": dict( - default=False, - info="BiseNet mask still runs fairly quickly on CPU on some setups. Enable " - "CPU mode here to use the CPU for this masker to save some VRAM at a speed cost.", - datatype=bool, - group="settings"), - "weights": dict( - default="faceswap", - info="The trained weights to use.\n" - "\n\tfaceswap - Weights trained on wildly varied Faceswap extracted data to better " - "handle varying conditions, obstructions, glasses and multiple targets within a " - "single extracted image." - "\n\toriginal - The original weights trained on the CelebAMask-HQ dataset.", - choices=["faceswap", "original"], - datatype=str, - group="settings", - gui_radio=True, - ), - "include_ears": dict( - default=False, - info="Whether to include ears within the face mask.", - datatype=bool, - group="settings" - ), - "include_hair": dict( - default=False, - info="Whether to include hair within the face mask.", - datatype=bool, - group="settings" - ), - "include_glasses": dict( - default=True, - info="Whether to include glasses within the face mask.\n\tFor 'original' weights " - "excluding glasses will mask out the lenses as well as the frames.\n\tFor 'faceswap' " - "weights, the model has been trained to mask out lenses if eyes cannot be seen (i.e. " - "dark sunglasses) or just the frames if the eyes can be seen. ", - datatype=bool, - group="settings" - ), + "batch-size": { + "default": 8, + "info": "The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered.", + "datatype": int, + "rounding": 1, + "min_max": (1, 64), + "choices": [], + "group": "settings", + "gui_radio": False, + "fixed": True + }, + "cpu": { + "default": False, + "info": "BiseNet mask still runs fairly quickly on CPU on some setups. Enable " + "CPU mode here to use the CPU for this masker to save some VRAM at a speed cost.", + "datatype": bool, + "group": "settings" + }, + "weights": { + "default": "faceswap", + "info": "The trained weights to use.\n" + "\n\tfaceswap - Weights trained on wildly varied Faceswap extracted data to " + "better handle varying conditions, obstructions, glasses and multiple targets " + "within a single extracted image." + "\n\toriginal - The original weights trained on the CelebAMask-HQ dataset.", + "choices": ["faceswap", "original"], + "datatype": str, + "group": "settings", + "gui_radio": True, + }, + "include_ears": { + "default": False, + "info": "Whether to include ears within the face mask.", + "datatype": bool, + "group": "settings" + }, + "include_hair": { + "default": False, + "info": "Whether to include hair within the face mask.", + "datatype": bool, + "group": "settings" + }, + "include_glasses": { + "default": True, + "info": "Whether to include glasses within the face mask.\n\tFor 'original' weights " + "excluding glasses will mask out the lenses as well as the frames.\n\tFor " + "'faceswap' weights, the model has been trained to mask out lenses if eyes cannot " + "be seen (i.e. dark sunglasses) or just the frames if the eyes can be seen.", + "datatype": bool, + "group": "settings" + }, } diff --git a/plugins/extract/mask/components.py b/plugins/extract/mask/components.py index 6ba0b540d7..a787023512 100644 --- a/plugins/extract/mask/components.py +++ b/plugins/extract/mask/components.py @@ -42,9 +42,9 @@ def predict(self, feed: np.ndarray) -> np.ndarray: for mask, face in zip(feed, faces): parts = self.parse_parts(np.array(face.landmarks)) for item in parts: - item = np.rint(np.concatenate(item)).astype("int32") - hull = cv2.convexHull(item) - cv2.fillConvexPoly(mask, hull, 1.0, lineType=cv2.LINE_AA) + a_item = np.rint(np.concatenate(item)).astype("int32") + hull = cv2.convexHull(a_item) + cv2.fillConvexPoly(mask, hull, [1.0], lineType=cv2.LINE_AA) return feed def process_output(self, batch: BatchType) -> None: diff --git a/plugins/extract/mask/custom_defaults.py b/plugins/extract/mask/custom_defaults.py index 30e5c84fb5..9da35416f5 100644 --- a/plugins/extract/mask/custom_defaults.py +++ b/plugins/extract/mask/custom_defaults.py @@ -51,29 +51,31 @@ _DEFAULTS = { - "batch-size": dict( - default=8, - info="The batch size to use. To a point, higher batch sizes equal better performance, but " - "setting it too high can harm performance.", - datatype=int, - rounding=1, - min_max=(1, 64), - group="settings"), - "centering": dict( - default="face", - info="Whether to create a dummy mask with face or head centering.", - choices=["face", "head"], - datatype=str, - group="settings", - gui_radio=True), - "fill": dict( - default=False, - info="Whether the mask should be filled (True) in which case the custom mask will be " - "created with the whole area masked in (i.e. you would need to manually edit out the " - "background) or unfilled (False) in which case you would need to manually edit in " - "the face.", - datatype=bool, - group="settings", - gui_radio=True, - ), + "batch-size": { + "default": 8, + "info": "The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.", + "datatype": int, + "rounding": 1, + "min_max": (1, 64), + "group": "settings" + }, + "centering": { + "default": "face", + "info": "Whether to create a dummy mask with face or head centering.", + "choices": ["face", "head"], + "datatype": str, + "group": "settings", + "gui_radio": True + }, + "fill": { + "default": False, + "info": "Whether the mask should be filled (True) in which case the custom mask will be " + "created with the whole area masked in (i.e. you would need to manually edit out " + "the background) or unfilled (False) in which case you would need to manually " + "edit in the face.", + "datatype": bool, + "group": "settings", + "gui_radio": True, + }, } diff --git a/plugins/extract/mask/extended.py b/plugins/extract/mask/extended.py index 0755e794f8..633238367b 100644 --- a/plugins/extract/mask/extended.py +++ b/plugins/extract/mask/extended.py @@ -41,9 +41,9 @@ def predict(self, feed: np.ndarray) -> np.ndarray: for mask, face in zip(feed, faces): parts = self.parse_parts(np.array(face.landmarks)) for item in parts: - item = np.rint(np.concatenate(item)).astype("int32") - hull = cv2.convexHull(item) - cv2.fillConvexPoly(mask, hull, 1.0, lineType=cv2.LINE_AA) + a_item = np.rint(np.concatenate(item)).astype("int32") + hull = cv2.convexHull(a_item) + cv2.fillConvexPoly(mask, hull, [1.0], lineType=cv2.LINE_AA) return feed def process_output(self, batch: BatchType) -> None: diff --git a/plugins/extract/mask/unet_dfl_defaults.py b/plugins/extract/mask/unet_dfl_defaults.py index 1a3fb81890..62514c0188 100644 --- a/plugins/extract/mask/unet_dfl_defaults.py +++ b/plugins/extract/mask/unet_dfl_defaults.py @@ -51,18 +51,18 @@ _DEFAULTS = { - "batch-size": dict( - default=8, - info="The batch size to use. To a point, higher batch sizes equal better performance, but " - "setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can accomodate " - "then this will automatically be lowered.", - datatype=int, - rounding=1, - min_max=(1, 64), - choices=[], - group="settings", - gui_radio=False, - fixed=True, - ) + "batch-size": { + "default": 8, + "info": "The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered.", + "datatype": int, + "rounding": 1, + "min_max": (1, 64), + "choices": [], + "group": "settings", + "gui_radio": False, + "fixed": True, + } } diff --git a/plugins/extract/mask/vgg_clear_defaults.py b/plugins/extract/mask/vgg_clear_defaults.py index b9592c5b12..48c5d1f428 100644 --- a/plugins/extract/mask/vgg_clear_defaults.py +++ b/plugins/extract/mask/vgg_clear_defaults.py @@ -50,18 +50,18 @@ _DEFAULTS = { - "batch-size": dict( - default=6, - info="The batch size to use. To a point, higher batch sizes equal better performance, but " - "setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can accomodate " - "then this will automatically be lowered.", - datatype=int, - rounding=1, - min_max=(1, 64), - choices=[], - group="settings", - gui_radio=False, - fixed=True, - ) + "batch-size": { + "default": 6, + "info": "The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered.", + "datatype": int, + "rounding": 1, + "min_max": (1, 64), + "choices": [], + "group": "settings", + "gui_radio": False, + "fixed": True, + } } diff --git a/plugins/extract/mask/vgg_obstructed_defaults.py b/plugins/extract/mask/vgg_obstructed_defaults.py index a4ca3e28af..7d19354289 100644 --- a/plugins/extract/mask/vgg_obstructed_defaults.py +++ b/plugins/extract/mask/vgg_obstructed_defaults.py @@ -51,18 +51,18 @@ _DEFAULTS = { - "batch-size": dict( - default=2, - info="The batch size to use. To a point, higher batch sizes equal better performance, but " - "setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can accomodate " - "then this will automatically be lowered.", - datatype=int, - rounding=1, - min_max=(1, 64), - choices=[], - group="settings", - gui_radio=False, - fixed=True, - ) + "batch-size": { + "default": 2, + "info": "The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered.", + "datatype": int, + "rounding": 1, + "min_max": (1, 64), + "choices": [], + "group": "settings", + "gui_radio": False, + "fixed": True, + } } diff --git a/plugins/extract/recognition/vgg_face2_defaults.py b/plugins/extract/recognition/vgg_face2_defaults.py index cde066285b..67c92783a7 100644 --- a/plugins/extract/recognition/vgg_face2_defaults.py +++ b/plugins/extract/recognition/vgg_face2_defaults.py @@ -51,23 +51,25 @@ _DEFAULTS = { - "batch-size": dict( - default=16, - info="The batch size to use. To a point, higher batch sizes equal better performance, but " - "setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can accomodate " - "then this will automatically be lowered.", - datatype=int, - rounding=1, - min_max=(1, 64), - choices=[], - group="settings", - gui_radio=False, - fixed=True), - "cpu": dict( - default=False, - info="VGG Face2 still runs fairly quickly on CPU on some setups. Enable " - "CPU mode here to use the CPU for this plugin to save some VRAM at a speed cost.", - datatype=bool, - group="settings"), + "batch-size": { + "default": 16, + "info": "The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered.", + "datatype": int, + "rounding": 1, + "min_max": (1, 64), + "choices": [], + "group": "settings", + "gui_radio": False, + "fixed": True + }, + "cpu": { + "default": False, + "info": "VGG Face2 still runs fairly quickly on CPU on some setups. Enable " + "CPU mode here to use the CPU for this plugin to save some VRAM at a speed cost.", + "datatype": bool, + "group": "settings" + }, } diff --git a/scripts/extract.py b/scripts/extract.py index 2b16aaa4b9..812cf94a23 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -16,7 +16,7 @@ from lib.image import encode_image, generate_thumbnail, ImagesLoader, ImagesSaver, read_image_meta from lib.multithreading import MultiThread -from lib.utils import get_folder, _image_extensions, _video_extensions +from lib.utils import get_folder, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS from plugins.extract.pipeline import Extractor, ExtractMedia from scripts.fsmedia import Alignments, PostProcess, finalize @@ -90,9 +90,9 @@ def _get_input_locations(self) -> list[str]: retval = [os.path.join(self._args.input_dir, fname) for fname in os.listdir(self._args.input_dir) if (os.path.isdir(os.path.join(self._args.input_dir, fname)) # folder images - and any(os.path.splitext(iname)[-1].lower() in _image_extensions + and any(os.path.splitext(iname)[-1].lower() in IMAGE_EXTENSIONS for iname in os.listdir(os.path.join(self._args.input_dir, fname)))) - or os.path.splitext(fname)[-1].lower() in _video_extensions] # video + or os.path.splitext(fname)[-1].lower() in VIDEO_EXTENSIONS] # video logger.debug("Input locations: %s", retval) return retval @@ -268,7 +268,7 @@ def _files_from_folder(cls, input_location: list[str]) -> list[str]: retval = [os.path.join(test_folder, fname) for fname in os.listdir(test_folder) - if os.path.splitext(fname)[-1].lower() in _image_extensions] + if os.path.splitext(fname)[-1].lower() in IMAGE_EXTENSIONS] logger.info("Collected files from folder '%s': %s", test_folder, [os.path.basename(f) for f in retval]) return retval @@ -299,7 +299,7 @@ def _validate_inputs(self, filt_files = [] if files is None else self._files_from_folder(files) for file in filt_files: if (not os.path.isfile(file) or - os.path.splitext(file)[-1].lower() not in _image_extensions): + os.path.splitext(file)[-1].lower() not in IMAGE_EXTENSIONS): logger.warning("Filter file '%s' does not exist or is not an image file", file) error = True retval.append(filt_files) diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 4bfd9a8a0b..3837c68eed 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -19,7 +19,7 @@ from lib.align import Alignments as AlignmentsBase, get_centered_size from lib.image import count_frames, read_image -from lib.utils import (camel_case_split, get_image_paths, _video_extensions) +from lib.utils import (camel_case_split, get_image_paths, VIDEO_EXTENSIONS) if T.TYPE_CHECKING: from collections.abc import Generator @@ -222,7 +222,7 @@ def _check_input_folder(self) -> bool: logger.error("Input location %s not found.", self._args.input_dir) sys.exit(1) if (os.path.isfile(self._args.input_dir) and - os.path.splitext(self._args.input_dir)[1].lower() in _video_extensions): + os.path.splitext(self._args.input_dir)[1].lower() in VIDEO_EXTENSIONS): logger.info("Input Video: %s", self._args.input_dir) retval = True else: @@ -345,7 +345,7 @@ def _load_one_video_frame(self, frame_no: int) -> np.ndarray: return frame -class PostProcess(): # pylint:disable=too-few-public-methods +class PostProcess(): """ Optional pre/post processing tasks for convert and extract. Builds a pipeline of actions that have optionally been requested to be performed @@ -428,7 +428,7 @@ def do_actions(self, extract_media: ExtractMedia) -> None: action.process(extract_media) -class PostProcessAction(): # pylint:disable=too-few-public-methods +class PostProcessAction(): """ Parent class for Post Processing Actions. Usable in Extract or Convert or both depending on context. Any post-processing actions should @@ -465,7 +465,7 @@ def process(self, extract_media: ExtractMedia) -> None: raise NotImplementedError -class DebugLandmarks(PostProcessAction): # pylint:disable=too-few-public-methods +class DebugLandmarks(PostProcessAction): """ Draw debug landmarks on face output. Extract Only """ def __init__(self, *args, **kwargs) -> None: super().__init__(self, *args, **kwargs) diff --git a/scripts/gui.py b/scripts/gui.py index b79cf30b31..eea446f54b 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -173,7 +173,7 @@ def _confirm_close_on_running_task(self): return True -class Gui(): # pylint:disable=too-few-public-methods +class Gui(): """ The GUI process. """ def __init__(self, arguments): self.root = FaceswapGui(arguments.debug) diff --git a/scripts/train.py b/scripts/train.py index 66c271dd0b..e08e9a8261 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -18,7 +18,7 @@ from lib.multithreading import MultiThread, FSThread from lib.training import Preview, PreviewBuffer, TriggerType from lib.utils import (get_folder, get_image_paths, - FaceswapError, _image_extensions) + FaceswapError, IMAGE_EXTENSIONS) from plugins.plugin_loader import PluginLoader if T.TYPE_CHECKING: @@ -31,7 +31,7 @@ logger = logging.getLogger(__name__) -class Train(): # pylint:disable=too-few-public-methods +class Train(): """ The Faceswap Training Process. The training process is responsible for training a model on a set of source faces and a set of @@ -174,7 +174,7 @@ def _set_timelapse(self) -> dict[T.Literal["input_a", "input_b", "output"], str] continue # Time-lapse folder is training folder filenames = [fname for fname in os.listdir(folder) - if os.path.splitext(fname)[-1].lower() in _image_extensions] + if os.path.splitext(fname)[-1].lower() in IMAGE_EXTENSIONS] if not filenames: raise FaceswapError(f"The Timelapse path '{folder}' does not contain any valid " "images") diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index 9e020730fc..8017d149c6 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -8,7 +8,7 @@ from argparse import Namespace from multiprocessing import Process -from lib.utils import _video_extensions, FaceswapError +from lib.utils import VIDEO_EXTENSIONS, FaceswapError from .media import AlignmentData from .jobs import Check, Sort, Spatial # noqa pylint: disable=unused-import from .jobs_faces import FromFaces, RemoveFaces, Rename # noqa pylint: disable=unused-import @@ -117,7 +117,7 @@ def _get_frames_locations(self) -> dict[str, list[str | None]]: candidates = [os.path.join(self._args.frames_dir, fname) for fname in os.listdir(self._args.frames_dir) if os.path.isdir(os.path.join(self._args.frames_dir, fname)) - or os.path.splitext(fname)[-1].lower() in _video_extensions] + or os.path.splitext(fname)[-1].lower() in VIDEO_EXTENSIONS] logger.debug("Frame candidates: %s", candidates) for candidate in candidates: @@ -289,7 +289,7 @@ def _find_alignments(self) -> str: if os.path.isdir(frames) and os.path.exists(os.path.join(frames, fname)): return fname - if os.path.isdir(frames) or os.path.splitext(frames)[-1] not in _video_extensions: + if os.path.isdir(frames) or os.path.splitext(frames)[-1] not in VIDEO_EXTENSIONS: logger.error("Can't find a valid alignments file in location: %s", frames) sys.exit(1) diff --git a/tools/alignments/media.py b/tools/alignments/media.py index da59992fcd..a68da7835c 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -17,7 +17,7 @@ from lib.align import Alignments, DetectedFace, update_legacy_png_header from lib.image import (count_frames, generate_thumbnail, ImagesLoader, png_write_meta, read_image, read_image_meta_batch) -from lib.utils import _image_extensions, _video_extensions, FaceswapError +from lib.utils import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, FaceswapError if T.TYPE_CHECKING: from collections.abc import Generator @@ -134,7 +134,7 @@ def check_input_folder(self) -> cv2.VideoCapture | None: if (loadtype == "Frames" and os.path.isfile(self.folder) and - os.path.splitext(self.folder)[1].lower() in _video_extensions): + os.path.splitext(self.folder)[1].lower() in VIDEO_EXTENSIONS): logger.verbose("Video exists at: '%s'", self.folder) # type: ignore retval = cv2.VideoCapture(self.folder) # pylint:disable=no-member # TODO ImageIO single frame seek seems slow. Look into this @@ -148,7 +148,7 @@ def check_input_folder(self) -> cv2.VideoCapture | None: def valid_extension(filename) -> bool: """ bool: Check whether passed in file has a valid extension """ extension = os.path.splitext(filename)[1] - retval = extension.lower() in _image_extensions + retval = extension.lower() in IMAGE_EXTENSIONS logger.trace("Filename has valid extension: '%s': %s", filename, retval) # type: ignore return retval diff --git a/tools/effmpeg/cli.py b/tools/effmpeg/cli.py index ececeeaa39..ae82625bf6 100644 --- a/tools/effmpeg/cli.py +++ b/tools/effmpeg/cli.py @@ -4,7 +4,7 @@ from lib.cli.args import FaceSwapArgs from lib.cli.actions import ContextFullPaths, FileFullPaths, Radio -from lib.utils import _image_extensions +from lib.utils import IMAGE_EXTENSIONS # LOCALES @@ -100,7 +100,7 @@ def get_argument_list(self): argument_list.append(dict( opts=("-ef", "--extract-filetype"), action=Radio, - choices=_image_extensions, + choices=IMAGE_EXTENSIONS, dest="extract_ext", group=_("output"), default=".png", diff --git a/tools/effmpeg/effmpeg.py b/tools/effmpeg/effmpeg.py index 724c555e4a..4f056cd7eb 100644 --- a/tools/effmpeg/effmpeg.py +++ b/tools/effmpeg/effmpeg.py @@ -17,7 +17,7 @@ from ffmpy import FFmpeg, FFRuntimeError # faceswap imports -from lib.utils import _image_extensions, _video_extensions +from lib.utils import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS logger = logging.getLogger(__name__) @@ -27,10 +27,10 @@ class DataItem(): A simple class used for storing the media data items and directories that Effmpeg uses for 'input', 'output' and 'ref_vid'. """ - vid_ext = _video_extensions + vid_ext = VIDEO_EXTENSIONS # future option in effmpeg to use audio file for muxing audio_ext = ['.aiff', '.flac', '.mp3', '.wav'] - img_ext = _image_extensions + img_ext = IMAGE_EXTENSIONS def __init__(self, path=None, name=None, item_type=None, ext=None, fps=None): diff --git a/tools/manual/manual.py b/tools/manual/manual.py index f2187dbcdc..e33826ef1d 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -18,7 +18,7 @@ from lib.gui.utils import get_images, get_config, initialize_config, initialize_images from lib.image import SingleFrameLoader, read_image_meta from lib.multithreading import MultiThread -from lib.utils import _video_extensions +from lib.utils import VIDEO_EXTENSIONS from plugins.extract.pipeline import Extractor, ExtractMedia from .detected_faces import DetectedFaces @@ -569,7 +569,7 @@ def _check_input(frames_location): """ if os.path.isdir(frames_location): retval = False - elif os.path.splitext(frames_location)[1].lower() in _video_extensions: + elif os.path.splitext(frames_location)[1].lower() in VIDEO_EXTENSIONS: retval = True else: logger.error("The input location '%s' is not valid", frames_location) diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 02e976c49f..7c9c0a2227 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -10,7 +10,7 @@ from lib.align import Alignments -from lib.utils import _video_extensions +from lib.utils import VIDEO_EXTENSIONS from plugins.extract.pipeline import ExtractMedia from .loader import Loader @@ -64,7 +64,7 @@ def _get_input_locations(self) -> list[str]: retval = [os.path.join(self._args.input, fname) for fname in os.listdir(self._args.input) if os.path.isdir(os.path.join(self._args.input, fname)) - or os.path.splitext(fname)[-1].lower() in _video_extensions] + or os.path.splitext(fname)[-1].lower() in VIDEO_EXTENSIONS] logger.info("Batch mode selected. Processing locations: %s", retval) return retval From ab8199e2430509d744a547eb278dc4667c632f3a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 3 Apr 2024 15:30:46 +0100 Subject: [PATCH 890/981] linting + typing --- tools/alignments/alignments.py | 4 ++-- tools/alignments/jobs.py | 2 +- tools/alignments/jobs_faces.py | 8 ++++---- tools/alignments/jobs_frames.py | 4 ++-- tools/alignments/media.py | 4 ++-- tools/manual/detected_faces.py | 2 +- tools/manual/faceviewer/viewport.py | 2 +- tools/manual/frameviewer/control.py | 2 +- tools/mask/mask_generate.py | 2 +- tools/mask/mask_import.py | 2 +- tools/model/model.py | 8 ++++---- tools/sort/sort_methods_aligned.py | 2 +- 12 files changed, 21 insertions(+), 21 deletions(-) diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index 8017d149c6..eca95982ec 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) -class Alignments(): # pylint:disable=too-few-public-methods +class Alignments(): """ The main entry point for Faceswap's Alignments Tool. This tool is part of the Faceswap Tools suite and should be called from the ``python tools.py alignments`` command. @@ -239,7 +239,7 @@ def process(self): self._run_process(args) -class _Alignments(): # pylint:disable=too-few-public-methods +class _Alignments(): """ The main entry point for Faceswap's Alignments Tool. This tool is part of the Faceswap Tools suite and should be called from the ``python tools.py alignments`` command. diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index 2dfdc9a75b..2642ebaaaa 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -418,7 +418,7 @@ def reindex_faces(self) -> int: return reindexed -class Spatial(): # pylint:disable=too-few-public-methods +class Spatial(): """ Apply spatial temporal filtering to landmarks Parameters diff --git a/tools/alignments/jobs_faces.py b/tools/alignments/jobs_faces.py index 3dae491e5d..ac2205f89c 100644 --- a/tools/alignments/jobs_faces.py +++ b/tools/alignments/jobs_faces.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) -class FromFaces(): # pylint:disable=too-few-public-methods +class FromFaces(): """ Scan a folder of Faceswap Extracted Faces and re-create the associated alignments file(s) Parameters @@ -222,7 +222,7 @@ def _save_alignments(self, aln.save() -class Rename(): # pylint:disable=too-few-public-methods +class Rename(): """ Rename faces in a folder to match their filename as stored in an alignments file. Parameters @@ -319,7 +319,7 @@ def _rename_faces(self, filename_mappings: list[tuple[str, str]]) -> int: return rename_count -class RemoveFaces(): # pylint:disable=too-few-public-methods +class RemoveFaces(): """ Remove items from alignments file. Parameters @@ -407,7 +407,7 @@ def _update_png_headers(self) -> None: logger.info("%s Extracted face(s) had their header information updated", len(to_update)) -class FaceToFile(): # pylint:disable=too-few-public-methods +class FaceToFile(): """ Updates any optional/missing keys in the alignments file with any data that has been populated in a PNGHeader. Includes masks and identity fields. diff --git a/tools/alignments/jobs_frames.py b/tools/alignments/jobs_frames.py index 62ce578df1..8f7efb9b85 100644 --- a/tools/alignments/jobs_frames.py +++ b/tools/alignments/jobs_frames.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) -class Draw(): # pylint:disable=too-few-public-methods +class Draw(): """ Draws annotations onto original frames and saves into a sub-folder next to the original frames. @@ -171,7 +171,7 @@ def _annotate_pose(cls, image: np.ndarray, face: DetectedFace) -> None: cv2.line(image, tuple(center), tuple(points[2]), (0, 0, 255), 2) -class Extract(): # pylint:disable=too-few-public-methods +class Extract(): """ Re-extract faces from source frames based on Alignment data Parameters diff --git a/tools/alignments/media.py b/tools/alignments/media.py index a68da7835c..eee258c8f1 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -245,8 +245,8 @@ def save_image(output_folder: str, output_file = os.path.splitext(output_file)[0] + ".png" logger.trace("Saving image: '%s'", output_file) # type: ignore if metadata: - encoded_image = cv2.imencode(".png", image)[1] - encoded_image = png_write_meta(encoded_image.tobytes(), metadata) + encoded = cv2.imencode(".png", image)[1] + encoded_image = png_write_meta(encoded.tobytes(), metadata) with open(output_file, "wb") as out_file: out_file.write(encoded_image) else: diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index 0f9cf1cc1b..29d187f86c 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -248,7 +248,7 @@ def _get_alignments(self, alignments_path: str, input_location: str) -> Alignmen return retval -class _DiskIO(): # pylint:disable=too-few-public-methods +class _DiskIO(): """ Handles the loading of :class:`~lib.align.DetectedFaces` from the alignments file into :class:`DetectedFaces` and the saving of this data (in the opposite direction) to an alignments file. diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index 0ca4818d8f..8b52ac2812 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -636,7 +636,7 @@ def _shift(self): return True -class HoverBox(): # pylint:disable=too-few-public-methods +class HoverBox(): """ Handle the current mouse location when over the :class:`Viewport`. Highlights the face currently underneath the cursor and handles actions when clicking diff --git a/tools/manual/frameviewer/control.py b/tools/manual/frameviewer/control.py index 2112ad174f..8230f24b00 100644 --- a/tools/manual/frameviewer/control.py +++ b/tools/manual/frameviewer/control.py @@ -157,7 +157,7 @@ def goto_last_frame(self): self._globals.tk_transport_index.set(frame_count - 1) -class BackgroundImage(): # pylint:disable=too-few-public-methods +class BackgroundImage(): """ The background image of the canvas """ def __init__(self, canvas): self._canvas = canvas diff --git a/tools/mask/mask_generate.py b/tools/mask/mask_generate.py index 4b09172ce6..a1a7f628ef 100644 --- a/tools/mask/mask_generate.py +++ b/tools/mask/mask_generate.py @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) -class MaskGenerator: # pylint:disable=too-few-public-methods +class MaskGenerator: """ Uses faceswap's extract pipeline to generate masks and update them into the alignments file and/or extracted face PNG Headers diff --git a/tools/mask/mask_import.py b/tools/mask/mask_import.py index 48262c6299..e9a0f4beac 100644 --- a/tools/mask/mask_import.py +++ b/tools/mask/mask_import.py @@ -26,7 +26,7 @@ logger = logging.getLogger(__name__) -class Import: # pylint:disable=too-few-public-methods +class Import: """ Import masks from disk into an Alignments file Parameters diff --git a/tools/model/model.py b/tools/model/model.py index d179d8d9ae..0cb3a033b3 100644 --- a/tools/model/model.py +++ b/tools/model/model.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) -class Model(): # pylint:disable=too-few-public-methods +class Model(): """ Tool to perform actions on a model file. Parameters @@ -105,7 +105,7 @@ def process(self) -> None: self._job.process() -class Inference(): # pylint:disable=too-few-public-methods +class Inference(): """ Save an inference model from a trained Faceswap model. Parameters @@ -153,7 +153,7 @@ def process(self) -> None: inference.save(self._output_file) -class NaNScan(): # pylint:disable=too-few-public-methods +class NaNScan(): """ Tool to scan for NaN and Infs in model weights. Parameters @@ -245,7 +245,7 @@ def process(self) -> None: self._parse_output(errors) -class Restore(): # pylint:disable=too-few-public-methods +class Restore(): """ Restore a model from backup. Parameters diff --git a/tools/sort/sort_methods_aligned.py b/tools/sort/sort_methods_aligned.py index ecf6dd4af4..34275efbdd 100644 --- a/tools/sort/sort_methods_aligned.py +++ b/tools/sort/sort_methods_aligned.py @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) -class SortAlignedMetric(SortMethod): # pylint:disable=too-few-public-methods +class SortAlignedMetric(SortMethod): """ Sort by comparison of metrics stored in an Aligned Face objects. This is a parent class for sort by aligned metrics methods. Individual methods should inherit from this class From 64a7b5812e861de39746cc72fcdad6d0c3473748 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:43:37 +0100 Subject: [PATCH 891/981] bugfix: setup/sysinfo - Don't look for files in non-existant path --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a7a86fe1e4..56c0188a55 100755 --- a/setup.py +++ b/setup.py @@ -803,7 +803,7 @@ def _check_ld_config(lib: str) -> str: return retval for path in os.environ["LD_LIBRARY_PATH"].split(":"): - if not path: + if not path or not os.path.exists(path): continue retval = next((fname.strip() for fname in reversed(os.listdir(path)) From 95b4431c575ec9f03cdac4c820ed1cd3c45dd9cd Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 5 Apr 2024 13:51:57 +0100 Subject: [PATCH 892/981] Deprecate multi-character cli switches --- docs/full/lib/gui.rst | 7 + faceswap.py | 12 +- lib/cli/args.py | 1044 +--------------- lib/cli/args_extract_convert.py | 743 +++++++++++ lib/cli/args_train.py | 382 ++++++ lib/cli/launcher.py | 2 +- lib/gui/command.py | 3 +- lib/gui/options.py | 727 ++++++++--- lib/gui/project.py | 11 +- lib/logger.py | 5 +- lib/utils.py | 46 +- locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 48433 -> 1728 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 1091 +---------------- .../lib.cli.args_extract_convert.mo | Bin 0 -> 31533 bytes .../lib.cli.args_extract_convert.po | 713 +++++++++++ locales/es/LC_MESSAGES/lib.cli.args_train.mo | Bin 0 -> 15946 bytes locales/es/LC_MESSAGES/lib.cli.args_train.po | 380 ++++++ .../es/LC_MESSAGES/tools.alignments.cli.mo | Bin 11950 -> 11850 bytes .../es/LC_MESSAGES/tools.alignments.cli.po | 51 +- locales/es/LC_MESSAGES/tools.effmpeg.cli.mo | Bin 6576 -> 6598 bytes locales/es/LC_MESSAGES/tools.effmpeg.cli.po | 63 +- locales/es/LC_MESSAGES/tools.manual.mo | Bin 8168 -> 8191 bytes locales/es/LC_MESSAGES/tools.manual.po | 21 +- locales/es/LC_MESSAGES/tools.mask.cli.mo | Bin 14916 -> 14916 bytes locales/es/LC_MESSAGES/tools.mask.cli.po | 4 +- locales/es/LC_MESSAGES/tools.model.cli.mo | Bin 2899 -> 2688 bytes locales/es/LC_MESSAGES/tools.model.cli.po | 26 +- locales/es/LC_MESSAGES/tools.preview.mo | Bin 2227 -> 2282 bytes locales/es/LC_MESSAGES/tools.preview.po | 63 +- locales/es/LC_MESSAGES/tools.sort.cli.mo | Bin 15559 -> 11665 bytes locales/es/LC_MESSAGES/tools.sort.cli.po | 151 ++- locales/kr/LC_MESSAGES/lib.cli.args.mo | Bin 48993 -> 1723 bytes locales/kr/LC_MESSAGES/lib.cli.args.po | 985 +-------------- .../lib.cli.args_extract_convert.mo | Bin 0 -> 31900 bytes .../lib.cli.args_extract_convert.po | 650 ++++++++++ locales/kr/LC_MESSAGES/lib.cli.args_train.mo | Bin 0 -> 16024 bytes locales/kr/LC_MESSAGES/lib.cli.args_train.po | 350 ++++++ .../kr/LC_MESSAGES/tools.alignments.cli.mo | Bin 11673 -> 11572 bytes .../kr/LC_MESSAGES/tools.alignments.cli.po | 51 +- locales/kr/LC_MESSAGES/tools.effmpeg.cli.mo | Bin 6735 -> 6760 bytes locales/kr/LC_MESSAGES/tools.effmpeg.cli.po | 51 +- locales/kr/LC_MESSAGES/tools.manual.mo | Bin 8143 -> 8168 bytes locales/kr/LC_MESSAGES/tools.manual.po | 23 +- locales/kr/LC_MESSAGES/tools.mask.cli.mo | Bin 14151 -> 14151 bytes locales/kr/LC_MESSAGES/tools.mask.cli.po | 4 +- locales/kr/LC_MESSAGES/tools.model.cli.mo | Bin 2967 -> 2762 bytes locales/kr/LC_MESSAGES/tools.model.cli.po | 22 +- locales/kr/LC_MESSAGES/tools.preview.mo | Bin 2080 -> 2135 bytes locales/kr/LC_MESSAGES/tools.preview.po | 48 +- locales/kr/LC_MESSAGES/tools.sort.cli.mo | Bin 15578 -> 11625 bytes locales/kr/LC_MESSAGES/tools.sort.cli.po | 151 ++- locales/lib.cli.args.pot | 683 +---------- locales/lib.cli.args_extract_convert.pot | 458 +++++++ locales/lib.cli.args_train.pot | 255 ++++ locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 65130 -> 2173 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 1053 +--------------- .../lib.cli.args_extract_convert.mo | Bin 0 -> 42512 bytes .../lib.cli.args_extract_convert.po | 703 +++++++++++ locales/ru/LC_MESSAGES/lib.cli.args_train.mo | Bin 0 -> 21253 bytes locales/ru/LC_MESSAGES/lib.cli.args_train.po | 1045 ++++++++++++++++ .../ru/LC_MESSAGES/tools.alignments.cli.mo | Bin 15272 -> 15152 bytes .../ru/LC_MESSAGES/tools.alignments.cli.po | 51 +- locales/ru/LC_MESSAGES/tools.effmpeg.cli.mo | Bin 8647 -> 8670 bytes locales/ru/LC_MESSAGES/tools.effmpeg.cli.po | 51 +- locales/ru/LC_MESSAGES/tools.manual.mo | Bin 10909 -> 10932 bytes locales/ru/LC_MESSAGES/tools.manual.po | 23 +- locales/ru/LC_MESSAGES/tools.mask.cli.mo | Bin 18221 -> 18221 bytes locales/ru/LC_MESSAGES/tools.mask.cli.po | 4 +- locales/ru/LC_MESSAGES/tools.model.cli.mo | Bin 3819 -> 3562 bytes locales/ru/LC_MESSAGES/tools.model.cli.po | 36 +- locales/ru/LC_MESSAGES/tools.preview.mo | Bin 2891 -> 2891 bytes locales/ru/LC_MESSAGES/tools.preview.po | 32 +- locales/ru/LC_MESSAGES/tools.sort.cli.mo | Bin 20597 -> 15483 bytes locales/ru/LC_MESSAGES/tools.sort.cli.po | 152 ++- locales/tools.alignments.cli.pot | 44 +- locales/tools.effmpeg.cli.pot | 108 +- locales/tools.manual.pot | 54 +- locales/tools.mask.cli.pot | 2 +- locales/tools.model.cli.pot | 14 +- locales/tools.preview.pot | 28 +- locales/tools.sort.cli.pot | 104 +- scripts/convert.py | 21 +- scripts/extract.py | 8 +- scripts/fsmedia.py | 1 + scripts/train.py | 13 +- tools/alignments/alignments.py | 10 +- tools/alignments/cli.py | 311 ++--- tools/alignments/jobs_frames.py | 2 + tools/effmpeg/cli.py | 373 +++--- tools/effmpeg/effmpeg.py | 194 +-- tools/manual/cli.py | 91 +- tools/manual/manual.py | 3 +- tools/mask/cli.py | 11 +- tools/mask/mask.py | 8 +- tools/model/cli.py | 86 +- tools/preview/cli.py | 71 +- tools/preview/preview.py | 9 +- tools/sort/cli.py | 294 +++-- tools/sort/sort.py | 30 +- 99 files changed, 7978 insertions(+), 6338 deletions(-) create mode 100644 lib/cli/args_extract_convert.py create mode 100644 lib/cli/args_train.py mode change 100755 => 100644 locales/es/LC_MESSAGES/lib.cli.args.mo create mode 100644 locales/es/LC_MESSAGES/lib.cli.args_extract_convert.mo create mode 100755 locales/es/LC_MESSAGES/lib.cli.args_extract_convert.po create mode 100644 locales/es/LC_MESSAGES/lib.cli.args_train.mo create mode 100755 locales/es/LC_MESSAGES/lib.cli.args_train.po create mode 100644 locales/kr/LC_MESSAGES/lib.cli.args_extract_convert.mo create mode 100644 locales/kr/LC_MESSAGES/lib.cli.args_extract_convert.po create mode 100644 locales/kr/LC_MESSAGES/lib.cli.args_train.mo create mode 100644 locales/kr/LC_MESSAGES/lib.cli.args_train.po create mode 100644 locales/lib.cli.args_extract_convert.pot create mode 100644 locales/lib.cli.args_train.pot mode change 100755 => 100644 locales/ru/LC_MESSAGES/lib.cli.args.mo create mode 100644 locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.mo create mode 100755 locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.po create mode 100644 locales/ru/LC_MESSAGES/lib.cli.args_train.mo create mode 100755 locales/ru/LC_MESSAGES/lib.cli.args_train.po diff --git a/docs/full/lib/gui.rst b/docs/full/lib/gui.rst index 3d77e15674..07981633a4 100755 --- a/docs/full/lib/gui.rst +++ b/docs/full/lib/gui.rst @@ -111,6 +111,13 @@ menu module :undoc-members: :show-inheritance: +options module +============== +.. automodule:: lib.gui.options + :members: + :undoc-members: + :show-inheritance: + popup_configure module ====================== .. automodule:: lib.gui.popup_configure diff --git a/faceswap.py b/faceswap.py index 1189f2e580..5f27ba1792 100755 --- a/faceswap.py +++ b/faceswap.py @@ -10,6 +10,8 @@ os.environ["LANG"], _ = locale.getdefaultlocale() from lib.cli import args as cli_args # pylint:disable=wrong-import-position +from lib.cli.args_train import TrainArgs # pylint:disable=wrong-import-position +from lib.cli.args_extract_convert import ConvertArgs, ExtractArgs # noqa:E501 pylint:disable=wrong-import-position from lib.config import generate_configs # pylint:disable=wrong-import-position # LOCALES @@ -41,11 +43,11 @@ def _main() -> None: generate_configs() subparser = _PARSER.add_subparsers() - cli_args.ExtractArgs(subparser, "extract", _("Extract the faces from pictures or a video")) - cli_args.TrainArgs(subparser, "train", _("Train a model for the two faces A and B")) - cli_args.ConvertArgs(subparser, - "convert", - _("Convert source pictures or video to a new one with the face swapped")) + ExtractArgs(subparser, "extract", _("Extract the faces from pictures or a video")) + TrainArgs(subparser, "train", _("Train a model for the two faces A and B")) + ConvertArgs(subparser, + "convert", + _("Convert source pictures or video to a new one with the face swapped")) cli_args.GuiArgs(subparser, "gui", _("Launch the Faceswap Graphical User Interface")) _PARSER.set_defaults(func=_bad_args) arguments = _PARSER.parse_args() diff --git a/lib/cli/args.py b/lib/cli/args.py index d7a6626f4e..29aa74ee40 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 -""" The Command Line Argument options for faceswap.py """ +""" The global and GUI Command Line Argument options for faceswap.py """ -# pylint:disable=too-many-lines import argparse import gettext import logging @@ -13,10 +12,7 @@ from lib.utils import get_backend from lib.gpu_stats import GPUStats -from plugins.plugin_loader import PluginLoader - -from .actions import (DirFullPaths, DirOrFileFullPaths, DirOrFilesFullPaths, FileFullPaths, - FilesFullPaths, MultiOption, Radio, SaveFileFullPaths, Slider) +from .actions import FileFullPaths, MultiOption, SaveFileFullPaths from .launcher import ScriptExecutor logger = logging.getLogger(__name__) @@ -99,19 +95,20 @@ class FaceSwapArgs(): Parameters ---------- - subparser: :class:`argparse._SubParsersAction` - The subparser for the given command + subparser: :class:`argparse._SubParsersAction` | None + The subparser for the given command. ``None`` if the class is being called for reading + rather than processing command: str The faceswap command that is to be executed description: str, optional The description for the given command. Default: "default" """ def __init__(self, - subparser: argparse._SubParsersAction, + subparser: argparse._SubParsersAction | None, command: str, description: str = "default") -> None: self.global_arguments = self._get_global_arguments() - self.info = self.get_info() + self.info: str = self.get_info() self.argument_list = self.get_argument_list() self.optional_arguments = self.get_optional_arguments() self._process_suppressions() @@ -182,56 +179,62 @@ def _get_global_arguments() -> list[dict[str, T.Any]]: """ global_args: list[dict[str, T.Any]] = [] if _GPUS: - global_args.append(dict( - opts=("-X", "--exclude-gpus"), - dest="exclude_gpus", - action=MultiOption, - type=str.lower, - nargs="+", - choices=[str(idx) for idx in range(len(_GPUS))], - group=_("Global Options"), - help=_("R|Exclude GPUs from use by Faceswap. Select the number(s) which " - "correspond to any GPU(s) that you do not wish to be made available to " - "Faceswap. Selecting all GPUs here will force Faceswap into CPU mode." - "\nL|{}").format(" \nL|".join(_GPUS)))) - global_args.append(dict( - opts=("-C", "--configfile"), - action=FileFullPaths, - filetypes="ini", - type=str, - group=_("Global Options"), - help=_("Optionally overide the saved config with the path to a custom config file."))) - global_args.append(dict( - opts=("-L", "--loglevel"), - type=str.upper, - dest="loglevel", - default="INFO", - choices=("INFO", "VERBOSE", "DEBUG", "TRACE"), - group=_("Global Options"), - help=_("Log level. Stick with INFO or VERBOSE unless you need to file an error " - "report. Be careful with TRACE as it will generate a lot of data"))) - global_args.append(dict( - opts=("-LF", "--logfile"), - action=SaveFileFullPaths, - filetypes='log', - type=str, - dest="logfile", - default=None, - group=_("Global Options"), - help=_("Path to store the logfile. Leave blank to store in the faceswap folder"))) + global_args.append({ + "opts": ("-X", "--exclude-gpus"), + "dest": "exclude_gpus", + "action": MultiOption, + "type": str.lower, + "nargs": "+", + "choices": [str(idx) for idx in range(len(_GPUS))], + "group": _("Global Options"), + "help": _( + "R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " + "to any GPU(s) that you do not wish to be made available to Faceswap. " + "Selecting all GPUs here will force Faceswap into CPU mode." + "\nL|{}".format(' \nL|'.join(_GPUS)))}) + global_args.append({ + "opts": ("-C", "--configfile"), + "action": FileFullPaths, + "filetypes": "ini", + "type": str, + "group": _("Global Options"), + "help": _( + "Optionally overide the saved config with the path to a custom config file.")}) + global_args.append({ + "opts": ("-L", "--loglevel"), + "type": str.upper, + "dest": "loglevel", + "default": "INFO", + "choices": ("INFO", "VERBOSE", "DEBUG", "TRACE"), + "group": _("Global Options"), + "help": _( + "Log level. Stick with INFO or VERBOSE unless you need to file an error report. " + "Be careful with TRACE as it will generate a lot of data")}) + global_args.append({ + "opts": ("-F", "--logfile"), + "action": SaveFileFullPaths, + "filetypes": 'log', + "type": str, + "dest": "logfile", + "default": None, + "group": _("Global Options"), + "help": _("Path to store the logfile. Leave blank to store in the faceswap folder")}) # These are hidden arguments to indicate that the GUI/Colab is being used - global_args.append(dict( - opts=("-gui", "--gui"), - action="store_true", - dest="redirect_gui", - default=False, - help=argparse.SUPPRESS)) - global_args.append(dict( - opts=("-colab", "--colab"), - action="store_true", - dest="colab", - default=False, - help=argparse.SUPPRESS)) + global_args.append({ + "opts": ("-gui", "--gui"), + "action": "store_true", + "dest": "redirect_gui", + "default": False, + "help": argparse.SUPPRESS}) + # Deprecated multi-character switches + global_args.append({ + "opts": ("-LF",), + "action": SaveFileFullPaths, + "filetypes": 'log', + "type": str, + "dest": "depr_logfile_LF_F", + "help": argparse.SUPPRESS}) + return global_args @staticmethod @@ -290,917 +293,6 @@ def _process_suppressions(self) -> None: opts["help"] = argparse.SUPPRESS -class ExtractConvertArgs(FaceSwapArgs): - """ Parent class to capture arguments that will be used in both extract and convert processes. - - Extract and Convert share a fair amount of arguments, so arguments that can be used in both of - these processes should be placed here. - - No further processing is done in this class (this is handled by the children), this just - captures the shared arguments. - """ - - @staticmethod - def get_argument_list() -> list[dict[str, T.Any]]: - """ Returns the argument list for shared Extract and Convert arguments. - - Returns - ------- - list - The list of command line options for the given Extract and Convert - """ - argument_list: list[dict[str, T.Any]] = [] - argument_list.append(dict( - opts=("-i", "--input-dir"), - action=DirOrFileFullPaths, - filetypes="video", - dest="input_dir", - required=True, - group=_("Data"), - help=_("Input directory or video. Either a directory containing the image files you " - "wish to process or path to a video file. NB: This should be the source video/" - "frames NOT the source faces."))) - argument_list.append(dict( - opts=("-o", "--output-dir"), - action=DirFullPaths, - dest="output_dir", - required=True, - group=_("Data"), - help=_("Output directory. This is where the converted files will be saved."))) - argument_list.append(dict( - opts=("-al", "--alignments"), - action=FileFullPaths, - filetypes="alignments", - type=str, - dest="alignments_path", - group=_("Data"), - help=_("Optional path to an alignments file. Leave blank if the alignments file is " - "at the default location."))) - return argument_list - - -class ExtractArgs(ExtractConvertArgs): - """ Creates the command line arguments for extraction. - - This class inherits base options from :class:`ExtractConvertArgs` where arguments that are used - for both Extract and Convert should be placed. - - Commands explicit to Extract should be added in :func:`get_optional_arguments` - """ - - @staticmethod - def get_info() -> str: - """ The information text for the Extract command. - - Returns - ------- - str - The information text for the Extract command. - """ - return _("Extract faces from image or video sources.\n" - "Extraction plugins can be configured in the 'Settings' Menu") - - @staticmethod - def get_optional_arguments() -> list[dict[str, T.Any]]: - """ Returns the argument list unique to the Extract command. - - Returns - ------- - list - The list of optional command line options for the Extract command - """ - if get_backend() == "cpu": - default_detector = "mtcnn" - default_aligner = "cv2-dnn" - else: - default_detector = "s3fd" - default_aligner = "fan" - - argument_list: list[dict[str, T.Any]] = [] - argument_list.append(dict( - opts=("-b", "--batch-mode"), - action="store_true", - dest="batch_mode", - default=False, - group=_("Data"), - help=_("R|If selected then the input_dir should be a parent folder containing " - "multiple videos and/or folders of images you wish to extract from. The faces " - "will be output to separate sub-folders in the output_dir."))) - argument_list.append(dict( - opts=("-D", "--detector"), - action=Radio, - type=str.lower, - default=default_detector, - choices=PluginLoader.get_available_extractors("detect"), - group=_("Plugins"), - help=_("R|Detector to use. Some of these have configurable settings in " - "'/config/extract.ini' or 'Settings > Configure Extract 'Plugins':" - "\nL|cv2-dnn: A CPU only extractor which is the least reliable and least " - "resource intensive. Use this if not using a GPU and time is important." - "\nL|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " - "than other GPU detectors but can often return more false positives." - "\nL|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces " - "and fewer false positives than other GPU detectors, but is a lot more " - "resource intensive."))) - argument_list.append(dict( - opts=("-A", "--aligner"), - action=Radio, - type=str.lower, - default=default_aligner, - choices=PluginLoader.get_available_extractors("align"), - group=_("Plugins"), - help=_("R|Aligner to use." - "\nL|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " - "but less accurate. Only use this if not using a GPU and time is important." - "\nL|fan: Best aligner. Fast on GPU, slow on CPU."))) - argument_list.append(dict( - opts=("-M", "--masker"), - action=MultiOption, - type=str.lower, - nargs="+", - choices=[mask for mask in PluginLoader.get_available_extractors("mask") - if mask not in ("components", "extended")], - group=_("Plugins"), - help=_("R|Additional Masker(s) to use. The masks generated here will all take up GPU " - "RAM. You can select none, one or multiple masks, but the extraction may take " - "longer the more you select. NB: The Extended and Components (landmark based) " - "masks are automatically generated on extraction." - "\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|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 so will not use any additional VRAM." - "\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." - "\nThe auto generated masks are as follows:" - "\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|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." - "\n(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)"))) - argument_list.append(dict( - opts=("-nm", "--normalization"), - action=Radio, - type=str.lower, - dest="normalization", - default="none", - choices=["none", "clahe", "hist", "mean"], - group=_("Plugins"), - help=_("R|Performing normalization can help the aligner better align faces with " - "difficult lighting conditions at an extraction speed cost. Different methods " - "will yield different results on different sets. NB: This does not impact the " - "output face, just the input to the aligner." - "\nL|none: Don't perform normalization on the face." - "\nL|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " - "face." - "\nL|hist: Equalize the histograms on the RGB channels." - "\nL|mean: Normalize the face colors to the mean."))) - argument_list.append(dict( - opts=("-rf", "--re-feed"), - action=Slider, - min_max=(0, 10), - rounding=1, - type=int, - dest="re_feed", - default=0, - group=_("Plugins"), - help=_("The number of times to re-feed the detected face into the aligner. Each time " - "the face is re-fed into the aligner the bounding box is adjusted by a small " - "amount. The final landmarks are then averaged from each iteration. Helps to " - "remove 'micro-jitter' but at the cost of slower extraction speed. The more " - "times the face is re-fed into the aligner, the less micro-jitter should occur " - "but the longer extraction will take."))) - argument_list.append(dict( - opts=("-a", "--re-align"), - action="store_true", - dest="re_align", - default=False, - group=_("Plugins"), - help=_("Re-feed the initially found aligned face through the aligner. Can help " - "produce better alignments for faces that are rotated beyond 45 degrees in " - "the frame or are at extreme angles. Slows down extraction."))) - argument_list.append(dict( - opts=("-r", "--rotate-images"), - type=str, - dest="rotate_images", - default=None, - group=_("Plugins"), - help=_("If a face isn't found, rotate the images to try to find a face. Can find " - "more faces at the cost of extraction speed. Pass in a single number to use " - "increments of that size up to 360, or pass in a list of numbers to enumerate " - "exactly what angles to check."))) - argument_list.append(dict( - opts=("-I", "--identity"), - action="store_true", - default=False, - group=_("Plugins"), - help=_("Obtain and store face identity encodings from VGGFace2. Slows down extract a " - "little, but will save time if using 'sort by face'"))) - argument_list.append(dict( - opts=("-min", "--min-size"), - action=Slider, - min_max=(0, 1080), - rounding=20, - type=int, - dest="min_size", - default=0, - group=_("Face Processing"), - help=_("Filters out faces detected below this size. Length, in pixels across the " - "diagonal of the bounding box. Set to 0 for off"))) - argument_list.append(dict( - opts=("-n", "--nfilter"), - action=DirOrFilesFullPaths, - filetypes="image", - dest="nfilter", - default=None, - nargs="+", - group=_("Face Processing"), - help=_("Optionally filter out people who you do not wish to extract by passing in " - "images of those people. Should be a small variety of images at different " - "angles and in different conditions. A folder containing the required images " - "or multiple image files, space separated, can be selected."))) - argument_list.append(dict( - opts=("-f", "--filter"), - action=DirOrFilesFullPaths, - filetypes="image", - dest="filter", - default=None, - nargs="+", - group=_("Face Processing"), - help=_("Optionally select people you wish to extract by passing in images of that " - "person. Should be a small variety of images at different angles and in " - "different conditions A folder containing the required images or multiple " - "image files, space separated, can be selected."))) - argument_list.append(dict( - opts=("-l", "--ref_threshold"), - action=Slider, - min_max=(0.01, 0.99), - rounding=2, - type=float, - dest="ref_threshold", - default=0.60, - group=_("Face Processing"), - help=_("For use with the optional nfilter/filter files. Threshold for positive face " - "recognition. Higher values are stricter."))) - argument_list.append(dict( - opts=("-sz", "--size"), - action=Slider, - min_max=(256, 1024), - rounding=64, - type=int, - default=512, - group=_("output"), - help=_("The output size of extracted faces. Make sure that the model you intend to " - "train supports your required size. This will only need to be changed for " - "hi-res models."))) - argument_list.append(dict( - opts=("-een", "--extract-every-n"), - action=Slider, - min_max=(1, 100), - rounding=1, - type=int, - dest="extract_every_n", - default=1, - group=_("output"), - help=_("Extract every 'nth' frame. This option will skip frames when extracting " - "faces. For example a value of 1 will extract faces from every frame, a value " - "of 10 will extract faces from every 10th frame."))) - argument_list.append(dict( - opts=("-si", "--save-interval"), - action=Slider, - min_max=(0, 1000), - rounding=10, - type=int, - dest="save_interval", - default=0, - group=_("output"), - help=_("Automatically save the alignments file after a set amount of frames. By " - "default the alignments file is only saved at the end of the extraction " - "process. NB: If extracting in 2 passes then the alignments file will only " - "start to be saved out during the second pass. WARNING: Don't interrupt the " - "script when writing the file because it might get corrupted. Set to 0 to " - "turn off"))) - argument_list.append(dict( - opts=("-dl", "--debug-landmarks"), - action="store_true", - dest="debug_landmarks", - default=False, - group=_("output"), - help=_("Draw landmarks on the ouput faces for debugging purposes."))) - argument_list.append(dict( - opts=("-sp", "--singleprocess"), - action="store_true", - default=False, - backend=("nvidia", "directml", "rocm", "apple_silicon"), - group=_("settings"), - help=_("Don't run extraction in parallel. Will run each part of the extraction " - "process separately (one after the other) rather than all at the same time. " - "Useful if VRAM is at a premium."))) - argument_list.append(dict( - opts=("-s", "--skip-existing"), - action="store_true", - dest="skip_existing", - default=False, - group=_("settings"), - help=_("Skips frames that have already been extracted and exist in the alignments " - "file"))) - argument_list.append(dict( - opts=("-sf", "--skip-existing-faces"), - action="store_true", - dest="skip_faces", - default=False, - group=_("settings"), - help=_("Skip frames that already have detected faces in the alignments file"))) - argument_list.append(dict( - opts=("-ssf", "--skip-saving-faces"), - action="store_true", - dest="skip_saving_faces", - default=False, - group=_("settings"), - help=_("Skip saving the detected faces to disk. Just create an alignments file"))) - return argument_list - - -class ConvertArgs(ExtractConvertArgs): - """ Creates the command line arguments for conversion. - - This class inherits base options from :class:`ExtractConvertArgs` where arguments that are used - for both Extract and Convert should be placed. - - Commands explicit to Convert should be added in :func:`get_optional_arguments` - """ - - @staticmethod - def get_info() -> str: - """ The information text for the Convert command. - - Returns - ------- - str - The information text for the Convert command. - """ - return _("Swap the original faces in a source video/images to your final faces.\n" - "Conversion plugins can be configured in the 'Settings' Menu") - - @staticmethod - def get_optional_arguments() -> list[dict[str, T.Any]]: - """ Returns the argument list unique to the Convert command. - - Returns - ------- - list - The list of optional command line options for the Convert command - """ - - argument_list: list[dict[str, T.Any]] = [] - argument_list.append(dict( - opts=("-ref", "--reference-video"), - action=FileFullPaths, - filetypes="video", - type=str, - dest="reference_video", - group=_("Data"), - help=_("Only required if converting from images to video. Provide The original video " - "that the source frames were extracted from (for extracting the fps and " - "audio)."))) - argument_list.append(dict( - opts=("-m", "--model-dir"), - action=DirFullPaths, - dest="model_dir", - required=True, - group=_("Data"), - help=_("Model directory. The directory containing the trained model you wish to use " - "for conversion."))) - argument_list.append(dict( - opts=("-c", "--color-adjustment"), - action=Radio, - type=str.lower, - dest="color_adjustment", - default="avg-color", - choices=PluginLoader.get_available_convert_plugins("color", True), - group=_("Plugins"), - help=_("R|Performs color adjustment to the swapped face. Some of these options have " - "configurable settings in '/config/convert.ini' or 'Settings > Configure " - "Convert Plugins':" - "\nL|avg-color: Adjust the mean of each color channel in the swapped " - "reconstruction to equal the mean of the masked area in the original image." - "\nL|color-transfer: Transfers the color distribution from the source to the " - "target image using the mean and standard deviations of the L*a*b* " - "color space." - "\nL|manual-balance: Manually adjust the balance of the image in a variety of " - "color spaces. Best used with the Preview tool to set correct values." - "\nL|match-hist: Adjust the histogram of each color channel in the swapped " - "reconstruction to equal the histogram of the masked area in the original " - "image." - "\nL|seamless-clone: Use cv2's seamless clone function to remove extreme " - "gradients at the mask seam by smoothing colors. Generally does not give " - "very satisfactory results." - "\nL|none: Don't perform color adjustment."))) - argument_list.append(dict( - opts=("-M", "--mask-type"), - action=Radio, - type=str.lower, - dest="mask_type", - default="extended", - choices=PluginLoader.get_available_extractors("mask", - add_none=True, - extend_plugin=True) + ["predicted"], - group=_("Plugins"), - help=_("R|Masker to use. NB: The mask you require must exist within the alignments " - "file. You can add additional masks with the Mask Tool." - "\nL|none: Don't use a mask." - "\nL|bisenet-fp_face: Relatively lightweight NN based mask that provides more " - "refined control over the area to be masked (configurable in mask settings). " - "Use this version of bisenet-fp if your model is trained with 'face' or " - "'legacy' centering." - "\nL|bisenet-fp_head: Relatively lightweight NN based mask that provides more " - "refined control over the area to be masked (configurable in mask settings). " - "Use this version of bisenet-fp if your model is trained with 'head' centering." - "\nL|custom_face: Custom user created, face centered mask." - "\nL|custom_head: Custom user created, head centered mask." - "\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|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." - "\nL|predicted: If the 'Learn Mask' option was enabled during training, this " - "will use the mask that was created by the trained model."))) - argument_list.append(dict( - opts=("-w", "--writer"), - action=Radio, - type=str, - default="opencv", - choices=PluginLoader.get_available_convert_plugins("writer", False), - group=_("Plugins"), - help=_("R|The plugin to use to output the converted images. The writers are " - "configurable in '/config/convert.ini' or 'Settings > Configure Convert " - "Plugins:'" - "\nL|ffmpeg: [video] Writes out the convert straight to video. When the input " - "is a series of images then the '-ref' (--reference-video) parameter must be " - "set." - "\nL|gif: [animated image] Create an animated gif." - "\nL|opencv: [images] The fastest image writer, but less options and formats " - "than other plugins." - "\nL|patch: [images] Outputs the raw swapped face patch, along with the " - "transformation matrix required to re-insert the face back into the original " - "frame. Use this option if you wish to post-process and composite the final " - "face within external tools." - "\nL|pillow: [images] Slower than opencv, but has more options and supports " - "more formats."))) - argument_list.append(dict( - opts=("-osc", "--output-scale"), - action=Slider, - min_max=(25, 400), - rounding=1, - type=int, - dest="output_scale", - default=100, - group=_("Frame Processing"), - help=_("Scale the final output frames by this amount. 100%% will output the frames " - "at source dimensions. 50%% at half size 200%% at double size"))) - argument_list.append(dict( - opts=("-fr", "--frame-ranges"), - type=str, - nargs="+", - group=_("Frame Processing"), - help=_("Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " - "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " - "will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " - "converting from images, then the filenames must end with the frame-number!"))) - argument_list.append(dict( - opts=("-S", "--face-scale"), - action=Slider, - min_max=(-10.0, 10.0), - rounding=2, - dest="face_scale", - type=float, - default=0.0, - group=_("Face Processing"), - help=_("Scale the swapped face by this percentage. Positive values will enlarge the " - "face, Negative values will shrink the face."))) - argument_list.append(dict( - opts=("-a", "--input-aligned-dir"), - action=DirFullPaths, - dest="input_aligned_dir", - default=None, - group=_("Face Processing"), - help=_("If you have not cleansed your alignments file, then you can filter out faces " - "by defining a folder here that contains the faces extracted from your input " - "files/video. If this folder is defined, then only faces that exist within " - "your alignments file and also exist within the specified folder will be " - "converted. Leaving this blank will convert all faces that exist within the " - "alignments file."))) - argument_list.append(dict( - opts=("-n", "--nfilter"), - action=FilesFullPaths, - filetypes="image", - dest="nfilter", - default=None, - nargs="+", - group=_("Face Processing"), - help=_("Optionally filter out people who you do not wish to process by passing in an " - "image of that person. Should be a front portrait with a single person in the " - "image. Multiple images can be added space separated. NB: Using face filter " - "will significantly decrease extraction speed and its accuracy cannot be " - "guaranteed."))) - argument_list.append(dict( - opts=("-f", "--filter"), - action=FilesFullPaths, - filetypes="image", - dest="filter", - default=None, - nargs="+", - group=_("Face Processing"), - help=_("Optionally select people you wish to process by passing in an image of that " - "person. Should be a front portrait with a single person in the image. " - "Multiple images can be added space separated. NB: Using face filter will " - "significantly decrease extraction speed and its accuracy cannot be " - "guaranteed."))) - argument_list.append(dict( - opts=("-l", "--ref_threshold"), - action=Slider, - min_max=(0.01, 0.99), - rounding=2, - type=float, - dest="ref_threshold", - default=0.4, - group=_("Face Processing"), - help=_("For use with the optional nfilter/filter files. Threshold for positive face " - "recognition. Lower values are stricter. NB: Using face filter will " - "significantly decrease extraction speed and its accuracy cannot be " - "guaranteed."))) - argument_list.append(dict( - opts=("-j", "--jobs"), - action=Slider, - min_max=(0, 40), - rounding=1, - type=int, - dest="jobs", - default=0, - group=_("settings"), - help=_("The maximum number of parallel processes for performing conversion. " - "Converting images is system RAM heavy so it is possible to run out of memory " - "if you have a lot of processes and not enough RAM to accommodate them all. " - "Setting this to 0 will use the maximum available. No matter what you set " - "this to, it will never attempt to use more processes than are available on " - "your system. If singleprocess is enabled this setting will be ignored."))) - argument_list.append(dict( - opts=("-t", "--trainer"), - type=str.lower, - choices=PluginLoader.get_available_models(), - group=_("settings"), - help=_("[LEGACY] This only needs to be selected if a legacy model is being loaded or " - "if there are multiple models in the model folder"))) - argument_list.append(dict( - opts=("-otf", "--on-the-fly"), - action="store_true", - dest="on_the_fly", - default=False, - group=_("settings"), - help=_("Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " - "alignments file for your destination video. However, if you wish you can " - "generate the alignments on-the-fly by enabling this option. This will use " - "an inferior extraction pipeline and will lead to substandard results. If an " - "alignments file is found, this option will be ignored."))) - argument_list.append(dict( - opts=("-k", "--keep-unchanged"), - action="store_true", - dest="keep_unchanged", - default=False, - group=_("Frame Processing"), - help=_("When used with --frame-ranges outputs the unchanged frames that are not " - "processed instead of discarding them."))) - argument_list.append(dict( - opts=("-s", "--swap-model"), - action="store_true", - dest="swap_model", - default=False, - group=_("settings"), - help=_("Swap the model. Instead converting from of A -> B, converts B -> A"))) - argument_list.append(dict( - opts=("-sp", "--singleprocess"), - action="store_true", - default=False, - group=_("settings"), - help=_("Disable multiprocessing. Slower but less resource intensive."))) - return argument_list - - -class TrainArgs(FaceSwapArgs): - """ Creates the command line arguments for training. """ - - @staticmethod - def get_info() -> str: - """ The information text for the Train command. - - Returns - ------- - str - The information text for the Train command. - """ - return _("Train a model on extracted original (A) and swap (B) faces.\n" - "Training models can take a long time. Anything from 24hrs to over a week\n" - "Model plugins can be configured in the 'Settings' Menu") - - @staticmethod - def get_argument_list() -> list[dict[str, T.Any]]: - """ Returns the argument list for Train arguments. - - Returns - ------- - list - The list of command line options for training - """ - argument_list: list[dict[str, T.Any]] = [] - argument_list.append(dict( - opts=("-A", "--input-A"), - action=DirFullPaths, - dest="input_a", - required=True, - group=_("faces"), - help=_("Input directory. A directory containing training images for face A. This is " - "the original face, i.e. the face that you want to remove and replace with " - "face B."))) - argument_list.append(dict( - opts=("-B", "--input-B"), - action=DirFullPaths, - dest="input_b", - required=True, - group=_("faces"), - help=_("Input directory. A directory containing training images for face B. This is " - "the swap face, i.e. the face that you want to place onto the head of person " - "A."))) - argument_list.append(dict( - opts=("-m", "--model-dir"), - action=DirFullPaths, - dest="model_dir", - required=True, - group=_("model"), - help=_("Model directory. This is where the training data will be stored. You should " - "always specify a new folder for new models. If starting a new model, select " - "either an empty folder, or a folder which does not exist (which will be " - "created). If continuing to train an existing model, specify the location of " - "the existing model."))) - argument_list.append(dict( - opts=("-l", "--load-weights"), - action=FileFullPaths, - filetypes="model", - dest="load_weights", - required=False, - group=_("model"), - help=_("R|Load the weights from a pre-existing model into a newly created model. " - "For most models this will load weights from the Encoder of the given model " - "into the encoder of the newly created model. Some plugins may have specific " - "configuration options allowing you to load weights from other layers. Weights " - "will only be loaded when creating a new model. This option will be ignored if " - "you are resuming an existing model. Generally you will also want to 'freeze-" - "weights' whilst the rest of your model catches up with your Encoder.\n" - "NB: Weights can only be loaded from models of the same plugin as you intend " - "to train."))) - argument_list.append(dict( - opts=("-t", "--trainer"), - action=Radio, - type=str.lower, - default=PluginLoader.get_default_model(), - choices=PluginLoader.get_available_models(), - group=_("model"), - help=_("R|Select which trainer to use. Trainers can be configured from the Settings " - "menu or the config folder." - "\nL|original: The original model created by /u/deepfakes." - "\nL|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " - "for full dfaker method." - "\nL|dfl-h128: 128px in/out model from deepfacelab" - "\nL|dfl-sae: Adaptable model from deepfacelab" - "\nL|dlight: A lightweight, high resolution DFaker variant." - "\nL|iae: A model that uses intermediate layers to try to get better details" - "\nL|lightweight: A lightweight model for low-end cards. Don't expect great " - "results. Can train as low as 1.6GB with batch size 8." - "\nL|realface: A high detail, dual density model based on DFaker, with " - "customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " - "won't work so well. By andenixa et al. Very configurable." - "\nL|unbalanced: 128px in/out model from andenixa. The autoencoders are " - "unbalanced so B>A swaps won't work so well. Very configurable." - "\nL|villain: 128px in/out model from villainguy. Very resource hungry (You " - "will require a GPU with a fair amount of VRAM). Good for details, but more " - "susceptible to color differences."))) - argument_list.append(dict( - opts=("-su", "--summary"), - action="store_true", - dest="summary", - default=False, - group=_("model"), - help=_("Output a summary of the model and exit. If a model folder is provided then a " - "summary of the saved model is displayed. Otherwise a summary of the model " - "that would be created by the chosen plugin and configuration settings is " - "displayed."))) - argument_list.append(dict( - opts=("-f", "--freeze-weights"), - action="store_true", - dest="freeze_weights", - default=False, - group=_("model"), - help=_("Freeze the weights of the model. Freezing weights means that some of the " - "parameters in the model will no longer continue to learn, but those that are " - "not frozen will continue to learn. For most models, this will freeze the " - "encoder, but some models may have configuration options for freezing other " - "layers."))) - argument_list.append(dict( - opts=("-bs", "--batch-size"), - action=Slider, - min_max=(1, 256), - rounding=1, - type=int, - dest="batch_size", - default=16, - group=_("training"), - help=_("Batch size. This is the number of images processed through the model for each " - "side per iteration. NB: As the model is fed 2 sides at a time, the actual " - "number of images within the model at any one time is double the number that " - "you set here. Larger batches require more GPU RAM."))) - argument_list.append(dict( - opts=("-it", "--iterations"), - action=Slider, - min_max=(0, 5000000), - rounding=20000, - type=int, - default=1000000, - group=_("training"), - help=_("Length of training in iterations. This is only really used for automation. " - "There is no 'correct' number of iterations a model should be trained for. " - "You should stop training when you are happy with the previews. However, if " - "you want the model to stop automatically at a set number of iterations, you " - "can set that value here."))) - argument_list.append(dict( - opts=("-D", "--distribution-strategy"), - dest="distribution_strategy", - action=Radio, - type=str.lower, - choices=["default", "central-storage", "mirrored"], - default="default", - backend=("nvidia", "directml", "rocm", "apple_silicon"), - group=_("training"), - help=_("R|Select the distribution stategy to use." - "\nL|default: Use Tensorflow's default distribution strategy." - "\nL|central-storage: Centralizes variables on the CPU whilst operations are " - "performed on 1 or more local GPUs. This can help save some VRAM at the cost " - "of some speed by not storing variables on the GPU. Note: Mixed-Precision is " - "not supported on multi-GPU setups." - "\nL|mirrored: Supports synchronous distributed training across multiple local " - "GPUs. A copy of the model and all variables are loaded onto each GPU with " - "batches distributed to each GPU at each iteration."))) - argument_list.append(dict( - opts=("-nl", "--no-logs"), - action="store_true", - dest="no_logs", - default=False, - group=_("training"), - help=_("Disables TensorBoard logging. NB: Disabling logs means that you will not be " - "able to use the graph or analysis for this session in the GUI."))) - argument_list.append(dict( - opts=("-r", "--use-lr-finder"), - action="store_true", - dest="use_lr_finder", - default=False, - group=_("training"), - help=_("Use the Learning Rate Finder to discover the optimal learning rate for " - "training. For new models, this will calculate the optimal learning rate for " - "the model. For existing models this will use the optimal learning rate that " - "was discovered when initializing the model. Setting this option will ignore " - "the manually configured learning rate (configurable in train settings)."))) - argument_list.append(dict( - opts=("-s", "--save-interval"), - action=Slider, - min_max=(10, 1000), - rounding=10, - type=int, - dest="save_interval", - default=250, - group=_("Saving"), - help=_("Sets the number of iterations between each model save."))) - argument_list.append(dict( - opts=("-ss", "--snapshot-interval"), - action=Slider, - min_max=(0, 100000), - rounding=5000, - type=int, - dest="snapshot_interval", - default=25000, - group=_("Saving"), - help=_("Sets the number of iterations before saving a backup snapshot of the model " - "in it's current state. Set to 0 for off."))) - argument_list.append(dict( - opts=("-tia", "--timelapse-input-A"), - action=DirFullPaths, - dest="timelapse_input_a", - default=None, - group=_("timelapse"), - help=_("Optional for creating a timelapse. Timelapse will save an image of your " - "selected faces into the timelapse-output folder at every save iteration. " - "This should be the input folder of 'A' faces that you would like to use for " - "creating the timelapse. You must also supply a --timelapse-output and a " - "--timelapse-input-B parameter."))) - argument_list.append(dict( - opts=("-tib", "--timelapse-input-B"), - action=DirFullPaths, - dest="timelapse_input_b", - default=None, - group=_("timelapse"), - help=_("Optional for creating a timelapse. Timelapse will save an image of your " - "selected faces into the timelapse-output folder at every save iteration. " - "This should be the input folder of 'B' faces that you would like to use for " - "creating the timelapse. You must also supply a --timelapse-output and a " - "--timelapse-input-A parameter."))) - argument_list.append(dict( - opts=("-to", "--timelapse-output"), - action=DirFullPaths, - dest="timelapse_output", - default=None, - group=_("timelapse"), - help=_("Optional for creating a timelapse. Timelapse will save an image of your " - "selected faces into the timelapse-output folder at every save iteration. If " - "the input folders are supplied but no output folder, it will default to your " - "model folder /timelapse/"))) - argument_list.append(dict( - opts=("-p", "--preview"), - action="store_true", - dest="preview", - default=False, - group=_("preview"), - help=_("Show training preview output. in a separate window."))) - argument_list.append(dict( - opts=("-w", "--write-image"), - action="store_true", - dest="write_image", - default=False, - group=_("preview"), - help=_("Writes the training result to a file. The image will be stored in the root " - "of your FaceSwap folder."))) - argument_list.append(dict( - opts=("-wl", "--warp-to-landmarks"), - action="store_true", - dest="warp_to_landmarks", - default=False, - group=_("augmentation"), - help=_("Warps training faces to closely matched Landmarks from the opposite face-set " - "rather than randomly warping the face. This is the 'dfaker' way of doing " - "warping."))) - argument_list.append(dict( - opts=("-nf", "--no-flip"), - action="store_true", - dest="no_flip", - default=False, - group=_("augmentation"), - help=_("To effectively learn, a random set of images are flipped horizontally. " - "Sometimes it is desirable for this not to occur. Generally this should be " - "left off except for during 'fit training'."))) - argument_list.append(dict( - opts=("-nac", "--no-augment-color"), - action="store_true", - dest="no_augment_color", - default=False, - group=_("augmentation"), - help=_("Color augmentation helps make the model less susceptible to color " - "differences between the A and B sets, at an increased training time cost. " - "Enable this option to disable color augmentation."))) - argument_list.append(dict( - opts=("-nw", "--no-warp"), - action="store_true", - dest="no_warp", - default=False, - group=_("augmentation"), - help=_("Warping is integral to training the Neural Network. This option should only " - "be enabled towards the very end of training to try to bring out more detail. " - "Think of it as 'fine-tuning'. Enabling this option from the beginning is " - "likely to kill a model and lead to terrible results."))) - return argument_list - - class GuiArgs(FaceSwapArgs): """ Creates the command line arguments for the GUI. """ @@ -1214,10 +306,10 @@ def get_argument_list() -> list[dict[str, T.Any]]: The list of command line options for the GUI """ argument_list: list[dict[str, T.Any]] = [] - argument_list.append(dict( - opts=("-d", "--debug"), - action="store_true", - dest="debug", - default=False, - help=_("Output to Shell console instead of GUI console"))) + argument_list.append({ + "opts": ("-d", "--debug"), + "action": "store_true", + "dest": "debug", + "default": False, + "help": _("Output to Shell console instead of GUI console")}) return argument_list diff --git a/lib/cli/args_extract_convert.py b/lib/cli/args_extract_convert.py new file mode 100644 index 0000000000..ed58a53e29 --- /dev/null +++ b/lib/cli/args_extract_convert.py @@ -0,0 +1,743 @@ +#!/usr/bin/env python3 +""" The Command Line Argument options for extracting and converting with faceswap.py """ +import argparse +import gettext +import typing as T + +from lib.utils import get_backend +from plugins.plugin_loader import PluginLoader + +from .actions import (DirFullPaths, DirOrFileFullPaths, DirOrFilesFullPaths, FileFullPaths, + FilesFullPaths, MultiOption, Radio, Slider) +from .args import FaceSwapArgs + + +# LOCALES +_LANG = gettext.translation("lib.cli.args_extract_convert", localedir="locales", fallback=True) +_ = _LANG.gettext + + +class ExtractConvertArgs(FaceSwapArgs): + """ Parent class to capture arguments that will be used in both extract and convert processes. + + Extract and Convert share a fair amount of arguments, so arguments that can be used in both of + these processes should be placed here. + + No further processing is done in this class (this is handled by the children), this just + captures the shared arguments. + """ + + @staticmethod + def get_argument_list() -> list[dict[str, T.Any]]: + """ Returns the argument list for shared Extract and Convert arguments. + + Returns + ------- + list + The list of command line options for the given Extract and Convert + """ + argument_list: list[dict[str, T.Any]] = [] + argument_list.append({ + "opts": ("-i", "--input-dir"), + "action": DirOrFileFullPaths, + "filetypes": "video", + "dest": "input_dir", + "required": True, + "group": _("Data"), + "help": _( + "Input directory or video. Either a directory containing the image files you wish " + "to process or path to a video file. NB: This should be the source video/frames " + "NOT the source faces.")}) + argument_list.append({ + "opts": ("-o", "--output-dir"), + "action": DirFullPaths, + "dest": "output_dir", + "required": True, + "group": _("Data"), + "help": _("Output directory. This is where the converted files will be saved.")}) + argument_list.append({ + "opts": ("-p", "--alignments"), + "action": FileFullPaths, + "filetypes": "alignments", + "type": str, + "dest": "alignments_path", + "group": _("Data"), + "help": _( + "Optional path to an alignments file. Leave blank if the alignments file is at " + "the default location.")}) + # Deprecated multi-character switches + argument_list.append({ + "opts": ("-al", ), + "action": FileFullPaths, + "filetypes": "alignments", + "type": str, + "dest": "depr_alignments_path_al_p", + "help": argparse.SUPPRESS}) + return argument_list + + +class ExtractArgs(ExtractConvertArgs): + """ Creates the command line arguments for extraction. + + This class inherits base options from :class:`ExtractConvertArgs` where arguments that are used + for both Extract and Convert should be placed. + + Commands explicit to Extract should be added in :func:`get_optional_arguments` + """ + + @staticmethod + def get_info() -> str: + """ The information text for the Extract command. + + Returns + ------- + str + The information text for the Extract command. + """ + return _("Extract faces from image or video sources.\n" + "Extraction plugins can be configured in the 'Settings' Menu") + + @staticmethod + def get_optional_arguments() -> list[dict[str, T.Any]]: + """ Returns the argument list unique to the Extract command. + + Returns + ------- + list + The list of optional command line options for the Extract command + """ + if get_backend() == "cpu": + default_detector = "mtcnn" + default_aligner = "cv2-dnn" + else: + default_detector = "s3fd" + default_aligner = "fan" + + argument_list: list[dict[str, T.Any]] = [] + argument_list.append({ + "opts": ("-b", "--batch-mode"), + "action": "store_true", + "dest": "batch_mode", + "default": False, + "group": _("Data"), + "help": _( + "R|If selected then the input_dir should be a parent folder containing multiple " + "videos and/or folders of images you wish to extract from. The faces will be " + "output to separate sub-folders in the output_dir.")}) + argument_list.append({ + "opts": ("-D", "--detector"), + "action": Radio, + "type": str.lower, + "default": default_detector, + "choices": PluginLoader.get_available_extractors("detect"), + "group": _("Plugins"), + "help": _( + "R|Detector to use. Some of these have configurable settings in " + "'/config/extract.ini' or 'Settings > Configure Extract 'Plugins':" + "\nL|cv2-dnn: A CPU only extractor which is the least reliable and least resource " + "intensive. Use this if not using a GPU and time is important." + "\nL|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources than " + "other GPU detectors but can often return more false positives." + "\nL|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " + "fewer false positives than other GPU detectors, but is a lot more resource " + "intensive.")}) + argument_list.append({ + "opts": ("-A", "--aligner"), + "action": Radio, + "type": str.lower, + "default": default_aligner, + "choices": PluginLoader.get_available_extractors("align"), + "group": _("Plugins"), + "help": _( + "R|Aligner to use." + "\nL|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, but " + "less accurate. Only use this if not using a GPU and time is important." + "\nL|fan: Best aligner. Fast on GPU, slow on CPU.")}) + argument_list.append({ + "opts": ("-M", "--masker"), + "action": MultiOption, + "type": str.lower, + "nargs": "+", + "choices": [mask for mask in PluginLoader.get_available_extractors("mask") + if mask not in ("components", "extended")], + "group": _("Plugins"), + "help": _( + "R|Additional Masker(s) to use. The masks generated here will all take up GPU " + "RAM. You can select none, one or multiple masks, but the extraction may take " + "longer the more you select. NB: The Extended and Components (landmark based) " + "masks are automatically generated on extraction." + "\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|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 so " + "will not use any additional VRAM." + "\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." + "\nThe auto generated masks are as follows:" + "\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|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." + "\n(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)")}) + argument_list.append({ + "opts": ("-O", "--normalization"), + "action": Radio, + "type": str.lower, + "dest": "normalization", + "default": "none", + "choices": ["none", "clahe", "hist", "mean"], + "group": _("Plugins"), + "help": _( + "R|Performing normalization can help the aligner better align faces with " + "difficult lighting conditions at an extraction speed cost. Different methods " + "will yield different results on different sets. NB: This does not impact the " + "output face, just the input to the aligner." + "\nL|none: Don't perform normalization on the face." + "\nL|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the face." + "\nL|hist: Equalize the histograms on the RGB channels." + "\nL|mean: Normalize the face colors to the mean.")}) + argument_list.append({ + "opts": ("-R", "--re-feed"), + "action": Slider, + "min_max": (0, 10), + "rounding": 1, + "type": int, + "dest": "re_feed", + "default": 0, + "group": _("Plugins"), + "help": _( + "The number of times to re-feed the detected face into the aligner. Each time the " + "face is re-fed into the aligner the bounding box is adjusted by a small amount. " + "The final landmarks are then averaged from each iteration. Helps to remove " + "'micro-jitter' but at the cost of slower extraction speed. The more times the " + "face is re-fed into the aligner, the less micro-jitter should occur but the " + "longer extraction will take.")}) + argument_list.append({ + "opts": ("-a", "--re-align"), + "action": "store_true", + "dest": "re_align", + "default": False, + "group": _("Plugins"), + "help": _( + "Re-feed the initially found aligned face through the aligner. Can help produce " + "better alignments for faces that are rotated beyond 45 degrees in the frame or " + "are at extreme angles. Slows down extraction.")}) + argument_list.append({ + "opts": ("-r", "--rotate-images"), + "type": str, + "dest": "rotate_images", + "default": None, + "group": _("Plugins"), + "help": _( + "If a face isn't found, rotate the images to try to find a face. Can find more " + "faces at the cost of extraction speed. Pass in a single number to use increments " + "of that size up to 360, or pass in a list of numbers to enumerate exactly what " + "angles to check.")}) + argument_list.append({ + "opts": ("-I", "--identity"), + "action": "store_true", + "default": False, + "group": _("Plugins"), + "help": _( + "Obtain and store face identity encodings from VGGFace2. Slows down extract a " + "little, but will save time if using 'sort by face'")}) + argument_list.append({ + "opts": ("-m", "--min-size"), + "action": Slider, + "min_max": (0, 1080), + "rounding": 20, + "type": int, + "dest": "min_size", + "default": 0, + "group": _("Face Processing"), + "help": _( + "Filters out faces detected below this size. Length, in pixels across the " + "diagonal of the bounding box. Set to 0 for off")}) + argument_list.append({ + "opts": ("-n", "--nfilter"), + "action": DirOrFilesFullPaths, + "filetypes": "image", + "dest": "nfilter", + "default": None, + "nargs": "+", + "group": _("Face Processing"), + "help": _( + "Optionally filter out people who you do not wish to extract by passing in images " + "of those people. Should be a small variety of images at different angles and in " + "different conditions. A folder containing the required images or multiple image " + "files, space separated, can be selected.")}) + argument_list.append({ + "opts": ("-f", "--filter"), + "action": DirOrFilesFullPaths, + "filetypes": "image", + "dest": "filter", + "default": None, + "nargs": "+", + "group": _("Face Processing"), + "help": _( + "Optionally select people you wish to extract by passing in images of that " + "person. Should be a small variety of images at different angles and in different " + "conditions A folder containing the required images or multiple image files, " + "space separated, can be selected.")}) + argument_list.append({ + "opts": ("-l", "--ref_threshold"), + "action": Slider, + "min_max": (0.01, 0.99), + "rounding": 2, + "type": float, + "dest": "ref_threshold", + "default": 0.60, + "group": _("Face Processing"), + "help": _( + "For use with the optional nfilter/filter files. Threshold for positive face " + "recognition. Higher values are stricter.")}) + argument_list.append({ + "opts": ("-z", "--size"), + "action": Slider, + "min_max": (256, 1024), + "rounding": 64, + "type": int, + "default": 512, + "group": _("output"), + "help": _( + "The output size of extracted faces. Make sure that the model you intend to train " + "supports your required size. This will only need to be changed for hi-res " + "models.")}) + argument_list.append({ + "opts": ("-N", "--extract-every-n"), + "action": Slider, + "min_max": (1, 100), + "rounding": 1, + "type": int, + "dest": "extract_every_n", + "default": 1, + "group": _("output"), + "help": _( + "Extract every 'nth' frame. This option will skip frames when extracting faces. " + "For example a value of 1 will extract faces from every frame, a value of 10 will " + "extract faces from every 10th frame.")}) + argument_list.append({ + "opts": ("-v", "--save-interval"), + "action": Slider, + "min_max": (0, 1000), + "rounding": 10, + "type": int, + "dest": "save_interval", + "default": 0, + "group": _("output"), + "help": _( + "Automatically save the alignments file after a set amount of frames. By default " + "the alignments file is only saved at the end of the extraction process. NB: If " + "extracting in 2 passes then the alignments file will only start to be saved out " + "during the second pass. WARNING: Don't interrupt the script when writing the " + "file because it might get corrupted. Set to 0 to turn off")}) + argument_list.append({ + "opts": ("-B", "--debug-landmarks"), + "action": "store_true", + "dest": "debug_landmarks", + "default": False, + "group": _("output"), + "help": _("Draw landmarks on the ouput faces for debugging purposes.")}) + argument_list.append({ + "opts": ("-P", "--singleprocess"), + "action": "store_true", + "default": False, + "backend": ("nvidia", "directml", "rocm", "apple_silicon"), + "group": _("settings"), + "help": _( + "Don't run extraction in parallel. Will run each part of the extraction process " + "separately (one after the other) rather than all at the same time. Useful if " + "VRAM is at a premium.")}) + argument_list.append({ + "opts": ("-s", "--skip-existing"), + "action": "store_true", + "dest": "skip_existing", + "default": False, + "group": _("settings"), + "help": _( + "Skips frames that have already been extracted and exist in the alignments file")}) + argument_list.append({ + "opts": ("-e", "--skip-existing-faces"), + "action": "store_true", + "dest": "skip_faces", + "default": False, + "group": _("settings"), + "help": _("Skip frames that already have detected faces in the alignments file")}) + argument_list.append({ + "opts": ("-K", "--skip-saving-faces"), + "action": "store_true", + "dest": "skip_saving_faces", + "default": False, + "group": _("settings"), + "help": _("Skip saving the detected faces to disk. Just create an alignments file")}) + # Deprecated multi-character switches + argument_list.append({ + "opts": ("-min", ), + "type": int, + "dest": "depr_min_size_min_m", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-een", ), + "type": int, + "dest": "depr_extract_every_n_een_N", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-nm",), + "type": str.lower, + "dest": "depr_normalization_nm_O", + "choices": ["none", "clahe", "hist", "mean"], + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-rf", ), + "type": int, + "dest": "depr_re_feed_rf_R", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-sz", ), + "type": int, + "dest": "depr_size_sz_z", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-si", ), + "type": int, + "dest": "depr_save_interval_si_v", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-dl", ), + "action": "store_true", + "dest": "depr_debug_landmarks_dl_B", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-sp", ), + "dest": "depr_singleprocess_sp_P", + "action": "store_true", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-sf", ), + "action": "store_true", + "dest": "depr_skip_faces_sf_e", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-ssf", ), + "action": "store_true", + "dest": "depr_skip_saving_faces_ssf_K", + "help": argparse.SUPPRESS}) + return argument_list + + +class ConvertArgs(ExtractConvertArgs): + """ Creates the command line arguments for conversion. + + This class inherits base options from :class:`ExtractConvertArgs` where arguments that are used + for both Extract and Convert should be placed. + + Commands explicit to Convert should be added in :func:`get_optional_arguments` + """ + + @staticmethod + def get_info() -> str: + """ The information text for the Convert command. + + Returns + ------- + str + The information text for the Convert command. + """ + return _("Swap the original faces in a source video/images to your final faces.\n" + "Conversion plugins can be configured in the 'Settings' Menu") + + @staticmethod + def get_optional_arguments() -> list[dict[str, T.Any]]: + """ Returns the argument list unique to the Convert command. + + Returns + ------- + list + The list of optional command line options for the Convert command + """ + + argument_list: list[dict[str, T.Any]] = [] + argument_list.append({ + "opts": ("-r", "--reference-video"), + "action": FileFullPaths, + "filetypes": "video", + "type": str, + "dest": "reference_video", + "group": _("Data"), + "help": _( + "Only required if converting from images to video. Provide The original video " + "that the source frames were extracted from (for extracting the fps and audio).")}) + argument_list.append({ + "opts": ("-m", "--model-dir"), + "action": DirFullPaths, + "dest": "model_dir", + "required": True, + "group": _("Data"), + "help": _( + "Model directory. The directory containing the trained model you wish to use for " + "conversion.")}) + argument_list.append({ + "opts": ("-c", "--color-adjustment"), + "action": Radio, + "type": str.lower, + "dest": "color_adjustment", + "default": "avg-color", + "choices": PluginLoader.get_available_convert_plugins("color", True), + "group": _("Plugins"), + "help": _( + "R|Performs color adjustment to the swapped face. Some of these options have " + "configurable settings in '/config/convert.ini' or 'Settings > Configure Convert " + "Plugins':" + "\nL|avg-color: Adjust the mean of each color channel in the swapped " + "reconstruction to equal the mean of the masked area in the original image." + "\nL|color-transfer: Transfers the color distribution from the source to the " + "target image using the mean and standard deviations of the L*a*b* color space." + "\nL|manual-balance: Manually adjust the balance of the image in a variety of " + "color spaces. Best used with the Preview tool to set correct values." + "\nL|match-hist: Adjust the histogram of each color channel in the swapped " + "reconstruction to equal the histogram of the masked area in the original image." + "\nL|seamless-clone: Use cv2's seamless clone function to remove extreme " + "gradients at the mask seam by smoothing colors. Generally does not give very " + "satisfactory results." + "\nL|none: Don't perform color adjustment.")}) + argument_list.append({ + "opts": ("-M", "--mask-type"), + "action": Radio, + "type": str.lower, + "dest": "mask_type", + "default": "extended", + "choices": PluginLoader.get_available_extractors("mask", + add_none=True, + extend_plugin=True) + ["predicted"], + "group": _("Plugins"), + "help": _( + "R|Masker to use. NB: The mask you require must exist within the alignments file. " + "You can add additional masks with the Mask Tool." + "\nL|none: Don't use a mask." + "\nL|bisenet-fp_face: Relatively lightweight NN based mask that provides more " + "refined control over the area to be masked (configurable in mask settings). Use " + "this version of bisenet-fp if your model is trained with 'face' or " + "'legacy' centering." + "\nL|bisenet-fp_head: Relatively lightweight NN based mask that provides more " + "refined control over the area to be masked (configurable in mask settings). Use " + "this version of bisenet-fp if your model is trained with 'head' centering." + "\nL|custom_face: Custom user created, face centered mask." + "\nL|custom_head: Custom user created, head centered mask." + "\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|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." + "\nL|predicted: If the 'Learn Mask' option was enabled during training, this will " + "use the mask that was created by the trained model.")}) + argument_list.append({ + "opts": ("-w", "--writer"), + "action": Radio, + "type": str, + "default": "opencv", + "choices": PluginLoader.get_available_convert_plugins("writer", False), + "group": _("Plugins"), + "help": _( + "R|The plugin to use to output the converted images. The writers are configurable " + "in '/config/convert.ini' or 'Settings > Configure Convert Plugins:'" + "\nL|ffmpeg: [video] Writes out the convert straight to video. When the input is " + "a series of images then the '-ref' (--reference-video) parameter must be set." + "\nL|gif: [animated image] Create an animated gif." + "\nL|opencv: [images] The fastest image writer, but less options and formats than " + "other plugins." + "\nL|patch: [images] Outputs the raw swapped face patch, along with the " + "transformation matrix required to re-insert the face back into the original " + "frame. Use this option if you wish to post-process and composite the final face " + "within external tools." + "\nL|pillow: [images] Slower than opencv, but has more options and supports more " + "formats.")}) + argument_list.append({ + "opts": ("-O", "--output-scale"), + "action": Slider, + "min_max": (25, 400), + "rounding": 1, + "type": int, + "dest": "output_scale", + "default": 100, + "group": _("Frame Processing"), + "help": _( + "Scale the final output frames by this amount. 100%% will output the frames at " + "source dimensions. 50%% at half size 200%% at double size")}) + argument_list.append({ + "opts": ("-R", "--frame-ranges"), + "type": str, + "nargs": "+", + "dest": "frame_ranges", + "group": _("Frame Processing"), + "help": _( + "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " + "--frame-ranges 10-50 90-100. Frames falling outside of the selected range will " + "be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " + "converting from images, then the filenames must end with the frame-number!")}) + argument_list.append({ + "opts": ("-S", "--face-scale"), + "action": Slider, + "min_max": (-10.0, 10.0), + "rounding": 2, + "dest": "face_scale", + "type": float, + "default": 0.0, + "group": _("Face Processing"), + "help": _( + "Scale the swapped face by this percentage. Positive values will enlarge the " + "face, Negative values will shrink the face.")}) + argument_list.append({ + "opts": ("-a", "--input-aligned-dir"), + "action": DirFullPaths, + "dest": "input_aligned_dir", + "default": None, + "group": _("Face Processing"), + "help": _( + "If you have not cleansed your alignments file, then you can filter out faces by " + "defining a folder here that contains the faces extracted from your input files/" + "video. If this folder is defined, then only faces that exist within your " + "alignments file and also exist within the specified folder will be converted. " + "Leaving this blank will convert all faces that exist within the alignments " + "file.")}) + argument_list.append({ + "opts": ("-n", "--nfilter"), + "action": FilesFullPaths, + "filetypes": "image", + "dest": "nfilter", + "default": None, + "nargs": "+", + "group": _("Face Processing"), + "help": _( + "Optionally filter out people who you do not wish to process by passing in an " + "image of that person. Should be a front portrait with a single person in the " + "image. Multiple images can be added space separated. NB: Using face filter will " + "significantly decrease extraction speed and its accuracy cannot be guaranteed.")}) + argument_list.append({ + "opts": ("-f", "--filter"), + "action": FilesFullPaths, + "filetypes": "image", + "dest": "filter", + "default": None, + "nargs": "+", + "group": _("Face Processing"), + "help": _( + "Optionally select people you wish to process by passing in an image of that " + "person. Should be a front portrait with a single person in the image. Multiple " + "images can be added space separated. NB: Using face filter will significantly " + "decrease extraction speed and its accuracy cannot be guaranteed.")}) + argument_list.append({ + "opts": ("-l", "--ref_threshold"), + "action": Slider, + "min_max": (0.01, 0.99), + "rounding": 2, + "type": float, + "dest": "ref_threshold", + "default": 0.4, + "group": _("Face Processing"), + "help": _( + "For use with the optional nfilter/filter files. Threshold for positive face " + "recognition. Lower values are stricter. NB: Using face filter will significantly " + "decrease extraction speed and its accuracy cannot be guaranteed.")}) + argument_list.append({ + "opts": ("-j", "--jobs"), + "action": Slider, + "min_max": (0, 40), + "rounding": 1, + "type": int, + "dest": "jobs", + "default": 0, + "group": _("settings"), + "help": _( + "The maximum number of parallel processes for performing conversion. Converting " + "images is system RAM heavy so it is possible to run out of memory if you have a " + "lot of processes and not enough RAM to accommodate them all. Setting this to 0 " + "will use the maximum available. No matter what you set this to, it will never " + "attempt to use more processes than are available on your system. If " + "singleprocess is enabled this setting will be ignored.")}) + argument_list.append({ + "opts": ("-T", "--on-the-fly"), + "action": "store_true", + "dest": "on_the_fly", + "default": False, + "group": _("settings"), + "help": _( + "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " + "alignments file for your destination video. However, if you wish you can " + "generate the alignments on-the-fly by enabling this option. This will use an " + "inferior extraction pipeline and will lead to substandard results. If an " + "alignments file is found, this option will be ignored.")}) + argument_list.append({ + "opts": ("-k", "--keep-unchanged"), + "action": "store_true", + "dest": "keep_unchanged", + "default": False, + "group": _("Frame Processing"), + "help": _( + "When used with --frame-ranges outputs the unchanged frames that are not " + "processed instead of discarding them.")}) + argument_list.append({ + "opts": ("-s", "--swap-model"), + "action": "store_true", + "dest": "swap_model", + "default": False, + "group": _("settings"), + "help": _("Swap the model. Instead converting from of A -> B, converts B -> A")}) + argument_list.append({ + "opts": ("-P", "--singleprocess"), + "action": "store_true", + "default": False, + "group": _("settings"), + "help": _("Disable multiprocessing. Slower but less resource intensive.")}) + # Deprecated multi-character switches + argument_list.append({ + "opts": ("-sp", ), + "action": "store_true", + "dest": "depr_singleprocess_sp_P", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-ref", ), + "type": str, + "dest": "depr_reference_video_ref_r", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-fr", ), + "type": str, + "nargs": "+", + "dest": "depr_frame_ranges_fr_R", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-osc", ), + "type": int, + "dest": "depr_output_scale_osc_O", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-otf", ), + "action": "store_true", + "dest": "depr_on_the_fly_otf_T", + "help": argparse.SUPPRESS}) + return argument_list diff --git a/lib/cli/args_train.py b/lib/cli/args_train.py new file mode 100644 index 0000000000..2ed2c62c7d --- /dev/null +++ b/lib/cli/args_train.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 +""" The Command Line Argument options for training with faceswap.py """ +import argparse +import gettext +import typing as T + +from plugins.plugin_loader import PluginLoader + +from .actions import DirFullPaths, FileFullPaths, Radio, Slider +from .args import FaceSwapArgs + + +# LOCALES +_LANG = gettext.translation("lib.cli.args_train", localedir="locales", fallback=True) +_ = _LANG.gettext + + +class TrainArgs(FaceSwapArgs): + """ Creates the command line arguments for training. """ + + @staticmethod + def get_info() -> str: + """ The information text for the Train command. + + Returns + ------- + str + The information text for the Train command. + """ + return _("Train a model on extracted original (A) and swap (B) faces.\n" + "Training models can take a long time. Anything from 24hrs to over a week\n" + "Model plugins can be configured in the 'Settings' Menu") + + @staticmethod + def get_argument_list() -> list[dict[str, T.Any]]: + """ Returns the argument list for Train arguments. + + Returns + ------- + list + The list of command line options for training + """ + argument_list: list[dict[str, T.Any]] = [] + argument_list.append({ + "opts": ("-A", "--input-A"), + "action": DirFullPaths, + "dest": "input_a", + "required": True, + "group": _("faces"), + "help": _( + "Input directory. A directory containing training images for face A. This is the " + "original face, i.e. the face that you want to remove and replace with face B.")}) + argument_list.append({ + "opts": ("-B", "--input-B"), + "action": DirFullPaths, + "dest": "input_b", + "required": True, + "group": _("faces"), + "help": _( + "Input directory. A directory containing training images for face B. This is the " + "swap face, i.e. the face that you want to place onto the head of person A.")}) + argument_list.append({ + "opts": ("-m", "--model-dir"), + "action": DirFullPaths, + "dest": "model_dir", + "required": True, + "group": _("model"), + "help": _( + "Model directory. This is where the training data will be stored. You should " + "always specify a new folder for new models. If starting a new model, select " + "either an empty folder, or a folder which does not exist (which will be " + "created). If continuing to train an existing model, specify the location of the " + "existing model.")}) + argument_list.append({ + "opts": ("-l", "--load-weights"), + "action": FileFullPaths, + "filetypes": "model", + "dest": "load_weights", + "required": False, + "group": _("model"), + "help": _( + "R|Load the weights from a pre-existing model into a newly created model. For " + "most models this will load weights from the Encoder of the given model into the " + "encoder of the newly created model. Some plugins may have specific configuration " + "options allowing you to load weights from other layers. Weights will only be " + "loaded when creating a new model. This option will be ignored if you are " + "resuming an existing model. Generally you will also want to 'freeze-weights' " + "whilst the rest of your model catches up with your Encoder.\n" + "NB: Weights can only be loaded from models of the same plugin as you intend to " + "train.")}) + argument_list.append({ + "opts": ("-t", "--trainer"), + "action": Radio, + "type": str.lower, + "default": PluginLoader.get_default_model(), + "choices": PluginLoader.get_available_models(), + "group": _("model"), + "help": _( + "R|Select which trainer to use. Trainers can be configured from the Settings menu " + "or the config folder." + "\nL|original: The original model created by /u/deepfakes." + "\nL|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' for " + "full dfaker method." + "\nL|dfl-h128: 128px in/out model from deepfacelab" + "\nL|dfl-sae: Adaptable model from deepfacelab" + "\nL|dlight: A lightweight, high resolution DFaker variant." + "\nL|iae: A model that uses intermediate layers to try to get better details" + "\nL|lightweight: A lightweight model for low-end cards. Don't expect great " + "results. Can train as low as 1.6GB with batch size 8." + "\nL|realface: A high detail, dual density model based on DFaker, with " + "customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " + "won't work so well. By andenixa et al. Very configurable." + "\nL|unbalanced: 128px in/out model from andenixa. The autoencoders are " + "unbalanced so B>A swaps won't work so well. Very configurable." + "\nL|villain: 128px in/out model from villainguy. Very resource hungry (You will " + "require a GPU with a fair amount of VRAM). Good for details, but more " + "susceptible to color differences.")}) + argument_list.append({ + "opts": ("-u", "--summary"), + "action": "store_true", + "dest": "summary", + "default": False, + "group": _("model"), + "help": _( + "Output a summary of the model and exit. If a model folder is provided then a " + "summary of the saved model is displayed. Otherwise a summary of the model that " + "would be created by the chosen plugin and configuration settings is displayed.")}) + argument_list.append({ + "opts": ("-f", "--freeze-weights"), + "action": "store_true", + "dest": "freeze_weights", + "default": False, + "group": _("model"), + "help": _( + "Freeze the weights of the model. Freezing weights means that some of the " + "parameters in the model will no longer continue to learn, but those that are not " + "frozen will continue to learn. For most models, this will freeze the encoder, " + "but some models may have configuration options for freezing other layers.")}) + argument_list.append({ + "opts": ("-b", "--batch-size"), + "action": Slider, + "min_max": (1, 256), + "rounding": 1, + "type": int, + "dest": "batch_size", + "default": 16, + "group": _("training"), + "help": _( + "Batch size. This is the number of images processed through the model for each " + "side per iteration. NB: As the model is fed 2 sides at a time, the actual number " + "of images within the model at any one time is double the number that you set " + "here. Larger batches require more GPU RAM.")}) + argument_list.append({ + "opts": ("-i", "--iterations"), + "action": Slider, + "min_max": (0, 5000000), + "rounding": 20000, + "type": int, + "default": 1000000, + "group": _("training"), + "help": _( + "Length of training in iterations. This is only really used for automation. There " + "is no 'correct' number of iterations a model should be trained for. You should " + "stop training when you are happy with the previews. However, if you want the " + "model to stop automatically at a set number of iterations, you can set that " + "value here.")}) + argument_list.append({ + "opts": ("-D", "--distribution-strategy"), + "dest": "distribution_strategy", + "action": Radio, + "type": str.lower, + "choices": ["default", "central-storage", "mirrored"], + "default": "default", + "backend": ("nvidia", "directml", "rocm", "apple_silicon"), + "group": _("training"), + "help": _( + "R|Select the distribution stategy to use." + "\nL|default: Use Tensorflow's default distribution strategy." + "\nL|central-storage: Centralizes variables on the CPU whilst operations are " + "performed on 1 or more local GPUs. This can help save some VRAM at the cost of " + "some speed by not storing variables on the GPU. Note: Mixed-Precision is not " + "supported on multi-GPU setups." + "\nL|mirrored: Supports synchronous distributed training across multiple local " + "GPUs. A copy of the model and all variables are loaded onto each GPU with " + "batches distributed to each GPU at each iteration.")}) + argument_list.append({ + "opts": ("-n", "--no-logs"), + "action": "store_true", + "dest": "no_logs", + "default": False, + "group": _("training"), + "help": _( + "Disables TensorBoard logging. NB: Disabling logs means that you will not be able " + "to use the graph or analysis for this session in the GUI.")}) + argument_list.append({ + "opts": ("-r", "--use-lr-finder"), + "action": "store_true", + "dest": "use_lr_finder", + "default": False, + "group": _("training"), + "help": _( + "Use the Learning Rate Finder to discover the optimal learning rate for training. " + "For new models, this will calculate the optimal learning rate for the model. For " + "existing models this will use the optimal learning rate that was discovered when " + "initializing the model. Setting this option will ignore the manually configured " + "learning rate (configurable in train settings).")}) + argument_list.append({ + "opts": ("-s", "--save-interval"), + "action": Slider, + "min_max": (10, 1000), + "rounding": 10, + "type": int, + "dest": "save_interval", + "default": 250, + "group": _("Saving"), + "help": _("Sets the number of iterations between each model save.")}) + argument_list.append({ + "opts": ("-I", "--snapshot-interval"), + "action": Slider, + "min_max": (0, 100000), + "rounding": 5000, + "type": int, + "dest": "snapshot_interval", + "default": 25000, + "group": _("Saving"), + "help": _( + "Sets the number of iterations before saving a backup snapshot of the model in " + "it's current state. Set to 0 for off.")}) + argument_list.append({ + "opts": ("-x", "--timelapse-input-A"), + "action": DirFullPaths, + "dest": "timelapse_input_a", + "default": None, + "group": _("timelapse"), + "help": _( + "Optional for creating a timelapse. Timelapse will save an image of your selected " + "faces into the timelapse-output folder at every save iteration. This should be " + "the input folder of 'A' faces that you would like to use for creating the " + "timelapse. You must also supply a --timelapse-output and a --timelapse-input-B " + "parameter.")}) + argument_list.append({ + "opts": ("-y", "--timelapse-input-B"), + "action": DirFullPaths, + "dest": "timelapse_input_b", + "default": None, + "group": _("timelapse"), + "help": _( + "Optional for creating a timelapse. Timelapse will save an image of your selected " + "faces into the timelapse-output folder at every save iteration. This should be " + "the input folder of 'B' faces that you would like to use for creating the " + "timelapse. You must also supply a --timelapse-output and a --timelapse-input-A " + "parameter.")}) + argument_list.append({ + "opts": ("-z", "--timelapse-output"), + "action": DirFullPaths, + "dest": "timelapse_output", + "default": None, + "group": _("timelapse"), + "help": _( + "Optional for creating a timelapse. Timelapse will save an image of your selected " + "faces into the timelapse-output folder at every save iteration. If the input " + "folders are supplied but no output folder, it will default to your model folder/" + "timelapse/")}) + argument_list.append({ + "opts": ("-p", "--preview"), + "action": "store_true", + "dest": "preview", + "default": False, + "group": _("preview"), + "help": _("Show training preview output. in a separate window.")}) + argument_list.append({ + "opts": ("-w", "--write-image"), + "action": "store_true", + "dest": "write_image", + "default": False, + "group": _("preview"), + "help": _( + "Writes the training result to a file. The image will be stored in the root of " + "your FaceSwap folder.")}) + argument_list.append({ + "opts": ("-M", "--warp-to-landmarks"), + "action": "store_true", + "dest": "warp_to_landmarks", + "default": False, + "group": _("augmentation"), + "help": _( + "Warps training faces to closely matched Landmarks from the opposite face-set " + "rather than randomly warping the face. This is the 'dfaker' way of doing " + "warping.")}) + argument_list.append({ + "opts": ("-P", "--no-flip"), + "action": "store_true", + "dest": "no_flip", + "default": False, + "group": _("augmentation"), + "help": _( + "To effectively learn, a random set of images are flipped horizontally. Sometimes " + "it is desirable for this not to occur. Generally this should be left off except " + "for during 'fit training'.")}) + argument_list.append({ + "opts": ("-c", "--no-augment-color"), + "action": "store_true", + "dest": "no_augment_color", + "default": False, + "group": _("augmentation"), + "help": _( + "Color augmentation helps make the model less susceptible to color differences " + "between the A and B sets, at an increased training time cost. Enable this option " + "to disable color augmentation.")}) + argument_list.append({ + "opts": ("-W", "--no-warp"), + "action": "store_true", + "dest": "no_warp", + "default": False, + "group": _("augmentation"), + "help": _( + "Warping is integral to training the Neural Network. This option should only be " + "enabled towards the very end of training to try to bring out more detail. Think " + "of it as 'fine-tuning'. Enabling this option from the beginning is likely to " + "kill a model and lead to terrible results.")}) + # Deprecated multi-character switches + argument_list.append({ + "opts": ("-su", ), + "action": "store_true", + "dest": "depr_summary_su_u", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-bs", ), + "type": int, + "dest": "depr_batch_size_bs_b", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-it", ), + "type": int, + "dest": "depr_iterations_it_i", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-nl", ), + "action": "store_true", + "dest": "depr_no_logs_nl_n", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-ss", ), + "type": int, + "dest": "depr_snapshot_interval_ss_I", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-tia", ), + "type": str, + "dest": "depr_timelapse_input_a_tia_x", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-tib", ), + "type": str, + "dest": "depr_timelapse_input_b_tib_y", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-to", ), + "type": str, + "dest": "depr_timelapse_output_to_z", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-wl", ), + "action": "store_true", + "dest": "depr_warp_to_landmarks_wl_M", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-nf", ), + "action": "store_true", + "dest": "depr_no_flip_nf_P", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-nac", ), + "action": "store_true", + "dest": "depr_no_augment_color_nac_c", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-nw", ), + "action": "store_true", + "dest": "depr_no_warp_nw_W", + "help": argparse.SUPPRESS}) + return argument_list diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 98ef321607..8c15a25815 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -170,7 +170,7 @@ def _test_tkinter(cls) -> None: If tkinter cannot be imported """ try: - import tkinter # noqa pylint: disable=unused-import,import-outside-toplevel + import tkinter # noqa pylint:disable=unused-import,import-outside-toplevel except ImportError as err: logger.error("It looks like TkInter isn't installed for your OS, so the GUI has been " "disabled. To enable the GUI please install the TkInter application. You " diff --git a/lib/gui/command.py b/lib/gui/command.py index 9ac949d9c9..95528be95f 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -9,6 +9,7 @@ from .control_helper import ControlPanel from .custom_widgets import Tooltip from .utils import get_images, get_config +from .options import CliOption logger = logging.getLogger(__name__) @@ -129,7 +130,7 @@ def build_tab(self): """ Build the tab """ logger.debug("Build Tab: '%s'", self.command) options = get_config().cli_opts.opts[self.command] - cp_opts = [val["cpanel_option"] for key, val in options.items() if key != "helptext"] + cp_opts = [val.cpanel_option for val in options.values() if isinstance(val, CliOption)] ControlPanel(self, cp_opts, label_width=16, diff --git a/lib/gui/options.py b/lib/gui/options.py index a60792563a..2579d60233 100644 --- a/lib/gui/options.py +++ b/lib/gui/options.py @@ -1,172 +1,374 @@ #!/usr/bin python3 """ Cli Options for the GUI """ +from __future__ import annotations + import inspect from argparse import SUPPRESS +from dataclasses import dataclass from importlib import import_module import logging import os import re import sys -from collections import OrderedDict +import typing as T -from lib.cli import actions, args as cli +from lib.cli import actions from .utils import get_images from .control_helper import ControlPanelOption +if T.TYPE_CHECKING: + from tkinter import Variable + from types import ModuleType + from lib.cli.args import FaceSwapArgs + logger = logging.getLogger(__name__) +@dataclass +class CliOption: + """ A parsed command line option + + Parameters + ---------- + cpanel_option: :class:`~lib.gui.control_helper.ControlPanelOption`: + Object to hold information of a command line item for displaying in a GUI + :class:`~lib.gui.control_helper.ControlPanel` + opts: tuple[str, ...]: + The short switch and long name (if exists) of the command line option + nargs: Literal["+"] | None: + ``None`` for not used. "+" for at least 1 argument required with values to be contained + in a list + """ + cpanel_option: ControlPanelOption + """:class:`~lib.gui.control_helper.ControlPanelOption`: Object to hold information of a command + line item for displaying in a GUI :class:`~lib.gui.control_helper.ControlPanel`""" + opts: tuple[str, ...] + """tuple[str, ...]: The short switch and long name (if exists) of cli option """ + nargs: T.Literal["+"] | None + """Literal["+"] | None: ``None`` for not used. "+" for at least 1 argument required with + values to be contained in a list """ + + class CliOptions(): """ Class and methods for the command line options """ - def __init__(self): + def __init__(self) -> None: logger.debug("Initializing %s", self.__class__.__name__) - self.categories = ("faceswap", "tools") - self.commands = {} - self.opts = {} - self.build_options() + self._base_path = os.path.realpath(os.path.dirname(sys.argv[0])) + self._commands: dict[T.Literal["faceswap", "tools"], list[str]] = {"faceswap": [], + "tools": []} + self._opts: dict[str, dict[str, CliOption | str]] = {} + self._build_options() logger.debug("Initialized %s", self.__class__.__name__) - def build_options(self): - """ Get the commands that belong to each category """ - for category in self.categories: - logger.debug("Building '%s'", category) - if category == "tools": - mod_classes = self._get_tools_cli_classes() - self.commands[category] = self.sort_commands(category, mod_classes) - for tool in sorted(mod_classes): - self.opts.update(self.extract_options(mod_classes[tool], [tool])) - else: - mod_classes = self.get_cli_classes(cli) - self.commands[category] = self.sort_commands(category, mod_classes) - self.opts.update(self.extract_options(cli, mod_classes)) - logger.debug("Built '%s'", category) + @property + def categories(self) -> tuple[T.Literal["faceswap", "tools"], ...]: + """tuple[str, str] The categories for faceswap's GUI """ + return tuple(self._commands) - @staticmethod - def get_cli_classes(cli_source): - """ Parse the cli scripts for the argument classes """ - mod_classes = [] - for name, obj in inspect.getmembers(cli_source): - if inspect.isclass(obj) and name.lower().endswith("args") \ - and name.lower() not in (("faceswapargs", - "extractconvertargs", - "guiargs")): - mod_classes.append(name) - logger.debug(mod_classes) - return mod_classes - - @staticmethod - def _get_tools_cli_classes(): - """ Parse the tools cli scripts for the argument classes """ - base_path = os.path.realpath(os.path.dirname(sys.argv[0])) - tools_dir = os.path.join(base_path, "tools") - mod_classes = {} + @property + def commands(self) -> dict[T.Literal["faceswap", "tools"], list[str]]: + """dict[str, ]""" + return self._commands + + @property + def opts(self) -> dict[str, dict[str, CliOption | str]]: + """dict[str, dict[str, CliOption | str]] The command line options collected from faceswap's + cli files """ + return self._opts + + def _get_modules_tools(self) -> list[ModuleType]: + """ Parse the tools cli python files for the modules that contain the command line + arguments + + Returns + ------- + list[`types.ModuleType`] + The modules for each faceswap tool that exists in the project + """ + tools_dir = os.path.join(self._base_path, "tools") + logger.debug("Scanning '%s' for cli files", tools_dir) + retval: list[ModuleType] = [] for tool_name in sorted(os.listdir(tools_dir)): cli_file = os.path.join(tools_dir, tool_name, "cli.py") - if os.path.exists(cli_file): - mod = ".".join(("tools", tool_name, "cli")) - mod_classes[f"{tool_name.title()}Args"] = import_module(mod) - return mod_classes - - def sort_commands(self, category, classes): - """ Format classes into command names and sort: - Specific workflow order for faceswap. - Alphabetical for all others """ - commands = sorted(self.format_command_name(command) - for command in classes) + if not os.path.exists(cli_file): + logger.debug("File does not exist. Skipping: '%s'", cli_file) + continue + + mod = ".".join(("tools", tool_name, "cli")) + retval.append(import_module(mod)) + logger.debug("Collected: %s", retval[-1]) + return retval + + def _get_modules_faceswap(self) -> list[ModuleType]: + """ Parse the faceswap cli python files for the modules that contain the command line + arguments + + Returns + ------- + list[`types.ModuleType`] + The modules for each faceswap command line argument file that exists in the project + """ + base_dir = ["lib", "cli"] + cli_dir = os.path.join(self._base_path, *base_dir) + logger.debug("Scanning '%s' for cli files", cli_dir) + retval: list[ModuleType] = [] + + for fname in os.listdir(cli_dir): + if not fname.startswith("args"): + logger.debug("Skipping file '%s'", fname) + continue + mod = ".".join((*base_dir, os.path.splitext(fname)[0])) + retval.append(import_module(mod)) + logger.debug("Collected: '%s", retval[-1]) + return retval + + def _get_modules(self, category: T.Literal["faceswap", "tools"]) -> list[ModuleType]: + """ Parse the cli files for faceswap and tools and return the imported module + + Parameters + ---------- + category: Literal["faceswap", "tools"] + The faceswap category to obtain the cli modules + + Returns + ------- + list[`types.ModuleType`] + The modules for each faceswap command/tool that exists in the project for the given + category + """ + logger.debug("Getting '%s' cli modules", category) + if category == "tools": + return self._get_modules_tools() + return self._get_modules_faceswap() + + @classmethod + def _get_classes(cls, module: ModuleType) -> list[T.Type[FaceSwapArgs]]: + """ Obtain the classes from the given module that contain the command line + arguments + + Parameters + ---------- + module: :class:`types.ModuleType` + The imported module to parse for command line argument classes + + Returns + ------- + list[:class:`~lib.cli.args.FaceswapArgs`] + The command line argument class objects that exist in the module + """ + retval = [] + for name, obj in inspect.getmembers(module): + if not inspect.isclass(obj) or not name.lower().endswith("args"): + logger.debug("Skipping non-cli class object '%s'", name) + continue + if name.lower() in (("faceswapargs", "extractconvertargs", "guiargs")): + logger.debug("Skipping uneeded object '%s'", name) + continue + logger.debug("Collecting %s", obj) + retval.append(obj) + logger.debug("Collected from '%s': %s", module.__name__, [c.__name__ for c in retval]) + return retval + + def _get_all_classes(self, modules: list[ModuleType]) -> list[T.Type[FaceSwapArgs]]: + """Obtain the the command line options classes for the given modules + + Parameters + ---------- + modules : list[:class:`types.ModuleType`] + The imported modules to extract the command line argument classes from + + Returns + ------- + list[:class:`~lib.cli.args.FaceSwapArgs`] + The valid command line class objects for the given modules + """ + retval = [] + for module in modules: + mod_classes = self._get_classes(module) + if not mod_classes: + logger.debug("module '%s' contains no cli classes. Skipping", module) + continue + retval.extend(mod_classes) + logger.debug("Obtained %s cli classes from %s modules", len(retval), len(modules)) + return retval + + @classmethod + def _class_name_to_command(cls, class_name: str) -> str: + """ Format a FaceSwapArgs class name to a standardized command name + + Parameters + ---------- + class_name: str + The name of the class to convert to a command name + + Returns + ------- + str + The formatted command name + """ + return class_name.lower()[:-4] + + def _store_commands(self, + category: T.Literal["faceswap", "tools"], + classes: list[T.Type[FaceSwapArgs]]) -> None: + """ Format classes into command names and sort. Store in :attr:`commands`. + Sorting is in specific workflow order for faceswap and alphabetical for all others + + Parameters + ---------- + category: Literal["faceswap", "tools"] + The category to store the command names for + classes: list[:class:`~lib.cli.args.FaceSwapArgs`] + The valid command line class objects for the category + """ + class_names = [c.__name__ for c in classes] + commands = sorted(self._class_name_to_command(n) for n in class_names) + if category == "faceswap": ordered = ["extract", "train", "convert"] commands = ordered + [command for command in commands if command not in ordered] - logger.debug(commands) - return commands - - @staticmethod - def format_command_name(classname): - """ Format args class name to command """ - return classname.lower()[:-4] - - def extract_options(self, cli_source, mod_classes): - """ Extract the existing ArgParse Options - into master options Dictionary """ - subopts = {} - for classname in mod_classes: - logger.debug("Processing: (classname: '%s')", classname) - command = self.format_command_name(classname) - info, options = self.get_cli_arguments(cli_source, classname, command) - options = self.process_options(options, command) - options["helptext"] = info - logger.debug("Processed: (classname: '%s', command: '%s', options: %s)", - classname, command, options) - subopts[command] = options - return subopts - - @staticmethod - def get_cli_arguments(cli_source, classname, command): - """ Extract the options from the main and tools cli files """ - meth = getattr(cli_source, classname)(None, command) - return meth.info, meth.argument_list + meth.optional_arguments + meth.global_arguments - - def process_options(self, command_options, command): - """ Process the options for a single command """ - gui_options = OrderedDict() - for opt in command_options: - logger.trace("Processing: %s", opt) - if opt.get("help", "") == SUPPRESS: - logger.trace("Skipping suppressed option: %s", opt) - continue - title = self.set_control_title(opt["opts"]) - cpanel_option = ControlPanelOption( - title, - self.get_data_type(opt), - group=opt.get("group", None), - default=opt.get("default", None), - choices=opt.get("choices", None), - is_radio=opt.get("action", "") == actions.Radio, - is_multi_option=opt.get("action", "") == actions.MultiOption, - rounding=self.get_rounding(opt), - min_max=opt.get("min_max", None), - sysbrowser=self.get_sysbrowser(opt, command_options, command), - helptext=opt["help"], - track_modified=True, - command=command) - gui_options[title] = {"cpanel_option": cpanel_option, - "opts": opt["opts"], - "nargs": opt.get("nargs", None)} - logger.trace("Processed: %s", gui_options) - return gui_options - - @staticmethod - def set_control_title(opts): - """ Take the option switch and format it nicely """ + self._commands[category].extend(commands) + logger.debug("Set '%s' commands: %s", category, self._commands[category]) + + @classmethod + def _get_cli_arguments(cls, + arg_class: T.Type[FaceSwapArgs], + command: str) -> tuple[str, list[dict[str, T.Any]]]: + """ Extract the command line options from the given cli class + + Parameters + ---------- + arg_class: :class:`~lib.cli.args.FaceSwapArgs` + The class to extract the options from + command: str + The command name to extract the options for + + Returns + ------- + info: str + The helptext information for given command + options: list[dict. str, Any] + The command line options for the given command + """ + args = arg_class(None, command) + arg_list = args.argument_list + args.optional_arguments + args.global_arguments + logger.debug("Obtain options for '%s'. Info: '%s', options: %s", + command, args.info, len(arg_list)) + return args.info, arg_list + + @classmethod + def _set_control_title(cls, opts: tuple[str, ...]) -> str: + """ Take the option switch and format it nicely + + Parameters + ---------- + opts: tuple[str, ...] + The option switch for a command line option + + Returns + ------- + str + The option switch formatted for display + """ ctltitle = opts[1] if len(opts) == 2 else opts[0] - ctltitle = ctltitle.replace("-", " ").replace("_", " ").strip().title() - return ctltitle - - @staticmethod - def get_data_type(opt): - """ Return a datatype for passing into control_helper.py to get the correct control """ - if opt.get("type", None) is not None and isinstance(opt["type"], type): - retval = opt["type"] + retval = ctltitle.replace("-", " ").replace("_", " ").strip().title() + logger.debug("Formatted '%s' to '%s'", ctltitle, retval) + return retval + + @classmethod + def _get_data_type(cls, opt: dict[str, T.Any]) -> type: + """ Return a data type for passing into control_helper.py to get the correct control + + Parameters + ---------- + option: dict[str, Any] + The option to extract the data type from + + Returns + ------- + :class:`type` + The Python type for the option + """ + type_ = opt.get("type") + if type_ is not None and isinstance(opt["type"], type): + retval = type_ elif opt.get("action", "") in ("store_true", "store_false"): retval = bool else: retval = str + logger.debug("Setting type to %s for %s", retval, type_) return retval - @staticmethod - def get_rounding(opt): - """ Return rounding if correct data type, else None """ - dtype = opt.get("type", None) + @classmethod + def _get_rounding(cls, opt: dict[str, T.Any]) -> int | None: + """ Return rounding for the given option + + Parameters + ---------- + option: dict[str, Any] + The option to extract the rounding from + + Returns + ------- + int | None + int if the data type supports rounding otherwise ``None`` + """ + dtype = opt.get("type") if dtype == float: retval = opt.get("rounding", 2) elif dtype == int: retval = opt.get("rounding", 1) else: retval = None + logger.debug("Setting rounding to %s for type %s", retval, dtype) return retval - def get_sysbrowser(self, option, options, command): - """ Return the system file browser and file types if required else None """ + @classmethod + def _expand_action_option(cls, + option: dict[str, T.Any], + options: list[dict[str, T.Any]]) -> None: + """ Expand the action option to the full command name + + Parameters + ---------- + option: dict[str, Any] + The option to expand the action for + options: list[dict[str, Any]] + The full list of options for the command + """ + opts = {opt["opts"][0]: opt["opts"][-1] + for opt in options} + old_val = option["action_option"] + new_val = opts[old_val] + logger.debug("Updating action option from '%s' to '%s'", old_val, new_val) + option["action_option"] = new_val + + def _get_sysbrowser(self, + option: dict[str, T.Any], + options: list[dict[str, T.Any]], + command: str) -> dict[T.Literal["filetypes", + "browser", + "command", + "destination", + "action_option"], str | list[str]] | None: + """ Return the system file browser and file types if required + + Parameters + ---------- + option: dict[str, Any] + The option to obtain the system browser for + options: list[dict[str, Any]] + The full list of options for the command + command: str + The command that the options belong to + + Returns + ------- + dict[Literal["filetypes", "browser", "command", + "destination", "action_option"], list[str]] | None + The browser information, if valid, or ``None`` if browser not required + """ action = option.get("action", None) if action not in (actions.DirFullPaths, actions.FileFullPaths, @@ -177,10 +379,14 @@ def get_sysbrowser(self, option, options, command): actions.ContextFullPaths): return None - retval = {} + retval: dict[T.Literal["filetypes", + "browser", + "command", + "destination", + "action_option"], str | list[str]] = {} action_option = None if option.get("action_option", None) is not None: - self.expand_action_option(option, options) + self._expand_action_option(option, options) action_option = option["action_option"] retval["filetypes"] = option.get("filetypes", "default") if action == actions.FileFullPaths: @@ -203,50 +409,147 @@ def get_sysbrowser(self, option, options, command): logger.debug(retval) return retval - @staticmethod - def expand_action_option(option, options): - """ Expand the action option to the full command name """ - opts = {opt["opts"][0]: opt["opts"][-1] - for opt in options} - old_val = option["action_option"] - new_val = opts[old_val] - logger.debug("Updating action option from '%s' to '%s'", old_val, new_val) - option["action_option"] = new_val + def _process_options(self, command_options: list[dict[str, T.Any]], command: str + ) -> dict[str, CliOption]: + """ Process the options for a single command + + Parameters + ---------- + command_options: list[dict. str, Any] + The command line options for the given command + command: str + The command name to process + + Returns + ------- + dict[str, :class:`CliOption`] + The collected command line options for handling by the GUI + """ + retval: dict[str, CliOption] = {} + for opt in command_options: + logger.debug("Processing: cli option: %s", opt["opts"]) + if opt.get("help", "") == SUPPRESS: + logger.debug("Skipping suppressed option: %s", opt) + continue + title = self._set_control_title(opt["opts"]) + cpanel_option = ControlPanelOption( + title, + self._get_data_type(opt), + group=opt.get("group", None), + default=opt.get("default", None), + choices=opt.get("choices", None), + is_radio=opt.get("action", "") == actions.Radio, + is_multi_option=opt.get("action", "") == actions.MultiOption, + rounding=self._get_rounding(opt), + min_max=opt.get("min_max", None), + sysbrowser=self._get_sysbrowser(opt, command_options, command), + helptext=opt["help"], + track_modified=True, + command=command) + retval[title] = CliOption(cpanel_option=cpanel_option, + opts=opt["opts"], + nargs=opt.get("nargs")) + logger.debug("Processed: %s", retval) + return retval + + def _extract_options(self, arguments: list[T.Type[FaceSwapArgs]]): + """ Extract the collected command line FaceSwapArg options into master options + :attr:`opts` dictionary + + Parameters + ---------- + arguments: list[:class:`~lib.cli.args.FaceSwapArgs`] + The command line class objects to process + """ + retval = {} + for arg_class in arguments: + logger.debug("Processing: '%s'", arg_class.__name__) + command = self._class_name_to_command(arg_class.__name__) + info, options = self._get_cli_arguments(arg_class, command) + opts = T.cast(dict[str, CliOption | str], self._process_options(options, command)) + opts["helptext"] = info + retval[command] = opts + self._opts.update(retval) + + def _build_options(self) -> None: + """ Parse the command line argument modules and populate :attr:`commands` and :attr:`opts` + for each category """ + for category in self.categories: + modules = self._get_modules(category) + classes = self._get_all_classes(modules) + self._store_commands(category, classes) + self._extract_options(classes) + logger.debug("Built '%s'", category) - def gen_command_options(self, command): - """ Yield each option for specified command """ - for key, val in self.opts.get(command, {}).items(): - if not isinstance(val, dict): + def _gen_command_options(self, command: str + ) -> T.Generator[tuple[str, CliOption], None, None]: + """ Yield each option for specified command + + Parameters + ---------- + command: str + The faceswap command to generate the options for + + Yields + ------ + str + The option name for display + :class:`CliOption`: + The option object + """ + for key, val in self._opts.get(command, {}).items(): + if not isinstance(val, CliOption): continue yield key, val - def options_to_process(self, command=None): + def _options_to_process(self, command: str | None = None) -> list[CliOption]: """ Return a consistent object for processing regardless of whether processing all commands - or just one command for reset and clear. Removes helptext from return value """ + or just one command for reset and clear. Removes helptext from return value + + Parameters + ---------- + command: str | None, optional + The command to return the options for. ``None`` for all commands. Default ``None`` + + Returns + ------- + list[:class:`CliOption`] + The options to be processed + """ if command is None: - options = [opt for opts in self.opts.values() - for opt in opts.values() if isinstance(opt, dict)] - else: - options = [opt for opt in self.opts[command].values() if isinstance(opt, dict)] - return options + return [opt for opts in self._opts.values() + for opt in opts if isinstance(opt, CliOption)] + return [opt for opt in self._opts[command] if isinstance(opt, CliOption)] + + def reset(self, command: str | None = None) -> None: + """ Reset the options for all or passed command back to default value - def reset(self, command=None): - """ Reset the options for all or passed command - back to default value """ + Parameters + ---------- + command: str | None, optional + The command to reset the options for. ``None`` to reset for all commands. + Default: ``None`` + """ logger.debug("Resetting options to default. (command: '%s'", command) - for option in self.options_to_process(command): - cp_opt = option["cpanel_option"] + for option in self._options_to_process(command): + cp_opt = option.cpanel_option default = "" if cp_opt.default is None else cp_opt.default - if (option.get("nargs", None) - and isinstance(default, (list, tuple))): + if option.nargs is not None and isinstance(default, (list, tuple)): default = ' '.join(str(val) for val in default) cp_opt.set(default) - def clear(self, command=None): - """ Clear the options values for all or passed commands """ + def clear(self, command: str | None = None) -> None: + """ Clear the options values for all or passed commands + + Parameters + ---------- + command: str | None, optional + The command to clear the options for. ``None`` to clear options for all commands. + Default: ``None`` + """ logger.debug("Clearing options. (command: '%s'", command) - for option in self.options_to_process(command): - cp_opt = option["cpanel_option"] + for option in self._options_to_process(command): + cp_opt = option.cpanel_option if isinstance(cp_opt.get(), bool): cp_opt.set(False) elif isinstance(cp_opt.get(), (int, float)): @@ -254,54 +557,92 @@ def clear(self, command=None): else: cp_opt.set("") - def get_option_values(self, command=None): - """ Return all or single command control titles with the associated tk_var value """ - ctl_dict = {} - for cmd, opts in self.opts.items(): + def get_option_values(self, command: str | None = None + ) -> dict[str, dict[str, bool | int | float | str]]: + """ Return all or single command control titles with the associated tk_var value + + Parameters + ---------- + command: str | None, optional + The command to get the option values for. ``None`` to get all option values. + Default: ``None`` + + Returns + ------- + dict[str, dict[str, bool | int | float | str]] + option values in the format {command: {option_name: option_value}} + """ + ctl_dict: dict[str, dict[str, bool | int | float | str]] = {} + for cmd, opts in self._opts.items(): if command and command != cmd: continue - cmd_dict = {} + cmd_dict: dict[str, bool | int | float | str] = {} for key, val in opts.items(): - if not isinstance(val, dict): + if not isinstance(val, CliOption): continue - cmd_dict[key] = val["cpanel_option"].get() + cmd_dict[key] = val.cpanel_option.get() ctl_dict[cmd] = cmd_dict logger.debug("command: '%s', ctl_dict: %s", command, ctl_dict) return ctl_dict - def get_one_option_variable(self, command, title): - """ Return a single tk_var for the specified - command and control_title """ - for opt_title, option in self.gen_command_options(command): + def get_one_option_variable(self, command: str, title: str) -> Variable | None: + """ Return a single :class:`tkinter.Variable` tk_var for the specified command and + control_title + + Parameters + ---------- + command: str + The command to return the variable from + title: str + The option title to return the variable for + + Returns + ------- + :class:`tkinter.Variable` | None + The requested tkinter variable, or ``None`` if it could not be found + """ + for opt_title, option in self._gen_command_options(command): if opt_title == title: - return option["cpanel_option"].tk_var + return option.cpanel_option.tk_var return None - def gen_cli_arguments(self, command): - """ Return the generated cli arguments for the selected command """ + def gen_cli_arguments(self, command: str) -> T.Generator[tuple[str, ...], None, None]: + """ Yield the generated cli arguments for the selected command + + Parameters + ---------- + command: str + The command to generate the command line arguments for + + Yields + ------ + tuple[str, ...] + The generated command line arguments + """ output_dir = None - batch_mode = False - for _, option in self.gen_command_options(command): - optval = str(option["cpanel_option"].get()) - opt = option["opts"][0] - if command in ("extract", "convert") and opt == "-o": # Output location for preview - output_dir = optval - if command == "extract" and opt == "-b": # Check for batch mode - batch_mode = optval - if optval in ("False", ""): + for _, option in self._gen_command_options(command): + str_val = str(option.cpanel_option.get()) + switch = option.opts[0] + batch_mode = command == "extract" and switch == "-b" # Check for batch mode + if command in ("extract", "convert") and switch == "-o": # Output location for preview + output_dir = str_val + + if str_val in ("False", ""): # skip no value opts continue - if optval == "True": - yield (opt, ) - else: - if option.get("nargs", None): - if "\"" in optval: - optval = [arg[1:-1] for arg in re.findall(r"\".+?\"", optval)] - else: - optval = optval.split(" ") - opt = [opt] + optval + + if str_val == "True": # store_true just output the switch + yield (switch, ) + continue + + if option.nargs is not None: + if "\"" in str_val: + val = [arg[1:-1] for arg in re.findall(r"\".+?\"", str_val)] else: - opt = (opt, optval) - yield opt + val = str_val.split(" ") + retval = (switch, *val) + else: + retval = (switch, str_val) + yield retval if command in ("extract", "convert") and output_dir is not None: get_images().preview_extract.set_faceswap_output_path(output_dir, diff --git a/lib/gui/project.py b/lib/gui/project.py index 83d1320718..1b5b2528a5 100644 --- a/lib/gui/project.py +++ b/lib/gui/project.py @@ -11,7 +11,7 @@ logger = logging.getLogger(__name__) -class _GuiSession(): +class _GuiSession(): # pylint:disable=too-few-public-methods """ Parent class for GUI Session Handlers. Parameters @@ -90,11 +90,12 @@ def _stored_tab_name(self): def _selected_to_choices(self): """ dict: The selected value and valid choices for multi-option, radio or combo options. """ - valid_choices = {cmd: {opt: {"choices": val["cpanel_option"].choices, - "is_multi": val["cpanel_option"].is_multi_option} + valid_choices = {cmd: {opt: {"choices": val.cpanel_option.choices, + "is_multi": val.cpanel_option.is_multi_option} for opt, val in data.items() - if isinstance(val, dict) and "cpanel_option" in val - and val["cpanel_option"].choices is not None} + if hasattr(val, "cpanel_option") # Filter out helptext + and val.cpanel_option.choices is not None + } for cmd, data in self._config.cli_opts.opts.items()} logger.trace("valid_choices: %s", valid_choices) retval = {command: {option: {"value": value, diff --git a/lib/logger.py b/lib/logger.py index c92a32164d..89fb036627 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -1,5 +1,6 @@ #!/usr/bin/python """ Logging Functions for Faceswap. """ +# NOTE: Don't import non stdlib packages. This module is accessed by setup.py import collections import logging from logging.handlers import RotatingFileHandler @@ -144,7 +145,7 @@ def _get_newline_padding(self, pad_newlines: bool, fmt: str) -> int: def _get_sample_time_string(self) -> int: """ Obtain a sample time string and calculate correct padding. - This may be inaccurate wheb ticking over an integer from single to double digits, but that + This may be inaccurate when ticking over an integer from single to double digits, but that shouldn't be a huge issue. Returns @@ -563,7 +564,7 @@ def _process_value(value: T.Any) -> T.Any: return f'[type: "{type(value).__name__}" len: {len(value)}' try: - import numpy as np + import numpy as np # pylint:disable=import-outside-toplevel except ImportError: return value diff --git a/lib/utils.py b/lib/utils.py index 0a8e0d148f..be7588fdfd 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -21,14 +21,13 @@ from tqdm import tqdm if T.TYPE_CHECKING: + from argparse import Namespace from http.client import HTTPResponse # Global variables -IMAGE_EXTENSIONS = [ # pylint:disable=invalid-name - ".bmp", ".jpeg", ".jpg", ".png", ".tif", ".tiff"] -VIDEO_EXTENSIONS = [ # pylint:disable=invalid-name - ".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv", - ".ts", ".vob"] +IMAGE_EXTENSIONS = [".bmp", ".jpeg", ".jpg", ".png", ".tif", ".tiff"] +VIDEO_EXTENSIONS = [".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv", + ".ts", ".vob"] _TF_VERS: tuple[int, int] | None = None ValidBackends = T.Literal["nvidia", "cpu", "apple_silicon", "directml", "rocm"] @@ -431,6 +430,43 @@ def deprecation_warning(function: str, additional_info: str | None = None) -> No logger.warning(msg) +def handle_deprecated_cliopts(arguments: Namespace) -> Namespace: + """ Handle deprecated command line arguments and update to correct argument. + + Deprecated cli opts will be provided in the following format: + `"depr___"` + + Parameters + ---------- + arguments: :class:`argpares.Namespace` + The passed in faceswap cli arguments + + Returns + ------- + :class:`argpares.Namespace` + The cli arguments with deprecated values mapped to the correct entry + """ + logger = logging.getLogger(__name__) + + for key, selected in vars(arguments).items(): + if not key.startswith("depr_") or key.startswith("depr_") and selected is None: + continue # Not a deprecated opt + if isinstance(selected, bool) and not selected: + continue # store-true opt with default value + + opt, old, new = key.replace("depr_", "").rsplit("_", maxsplit=2) + deprecation_warning(f"Command line option '-{old}'", f"Use '-{new}, --{opt}' instead") + + exist = getattr(arguments, opt) + if exist == selected: + logger.debug("Keeping existing '%s' value of '%s'", opt, exist) + else: + logger.debug("Updating arg '%s' from '%s' to '%s' from deprecated opt", + opt, exist, selected) + + return arguments + + def camel_case_split(identifier: str) -> list[str]: """ Split a camelCase string into a list of its individual parts diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo old mode 100755 new mode 100644 index ff51300b89505353c433d8f21c13873b7c084c53..914c7bf3b883d77c89021cb76e5b86b663df1885 GIT binary patch delta 286 zcmdn^i|GJw{XHR;sSH5C4#YA*90J7LK->ewAg}|7`G9yQ5VryG2_W_YVt+;k20b7> z3rM>G>61YEJdhS(Vqk~@vL66xK_K6ng@IuTkoE=A?m+qxkTwC*TC5BVl?)6X4Dmq5 zGoZjtAPv+920$hQGY|vK00TBC4b%$;AjdMe=j10P<|z0Vlw{`T6;HO8?3lbm$!Bt^ zTIpl~qYIN)M+FL)=o%R78d)e9T38vHXd4(z=866)W};`r#o%9%4AhlctN<}3wRm!S T!L-TJ)gF^A>eo*$>U|CX0bDe& literal 48433 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 Configure Extract 'Plugins':\n" -"L|cv2-dnn: A CPU only extractor which is the least reliable and least " -"resource intensive. Use this if not using a GPU and time is important.\n" -"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " -"than other GPU detectors but can often return more false positives.\n" -"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " -"fewer false positives than other GPU detectors, but is a lot more resource " -"intensive." -msgstr "" -"R|Detector de caras a usar. Algunos tienen ajustes configurables en '/config/" -"extract.ini' o 'Ajustes > Configurar Extensiones de Extracción:\n" -"L|cv2-dnn: Extractor que usa sólo la CPU. Es el menos fiable y el que menos " -"recursos usa. Elegir este si necesita rapidez y no usar la GPU.\n" -"L|mtcnn: Buen detector. Rápido en la CPU y más rápido en la GPU. Usa menos " -"recursos que otros detectores basados en GPU, pero puede devolver más falsos " -"positivos.\n" -"L|s3fd: El mejor detector. Lento en la CPU, y más rápido en la GPU. Puede " -"detectar más caras y tiene menos falsos positivos que otros detectores " -"basados en GPU, pero uso muchos más recursos." - -#: lib/cli/args.py:412 -msgid "" -"R|Aligner to use.\n" -"L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " -"but less accurate. Only use this if not using a GPU and time is important.\n" -"L|fan: Best aligner. Fast on GPU, slow on CPU." -msgstr "" -"R|Alineador a usar.\n" -"L|cv2-dnn: Detector que usa sólo la CPU. Más rápido, usa menos recursos, " -"pero es menos preciso. Elegir este si necesita rapidez y no usar la GPU.\n" -"L|fan: El mejor alineador. Rápido en la GPU, y lento en la CPU." - -#: lib/cli/args.py:424 -msgid "" -"R|Additional Masker(s) to use. The masks generated here will all take up GPU " -"RAM. You can select none, one or multiple masks, but the extraction may take " -"longer the more you select. NB: The Extended and Components (landmark based) " -"masks are automatically generated on extraction.\n" -"L|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).\n" -"L|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 so will not use any additional VRAM.\n" -"L|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.\n" -"L|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.\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.\n" -"The auto generated masks are as follows:\n" -"L|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.\n" -"L|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.\n" -"(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -msgstr "" -"R|Enmascarador(es) adicional(es) a usar. Las máscaras generadas aquí usarán " -"todas RAM de la GPU. Puede seleccionar una, varias o ninguna máscaras, pero " -"la extracción tardará más cuanto más marque. Las máscaras Extended y " -"Components son siempre generadas durante la extracción.\n" -"L|bisenet-fp: Máscara relativamente ligera basada en NN que proporciona un " -"control más refinado sobre el área a enmascarar, incluido el enmascaramiento " -"completo de la cabeza (configurable en la configuración de la máscara).\n" -"L|custom: Una máscara ficticia que llena el área de la máscara con 1 o 0 " -"(configurable en la configuración). Esto solo es necesario si tiene la " -"intención de editar manualmente las máscaras personalizadas usted mismo en " -"la herramienta manual. Esta máscara no usa la GPU, por lo que no usará VRAM " -"adicional.\n" -"L|vgg-clear: Máscara diseñada para proporcionar una segmentación inteligente " -"de rostros principalmente frontales y libres de obstrucciones. Los rostros " -"de perfil y las obstrucciones pueden dar lugar a un rendimiento inferior.\n" -"L|vgg-obstructed: Máscara diseñada para proporcionar una segmentación " -"inteligente de rostros principalmente frontales. El modelo de la máscara ha " -"sido entrenado específicamente para reconocer algunas obstrucciones faciales " -"(manos y gafas). Los rostros de perfil pueden dar lugar a un rendimiento " -"inferior.\n" -"L|unet-dfl: Máscara diseñada para proporcionar una segmentación inteligente " -"de rostros principalmente frontales. El modelo de máscara ha sido entrenado " -"por los miembros de la comunidad y necesitará ser probado para una mayor " -"descripción. Los rostros de perfil pueden dar lugar a un rendimiento " -"inferior.\n" -"Las máscaras que siempre se generan son:\n" -"L|components: Máscara diseñada para proporcionar una segmentación facial " -"basada en el posicionamiento de las ubicaciones de los puntos de referencia. " -"Se construye un casco convexo alrededor del exterior de los puntos de " -"referencia para crear una máscara.\n" -"L|extended: Máscara diseñada para proporcionar una segmentación facial " -"basada en el posicionamiento de las ubicaciones de los puntos de referencia. " -"Se construye un casco convexo alrededor del exterior de los puntos de " -"referencia y la máscara se extiende hacia arriba en la frente.\n" -"(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" - -#: lib/cli/args.py:463 -msgid "" -"R|Performing normalization can help the aligner better align faces with " -"difficult lighting conditions at an extraction speed cost. Different methods " -"will yield different results on different sets. NB: This does not impact the " -"output face, just the input to the aligner.\n" -"L|none: Don't perform normalization on the face.\n" -"L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " -"face.\n" -"L|hist: Equalize the histograms on the RGB channels.\n" -"L|mean: Normalize the face colors to the mean." -msgstr "" -"R|Realizar la normalización puede ayudar al alineador a alinear mejor las " -"caras con condiciones de iluminación difíciles a un coste de velocidad de " -"extracción. Diferentes métodos darán diferentes resultados en diferentes " -"conjuntos. NB: Esto no afecta a la cara de salida, sólo a la entrada del " -"alineador.\n" -"L|none: No realice la normalización en la cara.\n" -"L|clahe: Realice la ecualización adaptativa del histograma con contraste " -"limitado en el rostro.\n" -"L|hist: Iguala los histogramas de los canales RGB.\n" -"L|mean: Normalizar los colores de la cara a la media." - -#: lib/cli/args.py:481 -msgid "" -"The number of times to re-feed the detected face into the aligner. Each time " -"the face is re-fed into the aligner the bounding box is adjusted by a small " -"amount. The final landmarks are then averaged from each iteration. Helps to " -"remove 'micro-jitter' but at the cost of slower extraction speed. The more " -"times the face is re-fed into the aligner, the less micro-jitter should " -"occur but the longer extraction will take." -msgstr "" -"El número de veces que hay que volver a introducir la cara detectada en el " -"alineador. Cada vez que la cara se vuelve a introducir en el alineador, el " -"cuadro delimitador se ajusta en una pequeña cantidad. Los puntos de " -"referencia finales se promedian en cada iteración. Esto ayuda a eliminar el " -"'micro-jitter', pero a costa de una menor velocidad de extracción. Cuantas " -"más veces se vuelva a introducir la cara en el alineador, menos " -"microfluctuaciones se producirán, pero la extracción será más larga." - -#: lib/cli/args.py:493 -msgid "" -"Re-feed the initially found aligned face through the aligner. Can help " -"produce better alignments for faces that are rotated beyond 45 degrees in " -"the frame or are at extreme angles. Slows down extraction." -msgstr "" -"Vuelva a introducir la cara alineada encontrada inicialmente a través del " -"alineador. Puede ayudar a producir mejores alineaciones para las caras que " -"se giran más de 45 grados en el marco o se encuentran en ángulos extremos. " -"Ralentiza la extracción." - -#: lib/cli/args.py:502 -msgid "" -"If a face isn't found, rotate the images to try to find a face. Can find " -"more faces at the cost of extraction speed. Pass in a single number to use " -"increments of that size up to 360, or pass in a list of numbers to enumerate " -"exactly what angles to check." -msgstr "" -"Si no se encuentra una cara, gira las imágenes para intentar encontrar una " -"cara. Puede encontrar más caras a costa de la velocidad de extracción. Pase " -"un solo número para usar incrementos de ese tamaño hasta 360, o pase una " -"lista de números para enumerar exactamente qué ángulos comprobar." - -#: lib/cli/args.py:511 -msgid "" -"Obtain and store face identity encodings from VGGFace2. Slows down extract a " -"little, but will save time if using 'sort by face'" -msgstr "" -"Obtenga y almacene codificaciones de identidad facial de VGGFace2. Ralentiza " -"un poco la extracción, pero ahorrará tiempo si usa 'sort by face'" - -#: lib/cli/args.py:521 lib/cli/args.py:531 lib/cli/args.py:543 -#: lib/cli/args.py:556 lib/cli/args.py:804 lib/cli/args.py:812 -#: lib/cli/args.py:826 lib/cli/args.py:839 lib/cli/args.py:853 -msgid "Face Processing" -msgstr "Proceso de Caras" - -#: lib/cli/args.py:522 -msgid "" -"Filters out faces detected below this size. Length, in pixels across the " -"diagonal of the bounding box. Set to 0 for off" -msgstr "" -"Filtra las caras detectadas por debajo de este tamaño. Longitud, en píxeles " -"a lo largo de la diagonal del cuadro delimitador. Establecer a 0 para " -"desactivar" - -#: lib/cli/args.py:532 -msgid "" -"Optionally filter out people who you do not wish to extract by passing in " -"images of those people. Should be a small variety of images at different " -"angles and in different conditions. A folder containing the required images " -"or multiple image files, space separated, can be selected." -msgstr "" -"Opcionalmente, filtre a las personas que no desea extraer pasando imágenes " -"de esas personas. Debe ser una pequeña variedad de imágenes en diferentes " -"ángulos y en diferentes condiciones. Se puede seleccionar una carpeta que " -"contenga las imágenes requeridas o múltiples archivos de imágenes, separados " -"por espacios." - -#: lib/cli/args.py:544 -msgid "" -"Optionally select people you wish to extract by passing in images of that " -"person. Should be a small variety of images at different angles and in " -"different conditions A folder containing the required images or multiple " -"image files, space separated, can be selected." -msgstr "" -"Opcionalmente, seleccione las personas que desea extraer pasando imágenes de " -"esa persona. Debe haber una pequeña variedad de imágenes en diferentes " -"ángulos y en diferentes condiciones. Se puede seleccionar una carpeta que " -"contenga las imágenes requeridas o múltiples archivos de imágenes, separados " -"por espacios." - -#: lib/cli/args.py:557 -msgid "" -"For use with the optional nfilter/filter files. Threshold for positive face " -"recognition. Higher values are stricter." -msgstr "" -"Para usar con los archivos nfilter/filter opcionales. Umbral para el " -"reconocimiento facial positivo. Los valores más altos son más estrictos." - -#: lib/cli/args.py:566 lib/cli/args.py:578 lib/cli/args.py:590 -#: lib/cli/args.py:602 -msgid "output" -msgstr "salida" - -#: lib/cli/args.py:567 -msgid "" -"The output size of extracted faces. Make sure that the model you intend to " -"train supports your required size. This will only need to be changed for hi-" -"res models." -msgstr "" -"El tamaño de salida de las caras extraídas. Asegúrese de que el modelo que " -"pretende entrenar admite el tamaño deseado. Esto sólo tendrá que ser " -"cambiado para los modelos de alta resolución." - -#: lib/cli/args.py:579 -msgid "" -"Extract every 'nth' frame. This option will skip frames when extracting " -"faces. For example a value of 1 will extract faces from every frame, a value " -"of 10 will extract faces from every 10th frame." -msgstr "" -"Extraer cada 'enésimo' fotograma. Esta opción omitirá los fotogramas al " -"extraer las caras. Por ejemplo, un valor de 1 extraerá las caras de cada " -"fotograma, un valor de 10 extraerá las caras de cada 10 fotogramas." - -#: lib/cli/args.py:591 -msgid "" -"Automatically save the alignments file after a set amount of frames. By " -"default the alignments file is only saved at the end of the extraction " -"process. NB: If extracting in 2 passes then the alignments file will only " -"start to be saved out during the second pass. WARNING: Don't interrupt the " -"script when writing the file because it might get corrupted. Set to 0 to " -"turn off" -msgstr "" -"Guardar automáticamente el archivo de alineaciones después de una cantidad " -"determinada de cuadros. Por defecto, el archivo de alineaciones sólo se " -"guarda al final del proceso de extracción. Nota: Si se extrae en 2 pases, el " -"archivo de alineaciones sólo se empezará a guardar durante el segundo pase. " -"ADVERTENCIA: No interrumpa el script al escribir el archivo porque podría " -"corromperse. Poner a 0 para desactivar" - -#: lib/cli/args.py:603 -msgid "Draw landmarks on the ouput faces for debugging purposes." -msgstr "" -"Dibujar puntos de referencia en las caras de salida para fines de depuración." - -#: lib/cli/args.py:609 lib/cli/args.py:618 lib/cli/args.py:626 -#: lib/cli/args.py:633 lib/cli/args.py:866 lib/cli/args.py:877 -#: lib/cli/args.py:885 lib/cli/args.py:904 lib/cli/args.py:910 -msgid "settings" -msgstr "ajustes" - -#: lib/cli/args.py:610 -msgid "" -"Don't run extraction in parallel. Will run each part of the extraction " -"process separately (one after the other) rather than all at the same time. " -"Useful if VRAM is at a premium." -msgstr "" -"No ejecute la extracción en paralelo. Ejecutará cada parte del proceso de " -"extracción por separado (una tras otra) en lugar de hacerlo todo al mismo " -"tiempo. Útil si la VRAM es escasa." - -#: lib/cli/args.py:619 -msgid "" -"Skips frames that have already been extracted and exist in the alignments " -"file" -msgstr "" -"Omite los fotogramas que ya han sido extraídos y que existen en el archivo " -"de alineaciones" - -#: lib/cli/args.py:627 -msgid "Skip frames that already have detected faces in the alignments file" -msgstr "" -"Omitir los fotogramas que ya tienen caras detectadas en el archivo de " -"alineaciones" - -#: lib/cli/args.py:634 -msgid "Skip saving the detected faces to disk. Just create an alignments file" -msgstr "" -"No guardar las caras detectadas en el disco. Crear sólo un archivo de " -"alineaciones" - -#: lib/cli/args.py:656 -msgid "" -"Swap the original faces in a source video/images to your final faces.\n" -"Conversion plugins can be configured in the 'Settings' Menu" -msgstr "" -"Cambia las caras originales de un vídeo/imágenes de origen por las caras " -"finales.\n" -"Los plugins de conversión pueden ser configurados en el menú " -"\"Configuración\"" - -#: lib/cli/args.py:677 -msgid "" -"Only required if converting from images to video. Provide The original video " -"that the source frames were extracted from (for extracting the fps and " -"audio)." -msgstr "" -"Sólo es necesario si se convierte de imágenes a vídeo. Proporcione el vídeo " -"original del que se extrajeron los fotogramas de origen (para extraer los " -"fps y el audio)." - -#: lib/cli/args.py:686 -msgid "" -"Model directory. The directory containing the trained model you wish to use " -"for conversion." -msgstr "" -"Directorio del modelo. El directorio que contiene el modelo entrenado que " -"desea utilizar para la conversión." - -#: lib/cli/args.py:696 -msgid "" -"R|Performs color adjustment to the swapped face. Some of these options have " -"configurable settings in '/config/convert.ini' or 'Settings > Configure " -"Convert Plugins':\n" -"L|avg-color: Adjust the mean of each color channel in the swapped " -"reconstruction to equal the mean of the masked area in the original image.\n" -"L|color-transfer: Transfers the color distribution from the source to the " -"target image using the mean and standard deviations of the L*a*b* color " -"space.\n" -"L|manual-balance: Manually adjust the balance of the image in a variety of " -"color spaces. Best used with the Preview tool to set correct values.\n" -"L|match-hist: Adjust the histogram of each color channel in the swapped " -"reconstruction to equal the histogram of the masked area in the original " -"image.\n" -"L|seamless-clone: Use cv2's seamless clone function to remove extreme " -"gradients at the mask seam by smoothing colors. Generally does not give very " -"satisfactory results.\n" -"L|none: Don't perform color adjustment." -msgstr "" -"R|Realiza un ajuste de color a la cara intercambiada. Algunas de estas " -"opciones tienen ajustes configurables en '/config/convert.ini' o 'Ajustes > " -"Configurar Extensiones de Conversión':\n" -"L|avg-color: Ajuste la media de cada canal de color en la reconstrucción " -"intercambiada para igualar la media del área enmascarada en la imagen " -"original.\n" -"L|color-transfer: Transfiere la distribución del color de la imagen de " -"origen a la de destino utilizando la media y las desviaciones estándar del " -"espacio de color L*a*b*.\n" -"L|manual-balance: Ajuste manualmente el equilibrio de la imagen en una " -"variedad de espacios de color. Se utiliza mejor con la herramienta de vista " -"previa para establecer los valores correctos.\n" -"L|match-hist: Ajuste el histograma de cada canal de color en la " -"reconstrucción intercambiada para igualar el histograma del área enmascarada " -"en la imagen original.\n" -"L|seamless-clone: Utilice la función de clonación sin costuras de cv2 para " -"eliminar los gradientes extremos en la costura de la máscara, suavizando los " -"colores. Generalmente no da resultados muy satisfactorios.\n" -"L|none: No realice el ajuste de color." - -#: lib/cli/args.py:723 -msgid "" -"R|Masker to use. NB: The mask you require must exist within the alignments " -"file. You can add additional masks with the Mask Tool.\n" -"L|none: Don't use a mask.\n" -"L|bisenet-fp_face: Relatively lightweight NN based mask that provides more " -"refined control over the area to be masked (configurable in mask settings). " -"Use this version of bisenet-fp if your model is trained with 'face' or " -"'legacy' centering.\n" -"L|bisenet-fp_head: Relatively lightweight NN based mask that provides more " -"refined control over the area to be masked (configurable in mask settings). " -"Use this version of bisenet-fp if your model is trained with 'head' " -"centering.\n" -"L|custom_face: Custom user created, face centered mask.\n" -"L|custom_head: Custom user created, head centered mask.\n" -"L|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.\n" -"L|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.\n" -"L|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.\n" -"L|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.\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.\n" -"L|predicted: If the 'Learn Mask' option was enabled during training, this " -"will use the mask that was created by the trained model." -msgstr "" -"R|Máscara a utilizar. NB: La máscara que necesita debe existir en el archivo " -"de alineaciones. Puede añadir máscaras adicionales con la herramienta de " -"máscaras.\n" -"L|none: No utilizar una máscara.\n" -"L|bisenet-fp-face: Máscara relativamente ligera basada en NN que proporciona " -"un control más refinado sobre el área a enmascarar (configurable en la " -"configuración de la máscara). Utilice esta versión de bisenet-fp si su " -"modelo está entrenado con centrado 'face' o 'legacy'.\n" -"L|bisenet-fp-head: Máscara relativamente ligera basada en NN que proporciona " -"un control más refinado sobre el área a enmascarar (configurable en la " -"configuración de la máscara). Utilice esta versión de bisenet-fp si su " -"modelo está entrenado con centrado de 'cabeza'.\n" -"L|custom_face: Máscara personalizada creada por el usuario y centrada en el " -"rostro..\n" -"L|custom_head: Máscara personalizada centrada en la cabeza creada por el " -"usuario.\n" -"L|components: Máscara diseñada para proporcionar una segmentación facial " -"basada en el posicionamiento de las ubicaciones de los puntos de referencia. " -"Se construye un casco convexo alrededor del exterior de los puntos de " -"referencia para crear una máscara.\n" -"L|extended: Máscara diseñada para proporcionar una segmentación facial " -"basada en el posicionamiento de las ubicaciones de los puntos de referencia. " -"Se construye un casco convexo alrededor del exterior de los puntos de " -"referencia y la máscara se extiende hacia arriba en la frente.\n" -"L|vgg-clear: Máscara diseñada para proporcionar una segmentación inteligente " -"de rostros principalmente frontales y libres de obstrucciones. Los rostros " -"de perfil y las obstrucciones pueden dar lugar a un rendimiento inferior.\n" -"L|vgg-obstructed: Máscara diseñada para proporcionar una segmentación " -"inteligente de rostros principalmente frontales. El modelo de la máscara ha " -"sido entrenado específicamente para reconocer algunas obstrucciones faciales " -"(manos y gafas). Los rostros de perfil pueden dar lugar a un rendimiento " -"inferior.\n" -"L|unet-dfl: Máscara diseñada para proporcionar una segmentación inteligente " -"de rostros principalmente frontales. El modelo de máscara ha sido entrenado " -"por los miembros de la comunidad y necesitará ser probado para una mayor " -"descripción. Los rostros de perfil pueden dar lugar a un rendimiento " -"inferior.\n" -"L|predicted: Si la opción 'Learn Mask' se habilitó durante el entrenamiento, " -"esto usará la máscara que fue creada por el modelo entrenado." - -#: lib/cli/args.py:761 -msgid "" -"R|The plugin to use to output the converted images. The writers are " -"configurable in '/config/convert.ini' or 'Settings > Configure Convert " -"Plugins:'\n" -"L|ffmpeg: [video] Writes out the convert straight to video. When the input " -"is a series of images then the '-ref' (--reference-video) parameter must be " -"set.\n" -"L|gif: [animated image] Create an animated gif.\n" -"L|opencv: [images] The fastest image writer, but less options and formats " -"than other plugins.\n" -"L|patch: [images] Outputs the raw swapped face patch, along with the " -"transformation matrix required to re-insert the face back into the original " -"frame. Use this option if you wish to post-process and composite the final " -"face within external tools.\n" -"L|pillow: [images] Slower than opencv, but has more options and supports " -"more formats." -msgstr "" -"R|El plugin a utilizar para dar salida a las imágenes convertidas. Los " -"escritores son configurables en '/config/convert.ini' o 'Ajustes > " -"Configurar Extensiones de Conversión:'\n" -"L|ffmpeg: [video] Escribe la conversión directamente en vídeo. Cuando la " -"entrada es una serie de imágenes, el parámetro '-ref' (--reference-video) " -"debe ser establecido.\n" -"L|gif: [imagen animada] Crea un gif animado.\n" -"L|opencv: [images] El escritor de imágenes más rápido, pero con menos " -"opciones y formatos que otros plugins.\n" -"L|patch: [images] Genera el parche de cara intercambiado sin formato, junto " -"con la matriz de transformación necesaria para volver a insertar la cara en " -"el marco original.\n" -"L|pillow: [images] Más lento que opencv, pero tiene más opciones y soporta " -"más formatos." - -#: lib/cli/args.py:784 lib/cli/args.py:791 lib/cli/args.py:896 -msgid "Frame Processing" -msgstr "Proceso de fotogramas" - -#: lib/cli/args.py:785 -#, python-format -msgid "" -"Scale the final output frames by this amount. 100%% will output the frames " -"at source dimensions. 50%% at half size 200%% at double size" -msgstr "" -"Escala los fotogramas finales de salida en esta cantidad. 100%% dará salida " -"a los fotogramas a las dimensiones de origen. 50%% a la mitad de tamaño. " -"200%% al doble de tamaño" - -#: lib/cli/args.py:792 -msgid "" -"Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " -"--frame-ranges 10-50 90-100. Frames falling outside of the selected range " -"will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " -"converting from images, then the filenames must end with the frame-number!" -msgstr "" -"Rangos de fotogramas a los que aplicar la transferencia, por ejemplo, para " -"los fotogramas de 10 a 50 y de 90 a 100 utilice --frame-ranges 10-50 90-100. " -"Los fotogramas que queden fuera del rango seleccionado se descartarán a " -"menos que se seleccione '-k' (--keep-unchanged). Nota: Si está convirtiendo " -"imágenes, ¡los nombres de los archivos deben terminar con el número de " -"fotograma!" - -#: lib/cli/args.py:805 -msgid "" -"Scale the swapped face by this percentage. Positive values will enlarge the " -"face, Negative values will shrink the face." -msgstr "" -"Escale la cara intercambiada según este porcentaje. Los valores positivos " -"agrandarán la cara, los valores negativos la reducirán." - -#: lib/cli/args.py:813 -msgid "" -"If you have not cleansed your alignments file, then you can filter out faces " -"by defining a folder here that contains the faces extracted from your input " -"files/video. If this folder is defined, then only faces that exist within " -"your alignments file and also exist within the specified folder will be " -"converted. Leaving this blank will convert all faces that exist within the " -"alignments file." -msgstr "" -"Si no ha limpiado su archivo de alineaciones, puede filtrar las caras " -"definiendo aquí una carpeta que contenga las caras extraídas de sus archivos/" -"vídeos de entrada. Si se define esta carpeta, sólo se convertirán las caras " -"que existan en el archivo de alineaciones y también en la carpeta " -"especificada. Si se deja en blanco, se convertirán todas las caras que " -"existan en el archivo de alineaciones." - -#: lib/cli/args.py:827 -msgid "" -"Optionally filter out people who you do not wish to process by passing in an " -"image of that person. Should be a front portrait with a single person in the " -"image. Multiple images can be added space separated. NB: Using face filter " -"will significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." -msgstr "" -"Opcionalmente, puede filtrar las personas que no desea procesar pasando una " -"imagen de esa persona. Debe ser un retrato frontal con una sola persona en " -"la imagen. Se pueden añadir varias imágenes separadas por espacios. NB: El " -"uso del filtro de caras disminuirá significativamente la velocidad de " -"extracción y no se puede garantizar su precisión." - -#: lib/cli/args.py:840 -msgid "" -"Optionally select people you wish to process by passing in an image of that " -"person. Should be a front portrait with a single person in the image. " -"Multiple images can be added space separated. NB: Using face filter will " -"significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." -msgstr "" -"Opcionalmente, seleccione las personas que desea procesar pasando una imagen " -"de esa persona. Debe ser un retrato frontal con una sola persona en la " -"imagen. Se pueden añadir varias imágenes separadas por espacios. NB: El uso " -"del filtro facial disminuirá significativamente la velocidad de extracción y " -"no se puede garantizar su precisión." - -#: lib/cli/args.py:854 -msgid "" -"For use with the optional nfilter/filter files. Threshold for positive face " -"recognition. Lower values are stricter. NB: Using face filter will " -"significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." -msgstr "" -"Para usar con los archivos opcionales nfilter/filter. Umbral para el " -"reconocimiento positivo de caras. Los valores más bajos son más estrictos. " -"NB: El uso del filtro facial disminuirá significativamente la velocidad de " -"extracción y no se puede garantizar su precisión." - -#: lib/cli/args.py:867 -msgid "" -"The maximum number of parallel processes for performing conversion. " -"Converting images is system RAM heavy so it is possible to run out of memory " -"if you have a lot of processes and not enough RAM to accommodate them all. " -"Setting this to 0 will use the maximum available. No matter what you set " -"this to, it will never attempt to use more processes than are available on " -"your system. If singleprocess is enabled this setting will be ignored." -msgstr "" -"El número máximo de procesos paralelos para realizar la conversión. La " -"conversión de imágenes requiere mucha RAM del sistema, por lo que es posible " -"que se agote la memoria si tiene muchos procesos y no hay suficiente RAM " -"para acomodarlos a todos. Si se ajusta a 0, se utilizará el máximo " -"disponible. No importa lo que establezca, nunca intentará utilizar más " -"procesos que los disponibles en su sistema. Si 'singleprocess' está " -"habilitado, este ajuste será ignorado." - -#: lib/cli/args.py:878 -msgid "" -"[LEGACY] This only needs to be selected if a legacy model is being loaded or " -"if there are multiple models in the model folder" -msgstr "" -"[LEGACY] Sólo es necesario seleccionar esta opción si se está cargando un " -"modelo heredado si hay varios modelos en la carpeta de modelos" - -#: lib/cli/args.py:886 -msgid "" -"Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " -"alignments file for your destination video. However, if you wish you can " -"generate the alignments on-the-fly by enabling this option. This will use an " -"inferior extraction pipeline and will lead to substandard results. If an " -"alignments file is found, this option will be ignored." -msgstr "" -"Activar la conversión sobre la marcha. NO se recomienda. Debe generar un " -"archivo de alineación limpio para su vídeo de destino. Sin embargo, si lo " -"desea, puede generar las alineaciones sobre la marcha activando esta opción. " -"Esto utilizará una tubería de extracción inferior y conducirá a resultados " -"de baja calidad. Si se encuentra un archivo de alineaciones, esta opción " -"será ignorada." - -#: lib/cli/args.py:897 -msgid "" -"When used with --frame-ranges outputs the unchanged frames that are not " -"processed instead of discarding them." -msgstr "" -"Cuando se usa con --frame-ranges, la salida incluye los fotogramas no " -"procesados en vez de descartarlos." - -#: lib/cli/args.py:905 -msgid "Swap the model. Instead converting from of A -> B, converts B -> A" -msgstr "" -"Intercambiar el modelo. En vez de convertir de A a B, convierte de B a A" - -#: lib/cli/args.py:911 -msgid "Disable multiprocessing. Slower but less resource intensive." -msgstr "Desactiva el multiproceso. Es más lento, pero usa menos recursos." - -#: lib/cli/args.py:927 -msgid "" -"Train a model on extracted original (A) and swap (B) faces.\n" -"Training models can take a long time. Anything from 24hrs to over a week\n" -"Model plugins can be configured in the 'Settings' Menu" -msgstr "" -"Entrene un modelo con las caras originales (A) e intercambiadas (B) " -"extraídas.\n" -"El entrenamiento de los modelos puede llevar mucho tiempo. Desde 24 horas " -"hasta más de una semana.\n" -"Los plugins de los modelos pueden configurarse en el menú \"Ajustes\"" - -#: lib/cli/args.py:946 lib/cli/args.py:955 -msgid "faces" -msgstr "caras" - -#: lib/cli/args.py:947 -msgid "" -"Input directory. A directory containing training images for face A. This is " -"the original face, i.e. the face that you want to remove and replace with " -"face B." -msgstr "" -"Directorio de entrada. Un directorio que contiene imágenes de entrenamiento " -"para la cara A. Esta es la cara original, es decir, la cara que se quiere " -"eliminar y sustituir por la cara B." - -#: lib/cli/args.py:956 -msgid "" -"Input directory. A directory containing training images for face B. This is " -"the swap face, i.e. the face that you want to place onto the head of person " -"A." -msgstr "" -"Directorio de entrada. Un directorio que contiene imágenes de entrenamiento " -"para la cara B. Esta es la cara de intercambio, es decir, la cara que se " -"quiere colocar en la cabeza de la persona A." - -#: lib/cli/args.py:964 lib/cli/args.py:976 lib/cli/args.py:992 -#: lib/cli/args.py:1017 lib/cli/args.py:1027 -msgid "model" -msgstr "modelo" - -#: lib/cli/args.py:965 -msgid "" -"Model directory. This is where the training data will be stored. You should " -"always specify a new folder for new models. If starting a new model, select " -"either an empty folder, or a folder which does not exist (which will be " -"created). If continuing to train an existing model, specify the location of " -"the existing model." -msgstr "" -"Directorio del modelo. Aquí es donde se almacenarán los datos de " -"entrenamiento. Siempre debe especificar una nueva carpeta para los nuevos " -"modelos. Si se inicia un nuevo modelo, seleccione una carpeta vacía o una " -"carpeta que no exista (que se creará). Si continúa entrenando un modelo " -"existente, especifique la ubicación del modelo existente." - -#: lib/cli/args.py:977 -msgid "" -"R|Load the weights from a pre-existing model into a newly created model. For " -"most models this will load weights from the Encoder of the given model into " -"the encoder of the newly created model. Some plugins may have specific " -"configuration options allowing you to load weights from other layers. " -"Weights will only be loaded when creating a new model. This option will be " -"ignored if you are resuming an existing model. Generally you will also want " -"to 'freeze-weights' whilst the rest of your model catches up with your " -"Encoder.\n" -"NB: Weights can only be loaded from models of the same plugin as you intend " -"to train." -msgstr "" -"R|Cargue los pesos de un modelo preexistente en un modelo recién creado. " -"Para la mayoría de los modelos, esto cargará pesos del codificador del " -"modelo dado en el codificador del modelo recién creado. Algunos complementos " -"pueden tener opciones de configuración específicas que le permiten cargar " -"pesos de otras capas. Los pesos solo se cargarán al crear un nuevo modelo. " -"Esta opción se ignorará si está reanudando un modelo existente. En general, " -"también querrá 'congelar pesos' mientras el resto de su modelo se pone al " -"día con su codificador.\n" -"NB: Los pesos solo se pueden cargar desde modelos del mismo complemento que " -"desea entrenar." - -#: lib/cli/args.py:993 -msgid "" -"R|Select which trainer to use. Trainers can be configured from the Settings " -"menu or the config folder.\n" -"L|original: The original model created by /u/deepfakes.\n" -"L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " -"for full dfaker method.\n" -"L|dfl-h128: 128px in/out model from deepfacelab\n" -"L|dfl-sae: Adaptable model from deepfacelab\n" -"L|dlight: A lightweight, high resolution DFaker variant.\n" -"L|iae: A model that uses intermediate layers to try to get better details\n" -"L|lightweight: A lightweight model for low-end cards. Don't expect great " -"results. Can train as low as 1.6GB with batch size 8.\n" -"L|realface: A high detail, dual density model based on DFaker, with " -"customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " -"won't work so well. By andenixa et al. Very configurable.\n" -"L|unbalanced: 128px in/out model from andenixa. The autoencoders are " -"unbalanced so B>A swaps won't work so well. Very configurable.\n" -"L|villain: 128px in/out model from villainguy. Very resource hungry (You " -"will require a GPU with a fair amount of VRAM). Good for details, but more " -"susceptible to color differences." -msgstr "" -"R|Seleccione el entrenador que desea utilizar. Los entrenadores se pueden " -"configurar desde el menú de configuración o la carpeta de configuración.\n" -"L|original: El modelo original creado por /u/deepfakes.\n" -"L|dfaker: Modelo de 64px in/128px out de dfaker. Habilitar 'warp-to-" -"landmarks' para el método completo de dfaker.\n" -"L|dfl-h128: modelo de 128px in/out de deepfacelab\n" -"L|dfl-sae: Modelo adaptable de deepfacelab\n" -"L|dlight: Una variante de DFaker ligera y de alta resolución.\n" -"L|iae: Un modelo que utiliza capas intermedias para tratar de obtener " -"mejores detalles.\n" -"L|lightweight: Un modelo ligero para tarjetas de gama baja. No esperes " -"grandes resultados. Puede entrenar hasta 1,6GB con tamaño de lote 8.\n" -"L|realface: Un modelo de alto detalle y doble densidad basado en DFaker, con " -"resolución de entrada y salida personalizable. Los autocodificadores están " -"desequilibrados, por lo que los intercambios B>A no funcionan tan bien. Por " -"andenixa et al. Muy configurable\n" -"L|Unbalanced: modelo de 128px de entrada/salida de andenixa. Los " -"autocodificadores están desequilibrados por lo que los intercambios B>A no " -"funcionarán tan bien. Muy configurable\n" -"L|villain: Modelo de 128px de entrada/salida de villainguy. Requiere muchos " -"recursos (se necesita una GPU con una buena cantidad de VRAM). Bueno para " -"los detalles, pero más susceptible a las diferencias de color." - -#: lib/cli/args.py:1018 -msgid "" -"Output a summary of the model and exit. If a model folder is provided then a " -"summary of the saved model is displayed. Otherwise a summary of the model " -"that would be created by the chosen plugin and configuration settings is " -"displayed." -msgstr "" -"Genere un resumen del modelo y salga. Si se proporciona una carpeta de " -"modelo, se muestra un resumen del modelo guardado. De lo contrario, se " -"muestra un resumen del modelo que crearía el complemento elegido y los " -"ajustes de configuración." - -#: lib/cli/args.py:1028 -msgid "" -"Freeze the weights of the model. Freezing weights means that some of the " -"parameters in the model will no longer continue to learn, but those that are " -"not frozen will continue to learn. For most models, this will freeze the " -"encoder, but some models may have configuration options for freezing other " -"layers." -msgstr "" -"Congele los pesos del modelo. Congelar pesos significa que algunos de los " -"parámetros del modelo ya no seguirán aprendiendo, pero los que no están " -"congelados seguirán aprendiendo. Para la mayoría de los modelos, esto " -"congelará el codificador, pero algunos modelos pueden tener opciones de " -"configuración para congelar otras capas." - -#: lib/cli/args.py:1041 lib/cli/args.py:1053 lib/cli/args.py:1067 -#: lib/cli/args.py:1082 lib/cli/args.py:1090 -msgid "training" -msgstr "entrenamiento" - -#: lib/cli/args.py:1042 -msgid "" -"Batch size. This is the number of images processed through the model for " -"each side per iteration. NB: As the model is fed 2 sides at a time, the " -"actual number of images within the model at any one time is double the " -"number that you set here. Larger batches require more GPU RAM." -msgstr "" -"Tamaño del lote. Este es el número de imágenes procesadas a través del " -"modelo para cada lado por iteración. Nota: Como el modelo se alimenta de 2 " -"lados a la vez, el número real de imágenes dentro del modelo en cualquier " -"momento es el doble del número que se establece aquí. Los lotes más grandes " -"requieren más RAM de la GPU." - -#: lib/cli/args.py:1054 -msgid "" -"Length of training in iterations. This is only really used for automation. " -"There is no 'correct' number of iterations a model should be trained for. " -"You should stop training when you are happy with the previews. However, if " -"you want the model to stop automatically at a set number of iterations, you " -"can set that value here." -msgstr "" -"Duración del entrenamiento en iteraciones. Esto sólo se utiliza realmente " -"para la automatización. No hay un número 'correcto' de iteraciones para las " -"que deba entrenarse un modelo. Debe dejar de entrenar cuando esté satisfecho " -"con las previsiones. Sin embargo, si desea que el modelo se detenga " -"automáticamente en un número determinado de iteraciones, puede establecer " -"ese valor aquí." - -#: lib/cli/args.py:1068 -msgid "" -"R|Select the distribution stategy to use.\n" -"L|default: Use Tensorflow's default distribution strategy.\n" -"L|central-storage: Centralizes variables on the CPU whilst operations are " -"performed on 1 or more local GPUs. This can help save some VRAM at the cost " -"of some speed by not storing variables on the GPU. Note: Mixed-Precision is " -"not supported on multi-GPU setups.\n" -"L|mirrored: Supports synchronous distributed training across multiple local " -"GPUs. A copy of the model and all variables are loaded onto each GPU with " -"batches distributed to each GPU at each iteration." -msgstr "" -"562 / 5,000\n" -"Translation results\n" -"R|Seleccione la estrategia de distribución a utilizar.\n" -"L|default: utiliza la estrategia de distribución predeterminada de " -"Tensorflow.\n" -"L|central-storage: centraliza las variables en la CPU mientras que las " -"operaciones se realizan en 1 o más GPU locales. Esto puede ayudar a ahorrar " -"algo de VRAM a costa de cierta velocidad al no almacenar variables en la " -"GPU. Nota: Mixed-Precision no es compatible con configuraciones de múltiples " -"GPU.\n" -"L|mirrored: Admite el entrenamiento distribuido síncrono en varias GPU " -"locales. Se carga una copia del modelo y todas las variables en cada GPU con " -"lotes distribuidos a cada GPU en cada iteración." - -#: lib/cli/args.py:1083 -msgid "" -"Disables TensorBoard logging. NB: Disabling logs means that you will not be " -"able to use the graph or analysis for this session in the GUI." -msgstr "" -"Desactiva el registro de TensorBoard. NB: Desactivar los registros significa " -"que no podrá utilizar el gráfico o el análisis de esta sesión en la GUI." - -#: lib/cli/args.py:1091 -msgid "" -"Use the Learning Rate Finder to discover the optimal learning rate for " -"training. For new models, this will calculate the optimal learning rate for " -"the model. For existing models this will use the optimal learning rate that " -"was discovered when initializing the model. Setting this option will ignore " -"the manually configured learning rate (configurable in train settings)." -msgstr "" -"Utilice el Buscador de tasa de aprendizaje para descubrir la tasa de " -"aprendizaje óptima para la capacitación. Para modelos nuevos, esto calculará " -"la tasa de aprendizaje óptima para el modelo. Para los modelos existentes, " -"esto utilizará la tasa de aprendizaje óptima que se descubrió al inicializar " -"el modelo. Configurar esta opción ignorará la tasa de aprendizaje " -"configurada manualmente (configurable en la configuración del tren)." - -#: lib/cli/args.py:1104 lib/cli/args.py:1114 -msgid "Saving" -msgstr "Guardar" - -#: lib/cli/args.py:1105 -msgid "Sets the number of iterations between each model save." -msgstr "Establece el número de iteraciones entre cada guardado del modelo." - -#: lib/cli/args.py:1115 -msgid "" -"Sets the number of iterations before saving a backup snapshot of the model " -"in it's current state. Set to 0 for off." -msgstr "" -"Establece el número de iteraciones antes de guardar una copia de seguridad " -"del modelo en su estado actual. Establece 0 para que esté desactivado." - -#: lib/cli/args.py:1122 lib/cli/args.py:1133 lib/cli/args.py:1144 -msgid "timelapse" -msgstr "intervalo" - -#: lib/cli/args.py:1123 -msgid "" -"Optional for creating a timelapse. Timelapse will save an image of your " -"selected faces into the timelapse-output folder at every save iteration. " -"This should be the input folder of 'A' faces that you would like to use for " -"creating the timelapse. You must also supply a --timelapse-output and a --" -"timelapse-input-B parameter." -msgstr "" -"Opcional para crear un timelapse. Timelapse guardará una imagen de las caras " -"seleccionadas en la carpeta timelapse-output en cada iteración de guardado. " -"Esta debe ser la carpeta de entrada de las caras \"A\" que desea utilizar " -"para crear el timelapse. También debe suministrar un parámetro --timelapse-" -"output y un parámetro --timelapse-input-B." - -#: lib/cli/args.py:1134 -msgid "" -"Optional for creating a timelapse. Timelapse will save an image of your " -"selected faces into the timelapse-output folder at every save iteration. " -"This should be the input folder of 'B' faces that you would like to use for " -"creating the timelapse. You must also supply a --timelapse-output and a --" -"timelapse-input-A parameter." -msgstr "" -"Opcional para crear un timelapse. Timelapse guardará una imagen de las caras " -"seleccionadas en la carpeta timelapse-output en cada iteración de guardado. " -"Esta debe ser la carpeta de entrada de las caras \"B\" que desea utilizar " -"para crear el timelapse. También debe suministrar un parámetro --timelapse-" -"output y un parámetro --timelapse-input-A." - -#: lib/cli/args.py:1145 -msgid "" -"Optional for creating a timelapse. Timelapse will save an image of your " -"selected faces into the timelapse-output folder at every save iteration. If " -"the input folders are supplied but no output folder, it will default to your " -"model folder /timelapse/" -msgstr "" -"Opcional para crear un timelapse. Timelapse guardará una imagen de las caras " -"seleccionadas en la carpeta timelapse-output en cada iteración de guardado. " -"Si se suministran las carpetas de entrada pero no la carpeta de salida, se " -"guardará por defecto en la carpeta del modelo /timelapse/" - -#: lib/cli/args.py:1154 lib/cli/args.py:1161 -msgid "preview" -msgstr "previsualización" - -#: lib/cli/args.py:1155 -msgid "Show training preview output. in a separate window." -msgstr "" -"Mostrar la salida de la vista previa del entrenamiento. en una ventana " -"separada." - -#: lib/cli/args.py:1162 -msgid "" -"Writes the training result to a file. The image will be stored in the root " -"of your FaceSwap folder." -msgstr "" -"Escribe el resultado del entrenamiento en un archivo. La imagen se " -"almacenará en la raíz de su carpeta FaceSwap." - -#: lib/cli/args.py:1169 lib/cli/args.py:1178 lib/cli/args.py:1187 -#: lib/cli/args.py:1196 -msgid "augmentation" -msgstr "aumento" - -#: lib/cli/args.py:1170 -msgid "" -"Warps training faces to closely matched Landmarks from the opposite face-set " -"rather than randomly warping the face. This is the 'dfaker' way of doing " -"warping." -msgstr "" -"Deforma las caras de entrenamiento a puntos de referencia muy parecidos del " -"conjunto de caras opuestas en lugar de deformar la cara al azar. Esta es la " -"forma 'dfaker' de hacer la deformación." - -#: lib/cli/args.py:1179 -msgid "" -"To effectively learn, a random set of images are flipped horizontally. " -"Sometimes it is desirable for this not to occur. Generally this should be " -"left off except for during 'fit training'." -msgstr "" -"Para aprender de forma efectiva, se voltea horizontalmente un conjunto " -"aleatorio de imágenes. A veces es deseable que esto no ocurra. Por lo " -"general, esto debería dejarse sin efecto, excepto durante el 'entrenamiento " -"de ajuste'." - -#: lib/cli/args.py:1188 -msgid "" -"Color augmentation helps make the model less susceptible to color " -"differences between the A and B sets, at an increased training time cost. " -"Enable this option to disable color augmentation." -msgstr "" -"El aumento del color ayuda a que el modelo sea menos susceptible a las " -"diferencias de color entre los conjuntos A y B, con un mayor coste de tiempo " -"de entrenamiento. Activa esta opción para desactivar el aumento de color." - -#: lib/cli/args.py:1197 -msgid "" -"Warping is integral to training the Neural Network. This option should only " -"be enabled towards the very end of training to try to bring out more detail. " -"Think of it as 'fine-tuning'. Enabling this option from the beginning is " -"likely to kill a model and lead to terrible results." -msgstr "" -"La deformación es fundamental para el entrenamiento de la red neuronal. Esta " -"opción sólo debería activarse hacia el final del entrenamiento para tratar " -"de obtener más detalles. Piense en ello como un 'ajuste fino'. Si se activa " -"esta opción desde el principio, es probable que arruine el modelo y se " -"obtengan resultados terribles." - -#: lib/cli/args.py:1222 +#: lib/cli/args.py:319 msgid "Output to Shell console instead of GUI console" msgstr "Salida a la consola Shell en lugar de la consola GUI" - -#~ msgid "" -#~ "[Deprecated - Use '-D, --distribution-strategy' instead] Use the " -#~ "Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." -#~ msgstr "" -#~ "[Obsoleto: use '-D, --distribution-strategy' en su lugar] Use la " -#~ "estrategia de distribución duplicada de Tensorflow para entrenar en " -#~ "varias GPU." - -#~ msgid "" -#~ "DEPRECATED - This option will be removed in a future update. Path to " -#~ "alignments file for training set A. Defaults to /alignments.json " -#~ "if not provided." -#~ msgstr "" -#~ "DEPRECIADO - Esta opción se eliminará en una futura actualización. Ruta " -#~ "al archivo de alineaciones para el conjunto de entrenamiento A. Por " -#~ "defecto es /alignments.json si no se proporciona." - -#~ msgid "" -#~ "DEPRECATED - This option will be removed in a future update. Path to " -#~ "alignments file for training set B. Defaults to /alignments.json " -#~ "if not provided." -#~ msgstr "" -#~ "DEPRECIADO - Esta opción se eliminará en una futura actualización. Ruta " -#~ "al archivo de alineaciones para el conjunto de entrenamiento B. Por " -#~ "defecto es /alignments.json si no se proporciona." diff --git a/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.mo b/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.mo new file mode 100644 index 0000000000000000000000000000000000000000..70acb02db09c2848aed6891b43859a4977d3f793 GIT binary patch literal 31533 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 literal 0 HcmV?d00001 diff --git a/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.po b/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.po new file mode 100755 index 0000000000..290d476d2f --- /dev/null +++ b/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.po @@ -0,0 +1,713 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: faceswap.spanish\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-28 18:11+0000\n" +"PO-Revision-Date: 2024-03-28 18:13+0000\n" +"Last-Translator: \n" +"Language-Team: tokafondo\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.4.2\n" + +#: lib/cli/args_extract_convert.py:46 lib/cli/args_extract_convert.py:56 +#: lib/cli/args_extract_convert.py:64 lib/cli/args_extract_convert.py:122 +#: lib/cli/args_extract_convert.py:479 lib/cli/args_extract_convert.py:488 +msgid "Data" +msgstr "Datos" + +#: lib/cli/args_extract_convert.py:48 +msgid "" +"Input directory or video. Either a directory containing the image files you " +"wish to process or path to a video file. NB: This should be the source video/" +"frames NOT the source faces." +msgstr "" +"Directorio o vídeo de entrada. Un directorio que contenga los archivos de " +"imagen que desea procesar o la ruta a un archivo de vídeo. NB: Debe ser el " +"vídeo/los fotogramas de origen, NO las caras de origen." + +#: lib/cli/args_extract_convert.py:57 +msgid "Output directory. This is where the converted files will be saved." +msgstr "" +"Directorio de salida. Aquí es donde se guardarán los archivos convertidos." + +#: lib/cli/args_extract_convert.py:66 +msgid "" +"Optional path to an alignments file. Leave blank if the alignments file is " +"at the default location." +msgstr "" +"Ruta opcional a un archivo de alineaciones. Dejar en blanco si el archivo de " +"alineaciones está en la ubicación por defecto." + +#: lib/cli/args_extract_convert.py:97 +msgid "" +"Extract faces from image or video sources.\n" +"Extraction plugins can be configured in the 'Settings' Menu" +msgstr "" +"Extrae caras de fuentes de imagen o video.\n" +"Los plugins de extracción pueden ser configuradas en el menú de 'Ajustes'" + +#: lib/cli/args_extract_convert.py:124 +msgid "" +"R|If selected then the input_dir should be a parent folder containing " +"multiple videos and/or folders of images you wish to extract from. The faces " +"will be output to separate sub-folders in the output_dir." +msgstr "" +"Si se selecciona, input_dir debe ser una carpeta principal que contenga " +"varios videos y/o carpetas de imágenes de las que desea extraer. Las caras " +"se enviarán a subcarpetas separadas en output_dir." + +#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:150 +#: lib/cli/args_extract_convert.py:163 lib/cli/args_extract_convert.py:202 +#: lib/cli/args_extract_convert.py:220 lib/cli/args_extract_convert.py:233 +#: lib/cli/args_extract_convert.py:243 lib/cli/args_extract_convert.py:253 +#: lib/cli/args_extract_convert.py:499 lib/cli/args_extract_convert.py:525 +#: lib/cli/args_extract_convert.py:564 +msgid "Plugins" +msgstr "Extensiones" + +#: lib/cli/args_extract_convert.py:135 +msgid "" +"R|Detector to use. Some of these have configurable settings in '/config/" +"extract.ini' or 'Settings > Configure Extract 'Plugins':\n" +"L|cv2-dnn: A CPU only extractor which is the least reliable and least " +"resource intensive. Use this if not using a GPU and time is important.\n" +"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " +"than other GPU detectors but can often return more false positives.\n" +"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " +"fewer false positives than other GPU detectors, but is a lot more resource " +"intensive." +msgstr "" +"R|Detector de caras a usar. Algunos tienen ajustes configurables en '/config/" +"extract.ini' o 'Ajustes > Configurar Extensiones de Extracción:\n" +"L|cv2-dnn: Extractor que usa sólo la CPU. Es el menos fiable y el que menos " +"recursos usa. Elegir este si necesita rapidez y no usar la GPU.\n" +"L|mtcnn: Buen detector. Rápido en la CPU y más rápido en la GPU. Usa menos " +"recursos que otros detectores basados en GPU, pero puede devolver más falsos " +"positivos.\n" +"L|s3fd: El mejor detector. Lento en la CPU, y más rápido en la GPU. Puede " +"detectar más caras y tiene menos falsos positivos que otros detectores " +"basados en GPU, pero uso muchos más recursos." + +#: lib/cli/args_extract_convert.py:152 +msgid "" +"R|Aligner to use.\n" +"L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " +"but less accurate. Only use this if not using a GPU and time is important.\n" +"L|fan: Best aligner. Fast on GPU, slow on CPU." +msgstr "" +"R|Alineador a usar.\n" +"L|cv2-dnn: Detector que usa sólo la CPU. Más rápido, usa menos recursos, " +"pero es menos preciso. Elegir este si necesita rapidez y no usar la GPU.\n" +"L|fan: El mejor alineador. Rápido en la GPU, y lento en la CPU." + +#: lib/cli/args_extract_convert.py:165 +msgid "" +"R|Additional Masker(s) to use. The masks generated here will all take up GPU " +"RAM. You can select none, one or multiple masks, but the extraction may take " +"longer the more you select. NB: The Extended and Components (landmark based) " +"masks are automatically generated on extraction.\n" +"L|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).\n" +"L|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 so will not use any additional VRAM.\n" +"L|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.\n" +"L|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.\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.\n" +"The auto generated masks are as follows:\n" +"L|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.\n" +"L|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.\n" +"(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" +msgstr "" +"R|Enmascarador(es) adicional(es) a usar. Las máscaras generadas aquí usarán " +"todas RAM de la GPU. Puede seleccionar una, varias o ninguna máscaras, pero " +"la extracción tardará más cuanto más marque. Las máscaras Extended y " +"Components son siempre generadas durante la extracción.\n" +"L|bisenet-fp: Máscara relativamente ligera basada en NN que proporciona un " +"control más refinado sobre el área a enmascarar, incluido el enmascaramiento " +"completo de la cabeza (configurable en la configuración de la máscara).\n" +"L|custom: Una máscara ficticia que llena el área de la máscara con 1 o 0 " +"(configurable en la configuración). Esto solo es necesario si tiene la " +"intención de editar manualmente las máscaras personalizadas usted mismo en " +"la herramienta manual. Esta máscara no usa la GPU, por lo que no usará VRAM " +"adicional.\n" +"L|vgg-clear: Máscara diseñada para proporcionar una segmentación inteligente " +"de rostros principalmente frontales y libres de obstrucciones. Los rostros " +"de perfil y las obstrucciones pueden dar lugar a un rendimiento inferior.\n" +"L|vgg-obstructed: Máscara diseñada para proporcionar una segmentación " +"inteligente de rostros principalmente frontales. El modelo de la máscara ha " +"sido entrenado específicamente para reconocer algunas obstrucciones faciales " +"(manos y gafas). Los rostros de perfil pueden dar lugar a un rendimiento " +"inferior.\n" +"L|unet-dfl: Máscara diseñada para proporcionar una segmentación inteligente " +"de rostros principalmente frontales. El modelo de máscara ha sido entrenado " +"por los miembros de la comunidad y necesitará ser probado para una mayor " +"descripción. Los rostros de perfil pueden dar lugar a un rendimiento " +"inferior.\n" +"Las máscaras que siempre se generan son:\n" +"L|components: Máscara diseñada para proporcionar una segmentación facial " +"basada en el posicionamiento de las ubicaciones de los puntos de referencia. " +"Se construye un casco convexo alrededor del exterior de los puntos de " +"referencia para crear una máscara.\n" +"L|extended: Máscara diseñada para proporcionar una segmentación facial " +"basada en el posicionamiento de las ubicaciones de los puntos de referencia. " +"Se construye un casco convexo alrededor del exterior de los puntos de " +"referencia y la máscara se extiende hacia arriba en la frente.\n" +"(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" + +#: lib/cli/args_extract_convert.py:204 +msgid "" +"R|Performing normalization can help the aligner better align faces with " +"difficult lighting conditions at an extraction speed cost. Different methods " +"will yield different results on different sets. NB: This does not impact the " +"output face, just the input to the aligner.\n" +"L|none: Don't perform normalization on the face.\n" +"L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " +"face.\n" +"L|hist: Equalize the histograms on the RGB channels.\n" +"L|mean: Normalize the face colors to the mean." +msgstr "" +"R|Realizar la normalización puede ayudar al alineador a alinear mejor las " +"caras con condiciones de iluminación difíciles a un coste de velocidad de " +"extracción. Diferentes métodos darán diferentes resultados en diferentes " +"conjuntos. NB: Esto no afecta a la cara de salida, sólo a la entrada del " +"alineador.\n" +"L|none: No realice la normalización en la cara.\n" +"L|clahe: Realice la ecualización adaptativa del histograma con contraste " +"limitado en el rostro.\n" +"L|hist: Iguala los histogramas de los canales RGB.\n" +"L|mean: Normalizar los colores de la cara a la media." + +#: lib/cli/args_extract_convert.py:222 +msgid "" +"The number of times to re-feed the detected face into the aligner. Each time " +"the face is re-fed into the aligner the bounding box is adjusted by a small " +"amount. The final landmarks are then averaged from each iteration. Helps to " +"remove 'micro-jitter' but at the cost of slower extraction speed. The more " +"times the face is re-fed into the aligner, the less micro-jitter should " +"occur but the longer extraction will take." +msgstr "" +"El número de veces que hay que volver a introducir la cara detectada en el " +"alineador. Cada vez que la cara se vuelve a introducir en el alineador, el " +"cuadro delimitador se ajusta en una pequeña cantidad. Los puntos de " +"referencia finales se promedian en cada iteración. Esto ayuda a eliminar el " +"'micro-jitter', pero a costa de una menor velocidad de extracción. Cuantas " +"más veces se vuelva a introducir la cara en el alineador, menos " +"microfluctuaciones se producirán, pero la extracción será más larga." + +#: lib/cli/args_extract_convert.py:235 +msgid "" +"Re-feed the initially found aligned face through the aligner. Can help " +"produce better alignments for faces that are rotated beyond 45 degrees in " +"the frame or are at extreme angles. Slows down extraction." +msgstr "" +"Vuelva a introducir la cara alineada encontrada inicialmente a través del " +"alineador. Puede ayudar a producir mejores alineaciones para las caras que " +"se giran más de 45 grados en el marco o se encuentran en ángulos extremos. " +"Ralentiza la extracción." + +#: lib/cli/args_extract_convert.py:245 +msgid "" +"If a face isn't found, rotate the images to try to find a face. Can find " +"more faces at the cost of extraction speed. Pass in a single number to use " +"increments of that size up to 360, or pass in a list of numbers to enumerate " +"exactly what angles to check." +msgstr "" +"Si no se encuentra una cara, gira las imágenes para intentar encontrar una " +"cara. Puede encontrar más caras a costa de la velocidad de extracción. Pase " +"un solo número para usar incrementos de ese tamaño hasta 360, o pase una " +"lista de números para enumerar exactamente qué ángulos comprobar." + +#: lib/cli/args_extract_convert.py:255 +msgid "" +"Obtain and store face identity encodings from VGGFace2. Slows down extract a " +"little, but will save time if using 'sort by face'" +msgstr "" +"Obtenga y almacene codificaciones de identidad facial de VGGFace2. Ralentiza " +"un poco la extracción, pero ahorrará tiempo si usa 'sort by face'" + +#: lib/cli/args_extract_convert.py:265 lib/cli/args_extract_convert.py:276 +#: lib/cli/args_extract_convert.py:289 lib/cli/args_extract_convert.py:303 +#: lib/cli/args_extract_convert.py:610 lib/cli/args_extract_convert.py:619 +#: lib/cli/args_extract_convert.py:634 lib/cli/args_extract_convert.py:647 +#: lib/cli/args_extract_convert.py:661 +msgid "Face Processing" +msgstr "Proceso de Caras" + +#: lib/cli/args_extract_convert.py:267 +msgid "" +"Filters out faces detected below this size. Length, in pixels across the " +"diagonal of the bounding box. Set to 0 for off" +msgstr "" +"Filtra las caras detectadas por debajo de este tamaño. Longitud, en píxeles " +"a lo largo de la diagonal del cuadro delimitador. Establecer a 0 para " +"desactivar" + +#: lib/cli/args_extract_convert.py:278 +msgid "" +"Optionally filter out people who you do not wish to extract by passing in " +"images of those people. Should be a small variety of images at different " +"angles and in different conditions. A folder containing the required images " +"or multiple image files, space separated, can be selected." +msgstr "" +"Opcionalmente, filtre a las personas que no desea extraer pasando imágenes " +"de esas personas. Debe ser una pequeña variedad de imágenes en diferentes " +"ángulos y en diferentes condiciones. Se puede seleccionar una carpeta que " +"contenga las imágenes requeridas o múltiples archivos de imágenes, separados " +"por espacios." + +#: lib/cli/args_extract_convert.py:291 +msgid "" +"Optionally select people you wish to extract by passing in images of that " +"person. Should be a small variety of images at different angles and in " +"different conditions A folder containing the required images or multiple " +"image files, space separated, can be selected." +msgstr "" +"Opcionalmente, seleccione las personas que desea extraer pasando imágenes de " +"esa persona. Debe haber una pequeña variedad de imágenes en diferentes " +"ángulos y en diferentes condiciones. Se puede seleccionar una carpeta que " +"contenga las imágenes requeridas o múltiples archivos de imágenes, separados " +"por espacios." + +#: lib/cli/args_extract_convert.py:305 +msgid "" +"For use with the optional nfilter/filter files. Threshold for positive face " +"recognition. Higher values are stricter." +msgstr "" +"Para usar con los archivos nfilter/filter opcionales. Umbral para el " +"reconocimiento facial positivo. Los valores más altos son más estrictos." + +#: lib/cli/args_extract_convert.py:314 lib/cli/args_extract_convert.py:327 +#: lib/cli/args_extract_convert.py:340 lib/cli/args_extract_convert.py:352 +msgid "output" +msgstr "salida" + +#: lib/cli/args_extract_convert.py:316 +msgid "" +"The output size of extracted faces. Make sure that the model you intend to " +"train supports your required size. This will only need to be changed for hi-" +"res models." +msgstr "" +"El tamaño de salida de las caras extraídas. Asegúrese de que el modelo que " +"pretende entrenar admite el tamaño deseado. Esto sólo tendrá que ser " +"cambiado para los modelos de alta resolución." + +#: lib/cli/args_extract_convert.py:329 +msgid "" +"Extract every 'nth' frame. This option will skip frames when extracting " +"faces. For example a value of 1 will extract faces from every frame, a value " +"of 10 will extract faces from every 10th frame." +msgstr "" +"Extraer cada 'enésimo' fotograma. Esta opción omitirá los fotogramas al " +"extraer las caras. Por ejemplo, un valor de 1 extraerá las caras de cada " +"fotograma, un valor de 10 extraerá las caras de cada 10 fotogramas." + +#: lib/cli/args_extract_convert.py:342 +msgid "" +"Automatically save the alignments file after a set amount of frames. By " +"default the alignments file is only saved at the end of the extraction " +"process. NB: If extracting in 2 passes then the alignments file will only " +"start to be saved out during the second pass. WARNING: Don't interrupt the " +"script when writing the file because it might get corrupted. Set to 0 to " +"turn off" +msgstr "" +"Guardar automáticamente el archivo de alineaciones después de una cantidad " +"determinada de cuadros. Por defecto, el archivo de alineaciones sólo se " +"guarda al final del proceso de extracción. Nota: Si se extrae en 2 pases, el " +"archivo de alineaciones sólo se empezará a guardar durante el segundo pase. " +"ADVERTENCIA: No interrumpa el script al escribir el archivo porque podría " +"corromperse. Poner a 0 para desactivar" + +#: lib/cli/args_extract_convert.py:353 +msgid "Draw landmarks on the ouput faces for debugging purposes." +msgstr "" +"Dibujar puntos de referencia en las caras de salida para fines de depuración." + +#: lib/cli/args_extract_convert.py:359 lib/cli/args_extract_convert.py:369 +#: lib/cli/args_extract_convert.py:377 lib/cli/args_extract_convert.py:384 +#: lib/cli/args_extract_convert.py:674 lib/cli/args_extract_convert.py:686 +#: lib/cli/args_extract_convert.py:695 lib/cli/args_extract_convert.py:716 +#: lib/cli/args_extract_convert.py:722 +msgid "settings" +msgstr "ajustes" + +#: lib/cli/args_extract_convert.py:361 +msgid "" +"Don't run extraction in parallel. Will run each part of the extraction " +"process separately (one after the other) rather than all at the same time. " +"Useful if VRAM is at a premium." +msgstr "" +"No ejecute la extracción en paralelo. Ejecutará cada parte del proceso de " +"extracción por separado (una tras otra) en lugar de hacerlo todo al mismo " +"tiempo. Útil si la VRAM es escasa." + +#: lib/cli/args_extract_convert.py:371 +msgid "" +"Skips frames that have already been extracted and exist in the alignments " +"file" +msgstr "" +"Omite los fotogramas que ya han sido extraídos y que existen en el archivo " +"de alineaciones" + +#: lib/cli/args_extract_convert.py:378 +msgid "Skip frames that already have detected faces in the alignments file" +msgstr "" +"Omitir los fotogramas que ya tienen caras detectadas en el archivo de " +"alineaciones" + +#: lib/cli/args_extract_convert.py:385 +msgid "Skip saving the detected faces to disk. Just create an alignments file" +msgstr "" +"No guardar las caras detectadas en el disco. Crear sólo un archivo de " +"alineaciones" + +#: lib/cli/args_extract_convert.py:459 +msgid "" +"Swap the original faces in a source video/images to your final faces.\n" +"Conversion plugins can be configured in the 'Settings' Menu" +msgstr "" +"Cambia las caras originales de un vídeo/imágenes de origen por las caras " +"finales.\n" +"Los plugins de conversión pueden ser configurados en el menú " +"\"Configuración\"" + +#: lib/cli/args_extract_convert.py:481 +msgid "" +"Only required if converting from images to video. Provide The original video " +"that the source frames were extracted from (for extracting the fps and " +"audio)." +msgstr "" +"Sólo es necesario si se convierte de imágenes a vídeo. Proporcione el vídeo " +"original del que se extrajeron los fotogramas de origen (para extraer los " +"fps y el audio)." + +#: lib/cli/args_extract_convert.py:490 +msgid "" +"Model directory. The directory containing the trained model you wish to use " +"for conversion." +msgstr "" +"Directorio del modelo. El directorio que contiene el modelo entrenado que " +"desea utilizar para la conversión." + +#: lib/cli/args_extract_convert.py:501 +msgid "" +"R|Performs color adjustment to the swapped face. Some of these options have " +"configurable settings in '/config/convert.ini' or 'Settings > Configure " +"Convert Plugins':\n" +"L|avg-color: Adjust the mean of each color channel in the swapped " +"reconstruction to equal the mean of the masked area in the original image.\n" +"L|color-transfer: Transfers the color distribution from the source to the " +"target image using the mean and standard deviations of the L*a*b* color " +"space.\n" +"L|manual-balance: Manually adjust the balance of the image in a variety of " +"color spaces. Best used with the Preview tool to set correct values.\n" +"L|match-hist: Adjust the histogram of each color channel in the swapped " +"reconstruction to equal the histogram of the masked area in the original " +"image.\n" +"L|seamless-clone: Use cv2's seamless clone function to remove extreme " +"gradients at the mask seam by smoothing colors. Generally does not give very " +"satisfactory results.\n" +"L|none: Don't perform color adjustment." +msgstr "" +"R|Realiza un ajuste de color a la cara intercambiada. Algunas de estas " +"opciones tienen ajustes configurables en '/config/convert.ini' o 'Ajustes > " +"Configurar Extensiones de Conversión':\n" +"L|avg-color: Ajuste la media de cada canal de color en la reconstrucción " +"intercambiada para igualar la media del área enmascarada en la imagen " +"original.\n" +"L|color-transfer: Transfiere la distribución del color de la imagen de " +"origen a la de destino utilizando la media y las desviaciones estándar del " +"espacio de color L*a*b*.\n" +"L|manual-balance: Ajuste manualmente el equilibrio de la imagen en una " +"variedad de espacios de color. Se utiliza mejor con la herramienta de vista " +"previa para establecer los valores correctos.\n" +"L|match-hist: Ajuste el histograma de cada canal de color en la " +"reconstrucción intercambiada para igualar el histograma del área enmascarada " +"en la imagen original.\n" +"L|seamless-clone: Utilice la función de clonación sin costuras de cv2 para " +"eliminar los gradientes extremos en la costura de la máscara, suavizando los " +"colores. Generalmente no da resultados muy satisfactorios.\n" +"L|none: No realice el ajuste de color." + +#: lib/cli/args_extract_convert.py:527 +msgid "" +"R|Masker to use. NB: The mask you require must exist within the alignments " +"file. You can add additional masks with the Mask Tool.\n" +"L|none: Don't use a mask.\n" +"L|bisenet-fp_face: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'face' or " +"'legacy' centering.\n" +"L|bisenet-fp_head: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'head' " +"centering.\n" +"L|custom_face: Custom user created, face centered mask.\n" +"L|custom_head: Custom user created, head centered mask.\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\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.\n" +"L|predicted: If the 'Learn Mask' option was enabled during training, this " +"will use the mask that was created by the trained model." +msgstr "" +"R|Máscara a utilizar. NB: La máscara que necesita debe existir en el archivo " +"de alineaciones. Puede añadir máscaras adicionales con la herramienta de " +"máscaras.\n" +"L|none: No utilizar una máscara.\n" +"L|bisenet-fp-face: Máscara relativamente ligera basada en NN que proporciona " +"un control más refinado sobre el área a enmascarar (configurable en la " +"configuración de la máscara). Utilice esta versión de bisenet-fp si su " +"modelo está entrenado con centrado 'face' o 'legacy'.\n" +"L|bisenet-fp-head: Máscara relativamente ligera basada en NN que proporciona " +"un control más refinado sobre el área a enmascarar (configurable en la " +"configuración de la máscara). Utilice esta versión de bisenet-fp si su " +"modelo está entrenado con centrado de 'cabeza'.\n" +"L|custom_face: Máscara personalizada creada por el usuario y centrada en el " +"rostro..\n" +"L|custom_head: Máscara personalizada centrada en la cabeza creada por el " +"usuario.\n" +"L|components: Máscara diseñada para proporcionar una segmentación facial " +"basada en el posicionamiento de las ubicaciones de los puntos de referencia. " +"Se construye un casco convexo alrededor del exterior de los puntos de " +"referencia para crear una máscara.\n" +"L|extended: Máscara diseñada para proporcionar una segmentación facial " +"basada en el posicionamiento de las ubicaciones de los puntos de referencia. " +"Se construye un casco convexo alrededor del exterior de los puntos de " +"referencia y la máscara se extiende hacia arriba en la frente.\n" +"L|vgg-clear: Máscara diseñada para proporcionar una segmentación inteligente " +"de rostros principalmente frontales y libres de obstrucciones. Los rostros " +"de perfil y las obstrucciones pueden dar lugar a un rendimiento inferior.\n" +"L|vgg-obstructed: Máscara diseñada para proporcionar una segmentación " +"inteligente de rostros principalmente frontales. El modelo de la máscara ha " +"sido entrenado específicamente para reconocer algunas obstrucciones faciales " +"(manos y gafas). Los rostros de perfil pueden dar lugar a un rendimiento " +"inferior.\n" +"L|unet-dfl: Máscara diseñada para proporcionar una segmentación inteligente " +"de rostros principalmente frontales. El modelo de máscara ha sido entrenado " +"por los miembros de la comunidad y necesitará ser probado para una mayor " +"descripción. Los rostros de perfil pueden dar lugar a un rendimiento " +"inferior.\n" +"L|predicted: Si la opción 'Learn Mask' se habilitó durante el entrenamiento, " +"esto usará la máscara que fue creada por el modelo entrenado." + +#: lib/cli/args_extract_convert.py:566 +msgid "" +"R|The plugin to use to output the converted images. The writers are " +"configurable in '/config/convert.ini' or 'Settings > Configure Convert " +"Plugins:'\n" +"L|ffmpeg: [video] Writes out the convert straight to video. When the input " +"is a series of images then the '-ref' (--reference-video) parameter must be " +"set.\n" +"L|gif: [animated image] Create an animated gif.\n" +"L|opencv: [images] The fastest image writer, but less options and formats " +"than other plugins.\n" +"L|patch: [images] Outputs the raw swapped face patch, along with the " +"transformation matrix required to re-insert the face back into the original " +"frame. Use this option if you wish to post-process and composite the final " +"face within external tools.\n" +"L|pillow: [images] Slower than opencv, but has more options and supports " +"more formats." +msgstr "" +"R|El plugin a utilizar para dar salida a las imágenes convertidas. Los " +"escritores son configurables en '/config/convert.ini' o 'Ajustes > " +"Configurar Extensiones de Conversión:'\n" +"L|ffmpeg: [video] Escribe la conversión directamente en vídeo. Cuando la " +"entrada es una serie de imágenes, el parámetro '-ref' (--reference-video) " +"debe ser establecido.\n" +"L|gif: [imagen animada] Crea un gif animado.\n" +"L|opencv: [images] El escritor de imágenes más rápido, pero con menos " +"opciones y formatos que otros plugins.\n" +"L|patch: [images] Genera el parche de cara intercambiado sin formato, junto " +"con la matriz de transformación necesaria para volver a insertar la cara en " +"el marco original.\n" +"L|pillow: [images] Más lento que opencv, pero tiene más opciones y soporta " +"más formatos." + +#: lib/cli/args_extract_convert.py:587 lib/cli/args_extract_convert.py:596 +#: lib/cli/args_extract_convert.py:707 +msgid "Frame Processing" +msgstr "Proceso de fotogramas" + +#: lib/cli/args_extract_convert.py:589 +#, python-format +msgid "" +"Scale the final output frames by this amount. 100%% will output the frames " +"at source dimensions. 50%% at half size 200%% at double size" +msgstr "" +"Escala los fotogramas finales de salida en esta cantidad. 100%% dará salida " +"a los fotogramas a las dimensiones de origen. 50%% a la mitad de tamaño. " +"200%% al doble de tamaño" + +#: lib/cli/args_extract_convert.py:598 +msgid "" +"Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " +"--frame-ranges 10-50 90-100. Frames falling outside of the selected range " +"will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " +"converting from images, then the filenames must end with the frame-number!" +msgstr "" +"Rangos de fotogramas a los que aplicar la transferencia, por ejemplo, para " +"los fotogramas de 10 a 50 y de 90 a 100 utilice --frame-ranges 10-50 90-100. " +"Los fotogramas que queden fuera del rango seleccionado se descartarán a " +"menos que se seleccione '-k' (--keep-unchanged). Nota: Si está convirtiendo " +"imágenes, ¡los nombres de los archivos deben terminar con el número de " +"fotograma!" + +#: lib/cli/args_extract_convert.py:612 +msgid "" +"Scale the swapped face by this percentage. Positive values will enlarge the " +"face, Negative values will shrink the face." +msgstr "" +"Escale la cara intercambiada según este porcentaje. Los valores positivos " +"agrandarán la cara, los valores negativos la reducirán." + +#: lib/cli/args_extract_convert.py:621 +msgid "" +"If you have not cleansed your alignments file, then you can filter out faces " +"by defining a folder here that contains the faces extracted from your input " +"files/video. If this folder is defined, then only faces that exist within " +"your alignments file and also exist within the specified folder will be " +"converted. Leaving this blank will convert all faces that exist within the " +"alignments file." +msgstr "" +"Si no ha limpiado su archivo de alineaciones, puede filtrar las caras " +"definiendo aquí una carpeta que contenga las caras extraídas de sus archivos/" +"vídeos de entrada. Si se define esta carpeta, sólo se convertirán las caras " +"que existan en el archivo de alineaciones y también en la carpeta " +"especificada. Si se deja en blanco, se convertirán todas las caras que " +"existan en el archivo de alineaciones." + +#: lib/cli/args_extract_convert.py:636 +msgid "" +"Optionally filter out people who you do not wish to process by passing in an " +"image of that person. Should be a front portrait with a single person in the " +"image. Multiple images can be added space separated. NB: Using face filter " +"will significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"Opcionalmente, puede filtrar las personas que no desea procesar pasando una " +"imagen de esa persona. Debe ser un retrato frontal con una sola persona en " +"la imagen. Se pueden añadir varias imágenes separadas por espacios. NB: El " +"uso del filtro de caras disminuirá significativamente la velocidad de " +"extracción y no se puede garantizar su precisión." + +#: lib/cli/args_extract_convert.py:649 +msgid "" +"Optionally select people you wish to process by passing in an image of that " +"person. Should be a front portrait with a single person in the image. " +"Multiple images can be added space separated. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"Opcionalmente, seleccione las personas que desea procesar pasando una imagen " +"de esa persona. Debe ser un retrato frontal con una sola persona en la " +"imagen. Se pueden añadir varias imágenes separadas por espacios. NB: El uso " +"del filtro facial disminuirá significativamente la velocidad de extracción y " +"no se puede garantizar su precisión." + +#: lib/cli/args_extract_convert.py:663 +msgid "" +"For use with the optional nfilter/filter files. Threshold for positive face " +"recognition. Lower values are stricter. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"Para usar con los archivos opcionales nfilter/filter. Umbral para el " +"reconocimiento positivo de caras. Los valores más bajos son más estrictos. " +"NB: El uso del filtro facial disminuirá significativamente la velocidad de " +"extracción y no se puede garantizar su precisión." + +#: lib/cli/args_extract_convert.py:676 +msgid "" +"The maximum number of parallel processes for performing conversion. " +"Converting images is system RAM heavy so it is possible to run out of memory " +"if you have a lot of processes and not enough RAM to accommodate them all. " +"Setting this to 0 will use the maximum available. No matter what you set " +"this to, it will never attempt to use more processes than are available on " +"your system. If singleprocess is enabled this setting will be ignored." +msgstr "" +"El número máximo de procesos paralelos para realizar la conversión. La " +"conversión de imágenes requiere mucha RAM del sistema, por lo que es posible " +"que se agote la memoria si tiene muchos procesos y no hay suficiente RAM " +"para acomodarlos a todos. Si se ajusta a 0, se utilizará el máximo " +"disponible. No importa lo que establezca, nunca intentará utilizar más " +"procesos que los disponibles en su sistema. Si 'singleprocess' está " +"habilitado, este ajuste será ignorado." + +#: lib/cli/args_extract_convert.py:688 +msgid "" +"[LEGACY] This only needs to be selected if a legacy model is being loaded or " +"if there are multiple models in the model folder" +msgstr "" +"[LEGACY] Sólo es necesario seleccionar esta opción si se está cargando un " +"modelo heredado si hay varios modelos en la carpeta de modelos" + +#: lib/cli/args_extract_convert.py:697 +msgid "" +"Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " +"alignments file for your destination video. However, if you wish you can " +"generate the alignments on-the-fly by enabling this option. This will use an " +"inferior extraction pipeline and will lead to substandard results. If an " +"alignments file is found, this option will be ignored." +msgstr "" +"Activar la conversión sobre la marcha. NO se recomienda. Debe generar un " +"archivo de alineación limpio para su vídeo de destino. Sin embargo, si lo " +"desea, puede generar las alineaciones sobre la marcha activando esta opción. " +"Esto utilizará una tubería de extracción inferior y conducirá a resultados " +"de baja calidad. Si se encuentra un archivo de alineaciones, esta opción " +"será ignorada." + +#: lib/cli/args_extract_convert.py:709 +msgid "" +"When used with --frame-ranges outputs the unchanged frames that are not " +"processed instead of discarding them." +msgstr "" +"Cuando se usa con --frame-ranges, la salida incluye los fotogramas no " +"procesados en vez de descartarlos." + +#: lib/cli/args_extract_convert.py:717 +msgid "Swap the model. Instead converting from of A -> B, converts B -> A" +msgstr "" +"Intercambiar el modelo. En vez de convertir de A a B, convierte de B a A" + +#: lib/cli/args_extract_convert.py:723 +msgid "Disable multiprocessing. Slower but less resource intensive." +msgstr "Desactiva el multiproceso. Es más lento, pero usa menos recursos." diff --git a/locales/es/LC_MESSAGES/lib.cli.args_train.mo b/locales/es/LC_MESSAGES/lib.cli.args_train.mo new file mode 100644 index 0000000000000000000000000000000000000000..5cb1754eb0b06d0a6bd4252055e0c9ca57ca0829 GIT binary patch literal 15946 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, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: faceswap.spanish\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-28 18:04+0000\n" +"PO-Revision-Date: 2024-03-28 18:09+0000\n" +"Last-Translator: \n" +"Language-Team: tokafondo\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.4.2\n" + +#: lib/cli/args_train.py:30 +msgid "" +"Train a model on extracted original (A) and swap (B) faces.\n" +"Training models can take a long time. Anything from 24hrs to over a week\n" +"Model plugins can be configured in the 'Settings' Menu" +msgstr "" +"Entrene un modelo con las caras originales (A) e intercambiadas (B) " +"extraídas.\n" +"El entrenamiento de los modelos puede llevar mucho tiempo. Desde 24 horas " +"hasta más de una semana.\n" +"Los plugins de los modelos pueden configurarse en el menú \"Ajustes\"" + +#: lib/cli/args_train.py:49 lib/cli/args_train.py:58 +msgid "faces" +msgstr "caras" + +#: lib/cli/args_train.py:51 +msgid "" +"Input directory. A directory containing training images for face A. This is " +"the original face, i.e. the face that you want to remove and replace with " +"face B." +msgstr "" +"Directorio de entrada. Un directorio que contiene imágenes de entrenamiento " +"para la cara A. Esta es la cara original, es decir, la cara que se quiere " +"eliminar y sustituir por la cara B." + +#: lib/cli/args_train.py:60 +msgid "" +"Input directory. A directory containing training images for face B. This is " +"the swap face, i.e. the face that you want to place onto the head of person " +"A." +msgstr "" +"Directorio de entrada. Un directorio que contiene imágenes de entrenamiento " +"para la cara B. Esta es la cara de intercambio, es decir, la cara que se " +"quiere colocar en la cabeza de la persona A." + +#: lib/cli/args_train.py:67 lib/cli/args_train.py:80 lib/cli/args_train.py:97 +#: lib/cli/args_train.py:123 lib/cli/args_train.py:133 +msgid "model" +msgstr "modelo" + +#: lib/cli/args_train.py:69 +msgid "" +"Model directory. This is where the training data will be stored. You should " +"always specify a new folder for new models. If starting a new model, select " +"either an empty folder, or a folder which does not exist (which will be " +"created). If continuing to train an existing model, specify the location of " +"the existing model." +msgstr "" +"Directorio del modelo. Aquí es donde se almacenarán los datos de " +"entrenamiento. Siempre debe especificar una nueva carpeta para los nuevos " +"modelos. Si se inicia un nuevo modelo, seleccione una carpeta vacía o una " +"carpeta que no exista (que se creará). Si continúa entrenando un modelo " +"existente, especifique la ubicación del modelo existente." + +#: lib/cli/args_train.py:82 +msgid "" +"R|Load the weights from a pre-existing model into a newly created model. For " +"most models this will load weights from the Encoder of the given model into " +"the encoder of the newly created model. Some plugins may have specific " +"configuration options allowing you to load weights from other layers. " +"Weights will only be loaded when creating a new model. This option will be " +"ignored if you are resuming an existing model. Generally you will also want " +"to 'freeze-weights' whilst the rest of your model catches up with your " +"Encoder.\n" +"NB: Weights can only be loaded from models of the same plugin as you intend " +"to train." +msgstr "" +"R|Cargue los pesos de un modelo preexistente en un modelo recién creado. " +"Para la mayoría de los modelos, esto cargará pesos del codificador del " +"modelo dado en el codificador del modelo recién creado. Algunos complementos " +"pueden tener opciones de configuración específicas que le permiten cargar " +"pesos de otras capas. Los pesos solo se cargarán al crear un nuevo modelo. " +"Esta opción se ignorará si está reanudando un modelo existente. En general, " +"también querrá 'congelar pesos' mientras el resto de su modelo se pone al " +"día con su codificador.\n" +"NB: Los pesos solo se pueden cargar desde modelos del mismo complemento que " +"desea entrenar." + +#: lib/cli/args_train.py:99 +msgid "" +"R|Select which trainer to use. Trainers can be configured from the Settings " +"menu or the config folder.\n" +"L|original: The original model created by /u/deepfakes.\n" +"L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " +"for full dfaker method.\n" +"L|dfl-h128: 128px in/out model from deepfacelab\n" +"L|dfl-sae: Adaptable model from deepfacelab\n" +"L|dlight: A lightweight, high resolution DFaker variant.\n" +"L|iae: A model that uses intermediate layers to try to get better details\n" +"L|lightweight: A lightweight model for low-end cards. Don't expect great " +"results. Can train as low as 1.6GB with batch size 8.\n" +"L|realface: A high detail, dual density model based on DFaker, with " +"customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " +"won't work so well. By andenixa et al. Very configurable.\n" +"L|unbalanced: 128px in/out model from andenixa. The autoencoders are " +"unbalanced so B>A swaps won't work so well. Very configurable.\n" +"L|villain: 128px in/out model from villainguy. Very resource hungry (You " +"will require a GPU with a fair amount of VRAM). Good for details, but more " +"susceptible to color differences." +msgstr "" +"R|Seleccione el entrenador que desea utilizar. Los entrenadores se pueden " +"configurar desde el menú de configuración o la carpeta de configuración.\n" +"L|original: El modelo original creado por /u/deepfakes.\n" +"L|dfaker: Modelo de 64px in/128px out de dfaker. Habilitar 'warp-to-" +"landmarks' para el método completo de dfaker.\n" +"L|dfl-h128: modelo de 128px in/out de deepfacelab\n" +"L|dfl-sae: Modelo adaptable de deepfacelab\n" +"L|dlight: Una variante de DFaker ligera y de alta resolución.\n" +"L|iae: Un modelo que utiliza capas intermedias para tratar de obtener " +"mejores detalles.\n" +"L|lightweight: Un modelo ligero para tarjetas de gama baja. No esperes " +"grandes resultados. Puede entrenar hasta 1,6GB con tamaño de lote 8.\n" +"L|realface: Un modelo de alto detalle y doble densidad basado en DFaker, con " +"resolución de entrada y salida personalizable. Los autocodificadores están " +"desequilibrados, por lo que los intercambios B>A no funcionan tan bien. Por " +"andenixa et al. Muy configurable\n" +"L|Unbalanced: modelo de 128px de entrada/salida de andenixa. Los " +"autocodificadores están desequilibrados por lo que los intercambios B>A no " +"funcionarán tan bien. Muy configurable\n" +"L|villain: Modelo de 128px de entrada/salida de villainguy. Requiere muchos " +"recursos (se necesita una GPU con una buena cantidad de VRAM). Bueno para " +"los detalles, pero más susceptible a las diferencias de color." + +#: lib/cli/args_train.py:125 +msgid "" +"Output a summary of the model and exit. If a model folder is provided then a " +"summary of the saved model is displayed. Otherwise a summary of the model " +"that would be created by the chosen plugin and configuration settings is " +"displayed." +msgstr "" +"Genere un resumen del modelo y salga. Si se proporciona una carpeta de " +"modelo, se muestra un resumen del modelo guardado. De lo contrario, se " +"muestra un resumen del modelo que crearía el complemento elegido y los " +"ajustes de configuración." + +#: lib/cli/args_train.py:135 +msgid "" +"Freeze the weights of the model. Freezing weights means that some of the " +"parameters in the model will no longer continue to learn, but those that are " +"not frozen will continue to learn. For most models, this will freeze the " +"encoder, but some models may have configuration options for freezing other " +"layers." +msgstr "" +"Congele los pesos del modelo. Congelar pesos significa que algunos de los " +"parámetros del modelo ya no seguirán aprendiendo, pero los que no están " +"congelados seguirán aprendiendo. Para la mayoría de los modelos, esto " +"congelará el codificador, pero algunos modelos pueden tener opciones de " +"configuración para congelar otras capas." + +#: lib/cli/args_train.py:147 lib/cli/args_train.py:160 +#: lib/cli/args_train.py:175 lib/cli/args_train.py:191 +#: lib/cli/args_train.py:200 +msgid "training" +msgstr "entrenamiento" + +#: lib/cli/args_train.py:149 +msgid "" +"Batch size. This is the number of images processed through the model for " +"each side per iteration. NB: As the model is fed 2 sides at a time, the " +"actual number of images within the model at any one time is double the " +"number that you set here. Larger batches require more GPU RAM." +msgstr "" +"Tamaño del lote. Este es el número de imágenes procesadas a través del " +"modelo para cada lado por iteración. Nota: Como el modelo se alimenta de 2 " +"lados a la vez, el número real de imágenes dentro del modelo en cualquier " +"momento es el doble del número que se establece aquí. Los lotes más grandes " +"requieren más RAM de la GPU." + +#: lib/cli/args_train.py:162 +msgid "" +"Length of training in iterations. This is only really used for automation. " +"There is no 'correct' number of iterations a model should be trained for. " +"You should stop training when you are happy with the previews. However, if " +"you want the model to stop automatically at a set number of iterations, you " +"can set that value here." +msgstr "" +"Duración del entrenamiento en iteraciones. Esto sólo se utiliza realmente " +"para la automatización. No hay un número 'correcto' de iteraciones para las " +"que deba entrenarse un modelo. Debe dejar de entrenar cuando esté satisfecho " +"con las previsiones. Sin embargo, si desea que el modelo se detenga " +"automáticamente en un número determinado de iteraciones, puede establecer " +"ese valor aquí." + +#: lib/cli/args_train.py:177 +msgid "" +"R|Select the distribution stategy to use.\n" +"L|default: Use Tensorflow's default distribution strategy.\n" +"L|central-storage: Centralizes variables on the CPU whilst operations are " +"performed on 1 or more local GPUs. This can help save some VRAM at the cost " +"of some speed by not storing variables on the GPU. Note: Mixed-Precision is " +"not supported on multi-GPU setups.\n" +"L|mirrored: Supports synchronous distributed training across multiple local " +"GPUs. A copy of the model and all variables are loaded onto each GPU with " +"batches distributed to each GPU at each iteration." +msgstr "" +"562 / 5,000\n" +"Translation results\n" +"R|Seleccione la estrategia de distribución a utilizar.\n" +"L|default: utiliza la estrategia de distribución predeterminada de " +"Tensorflow.\n" +"L|central-storage: centraliza las variables en la CPU mientras que las " +"operaciones se realizan en 1 o más GPU locales. Esto puede ayudar a ahorrar " +"algo de VRAM a costa de cierta velocidad al no almacenar variables en la " +"GPU. Nota: Mixed-Precision no es compatible con configuraciones de múltiples " +"GPU.\n" +"L|mirrored: Admite el entrenamiento distribuido síncrono en varias GPU " +"locales. Se carga una copia del modelo y todas las variables en cada GPU con " +"lotes distribuidos a cada GPU en cada iteración." + +#: lib/cli/args_train.py:193 +msgid "" +"Disables TensorBoard logging. NB: Disabling logs means that you will not be " +"able to use the graph or analysis for this session in the GUI." +msgstr "" +"Desactiva el registro de TensorBoard. NB: Desactivar los registros significa " +"que no podrá utilizar el gráfico o el análisis de esta sesión en la GUI." + +#: lib/cli/args_train.py:202 +msgid "" +"Use the Learning Rate Finder to discover the optimal learning rate for " +"training. For new models, this will calculate the optimal learning rate for " +"the model. For existing models this will use the optimal learning rate that " +"was discovered when initializing the model. Setting this option will ignore " +"the manually configured learning rate (configurable in train settings)." +msgstr "" +"Utilice el Buscador de tasa de aprendizaje para descubrir la tasa de " +"aprendizaje óptima para la capacitación. Para modelos nuevos, esto calculará " +"la tasa de aprendizaje óptima para el modelo. Para los modelos existentes, " +"esto utilizará la tasa de aprendizaje óptima que se descubrió al inicializar " +"el modelo. Configurar esta opción ignorará la tasa de aprendizaje " +"configurada manualmente (configurable en la configuración del tren)." + +#: lib/cli/args_train.py:215 lib/cli/args_train.py:225 +msgid "Saving" +msgstr "Guardar" + +#: lib/cli/args_train.py:216 +msgid "Sets the number of iterations between each model save." +msgstr "Establece el número de iteraciones entre cada guardado del modelo." + +#: lib/cli/args_train.py:227 +msgid "" +"Sets the number of iterations before saving a backup snapshot of the model " +"in it's current state. Set to 0 for off." +msgstr "" +"Establece el número de iteraciones antes de guardar una copia de seguridad " +"del modelo en su estado actual. Establece 0 para que esté desactivado." + +#: lib/cli/args_train.py:234 lib/cli/args_train.py:246 +#: lib/cli/args_train.py:258 +msgid "timelapse" +msgstr "intervalo" + +#: lib/cli/args_train.py:236 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. " +"This should be the input folder of 'A' faces that you would like to use for " +"creating the timelapse. You must also supply a --timelapse-output and a --" +"timelapse-input-B parameter." +msgstr "" +"Opcional para crear un timelapse. Timelapse guardará una imagen de las caras " +"seleccionadas en la carpeta timelapse-output en cada iteración de guardado. " +"Esta debe ser la carpeta de entrada de las caras \"A\" que desea utilizar " +"para crear el timelapse. También debe suministrar un parámetro --timelapse-" +"output y un parámetro --timelapse-input-B." + +#: lib/cli/args_train.py:248 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. " +"This should be the input folder of 'B' faces that you would like to use for " +"creating the timelapse. You must also supply a --timelapse-output and a --" +"timelapse-input-A parameter." +msgstr "" +"Opcional para crear un timelapse. Timelapse guardará una imagen de las caras " +"seleccionadas en la carpeta timelapse-output en cada iteración de guardado. " +"Esta debe ser la carpeta de entrada de las caras \"B\" que desea utilizar " +"para crear el timelapse. También debe suministrar un parámetro --timelapse-" +"output y un parámetro --timelapse-input-A." + +#: lib/cli/args_train.py:260 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. If " +"the input folders are supplied but no output folder, it will default to your " +"model folder/timelapse/" +msgstr "" +"Opcional para crear un timelapse. Timelapse guardará una imagen de las caras " +"seleccionadas en la carpeta timelapse-output en cada iteración de guardado. " +"Si se suministran las carpetas de entrada pero no la carpeta de salida, se " +"guardará por defecto en la carpeta del modelo/timelapse/" + +#: lib/cli/args_train.py:269 lib/cli/args_train.py:276 +msgid "preview" +msgstr "previsualización" + +#: lib/cli/args_train.py:270 +msgid "Show training preview output. in a separate window." +msgstr "" +"Mostrar la salida de la vista previa del entrenamiento. en una ventana " +"separada." + +#: lib/cli/args_train.py:278 +msgid "" +"Writes the training result to a file. The image will be stored in the root " +"of your FaceSwap folder." +msgstr "" +"Escribe el resultado del entrenamiento en un archivo. La imagen se " +"almacenará en la raíz de su carpeta FaceSwap." + +#: lib/cli/args_train.py:285 lib/cli/args_train.py:295 +#: lib/cli/args_train.py:305 lib/cli/args_train.py:315 +msgid "augmentation" +msgstr "aumento" + +#: lib/cli/args_train.py:287 +msgid "" +"Warps training faces to closely matched Landmarks from the opposite face-set " +"rather than randomly warping the face. This is the 'dfaker' way of doing " +"warping." +msgstr "" +"Deforma las caras de entrenamiento a puntos de referencia muy parecidos del " +"conjunto de caras opuestas en lugar de deformar la cara al azar. Esta es la " +"forma 'dfaker' de hacer la deformación." + +#: lib/cli/args_train.py:297 +msgid "" +"To effectively learn, a random set of images are flipped horizontally. " +"Sometimes it is desirable for this not to occur. Generally this should be " +"left off except for during 'fit training'." +msgstr "" +"Para aprender de forma efectiva, se voltea horizontalmente un conjunto " +"aleatorio de imágenes. A veces es deseable que esto no ocurra. Por lo " +"general, esto debería dejarse sin efecto, excepto durante el 'entrenamiento " +"de ajuste'." + +#: lib/cli/args_train.py:307 +msgid "" +"Color augmentation helps make the model less susceptible to color " +"differences between the A and B sets, at an increased training time cost. " +"Enable this option to disable color augmentation." +msgstr "" +"El aumento del color ayuda a que el modelo sea menos susceptible a las " +"diferencias de color entre los conjuntos A y B, con un mayor coste de tiempo " +"de entrenamiento. Activa esta opción para desactivar el aumento de color." + +#: lib/cli/args_train.py:317 +msgid "" +"Warping is integral to training the Neural Network. This option should only " +"be enabled towards the very end of training to try to bring out more detail. " +"Think of it as 'fine-tuning'. Enabling this option from the beginning is " +"likely to kill a model and lead to terrible results." +msgstr "" +"La deformación es fundamental para el entrenamiento de la red neuronal. Esta " +"opción sólo debería activarse hacia el final del entrenamiento para tratar " +"de obtener más detalles. Piense en ello como un 'ajuste fino'. Si se activa " +"esta opción desde el principio, es probable que arruine el modelo y se " +"obtengan resultados terribles." diff --git a/locales/es/LC_MESSAGES/tools.alignments.cli.mo b/locales/es/LC_MESSAGES/tools.alignments.cli.mo index 9d0b80ace08586820dfc198757b39eefb0887f8a..9aa60ed0c98cded8c8f14625010b9ae422c6b8c9 100644 GIT binary patch delta 483 zcmX}oy-EW?6o%n%O*ByxeNzaKOp7?1SPG7tc|5uh~Pa4 zHtPkLLWnjBVr#IluuyCS!NzwI@xYm9XP7f{W-qIE+x~MRd?sRuOp{mS7`b9G5Z5?~ zbu8j5u41}F+Q2=W!y7!oFD&CxsC`BQa}mkzl%}X%j!CsJb#bX8`DzYIj}$lw28t~@ z@yw?!u}||zy-L`FWvp^g1*e%`B)JHma0e%b+aBX0^KZPx;)t|{N=f_FuVW+XOZTJF zCJX00Gf$VF8EK1snHlLFeLSSG-C1dg`G+I1O>S1|#Uh#p%g8b24gX=@WFBF{G(XV# zl6ZV8A_XwNzMJf7EwzUzn8W-3*7!;4BhAIJpl-cc!9ysVh!-3;?|2L2u3K`w(--?E QRLFWkGg<2h4pKS$7p_b++5i9m delta 540 zcmX}pJ1oOd6vy#jX{kq*QjZj=2#H6lS}Bnjhz$b~L~8h_tpS5*2L_MCVl!Ydi$zGJ zldxIJq!Ft`Oa`Ngi0>_NlJo!E)BMl9_xyVwyI9zM_Sla^)R2wj4cSD-Y&?huY{eW# z@dGEZyFi-3E$qQ3+($>D)P_emg4Z}s&o}lKOJfeHoqDreTD5CiCoXA%7rh?o5#uN} z>#vr=EC;-j9hZ@Qtzs#z;{qG)Vh8n0EeBy)owS5=f8OE{b+BH#!xao*v_YEYd?i?H zu+bG}ol?Xn%~F50vgWqaj5ANeUZ?bqueienb6wIf^;D3l@es@K7|lgbk!{Q@|G`Z8 zVayNaUFWD-5f#7W8>?8G35!Vcx0om)`C_Ux1z|ptn=}WzGMBbDn>)FlUbEuqWWv8_ z#gnPbZlTZPiUdO8K)BBz3Jpf02WQ?-dnnjjdeBnWoXOTs7F1g)f81K*neV4#ZOO)klBWU=Z zSaCvWYld!ELN|p`)O6j}jl9l@j@9(ShU2+y$CaWgou;jY{*iO&dv#y=FKkT)2dkCn bJGC2KsQIX!UV0WpmK4Bws?stZGH553GwK=r@E?C@F2C;N)CglnhRa zqc|0^2?asa$xZ)_F6t^C@AA8cdpWO8*SY*SD{H`M23P~|qyvw1WDIzt&-9&MWPvZb zmjmwT#yBuZ`?O03CV&I>B=m?mHwC=V7VXi+JW!=S^nrit)4(S4cL61oxhxyNFFmKj zv@-(?(knVduW5?j(*OH?($VsxbK6^D$3Llu^=2SeROEZ6w4^O8DWq-MCC^=&w>WV> zGaDQFete@l1zB{9GF?$&BzD^>2pcMDwmma$=?mG43Tnry#vVUBT&*Ubx|`O_BxUrp EKh%#rj{pDw diff --git a/locales/es/LC_MESSAGES/tools.effmpeg.cli.po b/locales/es/LC_MESSAGES/tools.effmpeg.cli.po index dab3f62f5f..ea47680568 100644 --- a/locales/es/LC_MESSAGES/tools.effmpeg.cli.po +++ b/locales/es/LC_MESSAGES/tools.effmpeg.cli.po @@ -5,27 +5,28 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-02-19 16:39+0000\n" -"PO-Revision-Date: 2021-02-21 16:49+0000\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-28 23:50+0000\n" +"PO-Revision-Date: 2024-03-29 00:02+0000\n" +"Last-Translator: \n" "Language-Team: tokafondo\n" +"Language: es_ES\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 2.4.2\n" -"Last-Translator: \n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -"Language: es_ES\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.4.2\n" #: tools/effmpeg/cli.py:15 msgid "This command allows you to easily execute common ffmpeg tasks." msgstr "Este comando le permite ejecutar fácilmente tareas comunes de ffmpeg." -#: tools/effmpeg/cli.py:24 +#: tools/effmpeg/cli.py:52 msgid "A wrapper for ffmpeg for performing image <> video converting." msgstr "Un interfaz de ffmpeg para realizar la conversión de imagen <> vídeo." -#: tools/effmpeg/cli.py:51 +#: tools/effmpeg/cli.py:64 msgid "" "R|Choose which action you want ffmpeg ffmpeg to do.\n" "L|'extract': turns videos into images \n" @@ -45,17 +46,17 @@ msgstr "" "L|'mux-audio' añade audio de un vídeo a otro.\n" "L|'rescale' cambia el tamaño del vídeo.\n" "L|'rotate' rotar video\n" -"L|'slice' corta una parte del video en un archivo de video separado. " +"L|'slice' corta una parte del video en un archivo de video separado." -#: tools/effmpeg/cli.py:65 +#: tools/effmpeg/cli.py:78 msgid "Input file." msgstr "Archivo de entrada." -#: tools/effmpeg/cli.py:66 tools/effmpeg/cli.py:73 tools/effmpeg/cli.py:87 +#: tools/effmpeg/cli.py:79 tools/effmpeg/cli.py:86 tools/effmpeg/cli.py:100 msgid "data" msgstr "datos" -#: tools/effmpeg/cli.py:76 +#: tools/effmpeg/cli.py:89 msgid "" "Output file. If no output is specified then: if the output is meant to be a " "video then a video called 'out.mkv' will be created in the input directory; " @@ -71,18 +72,18 @@ msgstr "" "Nota: la extensión del archivo de salida elegida determinará la codificación " "del archivo." -#: tools/effmpeg/cli.py:89 +#: tools/effmpeg/cli.py:102 msgid "Path to reference video if 'input' was not a video." msgstr "" "Ruta de acceso al vídeo de referencia si se dio una carpeta con fotogramas " "en vez de un vídeo." -#: tools/effmpeg/cli.py:95 tools/effmpeg/cli.py:105 tools/effmpeg/cli.py:142 -#: tools/effmpeg/cli.py:171 +#: tools/effmpeg/cli.py:108 tools/effmpeg/cli.py:118 tools/effmpeg/cli.py:156 +#: tools/effmpeg/cli.py:185 msgid "output" msgstr "salida" -#: tools/effmpeg/cli.py:97 +#: tools/effmpeg/cli.py:110 msgid "" "Provide video fps. Can be an integer, float or fraction. Negative values " "will will make the program try to get the fps from the input or reference " @@ -92,7 +93,7 @@ msgstr "" "fracción. Los valores negativos harán que el programa intente obtener los " "fps de los vídeos de entrada o de referencia." -#: tools/effmpeg/cli.py:107 +#: tools/effmpeg/cli.py:120 msgid "" "Image format that extracted images should be saved as. '.bmp' will offer the " "fastest extraction speed, but will take the most storage space. '.png' will " @@ -103,11 +104,11 @@ msgstr "" "almacenamiento. '.png' será más lento pero ocupará menos espacio de " "almacenamiento." -#: tools/effmpeg/cli.py:114 tools/effmpeg/cli.py:123 tools/effmpeg/cli.py:132 +#: tools/effmpeg/cli.py:127 tools/effmpeg/cli.py:136 tools/effmpeg/cli.py:145 msgid "clip" msgstr "recorte" -#: tools/effmpeg/cli.py:116 +#: tools/effmpeg/cli.py:129 msgid "" "Enter the start time from which an action is to be applied. Default: " "00:00:00, in HH:MM:SS format. You can also enter the time with or without " @@ -117,7 +118,7 @@ msgstr "" "defecto: 00:00:00, en formato HH:MM:SS. También puede introducir la hora con " "o sin los dos puntos, por ejemplo, 00:0000 o 026010." -#: tools/effmpeg/cli.py:125 +#: tools/effmpeg/cli.py:138 msgid "" "Enter the end time to which an action is to be applied. If both an end time " "and duration are set, then the end time will be used and the duration will " @@ -127,7 +128,7 @@ msgstr "" "00:00:00, en formato HH:MM:SS. También puede introducir la hora con o sin " "los dos puntos, por ejemplo, 00:0000 o 026010." -#: tools/effmpeg/cli.py:134 +#: tools/effmpeg/cli.py:147 msgid "" "Enter the duration of the chosen action, for example if you enter 00:00:10 " "for slice, then the first 10 seconds after and including the start time will " @@ -138,7 +139,7 @@ msgstr "" "formato HH:MM:SS. También puede introducir la hora con o sin los dos puntos, " "por ejemplo, 00:0000 o 026010." -#: tools/effmpeg/cli.py:144 +#: tools/effmpeg/cli.py:158 msgid "" "Mux the audio from the reference video into the input video. This option is " "only used for the 'gen-vid' action. 'mux-audio' action has this turned on " @@ -148,11 +149,11 @@ msgstr "" "se utiliza para la acción 'gen-vid'. La acción 'mux-audio' la tiene activada " "implícitamente." -#: tools/effmpeg/cli.py:155 tools/effmpeg/cli.py:165 +#: tools/effmpeg/cli.py:169 tools/effmpeg/cli.py:179 msgid "rotate" msgstr "rotación" -#: tools/effmpeg/cli.py:157 +#: tools/effmpeg/cli.py:171 msgid "" "Transpose the video. If transpose is set, then degrees will be ignored. For " "cli you can enter either the number or the long command name, e.g. to use " @@ -163,23 +164,23 @@ msgstr "" "comando, por ejemplo, para usar (1, 90Clockwise) son válidas las opciones -" "tr 1 y -tr 90Clockwise" -#: tools/effmpeg/cli.py:166 +#: tools/effmpeg/cli.py:180 msgid "Rotate the video clockwise by the given number of degrees." msgstr "" "Gira el vídeo en el sentido de las agujas del reloj el número de grados " "indicado." -#: tools/effmpeg/cli.py:173 +#: tools/effmpeg/cli.py:187 msgid "Set the new resolution scale if the chosen action is 'rescale'." msgstr "" -"Establece la nueva escala de resolución si la acción elegida es \"reescalar" -"\"." +"Establece la nueva escala de resolución si la acción elegida es " +"\"reescalar\"." -#: tools/effmpeg/cli.py:178 tools/effmpeg/cli.py:186 +#: tools/effmpeg/cli.py:192 tools/effmpeg/cli.py:200 msgid "settings" msgstr "ajustes" -#: tools/effmpeg/cli.py:180 +#: tools/effmpeg/cli.py:194 msgid "" "Reduces output verbosity so that only serious errors are printed. If both " "quiet and verbose are set, verbose will override quiet." @@ -188,7 +189,7 @@ msgstr "" "errores graves. Si se establecen tanto 'quiet' como 'verbose', 'verbose' " "tendrá preferencia y anulará a 'quiet'." -#: tools/effmpeg/cli.py:188 +#: tools/effmpeg/cli.py:202 msgid "" "Increases output verbosity. If both quiet and verbose are set, verbose will " "override quiet." diff --git a/locales/es/LC_MESSAGES/tools.manual.mo b/locales/es/LC_MESSAGES/tools.manual.mo index a9c32b1e4970e6c5e7042a8ced3093cc6255dd63..33cfdb81424b4d1f7db679954f14cdd7262eb57a 100644 GIT binary patch delta 386 zcmWmAJxD@P7{>8O!zd!I_HoO!G$JtSO7yX~+%TFVf@rd#2*U!!$edj&XlRIr)~Kza z3@4XxXs|W+ri6-+YKSK5f8lU{=Q-!T=RKTP`_C?>U4?H+w}y0xmtLuY5ua3cNpBb= zUv*34SiuhbzzF^#L&H5%FV3Qgi?~MpCOUQZY%btCZr~(8Ip?05DrkVo4GWHs=*JhV zVH1b&sZZi8ePKVg^2yL3gk9tlirVo4V{Yjh6Xbe88o@cbwP6y+FoP3#9$c2fOdePm z#5&s8LO8&A9HmhekH`&fvyJ(X#N+50J@|=EgP&5_9q2Sywsy07`_@t}wUe~s`BctY V&BnrKHRH}R9i53zm5#mZ#y{HoI*0%O delta 363 zcmWmAze@sP9LMpG!og+4Y55~PH6el|aj=_OZfXi~YbdBjhgut2&OrsS&7rj|93st0 zT-us)=!mEWaVjY2PpJ37!Rz^apXd90AMVfnatEg&??cj^CAF{{l^!q_lg1(G8MEZe z6=@6mxQH*fiF4#rBrYW|k4bd!kp4Xk`fk|VMITQv&7t7li=;edSX@yEyvH;?p^u-K z$4N@!F1_O_{^A(}Ou{nxoTdfr;7(ZT;Q@JSOY2xUgxqU&2Q;@C}0jv(Y$gFZI5yfl(;Ph0Al?bY!9e^Si0?8oFn9y8I}mF! zGcecy#R^#%7&w9SIv}kC6yE`)1A+Y8tPBhZKw6iLfkA0TELuv2gV^G2yZ&cNfq~v9^&j zo%jy~LDE}lZKa~WLTqe&i7^iQ@z{AYyYFo3Ieq#u5$g~t31+}97z5X!SC3#BJb^8c zh!E{StI&7Q1tkzhiI&iRixG`Nr!^u*KLg#weKk(B2kk(c&@U*DP=Q2(=oo|N*eb*L zP7&eI8d9K5*eA^P1L63H-rj6)DCdORzO=dKdYo;jn)IYPkXdfJ`*U29N_rxY z92`m>n%r`2>GU^>tQU9%mLEuAvs=Rjo#Op^&AkfUu1LMGR>S}8b+!tRJ|?g zQm;tSvN*J~YWSd~Cwq<`=oL>W-w}c9!MoCC$N%OnmVWr6b;H+WuJD^nGD7sDxNQH& aYY09Pp5Q?=sn}`Ph1VMyWV?ueMt=a>fn>J; diff --git a/locales/es/LC_MESSAGES/tools.model.cli.po b/locales/es/LC_MESSAGES/tools.model.cli.po index 26fec0ed61..56079517ca 100644 --- a/locales/es/LC_MESSAGES/tools.model.cli.po +++ b/locales/es/LC_MESSAGES/tools.model.cli.po @@ -7,16 +7,16 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-06-28 14:05+0100\n" -"PO-Revision-Date: 2022-06-28 14:11+0100\n" +"POT-Creation-Date: 2024-03-28 23:51+0000\n" +"PO-Revision-Date: 2024-03-29 00:00+0000\n" +"Last-Translator: \n" "Language-Team: \n" +"Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Poedit 3.0\n" -"Last-Translator: \n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -"Language: es\n" +"X-Generator: Poedit 3.4.2\n" #: tools/model/cli.py:13 msgid "This tool lets you perform actions on saved Faceswap models." @@ -29,7 +29,7 @@ msgstr "" "Una herramienta para realizar acciones en archivos de modelos entrenados " "Faceswap" -#: tools/model/cli.py:33 +#: tools/model/cli.py:34 msgid "" "Model directory. A directory containing the model you wish to perform an " "action on." @@ -37,7 +37,7 @@ msgstr "" "Directorio de modelo. Un directorio que contiene el modelo en el que desea " "realizar una acción." -#: tools/model/cli.py:41 +#: tools/model/cli.py:43 msgid "" "R|Choose which action you want to perform.\n" "L|'inference' - Create an inference only copy of the model. Strips any " @@ -58,11 +58,11 @@ msgstr "" "válidos).\n" "L|'restore': restaura un modelo desde una copia de seguridad." -#: tools/model/cli.py:55 tools/model/cli.py:66 +#: tools/model/cli.py:57 tools/model/cli.py:69 msgid "inference" msgstr "inferencia" -#: tools/model/cli.py:56 +#: tools/model/cli.py:59 msgid "" "R|The format to save the model as. Note: Only used for 'inference' job.\n" "L|'h5' - Standard Keras H5 format. Does not store any custom layer " @@ -77,9 +77,13 @@ msgstr "" "L|'saved-model': formato de modelo guardado de Tensorflow. Contiene toda la " "información necesaria para cargar el modelo fuera de Faceswap." -#: tools/model/cli.py:67 +#: tools/model/cli.py:71 +#, fuzzy +#| msgid "" +#| "Only used for 'inference' job. Generate the inference model for B -> A " +#| "instead of A -> B." msgid "" -"Only used for 'inference' job. Generate the inference model for B -> A " +"Only used for 'inference' job. Generate the inference model for B -> A " "instead of A -> B." msgstr "" "Solo se usa para el trabajo de 'inference'. Genere el modelo de inferencia " diff --git a/locales/es/LC_MESSAGES/tools.preview.mo b/locales/es/LC_MESSAGES/tools.preview.mo index ccf4c668d14e485bf072ad37f7cf318b1f3fa2f5..955c9576456a650d2cea24d53cb5381abb1084c2 100644 GIT binary patch delta 459 zcmX}nze~eF6bJCvHfd}`u~rwSAmUQ4#H64G6j7@bTa-2q^#>B@8KO0jCUp_**2zK0 zpCCF5MF;;0T^!tF|H&p zqdJ^SsA9h=Bp%uoe(p5`R`zYD&Q@Et&rHuylrbJB03501~h-&kkTfZK z;f``%7HMAO8v9F8`j>CXf`0+qfU~ksur!PDWL0@ml%c#=P32rxHAni|tu#bs%C#KN za#u`uv0huAcNlU{%*a@DkzLoO8_e*vf;Ih+6h diff --git a/locales/es/LC_MESSAGES/tools.preview.po b/locales/es/LC_MESSAGES/tools.preview.po index 8b17fc3ab9..f9cfb9218a 100644 --- a/locales/es/LC_MESSAGES/tools.preview.po +++ b/locales/es/LC_MESSAGES/tools.preview.po @@ -5,25 +5,26 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" -"POT-Creation-Date: 2021-02-18 23:09-0000\n" -"PO-Revision-Date: 2021-03-19 14:28+0000\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-28 23:53+0000\n" +"PO-Revision-Date: 2024-03-29 00:00+0000\n" +"Last-Translator: \n" "Language-Team: tokafondo\n" +"Language: es_ES\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 2.3\n" -"Last-Translator: \n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -"Language: es_ES\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 3.4.2\n" -#: tools/preview/cli.py:14 +#: tools/preview/cli.py:15 msgid "This command allows you to preview swaps to tweak convert settings." msgstr "" "Este comando permite previsualizar los intercambios para ajustar la " "configuración de la conversión." -#: tools/preview/cli.py:23 +#: tools/preview/cli.py:30 msgid "" "Preview tool\n" "Allows you to configure your convert settings with a live preview" @@ -31,11 +32,11 @@ msgstr "" "Herramienta de vista previa\n" "Permite configurar los ajustes de conversión con una vista previa en directo" -#: tools/preview/cli.py:33 tools/preview/cli.py:42 tools/preview/cli.py:49 +#: tools/preview/cli.py:47 tools/preview/cli.py:57 tools/preview/cli.py:65 msgid "data" msgstr "datos" -#: tools/preview/cli.py:35 +#: tools/preview/cli.py:50 msgid "" "Input directory or video. Either a directory containing the image files you " "wish to process or path to a video file." @@ -43,14 +44,14 @@ msgstr "" "Directorio o vídeo de entrada. Un directorio que contenga los archivos de " "imagen que desea procesar o la ruta a un archivo de vídeo." -#: tools/preview/cli.py:44 +#: tools/preview/cli.py:60 msgid "" "Path to the alignments file for the input, if not at the default location" msgstr "" -"Ruta del archivo de alineaciones para la entrada, si no está en la " -"ubicación por defecto" +"Ruta del archivo de alineaciones para la entrada, si no está en la ubicación " +"por defecto" -#: tools/preview/cli.py:51 +#: tools/preview/cli.py:68 msgid "" "Model directory. A directory containing the trained model you wish to " "process." @@ -58,31 +59,35 @@ msgstr "" "Directorio del modelo. Un directorio que contiene el modelo entrenado que " "desea procesar." -#: tools/preview/cli.py:58 +#: tools/preview/cli.py:74 msgid "Swap the model. Instead of A -> B, swap B -> A" -msgstr "" -"Intercambiar el modelo. En lugar de convertir A en B, convierte B en A" +msgstr "Intercambiar el modelo. En lugar de convertir A en B, convierte B en A" -#: tools/preview\preview.py:1303 +#: tools/preview/control_panels.py:510 msgid "Save full config" msgstr "Guardar la configuración completa" -#: tools/preview\preview.py:1306 +#: tools/preview/control_panels.py:513 msgid "Reset full config to default values" msgstr "Restablecer la configuración completa a los valores por defecto" -#: tools/preview\preview.py:1309 +#: tools/preview/control_panels.py:516 msgid "Reset full config to saved values" msgstr "Restablecer la configuración completa a los valores guardados" -#: tools/preview\preview.py:1453 -msgid "Save {} config" -msgstr "Guardar la configuración de {}" +#: tools/preview/control_panels.py:667 +#, python-brace-format +msgid "Save {title} config" +msgstr "Guardar la configuración de {title}" -#: tools/preview\preview.py:1456 -msgid "Reset {} config to default values" -msgstr "Restablecer la configuración completa de {} a los valores por defecto" +#: tools/preview/control_panels.py:670 +#, python-brace-format +msgid "Reset {title} config to default values" +msgstr "" +"Restablecer la configuración completa de {title} a los valores por defecto" -#: tools/preview\preview.py:1459 -msgid "Reset {} config to saved values" -msgstr "Restablecer la configuración completa de {} a los valores guardados" +#: tools/preview/control_panels.py:673 +#, python-brace-format +msgid "Reset {title} config to saved values" +msgstr "" +"Restablecer la configuración completa de {title} a los valores guardados" diff --git a/locales/es/LC_MESSAGES/tools.sort.cli.mo b/locales/es/LC_MESSAGES/tools.sort.cli.mo index a4420970f517001c0452821997dff300f3f4cee1..1eea2cf248ed6c875de7ceac4ad29537a7e3c7ba 100644 GIT binary patch delta 892 zcmZY8Pe_zO7{~FSyQ{nVR=TOVrETk)WT{(kyPLGJkk?Knh#+Z)=n%9-7#;G^n+OsV zRIEXVJctEL2ZLUt4xNIi4n>3*_D2we2)uN$KXm9jv(}}7+0Q%k&ilO2`^@Z%i#LyZ zOZA~!qP5ev(+gG70UQeOLvw@D1Kf|ZxQrk1Mo2n}#cC;qPq7P^umhbMX$3QwCuTe> zm9Z}(jS~A;RGO1KUD!x)7Itou7H|j;Vz!orJWxd5r7v~TE?mWFY=~9HkFb&PCrsft zd}i)%mXf%D+i(pZW2{~xq+a3}@7H@S#+evwkS^nAJc1{PI)Y_1U)-`qB6)pBa+I2+ zVG^vxR}71B={fdnm8zNljRTAW&C(tm#CvL^Ar%^X~IMKyGa`L9t#yJZwst?RQJ*0o!xY$a0; zwJnV~7@RM0R_s3lY~ze-2i+Vb$G;c69`K{#vths3_&QSRO`MM9(rz}L$+x=hAvfFQ jx}_(HAEDgdj8nRlyyo{NPX>IiZ7kwPGq;^mxsdz=6t83` delta 4479 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 ZjKPjQpI`a={okLuIX4MiYH)Ez{}0t;Q&j)} diff --git a/locales/es/LC_MESSAGES/tools.sort.cli.po b/locales/es/LC_MESSAGES/tools.sort.cli.po index dd0311930d..0914d6011c 100644 --- a/locales/es/LC_MESSAGES/tools.sort.cli.po +++ b/locales/es/LC_MESSAGES/tools.sort.cli.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-09-23 14:16+0100\n" -"PO-Revision-Date: 2022-09-23 14:16+0100\n" +"POT-Creation-Date: 2024-03-28 23:53+0000\n" +"PO-Revision-Date: 2024-03-29 00:03+0000\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es_ES\n" @@ -16,14 +16,14 @@ 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/sort/cli.py:14 +#: tools/sort/cli.py:15 msgid "This command lets you sort images using various methods." msgstr "" "Este comando le permite ordenar las imágenes utilizando varios métodos." -#: tools/sort/cli.py:20 +#: tools/sort/cli.py:21 msgid "" " Adjust the '-t' ('--threshold') parameter to control the strength of " "grouping." @@ -31,7 +31,7 @@ msgstr "" " Ajuste el parámetro '-t' ('--threshold') para controlar la fuerza de la " "agrupación." -#: tools/sort/cli.py:21 +#: tools/sort/cli.py:22 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. Each image is allocated to a bin by the percentage of color pixels " @@ -41,7 +41,7 @@ msgstr "" "contenedores para agrupar. Cada imagen se asigna a un contenedor por el " "porcentaje de píxeles de color que aparecen en la imagen." -#: tools/sort/cli.py:24 +#: tools/sort/cli.py:25 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. Each image is allocated to a bin by the number of degrees the face " @@ -51,7 +51,7 @@ msgstr "" "contenedores para agrupar. Cada imagen se asigna a un contenedor por el " "número de grados que la cara está orientada desde el centro." -#: tools/sort/cli.py:27 +#: tools/sort/cli.py:28 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. The minimum and maximum values are taken for the chosen sort " @@ -62,15 +62,15 @@ msgstr "" "métrica de clasificación elegida. Luego, los contenedores se llenan con los " "resultados de la clasificación de grupos." -#: tools/sort/cli.py:31 +#: tools/sort/cli.py:32 msgid "faces by blurriness." msgstr "rostros por desenfoque." -#: tools/sort/cli.py:32 +#: tools/sort/cli.py:33 msgid "faces by fft filtered blurriness." msgstr "caras por borrosidad filtrada fft." -#: tools/sort/cli.py:33 +#: tools/sort/cli.py:34 msgid "" "faces by the estimated distance of the alignments from an 'average' face. " "This can be useful for eliminating misaligned faces. Sorts from most like an " @@ -80,7 +80,7 @@ msgstr "" "'promedio'. Esto puede ser útil para eliminar caras desalineadas. Ordena de " "más parecido a un rostro promedio a menos parecido a un rostro promedio." -#: tools/sort/cli.py:36 +#: tools/sort/cli.py:37 msgid "" "faces using VGG Face2 by face similarity. This uses a pairwise clustering " "algorithm to check the distances between 512 features on every face in your " @@ -90,23 +90,23 @@ msgstr "" "agrupamiento por pares para verificar las distancias entre 512 " "características en cada cara de su conjunto y ordenarlas apropiadamente." -#: tools/sort/cli.py:39 +#: tools/sort/cli.py:40 msgid "faces by their landmarks." msgstr "caras por sus puntos de referencia." -#: tools/sort/cli.py:40 +#: tools/sort/cli.py:41 msgid "Like 'face-cnn' but sorts by dissimilarity." msgstr "Como 'face-cnn' pero ordenada por la similitud." -#: tools/sort/cli.py:41 +#: tools/sort/cli.py:42 msgid "faces by Yaw (rotation left to right)." msgstr "caras por guiñada (rotación de izquierda a derecha)." -#: tools/sort/cli.py:42 +#: tools/sort/cli.py:43 msgid "faces by Pitch (rotation up and down)." msgstr "caras por Pitch (rotación arriba y abajo)." -#: tools/sort/cli.py:43 +#: tools/sort/cli.py:44 msgid "" "faces by Roll (rotation). Aligned faces should have a roll value close to " "zero. The further the Roll value from zero the higher liklihood the face is " @@ -116,22 +116,22 @@ msgstr "" "balanceo cercano a cero. Cuanto más lejos esté el valor de Roll de cero, " "mayor será la probabilidad de que la cara esté desalineada." -#: tools/sort/cli.py:45 +#: tools/sort/cli.py:46 msgid "faces by their color histogram." msgstr "caras por su histograma de color." -#: tools/sort/cli.py:46 +#: tools/sort/cli.py:47 msgid "Like 'hist' but sorts by dissimilarity." msgstr "Como 'hist' pero ordenada por la disimilitud." -#: tools/sort/cli.py:47 +#: tools/sort/cli.py:48 msgid "" "images by the average intensity of the converted grayscale color channel." msgstr "" "imágenes por la intensidad media del canal de color en escala de grises " "convertido." -#: tools/sort/cli.py:48 +#: tools/sort/cli.py:49 msgid "" "images by their number of black pixels. Useful when faces are near borders " "and a large part of the image is black." @@ -139,7 +139,7 @@ msgstr "" "imágenes por su número de píxeles negros. Útil cuando las caras están cerca " "de los bordes y una gran parte de la imagen es negra." -#: tools/sort/cli.py:50 +#: tools/sort/cli.py:51 msgid "" "images by the average intensity of the converted Y color channel. Bright " "lighting and oversaturated images will be ranked first." @@ -148,7 +148,7 @@ msgstr "" "iluminación brillante y las imágenes sobresaturadas se clasificarán en " "primer lugar." -#: tools/sort/cli.py:52 +#: tools/sort/cli.py:53 msgid "" "images by the average intensity of the converted Cg color channel. Green " "images will be ranked first and red images will be last." @@ -157,7 +157,7 @@ msgstr "" "imágenes verdes se clasificarán primero y las imágenes rojas serán las " "últimas." -#: tools/sort/cli.py:54 +#: tools/sort/cli.py:55 msgid "" "images by the average intensity of the converted Co color channel. Orange " "images will be ranked first and blue images will be last." @@ -166,7 +166,7 @@ msgstr "" "imágenes naranjas se clasificarán en primer lugar y las imágenes azules en " "último lugar." -#: tools/sort/cli.py:56 +#: tools/sort/cli.py:57 msgid "" "images by their size in the original frame. Faces further from the camera " "and from lower resolution sources will be sorted first, whilst faces closer " @@ -177,24 +177,16 @@ msgstr "" "las caras más cercanas a la cámara y de fuentes de mayor resolución se " "ordenarán en último lugar." -#: tools/sort/cli.py:59 -msgid " option is deprecated. Use 'yaw'" -msgstr " la opción está en desuso. Usa 'yaw'" - -#: tools/sort/cli.py:60 -msgid " option is deprecated. Use 'color-black'" -msgstr " la opción está en desuso. Usa 'color-black'" - -#: tools/sort/cli.py:82 +#: tools/sort/cli.py:81 msgid "Sort faces using a number of different techniques" msgstr "Clasificar los rostros mediante diferentes técnicas" -#: tools/sort/cli.py:92 tools/sort/cli.py:99 tools/sort/cli.py:110 -#: tools/sort/cli.py:148 +#: tools/sort/cli.py:91 tools/sort/cli.py:98 tools/sort/cli.py:110 +#: tools/sort/cli.py:150 msgid "data" msgstr "datos" -#: tools/sort/cli.py:93 +#: tools/sort/cli.py:92 msgid "Input directory of aligned faces." msgstr "Directorio de entrada de caras alineadas." @@ -212,7 +204,7 @@ msgstr "" "selecciona 'keep', las imágenes se ordenarán en el lugar, sobrescribiendo el " "contenido original de 'input_dir'" -#: tools/sort/cli.py:111 +#: tools/sort/cli.py:112 msgid "" "R|If selected then the input_dir should be a parent folder containing " "multiple folders of faces you wish to sort. The faces will be output to " @@ -222,11 +214,11 @@ msgstr "" "varias carpetas de caras que desea ordenar. Las caras se enviarán a " "subcarpetas separadas en output_dir" -#: tools/sort/cli.py:120 +#: tools/sort/cli.py:121 msgid "sort settings" msgstr "ajustes de ordenación" -#: tools/sort/cli.py:122 +#: tools/sort/cli.py:124 msgid "" "R|Choose how images are sorted. Selecting a sort method gives the images a " "new filename based on the order the image appears within the given method.\n" @@ -244,17 +236,25 @@ msgstr "" "nombres de archivo originales. Seleccionar 'none' para 'sort-by' y 'group-" "by' no hará nada" -#: tools/sort/cli.py:135 tools/sort/cli.py:162 tools/sort/cli.py:191 +#: tools/sort/cli.py:136 tools/sort/cli.py:164 tools/sort/cli.py:184 msgid "group settings" msgstr "ajustes de grupo" -#: tools/sort/cli.py:137 +#: tools/sort/cli.py:139 +#, fuzzy +#| msgid "" +#| "R|Selecting a group by method will move/copy files into numbered bins " +#| "based on the selected method.\n" +#| "L|'none': Don't bin the images. Folders will be sorted by the selected " +#| "'sort-by' but will not be binned, instead they will be sorted into a " +#| "single folder. Selecting 'none' for both 'sort-by' and 'group-by' will " +#| "do nothing" msgid "" "R|Selecting a group by method will move/copy files into numbered bins based " "on the selected method.\n" "L|'none': Don't bin the images. Folders will be sorted by the selected 'sort-" "by' but will not be binned, instead they will be sorted into a single " -"folder. Selecting 'none' for both 'sort-by' and 'group-by' will do nothing" +"folder. Selecting 'none' for both 'sort-by' and 'group-by' will do nothing" msgstr "" "R|Al seleccionar un grupo por método, los archivos se moverán/copiarán en " "contenedores numerados según el método seleccionado.\n" @@ -262,7 +262,7 @@ msgstr "" "by' seleccionado, pero no se agruparán, sino que se ordenarán en una sola " "carpeta. Seleccionar 'none' para 'sort-by' y 'group-by' no hará nada" -#: tools/sort/cli.py:149 +#: tools/sort/cli.py:152 msgid "" "Whether to keep the original files in their original location. Choosing a " "'sort-by' method means that the files have to be renamed. Selecting 'keep' " @@ -279,7 +279,7 @@ msgstr "" "moverán y cambiarán de nombre en función de los criterios de clasificación/" "grupo seleccionados." -#: tools/sort/cli.py:164 +#: tools/sort/cli.py:167 msgid "" "R|Float value. Minimum threshold to use for grouping comparison with 'face-" "cnn' 'hist' and 'face' methods.\n" @@ -306,20 +306,29 @@ msgstr "" "de muchas carpetas. Valores predeterminados: face-cnn 7.2, hist 0.3, face " "0.25" -#: tools/sort/cli.py:181 -msgid "output" -msgstr "salida" - -#: tools/sort/cli.py:182 -msgid "" -"Deprecated and no longer used. The final processing will be dictated by the " -"sort/group by methods and whether 'keep_original' is selected." -msgstr "" -"En desuso y ya no se usa. El procesamiento final será dictado por los " -"métodos de ordenación/agrupación y si se selecciona 'keepl'." - -#: tools/sort/cli.py:193 -#, python-format +#: tools/sort/cli.py:187 +#, fuzzy, python-format +#| msgid "" +#| "R|Integer value. Used to control the number of bins created for grouping " +#| "by: any 'blur' methods, 'color' methods or 'face metric' methods " +#| "('distance', 'size') and 'orientation; methods ('yaw', 'pitch'). For any " +#| "other grouping methods see the '-t' ('--threshold') option.\n" +#| "L|For 'face metric' methods the bins are filled, according the the " +#| "distribution of faces between the minimum and maximum chosen metric.\n" +#| "L|For 'color' methods the number of bins represents the divider of the " +#| "percentage of colored pixels. Eg. For a bin number of '5': The first " +#| "folder will have the faces with 0%% to 20%% colored pixels, second 21%% " +#| "to 40%%, etc. Any empty bins will be deleted, so you may end up with " +#| "fewer bins than selected.\n" +#| "L|For 'blur' methods folder 0 will be the least blurry, while the last " +#| "folder will be the blurriest.\n" +#| "L|For 'orientation' methods the number of bins is dictated by how much " +#| "180 degrees is divided. Eg. If 18 is selected, then each folder will be a " +#| "10 degree increment. Folder 0 will contain faces looking the most to the " +#| "left/down whereas the last folder will contain the faces looking the most " +#| "to the right/up. NB: Some bins may be empty if faces do not fit the " +#| "criteria.\n" +#| "Default value: 5" msgid "" "R|Integer value. Used to control the number of bins created for grouping by: " "any 'blur' methods, 'color' methods or 'face metric' methods ('distance', " @@ -338,7 +347,7 @@ msgid "" "degrees is divided. Eg. If 18 is selected, then each folder will be a 10 " "degree increment. Folder 0 will contain faces looking the most to the left/" "down whereas the last folder will contain the faces looking the most to the " -"right/up. NB: Some bins may be empty if faces do not fit the criteria.\n" +"right/up. NB: Some bins may be empty if faces do not fit the criteria. \n" "Default value: 5" msgstr "" "R|Valor entero. Se utiliza para controlar el número de contenedores creados " @@ -364,11 +373,11 @@ msgstr "" "pueden estar vacíos si las caras no se ajustan a los criterios.\n" "Valor predeterminado: 5" -#: tools/sort/cli.py:215 tools/sort/cli.py:225 +#: tools/sort/cli.py:207 tools/sort/cli.py:217 msgid "settings" msgstr "ajustes" -#: tools/sort/cli.py:217 +#: tools/sort/cli.py:210 msgid "" "Logs file renaming changes if grouping by renaming, or it logs the file " "copying/movement if grouping by folders. If no log file is specified with " @@ -380,7 +389,7 @@ msgstr "" "se especifica ningún archivo de registro con '--log-file', se creará un " "archivo 'sort_log.json' en el directorio de entrada." -#: tools/sort/cli.py:228 +#: tools/sort/cli.py:221 msgid "" "Specify a log file to use for saving the renaming or grouping information. " "If specified extension isn't 'json' or 'yaml', then json will be used as the " @@ -391,6 +400,22 @@ msgstr "" "'json' o 'yaml', se utilizará json como serializador, con el nombre de " "archivo suministrado. Por defecto: sort_log.json" +#~ msgid " option is deprecated. Use 'yaw'" +#~ msgstr " la opción está en desuso. Usa 'yaw'" + +#~ msgid " option is deprecated. Use 'color-black'" +#~ msgstr " la opción está en desuso. Usa 'color-black'" + +#~ msgid "output" +#~ msgstr "salida" + +#~ msgid "" +#~ "Deprecated and no longer used. The final processing will be dictated by " +#~ "the sort/group by methods and whether 'keep_original' is selected." +#~ msgstr "" +#~ "En desuso y ya no se usa. El procesamiento final será dictado por los " +#~ "métodos de ordenación/agrupación y si se selecciona 'keepl'." + #~ msgid "Output directory for sorted aligned faces." #~ msgstr "Directorio de salida para las caras alineadas ordenadas." diff --git a/locales/kr/LC_MESSAGES/lib.cli.args.mo b/locales/kr/LC_MESSAGES/lib.cli.args.mo index 9b1d915197663bf5f9428d5299c5a03ee991509e..bc4b9fce780957f3af7f97feea345bbb23e7b7bc 100644 GIT binary patch delta 292 zcmaF(k7+k={XHR;sSH5C4#YA*90J7LK->ewAg}|7`G9yQ5VryG2_W_YVt+;k20b7> z3rM>G>61YEJdhS(VqlO1vL66x0U-ZBGXujqkT?qiLjsWA52S5?v;r#wLo@?}2SY57 z!3fl_6GQ_gKmf>O0P14^ngIrEP#UNg3_y-$aL>t4O3YF4FDS{(&nup6FWE79hmz0a zRJGE{0!9}mua0sQFwr$I)-|$FFto5TG}ksTnEWT|q^OCW5f{Uo1ubu;uT^+6W9ysN h8LuZVnS7~W!(@$`#L4LmGns;FChPS}Ocv~a0RY1iJAwcJ literal 48993 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 I5rk&{50*?TSpWb4 diff --git a/locales/kr/LC_MESSAGES/lib.cli.args.po b/locales/kr/LC_MESSAGES/lib.cli.args.po index 365b326a66..ec473df1f1 100644 --- a/locales/kr/LC_MESSAGES/lib.cli.args.po +++ b/locales/kr/LC_MESSAGES/lib.cli.args.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-25 16:09+0100\n" -"PO-Revision-Date: 2023-09-25 16:15+0100\n" +"POT-Creation-Date: 2024-03-28 18:06+0000\n" +"PO-Revision-Date: 2024-03-28 18:17+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ko_KR\n" @@ -16,31 +16,31 @@ 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.3.2\n" +"X-Generator: Poedit 3.4.2\n" -#: lib/cli/args.py:192 lib/cli/args.py:202 lib/cli/args.py:210 -#: lib/cli/args.py:220 +#: lib/cli/args.py:188 lib/cli/args.py:199 lib/cli/args.py:208 +#: lib/cli/args.py:219 msgid "Global Options" msgstr "전역 옵션들" -#: lib/cli/args.py:193 +#: lib/cli/args.py:190 msgid "" "R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " "to any GPU(s) that you do not wish to be made available to Faceswap. " "Selecting all GPUs here will force Faceswap into CPU mode.\n" "L|{}" msgstr "" -"R|Faceswap에서 사용되는 GPUs를 제외합니다. Faceswap에서 사용되게 하고 싶지 않" -"은 GPU(s)에 해당하는 번호를 선택하세요. 모든 GPUs를 선택하면 Faceswap으로 하" -"여금 CPU mode를 강제로 사용하게 합니다.\n" +"R|Faceswap에서 사용되는 GPUs를 제외합니다. Faceswap에서 사용되게 하고 싶지 " +"않은 GPU(s)에 해당하는 번호를 선택하세요. 모든 GPUs를 선택하면 Faceswap으로 " +"하여금 CPU mode를 강제로 사용하게 합니다.\n" "L|{}" -#: lib/cli/args.py:203 +#: lib/cli/args.py:201 msgid "" "Optionally overide the saved config with the path to a custom config file." msgstr "선택적으로 저장된 설정을 경로와 함께 개인 설정 파일에 덮어씌웁니다." -#: lib/cli/args.py:211 +#: lib/cli/args.py:210 msgid "" "Log level. Stick with INFO or VERBOSE unless you need to file an error " "report. Be careful with TRACE as it will generate a lot of data" @@ -48,969 +48,10 @@ msgstr "" "로그 레벨. 오류 리포트가 필요하지 않다면 INFO와 VERBOSE를 사용하세요. 단, 굉" "장히 많은 데이터를 생성할 수 있는 TRACE는 조심하세요" -#: lib/cli/args.py:221 +#: lib/cli/args.py:220 msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "로그파일을 저장할 경로. faceswap 폴더에 저장하고 싶으면 비워두세요" -#: lib/cli/args.py:319 lib/cli/args.py:328 lib/cli/args.py:336 -#: lib/cli/args.py:385 lib/cli/args.py:676 lib/cli/args.py:685 -msgid "Data" -msgstr "데이터" - -#: lib/cli/args.py:320 -msgid "" -"Input directory or video. Either a directory containing the image files you " -"wish to process or path to a video file. NB: This should be the source video/" -"frames NOT the source faces." -msgstr "" -"폴더나 비디오를 입력하세요. 당신이 사용하고 싶은 이미지 파일들을 가진 폴더 또" -"는 비디오 파일의 경로여야 합니다. NB: 이 폴더는 원본 비디오여야 합니다." - -#: lib/cli/args.py:329 -msgid "Output directory. This is where the converted files will be saved." -msgstr "출력 폴더. 변환된 파일들이 저장될 곳입니다." - -#: lib/cli/args.py:337 -msgid "" -"Optional path to an alignments file. Leave blank if the alignments file is " -"at the default location." -msgstr "" -"(선택적) alignments 파일의 경로. 비워두면 alignments 파일이 기본 위치에 저장" -"됩니다." - -#: lib/cli/args.py:360 -msgid "" -"Extract faces from image or video sources.\n" -"Extraction plugins can be configured in the 'Settings' Menu" -msgstr "" -"얼굴들을 이미지 또는 비디오에서 추출합니다.\n" -"추출 플러그인은 '설정' 메뉴에서 설정할 수 있습니다" - -#: lib/cli/args.py:386 -msgid "" -"R|If selected then the input_dir should be a parent folder containing " -"multiple videos and/or folders of images you wish to extract from. The faces " -"will be output to separate sub-folders in the output_dir." -msgstr "" -"R|만약 선택된다면 input_dir은 당신이 추출하고자 하는 여러개의 비디오 그리고/" -"또는 이미지들을 가진 부모 폴더가 되야 합니다. 얼굴들은 output_dir에 분리된 하" -"위 폴더에 저장됩니다." - -#: lib/cli/args.py:395 lib/cli/args.py:411 lib/cli/args.py:423 -#: lib/cli/args.py:462 lib/cli/args.py:480 lib/cli/args.py:492 -#: lib/cli/args.py:501 lib/cli/args.py:510 lib/cli/args.py:695 -#: lib/cli/args.py:722 lib/cli/args.py:760 -msgid "Plugins" -msgstr "플러그인들" - -#: lib/cli/args.py:396 -msgid "" -"R|Detector to use. Some of these have configurable settings in '/config/" -"extract.ini' or 'Settings > Configure Extract 'Plugins':\n" -"L|cv2-dnn: A CPU only extractor which is the least reliable and least " -"resource intensive. Use this if not using a GPU and time is important.\n" -"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " -"than other GPU detectors but can often return more false positives.\n" -"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " -"fewer false positives than other GPU detectors, but is a lot more resource " -"intensive." -msgstr "" -"R|사용할 감지기. 몇몇 감지기들은 '/config/extract.ini' 또는 '설정 > 추출 플러" -"그인 설정'에서 설정이 가능합니다:\n" -"L|cv2-dnn: 가장 믿을 수 없고 가장 자원을 덜 사용하며 CPU만을 사용하는 추출기" -"입니다. 만약 GPU를 사용하지 않고 시간이 중요하다면 사용하세요.\n" -"L|mtcnn: 좋은 감지기. CPU에서도 빠르고 GPU에서도 빠릅니다. 다른 GPU 감지기들" -"보다 더 적은 자원을 사용하지만 가끔 더 많은 false positives를 돌려줄 수 있습" -"니다.\n" -"L|s3fd: 가장 좋은 감지기. CPU에선 느리고 GPU에선 빠릅니다. 다른 GPU 감지기들" -"보다 더 많은 얼굴들을 감지할 수 있고 과 더 적은 false positives를 돌려주지만 " -"자원을 굉장히 많이 사용합니다." - -#: lib/cli/args.py:412 -msgid "" -"R|Aligner to use.\n" -"L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " -"but less accurate. Only use this if not using a GPU and time is important.\n" -"L|fan: Best aligner. Fast on GPU, slow on CPU." -msgstr "" -"R|사용할 Aligner.\n" -"L|cv2-dnn: CPU만을 사용하는 특징점 감지기. 빠르고 자원을 덜 사용하지만 부정확" -"합니다. GPU를 사용하지 않고 시간이 중요할 때에만 사용하세요.\n" -"L|fan: 가장 좋은 aligner. GPU에선 빠르고 CPU에선 느립니다." - -#: lib/cli/args.py:424 -msgid "" -"R|Additional Masker(s) to use. The masks generated here will all take up GPU " -"RAM. You can select none, one or multiple masks, but the extraction may take " -"longer the more you select. NB: The Extended and Components (landmark based) " -"masks are automatically generated on extraction.\n" -"L|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).\n" -"L|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 so will not use any additional VRAM.\n" -"L|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.\n" -"L|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.\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.\n" -"The auto generated masks are as follows:\n" -"L|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.\n" -"L|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.\n" -"(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -msgstr "" -"R|사용할 추가 Mask입니다. 여기서 생성된 마스크는 모두 GPU RAM을 차지합니다. " -"마스크를 0개, 1개 또는 여러 개 선택할 수 있지만 더 많이 선택할수록 추출에 시" -"간이 더 걸릴 수 있습니다. NB: 확장 및 구성 요소(특징점 기반) 마스크는 추출 " -"시 자동으로 생성됩니다.\n" -"L|bisnet-fp: 전체 헤드 마스킹(마스크 설정에서 구성 가능)을 포함하여 마스킹할 " -"영역에 대한 보다 정교한 제어를 제공하는 비교적 가벼운 NN 기반 마스크입니다.\n" -"L|custom: 마스크 영역을 모든 1 또는 0으로 채우는 dummy 마스크입니다(설정에서 " -"구성 가능). 수동 도구에서 사용자 정의 마스크를 직접 수동으로 편집하려는 경우" -"에만 필요합니다. 이 마스크는 GPU를 사용하지 않으므로 추가 VRAM을 사용하지 않" -"습니다.\n" -"L|vgg-clear: 대부분의 정면에 장애물이 없는 스마트한 분할을 제공하도록 설계된 " -"마스크입니다. 프로필 얼굴들 및 장애물들로 인해 성능이 저하될 수 있습니다.\n" -"L|vgg-obstructed: 대부분의 정면 얼굴을 스마트하게 분할할 수 있도록 설계된 마" -"스크입니다. 마스크 모델은 일부 안면 장애물(손과 안경)을 인식하도록 특별히 훈" -"련되었습니다. 프로필 얼굴들은 평균 이하의 성능을 초래할 수 있습니다.\n" -"L|unet-dfl: 대부분 정면 얼굴을 스마트하게 분할하도록 설계된 마스크. 마스크 모" -"델은 커뮤니티 구성원들에 의해 훈련되었으며 추가 설명을 위해 테스트가 필요하" -"다. 프로필 얼굴들은 평균 이하의 성능을 초래할 수 있습니다.\n" -"자동 생성 마스크는 다음과 같습니다.\n" -"L|components: 특징점 위치의 위치를 기반으로 얼굴 분할을 제공하도록 설계된 마" -"스크입니다. 특징점의 외부에는 마스크를 만들기 위해 convex hull가 형성되어 있" -"습니다.\n" -"L|extended: 특징점 위치의 위치를 기반으로 얼굴 분할을 제공하도록 설계된 마스" -"크입니다. 특징점의 외부에는 convex hull가 형성되어 있으며, 마스크는 이마 위" -"로 뻗어 있습ㄴ다.\n" -"(예: '-M unet-dfl vgg-clear', '--masker vgg-obstructed')" - -#: lib/cli/args.py:463 -msgid "" -"R|Performing normalization can help the aligner better align faces with " -"difficult lighting conditions at an extraction speed cost. Different methods " -"will yield different results on different sets. NB: This does not impact the " -"output face, just the input to the aligner.\n" -"L|none: Don't perform normalization on the face.\n" -"L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " -"face.\n" -"L|hist: Equalize the histograms on the RGB channels.\n" -"L|mean: Normalize the face colors to the mean." -msgstr "" -"R|정규화를 수행하면 aligner가 추출 속도 비용으로 어려운 조명 조건의 얼굴을 " -"더 잘 정렬할 수 있습니다. 방법이 다르면 세트마다 결과가 다릅니다. NB: 출력 얼" -"굴에는 영향을 주지 않으며 aligner에 대한 입력에만 영향을 줍니다.\n" -"L|none: 얼굴에 정규화를 수행하지 마십시오.\n" -"L|clahe: 얼굴에 Contrast Limited Adaptive Histogram Equalization를 수행합니" -"다.\n" -"L|hist: RGB 채널의 히스토그램을 동일하게 합니다.\n" -"L|mean: 얼굴 색상을 평균으로 정규화합니다." - -#: lib/cli/args.py:481 -msgid "" -"The number of times to re-feed the detected face into the aligner. Each time " -"the face is re-fed into the aligner the bounding box is adjusted by a small " -"amount. The final landmarks are then averaged from each iteration. Helps to " -"remove 'micro-jitter' but at the cost of slower extraction speed. The more " -"times the face is re-fed into the aligner, the less micro-jitter should " -"occur but the longer extraction will take." -msgstr "" -"검출된 얼굴을 aligner에 다시 공급하는 횟수입니다. 얼굴이 aligner에 다시 공급" -"될 때마다 경계 상자가 소량 조정됩니다. 그런 다음 각 반복에서 최종 특징점의 평" -"균을 구한다. 'micro-jitter'를 제거하는 데 도움이 되지만 추출 속도가 느려집니" -"다. 얼굴이 aligner에 다시 공급되는 횟수가 많을수록 micro-jitter 적게 발생하지" -"만 추출에 더 오랜 시간이 걸립니다." - -#: lib/cli/args.py:493 -msgid "" -"Re-feed the initially found aligned face through the aligner. Can help " -"produce better alignments for faces that are rotated beyond 45 degrees in " -"the frame or are at extreme angles. Slows down extraction." -msgstr "" -"_aligner를 통해 처음 발견된 정렬된 얼굴을 재공급합니다. 프레임에서 45도 이상 " -"회전하거나 극단적인 각도에 있는 얼굴을 더 잘 정렬할 수 있습니다. 추출 속도가 " -"느려집니다." - -#: lib/cli/args.py:502 -msgid "" -"If a face isn't found, rotate the images to try to find a face. Can find " -"more faces at the cost of extraction speed. Pass in a single number to use " -"increments of that size up to 360, or pass in a list of numbers to enumerate " -"exactly what angles to check." -msgstr "" -"얼굴이 발견되지 않으면 이미지를 회전하여 얼굴을 찾습니다. 추출 속도를 희생하" -"면서 더 많은 얼굴을 찾을 수 있습니다. 단일 숫자를 입력하여 해당 크기의 증분" -"을 360까지 사용하거나 숫자 목록을 입력하여 확인할 각도를 정확하게 열거합니다." - -#: lib/cli/args.py:511 -msgid "" -"Obtain and store face identity encodings from VGGFace2. Slows down extract a " -"little, but will save time if using 'sort by face'" -msgstr "" -"VGGFace2에서 얼굴 식별 인코딩을 가져와 저장합니다. 추출 속도를 약간 늦추지만 " -"'얼굴별로 정렬'을 사용하면 시간을 절약할 수 있습니다." - -#: lib/cli/args.py:521 lib/cli/args.py:531 lib/cli/args.py:543 -#: lib/cli/args.py:556 lib/cli/args.py:804 lib/cli/args.py:812 -#: lib/cli/args.py:826 lib/cli/args.py:839 lib/cli/args.py:853 -msgid "Face Processing" -msgstr "얼굴 처리" - -#: lib/cli/args.py:522 -msgid "" -"Filters out faces detected below this size. Length, in pixels across the " -"diagonal of the bounding box. Set to 0 for off" -msgstr "" -"이 크기 미만으로 탐지된 얼굴을 필터링합니다. 길이, 경계 상자의 대각선에 걸친 " -"픽셀 단위입니다. 0으로 설정하면 꺼집니다" - -#: lib/cli/args.py:532 -msgid "" -"Optionally filter out people who you do not wish to extract by passing in " -"images of those people. Should be a small variety of images at different " -"angles and in different conditions. A folder containing the required images " -"or multiple image files, space separated, can be selected." -msgstr "" -"선택적으로 추출하지 않을 사람의 이미지들을 전달하여 그 사람들을 제외합니다. " -"각도와 조건이 다른 작은 다양한 이미지여야 합니다. 추출되지 않는데 필요한 이미" -"지들 또는 공백으로 구분된 여러 이미지 파일이 들어 있는 폴더를 선택할 수 있습" -"니다." - -#: lib/cli/args.py:544 -msgid "" -"Optionally select people you wish to extract by passing in images of that " -"person. Should be a small variety of images at different angles and in " -"different conditions A folder containing the required images or multiple " -"image files, space separated, can be selected." -msgstr "" -"선택적으로 추출하고 싶은 사람의 이미지를 전달하여 그 사람을 선택합니다. 각도" -"와 조건이 다른 작은 다양한 이미지여야 합니다. 추출할 때 필요한 이미지들 또는 " -"공백으로 구분된 여러 이미지 파일이 들어 있는 폴더를 선택할 수 있습니다." - -#: lib/cli/args.py:557 -msgid "" -"For use with the optional nfilter/filter files. Threshold for positive face " -"recognition. Higher values are stricter." -msgstr "" -"옵션인 nfilter/filter 파일과 함께 사용합니다. 긍정적인 얼굴 인식을 위한 임계" -"값. 값이 높을수록 엄격합니다." - -#: lib/cli/args.py:566 lib/cli/args.py:578 lib/cli/args.py:590 -#: lib/cli/args.py:602 -msgid "output" -msgstr "출력" - -#: lib/cli/args.py:567 -msgid "" -"The output size of extracted faces. Make sure that the model you intend to " -"train supports your required size. This will only need to be changed for hi-" -"res models." -msgstr "" -"추출된 얼굴의 출력 크기입니다. 훈련하려는 모델이 필요한 크기를 지원하는지 꼭 " -"확인하세요. 이것은 고해상도 모델에 대해서만 변경하면 됩니다." - -#: lib/cli/args.py:579 -msgid "" -"Extract every 'nth' frame. This option will skip frames when extracting " -"faces. For example a value of 1 will extract faces from every frame, a value " -"of 10 will extract faces from every 10th frame." -msgstr "" -"모든 'n번째' 프레임을 추출합니다. 이 옵션은 얼굴을 추출할 때 건너뛸 프레임을 " -"설정합니다. 예를 들어, 값이 1이면 모든 프레임에서 얼굴이 추출되고, 값이 10이" -"면 모든 10번째 프레임에서 얼굴이 추출됩니다." - -#: lib/cli/args.py:591 -msgid "" -"Automatically save the alignments file after a set amount of frames. By " -"default the alignments file is only saved at the end of the extraction " -"process. NB: If extracting in 2 passes then the alignments file will only " -"start to be saved out during the second pass. WARNING: Don't interrupt the " -"script when writing the file because it might get corrupted. Set to 0 to " -"turn off" -msgstr "" -"프레임 수가 설정된 후 alignments 파일을 자동으로 저장합니다. 기본적으로 " -"alignments 파일은 추출 프로세스가 끝날 때만 저장됩니다. NB: 2번째 추출에서 성" -"공하면 두 번째 추출 중에만 alignments 파일이 저장되기 시작합니다. 경고: 파일" -"을 쓸 때 스크립트가 손상될 수 있으므로 스크립트를 중단하지 마십시오. 해제하려" -"면 0으로 설정" - -#: lib/cli/args.py:603 -msgid "Draw landmarks on the ouput faces for debugging purposes." -msgstr "디버깅을 위해 출력 얼굴에 특징점을 그립니다." - -#: lib/cli/args.py:609 lib/cli/args.py:618 lib/cli/args.py:626 -#: lib/cli/args.py:633 lib/cli/args.py:866 lib/cli/args.py:877 -#: lib/cli/args.py:885 lib/cli/args.py:904 lib/cli/args.py:910 -msgid "settings" -msgstr "설정" - -#: lib/cli/args.py:610 -msgid "" -"Don't run extraction in parallel. Will run each part of the extraction " -"process separately (one after the other) rather than all at the same time. " -"Useful if VRAM is at a premium." -msgstr "" -"추출을 병렬로 실행하지 마십시오. 추출 프로세스의 각 부분을 동시에 모두 실행하" -"는 것이 아니라 개별적으로(하나씩) 실행합니다. VRAM이 프리미엄인 경우 유용합니" -"다." - -#: lib/cli/args.py:619 -msgid "" -"Skips frames that have already been extracted and exist in the alignments " -"file" -msgstr "이미 추출되었거나 alignments 파일에 존재하는 프레임들을 스킵합니다" - -#: lib/cli/args.py:627 -msgid "Skip frames that already have detected faces in the alignments file" -msgstr "이미 얼굴을 탐지하여 alignments 파일에 존재하는 프레임들을 스킵합니다" - -#: lib/cli/args.py:634 -msgid "Skip saving the detected faces to disk. Just create an alignments file" -msgstr "" -"탐지된 얼굴을 디스크에 저장하지 않습니다. 그저 alignments 파일을 만듭니다" - -#: lib/cli/args.py:656 -msgid "" -"Swap the original faces in a source video/images to your final faces.\n" -"Conversion plugins can be configured in the 'Settings' Menu" -msgstr "" -"원본 비디오/이미지의 원래 얼굴을 최종 얼굴으로 바꿉니다.\n" -"변환 플러그인은 '설정' 메뉴에서 구성할 수 있습니다" - -#: lib/cli/args.py:677 -msgid "" -"Only required if converting from images to video. Provide The original video " -"that the source frames were extracted from (for extracting the fps and " -"audio)." -msgstr "" -"이미지에서 비디오로 변환하는 경우에만 필요합니다. 소스 프레임이 추출된 원본 " -"비디오(fps 및 오디오 추출용)를 입력하세요." - -#: lib/cli/args.py:686 -msgid "" -"Model directory. The directory containing the trained model you wish to use " -"for conversion." -msgstr "" -"모델 폴더. 당신이 변환에 사용하고자 하는 훈련된 모델을 가진 폴더입니다." - -#: lib/cli/args.py:696 -msgid "" -"R|Performs color adjustment to the swapped face. Some of these options have " -"configurable settings in '/config/convert.ini' or 'Settings > Configure " -"Convert Plugins':\n" -"L|avg-color: Adjust the mean of each color channel in the swapped " -"reconstruction to equal the mean of the masked area in the original image.\n" -"L|color-transfer: Transfers the color distribution from the source to the " -"target image using the mean and standard deviations of the L*a*b* color " -"space.\n" -"L|manual-balance: Manually adjust the balance of the image in a variety of " -"color spaces. Best used with the Preview tool to set correct values.\n" -"L|match-hist: Adjust the histogram of each color channel in the swapped " -"reconstruction to equal the histogram of the masked area in the original " -"image.\n" -"L|seamless-clone: Use cv2's seamless clone function to remove extreme " -"gradients at the mask seam by smoothing colors. Generally does not give very " -"satisfactory results.\n" -"L|none: Don't perform color adjustment." -msgstr "" -"R|스왑된 얼굴의 색상 조정을 수행합니다. 이러한 옵션 중 일부에는 '/config/" -"convert.ini' 또는 '설정 > 변환 플러그인 구성'에서 구성 가능한 설정이 있습니" -"다.\n" -"L|avg-color: 스왑된 재구성에서 각 색상 채널의 평균이 원본 영상에서 마스킹된 " -"영역의 평균과 동일하도록 조정합니다.\n" -"L|color-transfer: L*a*b* 색 공간의 평균 및 표준 편차를 사용하여 소스에서 대" -"상 이미지로 색 분포를 전송합니다.\n" -"L|manual-balance: 다양한 색 공간에서 이미지의 밸런스를 수동으로 조정합니다. " -"올바른 값을 설정하려면 미리 보기 도구와 함께 사용하는 것이 좋습니다.\n" -"L|match-hist: 스왑된 재구성에서 각 색상 채널의 히스토그램을 조정하여 원래 영" -"상에서 마스킹된 영역의 히스토그램과 동일하게 만듭니다.\n" -"L|seamless-clone: cv2의 원활한 복제 기능을 사용하여 색상을 평활화하여 마스크 " -"심에서 극단적인 gradients을 제거합니다. 일반적으로 매우 만족스러운 결과를 제" -"공하지 않습니다.\n" -"L|none: 색상 조정을 수행하지 않습니다." - -#: lib/cli/args.py:723 -msgid "" -"R|Masker to use. NB: The mask you require must exist within the alignments " -"file. You can add additional masks with the Mask Tool.\n" -"L|none: Don't use a mask.\n" -"L|bisenet-fp_face: Relatively lightweight NN based mask that provides more " -"refined control over the area to be masked (configurable in mask settings). " -"Use this version of bisenet-fp if your model is trained with 'face' or " -"'legacy' centering.\n" -"L|bisenet-fp_head: Relatively lightweight NN based mask that provides more " -"refined control over the area to be masked (configurable in mask settings). " -"Use this version of bisenet-fp if your model is trained with 'head' " -"centering.\n" -"L|custom_face: Custom user created, face centered mask.\n" -"L|custom_head: Custom user created, head centered mask.\n" -"L|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.\n" -"L|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.\n" -"L|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.\n" -"L|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.\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.\n" -"L|predicted: If the 'Learn Mask' option was enabled during training, this " -"will use the mask that was created by the trained model." -msgstr "" -"R|사용할 마스크. NB: 필요한 마스크는 alignments 파일 내에 있어야 합니다. 마스" -"크 도구를 사용하여 마스크를 추가할 수 있습니다.\n" -"L|none: 마스크 쓰지 마세요.\n" -"L|bisnet-fp_face: 마스크할 영역을 보다 정교하게 제어할 수 있는 비교적 가벼운 " -"NN 기반 마스크입니다(마스크 설정에서 구성 가능). 모델이 '얼굴' 또는 '레거시' " -"중심으로 훈련된 경우 이 버전의 bisnet-fp를 사용하십시오.\n" -"L|bisnet-fp_head: 마스크할 영역을 보다 정교하게 제어할 수 있는 비교적 가벼운 " -"NN 기반 마스크입니다(마스크 설정에서 구성 가능). 모델이 '헤드' 중심으로 훈련" -"된 경우 이 버전의 bisnet-fp를 사용하십시오.\n" -"L|custom_face: 사용자 지정 사용자가 생성한 얼굴 중심 마스크입니다.\n" -"L|custom_head: 사용자 지정 사용자가 생성한 머리 중심 마스크입니다.\n" -"L|components: 특징점 위치의 배치를 기반으로 얼굴 분할을 제공하도록 설계된 마" -"스크입니다. 특징점의 외부에는 마스크를 만들기 위해 convex hull가 형성되어 있" -"습니다.\n" -"L|extended: 특징점 위치의 배치를 기반으로 얼굴 분할을 제공하도록 설계된 마스" -"크입니다. 지형지물의 외부에는 convex hull가 형성되어 있으며, 마스크는 이마 위" -"로 뻗어 있습니다.\n" -"L|vgg-clear: 대부분의 정면에 장애물이 없는 스마트한 분할을 제공하도록 설계된 " -"마스크입니다. 옆 얼굴 및 장애물로 인해 성능이 저하될 수 있습니다.\n" -"L|vgg-obstructed: 대부분의 정면 얼굴을 스마트하게 분할할 수 있도록 설계된 마" -"스크입니다. 마스크 모델은 일부 안면 장애물(손과 안경)을 인식하도록 특별히 훈" -"련되었습니다. 옆 얼굴은 평균 이하의 성능을 초래할 수 있습니다.\n" -"L|unet-dfl: 대부분 정면 얼굴을 스마트하게 분할하도록 설계된 마스크. 마스크 모" -"델은 커뮤니티 구성원들에 의해 훈련되었으며 추가 설명을 위해 테스트가 필요하" -"다. 옆 얼굴은 평균 이하의 성능을 초래할 수 있습니다.\n" -"L|predicted: 교육 중에 'Learn Mask(마스크 학습)' 옵션이 활성화된 경우에는 교" -"육을 받은 모델이 만든 마스크가 사용됩니다." - -#: lib/cli/args.py:761 -msgid "" -"R|The plugin to use to output the converted images. The writers are " -"configurable in '/config/convert.ini' or 'Settings > Configure Convert " -"Plugins:'\n" -"L|ffmpeg: [video] Writes out the convert straight to video. When the input " -"is a series of images then the '-ref' (--reference-video) parameter must be " -"set.\n" -"L|gif: [animated image] Create an animated gif.\n" -"L|opencv: [images] The fastest image writer, but less options and formats " -"than other plugins.\n" -"L|patch: [images] Outputs the raw swapped face patch, along with the " -"transformation matrix required to re-insert the face back into the original " -"frame. Use this option if you wish to post-process and composite the final " -"face within external tools.\n" -"L|pillow: [images] Slower than opencv, but has more options and supports " -"more formats." -msgstr "" -"R|변환된 이미지를 출력하는 데 사용할 플러그인입니다. 기록 장치는 '/config/" -"convert.ini' 또는 '설정 > 변환 플러그인 구성:'에서 구성할 수 있습니다.\n" -"L|ffmpeg: [video] 변환된 결과를 바로 video로 씁니다. 입력이 영상 시리즈인 경" -"우 '-ref'(--reference-video) 파라미터를 설정해야 합니다.\n" -"L|gif : [애니메이션 이미지] 애니메이션 gif를 만듭니다.\n" -"L|opencv: [이미지] 가장 빠른 이미지 작성기이지만 다른 플러그인에 비해 옵션과 " -"형식이 적습니다.\n" -"L|patch: [이미지] 원래 프레임에 얼굴을 다시 삽입하는 데 필요한 변환 행렬과 함" -"께 원시 교체된 얼굴 패치를 출력합니다.\n" -"L|pillow: [images] opencv보다 느리지만 더 많은 옵션이 있고 더 많은 형식을 지" -"원합니다." - -#: lib/cli/args.py:784 lib/cli/args.py:791 lib/cli/args.py:896 -msgid "Frame Processing" -msgstr "프레임 처리" - -#: lib/cli/args.py:785 -#, python-format -msgid "" -"Scale the final output frames by this amount. 100%% will output the frames " -"at source dimensions. 50%% at half size 200%% at double size" -msgstr "" -"최종 출력 프레임의 크기를 이 양만큼 조정합니다. 100%%는 원본의 차원에서 프레" -"임을 출력합니다. 50%%는 절반 크기에서, 200%%는 두 배 크기에서" - -#: lib/cli/args.py:792 -msgid "" -"Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " -"--frame-ranges 10-50 90-100. Frames falling outside of the selected range " -"will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " -"converting from images, then the filenames must end with the frame-number!" -msgstr "" -"예를 들어 전송을 적용할 프레임 범위 프레임 10 - 50 및 90 - 100의 경우 --" -"frame-ranges 10-50 90-100을 사용합니다. '-k'(--keep-unchanged)를 선택하지 않" -"으면 선택한 범위를 벗어나는 프레임이 삭제됩니다. NB: 이미지에서 변환하는 경" -"우 파일 이름은 프레임 번호로 끝나야 합니다!" - -#: lib/cli/args.py:805 -msgid "" -"Scale the swapped face by this percentage. Positive values will enlarge the " -"face, Negative values will shrink the face." -msgstr "" -"이 백분율로 교체된 면의 크기를 조정합니다. 양수 값은 얼굴을 확대하고, 음수 값" -"은 얼굴을 축소합니다." - -#: lib/cli/args.py:813 -msgid "" -"If you have not cleansed your alignments file, then you can filter out faces " -"by defining a folder here that contains the faces extracted from your input " -"files/video. If this folder is defined, then only faces that exist within " -"your alignments file and also exist within the specified folder will be " -"converted. Leaving this blank will convert all faces that exist within the " -"alignments file." -msgstr "" -"만약 alignments 파일을 지우지 않은 경우 입력 파일/비디오에서 추출된 얼굴이 포" -"함된 폴더를 정의하여 얼굴을 걸러낼 수 있습니다. 이 폴더가 정의된 경우 " -"alignments 파일 내에 존재하거나 지정된 폴더 내에 존재하는 얼굴만 변환됩니다. " -"이 항목을 공백으로 두면 alignments 파일 내에 있는 모든 얼굴이 변환됩니다." - -#: lib/cli/args.py:827 -msgid "" -"Optionally filter out people who you do not wish to process by passing in an " -"image of that person. Should be a front portrait with a single person in the " -"image. Multiple images can be added space separated. NB: Using face filter " -"will significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." -msgstr "" -"선택적으로 처리하고 싶지 않은 사람의 이미지를 전달하여 그 사람을 걸러낼 수 있" -"습니다. 이미지는 한 사람의 정면 모습이여야 합니다. 여러 이미지를 공백으로 구" -"분하여 추가할 수 있습니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감소" -"하므로 정확성을 보장할 수 없습니다." - -#: lib/cli/args.py:840 -msgid "" -"Optionally select people you wish to process by passing in an image of that " -"person. Should be a front portrait with a single person in the image. " -"Multiple images can be added space separated. NB: Using face filter will " -"significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." -msgstr "" -"선택적으로 해당 사용자의 이미지를 전달하여 처리할 사용자를 선택합니다. 이미지" -"에 한 사람이 있는 정면 초상화여야 합니다. 여러 이미지를 공백으로 구분하여 추" -"가할 수 있습니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감소하므로 정" -"확성을 보장할 수 없습니다." - -#: lib/cli/args.py:854 -msgid "" -"For use with the optional nfilter/filter files. Threshold for positive face " -"recognition. Lower values are stricter. NB: Using face filter will " -"significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." -msgstr "" -"옵션인 nfilter/filter 파일을 함께 사용합니다. 긍정적인 얼굴 인식을 위한 임계" -"값. 낮은 값이 더 엄격합니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감" -"소하므로 정확성을 보장할 수 없습니다." - -#: lib/cli/args.py:867 -msgid "" -"The maximum number of parallel processes for performing conversion. " -"Converting images is system RAM heavy so it is possible to run out of memory " -"if you have a lot of processes and not enough RAM to accommodate them all. " -"Setting this to 0 will use the maximum available. No matter what you set " -"this to, it will never attempt to use more processes than are available on " -"your system. If singleprocess is enabled this setting will be ignored." -msgstr "" -"변환을 수행하기 위한 최대 병렬 프로세스 수입니다. 이미지 변환은 시스템 RAM에 " -"부담이 크기 때문에 프로세스가 많고 모든 프로세스를 수용할 RAM이 충분하지 않" -"은 경우 메모리가 부족할 수 있습니다. 이것을 0으로 설정하면 사용 가능한 최대값" -"을 사용합니다. 얼마를 설정하든 시스템에서 사용 가능한 것보다 더 많은 프로세스" -"를 사용하려고 시도하지 않습니다. 단일 프로세스가 활성화된 경우 이 설정은 무시" -"됩니다." - -#: lib/cli/args.py:878 -msgid "" -"[LEGACY] This only needs to be selected if a legacy model is being loaded or " -"if there are multiple models in the model folder" -msgstr "" -"[LEGACY] 이것은 레거시 모델을 로드 중이거나 모델 폴더에 여러 모델이 있는 경우" -"에만 선택되어야 합니다" - -#: lib/cli/args.py:886 -msgid "" -"Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " -"alignments file for your destination video. However, if you wish you can " -"generate the alignments on-the-fly by enabling this option. This will use an " -"inferior extraction pipeline and will lead to substandard results. If an " -"alignments file is found, this option will be ignored." -msgstr "" -"실시간 변환을 활성화합니다. 권장하지 않습니다. 당신은 변환 비디오에 대한 깨끗" -"한 alignments 파일을 생성해야 합니다. 그러나 원하는 경우 이 옵션을 활성화하" -"여 즉시 alignments 파일을 생성할 수 있습니다. 이것은 안좋은 추출 과정을 사용" -"하고 표준 이하의 결과로 이어질 것입니다. alignments 파일이 발견되면 이 옵션" -"은 무시됩니다." - -#: lib/cli/args.py:897 -msgid "" -"When used with --frame-ranges outputs the unchanged frames that are not " -"processed instead of discarding them." -msgstr "" -"사용시 --frame-ranges 인자를 사용하면 변경되지 않은 프레임을 버리지 않은 결과" -"가 출력됩니다." - -#: lib/cli/args.py:905 -msgid "Swap the model. Instead converting from of A -> B, converts B -> A" -msgstr "모델을 바꿉니다. A -> B에서 변환하는 대신 B -> A로 변환" - -#: lib/cli/args.py:911 -msgid "Disable multiprocessing. Slower but less resource intensive." -msgstr "멀티프로세싱을 쓰지 않습니다. 느리지만 자원을 덜 소모합니다." - -#: lib/cli/args.py:927 -msgid "" -"Train a model on extracted original (A) and swap (B) faces.\n" -"Training models can take a long time. Anything from 24hrs to over a week\n" -"Model plugins can be configured in the 'Settings' Menu" -msgstr "" -"추출된 원래(A) 얼굴과 스왑(B) 얼굴에 대한 모델을 훈련합니다.\n" -"모델을 훈련하는 데 시간이 오래 걸릴 수 있습니다. 24시간에서 일주일 이상의 시" -"간이 필요합니다.\n" -"모델 플러그인은 '설정' 메뉴에서 구성할 수 있습니다" - -#: lib/cli/args.py:946 lib/cli/args.py:955 -msgid "faces" -msgstr "얼굴들" - -#: lib/cli/args.py:947 -msgid "" -"Input directory. A directory containing training images for face A. This is " -"the original face, i.e. the face that you want to remove and replace with " -"face B." -msgstr "" -"입력 디렉토리. 얼굴 A에 대한 훈련 이미지가 포함된 디렉토리입니다. 이것은 원" -"래 얼굴, 즉 제거하고 B 얼굴로 대체하려는 얼굴입니다." - -#: lib/cli/args.py:956 -msgid "" -"Input directory. A directory containing training images for face B. This is " -"the swap face, i.e. the face that you want to place onto the head of person " -"A." -msgstr "" -"입력 디렉터리. 얼굴 B에 대한 훈련 이미지를 포함하는 디렉토리. 이것은 대체 얼" -"굴, 즉 사람 A의 얼굴 앞에 배치하려는 얼굴이다." - -#: lib/cli/args.py:964 lib/cli/args.py:976 lib/cli/args.py:992 -#: lib/cli/args.py:1017 lib/cli/args.py:1027 -msgid "model" -msgstr "모델" - -#: lib/cli/args.py:965 -msgid "" -"Model directory. This is where the training data will be stored. You should " -"always specify a new folder for new models. If starting a new model, select " -"either an empty folder, or a folder which does not exist (which will be " -"created). If continuing to train an existing model, specify the location of " -"the existing model." -msgstr "" -"모델 디렉토리. 여기에 훈련 데이터가 저장됩니다. 새 모델의 경우 항상 새 폴더" -"를 지정해야 합니다. 새 모델을 시작할 경우 빈 폴더 또는 존재하지 않는 폴더(생" -"성될 폴더)를 선택합니다. 기존 모델을 계속 학습하는 경우 기존 모델의 위치를 지" -"정합니다." - -#: lib/cli/args.py:977 -msgid "" -"R|Load the weights from a pre-existing model into a newly created model. For " -"most models this will load weights from the Encoder of the given model into " -"the encoder of the newly created model. Some plugins may have specific " -"configuration options allowing you to load weights from other layers. " -"Weights will only be loaded when creating a new model. This option will be " -"ignored if you are resuming an existing model. Generally you will also want " -"to 'freeze-weights' whilst the rest of your model catches up with your " -"Encoder.\n" -"NB: Weights can only be loaded from models of the same plugin as you intend " -"to train." -msgstr "" -"R|기존 모델의 가중치를 새로 생성된 모델로 로드합니다. 대부분의 모델에서는 주" -"어진 모델의 인코더에서 새로 생성된 모델의 인코더로 가중치를 로드합니다. 일부 " -"플러그인에는 다른 층에서 가중치를 로드할 수 있는 특정 구성 옵션이 있을 수 있" -"습니다. 가중치는 새 모델을 생성할 때만 로드됩니다. 기존 모델을 재개하는 경우 " -"이 옵션은 무시됩니다. 일반적으로 나머지 모델이 인코더를 따라잡는 동안에도 '가" -"중치 동결'이 필요합니다.\n" -"주의: 가중치는 훈련하려는 플러그인 모델에서만 로드할 수 있습니다." - -#: lib/cli/args.py:993 -msgid "" -"R|Select which trainer to use. Trainers can be configured from the Settings " -"menu or the config folder.\n" -"L|original: The original model created by /u/deepfakes.\n" -"L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " -"for full dfaker method.\n" -"L|dfl-h128: 128px in/out model from deepfacelab\n" -"L|dfl-sae: Adaptable model from deepfacelab\n" -"L|dlight: A lightweight, high resolution DFaker variant.\n" -"L|iae: A model that uses intermediate layers to try to get better details\n" -"L|lightweight: A lightweight model for low-end cards. Don't expect great " -"results. Can train as low as 1.6GB with batch size 8.\n" -"L|realface: A high detail, dual density model based on DFaker, with " -"customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " -"won't work so well. By andenixa et al. Very configurable.\n" -"L|unbalanced: 128px in/out model from andenixa. The autoencoders are " -"unbalanced so B>A swaps won't work so well. Very configurable.\n" -"L|villain: 128px in/out model from villainguy. Very resource hungry (You " -"will require a GPU with a fair amount of VRAM). Good for details, but more " -"susceptible to color differences." -msgstr "" -"R|사용할 훈련 모델을 선택합니다. 훈련 모델은 설정 메뉴 또는 구성 폴더에서 구" -"성할 수 있습니다.\n" -"L|original: /u/deepfakes로 만든 원래 모델입니다.\n" -"L|dfaker: 64px in/128px out 모델 from dfaker. Full dfaker 메서드에 대해 '특징" -"점으로 변환'를 활성화합니다.\n" -"L|dfl-h128: Deepfake lab의 128px in/out 모델\n" -"L|dfl-sae: Deepface Lab의 적응형 모델\n" -"L|dlight: 경량, 고해상도 DFaker 변형입니다.\n" -"L|iae: 중간 층들을 사용하여 더 나은 세부 정보를 얻기 위해 노력하는 모델.\n" -"L|lightweight: 저가형 카드용 경량 모델. 좋은 결과를 기대하지 마세요. 최대한 " -"낮게 잡아서 배치 사이즈 8에 1.6GB까지 훈련이 가능합니다.\n" -"L|realface: DFaker를 기반으로 한 높은 디테일의 이중 밀도 모델로, 사용자 정의 " -"가능한 입/출력 해상도를 제공합니다. 오토인코더가 불균형하여 B>A 스왑이 잘 작" -"동하지 않습니다. Andenixa 등에 의해. 매우 구성 가능합니다.\n" -"L|unbalanced: andenixa의 128px in/out 모델. 오토인코더가 불균형하여 B>A 스왑" -"이 잘 작동하지 않습니다. 매우 구성 가능합니다.\n" -"L|villain : villainguy의 128px in/out 모델. 리소스가 매우 부족합니다( 상당한 " -"양의 VRAM이 있는 GPU가 필요합니다). 세부 사항에는 좋지만 색상 차이에 더 취약" -"합니다." - -#: lib/cli/args.py:1018 -msgid "" -"Output a summary of the model and exit. If a model folder is provided then a " -"summary of the saved model is displayed. Otherwise a summary of the model " -"that would be created by the chosen plugin and configuration settings is " -"displayed." -msgstr "" -"모델 요약을 출력하고 종료합니다. 모델 폴더가 제공되면 저장된 모델의 요약이 표" -"시됩니다. 그렇지 않으면 선택한 플러그인 및 구성 설정에 의해 생성되는 모델 요" -"약이 표시됩니다." - -#: lib/cli/args.py:1028 -msgid "" -"Freeze the weights of the model. Freezing weights means that some of the " -"parameters in the model will no longer continue to learn, but those that are " -"not frozen will continue to learn. For most models, this will freeze the " -"encoder, but some models may have configuration options for freezing other " -"layers." -msgstr "" -"모델의 가중치를 동결합니다. 가중치를 고정하면 모델의 일부 매개변수가 더 이상 " -"학습되지 않지만 고정되지 않은 매개변수는 계속 학습됩니다. 대부분의 모델에서 " -"이렇게 하면 인코더가 고정되지만 일부 모델에는 다른 레이어를 고정하기 위한 구" -"성 옵션이 있을 수 있습니다." - -#: lib/cli/args.py:1041 lib/cli/args.py:1053 lib/cli/args.py:1067 -#: lib/cli/args.py:1082 lib/cli/args.py:1090 -msgid "training" -msgstr "훈련" - -#: lib/cli/args.py:1042 -msgid "" -"Batch size. This is the number of images processed through the model for " -"each side per iteration. NB: As the model is fed 2 sides at a time, the " -"actual number of images within the model at any one time is double the " -"number that you set here. Larger batches require more GPU RAM." -msgstr "" -"배치 크기. 반복당 각 측면에 대해 모델을 통해 처리되는 이미지 수입니다. NB: " -"한 번에 모델에게 2개의 측면이 공급되므로 한 번에 모델 내의 실제 이미지 수는 " -"여기에서 설정한 수의 두 배입니다. 더 큰 배치에는 더 많은 GPU RAM이 필요합니" -"다." - -#: lib/cli/args.py:1054 -msgid "" -"Length of training in iterations. This is only really used for automation. " -"There is no 'correct' number of iterations a model should be trained for. " -"You should stop training when you are happy with the previews. However, if " -"you want the model to stop automatically at a set number of iterations, you " -"can set that value here." -msgstr "" -"반복에서 훈련 길이. 이것은 실제로 자동화에만 사용됩니다. 모델을 훈련해야 하" -"는 '올바른' 반복 횟수는 없습니다. 미리 보기에 만족하면 훈련을 중단해야 합니" -"다. 그러나 설정된 반복 횟수에서 모델이 자동으로 중지되도록 하려면 여기에서 해" -"당 값을 설정할 수 있습니다." - -#: lib/cli/args.py:1068 -msgid "" -"R|Select the distribution stategy to use.\n" -"L|default: Use Tensorflow's default distribution strategy.\n" -"L|central-storage: Centralizes variables on the CPU whilst operations are " -"performed on 1 or more local GPUs. This can help save some VRAM at the cost " -"of some speed by not storing variables on the GPU. Note: Mixed-Precision is " -"not supported on multi-GPU setups.\n" -"L|mirrored: Supports synchronous distributed training across multiple local " -"GPUs. A copy of the model and all variables are loaded onto each GPU with " -"batches distributed to each GPU at each iteration." -msgstr "" -"R|사용할 배포 상태를 선택합니다.\n" -"L|default: Tensorflow의 기본 배포 전략을 사용합니다.\n" -"L|central-storage: 작업이 1개 이상의 로컬 GPU에서 수행되는 동안 CPU의 변수를 " -"중앙 집중화합니다. 이렇게 하면 GPU에 변수를 저장하지 않음으로써 약간의 속도" -"를 희생하여 일부 VRAM을 절약할 수 있습니다. 참고: 다중 정밀도는 다중 GPU 설정" -"에서 지원되지 않습니다.\n" -"L|mirrored: 여러 로컬 GPU에서 동기화 분산 훈련을 지원합니다. 모델의 복사본과 " -"모든 변수는 각 반복에서 각 GPU에 배포된 배치들와 함께 각 GPU에 로드됩니다." - -#: lib/cli/args.py:1083 -msgid "" -"Disables TensorBoard logging. NB: Disabling logs means that you will not be " -"able to use the graph or analysis for this session in the GUI." -msgstr "" -"텐서보드 로깅을 비활성화합니다. 주의: 로그를 비활성화하면 GUI에서 이 세션에 " -"대한 그래프 또는 분석을 사용할 수 없습니다." - -#: lib/cli/args.py:1091 -msgid "" -"Use the Learning Rate Finder to discover the optimal learning rate for " -"training. For new models, this will calculate the optimal learning rate for " -"the model. For existing models this will use the optimal learning rate that " -"was discovered when initializing the model. Setting this option will ignore " -"the manually configured learning rate (configurable in train settings)." -msgstr "" -"학습률 찾기를 사용하여 훈련을 위한 최적의 학습률을 찾아보세요. 새 모델의 경" -"우 모델에 대한 최적의 학습률을 계산합니다. 기존 모델의 경우 모델을 초기화할 " -"때 발견된 최적의 학습률을 사용합니다. 이 옵션을 설정하면 수동으로 구성된 학습" -"률(기차 설정에서 구성 가능)이 무시됩니다." - -#: lib/cli/args.py:1104 lib/cli/args.py:1114 -msgid "Saving" -msgstr "저장" - -#: lib/cli/args.py:1105 -msgid "Sets the number of iterations between each model save." -msgstr "각 모델 저장 사이의 반복 횟수를 설정합니다." - -#: lib/cli/args.py:1115 -msgid "" -"Sets the number of iterations before saving a backup snapshot of the model " -"in it's current state. Set to 0 for off." -msgstr "" -"현재 상태에서 모델의 백업 스냅샷을 저장하기 전에 반복할 횟수를 설정합니다. 0" -"으로 설정하면 꺼집니다." - -#: lib/cli/args.py:1122 lib/cli/args.py:1133 lib/cli/args.py:1144 -msgid "timelapse" -msgstr "타임랩스" - -#: lib/cli/args.py:1123 -msgid "" -"Optional for creating a timelapse. Timelapse will save an image of your " -"selected faces into the timelapse-output folder at every save iteration. " -"This should be the input folder of 'A' faces that you would like to use for " -"creating the timelapse. You must also supply a --timelapse-output and a --" -"timelapse-input-B parameter." -msgstr "" -"타임랩스를 만드는 옵션입니다. Timelapse(시간 경과)는 저장을 반복할 때마다 선" -"택한 얼굴의 이미지를 Timelapse-output(시간 경과 출력) 폴더에 저장합니다. 타임" -"랩스를 만드는 데 사용할 'A' 얼굴의 입력 폴더여야 합니다. 또한 사용자는 --" -"timelapse-output 및 --timelapse-input-B 매개 변수를 제공해야 합니다." - -#: lib/cli/args.py:1134 -msgid "" -"Optional for creating a timelapse. Timelapse will save an image of your " -"selected faces into the timelapse-output folder at every save iteration. " -"This should be the input folder of 'B' faces that you would like to use for " -"creating the timelapse. You must also supply a --timelapse-output and a --" -"timelapse-input-A parameter." -msgstr "" -"타임 랩스를 만드는 데 선택적입니다. Timelapse(시간 경과)는 저장을 반복할 때마" -"다 선택한 얼굴의 이미지를 Timelapse-output(시간 경과 출력) 폴더에 저장합니" -"다. 타임 랩스를 만드는 데 사용할 'B' 얼굴의 입력 폴더여야 합니다. 또한 사용자" -"는 --timelapse-output 및 --timelapse-input-A 매개 변수를 제공해야 합니다." - -#: lib/cli/args.py:1145 -msgid "" -"Optional for creating a timelapse. Timelapse will save an image of your " -"selected faces into the timelapse-output folder at every save iteration. If " -"the input folders are supplied but no output folder, it will default to your " -"model folder /timelapse/" -msgstr "" -"타임랩스를 만드는 데 선택적입니다. Timelapse(시간 경과)는 저장을 반복할 때마" -"다 선택한 얼굴의 이미지를 Timelapse-output(시간 경과 출력) 폴더에 저장합니" -"다. 입력 폴더가 제공되었지만 출력 폴더가 없는 경우 모델 폴더에 /timelapse/로 " -"기본 설정됩니다" - -#: lib/cli/args.py:1154 lib/cli/args.py:1161 -msgid "preview" -msgstr "미리보기" - -#: lib/cli/args.py:1155 -msgid "Show training preview output. in a separate window." -msgstr "훈련 미리보기 결과를 각기 다른 창에서 보여줍니다." - -#: lib/cli/args.py:1162 -msgid "" -"Writes the training result to a file. The image will be stored in the root " -"of your FaceSwap folder." -msgstr "" -"훈련 결과를 파일에 씁니다. 이미지는 Faceswap 폴더의 최상위 폴더에 저장됩니다." - -#: lib/cli/args.py:1169 lib/cli/args.py:1178 lib/cli/args.py:1187 -#: lib/cli/args.py:1196 -msgid "augmentation" -msgstr "보정" - -#: lib/cli/args.py:1170 -msgid "" -"Warps training faces to closely matched Landmarks from the opposite face-set " -"rather than randomly warping the face. This is the 'dfaker' way of doing " -"warping." -msgstr "" -"무작위로 얼굴을 변환하지 않고 반대쪽 얼굴 세트에서 특징점과 밀접하게 일치하도" -"록 훈련 얼굴을 변환해줍니다. 이것은 변환하는 'dfaker' 방식이다." - -#: lib/cli/args.py:1179 -msgid "" -"To effectively learn, a random set of images are flipped horizontally. " -"Sometimes it is desirable for this not to occur. Generally this should be " -"left off except for during 'fit training'." -msgstr "" -"효과적으로 학습하기 위해 임의의 이미지 세트를 수평으로 뒤집습니다. 때때로 이" -"런 일이 일어나지 않는 것이 바람직합니다. 일반적으로 'fit training' 중을 제외" -"하고는 이 작업을 중단해야 합니다." - -#: lib/cli/args.py:1188 -msgid "" -"Color augmentation helps make the model less susceptible to color " -"differences between the A and B sets, at an increased training time cost. " -"Enable this option to disable color augmentation." -msgstr "" -"색상 보정은 모델이 A와 B 세트 사이의 색상 차이에 덜 민감하게 만드는 데 도움" -"이 되며, 훈련 시간 비용이 증가합니다. 색상 보저를 사용하지 않으려면 이 옵션" -"을 사용합니다." - -#: lib/cli/args.py:1197 -msgid "" -"Warping is integral to training the Neural Network. This option should only " -"be enabled towards the very end of training to try to bring out more detail. " -"Think of it as 'fine-tuning'. Enabling this option from the beginning is " -"likely to kill a model and lead to terrible results." -msgstr "" -"변환은 신경망을 훈련하는 데 필수적입니다. 이 옵션은 보다 세부적인 것들을 뽑아" -"내위하여 훈련 막바지까지 활성화하여야 합니다. 이것은 '미세 조정'이라고 생각하" -"면 됩니다. 처음부터 이 옵션을 활성화하면 모델이 죽을 수있고 끔찍한 결과를 초" -"래할 수 있습니다." - -#: lib/cli/args.py:1222 +#: lib/cli/args.py:319 msgid "Output to Shell console instead of GUI console" msgstr "결과를 GUI 콘솔이 아닌 쉘 콘솔에 출력합니다" - -#~ msgid "" -#~ "[Deprecated - Use '-D, --distribution-strategy' instead] Use the " -#~ "Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." -#~ msgstr "" -#~ "[Deprecated - 대신 '-D, --distribution-strategy' 사용] Tensorflow 미러 분" -#~ "산 전략을 사용하여 여러 GPU에서 훈련합니다." diff --git a/locales/kr/LC_MESSAGES/lib.cli.args_extract_convert.mo b/locales/kr/LC_MESSAGES/lib.cli.args_extract_convert.mo new file mode 100644 index 0000000000000000000000000000000000000000..93c9b7658acf51f3b443805757fb09f187f8b5cb GIT binary patch literal 31900 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, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-28 18:11+0000\n" +"PO-Revision-Date: 2024-03-28 18:16+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ko_KR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Poedit 3.4.2\n" + +#: lib/cli/args_extract_convert.py:46 lib/cli/args_extract_convert.py:56 +#: lib/cli/args_extract_convert.py:64 lib/cli/args_extract_convert.py:122 +#: lib/cli/args_extract_convert.py:479 lib/cli/args_extract_convert.py:488 +msgid "Data" +msgstr "데이터" + +#: lib/cli/args_extract_convert.py:48 +msgid "" +"Input directory or video. Either a directory containing the image files you " +"wish to process or path to a video file. NB: This should be the source video/" +"frames NOT the source faces." +msgstr "" +"폴더나 비디오를 입력하세요. 당신이 사용하고 싶은 이미지 파일들을 가진 폴더 또" +"는 비디오 파일의 경로여야 합니다. NB: 이 폴더는 원본 비디오여야 합니다." + +#: lib/cli/args_extract_convert.py:57 +msgid "Output directory. This is where the converted files will be saved." +msgstr "출력 폴더. 변환된 파일들이 저장될 곳입니다." + +#: lib/cli/args_extract_convert.py:66 +msgid "" +"Optional path to an alignments file. Leave blank if the alignments file is " +"at the default location." +msgstr "" +"(선택적) alignments 파일의 경로. 비워두면 alignments 파일이 기본 위치에 저장" +"됩니다." + +#: lib/cli/args_extract_convert.py:97 +msgid "" +"Extract faces from image or video sources.\n" +"Extraction plugins can be configured in the 'Settings' Menu" +msgstr "" +"얼굴들을 이미지 또는 비디오에서 추출합니다.\n" +"추출 플러그인은 '설정' 메뉴에서 설정할 수 있습니다" + +#: lib/cli/args_extract_convert.py:124 +msgid "" +"R|If selected then the input_dir should be a parent folder containing " +"multiple videos and/or folders of images you wish to extract from. The faces " +"will be output to separate sub-folders in the output_dir." +msgstr "" +"R|만약 선택된다면 input_dir은 당신이 추출하고자 하는 여러개의 비디오 그리고/" +"또는 이미지들을 가진 부모 폴더가 되야 합니다. 얼굴들은 output_dir에 분리된 하" +"위 폴더에 저장됩니다." + +#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:150 +#: lib/cli/args_extract_convert.py:163 lib/cli/args_extract_convert.py:202 +#: lib/cli/args_extract_convert.py:220 lib/cli/args_extract_convert.py:233 +#: lib/cli/args_extract_convert.py:243 lib/cli/args_extract_convert.py:253 +#: lib/cli/args_extract_convert.py:499 lib/cli/args_extract_convert.py:525 +#: lib/cli/args_extract_convert.py:564 +msgid "Plugins" +msgstr "플러그인들" + +#: lib/cli/args_extract_convert.py:135 +msgid "" +"R|Detector to use. Some of these have configurable settings in '/config/" +"extract.ini' or 'Settings > Configure Extract 'Plugins':\n" +"L|cv2-dnn: A CPU only extractor which is the least reliable and least " +"resource intensive. Use this if not using a GPU and time is important.\n" +"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " +"than other GPU detectors but can often return more false positives.\n" +"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " +"fewer false positives than other GPU detectors, but is a lot more resource " +"intensive." +msgstr "" +"R|사용할 감지기. 몇몇 감지기들은 '/config/extract.ini' 또는 '설정 > 추출 플러" +"그인 설정'에서 설정이 가능합니다:\n" +"L|cv2-dnn: 가장 믿을 수 없고 가장 자원을 덜 사용하며 CPU만을 사용하는 추출기" +"입니다. 만약 GPU를 사용하지 않고 시간이 중요하다면 사용하세요.\n" +"L|mtcnn: 좋은 감지기. CPU에서도 빠르고 GPU에서도 빠릅니다. 다른 GPU 감지기들" +"보다 더 적은 자원을 사용하지만 가끔 더 많은 false positives를 돌려줄 수 있습" +"니다.\n" +"L|s3fd: 가장 좋은 감지기. CPU에선 느리고 GPU에선 빠릅니다. 다른 GPU 감지기들" +"보다 더 많은 얼굴들을 감지할 수 있고 과 더 적은 false positives를 돌려주지만 " +"자원을 굉장히 많이 사용합니다." + +#: lib/cli/args_extract_convert.py:152 +msgid "" +"R|Aligner to use.\n" +"L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " +"but less accurate. Only use this if not using a GPU and time is important.\n" +"L|fan: Best aligner. Fast on GPU, slow on CPU." +msgstr "" +"R|사용할 Aligner.\n" +"L|cv2-dnn: CPU만을 사용하는 특징점 감지기. 빠르고 자원을 덜 사용하지만 부정확" +"합니다. GPU를 사용하지 않고 시간이 중요할 때에만 사용하세요.\n" +"L|fan: 가장 좋은 aligner. GPU에선 빠르고 CPU에선 느립니다." + +#: lib/cli/args_extract_convert.py:165 +msgid "" +"R|Additional Masker(s) to use. The masks generated here will all take up GPU " +"RAM. You can select none, one or multiple masks, but the extraction may take " +"longer the more you select. NB: The Extended and Components (landmark based) " +"masks are automatically generated on extraction.\n" +"L|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).\n" +"L|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 so will not use any additional VRAM.\n" +"L|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.\n" +"L|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.\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.\n" +"The auto generated masks are as follows:\n" +"L|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.\n" +"L|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.\n" +"(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" +msgstr "" +"R|사용할 추가 Mask입니다. 여기서 생성된 마스크는 모두 GPU RAM을 차지합니다. " +"마스크를 0개, 1개 또는 여러 개 선택할 수 있지만 더 많이 선택할수록 추출에 시" +"간이 더 걸릴 수 있습니다. NB: 확장 및 구성 요소(특징점 기반) 마스크는 추출 " +"시 자동으로 생성됩니다.\n" +"L|bisnet-fp: 전체 헤드 마스킹(마스크 설정에서 구성 가능)을 포함하여 마스킹할 " +"영역에 대한 보다 정교한 제어를 제공하는 비교적 가벼운 NN 기반 마스크입니다.\n" +"L|custom: 마스크 영역을 모든 1 또는 0으로 채우는 dummy 마스크입니다(설정에서 " +"구성 가능). 수동 도구에서 사용자 정의 마스크를 직접 수동으로 편집하려는 경우" +"에만 필요합니다. 이 마스크는 GPU를 사용하지 않으므로 추가 VRAM을 사용하지 않" +"습니다.\n" +"L|vgg-clear: 대부분의 정면에 장애물이 없는 스마트한 분할을 제공하도록 설계된 " +"마스크입니다. 프로필 얼굴들 및 장애물들로 인해 성능이 저하될 수 있습니다.\n" +"L|vgg-obstructed: 대부분의 정면 얼굴을 스마트하게 분할할 수 있도록 설계된 마" +"스크입니다. 마스크 모델은 일부 안면 장애물(손과 안경)을 인식하도록 특별히 훈" +"련되었습니다. 프로필 얼굴들은 평균 이하의 성능을 초래할 수 있습니다.\n" +"L|unet-dfl: 대부분 정면 얼굴을 스마트하게 분할하도록 설계된 마스크. 마스크 모" +"델은 커뮤니티 구성원들에 의해 훈련되었으며 추가 설명을 위해 테스트가 필요하" +"다. 프로필 얼굴들은 평균 이하의 성능을 초래할 수 있습니다.\n" +"자동 생성 마스크는 다음과 같습니다.\n" +"L|components: 특징점 위치의 위치를 기반으로 얼굴 분할을 제공하도록 설계된 마" +"스크입니다. 특징점의 외부에는 마스크를 만들기 위해 convex hull가 형성되어 있" +"습니다.\n" +"L|extended: 특징점 위치의 위치를 기반으로 얼굴 분할을 제공하도록 설계된 마스" +"크입니다. 특징점의 외부에는 convex hull가 형성되어 있으며, 마스크는 이마 위" +"로 뻗어 있습ㄴ다.\n" +"(예: '-M unet-dfl vgg-clear', '--masker vgg-obstructed')" + +#: lib/cli/args_extract_convert.py:204 +msgid "" +"R|Performing normalization can help the aligner better align faces with " +"difficult lighting conditions at an extraction speed cost. Different methods " +"will yield different results on different sets. NB: This does not impact the " +"output face, just the input to the aligner.\n" +"L|none: Don't perform normalization on the face.\n" +"L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " +"face.\n" +"L|hist: Equalize the histograms on the RGB channels.\n" +"L|mean: Normalize the face colors to the mean." +msgstr "" +"R|정규화를 수행하면 aligner가 추출 속도 비용으로 어려운 조명 조건의 얼굴을 " +"더 잘 정렬할 수 있습니다. 방법이 다르면 세트마다 결과가 다릅니다. NB: 출력 얼" +"굴에는 영향을 주지 않으며 aligner에 대한 입력에만 영향을 줍니다.\n" +"L|none: 얼굴에 정규화를 수행하지 마십시오.\n" +"L|clahe: 얼굴에 Contrast Limited Adaptive Histogram Equalization를 수행합니" +"다.\n" +"L|hist: RGB 채널의 히스토그램을 동일하게 합니다.\n" +"L|mean: 얼굴 색상을 평균으로 정규화합니다." + +#: lib/cli/args_extract_convert.py:222 +msgid "" +"The number of times to re-feed the detected face into the aligner. Each time " +"the face is re-fed into the aligner the bounding box is adjusted by a small " +"amount. The final landmarks are then averaged from each iteration. Helps to " +"remove 'micro-jitter' but at the cost of slower extraction speed. The more " +"times the face is re-fed into the aligner, the less micro-jitter should " +"occur but the longer extraction will take." +msgstr "" +"검출된 얼굴을 aligner에 다시 공급하는 횟수입니다. 얼굴이 aligner에 다시 공급" +"될 때마다 경계 상자가 소량 조정됩니다. 그런 다음 각 반복에서 최종 특징점의 평" +"균을 구한다. 'micro-jitter'를 제거하는 데 도움이 되지만 추출 속도가 느려집니" +"다. 얼굴이 aligner에 다시 공급되는 횟수가 많을수록 micro-jitter 적게 발생하지" +"만 추출에 더 오랜 시간이 걸립니다." + +#: lib/cli/args_extract_convert.py:235 +msgid "" +"Re-feed the initially found aligned face through the aligner. Can help " +"produce better alignments for faces that are rotated beyond 45 degrees in " +"the frame or are at extreme angles. Slows down extraction." +msgstr "" +"_aligner를 통해 처음 발견된 정렬된 얼굴을 재공급합니다. 프레임에서 45도 이상 " +"회전하거나 극단적인 각도에 있는 얼굴을 더 잘 정렬할 수 있습니다. 추출 속도가 " +"느려집니다." + +#: lib/cli/args_extract_convert.py:245 +msgid "" +"If a face isn't found, rotate the images to try to find a face. Can find " +"more faces at the cost of extraction speed. Pass in a single number to use " +"increments of that size up to 360, or pass in a list of numbers to enumerate " +"exactly what angles to check." +msgstr "" +"얼굴이 발견되지 않으면 이미지를 회전하여 얼굴을 찾습니다. 추출 속도를 희생하" +"면서 더 많은 얼굴을 찾을 수 있습니다. 단일 숫자를 입력하여 해당 크기의 증분" +"을 360까지 사용하거나 숫자 목록을 입력하여 확인할 각도를 정확하게 열거합니다." + +#: lib/cli/args_extract_convert.py:255 +msgid "" +"Obtain and store face identity encodings from VGGFace2. Slows down extract a " +"little, but will save time if using 'sort by face'" +msgstr "" +"VGGFace2에서 얼굴 식별 인코딩을 가져와 저장합니다. 추출 속도를 약간 늦추지만 " +"'얼굴별로 정렬'을 사용하면 시간을 절약할 수 있습니다." + +#: lib/cli/args_extract_convert.py:265 lib/cli/args_extract_convert.py:276 +#: lib/cli/args_extract_convert.py:289 lib/cli/args_extract_convert.py:303 +#: lib/cli/args_extract_convert.py:610 lib/cli/args_extract_convert.py:619 +#: lib/cli/args_extract_convert.py:634 lib/cli/args_extract_convert.py:647 +#: lib/cli/args_extract_convert.py:661 +msgid "Face Processing" +msgstr "얼굴 처리" + +#: lib/cli/args_extract_convert.py:267 +msgid "" +"Filters out faces detected below this size. Length, in pixels across the " +"diagonal of the bounding box. Set to 0 for off" +msgstr "" +"이 크기 미만으로 탐지된 얼굴을 필터링합니다. 길이, 경계 상자의 대각선에 걸친 " +"픽셀 단위입니다. 0으로 설정하면 꺼집니다" + +#: lib/cli/args_extract_convert.py:278 +msgid "" +"Optionally filter out people who you do not wish to extract by passing in " +"images of those people. Should be a small variety of images at different " +"angles and in different conditions. A folder containing the required images " +"or multiple image files, space separated, can be selected." +msgstr "" +"선택적으로 추출하지 않을 사람의 이미지들을 전달하여 그 사람들을 제외합니다. " +"각도와 조건이 다른 작은 다양한 이미지여야 합니다. 추출되지 않는데 필요한 이미" +"지들 또는 공백으로 구분된 여러 이미지 파일이 들어 있는 폴더를 선택할 수 있습" +"니다." + +#: lib/cli/args_extract_convert.py:291 +msgid "" +"Optionally select people you wish to extract by passing in images of that " +"person. Should be a small variety of images at different angles and in " +"different conditions A folder containing the required images or multiple " +"image files, space separated, can be selected." +msgstr "" +"선택적으로 추출하고 싶은 사람의 이미지를 전달하여 그 사람을 선택합니다. 각도" +"와 조건이 다른 작은 다양한 이미지여야 합니다. 추출할 때 필요한 이미지들 또는 " +"공백으로 구분된 여러 이미지 파일이 들어 있는 폴더를 선택할 수 있습니다." + +#: lib/cli/args_extract_convert.py:305 +msgid "" +"For use with the optional nfilter/filter files. Threshold for positive face " +"recognition. Higher values are stricter." +msgstr "" +"옵션인 nfilter/filter 파일과 함께 사용합니다. 긍정적인 얼굴 인식을 위한 임계" +"값. 값이 높을수록 엄격합니다." + +#: lib/cli/args_extract_convert.py:314 lib/cli/args_extract_convert.py:327 +#: lib/cli/args_extract_convert.py:340 lib/cli/args_extract_convert.py:352 +msgid "output" +msgstr "출력" + +#: lib/cli/args_extract_convert.py:316 +msgid "" +"The output size of extracted faces. Make sure that the model you intend to " +"train supports your required size. This will only need to be changed for hi-" +"res models." +msgstr "" +"추출된 얼굴의 출력 크기입니다. 훈련하려는 모델이 필요한 크기를 지원하는지 꼭 " +"확인하세요. 이것은 고해상도 모델에 대해서만 변경하면 됩니다." + +#: lib/cli/args_extract_convert.py:329 +msgid "" +"Extract every 'nth' frame. This option will skip frames when extracting " +"faces. For example a value of 1 will extract faces from every frame, a value " +"of 10 will extract faces from every 10th frame." +msgstr "" +"모든 'n번째' 프레임을 추출합니다. 이 옵션은 얼굴을 추출할 때 건너뛸 프레임을 " +"설정합니다. 예를 들어, 값이 1이면 모든 프레임에서 얼굴이 추출되고, 값이 10이" +"면 모든 10번째 프레임에서 얼굴이 추출됩니다." + +#: lib/cli/args_extract_convert.py:342 +msgid "" +"Automatically save the alignments file after a set amount of frames. By " +"default the alignments file is only saved at the end of the extraction " +"process. NB: If extracting in 2 passes then the alignments file will only " +"start to be saved out during the second pass. WARNING: Don't interrupt the " +"script when writing the file because it might get corrupted. Set to 0 to " +"turn off" +msgstr "" +"프레임 수가 설정된 후 alignments 파일을 자동으로 저장합니다. 기본적으로 " +"alignments 파일은 추출 프로세스가 끝날 때만 저장됩니다. NB: 2번째 추출에서 성" +"공하면 두 번째 추출 중에만 alignments 파일이 저장되기 시작합니다. 경고: 파일" +"을 쓸 때 스크립트가 손상될 수 있으므로 스크립트를 중단하지 마십시오. 해제하려" +"면 0으로 설정" + +#: lib/cli/args_extract_convert.py:353 +msgid "Draw landmarks on the ouput faces for debugging purposes." +msgstr "디버깅을 위해 출력 얼굴에 특징점을 그립니다." + +#: lib/cli/args_extract_convert.py:359 lib/cli/args_extract_convert.py:369 +#: lib/cli/args_extract_convert.py:377 lib/cli/args_extract_convert.py:384 +#: lib/cli/args_extract_convert.py:674 lib/cli/args_extract_convert.py:686 +#: lib/cli/args_extract_convert.py:695 lib/cli/args_extract_convert.py:716 +#: lib/cli/args_extract_convert.py:722 +msgid "settings" +msgstr "설정" + +#: lib/cli/args_extract_convert.py:361 +msgid "" +"Don't run extraction in parallel. Will run each part of the extraction " +"process separately (one after the other) rather than all at the same time. " +"Useful if VRAM is at a premium." +msgstr "" +"추출을 병렬로 실행하지 마십시오. 추출 프로세스의 각 부분을 동시에 모두 실행하" +"는 것이 아니라 개별적으로(하나씩) 실행합니다. VRAM이 프리미엄인 경우 유용합니" +"다." + +#: lib/cli/args_extract_convert.py:371 +msgid "" +"Skips frames that have already been extracted and exist in the alignments " +"file" +msgstr "이미 추출되었거나 alignments 파일에 존재하는 프레임들을 스킵합니다" + +#: lib/cli/args_extract_convert.py:378 +msgid "Skip frames that already have detected faces in the alignments file" +msgstr "이미 얼굴을 탐지하여 alignments 파일에 존재하는 프레임들을 스킵합니다" + +#: lib/cli/args_extract_convert.py:385 +msgid "Skip saving the detected faces to disk. Just create an alignments file" +msgstr "" +"탐지된 얼굴을 디스크에 저장하지 않습니다. 그저 alignments 파일을 만듭니다" + +#: lib/cli/args_extract_convert.py:459 +msgid "" +"Swap the original faces in a source video/images to your final faces.\n" +"Conversion plugins can be configured in the 'Settings' Menu" +msgstr "" +"원본 비디오/이미지의 원래 얼굴을 최종 얼굴으로 바꿉니다.\n" +"변환 플러그인은 '설정' 메뉴에서 구성할 수 있습니다" + +#: lib/cli/args_extract_convert.py:481 +msgid "" +"Only required if converting from images to video. Provide The original video " +"that the source frames were extracted from (for extracting the fps and " +"audio)." +msgstr "" +"이미지에서 비디오로 변환하는 경우에만 필요합니다. 소스 프레임이 추출된 원본 " +"비디오(fps 및 오디오 추출용)를 입력하세요." + +#: lib/cli/args_extract_convert.py:490 +msgid "" +"Model directory. The directory containing the trained model you wish to use " +"for conversion." +msgstr "" +"모델 폴더. 당신이 변환에 사용하고자 하는 훈련된 모델을 가진 폴더입니다." + +#: lib/cli/args_extract_convert.py:501 +msgid "" +"R|Performs color adjustment to the swapped face. Some of these options have " +"configurable settings in '/config/convert.ini' or 'Settings > Configure " +"Convert Plugins':\n" +"L|avg-color: Adjust the mean of each color channel in the swapped " +"reconstruction to equal the mean of the masked area in the original image.\n" +"L|color-transfer: Transfers the color distribution from the source to the " +"target image using the mean and standard deviations of the L*a*b* color " +"space.\n" +"L|manual-balance: Manually adjust the balance of the image in a variety of " +"color spaces. Best used with the Preview tool to set correct values.\n" +"L|match-hist: Adjust the histogram of each color channel in the swapped " +"reconstruction to equal the histogram of the masked area in the original " +"image.\n" +"L|seamless-clone: Use cv2's seamless clone function to remove extreme " +"gradients at the mask seam by smoothing colors. Generally does not give very " +"satisfactory results.\n" +"L|none: Don't perform color adjustment." +msgstr "" +"R|스왑된 얼굴의 색상 조정을 수행합니다. 이러한 옵션 중 일부에는 '/config/" +"convert.ini' 또는 '설정 > 변환 플러그인 구성'에서 구성 가능한 설정이 있습니" +"다.\n" +"L|avg-color: 스왑된 재구성에서 각 색상 채널의 평균이 원본 영상에서 마스킹된 " +"영역의 평균과 동일하도록 조정합니다.\n" +"L|color-transfer: L*a*b* 색 공간의 평균 및 표준 편차를 사용하여 소스에서 대" +"상 이미지로 색 분포를 전송합니다.\n" +"L|manual-balance: 다양한 색 공간에서 이미지의 밸런스를 수동으로 조정합니다. " +"올바른 값을 설정하려면 미리 보기 도구와 함께 사용하는 것이 좋습니다.\n" +"L|match-hist: 스왑된 재구성에서 각 색상 채널의 히스토그램을 조정하여 원래 영" +"상에서 마스킹된 영역의 히스토그램과 동일하게 만듭니다.\n" +"L|seamless-clone: cv2의 원활한 복제 기능을 사용하여 색상을 평활화하여 마스크 " +"심에서 극단적인 gradients을 제거합니다. 일반적으로 매우 만족스러운 결과를 제" +"공하지 않습니다.\n" +"L|none: 색상 조정을 수행하지 않습니다." + +#: lib/cli/args_extract_convert.py:527 +msgid "" +"R|Masker to use. NB: The mask you require must exist within the alignments " +"file. You can add additional masks with the Mask Tool.\n" +"L|none: Don't use a mask.\n" +"L|bisenet-fp_face: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'face' or " +"'legacy' centering.\n" +"L|bisenet-fp_head: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'head' " +"centering.\n" +"L|custom_face: Custom user created, face centered mask.\n" +"L|custom_head: Custom user created, head centered mask.\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\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.\n" +"L|predicted: If the 'Learn Mask' option was enabled during training, this " +"will use the mask that was created by the trained model." +msgstr "" +"R|사용할 마스크. NB: 필요한 마스크는 alignments 파일 내에 있어야 합니다. 마스" +"크 도구를 사용하여 마스크를 추가할 수 있습니다.\n" +"L|none: 마스크 쓰지 마세요.\n" +"L|bisnet-fp_face: 마스크할 영역을 보다 정교하게 제어할 수 있는 비교적 가벼운 " +"NN 기반 마스크입니다(마스크 설정에서 구성 가능). 모델이 '얼굴' 또는 '레거시' " +"중심으로 훈련된 경우 이 버전의 bisnet-fp를 사용하십시오.\n" +"L|bisnet-fp_head: 마스크할 영역을 보다 정교하게 제어할 수 있는 비교적 가벼운 " +"NN 기반 마스크입니다(마스크 설정에서 구성 가능). 모델이 '헤드' 중심으로 훈련" +"된 경우 이 버전의 bisnet-fp를 사용하십시오.\n" +"L|custom_face: 사용자 지정 사용자가 생성한 얼굴 중심 마스크입니다.\n" +"L|custom_head: 사용자 지정 사용자가 생성한 머리 중심 마스크입니다.\n" +"L|components: 특징점 위치의 배치를 기반으로 얼굴 분할을 제공하도록 설계된 마" +"스크입니다. 특징점의 외부에는 마스크를 만들기 위해 convex hull가 형성되어 있" +"습니다.\n" +"L|extended: 특징점 위치의 배치를 기반으로 얼굴 분할을 제공하도록 설계된 마스" +"크입니다. 지형지물의 외부에는 convex hull가 형성되어 있으며, 마스크는 이마 위" +"로 뻗어 있습니다.\n" +"L|vgg-clear: 대부분의 정면에 장애물이 없는 스마트한 분할을 제공하도록 설계된 " +"마스크입니다. 옆 얼굴 및 장애물로 인해 성능이 저하될 수 있습니다.\n" +"L|vgg-obstructed: 대부분의 정면 얼굴을 스마트하게 분할할 수 있도록 설계된 마" +"스크입니다. 마스크 모델은 일부 안면 장애물(손과 안경)을 인식하도록 특별히 훈" +"련되었습니다. 옆 얼굴은 평균 이하의 성능을 초래할 수 있습니다.\n" +"L|unet-dfl: 대부분 정면 얼굴을 스마트하게 분할하도록 설계된 마스크. 마스크 모" +"델은 커뮤니티 구성원들에 의해 훈련되었으며 추가 설명을 위해 테스트가 필요하" +"다. 옆 얼굴은 평균 이하의 성능을 초래할 수 있습니다.\n" +"L|predicted: 교육 중에 'Learn Mask(마스크 학습)' 옵션이 활성화된 경우에는 교" +"육을 받은 모델이 만든 마스크가 사용됩니다." + +#: lib/cli/args_extract_convert.py:566 +msgid "" +"R|The plugin to use to output the converted images. The writers are " +"configurable in '/config/convert.ini' or 'Settings > Configure Convert " +"Plugins:'\n" +"L|ffmpeg: [video] Writes out the convert straight to video. When the input " +"is a series of images then the '-ref' (--reference-video) parameter must be " +"set.\n" +"L|gif: [animated image] Create an animated gif.\n" +"L|opencv: [images] The fastest image writer, but less options and formats " +"than other plugins.\n" +"L|patch: [images] Outputs the raw swapped face patch, along with the " +"transformation matrix required to re-insert the face back into the original " +"frame. Use this option if you wish to post-process and composite the final " +"face within external tools.\n" +"L|pillow: [images] Slower than opencv, but has more options and supports " +"more formats." +msgstr "" +"R|변환된 이미지를 출력하는 데 사용할 플러그인입니다. 기록 장치는 '/config/" +"convert.ini' 또는 '설정 > 변환 플러그인 구성:'에서 구성할 수 있습니다.\n" +"L|ffmpeg: [video] 변환된 결과를 바로 video로 씁니다. 입력이 영상 시리즈인 경" +"우 '-ref'(--reference-video) 파라미터를 설정해야 합니다.\n" +"L|gif : [애니메이션 이미지] 애니메이션 gif를 만듭니다.\n" +"L|opencv: [이미지] 가장 빠른 이미지 작성기이지만 다른 플러그인에 비해 옵션과 " +"형식이 적습니다.\n" +"L|patch: [이미지] 원래 프레임에 얼굴을 다시 삽입하는 데 필요한 변환 행렬과 함" +"께 원시 교체된 얼굴 패치를 출력합니다.\n" +"L|pillow: [images] opencv보다 느리지만 더 많은 옵션이 있고 더 많은 형식을 지" +"원합니다." + +#: lib/cli/args_extract_convert.py:587 lib/cli/args_extract_convert.py:596 +#: lib/cli/args_extract_convert.py:707 +msgid "Frame Processing" +msgstr "프레임 처리" + +#: lib/cli/args_extract_convert.py:589 +#, python-format +msgid "" +"Scale the final output frames by this amount. 100%% will output the frames " +"at source dimensions. 50%% at half size 200%% at double size" +msgstr "" +"최종 출력 프레임의 크기를 이 양만큼 조정합니다. 100%%는 원본의 차원에서 프레" +"임을 출력합니다. 50%%는 절반 크기에서, 200%%는 두 배 크기에서" + +#: lib/cli/args_extract_convert.py:598 +msgid "" +"Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " +"--frame-ranges 10-50 90-100. Frames falling outside of the selected range " +"will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " +"converting from images, then the filenames must end with the frame-number!" +msgstr "" +"예를 들어 전송을 적용할 프레임 범위 프레임 10 - 50 및 90 - 100의 경우 --" +"frame-ranges 10-50 90-100을 사용합니다. '-k'(--keep-unchanged)를 선택하지 않" +"으면 선택한 범위를 벗어나는 프레임이 삭제됩니다. NB: 이미지에서 변환하는 경" +"우 파일 이름은 프레임 번호로 끝나야 합니다!" + +#: lib/cli/args_extract_convert.py:612 +msgid "" +"Scale the swapped face by this percentage. Positive values will enlarge the " +"face, Negative values will shrink the face." +msgstr "" +"이 백분율로 교체된 면의 크기를 조정합니다. 양수 값은 얼굴을 확대하고, 음수 값" +"은 얼굴을 축소합니다." + +#: lib/cli/args_extract_convert.py:621 +msgid "" +"If you have not cleansed your alignments file, then you can filter out faces " +"by defining a folder here that contains the faces extracted from your input " +"files/video. If this folder is defined, then only faces that exist within " +"your alignments file and also exist within the specified folder will be " +"converted. Leaving this blank will convert all faces that exist within the " +"alignments file." +msgstr "" +"만약 alignments 파일을 지우지 않은 경우 입력 파일/비디오에서 추출된 얼굴이 포" +"함된 폴더를 정의하여 얼굴을 걸러낼 수 있습니다. 이 폴더가 정의된 경우 " +"alignments 파일 내에 존재하거나 지정된 폴더 내에 존재하는 얼굴만 변환됩니다. " +"이 항목을 공백으로 두면 alignments 파일 내에 있는 모든 얼굴이 변환됩니다." + +#: lib/cli/args_extract_convert.py:636 +msgid "" +"Optionally filter out people who you do not wish to process by passing in an " +"image of that person. Should be a front portrait with a single person in the " +"image. Multiple images can be added space separated. NB: Using face filter " +"will significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"선택적으로 처리하고 싶지 않은 사람의 이미지를 전달하여 그 사람을 걸러낼 수 있" +"습니다. 이미지는 한 사람의 정면 모습이여야 합니다. 여러 이미지를 공백으로 구" +"분하여 추가할 수 있습니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감소" +"하므로 정확성을 보장할 수 없습니다." + +#: lib/cli/args_extract_convert.py:649 +msgid "" +"Optionally select people you wish to process by passing in an image of that " +"person. Should be a front portrait with a single person in the image. " +"Multiple images can be added space separated. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"선택적으로 해당 사용자의 이미지를 전달하여 처리할 사용자를 선택합니다. 이미지" +"에 한 사람이 있는 정면 초상화여야 합니다. 여러 이미지를 공백으로 구분하여 추" +"가할 수 있습니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감소하므로 정" +"확성을 보장할 수 없습니다." + +#: lib/cli/args_extract_convert.py:663 +msgid "" +"For use with the optional nfilter/filter files. Threshold for positive face " +"recognition. Lower values are stricter. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"옵션인 nfilter/filter 파일을 함께 사용합니다. 긍정적인 얼굴 인식을 위한 임계" +"값. 낮은 값이 더 엄격합니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감" +"소하므로 정확성을 보장할 수 없습니다." + +#: lib/cli/args_extract_convert.py:676 +msgid "" +"The maximum number of parallel processes for performing conversion. " +"Converting images is system RAM heavy so it is possible to run out of memory " +"if you have a lot of processes and not enough RAM to accommodate them all. " +"Setting this to 0 will use the maximum available. No matter what you set " +"this to, it will never attempt to use more processes than are available on " +"your system. If singleprocess is enabled this setting will be ignored." +msgstr "" +"변환을 수행하기 위한 최대 병렬 프로세스 수입니다. 이미지 변환은 시스템 RAM에 " +"부담이 크기 때문에 프로세스가 많고 모든 프로세스를 수용할 RAM이 충분하지 않" +"은 경우 메모리가 부족할 수 있습니다. 이것을 0으로 설정하면 사용 가능한 최대값" +"을 사용합니다. 얼마를 설정하든 시스템에서 사용 가능한 것보다 더 많은 프로세스" +"를 사용하려고 시도하지 않습니다. 단일 프로세스가 활성화된 경우 이 설정은 무시" +"됩니다." + +#: lib/cli/args_extract_convert.py:688 +msgid "" +"[LEGACY] This only needs to be selected if a legacy model is being loaded or " +"if there are multiple models in the model folder" +msgstr "" +"[LEGACY] 이것은 레거시 모델을 로드 중이거나 모델 폴더에 여러 모델이 있는 경우" +"에만 선택되어야 합니다" + +#: lib/cli/args_extract_convert.py:697 +msgid "" +"Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " +"alignments file for your destination video. However, if you wish you can " +"generate the alignments on-the-fly by enabling this option. This will use an " +"inferior extraction pipeline and will lead to substandard results. If an " +"alignments file is found, this option will be ignored." +msgstr "" +"실시간 변환을 활성화합니다. 권장하지 않습니다. 당신은 변환 비디오에 대한 깨끗" +"한 alignments 파일을 생성해야 합니다. 그러나 원하는 경우 이 옵션을 활성화하" +"여 즉시 alignments 파일을 생성할 수 있습니다. 이것은 안좋은 추출 과정을 사용" +"하고 표준 이하의 결과로 이어질 것입니다. alignments 파일이 발견되면 이 옵션" +"은 무시됩니다." + +#: lib/cli/args_extract_convert.py:709 +msgid "" +"When used with --frame-ranges outputs the unchanged frames that are not " +"processed instead of discarding them." +msgstr "" +"사용시 --frame-ranges 인자를 사용하면 변경되지 않은 프레임을 버리지 않은 결과" +"가 출력됩니다." + +#: lib/cli/args_extract_convert.py:717 +msgid "Swap the model. Instead converting from of A -> B, converts B -> A" +msgstr "모델을 바꿉니다. A -> B에서 변환하는 대신 B -> A로 변환" + +#: lib/cli/args_extract_convert.py:723 +msgid "Disable multiprocessing. Slower but less resource intensive." +msgstr "멀티프로세싱을 쓰지 않습니다. 느리지만 자원을 덜 소모합니다." diff --git a/locales/kr/LC_MESSAGES/lib.cli.args_train.mo b/locales/kr/LC_MESSAGES/lib.cli.args_train.mo new file mode 100644 index 0000000000000000000000000000000000000000..1e5985f335f035f6a0490a5d8e2c49e210e9d8d8 GIT binary patch literal 16024 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 literal 0 HcmV?d00001 diff --git a/locales/kr/LC_MESSAGES/lib.cli.args_train.po b/locales/kr/LC_MESSAGES/lib.cli.args_train.po new file mode 100644 index 0000000000..c90082345d --- /dev/null +++ b/locales/kr/LC_MESSAGES/lib.cli.args_train.po @@ -0,0 +1,350 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-28 18:04+0000\n" +"PO-Revision-Date: 2024-03-28 18:16+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ko_KR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Poedit 3.4.2\n" + +#: lib/cli/args_train.py:30 +msgid "" +"Train a model on extracted original (A) and swap (B) faces.\n" +"Training models can take a long time. Anything from 24hrs to over a week\n" +"Model plugins can be configured in the 'Settings' Menu" +msgstr "" +"추출된 원래(A) 얼굴과 스왑(B) 얼굴에 대한 모델을 훈련합니다.\n" +"모델을 훈련하는 데 시간이 오래 걸릴 수 있습니다. 24시간에서 일주일 이상의 시" +"간이 필요합니다.\n" +"모델 플러그인은 '설정' 메뉴에서 구성할 수 있습니다" + +#: lib/cli/args_train.py:49 lib/cli/args_train.py:58 +msgid "faces" +msgstr "얼굴들" + +#: lib/cli/args_train.py:51 +msgid "" +"Input directory. A directory containing training images for face A. This is " +"the original face, i.e. the face that you want to remove and replace with " +"face B." +msgstr "" +"입력 디렉토리. 얼굴 A에 대한 훈련 이미지가 포함된 디렉토리입니다. 이것은 원" +"래 얼굴, 즉 제거하고 B 얼굴로 대체하려는 얼굴입니다." + +#: lib/cli/args_train.py:60 +msgid "" +"Input directory. A directory containing training images for face B. This is " +"the swap face, i.e. the face that you want to place onto the head of person " +"A." +msgstr "" +"입력 디렉터리. 얼굴 B에 대한 훈련 이미지를 포함하는 디렉토리. 이것은 대체 얼" +"굴, 즉 사람 A의 얼굴 앞에 배치하려는 얼굴이다." + +#: lib/cli/args_train.py:67 lib/cli/args_train.py:80 lib/cli/args_train.py:97 +#: lib/cli/args_train.py:123 lib/cli/args_train.py:133 +msgid "model" +msgstr "모델" + +#: lib/cli/args_train.py:69 +msgid "" +"Model directory. This is where the training data will be stored. You should " +"always specify a new folder for new models. If starting a new model, select " +"either an empty folder, or a folder which does not exist (which will be " +"created). If continuing to train an existing model, specify the location of " +"the existing model." +msgstr "" +"모델 디렉토리. 여기에 훈련 데이터가 저장됩니다. 새 모델의 경우 항상 새 폴더" +"를 지정해야 합니다. 새 모델을 시작할 경우 빈 폴더 또는 존재하지 않는 폴더(생" +"성될 폴더)를 선택합니다. 기존 모델을 계속 학습하는 경우 기존 모델의 위치를 지" +"정합니다." + +#: lib/cli/args_train.py:82 +msgid "" +"R|Load the weights from a pre-existing model into a newly created model. For " +"most models this will load weights from the Encoder of the given model into " +"the encoder of the newly created model. Some plugins may have specific " +"configuration options allowing you to load weights from other layers. " +"Weights will only be loaded when creating a new model. This option will be " +"ignored if you are resuming an existing model. Generally you will also want " +"to 'freeze-weights' whilst the rest of your model catches up with your " +"Encoder.\n" +"NB: Weights can only be loaded from models of the same plugin as you intend " +"to train." +msgstr "" +"R|기존 모델의 가중치를 새로 생성된 모델로 로드합니다. 대부분의 모델에서는 주" +"어진 모델의 인코더에서 새로 생성된 모델의 인코더로 가중치를 로드합니다. 일부 " +"플러그인에는 다른 층에서 가중치를 로드할 수 있는 특정 구성 옵션이 있을 수 있" +"습니다. 가중치는 새 모델을 생성할 때만 로드됩니다. 기존 모델을 재개하는 경우 " +"이 옵션은 무시됩니다. 일반적으로 나머지 모델이 인코더를 따라잡는 동안에도 '가" +"중치 동결'이 필요합니다.\n" +"주의: 가중치는 훈련하려는 플러그인 모델에서만 로드할 수 있습니다." + +#: lib/cli/args_train.py:99 +msgid "" +"R|Select which trainer to use. Trainers can be configured from the Settings " +"menu or the config folder.\n" +"L|original: The original model created by /u/deepfakes.\n" +"L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " +"for full dfaker method.\n" +"L|dfl-h128: 128px in/out model from deepfacelab\n" +"L|dfl-sae: Adaptable model from deepfacelab\n" +"L|dlight: A lightweight, high resolution DFaker variant.\n" +"L|iae: A model that uses intermediate layers to try to get better details\n" +"L|lightweight: A lightweight model for low-end cards. Don't expect great " +"results. Can train as low as 1.6GB with batch size 8.\n" +"L|realface: A high detail, dual density model based on DFaker, with " +"customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " +"won't work so well. By andenixa et al. Very configurable.\n" +"L|unbalanced: 128px in/out model from andenixa. The autoencoders are " +"unbalanced so B>A swaps won't work so well. Very configurable.\n" +"L|villain: 128px in/out model from villainguy. Very resource hungry (You " +"will require a GPU with a fair amount of VRAM). Good for details, but more " +"susceptible to color differences." +msgstr "" +"R|사용할 훈련 모델을 선택합니다. 훈련 모델은 설정 메뉴 또는 구성 폴더에서 구" +"성할 수 있습니다.\n" +"L|original: /u/deepfakes로 만든 원래 모델입니다.\n" +"L|dfaker: 64px in/128px out 모델 from dfaker. Full dfaker 메서드에 대해 '특징" +"점으로 변환'를 활성화합니다.\n" +"L|dfl-h128: Deepfake lab의 128px in/out 모델\n" +"L|dfl-sae: Deepface Lab의 적응형 모델\n" +"L|dlight: 경량, 고해상도 DFaker 변형입니다.\n" +"L|iae: 중간 층들을 사용하여 더 나은 세부 정보를 얻기 위해 노력하는 모델.\n" +"L|lightweight: 저가형 카드용 경량 모델. 좋은 결과를 기대하지 마세요. 최대한 " +"낮게 잡아서 배치 사이즈 8에 1.6GB까지 훈련이 가능합니다.\n" +"L|realface: DFaker를 기반으로 한 높은 디테일의 이중 밀도 모델로, 사용자 정의 " +"가능한 입/출력 해상도를 제공합니다. 오토인코더가 불균형하여 B>A 스왑이 잘 작" +"동하지 않습니다. Andenixa 등에 의해. 매우 구성 가능합니다.\n" +"L|unbalanced: andenixa의 128px in/out 모델. 오토인코더가 불균형하여 B>A 스왑" +"이 잘 작동하지 않습니다. 매우 구성 가능합니다.\n" +"L|villain : villainguy의 128px in/out 모델. 리소스가 매우 부족합니다( 상당한 " +"양의 VRAM이 있는 GPU가 필요합니다). 세부 사항에는 좋지만 색상 차이에 더 취약" +"합니다." + +#: lib/cli/args_train.py:125 +msgid "" +"Output a summary of the model and exit. If a model folder is provided then a " +"summary of the saved model is displayed. Otherwise a summary of the model " +"that would be created by the chosen plugin and configuration settings is " +"displayed." +msgstr "" +"모델 요약을 출력하고 종료합니다. 모델 폴더가 제공되면 저장된 모델의 요약이 표" +"시됩니다. 그렇지 않으면 선택한 플러그인 및 구성 설정에 의해 생성되는 모델 요" +"약이 표시됩니다." + +#: lib/cli/args_train.py:135 +msgid "" +"Freeze the weights of the model. Freezing weights means that some of the " +"parameters in the model will no longer continue to learn, but those that are " +"not frozen will continue to learn. For most models, this will freeze the " +"encoder, but some models may have configuration options for freezing other " +"layers." +msgstr "" +"모델의 가중치를 동결합니다. 가중치를 고정하면 모델의 일부 매개변수가 더 이상 " +"학습되지 않지만 고정되지 않은 매개변수는 계속 학습됩니다. 대부분의 모델에서 " +"이렇게 하면 인코더가 고정되지만 일부 모델에는 다른 레이어를 고정하기 위한 구" +"성 옵션이 있을 수 있습니다." + +#: lib/cli/args_train.py:147 lib/cli/args_train.py:160 +#: lib/cli/args_train.py:175 lib/cli/args_train.py:191 +#: lib/cli/args_train.py:200 +msgid "training" +msgstr "훈련" + +#: lib/cli/args_train.py:149 +msgid "" +"Batch size. This is the number of images processed through the model for " +"each side per iteration. NB: As the model is fed 2 sides at a time, the " +"actual number of images within the model at any one time is double the " +"number that you set here. Larger batches require more GPU RAM." +msgstr "" +"배치 크기. 반복당 각 측면에 대해 모델을 통해 처리되는 이미지 수입니다. NB: " +"한 번에 모델에게 2개의 측면이 공급되므로 한 번에 모델 내의 실제 이미지 수는 " +"여기에서 설정한 수의 두 배입니다. 더 큰 배치에는 더 많은 GPU RAM이 필요합니" +"다." + +#: lib/cli/args_train.py:162 +msgid "" +"Length of training in iterations. This is only really used for automation. " +"There is no 'correct' number of iterations a model should be trained for. " +"You should stop training when you are happy with the previews. However, if " +"you want the model to stop automatically at a set number of iterations, you " +"can set that value here." +msgstr "" +"반복에서 훈련 길이. 이것은 실제로 자동화에만 사용됩니다. 모델을 훈련해야 하" +"는 '올바른' 반복 횟수는 없습니다. 미리 보기에 만족하면 훈련을 중단해야 합니" +"다. 그러나 설정된 반복 횟수에서 모델이 자동으로 중지되도록 하려면 여기에서 해" +"당 값을 설정할 수 있습니다." + +#: lib/cli/args_train.py:177 +msgid "" +"R|Select the distribution stategy to use.\n" +"L|default: Use Tensorflow's default distribution strategy.\n" +"L|central-storage: Centralizes variables on the CPU whilst operations are " +"performed on 1 or more local GPUs. This can help save some VRAM at the cost " +"of some speed by not storing variables on the GPU. Note: Mixed-Precision is " +"not supported on multi-GPU setups.\n" +"L|mirrored: Supports synchronous distributed training across multiple local " +"GPUs. A copy of the model and all variables are loaded onto each GPU with " +"batches distributed to each GPU at each iteration." +msgstr "" +"R|사용할 배포 상태를 선택합니다.\n" +"L|default: Tensorflow의 기본 배포 전략을 사용합니다.\n" +"L|central-storage: 작업이 1개 이상의 로컬 GPU에서 수행되는 동안 CPU의 변수를 " +"중앙 집중화합니다. 이렇게 하면 GPU에 변수를 저장하지 않음으로써 약간의 속도" +"를 희생하여 일부 VRAM을 절약할 수 있습니다. 참고: 다중 정밀도는 다중 GPU 설정" +"에서 지원되지 않습니다.\n" +"L|mirrored: 여러 로컬 GPU에서 동기화 분산 훈련을 지원합니다. 모델의 복사본과 " +"모든 변수는 각 반복에서 각 GPU에 배포된 배치들와 함께 각 GPU에 로드됩니다." + +#: lib/cli/args_train.py:193 +msgid "" +"Disables TensorBoard logging. NB: Disabling logs means that you will not be " +"able to use the graph or analysis for this session in the GUI." +msgstr "" +"텐서보드 로깅을 비활성화합니다. 주의: 로그를 비활성화하면 GUI에서 이 세션에 " +"대한 그래프 또는 분석을 사용할 수 없습니다." + +#: lib/cli/args_train.py:202 +msgid "" +"Use the Learning Rate Finder to discover the optimal learning rate for " +"training. For new models, this will calculate the optimal learning rate for " +"the model. For existing models this will use the optimal learning rate that " +"was discovered when initializing the model. Setting this option will ignore " +"the manually configured learning rate (configurable in train settings)." +msgstr "" +"학습률 찾기를 사용하여 훈련을 위한 최적의 학습률을 찾아보세요. 새 모델의 경" +"우 모델에 대한 최적의 학습률을 계산합니다. 기존 모델의 경우 모델을 초기화할 " +"때 발견된 최적의 학습률을 사용합니다. 이 옵션을 설정하면 수동으로 구성된 학습" +"률(기차 설정에서 구성 가능)이 무시됩니다." + +#: lib/cli/args_train.py:215 lib/cli/args_train.py:225 +msgid "Saving" +msgstr "저장" + +#: lib/cli/args_train.py:216 +msgid "Sets the number of iterations between each model save." +msgstr "각 모델 저장 사이의 반복 횟수를 설정합니다." + +#: lib/cli/args_train.py:227 +msgid "" +"Sets the number of iterations before saving a backup snapshot of the model " +"in it's current state. Set to 0 for off." +msgstr "" +"현재 상태에서 모델의 백업 스냅샷을 저장하기 전에 반복할 횟수를 설정합니다. 0" +"으로 설정하면 꺼집니다." + +#: lib/cli/args_train.py:234 lib/cli/args_train.py:246 +#: lib/cli/args_train.py:258 +msgid "timelapse" +msgstr "타임랩스" + +#: lib/cli/args_train.py:236 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. " +"This should be the input folder of 'A' faces that you would like to use for " +"creating the timelapse. You must also supply a --timelapse-output and a --" +"timelapse-input-B parameter." +msgstr "" +"타임랩스를 만드는 옵션입니다. Timelapse(시간 경과)는 저장을 반복할 때마다 선" +"택한 얼굴의 이미지를 Timelapse-output(시간 경과 출력) 폴더에 저장합니다. 타임" +"랩스를 만드는 데 사용할 'A' 얼굴의 입력 폴더여야 합니다. 또한 사용자는 --" +"timelapse-output 및 --timelapse-input-B 매개 변수를 제공해야 합니다." + +#: lib/cli/args_train.py:248 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. " +"This should be the input folder of 'B' faces that you would like to use for " +"creating the timelapse. You must also supply a --timelapse-output and a --" +"timelapse-input-A parameter." +msgstr "" +"타임 랩스를 만드는 데 선택적입니다. Timelapse(시간 경과)는 저장을 반복할 때마" +"다 선택한 얼굴의 이미지를 Timelapse-output(시간 경과 출력) 폴더에 저장합니" +"다. 타임 랩스를 만드는 데 사용할 'B' 얼굴의 입력 폴더여야 합니다. 또한 사용자" +"는 --timelapse-output 및 --timelapse-input-A 매개 변수를 제공해야 합니다." + +#: lib/cli/args_train.py:260 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. If " +"the input folders are supplied but no output folder, it will default to your " +"model folder/timelapse/" +msgstr "" +"타임랩스를 만드는 데 선택적입니다. Timelapse(시간 경과)는 저장을 반복할 때마" +"다 선택한 얼굴의 이미지를 Timelapse-output(시간 경과 출력) 폴더에 저장합니" +"다. 입력 폴더가 제공되었지만 출력 폴더가 없는 경우 모델 폴더에/timelapse/로 " +"기본 설정됩니다" + +#: lib/cli/args_train.py:269 lib/cli/args_train.py:276 +msgid "preview" +msgstr "미리보기" + +#: lib/cli/args_train.py:270 +msgid "Show training preview output. in a separate window." +msgstr "훈련 미리보기 결과를 각기 다른 창에서 보여줍니다." + +#: lib/cli/args_train.py:278 +msgid "" +"Writes the training result to a file. The image will be stored in the root " +"of your FaceSwap folder." +msgstr "" +"훈련 결과를 파일에 씁니다. 이미지는 Faceswap 폴더의 최상위 폴더에 저장됩니다." + +#: lib/cli/args_train.py:285 lib/cli/args_train.py:295 +#: lib/cli/args_train.py:305 lib/cli/args_train.py:315 +msgid "augmentation" +msgstr "보정" + +#: lib/cli/args_train.py:287 +msgid "" +"Warps training faces to closely matched Landmarks from the opposite face-set " +"rather than randomly warping the face. This is the 'dfaker' way of doing " +"warping." +msgstr "" +"무작위로 얼굴을 변환하지 않고 반대쪽 얼굴 세트에서 특징점과 밀접하게 일치하도" +"록 훈련 얼굴을 변환해줍니다. 이것은 변환하는 'dfaker' 방식이다." + +#: lib/cli/args_train.py:297 +msgid "" +"To effectively learn, a random set of images are flipped horizontally. " +"Sometimes it is desirable for this not to occur. Generally this should be " +"left off except for during 'fit training'." +msgstr "" +"효과적으로 학습하기 위해 임의의 이미지 세트를 수평으로 뒤집습니다. 때때로 이" +"런 일이 일어나지 않는 것이 바람직합니다. 일반적으로 'fit training' 중을 제외" +"하고는 이 작업을 중단해야 합니다." + +#: lib/cli/args_train.py:307 +msgid "" +"Color augmentation helps make the model less susceptible to color " +"differences between the A and B sets, at an increased training time cost. " +"Enable this option to disable color augmentation." +msgstr "" +"색상 보정은 모델이 A와 B 세트 사이의 색상 차이에 덜 민감하게 만드는 데 도움" +"이 되며, 훈련 시간 비용이 증가합니다. 색상 보저를 사용하지 않으려면 이 옵션" +"을 사용합니다." + +#: lib/cli/args_train.py:317 +msgid "" +"Warping is integral to training the Neural Network. This option should only " +"be enabled towards the very end of training to try to bring out more detail. " +"Think of it as 'fine-tuning'. Enabling this option from the beginning is " +"likely to kill a model and lead to terrible results." +msgstr "" +"변환은 신경망을 훈련하는 데 필수적입니다. 이 옵션은 보다 세부적인 것들을 뽑아" +"내위하여 훈련 막바지까지 활성화하여야 합니다. 이것은 '미세 조정'이라고 생각하" +"면 됩니다. 처음부터 이 옵션을 활성화하면 모델이 죽을 수있고 끔찍한 결과를 초" +"래할 수 있습니다." diff --git a/locales/kr/LC_MESSAGES/tools.alignments.cli.mo b/locales/kr/LC_MESSAGES/tools.alignments.cli.mo index 092983e8578d7697534a0d59ab3dfffed4a2b5e2..ec090bd06a601115a38d42b0eebb487d911f69a0 100644 GIT binary patch delta 483 zcmX}oKP*F06vy%3>mM!J($Z2X5r3KxkE*swn;3M^1RX3S3LbKlff#C65mTX$$6jqZtgkv&)IrytvK;s%GYjyZhA zWlXrF72L%syu~VhVi6DBzdi17+9yT5Qj*$IK-%+A7nI6UMK59Li2^&qhhmG9q|GoY zu})c}UIlE!A`Z}~jCuZdV$v2~;68?Wqys#{IrR2Q*H}U})E910zZjS1{T1n|Pg-T- zeMov{$AuATn}wreQk?lJW;od8xHQXrV?t`hge|pTvZ;+MH+T3Cb0>2N6Xx)pKO-sF z)R$pkp58Px&HlpApq=b!PVh(yk-VY;PThL5oCo(uIGC|hGj=*Zm`W8=+2hCPy*o3R OcFtobE@v~I>;49+yfZ5R delta 538 zcmX}pyDx)L7{~EnweDJ!qH#$RmqaMlHm)(a#Dc*>f<&kiEP{lEMr;g3kqDF6kz&z5 z!9c_!Vs{x?8{dO?lIQ)Lb8>Fac`t_#MArzZ6q8zt}frBr_p+M KX)ICHnEwsdxk)4d diff --git a/locales/kr/LC_MESSAGES/tools.alignments.cli.po b/locales/kr/LC_MESSAGES/tools.alignments.cli.po index 406c53a46f..a86b4112e7 100644 --- a/locales/kr/LC_MESSAGES/tools.alignments.cli.po +++ b/locales/kr/LC_MESSAGES/tools.alignments.cli.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-02-24 00:27+0000\n" -"PO-Revision-Date: 2023-02-24 00:34+0000\n" +"POT-Creation-Date: 2024-03-28 23:49+0000\n" +"PO-Revision-Date: 2024-03-29 00:05+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ko_KR\n" @@ -16,15 +16,15 @@ 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/alignments/cli.py:17 +#: tools/alignments/cli.py:16 msgid "" "This command lets you perform various tasks pertaining to an alignments file." msgstr "" "이 명령을 사용하여 alignments 파일과 관련된 다양한 작ㅇ를 수행할 수 있습니다." -#: tools/alignments/cli.py:32 +#: tools/alignments/cli.py:31 msgid "" "Alignments tool\n" "This tool allows you to perform numerous actions on or using an alignments " @@ -34,16 +34,16 @@ msgstr "" "이 도구를 사용하면 해당 얼굴 세트/프레임 원본에 해당하는 alignments 파일을 사" "용하거나 여러 작업을 수행할 수 있습니다." -#: tools/alignments/cli.py:44 +#: tools/alignments/cli.py:43 msgid " Must Pass in a frames folder/source video file (-fr)." msgstr "" " 프레임들이 저장된 폴더나 원본 비디오 파일을 무조건 전달해야 합니다 (-fr)." -#: tools/alignments/cli.py:45 +#: tools/alignments/cli.py:44 msgid " Must Pass in a faces folder (-fc)." msgstr " 얼굴 폴더를 무조건 전달해야 합니다 (-fc)." -#: tools/alignments/cli.py:46 +#: tools/alignments/cli.py:45 msgid "" " Must Pass in either a frames folder/source video file OR a faces folder (-" "fr or -fc)." @@ -51,7 +51,7 @@ msgstr "" " 프레임 폴더나 원본 비디오 파일 또는 얼굴 폴더중 하나를 무조건 전달해야 합니" "다 (-fr and -fc)." -#: tools/alignments/cli.py:48 +#: tools/alignments/cli.py:47 msgid "" " Must Pass in a frames folder/source video file AND a faces folder (-fr and -" "fc)." @@ -59,11 +59,11 @@ msgstr "" " 프레임 폴더나 원본 비디오 파일 그리고 얼굴 폴더를 무조건 전달해야 합니다 (-" "fr and -fc)." -#: tools/alignments/cli.py:50 +#: tools/alignments/cli.py:49 msgid " Use the output option (-o) to process results." msgstr " 결과를 진행하려면 (-o) 출력 옵션을 사용하세요." -#: tools/alignments/cli.py:58 tools/alignments/cli.py:97 +#: tools/alignments/cli.py:57 tools/alignments/cli.py:97 msgid "processing" msgstr "처리" @@ -131,7 +131,7 @@ msgstr "" "L| 'spatial': 공간 및 시간 필터링을 수행하여 alignments를 원활하게 수행합니다" "(실험적!)." -#: tools/alignments/cli.py:99 +#: tools/alignments/cli.py:100 msgid "" "R|How to output discovered items ('faces' and 'frames' only):\n" "L|'console': Print the list of frames to the screen. (DEFAULT)\n" @@ -145,12 +145,12 @@ msgstr "" "L|'파일': 프레임 목록을 텍스트 파일(소스 디렉토리에 저장)로 출력합니다.\n" "L|'이동': 검색된 항목을 원본 디렉토리 내의 하위 폴더로 이동합니다." -#: tools/alignments/cli.py:110 tools/alignments/cli.py:123 -#: tools/alignments/cli.py:130 tools/alignments/cli.py:137 +#: tools/alignments/cli.py:111 tools/alignments/cli.py:134 +#: tools/alignments/cli.py:141 msgid "data" msgstr "데이터" -#: tools/alignments/cli.py:114 +#: tools/alignments/cli.py:118 msgid "" "Full path to the alignments file to be processed. If you have input a " "'frames_dir' and don't provide this option, the process will try to find the " @@ -163,15 +163,11 @@ msgstr "" "다. 지정된 얼굴 폴더에 alignments 파일이 생성될 때 모든 작업은 'from-" "faces'를 제외한 alignments 파일이 필요로 합니다." -#: tools/alignments/cli.py:124 -msgid "Directory containing extracted faces." -msgstr "추출된 얼굴들이 저장된 디렉토리." - -#: tools/alignments/cli.py:131 +#: tools/alignments/cli.py:135 msgid "Directory containing source frames that faces were extracted from." msgstr "얼굴 추출의 소스로 쓰인 원본 프레임이 저장된 디렉토리." -#: tools/alignments/cli.py:138 +#: tools/alignments/cli.py:143 msgid "" "R|Run the aligmnents tool on multiple sources. The following jobs support " "batch mode:\n" @@ -207,12 +203,12 @@ msgstr "" "지의 하위 폴더여야 합니다. 에. 정렬 파일은 기본 위치에 있어야 합니다. 다른 모" "든 작업의 경우 이 옵션은 무시됩니다." -#: tools/alignments/cli.py:164 tools/alignments/cli.py:175 -#: tools/alignments/cli.py:185 +#: tools/alignments/cli.py:169 tools/alignments/cli.py:181 +#: tools/alignments/cli.py:191 msgid "extract" msgstr "추출" -#: tools/alignments/cli.py:165 +#: tools/alignments/cli.py:171 msgid "" "[Extract only] Extract every 'nth' frame. This option will skip frames when " "extracting faces. For example a value of 1 will extract faces from every " @@ -222,11 +218,11 @@ msgstr "" "프레임을 건너뜁니다. 예를 들어, 값이 1이면 모든 프레임에서 얼굴이 추출되고, " "값이 10이면 모든 10번째 프레임에서 얼굴이 추출됩니다." -#: tools/alignments/cli.py:176 +#: tools/alignments/cli.py:182 msgid "[Extract only] The output size of extracted faces." msgstr "[Extract only] 추출된 얼굴들의 결과 크기입니다." -#: tools/alignments/cli.py:186 +#: tools/alignments/cli.py:193 msgid "" "[Extract only] Only extract faces that have been resized by this percent or " "more to meet the specified extract size (`-sz`, `--size`). Useful for " @@ -242,3 +238,6 @@ msgstr "" "512px인 경우, 50으로 설정하면 크기가 256px 이상인 면만 포함됩니다. 100으로 설" "정하면 512px 이상에서 크기가 조정된 얼굴만 추출됩니다. 200으로 설정하면 " "1024px 이상에서 축소된 얼굴만 추출됩니다." + +#~ msgid "Directory containing extracted faces." +#~ msgstr "추출된 얼굴들이 저장된 디렉토리." diff --git a/locales/kr/LC_MESSAGES/tools.effmpeg.cli.mo b/locales/kr/LC_MESSAGES/tools.effmpeg.cli.mo index 088d8326c2817181f6dc827ec440a81f02135284..f6f563913f86626bb5e99f21f316b303611d1b60 100644 GIT binary patch delta 266 zcmXZSJxjx26o%nDilR1E>lf-`c6s3?M!}#<5ut+^qFo$>fT3hD)_#DKu5Lm%S4YQC zTpS#A(n0VqbaruY@ew**=RW7W2ZP}5^Lp2TD;FZ*sSI`X#5BC9PiNpuuFb-z_CKRwFwz)lQ>jj`Pr)iVjVjcC%KNpPRj;ora!o{lM4_ W<8OP`hIVt^+Fs!foS?Wb_uW4j3NS+e delta 241 zcmaE1a^7UZm3kFM28K!=28I9z28MaO3=E+_{(B&O0LXXcXJEJvq}c=*7?^?L_keUM zkpD=Kfq@T5M+h-6oB-14Kspg9Zy?OT;0EMR5@BFC38ZfV>B&HPu_yyW5Rf((V_*+9hEyQqlLQ091t1+E3A7AIYf3RNZ~|!qAPwSML1{-Q?XkI&k(FCD zz+X2gwJftZGe1w)C9x#cO2Np$$Vk`FP}j&z!N}0c&{EsLa`Gvj7n`^6Zs7p{k7Fo8 diff --git a/locales/kr/LC_MESSAGES/tools.effmpeg.cli.po b/locales/kr/LC_MESSAGES/tools.effmpeg.cli.po index cfd72bb51d..58b106c585 100644 --- a/locales/kr/LC_MESSAGES/tools.effmpeg.cli.po +++ b/locales/kr/LC_MESSAGES/tools.effmpeg.cli.po @@ -5,8 +5,9 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2021-02-18 23:34-0000\n" -"PO-Revision-Date: 2022-11-26 21:19+0900\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-28 23:50+0000\n" +"PO-Revision-Date: 2024-03-29 00:05+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ko_KR\n" @@ -15,18 +16,18 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 3.2\n" +"X-Generator: Poedit 3.4.2\n" #: tools/effmpeg/cli.py:15 msgid "This command allows you to easily execute common ffmpeg tasks." msgstr "" "이 명령어는 사용자에게 일반 ffmpeg 작업을 쉽게 실행할 수 있도록 해줍니다." -#: tools/effmpeg/cli.py:24 +#: tools/effmpeg/cli.py:52 msgid "A wrapper for ffmpeg for performing image <> video converting." msgstr "이미지 <> 비디오 변환을 수행하기 위한 ffmpeg용 wrapper입니다." -#: tools/effmpeg/cli.py:51 +#: tools/effmpeg/cli.py:64 msgid "" "R|Choose which action you want ffmpeg ffmpeg to do.\n" "L|'extract': turns videos into images \n" @@ -48,15 +49,15 @@ msgstr "" "L|'rotate' 비디오 회전.\n" "L| 'slice'는 동영상의 일부를 별도의 동영상 파일로 잘라냅니다." -#: tools/effmpeg/cli.py:65 +#: tools/effmpeg/cli.py:78 msgid "Input file." msgstr "입력 파일." -#: tools/effmpeg/cli.py:66 tools/effmpeg/cli.py:73 tools/effmpeg/cli.py:87 +#: tools/effmpeg/cli.py:79 tools/effmpeg/cli.py:86 tools/effmpeg/cli.py:100 msgid "data" msgstr "데이터" -#: tools/effmpeg/cli.py:76 +#: tools/effmpeg/cli.py:89 msgid "" "Output file. If no output is specified then: if the output is meant to be a " "video then a video called 'out.mkv' will be created in the input directory; " @@ -69,16 +70,16 @@ msgstr "" "리 내에 'out'이라는 디렉터리가 생성됩니다. 참고: 선택한 출력 파일 확장자가 파" "일 인코딩을 결정합니다." -#: tools/effmpeg/cli.py:89 +#: tools/effmpeg/cli.py:102 msgid "Path to reference video if 'input' was not a video." msgstr "만약 input이 비디오가 아닐 경우 참고 비디으의 경로." -#: tools/effmpeg/cli.py:95 tools/effmpeg/cli.py:105 tools/effmpeg/cli.py:142 -#: tools/effmpeg/cli.py:171 +#: tools/effmpeg/cli.py:108 tools/effmpeg/cli.py:118 tools/effmpeg/cli.py:156 +#: tools/effmpeg/cli.py:185 msgid "output" msgstr "출력" -#: tools/effmpeg/cli.py:97 +#: tools/effmpeg/cli.py:110 msgid "" "Provide video fps. Can be an integer, float or fraction. Negative values " "will will make the program try to get the fps from the input or reference " @@ -87,7 +88,7 @@ msgstr "" "비디오 fps를 제공합니다. 정수, 부동 또는 분수가 될 수 있습니다. 음수 값을 지" "정하면 프로그램이 입력 또는 참조 비디오에서 fps를 가져오려고 합니다." -#: tools/effmpeg/cli.py:107 +#: tools/effmpeg/cli.py:120 msgid "" "Image format that extracted images should be saved as. '.bmp' will offer the " "fastest extraction speed, but will take the most storage space. '.png' will " @@ -97,11 +98,11 @@ msgstr "" "속도를 제공하지만 가장 많은 저장 공간을 차지합니다. '.png'은 속도는 더 느리지" "만 저장 공간은 더 적게 차지합니다." -#: tools/effmpeg/cli.py:114 tools/effmpeg/cli.py:123 tools/effmpeg/cli.py:132 +#: tools/effmpeg/cli.py:127 tools/effmpeg/cli.py:136 tools/effmpeg/cli.py:145 msgid "clip" msgstr "클립" -#: tools/effmpeg/cli.py:116 +#: tools/effmpeg/cli.py:129 msgid "" "Enter the start time from which an action is to be applied. Default: " "00:00:00, in HH:MM:SS format. You can also enter the time with or without " @@ -111,7 +112,7 @@ msgstr "" "콜론을 포함하거나 포함하지 않은 시간(예: 00:0000 또는 026010)을 입력할 수도 " "있습니다." -#: tools/effmpeg/cli.py:125 +#: tools/effmpeg/cli.py:138 msgid "" "Enter the end time to which an action is to be applied. If both an end time " "and duration are set, then the end time will be used and the duration will " @@ -120,7 +121,7 @@ msgstr "" "적용된 작업의 종료 시간을 입력합니다. 종료 시간과 기간이 모두 설정된 경우 종" "료 시간이 사용되고 기간이 무시됩니다. 기본값: 00:00:00, HH:MM:SS." -#: tools/effmpeg/cli.py:134 +#: tools/effmpeg/cli.py:147 msgid "" "Enter the duration of the chosen action, for example if you enter 00:00:10 " "for slice, then the first 10 seconds after and including the start time will " @@ -132,7 +133,7 @@ msgstr "" "MM:SS 형식입니다. 콜론을 포함하거나 포함하지 않은 시간(예: 00:0000 또는 " "026010)을 입력할 수도 있습니다." -#: tools/effmpeg/cli.py:144 +#: tools/effmpeg/cli.py:158 msgid "" "Mux the audio from the reference video into the input video. This option is " "only used for the 'gen-vid' action. 'mux-audio' action has this turned on " @@ -141,11 +142,11 @@ msgstr "" "참조 비디오의 오디오를 입력 비디오에 병합합니다. 이 옵션은 'gen-vid' 작업에" "만 사용됩니다. 'mux-timeout' 작업은 이 작업을 암시적으로 활성화했습니다." -#: tools/effmpeg/cli.py:155 tools/effmpeg/cli.py:165 +#: tools/effmpeg/cli.py:169 tools/effmpeg/cli.py:179 msgid "rotate" msgstr "회전" -#: tools/effmpeg/cli.py:157 +#: tools/effmpeg/cli.py:171 msgid "" "Transpose the video. If transpose is set, then degrees will be ignored. For " "cli you can enter either the number or the long command name, e.g. to use " @@ -155,19 +156,19 @@ msgstr "" "긴 명령 이름을 입력할 수 있습니다(예: (1, 90Clockwise) (-tr 1 또는 -tr " "90Clockwise)" -#: tools/effmpeg/cli.py:166 +#: tools/effmpeg/cli.py:180 msgid "Rotate the video clockwise by the given number of degrees." msgstr "비디오를 주어진 입력 각도에 따라 시계방향으로 회전합니다." -#: tools/effmpeg/cli.py:173 +#: tools/effmpeg/cli.py:187 msgid "Set the new resolution scale if the chosen action is 'rescale'." msgstr "선택한 작업이 'rescale'이라면 새로운 해상도 크기를 설정합니다." -#: tools/effmpeg/cli.py:178 tools/effmpeg/cli.py:186 +#: tools/effmpeg/cli.py:192 tools/effmpeg/cli.py:200 msgid "settings" msgstr "설정" -#: tools/effmpeg/cli.py:180 +#: tools/effmpeg/cli.py:194 msgid "" "Reduces output verbosity so that only serious errors are printed. If both " "quiet and verbose are set, verbose will override quiet." @@ -175,7 +176,7 @@ msgstr "" "출력 상세도를 줄여 심각한 오류만 출력합니다. quiet와 verbose가 모두 설정된 경" "우 verbose가 quiet를 재정의합니다." -#: tools/effmpeg/cli.py:188 +#: tools/effmpeg/cli.py:202 msgid "" "Increases output verbosity. If both quiet and verbose are set, verbose will " "override quiet." diff --git a/locales/kr/LC_MESSAGES/tools.manual.mo b/locales/kr/LC_MESSAGES/tools.manual.mo index 74eaf489c6f5ad13c1af0559805fab758880c11d..2a801da9a254a1a344b413e05b3a5c742e6e9e69 100644 GIT binary patch delta 401 zcmXZYF-SsD6vpvShakdym06*c5=0@79%(~S2oi-u1mPeQwJ2gpM%L8e6g39Y6mIPX z(hv>N7!*m1ZP8{!&>TelFFO71ch0@<9NtU36~8XH%1bV(=9Ts&eOb~wZuLo*)EE1u zA@bXR6vS`zpvTv)2auAY$WyI$OydtZj}w>~l#+OcX?(>gjQCa7B!`q=3gdCdDmKXX zXa=0Kg&A;#=H3k!Sg?tvzdRy!VH!8x(hg?HcL6DcVYWB@1WscT&38({b&0#WpfHLx zoWW;2#a~=u&audyfkNT~<$K%_`?iUXZ?X^-apRp51xsshK hRvx^=R?K#y_UwGfaTc7ph~rc~y)9R)-mw09{{iV7JB$DT delta 402 zcmXZYze_?<7{>8OgXjmAS{ap@VMZdi^cI0O+@TPnL2HVFAmI*z=up%WxC9Mt1wm9x zLu-O9F1EJS5Hz+l_y;sKMBhVBpYy!$Ip;l`w)5)L%bwbdM|vETDw5v)(kHG4q#Nq7 zpfpN;9+JZNfj<03Q}+%_EH#cC)qF<>f5-)l;l_xxj#b>oCeC3nq?%?~tc0X#EOtD_ zH}V6T0mn=;1J2RxyTvjOY@z8V$E0p_aMvs4&?VnbNKx$J^`@V|1g6m3vlGrs?9~Z{ zNxZ@Ze8MyQ!W@IjH1Eibh_r{BQ~y6v$6j&^&48Ci)7$Th7xLDYdsI3oRV&uo{-K+V j+9}(%(rL?Hj@paarA#uFx$w8U>^@!oMv9Gm;CkQ>tlKuc diff --git a/locales/kr/LC_MESSAGES/tools.manual.po b/locales/kr/LC_MESSAGES/tools.manual.po index 0d4f83d414..0fbcc99572 100644 --- a/locales/kr/LC_MESSAGES/tools.manual.po +++ b/locales/kr/LC_MESSAGES/tools.manual.po @@ -5,8 +5,9 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2022-11-24 14:17+0900\n" -"PO-Revision-Date: 2022-11-26 23:49+0900\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-28 23:55+0000\n" +"PO-Revision-Date: 2024-03-29 00:05+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ko_KR\n" @@ -15,9 +16,9 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 3.2\n" +"X-Generator: Poedit 3.4.2\n" -#: tools/manual\cli.py:13 +#: tools/manual/cli.py:13 msgid "" "This command lets you perform various actions on frames, faces and " "alignments files using visual tools." @@ -25,7 +26,7 @@ msgstr "" "이 명령어는 visual 도구들을 사용하여 프레임, 얼굴, alignments 파일들에 대한 " "다양한 작업을 수행할 수 있도록 해줍니다." -#: tools/manual\cli.py:23 +#: tools/manual/cli.py:23 msgid "" "A tool to perform various actions on frames, faces and alignments files " "using visual tools" @@ -33,33 +34,33 @@ msgstr "" "프레임, 얼굴, alignments 파일들에 대한 다양한 작업을 수행할 수 있도록 해주는 " "도구" -#: tools/manual\cli.py:35 tools/manual\cli.py:43 +#: tools/manual/cli.py:35 tools/manual/cli.py:44 msgid "data" msgstr "데이터" -#: tools/manual\cli.py:37 +#: tools/manual/cli.py:38 msgid "" "Path to the alignments file for the input, if not at the default location" msgstr "" "입력에 대한 alignments 파일의 경로, 만약 설정되지 않았다면 기본 경로입니다" -#: tools/manual\cli.py:44 +#: tools/manual/cli.py:46 msgid "" "Video file or directory containing source frames that faces were extracted " "from." msgstr "얼굴이 추출된 소스 프레임을 가지고 있는 비디오 파일 또는 디렉토리." -#: tools/manual\cli.py:51 tools/manual\cli.py:59 +#: tools/manual/cli.py:53 tools/manual/cli.py:62 msgid "options" msgstr "설정" -#: tools/manual\cli.py:52 +#: tools/manual/cli.py:55 msgid "" "Force regeneration of the low resolution jpg thumbnails in the alignments " "file." msgstr "_alignments 파일에서 저해상도 jpg 미리 보기를 강제로 재생성합니다." -#: tools/manual\cli.py:60 +#: tools/manual/cli.py:64 msgid "" "The process attempts to speed up generation of thumbnails by extracting from " "the video in parallel threads. For some videos, this causes the caching " diff --git a/locales/kr/LC_MESSAGES/tools.mask.cli.mo b/locales/kr/LC_MESSAGES/tools.mask.cli.mo index b400456c94c01de1483768af1f841dd261d418f2..89a197bb3557a0240156dd8d4d3411f45059bfe5 100644 GIT binary patch delta 21 ccmX?}cRX*y14#}eO9cZ1D+ANbFC^n-0b$_>)&Kwi delta 21 ccmX?}cRX*y14#};Lj@yaD^r8bFC^n-0b!^I(f|Me diff --git a/locales/kr/LC_MESSAGES/tools.mask.cli.po b/locales/kr/LC_MESSAGES/tools.mask.cli.po index 00ad1bed0a..ce2b631725 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: 2024-03-11 23:45+0000\n" -"PO-Revision-Date: 2024-03-11 23:50+0000\n" +"POT-Creation-Date: 2024-03-28 23:51+0000\n" +"PO-Revision-Date: 2024-03-29 00:05+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ko_KR\n" diff --git a/locales/kr/LC_MESSAGES/tools.model.cli.mo b/locales/kr/LC_MESSAGES/tools.model.cli.mo index a6a953c94c46151d127e1aa0c4b9029d823cadcf..9ccdfde6dcf4c9ca4c52a06ce39594af1b0cf5cd 100644 GIT binary patch delta 219 zcmbO(eoD0do)F7a1|VPuVi_O~0b*_-?g3&D*a5`0K)e%(gMs)v5Z`5FVDJWFcOcee zW?--ZiWRajFmM9tbwFAUD82(ogVbMVWnc&g(%Nhc3~o#e49!4#I#B);2LnR_1A`tz z5*GslP>kU)kOqn~0QE5d^?)4&G#dr5P24+g^H#=n%mOC52FAKZmI?+2RtBcp1_qOh SSx+*WOtxm*usML;odp0+)E!s= delta 416 zcmX|*PfJ2k5XHy*m$Zo>YSCh7^A@40EuttR7fCCF78Mm_y(<;=1hqx9C`cjDKzYgA zs2BwOfyj`ekC3g~MSX#H{SKX{;lPi>oVjP_ehwQWXTL*^GNJmx7}x>>paQJ=0P~;) zR)O0_v;p0O{(x5y0lJ-N4*s`;$O9d9Vjui*sFhoA5v@UWs0uZqOMMig6*rNAhXztR z7);_iW>kd2CgeN4^+DVq?C+Jk^i*4qYKap*P79Ue$rLLGT3n=rwkH%mOz!$PA~cbT zWdvvT1^0%0lvG8clL)gn#ETfEGa{yPGL8mLxVda=*aw5&K)@TEW`AIIDmdYv(LK(V z{^fk$R^;2Yc~e(d)=JXINuy}q6 A " +#| "instead of A -> B." msgid "" -"Only used for 'inference' job. Generate the inference model for B -> A " +"Only used for 'inference' job. Generate the inference model for B -> A " "instead of A -> B." msgstr "" "'추론' 작업에만 쓰입니다. A -> B 대신 B -> A에 대한 추론 모델을 생성합니다." diff --git a/locales/kr/LC_MESSAGES/tools.preview.mo b/locales/kr/LC_MESSAGES/tools.preview.mo index 20492ddcbfedf6526af0a8bf85c28120b7a4ab14..13d6841ba14e08ba94440d7fb1ecca8032812270 100644 GIT binary patch delta 384 zcmXxfJxjw-6b9foA5FBP1Y!}lAR&IrrRqYrR<)_0AN4FBO=iwgy;qiQ06N&e0vZ zM$f5EpJ_z_Ug;TYy8txk4GpMf08P40%k+u<-SbUn>4XW))0&BWj4|nOqeQRiBE6## zeW!!8IskB99-M(9*Kn`I5FO#-Tk_yvz&3Ce*p~Mbpj%NM6eEq(gYY!FR~_Rd&$E&y z3%zVZyIKjtX3|PUwH?I`v3eA>MLqGI;#N%r;c?uKlS8q-n})vQO4k!|LAc9~l)m)J gQeNtxir4+nN7ep?KgRU`z!zgCtD58egFW-{7eIAEhX4Qo delta 313 zcmXBPu}T9$5XSLu?=E*5F)Rd?REZ)H4LR=p&K@qI1(u5 B, swap B -> A" msgstr "모델을 스왑함. A -> B 대신, B -> A로 스왑함" -#: tools/preview\preview.py:1303 +#: tools/preview/control_panels.py:510 msgid "Save full config" msgstr "전체 설정을 저장" -#: tools/preview\preview.py:1306 +#: tools/preview/control_panels.py:513 msgid "Reset full config to default values" msgstr "전체 설정을 기본 값으로 초기화" -#: tools/preview\preview.py:1309 +#: tools/preview/control_panels.py:516 msgid "Reset full config to saved values" msgstr "전체 설정을 저장된 값으로 초기화" -#: tools/preview\preview.py:1453 -msgid "Save {} config" -msgstr "{} 설정 저장" +#: tools/preview/control_panels.py:667 +#, python-brace-format +msgid "Save {title} config" +msgstr "{title} 설정 저장" -#: tools/preview\preview.py:1456 -msgid "Reset {} config to default values" -msgstr "{} 설정을 기본 값으로 초기화" +#: tools/preview/control_panels.py:670 +#, python-brace-format +msgid "Reset {title} config to default values" +msgstr "{title} 설정을 기본 값으로 초기화" -#: tools/preview\preview.py:1459 -msgid "Reset {} config to saved values" -msgstr "{} 설정을 저장된 값으로 초기화" +#: tools/preview/control_panels.py:673 +#, python-brace-format +msgid "Reset {title} config to saved values" +msgstr "{title} 설정을 저장된 값으로 초기화" diff --git a/locales/kr/LC_MESSAGES/tools.sort.cli.mo b/locales/kr/LC_MESSAGES/tools.sort.cli.mo index 979f13a2fe820d03c6212533594ee31b73121f03..39509c675d65644f571db4fbc8b371feb308a997 100644 GIT binary patch delta 902 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 Qj9z6jF@|rf?5+Ik|54E(rvLx| diff --git a/locales/kr/LC_MESSAGES/tools.sort.cli.po b/locales/kr/LC_MESSAGES/tools.sort.cli.po index 813a6e29b6..19d99c1628 100644 --- a/locales/kr/LC_MESSAGES/tools.sort.cli.po +++ b/locales/kr/LC_MESSAGES/tools.sort.cli.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-11-24 14:19+0900\n" -"PO-Revision-Date: 2022-11-27 03:43+0900\n" +"POT-Creation-Date: 2024-03-28 23:53+0000\n" +"PO-Revision-Date: 2024-03-29 00:04+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ko_KR\n" @@ -16,19 +16,19 @@ 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.2\n" +"X-Generator: Poedit 3.4.2\n" -#: tools/sort/cli.py:14 +#: tools/sort/cli.py:15 msgid "This command lets you sort images using various methods." msgstr "이 명령어는 다양한 메소드를 이용하여 이미지를 정렬해줍니다." -#: tools/sort/cli.py:20 +#: tools/sort/cli.py:21 msgid "" " Adjust the '-t' ('--threshold') parameter to control the strength of " "grouping." msgstr " 그룹화의 강도를 제어하기 위해 '-t' ('--threshold') 인자를 조정하세요." -#: tools/sort/cli.py:21 +#: tools/sort/cli.py:22 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. Each image is allocated to a bin by the percentage of color pixels " @@ -37,7 +37,7 @@ msgstr "" " '-b'('--bins') 매개 변수를 조정하여 그룹화할 bins의 수를 제어합니다. 각 이미" "지는 이미지에 나타나는 색상 픽셀의 백분율에 따라 bin에 할당됩니다." -#: tools/sort/cli.py:24 +#: tools/sort/cli.py:25 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. Each image is allocated to a bin by the number of degrees the face " @@ -46,7 +46,7 @@ msgstr "" " '-b'('--bins') 매개 변수를 조정하여 그룹화할 bins의 수를 제어합니다. 각 이미" "지는 얼굴이 이미지 중심에서 떨어진 각도에 따라 bin에 할당됩니다." -#: tools/sort/cli.py:27 +#: tools/sort/cli.py:28 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. The minimum and maximum values are taken for the chosen sort " @@ -56,15 +56,15 @@ msgstr "" "정렬 방법에 대해 최소값과 최대값이 사용됩니다. 그런 다음 bins가 그룹 정렬의 " "결과로 채워집니다." -#: tools/sort/cli.py:31 +#: tools/sort/cli.py:32 msgid "faces by blurriness." msgstr "흐릿한 얼굴." -#: tools/sort/cli.py:32 +#: tools/sort/cli.py:33 msgid "faces by fft filtered blurriness." msgstr "fft 필터링된 흐릿한 얼굴." -#: tools/sort/cli.py:33 +#: tools/sort/cli.py:34 msgid "" "faces by the estimated distance of the alignments from an 'average' face. " "This can be useful for eliminating misaligned faces. Sorts from most like an " @@ -74,7 +74,7 @@ msgstr "" "된 얼굴을 제거하는 데 유용할 수 있습니다. 가장 평균 얼굴에서 가장 덜 평균 얼" "굴순으로 정렬합니다." -#: tools/sort/cli.py:36 +#: tools/sort/cli.py:37 msgid "" "faces using VGG Face2 by face similarity. This uses a pairwise clustering " "algorithm to check the distances between 512 features on every face in your " @@ -84,23 +84,23 @@ msgstr "" "알고리즘을 사용하여 세트의 모든 얼굴에서 512개의 특징 사이의 거리를 확인하고 " "적절하게 정렬합니다." -#: tools/sort/cli.py:39 +#: tools/sort/cli.py:40 msgid "faces by their landmarks." msgstr "특징점이 있는 얼굴." -#: tools/sort/cli.py:40 +#: tools/sort/cli.py:41 msgid "Like 'face-cnn' but sorts by dissimilarity." msgstr "'face-cnn'과 비슷하지만 비유사성에 따라 정렬된." -#: tools/sort/cli.py:41 +#: tools/sort/cli.py:42 msgid "faces by Yaw (rotation left to right)." msgstr "yaw (왼쪽에서 오른쪽으로 회전)에 의한 얼굴." -#: tools/sort/cli.py:42 +#: tools/sort/cli.py:43 msgid "faces by Pitch (rotation up and down)." msgstr "pitch (위에서 아래로 회전)에 의한 얼굴." -#: tools/sort/cli.py:43 +#: tools/sort/cli.py:44 msgid "" "faces by Roll (rotation). Aligned faces should have a roll value close to " "zero. The further the Roll value from zero the higher liklihood the face is " @@ -109,20 +109,20 @@ msgstr "" "이동 (회전)에 의한 얼굴. 정렬된 얼굴들은 0에 가까운 이동 값을 가져야 한다. 이" "동 값이 0에서 멀수록 얼굴들이 잘못 정렬되었을 가능성이 높습니다." -#: tools/sort/cli.py:45 +#: tools/sort/cli.py:46 msgid "faces by their color histogram." msgstr "색상 히스토그램에 의한 얼굴." -#: tools/sort/cli.py:46 +#: tools/sort/cli.py:47 msgid "Like 'hist' but sorts by dissimilarity." msgstr "'hist' 같지만 비유사성에 따라 정렬된." -#: tools/sort/cli.py:47 +#: tools/sort/cli.py:48 msgid "" "images by the average intensity of the converted grayscale color channel." msgstr "변환된 회색 계열 색상 채널의 평균 강도에 따른 이미지." -#: tools/sort/cli.py:48 +#: tools/sort/cli.py:49 msgid "" "images by their number of black pixels. Useful when faces are near borders " "and a large part of the image is black." @@ -130,7 +130,7 @@ msgstr "" "검은색 픽셀의 개수에 따른 이미지들. 얼굴이 테두리 근처에 있고 이미지의 대부분" "이 검은색일 때 유용합니다." -#: tools/sort/cli.py:50 +#: tools/sort/cli.py:51 msgid "" "images by the average intensity of the converted Y color channel. Bright " "lighting and oversaturated images will be ranked first." @@ -138,7 +138,7 @@ msgstr "" "변환된 Y 색상 채널의 평균 강도를 기준으로 한 이미지. 밝은 조명과 과포화 이미" "지가 1위를 차지할 것이다." -#: tools/sort/cli.py:52 +#: tools/sort/cli.py:53 msgid "" "images by the average intensity of the converted Cg color channel. Green " "images will be ranked first and red images will be last." @@ -146,7 +146,7 @@ msgstr "" "변환된 Cg 컬러 채널의 평균 강도를 기준으로 한 이미지. 녹색 이미지가 먼저 순위" "가 매겨지고 빨간색 이미지가 마지막 순위가 됩니다." -#: tools/sort/cli.py:54 +#: tools/sort/cli.py:55 msgid "" "images by the average intensity of the converted Co color channel. Orange " "images will be ranked first and blue images will be last." @@ -154,7 +154,7 @@ msgstr "" "변환된 Co 색상 채널의 평균 강도를 기준으로 한 이미지. 주황색 이미지가 먼저 순" "위가 매겨지고 파란색 이미지가 마지막 순위가 됩니다." -#: tools/sort/cli.py:56 +#: tools/sort/cli.py:57 msgid "" "images by their size in the original frame. Faces further from the camera " "and from lower resolution sources will be sorted first, whilst faces closer " @@ -164,24 +164,16 @@ msgstr "" "해상도 원본에서 온 얼굴이 먼저 정렬되고, 카메라에 더 가까이 있고 고해상도 원" "본에서 온 얼굴이 마지막으로 정렬됩니다." -#: tools/sort/cli.py:59 -msgid " option is deprecated. Use 'yaw'" -msgstr " 이 옵션은 더 이상 사용되지 않습니다. 'yaw'를 사용하세요" - -#: tools/sort/cli.py:60 -msgid " option is deprecated. Use 'color-black'" -msgstr " 이 옵션은 더 이상 사용되지 않습니다. 'color-black'을 사용하세요" - -#: tools/sort/cli.py:82 +#: tools/sort/cli.py:81 msgid "Sort faces using a number of different techniques" msgstr "얼굴을 정렬하는데 사용되는 서로 다른 기술들의 개수" -#: tools/sort/cli.py:92 tools/sort/cli.py:99 tools/sort/cli.py:110 -#: tools/sort/cli.py:148 +#: tools/sort/cli.py:91 tools/sort/cli.py:98 tools/sort/cli.py:110 +#: tools/sort/cli.py:150 msgid "data" msgstr "데이터" -#: tools/sort/cli.py:93 +#: tools/sort/cli.py:92 msgid "Input directory of aligned faces." msgstr "정렬된 얼굴들의 입력 디렉토리." @@ -198,7 +190,7 @@ msgstr "" "다. 제공되지 않고 'keep'을 선택하지 않으면 이미지가 제자리에 정렬되어 " "'input_dir'의 원래 내용을 덮어씁니다." -#: tools/sort/cli.py:111 +#: tools/sort/cli.py:112 msgid "" "R|If selected then the input_dir should be a parent folder containing " "multiple folders of faces you wish to sort. The faces will be output to " @@ -207,11 +199,11 @@ msgstr "" "R|선택되면 input_dir는 정렬할 여러 개의 얼굴 폴더를 포함하는 상위 폴더여야 합" "니다. 얼굴은 output_dir의 별도 하위 폴더로 출력됩니다" -#: tools/sort/cli.py:120 +#: tools/sort/cli.py:121 msgid "sort settings" msgstr "정렬 설정" -#: tools/sort/cli.py:122 +#: tools/sort/cli.py:124 msgid "" "R|Choose how images are sorted. Selecting a sort method gives the images a " "new filename based on the order the image appears within the given method.\n" @@ -227,17 +219,25 @@ msgstr "" "유지합니다. 'sort-by' 및 'group-by' 모두에 대해 'none'을 선택해도 아무 효과" "가 없습니다" -#: tools/sort/cli.py:135 tools/sort/cli.py:162 tools/sort/cli.py:191 +#: tools/sort/cli.py:136 tools/sort/cli.py:164 tools/sort/cli.py:184 msgid "group settings" msgstr "그룹 설정" -#: tools/sort/cli.py:137 +#: tools/sort/cli.py:139 +#, fuzzy +#| msgid "" +#| "R|Selecting a group by method will move/copy files into numbered bins " +#| "based on the selected method.\n" +#| "L|'none': Don't bin the images. Folders will be sorted by the selected " +#| "'sort-by' but will not be binned, instead they will be sorted into a " +#| "single folder. Selecting 'none' for both 'sort-by' and 'group-by' will " +#| "do nothing" msgid "" "R|Selecting a group by method will move/copy files into numbered bins based " "on the selected method.\n" "L|'none': Don't bin the images. Folders will be sorted by the selected 'sort-" "by' but will not be binned, instead they will be sorted into a single " -"folder. Selecting 'none' for both 'sort-by' and 'group-by' will do nothing" +"folder. Selecting 'none' for both 'sort-by' and 'group-by' will do nothing" msgstr "" "R|방법별로 그룹을 선택하면 선택한 방법에 따라 파일이 번호가 매겨진 빈으로 이" "동/복사됩니다.\n" @@ -245,7 +245,7 @@ msgstr "" "만 버려지진 않고 단일 폴더로 정렬됩니다. 'sort-by' 및 'group-by' 모두에 대해 " "'none'을 선택해도 아무 효과가 없습니다" -#: tools/sort/cli.py:149 +#: tools/sort/cli.py:152 msgid "" "Whether to keep the original files in their original location. Choosing a " "'sort-by' method means that the files have to be renamed. Selecting 'keep' " @@ -259,7 +259,7 @@ msgstr "" "된 파일이 지정된 출력 폴더에 생성됩니다. keep을 선택취소하면 선택한 정렬/그" "룹 기준에 따라 원래 파일이 이동되고 이름이 변경됩니다." -#: tools/sort/cli.py:164 +#: tools/sort/cli.py:167 msgid "" "R|Float value. Minimum threshold to use for grouping comparison with 'face-" "cnn' 'hist' and 'face' methods.\n" @@ -284,20 +284,29 @@ msgstr "" "이미지가 많은 디렉터리에서 너무 극단적인 값을 설정하면 폴더가 많이 생성될 수 " "있으므로 주의하십시오. 기본값: face-cnn 7.2, hist 0.3, face 0.25" -#: tools/sort/cli.py:181 -msgid "output" -msgstr "출력" - -#: tools/sort/cli.py:182 -msgid "" -"Deprecated and no longer used. The final processing will be dictated by the " -"sort/group by methods and whether 'keep_original' is selected." -msgstr "" -"폐기되었고 더 이상 사용되지 않습니다. 최종 처리는 sort/group-by 메서드와 " -"'keep_original'이 선택되었는지 여부에 의해 결정됩니다." - -#: tools/sort/cli.py:193 -#, python-format +#: tools/sort/cli.py:187 +#, fuzzy, python-format +#| msgid "" +#| "R|Integer value. Used to control the number of bins created for grouping " +#| "by: any 'blur' methods, 'color' methods or 'face metric' methods " +#| "('distance', 'size') and 'orientation; methods ('yaw', 'pitch'). For any " +#| "other grouping methods see the '-t' ('--threshold') option.\n" +#| "L|For 'face metric' methods the bins are filled, according the the " +#| "distribution of faces between the minimum and maximum chosen metric.\n" +#| "L|For 'color' methods the number of bins represents the divider of the " +#| "percentage of colored pixels. Eg. For a bin number of '5': The first " +#| "folder will have the faces with 0%% to 20%% colored pixels, second 21%% " +#| "to 40%%, etc. Any empty bins will be deleted, so you may end up with " +#| "fewer bins than selected.\n" +#| "L|For 'blur' methods folder 0 will be the least blurry, while the last " +#| "folder will be the blurriest.\n" +#| "L|For 'orientation' methods the number of bins is dictated by how much " +#| "180 degrees is divided. Eg. If 18 is selected, then each folder will be a " +#| "10 degree increment. Folder 0 will contain faces looking the most to the " +#| "left/down whereas the last folder will contain the faces looking the most " +#| "to the right/up. NB: Some bins may be empty if faces do not fit the " +#| "criteria.\n" +#| "Default value: 5" msgid "" "R|Integer value. Used to control the number of bins created for grouping by: " "any 'blur' methods, 'color' methods or 'face metric' methods ('distance', " @@ -316,7 +325,7 @@ msgid "" "degrees is divided. Eg. If 18 is selected, then each folder will be a 10 " "degree increment. Folder 0 will contain faces looking the most to the left/" "down whereas the last folder will contain the faces looking the most to the " -"right/up. NB: Some bins may be empty if faces do not fit the criteria.\n" +"right/up. NB: Some bins may be empty if faces do not fit the criteria. \n" "Default value: 5" msgstr "" "R| 정수 값. 그룹화를 위해 생성된 bins의 수를 제어하는 데 사용됩니다. 임의의 " @@ -337,11 +346,11 @@ msgstr "" "니다. 주의: 얼굴이 기준에 맞지 않으면 일부 bins가 비어 있을 수 있습니다.\n" "기본값: 5" -#: tools/sort/cli.py:215 tools/sort/cli.py:225 +#: tools/sort/cli.py:207 tools/sort/cli.py:217 msgid "settings" msgstr "설정" -#: tools/sort/cli.py:217 +#: tools/sort/cli.py:210 msgid "" "Logs file renaming changes if grouping by renaming, or it logs the file " "copying/movement if grouping by folders. If no log file is specified with " @@ -352,7 +361,7 @@ msgstr "" "로 그룹화하는 경우 파일 복사/이동을 기록합니다. '--log-file'로 로그 파일을 지" "정하지 않으면 'sort_log.json' 파일이 입력 디렉토리에 생성됩니다." -#: tools/sort/cli.py:228 +#: tools/sort/cli.py:221 msgid "" "Specify a log file to use for saving the renaming or grouping information. " "If specified extension isn't 'json' or 'yaml', then json will be used as the " @@ -361,3 +370,19 @@ msgstr "" "_renaming 또는 grouping 정보를 저장하는 데 사용할 로그 파일을 지정합니다. 지" "정된 확장자가 'json' 또는 'yaml'이 아니면 json이 제공된 파일 이름과 함께 직렬" "화기로 사용됩니다. 기본값: sort_log.json" + +#~ msgid " option is deprecated. Use 'yaw'" +#~ msgstr " 이 옵션은 더 이상 사용되지 않습니다. 'yaw'를 사용하세요" + +#~ msgid " option is deprecated. Use 'color-black'" +#~ msgstr " 이 옵션은 더 이상 사용되지 않습니다. 'color-black'을 사용하세요" + +#~ msgid "output" +#~ msgstr "출력" + +#~ msgid "" +#~ "Deprecated and no longer used. The final processing will be dictated by " +#~ "the sort/group by methods and whether 'keep_original' is selected." +#~ msgstr "" +#~ "폐기되었고 더 이상 사용되지 않습니다. 최종 처리는 sort/group-by 메서드와 " +#~ "'keep_original'이 선택되었는지 여부에 의해 결정됩니다." diff --git a/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index b2667c1f47..03a77c5a74 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-25 16:09+0100\n" +"POT-Creation-Date: 2024-03-28 18:06+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,12 +17,12 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: lib/cli/args.py:192 lib/cli/args.py:202 lib/cli/args.py:210 -#: lib/cli/args.py:220 +#: lib/cli/args.py:188 lib/cli/args.py:199 lib/cli/args.py:208 +#: lib/cli/args.py:219 msgid "Global Options" msgstr "" -#: lib/cli/args.py:193 +#: lib/cli/args.py:190 msgid "" "R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " "to any GPU(s) that you do not wish to be made available to Faceswap. " @@ -30,688 +30,21 @@ msgid "" "L|{}" msgstr "" -#: lib/cli/args.py:203 +#: lib/cli/args.py:201 msgid "" "Optionally overide the saved config with the path to a custom config file." msgstr "" -#: lib/cli/args.py:211 +#: lib/cli/args.py:210 msgid "" "Log level. Stick with INFO or VERBOSE unless you need to file an error " "report. Be careful with TRACE as it will generate a lot of data" msgstr "" -#: lib/cli/args.py:221 +#: lib/cli/args.py:220 msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "" -#: lib/cli/args.py:319 lib/cli/args.py:328 lib/cli/args.py:336 -#: lib/cli/args.py:385 lib/cli/args.py:676 lib/cli/args.py:685 -msgid "Data" -msgstr "" - -#: lib/cli/args.py:320 -msgid "" -"Input directory or video. Either a directory containing the image files you " -"wish to process or path to a video file. NB: This should be the source video/" -"frames NOT the source faces." -msgstr "" - -#: lib/cli/args.py:329 -msgid "Output directory. This is where the converted files will be saved." -msgstr "" - -#: lib/cli/args.py:337 -msgid "" -"Optional path to an alignments file. Leave blank if the alignments file is " -"at the default location." -msgstr "" - -#: lib/cli/args.py:360 -msgid "" -"Extract faces from image or video sources.\n" -"Extraction plugins can be configured in the 'Settings' Menu" -msgstr "" - -#: lib/cli/args.py:386 -msgid "" -"R|If selected then the input_dir should be a parent folder containing " -"multiple videos and/or folders of images you wish to extract from. The faces " -"will be output to separate sub-folders in the output_dir." -msgstr "" - -#: lib/cli/args.py:395 lib/cli/args.py:411 lib/cli/args.py:423 -#: lib/cli/args.py:462 lib/cli/args.py:480 lib/cli/args.py:492 -#: lib/cli/args.py:501 lib/cli/args.py:510 lib/cli/args.py:695 -#: lib/cli/args.py:722 lib/cli/args.py:760 -msgid "Plugins" -msgstr "" - -#: lib/cli/args.py:396 -msgid "" -"R|Detector to use. Some of these have configurable settings in '/config/" -"extract.ini' or 'Settings > Configure Extract 'Plugins':\n" -"L|cv2-dnn: A CPU only extractor which is the least reliable and least " -"resource intensive. Use this if not using a GPU and time is important.\n" -"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " -"than other GPU detectors but can often return more false positives.\n" -"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " -"fewer false positives than other GPU detectors, but is a lot more resource " -"intensive." -msgstr "" - -#: lib/cli/args.py:412 -msgid "" -"R|Aligner to use.\n" -"L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " -"but less accurate. Only use this if not using a GPU and time is important.\n" -"L|fan: Best aligner. Fast on GPU, slow on CPU." -msgstr "" - -#: lib/cli/args.py:424 -msgid "" -"R|Additional Masker(s) to use. The masks generated here will all take up GPU " -"RAM. You can select none, one or multiple masks, but the extraction may take " -"longer the more you select. NB: The Extended and Components (landmark based) " -"masks are automatically generated on extraction.\n" -"L|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).\n" -"L|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 so will not use any additional VRAM.\n" -"L|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.\n" -"L|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.\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.\n" -"The auto generated masks are as follows:\n" -"L|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.\n" -"L|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.\n" -"(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -msgstr "" - -#: lib/cli/args.py:463 -msgid "" -"R|Performing normalization can help the aligner better align faces with " -"difficult lighting conditions at an extraction speed cost. Different methods " -"will yield different results on different sets. NB: This does not impact the " -"output face, just the input to the aligner.\n" -"L|none: Don't perform normalization on the face.\n" -"L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " -"face.\n" -"L|hist: Equalize the histograms on the RGB channels.\n" -"L|mean: Normalize the face colors to the mean." -msgstr "" - -#: lib/cli/args.py:481 -msgid "" -"The number of times to re-feed the detected face into the aligner. Each time " -"the face is re-fed into the aligner the bounding box is adjusted by a small " -"amount. The final landmarks are then averaged from each iteration. Helps to " -"remove 'micro-jitter' but at the cost of slower extraction speed. The more " -"times the face is re-fed into the aligner, the less micro-jitter should " -"occur but the longer extraction will take." -msgstr "" - -#: lib/cli/args.py:493 -msgid "" -"Re-feed the initially found aligned face through the aligner. Can help " -"produce better alignments for faces that are rotated beyond 45 degrees in " -"the frame or are at extreme angles. Slows down extraction." -msgstr "" - -#: lib/cli/args.py:502 -msgid "" -"If a face isn't found, rotate the images to try to find a face. Can find " -"more faces at the cost of extraction speed. Pass in a single number to use " -"increments of that size up to 360, or pass in a list of numbers to enumerate " -"exactly what angles to check." -msgstr "" - -#: lib/cli/args.py:511 -msgid "" -"Obtain and store face identity encodings from VGGFace2. Slows down extract a " -"little, but will save time if using 'sort by face'" -msgstr "" - -#: lib/cli/args.py:521 lib/cli/args.py:531 lib/cli/args.py:543 -#: lib/cli/args.py:556 lib/cli/args.py:804 lib/cli/args.py:812 -#: lib/cli/args.py:826 lib/cli/args.py:839 lib/cli/args.py:853 -msgid "Face Processing" -msgstr "" - -#: lib/cli/args.py:522 -msgid "" -"Filters out faces detected below this size. Length, in pixels across the " -"diagonal of the bounding box. Set to 0 for off" -msgstr "" - -#: lib/cli/args.py:532 -msgid "" -"Optionally filter out people who you do not wish to extract by passing in " -"images of those people. Should be a small variety of images at different " -"angles and in different conditions. A folder containing the required images " -"or multiple image files, space separated, can be selected." -msgstr "" - -#: lib/cli/args.py:544 -msgid "" -"Optionally select people you wish to extract by passing in images of that " -"person. Should be a small variety of images at different angles and in " -"different conditions A folder containing the required images or multiple " -"image files, space separated, can be selected." -msgstr "" - -#: lib/cli/args.py:557 -msgid "" -"For use with the optional nfilter/filter files. Threshold for positive face " -"recognition. Higher values are stricter." -msgstr "" - -#: lib/cli/args.py:566 lib/cli/args.py:578 lib/cli/args.py:590 -#: lib/cli/args.py:602 -msgid "output" -msgstr "" - -#: lib/cli/args.py:567 -msgid "" -"The output size of extracted faces. Make sure that the model you intend to " -"train supports your required size. This will only need to be changed for hi-" -"res models." -msgstr "" - -#: lib/cli/args.py:579 -msgid "" -"Extract every 'nth' frame. This option will skip frames when extracting " -"faces. For example a value of 1 will extract faces from every frame, a value " -"of 10 will extract faces from every 10th frame." -msgstr "" - -#: lib/cli/args.py:591 -msgid "" -"Automatically save the alignments file after a set amount of frames. By " -"default the alignments file is only saved at the end of the extraction " -"process. NB: If extracting in 2 passes then the alignments file will only " -"start to be saved out during the second pass. WARNING: Don't interrupt the " -"script when writing the file because it might get corrupted. Set to 0 to " -"turn off" -msgstr "" - -#: lib/cli/args.py:603 -msgid "Draw landmarks on the ouput faces for debugging purposes." -msgstr "" - -#: lib/cli/args.py:609 lib/cli/args.py:618 lib/cli/args.py:626 -#: lib/cli/args.py:633 lib/cli/args.py:866 lib/cli/args.py:877 -#: lib/cli/args.py:885 lib/cli/args.py:904 lib/cli/args.py:910 -msgid "settings" -msgstr "" - -#: lib/cli/args.py:610 -msgid "" -"Don't run extraction in parallel. Will run each part of the extraction " -"process separately (one after the other) rather than all at the same time. " -"Useful if VRAM is at a premium." -msgstr "" - -#: lib/cli/args.py:619 -msgid "" -"Skips frames that have already been extracted and exist in the alignments " -"file" -msgstr "" - -#: lib/cli/args.py:627 -msgid "Skip frames that already have detected faces in the alignments file" -msgstr "" - -#: lib/cli/args.py:634 -msgid "Skip saving the detected faces to disk. Just create an alignments file" -msgstr "" - -#: lib/cli/args.py:656 -msgid "" -"Swap the original faces in a source video/images to your final faces.\n" -"Conversion plugins can be configured in the 'Settings' Menu" -msgstr "" - -#: lib/cli/args.py:677 -msgid "" -"Only required if converting from images to video. Provide The original video " -"that the source frames were extracted from (for extracting the fps and " -"audio)." -msgstr "" - -#: lib/cli/args.py:686 -msgid "" -"Model directory. The directory containing the trained model you wish to use " -"for conversion." -msgstr "" - -#: lib/cli/args.py:696 -msgid "" -"R|Performs color adjustment to the swapped face. Some of these options have " -"configurable settings in '/config/convert.ini' or 'Settings > Configure " -"Convert Plugins':\n" -"L|avg-color: Adjust the mean of each color channel in the swapped " -"reconstruction to equal the mean of the masked area in the original image.\n" -"L|color-transfer: Transfers the color distribution from the source to the " -"target image using the mean and standard deviations of the L*a*b* color " -"space.\n" -"L|manual-balance: Manually adjust the balance of the image in a variety of " -"color spaces. Best used with the Preview tool to set correct values.\n" -"L|match-hist: Adjust the histogram of each color channel in the swapped " -"reconstruction to equal the histogram of the masked area in the original " -"image.\n" -"L|seamless-clone: Use cv2's seamless clone function to remove extreme " -"gradients at the mask seam by smoothing colors. Generally does not give very " -"satisfactory results.\n" -"L|none: Don't perform color adjustment." -msgstr "" - -#: lib/cli/args.py:723 -msgid "" -"R|Masker to use. NB: The mask you require must exist within the alignments " -"file. You can add additional masks with the Mask Tool.\n" -"L|none: Don't use a mask.\n" -"L|bisenet-fp_face: Relatively lightweight NN based mask that provides more " -"refined control over the area to be masked (configurable in mask settings). " -"Use this version of bisenet-fp if your model is trained with 'face' or " -"'legacy' centering.\n" -"L|bisenet-fp_head: Relatively lightweight NN based mask that provides more " -"refined control over the area to be masked (configurable in mask settings). " -"Use this version of bisenet-fp if your model is trained with 'head' " -"centering.\n" -"L|custom_face: Custom user created, face centered mask.\n" -"L|custom_head: Custom user created, head centered mask.\n" -"L|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.\n" -"L|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.\n" -"L|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.\n" -"L|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.\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.\n" -"L|predicted: If the 'Learn Mask' option was enabled during training, this " -"will use the mask that was created by the trained model." -msgstr "" - -#: lib/cli/args.py:761 -msgid "" -"R|The plugin to use to output the converted images. The writers are " -"configurable in '/config/convert.ini' or 'Settings > Configure Convert " -"Plugins:'\n" -"L|ffmpeg: [video] Writes out the convert straight to video. When the input " -"is a series of images then the '-ref' (--reference-video) parameter must be " -"set.\n" -"L|gif: [animated image] Create an animated gif.\n" -"L|opencv: [images] The fastest image writer, but less options and formats " -"than other plugins.\n" -"L|patch: [images] Outputs the raw swapped face patch, along with the " -"transformation matrix required to re-insert the face back into the original " -"frame. Use this option if you wish to post-process and composite the final " -"face within external tools.\n" -"L|pillow: [images] Slower than opencv, but has more options and supports " -"more formats." -msgstr "" - -#: lib/cli/args.py:784 lib/cli/args.py:791 lib/cli/args.py:896 -msgid "Frame Processing" -msgstr "" - -#: lib/cli/args.py:785 -#, python-format -msgid "" -"Scale the final output frames by this amount. 100%% will output the frames " -"at source dimensions. 50%% at half size 200%% at double size" -msgstr "" - -#: lib/cli/args.py:792 -msgid "" -"Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " -"--frame-ranges 10-50 90-100. Frames falling outside of the selected range " -"will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " -"converting from images, then the filenames must end with the frame-number!" -msgstr "" - -#: lib/cli/args.py:805 -msgid "" -"Scale the swapped face by this percentage. Positive values will enlarge the " -"face, Negative values will shrink the face." -msgstr "" - -#: lib/cli/args.py:813 -msgid "" -"If you have not cleansed your alignments file, then you can filter out faces " -"by defining a folder here that contains the faces extracted from your input " -"files/video. If this folder is defined, then only faces that exist within " -"your alignments file and also exist within the specified folder will be " -"converted. Leaving this blank will convert all faces that exist within the " -"alignments file." -msgstr "" - -#: lib/cli/args.py:827 -msgid "" -"Optionally filter out people who you do not wish to process by passing in an " -"image of that person. Should be a front portrait with a single person in the " -"image. Multiple images can be added space separated. NB: Using face filter " -"will significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." -msgstr "" - -#: lib/cli/args.py:840 -msgid "" -"Optionally select people you wish to process by passing in an image of that " -"person. Should be a front portrait with a single person in the image. " -"Multiple images can be added space separated. NB: Using face filter will " -"significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." -msgstr "" - -#: lib/cli/args.py:854 -msgid "" -"For use with the optional nfilter/filter files. Threshold for positive face " -"recognition. Lower values are stricter. NB: Using face filter will " -"significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." -msgstr "" - -#: lib/cli/args.py:867 -msgid "" -"The maximum number of parallel processes for performing conversion. " -"Converting images is system RAM heavy so it is possible to run out of memory " -"if you have a lot of processes and not enough RAM to accommodate them all. " -"Setting this to 0 will use the maximum available. No matter what you set " -"this to, it will never attempt to use more processes than are available on " -"your system. If singleprocess is enabled this setting will be ignored." -msgstr "" - -#: lib/cli/args.py:878 -msgid "" -"[LEGACY] This only needs to be selected if a legacy model is being loaded or " -"if there are multiple models in the model folder" -msgstr "" - -#: lib/cli/args.py:886 -msgid "" -"Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " -"alignments file for your destination video. However, if you wish you can " -"generate the alignments on-the-fly by enabling this option. This will use an " -"inferior extraction pipeline and will lead to substandard results. If an " -"alignments file is found, this option will be ignored." -msgstr "" - -#: lib/cli/args.py:897 -msgid "" -"When used with --frame-ranges outputs the unchanged frames that are not " -"processed instead of discarding them." -msgstr "" - -#: lib/cli/args.py:905 -msgid "Swap the model. Instead converting from of A -> B, converts B -> A" -msgstr "" - -#: lib/cli/args.py:911 -msgid "Disable multiprocessing. Slower but less resource intensive." -msgstr "" - -#: lib/cli/args.py:927 -msgid "" -"Train a model on extracted original (A) and swap (B) faces.\n" -"Training models can take a long time. Anything from 24hrs to over a week\n" -"Model plugins can be configured in the 'Settings' Menu" -msgstr "" - -#: lib/cli/args.py:946 lib/cli/args.py:955 -msgid "faces" -msgstr "" - -#: lib/cli/args.py:947 -msgid "" -"Input directory. A directory containing training images for face A. This is " -"the original face, i.e. the face that you want to remove and replace with " -"face B." -msgstr "" - -#: lib/cli/args.py:956 -msgid "" -"Input directory. A directory containing training images for face B. This is " -"the swap face, i.e. the face that you want to place onto the head of person " -"A." -msgstr "" - -#: lib/cli/args.py:964 lib/cli/args.py:976 lib/cli/args.py:992 -#: lib/cli/args.py:1017 lib/cli/args.py:1027 -msgid "model" -msgstr "" - -#: lib/cli/args.py:965 -msgid "" -"Model directory. This is where the training data will be stored. You should " -"always specify a new folder for new models. If starting a new model, select " -"either an empty folder, or a folder which does not exist (which will be " -"created). If continuing to train an existing model, specify the location of " -"the existing model." -msgstr "" - -#: lib/cli/args.py:977 -msgid "" -"R|Load the weights from a pre-existing model into a newly created model. For " -"most models this will load weights from the Encoder of the given model into " -"the encoder of the newly created model. Some plugins may have specific " -"configuration options allowing you to load weights from other layers. " -"Weights will only be loaded when creating a new model. This option will be " -"ignored if you are resuming an existing model. Generally you will also want " -"to 'freeze-weights' whilst the rest of your model catches up with your " -"Encoder.\n" -"NB: Weights can only be loaded from models of the same plugin as you intend " -"to train." -msgstr "" - -#: lib/cli/args.py:993 -msgid "" -"R|Select which trainer to use. Trainers can be configured from the Settings " -"menu or the config folder.\n" -"L|original: The original model created by /u/deepfakes.\n" -"L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " -"for full dfaker method.\n" -"L|dfl-h128: 128px in/out model from deepfacelab\n" -"L|dfl-sae: Adaptable model from deepfacelab\n" -"L|dlight: A lightweight, high resolution DFaker variant.\n" -"L|iae: A model that uses intermediate layers to try to get better details\n" -"L|lightweight: A lightweight model for low-end cards. Don't expect great " -"results. Can train as low as 1.6GB with batch size 8.\n" -"L|realface: A high detail, dual density model based on DFaker, with " -"customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " -"won't work so well. By andenixa et al. Very configurable.\n" -"L|unbalanced: 128px in/out model from andenixa. The autoencoders are " -"unbalanced so B>A swaps won't work so well. Very configurable.\n" -"L|villain: 128px in/out model from villainguy. Very resource hungry (You " -"will require a GPU with a fair amount of VRAM). Good for details, but more " -"susceptible to color differences." -msgstr "" - -#: lib/cli/args.py:1018 -msgid "" -"Output a summary of the model and exit. If a model folder is provided then a " -"summary of the saved model is displayed. Otherwise a summary of the model " -"that would be created by the chosen plugin and configuration settings is " -"displayed." -msgstr "" - -#: lib/cli/args.py:1028 -msgid "" -"Freeze the weights of the model. Freezing weights means that some of the " -"parameters in the model will no longer continue to learn, but those that are " -"not frozen will continue to learn. For most models, this will freeze the " -"encoder, but some models may have configuration options for freezing other " -"layers." -msgstr "" - -#: lib/cli/args.py:1041 lib/cli/args.py:1053 lib/cli/args.py:1067 -#: lib/cli/args.py:1082 lib/cli/args.py:1090 -msgid "training" -msgstr "" - -#: lib/cli/args.py:1042 -msgid "" -"Batch size. This is the number of images processed through the model for " -"each side per iteration. NB: As the model is fed 2 sides at a time, the " -"actual number of images within the model at any one time is double the " -"number that you set here. Larger batches require more GPU RAM." -msgstr "" - -#: lib/cli/args.py:1054 -msgid "" -"Length of training in iterations. This is only really used for automation. " -"There is no 'correct' number of iterations a model should be trained for. " -"You should stop training when you are happy with the previews. However, if " -"you want the model to stop automatically at a set number of iterations, you " -"can set that value here." -msgstr "" - -#: lib/cli/args.py:1068 -msgid "" -"R|Select the distribution stategy to use.\n" -"L|default: Use Tensorflow's default distribution strategy.\n" -"L|central-storage: Centralizes variables on the CPU whilst operations are " -"performed on 1 or more local GPUs. This can help save some VRAM at the cost " -"of some speed by not storing variables on the GPU. Note: Mixed-Precision is " -"not supported on multi-GPU setups.\n" -"L|mirrored: Supports synchronous distributed training across multiple local " -"GPUs. A copy of the model and all variables are loaded onto each GPU with " -"batches distributed to each GPU at each iteration." -msgstr "" - -#: lib/cli/args.py:1083 -msgid "" -"Disables TensorBoard logging. NB: Disabling logs means that you will not be " -"able to use the graph or analysis for this session in the GUI." -msgstr "" - -#: lib/cli/args.py:1091 -msgid "" -"Use the Learning Rate Finder to discover the optimal learning rate for " -"training. For new models, this will calculate the optimal learning rate for " -"the model. For existing models this will use the optimal learning rate that " -"was discovered when initializing the model. Setting this option will ignore " -"the manually configured learning rate (configurable in train settings)." -msgstr "" - -#: lib/cli/args.py:1104 lib/cli/args.py:1114 -msgid "Saving" -msgstr "" - -#: lib/cli/args.py:1105 -msgid "Sets the number of iterations between each model save." -msgstr "" - -#: lib/cli/args.py:1115 -msgid "" -"Sets the number of iterations before saving a backup snapshot of the model " -"in it's current state. Set to 0 for off." -msgstr "" - -#: lib/cli/args.py:1122 lib/cli/args.py:1133 lib/cli/args.py:1144 -msgid "timelapse" -msgstr "" - -#: lib/cli/args.py:1123 -msgid "" -"Optional for creating a timelapse. Timelapse will save an image of your " -"selected faces into the timelapse-output folder at every save iteration. " -"This should be the input folder of 'A' faces that you would like to use for " -"creating the timelapse. You must also supply a --timelapse-output and a --" -"timelapse-input-B parameter." -msgstr "" - -#: lib/cli/args.py:1134 -msgid "" -"Optional for creating a timelapse. Timelapse will save an image of your " -"selected faces into the timelapse-output folder at every save iteration. " -"This should be the input folder of 'B' faces that you would like to use for " -"creating the timelapse. You must also supply a --timelapse-output and a --" -"timelapse-input-A parameter." -msgstr "" - -#: lib/cli/args.py:1145 -msgid "" -"Optional for creating a timelapse. Timelapse will save an image of your " -"selected faces into the timelapse-output folder at every save iteration. If " -"the input folders are supplied but no output folder, it will default to your " -"model folder /timelapse/" -msgstr "" - -#: lib/cli/args.py:1154 lib/cli/args.py:1161 -msgid "preview" -msgstr "" - -#: lib/cli/args.py:1155 -msgid "Show training preview output. in a separate window." -msgstr "" - -#: lib/cli/args.py:1162 -msgid "" -"Writes the training result to a file. The image will be stored in the root " -"of your FaceSwap folder." -msgstr "" - -#: lib/cli/args.py:1169 lib/cli/args.py:1178 lib/cli/args.py:1187 -#: lib/cli/args.py:1196 -msgid "augmentation" -msgstr "" - -#: lib/cli/args.py:1170 -msgid "" -"Warps training faces to closely matched Landmarks from the opposite face-set " -"rather than randomly warping the face. This is the 'dfaker' way of doing " -"warping." -msgstr "" - -#: lib/cli/args.py:1179 -msgid "" -"To effectively learn, a random set of images are flipped horizontally. " -"Sometimes it is desirable for this not to occur. Generally this should be " -"left off except for during 'fit training'." -msgstr "" - -#: lib/cli/args.py:1188 -msgid "" -"Color augmentation helps make the model less susceptible to color " -"differences between the A and B sets, at an increased training time cost. " -"Enable this option to disable color augmentation." -msgstr "" - -#: lib/cli/args.py:1197 -msgid "" -"Warping is integral to training the Neural Network. This option should only " -"be enabled towards the very end of training to try to bring out more detail. " -"Think of it as 'fine-tuning'. Enabling this option from the beginning is " -"likely to kill a model and lead to terrible results." -msgstr "" - -#: lib/cli/args.py:1222 +#: lib/cli/args.py:319 msgid "Output to Shell console instead of GUI console" msgstr "" diff --git a/locales/lib.cli.args_extract_convert.pot b/locales/lib.cli.args_extract_convert.pot new file mode 100644 index 0000000000..b7771a2ad9 --- /dev/null +++ b/locales/lib.cli.args_extract_convert.pot @@ -0,0 +1,458 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-28 18:11+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#: lib/cli/args_extract_convert.py:46 lib/cli/args_extract_convert.py:56 +#: lib/cli/args_extract_convert.py:64 lib/cli/args_extract_convert.py:122 +#: lib/cli/args_extract_convert.py:479 lib/cli/args_extract_convert.py:488 +msgid "Data" +msgstr "" + +#: lib/cli/args_extract_convert.py:48 +msgid "" +"Input directory or video. Either a directory containing the image files you " +"wish to process or path to a video file. NB: This should be the source video/" +"frames NOT the source faces." +msgstr "" + +#: lib/cli/args_extract_convert.py:57 +msgid "Output directory. This is where the converted files will be saved." +msgstr "" + +#: lib/cli/args_extract_convert.py:66 +msgid "" +"Optional path to an alignments file. Leave blank if the alignments file is " +"at the default location." +msgstr "" + +#: lib/cli/args_extract_convert.py:97 +msgid "" +"Extract faces from image or video sources.\n" +"Extraction plugins can be configured in the 'Settings' Menu" +msgstr "" + +#: lib/cli/args_extract_convert.py:124 +msgid "" +"R|If selected then the input_dir should be a parent folder containing " +"multiple videos and/or folders of images you wish to extract from. The faces " +"will be output to separate sub-folders in the output_dir." +msgstr "" + +#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:150 +#: lib/cli/args_extract_convert.py:163 lib/cli/args_extract_convert.py:202 +#: lib/cli/args_extract_convert.py:220 lib/cli/args_extract_convert.py:233 +#: lib/cli/args_extract_convert.py:243 lib/cli/args_extract_convert.py:253 +#: lib/cli/args_extract_convert.py:499 lib/cli/args_extract_convert.py:525 +#: lib/cli/args_extract_convert.py:564 +msgid "Plugins" +msgstr "" + +#: lib/cli/args_extract_convert.py:135 +msgid "" +"R|Detector to use. Some of these have configurable settings in '/config/" +"extract.ini' or 'Settings > Configure Extract 'Plugins':\n" +"L|cv2-dnn: A CPU only extractor which is the least reliable and least " +"resource intensive. Use this if not using a GPU and time is important.\n" +"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " +"than other GPU detectors but can often return more false positives.\n" +"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " +"fewer false positives than other GPU detectors, but is a lot more resource " +"intensive." +msgstr "" + +#: lib/cli/args_extract_convert.py:152 +msgid "" +"R|Aligner to use.\n" +"L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " +"but less accurate. Only use this if not using a GPU and time is important.\n" +"L|fan: Best aligner. Fast on GPU, slow on CPU." +msgstr "" + +#: lib/cli/args_extract_convert.py:165 +msgid "" +"R|Additional Masker(s) to use. The masks generated here will all take up GPU " +"RAM. You can select none, one or multiple masks, but the extraction may take " +"longer the more you select. NB: The Extended and Components (landmark based) " +"masks are automatically generated on extraction.\n" +"L|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).\n" +"L|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 so will not use any additional VRAM.\n" +"L|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.\n" +"L|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.\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.\n" +"The auto generated masks are as follows:\n" +"L|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.\n" +"L|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.\n" +"(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" +msgstr "" + +#: lib/cli/args_extract_convert.py:204 +msgid "" +"R|Performing normalization can help the aligner better align faces with " +"difficult lighting conditions at an extraction speed cost. Different methods " +"will yield different results on different sets. NB: This does not impact the " +"output face, just the input to the aligner.\n" +"L|none: Don't perform normalization on the face.\n" +"L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " +"face.\n" +"L|hist: Equalize the histograms on the RGB channels.\n" +"L|mean: Normalize the face colors to the mean." +msgstr "" + +#: lib/cli/args_extract_convert.py:222 +msgid "" +"The number of times to re-feed the detected face into the aligner. Each time " +"the face is re-fed into the aligner the bounding box is adjusted by a small " +"amount. The final landmarks are then averaged from each iteration. Helps to " +"remove 'micro-jitter' but at the cost of slower extraction speed. The more " +"times the face is re-fed into the aligner, the less micro-jitter should " +"occur but the longer extraction will take." +msgstr "" + +#: lib/cli/args_extract_convert.py:235 +msgid "" +"Re-feed the initially found aligned face through the aligner. Can help " +"produce better alignments for faces that are rotated beyond 45 degrees in " +"the frame or are at extreme angles. Slows down extraction." +msgstr "" + +#: lib/cli/args_extract_convert.py:245 +msgid "" +"If a face isn't found, rotate the images to try to find a face. Can find " +"more faces at the cost of extraction speed. Pass in a single number to use " +"increments of that size up to 360, or pass in a list of numbers to enumerate " +"exactly what angles to check." +msgstr "" + +#: lib/cli/args_extract_convert.py:255 +msgid "" +"Obtain and store face identity encodings from VGGFace2. Slows down extract a " +"little, but will save time if using 'sort by face'" +msgstr "" + +#: lib/cli/args_extract_convert.py:265 lib/cli/args_extract_convert.py:276 +#: lib/cli/args_extract_convert.py:289 lib/cli/args_extract_convert.py:303 +#: lib/cli/args_extract_convert.py:610 lib/cli/args_extract_convert.py:619 +#: lib/cli/args_extract_convert.py:634 lib/cli/args_extract_convert.py:647 +#: lib/cli/args_extract_convert.py:661 +msgid "Face Processing" +msgstr "" + +#: lib/cli/args_extract_convert.py:267 +msgid "" +"Filters out faces detected below this size. Length, in pixels across the " +"diagonal of the bounding box. Set to 0 for off" +msgstr "" + +#: lib/cli/args_extract_convert.py:278 +msgid "" +"Optionally filter out people who you do not wish to extract by passing in " +"images of those people. Should be a small variety of images at different " +"angles and in different conditions. A folder containing the required images " +"or multiple image files, space separated, can be selected." +msgstr "" + +#: lib/cli/args_extract_convert.py:291 +msgid "" +"Optionally select people you wish to extract by passing in images of that " +"person. Should be a small variety of images at different angles and in " +"different conditions A folder containing the required images or multiple " +"image files, space separated, can be selected." +msgstr "" + +#: lib/cli/args_extract_convert.py:305 +msgid "" +"For use with the optional nfilter/filter files. Threshold for positive face " +"recognition. Higher values are stricter." +msgstr "" + +#: lib/cli/args_extract_convert.py:314 lib/cli/args_extract_convert.py:327 +#: lib/cli/args_extract_convert.py:340 lib/cli/args_extract_convert.py:352 +msgid "output" +msgstr "" + +#: lib/cli/args_extract_convert.py:316 +msgid "" +"The output size of extracted faces. Make sure that the model you intend to " +"train supports your required size. This will only need to be changed for hi-" +"res models." +msgstr "" + +#: lib/cli/args_extract_convert.py:329 +msgid "" +"Extract every 'nth' frame. This option will skip frames when extracting " +"faces. For example a value of 1 will extract faces from every frame, a value " +"of 10 will extract faces from every 10th frame." +msgstr "" + +#: lib/cli/args_extract_convert.py:342 +msgid "" +"Automatically save the alignments file after a set amount of frames. By " +"default the alignments file is only saved at the end of the extraction " +"process. NB: If extracting in 2 passes then the alignments file will only " +"start to be saved out during the second pass. WARNING: Don't interrupt the " +"script when writing the file because it might get corrupted. Set to 0 to " +"turn off" +msgstr "" + +#: lib/cli/args_extract_convert.py:353 +msgid "Draw landmarks on the ouput faces for debugging purposes." +msgstr "" + +#: lib/cli/args_extract_convert.py:359 lib/cli/args_extract_convert.py:369 +#: lib/cli/args_extract_convert.py:377 lib/cli/args_extract_convert.py:384 +#: lib/cli/args_extract_convert.py:674 lib/cli/args_extract_convert.py:686 +#: lib/cli/args_extract_convert.py:695 lib/cli/args_extract_convert.py:716 +#: lib/cli/args_extract_convert.py:722 +msgid "settings" +msgstr "" + +#: lib/cli/args_extract_convert.py:361 +msgid "" +"Don't run extraction in parallel. Will run each part of the extraction " +"process separately (one after the other) rather than all at the same time. " +"Useful if VRAM is at a premium." +msgstr "" + +#: lib/cli/args_extract_convert.py:371 +msgid "" +"Skips frames that have already been extracted and exist in the alignments " +"file" +msgstr "" + +#: lib/cli/args_extract_convert.py:378 +msgid "Skip frames that already have detected faces in the alignments file" +msgstr "" + +#: lib/cli/args_extract_convert.py:385 +msgid "Skip saving the detected faces to disk. Just create an alignments file" +msgstr "" + +#: lib/cli/args_extract_convert.py:459 +msgid "" +"Swap the original faces in a source video/images to your final faces.\n" +"Conversion plugins can be configured in the 'Settings' Menu" +msgstr "" + +#: lib/cli/args_extract_convert.py:481 +msgid "" +"Only required if converting from images to video. Provide The original video " +"that the source frames were extracted from (for extracting the fps and " +"audio)." +msgstr "" + +#: lib/cli/args_extract_convert.py:490 +msgid "" +"Model directory. The directory containing the trained model you wish to use " +"for conversion." +msgstr "" + +#: lib/cli/args_extract_convert.py:501 +msgid "" +"R|Performs color adjustment to the swapped face. Some of these options have " +"configurable settings in '/config/convert.ini' or 'Settings > Configure " +"Convert Plugins':\n" +"L|avg-color: Adjust the mean of each color channel in the swapped " +"reconstruction to equal the mean of the masked area in the original image.\n" +"L|color-transfer: Transfers the color distribution from the source to the " +"target image using the mean and standard deviations of the L*a*b* color " +"space.\n" +"L|manual-balance: Manually adjust the balance of the image in a variety of " +"color spaces. Best used with the Preview tool to set correct values.\n" +"L|match-hist: Adjust the histogram of each color channel in the swapped " +"reconstruction to equal the histogram of the masked area in the original " +"image.\n" +"L|seamless-clone: Use cv2's seamless clone function to remove extreme " +"gradients at the mask seam by smoothing colors. Generally does not give very " +"satisfactory results.\n" +"L|none: Don't perform color adjustment." +msgstr "" + +#: lib/cli/args_extract_convert.py:527 +msgid "" +"R|Masker to use. NB: The mask you require must exist within the alignments " +"file. You can add additional masks with the Mask Tool.\n" +"L|none: Don't use a mask.\n" +"L|bisenet-fp_face: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'face' or " +"'legacy' centering.\n" +"L|bisenet-fp_head: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'head' " +"centering.\n" +"L|custom_face: Custom user created, face centered mask.\n" +"L|custom_head: Custom user created, head centered mask.\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\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.\n" +"L|predicted: If the 'Learn Mask' option was enabled during training, this " +"will use the mask that was created by the trained model." +msgstr "" + +#: lib/cli/args_extract_convert.py:566 +msgid "" +"R|The plugin to use to output the converted images. The writers are " +"configurable in '/config/convert.ini' or 'Settings > Configure Convert " +"Plugins:'\n" +"L|ffmpeg: [video] Writes out the convert straight to video. When the input " +"is a series of images then the '-ref' (--reference-video) parameter must be " +"set.\n" +"L|gif: [animated image] Create an animated gif.\n" +"L|opencv: [images] The fastest image writer, but less options and formats " +"than other plugins.\n" +"L|patch: [images] Outputs the raw swapped face patch, along with the " +"transformation matrix required to re-insert the face back into the original " +"frame. Use this option if you wish to post-process and composite the final " +"face within external tools.\n" +"L|pillow: [images] Slower than opencv, but has more options and supports " +"more formats." +msgstr "" + +#: lib/cli/args_extract_convert.py:587 lib/cli/args_extract_convert.py:596 +#: lib/cli/args_extract_convert.py:707 +msgid "Frame Processing" +msgstr "" + +#: lib/cli/args_extract_convert.py:589 +#, python-format +msgid "" +"Scale the final output frames by this amount. 100%% will output the frames " +"at source dimensions. 50%% at half size 200%% at double size" +msgstr "" + +#: lib/cli/args_extract_convert.py:598 +msgid "" +"Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " +"--frame-ranges 10-50 90-100. Frames falling outside of the selected range " +"will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " +"converting from images, then the filenames must end with the frame-number!" +msgstr "" + +#: lib/cli/args_extract_convert.py:612 +msgid "" +"Scale the swapped face by this percentage. Positive values will enlarge the " +"face, Negative values will shrink the face." +msgstr "" + +#: lib/cli/args_extract_convert.py:621 +msgid "" +"If you have not cleansed your alignments file, then you can filter out faces " +"by defining a folder here that contains the faces extracted from your input " +"files/video. If this folder is defined, then only faces that exist within " +"your alignments file and also exist within the specified folder will be " +"converted. Leaving this blank will convert all faces that exist within the " +"alignments file." +msgstr "" + +#: lib/cli/args_extract_convert.py:636 +msgid "" +"Optionally filter out people who you do not wish to process by passing in an " +"image of that person. Should be a front portrait with a single person in the " +"image. Multiple images can be added space separated. NB: Using face filter " +"will significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" + +#: lib/cli/args_extract_convert.py:649 +msgid "" +"Optionally select people you wish to process by passing in an image of that " +"person. Should be a front portrait with a single person in the image. " +"Multiple images can be added space separated. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" + +#: lib/cli/args_extract_convert.py:663 +msgid "" +"For use with the optional nfilter/filter files. Threshold for positive face " +"recognition. Lower values are stricter. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" + +#: lib/cli/args_extract_convert.py:676 +msgid "" +"The maximum number of parallel processes for performing conversion. " +"Converting images is system RAM heavy so it is possible to run out of memory " +"if you have a lot of processes and not enough RAM to accommodate them all. " +"Setting this to 0 will use the maximum available. No matter what you set " +"this to, it will never attempt to use more processes than are available on " +"your system. If singleprocess is enabled this setting will be ignored." +msgstr "" + +#: lib/cli/args_extract_convert.py:688 +msgid "" +"[LEGACY] This only needs to be selected if a legacy model is being loaded or " +"if there are multiple models in the model folder" +msgstr "" + +#: lib/cli/args_extract_convert.py:697 +msgid "" +"Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " +"alignments file for your destination video. However, if you wish you can " +"generate the alignments on-the-fly by enabling this option. This will use an " +"inferior extraction pipeline and will lead to substandard results. If an " +"alignments file is found, this option will be ignored." +msgstr "" + +#: lib/cli/args_extract_convert.py:709 +msgid "" +"When used with --frame-ranges outputs the unchanged frames that are not " +"processed instead of discarding them." +msgstr "" + +#: lib/cli/args_extract_convert.py:717 +msgid "Swap the model. Instead converting from of A -> B, converts B -> A" +msgstr "" + +#: lib/cli/args_extract_convert.py:723 +msgid "Disable multiprocessing. Slower but less resource intensive." +msgstr "" diff --git a/locales/lib.cli.args_train.pot b/locales/lib.cli.args_train.pot new file mode 100644 index 0000000000..9902e629a5 --- /dev/null +++ b/locales/lib.cli.args_train.pot @@ -0,0 +1,255 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-28 18:04+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#: lib/cli/args_train.py:30 +msgid "" +"Train a model on extracted original (A) and swap (B) faces.\n" +"Training models can take a long time. Anything from 24hrs to over a week\n" +"Model plugins can be configured in the 'Settings' Menu" +msgstr "" + +#: lib/cli/args_train.py:49 lib/cli/args_train.py:58 +msgid "faces" +msgstr "" + +#: lib/cli/args_train.py:51 +msgid "" +"Input directory. A directory containing training images for face A. This is " +"the original face, i.e. the face that you want to remove and replace with " +"face B." +msgstr "" + +#: lib/cli/args_train.py:60 +msgid "" +"Input directory. A directory containing training images for face B. This is " +"the swap face, i.e. the face that you want to place onto the head of person " +"A." +msgstr "" + +#: lib/cli/args_train.py:67 lib/cli/args_train.py:80 lib/cli/args_train.py:97 +#: lib/cli/args_train.py:123 lib/cli/args_train.py:133 +msgid "model" +msgstr "" + +#: lib/cli/args_train.py:69 +msgid "" +"Model directory. This is where the training data will be stored. You should " +"always specify a new folder for new models. If starting a new model, select " +"either an empty folder, or a folder which does not exist (which will be " +"created). If continuing to train an existing model, specify the location of " +"the existing model." +msgstr "" + +#: lib/cli/args_train.py:82 +msgid "" +"R|Load the weights from a pre-existing model into a newly created model. For " +"most models this will load weights from the Encoder of the given model into " +"the encoder of the newly created model. Some plugins may have specific " +"configuration options allowing you to load weights from other layers. " +"Weights will only be loaded when creating a new model. This option will be " +"ignored if you are resuming an existing model. Generally you will also want " +"to 'freeze-weights' whilst the rest of your model catches up with your " +"Encoder.\n" +"NB: Weights can only be loaded from models of the same plugin as you intend " +"to train." +msgstr "" + +#: lib/cli/args_train.py:99 +msgid "" +"R|Select which trainer to use. Trainers can be configured from the Settings " +"menu or the config folder.\n" +"L|original: The original model created by /u/deepfakes.\n" +"L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " +"for full dfaker method.\n" +"L|dfl-h128: 128px in/out model from deepfacelab\n" +"L|dfl-sae: Adaptable model from deepfacelab\n" +"L|dlight: A lightweight, high resolution DFaker variant.\n" +"L|iae: A model that uses intermediate layers to try to get better details\n" +"L|lightweight: A lightweight model for low-end cards. Don't expect great " +"results. Can train as low as 1.6GB with batch size 8.\n" +"L|realface: A high detail, dual density model based on DFaker, with " +"customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " +"won't work so well. By andenixa et al. Very configurable.\n" +"L|unbalanced: 128px in/out model from andenixa. The autoencoders are " +"unbalanced so B>A swaps won't work so well. Very configurable.\n" +"L|villain: 128px in/out model from villainguy. Very resource hungry (You " +"will require a GPU with a fair amount of VRAM). Good for details, but more " +"susceptible to color differences." +msgstr "" + +#: lib/cli/args_train.py:125 +msgid "" +"Output a summary of the model and exit. If a model folder is provided then a " +"summary of the saved model is displayed. Otherwise a summary of the model " +"that would be created by the chosen plugin and configuration settings is " +"displayed." +msgstr "" + +#: lib/cli/args_train.py:135 +msgid "" +"Freeze the weights of the model. Freezing weights means that some of the " +"parameters in the model will no longer continue to learn, but those that are " +"not frozen will continue to learn. For most models, this will freeze the " +"encoder, but some models may have configuration options for freezing other " +"layers." +msgstr "" + +#: lib/cli/args_train.py:147 lib/cli/args_train.py:160 +#: lib/cli/args_train.py:175 lib/cli/args_train.py:191 +#: lib/cli/args_train.py:200 +msgid "training" +msgstr "" + +#: lib/cli/args_train.py:149 +msgid "" +"Batch size. This is the number of images processed through the model for " +"each side per iteration. NB: As the model is fed 2 sides at a time, the " +"actual number of images within the model at any one time is double the " +"number that you set here. Larger batches require more GPU RAM." +msgstr "" + +#: lib/cli/args_train.py:162 +msgid "" +"Length of training in iterations. This is only really used for automation. " +"There is no 'correct' number of iterations a model should be trained for. " +"You should stop training when you are happy with the previews. However, if " +"you want the model to stop automatically at a set number of iterations, you " +"can set that value here." +msgstr "" + +#: lib/cli/args_train.py:177 +msgid "" +"R|Select the distribution stategy to use.\n" +"L|default: Use Tensorflow's default distribution strategy.\n" +"L|central-storage: Centralizes variables on the CPU whilst operations are " +"performed on 1 or more local GPUs. This can help save some VRAM at the cost " +"of some speed by not storing variables on the GPU. Note: Mixed-Precision is " +"not supported on multi-GPU setups.\n" +"L|mirrored: Supports synchronous distributed training across multiple local " +"GPUs. A copy of the model and all variables are loaded onto each GPU with " +"batches distributed to each GPU at each iteration." +msgstr "" + +#: lib/cli/args_train.py:193 +msgid "" +"Disables TensorBoard logging. NB: Disabling logs means that you will not be " +"able to use the graph or analysis for this session in the GUI." +msgstr "" + +#: lib/cli/args_train.py:202 +msgid "" +"Use the Learning Rate Finder to discover the optimal learning rate for " +"training. For new models, this will calculate the optimal learning rate for " +"the model. For existing models this will use the optimal learning rate that " +"was discovered when initializing the model. Setting this option will ignore " +"the manually configured learning rate (configurable in train settings)." +msgstr "" + +#: lib/cli/args_train.py:215 lib/cli/args_train.py:225 +msgid "Saving" +msgstr "" + +#: lib/cli/args_train.py:216 +msgid "Sets the number of iterations between each model save." +msgstr "" + +#: lib/cli/args_train.py:227 +msgid "" +"Sets the number of iterations before saving a backup snapshot of the model " +"in it's current state. Set to 0 for off." +msgstr "" + +#: lib/cli/args_train.py:234 lib/cli/args_train.py:246 +#: lib/cli/args_train.py:258 +msgid "timelapse" +msgstr "" + +#: lib/cli/args_train.py:236 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. " +"This should be the input folder of 'A' faces that you would like to use for " +"creating the timelapse. You must also supply a --timelapse-output and a --" +"timelapse-input-B parameter." +msgstr "" + +#: lib/cli/args_train.py:248 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. " +"This should be the input folder of 'B' faces that you would like to use for " +"creating the timelapse. You must also supply a --timelapse-output and a --" +"timelapse-input-A parameter." +msgstr "" + +#: lib/cli/args_train.py:260 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. If " +"the input folders are supplied but no output folder, it will default to your " +"model folder/timelapse/" +msgstr "" + +#: lib/cli/args_train.py:269 lib/cli/args_train.py:276 +msgid "preview" +msgstr "" + +#: lib/cli/args_train.py:270 +msgid "Show training preview output. in a separate window." +msgstr "" + +#: lib/cli/args_train.py:278 +msgid "" +"Writes the training result to a file. The image will be stored in the root " +"of your FaceSwap folder." +msgstr "" + +#: lib/cli/args_train.py:285 lib/cli/args_train.py:295 +#: lib/cli/args_train.py:305 lib/cli/args_train.py:315 +msgid "augmentation" +msgstr "" + +#: lib/cli/args_train.py:287 +msgid "" +"Warps training faces to closely matched Landmarks from the opposite face-set " +"rather than randomly warping the face. This is the 'dfaker' way of doing " +"warping." +msgstr "" + +#: lib/cli/args_train.py:297 +msgid "" +"To effectively learn, a random set of images are flipped horizontally. " +"Sometimes it is desirable for this not to occur. Generally this should be " +"left off except for during 'fit training'." +msgstr "" + +#: lib/cli/args_train.py:307 +msgid "" +"Color augmentation helps make the model less susceptible to color " +"differences between the A and B sets, at an increased training time cost. " +"Enable this option to disable color augmentation." +msgstr "" + +#: lib/cli/args_train.py:317 +msgid "" +"Warping is integral to training the Neural Network. This option should only " +"be enabled towards the very end of training to try to bring out more detail. " +"Think of it as 'fine-tuning'. Enabling this option from the beginning is " +"likely to kill a model and lead to terrible results." +msgstr "" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo old mode 100755 new mode 100644 index 02559444d6ee745129ac0759ab816287e4008851..5b51831151df6115c8704fc668f97c3a1a4c5f4c GIT binary patch delta 279 zcmaF$hxxBS{XHR;sSH5C4#YA*90J7LK->ewAg}|7`G9yQ5VryG2_W_YVt+;k20b7> z3rM>G>61YEJdhS(VqnMyvL66xbs*n|1xN$=B|!QpkUxi&fx!t#C$TXwbTcq`Ff0W! zynzY~fi3`2U;tFZzzoDdGr$0-1Pp*?fC0#{4DLDkNr^cM{skqO`FX{Y?Ik-V?@;oY zoT^qjS-|MRY literal 65130 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 diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.po b/locales/ru/LC_MESSAGES/lib.cli.args.po index b3116f1d6b..2f6a55435f 100755 --- a/locales/ru/LC_MESSAGES/lib.cli.args.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-25 16:09+0100\n" -"PO-Revision-Date: 2023-09-25 16:14+0100\n" +"POT-Creation-Date: 2024-03-28 18:06+0000\n" +"PO-Revision-Date: 2024-03-28 18:23+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -17,14 +17,14 @@ 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.3.2\n" +"X-Generator: Poedit 3.4.2\n" -#: lib/cli/args.py:192 lib/cli/args.py:202 lib/cli/args.py:210 -#: lib/cli/args.py:220 +#: lib/cli/args.py:188 lib/cli/args.py:199 lib/cli/args.py:208 +#: lib/cli/args.py:219 msgid "Global Options" msgstr "Глобальные Настройки" -#: lib/cli/args.py:193 +#: lib/cli/args.py:190 msgid "" "R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " "to any GPU(s) that you do not wish to be made available to Faceswap. " @@ -36,14 +36,14 @@ msgstr "" "Если выбрать здесь все GPU, Faceswap перейдет в режим CPU.\n" "L|{}" -#: lib/cli/args.py:203 +#: lib/cli/args.py:201 msgid "" "Optionally overide the saved config with the path to a custom config file." msgstr "" "Опционально переопределите сохраненную конфигурацию, указав путь к " "пользовательскому файлу конфигурации." -#: lib/cli/args.py:211 +#: lib/cli/args.py:210 msgid "" "Log level. Stick with INFO or VERBOSE unless you need to file an error " "report. Be careful with TRACE as it will generate a lot of data" @@ -52,1045 +52,12 @@ msgstr "" "нужно отправить отчет об ошибке. Будьте осторожны с TRACE, поскольку он " "генерирует много данных" -#: lib/cli/args.py:221 +#: lib/cli/args.py:220 msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "" "Путь для хранения файла журнала. Оставьте пустым, чтобы хранить в папке " "faceswap" -#: lib/cli/args.py:319 lib/cli/args.py:328 lib/cli/args.py:336 -#: lib/cli/args.py:385 lib/cli/args.py:676 lib/cli/args.py:685 -msgid "Data" -msgstr "Данные" - -#: lib/cli/args.py:320 -msgid "" -"Input directory or video. Either a directory containing the image files you " -"wish to process or path to a video file. NB: This should be the source video/" -"frames NOT the source faces." -msgstr "" -"Входная папка или видео. Либо каталог, содержащий файлы изображений, которые " -"вы хотите обработать, либо путь к видеофайлу. ПРИМЕЧАНИЕ: Это должно быть " -"исходное видео/кадры, а не исходные лица." - -#: lib/cli/args.py:329 -msgid "Output directory. This is where the converted files will be saved." -msgstr "Выходная папка. Здесь будут сохранены преобразованные файлы." - -#: lib/cli/args.py:337 -msgid "" -"Optional path to an alignments file. Leave blank if the alignments file is " -"at the default location." -msgstr "" -"Необязательный путь к файлу выравниваний. Оставьте пустым, если файл " -"выравнивания находится в месте по умолчанию." - -#: lib/cli/args.py:360 -msgid "" -"Extract faces from image or video sources.\n" -"Extraction plugins can be configured in the 'Settings' Menu" -msgstr "" -"Извлечение лиц из источников изображений или видео.\n" -"Плагины извлечения можно настроить в меню \"Настройки\"" - -#: lib/cli/args.py:386 -msgid "" -"R|If selected then the input_dir should be a parent folder containing " -"multiple videos and/or folders of images you wish to extract from. The faces " -"will be output to separate sub-folders in the output_dir." -msgstr "" -"R|Если выбрано, то input_dir должен быть родительской папкой, содержащей " -"несколько видео и/или папок с изображениями, из которых вы хотите извлечь " -"изображение. Лица будут выведены в отдельные вложенные папки в output_dir." - -#: lib/cli/args.py:395 lib/cli/args.py:411 lib/cli/args.py:423 -#: lib/cli/args.py:462 lib/cli/args.py:480 lib/cli/args.py:492 -#: lib/cli/args.py:501 lib/cli/args.py:510 lib/cli/args.py:695 -#: lib/cli/args.py:722 lib/cli/args.py:760 -msgid "Plugins" -msgstr "Плагины" - -#: lib/cli/args.py:396 -msgid "" -"R|Detector to use. Some of these have configurable settings in '/config/" -"extract.ini' or 'Settings > Configure Extract 'Plugins':\n" -"L|cv2-dnn: A CPU only extractor which is the least reliable and least " -"resource intensive. Use this if not using a GPU and time is important.\n" -"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " -"than other GPU detectors but can often return more false positives.\n" -"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " -"fewer false positives than other GPU detectors, but is a lot more resource " -"intensive." -msgstr "" -"R|Детектор для использования. Некоторые из них имеют настраиваемые параметры " -"в '/config/extract.ini' или 'Settings > Configure Extract 'Plugins':\n" -"L|cv2-dnn: Экстрактор только для процессора, который является наименее " -"надежным и наименее ресурсоемким. Используйте его, если не используется GPU " -"и важно время.\n" -"L|mtcnn: Хороший детектор. Быстрый на CPU, еще быстрее на GPU. Использует " -"меньше ресурсов, чем другие детекторы на GPU, но часто может давать больше " -"ложных срабатываний.\n" -"L|s3fd: Лучший детектор. Медленный на CPU, более быстрый на GPU. Может " -"обнаружить больше лиц и меньше ложных срабатываний, чем другие детекторы на " -"GPU, но требует гораздо больше ресурсов." - -#: lib/cli/args.py:412 -msgid "" -"R|Aligner to use.\n" -"L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " -"but less accurate. Only use this if not using a GPU and time is important.\n" -"L|fan: Best aligner. Fast on GPU, slow on CPU." -msgstr "" -"R|Выравниватель для использования.\n" -"L|cv2-dnn: Детектор ориентиров только для процессора. Быстрее, менее " -"ресурсоемкий, но менее точный. Используйте его, только если не используется " -"GPU и важно время.\n" -"L|fan: Лучший выравниватель. Быстрый на GPU, медленный на CPU." - -#: lib/cli/args.py:424 -msgid "" -"R|Additional Masker(s) to use. The masks generated here will all take up GPU " -"RAM. You can select none, one or multiple masks, but the extraction may take " -"longer the more you select. NB: The Extended and Components (landmark based) " -"masks are automatically generated on extraction.\n" -"L|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).\n" -"L|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 so will not use any additional VRAM.\n" -"L|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.\n" -"L|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.\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.\n" -"The auto generated masks are as follows:\n" -"L|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.\n" -"L|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.\n" -"(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -msgstr "" -"R|Дополнительный маскер(ы) для использования. Все маски, созданные здесь, " -"будут занимать видеопамять GPU. Вы можете выбрать ни одной, одну или " -"несколько масок, но извлечение может занять больше времени, чем больше масок " -"вы выберете. Примечание: Расширенные маски и маски компонентов (на основе " -"ориентиров) генерируются автоматически при извлечении.\n" -"L|bisenet-fp: Относительно легкая маска на основе NN, которая обеспечивает " -"более точный контроль над маскируемой областью, включая полное маскирование " -"головы (настраивается в настройках маски).\n" -"L|custom: Фиктивная маска, которая заполняет область маски всеми 1 или 0 " -"(настраивается в настройках). Она необходима только в том случае, если вы " -"собираетесь вручную редактировать пользовательские маски в ручном " -"инструменте. Эта маска не задействует GPU, поэтому не будет использовать " -"дополнительную память VRAM.\n" -"L|vgg-clear: Маска предназначена для интеллектуальной сегментации " -"преимущественно фронтальных лиц без препятствий. Профильные лица и " -"препятствия могут привести к снижению производительности.\n" -"L|vgg-obstructed: Маска, разработанная для интеллектуальной сегментации " -"преимущественно фронтальных лиц. Модель маски была специально обучена " -"распознавать некоторые препятствия на лице (руки и очки). Лица в профиль " -"могут иметь низкую производительность.\n" -"L|unet-dfl: Маска, разработанная для интеллектуальной сегментации " -"преимущественно фронтальных лиц. Модель маски была обучена членами " -"сообщества и для дальнейшего описания нуждается в тестировании. Профильные " -"лица могут привести к низкой производительности.\n" -"Автоматически сгенерированные маски выглядят следующим образом:\n" -"L|components: Маска, разработанная для сегментации лица на основе " -"расположения ориентиров. Для создания маски вокруг внешних ориентиров " -"строится выпуклая оболочка.\n" -"L|extended: Маска, предназначенная для сегментации лица на основе " -"расположения ориентиров. Выпуклый корпус строится вокруг внешних ориентиров, " -"и маска расширяется вверх на лоб.\n" -"(например: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" - -#: lib/cli/args.py:463 -msgid "" -"R|Performing normalization can help the aligner better align faces with " -"difficult lighting conditions at an extraction speed cost. Different methods " -"will yield different results on different sets. NB: This does not impact the " -"output face, just the input to the aligner.\n" -"L|none: Don't perform normalization on the face.\n" -"L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " -"face.\n" -"L|hist: Equalize the histograms on the RGB channels.\n" -"L|mean: Normalize the face colors to the mean." -msgstr "" -"R|Проведение нормализации может помочь выравнивателю лучше выравнивать лица " -"со сложными условиями освещения при затратах на скорость извлечения. " -"Различные методы дают разные результаты на разных наборах. NB: Это не влияет " -"на выходное лицо, только на вход выравнивателя.\n" -"L|none: Не выполнять нормализацию лица.\n" -"L|clahe: Выполнить для лица адаптивную гистограммную эквализацию с " -"ограничением контраста.\n" -"L|hist: Уравнять гистограммы в каналах RGB.\n" -"L|mean: Нормализовать цвета лица к среднему значению." - -#: lib/cli/args.py:481 -msgid "" -"The number of times to re-feed the detected face into the aligner. Each time " -"the face is re-fed into the aligner the bounding box is adjusted by a small " -"amount. The final landmarks are then averaged from each iteration. Helps to " -"remove 'micro-jitter' but at the cost of slower extraction speed. The more " -"times the face is re-fed into the aligner, the less micro-jitter should " -"occur but the longer extraction will take." -msgstr "" -"Количество повторных подач обнаруженной области лица в выравниватель. При " -"каждой повторной подаче лица в выравниватель ограничивающая рамка " -"корректируется на небольшую величину. Затем конечные ориентиры усредняются " -"по результатам каждой итерации. Это помогает устранить \"микро-дрожание\", " -"но ценой снижения скорости извлечения. Чем больше раз лицо повторно подается " -"в выравниватель, тем меньше микро-дрожание, но тем больше времени займет " -"извлечение." - -#: lib/cli/args.py:493 -msgid "" -"Re-feed the initially found aligned face through the aligner. Can help " -"produce better alignments for faces that are rotated beyond 45 degrees in " -"the frame or are at extreme angles. Slows down extraction." -msgstr "" -"Повторная подача первоначально найденной выровненной области лица через " -"выравниватель. Может помочь получить лучшее выравнивание для лиц, повернутых " -"в кадре более чем на 45 градусов или расположенных под экстремальными " -"углами. Замедляет извлечение." - -#: lib/cli/args.py:502 -msgid "" -"If a face isn't found, rotate the images to try to find a face. Can find " -"more faces at the cost of extraction speed. Pass in a single number to use " -"increments of that size up to 360, or pass in a list of numbers to enumerate " -"exactly what angles to check." -msgstr "" -"Если лицо не найдено, поворачивает изображения, чтобы попытаться найти лицо. " -"Может найти больше лиц ценой снижения скорости извлечения. Передайте одно " -"число, чтобы использовать приращения этого размера до 360, или передайте " -"список чисел, чтобы перечислить, какие именно углы нужно проверить." - -#: lib/cli/args.py:511 -msgid "" -"Obtain and store face identity encodings from VGGFace2. Slows down extract a " -"little, but will save time if using 'sort by face'" -msgstr "" -"Получение и хранение кодировок идентификации лица из VGGFace2. Немного " -"замедляет извлечение, но экономит время при использовании \"сортировки по " -"лицам\"." - -#: lib/cli/args.py:521 lib/cli/args.py:531 lib/cli/args.py:543 -#: lib/cli/args.py:556 lib/cli/args.py:804 lib/cli/args.py:812 -#: lib/cli/args.py:826 lib/cli/args.py:839 lib/cli/args.py:853 -msgid "Face Processing" -msgstr "Обработка лиц" - -#: lib/cli/args.py:522 -msgid "" -"Filters out faces detected below this size. Length, in pixels across the " -"diagonal of the bounding box. Set to 0 for off" -msgstr "" -"Отфильтровывает лица, обнаруженные ниже этого размера. Длина в пикселях по " -"диагонали ограничивающего поля. Установите значение 0, чтобы выключить" - -#: lib/cli/args.py:532 -msgid "" -"Optionally filter out people who you do not wish to extract by passing in " -"images of those people. Should be a small variety of images at different " -"angles and in different conditions. A folder containing the required images " -"or multiple image files, space separated, can be selected." -msgstr "" -"По желанию отфильтруйте людей, которых вы не хотите извлекать, передав " -"изображения этих людей. Должно быть небольшое разнообразие изображений под " -"разными углами и в разных условиях. Можно выбрать папку, содержащую " -"необходимые изображения, или несколько файлов изображений, разделенных " -"пробелами." - -#: lib/cli/args.py:544 -msgid "" -"Optionally select people you wish to extract by passing in images of that " -"person. Should be a small variety of images at different angles and in " -"different conditions A folder containing the required images or multiple " -"image files, space separated, can be selected." -msgstr "" -"По желанию выберите людей, которых вы хотите извлечь, передав изображения " -"этого человека. Должно быть небольшое разнообразие изображений под разными " -"углами и в разных условиях. Можно выбрать папку, содержащую необходимые " -"изображения, или несколько файлов изображений, разделенных пробелами." - -#: lib/cli/args.py:557 -msgid "" -"For use with the optional nfilter/filter files. Threshold for positive face " -"recognition. Higher values are stricter." -msgstr "" -"Для использования с дополнительными файлами nfilter/filter. Порог для " -"положительного распознавания лица. Более высокие значения являются более " -"строгими." - -#: lib/cli/args.py:566 lib/cli/args.py:578 lib/cli/args.py:590 -#: lib/cli/args.py:602 -msgid "output" -msgstr "вывод" - -#: lib/cli/args.py:567 -msgid "" -"The output size of extracted faces. Make sure that the model you intend to " -"train supports your required size. This will only need to be changed for hi-" -"res models." -msgstr "" -"Выходной размер извлеченных лиц. Убедитесь, что модель, которую вы " -"собираетесь тренировать, поддерживает требуемый размер. Это необходимо " -"изменить только для моделей высокого разрешения." - -#: lib/cli/args.py:579 -msgid "" -"Extract every 'nth' frame. This option will skip frames when extracting " -"faces. For example a value of 1 will extract faces from every frame, a value " -"of 10 will extract faces from every 10th frame." -msgstr "" -"Извлекать каждый 'n-й' кадр. Этот параметр пропускает кадры при извлечении " -"лиц. Например, значение 1 будет извлекать лица из каждого кадра, значение 10 " -"будет извлекать лица из каждого 10-го кадра." - -#: lib/cli/args.py:591 -msgid "" -"Automatically save the alignments file after a set amount of frames. By " -"default the alignments file is only saved at the end of the extraction " -"process. NB: If extracting in 2 passes then the alignments file will only " -"start to be saved out during the second pass. WARNING: Don't interrupt the " -"script when writing the file because it might get corrupted. Set to 0 to " -"turn off" -msgstr "" -"Автоматическое сохранение файла выравнивания после заданного количества " -"кадров. По умолчанию файл выравнивания сохраняется только в конце процесса " -"извлечения. Примечание: Если извлечение выполняется в 2 прохода, то файл " -"выравнивания начнет сохраняться только во время второго прохода. " -"ПРЕДУПРЕЖДЕНИЕ: Не прерывайте работу скрипта при записи файла, так как он " -"может быть поврежден. Установите значение 0, чтобы отключить" - -#: lib/cli/args.py:603 -msgid "Draw landmarks on the ouput faces for debugging purposes." -msgstr "Нарисуйте ориентиры на выходящих гранях для отладки." - -#: lib/cli/args.py:609 lib/cli/args.py:618 lib/cli/args.py:626 -#: lib/cli/args.py:633 lib/cli/args.py:866 lib/cli/args.py:877 -#: lib/cli/args.py:885 lib/cli/args.py:904 lib/cli/args.py:910 -msgid "settings" -msgstr "настройки" - -#: lib/cli/args.py:610 -msgid "" -"Don't run extraction in parallel. Will run each part of the extraction " -"process separately (one after the other) rather than all at the same time. " -"Useful if VRAM is at a premium." -msgstr "" -"Не запускать извлечение параллельно. Каждая часть процесса извлечения будет " -"выполняться отдельно (одна за другой), а не одновременно. Полезно, если " -"память VRAM ограничена." - -#: lib/cli/args.py:619 -msgid "" -"Skips frames that have already been extracted and exist in the alignments " -"file" -msgstr "" -"Пропускает кадры, которые уже были извлечены и существуют в файле " -"выравнивания" - -#: lib/cli/args.py:627 -msgid "Skip frames that already have detected faces in the alignments file" -msgstr "" -"Пропустить кадры, в которых уже есть обнаруженные лица в файле выравнивания" - -#: lib/cli/args.py:634 -msgid "Skip saving the detected faces to disk. Just create an alignments file" -msgstr "" -"Не сохранять обнаруженные лица на диск. Просто создать файл выравнивания" - -#: lib/cli/args.py:656 -msgid "" -"Swap the original faces in a source video/images to your final faces.\n" -"Conversion plugins can be configured in the 'Settings' Menu" -msgstr "" -"Поменять исходные лица в исходном видео/изображении на ваши конечные лица.\n" -"Плагины конвертирования можно настроить в меню \"Настройки\"" - -#: lib/cli/args.py:677 -msgid "" -"Only required if converting from images to video. Provide The original video " -"that the source frames were extracted from (for extracting the fps and " -"audio)." -msgstr "" -"Требуется только при преобразовании из изображений в видео. Предоставьте " -"исходное видео, из которого были извлечены исходные кадры (для извлечения " -"кадров в секунду и звука)." - -#: lib/cli/args.py:686 -msgid "" -"Model directory. The directory containing the trained model you wish to use " -"for conversion." -msgstr "" -"Папка модели. Папка, содержащая обученную модель, которую вы хотите " -"использовать для преобразования." - -#: lib/cli/args.py:696 -msgid "" -"R|Performs color adjustment to the swapped face. Some of these options have " -"configurable settings in '/config/convert.ini' or 'Settings > Configure " -"Convert Plugins':\n" -"L|avg-color: Adjust the mean of each color channel in the swapped " -"reconstruction to equal the mean of the masked area in the original image.\n" -"L|color-transfer: Transfers the color distribution from the source to the " -"target image using the mean and standard deviations of the L*a*b* color " -"space.\n" -"L|manual-balance: Manually adjust the balance of the image in a variety of " -"color spaces. Best used with the Preview tool to set correct values.\n" -"L|match-hist: Adjust the histogram of each color channel in the swapped " -"reconstruction to equal the histogram of the masked area in the original " -"image.\n" -"L|seamless-clone: Use cv2's seamless clone function to remove extreme " -"gradients at the mask seam by smoothing colors. Generally does not give very " -"satisfactory results.\n" -"L|none: Don't perform color adjustment." -msgstr "" -"R|Производит корректировку цвета поменявшегося лица. Некоторые из этих " -"параметров настраиваются в '/config/convert.ini' или 'Настройки > Настроить " -"плагины конвертации':\n" -"L|avg-color: корректирует среднее значение каждого цветового канала в " -"реконструкции, чтобы оно было равно среднему значению маскированной области " -"в исходном изображении.\n" -"L|color-transfer: Переносит распределение цветов с исходного изображения на " -"целевое, используя среднее и стандартные отклонения цветового пространства " -"L*a*b*.\n" -"L|manual-balance: Ручная настройка баланса изображения в различных цветовых " -"пространствах. Лучше всего использовать с инструментом предварительного " -"просмотра для установки правильных значений.\n" -"L|match-hist: Настроить гистограмму каждого цветового канала в измененном " -"восстановлении так, чтобы она соответствовала гистограмме маскированной " -"области исходного изображения.\n" -"L|seamless-clone: Используйте функцию бесшовного клонирования cv2 для " -"удаления экстремальных градиентов на шве маски путем сглаживания цветов. " -"Обычно дает не очень удовлетворительные результаты.\n" -"L|none: Не выполнять коррекцию цвета." - -#: lib/cli/args.py:723 -msgid "" -"R|Masker to use. NB: The mask you require must exist within the alignments " -"file. You can add additional masks with the Mask Tool.\n" -"L|none: Don't use a mask.\n" -"L|bisenet-fp_face: Relatively lightweight NN based mask that provides more " -"refined control over the area to be masked (configurable in mask settings). " -"Use this version of bisenet-fp if your model is trained with 'face' or " -"'legacy' centering.\n" -"L|bisenet-fp_head: Relatively lightweight NN based mask that provides more " -"refined control over the area to be masked (configurable in mask settings). " -"Use this version of bisenet-fp if your model is trained with 'head' " -"centering.\n" -"L|custom_face: Custom user created, face centered mask.\n" -"L|custom_head: Custom user created, head centered mask.\n" -"L|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.\n" -"L|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.\n" -"L|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.\n" -"L|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.\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.\n" -"L|predicted: If the 'Learn Mask' option was enabled during training, this " -"will use the mask that was created by the trained model." -msgstr "" -"R|Маскер для использования. Примечание: Нужная маска должна существовать в " -"файле выравнивания. Вы можете добавить дополнительные маски с помощью " -"инструмента Mask Tool.\n" -"L|none: Не использовать маску.\n" -"L|bisenet-fp_face: Относительно легкая маска на основе NN, которая " -"обеспечивает более точный контроль над маскируемой областью (настраивается в " -"настройках маски). Используйте эту версию bisenet-fp, если ваша модель " -"обучена с центрированием 'face' или 'legacy'.\n" -"L|bisenet-fp_head: Относительно легкая маска на основе NN, которая " -"обеспечивает более точный контроль над маскируемой областью (настраивается в " -"настройках маски). Используйте эту версию bisenet-fp, если ваша модель " -"обучена с центрированием по \"голове\".\n" -"L|custom_face: Пользовательская маска, созданная пользователем и " -"центрированная по лицу.\n" -"L|custom_head: Созданная пользователем маска, центрированная по голове.\n" -"L|components: Маска, разработанная для сегментации лица на основе " -"расположения ориентиров. Для создания маски вокруг внешних ориентиров " -"строится выпуклая оболочка.\n" -"L|extended: Маска, предназначенная для сегментации лица на основе " -"расположения ориентиров. Выпуклый корпус строится вокруг внешних ориентиров, " -"и маска расширяется вверх на лоб.\n" -"L|vgg-clear: Маска предназначена для интеллектуальной сегментации " -"преимущественно фронтальных лиц без препятствий. Профильные лица и " -"препятствия могут привести к снижению производительности.\n" -"L|vgg-obstructed: Маска, разработанная для интеллектуальной сегментации " -"преимущественно фронтальных лиц. Модель маски была специально обучена " -"распознавать некоторые препятствия на лице (руки и очки). Лица в профиль " -"могут иметь низкую производительность.\n" -"L|unet-dfl: Маска, разработанная для интеллектуальной сегментации " -"преимущественно фронтальных лиц. Модель маски была обучена членами " -"сообщества и для дальнейшего описания нуждается в тестировании. Профильные " -"лица могут привести к низкой производительности.\n" -"L|predicted: Если во время обучения была включена опция 'Изучить Маску', то " -"будет использоваться маска, созданная обученной моделью." - -#: lib/cli/args.py:761 -msgid "" -"R|The plugin to use to output the converted images. The writers are " -"configurable in '/config/convert.ini' or 'Settings > Configure Convert " -"Plugins:'\n" -"L|ffmpeg: [video] Writes out the convert straight to video. When the input " -"is a series of images then the '-ref' (--reference-video) parameter must be " -"set.\n" -"L|gif: [animated image] Create an animated gif.\n" -"L|opencv: [images] The fastest image writer, but less options and formats " -"than other plugins.\n" -"L|patch: [images] Outputs the raw swapped face patch, along with the " -"transformation matrix required to re-insert the face back into the original " -"frame. Use this option if you wish to post-process and composite the final " -"face within external tools.\n" -"L|pillow: [images] Slower than opencv, but has more options and supports " -"more formats." -msgstr "" -"R|Плагин, который нужно использовать для вывода преобразованных изображений. " -"Записи настраиваются в '/config/convert.ini' или 'Настройки > Настроить " -"плагины конвертации:'\n" -"L|ffmpeg: [видео] Записывает конвертацию прямо в видео. Если на вход " -"подается серия изображений, необходимо установить параметр '-ref' (--" -"reference-video).\n" -"L|gif: [анимированное изображение] Создает анимированный gif.\n" -"L|opencv: [изображения] Самый быстрый редактор изображений, но имеет меньше " -"опций и форматов, чем другие плагины.\n" -"L|patch: [изображения] Выводит необработанный фрагмент измененного лица " -"вместе с матрицей преобразования, необходимой для повторной вставки лица " -"обратно в исходный кадр.\n" -"L|pillow: [изображения] Медленнее, чем opencv, но имеет больше опций и " -"поддерживает больше форматов." - -#: lib/cli/args.py:784 lib/cli/args.py:791 lib/cli/args.py:896 -msgid "Frame Processing" -msgstr "Обработка лиц" - -#: lib/cli/args.py:785 -#, python-format -msgid "" -"Scale the final output frames by this amount. 100%% will output the frames " -"at source dimensions. 50%% at half size 200%% at double size" -msgstr "" -"Масштабирование конечных выходных кадров на эту величину. 100%% выводит " -"кадры в исходном размере. 50%% при половинном размере 200%% при двойном " -"размере" - -#: lib/cli/args.py:792 -msgid "" -"Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " -"--frame-ranges 10-50 90-100. Frames falling outside of the selected range " -"will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " -"converting from images, then the filenames must end with the frame-number!" -msgstr "" -"Диапазоны кадров для применения переноса, например, для кадров с 10 по 50 и " -"с 90 по 100 используйте --frame-ranges 10-50 90-100. Кадры, выходящие за " -"пределы выбранного диапазона, будут отброшены, если не выбрана опция '-k' (--" -"keep-unchanged). Примечание: Если вы конвертируете из изображений, то имена " -"файлов должны заканчиваться номером кадра!" - -#: lib/cli/args.py:805 -msgid "" -"Scale the swapped face by this percentage. Positive values will enlarge the " -"face, Negative values will shrink the face." -msgstr "" -"Увеличить масштаб нового лица на этот процент. Положительные значения " -"увеличат лицо, в то время как отрицательные значения уменьшат его." - -#: lib/cli/args.py:813 -msgid "" -"If you have not cleansed your alignments file, then you can filter out faces " -"by defining a folder here that contains the faces extracted from your input " -"files/video. If this folder is defined, then only faces that exist within " -"your alignments file and also exist within the specified folder will be " -"converted. Leaving this blank will convert all faces that exist within the " -"alignments file." -msgstr "" -"Если вы не очистили свой файл выравнивания, то вы можете отфильтровать лица, " -"определив здесь папку, содержащую лица, извлеченные из ваших входных файлов/" -"видео. Если эта папка определена, то будут преобразованы только те лица, " -"которые существуют в вашем файле выравнивания, а также в указанной папке. " -"Если оставить этот параметр пустым, будут преобразованы все лица, " -"существующие в файле выравнивания." - -#: lib/cli/args.py:827 -msgid "" -"Optionally filter out people who you do not wish to process by passing in an " -"image of that person. Should be a front portrait with a single person in the " -"image. Multiple images can be added space separated. NB: Using face filter " -"will significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." -msgstr "" -"По желанию отфильтровать людей, которых вы не хотите обрабатывать, передав " -"изображение этого человека. Это должен быть фронтальный портрет с " -"изображением одного человека. Можно добавить несколько изображений, " -"разделенных пробелами. Примечание: Использование фильтра лиц значительно " -"снизит скорость извлечения, а его точность не гарантируется." - -#: lib/cli/args.py:840 -msgid "" -"Optionally select people you wish to process by passing in an image of that " -"person. Should be a front portrait with a single person in the image. " -"Multiple images can be added space separated. NB: Using face filter will " -"significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." -msgstr "" -"По желанию выберите людей, которых вы хотите обработать, передав изображение " -"этого человека. Это должен быть фронтальный портрет с изображением одного " -"человека. Можно добавить несколько изображений, разделенных пробелами. " -"Примечание: Использование фильтра лиц значительно снизит скорость " -"извлечения, а его точность не гарантируется." - -#: lib/cli/args.py:854 -msgid "" -"For use with the optional nfilter/filter files. Threshold for positive face " -"recognition. Lower values are stricter. NB: Using face filter will " -"significantly decrease extraction speed and its accuracy cannot be " -"guaranteed." -msgstr "" -"Для использования с дополнительными файлами nfilter/filter. Порог для " -"положительного распознавания лиц. Более низкие значения являются более " -"строгими. Примечание: Использование фильтра лиц значительно снизит скорость " -"извлечения, а его точность не гарантируется." - -#: lib/cli/args.py:867 -msgid "" -"The maximum number of parallel processes for performing conversion. " -"Converting images is system RAM heavy so it is possible to run out of memory " -"if you have a lot of processes and not enough RAM to accommodate them all. " -"Setting this to 0 will use the maximum available. No matter what you set " -"this to, it will never attempt to use more processes than are available on " -"your system. If singleprocess is enabled this setting will be ignored." -msgstr "" -"Максимальное количество параллельных процессов для выполнения конвертации. " -"Конвертирование изображений занимает много системной оперативной памяти, " -"поэтому может закончиться память, если у вас много процессов и недостаточно " -"оперативной памяти для их размещения. Если установить значение 0, будет " -"использован максимум доступной памяти. Независимо от того, какое значение вы " -"установите, программа никогда не будет пытаться использовать больше " -"процессов, чем доступно в вашей системе. Если включена однопоточная " -"обработка, этот параметр будет проигнорирован." - -#: lib/cli/args.py:878 -msgid "" -"[LEGACY] This only needs to be selected if a legacy model is being loaded or " -"if there are multiple models in the model folder" -msgstr "" -"[ОТБРОШЕН] Этот параметр необходимо выбрать только в том случае, если " -"загружается устаревшая модель или если в папке моделей имеется несколько " -"моделей" - -#: lib/cli/args.py:886 -msgid "" -"Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " -"alignments file for your destination video. However, if you wish you can " -"generate the alignments on-the-fly by enabling this option. This will use an " -"inferior extraction pipeline and will lead to substandard results. If an " -"alignments file is found, this option will be ignored." -msgstr "" -"Включить преобразование \"на лету\". НЕ рекомендуется. Вы должны " -"сгенерировать чистый файл выравнивания для конечного видео. Однако при " -"желании вы можете генерировать выравнивания \"на лету\", включив эту опцию. " -"При этом будет использоваться некачественный конвейер извлечения, что " -"приведет к некачественным результатам. Если файл выравнивания найден, этот " -"параметр будет проигнорирован." - -#: lib/cli/args.py:897 -msgid "" -"When used with --frame-ranges outputs the unchanged frames that are not " -"processed instead of discarding them." -msgstr "" -"При использовании с --frame-ranges выводит неизмененные кадры, которые не " -"были обработаны, вместо того, чтобы отбрасывать их." - -#: lib/cli/args.py:905 -msgid "Swap the model. Instead converting from of A -> B, converts B -> A" -msgstr "" -"Поменять модель местами. Вместо преобразования из A -> B, преобразуется B -> " -"A" - -#: lib/cli/args.py:911 -msgid "Disable multiprocessing. Slower but less resource intensive." -msgstr "Отключение многопоточной обработки. Медленнее, но менее ресурсоемко." - -#: lib/cli/args.py:927 -msgid "" -"Train a model on extracted original (A) and swap (B) faces.\n" -"Training models can take a long time. Anything from 24hrs to over a week\n" -"Model plugins can be configured in the 'Settings' Menu" -msgstr "" -"Обучение модели на извлеченных оригинальных (A) и подмененных (B) лицах.\n" -"Обучение моделей может занять много времени. От 24 часов до недели.\n" -"Плагины для моделей можно настроить в меню \"Настройки\"" - -#: lib/cli/args.py:946 lib/cli/args.py:955 -msgid "faces" -msgstr "лица" - -#: lib/cli/args.py:947 -msgid "" -"Input directory. A directory containing training images for face A. This is " -"the original face, i.e. the face that you want to remove and replace with " -"face B." -msgstr "" -"Входная папка. Папка, содержащая обучающие изображения для лица A. Это " -"исходное лицо, т.е. лицо, которое вы хотите удалить и заменить лицом B." - -#: lib/cli/args.py:956 -msgid "" -"Input directory. A directory containing training images for face B. This is " -"the swap face, i.e. the face that you want to place onto the head of person " -"A." -msgstr "" -"Входная папка. Папка, содержащая обучающие изображения для лица B. Это " -"подменное лицо, т.е. лицо, которое вы хотите поместить на голову человека A." - -#: lib/cli/args.py:964 lib/cli/args.py:976 lib/cli/args.py:992 -#: lib/cli/args.py:1017 lib/cli/args.py:1027 -msgid "model" -msgstr "модель" - -#: lib/cli/args.py:965 -msgid "" -"Model directory. This is where the training data will be stored. You should " -"always specify a new folder for new models. If starting a new model, select " -"either an empty folder, or a folder which does not exist (which will be " -"created). If continuing to train an existing model, specify the location of " -"the existing model." -msgstr "" -"Папка модели. Здесь будут храниться данные для обучения. Для новых моделей " -"всегда следует указывать новую папку. Если вы начинаете новую модель, " -"выберите либо пустую папку, либо несуществующую папку (которая будет " -"создана). Если вы продолжаете обучение существующей модели, укажите " -"местоположение существующей модели." - -#: lib/cli/args.py:977 -msgid "" -"R|Load the weights from a pre-existing model into a newly created model. For " -"most models this will load weights from the Encoder of the given model into " -"the encoder of the newly created model. Some plugins may have specific " -"configuration options allowing you to load weights from other layers. " -"Weights will only be loaded when creating a new model. This option will be " -"ignored if you are resuming an existing model. Generally you will also want " -"to 'freeze-weights' whilst the rest of your model catches up with your " -"Encoder.\n" -"NB: Weights can only be loaded from models of the same plugin as you intend " -"to train." -msgstr "" -"R|Загрузить веса из уже существующей модели во вновь созданную модель. Для " -"большинства моделей это означает загрузку весов из кодировщика данной модели " -"в кодировщик вновь создаваемой модели. Некоторые плагины могут иметь " -"специальные параметры конфигурации, позволяющие загружать веса из других " -"слоев. Веса будут загружаться только при создании новой модели. Эта опция " -"будет проигнорирована, если вы возобновляете существующую модель. Обычно " -"также требуется \"заморозить\" веса, пока остальная часть модели догоняет " -"кодировщик.\n" -"Примечание: Веса могут быть загружены только из моделей того же плагина, " -"который вы собираетесь обучать." - -#: lib/cli/args.py:993 -msgid "" -"R|Select which trainer to use. Trainers can be configured from the Settings " -"menu or the config folder.\n" -"L|original: The original model created by /u/deepfakes.\n" -"L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " -"for full dfaker method.\n" -"L|dfl-h128: 128px in/out model from deepfacelab\n" -"L|dfl-sae: Adaptable model from deepfacelab\n" -"L|dlight: A lightweight, high resolution DFaker variant.\n" -"L|iae: A model that uses intermediate layers to try to get better details\n" -"L|lightweight: A lightweight model for low-end cards. Don't expect great " -"results. Can train as low as 1.6GB with batch size 8.\n" -"L|realface: A high detail, dual density model based on DFaker, with " -"customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " -"won't work so well. By andenixa et al. Very configurable.\n" -"L|unbalanced: 128px in/out model from andenixa. The autoencoders are " -"unbalanced so B>A swaps won't work so well. Very configurable.\n" -"L|villain: 128px in/out model from villainguy. Very resource hungry (You " -"will require a GPU with a fair amount of VRAM). Good for details, but more " -"susceptible to color differences." -msgstr "" -"R|Выберите, какой тренажер использовать. Тренажеры можно настроить в меню " -"\"Настройки\" или в папке config.\n" -"L|original: Оригинальная модель, созданная /u/deepfakes.\n" -"L|dfaker: модель 64px вход/ 128px выход от dfaker. Включите 'warp-to-" -"landmarks' для полного метода dfaker.\n" -"L|dfl-h128: модель 128px вход/выход от deepfacelab\n" -"L|dfl-sae: Адаптируемая модель от deepfacelab\n" -"L|dlight: Легкий вариант DFaker с высоким разрешением.\n" -"L|iae: Модель, использующая промежуточные слои для получения лучших " -"деталей.\n" -"L|lightweight: Облегченная модель для карт низкого класса. Не ожидайте " -"высоких результатов. Может обучаться на 1,6 ГБ при размере пачки 8.\n" -"L|realface: Модель с высокой детализацией и двойной плотностью, основанная " -"на DFaker, с настраиваемым разрешением входа/выхода. Автоэнкодеры " -"несбалансированы, поэтому замены B>A не будут работать так хорошо. Автор " -"andenixa и др. Очень настраиваемая.\n" -"L|unbalanced: модель 128px вход/выход от andenixa. Автокодировщики " -"несбалансированы, поэтому замены B>A не будут работать так хорошо. Очень " -"настраиваемая.\n" -"L|villain: модель 128px вход/выход от villainguy. Очень требовательна к " -"ресурсам (вам потребуется GPU с достаточным количеством VRAM). Хороша для " -"детализации, но более восприимчива к цветовым различиям." - -#: lib/cli/args.py:1018 -msgid "" -"Output a summary of the model and exit. If a model folder is provided then a " -"summary of the saved model is displayed. Otherwise a summary of the model " -"that would be created by the chosen plugin and configuration settings is " -"displayed." -msgstr "" -"Вывести сводку модели и выйти. Если указана папка модели, то выводится " -"сводка сохраненной модели. В противном случае отображается сводка модели, " -"которая будет создана выбранным плагином и настройками конфигурации." - -#: lib/cli/args.py:1028 -msgid "" -"Freeze the weights of the model. Freezing weights means that some of the " -"parameters in the model will no longer continue to learn, but those that are " -"not frozen will continue to learn. For most models, this will freeze the " -"encoder, but some models may have configuration options for freezing other " -"layers." -msgstr "" -"Заморозить веса модели. Замораживание весов означает, что некоторые " -"параметры в модели больше не будут продолжать обучение, но те, которые не " -"заморожены, будут продолжать обучение. Для большинства моделей это означает " -"замораживание кодера, но некоторые модели могут иметь опции конфигурации для " -"замораживания других слоев." - -#: lib/cli/args.py:1041 lib/cli/args.py:1053 lib/cli/args.py:1067 -#: lib/cli/args.py:1082 lib/cli/args.py:1090 -msgid "training" -msgstr "тренировка" - -#: lib/cli/args.py:1042 -msgid "" -"Batch size. This is the number of images processed through the model for " -"each side per iteration. NB: As the model is fed 2 sides at a time, the " -"actual number of images within the model at any one time is double the " -"number that you set here. Larger batches require more GPU RAM." -msgstr "" -"Размер пачки. Это количество изображений, обрабатываемых моделью для каждой " -"стороны за итерацию. Примечание: Поскольку модель обрабатывает 2 стороны " -"одновременно, фактическое количество изображений в модели в любой момент " -"времени будет вдвое больше, чем заданное здесь. Большие партии требуют " -"больше оперативной памяти GPU." - -#: lib/cli/args.py:1054 -msgid "" -"Length of training in iterations. This is only really used for automation. " -"There is no 'correct' number of iterations a model should be trained for. " -"You should stop training when you are happy with the previews. However, if " -"you want the model to stop automatically at a set number of iterations, you " -"can set that value here." -msgstr "" -"Продолжительность обучения в итерациях. Этот параметр действительно " -"используется только для автоматизации. Не существует \"правильного\" " -"количества итераций, за которое следует обучить модель. Вы должны прекратить " -"обучение, когда будете удовлетворены предварительным просмотром. Однако если " -"вы хотите, чтобы модель автоматически останавливалась при определенном " -"количестве итераций, вы можете задать это значение здесь." - -#: lib/cli/args.py:1068 -msgid "" -"R|Select the distribution stategy to use.\n" -"L|default: Use Tensorflow's default distribution strategy.\n" -"L|central-storage: Centralizes variables on the CPU whilst operations are " -"performed on 1 or more local GPUs. This can help save some VRAM at the cost " -"of some speed by not storing variables on the GPU. Note: Mixed-Precision is " -"not supported on multi-GPU setups.\n" -"L|mirrored: Supports synchronous distributed training across multiple local " -"GPUs. A copy of the model and all variables are loaded onto each GPU with " -"batches distributed to each GPU at each iteration." -msgstr "" -"R|Выберите стратегию распределения для использования.\n" -"L|default: Использовать стратегию распространения Tensorflow по умолчанию.\n" -"L|central-storage: Централизует переменные на CPU, в то время как операции " -"выполняются на 1 или более локальных GPU. Это может помочь сэкономить " -"немного VRAM за счет некоторой скорости, поскольку переменные не хранятся на " -"GPU. Примечание: Mixed-Precision не поддерживается на многопроцессорных " -"установках.\n" -"L|mirrored: Поддерживает синхронное распределенное обучение на нескольких " -"локальных GPU. Копия модели и все переменные загружаются на каждый GPU с " -"распределением партий на каждый GPU на каждой итерации." - -#: lib/cli/args.py:1083 -msgid "" -"Disables TensorBoard logging. NB: Disabling logs means that you will not be " -"able to use the graph or analysis for this session in the GUI." -msgstr "" -"Отключает ведение журналов TensorBoard. Примечание: Отключение ведения " -"журналов означает, что вы не сможете использовать график или анализ для этой " -"сессии в графическом интерфейсе." - -#: lib/cli/args.py:1091 -msgid "" -"Use the Learning Rate Finder to discover the optimal learning rate for " -"training. For new models, this will calculate the optimal learning rate for " -"the model. For existing models this will use the optimal learning rate that " -"was discovered when initializing the model. Setting this option will ignore " -"the manually configured learning rate (configurable in train settings)." -msgstr "" -"Используйте инструмент поиска коэффициента обучения, чтобы найти оптимальную " -"скорость обучения вашей модели. Для новых моделей это позволит рассчитать " -"оптимальный коэффициент обучения для модели. Для существующих моделей будет " -"использован оптимальный коэффициент обучения, найденный при инициализации " -"модели. Установка этой опции приведет к игнорированию вручную настроенного " -"коэффициента обучения (настраиваемого в параметрах обучения)." - -#: lib/cli/args.py:1104 lib/cli/args.py:1114 -msgid "Saving" -msgstr "Сохранение" - -#: lib/cli/args.py:1105 -msgid "Sets the number of iterations between each model save." -msgstr "Устанавливает количество итераций между каждым сохранением модели." - -#: lib/cli/args.py:1115 -msgid "" -"Sets the number of iterations before saving a backup snapshot of the model " -"in it's current state. Set to 0 for off." -msgstr "" -"Устанавливает количество итераций между каждым сохранением модели. " -"Устанавливает количество итераций перед сохранением резервного снимка модели " -"в текущем состоянии. Установите значение 0 для выключения." - -#: lib/cli/args.py:1122 lib/cli/args.py:1133 lib/cli/args.py:1144 -msgid "timelapse" -msgstr "таймлапс" - -#: lib/cli/args.py:1123 -msgid "" -"Optional for creating a timelapse. Timelapse will save an image of your " -"selected faces into the timelapse-output folder at every save iteration. " -"This should be the input folder of 'A' faces that you would like to use for " -"creating the timelapse. You must also supply a --timelapse-output and a --" -"timelapse-input-B parameter." -msgstr "" -"Опционально для создания таймлапса. Timelapse будет сохранять изображение " -"выбранных лиц в папку timelapse-output на каждой итерации сохранения. Это " -"должна быть входная папка с лицами 'A', которые вы хотите использовать для " -"создания timelapse. Вы также должны указать параметры --timelapse-output и --" -"timelapse-input-B." - -#: lib/cli/args.py:1134 -msgid "" -"Optional for creating a timelapse. Timelapse will save an image of your " -"selected faces into the timelapse-output folder at every save iteration. " -"This should be the input folder of 'B' faces that you would like to use for " -"creating the timelapse. You must also supply a --timelapse-output and a --" -"timelapse-input-A parameter." -msgstr "" -"Опционально для создания таймлапса. Timelapse будет сохранять изображение " -"выбранных лиц в папку timelapse-output на каждой итерации сохранения. Это " -"должна быть входная папка с лицами 'B', которые вы хотите использовать для " -"создания timelapse. Вы также должны указать параметры --timelapse-output и --" -"timelapse-input-A." - -#: lib/cli/args.py:1145 -msgid "" -"Optional for creating a timelapse. Timelapse will save an image of your " -"selected faces into the timelapse-output folder at every save iteration. If " -"the input folders are supplied but no output folder, it will default to your " -"model folder /timelapse/" -msgstr "" -"Опционально для создания таймлапса. Timelapse будет сохранять изображение " -"выбранных лиц в папку timelapse-output на каждой итерации сохранения. Если " -"указаны входные папки, но нет выходной папки, то по умолчанию будет выбрана " -"папка модели /timelapse/" - -#: lib/cli/args.py:1154 lib/cli/args.py:1161 -msgid "preview" -msgstr "предпросмотр" - -#: lib/cli/args.py:1155 -msgid "Show training preview output. in a separate window." -msgstr "Показать вывод предварительного просмотра тренировки в отдельном окне." - -#: lib/cli/args.py:1162 -msgid "" -"Writes the training result to a file. The image will be stored in the root " -"of your FaceSwap folder." -msgstr "" -"Записывает результат обучения в файл. Изображение будет сохранено в корне " -"папки Faceswap." - -#: lib/cli/args.py:1169 lib/cli/args.py:1178 lib/cli/args.py:1187 -#: lib/cli/args.py:1196 -msgid "augmentation" -msgstr "аугментация" - -#: lib/cli/args.py:1170 -msgid "" -"Warps training faces to closely matched Landmarks from the opposite face-set " -"rather than randomly warping the face. This is the 'dfaker' way of doing " -"warping." -msgstr "" -"Искажает обучаемые лица до близко подходящих ориентиров из противоположного " -"набора лиц вместо случайного искажения лица. Это способ выполнения искажения " -"от \"dfaker\" ." - -#: lib/cli/args.py:1179 -msgid "" -"To effectively learn, a random set of images are flipped horizontally. " -"Sometimes it is desirable for this not to occur. Generally this should be " -"left off except for during 'fit training'." -msgstr "" -"Для эффективного обучения случайный набор изображений переворачивается по " -"горизонтали. Иногда желательно, чтобы этого не происходило. Как правило, это " -"не нужно делать, за исключением случаев \"тренировки подгонки\"." - -#: lib/cli/args.py:1188 -msgid "" -"Color augmentation helps make the model less susceptible to color " -"differences between the A and B sets, at an increased training time cost. " -"Enable this option to disable color augmentation." -msgstr "" -"Аугментация цвета помогает сделать модель менее восприимчивой к цветовым " -"различиям между наборами A и B, что влечет за собой увеличение затрат " -"времени на обучение. Включите этот параметр для отключения цветовой " -"аугментации." - -#: lib/cli/args.py:1197 -msgid "" -"Warping is integral to training the Neural Network. This option should only " -"be enabled towards the very end of training to try to bring out more detail. " -"Think of it as 'fine-tuning'. Enabling this option from the beginning is " -"likely to kill a model and lead to terrible results." -msgstr "" -"Искажение является неотъемлемой частью обучения нейронной сети. Эту опцию " -"следует включать только в самом конце обучения, чтобы попытаться получить " -"больше деталей. Считайте это \"тонкой настройкой\". Включение этой опции в " -"самом начале, скорее всего, погубит модель и приведет к ужасным результатам." - -#: lib/cli/args.py:1222 +#: lib/cli/args.py:319 msgid "Output to Shell console instead of GUI console" msgstr "Вывод в консоль Shell вместо консоли GUI" - -#~ msgid "" -#~ "[Deprecated - Use '-D, --distribution-strategy' instead] Use the " -#~ "Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." -#~ msgstr "" -#~ "[Устарело - Используйте '-D, --distribution-strategy' вместо этого] " -#~ "Используйте стратегию Tensorflow Mirrored Distrubution Strategy(Стратегия " -#~ "Зеркального Распределения Tensorflow) для обучения на нескольких GPU." diff --git a/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.mo b/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.mo new file mode 100644 index 0000000000000000000000000000000000000000..4483776f6fe3e12dd20f74b171b1a75ddcce4638 GIT binary patch literal 42512 zcmeI5Ym8jydEbwd)J@sAX%b(WUeY6`_L2^FMN(2K)0S-At(25hiE?5Xsy*DD-5qmx zW<3{DOx2c@EMFi+ss+n-Y{^pX1PKZRpeWHIC5j5#0xi%4m_<>bMbicadO`cC>4$!4 z8}#@8Kks|yoY`54l5N>FNN9C-&gFfd>;JjD@0|S)Kl>9;7W})4pTEk_3vVlm+c+Nl zasJ1@)t@Me5$8G_`LFonKT{MR;`pC9KF9Hjw-?0+IQ}b+pW?oE{Mn-TXMFDdWKk@0 zyz0*t#ZPnmI>+N2|1rlQ$DKQh;@@-pD#yRbarEblVu9yB$MH}3eD%s;p>J^fLyn6aJ3mttKhOPt$njc^|Mh2!;wL$l?<|Vl9CyE~C~oAw zd){3XkXU?$wc^#9{cM>@dteVgDZ;S^IZSUoy^TR zSG*tokj>BSfme?I=1Q(}-+$R3{B;rJP5R97R*vt#x+w18_!*9x|Mxkb;`rm&6vcNK z?_rML;`pJz$&vGad~H#zb9@k?{ymQWgCn9Xx(DEg+krLqWCb+&vASw$CZOc@kbmVfGLgl@xNUZ=Nae4yW{!K+*1_SbNc=p#Am*YN^xytcx-B%PF-1ovKi{dYEeD9}_InQ6s@fSGp zwj;=!&m}kg9Srb4e+Jp``Q~Hjg6seMcv0Ms9RAzG=n_30`69Gqn{WG4QS9KpYrawx z|C@0?^3|evgyV<4hFx*|7aX~zxbrvT^MB;{zxcfRb?k$AK8bOCgmG7%fk(#q-EScm zuK(C?6~(*w{6`!y-Qs`#c2Qtz#sB5VY5$Wxeukeb;-l|_B>REx#l#BCr}#O3n4NMjE24S*08hKT3b6&4qC_B<#4rKw$?f;-Su{NI4GApYkXcFw)4$Izhx!iB9w+9R5jVH>b_Ht{qHk|ozXHfRKY4oMC6*slJOBzCxweKJHTZ_X^ zuUl^PdyDM>L*8-Yf%5j{>QZ;5>~zchbJ8NrcvSF*wOuh0*+iWr0 zXjm?d`W?p7tb_Jq4;nP$LiwvV+R_?o;n(Au^mxBB z%p+JbN7{?6(V$&+hUI!^Wp!AtAfm;dpKdQLl=tuvD)#Ds!%@Er)62!ptzoOUxie@T zLALA2t&PRgH|j4k2V`{zon!5VBIxXox|N8K9wORk_0eQ| z4VEMd-_TlI)p@IIw!9IB9vrsO(ym@N>svSX_+Ni_$wiI`uGPgj*0L4{7(hAfthX1+ zdk5|1(OTJAE6&i2qb&s|C11tthx$`g*TI{W3u1k{m z$9to4u-Y4~Euo8UTWmqc;#%9JH|<(eo2>EhGrtUxM^2P&Q6A)GJ`$lS;kqH_8{wufEW$8Koq?`5-V;A z2Q0sB;LN3%Hl(iNYM?{(v(ss8ul)_g=;2y9lH@H>dtf5<<;QESUPOofjY%mYOsyo18 zb*8-lUP~1b3d?=Q`D^xi$`AO%zP)>`sQG!{GoOarw|Ab~KCpM5>kQ#Y3WsZk3yf(1 z2xU_kwAaF-{A~CHv@dlAi+}(Oj=ByMbMr^%%3bsGN89a<`B8TfuwH2|?G_g2A*#qK zM3Zu{c)Z>*ZMpK9fdC_gSOgA?5$6e1gCUS6I7&7b9Cb(QN80@>3Sm$vsWVU%P0-Wt z4F$onQ9GnXIRt2s-*SiH;-Q7|CVbE*)`2At2l65)a4A71POf5!8qDjDdK+uz{dIBN@4^pa5S9Xtl@LLQH zzmy!A9IXvo!Yv6W5HE>_`K(7z+UYvFGT-3J6xE>8u^LK?*8T zW;oShh1isMz@r5ad7J0IGC_p$mD)A)won}EEw$GsgbAp%1d$pnmFs>=MwBU) z%?sc|e9n-HJCDd#tfv7YNuUqDbcdaxl9a`s0u8x>0N`V{-F7QMVSnVIm`Cq;m1M&8 zT4y+v`6@dN5O&Q#3aC73WT2iK^vE_bVKFyX+^OWh-~P-9b^zk#Z7@etO`0OU>Ng9a z*Y6OaiEF-w=rI4f`~o>im@DAVF0f02$cSMRgY^wCMVK>M>hyLm6n8o&WAu$y(yQ{@ zMv@pd+AhO5Az!mk-ILN?UCEWuT5r+mUY{Gms`_Ah;6B}Ox!~48N3`AyS4L}&64u~ zGKL$n*G@UfM3YLd$b9;#gn^sLxa1RrkimwMi(Fa&FhV+LtYT3m{Wl_+?2ApwFmlYD zQGSD@OzBKrG}t%1Z(yAGPk@>PhHxQ09*~r!MGv-69&&A#8LQI7){>&`6n}vNZ%wL_ z%@)ChG?OVbQ0w(=2%DHCr|*#oy~P%^y&x`|4B73SqoI<4DyF2`j+jMb!+AxO3<^Ny z!U6E|STLR;?uufmxSIl3d9@uybpwb4wy9tVVAKHe{o!iAH(H7M#E3}|;WTfxy|$sK zx-{ahBUD(5%Qe^qQydr>0UK4G8d;=@suOD4Tzw5dyV7q%Wb#;tSAmi4<{?=>nO}ms zShO5my{vA`OP`EB2jHt|ai`qqslivCPFlTABzJ8)c z5vEL~Z87R8&+H0D+S}*&wHJv^Pca#tGz2fKBZMB^I!|G)mU;+J^@CD5Q7ywFySBmQ^R4cQk|5+zjoqr_CLtVK zS(#Va(LZ2Q#zj;jBizVgol^nsBxi&6imIY$co8$#DX`?jijWK#&gY?&LiFf3^+#^e zk{I08?+G5#O|rcDb+}cwr@oXprCT*mZ&7iEbPS#s+nLh*RD0=rLhi&_Q(nB<8em`4 zG^q}W9(T^25sBJ{bPy;92R)Dira`hMGrOpvmm+0spJ=bFk&CqlyQgJvNi2`#OiRma z-)Cksf8u1m>OEj zsF_%xtZw8QJ+Z+7Ic$YiuOX16l7_V!(F9j=j%Z}{Tqk3w73}5g+j%UmeXM=(M(jT&EQtV_6?jYSqSh@RpjbJ=&56v+R(X#ABMb$Y zcF(5~KyvgD1kPODHXRWrh&ba13;O zwZj?@B9I!vf7t4?df;d&5#&=_){(So!$WUScGQT;Z)r+_No0G#QfV`Z(~Cnm ze_P91c{NMKk|lY4$-2s>0BDn^Due2=>e%H0ubZW&;F`+b)V-#afAE39Rm(V`RhdVK zWN$lKZerxPGjf!ObrMq$NE5a`$F^jKMIt(yB82Od?`#n<)^q!EO57BQb6?fHxlfXg z*Lp@QmjRU2sHtCu-KcdVdyZ8?qORK@M)E5K+itAk#8sVN?*=6cabqXKV=(IwKpW;mRN^j?DpxoZuAltd?fJtQUNm>gk5adIx+Pdo^4&T6cBPmzebtbYt|o-0 z)kS(J?&ZmcQ*+PE&xwx!i?#L&YZr6nA_hXBVx_prv7q}$fn)J8#bxBtNvAhC(^q;- zo6}*L){?}OB!iPl1(HsBE+jMaIbE9Z+~wTlElI52Om1?y%E@ROh;g~fdCRMugAcH) zWl3w|04~8@S=Ss3S?umnpaQPFs!$%M;_(&`ASv%W61aoiX-9?waU`%QLwCbV4<=~S z3V_DXQNc=A9KI_GYUKuOn zbfh%^@8GGog;Ytyi&kS7%A2dzg!T4twMQjkZccRCY%-WEHKZ*n%El%qNsp<_w8_hj zkE-WEj;_>Kl&9Q8z`m#aw3eQAHdZuK!IGGp&IwYhVM;8_uK_fkL&I8Yl>{M4QRz1l zW|D)Q^^P`g+_2O_i7e%Ql=aWv3ad=zEuW#ilchX094YkhK)xgTHag`8RbqMWyKlR( z)S`TstskOcoh?ZR$~%(w;;9H^y|o_IdSX+z)ikaGmdj*Il8U-G`NqLgEMr>a`r3m!Z!9_2C!JgM!`xT_CIZT2CNJxiLn9>nyu zvYa;g1U4os6hkxZQeDgC5;M~R)hqY zy0*v;I|CfddkYe_Nr3gDBm+?BX5y!E;=^ci!|NzghM~Wgu})G+j)w-VZ^+RT+IH&x z9c?u3X!OFX_Ck8}jrr_>If!3gUf)p5=Mye2?<;>*(_z7?4JR~dBUsCQe<=5z>@sbX zvt9;AGgxQ6BxQ^Mz`1!A1iVFwqgJ99+w&f1w+df28L4OBZAM-ucBf{gvkYOaF5(a_ zTPpXJH>G}@wl$C8kA(d0v;5qS0Ns5qNwd~b%HZfGfZ!NgsdXnpUxsY9 z2`sMG2EWr4bij!=xMmbHjQeJGG-@ShQp*M!c@`lKll43I=d#(Y`hI(!y*&a}N#8>q zX)PWFDG|I*ReK`5=;ihb=cyf2;z1{@-Uc9-AI`Hdqpj6mDRS?X9e^q1czcDXQBDBD z&^KuJwdX>h5qz_V(mP&bJZ(}ADOuzpPo<-7h16v_7;UKcmacdmfcRhBLr1<&LJs+{ zrNfDj+AH2~zQEqPz3+W*W#GdS|@9CG}M;~z@1fdv)O^Zf8IEm_9{B%X?NEYA1ZC_Des_GclJ=fZLmt!ezdv^ z{%d<_o%glYFzclg5u~F>g~>}Z)iS4Zp4iZ)MVJ_a^;rR!Lx*v{3LdJrO3O5Dl!q16X|MsI}zQL_S9}eLfZ}H z{PpFHYz~P_gYrh5xS_Zw4>2+JEC=>%)HhazqYF+kdRk(YIb*puc~rMuk{9P)C>chD zDG8nKX>RpBj5ya@_jlGu>v>lp^a`l7!#-~cLp54INtmA!=+x?Av3tUBA`^=qoIo1u zS}oQx=P`mxPv0Nl3Z6RX$Sz?8$Q5ii=z{POK)+_CS7_b$eSrbpu@Q~$A7TuMSXq7ny z7~4>_M6bccDLi?0uRV3W^y#e!X@|5Oc(p@vq6o!COk$H0n@J`;wR<~=kFZ~Dv~Iws z6e?%TMlZVw420RVb30)+Uyk|2!CP*-;iiu(S%KDyOsm?dP}%?v0` z)_N^%1ObY;2@Np6XsFf=f-$JD7DJTL=r4ksB6-xHxU1j$H08nk?Mw3?i=Op?a>w0m zh4A@9gO$$G{EefP!TjMKr|-IR{%#5kA?!R~Yoe6dzjy!D^LwwF-+yhn@7e?V_y5vf z{_QwOaXs(yYpq4@%##P%K|R9M=MR&~*FGNr`F0$-{m?Cw$@eYn6_T?W#26+U^#>-@Eo`%lG75F%~ z?y3(eE$7$kc6HyoZ|`;2?JM8+zEZ#Lz2drk`^pakk=)Mb>#y6NFMa5`tM#j&*!!V< z`^yI&h)1s9clB-_*~cUMcYkolugynIVaD(3d4I@N3s*1f-%*VJ;rPtvgX4?i3**xq z&uxBwe0KBH_$AJqE&1i*=EIvO$EU}yjL-6OPA5Oh$MfTt$EQmkeZ*JK_}UrYcW(3X za{MX}p5mUf<@iO;zThjbFzEB+i={_@InTwcJgAf0$7mPFXBNuwGu*fN5L8_pzYI+x z;N0e8)#TexU8i~TahQ2<^Ar@B$Co!B)!aNBG`zC;IZ^nkX4S9Uc}k?5gXlA&UefT) zkBcO3I|oHNYkJ0K50v94&F8rp4{2xwBv~-lD>Y)6e}A6#VXh*cJ*Myy_=B9&5P&{5 zA8aUS^Kn>xbQZJb7~;>4FGx4mw*_rw1ow#yOa9sMljARszl{#`+gJGg$??vzQvc)W9CC?$p2R9!QT`zIs9HMAdOXF4i5Z#o)8m)I z1UOtAzfxNB-!YTp=ddzZvsFR882Jt|Lo<;3Z8&BQ1cKo(wrF9ZkW#i;zGkucc`jYl zSWLjLFJV`YAxNIse6)Z^4e<&roh`=StXtr>s%;8$QBkC<&3zd_}^leRt8r!`OBV6 zCeAbv@O92fOs8f2Sghn^A#(TYS$hdX4@tz>Sks0zhHcD3loHa6Ogii_mobzBhg}>Ma-0FQ1+RLWI@Nx-;l^;EU$vg06&x&etm`07aA5SL@~-@{7DBA zQP`<`O&;XHhCa_&p7L}&H>9Cc=OyA9F1Go2#*QQp50G2~u-bn33!&>5(J(&3%nGnr zGQ8xVaS3XZGR|8)uRsWLkx|+wum(o6^+0&dVU6hM>UpWSZZ`mC0gk8OW~mqw6ncRQ zi)5}lKR!QKg=2{Qy=~`=iC)bhB~q#fWh4pqx55@yr1<|iMt_>QeV%bQ3NqV+vjqd4i`LJkHtv zNJhj|1`&CiJPqqSrzhnb**|v>lZ8jmb4KvMVN0fxeG}aq>@=T&!;y~BQV1fO+9~N* z*Uv-Ext&D6sc42^En&w+EEcL{&H+Z)j1bS7PkKPm@FEm(1PFc{Sw+t|ERE1-L$>&A z#xI8U5n)LvW*|LoTFDp4B~Bp}5LwWSqYER?Sq#E5g+8Rzj0xvN76JZ?JQ!&XH!uR@ zznP3dd0Yefl`%L(%)lOTsi1o*usr2|oFg}p?3`v?fadJ}u%M64Z%#ug5z$h9z6wY& z)i7d7&6~4ALOxYoDhq_l~f17Bep=;#?K`W>Affyz$ zu}U7D2?=;4gN7qi;8Q(71XBo)EHWobldR;m0O8AaL*-|}FC&s-Nh;$eRttTuoFLrb zCS!V40oNZ@Dhi(E%FR$v~f-0e2qO$=)3MXMlBhrGQp_^O7rXQ z&Xtv~J^~f;idt$Io^hH9Ol@SmAg>T1vOeYaDcA%oRq`-b4$Oo|LZi}2J#%sM3u4^U zRavgpP#={!bN=&qxy^U$1rk}fM4TCADI+RE2`EW(;4usP#C^!^`r1Upsyaa{)1 zX3d?R;1yYDU|Y^p(Z6y==7);F-dY0YWLJDEZ_5U9`ii2;gAzVDU_p}%A>}--1cZ8L zY~@PhJbXl)8F6!xufdOU5T1Qi1tbPuBPq((oQF8|eTq9_UWzgSl2pPx;vE{xfn2Qu z>zuqhd@;$)pkMb#4KO1_8`{JA)fvgPic@cJTt%r$ABv!Q)!Hy@OC*(r_8G)`+R-ir zpX~yy^%id)fHX(W?1>Xv>_VE4;E?GdbT#Qok5%LXN1Ko0KvHlDDcN+Ci{OgS6HZXc zEBs1%qc|?orCSNZ&l5;;ax52LyvkG3J8}*qu~X z$r=SovK@hx-CmX_P*(DPD*6%XXS}re5VJ^Dx^R*iPy`PDSh5$Rz_b;le8}FXOyvvk zM{;u}phwui$0|R7wkzQ{r-8KsLTVN`A#*6vuT7F=s4&7^NV^6_!B+P9s&GZ(y46{V zNmSn#ie}l2fx$IIF5STE2eyg!@+oS~`rxS`bO~9D7-5X5t0%{f z_M_scq?bp&(pHcIGq!CosNHWX%Q;C?kIXpX9vN z2np4)=zLLl(iENG(&>4#3DHP`X10nbN<_JwE>mEWBDI@w^q@C>MQ(zb$%{2mi#l&r)*?4kSJK%Q-i!}V!xaQ!K8Eq%FcEr~yx;zAgmi1! zk%*_aih^fF(oW%IPC^($Z663lZWXoT3p+r46nZd_=?;FHX&V>|_6@ExT|pQ# zF*DhRr=w|LCaFCi7q8)Q$>tVZsIU6|r)0FLQ zk-dN--6xTWc;S))4`QW*2qlV3Ah7P^wIbHAO!{$Qs9f8KuAn0%op|xYcNujSJde*K zoo`M{rHQ5BzZuShZ8MbEAQ&ZI7hb!lgda7r6xML*=q zV^BjvI^Zj6Xy=n&VN*PG!|NFReRHzTi?{kD*WZ5u94ti)jP69odF;!`3Iy~or@^U*5KZlBbM3sDxBO^O$mJX1V&5+|8F1rfq8bwD^y zQUlxcjK>wSoG>`$vT_8@%8XLt_*}Aw=s;Y8iC6|HZLahMk(Cq2=t6gNM~1Uabkq>; zd`V_J|N5q)!D>0VB28$-7KcWrVkCiRVmX_iMGh*kl_lj4l5--1slW>)R>NtQ7?p?d zbga9$Q?zyVnmwp>`W*AfSZonnRX1~*H>9nAT-OIg-AUd0HH-udFq2hQ%5*>MJ(5|r zXU9S_CnZRM+v`|d)7YfRv*`7$cu)F9vw9F@L1%gAA&nWSHaAgZ7@ z5-@($Am|Cju7vZ=;*#j{Rpmn@S&!CBU$C~ZzeiDTyNp`7hCx`ZIodc*$!cp+&d|sq z-5qeAN4h5@*A4^_KF3J)ZIlHH^c~5RA-Uh?jIAp7aS_Kntz)|=<2fRpLKCw(T_n-T z6^<%5ML{()6h7-j22l#13C!?bUVTm8e?T@bttiV+tu7L|91}SosbEfm zI6*QYG^aKlu}P$6mbe`v546$LZ_UlLA%A2&B5!g%|~`uMea70 zpV!@<;0YvQ52k>uv55uI+!tr4MxFg36AYbBpLyGCB)9cAO+;`x`PMMlwcAay0dt-_KW zCm@JDjB=L>g@vB^VRY(_DJRac`IPbHOncu9?Y!fPjR-bE*`-pRs@}vhZCSaHR6oR) zrnX2l(U1*{NX2$%36S4Kwk+3@(^V1AMl{S;npR_vt7?SNiBa=IUOa*F%u#HdwqGNMxi+@`4Zx z@g$^_JunmWSY35W#i;7Kq(^Q_F)^{tg$anxc%2*lf&hF7j0_>RiPiTEaH;BG2_sbC zx}k#f9%A?u?Qth4l=B(@#eeQs#>na@)Rq;v)ky*U0;{7@1k1=BeHB?9{bP(*jk}f| zqE`>=89ki4MR2WbCLWdi6DXa9IR%pRU^oWl6!aij4K0_E;b0t~f;f?{rUs3AQo89P zr&PQts?;Pc7}J9Sx#p{@0J=)RIfZ4Myrb0NG5qugCt+l$CV_kEQ%)ZpPrNb^Ro&-G z-3AMLg7nBelYGX;BOnX+lAl*N9r~G}OzFh1V6~&fU4~xA6;vY3=6AuzH;}!%-=L$l zlC>{)tCSiMH1#>8HoB^pq(|~e^qAjJ_9LP}%9+bnN_EJBiGg*6Ee7B}dsd>sn z?VsA$UY|>147&Qn@fRb_Ck?;}jW&ac%dN4(g3JsOuYg-z27p@^U zSEuX3^jitv7$h+KOiT?oaa`el^@OhGiApjuhM|lZnI2mBvX~JY7oj(Dc7NBS&NJ88 z_>X$ovgXPSD^U3WOwSmU!O;In9s(tk9pLz^E}$<}d7=eicNS#FJ}sZ)yEp0GhjkIz z#}&I}t@XIXXAKlK`u!Kv6B7>jEpkVA^peRehToXT$ZD{yEb{hY`<_MBSTZ1M+1Y4B{75ujduOC{$V(LBt&EtJYcwZZ*vK}9 zqWrGP_Y&OLnSpr0239F@Fw-wVSeR9O=_1OkZNG2frV&!1q^R9lOa2+M(UitG(<+nU zjd0zd4Pum5q`a(=jPYc~JH1p_HwiqdWf&KQl*)+5?}r?JLvd5Ol^K!4&K7u`=dd_w z+^>-|OGpc7h)R%gbTPeFa;xl3Tt%x=+$hHS3G0iqA z>;|!%)t(>K95qJ^yNdS9w7W2Ha<0_em=eL6Nee_v;abd0-L(cuG7=eYShhTtkYEc7 z=dBtqicMH7hDFuWXsSeubZQnU%*kKGzBhNzpq+F!p}*?KYD=QVcx{&6w0xtdn08^P*JF@%_}992 zU@{g&H?wKgXWT-3G8-(|g%l)i)E0=zH-UNgr(Zyd5EbE_fP`5|bKKsjpBO7Aag3u~ zK4f%^rF=xXNgMCbi9t@zqFbtl4<&)o*S|v!xC#OKny`@8dUdZJz$!CybKzMys_Gw$C?2|-ll6+K(lUo|oCWV26 zMz|ok{Uz*;o7J}6vJ|~t55scEgn3W6)kI|=zVC_3fk~r&6Q)v($Te$1u`Ml9J#VuiY1_uECXc4pVW2?cHNWG3 zgu!0=Tp6>pM|-v{*C(xs#1g5!C%8g-jigBXqW@2fCWdTjAR?yA-2y~#xmyt3w}i-3 z-Gauujwc2d?bKPq1Fv%hDO9#gU0wpXyaX^6w}GdWUR_=Scx!zm6`kr)5G-pso9iwD zpnAlYmjK8%YOmOyeBFp8Jym_FQP&ied*OK{3_Q(W7msBxq#CcX;H#W^Nq{aV?$Zts2_rql!<(TBRXgzF$M8&CD_y-jps*;|1vm ztLY4r)&^E9D`eJ6La{4=i6cLv4pqThUysu3Y$lEuhL}2C(Q-cNr|TO~zy_5E)m-6v zs(k55tN-TKoFO4AN^e$~*{Jv45x456n(zp+v5zPCL@g|nY9}v*sl+HPVpZ~=n(}d0O_v^y3+sRWW2xMW&U=>)zZEejM=ZOkdqe-&bfYk_Ic)ym9)3iXI zl%rmqln#9YR~bO+YN~G4DsbAE$XGm`eW0eiSOpBTV;BzyhNKtY+l{AQc0Ya=QuO{S z_X%`}r^9G*Wqtca7Rq4q>}@!9J4pNyxvn(}tYU+K)*$#WDP9xjQe?}nxfq`w-n%VoN?|K@B6hpmz72p@y zm4>so)G3tLu+~1&wIgy8lRRph&ErZ~B0TGzCWwTw2{;!mT7^DAl<~hDmQ8D zOyI>k+x^W58Cm>w2)I=~O0fbRfh2)ZSo183&LW~oo7k2=a|GMk^xu`3HR9&u9#LC( zZJyQAygx#e0)6ah#)W1RrLRHQ8@>D&{%FL_E6u5bmH7j&pT!|g8War-0$RI2h0;l! zLw*DIjZ9Ah%YVRPTP{>pgu5tLsT8MhRO@92U)m6?JTl1SZV9^?B^>&ydgP3*8u7_> zs49f%v38{1JB&pX-Rhqh*K8}&L&?!)my_>5Ig=x(%hX}WQMIBYO~5rvyqV#J3Kb#| zrDc}?_{FwDd&&w9RCi)ksU!Xz$qd!j7w;k5&52O0bB`C`N?w&g+(ue|X4(K2if9mT ze!(2aM*HjwwuSJ2zj-wB36L=-mvJKTEICT^gzCw9A!w}1PbCBI8Hqo}fw&vSlAcc` zT{8^(ZJf&1WC1ut`s*ClI7zniE&DDrNGpnDvaad@UM#LZ=aCH=B>r}+g#Op zl1Ywm;QS#=$FAlPng*JgR2C||zG93gVQ(+-h#sbBMY7}7Pxjxm4 zb-mYCa4R_g88nh4xtA1rvp1-Hk`hQ{j3>{Sz@BDw@B}VGF@t9qRt6RDnqak3jpwh2 zQzgv`rp#g2R6_{=Fh4?)s4-GePu=C!0S)on4|6cw2mZtF=cOd zhbk@cS+jX_g9JL&-hv5`qahND+TK(bNj<#)nvy)Cn}~G^*V48Uw1O-Q!P~rjDbpi~ zZcmXc++(HAj~aWmws4@Nw|&F?6BTY^Hm`w^rnLH#D${qu7K}S)r$4e{i*ArnbK0=G zn#QG!3>YFG_K=K-zNig7Tgp-u&sL*qBDT3Xi{ng!Ajv0d|P`z~;dAJb9y#IJ2*SF5Huv8u0~n z0gIb~aawzq{N+t=vVvr{r%;#Q?dT%}An&$Nh=~WG3n1KQax}q9HVY ztpKjJ3oFLhK$IJWYl}92=}%Gz!W=2KC%B6I>ZYNJ94y!bCCPX{)sq!T7KlWqpqLD$ z0jkJ)FX;H%z5jSMeq}wl6mF2{=fkU6+?8PbGWw^XND|baq&y>UjwBM~19%wdHM$qRGZO)$dTkC+ARuvla{?Llt~| z*J$W3^N;dF|7wi-+h!AM)OYT(P7ueIMVfd?itI3(f}2D+Ba9RjQITMgn^=Z2LTewN zSNxn}78gYxv){!n&zs@yFz2yP=DJYQL6)1JmsxHJjhw?DbwS;0Wt`Y`Lbi~_bX?*V z2X};_z-DC3O;TmS3|3JPqgND26_+%&D2fh(d?9_y)?a;*M3cafq(P6GLAgkyvkNC= zCI)bUSY-?t;$zZpm0v|lsG`$+;SffOI?lUsz!Pd?0C3ryH76P;E2zG<45K1$YDGsd z6D|^+WQ2qZCwy?f8d2)IoQ7+KR+)bkzTr4#Sd!54(@t*CS8%b7d1d+B&08bwIA?d_ zi-3gM5rPrv7JHXZO}ed|fJHr*)jgFVFpqX~{tiPxcO^QMXFAxfA399r)|4IEuh-Z1 zrtK^UKZv#u97(*%S+{2|bC;f~lZMSn@E%AuDa1Ojk4j&+Fh~lo*C|#dL`Z6a!l}%y zrA?5hjo-mK()l)mk^V!Id>L3(Q|Pdk4H+5(4o)W1llcgOwtkyf{x3N&rj)86guWRf z{AX)Tl^zg0Div4t9vU*=Cnr`V7Z3{7I~W#95|`~t#5U-Mep%jy!iWBj=!tFY(wkBo-2~K zK>19ui_aKd&TJBt!&&H2T;X*HdFFS~W!2y!3YW_(Vz`NwqO0_P9NGFR@T*j(q9|=y r&q, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-28 18:11+0000\n" +"PO-Revision-Date: 2024-03-28 18:22+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"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.4.2\n" + +#: lib/cli/args_extract_convert.py:46 lib/cli/args_extract_convert.py:56 +#: lib/cli/args_extract_convert.py:64 lib/cli/args_extract_convert.py:122 +#: lib/cli/args_extract_convert.py:479 lib/cli/args_extract_convert.py:488 +msgid "Data" +msgstr "Данные" + +#: lib/cli/args_extract_convert.py:48 +msgid "" +"Input directory or video. Either a directory containing the image files you " +"wish to process or path to a video file. NB: This should be the source video/" +"frames NOT the source faces." +msgstr "" +"Входная папка или видео. Либо каталог, содержащий файлы изображений, которые " +"вы хотите обработать, либо путь к видеофайлу. ПРИМЕЧАНИЕ: Это должно быть " +"исходное видео/кадры, а не исходные лица." + +#: lib/cli/args_extract_convert.py:57 +msgid "Output directory. This is where the converted files will be saved." +msgstr "Выходная папка. Здесь будут сохранены преобразованные файлы." + +#: lib/cli/args_extract_convert.py:66 +msgid "" +"Optional path to an alignments file. Leave blank if the alignments file is " +"at the default location." +msgstr "" +"Необязательный путь к файлу выравниваний. Оставьте пустым, если файл " +"выравнивания находится в месте по умолчанию." + +#: lib/cli/args_extract_convert.py:97 +msgid "" +"Extract faces from image or video sources.\n" +"Extraction plugins can be configured in the 'Settings' Menu" +msgstr "" +"Извлечение лиц из источников изображений или видео.\n" +"Плагины извлечения можно настроить в меню \"Настройки\"" + +#: lib/cli/args_extract_convert.py:124 +msgid "" +"R|If selected then the input_dir should be a parent folder containing " +"multiple videos and/or folders of images you wish to extract from. The faces " +"will be output to separate sub-folders in the output_dir." +msgstr "" +"R|Если выбрано, то input_dir должен быть родительской папкой, содержащей " +"несколько видео и/или папок с изображениями, из которых вы хотите извлечь " +"изображение. Лица будут выведены в отдельные вложенные папки в output_dir." + +#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:150 +#: lib/cli/args_extract_convert.py:163 lib/cli/args_extract_convert.py:202 +#: lib/cli/args_extract_convert.py:220 lib/cli/args_extract_convert.py:233 +#: lib/cli/args_extract_convert.py:243 lib/cli/args_extract_convert.py:253 +#: lib/cli/args_extract_convert.py:499 lib/cli/args_extract_convert.py:525 +#: lib/cli/args_extract_convert.py:564 +msgid "Plugins" +msgstr "Плагины" + +#: lib/cli/args_extract_convert.py:135 +msgid "" +"R|Detector to use. Some of these have configurable settings in '/config/" +"extract.ini' or 'Settings > Configure Extract 'Plugins':\n" +"L|cv2-dnn: A CPU only extractor which is the least reliable and least " +"resource intensive. Use this if not using a GPU and time is important.\n" +"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " +"than other GPU detectors but can often return more false positives.\n" +"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " +"fewer false positives than other GPU detectors, but is a lot more resource " +"intensive." +msgstr "" +"R|Детектор для использования. Некоторые из них имеют настраиваемые параметры " +"в '/config/extract.ini' или 'Settings > Configure Extract 'Plugins':\n" +"L|cv2-dnn: Экстрактор только для процессора, который является наименее " +"надежным и наименее ресурсоемким. Используйте его, если не используется GPU " +"и важно время.\n" +"L|mtcnn: Хороший детектор. Быстрый на CPU, еще быстрее на GPU. Использует " +"меньше ресурсов, чем другие детекторы на GPU, но часто может давать больше " +"ложных срабатываний.\n" +"L|s3fd: Лучший детектор. Медленный на CPU, более быстрый на GPU. Может " +"обнаружить больше лиц и меньше ложных срабатываний, чем другие детекторы на " +"GPU, но требует гораздо больше ресурсов." + +#: lib/cli/args_extract_convert.py:152 +msgid "" +"R|Aligner to use.\n" +"L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " +"but less accurate. Only use this if not using a GPU and time is important.\n" +"L|fan: Best aligner. Fast on GPU, slow on CPU." +msgstr "" +"R|Выравниватель для использования.\n" +"L|cv2-dnn: Детектор ориентиров только для процессора. Быстрее, менее " +"ресурсоемкий, но менее точный. Используйте его, только если не используется " +"GPU и важно время.\n" +"L|fan: Лучший выравниватель. Быстрый на GPU, медленный на CPU." + +#: lib/cli/args_extract_convert.py:165 +msgid "" +"R|Additional Masker(s) to use. The masks generated here will all take up GPU " +"RAM. You can select none, one or multiple masks, but the extraction may take " +"longer the more you select. NB: The Extended and Components (landmark based) " +"masks are automatically generated on extraction.\n" +"L|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).\n" +"L|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 so will not use any additional VRAM.\n" +"L|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.\n" +"L|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.\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.\n" +"The auto generated masks are as follows:\n" +"L|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.\n" +"L|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.\n" +"(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" +msgstr "" +"R|Дополнительный маскер(ы) для использования. Все маски, созданные здесь, " +"будут занимать видеопамять GPU. Вы можете выбрать ни одной, одну или " +"несколько масок, но извлечение может занять больше времени, чем больше масок " +"вы выберете. Примечание: Расширенные маски и маски компонентов (на основе " +"ориентиров) генерируются автоматически при извлечении.\n" +"L|bisenet-fp: Относительно легкая маска на основе NN, которая обеспечивает " +"более точный контроль над маскируемой областью, включая полное маскирование " +"головы (настраивается в настройках маски).\n" +"L|custom: Фиктивная маска, которая заполняет область маски всеми 1 или 0 " +"(настраивается в настройках). Она необходима только в том случае, если вы " +"собираетесь вручную редактировать пользовательские маски в ручном " +"инструменте. Эта маска не задействует GPU, поэтому не будет использовать " +"дополнительную память VRAM.\n" +"L|vgg-clear: Маска предназначена для интеллектуальной сегментации " +"преимущественно фронтальных лиц без препятствий. Профильные лица и " +"препятствия могут привести к снижению производительности.\n" +"L|vgg-obstructed: Маска, разработанная для интеллектуальной сегментации " +"преимущественно фронтальных лиц. Модель маски была специально обучена " +"распознавать некоторые препятствия на лице (руки и очки). Лица в профиль " +"могут иметь низкую производительность.\n" +"L|unet-dfl: Маска, разработанная для интеллектуальной сегментации " +"преимущественно фронтальных лиц. Модель маски была обучена членами " +"сообщества и для дальнейшего описания нуждается в тестировании. Профильные " +"лица могут привести к низкой производительности.\n" +"Автоматически сгенерированные маски выглядят следующим образом:\n" +"L|components: Маска, разработанная для сегментации лица на основе " +"расположения ориентиров. Для создания маски вокруг внешних ориентиров " +"строится выпуклая оболочка.\n" +"L|extended: Маска, предназначенная для сегментации лица на основе " +"расположения ориентиров. Выпуклый корпус строится вокруг внешних ориентиров, " +"и маска расширяется вверх на лоб.\n" +"(например: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" + +#: lib/cli/args_extract_convert.py:204 +msgid "" +"R|Performing normalization can help the aligner better align faces with " +"difficult lighting conditions at an extraction speed cost. Different methods " +"will yield different results on different sets. NB: This does not impact the " +"output face, just the input to the aligner.\n" +"L|none: Don't perform normalization on the face.\n" +"L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " +"face.\n" +"L|hist: Equalize the histograms on the RGB channels.\n" +"L|mean: Normalize the face colors to the mean." +msgstr "" +"R|Проведение нормализации может помочь выравнивателю лучше выравнивать лица " +"со сложными условиями освещения при затратах на скорость извлечения. " +"Различные методы дают разные результаты на разных наборах. NB: Это не влияет " +"на выходное лицо, только на вход выравнивателя.\n" +"L|none: Не выполнять нормализацию лица.\n" +"L|clahe: Выполнить для лица адаптивную гистограммную эквализацию с " +"ограничением контраста.\n" +"L|hist: Уравнять гистограммы в каналах RGB.\n" +"L|mean: Нормализовать цвета лица к среднему значению." + +#: lib/cli/args_extract_convert.py:222 +msgid "" +"The number of times to re-feed the detected face into the aligner. Each time " +"the face is re-fed into the aligner the bounding box is adjusted by a small " +"amount. The final landmarks are then averaged from each iteration. Helps to " +"remove 'micro-jitter' but at the cost of slower extraction speed. The more " +"times the face is re-fed into the aligner, the less micro-jitter should " +"occur but the longer extraction will take." +msgstr "" +"Количество повторных подач обнаруженной области лица в выравниватель. При " +"каждой повторной подаче лица в выравниватель ограничивающая рамка " +"корректируется на небольшую величину. Затем конечные ориентиры усредняются " +"по результатам каждой итерации. Это помогает устранить \"микро-дрожание\", " +"но ценой снижения скорости извлечения. Чем больше раз лицо повторно подается " +"в выравниватель, тем меньше микро-дрожание, но тем больше времени займет " +"извлечение." + +#: lib/cli/args_extract_convert.py:235 +msgid "" +"Re-feed the initially found aligned face through the aligner. Can help " +"produce better alignments for faces that are rotated beyond 45 degrees in " +"the frame or are at extreme angles. Slows down extraction." +msgstr "" +"Повторная подача первоначально найденной выровненной области лица через " +"выравниватель. Может помочь получить лучшее выравнивание для лиц, повернутых " +"в кадре более чем на 45 градусов или расположенных под экстремальными " +"углами. Замедляет извлечение." + +#: lib/cli/args_extract_convert.py:245 +msgid "" +"If a face isn't found, rotate the images to try to find a face. Can find " +"more faces at the cost of extraction speed. Pass in a single number to use " +"increments of that size up to 360, or pass in a list of numbers to enumerate " +"exactly what angles to check." +msgstr "" +"Если лицо не найдено, поворачивает изображения, чтобы попытаться найти лицо. " +"Может найти больше лиц ценой снижения скорости извлечения. Передайте одно " +"число, чтобы использовать приращения этого размера до 360, или передайте " +"список чисел, чтобы перечислить, какие именно углы нужно проверить." + +#: lib/cli/args_extract_convert.py:255 +msgid "" +"Obtain and store face identity encodings from VGGFace2. Slows down extract a " +"little, but will save time if using 'sort by face'" +msgstr "" +"Получение и хранение кодировок идентификации лица из VGGFace2. Немного " +"замедляет извлечение, но экономит время при использовании \"сортировки по " +"лицам\"." + +#: lib/cli/args_extract_convert.py:265 lib/cli/args_extract_convert.py:276 +#: lib/cli/args_extract_convert.py:289 lib/cli/args_extract_convert.py:303 +#: lib/cli/args_extract_convert.py:610 lib/cli/args_extract_convert.py:619 +#: lib/cli/args_extract_convert.py:634 lib/cli/args_extract_convert.py:647 +#: lib/cli/args_extract_convert.py:661 +msgid "Face Processing" +msgstr "Обработка лиц" + +#: lib/cli/args_extract_convert.py:267 +msgid "" +"Filters out faces detected below this size. Length, in pixels across the " +"diagonal of the bounding box. Set to 0 for off" +msgstr "" +"Отфильтровывает лица, обнаруженные ниже этого размера. Длина в пикселях по " +"диагонали ограничивающего поля. Установите значение 0, чтобы выключить" + +#: lib/cli/args_extract_convert.py:278 +msgid "" +"Optionally filter out people who you do not wish to extract by passing in " +"images of those people. Should be a small variety of images at different " +"angles and in different conditions. A folder containing the required images " +"or multiple image files, space separated, can be selected." +msgstr "" +"По желанию отфильтруйте людей, которых вы не хотите извлекать, передав " +"изображения этих людей. Должно быть небольшое разнообразие изображений под " +"разными углами и в разных условиях. Можно выбрать папку, содержащую " +"необходимые изображения, или несколько файлов изображений, разделенных " +"пробелами." + +#: lib/cli/args_extract_convert.py:291 +msgid "" +"Optionally select people you wish to extract by passing in images of that " +"person. Should be a small variety of images at different angles and in " +"different conditions A folder containing the required images or multiple " +"image files, space separated, can be selected." +msgstr "" +"По желанию выберите людей, которых вы хотите извлечь, передав изображения " +"этого человека. Должно быть небольшое разнообразие изображений под разными " +"углами и в разных условиях. Можно выбрать папку, содержащую необходимые " +"изображения, или несколько файлов изображений, разделенных пробелами." + +#: lib/cli/args_extract_convert.py:305 +msgid "" +"For use with the optional nfilter/filter files. Threshold for positive face " +"recognition. Higher values are stricter." +msgstr "" +"Для использования с дополнительными файлами nfilter/filter. Порог для " +"положительного распознавания лица. Более высокие значения являются более " +"строгими." + +#: lib/cli/args_extract_convert.py:314 lib/cli/args_extract_convert.py:327 +#: lib/cli/args_extract_convert.py:340 lib/cli/args_extract_convert.py:352 +msgid "output" +msgstr "вывод" + +#: lib/cli/args_extract_convert.py:316 +msgid "" +"The output size of extracted faces. Make sure that the model you intend to " +"train supports your required size. This will only need to be changed for hi-" +"res models." +msgstr "" +"Выходной размер извлеченных лиц. Убедитесь, что модель, которую вы " +"собираетесь тренировать, поддерживает требуемый размер. Это необходимо " +"изменить только для моделей высокого разрешения." + +#: lib/cli/args_extract_convert.py:329 +msgid "" +"Extract every 'nth' frame. This option will skip frames when extracting " +"faces. For example a value of 1 will extract faces from every frame, a value " +"of 10 will extract faces from every 10th frame." +msgstr "" +"Извлекать каждый 'n-й' кадр. Этот параметр пропускает кадры при извлечении " +"лиц. Например, значение 1 будет извлекать лица из каждого кадра, значение 10 " +"будет извлекать лица из каждого 10-го кадра." + +#: lib/cli/args_extract_convert.py:342 +msgid "" +"Automatically save the alignments file after a set amount of frames. By " +"default the alignments file is only saved at the end of the extraction " +"process. NB: If extracting in 2 passes then the alignments file will only " +"start to be saved out during the second pass. WARNING: Don't interrupt the " +"script when writing the file because it might get corrupted. Set to 0 to " +"turn off" +msgstr "" +"Автоматическое сохранение файла выравнивания после заданного количества " +"кадров. По умолчанию файл выравнивания сохраняется только в конце процесса " +"извлечения. Примечание: Если извлечение выполняется в 2 прохода, то файл " +"выравнивания начнет сохраняться только во время второго прохода. " +"ПРЕДУПРЕЖДЕНИЕ: Не прерывайте работу скрипта при записи файла, так как он " +"может быть поврежден. Установите значение 0, чтобы отключить" + +#: lib/cli/args_extract_convert.py:353 +msgid "Draw landmarks on the ouput faces for debugging purposes." +msgstr "Нарисуйте ориентиры на выходящих гранях для отладки." + +#: lib/cli/args_extract_convert.py:359 lib/cli/args_extract_convert.py:369 +#: lib/cli/args_extract_convert.py:377 lib/cli/args_extract_convert.py:384 +#: lib/cli/args_extract_convert.py:674 lib/cli/args_extract_convert.py:686 +#: lib/cli/args_extract_convert.py:695 lib/cli/args_extract_convert.py:716 +#: lib/cli/args_extract_convert.py:722 +msgid "settings" +msgstr "настройки" + +#: lib/cli/args_extract_convert.py:361 +msgid "" +"Don't run extraction in parallel. Will run each part of the extraction " +"process separately (one after the other) rather than all at the same time. " +"Useful if VRAM is at a premium." +msgstr "" +"Не запускать извлечение параллельно. Каждая часть процесса извлечения будет " +"выполняться отдельно (одна за другой), а не одновременно. Полезно, если " +"память VRAM ограничена." + +#: lib/cli/args_extract_convert.py:371 +msgid "" +"Skips frames that have already been extracted and exist in the alignments " +"file" +msgstr "" +"Пропускает кадры, которые уже были извлечены и существуют в файле " +"выравнивания" + +#: lib/cli/args_extract_convert.py:378 +msgid "Skip frames that already have detected faces in the alignments file" +msgstr "" +"Пропустить кадры, в которых уже есть обнаруженные лица в файле выравнивания" + +#: lib/cli/args_extract_convert.py:385 +msgid "Skip saving the detected faces to disk. Just create an alignments file" +msgstr "" +"Не сохранять обнаруженные лица на диск. Просто создать файл выравнивания" + +#: lib/cli/args_extract_convert.py:459 +msgid "" +"Swap the original faces in a source video/images to your final faces.\n" +"Conversion plugins can be configured in the 'Settings' Menu" +msgstr "" +"Поменять исходные лица в исходном видео/изображении на ваши конечные лица.\n" +"Плагины конвертирования можно настроить в меню \"Настройки\"" + +#: lib/cli/args_extract_convert.py:481 +msgid "" +"Only required if converting from images to video. Provide The original video " +"that the source frames were extracted from (for extracting the fps and " +"audio)." +msgstr "" +"Требуется только при преобразовании из изображений в видео. Предоставьте " +"исходное видео, из которого были извлечены исходные кадры (для извлечения " +"кадров в секунду и звука)." + +#: lib/cli/args_extract_convert.py:490 +msgid "" +"Model directory. The directory containing the trained model you wish to use " +"for conversion." +msgstr "" +"Папка модели. Папка, содержащая обученную модель, которую вы хотите " +"использовать для преобразования." + +#: lib/cli/args_extract_convert.py:501 +msgid "" +"R|Performs color adjustment to the swapped face. Some of these options have " +"configurable settings in '/config/convert.ini' or 'Settings > Configure " +"Convert Plugins':\n" +"L|avg-color: Adjust the mean of each color channel in the swapped " +"reconstruction to equal the mean of the masked area in the original image.\n" +"L|color-transfer: Transfers the color distribution from the source to the " +"target image using the mean and standard deviations of the L*a*b* color " +"space.\n" +"L|manual-balance: Manually adjust the balance of the image in a variety of " +"color spaces. Best used with the Preview tool to set correct values.\n" +"L|match-hist: Adjust the histogram of each color channel in the swapped " +"reconstruction to equal the histogram of the masked area in the original " +"image.\n" +"L|seamless-clone: Use cv2's seamless clone function to remove extreme " +"gradients at the mask seam by smoothing colors. Generally does not give very " +"satisfactory results.\n" +"L|none: Don't perform color adjustment." +msgstr "" +"R|Производит корректировку цвета поменявшегося лица. Некоторые из этих " +"параметров настраиваются в '/config/convert.ini' или 'Настройки > Настроить " +"плагины конвертации':\n" +"L|avg-color: корректирует среднее значение каждого цветового канала в " +"реконструкции, чтобы оно было равно среднему значению маскированной области " +"в исходном изображении.\n" +"L|color-transfer: Переносит распределение цветов с исходного изображения на " +"целевое, используя среднее и стандартные отклонения цветового пространства " +"L*a*b*.\n" +"L|manual-balance: Ручная настройка баланса изображения в различных цветовых " +"пространствах. Лучше всего использовать с инструментом предварительного " +"просмотра для установки правильных значений.\n" +"L|match-hist: Настроить гистограмму каждого цветового канала в измененном " +"восстановлении так, чтобы она соответствовала гистограмме маскированной " +"области исходного изображения.\n" +"L|seamless-clone: Используйте функцию бесшовного клонирования cv2 для " +"удаления экстремальных градиентов на шве маски путем сглаживания цветов. " +"Обычно дает не очень удовлетворительные результаты.\n" +"L|none: Не выполнять коррекцию цвета." + +#: lib/cli/args_extract_convert.py:527 +msgid "" +"R|Masker to use. NB: The mask you require must exist within the alignments " +"file. You can add additional masks with the Mask Tool.\n" +"L|none: Don't use a mask.\n" +"L|bisenet-fp_face: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'face' or " +"'legacy' centering.\n" +"L|bisenet-fp_head: Relatively lightweight NN based mask that provides more " +"refined control over the area to be masked (configurable in mask settings). " +"Use this version of bisenet-fp if your model is trained with 'head' " +"centering.\n" +"L|custom_face: Custom user created, face centered mask.\n" +"L|custom_head: Custom user created, head centered mask.\n" +"L|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.\n" +"L|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.\n" +"L|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.\n" +"L|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.\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.\n" +"L|predicted: If the 'Learn Mask' option was enabled during training, this " +"will use the mask that was created by the trained model." +msgstr "" +"R|Маскер для использования. Примечание: Нужная маска должна существовать в " +"файле выравнивания. Вы можете добавить дополнительные маски с помощью " +"инструмента Mask Tool.\n" +"L|none: Не использовать маску.\n" +"L|bisenet-fp_face: Относительно легкая маска на основе NN, которая " +"обеспечивает более точный контроль над маскируемой областью (настраивается в " +"настройках маски). Используйте эту версию bisenet-fp, если ваша модель " +"обучена с центрированием 'face' или 'legacy'.\n" +"L|bisenet-fp_head: Относительно легкая маска на основе NN, которая " +"обеспечивает более точный контроль над маскируемой областью (настраивается в " +"настройках маски). Используйте эту версию bisenet-fp, если ваша модель " +"обучена с центрированием по \"голове\".\n" +"L|custom_face: Пользовательская маска, созданная пользователем и " +"центрированная по лицу.\n" +"L|custom_head: Созданная пользователем маска, центрированная по голове.\n" +"L|components: Маска, разработанная для сегментации лица на основе " +"расположения ориентиров. Для создания маски вокруг внешних ориентиров " +"строится выпуклая оболочка.\n" +"L|extended: Маска, предназначенная для сегментации лица на основе " +"расположения ориентиров. Выпуклый корпус строится вокруг внешних ориентиров, " +"и маска расширяется вверх на лоб.\n" +"L|vgg-clear: Маска предназначена для интеллектуальной сегментации " +"преимущественно фронтальных лиц без препятствий. Профильные лица и " +"препятствия могут привести к снижению производительности.\n" +"L|vgg-obstructed: Маска, разработанная для интеллектуальной сегментации " +"преимущественно фронтальных лиц. Модель маски была специально обучена " +"распознавать некоторые препятствия на лице (руки и очки). Лица в профиль " +"могут иметь низкую производительность.\n" +"L|unet-dfl: Маска, разработанная для интеллектуальной сегментации " +"преимущественно фронтальных лиц. Модель маски была обучена членами " +"сообщества и для дальнейшего описания нуждается в тестировании. Профильные " +"лица могут привести к низкой производительности.\n" +"L|predicted: Если во время обучения была включена опция 'Изучить Маску', то " +"будет использоваться маска, созданная обученной моделью." + +#: lib/cli/args_extract_convert.py:566 +msgid "" +"R|The plugin to use to output the converted images. The writers are " +"configurable in '/config/convert.ini' or 'Settings > Configure Convert " +"Plugins:'\n" +"L|ffmpeg: [video] Writes out the convert straight to video. When the input " +"is a series of images then the '-ref' (--reference-video) parameter must be " +"set.\n" +"L|gif: [animated image] Create an animated gif.\n" +"L|opencv: [images] The fastest image writer, but less options and formats " +"than other plugins.\n" +"L|patch: [images] Outputs the raw swapped face patch, along with the " +"transformation matrix required to re-insert the face back into the original " +"frame. Use this option if you wish to post-process and composite the final " +"face within external tools.\n" +"L|pillow: [images] Slower than opencv, but has more options and supports " +"more formats." +msgstr "" +"R|Плагин, который нужно использовать для вывода преобразованных изображений. " +"Записи настраиваются в '/config/convert.ini' или 'Настройки > Настроить " +"плагины конвертации:'\n" +"L|ffmpeg: [видео] Записывает конвертацию прямо в видео. Если на вход " +"подается серия изображений, необходимо установить параметр '-ref' (--" +"reference-video).\n" +"L|gif: [анимированное изображение] Создает анимированный gif.\n" +"L|opencv: [изображения] Самый быстрый редактор изображений, но имеет меньше " +"опций и форматов, чем другие плагины.\n" +"L|patch: [изображения] Выводит необработанный фрагмент измененного лица " +"вместе с матрицей преобразования, необходимой для повторной вставки лица " +"обратно в исходный кадр.\n" +"L|pillow: [изображения] Медленнее, чем opencv, но имеет больше опций и " +"поддерживает больше форматов." + +#: lib/cli/args_extract_convert.py:587 lib/cli/args_extract_convert.py:596 +#: lib/cli/args_extract_convert.py:707 +msgid "Frame Processing" +msgstr "Обработка лиц" + +#: lib/cli/args_extract_convert.py:589 +#, python-format +msgid "" +"Scale the final output frames by this amount. 100%% will output the frames " +"at source dimensions. 50%% at half size 200%% at double size" +msgstr "" +"Масштабирование конечных выходных кадров на эту величину. 100%% выводит " +"кадры в исходном размере. 50%% при половинном размере 200%% при двойном " +"размере" + +#: lib/cli/args_extract_convert.py:598 +msgid "" +"Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " +"--frame-ranges 10-50 90-100. Frames falling outside of the selected range " +"will be discarded unless '-k' (--keep-unchanged) is selected. NB: If you are " +"converting from images, then the filenames must end with the frame-number!" +msgstr "" +"Диапазоны кадров для применения переноса, например, для кадров с 10 по 50 и " +"с 90 по 100 используйте --frame-ranges 10-50 90-100. Кадры, выходящие за " +"пределы выбранного диапазона, будут отброшены, если не выбрана опция '-k' (--" +"keep-unchanged). Примечание: Если вы конвертируете из изображений, то имена " +"файлов должны заканчиваться номером кадра!" + +#: lib/cli/args_extract_convert.py:612 +msgid "" +"Scale the swapped face by this percentage. Positive values will enlarge the " +"face, Negative values will shrink the face." +msgstr "" +"Увеличить масштаб нового лица на этот процент. Положительные значения " +"увеличат лицо, в то время как отрицательные значения уменьшат его." + +#: lib/cli/args_extract_convert.py:621 +msgid "" +"If you have not cleansed your alignments file, then you can filter out faces " +"by defining a folder here that contains the faces extracted from your input " +"files/video. If this folder is defined, then only faces that exist within " +"your alignments file and also exist within the specified folder will be " +"converted. Leaving this blank will convert all faces that exist within the " +"alignments file." +msgstr "" +"Если вы не очистили свой файл выравнивания, то вы можете отфильтровать лица, " +"определив здесь папку, содержащую лица, извлеченные из ваших входных файлов/" +"видео. Если эта папка определена, то будут преобразованы только те лица, " +"которые существуют в вашем файле выравнивания, а также в указанной папке. " +"Если оставить этот параметр пустым, будут преобразованы все лица, " +"существующие в файле выравнивания." + +#: lib/cli/args_extract_convert.py:636 +msgid "" +"Optionally filter out people who you do not wish to process by passing in an " +"image of that person. Should be a front portrait with a single person in the " +"image. Multiple images can be added space separated. NB: Using face filter " +"will significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"По желанию отфильтровать людей, которых вы не хотите обрабатывать, передав " +"изображение этого человека. Это должен быть фронтальный портрет с " +"изображением одного человека. Можно добавить несколько изображений, " +"разделенных пробелами. Примечание: Использование фильтра лиц значительно " +"снизит скорость извлечения, а его точность не гарантируется." + +#: lib/cli/args_extract_convert.py:649 +msgid "" +"Optionally select people you wish to process by passing in an image of that " +"person. Should be a front portrait with a single person in the image. " +"Multiple images can be added space separated. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"По желанию выберите людей, которых вы хотите обработать, передав изображение " +"этого человека. Это должен быть фронтальный портрет с изображением одного " +"человека. Можно добавить несколько изображений, разделенных пробелами. " +"Примечание: Использование фильтра лиц значительно снизит скорость " +"извлечения, а его точность не гарантируется." + +#: lib/cli/args_extract_convert.py:663 +msgid "" +"For use with the optional nfilter/filter files. Threshold for positive face " +"recognition. Lower values are stricter. NB: Using face filter will " +"significantly decrease extraction speed and its accuracy cannot be " +"guaranteed." +msgstr "" +"Для использования с дополнительными файлами nfilter/filter. Порог для " +"положительного распознавания лиц. Более низкие значения являются более " +"строгими. Примечание: Использование фильтра лиц значительно снизит скорость " +"извлечения, а его точность не гарантируется." + +#: lib/cli/args_extract_convert.py:676 +msgid "" +"The maximum number of parallel processes for performing conversion. " +"Converting images is system RAM heavy so it is possible to run out of memory " +"if you have a lot of processes and not enough RAM to accommodate them all. " +"Setting this to 0 will use the maximum available. No matter what you set " +"this to, it will never attempt to use more processes than are available on " +"your system. If singleprocess is enabled this setting will be ignored." +msgstr "" +"Максимальное количество параллельных процессов для выполнения конвертации. " +"Конвертирование изображений занимает много системной оперативной памяти, " +"поэтому может закончиться память, если у вас много процессов и недостаточно " +"оперативной памяти для их размещения. Если установить значение 0, будет " +"использован максимум доступной памяти. Независимо от того, какое значение вы " +"установите, программа никогда не будет пытаться использовать больше " +"процессов, чем доступно в вашей системе. Если включена однопоточная " +"обработка, этот параметр будет проигнорирован." + +#: lib/cli/args_extract_convert.py:688 +msgid "" +"[LEGACY] This only needs to be selected if a legacy model is being loaded or " +"if there are multiple models in the model folder" +msgstr "" +"[ОТБРОШЕН] Этот параметр необходимо выбрать только в том случае, если " +"загружается устаревшая модель или если в папке моделей имеется несколько " +"моделей" + +#: lib/cli/args_extract_convert.py:697 +msgid "" +"Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " +"alignments file for your destination video. However, if you wish you can " +"generate the alignments on-the-fly by enabling this option. This will use an " +"inferior extraction pipeline and will lead to substandard results. If an " +"alignments file is found, this option will be ignored." +msgstr "" +"Включить преобразование \"на лету\". НЕ рекомендуется. Вы должны " +"сгенерировать чистый файл выравнивания для конечного видео. Однако при " +"желании вы можете генерировать выравнивания \"на лету\", включив эту опцию. " +"При этом будет использоваться некачественный конвейер извлечения, что " +"приведет к некачественным результатам. Если файл выравнивания найден, этот " +"параметр будет проигнорирован." + +#: lib/cli/args_extract_convert.py:709 +msgid "" +"When used with --frame-ranges outputs the unchanged frames that are not " +"processed instead of discarding them." +msgstr "" +"При использовании с --frame-ranges выводит неизмененные кадры, которые не " +"были обработаны, вместо того, чтобы отбрасывать их." + +#: lib/cli/args_extract_convert.py:717 +msgid "Swap the model. Instead converting from of A -> B, converts B -> A" +msgstr "" +"Поменять модель местами. Вместо преобразования из A -> B, преобразуется B -> " +"A" + +#: lib/cli/args_extract_convert.py:723 +msgid "Disable multiprocessing. Slower but less resource intensive." +msgstr "Отключение многопоточной обработки. Медленнее, но менее ресурсоемко." diff --git a/locales/ru/LC_MESSAGES/lib.cli.args_train.mo b/locales/ru/LC_MESSAGES/lib.cli.args_train.mo new file mode 100644 index 0000000000000000000000000000000000000000..f300361161083773dbbf770f2a180053a681f6f6 GIT binary patch literal 21253 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 UIYjLk6E}=Q3F$P_vu1PhfA)qP=l}o! literal 0 HcmV?d00001 diff --git a/locales/ru/LC_MESSAGES/lib.cli.args_train.po b/locales/ru/LC_MESSAGES/lib.cli.args_train.po new file mode 100755 index 0000000000..4e41e6a3f2 --- /dev/null +++ b/locales/ru/LC_MESSAGES/lib.cli.args_train.po @@ -0,0 +1,1045 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-28 18:04+0000\n" +"PO-Revision-Date: 2024-03-28 18:18+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"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.4.2\n" + +#: lib/cli/args_train.py:30 +msgid "" +"Train a model on extracted original (A) and swap (B) faces.\n" +"Training models can take a long time. Anything from 24hrs to over a week\n" +"Model plugins can be configured in the 'Settings' Menu" +msgstr "" +"Обучение модели на извлеченных оригинальных (A) и подмененных (B) лицах.\n" +"Обучение моделей может занять много времени. От 24 часов до недели.\n" +"Плагины для моделей можно настроить в меню \"Настройки\"" + +#: lib/cli/args_train.py:49 lib/cli/args_train.py:58 +msgid "faces" +msgstr "лица" + +#: lib/cli/args_train.py:51 +msgid "" +"Input directory. A directory containing training images for face A. This is " +"the original face, i.e. the face that you want to remove and replace with " +"face B." +msgstr "" +"Входная папка. Папка, содержащая обучающие изображения для лица A. Это " +"исходное лицо, т.е. лицо, которое вы хотите удалить и заменить лицом B." + +#: lib/cli/args_train.py:60 +msgid "" +"Input directory. A directory containing training images for face B. This is " +"the swap face, i.e. the face that you want to place onto the head of person " +"A." +msgstr "" +"Входная папка. Папка, содержащая обучающие изображения для лица B. Это " +"подменное лицо, т.е. лицо, которое вы хотите поместить на голову человека A." + +#: lib/cli/args_train.py:67 lib/cli/args_train.py:80 lib/cli/args_train.py:97 +#: lib/cli/args_train.py:123 lib/cli/args_train.py:133 +msgid "model" +msgstr "модель" + +#: lib/cli/args_train.py:69 +msgid "" +"Model directory. This is where the training data will be stored. You should " +"always specify a new folder for new models. If starting a new model, select " +"either an empty folder, or a folder which does not exist (which will be " +"created). If continuing to train an existing model, specify the location of " +"the existing model." +msgstr "" +"Папка модели. Здесь будут храниться данные для обучения. Для новых моделей " +"всегда следует указывать новую папку. Если вы начинаете новую модель, " +"выберите либо пустую папку, либо несуществующую папку (которая будет " +"создана). Если вы продолжаете обучение существующей модели, укажите " +"местоположение существующей модели." + +#: lib/cli/args_train.py:82 +msgid "" +"R|Load the weights from a pre-existing model into a newly created model. For " +"most models this will load weights from the Encoder of the given model into " +"the encoder of the newly created model. Some plugins may have specific " +"configuration options allowing you to load weights from other layers. " +"Weights will only be loaded when creating a new model. This option will be " +"ignored if you are resuming an existing model. Generally you will also want " +"to 'freeze-weights' whilst the rest of your model catches up with your " +"Encoder.\n" +"NB: Weights can only be loaded from models of the same plugin as you intend " +"to train." +msgstr "" +"R|Загрузить веса из уже существующей модели во вновь созданную модель. Для " +"большинства моделей это означает загрузку весов из кодировщика данной модели " +"в кодировщик вновь создаваемой модели. Некоторые плагины могут иметь " +"специальные параметры конфигурации, позволяющие загружать веса из других " +"слоев. Веса будут загружаться только при создании новой модели. Эта опция " +"будет проигнорирована, если вы возобновляете существующую модель. Обычно " +"также требуется \"заморозить\" веса, пока остальная часть модели догоняет " +"кодировщик.\n" +"Примечание: Веса могут быть загружены только из моделей того же плагина, " +"который вы собираетесь обучать." + +#: lib/cli/args_train.py:99 +msgid "" +"R|Select which trainer to use. Trainers can be configured from the Settings " +"menu or the config folder.\n" +"L|original: The original model created by /u/deepfakes.\n" +"L|dfaker: 64px in/128px out model from dfaker. Enable 'warp-to-landmarks' " +"for full dfaker method.\n" +"L|dfl-h128: 128px in/out model from deepfacelab\n" +"L|dfl-sae: Adaptable model from deepfacelab\n" +"L|dlight: A lightweight, high resolution DFaker variant.\n" +"L|iae: A model that uses intermediate layers to try to get better details\n" +"L|lightweight: A lightweight model for low-end cards. Don't expect great " +"results. Can train as low as 1.6GB with batch size 8.\n" +"L|realface: A high detail, dual density model based on DFaker, with " +"customizable in/out resolution. The autoencoders are unbalanced so B>A swaps " +"won't work so well. By andenixa et al. Very configurable.\n" +"L|unbalanced: 128px in/out model from andenixa. The autoencoders are " +"unbalanced so B>A swaps won't work so well. Very configurable.\n" +"L|villain: 128px in/out model from villainguy. Very resource hungry (You " +"will require a GPU with a fair amount of VRAM). Good for details, but more " +"susceptible to color differences." +msgstr "" +"R|Выберите, какой тренажер использовать. Тренажеры можно настроить в меню " +"\"Настройки\" или в папке config.\n" +"L|original: Оригинальная модель, созданная /u/deepfakes.\n" +"L|dfaker: модель 64px вход/ 128px выход от dfaker. Включите 'warp-to-" +"landmarks' для полного метода dfaker.\n" +"L|dfl-h128: модель 128px вход/выход от deepfacelab\n" +"L|dfl-sae: Адаптируемая модель от deepfacelab\n" +"L|dlight: Легкий вариант DFaker с высоким разрешением.\n" +"L|iae: Модель, использующая промежуточные слои для получения лучших " +"деталей.\n" +"L|lightweight: Облегченная модель для карт низкого класса. Не ожидайте " +"высоких результатов. Может обучаться на 1,6 ГБ при размере пачки 8.\n" +"L|realface: Модель с высокой детализацией и двойной плотностью, основанная " +"на DFaker, с настраиваемым разрешением входа/выхода. Автоэнкодеры " +"несбалансированы, поэтому замены B>A не будут работать так хорошо. Автор " +"andenixa и др. Очень настраиваемая.\n" +"L|unbalanced: модель 128px вход/выход от andenixa. Автокодировщики " +"несбалансированы, поэтому замены B>A не будут работать так хорошо. Очень " +"настраиваемая.\n" +"L|villain: модель 128px вход/выход от villainguy. Очень требовательна к " +"ресурсам (вам потребуется GPU с достаточным количеством VRAM). Хороша для " +"детализации, но более восприимчива к цветовым различиям." + +#: lib/cli/args_train.py:125 +msgid "" +"Output a summary of the model and exit. If a model folder is provided then a " +"summary of the saved model is displayed. Otherwise a summary of the model " +"that would be created by the chosen plugin and configuration settings is " +"displayed." +msgstr "" +"Вывести сводку модели и выйти. Если указана папка модели, то выводится " +"сводка сохраненной модели. В противном случае отображается сводка модели, " +"которая будет создана выбранным плагином и настройками конфигурации." + +#: lib/cli/args_train.py:135 +msgid "" +"Freeze the weights of the model. Freezing weights means that some of the " +"parameters in the model will no longer continue to learn, but those that are " +"not frozen will continue to learn. For most models, this will freeze the " +"encoder, but some models may have configuration options for freezing other " +"layers." +msgstr "" +"Заморозить веса модели. Замораживание весов означает, что некоторые " +"параметры в модели больше не будут продолжать обучение, но те, которые не " +"заморожены, будут продолжать обучение. Для большинства моделей это означает " +"замораживание кодера, но некоторые модели могут иметь опции конфигурации для " +"замораживания других слоев." + +#: lib/cli/args_train.py:147 lib/cli/args_train.py:160 +#: lib/cli/args_train.py:175 lib/cli/args_train.py:191 +#: lib/cli/args_train.py:200 +msgid "training" +msgstr "тренировка" + +#: lib/cli/args_train.py:149 +msgid "" +"Batch size. This is the number of images processed through the model for " +"each side per iteration. NB: As the model is fed 2 sides at a time, the " +"actual number of images within the model at any one time is double the " +"number that you set here. Larger batches require more GPU RAM." +msgstr "" +"Размер пачки. Это количество изображений, обрабатываемых моделью для каждой " +"стороны за итерацию. Примечание: Поскольку модель обрабатывает 2 стороны " +"одновременно, фактическое количество изображений в модели в любой момент " +"времени будет вдвое больше, чем заданное здесь. Большие партии требуют " +"больше оперативной памяти GPU." + +#: lib/cli/args_train.py:162 +msgid "" +"Length of training in iterations. This is only really used for automation. " +"There is no 'correct' number of iterations a model should be trained for. " +"You should stop training when you are happy with the previews. However, if " +"you want the model to stop automatically at a set number of iterations, you " +"can set that value here." +msgstr "" +"Продолжительность обучения в итерациях. Этот параметр действительно " +"используется только для автоматизации. Не существует \"правильного\" " +"количества итераций, за которое следует обучить модель. Вы должны прекратить " +"обучение, когда будете удовлетворены предварительным просмотром. Однако если " +"вы хотите, чтобы модель автоматически останавливалась при определенном " +"количестве итераций, вы можете задать это значение здесь." + +#: lib/cli/args_train.py:177 +msgid "" +"R|Select the distribution stategy to use.\n" +"L|default: Use Tensorflow's default distribution strategy.\n" +"L|central-storage: Centralizes variables on the CPU whilst operations are " +"performed on 1 or more local GPUs. This can help save some VRAM at the cost " +"of some speed by not storing variables on the GPU. Note: Mixed-Precision is " +"not supported on multi-GPU setups.\n" +"L|mirrored: Supports synchronous distributed training across multiple local " +"GPUs. A copy of the model and all variables are loaded onto each GPU with " +"batches distributed to each GPU at each iteration." +msgstr "" +"R|Выберите стратегию распределения для использования.\n" +"L|default: Использовать стратегию распространения Tensorflow по умолчанию.\n" +"L|central-storage: Централизует переменные на CPU, в то время как операции " +"выполняются на 1 или более локальных GPU. Это может помочь сэкономить " +"немного VRAM за счет некоторой скорости, поскольку переменные не хранятся на " +"GPU. Примечание: Mixed-Precision не поддерживается на многопроцессорных " +"установках.\n" +"L|mirrored: Поддерживает синхронное распределенное обучение на нескольких " +"локальных GPU. Копия модели и все переменные загружаются на каждый GPU с " +"распределением партий на каждый GPU на каждой итерации." + +#: lib/cli/args_train.py:193 +msgid "" +"Disables TensorBoard logging. NB: Disabling logs means that you will not be " +"able to use the graph or analysis for this session in the GUI." +msgstr "" +"Отключает ведение журналов TensorBoard. Примечание: Отключение ведения " +"журналов означает, что вы не сможете использовать график или анализ для этой " +"сессии в графическом интерфейсе." + +#: lib/cli/args_train.py:202 +msgid "" +"Use the Learning Rate Finder to discover the optimal learning rate for " +"training. For new models, this will calculate the optimal learning rate for " +"the model. For existing models this will use the optimal learning rate that " +"was discovered when initializing the model. Setting this option will ignore " +"the manually configured learning rate (configurable in train settings)." +msgstr "" +"Используйте инструмент поиска коэффициента обучения, чтобы найти оптимальную " +"скорость обучения вашей модели. Для новых моделей это позволит рассчитать " +"оптимальный коэффициент обучения для модели. Для существующих моделей будет " +"использован оптимальный коэффициент обучения, найденный при инициализации " +"модели. Установка этой опции приведет к игнорированию вручную настроенного " +"коэффициента обучения (настраиваемого в параметрах обучения)." + +#: lib/cli/args_train.py:215 lib/cli/args_train.py:225 +msgid "Saving" +msgstr "Сохранение" + +#: lib/cli/args_train.py:216 +msgid "Sets the number of iterations between each model save." +msgstr "Устанавливает количество итераций между каждым сохранением модели." + +#: lib/cli/args_train.py:227 +msgid "" +"Sets the number of iterations before saving a backup snapshot of the model " +"in it's current state. Set to 0 for off." +msgstr "" +"Устанавливает количество итераций между каждым сохранением модели. " +"Устанавливает количество итераций перед сохранением резервного снимка модели " +"в текущем состоянии. Установите значение 0 для выключения." + +#: lib/cli/args_train.py:234 lib/cli/args_train.py:246 +#: lib/cli/args_train.py:258 +msgid "timelapse" +msgstr "таймлапс" + +#: lib/cli/args_train.py:236 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. " +"This should be the input folder of 'A' faces that you would like to use for " +"creating the timelapse. You must also supply a --timelapse-output and a --" +"timelapse-input-B parameter." +msgstr "" +"Опционально для создания таймлапса. Timelapse будет сохранять изображение " +"выбранных лиц в папку timelapse-output на каждой итерации сохранения. Это " +"должна быть входная папка с лицами 'A', которые вы хотите использовать для " +"создания timelapse. Вы также должны указать параметры --timelapse-output и --" +"timelapse-input-B." + +#: lib/cli/args_train.py:248 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. " +"This should be the input folder of 'B' faces that you would like to use for " +"creating the timelapse. You must also supply a --timelapse-output and a --" +"timelapse-input-A parameter." +msgstr "" +"Опционально для создания таймлапса. Timelapse будет сохранять изображение " +"выбранных лиц в папку timelapse-output на каждой итерации сохранения. Это " +"должна быть входная папка с лицами 'B', которые вы хотите использовать для " +"создания timelapse. Вы также должны указать параметры --timelapse-output и --" +"timelapse-input-A." + +#: lib/cli/args_train.py:260 +msgid "" +"Optional for creating a timelapse. Timelapse will save an image of your " +"selected faces into the timelapse-output folder at every save iteration. If " +"the input folders are supplied but no output folder, it will default to your " +"model folder/timelapse/" +msgstr "" +"Опционально для создания таймлапса. Timelapse будет сохранять изображение " +"выбранных лиц в папку timelapse-output на каждой итерации сохранения. Если " +"указаны входные папки, но нет выходной папки, то по умолчанию будет выбрана " +"папка модели/timelapse/" + +#: lib/cli/args_train.py:269 lib/cli/args_train.py:276 +msgid "preview" +msgstr "предпросмотр" + +#: lib/cli/args_train.py:270 +msgid "Show training preview output. in a separate window." +msgstr "Показать вывод предварительного просмотра тренировки в отдельном окне." + +#: lib/cli/args_train.py:278 +msgid "" +"Writes the training result to a file. The image will be stored in the root " +"of your FaceSwap folder." +msgstr "" +"Записывает результат обучения в файл. Изображение будет сохранено в корне " +"папки Faceswap." + +#: lib/cli/args_train.py:285 lib/cli/args_train.py:295 +#: lib/cli/args_train.py:305 lib/cli/args_train.py:315 +msgid "augmentation" +msgstr "аугментация" + +#: lib/cli/args_train.py:287 +msgid "" +"Warps training faces to closely matched Landmarks from the opposite face-set " +"rather than randomly warping the face. This is the 'dfaker' way of doing " +"warping." +msgstr "" +"Искажает обучаемые лица до близко подходящих ориентиров из противоположного " +"набора лиц вместо случайного искажения лица. Это способ выполнения искажения " +"от \"dfaker\" ." + +#: lib/cli/args_train.py:297 +msgid "" +"To effectively learn, a random set of images are flipped horizontally. " +"Sometimes it is desirable for this not to occur. Generally this should be " +"left off except for during 'fit training'." +msgstr "" +"Для эффективного обучения случайный набор изображений переворачивается по " +"горизонтали. Иногда желательно, чтобы этого не происходило. Как правило, это " +"не нужно делать, за исключением случаев \"тренировки подгонки\"." + +#: lib/cli/args_train.py:307 +msgid "" +"Color augmentation helps make the model less susceptible to color " +"differences between the A and B sets, at an increased training time cost. " +"Enable this option to disable color augmentation." +msgstr "" +"Аугментация цвета помогает сделать модель менее восприимчивой к цветовым " +"различиям между наборами A и B, что влечет за собой увеличение затрат " +"времени на обучение. Включите этот параметр для отключения цветовой " +"аугментации." + +#: lib/cli/args_train.py:317 +msgid "" +"Warping is integral to training the Neural Network. This option should only " +"be enabled towards the very end of training to try to bring out more detail. " +"Think of it as 'fine-tuning'. Enabling this option from the beginning is " +"likely to kill a model and lead to terrible results." +msgstr "" +"Искажение является неотъемлемой частью обучения нейронной сети. Эту опцию " +"следует включать только в самом конце обучения, чтобы попытаться получить " +"больше деталей. Считайте это \"тонкой настройкой\". Включение этой опции в " +"самом начале, скорее всего, погубит модель и приведет к ужасным результатам." + +#~ msgid "Global Options" +#~ msgstr "Глобальные Настройки" + +#~ msgid "" +#~ "R|Exclude GPUs from use by Faceswap. Select the number(s) which " +#~ "correspond to any GPU(s) that you do not wish to be made available to " +#~ "Faceswap. Selecting all GPUs here will force Faceswap into CPU mode.\n" +#~ "L|{}" +#~ msgstr "" +#~ "R|Исключить GPU из использования Faceswap. Выберите номер (номера), " +#~ "соответствующие любому GPU, который вы не хотите предоставлять Faceswap. " +#~ "Если выбрать здесь все GPU, Faceswap перейдет в режим CPU.\n" +#~ "L|{}" + +#~ msgid "" +#~ "Optionally overide the saved config with the path to a custom config file." +#~ msgstr "" +#~ "Опционально переопределите сохраненную конфигурацию, указав путь к " +#~ "пользовательскому файлу конфигурации." + +#~ msgid "" +#~ "Log level. Stick with INFO or VERBOSE unless you need to file an error " +#~ "report. Be careful with TRACE as it will generate a lot of data" +#~ msgstr "" +#~ "Уровень логирования. Придерживайтесь INFO или VERBOSE, если только вам не " +#~ "нужно отправить отчет об ошибке. Будьте осторожны с TRACE, поскольку он " +#~ "генерирует много данных" + +#~ msgid "" +#~ "Path to store the logfile. Leave blank to store in the faceswap folder" +#~ msgstr "" +#~ "Путь для хранения файла журнала. Оставьте пустым, чтобы хранить в папке " +#~ "faceswap" + +#~ msgid "Data" +#~ msgstr "Данные" + +#~ msgid "" +#~ "Input directory or video. Either a directory containing the image files " +#~ "you wish to process or path to a video file. NB: This should be the " +#~ "source video/frames NOT the source faces." +#~ msgstr "" +#~ "Входная папка или видео. Либо каталог, содержащий файлы изображений, " +#~ "которые вы хотите обработать, либо путь к видеофайлу. ПРИМЕЧАНИЕ: Это " +#~ "должно быть исходное видео/кадры, а не исходные лица." + +#~ msgid "Output directory. This is where the converted files will be saved." +#~ msgstr "Выходная папка. Здесь будут сохранены преобразованные файлы." + +#~ msgid "" +#~ "Optional path to an alignments file. Leave blank if the alignments file " +#~ "is at the default location." +#~ msgstr "" +#~ "Необязательный путь к файлу выравниваний. Оставьте пустым, если файл " +#~ "выравнивания находится в месте по умолчанию." + +#~ msgid "" +#~ "Extract faces from image or video sources.\n" +#~ "Extraction plugins can be configured in the 'Settings' Menu" +#~ msgstr "" +#~ "Извлечение лиц из источников изображений или видео.\n" +#~ "Плагины извлечения можно настроить в меню \"Настройки\"" + +#~ msgid "" +#~ "R|If selected then the input_dir should be a parent folder containing " +#~ "multiple videos and/or folders of images you wish to extract from. The " +#~ "faces will be output to separate sub-folders in the output_dir." +#~ msgstr "" +#~ "R|Если выбрано, то input_dir должен быть родительской папкой, содержащей " +#~ "несколько видео и/или папок с изображениями, из которых вы хотите извлечь " +#~ "изображение. Лица будут выведены в отдельные вложенные папки в output_dir." + +#~ msgid "Plugins" +#~ msgstr "Плагины" + +#~ msgid "" +#~ "R|Detector to use. Some of these have configurable settings in '/config/" +#~ "extract.ini' or 'Settings > Configure Extract 'Plugins':\n" +#~ "L|cv2-dnn: A CPU only extractor which is the least reliable and least " +#~ "resource intensive. Use this if not using a GPU and time is important.\n" +#~ "L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " +#~ "than other GPU detectors but can often return more false positives.\n" +#~ "L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces " +#~ "and fewer false positives than other GPU detectors, but is a lot more " +#~ "resource intensive." +#~ msgstr "" +#~ "R|Детектор для использования. Некоторые из них имеют настраиваемые " +#~ "параметры в '/config/extract.ini' или 'Settings > Configure Extract " +#~ "'Plugins':\n" +#~ "L|cv2-dnn: Экстрактор только для процессора, который является наименее " +#~ "надежным и наименее ресурсоемким. Используйте его, если не используется " +#~ "GPU и важно время.\n" +#~ "L|mtcnn: Хороший детектор. Быстрый на CPU, еще быстрее на GPU. Использует " +#~ "меньше ресурсов, чем другие детекторы на GPU, но часто может давать " +#~ "больше ложных срабатываний.\n" +#~ "L|s3fd: Лучший детектор. Медленный на CPU, более быстрый на GPU. Может " +#~ "обнаружить больше лиц и меньше ложных срабатываний, чем другие детекторы " +#~ "на GPU, но требует гораздо больше ресурсов." + +#~ msgid "" +#~ "R|Aligner to use.\n" +#~ "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " +#~ "but less accurate. Only use this if not using a GPU and time is " +#~ "important.\n" +#~ "L|fan: Best aligner. Fast on GPU, slow on CPU." +#~ msgstr "" +#~ "R|Выравниватель для использования.\n" +#~ "L|cv2-dnn: Детектор ориентиров только для процессора. Быстрее, менее " +#~ "ресурсоемкий, но менее точный. Используйте его, только если не " +#~ "используется GPU и важно время.\n" +#~ "L|fan: Лучший выравниватель. Быстрый на GPU, медленный на CPU." + +#~ msgid "" +#~ "R|Additional Masker(s) to use. The masks generated here will all take up " +#~ "GPU RAM. You can select none, one or multiple masks, but the extraction " +#~ "may take longer the more you select. NB: The Extended and Components " +#~ "(landmark based) masks are automatically generated on extraction.\n" +#~ "L|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).\n" +#~ "L|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 so will not use any additional VRAM.\n" +#~ "L|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.\n" +#~ "L|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.\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.\n" +#~ "The auto generated masks are as follows:\n" +#~ "L|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.\n" +#~ "L|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.\n" +#~ "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" +#~ msgstr "" +#~ "R|Дополнительный маскер(ы) для использования. Все маски, созданные здесь, " +#~ "будут занимать видеопамять GPU. Вы можете выбрать ни одной, одну или " +#~ "несколько масок, но извлечение может занять больше времени, чем больше " +#~ "масок вы выберете. Примечание: Расширенные маски и маски компонентов (на " +#~ "основе ориентиров) генерируются автоматически при извлечении.\n" +#~ "L|bisenet-fp: Относительно легкая маска на основе NN, которая " +#~ "обеспечивает более точный контроль над маскируемой областью, включая " +#~ "полное маскирование головы (настраивается в настройках маски).\n" +#~ "L|custom: Фиктивная маска, которая заполняет область маски всеми 1 или 0 " +#~ "(настраивается в настройках). Она необходима только в том случае, если вы " +#~ "собираетесь вручную редактировать пользовательские маски в ручном " +#~ "инструменте. Эта маска не задействует GPU, поэтому не будет использовать " +#~ "дополнительную память VRAM.\n" +#~ "L|vgg-clear: Маска предназначена для интеллектуальной сегментации " +#~ "преимущественно фронтальных лиц без препятствий. Профильные лица и " +#~ "препятствия могут привести к снижению производительности.\n" +#~ "L|vgg-obstructed: Маска, разработанная для интеллектуальной сегментации " +#~ "преимущественно фронтальных лиц. Модель маски была специально обучена " +#~ "распознавать некоторые препятствия на лице (руки и очки). Лица в профиль " +#~ "могут иметь низкую производительность.\n" +#~ "L|unet-dfl: Маска, разработанная для интеллектуальной сегментации " +#~ "преимущественно фронтальных лиц. Модель маски была обучена членами " +#~ "сообщества и для дальнейшего описания нуждается в тестировании. " +#~ "Профильные лица могут привести к низкой производительности.\n" +#~ "Автоматически сгенерированные маски выглядят следующим образом:\n" +#~ "L|components: Маска, разработанная для сегментации лица на основе " +#~ "расположения ориентиров. Для создания маски вокруг внешних ориентиров " +#~ "строится выпуклая оболочка.\n" +#~ "L|extended: Маска, предназначенная для сегментации лица на основе " +#~ "расположения ориентиров. Выпуклый корпус строится вокруг внешних " +#~ "ориентиров, и маска расширяется вверх на лоб.\n" +#~ "(например: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" + +#~ msgid "" +#~ "R|Performing normalization can help the aligner better align faces with " +#~ "difficult lighting conditions at an extraction speed cost. Different " +#~ "methods will yield different results on different sets. NB: This does not " +#~ "impact the output face, just the input to the aligner.\n" +#~ "L|none: Don't perform normalization on the face.\n" +#~ "L|clahe: Perform Contrast Limited Adaptive Histogram Equalization on the " +#~ "face.\n" +#~ "L|hist: Equalize the histograms on the RGB channels.\n" +#~ "L|mean: Normalize the face colors to the mean." +#~ msgstr "" +#~ "R|Проведение нормализации может помочь выравнивателю лучше выравнивать " +#~ "лица со сложными условиями освещения при затратах на скорость извлечения. " +#~ "Различные методы дают разные результаты на разных наборах. NB: Это не " +#~ "влияет на выходное лицо, только на вход выравнивателя.\n" +#~ "L|none: Не выполнять нормализацию лица.\n" +#~ "L|clahe: Выполнить для лица адаптивную гистограммную эквализацию с " +#~ "ограничением контраста.\n" +#~ "L|hist: Уравнять гистограммы в каналах RGB.\n" +#~ "L|mean: Нормализовать цвета лица к среднему значению." + +#~ msgid "" +#~ "The number of times to re-feed the detected face into the aligner. Each " +#~ "time the face is re-fed into the aligner the bounding box is adjusted by " +#~ "a small amount. The final landmarks are then averaged from each " +#~ "iteration. Helps to remove 'micro-jitter' but at the cost of slower " +#~ "extraction speed. The more times the face is re-fed into the aligner, the " +#~ "less micro-jitter should occur but the longer extraction will take." +#~ msgstr "" +#~ "Количество повторных подач обнаруженной области лица в выравниватель. При " +#~ "каждой повторной подаче лица в выравниватель ограничивающая рамка " +#~ "корректируется на небольшую величину. Затем конечные ориентиры " +#~ "усредняются по результатам каждой итерации. Это помогает устранить " +#~ "\"микро-дрожание\", но ценой снижения скорости извлечения. Чем больше раз " +#~ "лицо повторно подается в выравниватель, тем меньше микро-дрожание, но тем " +#~ "больше времени займет извлечение." + +#~ msgid "" +#~ "Re-feed the initially found aligned face through the aligner. Can help " +#~ "produce better alignments for faces that are rotated beyond 45 degrees in " +#~ "the frame or are at extreme angles. Slows down extraction." +#~ msgstr "" +#~ "Повторная подача первоначально найденной выровненной области лица через " +#~ "выравниватель. Может помочь получить лучшее выравнивание для лиц, " +#~ "повернутых в кадре более чем на 45 градусов или расположенных под " +#~ "экстремальными углами. Замедляет извлечение." + +#~ msgid "" +#~ "If a face isn't found, rotate the images to try to find a face. Can find " +#~ "more faces at the cost of extraction speed. Pass in a single number to " +#~ "use increments of that size up to 360, or pass in a list of numbers to " +#~ "enumerate exactly what angles to check." +#~ msgstr "" +#~ "Если лицо не найдено, поворачивает изображения, чтобы попытаться найти " +#~ "лицо. Может найти больше лиц ценой снижения скорости извлечения. " +#~ "Передайте одно число, чтобы использовать приращения этого размера до 360, " +#~ "или передайте список чисел, чтобы перечислить, какие именно углы нужно " +#~ "проверить." + +#~ msgid "" +#~ "Obtain and store face identity encodings from VGGFace2. Slows down " +#~ "extract a little, but will save time if using 'sort by face'" +#~ msgstr "" +#~ "Получение и хранение кодировок идентификации лица из VGGFace2. Немного " +#~ "замедляет извлечение, но экономит время при использовании \"сортировки по " +#~ "лицам\"." + +#~ msgid "Face Processing" +#~ msgstr "Обработка лиц" + +#~ msgid "" +#~ "Filters out faces detected below this size. Length, in pixels across the " +#~ "diagonal of the bounding box. Set to 0 for off" +#~ msgstr "" +#~ "Отфильтровывает лица, обнаруженные ниже этого размера. Длина в пикселях " +#~ "по диагонали ограничивающего поля. Установите значение 0, чтобы выключить" + +#~ msgid "" +#~ "Optionally filter out people who you do not wish to extract by passing in " +#~ "images of those people. Should be a small variety of images at different " +#~ "angles and in different conditions. A folder containing the required " +#~ "images or multiple image files, space separated, can be selected." +#~ msgstr "" +#~ "По желанию отфильтруйте людей, которых вы не хотите извлекать, передав " +#~ "изображения этих людей. Должно быть небольшое разнообразие изображений " +#~ "под разными углами и в разных условиях. Можно выбрать папку, содержащую " +#~ "необходимые изображения, или несколько файлов изображений, разделенных " +#~ "пробелами." + +#~ msgid "" +#~ "Optionally select people you wish to extract by passing in images of that " +#~ "person. Should be a small variety of images at different angles and in " +#~ "different conditions A folder containing the required images or multiple " +#~ "image files, space separated, can be selected." +#~ msgstr "" +#~ "По желанию выберите людей, которых вы хотите извлечь, передав изображения " +#~ "этого человека. Должно быть небольшое разнообразие изображений под " +#~ "разными углами и в разных условиях. Можно выбрать папку, содержащую " +#~ "необходимые изображения, или несколько файлов изображений, разделенных " +#~ "пробелами." + +#~ msgid "" +#~ "For use with the optional nfilter/filter files. Threshold for positive " +#~ "face recognition. Higher values are stricter." +#~ msgstr "" +#~ "Для использования с дополнительными файлами nfilter/filter. Порог для " +#~ "положительного распознавания лица. Более высокие значения являются более " +#~ "строгими." + +#~ msgid "output" +#~ msgstr "вывод" + +#~ msgid "" +#~ "The output size of extracted faces. Make sure that the model you intend " +#~ "to train supports your required size. This will only need to be changed " +#~ "for hi-res models." +#~ msgstr "" +#~ "Выходной размер извлеченных лиц. Убедитесь, что модель, которую вы " +#~ "собираетесь тренировать, поддерживает требуемый размер. Это необходимо " +#~ "изменить только для моделей высокого разрешения." + +#~ msgid "" +#~ "Extract every 'nth' frame. This option will skip frames when extracting " +#~ "faces. For example a value of 1 will extract faces from every frame, a " +#~ "value of 10 will extract faces from every 10th frame." +#~ msgstr "" +#~ "Извлекать каждый 'n-й' кадр. Этот параметр пропускает кадры при " +#~ "извлечении лиц. Например, значение 1 будет извлекать лица из каждого " +#~ "кадра, значение 10 будет извлекать лица из каждого 10-го кадра." + +#~ msgid "" +#~ "Automatically save the alignments file after a set amount of frames. By " +#~ "default the alignments file is only saved at the end of the extraction " +#~ "process. NB: If extracting in 2 passes then the alignments file will only " +#~ "start to be saved out during the second pass. WARNING: Don't interrupt " +#~ "the script when writing the file because it might get corrupted. Set to 0 " +#~ "to turn off" +#~ msgstr "" +#~ "Автоматическое сохранение файла выравнивания после заданного количества " +#~ "кадров. По умолчанию файл выравнивания сохраняется только в конце " +#~ "процесса извлечения. Примечание: Если извлечение выполняется в 2 прохода, " +#~ "то файл выравнивания начнет сохраняться только во время второго прохода. " +#~ "ПРЕДУПРЕЖДЕНИЕ: Не прерывайте работу скрипта при записи файла, так как он " +#~ "может быть поврежден. Установите значение 0, чтобы отключить" + +#~ msgid "Draw landmarks on the ouput faces for debugging purposes." +#~ msgstr "Нарисуйте ориентиры на выходящих гранях для отладки." + +#~ msgid "settings" +#~ msgstr "настройки" + +#~ msgid "" +#~ "Don't run extraction in parallel. Will run each part of the extraction " +#~ "process separately (one after the other) rather than all at the same " +#~ "time. Useful if VRAM is at a premium." +#~ msgstr "" +#~ "Не запускать извлечение параллельно. Каждая часть процесса извлечения " +#~ "будет выполняться отдельно (одна за другой), а не одновременно. Полезно, " +#~ "если память VRAM ограничена." + +#~ msgid "" +#~ "Skips frames that have already been extracted and exist in the alignments " +#~ "file" +#~ msgstr "" +#~ "Пропускает кадры, которые уже были извлечены и существуют в файле " +#~ "выравнивания" + +#~ msgid "Skip frames that already have detected faces in the alignments file" +#~ msgstr "" +#~ "Пропустить кадры, в которых уже есть обнаруженные лица в файле " +#~ "выравнивания" + +#~ msgid "" +#~ "Skip saving the detected faces to disk. Just create an alignments file" +#~ msgstr "" +#~ "Не сохранять обнаруженные лица на диск. Просто создать файл выравнивания" + +#~ msgid "" +#~ "Swap the original faces in a source video/images to your final faces.\n" +#~ "Conversion plugins can be configured in the 'Settings' Menu" +#~ msgstr "" +#~ "Поменять исходные лица в исходном видео/изображении на ваши конечные " +#~ "лица.\n" +#~ "Плагины конвертирования можно настроить в меню \"Настройки\"" + +#~ msgid "" +#~ "Only required if converting from images to video. Provide The original " +#~ "video that the source frames were extracted from (for extracting the fps " +#~ "and audio)." +#~ msgstr "" +#~ "Требуется только при преобразовании из изображений в видео. Предоставьте " +#~ "исходное видео, из которого были извлечены исходные кадры (для извлечения " +#~ "кадров в секунду и звука)." + +#~ msgid "" +#~ "Model directory. The directory containing the trained model you wish to " +#~ "use for conversion." +#~ msgstr "" +#~ "Папка модели. Папка, содержащая обученную модель, которую вы хотите " +#~ "использовать для преобразования." + +#~ msgid "" +#~ "R|Performs color adjustment to the swapped face. Some of these options " +#~ "have configurable settings in '/config/convert.ini' or 'Settings > " +#~ "Configure Convert Plugins':\n" +#~ "L|avg-color: Adjust the mean of each color channel in the swapped " +#~ "reconstruction to equal the mean of the masked area in the original " +#~ "image.\n" +#~ "L|color-transfer: Transfers the color distribution from the source to the " +#~ "target image using the mean and standard deviations of the L*a*b* color " +#~ "space.\n" +#~ "L|manual-balance: Manually adjust the balance of the image in a variety " +#~ "of color spaces. Best used with the Preview tool to set correct values.\n" +#~ "L|match-hist: Adjust the histogram of each color channel in the swapped " +#~ "reconstruction to equal the histogram of the masked area in the original " +#~ "image.\n" +#~ "L|seamless-clone: Use cv2's seamless clone function to remove extreme " +#~ "gradients at the mask seam by smoothing colors. Generally does not give " +#~ "very satisfactory results.\n" +#~ "L|none: Don't perform color adjustment." +#~ msgstr "" +#~ "R|Производит корректировку цвета поменявшегося лица. Некоторые из этих " +#~ "параметров настраиваются в '/config/convert.ini' или 'Настройки > " +#~ "Настроить плагины конвертации':\n" +#~ "L|avg-color: корректирует среднее значение каждого цветового канала в " +#~ "реконструкции, чтобы оно было равно среднему значению маскированной " +#~ "области в исходном изображении.\n" +#~ "L|color-transfer: Переносит распределение цветов с исходного изображения " +#~ "на целевое, используя среднее и стандартные отклонения цветового " +#~ "пространства L*a*b*.\n" +#~ "L|manual-balance: Ручная настройка баланса изображения в различных " +#~ "цветовых пространствах. Лучше всего использовать с инструментом " +#~ "предварительного просмотра для установки правильных значений.\n" +#~ "L|match-hist: Настроить гистограмму каждого цветового канала в измененном " +#~ "восстановлении так, чтобы она соответствовала гистограмме маскированной " +#~ "области исходного изображения.\n" +#~ "L|seamless-clone: Используйте функцию бесшовного клонирования cv2 для " +#~ "удаления экстремальных градиентов на шве маски путем сглаживания цветов. " +#~ "Обычно дает не очень удовлетворительные результаты.\n" +#~ "L|none: Не выполнять коррекцию цвета." + +#~ msgid "" +#~ "R|Masker to use. NB: The mask you require must exist within the " +#~ "alignments file. You can add additional masks with the Mask Tool.\n" +#~ "L|none: Don't use a mask.\n" +#~ "L|bisenet-fp_face: Relatively lightweight NN based mask that provides " +#~ "more refined control over the area to be masked (configurable in mask " +#~ "settings). Use this version of bisenet-fp if your model is trained with " +#~ "'face' or 'legacy' centering.\n" +#~ "L|bisenet-fp_head: Relatively lightweight NN based mask that provides " +#~ "more refined control over the area to be masked (configurable in mask " +#~ "settings). Use this version of bisenet-fp if your model is trained with " +#~ "'head' centering.\n" +#~ "L|custom_face: Custom user created, face centered mask.\n" +#~ "L|custom_head: Custom user created, head centered mask.\n" +#~ "L|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.\n" +#~ "L|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.\n" +#~ "L|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.\n" +#~ "L|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.\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.\n" +#~ "L|predicted: If the 'Learn Mask' option was enabled during training, this " +#~ "will use the mask that was created by the trained model." +#~ msgstr "" +#~ "R|Маскер для использования. Примечание: Нужная маска должна существовать " +#~ "в файле выравнивания. Вы можете добавить дополнительные маски с помощью " +#~ "инструмента Mask Tool.\n" +#~ "L|none: Не использовать маску.\n" +#~ "L|bisenet-fp_face: Относительно легкая маска на основе NN, которая " +#~ "обеспечивает более точный контроль над маскируемой областью " +#~ "(настраивается в настройках маски). Используйте эту версию bisenet-fp, " +#~ "если ваша модель обучена с центрированием 'face' или 'legacy'.\n" +#~ "L|bisenet-fp_head: Относительно легкая маска на основе NN, которая " +#~ "обеспечивает более точный контроль над маскируемой областью " +#~ "(настраивается в настройках маски). Используйте эту версию bisenet-fp, " +#~ "если ваша модель обучена с центрированием по \"голове\".\n" +#~ "L|custom_face: Пользовательская маска, созданная пользователем и " +#~ "центрированная по лицу.\n" +#~ "L|custom_head: Созданная пользователем маска, центрированная по голове.\n" +#~ "L|components: Маска, разработанная для сегментации лица на основе " +#~ "расположения ориентиров. Для создания маски вокруг внешних ориентиров " +#~ "строится выпуклая оболочка.\n" +#~ "L|extended: Маска, предназначенная для сегментации лица на основе " +#~ "расположения ориентиров. Выпуклый корпус строится вокруг внешних " +#~ "ориентиров, и маска расширяется вверх на лоб.\n" +#~ "L|vgg-clear: Маска предназначена для интеллектуальной сегментации " +#~ "преимущественно фронтальных лиц без препятствий. Профильные лица и " +#~ "препятствия могут привести к снижению производительности.\n" +#~ "L|vgg-obstructed: Маска, разработанная для интеллектуальной сегментации " +#~ "преимущественно фронтальных лиц. Модель маски была специально обучена " +#~ "распознавать некоторые препятствия на лице (руки и очки). Лица в профиль " +#~ "могут иметь низкую производительность.\n" +#~ "L|unet-dfl: Маска, разработанная для интеллектуальной сегментации " +#~ "преимущественно фронтальных лиц. Модель маски была обучена членами " +#~ "сообщества и для дальнейшего описания нуждается в тестировании. " +#~ "Профильные лица могут привести к низкой производительности.\n" +#~ "L|predicted: Если во время обучения была включена опция 'Изучить Маску', " +#~ "то будет использоваться маска, созданная обученной моделью." + +#~ msgid "" +#~ "R|The plugin to use to output the converted images. The writers are " +#~ "configurable in '/config/convert.ini' or 'Settings > Configure Convert " +#~ "Plugins:'\n" +#~ "L|ffmpeg: [video] Writes out the convert straight to video. When the " +#~ "input is a series of images then the '-ref' (--reference-video) parameter " +#~ "must be set.\n" +#~ "L|gif: [animated image] Create an animated gif.\n" +#~ "L|opencv: [images] The fastest image writer, but less options and formats " +#~ "than other plugins.\n" +#~ "L|patch: [images] Outputs the raw swapped face patch, along with the " +#~ "transformation matrix required to re-insert the face back into the " +#~ "original frame. Use this option if you wish to post-process and composite " +#~ "the final face within external tools.\n" +#~ "L|pillow: [images] Slower than opencv, but has more options and supports " +#~ "more formats." +#~ msgstr "" +#~ "R|Плагин, который нужно использовать для вывода преобразованных " +#~ "изображений. Записи настраиваются в '/config/convert.ini' или 'Настройки " +#~ "> Настроить плагины конвертации:'\n" +#~ "L|ffmpeg: [видео] Записывает конвертацию прямо в видео. Если на вход " +#~ "подается серия изображений, необходимо установить параметр '-ref' (--" +#~ "reference-video).\n" +#~ "L|gif: [анимированное изображение] Создает анимированный gif.\n" +#~ "L|opencv: [изображения] Самый быстрый редактор изображений, но имеет " +#~ "меньше опций и форматов, чем другие плагины.\n" +#~ "L|patch: [изображения] Выводит необработанный фрагмент измененного лица " +#~ "вместе с матрицей преобразования, необходимой для повторной вставки лица " +#~ "обратно в исходный кадр.\n" +#~ "L|pillow: [изображения] Медленнее, чем opencv, но имеет больше опций и " +#~ "поддерживает больше форматов." + +#~ msgid "Frame Processing" +#~ msgstr "Обработка лиц" + +#, python-format +#~ msgid "" +#~ "Scale the final output frames by this amount. 100%% will output the " +#~ "frames at source dimensions. 50%% at half size 200%% at double size" +#~ msgstr "" +#~ "Масштабирование конечных выходных кадров на эту величину. 100%% выводит " +#~ "кадры в исходном размере. 50%% при половинном размере 200%% при двойном " +#~ "размере" + +#~ msgid "" +#~ "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 " +#~ "use --frame-ranges 10-50 90-100. Frames falling outside of the selected " +#~ "range will be discarded unless '-k' (--keep-unchanged) is selected. NB: " +#~ "If you are converting from images, then the filenames must end with the " +#~ "frame-number!" +#~ msgstr "" +#~ "Диапазоны кадров для применения переноса, например, для кадров с 10 по 50 " +#~ "и с 90 по 100 используйте --frame-ranges 10-50 90-100. Кадры, выходящие " +#~ "за пределы выбранного диапазона, будут отброшены, если не выбрана опция '-" +#~ "k' (--keep-unchanged). Примечание: Если вы конвертируете из изображений, " +#~ "то имена файлов должны заканчиваться номером кадра!" + +#~ msgid "" +#~ "Scale the swapped face by this percentage. Positive values will enlarge " +#~ "the face, Negative values will shrink the face." +#~ msgstr "" +#~ "Увеличить масштаб нового лица на этот процент. Положительные значения " +#~ "увеличат лицо, в то время как отрицательные значения уменьшат его." + +#~ msgid "" +#~ "If you have not cleansed your alignments file, then you can filter out " +#~ "faces by defining a folder here that contains the faces extracted from " +#~ "your input files/video. If this folder is defined, then only faces that " +#~ "exist within your alignments file and also exist within the specified " +#~ "folder will be converted. Leaving this blank will convert all faces that " +#~ "exist within the alignments file." +#~ msgstr "" +#~ "Если вы не очистили свой файл выравнивания, то вы можете отфильтровать " +#~ "лица, определив здесь папку, содержащую лица, извлеченные из ваших " +#~ "входных файлов/видео. Если эта папка определена, то будут преобразованы " +#~ "только те лица, которые существуют в вашем файле выравнивания, а также в " +#~ "указанной папке. Если оставить этот параметр пустым, будут преобразованы " +#~ "все лица, существующие в файле выравнивания." + +#~ msgid "" +#~ "Optionally filter out people who you do not wish to process by passing in " +#~ "an image of that person. Should be a front portrait with a single person " +#~ "in the image. Multiple images can be added space separated. NB: Using " +#~ "face filter will significantly decrease extraction speed and its accuracy " +#~ "cannot be guaranteed." +#~ msgstr "" +#~ "По желанию отфильтровать людей, которых вы не хотите обрабатывать, " +#~ "передав изображение этого человека. Это должен быть фронтальный портрет с " +#~ "изображением одного человека. Можно добавить несколько изображений, " +#~ "разделенных пробелами. Примечание: Использование фильтра лиц значительно " +#~ "снизит скорость извлечения, а его точность не гарантируется." + +#~ msgid "" +#~ "Optionally select people you wish to process by passing in an image of " +#~ "that person. Should be a front portrait with a single person in the " +#~ "image. Multiple images can be added space separated. NB: Using face " +#~ "filter will significantly decrease extraction speed and its accuracy " +#~ "cannot be guaranteed." +#~ msgstr "" +#~ "По желанию выберите людей, которых вы хотите обработать, передав " +#~ "изображение этого человека. Это должен быть фронтальный портрет с " +#~ "изображением одного человека. Можно добавить несколько изображений, " +#~ "разделенных пробелами. Примечание: Использование фильтра лиц значительно " +#~ "снизит скорость извлечения, а его точность не гарантируется." + +#~ msgid "" +#~ "For use with the optional nfilter/filter files. Threshold for positive " +#~ "face recognition. Lower values are stricter. NB: Using face filter will " +#~ "significantly decrease extraction speed and its accuracy cannot be " +#~ "guaranteed." +#~ msgstr "" +#~ "Для использования с дополнительными файлами nfilter/filter. Порог для " +#~ "положительного распознавания лиц. Более низкие значения являются более " +#~ "строгими. Примечание: Использование фильтра лиц значительно снизит " +#~ "скорость извлечения, а его точность не гарантируется." + +#~ msgid "" +#~ "The maximum number of parallel processes for performing conversion. " +#~ "Converting images is system RAM heavy so it is possible to run out of " +#~ "memory if you have a lot of processes and not enough RAM to accommodate " +#~ "them all. Setting this to 0 will use the maximum available. No matter " +#~ "what you set this to, it will never attempt to use more processes than " +#~ "are available on your system. If singleprocess is enabled this setting " +#~ "will be ignored." +#~ msgstr "" +#~ "Максимальное количество параллельных процессов для выполнения " +#~ "конвертации. Конвертирование изображений занимает много системной " +#~ "оперативной памяти, поэтому может закончиться память, если у вас много " +#~ "процессов и недостаточно оперативной памяти для их размещения. Если " +#~ "установить значение 0, будет использован максимум доступной памяти. " +#~ "Независимо от того, какое значение вы установите, программа никогда не " +#~ "будет пытаться использовать больше процессов, чем доступно в вашей " +#~ "системе. Если включена однопоточная обработка, этот параметр будет " +#~ "проигнорирован." + +#~ msgid "" +#~ "[LEGACY] This only needs to be selected if a legacy model is being loaded " +#~ "or if there are multiple models in the model folder" +#~ msgstr "" +#~ "[ОТБРОШЕН] Этот параметр необходимо выбрать только в том случае, если " +#~ "загружается устаревшая модель или если в папке моделей имеется несколько " +#~ "моделей" + +#~ msgid "" +#~ "Enable On-The-Fly Conversion. NOT recommended. You should generate a " +#~ "clean alignments file for your destination video. However, if you wish " +#~ "you can generate the alignments on-the-fly by enabling this option. This " +#~ "will use an inferior extraction pipeline and will lead to substandard " +#~ "results. If an alignments file is found, this option will be ignored." +#~ msgstr "" +#~ "Включить преобразование \"на лету\". НЕ рекомендуется. Вы должны " +#~ "сгенерировать чистый файл выравнивания для конечного видео. Однако при " +#~ "желании вы можете генерировать выравнивания \"на лету\", включив эту " +#~ "опцию. При этом будет использоваться некачественный конвейер извлечения, " +#~ "что приведет к некачественным результатам. Если файл выравнивания найден, " +#~ "этот параметр будет проигнорирован." + +#~ msgid "" +#~ "When used with --frame-ranges outputs the unchanged frames that are not " +#~ "processed instead of discarding them." +#~ msgstr "" +#~ "При использовании с --frame-ranges выводит неизмененные кадры, которые не " +#~ "были обработаны, вместо того, чтобы отбрасывать их." + +#~ msgid "Swap the model. Instead converting from of A -> B, converts B -> A" +#~ msgstr "" +#~ "Поменять модель местами. Вместо преобразования из A -> B, преобразуется B " +#~ "-> A" + +#~ msgid "Disable multiprocessing. Slower but less resource intensive." +#~ msgstr "" +#~ "Отключение многопоточной обработки. Медленнее, но менее ресурсоемко." + +#~ msgid "Output to Shell console instead of GUI console" +#~ msgstr "Вывод в консоль Shell вместо консоли GUI" + +#~ msgid "" +#~ "[Deprecated - Use '-D, --distribution-strategy' instead] Use the " +#~ "Tensorflow Mirrored Distrubution Strategy to train on multiple GPUs." +#~ msgstr "" +#~ "[Устарело - Используйте '-D, --distribution-strategy' вместо этого] " +#~ "Используйте стратегию Tensorflow Mirrored Distrubution Strategy(Стратегия " +#~ "Зеркального Распределения Tensorflow) для обучения на нескольких GPU." diff --git a/locales/ru/LC_MESSAGES/tools.alignments.cli.mo b/locales/ru/LC_MESSAGES/tools.alignments.cli.mo index 1c47a8cb014258a22d74e709ffd6c39cf6122b44..1a4953f0e57bef7f23018bdd4189d9e4316090f3 100644 GIT binary patch delta 485 zcmX|-KP*F06o-FLON*i{eg3scgGi)l`cw(EHNm2TMPf5qBx-8M#$YyygjmH$o<%G= zNV<`b*jN~(iAg5|-+dJ)Ip@3g*z9>=fn1ioq!uv<6|Yj6&} z!!;Ohlh)x5%)mQ%0GqG?_kFDybvPZ6V(ro-y(=MU*H2%MRFtZEjYy9)q@x6yEjG!T zV@#q>vygt}VHYgGZ3Y$L68CFcD#0Ik21|p|CA5d6Em()k)MtjJ3itAeG{HNc@HtdP zIdbnQXiQ0`LFpzXolv-ullI6r;TW4#kiSX(I4>bW-*5m17bUZM0#a@?{F})e5ys5& zVecd%-m?I+y|3?t9j%-f*+boU-(QYTQZLRgD&*Fz56gYVYluURa{Wt`%m4gARbAu7#aqDnfzeLTV8H&PmNI7EG_ zTIy&1t43Ple%&)@;KFpR^oPINr2T6Olg-l4BB`@g`oRW2F0HdZosjDJqX(X2g7t8p z#5T>NeZ@7jJ!Ft_+spr;r{`wuLJ3(kXh+1$e>`_s$=G&SK-vRsyddo*6?Q_{3(84e zQ)S*s;5Ja6T3(o&OfIB8JL$<}YTnxjmV_%h+dATHuIspoUe|5u==OFZ(LgBH9xL9; uMdRLG^eFhwoS9Q|VY2==lQmc7%HK3uhlO+ht2u6$+1=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/alignments/cli.py:17 +#: tools/alignments/cli.py:16 msgid "" "This command lets you perform various tasks pertaining to an alignments file." msgstr "" "Эта команда позволяет выполнять различные задачи, относящиеся к файлу " "выравнивания." -#: tools/alignments/cli.py:32 +#: tools/alignments/cli.py:31 msgid "" "Alignments tool\n" "This tool allows you to perform numerous actions on or using an alignments " @@ -37,15 +37,15 @@ msgstr "" "выравнивания или с его использованием против соответствующего набора лиц/" "кадров." -#: tools/alignments/cli.py:44 +#: tools/alignments/cli.py:43 msgid " Must Pass in a frames folder/source video file (-fr)." msgstr " Должен проходить в папке с кадрами/исходным видеофайлом (-fr)." -#: tools/alignments/cli.py:45 +#: tools/alignments/cli.py:44 msgid " Must Pass in a faces folder (-fc)." msgstr " Должен проходить в папке с лицами (-fc)." -#: tools/alignments/cli.py:46 +#: tools/alignments/cli.py:45 msgid "" " Must Pass in either a frames folder/source video file OR a faces folder (-" "fr or -fc)." @@ -53,7 +53,7 @@ msgstr "" " Должно передаваться либо в папку с кадрами/исходным видеофайлом, либо в " "папку с лицами (-fr или -fc)." -#: tools/alignments/cli.py:48 +#: tools/alignments/cli.py:47 msgid "" " Must Pass in a frames folder/source video file AND a faces folder (-fr and -" "fc)." @@ -61,11 +61,11 @@ msgstr "" " Должно передаваться либо в папку с кадрами/исходным видеофайлом И в папку с " "лицами (-fr и -fc)." -#: tools/alignments/cli.py:50 +#: tools/alignments/cli.py:49 msgid " Use the output option (-o) to process results." msgstr " Используйте опцию вывода (-o) для обработки результатов." -#: tools/alignments/cli.py:58 tools/alignments/cli.py:97 +#: tools/alignments/cli.py:57 tools/alignments/cli.py:97 msgid "processing" msgstr "обработка" @@ -139,7 +139,7 @@ msgstr "" "L|'spatial': Выполнить пространственную и временную фильтрацию для " "сглаживания выравниваний (ЭКСПЕРИМЕНТАЛЬНО!)." -#: tools/alignments/cli.py:99 +#: tools/alignments/cli.py:100 msgid "" "R|How to output discovered items ('faces' and 'frames' only):\n" "L|'console': Print the list of frames to the screen. (DEFAULT)\n" @@ -154,12 +154,12 @@ msgstr "" "каталоге).\n" "L|'move': Переместить обнаруженные элементы в подпапку в исходном каталоге." -#: tools/alignments/cli.py:110 tools/alignments/cli.py:123 -#: tools/alignments/cli.py:130 tools/alignments/cli.py:137 +#: tools/alignments/cli.py:111 tools/alignments/cli.py:134 +#: tools/alignments/cli.py:141 msgid "data" msgstr "данные" -#: tools/alignments/cli.py:114 +#: tools/alignments/cli.py:118 msgid "" "Full path to the alignments file to be processed. If you have input a " "'frames_dir' and don't provide this option, the process will try to find the " @@ -173,15 +173,11 @@ msgstr "" "задания 'from-faces', когда файл выравнивания будет создан в указанной папке " "с лицами." -#: tools/alignments/cli.py:124 -msgid "Directory containing extracted faces." -msgstr "Папка, содержащая извлеченные лица." - -#: tools/alignments/cli.py:131 +#: tools/alignments/cli.py:135 msgid "Directory containing source frames that faces were extracted from." msgstr "Папка, содержащая исходные кадры, из которых были извлечены лица." -#: tools/alignments/cli.py:138 +#: tools/alignments/cli.py:143 msgid "" "R|Run the aligmnents tool on multiple sources. The following jobs support " "batch mode:\n" @@ -224,12 +220,12 @@ msgstr "" "выравнивания должен существовать в месте по умолчанию. Для всех остальных " "заданий этот параметр игнорируется." -#: tools/alignments/cli.py:164 tools/alignments/cli.py:175 -#: tools/alignments/cli.py:185 +#: tools/alignments/cli.py:169 tools/alignments/cli.py:181 +#: tools/alignments/cli.py:191 msgid "extract" msgstr "извлечение" -#: tools/alignments/cli.py:165 +#: tools/alignments/cli.py:171 msgid "" "[Extract only] Extract every 'nth' frame. This option will skip frames when " "extracting faces. For example a value of 1 will extract faces from every " @@ -239,11 +235,11 @@ msgstr "" "кадры при извлечении лиц. Например, значение 1 будет извлекать лица из " "каждого кадра, значение 10 будет извлекать лица из каждого 10-го кадра." -#: tools/alignments/cli.py:176 +#: tools/alignments/cli.py:182 msgid "[Extract only] The output size of extracted faces." msgstr "[Только извлечение] Выходной размер извлеченных лиц." -#: tools/alignments/cli.py:186 +#: tools/alignments/cli.py:193 msgid "" "[Extract only] Only extract faces that have been resized by this percent or " "more to meet the specified extract size (`-sz`, `--size`). Useful for " @@ -262,3 +258,6 @@ msgstr "" "значении 100 будут извлечены только лица, размер которых был изменен с 512px " "или выше. При значении 200 будут извлечены только лица, уменьшенные с 1024px " "или выше." + +#~ msgid "Directory containing extracted faces." +#~ msgstr "Папка, содержащая извлеченные лица." diff --git a/locales/ru/LC_MESSAGES/tools.effmpeg.cli.mo b/locales/ru/LC_MESSAGES/tools.effmpeg.cli.mo index b14684eb32b24b0a77f3242d2dcf295b10e7a35f..b47b17d08daf2b874116715b260e4373cf901136 100644 GIT binary patch delta 267 zcmWm8JxIe)6vgp>M4_}*#D3rx8fSU%h^=VQr4?}zrMNi>jU|f_)0Qff&UMjIaCLQ% zfRoZqL04D1yF0iD9*4*8oy)m!U#r9F^+)g0f%7c1fYAxakHCd|)ck4^Dsp=Y{(Jo# z+^Cl?z_h%VKk`NHXm3!2L*>*A1nSr2xANO8bmd@9Prc1~=qUd#u*$JM?L~Oj!lz6r z-!6e};!dXJgVaqtN!`MG(s#x(8@1pxI%}HUxDnP(rPYYdLFBvX{XJ6)+F=|;-B#2BbNF{1!z92B4c5dVw?-ke&skfo@`0 z3Z#K{laZBMHNamtD77rJI5R&_*Cnwe)k?w0z{ptFz(m*3P{GjD%FsgFz=2 && " "n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 3.2.2\n" +"X-Generator: Poedit 3.4.2\n" #: tools/effmpeg/cli.py:15 msgid "This command allows you to easily execute common ffmpeg tasks." msgstr "Эта команда позволяет легко выполнять общие задачи ffmpeg." -#: tools/effmpeg/cli.py:24 +#: tools/effmpeg/cli.py:52 msgid "A wrapper for ffmpeg for performing image <> video converting." msgstr "Обертка для ffmpeg для выполнения конвертации изображений <> видео." -#: tools/effmpeg/cli.py:51 +#: tools/effmpeg/cli.py:64 msgid "" "R|Choose which action you want ffmpeg ffmpeg to do.\n" "L|'extract': turns videos into images \n" @@ -48,15 +49,15 @@ msgstr "" "L|'rotate' вращение видео.\n" "L|'slice' вырезает часть видео в отдельный видеофайл." -#: tools/effmpeg/cli.py:65 +#: tools/effmpeg/cli.py:78 msgid "Input file." msgstr "Входной файл." -#: tools/effmpeg/cli.py:66 tools/effmpeg/cli.py:73 tools/effmpeg/cli.py:87 +#: tools/effmpeg/cli.py:79 tools/effmpeg/cli.py:86 tools/effmpeg/cli.py:100 msgid "data" msgstr "данные" -#: tools/effmpeg/cli.py:76 +#: tools/effmpeg/cli.py:89 msgid "" "Output file. If no output is specified then: if the output is meant to be a " "video then a video called 'out.mkv' will be created in the input directory; " @@ -70,16 +71,16 @@ msgstr "" "создан каталог с именем 'out'. Примечание: выбранное расширение выходного " "файла определяет кодировку файла." -#: tools/effmpeg/cli.py:89 +#: tools/effmpeg/cli.py:102 msgid "Path to reference video if 'input' was not a video." msgstr "Путь к опорному видео, если 'input' не является видео." -#: tools/effmpeg/cli.py:95 tools/effmpeg/cli.py:105 tools/effmpeg/cli.py:142 -#: tools/effmpeg/cli.py:171 +#: tools/effmpeg/cli.py:108 tools/effmpeg/cli.py:118 tools/effmpeg/cli.py:156 +#: tools/effmpeg/cli.py:185 msgid "output" msgstr "выход" -#: tools/effmpeg/cli.py:97 +#: tools/effmpeg/cli.py:110 msgid "" "Provide video fps. Can be an integer, float or fraction. Negative values " "will will make the program try to get the fps from the input or reference " @@ -89,7 +90,7 @@ msgstr "" "плавающей цифрой или дробью. Отрицательные значения заставят программу " "попытаться получить fps из входного или опорного видео." -#: tools/effmpeg/cli.py:107 +#: tools/effmpeg/cli.py:120 msgid "" "Image format that extracted images should be saved as. '.bmp' will offer the " "fastest extraction speed, but will take the most storage space. '.png' will " @@ -99,11 +100,11 @@ msgstr "" "'.bmp' обеспечивает самую высокую скорость извлечения, но занимает больше " "всего места в памяти. '.png' будет медленнее, но займет меньше места." -#: tools/effmpeg/cli.py:114 tools/effmpeg/cli.py:123 tools/effmpeg/cli.py:132 +#: tools/effmpeg/cli.py:127 tools/effmpeg/cli.py:136 tools/effmpeg/cli.py:145 msgid "clip" msgstr "клип" -#: tools/effmpeg/cli.py:116 +#: tools/effmpeg/cli.py:129 msgid "" "Enter the start time from which an action is to be applied. Default: " "00:00:00, in HH:MM:SS format. You can also enter the time with or without " @@ -113,7 +114,7 @@ msgstr "" "00:00:00, в формате ЧЧ:ММ:СС. Вы также можете ввести время с двоеточием или " "без него, например, 00:0000 или 026010." -#: tools/effmpeg/cli.py:125 +#: tools/effmpeg/cli.py:138 msgid "" "Enter the end time to which an action is to be applied. If both an end time " "and duration are set, then the end time will be used and the duration will " @@ -124,7 +125,7 @@ msgstr "" "окончания, а продолжительность будет игнорироваться. По умолчанию: 00:00:00, " "в формате ЧЧ:ММ:СС." -#: tools/effmpeg/cli.py:134 +#: tools/effmpeg/cli.py:147 msgid "" "Enter the duration of the chosen action, for example if you enter 00:00:10 " "for slice, then the first 10 seconds after and including the start time will " @@ -137,7 +138,7 @@ msgstr "" "СС. Вы также можете ввести время с двоеточием или без него, например, " "00:0000 или 026010." -#: tools/effmpeg/cli.py:144 +#: tools/effmpeg/cli.py:158 msgid "" "Mux the audio from the reference video into the input video. This option is " "only used for the 'gen-vid' action. 'mux-audio' action has this turned on " @@ -146,11 +147,11 @@ msgstr "" "Mux аудио из опорного видео во входное видео. Эта опция используется только " "для действия 'gen-vid'. Действие 'mux-audio' включает эту опцию неявно." -#: tools/effmpeg/cli.py:155 tools/effmpeg/cli.py:165 +#: tools/effmpeg/cli.py:169 tools/effmpeg/cli.py:179 msgid "rotate" msgstr "поворот" -#: tools/effmpeg/cli.py:157 +#: tools/effmpeg/cli.py:171 msgid "" "Transpose the video. If transpose is set, then degrees will be ignored. For " "cli you can enter either the number or the long command name, e.g. to use " @@ -161,19 +162,19 @@ msgstr "" "длинное имя команды, например, для использования (1, 90 по часовой стрелке) -" "tr 1 или -tr 90 по часовой стрелке" -#: tools/effmpeg/cli.py:166 +#: tools/effmpeg/cli.py:180 msgid "Rotate the video clockwise by the given number of degrees." msgstr "Поверните видео по часовой стрелке на заданное количество градусов." -#: tools/effmpeg/cli.py:173 +#: tools/effmpeg/cli.py:187 msgid "Set the new resolution scale if the chosen action is 'rescale'." msgstr "Установите новый масштаб разрешения, если выбрано действие 'rescale'." -#: tools/effmpeg/cli.py:178 tools/effmpeg/cli.py:186 +#: tools/effmpeg/cli.py:192 tools/effmpeg/cli.py:200 msgid "settings" msgstr "настройки" -#: tools/effmpeg/cli.py:180 +#: tools/effmpeg/cli.py:194 msgid "" "Reduces output verbosity so that only serious errors are printed. If both " "quiet and verbose are set, verbose will override quiet." @@ -181,7 +182,7 @@ msgstr "" "Уменьшает многословность вывода, чтобы выводились только серьезные ошибки. " "Если заданы и quiet, и verbose, то verbose будет преобладать над quiet." -#: tools/effmpeg/cli.py:188 +#: tools/effmpeg/cli.py:202 msgid "" "Increases output verbosity. If both quiet and verbose are set, verbose will " "override quiet." diff --git a/locales/ru/LC_MESSAGES/tools.manual.mo b/locales/ru/LC_MESSAGES/tools.manual.mo index a3dfff23e2d2799883efa16739b5b7c91c07e4a4..6e724e6f9d41624a2e5ebc7ae1b68992d799efe8 100644 GIT binary patch delta 402 zcmXBO&nv@m9LMq3wiBZn3!Ajb{F=Ue&1SPG$w{PSCr-;+w3^vCDCX>-N*A{(&i66{qFk=DBwG^!U8qpHJ`i`#gRhze`&R2NtR5kUk{EoYFGc(=1i2(sm0q z>g%mi7_YGn-*5~o*o6U#eK?2Rn8#oGoZtxg&@E;05l=Db;oz%B1x2{1LyAHas{^yR zLW5fzBG+|F1J!q&!UWn`@Cx~&qE}*Pz2K!yddCa$KHJQo&o8+!iLs`mN~DRK<=`eoDJjX( z1=&*0Q}PG6DQCrn9Nav$)8q4geLj8OuS)Jbce`ekiiUL4EWJqzd!#vLcdPX6k`}#E zihQPBa_|s4@DUUEiXrq8_M(GfT)-dx*~1v~j!)XeD?G!1U$XJuuac4+-U&uA6sRqn z#d$tB!6EXmPO1O@zDr8u1U69c0C}R*Zi&vS;H6u7!VBgCZ8BIFk=k$)$8iP6@H&#M zE&d=#6GWrZGOpq;KBI?4)gI}S`K3=f!YXZsF*hJJ;y%{narwd3&=j4@n$!91Lb0&1 kZf2IY@~NP0*>TevF=Me{%t^(qK`VLJ;uwZqPIz{Gf5@9Tp8x;= diff --git a/locales/ru/LC_MESSAGES/tools.manual.po b/locales/ru/LC_MESSAGES/tools.manual.po index 0f6aad8c86..2c74501b46 100644 --- a/locales/ru/LC_MESSAGES/tools.manual.po +++ b/locales/ru/LC_MESSAGES/tools.manual.po @@ -5,8 +5,9 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2022-11-24 14:17+0900\n" -"PO-Revision-Date: 2023-04-11 15:30+0700\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-28 23:55+0000\n" +"PO-Revision-Date: 2024-03-29 00:07+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -16,9 +17,9 @@ msgstr "" "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" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 3.2.2\n" +"X-Generator: Poedit 3.4.2\n" -#: tools/manual\cli.py:13 +#: tools/manual/cli.py:13 msgid "" "This command lets you perform various actions on frames, faces and " "alignments files using visual tools." @@ -26,7 +27,7 @@ msgstr "" "Эта команда позволяет выполнять различные действия с кадрами, гранями и " "файлами выравнивания с помощью визуальных инструментов." -#: tools/manual\cli.py:23 +#: tools/manual/cli.py:23 msgid "" "A tool to perform various actions on frames, faces and alignments files " "using visual tools" @@ -34,18 +35,18 @@ msgstr "" "Инструмент для выполнения различных действий с кадрами, лицами и файлами " "выравнивания с помощью визуальных инструментов" -#: tools/manual\cli.py:35 tools/manual\cli.py:43 +#: tools/manual/cli.py:35 tools/manual/cli.py:44 msgid "data" msgstr "данные" -#: tools/manual\cli.py:37 +#: tools/manual/cli.py:38 msgid "" "Path to the alignments file for the input, if not at the default location" msgstr "" "Путь к файлу выравниваний для входных данных, если он не находится в месте " "по умолчанию" -#: tools/manual\cli.py:44 +#: tools/manual/cli.py:46 msgid "" "Video file or directory containing source frames that faces were extracted " "from." @@ -53,11 +54,11 @@ msgstr "" "Видеофайл или папка, содержащая исходные кадры, из которых были извлечены " "лица." -#: tools/manual\cli.py:51 tools/manual\cli.py:59 +#: tools/manual/cli.py:53 tools/manual/cli.py:62 msgid "options" msgstr "опции" -#: tools/manual\cli.py:52 +#: tools/manual/cli.py:55 msgid "" "Force regeneration of the low resolution jpg thumbnails in the alignments " "file." @@ -65,7 +66,7 @@ msgstr "" "Принудительное восстановление миниатюр jpg низкого разрешения в файле " "выравнивания." -#: tools/manual\cli.py:60 +#: tools/manual/cli.py:64 msgid "" "The process attempts to speed up generation of thumbnails by extracting from " "the video in parallel threads. For some videos, this causes the caching " diff --git a/locales/ru/LC_MESSAGES/tools.mask.cli.mo b/locales/ru/LC_MESSAGES/tools.mask.cli.mo index 0f567b5d3ff4edb743fc561e7544e905e29bf906..e682d111a71805bc4a0baa0235f79b1e24160bcd 100644 GIT binary patch delta 23 ecmZ46$GEnSal->i4kJqi0|P4q^UW_LU3CFwQwQ7t delta 23 ecmZ46$GEnSal->i4nso)BV#L5gUv4_U3CFw4hPl% diff --git a/locales/ru/LC_MESSAGES/tools.mask.cli.po b/locales/ru/LC_MESSAGES/tools.mask.cli.po index 38f0ba46b8..1f63fe42ff 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: 2024-03-11 23:45+0000\n" -"PO-Revision-Date: 2024-03-11 23:50+0000\n" +"POT-Creation-Date: 2024-03-28 23:51+0000\n" +"PO-Revision-Date: 2024-03-29 00:07+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" diff --git a/locales/ru/LC_MESSAGES/tools.model.cli.mo b/locales/ru/LC_MESSAGES/tools.model.cli.mo index 226dd0c52b6f3d5456757e050f654a44b6e378ae..37b7545821547dd105882a54185c1fe1ce6d77c9 100644 GIT binary patch delta 221 zcmaDY`%1e0o)F7a1|VPuVi_O~0b*_-?g3&D*a5`0K)e%(gMs)v5Z`5FVDJWFcOcee zW?--ZiWRajFmM9tbwD~BD82(oHv#!7Yzz!jfb?=8y&j|%Xi*Xq1H&6G28MnH20aFU zUIqpro8dl?2I^w~>SJI9VvvJ?>QMmO#J%%2Z)IG^EMTH*V61CosbFAWWnivtV6eHE TRg96*WOEmr4C7{7j&*DRNfsVd delta 408 zcmX|*ze_@K6vgk$A1@Q48Y0Nx(&7+?S`=6cxmZIKv=p^yex;x9f!fd%xfV%SO%b%T zMGy56p*Yue-yB7A(f`m%$_pP3_gv2Rd_RZ3MvmSG+?Ryu1_7`E{NNh=&NGOE7q9|! zjc5(J4t)bQNP&urXcqpfo5%+p@(}I9ABC2%pLvNcp#3`074#1J;GzV@(VXcb`bKLR zhvid+kJ$LkXV4sZdnuESotjaFVqJ&2sfkgu;`dNQ5I(v2b)OJYDm7 z1~n~OV{bsK%=qUQS#G5zk5U&)o}7}{EKL}7v8CahvQ(WDskRM^;WnHy|24vHitQXD L)cWs?>6Z5g-KT4q diff --git a/locales/ru/LC_MESSAGES/tools.model.cli.po b/locales/ru/LC_MESSAGES/tools.model.cli.po index c3b43f4193..bef71ab233 100644 --- a/locales/ru/LC_MESSAGES/tools.model.cli.po +++ b/locales/ru/LC_MESSAGES/tools.model.cli.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-06-28 14:05+0100\n" -"PO-Revision-Date: 2023-04-11 16:02+0700\n" +"POT-Creation-Date: 2024-03-28 23:51+0000\n" +"PO-Revision-Date: 2024-03-29 00:07+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -17,7 +17,7 @@ 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/model/cli.py:13 msgid "This tool lets you perform actions on saved Faceswap models." @@ -30,7 +30,7 @@ msgid "A tool for performing actions on Faceswap trained model files" msgstr "" "Инструмент для выполнения действий над файлами обученных моделей Faceswap" -#: tools/model/cli.py:33 +#: tools/model/cli.py:34 msgid "" "Model directory. A directory containing the model you wish to perform an " "action on." @@ -38,7 +38,7 @@ msgstr "" "Папка модели. Папка, содержащая модель, над которой вы хотите выполнить " "действие." -#: tools/model/cli.py:41 +#: tools/model/cli.py:43 msgid "" "R|Choose which action you want to perform.\n" "L|'inference' - Create an inference only copy of the model. Strips any " @@ -50,20 +50,20 @@ msgid "" "L|'restore' - Restore a model from backup." msgstr "" "R|Выберите действие, которое вы хотите выполнить.\n" -"L|'inference' - Создать копию модели только для проведения расчетов. " -"Удаляет из модели все слои, которые нужны только для обучения. Примечание: " -"Эта функция предназначена для экспорта модели для использования во внешних " -"приложениях. Модели, созданные в режиме вывода, не могут быть использованы " -"в Faceswap. См. опцию 'format' для указания формата вывода модели.\n" +"L|'inference' - Создать копию модели только для проведения расчетов. Удаляет " +"из модели все слои, которые нужны только для обучения. Примечание: Эта " +"функция предназначена для экспорта модели для использования во внешних " +"приложениях. Модели, созданные в режиме вывода, не могут быть использованы в " +"Faceswap. См. опцию 'format' для указания формата вывода модели.\n" "L|'nan-scan' - Проверить файл модели на наличие NaNs или Infs (недопустимых " "данных).\n" "L|'restore' - Восстановить модель из резервной копии." -#: tools/model/cli.py:55 tools/model/cli.py:66 +#: tools/model/cli.py:57 tools/model/cli.py:69 msgid "inference" msgstr "вывод" -#: tools/model/cli.py:56 +#: tools/model/cli.py:59 msgid "" "R|The format to save the model as. Note: Only used for 'inference' job.\n" "L|'h5' - Standard Keras H5 format. Does not store any custom layer " @@ -79,10 +79,14 @@ msgstr "" "L|'saved-model' - формат сохраненной модели Tensorflow. Содержит всю " "информацию, необходимую для загрузки модели вне Faceswap." -#: tools/model/cli.py:67 +#: tools/model/cli.py:71 +#, fuzzy +#| msgid "" +#| "Only used for 'inference' job. Generate the inference model for B -> A " +#| "instead of A -> B." msgid "" -"Only used for 'inference' job. Generate the inference model for B -> A " +"Only used for 'inference' job. Generate the inference model for B -> A " "instead of A -> B." msgstr "" -"Используется только для задания 'inference'. Создайте модель вывода для B -" -"> A вместо A -> B." +"Используется только для задания 'inference'. Создайте модель вывода для B -> " +"A вместо A -> B." diff --git a/locales/ru/LC_MESSAGES/tools.preview.mo b/locales/ru/LC_MESSAGES/tools.preview.mo index d8ebe91405f0647ac86613fe01997a9e2a358ccc..780e7173eb13faf0f3829e9d6d0b033fee4bbc53 100644 GIT binary patch delta 36 rcmX>tc3NzM7>j_3u7RNBH5M^OMw899tbA+$r(Orv delta 36 rcmX>tc3NzM7>j_hu7QcJp`n7InU#T=wt@L(H5M^OMx)KPtbA+$r)dY- diff --git a/locales/ru/LC_MESSAGES/tools.preview.po b/locales/ru/LC_MESSAGES/tools.preview.po index c4e13e338d..ebcaea18d8 100644 --- a/locales/ru/LC_MESSAGES/tools.preview.po +++ b/locales/ru/LC_MESSAGES/tools.preview.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-01-16 12:27+0000\n" -"PO-Revision-Date: 2023-04-11 16:06+0700\n" +"POT-Creation-Date: 2024-03-28 23:53+0000\n" +"PO-Revision-Date: 2024-03-29 00:06+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -17,15 +17,15 @@ 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/preview/cli.py:14 +#: tools/preview/cli.py:15 msgid "This command allows you to preview swaps to tweak convert settings." msgstr "" "Эта команда позволяет просматривать замены для настройки параметров " "конвертирования." -#: tools/preview/cli.py:29 +#: tools/preview/cli.py:30 msgid "" "Preview tool\n" "Allows you to configure your convert settings with a live preview" @@ -34,11 +34,11 @@ msgstr "" "Позволяет настраивать параметры конвертации с помощью предварительного " "просмотра в реальном времени" -#: tools/preview/cli.py:46 tools/preview/cli.py:55 tools/preview/cli.py:62 +#: tools/preview/cli.py:47 tools/preview/cli.py:57 tools/preview/cli.py:65 msgid "data" msgstr "данные" -#: tools/preview/cli.py:48 +#: tools/preview/cli.py:50 msgid "" "Input directory or video. Either a directory containing the image files you " "wish to process or path to a video file." @@ -46,14 +46,14 @@ msgstr "" "Входная папка или видео. Либо папка, содержащая файлы изображений, которые " "необходимо обработать, либо путь к видеофайлу." -#: tools/preview/cli.py:57 +#: tools/preview/cli.py:60 msgid "" "Path to the alignments file for the input, if not at the default location" msgstr "" "Путь к файлу выравниваний для входных данных, если он не находится в месте " "по умолчанию" -#: tools/preview/cli.py:64 +#: tools/preview/cli.py:68 msgid "" "Model directory. A directory containing the trained model you wish to " "process." @@ -61,33 +61,33 @@ msgstr "" "Папка модели. Папка, содержащая обученную модель, которую вы хотите " "обработать." -#: tools/preview/cli.py:71 +#: tools/preview/cli.py:74 msgid "Swap the model. Instead of A -> B, swap B -> A" msgstr "Поменять местами модели. Вместо A -> B заменить B -> A" -#: tools/preview/control_panels.py:496 +#: tools/preview/control_panels.py:510 msgid "Save full config" msgstr "Сохранить полную конфигурацию" -#: tools/preview/control_panels.py:499 +#: tools/preview/control_panels.py:513 msgid "Reset full config to default values" msgstr "Сбросить полную конфигурацию до заводских значений" -#: tools/preview/control_panels.py:502 +#: tools/preview/control_panels.py:516 msgid "Reset full config to saved values" msgstr "Сбросить полную конфигурацию до сохраненных значений" -#: tools/preview/control_panels.py:653 +#: tools/preview/control_panels.py:667 #, python-brace-format msgid "Save {title} config" msgstr "Сохранить конфигурацию {title}" -#: tools/preview/control_panels.py:656 +#: tools/preview/control_panels.py:670 #, python-brace-format msgid "Reset {title} config to default values" msgstr "Сбросить полную конфигурацию {title} до заводских значений" -#: tools/preview/control_panels.py:659 +#: tools/preview/control_panels.py:673 #, python-brace-format msgid "Reset {title} config to saved values" msgstr "Сбросить полную конфигурацию {title} до сохраненных значений" diff --git a/locales/ru/LC_MESSAGES/tools.sort.cli.mo b/locales/ru/LC_MESSAGES/tools.sort.cli.mo index 32cc570ea893ff14f1446855ca2c12a1355eb209..6b832be91e8345d476597eee9b2038f8d2e4d451 100644 GIT binary patch delta 887 zcmZY7O-K}B9LMqBuB+?owyx%uy5^eYTjJ=Nx~rJdA-eQ>sW49kp^FD0C_$!(Zh@u< z9z57V*pnT`>XJpBlIY?=15HYVA_@{T!yx+3?Bc}-W znBSDWKI3a!Gy>%prdfy;*;zx|JE@()n=wBkdM{x|h@i{(X{tue% z3_OfTd_!+=n2GOLL;n_q;HE#8a-xMgp+DIwO*21Fp4PE9D*a=;n`078b#)|b;4(72 zuaO}qrC^G>5Rr3o1tNORCib(PSF#~$MFdIImecIhgj3HpL{el$)S8I;UL!b8%E*Aq zQ=>U4ofL|bl0Ff2RdCsbL47Hoi|Fq)JG^AaDWq&`CT+xRWqAj@R=n^-=6F}=iodzu ph}1Vl%MBxDwA2}fl?WBNT+P<&vMrC*aBLyZs_66u#@jE%{sSeWW_SPq delta 5280 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 =KSkj08AAcI|8;|5j7>Hh=Ag^K|o(`P1zHxPe zA@DMRP*o`ag7e!^m{yDD0F7btKC5*(V+l5?YhzNN8=4CdM6EfL%t#wU(Ipuv*P?sz zg*6G9<13s>tq5NVuJ3r@@%W37DZ%+H9+}EQ%$t}UHf=KNppY>T4?T>S9E$Q_Tid-S zQG2OP0%PYSRv;xcr9ROm9X-$ORq1jWBK#K}@*y%556?uH*(T#j(lfvmnGCjV-xTEA zHmsWE1Hx0m(WZmT8+@A#Qu}xLW*E+VW5~%MQ>&@|QQ2guhLHJ0nl<#|;XrSaYvPsm zO9p`?d(5~fAAZxiJ`D{GabM1)hNfWO{w-^XOqfMniH>N9Z_qcld$m2g?z(5Wj5wM7 K=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/sort/cli.py:14 +#: tools/sort/cli.py:15 msgid "This command lets you sort images using various methods." msgstr "Эта команда позволяет сортировать изображения различными методами." -#: tools/sort/cli.py:20 +#: tools/sort/cli.py:21 msgid "" " Adjust the '-t' ('--threshold') parameter to control the strength of " "grouping." msgstr "" " Настройте параметр '-t' ('--threshold') для контроля силы группировки." -#: tools/sort/cli.py:21 +#: tools/sort/cli.py:22 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. Each image is allocated to a bin by the percentage of color pixels " @@ -40,7 +40,7 @@ msgstr "" "группировки. Каждое изображение распределяется по корзинкам в зависимости от " "процента цветных пикселей, присутствующих в изображении." -#: tools/sort/cli.py:24 +#: tools/sort/cli.py:25 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. Each image is allocated to a bin by the number of degrees the face " @@ -50,7 +50,7 @@ msgstr "" "группировки. Каждое изображение распределяется по корзинам по количеству " "градусов, на которые лицо ориентировано от центра." -#: tools/sort/cli.py:27 +#: tools/sort/cli.py:28 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. The minimum and maximum values are taken for the chosen sort " @@ -61,15 +61,15 @@ msgstr "" "максимальное значения. Затем корзины заполняются результатами групповой " "сортировки." -#: tools/sort/cli.py:31 +#: tools/sort/cli.py:32 msgid "faces by blurriness." msgstr "лица по размытости." -#: tools/sort/cli.py:32 +#: tools/sort/cli.py:33 msgid "faces by fft filtered blurriness." msgstr "лица по размытости с фильтрацией fft." -#: tools/sort/cli.py:33 +#: tools/sort/cli.py:34 msgid "" "faces by the estimated distance of the alignments from an 'average' face. " "This can be useful for eliminating misaligned faces. Sorts from most like an " @@ -79,7 +79,7 @@ msgstr "" "быть полезно для устранения неправильно расположенных лиц. Сортирует от " "наиболее похожего на среднее лицо к наименее похожему на среднее лицо." -#: tools/sort/cli.py:36 +#: tools/sort/cli.py:37 msgid "" "faces using VGG Face2 by face similarity. This uses a pairwise clustering " "algorithm to check the distances between 512 features on every face in your " @@ -89,23 +89,23 @@ msgstr "" "парной кластеризации для проверки расстояний между 512 признаками на каждом " "лице в вашем наборе и их упорядочивания соответствующим образом." -#: tools/sort/cli.py:39 +#: tools/sort/cli.py:40 msgid "faces by their landmarks." msgstr "лица по их ориентирам." -#: tools/sort/cli.py:40 +#: tools/sort/cli.py:41 msgid "Like 'face-cnn' but sorts by dissimilarity." msgstr "Как 'face-cnn', но сортирует по непохожести." -#: tools/sort/cli.py:41 +#: tools/sort/cli.py:42 msgid "faces by Yaw (rotation left to right)." msgstr "лица по Yaw (вращение слева направо)." -#: tools/sort/cli.py:42 +#: tools/sort/cli.py:43 msgid "faces by Pitch (rotation up and down)." msgstr "лица по Pitch (вращение вверх и вниз)." -#: tools/sort/cli.py:43 +#: tools/sort/cli.py:44 msgid "" "faces by Roll (rotation). Aligned faces should have a roll value close to " "zero. The further the Roll value from zero the higher liklihood the face is " @@ -115,22 +115,22 @@ msgstr "" "близкое к нулю. Чем дальше значение Roll от нуля, тем выше вероятность того, " "что лицо неправильно выровнено." -#: tools/sort/cli.py:45 +#: tools/sort/cli.py:46 msgid "faces by their color histogram." msgstr "лица по их цветовой гистограмме." -#: tools/sort/cli.py:46 +#: tools/sort/cli.py:47 msgid "Like 'hist' but sorts by dissimilarity." msgstr "Как 'hist', но сортирует по непохожести." -#: tools/sort/cli.py:47 +#: tools/sort/cli.py:48 msgid "" "images by the average intensity of the converted grayscale color channel." msgstr "" "изображения по средней интенсивности преобразованного полутонового цветового " "канала." -#: tools/sort/cli.py:48 +#: tools/sort/cli.py:49 msgid "" "images by their number of black pixels. Useful when faces are near borders " "and a large part of the image is black." @@ -138,7 +138,7 @@ msgstr "" "изображения по количеству черных пикселей. Полезно, когда лица находятся " "вблизи границ и большая часть изображения черная." -#: tools/sort/cli.py:50 +#: tools/sort/cli.py:51 msgid "" "images by the average intensity of the converted Y color channel. Bright " "lighting and oversaturated images will be ranked first." @@ -147,7 +147,7 @@ msgstr "" "Яркое освещение и перенасыщенные изображения будут ранжироваться в первую " "очередь." -#: tools/sort/cli.py:52 +#: tools/sort/cli.py:53 msgid "" "images by the average intensity of the converted Cg color channel. Green " "images will be ranked first and red images will be last." @@ -155,7 +155,7 @@ msgstr "" "изображений по средней интенсивности преобразованного цветового канала Cg. " "Зеленые изображения занимают первое место, а красные - последнее." -#: tools/sort/cli.py:54 +#: tools/sort/cli.py:55 msgid "" "images by the average intensity of the converted Co color channel. Orange " "images will be ranked first and blue images will be last." @@ -163,7 +163,7 @@ msgstr "" "изображений по средней интенсивности преобразованного цветового канала Co. " "Оранжевые изображения занимают первое место, а синие - последнее." -#: tools/sort/cli.py:56 +#: tools/sort/cli.py:57 msgid "" "images by their size in the original frame. Faces further from the camera " "and from lower resolution sources will be sorted first, whilst faces closer " @@ -174,24 +174,16 @@ msgstr "" "первыми, а лица, расположенные ближе к камере и полученные из источников с " "высоким разрешением, будут отсортированы последними." -#: tools/sort/cli.py:59 -msgid " option is deprecated. Use 'yaw'" -msgstr " является устаревшей. Используйте 'yaw'" - -#: tools/sort/cli.py:60 -msgid " option is deprecated. Use 'color-black'" -msgstr " является устаревшей. Используйте 'color-black'" - -#: tools/sort/cli.py:82 +#: tools/sort/cli.py:81 msgid "Sort faces using a number of different techniques" msgstr "Сортировка лиц с использованием различных методов" -#: tools/sort/cli.py:92 tools/sort/cli.py:99 tools/sort/cli.py:110 -#: tools/sort/cli.py:148 +#: tools/sort/cli.py:91 tools/sort/cli.py:98 tools/sort/cli.py:110 +#: tools/sort/cli.py:150 msgid "data" msgstr "данные" -#: tools/sort/cli.py:93 +#: tools/sort/cli.py:92 msgid "Input directory of aligned faces." msgstr "Входная папка соотнесенных лиц." @@ -209,7 +201,7 @@ msgstr "" "'keep', то изображения будут отсортированы на месте, перезаписывая исходное " "содержимое 'input_dir'." -#: tools/sort/cli.py:111 +#: tools/sort/cli.py:112 msgid "" "R|If selected then the input_dir should be a parent folder containing " "multiple folders of faces you wish to sort. The faces will be output to " @@ -219,11 +211,11 @@ msgstr "" "несколько папок с лицами, которые вы хотите отсортировать. Лица будут " "выведены в отдельные вложенные папки в output_dir" -#: tools/sort/cli.py:120 +#: tools/sort/cli.py:121 msgid "sort settings" msgstr "настройки сортировки" -#: tools/sort/cli.py:122 +#: tools/sort/cli.py:124 msgid "" "R|Choose how images are sorted. Selecting a sort method gives the images a " "new filename based on the order the image appears within the given method.\n" @@ -240,17 +232,25 @@ msgstr "" "корзины, но файлы сохранят свои оригинальные имена. Выбор значения 'none' " "как для 'sort-by', так и для 'group-by' ничего не даст" -#: tools/sort/cli.py:135 tools/sort/cli.py:162 tools/sort/cli.py:191 +#: tools/sort/cli.py:136 tools/sort/cli.py:164 tools/sort/cli.py:184 msgid "group settings" msgstr "настройки группировки" -#: tools/sort/cli.py:137 +#: tools/sort/cli.py:139 +#, fuzzy +#| msgid "" +#| "R|Selecting a group by method will move/copy files into numbered bins " +#| "based on the selected method.\n" +#| "L|'none': Don't bin the images. Folders will be sorted by the selected " +#| "'sort-by' but will not be binned, instead they will be sorted into a " +#| "single folder. Selecting 'none' for both 'sort-by' and 'group-by' will " +#| "do nothing" msgid "" "R|Selecting a group by method will move/copy files into numbered bins based " "on the selected method.\n" "L|'none': Don't bin the images. Folders will be sorted by the selected 'sort-" "by' but will not be binned, instead they will be sorted into a single " -"folder. Selecting 'none' for both 'sort-by' and 'group-by' will do nothing" +"folder. Selecting 'none' for both 'sort-by' and 'group-by' will do nothing" msgstr "" "R|Выбор группы по методу приведет к перемещению/копированию файлов в " "пронумерованные корзины в соответствии с выбранным методом.\n" @@ -259,7 +259,7 @@ msgstr "" "отсортированы в одну папку. Выбор значения 'none' как для 'sort-by', так и " "для 'group-by' ничего не даст" -#: tools/sort/cli.py:149 +#: tools/sort/cli.py:152 msgid "" "Whether to keep the original files in their original location. Choosing a " "'sort-by' method means that the files have to be renamed. Selecting 'keep' " @@ -275,7 +275,7 @@ msgstr "" "что исходные файлы будут перемещены и переименованы в соответствии с " "выбранными критериями сортировки/группировки." -#: tools/sort/cli.py:164 +#: tools/sort/cli.py:167 msgid "" "R|Float value. Minimum threshold to use for grouping comparison with 'face-" "cnn' 'hist' and 'face' methods.\n" @@ -303,20 +303,29 @@ msgstr "" "количеством изображений, так как это может привести к созданию большого " "количества папок. По умолчанию: face-cnn 7.2, hist 0.3, face 0.25" -#: tools/sort/cli.py:181 -msgid "output" -msgstr "вывод" - -#: tools/sort/cli.py:182 -msgid "" -"Deprecated and no longer used. The final processing will be dictated by the " -"sort/group by methods and whether 'keep_original' is selected." -msgstr "" -"Устарело и больше не используется. Окончательная обработка будет диктоваться " -"методами sort/group by и тем, выбрана ли опция 'keep_original'." - -#: tools/sort/cli.py:193 -#, python-format +#: tools/sort/cli.py:187 +#, fuzzy, python-format +#| msgid "" +#| "R|Integer value. Used to control the number of bins created for grouping " +#| "by: any 'blur' methods, 'color' methods or 'face metric' methods " +#| "('distance', 'size') and 'orientation; methods ('yaw', 'pitch'). For any " +#| "other grouping methods see the '-t' ('--threshold') option.\n" +#| "L|For 'face metric' methods the bins are filled, according the the " +#| "distribution of faces between the minimum and maximum chosen metric.\n" +#| "L|For 'color' methods the number of bins represents the divider of the " +#| "percentage of colored pixels. Eg. For a bin number of '5': The first " +#| "folder will have the faces with 0%% to 20%% colored pixels, second 21%% " +#| "to 40%%, etc. Any empty bins will be deleted, so you may end up with " +#| "fewer bins than selected.\n" +#| "L|For 'blur' methods folder 0 will be the least blurry, while the last " +#| "folder will be the blurriest.\n" +#| "L|For 'orientation' methods the number of bins is dictated by how much " +#| "180 degrees is divided. Eg. If 18 is selected, then each folder will be a " +#| "10 degree increment. Folder 0 will contain faces looking the most to the " +#| "left/down whereas the last folder will contain the faces looking the most " +#| "to the right/up. NB: Some bins may be empty if faces do not fit the " +#| "criteria.\n" +#| "Default value: 5" msgid "" "R|Integer value. Used to control the number of bins created for grouping by: " "any 'blur' methods, 'color' methods or 'face metric' methods ('distance', " @@ -335,7 +344,7 @@ msgid "" "degrees is divided. Eg. If 18 is selected, then each folder will be a 10 " "degree increment. Folder 0 will contain faces looking the most to the left/" "down whereas the last folder will contain the faces looking the most to the " -"right/up. NB: Some bins may be empty if faces do not fit the criteria.\n" +"right/up. NB: Some bins may be empty if faces do not fit the criteria. \n" "Default value: 5" msgstr "" "R| Целочисленное значение. Используется для управления количеством бинов, " @@ -360,11 +369,11 @@ msgstr "" "лица не соответствуют критериям.\n" "Значение по умолчанию: 5" -#: tools/sort/cli.py:215 tools/sort/cli.py:225 +#: tools/sort/cli.py:207 tools/sort/cli.py:217 msgid "settings" msgstr "настройки" -#: tools/sort/cli.py:217 +#: tools/sort/cli.py:210 msgid "" "Logs file renaming changes if grouping by renaming, or it logs the file " "copying/movement if grouping by folders. If no log file is specified with " @@ -376,7 +385,7 @@ msgstr "" "папкам. Если файл журнала не указан с помощью '--log-file', то в каталоге " "ввода будет создан файл 'sort_log.json'." -#: tools/sort/cli.py:228 +#: tools/sort/cli.py:221 msgid "" "Specify a log file to use for saving the renaming or grouping information. " "If specified extension isn't 'json' or 'yaml', then json will be used as the " @@ -386,3 +395,20 @@ msgstr "" "о переименовании или группировке. Если указанное расширение не 'json' или " "'yaml', то в качестве сериализатора будет использоваться json, с указанным " "именем файла. По умолчанию: sort_log.json" + +#~ msgid " option is deprecated. Use 'yaw'" +#~ msgstr " является устаревшей. Используйте 'yaw'" + +#~ msgid " option is deprecated. Use 'color-black'" +#~ msgstr " является устаревшей. Используйте 'color-black'" + +#~ msgid "output" +#~ msgstr "вывод" + +#~ msgid "" +#~ "Deprecated and no longer used. The final processing will be dictated by " +#~ "the sort/group by methods and whether 'keep_original' is selected." +#~ msgstr "" +#~ "Устарело и больше не используется. Окончательная обработка будет " +#~ "диктоваться методами sort/group by и тем, выбрана ли опция " +#~ "'keep_original'." diff --git a/locales/tools.alignments.cli.pot b/locales/tools.alignments.cli.pot index 253df0d06e..44f0bc67cb 100644 --- a/locales/tools.alignments.cli.pot +++ b/locales/tools.alignments.cli.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-02-24 00:27+0000\n" +"POT-Creation-Date: 2024-03-28 23:49+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,43 +17,43 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: tools/alignments/cli.py:17 +#: tools/alignments/cli.py:16 msgid "" "This command lets you perform various tasks pertaining to an alignments file." msgstr "" -#: tools/alignments/cli.py:32 +#: tools/alignments/cli.py:31 msgid "" "Alignments tool\n" "This tool allows you to perform numerous actions on or using an alignments " "file against its corresponding faceset/frame source." msgstr "" -#: tools/alignments/cli.py:44 +#: tools/alignments/cli.py:43 msgid " Must Pass in a frames folder/source video file (-fr)." msgstr "" -#: tools/alignments/cli.py:45 +#: tools/alignments/cli.py:44 msgid " Must Pass in a faces folder (-fc)." msgstr "" -#: tools/alignments/cli.py:46 +#: tools/alignments/cli.py:45 msgid "" " Must Pass in either a frames folder/source video file OR a faces folder (-" "fr or -fc)." msgstr "" -#: tools/alignments/cli.py:48 +#: tools/alignments/cli.py:47 msgid "" " Must Pass in a frames folder/source video file AND a faces folder (-fr and -" "fc)." msgstr "" -#: tools/alignments/cli.py:50 +#: tools/alignments/cli.py:49 msgid " Use the output option (-o) to process results." msgstr "" -#: tools/alignments/cli.py:58 tools/alignments/cli.py:97 +#: tools/alignments/cli.py:57 tools/alignments/cli.py:97 msgid "processing" msgstr "" @@ -94,7 +94,7 @@ msgid "" "(EXPERIMENTAL!)" msgstr "" -#: tools/alignments/cli.py:99 +#: tools/alignments/cli.py:100 msgid "" "R|How to output discovered items ('faces' and 'frames' only):\n" "L|'console': Print the list of frames to the screen. (DEFAULT)\n" @@ -104,12 +104,12 @@ msgid "" "directory." msgstr "" -#: tools/alignments/cli.py:110 tools/alignments/cli.py:123 -#: tools/alignments/cli.py:130 tools/alignments/cli.py:137 +#: tools/alignments/cli.py:111 tools/alignments/cli.py:134 +#: tools/alignments/cli.py:141 msgid "data" msgstr "" -#: tools/alignments/cli.py:114 +#: tools/alignments/cli.py:118 msgid "" "Full path to the alignments file to be processed. If you have input a " "'frames_dir' and don't provide this option, the process will try to find the " @@ -118,15 +118,11 @@ msgid "" "generated in the specified faces folder." msgstr "" -#: tools/alignments/cli.py:124 -msgid "Directory containing extracted faces." -msgstr "" - -#: tools/alignments/cli.py:131 +#: tools/alignments/cli.py:135 msgid "Directory containing source frames that faces were extracted from." msgstr "" -#: tools/alignments/cli.py:138 +#: tools/alignments/cli.py:143 msgid "" "R|Run the aligmnents tool on multiple sources. The following jobs support " "batch mode:\n" @@ -148,23 +144,23 @@ msgid "" "ignored." msgstr "" -#: tools/alignments/cli.py:164 tools/alignments/cli.py:175 -#: tools/alignments/cli.py:185 +#: tools/alignments/cli.py:169 tools/alignments/cli.py:181 +#: tools/alignments/cli.py:191 msgid "extract" msgstr "" -#: tools/alignments/cli.py:165 +#: tools/alignments/cli.py:171 msgid "" "[Extract only] Extract every 'nth' frame. This option will skip frames when " "extracting faces. For example a value of 1 will extract faces from every " "frame, a value of 10 will extract faces from every 10th frame." msgstr "" -#: tools/alignments/cli.py:176 +#: tools/alignments/cli.py:182 msgid "[Extract only] The output size of extracted faces." msgstr "" -#: tools/alignments/cli.py:186 +#: tools/alignments/cli.py:193 msgid "" "[Extract only] Only extract faces that have been resized by this percent or " "more to meet the specified extract size (`-sz`, `--size`). Useful for " diff --git a/locales/tools.effmpeg.cli.pot b/locales/tools.effmpeg.cli.pot index 83c4ac8f05..72ab831efa 100644 --- a/locales/tools.effmpeg.cli.pot +++ b/locales/tools.effmpeg.cli.pot @@ -1,29 +1,31 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR ORGANIZATION +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # +#, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-02-18 23:34-0000\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-28 23:50+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" +"Language: \n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=cp1252\n" +"Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" - #: tools/effmpeg/cli.py:15 msgid "This command allows you to easily execute common ffmpeg tasks." msgstr "" -#: tools/effmpeg/cli.py:24 +#: tools/effmpeg/cli.py:52 msgid "A wrapper for ffmpeg for performing image <> video converting." msgstr "" -#: tools/effmpeg/cli.py:51 +#: tools/effmpeg/cli.py:64 msgid "" "R|Choose which action you want ffmpeg ffmpeg to do.\n" "L|'extract': turns videos into images \n" @@ -36,80 +38,110 @@ msgid "" "L|'slice' cuts a portion of the video into a separate video file." msgstr "" -#: tools/effmpeg/cli.py:65 +#: tools/effmpeg/cli.py:78 msgid "Input file." msgstr "" -#: tools/effmpeg/cli.py:66 tools/effmpeg/cli.py:73 tools/effmpeg/cli.py:87 +#: tools/effmpeg/cli.py:79 tools/effmpeg/cli.py:86 tools/effmpeg/cli.py:100 msgid "data" msgstr "" -#: tools/effmpeg/cli.py:76 -msgid "Output file. If no output is specified then: if the output is meant to be a video then a video called 'out.mkv' will be created in the input directory; if the output is meant to be a directory then a directory called 'out' will be created inside the input directory. Note: the chosen output file extension will determine the file encoding." +#: tools/effmpeg/cli.py:89 +msgid "" +"Output file. If no output is specified then: if the output is meant to be a " +"video then a video called 'out.mkv' will be created in the input directory; " +"if the output is meant to be a directory then a directory called 'out' will " +"be created inside the input directory. Note: the chosen output file " +"extension will determine the file encoding." msgstr "" -#: tools/effmpeg/cli.py:89 +#: tools/effmpeg/cli.py:102 msgid "Path to reference video if 'input' was not a video." msgstr "" -#: tools/effmpeg/cli.py:95 tools/effmpeg/cli.py:105 tools/effmpeg/cli.py:142 -#: tools/effmpeg/cli.py:171 +#: tools/effmpeg/cli.py:108 tools/effmpeg/cli.py:118 tools/effmpeg/cli.py:156 +#: tools/effmpeg/cli.py:185 msgid "output" msgstr "" -#: tools/effmpeg/cli.py:97 -msgid "Provide video fps. Can be an integer, float or fraction. Negative values will will make the program try to get the fps from the input or reference videos." +#: tools/effmpeg/cli.py:110 +msgid "" +"Provide video fps. Can be an integer, float or fraction. Negative values " +"will will make the program try to get the fps from the input or reference " +"videos." msgstr "" -#: tools/effmpeg/cli.py:107 -msgid "Image format that extracted images should be saved as. '.bmp' will offer the fastest extraction speed, but will take the most storage space. '.png' will be slower but will take less storage." +#: tools/effmpeg/cli.py:120 +msgid "" +"Image format that extracted images should be saved as. '.bmp' will offer the " +"fastest extraction speed, but will take the most storage space. '.png' will " +"be slower but will take less storage." msgstr "" -#: tools/effmpeg/cli.py:114 tools/effmpeg/cli.py:123 tools/effmpeg/cli.py:132 +#: tools/effmpeg/cli.py:127 tools/effmpeg/cli.py:136 tools/effmpeg/cli.py:145 msgid "clip" msgstr "" -#: tools/effmpeg/cli.py:116 -msgid "Enter the start time from which an action is to be applied. Default: 00:00:00, in HH:MM:SS format. You can also enter the time with or without the colons, e.g. 00:0000 or 026010." +#: tools/effmpeg/cli.py:129 +msgid "" +"Enter the start time from which an action is to be applied. Default: " +"00:00:00, in HH:MM:SS format. You can also enter the time with or without " +"the colons, e.g. 00:0000 or 026010." msgstr "" -#: tools/effmpeg/cli.py:125 -msgid "Enter the end time to which an action is to be applied. If both an end time and duration are set, then the end time will be used and the duration will be ignored. Default: 00:00:00, in HH:MM:SS." +#: tools/effmpeg/cli.py:138 +msgid "" +"Enter the end time to which an action is to be applied. If both an end time " +"and duration are set, then the end time will be used and the duration will " +"be ignored. Default: 00:00:00, in HH:MM:SS." msgstr "" -#: tools/effmpeg/cli.py:134 -msgid "Enter the duration of the chosen action, for example if you enter 00:00:10 for slice, then the first 10 seconds after and including the start time will be cut out into a new video. Default: 00:00:00, in HH:MM:SS format. You can also enter the time with or without the colons, e.g. 00:0000 or 026010." +#: tools/effmpeg/cli.py:147 +msgid "" +"Enter the duration of the chosen action, for example if you enter 00:00:10 " +"for slice, then the first 10 seconds after and including the start time will " +"be cut out into a new video. Default: 00:00:00, in HH:MM:SS format. You can " +"also enter the time with or without the colons, e.g. 00:0000 or 026010." msgstr "" -#: tools/effmpeg/cli.py:144 -msgid "Mux the audio from the reference video into the input video. This option is only used for the 'gen-vid' action. 'mux-audio' action has this turned on implicitly." +#: tools/effmpeg/cli.py:158 +msgid "" +"Mux the audio from the reference video into the input video. This option is " +"only used for the 'gen-vid' action. 'mux-audio' action has this turned on " +"implicitly." msgstr "" -#: tools/effmpeg/cli.py:155 tools/effmpeg/cli.py:165 +#: tools/effmpeg/cli.py:169 tools/effmpeg/cli.py:179 msgid "rotate" msgstr "" -#: tools/effmpeg/cli.py:157 -msgid "Transpose the video. If transpose is set, then degrees will be ignored. For cli you can enter either the number or the long command name, e.g. to use (1, 90Clockwise) -tr 1 or -tr 90Clockwise" +#: tools/effmpeg/cli.py:171 +msgid "" +"Transpose the video. If transpose is set, then degrees will be ignored. For " +"cli you can enter either the number or the long command name, e.g. to use " +"(1, 90Clockwise) -tr 1 or -tr 90Clockwise" msgstr "" -#: tools/effmpeg/cli.py:166 +#: tools/effmpeg/cli.py:180 msgid "Rotate the video clockwise by the given number of degrees." msgstr "" -#: tools/effmpeg/cli.py:173 +#: tools/effmpeg/cli.py:187 msgid "Set the new resolution scale if the chosen action is 'rescale'." msgstr "" -#: tools/effmpeg/cli.py:178 tools/effmpeg/cli.py:186 +#: tools/effmpeg/cli.py:192 tools/effmpeg/cli.py:200 msgid "settings" msgstr "" -#: tools/effmpeg/cli.py:180 -msgid "Reduces output verbosity so that only serious errors are printed. If both quiet and verbose are set, verbose will override quiet." +#: tools/effmpeg/cli.py:194 +msgid "" +"Reduces output verbosity so that only serious errors are printed. If both " +"quiet and verbose are set, verbose will override quiet." msgstr "" -#: tools/effmpeg/cli.py:188 -msgid "Increases output verbosity. If both quiet and verbose are set, verbose will override quiet." +#: tools/effmpeg/cli.py:202 +msgid "" +"Increases output verbosity. If both quiet and verbose are set, verbose will " +"override quiet." msgstr "" - diff --git a/locales/tools.manual.pot b/locales/tools.manual.pot index 39ef3f5a1e..4e3fe2e9ab 100644 --- a/locales/tools.manual.pot +++ b/locales/tools.manual.pot @@ -1,51 +1,65 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR ORGANIZATION +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: \n" -"POT-Creation-Date: 2022-11-24 14:17+0900\n" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-28 23:55+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" +"Language: \n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=cp1252\n" +"Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 3.2\n" -#: tools/manual\cli.py:13 -msgid "This command lets you perform various actions on frames, faces and alignments files using visual tools." +#: tools/manual/cli.py:13 +msgid "" +"This command lets you perform various actions on frames, faces and " +"alignments files using visual tools." msgstr "" -#: tools/manual\cli.py:23 -msgid "A tool to perform various actions on frames, faces and alignments files using visual tools" +#: tools/manual/cli.py:23 +msgid "" +"A tool to perform various actions on frames, faces and alignments files " +"using visual tools" msgstr "" -#: tools/manual\cli.py:35 tools/manual\cli.py:43 +#: tools/manual/cli.py:35 tools/manual/cli.py:44 msgid "data" msgstr "" -#: tools/manual\cli.py:37 -msgid "Path to the alignments file for the input, if not at the default location" +#: tools/manual/cli.py:38 +msgid "" +"Path to the alignments file for the input, if not at the default location" msgstr "" -#: tools/manual\cli.py:44 -msgid "Video file or directory containing source frames that faces were extracted from." +#: tools/manual/cli.py:46 +msgid "" +"Video file or directory containing source frames that faces were extracted " +"from." msgstr "" -#: tools/manual\cli.py:51 tools/manual\cli.py:59 +#: tools/manual/cli.py:53 tools/manual/cli.py:62 msgid "options" msgstr "" -#: tools/manual\cli.py:52 -msgid "Force regeneration of the low resolution jpg thumbnails in the alignments file." +#: tools/manual/cli.py:55 +msgid "" +"Force regeneration of the low resolution jpg thumbnails in the alignments " +"file." msgstr "" -#: tools/manual\cli.py:60 -msgid "The process attempts to speed up generation of thumbnails by extracting from the video in parallel threads. For some videos, this causes the caching process to hang. If this happens, then set this option to generate the thumbnails in a slower, but more stable single thread." +#: tools/manual/cli.py:64 +msgid "" +"The process attempts to speed up generation of thumbnails by extracting from " +"the video in parallel threads. For some videos, this causes the caching " +"process to hang. If this happens, then set this option to generate the " +"thumbnails in a slower, but more stable single thread." msgstr "" #: tools/manual\faceviewer\frame.py:163 diff --git a/locales/tools.mask.cli.pot b/locales/tools.mask.cli.pot index fd1b65b3d5..54563a8620 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: 2024-03-11 23:45+0000\n" +"POT-Creation-Date: 2024-03-28 23:51+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/locales/tools.model.cli.pot b/locales/tools.model.cli.pot index 3afb8b1073..f5f2e9c690 100644 --- a/locales/tools.model.cli.pot +++ b/locales/tools.model.cli.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-06-28 14:05+0100\n" +"POT-Creation-Date: 2024-03-28 23:51+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -25,13 +25,13 @@ msgstr "" msgid "A tool for performing actions on Faceswap trained model files" msgstr "" -#: tools/model/cli.py:33 +#: tools/model/cli.py:34 msgid "" "Model directory. A directory containing the model you wish to perform an " "action on." msgstr "" -#: tools/model/cli.py:41 +#: tools/model/cli.py:43 msgid "" "R|Choose which action you want to perform.\n" "L|'inference' - Create an inference only copy of the model. Strips any " @@ -43,11 +43,11 @@ msgid "" "L|'restore' - Restore a model from backup." msgstr "" -#: tools/model/cli.py:55 tools/model/cli.py:66 +#: tools/model/cli.py:57 tools/model/cli.py:69 msgid "inference" msgstr "" -#: tools/model/cli.py:56 +#: tools/model/cli.py:59 msgid "" "R|The format to save the model as. Note: Only used for 'inference' job.\n" "L|'h5' - Standard Keras H5 format. Does not store any custom layer " @@ -56,8 +56,8 @@ msgid "" "required to load the model outside of Faceswap." msgstr "" -#: tools/model/cli.py:67 +#: tools/model/cli.py:71 msgid "" -"Only used for 'inference' job. Generate the inference model for B -> A " +"Only used for 'inference' job. Generate the inference model for B -> A " "instead of A -> B." msgstr "" diff --git a/locales/tools.preview.pot b/locales/tools.preview.pot index aa2e650ef4..1dac39da19 100644 --- a/locales/tools.preview.pot +++ b/locales/tools.preview.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-01-16 12:27+0000\n" +"POT-Creation-Date: 2024-03-28 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,64 +17,64 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: tools/preview/cli.py:14 +#: tools/preview/cli.py:15 msgid "This command allows you to preview swaps to tweak convert settings." msgstr "" -#: tools/preview/cli.py:29 +#: tools/preview/cli.py:30 msgid "" "Preview tool\n" "Allows you to configure your convert settings with a live preview" msgstr "" -#: tools/preview/cli.py:46 tools/preview/cli.py:55 tools/preview/cli.py:62 +#: tools/preview/cli.py:47 tools/preview/cli.py:57 tools/preview/cli.py:65 msgid "data" msgstr "" -#: tools/preview/cli.py:48 +#: tools/preview/cli.py:50 msgid "" "Input directory or video. Either a directory containing the image files you " "wish to process or path to a video file." msgstr "" -#: tools/preview/cli.py:57 +#: tools/preview/cli.py:60 msgid "" "Path to the alignments file for the input, if not at the default location" msgstr "" -#: tools/preview/cli.py:64 +#: tools/preview/cli.py:68 msgid "" "Model directory. A directory containing the trained model you wish to " "process." msgstr "" -#: tools/preview/cli.py:71 +#: tools/preview/cli.py:74 msgid "Swap the model. Instead of A -> B, swap B -> A" msgstr "" -#: tools/preview/control_panels.py:496 +#: tools/preview/control_panels.py:510 msgid "Save full config" msgstr "" -#: tools/preview/control_panels.py:499 +#: tools/preview/control_panels.py:513 msgid "Reset full config to default values" msgstr "" -#: tools/preview/control_panels.py:502 +#: tools/preview/control_panels.py:516 msgid "Reset full config to saved values" msgstr "" -#: tools/preview/control_panels.py:653 +#: tools/preview/control_panels.py:667 #, python-brace-format msgid "Save {title} config" msgstr "" -#: tools/preview/control_panels.py:656 +#: tools/preview/control_panels.py:670 #, python-brace-format msgid "Reset {title} config to default values" msgstr "" -#: tools/preview/control_panels.py:659 +#: tools/preview/control_panels.py:673 #, python-brace-format msgid "Reset {title} config to saved values" msgstr "" diff --git a/locales/tools.sort.cli.pot b/locales/tools.sort.cli.pot index 97e1dff6a2..8a963636d0 100644 --- a/locales/tools.sort.cli.pot +++ b/locales/tools.sort.cli.pot @@ -6,155 +6,147 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: \n" +"Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-11-24 14:19+0900\n" +"POT-Creation-Date: 2024-03-28 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" +"Language: \n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" +"Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Poedit 3.2\n" -#: tools/sort/cli.py:14 +#: tools/sort/cli.py:15 msgid "This command lets you sort images using various methods." msgstr "" -#: tools/sort/cli.py:20 +#: tools/sort/cli.py:21 msgid "" " Adjust the '-t' ('--threshold') parameter to control the strength of " "grouping." msgstr "" -#: tools/sort/cli.py:21 +#: tools/sort/cli.py:22 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. Each image is allocated to a bin by the percentage of color pixels " "that appear in the image." msgstr "" -#: tools/sort/cli.py:24 +#: tools/sort/cli.py:25 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. Each image is allocated to a bin by the number of degrees the face " "is orientated from center." msgstr "" -#: tools/sort/cli.py:27 +#: tools/sort/cli.py:28 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. The minimum and maximum values are taken for the chosen sort " "metric. The bins are then populated with the results from the group sorting." msgstr "" -#: tools/sort/cli.py:31 +#: tools/sort/cli.py:32 msgid "faces by blurriness." msgstr "" -#: tools/sort/cli.py:32 +#: tools/sort/cli.py:33 msgid "faces by fft filtered blurriness." msgstr "" -#: tools/sort/cli.py:33 +#: tools/sort/cli.py:34 msgid "" "faces by the estimated distance of the alignments from an 'average' face. " "This can be useful for eliminating misaligned faces. Sorts from most like an " "average face to least like an average face." msgstr "" -#: tools/sort/cli.py:36 +#: tools/sort/cli.py:37 msgid "" "faces using VGG Face2 by face similarity. This uses a pairwise clustering " "algorithm to check the distances between 512 features on every face in your " "set and order them appropriately." msgstr "" -#: tools/sort/cli.py:39 +#: tools/sort/cli.py:40 msgid "faces by their landmarks." msgstr "" -#: tools/sort/cli.py:40 +#: tools/sort/cli.py:41 msgid "Like 'face-cnn' but sorts by dissimilarity." msgstr "" -#: tools/sort/cli.py:41 +#: tools/sort/cli.py:42 msgid "faces by Yaw (rotation left to right)." msgstr "" -#: tools/sort/cli.py:42 +#: tools/sort/cli.py:43 msgid "faces by Pitch (rotation up and down)." msgstr "" -#: tools/sort/cli.py:43 +#: tools/sort/cli.py:44 msgid "" "faces by Roll (rotation). Aligned faces should have a roll value close to " "zero. The further the Roll value from zero the higher liklihood the face is " "misaligned." msgstr "" -#: tools/sort/cli.py:45 +#: tools/sort/cli.py:46 msgid "faces by their color histogram." msgstr "" -#: tools/sort/cli.py:46 +#: tools/sort/cli.py:47 msgid "Like 'hist' but sorts by dissimilarity." msgstr "" -#: tools/sort/cli.py:47 +#: tools/sort/cli.py:48 msgid "" "images by the average intensity of the converted grayscale color channel." msgstr "" -#: tools/sort/cli.py:48 +#: tools/sort/cli.py:49 msgid "" "images by their number of black pixels. Useful when faces are near borders " "and a large part of the image is black." msgstr "" -#: tools/sort/cli.py:50 +#: tools/sort/cli.py:51 msgid "" "images by the average intensity of the converted Y color channel. Bright " "lighting and oversaturated images will be ranked first." msgstr "" -#: tools/sort/cli.py:52 +#: tools/sort/cli.py:53 msgid "" "images by the average intensity of the converted Cg color channel. Green " "images will be ranked first and red images will be last." msgstr "" -#: tools/sort/cli.py:54 +#: tools/sort/cli.py:55 msgid "" "images by the average intensity of the converted Co color channel. Orange " "images will be ranked first and blue images will be last." msgstr "" -#: tools/sort/cli.py:56 +#: tools/sort/cli.py:57 msgid "" "images by their size in the original frame. Faces further from the camera " "and from lower resolution sources will be sorted first, whilst faces closer " "to the camera and from higher resolution sources will be sorted last." msgstr "" -#: tools/sort/cli.py:59 -msgid " option is deprecated. Use 'yaw'" -msgstr "" - -#: tools/sort/cli.py:60 -msgid " option is deprecated. Use 'color-black'" -msgstr "" - -#: tools/sort/cli.py:82 +#: tools/sort/cli.py:81 msgid "Sort faces using a number of different techniques" msgstr "" -#: tools/sort/cli.py:92 tools/sort/cli.py:99 tools/sort/cli.py:110 -#: tools/sort/cli.py:148 +#: tools/sort/cli.py:91 tools/sort/cli.py:98 tools/sort/cli.py:110 +#: tools/sort/cli.py:150 msgid "data" msgstr "" -#: tools/sort/cli.py:93 +#: tools/sort/cli.py:92 msgid "Input directory of aligned faces." msgstr "" @@ -167,18 +159,18 @@ msgid "" "'input_dir'" msgstr "" -#: tools/sort/cli.py:111 +#: tools/sort/cli.py:112 msgid "" "R|If selected then the input_dir should be a parent folder containing " "multiple folders of faces you wish to sort. The faces will be output to " "separate sub-folders in the output_dir" msgstr "" -#: tools/sort/cli.py:120 +#: tools/sort/cli.py:121 msgid "sort settings" msgstr "" -#: tools/sort/cli.py:122 +#: tools/sort/cli.py:124 msgid "" "R|Choose how images are sorted. Selecting a sort method gives the images a " "new filename based on the order the image appears within the given method.\n" @@ -188,20 +180,20 @@ msgid "" "'none' for both 'sort-by' and 'group-by' will do nothing" msgstr "" -#: tools/sort/cli.py:135 tools/sort/cli.py:162 tools/sort/cli.py:191 +#: tools/sort/cli.py:136 tools/sort/cli.py:164 tools/sort/cli.py:184 msgid "group settings" msgstr "" -#: tools/sort/cli.py:137 +#: tools/sort/cli.py:139 msgid "" "R|Selecting a group by method will move/copy files into numbered bins based " "on the selected method.\n" "L|'none': Don't bin the images. Folders will be sorted by the selected 'sort-" "by' but will not be binned, instead they will be sorted into a single " -"folder. Selecting 'none' for both 'sort-by' and 'group-by' will do nothing" +"folder. Selecting 'none' for both 'sort-by' and 'group-by' will do nothing" msgstr "" -#: tools/sort/cli.py:149 +#: tools/sort/cli.py:152 msgid "" "Whether to keep the original files in their original location. Choosing a " "'sort-by' method means that the files have to be renamed. Selecting 'keep' " @@ -211,7 +203,7 @@ msgid "" "criteria." msgstr "" -#: tools/sort/cli.py:164 +#: tools/sort/cli.py:167 msgid "" "R|Float value. Minimum threshold to use for grouping comparison with 'face-" "cnn' 'hist' and 'face' methods.\n" @@ -226,17 +218,7 @@ msgid "" "face-cnn 7.2, hist 0.3, face 0.25" msgstr "" -#: tools/sort/cli.py:181 -msgid "output" -msgstr "" - -#: tools/sort/cli.py:182 -msgid "" -"Deprecated and no longer used. The final processing will be dictated by the " -"sort/group by methods and whether 'keep_original' is selected." -msgstr "" - -#: tools/sort/cli.py:193 +#: tools/sort/cli.py:187 #, python-format msgid "" "R|Integer value. Used to control the number of bins created for grouping by: " @@ -256,15 +238,15 @@ msgid "" "degrees is divided. Eg. If 18 is selected, then each folder will be a 10 " "degree increment. Folder 0 will contain faces looking the most to the left/" "down whereas the last folder will contain the faces looking the most to the " -"right/up. NB: Some bins may be empty if faces do not fit the criteria.\n" +"right/up. NB: Some bins may be empty if faces do not fit the criteria. \n" "Default value: 5" msgstr "" -#: tools/sort/cli.py:215 tools/sort/cli.py:225 +#: tools/sort/cli.py:207 tools/sort/cli.py:217 msgid "settings" msgstr "" -#: tools/sort/cli.py:217 +#: tools/sort/cli.py:210 msgid "" "Logs file renaming changes if grouping by renaming, or it logs the file " "copying/movement if grouping by folders. If no log file is specified with " @@ -272,7 +254,7 @@ msgid "" "directory." msgstr "" -#: tools/sort/cli.py:228 +#: tools/sort/cli.py:221 msgid "" "Specify a log file to use for saving the renaming or grouping information. " "If specified extension isn't 'json' or 'yaml', then json will be used as the " diff --git a/scripts/convert.py b/scripts/convert.py index 72829cba87..57d98653cf 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -22,7 +22,7 @@ from lib.image import read_image_meta_batch, ImagesLoader from lib.multithreading import MultiThread, total_cpus from lib.queue_manager import queue_manager -from lib.utils import FaceswapError, get_folder, get_image_paths +from lib.utils import FaceswapError, get_folder, get_image_paths, handle_deprecated_cliopts from plugins.extract.pipeline import Extractor, ExtractMedia from plugins.plugin_loader import PluginLoader @@ -62,7 +62,7 @@ class ConvertItem: swapped_faces: np.ndarray = np.array([]) -class Convert(): # pylint:disable=too-few-public-methods +class Convert(): """ The Faceswap Face Conversion Process. The conversion process is responsible for swapping the faces on source frames with the output @@ -82,7 +82,7 @@ class Convert(): # pylint:disable=too-few-public-methods """ def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: (args: %s)", self.__class__.__name__, arguments) - self._args = arguments + self._args = handle_deprecated_cliopts(arguments) self._images = ImagesLoader(self._args.input_dir, fast_count=True) self._alignments = Alignments(self._args, False, self._images.is_video) @@ -847,8 +847,7 @@ def _get_batchsize(self, queue_size: int) -> int: def _get_model_name(self, model_dir: str) -> str: """ Return the name of the Faceswap model used. - If a "trainer" option has been selected in the command line arguments, use that value, - otherwise retrieve the name of the model from the model's state file. + Retrieve the name of the model from the model's state file. Parameters ---------- @@ -861,24 +860,18 @@ def _get_model_name(self, model_dir: str) -> str: The name of the Faceswap model being used. """ - if hasattr(self._args, "trainer") and self._args.trainer: - logger.debug("Trainer name provided: '%s'", self._args.trainer) - return self._args.trainer - statefiles = [fname for fname in os.listdir(str(model_dir)) if fname.endswith("_state.json")] if len(statefiles) != 1: raise FaceswapError("There should be 1 state file in your model folder. " - f"{len(statefiles)} were found. Specify a trainer with the '-t', " - "'--trainer' option.") + f"{len(statefiles)} were found.") statefile = os.path.join(str(model_dir), statefiles[0]) state = self._serializer.load(statefile) trainer = state.get("name", None) if not trainer: - raise FaceswapError("Trainer name could not be read from state file. " - "Specify a trainer with the '-t', '--trainer' option.") + raise FaceswapError("Trainer name could not be read from state file.") logger.debug("Trainer from state file: '%s'", trainer) return trainer @@ -1100,7 +1093,7 @@ def _queue_out_frames(self, batch: list[ConvertItem], swapped_faces: np.ndarray) logger.trace("Queued out batch. Batchsize: %s", len(batch)) # type:ignore -class OptionalActions(): # pylint:disable=too-few-public-methods +class OptionalActions(): """ Process specific optional actions for Convert. Currently only handles skip faces. This class should probably be (re)moved. diff --git a/scripts/extract.py b/scripts/extract.py index 812cf94a23..bd6cc41293 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -16,7 +16,7 @@ from lib.image import encode_image, generate_thumbnail, ImagesLoader, ImagesSaver, read_image_meta from lib.multithreading import MultiThread -from lib.utils import get_folder, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS +from lib.utils import get_folder, handle_deprecated_cliopts, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS from plugins.extract.pipeline import Extractor, ExtractMedia from scripts.fsmedia import Alignments, PostProcess, finalize @@ -27,7 +27,7 @@ logger = logging.getLogger(__name__) -class Extract(): # pylint:disable=too-few-public-methods +class Extract(): """ The Faceswap Face Extraction Process. The extraction process is responsible for detecting faces in a series of images/video, aligning @@ -47,7 +47,7 @@ class Extract(): # pylint:disable=too-few-public-methods """ def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: (args: %s", self.__class__.__name__, arguments) - self._args = arguments + self._args = handle_deprecated_cliopts(arguments) self._input_locations = self._get_input_locations() self._validate_batchmode() @@ -616,7 +616,7 @@ def _reload(self, detected_faces: dict[str, ExtractMedia]) -> None: logger.debug("Reload Images: Complete") -class _Extract(): # pylint:disable=too-few-public-methods +class _Extract(): """ The Actual extraction process. This class is called by the parent :class:`Extract` process diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 3837c68eed..e519078904 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -600,6 +600,7 @@ def process(self, extract_media: ExtractMedia) -> None: logger.trace("Drawing Landmarks. Frame: '%s'. Face: %s", # type:ignore[attr-defined] frame, idx) # Landmarks + assert face.aligned.face is not None for (pos_x, pos_y) in face.aligned.landmarks.astype("int32"): cv2.circle(face.aligned.face, (pos_x, pos_y), 1, (0, 255, 255), -1) # Pose diff --git a/scripts/train.py b/scripts/train.py index e08e9a8261..bd455bcb4a 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -17,7 +17,7 @@ from lib.keypress import KBHit from lib.multithreading import MultiThread, FSThread from lib.training import Preview, PreviewBuffer, TriggerType -from lib.utils import (get_folder, get_image_paths, +from lib.utils import (get_folder, get_image_paths, handle_deprecated_cliopts, FaceswapError, IMAGE_EXTENSIONS) from plugins.plugin_loader import PluginLoader @@ -48,8 +48,7 @@ class Train(): """ def __init__(self, arguments: argparse.Namespace) -> None: logger.debug("Initializing %s: (args: %s", self.__class__.__name__, arguments) - self._args = arguments - self._handle_deprecations() + self._args = handle_deprecated_cliopts(arguments) if self._args.summary: # If just outputting summary we don't need to initialize everything @@ -68,10 +67,6 @@ def __init__(self, arguments: argparse.Namespace) -> None: logger.debug("Initialized %s", self.__class__.__name__) - def _handle_deprecations(self) -> None: - """ Handle the update of deprecated arguments and output warnings. """ - return - def _get_images(self) -> dict[T.Literal["a", "b"], list[str]]: """ Check the image folders exist and contains valid extracted faces. Obtain image paths. @@ -381,8 +376,8 @@ def _output_startup_info(self) -> None: logger.info(" Using live preview") if sys.stdout.isatty(): logger.info(" Press '%s' to save and quit", - "Stop" if self._args.redirect_gui or self._args.colab else "ENTER") - if not self._args.redirect_gui and not self._args.colab and sys.stdout.isatty(): + "Stop" if self._args.redirect_gui else "ENTER") + if not self._args.redirect_gui and sys.stdout.isatty(): logger.info(" Press 'S' to save model weights immediately") logger.info("===================================================") diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index eca95982ec..8c9af327a6 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -8,11 +8,11 @@ from argparse import Namespace from multiprocessing import Process -from lib.utils import VIDEO_EXTENSIONS, FaceswapError +from lib.utils import FaceswapError, handle_deprecated_cliopts, VIDEO_EXTENSIONS from .media import AlignmentData -from .jobs import Check, Sort, Spatial # noqa pylint: disable=unused-import -from .jobs_faces import FromFaces, RemoveFaces, Rename # noqa pylint: disable=unused-import -from .jobs_frames import Draw, Extract # noqa pylint: disable=unused-import +from .jobs import Check, Sort, Spatial # noqa pylint:disable=unused-import +from .jobs_faces import FromFaces, RemoveFaces, Rename # noqa pylint:disable=unused-import +from .jobs_frames import Draw, Extract # noqa pylint:disable=unused-import logger = logging.getLogger(__name__) @@ -42,7 +42,7 @@ def __init__(self, arguments: Namespace) -> None: "missing-frames", "no-faces"] - self._args = arguments + self._args = handle_deprecated_cliopts(arguments) self._batch_mode = self._validate_batch_mode() self._locations = self._get_locations() diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py index d41b4df481..26221105b3 100644 --- a/tools/alignments/cli.py +++ b/tools/alignments/cli.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ +import argparse import sys import gettext import typing as T @@ -7,7 +8,6 @@ from lib.cli.args import FaceSwapArgs from lib.cli.actions import DirOrFileFullPaths, DirFullPaths, FileFullPaths, Radio, Slider - # LOCALES _LANG = gettext.translation("tools.alignments.cli", localedir="locales", fallback=True) _ = _LANG.gettext @@ -48,145 +48,174 @@ def get_argument_list() -> list[dict[str, T.Any]]: "folder (-fr and -fc).") output_opts = _(" Use the output option (-o) to process results.") argument_list = [] - argument_list.append(dict( - opts=("-j", "--job"), - action=Radio, - type=str, - choices=("draw", "extract", "from-faces", "missing-alignments", "missing-frames", - "multi-faces", "no-faces", "remove-faces", "rename", "sort", "spatial"), - group=_("processing"), - required=True, - help=_("R|Choose which action you want to perform. NB: All actions require an " - "alignments file (-a) to be passed in." - "\nL|'draw': Draw landmarks on frames in the selected folder/video. A " - "subfolder will be created within the frames folder to hold the output.{0}" - "\nL|'extract': Re-extract faces from the source frames/video based on " - "alignment data. This is a lot quicker than re-detecting faces. Can pass in " - "the '-een' (--extract-every-n) parameter to only extract every nth frame.{1}" - "\nL|'from-faces': Generate alignment file(s) from a folder of extracted " - "faces. if the folder of faces comes from multiple sources, then multiple " - "alignments files will be created. NB: for faces which have been extracted " - "from folders of source images, rather than a video, a single alignments file " - "will be created as there is no way for the process to know how many folders " - "of images were originally used. You do not need to provide an alignments file " - "path to run this job. {3}" - "\nL|'missing-alignments': Identify frames that do not exist in the alignments " - "file.{2}{0}" - "\nL|'missing-frames': Identify frames in the alignments file that do not " - "appear within the frames folder/video.{2}{0}" - "\nL|'multi-faces': Identify where multiple faces exist within the alignments " - "file.{2}{4}" - "\nL|'no-faces': Identify frames that exist within the alignment file but no " - "faces were detected.{2}{0}" - "\nL|'remove-faces': Remove deleted faces from an alignments file. The " - "original alignments file will be backed up.{3}" - "\nL|'rename' - Rename faces to correspond with their parent frame and " - "position index in the alignments file (i.e. how they are named after running " - "extract).{3}" - "\nL|'sort': Re-index the alignments from left to right. For alignments with " - "multiple faces this will ensure that the left-most face is at index 0." - "\nL|'spatial': Perform spatial and temporal filtering to smooth alignments " - "(EXPERIMENTAL!)").format(frames_dir, frames_and_faces_dir, output_opts, - faces_dir, frames_or_faces_dir))) - argument_list.append(dict( - opts=("-o", "--output"), - action=Radio, - type=str, - choices=("console", "file", "move"), - group=_("processing"), - default="console", - help=_("R|How to output discovered items ('faces' and 'frames' only):" - "\nL|'console': Print the list of frames to the screen. (DEFAULT)" - "\nL|'file': Output the list of frames to a text file (stored within the " - "source directory)." - "\nL|'move': Move the discovered items to a sub-folder within the source " - "directory."))) - argument_list.append(dict( - opts=("-a", "--alignments_file"), - action=FileFullPaths, - dest="alignments_file", - type=str, - group=_("data"), + argument_list.append({ + "opts": ("-j", "--job"), + "action": Radio, + "type": str, + "choices": ("draw", "extract", "from-faces", "missing-alignments", "missing-frames", + "multi-faces", "no-faces", "remove-faces", "rename", "sort", "spatial"), + "group": _("processing"), + "required": True, + "help": _( + "R|Choose which action you want to perform. NB: All actions require an " + "alignments file (-a) to be passed in." + "\nL|'draw': Draw landmarks on frames in the selected folder/video. A " + "subfolder will be created within the frames folder to hold the output.{0}" + "\nL|'extract': Re-extract faces from the source frames/video based on " + "alignment data. This is a lot quicker than re-detecting faces. Can pass in " + "the '-een' (--extract-every-n) parameter to only extract every nth frame.{1}" + "\nL|'from-faces': Generate alignment file(s) from a folder of extracted " + "faces. if the folder of faces comes from multiple sources, then multiple " + "alignments files will be created. NB: for faces which have been extracted " + "from folders of source images, rather than a video, a single alignments file " + "will be created as there is no way for the process to know how many folders " + "of images were originally used. You do not need to provide an alignments file " + "path to run this job. {3}" + "\nL|'missing-alignments': Identify frames that do not exist in the alignments " + "file.{2}{0}" + "\nL|'missing-frames': Identify frames in the alignments file that do not " + "appear within the frames folder/video.{2}{0}" + "\nL|'multi-faces': Identify where multiple faces exist within the alignments " + "file.{2}{4}" + "\nL|'no-faces': Identify frames that exist within the alignment file but no " + "faces were detected.{2}{0}" + "\nL|'remove-faces': Remove deleted faces from an alignments file. The " + "original alignments file will be backed up.{3}" + "\nL|'rename' - Rename faces to correspond with their parent frame and " + "position index in the alignments file (i.e. how they are named after running " + "extract).{3}" + "\nL|'sort': Re-index the alignments from left to right. For alignments with " + "multiple faces this will ensure that the left-most face is at index 0." + "\nL|'spatial': Perform spatial and temporal filtering to smooth alignments " + "(EXPERIMENTAL!)").format(frames_dir, frames_and_faces_dir, output_opts, + faces_dir, frames_or_faces_dir)}) + argument_list.append({ + "opts": ("-o", "--output"), + "action": Radio, + "type": str, + "choices": ("console", "file", "move"), + "group": _("processing"), + "default": "console", + "help": _( + "R|How to output discovered items ('faces' and 'frames' only):" + "\nL|'console': Print the list of frames to the screen. (DEFAULT)" + "\nL|'file': Output the list of frames to a text file (stored within the " + "source directory)." + "\nL|'move': Move the discovered items to a sub-folder within the source " + "directory.")}) + argument_list.append({ + "opts": ("-a", "--alignments_file"), + "action": FileFullPaths, + "dest": "alignments_file", + "type": str, + "group": _("data"), # hacky solution to not require alignments file if creating alignments from faces: - required=not any(val in sys.argv for val in ["from-faces", "-fr", "-frames_folder"]), - filetypes="alignments", - help=_("Full path to the alignments file to be processed. If you have input a " - "'frames_dir' and don't provide this option, the process will try to find the " - "alignments file at the default location. All jobs require an alignments file " - "with the exception of 'from-faces' when the alignments file will be generated " - "in the specified faces folder."))) - argument_list.append(dict( - opts=("-fc", "-faces_folder"), - action=DirFullPaths, - dest="faces_dir", - group=_("data"), - help=_("Directory containing extracted faces."))) - argument_list.append(dict( - opts=("-fr", "-frames_folder"), - action=DirOrFileFullPaths, - dest="frames_dir", - filetypes="video", - group=_("data"), - help=_("Directory containing source frames that faces were extracted from."))) - argument_list.append(dict( - opts=("-B", "--batch-mode"), - action="store_true", - dest="batch_mode", - default=False, - group=_("data"), - help=_("R|Run the aligmnents tool on multiple sources. The following jobs support " - "batch mode:" - "\nL|draw, extract, from-faces, missing-alignments, missing-frames, no-faces, " - "sort, spatial." - "\nIf batch mode is selected then the other options should be set as follows:" - "\nL|alignments_file: For 'sort' and 'spatial' this should point to the parent " - "folder containing the alignments files to be processed. For all other jobs " - "this option is ignored, and the alignments files must exist at their default " - "location relative to the original frames folder/video." - "\nL|faces_dir: For 'from-faces' this should be a parent folder, containing " - "sub-folders of extracted faces from which to generate alignments files. For " - "'extract' this should be a parent folder where sub-folders will be created " - "for each extraction to be run. For all other jobs this option is ignored." - "\nL|frames_dir: For 'draw', 'extract', 'missing-alignments', 'missing-frames' " - "and 'no-faces' this should be a parent folder containing video files or sub-" - "folders of images to perform the alignments job on. The alignments file " - "should exist at the default location. For all other jobs this option is " - "ignored."))) - argument_list.append(dict( - opts=("-een", "--extract-every-n"), - type=int, - action=Slider, - dest="extract_every_n", - min_max=(1, 100), - default=1, - rounding=1, - group=_("extract"), - help=_("[Extract only] Extract every 'nth' frame. This option will skip frames when " - "extracting faces. For example a value of 1 will extract faces from every " - "frame, a value of 10 will extract faces from every 10th frame."))) - argument_list.append(dict( - opts=("-sz", "--size"), - type=int, - action=Slider, - min_max=(256, 1024), - rounding=64, - default=512, - group=_("extract"), - help=_("[Extract only] The output size of extracted faces."))) - argument_list.append(dict( - opts=("-m", "--min-size"), - type=int, - action=Slider, - min_max=(0, 200), - rounding=1, - default=0, - dest="min_size", - group=_("extract"), - help=_("[Extract only] Only extract faces that have been resized by this percent or " - "more to meet the specified extract size (`-sz`, `--size`). Useful for " - "excluding low-res images from a training set. Set to 0 to extract all faces. " - "Eg: For an extract size of 512px, A setting of 50 will only include faces " - "that have been resized from 256px or above. Setting to 100 will only extract " - "faces that have been resized from 512px or above. A setting of 200 will only " - "extract faces that have been downscaled from 1024px or above."))) + "required": not any(val in sys.argv for val in ["from-faces", + "-fr", + "-frames_folder"]), + "filetypes": "alignments", + "help": _( + "Full path to the alignments file to be processed. If you have input a " + "'frames_dir' and don't provide this option, the process will try to find the " + "alignments file at the default location. All jobs require an alignments file " + "with the exception of 'from-faces' when the alignments file will be generated " + "in the specified faces folder.")}) + argument_list.append({ + "opts": ("-c", "-faces_folder"), + "action": DirFullPaths, + "dest": "faces_dir", + "group": ("data"), + "help": ("Directory containing extracted faces.")}) + argument_list.append({ + "opts": ("-r", "-frames_folder"), + "action": DirOrFileFullPaths, + "dest": "frames_dir", + "filetypes": "video", + "group": _("data"), + "help": _("Directory containing source frames that faces were extracted from.")}) + argument_list.append({ + "opts": ("-B", "--batch-mode"), + "action": "store_true", + "dest": "batch_mode", + "default": False, + "group": _("data"), + "help": _( + "R|Run the aligmnents tool on multiple sources. The following jobs support " + "batch mode:" + "\nL|draw, extract, from-faces, missing-alignments, missing-frames, no-faces, " + "sort, spatial." + "\nIf batch mode is selected then the other options should be set as follows:" + "\nL|alignments_file: For 'sort' and 'spatial' this should point to the parent " + "folder containing the alignments files to be processed. For all other jobs " + "this option is ignored, and the alignments files must exist at their default " + "location relative to the original frames folder/video." + "\nL|faces_dir: For 'from-faces' this should be a parent folder, containing " + "sub-folders of extracted faces from which to generate alignments files. For " + "'extract' this should be a parent folder where sub-folders will be created " + "for each extraction to be run. For all other jobs this option is ignored." + "\nL|frames_dir: For 'draw', 'extract', 'missing-alignments', 'missing-frames' " + "and 'no-faces' this should be a parent folder containing video files or sub-" + "folders of images to perform the alignments job on. The alignments file " + "should exist at the default location. For all other jobs this option is " + "ignored.")}) + argument_list.append({ + "opts": ("-N", "--extract-every-n"), + "type": int, + "action": Slider, + "dest": "extract_every_n", + "min_max": (1, 100), + "default": 1, + "rounding": 1, + "group": _("extract"), + "help": _( + "[Extract only] Extract every 'nth' frame. This option will skip frames when " + "extracting faces. For example a value of 1 will extract faces from every frame, " + "a value of 10 will extract faces from every 10th frame.")}) + argument_list.append({ + "opts": ("-z", "--size"), + "type": int, + "action": Slider, + "min_max": (256, 1024), + "rounding": 64, + "default": 512, + "group": _("extract"), + "help": _("[Extract only] The output size of extracted faces.")}) + argument_list.append({ + "opts": ("-m", "--min-size"), + "type": int, + "action": Slider, + "min_max": (0, 200), + "rounding": 1, + "default": 0, + "dest": "min_size", + "group": _("extract"), + "help": _( + "[Extract only] Only extract faces that have been resized by this percent or " + "more to meet the specified extract size (`-sz`, `--size`). Useful for " + "excluding low-res images from a training set. Set to 0 to extract all faces. " + "Eg: For an extract size of 512px, A setting of 50 will only include faces " + "that have been resized from 256px or above. Setting to 100 will only extract " + "faces that have been resized from 512px or above. A setting of 200 will only " + "extract faces that have been downscaled from 1024px or above.")}) + # Deprecated multi-character switches + argument_list.append({ + "opts": ("-fc", ), + "type": str, + "dest": "depr_faces_dir_fc_c", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-fr", ), + "type": str, + "dest": "depr_extract_every_n_een_N", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-een", ), + "type": int, + "dest": "depr_frames_dir_fr_r", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-sz", ), + "type": int, + "dest": "depr_size_sz_z", + "help": argparse.SUPPRESS}) return argument_list diff --git a/tools/alignments/jobs_frames.py b/tools/alignments/jobs_frames.py index 8f7efb9b85..314f8031ad 100644 --- a/tools/alignments/jobs_frames.py +++ b/tools/alignments/jobs_frames.py @@ -104,6 +104,8 @@ def _annotate_image(self, frame_name: str) -> None: face = DetectedFace() face.from_alignment(alignment, image=image) # Bounding Box + assert face.left is not None + assert face.top is not None cv2.rectangle(image, (face.left, face.top), (face.right, face.bottom), (255, 0, 0), 1) self._annotate_landmarks(image, np.rint(face.landmarks_xy).astype("int32")) self._annotate_extract_boxes(image, face, idx) diff --git a/tools/effmpeg/cli.py b/tools/effmpeg/cli.py index ae82625bf6..b80a81b1e9 100644 --- a/tools/effmpeg/cli.py +++ b/tools/effmpeg/cli.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ +import argparse import gettext from lib.cli.args import FaceSwapArgs @@ -11,10 +12,37 @@ _LANG = gettext.translation("tools.effmpeg.cli", localedir="locales", fallback=True) _ = _LANG.gettext - _HELPTEXT = _("This command allows you to easily execute common ffmpeg tasks.") +def __parse_transpose(value: str) -> str: + """ Parse transpose option + + Parameters + ---------- + value: str + The value to parse + + Returns + ------- + str + The option item for the given value + """ + index = 0 + opts = ["(0, 90CounterClockwise&VerticalFlip)", + "(1, 90Clockwise)", + "(2, 90CounterClockwise)", + "(3, 90Clockwise&VerticalFlip)"] + if len(value) == 1: + index = int(value) + else: + for i in range(5): + if value in opts[i]: + index = i + break + return opts[index] + + class EffmpegArgs(FaceSwapArgs): """ Class to parse the command line arguments for EFFMPEG tool """ @@ -24,167 +52,184 @@ def get_info(): return _("A wrapper for ffmpeg for performing image <> video converting.") @staticmethod - def __parse_transpose(value): - index = 0 - opts = ["(0, 90CounterClockwise&VerticalFlip)", - "(1, 90Clockwise)", - "(2, 90CounterClockwise)", - "(3, 90Clockwise&VerticalFlip)"] - if len(value) == 1: - index = int(value) - else: - for i in range(5): - if value in opts[i]: - index = i - break - return opts[index] - - def get_argument_list(self): - argument_list = list() - argument_list.append(dict( - opts=('-a', '--action'), - action=Radio, - dest="action", - choices=("extract", "gen-vid", "get-fps", "get-info", "mux-audio", "rescale", "rotate", - "slice"), - default="extract", - help=_("R|Choose which action you want ffmpeg ffmpeg to do." - "\nL|'extract': turns videos into images " - "\nL|'gen-vid': turns images into videos " - "\nL|'get-fps' returns the chosen video's fps." - "\nL|'get-info' returns information about a video." - "\nL|'mux-audio' add audio from one video to another." - "\nL|'rescale' resize video." - "\nL|'rotate' rotate video." - "\nL|'slice' cuts a portion of the video into a separate video file."))) - argument_list.append(dict( - opts=('-i', '--input'), - action=ContextFullPaths, - dest="input", - default="input", - help=_("Input file."), - group=_("data"), - required=True, - action_option="-a", - filetypes="video")) - argument_list.append(dict( - opts=('-o', '--output'), - action=ContextFullPaths, - group=_("data"), - default="", - dest="output", - help=_("Output file. If no output is specified then: if the output is meant to be a " - "video then a video called 'out.mkv' will be created in the input directory; " - "if the output is meant to be a directory then a directory called 'out' will " - "be created inside the input directory. Note: the chosen output file extension " - "will determine the file encoding."), - action_option="-a", - filetypes="video")) - argument_list.append(dict( - opts=('-r', '--reference-video'), - action=FileFullPaths, - dest="ref_vid", - group=_("data"), - default=None, - help=_("Path to reference video if 'input' was not a video."), - filetypes="video")) - argument_list.append(dict( - opts=('-fps', '--fps'), - type=str, - dest="fps", - group=_("output"), - default="-1.0", - help=_("Provide video fps. Can be an integer, float or fraction. Negative values will " - "will make the program try to get the fps from the input or reference " - "videos."))) - argument_list.append(dict( - opts=("-ef", "--extract-filetype"), - action=Radio, - choices=IMAGE_EXTENSIONS, - dest="extract_ext", - group=_("output"), - default=".png", - help=_("Image format that extracted images should be saved as. '.bmp' will offer the " - "fastest extraction speed, but will take the most storage space. '.png' will " - "be slower but will take less storage."))) - argument_list.append(dict( - opts=('-s', '--start'), - type=str, - dest="start", - group=_("clip"), - default="00:00:00", - help=_("Enter the start time from which an action is to be applied. Default: " - "00:00:00, in HH:MM:SS format. You can also enter the time with or without the " - "colons, e.g. 00:0000 or 026010."))) - argument_list.append(dict( - opts=('-e', '--end'), - type=str, - dest="end", - group=_("clip"), - default="00:00:00", - help=_("Enter the end time to which an action is to be applied. If both an end time " - "and duration are set, then the end time will be used and the duration will be " - "ignored. Default: 00:00:00, in HH:MM:SS."))) - argument_list.append(dict( - opts=('-d', '--duration'), - type=str, - dest="duration", - group=_("clip"), - default="00:00:00", - help=_("Enter the duration of the chosen action, for example if you enter 00:00:10 " - "for slice, then the first 10 seconds after and including the start time will " - "be cut out into a new video. Default: 00:00:00, in HH:MM:SS format. You can " - "also enter the time with or without the colons, e.g. 00:0000 or 026010."))) - argument_list.append(dict( - opts=('-m', '--mux-audio'), - action="store_true", - dest="mux_audio", - group=_("output"), - default=False, - help=_("Mux the audio from the reference video into the input video. This option is " - "only used for the 'gen-vid' action. 'mux-audio' action has this turned on " - "implicitly."))) - argument_list.append(dict( - opts=('-tr', '--transpose'), - choices=("(0, 90CounterClockwise&VerticalFlip)", - "(1, 90Clockwise)", - "(2, 90CounterClockwise)", - "(3, 90Clockwise&VerticalFlip)"), - type=lambda v: self.__parse_transpose(v), # pylint:disable=unnecessary-lambda - dest="transpose", - group=_("rotate"), - default=None, - help=_("Transpose the video. If transpose is set, then degrees will be ignored. For " - "cli you can enter either the number or the long command name, e.g. to use (1, " - "90Clockwise) -tr 1 or -tr 90Clockwise"))) - argument_list.append(dict( - opts=('-de', '--degrees'), - type=str, - dest="degrees", - default=None, - group=_("rotate"), - help=_("Rotate the video clockwise by the given number of degrees."))) - argument_list.append(dict( - opts=('-sc', '--scale'), - type=str, - dest="scale", - group=_("output"), - default="1920x1080", - help=_("Set the new resolution scale if the chosen action is 'rescale'."))) - argument_list.append(dict( - opts=('-q', '--quiet'), - action="store_true", - dest="quiet", - group=_("settings"), - default=False, - help=_("Reduces output verbosity so that only serious errors are printed. If both " - "quiet and verbose are set, verbose will override quiet."))) - argument_list.append(dict( - opts=('-v', '--verbose'), - action="store_true", - dest="verbose", - group=_("settings"), - default=False, - help=_("Increases output verbosity. If both quiet and verbose are set, verbose will " - "override quiet."))) + def get_argument_list(): + argument_list = [] + argument_list.append({ + "opts": ('-a', '--action'), + "action": Radio, + "dest": "action", + "choices": ("extract", "gen-vid", "get-fps", "get-info", "mux-audio", "rescale", + "rotate", "slice"), + "default": "extract", + "help": _("R|Choose which action you want ffmpeg ffmpeg to do." + "\nL|'extract': turns videos into images " + "\nL|'gen-vid': turns images into videos " + "\nL|'get-fps' returns the chosen video's fps." + "\nL|'get-info' returns information about a video." + "\nL|'mux-audio' add audio from one video to another." + "\nL|'rescale' resize video." + "\nL|'rotate' rotate video." + "\nL|'slice' cuts a portion of the video into a separate video file.")}) + argument_list.append({ + "opts": ('-i', '--input'), + "action": ContextFullPaths, + "dest": "input", + "default": "input", + "help": _("Input file."), + "group": _("data"), + "required": True, + "action_option": "-a", + "filetypes": "video"}) + argument_list.append({ + "opts": ('-o', '--output'), + "action": ContextFullPaths, + "group": _("data"), + "default": "", + "dest": "output", + "help": _("Output file. If no output is specified then: if the output is meant to be " + "a video then a video called 'out.mkv' will be created in the input " + "directory; if the output is meant to be a directory then a directory " + "called 'out' will be created inside the input directory. Note: the chosen " + "output file extension will determine the file encoding."), + "action_option": "-a", + "filetypes": "video"}) + argument_list.append({ + "opts": ('-r', '--reference-video'), + "action": FileFullPaths, + "dest": "ref_vid", + "group": _("data"), + "default": None, + "help": _("Path to reference video if 'input' was not a video."), + "filetypes": "video"}) + argument_list.append({ + "opts": ('-R', '--fps'), + "type": str, + "dest": "fps", + "group": _("output"), + "default": "-1.0", + "help": _("Provide video fps. Can be an integer, float or fraction. Negative values " + "will will make the program try to get the fps from the input or reference " + "videos.")}) + argument_list.append({ + "opts": ("-E", "--extract-filetype"), + "action": Radio, + "choices": IMAGE_EXTENSIONS, + "dest": "extract_ext", + "group": _("output"), + "default": ".png", + "help": _("Image format that extracted images should be saved as. '.bmp' will offer " + "the fastest extraction speed, but will take the most storage space. '.png' " + "will be slower but will take less storage.")}) + argument_list.append({ + "opts": ('-s', '--start'), + "type": str, + "dest": "start", + "group": _("clip"), + "default": "00:00:00", + "help": _("Enter the start time from which an action is to be applied. Default: " + "00:00:00, in HH:MM:SS format. You can also enter the time with or without " + "the colons, e.g. 00:0000 or 026010.")}) + argument_list.append({ + "opts": ('-e', '--end'), + "type": str, + "dest": "end", + "group": _("clip"), + "default": "00:00:00", + "help": _("Enter the end time to which an action is to be applied. If both an end " + "time and duration are set, then the end time will be used and the duration " + "will be ignored. Default: 00:00:00, in HH:MM:SS.")}) + argument_list.append({ + "opts": ('-d', '--duration'), + "type": str, + "dest": "duration", + "group": _("clip"), + "default": "00:00:00", + "help": _("Enter the duration of the chosen action, for example if you enter 00:00:10 " + "for slice, then the first 10 seconds after and including the start time " + "will be cut out into a new video. Default: 00:00:00, in HH:MM:SS format. " + "You can also enter the time with or without the colons, e.g. 00:0000 or " + "026010.")}) + argument_list.append({ + "opts": ('-m', '--mux-audio'), + "action": "store_true", + "dest": "mux_audio", + "group": _("output"), + "default": False, + "help": _("Mux the audio from the reference video into the input video. This option " + "is only used for the 'gen-vid' action. 'mux-audio' action has this turned " + "on implicitly.")}) + argument_list.append({ + "opts": ('-T', '--transpose'), + "choices": ("(0, 90CounterClockwise&VerticalFlip)", + "(1, 90Clockwise)", + "(2, 90CounterClockwise)", + "(3, 90Clockwise&VerticalFlip)"), + "type": lambda v: __parse_transpose(v), # pylint:disable=unnecessary-lambda + "dest": "transpose", + "group": _("rotate"), + "default": None, + "help": _("Transpose the video. If transpose is set, then degrees will be ignored. " + "For cli you can enter either the number or the long command name, e.g. to " + "use (1, 90Clockwise) -tr 1 or -tr 90Clockwise")}) + argument_list.append({ + "opts": ('-D', '--degrees'), + "type": str, + "dest": "degrees", + "default": None, + "group": _("rotate"), + "help": _("Rotate the video clockwise by the given number of degrees.")}) + argument_list.append({ + "opts": ('-S', '--scale'), + "type": str, + "dest": "scale", + "group": _("output"), + "default": "1920x1080", + "help": _("Set the new resolution scale if the chosen action is 'rescale'.")}) + argument_list.append({ + "opts": ('-q', '--quiet'), + "action": "store_true", + "dest": "quiet", + "group": _("settings"), + "default": False, + "help": _("Reduces output verbosity so that only serious errors are printed. If both " + "quiet and verbose are set, verbose will override quiet.")}) + argument_list.append({ + "opts": ('-v', '--verbose'), + "action": "store_true", + "dest": "verbose", + "group": _("settings"), + "default": False, + "help": _("Increases output verbosity. If both quiet and verbose are set, verbose " + "will override quiet.")}) + # Deprecated multi-character switches + argument_list.append({ + "opts": ('-fps', ), + "type": str, + "dest": "depr_fps_fps_R", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-ef", ), + "type": str, + "choices": IMAGE_EXTENSIONS, + "dest": "depr_extract_ext_et_E", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ('-tr', ), + "choices": ("(0, 90CounterClockwise&VerticalFlip)", + "(1, 90Clockwise)", + "(2, 90CounterClockwise)", + "(3, 90Clockwise&VerticalFlip)"), + "type": lambda v: __parse_transpose(v), # pylint:disable=unnecessary-lambda + "dest": "depr_transpose_tr_T", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ('-de', ), + "type": str, + "dest": "depr_degrees_de_D", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ('-sc', ), + "type": str, + "dest": "depr_scale_sc_S", + "help": argparse.SUPPRESS}) return argument_list diff --git a/tools/effmpeg/effmpeg.py b/tools/effmpeg/effmpeg.py index 4f056cd7eb..187f0a08aa 100644 --- a/tools/effmpeg/effmpeg.py +++ b/tools/effmpeg/effmpeg.py @@ -17,7 +17,7 @@ from ffmpy import FFmpeg, FFRuntimeError # faceswap imports -from lib.utils import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS +from lib.utils import handle_deprecated_cliopts, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS logger = logging.getLogger(__name__) @@ -29,7 +29,7 @@ class DataItem(): """ vid_ext = VIDEO_EXTENSIONS # future option in effmpeg to use audio file for muxing - audio_ext = ['.aiff', '.flac', '.mp3', '.wav'] + audio_ext = [".aiff", ".flac", ".mp3", ".wav"] img_ext = IMAGE_EXTENSIONS def __init__(self, path=None, name=None, item_type=None, ext=None, @@ -68,11 +68,11 @@ def set_type_ext(self, path=None): if self.path is not None: item_ext = os.path.splitext(self.path)[1].lower() if item_ext in DataItem.vid_ext: - item_type = 'vid' + item_type = "vid" elif item_ext in DataItem.audio_ext: - item_type = 'audio' + item_type = "audio" else: - item_type = 'dir' + item_type = "dir" self.type = item_type self.ext = item_ext logger.debug("path: '%s', type: '%s', ext: '%s'", self.path, self.type, self.ext) @@ -140,16 +140,16 @@ class Effmpeg(): # Class variable that stores the common ffmpeg arguments based on verbosity __common_ffmpeg_args_dict = {"normal": "-hide_banner ", "quiet": "-loglevel panic -hide_banner ", - "verbose": ''} + "verbose": ""} # _common_ffmpeg_args is the class variable that will get used by various # actions and it will be set by the process_arguments() method based on # passed verbosity - _common_ffmpeg_args = '' + _common_ffmpeg_args = "" def __init__(self, arguments): logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - self.args = arguments + self.args = handle_deprecated_cliopts(arguments) self.exe = im_ffm.get_ffmpeg_exe() self.input = DataItem() self.output = DataItem() @@ -160,17 +160,8 @@ def __init__(self, arguments): self.print_ = False logger.debug("Initialized %s", self.__class__.__name__) - def process(self): - """ EFFMPEG Process """ - logger.debug("Running Effmpeg") - # Format action to match the method name - self.args.action = self.args.action.replace('-', '_') - logger.debug("action: '%s", self.args.action) - - # Instantiate input DataItem object - self.input = DataItem(path=self.args.input) - - # Instantiate output DataItem object + def _set_output(self) -> None: + """ Set :attr:`output` based on input arguments """ if self.args.action in self._actions_have_dir_output: self.output = DataItem(path=self.__get_default_output()) elif self.args.action in self._actions_have_vid_output: @@ -180,70 +171,101 @@ def process(self): else: self.output = DataItem(path=self.__get_default_output()) - if self.args.ref_vid is None \ - or self.args.ref_vid == '': + def _set_ref_video(self) -> None: + """ Set :attr:`ref_vid` based on input arguments """ + if self.args.ref_vid is None or self.args.ref_vid == "": self.args.ref_vid = None - # Instantiate ref_vid DataItem object self.ref_vid = DataItem(path=self.args.ref_vid) - # Check that correct input and output arguments were provided + def _check_inputs(self) -> None: + """ Validate provided arguments are valid + + Raises + ------ + ValueError + If provided arguments are not valid + """ + if self.args.action in self._actions_have_dir_input and not self.input.is_type("dir"): - raise ValueError("The chosen action requires a directory as its " - "input, but you entered: " - "{}".format(self.input.path)) + raise ValueError("The chosen action requires a directory as its input, but you " + f"entered: {self.input.path}") if self.args.action in self._actions_have_vid_input and not self.input.is_type("vid"): - raise ValueError("The chosen action requires a video as its " - "input, but you entered: " - "{}".format(self.input.path)) + raise ValueError("The chosen action requires a video as its input, but you entered: " + f"{self.input.path}") if self.args.action in self._actions_have_dir_output and not self.output.is_type("dir"): - raise ValueError("The chosen action requires a directory as its " - "output, but you entered: " - "{}".format(self.output.path)) + raise ValueError("The chosen action requires a directory as its output, but you " + f"entered: {self.output.path}") if self.args.action in self._actions_have_vid_output and not self.output.is_type("vid"): - raise ValueError("The chosen action requires a video as its " - "output, but you entered: " - "{}".format(self.output.path)) + raise ValueError("The chosen action requires a video as its output, but you entered: " + f"{self.output.path}") # Check that ref_vid is a video when it needs to be if self.args.action in self._actions_req_ref_video: if self.ref_vid.is_type("none"): - raise ValueError("The file chosen as the reference video is " - "not a video, either leave the field blank " - "or type 'None': " - "{}".format(self.ref_vid.path)) + raise ValueError("The file chosen as the reference video is not a video, either " + f"leave the field blank or type 'None': {self.ref_vid.path}") elif self.args.action in self._actions_can_use_ref_video: if self.ref_vid.is_type("none"): logger.warning("Warning: no reference video was supplied, even though " "one may be used with the chosen action. If this is " "intentional then ignore this warning.") - # Process start and duration arguments + def _set_times(self) -> None: + """Set start, end and duration attributes """ self.start = self.parse_time(self.args.start) self.end = self.parse_time(self.args.end) if not self.__check_equals_time(self.args.end, "00:00:00"): self.duration = self.__get_duration(self.start, self.end) else: self.duration = self.parse_time(str(self.args.duration)) + + def _set_fps(self) -> None: + """ Set :attr:`arguments.fps` based on input arguments""" # If fps was left blank in gui, set it to default -1.0 value - if self.args.fps == '': + if self.args.fps == "": self.args.fps = str(-1.0) # Try to set fps automatically if needed and not supplied by user if self.args.action in self._actions_req_fps \ and self.__convert_fps(self.args.fps) <= 0: - if self.__check_have_fps(['r', 'i']): + if self.__check_have_fps(["r", "i"]): _error_str = "No fps, input or reference video was supplied, " _error_str += "hence it's not possible to " - _error_str += "'{}'.".format(self.args.action) + _error_str += f"'{self.args.action}'." raise ValueError(_error_str) - if self.output.fps is not None and self.__check_have_fps(['r', 'i']): + if self.output.fps is not None and self.__check_have_fps(["r", "i"]): self.args.fps = self.output.fps - elif self.ref_vid.fps is not None and self.__check_have_fps(['i']): + elif self.ref_vid.fps is not None and self.__check_have_fps(["i"]): self.args.fps = self.ref_vid.fps - elif self.input.fps is not None and self.__check_have_fps(['r']): + elif self.input.fps is not None and self.__check_have_fps(["r"]): self.args.fps = self.input.fps + def process(self): + """ EFFMPEG Process """ + logger.debug("Running Effmpeg") + # Format action to match the method name + self.args.action = self.args.action.replace("-", "_") + logger.debug("action: '%s'", self.args.action) + + # Instantiate input DataItem object + self.input = DataItem(path=self.args.input) + + # Instantiate output DataItem object + self._set_output() + + # Instantiate ref_vid DataItem object + self._set_ref_video() + + # Check that correct input and output arguments were provided + self._check_inputs() + + # Process start and duration arguments + self._set_times() + + # Set fps + self._set_fps() + # Processing transpose if self.args.transpose is None or \ self.args.transpose.lower() == "none": @@ -254,7 +276,7 @@ def process(self): # Processing degrees if self.args.degrees is None \ or self.args.degrees.lower() == "none" \ - or self.args.degrees == '': + or self.args.degrees == "": self.args.degrees = None elif self.args.transpose is None: try: @@ -300,7 +322,7 @@ def extract(input_=None, output=None, fps=None, # pylint:disable=unused-argumen input_, output, fps, extract_ext, start, duration) _input_opts = Effmpeg._common_ffmpeg_args[:] if start is not None and duration is not None: - _input_opts += '-ss {} -t {}'.format(start, duration) + _input_opts += f"-ss {start} -t {duration}" _input = {input_.path: _input_opts} _output_opts = '-y -vf fps="' + str(fps) + '" -q:v 1' _output_path = output.path + "/" + input_.name + "_%05d" + extract_ext @@ -318,12 +340,12 @@ def gen_vid(input_=None, output=None, fps=None, # pylint:disable=unused-argumen filename = Effmpeg.__get_extracted_filename(input_.path) _input_opts = Effmpeg._common_ffmpeg_args[:] _input_path = os.path.join(input_.path, filename) - _fps_arg = '-r ' + str(fps) + ' ' + _fps_arg = "-r " + str(fps) + " " _input_opts += _fps_arg + "-f image2 " - _output_opts = '-y ' + _fps_arg + ' -c:v libx264' + _output_opts = "-y " + _fps_arg + " -c:v libx264" if mux_audio: - _ref_vid_opts = '-c copy -map 0:0 -map 1:1' - _output_opts = _ref_vid_opts + ' ' + _output_opts + _ref_vid_opts = "-c copy -map 0:0 -map 1:1" + _output_opts = _ref_vid_opts + " " + _output_opts _inputs = OrderedDict([(_input_path, _input_opts), (ref_vid.path, None)]) else: _inputs = {_input_path: _input_opts} @@ -377,13 +399,12 @@ def rotate(input_=None, output=None, degrees=None, # pylint:disable=unused-argu transpose=None, exe=None, **kwargs): """ Rotate Video """ if transpose is None and degrees is None: - raise ValueError("You have not supplied a valid transpose or " - "degrees value:\ntranspose: {}\ndegrees: " - "{}".format(transpose, degrees)) + raise ValueError("You have not supplied a valid transpose or degrees value:\n" + f"transpose: {transpose}\ndegrees: {degrees}") _input_opts = Effmpeg._common_ffmpeg_args[:] - _output_opts = '-y -c:a copy -vf ' - _bilinear = '' + _output_opts = "-y -c:a copy -vf " + _bilinear = "" if transpose is not None: _output_opts += 'transpose="' + str(transpose) + '"' elif int(degrees) != 0: @@ -402,7 +423,7 @@ def mux_audio(input_=None, output=None, ref_vid=None, # pylint:disable=unused-a """ Mux Audio """ _input_opts = Effmpeg._common_ffmpeg_args[:] _ref_vid_opts = None - _output_opts = '-y -c copy -map 0:0 -map 1:1 -shortest' + _output_opts = "-y -c copy -map 0:0 -map 1:1 -shortest" _inputs = OrderedDict([(input_.path, _input_opts), (ref_vid.path, _ref_vid_opts)]) _outputs = {output.path: _output_opts} Effmpeg.__run_ffmpeg(exe=exe, inputs=_inputs, outputs=_outputs) @@ -433,28 +454,28 @@ def __get_default_output(self): if the user didn't specify it. """ if self.args.output == "": if self.args.action in self._actions_have_dir_output: - retval = os.path.join(self.input.dirname, 'out') + retval = os.path.join(self.input.dirname, "out") elif self.args.action in self._actions_have_vid_output: if self.input.is_type("media"): # Using the same extension as input leads to very poor # output quality, hence the default is mkv for now retval = os.path.join(self.input.dirname, "out.mkv") # + self.input.ext) else: # case if input was a directory - retval = os.path.join(self.input.dirname, 'out.mkv') + retval = os.path.join(self.input.dirname, "out.mkv") else: retval = self.args.output logger.debug(retval) return retval def __check_have_fps(self, items): - items_to_check = list() + items_to_check = [] for i in items: - if i == 'r': - items_to_check.append('ref_vid') - elif i == 'i': - items_to_check.append('input') - elif i == 'o': - items_to_check.append('output') + if i == "r": + items_to_check.append("ref_vid") + elif i == "i": + items_to_check.append("input") + elif i == "o": + items_to_check.append("output") return all(getattr(self, i).fps is None for i in items_to_check) @@ -470,8 +491,7 @@ def __run_ffmpeg(exe=im_ffm.get_ffmpeg_exe(), inputs=None, outputs=None): if ffe.exit_code == 255: pass else: - raise ValueError("An unexpected FFRuntimeError occurred: " - "{}".format(ffe)) + raise ValueError(f"An unexpected FFRuntimeError occurred: {ffe}") from ffe except KeyboardInterrupt: pass # Do nothing if voluntary interruption logger.debug("ffmpeg finished") @@ -479,8 +499,8 @@ def __run_ffmpeg(exe=im_ffm.get_ffmpeg_exe(), inputs=None, outputs=None): @staticmethod def __convert_fps(fps): """ Convert to Frames per Second """ - if '/' in fps: - _fps = fps.split('/') + if "/" in fps: + _fps = fps.split("/") retval = float(_fps[0]) / float(_fps[1]) else: retval = float(fps) @@ -490,15 +510,13 @@ def __convert_fps(fps): @staticmethod def __get_duration(start_time, end_time): """ Get the duration """ - start = [int(i) for i in start_time.split(':')] - end = [int(i) for i in end_time.split(':')] + start = [int(i) for i in start_time.split(":")] + end = [int(i) for i in end_time.split(":")] start = datetime.timedelta(hours=start[0], minutes=start[1], seconds=start[2]) end = datetime.timedelta(hours=end[0], minutes=end[1], seconds=end[2]) delta = end - start secs = delta.total_seconds() - retval = '{:02}:{:02}:{:02}'.format(int(secs // 3600), - int(secs % 3600 // 60), - int(secs % 60)) + retval = f"{int(secs // 3600):02}:{int(secs % 3600 // 60):02}:{int(secs % 60):02}" logger.debug(retval) return retval @@ -506,7 +524,7 @@ def __get_duration(start_time, end_time): def __get_extracted_filename(path): """ Get the extracted filename """ logger.debug("path: '%s'", path) - filename = '' + filename = "" for file in os.listdir(path): if any(i in file for i in DataItem.img_ext): filename = file @@ -515,7 +533,7 @@ def __get_extracted_filename(path): filename, img_ext = os.path.splitext(filename) zero_pad = Effmpeg.__get_zero_pad(filename) name = filename[:-zero_pad] - retval = "{}%{}d{}".format(name, zero_pad, img_ext) + retval = f"{name}%{zero_pad}d{img_ext}" logger.debug("filename: %s, img_ext: '%s', zero_pad: %s, name: '%s'", filename, img_ext, zero_pad, name) logger.debug(retval) @@ -527,25 +545,17 @@ def __get_zero_pad(filename): chkstring = filename[::-1] logger.trace("filename: %s, chkstring: %s", filename, chkstring) pos = 0 - for pos in range(len(chkstring)): - if not chkstring[pos].isdigit(): + for char in chkstring: + if not char.isdigit(): break logger.debug("filename: '%s', pos: %s", filename, pos) return pos - @staticmethod - def __check_is_valid_time(value): - """ Check valid time """ - val = value.replace(':', '') - retval = val.isdigit() - logger.debug("value: '%s', retval: %s", value, retval) - return retval - @staticmethod def __check_equals_time(value, time): """ Check equals time """ - val = value.replace(':', '') - tme = time.replace(':', '') + val = value.replace(":", "") + tme = time.replace(":", "") retval = val.zfill(6) == tme.zfill(6) logger.debug("value: '%s', time: %s, retval: %s", value, time, retval) return retval @@ -553,10 +563,10 @@ def __check_equals_time(value, time): @staticmethod def parse_time(txt): """ Parse Time """ - clean_txt = txt.replace(':', '') + clean_txt = txt.replace(":", "") hours = clean_txt[0:2] minutes = clean_txt[2:4] seconds = clean_txt[4:6] - retval = hours + ':' + minutes + ':' + seconds + retval = hours + ":" + minutes + ":" + seconds logger.debug("txt: '%s', retval: %s", txt, retval) return retval diff --git a/tools/manual/cli.py b/tools/manual/cli.py index 3cb571efc1..a423f02e57 100644 --- a/tools/manual/cli.py +++ b/tools/manual/cli.py @@ -1,15 +1,15 @@ #!/usr/bin/env python3 """ The Command Line Arguments for the Manual Editor tool. """ +import argparse import gettext -from lib.cli.args import FaceSwapArgs, DirOrFileFullPaths, FileFullPaths - +from lib.cli.args import FaceSwapArgs +from lib.cli.actions import DirOrFileFullPaths, FileFullPaths # LOCALES _LANG = gettext.translation("tools.manual", localedir="locales", fallback=True) _ = _LANG.gettext - _HELPTEXT = _("This command lets you perform various actions on frames, " "faces and alignments files using visual tools.") @@ -26,39 +26,54 @@ def get_info(): @staticmethod def get_argument_list(): """ Generate the command line argument list for the Manual Tool. """ - argument_list = list() - argument_list.append(dict( - opts=("-al", "--alignments"), - action=FileFullPaths, - filetypes="alignments", - type=str, - group=_("data"), - dest="alignments_path", - help=_("Path to the alignments file for the input, if not at the default location"))) - argument_list.append(dict( - opts=("-fr", "--frames"), - action=DirOrFileFullPaths, - filetypes="video", - required=True, - group=_("data"), - help=_("Video file or directory containing source frames that faces were extracted " - "from."))) - argument_list.append(dict( - opts=("-t", "--thumb-regen"), - action="store_true", - dest="thumb_regen", - default=False, - group=_("options"), - help=_("Force regeneration of the low resolution jpg thumbnails in the alignments " - "file."))) - argument_list.append(dict( - opts=("-s", "--single-process"), - action="store_true", - dest="single_process", - default=False, - group=_("options"), - help=_("The process attempts to speed up generation of thumbnails by extracting from " - "the video in parallel threads. For some videos, this causes the caching " - "process to hang. If this happens, then set this option to generate the " - "thumbnails in a slower, but more stable single thread."))) + argument_list = [] + argument_list.append({ + "opts": ("-a", "--alignments"), + "action": FileFullPaths, + "filetypes": "alignments", + "type": str, + "group": _("data"), + "dest": "alignments_path", + "help": _( + "Path to the alignments file for the input, if not at the default location")}) + argument_list.append({ + "opts": ("-f", "--frames"), + "action": DirOrFileFullPaths, + "filetypes": "video", + "required": True, + "group": _("data"), + "help": _( + "Video file or directory containing source frames that faces were extracted " + "from.")}) + argument_list.append({ + "opts": ("-t", "--thumb-regen"), + "action": "store_true", + "dest": "thumb_regen", + "default": False, + "group": _("options"), + "help": _( + "Force regeneration of the low resolution jpg thumbnails in the alignments " + "file.")}) + argument_list.append({ + "opts": ("-s", "--single-process"), + "action": "store_true", + "dest": "single_process", + "default": False, + "group": _("options"), + "help": _( + "The process attempts to speed up generation of thumbnails by extracting from the " + "video in parallel threads. For some videos, this causes the caching process to " + "hang. If this happens, then set this option to generate the thumbnails in a " + "slower, but more stable single thread.")}) + # Deprecated multi-character switches + argument_list.append({ + "opts": ("-al", ), + "type": str, + "dest": "depr_alignments_path_al_a", + "help": argparse.SUPPRESS}) + argument_list.append({ + "opts": ("-fr", ), + "type": str, + "dest": "depr_frames_fr_f", + "help": argparse.SUPPRESS}) return argument_list diff --git a/tools/manual/manual.py b/tools/manual/manual.py index e33826ef1d..05d32a2a42 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -18,7 +18,7 @@ from lib.gui.utils import get_images, get_config, initialize_config, initialize_images from lib.image import SingleFrameLoader, read_image_meta from lib.multithreading import MultiThread -from lib.utils import VIDEO_EXTENSIONS +from lib.utils import handle_deprecated_cliopts, VIDEO_EXTENSIONS from plugins.extract.pipeline import Extractor, ExtractMedia from .detected_faces import DetectedFaces @@ -52,6 +52,7 @@ class Manual(tk.Tk): def __init__(self, arguments): logger.debug("Initializing %s: (arguments: '%s')", self.__class__.__name__, arguments) super().__init__() + arguments = handle_deprecated_cliopts(arguments) self._validate_non_faces(arguments.frames) self._initialize_tkinter() diff --git a/tools/mask/cli.py b/tools/mask/cli.py index 6b9392a39e..a19a7b5cbd 100644 --- a/tools/mask/cli.py +++ b/tools/mask/cli.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ +import argparse import gettext from lib.cli.args import FaceSwapArgs @@ -11,7 +12,6 @@ _LANG = gettext.translation("tools.mask.cli", localedir="locales", fallback=True) _ = _LANG.gettext - _HELPTEXT = _("This tool allows you to generate, import, export or preview masks for existing " "alignments.") @@ -50,7 +50,7 @@ def get_argument_list(): "help": _( "Directory containing extracted faces, source frames, or a video file.")}) argument_list.append({ - "opts": ("-it", "--input-type"), + "opts": ("-I", "--input-type"), "action": Radio, "type": str.lower, "choices": ("faces", "frames"), @@ -234,5 +234,10 @@ def get_argument_list(): "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.")}) - + # Deprecated multi-character switches + argument_list.append({ + "opts": ("-it", ), + "type": str, + "dest": "depr_input_type_it_I", + "help": argparse.SUPPRESS}) return argument_list diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 7c9c0a2227..9249a9d82b 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -10,7 +10,7 @@ from lib.align import Alignments -from lib.utils import VIDEO_EXTENSIONS +from lib.utils import handle_deprecated_cliopts, VIDEO_EXTENSIONS from plugins.extract.pipeline import ExtractMedia from .loader import Loader @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) -class Mask: # pylint:disable=too-few-public-methods +class Mask: """ This tool is part of the Faceswap Tools suite and should be called from ``python tools.py mask`` command. @@ -128,7 +128,7 @@ def process(self) -> None: self._run_mask_process(arguments) -class _Mask: # pylint:disable=too-few-public-methods +class _Mask: """ This tool is part of the Faceswap Tools suite and should be called from ``python tools.py mask`` command. @@ -142,7 +142,7 @@ class _Mask: # pylint:disable=too-few-public-methods """ def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - + arguments = handle_deprecated_cliopts(arguments) self._update_type = arguments.processing self._input_is_faces = arguments.input_type == "faces" self._check_input(arguments.input) diff --git a/tools/model/cli.py b/tools/model/cli.py index 21117df531..68d1e8e455 100644 --- a/tools/model/cli.py +++ b/tools/model/cli.py @@ -25,45 +25,49 @@ def get_info() -> str: def get_argument_list() -> list[dict[str, T.Any]]: """ Put the arguments in a list so that they are accessible from both argparse and gui """ argument_list = [] - argument_list.append(dict( - opts=("-m", "--model-dir"), - action=DirFullPaths, - dest="model_dir", - required=True, - help=_("Model directory. A directory containing the model you wish to perform an " - "action on."))) - argument_list.append(dict( - opts=("-j", "--job"), - action=Radio, - type=str, - choices=("inference", "nan-scan", "restore"), - required=True, - help=_("R|Choose which action you want to perform." - "\nL|'inference' - Create an inference only copy of the model. Strips any " - "layers from the model which are only required for training. NB: This is for " - "exporting the model for use in external applications. Inference generated " - "models cannot be used within Faceswap. See the 'format' option for specifying " - "the model output format." - "\nL|'nan-scan' - Scan the model file for NaNs or Infs (invalid data)." - "\nL|'restore' - Restore a model from backup."))) - argument_list.append(dict( - opts=("-f", "--format"), - action=Radio, - type=str, - choices=("h5", "saved-model"), - default="h5", - group=_("inference"), - help=_("R|The format to save the model as. Note: Only used for 'inference' job." - "\nL|'h5' - Standard Keras H5 format. Does not store any custom layer " - "information. Layers will need to be loaded from Faceswap to use." - "\nL|'saved-model' - Tensorflow's Saved Model format. Contains all information " - "required to load the model outside of Faceswap."))) - argument_list.append(dict( - opts=("-s", "--swap-model"), - action="store_true", - dest="swap_model", - default=False, - group=_("inference"), - help=_("Only used for 'inference' job. Generate the inference model for B -> A " - "instead of A -> B."))) + argument_list.append({ + "opts": ("-m", "--model-dir"), + "action": DirFullPaths, + "dest": "model_dir", + "required": True, + "help": _( + "Model directory. A directory containing the model you wish to perform an action " + "on.")}) + argument_list.append({ + "opts": ("-j", "--job"), + "action": Radio, + "type": str, + "choices": ("inference", "nan-scan", "restore"), + "required": True, + "help": _( + "R|Choose which action you want to perform." + "\nL|'inference' - Create an inference only copy of the model. Strips any layers " + "from the model which are only required for training. NB: This is for exporting " + "the model for use in external applications. Inference generated models cannot be " + "used within Faceswap. See the 'format' option for specifying the model output " + "format." + "\nL|'nan-scan' - Scan the model file for NaNs or Infs (invalid data)." + "\nL|'restore' - Restore a model from backup.")}) + argument_list.append({ + "opts": ("-f", "--format"), + "action": Radio, + "type": str, + "choices": ("h5", "saved-model"), + "default": "h5", + "group": _("inference"), + "help": _( + "R|The format to save the model as. Note: Only used for 'inference' job." + "\nL|'h5' - Standard Keras H5 format. Does not store any custom layer " + "information. Layers will need to be loaded from Faceswap to use." + "\nL|'saved-model' - Tensorflow's Saved Model format. Contains all information " + "required to load the model outside of Faceswap.")}) + argument_list.append({ + "opts": ("-s", "--swap-model"), + "action": "store_true", + "dest": "swap_model", + "default": False, + "group": _("inference"), + "help": _( + "Only used for 'inference' job. Generate the inference model for B -> A instead " + "of A -> B.")}) return argument_list diff --git a/tools/preview/cli.py b/tools/preview/cli.py index da327028bd..6b435862dd 100644 --- a/tools/preview/cli.py +++ b/tools/preview/cli.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ +import argparse import gettext import typing as T @@ -38,35 +39,43 @@ def get_argument_list() -> list[dict[str, T.Any]]: Top command line options for the preview tool """ argument_list = [] - argument_list.append(dict( - opts=("-i", "--input-dir"), - action=DirOrFileFullPaths, - filetypes="video", - dest="input_dir", - group=_("data"), - required=True, - help=_("Input directory or video. Either a directory containing the image files you " - "wish to process or path to a video file."))) - argument_list.append(dict( - opts=("-al", "--alignments"), - action=FileFullPaths, - filetypes="alignments", - type=str, - group=_("data"), - dest="alignments_path", - help=_("Path to the alignments file for the input, if not at the default location"))) - argument_list.append(dict( - opts=("-m", "--model-dir"), - action=DirFullPaths, - dest="model_dir", - group=_("data"), - required=True, - help=_("Model directory. A directory containing the trained model you wish to " - "process."))) - argument_list.append(dict( - opts=("-s", "--swap-model"), - action="store_true", - dest="swap_model", - default=False, - help=_("Swap the model. Instead of A -> B, swap B -> A"))) + argument_list.append({ + "opts": ("-i", "--input-dir"), + "action": DirOrFileFullPaths, + "filetypes": "video", + "dest": "input_dir", + "group": _("data"), + "required": True, + "help": _( + "Input directory or video. Either a directory containing the image files you wish " + "to process or path to a video file.")}) + argument_list.append({ + "opts": ("-a", "--alignments"), + "action": FileFullPaths, + "filetypes": "alignments", + "type": str, + "group": _("data"), + "dest": "alignments_path", + "help": _( + "Path to the alignments file for the input, if not at the default location")}) + argument_list.append({ + "opts": ("-m", "--model-dir"), + "action": DirFullPaths, + "dest": "model_dir", + "group": _("data"), + "required": True, + "help": _( + "Model directory. A directory containing the trained model you wish to process.")}) + argument_list.append({ + "opts": ("-s", "--swap-model"), + "action": "store_true", + "dest": "swap_model", + "default": False, + "help": _("Swap the model. Instead of A -> B, swap B -> A")}) + # Deprecated multi-character switches + argument_list.append({ + "opts": ("-al", ), + "type": str, + "dest": "depr_alignments_path_al_a", + "help": argparse.SUPPRESS}) return argument_list diff --git a/tools/preview/preview.py b/tools/preview/preview.py index caef0e7353..e023073ab8 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -16,10 +16,10 @@ import numpy as np from lib.align import DetectedFace -from lib.cli.args import ConvertArgs +from lib.cli.args_extract_convert import ConvertArgs from lib.gui.utils import get_images, get_config, initialize_config, initialize_images from lib.convert import Converter -from lib.utils import FaceswapError +from lib.utils import FaceswapError, handle_deprecated_cliopts from lib.queue_manager import queue_manager from scripts.fsmedia import Alignments, Images from scripts.convert import Predict, ConvertItem @@ -41,7 +41,7 @@ _ = _LANG.gettext -class Preview(tk.Tk): # pylint:disable=too-few-public-methods +class Preview(tk.Tk): """ This tool is part of the Faceswap Tools suite and should be called from ``python tools.py preview`` command. @@ -59,6 +59,7 @@ class Preview(tk.Tk): # pylint:disable=too-few-public-methods def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) super().__init__() + arguments = handle_deprecated_cliopts(arguments) self._config_tools = ConfigTools() self._lock = Lock() self._dispatcher = Dispatcher(self) @@ -455,7 +456,7 @@ def _predict(self) -> None: logger.debug("Predicted faces") -class Patch(): # pylint:disable=too-few-public-methods +class Patch(): """ The Patch pipeline Runs in it's own thread. Takes the output from the Faceswap model predictor and runs the faces diff --git a/tools/sort/cli.py b/tools/sort/cli.py index c293a1034c..f678687461 100644 --- a/tools/sort/cli.py +++ b/tools/sort/cli.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ +import argparse import gettext from lib.cli.args import FaceSwapArgs @@ -15,7 +16,7 @@ _SORT_METHODS = ( "none", "blur", "blur-fft", "distance", "face", "face-cnn", "face-cnn-dissim", "yaw", "pitch", "roll", "hist", "hist-dissim", "color-black", "color-gray", "color-luma", - "color-green", "color-orange", "size", "face-yaw", "black-pixels") + "color-green", "color-orange", "size") _GPTHRESHOLD = _(" Adjust the '-t' ('--threshold') parameter to control the strength of grouping.") _GPCOLOR = _(" Adjust the '-b' ('--bins') parameter to control the number of bins for grouping. " @@ -55,9 +56,7 @@ "images will be ranked first and blue images will be last."), "size": _("images by their size in the original frame. Faces further from the camera and from " "lower resolution sources will be sorted first, whilst faces closer to the camera " - "and from higher resolution sources will be sorted last."), - "face-yaw": _(" option is deprecated. Use 'yaw'"), - "black-pixels": _(" option is deprecated. Use 'color-black'")} + "and from higher resolution sources will be sorted last.")} _BIN_TYPES = [ (("face", "face-cnn", "face-cnn-dissim", "hist", "hist-dissim"), _GPTHRESHOLD), @@ -85,148 +84,147 @@ def get_info(): def get_argument_list(): """ Put the arguments in a list so that they are accessible from both argparse and gui """ argument_list = [] - argument_list.append(dict( - opts=('-i', '--input'), - action=DirFullPaths, - dest="input_dir", - group=_("data"), - help=_("Input directory of aligned faces."), - required=True)) - argument_list.append(dict( - opts=('-o', '--output'), - action=DirFullPaths, - dest="output_dir", - group=_("data"), - help=_("Output directory for sorted aligned faces. If not provided and 'keep' is " - "selected then a new folder called 'sorted' will be created within the input " - "folder to house the output. If not provided and 'keep' is not selected then " - "the images will be sorted in-place, overwriting the original contents of the " - "'input_dir'"))) - argument_list.append(dict( - opts=("-B", "--batch-mode"), - action="store_true", - dest="batch_mode", - default=False, - group=_("data"), - help=_("R|If selected then the input_dir should be a parent folder containing " - "multiple folders of faces you wish to sort. The faces " - "will be output to separate sub-folders in the output_dir"))) - argument_list.append(dict( - opts=('-s', '--sort-by'), - action=Radio, - type=str, - choices=_SORT_METHODS, - dest='sort_method', - group=_("sort settings"), - default="face", - help=_("R|Choose how images are sorted. Selecting a sort method gives the images a " - "new filename based on the order the image appears within the given method." - "\nL|'none': Don't sort the images. When a 'group-by' method is selected, " - "selecting 'none' means that the files will be moved/copied into their " - "respective bins, but the files will keep their original filenames. Selecting " - "'none' for both 'sort-by' and 'group-by' will do nothing" + _SORT_HELP + - "\nDefault: face"))) - argument_list.append(dict( - opts=('-g', '--group-by'), - action=Radio, - type=str, - choices=_SORT_METHODS, - dest='group_method', - group=_("group settings"), - default="none", - help=_("R|Selecting a group by method will move/copy files into numbered bins based " - "on the selected method." - "\nL|'none': Don't bin the images. Folders will be sorted by the selected " - "'sort-by' but will not be binned, instead they will be sorted into a single " - "folder. Selecting 'none' for both 'sort-by' and 'group-by' will do nothing" + - _GROUP_HELP + "\nDefault: none"))) - argument_list.append(dict( - opts=('-k', '--keep'), - action='store_true', - dest='keep_original', - default=False, - group=_("data"), - help=_("Whether to keep the original files in their original location. Choosing a " - "'sort-by' method means that the files have to be renamed. Selecting 'keep' " - "means that the original files will be kept, and the renamed files will be " - "created in the specified output folder. Unselecting keep means that the " - "original files will be moved and renamed based on the selected sort/group " - "criteria."))) - argument_list.append(dict( - opts=('-t', '--threshold'), - action=Slider, - min_max=(-1.0, 10.0), - rounding=2, - type=float, - dest='threshold', - group=_("group settings"), - default=-1.0, - help=_("R|Float value. Minimum threshold to use for grouping comparison with " - "'face-cnn' 'hist' and 'face' methods." - "\nThe lower the value the more discriminating the grouping is. Leaving " - "-1.0 will allow Faceswap to choose the default value." - "\nL|For 'face-cnn' 7.2 should be enough, with 4 being very discriminating. " - "\nL|For 'hist' 0.3 should be enough, with 0.2 being very discriminating. " - "\nL|For 'face' between 0.1 (more bins) to 0.5 (fewer bins) should " - "be about right." - "\nBe careful setting a value that's too extrene in a directory " - "with many images, as this could result in a lot of folders being created. " - "Defaults: face-cnn 7.2, hist 0.3, face 0.25"))) - argument_list.append(dict( - opts=('-fp', '--final-process'), - action=Radio, - type=str, - choices=("folders", "rename"), - dest='final_process', - group=_("output"), - help=_("Deprecated and no longer used. The final processing will be dictated by the " - "sort/group by methods and whether 'keep_original' is selected."))) - argument_list.append(dict( - opts=('-b', '--bins'), - action=Slider, - min_max=(1, 100), - rounding=1, - type=int, - dest='num_bins', - group=_("group settings"), - default=5, - help=_("R|Integer value. Used to control the number of bins created for grouping by: " - "any 'blur' methods, 'color' methods or 'face metric' methods ('distance', " - "'size') and 'orientation; methods ('yaw', 'pitch'). For any other grouping " - "methods see the '-t' ('--threshold') option." - "\nL|For 'face metric' methods the bins are filled, according the the " - "distribution of faces between the minimum and maximum chosen metric." - "\nL|For 'color' methods the number of bins represents the divider of the " - "percentage of colored pixels. Eg. For a bin number of '5': The first folder " - "will have the faces with 0%% to 20%% colored pixels, second 21%% to 40%%, " - "etc. Any empty bins will be deleted, so you may end up with fewer bins than " - "selected." - "\nL|For 'blur' methods folder 0 will be the least blurry, while " - "the last folder will be the blurriest." - "\nL|For 'orientation' methods the number of bins is dictated by how much 180 " - "degrees is divided. Eg. If 18 is selected, then each folder will be a 10 " - "degree increment. Folder 0 will contain faces looking the most to the " - "left/down whereas the last folder will contain the faces looking the most to " - "the right/up. NB: Some bins may be empty if faces do not fit the criteria." - "\nDefault value: 5"))) - argument_list.append(dict( - opts=('-l', '--log-changes'), - action='store_true', - group=_("settings"), - default=False, - help=_("Logs file renaming changes if grouping by renaming, or it logs the file " - "copying/movement if grouping by folders. If no log file is specified with " - "'--log-file', then a 'sort_log.json' file will be created in the input " - "directory."))) - argument_list.append(dict( - opts=('-lf', '--log-file'), - action=SaveFileFullPaths, - filetypes="alignments", - group=_("settings"), - dest='log_file_path', - default='sort_log.json', - help=_("Specify a log file to use for saving the renaming or grouping information. If " - "specified extension isn't 'json' or 'yaml', then json will be used as the " - "serializer, with the supplied filename. Default: sort_log.json"))) - + argument_list.append({ + "opts": ('-i', '--input'), + "action": DirFullPaths, + "dest": "input_dir", + "group": _("data"), + "help": _("Input directory of aligned faces."), + "required": True}) + argument_list.append({ + "opts": ('-o', '--output'), + "action": DirFullPaths, + "dest": "output_dir", + "group": _("data"), + "help": _( + "Output directory for sorted aligned faces. If not provided and 'keep' is " + "selected then a new folder called 'sorted' will be created within the input " + "folder to house the output. If not provided and 'keep' is not selected then the " + "images will be sorted in-place, overwriting the original contents of the " + "'input_dir'")}) + argument_list.append({ + "opts": ("-B", "--batch-mode"), + "action": "store_true", + "dest": "batch_mode", + "default": False, + "group": _("data"), + "help": _( + "R|If selected then the input_dir should be a parent folder containing multiple " + "folders of faces you wish to sort. The faces will be output to separate sub-" + "folders in the output_dir")}) + argument_list.append({ + "opts": ('-s', '--sort-by'), + "action": Radio, + "type": str, + "choices": _SORT_METHODS, + "dest": 'sort_method', + "group": _("sort settings"), + "default": "face", + "help": _( + "R|Choose how images are sorted. Selecting a sort method gives the images a new " + "filename based on the order the image appears within the given method." + "\nL|'none': Don't sort the images. When a 'group-by' method is selected, " + "selecting 'none' means that the files will be moved/copied into their respective " + "bins, but the files will keep their original filenames. Selecting 'none' for " + "both 'sort-by' and 'group-by' will do nothing" + _SORT_HELP + "\nDefault: face")}) + argument_list.append({ + "opts": ('-g', '--group-by'), + "action": Radio, + "type": str, + "choices": _SORT_METHODS, + "dest": 'group_method', + "group": _("group settings"), + "default": "none", + "help": _( + "R|Selecting a group by method will move/copy files into numbered bins based on " + "the selected method." + "\nL|'none': Don't bin the images. Folders will be sorted by the selected 'sort-" + "by' but will not be binned, instead they will be sorted into a single folder. " + "Selecting 'none' for both 'sort-by' and 'group-by' will do nothing" + + _GROUP_HELP + "\nDefault: none")}) + argument_list.append({ + "opts": ('-k', '--keep'), + "action": 'store_true', + "dest": 'keep_original', + "default": False, + "group": _("data"), + "help": _( + "Whether to keep the original files in their original location. Choosing a 'sort-" + "by' method means that the files have to be renamed. Selecting 'keep' means that " + "the original files will be kept, and the renamed files will be created in the " + "specified output folder. Unselecting keep means that the original files will be " + "moved and renamed based on the selected sort/group criteria.")}) + argument_list.append({ + "opts": ('-t', '--threshold'), + "action": Slider, + "min_max": (-1.0, 10.0), + "rounding": 2, + "type": float, + "dest": 'threshold', + "group": _("group settings"), + "default": -1.0, + "help": _( + "R|Float value. Minimum threshold to use for grouping comparison with 'face-cnn' " + "'hist' and 'face' methods." + "\nThe lower the value the more discriminating the grouping is. Leaving -1.0 will " + "allow Faceswap to choose the default value." + "\nL|For 'face-cnn' 7.2 should be enough, with 4 being very discriminating. " + "\nL|For 'hist' 0.3 should be enough, with 0.2 being very discriminating. " + "\nL|For 'face' between 0.1 (more bins) to 0.5 (fewer bins) should be about right." + "\nBe careful setting a value that's too extrene in a directory with many images, " + "as this could result in a lot of folders being created. Defaults: face-cnn 7.2, " + "hist 0.3, face 0.25")}) + argument_list.append({ + "opts": ('-b', '--bins'), + "action": Slider, + "min_max": (1, 100), + "rounding": 1, + "type": int, + "dest": 'num_bins', + "group": _("group settings"), + "default": 5, + "help": _( + "R|Integer value. Used to control the number of bins created for grouping by: any " + "'blur' methods, 'color' methods or 'face metric' methods ('distance', 'size') " + "and 'orientation; methods ('yaw', 'pitch'). For any other grouping " + "methods see the '-t' ('--threshold') option." + "\nL|For 'face metric' methods the bins are filled, according the the " + "distribution of faces between the minimum and maximum chosen metric." + "\nL|For 'color' methods the number of bins represents the divider of the " + "percentage of colored pixels. Eg. For a bin number of '5': The first folder will " + "have the faces with 0%% to 20%% colored pixels, second 21%% to 40%%, etc. Any " + "empty bins will be deleted, so you may end up with fewer bins than selected." + "\nL|For 'blur' methods folder 0 will be the least blurry, while the last folder " + "will be the blurriest." + "\nL|For 'orientation' methods the number of bins is dictated by how much 180 " + "degrees is divided. Eg. If 18 is selected, then each folder will be a 10 degree " + "increment. Folder 0 will contain faces looking the most to the left/down whereas " + "the last folder will contain the faces looking the most to the right/up. NB: " + "Some bins may be empty if faces do not fit the criteria. \nDefault value: 5")}) + argument_list.append({ + "opts": ('-l', '--log-changes'), + "action": 'store_true', + "group": _("settings"), + "default": False, + "help": _( + "Logs file renaming changes if grouping by renaming, or it logs the file copying/" + "movement if grouping by folders. If no log file is specified with '--log-file', " + "then a 'sort_log.json' file will be created in the input directory.")}) + argument_list.append({ + "opts": ('-f', '--log-file'), + "action": SaveFileFullPaths, + "filetypes": "alignments", + "group": _("settings"), + "dest": 'log_file_path', + "default": 'sort_log.json', + "help": _( + "Specify a log file to use for saving the renaming or grouping information. If " + "specified extension isn't 'json' or 'yaml', then json will be used as the " + "serializer, with the supplied filename. Default: sort_log.json")}) + # Deprecated multi-character switches + argument_list.append({ + "opts": ("-lf", ), + "type": str, + "dest": "depr_log_file_path_lf_f", + "help": argparse.SUPPRESS}) return argument_list diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 17d141766d..c963f9af3b 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -15,7 +15,7 @@ # faceswap imports from lib.serializer import Serializer, get_serializer_from_filename -from lib.utils import deprecation_warning +from lib.utils import handle_deprecated_cliopts from .sort_methods import SortBlur, SortColor, SortFace, SortHistogram, SortMultiMethod from .sort_methods_aligned import SortDistance, SortFaceCNN, SortPitch, SortSize, SortYaw, SortRoll @@ -26,7 +26,7 @@ logger = logging.getLogger(__name__) -class Sort(): # pylint:disable=too-few-public-methods +class Sort(): """ Sorts folders of faces based on input criteria Wrapper for the sort process to run in either batch mode or single use mode @@ -39,32 +39,10 @@ class Sort(): # pylint:disable=too-few-public-methods """ def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing: %s (args: %s)", self.__class__.__name__, arguments) - self._args = arguments - self._handle_deprecations() + self._args = handle_deprecated_cliopts(arguments) self._input_locations = self._get_input_locations() logger.debug("Initialized: %s", self.__class__.__name__) - def _handle_deprecations(self): - """ Warn that 'final_process' is deprecated and remove from arguments """ - if self._args.final_process: - deprecation_warning("`-fp`, `--final-process`", "This option will be ignored") - logger.warning("Final processing is dictated by your choice of 'sort-by' and " - "'group-by' options and whether 'keep' has been selected.") - del self._args.final_process - if "face-yaw" in (self._args.sort_method, self._args.group_method): - deprecation_warning("`face-yaw` sort option", "Please use option 'yaw' going forward.") - sort_ = self._args.sort_method - group_ = self._args.group_method - self._args.sort_method = "yaw" if sort_ == "face-yaw" else sort_ - self._args.group_method = "yaw" if group_ == "face-yaw" else group_ - if "black-pixels" in (self._args.sort_method, self._args.group_method): - deprecation_warning("`black-pixels` sort option", - "Please use option 'color-black' going forward.") - sort_ = self._args.sort_method - group_ = self._args.group_method - self._args.sort_method = "color-black" if sort_ == "black-pixels" else sort_ - self._args.group_method = "color-black" if group_ == "black-pixels" else group_ - def _get_input_locations(self) -> list[str]: """ Obtain the full path to input locations. Will be a list of locations if batch mode is selected, or a containing a single location if batch mode is not selected. @@ -123,7 +101,7 @@ def process(self) -> None: sort.process() -class _Sort(): # pylint:disable=too-few-public-methods +class _Sort(): """ Sorts folders of faces based on input criteria """ def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: arguments: %s", self.__class__.__name__, arguments) From b1caa03e3fa57951a9cea1d558c212659f5776fc Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 6 Apr 2024 15:48:54 +0100 Subject: [PATCH 893/981] Fix tests for new cli switches --- tests/simple_tests.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/simple_tests.py b/tests/simple_tests.py index 956117422a..e406def93c 100644 --- a/tests/simple_tests.py +++ b/tests/simple_tests.py @@ -94,7 +94,7 @@ def train_args(model, model_path, faces, iterations=1, batchsize=2, extra_args=" """ Train command """ py_exe = sys.executable args = (f"{py_exe} faceswap.py train -A {faces} -B {faces} -m {model_path} -t {model} " - f"-bs {batchsize} -it {iterations} {extra_args}") + f"-b {batchsize} -it {iterations} {extra_args}") return args.split() @@ -191,7 +191,7 @@ def main(): ( py_exe, "tools.py", "alignments", "-j", "rename", "-a", pathjoin(vid_base, "test_alignments.fsa"), - "-fc", pathjoin(vid_base, "faces_sorted"), + "-c", pathjoin(vid_base, "faces_sorted"), ) ) set_train_config(True) @@ -202,7 +202,7 @@ def main(): pathjoin(vid_base, "faces"), iterations=1, batchsize=1, - extra_args="-wl")) + extra_args="-M")) set_train_config(False) was_trained = run_test( From 118e6157245f193da5032d6af3964b4a6685a1da Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 6 Apr 2024 16:53:12 +0100 Subject: [PATCH 894/981] bugfix: tests - change -it switch to -i --- tests/simple_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/simple_tests.py b/tests/simple_tests.py index e406def93c..0e6ea127d5 100644 --- a/tests/simple_tests.py +++ b/tests/simple_tests.py @@ -94,7 +94,7 @@ def train_args(model, model_path, faces, iterations=1, batchsize=2, extra_args=" """ Train command """ py_exe = sys.executable args = (f"{py_exe} faceswap.py train -A {faces} -B {faces} -m {model_path} -t {model} " - f"-b {batchsize} -it {iterations} {extra_args}") + f"-b {batchsize} -i {iterations} {extra_args}") return args.split() From 1c081aea7da9134c0b78e308a07959f01ddb1d86 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 15 Apr 2024 12:19:15 +0100 Subject: [PATCH 895/981] Add ability to export and import alignment data (#1383) * tools.alignments - add export job * plugins.extract: Update __repr__ for ExtractorBatch dataclass * plugins.extract: Initial implementation of external import plugins * plugins.extract: Disable lm masks on ROI alignment data import * lib.align: Add `landmark_type` property to AlignedFace and return dummy data for ROI Landmarks pose estimate * plugins.extract: Add centering config item for align import and fix filename mapping for images * plugins.extract: Log warning on downstream plugins on limited alignment data * tools: Fix plugins for 4 point ROI landmarks (alignments, sort, mask) * tools.manual: Fix for 2D-4 ROI landmarks * training: Fix for 4 point ROI landmarks * lib.convert: Average color plugin. Avoid divide by zero errors * extract - external: - Default detector to 'external' when importing alignments - Handle different frame origin co-ordinates * alignments: Store video extension in alignments file * plugins.extract.external: Handle video file keys * plugins.extract.external: Output warning if missing data * locales + docs * plugins.extract.align.external: Roll the corner points to top-left for different origins * Clean up * linting fix --- docs/full/lib/align.rst | 30 +- docs/full/plugins/extract.rst | 18 +- docs/full/tools/manual.faceviewer.rst | 24 +- lib/align/__init__.py | 9 +- lib/align/aligned_face.py | 329 ++----- lib/align/alignments.py | 6 +- lib/align/constants.py | 111 +++ lib/align/detected_face.py | 28 +- lib/align/pose.py | 187 ++++ lib/cli/args_extract_convert.py | 8 +- lib/image.py | 8 +- lib/training/cache.py | 29 +- .../lib.cli.args_extract_convert.mo | Bin 31533 -> 31746 bytes .../lib.cli.args_extract_convert.po | 151 ++-- .../es/LC_MESSAGES/tools.alignments.cli.mo | Bin 11850 -> 12801 bytes .../es/LC_MESSAGES/tools.alignments.cli.po | 44 +- .../lib.cli.args_extract_convert.mo | Bin 31900 -> 32085 bytes .../lib.cli.args_extract_convert.po | 150 ++-- .../kr/LC_MESSAGES/tools.alignments.cli.mo | Bin 11572 -> 12494 bytes .../kr/LC_MESSAGES/tools.alignments.cli.po | 41 +- locales/lib.cli.args_extract_convert.pot | 131 ++- .../lib.cli.args_extract_convert.mo | Bin 42512 -> 42768 bytes .../lib.cli.args_extract_convert.po | 153 ++-- .../ru/LC_MESSAGES/tools.alignments.cli.mo | Bin 15152 -> 16449 bytes .../ru/LC_MESSAGES/tools.alignments.cli.po | 43 +- locales/tools.alignments.cli.pot | 34 +- plugins/convert/color/avg_color.py | 28 +- plugins/extract/__init__.py | 4 + plugins/extract/_base.py | 27 +- plugins/extract/align/_base/aligner.py | 52 +- plugins/extract/align/external.py | 277 ++++++ plugins/extract/align/external_defaults.py | 97 +++ plugins/extract/detect/_base.py | 21 +- plugins/extract/detect/external.py | 353 ++++++++ plugins/extract/detect/external_defaults.py | 79 ++ plugins/extract/extract_media.py | 210 +++++ plugins/extract/mask/_base.py | 42 +- plugins/extract/mask/components.py | 7 + plugins/extract/mask/extended.py | 8 + plugins/extract/pipeline.py | 340 +++----- plugins/extract/recognition/_base.py | 34 +- scripts/convert.py | 7 +- scripts/extract.py | 11 +- scripts/fsmedia.py | 21 +- tools/alignments/alignments.py | 4 +- tools/alignments/cli.py | 11 +- tools/alignments/jobs.py | 112 ++- tools/alignments/jobs_frames.py | 23 +- tools/manual/detected_faces.py | 2 +- tools/manual/faceviewer/frame.py | 285 ++++--- tools/manual/faceviewer/interact.py | 423 +++++++++ tools/manual/faceviewer/viewport.py | 806 ++++++------------ tools/manual/frameviewer/editor/landmarks.py | 63 +- tools/manual/frameviewer/frame.py | 106 +-- tools/manual/manual.py | 6 +- tools/mask/loader.py | 2 +- tools/mask/mask.py | 4 +- tools/mask/mask_generate.py | 4 +- tools/mask/mask_import.py | 8 +- tools/preview/preview.py | 2 +- tools/sort/sort_methods.py | 44 +- tools/sort/sort_methods_aligned.py | 11 +- 62 files changed, 3332 insertions(+), 1736 deletions(-) create mode 100644 lib/align/constants.py create mode 100644 lib/align/pose.py create mode 100644 plugins/extract/align/external.py create mode 100644 plugins/extract/align/external_defaults.py create mode 100644 plugins/extract/detect/external.py create mode 100644 plugins/extract/detect/external_defaults.py create mode 100644 plugins/extract/extract_media.py create mode 100644 tools/manual/faceviewer/interact.py diff --git a/docs/full/lib/align.rst b/docs/full/lib/align.rst index bb449f805d..cb01196739 100644 --- a/docs/full/lib/align.rst +++ b/docs/full/lib/align.rst @@ -7,6 +7,7 @@ The align Package handles detected faces, their alignments and masks. .. contents:: Contents :local: + aligned\_face module ==================== @@ -16,10 +17,9 @@ Handles aligned faces and corresponding pose estimates .. autosummary:: :nosignatures: - + ~lib.align.aligned_face.AlignedFace ~lib.align.aligned_face.get_matrix_scaling - ~lib.align.aligned_face.PoseEstimate ~lib.align.aligned_face.transform_image .. rubric:: Module @@ -29,6 +29,7 @@ Handles aligned faces and corresponding pose estimates :undoc-members: :show-inheritance: + alignments module ================= @@ -38,7 +39,7 @@ Handles alignments stored in a serialized alignments.fsa file .. autosummary:: :nosignatures: - + ~lib.align.alignments.Alignments ~lib.align.alignments.Thumbnails @@ -49,6 +50,17 @@ Handles alignments stored in a serialized alignments.fsa file :undoc-members: :show-inheritance: + +constants module +================ +Holds various constants for use in generating and manipulating aligned face images + +.. automodule:: lib.align.constants + :members: + :undoc-members: + :show-inheritance: + + detected\_face module ===================== @@ -58,7 +70,7 @@ Handles detected face objects and their associated masks. .. autosummary:: :nosignatures: - + ~lib.align.detected_face.BlurMask ~lib.align.detected_face.DetectedFace ~lib.align.detected_face.Mask @@ -70,3 +82,13 @@ Handles detected face objects and their associated masks. :members: :undoc-members: :show-inheritance: + + +pose module +=========== +Handles pose estimates based on aligned face data + +.. automodule:: lib.align.pose + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/plugins/extract.rst b/docs/full/plugins/extract.rst index 5eb3caee1d..f7e441a106 100755 --- a/docs/full/plugins/extract.rst +++ b/docs/full/plugins/extract.rst @@ -8,18 +8,16 @@ The Extract Package handles the various plugins available for extracting face se :local: -pipeline module -=============== -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~plugins.extract.pipeline.ExtractMedia - ~plugins.extract.pipeline.Extractor +extract\_media module +===================== +.. automodule:: plugins.extract.extract_media + :members: + :undoc-members: + :show-inheritance: -.. rubric:: Module +pipeline module +=============== .. automodule:: plugins.extract.pipeline :members: :undoc-members: diff --git a/docs/full/tools/manual.faceviewer.rst b/docs/full/tools/manual.faceviewer.rst index ed3209f4f1..5589e144a1 100644 --- a/docs/full/tools/manual.faceviewer.rst +++ b/docs/full/tools/manual.faceviewer.rst @@ -7,6 +7,7 @@ Handles the display of faces in the Face Viewer section of Faceswap's Manual Too .. contents:: Contents :local: + frame module ============ @@ -28,6 +29,27 @@ frame module :undoc-members: :show-inheritance: + +interact module +=============== + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~tools.manual.faceviewer.interact.ActiveFrame + ~tools.manual.faceviewer.interact.Asset + ~tools.manual.faceviewer.interact.HoverBox + +.. rubric:: Module + +.. automodule:: tools.manual.faceviewer.interact + :members: + :undoc-members: + :show-inheritance: + + viewport module =============== @@ -36,8 +58,6 @@ viewport module .. autosummary:: :nosignatures: - ~tools.manual.faceviewer.viewport.ActiveFrame - ~tools.manual.faceviewer.viewport.HoverBox ~tools.manual.faceviewer.viewport.TKFace ~tools.manual.faceviewer.viewport.Viewport ~tools.manual.faceviewer.viewport.VisibleObjects diff --git a/lib/align/__init__.py b/lib/align/__init__.py index d599199dae..ec00ec7798 100644 --- a/lib/align/__init__.py +++ b/lib/align/__init__.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """ Package for handling alignments files, detected faces and aligned faces along with their associated objects. """ -from .aligned_face import (AlignedFace, _EXTRACT_RATIOS, get_adjusted_center, # noqa - get_matrix_scaling, get_centered_size, PoseEstimate, transform_image) -from .alignments import Alignments # noqa -from .detected_face import BlurMask, DetectedFace, Mask, update_legacy_png_header # noqa +from .aligned_face import (AlignedFace, get_adjusted_center, get_matrix_scaling, + get_centered_size, transform_image) +from .alignments import Alignments +from .constants import CenteringType, EXTRACT_RATIOS, LANDMARK_PARTS, LandmarkType +from .detected_face import BlurMask, DetectedFace, Mask, update_legacy_png_header diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 263b03cb8f..41f2eed8c3 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -1,63 +1,22 @@ #!/usr/bin/env python3 """ Aligner for faceswap.py """ +from __future__ import annotations from dataclasses import dataclass, field import logging import typing as T + from threading import Lock import cv2 import numpy as np +from lib.logger import parse_class_init + +from .constants import CenteringType, EXTRACT_RATIOS, LandmarkType, _MEAN_FACE +from .pose import PoseEstimate + logger = logging.getLogger(__name__) -CenteringType = T.Literal["face", "head", "legacy"] - -_MEAN_FACE = np.array([[0.010086, 0.106454], [0.085135, 0.038915], [0.191003, 0.018748], - [0.300643, 0.034489], [0.403270, 0.077391], [0.596729, 0.077391], - [0.699356, 0.034489], [0.808997, 0.018748], [0.914864, 0.038915], - [0.989913, 0.106454], [0.500000, 0.203352], [0.500000, 0.307009], - [0.500000, 0.409805], [0.500000, 0.515625], [0.376753, 0.587326], - [0.435909, 0.609345], [0.500000, 0.628106], [0.564090, 0.609345], - [0.623246, 0.587326], [0.131610, 0.216423], [0.196995, 0.178758], - [0.275698, 0.179852], [0.344479, 0.231733], [0.270791, 0.245099], - [0.192616, 0.244077], [0.655520, 0.231733], [0.724301, 0.179852], - [0.803005, 0.178758], [0.868389, 0.216423], [0.807383, 0.244077], - [0.729208, 0.245099], [0.264022, 0.780233], [0.350858, 0.745405], - [0.438731, 0.727388], [0.500000, 0.742578], [0.561268, 0.727388], - [0.649141, 0.745405], [0.735977, 0.780233], [0.652032, 0.864805], - [0.566594, 0.902192], [0.500000, 0.909281], [0.433405, 0.902192], - [0.347967, 0.864805], [0.300252, 0.784792], [0.437969, 0.778746], - [0.500000, 0.785343], [0.562030, 0.778746], [0.699747, 0.784792], - [0.563237, 0.824182], [0.500000, 0.831803], [0.436763, 0.824182]]) - -_MEAN_FACE_3D = np.array([[4.056931, -11.432347, 1.636229], # 8 chin LL - [1.833492, -12.542305, 4.061275], # 7 chin L - [0.0, -12.901019, 4.070434], # 6 chin C - [-1.833492, -12.542305, 4.061275], # 5 chin R - [-4.056931, -11.432347, 1.636229], # 4 chin RR - [6.825897, 1.275284, 4.402142], # 33 L eyebrow L - [1.330353, 1.636816, 6.903745], # 29 L eyebrow R - [-1.330353, 1.636816, 6.903745], # 34 R eyebrow L - [-6.825897, 1.275284, 4.402142], # 38 R eyebrow R - [1.930245, -5.060977, 5.914376], # 54 nose LL - [0.746313, -5.136947, 6.263227], # 53 nose L - [0.0, -5.485328, 6.76343], # 52 nose C - [-0.746313, -5.136947, 6.263227], # 51 nose R - [-1.930245, -5.060977, 5.914376], # 50 nose RR - [5.311432, 0.0, 3.987654], # 13 L eye L - [1.78993, -0.091703, 4.413414], # 17 L eye R - [-1.78993, -0.091703, 4.413414], # 25 R eye L - [-5.311432, 0.0, 3.987654], # 21 R eye R - [2.774015, -7.566103, 5.048531], # 43 mouth L - [0.509714, -7.056507, 6.566167], # 42 mouth top L - [0.0, -7.131772, 6.704956], # 41 mouth top C - [-0.509714, -7.056507, 6.566167], # 40 mouth top R - [-2.774015, -7.566103, 5.048531], # 39 mouth R - [-0.589441, -8.443925, 6.109526], # 46 mouth bottom R - [0.0, -8.601736, 6.097667], # 45 mouth bottom C - [0.589441, -8.443925, 6.109526]]) # 44 mouth bottom L - -_EXTRACT_RATIOS = {"legacy": 0.375, "face": 0.5, "head": 0.625} def get_matrix_scaling(matrix: np.ndarray) -> tuple[int, int]: @@ -82,7 +41,7 @@ def get_matrix_scaling(matrix: np.ndarray) -> tuple[int, int]: interpolators = cv2.INTER_CUBIC, cv2.INTER_AREA else: interpolators = cv2.INTER_AREA, cv2.INTER_CUBIC - logger.trace("interpolator: %s, inverse interpolator: %s", # type: ignore + logger.trace("interpolator: %s, inverse interpolator: %s", # type:ignore[attr-defined] interpolators[0], interpolators[1]) return interpolators @@ -109,7 +68,7 @@ def transform_image(image: np.ndarray, :class:`numpy.ndarray` The transformed image """ - logger.trace("image shape: %s, matrix: %s, size: %s. padding: %s", # type: ignore + logger.trace("image shape: %s, matrix: %s, size: %s. padding: %s", # type:ignore[attr-defined] image.shape, matrix, size, padding) # transform the matrix for size and padding mat = matrix * (size - 2 * padding) @@ -118,7 +77,7 @@ def transform_image(image: np.ndarray, # transform image interpolators = get_matrix_scaling(mat) retval = cv2.warpAffine(image, mat, (size, size), flags=interpolators[0]) - logger.trace("transformed matrix: %s, final image shape: %s", # type: ignore + logger.trace("transformed matrix: %s, final image shape: %s", # type:ignore[attr-defined] mat, image.shape) return retval @@ -146,13 +105,14 @@ def get_adjusted_center(image_size: int, :class:`numpy.ndarray` The center point of the image at the given size for the target centering """ - source_size = image_size - (image_size * _EXTRACT_RATIOS[source_centering]) + source_size = image_size - (image_size * EXTRACT_RATIOS[source_centering]) offset = target_offset - source_offset offset *= source_size center = np.rint(offset + image_size / 2).astype("int32") - logger.trace("image_size: %s, source_offset: %s, target_offset: %s, " # type: ignore - "source_centering: '%s', adjusted_offset: %s, center: %s", image_size, - source_offset, target_offset, source_centering, offset, center) + logger.trace( # type:ignore[attr-defined] + "image_size: %s, source_offset: %s, target_offset: %s, source_centering: '%s', " + "adjusted_offset: %s, center: %s", + image_size, source_offset, target_offset, source_centering, offset, center) return center @@ -196,158 +156,16 @@ def get_centered_size(source_centering: CenteringType, if source_centering == target_centering and coverage_ratio == 1.0: retval = size else: - src_size = size - (size * _EXTRACT_RATIOS[source_centering]) - retval = 2 * int(np.rint((src_size / (1 - _EXTRACT_RATIOS[target_centering]) + src_size = size - (size * EXTRACT_RATIOS[source_centering]) + retval = 2 * int(np.rint((src_size / (1 - EXTRACT_RATIOS[target_centering]) * coverage_ratio) / 2)) - logger.trace("source_centering: %s, target_centering: %s, size: %s, " # type: ignore - "coverage_ratio: %s, source_size: %s, crop_size: %s", source_centering, - target_centering, size, coverage_ratio, src_size, retval) + logger.trace( # type:ignore[attr-defined] + "source_centering: %s, target_centering: %s, size: %s, coverage_ratio: %s, " + "source_size: %s, crop_size: %s", + source_centering, target_centering, size, coverage_ratio, src_size, retval) return retval -class PoseEstimate(): - """ Estimates pose from a generic 3D head model for the given 2D face landmarks. - - Parameters - ---------- - landmarks: :class:`numpy.ndarry` - The original 68 point landmarks aligned to 0.0 - 1.0 range - - References - ---------- - Head Pose Estimation using OpenCV and Dlib - https://www.learnopencv.com/tag/solvepnp/ - 3D Model points - http://aifi.isr.uc.pt/Downloads/OpenGL/glAnthropometric3DModel.cpp - """ - def __init__(self, landmarks: np.ndarray) -> None: - self._distortion_coefficients = np.zeros((4, 1)) # Assuming no lens distortion - self._xyz_2d: np.ndarray | None = None - - self._camera_matrix = self._get_camera_matrix() - self._rotation, self._translation = self._solve_pnp(landmarks) - self._offset = self._get_offset() - self._pitch_yaw_roll: tuple[float, float, float] = (0, 0, 0) - - @property - def xyz_2d(self) -> np.ndarray: - """ :class:`numpy.ndarray` projected (x, y) coordinates for each x, y, z point at a - constant distance from adjusted center of the skull (0.5, 0.5) in the 2D space. """ - if self._xyz_2d is None: - xyz = cv2.projectPoints(np.array([[6., 0., -2.3], - [0., 6., -2.3], - [0., 0., 3.7]]).astype("float32"), - self._rotation, - self._translation, - self._camera_matrix, - self._distortion_coefficients)[0].squeeze() - self._xyz_2d = xyz - self._offset["head"] - return self._xyz_2d - - @property - def offset(self) -> dict[CenteringType, np.ndarray]: - """ dict: The amount to offset a standard 0.0 - 1.0 umeyama transformation matrix for a - from the center of the face (between the eyes) or center of the head (middle of skull) - rather than the nose area. """ - return self._offset - - @property - def pitch(self) -> float: - """ float: The pitch of the aligned face in eular angles """ - if not any(self._pitch_yaw_roll): - self._get_pitch_yaw_roll() - return self._pitch_yaw_roll[0] - - @property - def yaw(self) -> float: - """ float: The yaw of the aligned face in eular angles """ - if not any(self._pitch_yaw_roll): - self._get_pitch_yaw_roll() - return self._pitch_yaw_roll[1] - - @property - def roll(self) -> float: - """ float: The roll of the aligned face in eular angles """ - if not any(self._pitch_yaw_roll): - self._get_pitch_yaw_roll() - return self._pitch_yaw_roll[2] - - def _get_pitch_yaw_roll(self) -> None: - """ Obtain the yaw, roll and pitch from the :attr:`_rotation` in eular angles. """ - proj_matrix = np.zeros((3, 4), dtype="float32") - proj_matrix[:3, :3] = cv2.Rodrigues(self._rotation)[0] - euler = cv2.decomposeProjectionMatrix(proj_matrix)[-1] - self._pitch_yaw_roll = T.cast(tuple[float, float, float], tuple(euler.squeeze())) - logger.trace("yaw_pitch: %s", self._pitch_yaw_roll) # type: ignore - - @classmethod - def _get_camera_matrix(cls) -> np.ndarray: - """ Obtain an estimate of the camera matrix based off the original frame dimensions. - - Returns - ------- - :class:`numpy.ndarray` - An estimated camera matrix - """ - focal_length = 4 - camera_matrix = np.array([[focal_length, 0, 0.5], - [0, focal_length, 0.5], - [0, 0, 1]], dtype="double") - logger.trace("camera_matrix: %s", camera_matrix) # type: ignore - return camera_matrix - - def _solve_pnp(self, landmarks: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - """ Solve the Perspective-n-Point for the given landmarks. - - Takes 2D landmarks in world space and estimates the rotation and translation vectors - in 3D space. - - Parameters - ---------- - landmarks: :class:`numpy.ndarry` - The original 68 point landmark co-ordinates relating to the original frame - - Returns - ------- - rotation: :class:`numpy.ndarray` - The solved rotation vector - translation: :class:`numpy.ndarray` - The solved translation vector - """ - points = landmarks[[6, 7, 8, 9, 10, 17, 21, 22, 26, 31, 32, 33, 34, - 35, 36, 39, 42, 45, 48, 50, 51, 52, 54, 56, 57, 58]] - _, rotation, translation = cv2.solvePnP(_MEAN_FACE_3D, - points, - self._camera_matrix, - self._distortion_coefficients, - flags=cv2.SOLVEPNP_ITERATIVE) - logger.trace("points: %s, rotation: %s, translation: %s", # type: ignore - points, rotation, translation) - return rotation, translation - - def _get_offset(self) -> dict[CenteringType, np.ndarray]: - """ Obtain the offset between the original center of the extracted face to the new center - of the head in 2D space. - - Returns - ------- - :class:`numpy.ndarray` - The x, y offset of the new center from the old center. - """ - offset: dict[CenteringType, np.ndarray] = {"legacy": np.array([0.0, 0.0])} - points: dict[T.Literal["face", "head"], tuple[float, ...]] = {"head": (0.0, 0.0, -2.3), - "face": (0.0, -1.5, 4.2)} - - for key, pnts in points.items(): - center = cv2.projectPoints(np.array([pnts]).astype("float32"), - self._rotation, - self._translation, - self._camera_matrix, - self._distortion_coefficients)[0].squeeze() - logger.trace("center %s: %s", key, center) # type: ignore - offset[key] = center - (0.5, 0.5) - logger.trace("offset: %s", offset) # type: ignore - return offset - - @dataclass class _FaceCache: # pylint:disable=too-many-instance-attributes """ Cache for storing items related to a single aligned face. @@ -464,27 +282,26 @@ def __init__(self, dtype: str | None = None, is_aligned: bool = False, is_legacy: bool = False) -> None: - logger.trace("Initializing: %s (image shape: %s, centering: '%s', " # type: ignore - "size: %s, coverage_ratio: %s, dtype: %s, is_aligned: %s, is_legacy: %s)", - self.__class__.__name__, image if image is None else image.shape, - centering, size, coverage_ratio, dtype, is_aligned, is_legacy) + logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] self._frame_landmarks = landmarks + self._landmark_type = LandmarkType.from_shape(landmarks.shape) self._centering = centering self._size = size self._coverage_ratio = coverage_ratio self._dtype = dtype self._is_aligned = is_aligned self._source_centering: CenteringType = "legacy" if is_legacy and is_aligned else "head" - self._matrices = {"legacy": _umeyama(landmarks[17:], _MEAN_FACE, True)[0:2], - "face": np.array([]), - "head": np.array([])} self._padding = self._padding_from_coverage(size, coverage_ratio) + lookup = self._landmark_type + self._mean_lookup = LandmarkType.LM_2D_51 if lookup == LandmarkType.LM_2D_68 else lookup + self._cache = _FaceCache() + self._matrices: dict[CenteringType, np.ndarray] = {"legacy": self._get_default_matrix()} self._face = self.extract_face(image) - logger.trace("Initialized: %s (matrix: %s, padding: %s, face shape: %s)", # type: ignore - self.__class__.__name__, self._matrices["legacy"], self._padding, + logger.trace("Initialized: %s (padding: %s, face shape: %s)", # type:ignore[attr-defined] + self.__class__.__name__, self._padding, self._face if self._face is None else self._face.shape) @property @@ -508,11 +325,11 @@ def matrix(self) -> np.ndarray: """ :class:`numpy.ndarray`: The 3x2 transformation matrix for extracting and aligning the core face area out of the original frame, with no padding or sizing applied. The returned matrix is offset for the given :attr:`centering`. """ - if not np.any(self._matrices[self._centering]): + if self._centering not in self._matrices: matrix = self._matrices["legacy"].copy() matrix[:, 2] -= self.pose.offset[self._centering] self._matrices[self._centering] = matrix - logger.trace("original matrix: %s, new matrix: %s", # type: ignore + logger.trace("original matrix: %s, new matrix: %s", # type:ignore[attr-defined] self._matrices["legacy"], matrix) return self._matrices[self._centering] @@ -523,7 +340,7 @@ def pose(self) -> PoseEstimate: if self._cache.pose is None: lms = np.nan_to_num(cv2.transform(np.expand_dims(self._frame_landmarks, axis=1), self._matrices["legacy"]).squeeze()) - self._cache.pose = PoseEstimate(lms) + self._cache.pose = PoseEstimate(lms, self._landmark_type) return self._cache.pose @property @@ -535,7 +352,7 @@ def adjusted_matrix(self) -> np.ndarray: matrix = self.matrix.copy() mat = matrix * (self._size - 2 * self.padding) mat[:, 2] += self.padding - logger.trace("adjusted_matrix: %s", mat) # type: ignore + logger.trace("adjusted_matrix: %s", mat) # type:ignore[attr-defined] self._cache.adjusted_matrix = mat return self._cache.adjusted_matrix @@ -557,7 +374,7 @@ def original_roi(self) -> np.ndarray: [self._size - 1, self._size - 1], [self._size - 1, 0]]) roi = np.rint(self.transform_points(roi, invert=True)).astype("int32") - logger.trace("original roi: %s", roi) # type: ignore + logger.trace("original roi: %s", roi) # type:ignore[attr-defined] self._cache.original_roi = roi return self._cache.original_roi @@ -568,10 +385,15 @@ def landmarks(self) -> np.ndarray: with self._cache.lock("landmarks"): if self._cache.landmarks is None: lms = self.transform_points(self._frame_landmarks) - logger.trace("aligned landmarks: %s", lms) # type: ignore + logger.trace("aligned landmarks: %s", lms) # type:ignore[attr-defined] self._cache.landmarks = lms return self._cache.landmarks + @property + def landmark_type(self) -> LandmarkType: + """:class:`~LandmarkType`: The type of landmarks that generated this aligned face """ + return self._landmark_type + @property def normalized_landmarks(self) -> np.ndarray: """ :class:`numpy.ndarray`: The 68 point facial landmarks normalized to 0.0 - 1.0 as @@ -579,8 +401,8 @@ def normalized_landmarks(self) -> np.ndarray: with self._cache.lock("landmarks_normalized"): if self._cache.landmarks_normalized is None: lms = np.expand_dims(self._frame_landmarks, axis=1) - lms = cv2.transform(lms, self._matrices["legacy"], lms.shape).squeeze() - logger.trace("normalized landmarks: %s", lms) # type: ignore + lms = cv2.transform(lms, self._matrices["legacy"]).squeeze() + logger.trace("normalized landmarks: %s", lms) # type:ignore[attr-defined] self._cache.landmarks_normalized = lms return self._cache.landmarks_normalized @@ -590,7 +412,7 @@ def interpolators(self) -> tuple[int, int]: with self._cache.lock("interpolators"): if not any(self._cache.interpolators): interpolators = get_matrix_scaling(self.adjusted_matrix) - logger.trace("interpolators: %s", interpolators) # type: ignore + logger.trace("interpolators: %s", interpolators) # type:ignore[attr-defined] self._cache.interpolators = interpolators return self._cache.interpolators @@ -600,8 +422,12 @@ def average_distance(self) -> float: used for aligning the image. """ with self._cache.lock("average_distance"): if not self._cache.average_distance: - average_distance = np.mean(np.abs(self.normalized_landmarks[17:] - _MEAN_FACE)) - logger.trace("average_distance: %s", average_distance) # type: ignore + mean_face = _MEAN_FACE[self._mean_lookup] + lms = self.normalized_landmarks + if self._landmark_type == LandmarkType.LM_2D_68: + lms = lms[17:] # 68 point landmarks only use core face items + average_distance = np.mean(np.abs(lms - mean_face)) + logger.trace("average_distance: %s", average_distance) # type:ignore[attr-defined] self._cache.average_distance = average_distance return self._cache.average_distance @@ -612,10 +438,13 @@ def relative_eye_mouth_position(self) -> float: mouth, negative values indicate that eyes/eyebrows are misaligned below the mouth. """ with self._cache.lock("relative_eye_mouth_position"): if not self._cache.relative_eye_mouth_position: - lowest_eyes = np.max(self.normalized_landmarks[np.r_[17:27, 36:48], 1]) - highest_mouth = np.min(self.normalized_landmarks[48:68, 1]) - position = highest_mouth - lowest_eyes - logger.trace("lowest_eyes: %s, highest_mouth: %s, " # type: ignore + if self._landmark_type != LandmarkType.LM_2D_68: + position = 1.0 # arbitrary positive value + else: + lowest_eyes = np.max(self.normalized_landmarks[np.r_[17:27, 36:48], 1]) + highest_mouth = np.min(self.normalized_landmarks[48:68, 1]) + position = highest_mouth - lowest_eyes + logger.trace("lowest_eyes: %s, highest_mouth: %s, " # type:ignore[attr-defined] "relative_eye_mouth_position: %s", lowest_eyes, highest_mouth, position) self._cache.relative_eye_mouth_position = position @@ -638,9 +467,24 @@ def _padding_from_coverage(cls, size: int, coverage_ratio: float) -> dict[Center dict The padding required, in pixels for 'head', 'face' and 'legacy' face types """ - retval = {_type: round((size * (coverage_ratio - (1 - _EXTRACT_RATIOS[_type]))) / 2) + retval = {_type: round((size * (coverage_ratio - (1 - EXTRACT_RATIOS[_type]))) / 2) for _type in T.get_args(T.Literal["legacy", "face", "head"])} - logger.trace(retval) # type: ignore + logger.trace(retval) # type:ignore[attr-defined] + return retval + + def _get_default_matrix(self) -> np.ndarray: + """ Get the default (legacy) matrix. All subsequent matrices are calculated from this + + Returns + ------- + :class:`numpy.ndarray` + The default 'legacy' matrix + """ + lms = self._frame_landmarks + if self._landmark_type == LandmarkType.LM_2D_68: + lms = lms[17:] # 68 point landmarks only use core face items + retval = _umeyama(lms, _MEAN_FACE[self._mean_lookup], True)[0:2] + logger.trace("Default matrix: %s", retval) # type:ignore[attr-defined] return retval def transform_points(self, points: np.ndarray, invert: bool = False) -> np.ndarray: @@ -662,9 +506,9 @@ def transform_points(self, points: np.ndarray, invert: bool = False) -> np.ndarr """ retval = np.expand_dims(points, axis=1) mat = cv2.invertAffineTransform(self.adjusted_matrix) if invert else self.adjusted_matrix - retval = cv2.transform(retval, mat, retval.shape).squeeze() - logger.trace("invert: %s, Original points: %s, transformed points: %s", # type: ignore - invert, points, retval) + retval = cv2.transform(retval, mat).squeeze() + logger.trace( # type:ignore[attr-defined] + "invert: %s, Original points: %s, transformed points: %s", invert, points, retval) return retval def extract_face(self, image: np.ndarray | None) -> np.ndarray | None: @@ -684,8 +528,8 @@ def extract_face(self, image: np.ndarray | None) -> np.ndarray | None: ``None`` if no image has been provided. """ if image is None: - logger.trace("_extract_face called without a loaded image. " # type: ignore - "Returning empty face.") + logger.trace("_extract_face called without a loaded " # type:ignore[attr-defined] + "image. Returning empty face.") return None if self._is_aligned and (self._centering != self._source_centering or @@ -721,8 +565,9 @@ def _convert_centering(self, image: np.ndarray) -> np.ndarray: :class:`numpy.ndarray` The aligned image with the correct centering, scaled to image input size """ - logger.trace("image_size: %s, target_size: %s, coverage_ratio: %s", # type: ignore - image.shape[0], self.size, self._coverage_ratio) + logger.trace( # type:ignore[attr-defined] + "image_size: %s, target_size: %s, coverage_ratio: %s", + image.shape[0], self.size, self._coverage_ratio) img_size = image.shape[0] target_size = get_centered_size(self._source_centering, @@ -733,8 +578,9 @@ def _convert_centering(self, image: np.ndarray) -> np.ndarray: slices = self._get_cropped_slices(img_size, target_size) out[slices["out"][0], slices["out"][1], :] = image[slices["in"][0], slices["in"][1], :] - logger.trace("Cropped from aligned extract: (centering: %s, in shape: %s, " # type: ignore - "out shape: %s)", self._centering, image.shape, out.shape) + logger.trace( # type:ignore[attr-defined] + "Cropped from aligned extract: (centering: %s, in shape: %s, out shape: %s)", + self._centering, image.shape, out.shape) return out def _get_cropped_slices(self, @@ -766,7 +612,7 @@ def _get_cropped_slices(self, slice(max(roi[0] * -1, 0), target_size - min(target_size, max(0, roi[2] - image_size)))) self._cache.cropped_slices[self._centering] = {"in": slice_in, "out": slice_out} - logger.trace("centering: %s, cropped_slices: %s", # type: ignore + logger.trace("centering: %s, cropped_slices: %s", # type:ignore[attr-defined] self._centering, self._cache.cropped_slices[self._centering]) return self._cache.cropped_slices[self._centering] @@ -805,8 +651,9 @@ def get_cropped_roi(self, self._source_centering) padding = target_size // 2 roi = np.array([center - padding, center + padding]).ravel() - logger.trace("centering: '%s', center: %s, padding: %s, " # type: ignore - "sub roi: %s", centering, center, padding, roi) + logger.trace( # type:ignore[attr-defined] + "centering: '%s', center: %s, padding: %s, sub roi: %s", + centering, center, padding, roi) self._cache.cropped_roi[centering] = roi return self._cache.cropped_roi[centering] diff --git a/lib/align/alignments.py b/lib/align/alignments.py index 7013a7bd4c..c5d7452caa 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -252,12 +252,14 @@ def save_video_meta_data(self, pts_time: list[float], keyframes: list[int]) -> N sample_filename = next(fname for fname in self.data) basename = sample_filename[:sample_filename.rfind("_")] - logger.debug("sample filename: %s, base filename: %s", sample_filename, basename) + ext = os.path.splitext(sample_filename)[-1] + logger.debug("sample filename: '%s', base filename: '%s' extension: '%s'", + sample_filename, basename, ext) logger.info("Saving video meta information to Alignments file") for idx, pts in enumerate(pts_time): meta: dict[str, float | int] = {"pts_time": pts, "keyframe": idx in keyframes} - key = f"{basename}_{idx + 1:06d}.png" + key = f"{basename}_{idx + 1:06d}{ext}" if key not in self.data: self.data[key] = {"video_meta": meta, "faces": []} else: diff --git a/lib/align/constants.py b/lib/align/constants.py new file mode 100644 index 0000000000..6d5b484e59 --- /dev/null +++ b/lib/align/constants.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" Constants that are required across faceswap's lib.align package """ +from __future__ import annotations + +import typing as T +from enum import Enum + +import numpy as np + +CenteringType = T.Literal["face", "head", "legacy"] + +EXTRACT_RATIOS: dict[CenteringType, float] = {"legacy": 0.375, "face": 0.5, "head": 0.625} +"""dict[Literal["legacy", "face", head"] float]: The amount of padding applied to each +centering type when generating aligned faces """ + + +class LandmarkType(Enum): + """ Enumeration for the landmark types that Faceswap supports """ + LM_2D_4 = 1 + LM_2D_51 = 2 + LM_2D_68 = 3 + LM_3D_26 = 4 + + @classmethod + def from_shape(cls, shape: tuple[int, ...]) -> LandmarkType: + """ The landmark type for a given shape + + Parameters + ---------- + shape: tuple[int, ...] + The shape to get the landmark type for + + Returns + ------- + Type[LandmarkType] + The enum for the given shape + + Raises + ------ + ValueError + If the requested shape is not valid + """ + shapes: dict[tuple[int, ...], LandmarkType] = {(4, 2): cls.LM_2D_4, + (51, 2): cls.LM_2D_51, + (68, 2): cls.LM_2D_68, + (26, 3): cls.LM_3D_26} + if shape not in shapes: + raise ValueError(f"The given shape {shape} is not valid. Valid shapes: {list(shapes)}") + return shapes[shape] + + +_MEAN_FACE: dict[LandmarkType, np.ndarray] = { + LandmarkType.LM_2D_4: np.array( + [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]), # Clockwise from TL + LandmarkType.LM_2D_51: np.array([ + [0.010086, 0.106454], [0.085135, 0.038915], [0.191003, 0.018748], [0.300643, 0.034489], + [0.403270, 0.077391], [0.596729, 0.077391], [0.699356, 0.034489], [0.808997, 0.018748], + [0.914864, 0.038915], [0.989913, 0.106454], [0.500000, 0.203352], [0.500000, 0.307009], + [0.500000, 0.409805], [0.500000, 0.515625], [0.376753, 0.587326], [0.435909, 0.609345], + [0.500000, 0.628106], [0.564090, 0.609345], [0.623246, 0.587326], [0.131610, 0.216423], + [0.196995, 0.178758], [0.275698, 0.179852], [0.344479, 0.231733], [0.270791, 0.245099], + [0.192616, 0.244077], [0.655520, 0.231733], [0.724301, 0.179852], [0.803005, 0.178758], + [0.868389, 0.216423], [0.807383, 0.244077], [0.729208, 0.245099], [0.264022, 0.780233], + [0.350858, 0.745405], [0.438731, 0.727388], [0.500000, 0.742578], [0.561268, 0.727388], + [0.649141, 0.745405], [0.735977, 0.780233], [0.652032, 0.864805], [0.566594, 0.902192], + [0.500000, 0.909281], [0.433405, 0.902192], [0.347967, 0.864805], [0.300252, 0.784792], + [0.437969, 0.778746], [0.500000, 0.785343], [0.562030, 0.778746], [0.699747, 0.784792], + [0.563237, 0.824182], [0.500000, 0.831803], [0.436763, 0.824182]]), + LandmarkType.LM_3D_26: np.array([ + [4.056931, -11.432347, 1.636229], # 8 chin LL + [1.833492, -12.542305, 4.061275], # 7 chin L + [0.0, -12.901019, 4.070434], # 6 chin C + [-1.833492, -12.542305, 4.061275], # 5 chin R + [-4.056931, -11.432347, 1.636229], # 4 chin RR + [6.825897, 1.275284, 4.402142], # 33 L eyebrow L + [1.330353, 1.636816, 6.903745], # 29 L eyebrow R + [-1.330353, 1.636816, 6.903745], # 34 R eyebrow L + [-6.825897, 1.275284, 4.402142], # 38 R eyebrow R + [1.930245, -5.060977, 5.914376], # 54 nose LL + [0.746313, -5.136947, 6.263227], # 53 nose L + [0.0, -5.485328, 6.76343], # 52 nose C + [-0.746313, -5.136947, 6.263227], # 51 nose R + [-1.930245, -5.060977, 5.914376], # 50 nose RR + [5.311432, 0.0, 3.987654], # 13 L eye L + [1.78993, -0.091703, 4.413414], # 17 L eye R + [-1.78993, -0.091703, 4.413414], # 25 R eye L + [-5.311432, 0.0, 3.987654], # 21 R eye R + [2.774015, -7.566103, 5.048531], # 43 mouth L + [0.509714, -7.056507, 6.566167], # 42 mouth top L + [0.0, -7.131772, 6.704956], # 41 mouth top C + [-0.509714, -7.056507, 6.566167], # 40 mouth top R + [-2.774015, -7.566103, 5.048531], # 39 mouth R + [-0.589441, -8.443925, 6.109526], # 46 mouth bottom R + [0.0, -8.601736, 6.097667], # 45 mouth bottom C + [0.589441, -8.443925, 6.109526]])} # 44 mouth bottom L +"""dict[:class:`~LandmarkType, np.ndarray]: 'Mean' landmark points for various landmark types. Used +for aligning faces """ + +LANDMARK_PARTS: dict[LandmarkType, dict[str, tuple[int, int, bool]]] = { + LandmarkType.LM_2D_68: {"mouth_outer": (48, 60, True), + "mouth_inner": (60, 68, True), + "right_eyebrow": (17, 22, False), + "left_eyebrow": (22, 27, False), + "right_eye": (36, 42, True), + "left_eye": (42, 48, True), + "nose": (27, 36, False), + "jaw": (0, 17, False), + "chin": (8, 11, False)}, + LandmarkType.LM_2D_4: {"face": (0, 4, True)}} +"""dict[:class:`LandmarkType`, dict[str, tuple[int, int, bool]]: For each landmark type, stores +the (start index, end index, is polygon) information about each part of the face. """ diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index 15fbb5c393..8ebea9a599 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -15,7 +15,7 @@ from lib.utils import FaceswapError from .alignments import (Alignments, AlignmentFileDict, MaskAlignmentsFileDict, PNGHeaderAlignmentsDict, PNGHeaderDict, PNGHeaderSourceDict) -from . import AlignedFace, get_adjusted_center, get_centered_size +from . import AlignedFace, get_adjusted_center, get_centered_size, LANDMARK_PARTS if T.TYPE_CHECKING: from collections.abc import Callable @@ -231,12 +231,26 @@ def get_landmark_mask(self, ------- :class:`numpy.ndarray` The generated landmarks mask for the selected area + + Raises + ------ + FaceSwapError + If the aligned face does not contain the correct landmarks to generate a landmark mask """ # TODO Face mask generation from landmarks logger.trace("area: %s, dilation: %s", area, dilation) # type:ignore[attr-defined] - areas = {"mouth": [slice(48, 60)], "eye": [slice(36, 42), slice(42, 48)]} - points = [self.aligned.landmarks[zone] - for zone in areas[area]] + + lm_type = self.aligned.landmark_type + if lm_type not in LANDMARK_PARTS: + raise FaceswapError(f"Landmark based masks cannot be created for {lm_type.name}") + + lm_parts = LANDMARK_PARTS[self.aligned.landmark_type] + mapped = {"mouth": ["mouth_outer"], "eye": ["right_eye", "left_eye"]} + if not all(part in lm_parts for parts in mapped.values() for part in parts): + raise FaceswapError(f"Landmark based masks cannot be created for {lm_type.name}") + + areas = {key: [slice(*lm_parts[v][:2]) for v in val]for key, val in mapped.items()} + points = [self.aligned.landmarks[zone] for zone in areas[area]] lmmask = LandmarksMask(points, storage_size=self.aligned.size, @@ -660,9 +674,9 @@ def replace_mask(self, mask: np.ndarray) -> None: The mask that is to be added as output from :mod:`plugins.extract.mask`. It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` """ - mask = (cv2.resize(mask, + mask = (cv2.resize(mask * 255.0, (self.stored_size, self.stored_size), - interpolation=cv2.INTER_AREA) * 255.0).astype("uint8") + interpolation=cv2.INTER_AREA)).astype("uint8") self._mask = compress(mask.tobytes()) def set_dilation(self, amount: float) -> None: @@ -903,7 +917,7 @@ def generate_mask(self, affine_matrix: np.ndarray, interpolator: int) -> None: mask = np.zeros((self.stored_size, self.stored_size, 1), dtype="float32") for landmarks in self._points: lms = np.rint(landmarks).astype("int") - cv2.fillConvexPoly(mask, cv2.convexHull(lms), 1.0, lineType=cv2.LINE_AA) + cv2.fillConvexPoly(mask, cv2.convexHull(lms), [1.0], lineType=cv2.LINE_AA) if self._dilation[-1] is not None: self._dilate_mask(mask) if self._blur_kernel != 0 and self._blur_type is not None: diff --git a/lib/align/pose.py b/lib/align/pose.py new file mode 100644 index 0000000000..fe186a3f5c --- /dev/null +++ b/lib/align/pose.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" Holds estimated pose information for a faceswap aligned face """ +from __future__ import annotations + +import logging +import typing as T + +import cv2 +import numpy as np + +from lib.logger import parse_class_init + +from .constants import _MEAN_FACE, LandmarkType + +logger = logging.getLogger(__name__) + +if T.TYPE_CHECKING: + from .constants import CenteringType + + +class PoseEstimate(): + """ Estimates pose from a generic 3D head model for the given 2D face landmarks. + + Parameters + ---------- + landmarks: :class:`numpy.ndarry` + The original 68 point landmarks aligned to 0.0 - 1.0 range + landmarks_type: :class:`~LandmarksType` + The type of landmarks that are generating this face + + References + ---------- + Head Pose Estimation using OpenCV and Dlib - https://www.learnopencv.com/tag/solvepnp/ + 3D Model points - http://aifi.isr.uc.pt/Downloads/OpenGL/glAnthropometric3DModel.cpp + """ + _logged_once = False + + def __init__(self, landmarks: np.ndarray, landmarks_type: LandmarkType) -> None: + logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] + self._distortion_coefficients = np.zeros((4, 1)) # Assuming no lens distortion + self._xyz_2d: np.ndarray | None = None + + if landmarks_type != LandmarkType.LM_2D_68: + self._log_once("Pose estimation is not available for non-68 point landmarks. Pose and " + "offset data will all be returned as the incorrect value of '0'") + self._landmarks_type = landmarks_type + self._camera_matrix = self._get_camera_matrix() + self._rotation, self._translation = self._solve_pnp(landmarks) + self._offset = self._get_offset() + self._pitch_yaw_roll: tuple[float, float, float] = (0, 0, 0) + logger.trace("Initialized %s", self.__class__.__name__) # type:ignore[attr-defined] + + @property + def xyz_2d(self) -> np.ndarray: + """ :class:`numpy.ndarray` projected (x, y) coordinates for each x, y, z point at a + constant distance from adjusted center of the skull (0.5, 0.5) in the 2D space. """ + if self._xyz_2d is None: + xyz = cv2.projectPoints(np.array([[6., 0., -2.3], + [0., 6., -2.3], + [0., 0., 3.7]]).astype("float32"), + self._rotation, + self._translation, + self._camera_matrix, + self._distortion_coefficients)[0].squeeze() + self._xyz_2d = xyz - self._offset["head"] + return self._xyz_2d + + @property + def offset(self) -> dict[CenteringType, np.ndarray]: + """ dict: The amount to offset a standard 0.0 - 1.0 umeyama transformation matrix for a + from the center of the face (between the eyes) or center of the head (middle of skull) + rather than the nose area. """ + return self._offset + + @property + def pitch(self) -> float: + """ float: The pitch of the aligned face in eular angles """ + if not any(self._pitch_yaw_roll): + self._get_pitch_yaw_roll() + return self._pitch_yaw_roll[0] + + @property + def yaw(self) -> float: + """ float: The yaw of the aligned face in eular angles """ + if not any(self._pitch_yaw_roll): + self._get_pitch_yaw_roll() + return self._pitch_yaw_roll[1] + + @property + def roll(self) -> float: + """ float: The roll of the aligned face in eular angles """ + if not any(self._pitch_yaw_roll): + self._get_pitch_yaw_roll() + return self._pitch_yaw_roll[2] + + @classmethod + def _log_once(cls, message: str) -> None: + """ Log a warning about unsupported landmarks if a message has not already been logged """ + if cls._logged_once: + return + logger.warning(message) + cls._logged_once = True + + def _get_pitch_yaw_roll(self) -> None: + """ Obtain the yaw, roll and pitch from the :attr:`_rotation` in eular angles. """ + proj_matrix = np.zeros((3, 4), dtype="float32") + proj_matrix[:3, :3] = cv2.Rodrigues(self._rotation)[0] + euler = cv2.decomposeProjectionMatrix(proj_matrix)[-1] + self._pitch_yaw_roll = T.cast(tuple[float, float, float], tuple(euler.squeeze())) + logger.trace("yaw_pitch: %s", self._pitch_yaw_roll) # type:ignore[attr-defined] + + @classmethod + def _get_camera_matrix(cls) -> np.ndarray: + """ Obtain an estimate of the camera matrix based off the original frame dimensions. + + Returns + ------- + :class:`numpy.ndarray` + An estimated camera matrix + """ + focal_length = 4 + camera_matrix = np.array([[focal_length, 0, 0.5], + [0, focal_length, 0.5], + [0, 0, 1]], dtype="double") + logger.trace("camera_matrix: %s", camera_matrix) # type:ignore[attr-defined] + return camera_matrix + + def _solve_pnp(self, landmarks: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """ Solve the Perspective-n-Point for the given landmarks. + + Takes 2D landmarks in world space and estimates the rotation and translation vectors + in 3D space. + + Parameters + ---------- + landmarks: :class:`numpy.ndarry` + The original 68 point landmark co-ordinates relating to the original frame + + Returns + ------- + rotation: :class:`numpy.ndarray` + The solved rotation vector + translation: :class:`numpy.ndarray` + The solved translation vector + """ + if self._landmarks_type != LandmarkType.LM_2D_68: + points: np.ndarray = np.empty([]) + rotation = np.array([[0.0], [0.0], [0.0]]) + translation = rotation.copy() + else: + points = landmarks[[6, 7, 8, 9, 10, 17, 21, 22, 26, 31, 32, 33, 34, + 35, 36, 39, 42, 45, 48, 50, 51, 52, 54, 56, 57, 58]] + _, rotation, translation = cv2.solvePnP(_MEAN_FACE[LandmarkType.LM_3D_26], + points, + self._camera_matrix, + self._distortion_coefficients, + flags=cv2.SOLVEPNP_ITERATIVE) + logger.trace("points: %s, rotation: %s, translation: %s", # type:ignore[attr-defined] + points, rotation, translation) + return rotation, translation + + def _get_offset(self) -> dict[CenteringType, np.ndarray]: + """ Obtain the offset between the original center of the extracted face to the new center + of the head in 2D space. + + Returns + ------- + :class:`numpy.ndarray` + The x, y offset of the new center from the old center. + """ + offset: dict[CenteringType, np.ndarray] = {"legacy": np.array([0.0, 0.0])} + if self._landmarks_type != LandmarkType.LM_2D_68: + offset["face"] = np.array([0.0, 0.0]) + offset["head"] = np.array([0.0, 0.0]) + else: + points: dict[T.Literal["face", "head"], tuple[float, ...]] = {"head": (0.0, 0.0, -2.3), + "face": (0.0, -1.5, 4.2)} + for key, pnts in points.items(): + center = cv2.projectPoints(np.array([pnts]).astype("float32"), + self._rotation, + self._translation, + self._camera_matrix, + self._distortion_coefficients)[0].squeeze() + logger.trace("center %s: %s", key, center) # type:ignore[attr-defined] + offset[key] = center - np.array([0.5, 0.5]) + logger.trace("offset: %s", offset) # type:ignore[attr-defined] + return offset diff --git a/lib/cli/args_extract_convert.py b/lib/cli/args_extract_convert.py index ed58a53e29..61c60b9b0f 100644 --- a/lib/cli/args_extract_convert.py +++ b/lib/cli/args_extract_convert.py @@ -140,7 +140,9 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "other GPU detectors but can often return more false positives." "\nL|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " "fewer false positives than other GPU detectors, but is a lot more resource " - "intensive.")}) + "intensive." + "\nL|external: Import a face detection bounding box from a json file. (" + "configurable in Detect settings)")}) argument_list.append({ "opts": ("-A", "--aligner"), "action": Radio, @@ -152,7 +154,9 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "R|Aligner to use." "\nL|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, but " "less accurate. Only use this if not using a GPU and time is important." - "\nL|fan: Best aligner. Fast on GPU, slow on CPU.")}) + "\nL|fan: Best aligner. Fast on GPU, slow on CPU." + "\nL|external: Import 68 point 2D landmarks or an aligned bounding box from a " + "json file. (configurable in Align settings)")}) argument_list.append({ "opts": ("-M", "--masker"), "action": MultiOption, diff --git a/lib/image.py b/lib/image.py index 7a9d1f9ad1..7d41a5a612 100644 --- a/lib/image.py +++ b/lib/image.py @@ -1253,7 +1253,8 @@ def _from_video(self): reader.close() def _dummy_video_framename(self, index): - """ Return a dummy filename for video files + """ Return a dummy filename for video files. The file name is made up of: + _. Parameters ---------- @@ -1268,8 +1269,8 @@ def _dummy_video_framename(self, index): Returns ------- str: A dummied filename for a video frame """ - vidname = os.path.splitext(os.path.basename(self.location))[0] - return "{}_{:06d}.png".format(vidname, index + 1) + vidname, ext = os.path.splitext(os.path.basename(self.location)) + return f"{vidname}_{index + 1:06d}{ext}" def _from_folder(self): """ Generator for loading images from a folder @@ -1565,6 +1566,7 @@ def _save(self, with open(filename, "wb") as out_file: out_file.write(image) else: + assert isinstance(image, np.ndarray) cv2.imwrite(filename, image) logger.trace("Saved image: '%s'", filename) # type:ignore except Exception as err: # pylint:disable=broad-except diff --git a/lib/training/cache.py b/lib/training/cache.py index 9813199949..8915f79ec6 100644 --- a/lib/training/cache.py +++ b/lib/training/cache.py @@ -11,8 +11,7 @@ import numpy as np from tqdm import tqdm -from lib.align import DetectedFace -from lib.align.aligned_face import CenteringType +from lib.align import CenteringType, DetectedFace, LandmarkType from lib.image import read_image_batch, read_image_meta_batch from lib.utils import FaceswapError @@ -280,6 +279,11 @@ def pre_fill(self, filenames: list[str], side: T.Literal["a", "b"]) -> None: The list of full paths to the images to load the metadata from side: str `"a"` or `"b"`. The side of the model being cached. Used for info output + + Raises + ------ + FaceSwapError + If unsupported landmark type exists """ with self._lock: for filename, meta in tqdm(read_image_meta_batch(filenames), @@ -294,6 +298,13 @@ def pre_fill(self, filenames: list[str], side: T.Literal["a", "b"]) -> None: # Version Check self._validate_version(meta, filename) detected_face = self._load_detected_face(filename, meta["alignments"]) + + aligned = detected_face.aligned + assert aligned is not None + if aligned.landmark_type != LandmarkType.LM_2D_68: + raise FaceswapError("68 Point facial Landmarks are required for Warp-to-" + f"landmarks. The face that failed was: '{filename}'") + self._cache[key] = detected_face self._partially_loaded.append(key) @@ -421,11 +432,14 @@ def _get_face_mask(self, filename: str, detected_face: DetectedFace) -> np.ndarr return None if self._config["mask_type"] not in detected_face.mask: + exist_masks = list(detected_face.mask) + msg = "No masks exist for this face" + if exist_masks: + msg = f"The masks that exist for this face are: {exist_masks}" raise FaceswapError( f"You have selected the mask type '{self._config['mask_type']}' but at least one " "face does not contain the selected mask.\n" - f"The face that failed was: '{filename}'\n" - f"The masks that exist for this face are: {list(detected_face.mask)}") + f"The face that failed was: '{filename}'\n{msg}") mask = detected_face.mask[str(self._config["mask_type"])] assert isinstance(self._config["mask_dilation"], float) @@ -469,7 +483,12 @@ def _get_localized_mask(self, assert isinstance(multiplier, int) if not self._config["penalized_mask_loss"] or multiplier <= 1: return None - mask = detected_face.get_landmark_mask(area, self._size // 16, 2.5) + try: + mask = detected_face.get_landmark_mask(area, self._size // 16, 2.5) + except FaceswapError as err: + logger.error(str(err)) + raise FaceswapError("Eye/Mouth multiplier masks could not be generated due to missing " + f"landmark data. The file that failed was: '{filename}'") from err logger.trace("Caching localized '%s' mask for: %s %s", # type: ignore area, filename, mask.shape) return mask diff --git a/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.mo b/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.mo index 70acb02db09c2848aed6891b43859a4977d3f793..5ccd0c2f2106fbffadd06ce06cbd8049936ce9e9 100644 GIT binary patch delta 1630 zcmb7@Z%kEX9LK*96`)9f`6oyo6BAGnE=I-*C9#A6OS4us<#_M8c<^!#oqMh_qwaG3 zb#A&Tt3_)K-!<8CclDxL%xU(*w(3P|wX9dQwbhH()~w9+IrosxjW<2xdq2S(4~Ux_mxExqQy`RI#nQTg7@CQ4{b}4lwqs~@~4>_nSk;>sgcmN)T0W2()E=pM)pmPfYJLXBvY>1U*O zvD8Q6>&v96Lg~j^=`#{vxkGxH`5h~y-kI>>{|@{L+2=b6MrgLux68lTk7nV z4iP^JLkqv&%(K9P2f01-uWgAwbf`xu-K4}A7iye0S{k})u*50U70_#TY7`}R|hFvo~mw`L#fWk zQ{0}sk4L3<$vc~tUVuR$v6cRW+hJcOs-?Fxyg!%FWd=^d&G_>j2mTDN!1H5KHGgtY z+ROamXQC(h9a0kd3zDWfbUO+!QIGCK)M37`LEg=37_MnWHHh-jGS1Kcd)x-sB7>Hr z)hK-XuoXwps#%u8r3eE=ZK?|W*XGlq@KuYJ%nmM`WmqeVX8R#lqJD~Aji?2MEry!% zz32y5uWKuaa9p^YZw>r5qXnoeXBX91XG{CW?Xkf2T`SdLdPYV)KQQg<&8X+Nfr)jS zl;tKyEPr3xc)qb*W2KxS*G`x{UdBy0?hu_Zlk~k2W0~Q!=bEIGvYSnP+;fx8P{z0R zq-^83rYl@$(smHADBYlK($8!_4?S=MSA7PoK6}L?y%nBSf{=X;Y_YbZenY*xj z<&xuWN1|+9Q)|q$#yVPJt6N%gKXm+U_FeY{PTmH~&l{0(11}vq(eEYbBy8i`N!z#G zxMLa5WLy)^SP9=_CgqGcft3i2N;$4gFH9%H^5c7*{hsOFzICAa_|t=RC9?@uL+;z1 zFBU#A$MN}vSGSIQh&yLtCGkJgfi! delta 1491 zcmYk6U2IfE6vzK9RVz|~7Ar_;r`2k=e6?K-wdJcK;zz4ujRvs5?e1;&Hn;cIy>~Iy z;06>S(Wt>7A_iFO71AcyKZ}l*sgiPxd61v3+#s!nQ}AQhe4}(fya0FN zS96ne7CWexMqu)0X)b&pz5q|a7`Duoew6Y$j53db+%3`yHh2yGgZ;@IbnMeL=-9pN zvJFndJRD%rQFtDvpf_Jyg#UTi4S%VXs-amRC1Bz<=>hx(>m_2;yYOqf(=n6*M#gTJ zE;8|1qqL?{db3G7hkdSD+Ryxv77k~l=5}sKcIuXKTln2d=JETvt8~MFRBo^~d#NJOSGtEnVOOT#x_e$JiSl?3dQT=>hWp z3`*w{6dVf_CoUgf69fmhto? zUj$6ND7_93Li;q0FO`a_72-bA=Fn4%YA%_Y4ec|{MW{_RBFi{mbqK*~F=E?tgjy08 zpJ|4^1Gx)Xge*mFMOGqrBX=U#+6t5e(spg&e{4nXMCKuPAk@3^)pYiaDR8wQWO1e~ zr02`K-pH!%DO<@hmt3DMWud8T^Fhu0msqpCJZ7JDfz|tRaX9Y8Udr+PNmF>Xs(x`{ zs%dRyVSh{O!u+=Wjhp&5Y#B07XS~RS0WN{-rXv%FX4o~6>$|DgO&f2-IL3EJoz$cm z57Vx1a36NPVAS}blg1;=TBkVUW?kbT<2gU}CVUj@9vLsNljX%Dp`UiM3g0=|ld4HB z@9H+4-94Sjr5zoGCB1){!l~!>RTj?dNH+Wz;9#-fhsKSFFXcu~)(cC~rMxh3vP{H| z2`5rsu@IQZW6lm0KQJjLJL&}K(By*B;i0iHrX7ax%Q%x}m)$KY&ql@#jPKZW6K>q# Vm~=VlFjD0BUfPi}mWyIH(!ZTZ1Z4mK diff --git a/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.po b/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.po index 290d476d2f..4b2e299256 100755 --- a/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.po +++ b/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 18:11+0000\n" -"PO-Revision-Date: 2024-03-28 18:13+0000\n" +"POT-Creation-Date: 2024-04-12 11:56+0100\n" +"PO-Revision-Date: 2024-04-12 12:02+0100\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es\n" @@ -20,7 +20,7 @@ msgstr "" #: lib/cli/args_extract_convert.py:46 lib/cli/args_extract_convert.py:56 #: lib/cli/args_extract_convert.py:64 lib/cli/args_extract_convert.py:122 -#: lib/cli/args_extract_convert.py:479 lib/cli/args_extract_convert.py:488 +#: lib/cli/args_extract_convert.py:483 lib/cli/args_extract_convert.py:492 msgid "Data" msgstr "Datos" @@ -65,12 +65,12 @@ msgstr "" "varios videos y/o carpetas de imágenes de las que desea extraer. Las caras " "se enviarán a subcarpetas separadas en output_dir." -#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:150 -#: lib/cli/args_extract_convert.py:163 lib/cli/args_extract_convert.py:202 -#: lib/cli/args_extract_convert.py:220 lib/cli/args_extract_convert.py:233 -#: lib/cli/args_extract_convert.py:243 lib/cli/args_extract_convert.py:253 -#: lib/cli/args_extract_convert.py:499 lib/cli/args_extract_convert.py:525 -#: lib/cli/args_extract_convert.py:564 +#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:152 +#: lib/cli/args_extract_convert.py:167 lib/cli/args_extract_convert.py:206 +#: lib/cli/args_extract_convert.py:224 lib/cli/args_extract_convert.py:237 +#: lib/cli/args_extract_convert.py:247 lib/cli/args_extract_convert.py:257 +#: lib/cli/args_extract_convert.py:503 lib/cli/args_extract_convert.py:529 +#: lib/cli/args_extract_convert.py:568 msgid "Plugins" msgstr "Extensiones" @@ -84,7 +84,9 @@ msgid "" "than other GPU detectors but can often return more false positives.\n" "L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " "fewer false positives than other GPU detectors, but is a lot more resource " -"intensive." +"intensive.\n" +"L|external: Import a face detection bounding box from a json file. " +"(configurable in Detect settings)" msgstr "" "R|Detector de caras a usar. Algunos tienen ajustes configurables en '/config/" "extract.ini' o 'Ajustes > Configurar Extensiones de Extracción:\n" @@ -95,21 +97,28 @@ msgstr "" "positivos.\n" "L|s3fd: El mejor detector. Lento en la CPU, y más rápido en la GPU. Puede " "detectar más caras y tiene menos falsos positivos que otros detectores " -"basados en GPU, pero uso muchos más recursos." +"basados en GPU, pero uso muchos más recursos.\n" +"L|external: importe un cuadro de detección de detección de cara desde un " +"archivo JSON. (configurable en la configuración de detección)" -#: lib/cli/args_extract_convert.py:152 +#: lib/cli/args_extract_convert.py:154 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " "but less accurate. Only use this if not using a GPU and time is important.\n" -"L|fan: Best aligner. Fast on GPU, slow on CPU." +"L|fan: Best aligner. Fast on GPU, slow on CPU.\n" +"L|external: Import 68 point 2D landmarks or an aligned bounding box from a " +"json file. (configurable in Align settings)" msgstr "" "R|Alineador a usar.\n" "L|cv2-dnn: Detector que usa sólo la CPU. Más rápido, usa menos recursos, " "pero es menos preciso. Elegir este si necesita rapidez y no usar la GPU.\n" -"L|fan: El mejor alineador. Rápido en la GPU, y lento en la CPU." +"L|fan: El mejor alineador. Rápido en la GPU, y lento en la CPU.\n" +"L|external: importar 68 puntos 2D Modos de referencia o un cuadro " +"delimitador alineado de un archivo JSON. (configurable en la configuración " +"alineada)" -#: lib/cli/args_extract_convert.py:165 +#: lib/cli/args_extract_convert.py:169 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -179,7 +188,7 @@ msgstr "" "referencia y la máscara se extiende hacia arriba en la frente.\n" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -#: lib/cli/args_extract_convert.py:204 +#: lib/cli/args_extract_convert.py:208 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -202,7 +211,7 @@ msgstr "" "L|hist: Iguala los histogramas de los canales RGB.\n" "L|mean: Normalizar los colores de la cara a la media." -#: lib/cli/args_extract_convert.py:222 +#: lib/cli/args_extract_convert.py:226 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -219,7 +228,7 @@ msgstr "" "más veces se vuelva a introducir la cara en el alineador, menos " "microfluctuaciones se producirán, pero la extracción será más larga." -#: lib/cli/args_extract_convert.py:235 +#: lib/cli/args_extract_convert.py:239 msgid "" "Re-feed the initially found aligned face through the aligner. Can help " "produce better alignments for faces that are rotated beyond 45 degrees in " @@ -230,7 +239,7 @@ msgstr "" "se giran más de 45 grados en el marco o se encuentran en ángulos extremos. " "Ralentiza la extracción." -#: lib/cli/args_extract_convert.py:245 +#: lib/cli/args_extract_convert.py:249 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -242,7 +251,7 @@ msgstr "" "un solo número para usar incrementos de ese tamaño hasta 360, o pase una " "lista de números para enumerar exactamente qué ángulos comprobar." -#: lib/cli/args_extract_convert.py:255 +#: lib/cli/args_extract_convert.py:259 msgid "" "Obtain and store face identity encodings from VGGFace2. Slows down extract a " "little, but will save time if using 'sort by face'" @@ -250,15 +259,15 @@ msgstr "" "Obtenga y almacene codificaciones de identidad facial de VGGFace2. Ralentiza " "un poco la extracción, pero ahorrará tiempo si usa 'sort by face'" -#: lib/cli/args_extract_convert.py:265 lib/cli/args_extract_convert.py:276 -#: lib/cli/args_extract_convert.py:289 lib/cli/args_extract_convert.py:303 -#: lib/cli/args_extract_convert.py:610 lib/cli/args_extract_convert.py:619 -#: lib/cli/args_extract_convert.py:634 lib/cli/args_extract_convert.py:647 -#: lib/cli/args_extract_convert.py:661 +#: lib/cli/args_extract_convert.py:269 lib/cli/args_extract_convert.py:280 +#: lib/cli/args_extract_convert.py:293 lib/cli/args_extract_convert.py:307 +#: lib/cli/args_extract_convert.py:614 lib/cli/args_extract_convert.py:623 +#: lib/cli/args_extract_convert.py:638 lib/cli/args_extract_convert.py:651 +#: lib/cli/args_extract_convert.py:665 msgid "Face Processing" msgstr "Proceso de Caras" -#: lib/cli/args_extract_convert.py:267 +#: lib/cli/args_extract_convert.py:271 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -267,7 +276,7 @@ msgstr "" "a lo largo de la diagonal del cuadro delimitador. Establecer a 0 para " "desactivar" -#: lib/cli/args_extract_convert.py:278 +#: lib/cli/args_extract_convert.py:282 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -280,7 +289,7 @@ msgstr "" "contenga las imágenes requeridas o múltiples archivos de imágenes, separados " "por espacios." -#: lib/cli/args_extract_convert.py:291 +#: lib/cli/args_extract_convert.py:295 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -293,7 +302,7 @@ msgstr "" "contenga las imágenes requeridas o múltiples archivos de imágenes, separados " "por espacios." -#: lib/cli/args_extract_convert.py:305 +#: lib/cli/args_extract_convert.py:309 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." @@ -301,12 +310,12 @@ msgstr "" "Para usar con los archivos nfilter/filter opcionales. Umbral para el " "reconocimiento facial positivo. Los valores más altos son más estrictos." -#: lib/cli/args_extract_convert.py:314 lib/cli/args_extract_convert.py:327 -#: lib/cli/args_extract_convert.py:340 lib/cli/args_extract_convert.py:352 +#: lib/cli/args_extract_convert.py:318 lib/cli/args_extract_convert.py:331 +#: lib/cli/args_extract_convert.py:344 lib/cli/args_extract_convert.py:356 msgid "output" msgstr "salida" -#: lib/cli/args_extract_convert.py:316 +#: lib/cli/args_extract_convert.py:320 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -316,7 +325,7 @@ msgstr "" "pretende entrenar admite el tamaño deseado. Esto sólo tendrá que ser " "cambiado para los modelos de alta resolución." -#: lib/cli/args_extract_convert.py:329 +#: lib/cli/args_extract_convert.py:333 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -326,7 +335,7 @@ msgstr "" "extraer las caras. Por ejemplo, un valor de 1 extraerá las caras de cada " "fotograma, un valor de 10 extraerá las caras de cada 10 fotogramas." -#: lib/cli/args_extract_convert.py:342 +#: lib/cli/args_extract_convert.py:346 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -342,20 +351,19 @@ msgstr "" "ADVERTENCIA: No interrumpa el script al escribir el archivo porque podría " "corromperse. Poner a 0 para desactivar" -#: lib/cli/args_extract_convert.py:353 +#: lib/cli/args_extract_convert.py:357 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" "Dibujar puntos de referencia en las caras de salida para fines de depuración." -#: lib/cli/args_extract_convert.py:359 lib/cli/args_extract_convert.py:369 -#: lib/cli/args_extract_convert.py:377 lib/cli/args_extract_convert.py:384 -#: lib/cli/args_extract_convert.py:674 lib/cli/args_extract_convert.py:686 -#: lib/cli/args_extract_convert.py:695 lib/cli/args_extract_convert.py:716 -#: lib/cli/args_extract_convert.py:722 +#: lib/cli/args_extract_convert.py:363 lib/cli/args_extract_convert.py:373 +#: lib/cli/args_extract_convert.py:381 lib/cli/args_extract_convert.py:388 +#: lib/cli/args_extract_convert.py:678 lib/cli/args_extract_convert.py:691 +#: lib/cli/args_extract_convert.py:712 lib/cli/args_extract_convert.py:718 msgid "settings" msgstr "ajustes" -#: lib/cli/args_extract_convert.py:361 +#: lib/cli/args_extract_convert.py:365 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the same time. " @@ -365,7 +373,7 @@ msgstr "" "extracción por separado (una tras otra) en lugar de hacerlo todo al mismo " "tiempo. Útil si la VRAM es escasa." -#: lib/cli/args_extract_convert.py:371 +#: lib/cli/args_extract_convert.py:375 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -373,19 +381,19 @@ msgstr "" "Omite los fotogramas que ya han sido extraídos y que existen en el archivo " "de alineaciones" -#: lib/cli/args_extract_convert.py:378 +#: lib/cli/args_extract_convert.py:382 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" "Omitir los fotogramas que ya tienen caras detectadas en el archivo de " "alineaciones" -#: lib/cli/args_extract_convert.py:385 +#: lib/cli/args_extract_convert.py:389 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "No guardar las caras detectadas en el disco. Crear sólo un archivo de " "alineaciones" -#: lib/cli/args_extract_convert.py:459 +#: lib/cli/args_extract_convert.py:463 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -395,7 +403,7 @@ msgstr "" "Los plugins de conversión pueden ser configurados en el menú " "\"Configuración\"" -#: lib/cli/args_extract_convert.py:481 +#: lib/cli/args_extract_convert.py:485 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -405,7 +413,7 @@ msgstr "" "original del que se extrajeron los fotogramas de origen (para extraer los " "fps y el audio)." -#: lib/cli/args_extract_convert.py:490 +#: lib/cli/args_extract_convert.py:494 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -413,7 +421,7 @@ msgstr "" "Directorio del modelo. El directorio que contiene el modelo entrenado que " "desea utilizar para la conversión." -#: lib/cli/args_extract_convert.py:501 +#: lib/cli/args_extract_convert.py:505 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -453,7 +461,7 @@ msgstr "" "colores. Generalmente no da resultados muy satisfactorios.\n" "L|none: No realice el ajuste de color." -#: lib/cli/args_extract_convert.py:527 +#: lib/cli/args_extract_convert.py:531 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -529,7 +537,7 @@ msgstr "" "L|predicted: Si la opción 'Learn Mask' se habilitó durante el entrenamiento, " "esto usará la máscara que fue creada por el modelo entrenado." -#: lib/cli/args_extract_convert.py:566 +#: lib/cli/args_extract_convert.py:570 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -562,12 +570,12 @@ msgstr "" "L|pillow: [images] Más lento que opencv, pero tiene más opciones y soporta " "más formatos." -#: lib/cli/args_extract_convert.py:587 lib/cli/args_extract_convert.py:596 -#: lib/cli/args_extract_convert.py:707 +#: lib/cli/args_extract_convert.py:591 lib/cli/args_extract_convert.py:600 +#: lib/cli/args_extract_convert.py:703 msgid "Frame Processing" msgstr "Proceso de fotogramas" -#: lib/cli/args_extract_convert.py:589 +#: lib/cli/args_extract_convert.py:593 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -577,7 +585,7 @@ msgstr "" "a los fotogramas a las dimensiones de origen. 50%% a la mitad de tamaño. " "200%% al doble de tamaño" -#: lib/cli/args_extract_convert.py:598 +#: lib/cli/args_extract_convert.py:602 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -591,7 +599,7 @@ msgstr "" "imágenes, ¡los nombres de los archivos deben terminar con el número de " "fotograma!" -#: lib/cli/args_extract_convert.py:612 +#: lib/cli/args_extract_convert.py:616 msgid "" "Scale the swapped face by this percentage. Positive values will enlarge the " "face, Negative values will shrink the face." @@ -599,7 +607,7 @@ msgstr "" "Escale la cara intercambiada según este porcentaje. Los valores positivos " "agrandarán la cara, los valores negativos la reducirán." -#: lib/cli/args_extract_convert.py:621 +#: lib/cli/args_extract_convert.py:625 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -615,7 +623,7 @@ msgstr "" "especificada. Si se deja en blanco, se convertirán todas las caras que " "existan en el archivo de alineaciones." -#: lib/cli/args_extract_convert.py:636 +#: lib/cli/args_extract_convert.py:640 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -629,7 +637,7 @@ msgstr "" "uso del filtro de caras disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args_extract_convert.py:649 +#: lib/cli/args_extract_convert.py:653 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -643,7 +651,7 @@ msgstr "" "del filtro facial disminuirá significativamente la velocidad de extracción y " "no se puede garantizar su precisión." -#: lib/cli/args_extract_convert.py:663 +#: lib/cli/args_extract_convert.py:667 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -655,7 +663,7 @@ msgstr "" "NB: El uso del filtro facial disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args_extract_convert.py:676 +#: lib/cli/args_extract_convert.py:680 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -672,15 +680,7 @@ msgstr "" "procesos que los disponibles en su sistema. Si 'singleprocess' está " "habilitado, este ajuste será ignorado." -#: lib/cli/args_extract_convert.py:688 -msgid "" -"[LEGACY] This only needs to be selected if a legacy model is being loaded or " -"if there are multiple models in the model folder" -msgstr "" -"[LEGACY] Sólo es necesario seleccionar esta opción si se está cargando un " -"modelo heredado si hay varios modelos en la carpeta de modelos" - -#: lib/cli/args_extract_convert.py:697 +#: lib/cli/args_extract_convert.py:693 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -695,7 +695,7 @@ msgstr "" "de baja calidad. Si se encuentra un archivo de alineaciones, esta opción " "será ignorada." -#: lib/cli/args_extract_convert.py:709 +#: lib/cli/args_extract_convert.py:705 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -703,11 +703,18 @@ msgstr "" "Cuando se usa con --frame-ranges, la salida incluye los fotogramas no " "procesados en vez de descartarlos." -#: lib/cli/args_extract_convert.py:717 +#: lib/cli/args_extract_convert.py:713 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Intercambiar el modelo. En vez de convertir de A a B, convierte de B a A" -#: lib/cli/args_extract_convert.py:723 +#: lib/cli/args_extract_convert.py:719 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Desactiva el multiproceso. Es más lento, pero usa menos recursos." + +#~ msgid "" +#~ "[LEGACY] This only needs to be selected if a legacy model is being loaded " +#~ "or if there are multiple models in the model folder" +#~ msgstr "" +#~ "[LEGACY] Sólo es necesario seleccionar esta opción si se está cargando un " +#~ "modelo heredado si hay varios modelos en la carpeta de modelos" diff --git a/locales/es/LC_MESSAGES/tools.alignments.cli.mo b/locales/es/LC_MESSAGES/tools.alignments.cli.mo index 9aa60ed0c98cded8c8f14625010b9ae422c6b8c9..f6786ef695682f93b758135d47b8af8579416fc5 100644 GIT binary patch delta 1242 zcmY*Xzi$*r6n;*?NsJH?U=$1_uZVIW?2FGJBrLhGA|iqOqrj1+h!oGeN#N{IwY12p$1xC%v@R1uxYydgHuNPf_3MNf zwfd#cpX2!q&oG`eYWhJI8tMN&w0Yo6;e2dN^OZa4z;R7+=4@0p26n64vmYPtMaRN7 zgR{J6-vaM}s@`8(MnmZD1)dh8q#yW0r zS7p)p!0Xx=wk{^B+GeKAK^J7wgsK`g(Z@jJ99$iZNKk4ktGq;fT~8!q4+;CzvkqKy z#`hJ;ZlNUf`JZ!>XVEp6C@FBi&AN_jRb%Hh==0ghah^QKQtKUE!A7k{=w0hCwkx4I zGIy^5<$Qm1_}3$cFHW?kxHUE1x_ExF)qgVj!|+!hI4AJI46M_+<6LdejGgWYmzKq6 zYr59yOcs#C8(E;(%HP`TbSrS?1bv4Z%UeIaUNBzNNs5!pDtU8pk$Cl~`E!etOceYEUgDG^(jCOVQ*dmxGo`lSy>*A;k^a*mkH3T2_Z1|_|DyVT0(0wD|=2)^l>{WquPkG=)=CV3A4 delta 326 zcmXBPze|Ea9LMqZ6XuUhD#adas4xji9yuRLP;kgah}@DuY7>N@Xw}8~1B$@K{szZq zYiTu?hG_0DNE#daoF3f0?tAyWyYD@8rkBs1K`{d9WjJu4u?lw~xJ^J4_}+j|=_NHR zfs+E?PSc4$>1$kSS)1rnj diff --git a/locales/es/LC_MESSAGES/tools.alignments.cli.po b/locales/es/LC_MESSAGES/tools.alignments.cli.po index 8560969250..6f116c4834 100644 --- a/locales/es/LC_MESSAGES/tools.alignments.cli.po +++ b/locales/es/LC_MESSAGES/tools.alignments.cli.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 23:49+0000\n" -"PO-Revision-Date: 2024-03-29 00:02+0000\n" +"POT-Creation-Date: 2024-04-12 12:10+0100\n" +"PO-Revision-Date: 2024-04-12 12:14+0100\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es_ES\n" @@ -65,17 +65,23 @@ msgstr "" msgid " Use the output option (-o) to process results." msgstr " Usar la opción de salida (-o) para procesar los resultados." -#: tools/alignments/cli.py:57 tools/alignments/cli.py:97 +#: tools/alignments/cli.py:58 tools/alignments/cli.py:104 msgid "processing" msgstr "proceso" -#: tools/alignments/cli.py:60 +#: tools/alignments/cli.py:61 #, python-brace-format msgid "" "R|Choose which action you want to perform. NB: All actions require an " "alignments file (-a) to be passed in.\n" "L|'draw': Draw landmarks on frames in the selected folder/video. A subfolder " "will be created within the frames folder to hold the output.{0}\n" +"L|'export': Export the contents of an alignments file to a json file. Can be " +"used for editing alignment information in external tools and then re-" +"importing by using Faceswap's Extract 'Import' plugins. Note: masks and " +"identity vectors will not be included in the exported file, so will be re-" +"generated when the json file is imported back into Faceswap. All data is " +"exported with the origin (0, 0) at the top left of the canvas.\n" "L|'extract': Re-extract faces from the source frames/video based on " "alignment data. This is a lot quicker than re-detecting faces. Can pass in " "the '-een' (--extract-every-n) parameter to only extract every nth frame." @@ -110,6 +116,14 @@ msgstr "" "L|'draw': Dibuja puntos de referencia en los fotogramas de la carpeta o " "vídeo seleccionado. Se creará una subcarpeta dentro de la carpeta de " "fotogramas para guardar el resultado.{0}\n" +"L|'export': Exportar el contenido de un archivo de alineaciones a un archivo " +"JSON. Se puede utilizar para editar información de alineación en " +"herramientas externas y luego volver a importar mediante el uso de " +"complementos de 'import' de extracto de Faceswap. Nota: Las máscaras y los " +"vectores de identidad no se incluirán en el archivo exportado, por lo que se " +"volverán a generar cuando el archivo JSON se importe a FacesWap. Todos los " +"datos se exportan con el origen (0, 0) en la parte superior izquierda del " +"lienzo.\n" "L|'extract': Reextrae las caras de los fotogramas o vídeos de origen " "basándose en los datos de alineación. Esto es mucho más rápido que volver a " "detectar las caras. Se puede pasar el parámetro '-een' (--extract-every-n) " @@ -142,7 +156,7 @@ msgstr "" "L|'spatial': Realiza un filtrado espacial y temporal para suavizar las " "alineaciones (¡EXPERIMENTAL!)" -#: tools/alignments/cli.py:100 +#: tools/alignments/cli.py:107 msgid "" "R|How to output discovered items ('faces' and 'frames' only):\n" "L|'console': Print the list of frames to the screen. (DEFAULT)\n" @@ -158,12 +172,12 @@ msgstr "" "L|'move': Mueve los elementos descubiertos a una subcarpeta dentro del " "directorio de origen." -#: tools/alignments/cli.py:111 tools/alignments/cli.py:134 -#: tools/alignments/cli.py:141 +#: tools/alignments/cli.py:118 tools/alignments/cli.py:141 +#: tools/alignments/cli.py:148 msgid "data" msgstr "datos" -#: tools/alignments/cli.py:118 +#: tools/alignments/cli.py:125 msgid "" "Full path to the alignments file to be processed. If you have input a " "'frames_dir' and don't provide this option, the process will try to find the " @@ -177,13 +191,13 @@ msgstr "" "requieren un archivo de alineaciones con la excepción de 'from-faces' cuando " "el archivo de alineaciones se generará en la carpeta de caras especificada." -#: tools/alignments/cli.py:135 +#: tools/alignments/cli.py:142 msgid "Directory containing source frames that faces were extracted from." msgstr "" "Directorio que contiene los fotogramas de origen de los que se extrajeron " "las caras." -#: tools/alignments/cli.py:143 +#: tools/alignments/cli.py:150 msgid "" "R|Run the aligmnents tool on multiple sources. The following jobs support " "batch mode:\n" @@ -226,12 +240,12 @@ msgstr "" "El archivo de alineaciones debe existir en la ubicación predeterminada. Para " "todos los demás trabajos, esta opción se ignora." -#: tools/alignments/cli.py:169 tools/alignments/cli.py:181 -#: tools/alignments/cli.py:191 +#: tools/alignments/cli.py:176 tools/alignments/cli.py:188 +#: tools/alignments/cli.py:198 msgid "extract" msgstr "extracción" -#: tools/alignments/cli.py:171 +#: tools/alignments/cli.py:178 msgid "" "[Extract only] Extract every 'nth' frame. This option will skip frames when " "extracting faces. For example a value of 1 will extract faces from every " @@ -242,11 +256,11 @@ msgstr "" "caras de cada fotograma, un valor de 10 extraerá las caras de cada 10 " "fotogramas." -#: tools/alignments/cli.py:182 +#: tools/alignments/cli.py:189 msgid "[Extract only] The output size of extracted faces." msgstr "[Sólo extracción] El tamaño de salida de las caras extraídas." -#: tools/alignments/cli.py:193 +#: tools/alignments/cli.py:200 msgid "" "[Extract only] Only extract faces that have been resized by this percent or " "more to meet the specified extract size (`-sz`, `--size`). Useful for " diff --git a/locales/kr/LC_MESSAGES/lib.cli.args_extract_convert.mo b/locales/kr/LC_MESSAGES/lib.cli.args_extract_convert.mo index 93c9b7658acf51f3b443805757fb09f187f8b5cb..1f0c43722c48ec666523c447dd823fe02f1d811e 100644 GIT binary patch delta 1661 zcmb7^Urby@6o(I#R*Fz1q<5N`l7YnvUhRoa<}eYC^oBG zbfW}Fy9sP1>CzAcc@QFXrQv}FV@OO|wDH0CVA?l%;NFe#$*PI*yX-cmiN-kLcfUC^ zXU@!=IZO1nl<$8}3Eo+k7772HACdKNKnqwd6WF!8Qa5wyVok%*A9FbDEI#*;b=8p9u55Nw1(~k5m zohC*a^F$8eI9Mn$k}C33k;np$-J3-|Wd7I|kzbg~#IDHD)vN(>g_Hw&lW zk56+e#`~TXc?DjAcVHYo2N(8`Is7s)a^P3blTpThsN>-9b{N@aiUKnWS&@jD2#0lSkb}fC=5u|YFlv0|5&<{%qnAs`vEgRRI zOH}j90700)!Dkgb@J@ow5I5P19zoQul%Pk^T4ZO@Zn-EQQASdQ3J~QZh3vol^VkHR zKnfM3?a1E9ZuZ;T?Xd(T18zk2+iynM=w8dDL*Yvi+K?Q~Ns?cztWWlBY(q^Hy_6w} zc)8jC2g;Lu8)Wsqwgrm>30YolrF<*U8k7~&(n|7!8HWN|r=j_Dho@FGw4L$#3{|sR zo$NP`aiA9QPQni0NghRO;Et+!LUWS{% z|98jb_{QH`#%>mG+t9DqzMQqQysBDNRo7NlZm+D0-Ku@B`2I9B9(}P{#ryiKp;_yr zuoa5h8Hrzq^)hn^j9w=dIxxYjE1^?KXQyNl?<;3XRh1|Mh5Y?C@)OEB@;s)}`5{=`RQuw#Luj z+s2%VSUscvvdwb*KRILt>9Q3cqt#I!UL3k(V>2 zQ6?_Tm)0gr9of9}<$5=UkHW<( zrAqiJbT{x7d>5uXAf1n};CpmFhHF+sCaxDt^{|<|z72neWLJR_P6WenKK|Ws5cZZz zWL1@GrRU%a@EZIaJ_I|~kwJV{VLEJin9MSMtc*;OT^Y9ZDa?ll;V7Jp(c&2HtEt~0 zRT1xo8{khHrEZv0E`87Zmyh9v`yY?Ls$1|B@#!ZdKYaR0Hi!QbY=YA&*cfcBB>(kv zzIlq2V_f-6yjU9GP2y%a!jXNOr7V1_D1Z!7zWGJzH|BjWNtqmdYqhjGnH2jtEmeBv z6;h3_c&Bs`->ycf9TtZqZqa4vg$u)xcu~C{me{m@f!%QVYZMF%ehaVi;9GCRtNQCV zrMH-G-6idVDFl;XM}nOYd#q8vnvQbfp{R55(Zs$6YEzV%mhybfL~K@B$Zg9IwG?}& zZfsBiT8QSLCFo8>-RWMm08O-fI(g{+iGlxd5&c5szS6mfdRN3Ps9A^tS1#hvu{N8l zTN@LNtm?i*Kw-z)JqfySvyh#L`JiI|C2q*a5^Gbrrn|kea41;s3HfR~et(mRKA)PA z6}^$YE;+h4chT&~woMz$%hqqLGSAle8ci^OC16?JMiUB}?Ure@{8mlK@)}>Q@fg3g z!&B2_>Vsa(Z?JE-e1RRtAM|)}1RGpasLpDzj0e?+{UP5@KONWJXnX;8GI4Wl(C@Vx z6g>P{$(FRDWh;tJVR1>}swD*l(X`U$oaln8%#`Rr^@(}^ZP4i*9y_3zr8eQjey zgHC&ovCp2i4_t71Ta5itkA1kuIQ<{EzM(<8xjQjy#t#nL2aY&hhm6xTV1GF9?_I;j zIoW3KJ7UJpU2={O;;>KcH%?36_?}+-P`7h**y)^m(TsPVvD@41_I^3%d+feX 추출 플러" "그인 설정'에서 설정이 가능합니다:\n" @@ -93,21 +95,27 @@ msgstr "" "니다.\n" "L|s3fd: 가장 좋은 감지기. CPU에선 느리고 GPU에선 빠릅니다. 다른 GPU 감지기들" "보다 더 많은 얼굴들을 감지할 수 있고 과 더 적은 false positives를 돌려주지만 " -"자원을 굉장히 많이 사용합니다." +"자원을 굉장히 많이 사용합니다.\n" +"L|external: JSON 파일에서 얼굴 감지 경계 박스를 가져옵니다. (설정 감지에서 구" +"성 가능)" -#: lib/cli/args_extract_convert.py:152 +#: lib/cli/args_extract_convert.py:154 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " "but less accurate. Only use this if not using a GPU and time is important.\n" -"L|fan: Best aligner. Fast on GPU, slow on CPU." +"L|fan: Best aligner. Fast on GPU, slow on CPU.\n" +"L|external: Import 68 point 2D landmarks or an aligned bounding box from a " +"json file. (configurable in Align settings)" msgstr "" "R|사용할 Aligner.\n" "L|cv2-dnn: CPU만을 사용하는 특징점 감지기. 빠르고 자원을 덜 사용하지만 부정확" "합니다. GPU를 사용하지 않고 시간이 중요할 때에만 사용하세요.\n" -"L|fan: 가장 좋은 aligner. GPU에선 빠르고 CPU에선 느립니다." +"L|fan: 가장 좋은 aligner. GPU에선 빠르고 CPU에선 느립니다.\n" +"L|external: JSON 파일에서 68 포인트 2D 랜드 마크 또는 정렬 된 경계 상자를 가" +"져옵니다. (정렬 설정에서 구성 가능)" -#: lib/cli/args_extract_convert.py:165 +#: lib/cli/args_extract_convert.py:169 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -168,7 +176,7 @@ msgstr "" "로 뻗어 있습ㄴ다.\n" "(예: '-M unet-dfl vgg-clear', '--masker vgg-obstructed')" -#: lib/cli/args_extract_convert.py:204 +#: lib/cli/args_extract_convert.py:208 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -189,7 +197,7 @@ msgstr "" "L|hist: RGB 채널의 히스토그램을 동일하게 합니다.\n" "L|mean: 얼굴 색상을 평균으로 정규화합니다." -#: lib/cli/args_extract_convert.py:222 +#: lib/cli/args_extract_convert.py:226 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -204,7 +212,7 @@ msgstr "" "다. 얼굴이 aligner에 다시 공급되는 횟수가 많을수록 micro-jitter 적게 발생하지" "만 추출에 더 오랜 시간이 걸립니다." -#: lib/cli/args_extract_convert.py:235 +#: lib/cli/args_extract_convert.py:239 msgid "" "Re-feed the initially found aligned face through the aligner. Can help " "produce better alignments for faces that are rotated beyond 45 degrees in " @@ -214,7 +222,7 @@ msgstr "" "회전하거나 극단적인 각도에 있는 얼굴을 더 잘 정렬할 수 있습니다. 추출 속도가 " "느려집니다." -#: lib/cli/args_extract_convert.py:245 +#: lib/cli/args_extract_convert.py:249 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -225,7 +233,7 @@ msgstr "" "면서 더 많은 얼굴을 찾을 수 있습니다. 단일 숫자를 입력하여 해당 크기의 증분" "을 360까지 사용하거나 숫자 목록을 입력하여 확인할 각도를 정확하게 열거합니다." -#: lib/cli/args_extract_convert.py:255 +#: lib/cli/args_extract_convert.py:259 msgid "" "Obtain and store face identity encodings from VGGFace2. Slows down extract a " "little, but will save time if using 'sort by face'" @@ -233,15 +241,15 @@ msgstr "" "VGGFace2에서 얼굴 식별 인코딩을 가져와 저장합니다. 추출 속도를 약간 늦추지만 " "'얼굴별로 정렬'을 사용하면 시간을 절약할 수 있습니다." -#: lib/cli/args_extract_convert.py:265 lib/cli/args_extract_convert.py:276 -#: lib/cli/args_extract_convert.py:289 lib/cli/args_extract_convert.py:303 -#: lib/cli/args_extract_convert.py:610 lib/cli/args_extract_convert.py:619 -#: lib/cli/args_extract_convert.py:634 lib/cli/args_extract_convert.py:647 -#: lib/cli/args_extract_convert.py:661 +#: lib/cli/args_extract_convert.py:269 lib/cli/args_extract_convert.py:280 +#: lib/cli/args_extract_convert.py:293 lib/cli/args_extract_convert.py:307 +#: lib/cli/args_extract_convert.py:614 lib/cli/args_extract_convert.py:623 +#: lib/cli/args_extract_convert.py:638 lib/cli/args_extract_convert.py:651 +#: lib/cli/args_extract_convert.py:665 msgid "Face Processing" msgstr "얼굴 처리" -#: lib/cli/args_extract_convert.py:267 +#: lib/cli/args_extract_convert.py:271 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -249,7 +257,7 @@ msgstr "" "이 크기 미만으로 탐지된 얼굴을 필터링합니다. 길이, 경계 상자의 대각선에 걸친 " "픽셀 단위입니다. 0으로 설정하면 꺼집니다" -#: lib/cli/args_extract_convert.py:278 +#: lib/cli/args_extract_convert.py:282 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -261,7 +269,7 @@ msgstr "" "지들 또는 공백으로 구분된 여러 이미지 파일이 들어 있는 폴더를 선택할 수 있습" "니다." -#: lib/cli/args_extract_convert.py:291 +#: lib/cli/args_extract_convert.py:295 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -272,7 +280,7 @@ msgstr "" "와 조건이 다른 작은 다양한 이미지여야 합니다. 추출할 때 필요한 이미지들 또는 " "공백으로 구분된 여러 이미지 파일이 들어 있는 폴더를 선택할 수 있습니다." -#: lib/cli/args_extract_convert.py:305 +#: lib/cli/args_extract_convert.py:309 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." @@ -280,12 +288,12 @@ msgstr "" "옵션인 nfilter/filter 파일과 함께 사용합니다. 긍정적인 얼굴 인식을 위한 임계" "값. 값이 높을수록 엄격합니다." -#: lib/cli/args_extract_convert.py:314 lib/cli/args_extract_convert.py:327 -#: lib/cli/args_extract_convert.py:340 lib/cli/args_extract_convert.py:352 +#: lib/cli/args_extract_convert.py:318 lib/cli/args_extract_convert.py:331 +#: lib/cli/args_extract_convert.py:344 lib/cli/args_extract_convert.py:356 msgid "output" msgstr "출력" -#: lib/cli/args_extract_convert.py:316 +#: lib/cli/args_extract_convert.py:320 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -294,7 +302,7 @@ msgstr "" "추출된 얼굴의 출력 크기입니다. 훈련하려는 모델이 필요한 크기를 지원하는지 꼭 " "확인하세요. 이것은 고해상도 모델에 대해서만 변경하면 됩니다." -#: lib/cli/args_extract_convert.py:329 +#: lib/cli/args_extract_convert.py:333 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -304,7 +312,7 @@ msgstr "" "설정합니다. 예를 들어, 값이 1이면 모든 프레임에서 얼굴이 추출되고, 값이 10이" "면 모든 10번째 프레임에서 얼굴이 추출됩니다." -#: lib/cli/args_extract_convert.py:342 +#: lib/cli/args_extract_convert.py:346 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -319,19 +327,18 @@ msgstr "" "을 쓸 때 스크립트가 손상될 수 있으므로 스크립트를 중단하지 마십시오. 해제하려" "면 0으로 설정" -#: lib/cli/args_extract_convert.py:353 +#: lib/cli/args_extract_convert.py:357 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "디버깅을 위해 출력 얼굴에 특징점을 그립니다." -#: lib/cli/args_extract_convert.py:359 lib/cli/args_extract_convert.py:369 -#: lib/cli/args_extract_convert.py:377 lib/cli/args_extract_convert.py:384 -#: lib/cli/args_extract_convert.py:674 lib/cli/args_extract_convert.py:686 -#: lib/cli/args_extract_convert.py:695 lib/cli/args_extract_convert.py:716 -#: lib/cli/args_extract_convert.py:722 +#: lib/cli/args_extract_convert.py:363 lib/cli/args_extract_convert.py:373 +#: lib/cli/args_extract_convert.py:381 lib/cli/args_extract_convert.py:388 +#: lib/cli/args_extract_convert.py:678 lib/cli/args_extract_convert.py:691 +#: lib/cli/args_extract_convert.py:712 lib/cli/args_extract_convert.py:718 msgid "settings" msgstr "설정" -#: lib/cli/args_extract_convert.py:361 +#: lib/cli/args_extract_convert.py:365 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the same time. " @@ -341,22 +348,22 @@ msgstr "" "는 것이 아니라 개별적으로(하나씩) 실행합니다. VRAM이 프리미엄인 경우 유용합니" "다." -#: lib/cli/args_extract_convert.py:371 +#: lib/cli/args_extract_convert.py:375 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" msgstr "이미 추출되었거나 alignments 파일에 존재하는 프레임들을 스킵합니다" -#: lib/cli/args_extract_convert.py:378 +#: lib/cli/args_extract_convert.py:382 msgid "Skip frames that already have detected faces in the alignments file" msgstr "이미 얼굴을 탐지하여 alignments 파일에 존재하는 프레임들을 스킵합니다" -#: lib/cli/args_extract_convert.py:385 +#: lib/cli/args_extract_convert.py:389 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "탐지된 얼굴을 디스크에 저장하지 않습니다. 그저 alignments 파일을 만듭니다" -#: lib/cli/args_extract_convert.py:459 +#: lib/cli/args_extract_convert.py:463 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -364,7 +371,7 @@ msgstr "" "원본 비디오/이미지의 원래 얼굴을 최종 얼굴으로 바꿉니다.\n" "변환 플러그인은 '설정' 메뉴에서 구성할 수 있습니다" -#: lib/cli/args_extract_convert.py:481 +#: lib/cli/args_extract_convert.py:485 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -373,14 +380,14 @@ msgstr "" "이미지에서 비디오로 변환하는 경우에만 필요합니다. 소스 프레임이 추출된 원본 " "비디오(fps 및 오디오 추출용)를 입력하세요." -#: lib/cli/args_extract_convert.py:490 +#: lib/cli/args_extract_convert.py:494 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." msgstr "" "모델 폴더. 당신이 변환에 사용하고자 하는 훈련된 모델을 가진 폴더입니다." -#: lib/cli/args_extract_convert.py:501 +#: lib/cli/args_extract_convert.py:505 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -416,7 +423,7 @@ msgstr "" "공하지 않습니다.\n" "L|none: 색상 조정을 수행하지 않습니다." -#: lib/cli/args_extract_convert.py:527 +#: lib/cli/args_extract_convert.py:531 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -480,7 +487,7 @@ msgstr "" "L|predicted: 교육 중에 'Learn Mask(마스크 학습)' 옵션이 활성화된 경우에는 교" "육을 받은 모델이 만든 마스크가 사용됩니다." -#: lib/cli/args_extract_convert.py:566 +#: lib/cli/args_extract_convert.py:570 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -510,12 +517,12 @@ msgstr "" "L|pillow: [images] opencv보다 느리지만 더 많은 옵션이 있고 더 많은 형식을 지" "원합니다." -#: lib/cli/args_extract_convert.py:587 lib/cli/args_extract_convert.py:596 -#: lib/cli/args_extract_convert.py:707 +#: lib/cli/args_extract_convert.py:591 lib/cli/args_extract_convert.py:600 +#: lib/cli/args_extract_convert.py:703 msgid "Frame Processing" msgstr "프레임 처리" -#: lib/cli/args_extract_convert.py:589 +#: lib/cli/args_extract_convert.py:593 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -524,7 +531,7 @@ msgstr "" "최종 출력 프레임의 크기를 이 양만큼 조정합니다. 100%%는 원본의 차원에서 프레" "임을 출력합니다. 50%%는 절반 크기에서, 200%%는 두 배 크기에서" -#: lib/cli/args_extract_convert.py:598 +#: lib/cli/args_extract_convert.py:602 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -536,7 +543,7 @@ msgstr "" "으면 선택한 범위를 벗어나는 프레임이 삭제됩니다. NB: 이미지에서 변환하는 경" "우 파일 이름은 프레임 번호로 끝나야 합니다!" -#: lib/cli/args_extract_convert.py:612 +#: lib/cli/args_extract_convert.py:616 msgid "" "Scale the swapped face by this percentage. Positive values will enlarge the " "face, Negative values will shrink the face." @@ -544,7 +551,7 @@ msgstr "" "이 백분율로 교체된 면의 크기를 조정합니다. 양수 값은 얼굴을 확대하고, 음수 값" "은 얼굴을 축소합니다." -#: lib/cli/args_extract_convert.py:621 +#: lib/cli/args_extract_convert.py:625 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -558,7 +565,7 @@ msgstr "" "alignments 파일 내에 존재하거나 지정된 폴더 내에 존재하는 얼굴만 변환됩니다. " "이 항목을 공백으로 두면 alignments 파일 내에 있는 모든 얼굴이 변환됩니다." -#: lib/cli/args_extract_convert.py:636 +#: lib/cli/args_extract_convert.py:640 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -571,7 +578,7 @@ msgstr "" "분하여 추가할 수 있습니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감소" "하므로 정확성을 보장할 수 없습니다." -#: lib/cli/args_extract_convert.py:649 +#: lib/cli/args_extract_convert.py:653 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -584,7 +591,7 @@ msgstr "" "가할 수 있습니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감소하므로 정" "확성을 보장할 수 없습니다." -#: lib/cli/args_extract_convert.py:663 +#: lib/cli/args_extract_convert.py:667 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -595,7 +602,7 @@ msgstr "" "값. 낮은 값이 더 엄격합니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감" "소하므로 정확성을 보장할 수 없습니다." -#: lib/cli/args_extract_convert.py:676 +#: lib/cli/args_extract_convert.py:680 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -611,15 +618,7 @@ msgstr "" "를 사용하려고 시도하지 않습니다. 단일 프로세스가 활성화된 경우 이 설정은 무시" "됩니다." -#: lib/cli/args_extract_convert.py:688 -msgid "" -"[LEGACY] This only needs to be selected if a legacy model is being loaded or " -"if there are multiple models in the model folder" -msgstr "" -"[LEGACY] 이것은 레거시 모델을 로드 중이거나 모델 폴더에 여러 모델이 있는 경우" -"에만 선택되어야 합니다" - -#: lib/cli/args_extract_convert.py:697 +#: lib/cli/args_extract_convert.py:693 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -633,7 +632,7 @@ msgstr "" "하고 표준 이하의 결과로 이어질 것입니다. alignments 파일이 발견되면 이 옵션" "은 무시됩니다." -#: lib/cli/args_extract_convert.py:709 +#: lib/cli/args_extract_convert.py:705 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -641,10 +640,17 @@ msgstr "" "사용시 --frame-ranges 인자를 사용하면 변경되지 않은 프레임을 버리지 않은 결과" "가 출력됩니다." -#: lib/cli/args_extract_convert.py:717 +#: lib/cli/args_extract_convert.py:713 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "모델을 바꿉니다. A -> B에서 변환하는 대신 B -> A로 변환" -#: lib/cli/args_extract_convert.py:723 +#: lib/cli/args_extract_convert.py:719 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "멀티프로세싱을 쓰지 않습니다. 느리지만 자원을 덜 소모합니다." + +#~ msgid "" +#~ "[LEGACY] This only needs to be selected if a legacy model is being loaded " +#~ "or if there are multiple models in the model folder" +#~ msgstr "" +#~ "[LEGACY] 이것은 레거시 모델을 로드 중이거나 모델 폴더에 여러 모델이 있는 " +#~ "경우에만 선택되어야 합니다" diff --git a/locales/kr/LC_MESSAGES/tools.alignments.cli.mo b/locales/kr/LC_MESSAGES/tools.alignments.cli.mo index ec090bd06a601115a38d42b0eebb487d911f69a0..21920ec6dc38692a280a7a7172cb86d9c27a0b74 100644 GIT binary patch delta 1219 zcmYjPQD|FL82+oXYGq7um37YXKP?X1kd%U0@S#kF4V~aX!52AAZqw^-?+y2!E}ab1 ztr1ESZKMrtHJKSy+Kx=KrK@2?+}mEn7oQYF1o0u~TwnGezWJRbEgo|7oqPW8|Nrm* z&fR_gCs9mhe|v&x@G+uOeMAor6QvIk9X~?!77^v2CHfio3y>Zmx^7 z06q@<3pfD$8+a0R{sq2*_hZM1QowhB8Q?wO$H37cq6y&bA%yQ@^AHE7;m&cQ4^ZGA z;20{tcLERI3#W*FMaMrQL^gOo3=<9E{PYNT5OyX?^eWEhVnj~@_kjJt`#pRRTRGQv zGL92^%KTwr)Xp;cpT=X-Oo|!REZ9N=jg7vK4!w}}d>I+%*XPWJ<+Q~K&y|)d1NNp? zCLTSSl^zTJFz{TSvK=eRXN1d>7S9G&lBYbMt)wk&H^suSQ|=5h*>-V0BczQS+huD` zTD~hBmY(MXEZn5&-Gnongj|R*6Iz{NjeHC=%z@Ws zg9%9~%e8zV5zQEpGK++R>Ddmrs~Pc0k+_JG@Mm9hlqb+NDWovOgEkpkPInr6z76^O zVr-aWr&)CUmR_12Yf8fJuK&WlB!cL`n^z)m&TkC%UpaDkY$QI)@zL@4%P+;^jq8JB z{n_5l>1s(;YaA8|y3*2>bynAQ^%pz3lH+q1-ag+8s2gPyP>o#^&fn7cvR>bevfgN_ zy?Iv4x%SvIlEsFB~)m4X@gUF!mF$u!h^t9q}jYORBN z3Res2t6KZJrmodSogHCA_Sgt>YrXMdb_v%Ww6T9L!$T3Fgv#dWDf*=m#Fj@mgO>4l2U-B!gNm0yGfQC2s%)k@XqMyZ}4eQ#CW x$w6+%l#Q0Y`8{JqmEShTUSBk#^_N9mE%v?uBdvQcY@A;Bq*uN1=e#}VdCuGE^YOTMTa80D3cd&J1YAYnG6`K^l7@F_GWyMc zw+uBm%z}>5kV>Z|=jDMc>7K4^YTjRgbNL{zWhw^)Ih6Y{n%DM=2JQTB!Ebq@3pxcj zoQ0<%9BKZw0UxRuY{G@kZ*4mylLV~;zqr}fVY%*j;)G3ozl%w6D>s;\n" "Language-Team: LANGUAGE \n" @@ -19,7 +19,7 @@ msgstr "" #: lib/cli/args_extract_convert.py:46 lib/cli/args_extract_convert.py:56 #: lib/cli/args_extract_convert.py:64 lib/cli/args_extract_convert.py:122 -#: lib/cli/args_extract_convert.py:479 lib/cli/args_extract_convert.py:488 +#: lib/cli/args_extract_convert.py:483 lib/cli/args_extract_convert.py:492 msgid "Data" msgstr "" @@ -53,12 +53,12 @@ msgid "" "will be output to separate sub-folders in the output_dir." msgstr "" -#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:150 -#: lib/cli/args_extract_convert.py:163 lib/cli/args_extract_convert.py:202 -#: lib/cli/args_extract_convert.py:220 lib/cli/args_extract_convert.py:233 -#: lib/cli/args_extract_convert.py:243 lib/cli/args_extract_convert.py:253 -#: lib/cli/args_extract_convert.py:499 lib/cli/args_extract_convert.py:525 -#: lib/cli/args_extract_convert.py:564 +#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:152 +#: lib/cli/args_extract_convert.py:167 lib/cli/args_extract_convert.py:206 +#: lib/cli/args_extract_convert.py:224 lib/cli/args_extract_convert.py:237 +#: lib/cli/args_extract_convert.py:247 lib/cli/args_extract_convert.py:257 +#: lib/cli/args_extract_convert.py:503 lib/cli/args_extract_convert.py:529 +#: lib/cli/args_extract_convert.py:568 msgid "Plugins" msgstr "" @@ -72,18 +72,22 @@ msgid "" "than other GPU detectors but can often return more false positives.\n" "L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " "fewer false positives than other GPU detectors, but is a lot more resource " -"intensive." +"intensive.\n" +"L|external: Import a face detection bounding box from a json file. " +"(configurable in Detect settings)" msgstr "" -#: lib/cli/args_extract_convert.py:152 +#: lib/cli/args_extract_convert.py:154 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " "but less accurate. Only use this if not using a GPU and time is important.\n" -"L|fan: Best aligner. Fast on GPU, slow on CPU." +"L|fan: Best aligner. Fast on GPU, slow on CPU.\n" +"L|external: Import 68 point 2D landmarks or an aligned bounding box from a " +"json file. (configurable in Align settings)" msgstr "" -#: lib/cli/args_extract_convert.py:165 +#: lib/cli/args_extract_convert.py:169 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -118,7 +122,7 @@ msgid "" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" msgstr "" -#: lib/cli/args_extract_convert.py:204 +#: lib/cli/args_extract_convert.py:208 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -131,7 +135,7 @@ msgid "" "L|mean: Normalize the face colors to the mean." msgstr "" -#: lib/cli/args_extract_convert.py:222 +#: lib/cli/args_extract_convert.py:226 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -141,14 +145,14 @@ msgid "" "occur but the longer extraction will take." msgstr "" -#: lib/cli/args_extract_convert.py:235 +#: lib/cli/args_extract_convert.py:239 msgid "" "Re-feed the initially found aligned face through the aligner. Can help " "produce better alignments for faces that are rotated beyond 45 degrees in " "the frame or are at extreme angles. Slows down extraction." msgstr "" -#: lib/cli/args_extract_convert.py:245 +#: lib/cli/args_extract_convert.py:249 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -156,27 +160,27 @@ msgid "" "exactly what angles to check." msgstr "" -#: lib/cli/args_extract_convert.py:255 +#: lib/cli/args_extract_convert.py:259 msgid "" "Obtain and store face identity encodings from VGGFace2. Slows down extract a " "little, but will save time if using 'sort by face'" msgstr "" -#: lib/cli/args_extract_convert.py:265 lib/cli/args_extract_convert.py:276 -#: lib/cli/args_extract_convert.py:289 lib/cli/args_extract_convert.py:303 -#: lib/cli/args_extract_convert.py:610 lib/cli/args_extract_convert.py:619 -#: lib/cli/args_extract_convert.py:634 lib/cli/args_extract_convert.py:647 -#: lib/cli/args_extract_convert.py:661 +#: lib/cli/args_extract_convert.py:269 lib/cli/args_extract_convert.py:280 +#: lib/cli/args_extract_convert.py:293 lib/cli/args_extract_convert.py:307 +#: lib/cli/args_extract_convert.py:614 lib/cli/args_extract_convert.py:623 +#: lib/cli/args_extract_convert.py:638 lib/cli/args_extract_convert.py:651 +#: lib/cli/args_extract_convert.py:665 msgid "Face Processing" msgstr "" -#: lib/cli/args_extract_convert.py:267 +#: lib/cli/args_extract_convert.py:271 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" msgstr "" -#: lib/cli/args_extract_convert.py:278 +#: lib/cli/args_extract_convert.py:282 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -184,7 +188,7 @@ msgid "" "or multiple image files, space separated, can be selected." msgstr "" -#: lib/cli/args_extract_convert.py:291 +#: lib/cli/args_extract_convert.py:295 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -192,32 +196,32 @@ msgid "" "image files, space separated, can be selected." msgstr "" -#: lib/cli/args_extract_convert.py:305 +#: lib/cli/args_extract_convert.py:309 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." msgstr "" -#: lib/cli/args_extract_convert.py:314 lib/cli/args_extract_convert.py:327 -#: lib/cli/args_extract_convert.py:340 lib/cli/args_extract_convert.py:352 +#: lib/cli/args_extract_convert.py:318 lib/cli/args_extract_convert.py:331 +#: lib/cli/args_extract_convert.py:344 lib/cli/args_extract_convert.py:356 msgid "output" msgstr "" -#: lib/cli/args_extract_convert.py:316 +#: lib/cli/args_extract_convert.py:320 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" "res models." msgstr "" -#: lib/cli/args_extract_convert.py:329 +#: lib/cli/args_extract_convert.py:333 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " "of 10 will extract faces from every 10th frame." msgstr "" -#: lib/cli/args_extract_convert.py:342 +#: lib/cli/args_extract_convert.py:346 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -227,59 +231,58 @@ msgid "" "turn off" msgstr "" -#: lib/cli/args_extract_convert.py:353 +#: lib/cli/args_extract_convert.py:357 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" -#: lib/cli/args_extract_convert.py:359 lib/cli/args_extract_convert.py:369 -#: lib/cli/args_extract_convert.py:377 lib/cli/args_extract_convert.py:384 -#: lib/cli/args_extract_convert.py:674 lib/cli/args_extract_convert.py:686 -#: lib/cli/args_extract_convert.py:695 lib/cli/args_extract_convert.py:716 -#: lib/cli/args_extract_convert.py:722 +#: lib/cli/args_extract_convert.py:363 lib/cli/args_extract_convert.py:373 +#: lib/cli/args_extract_convert.py:381 lib/cli/args_extract_convert.py:388 +#: lib/cli/args_extract_convert.py:678 lib/cli/args_extract_convert.py:691 +#: lib/cli/args_extract_convert.py:712 lib/cli/args_extract_convert.py:718 msgid "settings" msgstr "" -#: lib/cli/args_extract_convert.py:361 +#: lib/cli/args_extract_convert.py:365 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the same time. " "Useful if VRAM is at a premium." msgstr "" -#: lib/cli/args_extract_convert.py:371 +#: lib/cli/args_extract_convert.py:375 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" msgstr "" -#: lib/cli/args_extract_convert.py:378 +#: lib/cli/args_extract_convert.py:382 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" -#: lib/cli/args_extract_convert.py:385 +#: lib/cli/args_extract_convert.py:389 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" -#: lib/cli/args_extract_convert.py:459 +#: lib/cli/args_extract_convert.py:463 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args_extract_convert.py:481 +#: lib/cli/args_extract_convert.py:485 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " "audio)." msgstr "" -#: lib/cli/args_extract_convert.py:490 +#: lib/cli/args_extract_convert.py:494 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." msgstr "" -#: lib/cli/args_extract_convert.py:501 +#: lib/cli/args_extract_convert.py:505 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -300,7 +303,7 @@ msgid "" "L|none: Don't perform color adjustment." msgstr "" -#: lib/cli/args_extract_convert.py:527 +#: lib/cli/args_extract_convert.py:531 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -337,7 +340,7 @@ msgid "" "will use the mask that was created by the trained model." msgstr "" -#: lib/cli/args_extract_convert.py:566 +#: lib/cli/args_extract_convert.py:570 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -356,19 +359,19 @@ msgid "" "more formats." msgstr "" -#: lib/cli/args_extract_convert.py:587 lib/cli/args_extract_convert.py:596 -#: lib/cli/args_extract_convert.py:707 +#: lib/cli/args_extract_convert.py:591 lib/cli/args_extract_convert.py:600 +#: lib/cli/args_extract_convert.py:703 msgid "Frame Processing" msgstr "" -#: lib/cli/args_extract_convert.py:589 +#: lib/cli/args_extract_convert.py:593 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " "at source dimensions. 50%% at half size 200%% at double size" msgstr "" -#: lib/cli/args_extract_convert.py:598 +#: lib/cli/args_extract_convert.py:602 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -376,13 +379,13 @@ msgid "" "converting from images, then the filenames must end with the frame-number!" msgstr "" -#: lib/cli/args_extract_convert.py:612 +#: lib/cli/args_extract_convert.py:616 msgid "" "Scale the swapped face by this percentage. Positive values will enlarge the " "face, Negative values will shrink the face." msgstr "" -#: lib/cli/args_extract_convert.py:621 +#: lib/cli/args_extract_convert.py:625 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -392,7 +395,7 @@ msgid "" "alignments file." msgstr "" -#: lib/cli/args_extract_convert.py:636 +#: lib/cli/args_extract_convert.py:640 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -401,7 +404,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args_extract_convert.py:649 +#: lib/cli/args_extract_convert.py:653 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -410,7 +413,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args_extract_convert.py:663 +#: lib/cli/args_extract_convert.py:667 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -418,7 +421,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args_extract_convert.py:676 +#: lib/cli/args_extract_convert.py:680 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -428,13 +431,7 @@ msgid "" "your system. If singleprocess is enabled this setting will be ignored." msgstr "" -#: lib/cli/args_extract_convert.py:688 -msgid "" -"[LEGACY] This only needs to be selected if a legacy model is being loaded or " -"if there are multiple models in the model folder" -msgstr "" - -#: lib/cli/args_extract_convert.py:697 +#: lib/cli/args_extract_convert.py:693 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -443,16 +440,16 @@ msgid "" "alignments file is found, this option will be ignored." msgstr "" -#: lib/cli/args_extract_convert.py:709 +#: lib/cli/args_extract_convert.py:705 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." msgstr "" -#: lib/cli/args_extract_convert.py:717 +#: lib/cli/args_extract_convert.py:713 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" -#: lib/cli/args_extract_convert.py:723 +#: lib/cli/args_extract_convert.py:719 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.mo b/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.mo index 4483776f6fe3e12dd20f74b171b1a75ddcce4638..51d7f9676fcfebc7feab5b9dedf7ce6c1c5e45cd 100644 GIT binary patch delta 1733 zcmb7@Ur^Ll6vw|18Jc7&2xP$pvp@q~R8)pZNi0FM3B~Eps4VbX+Js$P7R4eL)X}WQ zY>-K#nu)R}ofbC?#9*eJX&RmU{T`bMpQ^VW(&^7kllJ-TG8K(Ibm9Cy_nvd^x#ymH z_V|_L<6kF7uFXm56QcyVA32jGt%K!v@L_C7kvh;-LH;#C!CtBHRmKg<+UHLz<8x+RNl#6m@q?#RTxdo2>h0N{3mWNRx(G&nL=r z(18(H#HN0D7B<4F*-|$CXW=UN^}vC0{y%y>5Z@6ZVNk+>q*AeMCxwCzfKzZ(v#ChKrUbDmVZiNB;xd z42xDI66k_GtjFQ`2mvcf*$jVM2{H6j&sO+onM5^p22xGM;4b_wx_S;S;2kKEmaIus zq#TyIcIZWaW3BWB7pbd|wxiFkls-qlcRh1hMs|^z7`8H*1-(y5-@x4)5)K=yr8NYw zU^+ZqBYg`ma2+?`TTe+N1p2u)vH#;OB#nN2E6;@fW}TFezr9U*3Ep^`xG=~&J`ay> zr~Yj?Uf(I*0~_lpIRQd22gRBO=_?#};N+p<3fiRu*q`l`@~}_rlRBxyE$aUsPt^Gu z?~7k%uQUU{jVGnwiM#ETgh{n;(^K#od=pyzk%Zz`I_Vb_``@8CX!DMDrL_cp`F-gb z0*!r0Ww2*`L|}Lw(qH-mV%CGm0|>pV0^}iNHbQU4M+V>d2sz4wxW6>oM>(7y|9RX8 zmmmhoLlz^`5O=t5?~aEPh$oQEoL`dik-3w47j8eeiRB_$h&$2~5QP!F{?}N@Y!gi{ z8d@}Qe3bAlnw-0!7*oaqR=kKPPJ9&dEk@kuON&`41@j{_YPzh>uoVh;TgpsTYg;fB zHl?dfThJc}o03Y?;te#ldPBR~O)zA<0po4)HwUaH(-7Xz*M;VOuH2hv#GsMwOy4~*lLXR<(4K}w@boneBMUO zG`U`3KY{=6iShX1zmJSv&Rdw(8z|eFw!FwwVmzL*6{{C7^~A20WvtvWB?EidzG#oz zV@|hoz%7P7Xh-c){>JQcZcXe@u=O|xSdTm1aql7Lh#f^UWRE(p+n-I^hYiM2d$_l! zK7D=EdBdBopXWkuswBrX boWuW4EA^jw&AaW+!q~b!r;2;8pU(Lco}$38 delta 1544 zcmYk6eN5G56vw|Hk%?p?FOnt?7!ke@FPaF1kQ!N7E9`|a!VCO>TbFwUE~)F~0*aXaJ{;6D_-vye_?)iPrInVi>-}9X3 zJa_-s@%ty@LswH04vJBNEJnuSq#9T|iwC14LF&clh5U5w7O4VWhbLinqO=x%1Gf^F ze5>?1wtu$N21{;}=E0+IJNyU+Va^=sM=7MkD0krKO_B;o@EW{<9hr-cJ(i4)T~3w; zI0-{=GcUajFT)n-oiAk)e;Jm-UsI&n(4Zi8bmF{L0OA3}rK(=QE^l znHXOpJrFOwnI%nNPh?BGnIF#K<0Q&n#tx~@f)(r*es>@9#Qjn{wL?FpT<_ZOA-HUn z^cd`d?gB2semHBjbSgx`L6i|#u?8}6tyJoS1Jv~u_y?rA@|UqA*b84E{#BUB0)8)- zD3&HGrz&#;-tW$@lRWq%Rnli{D5F|x#Qy`U`xO7$jl^f7{DU%=iFB4b4=$~dzK10b zQ&Q%WACcCPAP=U(_FCx*e3@;z3G1Jb&JcI{$*K3d>ZD5i{ad6c{!34B-o(H4wDb~; zJWF0!)Ij|^P#$fRwh=hiB;5{kw^DWztb$8$#5GfLY(vm6l zP>J6LIZlqM`3)9NgT@_@<`B2~kn}fss}4(hV8s!73x0Wo^Cw1+4^KIMf`4Im9HmM4 zP!ke^Kgl}jO;*OOFy(Df*Y#5$}$lXXLvK(=%MBnKiWGON;3Q+QqduJRsF&DiE;lk)n zBm)V#4V8vaHRT|jMrcwTQqiT+%-)XiAdJ8oetgq5_fP}VuM?)R9CC+ z4F)iz8EAEUhVW*`>u)!{fTxv+K(`wd>~Ojr<3T!meL?Rw zABr2^W4wNMa{A@AfUni*R$%DkvbyAwmBpo|sI;uOczI!AIJrD+O*pToDIr|HW4Iut z!5*_C_JDm49=8YWJB>q|HZ0Eh#767|donu6bIgw0^Y((VqtQLM`s|75Ky>fW)waUW zX?wzkqkYj`_7qwKe}8nJiS7aeg!PeSH2ON)KI5h!Rm7f)?lojQXU7e1^iL_H#$F`D nMSIppZVDy*j2nC&WhT32ZM=4tbVHk3l9Hygx}igpA7=drVbfDM diff --git a/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.po b/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.po index 6924835938..e95bf84dd7 100755 --- a/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 18:11+0000\n" -"PO-Revision-Date: 2024-03-28 18:22+0000\n" +"POT-Creation-Date: 2024-04-12 11:56+0100\n" +"PO-Revision-Date: 2024-04-12 11:59+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -21,7 +21,7 @@ msgstr "" #: lib/cli/args_extract_convert.py:46 lib/cli/args_extract_convert.py:56 #: lib/cli/args_extract_convert.py:64 lib/cli/args_extract_convert.py:122 -#: lib/cli/args_extract_convert.py:479 lib/cli/args_extract_convert.py:488 +#: lib/cli/args_extract_convert.py:483 lib/cli/args_extract_convert.py:492 msgid "Data" msgstr "Данные" @@ -65,12 +65,12 @@ msgstr "" "несколько видео и/или папок с изображениями, из которых вы хотите извлечь " "изображение. Лица будут выведены в отдельные вложенные папки в output_dir." -#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:150 -#: lib/cli/args_extract_convert.py:163 lib/cli/args_extract_convert.py:202 -#: lib/cli/args_extract_convert.py:220 lib/cli/args_extract_convert.py:233 -#: lib/cli/args_extract_convert.py:243 lib/cli/args_extract_convert.py:253 -#: lib/cli/args_extract_convert.py:499 lib/cli/args_extract_convert.py:525 -#: lib/cli/args_extract_convert.py:564 +#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:152 +#: lib/cli/args_extract_convert.py:167 lib/cli/args_extract_convert.py:206 +#: lib/cli/args_extract_convert.py:224 lib/cli/args_extract_convert.py:237 +#: lib/cli/args_extract_convert.py:247 lib/cli/args_extract_convert.py:257 +#: lib/cli/args_extract_convert.py:503 lib/cli/args_extract_convert.py:529 +#: lib/cli/args_extract_convert.py:568 msgid "Plugins" msgstr "Плагины" @@ -84,7 +84,9 @@ msgid "" "than other GPU detectors but can often return more false positives.\n" "L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " "fewer false positives than other GPU detectors, but is a lot more resource " -"intensive." +"intensive.\n" +"L|external: Import a face detection bounding box from a json file. " +"(configurable in Detect settings)" msgstr "" "R|Детектор для использования. Некоторые из них имеют настраиваемые параметры " "в '/config/extract.ini' или 'Settings > Configure Extract 'Plugins':\n" @@ -96,22 +98,29 @@ msgstr "" "ложных срабатываний.\n" "L|s3fd: Лучший детектор. Медленный на CPU, более быстрый на GPU. Может " "обнаружить больше лиц и меньше ложных срабатываний, чем другие детекторы на " -"GPU, но требует гораздо больше ресурсов." +"GPU, но требует гораздо больше ресурсов.\n" +"L|external: импортируйте ограничивающую коробку обнаружения лица из файла " +"JSON. (настраивается в настройках обнаружения)" -#: lib/cli/args_extract_convert.py:152 +#: lib/cli/args_extract_convert.py:154 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " "but less accurate. Only use this if not using a GPU and time is important.\n" -"L|fan: Best aligner. Fast on GPU, slow on CPU." +"L|fan: Best aligner. Fast on GPU, slow on CPU.\n" +"L|external: Import 68 point 2D landmarks or an aligned bounding box from a " +"json file. (configurable in Align settings)" msgstr "" "R|Выравниватель для использования.\n" "L|cv2-dnn: Детектор ориентиров только для процессора. Быстрее, менее " "ресурсоемкий, но менее точный. Используйте его, только если не используется " "GPU и важно время.\n" -"L|fan: Лучший выравниватель. Быстрый на GPU, медленный на CPU." +"L|fan: Лучший выравниватель. Быстрый на GPU, медленный на CPU.\n" +"L|external: импорт 68 баллов 2D достопримечательности или выровненная " +"ограничивающая коробка из файла JSON. (настраивается в настройках " +"выравнивания)" -#: lib/cli/args_extract_convert.py:165 +#: lib/cli/args_extract_convert.py:169 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -178,7 +187,7 @@ msgstr "" "и маска расширяется вверх на лоб.\n" "(например: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -#: lib/cli/args_extract_convert.py:204 +#: lib/cli/args_extract_convert.py:208 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -200,7 +209,7 @@ msgstr "" "L|hist: Уравнять гистограммы в каналах RGB.\n" "L|mean: Нормализовать цвета лица к среднему значению." -#: lib/cli/args_extract_convert.py:222 +#: lib/cli/args_extract_convert.py:226 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -217,7 +226,7 @@ msgstr "" "в выравниватель, тем меньше микро-дрожание, но тем больше времени займет " "извлечение." -#: lib/cli/args_extract_convert.py:235 +#: lib/cli/args_extract_convert.py:239 msgid "" "Re-feed the initially found aligned face through the aligner. Can help " "produce better alignments for faces that are rotated beyond 45 degrees in " @@ -228,7 +237,7 @@ msgstr "" "в кадре более чем на 45 градусов или расположенных под экстремальными " "углами. Замедляет извлечение." -#: lib/cli/args_extract_convert.py:245 +#: lib/cli/args_extract_convert.py:249 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -240,7 +249,7 @@ msgstr "" "число, чтобы использовать приращения этого размера до 360, или передайте " "список чисел, чтобы перечислить, какие именно углы нужно проверить." -#: lib/cli/args_extract_convert.py:255 +#: lib/cli/args_extract_convert.py:259 msgid "" "Obtain and store face identity encodings from VGGFace2. Slows down extract a " "little, but will save time if using 'sort by face'" @@ -249,15 +258,15 @@ msgstr "" "замедляет извлечение, но экономит время при использовании \"сортировки по " "лицам\"." -#: lib/cli/args_extract_convert.py:265 lib/cli/args_extract_convert.py:276 -#: lib/cli/args_extract_convert.py:289 lib/cli/args_extract_convert.py:303 -#: lib/cli/args_extract_convert.py:610 lib/cli/args_extract_convert.py:619 -#: lib/cli/args_extract_convert.py:634 lib/cli/args_extract_convert.py:647 -#: lib/cli/args_extract_convert.py:661 +#: lib/cli/args_extract_convert.py:269 lib/cli/args_extract_convert.py:280 +#: lib/cli/args_extract_convert.py:293 lib/cli/args_extract_convert.py:307 +#: lib/cli/args_extract_convert.py:614 lib/cli/args_extract_convert.py:623 +#: lib/cli/args_extract_convert.py:638 lib/cli/args_extract_convert.py:651 +#: lib/cli/args_extract_convert.py:665 msgid "Face Processing" msgstr "Обработка лиц" -#: lib/cli/args_extract_convert.py:267 +#: lib/cli/args_extract_convert.py:271 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -265,7 +274,7 @@ msgstr "" "Отфильтровывает лица, обнаруженные ниже этого размера. Длина в пикселях по " "диагонали ограничивающего поля. Установите значение 0, чтобы выключить" -#: lib/cli/args_extract_convert.py:278 +#: lib/cli/args_extract_convert.py:282 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -278,7 +287,7 @@ msgstr "" "необходимые изображения, или несколько файлов изображений, разделенных " "пробелами." -#: lib/cli/args_extract_convert.py:291 +#: lib/cli/args_extract_convert.py:295 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -290,7 +299,7 @@ msgstr "" "углами и в разных условиях. Можно выбрать папку, содержащую необходимые " "изображения, или несколько файлов изображений, разделенных пробелами." -#: lib/cli/args_extract_convert.py:305 +#: lib/cli/args_extract_convert.py:309 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." @@ -299,12 +308,12 @@ msgstr "" "положительного распознавания лица. Более высокие значения являются более " "строгими." -#: lib/cli/args_extract_convert.py:314 lib/cli/args_extract_convert.py:327 -#: lib/cli/args_extract_convert.py:340 lib/cli/args_extract_convert.py:352 +#: lib/cli/args_extract_convert.py:318 lib/cli/args_extract_convert.py:331 +#: lib/cli/args_extract_convert.py:344 lib/cli/args_extract_convert.py:356 msgid "output" msgstr "вывод" -#: lib/cli/args_extract_convert.py:316 +#: lib/cli/args_extract_convert.py:320 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -314,7 +323,7 @@ msgstr "" "собираетесь тренировать, поддерживает требуемый размер. Это необходимо " "изменить только для моделей высокого разрешения." -#: lib/cli/args_extract_convert.py:329 +#: lib/cli/args_extract_convert.py:333 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -324,7 +333,7 @@ msgstr "" "лиц. Например, значение 1 будет извлекать лица из каждого кадра, значение 10 " "будет извлекать лица из каждого 10-го кадра." -#: lib/cli/args_extract_convert.py:342 +#: lib/cli/args_extract_convert.py:346 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -340,19 +349,18 @@ msgstr "" "ПРЕДУПРЕЖДЕНИЕ: Не прерывайте работу скрипта при записи файла, так как он " "может быть поврежден. Установите значение 0, чтобы отключить" -#: lib/cli/args_extract_convert.py:353 +#: lib/cli/args_extract_convert.py:357 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "Нарисуйте ориентиры на выходящих гранях для отладки." -#: lib/cli/args_extract_convert.py:359 lib/cli/args_extract_convert.py:369 -#: lib/cli/args_extract_convert.py:377 lib/cli/args_extract_convert.py:384 -#: lib/cli/args_extract_convert.py:674 lib/cli/args_extract_convert.py:686 -#: lib/cli/args_extract_convert.py:695 lib/cli/args_extract_convert.py:716 -#: lib/cli/args_extract_convert.py:722 +#: lib/cli/args_extract_convert.py:363 lib/cli/args_extract_convert.py:373 +#: lib/cli/args_extract_convert.py:381 lib/cli/args_extract_convert.py:388 +#: lib/cli/args_extract_convert.py:678 lib/cli/args_extract_convert.py:691 +#: lib/cli/args_extract_convert.py:712 lib/cli/args_extract_convert.py:718 msgid "settings" msgstr "настройки" -#: lib/cli/args_extract_convert.py:361 +#: lib/cli/args_extract_convert.py:365 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the same time. " @@ -362,7 +370,7 @@ msgstr "" "выполняться отдельно (одна за другой), а не одновременно. Полезно, если " "память VRAM ограничена." -#: lib/cli/args_extract_convert.py:371 +#: lib/cli/args_extract_convert.py:375 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -370,17 +378,17 @@ msgstr "" "Пропускает кадры, которые уже были извлечены и существуют в файле " "выравнивания" -#: lib/cli/args_extract_convert.py:378 +#: lib/cli/args_extract_convert.py:382 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" "Пропустить кадры, в которых уже есть обнаруженные лица в файле выравнивания" -#: lib/cli/args_extract_convert.py:385 +#: lib/cli/args_extract_convert.py:389 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "Не сохранять обнаруженные лица на диск. Просто создать файл выравнивания" -#: lib/cli/args_extract_convert.py:459 +#: lib/cli/args_extract_convert.py:463 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -388,7 +396,7 @@ msgstr "" "Поменять исходные лица в исходном видео/изображении на ваши конечные лица.\n" "Плагины конвертирования можно настроить в меню \"Настройки\"" -#: lib/cli/args_extract_convert.py:481 +#: lib/cli/args_extract_convert.py:485 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -398,7 +406,7 @@ msgstr "" "исходное видео, из которого были извлечены исходные кадры (для извлечения " "кадров в секунду и звука)." -#: lib/cli/args_extract_convert.py:490 +#: lib/cli/args_extract_convert.py:494 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -406,7 +414,7 @@ msgstr "" "Папка модели. Папка, содержащая обученную модель, которую вы хотите " "использовать для преобразования." -#: lib/cli/args_extract_convert.py:501 +#: lib/cli/args_extract_convert.py:505 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -446,7 +454,7 @@ msgstr "" "Обычно дает не очень удовлетворительные результаты.\n" "L|none: Не выполнять коррекцию цвета." -#: lib/cli/args_extract_convert.py:527 +#: lib/cli/args_extract_convert.py:531 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -517,7 +525,7 @@ msgstr "" "L|predicted: Если во время обучения была включена опция 'Изучить Маску', то " "будет использоваться маска, созданная обученной моделью." -#: lib/cli/args_extract_convert.py:566 +#: lib/cli/args_extract_convert.py:570 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -550,12 +558,12 @@ msgstr "" "L|pillow: [изображения] Медленнее, чем opencv, но имеет больше опций и " "поддерживает больше форматов." -#: lib/cli/args_extract_convert.py:587 lib/cli/args_extract_convert.py:596 -#: lib/cli/args_extract_convert.py:707 +#: lib/cli/args_extract_convert.py:591 lib/cli/args_extract_convert.py:600 +#: lib/cli/args_extract_convert.py:703 msgid "Frame Processing" msgstr "Обработка лиц" -#: lib/cli/args_extract_convert.py:589 +#: lib/cli/args_extract_convert.py:593 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -565,7 +573,7 @@ msgstr "" "кадры в исходном размере. 50%% при половинном размере 200%% при двойном " "размере" -#: lib/cli/args_extract_convert.py:598 +#: lib/cli/args_extract_convert.py:602 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -578,7 +586,7 @@ msgstr "" "keep-unchanged). Примечание: Если вы конвертируете из изображений, то имена " "файлов должны заканчиваться номером кадра!" -#: lib/cli/args_extract_convert.py:612 +#: lib/cli/args_extract_convert.py:616 msgid "" "Scale the swapped face by this percentage. Positive values will enlarge the " "face, Negative values will shrink the face." @@ -586,7 +594,7 @@ msgstr "" "Увеличить масштаб нового лица на этот процент. Положительные значения " "увеличат лицо, в то время как отрицательные значения уменьшат его." -#: lib/cli/args_extract_convert.py:621 +#: lib/cli/args_extract_convert.py:625 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -602,7 +610,7 @@ msgstr "" "Если оставить этот параметр пустым, будут преобразованы все лица, " "существующие в файле выравнивания." -#: lib/cli/args_extract_convert.py:636 +#: lib/cli/args_extract_convert.py:640 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -616,7 +624,7 @@ msgstr "" "разделенных пробелами. Примечание: Использование фильтра лиц значительно " "снизит скорость извлечения, а его точность не гарантируется." -#: lib/cli/args_extract_convert.py:649 +#: lib/cli/args_extract_convert.py:653 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -630,7 +638,7 @@ msgstr "" "Примечание: Использование фильтра лиц значительно снизит скорость " "извлечения, а его точность не гарантируется." -#: lib/cli/args_extract_convert.py:663 +#: lib/cli/args_extract_convert.py:667 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -642,7 +650,7 @@ msgstr "" "строгими. Примечание: Использование фильтра лиц значительно снизит скорость " "извлечения, а его точность не гарантируется." -#: lib/cli/args_extract_convert.py:676 +#: lib/cli/args_extract_convert.py:680 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -660,16 +668,7 @@ msgstr "" "процессов, чем доступно в вашей системе. Если включена однопоточная " "обработка, этот параметр будет проигнорирован." -#: lib/cli/args_extract_convert.py:688 -msgid "" -"[LEGACY] This only needs to be selected if a legacy model is being loaded or " -"if there are multiple models in the model folder" -msgstr "" -"[ОТБРОШЕН] Этот параметр необходимо выбрать только в том случае, если " -"загружается устаревшая модель или если в папке моделей имеется несколько " -"моделей" - -#: lib/cli/args_extract_convert.py:697 +#: lib/cli/args_extract_convert.py:693 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -684,7 +683,7 @@ msgstr "" "приведет к некачественным результатам. Если файл выравнивания найден, этот " "параметр будет проигнорирован." -#: lib/cli/args_extract_convert.py:709 +#: lib/cli/args_extract_convert.py:705 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -692,12 +691,20 @@ msgstr "" "При использовании с --frame-ranges выводит неизмененные кадры, которые не " "были обработаны, вместо того, чтобы отбрасывать их." -#: lib/cli/args_extract_convert.py:717 +#: lib/cli/args_extract_convert.py:713 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Поменять модель местами. Вместо преобразования из A -> B, преобразуется B -> " "A" -#: lib/cli/args_extract_convert.py:723 +#: lib/cli/args_extract_convert.py:719 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Отключение многопоточной обработки. Медленнее, но менее ресурсоемко." + +#~ msgid "" +#~ "[LEGACY] This only needs to be selected if a legacy model is being loaded " +#~ "or if there are multiple models in the model folder" +#~ msgstr "" +#~ "[ОТБРОШЕН] Этот параметр необходимо выбрать только в том случае, если " +#~ "загружается устаревшая модель или если в папке моделей имеется несколько " +#~ "моделей" diff --git a/locales/ru/LC_MESSAGES/tools.alignments.cli.mo b/locales/ru/LC_MESSAGES/tools.alignments.cli.mo index 1a4953f0e57bef7f23018bdd4189d9e4316090f3..2ab82eb43de020412e1ac1e6a1df47f176fcde0f 100644 GIT binary patch delta 1582 zcmZ`(O>7%g5S~B@G^7fG2GP)l5dsV%vFyMB0urrID^Wo?^n%2NjlFia+3Z@oFL8@1 zZEO=-l(bY>mG~=gfHJ-beHU;!lWl8_}2hiDt0;>V6`m z(XWViBL0Rrg7`b)JaGO*Jc@Vw0U{sq8sg`O4;~=8j`%HN0Z~m*k&dC@=Lw=RHXc4m z^avV#i1_?HM7Ji1zQ_4rX`-tj`dx}J{Wp)&!LhZsMIVUJtGTN zr)=zCmnTszG#S{~2YycpVfF8Bf4c}F>3kuBFLdJCvz z$HV@Dr5w~a9@}S>9e9?@%JXF=LZ_1sw?%Ho)nv%|7gN`)Y$0xQZX zPQ4W8rMToS79B54^9f(sv%Fx1^9i7nM`K5w3qb4lIS!vXE2}3I^)g2|3}6^EoRw4@mg4%{k3S&^2!5-SurQlr98XrD4w(sfb>(_invnTmOr0oO$UVjIf_n(u0= zd#FWDNA~~^MrheEZ{xkgy3e|4R!v1dH*wlEm(2ON0>zlk{|psmduCPN91IV(h~|sZS#O$JjJyr}Z;L>;B|YN%(5Y`` zMkCa3pt2>S9Dv3*Vhd^OLj2QPaSZPDFjFYOK+!&)!H$nG=som3kMKWec z-3-s{||(eFMQt#nxIm zl~{kVaY&s3{FCD9EUpam4J8A zn^YJAlZ3K6ra)z^iE76eN5n1hNdMHuZQUQ!&=h~gJF%I82Qi(61MxM>AvAhEg+bBF0;YM|hRXA7vTb!4Y_B!k;f9-?X_z|bXXa$`1eC4uc zW=l@vgxr}{9GKIuwTQ{XgUo->G)99fa~cYsBc1X1idWv8v(~p8MQejv#)}1?3;Vf0 D_Twq- diff --git a/locales/ru/LC_MESSAGES/tools.alignments.cli.po b/locales/ru/LC_MESSAGES/tools.alignments.cli.po index 07ef9777cc..fe2ca62297 100644 --- a/locales/ru/LC_MESSAGES/tools.alignments.cli.po +++ b/locales/ru/LC_MESSAGES/tools.alignments.cli.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 23:49+0000\n" -"PO-Revision-Date: 2024-03-29 00:08+0000\n" +"POT-Creation-Date: 2024-04-12 12:10+0100\n" +"PO-Revision-Date: 2024-04-12 12:13+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -65,17 +65,23 @@ msgstr "" msgid " Use the output option (-o) to process results." msgstr " Используйте опцию вывода (-o) для обработки результатов." -#: tools/alignments/cli.py:57 tools/alignments/cli.py:97 +#: tools/alignments/cli.py:58 tools/alignments/cli.py:104 msgid "processing" msgstr "обработка" -#: tools/alignments/cli.py:60 +#: tools/alignments/cli.py:61 #, python-brace-format msgid "" "R|Choose which action you want to perform. NB: All actions require an " "alignments file (-a) to be passed in.\n" "L|'draw': Draw landmarks on frames in the selected folder/video. A subfolder " "will be created within the frames folder to hold the output.{0}\n" +"L|'export': Export the contents of an alignments file to a json file. Can be " +"used for editing alignment information in external tools and then re-" +"importing by using Faceswap's Extract 'Import' plugins. Note: masks and " +"identity vectors will not be included in the exported file, so will be re-" +"generated when the json file is imported back into Faceswap. All data is " +"exported with the origin (0, 0) at the top left of the canvas.\n" "L|'extract': Re-extract faces from the source frames/video based on " "alignment data. This is a lot quicker than re-detecting faces. Can pass in " "the '-een' (--extract-every-n) parameter to only extract every nth frame." @@ -109,6 +115,13 @@ msgstr "" "требуют передачи файла выравнивания (-a).\n" "L|'draw': Нарисовать ориентиры на кадрах в выбранной папке/видео. В папке " "frames будет создана подпапка для хранения результатов.\n" +"L|'export': экспортировать содержимое файла выравнивания в файл JSON. Может " +"использоваться для редактирования информации о выравнивании во внешних " +"инструментах, а затем повторно импортируется с помощью плагинов Faceswap " +"Extract 'Import'. ПРИМЕЧАНИЕ. Маски и векторы идентификации не будут " +"включены в экспортированный файл, поэтому будут повторно сгенерированы, " +"когда файл JSON будет импортирован обратно в Faceswap. Все данные " +"экспортируются с началом координат (0, 0) в верхнем левом углу холста.\n" "L|'extract': Повторное извлечение лиц из исходных кадров/видео на основе " "данных о выравнивании. Это намного быстрее, чем повторное обнаружение лиц. " "Можно передать параметр '-een' (--extract-every-n), чтобы извлекать только " @@ -139,7 +152,7 @@ msgstr "" "L|'spatial': Выполнить пространственную и временную фильтрацию для " "сглаживания выравниваний (ЭКСПЕРИМЕНТАЛЬНО!)." -#: tools/alignments/cli.py:100 +#: tools/alignments/cli.py:107 msgid "" "R|How to output discovered items ('faces' and 'frames' only):\n" "L|'console': Print the list of frames to the screen. (DEFAULT)\n" @@ -154,12 +167,12 @@ msgstr "" "каталоге).\n" "L|'move': Переместить обнаруженные элементы в подпапку в исходном каталоге." -#: tools/alignments/cli.py:111 tools/alignments/cli.py:134 -#: tools/alignments/cli.py:141 +#: tools/alignments/cli.py:118 tools/alignments/cli.py:141 +#: tools/alignments/cli.py:148 msgid "data" msgstr "данные" -#: tools/alignments/cli.py:118 +#: tools/alignments/cli.py:125 msgid "" "Full path to the alignments file to be processed. If you have input a " "'frames_dir' and don't provide this option, the process will try to find the " @@ -173,11 +186,11 @@ msgstr "" "задания 'from-faces', когда файл выравнивания будет создан в указанной папке " "с лицами." -#: tools/alignments/cli.py:135 +#: tools/alignments/cli.py:142 msgid "Directory containing source frames that faces were extracted from." msgstr "Папка, содержащая исходные кадры, из которых были извлечены лица." -#: tools/alignments/cli.py:143 +#: tools/alignments/cli.py:150 msgid "" "R|Run the aligmnents tool on multiple sources. The following jobs support " "batch mode:\n" @@ -220,12 +233,12 @@ msgstr "" "выравнивания должен существовать в месте по умолчанию. Для всех остальных " "заданий этот параметр игнорируется." -#: tools/alignments/cli.py:169 tools/alignments/cli.py:181 -#: tools/alignments/cli.py:191 +#: tools/alignments/cli.py:176 tools/alignments/cli.py:188 +#: tools/alignments/cli.py:198 msgid "extract" msgstr "извлечение" -#: tools/alignments/cli.py:171 +#: tools/alignments/cli.py:178 msgid "" "[Extract only] Extract every 'nth' frame. This option will skip frames when " "extracting faces. For example a value of 1 will extract faces from every " @@ -235,11 +248,11 @@ msgstr "" "кадры при извлечении лиц. Например, значение 1 будет извлекать лица из " "каждого кадра, значение 10 будет извлекать лица из каждого 10-го кадра." -#: tools/alignments/cli.py:182 +#: tools/alignments/cli.py:189 msgid "[Extract only] The output size of extracted faces." msgstr "[Только извлечение] Выходной размер извлеченных лиц." -#: tools/alignments/cli.py:193 +#: tools/alignments/cli.py:200 msgid "" "[Extract only] Only extract faces that have been resized by this percent or " "more to meet the specified extract size (`-sz`, `--size`). Useful for " diff --git a/locales/tools.alignments.cli.pot b/locales/tools.alignments.cli.pot index 44f0bc67cb..64c40ff9af 100644 --- a/locales/tools.alignments.cli.pot +++ b/locales/tools.alignments.cli.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 23:49+0000\n" +"POT-Creation-Date: 2024-04-12 12:10+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -53,17 +53,23 @@ msgstr "" msgid " Use the output option (-o) to process results." msgstr "" -#: tools/alignments/cli.py:57 tools/alignments/cli.py:97 +#: tools/alignments/cli.py:58 tools/alignments/cli.py:104 msgid "processing" msgstr "" -#: tools/alignments/cli.py:60 +#: tools/alignments/cli.py:61 #, python-brace-format msgid "" "R|Choose which action you want to perform. NB: All actions require an " "alignments file (-a) to be passed in.\n" "L|'draw': Draw landmarks on frames in the selected folder/video. A subfolder " "will be created within the frames folder to hold the output.{0}\n" +"L|'export': Export the contents of an alignments file to a json file. Can be " +"used for editing alignment information in external tools and then re-" +"importing by using Faceswap's Extract 'Import' plugins. Note: masks and " +"identity vectors will not be included in the exported file, so will be re-" +"generated when the json file is imported back into Faceswap. All data is " +"exported with the origin (0, 0) at the top left of the canvas.\n" "L|'extract': Re-extract faces from the source frames/video based on " "alignment data. This is a lot quicker than re-detecting faces. Can pass in " "the '-een' (--extract-every-n) parameter to only extract every nth frame." @@ -94,7 +100,7 @@ msgid "" "(EXPERIMENTAL!)" msgstr "" -#: tools/alignments/cli.py:100 +#: tools/alignments/cli.py:107 msgid "" "R|How to output discovered items ('faces' and 'frames' only):\n" "L|'console': Print the list of frames to the screen. (DEFAULT)\n" @@ -104,12 +110,12 @@ msgid "" "directory." msgstr "" -#: tools/alignments/cli.py:111 tools/alignments/cli.py:134 -#: tools/alignments/cli.py:141 +#: tools/alignments/cli.py:118 tools/alignments/cli.py:141 +#: tools/alignments/cli.py:148 msgid "data" msgstr "" -#: tools/alignments/cli.py:118 +#: tools/alignments/cli.py:125 msgid "" "Full path to the alignments file to be processed. If you have input a " "'frames_dir' and don't provide this option, the process will try to find the " @@ -118,11 +124,11 @@ msgid "" "generated in the specified faces folder." msgstr "" -#: tools/alignments/cli.py:135 +#: tools/alignments/cli.py:142 msgid "Directory containing source frames that faces were extracted from." msgstr "" -#: tools/alignments/cli.py:143 +#: tools/alignments/cli.py:150 msgid "" "R|Run the aligmnents tool on multiple sources. The following jobs support " "batch mode:\n" @@ -144,23 +150,23 @@ msgid "" "ignored." msgstr "" -#: tools/alignments/cli.py:169 tools/alignments/cli.py:181 -#: tools/alignments/cli.py:191 +#: tools/alignments/cli.py:176 tools/alignments/cli.py:188 +#: tools/alignments/cli.py:198 msgid "extract" msgstr "" -#: tools/alignments/cli.py:171 +#: tools/alignments/cli.py:178 msgid "" "[Extract only] Extract every 'nth' frame. This option will skip frames when " "extracting faces. For example a value of 1 will extract faces from every " "frame, a value of 10 will extract faces from every 10th frame." msgstr "" -#: tools/alignments/cli.py:182 +#: tools/alignments/cli.py:189 msgid "[Extract only] The output size of extracted faces." msgstr "" -#: tools/alignments/cli.py:193 +#: tools/alignments/cli.py:200 msgid "" "[Extract only] Only extract faces that have been resized by this percent or " "more to meet the specified extract size (`-sz`, `--size`). Useful for " diff --git a/plugins/convert/color/avg_color.py b/plugins/convert/color/avg_color.py index 89d0bac361..f62024289e 100644 --- a/plugins/convert/color/avg_color.py +++ b/plugins/convert/color/avg_color.py @@ -8,10 +8,32 @@ class Color(Adjustment): """ Adjust the mean of the color channels to be the same for the swap and old frame """ - def process(self, old_face, new_face, raw_mask): + def process(self, + old_face: np.ndarray, + new_face: np.ndarray, + raw_mask: np.ndarray) -> np.ndarray: + """ Adjust the mean of the original face and the new face to be the same + + Parameters + ---------- + old_face: :class:`numpy.ndarray` + The original face + new_face: :class:`numpy.ndarray` + The Faceswap generated face + raw_mask: :class:`numpy.ndarray` + A raw mask for including the face area only + + Returns + ------- + :class:`numpy.ndarray` + The adjusted face patch + """ for _ in [0, 1]: diff = old_face - new_face - avg_diff = np.sum(diff * raw_mask, axis=(0, 1)) - adjustment = avg_diff / np.sum(raw_mask, axis=(0, 1)) + if np.any(raw_mask): + avg_diff = np.sum(diff * raw_mask, axis=(0, 1)) + adjustment = avg_diff / np.sum(raw_mask, axis=(0, 1)) + else: + adjustment = diff new_face += adjustment return new_face diff --git a/plugins/extract/__init__.py b/plugins/extract/__init__.py index e69de29bb2..3bffbe70b8 100644 --- a/plugins/extract/__init__.py +++ b/plugins/extract/__init__.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 +""" Package for Faceswap's extraction pipeline """ +from .extract_media import ExtractMedia +from .pipeline import Extractor diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 4abe8a5e9d..588394adee 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -15,7 +15,7 @@ from lib.queue_manager import queue_manager from lib.utils import GetModel, FaceswapError from ._config import Config -from .pipeline import ExtractMedia +from . import ExtractMedia if T.TYPE_CHECKING: from collections.abc import Callable, Generator, Sequence @@ -86,6 +86,18 @@ class ExtractorBatch: prediction: np.ndarray = np.array([]) data: list[dict[str, T.Any]] = field(default_factory=list) + def __repr__(self) -> str: + """ Prettier repr for debug printing """ + data = [{k: (v.shape, v.dtype) if isinstance(v, np.ndarray) else v for k, v in dat.items()} + for dat in self.data] + return (f"{self.__class__.__name__}(" + f"image={[(img.shape, img.dtype) for img in self.image]}, " + f"detected_faces={self.detected_faces}, " + f"filename={self.filename}, " + f"feed={[(f.shape, f.dtype) for f in self.feed]}, " + f"prediction=({self.prediction.shape}, {self.prediction.dtype}), " + f"data={data}") + class Extractor(): """ Extractor Plugin Object @@ -197,7 +209,7 @@ def __init__(self, """ list: Internal threads for this plugin """ self._extract_media: dict[str, ExtractMedia] = {} - """ dict: The :class:`plugins.extract.pipeline.ExtractMedia` objects currently being + """ dict: The :class:`~plugins.extract.extract_media.ExtractMedia` objects currently being processed. Stored at input for pairing back up on output of extractor process """ # << THE FOLLOWING PROTECTED ATTRIBUTES ARE SET IN PLUGIN TYPE _base.py >>> # @@ -276,6 +288,11 @@ def process_output(self, batch: BatchType) -> None: """ raise NotImplementedError + def on_completion(self) -> None: + """ Override to perform an action when the extract process has completed. By default, no + action is undertaken """ + return + def _predict(self, batch: BatchType) -> BatchType: """ **Override method** (at `` level) @@ -362,7 +379,7 @@ def get_batch(self, queue: Queue) -> tuple[bool, BatchType]: :mod:`plugins.extract.detect._base`, :mod:`plugins.extract.align._base` or :mod:`plugins.extract.mask._base`) and should not be overridden within plugins themselves. - Get :class:`~plugins.extract.pipeline.ExtractMedia` items from the queue in batches of + Get :class:`~plugins.extract.extract_media.ExtractMedia` items from the queue in batches of :attr:`batchsize` Parameters @@ -409,11 +426,11 @@ def rollover_collector(self, queue: Queue) -> T.Literal["EOF"] | ExtractMedia: ---------- queue: :class:`queue.Queue` The input queue to the aligner. Should contain - :class:`~plugins.extract.pipeline.ExtractMedia` objects + :class:`~plugins.extract.extract_media.ExtractMedia` objects Returns ------- - :class:`~plugins.extract.pipeline.ExtractMedia` or EOF + :class:`~plugins.extract.extract_media.ExtractMedia` or EOF The next extract media object, or EOF if pipe has ended """ if self._rollover is not None: diff --git a/plugins/extract/align/_base/aligner.py b/plugins/extract/align/_base/aligner.py index 3eec920c08..6746daf631 100644 --- a/plugins/extract/align/_base/aligner.py +++ b/plugins/extract/align/_base/aligner.py @@ -4,7 +4,7 @@ All Aligner Plugins should inherit from this class. See the override methods for which methods are required. -The plugin will receive a :class:`~plugins.extract.pipeline.ExtractMedia` object. +The plugin will receive a :class:`~plugins.extract.extract_media.ExtractMedia` object. For each source item, the plugin must pass a dict to finalize containing: @@ -24,8 +24,10 @@ from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa +from lib.align import LandmarkType from lib.utils import FaceswapError -from plugins.extract._base import BatchType, Extractor, ExtractMedia, ExtractorBatch +from plugins.extract import ExtractMedia +from plugins.extract._base import BatchType, ExtractorBatch, Extractor from .processing import AlignedFilter, ReAlign if T.TYPE_CHECKING: @@ -81,20 +83,13 @@ class AlignerBatch(ExtractorBatch): def __repr__(self): """ Prettier repr for debug printing """ - data = [{k: v.shape if isinstance(v, np.ndarray) else v for k, v in dat.items()} - for dat in self.data] - return ("AlignerBatch(" - f"batch_id={self.batch_id}, " - f"image={[img.shape for img in self.image]}, " - f"detected_faces={self.detected_faces}, " - f"filename={self.filename}, " - f"feed={self.feed.shape}, " - f"prediction={self.prediction.shape}, " - f"data={data}, " - f"landmarks={self.landmarks.shape}, " - f"refeeds={[feed.shape for feed in self.refeeds]}, " - f"second_pass={self.second_pass}, " - f"second_pass_masks={self.second_pass_masks})") + retval = super().__repr__() + retval += (f", batch_id={self.batch_id}, " + f"landmarks=[({self.landmarks.shape}, {self.landmarks.dtype})], " + f"refeeds={[(f.shape, f.dtype) for f in self.refeeds]}, " + f"second_pass={self.second_pass}, " + f"second_pass_masks={self.second_pass_masks})") + return retval def __post_init__(self): """ Make sure that we have been given a non-zero ID """ @@ -157,6 +152,10 @@ def __init__(self, **kwargs) self._plugin_type = "align" self.realign_centering: CenteringType = "face" # overide for plugin specific centering + + # Override for specific landmark type: + self.landmark_type = LandmarkType.LM_2D_68 + self._eof_seen = False self._normalize_method: T.Literal["clahe", "hist", "mean"] | None = None self._re_feed = re_feed @@ -244,8 +243,8 @@ def get_batch(self, queue: Queue) -> tuple[bool, AlignerBatch]: Items are returned from the ``queue`` in batches of :attr:`~plugins.extract._base.Extractor.batchsize` - Items are received as :class:`~plugins.extract.pipeline.ExtractMedia` objects and converted - to ``dict`` for internal processing. + Items are received as :class:`~plugins.extract.extract_media.ExtractMedia` objects and + converted to ``dict`` for internal processing. To ensure consistent batch sizes for aligner the items are split into separate items for each :class:`~lib.align.DetectedFace` object. @@ -317,10 +316,6 @@ def get_batch(self, queue: Queue) -> tuple[bool, AlignerBatch]: else: logger.debug(item) - # TODO Move to end of process not beginning - if exhausted: - self._filter.output_counts() - return exhausted, batch def faces_to_feed(self, faces: np.ndarray) -> np.ndarray: @@ -354,7 +349,7 @@ def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: Yields ------ - :class:`~plugins.extract.pipeline.ExtractMedia` + :class:`~plugins.extract.extract_media.ExtractMedia` The :attr:`DetectedFaces` list will be populated for this class with the bounding boxes and landmarks for the detected faces found in the frame. """ @@ -388,6 +383,10 @@ def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: yield output self._re_align.untrack_batch(batch.batch_id) + def on_completion(self) -> None: + """ Output the filter counts when process has completed """ + self._filter.output_counts() + # <<< PROTECTED METHODS >>> # # << PROCESS_INPUT WRAPPER >> def _get_adjusted_boxes(self, original_boxes: np.ndarray) -> np.ndarray: @@ -584,7 +583,7 @@ def _process_refeeds(self, batch: AlignerBatch) -> list[AlignerBatch]: if not all_filtered: feed = batch.refeeds[selected_idx] pred = batch.prediction[selected_idx] - data = batch.data[selected_idx] + data = batch.data[selected_idx] if batch.data else {} selected_idx += 1 else: # All resuts have been filtered out feed = pred = np.array([]) @@ -604,14 +603,15 @@ def _process_refeeds(self, batch: AlignerBatch) -> list[AlignerBatch]: retval.append(subbatch) else: - for feed, pred, data in zip(batch.refeeds, batch.prediction, batch.data): + b_data = batch.data if batch.data else [{}] + for feed, pred, dat in zip(batch.refeeds, batch.prediction, b_data): subbatch = AlignerBatch(batch_id=batch.batch_id, image=batch.image, detected_faces=batch.detected_faces, filename=batch.filename, feed=feed, prediction=pred, - data=[data], + data=[dat], second_pass=batch.second_pass) self.process_output(subbatch) retval.append(subbatch) diff --git a/plugins/extract/align/external.py b/plugins/extract/align/external.py new file mode 100644 index 0000000000..929e9b11c9 --- /dev/null +++ b/plugins/extract/align/external.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +""" Import 68 point landmarks or ROI boxes from a json file """ +import logging +import typing as T +import os +import re + +import numpy as np + +from lib.align import EXTRACT_RATIOS, LandmarkType +from lib.utils import FaceswapError, IMAGE_EXTENSIONS + +from ._base import BatchType, Aligner, AlignerBatch + +logger = logging.getLogger(__name__) + + +class Align(Aligner): + """ Import face detection bounding boxes from an external json file """ + def __init__(self, **kwargs) -> None: + kwargs["normalize_method"] = None # Disable normalization + kwargs["re_feed"] = 0 # Disable re-feed + kwargs["re_align"] = False # Disablle re-align + kwargs["disable_filter"] = True # Disable aligner filters + super().__init__(git_model_id=None, model_filename=None, **kwargs) + + self.name = "External" + self.batchsize = 16 + + self._origin: T.Literal["top-left", + "bottom-left", + "top-right", + "bottom-right"] = self.config["origin"] + + self._re_frame_no: re.Pattern = re.compile(r"\d+$") + self._is_video: bool = False + self._imported: dict[str | int, tuple[int, np.ndarray]] = {} + """dict[str | int, tuple[int, np.ndarray]]: filename as key, value of [number of faces + remaining for the frame, all landmarks in the frame] """ + + self._missing: list[str] = [] + self._roll: dict[T.Literal["bottom-left", "top-right", "bottom-right"], int] = { + "bottom-left": 3, "top-right": 1, "bottom-right": 2} + """dict[Literal["bottom-left", "top-right", "bottom-right"], int]: Amount to roll the + points by for different origins when 4 Point ROI landmarks are provided """ + + centering = self.config["4_point_centering"] + self._adjustment: float = 1. if centering is None else 1. - EXTRACT_RATIOS[centering] + """float: The amount to adjust 4 point ROI landmarks to standardize the points for a + 'head' sized extracted face """ + + def init_model(self) -> None: + """ No initialization to perform """ + logger.debug("No aligner model to initialize") + + def _check_for_video(self, filename: str) -> None: + """ Check a sample filename from the import file for a file extension to set + :attr:`_is_video` + + Parameters + ---------- + filename: str + A sample file name from the imported data + """ + logger.debug("Checking for video from '%s'", filename) + ext = os.path.splitext(filename)[-1] + if ext.lower() not in IMAGE_EXTENSIONS: + self._is_video = True + logger.debug("Set is_video to %s from extension '%s'", self._is_video, ext) + + def _get_key(self, key: str) -> str | int: + """ Obtain the key for the item in the lookup table. If the input are images, the key will + be the image filename. If the input is a video, the key will be the frame number + + Parameters + ---------- + key: str + The initial key value from import data or an import image/frame + + Returns + ------- + str | int + The filename is the input data is images, otherwise the frame number of a video + """ + if not self._is_video: + return key + original_name = os.path.splitext(key)[0] + matches = self._re_frame_no.findall(original_name) + if not matches or len(matches) > 1: + raise FaceswapError(f"Invalid import name: '{key}'. For video files, the key should " + "end with the frame number.") + retval = int(matches[0]) + logger.trace("Obtained frame number %s from key '%s'", # type:ignore[attr-defined] + retval, key) + return retval + + def _import_face(self, face: dict[str, list[int] | list[list[float]]]) -> np.ndarray: + """ Import the landmarks from a single face + + Parameters + ---------- + face: dict[str, list[int] | list[list[float]]] + An import dictionary item for a face + + Returns + ------- + :class:`numpy.ndarray` + The landmark data imported from the json file + + Raises + ------ + FaceSwapError + If the landmarks_2d key does not exist or the landmarks are in an incorrect format + """ + landmarks = face.get("landmarks_2d") + if landmarks is None: + raise FaceswapError("The provided import file is the required key 'landmarks_2d") + if len(landmarks) not in (4, 68): + raise FaceswapError("Imported 'landmarks_2d' should be either 68 facial feature " + "landmarks or 4 ROI corner locations") + retval = np.array(landmarks, dtype="float32") + if retval.shape[-1] != 2: + raise FaceswapError("Imported 'landmarks_2d' should be formatted as a list of (x, y) " + "co-ordinates") + if retval.shape[0] == 4: # Adjust ROI landmarks based on centering selected + center = np.mean(retval, axis=0) + retval = (retval - center) * self._adjustment + center + + return retval + + def import_data(self, data: dict[str, list[dict[str, list[int] | list[list[float]]]]]) -> None: + """ Import the aligner data from the json import file and set to :attr:`_imported` + + Parameters + ---------- + data: dict[str, list[dict[str, list[int] | list[list[float]]]]] + The data to be imported + """ + logger.debug("Data length: %s", len(data)) + self._check_for_video(list(data)[0]) + for key, faces in data.items(): + try: + lms = np.array([self._import_face(face) for face in faces], dtype="float32") + if not np.any(lms): + logger.trace("Skipping frame '%s' with no faces") # type:ignore[attr-defined] + continue + + store_key = self._get_key(key) + self._imported[store_key] = (lms.shape[0], lms) + except FaceswapError as err: + logger.error(str(err)) + msg = f"The imported frame key that failed was '{key}'" + raise FaceswapError(msg) from err + lm_shape = set(v[1].shape[1:] for v in self._imported.values() if v[0] > 0) + if len(lm_shape) > 1: + raise FaceswapError("All external data should have the same number of landmarks. " + f"Found landmarks of shape: {lm_shape}") + if (4, 2) in lm_shape: + self.landmark_type = LandmarkType.LM_2D_4 + + def process_input(self, batch: BatchType) -> None: + """ Put the filenames and original frame dimensions into `batch.feed` so they can be + collected for mapping in `.predict` + + Parameters + ---------- + batch: :class:`~plugins.extract.detect._base.AlignerBatch` + The batch to be processed by the plugin + """ + batch.feed = np.array([(self._get_key(os.path.basename(f)), i.shape[:2]) + for f, i in zip(batch.filename, batch.image)], dtype="object") + + def faces_to_feed(self, faces: np.ndarray) -> np.ndarray: + """ No action required for import plugin + + Parameters + ---------- + faces: :class:`numpy.ndarray` + The batch of faces in UINT8 format + + Returns + ------- + class: `numpy.ndarray` + the original batch of faces + """ + return faces + + def _adjust_for_origin(self, landmarks: np.ndarray, frame_dims: tuple[int, int]) -> np.ndarray: + """ Adjust the landmarks to be top-left orientated based on the selected import origin + + Parameters + ---------- + landmarks: :class:`np.ndarray` + The imported facial landmarks box at original (0, 0) origin + frame_dims: tuple[int, int] + The (rows, columns) dimensions of the original frame + + Returns + ------- + :class:`numpy.ndarray` + The adjusted landmarks box for a top-left origin + """ + if not np.any(landmarks) or self._origin == "top-left": + return landmarks + + if LandmarkType.from_shape(landmarks.shape) == LandmarkType.LM_2D_4: + landmarks = np.roll(landmarks, self._roll[self._origin], axis=0) + + if self._origin.startswith("bottom"): + landmarks[:, 1] = frame_dims[0] - landmarks[:, 1] + if self._origin.endswith("right"): + landmarks[:, 0] = frame_dims[1] - landmarks[:, 0] + + return landmarks + + def predict(self, feed: np.ndarray) -> np.ndarray: + """ Pair the input filenames to the import file + + Parameters + ---------- + feed: :class:`numpy.ndarray` + The filenames in the batch to return imported alignments for + + Returns + ------- + :class:`numpy.ndarray` + The predictions for the given filenames + """ + preds = [] + for key, frame_dims in feed: + if key not in self._imported: + self._missing.append(key) + continue + + remaining, all_lms = self._imported[key] + preds.append(self._adjust_for_origin(all_lms[all_lms.shape[0] - remaining], + frame_dims)) + + if remaining == 1: + del self._imported[key] + else: + self._imported[key] = (remaining - 1, all_lms) + + return np.array(preds, dtype="float32") + + def process_output(self, batch: BatchType) -> None: + """ Process the imported data to the landmarks attribute + + Parameters + ---------- + batch: :class:`AlignerBatch` + The current batch from the model with :attr:`predictions` populated + """ + assert isinstance(batch, AlignerBatch) + batch.landmarks = batch.prediction + logger.trace("Imported landmarks: %s", batch.landmarks) # type:ignore[attr-defined] + + def on_completion(self) -> None: + """ Output information if: + - Imported items were not matched in input data + - Input data was not matched in imported items + """ + super().on_completion() + + if self._missing: + logger.warning("[ALIGN] %s input frames could not be matched in the import file " + "'%s'. Run in verbose mode for a list of frames.", + len(self._missing), self.config["file_name"]) + logger.verbose( # type:ignore[attr-defined] + "[ALIGN] Input frames not in import file: %s", self._missing) + + if self._imported: + logger.warning("[ALIGN] %s items in the import file '%s' could not be matched to any " + "input frames. Run in verbose mode for a list of items.", + len(self._imported), self.config["file_name"]) + logger.verbose( # type:ignore[attr-defined] + "[ALIGN] import file items not in input frames: %s", list(self._imported)) diff --git a/plugins/extract/align/external_defaults.py b/plugins/extract/align/external_defaults.py new file mode 100644 index 0000000000..875abd01d0 --- /dev/null +++ b/plugins/extract/align/external_defaults.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" + The default options for the faceswap Import Alignments plugin. + + Defaults files should be named _defaults.py + Any items placed into this file will automatically get added to the relevant config .ini files + within the faceswap/config folder. + + The following variables should be defined: + _HELPTEXT: A string describing what this plugin does + _DEFAULTS: A dictionary containing the options, defaults and meta information. The + dictionary should be defined as: + {: {}} + + should always be lower text. + dictionary requirements are listed below. + + The following keys are expected for the _DEFAULTS dict: + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid data types are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + group: [optional]. A group for grouping options together in the GUI. If not + provided this will not group this option with any others. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and data types this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and data types this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. +""" + + +_HELPTEXT = ( + "Import Aligner options.\n" + "Imports either 68 point 2D landmarks or an aligned bounding box from an external .json file." + ) + + +_DEFAULTS = { + "file_name": { + "default": "import.json", + "info": "The import file should be stored in the same folder as the video (if extracting " + "from a video file) or inside the folder of images (if importing from a folder of images)", + "datatype": str, + "choices": [], + "group": "settings", + "gui_radio": False, + "fixed": True, + }, + "origin": { + "default": "top-left", + "info": "The origin (0, 0) location of the co-ordinates system used. " + "\n\t top-left: The origin (0, 0) of the canvas is at the top left " + "corner." + "\n\t bottom-left: The origin (0, 0) of the canvas is at the bottom " + "left corner." + "\n\t top-right: The origin (0, 0) of the canvas is at the top right " + "corner." + "\n\t bottom-right: The origin (0, 0) of the canvas is at the bottom " + "right corner.", + "datatype": str, + "choices": ["top-left", "bottom-left", "top-right", "bottom-right"], + "group": "input", + "gui_radio": True + }, + "4_point_centering": { + "default": "head", + "info": "4 point ROI landmarks only. The approximate centering for the location of the " + "corner points to be imported. Default faceswap extracts are generated at 'head' " + "centering, but it is possible to pass in ROI points at a tighter centering. " + "Refer to https://github.com/deepfakes/faceswap/pull/1095 for a visual guide" + "\n\t head: The ROI points represent a loose crop enclosing the whole head." + "\n\t face: The ROI points represent a medium crop enclosing the face." + "\n\t legacy: The ROI points represent a tight crop enclosing the central face " + "area." + "\n\t none: Only required if importing 4 point ROI landmarks back into faceswap " + "having generated them from the 'alignments' tool 'export' job.", + "datatype": str, + "choices": ["head", "face", "legacy", "none"], + "group": "input", + "gui_radio": True + } + +} diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index a256e67c45..3c3221d84f 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -4,7 +4,7 @@ All Detector Plugins should inherit from this class. See the override methods for which methods are required. -The plugin will receive a :class:`~plugins.extract.pipeline.ExtractMedia` object. +The plugin will receive a :class:`~plugins.extract.extract_media.ExtractMedia` object. For each source frame, the plugin must pass a dict to finalize containing: @@ -30,7 +30,7 @@ from lib.utils import FaceswapError from plugins.extract._base import BatchType, Extractor, ExtractorBatch -from plugins.extract.pipeline import ExtractMedia +from plugins.extract import ExtractMedia if T.TYPE_CHECKING: from collections.abc import Generator @@ -62,6 +62,15 @@ class DetectorBatch(ExtractorBatch): pad: list[tuple[int, int]] = field(default_factory=list) initial_feed: np.ndarray = np.array([]) + def __repr__(self): + """ Prettier repr for debug printing """ + retval = super().__repr__() + retval += (f", rotation_matrix={self.rotation_matrix}, " + f"scale={self.scale}, " + f"pad={self.pad}, " + f"initial_feed=({self.initial_feed.shape}, {self.initial_feed.dtype})") + return retval + class Detector(Extractor): # pylint:disable=abstract-method """ Detector Object @@ -123,8 +132,8 @@ def __init__(self, def get_batch(self, queue: Queue) -> tuple[bool, DetectorBatch]: """ Get items for inputting to the detector plugin in batches - Items are received as :class:`~plugins.extract.pipeline.ExtractMedia` objects and converted - to ``dict`` for internal processing. + Items are received as :class:`~plugins.extract.extract_media.ExtractMedia` objects and + converted to ``dict`` for internal processing. Items are returned from the ``queue`` in batches of :attr:`~plugins.extract._base.Extractor.batchsize` @@ -199,7 +208,7 @@ def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: Yields ------ - :class:`~plugins.extract.pipeline.ExtractMedia` + :class:`~plugins.extract.extract_media.ExtractMedia` The :attr:`DetectedFaces` list will be populated for this class with the bounding boxes for the detected faces found in the frame. """ @@ -342,7 +351,7 @@ def _compile_detection_image(self, item: ExtractMedia Parameters ---------- - item: :class:`plugins.extract.pipeline.ExtractMedia` + item: :class:`~plugins.extract.extract_media.ExtractMedia` The input item from the pipeline Returns diff --git a/plugins/extract/detect/external.py b/plugins/extract/detect/external.py new file mode 100644 index 0000000000..98876e2a95 --- /dev/null +++ b/plugins/extract/detect/external.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +""" Import face detection ROI boxes from a json file """ +from __future__ import annotations + +import logging +import os +import re +import typing as T + +import numpy as np + +from lib.align import AlignedFace +from lib.utils import FaceswapError, IMAGE_EXTENSIONS + +from ._base import Detector + +if T.TYPE_CHECKING: + from lib.align import DetectedFace + from plugins.extract import ExtractMedia + from ._base import BatchType + +logger = logging.getLogger(__name__) + + +class Detect(Detector): + """ Import face detection bounding boxes from an external json file """ + def __init__(self, **kwargs) -> None: + kwargs["rotation"] = None # Disable rotation + kwargs["min_size"] = 0 # Disable min_size + super().__init__(git_model_id=None, model_filename=None, **kwargs) + + self.name = "External" + self.batchsize = 16 + + self._origin: T.Literal["top-left", + "bottom-left", + "top-right", + "bottom-right"] = self.config["origin"] + + self._re_frame_no: re.Pattern = re.compile(r"\d+$") + self._missing: list[str] = [] + self._log_once = True + self._is_video = False + self._imported: dict[str | int, np.ndarray] = {} + """dict[str | int, np.ndarray]: The imported data from external .json file""" + + def init_model(self) -> None: + """ No initialization to perform """ + logger.debug("No detector model to initialize") + + def _compile_detection_image(self, item: ExtractMedia + ) -> tuple[np.ndarray, float, tuple[int, int]]: + """ Override _compile_detection_image method, to obtain the source frame dimensions + + Parameters + ---------- + item: :class:`~plugins.extract.extract_media.ExtractMedia` + The input item from the pipeline + + Returns + ------- + image: :class:`numpy.ndarray` + dummy empty array + scale: float + The scaling factor for the image (1.0) + pad: int + The amount of padding applied to the image (0, 0) + """ + return np.array(item.image_shape[:2], dtype="int64"), 1.0, (0, 0) + + def _check_for_video(self, filename: str) -> None: + """ Check a sample filename from the import file for a file extension to set + :attr:`_is_video` + + Parameters + ---------- + filename: str + A sample file name from the imported data + """ + logger.debug("Checking for video from '%s'", filename) + ext = os.path.splitext(filename)[-1] + if ext.lower() not in IMAGE_EXTENSIONS: + self._is_video = True + logger.debug("Set is_video to %s from extension '%s'", self._is_video, ext) + + def _get_key(self, key: str) -> str | int: + """ Obtain the key for the item in the lookup table. If the input are images, the key will + be the image filename. If the input is a video, the key will be the frame number + + Parameters + ---------- + key: str + The initial key value from import data or an import image/frame + + Returns + ------- + str | int + The filename is the input data is images, otherwise the frame number of a video + """ + if not self._is_video: + return key + original_name = os.path.splitext(key)[0] + matches = self._re_frame_no.findall(original_name) + if not matches or len(matches) > 1: + raise FaceswapError(f"Invalid import name: '{key}'. For video files, the key should " + "end with the frame number.") + retval = int(matches[0]) + logger.trace("Obtained frame number %s from key '%s'", # type:ignore[attr-defined] + retval, key) + return retval + + @classmethod + def _bbox_from_detected(cls, bounding_box: list[int]) -> np.ndarray: + """ Import the detected face roi from a `detected` item in the import file + + Parameters + ---------- + bounding_box: list[int] + a bounding box contained within the import file + + Returns + ------- + :class:`numpy.ndarray` + The "left", "top", "right", "bottom" bounding box for the face + + Raises + ------ + FaceSwapError + If the number of bounding box co-ordinates is incorrect + """ + if len(bounding_box) != 4: + raise FaceswapError("Imported 'detected' bounding boxes should be a list of 4 numbers " + "representing the 'left', 'top', 'right', `bottom` of a face.") + return np.rint(bounding_box) + + def _validate_landmarks(self, landmarks: list[list[float]]) -> np.ndarray: + """ Validate that the there are 4 or 68 landmarks and are a complete list of (x, y) + co-ordinates + + Parameters + ---------- + landmarks: list[float] + The 4 point ROI or 68 point 2D landmarks that are being imported + + Returns + ------- + :class:`numpy.ndarray` + The original landmarks as a numpy array + + Raises + ------ + FaceSwapError + If the landmarks being imported are not correct + """ + if len(landmarks) not in (4, 68): + raise FaceswapError("Imported 'landmarks_2d' should be either 68 facial feature " + "landmarks or 4 ROI corner locations") + retval = np.array(landmarks, dtype="float32") + if retval.shape[-1] != 2: + raise FaceswapError("Imported 'landmarks_2d' should be formatted as a list of (x, y) " + "co-ordinates") + return retval + + def _bbox_from_landmarks2d(self, landmarks: list[list[float]]) -> np.ndarray: + """ Import the detected face roi by estimating from imported landmarks + + Parameters + ---------- + landmarks: list[float] + The 4 point ROI or 68 point 2D landmarks that are being imported + + Returns + ------- + :class:`numpy.ndarray` + The "left", "top", "right", "bottom" bounding box for the face + """ + n_landmarks = self._validate_landmarks(landmarks) + face = AlignedFace(n_landmarks, centering="legacy", coverage_ratio=0.75) + return np.concatenate([np.min(face.original_roi, axis=0), + np.max(face.original_roi, axis=0)]) + + def _import_frame_face(self, + face: dict[str, list[int] | list[list[float]]], + align_origin: T.Literal["top-left", + "bottom-left", + "top-right", + "bottom-right"] | None) -> np.ndarray: + """ Import a detected face ROI from the import file + + Parameters + ---------- + face: dict[str, list[int] | list[list[float]]] + The data that exists within the import file for the frame + align_origin: Literal["top-left", "bottom-left", "top-right", "bottom-right"] | None + The origin of the imported aligner data. Used if the detected ROI is being estimated + from imported aligner data + + Returns + ------- + :class:`numpy.ndarray` + The "left", "top", "right", "bottom" bounding box for the face + + Raises + ------ + FaceSwapError + If the required keys for the bounding boxes are not present for the face + """ + if "detected" in face: + return self._bbox_from_detected(T.cast(list[int], face["detected"])) + if "landmarks_2d" in face: + if self._log_once and align_origin is None: + logger.warning("You are importing Detection data, but have only provided " + "Alignment data. This is most likely incorrect and will lead " + "to poor results") + self._log_once = False + + if self._log_once and align_origin is not None and align_origin != self._origin: + logger.info("Updating Detect origin from Aligner config to '%s'", align_origin) + self._origin = align_origin + self._log_once = False + + return self._bbox_from_landmarks2d(T.cast(list[list[float]], face["landmarks_2d"])) + + raise FaceswapError("The provided import file is missing both of the required keys " + "'detected' and 'landmarks_2d") + + def import_data(self, + data: dict[str, list[dict[str, list[int] | list[list[float]]]]], + align_origin: T.Literal["top-left", + "bottom-left", + "top-right", + "bottom-right"] | None) -> None: + """ Import the detection data from the json import file and set to :attr:`_imported` + + Parameters + ---------- + data: dict[str, list[dict[str, list[int] | list[list[float]]]]] + The data to be imported + align_origin: Literal["top-left", "bottom-left", "top-right", "bottom-right"] | None + The origin of the imported aligner data. Used if the detected ROI is being estimated + from imported aligner data + """ + logger.debug("Data length: %s, align_origin: %s", len(data), align_origin) + self._check_for_video(list(data)[0]) + for key, faces in data.items(): + try: + store_key = self._get_key(key) + self._imported[store_key] = np.array([self._import_frame_face(face, align_origin) + for face in faces], dtype="int32") + except FaceswapError as err: + logger.error(str(err)) + msg = f"The imported frame key that failed was '{key}'" + raise FaceswapError(msg) from err + + def process_input(self, batch: BatchType) -> None: + """ Put the lookup key into `batch.feed` so they can be collected for mapping in `.predict` + + Parameters + ---------- + batch: :class:`~plugins.extract.detect._base.DetectorBatch` + The batch to be processed by the plugin + """ + batch.feed = np.array([(self._get_key(os.path.basename(f)), i) + for f, i in zip(batch.filename, batch.image)], dtype="object") + + def _adjust_for_origin(self, box: np.ndarray, frame_dims: tuple[int, int]) -> np.ndarray: + """ Adjust the bounding box to be top-left orientated based on the selected import origin + + Parameters + ---------- + box: :class:`np.ndarray` + The imported bounding box at original (0, 0) origin + frame_dims: tuple[int, int] + The (rows, columns) dimensions of the original frame + + Returns + ------- + :class:`numpy.ndarray` + The adjusted bounding box for a top-left origin + """ + if not np.any(box) or self._origin == "top-left": + return box + if self._origin.startswith("bottom"): + box[:, [1, 3]] = frame_dims[0] - box[:, [1, 3]] + if self._origin.endswith("right"): + box[:, [0, 2]] = frame_dims[1] - box[:, [0, 2]] + + return box + + def predict(self, feed: np.ndarray) -> list[np.ndarray]: # type:ignore[override] + """ Pair the input filenames to the import file + + Parameters + ---------- + feed: :class:`numpy.ndarray` + The filenames with original frame dimensions to obtain the imported bounding boxes for + + Returns + ------- + list[]:class:`numpy.ndarray`] + The bounding boxes for the given filenames + """ + self._missing.extend(f[0] for f in feed if f[0] not in self._imported) + return [self._adjust_for_origin(self._imported.pop(f[0], np.array([], dtype="int32")), + f[1]) + for f in feed] + + def process_output(self, batch: BatchType) -> None: + """ No output processing required for import plugin + + Parameters + ---------- + batch: :class:`~plugins.extract.detect._base.DetectorBatch` + The batch to be processed by the plugin + """ + logger.trace("No output processing for import plugin") # type:ignore[attr-defined] + + def _remove_zero_sized_faces(self, batch_faces: list[list[DetectedFace]] + ) -> list[list[DetectedFace]]: + """ Override _remove_zero_sized_faces to just return the faces that have been imported + + Parameters + ---------- + batch_faces: list[list[DetectedFace] + List of detected face objects + + Returns + ------- + list[list[DetectedFace] + Original list of detected face objects + """ + return batch_faces + + def on_completion(self) -> None: + """ Output information if: + - Imported items were not matched in input data + - Input data was not matched in imported items + """ + super().on_completion() + + if self._missing: + logger.warning("[DETECT] %s input frames could not be matched in the import file " + "'%s'. Run in verbose mode for a list of frames.", + len(self._missing), self.config["file_name"]) + logger.verbose( # type:ignore[attr-defined] + "[DETECT] Input frames not in import file: %s", self._missing) + + if self._imported: + logger.warning("[DETECT] %s items in the import file '%s' could not be matched to any " + "input frames. Run in verbose mode for a list of items.", + len(self._imported), self.config["file_name"]) + logger.verbose( # type:ignore[attr-defined] + "[DETECT] import file items not in input frames: %s", list(self._imported)) diff --git a/plugins/extract/detect/external_defaults.py b/plugins/extract/detect/external_defaults.py new file mode 100644 index 0000000000..c444bf419b --- /dev/null +++ b/plugins/extract/detect/external_defaults.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +""" + The default options for the faceswap Import Alignments plugin. + + Defaults files should be named _defaults.py + Any items placed into this file will automatically get added to the relevant config .ini files + within the faceswap/config folder. + + The following variables should be defined: + _HELPTEXT: A string describing what this plugin does + _DEFAULTS: A dictionary containing the options, defaults and meta information. The + dictionary should be defined as: + {: {}} + + should always be lower text. + dictionary requirements are listed below. + + The following keys are expected for the _DEFAULTS dict: + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid data types are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + group: [optional]. A group for grouping options together in the GUI. If not + provided this will not group this option with any others. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and data types this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and data types this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. +""" + + +_HELPTEXT = ( + "Import Detector options.\n" + "Imports a detected face bounding box from an external .json file.\n" + ) + + +_DEFAULTS = { + "file_name": { + "default": "import.json", + "info": "The import file should be stored in the same folder as the video (if extracting " + "from a video file) or inside the folder of images (if importing from a folder of images)", + "datatype": str, + "choices": [], + "group": "settings", + "gui_radio": False, + "fixed": True, + }, + "origin": { + "default": "top-left", + "info": "The origin (0, 0) location of the co-ordinates system used. " + "\n\t top-left: The origin (0, 0) of the canvas is at the top left " + "corner." + "\n\t bottom-left: The origin (0, 0) of the canvas is at the bottom " + "left corner." + "\n\t top-right: The origin (0, 0) of the canvas is at the top right " + "corner." + "\n\t bottom-right: The origin (0, 0) of the canvas is at the bottom " + "right corner.", + "datatype": str, + "choices": ["top-left", "bottom-left", "top-right", "bottom-right"], + "group": "output", + "gui_radio": True + } +} diff --git a/plugins/extract/extract_media.py b/plugins/extract/extract_media.py new file mode 100644 index 0000000000..b9d3f84a33 --- /dev/null +++ b/plugins/extract/extract_media.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" Object for holding and manipulating media passing through a faceswap extraction pipeline """ +from __future__ import annotations +import logging +import typing as T + +import cv2 + +from lib.logger import parse_class_init + +if T.TYPE_CHECKING: + import numpy as np + from lib.align.alignments import PNGHeaderSourceDict + from lib.align.detected_face import DetectedFace + +logger = logging.getLogger(__name__) + + +class ExtractMedia: + """ An object that passes through the :class:`~plugins.extract.pipeline.Extractor` pipeline. + + Parameters + ---------- + filename: str + The base name of the original frame's filename + image: :class:`numpy.ndarray` + The original frame or a faceswap aligned face image + detected_faces: list, optional + A list of :class:`~lib.align.DetectedFace` objects. Detected faces can be added + later with :func:`add_detected_faces`. Setting ``None`` will default to an empty list. + Default: ``None`` + is_aligned: bool, optional + ``True`` if the :attr:`image` is an aligned faceswap image otherwise ``False``. Used for + face filtering with vggface2. Aligned faceswap images will automatically skip detection, + alignment and masking. Default: ``False`` + """ + + def __init__(self, + filename: str, + image: np.ndarray, + detected_faces: list[DetectedFace] | None = None, + is_aligned: bool = False) -> None: + logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] + self._filename = filename + self._image: np.ndarray | None = image + self._image_shape = T.cast(tuple[int, int, int], image.shape) + self._detected_faces: list[DetectedFace] = ([] if detected_faces is None + else detected_faces) + self._is_aligned = is_aligned + self._frame_metadata: PNGHeaderSourceDict | None = None + self._sub_folders: list[str | None] = [] + + @property + def filename(self) -> str: + """ str: The base name of the :attr:`image` filename. """ + return self._filename + + @property + def image(self) -> np.ndarray: + """ :class:`numpy.ndarray`: The source frame for this object. """ + assert self._image is not None + return self._image + + @property + def image_shape(self) -> tuple[int, int, int]: + """ tuple: The shape of the stored :attr:`image`. """ + return self._image_shape + + @property + def image_size(self) -> tuple[int, int]: + """ tuple: The (`height`, `width`) of the stored :attr:`image`. """ + return self._image_shape[:2] + + @property + def detected_faces(self) -> list[DetectedFace]: + """list: A list of :class:`~lib.align.DetectedFace` objects in the :attr:`image`. """ + return self._detected_faces + + @property + def is_aligned(self) -> bool: + """ bool. ``True`` if :attr:`image` is an aligned faceswap image otherwise ``False`` """ + return self._is_aligned + + @property + def frame_metadata(self) -> PNGHeaderSourceDict: + """ dict: The frame metadata that has been added from an aligned image. This property + should only be called after :func:`add_frame_metadata` has been called when processing + an aligned face. For all other instances an assertion error will be raised. + + Raises + ------ + AssertionError + If frame metadata has not been populated from an aligned image + """ + assert self._frame_metadata is not None + return self._frame_metadata + + @property + def sub_folders(self) -> list[str | None]: + """ list: The sub_folders that the faces should be output to. Used when binning filter + output is enabled. The list corresponds to the list of detected faces + """ + return self._sub_folders + + def get_image_copy(self, color_format: T.Literal["BGR", "RGB", "GRAY"]) -> np.ndarray: + """ Get a copy of the image in the requested color format. + + Parameters + ---------- + color_format: ['BGR', 'RGB', 'GRAY'] + The requested color format of :attr:`image` + + Returns + ------- + :class:`numpy.ndarray`: + A copy of :attr:`image` in the requested :attr:`color_format` + """ + logger.trace("Requested color format '%s' for frame '%s'", # type:ignore[attr-defined] + color_format, self._filename) + image = getattr(self, f"_image_as_{color_format.lower()}")() + return image + + def add_detected_faces(self, faces: list[DetectedFace]) -> None: + """ Add detected faces to the object. Called at the end of each extraction phase. + + Parameters + ---------- + faces: list + A list of :class:`~lib.align.DetectedFace` objects + """ + logger.trace("Adding detected faces for filename: '%s'. " # type:ignore[attr-defined] + "(faces: %s, lrtb: %s)", self._filename, faces, + [(face.left, face.right, face.top, face.bottom) for face in faces]) + self._detected_faces = faces + + def add_sub_folders(self, folders: list[str | None]) -> None: + """ Add detected faces to the object. Called at the end of each extraction phase. + + Parameters + ---------- + folders: list + A list of str sub folder names or ``None`` if no sub folder is required. Should + correspond to the detected faces list + """ + logger.trace("Adding sub folders for filename: '%s'. " # type:ignore[attr-defined] + "(folders: %s)", self._filename, folders,) + self._sub_folders = folders + + def remove_image(self) -> None: + """ Delete the image and reset :attr:`image` to ``None``. + + Required for multi-phase extraction to avoid the frames stacking RAM. + """ + logger.trace("Removing image for filename: '%s'", # type:ignore[attr-defined] + self._filename) + del self._image + self._image = None + + def set_image(self, image: np.ndarray) -> None: + """ Add the image back into :attr:`image` + + Required for multi-phase extraction adds the image back to this object. + + Parameters + ---------- + image: :class:`numpy.ndarry` + The original frame to be re-applied to for this :attr:`filename` + """ + logger.trace("Reapplying image: (filename: `%s`, " # type:ignore[attr-defined] + "image shape: %s)", self._filename, image.shape) + self._image = image + + def add_frame_metadata(self, metadata: PNGHeaderSourceDict) -> None: + """ Add the source frame metadata from an aligned PNG's header data. + + metadata: dict + The contents of the 'source' field in the PNG header + """ + logger.trace("Adding PNG Source data for '%s': %s", # type:ignore[attr-defined] + self._filename, metadata) + dims = T.cast(tuple[int, int], metadata["source_frame_dims"]) + self._image_shape = (*dims, 3) + self._frame_metadata = metadata + + def _image_as_bgr(self) -> np.ndarray: + """ Get a copy of the source frame in BGR format. + + Returns + ------- + :class:`numpy.ndarray`: + A copy of :attr:`image` in BGR color format """ + return self.image[..., :3].copy() + + def _image_as_rgb(self) -> np.ndarray: + """ Get a copy of the source frame in RGB format. + + Returns + ------- + :class:`numpy.ndarray`: + A copy of :attr:`image` in RGB color format """ + return self.image[..., 2::-1].copy() + + def _image_as_gray(self) -> np.ndarray: + """ Get a copy of the source frame in gray-scale format. + + Returns + ------- + :class:`numpy.ndarray`: + A copy of :attr:`image` in gray-scale color format """ + return cv2.cvtColor(self.image.copy(), cv2.COLOR_BGR2GRAY) diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 0284a604fb..8b5d71e0ad 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -5,7 +5,7 @@ See the override methods for which methods are required. -The plugin will receive a :class:`~plugins.extract.pipeline.ExtractMedia` object. +The plugin will receive a :class:`~plugins.extract.extract_media.ExtractMedia` object. For each source item, the plugin must pass a dict to finalize containing: @@ -23,9 +23,10 @@ from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa -from lib.align import AlignedFace, transform_image +from lib.align import AlignedFace, LandmarkType, transform_image from lib.utils import FaceswapError -from plugins.extract._base import BatchType, Extractor, ExtractorBatch, ExtractMedia +from plugins.extract import ExtractMedia +from plugins.extract._base import BatchType, ExtractorBatch, Extractor if T.TYPE_CHECKING: from collections.abc import Generator @@ -79,6 +80,8 @@ class Masker(Extractor): # pylint:disable=abstract-method plugins.extract.align._base : Aligner parent class for extraction plugins. """ + _logged_lm_count_once = False + def __init__(self, git_model_id: int | None = None, model_filename: str | None = None, @@ -94,20 +97,41 @@ def __init__(self, self.input_size = 256 # Override for model specific input_size self.coverage_ratio = 1.0 # Override for model specific coverage_ratio + # Override if a specific type of landmark data is required: + self.landmark_type: LandmarkType | None = None + self._plugin_type = "mask" self._storage_name = self.__module__.rsplit(".", maxsplit=1)[-1].replace("_", "-") self._storage_centering: CenteringType = "face" # Centering to store the mask at self._storage_size = 128 # Size to store masks at. Leave this at default logger.debug("Initialized %s", self.__class__.__name__) + def _maybe_log_warning(self, face: AlignedFace) -> None: + """ Log a warning, once, if we do not have full facial landmarks + + Parameters + ---------- + face: :class:`~lib.align.aligned_face.AlignedFace` + The aligned face object to test the landmark type for + """ + if face.landmark_type != LandmarkType.LM_2D_4 or self._logged_lm_count_once: + return + + msg = "are likely to be sub-standard" + msg = "can not be be generated" if self.name in ("Components", "Extended") else msg + + logger.warning("Extracted faces do not contain facial landmark data. '%s' masks %s.", + self.name, msg) + self._logged_lm_count_once = True + def get_batch(self, queue: Queue) -> tuple[bool, MaskerBatch]: """ Get items for inputting into the masker from the queue in batches Items are returned from the ``queue`` in batches of :attr:`~plugins.extract._base.Extractor.batchsize` - Items are received as :class:`~plugins.extract.pipeline.ExtractMedia` objects and converted - to ``dict`` for internal processing. + Items are received as :class:`~plugins.extract.extract_media.ExtractMedia` objects and + converted to ``dict`` for internal processing. To ensure consistent batch sizes for masker the items are split into separate items for each :class:`~lib.align.DetectedFace` object. @@ -163,6 +187,8 @@ def get_batch(self, queue: Queue) -> tuple[bool, MaskerBatch]: dtype="float32", is_aligned=item.is_aligned) + self._maybe_log_warning(feed_face) + assert feed_face.face is not None if not item.is_aligned: # Split roi mask from feed face alpha channel @@ -240,7 +266,7 @@ def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: Yields ------ - :class:`~plugins.extract.pipeline.ExtractMedia` + :class:`~plugins.extract.extract_media.ExtractMedia` The :attr:`DetectedFaces` list will be populated for this class with the bounding boxes, landmarks and masks for the detected faces found in the frame. """ @@ -249,6 +275,10 @@ def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: batch.detected_faces, batch.feed_faces, batch.roi_masks): + if self.name in ("Components", "Extended") and not np.any(mask): + # Components/Extended masks can return empty when called from the manual tool with + # 4 Point ROI landmarks + continue self._crop_out_of_bounds(mask, roi_mask) face.add_mask(self._storage_name, mask, diff --git a/plugins/extract/mask/components.py b/plugins/extract/mask/components.py index a787023512..0a71af4866 100644 --- a/plugins/extract/mask/components.py +++ b/plugins/extract/mask/components.py @@ -7,6 +7,8 @@ import cv2 import numpy as np +from lib.align import LandmarkType + from ._base import BatchType, Masker if T.TYPE_CHECKING: @@ -26,6 +28,7 @@ def __init__(self, **kwargs) -> None: self.vram = 0 # Doesn't use GPU self.vram_per_batch = 0 self.batchsize = 1 + self.landmark_type = LandmarkType.LM_2D_68 def init_model(self) -> None: logger.debug("No mask model to initialize") @@ -40,6 +43,10 @@ def predict(self, feed: np.ndarray) -> np.ndarray: faces: list[AlignedFace] = feed[1] feed = feed[0] for mask, face in zip(feed, faces): + if LandmarkType.from_shape(face.landmarks.shape) != self.landmark_type: + # Called from the manual tool. # TODO This will only work with BS1 + feed = np.zeros_like(feed) + continue parts = self.parse_parts(np.array(face.landmarks)) for item in parts: a_item = np.rint(np.concatenate(item)).astype("int32") diff --git a/plugins/extract/mask/extended.py b/plugins/extract/mask/extended.py index 633238367b..d6970cb0e5 100644 --- a/plugins/extract/mask/extended.py +++ b/plugins/extract/mask/extended.py @@ -6,6 +6,9 @@ import cv2 import numpy as np + +from lib.align import LandmarkType + from ._base import BatchType, Masker logger = logging.getLogger(__name__) @@ -25,6 +28,7 @@ def __init__(self, **kwargs): self.vram = 0 # Doesn't use GPU self.vram_per_batch = 0 self.batchsize = 1 + self.landmark_type = LandmarkType.LM_2D_68 def init_model(self) -> None: logger.debug("No mask model to initialize") @@ -39,6 +43,10 @@ def predict(self, feed: np.ndarray) -> np.ndarray: faces: list[AlignedFace] = feed[1] feed = feed[0] for mask, face in zip(feed, faces): + if LandmarkType.from_shape(face.landmarks.shape) != self.landmark_type: + # Called from the manual tool. # TODO This will only work with BS1 + feed = np.zeros_like(feed) + continue parts = self.parse_parts(np.array(face.landmarks)) for item in parts: a_item = np.rint(np.concatenate(item)).astype("int32") diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 0cca3e5092..5a051936bb 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -10,25 +10,27 @@ """ from __future__ import annotations import logging +import os import typing as T -import cv2 - +from lib.align import LandmarkType from lib.gpu_stats import GPUStats +from lib.logger import parse_class_init from lib.queue_manager import EventQueue, queue_manager, QueueEmpty -from lib.utils import get_backend +from lib.serializer import get_serializer +from lib.utils import get_backend, FaceswapError from plugins.plugin_loader import PluginLoader if T.TYPE_CHECKING: - import numpy as np from collections.abc import Generator - from lib.align.alignments import PNGHeaderSourceDict - from lib.align.detected_face import DetectedFace - from plugins.extract._base import Extractor as PluginExtractor - from plugins.extract.detect._base import Detector - from plugins.extract.align._base import Aligner - from plugins.extract.mask._base import Masker - from plugins.extract.recognition._base import Identity + from ._base import Extractor as PluginExtractor + from .align._base import Aligner + from .align.external import Align as AlignImport + from .detect._base import Detector + from .detect.external import Detect as DetectImport + from .mask._base import Masker + from .recognition._base import Identity + from . import ExtractMedia logger = logging.getLogger(__name__) _INSTANCES = -1 # Tracking for multiple instances of pipeline @@ -110,12 +112,7 @@ def __init__(self, re_feed: int = 0, re_align: bool = False, disable_filter: bool = False) -> None: - logger.debug("Initializing %s: (detector: %s, aligner: %s, masker: %s, recognition: %s, " - "configfile: %s, multiprocess: %s, exclude_gpus: %s, rotate_images: %s, " - "min_size: %s, normalize_method: %s, re_feed: %s, re_align: %s, " - "disable_filter: %s)", self.__class__.__name__, detector, aligner, masker, - recognition, configfile, multiprocess, exclude_gpus, rotate_images, min_size, - normalize_method, re_feed, re_align, disable_filter) + logger.debug(parse_class_init(locals())) self._instance = _get_instance() maskers = [T.cast(str | None, masker)] if not isinstance(masker, list) else T.cast(list[str | None], @@ -128,7 +125,7 @@ def __init__(self, # TODO Calculate scaling for more plugins than currently exist in _parallel_scaling self._scaling_fallback = 0.4 self._vram_stats = self._get_vram_stats() - self._detect = self._load_detect(detector, rotate_images, min_size, configfile) + self._detect = self._load_detect(detector, aligner, rotate_images, min_size, configfile) self._align = self._load_align(aligner, configfile, normalize_method, @@ -212,7 +209,7 @@ def final_pass(self) -> bool: >>> extractor.input_queue.put(extract_media) """ retval = self._phase_index == len(self._phases) - 1 - logger.trace(retval) # type: ignore + logger.trace(retval) # type:ignore[attr-defined] return retval @property @@ -266,7 +263,7 @@ def launch(self) -> None: for phase in self._current_phase: self._launch_plugin(phase) - def detected_faces(self) -> Generator["ExtractMedia", None, None]: + def detected_faces(self) -> Generator[ExtractMedia, None, None]: """ Generator that returns results, frame by frame from the extraction pipeline This is the exit point for the extraction pipeline and is used to obtain the output @@ -274,7 +271,7 @@ def detected_faces(self) -> Generator["ExtractMedia", None, None]: Yields ------ - faces: :class:`ExtractMedia` + faces: :class:`~plugins.extract.extract_media.ExtractMedia` The populated extracted media object. Example @@ -300,11 +297,89 @@ def detected_faces(self) -> Generator["ExtractMedia", None, None]: self._join_threads() if self.final_pass: + for plugin in self._all_plugins: + plugin.on_completion() logger.debug("Detection Complete") else: self._phase_index += 1 logger.debug("Switching to phase: %s", self._current_phase) + def _disable_lm_maskers(self) -> None: + """ Disable any 68 point landmark based maskers if alignment data is not 2D 68 + point landmarks and update the process flow/phases accordingly """ + logger.warning("Alignment data is not 68 point 2D landmarks. Some Faceswap functionality " + "will be unavailable for these faces") + + rem_maskers = [m.name for m in self._mask + if m is not None and m.landmark_type == LandmarkType.LM_2D_68] + self._mask = [m for m in self._mask if m is None or m.name not in rem_maskers] + + self._flow = [ + item for item in self._flow + if not item.startswith("mask") + or item.startswith("mask") and int(item.rsplit("_", maxsplit=1)[-1]) < len(self._mask)] + + self._phases = [[s for s in p if s in self._flow] for p in self._phases + if any(t in p for t in self._flow)] + + for queue in self._queues: + queue_manager.del_queue(queue) + del self._queues + self._queues = self._add_queues() + + logger.warning("The following maskers have been disabled due to unsupported landmarks: %s", + rem_maskers) + + def import_data(self, input_location: str) -> None: + """ Import json data to the detector and/or aligner if 'import' plugin has been selected + + Parameters + ---------- + input_location: str + Full path to the input location for the extract process + """ + assert self._detect is not None + import_plugins: list[DetectImport | AlignImport] = [ + p for p in (self._detect, self.aligner) # type:ignore[misc] + if T.cast(str, p.name).lower() == "external"] + + if not import_plugins: + return + + align_origin = None + assert self.aligner.name is not None + if self.aligner.name.lower() == "external": + align_origin = self.aligner.config["origin"] + + logger.info("Importing external data for %s from json file...", + " and ".join([p.__class__.__name__ for p in import_plugins])) + + folder = input_location + folder = folder if os.path.isdir(folder) else os.path.dirname(folder) + + last_fname = "" + is_68_point = True + for plugin in import_plugins: + plugin_type = plugin.__class__.__name__ + path = os.path.join(folder, plugin.config["file_name"]) + if not os.path.isfile(path): + raise FaceswapError(f"{plugin_type} import file could not be found at '{path}'") + + if path != last_fname: # Different import file for aligner data + last_fname = path + data = get_serializer("json").load(path) + + if plugin_type == "Detect": + plugin.import_data(data, align_origin) # type:ignore[call-arg] + else: + plugin.import_data(data) # type:ignore[call-arg] + is_68_point = plugin.landmark_type == LandmarkType.LM_2D_68 # type:ignore[union-attr] # noqa:E501 # pylint:disable="line-too-long" + + if not is_68_point: + self._disable_lm_maskers() + + logger.info("Imported external data") + # <<< INTERNAL METHODS >>> # @property def _parallel_scaling(self) -> dict[int, float]: @@ -616,14 +691,40 @@ def _load_align(self, def _load_detect(self, detector: str | None, + aligner: str | None, rotation: str | None, min_size: int, configfile: str | None) -> Detector | None: - """ Set global arguments and load detector plugin """ + """ Set global arguments and load detector plugin + + Parameters + ---------- + detector: str | None + The name of the face detection plugin to use. ``None`` for no detection + aligner: str | None + The name of the face aligner plugin to use. ``None`` for no aligner + rotation: str | None + The rotation to perform on detection. ``None`` for no rotation + min_size: int + The minimum size of detected faces to accept + configfile: str | None + Full path to a custom config file to use. ``None`` for default config + + Returns + ------- + :class:`~plugins.extract.detect._base.Detector` | None + The face detection plugin to use, or ``None`` if no detection to be performed + """ if detector is None or detector.lower() == "none": logger.debug("No detector selected. Returning None") return None detector_name = detector.replace("-", "_").lower() + + if aligner == "external" and detector_name != "external": + logger.warning("Unsupported '%s' detector selected for 'External' aligner. Switching " + "detector to 'External'", detector_name) + detector_name = aligner + logger.debug("Loading Detector: '%s'", detector_name) plugin = PluginLoader.get_detector(detector_name)(exclude_gpus=self._exclude_gpus, rotation=rotation, @@ -775,198 +876,3 @@ def _check_and_raise_error(self) -> None: """ Check all threads for errors and raise if one occurs """ for plugin in self._active_plugins: plugin.check_and_raise_error() - - -class ExtractMedia(): - """ An object that passes through the :class:`~plugins.extract.pipeline.Extractor` pipeline. - - Parameters - ---------- - filename: str - The base name of the original frame's filename - image: :class:`numpy.ndarray` - The original frame or a faceswap aligned face image - detected_faces: list, optional - A list of :class:`~lib.align.DetectedFace` objects. Detected faces can be added - later with :func:`add_detected_faces`. Setting ``None`` will default to an empty list. - Default: ``None`` - is_aligned: bool, optional - ``True`` if the :attr:`image` is an aligned faceswap image otherwise ``False``. Used for - face filtering with vggface2. Aligned faceswap images will automatically skip detection, - alignment and masking. Default: ``False`` - """ - - def __init__(self, - filename: str, - image: np.ndarray, - detected_faces: list[DetectedFace] | None = None, - is_aligned: bool = False) -> None: - logger.trace("Initializing %s: (filename: '%s', image shape: %s, " # type: ignore - "detected_faces: %s, is_aligned: %s)", self.__class__.__name__, filename, - image.shape, detected_faces, is_aligned) - self._filename = filename - self._image: np.ndarray | None = image - self._image_shape = T.cast(tuple[int, int, int], image.shape) - self._detected_faces: list[DetectedFace] = ([] if detected_faces is None - else detected_faces) - self._is_aligned = is_aligned - self._frame_metadata: PNGHeaderSourceDict | None = None - self._sub_folders: list[str | None] = [] - - @property - def filename(self) -> str: - """ str: The base name of the :attr:`image` filename. """ - return self._filename - - @property - def image(self) -> np.ndarray: - """ :class:`numpy.ndarray`: The source frame for this object. """ - assert self._image is not None - return self._image - - @property - def image_shape(self) -> tuple[int, int, int]: - """ tuple: The shape of the stored :attr:`image`. """ - return self._image_shape - - @property - def image_size(self) -> tuple[int, int]: - """ tuple: The (`height`, `width`) of the stored :attr:`image`. """ - return self._image_shape[:2] - - @property - def detected_faces(self) -> list[DetectedFace]: - """list: A list of :class:`~lib.align.DetectedFace` objects in the :attr:`image`. """ - return self._detected_faces - - @property - def is_aligned(self) -> bool: - """ bool. ``True`` if :attr:`image` is an aligned faceswap image otherwise ``False`` """ - return self._is_aligned - - @property - def frame_metadata(self) -> PNGHeaderSourceDict: - """ dict: The frame metadata that has been added from an aligned image. This property - should only be called after :func:`add_frame_metadata` has been called when processing - an aligned face. For all other instances an assertion error will be raised. - - Raises - ------ - AssertionError - If frame metadata has not been populated from an aligned image - """ - assert self._frame_metadata is not None - return self._frame_metadata - - @property - def sub_folders(self) -> list[str | None]: - """ list: The sub_folders that the faces should be output to. Used when binning filter - output is enabled. The list corresponds to the list of detected faces - """ - return self._sub_folders - - def get_image_copy(self, color_format: T.Literal["BGR", "RGB", "GRAY"]) -> np.ndarray: - """ Get a copy of the image in the requested color format. - - Parameters - ---------- - color_format: ['BGR', 'RGB', 'GRAY'] - The requested color format of :attr:`image` - - Returns - ------- - :class:`numpy.ndarray`: - A copy of :attr:`image` in the requested :attr:`color_format` - """ - logger.trace("Requested color format '%s' for frame '%s'", # type: ignore - color_format, self._filename) - image = getattr(self, f"_image_as_{color_format.lower()}")() - return image - - def add_detected_faces(self, faces: list[DetectedFace]) -> None: - """ Add detected faces to the object. Called at the end of each extraction phase. - - Parameters - ---------- - faces: list - A list of :class:`~lib.align.DetectedFace` objects - """ - logger.trace("Adding detected faces for filename: '%s'. " # type: ignore - "(faces: %s, lrtb: %s)", self._filename, faces, - [(face.left, face.right, face.top, face.bottom) for face in faces]) - self._detected_faces = faces - - def add_sub_folders(self, folders: list[str | None]) -> None: - """ Add detected faces to the object. Called at the end of each extraction phase. - - Parameters - ---------- - folders: list - A list of str sub folder names or ``None`` if no sub folder is required. Should - correspond to the detected faces list - """ - logger.trace("Adding sub folders for filename: '%s'. " # type: ignore - "(folders: %s)", self._filename, folders,) - self._sub_folders = folders - - def remove_image(self) -> None: - """ Delete the image and reset :attr:`image` to ``None``. - - Required for multi-phase extraction to avoid the frames stacking RAM. - """ - logger.trace("Removing image for filename: '%s'", self._filename) # type: ignore - del self._image - self._image = None - - def set_image(self, image: np.ndarray) -> None: - """ Add the image back into :attr:`image` - - Required for multi-phase extraction adds the image back to this object. - - Parameters - ---------- - image: :class:`numpy.ndarry` - The original frame to be re-applied to for this :attr:`filename` - """ - logger.trace("Reapplying image: (filename: `%s`, image shape: %s)", # type: ignore - self._filename, image.shape) - self._image = image - - def add_frame_metadata(self, metadata: PNGHeaderSourceDict) -> None: - """ Add the source frame metadata from an aligned PNG's header data. - - metadata: dict - The contents of the 'source' field in the PNG header - """ - logger.trace("Adding PNG Source data for '%s': %s", # type:ignore - self._filename, metadata) - dims = T.cast(tuple[int, int], metadata["source_frame_dims"]) - self._image_shape = (*dims, 3) - self._frame_metadata = metadata - - def _image_as_bgr(self) -> np.ndarray: - """ Get a copy of the source frame in BGR format. - - Returns - ------- - :class:`numpy.ndarray`: - A copy of :attr:`image` in BGR color format """ - return self.image[..., :3].copy() - - def _image_as_rgb(self) -> np.ndarray: - """ Get a copy of the source frame in RGB format. - - Returns - ------- - :class:`numpy.ndarray`: - A copy of :attr:`image` in RGB color format """ - return self.image[..., 2::-1].copy() - - def _image_as_gray(self) -> np.ndarray: - """ Get a copy of the source frame in gray-scale format. - - Returns - ------- - :class:`numpy.ndarray`: - A copy of :attr:`image` in gray-scale color format """ - return cv2.cvtColor(self.image.copy(), cv2.COLOR_BGR2GRAY) diff --git a/plugins/extract/recognition/_base.py b/plugins/extract/recognition/_base.py index 3630607b98..61662e623b 100644 --- a/plugins/extract/recognition/_base.py +++ b/plugins/extract/recognition/_base.py @@ -4,7 +4,7 @@ All Recognition Plugins should inherit from this class. See the override methods for which methods are required. -The plugin will receive a :class:`~plugins.extract.pipeline.ExtractMedia` object. +The plugin will receive a :class:`~plugins.extract.extract_media.ExtractMedia` object. For each source frame, the plugin must pass a dict to finalize containing: @@ -24,11 +24,11 @@ import numpy as np from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa -from lib.align import AlignedFace, DetectedFace +from lib.align import AlignedFace, DetectedFace, LandmarkType from lib.image import read_image_meta from lib.utils import FaceswapError -from plugins.extract._base import BatchType, Extractor, ExtractorBatch -from plugins.extract.pipeline import ExtractMedia +from plugins.extract import ExtractMedia +from plugins.extract._base import BatchType, ExtractorBatch, Extractor if T.TYPE_CHECKING: from collections.abc import Generator @@ -75,6 +75,8 @@ class Identity(Extractor): # pylint:disable=abstract-method plugins.extract.mask._base : Masker parent class for extraction plugins. """ + _logged_lm_count_once = False + def __init__(self, git_model_id: int | None = None, model_filename: str | None = None, @@ -101,7 +103,7 @@ def _get_detected_from_aligned(self, item: ExtractMedia) -> None: Parameters ---------- - item: :class:`~plugins.extract.pipeline.ExtractMedia` + item: :class:`~plugins.extract.extract_media.ExtractMedia` The extract media to populate the detected face for """ detected_face = DetectedFace() @@ -113,14 +115,28 @@ def _get_detected_from_aligned(self, item: ExtractMedia) -> None: logger.debug("Obtained detected face: (filename: %s, detected_face: %s)", item.filename, item.detected_faces) + def _maybe_log_warning(self, face: AlignedFace) -> None: + """ Log a warning, once, if we do not have full facial landmarks + + Parameters + ---------- + face: :class:`~lib.align.aligned_face.AlignedFace` + The aligned face object to test the landmark type for + """ + if face.landmark_type != LandmarkType.LM_2D_4 or self._logged_lm_count_once: + return + logger.warning("Extracted faces do not contain facial landmark data. '%s' " + "identity data is likely to be sub-standard.", self.name) + self._logged_lm_count_once = True + def get_batch(self, queue: Queue) -> tuple[bool, RecogBatch]: """ Get items for inputting into the recognition from the queue in batches Items are returned from the ``queue`` in batches of :attr:`~plugins.extract._base.Extractor.batchsize` - Items are received as :class:`~plugins.extract.pipeline.ExtractMedia` objects and converted - to :class:`RecogBatch` for internal processing. + Items are received as :class:`~plugins.extract.extract_media.ExtractMedia` objects and + converted to :class:`RecogBatch` for internal processing. To ensure consistent batch sizes for masker the items are split into separate items for each :class:`~lib.align.DetectedFace` object. @@ -173,6 +189,8 @@ def get_batch(self, queue: Queue) -> tuple[bool, RecogBatch]: dtype="float32", is_aligned=item.is_aligned) + self._maybe_log_warning(feed_face) + batch.detected_faces.append(face) batch.feed_faces.append(feed_face) batch.filename.append(item.filename) @@ -234,7 +252,7 @@ def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: Yields ------ - :class:`~plugins.extract.pipeline.ExtractMedia` + :class:`~plugins.extract.extract_media.ExtractMedia` The :attr:`DetectedFaces` list will be populated for this class with the bounding boxes, landmarks and masks for the detected faces found in the frame. """ diff --git a/scripts/convert.py b/scripts/convert.py index 57d98653cf..20cf4c61a8 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -23,7 +23,7 @@ from lib.multithreading import MultiThread, total_cpus from lib.queue_manager import queue_manager from lib.utils import FaceswapError, get_folder, get_image_paths, handle_deprecated_cliopts -from plugins.extract.pipeline import Extractor, ExtractMedia +from plugins.extract import ExtractMedia, Extractor from plugins.plugin_loader import PluginLoader if T.TYPE_CHECKING: @@ -44,7 +44,7 @@ class ConvertItem: Parameters ---------- - input: :class:`~plugins.extract.pipeline.ExtractMedia` + input: :class:`~plugins.extract.extract_media.ExtractMedia` The ExtractMedia object holding the :attr:`filename`, :attr:`image` and attr:`list` of :class:`~lib.align.DetectedFace` objects loaded from disk feed_faces: list, Optional @@ -702,6 +702,7 @@ def _save(self, completion_event: Event) -> None: # Write out preview image for the GUI every 10 frames if writing to stream if write_preview and idx % 10 == 0 and not os.path.exists(preview_image): logger.debug("Writing GUI Preview image: '%s'", preview_image) + assert isinstance(image, np.ndarray) cv2.imwrite(preview_image, image) self._writer.write(filename, image) self._writer.close() @@ -1093,7 +1094,7 @@ def _queue_out_frames(self, batch: list[ConvertItem], swapped_faces: np.ndarray) logger.trace("Queued out batch. Batchsize: %s", len(batch)) # type:ignore -class OptionalActions(): +class OptionalActions(): # pylint:disable=too-few-public-methods """ Process specific optional actions for Convert. Currently only handles skip faces. This class should probably be (re)moved. diff --git a/scripts/extract.py b/scripts/extract.py index bd6cc41293..da9edf1d15 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -17,7 +17,7 @@ from lib.image import encode_image, generate_thumbnail, ImagesLoader, ImagesSaver, read_image_meta from lib.multithreading import MultiThread from lib.utils import get_folder, handle_deprecated_cliopts, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS -from plugins.extract.pipeline import Extractor, ExtractMedia +from plugins.extract import ExtractMedia, Extractor from scripts.fsmedia import Alignments, PostProcess, finalize if T.TYPE_CHECKING: @@ -596,8 +596,8 @@ def _reload(self, detected_faces: dict[str, ExtractMedia]) -> None: Parameters ---------- detected_faces: dict - Dictionary of :class:`plugins.extract.pipeline.ExtractMedia` with the filename as the - key for repopulating the image attribute. + Dictionary of :class:`~plugins.extract.extract_media.ExtractMedia` with the filename as + the key for repopulating the image attribute. """ logger.debug("Reload Images: Start. Detected Faces Count: %s", len(detected_faces)) load_queue = self._extractor.input_queue @@ -643,6 +643,7 @@ def __init__(self, self._alignments = Alignments(self._args, True, self._loader.is_video) self._extractor = extractor + self._extractor.import_data(self._args.input_dir) self._existing_count = 0 self._set_skip_list() @@ -753,7 +754,7 @@ def _output_processing(self, extract_media: ExtractMedia, size: int) -> None: Parameters ---------- - extract_media: :class:`plugins.extract.pipeline.ExtractMedia` + extract_media: :class:`~plugins.extract.extract_media.ExtractMedia` Output from :class:`plugins.extract.pipeline.Extractor` size: int The size that the aligned face should be created at @@ -785,7 +786,7 @@ def _output_faces(self, saver: ImagesSaver | None, extract_media: ExtractMedia) ---------- saver: :class:`lib.images.ImagesSaver` or ``None`` The background saver for saving the image or ``None`` if faces are not to be saved - extract_media: :class:`~plugins.extract.pipeline.ExtractMedia` + extract_media: :class:`~plugins.extract.extract_media.ExtractMedia` The output from :class:`~plugins.extract.Pipeline.Extractor` """ logger.trace("Outputting faces for %s", extract_media.filename) # type: ignore diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index e519078904..68f503a788 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -25,7 +25,7 @@ from collections.abc import Generator from argparse import Namespace from lib.align import AlignedFace - from plugins.extract.pipeline import ExtractMedia + from plugins.extract import ExtractMedia logger = logging.getLogger(__name__) @@ -414,14 +414,15 @@ def do_actions(self, extract_media: ExtractMedia) -> None: Parameters ---------- - extract_media: :class:`~plugins.extract.pipeline.ExtractMedia` - The :class:`~plugins.extract.pipeline.ExtractMedia` object to perform the + extract_media: :class:`~plugins.extract.extract_media.ExtractMedia` + The :class:`~plugins.extract.extract_media.ExtractMedia` object to perform the action on. Returns ------- - :class:`~plugins.extract.pipeline.ExtractMedia` - The original :class:`~plugins.extract.pipeline.ExtractMedia` with any actions applied + :class:`~plugins.extract.extract_media.ExtractMedia` + The original :class:`~plugins.extract.extract_media.ExtractMedia` with any actions + applied """ for action in self._actions: logger.debug("Performing postprocess action: '%s'", action.__class__.__name__) @@ -458,8 +459,8 @@ def process(self, extract_media: ExtractMedia) -> None: Parameters ---------- - extract_media: :class:`~plugins.extract.pipeline.ExtractMedia` - The :class:`~plugins.extract.pipeline.ExtractMedia` object to perform the + extract_media: :class:`~plugins.extract.extract_media.ExtractMedia` + The :class:`~plugins.extract.extract_media.ExtractMedia` object to perform the action on. """ raise NotImplementedError @@ -578,9 +579,9 @@ def process(self, extract_media: ExtractMedia) -> None: Parameters ---------- - extract_media: :class:`~plugins.extract.pipeline.ExtractMedia` - The :class:`~plugins.extract.pipeline.ExtractMedia` object that contains the faces to - draw the landmarks on to + extract_media: :class:`~plugins.extract.extract_media.ExtractMedia` + The :class:`~plugins.extract.extract_media.ExtractMedia` object that contains the faces + to draw the landmarks on to """ frame = os.path.splitext(os.path.basename(extract_media.filename))[0] for idx, face in enumerate(extract_media.detected_faces): diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index 8c9af327a6..531c6958c6 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -10,7 +10,7 @@ from lib.utils import FaceswapError, handle_deprecated_cliopts, VIDEO_EXTENSIONS from .media import AlignmentData -from .jobs import Check, Sort, Spatial # noqa pylint:disable=unused-import +from .jobs import Check, Export, Sort, Spatial # noqa pylint:disable=unused-import from .jobs_faces import FromFaces, RemoveFaces, Rename # noqa pylint:disable=unused-import from .jobs_frames import Draw, Extract # noqa pylint:disable=unused-import @@ -34,7 +34,7 @@ class Alignments(): """ def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - self._requires_alignments = ["sort", "spatial"] + self._requires_alignments = ["export", "sort", "spatial"] self._requires_faces = ["extract", "from-faces"] self._requires_frames = ["draw", "extract", diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py index 26221105b3..84be7261f1 100644 --- a/tools/alignments/cli.py +++ b/tools/alignments/cli.py @@ -52,8 +52,9 @@ def get_argument_list() -> list[dict[str, T.Any]]: "opts": ("-j", "--job"), "action": Radio, "type": str, - "choices": ("draw", "extract", "from-faces", "missing-alignments", "missing-frames", - "multi-faces", "no-faces", "remove-faces", "rename", "sort", "spatial"), + "choices": ("draw", "extract", "export", "from-faces", "missing-alignments", + "missing-frames", "multi-faces", "no-faces", "remove-faces", "rename", + "sort", "spatial"), "group": _("processing"), "required": True, "help": _( @@ -61,6 +62,12 @@ def get_argument_list() -> list[dict[str, T.Any]]: "alignments file (-a) to be passed in." "\nL|'draw': Draw landmarks on frames in the selected folder/video. A " "subfolder will be created within the frames folder to hold the output.{0}" + "\nL|'export': Export the contents of an alignments file to a json file. Can be " + "used for editing alignment information in external tools and then re-importing " + "by using Faceswap's Extract 'Import' plugins. Note: masks and identity vectors " + "will not be included in the exported file, so will be re-generated when the json " + "file is imported back into Faceswap. All data is exported with the origin (0, 0) " + "at the top left of the canvas." "\nL|'extract': Re-extract faces from the source frames/video based on " "alignment data. This is a lot quicker than re-detecting faces. Can pass in " "the '-een' (--extract-every-n) parameter to only extract every nth frame.{1}" diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index 2642ebaaaa..578ade1ea3 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -13,19 +13,23 @@ from sklearn import decomposition from tqdm import tqdm +from lib.logger import parse_class_init +from lib.serializer import get_serializer +from lib.utils import FaceswapError + from .media import Faces, Frames from .jobs_faces import FaceToFile if T.TYPE_CHECKING: from collections.abc import Generator from argparse import Namespace - from lib.align.alignments import PNGHeaderDict + from lib.align.alignments import AlignmentFileDict, PNGHeaderDict from .media import AlignmentData logger = logging.getLogger(__name__) -class Check(): +class Check: """ Frames and faces checking tasks. Parameters @@ -36,7 +40,7 @@ class Check(): The command line arguments that have called this job """ def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: - logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) + logger.debug(parse_class_init(locals())) self._alignments = alignments self._job = arguments.job self._type: T.Literal["faces", "frames"] | None = None @@ -371,7 +375,81 @@ def _move_faces(self, output_folder: str, items_output: list[tuple[str, int]]) - os.rename(src, dst) -class Sort(): +class Export: + """ Export alignments from a Faceswap .fsa file to a json formatted file. + + Parameters + ---------- + alignments: :class:`tools.lib_alignments.media.AlignmentData` + The alignments data loaded from an alignments file for this rename job + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py`. Unused + """ + def __init__(self, + alignments: AlignmentData, + arguments: Namespace) -> None: # pylint:disable=unused-argument + logger.debug(parse_class_init(locals())) + self._alignments = alignments + self._serializer = get_serializer("json") + self._output_file = self._get_output_file() + logger.debug("Initialized %s", self.__class__.__name__) + + def _get_output_file(self) -> str: + """ Obtain the name of an output file. If a file of the request name exists, then append a + digit to the end until a unique filename is found + + Returns + ------- + str + Full path to an output json file + """ + in_file = self._alignments.file + base_filename = f"{os.path.splitext(in_file)[0]}_export" + out_file = f"{base_filename}.json" + idx = 1 + while True: + if not os.path.exists(out_file): + break + logger.debug("Output file exists: '%s'", out_file) + out_file = f"{base_filename}_{idx}.json" + idx += 1 + logger.debug("Setting output file to '%s'", out_file) + return out_file + + @classmethod + def _format_face(cls, face: AlignmentFileDict) -> dict[str, list[int] | list[list[float]]]: + """ Format the relevant keys from an alignment file's face into the correct format for + export/import + + Parameters + ---------- + face: :class:`~lib.align.alignments.AlignmentFileDict` + The alignment dictionary for a face to process + + Returns + ------- + dict[str, list[int] | list[list[float]]] + The face formatted for exporting to a json file + """ + lms = face["landmarks_xy"] + assert isinstance(lms, np.ndarray) + retval = {"detected": [int(round(face["x"], 0)), + int(round(face["y"], 0)), + int(round(face["x"] + face["w"], 0)), + int(round(face["y"] + face["h"], 0))], + "landmarks_2d": lms.tolist()} + return retval + + def process(self) -> None: + """ Parse the imported alignments file and output relevant information to a json file """ + logger.info("[EXPORTING ALIGNMENTS]") # Tidy up cli output + formatted = {key: [self._format_face(face) for face in val["faces"]] + for key, val in self._alignments.data.items()} + logger.info("Saving export alignments to '%s'...", self._output_file) + self._serializer.save(self._output_file, formatted) + + +class Sort: """ Sort alignments' index by the order they appear in an image in left to right order. Parameters @@ -379,10 +457,12 @@ class Sort(): alignments: :class:`tools.lib_alignments.media.AlignmentData` The alignments data loaded from an alignments file for this rename job arguments: :class:`argparse.Namespace` - The :mod:`argparse` arguments as passed in from :mod:`tools.py` + The :mod:`argparse` arguments as passed in from :mod:`tools.py`. Unused """ - def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: - logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) + def __init__(self, + alignments: AlignmentData, + arguments: Namespace) -> None: # pylint:disable=unused-argument + logger.debug(parse_class_init(locals())) self._alignments = alignments logger.debug("Initialized %s", self.__class__.__name__) @@ -418,7 +498,7 @@ def reindex_faces(self) -> int: return reindexed -class Spatial(): +class Spatial: """ Apply spatial temporal filtering to landmarks Parameters @@ -433,7 +513,7 @@ class Spatial(): https://www.kaggle.com/selfishgene/animating-and-smoothing-3d-facial-keypoints/notebook """ def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: - logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) + logger.debug(parse_class_init(locals())) self.arguments = arguments self._alignments = alignments self._mappings: dict[int, str] = {} @@ -467,7 +547,7 @@ def _normalize_shapes(shapes_im_coords: np.ndarray Parameters ---------- shaped_im_coords: :class:`numpy.ndarray` - The 68 point landmarks + The facial landmarks Returns ------- @@ -530,7 +610,15 @@ def _normalize(self) -> None: """ Compile all original and normalized alignments """ logger.debug("Normalize") count = sum(1 for val in self._alignments.data.values() if val["faces"]) - landmarks_all = np.zeros((68, 2, int(count))) + + sample_lm = next((val["faces"][0]["landmarks_xy"] + for val in self._alignments.data.values() if val["faces"]), 68) + assert isinstance(sample_lm, np.ndarray) + lm_count = sample_lm.shape[0] + if lm_count != 68: + raise FaceswapError("Spatial smoothing only supports 68 point facial landmarks") + + landmarks_all = np.zeros((lm_count, 2, int(count))) end = 0 for key in tqdm(sorted(self._alignments.data.keys()), desc="Compiling", leave=False): @@ -539,7 +627,7 @@ def _normalize(self) -> None: continue # We should only be normalizing a single face, so just take # the first landmarks found - landmarks = np.array(val[0]["landmarks_xy"]).reshape((68, 2, 1)) + landmarks = np.array(val[0]["landmarks_xy"]).reshape((lm_count, 2, 1)) start = end end = start + landmarks.shape[2] # Store in one big array diff --git a/tools/alignments/jobs_frames.py b/tools/alignments/jobs_frames.py index 314f8031ad..3c25b48121 100644 --- a/tools/alignments/jobs_frames.py +++ b/tools/alignments/jobs_frames.py @@ -12,10 +12,10 @@ import numpy as np from tqdm import tqdm -from lib.align import DetectedFace, _EXTRACT_RATIOS +from lib.align import DetectedFace, EXTRACT_RATIOS, LANDMARK_PARTS, LandmarkType from lib.align.alignments import _VERSION, PNGHeaderDict from lib.image import encode_image, generate_thumbnail, ImagesSaver -from plugins.extract.pipeline import Extractor, ExtractMedia +from plugins.extract import ExtractMedia, Extractor from .media import ExtractedFaces, Frames if T.TYPE_CHECKING: @@ -41,14 +41,6 @@ def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: self._alignments = alignments self._frames = Frames(arguments.frames_dir) self._output_folder = self._set_output() - self._mesh_areas = {"mouth": (48, 68), - "right_eyebrow": (17, 22), - "left_eyebrow": (22, 27), - "right_eye": (36, 42), - "left_eye": (42, 48), - "nose": (27, 36), - "jaw": (0, 17), - "chin": (8, 11)} logger.debug("Initialized %s", self.__class__.__name__) def _set_output(self) -> str: @@ -121,12 +113,11 @@ def _annotate_landmarks(self, image: np.ndarray, landmarks: np.ndarray) -> None: image: :class:`numpy.ndarray` The frame that extract boxes are to be annotated on to landmarks: :class:`numpy.ndarray` - The 68 point landmarks that are to be annotated onto the frame + The facial landmarks that are to be annotated onto the frame """ # Mesh - for area, indices in self._mesh_areas.items(): - fill = area in ("right_eye", "left_eye", "mouth") - cv2.polylines(image, [landmarks[indices[0]:indices[1]]], fill, (255, 255, 0), 1) + for start, end, fill in LANDMARK_PARTS[LandmarkType.from_shape(landmarks.shape)].values(): + cv2.polylines(image, [landmarks[start:end]], fill, (255, 255, 0), 1) # Landmarks for (pos_x, pos_y) in landmarks: cv2.circle(image, (pos_x, pos_y), 1, (0, 255, 255), -1) @@ -462,9 +453,9 @@ def _pad_legacy_masks(cls, detected_face: DetectedFace) -> None: continue old_mask = mask.mask.astype("float32") / 255.0 size = old_mask.shape[0] - new_size = int(size + (size * _EXTRACT_RATIOS["face"]) / 2) + new_size = int(size + (size * EXTRACT_RATIOS["face"]) / 2) - shift = np.rint(offset * (size - (size * _EXTRACT_RATIOS["face"]))).astype("int32") + shift = np.rint(offset * (size - (size * EXTRACT_RATIOS["face"]))).astype("int32") pos = np.array([(new_size // 2 - size // 2) - shift[1], (new_size // 2) + (size // 2) - shift[1], (new_size // 2 - size // 2) - shift[0], diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index 29d187f86c..b0285a8194 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -684,7 +684,7 @@ def bounding_box(self, width: int, pnt_y: int, height: int, - aligner: T.Literal["cv2-dnn", "FAN"] = "FAN") -> None: + aligner: manual.TypeManualExtractor = "FAN") -> None: """ Update the bounding box for the :class:`~lib.align.DetectedFace` object at the given frame and face indices, with the given dimensions and update the 68 point landmarks from the :class:`~tools.manual.manual.Aligner` for the updated bounding box. diff --git a/tools/manual/faceviewer/frame.py b/tools/manual/faceviewer/frame.py index f2f77ea39b..27737dbb30 100644 --- a/tools/manual/faceviewer/frame.py +++ b/tools/manual/faceviewer/frame.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 """ The Faces Viewer Frame and Canvas for Faceswap's Manual Tool. """ +from __future__ import annotations import colorsys import gettext import logging import platform import tkinter as tk from tkinter import ttk +import typing as T from math import floor, ceil from threading import Thread, Event @@ -14,9 +16,15 @@ from lib.gui.custom_widgets import RightClickMenu, Tooltip from lib.gui.utils import get_config, get_images from lib.image import hex_to_rgb, rgb_to_hex +from lib.logger import parse_class_init from .viewport import Viewport +if T.TYPE_CHECKING: + from tools.manual.detected_faces import DetectedFaces + from tools.manual.frameviewer.frame import DisplayFrame + from tools.manual.manual import TkGlobals + logger = logging.getLogger(__name__) # LOCALES @@ -39,16 +47,18 @@ class FacesFrame(ttk.Frame): # pylint:disable=too-many-ancestors display_frame: :class:`~tools.manual.frameviewer.frame.DisplayFrame` The section of the Manual Tool that holds the frames viewer """ - def __init__(self, parent, tk_globals, detected_faces, display_frame): - logger.debug("Initializing %s: (parent: %s, tk_globals: %s, detected_faces: %s, " - "display_frame: %s)", self.__class__.__name__, parent, tk_globals, - detected_faces, display_frame) + def __init__(self, + parent: ttk.PanedWindow, + tk_globals: TkGlobals, + detected_faces: DetectedFaces, + display_frame: DisplayFrame) -> None: + logger.debug(parse_class_init(locals())) super().__init__(parent) self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) self._actions_frame = FacesActionsFrame(self) self._faces_frame = ttk.Frame(self) - self._faces_frame.pack_propagate(0) + self._faces_frame.pack_propagate(False) self._faces_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) self._event = Event() self._canvas = FacesViewer(self._faces_frame, @@ -60,7 +70,7 @@ def __init__(self, parent, tk_globals, detected_faces, display_frame): self._add_scrollbar() logger.debug("Initialized %s", self.__class__.__name__) - def _add_scrollbar(self): + def _add_scrollbar(self) -> None: """ Add a scrollbar to the faces frame """ logger.debug("Add Faces Viewer Scrollbar") scrollbar = ttk.Scrollbar(self._faces_frame, command=self._on_scroll) @@ -69,9 +79,8 @@ def _add_scrollbar(self): self.bind("", self._update_viewport) logger.debug("Added Faces Viewer Scrollbar") self.update_idletasks() # Update so scrollbar width is correct - return scrollbar.winfo_width() - def _on_scroll(self, *event): + def _on_scroll(self, *event: tk.Event) -> None: """ Callback on scrollbar scroll. Updates the canvas location and displays/hides thumbnail images. @@ -83,7 +92,7 @@ def _on_scroll(self, *event): self._canvas.yview(*event) self._canvas.viewport.update() - def _update_viewport(self, event): # pylint:disable=unused-argument + def _update_viewport(self, event: tk.Event) -> None: # pylint:disable=unused-argument """ Update the faces viewport and scrollbar. Parameters @@ -94,7 +103,7 @@ def _update_viewport(self, event): # pylint:disable=unused-argument self._canvas.viewport.update() self._canvas.configure(scrollregion=self._canvas.bbox("backdrop")) - def canvas_scroll(self, direction): + def canvas_scroll(self, direction: T.Literal["up", "down", "page-up", "page-down"]) -> None: """ Scroll the canvas on an up/down or page-up/page-down key press. Notes @@ -110,9 +119,11 @@ def canvas_scroll(self, direction): """ if self._event.is_set(): - logger.trace("Update already running. Aborting repeated keypress") + logger.trace("Update already running. " # type:ignore[attr-defined] + "Aborting repeated keypress") return - logger.trace("Running update on received key press: %s", direction) + logger.trace("Running update on received key press: %s", # type:ignore[attr-defined] + direction) amount = 1 if direction.endswith("down") else -1 units = "pages" if direction.startswith("page") else "units" @@ -121,7 +132,7 @@ def canvas_scroll(self, direction): args=(amount, units, self._event)) thread.start() - def set_annotation_display(self, key): + def set_annotation_display(self, key: str) -> None: """ Set the optional annotation overlay based on keyboard shortcut. Parameters @@ -140,33 +151,33 @@ class FacesActionsFrame(ttk.Frame): # pylint:disable=too-many-ancestors parent: :class:`FacesFrame` The Faces frame that this actions frame reside in """ - def __init__(self, parent): - logger.debug("Initializing %s: (parent: %s)", - self.__class__.__name__, parent) + def __init__(self, parent: FacesFrame) -> None: + logger.debug(parse_class_init(locals())) super().__init__(parent) self.pack(side=tk.LEFT, fill=tk.Y, padx=(2, 4), pady=2) - self._tk_vars = dict() + self._tk_vars: dict[T.Literal["mesh", "mask"], tk.BooleanVar] = {} self._configure_styles() self._buttons = self._add_buttons() logger.debug("Initialized %s", self.__class__.__name__) @property - def key_bindings(self): + def key_bindings(self) -> dict[str, T.Literal["mask", "mesh"]]: """ dict: The mapping of key presses to optional annotations to display. Keyboard shortcuts utilize the function keys. """ - return {"F{}".format(idx + 9): display for idx, display in enumerate(("mesh", "mask"))} + return {f"F{idx + 9}": display + for idx, display in enumerate(T.get_args(T.Literal["mesh", "mask"]))} @property - def _helptext(self): + def _helptext(self) -> dict[T.Literal["mask", "mesh"], str]: """ dict: `button key`: `button helptext`. The help text to display for each button. """ inverse_keybindings = {val: key for key, val in self.key_bindings.items()} - retval = dict(mesh=_("Display the landmarks mesh"), - mask=_("Display the mask")) + retval: dict[T.Literal["mask", "mesh"], str] = {"mesh": _('Display the landmarks mesh'), + "mask": _('Display the mask')} for item in retval: - retval[item] += " ({})".format(inverse_keybindings[item]) + retval[item] += f" ({inverse_keybindings[item]})" return retval - def _configure_styles(self): + def _configure_styles(self) -> None: """ Configure the background color for button frame and the button styles. """ style = ttk.Style() style.configure("display.TFrame", background='#d3d3d3') @@ -174,17 +185,17 @@ def _configure_styles(self): style.configure("display_deselected.TButton", relief="flat") self.config(style="display.TFrame") - def _add_buttons(self): + def _add_buttons(self) -> dict[T.Literal["mesh", "mask"], ttk.Button]: """ Add the display buttons to the Faces window. Returns ------- - dict + dict[Literal["mesh", "mask"], tk.Button]] The display name and its associated button. """ frame = ttk.Frame(self) frame.pack(side=tk.TOP, fill=tk.Y) - buttons = dict() + buttons = {} for display in self.key_bindings.values(): var = tk.BooleanVar() var.set(False) @@ -193,7 +204,7 @@ def _add_buttons(self): lookup = "landmarks" if display == "mesh" else display button = ttk.Button(frame, image=get_images().icons[lookup], - command=lambda t=display: self.on_click(t), + command=T.cast(T.Callable, lambda t=display: self.on_click(t)), style="display_deselected.TButton") button.state(["!pressed", "!focus"]) button.pack() @@ -201,13 +212,13 @@ def _add_buttons(self): buttons[display] = button return buttons - def on_click(self, display): + def on_click(self, display: T.Literal["mesh", "mask"]) -> None: """ Click event for the optional annotation buttons. Loads and unloads the annotations from the faces viewer. Parameters ---------- - display: str + display: Literal["mesh", "mask"] The display name for the button that has called this event as exists in :attr:`_buttons` """ @@ -239,16 +250,19 @@ class FacesViewer(tk.Canvas): # pylint:disable=too-many-ancestors event: :class:`threading.Event` The threading event object for repeated key press protection """ - def __init__(self, parent, tk_globals, tk_action_vars, detected_faces, display_frame, event): - logger.debug("Initializing %s: (parent: %s, tk_globals: %s, tk_action_vars: %s, " - "detected_faces: %s, display_frame: %s, event: %s)", self.__class__.__name__, - parent, tk_globals, tk_action_vars, detected_faces, display_frame, event) + def __init__(self, parent: ttk.Frame, + tk_globals: TkGlobals, + tk_action_vars: dict[T.Literal["mesh", "mask"], tk.BooleanVar], + detected_faces: DetectedFaces, + display_frame: DisplayFrame, + event: Event) -> None: + logger.debug(parse_class_init(locals())) super().__init__(parent, bd=0, highlightthickness=0, bg=get_config().user_theme["group_panel"]["panel_background"]) self.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.E) - self._sizes = dict(tiny=32, small=64, medium=96, large=128, extralarge=192) + self._sizes = {"tiny": 32, "small": 64, "medium": 96, "large": 128, "extralarge": 192} self._globals = tk_globals self._tk_optional_annotations = tk_action_vars @@ -256,8 +270,8 @@ def __init__(self, parent, tk_globals, tk_action_vars, detected_faces, display_f self._display_frame = display_frame self._grid = Grid(self, detected_faces) self._view = Viewport(self, detected_faces.tk_edited) - self._annotation_colors = dict(mesh=self.get_muted_color("Mesh"), - box=self.control_colors["ExtractBox"]) + self._annotation_colors = {"mesh": self.get_muted_color("Mesh"), + "box": self.control_colors["ExtractBox"]} ContextMenu(self, detected_faces) self._bind_mouse_wheel_scrolling() @@ -265,7 +279,7 @@ def __init__(self, parent, tk_globals, tk_action_vars, detected_faces, display_f logger.debug("Initialized %s", self.__class__.__name__) @property - def face_size(self): + def face_size(self) -> int: """ int: The currently selected thumbnail size in pixels """ scaling = get_config().scaling_factor size = self._sizes[self._globals.tk_faces_size.get().lower().replace(" ", "")] @@ -273,58 +287,65 @@ def face_size(self): return int(round(scaled / 2) * 2) @property - def viewport(self): + def viewport(self) -> Viewport: """ :class:`~tools.manual.faceviewer.viewport.Viewport`: The viewport area of the faces viewer. """ return self._view @property - def grid(self): + def layout(self) -> Grid: """ :class:`Grid`: The grid for the current :class:`FacesViewer`. """ return self._grid @property - def optional_annotations(self): - """ dict: The values currently set for the selectable optional annotations. """ + def optional_annotations(self) -> dict[T.Literal["mesh", "mask"], bool]: + """ dict[Literal["mesh", "mask"], bool]: The values currently set for the + selectable optional annotations. """ return {opt: val.get() for opt, val in self._tk_optional_annotations.items()} @property - def selected_mask(self): + def selected_mask(self) -> str: """ str: The currently selected mask from the display frame control panel. """ return self._display_frame.tk_selected_mask.get().lower() @property - def control_colors(self): - """ :dict: The frame Editor name as key with the current user selected hex code as + def control_colors(self) -> dict[str, str]: + """dict[str, str]: The frame Editor name as key with the current user selected hex code as value. """ return ({key: val.get() for key, val in self._display_frame.tk_control_colors.items()}) # << CALLBACK FUNCTIONS >> # - def _set_tk_callbacks(self, detected_faces): + def _set_tk_callbacks(self, detected_faces: DetectedFaces): """ Set the tkinter variable call backs. + Parameters + ---------- + detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` + The Manual Tool's Detected Faces object + Redraw the grid on a face size change, a filter change or on add/remove faces. Updates the annotation colors when user amends a color drop down. Updates the mask type when the user changes the selected mask types Toggles the face viewer annotations on an optional annotation button press. """ for var in (self._globals.tk_faces_size, self._globals.tk_filter_mode): - var.trace("w", lambda *e, v=var: self.refresh_grid(v)) + var.trace_add("write", lambda *e, v=var: self.refresh_grid(v)) var = detected_faces.tk_face_count_changed - var.trace("w", lambda *e, v=var: self.refresh_grid(v, retain_position=True)) + var.trace_add("write", lambda *e, v=var: self.refresh_grid(v, retain_position=True)) - self._display_frame.tk_control_colors["Mesh"].trace( - "w", lambda *e: self._update_mesh_color()) - self._display_frame.tk_control_colors["ExtractBox"].trace( - "w", lambda *e: self._update_box_color()) - self._display_frame.tk_selected_mask.trace("w", lambda *e: self._update_mask_type()) + self._display_frame.tk_control_colors["Mesh"].trace_add( + "write", lambda *e: self._update_mesh_color()) + self._display_frame.tk_control_colors["ExtractBox"].trace_add( + "write", lambda *e: self._update_box_color()) + self._display_frame.tk_selected_mask.trace_add( + "write", lambda *e: self._update_mask_type()) for opt, var in self._tk_optional_annotations.items(): - var.trace("w", lambda *e, o=opt: self._toggle_annotations(o)) + var.trace_add("write", lambda *e, o=opt: self._toggle_annotations(o)) self.bind("", lambda *e: self._view.update()) - def refresh_grid(self, trigger_var, retain_position=False): + def refresh_grid(self, trigger_var: tk.BooleanVar, retain_position: bool = False) -> None: """ Recalculate the full grid and redraw. Used when the active filter pull down is used, a face has been added or removed, or the face thumbnail size has changed. @@ -351,15 +372,16 @@ def refresh_grid(self, trigger_var, retain_position=False): if not size_change: trigger_var.set(False) - def _update_mask_type(self): + def _update_mask_type(self) -> None: """ Update the displayed mask in the :class:`FacesViewer` canvas when the user changes the mask type. """ + state: T.Literal["normal", "hidden"] state = "normal" if self.optional_annotations["mask"] else "hidden" logger.debug("Updating mask type: (mask_type: %s. state: %s)", self.selected_mask, state) self._view.toggle_mask(state, self.selected_mask) # << MOUSE HANDLING >> - def _bind_mouse_wheel_scrolling(self): + def _bind_mouse_wheel_scrolling(self) -> None: """ Bind mouse wheel to scroll the :class:`FacesViewer` canvas. """ if platform.system() == "Linux": self.bind("", self._scroll) @@ -367,7 +389,7 @@ def _bind_mouse_wheel_scrolling(self): else: self.bind("", self._scroll) - def _scroll(self, event): + def _scroll(self, event: tk.Event) -> None: """ Handle mouse wheel scrolling over the :class:`FacesViewer` canvas. Update is run in a thread to avoid repeated scroll actions stacking and locking up the GUI. @@ -378,12 +400,13 @@ def _scroll(self, event): The event fired by the mouse scrolling """ if self._event.is_set(): - logger.trace("Update already running. Aborting repeated mousewheel") + logger.trace("Update already running. " # type:ignore[attr-defined] + "Aborting repeated mousewheel") return if platform.system() == "Darwin": adjust = event.delta elif platform.system() == "Windows": - adjust = event.delta / 120 + adjust = int(event.delta / 120) elif event.num == 5: adjust = -1 else: @@ -392,14 +415,14 @@ def _scroll(self, event): thread = Thread(target=self.canvas_scroll, args=(-1 * adjust, "units", self._event)) thread.start() - def canvas_scroll(self, amount, units, event): + def canvas_scroll(self, amount: int, units: T.Literal["pages", "units"], event: Event) -> None: """ Scroll the canvas on an up/down or page-up/page-down key press. Parameters ---------- amount: int The number of units to scroll the canvas - units: ["page", "units"] + units: Literal["pages", "units"] The unit type to scroll by event: :class:`threading.Event` event to indicate to the calling process whether the scroll is still updating @@ -410,7 +433,7 @@ def canvas_scroll(self, amount, units, event): event.clear() # << OPTIONAL ANNOTATION METHODS >> # - def _update_mesh_color(self): + def _update_mesh_color(self) -> None: """ Update the mesh color when user updates the control panel. """ color = self.get_muted_color("Mesh") if self._annotation_colors["mesh"] == color: @@ -423,7 +446,7 @@ def _update_mesh_color(self): self.itemconfig("active_mesh_line", fill=highlight_color) self._annotation_colors["mesh"] = color - def _update_box_color(self): + def _update_box_color(self) -> None: """ Update the active box color when user updates the control panel. """ color = self.control_colors["ExtractBox"] @@ -432,13 +455,18 @@ def _update_box_color(self): self.itemconfig("active_highlighter", outline=color) self._annotation_colors["box"] = color - def get_muted_color(self, color_key): + def get_muted_color(self, color_key: str) -> str: """ Creates a muted version of the given annotation color for non-active faces. Parameters ---------- color_key: str The annotation key to obtain the color for from :attr:`control_colors` + + Returns + ------- + str + The hex color code of the muted color """ scale = 0.65 hls = np.array(colorsys.rgb_to_hls(*hex_to_rgb(self.control_colors[color_key]))) @@ -448,7 +476,7 @@ def get_muted_color(self, color_key): retval = rgb_to_hex(rgb) return retval - def _toggle_annotations(self, annotation): + def _toggle_annotations(self, annotation: T.Literal["mesh", "mask"]) -> None: """ Toggle optional annotations on or off after the user depresses an optional button. Parameters @@ -456,6 +484,7 @@ def _toggle_annotations(self, annotation): annotation: ["mesh", "mask"] The optional annotation to toggle on or off """ + state: T.Literal["hidden", "normal"] state = "normal" if self.optional_annotations[annotation] else "hidden" logger.debug("Toggle annotation: (annotation: %s, state: %s)", annotation, state) if annotation == "mesh": @@ -473,23 +502,22 @@ class Grid(): Parameters ---------- - canvas: :class:`tkinter.Canvas` + canvas: :class:`~FacesViewer` The :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` The :class:`~lib.align.DetectedFace` objects for this video """ - def __init__(self, canvas, detected_faces): - logger.debug("Initializing %s: (detected_faces: %s)", - self.__class__.__name__, detected_faces) + def __init__(self, canvas: FacesViewer, detected_faces: DetectedFaces): + logger.debug(parse_class_init(locals())) self._canvas = canvas self._detected_faces = detected_faces self._raw_indices = detected_faces.filter.raw_indices self._frames_list = detected_faces.filter.frames_list - self._is_valid = False - self._face_size = None - self._grid = None - self._display_faces = None + self._is_valid: bool = False + self._face_size: int = 0 + self._grid: np.ndarray | None = None + self._display_faces: np.ndarray | None = None self._canvas.update_idletasks() self._canvas.create_rectangle(0, 0, 0, 0, tags=["backdrop"]) @@ -497,64 +525,76 @@ def __init__(self, canvas, detected_faces): logger.debug("Initialized %s", self.__class__.__name__) @property - def face_size(self): + def face_size(self) -> int: """ int: The pixel size of each thumbnail within the face viewer. """ return self._face_size @property - def is_valid(self): + def is_valid(self) -> bool: """ bool: ``True`` if the current filter means that the grid holds faces. ``False`` if there are no faces displayed in the grid. """ return self._is_valid @property - def columns_rows(self): + def columns_rows(self) -> tuple[int, int]: """ tuple: the (`columns`, `rows`) required to hold all display images. """ - retval = tuple(reversed(self._grid.shape[1:])) if self._is_valid else (0, 0) - return retval + if not self._is_valid: + return (0, 0) + assert self._grid is not None + retval = tuple(reversed(self._grid.shape[1:])) + return T.cast(tuple[int, int], retval) @property - def dimensions(self): + def dimensions(self) -> tuple[int, int]: """ tuple: The (`width`, `height`) required to hold all display images. """ if self._is_valid: + assert self._grid is not None retval = tuple(dim * self._face_size for dim in reversed(self._grid.shape[1:])) + assert len(retval) == 2 else: retval = (0, 0) - return retval + return T.cast(tuple[int, int], retval) @property - def _visible_row_indices(self): + def _visible_row_indices(self) -> tuple[int, int]: """tuple: A 1 dimensional array of the (`top_row_index`, `bottom_row_index`) of the grid currently in the viewable area. """ height = self.dimensions[1] visible = (max(0, floor(height * self._canvas.yview()[0]) - self._face_size), ceil(height * self._canvas.yview()[1])) - logger.trace("height: %s, yview: %s, face_size: %s, visible: %s", - height, self._canvas.yview(), self._face_size, visible) + logger.trace("height: %s, yview: %s, face_size: %s, " # type:ignore[attr-defined] + "visible: %s", height, self._canvas.yview(), self._face_size, visible) + assert self._grid is not None y_points = self._grid[3, :, 1] top = np.searchsorted(y_points, visible[0], side="left") bottom = np.searchsorted(y_points, visible[1], side="right") - return top, bottom + return int(top), int(bottom) @property - def visible_area(self): - """:class:`numpy.ndarray`: A numpy array of shape (`4`, `rows`, `columns`) corresponding + def visible_area(self) -> tuple[np.ndarray, np.ndarray]: + """tuple[:class:`numpy.ndarray`, :class:`numpy.ndarray`]: Tuple containing 2 arrays. + + 1st array contains an array of shape (`4`, `rows`, `columns`) corresponding to the viewable area of the display grid. 1st dimension contains frame indices, 2nd dimension face indices. The 3rd and 4th dimension contain the x and y position of the top left corner of the face respectively. + 2nd array contains :class:`~lib.align.DetectedFace` objects laid out in (rows, columns) + Any locations that are not populated by a face will have a frame and face index of -1 """ if not self._is_valid: - retval = None, None + retval = np.zeros((4, 0, 0)), np.zeros((0, 0)) else: + assert self._grid is not None + assert self._display_faces is not None top, bottom = self._visible_row_indices retval = self._grid[:, top:bottom, :], self._display_faces[top:bottom, :] - logger.trace([r if r is None else r.shape for r in retval]) + logger.trace([r if r is None else r.shape for r in retval]) # type:ignore[attr-defined] return retval - def y_coord_from_frame(self, frame_index): + def y_coord_from_frame(self, frame_index: int) -> int: """ Return the y coordinate for the first face that appears in the given frame. Parameters @@ -567,9 +607,10 @@ def y_coord_from_frame(self, frame_index): int The y coordinate of the first face for the given frame """ + assert self._grid is not None return min(self._grid[3][np.where(self._grid[0] == frame_index)]) - def frame_has_faces(self, frame_index): + def frame_has_faces(self, frame_index: int) -> bool | np.bool_: """ Check whether the given frame index contains any faces. Parameters @@ -582,9 +623,12 @@ def frame_has_faces(self, frame_index): bool ``True`` if there are faces in the given frame otherwise ``False`` """ - return self._is_valid and np.any(self._grid[0] == frame_index) + if not self._is_valid: + return False + assert self._grid is not None + return np.any(self._grid[0] == frame_index) - def update(self): + def update(self) -> None: """ Update the underlying grid. Called on initialization, on a filter change or on add/remove faces. Recalculates the @@ -597,25 +641,23 @@ def update(self): self._get_grid() self._get_display_faces() self._canvas.coords("backdrop", 0, 0, *self.dimensions) - self._canvas.configure(scrollregion=(self._canvas.bbox("backdrop"))) + self._canvas.configure(scrollregion=self._canvas.bbox("backdrop")) self._canvas.yview_moveto(0.0) - def _get_grid(self): + def _get_grid(self) -> None: """ Get the grid information for faces currently displayed in the :class:`FacesViewer`. + and set to :attr:`_grid`. Creates a numpy array of shape (`4`, `rows`, `columns`) + corresponding to the display grid. 1st dimension contains frame indices, 2nd dimension face + indices. The 3rd and 4th dimension contain the x and y position of the top left corner of + the face respectively. - Returns - :class:`numpy.ndarray` - A numpy array of shape (`4`, `rows`, `columns`) corresponding to the display grid. - 1st dimension contains frame indices, 2nd dimension face indices. The 3rd and 4th - dimension contain the x and y position of the top left corner of the face respectively. - - Any locations that are not populated by a face will have a frame and face index of -1 - """ + Any locations that are not populated by a face will have a frame and face index of -1""" labels = self._get_labels() if not self._is_valid: logger.debug("Setting grid to None for no faces.") self._grid = None return + assert labels is not None x_coords = np.linspace(0, labels.shape[2] * self._face_size, num=labels.shape[2], @@ -629,12 +671,12 @@ def _get_grid(self): self._grid = np.array((*labels, *np.meshgrid(x_coords, y_coords)), dtype="int") logger.debug(self._grid.shape) - def _get_labels(self): + def _get_labels(self) -> np.ndarray | None: """ Get the frame and face index for each grid position for the current filter. Returns ------- - :class:`numpy.ndarray` + :class:`numpy.ndarray` | None Array of dimensions (2, rows, columns) corresponding to the display grid, with frame index as the first dimension and face index within the frame as the 2nd dimension. @@ -657,17 +699,12 @@ def _get_labels(self): return labels def _get_display_faces(self): - """ Get the detected faces for the current filter and arrange to grid. + """ Get the detected faces for the current filter, arrange to grid and set to + :attr:`_display_faces`. This is an array of dimensions (rows, columns) corresponding to the + display grid, containing the corresponding :class:`lib.align.DetectFace` object - Returns - ------- - :class:`numpy.ndarray` - Array of dimensions (rows, columns) corresponding to the display grid, containing the - corresponding :class:`lib.align.DetectFace` object - - Any remaining placeholders at the end of the grid which are not populated with a face - are replaced with ``None`` - """ + Any remaining placeholders at the end of the grid which are not populated with a face are + replaced with ``None``""" if not self._is_valid: logger.debug("Setting display_faces to None for no faces.") self._display_faces = None @@ -684,7 +721,7 @@ def _get_display_faces(self): logger.debug("faces: (shape: %s, dtype: %s)", self._display_faces.shape, self._display_faces.dtype) - def transport_index_from_frame(self, frame_index): + def transport_index_from_frame(self, frame_index: int) -> int | None: """ Return the main frame's transport index for the given frame index based on the current filter criteria. @@ -695,11 +732,13 @@ def transport_index_from_frame(self, frame_index): Returns ------- - int - The index of the requested frame within the filtered frames view. + int | None + The index of the requested frame within the filtered frames view. None if no valid + frames """ retval = self._frames_list.index(frame_index) if frame_index in self._frames_list else None - logger.trace("frame_index: %s, transport_index: %s", frame_index, retval) + logger.trace("frame_index: %s, transport_index: %s", # type:ignore[attr-defined] + frame_index, retval) return retval @@ -737,17 +776,17 @@ def _pop_menu(self, event): frame_idx, face_idx = self._canvas.viewport.face_from_point( self._canvas.canvasx(event.x), self._canvas.canvasy(event.y))[:2] if frame_idx == -1: - logger.trace("No valid item under mouse") + logger.trace("No valid item under mouse") # type:ignore[attr-defined] self._frame_index = self._face_index = None return self._frame_index = frame_idx self._face_index = face_idx - logger.trace("Popping right click menu") + logger.trace("Popping right click menu") # type:ignore[attr-defined] self._menu.popup(event) def _delete_face(self): """ Delete the selected face on a right click mouse delete action. """ - logger.trace("Right click delete received. frame_id: %s, face_id: %s", - self._frame_index, self._face_index) + logger.trace("Right click delete received. frame_id: %s, " # type:ignore[attr-defined] + "face_id: %s", self._frame_index, self._face_index) self._detected_faces.update.delete(self._frame_index, self._face_index) self._frame_index = self._face_index = None diff --git a/tools/manual/faceviewer/interact.py b/tools/manual/faceviewer/interact.py new file mode 100644 index 0000000000..ae8edf3707 --- /dev/null +++ b/tools/manual/faceviewer/interact.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +""" Handles the viewport area for mouse hover actions and the active frame """ +from __future__ import annotations +import logging +import tkinter as tk +import typing as T +from dataclasses import dataclass + +import numpy as np + +from lib.logger import parse_class_init + +if T.TYPE_CHECKING: + from lib.align import DetectedFace + from .viewport import Viewport + +logger = logging.getLogger(__name__) + + +class HoverBox(): + """ Handle the current mouse location when over the :class:`Viewport`. + + Highlights the face currently underneath the cursor and handles actions when clicking + on a face. + + Parameters + ---------- + viewport: :class:`Viewport` + The viewport object for the :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas + """ + def __init__(self, viewport: Viewport) -> None: + logger.debug(parse_class_init(locals())) + self._viewport = viewport + self._canvas = viewport._canvas + self._grid = viewport._canvas.layout + self._globals = viewport._canvas._globals + self._navigation = viewport._canvas._display_frame.navigation + self._box = self._canvas.create_rectangle(0., # type:ignore[call-overload] + 0., + float(self._size), + float(self._size), + outline="#0000ff", + width=2, + state="hidden", + fill="#0000ff", + stipple="gray12", + tags="hover_box") + self._current_frame_index = None + self._current_face_index = None + self._canvas.bind("", lambda e: self._clear()) + self._canvas.bind("", self.on_hover) + self._canvas.bind("", lambda e: self._select_frame()) + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def _size(self) -> int: + """ int: the currently set viewport face size in pixels. """ + return self._viewport.face_size + + def on_hover(self, event: tk.Event | None) -> None: + """ Highlight the face and set the mouse cursor for the mouse's current location. + + Parameters + ---------- + event: :class:`tkinter.Event` or ``None`` + The tkinter mouse event. Provides the current location of the mouse cursor. If ``None`` + is passed as the event (for example when this function is being called outside of a + mouse event) then the location of the cursor will be calculated + """ + if event is None: + pnts = np.array((self._canvas.winfo_pointerx(), self._canvas.winfo_pointery())) + pnts -= np.array((self._canvas.winfo_rootx(), self._canvas.winfo_rooty())) + else: + pnts = np.array((event.x, event.y)) + + coords = (int(self._canvas.canvasx(pnts[0])), int(self._canvas.canvasy(pnts[1]))) + face = self._viewport.face_from_point(*coords) + frame_idx, face_idx = face[:2] + + if frame_idx == self._current_frame_index and face_idx == self._current_face_index: + return + + is_zoomed = self._globals.is_zoomed + if (-1 in face or (frame_idx == self._globals.frame_index + and (not is_zoomed or + (is_zoomed and face_idx == self._globals.tk_face_index.get())))): + self._clear() + self._canvas.config(cursor="") + self._current_frame_index = None + self._current_face_index = None + return + + logger.debug("Viewport hover: frame_idx: %s, face_idx: %s", frame_idx, face_idx) + + self._canvas.config(cursor="hand2") + self._highlight(face[2:]) + self._current_frame_index = frame_idx + self._current_face_index = face_idx + + def _clear(self) -> None: + """ Hide the hover box when the mouse is not over a face. """ + if self._canvas.itemcget(self._box, "state") != "hidden": + self._canvas.itemconfig(self._box, state="hidden") + + def _highlight(self, top_left: np.ndarray) -> None: + """ Display the hover box around the face that the mouse is currently over. + + Parameters + ---------- + top_left: :class:`np.ndarray` + The top left point of the highlight box location + """ + coords = (*top_left, *[x + self._size for x in top_left]) + self._canvas.coords(self._box, *coords) + self._canvas.itemconfig(self._box, state="normal") + self._canvas.tag_raise(self._box) + + def _select_frame(self) -> None: + """ Select the face and the subsequent frame (in the editor view) when a face is clicked + on in the :class:`Viewport`. """ + frame_id = self._current_frame_index + is_zoomed = self._globals.is_zoomed + logger.debug("Face clicked. Global frame index: %s, Current frame_id: %s, is_zoomed: %s", + self._globals.frame_index, frame_id, is_zoomed) + if frame_id is None or (frame_id == self._globals.frame_index and not is_zoomed): + return + face_idx = self._current_face_index if is_zoomed else 0 + self._globals.tk_face_index.set(face_idx) + transport_id = self._grid.transport_index_from_frame(frame_id) + logger.trace("frame_index: %s, transport_id: %s, face_idx: %s", + frame_id, transport_id, face_idx) + if transport_id is None: + return + self._navigation.stop_playback() + self._globals.tk_transport_index.set(transport_id) + self._viewport.move_active_to_top() + self.on_hover(None) + + +@dataclass +class Asset: + """ Holds all of the display assets identifiers for the active frame's face viewer objects + + Parameters + ---------- + images: list[int] + Indices for a frame's tk image ids displayed in the active frame + meshes: list[dict[Literal["polygon", "line"], list[int]]] + Indices for a frame's tk line/polygon object ids displayed in the active frame + faces: list[:class:`~lib.align.detected_faces.DetectedFace`] + DetectedFace objects that exist in the current frame + boxes: list[int] + Indices for a frame's bounding box object ids displayed in the active frame + """ + images: list[int] + """list[int]: Indices for a frame's tk image ids displayed in the active frame""" + meshes: list[dict[T.Literal["polygon", "line"], list[int]]] + """list[dict[Literal["polygon", "line"], list[int]]]: Indices for a frame's tk line/polygon + object ids displayed in the active frame""" + faces: list[DetectedFace] + """list[:class:`~lib.align.detected_faces.DetectedFace`]: DetectedFace objects that exist + in the current frame""" + boxes: list[int] + """list[int]: Indices for a frame's bounding box object ids displayed in the active + frame""" + + +class ActiveFrame(): + """ Handles the display of faces and annotations for the currently active frame. + + Parameters + ---------- + canvas: :class:`tkinter.Canvas` + The :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas + tk_edited_variable: :class:`tkinter.BooleanVar` + The tkinter callback variable indicating that a face has been edited + """ + def __init__(self, viewport: Viewport, tk_edited_variable: tk.BooleanVar) -> None: + logger.debug(parse_class_init(locals())) + self._objects = viewport._objects + self._viewport = viewport + self._grid = viewport._grid + self._tk_faces = viewport._tk_faces + self._canvas = viewport._canvas + self._globals = viewport._canvas._globals + self._navigation = viewport._canvas._display_frame.navigation + self._last_execution: dict[T.Literal["frame_index", "size"], + int] = {"frame_index": -1, "size": viewport.face_size} + self._tk_vars: dict[T.Literal["selected_editor", "edited"], + tk.StringVar | tk.BooleanVar] = { + "selected_editor": self._canvas._display_frame.tk_selected_action, + "edited": tk_edited_variable} + self._assets: Asset = Asset([], [], [], []) + + self._globals.tk_update_active_viewport.trace_add("write", + lambda *e: self._reload_callback()) + tk_edited_variable.trace_add("write", lambda *e: self._update_on_edit()) + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def frame_index(self) -> int: + """ int: The frame index of the currently displayed frame. """ + return self._globals.frame_index + + @property + def current_frame(self) -> np.ndarray: + """ :class:`numpy.ndarray`: A BGR version of the frame currently being displayed. """ + return self._globals.current_frame["image"] + + @property + def _size(self) -> int: + """ int: The size of the thumbnails displayed in the viewport, in pixels. """ + return self._viewport.face_size + + @property + def _optional_annotations(self) -> dict[T.Literal["mesh", "mask"], bool]: + """ dict[Literal["mesh", "mask"], bool]: The currently selected optional + annotations """ + return self._canvas.optional_annotations + + def _reload_callback(self) -> None: + """ If a frame has changed, triggering the variable, then update the active frame. Return + having done nothing if the variable is resetting. """ + if self._globals.tk_update_active_viewport.get(): + self.reload_annotations() + + def reload_annotations(self) -> None: + """ Handles the reloading of annotations for the currently active faces. + + Highlights the faces within the viewport of those faces that exist in the currently + displaying frame. Applies annotations based on the optional annotations and current + editor selections. + """ + logger.trace("Reloading annotations") # type:ignore[attr-defined] + if self._assets.images: + self._clear_previous() + + self._set_active_objects() + self._check_active_in_view() + + if not self._assets.images: + logger.trace("No active faces. Returning") # type:ignore[attr-defined] + self._last_execution["frame_index"] = self.frame_index + return + + if self._last_execution["frame_index"] != self.frame_index: + self.move_to_top() + self._create_new_boxes() + + self._update_face() + self._canvas.tag_raise("active_highlighter") + self._globals.tk_update_active_viewport.set(False) + self._last_execution["frame_index"] = self.frame_index + + def _clear_previous(self) -> None: + """ Reverts the previously selected annotations to their default state. """ + logger.trace("Clearing previous active frame") # type:ignore[attr-defined] + self._canvas.itemconfig("active_highlighter", state="hidden") + + for key in T.get_args(T.Literal["polygon", "line"]): + tag = f"active_mesh_{key}" + self._canvas.itemconfig(tag, **self._viewport.mesh_kwargs[key], width=1) + self._canvas.dtag(tag) + + if self._viewport.selected_editor == "mask" and not self._optional_annotations["mask"]: + for name, tk_face in self._tk_faces.items(): + if name.startswith(f"{self._last_execution['frame_index']}_"): + tk_face.update_mask(None) + + def _set_active_objects(self) -> None: + """ Collect the objects that exist in the currently active frame from the main grid. """ + if self._grid.is_valid: + rows, cols = np.where(self._objects.visible_grid[0] == self.frame_index) + logger.trace("Setting active objects: (rows: %s, " # type:ignore[attr-defined] + "columns: %s)", rows, cols) + self._assets.images = self._objects.images[rows, cols].tolist() + self._assets.meshes = self._objects.meshes[rows, cols].tolist() + self._assets.faces = self._objects.visible_faces[rows, cols].tolist() + else: + logger.trace("No valid grid. Clearing active objects") # type:ignore[attr-defined] + self._assets.images = [] + self._assets.meshes = [] + self._assets.faces = [] + + def _check_active_in_view(self) -> None: + """ If the frame has changed, there are faces in the frame, but they don't appear in the + viewport, then bring the active faces to the top of the viewport. """ + if (not self._assets.images and + self._last_execution["frame_index"] != self.frame_index and + self._grid.frame_has_faces(self.frame_index)): + y_coord = self._grid.y_coord_from_frame(self.frame_index) + logger.trace("Active not in view. Moving to: %s", y_coord) # type:ignore[attr-defined] + self._canvas.yview_moveto(y_coord / self._canvas.bbox("backdrop")[3]) + self._viewport.update() + + def move_to_top(self) -> None: + """ Move the currently selected frame's faces to the top of the viewport if they are moving + off the bottom of the viewer. """ + height = self._canvas.bbox("backdrop")[3] + bot = int(self._canvas.coords(self._assets.images[-1])[1] + self._size) + + y_top, y_bot = (int(round(pnt * height)) for pnt in self._canvas.yview()) + + if y_top < bot < y_bot: # bottom face is still in fully visible area + logger.trace("Active faces in frame. Returning") # type:ignore[attr-defined] + return + + top = int(self._canvas.coords(self._assets.images[0])[1]) + if y_top == top: + logger.trace("Top face already on top row. Returning") # type:ignore[attr-defined] + return + + if self._canvas.winfo_height() > self._size: + logger.trace("Viewport taller than single face height. " # type:ignore[attr-defined] + "Moving Active faces to top: %s", top) + self._canvas.yview_moveto(top / height) + self._viewport.update() + elif self._canvas.winfo_height() <= self._size and y_top != top: + logger.trace("Viewport shorter than single face height. " # type:ignore[attr-defined] + "Moving Active faces to top: %s", top) + self._canvas.yview_moveto(top / height) + self._viewport.update() + + def _create_new_boxes(self) -> None: + """ The highlight boxes (border around selected faces) are the only additional annotations + that are required for the highlighter. If more faces are displayed in the current frame + than highlight boxes are available, then new boxes are created to accommodate the + additional faces. """ + new_boxes_count = max(0, len(self._assets.images) - len(self._assets.boxes)) + if new_boxes_count == 0: + return + logger.debug("new_boxes_count: %s", new_boxes_count) + for _ in range(new_boxes_count): + box = self._canvas.create_rectangle(0., # type:ignore[call-overload] + 0., + float(self._viewport.face_size), + float(self._viewport.face_size), + outline="#00FF00", + width=2, + state="hidden", + tags=["active_highlighter"]) + logger.trace("Created new highlight_box: %s", box) # type:ignore[attr-defined] + self._assets.boxes.append(box) + + def _update_on_edit(self) -> None: + """ Update the active faces on a frame edit. """ + if not self._tk_vars["edited"].get(): + return + self._set_active_objects() + self._update_face() + assert isinstance(self._tk_vars["edited"], tk.BooleanVar) + self._tk_vars["edited"].set(False) + + def _update_face(self) -> None: + """ Update the highlighted annotations for faces in the currently selected frame. """ + for face_idx, (image_id, mesh_ids, box_id, det_face), in enumerate( + zip(self._assets.images, + self._assets.meshes, + self._assets.boxes, + self._assets.faces)): + if det_face is None: + continue + top_left = self._canvas.coords(image_id) + coords = [*top_left, *[x + self._size for x in top_left]] + tk_face = self._viewport.get_tk_face(self.frame_index, face_idx, det_face) + self._canvas.itemconfig(image_id, image=tk_face.photo) + self._show_box(box_id, coords) + self._show_mesh(mesh_ids, face_idx, det_face, top_left) + self._last_execution["size"] = self._viewport.face_size + + def _show_box(self, item_id: int, coordinates: list[float]) -> None: + """ Display the highlight box around the given coordinates. + + Parameters + ---------- + item_id: int + The tkinter canvas object identifier for the highlight box + coordinates: list[float] + The (x, y, x1, y1) coordinates of the top left corner of the box + """ + self._canvas.coords(item_id, *coordinates) + self._canvas.itemconfig(item_id, state="normal") + + def _show_mesh(self, + mesh_ids: dict[T.Literal["polygon", "line"], list[int]], + face_index: int, + detected_face: DetectedFace, + top_left: list[float]) -> None: + """ Display the mesh annotation for the given face, at the given location. + + Parameters + ---------- + mesh_ids: dict[Literal["polygon", "line"], list[int]] + Dictionary containing the `polygon` and `line` tkinter canvas identifiers that make up + the mesh for the given face + face_index: int + The face index within the frame for the given face + detected_face: :class:`~lib.align.DetectedFace` + The detected face object that contains the landmarks for generating the mesh + top_left: list[float] + The (x, y) top left co-ordinates of the mesh's bounding box + """ + state = "normal" if (self._tk_vars["selected_editor"].get() != "Mask" or + self._optional_annotations["mesh"]) else "hidden" + kwargs: dict[T.Literal["polygon", "line"], dict[str, T.Any]] = { + "polygon": {"fill": "", "width": 2, "outline": self._canvas.control_colors["Mesh"]}, + "line": {"fill": self._canvas.control_colors["Mesh"], "width": 2}} + + assert isinstance(self._tk_vars["edited"], tk.BooleanVar) + edited = (self._tk_vars["edited"].get() and + self._tk_vars["selected_editor"].get() not in ("Mask", "View")) + landmarks = self._viewport.get_landmarks(self.frame_index, + face_index, + detected_face, + top_left, + edited) + for key, kwarg in kwargs.items(): + if key not in mesh_ids: + continue + for idx, mesh_id in enumerate(mesh_ids[key]): + self._canvas.coords(mesh_id, *landmarks[key][idx].flatten()) + self._canvas.itemconfig(mesh_id, state=state, **kwarg) + self._canvas.addtag_withtag(f"active_mesh_{key}", mesh_id) diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index 8b52ac2812..3b0670626b 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -1,14 +1,22 @@ #!/usr/bin/env python3 """ Handles the visible area of the :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas. """ - +from __future__ import annotations import logging import tkinter as tk +import typing as T import cv2 import numpy as np from PIL import Image, ImageTk -from lib.align import AlignedFace +from lib.align import AlignedFace, LANDMARK_PARTS, LandmarkType +from lib.logger import parse_class_init + +from .interact import ActiveFrame, HoverBox + +if T.TYPE_CHECKING: + from lib.align import CenteringType, DetectedFace + from .frame import FacesViewer logger = logging.getLogger(__name__) @@ -23,74 +31,64 @@ class Viewport(): tk_edited_variable: :class:`tkinter.BooleanVar` The variable that indicates that a face has been edited """ - def __init__(self, canvas, tk_edited_variable): - logger.debug("Initializing: %s: (canvas: %s, tk_edited_variable: %s)", - self.__class__.__name__, canvas, tk_edited_variable) + def __init__(self, canvas: FacesViewer, tk_edited_variable: tk.BooleanVar) -> None: + logger.debug(parse_class_init(locals())) self._canvas = canvas - self._grid = canvas.grid - self._centering = "face" + self._grid = canvas.layout + self._centering: CenteringType = "face" self._tk_selected_editor = canvas._display_frame.tk_selected_action - self._landmark_mapping = dict(mouth_inner=(60, 68), - mouth_outer=(48, 60), - right_eyebrow=(17, 22), - left_eyebrow=(22, 27), - right_eye=(36, 42), - left_eye=(42, 48), - nose=(27, 36), - jaw=(0, 17), - chin=(8, 11)) - self._landmarks = {} - self._tk_faces = {} + self._landmarks: dict[str, dict[T.Literal["polygon", "line"], list[np.ndarray]]] = {} + self._tk_faces: dict[str, TKFace] = {} self._objects = VisibleObjects(self) self._hoverbox = HoverBox(self) self._active_frame = ActiveFrame(self, tk_edited_variable) self._tk_selected_editor.trace( "w", lambda *e: self._active_frame.reload_annotations()) + logger.debug("Initialized %s", self.__class__.__name__) @property - def face_size(self): + def face_size(self) -> int: """ int: The pixel size of each thumbnail """ return self._grid.face_size @property - def mesh_kwargs(self): - """ dict: The color and state keyword arguments for the objects that make up a single - face's mesh annotation based on the current user selected options. Key is the object - type (`polygon` or `line`), value are the keyword arguments for that type. """ + def mesh_kwargs(self) -> dict[T.Literal["polygon", "line"], dict[str, T.Any]]: + """ dict[Literal["polygon", "line"], str | int]: Dynamic keyword arguments defining the + color and state for the objects that make up a single face's mesh annotation based on the + current user selected options. Values are the keyword arguments for that given type. """ state = "normal" if self._canvas.optional_annotations["mesh"] else "hidden" color = self._canvas.control_colors["Mesh"] - kwargs = dict(polygon=dict(fill="", outline=color, state=state), - line=dict(fill=color, state=state)) - return kwargs + return {"polygon": {"fill": "", "outline": color, "state": state}, + "line": {"fill": color, "state": state}} @property - def hover_box(self): + def hover_box(self) -> HoverBox: """ :class:`HoverBox`: The hover box for the viewport. """ return self._hoverbox @property - def selected_editor(self): + def selected_editor(self) -> str: """ str: The currently selected editor. """ return self._tk_selected_editor.get().lower() - def toggle_mesh(self, state): + def toggle_mesh(self, state: T.Literal["hidden", "normal"]) -> None: """ Toggles the mesh optional annotations on and off. Parameters ---------- - state: ["hidden", "normal"] + state: Literal["hidden", "normal"] The state to set the mesh annotations to """ logger.debug("Toggling mesh annotations to: %s", state) self._canvas.itemconfig("viewport_mesh", state=state) self.update() - def toggle_mask(self, state, mask_type): + def toggle_mask(self, state: T.Literal["hidden", "normal"], mask_type: str) -> None: """ Toggles the mask optional annotation on and off. Parameters ---------- - state: ["hidden", "normal"] + state: Literal["hidden", "normal"] Whether the mask should be displayed or hidden mask_type: str The type of mask to overlay onto the face @@ -108,7 +106,7 @@ def toggle_mask(self, state, mask_type): self.update() @classmethod - def _obtain_mask(cls, detected_face, mask_type): + def _obtain_mask(cls, detected_face: DetectedFace, mask_type: str) -> np.ndarray | None: """ Obtain the mask for the correct "face" centering that is used in the thumbnail display. Parameters @@ -133,12 +131,12 @@ def _obtain_mask(cls, detected_face, mask_type): centering="face") return mask.mask.squeeze() - def reset(self): + def reset(self) -> None: """ Reset all the cached objects on a face size change. """ self._landmarks = {} self._tk_faces = {} - def update(self, refresh_annotations=False): + def update(self, refresh_annotations: bool = False) -> None: """ Update the viewport. Parameters @@ -153,7 +151,7 @@ def update(self, refresh_annotations=False): self._update_viewport(refresh_annotations) self._active_frame.reload_annotations() - def _update_viewport(self, refresh_annotations): + def _update_viewport(self, refresh_annotations: bool) -> None: """ Update the viewport Parameters @@ -174,12 +172,12 @@ def _update_viewport(self, refresh_annotations): self._objects.meshes, self._objects.visible_faces): for (frame_idx, face_idx, pnt_x, pnt_y), image_id, mesh_ids, face in zip(*collection): - top_left = np.array((pnt_x, pnt_y)) if frame_idx == self._active_frame.frame_index and not refresh_annotations: - logger.trace("Skipping active frame: %s", frame_idx) + logger.trace("Skipping active frame: %s", # type:ignore[attr-defined] + frame_idx) continue if frame_idx == -1: - logger.trace("Blanking non-existant face") + logger.trace("Blanking non-existant face") # type:ignore[attr-defined] self._canvas.itemconfig(image_id, image="") for area in mesh_ids.values(): for mesh_id in area: @@ -192,20 +190,21 @@ def _update_viewport(self, refresh_annotations): if (self._canvas.optional_annotations["mesh"] or frame_idx == self._active_frame.frame_index or refresh_annotations): - landmarks = self.get_landmarks(frame_idx, face_idx, face, top_left, + landmarks = self.get_landmarks(frame_idx, face_idx, face, [pnt_x, pnt_y], refresh=True) self._locate_mesh(mesh_ids, landmarks) - def _discard_tk_faces(self): + def _discard_tk_faces(self) -> None: """ Remove any :class:`TKFace` objects from the cache that are not currently displayed. """ keys = [f"{pnt_x}_{pnt_y}" for pnt_x, pnt_y in self._objects.visible_grid[:2].T.reshape(-1, 2)] for key in list(self._tk_faces): if key not in keys: del self._tk_faces[key] - logger.trace("keys: %s allocated_faces: %s", keys, len(self._tk_faces)) + logger.trace("keys: %s allocated_faces: %s", # type:ignore[attr-defined] + keys, len(self._tk_faces)) - def get_tk_face(self, frame_index, face_index, face): + def get_tk_face(self, frame_index: int, face_index: int, face: DetectedFace) -> TKFace: """ Obtain the :class:`TKFace` object for the given face from the cache. If the face does not exist in the cache, then it is generated and added prior to returning. @@ -227,26 +226,33 @@ def get_tk_face(self, frame_index, face_index, face): is_active = frame_index == self._active_frame.frame_index key = "_".join([str(frame_index), str(face_index)]) if key not in self._tk_faces or is_active: - logger.trace("creating new tk_face: (key: %s, is_active: %s)", key, is_active) + logger.trace("creating new tk_face: (key: %s, " # type:ignore[attr-defined] + "is_active: %s)", key, is_active) if is_active: image = AlignedFace(face.landmarks_xy, image=self._active_frame.current_frame, centering=self._centering, size=self.face_size).face else: + thumb = face.thumbnail + assert thumb is not None image = AlignedFace(face.landmarks_xy, - image=cv2.imdecode(face.thumbnail, cv2.IMREAD_UNCHANGED), + image=cv2.imdecode(thumb, cv2.IMREAD_UNCHANGED), centering=self._centering, size=self.face_size, is_aligned=True).face + assert image is not None tk_face = self._get_tk_face_object(face, image, is_active) self._tk_faces[key] = tk_face else: - logger.trace("tk_face exists: %s", key) + logger.trace("tk_face exists: %s", key) # type:ignore[attr-defined] tk_face = self._tk_faces[key] return tk_face - def _get_tk_face_object(self, face, image, is_active): + def _get_tk_face_object(self, + face: DetectedFace, + image: np.ndarray, + is_active: bool) -> TKFace: """ Obtain an existing unallocated, or a newly created :class:`TKFace` and populate it with face information from the requested frame and face index. @@ -272,10 +278,16 @@ def _get_tk_face_object(self, face, image, is_active): (is_active and self.selected_editor == "mask")) mask = self._obtain_mask(face, self._canvas.selected_mask) if get_mask else None tk_face = TKFace(image, size=self.face_size, mask=mask) - logger.trace("face: %s, tk_face: %s", face, tk_face) + logger.trace("face: %s, tk_face: %s", face, tk_face) # type:ignore[attr-defined] return tk_face - def get_landmarks(self, frame_index, face_index, face, top_left, refresh=False): + def get_landmarks(self, + frame_index: int, + face_index: int, + face: DetectedFace, + top_left: list[float], + refresh: bool = False + ) -> dict[T.Literal["polygon", "line"], list[np.ndarray]]: """ Obtain the landmark points for each mesh annotation. First tries to obtain the aligned landmarks from the cache. If the landmarks do not exist @@ -290,7 +302,7 @@ def get_landmarks(self, frame_index, face_index, face, top_left, refresh=False): The face index of the face within the requested frame face: :class:`lib.align.DetectedFace` The detected face object to obtain landmarks for - top_left: tuple + top_left: list[float] The top left (x, y) points of the face's bounding box within the viewport refresh: bool, optional Whether to force a reload of the face's aligned landmarks, even if they already exist @@ -309,10 +321,10 @@ def get_landmarks(self, frame_index, face_index, face, top_left, refresh=False): aligned = AlignedFace(face.landmarks_xy, centering=self._centering, size=self.face_size) - landmarks = dict(polygon=[], line=[]) - for area, val in self._landmark_mapping.items(): - points = aligned.landmarks[val[0]:val[1]] + top_left - shape = "polygon" if area.endswith("eye") or area.startswith("mouth") else "line" + landmarks = {"polygon": [], "line": []} + for start, end, fill in LANDMARK_PARTS[aligned.landmark_type].values(): + points = aligned.landmarks[start:end] + top_left + shape: T.Literal["polygon", "line"] = "polygon" if fill else "line" landmarks[shape].append(points) self._landmarks[key] = landmarks return landmarks @@ -328,10 +340,12 @@ def _locate_mesh(self, mesh_ids, landmarks): The mesh point groupings and whether each group should be a line or a polygon """ for key, area in landmarks.items(): + if key not in mesh_ids: + continue for coords, mesh_id in zip(area, mesh_ids[key]): self._canvas.coords(mesh_id, *coords.flatten()) - def face_from_point(self, point_x, point_y): + def face_from_point(self, point_x: int, point_y: int) -> np.ndarray: """ Given an (x, y) point on the :class:`Viewport`, obtain the face information at that location. @@ -360,15 +374,117 @@ def face_from_point(self, point_x, point_y): retval = np.array((-1, -1, -1, -1)) else: retval = self._objects.visible_grid[:, y_idx, x_idx] - logger.trace(retval) + logger.trace(retval) # type:ignore[attr-defined] return retval - def move_active_to_top(self): + def move_active_to_top(self) -> None: """ Check whether the active frame is going off the bottom of the viewport, if so: move it to the top of the viewport. """ self._active_frame.move_to_top() +class Recycler: + """ Tkinter can slow down when constantly creating new objects. + + This class delivers recycled objects, if stale objects are available, otherwise creates a new + object + + Parameters + ---------- + :class:`~tools.manual.faceviewe.frame.FacesViewer` + The canvas that holds the faces display + """ + def __init__(self, canvas: FacesViewer) -> None: + self._canvas = canvas + self._assets: dict[T.Literal["image", "line", "polygon"], + list[int]] = {"image": [], "line": [], "polygon": []} + self._mesh_methods: dict[T.Literal["line", "polygon"], + T.Callable] = {"line": canvas.create_line, + "polygon": canvas.create_polygon} + + def recycle_assets(self, asset_ids: list[int]) -> None: + """ Recycle assets that are no longer required + + Parameters + ---------- + asset_ids: list[int] + The IDs of the assets to be recycled + """ + logger.trace("Recycling %s objects", len(asset_ids)) # type:ignore[attr-defined] + for asset_id in asset_ids: + asset_type = self._canvas.type(asset_id) + assert asset_type in self._assets + coords = (0, 0, 0, 0) if asset_type == "line" else (0, 0) + self._canvas.coords(asset_id, *coords) + + if asset_type == "image": + self._canvas.itemconfig(asset_id, image="") + + self._assets[asset_type].append(asset_id) + logger.trace("Recycled objects: %s", self._assets) # type:ignore[attr-defined] + + def get_image(self, coordinates: tuple[float | int, float | int]) -> int: + """ Obtain a recycled or new image object ID + + Parameters + ---------- + coordinates: tuple[float | int, float | int] + The co-ordinates that the image should be displayed at + + Returns + ------- + int + The canvas object id for the created image + """ + if self._assets["image"]: + retval = self._assets["image"].pop() + self._canvas.coords(retval, *coordinates) + logger.trace("Recycled image: %s", retval) # type:ignore[attr-defined] + else: + retval = self._canvas.create_image(*coordinates, + anchor=tk.NW, + tags=["viewport", "viewport_image"]) + logger.trace("Created new image: %s", retval) # type:ignore[attr-defined] + return retval + + def get_mesh(self, face: DetectedFace) -> dict[T.Literal["polygon", "line"], list[int]]: + """ Get the mesh annotation for the landmarks. This is made up of a series of polygons + or lines, depending on which part of the face is being annotated. Creates a new series of + objects, or pulls existing objects from the recycled objects pool if they are available. + + Parameters + ---------- + face: :class:`~lib.align.detected_face.DetectedFace` + The detected face object to obrain the mesh for + + Returns + ------- + dict[Literal["polygon", "line"], list[int]] + The dictionary of line and polygon tkinter canvas object ids for the mesh annotation + """ + mesh_kwargs = self._canvas.viewport.mesh_kwargs + mesh_parts = LANDMARK_PARTS[LandmarkType.from_shape(face.landmarks_xy.shape)] + retval: dict[T.Literal["polygon", "line"], list[int]] = {} + for _, _, fill in mesh_parts.values(): + asset_type: T.Literal["polygon", "line"] = "polygon" if fill else "line" + kwargs = mesh_kwargs[asset_type] + if self._assets[asset_type]: + asset_id = self._assets[asset_type].pop() + self._canvas.itemconfig(asset_id, **kwargs) + logger.trace("Recycled mesh %s: %s", # type:ignore[attr-defined] + asset_type, asset_id) + else: + coords = (0, 0) if asset_type == "polygon" else (0, 0, 0, 0) + tags = ["viewport", "viewport_mesh", f"viewport_{asset_type}"] + asset_id = self._mesh_methods[asset_type](coords, width=1, tags=tags, **kwargs) + logger.trace("Created new mesh %s: %s", # type:ignore[attr-defined] + asset_type, asset_id) + + retval.setdefault(asset_type, []).append(asset_id) + logger.trace("Got mesh: %s", retval) # type:ignore[attr-defined] + return retval + + class VisibleObjects(): """ Holds the objects from the :class:`~tools.manual.faceviewer.frame.Grid` that appear in the viewable area of the :class:`Viewport`. @@ -378,20 +494,22 @@ class VisibleObjects(): viewport: :class:`Viewport` The viewport object for the :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas """ - def __init__(self, viewport): + def __init__(self, viewport: Viewport) -> None: + logger.debug(parse_class_init(locals())) self._viewport = viewport self._canvas = viewport._canvas self._grid = viewport._grid self._size = viewport.face_size - self._visible_grid = None - self._visible_faces = None - self._images = [] - self._meshes = [] - self._recycled = dict(images=[], meshes=[]) + self._visible_grid = np.zeros((4, 0, 0)) + self._visible_faces = np.zeros((0, 0)) + self._recycler = Recycler(self._canvas) + self._images = np.zeros((0, 0), dtype=np.int64) + self._meshes = np.zeros((0, 0)) + logger.debug("Initialized: %s", self.__class__.__name__) @property - def visible_grid(self): + def visible_grid(self) -> np.ndarray: """ :class:`numpy.ndarray`: The currently visible section of the :class:`~tools.manual.faceviewer.frame.Grid` @@ -403,7 +521,7 @@ def visible_grid(self): return self._visible_grid @property - def visible_faces(self): + def visible_faces(self) -> np.ndarray: """ :class:`numpy.ndarray`: The currently visible :class:`~lib.align.DetectedFace` objects. @@ -414,7 +532,7 @@ def visible_faces(self): return self._visible_faces @property - def images(self): + def images(self) -> np.ndarray: """ :class:`numpy.ndarray`: The viewport's tkinter canvas image objects. A numpy array of shape (`rows`, `columns`) corresponding to the viewable area of the @@ -423,7 +541,7 @@ def images(self): return self._images @property - def meshes(self): + def meshes(self) -> np.ndarray: """ :class:`numpy.ndarray`: The viewport's tkinter canvas mesh annotation objects. A numpy array of shape (`rows`, `columns`) corresponding to the viewable area of the @@ -433,28 +551,29 @@ def meshes(self): return self._meshes @property - def _top_left(self): + def _top_left(self) -> np.ndarray: """ :class:`numpy.ndarray`: The canvas (`x`, `y`) position of the face currently in the viewable area's top left position. """ - if self._images is None or not np.any(self._images): - retval = [0, 0] + if not np.any(self._images): + retval = [0.0, 0.0] else: retval = self._canvas.coords(self._images[0][0]) return np.array(retval, dtype="int") - def update(self): + def update(self) -> None: """ Load and unload thumbnails in the visible area of the faces viewer. """ if self._canvas.optional_annotations["mesh"]: # Display any hidden end of row meshes self._canvas.itemconfig("viewport_mesh", state="normal") self._visible_grid, self._visible_faces = self._grid.visible_area - if (isinstance(self._images, np.ndarray) and isinstance(self._visible_grid, np.ndarray) + if (np.any(self._images) and np.any(self._visible_grid) and self._visible_grid.shape[1:] != self._images.shape): self._reset_viewport() required_rows = self._visible_grid.shape[1] if self._grid.is_valid else 0 existing_rows = len(self._images) - logger.trace("existing_rows: %s. required_rows: %s", existing_rows, required_rows) + logger.trace("existing_rows: %s. required_rows: %s", # type:ignore[attr-defined] + existing_rows, required_rows) if existing_rows > required_rows: self._remove_rows(existing_rows, required_rows) @@ -463,43 +582,20 @@ def update(self): self._shift() - def _reset_viewport(self): + def _reset_viewport(self) -> None: """ Reset all objects in the viewport on a column count change. Reset the viewport size to the newly specified face size. """ logger.debug("Resetting Viewport") self._size = self._viewport.face_size images = self._images.flatten().tolist() - meshes = self._meshes.flatten().tolist() - self._recycle_objects(images, meshes) - self._images = [] - self._meshes = [] - - def _recycle_objects(self, images, meshes): - """ Reset the visible property and position of the given objects and add to the recycle - bin. - - Parameters - --------- - images: list - List of image_ids to be recycled - meshes: list - List of dictionaries containing the mesh annotation ids to be recycled - """ - logger.debug("Recycling objects: (images: %s, meshes: %s)", len(images), len(meshes)) - for image_id in images: - self._canvas.itemconfig(image_id, image="") - self._canvas.coords(image_id, 0, 0) - for mesh in meshes: - for key, mesh_ids in mesh.items(): - coords = (0, 0, 0, 0) if key == "line" else (0, 0) - for mesh_id in mesh_ids: - self._canvas.coords(mesh_id, *coords) - - self._recycled["images"].extend(images) - self._recycled["meshes"].extend(meshes) - logger.trace("Recycled objects: %s", self._recycled) - - def _remove_rows(self, existing_rows, required_rows): + meshes = [parts for mesh in [mesh.values() for mesh in self._meshes.flatten()] + for parts in mesh] + mesh_ids = [asset for mesh in meshes for asset in mesh] + self._recycler.recycle_assets(images + mesh_ids) + self._images = np.zeros((0, 0), np.int64) + self._meshes = np.zeros((0, 0)) + + def _remove_rows(self, existing_rows: int, required_rows: int) -> None: """ Remove and recycle rows from the viewport that are not in the view area. Parameters @@ -511,14 +607,19 @@ def _remove_rows(self, existing_rows, required_rows): """ logger.debug("Removing rows from viewport: (existing_rows: %s, required_rows: %s)", existing_rows, required_rows) - self._recycle_objects(self._images[required_rows: existing_rows].flatten().tolist(), - self._meshes[required_rows: existing_rows].flatten().tolist()) + images = self._images[required_rows: existing_rows].flatten().tolist() + meshes = [parts + for mesh in [mesh.values() + for mesh in self._meshes[required_rows: existing_rows].flatten()] + for parts in mesh] + mesh_ids = [asset for mesh in meshes for asset in mesh] + self._recycler.recycle_assets(images + mesh_ids) self._images = self._images[:required_rows] self._meshes = self._meshes[:required_rows] - logger.trace("self._images: %s, self._meshes: %s", + logger.trace("self._images: %s, self._meshes: %s", # type:ignore[attr-defined] self._images.shape, self._meshes.shape) - def _add_rows(self, existing_rows, required_rows): + def _add_rows(self, existing_rows: int, required_rows: int) -> None: """ Add rows to the viewport. Parameters @@ -531,92 +632,41 @@ def _add_rows(self, existing_rows, required_rows): logger.debug("Adding rows to viewport: (existing_rows: %s, required_rows: %s)", existing_rows, required_rows) columns = self._grid.columns_rows[0] - if not isinstance(self._images, np.ndarray): - base_coords = [(col * self._size, 0) for col in range(columns)] + + base_coords: list[list[float | int]] + + if not np.any(self._images): + base_coords = [[col * self._size, 0] for col in range(columns)] else: base_coords = [self._canvas.coords(item_id) for item_id in self._images[0]] - logger.trace("existing rows: %s, required_rows: %s, base_coords: %s", - existing_rows, required_rows, base_coords) + logger.trace("existing rows: %s, required_rows: %s, " # type:ignore[attr-defined] + "base_coords: %s", existing_rows, required_rows, base_coords) images = [] meshes = [] for row in range(existing_rows, required_rows): y_coord = base_coords[0][1] + (row * self._size) - images.append(np.array([self._get_image((coords[0], y_coord)) - for coords in base_coords])) - meshes.append(np.array([self._get_mesh() for _ in range(columns)])) - images = np.array(images) - meshes = np.array(meshes) + images.append([self._recycler.get_image((coords[0], y_coord)) + for coords in base_coords]) + meshes.append([self._recycler.get_mesh(face) for face in self._visible_faces[row]]) - if not isinstance(self._images, np.ndarray): + a_images = np.array(images) + a_meshes = np.array(meshes) + + if not np.any(self._images): logger.debug("Adding initial viewport objects: (image shapes: %s, mesh shapes: %s)", - images.shape, meshes.shape) - self._images = images - self._meshes = meshes + a_images.shape, a_meshes.shape) + self._images = a_images + self._meshes = a_meshes else: logger.debug("Adding new viewport objects: (image shapes: %s, mesh shapes: %s)", - images.shape, meshes.shape) - self._images = np.concatenate((self._images, images)) - self._meshes = np.concatenate((self._meshes, meshes)) - logger.trace("self._images: %s, self._meshes: %s", self._images.shape, self._meshes.shape) - - def _get_image(self, coordinates): - """ Create or recycle a tkinter canvas image object with the given coordinates. - - Parameters - ---------- - coordinates: tuple - The (`x`, `y`) coordinates for the top left corner of the image + a_images.shape, a_meshes.shape) + self._images = np.concatenate((self._images, a_images)) + self._meshes = np.concatenate((self._meshes, a_meshes)) - Returns - ------- - int - The canvas object id for the created image - """ - if self._recycled["images"]: - image_id = self._recycled["images"].pop() - self._canvas.coords(image_id, *coordinates) - logger.trace("Recycled image: %s", image_id) - else: - image_id = self._canvas.create_image(*coordinates, - anchor=tk.NW, - tags=["viewport", "viewport_image"]) - logger.trace("Created new image: %s", image_id) - return image_id - - def _get_mesh(self): - """ Get the mesh annotation for the landmarks. This is made up of a series of polygons - or lines, depending on which part of the face is being annotated. Creates a new series of - objects, or pulls existing objects from the recycled objects pool if they are available. + logger.trace("self._images: %s, self._meshes: %s", # type:ignore[attr-defined] + self._images.shape, self._meshes.shape) - Returns - ------- - dict - The dictionary of line and polygon tkinter canvas object ids for the mesh annotation - """ - kwargs = self._viewport.mesh_kwargs - logger.trace("self.mesh_kwargs: %s", kwargs) - if self._recycled["meshes"]: - mesh = self._recycled["meshes"].pop() - for key, mesh_ids in mesh.items(): - for mesh_id in mesh_ids: - self._canvas.itemconfig(mesh_id, **kwargs[key]) - logger.trace("Recycled mesh: %s", mesh) - else: - tags = ["viewport", "viewport_mesh"] - mesh = dict(polygon=[self._canvas.create_polygon(0, 0, - width=1, - tags=tags + ["viewport_polygon"], - **kwargs["polygon"]) - for _ in range(4)], - line=[self._canvas.create_line(0, 0, 0, 0, - width=1, - tags=tags + ["viewport_line"], - **kwargs["line"]) - for _ in range(5)]) - logger.trace("Created new mesh: %s", mesh) - return mesh - - def _shift(self): + def _shift(self) -> bool: """ Shift the viewport in the y direction if required Returns @@ -625,378 +675,18 @@ def _shift(self): ``True`` if the viewport was shifted otherwise ``False`` """ current_y = self._top_left[1] - required_y = self._visible_grid[3, 0, 0] if self._grid.is_valid else 0 - logger.trace("current_y: %s, required_y: %s", current_y, required_y) + required_y = self.visible_grid[3, 0, 0] if self._grid.is_valid else 0 + logger.trace("current_y: %s, required_y: %s", # type:ignore[attr-defined] + current_y, required_y) if current_y == required_y: - logger.trace("No move required") + logger.trace("No move required") # type:ignore[attr-defined] return False shift_amount = required_y - current_y - logger.trace("Shifting viewport: %s", shift_amount) + logger.trace("Shifting viewport: %s", shift_amount) # type:ignore[attr-defined] self._canvas.move("viewport", 0, shift_amount) return True -class HoverBox(): - """ Handle the current mouse location when over the :class:`Viewport`. - - Highlights the face currently underneath the cursor and handles actions when clicking - on a face. - - Parameters - ---------- - viewport: :class:`Viewport` - The viewport object for the :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas - """ - def __init__(self, viewport): - logger.debug("Initializing: %s (viewport: %s)", self.__class__.__name__, viewport) - self._viewport = viewport - self._canvas = viewport._canvas - self._grid = viewport._canvas.grid - self._globals = viewport._canvas._globals - self._navigation = viewport._canvas._display_frame.navigation - self._box = self._canvas.create_rectangle(0, 0, self._size, self._size, - outline="#0000ff", - width=2, - state="hidden", - fill="#0000ff", - stipple="gray12", - tags="hover_box") - self._current_frame_index = None - self._current_face_index = None - self._canvas.bind("", lambda e: self._clear()) - self._canvas.bind("", self.on_hover) - self._canvas.bind("", lambda e: self._select_frame()) - logger.debug("Initialized: %s", self.__class__.__name__) - - @property - def _size(self): - """ int: the currently set viewport face size in pixels. """ - return self._viewport.face_size - - def on_hover(self, event): - """ Highlight the face and set the mouse cursor for the mouse's current location. - - Parameters - ---------- - event: :class:`tkinter.Event` or ``None`` - The tkinter mouse event. Provides the current location of the mouse cursor. If ``None`` - is passed as the event (for example when this function is being called outside of a - mouse event) then the location of the cursor will be calculated - """ - if event is None: - pnts = np.array((self._canvas.winfo_pointerx(), self._canvas.winfo_pointery())) - pnts -= np.array((self._canvas.winfo_rootx(), self._canvas.winfo_rooty())) - else: - pnts = (event.x, event.y) - - coords = (int(self._canvas.canvasx(pnts[0])), int(self._canvas.canvasy(pnts[1]))) - face = self._viewport.face_from_point(*coords) - frame_idx, face_idx = face[:2] - - if frame_idx == self._current_frame_index and face_idx == self._current_face_index: - return - - is_zoomed = self._globals.is_zoomed - if (-1 in face or (frame_idx == self._globals.frame_index - and (not is_zoomed or - (is_zoomed and face_idx == self._globals.tk_face_index.get())))): - self._clear() - self._canvas.config(cursor="") - self._current_frame_index = None - self._current_face_index = None - return - - logger.debug("Viewport hover: frame_idx: %s, face_idx: %s", frame_idx, face_idx) - - self._canvas.config(cursor="hand2") - self._highlight(face[2:]) - self._current_frame_index = frame_idx - self._current_face_index = face_idx - - def _clear(self): - """ Hide the hover box when the mouse is not over a face. """ - if self._canvas.itemcget(self._box, "state") != "hidden": - self._canvas.itemconfig(self._box, state="hidden") - - def _highlight(self, top_left): - """ Display the hover box around the face that the mouse is currently over. - - Parameters - ---------- - top_left: tuple - The top left point of the highlight box location - """ - coords = (*top_left, *top_left + self._size) - self._canvas.coords(self._box, *coords) - self._canvas.itemconfig(self._box, state="normal") - self._canvas.tag_raise(self._box) - - def _select_frame(self): - """ Select the face and the subsequent frame (in the editor view) when a face is clicked - on in the :class:`Viewport`. - """ - frame_id = self._current_frame_index - is_zoomed = self._globals.is_zoomed - logger.debug("Face clicked. Global frame index: %s, Current frame_id: %s, is_zoomed: %s", - self._globals.frame_index, frame_id, is_zoomed) - if frame_id is None or (frame_id == self._globals.frame_index and not is_zoomed): - return - face_idx = self._current_face_index if is_zoomed else 0 - self._globals.tk_face_index.set(face_idx) - transport_id = self._grid.transport_index_from_frame(frame_id) - logger.trace("frame_index: %s, transport_id: %s, face_idx: %s", - frame_id, transport_id, face_idx) - if transport_id is None: - return - self._navigation.stop_playback() - self._globals.tk_transport_index.set(transport_id) - self._viewport.move_active_to_top() - self.on_hover(None) - - -class ActiveFrame(): - """ Handles the display of faces and annotations for the currently active frame. - - Parameters - ---------- - canvas: :class:`tkinter.Canvas` - The :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas - tk_edited_variable: :class:`tkinter.BooleanVar` - The tkinter callback variable indicating that a face has been edited - """ - def __init__(self, viewport, tk_edited_variable): - logger.debug("Initializing: %s (viewport: %s, tk_edited_variable: %s)", - self.__class__.__name__, viewport, tk_edited_variable) - self._objects = viewport._objects - self._viewport = viewport - self._grid = viewport._grid - self._tk_faces = viewport._tk_faces - self._canvas = viewport._canvas - self._globals = viewport._canvas._globals - self._navigation = viewport._canvas._display_frame.navigation - self._last_execution = dict(frame_index=-1, size=viewport.face_size) - self._tk_vars = dict(selected_editor=self._canvas._display_frame.tk_selected_action, - edited=tk_edited_variable) - self._assets = dict(images=[], meshes=[], faces=[], boxes=[]) - - self._globals.tk_update_active_viewport.trace("w", lambda *e: self._reload_callback()) - tk_edited_variable.trace("w", lambda *e: self._update_on_edit()) - logger.debug("Initialized: %s", self.__class__.__name__) - - @property - def frame_index(self): - """ int: The frame index of the currently displayed frame. """ - return self._globals.frame_index - - @property - def current_frame(self): - """ :class:`numpy.ndarray`: A BGR version of the frame currently being displayed. """ - return self._globals.current_frame["image"] - - @property - def _size(self): - """ int: The size of the thumbnails displayed in the viewport, in pixels. """ - return self._viewport.face_size - - @property - def _optional_annotations(self): - """ dict: The currently selected optional annotations """ - return self._canvas.optional_annotations - - def _reload_callback(self): - """ If a frame has changed, triggering the variable, then update the active frame. Return - having done nothing if the variable is resetting. """ - if self._globals.tk_update_active_viewport.get(): - self.reload_annotations() - - def reload_annotations(self): - """ Handles the reloading of annotations for the currently active faces. - - Highlights the faces within the viewport of those faces that exist in the currently - displaying frame. Applies annotations based on the optional annotations and current - editor selections. - """ - logger.trace("Reloading annotations") - if np.any(self._assets["images"]): - self._clear_previous() - - self._set_active_objects() - self._check_active_in_view() - - if not np.any(self._assets["images"]): - logger.trace("No active faces. Returning") - self._last_execution["frame_index"] = self.frame_index - return - - if self._last_execution["frame_index"] != self.frame_index: - self.move_to_top() - self._create_new_boxes() - - self._update_face() - self._canvas.tag_raise("active_highlighter") - self._globals.tk_update_active_viewport.set(False) - self._last_execution["frame_index"] = self.frame_index - - def _clear_previous(self): - """ Reverts the previously selected annotations to their default state. """ - logger.trace("Clearing previous active frame") - self._canvas.itemconfig("active_highlighter", state="hidden") - - for key in ("polygon", "line"): - tag = f"active_mesh_{key}" - self._canvas.itemconfig(tag, **self._viewport.mesh_kwargs[key], width=1) - self._canvas.dtag(tag) - - if self._viewport.selected_editor == "mask" and not self._optional_annotations["mask"]: - for key, tk_face in self._tk_faces.items(): - if key.startswith(f"{self._last_execution['frame_index']}_"): - tk_face.update_mask(None) - - def _set_active_objects(self): - """ Collect the objects that exist in the currently active frame from the main grid. """ - if self._grid.is_valid: - rows, cols = np.where(self._objects.visible_grid[0] == self.frame_index) - logger.trace("Setting active objects: (rows: %s, columns: %s)", rows, cols) - self._assets["images"] = self._objects.images[rows, cols] - self._assets["meshes"] = self._objects.meshes[rows, cols] - self._assets["faces"] = self._objects.visible_faces[rows, cols] - else: - logger.trace("No valid grid. Clearing active objects") - self._assets["images"] = [] - self._assets["meshes"] = [] - self._assets["faces"] = [] - - def _check_active_in_view(self): - """ If the frame has changed, there are faces in the frame, but they don't appear in the - viewport, then bring the active faces to the top of the viewport. """ - if (not np.any(self._assets["images"]) and - self._last_execution["frame_index"] != self.frame_index and - self._grid.frame_has_faces(self.frame_index)): - y_coord = self._grid.y_coord_from_frame(self.frame_index) - logger.trace("Active not in view. Moving to: %s", y_coord) - self._canvas.yview_moveto(y_coord / self._canvas.bbox("backdrop")[3]) - self._viewport.update() - - def move_to_top(self): - """ Move the currently selected frame's faces to the top of the viewport if they are moving - off the bottom of the viewer. """ - height = self._canvas.bbox("backdrop")[3] - bot = int(self._canvas.coords(self._assets["images"][-1])[1] + self._size) - - y_top, y_bot = (int(round(pnt * height)) for pnt in self._canvas.yview()) - - if y_top < bot < y_bot: # bottom face is still in fully visible area - logger.trace("Active faces in frame. Returning") - return - - top = int(self._canvas.coords(self._assets["images"][0])[1]) - if y_top == top: - logger.trace("Top face already on top row. Returning") - return - - if self._canvas.winfo_height() > self._size: - logger.trace("Viewport taller than single face height. Moving Active faces to top: %s", - top) - self._canvas.yview_moveto(top / height) - self._viewport.update() - elif self._canvas.winfo_height() <= self._size and y_top != top: - logger.trace("Viewport shorter than single face height. Moving Active faces to " - "top: %s", top) - self._canvas.yview_moveto(top / height) - self._viewport.update() - - def _create_new_boxes(self): - """ The highlight boxes (border around selected faces) are the only additional annotations - that are required for the highlighter. If more faces are displayed in the current frame - than highlight boxes are available, then new boxes are created to accommodate the - additional faces. """ - new_boxes_count = max(0, len(self._assets["images"]) - len(self._assets["boxes"])) - if new_boxes_count == 0: - return - logger.debug("new_boxes_count: %s", new_boxes_count) - for _ in range(new_boxes_count): - box = self._canvas.create_rectangle(0, - 0, - self._viewport.face_size, self._viewport.face_size, - outline="#00FF00", - width=2, - state="hidden", - tags=["active_highlighter"]) - logger.trace("Created new highlight_box: %s", box) - self._assets["boxes"].append(box) - - def _update_on_edit(self): - """ Update the active faces on a frame edit. """ - if not self._tk_vars["edited"].get(): - return - self._set_active_objects() - self._update_face() - self._tk_vars["edited"].set(False) - - def _update_face(self): - """ Update the highlighted annotations for faces in the currently selected frame. """ - for face_idx, (image_id, mesh_ids, box_id, det_face), in enumerate( - zip(self._assets["images"], - self._assets["meshes"], - self._assets["boxes"], - self._assets["faces"])): - if det_face is None: - continue - top_left = np.array(self._canvas.coords(image_id)) - coords = (*top_left, *top_left + self._size) - tk_face = self._viewport.get_tk_face(self.frame_index, face_idx, det_face) - self._canvas.itemconfig(image_id, image=tk_face.photo) - self._show_box(box_id, coords) - self._show_mesh(mesh_ids, face_idx, det_face, top_left) - self._last_execution["size"] = self._viewport.face_size - - def _show_box(self, item_id, coordinates): - """ Display the highlight box around the given coordinates. - - Parameters - ---------- - item_id: int - The tkinter canvas object identifier for the highlight box - coordinates: :class:`numpy.ndarray` - The (x, y, x1, y1) coordinates of the top left corner of the box - """ - self._canvas.coords(item_id, *coordinates) - self._canvas.itemconfig(item_id, state="normal") - - def _show_mesh(self, mesh_ids, face_index, detected_face, top_left): - """ Display the mesh annotation for the given face, at the given location. - - Parameters - ---------- - mesh_ids: dict - Dictionary containing the `polygon` and `line` tkinter canvas identifiers that make up - the mesh for the given face - face_index: int - The face index within the frame for the given face - detected_face: :class:`~lib.align.DetectedFace` - The detected face object that contains the landmarks for generating the mesh - top_left: tuple - The (x, y) top left co-ordinates of the mesh's bounding box - """ - state = "normal" if (self._tk_vars["selected_editor"].get() != "Mask" or - self._optional_annotations["mesh"]) else "hidden" - kwargs = dict(polygon=dict(fill="", width=2, outline=self._canvas.control_colors["Mesh"]), - line=dict(fill=self._canvas.control_colors["Mesh"], width=2)) - - edited = (self._tk_vars["edited"].get() and - self._tk_vars["selected_editor"].get() not in ("Mask", "View")) - landmarks = self._viewport.get_landmarks(self.frame_index, - face_index, - detected_face, - top_left, - edited) - for key, kwarg in kwargs.items(): - for idx, mesh_id in enumerate(mesh_ids[key]): - self._canvas.coords(mesh_id, *landmarks[key][idx].flatten()) - self._canvas.itemconfig(mesh_id, state=state, **kwarg) - self._canvas.addtag_withtag(f"active_mesh_{key}", mesh_id) - - class TKFace(): """ An object that holds a single :class:`tkinter.PhotoImage` face, ready for placement in the :class:`Viewport`, Handles the placement of and removal of masks for the face as well as @@ -1013,12 +703,8 @@ class TKFace(): The mask to be applied to the face image. Pass ``None`` if no mask is to be used. Default ``None`` """ - def __init__(self, face, size=128, mask=None): - logger.trace("Initializing %s: (face: %s, size: %s, mask: %s)", - self.__class__.__name__, - face if face is None else face.shape, - size, - mask if mask is None else mask.shape) + def __init__(self, face: np.ndarray, size: int = 128, mask: np.ndarray | None = None) -> None: + logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] self._size = size if face.ndim == 2 and face.shape[1] == 1: self._face = self._image_from_jpg(face) @@ -1026,17 +712,17 @@ def __init__(self, face, size=128, mask=None): self._face = face[..., 2::-1] self._photo = ImageTk.PhotoImage(self._generate_tk_face_data(mask)) - logger.trace("Initialized %s", self.__class__.__name__) + logger.trace("Initialized %s", self.__class__.__name__) # type:ignore[attr-defined] # << PUBLIC PROPERTIES >> # @property - def photo(self): + def photo(self) -> tk.PhotoImage: """ :class:`tkinter.PhotoImage`: The face in a format that can be placed on the :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas. """ return self._photo # << PUBLIC METHODS >> # - def update(self, face, mask): + def update(self, face: np.ndarray, mask: np.ndarray) -> None: """ Update the :attr:`photo` with the given face and mask. Parameters @@ -1049,7 +735,7 @@ def update(self, face, mask): self._face = face[..., 2::-1] self._photo.paste(self._generate_tk_face_data(mask)) - def update_mask(self, mask): + def update_mask(self, mask: np.ndarray | None) -> None: """ Update the mask in the 4th channel of :attr:`photo` to the given mask. Parameters @@ -1060,7 +746,7 @@ def update_mask(self, mask): self._photo.paste(self._generate_tk_face_data(mask)) # << PRIVATE METHODS >> # - def _image_from_jpg(self, face): + def _image_from_jpg(self, face: np.ndarray) -> np.ndarray: """ Convert an encoded jpg into 3 channel BGR image. Parameters @@ -1079,7 +765,7 @@ def _image_from_jpg(self, face): face = cv2.resize(face, (self._size, self._size), interpolation=interp) return face[..., 2::-1] - def _generate_tk_face_data(self, mask): + def _generate_tk_face_data(self, mask: np.ndarray | None) -> tk.PhotoImage: """ Create the :class:`tkinter.PhotoImage` from the currant :attr:`_face`. Parameters diff --git a/tools/manual/frameviewer/editor/landmarks.py b/tools/manual/frameviewer/editor/landmarks.py index 49c9c17d86..452e426ab0 100644 --- a/tools/manual/frameviewer/editor/landmarks.py +++ b/tools/manual/frameviewer/editor/landmarks.py @@ -3,7 +3,7 @@ import gettext import numpy as np -from lib.align import AlignedFace +from lib.align import AlignedFace, LANDMARK_PARTS, LandmarkType from ._base import Editor, logger # LOCALES @@ -67,7 +67,7 @@ def _reset_selection(self, event=None): # pylint:disable=unused-argument outline="gray", state="hidden") self._canvas.coords(self._selection_box, 0, 0, 0, 0) - self._drag_data = dict() + self._drag_data = {} if event is not None: self._drag_start(event) @@ -83,7 +83,7 @@ def update_annotation(self): landmarks = aligned.landmarks + zoomed_offset # Hide all landmarks and only display selected self._canvas.itemconfig("lm_dsp", state="hidden") - self._canvas.itemconfig("lm_dsp_face_{}".format(face_index), state="normal") + self._canvas.itemconfig(f"lm_dsp_face_{face_index}", state="normal") else: landmarks = self._scale_to_display(face.landmarks_xy) for lm_idx, landmark in enumerate(landmarks): @@ -109,8 +109,8 @@ def _display_landmark(self, bounding_box, face_index, landmark_index): color = self._control_color bbox = (bounding_box[0] - radius, bounding_box[1] - radius, bounding_box[0] + radius, bounding_box[1] + radius) - key = "lm_dsp_{}".format(landmark_index) - kwargs = dict(outline=color, fill=color, width=radius) + key = f"lm_dsp_{landmark_index}" + kwargs = {"outline": color, "fill": color, "width": radius} self._object_tracker(key, "oval", face_index, bbox, kwargs) def _label_landmark(self, bounding_box, face_index, landmark_index): @@ -132,9 +132,9 @@ def _label_landmark(self, bounding_box, face_index, landmark_index): # NB The text must be visible to be able to get the bounding box, so set to hidden # after the bounding box has been retrieved - keys = ["lm_lbl_{}".format(landmark_index), "lm_lbl_bg_{}".format(landmark_index)] - text_kwargs = dict(fill="black", font=("Default", 10), text=str(landmark_index + 1)) - bg_kwargs = dict(fill="#ffffea", outline="black") + keys = [f"lm_lbl_{landmark_index}", f"lm_lbl_bg_{landmark_index}"] + text_kwargs = {"fill": "black", "font": ("Default", 10), "text": str(landmark_index + 1)} + bg_kwargs = {"fill": "#ffffea", "outline": "black"} text_id = self._object_tracker(keys[0], "text", face_index, top_left, text_kwargs) bbox = self._canvas.bbox(text_id) @@ -162,11 +162,11 @@ def _grab_landmark(self, bounding_box, face_index, landmark_index): radius = 7 bbox = (bounding_box[0] - radius, bounding_box[1] - radius, bounding_box[0] + radius, bounding_box[1] + radius) - key = "lm_grb_{}".format(landmark_index) - kwargs = dict(outline="", - fill="", - width=1, - dash=(2, 4)) + key = f"lm_grb_{landmark_index}" + kwargs = {"outline": "", + "fill": "", + "width": 1, + "dash": (2, 4)} self._object_tracker(key, "oval", face_index, bbox, kwargs) # << MOUSE HANDLING >> @@ -185,7 +185,7 @@ def _update_cursor(self, event): if self._drag_data: self._update_cursor_select_mode(event) else: - objs = self._canvas.find_withtag("lm_grb_face_{}".format(self._globals.face_index) + objs = self._canvas.find_withtag(f"lm_grb_face_{self._globals.face_index}" if self._globals.is_zoomed else "lm_grb") item_ids = set(self._canvas.find_overlapping(event.x - 6, event.y - 6, @@ -226,7 +226,7 @@ def _update_cursor_point_mode(self, item_id): self._canvas.config(cursor="none") for prefix in ("lm_lbl_", "lm_lbl_bg_"): - tag = "{}{}_face_{}".format(prefix, lm_idx, face_idx) + tag = f"{prefix}{lm_idx}_face_{face_idx}" logger.trace("Displaying: %s tag: %s", self._canvas.type(tag), tag) self._canvas.itemconfig(tag, state="normal") self._mouse_location = obj_idx @@ -271,7 +271,7 @@ def _drag_start(self, event): self._drag_data["start_location"] = (event.x, event.y) self._drag_callback = self._move_selection else: # Reset - self._drag_data = dict() + self._drag_data = {} self._drag_callback = None self._reset_selection(event) @@ -294,7 +294,7 @@ def _drag_stop(self, event): # pylint:disable=unused-argument self._det_faces.update.post_edit_trigger(self._globals.frame_index, self._mouse_location[0]) self._mouse_location = None - self._drag_data = dict() + self._drag_data = {} elif self._drag_data and self._drag_data.get("selected", False): self._drag_stop_selected() else: @@ -429,15 +429,6 @@ class Mesh(Editor): The _detected_faces data for this manual session """ def __init__(self, canvas, detected_faces): - self._landmark_mapping = dict(mouth_inner=(60, 68), - mouth_outer=(48, 60), - right_eyebrow=(17, 22), - left_eyebrow=(22, 27), - right_eye=(36, 42), - left_eye=(42, 48), - nose=(27, 36), - jaw=(0, 17), - chin=(8, 11)) super().__init__(canvas, detected_faces, None) def update_annotation(self): @@ -452,19 +443,23 @@ def update_annotation(self): centering="face", size=min(self._globals.frame_display_dims)) landmarks = aligned.landmarks + zoomed_offset + landmark_mapping = LANDMARK_PARTS[aligned.landmark_type] # Hide all meshes and only display selected self._canvas.itemconfig("Mesh", state="hidden") - self._canvas.itemconfig("Mesh_face_{}".format(face_index), state="normal") + self._canvas.itemconfig(f"Mesh_face_{face_index}", state="normal") else: landmarks = self._scale_to_display(face.landmarks_xy) + landmark_mapping = LANDMARK_PARTS[LandmarkType.from_shape(landmarks.shape)] logger.trace("Drawing Landmarks Mesh: (landmarks: %s, color: %s)", landmarks, color) - for idx, (segment, val) in enumerate(self._landmark_mapping.items()): - key = "mesh_{}".format(idx) - pts = landmarks[val[0]:val[1]].flatten() - if segment in ("right_eye", "left_eye", "mouth_inner", "mouth_outer"): - kwargs = dict(fill="", outline=color, width=1) - self._object_tracker(key, "polygon", face_index, pts, kwargs) + for idx, (start, end, fill) in enumerate(landmark_mapping.values()): + key = f"mesh_{idx}" + pts = landmarks[start:end].flatten() + if fill: + kwargs = {"fill": "", "outline": color, "width": 1} + asset = "polygon" else: - self._object_tracker(key, "line", face_index, pts, dict(fill=color, width=1)) + kwargs = {"fill": color, "width": 1} + asset = "line" + self._object_tracker(key, asset, face_index, pts, kwargs) # Place mesh as bottom annotation self._canvas.tag_raise(self.__class__.__name__, "main_image") diff --git a/tools/manual/frameviewer/frame.py b/tools/manual/frameviewer/frame.py index f42d53f79a..23c953a45b 100644 --- a/tools/manual/frameviewer/frame.py +++ b/tools/manual/frameviewer/frame.py @@ -42,7 +42,7 @@ def __init__(self, parent, tk_globals, detected_faces): self._globals = tk_globals self._det_faces = detected_faces - self._optional_widgets = dict() + self._optional_widgets = {} self._actions_frame = ActionsFrame(self) main_frame = ttk.Frame(self) @@ -74,28 +74,28 @@ def __init__(self, parent, tk_globals, detected_faces): @property def _helptext(self): """ dict: {`name`: `help text`} Helptext lookup for navigation buttons """ - return dict( - play=_("Play/Pause (SPACE)"), - beginning=_("Go to First Frame (HOME)"), - prev=_("Go to Previous Frame (Z)"), - next=_("Go to Next Frame (X)"), - end=_("Go to Last Frame (END)"), - extract=_("Extract the faces to a folder... (Ctrl+E)"), - save=_("Save the Alignments file (Ctrl+S)"), - mode=_("Filter Frames to only those Containing the Selected Item (F)"), - distance=_("Set the distance from an 'average face' to be considered misaligned. " - "Higher distances are more restrictive")) + return { + "play": _("Play/Pause (SPACE)"), + "beginning": _("Go to First Frame (HOME)"), + "prev": _("Go to Previous Frame (Z)"), + "next": _("Go to Next Frame (X)"), + "end": _("Go to Last Frame (END)"), + "extract": _("Extract the faces to a folder... (Ctrl+E)"), + "save": _("Save the Alignments file (Ctrl+S)"), + "mode": _("Filter Frames to only those Containing the Selected Item (F)"), + "distance": _("Set the distance from an 'average face' to be considered misaligned. " + "Higher distances are more restrictive")} @property def _btn_action(self): """ dict: {`name`: `action`} Command lookup for navigation buttons """ - actions = dict(play=self._navigation.handle_play_button, - beginning=self._navigation.goto_first_frame, - prev=self._navigation.decrement_frame, - next=self._navigation.increment_frame, - end=self._navigation.goto_last_frame, - extract=self._det_faces.extract, - save=self._det_faces.save) + actions = {"play": self._navigation.handle_play_button, + "beginning": self._navigation.goto_first_frame, + "prev": self._navigation.decrement_frame, + "next": self._navigation.increment_frame, + "end": self._navigation.goto_last_frame, + "extract": self._det_faces.extract, + "save": self._det_faces.save} return actions @property @@ -149,7 +149,7 @@ def _add_nav(self): textvariable=self._globals.tk_transport_index, justify=tk.RIGHT) tbox.pack(padx=0, side=tk.LEFT) - lbl = ttk.Label(lbl_frame, text="/{}".format(max_frame)) + lbl = ttk.Label(lbl_frame, text=f"/{max_frame}") lbl.pack(side=tk.RIGHT) cmd = partial(set_slider_rounding, @@ -165,7 +165,7 @@ def _add_nav(self): command=cmd) nav.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) self._globals.tk_transport_index.trace("w", self._set_frame_index) - return dict(entry=tbox, scale=nav, label=lbl) + return {"entry": tbox, "scale": nav, "label": lbl} def _set_frame_index(self, *args): # pylint:disable=unused-argument """ Set the actual frame index based on current slider position and filter mode. """ @@ -187,7 +187,7 @@ def _add_transport(self): frame = ttk.Frame(self._transport_frame) frame.pack(side=tk.BOTTOM, fill=tk.X) icons = get_images().icons - buttons = dict() + buttons = {} for action in ("play", "beginning", "prev", "next", "end", "save", "extract", "mode"): padx = (0, 6) if action in ("play", "prev", "mode") else (0, 0) side = tk.RIGHT if action in ("extract", "save", "mode") else tk.LEFT @@ -366,7 +366,7 @@ def __init__(self, parent): self._buttons = self._add_buttons() self._static_buttons = self._add_static_buttons() self._selected_action = self._set_selected_action_tkvar() - self._optional_buttons = dict() # Has to be set from parent after canvas is initialized + self._optional_buttons = {} # Has to be set from parent after canvas is initialized @property def actions(self): @@ -382,19 +382,19 @@ def tk_selected_action(self): def key_bindings(self): """ dict: {`key`: `action`}. The mapping of key presses to actions. Keyboard shortcut is the first letter of each action. """ - return {"F{}".format(idx + 1): action for idx, action in enumerate(self._actions)} + return {f"F{idx + 1}": action for idx, action in enumerate(self._actions)} @property def _helptext(self): """ dict: `button key`: `button helptext`. The help text to display for each button. """ inverse_keybindings = {val: key for key, val in self.key_bindings.items()} - retval = dict(View=_("View alignments"), - BoundingBox=_("Bounding box editor"), - ExtractBox=_("Location editor"), - Mask=_("Mask editor"), - Landmarks=_("Landmark point editor")) + retval = {"View": _('View alignments'), + "BoundingBox": _('Bounding box editor'), + "ExtractBox": _("Location editor"), + "Mask": _("Mask editor"), + "Landmarks": _("Landmark point editor")} for item in retval: - retval[item] += " ({})".format(inverse_keybindings[item]) + retval[item] += f" ({inverse_keybindings[item]})" return retval def _configure_styles(self): @@ -415,7 +415,7 @@ def _add_buttons(self): """ frame = ttk.Frame(self) frame.pack(side=tk.TOP, fill=tk.Y) - buttons = dict() + buttons = {} for action in self.key_bindings.values(): if action == self._initial_action: btn_style = "actions_selected.TButton" @@ -467,22 +467,24 @@ def _set_selected_action_tkvar(self): def _add_static_buttons(self): """ Add the buttons to copy alignments from previous and next frames """ - lookup = dict(copy_prev=(_("Previous"), "C"), copy_next=(_("Next"), "V"), reload=("", "R")) + lookup = {"copy_prev": (_("Previous"), "C"), + "copy_next": (_("Next"), "V"), + "reload": ("", "R")} frame = ttk.Frame(self) frame.pack(side=tk.TOP, fill=tk.Y) sep = ttk.Frame(frame, height=2, relief=tk.RIDGE) sep.pack(fill=tk.X, pady=5, side=tk.TOP) - buttons = dict() + buttons = {} tk_frame_index = self._globals.tk_frame_index for action in ("copy_prev", "copy_next", "reload"): if action == "reload": icon = "reload3" - cmd = lambda f=tk_frame_index: self._det_faces.revert_to_saved(f.get()) # noqa + cmd = lambda f=tk_frame_index: self._det_faces.revert_to_saved(f.get()) # noqa=E731 # pylint:disable=line-too-long,unnecessary-lambda-assignment helptext = _("Revert to saved Alignments ({})").format(lookup[action][1]) else: icon = action direction = action.replace("copy_", "") - cmd = lambda f=tk_frame_index, d=direction: self._det_faces.update.copy( # noqa + cmd = lambda f=tk_frame_index, d=direction: self._det_faces.update.copy( # noqa=E731 # pylint:disable=line-too-long,unnecessary-lambda-assignment f.get(), d) helptext = _("Copy {} Alignments ({})").format(*lookup[action]) state = ["!disabled"] if action == "copy_next" else ["disabled"] @@ -506,10 +508,10 @@ def _disable_enable_copy_buttons(self, *args): # pylint:disable=unused-argument for count in face_count_per_index[:position]) next_exists = position != -1 and any(count != 0 for count in face_count_per_index[position + 1:]) - states = dict(prev=["!disabled"] if prev_exists else ["disabled"], - next=["!disabled"] if next_exists else ["disabled"]) + states = {"prev": ["!disabled"] if prev_exists else ["disabled"], + "next": ["!disabled"] if next_exists else ["disabled"]} for direction in ("prev", "next"): - self._static_buttons["copy_{}".format(direction)].state(states[direction]) + self._static_buttons[f"copy_{direction}"].state(states[direction]) def _disable_enable_reload_button(self, *args): # pylint:disable=unused-argument """ Disable or enable the static buttons """ @@ -549,12 +551,12 @@ def add_optional_buttons(self, editors): helptext = action["helptext"] hotkey = action["hotkey"] - helptext += "" if hotkey is None else " ({})".format(hotkey.upper()) + helptext += "" if hotkey is None else f" ({hotkey.upper()})" Tooltip(button, text=helptext) self._optional_buttons.setdefault( - name, dict())[button] = dict(hotkey=hotkey, - group=group, - tk_var=action["tk_var"]) + name, {})[button] = {"hotkey": hotkey, + "group": group, + "tk_var": action["tk_var"]} self._optional_buttons[name]["frame"] = frame self._display_optional_buttons() @@ -652,9 +654,9 @@ def __init__(self, parent, tk_globals, detected_faces, actions, tk_action_var): self._actions = actions self._tk_action_var = tk_action_var self._image = BackgroundImage(self) - self._editor_globals = dict(control_tk_vars=dict(), - annotation_formats=dict(), - key_bindings=dict()) + self._editor_globals = {"control_tk_vars": {}, + "annotation_formats": {}, + "key_bindings": {}} self._max_face_count = 0 self._editors = self._get_editors() self._add_callbacks() @@ -695,11 +697,11 @@ def editors(self): @property def editor_display(self): """ dict: List of editors and any additional annotations they should display. """ - return dict(View=["BoundingBox", "ExtractBox", "Landmarks", "Mesh"], - BoundingBox=["Mesh"], - ExtractBox=["Mesh"], - Landmarks=["ExtractBox", "Mesh"], - Mask=[]) + return {"View": ["BoundingBox", "ExtractBox", "Landmarks", "Mesh"], + "BoundingBox": ["Mesh"], + "ExtractBox": ["Mesh"], + "Landmarks": ["ExtractBox", "Mesh"], + "Mask": []} @property def offset(self): @@ -719,7 +721,7 @@ def _get_editors(self): dict The {`action`: :class:`Editor`} dictionary of editors for :attr:`_actions` name. """ - editors = dict() + editors = {} for editor_name in self._actions + ("Mesh", ): editor = eval(editor_name)(self, # pylint:disable=eval-used self._det_faces) @@ -797,7 +799,7 @@ def _hide_additional_faces(self): self._max_face_count = current_face_count return for idx in range(current_face_count, self._max_face_count): - tag = "face_{}".format(idx) + tag = f"face_{idx}" if any(self.itemcget(item_id, "state") != "hidden" for item_id in self.find_withtag(tag)): logger.debug("Hiding face tag '%s'", tag) diff --git a/tools/manual/manual.py b/tools/manual/manual.py index 05d32a2a42..c31683e3ee 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -19,7 +19,7 @@ from lib.image import SingleFrameLoader, read_image_meta from lib.multithreading import MultiThread from lib.utils import handle_deprecated_cliopts, VIDEO_EXTENSIONS -from plugins.extract.pipeline import Extractor, ExtractMedia +from plugins.extract import ExtractMedia, Extractor from .detected_faces import DetectedFaces from .faceviewer.frame import FacesFrame @@ -678,8 +678,8 @@ def _in_queue(self) -> EventQueue: @property def _feed_face(self) -> ExtractMedia: - """ :class:`plugins.extract.pipeline.ExtractMedia`: The current face for feeding into the - aligner, formatted for the pipeline """ + """ :class:`~plugins.extract.extract_media.ExtractMedia`: The current face for feeding into + the aligner, formatted for the pipeline """ assert self._frame_index is not None assert self._face_index is not None assert self._detected_faces is not None diff --git a/tools/mask/loader.py b/tools/mask/loader.py index 020272511a..8f50d81c48 100644 --- a/tools/mask/loader.py +++ b/tools/mask/loader.py @@ -13,7 +13,7 @@ from lib.align import DetectedFace, update_legacy_png_header from lib.align.alignments import AlignmentFileDict from lib.image import FacesLoader, ImagesLoader -from plugins.extract.pipeline import ExtractMedia +from plugins.extract import ExtractMedia if T.TYPE_CHECKING: from lib.align import Alignments diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 9249a9d82b..c3619e0193 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -11,7 +11,7 @@ from lib.align import Alignments from lib.utils import handle_deprecated_cliopts, VIDEO_EXTENSIONS -from plugins.extract.pipeline import ExtractMedia +from plugins.extract import ExtractMedia from .loader import Loader from .mask_import import Import @@ -239,7 +239,7 @@ def _save_output(self, media: ExtractMedia) -> None: Parameters ---------- - media: :class:`~plugins.extract.pipeline.ExtractMedia` + media: :class:`~plugins.extract.extract_media.ExtractMedia` The extract media holding the faces to output """ filename = os.path.basename(media.frame_metadata["source_filename"] diff --git a/tools/mask/mask_generate.py b/tools/mask/mask_generate.py index a1a7f628ef..eb3cd6af44 100644 --- a/tools/mask/mask_generate.py +++ b/tools/mask/mask_generate.py @@ -8,13 +8,13 @@ from lib.image import encode_image, ImagesSaver from lib.multithreading import MultiThread -from plugins.extract.pipeline import Extractor +from plugins.extract import Extractor if T.TYPE_CHECKING: from lib.align import Alignments, DetectedFace from lib.align.alignments import PNGHeaderDict from lib.queue_manager import EventQueue - from plugins.extract.pipeline import ExtractMedia + from plugins.extract import ExtractMedia from .loader import Loader diff --git a/tools/mask/mask_import.py b/tools/mask/mask_import.py index e9a0f4beac..4192ce03ab 100644 --- a/tools/mask/mask_import.py +++ b/tools/mask/mask_import.py @@ -18,7 +18,7 @@ if T.TYPE_CHECKING: import numpy as np from .loader import Loader - from plugins.extract.pipeline import ExtractMedia + from plugins.extract import ExtractMedia from lib.align import Alignments, DetectedFace from lib.align.alignments import PNGHeaderDict from lib.align.aligned_face import CenteringType @@ -306,7 +306,7 @@ def _store_mask_face(self, media: ExtractMedia, mask: np.ndarray) -> None: Parameters ---------- - media: :class:`~plugins.extract.pipeline.ExtractMedia` + media: :class:`~plugins.extract.extract_media.ExtractMedia` The extract media object containing the face(s) to import the mask for mask: :class:`numpy.ndarray` @@ -361,7 +361,7 @@ def _store_mask_frame(self, media: ExtractMedia, mask: np.ndarray) -> None: Parameters ---------- - media: :class:`~plugins.extract.pipeline.ExtractMedia` + media: :class:`~plugins.extract.extract_media.ExtractMedia` The extract media object containing the face(s) to import the mask for mask: :class:`numpy.ndarray` @@ -384,7 +384,7 @@ def import_mask(self, media: ExtractMedia) -> None: Parameters ---------- - media: :class:`~plugins.extract.pipeline.ExtractMedia` + media: :class:`~plugins.extract.extract_media.ExtractMedia` The extract media object containing the face(s) to import the mask for """ mask_file = self._mapping.get(os.path.basename(media.filename)) diff --git a/tools/preview/preview.py b/tools/preview/preview.py index e023073ab8..4b60ba9fcc 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -24,7 +24,7 @@ from scripts.fsmedia import Alignments, Images from scripts.convert import Predict, ConvertItem -from plugins.extract.pipeline import ExtractMedia +from plugins.extract import ExtractMedia from .control_panels import ActionFrame, ConfigTools, OptionsBook from .viewer import FacesDisplay, ImagesCanvas diff --git a/tools/sort/sort_methods.py b/tools/sort/sort_methods.py index 4a1501022d..f2a4b29526 100644 --- a/tools/sort/sort_methods.py +++ b/tools/sort/sort_methods.py @@ -16,7 +16,7 @@ import numpy as np from tqdm import tqdm -from lib.align import AlignedFace, DetectedFace +from lib.align import AlignedFace, DetectedFace, LandmarkType from lib.image import FacesLoader, ImagesLoader, read_image_meta_batch, update_existing_metadata from lib.utils import FaceswapError from plugins.extract.recognition.vgg_face2 import Cluster, Recognition as VGGFace @@ -217,6 +217,8 @@ class SortMethod(): Set to ``True`` if this class is going to be called exclusively for binning. Default: ``False`` """ + _log_mask_once = False + def __init__(self, arguments: Namespace, loader_type: T.Literal["face", "meta", "all"] = "meta", @@ -454,12 +456,22 @@ def _mask_face(cls, image: np.ndarray, alignments: PNGHeaderAlignmentsDict) -> n centering="legacy", size=256, is_aligned=True) - mask = det_face.mask["components"] + assert aln_face.face is not None + + mask = det_face.mask.get("components", det_face.mask.get("extended", None)) + + if mask is None and not cls._log_mask_once: + logger.warning("No masks are available for masking the data. Results are likely to be " + "sub-standard") + cls._log_mask_once = True + + if mask is None: + return aln_face.face + mask.set_sub_crop(aln_face.pose.offset[mask.stored_centering], aln_face.pose.offset["legacy"], centering="legacy") nmask = cv2.resize(mask.mask, (256, 256), interpolation=cv2.INTER_CUBIC)[..., None] - assert aln_face.face is not None return np.minimum(aln_face.face, nmask) @@ -832,6 +844,11 @@ class SortFace(SortMethod): Set to ``True`` if this class is going to be called exclusively for binning. Default: ``False`` """ + + _logged_lm_count_once = False + _warning = ("Extracted faces do not contain facial landmark data. Results sorted by this " + "method are likely to be sub-standard.") + def __init__(self, arguments: Namespace, is_group: bool = False) -> None: super().__init__(arguments, loader_type="all", is_group=is_group) self._vgg_face = VGGFace(exclude_gpus=arguments.exclude_gpus) @@ -872,6 +889,11 @@ def score_image(self, if alignments.get("identity", {}).get("vggface2"): embedding = np.array(alignments["identity"]["vggface2"], dtype="float32") + + if not self._logged_lm_count_once and len(alignments["landmarks_xy"]) == 4: + logger.warning(self._warning) + self._logged_lm_count_once = True + self._result.append((filename, embedding)) return @@ -880,11 +902,17 @@ def score_image(self, "Sorting by this method will be quicker next time") self._output_update_info = False - face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), - image=image, - centering="legacy", - size=self._vgg_face.input_size, - is_aligned=True).face + a_face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), + image=image, + centering="legacy", + size=self._vgg_face.input_size, + is_aligned=True) + + if a_face.landmark_type == LandmarkType.LM_2D_4 and not self._logged_lm_count_once: + logger.warning(self._warning) + self._logged_lm_count_once = True + + face = a_face.face assert face is not None embedding = self._vgg_face.predict(face[None, ...])[0] alignments.setdefault("identity", {})["vggface2"] = embedding.tolist() diff --git a/tools/sort/sort_methods_aligned.py b/tools/sort/sort_methods_aligned.py index 34275efbdd..8f0ff0b8ea 100644 --- a/tools/sort/sort_methods_aligned.py +++ b/tools/sort/sort_methods_aligned.py @@ -11,7 +11,7 @@ import numpy as np from tqdm import tqdm -from lib.align import AlignedFace +from lib.align import AlignedFace, LandmarkType from lib.utils import FaceswapError from .sort_methods import SortMethod @@ -36,6 +36,9 @@ class SortAlignedMetric(SortMethod): Set to ``True`` if this class is going to be called exclusively for binning. Default: ``False`` """ + + _logged_lm_count_once: bool = False + def _get_metric(self, aligned_face: AlignedFace) -> np.ndarray | float: """ Obtain the correct metric for the given sort method" @@ -85,6 +88,12 @@ def score_image(self, raise FaceswapError(msg) face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32")) + if (not self._logged_lm_count_once + and face.landmark_type == LandmarkType.LM_2D_4 + and self.__class__.__name__ != "SortSize"): + logger.warning("You have selected to sort by an aligned metric, but at least one face " + "does not contain facial landmark data. This probably won't work") + self._logged_lm_count_once = True self._result.append((filename, self._get_metric(face))) From 3d5b962d29f2fd66b9bdae17ca06cc4a0d22653b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 16 Apr 2024 11:15:54 +0100 Subject: [PATCH 896/981] linting: typo-fix --- tools/manual/frameviewer/frame.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/manual/frameviewer/frame.py b/tools/manual/frameviewer/frame.py index 23c953a45b..0b761b543b 100644 --- a/tools/manual/frameviewer/frame.py +++ b/tools/manual/frameviewer/frame.py @@ -479,12 +479,12 @@ def _add_static_buttons(self): for action in ("copy_prev", "copy_next", "reload"): if action == "reload": icon = "reload3" - cmd = lambda f=tk_frame_index: self._det_faces.revert_to_saved(f.get()) # noqa=E731 # pylint:disable=line-too-long,unnecessary-lambda-assignment + cmd = lambda f=tk_frame_index: self._det_faces.revert_to_saved(f.get()) # noqa:E731 # pylint:disable=line-too-long,unnecessary-lambda-assignment helptext = _("Revert to saved Alignments ({})").format(lookup[action][1]) else: icon = action direction = action.replace("copy_", "") - cmd = lambda f=tk_frame_index, d=direction: self._det_faces.update.copy( # noqa=E731 # pylint:disable=line-too-long,unnecessary-lambda-assignment + cmd = lambda f=tk_frame_index, d=direction: self._det_faces.update.copy( # noqa:E731 # pylint:disable=line-too-long,unnecessary-lambda-assignment f.get(), d) helptext = _("Copy {} Alignments ({})").format(*lookup[action]) state = ["!disabled"] if action == "copy_next" else ["disabled"] From 957734dfc0a0b81b8fc4a13935078bfbf1292560 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 17 Apr 2024 12:37:47 +0100 Subject: [PATCH 897/981] convert: Bugfix: Update legacy .png video alignments to include video file extension --- lib/align/alignments.py | 82 ++++++++++++++++++++++++++++++++-- scripts/convert.py | 26 ++++++++--- tools/alignments/alignments.py | 5 +++ tools/alignments/media.py | 6 +-- tools/manual/detected_faces.py | 2 + 5 files changed, 107 insertions(+), 14 deletions(-) diff --git a/lib/align/alignments.py b/lib/align/alignments.py index c5d7452caa..e7e3519512 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -10,14 +10,14 @@ import numpy as np from lib.serializer import get_serializer, get_serializer_from_filename -from lib.utils import FaceswapError +from lib.utils import FaceswapError, VIDEO_EXTENSIONS if T.TYPE_CHECKING: from collections.abc import Generator from .aligned_face import CenteringType logger = logging.getLogger(__name__) -_VERSION = 2.3 +_VERSION = 2.4 # VERSION TRACKING # 1.0 - Never really existed. Basically any alignments file prior to version 2.0 # 2.0 - Implementation of full head extract. Any alignments version below this will have used @@ -27,6 +27,7 @@ # 2.2 - Add support for differently centered masks (i.e. not all masks stored as face centering) # 2.3 - Add 'identity' key to alignments file. May or may not be populated, to contain vggface2 # embeddings. Make 'video_meta' key a standard key. Can be unpopulated +# 2.4 - Update video file alignment keys to end in the video extension rather than '.png' # TODO Convert these to Dataclasses @@ -584,6 +585,21 @@ def yield_faces(self) -> Generator[tuple[str, list[AlignmentFileDict], int, str] frame_name, face_count, frame_fullname) yield frame_name, val["faces"], face_count, frame_fullname + def update_legacy_has_source(self, filename: str) -> None: + """ Update legacy alignments files when we have the source filename available. + + Updates here can only be performed when we have the source filename + + Parameters + ---------- + filename: str: + The filename/folder of the original source images/video for the current alignments + """ + updates = [updater.is_updated for updater in (_VideoExtension(self, filename), )] + if any(updates): + self._io.update_version() + self.save() + class _IO(): """ Class to handle the saving/loading of an alignments file. @@ -719,10 +735,14 @@ def update_legacy(self) -> None: _MaskCentering(self._alignments), _IdentityAndVideoMeta(self._alignments))] if any(updates): - self._version = _VERSION - logger.info("Updating alignments file to version %s", self._version) + self.update_version() self.save() + def update_version(self) -> None: + """ Update the version of the alignments file to the latest version """ + self._version = _VERSION + logger.info("Updating alignments file to version %s", self._version) + def load(self) -> dict[str, AlignmentDict]: """ Load the alignments data from the serialized alignments :attr:`file`. @@ -908,6 +928,60 @@ def update(self) -> int: raise NotImplementedError() +class _VideoExtension(_Updater): + """ Alignments files from video files used to have a dummy '.png' extension for each of the + keys. This has been changed to be file extension of the original input video (for better) + identification of alignments files generated from video files + + Parameters + ---------- + alignments: :class:`~Alignments` + The alignments object that is being tested and updated + video_filename: str + The video filename that holds these alignments + """ + def __init__(self, alignments: Alignments, video_filename: str) -> None: + self._video_name, self._extension = os.path.splitext(video_filename) + super().__init__(alignments) + + def test(self) -> bool: + """ Requires update if alignments version is < 2.4 + + Returns + ------- + bool + ``True`` if the key extensions need updating otherwise ``False`` + """ + retval = self._alignments.version < 2.4 and self._extension in VIDEO_EXTENSIONS + logger.debug("Needs update for video extension: %s (version: %s, extension: %s)", + retval, self._alignments.version, self._extension) + return retval + + def update(self) -> int: + """ Update alignments files that have been extracted from videos to have the key end in the + video file extension rather than ',png' (the old way) + + Parameters + ---------- + video_filename: str + The filename of the video file that created these alignments + """ + updated = 0 + for key in list(self._alignments.data): + val = self._alignments.data[key] + fname = os.path.splitext(key)[0] + if fname.rsplit("_")[0] != self._video_name: + continue # Key is from a different source + + new_key = f"{fname}{self._extension}" + del self._alignments.data[key] + self._alignments.data[new_key] = val + updated += 1 + + logger.debug("Updated alignemnt keys for video extension: %s", updated) + return updated + + class _FileStructure(_Updater): """ Alignments were structured: {frame_name: }. We need to be able to store information at the frame level, so new structure is: {frame_name: {faces: }} diff --git a/scripts/convert.py b/scripts/convert.py index 20cf4c61a8..7ae3f0eca6 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -85,13 +85,7 @@ def __init__(self, arguments: Namespace) -> None: self._args = handle_deprecated_cliopts(arguments) self._images = ImagesLoader(self._args.input_dir, fast_count=True) - self._alignments = Alignments(self._args, False, self._images.is_video) - if self._alignments.version == 1.0: - logger.error("The alignments file format has been updated since the given alignments " - "file was generated. You need to update the file to proceed.") - logger.error("To do this run the 'Alignments Tool' > 'Extract' Job.") - sys.exit(1) - + self._alignments = self._get_alignments() self._opts = OptionalActions(self._args, self._images.file_list, self._alignments) self._add_queues() @@ -133,6 +127,24 @@ def _pool_processes(self) -> int: logger.debug(retval) return retval + def _get_alignments(self) -> Alignments: + """ Perform validation checks and legacy updates and return alignemnts object + + Returns + ------- + :class:`~lib.align.alignments.Alignments` + The alignments file for the extract job + """ + retval = Alignments(self._args, False, self._images.is_video) + if retval.version == 1.0: + logger.error("The alignments file format has been updated since the given alignments " + "file was generated. You need to update the file to proceed.") + logger.error("To do this run the 'Alignments Tool' > 'Extract' Job.") + sys.exit(1) + + retval.update_legacy_has_source(os.path.basename(self._args.input_dir)) + return retval + def _validate(self) -> None: """ Validate the Command Line Options. diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index 531c6958c6..4c6251f1fe 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -260,6 +260,11 @@ def __init__(self, arguments: Namespace) -> None: else: self.alignments = AlignmentData(self._find_alignments()) + if (self.alignments is not None and + arguments.frames_dir and + os.path.isfile(arguments.frames_dir)): + self.alignments.update_legacy_has_source(os.path.basename(arguments.frames_dir)) + logger.debug("Initialized %s", self.__class__.__name__) def _find_alignments(self) -> str: diff --git a/tools/alignments/media.py b/tools/alignments/media.py index eee258c8f1..a0d6a94365 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -473,14 +473,14 @@ def process_video(self) -> Generator[dict[str, str], None, None]: The full framename, the filename and the file extension of the frame """ logger.info("Loading video frames from %s", self.folder) - vidname = os.path.splitext(os.path.basename(self.folder))[0] + vidname, ext = os.path.splitext(os.path.basename(self.folder)) for i in range(self.count): idx = i + 1 # Keep filename format for outputted face filename = f"{vidname}_{idx:06d}" - retval = {"frame_fullname": f"{filename}.png", + retval = {"frame_fullname": f"{filename}{ext}", "frame_name": filename, - "frame_extension": ".png"} + "frame_extension": ext} logger.trace(retval) # type: ignore yield retval diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index b0285a8194..ebd7218f81 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -58,6 +58,8 @@ def __init__(self, self._updated_frame_indices: set[int] = set() self._alignments: Alignments = self._get_alignments(alignments_path, input_location) + self._alignments.update_legacy_has_source(os.path.basename(input_location)) + self._extractor = extractor self._tk_vars = self._set_tk_vars() From ec2a95adf682ddfbc7cd374be4150ced32628b83 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 17 Apr 2024 14:02:43 +0100 Subject: [PATCH 898/981] tests: Fix alignment tool process_video test --- tests/tools/alignments/media_test.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/tools/alignments/media_test.py b/tests/tools/alignments/media_test.py index 5f603ee3a3..17e45a517e 100644 --- a/tests/tools/alignments/media_test.py +++ b/tests/tools/alignments/media_test.py @@ -625,12 +625,13 @@ def test_process_video(self, folder: str) -> None: folder : str Dummy media folder """ - expected = [{"frame_fullname": "images_000001.png", + ext = os.path.splitext(folder)[-1] + expected = [{"frame_fullname": f"images_000001{ext}", "frame_name": "images_000001", - "frame_extension": ".png"}, - {"frame_fullname": "images_000002.png", + "frame_extension": ext}, + {"frame_fullname": f"images_000002{ext}", "frame_name": "images_000002", - "frame_extension": ".png"}] + "frame_extension": ext}] frames = Frames(folder, None) returned = list(frames.process_video()) From 3f69d9feab47c23e93c222ff2a47697875a169f0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 17 Apr 2024 14:16:33 +0100 Subject: [PATCH 899/981] manual tool: bugfx: don't error when getting mesh for non-existant face --- tools/manual/faceviewer/viewport.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index 3b0670626b..ace7305aa9 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -481,7 +481,7 @@ def get_mesh(self, face: DetectedFace) -> dict[T.Literal["polygon", "line"], lis asset_type, asset_id) retval.setdefault(asset_type, []).append(asset_id) - logger.trace("Got mesh: %s", retval) # type:ignore[attr-defined] + logger.info("Got mesh: %s", retval) # type:ignore[attr-defined] return retval @@ -647,7 +647,8 @@ def _add_rows(self, existing_rows: int, required_rows: int) -> None: y_coord = base_coords[0][1] + (row * self._size) images.append([self._recycler.get_image((coords[0], y_coord)) for coords in base_coords]) - meshes.append([self._recycler.get_mesh(face) for face in self._visible_faces[row]]) + meshes.append([{} if face is None else self._recycler.get_mesh(face) + for face in self._visible_faces[row]]) a_images = np.array(images) a_meshes = np.array(meshes) From d75898f718cc5719630cddbcde174ab11c01338b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 18 Apr 2024 12:45:33 +0100 Subject: [PATCH 900/981] Bugfixes for video file alignments storage: - extract/convert: Load images with correct video extension - Manual tool: Cache thumbnails with correct extension - Mask tool + Preview tool:: Update legacy alignment keys for pre-video extension storage --- scripts/fsmedia.py | 4 ++-- tools/manual/faceviewer/viewport.py | 2 +- tools/manual/thumbnails.py | 4 ++-- tools/mask/mask.py | 8 ++++++-- tools/preview/preview.py | 9 ++++++++- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 68f503a788..36932a4950 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -287,12 +287,12 @@ def _load_video_frames(self) -> Generator[tuple[str, np.ndarray], None, None]: A single frame """ logger.debug("Input is video. Capturing frames") - vidname = os.path.splitext(os.path.basename(self._args.input_dir))[0] + vidname, ext = os.path.splitext(os.path.basename(self._args.input_dir)) reader = imageio.get_reader(self._args.input_dir, "ffmpeg") # type:ignore[arg-type] for i, frame in enumerate(T.cast(Iterator[np.ndarray], reader)): # Convert to BGR for cv2 compatibility frame = frame[:, :, ::-1] - filename = f"{vidname}_{i + 1:06d}.png" + filename = f"{vidname}_{i + 1:06d}{ext}" logger.trace("Loading video frame: '%s'", filename) # type:ignore[attr-defined] yield filename, frame reader.close() diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index ace7305aa9..94486a82f8 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -481,7 +481,7 @@ def get_mesh(self, face: DetectedFace) -> dict[T.Literal["polygon", "line"], lis asset_type, asset_id) retval.setdefault(asset_type, []).append(asset_id) - logger.info("Got mesh: %s", retval) # type:ignore[attr-defined] + logger.trace("Got mesh: %s", retval) # type:ignore[attr-defined] return retval diff --git a/tools/manual/thumbnails.py b/tools/manual/thumbnails.py index b877346c27..617d37b4c3 100644 --- a/tools/manual/thumbnails.py +++ b/tools/manual/thumbnails.py @@ -209,11 +209,11 @@ def _load_from_video(self, pts_start, pts_end, start_index, segment_count) reader = self._get_reader(pts_start, pts_end) idx = 0 - sample_filename = next(fname for fname in self._alignments.data) + sample_filename, ext = os.path.splitext(next(fname for fname in self._alignments.data)) vidname = sample_filename[:sample_filename.rfind("_")] for idx, frame in enumerate(reader): frame_idx = idx + start_index - filename = f"{vidname}_{frame_idx + 1:06d}.png" + filename = f"{vidname}_{frame_idx + 1:06d}{ext}" self._set_thumbail(filename, frame[..., ::-1], frame_idx) if idx == segment_count - 1: # Sometimes extra frames are picked up at the end of a segment, so stop diff --git a/tools/mask/mask.py b/tools/mask/mask.py index c3619e0193..25e129bda2 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -149,6 +149,10 @@ def __init__(self, arguments: Namespace) -> None: self._loader = Loader(arguments.input, self._input_is_faces) self._alignments = self._get_alignments(arguments.alignments, arguments.input) + + if self._loader.is_video and self._alignments is not None: + self._alignments.update_legacy_has_source(os.path.basename(self._loader.location)) + self._loader.add_alignments(self._alignments) self._output = Output(arguments, self._alignments, self._loader.file_list) @@ -206,8 +210,8 @@ def _get_alignments(self, alignments: str | None, input_location: str) -> Alignm Returns ------- - ``None`` or :class:`lib.align.alignments.Alignments`: - If output is requested, returns a :class:`lib.image.ImagesSaver` otherwise + ``None`` or :class:`~lib.align.alignments.Alignments`: + If output is requested, returns a :class:`~lib.align.alignments.Alignments` otherwise returns ``None`` """ if alignments: diff --git a/tools/preview/preview.py b/tools/preview/preview.py index 4b60ba9fcc..4ac81b3627 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -290,9 +290,15 @@ def __init__(self, app: Preview, arguments: Namespace, sample_size: int) -> None "file was generated. You need to update the file to proceed.") logger.error("To do this run the 'Alignments Tool' > 'Extract' Job.") sys.exit(1) + if not self._alignments.have_alignments_file: logger.error("Alignments file not found at: '%s'", self._alignments.file) sys.exit(1) + + if self._images.is_video: + assert isinstance(self._images.input_images, str) + self._alignments.update_legacy_has_source(os.path.basename(self._images.input_images)) + self._filelist = self._get_filelist() self._indices = self._get_indices() @@ -349,7 +355,8 @@ def _get_filelist(self) -> list[str]: """ logger.debug("Filtering file list to frames with faces") if isinstance(self._images.input_images, str): - filelist = [f"{os.path.splitext(self._images.input_images)[0]}_{frame_no:06d}.png" + vid_name, ext = os.path.splitext(self._images.input_images) + filelist = [f"{vid_name}_{frame_no:06d}{ext}" for frame_no in range(1, self._images.images_found + 1)] else: filelist = self._images.input_images From 2bad105dc8500ad667d7ff311dcf6f9d396ed1c8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 19 Apr 2024 11:33:52 +0100 Subject: [PATCH 901/981] bugfix: Alignment tool, auto-detect alignments - Random linting and typing --- lib/image.py | 8 +- lib/training/augmentation.py | 313 ++++++++++-------- lib/training/generator.py | 51 +-- .../es/LC_MESSAGES/tools.alignments.cli.mo | Bin 12801 -> 12789 bytes .../es/LC_MESSAGES/tools.alignments.cli.po | 24 +- .../kr/LC_MESSAGES/tools.alignments.cli.mo | Bin 12494 -> 12482 bytes .../kr/LC_MESSAGES/tools.alignments.cli.po | 24 +- .../ru/LC_MESSAGES/tools.alignments.cli.mo | Bin 16449 -> 16437 bytes .../ru/LC_MESSAGES/tools.alignments.cli.po | 24 +- locales/tools.alignments.cli.pot | 14 +- tools/alignments/cli.py | 10 +- 11 files changed, 258 insertions(+), 210 deletions(-) diff --git a/lib/image.py b/lib/image.py index 7d41a5a612..4a2b524a15 100644 --- a/lib/image.py +++ b/lib/image.py @@ -579,7 +579,7 @@ def encode_image(image: np.ndarray, Returns ------- encoded_image: bytes - The image encoded into the correct file format + The image encoded into the correct file format as bytes Example ------- @@ -591,10 +591,10 @@ def encode_image(image: np.ndarray, raise ValueError("Metadata is only supported for .png and .tif images") args = tuple() if encoding_args is None else encoding_args - retval = cv2.imencode(extension, image, args)[1] + retval = cv2.imencode(extension, image, args)[1].tobytes() if metadata: func = {".png": png_write_meta, ".tif": tiff_write_meta}[extension] - retval = func(retval.tobytes(), metadata) # type:ignore[arg-type] + retval = func(retval, metadata) return retval @@ -624,7 +624,7 @@ def png_write_meta(image: bytes, data: PNGHeaderDict | dict[str, T.Any] | bytes) return retval -def tiff_write_meta(image: bytes, data: dict[str, T.Any] | bytes) -> bytes: +def tiff_write_meta(image: bytes, data: PNGHeaderDict | dict[str, T.Any] | bytes) -> bytes: """ Write Faceswap information to a tiff's image_description field. Parameters diff --git a/lib/training/augmentation.py b/lib/training/augmentation.py index f9edf2a558..184b1264ee 100644 --- a/lib/training/augmentation.py +++ b/lib/training/augmentation.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 """ Processes the augmentation of images for feeding into a Faceswap model. """ from __future__ import annotations -from dataclasses import dataclass import logging import typing as T @@ -11,6 +10,7 @@ from scipy.interpolate import griddata from lib.image import batch_convert_color +from lib.logger import parse_class_init if T.TYPE_CHECKING: from lib.config import ConfigValueType @@ -18,49 +18,152 @@ logger = logging.getLogger(__name__) -@dataclass -class AugConstants: +class AugConstants: # pylint:disable=too-many-instance-attributes,too-few-public-methods """ Dataclass for holding constants for Image Augmentation. Parameters ---------- - clahe_base_contrast: int - The base number for Contrast Limited Adaptive Histogram Equalization - clahe_chance: float - Probability to perform Contrast Limited Adaptive Histogram Equilization - clahe_max_size: int - Maximum clahe window size - lab_adjust: np.ndarray - Adjustment amounts for L*A*B augmentation - transform_rotation: int - Rotation range for transformations - transform_zoom: float - Zoom range for transformations - transform_shift: float - Shift range for transformations - warp_maps: :class:`numpy.ndarray` - The stacked (x, y) mappings for image warping - warp_pads: tuple - The padding to apply for image warping - warp_slices: slice - The slices for extracting a warped image - warp_lm_edge_anchors: :class:`numpy.ndarray` - The edge anchors for landmark based warping - warp_lm_grids: :class:`numpy.ndarray` - The grids for landmark based warping + config: dict[str, ConfigValueType] + The user training configuration options + pricessing_size: int: + The size of image to augment the data for + batch_size: int + The batch size that augmented data is being prepared for """ - clahe_base_contrast: int - clahe_chance: float - clahe_max_size: int - lab_adjust: np.ndarray - transform_rotation: int - transform_zoom: float - transform_shift: float - warp_maps: np.ndarray - warp_pad: tuple[int, int] - warp_slices: slice - warp_lm_edge_anchors: np.ndarray - warp_lm_grids: np.ndarray + def __init__(self, + config: dict[str, ConfigValueType], + processing_size: int, + batch_size: int) -> None: + logger.debug(parse_class_init(locals())) + self.clahe_base_contrast: int = 0 + """int: The base number for Contrast Limited Adaptive Histogram Equalization""" + self.clahe_chance: float = 0.0 + """float: Probability to perform Contrast Limited Adaptive Histogram Equilization""" + self.clahe_max_size: int = 0 + """int: Maximum clahe window size""" + + self.lab_adjust: np.ndarray + """:class:`numpy.ndarray`: Adjustment amounts for L*A*B augmentation""" + self.transform_rotation: int = 0 + """int: Rotation range for transformations""" + self.transform_zoom: float = 0.0 + """float: Zoom range for transformations""" + self.transform_shift: float = 0.0 + """float: Shift range for transformations""" + self.warp_maps: np.ndarray + """:class:`numpy.ndarray`The stacked (x, y) mappings for image warping""" + self.warp_pad: tuple[int, int] = (0, 0) + """:tuple[int, int]: The padding to apply for image warping""" + self.warp_slices: slice + """:slice: The slices for extracting a warped image""" + self.warp_lm_edge_anchors: np.ndarray + """::class:`numpy.ndarray`: The edge anchors for landmark based warping""" + self.warp_lm_grids: np.ndarray + """::class:`numpy.ndarray`: The grids for landmark based warping""" + + self._config = config + self._size = processing_size + self._load_config(batch_size) + logger.debug("Initialized: %s", self.__class__.__name__) + + def _load_clahe(self) -> None: + """ Load the CLAHE constants from user config """ + color_clahe_chance = self._config.get("color_clahe_chance", 50) + color_clahe_max_size = self._config.get("color_clahe_max_size", 4) + assert isinstance(color_clahe_chance, int) + assert isinstance(color_clahe_max_size, int) + + self.clahe_base_contrast = max(2, self._size // 128) + self.clahe_chance = color_clahe_chance / 100 + self.clahe_max_size = color_clahe_max_size + logger.debug("clahe_base_contrast: %s, clahe_chance: %s, clahe_max_size: %s", + self.clahe_base_contrast, self.clahe_chance, self.clahe_max_size) + + def _load_lab(self) -> None: + """ Load the random L*A*B augmentation constants """ + color_lightness = self._config.get("color_lightness", 30) + color_ab = self._config.get("color_ab", 8) + assert isinstance(color_lightness, int) + assert isinstance(color_ab, int) + + amount_l = int(color_lightness) / 100 + amount_ab = int(color_ab) / 100 + + self.lab_adjust = np.array([amount_l, amount_ab, amount_ab], dtype="float32") + logger.debug("lab_adjust: %s", self.lab_adjust) + + def _load_transform(self) -> None: + """ Load the random transform constants """ + shift_range = self._config.get("shift_range", 5) + rotation_range = self._config.get("rotation_range", 10) + zoom_amount = self._config.get("zoom_amount", 5) + assert isinstance(shift_range, int) + assert isinstance(rotation_range, int) + assert isinstance(zoom_amount, int) + + self.transform_shift = (shift_range / 100) * self._size + self.transform_rotation = rotation_range + self.transform_zoom = zoom_amount / 100 + logger.debug("transform_shift: %s, transform_rotation: %s, transform_zoom: %s", + self.transform_shift, self.transform_rotation, self.transform_zoom) + + def _load_warp(self, batch_size: int) -> None: + """ Load the warp augmentation constants + + Parameters + ---------- + batch_size: int + The batch size that augmented data is being prepared for + """ + warp_range = np.linspace(0, self._size, 5, dtype='float32') + warp_mapx = np.broadcast_to(warp_range, (batch_size, 5, 5)).astype("float32") + warp_mapy = np.broadcast_to(warp_mapx[0].T, (batch_size, 5, 5)).astype("float32") + warp_pad = int(1.25 * self._size) + + self.warp_maps = np.stack((warp_mapx, warp_mapy), axis=1) + self.warp_pad = (warp_pad, warp_pad) + self.warp_slices = slice(warp_pad // 10, -warp_pad // 10) + logger.debug("warp_maps: (%s, %s), warp_pad: %s, warp_slices: %s", + self.warp_maps.shape, self.warp_maps.dtype, + self.warp_pad, self.warp_slices) + + def _load_warp_to_landmarks(self, batch_size: int) -> None: + """ Load the warp-to-landmarks augmentation constants + + Parameters + ---------- + batch_size: int + The batch size that augmented data is being prepared for + """ + p_mx = self._size - 1 + p_hf = (self._size // 2) - 1 + edge_anchors = np.array([(0, 0), (0, p_mx), (p_mx, p_mx), (p_mx, 0), + (p_hf, 0), (p_hf, p_mx), (p_mx, p_hf), (0, p_hf)]).astype("int32") + edge_anchors = np.broadcast_to(edge_anchors, (batch_size, 8, 2)) + grids = np.mgrid[0: p_mx: complex(self._size), # type:ignore[misc] + 0: p_mx: complex(self._size)] # type:ignore[misc] + + self.warp_lm_edge_anchors = edge_anchors + self.warp_lm_grids = grids + logger.debug("warp_lm_edge_anchors: (%s, %s), warp_lm_grids: (%s, %s)", + self.warp_lm_edge_anchors.shape, self.warp_lm_edge_anchors.dtype, + self.warp_lm_grids.shape, self.warp_lm_grids.dtype) + + def _load_config(self, batch_size: int) -> None: + """ Load the constants into the class from user config + + Parameters + ---------- + batch_size: int + The batch size that augmented data is being prepared for + """ + logger.debug("Loading augmentation constants") + self._load_clahe() + self._load_lab() + self._load_transform() + self._load_warp(batch_size) + self._load_warp_to_landmarks(batch_size) + logger.debug("Loaded augmentation constants") class ImageAugmentation(): @@ -68,7 +171,7 @@ class ImageAugmentation(): Parameters ---------- - batchsize: int + batch_size: int The number of images that will be fed through the augmentation functions at once. processing_size: int The largest input or output size of the model. This is the size that images are processed @@ -78,88 +181,25 @@ class ImageAugmentation(): plugin configuration options. """ def __init__(self, - batchsize: int, + batch_size: int, processing_size: int, config: dict[str, ConfigValueType]) -> None: - logger.debug("Initializing %s: (batchsize: %s, processing_size: %s, " - "config: %s)", - self.__class__.__name__, batchsize, processing_size, config) - + logger.debug(parse_class_init(locals())) self._processing_size = processing_size - self._batchsize = batchsize - self._config = config + self._batch_size = batch_size + + # flip_args + flip_chance = config.get("random_flip", 50) + assert isinstance(flip_chance, int) + self._flip_chance = flip_chance # Warp args self._warp_scale = 5 / 256 * self._processing_size # Normal random variable scale self._warp_lm_scale = 2 / 256 * self._processing_size # Normal random variable scale - self._constants = self._get_constants() + self._constants = AugConstants(config, processing_size, batch_size) logger.debug("Initialized %s", self.__class__.__name__) - def _get_constants(self) -> AugConstants: - """ Initializes the caching of constants for use in various image augmentations. - - Returns - ------- - dict - Cached constants that are used for various augmentations - """ - logger.debug("Initializing constants.") - - # Config variables typing check - shift_range = self._config.get("shift_range", 5) - color_lightness = self._config.get("color_lightness", 30) - color_ab = self._config.get("color_ab", 8) - color_clahe_chance = self._config.get("color_clahe_chance", 50) - color_clahe_max_size = self._config.get("color_clahe_max_size", 4) - rotation_range = self._config.get("rotation_range", 10) - zoom_amount = self._config.get("zoom_amount", 5) - - assert isinstance(shift_range, int) - assert isinstance(color_lightness, int) - assert isinstance(color_ab, int) - assert isinstance(color_clahe_chance, int) - assert isinstance(color_clahe_max_size, int) - assert isinstance(rotation_range, int) - assert isinstance(zoom_amount, int) - - # Transform - tform_shift = (shift_range / 100) * self._processing_size - - # Color Aug - amount_l = int(color_lightness) / 100 - amount_ab = int(color_ab) / 100 - lab_adjust = np.array([amount_l, amount_ab, amount_ab], dtype="float32") - - # Random Warp - warp_range = np.linspace(0, self._processing_size, 5, dtype='float32') - warp_mapx = np.broadcast_to(warp_range, (self._batchsize, 5, 5)).astype("float32") - warp_mapy = np.broadcast_to(warp_mapx[0].T, (self._batchsize, 5, 5)).astype("float32") - warp_pad = int(1.25 * self._processing_size) - - # Random Warp Landmarks - p_mx = self._processing_size - 1 - p_hf = (self._processing_size // 2) - 1 - edge_anchors = np.array([(0, 0), (0, p_mx), (p_mx, p_mx), (p_mx, 0), - (p_hf, 0), (p_hf, p_mx), (p_mx, p_hf), (0, p_hf)]).astype("int32") - edge_anchors = np.broadcast_to(edge_anchors, (self._batchsize, 8, 2)) - grids = np.mgrid[0: p_mx: complex(self._processing_size), # type: ignore - 0: p_mx: complex(self._processing_size)] # type: ignore - retval = AugConstants(clahe_base_contrast=max(2, self._processing_size // 128), - clahe_chance=color_clahe_chance / 100, - clahe_max_size=color_clahe_max_size, - lab_adjust=lab_adjust, - transform_rotation=rotation_range, - transform_zoom=zoom_amount / 100, - transform_shift=tform_shift, - warp_maps=np.stack((warp_mapx, warp_mapy), axis=1), - warp_pad=(warp_pad, warp_pad), - warp_slices=slice(warp_pad // 10, -warp_pad // 10), - warp_lm_edge_anchors=edge_anchors, - warp_lm_grids=grids) - logger.debug("Initialized constants: %s", retval) - return retval - # <<< COLOR AUGMENTATION >>> # def color_adjust(self, batch: np.ndarray) -> np.ndarray: """ Perform color augmentation on the passed in batch. @@ -178,7 +218,7 @@ def color_adjust(self, batch: np.ndarray) -> np.ndarray: A 4-dimensional array of the same shape as :attr:`batch` with color augmentation applied. """ - logger.trace("Augmenting color") # type: ignore + logger.trace("Augmenting color") # type:ignore[attr-defined] batch = batch_convert_color(batch, "BGR2LAB") self._random_lab(batch) self._random_clahe(batch) @@ -190,7 +230,7 @@ def _random_clahe(self, batch: np.ndarray) -> None: a batch of images """ base_contrast = self._constants.clahe_base_contrast - batch_random = np.random.rand(self._batchsize) + batch_random = np.random.rand(self._batch_size) indices = np.where(batch_random < self._constants.clahe_chance)[0] if not np.any(indices): return @@ -198,9 +238,9 @@ def _random_clahe(self, batch: np.ndarray) -> None: size=indices.shape[0], dtype="uint8") grid_sizes = (grid_bases * (base_contrast // 2)) + base_contrast - logger.trace("Adjusting Contrast. Grid Sizes: %s", grid_sizes) # type: ignore + logger.trace("Adjusting Contrast. Grid Sizes: %s", grid_sizes) # type:ignore[attr-defined] - clahes = [cv2.createCLAHE(clipLimit=2.0, # pylint:disable=no-member + clahes = [cv2.createCLAHE(clipLimit=2.0, tileGridSize=(grid_size, grid_size)) for grid_size in grid_sizes] @@ -212,8 +252,8 @@ def _random_lab(self, batch: np.ndarray) -> None: images """ randoms = np.random.uniform(-self._constants.lab_adjust, self._constants.lab_adjust, - size=(self._batchsize, 1, 1, 3)).astype("float32") - logger.trace("Random LAB adjustments: %s", randoms) # type: ignore + size=(self._batch_size, 1, 1, 3)).astype("float32") + logger.trace("Random LAB adjustments: %s", randoms) # type:ignore[attr-defined] # Iterating through the images and channels is much faster than numpy.where and slightly # faster than numexpr.where. for image, rand in zip(batch, randoms): @@ -236,18 +276,18 @@ def transform(self, batch: np.ndarray): The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `channels`) and in `BGR` format. """ - logger.trace("Randomly transforming image") # type: ignore + logger.trace("Randomly transforming image") # type:ignore[attr-defined] rotation = np.random.uniform(-self._constants.transform_rotation, self._constants.transform_rotation, - size=self._batchsize).astype("float32") + size=self._batch_size).astype("float32") scale = np.random.uniform(1 - self._constants.transform_zoom, 1 + self._constants.transform_zoom, - size=self._batchsize).astype("float32") + size=self._batch_size).astype("float32") tform = np.random.uniform(-self._constants.transform_shift, self._constants.transform_shift, - size=(self._batchsize, 2)).astype("float32") + size=(self._batch_size, 2)).astype("float32") mats = np.array( [cv2.getRotationMatrix2D((self._processing_size // 2, self._processing_size // 2), rot, @@ -262,7 +302,7 @@ def transform(self, batch: np.ndarray): dst=image, borderMode=cv2.BORDER_REPLICATE) - logger.trace("Randomly transformed image") # type: ignore + logger.trace("Randomly transformed image") # type:ignore[attr-defined] def random_flip(self, batch: np.ndarray): """ Perform random horizontal flipping on the passed in batch. @@ -275,14 +315,12 @@ def random_flip(self, batch: np.ndarray): The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `channels`) and in `BGR` format. """ - logger.trace("Randomly flipping image") # type: ignore - randoms = np.random.rand(self._batchsize) - flip_chance = self._config.get("random_flip", 50) - assert isinstance(flip_chance, int) - indices = np.where(randoms > flip_chance / 100)[0] + logger.trace("Randomly flipping image") # type:ignore[attr-defined] + randoms = np.random.rand(self._batch_size) + indices = np.where(randoms <= self._flip_chance / 100)[0] batch[indices] = batch[indices, :, ::-1] - logger.trace("Randomly flipped %s images of %s", # type: ignore - len(indices), self._batchsize) + logger.trace("Randomly flipped %s images of %s", # type:ignore[attr-defined] + len(indices), self._batch_size) def warp(self, batch: np.ndarray, to_landmarks: bool = False, **kwargs) -> np.ndarray: """ Perform random warping on the passed in batch by one of two methods. @@ -329,9 +367,9 @@ def _random_warp(self, batch: np.ndarray) -> np.ndarray: :class:`numpy.ndarray` A 4-dimensional array of the same shape as :attr:`batch` with warping applied. """ - logger.trace("Randomly warping batch") # type: ignore + logger.trace("Randomly warping batch") # type:ignore[attr-defined] slices = self._constants.warp_slices - rands = np.random.normal(size=(self._batchsize, 2, 5, 5), + rands = np.random.normal(size=(self._batch_size, 2, 5, 5), scale=self._warp_scale).astype("float32") batch_maps = ne.evaluate("m + r", local_dict={"m": self._constants.warp_maps, "r": rands}) batch_interp = np.array([[cv2.resize(map_, self._constants.warp_pad)[slices, slices] @@ -340,7 +378,7 @@ def _random_warp(self, batch: np.ndarray) -> np.ndarray: warped_batch = np.array([cv2.remap(image, interp[0], interp[1], cv2.INTER_LINEAR) for image, interp in zip(batch, batch_interp)]) - logger.trace("Warped image shape: %s", warped_batch.shape) # type: ignore + logger.trace("Warped image shape: %s", warped_batch.shape) # type:ignore[attr-defined] return warped_batch def _random_warp_landmarks(self, @@ -364,7 +402,7 @@ def _random_warp_landmarks(self, :class:`numpy.ndarray` A 4-dimensional array of the same shape as :attr:`batch` with warping applied. """ - logger.trace("Randomly warping landmarks") # type: ignore + logger.trace("Randomly warping landmarks") # type:ignore[attr-defined] edge_anchors = self._constants.warp_lm_edge_anchors grids = self._constants.warp_lm_grids @@ -389,15 +427,16 @@ def _random_warp_landmarks(self, grid_z = np.array([griddata(dst, src, (grids[0], grids[1]), method="linear") for src, dst in zip(lbatch_src, lbatch_dst)]) - maps = grid_z.reshape((self._batchsize, + maps = grid_z.reshape((self._batch_size, self._processing_size, self._processing_size, 2)).astype("float32") + warped_batch = np.array([cv2.remap(image, map_[..., 1], map_[..., 0], cv2.INTER_LINEAR, - cv2.BORDER_TRANSPARENT) + borderMode=cv2.BORDER_TRANSPARENT) for image, map_ in zip(batch, maps)]) - logger.trace("Warped batch shape: %s", warped_batch.shape) # type: ignore + logger.trace("Warped batch shape: %s", warped_batch.shape) # type:ignore[attr-defined] return warped_batch diff --git a/lib/training/generator.py b/lib/training/generator.py index 538941adf0..0624246e0a 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -57,7 +57,7 @@ def __init__(self, side: T.Literal["a", "b"], images: list[str], batch_size: int) -> None: - logger.debug("Initializing %s: (model: %s, side: %s, images: %s , " # type: ignore + logger.debug("Initializing %s: (model: %s, side: %s, images: %s , " "batch_size: %s, config: %s)", self.__class__.__name__, model.name, side, len(images), batch_size, config) self._config = config @@ -243,8 +243,9 @@ def _get_images_with_meta(self, filenames: list[str]) -> tuple[np.ndarray, list[ raw_faces = read_image_batch(filenames) detected_faces = self._face_cache.get_items(filenames) - logger.trace("filenames: %s, raw_faces: '%s', detected_faces: %s", # type: ignore - filenames, raw_faces.shape, len(detected_faces)) + logger.trace( # type:ignore[attr-defined] + "filenames: %s, raw_faces: '%s', detected_faces: %s", + filenames, raw_faces.shape, len(detected_faces)) return raw_faces, detected_faces def _crop_to_coverage(self, @@ -271,8 +272,8 @@ def _crop_to_coverage(self, batch: :class:`np.ndarray` The pre-allocated array to hold this batch """ - logger.trace("Cropping training images info: (filenames: %s, side: '%s')", # type: ignore - filenames, self._side) + logger.trace( # type:ignore[attr-defined] + "Cropping training images info: (filenames: %s, side: '%s')", filenames, self._side) with futures.ThreadPoolExecutor() as executor: proc = {executor.submit(face.aligned.extract_face, img): idx @@ -304,7 +305,7 @@ def _apply_mask(self, detected_faces: list[DetectedFace], batch: np.ndarray) -> masks = np.array([face.get_training_masks() for face in detected_faces]) batch[..., 3:] = masks - logger.trace("side: %s, masks: %s, batch: %s", # type: ignore + logger.trace("side: %s, masks: %s, batch: %s", # type:ignore[attr-defined] self._side, masks.shape, batch.shape) def _process_batch(self, filenames: list[str]) -> BatchType: @@ -333,9 +334,9 @@ def _process_batch(self, filenames: list[str]) -> BatchType: self._apply_mask(detected_faces, batch) feed, targets = self.process_batch(filenames, raw_faces, detected_faces, batch) - logger.trace("Processed %s batch side %s. (filenames: %s, feed: %s, " # type: ignore - "targets: %s)", self.__class__.__name__, self._side, filenames, - feed.shape, [t.shape for t in targets]) + logger.trace( # type:ignore[attr-defined] + "Processed %s batch side %s. (filenames: %s, feed: %s, targets: %s)", + self.__class__.__name__, self._side, filenames, feed.shape, [t.shape for t in targets]) return feed, targets @@ -450,15 +451,19 @@ def _create_targets(self, batch: np.ndarray) -> list[np.ndarray]: List of 4-dimensional target images, at all model output sizes, with masks compiled into channels 4+ for each output size """ - logger.trace("Compiling targets: batch shape: %s", batch.shape) # type: ignore + logger.trace("Compiling targets: batch shape: %s", # type:ignore[attr-defined] + batch.shape) if len(self._output_sizes) == 1 and self._output_sizes[0] == self._process_size: # Rolling buffer here makes next to no difference, so just create array on the fly retval = [self._to_float32(batch)] else: - retval = [self._to_float32(np.array([cv2.resize(image, (size, size), cv2.INTER_AREA) + retval = [self._to_float32(np.array([cv2.resize(image, + (size, size), + interpolation=cv2.INTER_AREA) for image in batch])) for size in self._output_sizes] - logger.trace("Processed targets: %s", [t.shape for t in retval]) # type: ignore + logger.trace("Processed targets: %s", # type:ignore[attr-defined] + [t.shape for t in retval]) return retval def process_batch(self, @@ -533,7 +538,7 @@ def process_batch(self, feed = self._to_float32(np.array([cv2.resize(image, (self._model_input_size, self._model_input_size), - cv2.INTER_AREA) + interpolation=cv2.INTER_AREA) for image in warped])) else: feed = self._to_float32(warped) @@ -556,8 +561,9 @@ def _get_closest_match(self, filenames: list[str], batch_src_points: np.ndarray) :class:`np.ndarray` Randomly selected closest matches from the other side's landmarks """ - logger.trace("Retrieving closest matched landmarks: (filenames: '%s', " # type: ignore - "src_points: '%s')", filenames, batch_src_points) + logger.trace( # type:ignore[attr-defined] + "Retrieving closest matched landmarks: (filenames: '%s', src_points: '%s')", + filenames, batch_src_points) lm_side: T.Literal["a", "b"] = "a" if self._side == "b" else "b" other_cache = get_cache(lm_side) landmarks = other_cache.aligned_landmarks @@ -575,7 +581,8 @@ def _get_closest_match(self, filenames: list[str], batch_src_points: np.ndarray) closest_matches = self._cache_closest_matches(filenames, batch_src_points, landmarks) batch_dst_points = np.array([landmarks[choice(fname)] for fname in closest_matches]) - logger.trace("Returning: (batch_dst_points: %s)", batch_dst_points.shape) # type: ignore + logger.trace("Returning: (batch_dst_points: %s)", # type:ignore[attr-defined] + batch_dst_points.shape) return batch_dst_points def _cache_closest_matches(self, @@ -648,8 +655,9 @@ def _create_samples(self, list List of 4-dimensional target images, at final model output size """ - logger.trace("Compiling samples: images shape: %s, detected_faces: %s ", # type: ignore - images.shape, len(detected_faces)) + logger.trace( # type:ignore[attr-defined] + "Compiling samples: images shape: %s, detected_faces: %s ", + images.shape, len(detected_faces)) output_size = self._output_sizes[-1] full_size = 2 * int(np.rint((output_size / self._coverage_ratio) / 2)) @@ -665,7 +673,7 @@ def _create_samples(self, is_aligned=True).face for idx, face in enumerate(detected_faces)])) - logger.trace("Processed samples: %s", retval.shape) # type: ignore + logger.trace("Processed samples: %s", retval.shape) # type:ignore[attr-defined] return [retval] def process_batch(self, @@ -840,8 +848,9 @@ def get_batch(self) -> tuple[list[list[np.ndarray]], ...]: side_feed, side_targets = next(self._feeds[side]) if self._model.config["learn_mask"]: # Add the face mask as it's own target side_targets += [side_targets[-1][..., 3][..., None]] - logger.trace("side: %s, input_shapes: %s, target_shapes: %s", # type: ignore - side, side_feed.shape, [i.shape for i in side_targets]) + logger.trace( # type:ignore[attr-defined] + "side: %s, input_shapes: %s, target_shapes: %s", + side, side_feed.shape, [i.shape for i in side_targets]) model_inputs.append([side_feed]) model_targets.append(side_targets) diff --git a/locales/es/LC_MESSAGES/tools.alignments.cli.mo b/locales/es/LC_MESSAGES/tools.alignments.cli.mo index f6786ef695682f93b758135d47b8af8579416fc5..9499ca1e8acd6c11dc050f5bd77a732215adafc5 100644 GIT binary patch delta 458 zcmXZYKS;ts6bJC{{Y>q@nLqTjpdV2gfr&%X5L&k42P-|$mxTR=u ztKF$7YzUefgcb=B8d{_w-1}a1aD46^?>*k#dA^ssoT*L&XbNDNe$q4z8Nfa*&?tSR zHTq3AY1ILwnEOQE`vH#=m}A@>0Z#e=(*x`>@4^SPXp4ficP0?$@!k(G4Q)TFaP#oN zi$VHD583F4&NJSVzz#j7NA&sMXb@Oqd`(~I8`T5XLqLi3J=$W!;v|q^ycGtPIOHvx z9x(4U!eQyc;yQ4~$t0h-!Hb(1z&y0+5baPsV3)EF#<=y;Ipg}EvSP~11{mOW=^HMG zx)K4eexY8&-1-AX)q@DDj#yFBNV;t+dz`l-s$isDwpFyPNTX@gOn1VH#a82qMkX*T fR9U8kttxU``GONp+bXf3ER(A87Zq7k4`$^rRNF@A delta 497 zcmYMwze_?<6bJC{eOdk}E6ZQcq#k6cfrc7FK}5BMVL=oG4WhiE!QySwU=B?U7Pmj4 zXsH&bXle**X=#cGGN>k7!ojWYgRlpe&pDTK&b{|F^OeF;s5}bbRsd=GL04%|0oG}e zPSQJCrA@j(4?2JhYwzi+4e)dVA-hIlXs?gcr6c{U8B zc**k!u*tfY7|@{U_#Cj$n}ztyd2XB~0M_A)cGCtmFW91-V?Lt&4KT9EoEUNSGqQ7a zahXqrJQhAzF9UN{4;OzFcdtcQ=<-dZ72S|^k&;V_$F3V%u2|4wy401djoFUDq;{iJ z-PWX*NX{gtYOB66VbSx^xLolM%ZwVaYLS?3wDSq+R8m#rr|LR2R;VR z+^C~(w2h6v=rGqo9ayGWx)&ZV8)jR81+I5vzynXb zj{~dBb9(@T`>lxyCh}%ONnng0*3v*NJ)>TF`Rfg3KAJey;DZrVfwV+>fDL}CRD~KI zc_<Q5{WXFy3w6%W;7dCW@Sa8^A}iJ z7-^!g@KIuAV?`-bSlUdIo#&Rut#g0xd+z(5bI-Xk9*o`2LI;3r0VL@=ouwfKNYgCs zrMGm4e$i37Sp!V5_MW~t0beZ;VP2^RR&7AA5twG(RtxY*pD5US=mm6!ML)nYl>I2c z!Qg`vZu&`ExzG#6 zRLDIM@H8;s#h{LZ7sc5#5f!F<6-mX+%d+T`lZww}=EFvIIUF;ksmwT;8Q7fQkF o(8CFRC^1k<1?+;I6KaOMtFP&$ejsiJaq9qzsfooc!n{+D%EU^EH?l2Gc00s{_q|;r1-OC#5KSRJd zFZwV7oUv~$3G8$JFF6C~yvp7bu+G8-^A3txzJmB@H`OWo!9AOU4{pvwW<+0rC!(F7 zyIBKV@ZU mOW2E%w2SwM=&a delta 482 zcmX}oPbh<790&04^P2t9*v9_7vserV*=(zoQT`MMkz8Dnc@Na4rIgF&pp*l8ZX`}F zi_%iGgQk>&GC4`g4jf(Npd5VP9lZ6tpXc{`e$U^FoG9hQZZO*ppd^4eeW%mZuK+VN zMZ@%#?$B>KLN{xG3D(}xS2N(M1v(jj)dMRAz-I#{S-0H;Jke(g&OLPkQFhPV0LxHu zBMTEdA57HIPrA&7e&{IUMK6%1d3r*dRp6Z7P>rtofHBs;&^5+wejvz=_UV8fxNZR+ zIQ|;~4tdb~Uf_UrOMSow^Txy=C-Ep-{lFXt@{Fz2It1{rP-!C#QP%61`2Y00LC%X- z2X}&n&B&%-6>?8_91ZO7VXEu#p_siAT|$#@BCco|SrIWguDC2(CNQ5$26{B9DN|-f z=?sHHQOP<@(Lf}ch{OuRo>{?}$KEvmHuXrKRK8@{g0!paGVJTNRx{;dHCssf9y|U3 DwIETT diff --git a/locales/ru/LC_MESSAGES/tools.alignments.cli.po b/locales/ru/LC_MESSAGES/tools.alignments.cli.po index fe2ca62297..3f68a44acf 100644 --- a/locales/ru/LC_MESSAGES/tools.alignments.cli.po +++ b/locales/ru/LC_MESSAGES/tools.alignments.cli.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-12 12:10+0100\n" -"PO-Revision-Date: 2024-04-12 12:13+0100\n" +"POT-Creation-Date: 2024-04-19 11:28+0100\n" +"PO-Revision-Date: 2024-04-19 11:31+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -38,28 +38,28 @@ msgstr "" "кадров." #: tools/alignments/cli.py:43 -msgid " Must Pass in a frames folder/source video file (-fr)." -msgstr " Должен проходить в папке с кадрами/исходным видеофайлом (-fr)." +msgid " Must Pass in a frames folder/source video file (-r)." +msgstr " Должен проходить в папке с кадрами/исходным видеофайлом (-r)." #: tools/alignments/cli.py:44 -msgid " Must Pass in a faces folder (-fc)." -msgstr " Должен проходить в папке с лицами (-fc)." +msgid " Must Pass in a faces folder (-c)." +msgstr " Должен проходить в папке с лицами (-c)." #: tools/alignments/cli.py:45 msgid "" -" Must Pass in either a frames folder/source video file OR a faces folder (-" -"fr or -fc)." +" Must Pass in either a frames folder/source video file OR a faces folder (-r " +"or -c)." msgstr "" " Должно передаваться либо в папку с кадрами/исходным видеофайлом, либо в " -"папку с лицами (-fr или -fc)." +"папку с лицами (-r или -c)." #: tools/alignments/cli.py:47 msgid "" -" Must Pass in a frames folder/source video file AND a faces folder (-fr and -" -"fc)." +" Must Pass in a frames folder/source video file AND a faces folder (-r and -" +"c)." msgstr "" " Должно передаваться либо в папку с кадрами/исходным видеофайлом И в папку с " -"лицами (-fr и -fc)." +"лицами (-r и -c)." #: tools/alignments/cli.py:49 msgid " Use the output option (-o) to process results." diff --git a/locales/tools.alignments.cli.pot b/locales/tools.alignments.cli.pot index 64c40ff9af..4f1e02ae15 100644 --- a/locales/tools.alignments.cli.pot +++ b/locales/tools.alignments.cli.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-12 12:10+0100\n" +"POT-Creation-Date: 2024-04-19 11:28+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -30,23 +30,23 @@ msgid "" msgstr "" #: tools/alignments/cli.py:43 -msgid " Must Pass in a frames folder/source video file (-fr)." +msgid " Must Pass in a frames folder/source video file (-r)." msgstr "" #: tools/alignments/cli.py:44 -msgid " Must Pass in a faces folder (-fc)." +msgid " Must Pass in a faces folder (-c)." msgstr "" #: tools/alignments/cli.py:45 msgid "" -" Must Pass in either a frames folder/source video file OR a faces folder (-" -"fr or -fc)." +" Must Pass in either a frames folder/source video file OR a faces folder (-r " +"or -c)." msgstr "" #: tools/alignments/cli.py:47 msgid "" -" Must Pass in a frames folder/source video file AND a faces folder (-fr and -" -"fc)." +" Must Pass in a frames folder/source video file AND a faces folder (-r and -" +"c)." msgstr "" #: tools/alignments/cli.py:49 diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py index 84be7261f1..5a76443cc0 100644 --- a/tools/alignments/cli.py +++ b/tools/alignments/cli.py @@ -40,12 +40,12 @@ def get_argument_list() -> list[dict[str, T.Any]]: dict The argparse command line options for processing by argparse """ - frames_dir = _(" Must Pass in a frames folder/source video file (-fr).") - faces_dir = _(" Must Pass in a faces folder (-fc).") + frames_dir = _(" Must Pass in a frames folder/source video file (-r).") + faces_dir = _(" Must Pass in a faces folder (-c).") frames_or_faces_dir = _(" Must Pass in either a frames folder/source video file OR a " - "faces folder (-fr or -fc).") + "faces folder (-r or -c).") frames_and_faces_dir = _(" Must Pass in a frames folder/source video file AND a faces " - "folder (-fr and -fc).") + "folder (-r and -c).") output_opts = _(" Use the output option (-o) to process results.") argument_list = [] argument_list.append({ @@ -118,7 +118,7 @@ def get_argument_list() -> list[dict[str, T.Any]]: "group": _("data"), # hacky solution to not require alignments file if creating alignments from faces: "required": not any(val in sys.argv for val in ["from-faces", - "-fr", + "-r", "-frames_folder"]), "filetypes": "alignments", "help": _( From 96528ee3e83a4ba4c5917a314ead2b40b6c13fe6 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 19 Apr 2024 12:25:00 +0100 Subject: [PATCH 902/981] lib.align.detected_face: Split Mask objects to own aligned_mask module --- docs/full/lib/align.rst | 23 +- lib/align/__init__.py | 3 +- lib/align/aligned_mask.py | 599 +++++++++++++++++++ lib/align/detected_face.py | 613 +------------------- tools/manual/frameviewer/editor/__init__.py | 10 +- tools/manual/manual.py | 5 +- 6 files changed, 643 insertions(+), 610 deletions(-) create mode 100644 lib/align/aligned_mask.py diff --git a/docs/full/lib/align.rst b/docs/full/lib/align.rst index cb01196739..7baf843e23 100644 --- a/docs/full/lib/align.rst +++ b/docs/full/lib/align.rst @@ -30,6 +30,27 @@ Handles aligned faces and corresponding pose estimates :show-inheritance: +aligned\_mask module +==================== + +Handles aligned storage and retrieval of Faceswap generated masks + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~lib.align.aligned_mask.BlurMask + ~lib.align.aligned_mask.Mask + +.. rubric:: Module + +.. automodule:: lib.align.aligned_mask + :members: + :undoc-members: + :show-inheritance: + + alignments module ================= @@ -71,9 +92,7 @@ Handles detected face objects and their associated masks. .. autosummary:: :nosignatures: - ~lib.align.detected_face.BlurMask ~lib.align.detected_face.DetectedFace - ~lib.align.detected_face.Mask ~lib.align.detected_face.update_legacy_png_header .. rubric:: Module diff --git a/lib/align/__init__.py b/lib/align/__init__.py index ec00ec7798..3f5887bcd6 100644 --- a/lib/align/__init__.py +++ b/lib/align/__init__.py @@ -3,6 +3,7 @@ associated objects. """ from .aligned_face import (AlignedFace, get_adjusted_center, get_matrix_scaling, get_centered_size, transform_image) +from .aligned_mask import BlurMask, LandmarksMask, Mask from .alignments import Alignments from .constants import CenteringType, EXTRACT_RATIOS, LANDMARK_PARTS, LandmarkType -from .detected_face import BlurMask, DetectedFace, Mask, update_legacy_png_header +from .detected_face import DetectedFace, update_legacy_png_header diff --git a/lib/align/aligned_mask.py b/lib/align/aligned_mask.py new file mode 100644 index 0000000000..6a34060653 --- /dev/null +++ b/lib/align/aligned_mask.py @@ -0,0 +1,599 @@ +#!/usr/bin python3 +""" Handles retrieval and storage of Faceswap aligned masks """ + +from __future__ import annotations +import logging +import typing as T + +from zlib import compress, decompress + +import cv2 +import numpy as np + +from lib.logger import parse_class_init + +from .alignments import MaskAlignmentsFileDict +from . import get_adjusted_center, get_centered_size + +if T.TYPE_CHECKING: + from collections.abc import Callable + from .aligned_face import CenteringType + +logger = logging.getLogger(__name__) + + +class Mask(): + """ Face Mask information and convenience methods + + Holds a Faceswap mask as generated from :mod:`plugins.extract.mask` and the information + required to transform it to its original frame. + + Holds convenience methods to handle the warping, storing and retrieval of the mask. + + Parameters + ---------- + storage_size: int, optional + The size (in pixels) that the mask should be stored at. Default: 128. + storage_centering, str (optional): + The centering to store the mask at. One of `"legacy"`, `"face"`, `"head"`. + Default: `"face"` + + Attributes + ---------- + stored_size: int + The size, in pixels, of the stored mask across its height and width. + stored_centering: str + The centering that the mask is stored at. One of `"legacy"`, `"face"`, `"head"` + """ + def __init__(self, + storage_size: int = 128, + storage_centering: CenteringType = "face") -> None: + logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] + self.stored_size = storage_size + self.stored_centering = storage_centering + + self._mask: bytes | None = None + self._affine_matrix: np.ndarray | None = None + self._interpolator: int | None = None + + self._blur_type: T.Literal["gaussian", "normalized"] | None = None + self._blur_passes: int = 0 + self._blur_kernel: float | int = 0 + self._threshold = 0.0 + self._dilation: tuple[T.Literal["erode", "dilate"], np.ndarray | None] = ("erode", None) + self._sub_crop_size = 0 + self._sub_crop_slices: dict[T.Literal["in", "out"], list[slice]] = {} + + self.set_blur_and_threshold() + logger.trace("Initialized: %s", self.__class__.__name__) # type:ignore[attr-defined] + + @property + def mask(self) -> np.ndarray: + """ :class:`numpy.ndarray`: The mask at the size of :attr:`stored_size` with any requested + blurring, threshold amount and centering applied.""" + mask = self.stored_mask + if self._dilation[-1] is not None or self._threshold != 0.0 or self._blur_kernel != 0: + mask = mask.copy() + self._dilate_mask(mask) + if self._threshold != 0.0: + mask[mask < self._threshold] = 0.0 + mask[mask > 255.0 - self._threshold] = 255.0 + if self._blur_kernel != 0 and self._blur_type is not None: + mask = BlurMask(self._blur_type, + mask, + self._blur_kernel, + passes=self._blur_passes).blurred + if self._sub_crop_size: # Crop the mask to the given centering + out = np.zeros((self._sub_crop_size, self._sub_crop_size, 1), dtype=mask.dtype) + slice_in, slice_out = self._sub_crop_slices["in"], self._sub_crop_slices["out"] + out[slice_out[0], slice_out[1], :] = mask[slice_in[0], slice_in[1], :] + mask = out + logger.trace("mask shape: %s", mask.shape) # type:ignore[attr-defined] + return mask + + @property + def stored_mask(self) -> np.ndarray: + """ :class:`numpy.ndarray`: The mask at the size of :attr:`stored_size` as it is stored + (i.e. with no blurring/centering applied). """ + assert self._mask is not None + dims = (self.stored_size, self.stored_size, 1) + mask = np.frombuffer(decompress(self._mask), dtype="uint8").reshape(dims) + logger.trace("stored mask shape: %s", mask.shape) # type:ignore[attr-defined] + return mask + + @property + def original_roi(self) -> np.ndarray: + """ :class: `numpy.ndarray`: The original region of interest of the mask in the + source frame. """ + points = np.array([[0, 0], + [0, self.stored_size - 1], + [self.stored_size - 1, self.stored_size - 1], + [self.stored_size - 1, 0]], np.int32).reshape((-1, 1, 2)) + matrix = cv2.invertAffineTransform(self.affine_matrix) + roi = cv2.transform(points, matrix).reshape((4, 2)) + logger.trace("Returning: %s", roi) # type:ignore[attr-defined] + return roi + + @property + def affine_matrix(self) -> np.ndarray: + """ :class: `numpy.ndarray`: The affine matrix to transpose the mask to a full frame. """ + assert self._affine_matrix is not None + return self._affine_matrix + + @property + def interpolator(self) -> int: + """ int: The cv2 interpolator required to transpose the mask to a full frame. """ + assert self._interpolator is not None + return self._interpolator + + def _dilate_mask(self, mask: np.ndarray) -> None: + """ Erode/Dilate the mask. The action is performed in-place on the given mask. + + No action is performed if a dilation amount has not been set + + Parameters + ---------- + mask: :class:`numpy.ndarray` + The mask to be eroded/dilated + """ + if self._dilation[-1] is None: + return + + func = cv2.erode if self._dilation[0] == "erode" else cv2.dilate + func(mask, self._dilation[-1], dst=mask, iterations=1) + + def get_full_frame_mask(self, width: int, height: int) -> np.ndarray: + """ Return the stored mask in a full size frame of the given dimensions + + Parameters + ---------- + width: int + The width of the original frame that the mask was extracted from + height: int + The height of the original frame that the mask was extracted from + + Returns + ------- + :class:`numpy.ndarray`: The mask affined to the original full frame of the given dimensions + """ + frame = np.zeros((width, height, 1), dtype="uint8") + mask = cv2.warpAffine(self.mask, + self.affine_matrix, + (width, height), + frame, + flags=cv2.WARP_INVERSE_MAP | self.interpolator, + borderMode=cv2.BORDER_CONSTANT) + logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, " # type:ignore[attr-defined] + "mask max: %s", mask.shape, mask.dtype, mask.min(), mask.max()) + return mask + + def add(self, mask: np.ndarray, affine_matrix: np.ndarray, interpolator: int) -> None: + """ Add a Faceswap mask to this :class:`Mask`. + + The mask should be the original output from :mod:`plugins.extract.mask` + + Parameters + ---------- + mask: :class:`numpy.ndarray` + The mask that is to be added as output from :mod:`plugins.extract.mask` + It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` + affine_matrix: :class:`numpy.ndarray` + The transformation matrix required to transform the mask to the original frame. + interpolator, int: + The CV2 interpolator required to transform this mask to it's original frame + """ + logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, " # type:ignore[attr-defined] + "mask max: %s, affine_matrix: %s, interpolator: %s)", + mask.shape, mask.dtype, mask.min(), affine_matrix, mask.max(), interpolator) + self._affine_matrix = self._adjust_affine_matrix(mask.shape[0], affine_matrix) + self._interpolator = interpolator + self.replace_mask(mask) + + def replace_mask(self, mask: np.ndarray) -> None: + """ Replace the existing :attr:`_mask` with the given mask. + + Parameters + ---------- + mask: :class:`numpy.ndarray` + The mask that is to be added as output from :mod:`plugins.extract.mask`. + It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` + """ + mask = (cv2.resize(mask * 255.0, + (self.stored_size, self.stored_size), + interpolation=cv2.INTER_AREA)).astype("uint8") + self._mask = compress(mask.tobytes()) + + def set_dilation(self, amount: float) -> None: + """ Set the internal dilation object for returned masks + + Parameters + ---------- + amount: float + The amount of erosion/dilation to apply as a percentage of the total mask size. + Negative values erode the mask. Positive values dilate the mask + """ + if amount == 0: + self._dilation = ("erode", None) + return + + action: T.Literal["erode", "dilate"] = "erode" if amount < 0 else "dilate" + kernel = int(round(self.stored_size * abs(amount / 100.), 0)) + self._dilation = (action, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel, kernel))) + + logger.trace("action: '%s', amount: %s, kernel: %s, ", # type:ignore[attr-defined] + action, amount, kernel) + + def set_blur_and_threshold(self, + blur_kernel: int = 0, + blur_type: T.Literal["gaussian", "normalized"] | None = "gaussian", + blur_passes: int = 1, + threshold: int = 0) -> None: + """ Set the internal blur kernel and threshold amount for returned masks + + Parameters + ---------- + blur_kernel: int, optional + The kernel size, in pixels to apply gaussian blurring to the mask. Set to 0 for no + blurring. Should be odd, if an even number is passed in (outside of 0) then it is + rounded up to the next odd number. Default: 0 + blur_type: ["gaussian", "normalized"], optional + The blur type to use. ``gaussian`` or ``normalized`` box filter. Default: ``gaussian`` + blur_passes: int, optional + The number of passed to perform when blurring. Default: 1 + threshold: int, optional + The threshold amount to minimize/maximize mask values to 0 and 100. Percentage value. + Default: 0 + """ + logger.trace("blur_kernel: %s, blur_type: %s, " # type:ignore[attr-defined] + "blur_passes: %s, threshold: %s", + blur_kernel, blur_type, blur_passes, threshold) + if blur_type is not None: + blur_kernel += 0 if blur_kernel == 0 or blur_kernel % 2 == 1 else 1 + self._blur_kernel = blur_kernel + self._blur_type = blur_type + self._blur_passes = blur_passes + self._threshold = (threshold / 100.0) * 255.0 + + def set_sub_crop(self, + source_offset: np.ndarray, + target_offset: np.ndarray, + centering: CenteringType, + coverage_ratio: float = 1.0) -> None: + """ Set the internal crop area of the mask to be returned. + + This impacts the returned mask from :attr:`mask` if the requested mask is required for + different face centering than what has been stored. + + Parameters + ---------- + source_offset: :class:`numpy.ndarray` + The (x, y) offset for the mask at its stored centering + target_offset: :class:`numpy.ndarray` + The (x, y) offset for the mask at the requested target centering + centering: str + The centering to set the sub crop area for. One of `"legacy"`, `"face"`. `"head"` + coverage_ratio: float, optional + The coverage ratio to be applied to the target image. ``None`` for default (1.0). + Default: ``None`` + """ + if centering == self.stored_centering and coverage_ratio == 1.0: + return + + center = get_adjusted_center(self.stored_size, + source_offset, + target_offset, + self.stored_centering) + crop_size = get_centered_size(self.stored_centering, + centering, + self.stored_size, + coverage_ratio=coverage_ratio) + roi = np.array([center - crop_size // 2, center + crop_size // 2]).ravel() + + self._sub_crop_size = crop_size + self._sub_crop_slices["in"] = [slice(max(roi[1], 0), max(roi[3], 0)), + slice(max(roi[0], 0), max(roi[2], 0))] + self._sub_crop_slices["out"] = [ + slice(max(roi[1] * -1, 0), + crop_size - min(crop_size, max(0, roi[3] - self.stored_size))), + slice(max(roi[0] * -1, 0), + crop_size - min(crop_size, max(0, roi[2] - self.stored_size)))] + + logger.trace("src_size: %s, coverage_ratio: %s, " # type:ignore[attr-defined] + "sub_crop_size: %s, sub_crop_slices: %s", + roi, coverage_ratio, self._sub_crop_size, self._sub_crop_slices) + + def _adjust_affine_matrix(self, mask_size: int, affine_matrix: np.ndarray) -> np.ndarray: + """ Adjust the affine matrix for the mask's storage size + + Parameters + ---------- + mask_size: int + The original size of the mask. + affine_matrix: :class:`numpy.ndarray` + The affine matrix to transform the mask at original size to the parent frame. + + Returns + ------- + affine_matrix: :class:`numpy,ndarray` + The affine matrix adjusted for the mask at its stored dimensions. + """ + zoom = self.stored_size / mask_size + zoom_mat = np.array([[zoom, 0, 0.], [0, zoom, 0.]]) + adjust_mat = np.dot(zoom_mat, np.concatenate((affine_matrix, np.array([[0., 0., 1.]])))) + logger.trace("storage_size: %s, mask_size: %s, zoom: %s, " # type:ignore[attr-defined] + "original matrix: %s, adjusted_matrix: %s", self.stored_size, mask_size, zoom, + affine_matrix.shape, adjust_mat.shape) + return adjust_mat + + def to_dict(self, is_png=False) -> MaskAlignmentsFileDict: + """ Convert the mask to a dictionary for saving to an alignments file + + Parameters + ---------- + is_png: bool + ``True`` if the dictionary is being created for storage in a png header otherwise + ``False``. Default: ``False`` + + Returns + ------- + dict: + The :class:`Mask` for saving to an alignments file. Contains the keys ``mask``, + ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` + """ + assert self._mask is not None + affine_matrix = self.affine_matrix.tolist() if is_png else self.affine_matrix + retval = MaskAlignmentsFileDict(mask=self._mask, + affine_matrix=affine_matrix, + interpolator=self.interpolator, + stored_size=self.stored_size, + stored_centering=self.stored_centering) + logger.trace({k: v if k != "mask" else type(v) # type:ignore[attr-defined] + for k, v in retval.items()}) + return retval + + def to_png_meta(self) -> MaskAlignmentsFileDict: + """ Convert the mask to a dictionary supported by png itxt headers. + + Returns + ------- + dict: + The :class:`Mask` for saving to an alignments file. Contains the keys ``mask``, + ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` + """ + return self.to_dict(is_png=True) + + def from_dict(self, mask_dict: MaskAlignmentsFileDict) -> None: + """ Populates the :class:`Mask` from a dictionary loaded from an alignments file. + + Parameters + ---------- + mask_dict: dict + A dictionary stored in an alignments file containing the keys ``mask``, + ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` + """ + self._mask = mask_dict["mask"] + affine_matrix = mask_dict["affine_matrix"] + self._affine_matrix = (affine_matrix if isinstance(affine_matrix, np.ndarray) + else np.array(affine_matrix, dtype="float64")) + self._interpolator = mask_dict["interpolator"] + self.stored_size = mask_dict["stored_size"] + centering = mask_dict.get("stored_centering") + self.stored_centering = "face" if centering is None else centering + logger.trace({k: v if k != "mask" else type(v) # type:ignore[attr-defined] + for k, v in mask_dict.items()}) + + +class LandmarksMask(Mask): + """ Create a single channel mask from aligned landmark points. + + Landmarks masks are created on the fly, so the stored centering and size should be the same as + the aligned face that the mask will be applied to. As the masks are created on the fly, blur + + dilation is applied to the mask at creation (prior to compression) rather than after + decompression when requested. + + Note + ---- + Threshold is not used for Landmarks mask as the mask is binary + + Parameters + ---------- + points: list + A list of landmark points that correspond to the given storage_size to create + the mask. Each item in the list should be a :class:`numpy.ndarray` that a filled + convex polygon will be created from + storage_size: int, optional + The size (in pixels) that the compressed mask should be stored at. Default: 128. + storage_centering, str (optional): + The centering to store the mask at. One of `"legacy"`, `"face"`, `"head"`. + Default: `"face"` + dilation: float, optional + The amount of dilation to apply to the mask. as a percentage of the mask size. Default: 0.0 + """ + def __init__(self, + points: list[np.ndarray], + storage_size: int = 128, + storage_centering: CenteringType = "face", + dilation: float = 0.0) -> None: + super().__init__(storage_size=storage_size, storage_centering=storage_centering) + self._points = points + self.set_dilation(dilation) + + @property + def mask(self) -> np.ndarray: + """ :class:`numpy.ndarray`: Overrides the default mask property, creating the processed + mask at first call and compressing it. The decompressed mask is returned from this + property. """ + return self.stored_mask + + def generate_mask(self, affine_matrix: np.ndarray, interpolator: int) -> None: + """ Generate the mask. + + Creates the mask applying any requested dilation and blurring and assigns compressed mask + to :attr:`_mask` + + Parameters + ---------- + affine_matrix: :class:`numpy.ndarray` + The transformation matrix required to transform the mask to the original frame. + interpolator, int: + The CV2 interpolator required to transform this mask to it's original frame + """ + mask = np.zeros((self.stored_size, self.stored_size, 1), dtype="float32") + for landmarks in self._points: + lms = np.rint(landmarks).astype("int") + cv2.fillConvexPoly(mask, cv2.convexHull(lms), [1.0], lineType=cv2.LINE_AA) + if self._dilation[-1] is not None: + self._dilate_mask(mask) + if self._blur_kernel != 0 and self._blur_type is not None: + mask = BlurMask(self._blur_type, + mask, + self._blur_kernel, + passes=self._blur_passes).blurred + logger.trace("mask: (shape: %s, dtype: %s)", # type:ignore[attr-defined] + mask.shape, mask.dtype) + self.add(mask, affine_matrix, interpolator) + + +class BlurMask(): + """ Factory class to return the correct blur object for requested blur type. + + Works for square images only. Currently supports Gaussian and Normalized Box Filters. + + Parameters + ---------- + blur_type: ["gaussian", "normalized"] + The type of blur to use + mask: :class:`numpy.ndarray` + The mask to apply the blur to + kernel: int or float + Either the kernel size (in pixels) or the size of the kernel as a ratio of mask size + is_ratio: bool, optional + Whether the given :attr:`kernel` parameter is a ratio or not. If ``True`` then the + actual kernel size will be calculated from the given ratio and the mask size. If + ``False`` then the kernel size will be set directly from the :attr:`kernel` parameter. + Default: ``False`` + passes: int, optional + The number of passes to perform when blurring. Default: ``1`` + + Example + ------- + >>> print(mask.shape) + (128, 128, 1) + >>> new_mask = BlurMask("gaussian", mask, 3, is_ratio=False, passes=1).blurred + >>> print(new_mask.shape) + (128, 128, 1) + """ + def __init__(self, + blur_type: T.Literal["gaussian", "normalized"], + mask: np.ndarray, + kernel: int | float, + is_ratio: bool = False, + passes: int = 1) -> None: + logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] + self._blur_type = blur_type + self._mask = mask + self._passes = passes + kernel_size = self._get_kernel_size(kernel, is_ratio) + self._kernel_size = self._get_kernel_tuple(kernel_size) + logger.trace("Initialized %s", self.__class__.__name__) # type:ignore[attr-defined] + + @property + def blurred(self) -> np.ndarray: + """ :class:`numpy.ndarray`: The final mask with blurring applied. """ + func = self._func_mapping[self._blur_type] + kwargs = self._get_kwargs() + blurred = self._mask + for i in range(self._passes): + assert isinstance(kwargs["ksize"], tuple) + ksize = int(kwargs["ksize"][0]) + logger.trace("Pass: %s, kernel_size: %s", # type:ignore[attr-defined] + i + 1, (ksize, ksize)) + blurred = func(blurred, **kwargs) + ksize = int(round(ksize * self._multipass_factor)) + kwargs["ksize"] = self._get_kernel_tuple(ksize) + blurred = blurred[..., None] + logger.trace("Returning blurred mask. Shape: %s", # type:ignore[attr-defined] + blurred.shape) + return blurred + + @property + def _multipass_factor(self) -> float: + """ For multiple passes the kernel must be scaled down. This value is + different for box filter and gaussian """ + factor = {"gaussian": 0.8, "normalized": 0.5} + return factor[self._blur_type] + + @property + def _sigma(self) -> T.Literal[0]: + """ int: The Sigma for Gaussian Blur. Returns 0 to force calculation from kernel size. """ + return 0 + + @property + def _func_mapping(self) -> dict[T.Literal["gaussian", "normalized"], Callable]: + """ dict: :attr:`_blur_type` mapped to cv2 Function name. """ + return {"gaussian": cv2.GaussianBlur, "normalized": cv2.blur} + + @property + def _kwarg_requirements(self) -> dict[T.Literal["gaussian", "normalized"], list[str]]: + """ dict: :attr:`_blur_type` mapped to cv2 Function required keyword arguments. """ + return {"gaussian": ['ksize', 'sigmaX'], "normalized": ['ksize']} + + @property + def _kwarg_mapping(self) -> dict[str, int | tuple[int, int]]: + """ dict: cv2 function keyword arguments mapped to their parameters. """ + return {"ksize": self._kernel_size, "sigmaX": self._sigma} + + def _get_kernel_size(self, kernel: int | float, is_ratio: bool) -> int: + """ Set the kernel size to absolute value. + + If :attr:`is_ratio` is ``True`` then the kernel size is calculated from the given ratio and + the :attr:`_mask` size, otherwise the given kernel size is just returned. + + Parameters + ---------- + kernel: int or float + Either the kernel size (in pixels) or the size of the kernel as a ratio of mask size + is_ratio: bool, optional + Whether the given :attr:`kernel` parameter is a ratio or not. If ``True`` then the + actual kernel size will be calculated from the given ratio and the mask size. If + ``False`` then the kernel size will be set directly from the :attr:`kernel` parameter. + + Returns + ------- + int + The size (in pixels) of the blur kernel + """ + if not is_ratio: + return int(kernel) + + mask_diameter = np.sqrt(np.sum(self._mask)) + radius = round(max(1., mask_diameter * kernel / 100.)) + kernel_size = int(radius * 2 + 1) + logger.trace("kernel_size: %s", kernel_size) # type:ignore[attr-defined] + return kernel_size + + @staticmethod + def _get_kernel_tuple(kernel_size: int) -> tuple[int, int]: + """ Make sure kernel_size is odd and return it as a tuple. + + Parameters + ---------- + kernel_size: int + The size in pixels of the blur kernel + + Returns + ------- + tuple + The kernel size as a tuple of ('int', 'int') + """ + kernel_size += 1 if kernel_size % 2 == 0 else 0 + retval = (kernel_size, kernel_size) + logger.trace(retval) # type:ignore[attr-defined] + return retval + + def _get_kwargs(self) -> dict[str, int | tuple[int, int]]: + """ dict: the valid keyword arguments for the requested :attr:`_blur_type` """ + retval = {kword: self._kwarg_mapping[kword] + for kword in self._kwarg_requirements[self._blur_type]} + logger.trace("BlurMask kwargs: %s", retval) # type:ignore[attr-defined] + return retval diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index 8ebea9a599..fb8ef37f98 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -8,17 +8,18 @@ from hashlib import sha1 from zlib import compress, decompress -import cv2 import numpy as np from lib.image import encode_image, read_image +from lib.logger import parse_class_init from lib.utils import FaceswapError -from .alignments import (Alignments, AlignmentFileDict, MaskAlignmentsFileDict, - PNGHeaderAlignmentsDict, PNGHeaderDict, PNGHeaderSourceDict) -from . import AlignedFace, get_adjusted_center, get_centered_size, LANDMARK_PARTS +from .alignments import (Alignments, AlignmentFileDict, PNGHeaderAlignmentsDict, + PNGHeaderDict, PNGHeaderSourceDict) +from .aligned_face import AlignedFace +from .aligned_mask import LandmarksMask, Mask +from .constants import LANDMARK_PARTS if T.TYPE_CHECKING: - from collections.abc import Callable from .aligned_face import CenteringType logger = logging.getLogger(__name__) @@ -53,7 +54,7 @@ class DetectedFace(): of 68 `(x, y)` ``tuples`` with each of the landmark co-ordinates. mask: dict The generated mask(s) for the face as generated in :mod:`plugins.extract.mask`. Must be a - dict of {**name** (`str`): :class:`Mask`}. + dict of {**name** (`str`): :class:`~lib.align.aligned_mask.Mask`}. Attributes ---------- @@ -77,7 +78,7 @@ class DetectedFace(): The 68 point landmarks as discovered in :mod:`plugins.extract.align`. mask: dict The generated mask(s) for the face as generated in :mod:`plugins.extract.mask`. Is a - dict of {**name** (`str`): :class:`Mask`}. + dict of {**name** (`str`): :class:`~lib.align.aligned_mask.Mask`}. """ def __init__(self, image: np.ndarray | None = None, @@ -86,13 +87,9 @@ def __init__(self, top: int | None = None, height: int | None = None, landmarks_xy: np.ndarray | None = None, - mask: dict[str, "Mask"] | None = None, + mask: dict[str, Mask] | None = None, filename: str | None = None) -> None: - logger.trace("Initializing %s: (image: %s, left: %s, " # type:ignore[attr-defined] - "width: %s, top: %s, height: %s, landmarks_xy: %s, mask: %s, filename: %s)", - self.__class__.__name__, - image.shape if image is not None and image.any() else image, left, width, top, - height, landmarks_xy, mask, filename) + logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] self.image = image self.left = left self.width = width @@ -143,7 +140,7 @@ def add_mask(self, interpolator: int, storage_size: int = 128, storage_centering: CenteringType = "face") -> None: - """ Add a :class:`Mask` to this detected face + """ Add a :class:`~lib.align.aligned_mask.Mask` to this detected face The mask should be the original output from :mod:`plugins.extract.mask` If a mask with this name already exists it will be overwritten by the given @@ -211,7 +208,7 @@ def get_landmark_mask(self, area: T.Literal["eye", "face", "mouth"], blur_kernel: int, dilation: float) -> np.ndarray: - """ Add a :class:`LandmarksMask` to this detected face + """ Add a :class:`L~lib.align.aligned_mask.LandmarksMask` to this detected face Landmark based masks are generated from face Aligned Face landmark points. An aligned face must be loaded. As the data is coming from the already aligned face, no further mask @@ -273,8 +270,8 @@ def store_training_masks(self, A list of training mask. Must be all be uint-8 3D arrays of the same size in 0-255 range delete_masks: bool, optional - ``True`` to delete any of the :class:`Mask` objects owned by this detected face. Use to - free up unrequired memory usage. Default: ``False`` + ``True`` to delete any of the :class:`~lib.align.aligned_mask.Mask` objects owned by + this detected face. Use to free up unrequired memory usage. Default: ``False`` """ if delete_masks: del self.mask @@ -496,588 +493,6 @@ def load_aligned(self, is_legacy=is_aligned and is_legacy) -class Mask(): - """ Face Mask information and convenience methods - - Holds a Faceswap mask as generated from :mod:`plugins.extract.mask` and the information - required to transform it to its original frame. - - Holds convenience methods to handle the warping, storing and retrieval of the mask. - - Parameters - ---------- - storage_size: int, optional - The size (in pixels) that the mask should be stored at. Default: 128. - storage_centering, str (optional): - The centering to store the mask at. One of `"legacy"`, `"face"`, `"head"`. - Default: `"face"` - - Attributes - ---------- - stored_size: int - The size, in pixels, of the stored mask across its height and width. - stored_centering: str - The centering that the mask is stored at. One of `"legacy"`, `"face"`, `"head"` - """ - def __init__(self, - storage_size: int = 128, - storage_centering: CenteringType = "face") -> None: - logger.trace("Initializing: %s (storage_size: %s, " # type:ignore[attr-defined] - "storage_centering: %s)", - self.__class__.__name__, storage_size, storage_centering) - self.stored_size = storage_size - self.stored_centering = storage_centering - - self._mask: bytes | None = None - self._affine_matrix: np.ndarray | None = None - self._interpolator: int | None = None - - self._blur_type: T.Literal["gaussian", "normalized"] | None = None - self._blur_passes: int = 0 - self._blur_kernel: float | int = 0 - self._threshold = 0.0 - self._dilation: tuple[T.Literal["erode", "dilate"], np.ndarray | None] = ("erode", None) - self._sub_crop_size = 0 - self._sub_crop_slices: dict[T.Literal["in", "out"], list[slice]] = {} - - self.set_blur_and_threshold() - logger.trace("Initialized: %s", self.__class__.__name__) # type:ignore[attr-defined] - - @property - def mask(self) -> np.ndarray: - """ :class:`numpy.ndarray`: The mask at the size of :attr:`stored_size` with any requested - blurring, threshold amount and centering applied.""" - mask = self.stored_mask - if self._dilation[-1] is not None or self._threshold != 0.0 or self._blur_kernel != 0: - mask = mask.copy() - self._dilate_mask(mask) - if self._threshold != 0.0: - mask[mask < self._threshold] = 0.0 - mask[mask > 255.0 - self._threshold] = 255.0 - if self._blur_kernel != 0 and self._blur_type is not None: - mask = BlurMask(self._blur_type, - mask, - self._blur_kernel, - passes=self._blur_passes).blurred - if self._sub_crop_size: # Crop the mask to the given centering - out = np.zeros((self._sub_crop_size, self._sub_crop_size, 1), dtype=mask.dtype) - slice_in, slice_out = self._sub_crop_slices["in"], self._sub_crop_slices["out"] - out[slice_out[0], slice_out[1], :] = mask[slice_in[0], slice_in[1], :] - mask = out - logger.trace("mask shape: %s", mask.shape) # type:ignore[attr-defined] - return mask - - @property - def stored_mask(self) -> np.ndarray: - """ :class:`numpy.ndarray`: The mask at the size of :attr:`stored_size` as it is stored - (i.e. with no blurring/centering applied). """ - assert self._mask is not None - dims = (self.stored_size, self.stored_size, 1) - mask = np.frombuffer(decompress(self._mask), dtype="uint8").reshape(dims) - logger.trace("stored mask shape: %s", mask.shape) # type:ignore[attr-defined] - return mask - - @property - def original_roi(self) -> np.ndarray: - """ :class: `numpy.ndarray`: The original region of interest of the mask in the - source frame. """ - points = np.array([[0, 0], - [0, self.stored_size - 1], - [self.stored_size - 1, self.stored_size - 1], - [self.stored_size - 1, 0]], np.int32).reshape((-1, 1, 2)) - matrix = cv2.invertAffineTransform(self.affine_matrix) - roi = cv2.transform(points, matrix).reshape((4, 2)) - logger.trace("Returning: %s", roi) # type:ignore[attr-defined] - return roi - - @property - def affine_matrix(self) -> np.ndarray: - """ :class: `numpy.ndarray`: The affine matrix to transpose the mask to a full frame. """ - assert self._affine_matrix is not None - return self._affine_matrix - - @property - def interpolator(self) -> int: - """ int: The cv2 interpolator required to transpose the mask to a full frame. """ - assert self._interpolator is not None - return self._interpolator - - def _dilate_mask(self, mask: np.ndarray) -> None: - """ Erode/Dilate the mask. The action is performed in-place on the given mask. - - No action is performed if a dilation amount has not been set - - Parameters - ---------- - mask: :class:`numpy.ndarray` - The mask to be eroded/dilated - """ - if self._dilation[-1] is None: - return - - func = cv2.erode if self._dilation[0] == "erode" else cv2.dilate - func(mask, self._dilation[-1], dst=mask, iterations=1) - - def get_full_frame_mask(self, width: int, height: int) -> np.ndarray: - """ Return the stored mask in a full size frame of the given dimensions - - Parameters - ---------- - width: int - The width of the original frame that the mask was extracted from - height: int - The height of the original frame that the mask was extracted from - - Returns - ------- - :class:`numpy.ndarray`: The mask affined to the original full frame of the given dimensions - """ - frame = np.zeros((width, height, 1), dtype="uint8") - mask = cv2.warpAffine(self.mask, - self.affine_matrix, - (width, height), - frame, - flags=cv2.WARP_INVERSE_MAP | self.interpolator, - borderMode=cv2.BORDER_CONSTANT) - logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, " # type:ignore[attr-defined] - "mask max: %s", mask.shape, mask.dtype, mask.min(), mask.max()) - return mask - - def add(self, mask: np.ndarray, affine_matrix: np.ndarray, interpolator: int) -> None: - """ Add a Faceswap mask to this :class:`Mask`. - - The mask should be the original output from :mod:`plugins.extract.mask` - - Parameters - ---------- - mask: :class:`numpy.ndarray` - The mask that is to be added as output from :mod:`plugins.extract.mask` - It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` - affine_matrix: :class:`numpy.ndarray` - The transformation matrix required to transform the mask to the original frame. - interpolator, int: - The CV2 interpolator required to transform this mask to it's original frame - """ - logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, " # type:ignore[attr-defined] - "mask max: %s, affine_matrix: %s, interpolator: %s)", - mask.shape, mask.dtype, mask.min(), affine_matrix, mask.max(), interpolator) - self._affine_matrix = self._adjust_affine_matrix(mask.shape[0], affine_matrix) - self._interpolator = interpolator - self.replace_mask(mask) - - def replace_mask(self, mask: np.ndarray) -> None: - """ Replace the existing :attr:`_mask` with the given mask. - - Parameters - ---------- - mask: :class:`numpy.ndarray` - The mask that is to be added as output from :mod:`plugins.extract.mask`. - It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` - """ - mask = (cv2.resize(mask * 255.0, - (self.stored_size, self.stored_size), - interpolation=cv2.INTER_AREA)).astype("uint8") - self._mask = compress(mask.tobytes()) - - def set_dilation(self, amount: float) -> None: - """ Set the internal dilation object for returned masks - - Parameters - ---------- - amount: float - The amount of erosion/dilation to apply as a percentage of the total mask size. - Negative values erode the mask. Positive values dilate the mask - """ - if amount == 0: - self._dilation = ("erode", None) - return - - action: T.Literal["erode", "dilate"] = "erode" if amount < 0 else "dilate" - kernel = int(round(self.stored_size * abs(amount / 100.), 0)) - self._dilation = (action, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel, kernel))) - - logger.trace("action: '%s', amount: %s, kernel: %s, ", # type:ignore[attr-defined] - action, amount, kernel) - - def set_blur_and_threshold(self, - blur_kernel: int = 0, - blur_type: T.Literal["gaussian", "normalized"] | None = "gaussian", - blur_passes: int = 1, - threshold: int = 0) -> None: - """ Set the internal blur kernel and threshold amount for returned masks - - Parameters - ---------- - blur_kernel: int, optional - The kernel size, in pixels to apply gaussian blurring to the mask. Set to 0 for no - blurring. Should be odd, if an even number is passed in (outside of 0) then it is - rounded up to the next odd number. Default: 0 - blur_type: ["gaussian", "normalized"], optional - The blur type to use. ``gaussian`` or ``normalized`` box filter. Default: ``gaussian`` - blur_passes: int, optional - The number of passed to perform when blurring. Default: 1 - threshold: int, optional - The threshold amount to minimize/maximize mask values to 0 and 100. Percentage value. - Default: 0 - """ - logger.trace("blur_kernel: %s, blur_type: %s, " # type:ignore[attr-defined] - "blur_passes: %s, threshold: %s", - blur_kernel, blur_type, blur_passes, threshold) - if blur_type is not None: - blur_kernel += 0 if blur_kernel == 0 or blur_kernel % 2 == 1 else 1 - self._blur_kernel = blur_kernel - self._blur_type = blur_type - self._blur_passes = blur_passes - self._threshold = (threshold / 100.0) * 255.0 - - def set_sub_crop(self, - source_offset: np.ndarray, - target_offset: np.ndarray, - centering: CenteringType, - coverage_ratio: float = 1.0) -> None: - """ Set the internal crop area of the mask to be returned. - - This impacts the returned mask from :attr:`mask` if the requested mask is required for - different face centering than what has been stored. - - Parameters - ---------- - source_offset: :class:`numpy.ndarray` - The (x, y) offset for the mask at its stored centering - target_offset: :class:`numpy.ndarray` - The (x, y) offset for the mask at the requested target centering - centering: str - The centering to set the sub crop area for. One of `"legacy"`, `"face"`. `"head"` - coverage_ratio: float, optional - The coverage ratio to be applied to the target image. ``None`` for default (1.0). - Default: ``None`` - """ - if centering == self.stored_centering and coverage_ratio == 1.0: - return - - center = get_adjusted_center(self.stored_size, - source_offset, - target_offset, - self.stored_centering) - crop_size = get_centered_size(self.stored_centering, - centering, - self.stored_size, - coverage_ratio=coverage_ratio) - roi = np.array([center - crop_size // 2, center + crop_size // 2]).ravel() - - self._sub_crop_size = crop_size - self._sub_crop_slices["in"] = [slice(max(roi[1], 0), max(roi[3], 0)), - slice(max(roi[0], 0), max(roi[2], 0))] - self._sub_crop_slices["out"] = [ - slice(max(roi[1] * -1, 0), - crop_size - min(crop_size, max(0, roi[3] - self.stored_size))), - slice(max(roi[0] * -1, 0), - crop_size - min(crop_size, max(0, roi[2] - self.stored_size)))] - - logger.trace("src_size: %s, coverage_ratio: %s, " # type:ignore[attr-defined] - "sub_crop_size: %s, sub_crop_slices: %s", - roi, coverage_ratio, self._sub_crop_size, self._sub_crop_slices) - - def _adjust_affine_matrix(self, mask_size: int, affine_matrix: np.ndarray) -> np.ndarray: - """ Adjust the affine matrix for the mask's storage size - - Parameters - ---------- - mask_size: int - The original size of the mask. - affine_matrix: :class:`numpy.ndarray` - The affine matrix to transform the mask at original size to the parent frame. - - Returns - ------- - affine_matrix: :class:`numpy,ndarray` - The affine matrix adjusted for the mask at its stored dimensions. - """ - zoom = self.stored_size / mask_size - zoom_mat = np.array([[zoom, 0, 0.], [0, zoom, 0.]]) - adjust_mat = np.dot(zoom_mat, np.concatenate((affine_matrix, np.array([[0., 0., 1.]])))) - logger.trace("storage_size: %s, mask_size: %s, zoom: %s, " # type:ignore[attr-defined] - "original matrix: %s, adjusted_matrix: %s", self.stored_size, mask_size, zoom, - affine_matrix.shape, adjust_mat.shape) - return adjust_mat - - def to_dict(self, is_png=False) -> MaskAlignmentsFileDict: - """ Convert the mask to a dictionary for saving to an alignments file - - Parameters - ---------- - is_png: bool - ``True`` if the dictionary is being created for storage in a png header otherwise - ``False``. Default: ``False`` - - Returns - ------- - dict: - The :class:`Mask` for saving to an alignments file. Contains the keys ``mask``, - ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` - """ - assert self._mask is not None - affine_matrix = self.affine_matrix.tolist() if is_png else self.affine_matrix - retval = MaskAlignmentsFileDict(mask=self._mask, - affine_matrix=affine_matrix, - interpolator=self.interpolator, - stored_size=self.stored_size, - stored_centering=self.stored_centering) - logger.trace({k: v if k != "mask" else type(v) # type:ignore[attr-defined] - for k, v in retval.items()}) - return retval - - def to_png_meta(self) -> MaskAlignmentsFileDict: - """ Convert the mask to a dictionary supported by png itxt headers. - - Returns - ------- - dict: - The :class:`Mask` for saving to an alignments file. Contains the keys ``mask``, - ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` - """ - return self.to_dict(is_png=True) - - def from_dict(self, mask_dict: MaskAlignmentsFileDict) -> None: - """ Populates the :class:`Mask` from a dictionary loaded from an alignments file. - - Parameters - ---------- - mask_dict: dict - A dictionary stored in an alignments file containing the keys ``mask``, - ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` - """ - self._mask = mask_dict["mask"] - affine_matrix = mask_dict["affine_matrix"] - self._affine_matrix = (affine_matrix if isinstance(affine_matrix, np.ndarray) - else np.array(affine_matrix, dtype="float64")) - self._interpolator = mask_dict["interpolator"] - self.stored_size = mask_dict["stored_size"] - centering = mask_dict.get("stored_centering") - self.stored_centering = "face" if centering is None else centering - logger.trace({k: v if k != "mask" else type(v) # type:ignore[attr-defined] - for k, v in mask_dict.items()}) - - -class LandmarksMask(Mask): - """ Create a single channel mask from aligned landmark points. - - Landmarks masks are created on the fly, so the stored centering and size should be the same as - the aligned face that the mask will be applied to. As the masks are created on the fly, blur + - dilation is applied to the mask at creation (prior to compression) rather than after - decompression when requested. - - Note - ---- - Threshold is not used for Landmarks mask as the mask is binary - - Parameters - ---------- - points: list - A list of landmark points that correspond to the given storage_size to create - the mask. Each item in the list should be a :class:`numpy.ndarray` that a filled - convex polygon will be created from - storage_size: int, optional - The size (in pixels) that the compressed mask should be stored at. Default: 128. - storage_centering, str (optional): - The centering to store the mask at. One of `"legacy"`, `"face"`, `"head"`. - Default: `"face"` - dilation: float, optional - The amount of dilation to apply to the mask. as a percentage of the mask size. Default: 0.0 - """ - def __init__(self, - points: list[np.ndarray], - storage_size: int = 128, - storage_centering: CenteringType = "face", - dilation: float = 0.0) -> None: - super().__init__(storage_size=storage_size, storage_centering=storage_centering) - self._points = points - self.set_dilation(dilation) - - @property - def mask(self) -> np.ndarray: - """ :class:`numpy.ndarray`: Overrides the default mask property, creating the processed - mask at first call and compressing it. The decompressed mask is returned from this - property. """ - return self.stored_mask - - def generate_mask(self, affine_matrix: np.ndarray, interpolator: int) -> None: - """ Generate the mask. - - Creates the mask applying any requested dilation and blurring and assigns compressed mask - to :attr:`_mask` - - Parameters - ---------- - affine_matrix: :class:`numpy.ndarray` - The transformation matrix required to transform the mask to the original frame. - interpolator, int: - The CV2 interpolator required to transform this mask to it's original frame - """ - mask = np.zeros((self.stored_size, self.stored_size, 1), dtype="float32") - for landmarks in self._points: - lms = np.rint(landmarks).astype("int") - cv2.fillConvexPoly(mask, cv2.convexHull(lms), [1.0], lineType=cv2.LINE_AA) - if self._dilation[-1] is not None: - self._dilate_mask(mask) - if self._blur_kernel != 0 and self._blur_type is not None: - mask = BlurMask(self._blur_type, - mask, - self._blur_kernel, - passes=self._blur_passes).blurred - logger.trace("mask: (shape: %s, dtype: %s)", # type:ignore[attr-defined] - mask.shape, mask.dtype) - self.add(mask, affine_matrix, interpolator) - - -class BlurMask(): - """ Factory class to return the correct blur object for requested blur type. - - Works for square images only. Currently supports Gaussian and Normalized Box Filters. - - Parameters - ---------- - blur_type: ["gaussian", "normalized"] - The type of blur to use - mask: :class:`numpy.ndarray` - The mask to apply the blur to - kernel: int or float - Either the kernel size (in pixels) or the size of the kernel as a ratio of mask size - is_ratio: bool, optional - Whether the given :attr:`kernel` parameter is a ratio or not. If ``True`` then the - actual kernel size will be calculated from the given ratio and the mask size. If - ``False`` then the kernel size will be set directly from the :attr:`kernel` parameter. - Default: ``False`` - passes: int, optional - The number of passes to perform when blurring. Default: ``1`` - - Example - ------- - >>> print(mask.shape) - (128, 128, 1) - >>> new_mask = BlurMask("gaussian", mask, 3, is_ratio=False, passes=1).blurred - >>> print(new_mask.shape) - (128, 128, 1) - """ - def __init__(self, - blur_type: T.Literal["gaussian", "normalized"], - mask: np.ndarray, - kernel: int | float, - is_ratio: bool = False, - passes: int = 1) -> None: - logger.trace("Initializing %s: (blur_type: '%s', " # type:ignore[attr-defined] - "mask_shape: %s, kernel: %s, is_ratio: %s, passes: %s)", - self.__class__.__name__, blur_type, - mask.shape, kernel, is_ratio, passes) - self._blur_type = blur_type - self._mask = mask - self._passes = passes - kernel_size = self._get_kernel_size(kernel, is_ratio) - self._kernel_size = self._get_kernel_tuple(kernel_size) - logger.trace("Initialized %s", self.__class__.__name__) # type:ignore[attr-defined] - - @property - def blurred(self) -> np.ndarray: - """ :class:`numpy.ndarray`: The final mask with blurring applied. """ - func = self._func_mapping[self._blur_type] - kwargs = self._get_kwargs() - blurred = self._mask - for i in range(self._passes): - assert isinstance(kwargs["ksize"], tuple) - ksize = int(kwargs["ksize"][0]) - logger.trace("Pass: %s, kernel_size: %s", # type:ignore[attr-defined] - i + 1, (ksize, ksize)) - blurred = func(blurred, **kwargs) - ksize = int(round(ksize * self._multipass_factor)) - kwargs["ksize"] = self._get_kernel_tuple(ksize) - blurred = blurred[..., None] - logger.trace("Returning blurred mask. Shape: %s", # type:ignore[attr-defined] - blurred.shape) - return blurred - - @property - def _multipass_factor(self) -> float: - """ For multiple passes the kernel must be scaled down. This value is - different for box filter and gaussian """ - factor = {"gaussian": 0.8, "normalized": 0.5} - return factor[self._blur_type] - - @property - def _sigma(self) -> T.Literal[0]: - """ int: The Sigma for Gaussian Blur. Returns 0 to force calculation from kernel size. """ - return 0 - - @property - def _func_mapping(self) -> dict[T.Literal["gaussian", "normalized"], Callable]: - """ dict: :attr:`_blur_type` mapped to cv2 Function name. """ - return {"gaussian": cv2.GaussianBlur, "normalized": cv2.blur} - - @property - def _kwarg_requirements(self) -> dict[T.Literal["gaussian", "normalized"], list[str]]: - """ dict: :attr:`_blur_type` mapped to cv2 Function required keyword arguments. """ - return {"gaussian": ['ksize', 'sigmaX'], "normalized": ['ksize']} - - @property - def _kwarg_mapping(self) -> dict[str, int | tuple[int, int]]: - """ dict: cv2 function keyword arguments mapped to their parameters. """ - return {"ksize": self._kernel_size, "sigmaX": self._sigma} - - def _get_kernel_size(self, kernel: int | float, is_ratio: bool) -> int: - """ Set the kernel size to absolute value. - - If :attr:`is_ratio` is ``True`` then the kernel size is calculated from the given ratio and - the :attr:`_mask` size, otherwise the given kernel size is just returned. - - Parameters - ---------- - kernel: int or float - Either the kernel size (in pixels) or the size of the kernel as a ratio of mask size - is_ratio: bool, optional - Whether the given :attr:`kernel` parameter is a ratio or not. If ``True`` then the - actual kernel size will be calculated from the given ratio and the mask size. If - ``False`` then the kernel size will be set directly from the :attr:`kernel` parameter. - - Returns - ------- - int - The size (in pixels) of the blur kernel - """ - if not is_ratio: - return int(kernel) - - mask_diameter = np.sqrt(np.sum(self._mask)) - radius = round(max(1., mask_diameter * kernel / 100.)) - kernel_size = int(radius * 2 + 1) - logger.trace("kernel_size: %s", kernel_size) # type:ignore[attr-defined] - return kernel_size - - @staticmethod - def _get_kernel_tuple(kernel_size: int) -> tuple[int, int]: - """ Make sure kernel_size is odd and return it as a tuple. - - Parameters - ---------- - kernel_size: int - The size in pixels of the blur kernel - - Returns - ------- - tuple - The kernel size as a tuple of ('int', 'int') - """ - kernel_size += 1 if kernel_size % 2 == 0 else 0 - retval = (kernel_size, kernel_size) - logger.trace(retval) # type:ignore[attr-defined] - return retval - - def _get_kwargs(self) -> dict[str, int | tuple[int, int]]: - """ dict: the valid keyword arguments for the requested :attr:`_blur_type` """ - retval = {kword: self._kwarg_mapping[kword] - for kword in self._kwarg_requirements[self._blur_type]} - logger.trace("BlurMask kwargs: %s", retval) # type:ignore[attr-defined] - return retval - - _HASHES_SEEN: dict[str, dict[str, int]] = {} diff --git a/tools/manual/frameviewer/editor/__init__.py b/tools/manual/frameviewer/editor/__init__.py index 8a7244abe4..7902b47227 100644 --- a/tools/manual/frameviewer/editor/__init__.py +++ b/tools/manual/frameviewer/editor/__init__.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """ The Frame Viewer for Faceswap's Manual Tool. """ -from ._base import View # noqa -from .bounding_box import BoundingBox # noqa -from .extract_box import ExtractBox # noqa -from .landmarks import Landmarks, Mesh # noqa -from .mask import Mask # noqa +from ._base import View +from .bounding_box import BoundingBox +from .extract_box import ExtractBox +from .landmarks import Landmarks, Mesh +from .mask import Mask diff --git a/tools/manual/manual.py b/tools/manual/manual.py index c31683e3ee..46685766bc 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -27,8 +27,7 @@ from .thumbnails import ThumbsCreator if T.TYPE_CHECKING: - from lib.align import DetectedFace - from lib.align.detected_face import Mask + from lib.align import DetectedFace, Mask from lib.queue_manager import EventQueue logger = logging.getLogger(__name__) @@ -819,7 +818,7 @@ def get_masks(self, frame_index: int, face_index: int) -> dict[str, Mask]: Returns ------- - dict[str, :class:`~lib.align.detected_face.Mask`] + dict[str, :class:`~lib.align.aligned_mask.Mask`] The updated masks """ logger.trace("frame_index: %s, face_index: %s", # type:ignore[attr-defined] From dce7d9830272a5a64ba35d8c1c99859a216436c8 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 19 Apr 2024 13:45:30 +0100 Subject: [PATCH 903/981] lib.align: Split lib.align.alignments to smaller modules: - Move update objects to own module - Move Thumbnails to own module - docs update + linting/typing --- docs/full/lib/align.rst | 21 ++ lib/align/alignments.py | 467 +++---------------------------------- lib/align/detected_face.py | 3 +- lib/align/thumbnails.py | 81 +++++++ lib/align/updater.py | 365 +++++++++++++++++++++++++++++ 5 files changed, 499 insertions(+), 438 deletions(-) create mode 100644 lib/align/thumbnails.py create mode 100644 lib/align/updater.py diff --git a/docs/full/lib/align.rst b/docs/full/lib/align.rst index 7baf843e23..feebc7af4a 100644 --- a/docs/full/lib/align.rst +++ b/docs/full/lib/align.rst @@ -41,6 +41,7 @@ Handles aligned storage and retrieval of Faceswap generated masks :nosignatures: ~lib.align.aligned_mask.BlurMask + ~lib.align.aligned_mask.LandmarksMask ~lib.align.aligned_mask.Mask .. rubric:: Module @@ -111,3 +112,23 @@ Handles pose estimates based on aligned face data :members: :undoc-members: :show-inheritance: + + +thumbnails module +================= +Handles creation of jpg thumbnails for storage in alignment files/png headers + +.. automodule:: lib.align.thumbnails + :members: + :undoc-members: + :show-inheritance: + + +updater module +============== +Handles the update of alignments files to the latest version + +.. automodule:: lib.align.updater + :members: + :undoc-members: + :show-inheritance: diff --git a/lib/align/alignments.py b/lib/align/alignments.py index e7e3519512..0901625c64 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -10,7 +10,11 @@ import numpy as np from lib.serializer import get_serializer, get_serializer_from_filename -from lib.utils import FaceswapError, VIDEO_EXTENSIONS +from lib.utils import FaceswapError + +from .thumbnails import Thumbnails +from .updater import (FileStructure, IdentityAndVideoMeta, LandmarkRename, Legacy, ListToNumpy, + MaskCentering, VideoExtension) if T.TYPE_CHECKING: from collections.abc import Generator @@ -105,7 +109,7 @@ def __init__(self, folder: str, filename: str = "alignments") -> None: self._data = self._load() self._io.update_legacy() - self._legacy = _Legacy(self) + self._legacy = Legacy(self) self._thumbnails = Thumbnails(self) logger.debug("Initialized %s", self.__class__.__name__) @@ -115,14 +119,14 @@ def __init__(self, folder: str, filename: str = "alignments") -> None: def frames_count(self) -> int: """ int: The number of frames that appear in the alignments :attr:`data`. """ retval = len(self._data) - logger.trace(retval) # type:ignore + logger.trace(retval) # type:ignore[attr-defined] return retval @property def faces_count(self) -> int: """ int: The total number of faces that appear in the alignments :attr:`data`. """ retval = sum(len(val["faces"]) for val in self._data.values()) - logger.trace(retval) # type:ignore + logger.trace(retval) # type:ignore[attr-defined] return retval @property @@ -196,9 +200,9 @@ def video_meta_data(self) -> dict[str, list[int] | list[float] | None]: return retval @property - def thumbnails(self) -> "Thumbnails": - """ :class:`~lib.align.Thumbnails`: The low resolution thumbnail images that exist - within the alignments file """ + def thumbnails(self) -> Thumbnails: + """ :class:`~lib.align.thumbnails.Thumbnails`: The low resolution thumbnail images that + exist within the alignments file """ return self._thumbnails @property @@ -339,7 +343,7 @@ def frame_exists(self, frame_name: str) -> bool: otherwise ``False`` """ retval = frame_name in self._data.keys() - logger.trace("'%s': %s", frame_name, retval) # type:ignore + logger.trace("'%s': %s", frame_name, retval) # type:ignore[attr-defined] return retval def frame_has_faces(self, frame_name: str) -> bool: @@ -359,7 +363,7 @@ def frame_has_faces(self, frame_name: str) -> bool: """ frame_data = self._data.get(frame_name, T.cast(AlignmentDict, {})) retval = bool(frame_data.get("faces", [])) - logger.trace("'%s': %s", frame_name, retval) # type:ignore + logger.trace("'%s': %s", frame_name, retval) # type:ignore[attr-defined] return retval def frame_has_multiple_faces(self, frame_name: str) -> bool: @@ -383,7 +387,7 @@ def frame_has_multiple_faces(self, frame_name: str) -> bool: else: frame_data = self._data.get(frame_name, T.cast(AlignmentDict, {})) retval = bool(len(frame_data.get("faces", [])) > 1) - logger.trace("'%s': %s", frame_name, retval) # type:ignore + logger.trace("'%s': %s", frame_name, retval) # type:ignore[attr-defined] return retval def mask_is_valid(self, mask_type: str) -> bool: @@ -425,7 +429,7 @@ def get_faces_in_frame(self, frame_name: str) -> list[AlignmentFileDict]: list The list of face dictionaries that appear within the requested frame_name """ - logger.trace("Getting faces for frame_name: '%s'", frame_name) # type:ignore + logger.trace("Getting faces for frame_name: '%s'", frame_name) # type:ignore[attr-defined] frame_data = self._data.get(frame_name, T.cast(AlignmentDict, {})) return frame_data.get("faces", T.cast(list[AlignmentFileDict], [])) @@ -445,7 +449,7 @@ def count_faces_in_frame(self, frame_name: str) -> int: """ frame_data = self._data.get(frame_name, T.cast(AlignmentDict, {})) retval = len(frame_data.get("faces", [])) - logger.trace(retval) # type:ignore + logger.trace(retval) # type:ignore[attr-defined] return retval # << MANIPULATION >> # @@ -538,11 +542,12 @@ def filter_faces(self, filter_dict: dict[str, list[int]], filter_out: bool = Fal else: filter_list = [idx for idx in range(len(frame_data["faces"])) if idx not in face_indices] - logger.trace("frame: '%s', filter_list: %s", source_frame, filter_list) # type:ignore + logger.trace("frame: '%s', filter_list: %s", # type:ignore[attr-defined] + source_frame, filter_list) for face_idx in reversed(sorted(filter_list)): - logger.verbose("Filtering out face: (filename: %s, index: %s)", # type:ignore - source_frame, face_idx) + logger.verbose( # type:ignore[attr-defined] + "Filtering out face: (filename: %s, index: %s)", source_frame, face_idx) del frame_data["faces"][face_idx] def update_from_dict(self, data: dict[str, AlignmentDict]) -> None: @@ -581,8 +586,9 @@ def yield_faces(self) -> Generator[tuple[str, list[AlignmentFileDict], int, str] for frame_fullname, val in self._data.items(): frame_name = os.path.splitext(frame_fullname)[0] face_count = len(val["faces"]) - logger.trace("Yielding: (frame: '%s', faces: %s, frame_fullname: '%s')", # type:ignore - frame_name, face_count, frame_fullname) + logger.trace( # type:ignore[attr-defined] + "Yielding: (frame: '%s', faces: %s, frame_fullname: '%s')", + frame_name, face_count, frame_fullname) yield frame_name, val["faces"], face_count, frame_fullname def update_legacy_has_source(self, filename: str) -> None: @@ -595,7 +601,7 @@ def update_legacy_has_source(self, filename: str) -> None: filename: str: The filename/folder of the original source images/video for the current alignments """ - updates = [updater.is_updated for updater in (_VideoExtension(self, filename), )] + updates = [updater.is_updated for updater in (VideoExtension(self, filename), )] if any(updates): self._io.update_version() self.save() @@ -635,7 +641,7 @@ def have_alignments_file(self) -> bool: """ bool: ``True`` if an alignments file exists at location :attr:`file` otherwise ``False``. """ retval = os.path.exists(self._file) - logger.trace(retval) # type:ignore + logger.trace(retval) # type:ignore[attr-defined] return retval def _update_file_format(self, folder: str, filename: str) -> str: @@ -723,17 +729,17 @@ def _get_location(self, folder: str, filename: str) -> str: # executed if an alignments file has not been explicitly provided therefore it will not # have been picked up in the extension test self._test_for_legacy(location) - logger.verbose("Alignments filepath: '%s'", location) # type:ignore + logger.verbose("Alignments filepath: '%s'", location) # type:ignore[attr-defined] return location def update_legacy(self) -> None: """ Check whether the alignments are legacy, and if so update them to current alignments format. """ - updates = [updater.is_updated for updater in (_FileStructure(self._alignments), - _LandmarkRename(self._alignments), - _ListToNumpy(self._alignments), - _MaskCentering(self._alignments), - _IdentityAndVideoMeta(self._alignments))] + updates = [updater.is_updated for updater in (FileStructure(self._alignments), + LandmarkRename(self._alignments), + ListToNumpy(self._alignments), + MaskCentering(self._alignments), + IdentityAndVideoMeta(self._alignments))] if any(updates): self.update_version() self.save() @@ -794,414 +800,3 @@ def backup(self) -> None: logger.info("Backing up original alignments to '%s'", dst) os.rename(src, dst) logger.debug("Backed up alignments") - - -class Thumbnails(): - """ Thumbnail images stored in the alignments file. - - The thumbnails are stored as low resolution (64px), low quality jpg in the alignments file - and are used for the Manual Alignments tool. - - Parameters - ---------- - alignments: :class:'~lib.align.Alignments` - The parent alignments class that these thumbs belong to - """ - def __init__(self, alignments: Alignments) -> None: - logger.debug("Initializing %s: (alignments: %s)", self.__class__.__name__, alignments) - self._alignments_dict = alignments.data - self._frame_list = list(sorted(self._alignments_dict)) - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def has_thumbnails(self) -> bool: - """ bool: ``True`` if all faces in the alignments file contain thumbnail images - otherwise ``False``. """ - retval = all(np.any(face.get("thumb")) # type:ignore # numpy complaining about ``None`` - for frame in self._alignments_dict.values() - for face in frame["faces"]) - logger.trace(retval) # type:ignore - return retval - - def get_thumbnail_by_index(self, frame_index: int, face_index: int) -> np.ndarray: - """ Obtain a jpg thumbnail from the given frame index for the given face index - - Parameters - ---------- - frame_index: int - The frame index that contains the thumbnail - face_index: int - The face index within the frame to retrieve the thumbnail for - - Returns - ------- - :class:`numpy.ndarray` - The encoded jpg thumbnail - """ - retval = self._alignments_dict[self._frame_list[frame_index]]["faces"][face_index]["thumb"] - assert retval is not None - logger.trace("frame index: %s, face_index: %s, thumb shape: %s", # type:ignore - frame_index, face_index, retval.shape) - return retval - - def add_thumbnail(self, frame: str, face_index: int, thumb: np.ndarray) -> None: - """ Add a thumbnail for the given face index for the given frame. - - Parameters - ---------- - frame: str - The name of the frame to add the thumbnail for - face_index: int - The face index within the given frame to add the thumbnail for - thumb: :class:`numpy.ndarray` - The encoded jpg thumbnail at 64px to add to the alignments file - """ - logger.debug("frame: %s, face_index: %s, thumb shape: %s thumb dtype: %s", - frame, face_index, thumb.shape, thumb.dtype) - self._alignments_dict[frame]["faces"][face_index]["thumb"] = thumb - - -class _Updater(): - """ Base class for inheriting to test for and update of an alignments file property - - Parameters - ---------- - alignments: :class:`~Alignments` - The alignments object that is being tested and updated - """ - def __init__(self, alignments: Alignments) -> None: - self._alignments = alignments - self._needs_update = self._test() - if self._needs_update: - self._update() - - @property - def is_updated(self) -> bool: - """ bool. ``True`` if this updater has been run otherwise ``False`` """ - return self._needs_update - - def _test(self) -> bool: - """ Calls the child's :func:`test` method and logs output - - Returns - ------- - bool - ``True`` if the test condition is met otherwise ``False`` - """ - logger.debug("checking %s", self.__class__.__name__) - retval = self.test() - logger.debug("legacy %s: %s", self.__class__.__name__, retval) - return retval - - def test(self) -> bool: - """ Override to set the condition to test for. - - Returns - ------- - bool - ``True`` if the test condition is met otherwise ``False`` - """ - raise NotImplementedError() - - def _update(self) -> int: - """ Calls the child's :func:`update` method, logs output and sets the - :attr:`is_updated` flag - - Returns - ------- - int - The number of items that were updated - """ - retval = self.update() - logger.debug("Updated %s: %s", self.__class__.__name__, retval) - return retval - - def update(self) -> int: - """ Override to set the action to perform on the alignments object if the test has - passed - - Returns - ------- - int - The number of items that were updated - """ - raise NotImplementedError() - - -class _VideoExtension(_Updater): - """ Alignments files from video files used to have a dummy '.png' extension for each of the - keys. This has been changed to be file extension of the original input video (for better) - identification of alignments files generated from video files - - Parameters - ---------- - alignments: :class:`~Alignments` - The alignments object that is being tested and updated - video_filename: str - The video filename that holds these alignments - """ - def __init__(self, alignments: Alignments, video_filename: str) -> None: - self._video_name, self._extension = os.path.splitext(video_filename) - super().__init__(alignments) - - def test(self) -> bool: - """ Requires update if alignments version is < 2.4 - - Returns - ------- - bool - ``True`` if the key extensions need updating otherwise ``False`` - """ - retval = self._alignments.version < 2.4 and self._extension in VIDEO_EXTENSIONS - logger.debug("Needs update for video extension: %s (version: %s, extension: %s)", - retval, self._alignments.version, self._extension) - return retval - - def update(self) -> int: - """ Update alignments files that have been extracted from videos to have the key end in the - video file extension rather than ',png' (the old way) - - Parameters - ---------- - video_filename: str - The filename of the video file that created these alignments - """ - updated = 0 - for key in list(self._alignments.data): - val = self._alignments.data[key] - fname = os.path.splitext(key)[0] - if fname.rsplit("_")[0] != self._video_name: - continue # Key is from a different source - - new_key = f"{fname}{self._extension}" - del self._alignments.data[key] - self._alignments.data[new_key] = val - updated += 1 - - logger.debug("Updated alignemnt keys for video extension: %s", updated) - return updated - - -class _FileStructure(_Updater): - """ Alignments were structured: {frame_name: }. We need to be able to store - information at the frame level, so new structure is: {frame_name: {faces: }} - """ - def test(self) -> bool: - """ Test whether the alignments file is laid out in the old structure of - `{frame_name: [faces]}` - - Returns - ------- - bool - ``True`` if the file has legacy structure otherwise ``False`` - """ - return any(isinstance(val, list) for val in self._alignments.data.values()) - - def update(self) -> int: - """ Update legacy alignments files from the format `{frame_name: [faces}` to the - format `{frame_name: {faces: [faces]}`. - - Returns - ------- - int - The number of items that were updated - """ - updated = 0 - for key, val in self._alignments.data.items(): - if not isinstance(val, list): - continue - self._alignments.data[key] = {"faces": val} - updated += 1 - return updated - - -class _LandmarkRename(_Updater): - """ Landmarks renamed from landmarksXY to landmarks_xy for PEP compliance """ - def test(self) -> bool: - """ check for legacy landmarksXY keys. - - Returns - ------- - bool - ``True`` if the alignments file contains legacy `landmarksXY` keys otherwise ``False`` - """ - return (any(key == "landmarksXY" - for val in self._alignments.data.values() - for alignment in val["faces"] - for key in alignment)) - - def update(self) -> int: - """ Update legacy `landmarksXY` keys to PEP compliant `landmarks_xy` keys. - - Returns - ------- - int - The number of landmarks keys that were changed - """ - update_count = 0 - for val in self._alignments.data.values(): - for alignment in val["faces"]: - if "landmarksXY" in alignment: - alignment["landmarks_xy"] = alignment.pop("landmarksXY") # type:ignore - update_count += 1 - return update_count - - -class _ListToNumpy(_Updater): - """ Landmarks stored as list instead of numpy array """ - def test(self) -> bool: - """ check for legacy landmarks stored as `list` rather than :class:`numpy.ndarray`. - - Returns - ------- - bool - ``True`` if not all landmarks are :class:`numpy.ndarray` otherwise ``False`` - """ - return not all(isinstance(face["landmarks_xy"], np.ndarray) - for val in self._alignments.data.values() - for face in val["faces"]) - - def update(self) -> int: - """ Update landmarks stored as `list` to :class:`numpy.ndarray`. - - Returns - ------- - int - The number of landmarks keys that were changed - """ - update_count = 0 - for val in self._alignments.data.values(): - for alignment in val["faces"]: - test = alignment["landmarks_xy"] - if not isinstance(test, np.ndarray): - alignment["landmarks_xy"] = np.array(test, dtype="float32") - update_count += 1 - return update_count - - -class _MaskCentering(_Updater): - """ Masks not containing the stored_centering parameters. Prior to this implementation all - masks were stored with face centering """ - - def test(self) -> bool: - """ Mask centering was introduced in alignments version 2.2 - - Returns - ------- - bool - ``True`` mask centering requires updating otherwise ``False`` - """ - return self._alignments.version < 2.2 - - def update(self) -> int: - """ Add the mask key to the alignment file and update the centering of existing masks - - Returns - ------- - int - The number of masks that were updated - """ - update_count = 0 - for val in self._alignments.data.values(): - for alignment in val["faces"]: - if "mask" not in alignment: - alignment["mask"] = {} - for mask in alignment["mask"].values(): - mask["stored_centering"] = "face" - update_count += 1 - return update_count - - -class _IdentityAndVideoMeta(_Updater): - """ Prior to version 2.3 the identity key did not exist and the video_meta key was not - compulsory. These should now both always appear, but do not need to be populated. """ - - def test(self) -> bool: - """ Identity Key was introduced in alignments version 2.3 - - Returns - ------- - bool - ``True`` identity key needs inserting otherwise ``False`` - """ - return self._alignments.version < 2.3 - - # Identity information was not previously stored in the alignments file. - def update(self) -> int: - """ Add the video_meta and identity keys to the alignment file and leave empty - - Returns - ------- - int - The number of keys inserted - """ - update_count = 0 - for val in self._alignments.data.values(): - this_update = 0 - if "video_meta" not in val: - val["video_meta"] = {} - this_update = 1 - for alignment in val["faces"]: - if "identity" not in alignment: - alignment["identity"] = {} - this_update = 1 - update_count += this_update - return update_count - - -class _Legacy(): - """ Legacy alignments properties that are no longer used, but are still required for backwards - compatibility/upgrading reasons. - - Parameters - ---------- - alignments: :class:`~Alignments` - The alignments object that requires these legacy properties - """ - def __init__(self, alignments: Alignments) -> None: - self._alignments = alignments - self._hashes_to_frame: dict[str, dict[str, int]] = {} - self._hashes_to_alignment: dict[str, AlignmentFileDict] = {} - - @property - def hashes_to_frame(self) -> dict[str, dict[str, int]]: - """ dict: The SHA1 hash of the face mapped to the frame(s) and face index within the frame - that the hash corresponds to. The structure of the dictionary is: - - {**SHA1_hash** (`str`): {**filename** (`str`): **face_index** (`int`)}}. - - Notes - ----- - This method is deprecated and exists purely for updating legacy hash based alignments - to new png header storage in :class:`lib.align.update_legacy_png_header`. - - The first time this property is referenced, the dictionary will be created and cached. - Subsequent references will be made to this cached dictionary. - """ - if not self._hashes_to_frame: - logger.debug("Generating hashes to frame") - for frame_name, val in self._alignments.data.items(): - for idx, face in enumerate(val["faces"]): - self._hashes_to_frame.setdefault( - face["hash"], {})[frame_name] = idx # type:ignore - return self._hashes_to_frame - - @property - def hashes_to_alignment(self) -> dict[str, AlignmentFileDict]: - """ dict: The SHA1 hash of the face mapped to the alignment for the face that the hash - corresponds to. The structure of the dictionary is: - - Notes - ----- - This method is deprecated and exists purely for updating legacy hash based alignments - to new png header storage in :class:`lib.align.update_legacy_png_header`. - - The first time this property is referenced, the dictionary will be created and cached. - Subsequent references will be made to this cached dictionary. - """ - if not self._hashes_to_alignment: - logger.debug("Generating hashes to alignment") - self._hashes_to_alignment = {face["hash"]: face # type:ignore - for val in self._alignments.data.values() - for face in val["faces"]} - return self._hashes_to_alignment diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index fb8ef37f98..efd4475ab2 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -87,8 +87,7 @@ def __init__(self, top: int | None = None, height: int | None = None, landmarks_xy: np.ndarray | None = None, - mask: dict[str, Mask] | None = None, - filename: str | None = None) -> None: + mask: dict[str, Mask] | None = None) -> None: logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] self.image = image self.left = left diff --git a/lib/align/thumbnails.py b/lib/align/thumbnails.py new file mode 100644 index 0000000000..d97801d1d9 --- /dev/null +++ b/lib/align/thumbnails.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" Handles the generation of thumbnail jpgs for storing inside an alignments file/png header """ +from __future__ import annotations + +import logging +import typing as T + +import numpy as np + +from lib.logger import parse_class_init + +if T.TYPE_CHECKING: + from .alignments import Alignments + +logger = logging.getLogger(__name__) + + +class Thumbnails(): + """ Thumbnail images stored in the alignments file. + + The thumbnails are stored as low resolution (64px), low quality jpg in the alignments file + and are used for the Manual Alignments tool. + + Parameters + ---------- + alignments: :class:'~lib.align.alignments.Alignments` + The parent alignments class that these thumbs belong to + """ + def __init__(self, alignments: Alignments) -> None: + logger.debug(parse_class_init(locals())) + self._alignments_dict = alignments.data + self._frame_list = list(sorted(self._alignments_dict)) + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def has_thumbnails(self) -> bool: + """ bool: ``True`` if all faces in the alignments file contain thumbnail images + otherwise ``False``. """ + retval = all(np.any(T.cast(np.ndarray, face.get("thumb"))) + for frame in self._alignments_dict.values() + for face in frame["faces"]) + logger.trace(retval) # type:ignore[attr-defined] + return retval + + def get_thumbnail_by_index(self, frame_index: int, face_index: int) -> np.ndarray: + """ Obtain a jpg thumbnail from the given frame index for the given face index + + Parameters + ---------- + frame_index: int + The frame index that contains the thumbnail + face_index: int + The face index within the frame to retrieve the thumbnail for + + Returns + ------- + :class:`numpy.ndarray` + The encoded jpg thumbnail + """ + retval = self._alignments_dict[self._frame_list[frame_index]]["faces"][face_index]["thumb"] + assert retval is not None + logger.trace( # type:ignore[attr-defined] + "frame index: %s, face_index: %s, thumb shape: %s", + frame_index, face_index, retval.shape) + return retval + + def add_thumbnail(self, frame: str, face_index: int, thumb: np.ndarray) -> None: + """ Add a thumbnail for the given face index for the given frame. + + Parameters + ---------- + frame: str + The name of the frame to add the thumbnail for + face_index: int + The face index within the given frame to add the thumbnail for + thumb: :class:`numpy.ndarray` + The encoded jpg thumbnail at 64px to add to the alignments file + """ + logger.debug("frame: %s, face_index: %s, thumb shape: %s thumb dtype: %s", + frame, face_index, thumb.shape, thumb.dtype) + self._alignments_dict[frame]["faces"][face_index]["thumb"] = thumb diff --git a/lib/align/updater.py b/lib/align/updater.py new file mode 100644 index 0000000000..d31d942494 --- /dev/null +++ b/lib/align/updater.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +""" Handles updating of an alignments file from an older version to the current version. """ +from __future__ import annotations + +import logging +import os +import typing as T + +import numpy as np + +from lib.logger import parse_class_init +from lib.utils import VIDEO_EXTENSIONS + +logger = logging.getLogger(__name__) + +if T.TYPE_CHECKING: + from .alignments import Alignments, AlignmentFileDict + + +class _Updater(): + """ Base class for inheriting to test for and update of an alignments file property + + Parameters + ---------- + alignments: :class:`~Alignments` + The alignments object that is being tested and updated + """ + def __init__(self, alignments: Alignments) -> None: + logger.debug(parse_class_init(locals())) + self._alignments = alignments + self._needs_update = self._test() + if self._needs_update: + self._update() + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def is_updated(self) -> bool: + """ bool. ``True`` if this updater has been run otherwise ``False`` """ + return self._needs_update + + def _test(self) -> bool: + """ Calls the child's :func:`test` method and logs output + + Returns + ------- + bool + ``True`` if the test condition is met otherwise ``False`` + """ + logger.debug("checking %s", self.__class__.__name__) + retval = self.test() + logger.debug("legacy %s: %s", self.__class__.__name__, retval) + return retval + + def test(self) -> bool: + """ Override to set the condition to test for. + + Returns + ------- + bool + ``True`` if the test condition is met otherwise ``False`` + """ + raise NotImplementedError() + + def _update(self) -> int: + """ Calls the child's :func:`update` method, logs output and sets the + :attr:`is_updated` flag + + Returns + ------- + int + The number of items that were updated + """ + retval = self.update() + logger.debug("Updated %s: %s", self.__class__.__name__, retval) + return retval + + def update(self) -> int: + """ Override to set the action to perform on the alignments object if the test has + passed + + Returns + ------- + int + The number of items that were updated + """ + raise NotImplementedError() + + +class VideoExtension(_Updater): + """ Alignments files from video files used to have a dummy '.png' extension for each of the + keys. This has been changed to be file extension of the original input video (for better) + identification of alignments files generated from video files + + Parameters + ---------- + alignments: :class:`~Alignments` + The alignments object that is being tested and updated + video_filename: str + The video filename that holds these alignments + """ + def __init__(self, alignments: Alignments, video_filename: str) -> None: + self._video_name, self._extension = os.path.splitext(video_filename) + super().__init__(alignments) + + def test(self) -> bool: + """ Requires update if alignments version is < 2.4 + + Returns + ------- + bool + ``True`` if the key extensions need updating otherwise ``False`` + """ + retval = self._alignments.version < 2.4 and self._extension in VIDEO_EXTENSIONS + logger.debug("Needs update for video extension: %s (version: %s, extension: %s)", + retval, self._alignments.version, self._extension) + return retval + + def update(self) -> int: + """ Update alignments files that have been extracted from videos to have the key end in the + video file extension rather than ',png' (the old way) + + Parameters + ---------- + video_filename: str + The filename of the video file that created these alignments + """ + updated = 0 + for key in list(self._alignments.data): + val = self._alignments.data[key] + fname = os.path.splitext(key)[0] + if fname.rsplit("_")[0] != self._video_name: + continue # Key is from a different source + + new_key = f"{fname}{self._extension}" + del self._alignments.data[key] + self._alignments.data[new_key] = val + updated += 1 + + logger.debug("Updated alignemnt keys for video extension: %s", updated) + return updated + + +class FileStructure(_Updater): + """ Alignments were structured: {frame_name: }. We need to be able to store + information at the frame level, so new structure is: {frame_name: {faces: }} + """ + def test(self) -> bool: + """ Test whether the alignments file is laid out in the old structure of + `{frame_name: [faces]}` + + Returns + ------- + bool + ``True`` if the file has legacy structure otherwise ``False`` + """ + return any(isinstance(val, list) for val in self._alignments.data.values()) + + def update(self) -> int: + """ Update legacy alignments files from the format `{frame_name: [faces}` to the + format `{frame_name: {faces: [faces]}`. + + Returns + ------- + int + The number of items that were updated + """ + updated = 0 + for key, val in self._alignments.data.items(): + if not isinstance(val, list): + continue + self._alignments.data[key] = {"faces": val} + updated += 1 + return updated + + +class LandmarkRename(_Updater): + """ Landmarks renamed from landmarksXY to landmarks_xy for PEP compliance """ + def test(self) -> bool: + """ check for legacy landmarksXY keys. + + Returns + ------- + bool + ``True`` if the alignments file contains legacy `landmarksXY` keys otherwise ``False`` + """ + return (any(key == "landmarksXY" + for val in self._alignments.data.values() + for alignment in val["faces"] + for key in alignment)) + + def update(self) -> int: + """ Update legacy `landmarksXY` keys to PEP compliant `landmarks_xy` keys. + + Returns + ------- + int + The number of landmarks keys that were changed + """ + update_count = 0 + for val in self._alignments.data.values(): + for alignment in val["faces"]: + if "landmarksXY" in alignment: + alignment["landmarks_xy"] = alignment.pop("landmarksXY") # type:ignore + update_count += 1 + return update_count + + +class ListToNumpy(_Updater): + """ Landmarks stored as list instead of numpy array """ + def test(self) -> bool: + """ check for legacy landmarks stored as `list` rather than :class:`numpy.ndarray`. + + Returns + ------- + bool + ``True`` if not all landmarks are :class:`numpy.ndarray` otherwise ``False`` + """ + return not all(isinstance(face["landmarks_xy"], np.ndarray) + for val in self._alignments.data.values() + for face in val["faces"]) + + def update(self) -> int: + """ Update landmarks stored as `list` to :class:`numpy.ndarray`. + + Returns + ------- + int + The number of landmarks keys that were changed + """ + update_count = 0 + for val in self._alignments.data.values(): + for alignment in val["faces"]: + test = alignment["landmarks_xy"] + if not isinstance(test, np.ndarray): + alignment["landmarks_xy"] = np.array(test, dtype="float32") + update_count += 1 + return update_count + + +class MaskCentering(_Updater): + """ Masks not containing the stored_centering parameters. Prior to this implementation all + masks were stored with face centering """ + + def test(self) -> bool: + """ Mask centering was introduced in alignments version 2.2 + + Returns + ------- + bool + ``True`` mask centering requires updating otherwise ``False`` + """ + return self._alignments.version < 2.2 + + def update(self) -> int: + """ Add the mask key to the alignment file and update the centering of existing masks + + Returns + ------- + int + The number of masks that were updated + """ + update_count = 0 + for val in self._alignments.data.values(): + for alignment in val["faces"]: + if "mask" not in alignment: + alignment["mask"] = {} + for mask in alignment["mask"].values(): + mask["stored_centering"] = "face" + update_count += 1 + return update_count + + +class IdentityAndVideoMeta(_Updater): + """ Prior to version 2.3 the identity key did not exist and the video_meta key was not + compulsory. These should now both always appear, but do not need to be populated. """ + + def test(self) -> bool: + """ Identity Key was introduced in alignments version 2.3 + + Returns + ------- + bool + ``True`` identity key needs inserting otherwise ``False`` + """ + return self._alignments.version < 2.3 + + # Identity information was not previously stored in the alignments file. + def update(self) -> int: + """ Add the video_meta and identity keys to the alignment file and leave empty + + Returns + ------- + int + The number of keys inserted + """ + update_count = 0 + for val in self._alignments.data.values(): + this_update = 0 + if "video_meta" not in val: + val["video_meta"] = {} + this_update = 1 + for alignment in val["faces"]: + if "identity" not in alignment: + alignment["identity"] = {} + this_update = 1 + update_count += this_update + return update_count + + +class Legacy(): + """ Legacy alignments properties that are no longer used, but are still required for backwards + compatibility/upgrading reasons. + + Parameters + ---------- + alignments: :class:`~Alignments` + The alignments object that requires these legacy properties + """ + def __init__(self, alignments: Alignments) -> None: + self._alignments = alignments + self._hashes_to_frame: dict[str, dict[str, int]] = {} + self._hashes_to_alignment: dict[str, AlignmentFileDict] = {} + + @property + def hashes_to_frame(self) -> dict[str, dict[str, int]]: + """ dict: The SHA1 hash of the face mapped to the frame(s) and face index within the frame + that the hash corresponds to. The structure of the dictionary is: + + {**SHA1_hash** (`str`): {**filename** (`str`): **face_index** (`int`)}}. + + Notes + ----- + This method is deprecated and exists purely for updating legacy hash based alignments + to new png header storage in :class:`lib.align.update_legacy_png_header`. + + The first time this property is referenced, the dictionary will be created and cached. + Subsequent references will be made to this cached dictionary. + """ + if not self._hashes_to_frame: + logger.debug("Generating hashes to frame") + for frame_name, val in self._alignments.data.items(): + for idx, face in enumerate(val["faces"]): + self._hashes_to_frame.setdefault( + face["hash"], {})[frame_name] = idx # type:ignore + return self._hashes_to_frame + + @property + def hashes_to_alignment(self) -> dict[str, AlignmentFileDict]: + """ dict: The SHA1 hash of the face mapped to the alignment for the face that the hash + corresponds to. The structure of the dictionary is: + + Notes + ----- + This method is deprecated and exists purely for updating legacy hash based alignments + to new png header storage in :class:`lib.align.update_legacy_png_header`. + + The first time this property is referenced, the dictionary will be created and cached. + Subsequent references will be made to this cached dictionary. + """ + if not self._hashes_to_alignment: + logger.debug("Generating hashes to alignment") + self._hashes_to_alignment = {face["hash"]: face # type:ignore + for val in self._alignments.data.values() + for face in val["faces"]} + return self._hashes_to_alignment From 696692dc0829818f28327685d446c930c549ed34 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 21 Apr 2024 16:22:42 +0100 Subject: [PATCH 904/981] Fixups - Deprecations - display correct long argument - Minor spelling + linting --- lib/cli/args_extract_convert.py | 24 ++++++++++++------------ lib/cli/args_train.py | 20 ++++++++++---------- lib/training/augmentation.py | 2 +- plugins/train/trainer/_base.py | 2 +- scripts/fsmedia.py | 2 +- tools/alignments/cli.py | 6 +++--- tools/effmpeg/cli.py | 2 +- tools/manual/cli.py | 2 +- tools/mask/cli.py | 2 +- tools/preview/cli.py | 2 +- tools/sort/cli.py | 2 +- 11 files changed, 33 insertions(+), 33 deletions(-) diff --git a/lib/cli/args_extract_convert.py b/lib/cli/args_extract_convert.py index 61c60b9b0f..ad3b4da9de 100644 --- a/lib/cli/args_extract_convert.py +++ b/lib/cli/args_extract_convert.py @@ -71,7 +71,7 @@ def get_argument_list() -> list[dict[str, T.Any]]: "action": FileFullPaths, "filetypes": "alignments", "type": str, - "dest": "depr_alignments_path_al_p", + "dest": "depr_alignments_al_p", "help": argparse.SUPPRESS}) return argument_list @@ -391,12 +391,12 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: argument_list.append({ "opts": ("-min", ), "type": int, - "dest": "depr_min_size_min_m", + "dest": "depr_min-size_min_m", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-een", ), "type": int, - "dest": "depr_extract_every_n_een_N", + "dest": "depr_extract-every-n_een_N", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-nm",), @@ -407,7 +407,7 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: argument_list.append({ "opts": ("-rf", ), "type": int, - "dest": "depr_re_feed_rf_R", + "dest": "depr_re-feed_rf_R", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-sz", ), @@ -417,12 +417,12 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: argument_list.append({ "opts": ("-si", ), "type": int, - "dest": "depr_save_interval_si_v", + "dest": "depr_save-interval_si_v", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-dl", ), "action": "store_true", - "dest": "depr_debug_landmarks_dl_B", + "dest": "depr_debug-landmarks_dl_B", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-sp", ), @@ -432,12 +432,12 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: argument_list.append({ "opts": ("-sf", ), "action": "store_true", - "dest": "depr_skip_faces_sf_e", + "dest": "depr_skip-existing-faces_sf_e", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-ssf", ), "action": "store_true", - "dest": "depr_skip_saving_faces_ssf_K", + "dest": "depr_skip-saving-faces_ssf_K", "help": argparse.SUPPRESS}) return argument_list @@ -726,22 +726,22 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: argument_list.append({ "opts": ("-ref", ), "type": str, - "dest": "depr_reference_video_ref_r", + "dest": "depr_reference-video_ref_r", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-fr", ), "type": str, "nargs": "+", - "dest": "depr_frame_ranges_fr_R", + "dest": "depr_frame-ranges_fr_R", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-osc", ), "type": int, - "dest": "depr_output_scale_osc_O", + "dest": "depr_output-scale_osc_O", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-otf", ), "action": "store_true", - "dest": "depr_on_the_fly_otf_T", + "dest": "depr_on-the-fly_otf_T", "help": argparse.SUPPRESS}) return argument_list diff --git a/lib/cli/args_train.py b/lib/cli/args_train.py index 2ed2c62c7d..efbaa93c5b 100644 --- a/lib/cli/args_train.py +++ b/lib/cli/args_train.py @@ -327,7 +327,7 @@ def get_argument_list() -> list[dict[str, T.Any]]: argument_list.append({ "opts": ("-bs", ), "type": int, - "dest": "depr_batch_size_bs_b", + "dest": "depr_batch-size_bs_b", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-it", ), @@ -337,46 +337,46 @@ def get_argument_list() -> list[dict[str, T.Any]]: argument_list.append({ "opts": ("-nl", ), "action": "store_true", - "dest": "depr_no_logs_nl_n", + "dest": "depr_no-logs_nl_n", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-ss", ), "type": int, - "dest": "depr_snapshot_interval_ss_I", + "dest": "depr_snapshot-interval_ss_I", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-tia", ), "type": str, - "dest": "depr_timelapse_input_a_tia_x", + "dest": "depr_timelapse-input-A_tia_x", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-tib", ), "type": str, - "dest": "depr_timelapse_input_b_tib_y", + "dest": "depr_timelapse-input-B_tib_y", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-to", ), "type": str, - "dest": "depr_timelapse_output_to_z", + "dest": "depr_timelapse-output_to_z", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-wl", ), "action": "store_true", - "dest": "depr_warp_to_landmarks_wl_M", + "dest": "depr_warp-to-landmarks_wl_M", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-nf", ), "action": "store_true", - "dest": "depr_no_flip_nf_P", + "dest": "depr_no-flip_nf_P", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-nac", ), "action": "store_true", - "dest": "depr_no_augment_color_nac_c", + "dest": "depr_no-augment-color_nac_c", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-nw", ), "action": "store_true", - "dest": "depr_no_warp_nw_W", + "dest": "depr_no-warp_nw_W", "help": argparse.SUPPRESS}) return argument_list diff --git a/lib/training/augmentation.py b/lib/training/augmentation.py index 184b1264ee..8fd911969b 100644 --- a/lib/training/augmentation.py +++ b/lib/training/augmentation.py @@ -25,7 +25,7 @@ class AugConstants: # pylint:disable=too-many-instance-attributes,too-few-publi ---------- config: dict[str, ConfigValueType] The user training configuration options - pricessing_size: int: + processing_size: int: The size of image to augment the data for batch_size: int The batch size that augmented data is being prepared for diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 37dc47f85e..11120022c0 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -479,7 +479,7 @@ def _resize_sample(cls, logger.debug("Resizing sample: (side: '%s', sample.shape: %s, target_size: %s, scale: %s)", side, sample.shape, target_size, scale) interpn = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA - retval = np.array([cv2.resize(img, (target_size, target_size), interpn) + retval = np.array([cv2.resize(img, (target_size, target_size), interpolation=interpn) for img in sample]) logger.debug("Resized sample: (side: '%s' shape: %s)", side, retval.shape) return retval diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 36932a4950..9d1fbdbb4e 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -53,7 +53,7 @@ def finalize(images_found: int, num_faces_detected: int, verify_output: bool) -> logger.info("Double check your results.") logger.info("-------------------------") - logger.info("Process Succesfully Completed. Shutting Down...") + logger.info("Process Successfully Completed. Shutting Down...") class Alignments(AlignmentsBase): diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py index 5a76443cc0..aaf7e7308b 100644 --- a/tools/alignments/cli.py +++ b/tools/alignments/cli.py @@ -208,17 +208,17 @@ def get_argument_list() -> list[dict[str, T.Any]]: argument_list.append({ "opts": ("-fc", ), "type": str, - "dest": "depr_faces_dir_fc_c", + "dest": "depr_faces_folder_fc_c", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-fr", ), "type": str, - "dest": "depr_extract_every_n_een_N", + "dest": "depr_extract-every-n_een_N", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-een", ), "type": int, - "dest": "depr_frames_dir_fr_r", + "dest": "depr_faces_folder_fr_r", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-sz", ), diff --git a/tools/effmpeg/cli.py b/tools/effmpeg/cli.py index b80a81b1e9..ac7647f8e4 100644 --- a/tools/effmpeg/cli.py +++ b/tools/effmpeg/cli.py @@ -211,7 +211,7 @@ def get_argument_list(): "opts": ("-ef", ), "type": str, "choices": IMAGE_EXTENSIONS, - "dest": "depr_extract_ext_et_E", + "dest": "depr_extract-filetype_et_E", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ('-tr', ), diff --git a/tools/manual/cli.py b/tools/manual/cli.py index a423f02e57..db27d785b6 100644 --- a/tools/manual/cli.py +++ b/tools/manual/cli.py @@ -69,7 +69,7 @@ def get_argument_list(): argument_list.append({ "opts": ("-al", ), "type": str, - "dest": "depr_alignments_path_al_a", + "dest": "depr_alignments_al_a", "help": argparse.SUPPRESS}) argument_list.append({ "opts": ("-fr", ), diff --git a/tools/mask/cli.py b/tools/mask/cli.py index a19a7b5cbd..4ec06e9ed3 100644 --- a/tools/mask/cli.py +++ b/tools/mask/cli.py @@ -238,6 +238,6 @@ def get_argument_list(): argument_list.append({ "opts": ("-it", ), "type": str, - "dest": "depr_input_type_it_I", + "dest": "depr_input-type_it_I", "help": argparse.SUPPRESS}) return argument_list diff --git a/tools/preview/cli.py b/tools/preview/cli.py index 6b435862dd..147f0449cc 100644 --- a/tools/preview/cli.py +++ b/tools/preview/cli.py @@ -76,6 +76,6 @@ def get_argument_list() -> list[dict[str, T.Any]]: argument_list.append({ "opts": ("-al", ), "type": str, - "dest": "depr_alignments_path_al_a", + "dest": "depr_alignments_al_a", "help": argparse.SUPPRESS}) return argument_list diff --git a/tools/sort/cli.py b/tools/sort/cli.py index f678687461..d85b9637a5 100644 --- a/tools/sort/cli.py +++ b/tools/sort/cli.py @@ -225,6 +225,6 @@ def get_argument_list(): argument_list.append({ "opts": ("-lf", ), "type": str, - "dest": "depr_log_file_path_lf_f", + "dest": "depr_log-file_lf_f", "help": argparse.SUPPRESS}) return argument_list From be42b040649327b0648b33465e9f156ca79d1371 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 21 Apr 2024 19:55:45 +0100 Subject: [PATCH 905/981] Bugfix: Alignment file video key lookup for very old alignment files --- lib/align/updater.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/lib/align/updater.py b/lib/align/updater.py index d31d942494..9f5b6bc5a9 100644 --- a/lib/align/updater.py +++ b/lib/align/updater.py @@ -103,17 +103,32 @@ def __init__(self, alignments: Alignments, video_filename: str) -> None: super().__init__(alignments) def test(self) -> bool: - """ Requires update if alignments version is < 2.4 + """ Requires update if the extension of the key in the alignment file is not the same + as for the input video file Returns ------- bool ``True`` if the key extensions need updating otherwise ``False`` """ - retval = self._alignments.version < 2.4 and self._extension in VIDEO_EXTENSIONS - logger.debug("Needs update for video extension: %s (version: %s, extension: %s)", - retval, self._alignments.version, self._extension) - return retval + if self._alignments.version > 2.4: + return False + + if self._extension.lower() not in VIDEO_EXTENSIONS: + return False + + exts = set(os.path.splitext(k)[-1] for k in self._alignments.data) + if len(exts) != 1: + logger.debug("Alignments file has multiple key extensions. Skipping") + return False + + if self._extension in exts: + logger.debug("Alignments file contains correct key extensions. Skipping") + return False + + logger.debug("Needs update for video extension (version: %s, extension: %s)", + self._alignments.version, self._extension) + return True def update(self) -> int: """ Update alignments files that have been extracted from videos to have the key end in the From 0f947791f57db7e31d372ce2d098e5d7737cf1fe Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 21 Apr 2024 20:03:42 +0100 Subject: [PATCH 906/981] bugfix: Alignments remove version check for video file extension update - Typofixes --- .install/linux/faceswap_setup_x64.sh | 2 +- .install/macos/faceswap_setup_macos.sh | 2 +- lib/align/updater.py | 5 ++--- lib/gui/control_helper.py | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.install/linux/faceswap_setup_x64.sh b/.install/linux/faceswap_setup_x64.sh index 21bbec6310..74825a0844 100644 --- a/.install/linux/faceswap_setup_x64.sh +++ b/.install/linux/faceswap_setup_x64.sh @@ -266,7 +266,7 @@ conda_opts () { echo "" info "Faceswap will be installed inside a Conda Environment. If an environment already\ exists with the name specified then it will be deleted." - ask "Please specify a name for the Faceswap Conda Environmnet" "ENV_NAME" + ask "Please specify a name for the Faceswap Conda Environment" "ENV_NAME" } faceswap_opts () { diff --git a/.install/macos/faceswap_setup_macos.sh b/.install/macos/faceswap_setup_macos.sh index 62b3430383..804b828c3e 100644 --- a/.install/macos/faceswap_setup_macos.sh +++ b/.install/macos/faceswap_setup_macos.sh @@ -298,7 +298,7 @@ conda_opts () { echo "" info "Faceswap will be installed inside a Conda Environment. If an environment already\ exists with the name specified then it will be deleted." - ask "Please specify a name for the Faceswap Conda Environmnet" "ENV_NAME" + ask "Please specify a name for the Faceswap Conda Environment" "ENV_NAME" } faceswap_opts () { diff --git a/lib/align/updater.py b/lib/align/updater.py index 9f5b6bc5a9..fa33a3f8c6 100644 --- a/lib/align/updater.py +++ b/lib/align/updater.py @@ -111,9 +111,8 @@ def test(self) -> bool: bool ``True`` if the key extensions need updating otherwise ``False`` """ - if self._alignments.version > 2.4: - return False - + # Note: Don't check on alignments file version. It's possible that the file gets updated to + # a newer version before this check is run if self._extension.lower() not in VIDEO_EXTENSIONS: return False diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 1b6571335f..5179256759 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -1061,7 +1061,7 @@ def _get_multi_help_items(helptext): if any(line.startswith(" - ") for line in all_help): intro = all_help[0] retval = (intro, - {re.sub(r"[^A-Za-z0-9\-\_]+", "", + {re.sub(r"[^\w\-\_]+", "", line.split()[1].lower()): " ".join(line.replace("_", " ").split()[1:]) for line in all_help if line.startswith(" - ")}) logger.debug("help items: %s", retval) From 13dd5b3a90e597d6017e8abd8b96f5572317461b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 26 Apr 2024 17:39:46 +0100 Subject: [PATCH 907/981] bugfix: Alignments - correctly update keys for multiple underscores in the filename --- lib/align/updater.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/align/updater.py b/lib/align/updater.py index fa33a3f8c6..7a98e3c8fc 100644 --- a/lib/align/updater.py +++ b/lib/align/updater.py @@ -140,17 +140,19 @@ def update(self) -> int: """ updated = 0 for key in list(self._alignments.data): - val = self._alignments.data[key] fname = os.path.splitext(key)[0] - if fname.rsplit("_")[0] != self._video_name: + if fname.rsplit("_", maxsplit=1)[0] != self._video_name: continue # Key is from a different source + val = self._alignments.data[key] new_key = f"{fname}{self._extension}" + del self._alignments.data[key] self._alignments.data[new_key] = val + updated += 1 - logger.debug("Updated alignemnt keys for video extension: %s", updated) + logger.debug("Updated alignment keys for video extension: %s", updated) return updated From 8c3bc3945474fdabad54091fa534afea99753972 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 10 May 2024 21:48:38 +0100 Subject: [PATCH 908/981] bugfix: Patch writer. Correctly split frame number from the end of filenames --- plugins/convert/writer/patch.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/convert/writer/patch.py b/plugins/convert/writer/patch.py index 5f569677f5..fe4a7f4bb1 100644 --- a/plugins/convert/writer/patch.py +++ b/plugins/convert/writer/patch.py @@ -5,6 +5,7 @@ """ import json import logging +import re import os import cv2 @@ -34,6 +35,7 @@ def __init__(self, output_folder: str, patch_size: int, **kwargs) -> None: super().__init__(output_folder, **kwargs) self._extension = {"png": ".png", "tiff": ".tif"}[self.config["format"]] self._separate_mask = self.config["separate_mask"] + self._fname_split = re.compile("[^0-9a-zA-Z]") if self._extension == ".png" and self.config["bit_depth"] not in ("8", "16"): logger.warning("Patch Writer: Bit Depth '%s' is unsupported for format '%s'. " @@ -97,18 +99,20 @@ def _get_new_filename(self, filename: str, face_index: int) -> str: fname, ext = os.path.splitext(filename) fname = os.path.basename(fname) - split_fname = fname.rsplit("_", 1) - if split_fname[-1].isdigit(): + split_fname = self._fname_split.split(fname) + if split_fname and split_fname[-1].isdigit(): i_frame_no = (int(split_fname[-1]) + (int(self.config["start_index"]) - 1) + self.config["index_offset"]) frame_no = f".{str(i_frame_no).rjust(self.config['number_padding'], '0')}" + base_fname = fname[:-len(split_fname[-1]) - 1] else: frame_no = "" + base_fname = fname retval = "" if self.config["include_filename"]: - retval += f"{split_fname[0]}" + retval += base_fname if self.config["face_index_location"] == "before": retval = f"{retval}_{face_idx}" retval += frame_no From 1f4fcff5dd14d2c2d22bb80c42f038dd9c81a27b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 19 May 2024 11:04:48 +0100 Subject: [PATCH 909/981] bugfix: Alignments, prevent duplicate backup alignment file names --- lib/align/alignments.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/align/alignments.py b/lib/align/alignments.py index 0901625c64..68bdbcebe9 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -796,7 +796,15 @@ def backup(self) -> None: now = datetime.now().strftime("%Y%m%d_%H%M%S") src = self._file split = os.path.splitext(src) - dst = split[0] + "_" + now + split[1] + dst = f"{split[0]}_{now}{split[1]}" + idx = 1 + while True: + if not os.path.exists(dst): + break + logger.debug("Backup file %s exists. Incrementing", dst) + dst = f"{split[0]}_{now}({idx}){split[1]}" + idx += 1 + logger.info("Backing up original alignments to '%s'", dst) os.rename(src, dst) logger.debug("Backed up alignments") From c8652ecaac56aae1bc328cf354c44853a2c17d7f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 2 Jun 2024 16:25:30 +0100 Subject: [PATCH 910/981] Training preview: Correctly display blur/kernel amount on mask --- plugins/train/trainer/_base.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 11120022c0..dbbc74c9fd 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -672,24 +672,23 @@ def _compile_masked(self, faces: list[np.ndarray], masks: np.ndarray) -> list[np list List of :class:`numpy.ndarray` faces with the opaque mask layer applied """ - orig_masks = 1 - np.rint(masks) + orig_masks = 1. - masks masks3: list[np.ndarray] | np.ndarray = [] if faces[-1].shape[-1] == 4: # Mask contained in alpha channel of predictions - pred_masks = [1 - np.rint(face[..., -1])[..., None] for face in faces[-2:]] + pred_masks = [1. - face[..., -1][..., None] for face in faces[-2:]] faces[-2:] = [face[..., :-1] for face in faces[-2:]] masks3 = [orig_masks, *pred_masks] else: masks3 = np.repeat(np.expand_dims(orig_masks, axis=0), 3, axis=0) retval: list[np.ndarray] = [] - alpha = 1.0 - self._mask_opacity - for previews, compiled_masks in zip(faces, masks3): - overlays = previews.copy() - overlays[np.where((compiled_masks == 1.).all(axis=3))] = self._mask_color - retval.append(np.array([cv2.addWeighted(img, alpha, ovl, self._mask_opacity, 0) - for img, ovl in zip(previews, overlays)])) - + overlays3 = np.ones_like(faces) * self._mask_color + for previews, overlays, compiled_masks in zip(faces, overlays3, masks3): + compiled_masks *= self._mask_opacity + overlays *= compiled_masks + previews *= (1. - compiled_masks) + retval.append(previews + overlays) logger.debug("masked shapes: %s", [faces.shape for faces in retval]) return retval From ea63f1e64a206157b61eaff1329324f03b3a4bd3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 28 Jun 2024 13:50:14 +0100 Subject: [PATCH 911/981] Mask tool. Add ability to output custom imported masks --- locales/es/LC_MESSAGES/tools.mask.cli.mo | Bin 14916 -> 15278 bytes locales/es/LC_MESSAGES/tools.mask.cli.po | 41 +++++++++++++---------- locales/kr/LC_MESSAGES/tools.mask.cli.mo | Bin 14151 -> 14531 bytes locales/kr/LC_MESSAGES/tools.mask.cli.po | 40 ++++++++++++---------- locales/ru/LC_MESSAGES/tools.mask.cli.mo | Bin 18221 -> 18726 bytes locales/ru/LC_MESSAGES/tools.mask.cli.po | 41 +++++++++++++---------- locales/tools.mask.cli.pot | 28 +++++++++------- tools/mask/cli.py | 8 +++-- tools/mask/mask_output.py | 4 +++ 9 files changed, 92 insertions(+), 70 deletions(-) diff --git a/locales/es/LC_MESSAGES/tools.mask.cli.mo b/locales/es/LC_MESSAGES/tools.mask.cli.mo index 2b153b9c56767340632a1725fdbfdb9220ba1b4f..ad86f74687970000a91cee1fd14ce3bb86455225 100644 GIT binary patch delta 868 zcmY*WOKTHR7(HoiQ>&HM`sl;QXHfgFO`lk87g7YljTMCY0EIDgZAK?^)0qh+-K0vz zZ8hpnLR*5kwRzF8u+bpo@BDl1Mwuox?rf_nmX@on`;!%z|0?Rs}qc z1JA30LOpP&2I$@bX!5U{fk|TT4&V)d`4-?N@pvn6f%uNNfw;d7s3mrlv7bmuj1Vuz zfMdk-#0TxbCGx*_QIq@0-6-HEiTORiP7?XOzzyzi3ou9AwhtI5e~&oFfS(h<7#+_Y z1g_HIZa1*PfU5&Q2lg$kkAM6>Hzf3j;3;2tLKW*9438@2d(YGg#Ei5c7WaYh?uTIZ7@7Fo+IoB(9#@5P&6#)A#V9N`r ztbJv`7`a^z+yKZ_@Q*z80TX0HC6GfV$y{#GF+G+UdVl*77o zzdEd7(QKi&vpv)+WH~H%w8^r0R`n}|tYiPMJ2)5<_E1fkQH3IwjA$&ns%S!qWv4Qa e-=6E!il*r1P#`2})@~rrX+77kJFUZ3U&$Xc*L{My2mZ;He z4X3D#nE2AD>0>3NK?FrF&`R-GT$)QF?SXN>zTV#+{fgS zXk?ZdfP>5{%(fEXEXTiW1M+x%pcG#0;-3>`z)w!NxAR8k83KIFPt22?Z`}bLVuQ10 z;4~|Wa^M3i9<2oC* z9q@&*iKqT?Bd{URYbe>eIk5)V0ayijJaYo`hLS997`R!0_R;V?Q+t;E-snBv-J`hF zz0DrA$D@+s?7Cb-Ee^YrIunUb=XvUGcVBQvrFZCN}=6pBez5GblIpwak=d79)%;UkS>`h zc`4nazC*7P_-v}k*6UajX0%_X2S)8yPRquehU)!Rs;z6Px9-_j8xTeB4E5U6;x>~u zQeKzw-!_QpemM|X?Tw15Ws1jqGQ1>1Q4+zSDv}e6@rCPh!bkF2RF2KA2E!yH!!kHZ zG8`1)sGRH*gZ*MKQbY2|xCl?li4esXR^(Xh-_!E&q@Uz8D+JesW++WRS!6XB5(D>0 z+>VLqp_H`nhs12GDo|iH=L`RMx{bKD9C%bw*hVt86xYH{WJ$iOg+%O5s!6Ivzm6PT n(k+U)84=*SOb*MDK0WZmH^?LD{I8{S6>Z!sjoRCatGT}c-rZ1D delta 524 zcmYMw-%FEG7zgn0JGX|;4Syi%HtitI4b6I4o1$0gg%@4eU4cXqI!waN+{Bw=F6J2R zMtmt`7qVn9MK4}EMhjAiE((IW@-ER!+0Ook`VIvTJbcc1&N=V%ocH_7AJMg$q^$wi zvIBoDz;+X`ZUa`GK#c3ocHkk+b^u2JPOo#1Zgv7w^kx@erAc~?rZ3G=eiG}nM+5To z75(A{o-#k=1zNa%??n=QoRqqOb8h_Z;X(TO2Czay0+{6fCz@iT)?Q$YW_-XOR{R+N z_SxXZ9pD?iA7qof*Bk~8sG=GAVF+lXf#HirgO`pBvrxVBdrYtn_+Yd$>VrQ5ZAMD- z@bwpimBA`#VKf>0ny1mhnTz3+$J&|AHyw#4B8zj;S&@i|rFcXvKaWJk-26gpF;UOX zKa0PJ8xI@-nRaYjWYKW4t{ZFaUs_N;_B^p0|9sw%P;d5B zey8@XAXNHmEp1jyDYdy;&1I{(;%%YKazz$Kg}-91s6tt76f0%Z=<;{FD<2P4-W1hR aR=q9OQhQg|<&9Gje+zRVn()LTqE)A3`{N?IfoNkforN(fwtGp4BXWaf<-1mj2A zBGd4;Y9q90kp$+`8X6rzG72K@g$$B{*_H%Bv}sf4j`ktl`!4_Y&iS9;IqyCi(;iG{ zcY^T`HsHPkc$fvmoxnskP_`W~D87&fTq3R53#0(ta0A1nrwf70q_0Trq~3kN7Sh8T zT1`qw94GD6fO^sZai%;#AH}~HQJ%iT#fal28G0G8tc(YNw`9on0-s4&N#9d^`4I4f zI!)?8Ig#)AC`QA+r~%q2K6(;(PQY)cfNJt5&HyE(pBjL7#K}1e7TrE+Wy zlmNTwB`*MwAN0f6MMo{V2yEj6nkQd5)S@c_b-_+LF8)~a1-?Md~J&d zx_gX>S=AUbdtxRt!d=%YSyOu`%sMiOj_?)M-4gAL`k2|?5oPq4?IG403Y(#bN}2|X zMMIUW!H6(p9rD|#U@948ZGV!cs%ecyO`|*0p)q1oexpN6ILM;yMyxByT8&5~6zwr6 zLLD}@%pk9Bo>4gzg|9kForF=EC1ylQ%vLfn$4kmBQL6PFF(jxxRf8Zh$cSTZ;v%gj` zz2RXZAqT`Fl_%w>cq)g)oE($`WLAVs@R>+4G5a5Yr;nBt$YBDAc?FRDRFRPVR5ioj zcnh2fRYeH#gkSJ`d2gB9Cdas6FU*>kL;Rdx%-7g{ZI+Md^*T}5($v56EhT;HBTh4d cEXomLkMp#C-7zN?XwO;ArG0iw_?z>80k|zF&j0`b delta 503 zcmXYtOGsNm6o&taiJ>O3Y88`Q)LVRl@%E~SD1|DON>|p6f*`bLd>|%bBDyFwo+nuMXU}cTv@<| z8@O-)+c`kW1uXc0C}aOKpod&60@48f7W0l=_XB-oMF4P;2{N0Uf8-L$l1Pzb0{BF} zWu0sdm|^~13GW%7lpuj)3hXlATSoXC_({S0a^Q+=ssMg7uU7)+9PGA=#*U{U>N)K9 zdSHmLd#r2gnNP)1&N0KZr#?+xH3FUcTzL-IMEcCv8+onR+GJ-9IPX_>=* zD3EhP5*_mA03~F zPR29Y@ei@FnC$Y@sCmz}Lv6bMI8-RN*XwJnYiQ9;v(;>_HBGe|^b66TcC^)#F`v2UTGoSUbd4~+D_`$k+o;fT6@+P>%dOhv+{@WQ5>r7 R7c)+^QGMrBCyo6@{{bA>Z=nDH diff --git a/locales/ru/LC_MESSAGES/tools.mask.cli.po b/locales/ru/LC_MESSAGES/tools.mask.cli.po index 1f63fe42ff..6cabf81c53 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: 2024-03-28 23:51+0000\n" -"PO-Revision-Date: 2024-03-29 00:07+0000\n" +"POT-Creation-Date: 2024-06-28 13:45+0100\n" +"PO-Revision-Date: 2024-06-28 13:48+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -17,7 +17,7 @@ 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.4.2\n" +"X-Generator: Poedit 3.4.4\n" #: tools/mask/cli.py:15 msgid "" @@ -175,7 +175,7 @@ msgstr "" "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 +#: tools/mask/cli.py:135 tools/mask/cli.py:154 tools/mask/cli.py:176 msgid "import" msgstr "Импортировать" @@ -208,9 +208,11 @@ 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" +"R|Import/Output only. When importing masks, this is the centering to use. " +"For output this is only used for outputting custom imported masks, and " +"should correspond to the centering used when importing the mask. Note: For " +"any job other than 'import' and 'output' 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" @@ -222,9 +224,12 @@ msgid "" "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" +"R|Только импорт/вывод. При импорте масок это центрирование для " +"использования. Для вывода это используется только для вывода " +"пользовательских импортированных масок и должно соответствовать " +"центрированию, используемому при импорте маски. Примечание: для любого " +"задания, кроме «импорта» и «вывода», эта опция игнорируется, поскольку " +"центрирование маски обрабатывается внутренне.\n" "L|face: центрирует маску по центру лица с регулировкой угла наклона и " "отклонения от курса. Помимо требований к полной маскировке/тренировке " "головы, это, вероятно, будет лучшим выбором.\n" @@ -236,7 +241,7 @@ msgstr "" "приближает ее к лицу. Это может привести к тому, что края маски окажутся за " "пределами тренировочной зоны." -#: tools/mask/cli.py:179 +#: tools/mask/cli.py:181 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 " @@ -247,12 +252,12 @@ msgstr "" "использования. Большие размеры приведут к увеличению размера файлов " "выравниваний и более длительной обработке." -#: tools/mask/cli.py:187 tools/mask/cli.py:195 tools/mask/cli.py:209 -#: tools/mask/cli.py:223 tools/mask/cli.py:233 +#: tools/mask/cli.py:189 tools/mask/cli.py:197 tools/mask/cli.py:211 +#: tools/mask/cli.py:225 tools/mask/cli.py:235 msgid "output" msgstr "вывод" -#: tools/mask/cli.py:189 +#: tools/mask/cli.py:191 msgid "" "Optional output location. If provided, a preview of the masks created will " "be output in the given folder." @@ -260,7 +265,7 @@ msgstr "" "Необязательное местоположение вывода. Если указано, предварительный просмотр " "созданных масок будет выведен в указанную папку." -#: tools/mask/cli.py:200 +#: tools/mask/cli.py:202 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 " @@ -273,7 +278,7 @@ msgstr "" "Примечание: влияет только на предварительный просмотр. Установите значение 0 " "для выключения" -#: tools/mask/cli.py:214 +#: tools/mask/cli.py:216 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 " @@ -284,7 +289,7 @@ msgstr "" "часть маски. Примечание: влияет только на предварительный просмотр. " "Установите значение 0 для выключения" -#: tools/mask/cli.py:225 +#: tools/mask/cli.py:227 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" @@ -297,7 +302,7 @@ msgstr "" "L|masked: Вывести лицо/кадр как изображение rgba с маскированным лицом.\n" "L|mask: Выводить только маску как одноканальное изображение." -#: tools/mask/cli.py:235 +#: tools/mask/cli.py:237 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/locales/tools.mask.cli.pot b/locales/tools.mask.cli.pot index 54563a8620..f8024c88ee 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: 2024-03-28 23:51+0000\n" +"POT-Creation-Date: 2024-06-28 13:45+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -114,7 +114,7 @@ msgid "" "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 +#: tools/mask/cli.py:135 tools/mask/cli.py:154 tools/mask/cli.py:176 msgid "import" msgstr "" @@ -135,9 +135,11 @@ 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" +"R|Import/Output only. When importing masks, this is the centering to use. " +"For output this is only used for outputting custom imported masks, and " +"should correspond to the centering used when importing the mask. Note: For " +"any job other than 'import' and 'output' 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" @@ -150,25 +152,25 @@ msgid "" "mask appearing outside of the training area." msgstr "" -#: tools/mask/cli.py:179 +#: tools/mask/cli.py:181 msgid "" "Import only. The size, in pixels to internally store the mask at.\n" "The default is 128 which is fine for nearly all usecases. Larger sizes will " "result in larger alignments files and longer processing." msgstr "" -#: tools/mask/cli.py:187 tools/mask/cli.py:195 tools/mask/cli.py:209 -#: tools/mask/cli.py:223 tools/mask/cli.py:233 +#: tools/mask/cli.py:189 tools/mask/cli.py:197 tools/mask/cli.py:211 +#: tools/mask/cli.py:225 tools/mask/cli.py:235 msgid "output" msgstr "" -#: tools/mask/cli.py:189 +#: tools/mask/cli.py:191 msgid "" "Optional output location. If provided, a preview of the masks created will " "be output in the given folder." msgstr "" -#: tools/mask/cli.py:200 +#: tools/mask/cli.py:202 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 " @@ -176,14 +178,14 @@ msgid "" "to the next odd number. NB: Only effects the output preview. Set to 0 for off" msgstr "" -#: tools/mask/cli.py:214 +#: tools/mask/cli.py:216 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:225 +#: tools/mask/cli.py:227 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" @@ -191,7 +193,7 @@ msgid "" "L|mask: Only output the mask as a single channel image." msgstr "" -#: tools/mask/cli.py:235 +#: tools/mask/cli.py:237 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/mask/cli.py b/tools/mask/cli.py index 4ec06e9ed3..cc14bb1b9d 100644 --- a/tools/mask/cli.py +++ b/tools/mask/cli.py @@ -153,9 +153,11 @@ def get_argument_list(): "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." + "R|Import/Output only. When importing masks, this is the centering to use. For " + "output this is only used for outputting custom imported masks, and should " + "correspond to the centering used when importing the mask. Note: For any job " + "other than 'import' and 'output' 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." diff --git a/tools/mask/mask_output.py b/tools/mask/mask_output.py index 344332a9b3..79ffb809e3 100644 --- a/tools/mask/mask_output.py +++ b/tools/mask/mask_output.py @@ -49,6 +49,7 @@ def __init__(self, arguments: Namespace, self._type: T.Literal["combined", "masked", "mask"] = arguments.output_type self._full_frame: bool = arguments.full_frame self._mask_type = arguments.masker + self._centering: CenteringType = arguments.centering self._input_is_faces = arguments.input_type == "faces" self._saver = self._set_saver(arguments.output, arguments.processing) @@ -445,6 +446,9 @@ def _get_mask_types(self, else: mask_types = [self._mask_type] + if self._mask_type == "custom": + mask_types.append(f"{self._mask_type}_{self._centering}") + final_masks = set() for idx in reversed(range(len(detected_faces))): face_idx, detected_face = detected_faces[idx] From b6ac7b8039a1474dfe1607601426289939b9b346 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 5 Jul 2024 18:46:10 +0100 Subject: [PATCH 912/981] bugfix: Manual tool. Allow working with image folders at EEN values > 1 --- docs/full/tools/manual.rst | 25 +- lib/image.py | 5 +- tools/manual/detected_faces.py | 56 ++- tools/manual/faceviewer/frame.py | 15 +- tools/manual/faceviewer/interact.py | 16 +- tools/manual/frameviewer/control.py | 46 +- tools/manual/frameviewer/editor/_base.py | 52 +- .../manual/frameviewer/editor/bounding_box.py | 25 +- .../manual/frameviewer/editor/extract_box.py | 23 +- tools/manual/frameviewer/editor/landmarks.py | 4 +- tools/manual/frameviewer/editor/mask.py | 49 +- tools/manual/frameviewer/frame.py | 57 ++- tools/manual/globals.py | 309 ++++++++++++ tools/manual/manual.py | 452 ++++++------------ 14 files changed, 657 insertions(+), 477 deletions(-) create mode 100644 tools/manual/globals.py diff --git a/docs/full/tools/manual.rst b/docs/full/tools/manual.rst index 9f3bed9d8f..4b35542559 100644 --- a/docs/full/tools/manual.rst +++ b/docs/full/tools/manual.rst @@ -23,11 +23,10 @@ The Manual Module is the main entry point into the Manual Editor Tool. .. autosummary:: :nosignatures: - + ~tools.manual.manual.Aligner ~tools.manual.manual.FrameLoader ~tools.manual.manual.Manual - ~tools.manual.manual.TkGlobals .. rubric:: Module @@ -43,7 +42,7 @@ detected_faces module .. autosummary:: :nosignatures: - + ~tools.manual.detected_faces.DetectedFaces ~tools.manual.detected_faces.FaceUpdate ~tools.manual.detected_faces.Filter @@ -55,6 +54,26 @@ detected_faces module :undoc-members: :show-inheritance: +globals module +============== + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~tools.manual.globals.CurrentFrame + ~tools.manual.globals.TkGlobals + ~tools.manual.globals.TKVars + +.. rubric:: Module + +.. automodule:: tools.manual.globals + :members: + :undoc-members: + :show-inheritance: + + thumbnails module ================== diff --git a/lib/image.py b/lib/image.py index 4a2b524a15..897d8e2daa 100644 --- a/lib/image.py +++ b/lib/image.py @@ -1466,7 +1466,10 @@ def image_from_index(self, index): image = self._reader.get_data(index)[..., ::-1] filename = self._dummy_video_framename(index) else: - filename = self.file_list[index] + file_list = [f for idx, f in enumerate(self._file_list) + if idx not in self._skip_list] if self._skip_list else self._file_list + + filename = file_list[index] image = read_image(filename, raise_error=True) filename = os.path.basename(filename) logger.trace("index: %s, filename: %s image shape: %s", index, filename, image.shape) diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index ebd7218f81..7dcd90fc83 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -69,7 +69,6 @@ def __init__(self, logger.debug("Initialized %s", self.__class__.__name__) # <<<< PUBLIC PROPERTIES >>>> # - # << SUBCLASSES >> # @property def extractor(self) -> manual.Aligner: """ :class:`~tools.manual.manual.Aligner`: The pipeline for passing faces through the @@ -108,6 +107,11 @@ def tk_face_count_changed(self) -> tk.BooleanVar: return self._tk_vars["face_count_changed"] # << STATISTICS >> # + @property + def frame_list(self) -> list[str]: + """ list[str]: The list of all frame names that appear in the alignments file """ + return list(self._alignments.data) + @property def available_masks(self) -> dict[str, int]: """ dict[str, int]: The mask type names stored in the alignments; type as key with the @@ -343,7 +347,7 @@ def revert_to_saved(self, frame_index: int) -> None: self._tk_face_count_changed.set(True) else: self._tk_edited.set(True) - self._globals.tk_update.set(True) + self._globals.var_full_update.set(True) @classmethod def _add_remove_faces(cls, @@ -485,7 +489,7 @@ def __init__(self, detected_faces: DetectedFaces) -> None: def frame_meets_criteria(self) -> bool: """ bool: ``True`` if the current frame meets the selected filter criteria otherwise ``False`` """ - filter_mode = self._globals.filter_mode + filter_mode = self._globals.var_filter_mode.get() frame_faces = self._detected_faces.current_faces[self._globals.frame_index] distance = self._filter_distance @@ -505,7 +509,7 @@ def frame_meets_criteria(self) -> bool: def _filter_distance(self) -> float: """ float: The currently selected distance when Misaligned Faces filter is selected. """ try: - retval = self._globals.tk_filter_distance.get() + retval = self._globals.var_filter_distance.get() except tk.TclError: # Suppress error when distance box is empty retval = 0 @@ -514,22 +518,22 @@ def _filter_distance(self) -> float: @property def count(self) -> int: """ int: The number of frames that meet the filter criteria returned by - :attr:`~tools.manual.manual.TkGlobals.filter_mode`. """ + :attr:`~tools.manual.manual.TkGlobals.var_filter_mode.get()`. """ face_count_per_index = self._detected_faces.face_count_per_index - if self._globals.filter_mode == "No Faces": + if self._globals.var_filter_mode.get() == "No Faces": retval = sum(1 for fcount in face_count_per_index if fcount == 0) - elif self._globals.filter_mode == "Has Face(s)": + elif self._globals.var_filter_mode.get() == "Has Face(s)": retval = sum(1 for fcount in face_count_per_index if fcount != 0) - elif self._globals.filter_mode == "Multiple Faces": + elif self._globals.var_filter_mode.get() == "Multiple Faces": retval = sum(1 for fcount in face_count_per_index if fcount > 1) - elif self._globals.filter_mode == "Misaligned Faces": + elif self._globals.var_filter_mode.get() == "Misaligned Faces": distance = self._filter_distance retval = sum(1 for frame in self._detected_faces.current_faces if any(face.aligned.average_distance > distance for face in frame)) else: retval = len(face_count_per_index) logger.trace("filter mode: %s, frame count: %s", # type:ignore[attr-defined] - self._globals.filter_mode, retval) + self._globals.var_filter_mode.get(), retval) return retval @property @@ -554,22 +558,22 @@ def raw_indices(self) -> dict[T.Literal["frame", "face"], list[int]]: @property def frames_list(self) -> list[int]: """ list[int]: The list of frame indices that meet the filter criteria returned by - :attr:`~tools.manual.manual.TkGlobals.filter_mode`. """ + :attr:`~tools.manual.manual.TkGlobals.var_filter_mode.get()`. """ face_count_per_index = self._detected_faces.face_count_per_index - if self._globals.filter_mode == "No Faces": + if self._globals.var_filter_mode.get() == "No Faces": retval = [idx for idx, count in enumerate(face_count_per_index) if count == 0] - elif self._globals.filter_mode == "Multiple Faces": + elif self._globals.var_filter_mode.get() == "Multiple Faces": retval = [idx for idx, count in enumerate(face_count_per_index) if count > 1] - elif self._globals.filter_mode == "Has Face(s)": + elif self._globals.var_filter_mode.get() == "Has Face(s)": retval = [idx for idx, count in enumerate(face_count_per_index) if count != 0] - elif self._globals.filter_mode == "Misaligned Faces": + elif self._globals.var_filter_mode.get() == "Misaligned Faces": distance = self._filter_distance retval = [idx for idx, frame in enumerate(self._detected_faces.current_faces) if any(face.aligned.average_distance > distance for face in frame)] else: retval = list(range(len(face_count_per_index))) logger.trace("filter mode: %s, number_frames: %s", # type:ignore[attr-defined] - self._globals.filter_mode, len(retval)) + self._globals.var_filter_mode.get(), len(retval)) return retval @@ -677,7 +681,7 @@ def delete(self, frame_index: int, face_index: int) -> None: faces = self._faces_at_frame_index(frame_index) del faces[face_index] self._tk_face_count_changed.set(True) - self._globals.tk_update.set(True) + self._globals.var_full_update.set(True) def bounding_box(self, frame_index: int, @@ -717,7 +721,7 @@ def bounding_box(self, face.top = pnt_y face.height = height face.add_landmarks_xy(self._extractor.get_landmarks(frame_index, face_index, aligner)) - self._globals.tk_update.set(True) + self._globals.var_full_update.set(True) def landmark(self, frame_index: int, face_index: int, @@ -764,7 +768,7 @@ def landmark(self, face.landmarks_xy[idx] = lmk else: face.landmarks_xy[landmark_index] += (shift_x, shift_y) - self._globals.tk_update.set(True) + self._globals.var_full_update.set(True) def landmarks(self, frame_index: int, face_index: int, shift_x: int, shift_y: int) -> None: """ Shift all of the landmarks and bounding box for the @@ -792,7 +796,7 @@ def landmarks(self, frame_index: int, face_index: int, shift_x: int, shift_y: in face.left += shift_x face.top += shift_y face.add_landmarks_xy(face.landmarks_xy + (shift_x, shift_y)) - self._globals.tk_update.set(True) + self._globals.var_full_update.set(True) def landmarks_rotate(self, frame_index: int, @@ -818,7 +822,7 @@ def landmarks_rotate(self, rot_mat = cv2.getRotationMatrix2D(tuple(center.astype("float32")), angle, 1.) face.add_landmarks_xy(cv2.transform(np.expand_dims(face.landmarks_xy, axis=0), rot_mat).squeeze()) - self._globals.tk_update.set(True) + self._globals.var_full_update.set(True) def landmarks_scale(self, frame_index: int, @@ -842,7 +846,7 @@ def landmarks_scale(self, """ face = self._faces_at_frame_index(frame_index)[face_index] face.add_landmarks_xy(((face.landmarks_xy - center) * scale) + center) - self._globals.tk_update.set(True) + self._globals.var_full_update.set(True) def mask(self, frame_index: int, face_index: int, mask: np.ndarray, mask_type: str) -> None: """ Update the mask on an edit for the :class:`~lib.align.DetectedFace` object at @@ -862,7 +866,7 @@ def mask(self, frame_index: int, face_index: int, mask: np.ndarray, mask_type: s face = self._faces_at_frame_index(frame_index)[face_index] face.mask[mask_type].replace_mask(mask) self._tk_edited.set(True) - self._globals.tk_update.set(True) + self._globals.var_full_update.set(True) def copy(self, frame_index: int, direction: T.Literal["prev", "next"]) -> None: """ Copy the alignments from the previous or next frame that has alignments @@ -903,7 +907,7 @@ def copy(self, frame_index: int, direction: T.Literal["prev", "next"]) -> None: faces.extend(copied) self._tk_face_count_changed.set(True) - self._globals.tk_update.set(True) + self._globals.var_full_update.set(True) def post_edit_trigger(self, frame_index: int, face_index: int) -> None: """ Update the jpg thumbnail, the viewport thumbnail, the landmark masks and the aligned @@ -922,11 +926,11 @@ def post_edit_trigger(self, frame_index: int, face_index: int) -> None: face.clear_all_identities() aligned = AlignedFace(face.landmarks_xy, - image=self._globals.current_frame["image"], + image=self._globals.current_frame.image, centering="head", size=96) assert aligned.face is not None face.thumbnail = generate_thumbnail(aligned.face, size=96) - if self._globals.filter_mode == "Misaligned Faces": + if self._globals.var_filter_mode.get() == "Misaligned Faces": self._detected_faces.tk_face_count_changed.set(True) self._tk_edited.set(True) diff --git a/tools/manual/faceviewer/frame.py b/tools/manual/faceviewer/frame.py index 27737dbb30..5c6c8f024b 100644 --- a/tools/manual/faceviewer/frame.py +++ b/tools/manual/faceviewer/frame.py @@ -38,7 +38,7 @@ class FacesFrame(ttk.Frame): # pylint:disable=too-many-ancestors Parameters ---------- - parent: :class:`ttk.PanedWindow` + parent: :class:`ttk.Frame` The paned window that the faces frame resides in tk_globals: :class:`~tools.manual.manual.TkGlobals` The tkinter variables that apply to the whole of the GUI @@ -48,7 +48,7 @@ class FacesFrame(ttk.Frame): # pylint:disable=too-many-ancestors The section of the Manual Tool that holds the frames viewer """ def __init__(self, - parent: ttk.PanedWindow, + parent: ttk.Frame, tk_globals: TkGlobals, detected_faces: DetectedFaces, display_frame: DisplayFrame) -> None: @@ -282,7 +282,7 @@ def __init__(self, parent: ttk.Frame, def face_size(self) -> int: """ int: The currently selected thumbnail size in pixels """ scaling = get_config().scaling_factor - size = self._sizes[self._globals.tk_faces_size.get().lower().replace(" ", "")] + size = self._sizes[self._globals.var_faces_size.get().lower().replace(" ", "")] scaled = size * scaling return int(round(scaled / 2) * 2) @@ -328,10 +328,11 @@ def _set_tk_callbacks(self, detected_faces: DetectedFaces): Updates the mask type when the user changes the selected mask types Toggles the face viewer annotations on an optional annotation button press. """ - for var in (self._globals.tk_faces_size, self._globals.tk_filter_mode): - var.trace_add("write", lambda *e, v=var: self.refresh_grid(v)) - var = detected_faces.tk_face_count_changed - var.trace_add("write", lambda *e, v=var: self.refresh_grid(v, retain_position=True)) + for strvar in (self._globals.var_faces_size, self._globals.var_filter_mode): + strvar.trace_add("write", lambda *e, v=strvar: self.refresh_grid(v)) + boolvar = detected_faces.tk_face_count_changed + boolvar.trace_add("write", + lambda *e, v=boolvar: self.refresh_grid(v, retain_position=True)) self._display_frame.tk_control_colors["Mesh"].trace_add( "write", lambda *e: self._update_mesh_color()) diff --git a/tools/manual/faceviewer/interact.py b/tools/manual/faceviewer/interact.py index ae8edf3707..124629320c 100644 --- a/tools/manual/faceviewer/interact.py +++ b/tools/manual/faceviewer/interact.py @@ -83,7 +83,7 @@ def on_hover(self, event: tk.Event | None) -> None: is_zoomed = self._globals.is_zoomed if (-1 in face or (frame_idx == self._globals.frame_index and (not is_zoomed or - (is_zoomed and face_idx == self._globals.tk_face_index.get())))): + (is_zoomed and face_idx == self._globals.face_index)))): self._clear() self._canvas.config(cursor="") self._current_frame_index = None @@ -125,14 +125,14 @@ def _select_frame(self) -> None: if frame_id is None or (frame_id == self._globals.frame_index and not is_zoomed): return face_idx = self._current_face_index if is_zoomed else 0 - self._globals.tk_face_index.set(face_idx) + self._globals.set_face_index(face_idx) transport_id = self._grid.transport_index_from_frame(frame_id) logger.trace("frame_index: %s, transport_id: %s, face_idx: %s", frame_id, transport_id, face_idx) if transport_id is None: return self._navigation.stop_playback() - self._globals.tk_transport_index.set(transport_id) + self._globals.var_transport_index.set(transport_id) self._viewport.move_active_to_top() self.on_hover(None) @@ -192,8 +192,8 @@ def __init__(self, viewport: Viewport, tk_edited_variable: tk.BooleanVar) -> Non "edited": tk_edited_variable} self._assets: Asset = Asset([], [], [], []) - self._globals.tk_update_active_viewport.trace_add("write", - lambda *e: self._reload_callback()) + self._globals.var_update_active_viewport.trace_add("write", + lambda *e: self._reload_callback()) tk_edited_variable.trace_add("write", lambda *e: self._update_on_edit()) logger.debug("Initialized: %s", self.__class__.__name__) @@ -205,7 +205,7 @@ def frame_index(self) -> int: @property def current_frame(self) -> np.ndarray: """ :class:`numpy.ndarray`: A BGR version of the frame currently being displayed. """ - return self._globals.current_frame["image"] + return self._globals.current_frame.image @property def _size(self) -> int: @@ -221,7 +221,7 @@ def _optional_annotations(self) -> dict[T.Literal["mesh", "mask"], bool]: def _reload_callback(self) -> None: """ If a frame has changed, triggering the variable, then update the active frame. Return having done nothing if the variable is resetting. """ - if self._globals.tk_update_active_viewport.get(): + if self._globals.var_update_active_viewport.get(): self.reload_annotations() def reload_annotations(self) -> None: @@ -249,7 +249,7 @@ def reload_annotations(self) -> None: self._update_face() self._canvas.tag_raise("active_highlighter") - self._globals.tk_update_active_viewport.set(False) + self._globals.var_update_active_viewport.set(False) self._last_execution["frame_index"] = self.frame_index def _clear_previous(self) -> None: diff --git a/tools/manual/frameviewer/control.py b/tools/manual/frameviewer/control.py index 8230f24b00..8315cf6a3a 100644 --- a/tools/manual/frameviewer/control.py +++ b/tools/manual/frameviewer/control.py @@ -48,11 +48,11 @@ def nav_scale_callback(self, *args, reset_progress=True): # pylint:disable=unus frame_count = self._det_faces.filter.count if self._current_nav_frame_count == frame_count: logger.trace("Filtered count has not changed. Returning") - if self._globals.tk_filter_mode.get() == "Misaligned Faces": + if self._globals.var_filter_mode.get() == "Misaligned Faces": self._det_faces.tk_face_count_changed.set(True) self._update_total_frame_count() if reset_progress: - self._globals.tk_transport_index.set(0) + self._globals.var_transport_index.set(0) def _update_total_frame_count(self, *args): # pylint:disable=unused-argument """ Update the displayed number of total frames that meet the current filter criteria. @@ -70,7 +70,7 @@ def _update_total_frame_count(self, *args): # pylint:disable=unused-argument logger.debug("Filtered frame count has changed. Updating from %s to %s", self._current_nav_frame_count, frame_count) self._nav["scale"].config(to=max_frame) - self._nav["label"].config(text="/{}".format(max_frame)) + self._nav["label"].config(text=f"/{max_frame}") state = "disabled" if max_frame == 0 else "normal" self._nav["entry"].config(state=state) @@ -106,7 +106,7 @@ def increment_frame(self, frame_count=None, is_playing=False): logger.debug("End of Stream. Not incrementing") self.stop_playback() return - self._globals.tk_transport_index.set(min(position + 1, max(0, frame_count - 1))) + self._globals.var_transport_index.set(min(position + 1, max(0, frame_count - 1))) def decrement_frame(self): """ Update The frame navigation position to the previous frame based on filter. """ @@ -116,11 +116,11 @@ def decrement_frame(self): if not face_count_change and (self._det_faces.filter.count == 0 or position == 0): logger.debug("End of Stream. Not decrementing") return - self._globals.tk_transport_index.set(min(max(0, self._det_faces.filter.count - 1), - max(0, position - 1))) + self._globals.var_transport_index.set(min(max(0, self._det_faces.filter.count - 1), + max(0, position - 1))) def _get_safe_frame_index(self): - """ Obtain the current frame position from the tk_transport_index variable in + """ Obtain the current frame position from the var_transport_index variable in a safe manner (i.e. handle for non-numeric) Returns @@ -129,32 +129,32 @@ def _get_safe_frame_index(self): The current transport frame index """ try: - retval = self._globals.tk_transport_index.get() + retval = self._globals.var_transport_index.get() except tk.TclError as err: if "expected floating-point" not in str(err): raise - val = str(err).split(" ")[-1].replace("\"", "") + val = str(err).rsplit(" ", maxsplit=1)[-1].replace("\"", "") retval = "".join(ch for ch in val if ch.isdigit()) retval = 0 if not retval else int(retval) - self._globals.tk_transport_index.set(retval) + self._globals.var_transport_index.set(retval) return retval def goto_first_frame(self): """ Go to the first frame that meets the filter criteria. """ self.stop_playback() - position = self._globals.tk_transport_index.get() + position = self._globals.var_transport_index.get() if position == 0: return - self._globals.tk_transport_index.set(0) + self._globals.var_transport_index.set(0) def goto_last_frame(self): """ Go to the last frame that meets the filter criteria. """ self.stop_playback() - position = self._globals.tk_transport_index.get() + position = self._globals.var_transport_index.get() frame_count = self._det_faces.filter.count if position == frame_count - 1: return - self._globals.tk_transport_index.set(frame_count - 1) + self._globals.var_transport_index.set(frame_count - 1) class BackgroundImage(): @@ -190,7 +190,7 @@ def refresh(self, view_mode): """ self._switch_image(view_mode) logger.trace("Updating background frame") - getattr(self, "_update_tk_{}".format(self._current_view_mode))() + getattr(self, f"_update_tk_{self._current_view_mode}")() def _switch_image(self, view_mode): """ Switch the image between the full frame image and the zoomed face image. @@ -206,10 +206,10 @@ def _switch_image(self, view_mode): self._zoomed_centering = self._canvas.active_editor.zoomed_centering logger.trace("Switching background image from '%s' to '%s'", self._current_view_mode, view_mode) - img = getattr(self, "_tk_{}".format(view_mode)) + img = getattr(self, f"_tk_{view_mode}") self._canvas.itemconfig(self._image, image=img) - self._globals.tk_is_zoomed.set(view_mode == "face") - self._globals.tk_face_index.set(0) + self._globals.set_zoomed(view_mode == "face") + self._globals.set_face_index(0) def _update_tk_face(self): """ Update the currently zoomed face. """ @@ -239,14 +239,14 @@ def _get_zoomed_face(self): if face_idx + 1 > faces_in_frame: logger.debug("Resetting face index to 0 for more faces in frame than current index: (" "faces_in_frame: %s, zoomed_face_index: %s", faces_in_frame, face_idx) - self._globals.tk_face_index.set(0) + self._globals.set_face_index(0) if faces_in_frame == 0: face = np.ones((size, size, 3), dtype="uint8") else: det_face = self._det_faces.current_faces[frame_idx][face_idx] face = AlignedFace(det_face.landmarks_xy, - image=self._globals.current_frame["image"], + image=self._globals.current_frame.image, centering=self._zoomed_centering, size=size).face logger.trace("face shape: %s", face.shape) @@ -254,9 +254,9 @@ def _get_zoomed_face(self): def _update_tk_frame(self): """ Place the currently held frame into :attr:`_tk_frame`. """ - img = cv2.resize(self._globals.current_frame["image"], - self._globals.current_frame["display_dims"], - interpolation=self._globals.current_frame["interpolation"])[..., 2::-1] + img = cv2.resize(self._globals.current_frame.image, + self._globals.current_frame.display_dims, + interpolation=self._globals.current_frame.interpolation)[..., 2::-1] padding = self._get_padding(img.shape[:2]) if any(padding): img = cv2.copyMakeBorder(img, *padding, cv2.BORDER_CONSTANT) diff --git a/tools/manual/frameviewer/editor/_base.py b/tools/manual/frameviewer/editor/_base.py index c4e9ebb6bf..d295c0d847 100644 --- a/tools/manual/frameviewer/editor/_base.py +++ b/tools/manual/frameviewer/editor/_base.py @@ -41,9 +41,9 @@ def __init__(self, canvas, detected_faces, control_text="", key_bindings=None): self._globals = canvas._globals self._det_faces = detected_faces - self._current_color = dict() + self._current_color = {} self._actions = OrderedDict() - self._controls = dict(header=control_text, controls=[]) + self._controls = {"header": control_text, "controls": []} self._add_key_bindings(key_bindings) self._add_actions() @@ -51,7 +51,7 @@ def __init__(self, canvas, detected_faces, control_text="", key_bindings=None): self._add_annotation_format_controls() self._mouse_location = None - self._drag_data = dict() + self._drag_data = {} self._drag_callback = None self.bind_mouse_motion() logger.debug("Initialized %s", self.__class__.__name__) @@ -80,7 +80,7 @@ def _is_active(self): def view_mode(self): """ ["frame", "face"]: The view mode for the currently selected editor. If the editor does not have a view mode that can be updated, then `"frame"` will be returned. """ - tk_var = self._actions.get("magnify", dict()).get("tk_var", None) + tk_var = self._actions.get("magnify", {}).get("tk_var", None) retval = "frame" if tk_var is None or not tk_var.get() else "face" return retval @@ -106,7 +106,7 @@ def _zoomed_dims(self): @property def _control_vars(self): """ dict: The tk control panel variables for the currently selected editor. """ - return self._canvas.control_tk_vars.get(self.__class__.__name__, dict()) + return self._canvas.control_tk_vars.get(self.__class__.__name__, {}) @property def controls(self): @@ -155,7 +155,7 @@ def _add_key_bindings(self, key_bindings): for key, method in key_bindings.items(): logger.debug("Binding key '%s' to method %s for editor '%s'", key, method, self.__class__.__name__) - self._canvas.key_bindings.setdefault(key, dict())["bound_to"] = None + self._canvas.key_bindings.setdefault(key, {})["bound_to"] = None self._canvas.key_bindings[key][self.__class__.__name__] = method @staticmethod @@ -187,7 +187,7 @@ def _get_anchor_points(bounding_box): for cnr in bounding_box) return display_anchors, grab_anchors - def update_annotation(self): # pylint:disable=no-self-use + def update_annotation(self): """ Update the display annotations for the current objects. Override for specific editors. @@ -233,7 +233,7 @@ def _object_tracker(self, key, object_type, face_index, """ object_color_keys = self._get_object_color_keys(key, object_type) tracking_id = "_".join((key, str(face_index))) - face_tag = "face_{}".format(face_index) + face_tag = f"face_{face_index}" face_objects = set(self._canvas.find_withtag(face_tag)) annotation_objects = set(self._canvas.find_withtag(key)) existing_object = tuple(face_objects.intersection(annotation_objects)) @@ -311,7 +311,7 @@ def _add_new_object(self, key, object_type, face_index, coordinates, object_kwar coordinates, object_kwargs) object_kwargs["tags"] = self._set_object_tags(face_index, key) item_id = getattr(self._canvas, - "create_{}".format(object_type))(*coordinates, **object_kwargs) + f"create_{object_type}")(*coordinates, **object_kwargs) return item_id def _set_object_tags(self, face_index, key): @@ -329,17 +329,17 @@ def _set_object_tags(self, face_index, key): list The generated tags for the current object """ - tags = ["face_{}".format(face_index), + tags = [f"face_{face_index}", self.__class__.__name__, - "{}_face_{}".format(self.__class__.__name__, face_index), + f"{self.__class__.__name__}_face_{face_index}", key, - "{}_face_{}".format(key, face_index)] + f"{key}_face_{face_index}"] if "_" in key: split_key = key.split("_") if split_key[-1].isdigit(): base_tag = "_".join(split_key[:-1]) tags.append(base_tag) - tags.append("{}_face_{}".format(base_tag, face_index)) + tags.append(f"{base_tag}_face_{face_index}") return tags def _update_existing_object(self, item_id, coordinates, object_kwargs, @@ -366,11 +366,11 @@ def _update_existing_object(self, item_id, coordinates, object_kwargs, """ update_color = (object_color_keys and object_kwargs[object_color_keys[0]] != self._current_color[tracking_id]) - update_kwargs = dict(state=object_kwargs.get("state", "normal")) + update_kwargs = {"state": object_kwargs.get("state", "normal")} if update_color: for key in object_color_keys: update_kwargs[key] = object_kwargs[object_color_keys[0]] - if self._canvas.type(item_id) == "image" and "image" in object_kwargs: + if self._canvas.type(item_id) == "image" and "image" in object_kwargs: # noqa:E721 update_kwargs["image"] = object_kwargs["image"] logger.trace("Updating coordinates: (item_id: '%s', object_kwargs: %s, " "coordinates: %s, update_kwargs: %s", item_id, object_kwargs, @@ -433,7 +433,7 @@ def _drag_start(self, event): # pylint:disable=unused-argument The tkinter mouse event. Unused but for default action, but available for editor specific actions """ - self._drag_data = dict() + self._drag_data = {} self._drag_callback = None def _drag(self, event): @@ -461,7 +461,7 @@ def _drag_stop(self, event): # pylint:disable=unused-argument event: :class:`tkinter.Event` The tkinter mouse event. Unused but required """ - self._drag_data = dict() + self._drag_data = {} def _scale_to_display(self, points): """ Scale and offset the given points to the current display scale and offset values. @@ -476,7 +476,7 @@ def _scale_to_display(self, points): :class:`numpy.ndarray` The adjusted x, y co-ordinates for display purposes rounded to the nearest integer """ - retval = np.rint((points * self._globals.current_frame["scale"]) + retval = np.rint((points * self._globals.current_frame.scale) + self._canvas.offset).astype("int32") logger.trace("Original points: %s, scaled points: %s", points, retval) return retval @@ -499,7 +499,7 @@ def scale_from_display(self, points, do_offset=True): integer """ offset = self._canvas.offset if do_offset else (0, 0) - retval = np.rint((points - offset) / self._globals.current_frame["scale"]).astype("int32") + retval = np.rint((points - offset) / self._globals.current_frame.scale).astype("int32") logger.trace("Original points: %s, scaled points: %s", points, retval) return retval @@ -532,7 +532,11 @@ def _add_action(self, title, icon, helptext, group=None, hotkey=None): Default: ``None`` """ var = tk.BooleanVar() - action = dict(icon=icon, helptext=helptext, group=group, tk_var=var, hotkey=hotkey) + action = {"icon": icon, + "helptext": helptext, + "group": group, + "tk_var": var, + "hotkey": hotkey} logger.debug("Adding action: %s", action) self._actions[title] = action @@ -567,7 +571,7 @@ def _add_control(self, option, global_control=False): group_key = "none" if group_key == "_master" else group_key annotation_key = option.title.replace(" ", "") self._canvas.control_tk_vars.setdefault( - editor_key, dict()).setdefault(group_key, dict())[annotation_key] = option.tk_var + editor_key, {}).setdefault(group_key, {})[annotation_key] = option.tk_var def _add_annotation_format_controls(self): """ Add the annotation display (color/size) controls to :attr:`_annotation_formats`. @@ -594,7 +598,7 @@ def _add_annotation_format_controls(self): default=self._default_colors[annotation_key], helptext="Set the annotation color") colors.set(self._default_colors[annotation_key]) - self._annotation_formats.setdefault(annotation_key, dict())["color"] = colors + self._annotation_formats.setdefault(annotation_key, {})["color"] = colors self._annotation_formats[annotation_key]["mask_opacity"] = opacity for editor in editors: @@ -627,4 +631,6 @@ def _add_actions(self): """ Add the optional action buttons to the viewer. Current actions are Zoom. """ self._add_action("magnify", "zoom", _("Magnify/Demagnify the View"), group=None, hotkey="M") - self._actions["magnify"]["tk_var"].trace("w", lambda *e: self._globals.tk_update.set(True)) + self._actions["magnify"]["tk_var"].trace_add( + "write", + lambda *e: self._globals.var_full_update.set(True)) diff --git a/tools/manual/frameviewer/editor/bounding_box.py b/tools/manual/frameviewer/editor/bounding_box.py index f3bbd78d00..d546feb172 100644 --- a/tools/manual/frameviewer/editor/bounding_box.py +++ b/tools/manual/frameviewer/editor/bounding_box.py @@ -105,7 +105,7 @@ def update_annotation(self): for idx, face in enumerate(self._face_iterator): box = np.array([(face.left, face.top), (face.right, face.bottom)]) box = self._scale_to_display(box).astype("int32").flatten() - kwargs = dict(outline=color, width=1) + kwargs = {"outline": color, "width": 1} logger.trace("frame_index: %s, face_index: %s, box: %s, kwargs: %s", self._globals.frame_index, idx, box, kwargs) self._object_tracker(key, "rectangle", idx, box, kwargs) @@ -137,10 +137,10 @@ def _update_anchor_annotation(self, face_index, bounding_box, color): (bounding_box[2], bounding_box[3]), (bounding_box[0], bounding_box[3]))) for idx, (anc_dsp, anc_grb) in enumerate(zip(*anchor_points)): - dsp_kwargs = dict(outline=color, fill=fill_color, width=1) - grb_kwargs = dict(outline="", fill="", width=1, activefill=activefill_color) - dsp_key = "bb_anc_dsp_{}".format(idx) - grb_key = "bb_anc_grb_{}".format(idx) + dsp_kwargs = {"outline": color, "fill": fill_color, "width": 1} + grb_kwargs = {"outline": '', "fill": '', "width": 1, "activefill": activefill_color} + dsp_key = f"bb_anc_dsp_{idx}" + grb_key = f"bb_anc_grb_{idx}" self._object_tracker(dsp_key, "oval", face_index, anc_dsp, dsp_kwargs) self._object_tracker(grb_key, "oval", face_index, anc_grb, grb_kwargs) logger.trace("Updated bounding box anchor annotations") @@ -193,8 +193,9 @@ def _check_cursor_anchors(self): corner_idx = int(next(tag for tag in tags if tag.startswith("bb_anc_grb_") and "face_" not in tag).split("_")[-1]) - self._canvas.config(cursor="{}_{}_corner".format(*self._corner_order[corner_idx])) - self._mouse_location = ("anchor", "{}_{}".format(face_idx, corner_idx)) + pos_x, pos_y = self._corner_order[corner_idx] + self._canvas.config(cursor=f"{pos_x}_{pos_y}_corner") + self._mouse_location = ("anchor", f"{face_idx}_{corner_idx}") return True def _check_cursor_bounding_box(self, event): @@ -242,7 +243,7 @@ def _check_cursor_image(self, event): """ if self._globals.frame_index == -1: return False - display_dims = self._globals.current_frame["display_dims"] + display_dims = self._globals.current_frame.display_dims if (self._canvas.offset[0] <= event.x <= display_dims[0] + self._canvas.offset[0] and self._canvas.offset[1] <= event.y <= display_dims[1] + self._canvas.offset[1]): self._canvas.config(cursor="plus") @@ -275,7 +276,7 @@ def _drag_start(self, event): The tkinter mouse event. """ if self._mouse_location is None: - self._drag_data = dict() + self._drag_data = {} self._drag_callback = None return if self._mouse_location[0] == "anchor": @@ -315,7 +316,7 @@ def _create_new_bounding_box(self, event): event: :class:`tkinter.Event` The tkinter mouse event """ - size = min(self._globals.current_frame["display_dims"]) // 8 + size = min(self._globals.current_frame.display_dims) // 8 box = (event.x - size, event.y - size, event.x + size, event.y + size) logger.debug("Creating new bounding box: %s ", box) self._det_faces.update.add(self._globals.frame_index, *self._coords_to_bounding_box(box)) @@ -329,7 +330,7 @@ def _resize(self, event): The tkinter mouse event. """ face_idx = int(self._mouse_location[1].split("_")[0]) - face_tag = "bb_box_face_{}".format(face_idx) + face_tag = f"bb_box_face_{face_idx}" box = self._canvas.coords(face_tag) logger.trace("Face Index: %s, Corner Index: %s. Original ROI: %s", face_idx, self._drag_data["corner"], box) @@ -361,7 +362,7 @@ def _move(self, event): face_idx = int(self._mouse_location[1]) shift = (event.x - self._drag_data["current_location"][0], event.y - self._drag_data["current_location"][1]) - face_tag = "bb_box_face_{}".format(face_idx) + face_tag = f"bb_box_face_{face_idx}" coords = np.array(self._canvas.coords(face_tag)) + (*shift, *shift) logger.trace("face_tag: %s, shift: %s, new co-ords: %s", face_tag, shift, coords) self._det_faces.update.bounding_box(self._globals.frame_index, diff --git a/tools/manual/frameviewer/editor/extract_box.py b/tools/manual/frameviewer/editor/extract_box.py index f49acc055b..ffe8bf4734 100644 --- a/tools/manual/frameviewer/editor/extract_box.py +++ b/tools/manual/frameviewer/editor/extract_box.py @@ -61,9 +61,9 @@ def update_annotation(self): aligned = AlignedFace(face.landmarks_xy, centering="face") box = self._scale_to_display(aligned.original_roi).flatten() top_left = box[:2] - 10 - kwargs = dict(fill=color, font=("Default", 20, "bold"), text=str(idx)) + kwargs = {"fill": color, "font": ('Default', 20, 'bold'), "text": str(idx)} self._object_tracker("eb_text", "text", idx, top_left, kwargs) - kwargs = dict(fill="", outline=color, width=1) + kwargs = {"fill": '', "outline": color, "width": 1} self._object_tracker("eb_box", "polygon", idx, box, kwargs) self._update_anchor_annotation(idx, box, color) logger.trace("Updated extract box annotations") @@ -93,10 +93,10 @@ def _update_anchor_annotation(self, face_index, extract_box, color): extract_box[4:6], extract_box[6:])) for idx, (anc_dsp, anc_grb) in enumerate(zip(*anchor_points)): - dsp_kwargs = dict(outline=color, fill=fill_color, width=1) - grb_kwargs = dict(outline="", fill="", width=1, activefill=activefill_color) - dsp_key = "eb_anc_dsp_{}".format(idx) - grb_key = "eb_anc_grb_{}".format(idx) + dsp_kwargs = {"outline": color, "fill": fill_color, "width": 1} + grb_kwargs = {"outline": '', "fill": '', "width": 1, "activefill": activefill_color} + dsp_key = f"eb_anc_dsp_{idx}" + grb_key = f"eb_anc_grb_{idx}" self._object_tracker(dsp_key, "oval", face_index, anc_dsp, dsp_kwargs) self._object_tracker(grb_key, "oval", face_index, anc_grb, grb_kwargs) logger.trace("Updated extract box anchor annotations") @@ -143,7 +143,8 @@ def _check_cursor_anchors(self): if tag.startswith("eb_anc_grb_") and "face_" not in tag).split("_")[-1]) - self._canvas.config(cursor="{}_{}_corner".format(*self._corner_order[corner_idx])) + pos_x, pos_y = self._corner_order[corner_idx] + self._canvas.config(cursor=f"{pos_x}_{pos_y}_corner") self._mouse_location = ("anchor", face_idx, corner_idx) return True @@ -222,11 +223,11 @@ def _drag_start(self, event): The tkinter mouse event. """ if self._mouse_location is None: - self._drag_data = dict() + self._drag_data = {} self._drag_callback = None return self._drag_data["current_location"] = np.array((event.x, event.y)) - callback = dict(anchor=self._resize, rotate=self._rotate, box=self._move) + callback = {"anchor": self._resize, "rotate": self._rotate, "box": self._move} self._drag_callback = callback[self._mouse_location[0]] def _drag_stop(self, event): # pylint:disable=unused-argument @@ -270,7 +271,7 @@ def _resize(self, event): The tkinter mouse event. """ face_idx = self._mouse_location[1] - face_tag = "eb_box_face_{}".format(face_idx) + face_tag = f"eb_box_face_{face_idx}" position = np.array((event.x, event.y)) box = np.array(self._canvas.coords(face_tag)) center = np.array((sum(box[0::2]) / 4, sum(box[1::2]) / 4)) @@ -365,7 +366,7 @@ def _rotate(self, event): The tkinter mouse event. """ face_idx = self._mouse_location[1] - face_tag = "eb_box_face_{}".format(face_idx) + face_tag = f"eb_box_face_{face_idx}" box = np.array(self._canvas.coords(face_tag)) position = np.array((event.x, event.y)) diff --git a/tools/manual/frameviewer/editor/landmarks.py b/tools/manual/frameviewer/editor/landmarks.py index 452e426ab0..e59517e7b0 100644 --- a/tools/manual/frameviewer/editor/landmarks.py +++ b/tools/manual/frameviewer/editor/landmarks.py @@ -36,7 +36,7 @@ def __init__(self, canvas, detected_faces): super().__init__(canvas, detected_faces, control_text) # Clear selection box on an editor or frame change self._canvas._tk_action_var.trace("w", lambda *e: self._reset_selection()) - self._globals.tk_frame_index.trace("w", lambda *e: self._reset_selection()) + self._globals.var_frame_index.trace_add("write", lambda *e: self._reset_selection()) def _add_actions(self): """ Add the optional action buttons to the viewer. Current actions are Point, Select @@ -55,7 +55,7 @@ def _toggle_zoom(self, *args): # pylint:disable=unused-argument tkinter callback arguments. Required but unused. """ self._reset_selection() - self._globals.tk_update.set(True) + self._globals.var_full_update.set(True) def _reset_selection(self, event=None): # pylint:disable=unused-argument """ Reset the selection box and the selected landmark annotations. """ diff --git a/tools/manual/frameviewer/editor/mask.py b/tools/manual/frameviewer/editor/mask.py index 66372ce522..fec2c92d13 100644 --- a/tools/manual/frameviewer/editor/mask.py +++ b/tools/manual/frameviewer/editor/mask.py @@ -82,7 +82,9 @@ def _add_actions(self): group=None, hotkey="M") self._add_action("draw", "draw", _("Draw Tool"), group="paint", hotkey="D") self._add_action("erase", "erase", _("Erase Tool"), group="paint", hotkey="E") - self._actions["magnify"]["tk_var"].trace("w", lambda *e: self._globals.tk_update.set(True)) + self._actions["magnify"]["tk_var"].trace( + "w", + lambda *e: self._globals.var_full_update.set(True)) def _add_controls(self): """ Add the mask specific control panel controls. @@ -143,21 +145,21 @@ def _on_mask_type_change(self): mask_type = self._control_vars["display"]["MaskType"].get() if mask_type == self._mask_type: return - self._meta = dict(position=self._globals.frame_index) + self._meta = {"position": self._globals.frame_index} self._mask_type = mask_type - self._globals.tk_update.set(True) + self._globals.var_full_update.set(True) def hide_annotation(self, tag=None): """ Clear the mask :attr:`_meta` dict when hiding the annotation. """ super().hide_annotation() - self._meta = dict() + self._meta = {} def update_annotation(self): """ Update the mask annotation with the latest mask. """ position = self._globals.frame_index if position != self._meta.get("position", -1): # Reset meta information when moving to a new frame - self._meta = dict(position=position) + self._meta = {"position": position} key = self.__class__.__name__ mask_type = self._control_vars["display"]["MaskType"].get().lower() color = self._control_color[1:] @@ -221,21 +223,21 @@ def _set_full_frame_meta(self, mask, mask_scale): - slices: The (`x`, `y`) slice objects required to extract the mask ROI from the full frame """ - frame_dims = self._globals.current_frame["display_dims"] + frame_dims = self._globals.current_frame.display_dims scaled_mask_roi = np.rint(mask.original_roi * - self._globals.current_frame["scale"]).astype("int32") + self._globals.current_frame.scale).astype("int32") # Scale and clip the ROI to fit within display frame boundaries clipped_roi = scaled_mask_roi.clip(min=(0, 0), max=frame_dims) # Obtain min and max points to get ROI as a rectangle - min_max = dict(min=clipped_roi.min(axis=0), max=clipped_roi.max(axis=0)) + min_max = {"min": clipped_roi.min(axis=0), "max": clipped_roi.max(axis=0)} # Create a bounding box rectangle ROI roi_dims = np.rint((min_max["max"][1] - min_max["min"][1], min_max["max"][0] - min_max["min"][0])).astype("uint16") - roi = dict(mask=np.zeros(roi_dims, dtype="uint8")[..., None], - corners=np.expand_dims(scaled_mask_roi - min_max["min"], axis=0)) + roi = {"mask": np.zeros(roi_dims, dtype="uint8")[..., None], + "corners": np.expand_dims(scaled_mask_roi - min_max["min"], axis=0)} # Block out areas outside of the actual mask ROI polygon cv2.fillPoly(roi["mask"], roi["corners"], 255) logger.trace("Setting Full Frame mask ROI. shape: %s", roi["mask"].shape) @@ -246,8 +248,8 @@ def _set_full_frame_meta(self, mask, mask_scale): # Adjust affine matrix for internal mask size and display dimensions adjustments = (np.array([[mask_scale, 0., 0.], [0., mask_scale, 0.]]), - np.array([[1 / self._globals.current_frame["scale"], 0., 0.], - [0., 1 / self._globals.current_frame["scale"], 0.], + np.array([[1 / self._globals.current_frame.scale, 0., 0.], + [0., 1 / self._globals.current_frame.scale, 0.], [0., 0., 1.]])) in_matrix = np.dot(adjustments[0], np.concatenate((mask.affine_matrix, np.array([[0., 0., 1.]])))) @@ -285,7 +287,7 @@ def _update_mask_image(self, key, face_index, rgb_color, opacity): top_left = self._zoomed_roi[:2] # Hide all masks and only display selected self._canvas.itemconfig("Mask", state="hidden") - self._canvas.itemconfig("Mask_face_{}".format(face_index), state="normal") + self._canvas.itemconfig(f"Mask_face_{face_index}", state="normal") else: display_image = self._update_mask_image_full_frame(mask, rgb_color, face_index) top_left = self._meta["top_left"][face_index] @@ -305,7 +307,7 @@ def _update_mask_image(self, key, face_index, rgb_color, opacity): "image", face_index, top_left, - dict(image=self._tk_faces[face_index], anchor=tk.NW)) + {"image": self._tk_faces[face_index], "anchor": tk.NW}) def _update_mask_image_zoomed(self, mask, rgb_color): """ Update the mask image when zoomed in. @@ -346,7 +348,7 @@ def _update_mask_image_full_frame(self, mask, rgb_color, face_index): :class: `PIL.Image` The full frame mask image formatted for display """ - frame_dims = self._globals.current_frame["display_dims"] + frame_dims = self._globals.current_frame.display_dims frame = np.zeros(frame_dims + (1, ), dtype="uint8") interpolator = self._meta["interpolator"][face_index] slices = self._meta["slices"][face_index] @@ -377,13 +379,13 @@ def _update_roi_box(self, mask, face_index, color): else: box = self._scale_to_display(mask.original_roi).flatten() top_left = box[:2] - 10 - kwargs = dict(fill=color, font=("Default", 20, "bold"), text=str(face_index)) + kwargs = {"fill": color, "font": ("Default", 20, "bold"), "text": str(face_index)} self._object_tracker("mask_text", "text", face_index, top_left, kwargs) - kwargs = dict(fill="", outline=color, width=1) + kwargs = {"fill": "", "outline": color, "width": 1} self._object_tracker("mask_roi", "polygon", face_index, box, kwargs) if self._globals.is_zoomed: # Raise box above zoomed image - self._canvas.tag_raise("mask_roi_face_{}".format(face_index)) + self._canvas.tag_raise(f"mask_roi_face_{face_index}") # << MOUSE HANDLING >> # Mouse cursor display @@ -450,7 +452,7 @@ def _drag_start(self, event, control_click=False): # pylint:disable=arguments-d """ face_idx = self._mouse_location[1] if face_idx is None: - self._drag_data = dict() + self._drag_data = {} self._drag_callback = None else: self._drag_data["starting_location"] = np.array((event.x, event.y)) @@ -532,7 +534,7 @@ def _drag_stop(self, event): if np.array_equal(self._drag_data["starting_location"], location[0]): self._get_cursor_shape_mark(self._meta["mask"][face_idx], location, face_idx) self._mask_to_alignments(face_idx) - self._drag_data = dict() + self._drag_data = {} self._update_cursor(event) def _get_cursor_shape_mark(self, img, location, face_idx): @@ -562,11 +564,10 @@ def _get_cursor_shape_mark(self, img, location, face_idx): else: cv2.circle(img, tuple(points), radius, color, thickness=-1) - def _get_cursor_shape(self, x1=0, y1=0, x2=0, y2=0, outline="black", state="hidden"): + def _get_cursor_shape(self, x_1=0, y_1=0, x_2=0, y_2=0, outline="black", state="hidden"): if self._cursor_shape_name == "Rectangle": - return self._canvas.create_rectangle(x1, y1, x2, y2, outline=outline, state=state) - else: - return self._canvas.create_oval(x1, y1, x2, y2, outline=outline, state=state) + return self._canvas.create_rectangle(x_1, y_1, x_2, y_2, outline=outline, state=state) + return self._canvas.create_oval(x_1, y_1, x_2, y_2, outline=outline, state=state) def _mask_to_alignments(self, face_index): """ Update the annotated mask to alignments. diff --git a/tools/manual/frameviewer/frame.py b/tools/manual/frameviewer/frame.py index 0b761b543b..e83f3492f8 100644 --- a/tools/manual/frameviewer/frame.py +++ b/tools/manual/frameviewer/frame.py @@ -146,41 +146,41 @@ def _add_nav(self): lbl_frame.pack(side=tk.RIGHT) tbox = ttk.Entry(lbl_frame, width=7, - textvariable=self._globals.tk_transport_index, + textvariable=self._globals.var_transport_index, justify=tk.RIGHT) tbox.pack(padx=0, side=tk.LEFT) lbl = ttk.Label(lbl_frame, text=f"/{max_frame}") lbl.pack(side=tk.RIGHT) cmd = partial(set_slider_rounding, - var=self._globals.tk_transport_index, + var=self._globals.var_transport_index, d_type=int, round_to=1, min_max=(0, max_frame)) nav = ttk.Scale(frame, - variable=self._globals.tk_transport_index, + variable=self._globals.var_transport_index, from_=0, to=max_frame, command=cmd) nav.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - self._globals.tk_transport_index.trace("w", self._set_frame_index) + self._globals.var_transport_index.trace_add("write", self._set_frame_index) return {"entry": tbox, "scale": nav, "label": lbl} def _set_frame_index(self, *args): # pylint:disable=unused-argument """ Set the actual frame index based on current slider position and filter mode. """ try: - slider_position = self._globals.tk_transport_index.get() + slider_position = self._globals.var_transport_index.get() except TclError: # don't update the slider when the entry box has been cleared of any value return frames = self._det_faces.filter.frames_list actual_position = max(0, min(len(frames) - 1, slider_position)) if actual_position != slider_position: - self._globals.tk_transport_index.set(actual_position) + self._globals.var_transport_index.set(actual_position) frame_idx = frames[actual_position] if frames else -1 logger.trace("slider_position: %s, frame_idx: %s", actual_position, frame_idx) - self._globals.tk_frame_index.set(frame_idx) + self._globals.var_frame_index.set(frame_idx) def _add_transport(self): """ Add video transport controls """ @@ -237,14 +237,14 @@ def _add_filter_mode_combo(self, frame): frame: :class:`tkinter.ttk.Frame` The Filter Frame that holds the filter combo box """ - self._globals.tk_filter_mode.set("All Frames") - self._globals.tk_filter_mode.trace("w", self._navigation.nav_scale_callback) + self._globals.var_filter_mode.set("All Frames") + self._globals.var_filter_mode.trace("w", self._navigation.nav_scale_callback) nav_frame = ttk.Frame(frame) lbl = ttk.Label(nav_frame, text="Filter:") lbl.pack(side=tk.LEFT, padx=(0, 5)) combo = ttk.Combobox( nav_frame, - textvariable=self._globals.tk_filter_mode, + textvariable=self._globals.var_filter_mode, state="readonly", values=self._filter_modes) combo.pack(side=tk.RIGHT) @@ -260,7 +260,7 @@ def _add_filter_threshold_slider(self, frame): The Filter Frame that holds the filter threshold slider """ slider_frame = ttk.Frame(frame) - tk_var = self._globals.tk_filter_distance + tk_var = self._globals.var_filter_distance min_max = (5, 20) ctl_frame = ttk.Frame(slider_frame) @@ -284,22 +284,22 @@ def _add_filter_threshold_slider(self, frame): Tooltip(item, text=self._helptext["distance"], wrap_length=200) - tk_var.trace("w", self._navigation.nav_scale_callback) + tk_var.trace_add("write", self._navigation.nav_scale_callback) self._optional_widgets["distance_slider"] = slider_frame def pack_threshold_slider(self): """ Display or hide the threshold slider depending on the current filter mode. For misaligned faces filter, display the slider. Hide for all other filters. """ - if self._globals.tk_filter_mode.get() == "Misaligned Faces": + if self._globals.var_filter_mode.get() == "Misaligned Faces": self._optional_widgets["distance_slider"].pack(side=tk.LEFT) else: self._optional_widgets["distance_slider"].pack_forget() def cycle_filter_mode(self): """ Cycle the navigation mode combo entry """ - current_mode = self._globals.filter_mode + current_mode = self._globals.var_filter_mode.get() idx = (self._filter_modes.index(current_mode) + 1) % len(self._filter_modes) - self._globals.tk_filter_mode.set(self._filter_modes[idx]) + self._globals.var_filter_mode.set(self._filter_modes[idx]) def set_action(self, key): """ Set the current action based on keyboard shortcut @@ -318,7 +318,7 @@ def _resize(self, event): framesize = (event.width, event.height) logger.trace("Resizing video frame. Framesize: %s", framesize) self._globals.set_frame_display_dims(*framesize) - self._globals.tk_update.set(True) + self._globals.var_full_update.set(True) # << TRANSPORT >> # def _play(self, *args, frame_count=None): # pylint:disable=unused-argument @@ -475,17 +475,16 @@ def _add_static_buttons(self): sep = ttk.Frame(frame, height=2, relief=tk.RIDGE) sep.pack(fill=tk.X, pady=5, side=tk.TOP) buttons = {} - tk_frame_index = self._globals.tk_frame_index for action in ("copy_prev", "copy_next", "reload"): if action == "reload": icon = "reload3" - cmd = lambda f=tk_frame_index: self._det_faces.revert_to_saved(f.get()) # noqa:E731 # pylint:disable=line-too-long,unnecessary-lambda-assignment + cmd = lambda f=self._globals: self._det_faces.revert_to_saved(f.frame_index) # noqa:E731,E501 # pylint:disable=line-too-long,unnecessary-lambda-assignment helptext = _("Revert to saved Alignments ({})").format(lookup[action][1]) else: icon = action direction = action.replace("copy_", "") - cmd = lambda f=tk_frame_index, d=direction: self._det_faces.update.copy( # noqa:E731 # pylint:disable=line-too-long,unnecessary-lambda-assignment - f.get(), d) + cmd = lambda f=self._globals, d=direction: self._det_faces.update.copy( # noqa:E731,E501 # pylint:disable=line-too-long,unnecessary-lambda-assignment + f.frame_index, d) helptext = _("Copy {} Alignments ({})").format(*lookup[action]) state = ["!disabled"] if action == "copy_next" else ["disabled"] button = ttk.Button(frame, @@ -496,8 +495,8 @@ def _add_static_buttons(self): button.pack() Tooltip(button, text=helptext) buttons[action] = button - self._globals.tk_frame_index.trace("w", self._disable_enable_copy_buttons) - self._globals.tk_update.trace("w", self._disable_enable_reload_button) + self._globals.var_frame_index.trace_add("write", self._disable_enable_copy_buttons) + self._globals.var_full_update.trace_add("write", self._disable_enable_reload_button) return buttons def _disable_enable_copy_buttons(self, *args): # pylint:disable=unused-argument @@ -707,7 +706,7 @@ def editor_display(self): def offset(self): """ tuple: The (`width`, `height`) offset of the canvas based on the size of the currently displayed image """ - frame_dims = self._globals.current_frame["display_dims"] + frame_dims = self._globals.current_frame.display_dims offset_x = (self._globals.frame_display_dims[0] - frame_dims[0]) / 2 offset_y = (self._globals.frame_display_dims[1] - frame_dims[1]) / 2 logger.trace("offset_x: %s, offset_y: %s", offset_x, offset_y) @@ -733,11 +732,11 @@ def _add_callbacks(self): """ Add the callback trace functions to the :class:`tkinter.Variable` s Adds callbacks for: - :attr:`_globals.tk_update` Update the display for the current image + :attr:`_globals.var_full_update` Update the display for the current image :attr:`__tk_action_var` Update the mouse display tracking for current action """ - self._globals.tk_update.trace("w", self._update_display) - self._tk_action_var.trace("w", self._change_active_editor) + self._globals.var_full_update.trace_add("write", self._update_display) + self._tk_action_var.trace_add("write", self._change_active_editor) def _change_active_editor(self, *args): # pylint:disable=unused-argument """ Update the display for the active editor. @@ -757,7 +756,7 @@ def _change_active_editor(self, *args): # pylint:disable=unused-argument self.active_editor.bind_mouse_motion() self.active_editor.set_mouse_click_actions() - self._globals.tk_update.set(True) + self._globals.var_full_update.set(True) def _update_display(self, *args): # pylint:disable=unused-argument """ Update the display on frame cache update @@ -767,7 +766,7 @@ def _update_display(self, *args): # pylint:disable=unused-argument A little hacky, but the editors to display or hide are processed in alphabetical order, so that they are always processed in the same order (for tag lowering and raising) """ - if not self._globals.tk_update.get(): + if not self._globals.var_full_update.get(): return zoomed_centering = self.active_editor.zoomed_centering self._image.refresh(self.active_editor.view_mode) @@ -779,7 +778,7 @@ def _update_display(self, *args): # pylint:disable=unused-argument if zoomed_centering != self.active_editor.zoomed_centering: # Refresh the image if editor annotation has changed the zoom centering of the image self._image.refresh(self.active_editor.view_mode) - self._globals.tk_update.set(False) + self._globals.var_full_update.set(False) self.update_idletasks() def _hide_additional_faces(self): diff --git a/tools/manual/globals.py b/tools/manual/globals.py new file mode 100644 index 0000000000..07843f552e --- /dev/null +++ b/tools/manual/globals.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +""" Holds global tkinter variables and information pertaining to the entire Manual tool """ +from __future__ import annotations + +import logging +import os +import sys +import tkinter as tk + +from dataclasses import dataclass, field + +import cv2 +import numpy as np + +from lib.gui.utils import get_config +from lib.logger import parse_class_init +from lib.utils import VIDEO_EXTENSIONS + +logger = logging.getLogger(__name__) + + +@dataclass +class CurrentFrame: + """ Dataclass for holding information about the currently displayed frame """ + image: np.ndarray = field(default_factory=lambda: np.zeros(1)) + """:class:`numpy.ndarry`: The currently displayed frame in original dimensions """ + scale: float = 1.0 + """float: The scaling factor to use to resize the image to the display window """ + interpolation: int = cv2.INTER_AREA + """int: The opencv interpolator ID to use for resizing the image to the display window """ + display_dims: tuple[int, int] = (0, 0) + """tuple[int, int]`: The size of the currently displayed frame, in the display window """ + filename: str = "" + """str: The filename of the currently displayed frame """ + + def __repr__(self) -> str: + """ Clean string representation showing numpy arrays as shape and dtype + + Returns + ------- + str + Loggable representation of the dataclass + """ + properties = [f"{k}={(v.shape, v.dtype) if isinstance(v, np.ndarray) else v}" + for k, v in self.__dict__.items()] + return f"{self.__class__.__name__} ({', '.join(properties)}" + + +@dataclass +class TKVars: + """ Holds the global TK Variables """ + frame_index: tk.IntVar + """:class:`tkinter.IntVar`: The absolute frame index of the currently displayed frame""" + transport_index: tk.IntVar + """:class:`tkinter.IntVar`: The transport index of the currently displayed frame when filters + have been applied """ + face_index: tk.IntVar + """:class:`tkinter.IntVar`: The face index of the currently selected face""" + filter_distance: tk.IntVar + """:class:`tkinter.IntVar`: The amount to filter by distance""" + + update: tk.BooleanVar + """:class:`tkinter.BooleanVar`: Whether an update has been performed """ + update_active_viewport: tk.BooleanVar + """:class:`tkinter.BooleanVar`: Whether the viewport needs updating """ + is_zoomed: tk.BooleanVar + """:class:`tkinter.BooleanVar`: Whether the main window is zoomed in to a face or out to a + full frame""" + + filter_mode: tk.StringVar + """:class:`tkinter.StringVar`: The currently selected filter mode """ + faces_size: tk.StringVar + """:class:`tkinter.StringVar`: The pixel size of faces in the viewport """ + + def __repr__(self) -> str: + """ Clean string representation showing variable type as well as their value + + Returns + ------- + str + Loggable representation of the dataclass + """ + properties = [f"{k}={v.__class__.__name__}({v.get()})" for k, v in self.__dict__.items()] + return f"{self.__class__.__name__} ({', '.join(properties)}" + + +class TkGlobals(): + """ Holds Tkinter Variables and other frame information that need to be accessible from all + areas of the GUI. + + Parameters + ---------- + input_location: str + The location of the input folder of frames or video file + """ + def __init__(self, input_location: str) -> None: + logger.debug(parse_class_init(locals())) + self._tk_vars = self._get_tk_vars() + + self._is_video = self._check_input(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 = CurrentFrame() + logger.debug("Initialized %s", self.__class__.__name__) + + @classmethod + def _get_tk_vars(cls) -> TKVars: + """ Create and initialize the tkinter variables. + + Returns + ------- + :class:`TKVars` + The global tkinter variables + """ + retval = TKVars(frame_index=tk.IntVar(value=0), + transport_index=tk.IntVar(value=0), + face_index=tk.IntVar(value=0), + filter_distance=tk.IntVar(value=10), + update=tk.BooleanVar(value=False), + update_active_viewport=tk.BooleanVar(value=False), + is_zoomed=tk.BooleanVar(value=False), + filter_mode=tk.StringVar(), + faces_size=tk.StringVar()) + logger.debug(retval) + return retval + + @property + def current_frame(self) -> CurrentFrame: + """ :class:`CurrentFrame`: The currently displayed frame in the frame viewer with it's + meta information. """ + return self._current_frame + + @property + def frame_count(self) -> int: + """ int: The total number of frames for the input location """ + return self._frame_count + + @property + def frame_display_dims(self) -> tuple[int, int]: + """ tuple: The (`width`, `height`) of the video display frame in pixels. """ + return self._frame_display_dims + + @property + def is_video(self) -> bool: + """ bool: ``True`` if the input is a video file, ``False`` if it is a folder of images. """ + return self._is_video + + # TK Variables that need to be exposed + @property + def var_full_update(self) -> tk.BooleanVar: + """ :class:`tkinter.BooleanVar`: Flag to indicate that whole GUI should be refreshed """ + return self._tk_vars.update + + @property + def var_transport_index(self) -> tk.IntVar: + """ :class:`tkinter.IntVar`: The current index of the display frame's transport slider. """ + return self._tk_vars.transport_index + + @property + def var_frame_index(self) -> tk.IntVar: + """ :class:`tkinter.IntVar`: The current absolute frame index of the currently + displayed frame. """ + return self._tk_vars.frame_index + + @property + def var_filter_distance(self) -> tk.IntVar: + """ :class:`tkinter.IntVar`: The variable holding the currently selected threshold + distance for misaligned filter mode. """ + return self._tk_vars.filter_distance + + @property + def var_filter_mode(self) -> tk.StringVar: + """ :class:`tkinter.StringVar`: The variable holding the currently selected navigation + filter mode. """ + return self._tk_vars.filter_mode + + @property + def var_faces_size(self) -> tk.StringVar: + """ :class:`tkinter..IntVar`: The variable holding the currently selected Faces Viewer + thumbnail size. """ + return self._tk_vars.faces_size + + @property + def var_update_active_viewport(self) -> tk.BooleanVar: + """ :class:`tkinter.BooleanVar`: Boolean Variable that is traced by the viewport's active + frame to update. """ + return self._tk_vars.update_active_viewport + + # Raw values returned from TK Variables + @property + def face_index(self) -> int: + """ int: The currently displayed face index when in zoomed mode. """ + return self._tk_vars.face_index.get() + + @property + def frame_index(self) -> int: + """ int: The currently displayed frame index. NB This returns -1 if there are no frames + that meet the currently selected filter criteria. """ + return self._tk_vars.frame_index.get() + + @property + def is_zoomed(self) -> bool: + """ bool: ``True`` if the frame viewer is zoomed into a face, ``False`` if the frame viewer + is displaying a full frame. """ + return self._tk_vars.is_zoomed.get() + + @staticmethod + def _check_input(frames_location: str) -> bool: + """ Check whether the input is a video + + Parameters + ---------- + frames_location: str + The input location for video or images + + Returns + ------- + bool: 'True' if input is a video 'False' if it is a folder. + """ + if os.path.isdir(frames_location): + retval = False + elif os.path.splitext(frames_location)[1].lower() in VIDEO_EXTENSIONS: + retval = True + else: + logger.error("The input location '%s' is not valid", frames_location) + sys.exit(1) + logger.debug("Input '%s' is_video: %s", frames_location, retval) + return retval + + def set_face_index(self, index: int) -> None: + """ Set the currently selected face index + + Parameters + ---------- + index: int + The currently selected face index + """ + logger.trace("Setting face index from %s to %s", # type:ignore[attr-defined] + self.face_index, index) + self._tk_vars.face_index.set(index) + + def set_frame_count(self, count: int) -> None: + """ Set the count of total number of frames to :attr:`frame_count` when the + :class:`FramesLoader` has completed loading. + + Parameters + ---------- + count: int + The number of frames that exist for this session + """ + logger.debug("Setting frame_count to : %s", count) + self._frame_count = count + + def set_current_frame(self, image: np.ndarray, filename: str) -> None: + """ Set the frame and meta information for the currently displayed frame. Populates the + attribute :attr:`current_frame` + + Parameters + ---------- + image: :class:`numpy.ndarray` + The image used to display in the Frame Viewer + filename: str + The filename of the current frame + """ + scale = min(self.frame_display_dims[0] / image.shape[1], + self.frame_display_dims[1] / image.shape[0]) + self._current_frame.image = image + self._current_frame.filename = filename + self._current_frame.scale = scale + self._current_frame.interpolation = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA + self._current_frame.display_dims = (int(round(image.shape[1] * scale)), + int(round(image.shape[0] * scale))) + logger.trace(self._current_frame) # type:ignore[attr-defined] + + def set_frame_display_dims(self, width: int, height: int) -> None: + """ Set the size, in pixels, of the video frame display window and resize the displayed + frame. + + Used on a frame resize callback, sets the :attr:frame_display_dims`. + + Parameters + ---------- + width: int + The width of the frame holding the video canvas in pixels + height: int + The height of the frame holding the video canvas in pixels + """ + self._frame_display_dims = (int(width), int(height)) + image = self._current_frame.image + scale = min(self.frame_display_dims[0] / image.shape[1], + self.frame_display_dims[1] / image.shape[0]) + self._current_frame.scale = scale + self._current_frame.interpolation = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA + self._current_frame.display_dims = (int(round(image.shape[1] * scale)), + int(round(image.shape[0] * scale))) + logger.trace(self._current_frame) # type:ignore[attr-defined] + + def set_zoomed(self, state: bool) -> None: + """ Set the current zoom state + + Parameters + ---------- + state: bool + ``True`` for zoomed ``False`` for full frame + """ + logger.trace("Setting zoom state from %s to %s", # type:ignore[attr-defined] + self.is_zoomed, state) + self._tk_vars.is_zoomed.set(state) diff --git a/tools/manual/manual.py b/tools/manual/manual.py index 46685766bc..2516a62b9c 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -1,6 +1,5 @@ #!/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. """ +""" Main entry point for the Manual Tool. A GUI app for editing alignments files """ from __future__ import annotations import logging @@ -9,24 +8,27 @@ import typing as T import tkinter as tk from tkinter import ttk +from dataclasses import dataclass from time import sleep -import cv2 import numpy as np from lib.gui.control_helper import ControlPanel from lib.gui.utils import get_images, get_config, initialize_config, initialize_images from lib.image import SingleFrameLoader, read_image_meta +from lib.logger import parse_class_init from lib.multithreading import MultiThread -from lib.utils import handle_deprecated_cliopts, VIDEO_EXTENSIONS +from lib.utils import handle_deprecated_cliopts from plugins.extract import ExtractMedia, Extractor from .detected_faces import DetectedFaces from .faceviewer.frame import FacesFrame from .frameviewer.frame import DisplayFrame +from .globals import TkGlobals from .thumbnails import ThumbsCreator if T.TYPE_CHECKING: + from argparse import Namespace from lib.align import DetectedFace, Mask from lib.queue_manager import EventQueue @@ -35,6 +37,17 @@ TypeManualExtractor = T.Literal["FAN", "cv2-dnn", "mask"] +@dataclass +class _Containers: + """ Dataclass for holding the main area containers in the GUI """ + main: ttk.PanedWindow + """:class:`tkinter.ttk.PanedWindow`: The main window holding the full GUI """ + top: ttk.Frame + """:class:`tkinter.ttk.Frame: The top part (frame viewer) of the GUI""" + bottom: ttk.Frame + """:class:`tkinter.ttk.Frame: The bottom part (face viewer) of the GUI""" + + class Manual(tk.Tk): """ The main entry point for Faceswap's Manual Editor Tool. This tool is part of the Faceswap Tools suite and should be called from ``python tools.py manual`` command. @@ -48,8 +61,8 @@ class Manual(tk.Tk): The :mod:`argparse` arguments as passed in from :mod:`tools.py` """ - def __init__(self, arguments): - logger.debug("Initializing %s: (arguments: '%s')", self.__class__.__name__, arguments) + def __init__(self, arguments: Namespace) -> None: + logger.debug(parse_class_init(locals())) super().__init__() arguments = handle_deprecated_cliopts(arguments) self._validate_non_faces(arguments.frames) @@ -66,24 +79,27 @@ def __init__(self, arguments): video_meta_data = self._detected_faces.video_meta_data valid_meta = all(val is not None for val in video_meta_data.values()) - loader = FrameLoader(self._globals, arguments.frames, video_meta_data) + loader = FrameLoader(self._globals, + arguments.frames, + video_meta_data, + self._detected_faces.frame_list) + if valid_meta: # Load the faces whilst other threads complete if we have valid meta data self._detected_faces.load_faces() self._containers = self._create_containers() self._wait_for_threads(extractor, loader, valid_meta) - if not valid_meta: - # Load the faces after other threads complete if meta data required updating + if not valid_meta: # If meta data needs updating, load faces after other threads self._detected_faces.load_faces() self._generate_thumbs(arguments.frames, arguments.thumb_regen, arguments.single_process) - self._display = DisplayFrame(self._containers["top"], + self._display = DisplayFrame(self._containers.top, self._globals, self._detected_faces) - _Options(self._containers["top"], self._globals, self._display) + _Options(self._containers.top, self._globals, self._display) - self._faces_frame = FacesFrame(self._containers["bottom"], + self._faces_frame = FacesFrame(self._containers.bottom, self._globals, self._detected_faces, self._display) @@ -94,7 +110,7 @@ def __init__(self, arguments): logger.debug("Initialized %s", self.__class__.__name__) @classmethod - def _validate_non_faces(cls, frames_folder): + def _validate_non_faces(cls, frames_folder: str) -> None: """ Quick check on the input to make sure that a folder of extracted faces is not being passed in. """ if not os.path.isdir(frames_folder): @@ -117,7 +133,7 @@ def _validate_non_faces(cls, frames_folder): sys.exit(1) logger.debug("Test input file '%s' does not contain Faceswap header data", test_file) - def _wait_for_threads(self, extractor, loader, valid_meta): + def _wait_for_threads(self, extractor: Aligner, loader: FrameLoader, valid_meta: bool) -> None: """ The :class:`Aligner` and :class:`FramesLoader` are launched in background threads. Wait for them to be initialized prior to proceeding. @@ -150,9 +166,10 @@ def _wait_for_threads(self, extractor, loader, valid_meta): extractor.link_faces(self._detected_faces) if not valid_meta: logger.debug("Saving video meta data to alignments file") - self._detected_faces.save_video_meta_data(**loader.video_meta_data) + self._detected_faces.save_video_meta_data( + **loader.video_meta_data) # type:ignore[arg-type] - def _generate_thumbs(self, input_location, force, single_process): + def _generate_thumbs(self, input_location: str, force: bool, single_process: bool) -> None: """ Check whether thumbnails are stored in the alignments file and if not generate them. Parameters @@ -173,7 +190,7 @@ def _generate_thumbs(self, input_location, force, single_process): thumbs.generate_cache() logger.debug("Generated thumbnails cache") - def _initialize_tkinter(self): + def _initialize_tkinter(self) -> None: """ Initialize a standalone tkinter instance. """ logger.debug("Initializing tkinter") for widget in ("TButton", "TCheckbutton", "TRadiobutton"): @@ -184,15 +201,16 @@ def _initialize_tkinter(self): self.title("Faceswap.py - Visual Alignments") logger.debug("Initialized tkinter") - def _create_containers(self): + def _create_containers(self) -> _Containers: """ Create the paned window containers for various GUI elements Returns ------- - dict: + :class:`_Containers`: The main containers of the manual tool. """ logger.debug("Creating containers") + main = ttk.PanedWindow(self, orient=tk.VERTICAL, name="pw_main") @@ -203,11 +221,13 @@ def _create_containers(self): bottom = ttk.Frame(main, name="frame_bottom") main.add(bottom) - retval = {"main": main, "top": top, "bottom": bottom} + + retval = _Containers(main=main, top=top, bottom=bottom) + logger.debug("Created containers: %s", retval) return retval - def _handle_key_press(self, event): + def _handle_key_press(self, event: tk.Event) -> None: """ Keyboard shortcuts Parameters @@ -226,7 +246,7 @@ def _handle_key_press(self, event): modifiers = {0x0001: 'shift', 0x0004: 'ctrl'} - tk_pos = self._globals.tk_frame_index + globs = self._globals bindings = { "z": self._display.navigation.decrement_frame, "x": self._display.navigation.increment_frame, @@ -245,21 +265,23 @@ def _handle_key_press(self, event): "f5": lambda k=event.keysym: self._display.set_action(k), "f9": lambda k=event.keysym: self._faces_frame.set_annotation_display(k), "f10": lambda k=event.keysym: self._faces_frame.set_annotation_display(k), - "c": lambda f=tk_pos.get(), d="prev": self._detected_faces.update.copy(f, d), - "v": lambda f=tk_pos.get(), d="next": self._detected_faces.update.copy(f, d), + "c": lambda f=globs.frame_index, d="prev": self._detected_faces.update.copy(f, d), + "v": lambda f=globs.frame_index, d="next": self._detected_faces.update.copy(f, d), "ctrl_s": self._detected_faces.save, - "r": lambda f=tk_pos.get(): self._detected_faces.revert_to_saved(f)} + "r": lambda f=globs.frame_index: self._detected_faces.revert_to_saved(f)} # Allow keypad keys to be used for numbers press = event.keysym.replace("KP_", "") if event.keysym.startswith("KP_") else event.keysym + assert isinstance(event.state, int) modifier = "_".join(val for key, val in modifiers.items() if event.state & key != 0) key_press = "_".join([modifier, press]) if modifier else press if key_press.lower() in bindings: - logger.trace("key press: %s, action: %s", key_press, bindings[key_press.lower()]) + logger.trace("key press: %s, action: %s", # type:ignore[attr-defined] + key_press, bindings[key_press.lower()]) self.focus_set() bindings[key_press.lower()]() - def _set_initial_layout(self): + def _set_initial_layout(self) -> None: """ Set the favicon and the bottom frame position to correct location to display full frame window. @@ -271,12 +293,13 @@ def _set_initial_layout(self): logger.debug("Setting initial layout") self.tk.call("wm", "iconphoto", - self._w, get_images().icons["favicon"]) # pylint:disable=protected-access + self._w, # type:ignore[attr-defined] # pylint:disable=protected-access + get_images().icons["favicon"]) location = int(self.winfo_screenheight() // 1.5) - self._containers["main"].sashpos(0, location) + self._containers.main.sashpos(0, location) self.update_idletasks() - def process(self): + def process(self) -> None: """ The entry point for the Visual Alignments tool from :mod:`lib.tools.manual.cli`. Launch the tkinter Visual Alignments Window and run main loop. @@ -289,6 +312,8 @@ class _Options(ttk.Frame): # pylint:disable=too-many-ancestors """ Control panel options for currently displayed Editor. This is the right hand panel of the GUI that holds editor specific settings and annotation display settings. + Parameters + ---------- parent: :class:`tkinter.ttk.Frame` The parent frame for the control panel options tk_globals: :class:`~tools.manual.manual.TkGlobals` @@ -296,9 +321,11 @@ class _Options(ttk.Frame): # pylint:disable=too-many-ancestors display_frame: :class:`DisplayFrame` The frame that holds the editors """ - def __init__(self, parent, tk_globals, display_frame): - logger.debug("Initializing %s: (parent: %s, tk_globals: %s, display_frame: %s)", - self.__class__.__name__, parent, tk_globals, display_frame) + def __init__(self, + parent: ttk.Frame, + tk_globals: TkGlobals, + display_frame: DisplayFrame) -> None: + logger.debug(parse_class_init(locals())) super().__init__(parent) self._globals = tk_globals @@ -309,7 +336,7 @@ def __init__(self, parent, tk_globals, display_frame): self.pack(side=tk.RIGHT, fill=tk.Y) logger.debug("Initialized %s", self.__class__.__name__) - def _initialize(self): + def _initialize(self) -> dict[str, ControlPanel]: """ Initialize all of the control panels, then display the default panel. Adds the control panel to :attr:`_control_panels` and sets the traceback to update @@ -322,6 +349,11 @@ def _initialize(self): The Traceback must be set after the panel has first been packed as otherwise it interferes with the loading of the faces pane. + + Returns + ------- + dict[str, :class:`~lib.gui.control_helper.ControlPanel`] + The configured control panels """ self._initialize_face_options() frame = ttk.Frame(self) @@ -343,7 +375,7 @@ def _initialize(self): panels[name] = panel return panels - def _initialize_face_options(self): + def _initialize_face_options(self) -> None: """ Set the Face Viewer options panel, beneath the standard control options. """ frame = ttk.Frame(self) frame.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=5) @@ -352,13 +384,13 @@ def _initialize_face_options(self): lbl = ttk.Label(size_frame, text="Face Size:") lbl.pack(side=tk.LEFT) cmb = ttk.Combobox(size_frame, - value=["Tiny", "Small", "Medium", "Large", "Extra Large"], + values=["Tiny", "Small", "Medium", "Large", "Extra Large"], state="readonly", - textvariable=self._globals.tk_faces_size) - self._globals.tk_faces_size.set("Medium") + textvariable=self._globals.var_faces_size) + self._globals.var_faces_size.set("Medium") cmb.pack(side=tk.RIGHT, padx=5) - def _set_tk_callbacks(self): + def _set_tk_callbacks(self) -> None: """ Sets the callback to change to the relevant control panel options when the selected editor is changed, and the display update on panel option change.""" self._display_frame.tk_selected_action.trace("w", self._update_options) @@ -372,9 +404,9 @@ def _set_tk_callbacks(self): logger.debug("Adding control update callback: (editor: %s, control: %s)", name, ctl.title) seen_controls.add(ctl) - ctl.tk_var.trace("w", lambda *e: self._globals.tk_update.set(True)) + ctl.tk_var.trace("w", lambda *e: self._globals.var_full_update.set(True)) - def _update_options(self, *args): # pylint:disable=unused-argument + def _update_options(self, *args) -> None: # pylint:disable=unused-argument """ Update the control panel display for the current editor. If the options have not already been set, then adds the control panel to @@ -390,7 +422,7 @@ def _update_options(self, *args): # pylint:disable=unused-argument logger.debug("Displaying control panel for editor: '%s'", editor) self._control_panels[editor].pack(expand=True, fill=tk.BOTH) - def _clear_options_frame(self): + def _clear_options_frame(self) -> None: """ Hides the currently displayed control panel """ for editor, panel in self._control_panels.items(): if panel.winfo_ismapped(): @@ -398,244 +430,6 @@ def _clear_options_frame(self): panel.pack_forget() -class TkGlobals(): - """ Holds Tkinter Variables and other frame information that need to be accessible from all - areas of the GUI. - - Parameters - ---------- - input_location: str - The location of the input folder of frames or video file - """ - def __init__(self, input_location): - logger.debug("Initializing %s: (input_location: %s)", - self.__class__.__name__, input_location) - self._tk_vars = self._get_tk_vars() - - self._is_video = self._check_input(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 = {"image": None, - "scale": None, - "interpolation": None, - "display_dims": None, - "filename": None} - logger.debug("Initialized %s", self.__class__.__name__) - - @classmethod - def _get_tk_vars(cls): - """ Create and initialize the tkinter variables. - - Returns - ------- - dict - The variable name as key, the variable as value - """ - retval = {} - for name in ("frame_index", "transport_index", "face_index", "filter_distance"): - var = tk.IntVar() - var.set(10 if name == "filter_distance" else 0) - retval[name] = var - for name in ("update", "update_active_viewport", "is_zoomed"): - var = tk.BooleanVar() - var.set(False) - retval[name] = var - for name in ("filter_mode", "faces_size"): - retval[name] = tk.StringVar() - return retval - - @property - def current_frame(self): - """ dict: The currently displayed frame in the frame viewer with it's meta information. Key - and Values are as follows: - - **image** (:class:`numpy.ndarry`): The currently displayed frame in original dimensions - - **scale** (`float`): The scaling factor to use to resize the image to the display - window - - **interpolation** (`int`): The opencv interpolator ID to use for resizing the image to - the display window - - **display_dims** (`tuple`): The size of the currently displayed frame, sized for the - display window - - **filename** (`str`): The filename of the currently displayed frame - """ - return self._current_frame - - @property - def frame_count(self): - """ int: The total number of frames for the input location """ - return self._frame_count - - @property - def tk_face_index(self): - """ :class:`tkinter.IntVar`: The variable that holds the face index of the selected face - within the current frame when in zoomed mode. """ - return self._tk_vars["face_index"] - - @property - def tk_update_active_viewport(self): - """ :class:`tkinter.BooleanVar`: Boolean Variable that is traced by the viewport's active - frame to update.. """ - return self._tk_vars["update_active_viewport"] - - @property - def face_index(self): - """ int: The currently displayed face index when in zoomed mode. """ - return self._tk_vars["face_index"].get() - - @property - def frame_display_dims(self): - """ tuple: The (`width`, `height`) of the video display frame in pixels. """ - return self._frame_display_dims - - @property - def frame_index(self): - """ int: The currently displayed frame index. NB This returns -1 if there are no frames - that meet the currently selected filter criteria. """ - return self._tk_vars["frame_index"].get() - - @property - def tk_frame_index(self): - """ :class:`tkinter.IntVar`: The variable holding the current frame index. """ - return self._tk_vars["frame_index"] - - @property - def filter_mode(self): - """ str: The currently selected navigation mode. """ - return self._tk_vars["filter_mode"].get() - - @property - def tk_filter_mode(self): - """ :class:`tkinter.StringVar`: The variable holding the currently selected navigation - filter mode. """ - return self._tk_vars["filter_mode"] - - @property - def tk_filter_distance(self): - """ :class:`tkinter.DoubleVar`: The variable holding the currently selected threshold - distance for misaligned filter mode. """ - return self._tk_vars["filter_distance"] - - @property - def tk_faces_size(self): - """ :class:`tkinter.StringVar`: The variable holding the currently selected Faces Viewer - thumbnail size. """ - return self._tk_vars["faces_size"] - - @property - def is_video(self): - """ bool: ``True`` if the input is a video file, ``False`` if it is a folder of images. """ - return self._is_video - - @property - def tk_is_zoomed(self): - """ :class:`tkinter.BooleanVar`: The variable holding the value indicating whether the - frame viewer is zoomed into a face or zoomed out to the full frame. """ - return self._tk_vars["is_zoomed"] - - @property - def is_zoomed(self): - """ bool: ``True`` if the frame viewer is zoomed into a face, ``False`` if the frame viewer - is displaying a full frame. """ - return self._tk_vars["is_zoomed"].get() - - @property - def tk_transport_index(self): - """ :class:`tkinter.IntVar`: The current index of the display frame's transport slider. """ - return self._tk_vars["transport_index"] - - @property - def tk_update(self): - """ :class:`tkinter.BooleanVar`: The variable holding the trigger that indicates that a - full update needs to occur. """ - return self._tk_vars["update"] - - @staticmethod - def _check_input(frames_location): - """ Check whether the input is a video - - Parameters - ---------- - frames_location: str - The input location for video or images - - Returns - ------- - bool: 'True' if input is a video 'False' if it is a folder. - """ - if os.path.isdir(frames_location): - retval = False - elif os.path.splitext(frames_location)[1].lower() in VIDEO_EXTENSIONS: - retval = True - else: - logger.error("The input location '%s' is not valid", frames_location) - sys.exit(1) - logger.debug("Input '%s' is_video: %s", frames_location, retval) - return retval - - def set_frame_count(self, count): - """ Set the count of total number of frames to :attr:`frame_count` when the - :class:`FramesLoader` has completed loading. - - Parameters - ---------- - count: int - The number of frames that exist for this session - """ - logger.debug("Setting frame_count to : %s", count) - self._frame_count = count - - def set_current_frame(self, image, filename): - """ Set the frame and meta information for the currently displayed frame. Populates the - attribute :attr:`current_frame` - - Parameters - ---------- - image: :class:`numpy.ndarray` - The image used to display in the Frame Viewer - filename: str - The filename of the current frame - """ - scale = min(self.frame_display_dims[0] / image.shape[1], - self.frame_display_dims[1] / image.shape[0]) - self._current_frame["image"] = image - self._current_frame["filename"] = filename - self._current_frame["scale"] = scale - self._current_frame["interpolation"] = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA - self._current_frame["display_dims"] = (int(round(image.shape[1] * scale)), - int(round(image.shape[0] * scale))) - logger.trace({k: v.shape if isinstance(v, np.ndarray) else v - for k, v in self._current_frame.items()}) - - def set_frame_display_dims(self, width, height): - """ Set the size, in pixels, of the video frame display window and resize the displayed - frame. - - Used on a frame resize callback, sets the :attr:frame_display_dims`. - - Parameters - ---------- - width: int - The width of the frame holding the video canvas in pixels - height: int - The height of the frame holding the video canvas in pixels - """ - self._frame_display_dims = (int(width), int(height)) - image = self._current_frame["image"] - scale = min(self.frame_display_dims[0] / image.shape[1], - self.frame_display_dims[1] / image.shape[0]) - self._current_frame["scale"] = scale - self._current_frame["interpolation"] = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA - self._current_frame["display_dims"] = (int(round(image.shape[1] * scale)), - int(round(image.shape[0] * scale))) - logger.trace({k: v.shape if isinstance(v, np.ndarray) else v - for k, v in self._current_frame.items()}) - - class Aligner(): """ The :class:`Aligner` class sets up an extraction pipeline for each of the current Faceswap Aligners, along with the Landmarks based Maskers. When new landmarks are required, the bounding @@ -684,8 +478,8 @@ def _feed_face(self) -> ExtractMedia: 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"], - self._globals.current_frame["image"], + self._globals.current_frame.filename, + self._globals.current_frame.image, detected_faces=[face]) @property @@ -864,53 +658,93 @@ class FrameLoader(): The path to the input frames video_meta_data: dict The meta data held within the alignments file, if it exists and the input is a video + file_list: list[str] + The list of filenames that exist within the alignments file """ - def __init__(self, tk_globals, frames_location, video_meta_data): - logger.debug("Initializing %s: (tk_globals: %s, frames_location: '%s', " - "video_meta_data: %s)", self.__class__.__name__, tk_globals, frames_location, - video_meta_data) + def __init__(self, + tk_globals: TkGlobals, + frames_location: str, + video_meta_data: dict[str, list[int] | list[float] | None], + file_list: list[str]) -> None: + logger.debug(parse_class_init(locals())) self._globals = tk_globals - self._loader = None + self._loader: SingleFrameLoader | None = None self._current_idx = 0 - self._init_thread = self._background_init_frames(frames_location, video_meta_data) - self._globals.tk_frame_index.trace("w", self._set_frame) + self._init_thread = self._background_init_frames(frames_location, + video_meta_data, + file_list) + self._globals.var_frame_index.trace_add("write", self._set_frame) logger.debug("Initialized %s", self.__class__.__name__) @property - def is_initialized(self): - """ bool: ``True`` if the Frame Loader has completed initialization otherwise - ``False``. """ + def is_initialized(self) -> bool: + """ bool: ``True`` if the Frame Loader has completed initialization. """ thread_is_alive = self._init_thread.is_alive() if thread_is_alive: self._init_thread.check_and_raise_error() else: self._init_thread.join() - # Setting the initial frame cannot be done in the thread, so set when queried from main - self._set_frame(initialize=True) + self._set_frame(initialize=True) # Setting initial frame must be done from main thread return not thread_is_alive @property - def video_meta_data(self): + def video_meta_data(self) -> dict[str, list[int] | list[float] | None]: """ dict: The pts_time and key frames for the loader. """ + assert self._loader is not None return self._loader.video_meta_data - def _background_init_frames(self, frames_location, video_meta_data): + def _background_init_frames(self, + frames_location: str, + video_meta_data: dict[str, list[int] | list[float] | None], + frame_list: list[str]) -> MultiThread: """ Launch the images loader in a background thread so we can run other tasks whilst - waiting for initialization. """ + waiting for initialization. + + Parameters + ---------- + frame_location: str + The location of the source video file/frames folder + video_meta_data: dict + The meta data for video file sources + frame_list: list[str] + The list of frames that exist in the alignments file + """ thread = MultiThread(self._load_images, frames_location, video_meta_data, + frame_list, thread_count=1, name=f"{self.__class__.__name__}.init_frames") thread.start() return thread - def _load_images(self, frames_location, video_meta_data): - """ Load the images in a background thread. """ - self._loader = SingleFrameLoader(frames_location, video_meta_data=video_meta_data) - self._globals.set_frame_count(self._loader.count) + def _load_images(self, + frames_location: str, + video_meta_data: dict[str, list[int] | list[float] | None], + frame_list: list[str]) -> None: + """ Load the images in a background thread. - def _set_frame(self, *args, initialize=False): # pylint:disable=unused-argument + Parameters + ---------- + frame_location: str + The location of the source video file/frames folder + video_meta_data: dict + The meta data for video file sources + frame_list: list[str] + The list of frames that exist in the alignments file + """ + self._loader = SingleFrameLoader(frames_location, video_meta_data=video_meta_data) + if not self._loader.is_video and len(frame_list) < self._loader.count: + files = [os.path.basename(f) for f in self._loader.file_list] + skip_list = [idx for idx, fname in enumerate(files) if fname not in frame_list] + logger.debug("Adding %s entries to skip list for images not in alignments file", + len(skip_list)) + self._loader.add_skip_list(skip_list) + self._globals.set_frame_count(self._loader.process_count) + + def _set_frame(self, # pylint:disable=unused-argument + *args, + initialize: bool = False) -> None: """ Set the currently loaded frame to :attr:`_current_frame` and trigger a full GUI update. If the loader has not been initialized, or the navigation position is the same as the @@ -926,17 +760,19 @@ def _set_frame(self, *args, initialize=False): # pylint:disable=unused-argument """ position = self._globals.frame_index if not initialize and (position == self._current_idx and not self._globals.is_zoomed): - logger.trace("Update criteria not met. Not updating: (initialize: %s, position: %s, " - "current_idx: %s, is_zoomed: %s)", initialize, position, - self._current_idx, self._globals.is_zoomed) + logger.trace("Update criteria not met. Not updating: " # type:ignore[attr-defined] + "(initialize: %s, position: %s, current_idx: %s, is_zoomed: %s)", + initialize, position, self._current_idx, self._globals.is_zoomed) return if position == -1: filename = "No Frame" frame = np.ones(self._globals.frame_display_dims + (3, ), dtype="uint8") else: + assert self._loader is not None filename, frame = self._loader.image_from_index(position) - logger.trace("filename: %s, frame: %s, position: %s", filename, frame.shape, position) + logger.trace("filename: %s, frame: %s, position: %s", # type:ignore[attr-defined] + filename, frame.shape, position) self._globals.set_current_frame(frame, filename) self._current_idx = position - self._globals.tk_update.set(True) - self._globals.tk_update_active_viewport.set(True) + self._globals.var_full_update.set(True) + self._globals.var_update_active_viewport.set(True) From 3f53ee9cec6d77565fc5bc2b3c6edd885334615d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 2 Aug 2024 18:35:34 +0100 Subject: [PATCH 913/981] Bugfix: Convert - Correctly error if a valid mask has not been selected --- lib/align/alignments.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/align/alignments.py b/lib/align/alignments.py index 68bdbcebe9..ff0f2207d3 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -407,8 +407,8 @@ def mask_is_valid(self, mask_type: str) -> bool: ``True`` if all faces in the current alignments possess the given ``mask_type`` otherwise ``False`` """ - retval = any((face.get("mask", None) is not None and - face["mask"].get(mask_type, None) is not None) + retval = all((face.get("mask") is not None and + face["mask"].get(mask_type) is not None) for val in self._data.values() for face in val["faces"]) logger.debug(retval) From 6fe300e601258babcbd5f3099178bbb52ae0c63a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 2 Aug 2024 18:42:57 +0100 Subject: [PATCH 914/981] pin numpy to < 2.0 --- requirements/_requirements_base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index a78ebb1b88..a2f04a17a1 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -1,7 +1,7 @@ tqdm>=4.65 psutil>=5.9.0 numexpr>=2.8.7 -numpy>=1.26.0 +numpy>=1.26.0,<2.0.0 opencv-python>=4.9.0.0 pillow>=9.4.0,<10.0.0 scikit-learn>=1.3.0 From cbaad146d5aca9bd714bb6c69b10dd7c02d88f9d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 10 Aug 2024 12:42:47 +0100 Subject: [PATCH 915/981] Bugfix: Linux installer - pin git to < 2.45 --- .install/linux/faceswap_setup_x64.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.install/linux/faceswap_setup_x64.sh b/.install/linux/faceswap_setup_x64.sh index 74825a0844..26d3997fc0 100644 --- a/.install/linux/faceswap_setup_x64.sh +++ b/.install/linux/faceswap_setup_x64.sh @@ -380,7 +380,9 @@ activate_env() { install_git() { # Install git inside conda environment info "Installing Git..." - yellow ; conda install git -q -y + # TODO On linux version 2.45.2 makes the font fixed TK pull in Python from + # graalpy, which breaks pretty much everything + yellow ; conda install "git<2.45" -q -y } delete_faceswap() { From 41b61f96a48dc94b13957e76f4db99330188be8d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 19 Nov 2024 23:12:57 +0000 Subject: [PATCH 916/981] Update README.md --- README.md | 32 ++++++-------------------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 2b1e829214..1360ae18d0 100755 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ # deepfakes_faceswap + +### Important information for **Patreon** and **PayPal** supporters. Please see this forum post: https://forum.faceswap.dev/viewtopic.php?f=14&t=3120 +


    FaceSwap is a tool that utilizes deep learning to recognize and swap faces in pictures and videos. @@ -21,11 +24,13 @@
    Jennifer Lawrence/Steve Buscemi FaceSwap using the Villain model

    + ![Build Status](https://github.com/deepfakes/faceswap/actions/workflows/pytest.yml/badge.svg) [![Documentation Status](https://readthedocs.org/projects/faceswap/badge/?version=latest)](https://faceswap.readthedocs.io/en/latest/?badge=latest) Make sure you check out [INSTALL.md](INSTALL.md) before getting started. -- [deepfakes_faceswap](#deepfakes_faceswap) +- [deepfakes\_faceswap](#deepfakes_faceswap) + - [Important information for **Patreon** and **PayPal** supporters. Please see this forum post: https://forum.faceswap.dev/viewtopic.php?f=14\&t=3120](#important-information-for-patreon-and-paypal-supporters-please-see-this-forum-post-httpsforumfaceswapdevviewtopicphpf14t3120) - [Manifesto](#manifesto) - [FaceSwap has ethical uses.](#faceswap-has-ethical-uses) - [How To setup and run the project](#how-to-setup-and-run-the-project) @@ -48,12 +53,6 @@ Make sure you check out [INSTALL.md](INSTALL.md) before getting started. - [For devs](#for-devs) - [For non-dev advanced users](#for-non-dev-advanced-users) - [For end-users](#for-end-users) - - [For haters](#for-haters) -- [About github.com/deepfakes](#about-githubcomdeepfakes) - - [What is this repo?](#what-is-this-repo) - - [Why this repo?](#why-this-repo) - - [Why is it named 'deepfakes' if it is not /u/deepfakes?](#why-is-it-named-deepfakes-if-it-is-not-udeepfakes) - - [What if /u/deepfakes feels bad about that?](#what-if-udeepfakes-feels-bad-about-that) - [About machine learning](#about-machine-learning) - [How does a computer know how to recognize/shape faces? How does machine learning work? What is a neural network?](#how-does-a-computer-know-how-to-recognizeshape-faces-how-does-machine-learning-work-what-is-a-neural-network) @@ -171,25 +170,6 @@ Creator of the Unbalanced and OHR models, as well as expanding various capabilit - Be patient. This is a relatively new technology for developers as well. Much effort is already being put into making this program easy to use for the average user. It just takes time! - **Notice** Any issue related to running the code has to be opened in the [faceswap Forum](https://faceswap.dev/forum)! -## For haters -Sorry, no time for that. - -# About github.com/deepfakes - -## What is this repo? -It is a community repository for active users. - -## Why this repo? -The joshua-wu repo seems not active. Simple bugs like missing _http://_ in front of urls have not been solved since days. - -## Why is it named 'deepfakes' if it is not /u/deepfakes? - 1. Because a typosquat would have happened sooner or later as project grows - 2. Because we wanted to recognize the original author - 3. Because it will better federate contributors and users - -## What if /u/deepfakes feels bad about that? -This is a friendly typosquat, and it is fully dedicated to the project. If /u/deepfakes wants to take over this repo/user and drive the project, he is welcomed to do so (Raise an issue, and he will be contacted on Reddit). Please do not send /u/deepfakes messages for help with the code you find here. - # About machine learning ## How does a computer know how to recognize/shape faces? How does machine learning work? What is a neural network? From 7d80bdbba386ca0e91dbc10c4a49e830e65942ac Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 26 Feb 2025 17:55:36 +0000 Subject: [PATCH 917/981] bugfix: setup.py - Don't delimit package specs --- setup.py | 2 -- tools/alignments/jobs.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 56c0188a55..17e3e3e9d8 100755 --- a/setup.py +++ b/setup.py @@ -1088,8 +1088,6 @@ def _install_setup_packages(self) -> None: pkg_str = self._format_package(*pkg) if self._env.is_conda: cmd = ["conda", "install", "-y"] - if any(char in pkg_str for char in (" ", "<", ">", "*", "|")): - pkg_str = f"\"{pkg_str}\"" else: cmd = [sys.executable, "-m", "pip", "install", "--no-cache-dir"] if self._env.is_admin: diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index 578ade1ea3..72130c6d47 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -327,7 +327,7 @@ def _move_file(self, items_output: list[str] | list[tuple[str, int]]) -> None: """ now = datetime.now().strftime("%Y%m%d_%H%M%S") folder_name = (f"{self._get_filename_prefix()}" - f"{self.output_message.replace(' ','_').lower()}_{now}") + f"{self.output_message.replace(' ', '_').lower()}_{now}") dst_dir = self._get_output_folder() output_folder = os.path.join(dst_dir, folder_name) logger.debug("Creating folder: '%s'", output_folder) From 5212589242380f4e13a8d38bdc42e8886a46448c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 19 May 2025 16:48:50 +0100 Subject: [PATCH 918/981] Pin imageio-ffmpeg to <0.6.0 --- requirements/_requirements_base.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index a2f04a17a1..9c9a2d528c 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -8,6 +8,8 @@ scikit-learn>=1.3.0 fastcluster>=1.2.6 matplotlib>=3.8.0 imageio>=2.33.1 -imageio-ffmpeg>=0.4.9 +# ffmpeg binary >=0.6.0 breaks convert. +# TODO fix convert to use latest binary +imageio-ffmpeg>=0.4.9,<0.6.0 ffmpy>=0.3.0 pywin32>=305 ; sys_platform == "win32" From 92ef5aa92dac1ed02829284d05268d120107417f Mon Sep 17 00:00:00 2001 From: torzdf Date: Wed, 21 May 2025 17:57:00 +0100 Subject: [PATCH 919/981] bugfix: setup.py fix badly escaped delimiters for Windows --- setup.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 56c0188a55..f0109dce55 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ import sys import typing as T from shutil import which -from subprocess import list2cmdline, PIPE, Popen, run, STDOUT +from subprocess import PIPE, Popen, run, STDOUT from pkg_resources import parse_requirements @@ -1076,7 +1076,9 @@ def _format_package(cls, package: str, version: list[tuple[str, str]]) -> str: str The formatted full package and version string """ - return f"{package}{','.join(''.join(spec) for spec in version)}" + retval = f"{package}{','.join(''.join(spec) for spec in version)}" + logger.debug("Formatted package \"%s\" version \"%s\" to \"%s'", package, version, retval) + return retval def _install_setup_packages(self) -> None: """ Install any packages that are required for the setup.py installer to work. This @@ -1495,7 +1497,7 @@ def __init__(self, is_gui: bool) -> None: super().__init__(environment, package, command, is_gui) self._cmd = which(command[0], path=os.environ.get('PATH', os.defpath)) - self._cmdline = list2cmdline(command) + self._cmdline = " ".join(command) logger.debug("cmd: '%s', cmdline: '%s'", self._cmd, self._cmdline) self._pbar = re.compile(r"(?:eta\s[\d\W]+)|(?:\s+\|\s+\d+%)\Z") From 9cda7aacaeb5de726a0188b30402a03cb48eaaf0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 11 Jul 2025 18:19:27 +0100 Subject: [PATCH 920/981] Bugfix: requirements, pin opencv to prevent numpy2.x install --- requirements/_requirements_base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index 9c9a2d528c..a3e3990781 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -2,7 +2,7 @@ tqdm>=4.65 psutil>=5.9.0 numexpr>=2.8.7 numpy>=1.26.0,<2.0.0 -opencv-python>=4.9.0.0 +opencv-python>=4.9.0.0,<4.12.0.0 # >=4.12 pulls in numpy2.x pillow>=9.4.0,<10.0.0 scikit-learn>=1.3.0 fastcluster>=1.2.6 From 161c610c01756b1bd4c290d6db87cd6ae8fa726e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:10:27 +0100 Subject: [PATCH 921/981] Install: Miniconda - Auto Accept TOS --- .install/linux/faceswap_setup_x64.sh | 3 ++- .install/macos/faceswap_setup_macos.sh | 3 ++- .install/windows/install.nsi | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.install/linux/faceswap_setup_x64.sh b/.install/linux/faceswap_setup_x64.sh index 26d3997fc0..9cbcfaa010 100644 --- a/.install/linux/faceswap_setup_x64.sh +++ b/.install/linux/faceswap_setup_x64.sh @@ -338,10 +338,11 @@ conda_install() { info "Installing Miniconda3..." yellow ; fname="$(basename -- $DL_CONDA)" bash "$TMP_DIR/$fname" -b -p "$DIR_CONDA" + "$CONDA_EXECUTABLE" tos accept if $CONDA_TO_PATH ; then info "Adding Miniconda3 to PATH..." yellow ; "$CONDA_EXECUTABLE" init - "$CONDA_EXECUTABLE" config --set auto_activate_base false + "$CONDA_EXECUTABLE" config --set auto_activate false fi fi } diff --git a/.install/macos/faceswap_setup_macos.sh b/.install/macos/faceswap_setup_macos.sh index 804b828c3e..fc46ae4590 100644 --- a/.install/macos/faceswap_setup_macos.sh +++ b/.install/macos/faceswap_setup_macos.sh @@ -379,10 +379,11 @@ conda_install() { info "Installing Miniconda3..." yellow ; fname="$(basename -- $DL_CONDA)" bash "$TMP_DIR/$fname" -b -p "$DIR_CONDA" + "$CONDA_EXECUTABLE" tos accept if $CONDA_TO_PATH ; then info "Adding Miniconda3 to PATH..." yellow ; "$CONDA_EXECUTABLE" init zsh bash - "$CONDA_EXECUTABLE" config --set auto_activate_base false + "$CONDA_EXECUTABLE" config --set auto_activate false fi fi } diff --git a/.install/windows/install.nsi b/.install/windows/install.nsi index d407807872..55a2739119 100644 --- a/.install/windows/install.nsi +++ b/.install/windows/install.nsi @@ -361,6 +361,8 @@ FunctionEnd Function SetEnvironment DetailPrint "Initializing Conda..." SetDetailsPrint listonly + ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda tos accept && conda deactivate" + pop $0 ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda update -y -n base -c defaults conda && conda deactivate" pop $0 ExecDos::wait $0 From 0f27fe19cd27fb826fde34cfcaca42cb3fe5bd5f Mon Sep 17 00:00:00 2001 From: torzdf Date: Thu, 18 Sep 2025 14:12:07 +0100 Subject: [PATCH 922/981] Installer: Miniconda fix TOS for Windows --- .install/windows/install.nsi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.install/windows/install.nsi b/.install/windows/install.nsi index 55a2739119..13751b1c02 100644 --- a/.install/windows/install.nsi +++ b/.install/windows/install.nsi @@ -361,7 +361,7 @@ FunctionEnd Function SetEnvironment DetailPrint "Initializing Conda..." SetDetailsPrint listonly - ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda tos accept && conda deactivate" + ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\Scripts\conda.exe$\" tos accept" pop $0 ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda update -y -n base -c defaults conda && conda deactivate" pop $0 From b16b755d55728fafa953b8ac7a0f9a3916912437 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 23 Oct 2025 18:18:39 +0100 Subject: [PATCH 923/981] lr-finder: Log error on NaN --- lib/training/lr_finder.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/training/lr_finder.py b/lib/training/lr_finder.py index d7c3298c69..2ed81a3bf8 100644 --- a/lib/training/lr_finder.py +++ b/lib/training/lr_finder.py @@ -132,7 +132,8 @@ def _train(self) -> None: for idx in pbar: model_inputs, model_targets = self._feeder.get_batch() loss: list[float] = self._model.model.train_on_batch(model_inputs, y=model_targets) - if np.isnan(loss[0]): + if any(np.isnan(x) for x in loss): + logger.warning("NaN detected! Exiting early") break self._on_batch_end(idx, loss[0]) self._update_description(pbar) From 50f895d3c16202eee81b1cf229199019f50812e9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 4 Nov 2025 12:29:25 +0000 Subject: [PATCH 924/981] bugfix: FFL loss - prevent divide by zero errors --- lib/model/losses/loss.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/model/losses/loss.py b/lib/model/losses/loss.py index ab03ff53fd..fe4dd045c1 100644 --- a/lib/model/losses/loss.py +++ b/lib/model/losses/loss.py @@ -44,6 +44,9 @@ class FocalFrequencyLoss(): # pylint:disable=too-few-public-methods batch_matrix: bool, Optional ``True`` to calculate the spectrum weight matrix using batch-based statistics otherwise ``False``. Default: ``False`` + epsilon : float, Optional + Small epsilon for safer weights scaling division. Default: `1e-6` + References ---------- @@ -56,13 +59,15 @@ def __init__(self, patch_factor: int = 1, ave_spectrum: bool = False, log_matrix: bool = False, - batch_matrix: bool = False) -> None: + batch_matrix: bool = False, + epsilon: float = 1e-6) -> None: self._alpha = alpha # TODO Fix bug where FFT will be incorrect if patch_factor > 1 self._patch_factor = patch_factor self._ave_spectrum = ave_spectrum self._log_matrix = log_matrix self._batch_matrix = batch_matrix + self._epsilon = epsilon self._dims: tuple[int, int] = (0, 0) def _get_patches(self, inputs: tf.Tensor) -> tf.Tensor: @@ -145,11 +150,11 @@ def _get_weight_matrix(self, freq_true: tf.Tensor, freq_pred: tf.Tensor) -> tf.T weights = K.log(weights + 1.0) if self._batch_matrix: # calculate the spectrum weight matrix using batch-based statistics - weights = weights / K.max(weights) + scale = K.max(weights) else: - weights = weights / K.max(K.max(weights, axis=-2), axis=-2)[..., None, None, :] + scale = K.max(weights, axis=(-2, -3), keepdims=True) + weights = weights / K.maximum(scale, self._epsilon) - weights = K.switch(tf.math.is_nan(weights), K.zeros_like(weights), weights) weights = K.clip(weights, min_value=0.0, max_value=1.0) return weights From 57027092afd9e6f2269990304b87e3d9c69ca52c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 6 Nov 2025 17:51:56 +0000 Subject: [PATCH 925/981] bugfix: Correctly generate thumbnails for alignments when frames are missing --- lib/image.py | 21 +++++++++++---------- tools/manual/thumbnails.py | 12 ++++++++---- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/lib/image.py b/lib/image.py index 897d8e2daa..a0b28ab04b 100644 --- a/lib/image.py +++ b/lib/image.py @@ -1087,21 +1087,22 @@ def __init__(self, self._fps = self._get_fps() self._count = None - self._file_list = None + self._file_list: list[str] = [] self._get_count_and_filelist(fast_count, count) @property - def count(self): + def count(self) -> int: """ int: The number of images or video frames in the source location. This count includes any files that will ultimately be skipped if a :attr:`skip_list` has been provided. See also: :attr:`process_count`""" + assert self._count is not None return self._count @property - def process_count(self): + def process_count(self) -> int: """ int: The number of images or video frames to be processed (IE the total count less items that are to be skipped from the :attr:`skip_list`)""" - return self._count - len(self._skip_list) + return self.count - len(self._skip_list) @property def is_video(self): @@ -1115,10 +1116,10 @@ def fps(self): return self._fps @property - def file_list(self): - """ list: A full list of files in the source location. This includes any files that will - ultimately be skipped if a :attr:`skip_list` has been provided. If the input is a video - then this is a list of dummy filenames as corresponding to an alignments file """ + def file_list(self) -> list[str]: + """ list[str]: A full list of files in the source location. This includes any files that + will ultimately be skipped if a :attr:`skip_list` has been provided. If the input is a + video then this is a list of dummy filenames as corresponding to an alignments file """ return self._file_list def add_skip_list(self, skip_list): @@ -1436,7 +1437,7 @@ def _get_count_and_filelist(self, fast_count, count): self._video_meta_data = video_meta_data super()._get_count_and_filelist(fast_count, count) - def image_from_index(self, index): + def image_from_index(self, index: int) -> tuple[str, np.ndarray]: """ Return a single image from :attr:`file_list` for the given index. Parameters @@ -1468,7 +1469,7 @@ def image_from_index(self, index): else: file_list = [f for idx, f in enumerate(self._file_list) if idx not in self._skip_list] if self._skip_list else self._file_list - + filename = file_list[index] image = read_image(filename, raise_error=True) filename = os.path.basename(filename) diff --git a/tools/manual/thumbnails.py b/tools/manual/thumbnails.py index 617d37b4c3..d1992c83ce 100644 --- a/tools/manual/thumbnails.py +++ b/tools/manual/thumbnails.py @@ -170,14 +170,18 @@ def _launch_folder(self) -> None: thread for some speed up. """ reader = SingleFrameLoader(self._location) - num_threads = min(reader.count, self._num_threads) - frame_split = reader.count // self._num_threads + skip_list = [idx for idx, f in enumerate(reader.file_list) + if os.path.basename(f) not in self._alignments.data] + if skip_list: + reader.add_skip_list(skip_list) + num_threads = min(reader.process_count, self._num_threads) + frame_split = reader.process_count // self._num_threads logger.debug("total images: %s, num_threads: %s, frames_per_thread: %s", - reader.count, num_threads, frame_split) + reader.process_count, num_threads, frame_split) for idx in range(num_threads): is_final = idx == num_threads - 1 start_idx = idx * frame_split - end_idx = reader.count if is_final else start_idx + frame_split + end_idx = reader.process_count if is_final else start_idx + frame_split thread = MultiThread(self._load_from_folder, reader, start_idx, end_idx) thread.start() self._threads.append(thread) From 02bcde10895e6ad6014a1b7187d5c0b0cc501c7c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 11 Nov 2025 14:59:05 +0000 Subject: [PATCH 926/981] bugfix: setup.py - Explicitly use Conda defaults channel - Prevent tkinter from pulling in incompatible libs on Linux --- setup.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index 69b22a2bdc..754f3b209d 100755 --- a/setup.py +++ b/setup.py @@ -306,9 +306,9 @@ def __init__(self, environment: Environment) -> None: # Default TK has bad fonts under Linux. There is a better build in Conda-Forge, so set # channel accordingly - tk_channel = "conda-forge" if self._env.os_version[0].lower() == "linux" else "default" + tk_channel = "conda-forge" if self._env.os_version[0].lower() == "linux" else "defaults" self._conda_required_packages: list[tuple[list[str] | str, str]] = [("tk", tk_channel), - ("git", "default")] + ("git", "defaults")] self._update_backend_specific_conda() self._installed_packages = self._get_installed_packages() self._conda_installed_packages = self._get_installed_conda_packages() @@ -924,6 +924,7 @@ def _cudnn_check_files(self) -> bool: return False found = 0 + major = minor = patchlevel = 0 with open(cudnn_checkfile, "r", encoding="utf8") as ofile: for line in ofile: if line.lower().startswith("#define cudnn_major"): @@ -1089,7 +1090,7 @@ def _install_setup_packages(self) -> None: for pkg in self._packages.prerequisites: pkg_str = self._format_package(*pkg) if self._env.is_conda: - cmd = ["conda", "install", "-y"] + cmd = ["conda", "install", "-y", "-c", "defaults"] else: cmd = [sys.executable, "-m", "pip", "install", "--no-cache-dir"] if self._env.is_admin: @@ -1153,11 +1154,14 @@ def _from_conda(self, """ # Packages with special characters need to be enclosed in double quotes success = True - condaexe = ["conda", "install", "-y"] - if channel: - condaexe.extend(["-c", channel]) + channel = "defaults" if not channel else channel + condaexe = ["conda", "install", "-y", "-c", channel] pkgs = package if isinstance(package, list) else [package] + if pkgs[0].startswith("tk"): + # TODO this is hacky and fragile, but for some reason tk from conda-forge has started + # pulling in the graapy version of Python which breaks opencv install + condaexe.append("--no-deps") for i, pkg in enumerate(pkgs): if any(char in pkg for char in (" ", "<", ">", "*", "|")): From 0370d2b77e1c62441da11719b28d8f0d9ca8461f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 11 Nov 2025 15:35:21 +0000 Subject: [PATCH 927/981] bugfix: Update installers for conda changes --- .install/linux/faceswap_setup_x64.sh | 2 +- .install/macos/faceswap_setup_macos.sh | 2 +- .install/windows/install.nsi | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.install/linux/faceswap_setup_x64.sh b/.install/linux/faceswap_setup_x64.sh index 9cbcfaa010..b5f7960011 100644 --- a/.install/linux/faceswap_setup_x64.sh +++ b/.install/linux/faceswap_setup_x64.sh @@ -367,7 +367,7 @@ create_env() { # Create Python 3.10 env for faceswap delete_env info "Creating Conda Virtual Environment..." - yellow ; "$CONDA_EXECUTABLE" create -n "$ENV_NAME" -q python="$PYENV_VERSION" -y + yellow ; "$CONDA_EXECUTABLE" create -n "$ENV_NAME" -c defaults -q python="$PYENV_VERSION" -y } diff --git a/.install/macos/faceswap_setup_macos.sh b/.install/macos/faceswap_setup_macos.sh index fc46ae4590..1c67239eae 100644 --- a/.install/macos/faceswap_setup_macos.sh +++ b/.install/macos/faceswap_setup_macos.sh @@ -408,7 +408,7 @@ create_env() { # Create Python 3.10 env for faceswap delete_env info "Creating Conda Virtual Environment..." - yellow ; "$CONDA_EXECUTABLE" create -n "$ENV_NAME" -q python="$PYENV_VERSION" -y + yellow ; "$CONDA_EXECUTABLE" create -n "$ENV_NAME" -c defaults -q python="$PYENV_VERSION" -y } diff --git a/.install/windows/install.nsi b/.install/windows/install.nsi index 13751b1c02..e6be716db1 100644 --- a/.install/windows/install.nsi +++ b/.install/windows/install.nsi @@ -400,7 +400,7 @@ Function SetEnvironment CreateEnv: SetDetailsPrint listonly StrCpy $0 "${flagsEnv}" - ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda create $0 -n $\"$envName$\" && conda deactivate" + ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda create $0 -c defaults -n $\"$envName$\" && conda deactivate" pop $0 ExecDos::wait $0 pop $0 From 05857b63e1d5033cb84eb69abfd1d40680831674 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 21 Nov 2025 15:19:12 +0000 Subject: [PATCH 928/981] bugfix: Correctly set learning rate from LR Finder when resuming --- .pylintrc | 4 ---- lib/config.py | 8 ++++---- lib/training/lr_finder.py | 4 ++-- plugins/train/model/_base/model.py | 24 +++++++++++++++++++++++- plugins/train/trainer/_base.py | 10 ++++++++-- 5 files changed, 37 insertions(+), 13 deletions(-) diff --git a/.pylintrc b/.pylintrc index 5f6e63247f..ee20f62b22 100644 --- a/.pylintrc +++ b/.pylintrc @@ -36,10 +36,6 @@ persistent=yes # Specify a configuration file. #rcfile= -# When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages. -suggestion-mode=yes - # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no diff --git a/lib/config.py b/lib/config.py index d4a2f1298b..66e6b369f5 100644 --- a/lib/config.py +++ b/lib/config.py @@ -302,7 +302,7 @@ def add_section(self, title: str, info: str) -> None: logger.debug("Add section: (title: '%s', info: '%s')", title, info) self.defaults[title] = ConfigSection(helptext=info, items=OrderedDict()) - def add_item(self, + def add_item(self, # pylint:disable=too-many-arguments,too-many-positional-arguments section: str | None = None, title: str | None = None, datatype: type = str, @@ -374,7 +374,7 @@ def add_item(self, group=group) @classmethod - def _expand_helptext(cls, + def _expand_helptext(cls, # pylint:disable=too-many-positional-arguments helptext: str, choices: str | list[str], default: ConfigValueType, @@ -453,7 +453,7 @@ def insert_config_section(self, config.set(section, helptext) logger.debug("Inserted section: '%s'", section) - def _insert_config_item(self, + def _insert_config_item(self, # pylint:disable=too-many-positional-arguments section: str, item: str, default: ConfigValueType, @@ -526,7 +526,7 @@ def _load_config(self) -> None: def save_config(self) -> None: """ Save a config file """ - logger.info("Updating config at: '%s'", self.configfile) + logger.debug("Updating config at: '%s'", self.configfile) with open(self.configfile, "w", encoding="utf-8", errors="replace") as f_cfgfile: self.config.write(f_cfgfile) logger.debug("Updated config at: '%s'", self.configfile) diff --git a/lib/training/lr_finder.py b/lib/training/lr_finder.py index 2ed81a3bf8..989f9df873 100644 --- a/lib/training/lr_finder.py +++ b/lib/training/lr_finder.py @@ -48,7 +48,7 @@ class LearningRateFinder: beta: float Amount to smooth loss by, for graphing purposes """ - def __init__(self, + def __init__(self, # pylint:disable=too-many-positional-arguments model: ModelBase, config: dict[str, ConfigValueType], feeder: Feeder, @@ -149,7 +149,7 @@ def _reset_model(self, original_lr: float, new_lr: float) -> None: new_lr: float The discovered optimal learning rate """ - self._model.state.update_session_config("learning_rate", new_lr) + self._model.state.add_lr_finder(new_lr) self._model.state.save() logger.debug("Loading initial weights") diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 5bc1161b17..66ea0e71cf 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -367,11 +367,13 @@ def _output_summary(self) -> None: else: # print to logger print_fn = lambda x: logger.verbose("%s", x) #type:ignore[attr-defined] # noqa[E731] # pylint:disable=C3001 + parent = None for idx, model in enumerate(get_all_sub_models(self.model)): if idx == 0: parent = model continue model.summary(line_length=100, print_fn=print_fn) + assert parent is not None parent.summary(line_length=100, print_fn=print_fn) def _compile_model(self) -> None: @@ -453,6 +455,7 @@ def __init__(self, self._name = model_name self._iterations = 0 self._mixed_precision_layers: list[str] = [] + self._lr_finder = -1.0 self._rebuild_model = False self._sessions: dict[int, dict] = {} self._lowest_avg_loss: dict[str, float] = {} @@ -503,6 +506,11 @@ def mixed_precision_layers(self) -> list[str]: """list: Layers that can be switched between mixed-float16 and float32. """ return self._mixed_precision_layers + @property + def lr_finder(self) -> float: + """ The value discovered from the learning rate finder. -1 if no value stored """ + return self._lr_finder + @property def model_needs_rebuild(self) -> bool: """bool: ``True`` if mixed precision policy has changed so model needs to be rebuilt @@ -595,6 +603,17 @@ def add_mixed_precision_layers(self, layers: list[str]) -> None: logger.debug("Storing mixed precision layers: %s", layers) self._mixed_precision_layers = layers + def add_lr_finder(self, learning_rate: float) -> None: + """ Add the optimal discovered learning rate from the learning rate finder + + Parameters + ---------- + learning_rate : float + The discovered learning rate + """ + logger.debug("Storing learning rate from LR Finder: %s", learning_rate) + self._lr_finder = learning_rate + def _load(self, config_changeable_items: dict) -> None: """ Load a state file and set the serialized values to the class instance. @@ -616,6 +635,7 @@ def _load(self, config_changeable_items: dict) -> None: self._lowest_avg_loss = state.get("lowest_avg_loss", {}) self._iterations = state.get("iterations", 0) self._mixed_precision_layers = state.get("mixed_precision_layers", []) + self._lr_finder = state.get("lr_finder", -1.0) self._config = state.get("config", {}) logger.debug("Loaded state: %s", state) self._replace_config(config_changeable_items) @@ -624,10 +644,12 @@ def save(self) -> None: """ Save the state values to the serialized state file. """ logger.debug("Saving State") state = {"name": self._name, - "sessions": self._sessions, + "sessions": {k: v for k, v in self._sessions.items() + if v.get("iterations", 0) > 0}, "lowest_avg_loss": self._lowest_avg_loss, "iterations": self._iterations, "mixed_precision_layers": self._mixed_precision_layers, + "lr_finder": self._lr_finder, "config": _CONFIG} self._serializer.save(self._filename, state) logger.debug("Saved State") diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index dbbc74c9fd..a839a91a36 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -15,6 +15,7 @@ import cv2 import numpy as np +import keras.backend as K import tensorflow as tf from tensorflow.python.framework import ( # pylint:disable=no-name-in-module errors_impl as tf_errors) @@ -162,9 +163,14 @@ def _handle_lr_finder(self) -> bool: success = lrf.find() return self._config["lr_finder_mode"] == "graph_and_exit" or not success - learning_rate = self._model.state.sessions[1]["config"]["learning_rate"] + learning_rate = self._model.state.lr_finder + if learning_rate < 0.: + logger.debug("No learning rate finder rate stored. Not setting") + return False + logger.info("Setting learning rate from Learning Rate Finder to %s", f"{learning_rate:.1e}") + K.set_value(self._model.model.optimizer.lr, learning_rate) return False def _set_tensorboard(self) -> tf.keras.callbacks.TensorBoard: @@ -807,7 +813,7 @@ class _Timelapse(): # pylint:disable=too-few-public-methods image_paths: dict The full paths to the training images for each side of the model """ - def __init__(self, + def __init__(self, # pylint:disable=too-many-positional-arguments model: ModelBase, coverage_ratio: float, image_count: int, From 381b06f6d372aaf2dfbfa89a8ef311035cae42e7 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 21 Nov 2025 17:52:45 +0000 Subject: [PATCH 929/981] training: Add Learning Rate Warmup --- lib/cli/args_train.py | 11 +++ lib/gui/wrapper.py | 2 +- lib/training/__init__.py | 1 + lib/training/lr_warmup.py | 98 +++++++++++++++++++ locales/kr/LC_MESSAGES/lib.cli.args_train.mo | Bin 16024 -> 16397 bytes locales/kr/LC_MESSAGES/lib.cli.args_train.po | 58 ++++++----- locales/lib.cli.args_train.pot | 52 +++++----- locales/ru/LC_MESSAGES/lib.cli.args_train.mo | Bin 21253 -> 21721 bytes locales/ru/LC_MESSAGES/lib.cli.args_train.po | 59 ++++++----- plugins/train/model/_base/model.py | 5 + plugins/train/trainer/_base.py | 15 ++- 11 files changed, 226 insertions(+), 75 deletions(-) create mode 100644 lib/training/lr_warmup.py diff --git a/lib/cli/args_train.py b/lib/cli/args_train.py index efbaa93c5b..e2f461db0f 100644 --- a/lib/cli/args_train.py +++ b/lib/cli/args_train.py @@ -164,6 +164,17 @@ def get_argument_list() -> list[dict[str, T.Any]]: "stop training when you are happy with the previews. However, if you want the " "model to stop automatically at a set number of iterations, you can set that " "value here.")}) + argument_list.append({ + "opts": ("-a", "--warmup"), + "action": Slider, + "min_max": (0, 5000), + "rounding": 100, + "type": int, + "default": 0, + "group": _("training"), + "help": _( + "Learning rate warmup. Linearly increase the learning rate from 0 to the chosen " + "target rate over the number of iterations given here. 0 to disable.")}) argument_list.append({ "opts": ("-D", "--distribution-strategy"), "dest": "distribution_strategy", diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index 88ee11f646..c248fd3cf1 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -366,7 +366,7 @@ def _read_stdout(self) -> None: if output and self._process_progress_stdout(output): continue - if output: + if output.strip(): self._process_training_stdout(output) print(output.rstrip()) diff --git a/lib/training/__init__.py b/lib/training/__init__.py index 0e30c90340..cc254c0fae 100644 --- a/lib/training/__init__.py +++ b/lib/training/__init__.py @@ -7,6 +7,7 @@ from .augmentation import ImageAugmentation from .generator import Feeder from .lr_finder import LearningRateFinder +from .lr_warmup import LearningRateWarmup from .preview_cv import PreviewBuffer, TriggerType if T.TYPE_CHECKING: diff --git a/lib/training/lr_warmup.py b/lib/training/lr_warmup.py new file mode 100644 index 0000000000..5044832400 --- /dev/null +++ b/lib/training/lr_warmup.py @@ -0,0 +1,98 @@ +#! /usr/env/bin/python3 +""" Handles Learning Rate Warmup when training a model """ +from __future__ import annotations + +import logging +import typing as T + +import keras.backend as K + +logger = logging.getLogger(__name__) + +if T.TYPE_CHECKING: + from keras.models import Model + + +class LearningRateWarmup(): + """ Handles the updating of the model's learning rate during Learning Rate Warmup + + Parameters + ---------- + model : :class:`keras.models.Model` + The keras model that is to be trained + target_learning_rate : float + The final learning rate at the end of warmup + steps : int + The number of iterations to warmup the learning rate for + """ + def __init__(self, model: Model, target_learning_rate: float, steps: int) -> None: + self._model = model + self._target_lr = target_learning_rate + self._steps = steps + self._current_lr = 0.0 + self._current_step = 0 + self._reporting_points = [int(self._steps * i / 10) for i in range(11)] + logger.debug("Initialized %s", self) + + def __repr__(self) -> str: + """ Pretty string representation for logging """ + params = ", ".join(f"{k}={v}" for k, v in self.__dict__.items()) + return f"{self.__class__.__name__}({params})" + + @classmethod + def _format_notation(cls, value: float) -> str: + """ Format a float to scientific notation at 1 decimal place + + Parameters + ---------- + value : float + The value to format + + Returns + ------- + str + The formatted float in scientific notation at 1 decimal place + """ + return f"{value:.1e}" + + def _set_learning_rate(self) -> None: + """ Set the learning rate for the current step """ + self._current_lr = self._current_step / self._steps * self._target_lr + K.set_value(self._model.optimizer.lr, self._current_lr) + logger.debug("Learning rate set to %s for step %s/%s", + self._current_lr, self._current_step, self._steps) + + def _output_status(self) -> None: + """ Output the progress of Learning Rate Warmup at set intervals """ + if self._current_step == 1: + logger.info("[Learning Rate Warmup] Start: %s, Target: %s, Steps: %s", + self._format_notation(self._current_lr), + self._format_notation(self._target_lr), self._steps) + return + + if self._current_step == self._steps: + print() + logger.info("[Learning Rate Warmup] Final Learning Rate: %s", + self._format_notation(self._target_lr)) + return + + if self._current_step in self._reporting_points: + print() + progress = int(round(100 / (len(self._reporting_points) - 1) * + self._reporting_points.index(self._current_step), 0)) + logger.info("[Learning Rate Warmup] Step: %s/%s (%s), Current: %s, Target: %s", + self._current_step, + self._steps, + f"{progress}%", + self._format_notation(self._current_lr), + self._format_notation(self._target_lr)) + + def __call__(self) -> None: + """ If a learning rate update is required, update the model's learning rate, otherwise + do nothing """ + if self._steps == 0 or self._current_step >= self._steps: + return + + self._current_step += 1 + self._set_learning_rate() + self._output_status() diff --git a/locales/kr/LC_MESSAGES/lib.cli.args_train.mo b/locales/kr/LC_MESSAGES/lib.cli.args_train.mo index 1e5985f335f035f6a0490a5d8e2c49e210e9d8d8..63d431a100000ff87c7406bc5623cc7cce722963 100644 GIT binary patch delta 1105 zcmYL{TS!z<6o!9I?P{i#_sgz~uG);1SJG~Dfh2;UdI&n%$vI(ToSBT27^_J{9Wr%N zbEZb5d&0yhBv}tdZxKX2L_tqo=j?gttzM%49K{x_^X>hweb(N4tvztT*Ba;foR;7R zZ5ch6UKj^#AlJw9L;FFdQ~OCiGlc}8gLqEbsqak$f}FP`0XN93*+36DM($*M_Z*;x z?3xSwrv7>!u!Hkg^DTNXHyMaBP`eN~&P~cwfD&>j6{zDTZx#d0jgm~jMOKid)}RMCOwt zm4KQ5MHO(2+|I?XxNf8xI7MnTjGGDHb70>16KT%Bl4gnIH2+NNNm~Kt0XsHhthQxh zn(QLd={&djRIr%N4q0!D9kGme!BRRq?XiSe{F)mU&~xZ~BbKpMGsfy$#&=^qR%*$D}U2)ergiC8yi^V~Q<`R*Q@skNh72T;h>xHhkRdG(y zPoF(gEcR(mRnZ&H3(Z-ltBOk%ZigxwsQq7iQg1vhY{K1W#p)c5F4ZaAie9g}r;;@` zse13VNaja#yPzzi9E zVIw#xW)jGtSJ)Vk-hhzaQ0#Ir<_`$t!Ts1Z3T-~)sy8~(CGWOc(E+~B%&6o#KpXR3aR4yJ!PouZ+smYFs+idv9LCBn*vASQ@L#80BDW+cjjbRjg6 zNmRNKiH&5I(j_5b(WM2Es1Q4e1!Cd3-`Dgc_j~SrzkAO;_nb3zv*yu6`BOSMjh1t4 z45J2m0uz3;H!`F4R?fNpTnl&QOcL_y-6?qN`a~U6*=XJ!#NP%dC!O#pEZaH=Vdtv zSDb{@j4yIh2K~O&&yra>HaN`qk~KQBC8CtJ%8Vm?_Ntsl)a4D{~o?=2K_`QlK1 X@WGSpyV`-iLiJL|Y^plnwYT*@K#o;p diff --git a/locales/kr/LC_MESSAGES/lib.cli.args_train.po b/locales/kr/LC_MESSAGES/lib.cli.args_train.po index c90082345d..68b661a673 100644 --- a/locales/kr/LC_MESSAGES/lib.cli.args_train.po +++ b/locales/kr/LC_MESSAGES/lib.cli.args_train.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 18:04+0000\n" -"PO-Revision-Date: 2024-03-28 18:16+0000\n" +"POT-Creation-Date: 2025-11-21 17:32+0000\n" +"PO-Revision-Date: 2025-11-21 17:47+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ko_KR\n" @@ -16,7 +16,7 @@ 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.4.2\n" +"X-Generator: Poedit 3.6\n" #: lib/cli/args_train.py:30 msgid "" @@ -157,8 +157,8 @@ msgstr "" "성 옵션이 있을 수 있습니다." #: lib/cli/args_train.py:147 lib/cli/args_train.py:160 -#: lib/cli/args_train.py:175 lib/cli/args_train.py:191 -#: lib/cli/args_train.py:200 +#: lib/cli/args_train.py:174 lib/cli/args_train.py:186 +#: lib/cli/args_train.py:202 lib/cli/args_train.py:211 msgid "training" msgstr "훈련" @@ -187,7 +187,15 @@ msgstr "" "다. 그러나 설정된 반복 횟수에서 모델이 자동으로 중지되도록 하려면 여기에서 해" "당 값을 설정할 수 있습니다." -#: lib/cli/args_train.py:177 +#: lib/cli/args_train.py:176 +msgid "" +"Learning rate warmup. Linearly increase the learning rate from 0 to the " +"chosen target rate over the number of iterations given here. 0 to disable." +msgstr "" +"학습률 워밍업. 여기에 주어진 반복 횟수에 따라 학습률을 0에서 선택한 목표 속도" +"까지 선형적으로 증가시킵니다. 0으로 설정하면 비활성화됩니다." + +#: lib/cli/args_train.py:188 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -208,7 +216,7 @@ msgstr "" "L|mirrored: 여러 로컬 GPU에서 동기화 분산 훈련을 지원합니다. 모델의 복사본과 " "모든 변수는 각 반복에서 각 GPU에 배포된 배치들와 함께 각 GPU에 로드됩니다." -#: lib/cli/args_train.py:193 +#: lib/cli/args_train.py:204 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -216,7 +224,7 @@ msgstr "" "텐서보드 로깅을 비활성화합니다. 주의: 로그를 비활성화하면 GUI에서 이 세션에 " "대한 그래프 또는 분석을 사용할 수 없습니다." -#: lib/cli/args_train.py:202 +#: lib/cli/args_train.py:213 msgid "" "Use the Learning Rate Finder to discover the optimal learning rate for " "training. For new models, this will calculate the optimal learning rate for " @@ -229,15 +237,15 @@ msgstr "" "때 발견된 최적의 학습률을 사용합니다. 이 옵션을 설정하면 수동으로 구성된 학습" "률(기차 설정에서 구성 가능)이 무시됩니다." -#: lib/cli/args_train.py:215 lib/cli/args_train.py:225 +#: lib/cli/args_train.py:226 lib/cli/args_train.py:236 msgid "Saving" msgstr "저장" -#: lib/cli/args_train.py:216 +#: lib/cli/args_train.py:227 msgid "Sets the number of iterations between each model save." msgstr "각 모델 저장 사이의 반복 횟수를 설정합니다." -#: lib/cli/args_train.py:227 +#: lib/cli/args_train.py:238 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -245,12 +253,12 @@ msgstr "" "현재 상태에서 모델의 백업 스냅샷을 저장하기 전에 반복할 횟수를 설정합니다. 0" "으로 설정하면 꺼집니다." -#: lib/cli/args_train.py:234 lib/cli/args_train.py:246 -#: lib/cli/args_train.py:258 +#: lib/cli/args_train.py:245 lib/cli/args_train.py:257 +#: lib/cli/args_train.py:269 msgid "timelapse" msgstr "타임랩스" -#: lib/cli/args_train.py:236 +#: lib/cli/args_train.py:247 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -263,7 +271,7 @@ msgstr "" "랩스를 만드는 데 사용할 'A' 얼굴의 입력 폴더여야 합니다. 또한 사용자는 --" "timelapse-output 및 --timelapse-input-B 매개 변수를 제공해야 합니다." -#: lib/cli/args_train.py:248 +#: lib/cli/args_train.py:259 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -276,7 +284,7 @@ msgstr "" "다. 타임 랩스를 만드는 데 사용할 'B' 얼굴의 입력 폴더여야 합니다. 또한 사용자" "는 --timelapse-output 및 --timelapse-input-A 매개 변수를 제공해야 합니다." -#: lib/cli/args_train.py:260 +#: lib/cli/args_train.py:271 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -288,27 +296,27 @@ msgstr "" "다. 입력 폴더가 제공되었지만 출력 폴더가 없는 경우 모델 폴더에/timelapse/로 " "기본 설정됩니다" -#: lib/cli/args_train.py:269 lib/cli/args_train.py:276 +#: lib/cli/args_train.py:280 lib/cli/args_train.py:287 msgid "preview" msgstr "미리보기" -#: lib/cli/args_train.py:270 +#: lib/cli/args_train.py:281 msgid "Show training preview output. in a separate window." msgstr "훈련 미리보기 결과를 각기 다른 창에서 보여줍니다." -#: lib/cli/args_train.py:278 +#: lib/cli/args_train.py:289 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." msgstr "" "훈련 결과를 파일에 씁니다. 이미지는 Faceswap 폴더의 최상위 폴더에 저장됩니다." -#: lib/cli/args_train.py:285 lib/cli/args_train.py:295 -#: lib/cli/args_train.py:305 lib/cli/args_train.py:315 +#: lib/cli/args_train.py:296 lib/cli/args_train.py:306 +#: lib/cli/args_train.py:316 lib/cli/args_train.py:326 msgid "augmentation" msgstr "보정" -#: lib/cli/args_train.py:287 +#: lib/cli/args_train.py:298 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -317,7 +325,7 @@ msgstr "" "무작위로 얼굴을 변환하지 않고 반대쪽 얼굴 세트에서 특징점과 밀접하게 일치하도" "록 훈련 얼굴을 변환해줍니다. 이것은 변환하는 'dfaker' 방식이다." -#: lib/cli/args_train.py:297 +#: lib/cli/args_train.py:308 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -327,7 +335,7 @@ msgstr "" "런 일이 일어나지 않는 것이 바람직합니다. 일반적으로 'fit training' 중을 제외" "하고는 이 작업을 중단해야 합니다." -#: lib/cli/args_train.py:307 +#: lib/cli/args_train.py:318 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -337,7 +345,7 @@ msgstr "" "이 되며, 훈련 시간 비용이 증가합니다. 색상 보저를 사용하지 않으려면 이 옵션" "을 사용합니다." -#: lib/cli/args_train.py:317 +#: lib/cli/args_train.py:328 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " diff --git a/locales/lib.cli.args_train.pot b/locales/lib.cli.args_train.pot index 9902e629a5..45f044d80e 100644 --- a/locales/lib.cli.args_train.pot +++ b/locales/lib.cli.args_train.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 18:04+0000\n" +"POT-Creation-Date: 2025-11-21 17:32+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -111,8 +111,8 @@ msgid "" msgstr "" #: lib/cli/args_train.py:147 lib/cli/args_train.py:160 -#: lib/cli/args_train.py:175 lib/cli/args_train.py:191 -#: lib/cli/args_train.py:200 +#: lib/cli/args_train.py:174 lib/cli/args_train.py:186 +#: lib/cli/args_train.py:202 lib/cli/args_train.py:211 msgid "training" msgstr "" @@ -133,7 +133,13 @@ msgid "" "can set that value here." msgstr "" -#: lib/cli/args_train.py:177 +#: lib/cli/args_train.py:176 +msgid "" +"Learning rate warmup. Linearly increase the learning rate from 0 to the " +"chosen target rate over the number of iterations given here. 0 to disable." +msgstr "" + +#: lib/cli/args_train.py:188 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -146,13 +152,13 @@ msgid "" "batches distributed to each GPU at each iteration." msgstr "" -#: lib/cli/args_train.py:193 +#: lib/cli/args_train.py:204 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." msgstr "" -#: lib/cli/args_train.py:202 +#: lib/cli/args_train.py:213 msgid "" "Use the Learning Rate Finder to discover the optimal learning rate for " "training. For new models, this will calculate the optimal learning rate for " @@ -161,26 +167,26 @@ msgid "" "the manually configured learning rate (configurable in train settings)." msgstr "" -#: lib/cli/args_train.py:215 lib/cli/args_train.py:225 +#: lib/cli/args_train.py:226 lib/cli/args_train.py:236 msgid "Saving" msgstr "" -#: lib/cli/args_train.py:216 +#: lib/cli/args_train.py:227 msgid "Sets the number of iterations between each model save." msgstr "" -#: lib/cli/args_train.py:227 +#: lib/cli/args_train.py:238 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." msgstr "" -#: lib/cli/args_train.py:234 lib/cli/args_train.py:246 -#: lib/cli/args_train.py:258 +#: lib/cli/args_train.py:245 lib/cli/args_train.py:257 +#: lib/cli/args_train.py:269 msgid "timelapse" msgstr "" -#: lib/cli/args_train.py:236 +#: lib/cli/args_train.py:247 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -189,7 +195,7 @@ msgid "" "timelapse-input-B parameter." msgstr "" -#: lib/cli/args_train.py:248 +#: lib/cli/args_train.py:259 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -198,7 +204,7 @@ msgid "" "timelapse-input-A parameter." msgstr "" -#: lib/cli/args_train.py:260 +#: lib/cli/args_train.py:271 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -206,47 +212,47 @@ msgid "" "model folder/timelapse/" msgstr "" -#: lib/cli/args_train.py:269 lib/cli/args_train.py:276 +#: lib/cli/args_train.py:280 lib/cli/args_train.py:287 msgid "preview" msgstr "" -#: lib/cli/args_train.py:270 +#: lib/cli/args_train.py:281 msgid "Show training preview output. in a separate window." msgstr "" -#: lib/cli/args_train.py:278 +#: lib/cli/args_train.py:289 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." msgstr "" -#: lib/cli/args_train.py:285 lib/cli/args_train.py:295 -#: lib/cli/args_train.py:305 lib/cli/args_train.py:315 +#: lib/cli/args_train.py:296 lib/cli/args_train.py:306 +#: lib/cli/args_train.py:316 lib/cli/args_train.py:326 msgid "augmentation" msgstr "" -#: lib/cli/args_train.py:287 +#: lib/cli/args_train.py:298 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " "warping." msgstr "" -#: lib/cli/args_train.py:297 +#: lib/cli/args_train.py:308 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " "left off except for during 'fit training'." msgstr "" -#: lib/cli/args_train.py:307 +#: lib/cli/args_train.py:318 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " "Enable this option to disable color augmentation." msgstr "" -#: lib/cli/args_train.py:317 +#: lib/cli/args_train.py:328 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " diff --git a/locales/ru/LC_MESSAGES/lib.cli.args_train.mo b/locales/ru/LC_MESSAGES/lib.cli.args_train.mo index f300361161083773dbbf770f2a180053a681f6f6..94837884e0a9b20200e88ce9782e37be12ced462 100644 GIT binary patch delta 1175 zcmYL{U2IKR6vzLUq8-M!s%y$-Bsz^&Z&8C}i0`i^aA+iy-E<^%d1Y zlovvHk&sB*-ZrXr?-OsrK8cr^2M=NrOw6-Hh~GJvTFE}Yv)9^tt+m%$r|(4mz8lHD zoLX{K$UN3htOXHiFUh-Hci5}>MNu)Tmc*4 zeYga^hQqL8R*^5DM}SMwV&bED)lKZtO8mixa00BHBaKm3st$joI!MxLfF!HmAjxYp zOyY0{+(}}?b4iZ<#QDXS-1}MDh5r8q(kITZU5Fp-YpSIAaPu$t87=)$%|GWdHPT)5 zdCELCi=rvU4@~i#P7H^ZN#}UEmQ!BXxk~y-W-nGt(^1<^(lO5c3s11$yG1$$U&3?f z<6EUY@clNbM&NCYB!d4xf0t^|-`-hl+4Egeww#MCyNfS91%uN31*xj8!O1WOr^CmP z`=VnbLbXFNUr84g_#|F1Dw@U$dvewDO6DFNJ=GL0siDcU3{u%YCWRK&elBp)+ zIT_a+alDp82W!ptWXg5C=A$N=ihHh;c1tWaH{yZ~U(Ud%lDdBnq4lnFark#DwZtn89#_|cvYGSdP`k09=U%52*Uu85B zX~>n%y;SDhvOV^JE!dpjW(RDa@!Rc?EwCy0?S6+HG}wCmlm1z(c{}J|tTpz!9prz& zKIB_4{z==%W*FmH41G8p@H<8vb@x`y-d{jbXKX*mZJ&Q0k8K>w2ls6*@Np*eKmuPC zgM|9P@i4q01@Rf}qF@lYxF^J%!(IOzoEi{4; zP|+Z+xM(B9N>~tr*oqbwmMMHRf?8P`qu@7p)dO?CnKO6J`RAOOnMzGONyQ(s=}EM_ zV^tU_*brFfM|&+>^}UfVU4N>FYqFAtNdNkI@X+m7J6vng++i!@ zx%(rv3qF%^D9N-FtH>*9_4z16;_uS)|IOC1+!6l$XDv?5nUu6mhROxAt8^waPz!b! zJCj(}Z-}oAVO5hp2W7z)J8XSoz8NeCd57&x#EGO`LAXyLX<2x<9o5fu@6y#$W2v>d izp$mRP#nld13RK>rL!$n-@mC?o$k8TR6Wysw)YR3TUOBk diff --git a/locales/ru/LC_MESSAGES/lib.cli.args_train.po b/locales/ru/LC_MESSAGES/lib.cli.args_train.po index 4e41e6a3f2..c511b56940 100755 --- a/locales/ru/LC_MESSAGES/lib.cli.args_train.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args_train.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 18:04+0000\n" -"PO-Revision-Date: 2024-03-28 18:18+0000\n" +"POT-Creation-Date: 2025-11-21 17:32+0000\n" +"PO-Revision-Date: 2025-11-21 17:48+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -17,7 +17,7 @@ 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.4.2\n" +"X-Generator: Poedit 3.6\n" #: lib/cli/args_train.py:30 msgid "" @@ -165,8 +165,8 @@ msgstr "" "замораживания других слоев." #: lib/cli/args_train.py:147 lib/cli/args_train.py:160 -#: lib/cli/args_train.py:175 lib/cli/args_train.py:191 -#: lib/cli/args_train.py:200 +#: lib/cli/args_train.py:174 lib/cli/args_train.py:186 +#: lib/cli/args_train.py:202 lib/cli/args_train.py:211 msgid "training" msgstr "тренировка" @@ -198,7 +198,16 @@ msgstr "" "вы хотите, чтобы модель автоматически останавливалась при определенном " "количестве итераций, вы можете задать это значение здесь." -#: lib/cli/args_train.py:177 +#: lib/cli/args_train.py:176 +msgid "" +"Learning rate warmup. Linearly increase the learning rate from 0 to the " +"chosen target rate over the number of iterations given here. 0 to disable." +msgstr "" +"Разогрев скорости обучения. Линейно увеличивает скорость обучения от 0 до " +"выбранного целевого значения за указанное здесь количество итераций. 0 — " +"отключить." + +#: lib/cli/args_train.py:188 msgid "" "R|Select the distribution stategy to use.\n" "L|default: Use Tensorflow's default distribution strategy.\n" @@ -221,7 +230,7 @@ msgstr "" "локальных GPU. Копия модели и все переменные загружаются на каждый GPU с " "распределением партий на каждый GPU на каждой итерации." -#: lib/cli/args_train.py:193 +#: lib/cli/args_train.py:204 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -230,7 +239,7 @@ msgstr "" "журналов означает, что вы не сможете использовать график или анализ для этой " "сессии в графическом интерфейсе." -#: lib/cli/args_train.py:202 +#: lib/cli/args_train.py:213 msgid "" "Use the Learning Rate Finder to discover the optimal learning rate for " "training. For new models, this will calculate the optimal learning rate for " @@ -245,15 +254,15 @@ msgstr "" "модели. Установка этой опции приведет к игнорированию вручную настроенного " "коэффициента обучения (настраиваемого в параметрах обучения)." -#: lib/cli/args_train.py:215 lib/cli/args_train.py:225 +#: lib/cli/args_train.py:226 lib/cli/args_train.py:236 msgid "Saving" msgstr "Сохранение" -#: lib/cli/args_train.py:216 +#: lib/cli/args_train.py:227 msgid "Sets the number of iterations between each model save." msgstr "Устанавливает количество итераций между каждым сохранением модели." -#: lib/cli/args_train.py:227 +#: lib/cli/args_train.py:238 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -262,12 +271,12 @@ msgstr "" "Устанавливает количество итераций перед сохранением резервного снимка модели " "в текущем состоянии. Установите значение 0 для выключения." -#: lib/cli/args_train.py:234 lib/cli/args_train.py:246 -#: lib/cli/args_train.py:258 +#: lib/cli/args_train.py:245 lib/cli/args_train.py:257 +#: lib/cli/args_train.py:269 msgid "timelapse" msgstr "таймлапс" -#: lib/cli/args_train.py:236 +#: lib/cli/args_train.py:247 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -281,7 +290,7 @@ msgstr "" "создания timelapse. Вы также должны указать параметры --timelapse-output и --" "timelapse-input-B." -#: lib/cli/args_train.py:248 +#: lib/cli/args_train.py:259 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -295,7 +304,7 @@ msgstr "" "создания timelapse. Вы также должны указать параметры --timelapse-output и --" "timelapse-input-A." -#: lib/cli/args_train.py:260 +#: lib/cli/args_train.py:271 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -307,15 +316,15 @@ msgstr "" "указаны входные папки, но нет выходной папки, то по умолчанию будет выбрана " "папка модели/timelapse/" -#: lib/cli/args_train.py:269 lib/cli/args_train.py:276 +#: lib/cli/args_train.py:280 lib/cli/args_train.py:287 msgid "preview" msgstr "предпросмотр" -#: lib/cli/args_train.py:270 +#: lib/cli/args_train.py:281 msgid "Show training preview output. in a separate window." msgstr "Показать вывод предварительного просмотра тренировки в отдельном окне." -#: lib/cli/args_train.py:278 +#: lib/cli/args_train.py:289 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -323,12 +332,12 @@ msgstr "" "Записывает результат обучения в файл. Изображение будет сохранено в корне " "папки Faceswap." -#: lib/cli/args_train.py:285 lib/cli/args_train.py:295 -#: lib/cli/args_train.py:305 lib/cli/args_train.py:315 +#: lib/cli/args_train.py:296 lib/cli/args_train.py:306 +#: lib/cli/args_train.py:316 lib/cli/args_train.py:326 msgid "augmentation" msgstr "аугментация" -#: lib/cli/args_train.py:287 +#: lib/cli/args_train.py:298 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -338,7 +347,7 @@ msgstr "" "набора лиц вместо случайного искажения лица. Это способ выполнения искажения " "от \"dfaker\" ." -#: lib/cli/args_train.py:297 +#: lib/cli/args_train.py:308 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -348,7 +357,7 @@ msgstr "" "горизонтали. Иногда желательно, чтобы этого не происходило. Как правило, это " "не нужно делать, за исключением случаев \"тренировки подгонки\"." -#: lib/cli/args_train.py:307 +#: lib/cli/args_train.py:318 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -359,7 +368,7 @@ msgstr "" "времени на обучение. Включите этот параметр для отключения цветовой " "аугментации." -#: lib/cli/args_train.py:317 +#: lib/cli/args_train.py:328 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 66ea0e71cf..35d8b5f4a9 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -179,6 +179,11 @@ def iterations(self) -> int: """ int: The total number of iterations that the model has trained. """ return self._state.iterations + @property + def warmup_steps(self) -> int: + """ int : The number of steps to perform learning rate warmup """ + return self._args.warmup + # Private properties @property def _config_section(self) -> str: diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index a839a91a36..b5916be319 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -21,7 +21,7 @@ errors_impl as tf_errors) from lib.image import hex_to_rgb -from lib.training import Feeder, LearningRateFinder +from lib.training import Feeder, LearningRateFinder, LearningRateWarmup from lib.utils import FaceswapError, get_folder, get_image_paths from plugins.train._config import Config @@ -89,6 +89,7 @@ def __init__(self, if self._exit_early: return + self._warmup = self._get_warmup() self._model.state.add_session_batchsize(batch_size) self._images = images self._sides = sorted(key for key in self._images.keys()) @@ -173,6 +174,17 @@ def _handle_lr_finder(self) -> bool: K.set_value(self._model.model.optimizer.lr, learning_rate) return False + def _get_warmup(self) -> LearningRateWarmup: + """ Obtain the learning rate warmup instance + + Returns + ------- + :class:`plugins.train.lr_warmup.LRWarmup` + The Learning Rate Warmup object + """ + target_lr = float(K.get_value(self._model.model.optimizer.lr)) + return LearningRateWarmup(self._model.model, target_lr, self._model.warmup_steps) + def _set_tensorboard(self) -> tf.keras.callbacks.TensorBoard: """ Set up Tensorboard callback for logging loss. @@ -251,6 +263,7 @@ def train_one_step(self, (self._model.iterations - 1) % snapshot_interval == 0) model_inputs, model_targets = self._feeder.get_batch() + self._warmup() try: loss: list[float] = self._model.model.train_on_batch(model_inputs, y=model_targets) From 102c8b4f906379b662d038573c0eb2aa61262a3c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 21 Nov 2025 22:51:20 +0000 Subject: [PATCH 930/981] bugfix: Correctly set learning rate from learning rate finder on restart --- plugins/train/trainer/_base.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index b5916be319..529c0f87a7 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -159,19 +159,20 @@ def _handle_lr_finder(self) -> bool: if not self._model.command_line_arguments.use_lr_finder: return False + if self._model.state.lr_finder > -1: + learning_rate = self._model.state.lr_finder + logger.info("Setting learning rate from Learning Rate Finder to %s", + f"{learning_rate:.1e}") + K.set_value(self._model.model.optimizer.lr, learning_rate) + self._model.state.update_session_config("learning_rate", learning_rate) + return False + if self._model.state.iterations == 0 and self._model.state.session_id == 1: lrf = LearningRateFinder(self._model, self._config, self._feeder) success = lrf.find() return self._config["lr_finder_mode"] == "graph_and_exit" or not success - learning_rate = self._model.state.lr_finder - if learning_rate < 0.: - logger.debug("No learning rate finder rate stored. Not setting") - return False - - logger.info("Setting learning rate from Learning Rate Finder to %s", - f"{learning_rate:.1e}") - K.set_value(self._model.model.optimizer.lr, learning_rate) + logger.debug("No learning rate finder rate. Not setting") return False def _get_warmup(self) -> LearningRateWarmup: From d115fcbabfb8913c3eab9386dc314d3b441a11fd Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 23 Nov 2025 15:10:40 +0000 Subject: [PATCH 931/981] bugfux: GUI session data. Prevent crash on missing session data --- lib/gui/analysis/stats.py | 40 ++++++++++++++++++++++++------------- lib/gui/display_analysis.py | 5 +++-- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index b055cfa332..44f9651e3d 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -60,10 +60,15 @@ def model_filename(self) -> str: """ str: The full model filename """ return os.path.join(self._model_dir, self._model_name) + @property + def have_session_data(self) -> bool: + """ bool : ``True`` if session data is available otherwise ``False`` """ + return bool(self._state and self._state["sessions"]) + @property def batch_sizes(self) -> dict[int, int]: """ dict: The batch sizes for each session_id for the model. """ - if not self._state: + if not self.have_session_data: return {} return {int(sess_id): sess["batchsize"] for sess_id, sess in self._state.get("sessions", {}).items()} @@ -76,9 +81,9 @@ def full_summary(self) -> list[dict]: @property def logging_disabled(self) -> bool: - """ bool: ``True`` if logging is enabled for the currently training session otherwise + """ bool: ``True`` if logging is disabled for the currently training session otherwise ``False``. """ - if not self._state: + if not self.have_session_data: return True max_id = str(max(int(idx) for idx in self._state["sessions"])) return self._state["sessions"][max_id]["no_logs"] @@ -311,6 +316,10 @@ def get_summary_stats(self) -> list[dict]: within the loaded data as well as the totals. """ logger.debug("Compiling sessions summary data") + if not self._session.have_session_data: + logger.debug("Session data doesn't exist. Most likely task has been " + "terminated during compilation, or is from LR finder") + return [] self._get_time_stats() self._get_per_session_stats() if not self._per_session_stats: @@ -365,9 +374,9 @@ def _get_per_session_stats(self) -> None: compiled = [] for session_id in self._time_stats: logger.debug("Compiling session ID: %s", session_id) - if not self._state: - logger.debug("Session state dict doesn't exist. Most likely task has been " - "terminated during compilation") + if not self._session.have_session_data: + logger.debug("Session data doesn't exist. Most likely task has been " + "terminated during compilation, or is from LR finder") return compiled.append(self._collate_stats(session_id)) @@ -435,6 +444,8 @@ def _total_stats(self) -> dict[str, str | int | float]: iterations for all session ids within the loaded data. """ logger.debug("Compiling Totals") + starttime = 0.0 + endtime = 0.0 elapsed = 0 examples = 0 iterations = 0 @@ -450,13 +461,14 @@ def _total_stats(self) -> dict[str, str | int | float]: batchset.add(summary["batch"]) iterations += summary["iterations"] batch = ",".join(str(bs) for bs in batchset) - totals = {"session": "Total", - "start": starttime, - "end": endtime, - "elapsed": elapsed, - "rate": examples / elapsed if elapsed != 0 else 0, - "batch": batch, - "iterations": iterations} + totals: dict[str, str | int | float] = { + "session": "Total", + "start": starttime, + "end": endtime, + "elapsed": elapsed, + "rate": examples / elapsed if elapsed != 0 else 0, + "batch": batch, + "iterations": iterations} logger.debug(totals) return totals @@ -533,7 +545,7 @@ class Calculations(): ``True`` if values significantly away from the average should be excluded, otherwise ``False``. Default: ``False`` """ - def __init__(self, session_id, + def __init__(self, session_id, # pylint:disable=too-many-positional-arguments display: str = "loss", loss_keys: list[str] | str = "loss", selections: list[str] | str = "raw", diff --git a/lib/gui/display_analysis.py b/lib/gui/display_analysis.py index 9dcef89164..bf37123040 100644 --- a/lib/gui/display_analysis.py +++ b/lib/gui/display_analysis.py @@ -195,12 +195,13 @@ def _set_session_summary(self, message): else: logger.debug("Retrieving data from thread") result = self._thread.get_result() - if result is None: + del self._thread + self._thread = None + if not result: logger.debug("No result from session summary. Clearing analysis view") self._clear_session() return self._summary = result - self._thread = None self.set_info(f"Session: {message}") self._stats.tree_insert_data(self._summary) From 837bc2d51dc808110af9b2de0b936b28d41c4f6a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 21 Dec 2025 02:45:11 +0000 Subject: [PATCH 932/981] Faceswap 3 (#1516) * FaceSwap 3 (#1515) * Update extract pipeline * Update requirements + setup for nvidia * Remove allow-growth option * tf.keras to keras updates * lib.model.losses - Port + fix all loss functions for Keras3 * lib.model - port initializers, layers. normalization to Keras3 * lib.model.autoclip to Keras 3 * Update mixed precision layer storage * model file to .keras format * Restructure nn_blocks to initialize layers in __init__ * Tensorboard - Trainer: Add Torch compatible Tensorboard callbacks - GUI event reader remove TF dependency * Loss logging - Flush TB logs on save - Replace TB live iterator for GUI * Backup models on total loss drop rather than per side * Update all models to Keras3 Compat * Remove lib.model.session * Update clip ViT to Keras 3 * plugins.extract.mask.unet-dfl - Fix for Keras3/Torch backend * Port AdaBelief to Keras 3 * setup.py: - Add --dev flag for dev tool install * Fix Keras 3 syntax * Fix LR Finder for Keras 3 * Fix mixed precision switching for Keras 3 * Add more optimizers + open up config setting * train: Remove updating FS1 weights to FS2 models * Alignments: Remove support for legacy .json files * tools.model: - Remove TF Saved Format saving - Fix Backup/Restore + Nan-Scan * Fix inference model creation for Keras 3 * Preview tool: Fix for Keras3 * setup.py: Configure keras backend * train: Migration of FS2 models to FS3 * Training: Default coverage to 100% * Remove DirectML backend * Update setup for MacOS * GUI: Force line reading to UTF-8 * Remove redundant Tensorflow references * Remove redundant code * Legacy model loading: Fix TFLamdaOp scalar ops and DepthwiseConv2D * Add vertical offset option for training * Github actions: Add more python versions * Add python version to workflow names * Github workflow: Exclude Python 3.12 for macOS * Implement custom training loop * Fs3 - Add RTX5xxx and ROCm 6.1-6.4 support (#1511) * setup.py: Add Cuda/ROCm version select options * bump minimum python version to 3.11 * Switch from setup.cgf to pyproject.toml * Documentation: Update all docs to use automodapi * Allow sysinfo to run with missing packages + correctly install tk under Linux * Bugfix: dot naming convention in clip models * lib.config: Centralise globally rather than passing as object - Add torch DataParallel for multi-gpu training - GUI: Group switches together when generating cli args - CLI: Remove deprecated multi-character argparse args - Refactor: - Centralise tensorboard reading/writing + unit tests - Create trainer plugin interfaces + add original + distributed * Update installers --- .github/workflows/pytest.yml | 128 +- .gitignore | 16 +- .install/linux/faceswap_setup_x64.sh | 56 +- .install/macos/faceswap_setup_macos.sh | 6 +- .install/windows/install.nsi | 42 +- .pylintrc | 488 ---- .readthedocs.yml | 6 +- INSTALL.md | 36 +- README.md | 2 +- docs/conf.py | 67 +- docs/full/lib/align.rst | 169 +- docs/full/lib/cli.rst | 70 +- docs/full/lib/config.rst | 28 +- docs/full/lib/convert.rst | 10 +- docs/full/lib/git.rst | 13 +- docs/full/lib/gpu_stats.rst | 76 +- docs/full/lib/gui.rst | 269 +-- docs/full/lib/image.rst | 38 +- docs/full/lib/keras_utils.rst | 11 +- docs/full/lib/keypress.rst | 2 + docs/full/lib/lib.rst | 1 + docs/full/lib/logger.rst | 11 +- docs/full/lib/model.rst | 196 +- docs/full/lib/multithreading.rst | 9 +- docs/full/lib/plaidml_utils.rst | 8 - docs/full/lib/queue_manager.rst | 2 + docs/full/lib/serializer.rst | 22 +- docs/full/lib/sysinfo.rst | 7 - docs/full/lib/system.rst | 23 + docs/full/lib/training.rst | 76 +- docs/full/lib/utils.rst | 11 +- docs/full/modules.rst | 1 - docs/full/plugins/convert.rst | 88 +- docs/full/plugins/extract.rst | 165 +- docs/full/plugins/plugin_loader.rst | 10 +- docs/full/plugins/plugins.rst | 1 + docs/full/plugins/train.rst | 84 +- docs/full/scripts.rst | 63 +- docs/full/setup.rst | 11 +- docs/full/tests/lib.gpu_stats.rst | 15 - docs/full/tests/lib.gui.rst | 15 - docs/full/tests/lib.rst | 39 - docs/full/tests/tests.rst | 17 - docs/full/tests/tools.alignments.rst | 15 - docs/full/tests/tools.preview.rst | 15 - docs/full/tests/tools.rst | 15 - docs/full/tools/alignments.rst | 79 +- docs/full/tools/ffmpeg.rst | 15 + docs/full/tools/manual.faceviewer.rst | 70 - docs/full/tools/manual.frameviewer.rst | 109 - docs/full/tools/manual.rst | 119 +- docs/full/tools/mask.rst | 35 + docs/full/tools/model.rst | 15 + docs/full/tools/preview.rst | 52 +- docs/full/tools/sort.rst | 37 +- docs/full/tools/tools.rst | 30 +- docs/full/update_deps.rst | 11 +- docs/index.rst | 2 +- docs/sphinx_requirements.txt | 23 +- faceswap.py | 9 +- lib/align/aligned_face.py | 38 +- lib/align/aligned_mask.py | 24 +- lib/align/alignments.py | 73 +- lib/align/constants.py | 5 + lib/align/detected_face.py | 173 +- lib/align/pose.py | 4 + lib/align/thumbnails.py | 8 +- lib/align/updater.py | 29 +- lib/cli/actions.py | 5 + lib/cli/args.py | 23 +- lib/cli/args_extract_convert.py | 96 +- lib/cli/args_train.py | 89 +- lib/cli/launcher.py | 113 +- lib/config.py | 651 ------ lib/config/__init__.py | 4 + lib/config/config.py | 271 +++ lib/config/ini.py | 410 ++++ lib/config/objects.py | 463 ++++ lib/convert.py | 48 +- lib/git.py | 5 + lib/gpu_stats/__init__.py | 28 +- lib/gpu_stats/_base.py | 30 +- lib/gpu_stats/apple_silicon.py | 62 +- lib/gpu_stats/cpu.py | 16 + lib/gpu_stats/directml.py | 630 ----- lib/gpu_stats/nvidia.py | 36 +- lib/gpu_stats/nvidia_apple.py | 135 -- lib/gpu_stats/rocm.py | 29 +- lib/gui/_config.py | 119 - lib/gui/analysis/event_reader.py | 173 +- lib/gui/analysis/moving_average.py | 179 ++ lib/gui/analysis/stats.py | 180 +- lib/gui/command.py | 5 + lib/gui/control_helper.py | 369 ++- lib/gui/custom_widgets.py | 7 +- lib/gui/display.py | 4 + lib/gui/display_analysis.py | 9 +- lib/gui/display_command.py | 19 +- lib/gui/display_graph.py | 39 +- lib/gui/display_page.py | 8 +- lib/gui/gui_config.py | 181 ++ lib/gui/menu.py | 21 +- lib/gui/options.py | 18 +- lib/gui/popup_configure.py | 691 +++--- lib/gui/popup_session.py | 20 +- lib/gui/project.py | 19 +- lib/gui/theme.py | 12 +- lib/gui/utils/config.py | 31 +- lib/gui/utils/file_handler.py | 23 +- lib/gui/utils/image.py | 34 +- lib/gui/utils/misc.py | 5 + lib/gui/wrapper.py | 20 +- lib/image.py | 246 +- lib/keras_utils.py | 172 +- lib/keypress.py | 7 + lib/logger.py | 61 +- lib/model/autoclip.py | 119 +- lib/model/backup_restore.py | 82 +- lib/model/initializers.py | 354 +-- lib/model/layers.py | 544 ++--- lib/model/losses/feature_loss.py | 122 +- lib/model/losses/loss.py | 389 ++-- lib/model/losses/perceptual_loss.py | 664 ++++-- lib/model/networks/clip.py | 257 ++- lib/model/networks/simple_nets.py | 57 +- lib/model/nn_blocks.py | 634 ++--- lib/model/normalization.py | 397 ++-- lib/model/optimizers.py | 418 ++-- lib/model/session.py | 208 -- lib/multithreading.py | 5 + lib/queue_manager.py | 7 +- lib/serializer.py | 6 +- lib/system/__init__.py | 5 + lib/system/ml_libs.py | 998 ++++++++ lib/{ => system}/sysinfo.py | 248 +- lib/system/system.py | 299 +++ lib/training/augmentation.py | 615 +++-- lib/training/cache.py | 788 ++++--- lib/training/generator.py | 121 +- lib/training/lr_finder.py | 108 +- lib/training/lr_warmup.py | 23 +- lib/training/preview_cv.py | 10 +- lib/training/preview_tk.py | 13 +- lib/training/tensorboard.py | 221 ++ lib/utils.py | 153 +- lib/vgg_face.py | 117 - locales/es/LC_MESSAGES/lib.cli.args_train.mo | Bin 15946 -> 15202 bytes locales/es/LC_MESSAGES/lib.cli.args_train.po | 108 +- locales/kr/LC_MESSAGES/lib.cli.args_train.mo | Bin 16397 -> 15276 bytes locales/kr/LC_MESSAGES/lib.cli.args_train.po | 94 +- locales/lib.cli.args_train.pot | 61 +- ...{lib.config.pot => lib.config.objects.pot} | 16 +- ...pot => plugins.extract.extract_config.pot} | 44 +- ...fig.pot => plugins.train.train_config.pot} | 675 +++--- .../plugins.train.trainer.trainer_config.pot | 110 + locales/ru/LC_MESSAGES/lib.cli.args_train.mo | Bin 21721 -> 20273 bytes locales/ru/LC_MESSAGES/lib.cli.args_train.po | 98 +- locales/ru/LC_MESSAGES/lib.config.objects.mo | Bin 0 -> 1309 bytes .../{lib.config.po => lib.config.objects.po} | 24 +- ...g.mo => plugins.extract.extract_config.mo} | Bin 9371 -> 8416 bytes ...g.po => plugins.extract.extract_config.po} | 68 +- ...onfig.mo => plugins.train.train_config.mo} | Bin 59901 -> 70393 bytes ...onfig.po => plugins.train.train_config.po} | 1261 ++++++---- plugins/convert/_config.py | 17 - plugins/convert/color/_base.py | 29 +- plugins/convert/color/avg_color.py | 4 + plugins/convert/color/color_transfer.py | 11 +- .../convert/color/color_transfer_defaults.py | 116 +- plugins/convert/color/manual_balance.py | 17 +- .../convert/color/manual_balance_defaults.py | 244 +- plugins/convert/color/match_hist.py | 7 +- plugins/convert/color/match_hist_defaults.py | 89 +- plugins/convert/color/seamless_clone.py | 17 +- plugins/convert/convert_config.py | 41 + plugins/convert/mask/mask_blend.py | 79 +- plugins/convert/mask/mask_blend_defaults.py | 282 +-- plugins/convert/scaling/_base.py | 37 +- plugins/convert/scaling/sharpen.py | 158 +- plugins/convert/scaling/sharpen_defaults.py | 170 +- plugins/convert/writer/_base.py | 50 +- plugins/convert/writer/ffmpeg.py | 30 +- plugins/convert/writer/ffmpeg_defaults.py | 255 +- plugins/convert/writer/gif.py | 14 +- plugins/convert/writer/gif_defaults.py | 141 +- plugins/convert/writer/opencv.py | 36 +- plugins/convert/writer/opencv_defaults.py | 178 +- plugins/convert/writer/patch.py | 62 +- plugins/convert/writer/patch_defaults.py | 333 ++- plugins/convert/writer/pillow.py | 34 +- plugins/convert/writer/pillow_defaults.py | 250 +- plugins/extract/_base.py | 225 +- plugins/extract/_config.py | 140 -- plugins/extract/align/_base/aligner.py | 59 +- plugins/extract/align/cv2_dnn.py | 4 + plugins/extract/align/external.py | 38 +- plugins/extract/align/external_defaults.py | 146 +- plugins/extract/align/fan.py | 38 +- plugins/extract/align/fan_defaults.py | 90 +- plugins/extract/detect/_base.py | 9 +- plugins/extract/detect/cv2_dnn.py | 7 +- plugins/extract/detect/cv2_dnn_defaults.py | 83 +- plugins/extract/detect/external.py | 36 +- plugins/extract/detect/external_defaults.py | 113 +- plugins/extract/detect/mtcnn.py | 519 +++-- plugins/extract/detect/mtcnn_defaults.py | 211 +- plugins/extract/detect/s3fd.py | 335 +-- plugins/extract/detect/s3fd_defaults.py | 113 +- plugins/extract/extract_config.py | 150 ++ plugins/extract/extract_media.py | 4 + plugins/extract/mask/_base.py | 50 +- plugins/extract/mask/bisenet_fp.py | 329 +-- plugins/extract/mask/bisenet_fp_defaults.py | 176 +- plugins/extract/mask/components.py | 7 +- plugins/extract/mask/custom.py | 19 +- plugins/extract/mask/custom_defaults.py | 116 +- plugins/extract/mask/extended.py | 6 +- plugins/extract/mask/unet_dfl.py | 219 +- plugins/extract/mask/unet_dfl_defaults.py | 88 +- plugins/extract/mask/vgg_clear.py | 192 +- plugins/extract/mask/vgg_clear_defaults.py | 88 +- plugins/extract/mask/vgg_obstructed.py | 212 +- .../extract/mask/vgg_obstructed_defaults.py | 88 +- plugins/extract/pipeline.py | 157 +- plugins/extract/recognition/_base.py | 45 +- plugins/extract/recognition/vgg_face2.py | 327 ++- .../extract/recognition/vgg_face2_defaults.py | 102 +- plugins/plugin_loader.py | 5 + plugins/train/_config.py | 684 ------ plugins/train/model/_base/inference.py | 282 +++ plugins/train/model/_base/io.py | 271 ++- plugins/train/model/_base/model.py | 786 +------ plugins/train/model/_base/settings.py | 656 +++--- plugins/train/model/_base/state.py | 446 ++++ plugins/train/model/_base/update.py | 496 ++++ plugins/train/model/dfaker.py | 22 +- plugins/train/model/dfaker_defaults.py | 88 +- plugins/train/model/dfl_h128.py | 16 +- plugins/train/model/dfl_h128_defaults.py | 89 +- plugins/train/model/dfl_sae.py | 83 +- plugins/train/model/dfl_sae_defaults.py | 187 +- plugins/train/model/dlight.py | 87 +- plugins/train/model/dlight_defaults.py | 128 +- plugins/train/model/iae.py | 28 +- plugins/train/model/lightweight.py | 15 +- plugins/train/model/original.py | 31 +- plugins/train/model/original_defaults.py | 92 +- plugins/train/model/phaze_a.py | 556 +++-- plugins/train/model/phaze_a_defaults.py | 1404 ++++++----- plugins/train/model/realface.py | 73 +- plugins/train/model/realface_defaults.py | 175 +- plugins/train/model/unbalanced.py | 58 +- plugins/train/model/unbalanced_defaults.py | 192 +- plugins/train/model/villain.py | 34 +- plugins/train/model/villain_defaults.py | 89 +- plugins/train/train_config.py | 805 +++++++ plugins/train/trainer/_base.py | 939 +------- plugins/train/trainer/_display.py | 626 +++++ plugins/train/trainer/distributed.py | 221 ++ plugins/train/trainer/original.py | 93 +- plugins/train/trainer/original_defaults.py | 140 -- plugins/train/trainer/trainer_config.py | 142 ++ plugins/train/training.py | 362 +++ pyproject.toml | 43 + requirements/__init__.py | 0 requirements/_requirements_base.txt | 27 +- requirements/_requirements_dev.txt | 11 + requirements/requirements.py | 205 ++ ...con.txt => requirements_apple-silicon.txt} | 4 +- requirements/requirements_cpu.txt | 3 +- requirements/requirements_directml.txt | 4 - requirements/requirements_nvidia.txt | 7 +- requirements/requirements_nvidia_11.txt | 8 + requirements/requirements_nvidia_12.txt | 7 + requirements/requirements_nvidia_13.txt | 7 + requirements/requirements_rocm.txt | 4 +- requirements/requirements_rocm_60.txt | 4 + requirements/requirements_rocm_61.txt | 4 + requirements/requirements_rocm_62.txt | 5 + requirements/requirements_rocm_63.txt | 3 + requirements/requirements_rocm_64.txt | 3 + scripts/convert.py | 63 +- scripts/extract.py | 17 +- scripts/fsmedia.py | 5 +- scripts/gui.py | 41 +- scripts/train.py | 55 +- setup.cfg | 57 - setup.py | 2054 ++++++----------- tests/data/imgs/test_img1.jpg | Bin 0 -> 154538 bytes tests/data/imgs/test_img2.jpg | Bin 0 -> 217312 bytes tests/data/imgs/test_img3.jpg | Bin 0 -> 71388 bytes tests/data/imgs/test_img4.jpg | Bin 0 -> 173613 bytes tests/data/vid/test.mp4 | Bin 0 -> 305846 bytes tests/lib/config/__init__.py | 0 tests/lib/config/config_test.py | 276 +++ tests/lib/config/helpers.py | 51 + tests/lib/config/ini_test.py | 377 +++ tests/lib/config/objects_test.py | 411 ++++ tests/lib/gpu_stats/_base_test.py | 30 +- tests/lib/gui/stats/event_reader_test.py | 28 +- tests/lib/gui/stats/moving_average_test.py | 111 + tests/lib/model/initializers_test.py | 31 +- tests/lib/model/layers_test.py | 193 +- tests/lib/model/losses/feature_loss_test.py | 25 + tests/lib/model/losses/loss_test.py | 68 + .../lib/model/losses/perceptual_loss_test.py | 25 + tests/lib/model/losses_test.py | 64 - tests/lib/model/nn_blocks_test.py | 39 +- tests/lib/model/normalization_test.py | 37 +- tests/lib/model/optimizers_test.py | 45 +- tests/lib/sysinfo_test.py | 438 ---- tests/lib/system/__init__.py | 0 tests/lib/system/sysinfo_test.py | 258 +++ tests/lib/system/system_test.py | 256 ++ tests/lib/training/__init__.py | 0 tests/lib/training/augmentation_test.py | 524 +++++ tests/lib/training/cache_test.py | 964 ++++++++ tests/lib/training/lr_finder_test.py | 270 +++ tests/lib/training/lr_warmup_test.py | 181 ++ tests/lib/training/tensorboard_test.py | 166 ++ tests/lib/utils_test.py | 161 +- tests/plugins/__init.__.py | 0 tests/plugins/train/__init__.py | 0 tests/plugins/train/trainer/__init__.py | 0 .../plugins/train/trainer/test_distributed.py | 138 ++ tests/plugins/train/trainer/test_original.py | 122 + tests/simple_tests.py | 53 +- tests/startup_test.py | 43 +- tests/tools/alignments/media_test.py | 11 +- tests/tools/preview/viewer_test.py | 18 +- tools.py | 4 +- tools/alignments/alignments.py | 6 +- tools/alignments/cli.py | 26 +- tools/alignments/jobs.py | 80 +- tools/alignments/jobs_faces.py | 4 + tools/alignments/jobs_frames.py | 4 + tools/alignments/media.py | 8 +- tools/effmpeg/cli.py | 37 +- tools/effmpeg/effmpeg.py | 9 +- tools/manual/cli.py | 16 +- tools/manual/detected_faces.py | 5 +- tools/manual/faceviewer/frame.py | 36 +- tools/manual/faceviewer/interact.py | 4 + tools/manual/faceviewer/viewport.py | 36 +- tools/manual/frameviewer/control.py | 4 + tools/manual/frameviewer/editor/_base.py | 2 +- .../manual/frameviewer/editor/bounding_box.py | 4 + .../manual/frameviewer/editor/extract_box.py | 7 + tools/manual/frameviewer/editor/landmarks.py | 7 +- tools/manual/frameviewer/editor/mask.py | 5 + tools/manual/frameviewer/frame.py | 4 + tools/manual/globals.py | 5 +- tools/manual/manual.py | 24 +- tools/manual/thumbnails.py | 4 + tools/mask/cli.py | 12 +- tools/mask/loader.py | 31 +- tools/mask/mask.py | 8 +- tools/mask/mask_generate.py | 40 +- tools/mask/mask_import.py | 18 +- tools/mask/mask_output.py | 20 +- tools/model/cli.py | 17 +- tools/model/model.py | 70 +- tools/preview/cli.py | 12 +- tools/preview/control_panels.py | 284 ++- tools/preview/preview.py | 9 +- tools/preview/viewer.py | 8 +- tools/sort/cli.py | 20 +- tools/sort/sort.py | 5 +- tools/sort/sort_methods.py | 34 +- tools/sort/sort_methods_aligned.py | 5 +- update_deps.py | 4 + 370 files changed, 26140 insertions(+), 20266 deletions(-) delete mode 100644 .pylintrc create mode 100644 docs/full/lib/keypress.rst delete mode 100644 docs/full/lib/plaidml_utils.rst create mode 100755 docs/full/lib/queue_manager.rst delete mode 100755 docs/full/lib/sysinfo.rst create mode 100644 docs/full/lib/system.rst delete mode 100644 docs/full/tests/lib.gpu_stats.rst delete mode 100644 docs/full/tests/lib.gui.rst delete mode 100644 docs/full/tests/lib.rst delete mode 100644 docs/full/tests/tests.rst delete mode 100644 docs/full/tests/tools.alignments.rst delete mode 100644 docs/full/tests/tools.preview.rst delete mode 100644 docs/full/tests/tools.rst create mode 100644 docs/full/tools/ffmpeg.rst delete mode 100644 docs/full/tools/manual.faceviewer.rst delete mode 100644 docs/full/tools/manual.frameviewer.rst create mode 100644 docs/full/tools/mask.rst create mode 100644 docs/full/tools/model.rst delete mode 100644 lib/config.py create mode 100644 lib/config/__init__.py create mode 100644 lib/config/config.py create mode 100644 lib/config/ini.py create mode 100644 lib/config/objects.py delete mode 100644 lib/gpu_stats/directml.py delete mode 100644 lib/gpu_stats/nvidia_apple.py delete mode 100644 lib/gui/_config.py create mode 100644 lib/gui/analysis/moving_average.py create mode 100644 lib/gui/gui_config.py delete mode 100644 lib/model/session.py create mode 100644 lib/system/__init__.py create mode 100644 lib/system/ml_libs.py rename lib/{ => system}/sysinfo.py (69%) create mode 100644 lib/system/system.py create mode 100644 lib/training/tensorboard.py delete mode 100644 lib/vgg_face.py rename locales/{lib.config.pot => lib.config.objects.pot} (80%) rename locales/{plugins.extract._config.pot => plugins.extract.extract_config.pot} (76%) rename locales/{plugins.train._config.pot => plugins.train.train_config.pot} (71%) create mode 100644 locales/plugins.train.trainer.trainer_config.pot create mode 100644 locales/ru/LC_MESSAGES/lib.config.objects.mo rename locales/ru/LC_MESSAGES/{lib.config.po => lib.config.objects.po} (73%) rename locales/ru/LC_MESSAGES/{plugins.extract._config.mo => plugins.extract.extract_config.mo} (57%) rename locales/ru/LC_MESSAGES/{plugins.extract._config.po => plugins.extract.extract_config.po} (82%) rename locales/ru/LC_MESSAGES/{plugins.train._config.mo => plugins.train.train_config.mo} (55%) rename locales/ru/LC_MESSAGES/{plugins.train._config.po => plugins.train.train_config.po} (68%) delete mode 100644 plugins/convert/_config.py create mode 100644 plugins/convert/convert_config.py delete mode 100644 plugins/extract/_config.py create mode 100644 plugins/extract/extract_config.py delete mode 100644 plugins/train/_config.py create mode 100644 plugins/train/model/_base/inference.py create mode 100644 plugins/train/model/_base/state.py create mode 100644 plugins/train/model/_base/update.py create mode 100644 plugins/train/train_config.py create mode 100644 plugins/train/trainer/_display.py create mode 100644 plugins/train/trainer/distributed.py delete mode 100755 plugins/train/trainer/original_defaults.py create mode 100644 plugins/train/trainer/trainer_config.py create mode 100644 plugins/train/training.py create mode 100644 pyproject.toml create mode 100644 requirements/__init__.py create mode 100644 requirements/_requirements_dev.txt create mode 100644 requirements/requirements.py rename requirements/{requirements_apple_silicon.txt => requirements_apple-silicon.txt} (56%) delete mode 100644 requirements/requirements_directml.txt create mode 100644 requirements/requirements_nvidia_11.txt create mode 100644 requirements/requirements_nvidia_12.txt create mode 100644 requirements/requirements_nvidia_13.txt create mode 100644 requirements/requirements_rocm_60.txt create mode 100644 requirements/requirements_rocm_61.txt create mode 100644 requirements/requirements_rocm_62.txt create mode 100644 requirements/requirements_rocm_63.txt create mode 100644 requirements/requirements_rocm_64.txt delete mode 100644 setup.cfg create mode 100644 tests/data/imgs/test_img1.jpg create mode 100644 tests/data/imgs/test_img2.jpg create mode 100644 tests/data/imgs/test_img3.jpg create mode 100644 tests/data/imgs/test_img4.jpg create mode 100644 tests/data/vid/test.mp4 create mode 100644 tests/lib/config/__init__.py create mode 100644 tests/lib/config/config_test.py create mode 100644 tests/lib/config/helpers.py create mode 100644 tests/lib/config/ini_test.py create mode 100644 tests/lib/config/objects_test.py create mode 100644 tests/lib/gui/stats/moving_average_test.py create mode 100644 tests/lib/model/losses/feature_loss_test.py create mode 100644 tests/lib/model/losses/loss_test.py create mode 100644 tests/lib/model/losses/perceptual_loss_test.py delete mode 100644 tests/lib/model/losses_test.py delete mode 100644 tests/lib/sysinfo_test.py create mode 100644 tests/lib/system/__init__.py create mode 100644 tests/lib/system/sysinfo_test.py create mode 100644 tests/lib/system/system_test.py create mode 100644 tests/lib/training/__init__.py create mode 100644 tests/lib/training/augmentation_test.py create mode 100644 tests/lib/training/cache_test.py create mode 100644 tests/lib/training/lr_finder_test.py create mode 100644 tests/lib/training/lr_warmup_test.py create mode 100644 tests/lib/training/tensorboard_test.py create mode 100644 tests/plugins/__init.__.py create mode 100644 tests/plugins/train/__init__.py create mode 100644 tests/plugins/train/trainer/__init__.py create mode 100644 tests/plugins/train/trainer/test_distributed.py create mode 100644 tests/plugins/train/trainer/test_original.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 1172b91594..2b3415a366 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -9,7 +9,7 @@ on: jobs: build_conda: - name: conda (${{ matrix.os }}, ${{ matrix.backend }}) + name: conda (${{ matrix.os }}, ${{ matrix.backend }} ${{ matrix.python-version }}) runs-on: ${{ matrix.os }} defaults: run: @@ -17,39 +17,59 @@ jobs: strategy: fail-fast: false matrix: - os: ["ubuntu-latest", "macos-latest", "windows-latest"] - backend: ["nvidia", "cpu"] - include: - - os: "ubuntu-latest" - backend: "rocm" + # TODO revert. Despite documentation to the contrary, MacOS runners are always x86-64 + #os: ["ubuntu-latest", "windows-latest", "macos-latest"] + os: ["ubuntu-latest", "windows-latest"] + python-version: ["3.11", "3.12", "3.13"] + backend: ["nvidia", "cpu", "rocm", "apple-silicon"] + exclude: + # CPU + Nvidia only on Windows - os: "windows-latest" - backend: "directml" + backend: "rocm" + - os: windows-latest + backend: apple-silicon + # No apple-silicon on Linux + - os: ubuntu-latest + backend: apple-silicon + # Only Apple-Silicon on MacOS + - os: "macos-latest" + backend: "rocm" + - os: "macos-latest" + backend: "cpu" + - os: "macos-latest" + backend: "nvidia" steps: - uses: actions/checkout@v3 + - name: Cleanup space + # We run out of space on rocm. Ref: https://github.com/actions/runner-images/issues/709 + if: matrix.backend == 'rocm' + run: | + sudo rm -rf "/usr/local/share/boost" "$AGENT_TOOLSDIRECTORY" - name: Set cache date run: echo "DATE=$(date +'%Y%m%d')" >> $GITHUB_ENV - - name: Cache conda - uses: actions/cache@v3 - env: - # Increase this value to manually reset cache - CACHE_NUMBER: 1 - REQ_FILE: ./requirements/requirements_${{ matrix.backend }}.txt - with: - path: ~/conda_pkgs_dir - key: ${{ runner.os }}-${{ matrix.backend }}-conda-${{ env.CACHE_NUMBER }}-${{ env.DATE }}-${{ hashFiles('./requirements/requirements.txt', env.REQ_FILE) }} + # TODO Re-enable. Currently disabled as it does not seem to get used and takes a lot of space + #- name: Cache conda + # uses: actions/cache@v3 + # env: + # # Increase this value to manually reset cache + # CACHE_NUMBER: 1 + # REQ_FILE: ./requirements/requirements_${{ matrix.backend }}.txt + # with: + # path: ~/conda_pkgs_dir + # key: ${{ runner.os }}-${{ matrix.backend }}-conda-${{ matrix.python-version }}-${{ env.CACHE_NUMBER }}-${{ env.DATE }}-${{ hashFiles('./requirements/requirements.txt', env.REQ_FILE) }} - name: Set up Conda uses: conda-incubator/setup-miniconda@v2 with: - python-version: "3.10" + python-version: ${{ matrix.python-version }} + miniconda-version: "latest" auto-update-conda: true activate-environment: faceswap - name: Conda info run: conda info && conda list - name: Install run: | - python setup.py --installer --${{ matrix.backend }} - pip install flake8 pylint mypy pytest pytest-mock wheel pytest-xvfb - pip install types-attrs types-cryptography types-pyOpenSSL types-PyYAML types-setuptools + python setup.py --installer --dev --${{ matrix.backend }} + pip install wheel pytest-xvfb types-attrs types-cryptography types-pyOpenSSL - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names @@ -60,45 +80,44 @@ jobs: run: | mypy . - name: SysInfo - run: python -c "from lib.sysinfo import sysinfo ; print(sysinfo)" - - name: Simple Tests + run: python -m lib.system.sysinfo + - name: Unit Tests # These backends will fail as GPU drivers not available - if: matrix.backend != 'rocm' && matrix.backend != 'nvidia' && matrix.backend != 'directml' + if: matrix.backend == 'cpu' run: | - FACESWAP_BACKEND="${{ matrix.backend }}" py.test -v tests/; + KERAS_BACKEND=torch FACESWAP_BACKEND="${{ matrix.backend }}" py.test -v tests/; - name: End to End Tests # These backends will fail as GPU drivers not available - # macOS fails on first extract test with 'died with ' - if: matrix.backend != 'rocm' && matrix.backend != 'nvidia' && matrix.backend != 'directml' && matrix.os != 'macos-latest' + if: matrix.backend == 'cpu' run: | - FACESWAP_BACKEND="${{ matrix.backend }}" python tests/simple_tests.py; + KERAS_BACKEND=torch FACESWAP_BACKEND="${{ matrix.backend }}" python tests/simple_tests.py; build_linux: - name: "pip (ubuntu-latest, ${{ matrix.backend }})" + name: "pip (ubuntu-latest, ${{ matrix.backend }} ${{ matrix.python-version }})" runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: ["3.10"] + python-version: ["3.11", "3.12", "3.13"] backend: ["cpu"] include: - backend: "cpu" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: 'pip' - cache-dependency-path: './requirements/requirements_${{ matrix.backend }}.txt' + cache-dependency-path: | + './requirements/requirements_base.txt' + './requirements/requirements_${{ matrix.backend }}.txt' - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 pylint mypy pytest pytest-mock pytest-xvfb wheel - pip install types-attrs types-cryptography types-pyOpenSSL types-PyYAML types-setuptools pip install -r ./requirements/requirements_${{ matrix.backend }}.txt - - name: List installed packages - run: pip freeze + pip install -r ./requirements/_requirements_dev.txt + pip install wheel pytest-xvfb types-attrs types-cryptography types-pyOpenSSL - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names @@ -109,42 +128,45 @@ jobs: continue-on-error: true run: | mypy . - - name: Simple Tests + - name: SysInfo + run: FACESWAP_BACKEND="${{ matrix.backend }}" python -m lib.system.sysinfo + - name: Unit Tests run: | - FACESWAP_BACKEND="${{ matrix.backend }}" py.test -v tests/; + KERAS_BACKEND=torch FACESWAP_BACKEND="${{ matrix.backend }}" py.test -v tests/; - name: End to End Tests run: | - FACESWAP_BACKEND="${{ matrix.backend }}" python tests/simple_tests.py; + KERAS_BACKEND=torch FACESWAP_BACKEND="${{ matrix.backend }}" python tests/simple_tests.py; build_windows: - name: "pip (windows-latest, ${{ matrix.backend }})" + name: "pip (windows-latest, ${{ matrix.backend }} ${{ matrix.python-version }})" runs-on: windows-latest strategy: fail-fast: false matrix: - python-version: ["3.10"] - backend: ["cpu", "directml"] + python-version: ["3.11", "3.12", "3.13"] + backend: ["cpu"] include: - backend: "cpu" - - backend: "directml" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: 'pip' - cache-dependency-path: './requirements/requirements_${{ matrix.backend }}.txt' + cache-dependency-path: | + './requirements/requirements_base.txt' + './requirements/requirements_${{ matrix.backend }}.txt' - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 pylint mypy pytest pytest-mock wheel - pip install types-attrs types-cryptography types-pyOpenSSL types-PyYAML types-setuptools + pip install types-attrs types-cryptography types-pyOpenSSL wheel + pip install -r ./requirements/_requirements_dev.txt pip install -r ./requirements/requirements_${{ matrix.backend }}.txt - - name: List installed packages - run: pip freeze - - name: Set Backend EnvVar + - name: Set Faceswap Backend EnvVar run: echo "FACESWAP_BACKEND=${{ matrix.backend }}" | Out-File -FilePath $env:GITHUB_ENV -Append + - name: Set Keras Backend EnvVar + run: echo "KERAS_BACKEND=torch" | Out-File -FilePath $env:GITHUB_ENV -Append - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names @@ -155,7 +177,9 @@ jobs: continue-on-error: true run: | mypy . - - name: Simple Tests + - name: SysInfo + run: python -m lib.system.sysinfo + - name: Unit Tests run: py.test -v tests - name: End to End Tests run: python tests/simple_tests.py diff --git a/.gitignore b/.gitignore index 63f21e29a7..ba4b84e9eb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,11 +7,12 @@ !/requirements/ !/requirements/*requirements*.txt !/requirements/*conda*.yml +!/requirements/*.py # Root files !Dockerfile* -!.pylintrc -!setup.cfg +!pyproject.toml +!.gitignore !.travis.yml !/faceswap.py !/setup.py @@ -27,13 +28,20 @@ !config/ !.readthedocs.yml !docs/ -!docs/full** -!docs/_static** +!docs/_static/ +!docs/_static/*.png +!docs/full/ +!docs/full/**/ +!docs/full/**/*.rst !locales/ !locales/** + +# Test files !tests/ !tests/**/ !tests/**/*.py +!tests/**/*.mp4 +!tests/**/*.jpg # Core files !.fs_cache diff --git a/.install/linux/faceswap_setup_x64.sh b/.install/linux/faceswap_setup_x64.sh index b5f7960011..f9833ff8c8 100644 --- a/.install/linux/faceswap_setup_x64.sh +++ b/.install/linux/faceswap_setup_x64.sh @@ -1,4 +1,5 @@ #!/bin/bash +# TODO force conda-forge TMP_DIR="/tmp/faceswap_install" DL_CONDA="https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh" @@ -12,10 +13,11 @@ DIR_CONDA="$HOME/miniconda3" CONDA_EXECUTABLE="${DIR_CONDA}/bin/conda" CONDA_TO_PATH=false ENV_NAME="faceswap" -PYENV_VERSION="3.10" +PYENV_VERSION="3.13" DIR_FACESWAP="$HOME/faceswap" VERSION="nvidia" +LIB_VERSION="13" DESKTOP=false @@ -145,6 +147,40 @@ ask_version() { done } + +ask_cuda_version() { + # Ask which Cuda Version to install + while true; do + default=1 + read -rp $'\e[36mSelect:\t1: RTX 20xx ->\n\t2: GTX 9xx - GTX 10xx\n\t3: GTX 7xx - GTX 9xx\n'"[default: $default]: "$'\e[97m' vers + vers="${vers:-${default}}" + case $vers in + 1) LIB_VERSION="13" ; break ;; + 2) LIB_VERSION="12" ; break ;; + 3) LIB_VERSION="11" ; break ;; + * ) echo "Invalid selection." ;; + esac + done +} + + +ask_rocm_version() { + # Ask which Cuda Version to install + while true; do + default=1 + read -rp $'\e[36mSelect:\t1: ROCm 6.4\n\t2: ROCm 6.3\n\t3: ROCm 6.2\n\t4: ROCm 6.1\n\t5: ROCm 6.0\n'"[default: $default]: "$'\e[97m' vers + vers="${vers:-${default}}" + case $vers in + 1) LIB_VERSION="64" ; break ;; + 2) LIB_VERSION="63" ; break ;; + 3) LIB_VERSION="62" ; break ;; + 4) LIB_VERSION="61" ; break ;; + 5) LIB_VERSION="60" ; break ;; + * ) echo "Invalid selection." ;; + esac + done +} + banner () { echo -e " \e[32m 001" echo -e " \e[32m 11 10 010" @@ -280,7 +316,15 @@ faceswap_opts () { latest graphics card drivers installed from the relevant vendor. Please select the version\ of Faceswap you wish to install." ask_version + if [ $VERSION == "nvidia" ] ; then + info "Depending on your GPU a different version of Cuda may be required. Please select the \ + generation of Nvidia GPU you use below." + ask_cuda_version + fi if [ $VERSION == "rocm" ] ; then + info "Depending on your installed version of ROCm a different version of PyTorch may be required. \ + Please select the ROCm version you use below." + ask_rocm_version warn "ROCm support is experimental. Please make sure that your GPU is supported by ROCm and that \ ROCm has been installed on your system before proceeding. Installation instructions: \ https://docs.amd.com/bundle/ROCm_Installation_Guidev5.0/page/Overview_of_ROCm_Installation_Methods.html" @@ -322,7 +366,11 @@ review() { fi echo " - Faceswap will be installed in '$DIR_FACESWAP'" echo " - Installing for '$VERSION'" + if [ $VERSION == "nvidia" ] ; then + echo " - Cuda version $LIB_VERSION will be used" + fi if [ $VERSION == "rocm" ] ; then + echo " - ROCm version '$LIB_VERSION' will be used" echo -e " \e[33m- Note: Please ensure that ROCm is supported by your GPU\e[97m" echo -e " \e[33m and is installed prior to proceeding.\e[97m" fi @@ -364,10 +412,10 @@ delete_env() { } create_env() { - # Create Python 3.10 env for faceswap + # Create Python 3.13 env for faceswap delete_env info "Creating Conda Virtual Environment..." - yellow ; "$CONDA_EXECUTABLE" create -n "$ENV_NAME" -c defaults -q python="$PYENV_VERSION" -y + yellow ; "$CONDA_EXECUTABLE" create -n "$ENV_NAME" -c conda-forge -q python="$PYENV_VERSION" -y } @@ -404,7 +452,7 @@ clone_faceswap() { setup_faceswap() { # Run faceswap setup script info "Setting up Faceswap..." - python -u "$DIR_FACESWAP/setup.py" --installer --$VERSION + python -u "$DIR_FACESWAP/setup.py" --installer --$VERSION$LIB_VERSION } create_gui_launcher () { diff --git a/.install/macos/faceswap_setup_macos.sh b/.install/macos/faceswap_setup_macos.sh index 1c67239eae..443c9bf127 100644 --- a/.install/macos/faceswap_setup_macos.sh +++ b/.install/macos/faceswap_setup_macos.sh @@ -15,7 +15,7 @@ DIR_CONDA="$HOME/miniconda3" CONDA_EXECUTABLE="${DIR_CONDA}/bin/conda" CONDA_TO_PATH=false ENV_NAME="faceswap" -PYENV_VERSION="3.10" +PYENV_VERSION="3.13" DIR_FACESWAP="$HOME/faceswap" VERSION="nvidia" @@ -405,10 +405,10 @@ delete_env() { } create_env() { - # Create Python 3.10 env for faceswap + # Create Python 3.13 env for faceswap delete_env info "Creating Conda Virtual Environment..." - yellow ; "$CONDA_EXECUTABLE" create -n "$ENV_NAME" -c defaults -q python="$PYENV_VERSION" -y + yellow ; "$CONDA_EXECUTABLE" create -n "$ENV_NAME" -c conda-forge -q python="$PYENV_VERSION" -y } diff --git a/.install/windows/install.nsi b/.install/windows/install.nsi index e6be716db1..0e60506beb 100644 --- a/.install/windows/install.nsi +++ b/.install/windows/install.nsi @@ -1,3 +1,5 @@ +# TODO: Install visualstudio build tools for fastcluster +# TODO: Check if we still get realtime output with Subprocess in setup.py !include MUI2.nsh !include nsDialogs.nsh !include winmessages.nsh @@ -22,7 +24,7 @@ InstallDir $PROFILE\faceswap # Install cli flags !define flagsConda "/S /RegisterPython=0 /AddToPath=0 /D=$PROFILE\MiniConda3" !define flagsRepo "--depth 1 --no-single-branch ${wwwRepo}" -!define flagsEnv "-y python=3.10" +!define flagsEnv "-y python=3.13" # Folders Var ProgramData @@ -118,7 +120,7 @@ Function pgPrereqCreate StrCpy $lblPos 14 # Info Installing applications - ${NSD_CreateGroupBox} 5% 5% 90% 35% "The following applications will be installed" + ${NSD_CreateGroupBox} 1% 1% 98% 30% "The following applications will be installed" Pop $0 ${If} $InstallConda == 1 @@ -129,43 +131,47 @@ Function pgPrereqCreate ${NSD_CreateLabel} 10% $lblPos% 80% 14u "Faceswap" Pop $0 - StrCpy $lblPos 46 + intOp $lblPos $lblPos + 15 # Info Custom Options - ${NSD_CreateGroupBox} 5% 40% 90% 60% "Custom Items" + ${NSD_CreateGroupBox} 1% 31% 98% 65% "GPU and Location" Pop $0 - ${NSD_CreateRadioButton} 10% $lblPos% 27% 11u "Setup for NVIDIA GPU" + ${NSD_CreateRadioButton} 4% $lblPos% 27% 20u "NVIDIA RTX 20xx +" Pop $ctlRadio ${NSD_AddStyle} $ctlRadio ${WS_GROUP} - nsDialogs::SetUserData $ctlRadio "nvidia" + nsDialogs::SetUserData $ctlRadio "nvidia13" ${NSD_OnClick} $ctlRadio RadioClick - ${NSD_CreateRadioButton} 40% $lblPos% 25% 11u "Setup for DirectML" + ${NSD_CreateRadioButton} 32% $lblPos% 25% 20u "Nvidia GTX 9xx - GTX 10xx" Pop $ctlRadio - nsDialogs::SetUserData $ctlRadio "directml" + nsDialogs::SetUserData $ctlRadio "nvidia12" ${NSD_OnClick} $ctlRadio RadioClick - ${NSD_CreateRadioButton} 70% $lblPos% 20% 11u "Setup for CPU" + ${NSD_CreateRadioButton} 60% $lblPos% 25% 20u "Nvidia GTX 7xx - GTX 8xx" + Pop $ctlRadio + nsDialogs::SetUserData $ctlRadio "nvidia11" + ${NSD_OnClick} $ctlRadio RadioClick + ${NSD_CreateRadioButton} 88% $lblPos% 25% 20u "CPU" Pop $ctlRadio nsDialogs::SetUserData $ctlRadio "cpu" ${NSD_OnClick} $ctlRadio RadioClick - intOp $lblPos $lblPos + 10 + intOp $lblPos $lblPos + 18 - ${NSD_CreateLabel} 10% $lblPos% 80% 10u "Environment Name (NB: Existing envs with this name will be deleted):" + ${NSD_CreateLabel} 4% $lblPos% 90% 10u "Environment Name (NB: Existing envs with this name will be deleted):" pop $0 intOp $lblPos $lblPos + 7 - ${NSD_CreateText} 10% $lblPos% 80% 11u "$envName" + ${NSD_CreateText} 4% $lblPos% 90% 11u "$envName" Pop $envName intOp $lblPos $lblPos + 11 ${If} $InstallConda == 1 - ${NSD_CreateLabel} 10% $lblPos% 80% 18u "Conda is required but could not be detected. If you have Conda already installed specify the location below, otherwise leave blank:" + ${NSD_CreateLabel} 4% $lblPos% 90% 18u "Conda is required but could not be detected. If you have Conda already installed specify the location below, otherwise leave blank:" Pop $0 intOp $lblPos $lblPos + 13 - ${NSD_CreateText} 10% $lblPos% 73% 12u "" + ${NSD_CreateText} 4% $lblPos% 73% 12u "" Pop $ctlCondaText - ${NSD_CreateButton} 83% $lblPos% 7% 12u "..." + ${NSD_CreateButton} 77% $lblPos% 13% 12u "..." Pop $ctlCondaButton ${NSD_OnClick} $ctlCondaButton fnc_hCtl_test_DirRequest1_Click ${EndIf} @@ -200,7 +206,7 @@ FunctionEnd Function CheckSetupType ${If} $setupType == "" - MessageBox MB_OK "Please specify whether to setup for Nvidia, DirectML or CPU." + MessageBox MB_OK "Please specify whether to setup for Nvidia or CPU." Abort ${EndIf} StrCpy $Log "$log(check) Setting up for: $setupType$\n" @@ -400,7 +406,7 @@ Function SetEnvironment CreateEnv: SetDetailsPrint listonly StrCpy $0 "${flagsEnv}" - ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda create $0 -c defaults -n $\"$envName$\" && conda deactivate" + ExecDos::exec /NOUNLOAD /ASYNC /DETAILED "$\"$dirConda\scripts\activate.bat$\" && conda create $0 -c conda-forge -n $\"$envName$\" && conda deactivate" pop $0 ExecDos::wait $0 pop $0 @@ -467,4 +473,4 @@ FunctionEnd Function DesktopShortcut DetailPrint "Creating Desktop Shortcut" CreateShortCut "$DESKTOP\FaceSwap.lnk" "$\"$INSTDIR\$0$\"" "" "$INSTDIR\.install\windows\fs_logo.ico" -FunctionEnd \ No newline at end of file +FunctionEnd diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index ee20f62b22..0000000000 --- a/.pylintrc +++ /dev/null @@ -1,488 +0,0 @@ -[MASTER] - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code. -extension-pkg-whitelist=cv2 - -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=CVS - -# Add files or directories matching the regex patterns to the blacklist. The -# regex matches against base names, not paths. -ignore-patterns= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the -# number of processors available to use. -jobs=0 - -# Control the amount of potential inferred values when inferring a single -# object. This can help the performance when dealing with large functions or -# complex, nested conditions. -limit-inference-results=100 - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -load-plugins= - -# Pickle collected data for later comparisons. -persistent=yes - -# Specify a configuration file. -#rcfile= - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. -confidence= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once). You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use "--disable=all --enable=classes -# --disable=W". -disable=raw-checker-failed, - bad-inline-option, - locally-disabled, - file-ignored, - suppressed-message, - useless-suppression, - deprecated-pragma, - use-symbolic-message-instead - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable=c-extension-no-member - - -[REPORTS] - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details. -#msg-template= - -# Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio). You can also give a reporter class, e.g. -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Tells whether to display a full report or only the messages. -reports=no - -# Activate the evaluation score. -score=yes - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - -# Complete name of functions that never returns. When checking for -# inconsistent-return-statements if a never returning function is called then -# it will be considered as an explicit return statement and no message will be -# printed. -never-returning-functions=sys.exit - - -[BASIC] - -# Naming style matching correct argument names. -argument-naming-style=snake_case - -# Regular expression matching correct argument names. Overrides argument- -# naming-style. -#argument-rgx= - -# Naming style matching correct attribute names. -attr-naming-style=snake_case - -# Regular expression matching correct attribute names. Overrides attr-naming- -# style. -#attr-rgx= - -# Bad variable names which should always be refused, separated by a comma. -bad-names=foo, - bar, - baz, - toto, - tutu, - tata - -# Naming style matching correct class attribute names. -class-attribute-naming-style=any - -# Regular expression matching correct class attribute names. Overrides class- -# attribute-naming-style. -#class-attribute-rgx= - -# Naming style matching correct class names. -class-naming-style=PascalCase - -# Regular expression matching correct class names. Overrides class-naming- -# style. -#class-rgx= - -# Naming style matching correct constant names. -const-naming-style=UPPER_CASE - -# Regular expression matching correct constant names. Overrides const-naming- -# style. -#const-rgx= - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - -# Naming style matching correct function names. -function-naming-style=snake_case - -# Regular expression matching correct function names. Overrides function- -# naming-style. -#function-rgx= - -# Good variable names which should always be accepted, separated by a comma. -good-names=i, - j, - k, - ex, - Run, - _ - -# Include a hint for the correct naming format with invalid-name. -include-naming-hint=no - -# Naming style matching correct inline iteration names. -inlinevar-naming-style=any - -# Regular expression matching correct inline iteration names. Overrides -# inlinevar-naming-style. -#inlinevar-rgx= - -# Naming style matching correct method names. -method-naming-style=snake_case - -# Regular expression matching correct method names. Overrides method-naming- -# style. -#method-rgx= - -# Naming style matching correct module names. -module-naming-style=snake_case - -# Regular expression matching correct module names. Overrides module-naming- -# style. -#module-rgx= - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -# These decorators are taken in consideration only for invalid-name. -property-classes=abc.abstractproperty - -# Naming style matching correct variable names. -variable-naming-style=snake_case - -# Regular expression matching correct variable names. Overrides variable- -# naming-style. -#variable-rgx= - - -[LOGGING] - -# Format style used to check logging format string. `old` means using % -# formatting, while `new` is for `{}` formatting. -logging-format-style=old - -# Logging modules to check that the string format arguments are in logging -# function parameter format. -logging-modules=logging - - -[SIMILARITIES] - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=no - -# Minimum lines number of a similarity. -min-similarity-lines=4 - - -[SPELLING] - -# Limits count of emitted suggestions for spelling mistakes. -max-spelling-suggestions=4 - -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package.. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=100 - -# Maximum number of lines in a module. -max-module-lines=1000 - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[STRING] - -# This flag controls whether the implicit-str-concat-in-sequence should -# generate a warning on implicit string concatenation in sequences defined over -# several lines. -check-str-concat-over-line-jumps=no - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid defining new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_, - _cb - -# A regular expression matching the name of dummy variables (i.e. expected to -# not be used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore. -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members=cv2.* - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# Tells whether to warn about missing members when the owner of the attribute -# is inferred to be None. -ignore-none=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules= - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME, - XXX, - TODO - - -[DESIGN] - -# Maximum number of arguments for function / method. -max-args=10 - -# Maximum number of attributes for a class (see R0902). -max-attributes=12 - -# Maximum number of boolean expressions in an if statement. -max-bool-expr=5 - -# Maximum number of branch for function / method body. -max-branches=12 - -# Maximum number of locals for function / method body. -max-locals=15 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body. -max-returns=6 - -# Maximum number of statements in function / method body. -max-statements=50 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=1 - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__, - __new__, - setUp - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict, - _fields, - _replace, - _source, - _make - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=cls - - -[IMPORTS] - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Deprecated modules which should not be used, separated by a comma. -deprecated-modules=optparse,tkinter.tix - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled). -ext-import-graph= - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled). -import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled). -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "BaseException, Exception". -overgeneral-exceptions=builtins.BaseException, - builtins.Exception diff --git a/.readthedocs.yml b/.readthedocs.yml index 8d199514eb..1b36b2bbec 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -7,9 +7,11 @@ version: 2 # Set the version of Python and other tools you might need build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: "3.10" + python: "3.13" + apt_packages: + - graphviz # Build documentation in the docs/ directory with Sphinx sphinx: diff --git a/INSTALL.md b/INSTALL.md index 752c272929..63ba045f91 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -56,9 +56,8 @@ The type of computations that the process does are well suited for graphics card - Laptop CPUs can often run the software, but will not be fast enough to train at reasonable speeds - **A powerful GPU** - Currently, Nvidia GPUs are fully supported - - DirectX 12 AMD GPUs are supported on Windows through DirectML. - More modern AMD GPUs are supported on Linux through ROCm. - - M-series Macs are supported through Tensorflow-Metal + - M-series Macs are supported using Metal - If using an Nvidia GPU, then it needs to support at least CUDA Compute Capability 3.5. (Release 1.0 will work on Compute Capability 3.0) To see which version your GPU supports, consult this list: https://developer.nvidia.com/cuda-gpus Desktop cards later than the 7xx series are most likely supported. @@ -67,14 +66,13 @@ The type of computations that the process does are well suited for graphics card ## Supported operating systems - **Windows 10/11** Windows 7 and 8 might work for Nvidia. Your mileage may vary. - DirectML support is only available in Windows 10 onwards. Windows has an installer which will set up everything you need. See: https://github.com/deepfakes/faceswap/releases - **Linux** Most Ubuntu/Debian or CentOS based Linux distributions will work. There is a Linux install script that will install and set up everything you need. See: https://github.com/deepfakes/faceswap/releases - **macOS** Experimental support for GPU-accelerated, native Apple Silicon processing (e.g. Apple M1 chips). Installation instructions can be found [further down this page](#macos-apple-silicon-install-guide). Intel based macOS systems should work, but you will need to follow the [Manual Install](#manual-install) instructions. -- All operating systems must be 64-bit for Tensorflow to run. +- All operating systems must be 64-bit. Alternatively, there is a docker image that is based on Debian. @@ -112,7 +110,7 @@ Reboot your PC, so that everything you have just installed gets registered. - Select "Create" at the bottom - In the pop up: - Give it the name: faceswap - - **IMPORTANT**: Select python version 3.10 + - **IMPORTANT**: Select python version 3.13 - Hit "Create" (NB: This may take a while as it will need to download Python) ![Anaconda virtual env setup](https://i.imgur.com/CLIDDfa.png) @@ -134,11 +132,29 @@ To enter the virtual environment: #### Manual install Do not follow these steps if the Easy Install above completed succesfully. -If you are using an Nvidia card make sure you have the correct versions of Cuda/cuDNN installed for the required version of Tensorflow +If you are using an Nvidia card make sure you have the correct versions of Cuda/cuDNN installed for the required version of Torch - Install tkinter (required for the GUI) by typing: `conda install tk` - Install requirements: - - For Nvidia GPU users: `pip install -r ./requirements/requirements_nvidia.txt` - - For CPU users: `pip install -r ./requirements/requirements_cpu.txt` + - For **Nvidia** GPU users: + - RTX20xx GPUS onwards: `pip install -r ./requirements/requirements_nvidia_13.txt` + - GTX9xx - GTX10xx GPUs: `pip install -r ./requirements/requirements_nvidia_12.txt` + - GTX7xx - GTX8xx GPUs: `pip install -r ./requirements/requirements_nvidia_11.txt` + - **Note:** Maximum supported Python version for GTX8xx - GTX9xx GPUs is `3.13` + + - For **AMD** GPU users (Linux only): + - **Note** You must install a version of ROCm to your system that is compatible with your OS and GPU. + - ROCm 6.4: `pip install -r ./requirements/requirements_rocm64.txt` + - ROCm 6.3: `pip install -r ./requirements/requirements_rocm63.txt` + - ROCm 6.2: `pip install -r ./requirements/requirements_rocm62.txt` + - **Note:** Maximum supported Python version for ROCm 6.2 is `3.13` + - ROCm 6.1: `pip install -r ./requirements/requirements_rocm61.txt` + - **Note:** Maximum supported Python version for ROCm 6.1 is `3.13` + - ROCm 6.0: `pip install -r ./requirements/requirements_rocm60.txt` + - **Note:** Maximum supported Python version for ROCm 6.0 is `3.12` + + - For **CPU** users: `pip install -r ./requirements/requirements_cpu.txt` + + - For **Apple-Silicon (M Series)** users: `pip install -r ./requirements/requirements_apple-silicon.txt` ## Running faceswap - If you are not already in your virtual environment follow [these steps](#entering-your-virtual-environment) @@ -194,7 +210,7 @@ $ source ~/miniforge3/bin/activate ## Setup ### Create and Activate the Environment ```sh -$ conda create --name faceswap python=3.10 +$ conda create --name faceswap python=3.13 $ conda activate faceswap ``` @@ -224,7 +240,7 @@ Obtain git for your distribution from the [git website](https://git-scm.com/down The recommended install method is to use a Conda3 Environment as this will handle the installation of Nvidia's CUDA and cuDNN straight into your Conda Environment. This is by far the easiest and most reliable way to setup the project. - MiniConda3 is recommended: [MiniConda3](https://docs.conda.io/en/latest/miniconda.html) -Alternatively you can install Python (3.10 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Tensorflow yourself, make sure you install the correct Cuda and cuDNN package for the currently installed version of Tensorflow (Current release: Tensorflow 2.9. Release v1.0: Tensorflow 1.15). You can check for the compatible versions here: (https://www.tensorflow.org/install/source#gpu). +Alternatively you can install Python (3.14 64-bit) for your distribution (links below.) If you go down this route and are using an Nvidia GPU you should install CUDA (https://developer.nvidia.com/cuda-zone) and cuDNN (https://developer.nvidia.com/cudnn). for your system. If you do not plan to build Torch yourself, make sure you install the correct Cuda and cuDNN package for the currently installed version of Torch. - Python distributions: - apt/yum install python3 (Linux) - [Installer](https://www.python.org/downloads/release/python-368/) (Windows) diff --git a/README.md b/README.md index 1360ae18d0..fd7941cb19 100755 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ We are very troubled by the fact that FaceSwap can be used for unethical and dis # How To setup and run the project FaceSwap is a Python program that will run on multiple Operating Systems including Windows, Linux, and MacOS. -See [INSTALL.md](INSTALL.md) for full installation instructions. You will need a modern GPU with CUDA support for best performance. Many AMD GPUs are supported through DirectML (Windows) and ROCm (Linux). +See [INSTALL.md](INSTALL.md) for full installation instructions. You will need a modern GPU with CUDA support for best performance. Many AMD GPUs are supported through ROCm (Linux). # Overview The project has multiple entry points. You will have to: diff --git a/docs/conf.py b/docs/conf.py index f547655839..0e46e443ca 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -9,28 +9,37 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -# + +# NOTE: To generate docs: +# $ cd docs +# $ rm -rf _build api +# $ python -m sphinx -T -b html -d _build/doctrees -D language=en . _build/output/html + +# pylint:skip-file +import logging import os import sys from unittest import mock -os.environ["FACESWAP_BACKEND"] = "nvidia" +os.environ["FACESWAP_BACKEND"] = "cpu" +os.environ["KERAS_BACKEND"] = "torch" + sys.path.insert(0, os.path.abspath('../')) sys.setrecursionlimit(1500) -MOCK_MODULES = ["pynvx", "ctypes.windll", "comtypes"] +MOCK_MODULES = ["pynvml", "ctypes.windll", "comtypes"] for mod_name in MOCK_MODULES: sys.modules[mod_name] = mock.Mock() # -- Project information ----------------------------------------------------- project = 'faceswap' -copyright = '2022, faceswap.dev' +copyright = '2025, faceswap.dev' author = 'faceswap.dev' # The full version, including alpha/beta/rc tags -release = '0.99' +release = '3.0' # -- General configuration --------------------------------------------------- @@ -38,8 +47,10 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ['sphinx.ext.napoleon', "sphinx.ext.autosummary", ] +extensions = ['sphinx.ext.napoleon', "sphinx_automodapi.automodapi"] napoleon_custom_sections = ['License'] +numpydoc_show_class_members = False +automodsumm_inherited_members = True # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -72,4 +83,46 @@ master_doc = 'index' -autosummary_generate = True +# Suppress warnings from all 3rd party libraries +_suppressed_warning_count = 0 + + +def _suppress_third_party_warnings(): + """ Override Sphinx logging to ignore any warnings generated by 3rd party libraries """ + skip = ["lib/python", "site-packages", # system packages/python lib + ".variables", ".non_trainable_variables"] # keras layer inheritance + root = logging.getLogger("sphinx") + for handler in root.handlers: + orig_emit = handler.emit + + def make_filtered_emit(orig_emit): + + def filtered_emit(record): + if record.levelname in ("WARNING", "ERROR"): + try: + msg = record.getMessage() + except TypeError: + orig_emit(record) + return + loc = getattr(record, "location", "") + if any(x in msg or x in str(loc) for x in skip): + global _suppressed_warning_count + _suppressed_warning_count += 1 + return + orig_emit(record) + return filtered_emit + handler.emit = make_filtered_emit(orig_emit) + + +def _on_build_finish(app, exception): + """ Subtract our suppressed warnings from the total warnings count """ + if hasattr(app, "_warncount") and _suppressed_warning_count: + setattr(app, "_warncount", max(0, + getattr(app, + "_warncount", 0) - _suppressed_warning_count)) + + +def setup(app): + """ Install our warnings filter and capture suppressed counts """ + _suppress_third_party_warnings() + app.connect("build-finished", _on_build_finish) diff --git a/docs/full/lib/align.rst b/docs/full/lib/align.rst index feebc7af4a..1b1a5d4479 100644 --- a/docs/full/lib/align.rst +++ b/docs/full/lib/align.rst @@ -1,134 +1,45 @@ -************* -align package -************* +***************** +lib.align package +***************** The align Package handles detected faces, their alignments and masks. .. contents:: Contents :local: - - -aligned\_face module -==================== - -Handles aligned faces and corresponding pose estimates - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~lib.align.aligned_face.AlignedFace - ~lib.align.aligned_face.get_matrix_scaling - ~lib.align.aligned_face.transform_image - -.. rubric:: Module - -.. automodule:: lib.align.aligned_face - :members: - :undoc-members: - :show-inheritance: - - -aligned\_mask module -==================== - -Handles aligned storage and retrieval of Faceswap generated masks - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~lib.align.aligned_mask.BlurMask - ~lib.align.aligned_mask.LandmarksMask - ~lib.align.aligned_mask.Mask - -.. rubric:: Module - -.. automodule:: lib.align.aligned_mask - :members: - :undoc-members: - :show-inheritance: - - -alignments module -================= - -Handles alignments stored in a serialized alignments.fsa file - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~lib.align.alignments.Alignments - ~lib.align.alignments.Thumbnails - -.. rubric:: Module - -.. automodule:: lib.align.alignments - :members: - :undoc-members: - :show-inheritance: - - -constants module -================ -Holds various constants for use in generating and manipulating aligned face images - -.. automodule:: lib.align.constants - :members: - :undoc-members: - :show-inheritance: - - -detected\_face module -===================== - -Handles detected face objects and their associated masks. - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~lib.align.detected_face.DetectedFace - ~lib.align.detected_face.update_legacy_png_header - -.. rubric:: Module - -.. automodule:: lib.align.detected_face - :members: - :undoc-members: - :show-inheritance: - - -pose module -=========== -Handles pose estimates based on aligned face data - -.. automodule:: lib.align.pose - :members: - :undoc-members: - :show-inheritance: - - -thumbnails module -================= -Handles creation of jpg thumbnails for storage in alignment files/png headers - -.. automodule:: lib.align.thumbnails - :members: - :undoc-members: - :show-inheritance: - - -updater module -============== -Handles the update of alignments files to the latest version - -.. automodule:: lib.align.updater - :members: - :undoc-members: - :show-inheritance: + :depth: 2 + +.. automodapi:: lib.align.aligned_face + :include-all-objects: + :no-inheritance-diagram: + +| +.. automodapi:: lib.align.aligned_mask + :include-all-objects: + +| +.. automodapi:: lib.align.alignments + :include-all-objects: + :no-inheritance-diagram: + +| +.. automodapi:: lib.align.constants + :include-all-objects: + +| +.. automodapi:: lib.align.detected_face + :include-all-objects: + :no-inheritance-diagram: + +| +.. automodapi:: lib.align.pose + :include-all-objects: + :no-inheritance-diagram: + +| +.. automodapi:: lib.align.thumbnails + :include-all-objects: + :no-inheritance-diagram: + +| +.. automodapi:: lib.align.updater + :include-all-objects: diff --git a/docs/full/lib/cli.rst b/docs/full/lib/cli.rst index 2a0d2c79a6..4ffca57193 100644 --- a/docs/full/lib/cli.rst +++ b/docs/full/lib/cli.rst @@ -1,65 +1,25 @@ -*********** -cli package -*********** +*************** +lib.cli package +*************** The CLI Package handles the Command Line Arguments that act as the entry point into Faceswap. .. contents:: Contents :local: + :depth: 2 -args module -=========== +.. automodapi:: lib.cli.actions + :include-all-objects: -.. rubric:: Module Summary +.. automodapi:: lib.cli.args_extract_convert + :include-all-objects: -.. autosummary:: - :nosignatures: - - ~lib.cli.args.ConvertArgs - ~lib.cli.args.ExtractArgs - ~lib.cli.args.ExtractConvertArgs - ~lib.cli.args.FaceSwapArgs - ~lib.cli.args.FullHelpArgumentParser - ~lib.cli.args.GuiArgs - ~lib.cli.args.SmartFormatter - ~lib.cli.args.TrainArgs +.. automodapi:: lib.cli.args_train + :include-all-objects: -.. rubric:: Module +.. automodapi:: lib.cli.args + :include-all-objects: -.. automodule:: lib.cli.args - :members: - :undoc-members: - :show-inheritance: - -actions module -============== - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~lib.cli.actions.ContextFullPaths - ~lib.cli.actions.DirFullPaths - ~lib.cli.actions.DirOrFileFullPaths - ~lib.cli.actions.FileFullPaths - ~lib.cli.actions.FilesFullPaths - ~lib.cli.actions.MultiOption - ~lib.cli.actions.Radio - ~lib.cli.actions.SaveFileFullPaths - ~lib.cli.actions.Slider - -.. rubric:: Module - -.. automodule:: lib.cli.actions - :members: - :undoc-members: - :show-inheritance: - -launcher module -=============== - -.. automodule:: lib.cli.launcher - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file +.. automodapi:: lib.cli.launcher + :include-all-objects: + :no-inheritance-diagram: diff --git a/docs/full/lib/config.rst b/docs/full/lib/config.rst index dcd5dcb8b8..36aacfb202 100755 --- a/docs/full/lib/config.rst +++ b/docs/full/lib/config.rst @@ -1,7 +1,23 @@ -config module -============= +****************** +lib.config package +****************** -.. automodule:: lib.config - :members: - :undoc-members: - :show-inheritance: +Holds, validates and handles faceswap configuration items, ensuring type correctness. Handles +interfacing with saved config .ini files + +.. contents:: Contents + :local: + :depth: 2 + +.. automodapi:: lib.config.config + :include-all-objects: + :no-inheritance-diagram: + +| +.. automodapi:: lib.config.ini + :include-all-objects: + :no-inheritance-diagram: + +| +.. automodapi:: lib.config.objects + :include-all-objects: diff --git a/docs/full/lib/convert.rst b/docs/full/lib/convert.rst index ca6add4efb..b01c3adc82 100755 --- a/docs/full/lib/convert.rst +++ b/docs/full/lib/convert.rst @@ -1,7 +1,3 @@ -convert module -============== - -.. automodule:: lib.convert - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: lib.convert + :include-all-objects: + :no-inheritance-diagram: diff --git a/docs/full/lib/git.rst b/docs/full/lib/git.rst index 3f8d8de585..55ccdc06b1 100644 --- a/docs/full/lib/git.rst +++ b/docs/full/lib/git.rst @@ -1,10 +1,3 @@ -********** -git module -********** - -Handles interfacing with the git executable - -.. automodule:: lib.git - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: lib.git + :include-all-objects: + :no-inheritance-diagram: \ No newline at end of file diff --git a/docs/full/lib/gpu_stats.rst b/docs/full/lib/gpu_stats.rst index 9ba5ce3c97..a895857be2 100755 --- a/docs/full/lib/gpu_stats.rst +++ b/docs/full/lib/gpu_stats.rst @@ -1,71 +1,23 @@ -gpu\_stats package -================== +********************** +lib.gpu\_stats package +********************** The GPU Stats Package handles collection of information from connected GPUs .. contents:: Contents :local: + :depth: 2 -gpu_stats._base module ----------------------- +.. automodapi:: lib.gpu_stats.apple_silicon + :include-all-objects: -.. automodule:: lib.gpu_stats._base - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: lib.gpu_stats.cpu + :include-all-objects: -gpu_stats.apple_silicon module ------------------------------- +| +.. automodapi:: lib.gpu_stats.nvidia + :include-all-objects: -.. automodule:: lib.gpu_stats.apple_silicon - :members: - :undoc-members: - :show-inheritance: - -gpu_stats.amd module --------------------- - -.. automodule:: lib.gpu_stats.amd - :members: - :undoc-members: - :show-inheritance: - -gpu_stats.cpu module --------------------- - -.. automodule:: lib.gpu_stats.cpu - :members: - :undoc-members: - :show-inheritance: - -gpu_stats.directml module -------------------------- - -.. automodule:: lib.gpu_stats.directml - :members: - :undoc-members: - :show-inheritance: - -gpu_stats.nvidia_apple module ------------------------------ - -.. automodule:: lib.gpu_stats.nvidia_apple - :members: - :undoc-members: - :show-inheritance: - -gpu_stats.nvidia module ------------------------ - -.. automodule:: lib.gpu_stats.nvidia - :members: - :undoc-members: - :show-inheritance: - -gpu_stats.rocm module ----------------------- - -.. automodule:: lib.gpu_stats.rocm - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: lib.gpu_stats.rocm + :include-all-objects: diff --git a/docs/full/lib/gui.rst b/docs/full/lib/gui.rst index 07981633a4..dd4f1f050e 100755 --- a/docs/full/lib/gui.rst +++ b/docs/full/lib/gui.rst @@ -1,221 +1,120 @@ -*********** -gui package -*********** +*************** +lib.gui package +*************** The GUI Package contains the entire code base for Faceswap's optional GUI. The GUI itself is largely self-generated from the command line options specified in :mod:`lib.cli.args`. .. contents:: Contents :local: + :depth: 2 analysis package ================ +.. automodapi:: lib.gui.analysis.event_reader + :include-all-objects: + :no-inheritance-diagram: -stats module -============ - -.. rubric:: Package Summary - -.. autosummary:: - :nosignatures: - - ~lib.gui.analysis.stats.Calculations - ~lib.gui.analysis.stats.GlobalSession - ~lib.gui.analysis.stats.SessionsSummary - ~lib.gui.analysis.event_reader.TensorBoardLogs - -.. rubric:: stats Module - -.. automodule:: lib.gui.analysis.stats - :members: - :undoc-members: - :show-inheritance: - -.. rubric:: event_reader Module - -.. automodule:: lib.gui.analysis.event_reader - :members: - :undoc-members: - :show-inheritance: - - -custom\_widgets module -====================== - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~lib.gui.custom_widgets.ConsoleOut - ~lib.gui.custom_widgets.ContextMenu - ~lib.gui.custom_widgets.MultiOption - ~lib.gui.custom_widgets.RightClickMenu - ~lib.gui.custom_widgets.StatusBar - ~lib.gui.custom_widgets.Tooltip - -.. rubric:: Module - -.. automodule:: lib.gui.custom_widgets - :members: - :undoc-members: - :show-inheritance: - -display module -============== -.. automodule:: lib.gui.display - :members: - :undoc-members: - :show-inheritance: - - -display\_analysis module -======================== - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: +| +.. automodapi:: lib.gui.analysis.stats + :include-all-objects: + :no-inheritance-diagram: - ~lib.gui.display_analysis.Analysis - ~lib.gui.display_analysis.StatsData +| +.. automodapi:: lib.gui.analysis.moving_average + :include-all-objects: + :no-inheritance-diagram: -.. rubric:: Module +utils package +============= -.. automodule:: lib.gui.display_analysis - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: lib.gui.utils.config + :include-all-objects: + :no-inheritance-diagram: -display\_command module -======================= +| +.. automodapi:: lib.gui.utils.file_handler + :include-all-objects: + :no-inheritance-diagram: -.. automodule:: lib.gui.display_command - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: lib.gui.utils.image + :include-all-objects: + :no-inheritance-diagram: -display\_graph module -===================== +| +.. automodapi:: lib.gui.utils.misc + :include-all-objects: -.. automodule:: lib.gui.display_graph - :members: - :undoc-members: - :show-inheritance: -menu module +gui package =========== -.. automodule:: lib.gui.menu - :members: - :undoc-members: - :show-inheritance: - -options module -============== -.. automodule:: lib.gui.options - :members: - :undoc-members: - :show-inheritance: - -popup_configure module -====================== -.. automodule:: lib.gui.popup_configure - :members: - :undoc-members: - :show-inheritance: - -popup_session module -====================== -.. automodule:: lib.gui.popup_session - :members: - :undoc-members: - :show-inheritance: - -project module -============== - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~lib.gui.project.LastSession - ~lib.gui.project.Project - ~lib.gui.project.Tasks - -.. rubric:: Module - -.. automodule:: lib.gui.project - :members: - :undoc-members: - :show-inheritance: - -theme module -============ - -.. rubric:: Module - -.. automodule:: lib.gui.theme - :members: - :undoc-members: - :show-inheritance: - -utils package -============= - -.. rubric:: Package Summary - -.. autosummary:: - :nosignatures: - ~lib.gui.utils.config.Config - ~lib.gui.utils.config.initialize_config - ~lib.gui.utils.config.get_config - ~lib.gui.utils.file_handler.FileHandler - ~lib.gui.utils.image.Images - ~lib.gui.utils.image.get_images - ~lib.gui.utils.image.initialize_images - ~lib.gui.utils.misc.LongRunningTask +| +.. automodapi:: lib.gui.gui_config + :include-all-objects: +| +.. automodapi:: lib.gui.command + :include-all-objects: -.. rubric:: config Module +| +.. automodapi:: lib.gui.control_helper + :include-all-objects: -.. automodule:: lib.gui.utils.config - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: lib.gui.custom_widgets + :include-all-objects: +| +.. automodapi:: lib.gui.display + :include-all-objects: -.. rubric:: file_handler Module +| +.. automodapi:: lib.gui.display_analysis + :include-all-objects: -.. automodule:: lib.gui.utils.file_handler - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: lib.gui.display_command + :include-all-objects: +| +.. automodapi:: lib.gui.display_graph + :include-all-objects: -.. rubric:: image Module +| +.. automodapi:: lib.gui.display_page + :include-all-objects: -.. automodule:: lib.gui.utils.image - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: lib.gui.menu + :include-all-objects: +| +.. automodapi:: lib.gui.options + :include-all-objects: + :no-inheritance-diagram: -.. rubric:: misc Module +| +.. automodapi:: lib.gui.popup_configure + :include-all-objects: -.. automodule:: lib.gui.utils.misc - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: lib.gui.popup_session + :include-all-objects: -wrapper module -============== +| +.. automodapi:: lib.gui.project + :include-all-objects: -.. rubric:: Module +| +.. automodapi:: lib.gui.theme + :include-all-objects: + :no-inheritance-diagram: -.. automodule:: lib.gui.wrapper - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: lib.gui.wrapper + :include-all-objects: + :no-inheritance-diagram: diff --git a/docs/full/lib/image.rst b/docs/full/lib/image.rst index 8b0c081d6d..6f7fffd075 100755 --- a/docs/full/lib/image.rst +++ b/docs/full/lib/image.rst @@ -1,36 +1,2 @@ -************ -image module -************ - -Handles loading and manipulation of images in Faceswap. - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~lib.image.FacesLoader - ~lib.image.FfmpegReader - ~lib.image.ImageIO - ~lib.image.ImagesLoader - ~lib.image.ImagesSaver - ~lib.image.SingleFrameLoader - ~lib.image.batch_convert_color - ~lib.image.count_frames - ~lib.image.encode_image - ~lib.image.generate_thumbnail - ~lib.image.hex_to_rgb - ~lib.image.png_read_meta - ~lib.image.png_write_meta - ~lib.image.read_image - ~lib.image.read_image_batch - ~lib.image.read_image_meta - ~lib.image.read_image_meta_batch - ~lib.image.rgb_to_hex - -.. rubric:: Module - -.. automodule:: lib.image - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: lib.image + :include-all-objects: diff --git a/docs/full/lib/keras_utils.rst b/docs/full/lib/keras_utils.rst index 1dda86a3ec..03a049a76d 100644 --- a/docs/full/lib/keras_utils.rst +++ b/docs/full/lib/keras_utils.rst @@ -1,8 +1,3 @@ -****************** -keras_utils module -****************** - -.. automodule:: lib.keras_utils - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: lib.keras_utils + :include-all-objects: + :no-inheritance-diagram: diff --git a/docs/full/lib/keypress.rst b/docs/full/lib/keypress.rst new file mode 100644 index 0000000000..16fc211302 --- /dev/null +++ b/docs/full/lib/keypress.rst @@ -0,0 +1,2 @@ +.. automodapi:: lib.keypress + :include-all-objects: \ No newline at end of file diff --git a/docs/full/lib/lib.rst b/docs/full/lib/lib.rst index 09dffd99c8..4f20a3c3dc 100644 --- a/docs/full/lib/lib.rst +++ b/docs/full/lib/lib.rst @@ -4,6 +4,7 @@ lib package The lib package holds core functionality used throughout Faceswap. .. toctree:: + :maxdepth: 2 :glob: * diff --git a/docs/full/lib/logger.rst b/docs/full/lib/logger.rst index 82c375a95d..9f69671d82 100755 --- a/docs/full/lib/logger.rst +++ b/docs/full/lib/logger.rst @@ -1,8 +1,3 @@ -************* -logger module -************* - -.. automodule:: lib.logger - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: lib.logger + :include-all-objects: + :no-inheritance-diagram: diff --git a/docs/full/lib/model.rst b/docs/full/lib/model.rst index e01f9fd02f..bd5dea4224 100755 --- a/docs/full/lib/model.rst +++ b/docs/full/lib/model.rst @@ -1,168 +1,66 @@ -************* -model package -************* - +***************** +lib.model package +***************** The Model Package handles interfacing with the neural network backend and holds custom objects. .. contents:: Contents :local: + :depth: 2 -model.backup_restore module -=========================== - -.. automodule:: lib.model.backup_restore - :members: - :undoc-members: - :show-inheritance: - -model.initializers module -========================= - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~lib.model.initializers.ConvolutionAware - ~lib.model.initializers.ICNR - ~lib.model.initializers.compute_fans - -.. automodule:: lib.model.initializers - :members: - :undoc-members: - :show-inheritance: - -model.layers module -=================== - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~lib.model.layers.GlobalMinPooling2D - ~lib.model.layers.GlobalStdDevPooling2D - ~lib.model.layers.KResizeImages - ~lib.model.layers.L2_normalize - ~lib.model.layers.PixelShuffler - ~lib.model.layers.QuickGELU - ~lib.model.layers.ReflectionPadding2D - ~lib.model.layers.SubPixelUpscaling - ~lib.model.layers.Swish - -.. automodule:: lib.model.layers - :members: - :undoc-members: - :show-inheritance: - -model.losses module -=================== - -.. rubric:: Module Summary +losses package +============== -.. autosummary:: - :nosignatures: +.. automodapi:: lib.model.losses.feature_loss + :include-all-objects: - ~lib.model.loss.loss_tf.FocalFrequencyLoss - ~lib.model.loss.loss_tf.GeneralizedLoss - ~lib.model.loss.loss_tf.GradientLoss - ~lib.model.loss.loss_tf.LaplacianPyramidLoss - ~lib.model.loss.loss_tf.LInfNorm - ~lib.model.loss.loss_tf.LossWrapper - ~lib.model.loss.feature_loss_tf.LPIPSLoss - ~lib.model.loss.perceptual_loss_tf.DSSIMObjective - ~lib.model.loss.perceptual_loss_tf.GMSDLoss - ~lib.model.loss.perceptual_loss_tf.LDRFLIPLoss - ~lib.model.loss.perceptual_loss_tf.MSSIMLoss +| +.. automodapi:: lib.model.losses.loss + :include-all-objects: -.. automodule:: lib.model.loss.loss_tf - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: lib.model.losses.perceptual_loss + :include-all-objects: -.. automodule:: lib.model.loss.feature_loss_tf - :members: - :undoc-members: - :show-inheritance: +networks package +================ -.. automodule:: lib.model.loss.perceptual_loss_tf - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: lib.model.networks.clip + :include-all-objects: + :noindex: +| +.. automodapi:: lib.model.networks.simple_nets + :include-all-objects: -model.nets module -================= - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~lib.model.nets.AlexNet - ~lib.model.nets.SqueezeNet - -.. automodule:: lib.model.nets - :members: - :undoc-members: - :show-inheritance: - -model.nn_blocks module -====================== - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~lib.model.nn_blocks.Conv2D - ~lib.model.nn_blocks.Conv2DBlock - ~lib.model.nn_blocks.Conv2DOutput - ~lib.model.nn_blocks.ResidualBlock - ~lib.model.nn_blocks.SeparableConv2DBlock - ~lib.model.nn_blocks.Upscale2xBlock - ~lib.model.nn_blocks.UpscaleBlock - ~lib.model.nn_blocks.set_config - -.. automodule:: lib.model.nn_blocks - :members: - :undoc-members: - :show-inheritance: - -model.normalization module -========================== - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~lib.model.normalization.InstanceNormalization - -.. automodule:: lib.model.normalization - :members: - :undoc-members: - :show-inheritance: +model package +============= -model.optimizers module -======================= +.. automodapi:: lib.model.autoclip + :include-all-objects: + :no-inheritance-diagram: -.. rubric:: Module Summary +| +.. automodapi:: lib.model.backup_restore + :include-all-objects: + :no-inheritance-diagram: -.. autosummary:: - :nosignatures: +| +.. automodapi:: lib.model.initializers + :include-all-objects: - ~lib.model.optimizers_tf.AdaBelief +| +.. automodapi:: lib.model.layers + :include-all-objects: -.. automodule:: lib.model.optimizers_tf - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: lib.model.nn_blocks + :include-all-objects: + :no-inheritance-diagram: -model.session module -===================== +| +.. automodapi:: lib.model.normalization + :include-all-objects: -.. automodule:: lib.model.session - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: lib.model.optimizers + :include-all-objects: diff --git a/docs/full/lib/multithreading.rst b/docs/full/lib/multithreading.rst index e786abe83a..b99a029a2f 100644 --- a/docs/full/lib/multithreading.rst +++ b/docs/full/lib/multithreading.rst @@ -1,7 +1,2 @@ -multithreading module -===================== - -.. automodule:: lib.multithreading - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: lib.multithreading + :include-all-objects: diff --git a/docs/full/lib/plaidml_utils.rst b/docs/full/lib/plaidml_utils.rst deleted file mode 100644 index 256e96ed7a..0000000000 --- a/docs/full/lib/plaidml_utils.rst +++ /dev/null @@ -1,8 +0,0 @@ -******************** -plaidml_utils module -******************** - -.. automodule:: lib.plaidml_utils - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/lib/queue_manager.rst b/docs/full/lib/queue_manager.rst new file mode 100755 index 0000000000..9021183da6 --- /dev/null +++ b/docs/full/lib/queue_manager.rst @@ -0,0 +1,2 @@ +.. automodapi:: lib.queue_manager + :include-all-objects: diff --git a/docs/full/lib/serializer.rst b/docs/full/lib/serializer.rst index 50370c19f9..04b177993d 100755 --- a/docs/full/lib/serializer.rst +++ b/docs/full/lib/serializer.rst @@ -1,19 +1,3 @@ -***************** -serializer module -***************** - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~lib.serializer.Serializer - ~lib.serializer.get_serializer - ~lib.serializer.get_serializer_from_filename - -.. rubric:: Module - -.. automodule:: lib.serializer - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: lib.serializer + :include-all-objects: + :no-inheritance-diagram: diff --git a/docs/full/lib/sysinfo.rst b/docs/full/lib/sysinfo.rst deleted file mode 100755 index 409c310b78..0000000000 --- a/docs/full/lib/sysinfo.rst +++ /dev/null @@ -1,7 +0,0 @@ -sysinfo module -============== - -.. automodule:: lib.sysinfo - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/lib/system.rst b/docs/full/lib/system.rst new file mode 100644 index 0000000000..25da19aae4 --- /dev/null +++ b/docs/full/lib/system.rst @@ -0,0 +1,23 @@ +****************** +lib.system package +****************** + +The System Package handles collecting information about the running system + +.. contents:: Contents + :local: + :depth: 2 + +.. automodapi:: lib.system.ml_libs + :include-all-objects: + :no-inheritance-diagram: + +| +.. automodapi:: lib.system.sysinfo + :include-all-objects: + :no-inheritance-diagram: + +| +.. automodapi:: lib.system.system + :include-all-objects: + :no-inheritance-diagram: diff --git a/docs/full/lib/training.rst b/docs/full/lib/training.rst index f3cb797f40..b47349e087 100644 --- a/docs/full/lib/training.rst +++ b/docs/full/lib/training.rst @@ -1,57 +1,43 @@ -**************** -training package -**************** +********************* +lib.training package +********************* -The training Package handles the processing of faces for feeding into a Faceswap model. +The training Package handles libraries to assist with training a model .. contents:: Contents :local: + :depth: 2 -training.augmentation module -============================ +.. automodapi:: lib.training.augmentation + :include-all-objects: + :no-inheritance-diagram: -.. automodule:: lib.training.augmentation - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: lib.training.cache + :include-all-objects: + :no-inheritance-diagram: -training.cache module -===================== +| +.. automodapi:: lib.training.generator + :include-all-objects: -.. automodule:: lib.training.cache - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: lib.training.lr_finder + :include-all-objects: +| +.. automodapi:: lib.training.lr_warmup + :include-all-objects: + :no-inheritance-diagram: -training.generator module -========================= +| +.. automodapi:: lib.training.preview_cv + :include-all-objects: -.. automodule:: lib.training.generator - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: lib.training.preview_tk + :include-all-objects: -training.lr_finder module -========================= - -.. automodule:: lib.training.lr_finder - :members: - :undoc-members: - :show-inheritance: - -training.preview_cv module -========================== - -.. automodule:: lib.training.preview_cv - :members: - :undoc-members: - :show-inheritance: - -training.preview_tk module -========================== - -.. automodule:: lib.training.preview_tk - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: lib.training.tensorboard + :include-all-objects: diff --git a/docs/full/lib/utils.rst b/docs/full/lib/utils.rst index 53fefa7c0b..237dc2ab5a 100755 --- a/docs/full/lib/utils.rst +++ b/docs/full/lib/utils.rst @@ -1,8 +1,3 @@ -************ -utils module -************ - -.. automodule:: lib.utils - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: lib.utils + :include-all-objects: + :no-inheritance-diagram: diff --git a/docs/full/modules.rst b/docs/full/modules.rst index 877e28ef66..1286cb4a7e 100644 --- a/docs/full/modules.rst +++ b/docs/full/modules.rst @@ -7,7 +7,6 @@ faceswap lib/lib plugins/plugins scripts - tests/tests tools/tools setup update_deps diff --git a/docs/full/plugins/convert.rst b/docs/full/plugins/convert.rst index 103d650ba7..845dbd3cbb 100755 --- a/docs/full/plugins/convert.rst +++ b/docs/full/plugins/convert.rst @@ -6,65 +6,61 @@ The Convert Package handles the various plugins available for performing convers .. contents:: Contents :local: + :depth: 2 -mask package -============ +colour package +============== -mask.mask_blend module ----------------------- +.. automodapi:: plugins.convert.color.avg_color + :include-all-objects: -.. automodule:: plugins.convert.mask.mask_blend - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: plugins.convert.color.color_transfer + :include-all-objects: -writer package -============== +| +.. automodapi:: plugins.convert.color.manual_balance + :include-all-objects: -writer._base module -------------------- +| +.. automodapi:: plugins.convert.color.match_hist + :include-all-objects: -.. automodule:: plugins.convert.writer._base - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: plugins.convert.color.seamless_clone + :include-all-objects: -writer.ffmpeg module --------------------- +mask package +============ -.. automodule:: plugins.convert.writer.ffmpeg - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: plugins.convert.mask.mask_blend + :include-all-objects: + :no-inheritance-diagram: -writer.gif module ------------------ +scaling package +=============== -.. automodule:: plugins.convert.writer.gif - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: plugins.convert.scaling.sharpen + :include-all-objects: -writer.opencv module --------------------- +writer package +============== -.. automodule:: plugins.convert.writer.opencv - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: plugins.convert.writer.ffmpeg + :include-all-objects: -writer.patch module --------------------- +| +.. automodapi:: plugins.convert.writer.gif + :include-all-objects: -.. automodule:: plugins.convert.writer.patch - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: plugins.convert.writer.opencv + :include-all-objects: -writer.pillow module --------------------- +| +.. automodapi:: plugins.convert.writer.patch + :include-all-objects: -.. automodule:: plugins.convert.writer.pillow - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: plugins.convert.writer.pillow + :include-all-objects: diff --git a/docs/full/plugins/extract.rst b/docs/full/plugins/extract.rst index f7e441a106..6d990623be 100755 --- a/docs/full/plugins/extract.rst +++ b/docs/full/plugins/extract.rst @@ -6,122 +6,89 @@ The Extract Package handles the various plugins available for extracting face se .. contents:: Contents :local: + :depth: 2 +align package +============= -extract\_media module -===================== -.. automodule:: plugins.extract.extract_media - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: plugins.extract.align.cv2_dnn + :include-all-objects: +| +.. automodapi:: plugins.extract.align.external + :include-all-objects: -pipeline module -=============== -.. automodule:: plugins.extract.pipeline - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: plugins.extract.align.fan + :include-all-objects: +detect package +============== -_base module -============ -.. automodule:: plugins.extract._base - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: plugins.extract.detect.cv2_dnn + :include-all-objects: +| +.. automodapi:: plugins.extract.detect.external + :include-all-objects: -align plugins package -===================== -.. contents:: Contents - :local: +| +.. automodapi:: plugins.extract.detect.mtcnn + :include-all-objects: -align._base.aligner module --------------------------- -.. automodule:: plugins.extract.align._base.aligner - :members: - :undoc-members: - :show-inheritance: - -align._base.processing module ------------------------------ -.. automodule:: plugins.extract.align._base.processing - :members: - :undoc-members: - :show-inheritance: - -align.cv2_dnn module --------------------- -.. automodule:: plugins.extract.align.cv2_dnn - :members: - :undoc-members: - :show-inheritance: - -align.fan module ----------------- -.. automodule:: plugins.extract.align.fan - :members: - :undoc-members: - :show-inheritance: - - -detect plugins package -====================== -.. contents:: Contents - :local: +| +.. automodapi:: plugins.extract.detect.s3fd + :include-all-objects: -detect._base module -------------------- -.. automodule:: plugins.extract.detect._base - :members: - :undoc-members: - :show-inheritance: +mask package +============ -detect.mtcnn module -------------------- -.. automodule:: plugins.extract.detect.mtcnn - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: plugins.extract.mask.bisenet_fp + :include-all-objects: +| +.. automodapi:: plugins.extract.mask.components + :include-all-objects: -mask plugins package -==================== -.. contents:: Contents - :local: +| +.. automodapi:: plugins.extract.mask.custom + :include-all-objects: -mask._base module ------------------ -.. automodule:: plugins.extract.mask._base - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: plugins.extract.mask.extended + :include-all-objects: -mask.bisenet_fp module ----------------------- -.. automodule:: plugins.extract.mask.bisenet_fp - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: plugins.extract.mask.unet_dfl + :include-all-objects: +| +.. automodapi:: plugins.extract.mask.vgg_clear + :include-all-objects: -recognition plugins package -=========================== -.. contents:: Contents - :local: +| +.. automodapi:: plugins.extract.mask.vgg_obstructed + :include-all-objects: + +recognition package +=================== + +.. automodapi:: plugins.extract.recognition.vgg_face2 + :include-all-objects: + +extract package +=============== -recognition._base module ------------------------- -.. automodule:: plugins.extract.recognition._base - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: plugins.extract.extract_config + :include-all-objects: + :no-inheritance-diagram: +| +.. automodapi:: plugins.extract.extract_media + :include-all-objects: + :no-inheritance-diagram: -recognition.vgg_face2 module ----------------------------- -.. automodule:: plugins.extract.recognition.vgg_face2 - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: plugins.extract.pipeline + :include-all-objects: + :no-inheritance-diagram: diff --git a/docs/full/plugins/plugin_loader.rst b/docs/full/plugins/plugin_loader.rst index 677ab6cfaa..bf42d393ce 100755 --- a/docs/full/plugins/plugin_loader.rst +++ b/docs/full/plugins/plugin_loader.rst @@ -1,8 +1,2 @@ -********************* -plugin\_loader module -********************* - -.. automodule:: plugins.plugin_loader - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: plugins.plugin_loader + :include-all-objects: diff --git a/docs/full/plugins/plugins.rst b/docs/full/plugins/plugins.rst index 5313aa8f1c..70f8ca69b6 100644 --- a/docs/full/plugins/plugins.rst +++ b/docs/full/plugins/plugins.rst @@ -4,6 +4,7 @@ plugins package The plugins package holds Extraction, Training and Conversion plugins for Faceswap. .. toctree:: + :maxdepth: 3 :glob: * diff --git a/docs/full/plugins/train.rst b/docs/full/plugins/train.rst index cfaf89bd45..51d6b55d76 100755 --- a/docs/full/plugins/train.rst +++ b/docs/full/plugins/train.rst @@ -4,7 +4,6 @@ train package The Train Package handles the Model and Trainer plugins for training models in Faceswap. - .. contents:: Contents :local: @@ -13,54 +12,61 @@ model package This package contains various helper functions that plugins can inherit from -.. rubric:: Module Summary +.. automodapi:: plugins.train.model._base.inference + :include-all-objects: + :no-inheritance-diagram: + +.. automodapi:: plugins.train.model._base.io + :include-all-objects: + :no-inheritance-diagram: -.. autosummary:: - :nosignatures: +| +.. automodapi:: plugins.train.model._base.model + :include-all-objects: + :no-inheritance-diagram: - ~plugins.train.model._base.model - ~plugins.train.model._base.settings - ~plugins.train.model._base.io +| +.. automodapi:: plugins.train.model._base.settings + :include-all-objects: + :no-inheritance-diagram: -model._base.model module ------------------------- +| +.. automodapi:: plugins.train.model._base.state + :include-all-objects: + :no-inheritance-diagram: -.. automodule:: plugins.train.model._base.model - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: plugins.train.model._base.update + :include-all-objects: + :no-inheritance-diagram: -model._base.settings module ---------------------------- +| +.. automodapi:: plugins.train.model.original + :include-all-objects: -.. automodule:: plugins.train.model._base.settings - :members: - :undoc-members: - :show-inheritance: -model._base.io module ---------------------- +trainer package +=============== -.. automodule:: plugins.train.model._base.io - :members: - :undoc-members: - :show-inheritance: +This package contains the training loop for Faceswap -model.original module ----------------------- +.. automodapi:: plugins.train.trainer._base + :include-all-objects: + :no-inheritance-diagram: -.. automodule:: plugins.train.model.original - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: plugins.train.trainer._display + :include-all-objects: + :no-inheritance-diagram: -trainer package -=============== +| +.. automodapi:: plugins.train.trainer.distributed + :include-all-objects: -trainer._base module ----------------------- +| +.. automodapi:: plugins.train.trainer.original + :include-all-objects: -.. automodule:: plugins.train.trainer._base - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: plugins.train.trainer.trainer_config + :include-all-objects: diff --git a/docs/full/scripts.rst b/docs/full/scripts.rst index 1736d5a308..c620a8e46a 100644 --- a/docs/full/scripts.rst +++ b/docs/full/scripts.rst @@ -7,57 +7,20 @@ The Scripts Package is the entry point into Faceswap. .. contents:: Contents :local: -extract module -============== -.. automodule:: scripts.extract - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: scripts.convert + :include-all-objects: + :no-inheritance-diagram: -train module -============ -.. automodule:: scripts.train - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: scripts.extract + :include-all-objects: + :no-inheritance-diagram: -convert module -============== +.. automodapi:: scripts.fsmedia + :include-all-objects: -.. rubric:: Module Summary +.. automodapi:: scripts.gui + :include-all-objects: -.. autosummary:: - :nosignatures: - - ~scripts.convert.Convert - ~scripts.convert.DiskIO - ~scripts.convert.OptionalActions - ~scripts.convert.Predict - -.. rubric:: Module - -.. automodule:: scripts.convert - :members: - :undoc-members: - :show-inheritance: - -fsmedia module -============== - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~scripts.fsmedia.Alignments - ~scripts.fsmedia.DebugLandmarks - ~scripts.fsmedia.Images - ~scripts.fsmedia.PostProcess - ~scripts.fsmedia.finalize - -.. rubric:: Module - -.. automodule:: scripts.fsmedia - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: scripts.train + :include-all-objects: + :no-inheritance-diagram: diff --git a/docs/full/setup.rst b/docs/full/setup.rst index baa29b9f19..c0419ad122 100644 --- a/docs/full/setup.rst +++ b/docs/full/setup.rst @@ -1,8 +1,3 @@ -************ -setup module -************ - -.. automodule:: setup - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file +.. automodapi:: setup + :include-all-objects: + :no-inheritance-diagram: \ No newline at end of file diff --git a/docs/full/tests/lib.gpu_stats.rst b/docs/full/tests/lib.gpu_stats.rst deleted file mode 100644 index dbca67ef7f..0000000000 --- a/docs/full/tests/lib.gpu_stats.rst +++ /dev/null @@ -1,15 +0,0 @@ -***************** -gpu_stats package -***************** - -.. contents:: Contents - :local: - -_base_test module -***************** -Unittests for the :class:`~lib.gpu_stats._base` module - -.. automodule:: tests.lib.gpu_stats._base_test - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file diff --git a/docs/full/tests/lib.gui.rst b/docs/full/tests/lib.gui.rst deleted file mode 100644 index 4ec4258e7b..0000000000 --- a/docs/full/tests/lib.gui.rst +++ /dev/null @@ -1,15 +0,0 @@ -*********** -gui package -*********** - -.. contents:: Contents - :local: - -gui.analysis.event_reader module -******************************** -Unittests for the :class:`~lib.gui.analysis.event_reader` module - -.. automodule:: tests.lib.gui.analysis.event_reader_test - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file diff --git a/docs/full/tests/lib.rst b/docs/full/tests/lib.rst deleted file mode 100644 index a020342f8b..0000000000 --- a/docs/full/tests/lib.rst +++ /dev/null @@ -1,39 +0,0 @@ -*********** -lib package -*********** - -.. contents:: Contents - :local: - -Subpackages -=========== - -.. toctree:: - :maxdepth: 1 - - lib.gpu_stats - lib.gui - - -sysinfo module -************** -Unit tests for :class:`~lib.sysinfo` module - -.. rubric:: Module - -.. automodule:: tests.lib.sysinfo_test - :members: - :undoc-members: - :show-inheritance: - - -utils_test module -***************** -Unit tests for :class:`~lib.utils` module - -.. rubric:: Module - -.. automodule:: tests.lib.utils_test - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/tests/tests.rst b/docs/full/tests/tests.rst deleted file mode 100644 index a24f36aa6f..0000000000 --- a/docs/full/tests/tests.rst +++ /dev/null @@ -1,17 +0,0 @@ -************* -tests package -************* - -The Tests Package provides Faceswap's Unit Tests. - -.. contents:: Contents - :local: - -Subpackages -=========== - -.. toctree:: - :maxdepth: 1 - - lib - tools diff --git a/docs/full/tests/tools.alignments.rst b/docs/full/tests/tools.alignments.rst deleted file mode 100644 index cb5e3d1018..0000000000 --- a/docs/full/tests/tools.alignments.rst +++ /dev/null @@ -1,15 +0,0 @@ -****************** -alignments package -****************** - -.. contents:: Contents - :local: - -media_test module -***************** -Unittests for the :class:`~tools.alignments.media` module - -.. automodule:: tests.tools.alignments.media_test - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/full/tests/tools.preview.rst b/docs/full/tests/tools.preview.rst deleted file mode 100644 index 7c744b12f0..0000000000 --- a/docs/full/tests/tools.preview.rst +++ /dev/null @@ -1,15 +0,0 @@ -*************** -preview package -*************** - -.. contents:: Contents - :local: - -viewer_test module -****************** -Unittests for the :class:`~tools.preview.viewer` module - -.. automodule:: tests.tools.preview.viewer_test - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file diff --git a/docs/full/tests/tools.rst b/docs/full/tests/tools.rst deleted file mode 100644 index d19ad80daf..0000000000 --- a/docs/full/tests/tools.rst +++ /dev/null @@ -1,15 +0,0 @@ -************* -tools package -************* - -.. contents:: Contents - :local: - -Subpackages -=========== - -.. toctree:: - :maxdepth: 1 - - tools.alignments - tools.preview diff --git a/docs/full/tools/alignments.rst b/docs/full/tools/alignments.rst index 33119bfa64..f91cce2733 100644 --- a/docs/full/tools/alignments.rst +++ b/docs/full/tools/alignments.rst @@ -1,50 +1,35 @@ -****************** -alignments package -****************** +************************ +tools.alignments package +************************ .. contents:: Contents :local: - - -alignments module -***************** -The Alignments Module is the main entry point into the Alignments Tool. - -.. automodule:: tools.alignments.alignments - :members: - :undoc-members: - :show-inheritance: - - -jobs_faces module -================= - -.. automodule:: tools.alignments.jobs_faces - :members: - :undoc-members: - :show-inheritance: - - -jobs_frames module -================== - -.. automodule:: tools.alignments.jobs_frames - :members: - :undoc-members: - :show-inheritance: - -jobs module -=========== - -.. automodule:: tools.alignments.jobs - :members: - :undoc-members: - :show-inheritance: - -media module -============ - -.. automodule:: tools.alignments.media - :members: - :undoc-members: - :show-inheritance: + :depth: 2 + +.. automodapi:: tools.alignments.alignments + :include-all-objects: + :no-inheritance-diagram: + +| +.. automodapi:: tools.alignments.cli + :include-all-objects: + :no-inheritance-diagram: + +| +.. automodapi:: tools.alignments.jobs + :include-all-objects: + :no-inheritance-diagram: + +| +.. automodapi:: tools.alignments.jobs_faces + :include-all-objects: + :no-inheritance-diagram: + +| +.. automodapi:: tools.alignments.jobs_frames + :include-all-objects: + :no-inheritance-diagram: + +| +.. automodapi:: tools.alignments.media + :include-all-objects: diff --git a/docs/full/tools/ffmpeg.rst b/docs/full/tools/ffmpeg.rst new file mode 100644 index 0000000000..f370594827 --- /dev/null +++ b/docs/full/tools/ffmpeg.rst @@ -0,0 +1,15 @@ +********************* +tools.effmpeg package +********************* + +.. contents:: Contents + :local: + :depth: 2 + +.. automodapi:: tools.effmpeg.cli + :include-all-objects: + +| +.. automodapi:: tools.effmpeg.effmpeg + :include-all-objects: + :no-inheritance-diagram: diff --git a/docs/full/tools/manual.faceviewer.rst b/docs/full/tools/manual.faceviewer.rst deleted file mode 100644 index 5589e144a1..0000000000 --- a/docs/full/tools/manual.faceviewer.rst +++ /dev/null @@ -1,70 +0,0 @@ -****************** -faceviewer package -****************** - -Handles the display of faces in the Face Viewer section of Faceswap's Manual Tool. - -.. contents:: Contents - :local: - - -frame module -============ - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~tools.manual.faceviewer.frame.ContextMenu - ~tools.manual.faceviewer.frame.FacesActionsFrame - ~tools.manual.faceviewer.frame.FacesFrame - ~tools.manual.faceviewer.frame.FacesViewer - ~tools.manual.faceviewer.frame.Grid - -.. rubric:: Module - -.. automodule:: tools.manual.faceviewer.frame - :members: - :undoc-members: - :show-inheritance: - - -interact module -=============== - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~tools.manual.faceviewer.interact.ActiveFrame - ~tools.manual.faceviewer.interact.Asset - ~tools.manual.faceviewer.interact.HoverBox - -.. rubric:: Module - -.. automodule:: tools.manual.faceviewer.interact - :members: - :undoc-members: - :show-inheritance: - - -viewport module -=============== - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~tools.manual.faceviewer.viewport.TKFace - ~tools.manual.faceviewer.viewport.Viewport - ~tools.manual.faceviewer.viewport.VisibleObjects - -.. rubric:: Module - -.. automodule:: tools.manual.faceviewer.viewport - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file diff --git a/docs/full/tools/manual.frameviewer.rst b/docs/full/tools/manual.frameviewer.rst deleted file mode 100644 index ee6f084afe..0000000000 --- a/docs/full/tools/manual.frameviewer.rst +++ /dev/null @@ -1,109 +0,0 @@ -****************** -frameviewer module -****************** - -Handles the display of frames in the Frame Viewer section of Faceswap's Manual Tool. - -.. contents:: Contents - :local: - -frame module -============ - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~tools.manual.frameviewer.frame.ActionsFrame - ~tools.manual.frameviewer.frame.BackgroundImage - ~tools.manual.frameviewer.frame.DisplayFrame - ~tools.manual.frameviewer.frame.FrameViewer - ~tools.manual.frameviewer.frame.Navigation - -.. rubric:: Module - -.. automodule:: tools.manual.frameviewer.frame - :members: - :undoc-members: - :show-inheritance: - -control module -============== - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~tools.manual.frameviewer.control.BackgroundImage - ~tools.manual.frameviewer.control.Navigation - -.. rubric:: Module - -.. automodule:: tools.manual.frameviewer.control - :members: - :undoc-members: - :show-inheritance: - -editor package -============== -.. contents:: Contents - :local: - -_base module ------------- - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~tools.manual.frameviewer.editor._base.Editor - ~tools.manual.frameviewer.editor._base.View - -.. rubric:: Module - -.. automodule:: tools.manual.frameviewer.editor._base - :members: - :undoc-members: - :show-inheritance: - -bounding_box module -------------------- -.. automodule:: tools.manual.frameviewer.editor.bounding_box - :members: - :undoc-members: - :show-inheritance: - -extract_box module ------------------- -.. automodule:: tools.manual.frameviewer.editor.extract_box - :members: - :undoc-members: - :show-inheritance: - -landmarks module ----------------- - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~tools.manual.frameviewer.editor.landmarks.Landmarks - ~tools.manual.frameviewer.editor.landmarks.Mesh - -.. rubric:: Module - -.. automodule:: tools.manual.frameviewer.editor.landmarks - :members: - :undoc-members: - :show-inheritance: - -mask module ------------ -.. automodule:: tools.manual.frameviewer.editor.mask - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file diff --git a/docs/full/tools/manual.rst b/docs/full/tools/manual.rst index 4b35542559..859267a88a 100644 --- a/docs/full/tools/manual.rst +++ b/docs/full/tools/manual.rst @@ -1,84 +1,75 @@ -************** -manual package -************** +******************** +tools.manual package +******************** .. contents:: Contents :local: + :depth: 2 -Subpackages -=========== -The following subpackages handle the main two display areas of the Manual Tool's GUI. - -.. toctree:: - :maxdepth: 4 - - manual.faceviewer - manual.frameviewer - -manual module -============= -The Manual Module is the main entry point into the Manual Editor Tool. - -.. rubric:: Module Summary +manual.faceviewer package +========================= -.. autosummary:: - :nosignatures: +.. automodapi:: tools.manual.faceviewer.frame + :include-all-objects: - ~tools.manual.manual.Aligner - ~tools.manual.manual.FrameLoader - ~tools.manual.manual.Manual +| +.. automodapi:: tools.manual.faceviewer.interact + :include-all-objects: + :no-inheritance-diagram: -.. rubric:: Module +| +.. automodapi:: tools.manual.faceviewer.viewport + :include-all-objects: + :no-inheritance-diagram: -.. automodule:: tools.manual.manual - :members: - :undoc-members: - :show-inheritance: +manual.frameviewer package +========================== -detected_faces module -===================== +.. automodapi:: tools.manual.frameviewer.control + :include-all-objects: + :no-inheritance-diagram: -.. rubric:: Module Summary +| +.. automodapi:: tools.manual.frameviewer.frame + :include-all-objects: -.. autosummary:: - :nosignatures: +| +.. automodapi:: tools.manual.frameviewer.editor.bounding_box + :include-all-objects: - ~tools.manual.detected_faces.DetectedFaces - ~tools.manual.detected_faces.FaceUpdate - ~tools.manual.detected_faces.Filter +| +.. automodapi:: tools.manual.frameviewer.editor.extract_box + :include-all-objects: -.. rubric:: Module +| +.. automodapi:: tools.manual.frameviewer.editor.landmarks + :include-all-objects: -.. automodule:: tools.manual.detected_faces - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: tools.manual.frameviewer.editor.mask + :include-all-objects: -globals module -============== - -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~tools.manual.globals.CurrentFrame - ~tools.manual.globals.TkGlobals - ~tools.manual.globals.TKVars - -.. rubric:: Module +manual package +========================== -.. automodule:: tools.manual.globals - :members: - :undoc-members: - :show-inheritance: +.. automodapi:: tools.manual.cli + :include-all-objects: +| +.. automodapi:: tools.manual.detected_faces + :include-all-objects: + :no-inheritance-diagram: -thumbnails module -================== +| +.. automodapi:: tools.manual.globals + :include-all-objects: + :no-inheritance-diagram: -.. automodule:: tools.manual.thumbnails - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: tools.manual.manual + :include-all-objects: +| +.. automodapi:: tools.manual.thumbnails + :include-all-objects: + :no-inheritance-diagram: diff --git a/docs/full/tools/mask.rst b/docs/full/tools/mask.rst new file mode 100644 index 0000000000..963cf95b3f --- /dev/null +++ b/docs/full/tools/mask.rst @@ -0,0 +1,35 @@ +****************** +tools.mask package +****************** + +.. contents:: Contents + :local: + :depth: 2 + +.. automodapi:: tools.mask.cli + :include-all-objects: + +| +.. automodapi:: tools.mask.loader + :include-all-objects: + :no-inheritance-diagram: + +| +.. automodapi:: tools.mask.mask + :include-all-objects: + :no-inheritance-diagram: + +| +.. automodapi:: tools.mask.mask_generate + :include-all-objects: + :no-inheritance-diagram: + +| +.. automodapi:: tools.mask.mask_import + :include-all-objects: + :no-inheritance-diagram: + +| +.. automodapi:: tools.mask.mask_output + :include-all-objects: + :no-inheritance-diagram: diff --git a/docs/full/tools/model.rst b/docs/full/tools/model.rst new file mode 100644 index 0000000000..3d59937855 --- /dev/null +++ b/docs/full/tools/model.rst @@ -0,0 +1,15 @@ +******************* +tools.model package +******************* + +.. contents:: Contents + :local: + :depth: 2 + +.. automodapi:: tools.model.cli + :include-all-objects: + +| +.. automodapi:: tools.model.model + :include-all-objects: + :no-inheritance-diagram: diff --git a/docs/full/tools/preview.rst b/docs/full/tools/preview.rst index 5c0c76d790..350c953d84 100644 --- a/docs/full/tools/preview.rst +++ b/docs/full/tools/preview.rst @@ -1,44 +1,22 @@ -*************** -preview package -*************** +********************* +tools.preview package +********************* .. contents:: Contents :local: + :depth: 2 +.. automodapi:: tools.preview.cli + :include-all-objects: -preview module -============== -The Preview Module is the main entry point into the Preview Tool. +| +.. automodapi:: tools.preview.control_panels + :include-all-objects: -.. automodule:: tools.preview.preview - :members: - :undoc-members: - :show-inheritance: - - -cli module -========== - -.. automodule:: tools.preview.cli - :members: - :undoc-members: - :show-inheritance: - - -control_panels module -===================== - -.. automodule:: tools.preview.control_panels - :members: - :undoc-members: - :show-inheritance: - - -viewer module -============= - -.. automodule:: tools.preview.viewer - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: tools.preview.preview + :include-all-objects: +| +.. automodapi:: tools.preview.viewer + :include-all-objects: diff --git a/docs/full/tools/sort.rst b/docs/full/tools/sort.rst index 6335339799..05aae7ec7e 100644 --- a/docs/full/tools/sort.rst +++ b/docs/full/tools/sort.rst @@ -4,31 +4,20 @@ sort package .. contents:: Contents :local: + :depth: 2 +.. automodapi:: tools.sort.cli + :include-all-objects: -sort module -=========== -The Sort Module is the main entry point into the Sort Tool. +| +.. automodapi:: tools.sort.sort + :include-all-objects: + :no-inheritance-diagram: -.. automodule:: tools.sort.sort - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: tools.sort.sort_methods + :include-all-objects: - -sort_methods module -=================== - -.. automodule:: tools.sort.sort_methods - :members: - :undoc-members: - :show-inheritance: - - -sort_methods_aligned module -=========================== - -.. automodule:: tools.sort.sort_methods_aligned - :members: - :undoc-members: - :show-inheritance: +| +.. automodapi:: tools.sort.sort_methods_aligned + :include-all-objects: diff --git a/docs/full/tools/tools.rst b/docs/full/tools/tools.rst index 0381c4b9c3..9e02fcbc6f 100644 --- a/docs/full/tools/tools.rst +++ b/docs/full/tools/tools.rst @@ -4,32 +4,8 @@ tools package The Tools Package provides various tools for working with Faceswap outside of the core functionality. -.. contents:: Contents - :local: - -Subpackages -=========== - .. toctree:: - :maxdepth: 1 - - alignments - manual - preview - sort - -mask module -=========== - -.. automodule:: tools.mask.mask - :members: - :undoc-members: - :show-inheritance: - -model module -============ + :maxdepth: 3 + :glob: -.. automodule:: tools.model.model - :members: - :undoc-members: - :show-inheritance: + * diff --git a/docs/full/update_deps.rst b/docs/full/update_deps.rst index aea4753eaf..be3d11dc52 100644 --- a/docs/full/update_deps.rst +++ b/docs/full/update_deps.rst @@ -1,8 +1,3 @@ -****************** -update_deps module -****************** - -.. automodule:: update_deps - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file +.. automodapi:: update_deps + :include-all-objects: + :no-inheritance-diagram: diff --git a/docs/index.rst b/docs/index.rst index ca88bba8dd..511d36b598 100755 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,7 +7,7 @@ faceswap.dev Developer Documentation ==================================== .. toctree:: - :maxdepth: 2 + :maxdepth: 4 :caption: Contents: full/modules diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index 59a4b9b8f6..c1699c198f 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -1,21 +1,6 @@ # NB Do not install from this requirements file # It is for documentation purposes only - -sphinx>=6.0.0,<7.0.0 -sphinx_rtd_theme==1.2.2 -tqdm==4.65 -psutil==5.9.0 -numexpr>=2.8.7 -numpy>=1.26.0 -opencv-python>=4.9.0.0 -pillow==9.4.0 -scikit-learn>=1.3.0 -fastcluster>=1.2.6 -matplotlib==3.8.0 -imageio==2.33.1 -imageio-ffmpeg==0.4.9 -ffmpy==0.3.0 -nvidia-ml-py>=12.535,<12.536 -pytest==7.2.0 -pytest-mock==3.10.0 -tensorflow>=2.10.0,<2.11.0 +-r ../requirements/requirements_cpu.txt +-r ../requirements/_requirements_dev.txt +sphinx_rtd_theme +sphinx-automodapi diff --git a/faceswap.py b/faceswap.py index 5f27ba1792..6fb2f06b39 100755 --- a/faceswap.py +++ b/faceswap.py @@ -7,19 +7,22 @@ # Translations don't work by default in Windows, so hack in environment variable if sys.platform.startswith("win"): - os.environ["LANG"], _ = locale.getdefaultlocale() + import ctypes + windll = ctypes.windll.kernel32 + os.environ["LANG"] = locale.windows_locale[windll.GetUserDefaultUILanguage()] from lib.cli import args as cli_args # pylint:disable=wrong-import-position from lib.cli.args_train import TrainArgs # pylint:disable=wrong-import-position from lib.cli.args_extract_convert import ConvertArgs, ExtractArgs # noqa:E501 pylint:disable=wrong-import-position from lib.config import generate_configs # pylint:disable=wrong-import-position +from lib.system import System # pylint:disable=wrong-import-position # LOCALES _LANG = gettext.translation("faceswap", localedir="locales", fallback=True) _ = _LANG.gettext -if sys.version_info < (3, 10): - raise ValueError("This program requires at least python 3.10") +system = System() +system.validate_python() _PARSER = cli_args.FullHelpArgumentParser() diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 41f2eed8c3..0a5c92c66e 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -12,6 +12,7 @@ import numpy as np from lib.logger import parse_class_init +from lib.utils import get_module_objects from .constants import CenteringType, EXTRACT_RATIOS, LandmarkType, _MEAN_FACE from .pose import PoseEstimate @@ -35,7 +36,10 @@ def get_matrix_scaling(matrix: np.ndarray) -> tuple[int, int]: for an upscale matrix and (Area, Cubic) for a downscale matrix """ x_scale = np.sqrt(matrix[0, 0] * matrix[0, 0] + matrix[0, 1] * matrix[0, 1]) - y_scale = (matrix[0, 0] * matrix[1, 1] - matrix[0, 1] * matrix[1, 0]) / x_scale + if x_scale == 0: + y_scale = 0. + else: + y_scale = (matrix[0, 0] * matrix[1, 1] - matrix[0, 1] * matrix[1, 0]) / x_scale avg_scale = (x_scale + y_scale) * 0.5 if avg_scale >= 1.: interpolators = cv2.INTER_CUBIC, cv2.INTER_AREA @@ -85,7 +89,8 @@ def transform_image(image: np.ndarray, def get_adjusted_center(image_size: int, source_offset: np.ndarray, target_offset: np.ndarray, - source_centering: CenteringType) -> np.ndarray: + source_centering: CenteringType, + y_offset: float) -> np.ndarray: """ Obtain the correct center of a face extracted image to translate between two different extract centerings. @@ -99,6 +104,8 @@ def get_adjusted_center(image_size: int, The pose offset to translate a base extracted face to target centering source_centering: ["face", "head", "legacy"] The centering of the source image + y_offset: float + Amount to additionally offset the center of the image along the y-axis Returns ------- @@ -106,13 +113,13 @@ def get_adjusted_center(image_size: int, The center point of the image at the given size for the target centering """ source_size = image_size - (image_size * EXTRACT_RATIOS[source_centering]) - offset = target_offset - source_offset + offset = target_offset - source_offset - [0., y_offset] offset *= source_size center = np.rint(offset + image_size / 2).astype("int32") logger.trace( # type:ignore[attr-defined] "image_size: %s, source_offset: %s, target_offset: %s, source_centering: '%s', " - "adjusted_offset: %s, center: %s", - image_size, source_offset, target_offset, source_centering, offset, center) + "y_offset: %s, adjusted_offset: %s, center: %s", + image_size, source_offset, target_offset, source_centering, y_offset, offset, center) return center @@ -154,6 +161,7 @@ def get_centered_size(source_centering: CenteringType, ratio """ if source_centering == target_centering and coverage_ratio == 1.0: + src_size: float | int = size retval = size else: src_size = size - (size * EXTRACT_RATIOS[source_centering]) @@ -238,7 +246,7 @@ def lock(self, name: str) -> Lock: return self._locks[name] -class AlignedFace(): +class AlignedFace(): # pylint:disable=too-many-instance-attributes """ Class to align a face. Holds the aligned landmarks and face image, as well as associated matrices and information @@ -263,6 +271,8 @@ class AlignedFace(): The amount of the aligned image to return. A ratio of 1.0 will return the full contents of the aligned image. A ratio of 0.5 will return an image of the given size, but will crop to the central 50%% of the image. + y_offset: float, optional + Amount to adjust the aligned face along the y-axis in the range -1. to 1. Default: 0.0 dtype: str, optional Set a data type for the final face to be returned as. Passing ``None`` will return a face with the same data type as the original :attr:`image`. Default: ``None`` @@ -279,6 +289,7 @@ def __init__(self, centering: CenteringType = "face", size: int = 64, coverage_ratio: float = 1.0, + y_offset: float = 0.0, dtype: str | None = None, is_aligned: bool = False, is_legacy: bool = False) -> None: @@ -288,6 +299,7 @@ def __init__(self, self._centering = centering self._size = size self._coverage_ratio = coverage_ratio + self._y_offset = y_offset self._dtype = dtype self._is_aligned = is_aligned self._source_centering: CenteringType = "legacy" if is_legacy and is_aligned else "head" @@ -320,6 +332,11 @@ def padding(self) -> int: extracted face image for the selected extract type. """ return self._padding[self._centering] + @property + def y_offset(self) -> float: + """ float: Additional offset applied to the face along the y-axis in -1. to 1. range """ + return self._y_offset + @property def matrix(self) -> np.ndarray: """ :class:`numpy.ndarray`: The 3x2 transformation matrix for extracting and aligning the @@ -532,8 +549,7 @@ def extract_face(self, image: np.ndarray | None) -> np.ndarray | None: "image. Returning empty face.") return None - if self._is_aligned and (self._centering != self._source_centering or - self._coverage_ratio != 1.0): + if self._is_aligned: # Crop out the sub face from full head image = self._convert_centering(image) @@ -648,7 +664,8 @@ def get_cropped_roi(self, center = get_adjusted_center(image_size, self.pose.offset[self._source_centering], self.pose.offset[centering], - self._source_centering) + self._source_centering, + self.y_offset) padding = target_size // 2 roi = np.array([center - padding, center + padding]).ravel() logger.trace( # type:ignore[attr-defined] @@ -753,3 +770,6 @@ def _umeyama(source: np.ndarray, destination: np.ndarray, estimate_scale: bool) retval[:dim, :dim] *= scale return retval + + +__all__ = get_module_objects(__name__) diff --git a/lib/align/aligned_mask.py b/lib/align/aligned_mask.py index 6a34060653..ad5a7e7e1c 100644 --- a/lib/align/aligned_mask.py +++ b/lib/align/aligned_mask.py @@ -11,6 +11,7 @@ import numpy as np from lib.logger import parse_class_init +from lib.utils import get_module_objects from .alignments import MaskAlignmentsFileDict from . import get_adjusted_center, get_centered_size @@ -22,7 +23,7 @@ logger = logging.getLogger(__name__) -class Mask(): +class Mask(): # pylint:disable=too-many-instance-attributes """ Face Mask information and convenience methods Holds a Faceswap mask as generated from :mod:`plugins.extract.mask` and the information @@ -50,7 +51,7 @@ def __init__(self, storage_centering: CenteringType = "face") -> None: logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] self.stored_size = storage_size - self.stored_centering = storage_centering + self.stored_centering: CenteringType = storage_centering self._mask: bytes | None = None self._affine_matrix: np.ndarray | None = None @@ -258,7 +259,8 @@ def set_sub_crop(self, source_offset: np.ndarray, target_offset: np.ndarray, centering: CenteringType, - coverage_ratio: float = 1.0) -> None: + coverage_ratio: float = 1.0, + y_offset: float = 0.0) -> None: """ Set the internal crop area of the mask to be returned. This impacts the returned mask from :attr:`mask` if the requested mask is required for @@ -275,6 +277,8 @@ def set_sub_crop(self, coverage_ratio: float, optional The coverage ratio to be applied to the target image. ``None`` for default (1.0). Default: ``None`` + y_offset: float, optional + Amount to additionally adjust the masks's offset along the y-axis. Default: 0.0 """ if centering == self.stored_centering and coverage_ratio == 1.0: return @@ -282,7 +286,8 @@ def set_sub_crop(self, center = get_adjusted_center(self.stored_size, source_offset, target_offset, - self.stored_centering) + self.stored_centering, + y_offset) crop_size = get_centered_size(self.stored_centering, centering, self.stored_size, @@ -397,16 +402,16 @@ class LandmarksMask(Mask): Parameters ---------- - points: list + points : list[:class:`numpy.ndarray`] A list of landmark points that correspond to the given storage_size to create the mask. Each item in the list should be a :class:`numpy.ndarray` that a filled convex polygon will be created from - storage_size: int, optional + storage_size : int, optional The size (in pixels) that the compressed mask should be stored at. Default: 128. - storage_centering, str (optional): + storage_centering : str, optional: The centering to store the mask at. One of `"legacy"`, `"face"`, `"head"`. Default: `"face"` - dilation: float, optional + dilation : float, optional The amount of dilation to apply to the mask. as a percentage of the mask size. Default: 0.0 """ def __init__(self, @@ -597,3 +602,6 @@ def _get_kwargs(self) -> dict[str, int | tuple[int, int]]: for kword in self._kwarg_requirements[self._blur_type]} logger.trace("BlurMask kwargs: %s", retval) # type:ignore[attr-defined] return retval + + +__all__ = get_module_objects(__name__) diff --git a/lib/align/alignments.py b/lib/align/alignments.py index ff0f2207d3..aedf0c94ee 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -9,8 +9,8 @@ import numpy as np -from lib.serializer import get_serializer, get_serializer_from_filename -from lib.utils import FaceswapError +from lib.serializer import get_serializer +from lib.utils import FaceswapError, get_module_objects from .thumbnails import Thumbnails from .updater import (FileStructure, IdentityAndVideoMeta, LandmarkRename, Legacy, ListToNumpy, @@ -51,7 +51,7 @@ class PNGHeaderAlignmentsDict(T.TypedDict): y: int w: int h: int - landmarks_xy: list[float] | np.ndarray + landmarks_xy: list[list[float]] | np.ndarray mask: dict[str, MaskAlignmentsFileDict] identity: dict[str, list[float]] @@ -83,7 +83,7 @@ class PNGHeaderDict(T.TypedDict): source: PNGHeaderSourceDict -class Alignments(): +class Alignments(): # pylint:disable=too-many-public-methods """ The alignments file is a custom serialized ``.fsa`` file that holds information for each frame for a video or series of images. @@ -644,61 +644,9 @@ def have_alignments_file(self) -> bool: logger.trace(retval) # type:ignore[attr-defined] return retval - def _update_file_format(self, folder: str, filename: str) -> str: - """ Convert old style serialized alignments to new ``.fsa`` format. - - Parameters - ---------- - folder: str - The folder that the legacy alignments exist in - filename: str - The file name of the legacy alignments - - Returns - ------- - str - The full path to the newly created ``.fsa`` alignments file - """ - logger.info("Reformatting legacy alignments file...") - old_location = os.path.join(str(folder), filename) - new_location = f"{os.path.splitext(old_location)[0]}.{self._serializer.file_extension}" - if os.path.exists(old_location): - if os.path.exists(new_location): - logger.info("Using existing updated alignments file found at '%s'. If you do not " - "wish to use this existing file then you should delete or rename it.", - new_location) - else: - logger.info("Old location: '%s', New location: '%s'", old_location, new_location) - load_serializer = get_serializer_from_filename(old_location) - data = load_serializer.load(old_location) - self._serializer.save(new_location, data) - return os.path.basename(new_location) - - def _test_for_legacy(self, location: str) -> None: - """ For alignments filenames passed in without an extension, test for legacy - serialization formats and update to current ``.fsa`` format if any are found. - - Parameters - ---------- - location: str - The folder location to check for legacy alignments - """ - logger.debug("Checking for legacy alignments file formats: '%s'", location) - filename = os.path.splitext(location)[0] - for ext in (".json", ".p", ".pickle", ".yaml"): - legacy_filename = f"{filename}{ext}" - if os.path.exists(legacy_filename): - logger.debug("Legacy alignments file exists: '%s'", legacy_filename) - _ = self._update_file_format(*os.path.split(legacy_filename)) - break - logger.debug("Legacy alignments file does not exist: '%s'", legacy_filename) - def _get_location(self, folder: str, filename: str) -> str: """ Obtains the location of an alignments file. - If a legacy alignments file is provided/discovered, then the alignments file will be - updated to the custom ``.fsa`` format and saved. - Parameters ---------- folder: str @@ -713,10 +661,6 @@ def _get_location(self, folder: str, filename: str) -> str: """ logger.debug("Getting location: (folder: '%s', filename: '%s')", folder, filename) noext_name, extension = os.path.splitext(filename) - if extension in (".json", ".p", ".pickle", ".yaml", ".yml"): - # Reformat legacy alignments file - filename = self._update_file_format(folder, filename) - logger.debug("Updated legacy alignments. New filename: '%s'", filename) if extension[1:] == self._serializer.file_extension: logger.debug("Valid Alignments filename provided: '%s'", filename) else: @@ -724,11 +668,7 @@ def _get_location(self, folder: str, filename: str) -> str: logger.debug("File extension set from serializer: '%s'", self._serializer.file_extension) location = os.path.join(str(folder), filename) - if not os.path.exists(location): - # Test for old format alignments files and reformat if they exist. This will be - # executed if an alignments file has not been explicitly provided therefore it will not - # have been picked up in the extension test - self._test_for_legacy(location) + logger.verbose("Alignments filepath: '%s'", location) # type:ignore[attr-defined] return location @@ -808,3 +748,6 @@ def backup(self) -> None: logger.info("Backing up original alignments to '%s'", dst) os.rename(src, dst) logger.debug("Backed up alignments") + + +__all__ = get_module_objects(__name__) diff --git a/lib/align/constants.py b/lib/align/constants.py index 6d5b484e59..27f4eb51bf 100644 --- a/lib/align/constants.py +++ b/lib/align/constants.py @@ -7,6 +7,8 @@ import numpy as np +from lib.utils import get_module_objects + CenteringType = T.Literal["face", "head", "legacy"] EXTRACT_RATIOS: dict[CenteringType, float] = {"legacy": 0.375, "face": 0.5, "head": 0.625} @@ -109,3 +111,6 @@ def from_shape(cls, shape: tuple[int, ...]) -> LandmarkType: LandmarkType.LM_2D_4: {"face": (0, 4, True)}} """dict[:class:`LandmarkType`, dict[str, tuple[int, int, bool]]: For each landmark type, stores the (start index, end index, is polygon) information about each part of the face. """ + + +__all__ = get_module_objects(__name__) diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index efd4475ab2..87835257de 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -12,7 +12,7 @@ from lib.image import encode_image, read_image from lib.logger import parse_class_init -from lib.utils import FaceswapError +from lib.utils import FaceswapError, get_module_objects from .alignments import (Alignments, AlignmentFileDict, PNGHeaderAlignmentsDict, PNGHeaderDict, PNGHeaderSourceDict) from .aligned_face import AlignedFace @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) -class DetectedFace(): +class DetectedFace(): # pylint:disable=too-many-instance-attributes """ Detected face and landmark information Holds information about a detected face, it's location in a source image @@ -35,50 +35,26 @@ class DetectedFace(): Parameters ---------- - image: numpy.ndarray, optional - Original frame that holds this face. Optional (not required if just storing coordinates) - left: int + image : :class:`numpy.ndarray` | None, optional + Original frame that holds this face. Optional (not required if just storing coordinates). + Default: ``None`` + left : int The left most point (in pixels) of the face's bounding box as discovered in :mod:`plugins.extract.detect` - width: int + width : int The width (in pixels) of the face's bounding box as discovered in :mod:`plugins.extract.detect` - top: int + top : int The top most point (in pixels) of the face's bounding box as discovered in :mod:`plugins.extract.detect` - height: int + height : int The height (in pixels) of the face's bounding box as discovered in :mod:`plugins.extract.detect` - landmarks_xy: list - The 68 point landmarks as discovered in :mod:`plugins.extract.align`. Should be a ``list`` - of 68 `(x, y)` ``tuples`` with each of the landmark co-ordinates. - mask: dict - The generated mask(s) for the face as generated in :mod:`plugins.extract.mask`. Must be a - dict of {**name** (`str`): :class:`~lib.align.aligned_mask.Mask`}. - - Attributes - ---------- - image: numpy.ndarray, optional - This is a generic image placeholder that should not be relied on to be holding a particular - image. It may hold the source frame that holds the face, a cropped face or a scaled image - depending on the method using this object. - left: int - The left most point (in pixels) of the face's bounding box as discovered in - :mod:`plugins.extract.detect` - width: int - The width (in pixels) of the face's bounding box as discovered in - :mod:`plugins.extract.detect` - top: int - The top most point (in pixels) of the face's bounding box as discovered in - :mod:`plugins.extract.detect` - height: int - The height (in pixels) of the face's bounding box as discovered in - :mod:`plugins.extract.detect` - landmarks_xy: list - The 68 point landmarks as discovered in :mod:`plugins.extract.align`. - mask: dict - The generated mask(s) for the face as generated in :mod:`plugins.extract.mask`. Is a - dict of {**name** (`str`): :class:`~lib.align.aligned_mask.Mask`}. + landmarks_xy : :class:`numpy.ndarray` + The 68 point landmarks as discovered in :mod:`plugins.extract.align`. Should be an array + of 68 `(x, y)` points of each of the landmark co-ordinates. + mask : dict[str: :class:`~lib.align.aligned_mask.Mask`] + The generated mask(s) for the face as generated in :mod:`plugins.extract.mask`. """ def __init__(self, image: np.ndarray | None = None, @@ -90,46 +66,63 @@ def __init__(self, mask: dict[str, Mask] | None = None) -> None: logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] self.image = image + """ :class:`numpy.ndarray` | None : This is a generic image placeholder that should not be + relied on to be holding a particular image. It may hold the source frame that holds the + face, a cropped face or a scaled image depending on the method using this object. """ self.left = left + """ int : The left most point (in pixels) of the face's bounding box as discovered in + :mod:`plugins.extract.detect` """ self.width = width + """ int : The width (in pixels) of the face's bounding box as discovered in + :mod:`plugins.extract.detect` """ self.top = top + """ int : The top most point (in pixels) of the face's bounding box as discovered in + :mod:`plugins.extract.detect` """ self.height = height + """ int : The height (in pixels) of the face's bounding box as discovered in + :mod:`plugins.extract.detect` """ self._landmarks_xy = landmarks_xy self._identity: dict[str, np.ndarray] = {} self.thumbnail: np.ndarray | None = None + self.mask = {} if mask is None else mask - self._training_masks: tuple[bytes, tuple[int, int, int]] | None = None + """ dict[str: :class:`~lib.align.aligned_mask.Mask`] : The generated mask(s) for the face + as generated in :mod:`plugins.extract.mask` """ + self._training_masks: tuple[bytes, tuple[int, int, int]] | None = None self._aligned: AlignedFace | None = None logger.trace("Initialized %s", self.__class__.__name__) # type:ignore[attr-defined] @property def aligned(self) -> AlignedFace: - """ The aligned face connected to this detected face. """ + """ :class:`~lib.align.aligned_face.AlignedFace` : The aligned face connected to this + detected face. """ assert self._aligned is not None return self._aligned @property def landmarks_xy(self) -> np.ndarray: - """ The aligned face connected to this detected face. """ + """ :class:`numpy.ndarray` : The aligned face connected to this detected face. """ assert self._landmarks_xy is not None return self._landmarks_xy @property def right(self) -> int: - """int: Right point (in pixels) of face detection bounding box within the parent image """ + """int : Right point (in pixels) of face detection bounding box within the parent image """ assert self.left is not None and self.width is not None return self.left + self.width @property def bottom(self) -> int: - """int: Bottom point (in pixels) of face detection bounding box within the parent image """ + """int : Bottom point (in pixels) of face detection bounding box within the parent + image """ assert self.top is not None and self.height is not None return self.top + self.height @property def identity(self) -> dict[str, np.ndarray]: - """ dict: Identity mechanism as key, identity embedding as value. """ + """ dict[str, :class:`numpy.ndarray`] : Identity mechanism as key, identity embedding as + value. """ return self._identity def add_mask(self, @@ -147,19 +140,19 @@ def add_mask(self, Parameters ---------- - name: str + name : str The name of the mask as defined by the :attr:`plugins.extract.mask._base.name` parameter. - mask: numpy.ndarray + mask : :class:`numpy.ndarray` The mask that is to be added as output from :mod:`plugins.extract.mask` It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` - affine_matrix: numpy.ndarray + affine_matrix : :class:`numpy.ndarray` The transformation matrix required to transform the mask to the original frame. - interpolator, int: + interpolator : int The CV2 interpolator required to transform this mask to it's original frame. - storage_size, int (optional): + storage_size : int, optional The size the mask is to be stored at. Default: 128 - storage_centering, str (optional): + storage_centering : Literal["face", "head", "legacy"], optional: The centering to store the mask at. One of `"legacy"`, `"face"`, `"head"`. Default: `"face"` """ @@ -176,7 +169,7 @@ def add_landmarks_xy(self, landmarks: np.ndarray) -> None: Parameters ---------- - landmarks: :class:`numpy.ndarray` + landmarks : :class:`numpy.ndarray` The 68 point face landmarks to add for the face """ logger.trace("landmarks shape: '%s'", landmarks.shape) # type:ignore[attr-defined] @@ -188,9 +181,9 @@ def add_identity(self, name: str, embedding: np.ndarray, ) -> None: Parameters ---------- - name: str + name : str The name of the mechanism that calculated the identity - embedding: numpy.ndarray + embedding : :class:`numpy.ndarray` The identity embedding """ logger.trace("name: '%s', embedding shape: %s", # type:ignore[attr-defined] @@ -215,12 +208,12 @@ def get_landmark_mask(self, Parameters ---------- - area: ["face", "mouth", "eye"] + area : Literal["face", "mouth", "eye"] The type of mask to obtain. `face` is a full face mask the others are masks for those specific areas - blur_kernel: int + blur_kernel : int The size of the kernel for blurring the mask edges - dilation: float + dilation : float The amount of dilation to apply to the mask. as a percentage of the mask size Returns @@ -230,7 +223,7 @@ def get_landmark_mask(self, Raises ------ - FaceSwapError + :class:`lib.utils.FaceSwapError` If the aligned face does not contain the correct landmarks to generate a landmark mask """ # TODO Face mask generation from landmarks @@ -265,10 +258,10 @@ def store_training_masks(self, Parameters ---------- - masks: list + masks : list[:class:`numpy.ndarray` | None] A list of training mask. Must be all be uint-8 3D arrays of the same size in 0-255 range - delete_masks: bool, optional + delete_masks : bool, optional ``True`` to delete any of the :class:`~lib.align.aligned_mask.Mask` objects owned by this detected face. Use to free up unrequired memory usage. Default: ``False`` """ @@ -301,7 +294,7 @@ def to_alignment(self) -> AlignmentFileDict: returns ------- - alignment: dict + alignment : :class:`lib.align.alignments.AlignmentFileDict` The alignment dict will be returned with the keys ``x``, ``w``, ``y``, ``h``, ``landmarks_xy``, ``mask``. The additional key ``thumb`` will be provided if the detected face object contains a thumbnail. @@ -327,17 +320,17 @@ def from_alignment(self, alignment: AlignmentFileDict, Parameters ---------- - alignment: dict + alignment : :class:`lib.align.alignments.AlignmentFileDict` A dictionary entry for a face from an alignments file containing the keys ``x``, ``w``, ``y``, ``h``, ``landmarks_xy``. Optionally the key ``thumb`` will be provided. This is for use in the manual tool and contains the compressed jpg thumbnail of the face to be allocated to :attr:`thumbnail. Optionally the key ``mask`` will be provided, but legacy alignments will not have this key. - image: numpy.ndarray, optional + image : :class:`numpy.ndarray`, optional If an image is passed in, then the ``image`` attribute will be set to the cropped face based on the passed in bounding box co-ordinates - with_thumb: bool, optional + with_thumb : bool, optional Whether to load the jpg thumbnail into the detected face object, if provided. Default: ``False`` """ @@ -375,7 +368,9 @@ def from_alignment(self, alignment: AlignmentFileDict, def to_png_meta(self) -> PNGHeaderAlignmentsDict: """ Return the detected face formatted for insertion into a png itxt header. - returns: dict + Returns + ------- + :class:`lib.align.alignments.PNGHeaderAlignmentsDict` The alignments dict will be returned with the keys ``x``, ``w``, ``y``, ``h``, ``landmarks_xy`` and ``mask`` """ @@ -396,7 +391,7 @@ def from_png_meta(self, alignment: PNGHeaderAlignmentsDict) -> None: Parameters ---------- - alignment: dict + alignment : :class:`lib.align.alignments.PNGHeaderAlignmentsDict` A dictionary entry for a face from alignments stored in a png exif header containing the keys ``x``, ``w``, ``y``, ``h``, ``landmarks_xy`` and ``mask`` """ @@ -419,7 +414,13 @@ def from_png_meta(self, alignment: PNGHeaderAlignmentsDict) -> None: {k: v.shape for k, v in self._identity.items()}) def _image_to_face(self, image: np.ndarray) -> None: - """ set self.image to be the cropped face from detected bounding box """ + """ set self.image to be the cropped face from detected bounding box + + Parameters + ---------- + image : class:`numpy.ndarray` + The image to be cropped + """ logger.trace("Cropping face from image") # type:ignore[attr-defined] self.image = image[self.top: self.bottom, self.left: self.right] @@ -431,6 +432,7 @@ def load_aligned(self, dtype: str | None = None, centering: CenteringType = "head", coverage_ratio: float = 1.0, + y_offset: float = 0.0, force: bool = False, is_aligned: bool = False, is_legacy: bool = False) -> None: @@ -446,34 +448,39 @@ def load_aligned(self, Parameters ---------- - image: numpy.ndarray - The image that contains the face to be aligned - size: int - The size of the output face in pixels - dtype: str, optional + image : :class:`numpy.ndarray` | None, optional + The image that contains the face to be aligned. Default: ``None`` + size : int, optional + The size of the output face in pixels. Default: `256` + dtype : str, optional Optionally set a ``dtype`` for the final face to be formatted in. Default: ``None`` - centering: ["legacy", "face", "head"], optional + centering : Literal["legacy", "face", "head"], optional The type of extracted face that should be loaded. "legacy" places the nose in the center of the image (the original method for aligning). "face" aligns for the nose to be in the center of the face (top to bottom) but the center of the skull for left to right. "head" aligns for the center of the skull (in 3D space) being the center of the extracted image, with the crop holding the full head. Default: `"head"` - coverage_ratio: float, optional + coverage_ratio : float, optional The amount of the aligned image to return. A ratio of 1.0 will return the full contents of the aligned image. A ratio of 0.5 will return an image of the given size, but will crop to the central 50%% of the image. Default: `1.0` - force: bool, optional + y_offset : float, optional + The amount to adjust the aligned face along the y_axis in -1. to 1. range. + Default: `0.0` + force : bool, optional Force an update of the aligned face, even if it is already loaded. Default: ``False`` - is_aligned: bool, optional + is_aligned : bool, optional Indicates that the :attr:`image` is an aligned face rather than a frame. Default: ``False`` - is_legacy: bool, optional + is_legacy : bool, optional Only used if `is_aligned` is ``True``. ``True`` indicates that the aligned image being loaded is a legacy extracted face rather than a current head extracted face + Notes ----- - This method must be executed to get access to the following an :class:`AlignedFace` object + This method must be executed to get access to the following a + :class:`lib.align.aligned_face.AlignedFace` object """ if self._aligned and not force: # Don't reload an already aligned face @@ -487,6 +494,7 @@ def load_aligned(self, centering=centering, size=size, coverage_ratio=coverage_ratio, + y_offset=y_offset, dtype=dtype, is_aligned=is_aligned, is_legacy=is_aligned and is_legacy) @@ -504,16 +512,16 @@ def update_legacy_png_header(filename: str, alignments: Alignments Parameters ---------- - filename: str + filename : str The image file to update - alignments: :class:`lib.align.alignments.Alignments` + alignments : :class:`lib.align.alignments.Alignments` The alignments data the contains the information to store in the image header. This must be a v2.0 or less alignments file as later versions no longer store the face hash (not required) Returns ------- - dict + :class:`lib.align.alignments.PNGHeaderDict` The metadata that has been applied to the given image """ if alignments.version > 2.0: @@ -527,7 +535,7 @@ def update_legacy_png_header(filename: str, alignments: Alignments hashes_seen = _HASHES_SEEN[folder] in_image = read_image(filename, raise_error=True) - in_hash = sha1(in_image).hexdigest() + in_hash = sha1(T.cast(bytes, in_image)).hexdigest() hashes_seen[in_hash] = hashes_seen.get(in_hash, -1) + 1 alignment = alignments.hashes_to_alignment.get(in_hash) @@ -560,3 +568,6 @@ def update_legacy_png_header(filename: str, alignments: Alignments os.remove(filename) return meta + + +__all__ = get_module_objects(__name__) diff --git a/lib/align/pose.py b/lib/align/pose.py index fe186a3f5c..cac8337cfd 100644 --- a/lib/align/pose.py +++ b/lib/align/pose.py @@ -9,6 +9,7 @@ import numpy as np from lib.logger import parse_class_init +from lib.utils import get_module_objects from .constants import _MEAN_FACE, LandmarkType @@ -185,3 +186,6 @@ def _get_offset(self) -> dict[CenteringType, np.ndarray]: offset[key] = center - np.array([0.5, 0.5]) logger.trace("offset: %s", offset) # type:ignore[attr-defined] return offset + + +__all__ = get_module_objects(__name__) diff --git a/lib/align/thumbnails.py b/lib/align/thumbnails.py index d97801d1d9..ccdfa1ed56 100644 --- a/lib/align/thumbnails.py +++ b/lib/align/thumbnails.py @@ -8,9 +8,10 @@ import numpy as np from lib.logger import parse_class_init +from lib.utils import get_module_objects if T.TYPE_CHECKING: - from .alignments import Alignments + from lib import align logger = logging.getLogger(__name__) @@ -26,7 +27,7 @@ class Thumbnails(): alignments: :class:'~lib.align.alignments.Alignments` The parent alignments class that these thumbs belong to """ - def __init__(self, alignments: Alignments) -> None: + def __init__(self, alignments: align.alignments.Alignments) -> None: logger.debug(parse_class_init(locals())) self._alignments_dict = alignments.data self._frame_list = list(sorted(self._alignments_dict)) @@ -79,3 +80,6 @@ def add_thumbnail(self, frame: str, face_index: int, thumb: np.ndarray) -> None: logger.debug("frame: %s, face_index: %s, thumb shape: %s thumb dtype: %s", frame, face_index, thumb.shape, thumb.dtype) self._alignments_dict[frame]["faces"][face_index]["thumb"] = thumb + + +__all__ = get_module_objects(__name__) diff --git a/lib/align/updater.py b/lib/align/updater.py index 7a98e3c8fc..a877656613 100644 --- a/lib/align/updater.py +++ b/lib/align/updater.py @@ -9,12 +9,12 @@ import numpy as np from lib.logger import parse_class_init -from lib.utils import VIDEO_EXTENSIONS +from lib.utils import get_module_objects, VIDEO_EXTENSIONS logger = logging.getLogger(__name__) if T.TYPE_CHECKING: - from .alignments import Alignments, AlignmentFileDict + from lib import align class _Updater(): @@ -22,10 +22,10 @@ class _Updater(): Parameters ---------- - alignments: :class:`~Alignments` + alignments : :class:`~lib.align.alignments.Alignments` The alignments object that is being tested and updated """ - def __init__(self, alignments: Alignments) -> None: + def __init__(self, alignments: align.alignments.Alignments) -> None: logger.debug(parse_class_init(locals())) self._alignments = alignments self._needs_update = self._test() @@ -35,7 +35,7 @@ def __init__(self, alignments: Alignments) -> None: @property def is_updated(self) -> bool: - """ bool. ``True`` if this updater has been run otherwise ``False`` """ + """ bool : ``True`` if this updater has been run otherwise ``False`` """ return self._needs_update def _test(self) -> bool: @@ -93,12 +93,12 @@ class VideoExtension(_Updater): Parameters ---------- - alignments: :class:`~Alignments` + alignments : :class:`~lib.align.alignments.Alignments` The alignments object that is being tested and updated - video_filename: str + video_filename : str The video filename that holds these alignments """ - def __init__(self, alignments: Alignments, video_filename: str) -> None: + def __init__(self, alignments: align.alignments.Alignments, video_filename: str) -> None: self._video_name, self._extension = os.path.splitext(video_filename) super().__init__(alignments) @@ -135,7 +135,7 @@ def update(self) -> int: Parameters ---------- - video_filename: str + video_filename : str The filename of the video file that created these alignments """ updated = 0 @@ -329,13 +329,13 @@ class Legacy(): Parameters ---------- - alignments: :class:`~Alignments` + alignments : :class:`~lib.align.alignments.Alignments` The alignments object that requires these legacy properties """ - def __init__(self, alignments: Alignments) -> None: + def __init__(self, alignments: align.alignments.Alignments) -> None: self._alignments = alignments self._hashes_to_frame: dict[str, dict[str, int]] = {} - self._hashes_to_alignment: dict[str, AlignmentFileDict] = {} + self._hashes_to_alignment: dict[str, align.alignments.AlignmentFileDict] = {} @property def hashes_to_frame(self) -> dict[str, dict[str, int]]: @@ -361,7 +361,7 @@ def hashes_to_frame(self) -> dict[str, dict[str, int]]: return self._hashes_to_frame @property - def hashes_to_alignment(self) -> dict[str, AlignmentFileDict]: + def hashes_to_alignment(self) -> dict[str, align.alignments.AlignmentFileDict]: """ dict: The SHA1 hash of the face mapped to the alignment for the face that the hash corresponds to. The structure of the dictionary is: @@ -379,3 +379,6 @@ def hashes_to_alignment(self) -> dict[str, AlignmentFileDict]: for val in self._alignments.data.values() for face in val["faces"]} return self._hashes_to_alignment + + +__all__ = get_module_objects(__name__) diff --git a/lib/cli/actions.py b/lib/cli/actions.py index e3f36ae48c..634b5983f8 100644 --- a/lib/cli/actions.py +++ b/lib/cli/actions.py @@ -9,6 +9,8 @@ import os import typing as T +from lib.utils import get_module_objects + # << FILE HANDLING >> @@ -414,3 +416,6 @@ def _get_kwargs(self) -> list[tuple[str, T.Any]]: def __call__(self, parser, namespace, values, option_string=None) -> None: setattr(namespace, self.dest, values) + + +__all__ = get_module_objects(__name__) diff --git a/lib/cli/args.py b/lib/cli/args.py index 29aa74ee40..10002045f8 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -9,14 +9,19 @@ import textwrap import typing as T -from lib.utils import get_backend +from lib.utils import get_backend, get_module_objects from lib.gpu_stats import GPUStats from .actions import FileFullPaths, MultiOption, SaveFileFullPaths from .launcher import ScriptExecutor logger = logging.getLogger(__name__) -_GPUS = GPUStats().cli_devices + + +if GPUStats is None: + _GPUS = [] +else: + _GPUS = GPUStats().cli_devices # LOCALES _LANG = gettext.translation("lib.cli.args", localedir="locales", fallback=True) @@ -221,20 +226,11 @@ def _get_global_arguments() -> list[dict[str, T.Any]]: "help": _("Path to store the logfile. Leave blank to store in the faceswap folder")}) # These are hidden arguments to indicate that the GUI/Colab is being used global_args.append({ - "opts": ("-gui", "--gui"), + "opts": ("-G", "--gui"), "action": "store_true", "dest": "redirect_gui", "default": False, "help": argparse.SUPPRESS}) - # Deprecated multi-character switches - global_args.append({ - "opts": ("-LF",), - "action": SaveFileFullPaths, - "filetypes": 'log', - "type": str, - "dest": "depr_logfile_LF_F", - "help": argparse.SUPPRESS}) - return global_args @staticmethod @@ -313,3 +309,6 @@ def get_argument_list() -> list[dict[str, T.Any]]: "default": False, "help": _("Output to Shell console instead of GUI console")}) return argument_list + + +__all__ = get_module_objects(__name__) diff --git a/lib/cli/args_extract_convert.py b/lib/cli/args_extract_convert.py index ad3b4da9de..5c0b3ce903 100644 --- a/lib/cli/args_extract_convert.py +++ b/lib/cli/args_extract_convert.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """ The Command Line Argument options for extracting and converting with faceswap.py """ -import argparse import gettext import typing as T +from lib.utils import get_module_objects from lib.utils import get_backend from plugins.plugin_loader import PluginLoader @@ -65,14 +65,6 @@ def get_argument_list() -> list[dict[str, T.Any]]: "help": _( "Optional path to an alignments file. Leave blank if the alignments file is at " "the default location.")}) - # Deprecated multi-character switches - argument_list.append({ - "opts": ("-al", ), - "action": FileFullPaths, - "filetypes": "alignments", - "type": str, - "dest": "depr_alignments_al_p", - "help": argparse.SUPPRESS}) return argument_list @@ -359,7 +351,7 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "opts": ("-P", "--singleprocess"), "action": "store_true", "default": False, - "backend": ("nvidia", "directml", "rocm", "apple_silicon"), + "backend": ("nvidia", "rocm", "apple_silicon"), "group": _("settings"), "help": _( "Don't run extraction in parallel. Will run each part of the extraction process " @@ -387,58 +379,6 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "default": False, "group": _("settings"), "help": _("Skip saving the detected faces to disk. Just create an alignments file")}) - # Deprecated multi-character switches - argument_list.append({ - "opts": ("-min", ), - "type": int, - "dest": "depr_min-size_min_m", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-een", ), - "type": int, - "dest": "depr_extract-every-n_een_N", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-nm",), - "type": str.lower, - "dest": "depr_normalization_nm_O", - "choices": ["none", "clahe", "hist", "mean"], - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-rf", ), - "type": int, - "dest": "depr_re-feed_rf_R", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-sz", ), - "type": int, - "dest": "depr_size_sz_z", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-si", ), - "type": int, - "dest": "depr_save-interval_si_v", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-dl", ), - "action": "store_true", - "dest": "depr_debug-landmarks_dl_B", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-sp", ), - "dest": "depr_singleprocess_sp_P", - "action": "store_true", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-sf", ), - "action": "store_true", - "dest": "depr_skip-existing-faces_sf_e", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-ssf", ), - "action": "store_true", - "dest": "depr_skip-saving-faces_ssf_K", - "help": argparse.SUPPRESS}) return argument_list @@ -484,7 +424,7 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "help": _( "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and audio).")}) - argument_list.append({ + argument_list.append({ # pylint:disable=duplicate-code "opts": ("-m", "--model-dir"), "action": DirFullPaths, "dest": "model_dir", @@ -717,31 +657,7 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "default": False, "group": _("settings"), "help": _("Disable multiprocessing. Slower but less resource intensive.")}) - # Deprecated multi-character switches - argument_list.append({ - "opts": ("-sp", ), - "action": "store_true", - "dest": "depr_singleprocess_sp_P", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-ref", ), - "type": str, - "dest": "depr_reference-video_ref_r", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-fr", ), - "type": str, - "nargs": "+", - "dest": "depr_frame-ranges_fr_R", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-osc", ), - "type": int, - "dest": "depr_output-scale_osc_O", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-otf", ), - "action": "store_true", - "dest": "depr_on-the-fly_otf_T", - "help": argparse.SUPPRESS}) return argument_list + + +__all__ = get_module_objects(__name__) diff --git a/lib/cli/args_train.py b/lib/cli/args_train.py index e2f461db0f..821268f321 100644 --- a/lib/cli/args_train.py +++ b/lib/cli/args_train.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """ The Command Line Argument options for training with faceswap.py """ -import argparse import gettext import typing as T +from lib.utils import get_module_objects from plugins.plugin_loader import PluginLoader from .actions import DirFullPaths, FileFullPaths, Radio, Slider @@ -176,24 +176,13 @@ def get_argument_list() -> list[dict[str, T.Any]]: "Learning rate warmup. Linearly increase the learning rate from 0 to the chosen " "target rate over the number of iterations given here. 0 to disable.")}) argument_list.append({ - "opts": ("-D", "--distribution-strategy"), - "dest": "distribution_strategy", - "action": Radio, - "type": str.lower, - "choices": ["default", "central-storage", "mirrored"], - "default": "default", - "backend": ("nvidia", "directml", "rocm", "apple_silicon"), + "opts": ("-d", "--distributed"), + "dest": "distributed", + "action": "store_true", + "default": False, + "backend": ("nvidia", "rocm"), "group": _("training"), - "help": _( - "R|Select the distribution stategy to use." - "\nL|default: Use Tensorflow's default distribution strategy." - "\nL|central-storage: Centralizes variables on the CPU whilst operations are " - "performed on 1 or more local GPUs. This can help save some VRAM at the cost of " - "some speed by not storing variables on the GPU. Note: Mixed-Precision is not " - "supported on multi-GPU setups." - "\nL|mirrored: Supports synchronous distributed training across multiple local " - "GPUs. A copy of the model and all variables are loaded onto each GPU with " - "batches distributed to each GPU at each iteration.")}) + "help": _("Use distibuted training on multi-gpu setups.")}) argument_list.append({ "opts": ("-n", "--no-logs"), "action": "store_true", @@ -329,65 +318,7 @@ def get_argument_list() -> list[dict[str, T.Any]]: "enabled towards the very end of training to try to bring out more detail. Think " "of it as 'fine-tuning'. Enabling this option from the beginning is likely to " "kill a model and lead to terrible results.")}) - # Deprecated multi-character switches - argument_list.append({ - "opts": ("-su", ), - "action": "store_true", - "dest": "depr_summary_su_u", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-bs", ), - "type": int, - "dest": "depr_batch-size_bs_b", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-it", ), - "type": int, - "dest": "depr_iterations_it_i", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-nl", ), - "action": "store_true", - "dest": "depr_no-logs_nl_n", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-ss", ), - "type": int, - "dest": "depr_snapshot-interval_ss_I", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-tia", ), - "type": str, - "dest": "depr_timelapse-input-A_tia_x", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-tib", ), - "type": str, - "dest": "depr_timelapse-input-B_tib_y", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-to", ), - "type": str, - "dest": "depr_timelapse-output_to_z", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-wl", ), - "action": "store_true", - "dest": "depr_warp-to-landmarks_wl_M", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-nf", ), - "action": "store_true", - "dest": "depr_no-flip_nf_P", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-nac", ), - "action": "store_true", - "dest": "depr_no-augment-color_nac_c", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-nw", ), - "action": "store_true", - "dest": "depr_no-warp_nw_W", - "help": argparse.SUPPRESS}) return argument_list + + +__all__ = get_module_objects(__name__) diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 8c15a25815..b96b2cc13f 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -9,10 +9,10 @@ from importlib import import_module -from lib.gpu_stats import set_exclude_devices, GPUStats +from lib.gpu_stats import GPUStats from lib.logger import crash_log, log_setup -from lib.utils import (FaceswapError, get_backend, get_tf_version, - safe_shutdown, set_backend, set_system_verbosity) +from lib.utils import (FaceswapError, get_backend, get_torch_version, + get_module_objects, safe_shutdown, set_backend) if T.TYPE_CHECKING: import argparse @@ -36,6 +36,22 @@ class ScriptExecutor(): def __init__(self, command: str) -> None: self._command = command.lower() + def _set_environment_variables(self) -> None: + """ Set the number of threads that numexpr can use. """ + # Allocate a decent number of threads to numexpr to suppress warnings + cpu_count = os.cpu_count() + allocate = max(1, cpu_count - cpu_count // 3 if cpu_count is not None else 1) + if "OMP_NUM_THREADS" in os.environ: + # If this is set above NUMEXPR_MAX_THREADS, numexpr will error. + # ref: https://github.com/pydata/numexpr/issues/322 + os.environ.pop("OMP_NUM_THREADS") + logger.debug("Setting NUMEXPR_MAX_THREADS to %s", allocate) + os.environ["NUMEXPR_MAX_THREADS"] = str(allocate) + + if get_backend() == "apple_silicon": # Let apple put unsupported ops on the CPU + logger.debug("Enabling unsupported Ops on CPU for Apple Silicon") + os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1" + def _import_script(self) -> Callable: """ Imports the relevant script as indicated by :attr:`_command` from the scripts folder. @@ -45,7 +61,7 @@ def _import_script(self) -> Callable: The uninitialized script from the faceswap scripts folder. """ self._set_environment_variables() - self._test_for_tf_version() + self._test_for_torch_version() self._test_for_gui() cmd = os.path.basename(sys.argv[0]) src = f"tools.{self._command.lower()}" if cmd == "tools.py" else "scripts" @@ -54,82 +70,34 @@ def _import_script(self) -> Callable: script = getattr(module, self._command.title()) return script - def _set_environment_variables(self) -> None: - """ Set the number of threads that numexpr can use and TF environment variables. """ - # Allocate a decent number of threads to numexpr to suppress warnings - cpu_count = os.cpu_count() - allocate = cpu_count - cpu_count // 3 if cpu_count is not None else 1 - if "OMP_NUM_THREADS" in os.environ: - # If this is set above NUMEXPR_MAX_THREADS, numexpr will error. - # ref: https://github.com/pydata/numexpr/issues/322 - os.environ.pop("OMP_NUM_THREADS") - os.environ["NUMEXPR_MAX_THREADS"] = str(max(1, allocate)) - - # Ensure tensorflow doesn't pin all threads to one core when using Math Kernel Library - os.environ["TF_MIN_GPU_MULTIPROCESSOR_COUNT"] = "4" - os.environ["KMP_AFFINITY"] = "disabled" - - # If running under CPU on Windows, the following error can be encountered: - # OMP: Error #15: Initializing libiomp5md.dll, but found libiomp5 already initialized. - # OMP: Hint This means that multiple copies of the OpenMP runtime have been linked into - # the program. That is dangerous, since it can degrade performance or cause incorrect - # results. The best thing to do is to ensure that only a single OpenMP runtime is linked - # into the process, e.g. by avoiding static linking of the OpenMP runtime in any library. - # As an unsafe, unsupported, undocumented workaround you can set the environment variable - # KMP_DUPLICATE_LIB_OK=TRUE to allow the program to continue to execute, but that may cause - # crashes or silently produce incorrect results. For more information, - # please see http://www.intel.com/software/products/support/. - # - # TODO find a better way than just allowing multiple libs - if get_backend() == "cpu" and platform.system() == "Windows": - logger.debug("Setting `KMP_DUPLICATE_LIB_OK` environment variable to `TRUE`") - os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE" - - # There is a memory leak in TF2.10+ predict function. This fix will work for tf2.10 but not - # for later versions. This issue has been patched recently, but we'll probably need to - # skip some TF versions - # ref: https://github.com/tensorflow/tensorflow/issues/58676 - # TODO remove this fix post TF2.10 and check memleak is fixed - logger.debug("Setting TF_RUN_EAGER_OP_AS_FUNCTION env var to False") - os.environ["TF_RUN_EAGER_OP_AS_FUNCTION"] = "false" - - def _test_for_tf_version(self) -> None: - """ Check that the required Tensorflow version is installed. + def _test_for_torch_version(self) -> None: + """ Check that the required PyTorch version is installed. Raises ------ FaceswapError - If Tensorflow is not found, or is not between versions 2.4 and 2.9 + If PyTorch is not found, or is not between versions 2.3 and 2.9 """ - min_ver = (2, 10) - max_ver = (2, 10) + min_ver = (2, 3) + max_ver = (2, 9) try: - import tensorflow as tf # noqa pylint:disable=import-outside-toplevel,unused-import + import torch # noqa:F401 pylint:disable=unused-import,import-outside-toplevel except ImportError as err: - if "DLL load failed while importing" in str(err): - msg = ( - f"A DLL library file failed to load. Make sure that you have Microsoft Visual " - "C++ Redistributable (2015, 2017, 2019) installed for your machine from: " - "https://support.microsoft.com/en-gb/help/2977003. Original error: " - f"{str(err)}") - else: - msg = ( - f"There was an error importing Tensorflow. This is most likely because you do " - "not have TensorFlow installed, or you are trying to run tensorflow-gpu on a " - "system without an Nvidia graphics card. Original import " - f"error: {str(err)}") + msg = ( + f"There was an error importing PyTorch. This is most likely because you do " + f"not have PyTorch installed. Original import error: {str(err)}") self._handle_import_error(msg) - tf_ver = get_tf_version() - if tf_ver < min_ver: - msg = (f"The minimum supported Tensorflow is version {min_ver} but you have version " - f"{tf_ver} installed. Please upgrade Tensorflow.") + torch_ver = get_torch_version() + if torch_ver < min_ver: + msg = (f"The minimum supported PyTorch is version {min_ver} but you have version " + f"{torch_ver} installed. Please upgrade PyTorch.") self._handle_import_error(msg) - if tf_ver > max_ver: - msg = (f"The maximum supported Tensorflow is version {max_ver} but you have version " - f"{tf_ver} installed. Please downgrade Tensorflow.") + if torch_ver > max_ver: + msg = (f"The maximum supported PyTorch is version {max_ver} but you have version " + f"{torch_ver} installed. Please downgrade PyTorch.") self._handle_import_error(msg) - logger.debug("Installed Tensorflow Version: %s", tf_ver) + logger.debug("Installed PyTorch Version: %s", torch_ver) @classmethod def _handle_import_error(cls, message: str) -> None: @@ -212,7 +180,6 @@ def execute_script(self, arguments: argparse.Namespace) -> None: arguments: :class:`argparse.Namespace` The command line arguments to be passed to the executing script. """ - set_system_verbosity(arguments.loglevel) is_gui = hasattr(arguments, "redirect_gui") and arguments.redirect_gui log_setup(arguments.loglevel, arguments.logfile, self._command, is_gui) success = False @@ -260,13 +227,14 @@ def _configure_backend(self, arguments: argparse.Namespace) -> None: setattr(arguments, "exclude_gpus", None) return + assert GPUStats is not None if arguments.exclude_gpus: if not all(idx.isdigit() for idx in arguments.exclude_gpus): logger.error("GPUs passed to the ['-X', '--exclude-gpus'] argument must all be " "integers.") sys.exit(1) arguments.exclude_gpus = [int(idx) for idx in arguments.exclude_gpus] - set_exclude_devices(arguments.exclude_gpus) + GPUStats().exclude_devices(arguments.exclude_gpus) if GPUStats().exclude_all_devices: msg = "Switching backend to CPU" @@ -274,3 +242,6 @@ def _configure_backend(self, arguments: argparse.Namespace) -> None: logger.info(msg) logger.debug("Executing: %s. PID: %s", self._command, os.getpid()) + + +__all__ = get_module_objects(__name__) diff --git a/lib/config.py b/lib/config.py deleted file mode 100644 index 66e6b369f5..0000000000 --- a/lib/config.py +++ /dev/null @@ -1,651 +0,0 @@ -#!/usr/bin/env python3 -""" Default configurations for faceswap. - Extends out :class:`configparser.ConfigParser` functionality by checking for default - configuration updates and returning data in it's correct format """ - -import gettext -import logging -import os -import sys -import textwrap - -from collections import OrderedDict -from configparser import ConfigParser -from dataclasses import dataclass -from importlib import import_module - -from lib.utils import full_path_split - -# LOCALES -_LANG = gettext.translation("lib.config", localedir="locales", fallback=True) -_ = _LANG.gettext - -OrderedDictSectionType = OrderedDict[str, "ConfigSection"] -OrderedDictItemType = OrderedDict[str, "ConfigItem"] - -logger = logging.getLogger(__name__) -ConfigValueType = bool | int | float | list[str] | str | None - - -@dataclass -class ConfigItem: - """ Dataclass for holding information about configuration items - - Parameters - ---------- - default: any - The default value for the configuration item - helptext: str - The helptext to be displayed for the configuration item - datatype: type - The type of the configuration item - rounding: int - The decimal places for floats or the step interval for ints for slider updates - min_max: tuple - The minumum and maximum value for the GUI slider for the configuration item - gui_radio: bool - ``True`` to display the configuration item in a Radio Box - fixed: bool - ``True`` if the item cannot be changed for existing models (training only) - group: str - The group that this configuration item belongs to in the GUI - """ - default: ConfigValueType - helptext: str - datatype: type - rounding: int - min_max: tuple[int, int] | tuple[float, float] | None - choices: str | list[str] - gui_radio: bool - fixed: bool - group: str | None - - -@dataclass -class ConfigSection: - """ Dataclass for holding information about configuration sections - - Parameters - ---------- - helptext: str - The helptext to be displayed for the configuration section - items: :class:`collections.OrderedDict` - Dictionary of configuration items for the section - """ - helptext: str - items: OrderedDictItemType - - -class FaceswapConfig(): - """ Config Items """ - def __init__(self, section: str | None, configfile: str | None = None) -> None: - """ Init Configuration - - Parameters - ---------- - section: str or ``None`` - The configuration section. ``None`` for all sections - configfile: str, optional - Optional path to a config file. ``None`` for default location. Default: ``None`` - """ - logger.debug("Initializing: %s", self.__class__.__name__) - self.configfile = self._get_config_file(configfile) - self.config = ConfigParser(allow_no_value=True) - self.defaults: OrderedDictSectionType = OrderedDict() - self.config.optionxform = str # type:ignore - self.section = section - - self.set_defaults() - self._handle_config() - logger.debug("Initialized: %s", self.__class__.__name__) - - @property - def changeable_items(self) -> dict[str, ConfigValueType]: - """ Training only. - Return a dict of config items with their set values for items - that can be altered after the model has been created """ - retval: dict[str, ConfigValueType] = {} - sections = [sect for sect in self.config.sections() if sect.startswith("global")] - all_sections = sections if self.section is None else sections + [self.section] - for sect in all_sections: - if sect not in self.defaults: - continue - for key, val in self.defaults[sect].items.items(): - if val.fixed: - continue - retval[key] = self.get(sect, key) - logger.debug("Alterable for existing models: %s", retval) - return retval - - def set_defaults(self) -> None: - """ Override for plugin specific config defaults - - Should be a series of self.add_section() and self.add_item() calls - - e.g: - - section = "sect_1" - self.add_section(section, - "Section 1 Information") - - self.add_item(section=section, - title="option_1", - datatype=bool, - default=False, - info="sect_1 option_1 information") - """ - raise NotImplementedError - - def _defaults_from_plugin(self, plugin_folder: str) -> None: - """ Scan the given plugins folder for config defaults.py files and update the - default configuration. - - Parameters - ---------- - plugin_folder: str - The folder to scan for plugins - """ - for dirpath, _, filenames in os.walk(plugin_folder): - default_files = [fname for fname in filenames if fname.endswith("_defaults.py")] - if not default_files: - continue - base_path = os.path.dirname(os.path.realpath(sys.argv[0])) - # Can't use replace as there is a bug on some Windows installs that lowers some paths - import_path = ".".join(full_path_split(dirpath[len(base_path):])[1:]) - plugin_type = import_path.rsplit(".", maxsplit=1)[-1] - for filename in default_files: - self._load_defaults_from_module(filename, import_path, plugin_type) - - def _load_defaults_from_module(self, - filename: str, - module_path: str, - plugin_type: str) -> None: - """ Load the plugin's defaults module, extract defaults and add to default configuration. - - Parameters - ---------- - filename: str - The filename to load the defaults from - module_path: str - The path to load the module from - plugin_type: str - The type of plugin that the defaults are being loaded for - """ - logger.debug("Adding defaults: (filename: %s, module_path: %s, plugin_type: %s", - filename, module_path, plugin_type) - module = os.path.splitext(filename)[0] - section = ".".join((plugin_type, module.replace("_defaults", ""))) - logger.debug("Importing defaults module: %s.%s", module_path, module) - mod = import_module(f"{module_path}.{module}") - self.add_section(section, mod._HELPTEXT) # type:ignore[attr-defined] # pylint:disable=protected-access # noqa:E501 - for key, val in mod._DEFAULTS.items(): # type:ignore[attr-defined] # pylint:disable=protected-access # noqa:E501 - self.add_item(section=section, title=key, **val) - logger.debug("Added defaults: %s", section) - - @property - def config_dict(self) -> dict[str, ConfigValueType]: - """ dict: Collate global options and requested section into a dictionary with the correct - data types """ - conf: dict[str, ConfigValueType] = {} - sections = [sect for sect in self.config.sections() if sect.startswith("global")] - if self.section is not None: - sections.append(self.section) - for sect in sections: - if sect not in self.config.sections(): - continue - for key in self.config[sect]: - if key.startswith(("#", "\n")): # Skip comments - continue - conf[key] = self.get(sect, key) - return conf - - def get(self, section: str, option: str) -> ConfigValueType: - """ Return a config item in it's correct format. - - Parameters - ---------- - section: str - The configuration section currently being processed - option: str - The configuration option currently being processed - - Returns - ------- - varies - The selected configuration option in the correct data format - """ - logger.debug("Getting config item: (section: '%s', option: '%s')", section, option) - datatype = self.defaults[section].items[option].datatype - - retval: ConfigValueType - if datatype == bool: - retval = self.config.getboolean(section, option) - elif datatype == int: - retval = self.config.getint(section, option) - elif datatype == float: - retval = self.config.getfloat(section, option) - elif datatype == list: - retval = self._parse_list(section, option) - else: - retval = self.config.get(section, option) - - if isinstance(retval, str) and retval.lower() == "none": - retval = None - logger.debug("Returning item: (type: %s, value: %s)", datatype, retval) - return retval - - def _parse_list(self, section: str, option: str) -> list[str]: - """ Parse options that are stored as lists in the config file. These can be space or - comma-separated items in the config file. They will be returned as a list of strings, - regardless of what the final data type should be, so conversion from strings to other - formats should be done explicitly within the retrieving code. - - Parameters - ---------- - section: str - The configuration section currently being processed - option: str - The configuration option currently being processed - - Returns - ------- - list - List of `str` selected items for the config choice. - """ - raw_option = self.config.get(section, option) - if not raw_option: - logger.debug("No options selected, returning empty list") - return [] - delimiter = "," if "," in raw_option else None - retval = [opt.strip().lower() for opt in raw_option.split(delimiter)] - logger.debug("Processed raw option '%s' to list %s for section '%s', option '%s'", - raw_option, retval, section, option) - return retval - - def _get_config_file(self, configfile: str | None) -> str: - """ Return the config file from the calling folder or the provided file - - Parameters - ---------- - configfile: str or ``None`` - Path to a config file. ``None`` for default location. - - Returns - ------- - str - The full path to the configuration file - """ - if configfile is not None: - if not os.path.isfile(configfile): - err = f"Config file does not exist at: {configfile}" - logger.error(err) - raise ValueError(err) - return configfile - filepath = sys.modules[self.__module__].__file__ - assert filepath is not None - dirname = os.path.dirname(filepath) - folder, fname = os.path.split(dirname) - retval = os.path.join(os.path.dirname(folder), "config", f"{fname}.ini") - logger.debug("Config File location: '%s'", retval) - return retval - - def add_section(self, title: str, info: str) -> None: - """ Add a default section to config file - - Parameters - ---------- - title: str - The title for the section - info: str - The helptext for the section - """ - logger.debug("Add section: (title: '%s', info: '%s')", title, info) - self.defaults[title] = ConfigSection(helptext=info, items=OrderedDict()) - - def add_item(self, # pylint:disable=too-many-arguments,too-many-positional-arguments - section: str | None = None, - title: str | None = None, - datatype: type = str, - default: ConfigValueType = None, - info: str | None = None, - rounding: int | None = None, - min_max: tuple[int, int] | tuple[float, float] | None = None, - choices: str | list[str] | None = None, - gui_radio: bool = False, - fixed: bool = True, - group: str | None = None) -> None: - """ Add a default item to a config section - - For int or float values, rounding and min_max must be set - This is for the slider in the GUI. The min/max values are not enforced: - rounding: sets the decimal places for floats or the step interval for ints. - min_max: tuple of min and max accepted values - - For str values choices can be set to validate input and create a combo box - in the GUI - - For list values, choices must be provided, and a multi-option select box will - be created - - is_radio is to indicate to the GUI that it should display Radio Buttons rather than - combo boxes for multiple choice options. - - The 'fixed' parameter is only for training configurations. Training configurations - are set when the model is created, and then reloaded from the state file. - Marking an item as fixed=False indicates that this value can be changed for - existing models, and will override the value saved in the state file with the - updated value in config. - - The 'Group' parameter allows you to assign the config item to a group in the GUI - - """ - logger.debug("Add item: (section: '%s', title: '%s', datatype: '%s', default: '%s', " - "info: '%s', rounding: '%s', min_max: %s, choices: %s, gui_radio: %s, " - "fixed: %s, group: %s)", section, title, datatype, default, info, rounding, - min_max, choices, gui_radio, fixed, group) - - choices = [] if not choices else choices - - assert (section is not None and - title is not None and - default is not None and - info is not None), ("Default config items must have a section, title, defult and " - "information text") - if not self.defaults.get(section, None): - raise ValueError(f"Section does not exist: {section}") - assert datatype in (str, bool, float, int, list), ( - f"'datatype' must be one of str, bool, float or int: {section} - {title}") - if datatype in (float, int) and (rounding is None or min_max is None): - raise ValueError("'rounding' and 'min_max' must be set for numerical options") - if isinstance(datatype, list) and not choices: - raise ValueError("'choices' must be defined for list based configuration items") - if choices != "colorchooser" and not isinstance(choices, (list, tuple)): - raise ValueError("'choices' must be a list or tuple or 'colorchooser") - - info = self._expand_helptext(info, choices, default, datatype, min_max, fixed) - self.defaults[section].items[title] = ConfigItem(default=default, - helptext=info, - datatype=datatype, - rounding=rounding or 0, - min_max=min_max, - choices=choices, - gui_radio=gui_radio, - fixed=fixed, - group=group) - - @classmethod - def _expand_helptext(cls, # pylint:disable=too-many-positional-arguments - helptext: str, - choices: str | list[str], - default: ConfigValueType, - datatype: type, - min_max: tuple[int, int] | tuple[float, float] | None, - fixed: bool) -> str: - """ Add extra helptext info from parameters """ - helptext += "\n" - if not fixed: - helptext += _("\nThis option can be updated for existing models.\n") - if datatype == list: - helptext += _("\nIf selecting multiple options then each option should be separated " - "by a space or a comma (e.g. item1, item2, item3)\n") - if choices and choices != "colorchooser": - helptext += _("\nChoose from: {}").format(choices) - elif datatype == bool: - helptext += _("\nChoose from: True, False") - elif datatype == int: - assert min_max is not None - cmin, cmax = min_max - helptext += _("\nSelect an integer between {} and {}").format(cmin, cmax) - elif datatype == float: - assert min_max is not None - cmin, cmax = min_max - helptext += _("\nSelect a decimal number between {} and {}").format(cmin, cmax) - helptext += _("\n[Default: {}]").format(default) - return helptext - - def _check_exists(self) -> bool: - """ Check that a config file exists - - Returns - ------- - bool - ``True`` if the given configuration file exists - """ - if not os.path.isfile(self.configfile): - logger.debug("Config file does not exist: '%s'", self.configfile) - return False - logger.debug("Config file exists: '%s'", self.configfile) - return True - - def _create_default(self) -> None: - """ Generate a default config if it does not exist """ - logger.debug("Creating default Config") - for name, section in self.defaults.items(): - logger.debug("Adding section: '%s')", name) - self.insert_config_section(name, section.helptext) - for item, opt in section.items.items(): - logger.debug("Adding option: (item: '%s', opt: '%s')", item, opt) - self._insert_config_item(name, item, opt.default, opt) - self.save_config() - - def insert_config_section(self, - section: str, - helptext: str, - config: ConfigParser | None = None) -> None: - """ Insert a section into the config - - Parameters - ---------- - section: str - The section title to insert - helptext: str - The help text for the config section - config: :class:`configparser.ConfigParser`, optional - The config parser object to insert the section into. ``None`` to insert it into the - default config. Default: ``None`` - """ - logger.debug("Inserting section: (section: '%s', helptext: '%s', config: '%s')", - section, helptext, config) - config = self.config if config is None else config - config.optionxform = str # type:ignore - helptext = self.format_help(helptext, is_section=True) - config.add_section(section) - config.set(section, helptext) - logger.debug("Inserted section: '%s'", section) - - def _insert_config_item(self, # pylint:disable=too-many-positional-arguments - section: str, - item: str, - default: ConfigValueType, - option: ConfigItem, - config: ConfigParser | None = None) -> None: - """ Insert an item into a config section - - Parameters - ---------- - section: str - The section to insert the item into - item: str - The name of the item to insert - default: ConfigValueType - The default value for the item - option: :class:`ConfigItem` - The configuration option to insert - config: :class:`configparser.ConfigParser`, optional - The config parser object to insert the section into. ``None`` to insert it into the - default config. Default: ``None`` - """ - logger.debug("Inserting item: (section: '%s', item: '%s', default: '%s', helptext: '%s', " - "config: '%s')", section, item, default, option.helptext, config) - config = self.config if config is None else config - config.optionxform = str # type:ignore - helptext = option.helptext - helptext = self.format_help(helptext, is_section=False) - config.set(section, helptext) - config.set(section, item, str(default)) - logger.debug("Inserted item: '%s'", item) - - @classmethod - def format_help(cls, helptext: str, is_section: bool = False) -> str: - """ Format comments for default ini file - - Parameters - ---------- - helptext: str - The help text to be formatted - is_section: bool, optional - ``True`` if the help text pertains to a section. ``False`` if it pertains to an item. - Default: ``True`` - - Returns - ------- - str - The formatted help text - """ - logger.debug("Formatting help: (helptext: '%s', is_section: '%s')", helptext, is_section) - formatted = "" - for hlp in helptext.split("\n"): - subsequent_indent = "\t\t" if hlp.startswith("\t") else "" - hlp = f"\t- {hlp[1:].strip()}" if hlp.startswith("\t") else hlp - formatted += textwrap.fill(hlp, - 100, - tabsize=4, - subsequent_indent=subsequent_indent) + "\n" - helptext = '# {}'.format(formatted[:-1].replace("\n", "\n# ")) # Strip last newline - if is_section: - helptext = helptext.upper() - else: - helptext = f"\n{helptext}" - logger.debug("formatted help: '%s'", helptext) - return helptext - - def _load_config(self) -> None: - """ Load values from config """ - logger.verbose("Loading config: '%s'", self.configfile) # type:ignore[attr-defined] - self.config.read(self.configfile, encoding="utf-8") - - def save_config(self) -> None: - """ Save a config file """ - logger.debug("Updating config at: '%s'", self.configfile) - with open(self.configfile, "w", encoding="utf-8", errors="replace") as f_cfgfile: - self.config.write(f_cfgfile) - logger.debug("Updated config at: '%s'", self.configfile) - - def _validate_config(self) -> None: - """ Check for options in default config against saved config - and add/remove as appropriate """ - logger.debug("Validating config") - if self._check_config_change(): - self._add_new_config_items() - self._check_config_choices() - logger.debug("Validated config") - - def _add_new_config_items(self) -> None: - """ Add new items to the config file """ - logger.debug("Updating config") - new_config = ConfigParser(allow_no_value=True) - for section_name, section in self.defaults.items(): - self.insert_config_section(section_name, section.helptext, new_config) - for item, opt in section.items.items(): - if section_name not in self.config.sections(): - logger.debug("Adding new config section: '%s'", section_name) - opt_value = opt.default - else: - opt_value = self.config[section_name].get(item, str(opt.default)) - self._insert_config_item(section_name, - item, - opt_value, - opt, - new_config) - self.config = new_config - self.config.optionxform = str # type:ignore - self.save_config() - logger.debug("Updated config") - - def _check_config_choices(self) -> None: - """ Check that config items are valid choices """ - logger.debug("Checking config choices") - for section_name, section in self.defaults.items(): - for item, opt in section.items.items(): - if not opt.choices: - continue - if opt.datatype == list: # Multi-select items - opt_values = self._parse_list(section_name, item) - if not opt_values: # No option selected - continue - if not all(val in opt.choices for val in opt_values): - invalid = [val for val in opt_values if val not in opt.choices] - valid = ", ".join(val for val in opt_values if val in opt.choices) - logger.warning("The option(s) %s are not valid selections for '%s': '%s'. " - "setting to: '%s'", invalid, section_name, item, valid) - self.config.set(section_name, item, valid) - else: # Single-select items - if opt.choices == "colorchooser": - continue - opt_value = self.config.get(section_name, item) - if opt_value.lower() == "none" and any(choice.lower() == "none" - for choice in opt.choices): - continue - if opt_value not in opt.choices: - default = str(opt.default) - logger.warning("'%s' is not a valid config choice for '%s': '%s'. " - "Defaulting to: '%s'", - opt_value, section_name, item, default) - self.config.set(section_name, item, default) - logger.debug("Checked config choices") - - def _check_config_change(self) -> bool: - """ Check whether new default items have been added or removed from the config file - compared to saved version - - Returns - ------- - bool - ``True`` if a config option has been added or removed - """ - if set(self.config.sections()) != set(self.defaults.keys()): - logger.debug("Default config has new section(s)") - return True - - for section_name, section in self.defaults.items(): - opts = list(section.items) - exists = [opt for opt in self.config[section_name].keys() - if not opt.startswith(("# ", "\n# "))] - if set(exists) != set(opts): - logger.debug("Default config has new item(s)") - return True - logger.debug("Default config has not changed") - return False - - def _handle_config(self) -> None: - """ Handle the config. - - Checks whether a config file exists for this section. If not then a default is created. - - Configuration choices are then loaded and validated - """ - logger.debug("Handling config: (section: %s, configfile: '%s')", - self.section, self.configfile) - if not self._check_exists(): - self._create_default() - self._load_config() - self._validate_config() - logger.debug("Handled config") - - -def generate_configs() -> None: - """ Generate config files if they don't exist. - - This script is run prior to anything being set up, so don't use logging - Generates the default config files for plugins in the faceswap config folder - """ - base_path = os.path.realpath(os.path.dirname(sys.argv[0])) - plugins_path = os.path.join(base_path, "plugins") - configs_path = os.path.join(base_path, "config") - for dirpath, _, filenames in os.walk(plugins_path): - if "_config.py" in filenames: - section = os.path.split(dirpath)[-1] - config_file = os.path.join(configs_path, f"{section}.ini") - if not os.path.exists(config_file): - mod = import_module(f"plugins.{section}._config") - mod.Config(None) # type:ignore[attr-defined] diff --git a/lib/config/__init__.py b/lib/config/__init__.py new file mode 100644 index 0000000000..c24ec1d2ab --- /dev/null +++ b/lib/config/__init__.py @@ -0,0 +1,4 @@ +#! /usr/env/bin/python3 +""" Config handling for Faceswap """ +from .objects import ConfigItem, ConfigValueType, GlobalSection +from .config import generate_configs, get_configs, FaceswapConfig diff --git a/lib/config/config.py b/lib/config/config.py new file mode 100644 index 0000000000..16de36811e --- /dev/null +++ b/lib/config/config.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +""" Default configurations for faceswap. Handles parsing and validating of Faceswap Configs and +interfacing with :class:`configparser.ConfigParser` """ +from __future__ import annotations + +import inspect +import logging +import os +import sys + +from importlib import import_module + +from lib.utils import full_path_split, get_module_objects, PROJECT_ROOT + +from .ini import ConfigFile +from .objects import ConfigItem, ConfigSection, GlobalSection + + +logger = logging.getLogger(__name__) + +_CONFIGS: dict[str, FaceswapConfig] = {} +""" dict[str, FaceswapConfig] : plugin group to FaceswapConfig mapping for all loaded configs """ + + +class FaceswapConfig(): + """ Config Items """ + def __init__(self, configfile: str | None = None) -> None: + """ Init Configuration + + Parameters + ---------- + configfile : str, optional + Optional path to a config file. ``None`` for default location. Default: ``None`` + """ + logger.debug("Initializing: %s", self.__class__.__name__) + + self._plugin_group = self._get_plugin_group() + + self._ini = ConfigFile(self._plugin_group, ini_path=configfile) + self.sections: dict[str, ConfigSection] = {} + """ dict[str, :class:`ConfigSection`] : The Faceswap config sections and options """ + + self._set_defaults() + self._ini.on_load(self.sections) + _CONFIGS[self._plugin_group] = self + + logger.debug("Initialized: %s", self.__class__.__name__) + + def _get_plugin_group(self) -> str: + """ Obtain the name of the plugin group based on the child module's folder path + + Returns + ------- + str + The plugin group for this Config object + """ + mod_split = self.__module__.split(".") + mod_name = mod_split[-1] + retval = mod_name.rsplit("_", maxsplit=1)[0] + logger.debug("Got plugin group '%s' from module '%s'", + retval, self.__module__) + # Sanity check in case of defaults config file name/location changes + parent = mod_split[-2] + assert mod_name == f"{parent}_config" + return retval + + def add_section(self, title: str, info: str) -> None: + """ Add a default section to config file + + Parameters + ---------- + title : str + The title for the section + info : str + The helptext for the section + """ + logger.debug("Add section: (title: '%s', info: '%s')", title, info) + self.sections[title] = ConfigSection(helptext=info, options={}) + + def add_item(self, section: str, title: str, config_item: ConfigItem) -> None: + """ Add a default item to a config section + + Parameters + ---------- + section : str + The section of the config to add the item to + title : str + The name of the config item + config_item : :class:`~lib.config.objects.ConfigItem` + The default config item object to add to the config + """ + logger.debug("Add item: (section: '%s', item: %s", section, config_item) + self.sections[section].options[title] = config_item + + def _import_defaults_from_module(self, + filename: str, + module_path: str, + plugin_type: str) -> None: + """ Load the plugin's defaults module, extract defaults and add to default configuration. + + Parameters + ---------- + filename : str + The filename to load the defaults from + module_path : str + The path to load the module from + plugin_type : str + The type of plugin that the defaults are being loaded for + """ + logger.debug("Adding defaults: (filename: %s, module_path: %s, plugin_type: %s", + filename, module_path, plugin_type) + module = os.path.splitext(filename)[0] + section = ".".join((plugin_type, module.replace("_defaults", ""))) + logger.debug("Importing defaults module: %s.%s", module_path, module) + mod = import_module(f"{module_path}.{module}") + self.add_section(section, mod.HELPTEXT) # type:ignore[attr-defined] + for key, val in vars(mod).items(): + if isinstance(val, ConfigItem): + self.add_item(section=section, title=key, config_item=val) + logger.debug("Added defaults: %s", section) + + def _defaults_from_plugin(self, plugin_folder: str) -> None: + """ Scan the given plugins folder for config defaults.py files and update the + default configuration. + + Parameters + ---------- + plugin_folder : str + The folder to scan for plugins + """ + for dirpath, _, filenames in os.walk(plugin_folder): + default_files = [fname for fname in filenames if fname.endswith("_defaults.py")] + if not default_files: + continue + base_path = os.path.dirname(os.path.realpath(sys.argv[0])) + # Can't use replace as there is a bug on some Windows installs that lowers some paths + import_path = ".".join(full_path_split(dirpath[len(base_path):])[1:]) + plugin_type = import_path.rsplit(".", maxsplit=1)[-1] + for filename in default_files: + self._import_defaults_from_module(filename, import_path, plugin_type) + + def set_defaults(self, helptext: str = "") -> None: + """ Override for plugin specific config defaults. + + This method should always be overriden to add the help text for the global plugin group. + If `helptext` is not provided, then it is assumed that there is no global section for this + plugin group. + + The default action will parse the child class' module for + :class:`~lib.config.objects.ConfigItem` objects and add them to this plugin group's + "global" section of :attr:`sections`. + + The name of each config option will be the variable name found in the module. + + It will then parse the child class' module for subclasses of + :class:`~lib.config.objects.GlobalSection` objects and add each of these sections to this + plugin group's :attr:`sections`, adding any :class:`~lib.config.objects.ConfigItem` within + the GlobalSection to that sub-section. + + The section name will be the name of the GlobalSection subclass, lowercased + + Parameters + ---------- + helptext : str + The help text to display for the plugin group + + Raises + ------ + ValueError + If the plugin group's help text has not been provided + """ + section = "global" + logger.debug("[%s:%s] Adding defaults", self._plugin_group, section) + + if not helptext: + logger.debug("No help text provided for '%s'. Not creating global section", + self.__module__) + return + + self.add_section(section, helptext) + + for key, val in vars(sys.modules[self.__module__]).items(): + if isinstance(val, ConfigItem): + self.add_item(section=section, title=key, config_item=val) + logger.debug("[%s:%s] Added defaults", self._plugin_group, section) + + # Add global sub-sections + for key, val in vars(sys.modules[self.__module__]).items(): + if inspect.isclass(val) and issubclass(val, GlobalSection) and val != GlobalSection: + section_name = f"{section}.{key.lower()}" + self.add_section(section_name, val.helptext) + for opt_name, opt in val.__dict__.items(): + if isinstance(opt, ConfigItem): + self.add_item(section=section_name, title=opt_name, config_item=opt) + + def _set_defaults(self) -> None: + """Load the plugin's default values, set the object names and order the sections, global + first then alphabetically.""" + self.set_defaults() + for section_name, section in self.sections.items(): + for opt_name, opt in section.options.items(): + opt.set_name(f"{self._plugin_group}.{section_name}.{opt_name}") + + global_keys = sorted(s for s in self.sections if s.startswith("global")) + remaining_keys = sorted(s for s in self.sections if not s.startswith("global")) + ordered = {k: self.sections[k] for k in global_keys + remaining_keys} + + self.sections = ordered + + def save_config(self) -> None: + """Update the ini file with the currently stored app values and save the config file.""" + self._ini.update_from_app(self.sections) + + +def get_configs() -> dict[str, FaceswapConfig]: + """ Get all of the FaceswapConfig options. Loads any configs that have not been loaded and + return a dictionary of all configs. + + Returns + ------- + dict[str, :class:`FaceswapConfig`] + All of the loaded faceswap config objects + """ + generate_configs(force=True) + return _CONFIGS + + +def generate_configs(force: bool = False) -> None: + """ Generate config files if they don't exist. + + This script is run prior to anything being set up, so don't use logging + Generates the default config files for plugins in the faceswap config folder + + Logic: + - Scan the plugins path for files named _config.py> + - Import the discovered module and look for instances of FaceswapConfig + - If exists initialize the class + + Parameters + ---------- + force : bool + Force the loading of all plugin configs even if their .ini files pre-exist + """ + configs_path = os.path.join(PROJECT_ROOT, "config") + plugins_path = os.path.join(PROJECT_ROOT, "plugins") + for dirpath, _, filenames in os.walk(plugins_path): + relative_path = dirpath.replace(PROJECT_ROOT, "")[1:] + if len(full_path_split(relative_path)) > 2: # don't dig further than 1 folder deep + continue + plugin_group = os.path.basename(dirpath) + filename = f"{plugin_group}_config.py" + if filename not in filenames: + continue + + if plugin_group in _CONFIGS: + continue + + config_file = os.path.join(configs_path, f"{plugin_group}.ini") + if not os.path.exists(config_file) or force: + modname = os.path.splitext(filename)[0] + modpath = os.path.join(dirpath.replace(PROJECT_ROOT, ""), + modname)[1:].replace(os.sep, ".") + mod = import_module(modpath) + for obj in vars(mod).values(): + if (inspect.isclass(obj) + and issubclass(obj, FaceswapConfig) + and obj != FaceswapConfig): + obj() + + +__all__ = get_module_objects(__name__) diff --git a/lib/config/ini.py b/lib/config/ini.py new file mode 100644 index 0000000000..e48c91862c --- /dev/null +++ b/lib/config/ini.py @@ -0,0 +1,410 @@ +#! /usr/env/bin/python3 +""" Handles interfacing between Faceswap Configs and ConfigParser .ini files """ +from __future__ import annotations + +import logging +import os +import textwrap +import typing as T + +from configparser import ConfigParser + +from lib.logger import parse_class_init +from lib.utils import get_module_objects, PROJECT_ROOT + +if T.TYPE_CHECKING: + from .objects import ConfigSection, ConfigValueType + +logger = logging.getLogger(__name__) + + +class ConfigFile(): + """ Handles the interfacing between saved faceswap .ini configs and internal Config objects + + Parameters + ---------- + plugin_group : str + The plugin group that is requesting a config file + ini_path : str | None, optional + Optional path to a .ini config file. ``None`` for default location. Default: ``None`` + """ + def __init__(self, plugin_group: str, ini_path: str | None = None) -> None: + parse_class_init(locals()) + self._plugin_group = plugin_group + self._file_path = self._get_config_path(ini_path) + self._parser = self._get_new_configparser() + if self._exists: # Load or create new + self.load() + + @property + def _exists(self) -> bool: + """ bool : ``True`` if the config.ini file exists """ + return os.path.isfile(self._file_path) + + def _get_config_path(self, ini_path: str | None) -> str: + """ Return the path to the config file from the calling folder or the provided file + + Parameters + ---------- + ini_path : str | None + Path to a config ini file. ``None`` for default location. + + Returns + ------- + str + The full path to the configuration file + """ + if ini_path is not None: + if not os.path.isfile(ini_path): + err = f"Config file does not exist at: {ini_path}" + logger.error(err) + raise ValueError(err) + return ini_path + + retval = os.path.join(PROJECT_ROOT, "config", f"{self._plugin_group}.ini") + logger.debug("[%s] Config File location: '%s'", os.path.basename(retval), retval) + return retval + + def _get_new_configparser(self) -> ConfigParser: + """ Obtain a fresh ConfigParser object and set it to case-sensitive + + Returns + ------- + :class:`configparser.ConfigParser` + A new ConfigParser object set to case-sensitive + """ + retval = ConfigParser(allow_no_value=True) + retval.optionxform = str # type:ignore[assignment,method-assign] + return retval + + # I/O + def load(self) -> None: + """ Load values from the saved config ini file into our Config object """ + logger.verbose("[%s] Loading config: '%s'", # type:ignore[attr-defined] + self._plugin_group, self._file_path) + self._parser.read(self._file_path, encoding="utf-8") + + def save(self) -> None: + """ Save a config file """ + logger.debug("[%s] %s config: '%s'", + self._plugin_group, "Updating" if self._exists else "Saving", self._file_path) + # TODO in python >= 3.14 this will error when there are delimiters in the comments + with open(self._file_path, "w", encoding="utf-8", errors="replace") as f_cfgfile: + self._parser.write(f_cfgfile) + logger.info("[%s] Saved config: '%s'", self._plugin_group, self._file_path) + + # .ini vs Faceswap Config checking + def _sections_synced(self, app_config: dict[str, ConfigSection]) -> bool: + """ Validate that all of the sections within the application config match with all of the + sections in the ini file + + Parameters + ---------- + app_config : dict[str, :class:`ConfigSection`] + The latest configuration settings from the application. Section name is key + + Returns + ------- + bool + ``True`` if application sections and saved ini sections match + """ + given_sections = set(app_config) + loaded_sections = set(self._parser.sections()) + retval = given_sections == loaded_sections + if not retval: + logger.debug("[%s] Config sections are not synced: (app: %s, ini: %s)", + self._plugin_group, sorted(given_sections), sorted(loaded_sections)) + return retval + + def _options_synced(self, app_config: dict[str, ConfigSection]) -> bool: + """ Validate that all of the option names within the application config match with all of + the option names in the ini file + + Note + ---- + As we need to write a new config anyway, we return on the first change found + + Parameters + ---------- + app_config : dict[str, :class:`ConfigSection`] + The latest configuration settings from the application. Section name is key + + Returns + ------- + bool + ``True`` if application option names match with saved ini option names + """ + for name, section in app_config.items(): + given_opts = set(opt for opt in section.options) + loaded_opts = set(self._parser[name].keys()) + if given_opts != loaded_opts: + logger.debug("[%s:%s] Config options are not synced: (app: %s, ini: %s)", + self._plugin_group, name, sorted(given_opts), sorted(loaded_opts)) + return False + return True + + def _values_synced(self, app_section: ConfigSection, section: str) -> bool: + """ Validate that all of the option values within the application config match with all of + the option values in the ini file + + Parameters + ---------- + app_section : :class:`ConfigSection` + The latest configuration settings from the application for the given section + section : str + The section name to check the option values for + + Returns + ------- + bool + ``True`` if application option values match with saved ini option values + """ + # Need to also pull in keys as False is omitted from the set with just values which can + # cause edge-case false negatives + given_vals = set((k, v.ini_value) for k, v in app_section.options.items()) + loaded_vals = set((k, v) for k, v in self._parser[section].items()) + retval = given_vals == loaded_vals + if not retval: + logger.debug("[%s:%s] Config values are not synced: (app: %s, ini: %s)", + self._plugin_group, section, sorted(given_vals), sorted(loaded_vals)) + return retval + + def _is_synced_structure(self, app_config: dict[str, ConfigSection]) -> bool: + """ Validate that all the given sections and option names within the application config + match with their corresponding items in the save .ini file + + Parameters + ---------- + app_config: dict[str, :class:`ConfigSection`] + The latest configuration settings from the application. Section name is key + + Returns + ------- + bool + ``True`` if the app config and saved ini config structure match + """ + if not self._sections_synced(app_config): + return False + if not self._options_synced(app_config): + return False + + logger.debug("[%s] Configs are synced", self._plugin_group) + return True + + # .ini file insertion + def format_help(self, helptext: str, is_section: bool = False) -> str: + """ Format comments for insertion into a config ini file + + Parameters + ---------- + helptext : str + The help text to be formatted + is_section : bool, optional + ``True`` if the help text pertains to a section. ``False`` if it pertains to an option. + Default: ``True`` + + Returns + ------- + str + The formatted help text + """ + logger.debug("[%s] Formatting help: (helptext: '%s', is_section: '%s')", + self._plugin_group, helptext, is_section) + formatted = "" + for hlp in helptext.split("\n"): + subsequent_indent = "\t\t" if hlp.startswith("\t") else "" + hlp = f"\t- {hlp[1:].strip()}" if hlp.startswith("\t") else hlp + formatted += textwrap.fill(hlp, + 100, + tabsize=4, + subsequent_indent=subsequent_indent) + "\n" + helptext = '# {}'.format(formatted[:-1].replace("\n", "\n# ")) # Strip last newline + helptext = helptext.upper() if is_section else f"\n{helptext}" + return helptext + + def _insert_section(self, section: str, helptext: str, config: ConfigParser) -> None: + """ Insert a section into the config + + Parameters + ---------- + section : str + The section title to insert + helptext : str + The help text for the config section + config : :class:`configparser.ConfigParser` + The config parser object to insert the section into. + """ + logger.debug("[%s:%s] Inserting section: (helptext: '%s', config: '%s')", + self._plugin_group, section, helptext, config) + helptext = self.format_help(helptext, is_section=True) + config.add_section(section) + config.set(section, helptext) + + def _insert_option(self, + section: str, + name: str, + helptext: str, + value: str, + config: ConfigParser) -> None: + """ Insert an option into a config section + + Parameters + ---------- + section : str + The section to insert the option into + name : str + The name of the option to insert + helptext : str + The help text for the option + value : str + The value for the option + config : :class:`configparser.ConfigParser` + The config parser object to insert the option into + """ + logger.debug( + "[%s:%s] Inserting option: (name: '%s', helptext: %s, value: '%s', config: '%s')", + self._plugin_group, section, name, helptext, value, config) + helptext = self.format_help(helptext, is_section=False) + config.set(section, helptext) + config.set(section, name, value) + + def _sync_from_app(self, app_config: dict[str, ConfigSection]) -> None: + """ Update the saved config.ini file from the values stored in the application config + + Existing options keep their saved values as per the .ini files. New options are added with + their application defined default value. Options in the .ini file not in application + provided config are removed. + + Note + ---- + A new configuration object is created as comments are stripped from the loaded ini files. + + Parameters + ---------- + app_config: dict[str, :class:`ConfigSection`] + The latest configuration settings from the application. Section name is key + """ + logger.debug("[%s] Syncing from app", self._plugin_group) + parser = self._get_new_configparser() if self._exists else self._parser + for section_name, section in app_config.items(): + self._insert_section(section_name, section.helptext, parser) + for name, opt in section.options.items(): + + value = self._parser.get(section_name, name, fallback=None) + if value is None: + value = opt.ini_value + logger.debug( + "[%s:%s] Setting default value for non-existent config option '%s': '%s'", + self._plugin_group, section_name, name, value) + + self._insert_option(section_name, name, opt.helptext, value, parser) + + if parser != self._parser: + self._parser = parser + + self.save() + + # .ini extraction + def _get_converted_value(self, section: str, option: str, datatype: type) -> ConfigValueType: + """ Return a config item from the .ini file in it's correct type. + + Parameters + ---------- + section : str + The configuration section to obtain the config option for + option : str + The configuration option to obtain the converted value for + datatype : type + The type to return the value as + + Returns + ------- + bool | int | float | list[str] | str + The selected configuration option in the correct data format + """ + logger.debug("[%s:%s] Getting config item: (option: '%s', datatype: %s)", + self._plugin_group, section, option, datatype) + + assert datatype in (bool, int, float, str, list), ( + f"Expected (bool, int, float, str, list). Got {datatype}") + + retval: ConfigValueType + if datatype == bool: + retval = self._parser.getboolean(section, option) + elif datatype == int: + retval = self._parser.getint(section, option) + elif datatype == float: + retval = self._parser.getfloat(section, option) + else: + retval = self._parser.get(section, option) + + logger.debug("[%s:%s] Got config item: (value: %s, type: %s)", + self._plugin_group, section, retval, type(retval)) + return retval + + def _sync_to_app(self, app_config: dict[str, ConfigSection]) -> None: + """ Update the values in the application config to those loaded from the saved config.ini. + + Parameters + ---------- + app_config: dict[str, :class:`ConfigSection`] + The latest configuration settings from the application. Section name is key + """ + logger.debug("[%s] Syncing to app", self._plugin_group) + for section_name, section in app_config.items(): + if self._values_synced(section, section_name): + continue + for opt_name, opt in section.options.items(): + if section_name not in self._parser or opt_name not in self._parser[section_name]: + logger.debug("[%s:%s] Skipping new option: '%s'", + self._plugin_group, section_name, opt_name) + continue + + ini_opt = self._parser[section_name][opt_name] + if opt.ini_value != ini_opt: + logger.debug("[%s:%s] Updating '%s' from '%s' to '%s'", + self._plugin_group, section_name, + opt_name, ini_opt, opt.ini_value) + opt.set(self._get_converted_value(section_name, opt_name, opt.datatype)) + + # .ini insertion and extraction + def on_load(self, app_config: dict[str, ConfigSection]) -> None: + """ Check whether there has been any change between the current application config and + the loaded ini config. If so, update the relevant object(s) appropriately. This check will + also create new config.ini files if they do not pre-exist + + Parameters + ---------- + app_config : dict[str, :class:`ConfigSection`] + The latest configuration settings from the application. Section name is key + """ + if not self._exists: + logger.debug("[%s] Creating new ini file", self._plugin_group) + self._sync_from_app(app_config) + + if not self._is_synced_structure(app_config): + self._sync_from_app(app_config) + + self._sync_to_app(app_config) + + def update_from_app(self, app_config: dict[str, ConfigSection]) -> None: + """ Update the config.ini file to those values that are currently in Faceswap's app + config + + Parameters + ---------- + app_config : dict[str, :class:`ConfigSection`] + The latest configuration settings from the application. Section name is key + """ + logger.debug("[%s] Updating saved config", self._plugin_group) + parser = self._get_new_configparser() if self._exists else self._parser + for section_name, section in app_config.items(): + self._insert_section(section_name, section.helptext, parser) + for name, opt in section.options.items(): + self._insert_option(section_name, name, opt.helptext, opt.ini_value, parser) + if parser != self._parser: + self._parser = parser + self.save() + + +__all__ = get_module_objects(__name__) diff --git a/lib/config/objects.py b/lib/config/objects.py new file mode 100644 index 0000000000..d496c56904 --- /dev/null +++ b/lib/config/objects.py @@ -0,0 +1,463 @@ +#! /usr/env/bin/python3 +""" Dataclass objects for holding and validating Faceswap Config items """ +from __future__ import annotations + +import gettext +import logging +from typing import (Any, cast, Generic, get_args, get_origin, get_type_hints, + Literal, TypeVar, Union) +import types + +from dataclasses import dataclass, field + +from lib.utils import get_module_objects + + +# LOCALES +_LANG = gettext.translation("lib.config", localedir="locales", fallback=True) +_ = _LANG.gettext + + +logger = logging.getLogger(__name__) +ConfigValueType = bool | int | float | list[str] | str +T = TypeVar("T") + + +# TODO allow list items other than strings +@dataclass +class ConfigItem(Generic[T]): # pylint:disable=too-many-instance-attributes + """ A dataclass for storing config items loaded from config.ini files and dynamically assigning + and validating that the correct datatype is used. + + The value loaded from the .ini config file can be accessed with either: + + >>> conf.value + >>> conf() + >>> conf.get() + + Parameters + ---------- + datatype : type + A python type class. This limits the type of data that can be provided in the .ini file + and ensures that the value is returned to faceswap is correct. Valid datatypes are: + `int`, `float`, `str`, `bool` or `list`. Note that `list` items must all be strings. + default : Any + The default value for this option. It must be of the same type as :attr:`datatype`. + group : str + The group that this config item exists within in the config section + info : str + A description of what this option does. + choices : list[str] | Literal["colorchooser"], optional + If this option's datatype is a `str` then valid selections can be defined here, empty list + for any value. If the option's datatype is a `list`, then this option must be populated + with the valid selections. This validates the option and also enables a combobox / radio + option in the GUI. If the default value is a hex color value, then this should be the + literal "colorchooser" to present a color choosing interface in the GUI. Ignored for all + other datatypes + Default: [] (empty list: no options) + gui_radio : bool, optional + If :attr:`choices` are defined, this indicates that the GUI should use radio buttons rather + than a combobox to display this option. Default: ``False`` + min_max : tuple[int | float, int | float] | None, optional + For `int` and `float` :attr:`datatype` this is required otherwise it is ignored. Should be + a tuple of min and max accepted values of the same datatype as the option value. This is + used for controlling the GUI slider range. Values are not enforced. Default: ``None`` + rounding : int | None, optional + For `int` and `float :attr:datatypes this is required to be > 0 otherwise it is ignored. + Used for the GUI slider. For `float`, this is the number of decimal places to display. For + `int` this is the step size. Default: `-1` (ignored) + fixed : bool, optional + [train only]. Training configurations are fixed when the model is created, and then + reloaded from the state file. Marking an item as fixed=``False`` indicates that this value + can be changed for existing models, and will override the value saved in the state file + with the updated value in config. Default: ``True`` + """ + datatype: type[T] + """ type : A python type class. The datatype of the config value. One of `int`, `float`, `str`, + `bool` or `list`. `list` will only contain `str` items """ + default: T + """ Any : The default value for this option. It is of the same type as :attr:`datatype` """ + group: str + """ str : The group that this config option belongs to """ + info: str + """ str : A description of what this option does """ + choices: list[str] | Literal["colorchooser"] = field(default_factory=list) + """ list[str] | Literal["colorchooser"]: If this option's datatype is a `str` then valid + selections may be defined here, Empty list if any value is valid. If the datatype is a `list` + then valid choices will be populated here. If the default value is a hex color code, then the + literal "colorchooser" will display a color choosing interface in the GUI. """ + gui_radio: bool = False + """ bool : indicates that the GUI should use radio buttons rather than a combobox to display + this option if :attr:`choices` is populated """ + min_max: tuple[T, T] | None = None + """ tuple[int | float, int | float] | None : For `int` and `float` :attr:`datatype` this will + be populated otherwise it will be ``None``. Used for controlling the GUI slider range. Values + are not enforced. """ + rounding: int = -1 + """ int : For `int` and `float` :attr:`datatypes` this will be > 0 otherwise it will be `-1`. + Used for the GUI slider. For `float`, this is the number of decimal places to display. For + `int` this is the step size. """ + fixed: bool = True + """ bool : Only used for train.model configurations. Options marked as fixed=``False`` + indicates that this value can be changed for existing models, otherwise the option set when the + model commenced training is fixed and cannot be changed. Default: ``True`` """ + _value: T = field(init=False) + """ Any : The value of the config item of type :attr:`datatype`""" + _name: str = field(init=False) + """ str: The option name for this object. Set when the config is first loaded """ + + @property + def helptext(self) -> str: + """ str | Description of the config option with additional formating and helptext added + from the item parameters """ + retval = f"{self.info}\n" + if not self.fixed: + retval += _("\nThis option can be updated for existing models.\n") + if self.datatype == list: + retval += _("\nIf selecting multiple options then each option should be separated " + "by a space or a comma (e.g. item1, item2, item3)\n") + if self.choices and self.choices != "colorchooser": + retval += _("\nChoose from: {}").format(self.choices) + elif self.datatype == bool: + retval += _("\nChoose from: True, False") + elif self.datatype == int: + assert self.min_max is not None + cmin, cmax = self.min_max + retval += _("\nSelect an integer between {} and {}").format(cmin, cmax) + elif self.datatype == float: + assert self.min_max is not None + cmin, cmax = self.min_max + retval += _("\nSelect a decimal number between {} and {}").format(cmin, cmax) + default = ", ".join(self.default) if isinstance(self.default, list) else self.default + retval += _("\n[Default: {}]").format(default) + return retval + + @property + def value(self) -> T: + """ Any : The config value for this item loaded from the config .ini file. String values + will always be lowercase, regardless of what is loaded from Config """ + retval = self._value + if isinstance(self._value, str): + retval = cast(T, self._value.lower()) + if isinstance(self._value, list): + retval = cast(T, [x.lower() for x in self._value]) + return retval + + @property + def ini_value(self) -> str: + """ str : The current value of the ConfigItem as a string for writing to a .ini file """ + if isinstance(self._value, list): + return ", ".join(str(x) for x in self._value) + return str(self._value) + + @property + def name(self) -> str: + """str: The name associated with this option """ + return self._name + + def _validate_type(self, # pylint:disable=too-many-return-statements + expected_type: Any, + attr: Any, + depth=1) -> bool: + """ Validate that provided types are correct when this Dataclass is initialized + + Parameters + ---------- + expected_type : Any + The expected data type for the given attribute + attr : Any + The attribute to test for correctness + depth : int, optional + The current recursion depth + + Returns + ------- + bool + ``True`` if the given attribute is a valid datatype + + Raises + ------ + AssertionError + On explicit data type failure + ValueError + On unhandled data type failure + """ + value = getattr(self, attr) + attr_type = type(value) + expected_type = self.datatype if expected_type == T else expected_type # type:ignore[misc] + + if attr_type is expected_type: + return True + + if attr == "datatype": + assert value in (str, bool, float, int, list), ( + "'datatype' must be one of str, bool, float, int or list. Got {value}") + return True + + if expected_type == T: # type:ignore[misc] + assert attr_type == self.datatype, ( + f"'{attr}' expected: {self.datatype}. Got: {attr_type}") + return True + + if get_origin(expected_type) is Literal: + return value in get_args(expected_type) + + if get_origin(expected_type) in (Union, types.UnionType): + for subtype in get_args(expected_type): + if self._validate_type(subtype, attr, depth=depth + 1): + return True + + if get_origin(expected_type) in (list, tuple) and attr_type in (list, tuple): + sub_expected = [self.datatype if v == T # type:ignore[misc] + else v for v in get_args(expected_type)] + return set(type(v) for v in value).issubset(sub_expected) + + if depth == 1: + raise ValueError(f"'{attr}' expected: {expected_type}. Got: {attr_type}") + + return False + + def _validate_required(self) -> None: + """ Validate that required parameters are populated + + Raises + ------ + ValueError + If any required parameters are empty + """ + if not self.group: + raise ValueError("A group must be provided") + if not self.info: + raise ValueError("Option info must me provided") + + def _validate_choices(self) -> None: + """ Validate that choices have been used correctly + + Raises + ------ + ValueError + If any choices options have not been populated correctly + """ + if self.choices == "colorchooser": + if not isinstance(self.default, str): + raise ValueError(f"Config Item default must be a string when selecting " + f"choice='colorchooser'. Got {type(self.default)}") + if not self.default.startswith("#") or len(self.default) != 7: + raise ValueError(f"Hex color codes should start with a '#' and be 6 " + f"characters long. Got: '{self.default}'") + elif self.choices and isinstance(self.default, str) and self.default not in self.choices: + raise ValueError(f"Config item default value '{self.default}' must exist in " + f"in choices {self.choices}") + + if isinstance(self.choices, list) and self.choices: + unique_choices = set(x.lower() for x in self.choices) + if len(unique_choices) != len(self.choices): + raise ValueError("Config item choices must be a unique list") + if isinstance(self.default, list): + defaults = set(x.lower() for x in self.default) + else: + assert isinstance(self.default, str), type(self.default) + defaults = {self.default.lower()} + if not defaults.issubset(unique_choices): + raise ValueError(f"Config item default {self.default} must exist in choices " + f"{self.choices}") + + if not self.choices and isinstance(self.default, list): + raise ValueError("Config item of type list must have choices defined") + + def _validate_numeric(self) -> None: + """ Validate that float and int values have been set correctly + + Raises + ------ + ValueError + If any float or int options have not been configured correctly + """ + # NOTE: Have to include datatype filter in next check to exclude bools + if self.datatype in (float, int) and isinstance(self.default, (float, int)): + if self.rounding <= 0: + raise ValueError(f"Config Item rounding must be a positive number for " + f"datatypes float and int. Got {self.rounding}") + if self.min_max is None or len(self.min_max) != 2: + raise ValueError(f"Config Item min_max must be a tuple of (, " + f") values. Got {self.min_max}") + + def __post_init__(self) -> None: + """ Validate and type check that the given parameters are valid and set the default value. + + Raises + ------ + ValueError + If the Dataclass fails validation checks + """ + self._name = "" + self._value = self.default + try: + for attr, dtype in get_type_hints(self.__class__).items(): + self._validate_type(dtype, attr) + except (AssertionError, ValueError) as err: + raise ValueError(f"Config item failed type checking: {str(err)}") from err + + self._validate_required() + self._validate_choices() + self._validate_numeric() + + def get(self) -> T: + """ Obtain the currently stored configuration value + + Returns + ------- + Any + The config value for this item loaded from the config .ini file. String values will + always be lowecase, regardless of what is loaded from Config """ + return self.value + + def _parse_list(self, value: str | list[str]) -> list[str]: + """ Parse inbound list values. These can be space/comma-separated strings or a list. + + Parameters + ---------- + value : str | list[str] + The inbound value to be converted to a list + + Returns + ------- + list[str] + List of strings representing the inbound values. + """ + if not value: + return [] + if isinstance(value, list): + return [str(x) for x in value] + delimiter = "," if "," in value else None + retval = list(set(x.strip() for x in value.split(delimiter))) + logger.debug("[%s] Processed str value '%s' to unique list %s", self._name, value, retval) + return retval + + def _validate_selection(self, value: str | list[str]) -> str | list[str]: + """ Validate that the given value is valid within the stored choices + + Parameters + ---------- + str | list[str] + The inbound config value to validate + + Returns + ------- + bool + ``True`` if the selected value is a valid choice + """ + assert isinstance(self.choices, list) + choices = [x.lower() for x in self.choices] + logger.debug("[%s] Checking config choices", self._name) + + if isinstance(value, str): + if value.lower() not in choices: + logger.warning("[%s] '%s' is not a valid config choice. Defaulting to '%s'", + self._name, value, self.default) + return cast(str, self.default) + return value + + if all(x.lower() in choices for x in value): + return value + + valid = [x for x in value if x.lower() in choices] + valid = valid if valid else cast(list[str], self.default) + invalid = [x for x in value if x.lower() not in choices] + logger.warning("[%s] The option(s) %s are not valid selections. Setting to: %s", + self._name, invalid, valid) + + return valid + + def set(self, value: T) -> None: + """ Set the item's option value + + Parameters + ---------- + value : Any + The value to set this item to. Must be of type :attr:`datatype` + + Raises + ------ + ValueError + If the given value does not pass type and content validation checks + """ + if not self._name: + raise ValueError("The name of this object should have been set before any value is" + "added") + + if self.datatype is list: + if not isinstance(value, (str, list)): + raise ValueError(f"[{self._name}] List values should be set as a Str or List. Got " + f"{type(value)} ({value})") + value = cast(T, self._parse_list(value)) + + if not isinstance(value, self.datatype): + raise ValueError( + f"[{self._name}] Expected {self.datatype} got {type(value)} ({value})") + + if isinstance(self.choices, list) and self.choices: + assert isinstance(value, (list, str)) + value = cast(T, self._validate_selection(value)) + + if self.choices == "colorchooser": + assert isinstance(value, str) + if not value.startswith("#") or len(value) != 7: + raise ValueError(f"Hex color codes should start with a '#' and be 6 " + f"characters long. Got: '{value}'") + + self._value = value + + def set_name(self, name: str) -> None: + """ Set the logging name for this object for display purposes + + Parameters + ---------- + name : str + The name to assign to this option + """ + logger.debug("Setting name to '%s'", name) + assert isinstance(name, str) and name + self._name = name + + def __call__(self) -> T: + """ Obtain the currently stored configuration value + + Returns + ------- + Any + The config value for this item loaded from the config .ini file. String values will + always be lowecase, regardless of what is loaded from Config """ + return self.value + + +@dataclass +class ConfigSection: + """ Dataclass for holding information about configuration sections and the contained + configuration items + + Parameters + ---------- + helptext : str + The helptext to be displayed for the configuration section + options : dict[str, :class:`ConfigItem`] + Dictionary of configuration option name to the options for the section + """ + helptext: str + options: dict[str, ConfigItem] + + +@dataclass +class GlobalSection: + """ A dataclass for holding and identifying global sub-sections for plugin groups. Any global + subsections must inherit from this. + + Parameters + ---------- + helptext : str + The helptext to be displayed for the global configuration section + """ + helptext: str + + +__all__ = get_module_objects(__name__) diff --git a/lib/convert.py b/lib/convert.py index c96b759213..5b41a7817c 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -8,6 +8,7 @@ import cv2 import numpy as np +from lib.utils import get_module_objects from plugins.plugin_loader import PluginLoader if T.TYPE_CHECKING: @@ -15,7 +16,6 @@ from collections.abc import Callable from lib.align.aligned_face import AlignedFace, CenteringType from lib.align.detected_face import DetectedFace - from lib.config import FaceswapConfig from lib.queue_manager import EventQueue from scripts.convert import ConvertItem from plugins.convert.color._base import Adjustment as ColorAdjust @@ -47,7 +47,7 @@ class Adjustments: sharpening: ScalingAdjust | None = None -class Converter(): +class Converter(): # pylint:disable=too-many-instance-attributes """ The converter is responsible for swapping the original face(s) in a frame with the output of a trained Faceswap model. @@ -87,7 +87,7 @@ def __init__(self, draw_transparent, pre_encode, arguments, configfile) self._output_size = output_size self._coverage_ratio = coverage_ratio - self._centering = centering + self._centering: CenteringType = centering self._draw_transparent = draw_transparent self._writer_pre_encode = pre_encode self._args = arguments @@ -107,26 +107,19 @@ def cli_arguments(self) -> Namespace: process """ return self._args - def reinitialize(self, config: FaceswapConfig) -> None: + def reinitialize(self) -> None: """ Reinitialize this :class:`Converter`. Called as part of the :mod:`~tools.preview` tool. Resets all adjustments then loads the - plugins as specified in the given config. - - Parameters - ---------- - config: :class:`lib.config.FaceswapConfig` - Pre-loaded :class:`lib.config.FaceswapConfig`. used over any configuration on disk. + plugins as specified in the current config. """ logger.debug("Reinitializing converter") self._face_scale = 1.0 - self._args.face_scale / 100. self._adjustments = Adjustments() - self._load_plugins(config=config, disable_logging=True) + self._load_plugins(disable_logging=True) logger.debug("Reinitialized converter") - def _load_plugins(self, - config: FaceswapConfig | None = None, - disable_logging: bool = False) -> None: + def _load_plugins(self, disable_logging: bool = False) -> None: """ Load the requested adjustment plugins. Loads the :mod:`plugins.converter` plugins that have been requested for this conversion @@ -137,34 +130,27 @@ def _load_plugins(self, config: :class:`lib.config.FaceswapConfig`, optional Optional pre-loaded :class:`lib.config.FaceswapConfig`. If passed, then this will be used over any configuration on disk. If ``None`` then it is ignored. Default: ``None`` - disable_logging: bool, optional - Plugin loader outputs logging info every time a plugin is loaded. Set to ``True`` to - suppress these messages otherwise ``False``. Default: ``False`` """ - logger.debug("Loading plugins. config: %s", config) + logger.debug("Loading plugins. disable_logging: %s", disable_logging) self._adjustments.mask = PluginLoader.get_converter("mask", "mask_blend", disable_logging=disable_logging)( self._args.mask_type, self._output_size, self._coverage_ratio, - configfile=self._configfile, - config=config) + configfile=self._configfile) - if self._args.color_adjustment != "none" and self._args.color_adjustment is not None: + if self._args.color_adjustment is not None: self._adjustments.color = PluginLoader.get_converter("color", self._args.color_adjustment, disable_logging=disable_logging)( - configfile=self._configfile, - config=config) + configfile=self._configfile) sharpening = PluginLoader.get_converter("scaling", "sharpen", disable_logging=disable_logging)( - configfile=self._configfile, - config=config) - if sharpening.config.get("method") is not None: - self._adjustments.sharpening = sharpening + configfile=self._configfile) + self._adjustments.sharpening = sharpening logger.debug("Loaded plugins: %s", self._adjustments) def process(self, in_queue: EventQueue, out_queue: EventQueue): @@ -347,7 +333,8 @@ def _get_new_image(self, logger.trace("Getting: (filename: '%s', faces: %s)", # type: ignore[attr-defined] predicted.inbound.filename, len(predicted.swapped_faces)) - placeholder = np.zeros((frame_size[1], frame_size[0], 4), dtype="float32") + placeholder: np.ndarray = np.zeros((frame_size[1], frame_size[0], 4), dtype="float32") + faces: list[np.ndarray] | None = None if self._full_frame_output: background = predicted.inbound.image / np.array(255.0, dtype="float32") placeholder[:, :, :3] = background @@ -370,6 +357,7 @@ def _get_new_image(self, new_face, placeholder, len(predicted.swapped_faces) > 1) else: + assert faces is not None faces.append(new_face) if not self._full_frame_output: @@ -452,6 +440,7 @@ def _get_image_mask(self, The raw mask with no erosion or blurring applied """ logger.trace("Getting mask. Image shape: %s", new_face.shape) # type: ignore[attr-defined] + mask_centering: CenteringType if self._args.mask_type not in ("none", "predicted"): mask_centering = detected_face.mask[self._args.mask_type].stored_centering else: @@ -525,3 +514,6 @@ def _scale_image(self, frame: np.ndarray) -> np.ndarray: logger.trace("resized frame: %s", frame.shape) # type: ignore[attr-defined] np.clip(frame, 0.0, 1.0, out=frame) return frame + + +__all__ = get_module_objects(__name__) diff --git a/lib/git.py b/lib/git.py index 90cba0af3c..3460eba394 100644 --- a/lib/git.py +++ b/lib/git.py @@ -6,6 +6,8 @@ from subprocess import PIPE, Popen +from lib.utils import get_module_objects + logger = logging.getLogger(__name__) @@ -155,3 +157,6 @@ def get_commits(self, count: int) -> list[str]: git = Git() """ :class:`Git`: Handles calls to github """ + + +__all__ = get_module_objects(__name__) diff --git a/lib/gpu_stats/__init__.py b/lib/gpu_stats/__init__.py index 070a53d057..71246ba31c 100644 --- a/lib/gpu_stats/__init__.py +++ b/lib/gpu_stats/__init__.py @@ -2,23 +2,21 @@ """ Dynamically import the correct GPU Stats library based on the faceswap backend and the machine being used. """ -import platform - from lib.utils import get_backend -from ._base import set_exclude_devices, GPUInfo +from ._base import GPUInfo, _GPUStats backend = get_backend() -if backend == "nvidia" and platform.system().lower() == "darwin": - from .nvidia_apple import NvidiaAppleStats as GPUStats # type:ignore -elif backend == "nvidia": - from .nvidia import NvidiaStats as GPUStats # type:ignore -elif backend == "apple_silicon": - from .apple_silicon import AppleSiliconStats as GPUStats # type:ignore -elif backend == "directml": - from .directml import DirectML as GPUStats # type:ignore -elif backend == "rocm": - from .rocm import ROCm as GPUStats # type:ignore -else: - from .cpu import CPUStats as GPUStats # type:ignore +GPUStats: type[_GPUStats] | None +try: + if backend == "nvidia": + from .nvidia import NvidiaStats as GPUStats + elif backend == "apple_silicon": + from .apple_silicon import AppleSiliconStats as GPUStats + elif backend == "rocm": + from .rocm import ROCm as GPUStats + else: + from .cpu import CPUStats as GPUStats +except (ImportError, ModuleNotFoundError): + GPUStats = None diff --git a/lib/gpu_stats/_base.py b/lib/gpu_stats/_base.py index 8953f134ae..cc7df62785 100644 --- a/lib/gpu_stats/_base.py +++ b/lib/gpu_stats/_base.py @@ -56,26 +56,6 @@ class BiggestGPUInfo(): total: float -def set_exclude_devices(devices: list[int]) -> None: - """ Add any explicitly selected GPU devices to the global list of devices to be excluded - from use by Faceswap. - - Parameters - ---------- - devices: list[int] - list of GPU device indices to exclude - - Example - ------- - >>> set_exclude_devices([0, 1]) # Exclude the first two GPU devices - """ - logger = logging.getLogger(__name__) - logger.debug("Excluding GPU indicies: %s", devices) - if not devices: - return - _EXCLUDE_DEVICES.extend(devices) - - class _GPUStats(): """ Parent class for collecting GPU device information. @@ -263,3 +243,13 @@ def get_card_most_free(self) -> BiggestGPUInfo: total=self._vram[card_id]) self._log("debug", f"Active GPU Card with most free VRAM: {retval}") return retval + + def exclude_devices(self, devices: list[int]) -> None: + """ Exclude GPU devices from being used by Faceswap. Override for backend specific logic + + Parameters + ---------- + devices: list[int] + The GPU device IDS to be excluded + """ + raise NotImplementedError diff --git a/lib/gpu_stats/apple_silicon.py b/lib/gpu_stats/apple_silicon.py index a8b0815015..467cc20819 100644 --- a/lib/gpu_stats/apple_silicon.py +++ b/lib/gpu_stats/apple_silicon.py @@ -4,14 +4,15 @@ import os import psutil -import tensorflow as tf +import torch + +from lib.utils import FaceswapError, get_module_objects -from lib.utils import FaceswapError from ._base import _GPUStats -_METAL_INITIALIZED: bool = False +_metal_initialized: bool = False class AppleSiliconStats(_GPUStats): @@ -22,7 +23,7 @@ class AppleSiliconStats(_GPUStats): ----- Apple Silicon is a bit different from other backends, as it does not have a dedicated GPU with it's own dedicated VRAM, rather the RAM is shared with the CPU and GPU. A combination of psutil - and Tensorflow are used to pull as much useful information as possible. + and torch are used to pull as much useful information as possible. Parameters ---------- @@ -35,7 +36,7 @@ class AppleSiliconStats(_GPUStats): """ def __init__(self, log: bool = True) -> None: # Following attribute set in :func:``_initialize`` - self._tf_devices: list[T.Any] = [] + self._mps_devices: list[T.Any] = [] super().__init__(log=log) @@ -51,18 +52,18 @@ def _initialize(self) -> None: self._log("debug", "Initializing Metal for Apple Silicon SoC.") self._initialize_metal() - self._tf_devices = tf.config.list_physical_devices(device_type="GPU") + self._mps_devices = [torch.device("mps")] super()._initialize() def _initialize_metal(self) -> None: """ Initialize Metal on first call to this class and set global - :attr:``_METAL_INITIALIZED`` to ``True``. If Metal has already been initialized then return + :attr:``_metal_initialized`` to ``True``. If Metal has already been initialized then return performing no action. """ - global _METAL_INITIALIZED # pylint:disable=global-statement + global _metal_initialized # pylint:disable=global-statement - if _METAL_INITIALIZED: + if _metal_initialized: return self._log("debug", "Performing first time Apple SoC setup.") @@ -74,25 +75,24 @@ def _initialize_metal(self) -> None: except Exception as err: # pylint:disable=broad-except self._log("debug", f"Swallowing error opening XQuartz: {str(err)}") - self._test_tensorflow() + self._test_torch() - _METAL_INITIALIZED = True + _metal_initialized = True - def _test_tensorflow(self) -> None: - """ Test that tensorflow can execute correctly. + def _test_torch(self) -> None: + """ Test that torch can execute correctly. Raises ------ FaceswapError - If the Tensorflow library could not be successfully initialized + If the Torch library could not be successfully initialized """ try: - meminfo = tf.config.experimental.get_memory_info('GPU:0') - devices = tf.config.list_logical_devices() + meminfo = torch.mps.driver_allocated_memory() self._log("debug", - f"Tensorflow initialization test: (mem_info: {meminfo}, devices: {devices}") + f"Torch initialization test: (mem_info: {meminfo})") except RuntimeError as err: - msg = ("An unhandled exception occured initializing the device via Tensorflow " + msg = ("An unhandled exception occured initializing the device via Torch " f"Library. Original error: {str(err)}") raise FaceswapError(msg) from err @@ -104,7 +104,7 @@ def _get_device_count(self) -> int: int The total number of SoCs available """ - retval = len(self._tf_devices) + retval = len(self._mps_devices) self._log("debug", f"GPU Device count: {retval}") return retval @@ -151,7 +151,7 @@ def _get_device_names(self) -> list[str]: list The list of available Apple Silicon SoC names """ - names = [d.name for d in self._tf_devices] + names = [d.type for d in self._mps_devices] self._log("debug", f"GPU Devices: {names}") return names @@ -159,18 +159,12 @@ def _get_vram(self) -> list[int]: """ Obtain the VRAM in Megabytes for each available Apple Silicon SoC(s) as identified in :attr:`_handles`. - Notes - ----- - `tf.config.experimental.get_memory_info('GPU:0')` does not work, so uses psutil instead. - The total memory on the system is returned as it is shared between the CPU and the GPU. - There is no dedicated VRAM. - Returns ------- list The RAM in Megabytes for each available Apple Silicon SoC """ - vram = [int((psutil.virtual_memory().total / self._device_count) / (1024 * 1024)) + vram = [int((torch.mps.driver_allocated_memory() / self._device_count) / (1024 * 1024)) for _ in range(self._device_count)] self._log("debug", f"SoC RAM: {vram}") return vram @@ -189,3 +183,17 @@ def _get_free_vram(self) -> list[int]: for _ in range(self._device_count)] self._log("debug", f"SoC RAM free: {vram}") return vram + + def exclude_devices(self, devices: list[int]) -> None: + """ Apple-Silicon does not support excluding devices + + Parameters + ---------- + devices: list[int] + The GPU device IDS to be excluded + """ + self._log("warning", "Apple Silicon does not support excluding GPUs. This option has been " + "ignored") + + +__all__ = get_module_objects(__name__) diff --git a/lib/gpu_stats/cpu.py b/lib/gpu_stats/cpu.py index ae20c96c09..0a4194ee9b 100644 --- a/lib/gpu_stats/cpu.py +++ b/lib/gpu_stats/cpu.py @@ -1,5 +1,8 @@ #!/usr/bin/env python3 """ Dummy functions for running faceswap on CPU. """ + +from lib.utils import get_module_objects + from ._base import _GPUStats @@ -96,3 +99,16 @@ def _get_free_vram(self) -> list[int]: vram: list[int] = [] self._log("debug", f"GPU VRAM free: {vram}") return vram + + def exclude_devices(self, devices: list[int]) -> None: + """ CPU does not support excluding devices + + Parameters + ---------- + devices: list[int] + The GPU device IDS to be excluded + """ + self._log("warning", "CPU does not support excluding GPUs. This option has been ignored") + + +__all__ = get_module_objects(__name__) diff --git a/lib/gpu_stats/directml.py b/lib/gpu_stats/directml.py deleted file mode 100644 index 10bb435b93..0000000000 --- a/lib/gpu_stats/directml.py +++ /dev/null @@ -1,630 +0,0 @@ -#!/usr/bin/env python3 -""" Collects and returns Information on DirectX 12 hardware devices for DirectML. """ -from __future__ import annotations -import os -import sys -import typing as T -assert sys.platform == "win32" - -import ctypes -from ctypes import POINTER, Structure, windll -from dataclasses import dataclass -from enum import Enum, IntEnum - -from comtypes import COMError, IUnknown, GUID, STDMETHOD, HRESULT # pylint:disable=import-error - -from ._base import _GPUStats - -if T.TYPE_CHECKING: - from collections.abc import Callable - -# Monkey patch default ctypes.c_uint32 value to Enum ctypes property for easier tracking of types -# We can't just subclass as the attribute will be assumed to be part of the Enumeration, so we -# attach it directly and suck up the typing errors. -setattr(Enum, "ctype", ctypes.c_uint32) - - -############################# -# CTYPES SUPPORTING OBJECTS # -############################# -# GUIDs -@dataclass -class LookupGUID: - """ GUIDs that are required for creating COM objects which are used and discarded. - - Reference - --------- - https://learn.microsoft.com/en-us/windows/win32/api/d3d12/nn-d3d12-id3d12device2 - """ - IDXGIDevice = GUID("{54ec77fa-1377-44e6-8c32-88fd5f44c84c}") - ID3D12Device = GUID("{189819f1-1db6-4b57-be54-1821339b85f7}") - - -# ENUMS -class DXGIGpuPreference(IntEnum): - """ The preference of GPU for the app to run on. - - Reference - --------- - https://learn.microsoft.com/en-us/windows/win32/api/dxgi1_6/ne-dxgi1_6-dxgi_gpu_preference - """ - DXGI_GPU_PREFERENCE_UNSPECIFIED = 0 - DXGI_GPU_PREFERENCE_MINIMUM_POWER = 1 - DXGI_GPU_PREFERENCE_HIGH_PERFORMANCE = 2 - - -class DXGIAdapterFlag(IntEnum): - """ Identifies the type of DXGI adapter. - - Reference - --------- - https://learn.microsoft.com/en-us/windows/win32/api/dxgi/ne-dxgi-dxgi_adapter_flag - """ - DXGI_ADAPTER_FLAG_NONE = 0 - DXGI_ADAPTER_FLAG_REMOTE = 1 - DXGI_ADAPTER_FLAG_SOFTWARE = 2 - DXGI_ADAPTER_FLAG_FORCE_DWORD = 0xffffffff - - -class DXGIMemorySegmentGroup(IntEnum): - """ Constants that specify an adapter's memory segment grouping. - - Reference - --------- - https://learn.microsoft.com/en-us/windows/win32/api/dxgi1_4/ne-dxgi1_4-dxgi_memory_segment_group - """ - DXGI_MEMORY_SEGMENT_GROUP_LOCAL = 0 - DXGI_MEMORY_SEGMENT_GROUP_NON_LOCAL = 1 - - -class D3DFeatureLevel(Enum): - """ Describes the set of features targeted by a Direct3D device. - - Reference - --------- - https://learn.microsoft.com/en-us/windows/win32/api/d3dcommon/ne-d3dcommon-d3d_feature_level - """ - D3D_FEATURE_LEVEL_1_0_CORE = 0x1000 - D3D_FEATURE_LEVEL_9_1 = 0x9100 - D3D_FEATURE_LEVEL_9_2 = 0x9200 - D3D_FEATURE_LEVEL_9_3 = 0x9300 - D3D_FEATURE_LEVEL_10_0 = 0xa000 - D3D_FEATURE_LEVEL_10_1 = 0xa100 - D3D_FEATURE_LEVEL_11_0 = 0xb000 - D3D_FEATURE_LEVEL_11_1 = 0xb100 - D3D_FEATURE_LEVEL_12_0 = 0xc000 - D3D_FEATURE_LEVEL_12_1 = 0xc100 - D3D_FEATURE_LEVEL_12_2 = 0xc200 - - -class VendorID(Enum): - """ DirectX VendorID Enum """ - AMD = 0x1002 - NVIDIA = 0x10DE - MICROSOFT = 0x1414 - QUALCOMM = 0x4D4F4351 - INTEL = 0x8086 - - -# STRUCTS -class StructureRepr(Structure): - """ Override the standard structure class to add a useful __repr__ for logging """ - def __repr__(self) -> str: - """ Output the class name and the structure contents """ - content = ["=".join([field[0], str(getattr(self, field[0]))]) - for field in self._fields_] - if self.__dict__: # Add manually added parameters - content.extend("=".join([key, str(val)]) for key, val in self.__dict__.items()) - return f"{self.__class__.__name__}({', '.join(content)})" - - -class LUID(StructureRepr): # pylint:disable=too-few-public-methods - """ Local Identifier for an adaptor - - Reference - --------- - https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-luid """ - _fields_ = [("LowPart", ctypes.c_ulong), ("HighPart", ctypes.c_long)] - - -class DriverVersion(StructureRepr): # pylint:disable=too-few-public-methods - """ Stucture (based off LARGE_INTEGER) to hold the driver version - - Reference - --------- - https://docs.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-large_integer-r1""" - _fields_ = [("parts_a", ctypes.c_uint16), - ("parts_b", ctypes.c_uint16), - ("parts_c", ctypes.c_uint16), - ("parts_d", ctypes.c_uint16)] - - -class DXGIAdapterDesc1(StructureRepr): # pylint:disable=too-few-public-methods - """ Describes an adapter (or video card) using DXGI 1.1 - - Reference - --------- - https://learn.microsoft.com/en-us/windows/win32/api/dxgi/ns-dxgi-DXGIAdapterDesc1 """ - _fields_ = [ - ("Description", ctypes.c_wchar * 128), - ("VendorId", ctypes.c_uint), - ("DeviceId", ctypes.c_uint), - ("SubSysId", ctypes.c_uint), - ("Revision", ctypes.c_uint), - ("DedicatedVideoMemory", ctypes.c_size_t), - ("DedicatedSystemMemory", ctypes.c_size_t), - ("SharedSystemMemory", ctypes.c_size_t), - ("AdapterLuid", LUID), - ("Flags", DXGIAdapterFlag.ctype)] # type:ignore[attr-defined] # pylint:disable=no-member - - -class DXGIQueryVideoMemoryInfo(StructureRepr): # pylint:disable=too-few-public-methods - """ Describes the current video memory budgeting parameters. - - Reference - --------- - https://learn.microsoft.com/en-us/windows/win32/api/dxgi1_4/ns-dxgi1_4-dxgi_query_video_memory_info - """ - _fields_ = [("Budget", ctypes.c_uint64), - ("CurrentUsage", ctypes.c_uint64), - ("AvailableForReservation", ctypes.c_uint64), - ("CurrentReservation", ctypes.c_uint64)] - - -# COM OBjects -class IDXObject(IUnknown): # pylint:disable=too-few-public-methods - """ Base interface for all DXGI objects. - - Reference - --------- - https://learn.microsoft.com/en-us/windows/win32/api/dxgi/nn-dxgi-idxgiobject - """ - _iid_ = GUID("{aec22fb8-76f3-4639-9be0-28eb43a67a2e}") - _methods_ = [STDMETHOD(HRESULT, "SetPrivateData", - [GUID, ctypes.c_uint, POINTER(ctypes.c_void_p)]), - STDMETHOD(HRESULT, "SetPrivateDataInterface", [GUID, POINTER(IUnknown)]), - STDMETHOD(HRESULT, "GetPrivateData", - [GUID, POINTER(ctypes.c_uint), POINTER(ctypes.c_void_p)]), - STDMETHOD(HRESULT, "GetParent", [GUID, POINTER(POINTER(ctypes.c_void_p))])] - - -class IDXGIFactory6(IDXObject): # pylint:disable=too-few-public-methods - """ Implements methods for generating DXGI objects - - Reference - --------- - https://learn.microsoft.com/en-us/windows/win32/api/dxgi/nn-dxgi-idxgifactory - """ - _iid_ = GUID("{c1b6694f-ff09-44a9-b03c-77900a0a1d17}") - - _methods_ = [STDMETHOD(HRESULT, "EnumAdapters"), # IDXGIFactory - STDMETHOD(HRESULT, "MakeWindowAssociation"), - STDMETHOD(HRESULT, "GetWindowAssociation"), - STDMETHOD(HRESULT, "CreateSwapChain"), - STDMETHOD(HRESULT, "CreateSoftwareAdapter"), - STDMETHOD(HRESULT, "EnumAdapters1"), # IDXGIFactory1 - STDMETHOD(ctypes.c_bool, "IsCurrent"), - STDMETHOD(ctypes.c_bool, "IsWindowedStereoEnabled"), # IDXGIFactory2 - STDMETHOD(HRESULT, "CreateSwapChainForHwnd"), - STDMETHOD(HRESULT, "CreateSwapChainForCoreWindow"), - STDMETHOD(HRESULT, "GetSharedResourceAdapterLuid"), - STDMETHOD(HRESULT, "RegisterStereoStatusWindow"), - STDMETHOD(HRESULT, "RegisterStereoStatusEvent"), - STDMETHOD(None, "UnregisterStereoStatus"), - STDMETHOD(HRESULT, "RegisterOcclusionStatusWindow"), - STDMETHOD(HRESULT, "RegisterOcclusionStatusEvent"), - STDMETHOD(None, "UnregisterOcclusionStatus"), - STDMETHOD(HRESULT, "CreateSwapChainForComposition"), - STDMETHOD(ctypes.c_uint, "GetCreationFlags"), # IDXGIFactory3 - STDMETHOD(HRESULT, "EnumAdapterByLuid", # IDXGIFactory4 - [LUID, GUID, POINTER(POINTER(ctypes.c_void_p))]), - STDMETHOD(HRESULT, "EnumWarpAdapter"), - STDMETHOD(HRESULT, "CheckFeatureSupport"), # IDXGIFactory5 - STDMETHOD(HRESULT, # IDXGIFactory6 - "EnumAdapterByGpuPreference", - [ctypes.c_uint, - DXGIGpuPreference.ctype, # type:ignore[attr-defined] # pylint:disable=no-member # noqa:E501 - GUID, - POINTER(ctypes.c_void_p)])] - - -class IDXGIAdapter3(IDXObject): # pylint:disable=too-few-public-methods - """ Represents a display sub-system (including one or more GPU's, DACs and video memory). - - Reference - --------- - https://learn.microsoft.com/en-us/windows/win32/api/dxgi1_4/nn-dxgi1_4-idxgiadapter3 - """ - _iid_ = GUID("{645967a4-1392-4310-a798-8053ce3e93fd}") - _methods_ = [STDMETHOD(HRESULT, "EnumOutputs"), # v1.0 Methods - STDMETHOD(HRESULT, "GetDesc"), - STDMETHOD(HRESULT, "CheckInterfaceSupport", # v1.1 Methods - [GUID, POINTER(DriverVersion)]), - STDMETHOD(HRESULT, "GetDesc1", [POINTER(DXGIAdapterDesc1)]), - STDMETHOD(HRESULT, "GetDesc2"), # v1.2 Methods - STDMETHOD(HRESULT, # v1.3 Methods - "RegisterHardwareContentProtectionTeardownStatusEvent"), - STDMETHOD(None, "UnregisterHardwareContentProtectionTeardownStatus"), - STDMETHOD(HRESULT, - "QueryVideoMemoryInfo", - [ctypes.c_uint, - DXGIMemorySegmentGroup.ctype, # type:ignore[attr-defined] # pylint:disable=no-member # noqa:E501 - POINTER(DXGIQueryVideoMemoryInfo)]), - STDMETHOD(HRESULT, "SetVideoMemoryReservation"), - STDMETHOD(HRESULT, "RegisterVideoMemoryBudgetChangeNotificationEvent"), - STDMETHOD(None, "UnregisterVideoMemoryBudgetChangeNotification")] - - -########################### -# PYTHON COLLATED OBJECTS # -########################### -@dataclass -class Device: - """ Holds information about a device attached to an adapter. - - Parameters - ---------- - description: :class:`DXGIAdapterDesc1` - The information returned from DXGI.dll about the device - driver_version: str - The driver version of the device - local_mem: :class:`DXGIQueryVideoMemoryInfo` - The amount of local memory currently available - non_local_mem: :class:`DXGIQueryVideoMemoryInfo` - The amount of non-local memory currently available - is_d3d12: bool - ``True`` if the device supports DirectX12 - is_compute_only: bool - ``True`` if the device is only compute (no graphics) - """ - description: DXGIAdapterDesc1 - driver_version: str - local_mem: DXGIQueryVideoMemoryInfo - non_local_mem: DXGIQueryVideoMemoryInfo - is_d3d12: bool - is_compute_only: bool = False - - @property - def is_software_adapter(self) -> bool: - """ bool: ``True`` if this is a software adapter. """ - return self.description.Flags == DXGIAdapterFlag.DXGI_ADAPTER_FLAG_SOFTWARE.value - - @property - def is_valid(self) -> bool: - """ bool: ``True`` if this adapter is a hardware adaptor and is not the basic renderer """ - if self.is_software_adapter: - return False - - if (self.description.VendorId == VendorID.MICROSOFT.value and - self.description.DeviceId == 0x8c): - return False - - return True - - -class Adapters(): # pylint:disable=too-few-public-methods - """ Wrapper to obtain connected DirectX Graphics interface adapters from Windows - - Parameters - ---------- - log_func: :func:`~lib.gpu_stats._base._log` - The logging function to use from the parent GPUStats class - """ - def __init__(self, log_func: Callable[[str, str], None]) -> None: - self._log = log_func - self._log("debug", f"Initializing {self.__class__.__name__}: (log_func: {log_func})") - - self._factory = self._get_factory() - self._adapters = self._get_adapters() - self._devices = self._process_adapters() - - self._valid_adaptors: list[Device] = [] - self._log("debug", f"Initialized {self.__class__.__name__}") - - def _get_factory(self) -> ctypes._Pointer: - """ Get a DXGI 1.1 Factory object - - Reference - --------- - https://learn.microsoft.com/en-us/windows/win32/api/dxgi/nf-dxgi-createdxgifactory1 - - Returns - ------- - :class:`ctypes._Pointer` - A pointer to a :class:`IDXGIFactory6` COM instance - """ - factory_func = windll.dxgi.CreateDXGIFactory - factory_func.argtypes = (GUID, POINTER(ctypes.c_void_p)) - factory_func.restype = HRESULT - handle = ctypes.c_void_p(0) - factory_func(IDXGIFactory6._iid_, ctypes.byref(handle)) # pylint:disable=protected-access - retval = ctypes.POINTER(IDXGIFactory6)(T.cast(IDXGIFactory6, handle.value)) - self._log("debug", f"factory: {retval}") - return retval - - @property - def valid_adapters(self) -> list[Device]: - """ list[:class:`Device`]: DirectX 12 compatible hardware :class:`Device` objects """ - if self._valid_adaptors: - return self._valid_adaptors - - for device in self._devices: - if not device.is_valid: - # Sorted by most performant so everything after first basic adapter is skipped - break - if not device.is_d3d12: - continue - self._valid_adaptors.append(device) - self._log("debug", f"valid_adaptors: {self._valid_adaptors}") - return self._valid_adaptors - - def _get_adapters(self) -> list[ctypes._Pointer]: - """ Obtain DirectX 12 supporting hardware adapter objects and add a Device class for - obtaining details - - Returns - ------- - list - List of :class:`ctypes._Pointer` objects - """ - idx = 0 - retval = [] - while True: - try: - handle = ctypes.c_void_p(0) - success = self._factory.EnumAdapterByGpuPreference( # type:ignore[attr-defined] - idx, - DXGIGpuPreference.DXGI_GPU_PREFERENCE_HIGH_PERFORMANCE.value, - IDXGIAdapter3._iid_, # pylint:disable=protected-access - ctypes.byref(handle)) - if success != 0: - raise AttributeError("Error calling EnumAdapterByGpuPreference. Result: " - f"{hex(ctypes.c_ulong(success).value)}") - adapter = POINTER(IDXGIAdapter3)(T.cast(IDXGIAdapter3, handle.value)) - self._log("debug", f"found adapter: {adapter}") - retval.append(adapter) - except COMError as err: - err_code = hex(ctypes.c_ulong(err.hresult).value) # pylint:disable=no-member - self._log( - "debug", - "COM Error. Breaking: " - f"{err.text}({err_code})") # pylint:disable=no-member - break - finally: - idx += 1 - - self._log("debug", f"adapters: {retval}") - return retval - - def _query_adapter(self, func: Callable[[T.Any], T.Any], *args: T.Any) -> None: - """ Query an adapter function, logging if the HRESULT is not a success - - Parameters - ---------- - func: Callable[[Any], Any] - The adaptor function to call - args: Any - The arguments to pass to the adaptor function - """ - check = func(*args) - if check: - self._log("debug", f"Failed HRESULT for func {func}({args}): " - f"{hex(ctypes.c_ulong(check).value)}") - - def _test_d3d12(self, adapter: ctypes._Pointer) -> bool: - """ Test whether the given adapter supports DirectX 12 - - Parameters - ---------- - adapter: :class:`ctypes._Pointer` - A pointer to an adapter instance - - Returns - ------- - bool - ``True`` if the given adapter supports DirectX 12 - """ - factory_func = windll.d3d12.D3D12CreateDevice - factory_func.argtypes = ( - POINTER(IUnknown), - D3DFeatureLevel.ctype, # type:ignore[attr-defined] # pylint:disable=no-member - GUID, - POINTER(ctypes.c_void_p)) - handle = ctypes.c_void_p(0) - factory_func.restype = HRESULT - success = factory_func(adapter, - D3DFeatureLevel.D3D_FEATURE_LEVEL_11_0.value, - LookupGUID.ID3D12Device, - ctypes.byref(handle)) - return success in (0, 1) - - def _process_adapters(self) -> list[Device]: - """ Process the adapters to add discovered information. - - Returns - ------- - list[:class:`Device`] - List of device of objects found in the adapters - """ - retval = [] - for adapter in self._adapters: - # Description - desc = DXGIAdapterDesc1() - self._query_adapter(adapter.GetDesc1, ctypes.byref(desc)) # type:ignore[attr-defined] - - # Driver Version - driver = DriverVersion() - self._query_adapter(adapter.CheckInterfaceSupport, # type:ignore[attr-defined] - LookupGUID.IDXGIDevice, - ctypes.byref(driver)) - driver_version = f"{driver.parts_d}.{driver.parts_c}.{driver.parts_b}.{driver.parts_a}" - - # Current Memory - local_mem = DXGIQueryVideoMemoryInfo() - self._query_adapter(adapter.QueryVideoMemoryInfo, # type:ignore[attr-defined] - 0, - DXGIMemorySegmentGroup.DXGI_MEMORY_SEGMENT_GROUP_LOCAL.value, - local_mem) - non_local_mem = DXGIQueryVideoMemoryInfo() - self._query_adapter( - adapter.QueryVideoMemoryInfo, # type:ignore[attr-defined] - 0, - DXGIMemorySegmentGroup.DXGI_MEMORY_SEGMENT_GROUP_NON_LOCAL.value, - non_local_mem) - - # is_d3d12 - is_d3d12 = self._test_d3d12(adapter) - - retval.append(Device(desc, driver_version, local_mem, non_local_mem, is_d3d12)) - - return retval - - -class DirectML(_GPUStats): - """ Holds information and statistics about GPUs connected using Windows API - - Parameters - ---------- - log: bool, optional - Whether the class should output information to the logger. There may be occasions where the - logger has not yet been set up when this class is queried. Attempting to log in these - instances will raise an error. If GPU stats are being queried prior to the logger being - available then this parameter should be set to ``False``. Otherwise set to ``True``. - Default: ``True`` - """ - def __init__(self, log: bool = True) -> None: - self._devices: list[Device] = [] - super().__init__(log=log) - - @property - def _all_vram(self) -> list[int]: - """ list: The VRAM of each GPU device that the DX API has discovered. """ - return [int(device.description.DedicatedVideoMemory / (1024 * 1024)) - for device in self._devices] - - @property - def names(self) -> list[str]: - """ list: The name of each GPU device that the DX API has discovered. """ - return [device.description.Description for device in self._devices] - - def _get_active_devices(self) -> list[int]: - """ Obtain the indices of active GPUs (those that have not been explicitly excluded by - DML_VISIBLE_DEVICES environment variable or explicitly excluded in the command line - arguments). - - Returns - ------- - list - The list of device indices that are available for Faceswap to use - """ - devices = super()._get_active_devices() - env_devices = os.environ.get("DML_VISIBLE_DEVICES") - if env_devices: - new_devices = [int(i) for i in env_devices.split(",")] - devices = [idx for idx in devices if idx in new_devices] - self._log("debug", f"Active GPU Devices: {devices}") - return devices - - def _get_devices(self) -> list[Device]: - """ Obtain all detected DX API devices. - - Returns - ------- - list - The :class:`~dx_lib.Device` objects for GPUs that the DX API has discovered. - """ - adapters = Adapters(log_func=self._log) - devices = adapters.valid_adapters - self._log("debug", f"Obtained Devices: {devices}") - return devices - - def _initialize(self) -> None: - """ Initialize DX Core for DirectML backend. - - If :attr:`_is_initialized` is ``True`` then this function just returns performing no - action. - - if ``False`` then DirectML is setup, if not already, and GPU information is extracted - from the DirectML context. - """ - if self._is_initialized: - return - self._log("debug", "Initializing Win DX API for DirectML.") - self._devices = self._get_devices() - super()._initialize() - - def _get_device_count(self) -> int: - """ Detect the number of GPUs available from the DX API. - - Returns - ------- - int - The total number of GPUs available - """ - retval = len(self._devices) - self._log("debug", f"GPU Device count: {retval}") - return retval - - def _get_handles(self) -> list: - """ The DX API doesn't really use device handles, so we just return the all devices list - - Returns - ------- - list - The list of all discovered GPUs - """ - handles = self._devices - self._log("debug", f"DirectML GPU Handles found: {handles}") - return handles - - def _get_driver(self) -> str: - """ Obtain the driver versions currently in use. - - Returns - ------- - str - The current DirectX 12 GPU driver versions - """ - drivers = "|".join([device.driver_version if device.driver_version else "No Driver Found" - for device in self._devices]) - self._log("debug", f"GPU Drivers: {drivers}") - return drivers - - def _get_device_names(self) -> list[str]: - """ Obtain the list of names of connected GPUs as identified in :attr:`_handles`. - - Returns - ------- - list - The list of connected Nvidia GPU names - """ - names = self.names - self._log("debug", f"GPU Devices: {names}") - return names - - def _get_vram(self) -> list[int]: - """ Obtain the VRAM in Megabytes for each connected DirectML GPU as identified in - :attr:`_handles`. - - Returns - ------- - list - The VRAM in Megabytes for each connected Nvidia GPU - """ - vram = self._all_vram - self._log("debug", f"GPU VRAM: {vram}") - return vram - - def _get_free_vram(self) -> list[int]: - """ Obtain the amount of VRAM that is available, in Megabytes, for each connected DirectX - 12 supporting GPU. - - Returns - ------- - list - List of `float`s containing the amount of VRAM available, in Megabytes, for each - connected GPU as corresponding to the values in :attr:`_handles - """ - vram = [int(device.local_mem.Budget / (1024 * 1024)) for device in self._devices] - self._log("debug", f"GPU VRAM free: {vram}") - return vram diff --git a/lib/gpu_stats/nvidia.py b/lib/gpu_stats/nvidia.py index 3347399d33..29f1a872f4 100644 --- a/lib/gpu_stats/nvidia.py +++ b/lib/gpu_stats/nvidia.py @@ -2,11 +2,11 @@ """ Collects and returns Information on available Nvidia GPUs. """ import os -import pynvml +import pynvml # pylint:disable=import-error -from lib.utils import FaceswapError +from lib.utils import FaceswapError, get_module_objects -from ._base import _GPUStats +from ._base import _GPUStats, _EXCLUDE_DEVICES class NvidiaStats(_GPUStats): @@ -57,6 +57,8 @@ def _initialize(self) -> None: msg = ("An unhandled exception occured reading from the Nvidia Machine Learning " f"Library. Original error: {str(err)}") raise FaceswapError(msg) from err + + os.environ["CUDA_DEVICE_ORDER"] = "PCI_BUS_ID" super()._initialize() def _shutdown(self) -> None: @@ -92,6 +94,7 @@ def _get_active_devices(self) -> list[int]: list The list of device indices that are available for Faceswap to use """ + # pylint:disable=duplicate-code devices = super()._get_active_devices() env_devices = os.environ.get("CUDA_VISIBLE_DEVICES") if env_devices: @@ -178,3 +181,30 @@ def _get_free_vram(self) -> list[int]: self._log("debug", f"GPU VRAM free: {vram}") return vram + + def exclude_devices(self, devices: list[int]) -> None: + """ Exclude GPU devices from being used by Faceswap. Sets the CUDA_VISIBLE_DEVICES + environment variable. This must be called before Torch/Keras are imported + + Parameters + ---------- + devices: list[int] + The GPU device IDS to be excluded + """ + # pylint:disable=duplicate-code + if not devices: + return + self._log("debug", f"Excluding GPU indicies: {devices}") + + _EXCLUDE_DEVICES.extend(devices) + + active = self._get_active_devices() + + os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(str(d) for d in active + if d not in _EXCLUDE_DEVICES) + + env_vars = [f"{k}: {v}" for k, v in os.environ.items() if k.lower().startswith("cuda")] + self._log("debug", f"Cuda environmet variables: {env_vars}") + + +__all__ = get_module_objects(__name__) diff --git a/lib/gpu_stats/nvidia_apple.py b/lib/gpu_stats/nvidia_apple.py deleted file mode 100644 index acbcd93f58..0000000000 --- a/lib/gpu_stats/nvidia_apple.py +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env python3 -""" Collects and returns Information on available Nvidia GPUs connected to Apple Macs. """ -import pynvx - -from lib.utils import FaceswapError - -from ._base import _GPUStats - - -class NvidiaAppleStats(_GPUStats): - """ Holds information and statistics about Nvidia GPU(s) available on the currently - running Apple system. - - Notes - ----- - PyNvx is used for hooking in to Nvidia's Machine Learning Library and allows for pulling fairly - extensive statistics for Apple based Nvidia GPUs - - Parameters - ---------- - log: bool, optional - Whether the class should output information to the logger. There may be occasions where the - logger has not yet been set up when this class is queried. Attempting to log in these - instances will raise an error. If GPU stats are being queried prior to the logger being - available then this parameter should be set to ``False``. Otherwise set to ``True``. - Default: ``True`` - """ - - def _initialize(self) -> None: - """ Initialize PyNvx for Nvidia GPUs on Apple. - - If :attr:`_is_initialized` is ``True`` then this function just returns performing no - action. Otherwise :attr:`is_initialized` is set to ``True`` after successfully - initializing NVML. - - Raises - ------ - FaceswapError - If the NVML library could not be successfully loaded - """ - if self._is_initialized: - return - self._log("debug", "Initializing Pynvx for Apple Nvidia GPU.") - try: - pynvx.cudaInit() # pylint:disable=no-member - except RuntimeError as err: - msg = ("An unhandled exception occured reading from the Nvidia Machine Learning " - f"Library. Original error: {str(err)}") - raise FaceswapError(msg) from err - super()._initialize() - - def _shutdown(self) -> None: - """ Set :attr:`_is_initialized` back to ``False``. """ - self._log("debug", "Shutting down NVML") - super()._shutdown() - - def _get_device_count(self) -> int: - """ Detect the number of GPUs attached to the system. - - Returns - ------- - int - The total number of GPUs connected to the PC - """ - retval = pynvx.cudaDeviceGetCount(ignore=True) # pylint:disable=no-member - self._log("debug", f"GPU Device count: {retval}") - return retval - - def _get_handles(self) -> list: - """ Obtain the device handles for all Apple connected Nvidia GPUs. - - Returns - ------- - list - The list of pointers for connected Nvidia GPUs - """ - handles = pynvx.cudaDeviceGetHandles(ignore=True) # pylint:disable=no-member - self._log("debug", f"GPU Handles found: {len(handles)}") - return handles - - def _get_driver(self) -> str: - """ Obtain the Nvidia driver version currently in use. - - Returns - ------- - str - The current GPU driver version - """ - driver = pynvx.cudaSystemGetDriverVersion(ignore=True) # pylint:disable=no-member - self._log("debug", f"GPU Driver: {driver}") - return driver - - def _get_device_names(self) -> list[str]: - """ Obtain the list of names of connected Nvidia GPUs as identified in :attr:`_handles`. - - Returns - ------- - list - The list of connected Nvidia GPU names - """ - names = [pynvx.cudaGetName(handle, ignore=True) # pylint:disable=no-member - for handle in self._handles] - self._log("debug", f"GPU Devices: {names}") - return names - - def _get_vram(self) -> list[int]: - """ Obtain the VRAM in Megabytes for each connected Nvidia GPU as identified in - :attr:`_handles`. - - Returns - ------- - list - The VRAM in Megabytes for each connected Nvidia GPU - """ - vram = [ - pynvx.cudaGetMemTotal(handle, ignore=True) / (1024 * 1024) # pylint:disable=no-member - for handle in self._handles] - self._log("debug", f"GPU VRAM: {vram}") - return vram - - def _get_free_vram(self) -> list[int]: - """ Obtain the amount of VRAM that is available, in Megabytes, for each connected Nvidia - GPU. - - Returns - ------- - list - List of `float`s containing the amount of VRAM available, in Megabytes, for each - connected GPU as corresponding to the values in :attr:`_handles - """ - vram = [ - pynvx.cudaGetMemFree(handle, ignore=True) / (1024 * 1024) # pylint:disable=no-member - for handle in self._handles] - self._log("debug", f"GPU VRAM free: {vram}") - return vram diff --git a/lib/gpu_stats/rocm.py b/lib/gpu_stats/rocm.py index dca43b3818..eddbd4a920 100644 --- a/lib/gpu_stats/rocm.py +++ b/lib/gpu_stats/rocm.py @@ -11,7 +11,8 @@ import re from subprocess import run -from ._base import _GPUStats +from lib.utils import get_module_objects +from ._base import _GPUStats, _EXCLUDE_DEVICES _DEVICE_LOOKUP = { # ref: https://gist.github.com/roalercon/51f13a387f3754615cce int("0x130F", 0): "AMD Radeon(TM) R7 Graphics", @@ -448,3 +449,29 @@ def _get_free_vram(self) -> list[int]: retval.append(vram - int(used / (1024 * 1024))) self._log("debug", f"GPU VRAM free: {retval}") return retval + + def exclude_devices(self, devices: list[int]) -> None: + """ Exclude GPU devices from being used by Faceswap. Sets the HIP_VISIBLE_DEVICES + environment variable. This must be called before Torch/Keras are imported + + Parameters + ---------- + devices: list[int] + The GPU device IDS to be excluded + """ + if not devices: + return + self._log("debug", f"Excluding GPU indicies: {devices}") + + _EXCLUDE_DEVICES.extend(devices) + + active = self._get_active_devices() + + os.environ["HIP_VISIBLE_DEVICES"] = ",".join(str(d) for d in active + if d not in _EXCLUDE_DEVICES) + + env_vars = [f"{k}: {v}" for k, v in os.environ.items() if k.lower().startswith("hip")] + self._log("debug", f"HIP environmet variables: {env_vars}") + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/_config.py b/lib/gui/_config.py deleted file mode 100644 index 9f87e3b775..0000000000 --- a/lib/gui/_config.py +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env python3 -""" Default configurations for models """ - -import logging -import sys -import os -from tkinter import font as tk_font -from matplotlib import font_manager - -from lib.config import FaceswapConfig - -logger = logging.getLogger(__name__) - - -class Config(FaceswapConfig): - """ Config File for GUI """ - # pylint:disable=too-many-statements - def set_defaults(self): - """ Set the default values for config """ - logger.debug("Setting defaults") - self.set_globals() - - def set_globals(self): - """ - Set the global options for GUI - """ - logger.debug("Setting global config") - section = "global" - self.add_section(section, - "Faceswap GUI Options.\nConfigure the appearance and behaviour of " - "the GUI") - self.add_item( - section=section, title="fullscreen", datatype=bool, default=False, group="startup", - info="Start Faceswap maximized.") - self.add_item( - section=section, title="tab", datatype=str, default="extract", group="startup", - choices=get_commands(), - info="Start Faceswap in this tab.") - self.add_item( - section=section, title="options_panel_width", datatype=int, default=30, - min_max=(10, 90), rounding=1, group="layout", - info="How wide the lefthand option panel is as a percentage of GUI width at startup.") - self.add_item( - section=section, title="console_panel_height", datatype=int, default=20, - min_max=(10, 90), rounding=1, group="layout", - info="How tall the bottom console panel is as a percentage of GUI height at startup.") - self.add_item( - section=section, title="icon_size", datatype=int, default=14, - min_max=(10, 20), rounding=1, group="layout", - info="Pixel size for icons. NB: Size is scaled by DPI.") - self.add_item( - section=section, title="font", datatype=str, - choices=get_clean_fonts(), - default="default", group="font", info="Global font") - self.add_item( - section=section, title="font_size", datatype=int, default=9, - min_max=(6, 12), rounding=1, group="font", - info="Global font size.") - self.add_item( - section=section, title="autosave_last_session", datatype=str, default="prompt", - choices=["never", "prompt", "always"], group="startup", gui_radio=True, - info="Automatically save the current settings on close and reload on startup" - "\n\tnever - Don't autosave session" - "\n\tprompt - Prompt to reload last session on launch" - "\n\talways - Always load last session on launch") - self.add_item( - section=section, title="timeout", datatype=int, default=120, - min_max=(10, 600), rounding=10, group="behaviour", - info="Training can take some time to save and shutdown. Set the timeout in seconds " - "before giving up and force quitting.") - self.add_item( - section=section, title="auto_load_model_stats", datatype=bool, default=True, - group="behaviour", - info="Auto load model statistics into the Analysis tab when selecting a model " - "in Train or Convert tabs.") - - -def get_commands(): - """ Return commands formatted for GUI """ - root_path = os.path.abspath(os.path.dirname(sys.argv[0])) - command_path = os.path.join(root_path, "scripts") - tools_path = os.path.join(root_path, "tools") - commands = [os.path.splitext(item)[0] for item in os.listdir(command_path) - if os.path.splitext(item)[1] == ".py" - and os.path.splitext(item)[0] not in ("gui", "fsmedia") - and not os.path.splitext(item)[0].startswith("_")] - tools = [os.path.splitext(item)[0] for item in os.listdir(tools_path) - if os.path.splitext(item)[1] == ".py" - and os.path.splitext(item)[0] not in ("gui", "cli") - and not os.path.splitext(item)[0].startswith("_")] - return commands + tools - - -def get_clean_fonts(): - """ Return a sane list of fonts for the system that has both regular and bold variants. - - Pre-pend "default" to the beginning of the list. - - Returns - ------- - list: - A list of valid fonts for the system - """ - fmanager = font_manager.FontManager() - fonts = {} - for font in fmanager.ttflist: - if str(font.weight) in ("400", "normal", "regular"): - fonts.setdefault(font.name, {})["regular"] = True - if str(font.weight) in ("700", "bold"): - fonts.setdefault(font.name, {})["bold"] = True - valid_fonts = {key for key, val in fonts.items() if len(val) == 2} - retval = sorted(list(valid_fonts.intersection(tk_font.families()))) - if not retval: - # Return the font list with any @prefixed or non-Unicode characters stripped and default - # prefixed - logger.debug("No bold/regular fonts found. Running simple filter") - retval = sorted([fnt for fnt in tk_font.families() - if not fnt.startswith("@") and not any(ord(c) > 127 for c in fnt)]) - return ["default"] + retval diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index 60aa45e6e3..1b348c69bf 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Handles the loading and collation of events from Tensorflow event log files. """ +""" Handles the loading and collation of events from Tensorboard event log files. """ from __future__ import annotations import logging import os @@ -10,13 +10,12 @@ from dataclasses import dataclass, field import numpy as np -import tensorflow as tf -from tensorflow.core.util import event_pb2 # pylint:disable=no-name-in-module -from tensorflow.python.framework import ( # pylint:disable=no-name-in-module - errors_impl as tf_errors) +from tensorboard.compat.proto import event_pb2 # type:ignore[import-untyped] from lib.logger import parse_class_init from lib.serializer import get_serializer +from lib.training.tensorboard import RecordIterator +from lib.utils import get_module_objects if T.TYPE_CHECKING: from collections.abc import Generator, Iterator @@ -26,7 +25,7 @@ @dataclass class EventData: - """ Holds data collected from Tensorflow Event Files + """ Holds data collected from Tensorboard Event Files Parameters ---------- @@ -40,7 +39,7 @@ class EventData: class _LogFiles(): - """ Holds the filenames of the Tensorflow Event logs that require parsing. + """ Holds the filenames of the Tensorboard Event logs that require parsing. Parameters ---------- @@ -51,7 +50,7 @@ def __init__(self, logs_folder: str) -> None: logger.debug(parse_class_init(locals())) self._logs_folder = logs_folder self._filenames = self._get_log_filenames() - logger.debug("Initialized: %s", self.__class__.__name__) + logger.debug("Initialized %s", self.__class__.__name__) @property def session_ids(self) -> list[int]: @@ -59,7 +58,7 @@ def session_ids(self) -> list[int]: return list(sorted(self._filenames)) def _get_log_filenames(self) -> dict[int, str]: - """ Get the Tensorflow event filenames for all existing sessions. + """ Get the Tensorboard event filenames for all existing sessions. Returns ------- @@ -86,7 +85,7 @@ def _get_session_id(cls, folder: str) -> int | None: Parameters ---------- folder: str - The full path to the folder that contains the session's Tensorflow Event Log + The full path to the folder that contains the session's Tensorboard Event Log Returns ------- @@ -109,7 +108,7 @@ def _get_log_filename(cls, folder: str, filenames: list[str]) -> str: Parameters ---------- folder: str - The full path to the folder that contains the session's Tensorflow Event Log + The full path to the folder that contains the session's Tensorboard Event Log filenames: list[str] List of filenames that exist within the given folder @@ -123,10 +122,23 @@ def _get_log_filename(cls, folder: str, filenames: list[str]) -> str: logger.debug("logfiles: %s, selected: '%s'", logfiles, retval) return retval - def refresh(self) -> None: - """ Refresh the list of log filenames. """ + def refresh(self) -> bool: + """ Refresh the list of log filenames. + + Returns + ------- + bool + ``True`` if the pre-existing log files are a subset of the new log files, otherwise + ``False`` + """ logger.debug("Refreshing log filenames") - self._filenames = self._get_log_filenames() + old_filenames = self._filenames + new_filenames = self._get_log_filenames() + retval = set(old_filenames.values()).issubset(set(new_filenames.values())) + self._filenames = new_filenames + logger.debug("old filenames are %sa subset of new filenames %s", + "" if retval else "not ", self._filenames) + return retval def get(self, session_id: int) -> str: """ Obtain the log filename for the given session id. @@ -147,7 +159,7 @@ def get(self, session_id: int) -> str: class _CacheData(): - """ Holds cached data that has been retrieved from Tensorflow Event Files and is compressed + """ Holds cached data that has been retrieved from Tensorboard Event Files and is compressed in memory for a single or live training session Parameters @@ -215,13 +227,13 @@ def add_live_data(self, timestamps: np.ndarray, loss: np.ndarray) -> None: class _Cache(): - """ Holds parsed Tensorflow log event data in a compressed cache in memory. """ + """ Holds parsed Tensorboard log event data in a compressed cache in memory. """ def __init__(self) -> None: logger.debug(parse_class_init(locals())) self._data: dict[int, _CacheData] = {} self._carry_over: dict[int, EventData] = {} self._loss_labels: list[str] = [] - logger.debug("Initialized: %s", self.__class__.__name__) + logger.debug("Initialized %s", self.__class__.__name__) def is_cached(self, session_id: int) -> bool: """ Check if the given session_id's data is already cached @@ -287,7 +299,7 @@ def _to_numpy(self, Parameters ---------- data: dict - The incoming tensorflow event data in dictionary form per step + The incoming Tensorboard event data in dictionary form per step is_live: bool, optional ``True`` if the data to be cached is from a live training session otherwise ``False``. Default: ``False`` @@ -367,7 +379,7 @@ def _process_data(self, Parameters ---------- data: dict - The incoming tensorflow event data in dictionary form per step + The incoming Tensorboard event data in dictionary form per step is_live: bool ``True`` if the data to be cached is from a live training session otherwise ``False``. @@ -457,6 +469,16 @@ def get_data(self, session_id: int, metric: T.Literal["loss", "timestamps"] for session_id, data in retval.items()}) return retval + def reset(self) -> None: + """ Remove all information stored within the cache and reset to default """ + logger.debug("Resetting cache") + del self._data + del self._carry_over + del self._loss_labels + self._data = {} + self._carry_over = {} + self._loss_labels = [] + class TensorBoardLogs(): """ Parse data from TensorBoard logs. @@ -475,21 +497,21 @@ class TensorBoardLogs(): def __init__(self, logs_folder: str, is_training: bool) -> None: logger.debug(parse_class_init(locals())) self._is_training = False - self._training_iterator = None + self._training_iterator: RecordIterator | None = None self._log_files = _LogFiles(logs_folder) self.set_training(is_training) self._cache = _Cache() - logger.debug("Initialized: %s", self.__class__.__name__) + logger.debug("Initialized %s", self.__class__.__name__) @property def session_ids(self) -> list[int]: """ list[int]: Sorted list of integers of available session ids. """ return self._log_files.session_ids - def set_training(self, is_training: bool) -> None: + def set_training(self, is_training: bool) -> bool: """ Set the internal training flag to the given `is_training` value. If a new training session is being instigated, refresh the log filenames @@ -499,22 +521,32 @@ def set_training(self, is_training: bool) -> None: is_training: bool ``True`` to indicate that the logs to be read are from the currently training session otherwise ``False`` + + Returns + ------- + bool + ``True`` if the session that is starting training belongs to the session already loaded + otherwise ``False`` """ + retval = True if self._is_training == is_training: logger.debug("Training flag already set to %s. Returning", is_training) - return + return retval logger.debug("Setting is_training to %s", is_training) self._is_training = is_training if is_training: - self._log_files.refresh() + retval = self._log_files.refresh() + if not retval: + self._cache.reset() log_file = self._log_files.get(self.session_ids[-1]) logger.debug("Setting training iterator for log file: '%s'", log_file) - self._training_iterator = tf.compat.v1.io.tf_record_iterator(log_file) + self._training_iterator = RecordIterator(log_file, is_live=True) else: logger.debug("Removing training iterator") del self._training_iterator self._training_iterator = None + return retval def _cache_data(self, session_id: int) -> None: """ Cache TensorBoard logs for the given session ID on first access. @@ -530,7 +562,7 @@ def _cache_data(self, session_id: int) -> None: The session ID to cache the data for """ live_data = self._is_training and session_id == max(self.session_ids) - iterator = self._training_iterator if live_data else tf.compat.v1.io.tf_record_iterator( + iterator = self._training_iterator if live_data else RecordIterator( self._log_files.get(session_id)) assert iterator is not None parser = _EventParser(iterator, self._cache, live_data) @@ -619,12 +651,12 @@ def get_timestamps(self, session_id: int | None = None) -> dict[int, np.ndarray] class _EventParser(): - """ Parses Tensorflow event and populates data to :class:`_Cache`. + """ Parses Tensorboard event and populates data to :class:`_Cache`. Parameters ---------- - iterator: :func:`tf.compat.v1.io.tf_record_iterator` - The iterator to use for reading Tensorflow event logs + iterator: :class:`lib.training.tensorboard.RecordIterator` + The iterator to use for reading Tensorboard event logs cache: :class:`_Cache` The cache object to store the collected parsed events to live_data: bool @@ -638,7 +670,7 @@ def __init__(self, iterator: Iterator[bytes], cache: _Cache, live_data: bool) -> self._iterator = self._get_latest_live(iterator) if live_data else iterator self._loss_labels: list[str] = [] self._num_strip = re.compile(r"_\d+$") - logger.debug("Initialized: %s", self.__class__.__name__) + logger.debug("Initialized %s", self.__class__.__name__) @classmethod def _get_latest_live(cls, iterator: Iterator[bytes]) -> Generator[bytes, None, None]: @@ -648,13 +680,13 @@ def _get_latest_live(cls, iterator: Iterator[bytes]) -> Generator[bytes, None, N Parameters ---------- - iterator: :func:`tf.compat.v1.io.tf_record_iterator` - The live training iterator to use for reading Tensorflow event logs + iterator: :class:`lib.training.tensorboard.RecordIterator` + The live training iterator to use for reading Tensorboard event logs Yields ------ dict - A Tensorflow event in dictionary form for a single step + A Tensorboard event in dictionary form for a single step """ i = 0 while True: @@ -664,15 +696,10 @@ def _get_latest_live(cls, iterator: Iterator[bytes]) -> Generator[bytes, None, N except StopIteration: logger.debug("End of data reached") break - except tf.errors.DataLossError as err: - # Truncated records are ignored. The iterator holds the offset, so the record will - # be completed at the next call. - logger.debug("Truncated record. Original Error: %s", err) - break logger.debug("Collected %s records from live log file", i) def cache_events(self, session_id: int) -> None: - """ Parse the Tensorflow events logs and add to :attr:`_cache`. + """ Parse the Tensorboard events logs and add to :attr:`_cache`. Parameters ---------- @@ -681,21 +708,15 @@ def cache_events(self, session_id: int) -> None: """ assert self._iterator is not None data: dict[int, EventData] = {} - try: - for record in self._iterator: - event = event_pb2.Event.FromString(record) # pylint:disable=no-member - if not event.summary.value: - continue - if event.summary.value[0].tag == "keras": - self._parse_outputs(event) - if event.summary.value[0].tag.startswith("batch_"): - data[event.step] = self._process_event(event, - data.get(event.step, EventData())) - - except tf_errors.DataLossError as err: - logger.warning("The logs for Session %s are corrupted and cannot be displayed. " - "The totals do not include this session. Original error message: " - "'%s'", session_id, str(err)) + for record in self._iterator: + event = event_pb2.Event.FromString(record) # pylint:disable=no-member + if not event.summary.value: + continue + if event.summary.value[0].tag.split("/", maxsplit=1)[0] == "keras": + self._parse_outputs(event) + if event.summary.value[0].tag.startswith("batch_"): + data[event.step] = self._process_event(event, + data.get(event.step, EventData())) self._cache.cache_data(session_id, data, self._loss_labels, is_live=self._live_data) @@ -712,24 +733,26 @@ def _parse_outputs(self, event: event_pb2.Event) -> None: Parameters ---------- - event: :class:`tensorflow.core.util.event_pb2` + event: :class:`tensorboard.compat.proto.event_pb2` The event data containing the keras model structure to be parsed """ serializer = get_serializer("json") - struct = event.summary.value[0].tensor.string_val[0] + structure = event.summary.value[0].tensor.string_val[0] - config = serializer.unmarshal(struct)["config"] - model_outputs = self._get_outputs(config) + config = serializer.unmarshal(structure)["config"] + model_outputs = self._get_outputs(config, False) for side_outputs, side in zip(model_outputs, ("a", "b")): - logger.debug("side: '%s', outputs: '%s'", side, side_outputs) + logger.debug("side: '%s', outputs: %s", side, side_outputs) layer_name = side_outputs[0][0] output_config = next(layer for layer in config["layers"] if layer["name"] == layer_name)["config"] - layer_outputs = self._get_outputs(output_config) - for output in layer_outputs: # Drill into sub-model to get the actual output names - loss_name = self._num_strip.sub("", output[0][0]) # strip trailing numbers + layer_outputs = self._get_outputs(output_config, True) + logger.debug("Layer name: %s, layer_outputs: %s", layer_name, layer_outputs) + for output in layer_outputs[0]: # Drill into sub-model to get the actual output names + logger.debug("Parsing output: %s", output) + loss_name = self._num_strip.sub("", output[0]) # strip trailing numbers if loss_name[-2:] not in ("_a", "_b"): # Rename losses to reflect the side output new_name = f"{loss_name.replace('_both', '')}_{side}" logger.debug("Renaming loss output from '%s' to '%s'", loss_name, new_name) @@ -740,7 +763,7 @@ def _parse_outputs(self, event: event_pb2.Event) -> None: logger.debug("Collated loss labels: %s", self._loss_labels) @classmethod - def _get_outputs(cls, model_config: dict[str, T.Any]) -> np.ndarray: + def _get_outputs(cls, model_config: dict[str, T.Any], is_sub_model: bool) -> np.ndarray: """ Obtain the output names, instance index and output index for the given model. If there is only a single output, the shape of the array is expanded to remain consistent @@ -750,6 +773,9 @@ def _get_outputs(cls, model_config: dict[str, T.Any]) -> np.ndarray: ---------- model_config: dict The saved Keras model configuration dictionary + is_sub_model: bool + ``True`` if the model_config is for a sub-model. ``False`` if it is for the main + faceswap model. Returns ------- @@ -757,27 +783,27 @@ def _get_outputs(cls, model_config: dict[str, T.Any]) -> np.ndarray: The layer output names, their instance index and their output index """ outputs = np.array(model_config["output_layers"]) - logger.debug("Obtained model outputs: %s, shape: %s", outputs, outputs.shape) - if outputs.ndim == 2: # Insert extra dimension for non learn mask models - outputs = np.expand_dims(outputs, axis=1) - logger.debug("Expanded dimensions for single output model. outputs: %s, shape: %s", - outputs, outputs.shape) + logger.debug("Obtained model outputs. is_sub_model: %s, outputs: %s, shape: %s", + is_sub_model, outputs, outputs.shape) + # Reshape the outputs to (side, outputs per side, output info) + outputs = outputs.reshape((1 if is_sub_model else 2, -1, outputs.shape[-1])) + logger.debug("Reshaped model outputs: %s, shape: %s", outputs, outputs.shape) return outputs @classmethod def _process_event(cls, event: event_pb2.Event, step: EventData) -> EventData: - """ Process a single Tensorflow event. + """ Process a single Tensorboard event. Adds timestamp to the step `dict` if a total loss value is received, process the labels for any new loss entries and adds the side loss value to the step `dict`. Parameters ---------- - event: :class:`tensorflow.core.util.event_pb2` + event: :class:`tensorboard.compat.proto.event_pb2` The event data to be processed step: :class:`EventData` The currently processing dictionary to be populated with the extracted data from the - tensorflow event for this step + Tensorboard event for this step Returns ------- @@ -796,8 +822,11 @@ def _process_event(cls, event: event_pb2.Event, step: EventData) -> EventData: # in logging or may be due to work around put in place in FS training function for the # following bug in TF 2.8/2.9 when writing records: # https://github.com/keras-team/keras/issues/16173 - loss = float(tf.make_ndarray(summary.tensor)) + loss = float(np.frombuffer(summary.tensor.tensor_content, dtype="float32")) step.loss.append(loss) return step + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/analysis/moving_average.py b/lib/gui/analysis/moving_average.py new file mode 100644 index 0000000000..b4bb447561 --- /dev/null +++ b/lib/gui/analysis/moving_average.py @@ -0,0 +1,179 @@ +#!/usr/bin python3 +""" Calculate Exponential Moving Average for faceswap GUI Stats. """ + +import logging + +import numpy as np + +from lib.logger import parse_class_init +from lib.utils import get_module_objects + + +logger = logging.getLogger(__name__) + + +class ExponentialMovingAverage: + """ Reshapes data before calculating exponential moving average, then iterates once over the + rows to calculate the offset without precision issues. + + Parameters + ---------- + data : :class:`numpy.ndarray` + A 1 dimensional numpy array to obtain smoothed data for + amount : float + in the range (0.0, 1.0) The alpha parameter (smoothing amount) for the moving average. + + Notes + ----- + Adapted from: https://stackoverflow.com/questions/42869495 + """ + def __init__(self, data: np.ndarray, amount: float) -> None: + logger.debug(parse_class_init(locals())) + assert data.ndim == 1 + amount = min(max(amount, 0.001), 0.999) + + self._data = np.nan_to_num(data) + self._alpha = 1. - amount + self._dtype = "float32" if data.dtype == np.float32 else "float64" + self._row_size = self._get_max_row_size() + self._out = np.empty_like(data, dtype=self._dtype) + logger.debug("Initialized %s", self.__class__.__name__) + + def __call__(self) -> np.ndarray: + """ Perform the exponential moving average calculation. + + Returns + ------- + :class:`numpy.ndarray` + The smoothed data + """ + if self._data.size <= self._row_size: + self._ewma_vectorized(self._data, self._out) # Normal function can handle this input + else: + self._ewma_vectorized_safe() # Use the safe version + return self._out + + def _get_max_row_size(self) -> int: + """ Calculate the maximum row size for the running platform for the given dtype. + + Returns + ------- + int + The maximum row size possible on the running platform for the given :attr:`_dtype` + + Notes + ----- + Might not be the optimal value for speed, which is hard to predict due to numpy + optimizations. + """ + # Use :func:`np.finfo(dtype).eps` if you are worried about accuracy and want to be safe. + epsilon = np.finfo(self._dtype).tiny + # If this produces an OverflowError, make epsilon larger: + retval = int(np.log(epsilon) / np.log(1 - self._alpha)) + 1 + logger.debug("row_size: %s", retval) + return retval + + def _ewma_vectorized_safe(self) -> None: + """ Perform the vectorized exponential moving average in a safe way. """ + num_rows = int(self._data.size // self._row_size) # the number of rows to use + leftover = int(self._data.size % self._row_size) # the amount of data leftover + first_offset = self._data[0] + + if leftover > 0: + # set temporary results to slice view of out parameter + out_main_view = np.reshape(self._out[:-leftover], (num_rows, self._row_size)) + data_main_view = np.reshape(self._data[:-leftover], (num_rows, self._row_size)) + else: + out_main_view = self._out.reshape(-1, self._row_size) + data_main_view = self._data.reshape(-1, self._row_size) + + self._ewma_vectorized_2d(data_main_view, out_main_view) # get the scaled cumulative sums + + scaling_factors = (1 - self._alpha) ** np.arange(1, self._row_size + 1) + last_scaling_factor = scaling_factors[-1] + + # create offset array + offsets = np.empty(out_main_view.shape[0], dtype=self._dtype) + offsets[0] = first_offset + # iteratively calculate offset for each row + + for i in range(1, out_main_view.shape[0]): + offsets[i] = offsets[i - 1] * last_scaling_factor + out_main_view[i - 1, -1] + + # add the offsets to the result + out_main_view += offsets[:, np.newaxis] * scaling_factors[np.newaxis, :] + + if leftover > 0: + # process trailing data in the 2nd slice of the out parameter + self._ewma_vectorized(self._data[-leftover:], + self._out[-leftover:], + offset=out_main_view[-1, -1]) + + def _ewma_vectorized(self, + data: np.ndarray, + out: np.ndarray, + offset: float | None = None) -> None: + """ Calculates the exponential moving average over a vector. Will fail for large inputs. + + The result is processed in place into the array passed to the `out` parameter + + Parameters + ---------- + data : :class:`numpy.ndarray` + A 1 dimensional numpy array to obtain smoothed data for + out : :class:`numpy.ndarray` + A location into which the result is stored. It must have the same shape and dtype as + the input data + offset : float, optional + The offset for the moving average, scalar. Default: the value held in data[0]. + """ + if data.size < 1: # empty input, return empty array + return + + offset = data[0] if offset is None else offset + + # scaling_factors -> 0 as len(data) gets large. This leads to divide-by-zeros below + scaling_factors = np.power(1. - self._alpha, np.arange(data.size + 1, dtype=self._dtype), + dtype=self._dtype) + # create cumulative sum array + np.multiply(data, (self._alpha * scaling_factors[-2]) / scaling_factors[:-1], + dtype=self._dtype, out=out) + np.cumsum(out, dtype=self._dtype, out=out) + + out /= scaling_factors[-2::-1] # cumulative sums / scaling + + if offset != 0: + noffset = np.asarray(offset).astype(self._dtype, copy=False) + out += noffset * scaling_factors[1:] + + def _ewma_vectorized_2d(self, data: np.ndarray, out: np.ndarray) -> None: + """ Calculates the exponential moving average over the last axis. + + The result is processed in place into the array passed to the `out` parameter + + Parameters + ---------- + data : :class:`numpy.ndarray` + A 1 or 2 dimensional numpy array to obtain smoothed data for. + out : :class:`numpy.ndarray` + A location into which the result is stored. It must have the same shape and dtype as + the input data + """ + if data.size < 1: # empty input, return empty array + return + + # calculate the moving average + scaling_factors = np.power(1. - self._alpha, np.arange(data.shape[1] + 1, + dtype=self._dtype), + dtype=self._dtype) + # create a scaled cumulative sum array + np.multiply(data, + np.multiply(self._alpha * scaling_factors[-2], + np.ones((data.shape[0], 1), dtype=self._dtype), + dtype=self._dtype) / scaling_factors[np.newaxis, :-1], + dtype=self._dtype, out=out) + np.cumsum(out, axis=1, dtype=self._dtype, out=out) + out /= scaling_factors[np.newaxis, -2::-1] + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/analysis/stats.py b/lib/gui/analysis/stats.py index 44f9651e3d..cb78e1f7e3 100644 --- a/lib/gui/analysis/stats.py +++ b/lib/gui/analysis/stats.py @@ -19,6 +19,9 @@ from lib.logger import parse_class_init from lib.serializer import get_serializer +from lib.utils import get_module_objects + +from .moving_average import ExponentialMovingAverage from .event_reader import TensorBoardLogs @@ -131,11 +134,13 @@ def initialize_session(self, if self._model_dir == model_folder and self._model_name == model_name: if is_training: assert self._tb_logs is not None - self._tb_logs.set_training(is_training) + if not self._tb_logs.set_training(is_training): + logger.debug("Resetting summary for updated log files") + self._summary = SessionsSummary(self) self._load_state_file() - self._is_training = True - logger.debug("Requested session is already loaded. Not initializing: (model_folder: " - "%s, model_name: %s)", model_folder, model_name) + self._is_training = is_training + logger.debug("Requested session is already loaded. Not initializing: " + "(model_folder: %s, model_name: %s)", model_folder, model_name) return self._is_training = is_training @@ -553,7 +558,7 @@ def __init__(self, session_id, # pylint:disable=too-many-positional-arguments smooth_amount: float = 0.90, flatten_outliers: bool = False) -> None: logger.debug(parse_class_init(locals())) - warnings.simplefilter("ignore", np.RankWarning) + warnings.simplefilter("ignore", np.exceptions.RankWarning) self._session_id = session_id @@ -835,7 +840,7 @@ def _calc_smoothed(self, data: np.ndarray) -> np.ndarray: :class:`numpy.ndarray` The smoothed data """ - retval = _ExponentialMovingAverage(data, self._args["smooth_amount"])() + retval = ExponentialMovingAverage(data, self._args["smooth_amount"])() logger.debug("Calculated Smoothed data: shape: %s", retval.shape) return retval @@ -865,165 +870,4 @@ def _calc_trend(cls, data: np.ndarray) -> np.ndarray: return trend -class _ExponentialMovingAverage(): - """ Reshapes data before calculating exponential moving average, then iterates once over the - rows to calculate the offset without precision issues. - - Parameters - ---------- - data: :class:`numpy.ndarray` - A 1 dimensional numpy array to obtain smoothed data for - amount: float - in the range (0.0, 1.0) The alpha parameter (smoothing amount) for the moving average. - - Notes - ----- - Adapted from: https://stackoverflow.com/questions/42869495 - """ - def __init__(self, data: np.ndarray, amount: float) -> None: - logger.debug(parse_class_init(locals())) - assert data.ndim == 1 - amount = min(max(amount, 0.001), 0.999) - - self._data = np.nan_to_num(data) - self._alpha = 1. - amount - self._dtype = "float32" if data.dtype == np.float32 else "float64" - self._row_size = self._get_max_row_size() - self._out = np.empty_like(data, dtype=self._dtype) - logger.debug("Initialized %s", self.__class__.__name__) - - def __call__(self) -> np.ndarray: - """ Perform the exponential moving average calculation. - - Returns - ------- - :class:`numpy.ndarray` - The smoothed data - """ - if self._data.size <= self._row_size: - self._ewma_vectorized(self._data, self._out) # Normal function can handle this input - else: - self._ewma_vectorized_safe() # Use the safe version - return self._out - - def _get_max_row_size(self) -> int: - """ Calculate the maximum row size for the running platform for the given dtype. - - Returns - ------- - int - The maximum row size possible on the running platform for the given :attr:`_dtype` - - Notes - ----- - Might not be the optimal value for speed, which is hard to predict due to numpy - optimizations. - """ - # Use :func:`np.finfo(dtype).eps` if you are worried about accuracy and want to be safe. - epsilon = np.finfo(self._dtype).tiny # pylint:disable=no-member - # If this produces an OverflowError, make epsilon larger: - retval = int(np.log(epsilon) / np.log(1 - self._alpha)) + 1 - logger.debug("row_size: %s", retval) - return retval - - def _ewma_vectorized_safe(self) -> None: - """ Perform the vectorized exponential moving average in a safe way. """ - num_rows = int(self._data.size // self._row_size) # the number of rows to use - leftover = int(self._data.size % self._row_size) # the amount of data leftover - first_offset = self._data[0] - - if leftover > 0: - # set temporary results to slice view of out parameter - out_main_view = np.reshape(self._out[:-leftover], (num_rows, self._row_size)) - data_main_view = np.reshape(self._data[:-leftover], (num_rows, self._row_size)) - else: - out_main_view = self._out.reshape(-1, self._row_size) - data_main_view = self._data.reshape(-1, self._row_size) - - self._ewma_vectorized_2d(data_main_view, out_main_view) # get the scaled cumulative sums - - scaling_factors = (1 - self._alpha) ** np.arange(1, self._row_size + 1) - last_scaling_factor = scaling_factors[-1] - - # create offset array - offsets = np.empty(out_main_view.shape[0], dtype=self._dtype) - offsets[0] = first_offset - # iteratively calculate offset for each row - - for i in range(1, out_main_view.shape[0]): - offsets[i] = offsets[i - 1] * last_scaling_factor + out_main_view[i - 1, -1] - - # add the offsets to the result - out_main_view += offsets[:, np.newaxis] * scaling_factors[np.newaxis, :] - - if leftover > 0: - # process trailing data in the 2nd slice of the out parameter - self._ewma_vectorized(self._data[-leftover:], - self._out[-leftover:], - offset=out_main_view[-1, -1]) - - def _ewma_vectorized(self, - data: np.ndarray, - out: np.ndarray, - offset: float | None = None) -> None: - """ Calculates the exponential moving average over a vector. Will fail for large inputs. - - The result is processed in place into the array passed to the `out` parameter - - Parameters - ---------- - data: :class:`numpy.ndarray` - A 1 dimensional numpy array to obtain smoothed data for - out: :class:`numpy.ndarray` - A location into which the result is stored. It must have the same shape and dtype as - the input data - offset: float, optional - The offset for the moving average, scalar. Default: the value held in data[0]. - """ - if data.size < 1: # empty input, return empty array - return - - offset = data[0] if offset is None else offset - - # scaling_factors -> 0 as len(data) gets large. This leads to divide-by-zeros below - scaling_factors = np.power(1. - self._alpha, np.arange(data.size + 1, dtype=self._dtype), - dtype=self._dtype) - # create cumulative sum array - np.multiply(data, (self._alpha * scaling_factors[-2]) / scaling_factors[:-1], - dtype=self._dtype, out=out) - np.cumsum(out, dtype=self._dtype, out=out) - - out /= scaling_factors[-2::-1] # cumulative sums / scaling - - if offset != 0: - noffset = np.array(offset, copy=False).astype(self._dtype, copy=False) - out += noffset * scaling_factors[1:] - - def _ewma_vectorized_2d(self, data: np.ndarray, out: np.ndarray) -> None: - """ Calculates the exponential moving average over the last axis. - - The result is processed in place into the array passed to the `out` parameter - - Parameters - ---------- - data: :class:`numpy.ndarray` - A 1 or 2 dimensional numpy array to obtain smoothed data for. - out: :class:`numpy.ndarray` - A location into which the result is stored. It must have the same shape and dtype as - the input data - """ - if data.size < 1: # empty input, return empty array - return - - # calculate the moving average - scaling_factors = np.power(1. - self._alpha, np.arange(data.shape[1] + 1, - dtype=self._dtype), - dtype=self._dtype) - # create a scaled cumulative sum array - np.multiply(data, - np.multiply(self._alpha * scaling_factors[-2], - np.ones((data.shape[0], 1), dtype=self._dtype), - dtype=self._dtype) / scaling_factors[np.newaxis, :-1], - dtype=self._dtype, out=out) - np.cumsum(out, axis=1, dtype=self._dtype, out=out) - out /= scaling_factors[np.newaxis, -2::-1] +__all__ = get_module_objects(__name__) diff --git a/lib/gui/command.py b/lib/gui/command.py index 95528be95f..1f1dbccd18 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -6,6 +6,8 @@ import tkinter as tk from tkinter import ttk +from lib.utils import get_module_objects + from .control_helper import ControlPanel from .custom_widgets import Tooltip from .utils import get_images, get_config @@ -198,3 +200,6 @@ def add_action_button(self, category, actionbtns): actionbtns[self.command] = btnact logger.debug("Added action buttons: '%s'", self.title) + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/control_helper.py b/lib/gui/control_helper.py index 5179256759..46c26a4017 100644 --- a/lib/gui/control_helper.py +++ b/lib/gui/control_helper.py @@ -1,19 +1,29 @@ #!/usr/bin/env python3 """ Helper functions and classes for GUI controls """ +from __future__ import annotations import gettext import logging import re - import tkinter as tk -import typing as T +import types + from tkinter import colorchooser, ttk from itertools import zip_longest from functools import partial +from typing import Any, cast, get_args, Literal, Self, TYPE_CHECKING from _tkinter import Tcl_Obj, TclError +from lib.logger import parse_class_init +from lib.utils import get_module_objects + from .custom_widgets import ContextMenu, MultiOption, ToggledFrame, Tooltip from .utils import FileHandler, get_config, get_images +from . import gui_config as cfg + +if TYPE_CHECKING: + from lib.config import ConfigItem + logger = logging.getLogger(__name__) @@ -24,9 +34,9 @@ # We store Tooltips, ContextMenus and Commands globally when they are created # Because we need to add them back to newly cloned widgets (they are not easily accessible from # original config or are prone to getting destroyed when the original widget is destroyed) -_RECREATE_OBJECTS: dict[str, dict[str, T.Any]] = {"tooltips": {}, - "commands": {}, - "contextmenus": {}} +_RECREATE_OBJECTS: dict[str, dict[str, Any]] = {"tooltips": {}, + "commands": {}, + "contextmenus": {}} def _get_tooltip(widget, text=None, text_variable=None): @@ -97,65 +107,74 @@ def set_slider_rounding(value, var, d_type, round_to, min_max): class ControlPanelOption(): - """ - A class to hold a control panel option. A list of these is expected - to be passed to the ControlPanel object. + """ A class to hold a control panel option. A list of these is expected to be passed to the + ControlPanel object. Parameters ---------- - title: str + title : str Title of the control. Will be used for label text and control naming - dtype: datatype object + dtype : type Datatype of the control. - group: str, optional + group : str | None, optional The group that this control should sit with. If provided, all controls in the same - group will be placed together. Default: None - subgroup: str, optional + group will be placed together. Default: ``None`` + subgroup : str | None, optional The subgroup that this option belongs to. If provided, will group options in the same subgroups together for the same layout as option/check boxes. Default: ``None`` - default: str, optional + default : str | bool | float | int | list[str] | None, optional Default value for the control. If None is provided, then action will be dictated by - whether "blank_nones" is set in ControlPanel - initial_value: str, optional - Initial value for the control. If None, default will be used - choices: list or tuple, object + whether "blank_nones" is set in ControlPanel. Default: ``None`` + initial_value : str | bool | float | int | list[str] | None, optional + Initial value for the control. If ``None``, default will be used. Default: ``None`` + choices : list[str] | tuple[str, ...] | Literal["colorchooser"] | None, optional Used for combo boxes and radio control option setting. Set to `"colorchooser"` for a color - selection dialog. - is_radio: bool, optional - Specifies to use a Radio control instead of combobox if choices are passed - is_multi_option: - Specifies to use a Multi Check Button option group for the specified control - rounding: int or float, optional - For slider controls. Sets the stepping - min_max: int or float, optional - For slider controls. Sets the min and max values - sysbrowser: dict, optional - Adds Filesystem browser buttons to ttk.Entry options. - Expects a dict: {sysbrowser: str, filetypes: str} - helptext: str, optional - Sets the tooltip text - track_modified: bool, optional + selection dialog. Default: ``None`` + is_radio : bool, optional + Specifies to use a Radio control instead of combobox if choices are passed. + Default: ``False`` + is_multi_option : bool, optional + Specifies to use a Multi Check Button option group for the specified control. + Default: ``False`` + rounding : int | float | None, optional + For slider controls. Sets the stepping. Default: ``None`` + min_max : tuple[int, int] | tuple[float, float] | None, optional + For slider controls. Sets the min and max values. Default: ``None`` + sysbrowser : dict[Literal["filetypes", "browser", "command", "destination", "action_option"], str | list[str]] | None, optional + Adds Filesystem browser buttons to ttk.Entry options. Default: ``None`` + helptext : str | None, optional + Sets the tooltip text. Default: ``None`` + track_modified : bool, optional Set whether to set a callback trace indicating that the parameter has been modified. - Default: False - command: str, optional - Required if tracking modified. The command that this option belongs to. Default: None - """ - - def __init__(self, title, dtype, # pylint:disable=too-many-arguments - group=None, subgroup=None, default=None, initial_value=None, choices=None, - is_radio=False, is_multi_option=False, rounding=None, min_max=None, - sysbrowser=None, helptext=None, track_modified=False, command=None): - logger.debug("Initializing %s: (title: '%s', dtype: %s, group: %s, subgroup: %s, " - "default: %s, initial_value: %s, choices: %s, is_radio: %s, " - "is_multi_option: %s, rounding: %s, min_max: %s, sysbrowser: %s, " - "helptext: '%s', track_modified: %s, command: '%s')", self.__class__.__name__, - title, dtype, group, subgroup, default, initial_value, choices, is_radio, - is_multi_option, rounding, min_max, sysbrowser, helptext, track_modified, - command) - + Default: ``False`` + command : str | None, optional + Required if tracking modified. The command that this option belongs to. Default: ``None`` + """ # noqa[E501] # pylint:disable=line-too-long + def __init__(self, # pylint:disable=too-many-arguments,too-many-positional-arguments,too-many-locals # noqa[E501] + title: str, + dtype: type, + group: str | None = None, + subgroup: str | None = None, + default: str | bool | float | int | None = None, + initial_value: str | bool | float | int | None = None, + choices: list[str] | tuple[str, ...] | Literal["colorchooser"] | None = None, + is_radio: bool = False, + is_multi_option: bool = False, + rounding: int | float | None = None, + min_max: tuple[int, int] | tuple[float, float] | None = None, + sysbrowser: dict[Literal["filetypes", + "browser", + "command", + "destination", + "action_option"], str | list[str]] | None = None, + helptext: str | None = None, + track_modified: bool = False, + command: str | None = None) -> None: + logger.debug(parse_class_init(locals())) self.dtype = dtype self.sysbrowser = sysbrowser self._command = command + self._track_modified = track_modified self._options = {"title": title, "subgroup": subgroup, "group": group, @@ -168,75 +187,119 @@ def __init__(self, title, dtype, # pylint:disable=too-many-arguments "min_max": min_max, "helptext": helptext} self.control = self.get_control() - self.tk_var = self.get_tk_var(initial_value, track_modified) + initial_value = default if initial_value is None else initial_value + initial_value = "" if initial_value is None else initial_value + self.tk_var = self.get_tk_var(initial_value) logger.debug("Initialized %s", self.__class__.__name__) + def __repr__(self) -> str: + """ Pretty printed representation for logging """ + non_opts = {"dtype": self.dtype, + "sysbrowser": self.sysbrowser, + "track_modified": self._track_modified} + params = non_opts | self._options + str_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) + return f"{self.__class__.__name__}({str_params})" + @property - def name(self): - """ Lowered title for naming """ - return self._options["title"].lower() + def name(self) -> str: + """ str : Lowered title for naming """ + title = self._options["title"] + assert isinstance(title, str) + return title.lower() @property def title(self): - """ Title case title for naming with underscores removed """ - return self._options["title"].replace("_", " ").title() + """ str : Title case title for naming with underscores removed """ + title = self._options["title"] + assert isinstance(title, str) + return title.replace("_", " ").title() @property - def group(self): - """ Return group or _master if no group set """ + def group(self) -> str: + """ str : Option group or "_master" if no group set """ group = self._options["group"] - group = "_master" if group is None else group + if group is None: + group = "_master" + assert isinstance(group, str) return group @property - def subgroup(self): - """ str: The subgroup for the option, or ``None`` if none provided. """ - return self._options["subgroup"] + def subgroup(self) -> str | None: + """ str | None : Option subgroup, or ``None`` if none provided. """ + retval = self._options["subgroup"] + if retval is not None: + assert isinstance(retval, str) + return retval @property - def default(self): - """ Return either selected value or default """ - return self._options["default"] + def default(self) -> str | bool | float | int | None: + """ str | bool | float | int | list[str] : Either the currently selected value or the + default """ + retval = self._options["default"] + assert isinstance(retval, (str, bool, float, int, types.NoneType)) + return retval @property - def value(self): - """ Return either initial value or default """ - val = self._options["initial_value"] - val = self.default if val is None else val - return val + def value(self) -> str | bool | float | int | None: + """ str | bool | float | int | list[str] : Either the initial value or default """ + retval = self._options["initial_value"] + retval = self.default if retval is None else retval + assert isinstance(retval, (str, bool, float, int, types.NoneType)) + return retval @property - def choices(self): - """ Return choices """ - return self._options["choices"] + def choices(self) -> list[str] | tuple[str, ...] | Literal["colorchooser"] | None: + """ list[str] | tuple[str, ...] | Literal["colorchooser"] : The option choices """ + retval = self._options["choices"] + if retval is not None: + assert isinstance(retval, (list, tuple, str)) + if isinstance(retval, str): + assert retval in get_args(Literal["colorchooser"]) + else: + assert all(isinstance(x, str) for x in retval) + return cast(list[str] | tuple[str, ...] | Literal["colorchooser"] | None, retval) @property - def is_radio(self): - """ Return is_radio """ - return self._options["is_radio"] + def is_radio(self) -> bool: + """ bool : If the option should be a radio control """ + retval = self._options["is_radio"] + assert isinstance(retval, bool) + return retval @property - def is_multi_option(self): - """ bool: ``True`` if the control should be contained in a multi check button group, + def is_multi_option(self) -> bool: + """ bool : ``True`` if the control should be contained in a multi check button group, otherwise ``False``. """ - return self._options["is_multi_option"] + retval = self._options["is_multi_option"] + assert isinstance(retval, bool) + return retval @property - def rounding(self): - """ Return rounding """ - return self._options["rounding"] + def rounding(self) -> int | float | None: + """ int | float | None : Rounding for numeric controls """ + retval = self._options["rounding"] + assert retval is None or isinstance(retval, (int, float)) + return retval @property - def min_max(self): - """ Return min_max """ - return self._options["min_max"] + def min_max(self) -> tuple[int, int] | tuple[float, float] | None: + """ tuple[int, int] | tuple[float, float] | None : minimum and maximum values for numeric + controls """ + retval = self._options["min_max"] + if retval is not None: + assert isinstance(retval, tuple) + assert len(retval) == 2 + assert isinstance(retval[0], (int, float)) and isinstance(retval[1], (int, float)) + return retval @property - def helptext(self): - """ Format and return help text for tooltips """ + def helptext(self) -> str | None: + """ str | None : The formatted option help text for tooltips """ helptext = self._options["helptext"] if helptext is None: return helptext + assert isinstance(helptext, str) logger.debug("Format control help: '%s'", self.name) if helptext.startswith("R|"): helptext = helptext[2:].replace("\nL|", "\n - ").replace("\n", "\n\n") @@ -246,8 +309,13 @@ def helptext(self): logger.debug("Formatted control help: (name: '%s', help: '%s'", self.name, helptext) return helptext - def get(self): - """ Return the value from the tk_var + def get(self) -> str | bool | int | float: + """ Return the option value from the tk_var + + Returns + ------- + str | bool | float | int + The value selected for this option Notes ----- @@ -267,23 +335,34 @@ def get(self): raise return val - def set(self, value): - """ Set the tk_var to a new value """ + def set(self, value: str | bool | int | float | None) -> None: + """ Set the variable for the config option with the given value + + Parameters + ---------- + value : str | bool | float | int | None + The value to set the config option variable to + """ self.tk_var.set(value) - def set_initial_value(self, value): + def set_initial_value(self, value: str | bool | int | float): """ Set the initial_value to the given value Parameters ---------- - value: varies + value : str | bool | int | float The value to set the initial value attribute to """ logger.debug("Setting inital value for %s to %s", self.name, value) self._options["initial_value"] = value - def get_control(self): + def get_control(self) -> Literal["radio", "multi", "colorchooser", "scale"] | type[ + ttk.Combobox] | type[ttk.Checkbutton] | type[tk.Entry]: """ Set the correct control type based on the datatype or for this option """ + control: Literal["radio", + "multi", + "colorchooser", + "scale"] | type[ttk.Combobox] | type[ttk.Checkbutton] | type[tk.Entry] if self.choices and self.is_radio: control = "radio" elif self.choices and self.is_multi_option: @@ -301,36 +380,58 @@ def get_control(self): logger.debug("Setting control '%s' to %s", self.title, control) return control - def get_tk_var(self, initial_value, track_modified): - """ Correct variable type for control """ + def get_tk_var(self, initial_value: str | bool | int | float) -> tk.Variable: + """ Correct variable type for control + + Parameters + ---------- + initial value : str | bool | int | float + The initial value to set the tk.Variable to + + Returns + ------- + :class:`tk.BooleanVar` | :class:`tk.IntVar` | :class:`tk.DoubleVar` | :class:`tk.StringVar` + The correct tk.Variable for the given initial value + """ + var: tk.Variable if self.dtype == bool: + assert isinstance(initial_value, bool) var = tk.BooleanVar() + var.set(initial_value) elif self.dtype == int: + assert isinstance(initial_value, int) var = tk.IntVar() + var.set(initial_value) elif self.dtype == float: + assert isinstance(initial_value, float) var = tk.DoubleVar() + var.set(initial_value) else: var = tk.StringVar() - if initial_value is not None: - var.set(initial_value) + var.set(cast(str, initial_value)) logger.debug("Setting tk variable: (name: '%s', dtype: %s, tk_var: %s, initial_value: %s)", self.name, self.dtype, var, initial_value) - if track_modified and self._command is not None: + if self._track_modified and self._command is not None: logger.debug("Tracking variable modification: %s", self.name) var.trace("w", lambda name, index, mode, cmd=self._command: self._modified_callback(cmd)) - if track_modified and self._command == "train" and self.title == "Model Dir": + if self._track_modified and self._command == "train" and self.title == "Model Dir": var.trace("w", lambda name, index, mode, v=var: self._model_callback(v)) return var @staticmethod - def _modified_callback(command): + def _modified_callback(command: str) -> None: """ Set the modified variable for this tab to TRUE On initial setup the notebook won't yet exist, and we don't want to track the changes for initial variables anyway, so make sure notebook exists prior to performing the callback + + Parameters + ---------- + command : str + The command to set the modified variable callback for """ config = get_config() if config.command_notebook is None: @@ -338,22 +439,67 @@ def _modified_callback(command): config.set_modified_true(command) @staticmethod - def _model_callback(var): - """ Set a callback to load model stats for existing models when a model - folder is selected """ + def _model_callback(tk_var: tk.StringVar) -> None: + """ Set a callback to load model stats for existing models when a model folder is selected + + Parameters + ---------- + tk_var : :class:`tkinter.StringVar` + The Tk variable to set the callback on + """ config = get_config() - if not config.user_config_dict["auto_load_model_stats"]: + if not cfg.auto_load_model_stats(): logger.debug("Session updating disabled by user config") return if config.tk_vars.running_task.get(): logger.debug("Task running. Not updating session") return - folder = var.get() + folder = tk_var.get() logger.debug("Setting analysis model folder callback: '%s'", folder) get_config().tk_vars.analysis_folder.set(folder) + @classmethod + def from_config_object(cls, title: str, option: ConfigItem) -> Self: + """ Create a GUI control panel option from a Faceswap ConfigItem + + Parameters + ---------- + title : str + The option title (that displays as a label in the GUI) + option : :class:`~lib.config.ConfigItem` + The faceswap object to create the Control Panel option from + + Returns + ------- + :class:`ControlPanelOption` + A GUI ControlPanelOption instance + """ + initial_value = option.value + if option.datatype == list and isinstance(initial_value, list): + # Split multi-select lists into space separated strings for tk variables + initial_value = " ".join(initial_value) + + default = ", ".join(option.default) if isinstance(option.default, list) else option.default + + logger.debug("Creating Gui Option '%s' from: %s", title, option) + + retval = cls( + title=title, + dtype=option.datatype, + group=option.group, + default=default, + initial_value=initial_value, + choices=option.choices, + is_radio=option.gui_radio, + is_multi_option=option.datatype == list, + rounding=option.rounding, + min_max=option.min_max, + helptext=option.helptext) + logger.debug("Created GUI option '%s': %s", title, retval) + return retval -class ControlPanel(ttk.Frame): # pylint:disable=too-many-ancestors + +class ControlPanel(ttk.Frame): # pylint:disable=too-many-ancestors,too-many-instance-attributes """ A Control Panel to hold control panel options. This class handles all of the formatting, placing and TK_Variables @@ -395,7 +541,7 @@ class ControlPanel(ttk.Frame): # pylint:disable=too-many-ancestors Default: ``True`` """ - def __init__(self, parent, options, # pylint:disable=too-many-arguments + def __init__(self, parent, options, # pylint:disable=too-many-arguments,too-many-positional-arguments # noqa[E501] label_width=20, columns=1, max_columns=4, option_columns=4, header_text=None, style=None, blank_nones=True, scrollbar=True): logger.debug("Initializing %s: (parent: '%s', options: %s, label_width: %s, columns: %s, " @@ -617,7 +763,7 @@ def __init__(self, parent, initial_columns, max_columns, style=""): @staticmethod def scale_column_width(original_size, original_fontsize): """ Scale the column width based on selected font size """ - font_size = get_config().user_config_dict["font_size"] + font_size = cfg.font_size() if font_size == original_fontsize: return original_size scale = 1 + (((font_size / original_fontsize) - 1) / 2) @@ -865,7 +1011,7 @@ def pack_widget_clones(self, widget_dicts, old_children=None, new_children=None) rc_menu = widget_dict["rc_menu"] if rc_menu is not None: # Re-initialize for new widget and bind - rc_menu.__init__(widget=clone) + rc_menu.__init__(widget=clone) # pylint:disable=unnecessary-dunder-call rc_menu.cm_bind() clone.pack(**widget_dict["pack_info"]) @@ -1370,3 +1516,6 @@ def ask_context(self, filepath, filetypes): if filename: logger.debug(filename) filepath.set(filename) + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/custom_widgets.py b/lib/gui/custom_widgets.py index d18d832ed8..90652376eb 100644 --- a/lib/gui/custom_widgets.py +++ b/lib/gui/custom_widgets.py @@ -11,6 +11,8 @@ import numpy as np +from lib.utils import get_module_objects + from .utils import get_config logger = logging.getLogger(__name__) @@ -636,7 +638,7 @@ def _unschedule(self): def _show(self): """ Show the tooltip """ - def tip_pos_calculator(widget, label, + def tip_pos_calculator(widget, label, # pylint:disable=too-many-locals *, tip_delta=(10, 5), pad=(5, 3, 5, 3)): """ Calculate the tooltip position """ @@ -1016,3 +1018,6 @@ def _toggle(self, event): # pylint:disable=unused-argument self.sub_frame.pack(fill=tk.X, expand=True) self._icon_var.set("-") self._toggle_var.set(1) + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/display.py b/lib/gui/display.py index 3729e35437..0c9dd07bde 100644 --- a/lib/gui/display.py +++ b/lib/gui/display.py @@ -11,6 +11,7 @@ from tkinter import ttk from lib.logger import parse_class_init +from lib.utils import get_module_objects from .display_analysis import Analysis from .display_command import GraphDisplay, PreviewExtract, PreviewTrain @@ -189,3 +190,6 @@ def _on_tab_change(self, event): # pylint:disable=unused-argument else: logger.debug("Object does not have on_tab_select method. Returning: '%s'", selected_object) + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/display_analysis.py b/lib/gui/display_analysis.py index bf37123040..127bf72a74 100644 --- a/lib/gui/display_analysis.py +++ b/lib/gui/display_analysis.py @@ -9,6 +9,7 @@ from tkinter import ttk from lib.logger import parse_class_init +from lib.utils import get_module_objects from .custom_widgets import Tooltip from .display_page import DisplayPage @@ -71,11 +72,8 @@ def set_vars(self): def on_tab_select(self): """ Callback for when the analysis tab is selected. - If Faceswap is currently training a model, then update the statistics with the latest - values. + Update the statistics with the latest values. """ - if not self.vars["is_training"].get(): - return logger.debug("Analysis update callback received") self._reset_session() @@ -587,3 +585,6 @@ def _data_popup_title(self): title = f"{model_name.title()} Model: Session #{selected_id}" logger.debug("Title: '%s'", title) return f"{title} - {model_dir}" + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/display_command.py b/lib/gui/display_command.py index 3cc2e31887..785085228f 100644 --- a/lib/gui/display_command.py +++ b/lib/gui/display_command.py @@ -11,6 +11,7 @@ from lib.logger import parse_class_init from lib.training.preview_tk import PreviewTk +from lib.utils import get_module_objects from .display_graph import TrainingGraph from .display_page import DisplayOptionalPage @@ -58,7 +59,7 @@ def add_child(self) -> None: """ Add the preview label child """ logger.debug("Adding child") preview = self.subnotebook_add_page(self.tabname, widget=None) - lblpreview = ttk.Label(preview, image=self._preview.image) + lblpreview = ttk.Label(preview, image=self._preview.image) # type:ignore[arg-type] lblpreview.pack(side=tk.TOP, anchor=tk.NW) Tooltip(lblpreview, text=self.helptext, wrap_length=200) @@ -110,9 +111,10 @@ def subnotebook_hide(self) -> None: def _add_option_refresh(self) -> None: """ Add refresh button to refresh preview immediately """ logger.debug("Adding refresh option") - btnrefresh = ttk.Button(self.optsframe, - image=get_images().icons["reload"], - command=lambda x="update": preview_trigger().set(x)) # type:ignore + btnrefresh = ttk.Button( + self.optsframe, + image=get_images().icons["reload"], # type:ignore[arg-type] + command=lambda x="update": preview_trigger().set(x)) # type:ignore[misc] btnrefresh.pack(padx=2, side=tk.RIGHT) Tooltip(btnrefresh, text=_("Preview updates at every model save. Click to refresh now."), @@ -124,8 +126,8 @@ def _add_option_mask_toggle(self) -> None: logger.debug("Adding mask toggle option") btntoggle = ttk.Button( self.optsframe, - image=get_images().icons["mask2"], - command=lambda x="mask_toggle": preview_trigger().set(x)) # type:ignore + image=get_images().icons["mask2"], # type:ignore[arg-type] + command=lambda x="mask_toggle": preview_trigger().set(x)) # type:ignore[misc] btntoggle.pack(padx=2, side=tk.RIGHT) Tooltip(btntoggle, text=_("Click to toggle mask overlay on and off."), @@ -233,7 +235,7 @@ def _add_option_refresh(self) -> None: logger.debug("Adding refresh option") tk_var = get_config().tk_vars.refresh_graph btnrefresh = ttk.Button(self.optsframe, - image=get_images().icons["reload"], + image=get_images().icons["reload"], # type:ignore[arg-type] command=lambda: tk_var.set(True)) btnrefresh.pack(padx=2, side=tk.RIGHT) Tooltip(btnrefresh, @@ -467,3 +469,6 @@ def close(self) -> None: logger.debug("Clearing: %s", name) graph.clear() super().close() + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/display_graph.py b/lib/gui/display_graph.py index c5f1304a81..9ae83f74a7 100755 --- a/lib/gui/display_graph.py +++ b/lib/gui/display_graph.py @@ -19,6 +19,7 @@ from matplotlib.backend_bases import NavigationToolbar2 from lib.logger import parse_class_init +from lib.utils import get_module_objects from .custom_widgets import Tooltip from .utils import get_config, get_images, LongRunningTask @@ -188,7 +189,8 @@ def _axes_set_yscale(self, scale: str) -> None: logger.debug("yscale: '%s'", scale) self._ax1.set_yscale(scale) - def _lines_sort(self, keys: list[str]) -> list[list[str | int | tuple[float]]]: + def _lines_sort(self, + keys: list[str]) -> list[list[str | int | tuple[float, float, float, float]]]: """ Sort the data keys into consistent order and set line color map and line width. Parameters @@ -199,7 +201,7 @@ def _lines_sort(self, keys: list[str]) -> list[list[str | int | tuple[float]]]: Returns ------- list - A list of loss keys with their corresponding line formatting and color information + list[list[str | int | tuple[float, float, float, float]]] """ logger.trace("Sorting lines") # type:ignore[attr-defined] raw_lines: list[list[str]] = [] @@ -247,7 +249,7 @@ def _lines_groupsize(raw_lines: list[list[str]], sorted_lines: list[list[str]]) def _lines_style(self, lines: list[list[str]], - groupsize: int) -> list[list[str | int | tuple[float]]]: + groupsize: int) -> list[list[str | int | tuple[float, float, float, float]]]: """ Obtain the color map and line width for each group. Parameters @@ -259,20 +261,22 @@ def _lines_style(self, Returns ------- - list + list[list[str | int | tuple[float, float, float, float]]] A list of loss keys with their corresponding line formatting and color information """ logger.trace("Setting lines style") # type:ignore[attr-defined] groups = int(len(lines) / groupsize) colours = self._lines_create_colors(groupsize, groups) widths = list(range(1, groups + 1)) - retval = T.cast(list[list[str | int | tuple[float]]], lines) + retval = T.cast(list[list[str | int | tuple[float, float, float, float]]], lines) for idx, item in enumerate(retval): linewidth = widths[idx // groupsize] item.extend((linewidth, colours[idx])) return retval - def _lines_create_colors(self, groupsize: int, groups: int) -> list[tuple[float]]: + def _lines_create_colors(self, + groupsize: int, + groups: int) -> list[tuple[float, float, float, float]]: """ Create the color maps. Parameters @@ -284,7 +288,7 @@ def _lines_create_colors(self, groupsize: int, groups: int) -> list[tuple[float] Returns ------- - list + list[tuple[float, float, float, float] The colour map for each group """ colours = [] @@ -490,8 +494,8 @@ class NavigationToolbar(NavigationToolbar2Tk): # pylint:disable=too-many-ancest pack_toolbar: bool, Optional Whether to pack the Tool bar or not. Default: ``True`` """ - toolitems = [t for t in NavigationToolbar2Tk.toolitems if - t[0] in ("Home", "Pan", "Zoom", "Save")] + toolitems = tuple(t for t in NavigationToolbar2Tk.toolitems if + t[0] in ("Home", "Pan", "Zoom", "Save")) def __init__(self, # pylint:disable=super-init-not-called canvas: FigureCanvasTkAgg, @@ -502,7 +506,7 @@ def __init__(self, # pylint:disable=super-init-not-called # Avoid using self.window (prefer self.canvas.get_tk_widget().master), # so that Tool implementations can reuse the methods. - ttk.Frame.__init__(self, # pylint:disable=non-parent-init-called + ttk.Frame.__init__(T.cast(ttk.Frame, self), # pylint:disable=non-parent-init-called master=window, width=int(canvas.figure.bbox.width), height=50) @@ -515,6 +519,9 @@ def __init__(self, # pylint:disable=super-init-not-called self._buttons = {} for text, tooltip_text, image_file, callback in self.toolitems: + assert isinstance(text, str) + assert isinstance(image_file, str) + assert isinstance(callback, str) self._buttons[text] = button = self._Button( btnframe, text, @@ -535,7 +542,7 @@ def __init__(self, # pylint:disable=super-init-not-called logger.debug("Initialized %s", self.__class__.__name__) @staticmethod - def _Button(frame: ttk.Frame, # pylint:disable=arguments-differ,arguments-renamed + def _Button(frame: ttk.Frame, # type:ignore[override] # pylint:disable=arguments-differ,arguments-renamed # noqa:E501 text: str, image_file: str, toggle: bool, @@ -571,11 +578,14 @@ def _Button(frame: ttk.Frame, # pylint:disable=arguments-differ,arguments-renam if not toggle: btn: ttk.Button | ttk.Checkbutton = ttk.Button(frame, text=text, - image=img, + image=img, # type:ignore[arg-type] command=command) else: var = tk.IntVar(master=frame) - btn = ttk.Checkbutton(frame, text=text, image=img, command=command, variable=var) + btn = ttk.Checkbutton(frame, + text=text, + image=img, # type:ignore[arg-type] + command=command, variable=var) # Original implementation uses tk Checkbuttons which have a select and deselect # method. These aren't available in ttk Checkbuttons, so we monkey patch the methods @@ -585,3 +595,6 @@ def _Button(frame: ttk.Frame, # pylint:disable=arguments-differ,arguments-renam btn.pack(side=tk.RIGHT, padx=2) return btn + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/display_page.py b/lib/gui/display_page.py index 5602c22f52..19444e57c3 100644 --- a/lib/gui/display_page.py +++ b/lib/gui/display_page.py @@ -6,6 +6,8 @@ import tkinter as tk from tkinter import ttk +from lib.utils import get_module_objects + from .custom_widgets import Tooltip from .utils import get_images @@ -141,8 +143,7 @@ def subnotebook_get_widgets(self): subnotebook frame """ logger.debug("Getting subnotebook widgets") for child in self.subnotebook.winfo_children(): - for widget in child.winfo_children(): - yield widget + yield from child.winfo_children() def subnotebook_get_titles_ids(self): """ Return tabs ids and titles """ @@ -285,3 +286,6 @@ def close(self): for child in self.winfo_children(): logger.debug("Destroying child: %s", child) child.destroy() + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/gui_config.py b/lib/gui/gui_config.py new file mode 100644 index 0000000000..a752e9ab0c --- /dev/null +++ b/lib/gui/gui_config.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +""" Default configurations for the GUI """ + +import logging +import os + +from tkinter import font as tk_font +from matplotlib import font_manager + +from lib.config import FaceswapConfig +from lib.config import ConfigItem +from lib.utils import get_module_objects, PROJECT_ROOT + +logger = logging.getLogger(__name__) + + +class _Config(FaceswapConfig): + """ Config File for GUI """ + def set_defaults(self, helptext="") -> None: + """ Set the default values for config """ + logger.debug("Setting defaults") + super().set_defaults( + helptext="Faceswap GUI Options.\nConfigure the appearance and behaviour of the GUI") + # Font choices cannot be added until tkinter has been launched + logger.debug("Adding font list from tkinter") + self.sections["global"].options["font"].choices = get_clean_fonts() + + +def get_commands() -> list[str]: + """ Return commands formatted for GUI + + Returns + ------- + list[str] + A list of faceswap and tools commands that can be displayed in Faceswap's GUI + """ + command_path = os.path.join(PROJECT_ROOT, "scripts") + tools_path = os.path.join(PROJECT_ROOT, "tools") + commands = [os.path.splitext(item)[0] for item in os.listdir(command_path) + if os.path.splitext(item)[1] == ".py" + and os.path.splitext(item)[0] not in ("gui", "fsmedia") + and not os.path.splitext(item)[0].startswith("_")] + tools = [os.path.splitext(item)[0] for item in os.listdir(tools_path) + if os.path.splitext(item)[1] == ".py" + and os.path.splitext(item)[0] not in ("gui", "cli") + and not os.path.splitext(item)[0].startswith("_")] + return commands + tools + + +def get_clean_fonts() -> list[str]: + """ Return a sane list of fonts for the system that has both regular and bold variants. + + Pre-pend "default" to the beginning of the list. + + Returns + ------- + list[str]: + A list of valid fonts for the system + """ + fmanager = font_manager.FontManager() + fonts: dict[str, dict[str, bool]] = {} + for fnt in fmanager.ttflist: + if str(fnt.weight) in ("400", "normal", "regular"): + fonts.setdefault(fnt.name, {})["regular"] = True + if str(fnt.weight) in ("700", "bold"): + fonts.setdefault(fnt.name, {})["bold"] = True + valid_fonts = {key for key, val in fonts.items() if len(val) == 2} + retval = sorted(list(valid_fonts.intersection(tk_font.families()))) + if not retval: + # Return the font list with any @prefixed or non-Unicode characters stripped and default + # prefixed + logger.debug("No bold/regular fonts found. Running simple filter") + retval = sorted([fnt for fnt in tk_font.families() + if not fnt.startswith("@") and not any(ord(c) > 127 for c in fnt)]) + return ["default"] + retval + + +fullscreen = ConfigItem( + datatype=bool, + default=False, + group="startup", + info="Start Faceswap maximized.") + + +tab = ConfigItem( + datatype=str, + default="extract", + group="startup", + info="Start Faceswap in this tab.", + choices=get_commands()) + + +options_panel_width = ConfigItem( + datatype=int, + default=30, + group="layout", + info="How wide the lefthand option panel is as a percentage of GUI width at " + "startup.", + min_max=(10, 90), + rounding=1) + + +console_panel_height = ConfigItem( + datatype=int, + default=20, + group="layout", + info="How tall the bottom console panel is as a percentage of GUI height at " + "startup.", + min_max=(10, 90), + rounding=1) + + +icon_size = ConfigItem( + datatype=int, + default=14, + group="layout", + info="Pixel size for icons. NB: Size is scaled by DPI.", + min_max=(10, 20), + rounding=1) + + +font = ConfigItem( + datatype=str, + default="default", + group="font", + info="Global font", + choices=["default"]) # Cannot get tk fonts until tk is loaded, so real value populated later + + +font_size = ConfigItem( + datatype=int, + default=9, + group="font", + info="Global font size.", + min_max=(6, 12), + rounding=1) + + +autosave_last_session = ConfigItem( + datatype=str, + default="prompt", + group="startup", + info="Automatically save the current settings on close and reload on startup" + "\n\tnever - Don't autosave session" + "\n\tprompt - Prompt to reload last session on launch" + "\n\talways - Always load last session on launch", + choices=["never", "prompt", "always"], + gui_radio=True) + + +timeout = ConfigItem( + datatype=int, + default=120, + group="behaviour", + info="Training can take some time to save and shutdown. Set the timeout " + "in seconds before giving up and force quitting.", + min_max=(10, 600), + rounding=10) + + +auto_load_model_stats = ConfigItem( + datatype=bool, + default=True, + group="behaviour", + info="Auto load model statistics into the Analysis tab when selecting a model " + "in Train or Convert tabs.") + + +def load_config(config_file: str | None = None) -> None: + """ Load the GUI configuration .ini file + + Parameters + ---------- + config_file : str | None, optional + Path to a custom .ini configuration file to load. Default: ``None`` (use default + configuration file) + """ + _Config(configfile=config_file) + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 460e08fd58..226b6be462 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -12,7 +12,7 @@ from lib.git import git from lib.multithreading import MultiThread from lib.serializer import get_serializer, Serializer -from lib.utils import FaceswapError +from lib.utils import FaceswapError, get_module_objects import update_deps from .popup_configure import open_popup @@ -174,6 +174,7 @@ def _build_recent_menu(self) -> None: logger.debug("Building Recent Files menu") serializer = get_serializer("json") menu_file = os.path.join(self._config.pathcache, ".recent.json") + recent_files = [] if not os.path.isfile(menu_file) or os.path.getsize(menu_file) == 0: self._clear_recent_files(serializer, menu_file) try: @@ -184,7 +185,6 @@ def _build_recent_menu(self) -> None: logger.warning("There was an error opening the recent files list so it has been " "reset.") self._clear_recent_files(serializer, menu_file) - recent_files = [] logger.debug("Loaded recent files: %s", recent_files) removed_files = [] @@ -259,7 +259,7 @@ def _output_sysinfo(self): self.root.config(cursor="watch") self._clear_console() try: - from lib.sysinfo import sysinfo # pylint:disable=import-outside-toplevel + from lib.system.sysinfo import sysinfo # pylint:disable=import-outside-toplevel info = sysinfo except Exception as err: # pylint:disable=broad-except info = f"Error obtaining system info: {str(err)}" @@ -568,8 +568,8 @@ def _project_btns(self) -> None: loader, kwargs = self._loader_and_kwargs(btntype) cmd = getattr(self._config.project, loader) btn = ttk.Button(frame, - image=get_images().icons[btntype], - command=lambda fn=cmd, kw=kwargs: fn(**kw)) # type:ignore + image=get_images().icons[btntype], # type:ignore[arg-type] + command=lambda fn=cmd, kw=kwargs: fn(**kw)) # type:ignore[misc] btn.pack(side=tk.LEFT, anchor=tk.W) hlp = self._set_help(btntype) Tooltip(btn, text=hlp, wrap_length=200) @@ -589,8 +589,8 @@ def _task_btns(self) -> None: cmd = getattr(self._config.tasks, loader) btn = ttk.Button( frame, - image=get_images().icons[btntype], - command=lambda fn=cmd, kw=kwargs: fn(**kw)) # type:ignore + image=get_images().icons[btntype], # type:ignore[arg-type] + command=lambda fn=cmd, kw=kwargs: fn(**kw)) # type:ignore[misc] btn.pack(side=tk.LEFT, anchor=tk.W) hlp = self._set_help(btntype) Tooltip(btn, text=hlp, wrap_length=200) @@ -606,8 +606,8 @@ def _settings_btns(self) -> None: logger.debug("Adding button: '%s'", btntype) btn = ttk.Button( frame, - image=get_images().icons[btntype], - command=lambda n=name: open_popup(name=n)) # type:ignore + image=get_images().icons[btntype], # type:ignore[arg-type] + command=lambda n=name: open_popup(name=n)) # type:ignore[misc] btn.pack(side=tk.LEFT, anchor=tk.W) hlp = _("Configure {} settings...").format(name.title()) Tooltip(btn, text=hlp, wrap_length=200) @@ -623,3 +623,6 @@ def _section_separator(self) -> None: frame.pack(side=tk.BOTTOM, fill=tk.X) separator = ttk.Separator(frame, orient="horizontal") separator.pack(fill=tk.X, side=tk.LEFT, expand=True) + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/options.py b/lib/gui/options.py index 2579d60233..c941910a6e 100644 --- a/lib/gui/options.py +++ b/lib/gui/options.py @@ -13,6 +13,8 @@ import typing as T from lib.cli import actions +from lib.utils import get_module_objects + from .utils import get_images from .control_helper import ControlPanelOption @@ -620,6 +622,8 @@ def gen_cli_arguments(self, command: str) -> T.Generator[tuple[str, ...], None, The generated command line arguments """ output_dir = None + switches = "" + args = [] for _, option in self._gen_command_options(command): str_val = str(option.cpanel_option.get()) switch = option.opts[0] @@ -631,7 +635,7 @@ def gen_cli_arguments(self, command: str) -> T.Generator[tuple[str, ...], None, continue if str_val == "True": # store_true just output the switch - yield (switch, ) + switches += switch[1:] continue if option.nargs is not None: @@ -639,11 +643,17 @@ def gen_cli_arguments(self, command: str) -> T.Generator[tuple[str, ...], None, val = [arg[1:-1] for arg in re.findall(r"\".+?\"", str_val)] else: val = str_val.split(" ") - retval = (switch, *val) + arg = (switch, *val) else: - retval = (switch, str_val) - yield retval + arg = (switch, str_val) + args.append(arg) + + switch_args = [] if not switches else [(f"-{switches}", )] + yield from switch_args + args if command in ("extract", "convert") and output_dir is not None: get_images().preview_extract.set_faceswap_output_path(output_dir, batch_mode=batch_mode) + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 6bdb725db4..405082c665 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -1,19 +1,17 @@ #!/usr/bin python3 -""" The pop-up window of the Faceswap GUI for the setting of configuration options. """ +"""The pop-up window of the Faceswap GUI for the setting of configuration options.""" from __future__ import annotations -from collections import OrderedDict -from configparser import ConfigParser import gettext import logging import os -import sys import tkinter as tk from tkinter import ttk import typing as T -from importlib import import_module - +from lib.config import get_configs +from lib.logger import parse_class_init from lib.serializer import get_serializer +from lib.utils import get_module_objects from .control_helper import ControlPanel, ControlPanelOption from .custom_widgets import Tooltip @@ -30,23 +28,22 @@ class _State(): - """ Holds the existing config files and the current state of the popup window. """ - def __init__(self): - self._popup = None - # The GUI Config cannot be scanned until GUI is launched, so this is populated - # on the first call to load the settings - self._configs = {} + """ + Holds the current state of the popup window, ensuring that only 1 instance can ever exist + """ + def __init__(self) -> None: + logger.debug(parse_class_init(locals())) + self._popup: _ConfigurePlugins | None = None - def open_popup(self, name=None): - """ Launch the popup, ensuring only one instance is ever open + def open_popup(self, name: str | None = None) -> None: + """Launch the popup, ensuring only one instance is ever open Parameters ---------- - name: str, Optional + name : str | None, Optional The name of the configuration file. Used for selecting the correct section if required. Set to ``None`` if no initial section should be selected. Default: ``None`` """ - self._scan_for_configs() logger.debug("name: %s", name) if self._popup is not None: logger.debug("Restoring existing popup") @@ -54,74 +51,34 @@ def open_popup(self, name=None): self._popup.deiconify() self._popup.lift() return - self._popup = _ConfigurePlugins(name, self._configs) + self._popup = _ConfigurePlugins(name) - def close_popup(self): - """ Destroy the open popup and remove it from tracking. """ + def close_popup(self) -> None: + """Destroy the open popup and remove it from tracking.""" if self._popup is None: - logger.info("No popup to close. Returning") + logger.debug("No popup to close. Returning") return + logger.debug("Destroying popup") self._popup.destroy() del self._popup self._popup = None - def _scan_for_configs(self): - """ Scan the plugin folders for configuration settings. Add in the GUI configuration also. - - Populates the attribute :attr:`_configs`. - """ - root_path = os.path.abspath(os.path.dirname(sys.argv[0])) - plugins_path = os.path.join(root_path, "plugins") - logger.debug("Scanning path: '%s'", plugins_path) - for dirpath, _, filenames in os.walk(plugins_path): - if "_config.py" in filenames: - plugin_type = os.path.split(dirpath)[-1] - config = self._load_config(plugin_type) - self._configs[plugin_type] = config - self._configs["gui"] = get_config().user_config - logger.debug("Configs loaded: %s", sorted(list(self._configs.keys()))) - - @classmethod - def _load_config(cls, plugin_type): - """ Load the config from disk. If the file doesn't exist, then it will be generated. - - Parameters - ---------- - plugin_type: str - The plugin type (i.e. extract, train convert) that the config should be loaded for - - Returns - ------- - :class:`lib.config.FaceswapConfig` - The Configuration for the selected plugin - """ - # Load config to generate default if doesn't exist - mod = ".".join(("plugins", plugin_type, "_config")) - module = import_module(mod) - config = module.Config(None) - logger.debug("Found '%s' config at '%s'", plugin_type, config.configfile) - return config - _STATE = _State() -open_popup = _STATE.open_popup # pylint:disable=invalid-name +open_popup = _STATE.open_popup class _ConfigurePlugins(tk.Toplevel): - """ Pop-up window for the setting of Faceswap Configuration Options. + """Pop-up window for the setting of Faceswap Configuration Options. Parameters ---------- - name: str + name : str | None The name of the section that is being navigated to. Used for opening on the correct - page in the Tree View. - configurations: dict - Dictionary containing the :class:`~lib.config.FaceswapConfig` object for each - configuration section for the requested pop-up window + page in the Tree View. ``None`` to open on the first page """ - def __init__(self, name, configurations): - logger.debug("Initializing %s: (name: %s, configurations: %s)", - self.__class__.__name__, name, configurations) + def __init__(self, name: str | None) -> None: + logger.debug(parse_class_init(locals())) super().__init__() self._root = get_config().root self._set_geometry() @@ -132,10 +89,10 @@ def __init__(self, name, configurations): header_frame = self._build_header() content_frame = ttk.Frame(self) - self._tree = _Tree(content_frame, configurations, name, theme).tree + self._tree = _Tree(content_frame, name, theme).tree self._tree.bind("", self._select_item) - self._opts_frame = DisplayArea(self, content_frame, configurations, self._tree, theme) + self._opts_frame = DisplayArea(self, content_frame, self._tree, theme) self._opts_frame.pack(fill=tk.BOTH, expand=True, side=tk.RIGHT) footer_frame = self._build_footer() @@ -146,16 +103,19 @@ def __init__(self, name, configurations): select = name if name else self._tree.get_children()[0] self._tree.selection_set(select) self._tree.focus(select) - self._select_item(0) + self._select_item(0) # type:ignore[arg-type] self.title("Configure Settings") - self.tk.call('wm', 'iconphoto', self._w, get_images().icons["favicon"]) + self.tk.call('wm', + 'iconphoto', + self._w, # type:ignore[attr-defined] + get_images().icons["favicon"]) self.protocol("WM_DELETE_WINDOW", _STATE.close_popup) logger.debug("Initialized %s", self.__class__.__name__) - def _set_geometry(self): - """ Set the geometry of the pop-up window """ + def _set_geometry(self) -> None: + """Set the geometry of the pop-up window""" scaling_factor = get_config().scaling_factor pos_x = self._root.winfo_x() + 80 pos_y = self._root.winfo_y() + 80 @@ -164,8 +124,14 @@ def _set_geometry(self): logger.debug("Pop up Geometry: %sx%s, %s+%s", width, height, pos_x, pos_y) self.geometry(f"{width}x{height}+{pos_x}+{pos_y}") - def _build_header(self): - """ Build the main header text and separator. """ + def _build_header(self) -> ttk.Frame: + """Build the main header text and separator. + + Returns + ------- + :class:`tkinter.ttk.Frame` + The header of the popup configuration window + """ header_frame = ttk.Frame(self) lbl_frame = ttk.Frame(header_frame) @@ -182,8 +148,14 @@ def _build_header(self): sep.pack(fill=tk.X, pady=(1, 0), side=tk.BOTTOM) return header_frame - def _build_footer(self): - """ Build the main footer buttons and separator. """ + def _build_footer(self) -> ttk.Frame: + """Build the main footer buttons and separator. + + Returns + ------- + :class:`ttk.Frame` + The footer of the popup configuration window + """ logger.debug("Adding action buttons") frame = ttk.Frame(self) left_frame = ttk.Frame(frame) @@ -229,15 +201,15 @@ def _build_footer(self): logger.debug("Added action buttons") return frame - def _select_item(self, event): # pylint:disable=unused-argument - """ Update the session summary info with the selected item or launch graph. + def _select_item(self, event: tk.Event) -> None: # pylint:disable=unused-argument + """Update the session summary info with the selected item or launch graph. If the mouse is clicked on the graph icon, then the session summary pop-up graph is launched. Otherwise the selected ID is stored. Parameters ---------- - event: :class:`tkinter.Event` + event : :class:`tkinter.Event` The tkinter mouse button release event. Unused. """ selection = self._tree.focus() @@ -248,27 +220,25 @@ def _select_item(self, event): # pylint:disable=unused-argument class _Tree(ttk.Frame): # pylint:disable=too-many-ancestors - """ Frame that holds the Tree View Navigator and scroll bar for the configuration pop-up. + """Frame that holds the Tree View Navigator and scroll bar for the configuration pop-up. Parameters ---------- - parent: :class:`tkinter.ttk.Frame` + parent : :class:`tkinter.ttk.Frame` The parent frame to the Tree View area - configurations: dict - Dictionary containing the :class:`~lib.config.FaceswapConfig` object for each - configuration section for the requested pop-up window - name: str + name : str | None The name of the section that is being navigated to. Used for opening on the correct page in the Tree View. ``None`` if no specific area is being navigated to - theme: dict + theme : dict[str, Any] The color mapping for the settings pop-up theme """ - def __init__(self, parent, configurations, name, theme): + def __init__(self, parent: ttk.Frame, name: str | None, theme: dict[str, T.Any]): + logger.debug(parse_class_init(locals())) super().__init__(parent) self._fix_styles(theme) frame = ttk.Frame(self, relief=tk.SOLID, borderwidth=1) - self._tree = self._build_tree(frame, configurations, name) + self._tree = self._build_tree(frame, name) scrollbar = ttk.Scrollbar(frame, orient="vertical", command=self._tree.yview) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) @@ -278,20 +248,20 @@ def __init__(self, parent, configurations, name, theme): self.pack(side=tk.LEFT, fill=tk.Y) @property - def tree(self): - """ :class:`tkinter.ttk.TreeView` The Tree View held within the frame """ + def tree(self) -> ttk.Treeview: + """:class:`tkinter.ttk.Treeview` The Tree View held within the frame""" return self._tree @classmethod - def _fix_styles(cls, theme): - """ Tkinter has a bug when setting the background style on certain OSes. This fixes the + def _fix_styles(cls, theme: dict[str, T.Any]) -> None: + """Tkinter has a bug when setting the background style on certain OSes. This fixes the issue so we can set different colored backgrounds. We also set some default styles for our tree view. Parameters ---------- - theme: dict + theme: dict[str, Any] The color mapping for the settings pop-up theme """ style = ttk.Style() @@ -306,60 +276,25 @@ def _fix_styles(cls, theme): # Set colors style.map("ConfigNav.Treeview", - foreground=fix_map("foreground"), - background=fix_map("background")) + foreground=fix_map("foreground"), # type:ignore[arg-type] + background=fix_map("background")) # type:ignore[arg-type] style.map('ConfigNav.Treeview', background=[('selected', theme["tree_select"])]) - def _build_tree(self, parent, configurations, name): - """ Build the configuration pop-up window. - - Parameters - ---------- - configurations: dict - Dictionary containing the :class:`~lib.config.FaceswapConfig` object for each - configuration section for the requested pop-up window - name: str - The name of the section that is being navigated to. Used for opening on the correct - page in the Tree View. ``None`` if no specific area is being navigated to - - Returns - ------- - :class:`tkinter.ttk.TreeView` - The populated tree view - """ - logger.debug("Building Tree View Navigator") - tree = ttk.Treeview(parent, show="tree", style="ConfigNav.Treeview") - data = {category: [sect.split(".") for sect in sorted(conf.config.sections())] - for category, conf in configurations.items()} - ordered = sorted(list(data.keys())) - categories = ["extract", "train", "convert"] - categories += [x for x in ordered if x not in categories] - - for cat in categories: - img = get_images().icons.get(f"settings_{cat}", "") - text = cat.replace("_", " ").title() - text = " " + text if img else text - is_open = tk.TRUE if name is None or name == cat else tk.FALSE - tree.insert("", "end", cat, text=text, image=img, open=is_open, tags="category") - self._process_sections(tree, data[cat], cat, name == cat) - - tree.tag_configure('category', background='#DFDFDF') - tree.tag_configure('section', background='#E8E8E8') - tree.tag_configure('option', background='#F0F0F0') - logger.debug("Tree View Navigator") - return tree - @classmethod - def _process_sections(cls, tree, sections, category, is_open): - """ Process the sections of a category's configuration. + def _process_sections(cls, + tree: ttk.Treeview, + sections: list[list[str]], + category: str, + is_open: bool) -> None: + """Process the sections of a category's configuration. Creates a category's sections, then the sub options for that category Parameters ---------- - tree: :class:`tkinter.ttk.TreeView` + tree: :class:`tkinter.ttk.Treeview` The tree view to insert sections into - sections: list + sections: list[list[str]] The sections to insert into the Tree View category: str The category node that these sections sit in @@ -383,112 +318,126 @@ def _process_sections(cls, tree, sections, category, is_open): opt_text = opt.replace("_", " ").title() tree.insert(section_id, "end", opt_id, text=opt_text, open=is_open, tags="option") + def _build_tree(self, parent: ttk.Frame, name: str | None) -> ttk.Treeview: + """Build the configuration pop-up window. + + Parameters + ---------- + parent : :class:`tkinter.ttk.Frame` + The parent frame that holds the treeview + name : str | None + The name of the section that is being navigated to. Used for opening on the correct + page in the Tree View. ``None`` if no specific area is being navigated to + + Returns + ------- + :class:`tkinter.ttk.Treeview` + The populated tree view + """ + logger.debug("Building Tree View Navigator") + tree = ttk.Treeview(parent, show="tree", style="ConfigNav.Treeview") + data = {category: [sect.split(".") for sect in sorted(conf.sections)] + for category, conf in get_configs().items()} + ordered = sorted(list(data.keys())) + categories = ["extract", "train", "convert"] + categories += [x for x in ordered if x not in categories] + + for cat in categories: + img = get_images().icons.get(f"settings_{cat}", "") + text = cat.replace("_", " ").title() + text = " " + text if img else text + is_open = tk.TRUE if name is None or name == cat else tk.FALSE + tree.insert("", "end", cat, text=text, image=img, open=is_open, tags="category") + self._process_sections(tree, data[cat], cat, name == cat) + + tree.tag_configure('category', background='#DFDFDF') + tree.tag_configure('section', background='#E8E8E8') + tree.tag_configure('option', background='#F0F0F0') + logger.debug("Tree View Navigator") + return tree + class DisplayArea(ttk.Frame): # pylint:disable=too-many-ancestors - """ The option configuration area of the pop up options. + """The option configuration area of the pop up options. Parameters ---------- - top_level: :class:``tk.Toplevel`` + top_level : :class:``tk.Toplevel`` The tkinter Top Level widget - parent: :class:`tkinter.ttk.Frame` + parent : :class:`tkinter.ttk.Frame` The parent frame that holds the Display Area of the pop up configuration window - tree: :class:`tkinter.ttk.TreeView` + tree : :class:`tkinter.ttk.Treeview` The Tree View navigator for the pop up configuration window - configurations: dict - Dictionary containing the :class:`~lib.config.FaceswapConfig` object for each - configuration section for the requested pop-up window - theme: dict + theme : dict[str, Any] The color mapping for the settings pop-up theme """ - def __init__(self, top_level, parent, configurations, tree, theme): + def __init__(self, + top_level: tk.Toplevel, + parent: ttk.Frame, + tree: ttk.Treeview, + theme: dict[str, T.Any]) -> None: + logger.debug(parse_class_init(locals())) super().__init__(parent) - self._configs: dict[str, FaceswapConfig] = configurations self._theme = theme self._tree = tree - self._vars = {} - self._cache = {} + self._vars: dict[str, tk.StringVar] = {} + self._cache: dict[str, ttk.Frame] = {} self._config_cpanel_dict = self._get_config() - self._displayed_frame = None - self._displayed_key = None + self._displayed_frame: ttk.Frame | None = None + self._displayed_key: str | None = None self._presets = _Presets(self, top_level) self._build_header() @property - def displayed_key(self): - """ str: The current display page's lookup key for configuration options. """ + def displayed_key(self) -> str | None: + """str : The current display page's lookup key for configuration options.""" return self._displayed_key @property - def config_dict(self): - """ dict: The configuration dictionary for all display pages. """ + def config_dict(self) -> dict[str, dict[str, str | dict[str, ControlPanelOption]]]: + """ + dict[str, dict[str, str | dict[str, ControlPanelOption]]] : The configuration + dictionary for all display pages. + """ return self._config_cpanel_dict - def _get_config(self): - """ Format the configuration options stored in :attr:`_config` into a dict of - :class:`~lib.gui.control_helper.ControlPanelOption's for placement into option frames. + def _get_config(self) -> dict[str, dict[str, str | dict[str, ControlPanelOption]]]: + """ + Format the configuration options stored in :attr:`lib.config.FACESWAP_CONFIGS` into a + dict of :class:`~lib.gui.control_helper.ControlPanelOption's for placement into option + frames. Returns ------- - dict + dict[str, dict[str, str | dict[str, class:`~lib.gui.control_helper.ControlPanelOption`]]] A dictionary of section names to :class:`~lib.gui.control_helper.ControlPanelOption` objects """ logger.debug("Formatting Config for GUI") - retval = {} - for plugin, conf in self._configs.items(): - for section in conf.config.sections(): - conf.section = section - category = section.split(".")[0] - sect = section.split(".")[-1] + retval: dict[str, dict[str, str | dict[str, ControlPanelOption]]] = {} + for plugin, conf in get_configs().items(): + for section_name, section in conf.sections.items(): + category = section_name.split(".")[0] + sect = section_name.split(".")[-1] # Elevate global to root key = plugin if sect == "global" else f"{plugin}|{category}|{sect}" - retval[key] = {"helptext": None, "options": OrderedDict()} - - retval[key]["helptext"] = conf.defaults[section].helptext - for option, params in conf.defaults[section].items.items(): - initial_value = conf.config_dict[option] - initial_value = "none" if initial_value is None else initial_value - if params.datatype == list and isinstance(initial_value, list): - # Split multi-select lists into space separated strings for tk variables - initial_value = " ".join(initial_value) - - retval[key]["options"][option] = ControlPanelOption( - title=option, - dtype=params.datatype, - group=params.group, - default=params.default, - initial_value=initial_value, - choices=params.choices, - is_radio=params.gui_radio, - is_multi_option=params.datatype == list, - rounding=params.rounding, - min_max=params.min_max, - helptext=params.helptext) + retval[key] = {"helptext": section.helptext, "options": {}} + cp_options: dict[str, ControlPanelOption] = {} + for option_name, option in section.options.items(): + cp_options[option_name] = ControlPanelOption.from_config_object(option_name, + option) + + retval[key] = {"helptext": section.helptext, "options": cp_options} logger.debug("Formatted Config for GUI: %s", retval) return retval - def _build_header(self): - """ Build the dynamic header text. """ - header_frame = ttk.Frame(self) - lbl_frame = ttk.Frame(header_frame) - - var = tk.StringVar() - lbl = ttk.Label(lbl_frame, textvariable=var, anchor=tk.W, style="SPanel.Header2.TLabel") - lbl.pack(fill=tk.X, expand=True, side=tk.TOP) - - self._build_presets_buttons(header_frame) - lbl_frame.pack(fill=tk.X, side=tk.LEFT, expand=True) - header_frame.pack(fill=tk.X, padx=5, pady=5, side=tk.TOP) - self._vars["header"] = var - - def _build_presets_buttons(self, frame): - """ Build the section that holds the preset load and save buttons. + def _build_presets_buttons(self, frame: ttk.Frame) -> None: + """Build the section that holds the preset load and save buttons. Parameters ---------- - frame: :class:`ttk.Frame` + frame : :class:`ttk.Frame` The frame that holds the preset buttons """ presets_frame = ttk.Frame(frame) @@ -500,47 +449,61 @@ def _build_presets_buttons(self, frame): btn.pack(padx=2, side=tk.LEFT) presets_frame.pack(side=tk.RIGHT) - def select_options(self, section, subsections): - """ Display the page for the given section and subsections. + def _build_header(self) -> None: + """Build the dynamic header text.""" + header_frame = ttk.Frame(self) + lbl_frame = ttk.Frame(header_frame) + + var = tk.StringVar() + lbl = ttk.Label(lbl_frame, textvariable=var, anchor=tk.W, style="SPanel.Header2.TLabel") + lbl.pack(fill=tk.X, expand=True, side=tk.TOP) - Parameters - ---------- - section: str - The main section to be navigated to (or root node) - subsections: list - The full list of subsections ending on the required node - """ - labels = ["global"] if not subsections else subsections - self._vars["header"].set(" - ".join(sect.replace("_", " ").title() for sect in labels)) - self._set_display(section, subsections) + self._build_presets_buttons(header_frame) + lbl_frame.pack(fill=tk.X, side=tk.LEFT, expand=True) + header_frame.pack(fill=tk.X, padx=5, pady=5, side=tk.TOP) + self._vars["header"] = var - def _set_display(self, section, subsections): - """ Set the correct display page for the given section and subsections. + def _create_links_page(self, key: str) -> ttk.Frame: + """For headings which don't have settings, build a links page to the subsections. Parameters ---------- - section: str - The main section to be navigated to (or root node) - subsections: list - The full list of subsections ending on the required node + key : str + The lookup key to set the links page for + + Returns + ------- + :class:`tkinter.ttk.Frame` + The created links page """ - key = "|".join([section] + subsections) - if self._displayed_frame is not None: - self._displayed_frame.pack_forget() + frame = ttk.Frame(self) + links = {item.replace(key, "")[1:].split("|")[0] + for item in self._config_cpanel_dict + if item.startswith(key)} - if key not in self._cache: - self._cache_page(key) + if not links: + return frame - self._displayed_frame = self._cache[key] - self._displayed_key = key - self._displayed_frame.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True) + header_lbl = ttk.Label(frame, text=_("Select a plugin to configure:")) + header_lbl.pack(side=tk.TOP, fill=tk.X, padx=5, pady=(5, 10)) + for link in sorted(links): + lbl = ttk.Label(frame, + text=link.replace("_", " ").title(), + anchor=tk.W, + foreground=self._theme["link_color"], + cursor="hand2") + lbl.pack(side=tk.TOP, fill=tk.X, padx=10, pady=(0, 5)) + bind = f"{key}|{link}" + lbl.bind("", lambda e, x=bind: self._link_callback(x)) # type:ignore[misc] + + return frame - def _cache_page(self, key): - """ Create the control panel options for the requested configuration and cache. + def _cache_page(self, key: str) -> None: + """Create the control panel options for the requested configuration and cache. Parameters ---------- - key: str + key : str The lookup key to the settings cache """ info = self._config_cpanel_dict.get(key, None) @@ -548,8 +511,9 @@ def _cache_page(self, key): logger.debug("key '%s' does not exist in options. Creating links page.", key) self._cache[key] = self._create_links_page(key) else: + opts = T.cast(dict[str, dict[str, ControlPanelOption]], info["options"]) self._cache[key] = ControlPanel(self, - list(info["options"].values()), + list(opts.values()), header_text=info["helptext"], columns=1, max_columns=1, @@ -557,42 +521,47 @@ def _cache_page(self, key): style="SPanel", blank_nones=False) - def _create_links_page(self, key): - """ For headings which don't have settings, build a links page to the subsections. + def _set_display(self, section: str, subsections: list[str]) -> None: + """Set the correct display page for the given section and subsections. Parameters ---------- - key: str - The lookup key to set the links page for + section : str + The main section to be navigated to (or root node) + subsections : list + The full list of subsections ending on the required node """ - frame = ttk.Frame(self) - links = {item.replace(key, "")[1:].split("|")[0] - for item in self._config_cpanel_dict - if item.startswith(key)} + key = "|".join([section] + subsections) + if self._displayed_frame is not None: + self._displayed_frame.pack_forget() - if not links: - return frame + if key not in self._cache: + self._cache_page(key) - header_lbl = ttk.Label(frame, text=_("Select a plugin to configure:")) - header_lbl.pack(side=tk.TOP, fill=tk.X, padx=5, pady=(5, 10)) - for link in sorted(links): - lbl = ttk.Label(frame, - text=link.replace("_", " ").title(), - anchor=tk.W, - foreground=self._theme["link_color"], - cursor="hand2") - lbl.pack(side=tk.TOP, fill=tk.X, padx=10, pady=(0, 5)) - bind = f"{key}|{link}" - lbl.bind("", lambda e, x=bind: self._link_callback(x)) + self._displayed_frame = self._cache[key] + self._displayed_key = key + self._displayed_frame.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True) - return frame + def select_options(self, section: str, subsections: list[str]) -> None: + """Display the page for the given section and subsections. - def _link_callback(self, identifier): - """ Set the tree view to the selected item and display the requested page on a link click. + Parameters + ---------- + section : str + The main section to be navigated to (or root node) + subsections : list[str] + The full list of subsections ending on the required node + """ + labels = ["global"] if not subsections else subsections + self._vars["header"].set(" - ".join(sect.replace("_", " ").title() for sect in labels)) + self._set_display(section, subsections) + + def _link_callback(self, identifier: str): + """Set the tree view to the selected item and display the requested page on a link click. Parameters ---------- - identifier: str + identifier : str The identifier from the tree view for the page to display """ parent = "|".join(identifier.split("|")[:-1]) @@ -604,12 +573,12 @@ def _link_callback(self, identifier): subsections = split[1:] if len(split) > 1 else [] self.select_options(section, subsections) - def reset(self, page_only=False): - """ Reset all configuration options to their default values. + def reset(self, page_only: bool = False) -> None: + """Reset all configuration options to their default values. Parameters ---------- - page_only: bool, optional + page_only : bool, optional ``True`` resets just the currently selected page's options to default, ``False`` resets all plugins within the currently selected config to default. Default: ``False`` """ @@ -619,11 +588,12 @@ def reset(self, page_only=False): if selection not in self._config_cpanel_dict: logger.info("No configuration options to reset for current page: %s", selection) return - items = list(self._config_cpanel_dict[selection]["options"].values()) + items = list(T.cast(dict[str, ControlPanelOption], + self._config_cpanel_dict[selection]["options"]).values()) else: items = [opt for key, val in self._config_cpanel_dict.items() - for opt in val["options"].values() + for opt in T.cast(dict[str, ControlPanelOption], val["options"]).values() if key.startswith(selection.split("|")[0])] for item in items: logger.debug("Resetting item '%s' from '%s' to default '%s'", @@ -631,108 +601,103 @@ def reset(self, page_only=False): item.set(item.default) logger.debug("Reset config") - def _get_new_config(self, - page_only: bool, - config: FaceswapConfig, - category: str, - lookup: str) -> ConfigParser: - """ Obtain a new configuration file for saving + def _update_config(self, + page_only: bool, + config: FaceswapConfig, + category: str, + current_section: str) -> bool: + """Update the FaceswapConfig item from the currently selected options Parameters ---------- - page_only: bool + page_only : bool ``True`` saves just the currently selected page's options, ``False`` saves all the plugins options within the currently selected config. - config: :class:`~lib.config.FaceswapConfig` + config : :class:`~lib.config.FaceswapConfig` The original config that is to be addressed - category: str + category : str The configuration category to update - lookup: str + current_section : str The section of the configuration to update Returns ------- - :class:`configparse.ConfigParser` - The newly created configuration object for saving + bool + ``True`` if the config has been updated. ``False`` if it is unchanged """ - new_config = ConfigParser(allow_no_value=True) - for section_name, section in config.defaults.items(): - logger.debug("Adding section: '%s')", section_name) - config.insert_config_section(section_name, section.helptext, config=new_config) - for item, options in section.items.items(): - if item == "helptext": + retval = False + for section_name, section in config.sections.items(): + if page_only and section_name != current_section: + logger.debug("Skipping section '%s' for page_only save", section_name) + continue + key = category + key += f"|{section_name.replace('.', '|')}" if section_name != "global" else "" + gui_opts = T.cast(dict[str, ControlPanelOption], + self._config_cpanel_dict[key]["options"]) + for option_name, option in section.options.items(): + new_opt = gui_opts[option_name].get() + if new_opt == option.value or (isinstance(option.value, list) and + set(str(new_opt).split()) == set(option.value)): + logger.debug("Skipping unchanged option '%s'", option_name) continue - if page_only and section_name != lookup: - # Keep existing values for pages we are not updating - new_opt = config.get(section_name, item) - logger.debug("Retain existing value '%s' for %s", - new_opt, ".".join([section_name, item])) - else: - # Get currently selected value - key = category - if section_name != "global": - key += f"|{section_name.replace('.', '|')}" - new_opt = self._config_cpanel_dict[key]["options"][item].get() - logger.debug("Updating value to '%s' for %s", - new_opt, ".".join([section_name, item])) - helptext = config.format_help(options.helptext, is_section=False) - new_config.set(section_name, helptext) - if options.datatype == list: # Comma separated multi select options - assert isinstance(new_opt, (list, str)) - new_opt = ", ".join(new_opt if isinstance(new_opt, list) else new_opt.split()) - new_config.set(section_name, item, str(new_opt)) - - return new_config - - def save(self, page_only=False): - """ Save the configuration file to disk. + fmt_opt = str(new_opt).split() if isinstance(option.value, list) else new_opt + logger.debug("Updating '%s' from %s to %s", + option_name, repr(option.value), repr(fmt_opt)) + option.set(new_opt) + retval = True + return retval + + def save(self, page_only: bool = False) -> None: + """Save the configuration file to disk. Parameters ---------- - page_only: bool, optional + page_only : bool, optional ``True`` saves just the currently selected page's options, ``False`` saves all the plugins options within the currently selected config. Default: ``False`` """ logger.debug("Saving config") selection = self._tree.focus() category = selection.split("|")[0] - config = self._configs[category] - # Create a new config to pull through any defaults change + config = get_configs()[category] if "|" in selection: lookup = ".".join(selection.split("|")[1:]) else: # Expand global out from root node lookup = "global" - if page_only and lookup not in config.config.sections(): + if page_only and lookup not in config.sections: logger.info("No settings to save for the current page") return - config.config = self._get_new_config(page_only, config, category, lookup) + if not self._update_config(page_only, config, category, lookup): + logger.info("No config changes to save") + return + config.save_config() - logger.info("Saved config: '%s'", config.configfile) - - if category == "gui": - if not get_config().tk_vars.running_task.get(): - get_config().root.rebuild() - else: - logger.info("Can't redraw GUI whilst a task is running. GUI Settings will be " - "applied at the next restart.") logger.debug("Saved config") + if category != "gui": + return + + if not get_config().tk_vars.running_task.get(): + get_config().root.rebuild() # type:ignore[attr-defined] + else: + logger.info("Can't redraw GUI whilst a task is running. GUI Settings will be " + "applied at the next restart.") class _Presets(): - """ Handles the file dialog and loading and saving of plugin preset files. + """Handles the file dialog and loading and saving of plugin preset files. Parameters ---------- - parent: :class:`ttk.Frame` + parent : :class:`DisplayArea` The parent display area frame - top_level: :class:`tkinter.Toplevel` + top_level : :class:`tkinter.Toplevel` The top level pop up window """ - def __init__(self, parent, top_level): - logger.debug("Initializing: %s (top_level: %s)", self.__class__.__name__, top_level) + def __init__(self, parent: DisplayArea, top_level: tk.Toplevel): + logger.debug(parse_class_init(locals())) self._parent = parent self._popup = top_level self._base_path = os.path.join(PATHCACHE, "presets") @@ -740,18 +705,25 @@ def __init__(self, parent, top_level): logger.debug("Initialized: %s", self.__class__.__name__) @property - def _preset_path(self): - """ str: The path to the default preset folder for the currently displayed plugin. """ - return os.path.join(self._base_path, self._parent.displayed_key.split("|")[0]) + def _displayed_key(self) -> str: + """str : The currently displayed plugin key""" + retval = self._parent.displayed_key + assert retval is not None + return retval + + @property + def _preset_path(self) -> str: + """str : The path to the default preset folder for the currently displayed plugin.""" + return os.path.join(self._base_path, self._displayed_key.split("|")[0]) @property - def _full_key(self): - """ str: The full extrapolated lookup key for the currently displayed page. """ - full_key = self._parent.displayed_key + def _full_key(self) -> str: + """str : The full extrapolated lookup key for the currently displayed page.""" + full_key = self._displayed_key return full_key if "|" in full_key else f"{full_key}|global" - def load(self): - """ Action to perform when load preset button is pressed. + def load(self) -> None: + """Load a preset on a load preset button press. Loads parameters from a saved json file and updates the displayed page. """ @@ -770,7 +742,8 @@ def load(self): logger.debug("Loaded preset: %s", opts) - exist = self._parent.config_dict[self._parent.displayed_key]["options"] + exist = T.cast(dict[str, ControlPanelOption], + self._parent.config_dict[self._displayed_key]["options"]) for key, val in opts.items(): if key.startswith("__") or key not in exist: logger.debug("Skipping non-existent item: '%s'", key) @@ -779,8 +752,8 @@ def load(self): exist[key].set(val) logger.info("Preset loaded from: '%s'", os.path.basename(filename)) - def save(self): - """ Action to perform when save preset button is pressed. + def save(self) -> None: + """Save the preset when on a save preset button is press. Compiles currently displayed configuration options into a json file and saves into selected location. @@ -789,45 +762,53 @@ def save(self): if not filename: return - opts = self._parent.config_dict[self._parent.displayed_key]["options"] + opts = T.cast(dict[str, ControlPanelOption], + self._parent.config_dict[self._displayed_key]["options"]) preset = {opt: val.get() for opt, val in opts.items()} preset["__filetype"] = "faceswap_preset" preset["__section"] = self._full_key self._serializer.save(filename, preset) logger.info("Preset '%s' saved to: '%s'", self._full_key, filename) - def _get_filename(self, action): - """ Obtain the filename for load and save preset actions. + def _get_filename(self, action: T.Literal["load", "save"]) -> str | None: + """Obtain the filename for load and save preset actions. Parameters ---------- - action: ["load", "save"] + action : ["load", "save"] The preset action that is being performed Returns ------- - str: The requested preset filename + str | None + The requested preset filename. ``None`` if no filename found """ - if not self._parent.config_dict.get(self._parent.displayed_key): + if not self._parent.config_dict.get(self._displayed_key): logger.info("No settings to %s for the current page.", action) return None - args = ("save_filename", "json") if action == "save" else ("filename", "json") - kwargs = {"title": f"{action.title()} Preset...", - "initial_folder": self._preset_path, - "parent": self._parent} if action == "save": - kwargs["initial_file"] = self._get_initial_filename() + filename = FileHandler("save_filename", + "json", + title="Save Preset...", + initial_folder=self._preset_path, + parent=self._parent, + initial_file=self._get_initial_filename()).return_file + else: + filename = FileHandler("filename", + "json", + title="Load Preset...", + initial_folder=self._preset_path, + parent=self._parent).return_file - filename = FileHandler(*args, **kwargs).return_file if not filename: logger.debug("%s cancelled", action.title()) self._raise_toplevel() return filename - def _get_initial_filename(self): - """ Obtain the initial filename for saving a preset. + def _get_initial_filename(self) -> str: + """Obtain the initial filename for saving a preset. The name is based on the plugin's display key. A scan of the default presets folder is done to ensure no filename clash. If a filename does clash, then an integer is added to the end. @@ -851,9 +832,11 @@ def _get_initial_filename(self): logger.debug("Initial filename: %s", filename) return filename - def _raise_toplevel(self): - """ Opening a file dialog tends to hide the top level pop up, so bring back to the - fore. """ + def _raise_toplevel(self) -> None: + """Bring Toplevel to the top in case file dialog has hidden it.""" self._popup.update() self._popup.deiconify() self._popup.lift() + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/popup_session.py b/lib/gui/popup_session.py index 6ba9e2b4a6..f33743cabe 100644 --- a/lib/gui/popup_session.py +++ b/lib/gui/popup_session.py @@ -9,6 +9,8 @@ from dataclasses import dataclass, field from tkinter import ttk +from lib.utils import get_module_objects + from .control_helper import ControlBuilder, ControlPanelOption from .custom_widgets import Tooltip from .display_graph import SessionGraph @@ -23,7 +25,7 @@ @dataclass -class SessionTKVars: +class SessionTKVars: # pylint:disable=too-many-instance-attributes """ Dataclass for holding the tk variables required for the session popup Parameters @@ -270,13 +272,18 @@ def _opts_slider(self, frame: ttk.Frame) -> None: self._add_section(frame, "Parameters") logger.debug("Building Slider Controls") + text = "" + dtype: type[int] | type[float] = int + default: int | float = 0 + rounding = 0 + min_max: tuple[int, int | float] = (0, 0) for item in ("avgiterations", "smoothamount"): if item == "avgiterations": - dtype: type[int] | type[float] = int + dtype = int text = "Iterations to Average:" - default: int | float = 500 + default = 500 rounding = 25 - min_max: tuple[int, int | float] = (25, 2500) + min_max = (25, 2500) elif item == "smoothamount": dtype = float text = "Smoothing Amount:" @@ -311,7 +318,7 @@ def _opts_buttons(self, frame: ttk.Frame) -> None: for btntype in ("reload", "save"): cmd = getattr(self, f"_option_button_{btntype}") btn = ttk.Button(btnframe, - image=get_images().icons[btntype], + image=get_images().icons[btntype], # type:ignore[arg-type] command=cmd) hlp = self._set_help(btntype) Tooltip(btn, text=hlp, wrap_length=200) @@ -577,3 +584,6 @@ def _graph_build(self, *args) -> None: # pylint:disable=unused-argument self._vars.status.set("") self._vars.buildgraph.set(False) logger.debug("Built Graph") + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/project.py b/lib/gui/project.py index 1b5b2528a5..24a4a392fe 100644 --- a/lib/gui/project.py +++ b/lib/gui/project.py @@ -7,6 +7,9 @@ from tkinter import messagebox from lib.serializer import get_serializer +from lib.gui import gui_config as cfg +from lib.utils import get_module_objects + logger = logging.getLogger(__name__) @@ -886,7 +889,7 @@ def close(self, *args): # pylint:disable=unused-argument self.set_default_options() self._reset_modified_var() self._update_root_title() - self._config.set_active_tab_by_name(self._config.user_config_dict["tab"]) + self._config.set_active_tab_by_name(cfg.tab()) def confirm_close(self): """ Pop a message box to get confirmation that an unsaved project should be closed @@ -926,20 +929,15 @@ def __init__(self, config): if not self._enabled: return - if self._save_option == "prompt": + if cfg.autosave_last_session() == "prompt": self.ask_load() - elif self._save_option == "always": + elif cfg.autosave_last_session() == "always": self.load() - @property - def _save_option(self): - """ str: The user config autosave option. """ - return self._config.user_config_dict.get("autosave_last_session", "never") - @property def _enabled(self): """ bool: ``True`` if autosave is enabled otherwise ``False``. """ - return self._save_option != "never" + return cfg.autosave_last_session() != "never" def from_dict(self, options): """ Set the :attr:`_options` property based on the given options dictionary @@ -1030,3 +1028,6 @@ def save(self): if opts is not None: self._serializer.save(self._filename, opts) logger.debug("Saved last session. (filename: '%s', opts: %s", self._filename, opts) + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/theme.py b/lib/gui/theme.py index cdb42cbdba..5f84d08fa3 100644 --- a/lib/gui/theme.py +++ b/lib/gui/theme.py @@ -8,7 +8,7 @@ import numpy as np from lib.serializer import get_serializer -from lib.utils import FaceswapError +from lib.utils import FaceswapError, get_module_objects logger = logging.getLogger(__name__) @@ -313,7 +313,12 @@ def notebook(self, key, frame_border, tab_color, tab_selected, tab_hover): self._style.configure(f"{key}.TNotebook.Tab", padding=(6, 2, 6, 2), expand=(0, 0, 2)) self._style.configure(f"{key}.TNotebook.Tab", expand=("selected", (1, 2, 4, 2))) - def scrollbar(self, key, trough_color, border_color, control_backgrounds, control_foregrounds, + def scrollbar(self, # pylint:disable=too-many-locals + key, + trough_color, + border_color, + control_backgrounds, + control_foregrounds, control_borders): """ Create a custom scroll bar widget so we can control the colors. @@ -578,3 +583,6 @@ def _create_photoimage(self, background, foreground, border, pattern): for row in pattern) image.put("{" + pixels + "}") return image + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/utils/config.py b/lib/gui/utils/config.py index a0c2aa72f0..92661e5809 100644 --- a/lib/gui/utils/config.py +++ b/lib/gui/utils/config.py @@ -9,9 +9,11 @@ from dataclasses import dataclass, field -from lib.gui._config import Config as UserConfig +from lib.gui import gui_config as cfg from lib.gui.project import Project, Tasks from lib.gui.theme import Style +from lib.utils import get_module_objects, PROJECT_ROOT + from .file_handler import FileHandler if T.TYPE_CHECKING: @@ -22,7 +24,7 @@ logger = logging.getLogger(__name__) -PATHCACHE = os.path.join(os.path.realpath(os.path.dirname(sys.argv[0])), "lib", "gui", ".cache") +PATHCACHE = os.path.join(PROJECT_ROOT, "lib", "gui", ".cache") _CONFIG: Config | None = None @@ -155,7 +157,7 @@ class _GuiObjects: command_notebook: CommandNotebook | None = None -class Config(): +class Config(): # pylint:disable=too-many-public-methods """ The centralized configuration class for holding items that should be made available to all parts of the GUI. @@ -189,7 +191,6 @@ def __init__(self, tasks=Tasks(self, FileHandler), status_bar=statusbar) - self._user_config = UserConfig(None) self._style = Style(self.default_font, root, PATHCACHE) self._user_theme = self._style.user_theme logger.debug("Initialized %s", self.__class__.__name__) @@ -278,17 +279,6 @@ def _tools_tabs(self) -> dict[str, int]: assert self.command_notebook is not None return self.command_notebook.tools_tab_names - # Config - @property - def user_config(self) -> UserConfig: - """ dict: The GUI config in dict form. """ - return self._user_config - - @property - def user_config_dict(self) -> dict[str, T.Any]: # TODO Dataclass - """ dict: The GUI config in dict form. """ - return self._user_config.config_dict - @property def user_theme(self) -> dict[str, T.Any]: # TODO Dataclass """ dict: The GUI theme selection options. """ @@ -298,9 +288,9 @@ def user_theme(self) -> dict[str, T.Any]: # TODO Dataclass def default_font(self) -> tuple[str, int]: """ tuple: The selected font as configured in user settings. First item is the font (`str`) second item the font size (`int`). """ - font = self.user_config_dict["font"] + font = cfg.font() font = self._default_font if font == "default" else font - return (font, self.user_config_dict["font_size"]) + return (font, cfg.font_size()) @staticmethod def _get_scaling(root) -> float: @@ -382,10 +372,6 @@ def set_modified_true(self, command: str) -> None: tkvar.set(True) logger.debug("Set modified var to True for: '%s'", command) - def refresh_config(self) -> None: - """ Reload the user config from file. """ - self._user_config = UserConfig(None) - def set_cursor_busy(self, widget: tk.Widget | None = None) -> None: """ Set the root or widget cursor to busy. @@ -455,3 +441,6 @@ def set_geometry(self, width: int, height: int, fullscreen: bool = False) -> Non else: self.root.geometry(f"{str(initial_dimensions[0])}x{str(initial_dimensions[1])}+80+80") logger.debug("Geometry: %sx%s", *initial_dimensions) + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/utils/file_handler.py b/lib/gui/utils/file_handler.py index 4364eece3e..6d9c8ea0fc 100644 --- a/lib/gui/utils/file_handler.py +++ b/lib/gui/utils/file_handler.py @@ -3,12 +3,14 @@ import logging import platform import tkinter as tk -from tkinter import filedialog +from tkinter import filedialog, ttk import typing as T +from lib.utils import get_module_objects + logger = logging.getLogger(__name__) _FILETYPE = T.Literal["default", "alignments", "config_project", "config_task", - "config_all", "csv", "image", "ini", "state", "log", "video"] + "config_all", "csv", "image", "ini", "json", "state", "log", "video"] _HANDLETYPE = T.Literal["open", "save", "filename", "filename_multi", "save_filename", "context", "dir"] @@ -45,7 +47,7 @@ class FileHandler(): # pylint:disable=too-few-public-methods variable: str, optional Required for context handling file dialog, otherwise unused. The variable to associate with this file dialog. Default: ``None`` - parent: :class:`tkinter.Frame`, optional + parent: :class:`tkinter.Frame` | :class:`tkinter.ttk.Frame`, optional The parent that is launching the file dialog. ``None`` sets this to root. Default: ``None`` Attributes @@ -70,7 +72,7 @@ def __init__(self, command: str | None = None, action: str | None = None, variable: str | None = None, - parent: tk.Frame | None = None) -> None: + parent: tk.Frame | ttk.Frame | None = None) -> None: logger.debug("Initializing %s: (handle_type: '%s', file_type: '%s', title: '%s', " "initial_folder: '%s', initial_file: '%s', command: '%s', action: '%s', " "variable: %s, parent: %s)", self.__class__.__name__, handle_type, file_type, @@ -109,7 +111,7 @@ def _filetypes(self) -> dict[str, list[tuple[str, str]]]: all_files], "ini": [("Faceswap config files", "*.ini"), all_files], "json": [("JSON file", "*.json"), all_files], - "model": [("Keras model files", "*.h5"), all_files], + "model": [("Keras model files", "*.keras"), all_files], "state": [("State files", "*.json"), all_files], "log": [("Log files", "*.log"), all_files], "video": [("Audio Video Interleave", "*.avi"), @@ -213,8 +215,8 @@ def _set_kwargs(self, command: str | None, action: str | None, variable: str | None, - parent: tk.Frame | None - ) -> dict[str, None | tk.Frame | str | list[tuple[str, str]]]: + parent: tk.Frame | ttk.Frame | None + ) -> dict[str, None | tk.Frame | ttk.Frame | str | list[tuple[str, str]]]: """ Generate the required kwargs for the requested file dialog browser. Parameters @@ -237,7 +239,7 @@ def _set_kwargs(self, variable: str, optional Required for context handling file dialog, otherwise unused. The variable to associate with this file dialog. Default: ``None`` - parent: :class:`tkinter.Frame` + parent: :class:`tkinter.Frame` | :class:`tkinter.tk.Frame | None The parent that is launching the file dialog. ``None`` sets this to root Returns @@ -250,7 +252,7 @@ def _set_kwargs(self, title, initial_folder, initial_file, file_type, command, action, variable, parent) - kwargs: dict[str, None | tk.Frame | str | list[tuple[str, str]]] = { + kwargs: dict[str, None | tk.Frame | ttk.Frame | str | list[tuple[str, str]]] = { "master": self._dummy_master} if self._handletype.lower() == "context": @@ -343,3 +345,6 @@ def _nothing() -> None: # pylint:disable=useless-return """ Method that does nothing, used for disabling open/save pop up. """ logger.debug("Popping Nothing browser") return + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/utils/image.py b/lib/gui/utils/image.py index 816ba19257..05305aab75 100644 --- a/lib/gui/utils/image.py +++ b/lib/gui/utils/image.py @@ -9,7 +9,9 @@ import numpy as np from PIL import Image, ImageDraw, ImageTk +from lib.gui import gui_config as cfg from lib.training.preview_cv import PreviewBuffer +from lib.utils import get_module_objects from .config import get_config, PATHCACHE @@ -17,8 +19,8 @@ from collections.abc import Sequence logger = logging.getLogger(__name__) -_IMAGES: "Images" | None = None -_PREVIEW_TRIGGER: "PreviewTrigger" | None = None +_IMAGES: Images | None = None +_PREVIEW_TRIGGER: PreviewTrigger | None = None TRAININGPREVIEW = ".gui_training_preview.png" @@ -99,6 +101,7 @@ def load(self) -> bool: image_files = _get_previews(self._cache_path) filename = next((fname for fname in image_files if os.path.basename(fname) == TRAININGPREVIEW), "") + img: np.ndarray | None = None if not filename: logger.trace("No preview to display") # type:ignore return False @@ -316,7 +319,7 @@ def _process_samples(self, logger.debug("Cache shape: %s", self._images.shape) return True - def _load_images_to_cache(self, + def _load_images_to_cache(self, # pylint:disable=too-many-locals image_files: list[str], frame_dims: tuple[int, int], thumbnail_size: int) -> bool: @@ -351,7 +354,7 @@ def _load_images_to_cache(self, dropped_files = [] for fname in show_files: try: - img = Image.open(fname) + img_file = Image.open(fname) except PermissionError as err: logger.debug("Permission error opening preview file: '%s'. Original error: %s", fname, str(err)) @@ -365,12 +368,12 @@ def _load_images_to_cache(self, dropped_files.append(fname) continue - width, height = img.size + width, height = img_file.size scaling = thumbnail_size / max(width, height) logger.debug("image width: %s, height: %s, scaling: %s", width, height, scaling) try: - img = img.resize((int(width * scaling), int(height * scaling))) + img = img_file.resize((int(width * scaling), int(height * scaling))) except OSError as err: # Image only gets loaded when we call a method, so may error on partial loads logger.debug("OS Error resizing preview image: '%s'. Original error: %s", @@ -397,11 +400,11 @@ def _create_placeholder(self, thumbnail_size: int) -> None: placeholder = Image.new("RGB", (thumbnail_size, thumbnail_size)) draw = ImageDraw.Draw(placeholder) draw.rectangle(((0, 0), (thumbnail_size, thumbnail_size)), outline="#E5E5E5", width=1) - placeholder = np.array(placeholder) - self._placeholder = placeholder - logger.debug("Created placeholder. shape: %s", placeholder.shape) + nplaceholder = np.array(placeholder) + self._placeholder = nplaceholder + logger.debug("Created placeholder. shape: %s", nplaceholder.shape) - def _place_previews(self, frame_dims: tuple[int, int]) -> Image.Image: + def _place_previews(self, frame_dims: tuple[int, int]) -> Image.Image | None: """ Format the preview thumbnails stored in the cache into a grid fitting the display panel. @@ -412,7 +415,7 @@ def _place_previews(self, frame_dims: tuple[int, int]) -> Image.Image: Returns ------- - :class:`PIL.Image`: + :class:`PIL.Image`: | None The final preview display image """ if self._images is None: @@ -563,7 +566,7 @@ def _load_icons() -> dict[str, ImageTk.PhotoImage]: The icons formatted as described in :attr:`icons` """ - size = get_config().user_config_dict.get("icon_size", 16) + size = cfg.icon_size() size = int(round(size * get_config().scaling_factor)) icons: dict[str, ImageTk.PhotoImage] = {} pathicons = os.path.join(PATHCACHE, "icons") @@ -572,8 +575,8 @@ def _load_icons() -> dict[str, ImageTk.PhotoImage]: if ext != ".png": continue img = Image.open(os.path.join(pathicons, fname)) - img = ImageTk.PhotoImage(img.resize((size, size), resample=Image.HAMMING)) - icons[name] = img + pimg = ImageTk.PhotoImage(img.resize((size, size), resample=Image.Resampling.HAMMING)) + icons[name] = pimg logger.debug(icons) return icons @@ -657,3 +660,6 @@ def preview_trigger() -> PreviewTrigger: if _PREVIEW_TRIGGER is None: _PREVIEW_TRIGGER = PreviewTrigger() return _PREVIEW_TRIGGER + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/utils/misc.py b/lib/gui/utils/misc.py index 4d60c80214..c559e329cc 100644 --- a/lib/gui/utils/misc.py +++ b/lib/gui/utils/misc.py @@ -8,6 +8,8 @@ from threading import Event, Thread from queue import Queue +from lib.utils import get_module_objects + from .config import get_config if T.TYPE_CHECKING: @@ -107,3 +109,6 @@ def get_result(self) -> T.Any: logger.debug("Got result from thread") self._config.set_cursor_default(widget=self._widget) return retval + + +__all__ = get_module_objects(__name__) diff --git a/lib/gui/wrapper.py b/lib/gui/wrapper.py index c248fd3cf1..6cef8a3c3c 100644 --- a/lib/gui/wrapper.py +++ b/lib/gui/wrapper.py @@ -14,6 +14,9 @@ import psutil +from lib.gui import gui_config as cfg +from lib.utils import get_module_objects + from .analysis import Session from .utils import get_config, get_images, LongRunningTask, preview_trigger @@ -167,7 +170,7 @@ def _build_args(self, self._get_training_session_info(cliopt) if not generate: - args.append("-gui") # Indicate to Faceswap that we are running the GUI + args.append("-G") # Indicate to Faceswap that we are running the GUI if generate: # Delimit args with spaces args = [f'"{arg}"' if " " in arg and not arg.startswith(("[", "(")) @@ -176,7 +179,7 @@ def _build_args(self, logger.debug("Built cli arguments: (%s)", args) return args - def _get_training_session_info(self, cli_option: list[str]) -> None: + def _get_training_session_info(self, cli_option: tuple[str, ...]) -> None: """ Set the model folder and model name to :`attr:_training_session_location` so the global session picks them up for logging to the graph and analysis tab. @@ -266,6 +269,7 @@ def execute_script(self, command: str, args: list[str]) -> None: bufsize=1, text=True, stdin=PIPE, + encoding="utf-8", errors="backslashreplace") self._process = proc self._thread_stdout() @@ -309,6 +313,9 @@ def _process_progress_stdout(self, output: str) -> bool: if self._command == "train" and self._capture_loss(output): return True + if self._command == "train" and output.strip() == "\x1b[2K": # Clear line command for cli + return True + if self._command == "effmpeg" and self._capture_ffmpeg(output): return True @@ -398,10 +405,6 @@ def _read_stderr(self) -> None: continue if self._process_training_determinate_function(output): continue - if os.name == "nt" and "Call to CreateProcess failed. Error code: 2" in output: - # Suppress ptxas errors on Tensorflow for Windows - logger.debug("Suppressed call to subprocess error: '%s'", output) - continue print(output.strip(), file=sys.stderr) logger.debug("Terminated stderr reader") @@ -597,7 +600,7 @@ def _terminate_in_thread(self, command: str, process: Popen) -> bool: """ logger.debug("Terminating wrapper") if command == "train": - timeout = self._config.user_config_dict.get("timeout", 120) + timeout = cfg.timeout() logger.debug("Sending Exit Signal") print("Sending Exit Signal", flush=True) now = time() @@ -697,3 +700,6 @@ def _set_final_status(self, returncode: int) -> str: status = f"Failed - {self._command}.py. Return Code: {returncode}" logger.debug("Set final status: %s", status) return status + + +__all__ = get_module_objects(__name__) diff --git a/lib/image.py b/lib/image.py index a0b28ab04b..26eb1de46c 100644 --- a/lib/image.py +++ b/lib/image.py @@ -23,7 +23,9 @@ from lib.multithreading import MultiThread from lib.queue_manager import queue_manager, QueueEmpty -from lib.utils import convert_to_secs, FaceswapError, VIDEO_EXTENSIONS, get_image_paths +from lib.utils import (convert_to_secs, FaceswapError, get_image_paths, + get_module_objects, VIDEO_EXTENSIONS) + if T.TYPE_CHECKING: from lib.align.alignments import PNGHeaderDict @@ -115,7 +117,7 @@ def get_frame_info(self, frame_pts=None, keyframes=None): break if "iskey" not in output: continue - logger.trace("Keyframe line: %s", output) + logger.trace("Keyframe line: %s", output) # type:ignore[attr-defined] line = re.split(r"\s+|:\s*", output) pts_time = float(line[line.index("pts_time") + 1]) frame_no = int(line[line.index("n") + 1]) @@ -123,7 +125,8 @@ def get_frame_info(self, frame_pts=None, keyframes=None): if "iskey:1" in output: key_frames.append(frame_no) - logger.trace("pts_time: %s, frame_no: %s", pts_time, frame_no) + logger.trace("pts_time: %s, frame_no: %s", # type:ignore[attr-defined] + pts_time, frame_no) if int(pts_time) == last_update: # Floating points make TQDM display poorly, so only update on full # second increments @@ -145,7 +148,8 @@ def _previous_keyframe_info(self, index=0): prev_keyframe_idx = bisect(self._keyframes, index) - 1 prev_keyframe = self._keyframes[prev_keyframe_idx] prev_pts_time = self._frame_pts[prev_keyframe] - logger.trace("keyframe pts_time: %s, keyframe: %s", prev_pts_time, prev_keyframe) + logger.trace("keyframe pts_time: %s, keyframe: %s", # type:ignore[attr-defined] + prev_pts_time, prev_keyframe) return prev_pts_time, prev_keyframe def _initialize(self, index=0): # noqa:C901 @@ -258,7 +262,33 @@ def _initialize(self, index=0): # noqa:C901 imageio.plugins.ffmpeg.FfmpegFormat.Reader = FfmpegReader # type: ignore -def read_image(filename, raise_error=False, with_metadata=False): +@T.overload +def read_image(filename: str, + raise_error: T.Literal[False] = False, + with_metadata: T.Literal[False] = False) -> np.ndarray | None: ... + + +@T.overload +def read_image(filename: str, + raise_error: T.Literal[True], + with_metadata: T.Literal[False] = False) -> np.ndarray: ... + + +@T.overload +def read_image(filename: str, + raise_error: T.Literal[False] = False, + *, + with_metadata: T.Literal[True]) -> tuple[np.ndarray, PNGHeaderDict]: ... + + +@T.overload +def read_image(filename: str, + raise_error: T.Literal[True], + with_metadata: T.Literal[True]) -> np.ndarray: ... + + +def read_image(filename: str, raise_error: bool = False, with_metadata: bool = False + ) -> np.ndarray | None | tuple[np.ndarray, PNGHeaderDict]: """ Read an image file from a file location. Extends the functionality of :func:`cv2.imread()` by ensuring that an image was actually @@ -267,23 +297,27 @@ def read_image(filename, raise_error=False, with_metadata=False): Parameters ---------- - filename: str + filename : str Full path to the image to be loaded. raise_error: bool, optional If ``True`` then any failures (including the returned image being ``None``) will be raised. If ``False`` then an error message will be logged, but the error will not be raised. Default: ``False`` - with_metadata: bool, optional + with_metadata : bool, optional Only returns a value if the images loaded are extracted Faceswap faces. If ``True`` then returns the Faceswap metadata stored with in a Face images .png exif header. Default: ``False`` Returns ------- - numpy.ndarray or tuple - If :attr:`with_metadata` is ``False`` then returns a `numpy.ndarray` of the image in `BGR` - channel order. If :attr:`with_metadata` is ``True`` then returns a `tuple` of - (`numpy.ndarray`" of the image in `BGR`, `dict` of face's Faceswap metadata) + Returns + ------- + batch : :class:`numpy.ndarray` + The image in `BGR` channel order for the corresponding :attr:`filename` + metadata : :class:`~lib.align.alignments.PNGHeaderDict`, optional + The faceswap metadata corresponding to the image. Only returned if + `with_metadata` is ``True`` + Example ------- >>> image_file = "/path/to/image.png" @@ -292,9 +326,10 @@ def read_image(filename, raise_error=False, with_metadata=False): >>> except: >>> raise ValueError("There was an error") """ - logger.trace("Requested image: '%s'", filename) + logger.trace("Requested image: '%s'", filename) # type:ignore[attr-defined] success = True image = None + retval: np.ndarray | tuple[np.ndarray, PNGHeaderDict] | None = None try: with open(filename, "rb") as infile: raw_file = infile.read() @@ -302,7 +337,7 @@ def read_image(filename, raise_error=False, with_metadata=False): if image is None: raise ValueError("Image is None") if with_metadata: - metadata = png_read_meta(raw_file) + metadata = T.cast("PNGHeaderDict", png_read_meta(raw_file)) retval = (image, metadata) else: retval = image @@ -327,11 +362,22 @@ def read_image(filename, raise_error=False, with_metadata=False): logger.error(msg) if raise_error: raise Exception(msg) - logger.trace("Loaded image: '%s'. Success: %s", filename, success) + logger.trace("Loaded image: '%s'. Success: %s", filename, success) # type:ignore[attr-defined] return retval -def read_image_batch(filenames, with_metadata=False): +@T.overload +def read_image_batch(filenames: list[str], with_metadata: T.Literal[False] = False + ) -> np.ndarray: ... + + +@T.overload +def read_image_batch(filenames: list[str], with_metadata: T.Literal[True] + ) -> tuple[np.ndarray, list[PNGHeaderDict]]: ... + + +def read_image_batch(filenames: list[str], with_metadata: bool = False + ) -> np.ndarray | tuple[np.ndarray, list[PNGHeaderDict]]: """ Load a batch of images from the given file locations. Leverages multi-threading to load multiple images from disk at the same time leading to vastly @@ -339,47 +385,70 @@ def read_image_batch(filenames, with_metadata=False): Parameters ---------- - filenames: list - A list of ``str`` full paths to the images to be loaded. - with_metadata: bool, optional + filenames : list[str] + A of full paths to the images to be loaded. + with_metadata : bool, optional Only returns a value if the images loaded are extracted Faceswap faces. If ``True`` then - returns the Faceswap metadata stored with in a Face images .png exif header. + returns the Faceswap metadata stored within each Face's .png exif header. Default: ``False`` Returns ------- - numpy.ndarray + batch : :class:`numpy.ndarray` The batch of images in `BGR` channel order returned in the order of :attr:`filenames` + metadata : list[:class:`~lib.align.alignments.PNGHeaderDict`], optional + The faceswap metadata corresponding to each image in the batch. Only returned if + `with_metadata` is ``True`` Notes ----- - As the images are compiled into a batch, they must be all of the same dimensions. + As the images are compiled into a batch, they should be all of the same dimensions, otherwise a + homongenous array will be returned Example ------- >>> image_filenames = ["/path/to/image_1.png", "/path/to/image_2.png", "/path/to/image_3.png"] >>> images = read_image_batch(image_filenames) + >>> print(images.shape) + ... (3, 64, 64, 3) + >>> images, metatdata = read_image_batch(image_filenames, with_metadata=True) + >>> print(images.shape) + ... (3, 64, 64, 3) + >>> print(len(metadata)) + ... 3 """ - logger.trace("Requested batch: '%s'", filenames) - batch = [None for _ in range(len(filenames))] - if with_metadata: - meta = [None for _ in range(len(filenames))] + logger.trace("Requested batch: '%s'", filenames) # type:ignore[attr-defined] + batch: list[np.ndarray | None] = [None for _ in range(len(filenames))] + meta: list[PNGHeaderDict | None] = [None for _ in range(len(filenames))] with futures.ThreadPoolExecutor() as executor: - images = {executor.submit(read_image, filename, - raise_error=True, with_metadata=with_metadata): idx + images = {executor.submit( # NOTE submit strips positionals, breaking type-checking + read_image, # type:ignore[arg-type] + filename, + raise_error=True, # pyright:ignore[reportArgumentType] + with_metadata=with_metadata): idx # pyright:ignore[reportArgumentType] for idx, filename in enumerate(filenames)} + for future in futures.as_completed(images): + result = T.cast(np.ndarray | tuple[np.ndarray, "PNGHeaderDict"], future.result()) ret_idx = images[future] if with_metadata: - batch[ret_idx], meta[ret_idx] = future.result() + assert isinstance(result, tuple) + batch[ret_idx], meta[ret_idx] = result else: - batch[ret_idx] = future.result() + assert isinstance(result, np.ndarray) + batch[ret_idx] = result + + arr_batch = np.array(batch) + retval: np.ndarray | tuple[np.ndarray, list[PNGHeaderDict]] + if with_metadata: + retval = (arr_batch, T.cast(list["PNGHeaderDict"], meta)) + else: + retval = arr_batch - batch = np.array(batch) - retval = (batch, meta) if with_metadata else batch - logger.trace("Returning images: (filenames: %s, batch shape: %s, with_metadata: %s)", - filenames, batch.shape, with_metadata) + logger.trace( # type:ignore[attr-defined] + "Returning images: (filenames: %s, batch shape: %s, with_metadata: %s)", + filenames, arr_batch.shape, with_metadata) return retval @@ -407,7 +476,9 @@ def read_image_meta(filename): retval = dict() if os.path.splitext(filename)[-1].lower() != ".png": # Get the dimensions directly from the image for non-pngs - logger.trace("Non png found. Loading file for dimensions: '%s'", filename) + logger.trace( # type:ignore[attr-defined] + "Non png found. Loading file for dimensions: '%s'", + filename) img = cv2.imread(filename) retval["height"], retval["width"] = img.shape[:2] return retval @@ -423,7 +494,9 @@ def read_image_meta(filename): while True: chunk = infile.read(8) length, field = struct.unpack(">I4s", chunk) - logger.trace("Read chunk: (chunk: %s, length: %s, field: %s", chunk, length, field) + logger.trace( # type:ignore[attr-defined] + "Read chunk: (chunk: %s, length: %s, field: %s", + chunk, length, field) if not chunk or field == b"IDAT": break if field == b"IHDR": @@ -437,11 +510,11 @@ def read_image_meta(filename): retval["itxt"] = literal_eval(value[4:].decode("utf-8", errors="replace")) break else: - logger.trace("Skipping iTXt chunk: '%s'", keyword.decode("latin-1", - errors="ignore")) + logger.trace("Skipping iTXt chunk: '%s'", # type:ignore[attr-defined] + keyword.decode("latin-1", errors="ignore")) length = 0 # Reset marker for next chunk infile.seek(length + 4, 1) - logger.trace("filename: %s, metadata: %s", filename, retval) + logger.trace("filename: %s, metadata: %s", filename, retval) # type:ignore[attr-defined] return retval @@ -473,7 +546,7 @@ def read_image_meta_batch(filenames): >>> for filename, meta in read_image_meta_batch(image_filenames): >>> """ - logger.trace("Requested batch: '%s'", filenames) + logger.trace("Requested batch: '%s'", filenames) # type:ignore[attr-defined] executor = futures.ThreadPoolExecutor() with executor: logger.debug("Submitting %s items to executor", len(filenames)) @@ -482,7 +555,7 @@ def read_image_meta_batch(filenames): logger.debug("Succesfully submitted %s items to executor", len(filenames)) for future in futures.as_completed(read_meta): retval = (read_meta[future], future.result()) - logger.trace("Yielding: %s", retval) + logger.trace("Yielding: %s", retval) # type:ignore[attr-defined] yield retval @@ -531,26 +604,29 @@ def update_existing_metadata(filename, metadata): while True: chunk = png.read(8) length, field = struct.unpack(">I4s", chunk) - logger.trace("Read chunk: (chunk: %s, length: %s, field: %s)", chunk, length, field) + logger.trace( # type:ignore[attr-defined] + "Read chunk: (chunk: %s, length: %s, field: %s)", + chunk, length, field) if field == b"IDAT": # Write out all remaining data - logger.trace("Writing image data and closing png") + logger.trace("Writing image data and closing png") # type:ignore[attr-defined] tmp.write(chunk + png.read()) break if field != b"iTXt": # Write non iTXt chunk straight out - logger.trace("Copying existing chunk") + logger.trace("Copying existing chunk") # type:ignore[attr-defined] tmp.write(chunk + png.read(length + 4)) # Header + CRC continue keyword, value = png.read(length).split(b"\0", 1) if keyword != b"faceswap": # Write existing non fs-iTXt data + CRC - logger.trace("Copying non-faceswap iTXt chunk: %s", keyword) + logger.trace("Copying non-faceswap iTXt chunk: %s", # type:ignore[attr-defined] + keyword) tmp.write(keyword + b"\0" + value + png.read(4)) continue - logger.trace("Updating faceswap iTXt chunk") + logger.trace("Updating faceswap iTXt chunk") # type:ignore[attr-defined] tmp.write(pack_to_itxt(metadata)) png.seek(4, 1) # Skip old CRC @@ -690,7 +766,14 @@ def tiff_write_meta(image: bytes, data: PNGHeaderDict | dict[str, T.Any] | bytes def tiff_read_meta(image: bytes) -> dict[str, T.Any]: - """ Read information stored in a Tiff's Image Description field """ + """ Read information stored in a Tiff's Image Description field + + Returns + ------- + dict[str, Any] + Any arbitrary information stored in the TIFF header (for example matrix information for + the patch writer) + """ assert image[:2] == b"II", "Not a supported TIFF file" assert struct.unpack(" dict[str, T.Any]: return retval -def png_read_meta(image): +def png_read_meta(image: bytes) -> PNGHeaderDict | dict[str, T.Any]: """ Read the Faceswap information stored in a png's iTXt field. Parameters @@ -732,8 +815,9 @@ def png_read_meta(image): Returns ------- - dict - The Faceswap information stored in the PNG header + :class:`~lib.align.alignments.PNGHeaderDict` | dict[str, Any] + The Faceswap information stored in the PNG header. This will either be a PNGHeaderDict + if an extracted face, or other arbitrary information (for example for the Patch Writer) Notes ----- @@ -741,12 +825,12 @@ def png_read_meta(image): task. OpenCV will not write any iTXt headers to the PNG file, so we make the assumption that the only iTXt header that exists is the one that Faceswap created for storing alignments. """ - retval = None + retval: PNGHeaderDict | None = None pointer = 0 while True: pointer = image.find(b"iTXt", pointer) - 4 if pointer < 0: - logger.trace("No metadata in png") + logger.trace("No metadata in png") # type:ignore[attr-defined] break length = struct.unpack(">I", image[pointer:pointer + 4])[0] pointer += 8 @@ -754,8 +838,10 @@ def png_read_meta(image): if keyword == b"faceswap": retval = literal_eval(value[4:].decode("utf-8", errors="ignore")) break - logger.trace("Skipping iTXt chunk: '%s'", keyword.decode("latin-1", errors="ignore")) + logger.trace("Skipping iTXt chunk: '%s'", # type:ignore[attr-defined] + keyword.decode("latin-1", errors="ignore")) pointer += length + 4 + assert retval is not None return retval @@ -776,13 +862,14 @@ def generate_thumbnail(image, size=96, quality=60): :class:`numpy.ndarray` The given image encoded to a jpg at the given size and quality settings """ - logger.trace("Input shape: %s, size: %s, quality: %s", image.shape, size, quality) + logger.trace("Input shape: %s, size: %s, quality: %s", # type:ignore[attr-defined] + image.shape, size, quality) orig_size = image.shape[0] if orig_size != size: interp = cv2.INTER_AREA if orig_size > size else cv2.INTER_CUBIC image = cv2.resize(image, (size, size), interpolation=interp) retval = cv2.imencode(".jpg", image, [cv2.IMWRITE_JPEG_QUALITY, quality])[1] - logger.trace("Output shape: %s", retval.shape) + logger.trace("Output shape: %s", retval.shape) # type:ignore[attr-defined] return retval @@ -822,7 +909,9 @@ def batch_convert_color(batch, colorspace): to use 32-bit images in cases that need the full range of colors or that convert an image before an operation and then convert back. """ - logger.trace("Batch converting: (batch shape: %s, colorspace: %s)", batch.shape, colorspace) + logger.trace( # type:ignore[attr-defined] + "Batch converting: (batch shape: %s, colorspace: %s)", + batch.shape, colorspace) original_shape = batch.shape batch = batch.reshape((original_shape[0] * original_shape[1], *original_shape[2:])) batch = cv2.cvtColor(batch, getattr(cv2, "COLOR_{}".format(colorspace))) @@ -1072,11 +1161,11 @@ class ImagesLoader(ImageIO): """ def __init__(self, - path, - queue_size=8, - fast_count=True, - skip_list=None, - count=None): + path: str | list[str], + queue_size: int = 8, + fast_count: bool = True, + skip_list: list[int] | None = None, + count: int | None = None) -> None: logger.debug("Initializing %s: (path: %s, queue_size: %s, fast_count: %s, skip_list: %s, " "count: %s)", self.__class__.__name__, path, queue_size, fast_count, skip_list, count) @@ -1204,7 +1293,7 @@ def _get_count_and_filelist(self, fast_count, count): self._count = len(self.file_list) if count is None else count logger.debug("count: %s", self.count) - logger.trace("filelist: %s", self.file_list) + logger.trace("filelist: %s", self.file_list) # type:ignore[attr-defined] def _process(self, queue): """ The load thread. @@ -1224,10 +1313,10 @@ def _process(self, queue): # All black frames will return not numpy.any() so check dims too logger.warning("Unable to open image. Skipping: '%s'", filename) continue - logger.trace("Putting to queue: %s", [v.shape if isinstance(v, np.ndarray) else v - for v in retval]) + logger.trace("Putting to queue: %s", # type:ignore[attr-defined] + [v.shape if isinstance(v, np.ndarray) else v for v in retval]) queue.put(retval) - logger.trace("Putting EOF") + logger.trace("Putting EOF") # type:ignore[attr-defined] queue.put("EOF") def _from_video(self): @@ -1244,12 +1333,13 @@ def _from_video(self): reader = imageio.get_reader(self.location, "ffmpeg") for idx, frame in enumerate(reader): if idx in self._skip_list: - logger.trace("Skipping frame %s due to skip list", idx) + logger.trace("Skipping frame %s due to skip list", # type:ignore[attr-defined] + idx) continue # Convert to BGR for cv2 compatibility frame = frame[:, :, ::-1] filename = self._dummy_video_framename(idx) - logger.trace("Loading video frame: '%s'", filename) + logger.trace("Loading video frame: '%s'", filename) # type:ignore[attr-defined] yield filename, frame reader.close() @@ -1286,7 +1376,7 @@ def _from_folder(self): logger.debug("Loading frames from folder: '%s'", self.location) for idx, filename in enumerate(self.file_list): if idx in self._skip_list: - logger.trace("Skipping frame %s due to skip list") + logger.trace("Skipping frame %s due to skip list") # type:ignore[attr-defined] continue image_read = read_image(filename, raise_error=False) retval = filename, image_read @@ -1319,10 +1409,10 @@ def load(self): except QueueEmpty: continue if retval == "EOF": - logger.trace("Got EOF") + logger.trace("Got EOF") # type:ignore[attr-defined] break - logger.trace("Yielding: %s", [v.shape if isinstance(v, np.ndarray) else v - for v in retval]) + logger.trace("Yielding: %s", # type:ignore[attr-defined] + [v.shape if isinstance(v, np.ndarray) else v for v in retval]) yield retval logger.debug("Closing Load Generator") self.close() @@ -1365,7 +1455,7 @@ def _get_count_and_filelist(self, fast_count, count): self._count = len(self.file_list) if count is None else count logger.debug("count: %s", self.count) - logger.trace("filelist: %s", self.file_list) + logger.trace("filelist: %s", self.file_list) # type:ignore[attr-defined] def _from_folder(self): """ Generator for loading images from a folder @@ -1384,7 +1474,7 @@ def _from_folder(self): logger.debug("Loading images from folder: '%s'", self.location) for idx, filename in enumerate(self.file_list): if idx in self._skip_list: - logger.trace("Skipping face %s due to skip list") + logger.trace("Skipping face %s due to skip list") # type:ignore[attr-defined] continue image_read = read_image(filename, raise_error=False, with_metadata=True) retval = filename, *image_read @@ -1473,7 +1563,8 @@ def image_from_index(self, index: int) -> tuple[str, np.ndarray]: filename = file_list[index] image = read_image(filename, raise_error=True) filename = os.path.basename(filename) - logger.trace("index: %s, filename: %s image shape: %s", index, filename, image.shape) + logger.trace("index: %s, filename: %s image shape: %s", # type:ignore[attr-defined] + index, filename, image.shape) return filename, image @@ -1538,7 +1629,7 @@ def _process(self, queue): if item == "EOF": logger.debug("EOF received") break - logger.trace("Submitting: '%s'", item[0]) + logger.trace("Submitting: '%s'", item[0]) # type:ignore[attr-defined] executor.submit(self._save, *item) executor.shutdown() @@ -1572,7 +1663,7 @@ def _save(self, else: assert isinstance(image, np.ndarray) cv2.imwrite(filename, image) - logger.trace("Saved image: '%s'", filename) # type:ignore + logger.trace("Saved image: '%s'", filename) # type:ignore[attr-defined] except Exception as err: # pylint:disable=broad-except logger.error("Failed to save image '%s'. Original Error: %s", filename, str(err)) del image @@ -1598,7 +1689,7 @@ def save(self, be provided here. ``None`` for no subfolder. Default: ``None`` """ self._set_thread() - logger.trace("Putting to save queue: '%s'", filename) # type:ignore + logger.trace("Putting to save queue: '%s'", filename) # type:ignore[attr-defined] self._queue.put((filename, image, sub_folder)) def close(self): @@ -1607,3 +1698,6 @@ def close(self): logger.debug("Putting EOF to save queue") self._queue.put("EOF") super().close() + + +__all__ = get_module_objects(__name__) diff --git a/lib/keras_utils.py b/lib/keras_utils.py index af47a3e466..916f3963cd 100644 --- a/lib/keras_utils.py +++ b/lib/keras_utils.py @@ -5,21 +5,25 @@ import numpy as np -import tensorflow.keras.backend as K # pylint:disable=import-error +from keras import ops, Variable + +from lib.utils import get_module_objects if T.TYPE_CHECKING: - from tensorflow import Tensor + from keras import KerasTensor + +# TODO these can probably be switched to pure pytorch -def frobenius_norm(matrix: Tensor, +def frobenius_norm(matrix: KerasTensor, axis: int = -1, keep_dims: bool = True, - epsilon: float = 1e-15) -> Tensor: + epsilon: float = 1e-15) -> KerasTensor: """ Frobenius normalization for Keras Tensor Parameters ---------- - matrix: Tensor + matrix: :class:`keras.KerasTensor` The matrix to normalize axis: int, optional The axis to normalize. Default: `-1` @@ -30,39 +34,39 @@ def frobenius_norm(matrix: Tensor, Returns ------- - Tensor + :class:`keras.KerasTensor` The normalized output """ - return K.sqrt(K.sum(K.pow(matrix, 2), axis=axis, keepdims=keep_dims) + epsilon) + return ops.sqrt(ops.sum(ops.power(matrix, 2), axis=axis, keepdims=keep_dims) + epsilon) -def replicate_pad(image: Tensor, padding: int) -> Tensor: +def replicate_pad(image: KerasTensor, padding: int) -> KerasTensor: """ Apply replication padding to an input batch of images. Expects 4D tensor in BHWC format. Notes ----- - At the time of writing Keras/Tensorflow does not have a native replication padding method. + At the time of writing Keras does not have a native replication padding method. The implementation here is probably not the most efficient, but it is a pure keras method - which should work on TF. + which should work ok. Parameters ---------- - image: Tensor + image: :class:`keras.KerasTensor` Image tensor to pad pad: int The amount of padding to apply to each side of the input image Returns ------- - Tensor + :class:`keras.KerasTensor` The input image with replication padding applied """ - top_pad = K.tile(image[:, :1, ...], (1, padding, 1, 1)) - bottom_pad = K.tile(image[:, -1:, ...], (1, padding, 1, 1)) - pad_top_bottom = K.concatenate([top_pad, image, bottom_pad], axis=1) - left_pad = K.tile(pad_top_bottom[..., :1, :], (1, 1, padding, 1)) - right_pad = K.tile(pad_top_bottom[..., -1:, :], (1, 1, padding, 1)) - padded = K.concatenate([left_pad, pad_top_bottom, right_pad], axis=2) + top_pad = ops.tile(image[:, :1, ...], (1, padding, 1, 1)) + bottom_pad = ops.tile(image[:, -1:, ...], (1, padding, 1, 1)) + pad_top_bottom = ops.concatenate([top_pad, image, bottom_pad], axis=1) + left_pad = ops.tile(pad_top_bottom[..., :1, :], (1, 1, padding, 1)) + right_pad = ops.tile(pad_top_bottom[..., -1:, :], (1, 1, padding, 1)) + padded = ops.concatenate([left_pad, pad_top_bottom, right_pad], axis=2) return padded @@ -101,7 +105,7 @@ def __init__(self, from_space: str, to_space: str) -> None: "srgb_ycxcz": self._srgb_to_ycxcz, "xyz_ycxcz": self._xyz_to_ycxcz, "xyz_lab": self._xyz_to_lab, - "xyz_to_rgb": self._xyz_to_rgb, + "xyz_rgb": self._xyz_to_rgb, "ycxcz_rgb": self._ycxcz_to_rgb, "ycxcz_xyz": self._ycxcz_to_xyz} func_name = f"{from_space.lower()}_{to_space.lower()}" @@ -109,15 +113,16 @@ def __init__(self, from_space: str, to_space: str) -> None: raise ValueError(f"The color transform {from_space} to {to_space} is not defined.") self._func = functions[func_name] - self._ref_illuminant = K.constant(np.array([[[0.950428545, 1.000000000, 1.088900371]]]), - dtype="float32") + self._ref_illuminant = Variable(np.array([[[0.950428545, 1.000000000, 1.088900371]]]), + dtype="float32", + trainable=False) self._inv_ref_illuminant = 1. / self._ref_illuminant self._rgb_xyz_map = self._get_rgb_xyz_map() - self._xyz_multipliers = K.constant([116, 500, 200], dtype="float32") + self._xyz_multipliers = Variable([116, 500, 200], dtype="float32", trainable=False) @classmethod - def _get_rgb_xyz_map(cls) -> tuple[Tensor, Tensor]: + def _get_rgb_xyz_map(cls) -> tuple[KerasTensor, KerasTensor]: """ Obtain the mapping and inverse mapping for rgb to xyz color space conversion. Returns @@ -129,40 +134,41 @@ def _get_rgb_xyz_map(cls) -> tuple[Tensor, Tensor]: [2613072 / 12288897, 8788810 / 12288897, 887015 / 12288897], [1425312 / 73733382, 8788810 / 73733382, 70074185 / 73733382]]) inverse = np.linalg.inv(mapping) - return (K.constant(mapping, dtype="float32"), K.constant(inverse, dtype="float32")) + return (Variable(mapping, dtype="float32", trainable=False), + Variable(inverse, dtype="float32", trainable=False)) - def __call__(self, image: Tensor) -> Tensor: + def __call__(self, image: KerasTensor) -> KerasTensor: """ Call the colorspace conversion function. Parameters ---------- - image: Tensor - The image tensor in the colorspace defined by :param:`from_space` + image: :class:`keras.KerasTensor` + The image tensor in the colorspace defined by :attr:`from_space` Returns ------- - Tensor - The image tensor in the colorspace defined by :param:`to_space` + :class:`keras.KerasTensor` + The image tensor in the colorspace defined by :attr:`to_space` """ return self._func(image) - def _rgb_to_lab(self, image: Tensor) -> Tensor: + def _rgb_to_lab(self, image: KerasTensor) -> KerasTensor: """ RGB to LAB conversion. Parameters ---------- - image: Tensor + image: :class:`keras.KerasTensor` The image tensor in RGB format Returns ------- - Tensor + :class:`keras.KerasTensor` The image tensor in LAB format """ converted = self._rgb_to_xyz(image) return self._xyz_to_lab(converted) - def _rgb_xyz_rgb(self, image: Tensor, mapping: Tensor) -> Tensor: + def _rgb_xyz_rgb(self, image: KerasTensor, mapping: KerasTensor) -> KerasTensor: """ RGB to XYZ or XYZ to RGB conversion. Notes @@ -176,41 +182,41 @@ def _rgb_xyz_rgb(self, image: Tensor, mapping: Tensor) -> Tensor: Parameters ---------- - mapping: Tensor + mapping: :class:`keras.KerasTensor` The mapping matrix to perform either the XYZ to RGB or RGB to XYZ color space conversion - image: Tensor + image: :class:`keras.KerasTensor` The image tensor in RGB format Returns ------- - Tensor + :class:`keras.KerasTensor` The image tensor in XYZ format """ - dim = K.int_shape(image) - image = K.permute_dimensions(image, (0, 3, 1, 2)) - image = K.reshape(image, (dim[0], dim[3], dim[1] * dim[2])) - converted = K.permute_dimensions(K.dot(mapping, image), (1, 2, 0)) - return K.reshape(converted, dim) + dim = image.shape + image = ops.transpose(image, (0, 3, 1, 2)) + image = ops.reshape(image, (dim[0], dim[3], dim[1] * dim[2])) + converted = ops.transpose(ops.dot(mapping, image), (0, 2, 1)) + return ops.reshape(converted, dim) - def _rgb_to_xyz(self, image: Tensor) -> Tensor: + def _rgb_to_xyz(self, image: KerasTensor) -> KerasTensor: """ RGB to XYZ conversion. Parameters ---------- - image: Tensor + image: :class:`keras.KerasTensor` The image tensor in RGB format Returns ------- - Tensor + :class:`keras.KerasTensor` The image tensor in XYZ format """ return self._rgb_xyz_rgb(image, self._rgb_xyz_map[0]) @classmethod - def _srgb_to_rgb(cls, image: Tensor) -> Tensor: + def _srgb_to_rgb(cls, image: KerasTensor) -> KerasTensor: """ SRGB to RGB conversion. Notes @@ -219,47 +225,47 @@ def _srgb_to_rgb(cls, image: Tensor) -> Tensor: Parameters ---------- - image: Tensor + image: :class:`keras.KerasTensor` The image tensor in SRGB format Returns ------- - Tensor + :class:`keras.KerasTensor` The image tensor in RGB format """ - limit = 0.04045 - return K.switch(image > limit, - K.pow((K.clip(image, limit, None) + 0.055) / 1.055, 2.4), - image / 12.92) + limit = np.float32(0.04045) + return ops.where(image > limit, + ops.power((ops.clip(image, limit, np.inf) + 0.055) / 1.055, 2.4), + image / 12.92) - def _srgb_to_ycxcz(self, image: Tensor) -> Tensor: + def _srgb_to_ycxcz(self, image: KerasTensor) -> KerasTensor: """ SRGB to YcXcZ conversion. Parameters ---------- - image: Tensor + image: :class:`keras.KerasTensor` The image tensor in SRGB format Returns ------- - Tensor + :class:`keras.KerasTensor` The image tensor in YcXcZ format """ converted = self._srgb_to_rgb(image) converted = self._rgb_to_xyz(converted) return self._xyz_to_ycxcz(converted) - def _xyz_to_lab(self, image: Tensor) -> Tensor: + def _xyz_to_lab(self, image: KerasTensor) -> KerasTensor: """ XYZ to LAB conversion. Parameters ---------- - image: Tensor + image: :class:`keras.KerasTensor` The image tensor in XYZ format Returns ------- - Tensor + :class:`keras.KerasTensor` The image tensor in LAB format """ image = image * self._inv_ref_illuminant @@ -267,78 +273,82 @@ def _xyz_to_lab(self, image: Tensor) -> Tensor: delta_cube = delta ** 3 factor = 1 / (3 * (delta ** 2)) - clamped_term = K.pow(K.clip(image, delta_cube, None), 1.0 / 3.0) + clamped_term = ops.power(ops.clip(image, delta_cube, np.inf), 1.0 / 3.0) div = factor * image + (4 / 29) - image = K.switch(image > delta_cube, clamped_term, div) - return K.concatenate([self._xyz_multipliers[0] * image[..., 1:2] - 16., - self._xyz_multipliers[1:] * (image[..., :2] - image[..., 1:3])], - axis=-1) + image = ops.where(image > delta_cube, clamped_term, div) + + return ops.concatenate([self._xyz_multipliers[0] * image[..., 1:2] - 16., + self._xyz_multipliers[1:] * (image[..., :2] - image[..., 1:3])], + axis=-1) - def _xyz_to_rgb(self, image: Tensor) -> Tensor: + def _xyz_to_rgb(self, image: KerasTensor) -> KerasTensor: """ XYZ to YcXcZ conversion. Parameters ---------- - image: Tensor + image: :class:`keras.KerasTensor` The image tensor in XYZ format Returns ------- - Tensor + :class:`keras.KerasTensor` The image tensor in RGB format """ return self._rgb_xyz_rgb(image, self._rgb_xyz_map[1]) - def _xyz_to_ycxcz(self, image: Tensor) -> Tensor: + def _xyz_to_ycxcz(self, image: KerasTensor) -> KerasTensor: """ XYZ to YcXcZ conversion. Parameters ---------- - image: Tensor + image: :class:`keras.KerasTensor` The image tensor in XYZ format Returns ------- - Tensor + :class:`keras.KerasTensor` The image tensor in YcXcZ format """ image = image * self._inv_ref_illuminant - return K.concatenate([self._xyz_multipliers[0] * image[..., 1:2] - 16., - self._xyz_multipliers[1:] * (image[..., :2] - image[..., 1:3])], - axis=-1) + return ops.concatenate([self._xyz_multipliers[0] * image[..., 1:2] - 16., + self._xyz_multipliers[1:] * (image[..., :2] - image[..., 1:3])], + axis=-1) - def _ycxcz_to_rgb(self, image: Tensor) -> Tensor: + def _ycxcz_to_rgb(self, image: KerasTensor) -> KerasTensor: """ YcXcZ to RGB conversion. Parameters ---------- - image: Tensor + image: :class:`keras.KerasTensor` The image tensor in YcXcZ format Returns ------- - Tensor + :class:`keras.KerasTensor` The image tensor in RGB format """ converted = self._ycxcz_to_xyz(image) return self._xyz_to_rgb(converted) - def _ycxcz_to_xyz(self, image: Tensor) -> Tensor: + def _ycxcz_to_xyz(self, image: KerasTensor) -> KerasTensor: """ YcXcZ to XYZ conversion. Parameters ---------- - image: Tensor + image: :class:`keras.KerasTensor` The image tensor in YcXcZ format Returns ------- - Tensor + :class:`keras.KerasTensor` The image tensor in XYZ format """ ch_y = (image[..., 0:1] + 16.) / self._xyz_multipliers[0] - return K.concatenate([ch_y + (image[..., 1:2] / self._xyz_multipliers[1]), - ch_y, - ch_y - (image[..., 2:3] / self._xyz_multipliers[2])], - axis=-1) * self._ref_illuminant + return ops.concatenate([ch_y + (image[..., 1:2] / self._xyz_multipliers[1]), + ch_y, + ch_y - (image[..., 2:3] / self._xyz_multipliers[2])], + axis=-1) * self._ref_illuminant + + +__all__ = get_module_objects(__name__) diff --git a/lib/keypress.py b/lib/keypress.py index c5c2030216..4505d67feb 100644 --- a/lib/keypress.py +++ b/lib/keypress.py @@ -19,6 +19,8 @@ import os import sys +from lib.utils import get_module_objects + # Windows if os.name == "nt": import msvcrt # pylint:disable=import-error @@ -29,6 +31,8 @@ import atexit from select import select +# pylint:disable=possibly-used-before-assignment + class KBHit: """ Creates a KBHit object that you can call to do various keyboard things. """ @@ -93,3 +97,6 @@ def kbhit(self): return msvcrt.kbhit() d_r, _, _ = select([sys.stdin], [], [], 0) return d_r != [] + + +__all__ = get_module_objects(__name__) diff --git a/lib/logger.py b/lib/logger.py index 89fb036627..d9dbbb1d3a 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -14,16 +14,7 @@ from datetime import datetime - -# TODO - Remove this monkey patch when TF autograph fixed to handle newer logging lib -def _patched_format(self, record): - """ Autograph tf-2.10 has a bug with the 3.10 version of logging.PercentStyle._format(). It is - non-critical but spits out warnings. This is the Python 3.9 version of the function and should - be removed once fixed """ - return self._fmt % record.__dict__ # pylint:disable=protected-access - - -setattr(logging.PercentStyle, "_format", _patched_format) +from lib.utils import get_module_objects class FaceswapLogger(logging.Logger): @@ -208,7 +199,6 @@ def format(self, record: logging.LogRecord) -> str: The formatted log message """ record.message = record.getMessage() - record = self._rewrite_warnings(record) record = self._lower_external(record) # strip newlines if record.levelno < 30 and ("\n" in record.message or "\r" in record.message): @@ -232,37 +222,6 @@ def format(self, record: logging.LogRecord) -> str: msg = msg + self.formatStack(record.stack_info) return msg - @classmethod - def _rewrite_warnings(cls, record: logging.LogRecord) -> logging.LogRecord: - """ Change certain warning messages from WARNING to DEBUG to avoid passing non-important - information to output. - - Parameters - ---------- - record: :class:`logging.LogRecord` - The log record to check for rewriting - - Returns - ------- - :class:`logging.LogRecord` - The log rewritten or untouched record - - """ - if record.levelno == 30 and record.funcName == "warn" and record.module == "ag_logging": - # TF 2.3 in Conda is imported with the wrong gast(0.4 when 0.3.3 should be used). This - # causes warnings in autograph. They don't appear to impact performance so de-elevate - # warning to debug - record.levelno = 10 - record.levelname = "DEBUG" - - if record.levelno == 30 and (record.funcName == "_tfmw_add_deprecation_warning" or - record.module in ("deprecation", "deprecation_wrapper")): - # Keras Deprecations. - record.levelno = 10 - record.levelname = "DEBUG" - - return record - @classmethod def _lower_external(cls, record: logging.LogRecord) -> logging.LogRecord: """ Some external libs log at a higher level than we would really like, so lower their @@ -338,6 +297,7 @@ def _set_root_logger(loglevel: int = logging.INFO) -> logging.Logger: """ rootlogger = logging.getLogger() rootlogger.setLevel(loglevel) + logging.captureWarnings(True) return rootlogger @@ -534,7 +494,7 @@ def crash_log() -> str: filename = os.path.join(path, datetime.now().strftime("crash_report.%Y.%m.%d.%H%M%S%f.log")) freeze_log = [line.encode("utf-8") for line in _DEBUG_BUFFER] try: - from lib.sysinfo import sysinfo # pylint:disable=import-outside-toplevel + from lib.system.sysinfo import sysinfo # pylint:disable=import-outside-toplevel except Exception: # pylint:disable=broad-except sysinfo = ("\n\nThere was an error importing System Information from lib.sysinfo. This is " f"probably a bug which should be fixed:\n{traceback.format_exc()}") @@ -558,19 +518,18 @@ def _process_value(value: T.Any) -> T.Any: Any The original or ammended value """ - if isinstance(value, str): - return f'"{value}"' if isinstance(value, (list, tuple, set)) and len(value) > 10: return f'[type: "{type(value).__name__}" len: {len(value)}' try: import numpy as np # pylint:disable=import-outside-toplevel except ImportError: - return value + return repr(value) if isinstance(value, np.ndarray) and np.prod(value.shape) > 10: return f'[type: "{type(value).__name__}" shape: {value.shape}, dtype: "{value.dtype}"]' - return value + + return repr(value) def parse_class_init(locals_dict: dict[str, T.Any]) -> str: @@ -579,6 +538,7 @@ def parse_class_init(locals_dict: dict[str, T.Any]) -> str: ---------- locals_dict: dict[str, T.Any] A locals() dictionary from a newly initialized class + Returns ------- str @@ -586,8 +546,8 @@ def parse_class_init(locals_dict: dict[str, T.Any]) -> str: """ delimit = {k: _process_value(v) for k, v in locals_dict.items() if k != "self"} - dsp = ", ".join(f"{k}: {v}" for k, v in delimit.items()) - dsp = f" ({dsp})" if dsp else "" + dsp = ", ".join(f"{k}={v}" for k, v in delimit.items()) + dsp = f"({dsp})" if dsp else "" return f"Initializing {locals_dict['self'].__class__.__name__}{dsp}" @@ -609,3 +569,6 @@ def _faceswap_logrecord(*args, **kwargs) -> logging.LogRecord: # Stores the last 100 debug messages _DEBUG_BUFFER = RollingBuffer(maxlen=100) + + +__all__ = get_module_objects(__name__) diff --git a/lib/model/autoclip.py b/lib/model/autoclip.py index 826959d66b..03d1a54af7 100644 --- a/lib/model/autoclip.py +++ b/lib/model/autoclip.py @@ -1,6 +1,19 @@ """ Auto clipper for clipping gradients. """ +from __future__ import annotations + +import logging +import typing as T + import numpy as np -import tensorflow as tf +import torch + +from lib.logger import parse_class_init +from lib.utils import get_module_objects + +if T.TYPE_CHECKING: + from keras import KerasTensor + +logger = logging.getLogger(__name__) class AutoClipper(): @@ -11,101 +24,41 @@ class AutoClipper(): clip_percentile: int The percentile to clip the gradients at history_size: int, optional - The number of iterations of data to use to calculate the norm - Default: ``10000`` + The number of iterations of data to use to calculate the norm Default: ``10000`` References ---------- - tf implementation: https://github.com/pseeth/autoclip + Adapted from: https://github.com/pseeth/autoclip original paper: https://arxiv.org/abs/2007.14469 """ - def __init__(self, clip_percentile: int, history_size: int = 10000): - self._clip_percentile = tf.cast(clip_percentile, tf.float64) - self._grad_history = tf.Variable(tf.zeros(history_size), trainable=False) - self._index = tf.Variable(0, trainable=False) + def __init__(self, clip_percentile: int, history_size: int = 10000) -> None: + logger.debug(parse_class_init(locals())) + + self._clip_percentile = clip_percentile self._history_size = history_size + self._grad_history: list[float] = [] - def _percentile(self, grad_history: tf.Tensor) -> tf.Tensor: - """ Compute the clip percentile of the gradient history + logger.debug("Initialized %s", self.__class__.__name__) + + def __call__(self, gradients: list[KerasTensor]) -> list[KerasTensor]: + """ Call the AutoClip function. Parameters ---------- - grad_history: :class:`tensorflow.Tensor` - Tge gradient history to calculate the clip percentile for + gradients: list[:class:`keras.KerasTensor`] + The list of gradient tensors for the optimizer Returns - ------- - :class:`tensorflow.Tensor` - A rank(:attr:`clip_percentile`) `Tensor` - - Notes - ----- - Adapted from - https://github.com/tensorflow/probability/blob/r0.14/tensorflow_probability/python/stats/quantiles.py - to remove reliance on full tensorflow_probability libraray - """ - with tf.name_scope("percentile"): - frac_at_q_or_below = self._clip_percentile / 100. - sorted_hist = tf.sort(grad_history, axis=-1, direction="ASCENDING") - - num = tf.cast(tf.shape(grad_history)[-1], tf.float64) - - # get indices - indices = tf.round((num - 1) * frac_at_q_or_below) - indices = tf.clip_by_value(tf.cast(indices, tf.int32), - 0, - tf.shape(grad_history)[-1] - 1) - gathered_hist = tf.gather(sorted_hist, indices, axis=-1) - - # Propagate NaNs. Apparently tf.is_nan doesn't like other dtypes - nan_batch_members = tf.reduce_any(tf.math.is_nan(grad_history), axis=None) - right_rank_matched_shape = tf.pad(tf.shape(nan_batch_members), - paddings=[[0, tf.rank(self._clip_percentile)]], - constant_values=1) - nan_batch_members = tf.reshape(nan_batch_members, shape=right_rank_matched_shape) - - nan = np.array(np.nan, gathered_hist.dtype.as_numpy_dtype) - gathered_hist = tf.where(nan_batch_members, nan, gathered_hist) - - return gathered_hist - - def __call__(self, grads_and_vars: list[tf.Tensor]) -> list[tf.Tensor]: - """ Call the AutoClip function. - - Parameters ---------- - grads_and_vars: list - The list of gradient tensors and variables for the optimizer + list[:class:`keras.KerasTensor`] + The autoclipped gradients """ - grad_norms = [self._get_grad_norm(g) for g, _ in grads_and_vars] - total_norm = tf.norm(grad_norms) - assign_idx = tf.math.mod(self._index, self._history_size) - self._grad_history = self._grad_history[assign_idx].assign(total_norm) - self._index = self._index.assign_add(1) - clip_value = self._percentile(self._grad_history[: self._index]) - return [(tf.clip_by_norm(g, clip_value), v) for g, v in grads_and_vars] - - @classmethod - def _get_grad_norm(cls, gradients: tf.Tensor) -> tf.Tensor: - """ Obtain the L2 Norm for the gradients + self._grad_history.append(sum(g.data.norm(2).item() ** 2 + for g in gradients if g is not None) ** (1. / 2)) + self._grad_history = self._grad_history[-self._history_size:] + clip_value = np.percentile(self._grad_history, self._clip_percentile) + torch.nn.utils.clip_grad_norm_(gradients, T.cast(float, clip_value)) + return gradients - Parameters - ---------- - gradients: :class:`tensorflow.Tensor` - The gradients to calculate the L2 norm for - Returns - ------- - :class:`tensorflow.Tensor` - The L2 Norm of the given gradients - """ - values = tf.convert_to_tensor(gradients.values - if isinstance(gradients, tf.IndexedSlices) - else gradients, name="t") - - # Calculate L2-norm, clip elements by ratio of clip_norm to L2-norm - l2sum = tf.math.reduce_sum(values * values, axis=None, keepdims=True) - pred = l2sum > 0 - # Two-tap tf.where trick to bypass NaN gradients - l2sum_safe = tf.where(pred, l2sum, tf.ones_like(l2sum)) - return tf.squeeze(tf.where(pred, tf.math.sqrt(l2sum_safe), l2sum)) +__all__ = get_module_objects(__name__) diff --git a/lib/model/backup_restore.py b/lib/model/backup_restore.py index e143266c9e..d60ecd0536 100644 --- a/lib/model/backup_restore.py +++ b/lib/model/backup_restore.py @@ -8,7 +8,7 @@ from shutil import copyfile, copytree, rmtree from lib.serializer import get_serializer -from lib.utils import get_folder +from lib.utils import get_folder, get_module_objects logger = logging.getLogger(__name__) @@ -24,14 +24,14 @@ class Backup(): model_name: str The name of the model that is to be backed up """ - def __init__(self, model_dir, model_name): + def __init__(self, model_dir: str, model_name: str) -> None: logger.debug("Initializing %s: (model_dir: '%s', model_name: '%s')", self.__class__.__name__, model_dir, model_name) self.model_dir = str(model_dir) self.model_name = model_name logger.debug("Initialized %s", self.__class__.__name__) - def _check_valid(self, filename, for_restore=False): + def _check_valid(self, filename: str, for_restore: bool = False) -> bool: """ Check if the passed in filename is valid for a backup or restore operation. Parameters @@ -57,7 +57,7 @@ def _check_valid(self, filename, for_restore=False): retval = True elif not for_restore and ((os.path.isfile(fullpath) and not filename.endswith(".bk")) or (os.path.isdir(fullpath) and - filename == "{}_logs".format(self.model_name))): + filename == f"{self.model_name}_logs")): # Only filenames that do not end with .bk or folders that are the logs folder # are valid for backup retval = True @@ -67,7 +67,7 @@ def _check_valid(self, filename, for_restore=False): return retval @staticmethod - def backup_model(full_path): + def backup_model(full_path: str) -> None: """ Backup a model file. The backed up file is saved with the original filename in the original location with `.bk` @@ -76,16 +76,17 @@ def backup_model(full_path): Parameters ---------- full_path: str - The full path to a `.h5` model file or a `.json` state file + The full path to a `.keras` model file or a `.json` state file """ backupfile = full_path + ".bk" if os.path.exists(backupfile): os.remove(backupfile) if os.path.exists(full_path): - logger.verbose("Backing up: '%s' to '%s'", full_path, backupfile) - os.rename(full_path, backupfile) + logger.verbose("Backing up: '%s' to '%s'", # type:ignore[attr-defined] + full_path, backupfile) + copyfile(full_path, backupfile) - def snapshot_models(self, iterations): + def snapshot_models(self, iterations: int) -> None: """ Take a snapshot of the model at the current state and back it up. The snapshot is a copy of the model folder located in the same root location @@ -97,9 +98,9 @@ def snapshot_models(self, iterations): iterations: int The number of iterations that the model has trained when performing the snapshot. """ - print("") # New line so log message doesn't append to last loss output - logger.verbose("Saving snapshot") - snapshot_dir = "{}_snapshot_{}_iters".format(self.model_dir, iterations) + print("\x1b[2K", end="\r") # Erase the current line + logger.verbose("Saving snapshot") # type:ignore[attr-defined] + snapshot_dir = f"{self.model_dir}_snapshot_{iterations}_iters" if os.path.isdir(snapshot_dir): logger.debug("Removing previously existing snapshot folder: '%s'", snapshot_dir) @@ -112,12 +113,15 @@ def snapshot_models(self, iterations): continue srcfile = os.path.join(self.model_dir, filename) dstfile = os.path.join(dst, filename) - copyfunc = copytree if os.path.isdir(srcfile) else copyfile + logger.debug("Saving snapshot: '%s' > '%s'", srcfile, dstfile) - copyfunc(srcfile, dstfile) + if os.path.isdir(srcfile): + copytree(srcfile, dstfile) + else: + copyfile(srcfile, dstfile) logger.info("Saved snapshot (%s iterations)", iterations) - def restore(self): + def restore(self) -> None: """ Restores a model from backup. The original model files are migrated into a folder within the original model folder @@ -128,7 +132,7 @@ def restore(self): self._restore_files() self._restore_logs(archive_dir) - def _move_archived(self): + def _move_archived(self) -> str: """ Move archived files to the archived folder. Returns @@ -138,20 +142,21 @@ def _move_archived(self): """ logger.info("Archiving existing model files...") now = datetime.now().strftime("%Y%m%d_%H%M%S") - archive_dir = os.path.join(self.model_dir, "{}_archived_{}".format(self.model_name, now)) + archive_dir = os.path.join(self.model_dir, f"{self.model_name}_archived_{now}") os.mkdir(archive_dir) for filename in os.listdir(self.model_dir): if not self._check_valid(filename, for_restore=False): logger.debug("Not moving file to archived: '%s'", filename) continue - logger.verbose("Moving '%s' to archived model folder: '%s'", filename, archive_dir) + logger.verbose( # type:ignore[attr-defined] + "Moving '%s' to archived model folder: '%s'", filename, archive_dir) src = os.path.join(self.model_dir, filename) dst = os.path.join(archive_dir, filename) os.rename(src, dst) - logger.verbose("Archived existing model files") + logger.verbose("Archived existing model files") # type:ignore[attr-defined] return archive_dir - def _restore_files(self): + def _restore_files(self) -> None: """ Restore files from .bk """ logger.info("Restoring models from backup...") for filename in os.listdir(self.model_dir): @@ -159,13 +164,14 @@ def _restore_files(self): logger.debug("Not restoring file: '%s'", filename) continue dstfile = os.path.splitext(filename)[0] - logger.verbose("Restoring '%s' to '%s'", filename, dstfile) + logger.verbose("Restoring '%s' to '%s'", # type:ignore[attr-defined] + filename, dstfile) src = os.path.join(self.model_dir, filename) dst = os.path.join(self.model_dir, dstfile) copyfile(src, dst) - logger.verbose("Restored models from backup") + logger.verbose("Restored models from backup") # type:ignore[attr-defined] - def _restore_logs(self, archive_dir): + def _restore_logs(self, archive_dir: str) -> None: """ Restores the log files up to and including the last backup. Parameters @@ -179,40 +185,48 @@ def _restore_logs(self, archive_dir): for log_dir in log_dirs: src = os.path.join(archive_dir, log_dir) dst = os.path.join(self.model_dir, log_dir) - logger.verbose("Restoring logfile: %s", dst) + logger.verbose("Restoring logfile: %s", dst) # type:ignore[attr-defined] copytree(src, dst) - logger.verbose("Restored Logs") + logger.verbose("Restored Logs") # type:ignore[attr-defined] + + def _get_session_names(self) -> list[str]: + """ Get the existing session names from a state file. - def _get_session_names(self): - """ Get the existing session names from a state file. """ + Returns + ------- + list[str] + The session names that exist for the model + """ serializer = get_serializer("json") state_file = os.path.join(self.model_dir, - "{}_state.{}".format(self.model_name, serializer.file_extension)) + f"{self.model_name}_state.{serializer.file_extension}") state = serializer.load(state_file) - session_names = ["session_{}".format(key) - for key in state["sessions"].keys()] + session_names = [f"session_{key}" for key in state["sessions"].keys()] logger.debug("Session to restore: %s", session_names) return session_names - def _get_log_dirs(self, archive_dir, session_names): + def _get_log_dirs(self, archive_dir: str, session_names: list[str]) -> list[str]: """ Get the session log directory paths in the archive folder. Parameters ---------- archive_dir: str The full path to the model's archive folder - session_names: list + session_names: list[str] The name of the training sessions that exist for the model Returns ------- - list + list[str] The full paths to the log folders """ - archive_logs = os.path.join(archive_dir, "{}_logs".format(self.model_name)) + archive_logs = os.path.join(archive_dir, f"{self.model_name}_logs") paths = [os.path.join(dirpath.replace(archive_dir, "")[1:], folder) for dirpath, dirnames, _ in os.walk(archive_logs) for folder in dirnames if folder in session_names] logger.debug("log folders to restore: %s", paths) return paths + + +__all__ = get_module_objects(__name__) diff --git a/lib/model/initializers.py b/lib/model/initializers.py index 41f7682371..908dd6e892 100644 --- a/lib/model/initializers.py +++ b/lib/model/initializers.py @@ -1,70 +1,28 @@ #!/usr/bin/env python3 """ Custom Initializers for faceswap.py """ +from __future__ import annotations import logging import sys import inspect +import typing as T + +from keras import backend as K, initializers, ops +from keras import saving, Variable +from keras.src.initializers.random_initializers import compute_fans import numpy as np -import tensorflow as tf -# Fix intellisense/linting for tf.keras' thoroughly broken import system -keras = tf.keras -K = keras.backend +from lib.logger import parse_class_init +from lib.utils import get_module_objects +if T.TYPE_CHECKING: + from keras import KerasTensor logger = logging.getLogger(__name__) -def compute_fans(shape, data_format='channels_last'): - """Computes the number of input and output units for a weight shape. - - Ported directly from Keras as the location moves between keras and tensorflow-keras - - Parameters - ---------- - shape: tuple - shape tuple of integers - data_format: str - Image data format to use for convolution kernels. Note that all kernels in Keras are - standardized on the `"channels_last"` ordering (even when inputs are set to - `"channels_first"`). - - Returns - ------- - tuple - A tuple of scalars, `(fan_in, fan_out)`. - - Raises - ------ - ValueError - In case of invalid `data_format` argument. - """ - if len(shape) == 2: - fan_in = shape[0] - fan_out = shape[1] - elif len(shape) in {3, 4, 5}: - # Assuming convolution kernels (1D, 2D or 3D). - # Theano kernel shape: (depth, input_depth, ...) - # Tensorflow kernel shape: (..., input_depth, depth) - if data_format == 'channels_first': - receptive_field_size = np.prod(shape[2:]) - fan_in = shape[1] * receptive_field_size - fan_out = shape[0] * receptive_field_size - elif data_format == 'channels_last': - receptive_field_size = np.prod(shape[:-2]) - fan_in = shape[-2] * receptive_field_size - fan_out = shape[-1] * receptive_field_size - else: - raise ValueError('Invalid data_format: ' + data_format) - else: - # No specific assumptions. - fan_in = np.sqrt(np.prod(shape)) - fan_out = np.sqrt(np.prod(shape)) - return fan_in, fan_out - - -class ICNR(keras.initializers.Initializer): # type:ignore[name-defined] +class ICNR(initializers.Initializer): """ ICNR initializer for checkerboard artifact free sub pixel convolution Parameters @@ -77,7 +35,7 @@ class ICNR(keras.initializers.Initializer): # type:ignore[name-defined] Returns ------- - tensor + :class:`keras.KerasTensor` The modified kernel weights Example @@ -90,76 +48,100 @@ class ICNR(keras.initializers.Initializer): # type:ignore[name-defined] https://arxiv.org/pdf/1707.02937.pdf, https://distill.pub/2016/deconv-checkerboard/ """ - def __init__(self, initializer, scale=2): - self.scale = scale - self.initializer = initializer + def __init__(self, + initializer: dict[str, T.Any] | initializers.Initializer, + scale: int = 2) -> None: + logger.debug(parse_class_init(locals())) + + self._scale = scale + self._initializer = initializer - def __call__(self, shape, dtype="float32", **kwargs): + logger.debug("Initialized %s", self.__class__.__name__) + + def __call__(self, + shape: list[int] | tuple[int, ...], + dtype: str | None = "float32") -> KerasTensor: """ Call function for the ICNR initializer. Parameters ---------- - shape: tuple or list + shape: list[int] | tuple[int, ...] The required resized shape for the output tensor dtype: str The data type for the tensor + kwargs: dict[str, Any] + Standard keras initializer keyword arguments Returns ------- - tensor + :class:`keras.KerasTensor` The modified kernel weights """ shape = list(shape) - if self.scale == 1: - return self.initializer(shape) - new_shape = shape[:3] + [shape[3] // (self.scale ** 2)] - if isinstance(self.initializer, dict): - self.initializer = keras.initializers.deserialize(self.initializer) - var_x = self.initializer(new_shape, dtype) - var_x = K.permute_dimensions(var_x, [2, 0, 1, 3]) - var_x = K.resize_images(var_x, - self.scale, - self.scale, - "channels_last", - interpolation="nearest") - var_x = self._space_to_depth(var_x) - var_x = K.permute_dimensions(var_x, [1, 2, 0, 3]) - logger.debug("Output shape: %s", var_x.shape) - return var_x - - def _space_to_depth(self, input_tensor): - """ Space to depth implementation. + + if self._scale == 1: + if isinstance(self._initializer, dict): + return next(i for i in self._initializer.values()) + return self._initializer(shape) + + new_shape = shape[:3] + [shape[3] // (self._scale ** 2)] + size = [s * self._scale for s in new_shape[:2]] + + if isinstance(self._initializer, dict): + self._initializer = initializers.deserialize(self._initializer) + + var_x = self._initializer(new_shape, dtype) + var_x = ops.transpose(var_x, [2, 0, 1, 3]) + var_x = ops.image.resize(var_x, + size, + interpolation="nearest", + data_format="channels_last") + var_x = self._space_to_depth(T.cast("KerasTensor", var_x)) + var_x = ops.transpose(var_x, [1, 2, 0, 3]) + + logger.debug("ICNR Output shape: %s", var_x.shape) + return T.cast("KerasTensor", var_x) + + def _space_to_depth(self, input_tensor: KerasTensor) -> KerasTensor: + """ Space to depth Keras implementation. Parameters ---------- - input_tensor: tensor + input_tensor: :class:`keras.KerasTensor` The tensor to be manipulated Returns ------- - tensor + :class:`keras.KerasTensor` The manipulated input tensor """ - retval = tf.nn.space_to_depth(input_tensor, block_size=self.scale, data_format="NHWC") - logger.debug("Input shape: %s, Output shape: %s", input_tensor.shape, retval.shape) - return retval + batch, height, width, depth = input_tensor.shape + assert height is not None and width is not None + new_height, new_width = height // 2, width // 2 + inter_shape = (batch, new_height, self._scale, new_width, self._scale, depth) + + var_x = ops.reshape(input_tensor, inter_shape) + var_x = ops.transpose(var_x, (0, 1, 3, 2, 4, 5)) + retval = ops.reshape(var_x, (batch, new_height, new_width, -1)) + + logger.debug("Space to depth - Input shape: %s, Output shape: %s", + input_tensor.shape, retval.shape) + return T.cast("KerasTensor", retval) - def get_config(self): + def get_config(self) -> dict[str, T.Any]: """ Return the ICNR Initializer configuration. Returns ------- - dict + dict[str, Any] The configuration for ICNR Initialization """ - config = {"scale": self.scale, - "initializer": self.initializer - } + config = {"scale": self._scale, "initializer": self._initializer} base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) -class ConvolutionAware(keras.initializers.Initializer): # type:ignore[name-defined] +class ConvolutionAware(initializers.Initializer): """ Initializer that generates orthogonal convolution filters in the Fourier space. If this initializer is passed a shape that is not 3D or 4D, orthogonal initialization will be used. @@ -172,7 +154,7 @@ class ConvolutionAware(keras.initializers.Initializer): # type:ignore[name-defi eps_std: float, optional The Standard deviation for the random normal noise used to break symmetry in the inverse Fourier transform. Default: 0.05 - seed: int, optional + seed: int | None, optional Used to seed the random generator. Default: ``None`` initialized: bool, optional This should always be set to ``False``. To avoid Keras re-calculating the values every time @@ -181,7 +163,7 @@ class ConvolutionAware(keras.initializers.Initializer): # type:ignore[name-defi Returns ------- - tensor + :class:`keras.Variable` The modified kernel weights References @@ -189,48 +171,132 @@ class ConvolutionAware(keras.initializers.Initializer): # type:ignore[name-defi Armen Aghajanyan, https://arxiv.org/abs/1702.06295 """ - def __init__(self, eps_std=0.05, seed=None, initialized=False): - self.eps_std = eps_std - self.seed = seed - self.orthogonal = keras.initializers.Orthogonal() - self.he_uniform = keras.initializers.he_uniform() - self.initialized = initialized + def __init__(self, + eps_std: float = 0.05, + seed: int | None = None, + initialized: bool = False) -> None: + logger.debug(parse_class_init(locals())) + + self._eps_std = eps_std + self._seed = seed + self._orthogonal = initializers.OrthogonalInitializer() + self._he_uniform = initializers.HeUniform() + self._initialized = initialized + + logger.debug("Initialized %s", self.__class__.__name__) + + @classmethod + def _symmetrize(cls, inputs: np.ndarray) -> np.ndarray: + """ Make the given tensor symmetrical. + + Parameters + ---------- + inputs: :class:`numpy.ndarray` + The input tensor to make symmetrical + + Returns + ------- + :class:`numpy.ndarray` + The symmetrical output + """ + var_a = np.transpose(inputs, axes=(0, 1, 3, 2)) + diag = var_a.diagonal(axis1=2, axis2=3) + var_b = np.array([[np.diag(arr) for arr in batch] for batch in diag]) + retval = inputs + var_a - var_b + logger.debug("Input shape: %s. Output shape: %s", inputs.shape, retval.shape) + return retval + + def _create_basis(self, filters_size: int, filters: int, size: int, dtype: str) -> np.ndarray: + """ Create the basis for convolutional aware initialization + + Parameters + ---------- + filters_size: int + The size of the filter + filters: int + The number of filters + dtype: str + The data type - def __call__(self, shape, dtype=None, **kwargs): + Returns + ------- + :class:`numpy.ndarray` + The output array + """ + if size == 1: + return np.random.normal(0.0, self._eps_std, (filters_size, filters, size)) + nbb = filters // size + 1 + var_a = np.random.normal(0.0, 1.0, (filters_size, nbb, size, size)) + var_a = self._symmetrize(var_a) + var_u = np.linalg.svd(var_a)[0].transpose(0, 1, 3, 2) + retval = np.reshape(var_u, (filters_size, nbb * size, size))[:, :filters, :].astype(dtype) + logger.debug("filters_size: %s, filters: %s, size: %s, dtype: %s, output: %s", + filters_size, filters, size, dtype, retval.shape) + return retval + + @classmethod + def _scale_filters(cls, filters: np.ndarray, variance: float) -> np.ndarray: + """ Scale the given filters. + + Parameters + ---------- + filters: :class:`numpy.ndarray` + The filters to scale + variance: float + The amount of variance + + Returns + ------- + :class:`numpy.ndarray` + The scaled filters + """ + c_var = np.var(filters) + var_p = np.sqrt(variance / c_var) + retval = filters * var_p + logger.debug("Scaled filters (filters: %s, variance: %s, output: %s)", + filters.shape, variance, retval.shape) + return retval + + def __call__(self, # pylint: disable=too-many-locals + shape: list[int] | tuple[int, ...], + dtype: str | None = None) -> Variable: """ Call function for the ICNR initializer. Parameters ---------- - shape: tuple or list + shape: list[int] | tuple[int, ...] The required shape for the output tensor dtype: str The data type for the tensor Returns ------- - tensor + :class:`keras.Variable` The modified kernel weights """ - # TODO Tensorflow appears to pass in a :class:`tensorflow.python.framework.dtypes.DType` - # object which causes this to error, so currently just reverts to default dtype if a string - # is not passed in. - if self.initialized: # Avoid re-calculating initializer when loading a saved model - return self.he_uniform(shape, dtype=dtype) - dtype = K.floatx() if not isinstance(dtype, str) else dtype + if self._initialized: # Avoid re-calculating initializer when loading a saved model + return T.cast("Variable", self._he_uniform(shape, dtype=dtype)) + dtype = K.floatx() if dtype is None else dtype logger.info("Calculating Convolution Aware Initializer for shape: %s", shape) rank = len(shape) - if self.seed is not None: - np.random.seed(self.seed) + if self._seed is not None: + np.random.seed(self._seed) - fan_in, _ = compute_fans(shape) # pylint:disable=protected-access + fan_in, _ = compute_fans(shape) variance = 2 / fan_in + kernel_shape: tuple[int, ...] + transpose_dimensions: tuple[int, ...] + correct_ifft: T.Callable + correct_fft: T.Callable + if rank == 3: row, stack_size, filters_size = shape transpose_dimensions = (2, 1, 0) kernel_shape = (row,) - correct_ifft = lambda shape, s=[None]: np.fft.irfft(shape, s[0]) # noqa:E501,E731 # pylint:disable=unnecessary-lambda-assignment + correct_ifft = lambda shape, s=[None]: np.fft.irfft(shape, s[0]) # noqa:E731,E501 pylint:disable=unnecessary-lambda-assignment + correct_fft = np.fft.rfft elif rank == 4: @@ -250,61 +316,45 @@ def __call__(self, shape, dtype=None, **kwargs): correct_ifft = np.fft.irfftn else: - self.initialized = True - return K.variable(self.orthogonal(shape), dtype=dtype) + self._initialized = True + return Variable(self._orthogonal(shape), dtype=dtype) kernel_fourier_shape = correct_fft(np.zeros(kernel_shape)).shape - basis = self._create_basis(filters_size, stack_size, np.prod(kernel_fourier_shape), dtype) + basis = self._create_basis(filters_size, + stack_size, + T.cast(int, np.prod(kernel_fourier_shape)), + dtype) basis = basis.reshape((filters_size, stack_size,) + kernel_fourier_shape) - randoms = np.random.normal(0, self.eps_std, basis.shape[:-2] + kernel_shape) + randoms = np.random.normal(0, self._eps_std, basis.shape[:-2] + kernel_shape) init = correct_ifft(basis, kernel_shape) + randoms init = self._scale_filters(init, variance) - self.initialized = True - return K.variable(init.transpose(transpose_dimensions), dtype=dtype, name="conv_aware") - - def _create_basis(self, filters_size, filters, size, dtype): - """ Create the basis for convolutional aware initialization """ - logger.debug("filters_size: %s, filters: %s, size: %s, dtype: %s", - filters_size, filters, size, dtype) - if size == 1: - return np.random.normal(0.0, self.eps_std, (filters_size, filters, size)) - nbb = filters // size + 1 - var_a = np.random.normal(0.0, 1.0, (filters_size, nbb, size, size)) - var_a = self._symmetrize(var_a) - var_u = np.linalg.svd(var_a)[0].transpose(0, 1, 3, 2) - var_p = np.reshape(var_u, (filters_size, nbb * size, size))[:, :filters, :].astype(dtype) - return var_p - - @staticmethod - def _symmetrize(var_a): - """ Make the given tensor symmetrical. """ - var_b = np.transpose(var_a, axes=(0, 1, 3, 2)) - diag = var_a.diagonal(axis1=2, axis2=3) - var_c = np.array([[np.diag(arr) for arr in batch] for batch in diag]) - return var_a + var_b - var_c - - @staticmethod - def _scale_filters(filters, variance): - """ Scale the given filters. """ - c_var = np.var(filters) - var_p = np.sqrt(variance / c_var) - return filters * var_p + self._initialized = True + retval = Variable(init.transpose(transpose_dimensions), dtype=dtype, name="conv_aware") + logger.debug("ConvAware output: %s", retval) + return retval - def get_config(self): + def get_config(self) -> dict[str, T.Any]: """ Return the Convolutional Aware Initializer configuration. Returns ------- - dict - The configuration for ICNR Initialization + dict[str, Any] + The configuration for Convolutional Aware Initialization """ - return {"eps_std": self.eps_std, - "seed": self.seed, - "initialized": self.initialized} + config = {"eps_std": self._eps_std, + "seed": self._seed, + "initialized": self._initialized} + # pylint:disable=duplicate-code + base_config = super().get_config() + return dict(list(base_config.items()) + list(config.items())) +# pylint:disable=duplicate-code # Update initializers into Keras custom objects for name, obj in inspect.getmembers(sys.modules[__name__]): if inspect.isclass(obj) and obj.__module__ == __name__: - keras.utils.get_custom_objects().update({name: obj}) + saving.get_custom_objects().update({name: obj}) + + +__all__ = get_module_objects(__name__) diff --git a/lib/model/layers.py b/lib/model/layers.py index 4f2c9824c6..51bc644eb7 100644 --- a/lib/model/layers.py +++ b/lib/model/layers.py @@ -2,30 +2,36 @@ """ Custom Layers for faceswap.py. """ from __future__ import annotations -import sys import inspect +import logging +import operator +import sys import typing as T -import tensorflow as tf +from keras import InputSpec, Layer, ops, saving -# Fix intellisense/linting for tf.keras' thoroughly broken import system -from tensorflow.python.keras.utils import conv_utils # pylint:disable=no-name-in-module -keras = tf.keras -layers = keras.layers -K = keras.backend +from lib.logger import parse_class_init +from lib.utils import get_module_objects +if T.TYPE_CHECKING: + from keras import KerasTensor -class _GlobalPooling2D(tf.keras.layers.Layer): - """Abstract class for different global pooling 2D layers. - From keras as access to pooling is trickier in tensorflow.keras - """ +logger = logging.getLogger(__name__) + + +class _GlobalPooling2D(Layer): # pylint:disable=too-many-ancestors + """Abstract class for different global pooling 2D layers. """ def __init__(self, data_format: str | None = None, **kwargs) -> None: + logger.debug(parse_class_init(locals())) + super().__init__(**kwargs) - self.data_format = conv_utils.normalize_data_format(data_format) - self.input_spec = keras.layers.InputSpec(ndim=4) + self.data_format = "channels_last" if data_format is None else data_format + self.input_spec = InputSpec(ndim=4) + logger.debug("Initialized %s", self.__class__.__name__) - def compute_output_shape(self, input_shape): + def compute_output_shape(self, input_shape: tuple[int, ...] # pylint:disable=arguments-differ + ) -> tuple[int, ...]: """ Compute the output shape based on the input shape. Parameters @@ -33,74 +39,83 @@ def compute_output_shape(self, input_shape): input_shape: tuple The input shape to the layer """ - if self.data_format == 'channels_last': + if self.data_format == "channels_last": return (input_shape[0], input_shape[3]) return (input_shape[0], input_shape[1]) - def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + def call(self, inputs: KerasTensor, *args, **kwargs # pylint:disable=arguments-differ + ) -> KerasTensor: """ Override to call the layer. Parameters ---------- - inputs: :class:`tf.Tensor` + inputs: :class:`keras.KerasTensor` The input to the layer + + Returns + ------- + :class:`keras.KerasTensor` + The output from the layer + """ raise NotImplementedError def get_config(self) -> dict[str, T.Any]: """ Set the Keras config """ - config = {'data_format': self.data_format} + config = {"data_format": self.data_format} base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) -class GlobalMinPooling2D(_GlobalPooling2D): +class GlobalMinPooling2D(_GlobalPooling2D): # pylint:disable=too-many-ancestors,abstract-method """Global minimum pooling operation for spatial data. """ - def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + def call(self, inputs: KerasTensor, *args, **kwargs # pylint:disable=arguments-differ + ) -> KerasTensor: """This is where the layer's logic lives. Parameters ---------- - inputs: :class:`tf.Tensor` + inputs: :class:`keras.KerasTensor` Input tensor, or list/tuple of input tensors Returns ------- - tensor + :class:`keras.KerasTensor` A tensor or list/tuple of tensors """ - if self.data_format == 'channels_last': - pooled = K.min(inputs, axis=[1, 2]) + if self.data_format == "channels_last": + pooled = ops.min(inputs, axis=[1, 2]) else: - pooled = K.min(inputs, axis=[2, 3]) + pooled = ops.min(inputs, axis=[2, 3]) return pooled -class GlobalStdDevPooling2D(_GlobalPooling2D): +class GlobalStdDevPooling2D(_GlobalPooling2D): # pylint:disable=too-many-ancestors,abstract-method """Global standard deviation pooling operation for spatial data. """ - def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + def call(self, inputs: KerasTensor, *args, **kwargs # pylint:disable=arguments-differ + ) -> KerasTensor: """This is where the layer's logic lives. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` Input tensor, or list/tuple of input tensors Returns ------- - tensor + :class:`keras.KerasTensor` A tensor or list/tuple of tensors """ - if self.data_format == 'channels_last': - pooled = K.std(inputs, axis=[1, 2]) + if self.data_format == "channels_last": + pooled = ops.std(inputs, axis=[1, 2]) else: - pooled = K.std(inputs, axis=[2, 3]) + pooled = ops.std(inputs, axis=[2, 3]) return pooled -class KResizeImages(tf.keras.layers.Layer): +class KResizeImages(Layer): # pylint:disable=too-many-ancestors,abstract-method """ A custom upscale function that uses :class:`keras.backend.resize_images` to upsample. Parameters @@ -116,36 +131,37 @@ def __init__(self, size: int = 2, interpolation: T.Literal["nearest", "bilinear"] = "nearest", **kwargs) -> None: + logger.debug(parse_class_init(locals())) super().__init__(**kwargs) self.size = size self.interpolation = interpolation + logger.debug("Initialized %s", self.__class__.__name__) - def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + def call(self, inputs: KerasTensor, *args, **kwargs # pylint:disable=arguments-differ + ) -> KerasTensor: """ Call the upsample layer Parameters ---------- - inputs: :class:`tf.Tensor` + inputs: :class:`keras.KerasTensor` Input tensor, or list/tuple of input tensors Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` A tensor or list/tuple of tensors """ - if isinstance(self.size, int): - retval = K.resize_images(inputs, - self.size, - self.size, - "channels_last", - interpolation=self.interpolation) - else: - # Arbitrary resizing - size = int(round(K.int_shape(inputs)[1] * self.size)) - retval = tf.image.resize(inputs, (size, size), method=self.interpolation) + height, width = inputs.shape[1:3] + assert height is not None and width is not None + size = int(round(width * self.size)), int(round(height * self.size)) + retval = ops.image.resize(inputs, + size, + interpolation=self.interpolation, + data_format="channels_last") return retval - def compute_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]: + def compute_output_shape(self, input_shape: tuple[int, ...] # pylint:disable=arguments-differ + ) -> tuple[int, ...]: """Computes the output shape of the layer. This is the input shape with size dimensions multiplied by :attr:`size` @@ -162,7 +178,7 @@ def compute_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]: An input shape tuple """ batch, height, width, channels = input_shape - return (batch, height * self.size, width * self.size, channels) + return (batch, int(round(height * self.size)), int(round(width * self.size)), channels) def get_config(self) -> dict[str, T.Any]: """Returns the config of the layer. @@ -177,7 +193,7 @@ def get_config(self) -> dict[str, T.Any]: return dict(list(base_config.items()) + list(config.items())) -class L2_normalize(tf.keras.layers.Layer): # pylint:disable=invalid-name +class L2Normalize(Layer): # pylint:disable=too-many-ancestors,abstract-method """ Normalizes a tensor w.r.t. the L2 norm alongside the specified axis. Parameters @@ -188,23 +204,37 @@ class L2_normalize(tf.keras.layers.Layer): # pylint:disable=invalid-name The standard Keras Layer keyword arguments (if any) """ def __init__(self, axis: int, **kwargs) -> None: + logger.debug(parse_class_init(locals())) self.axis = axis super().__init__(**kwargs) + logger.debug("Initialized %s", self.__class__.__name__) + + def compute_output_shape(self, input_shape: tuple[int, ...] # pylint:disable=arguments-differ + ) -> tuple[int, ...]: + """ Compute the output shape based on the input shape. - def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + Parameters + ---------- + input_shape: tuple + The input shape to the layer + """ + return input_shape + + def call(self, inputs: KerasTensor, *args, **kwargs # pylint:disable=arguments-differ + ) -> KerasTensor: """This is where the layer's logic lives. Parameters ---------- - inputs: :class:`tf.Tensor` + inputs: :class:`keras.KerasTensor` Input tensor, or list/tuple of input tensors Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` A tensor or list/tuple of tensors """ - return K.l2_normalize(inputs, self.axis) + return ops.normalize(inputs, self.axis, order=2) def get_config(self) -> dict[str, T.Any]: """Returns the config of the layer. @@ -226,7 +256,7 @@ class name. These are handled by `Network` (one layer of abstraction above). return config -class PixelShuffler(tf.keras.layers.Layer): +class PixelShuffler(Layer): # pylint:disable=too-many-ancestors,abstract-method """ PixelShuffler layer for Keras. This layer requires a Convolution2D prior to it, having output filters computed according to @@ -269,54 +299,62 @@ def __init__(self, size: int | tuple[int, int] = (2, 2), data_format: str | None = None, **kwargs) -> None: + logger.debug(parse_class_init(locals())) super().__init__(**kwargs) - self.data_format = conv_utils.normalize_data_format(data_format) - self.size = conv_utils.normalize_tuple(size, 2, 'size') + self.data_format = "channels_last" if data_format is None else data_format + self.size = (size, size) if isinstance(size, int) else tuple(size) + logger.debug("Initialized %s", self.__class__.__name__) - def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + def call(self, inputs: KerasTensor, *args, **kwargs # pylint:disable=arguments-differ + ) -> KerasTensor: """This is where the layer's logic lives. Parameters ---------- - inputs: :class:`tf.Tensor` + inputs: :class:`keras.KerasTensor` Input tensor, or list/tuple of input tensors Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` A tensor or list/tuple of tensors """ - input_shape = K.int_shape(inputs) + input_shape = inputs.shape if len(input_shape) != 4: - raise ValueError('Inputs should have rank ' + + raise ValueError("Inputs should have rank " + str(4) + - '; Received input shape:', str(input_shape)) + "; Received input shape:", str(input_shape)) - if self.data_format == 'channels_first': + out = None + if self.data_format == "channels_first": batch_size, channels, height, width = input_shape + assert height is not None and width is not None and channels is not None if batch_size is None: batch_size = -1 r_height, r_width = self.size o_height, o_width = height * r_height, width * r_width o_channels = channels // (r_height * r_width) - out = K.reshape(inputs, (batch_size, r_height, r_width, o_channels, height, width)) - out = K.permute_dimensions(out, (0, 3, 4, 1, 5, 2)) - out = K.reshape(out, (batch_size, o_channels, o_height, o_width)) - elif self.data_format == 'channels_last': + out = ops.reshape(inputs, (batch_size, r_height, r_width, o_channels, height, width)) + out = ops.transpose(out, (0, 3, 4, 1, 5, 2)) + out = ops.reshape(out, (batch_size, o_channels, o_height, o_width)) + elif self.data_format == "channels_last": batch_size, height, width, channels = input_shape + assert height is not None and width is not None and channels is not None if batch_size is None: batch_size = -1 r_height, r_width = self.size o_height, o_width = height * r_height, width * r_width o_channels = channels // (r_height * r_width) - out = K.reshape(inputs, (batch_size, height, width, r_height, r_width, o_channels)) - out = K.permute_dimensions(out, (0, 1, 3, 2, 4, 5)) - out = K.reshape(out, (batch_size, o_height, o_width, o_channels)) - return out + out = ops.reshape(inputs, (batch_size, height, width, r_height, r_width, o_channels)) + out = ops.transpose(out, (0, 1, 3, 2, 4, 5)) + out = ops.reshape(out, (batch_size, o_height, o_width, o_channels)) + assert out is not None + return T.cast("KerasTensor", out) - def compute_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]: + def compute_output_shape(self, # pylint:disable=arguments-differ + input_shape: tuple[int | None, ...]) -> tuple[int | None, ...]: """Computes the output shape of the layer. Assumes that the layer will be built to match that input shape provided. @@ -333,37 +371,42 @@ def compute_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]: An input shape tuple """ if len(input_shape) != 4: - raise ValueError('Inputs should have rank ' + + raise ValueError("Inputs should have rank " + str(4) + - '; Received input shape:', str(input_shape)) + "; Received input shape:", str(input_shape)) - if self.data_format == 'channels_first': + retval: tuple[int | None, ...] + if self.data_format == "channels_first": height = None width = None if input_shape[2] is not None: height = input_shape[2] * self.size[0] if input_shape[3] is not None: width = input_shape[3] * self.size[1] - channels = input_shape[1] // self.size[0] // self.size[1] + chs = input_shape[1] + assert chs is not None + channels = chs // self.size[0] // self.size[1] if channels * self.size[0] * self.size[1] != input_shape[1]: - raise ValueError('channels of input and size are incompatible') + raise ValueError("channels of input and size are incompatible") retval = (input_shape[0], channels, height, width) - elif self.data_format == 'channels_last': + else: height = None width = None if input_shape[1] is not None: height = input_shape[1] * self.size[0] if input_shape[2] is not None: width = input_shape[2] * self.size[1] - channels = input_shape[3] // self.size[0] // self.size[1] + chs = input_shape[3] + assert chs is not None + channels = chs // self.size[0] // self.size[1] if channels * self.size[0] * self.size[1] != input_shape[3]: - raise ValueError('channels of input and size are incompatible') + raise ValueError("channels of input and size are incompatible") retval = (input_shape[0], height, @@ -386,14 +429,14 @@ class name. These are handled by `Network` (one layer of abstraction above). dict A python dictionary containing the layer configuration """ - config = {'size': self.size, - 'data_format': self.data_format} + config = {"size": self.size, + "data_format": self.data_format} base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) -class QuickGELU(tf.keras.layers.Layer): +class QuickGELU(Layer): # pylint:disable=too-many-ancestors,abstract-method """ Applies GELU approximation that is fast but somewhat inaccurate. Parameters @@ -403,27 +446,40 @@ class QuickGELU(tf.keras.layers.Layer): kwargs: dict The standard Keras Layer keyword arguments (if any) """ - def __init__(self, name: str = "QuickGELU", **kwargs) -> None: + logger.debug(parse_class_init(locals())) super().__init__(name=name, **kwargs) + logger.debug("Initialized %s", self.__class__.__name__) + + def compute_output_shape(self, input_shape: tuple[int, ...] # pylint:disable=arguments-differ + ) -> tuple[int, ...]: + """ Compute the output shape based on the input shape. + + Parameters + ---------- + input_shape: tuple + The input shape to the layer + """ + return input_shape - def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + def call(self, inputs: KerasTensor, *args, **kwargs # pylint:disable=arguments-differ + ) -> KerasTensor: """ Call the QuickGELU layerr Parameters ---------- - inputs : :class:`tf.Tensor` + inputs : :class:`keras.KerasTensor` The input Tensor Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` The output Tensor """ - return inputs * K.sigmoid(1.702 * inputs) + return inputs * ops.sigmoid(1.702 * inputs) -class ReflectionPadding2D(tf.keras.layers.Layer): +class ReflectionPadding2D(Layer): # pylint:disable=too-many-ancestors,abstract-method """Reflection-padding layer for 2D input (e.g. picture). This layer can add rows and columns at the top, bottom, left and right side of an image tensor. @@ -438,39 +494,37 @@ class ReflectionPadding2D(tf.keras.layers.Layer): The standard Keras Layer keyword arguments (if any) """ def __init__(self, stride: int = 2, kernel_size: int = 5, **kwargs) -> None: + logger.debug(parse_class_init(locals())) + if isinstance(stride, (tuple, list)): assert len(stride) == 2 and stride[0] == stride[1] stride = stride[0] self.stride = stride self.kernel_size = kernel_size - self.input_spec: list[tf.Tensor] | None = None + self.input_spec: list[InputSpec] | None = None super().__init__(**kwargs) - def build(self, input_shape: tf.Tensor) -> None: + logger.debug("Initialized %s", self.__class__.__name__) + + def build(self, input_shape: KerasTensor) -> None: """Creates the layer weights. Must be implemented on all layers that have weights. Parameters ---------- - input_shape: :class:`tf.Tensor` + input_shape: :class:`keras.KerasTensor` Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to reference for weight shape computations. """ - self.input_spec = [keras.layers.InputSpec(shape=input_shape)] + self.input_spec = [InputSpec(shape=input_shape)] super().build(input_shape) - def compute_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]: + def compute_output_shape(self, *args, **kwargs) -> tuple[int | None, ...]: """Computes the output shape of the layer. Assumes that the layer will be built to match that input shape provided. - Parameters - ---------- - input_shape: tuple or list of tuples - Shape tuple (tuple of integers) or list of shape tuples (one per output tensor of the - layer). Shape tuples can include None for free dimensions, instead of an integer. - Returns ------- tuple @@ -478,6 +532,8 @@ def compute_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]: """ assert self.input_spec is not None input_shape = self.input_spec[0].shape + assert input_shape is not None + assert input_shape[1] is not None and input_shape[2] is not None in_width, in_height = input_shape[2], input_shape[1] kernel_width, kernel_height = self.kernel_size, self.kernel_size @@ -495,21 +551,24 @@ def compute_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]: input_shape[2] + padding_width, input_shape[3]) - def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + def call(self, inputs: KerasTensor, *args, **kwargs # pylint:disable=arguments-differ + ) -> KerasTensor: """This is where the layer's logic lives. Parameters ---------- - inputs: :class:`tf.Tensor` + inputs: :class:`keras.KerasTensor` Input tensor, or list/tuple of input tensors Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` A tensor or list/tuple of tensors """ assert self.input_spec is not None input_shape = self.input_spec[0].shape + assert input_shape is not None + assert input_shape[1] is not None and input_shape[2] is not None in_width, in_height = input_shape[2], input_shape[1] kernel_width, kernel_height = self.kernel_size, self.kernel_size @@ -527,12 +586,9 @@ def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: padding_left = padding_width // 2 padding_right = padding_width - padding_left - return tf.pad(inputs, - [[0, 0], - [padding_top, padding_bot], - [padding_left, padding_right], - [0, 0]], - 'REFLECT') + return ops.pad(inputs, + [[0, 0], [padding_top, padding_bot], [padding_left, padding_right], [0, 0]], + mode="reflect") def get_config(self) -> dict[str, T.Any]: """Returns the config of the layer. @@ -549,239 +605,118 @@ class name. These are handled by `Network` (one layer of abstraction above). dict A python dictionary containing the layer configuration """ - config = {'stride': self.stride, - 'kernel_size': self.kernel_size} + config = {"stride": self.stride, + "kernel_size": self.kernel_size} base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) -class SubPixelUpscaling(tf.keras.layers.Layer): - """ Sub-pixel convolutional up-scaling layer. - - This layer requires a Convolution2D prior to it, having output filters computed according to - the formula :math:`filters = k * (scale_factor * scale_factor)` where `k` is a user defined - number of filters (generally larger than 32) and `scale_factor` is the up-scaling factor - (generally 2). - - This layer performs the depth to space operation on the convolution filters, and returns a - tensor with the size as defined below. - - Notes - ----- - This method is deprecated as it just performs the same as :class:`PixelShuffler` - using explicit Tensorflow ops. The method is kept in the repository to support legacy - models that have been created with this layer. - - In practice, it is useful to have a second convolution layer after the - :class:`SubPixelUpscaling` layer to speed up the learning process. However, if you are stacking - multiple :class:`SubPixelUpscaling` blocks, it may increase the number of parameters greatly, - so the Convolution layer after :class:`SubPixelUpscaling` layer can be removed. - - Example - ------- - >>> # A standard sub-pixel up-scaling block - >>> x = Convolution2D(256, 3, 3, padding="same", activation="relu")(...) - >>> u = SubPixelUpscaling(scale_factor=2)(x) - [Optional] - >>> x = Convolution2D(256, 3, 3, padding="same", activation="relu")(u) +class Swish(Layer): # pylint:disable=too-many-ancestors,abstract-method + """ Swish Activation Layer implementation for Keras. Parameters ---------- - size: int, optional - The up-scaling factor. Default: `2` - data_format: ["channels_first", "channels_last", ``None``], optional - The data format for the input. Default: ``None`` + beta: float, optional + The beta value to apply to the activation function. Default: `1.0` kwargs: dict The standard Keras Layer keyword arguments (if any) References - ---------- - based on the paper "Real-Time Single Image and Video Super-Resolution Using an Efficient - Sub-Pixel Convolutional Neural Network" (https://arxiv.org/abs/1609.05158). + ----------- + Swish: a Self-Gated Activation Function: https://arxiv.org/abs/1710.05941v1 """ - - def __init__(self, scale_factor: int = 2, data_format: str | None = None, **kwargs) -> None: + def __init__(self, beta: float = 1.0, **kwargs) -> None: + logger.debug(parse_class_init(locals())) super().__init__(**kwargs) + self.beta = beta + logger.debug("Initialized %s", self.__class__.__name__) - self.scale_factor = scale_factor - self.data_format = conv_utils.normalize_data_format(data_format) - - def build(self, input_shape: tuple[int, ...]) -> None: - """Creates the layer weights. - - Must be implemented on all layers that have weights. + def compute_output_shape(self, input_shape: tuple[int, ...] # pylint:disable=arguments-differ + ) -> tuple[int, ...]: + """ Compute the output shape based on the input shape. Parameters ---------- - input_shape: tensor - Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to - reference for weight shape computations. + input_shape: tuple + The input shape to the layer """ - pass # pylint:disable=unnecessary-pass + return input_shape - def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: - """This is where the layer's logic lives. + def call(self, inputs: KerasTensor, *args, **kwargs # pylint:disable=arguments-differ + ) -> KerasTensor: + """ Call the Swish Activation function. Parameters ---------- - inputs: :class:`tf.Tensor` + inputs: tensor Input tensor, or list/tuple of input tensors Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` A tensor or list/tuple of tensors """ - retval = self._depth_to_space(inputs, self.scale_factor, self.data_format) - return retval + return ops.nn.swish(inputs * self.beta) - def compute_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]: - """Computes the output shape of the layer. - - Assumes that the layer will be built to match that input shape provided. - - Parameters - ---------- - input_shape: tuple or list of tuples - Shape tuple (tuple of integers) or list of shape tuples (one per output tensor of the - layer). Shape tuples can include None for free dimensions, instead of an integer. - - Returns - ------- - tuple - An input shape tuple - """ - if self.data_format == "channels_first": - batch, channels, rows, columns = input_shape - return (batch, - channels // (self.scale_factor ** 2), - rows * self.scale_factor, - columns * self.scale_factor) - batch, rows, columns, channels = input_shape - return (batch, - rows * self.scale_factor, - columns * self.scale_factor, - channels // (self.scale_factor ** 2)) - - @classmethod - def _depth_to_space(cls, - inputs: tf.Tensor, - scale: int, - data_format: str | None = None) -> tf.Tensor: - """ Uses phase shift algorithm to convert channels/depth for spatial resolution - - Parameters - ---------- - inputs : :class:`tf.Tensor` - The input Tensor - scale : int - Scale factor - data_format : str | None, optional - "channels_first" or "channels_last" - - Returns - ------- - :class:`tf.Tensor` - The output Tensor - """ - if data_format is None: - data_format = K.image_data_format() - data_format = data_format.lower() - inputs = cls._preprocess_conv2d_input(inputs, data_format) - out = tf.nn.depth_to_space(inputs, scale) - out = cls._postprocess_conv2d_output(out, data_format) - return out - - @staticmethod - def _postprocess_conv2d_output(inputs: tf.Tensor, data_format: str | None) -> tf.Tensor: - """Transpose and cast the output from conv2d if needed. - - Parameters - ---------- - inputs: :class:`tf.Tensor` - The input that requires transposing and casting - data_format: str - `"channels_last"` or `"channels_first"` - - Returns - ------- - :class:`tf.Tensor` - The transposed and cast input tensor - """ - - if data_format == "channels_first": - inputs = tf.transpose(inputs, (0, 3, 1, 2)) - - if K.floatx() == "float64": - inputs = tf.cast(inputs, "float64") - return inputs - - @staticmethod - def _preprocess_conv2d_input(inputs: tf.Tensor, data_format: str | None) -> tf.Tensor: - """Transpose and cast the input before the conv2d. - - Parameters - ---------- - inputs: :class:`tf.Tensor` - The input that requires transposing and casting - data_format: str - `"channels_last"` or `"channels_first"` - - Returns - ------- - :class:`tf.Tensor` - The transposed and cast input tensor - """ - if K.dtype(inputs) == "float64": - inputs = tf.cast(inputs, "float32") - if data_format == "channels_first": - # Tensorflow uses the last dimension as channel dimension, instead of the 2nd one. - # Theano input shape: (samples, input_depth, rows, cols) - # Tensorflow input shape: (samples, rows, cols, input_depth) - inputs = tf.transpose(inputs, (0, 2, 3, 1)) - return inputs - - def get_config(self) -> dict[str, T.Any]: + def get_config(self): """Returns the config of the layer. - A layer config is a Python dictionary (serializable) containing the configuration of a - layer. The same layer can be reinstated later (without its trained weights) from this - configuration. - - The configuration of a layer does not include connectivity information, nor the layer - class name. These are handled by `Network` (one layer of abstraction above). + Adds the :attr:`beta` to config. Returns -------- dict A python dictionary containing the layer configuration """ - config = {"scale_factor": self.scale_factor, - "data_format": self.data_format} - base_config = super().get_config() - return dict(list(base_config.items()) + list(config.items())) + config = super().get_config() + config["beta"] = self.beta + return config -class Swish(tf.keras.layers.Layer): - """ Swish Activation Layer implementation for Keras. +class ScalarOp(Layer): # pylint:disable=too-many-ancestors,abstract-method + """ A layer for scalar operations for migrating TFLambdaOps in Keras 2 models to Keras 3. This + layer should not be used directly Parameters ---------- - beta: float, optional - The beta value to apply to the activation function. Default: `1.0` - kwargs: dict - The standard Keras Layer keyword arguments (if any) - - References - ----------- - Swish: a Self-Gated Activation Function: https://arxiv.org/abs/1710.05941v1 + operation: Literal["multiply", "truediv", "add", "subtract"] + The scalar operation to perform + value: float + The scalar value to use """ - def __init__(self, beta: float = 1.0, **kwargs) -> None: + def __init__(self, + operation: T.Literal["multiply", "truediv", "add", "subtract"], + value: float, + **kwargs) -> None: + logger.debug(parse_class_init(locals())) + assert operation in ("multiply", "truediv", "add", "subtract") + self._operation = operation + self._operator = {"multiply": operator.mul, + "truediv": operator.truediv, + "add": operator.add, + "subtract": operator.sub}[operation] + self._value = value + + if "name" not in kwargs: + kwargs["name"] = f"ScalarOp_{operation}" super().__init__(**kwargs) - self.beta = beta - def call(self, inputs, *args, **kwargs): - """ Call the Swish Activation function. + logger.debug("Initialized %s", self.__class__.__name__) + + def compute_output_shape(self, input_shape: tuple[int, ...] # pylint:disable=arguments-differ + ) -> tuple[int, ...]: + """ Output shape is the same as the input shape. + + Parameters + ---------- + input_shape: tuple + The input shape to the layer + """ + return input_shape + + def call(self, inputs: KerasTensor, *args, **kwargs # pylint:disable=arguments-differ + ) -> KerasTensor: + """ Call the Scalar operation function. Parameters ---------- @@ -790,27 +725,28 @@ def call(self, inputs, *args, **kwargs): Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` A tensor or list/tuple of tensors """ - return tf.nn.swish(inputs * self.beta) + return self._operator(inputs, self._value) def get_config(self): """Returns the config of the layer. - - Adds the :attr:`beta` to config. - Returns -------- dict A python dictionary containing the layer configuration """ config = super().get_config() - config["beta"] = self.beta + config["operation"] = self._operation + config["value"] = self._value return config # Update layers into Keras custom objects for name_, obj in inspect.getmembers(sys.modules[__name__]): if inspect.isclass(obj) and obj.__module__ == __name__: - keras.utils.get_custom_objects().update({name_: obj}) + saving.get_custom_objects().update({name_: obj}) + + +__all__ = get_module_objects(__name__) diff --git a/lib/model/losses/feature_loss.py b/lib/model/losses/feature_loss.py index a23060f3e5..9e96841481 100644 --- a/lib/model/losses/feature_loss.py +++ b/lib/model/losses/feature_loss.py @@ -5,20 +5,18 @@ import logging import typing as T -# Ignore linting errors from Tensorflow's thoroughly broken import system -import tensorflow as tf -from tensorflow.keras import applications as kapp # pylint:disable=import-error -from tensorflow.keras.layers import Dropout, Conv2D, Input, Layer, Resizing # noqa,pylint:disable=no-name-in-module,import-error -from tensorflow.keras.models import Model # pylint:disable=no-name-in-module,import-error -import tensorflow.keras.backend as K # pylint:disable=no-name-in-module,import-error +import keras +from keras import applications as kapp, layers, Model, ops, Variable import numpy as np +from lib.logger import parse_class_init from lib.model.networks import AlexNet, SqueezeNet -from lib.utils import GetModel +from lib.utils import get_module_objects, GetModel if T.TYPE_CHECKING: from collections.abc import Callable + from keras import KerasTensor logger = logging.getLogger(__name__) @@ -45,7 +43,7 @@ class NetInfo: net: Callable | None = None init_kwargs: dict[str, T.Any] = field(default_factory=dict) needs_init: bool = True - outputs: list[Layer] = field(default_factory=list) + outputs: list[str] = field(default_factory=list) class _LPIPSTrunkNet(): @@ -60,9 +58,11 @@ class _LPIPSTrunkNet(): load_weights: bool ``True`` if pretrained trunk network weights should be loaded, otherwise ``False`` """ - def __init__(self, net_name: str, eval_mode: bool, load_weights: bool) -> None: - logger.debug("Initializing: %s (net_name '%s', eval_mode: %s, load_weights: %s)", - self.__class__.__name__, net_name, eval_mode, load_weights) + def __init__(self, + net_name: T.Literal["alex", "squeeze", "vgg16"], + eval_mode: bool, + load_weights: bool) -> None: + logger.debug(parse_class_init(locals())) self._eval_mode = eval_mode self._load_weights = load_weights self._net_name = net_name @@ -76,11 +76,11 @@ def _nets(self) -> dict[str, NetInfo]: "alex": NetInfo(model_id=15, model_name="alexnet_imagenet_no_top_v1.h5", net=AlexNet, - outputs=[f"features.{idx}" for idx in (0, 3, 6, 8, 10)]), + outputs=[f"features_{idx}" for idx in (0, 3, 6, 8, 10)]), "squeeze": NetInfo(model_id=16, model_name="squeezenet_imagenet_no_top_v1.h5", net=SqueezeNet, - outputs=[f"features.{idx}" for idx in (0, 4, 7, 9, 10, 11, 12)]), + outputs=[f"features_{idx}" for idx in (0, 4, 7, 9, 10, 11, 12)]), "vgg16": NetInfo(model_id=17, model_name="vgg16_imagenet_no_top_v1.h5", net=kapp.vgg16.VGG16, @@ -88,17 +88,17 @@ def _nets(self) -> dict[str, NetInfo]: outputs=[f"block{i + 1}_conv{2 if i < 2 else 3}" for i in range(5)])} @classmethod - def _normalize_output(cls, inputs: tf.Tensor, epsilon: float = 1e-10) -> tf.Tensor: + def _normalize_output(cls, inputs: KerasTensor, epsilon: float = 1e-10) -> KerasTensor: """ Normalize the output tensors from the trunk network. Parameters ---------- - inputs: :class:`tensorflow.Tensor` + inputs: :class:`keras.KerasTensor` An output tensor from the trunk model epsilon: float, optional Epsilon to apply to the normalization operation. Default: `1e-10` """ - norm_factor = K.sqrt(K.sum(K.square(inputs), axis=-1, keepdims=True)) + norm_factor = ops.sqrt(ops.sum(ops.square(inputs), axis=-1, keepdims=True)) return inputs / (norm_factor + epsilon) def _process_weights(self, model: Model) -> Model: @@ -130,7 +130,7 @@ def __call__(self) -> Model: Returns ------- - :class:`tensorflow.keras.models.Model` + :class:`keras.models.Model` The trunk net with normalized feature output layers """ if self._net.net is None: @@ -163,14 +163,12 @@ class _LPIPSLinearNet(_LPIPSTrunkNet): ``True`` if a dropout layer should be used in the Linear network otherwise ``False`` """ def __init__(self, - net_name: str, + net_name: T.Literal["alex", "squeeze", "vgg16"], eval_mode: bool, load_weights: bool, trunk_net: Model, use_dropout: bool) -> None: - logger.debug( - "Initializing: %s (trunk_net: %s, use_dropout: %s)", self.__class__.__name__, - trunk_net, use_dropout) + logger.debug(parse_class_init(locals())) super().__init__(net_name=net_name, eval_mode=eval_mode, load_weights=load_weights) self._trunk = trunk_net @@ -189,25 +187,25 @@ def _nets(self) -> dict[str, NetInfo]: "vgg16": NetInfo(model_id=20, model_name="vgg16_lpips_v1.h5")} - def _linear_block(self, net_output_layer: tf.Tensor) -> tuple[tf.Tensor, tf.Tensor]: + def _linear_block(self, net_output_layer: KerasTensor) -> tuple[KerasTensor, KerasTensor]: """ Build a linear block for a trunk network output. Parameters ---------- - net_output_layer: :class:`tensorflow.Tensor` + net_output_layer: :class:`keras.KerasTensor` An output from the selected trunk network Returns ------- - :class:`tensorflow.Tensor` + :class:`keras.KerasTensor` The input to the linear block - :class:`tensorflow.Tensor` + :class:`keras.KerasTensor` The output from the linear block """ - in_shape = K.int_shape(net_output_layer)[1:] - input_ = Input(in_shape) - var_x = Dropout(rate=0.5)(input_) if self._use_dropout else input_ - var_x = Conv2D(1, 1, strides=1, padding="valid", use_bias=False)(var_x) + in_shape = net_output_layer.shape[1:] + input_ = T.cast("KerasTensor", layers.Input(in_shape)) + var_x = layers.Dropout(rate=0.5)(input_) if self._use_dropout else input_ + var_x = layers.Conv2D(1, 1, strides=1, padding="valid", use_bias=False)(var_x) return input_, var_x def __call__(self) -> Model: @@ -216,7 +214,7 @@ def __call__(self) -> Model: Returns ------- - :class:`tensorflow.keras.models.Model` + :class:`keras.models.Model` The compiled Linear Net model """ inputs = [] @@ -232,7 +230,7 @@ def __call__(self) -> Model: return model -class LPIPSLoss(): +class LPIPSLoss(keras.losses.Loss): """ LPIPS Loss Function. A perceptual loss function that uses linear outputs from pretrained CNNs feature layers. @@ -278,8 +276,8 @@ class LPIPSLoss(): ``True`` to return the loss value per feature output layer otherwise ``False``. Default: ``False`` """ - def __init__(self, # pylint:disable=too-many-arguments - trunk_network: str, + def __init__(self, # pylint:disable=too-many-arguments,too-many-positional-arguments + trunk_network: T.Literal["alex", "squeeze", "vgg16"], trunk_pretrained: bool = True, trunk_eval_mode: bool = True, linear_pretrained: bool = True, @@ -289,27 +287,23 @@ def __init__(self, # pylint:disable=too-many-arguments spatial: bool = False, normalize: bool = True, ret_per_layer: bool = False) -> None: - logger.debug( - "Initializing: %s (trunk_network '%s', trunk_pretrained: %s, trunk_eval_mode: %s, " - "linear_pretrained: %s, linear_eval_mode: %s, linear_use_dropout: %s, lpips: %s, " - "spatial: %s, normalize: %s, ret_per_layer: %s)", self.__class__.__name__, - trunk_network, trunk_pretrained, trunk_eval_mode, linear_pretrained, linear_eval_mode, - linear_use_dropout, lpips, spatial, normalize, ret_per_layer) - + logger.debug(parse_class_init(locals())) + super().__init__(name=self.__class__.__name__) self._spatial = spatial self._use_lpips = lpips self._normalize = normalize self._ret_per_layer = ret_per_layer - self._shift = K.constant(np.array([-.030, -.088, -.188], - dtype="float32")[None, None, None, :]) - self._scale = K.constant(np.array([.458, .448, .450], - dtype="float32")[None, None, None, :]) + self._shift = Variable(np.array([-.030, -.088, -.188], + dtype="float32")[None, None, None, :], + trainable=False) + self._scale = Variable(np.array([.458, .448, .450], dtype="float32")[None, None, None, :], + trainable=False) # Loss needs to be done as fp32. We could cast at output, but better to update the model - switch_mixed_precision = tf.keras.mixed_precision.global_policy().name == "mixed_float16" + switch_mixed_precision = keras.mixed_precision.global_policy().name == "mixed_float16" if switch_mixed_precision: logger.debug("Temporarily disabling mixed precision") - tf.keras.mixed_precision.set_global_policy("float32") + keras.mixed_precision.set_global_policy("float32") self._trunk_net = _LPIPSTrunkNet(trunk_network, trunk_eval_mode, trunk_pretrained)() self._linear_net = _LPIPSLinearNet(trunk_network, @@ -319,10 +313,10 @@ def __init__(self, # pylint:disable=too-many-arguments linear_use_dropout)() if switch_mixed_precision: logger.debug("Re-enabling mixed precision") - tf.keras.mixed_precision.set_global_policy("mixed_float16") + keras.mixed_precision.set_global_policy("mixed_float16") logger.debug("Initialized: %s", self.__class__.__name__) - def _process_diffs(self, inputs: list[tf.Tensor]) -> list[tf.Tensor]: + def _process_diffs(self, inputs: list[KerasTensor]) -> list[KerasTensor]: """ Perform processing on the Trunk Network outputs. If :attr:`use_ldip` is enabled, process the diff values through the linear network, @@ -330,19 +324,19 @@ def _process_diffs(self, inputs: list[tf.Tensor]) -> list[tf.Tensor]: Parameters ---------- - inputs: list + inputs: list[:class:`keras.KerasTensor`] List of the squared difference of the true and predicted outputs from the trunk network Returns ------- - list + list[:class:`keras.KerasTensor`] List of either the linear network outputs (when using lpips) or summed network outputs """ if self._use_lpips: return self._linear_net(inputs) - return [K.sum(x, axis=-1) for x in inputs] + return [T.cast("KerasTensor", ops.sum(x, axis=-1)) for x in inputs] - def _process_output(self, inputs: tf.Tensor, output_dims: tuple) -> tf.Tensor: + def _process_output(self, inputs: KerasTensor, output_dims: tuple) -> KerasTensor: """ Process an individual output based on whether :attr:`is_spatial` has been selected. When spatial output is selected, all outputs are sized to the shape of the original True @@ -350,34 +344,34 @@ def _process_output(self, inputs: tf.Tensor, output_dims: tuple) -> tf.Tensor: Parameters ---------- - inputs: :class:`tensorflow.Tensor` + inputs: :class:`keras.KerasTensor` An individual diff output tensor from the linear network or summed output output_dims: tuple The (height, width) of the original true image Returns ------- - :class:`tensorflow.Tensor` + :class:`keras.KerasTensor` Either the original tensor resized to the true image dimensions, or the mean value across the height, width axes. """ if self._spatial: - return Resizing(*output_dims, interpolation="bilinear")(inputs) - return K.mean(inputs, axis=(1, 2), keepdims=True) + return layers.Resizing(*output_dims, interpolation="bilinear")(inputs) + return T.cast("KerasTensor", ops.mean(inputs, axis=(1, 2), keepdims=True)) - def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: """ Perform the LPIPS Loss Function. Parameters ---------- - y_true: :class:`tensorflow.Tensor` + y_true: :class:`keras.KerasTensor` The ground truth batch of images - y_pred: :class:`tensorflow.Tensor` + y_pred: :class:`keras.KerasTensor` The predicted batch of images Returns ------- - :class:`tensorflow.Tensor` + :class:`keras.KerasTensor` The final loss value """ if self._normalize: @@ -393,11 +387,15 @@ def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: diffs = [(out_true - out_pred) ** 2 for out_true, out_pred in zip(net_true, net_pred)] - dims = K.int_shape(y_true)[1:3] + dims = y_true.shape[1:3] res = [self._process_output(diff, dims) for diff in self._process_diffs(diffs)] axis = 0 if self._spatial else None - val = K.sum(res, axis=axis) + val = T.cast("KerasTensor", ops.sum(res, axis=axis)) retval = (val, res) if self._ret_per_layer else val + assert not isinstance(retval, tuple) return retval / 10.0 # Reduce by factor of 10 'cos this loss is STRONG + + +__all__ = get_module_objects(__name__) diff --git a/lib/model/losses/loss.py b/lib/model/losses/loss.py index fe4dd045c1..f8d375df67 100644 --- a/lib/model/losses/loss.py +++ b/lib/model/losses/loss.py @@ -6,19 +6,25 @@ import typing as T import numpy as np -import tensorflow as tf +from keras import Loss, backend as K +from keras import ops, Variable -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.python.keras.engine import compile_utils # pylint:disable=no-name-in-module -from tensorflow.keras import backend as K # pylint:disable=import-error +from lib.logger import parse_class_init +from lib.utils import get_module_objects + +if K.backend() == "torch": + import torch # pylint:disable=import-error +else: + import tensorflow as tf # pylint:disable=import-error # type:ignore if T.TYPE_CHECKING: from collections.abc import Callable + from keras import KerasTensor logger = logging.getLogger(__name__) -class FocalFrequencyLoss(): # pylint:disable=too-few-public-methods +class FocalFrequencyLoss(Loss): """ Focal Frequencey Loss Function. A channels last implementation. @@ -61,32 +67,34 @@ def __init__(self, log_matrix: bool = False, batch_matrix: bool = False, epsilon: float = 1e-6) -> None: + logger.debug(parse_class_init(locals())) + super().__init__(name=self.__class__.__name__) self._alpha = alpha - # TODO Fix bug where FFT will be incorrect if patch_factor > 1 + # TODO Fix bug where FFT will be incorrect if patch_factor > 1 for tensorflow self._patch_factor = patch_factor self._ave_spectrum = ave_spectrum self._log_matrix = log_matrix self._batch_matrix = batch_matrix self._epsilon = epsilon self._dims: tuple[int, int] = (0, 0) + logger.debug("Initialized: %s", self.__class__.__name__) - def _get_patches(self, inputs: tf.Tensor) -> tf.Tensor: + def _get_patches(self, inputs: KerasTensor) -> KerasTensor: """ Crop the incoming batch of images into patches as defined by :attr:`_patch_factor. Parameters ---------- - inputs: :class:`tf.Tensor` + inputs: :class:`keras.KerasTensor` A batch of images to be converted into patches Returns ------- - :class`tf.Tensor`` + :class:`keras.KerasTensor`` The incoming batch converted into patches """ - rows, cols = self._dims patch_list = [] - patch_rows = cols // self._patch_factor - patch_cols = rows // self._patch_factor + patch_rows = self._dims[0] // self._patch_factor + patch_cols = self._dims[1] // self._patch_factor for i in range(self._patch_factor): for j in range(self._patch_factor): row_from = i * patch_rows @@ -95,113 +103,118 @@ def _get_patches(self, inputs: tf.Tensor) -> tf.Tensor: col_to = (j + 1) * patch_cols patch_list.append(inputs[:, row_from: row_to, col_from: col_to, :]) - retval = K.stack(patch_list, axis=1) - return retval + retval = ops.stack(patch_list, axis=1) + return T.cast("KerasTensor", retval) - def _tensor_to_frequency_spectrum(self, patch: tf.Tensor) -> tf.Tensor: + def _tensor_to_frequency_spectrum(self, patch: KerasTensor) -> KerasTensor: """ Perform FFT to create the orthonomalized DFT frequencies. Parameters ---------- - inputs: :class:`tf.Tensor` + inputs: :class:`keras.KerasTensor` The incoming batch of patches to convert to the frequency spectrum Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` The DFT frequencies split into real and imaginary numbers as float32 """ - # TODO fix this for when self._patch_factor != 1. - rows, cols = self._dims - patch = K.permute_dimensions(patch, (0, 1, 4, 2, 3)) # move channels to first + patch = T.cast("KerasTensor", + ops.transpose(patch, (0, 1, 4, 2, 3))) # move channels to first - patch = patch / np.sqrt(rows * cols) # Orthonormalization - - patch = K.cast(patch, "complex64") - freq = tf.signal.fft2d(patch)[..., None] + assert K.backend() in ("torch", "tensorflow"), "Only Torch and Tensorflow are supported" + if K.backend() == "torch": + freq = torch.fft.fft2(patch, # pylint:disable=not-callable # type:ignore + norm="ortho") + else: + patch = patch / np.sqrt(self._dims[0] * self._dims[1]) # Orthonormalization + patch = T.cast("KerasTensor", ops.cast(patch, "complex64")) + freq = tf.signal.fft2d(patch)[..., None] # type:ignore - freq = K.concatenate([tf.math.real(freq), tf.math.imag(freq)], axis=-1) - freq = K.cast(freq, "float32") + freq = ops.stack([freq.real, freq.imag], axis=-1) - freq = K.permute_dimensions(freq, (0, 1, 3, 4, 2, 5)) # channels to last + if K.backend() == "tensorflow": + freq = ops.cast(freq, "float32") - return freq + freq = ops.transpose(freq, (0, 1, 3, 4, 2, 5)) # channels to last + return T.cast("KerasTensor", freq) - def _get_weight_matrix(self, freq_true: tf.Tensor, freq_pred: tf.Tensor) -> tf.Tensor: + def _get_weight_matrix(self, freq_true: KerasTensor, freq_pred: KerasTensor) -> KerasTensor: """ Calculate a continuous, dynamic weight matrix based on current Euclidean distance. Parameters ---------- - freq_true: :class:`tf.Tensor` + freq_true: :class:`keras.KerasTensor` The real and imaginary DFT frequencies for the true batch of images - freq_pred: :class:`tf.Tensor` + freq_pred: :class:`keras.KerasTensor` The real and imaginary DFT frequencies for the predicted batch of images Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` The weights matrix for prioritizing hard frequencies """ - weights = K.square(freq_pred - freq_true) - weights = K.sqrt(weights[..., 0] + weights[..., 1]) - weights = K.pow(weights, self._alpha) + weights = ops.square(freq_pred - freq_true) + weights = ops.sqrt(weights[..., 0] + weights[..., 1]) + weights = ops.power(weights, self._alpha) if self._log_matrix: # adjust the spectrum weight matrix by logarithm - weights = K.log(weights + 1.0) + weights = ops.log(weights + 1.0) if self._batch_matrix: # calculate the spectrum weight matrix using batch-based statistics - scale = K.max(weights) + scale = ops.max(weights) else: - scale = K.max(weights, axis=(-2, -3), keepdims=True) - weights = weights / K.maximum(scale, self._epsilon) + scale = ops.max(weights, axis=(-2, -3), keepdims=True) + weights = weights / ops.maximum(scale, self._epsilon) - weights = K.clip(weights, min_value=0.0, max_value=1.0) + weights = ops.clip(weights, x_min=0.0, x_max=1.0) - return weights + return T.cast("KerasTensor", weights) @classmethod def _calculate_loss(cls, - freq_true: tf.Tensor, - freq_pred: tf.Tensor, - weight_matrix: tf.Tensor) -> tf.Tensor: + freq_true: KerasTensor, + freq_pred: KerasTensor, + weight_matrix: KerasTensor) -> KerasTensor: """ Perform the loss calculation on the DFT spectrum applying the weights matrix. Parameters ---------- - freq_true: :class:`tf.Tensor` + freq_true: :class:`keras.KerasTensor` The real and imaginary DFT frequencies for the true batch of images - freq_pred: :class:`tf.Tensor` + freq_pred: :class:`keras.KerasTensor` The real and imaginary DFT frequencies for the predicted batch of images Returns - :class:`tf.Tensor` + :class:`keras.KerasTensor` The final loss matrix """ - tmp = K.square(freq_pred - freq_true) # freq distance using squared Euclidean distance + tmp = ops.square(freq_pred - freq_true) # freq distance using squared Euclidean distance freq_distance = tmp[..., 0] + tmp[..., 1] loss = weight_matrix * freq_distance # dynamic spectrum weighting (Hadamard product) - return loss + return T.cast("KerasTensor", ops.mean(loss)) - def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: """ Call the Focal Frequency Loss Function. Parameters ---------- - y_true: :class:`tf.Tensor` + y_true: :class:`keras.KerasTensor` The ground truth batch of images - y_pred: :class:`tf.Tensor` + y_pred: :class:`keras.KerasTensor` The predicted batch of images Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` The loss for this batch of images """ if not all(self._dims): - rows, cols = K.int_shape(y_true)[1:3] + rows, cols = y_true.shape[1:3] + assert rows is not None and cols is not None assert cols % self._patch_factor == 0 and rows % self._patch_factor == 0, ( "Patch factor must be a divisor of the image height and width") self._dims = (rows, cols) @@ -213,14 +226,14 @@ def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: freq_pred = self._tensor_to_frequency_spectrum(patches_pred) if self._ave_spectrum: # whether to use minibatch average spectrum - freq_true = K.mean(freq_true, axis=0, keepdims=True) - freq_pred = K.mean(freq_pred, axis=0, keepdims=True) + freq_true = T.cast("KerasTensor", ops.mean(freq_true, axis=0, keepdims=True)) + freq_pred = T.cast("KerasTensor", ops.mean(freq_pred, axis=0, keepdims=True)) weight_matrix = self._get_weight_matrix(freq_true, freq_pred) return self._calculate_loss(freq_true, freq_pred, weight_matrix) -class GeneralizedLoss(): # pylint:disable=too-few-public-methods +class GeneralizedLoss(Loss): """ Generalized function used to return a large variety of mathematical loss functions. The primary benefit is a smooth, differentiable version of L1 loss. @@ -243,33 +256,36 @@ class GeneralizedLoss(): # pylint:disable=too-few-public-methods Default: `1.0/255.0` """ def __init__(self, alpha: float = 1.0, beta: float = 1.0/255.0) -> None: + logger.debug(parse_class_init(locals())) + super().__init__(name=self.__class__.__name__) self._alpha = alpha self._beta = beta + logger.debug("Initialized: %s", self.__class__.__name__) - def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: """ Call the Generalized Loss Function Parameters ---------- - y_true: :class:`tf.Tensor` + y_true: :class:`keras.KerasTensor` The ground truth value - y_pred: :class:`tf.Tensor` + y_pred: :class:`keras.KerasTensor` The predicted value Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` The loss value from the results of function(y_pred - y_true) """ diff = y_pred - y_true - second = (K.pow(K.pow(diff/self._beta, 2.) / K.abs(2. - self._alpha) + 1., - (self._alpha / 2.)) - 1.) - loss = (K.abs(2. - self._alpha)/self._alpha) * second - loss = K.mean(loss, axis=-1) * self._beta - return loss + second = (ops.power(ops.power(diff/self._beta, 2.) / ops.abs(2. - self._alpha) + 1., + (self._alpha / 2.)) - 1.) + loss = (ops.abs(2. - self._alpha)/self._alpha) * second + loss = ops.mean(loss, axis=-1) * self._beta + return T.cast("KerasTensor", loss) -class GradientLoss(): # pylint:disable=too-few-public-methods +class GradientLoss(Loss): """ Gradient Loss Function. Calculates the first and second order gradient difference between pixels of an image in the x @@ -283,119 +299,122 @@ class GradientLoss(): # pylint:disable=too-few-public-methods Chengwu Lu & Hua Huang, 2014 - http://downloads.hindawi.com/journals/mpe/2014/790547.pdf """ def __init__(self) -> None: + logger.debug(parse_class_init(locals())) + super().__init__(name=self.__class__.__name__) self.generalized_loss = GeneralizedLoss(alpha=1.9999) self._tv_weight = 1.0 self._tv2_weight = 1.0 - - def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: - """ Call the gradient loss function. - - Parameters - ---------- - y_true: :class:`tf.Tensor` - The ground truth value - y_pred: :class:`tf.Tensor` - The predicted value - - Returns - ------- - :class:`tf.Tensor` - The loss value - """ - loss = 0.0 - loss += self._tv_weight * (self.generalized_loss(self._diff_x(y_true), - self._diff_x(y_pred)) + - self.generalized_loss(self._diff_y(y_true), - self._diff_y(y_pred))) - loss += self._tv2_weight * (self.generalized_loss(self._diff_xx(y_true), - self._diff_xx(y_pred)) + - self.generalized_loss(self._diff_yy(y_true), - self._diff_yy(y_pred)) + - self.generalized_loss(self._diff_xy(y_true), - self._diff_xy(y_pred)) * 2.) - loss = loss / (self._tv_weight + self._tv2_weight) - # TODO simplify to use MSE instead - return loss + logger.debug("Initialized: %s", self.__class__.__name__) @classmethod - def _diff_x(cls, img: tf.Tensor) -> tf.Tensor: + def _diff_x(cls, img: KerasTensor) -> KerasTensor: """ X Difference """ x_left = img[:, :, 1:2, :] - img[:, :, 0:1, :] x_inner = img[:, :, 2:, :] - img[:, :, :-2, :] x_right = img[:, :, -1:, :] - img[:, :, -2:-1, :] - x_out = K.concatenate([x_left, x_inner, x_right], axis=2) - return x_out * 0.5 + x_out = ops.concatenate([x_left, x_inner, x_right], axis=2) + return T.cast("KerasTensor", x_out) * 0.5 @classmethod - def _diff_y(cls, img: tf.Tensor) -> tf.Tensor: + def _diff_y(cls, img: KerasTensor) -> KerasTensor: """ Y Difference """ y_top = img[:, 1:2, :, :] - img[:, 0:1, :, :] y_inner = img[:, 2:, :, :] - img[:, :-2, :, :] y_bot = img[:, -1:, :, :] - img[:, -2:-1, :, :] - y_out = K.concatenate([y_top, y_inner, y_bot], axis=1) - return y_out * 0.5 + y_out = ops.concatenate([y_top, y_inner, y_bot], axis=1) + return T.cast("KerasTensor", y_out) * 0.5 @classmethod - def _diff_xx(cls, img: tf.Tensor) -> tf.Tensor: + def _diff_xx(cls, img: KerasTensor) -> KerasTensor: """ X-X Difference """ x_left = img[:, :, 1:2, :] + img[:, :, 0:1, :] x_inner = img[:, :, 2:, :] + img[:, :, :-2, :] x_right = img[:, :, -1:, :] + img[:, :, -2:-1, :] - x_out = K.concatenate([x_left, x_inner, x_right], axis=2) + x_out = ops.concatenate([x_left, x_inner, x_right], axis=2) return x_out - 2.0 * img @classmethod - def _diff_yy(cls, img: tf.Tensor) -> tf.Tensor: + def _diff_yy(cls, img: KerasTensor) -> KerasTensor: """ Y-Y Difference """ y_top = img[:, 1:2, :, :] + img[:, 0:1, :, :] y_inner = img[:, 2:, :, :] + img[:, :-2, :, :] y_bot = img[:, -1:, :, :] + img[:, -2:-1, :, :] - y_out = K.concatenate([y_top, y_inner, y_bot], axis=1) + y_out = ops.concatenate([y_top, y_inner, y_bot], axis=1) return y_out - 2.0 * img @classmethod - def _diff_xy(cls, img: tf.Tensor) -> tf.Tensor: + def _diff_xy(cls, img: KerasTensor) -> KerasTensor: """ X-Y Difference """ # xout1 # Left top = img[:, 1:2, 1:2, :] + img[:, 0:1, 0:1, :] inner = img[:, 2:, 1:2, :] + img[:, :-2, 0:1, :] bottom = img[:, -1:, 1:2, :] + img[:, -2:-1, 0:1, :] - xy_left = K.concatenate([top, inner, bottom], axis=1) + xy_left = ops.concatenate([top, inner, bottom], axis=1) # Mid top = img[:, 1:2, 2:, :] + img[:, 0:1, :-2, :] mid = img[:, 2:, 2:, :] + img[:, :-2, :-2, :] bottom = img[:, -1:, 2:, :] + img[:, -2:-1, :-2, :] - xy_mid = K.concatenate([top, mid, bottom], axis=1) + xy_mid = ops.concatenate([top, mid, bottom], axis=1) # Right top = img[:, 1:2, -1:, :] + img[:, 0:1, -2:-1, :] inner = img[:, 2:, -1:, :] + img[:, :-2, -2:-1, :] bottom = img[:, -1:, -1:, :] + img[:, -2:-1, -2:-1, :] - xy_right = K.concatenate([top, inner, bottom], axis=1) + xy_right = ops.concatenate([top, inner, bottom], axis=1) # Xout2 # Left top = img[:, 0:1, 1:2, :] + img[:, 1:2, 0:1, :] inner = img[:, :-2, 1:2, :] + img[:, 2:, 0:1, :] bottom = img[:, -2:-1, 1:2, :] + img[:, -1:, 0:1, :] - xy_left = K.concatenate([top, inner, bottom], axis=1) + xy_left = ops.concatenate([top, inner, bottom], axis=1) # Mid top = img[:, 0:1, 2:, :] + img[:, 1:2, :-2, :] mid = img[:, :-2, 2:, :] + img[:, 2:, :-2, :] bottom = img[:, -2:-1, 2:, :] + img[:, -1:, :-2, :] - xy_mid = K.concatenate([top, mid, bottom], axis=1) + xy_mid = ops.concatenate([top, mid, bottom], axis=1) # Right top = img[:, 0:1, -1:, :] + img[:, 1:2, -2:-1, :] inner = img[:, :-2, -1:, :] + img[:, 2:, -2:-1, :] bottom = img[:, -2:-1, -1:, :] + img[:, -1:, -2:-1, :] - xy_right = K.concatenate([top, inner, bottom], axis=1) + xy_right = ops.concatenate([top, inner, bottom], axis=1) - xy_out1 = K.concatenate([xy_left, xy_mid, xy_right], axis=2) - xy_out2 = K.concatenate([xy_left, xy_mid, xy_right], axis=2) + xy_out1 = T.cast("KerasTensor", ops.concatenate([xy_left, xy_mid, xy_right], axis=2)) + xy_out2 = T.cast("KerasTensor", ops.concatenate([xy_left, xy_mid, xy_right], axis=2)) return (xy_out1 - xy_out2) * 0.25 + def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: + """ Call the gradient loss function. -class LaplacianPyramidLoss(): # pylint:disable=too-few-public-methods + Parameters + ---------- + y_true: :class:`keras.KerasTensor` + The ground truth value + y_pred: :class:`keras.KerasTensor` + The predicted value + + Returns + ------- + :class:`keras.KerasTensor` + The loss value + """ + loss = 0.0 + loss += self._tv_weight * (self.generalized_loss(self._diff_x(y_true), + self._diff_x(y_pred)) + + self.generalized_loss(self._diff_y(y_true), + self._diff_y(y_pred))) + loss += self._tv2_weight * (self.generalized_loss(self._diff_xx(y_true), + self._diff_xx(y_pred)) + + self.generalized_loss(self._diff_yy(y_true), + self._diff_yy(y_pred)) + + self.generalized_loss(self._diff_xy(y_true), + self._diff_xy(y_pred)) * 2.) + loss = loss / (self._tv_weight + self._tv2_weight) + # TODO simplify to use MSE instead + return T.cast("KerasTensor", loss) + + +class LaplacianPyramidLoss(Loss): """ Laplacian Pyramid Loss Function Notes @@ -420,12 +439,16 @@ def __init__(self, max_levels: int = 5, gaussian_size: int = 5, gaussian_sigma: float = 1.0) -> None: + logger.debug(parse_class_init(locals())) + super().__init__(name=self.__class__.__name__) self._max_levels = max_levels - self._weights = K.constant([np.power(2., -2 * idx) for idx in range(max_levels + 1)]) + self._weights = Variable([np.power(2., -2 * idx) for idx in range(max_levels + 1)], + trainable=False) self._gaussian_kernel = self._get_gaussian_kernel(gaussian_size, gaussian_sigma) + logger.debug("Initialized: %s", self.__class__.__name__) @classmethod - def _get_gaussian_kernel(cls, size: int, sigma: float) -> tf.Tensor: + def _get_gaussian_kernel(cls, size: int, sigma: float) -> KerasTensor: """ Obtain the base gaussian kernel for the Laplacian Pyramid. Parameters @@ -437,7 +460,7 @@ def _get_gaussian_kernel(cls, size: int, sigma: float) -> tf.Tensor: Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` The base single channel Gaussian kernel """ assert size % 2 == 1, ("kernel size must be uneven") @@ -447,42 +470,45 @@ def _get_gaussian_kernel(cls, size: int, sigma: float) -> tf.Tensor: kernel = np.exp(- x_2[:, None] - x_2[None, :]) kernel /= kernel.sum() kernel = np.reshape(kernel, (size, size, 1, 1)) - return K.constant(kernel) + return Variable(kernel, trainable=False) - def _conv_gaussian(self, inputs: tf.Tensor) -> tf.Tensor: + def _conv_gaussian(self, inputs: KerasTensor) -> KerasTensor: """ Perform Gaussian convolution on a batch of images. Parameters ---------- - inputs: :class:`tf.Tensor` + inputs: :class:`keras.KerasTensor` The input batch of images to perform Gaussian convolution on. Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` The convolved images """ - channels = K.int_shape(inputs)[-1] - gauss = K.tile(self._gaussian_kernel, (1, 1, 1, channels)) + channels = inputs.shape[-1] + gauss = ops.tile(self._gaussian_kernel, (1, 1, 1, channels)) # TF doesn't implement replication padding like pytorch. This is an inefficient way to # implement it for a square guassian kernel - size = self._gaussian_kernel.shape[1] // 2 + # TODO Make this pure pytorch code + gauss_shape = self._gaussian_kernel.shape[1] + assert gauss_shape is not None + size = gauss_shape // 2 padded_inputs = inputs for _ in range(size): - padded_inputs = tf.pad(padded_inputs, # noqa,pylint:disable=no-value-for-parameter,unexpected-keyword-arg - ([0, 0], [1, 1], [1, 1], [0, 0]), - mode="SYMMETRIC") + padded_inputs = ops.pad(padded_inputs, + ([0, 0], [1, 1], [1, 1], [0, 0]), + mode="symmetric") - retval = K.conv2d(padded_inputs, gauss, strides=1, padding="valid") - return retval + retval = ops.conv(padded_inputs, gauss, strides=1, padding="valid") + return T.cast("KerasTensor", retval) - def _get_laplacian_pyramid(self, inputs: tf.Tensor) -> list[tf.Tensor]: + def _get_laplacian_pyramid(self, inputs: KerasTensor) -> list[KerasTensor]: """ Obtain the Laplacian Pyramid. Parameters ---------- - inputs: :class:`tf.Tensor` + inputs: :class:`keras.KerasTensor` The input batch of images to run through the Laplacian Pyramid Returns @@ -496,59 +522,64 @@ def _get_laplacian_pyramid(self, inputs: tf.Tensor) -> list[tf.Tensor]: gauss = self._conv_gaussian(current) diff = current - gauss pyramid.append(diff) - current = K.pool2d(gauss, (2, 2), strides=(2, 2), padding="valid", pool_mode="avg") + current = ops.average_pool(gauss, (2, 2), strides=(2, 2), padding="valid") pyramid.append(current) return pyramid - def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: """ Calculate the Laplacian Pyramid Loss. Parameters ---------- - y_true: :class:`tf.Tensor` + y_true: :class:`keras.KerasTensor` The ground truth value - y_pred: :class:`tf.Tensor` + y_pred: :class:`keras.KerasTensor` The predicted value Returns ------- - :class: `tf.Tensor` + :class:`keras.KerasTensor` The loss value """ pyramid_true = self._get_laplacian_pyramid(y_true) pyramid_pred = self._get_laplacian_pyramid(y_pred) - losses = K.stack([K.sum(K.abs(ppred - ptrue)) / K.cast(K.prod(K.shape(ptrue)), "float32") - for ptrue, ppred in zip(pyramid_true, pyramid_pred)]) - loss = K.sum(losses * self._weights) - - return loss + losses = ops.stack( + [ops.sum(ops.abs(ppred - ptrue)) / ops.cast(ops.prod(ops.shape(ptrue)), "float32") + for ptrue, ppred in zip(pyramid_true, pyramid_pred)]) + loss = ops.sum(losses * self._weights) + return T.cast("KerasTensor", loss) -class LInfNorm(): # pylint:disable=too-few-public-methods +class LInfNorm(Loss): """ Calculate the L-inf norm as a loss function. """ - def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + def __init__(self, *args, **kwargs) -> None: + logger.debug(parse_class_init(locals())) + super().__init__(*args, name=self.__class__.__name__, **kwargs) + logger.debug("Initialized: %s", self.__class__.__name__) + + def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: """ Call the L-inf norm loss function. Parameters ---------- - y_true: :class:`tf.Tensor` + y_true: :class:`keras.KerasTensor` The ground truth value - y_pred: :class:`tf.Tensor` + y_pred: :class:`keras.KerasTensor` The predicted value Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` The loss value """ - diff = K.abs(y_true - y_pred) - max_loss = K.max(diff, axis=(1, 2), keepdims=True) - loss = K.mean(max_loss, axis=-1) - return loss + diff = ops.abs(y_true - y_pred) + max_loss = ops.max(diff, axis=(1, 2), keepdims=True) + loss = ops.mean(max_loss, axis=-1) + return T.cast("KerasTensor", loss) -class LossWrapper(tf.keras.losses.Loss): +class LossWrapper(Loss): """ A wrapper class for multiple keras losses to enable multiple masked weighted loss functions on a single output. @@ -568,23 +599,23 @@ class LossWrapper(tf.keras.losses.Loss): splits off (4, 128, 128, 3:6) from the end of the tensor, leaving the original y_true of shape (4, 128, 128, 3) ready for masking and feeding through the loss functions. """ - def __init__(self) -> None: - logger.debug("Initializing: %s", self.__class__.__name__) - super().__init__(name="LossWrapper") - self._loss_functions: list[compile_utils.LossesContainer] = [] + def __init__(self, name="LossWrapper", reduction="sum_over_batch_size") -> None: + logger.debug(parse_class_init(locals())) + super().__init__(name=name, reduction=reduction) + self._loss_functions: list[Loss | Callable] = [] self._loss_weights: list[float] = [] self._mask_channels: list[int] = [] logger.debug("Initialized: %s", self.__class__.__name__) def add_loss(self, - function: Callable, + function: Callable | Loss, weight: float = 1.0, mask_channel: int = -1) -> None: """ Add the given loss function with the given weight to the loss function chain. Parameters ---------- - function: :class:`tf.keras.losses.Loss` + function: :class:`keras.losses.Loss` The loss function to add to the loss chain weight: float, optional The weighting to apply to the loss function. Default: `1.0` @@ -595,11 +626,11 @@ def add_loss(self, logger.debug("Adding loss: (function: %s, weight: %s, mask_channel: %s)", function, weight, mask_channel) # Loss must be compiled inside LossContainer for keras to handle distibuted strategies - self._loss_functions.append(compile_utils.LossesContainer(function)) + self._loss_functions.append(function) self._loss_weights.append(weight) self._mask_channels.append(mask_channel) - def call(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: """ Call the sub loss functions for the loss wrapper. Loss is returned as the weighted sum of the chosen losses. @@ -610,40 +641,41 @@ def call(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: Parameters ---------- - y_true: :class:`tensorflow.Tensor` + y_true: :class:`keras.KerasTensor` The ground truth batch of images, with any required masks stacked on the end - y_pred: :class:`tensorflow.Tensor` + y_pred: :class:`keras.KerasTensor` The batch of model predictions Returns ------- - :class:`tensorflow.Tensor` + :class:`keras.KerasTensor` The final weighted loss """ loss = 0.0 for func, weight, mask_channel in zip(self._loss_functions, self._loss_weights, self._mask_channels): - logger.debug("Processing loss function: (func: %s, weight: %s, mask_channel: %s)", + logger.trace("Processing loss function: " # type:ignore[attr-defined] + "(func: %s, weight: %s, mask_channel: %s)", func, weight, mask_channel) n_true, n_pred = self._apply_mask(y_true, y_pred, mask_channel) loss += (func(n_true, n_pred) * weight) - return loss + return T.cast("KerasTensor", loss) @classmethod def _apply_mask(cls, - y_true: tf.Tensor, - y_pred: tf.Tensor, + y_true: KerasTensor, + y_pred: KerasTensor, mask_channel: int, - mask_prop: float = 1.0) -> tuple[tf.Tensor, tf.Tensor]: + mask_prop: float = 1.0) -> tuple[KerasTensor, KerasTensor]: """ Apply the mask to the input y_true and y_pred. If a mask is not required then return the unmasked inputs. Parameters ---------- - y_true: tensor or variable + y_true: :class:`keras.KerasTensor` The ground truth value - y_pred: tensor or variable + y_pred: :class:`keras.KerasTensor` The predicted value mask_channel: int The channel within y_true that the required mask resides in @@ -652,18 +684,18 @@ def _apply_mask(cls, Returns ------- - tf.Tensor + :class:`keras.KerasTensor` The ground truth batch of images, with the required mask applied - tf.Tensor + :class:`keras.KerasTensor` The predicted batch of images with the required mask applied """ if mask_channel == -1: - logger.debug("No mask to apply") + logger.trace("No mask to apply") # type:ignore[attr-defined] return y_true[..., :3], y_pred[..., :3] - logger.debug("Applying mask from channel %s", mask_channel) + logger.trace("Applying mask from channel %s", mask_channel) # type:ignore[attr-defined] - mask = K.tile(K.expand_dims(y_true[..., mask_channel], axis=-1), (1, 1, 1, 3)) + mask = ops.tile(ops.expand_dims(y_true[..., mask_channel], axis=-1), (1, 1, 1, 3)) mask_as_k_inv_prop = 1 - mask_prop mask = (mask * mask_prop) + mask_as_k_inv_prop @@ -671,3 +703,6 @@ def _apply_mask(cls, m_pred = y_pred[..., :3] * mask return m_true, m_pred + + +__all__ = get_module_objects(__name__) diff --git a/lib/model/losses/perceptual_loss.py b/lib/model/losses/perceptual_loss.py index 0fc09b81d7..cbdfa6eed7 100644 --- a/lib/model/losses/perceptual_loss.py +++ b/lib/model/losses/perceptual_loss.py @@ -1,21 +1,28 @@ #!/usr/bin/env python3 -""" TF Keras implementation of Perceptual Loss Functions for faceswap.py """ +""" Keras implementation of Perceptual Loss Functions for faceswap.py """ +from __future__ import annotations import logging import typing as T import numpy as np -import tensorflow as tf +import torch -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras import backend as K # pylint:disable=import-error +import keras +from keras import ops, Variable from lib.keras_utils import ColorSpaceConvert, frobenius_norm, replicate_pad +from lib.logger import parse_class_init +from lib.utils import get_module_objects + +if T.TYPE_CHECKING: + from keras import KerasTensor + from torch import Tensor logger = logging.getLogger(__name__) -class DSSIMObjective(): # pylint:disable=too-few-public-methods +class DSSIMObjective(keras.losses.Loss): """ DSSIM Loss Functions Difference of Structural Similarity (DSSIM loss function). @@ -49,6 +56,8 @@ def __init__(self, filter_size: int = 11, filter_sigma: float = 1.5, max_value: float = 1.0) -> None: + logger.debug(parse_class_init(locals())) + super().__init__(name=self.__class__.__name__) self._filter_size = filter_size self._filter_sigma = filter_sigma self._kernel = self._get_kernel() @@ -56,13 +65,14 @@ def __init__(self, compensation = 1.0 self._c1 = (k_1 * max_value) ** 2 self._c2 = ((k_2 * max_value) ** 2) * compensation + logger.debug("Initialized: %s", self.__class__.__name__) - def _get_kernel(self) -> tf.Tensor: + def _get_kernel(self) -> KerasTensor: """ Obtain the base kernel for performing depthwise convolution. Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` The gaussian kernel based on selected size and sigma """ coords = np.arange(self._filter_size, dtype="float32") @@ -71,90 +81,93 @@ def _get_kernel(self) -> tf.Tensor: kernel = np.square(coords) kernel *= -0.5 / np.square(self._filter_sigma) kernel = np.reshape(kernel, (1, -1)) + np.reshape(kernel, (-1, 1)) - kernel = K.constant(np.reshape(kernel, (1, -1))) - kernel = K.softmax(kernel) - kernel = K.reshape(kernel, (self._filter_size, self._filter_size, 1, 1)) - return kernel + kernel = Variable(np.reshape(kernel, (1, -1)), trainable=False) + kernel = ops.softmax(kernel) + kernel = ops.reshape(kernel, (self._filter_size, self._filter_size, 1, 1)) + return T.cast("KerasTensor", kernel) @classmethod - def _depthwise_conv2d(cls, image: tf.Tensor, kernel: tf.Tensor) -> tf.Tensor: + def _depthwise_conv2d(cls, image: KerasTensor, kernel: KerasTensor) -> KerasTensor: """ Perform a standardized depthwise convolution. Parameters ---------- - image: :class:`tf.Tensor` + image: :class:`keras.KerasTensor` Batch of images, channels last, to perform depthwise convolution - kernel: :class:`tf.Tensor` + kernel: :class:`keras.KerasTensor` convolution kernel Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` The output from the convolution """ - return K.depthwise_conv2d(image, kernel, strides=(1, 1), padding="valid") + return T.cast("KerasTensor", ops.depthwise_conv(image, kernel, strides=1, padding="valid")) - def _get_ssim(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tuple[tf.Tensor, tf.Tensor]: + def _get_ssim(self, + y_true: KerasTensor, + y_pred: KerasTensor) -> tuple[KerasTensor, KerasTensor]: """ Obtain the structural similarity between a batch of true and predicted images. Parameters ---------- - y_true: :class:`tf.Tensor` + y_true: :class:`keras.KerasTensor` The input batch of ground truth images - y_pred: :class:`tf.Tensor` + y_pred: :class:`keras.KerasTensor` The input batch of predicted images Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` The SSIM for the given images - :class:`tf.Tensor` + :class:`keras.KerasTensor` The Contrast for the given images """ - channels = K.int_shape(y_true)[-1] - kernel = K.tile(self._kernel, (1, 1, channels, 1)) + channels = y_true.shape[-1] + kernel = ops.tile(self._kernel, (1, 1, channels, 1)) # SSIM luminance measure is (2 * mu_x * mu_y + c1) / (mu_x ** 2 + mu_y ** 2 + c1) mean_true = self._depthwise_conv2d(y_true, kernel) mean_pred = self._depthwise_conv2d(y_pred, kernel) num_lum = mean_true * mean_pred * 2.0 - den_lum = K.square(mean_true) + K.square(mean_pred) + den_lum = ops.square(mean_true) + ops.square(mean_pred) luminance = (num_lum + self._c1) / (den_lum + self._c1) # SSIM contrast-structure measure is (2 * cov_{xy} + c2) / (cov_{xx} + cov_{yy} + c2) num_con = self._depthwise_conv2d(y_true * y_pred, kernel) * 2.0 - den_con = self._depthwise_conv2d(K.square(y_true) + K.square(y_pred), kernel) + den_con = self._depthwise_conv2d( + T.cast("KerasTensor", ops.square(y_true) + ops.square(y_pred)), kernel) contrast = (num_con - num_lum + self._c2) / (den_con - den_lum + self._c2) # Average over the height x width dimensions axes = (-3, -2) - ssim = K.mean(luminance * contrast, axis=axes) - contrast = K.mean(contrast, axis=axes) + ssim = T.cast("KerasTensor", ops.mean(luminance * contrast, axis=axes)) + contrast = T.cast("KerasTensor", ops.mean(contrast, axis=axes)) return ssim, contrast - def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: """ Call the DSSIM or MS-DSSIM Loss Function. Parameters ---------- - y_true: :class:`tf.Tensor` + y_true: :class:`keras.KerasTensor` The input batch of ground truth images - y_pred: :class:`tf.Tensor` + y_pred: :class:`keras.KerasTensor` The input batch of predicted images Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` The DSSIM or MS-DSSIM for the given images """ ssim = self._get_ssim(y_true, y_pred)[0] retval = (1. - ssim) / 2.0 - return K.mean(retval) + return T.cast("KerasTensor", ops.mean(retval)) -class GMSDLoss(): # pylint:disable=too-few-public-methods +class GMSDLoss(keras.losses.Loss): """ Gradient Magnitude Similarity Deviation Loss. Improved image quality metric over MS-SSIM with easier calculations @@ -164,38 +177,45 @@ class GMSDLoss(): # pylint:disable=too-few-public-methods http://www4.comp.polyu.edu.hk/~cslzhang/IQA/GMSD/GMSD.htm https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf """ - def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: - """ Return the Gradient Magnitude Similarity Deviation Loss. - - Parameters - ---------- - y_true: :class:`tf.Tensor` - The ground truth value - y_pred: :class:`tf.Tensor` - The predicted value - Returns - ------- - :class:`tf.Tensor` - The loss value - """ - true_edge = self._scharr_edges(y_true, True) - pred_edge = self._scharr_edges(y_pred, True) - ephsilon = 0.0025 - upper = 2.0 * true_edge * pred_edge - lower = K.square(true_edge) + K.square(pred_edge) - gms = (upper + ephsilon) / (lower + ephsilon) - gmsd = K.std(gms, axis=(1, 2, 3), keepdims=True) - gmsd = K.squeeze(gmsd, axis=-1) - return gmsd - - @classmethod - def _scharr_edges(cls, image: tf.Tensor, magnitude: bool) -> tf.Tensor: + def __init__(self, *args, **kwargs) -> None: + logger.debug(parse_class_init(locals())) + super().__init__(*args, name=self.__class__.__name__, **kwargs) + self._scharr_edges = Variable(np.array([[[[0.00070, 0.00070]], + [[0.00520, 0.00370]], + [[0.03700, 0.00000]], + [[0.00520, -0.0037]], + [[0.00070, -0.0007]]], + [[[0.00370, 0.00520]], + [[0.11870, 0.11870]], + [[0.25890, 0.00000]], + [[0.11870, -0.1187]], + [[0.00370, -0.0052]]], + [[[0.00000, 0.03700]], + [[0.00000, 0.25890]], + [[0.00000, 0.00000]], + [[0.00000, -0.2589]], + [[0.00000, -0.0370]]], + [[[-0.0037, 0.00520]], + [[-0.1187, 0.11870]], + [[-0.2589, 0.00000]], + [[-0.1187, -0.1187]], + [[-0.0037, -0.0052]]], + [[[-0.0007, 0.00070]], + [[-0.0052, 0.00370]], + [[-0.0370, 0.00000]], + [[-0.0052, -0.0037]], + [[-0.0007, -0.0007]]]]), + dtype="float32", + trainable=False) + logger.debug("Initialized: %s", self.__class__.__name__) + + def _map_scharr_edges(self, image: KerasTensor, magnitude: bool) -> KerasTensor: """ Returns a tensor holding modified Scharr edge maps. Parameters ---------- - image: :class:`tf.Tensor` + image: :class:`keras.KerasTensor` Image tensor with shape [batch_size, h, w, d] and type float32. The image(s) must be 2x2 or larger. magnitude: bool @@ -203,65 +223,61 @@ def _scharr_edges(cls, image: tf.Tensor, magnitude: bool) -> tf.Tensor: Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` Tensor holding edge maps for each channel. Returns a tensor with shape `[batch_size, h, w, d, 2]` where the last two dimensions hold `[[dy[0], dx[0]], [dy[1], dx[1]], ..., [dy[d-1], dx[d-1]]]` calculated using the Scharr filter. """ - # Define vertical and horizontal Scharr filters. - static_image_shape = image.get_shape() - image_shape = K.shape(image) - - # 5x5 modified Scharr kernel ( reshape to (5,5,1,2) ) - matrix = np.array([[[[0.00070, 0.00070]], - [[0.00520, 0.00370]], - [[0.03700, 0.00000]], - [[0.00520, -0.0037]], - [[0.00070, -0.0007]]], - [[[0.00370, 0.00520]], - [[0.11870, 0.11870]], - [[0.25890, 0.00000]], - [[0.11870, -0.1187]], - [[0.00370, -0.0052]]], - [[[0.00000, 0.03700]], - [[0.00000, 0.25890]], - [[0.00000, 0.00000]], - [[0.00000, -0.2589]], - [[0.00000, -0.0370]]], - [[[-0.0037, 0.00520]], - [[-0.1187, 0.11870]], - [[-0.2589, 0.00000]], - [[-0.1187, -0.1187]], - [[-0.0037, -0.0052]]], - [[[-0.0007, 0.00070]], - [[-0.0052, 0.00370]], - [[-0.0370, 0.00000]], - [[-0.0052, -0.0037]], - [[-0.0007, -0.0007]]]]) + image_shape = image.shape num_kernels = [2] - kernels = K.constant(matrix, dtype='float32') - kernels = K.tile(kernels, [1, 1, image_shape[-1], 1]) + + kernels = ops.tile(self._scharr_edges, [1, 1, image_shape[-1], 1]) # Use depth-wise convolution to calculate edge maps per channel. # Output tensor has shape [batch_size, h, w, d * num_kernels]. pad_sizes = [[0, 0], [2, 2], [2, 2], [0, 0]] - padded = tf.pad(image, # pylint:disable=unexpected-keyword-arg,no-value-for-parameter - pad_sizes, - mode='REFLECT') - output = K.depthwise_conv2d(padded, kernels) + padded = ops.pad(image, pad_sizes, mode="reflect") + output = ops.depthwise_conv(padded, kernels) if not magnitude: # direction of edges # Reshape to [batch_size, h, w, d, num_kernels]. - shape = K.concatenate([image_shape, num_kernels], axis=0) - output = K.reshape(output, shape=shape) - output.set_shape(static_image_shape.concatenate(num_kernels)) - output = tf.atan(K.squeeze(output[:, :, :, :, 0] / output[:, :, :, :, 1], axis=None)) + shape = ops.concatenate([image_shape, num_kernels], axis=0) + output = ops.reshape(output, shape) + output = ops.reshape(output, ops.concatenate([image_shape, num_kernels])) + output = torch.atan(T.cast("Tensor", + ops.squeeze(output[:, :, :, :, 0] / output[:, :, :, :, 1], + axis=None))) # magnitude of edges -- unified x & y edges don't work well with Neural Networks - return output + return T.cast("KerasTensor", output) + + def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: + """ Return the Gradient Magnitude Similarity Deviation Loss. + + Parameters + ---------- + y_true: :class:`keras.KerasTensor` + The ground truth value + y_pred: :class:`keras.KerasTensor` + The predicted value + Returns + ------- + :class:`keras.KerasTensor` + The loss value + """ + true_edge = self._map_scharr_edges(y_true, True) + pred_edge = self._map_scharr_edges(y_pred, True) + ephsilon = 0.0025 + upper = 2.0 * true_edge * pred_edge + lower = ops.square(true_edge) + ops.square(pred_edge) + gms = (upper + ephsilon) / (lower + ephsilon) + gmsd = ops.std(gms, axis=(1, 2, 3), keepdims=True) + gmsd = ops.squeeze(gmsd, axis=-1) + return T.cast("KerasTensor", gmsd) -class LDRFLIPLoss(): # pylint:disable=too-few-public-methods + +class LDRFLIPLoss(keras.losses.Loss): # pylint:disable=too-many-instance-attributes """ Computes the LDR-FLIP error map between two LDR images, assuming the images are observed at a certain number of pixels per degree of visual angle. @@ -325,12 +341,8 @@ def __init__(self, epsilon: float = 1e-15, pixels_per_degree: float | None = None, color_order: T.Literal["bgr", "rgb"] = "bgr") -> None: - logger.debug("Initializing: %s (computed_distance_exponent '%s', feature_exponent: %s, " - "lower_threshold_exponent: %s, upper_threshold_exponent: %s, epsilon: %s, " - "pixels_per_degree: %s, color_order: %s)", self.__class__.__name__, - computed_distance_exponent, feature_exponent, lower_threshold_exponent, - upper_threshold_exponent, epsilon, pixels_per_degree, color_order) - + logger.debug(parse_class_init(locals())) + super().__init__(name=self.__class__.__name__) self._computed_distance_exponent = computed_distance_exponent self._feature_exponent = feature_exponent self._pc = lower_threshold_exponent @@ -343,86 +355,88 @@ def __init__(self, self._pixels_per_degree = pixels_per_degree self._spatial_filters = _SpatialFilters(pixels_per_degree) self._feature_detector = _FeatureDetection(pixels_per_degree) + self._col_conv = {"rgb2lab": ColorSpaceConvert(from_space="rgb", to_space="lab"), + "rgb2ycxcz": ColorSpaceConvert("srgb", "ycxcz")} + self._hunt = {"green": Variable([[[[0.0, 1.0, 0.0]]]], dtype="float32", trainable=False), + "blue": Variable([[[[0.0, 0.0, 1.0]]]], dtype="float32", trainable=False)} + logger.debug("Initialized: %s ", self.__class__.__name__) - def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: """ Call the LDR Flip Loss Function Parameters ---------- - y_true: :class:`tensorflow.Tensor` + y_true: :class:`keras.KerasTensor` The ground truth batch of images - y_pred: :class:`tensorflow.Tensor` + y_pred: :class:`keras.KerasTensor` The predicted batch of images Returns ------- - :class::class:`tensorflow.Tensor` + :class::class:`keras.KerasTensor` The calculated Flip loss value """ if self._color_order == "bgr": # Switch models training in bgr order to rgb - y_true = y_true[..., 2::-1] - y_pred = y_pred[..., 2::-1] + y_true = y_true[..., [2, 1, 0]] + y_pred = y_pred[..., [2, 1, 0]] - y_true = K.clip(y_true, 0, 1.) - y_pred = K.clip(y_pred, 0, 1.) + y_true = T.cast("KerasTensor", ops.clip(y_true, 0, 1.)) + y_pred = T.cast("KerasTensor", ops.clip(y_pred, 0, 1.)) - rgb2ycxcz = ColorSpaceConvert("srgb", "ycxcz") - true_ycxcz = rgb2ycxcz(y_true) - pred_ycxcz = rgb2ycxcz(y_pred) + true_ycxcz = self._col_conv["rgb2ycxcz"](y_true) + pred_ycxcz = self._col_conv["rgb2ycxcz"](y_pred) delta_e_color = self._color_pipeline(true_ycxcz, pred_ycxcz) delta_e_features = self._process_features(true_ycxcz, pred_ycxcz) - loss = K.pow(delta_e_color, 1 - delta_e_features) - return loss + loss = ops.power(delta_e_color, 1 - delta_e_features) + return T.cast("KerasTensor", loss) - def _color_pipeline(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + def _color_pipeline(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: """ Perform the color processing part of the FLIP loss function Parameters ---------- - y_true: :class:`tensorflow.Tensor` + y_true: :class:`keras.KerasTensor` The ground truth batch of images in YCxCz color space - y_pred: :class:`tensorflow.Tensor` + y_pred: :class:`keras.KerasTensor` The predicted batch of images in YCxCz color space Returns ------- - :class:`tensorflow.Tensor` + :class:`keras.KerasTensor` The exponentiated, maximum HyAB difference between two colors in Hunt-adjusted L*A*B* space """ filtered_true = self._spatial_filters(y_true) filtered_pred = self._spatial_filters(y_pred) - rgb2lab = ColorSpaceConvert(from_space="rgb", to_space="lab") + rgb2lab = self._col_conv["rgb2lab"] preprocessed_true = self._hunt_adjustment(rgb2lab(filtered_true)) preprocessed_pred = self._hunt_adjustment(rgb2lab(filtered_pred)) - hunt_adjusted_green = self._hunt_adjustment(rgb2lab(K.constant([[[[0.0, 1.0, 0.0]]]], - dtype="float32"))) - hunt_adjusted_blue = self._hunt_adjustment(rgb2lab(K.constant([[[[0.0, 0.0, 1.0]]]], - dtype="float32"))) + hunt_adjusted_green = self._hunt_adjustment(rgb2lab(self._hunt["green"])) + hunt_adjusted_blue = self._hunt_adjustment(rgb2lab(self._hunt["blue"])) delta = self._hyab(preprocessed_true, preprocessed_pred) - power_delta = K.pow(delta, self._computed_distance_exponent) - cmax = K.pow(self._hyab(hunt_adjusted_green, hunt_adjusted_blue), - self._computed_distance_exponent) + power_delta = T.cast("KerasTensor", ops.power(delta, self._computed_distance_exponent)) + cmax = T.cast("KerasTensor", ops.power(self._hyab(hunt_adjusted_green, hunt_adjusted_blue), + self._computed_distance_exponent)) return self._redistribute_errors(power_delta, cmax) - def _process_features(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + def _process_features(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: """ Perform the color processing part of the FLIP loss function Parameters ---------- - y_true: :class:`tensorflow.Tensor` + y_true: :class:`keras.KerasTensor` The ground truth batch of images in YCxCz color space - y_pred: :class:`tensorflow.Tensor` + y_pred: :class:`keras.KerasTensor` The predicted batch of images in YCxCz color space Returns ------- - :class:`tensorflow.Tensor` + :class:`keras.KerasTensor` The exponentiated features delta """ col_y_true = (y_true[..., 0:1] + 16) / 116. @@ -433,75 +447,79 @@ def _process_features(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: edges_pred = self._feature_detector(col_y_pred, "edge") points_pred = self._feature_detector(col_y_pred, "point") - delta = K.maximum(K.abs(frobenius_norm(edges_true) - frobenius_norm(edges_pred)), - K.abs(frobenius_norm(points_pred) - frobenius_norm(points_true))) + delta = ops.maximum(ops.abs(frobenius_norm(edges_true) - frobenius_norm(edges_pred)), + ops.abs(frobenius_norm(points_pred) - frobenius_norm(points_true))) - delta = K.clip(delta, min_value=self._epsilon, max_value=None) - return K.pow(((1 / np.sqrt(2)) * delta), self._feature_exponent) + delta = ops.clip(delta, x_min=self._epsilon, x_max=np.inf) + return T.cast("KerasTensor", ops.power(((1 / np.sqrt(2)) * delta), self._feature_exponent)) @classmethod - def _hunt_adjustment(cls, image: tf.Tensor) -> tf.Tensor: + def _hunt_adjustment(cls, image: KerasTensor) -> KerasTensor: """ Apply Hunt-adjustment to an image in L*a*b* color space Parameters ---------- - image: :class:`tensorflow.Tensor` + image: :class:`keras.KerasTensor` The batch of images in L*a*b* to adjust Returns ------- - :class:`tensorflow.Tensor` + :class:`keras.KerasTensor` The hunt adjusted batch of images in L*a*b color space """ ch_l = image[..., 0:1] - adjusted = K.concatenate([ch_l, image[..., 1:] * (ch_l * 0.01)], axis=-1) - return adjusted + adjusted = ops.concatenate([ch_l, image[..., 1:] * (ch_l * 0.01)], axis=-1) + return T.cast("KerasTensor", adjusted) - def _hyab(self, y_true, y_pred): + def _hyab(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: """ Compute the HyAB distance between true and predicted images. Parameters ---------- - y_true: :class:`tensorflow.Tensor` + y_true: :class:`keras.KerasTensor` The ground truth batch of images in standard or Hunt-adjusted L*A*B* color space - y_pred: :class:`tensorflow.Tensor` + y_pred: :class:`keras.KerasTensor` The predicted batch of images in in standard or Hunt-adjusted L*A*B* color space Returns ------- - :class:`tensorflow.Tensor` + :class:`keras.KerasTensor` image tensor containing the per-pixel HyAB distances between true and predicted images """ delta = y_true - y_pred - root = K.sqrt(K.clip(K.pow(delta[..., 0:1], 2), min_value=self._epsilon, max_value=None)) + root = T.cast("KerasTensor", ops.sqrt(ops.clip(ops.power(delta[..., 0:1], 2), + x_min=self._epsilon, + x_max=np.inf))) delta_norm = frobenius_norm(delta[..., 1:3]) return root + delta_norm - def _redistribute_errors(self, power_delta_e_hyab, cmax): + def _redistribute_errors(self, + power_delta_e_hyab: KerasTensor, + cmax: KerasTensor) -> KerasTensor: """ Redistribute exponentiated HyAB errors to the [0,1] range Parameters ---------- - power_delta_e_hyab: :class:`tensorflow.Tensor` + power_delta_e_hyab: :class:`keras.KerasTensor` The exponentiated HyAb distance - cmax: :class:`tensorflow.Tensor` + cmax: :class:`keras.KerasTensor` The exponentiated, maximum HyAB difference between two colors in Hunt-adjusted L*A*B* space Returns ------- - :class:`tensorflow.Tensor` + :class:`keras.KerasTensor` The redistributed per-pixel HyAB distances (in range [0,1]) """ pccmax = self._pc * cmax - delta_e_c = K.switch( + delta_e_c = ops.where( power_delta_e_hyab < pccmax, (self._pt / pccmax) * power_delta_e_hyab, self._pt + ((power_delta_e_hyab - pccmax) / (cmax - pccmax)) * (1.0 - self._pt)) - return delta_e_c + return T.cast("KerasTensor", delta_e_c) -class _SpatialFilters(): # pylint:disable=too-few-public-methods +class _SpatialFilters(): """ Filters an image with channel specific spatial contrast sensitivity functions and clips result to the unit cube in linear RGB. @@ -514,11 +532,13 @@ class _SpatialFilters(): # pylint:disable=too-few-public-methods impacts the tolerance when calculating loss. """ def __init__(self, pixels_per_degree: float) -> None: + logger.debug(parse_class_init(locals())) self._pixels_per_degree = pixels_per_degree self._spatial_filters, self._radius = self._generate_spatial_filters() self._ycxcz2rgb = ColorSpaceConvert(from_space="ycxcz", to_space="rgb") + logger.debug("Initialized: %s", self.__class__.__name__) - def _generate_spatial_filters(self) -> tuple[tf.Tensor, int]: + def _generate_spatial_filters(self) -> tuple[KerasTensor, int]: """ Generates spatial contrast sensitivity filters with width depending on the number of pixels per degree of visual angle of the observer for channels "A", "RG" and "BY" @@ -542,9 +562,9 @@ def _generate_spatial_filters(self) -> tuple[tf.Tensor, int]: weights = np.array([self._generate_weights(mapping[channel], domain) for channel in ("A", "RG", "BY")]) - weights = K.constant(np.moveaxis(weights, 0, -1), dtype="float32") + vweights = Variable(np.moveaxis(weights, 0, -1), dtype="float32", trainable=False) - return weights, radius + return vweights, radius def _get_evaluation_domain(self, b1_a: float, @@ -563,7 +583,7 @@ def _get_evaluation_domain(self, return domain, radius @classmethod - def _generate_weights(cls, channel: dict[str, float], domain: np.ndarray) -> tf.Tensor: + def _generate_weights(cls, channel: dict[str, float], domain: np.ndarray) -> np.ndarray: """ TODO docstring """ a_1, b_1, a_2, b_2 = channel["a1"], channel["b1"], channel["a2"], channel["b2"] grad = (a_1 * np.sqrt(np.pi / b_1) * np.exp(-np.pi ** 2 * domain / b_1) + @@ -572,30 +592,30 @@ def _generate_weights(cls, channel: dict[str, float], domain: np.ndarray) -> tf. grad = np.reshape(grad, (*grad.shape, 1)) return grad - def __call__(self, image: tf.Tensor) -> tf.Tensor: + def __call__(self, image: KerasTensor) -> KerasTensor: """ Call the spacial filtering. Parameters ---------- - image: Tensor + image: :class:`keras.KerasTensor` Image tensor to filter in YCxCz color space Returns ------- - Tensor + :class:`keras.KerasTensor` The input image transformed to linear RGB after filtering with spatial contrast sensitivity functions """ padded_image = replicate_pad(image, self._radius) - image_tilde_opponent = K.conv2d(padded_image, - self._spatial_filters, - strides=1, - padding="valid") - rgb = K.clip(self._ycxcz2rgb(image_tilde_opponent), 0., 1.) - return rgb + image_tilde_opponent = T.cast("KerasTensor", ops.conv(padded_image, + self._spatial_filters, + strides=1, + padding="valid")) + rgb = ops.clip(self._ycxcz2rgb(image_tilde_opponent), 0., 1.) + return T.cast("KerasTensor", rgb) -class _FeatureDetection(): # pylint:disable=too-few-public-methods +class _FeatureDetection(): """ Detect features (i.e. edges and points) in an achromatic YCxCz image. For use with LDRFlipLoss. @@ -606,57 +626,61 @@ class _FeatureDetection(): # pylint:disable=too-few-public-methods The number of pixels per degree of visual angle of the observer """ def __init__(self, pixels_per_degree: float) -> None: + logger.debug(parse_class_init(locals())) width = 0.082 self._std = 0.5 * width * pixels_per_degree self._radius = int(np.ceil(3 * self._std)) - self._grid = np.meshgrid(range(-self._radius, self._radius + 1), - range(-self._radius, self._radius + 1)) - self._gradient = np.exp(-(self._grid[0] ** 2 + self._grid[1] ** 2) - / (2 * (self._std ** 2))) + grid = np.meshgrid(range(-self._radius, self._radius + 1), + range(-self._radius, self._radius + 1)) - def __call__(self, image: tf.Tensor, feature_type: str) -> tf.Tensor: + gradient = np.exp(-(grid[0] ** 2 + grid[1] ** 2) / (2 * (self._std ** 2))) + self._grads = { + "edge": Variable(np.multiply(-grid[0], gradient), trainable=False, dtype="float32"), + "point": Variable(np.multiply(grid[0] ** 2 / (self._std ** 2) - 1, gradient), + trainable=False, + dtype="float32")} + + logger.debug("Initialized: %s", self.__class__.__name__) + + def __call__(self, image: KerasTensor, feature_type: str) -> KerasTensor: """ Run the feature detection Parameters ---------- - image: Tensor + image: :class:`keras.KerasTensor` Batch of images in YCxCz color space with normalized Y values feature_type: str Type of features to detect (`"edge"` or `"point"`) Returns ------- - Tensor + :class:`keras.KerasTensor` Detected features in the 0-1 range """ feature_type = feature_type.lower() - if feature_type == 'edge': - grad_x = np.multiply(-self._grid[0], self._gradient) - else: - grad_x = np.multiply(self._grid[0] ** 2 / (self._std ** 2) - 1, self._gradient) - - negative_weights_sum = -np.sum(grad_x[grad_x < 0]) - positive_weights_sum = np.sum(grad_x[grad_x > 0]) + grad_x = self._grads[feature_type] + negative_weights_sum = -ops.sum(grad_x[grad_x < 0]) + positive_weights_sum = ops.sum(grad_x[grad_x > 0]) - grad_x = K.constant(grad_x) - grad_x = K.switch(grad_x < 0, grad_x / negative_weights_sum, grad_x / positive_weights_sum) - kernel = K.expand_dims(K.expand_dims(grad_x, axis=-1), axis=-1) - - features_x = K.conv2d(replicate_pad(image, self._radius), + grad_x = ops.where(grad_x < 0, + grad_x / negative_weights_sum, + grad_x / positive_weights_sum) + kernel = ops.expand_dims(ops.expand_dims(grad_x, axis=-1), axis=-1) + features_x = ops.conv(replicate_pad(image, self._radius), kernel, strides=1, padding="valid") - kernel = K.permute_dimensions(kernel, (1, 0, 2, 3)) - features_y = K.conv2d(replicate_pad(image, self._radius), + kernel = ops.transpose(kernel, (1, 0, 2, 3)) + features_y = ops.conv(replicate_pad(image, self._radius), kernel, strides=1, padding="valid") - features = K.concatenate([features_x, features_y], axis=-1) - return features + features = ops.concatenate([features_x, features_y], axis=-1) + return T.cast("KerasTensor", features) -class MSSIMLoss(): # pylint:disable=too-few-public-methods +class MSSIMLoss(keras.losses.Loss): """ Multiscale Structural Similarity Loss Function Parameters @@ -680,6 +704,7 @@ class MSSIMLoss(): # pylint:disable=too-few-public-methods Notes ------ You should add a regularization term like a l2 loss in addition to this one. + Adapted from Tehnsorflow's ssim_multiscale implementation """ def __init__(self, k_1: float = 0.01, @@ -689,43 +714,243 @@ def __init__(self, max_value: float = 1.0, power_factors: tuple[float, ...] = (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) ) -> None: + logger.debug(parse_class_init(locals())) + super().__init__(name=self.__class__.__name__) self.filter_size = filter_size - self.filter_sigma = filter_sigma - self.k_1 = k_1 - self.k_2 = k_2 - self.max_value = max_value - self.power_factors = power_factors + self._filter_sigma = Variable(filter_sigma, dtype="float32", trainable=False) + self._k_1 = k_1 + self._k_2 = k_2 + self._max_value = max_value + self._power_factors = power_factors + self._divisor = [1, 2, 2, 1] + self._divisor_tensor = Variable(self._divisor[1:], dtype="int32", trainable=False) + logger.debug("Initialized: %s", self.__class__.__name__) + + @classmethod + def _reducer(cls, image: KerasTensor, kernel: KerasTensor) -> KerasTensor: + """ Computes local averages from a set of images + + Parameters + ---------- + image: :class:`keras.KerasTensor` + The images to be processed + kernel: :class:`keras.KerasTensor` + The kernel to apply - def __call__(self, y_true: tf.Tensor, y_pred: tf.Tensor) -> tf.Tensor: + Returns + ------- + :class:`keras.KerasTensor` + The reduced image + """ + shape = image.shape + var_x = ops.reshape(image, (-1, *shape[-3:])) + var_y = ops.nn.depthwise_conv(var_x, kernel, strides=1, padding="valid") + return T.cast("KerasTensor", ops.reshape(var_y, (*shape[:-3], *var_y.shape[1:]))) + + def _ssim_helper(self, + image1: KerasTensor, + image2: KerasTensor, + kernel: KerasTensor) -> tuple[KerasTensor, KerasTensor]: + """ Helper function for computing SSIM + + Parameters + ---------- + image1: :class:`keras.KerasTensor` + The first set of images + image2: :class:`keras.KerasTensor` + The second set of images + kernel: :class:`keras.KerasTensor` + The gaussian kernel + + Returns + ------- + :class:`keras.KerasTensor`: + The channel-wise SSIM + :class:`keras.KerasTensor`: + The channel-wise contrast-structure + """ + c_1 = (self._k_1 * self._max_value) ** 2 + c_2 = (self._k_2 * self._max_value) ** 2 + + mean0 = self._reducer(image1, kernel) + mean1 = self._reducer(image2, kernel) + num0 = mean0 * mean1 * 2.0 + den0 = ops.square(mean0) + ops.square(mean1) + luminance = (num0 + c_1) / (den0 + c_1) + + num1 = self._reducer(image1 * image2, kernel) * 2.0 + den1 = self._reducer(T.cast("KerasTensor", ops.square(image1) + ops.square(image2)), + kernel) + cs_ = (num1 - num0 + c_2) / (den1 - den0 + c_2) + + return luminance, cs_ + + def _fspecial_gauss(self, size: int) -> KerasTensor: + """Function to mimic the 'fspecial' gaussian MATLAB function. + + Parameters + ---------- + filter_size: int + size of gaussian filter + + Returns + ------- + :class:`keras.KerasTensor` + The gaussian kernel + """ + coords = ops.cast(range(size), self._filter_sigma.dtype) + coords -= ops.cast(size - 1, self._filter_sigma.dtype) / 2.0 + + gauss = ops.square(coords) + gauss *= -0.5 / ops.square(self._filter_sigma) + + gauss = ops.reshape(gauss, [1, -1]) + ops.reshape(gauss, [-1, 1]) + gauss = ops.reshape(gauss, [1, -1]) # For ops.softmax(). + gauss = ops.softmax(gauss) + return T.cast("KerasTensor", ops.reshape(gauss, [size, size, 1, 1])) + + def _ssim_per_channel(self, + image1: KerasTensor, + image2: KerasTensor, + filter_size: int) -> tuple[KerasTensor, KerasTensor]: + """Computes SSIM index between image1 and image2 per color channel. + + This function matches the standard SSIM implementation from: + Wang, Z., Bovik, A. C., Sheikh, H. R., & Simoncelli, E. P. (2004). Image + quality assessment: from error visibility to structural similarity. IEEE + transactions on image processing. + + Parameters + ---------- + image1: :class:`keras.KerasTensor` + The first image batch + image2: :class:`keras.KerasTensor` + The second image batch. + filter_size: int + size of gaussian filter). + + Returns + ------- + :class:`keras.KerasTensor`: + The channel-wise SSIM + :class:`keras.KerasTensor`: + The channel-wise contrast-structure + """ + shape = image1.shape + + kernel = self._fspecial_gauss(filter_size) + kernel = ops.tile(kernel, [1, 1, shape[-1], 1]) + + luminance, cs_ = self._ssim_helper(image1, image2, kernel) + + # Average over the second and the third from the last: height, width. + ssim_val = T.cast("KerasTensor", ops.mean(luminance * cs_, [-3, -2])) + cs_ = T.cast("KerasTensor", ops.mean(cs_, [-3, -2])) + return ssim_val, cs_ + + @classmethod + def _do_pad(cls, images: list[KerasTensor], remainder: KerasTensor) -> list[KerasTensor]: + """ Pad images + + Parameters + ---------- + images: list[:class:`keras.KerasTensor`] + Images to pad + remainder: :class:`keras.KerasTensor` + Remainding images to pad + + Returns + ------- + list[:class:`keras.KerasTensor`] + Padded images + """ + padding = ops.expand_dims(remainder, axis=-1) + padding = ops.pad(padding, [[1, 0], [1, 0]], mode="constant") + return [ops.pad(x, padding, mode="symmetric") for x in images] + + def _mssism(self, # pylint:disable=too-many-locals + y_true: KerasTensor, + y_pred: KerasTensor, + filter_size: int) -> KerasTensor: + """ Perform the MSSISM calculation. + + Ported from Tensorflow implementation `image.ssim_multiscale` + + Parameters + ---------- + y_true: :class:`keras.KerasTensor` + The ground truth value + y_pred: :class:`keras.KerasTensor` + The predicted value + filter_size: int + The filter size to use + """ + images = [y_true, y_pred] + shapes = [y_true.shape, y_pred.shape] + heads = [s[:-3] for s in shapes] + tails = [s[-3:] for s in shapes] + + mcs = [] + ssim_per_channel = None + for k in range(len(self._power_factors)): + if k > 0: + # Avg pool takes rank 4 tensors. Flatten leading dimensions. + flat_images = [T.cast("KerasTensor", ops.reshape(x, (-1, *t))) + for x, t in zip(images, tails)] + remainder = tails[0] % self._divisor_tensor + + need_padding = ops.any(ops.not_equal(remainder, 0)) + padded = ops.cond( + need_padding, + lambda: self._do_pad(flat_images, # pylint:disable=cell-var-from-loop + remainder), # pylint:disable=cell-var-from-loop + lambda: flat_images) # pylint:disable=cell-var-from-loop + + downscaled = [ops.average_pool(x, + self._divisor[1:3], + strides=self._divisor[1:3], + padding='valid') + for x in padded] + + tails = [x.shape[1:] for x in downscaled] + images = [T.cast("KerasTensor", ops.reshape(x, (*h, *t))) + for x, h, t in zip(downscaled, heads, tails)] + + # Overwrite previous ssim value since we only need the last one. + ssim_per_channel, cs_ = self._ssim_per_channel(images[0], images[1], filter_size) + mcs.append(ops.relu(cs_)) + + mcs.pop() # Remove the cs score for the last scale. + + mcs_and_ssim = ops.stack(mcs + [ops.relu(ssim_per_channel)], axis=-1) + ms_ssim = ops.prod(ops.power(mcs_and_ssim, self._power_factors), [-1]) + + return T.cast("KerasTensor", ops.mean(ms_ssim, [-1])) # Avg over color channels. + + def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: """ Call the MS-SSIM Loss Function. Parameters ---------- - y_true: :class:`tf.Tensor` + y_true: :class:`keras.KerasTensor` The ground truth value - y_pred: :class:`tf.Tensor` + y_pred: :class:`keras.KerasTensor` The predicted value Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` The MS-SSIM Loss value """ - im_size = K.int_shape(y_true)[1] + im_size = y_true.shape[1] + assert isinstance(im_size, int) # filter size cannot be larger than the smallest scale - smallest_scale = self._get_smallest_size(im_size, len(self.power_factors) - 1) + smallest_scale = self._get_smallest_size(im_size, len(self._power_factors) - 1) filter_size = min(self.filter_size, smallest_scale) - ms_ssim = tf.image.ssim_multiscale(y_true, - y_pred, - self.max_value, - power_factors=self.power_factors, - filter_size=filter_size, - filter_sigma=self.filter_sigma, - k1=self.k_1, - k2=self.k_2) + ms_ssim = self._mssism(y_true, y_pred, filter_size) ms_ssim_loss = 1. - ms_ssim - return K.mean(ms_ssim_loss) + return T.cast("KerasTensor", ops.mean(ms_ssim_loss)) def _get_smallest_size(self, size: int, idx: int) -> int: """ Recursive function to obtain the smallest size that the image will be scaled to. @@ -744,7 +969,10 @@ def _get_smallest_size(self, size: int, idx: int) -> int: The smallest size the image will be scaled to based on the original image size and the amount of scaling factors that will occur """ - logger.debug("scale id: %s, size: %s", idx, size) + logger.trace("scale id: %s, size: %s", idx, size) # type:ignore[attr-defined] if idx > 0: size = self._get_smallest_size(size // 2, idx - 1) return size + + +__all__ = get_module_objects(__name__) diff --git a/lib/model/networks/clip.py b/lib/model/networks/clip.py index 1b07e3fc76..d325624f57 100644 --- a/lib/model/networks/clip.py +++ b/lib/model/networks/clip.py @@ -8,17 +8,19 @@ import logging import typing as T import sys +import warnings from dataclasses import dataclass -import tensorflow as tf +from keras import layers, ops, Variable, models, saving +import numpy as np from lib.model.layers import QuickGELU -from lib.utils import GetModel +from lib.utils import get_module_objects, GetModel + +if T.TYPE_CHECKING: + from keras import KerasTensor -keras = tf.keras -layers = tf.keras.layers -K = tf.keras.backend logger = logging.getLogger(__name__) @@ -59,7 +61,7 @@ def __post_init__(self): isinstance(self.layer_conf, int) and self.patch > 0) -ModelConfig: dict[TypeModels, ViTConfig] = { # Each model has a different set of parameters +MODEL_CONFIG: dict[TypeModels, ViTConfig] = { # Each model has a different set of parameters "RN50": ViTConfig( embed_dim=1024, resolution=224, layer_conf=(3, 4, 6, 3), width=64, patch=0, git_id=21), "RN101": ViTConfig( @@ -88,7 +90,7 @@ def __post_init__(self): # VISUAL TRANSFORMER # # ################## # -class Transformer(): # pylint:disable=too-few-public-methods +class Transformer(): """ A class representing a Transformer model with attention mechanism and residual connections. Parameters @@ -99,14 +101,14 @@ class Transformer(): # pylint:disable=too-few-public-methods The number of layers in the Transformer. heads: int The number of attention heads. - attn_mask: tf.Tensor, optional + attn_mask: :class:`keras.KerasTensor`, optional The attention mask, by default None. name: str, optional The name of the Transformer model, by default "transformer". Methods ------- - __call__() -> Model: + __call__() -> :class:`keras.models.Model`: Calls the Transformer layers. """ _layer_names: dict[str, int] = {} @@ -116,7 +118,7 @@ def __init__(self, width: int, num_layers: int, heads: int, - attn_mask: tf.Tensor = None, + attn_mask: KerasTensor = None, name: str = "transformer") -> None: logger.debug("Initializing: %s (width: %s, num_layers: %s, heads: %s, attn_mask: %s, " "name: %s)", @@ -146,17 +148,17 @@ def _get_name(cls, name: str) -> str: The unique name for this layer """ cls._layer_names[name] = cls._layer_names.setdefault(name, -1) + 1 - name = f"{name}.{cls._layer_names[name]}" + name = f"{name}_{cls._layer_names[name]}" logger.debug("Generating block name: %s", name) return name @classmethod - def _mlp(cls, inputs: tf.Tensor, key_dim: int, name: str) -> tf.Tensor: - """" Multilayer Perecptron for Block Ateention + def _mlp(cls, inputs: KerasTensor, key_dim: int, name: str) -> KerasTensor: + """" Multilayer Perceptron for Block Attention Parameters ---------- - inputs: :class:`tensorflow.Tensor` + inputs: :class:`keras.KerasTensor` The input to the MLP key_dim: int key dimension per head for MultiHeadAttention @@ -165,65 +167,65 @@ def _mlp(cls, inputs: tf.Tensor, key_dim: int, name: str) -> tf.Tensor: Returns ------- - :class:`tensorflow.Tensor` + :class:`keras.KerasTensor` The output from the MLP """ - name = f"{name}.mlp" - var_x = layers.Dense(key_dim * 4, name=f"{name}.c_fc")(inputs) - var_x = QuickGELU(name=f"{name}.gelu")(var_x) - var_x = layers.Dense(key_dim, name=f"{name}.c_proj")(var_x) + name = f"{name}_mlp" + var_x = layers.Dense(key_dim * 4, name=f"{name}_c_fc")(inputs) + var_x = QuickGELU(name=f"{name}_gelu")(var_x) + var_x = layers.Dense(key_dim, name=f"{name}_c_proj")(var_x) return var_x def residual_attention_block(self, - inputs: tf.Tensor, + inputs: KerasTensor, key_dim: int, num_heads: int, - attn_mask: tf.Tensor, - name: str = "ResidualAttentionBlock") -> tf.Tensor: + attn_mask: KerasTensor, + name: str = "ResidualAttentionBlock") -> KerasTensor: """ Call the residual attention block Parameters ---------- - inputs: :class:`tf.Tensor` + inputs: :class:`keras.KerasTensor` The input Tensor key_dim: int key dimension per head for MultiHeadAttention num_heads: int Number of heads for MultiHeadAttention - attn_mask: :class:`tensorflow.Tensor`, optional + attn_mask: :class:`keras.KerasTensor`, optional Default: ``None`` name: str, optional The name for the layer. Default: "ResidualAttentionBlock" Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` The return Tensor """ name = self._get_name(name) - var_x = layers.LayerNormalization(epsilon=1e-05, name=f"{name}.ln_1")(inputs) + var_x = layers.LayerNormalization(epsilon=1e-05, name=f"{name}_ln_1")(inputs) var_x = layers.MultiHeadAttention( num_heads=num_heads, key_dim=key_dim // num_heads, - name=f"{name}.attn")(var_x, var_x, var_x, attention_mask=attn_mask) + name=f"{name}_attn")(var_x, var_x, var_x, attention_mask=attn_mask) var_x = layers.Add()([inputs, var_x]) var_y = var_x - var_x = layers.LayerNormalization(epsilon=1e-05, name=f"{name}.ln_2")(var_x) + var_x = layers.LayerNormalization(epsilon=1e-05, name=f"{name}_ln_2")(var_x) var_x = layers.Add()([var_y, self._mlp(var_x, key_dim, name)]) return var_x - def __call__(self, inputs: tf.Tensor) -> tf.Tensor: + def __call__(self, inputs: KerasTensor) -> KerasTensor: """ Call the Transformer layers Parameters ---------- - inputs: :class:`tf.Tensor` + inputs: :class:`keras.KerasTensor` The input Tensor Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` The return Tensor """ logger.debug("Calling %s with input: %s", self.__class__.__name__, inputs.shape) @@ -233,11 +235,11 @@ def __call__(self, inputs: tf.Tensor) -> tf.Tensor: self._width, self._heads, self._attn_mask, - name=f"{self._name}.resblocks") + name=f"{self._name}_resblocks") return var_x -class EmbeddingLayer(tf.keras.layers.Layer): +class EmbeddingLayer(layers.Layer): # pylint:disable=too-many-ancestors,abstract-method """ Parent class for trainable embedding variables Parameters @@ -261,7 +263,7 @@ def __init__(self, super().__init__(name=name, dtype=dtype, *args, **kwargs) self._input_shape = input_shape self._scale = scale - self._var: tf.Variable + self._var: KerasTensor def build(self, input_shape: tuple[int, ...]) -> None: """ Add the weights @@ -271,10 +273,9 @@ def build(self, input_shape: tuple[int, ...]) -> None: input_shape: tuple[int, ... The input shape of the incoming tensor """ - self._var = tf.Variable(self._scale * tf.random.normal(self._input_shape, - dtype=self.dtype), - trainable=True, - dtype=self.dtype) + self._var = Variable(self._scale * np.random.normal(size=self._input_shape), + trainable=True, + dtype=self.dtype) super().build(input_shape) def get_config(self) -> dict[str, T.Any]: @@ -291,61 +292,64 @@ def get_config(self) -> dict[str, T.Any]: return retval -class ClassEmbedding(EmbeddingLayer): +class ClassEmbedding(EmbeddingLayer): # pylint:disable=too-many-ancestors,abstract-method """ Trainable Class Embedding layer """ - def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + def call(self, inputs: KerasTensor, *args, **kwargs # pylint:disable=arguments-differ + ) -> KerasTensor: """ Get the Class Embedding layer Parameters ---------- - inputs: :class:`tensorflow.Tensor` + inputs: :class:`keras.KerasTensor` Input tensor to the embedding layer Returns ------- - :class:`tensorflow.Tensor` + :class:`keras.KerasTensor` The class embedding layer shaped for the input tensor """ - return K.tile(self._var[None, None], [K.shape(inputs)[0], 1, 1]) + return ops.tile(self._var[None, None], [inputs.shape[0], 1, 1]) -class PositionalEmbedding(EmbeddingLayer): +class PositionalEmbedding(EmbeddingLayer): # pylint:disable=too-many-ancestors,abstract-method """ Trainable Positional Embedding layer """ - def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + def call(self, inputs: KerasTensor, *args, **kwargs # pylint:disable=arguments-differ + ) -> KerasTensor: """ Get the Positional Embedding layer Parameters ---------- - inputs: :class:`tensorflow.Tensor` + inputs: :class:`keras.KerasTensor` Input tensor to the embedding layer Returns ------- - :class:`tensorflow.Tensor` + :class:`keras.KerasTensor` The positional embedding layer shaped for the input tensor """ - return K.tile(self._var[None], [K.shape(inputs)[0], 1, 1]) + return ops.tile(self._var[None], [inputs.shape[0], 1, 1]) -class Projection(EmbeddingLayer): +class Projection(EmbeddingLayer): # pylint:disable=too-many-ancestors,abstract-method """ Trainable Projection Embedding Layer """ - def call(self, inputs: tf.Tensor, *args, **kwargs) -> tf.Tensor: + def call(self, inputs: KerasTensor, *args, **kwargs # pylint:disable=arguments-differ + ) -> KerasTensor: """ Get the Projection layer Parameters ---------- - inputs: :class:`tensorflow.Tensor` + inputs: :class:`keras.KerasTensor` Input tensor to the embedding layer Returns ------- - :class:`tensorflow.Tensor` + :class:`keras.KerasTensor` The Projection layer expanded to the batch dimension and transposed for matmul """ - return K.tile(K.transpose(self._var)[None], [K.shape(inputs)[0], 1, 1]) + return ops.tile(ops.transpose(self._var)[None], [inputs.shape[0], 1, 1]) -class VisualTransformer(): # pylint:disable=too-few-public-methods +class VisualTransformer(): """ A class representing a Visual Transformer model for image classification tasks. Parameters @@ -367,7 +371,7 @@ class VisualTransformer(): # pylint:disable=too-few-public-methods Methods ------- - __call__() -> Model: + __call__() -> :class:`keras.models.Model`: Builds and returns the Visual Transformer model. """ def __init__(self, @@ -391,51 +395,51 @@ def __init__(self, self._name = name logger.debug("Initialized: %s", self.__class__.__name__) - def __call__(self) -> tf.keras.models.Model: + def __call__(self) -> models.Model: """ Builds and returns the Visual Transformer model. Returns ------- - Model + :class:`keras.models.Model` The Visual Transformer model. """ inputs = layers.Input([self._input_resolution, self._input_resolution, 3]) - var_x: tf.Tensor = layers.Conv2D(self._width, # shape = [*, grid, grid, width] - self._patch_size, - strides=self._patch_size, - use_bias=False, - name=f"{self._name}.conv1")(inputs) + var_x: KerasTensor = layers.Conv2D(self._width, # shape = [*, grid, grid, width] + self._patch_size, + strides=self._patch_size, + use_bias=False, + name=f"{self._name}_conv1")(inputs) var_x = layers.Reshape((-1, self._width))(var_x) # shape = [*, grid ** 2, width] class_embed = ClassEmbedding((self._width, ), self._width ** -0.5, - name=f"{self._name}.class_embedding")(var_x) + name=f"{self._name}_class_embedding")(var_x) var_x = layers.Concatenate(axis=1)([class_embed, var_x]) pos_embed = PositionalEmbedding(((self._input_resolution // self._patch_size) ** 2 + 1, self._width), self._width ** -0.5, - name=f"{self._name}.positional_embedding")(var_x) + name=f"{self._name}_positional_embedding")(var_x) var_x = layers.Add()([var_x, pos_embed]) - var_x = layers.LayerNormalization(epsilon=1e-05, name=f"{self._name}.ln_pre")(var_x) + var_x = layers.LayerNormalization(epsilon=1e-05, name=f"{self._name}_ln_pre")(var_x) var_x = Transformer(self._width, self._num_layers, self._heads, - name=f"{self._name}.transformer")(var_x) + name=f"{self._name}_transformer")(var_x) var_x = layers.LayerNormalization(epsilon=1e-05, - name=f"{self._name}.ln_post")(var_x[:, 0, :]) + name=f"{self._name}_ln_post")(var_x[:, 0, :]) proj = Projection((self._width, self._output_dim), self._width ** -0.5, - name=f"{self._name}.proj")(var_x) + name=f"{self._name}_proj")(var_x) var_x = layers.Dot(axes=-1)([var_x, proj]) - return keras.models.Model(inputs=inputs, outputs=[var_x], name=self._name) + return models.Model(inputs=inputs, outputs=var_x, name=self._name) # ################ # # MODIEFIED RESNET # # ################ # -class Bottleneck(): # pylint:disable=too-few-public-methods +class Bottleneck(): """ A ResNet bottleneck block that performs a sequence of convolutions, batch normalization, and ReLU activation operations on an input tensor. @@ -467,33 +471,33 @@ def __init__(self, self._name = name logger.debug("Initialized: %s", self.__class__.__name__) - def _downsample(self, inputs: tf.Tensor) -> tf.Tensor: + def _downsample(self, inputs: KerasTensor) -> KerasTensor: """ Perform downsample if required Parameters ---------- - inputs: :class:`tensorflow.Tensor` + inputs: :class:`keras.KerasTensor` The input the downsample Returns ------- - :class:`tensorflow.Tensor` + :class:`keras.KerasTensor` The original tensor, if downsizing not required, otherwise the downsized tensor """ if self._stride <= 1 and self._inplanes == self._planes * self.expansion: return inputs - name = f"{self._name}.downsample" - out = layers.AveragePooling2D(self._stride, name=f"{name}.avgpool")(inputs) + name = f"{self._name}_downsample" + out = layers.AveragePooling2D(self._stride, name=f"{name}_avgpool")(inputs) out = layers.Conv2D(self._planes * self.expansion, 1, strides=1, use_bias=False, - name=f"{name}.0")(out) - out = layers.BatchNormalization(name=f"{name}.1", epsilon=1e-5)(out) + name=f"{name}_0")(out) + out = layers.BatchNormalization(name=f"{name}_1", epsilon=1e-5)(out) return out - def __call__(self, inputs: tf.Tensor) -> tf.Tensor: + def __call__(self, inputs: KerasTensor) -> KerasTensor: """ Performs the forward pass for a Bottleneck block. All conv layers have stride 1. an avgpool is performed after the second convolution when @@ -501,21 +505,21 @@ def __call__(self, inputs: tf.Tensor) -> tf.Tensor: Parameters ---------- - inputs: :class:`tensorflow.Tensor` + inputs: :class:`keras.KerasTensor` The input tensor to the Bottleneck block. Returns ------- - :class:`tensorflow.Tensor` + :class:`keras.KerasTensor` The result of the forward pass through the Bottleneck block. """ - out = layers.Conv2D(self._planes, 1, use_bias=False, name=f"{self._name}.conv1")(inputs) - out = layers.BatchNormalization(name=f"{self._name}.bn1", epsilon=1e-5)(out) + out = layers.Conv2D(self._planes, 1, use_bias=False, name=f"{self._name}_conv1")(inputs) + out = layers.BatchNormalization(name=f"{self._name}_bn1", epsilon=1e-5)(out) out = layers.ReLU()(out) out = layers.ZeroPadding2D(padding=((1, 1), (1, 1)))(out) - out = layers.Conv2D(self._planes, 3, use_bias=False, name=f"{self._name}.conv2")(out) - out = layers.BatchNormalization(name=f"{self._name}.bn2", epsilon=1e-5)(out) + out = layers.Conv2D(self._planes, 3, use_bias=False, name=f"{self._name}_conv2")(out) + out = layers.BatchNormalization(name=f"{self._name}_bn2", epsilon=1e-5)(out) out = layers.ReLU()(out) if self._stride > 1: @@ -524,8 +528,8 @@ def __call__(self, inputs: tf.Tensor) -> tf.Tensor: out = layers.Conv2D(self._planes * self.expansion, 1, use_bias=False, - name=f"{self._name}.conv3")(out) - out = layers.BatchNormalization(name=f"{self._name}.bn3", epsilon=1e-5)(out) + name=f"{self._name}_conv3")(out) + out = layers.BatchNormalization(name=f"{self._name}_bn3", epsilon=1e-5)(out) identity = self._downsample(inputs) @@ -534,7 +538,7 @@ def __call__(self, inputs: tf.Tensor) -> tf.Tensor: return out -class AttentionPool2d(): # pylint:disable=too-few-public-methods +class AttentionPool2d(): """ An Attention Pooling layer that applies a multi-head self-attention mechanism over a spatial grid of features. @@ -568,39 +572,39 @@ def __init__(self, self._name = name logger.debug("Initialized: %s", self.__class__.__name__) - def __call__(self, inputs: tf.Tensor) -> tf.Tensor: + def __call__(self, inputs: KerasTensor) -> KerasTensor: """Performs the attention pooling operation on the input tensor. Parameters ---------- - inputs: :class:`tensorflow.Tensor`: + inputs: :class:`keras.KerasTensor`: The input tensor of shape [batch_size, height, width, embed_dim]. Returns ------- - :class:`tensorflow.Tensor`:: The result of the attention pooling operation + :class:`keras.KerasTensor`:: The result of the attention pooling operation """ - var_x: tf.Tensor + var_x: KerasTensor var_x = layers.Reshape((-1, inputs.shape[-1]))(inputs) # NHWC -> N(HW)C - var_x = layers.Concatenate(axis=1)([K.mean(var_x, axis=1, # N(HW)C -> N(HW+1)C - keepdims=True), var_x]) + var_x = layers.Concatenate(axis=1)([ops.mean(var_x, axis=1, # N(HW)C -> N(HW+1)C + keepdims=True), var_x]) pos_embed = PositionalEmbedding((self._spatial_dim ** 2 + 1, self._embed_dim), # N(HW+1)C self._embed_dim ** 0.5, - name=f"{self._name}.positional_embedding")(var_x) + name=f"{self._name}_positional_embedding")(var_x) var_x = layers.Add()([var_x, pos_embed]) # TODO At this point torch + keras match. They mismatch after MHA var_x = layers.MultiHeadAttention(num_heads=self._num_heads, key_dim=self._embed_dim // self._num_heads, output_shape=self._output_dim or self._embed_dim, use_bias=True, - name=f"{self._name}.mha")(var_x[:, :1, ...], + name=f"{self._name}_mha")(var_x[:, :1, ...], var_x, var_x) # only return the first element in the sequence return var_x[:, 0, ...] -class ModifiedResNet(): # pylint:disable=too-few-public-methods +class ModifiedResNet(): """ A ResNet class that is similar to torchvision's but contains the following changes: - There are now 3 "stem" convolutions as opposed to 1, with an average pool instead of a max @@ -638,19 +642,19 @@ def __init__(self, self._output_dim = output_dim self._name = name - def _stem(self, inputs: tf.Tensor) -> tf.Tensor: + def _stem(self, inputs: KerasTensor) -> KerasTensor: """ Applies the stem operation to the input tensor, which consists of 3 convolutional layers with BatchNormalization and ReLU activation, followed by an average pooling layer. Parameters ---------- - inputs: :class:`tensorflow.Tensor` + inputs: :class:`keras.KerasTensor` The input tensor Returns ------- - :class:`tensorflow.Tensor` + :class:`keras.KerasTensor` The output tensor after applying the stem operation. """ var_x = inputs @@ -669,17 +673,17 @@ def _stem(self, inputs: tf.Tensor) -> tf.Tensor: return var_x def _bottleneck(self, - inputs: tf.Tensor, + inputs: KerasTensor, planes: int, blocks: int, stride: int = 1, - name: str = "layer") -> tf.Tensor: + name: str = "layer") -> KerasTensor: """ A private method that creates a sequential layer of Bottleneck blocks for the ModifiedResNet model. Parameters ---------- - inputs: :class:`tensorflow.Tensor` + inputs: :class:`keras.KerasTensor` The input tensor planes: int The number of output channels for the layer. @@ -692,23 +696,23 @@ def _bottleneck(self, Returns ------- - :class:`tensorflow.Tensor` + :class:`keras.KerasTensor` Sequential block of bottlenecks """ - retval: tf.Tensor - retval = Bottleneck(planes, planes, stride, name=f"{name}.0")(inputs) + retval: KerasTensor + retval = Bottleneck(planes, planes, stride, name=f"{name}_0")(inputs) for i in range(1, blocks): retval = Bottleneck(planes * Bottleneck.expansion, planes, - name=f"{name}.{i}")(retval) + name=f"{name}_{i}")(retval) return retval - def __call__(self) -> tf.keras.models.Model: + def __call__(self) -> models.Model: """ Implements the forward pass of the ModifiedResNet model. Returns ------- - :class:`tensorflow.keras.models.Model` + :class:`keras.models.Model` The modified resnet model. """ inputs = layers.Input((self._input_resolution, self._input_resolution, 3)) @@ -720,20 +724,20 @@ def __call__(self) -> tf.keras.models.Model: self._width * (2 ** i), self._layer_config[i], stride=stride, - name=f"{self._name}.layer{i + 1}") + name=f"{self._name}_layer{i + 1}") var_x = AttentionPool2d(self._input_resolution // 32, self._width * 32, # the ResNet feature dimension self._heads, self._output_dim, - name=f"{self._name}.attnpool")(var_x) - return keras.models.Model(inputs, outputs=[var_x], name=self._name) + name=f"{self._name}_attnpool")(var_x) + return models.Model(inputs, outputs=var_x, name=self._name) # ### # # VIT # # ### # -class ViT(): # pylint:disable=too-few-public-methods +class ViT(): """ Visiual Transform from CLIP A Convolutional Language-Image Pre-Training (CLIP) model that encodes images and text into a @@ -759,12 +763,12 @@ def __init__(self, load_weights: bool = False) -> None: logger.debug("Initializing: %s (name: %s, input_size: %s, load_weights: %s)", self.__class__.__name__, name, input_size, load_weights) - assert name in ModelConfig, ("Name must be one of %s", list(ModelConfig)) + assert name in MODEL_CONFIG, ("Name must be one of %s", list(MODEL_CONFIG)) self._name = name self._load_weights = load_weights - config = ModelConfig[name] + config = MODEL_CONFIG[name] self._git_id = config.git_id res = input_size if input_size is not None else config.resolution @@ -780,7 +784,7 @@ def _get_vision_net(self, width: int, embed_dim: int, resolution: int, - patch_size: int) -> tf.keras.models.Model: + patch_size: int) -> models.Model: """ Obtain the network for the vision layets Parameters @@ -799,7 +803,7 @@ def _get_vision_net(self, Returns ------- - :class:`tensorflow.keras.models.Model` + :class:`keras.models.Model` The :class:`ModifiedResNet` or :class:`VisualTransformer` vision model to use """ if isinstance(layer_config, (tuple, list)): @@ -819,28 +823,37 @@ def _get_vision_net(self, patch_size=patch_size, name="visual") - def __call__(self) -> tf.keras.Model: + def __call__(self) -> models.Model: """ Get the configured ViT model Returns ------- - :class:`tensorflow.keras.models.Model` + :class:`keras.models.Model` The requested Visual Transformer model """ - net: tf.keras.models.Model = self._net() + net: models.Model = self._net() if self._load_weights and not self._git_id: logger.warning("Trained weights are not available for '%s'", self._name) return net if self._load_weights: model_path = GetModel(f"CLIPv_{self._name}_v1.h5", self._git_id).model_path logger.info("Loading CLIPv trained weights for '%s'", self._name) - net.load_weights(model_path, by_name=True, skip_mismatch=True) + with warnings.catch_warnings(): + # TODO There is a potential bug in keras load_weights_by_name that tries to load + # top_level_weights where they don't exist. This always generates a scary looking + # warning, so it supressed for now + warnings.simplefilter("ignore") + # NOTE: Don't load by name as we had to replace local dots with underscores + net.load_weights(model_path, by_name=False, skip_mismatch=True) return net # Update layers into Keras custom objects for name_, obj in inspect.getmembers(sys.modules[__name__]): - if (inspect.isclass(obj) and issubclass(obj, tf.keras.layers.Layer) + if (inspect.isclass(obj) and issubclass(obj, layers.Layer) and obj.__module__ == __name__): - keras.utils.get_custom_objects().update({name_: obj}) + saving.get_custom_objects().update({name_: obj}) + + +__all__ = get_module_objects(__name__) diff --git a/lib/model/networks/simple_nets.py b/lib/model/networks/simple_nets.py index 727161bcd8..a33e0337a3 100644 --- a/lib/model/networks/simple_nets.py +++ b/lib/model/networks/simple_nets.py @@ -4,16 +4,14 @@ import logging import typing as T -import tensorflow as tf +from keras import layers +from keras.models import Model -# Fix intellisense/linting for tf.keras' thoroughly broken import system -keras = tf.keras -layers = keras.layers -Model = keras.models.Model +from lib.logger import parse_class_init +from lib.utils import get_module_objects if T.TYPE_CHECKING: - from tensorflow import Tensor - + from keras import KerasTensor logger = logging.getLogger(__name__) @@ -32,7 +30,7 @@ class _net(): # pylint:disable=too-few-public-methods """ def __init__(self, input_shape: tuple[int, int, int] | None = None) -> None: - logger.debug("Initializing: %s (input_shape: %s)", self.__class__.__name__, input_shape) + logger.debug(parse_class_init(locals())) self._input_shape = (None, None, 3) if input_shape is None else input_shape assert len(self._input_shape) == 3 and self._input_shape[-1] == 3, ( "Input shape must be in the format (height, width, channels) and the number of " @@ -63,19 +61,19 @@ def __init__(self, input_shape: tuple[int, int, int] | None = None) -> None: @classmethod def _conv_block(cls, - inputs: Tensor, + inputs: KerasTensor, padding: int, filters: int, kernel_size: int, strides: int, block_idx: int, - max_pool: bool) -> Tensor: + max_pool: bool) -> KerasTensor: """ The Convolutional block for AlexNet Parameters ---------- - inputs: :class:`tf.Tensor` + inputs: :class:`keras.KerasTensor` The input tensor to the block padding: int The amount of zero paddin to apply prior to convolution @@ -92,14 +90,14 @@ def _conv_block(cls, Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` The output of the Convolutional block """ - name = f"features.{block_idx}" + name = f"features_{block_idx}" var_x = inputs if max_pool: - var_x = layers.MaxPool2D(pool_size=3, strides=2, name=f"{name}.pool")(var_x) - var_x = layers.ZeroPadding2D(padding=padding, name=f"{name}.pad")(var_x) + var_x = layers.MaxPooling2D(pool_size=3, strides=2, name=f"{name}_pool")(var_x) + var_x = layers.ZeroPadding2D(padding=padding, name=f"{name}_pad")(var_x) var_x = layers.Conv2D(filters, kernel_size=kernel_size, strides=strides, @@ -108,7 +106,7 @@ def _conv_block(cls, name=name)(var_x) return var_x - def __call__(self) -> tf.keras.models.Model: + def __call__(self) -> Model: """ Create the AlexNet Model Returns @@ -117,7 +115,7 @@ def __call__(self) -> tf.keras.models.Model: The compiled AlexNet model """ inputs = layers.Input(self._input_shape) - var_x = inputs + var_x = T.cast("KerasTensor", inputs) kernel_size = 11 strides = 4 @@ -155,15 +153,15 @@ class SqueezeNet(_net): @classmethod def _fire(cls, - inputs: Tensor, + inputs: KerasTensor, squeeze_planes: int, expand_planes: int, - block_idx: int) -> Tensor: + block_idx: int) -> KerasTensor: """ The fire block for SqueezeNet. Parameters ---------- - inputs: :class:`tf.Tensor` + inputs: :class:`keras.KerasTensor` The input to the fire block squeeze_planes: int The number of filters for the squeeze convolution @@ -174,22 +172,22 @@ def _fire(cls, Returns ------- - :class:`tf.Tensor` + :class:`keras.KerasTensor` The output of the SqueezeNet fire block """ - name = f"features.{block_idx}" + name = f"features_{block_idx}" squeezed = layers.Conv2D(squeeze_planes, 1, - activation="relu", name=f"{name}.squeeze")(inputs) + activation="relu", name=f"{name}_squeeze")(inputs) expand1 = layers.Conv2D(expand_planes, 1, - activation="relu", name=f"{name}.expand1x1")(squeezed) + activation="relu", name=f"{name}_expand1x1")(squeezed) expand3 = layers.Conv2D(expand_planes, 3, activation="relu", padding="same", - name=f"{name}.expand3x3")(squeezed) + name=f"{name}_expand3x3")(squeezed) return layers.Concatenate(axis=-1, name=name)([expand1, expand3]) - def __call__(self) -> tf.keras.models.Model: + def __call__(self) -> Model: """ Create the SqueezeNet Model Returns @@ -198,14 +196,14 @@ def __call__(self) -> tf.keras.models.Model: The compiled SqueezeNet model """ inputs = layers.Input(self._input_shape) - var_x = layers.Conv2D(64, 3, strides=2, activation="relu", name="features.0")(inputs) + var_x = layers.Conv2D(64, 3, strides=2, activation="relu", name="features_0")(inputs) block_idx = 2 squeeze = 16 expand = 64 for idx in range(4): if idx < 3: - var_x = layers.MaxPool2D(pool_size=3, strides=2)(var_x) + var_x = layers.MaxPooling2D(pool_size=3, strides=2)(var_x) block_idx += 1 var_x = self._fire(var_x, squeeze, expand, block_idx) block_idx += 1 @@ -214,3 +212,6 @@ def __call__(self) -> tf.keras.models.Model: squeeze += 16 expand += 64 return Model(inputs=inputs, outputs=[var_x]) + + +__all__ = get_module_objects(__name__) diff --git a/lib/model/nn_blocks.py b/lib/model/nn_blocks.py index 63e431d33e..b70a64b8c7 100644 --- a/lib/model/nn_blocks.py +++ b/lib/model/nn_blocks.py @@ -4,43 +4,23 @@ import logging import typing as T -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras.layers import ( # pylint:disable=import-error - Activation, Add, BatchNormalization, Concatenate, Conv2D as KConv2D, Conv2DTranspose, - DepthwiseConv2D as KDepthwiseConv2d, LeakyReLU, PReLU, SeparableConv2D, UpSampling2D) -from tensorflow.keras.initializers import he_uniform, VarianceScaling # noqa:E501 # pylint:disable=import-error +from keras import initializers, layers + +from lib.logger import parse_class_init +from lib.utils import get_module_objects +from plugins.train import train_config as cfg from .initializers import ICNR, ConvolutionAware from .layers import PixelShuffler, ReflectionPadding2D, Swish, KResizeImages from .normalization import InstanceNormalization if T.TYPE_CHECKING: - from tensorflow import keras - from tensorflow import Tensor - + from keras import KerasTensor logger = logging.getLogger(__name__) -_CONFIG: dict = {} -_NAMES: dict[str, int] = {} - - -def set_config(configuration: dict) -> None: - """ Set the global configuration parameters from the user's config file. - - These options are used when creating layers for new models. - - Parameters - ---------- - configuration: dict - The configuration options that exist in the training configuration files that pertain - specifically to Custom Faceswap Layers. The keys should be: `icnr_init`, `conv_aware_init` - and 'reflect_padding' - """ - global _CONFIG # pylint:disable=global-statement - _CONFIG = configuration - logger.debug("Set NNBlock configuration to: %s", _CONFIG) +_names: dict[str, int] = {} def _get_name(name: str) -> str: @@ -59,24 +39,33 @@ def _get_name(name: str) -> str: str The unique name for this layer """ - global _NAMES # pylint:disable=global-statement,global-variable-not-assigned - _NAMES[name] = _NAMES.setdefault(name, -1) + 1 - name = f"{name}_{_NAMES[name]}" + _names[name] = _names.setdefault(name, -1) + 1 + name = f"{name}_{_names[name]}" logger.debug("Generating block name: %s", name) return name +def reset_naming() -> None: + """ Reset the naming convention for nn_block layers to start from 0 + + Used when a model needs to be rebuilt and the names for each build should be identical + """ + logger.debug("Resetting nn_block layer naming") + global _names # pylint:disable=global-statement + _names = {} + + # << CONVOLUTIONS >> def _get_default_initializer( - initializer: keras.initializers.Initializer) -> keras.initializers.Initializer: - """ Returns a default initializer of Convolutional Aware or he_uniform for convolutional + initializer: initializers.Initializer) -> initializers.Initializer: + """ Returns a default initializer of Convolutional Aware or HeUniform for convolutional layers. Parameters ---------- initializer: :class:`keras.initializers.Initializer` or None The initializer that has been passed into the model. If this value is ``None`` then a - default initializer will be set to 'he_uniform'. If Convolutional Aware initialization + default initializer will be set to 'HeUniform'. If Convolutional Aware initialization has been enabled, then any passed through initializer will be replaced with the Convolutional Aware initializer. @@ -84,12 +73,16 @@ def _get_default_initializer( ------- :class:`keras.initializers.Initializer` The kernel initializer to use for this convolutional layer. Either the original given - initializer, he_uniform or convolutional aware (if selected in config options) + initializer, HeUniform or convolutional aware (if selected in config options) """ - if _CONFIG["conv_aware_init"]: + if isinstance(initializer, dict) and initializer.get("class_name", "") == "ConvolutionAware": + logger.debug("Returning serialized initialized ConvAware initializer: %s", initializer) + return initializer + + if cfg.conv_aware_init(): retval = ConvolutionAware() elif initializer is None: - retval = he_uniform() + retval = initializers.HeUniform() else: retval = initializer logger.debug("Using model supplied initializer: %s", retval) @@ -98,12 +91,12 @@ def _get_default_initializer( return retval -class Conv2D(KConv2D): # pylint:disable=too-few-public-methods, too-many-ancestors +class Conv2D(): # pylint:disable=too-many-ancestors,abstract-method """ A standard Keras Convolution 2D layer with parameters updated to be more appropriate for Faceswap architecture. Parameters are the same, with the same defaults, as a standard :class:`keras.layers.Conv2D` - except where listed below. The default initializer is updated to `he_uniform` or `convolutional + except where listed below. The default initializer is updated to `HeUniform` or `convolutional aware` based on user configuration settings. Parameters @@ -119,23 +112,45 @@ class Conv2D(KConv2D): # pylint:disable=too-few-public-methods, too-many-ancest layers. Default: ``False`` """ def __init__(self, *args, padding: str = "same", is_upscale: bool = False, **kwargs) -> None: + logger.debug(parse_class_init(locals())) if kwargs.get("name", None) is None: filters = kwargs["filters"] if "filters" in kwargs else args[0] kwargs["name"] = _get_name(f"conv2d_{filters}") initializer = _get_default_initializer(kwargs.pop("kernel_initializer", None)) - if is_upscale and _CONFIG["icnr_init"]: + if is_upscale and cfg.icnr_init(): initializer = ICNR(initializer=initializer) logger.debug("Using ICNR Initializer: %s", initializer) - super().__init__(*args, padding=padding, kernel_initializer=initializer, **kwargs) + self._conv2d = layers.Conv2D( + *args, + padding=padding, + kernel_initializer=initializer, # pyright:ignore[reportArgumentType] + **kwargs) + logger.debug("Initialized %s", self.__class__.__name__) + def __call__(self, *args, **kwargs) -> KerasTensor: + """ Call the Conv2D layer -class DepthwiseConv2D(KDepthwiseConv2d): # noqa,pylint:disable=too-few-public-methods, too-many-ancestors + Parameters + ---------- + args : tuple + Standard Conv2D layer call arguments + kwargs : dict[str, Any] + Standard Conv2D layer call keyword arguments + + Returns + ------- + :class: `keras.KerasTensor` + The Tensor from the Conv2D layer + """ + return self._conv2d(*args, **kwargs) + +class DepthwiseConv2D(): # noqa,pylint:disable=too-many-ancestors,abstract-method """ A standard Keras Depthwise Convolution 2D layer with parameters updated to be more appropriate for Faceswap architecture. Parameters are the same, with the same defaults, as a standard :class:`keras.layers.DepthwiseConv2D` except where listed below. The default initializer is - updated to `he_uniform` or `convolutional aware` based on user configuration settings. + updated to `HeUniform` or `convolutional aware` based on user configuration settings. Parameters ---------- @@ -150,16 +165,39 @@ class DepthwiseConv2D(KDepthwiseConv2d): # noqa,pylint:disable=too-few-public-m layers. Default: ``False`` """ def __init__(self, *args, padding: str = "same", is_upscale: bool = False, **kwargs) -> None: + logger.debug(parse_class_init(locals())) if kwargs.get("name", None) is None: kwargs["name"] = _get_name("dwconv2d") initializer = _get_default_initializer(kwargs.pop("depthwise_initializer", None)) - if is_upscale and _CONFIG["icnr_init"]: + if is_upscale and cfg.icnr_init(): initializer = ICNR(initializer=initializer) logger.debug("Using ICNR Initializer: %s", initializer) - super().__init__(*args, padding=padding, depthwise_initializer=initializer, **kwargs) + self._deptwiseconv2d = layers.DepthwiseConv2D( + *args, + padding=padding, + depthwise_initializer=initializer, # pyright:ignore[reportArgumentType] + **kwargs) + logger.debug("Initialized %s", self.__class__.__name__) + + def __call__(self, *args, **kwargs) -> KerasTensor: + """ Call the DepthwiseConv2D layer + + Parameters + ---------- + args : tuple + Standard DepthwiseConv2D layer call arguments + kwargs : dict[str, Any] + Standard DepthwiseConv2D layer call keyword arguments + + Returns + ------- + :class: `keras.KerasTensor` + The Tensor from the DepthwiseConv2D layer + """ + return self._deptwiseconv2d(*args, **kwargs) -class Conv2DOutput(): # pylint:disable=too-few-public-methods +class Conv2DOutput(): """ A Convolution 2D layer that separates out the activation layer to explicitly set the data type on the activation to float 32 to fully support mixed precision training. @@ -167,7 +205,7 @@ class Conv2DOutput(): # pylint:disable=too-few-public-methods architecture. Parameters are the same, with the same defaults, as a standard :class:`keras.layers.Conv2D` - except where listed below. The default initializer is updated to he_uniform or convolutional + except where listed below. The default initializer is updated to HeUniform or convolutional aware based on user config settings. Parameters @@ -192,37 +230,35 @@ def __init__(self, kernel_size: int | tuple[int], activation: str = "sigmoid", padding: str = "same", **kwargs) -> None: - self._name = _get_name(kwargs.pop("name")) if "name" in kwargs else _get_name( - f"conv_output_{filters}") - self._filters = filters - self._kernel_size = kernel_size - self._activation = activation - self._padding = padding - self._kwargs = kwargs - - def __call__(self, inputs: Tensor) -> Tensor: + logger.debug(parse_class_init(locals())) + name = _get_name(kwargs.pop("name")) if "name" in kwargs else _get_name( + f"conv_output_{filters}") + self._conv = Conv2D(filters, + kernel_size, + padding=padding, + name=f"{name}_conv2d", + **kwargs) + self._activation = layers.Activation(activation, dtype="float32", name=name) + logger.debug("Initialized %s", self.__class__.__name__) + + def __call__(self, inputs: KerasTensor) -> KerasTensor: """ Call the Faceswap Convolutional Output Layer. Parameters ---------- - inputs: Tensor + inputs: :class:`keras.KerasTensor` The input to the layer Returns ------- - Tensor + :class:`keras.KerasTensor` The output tensor from the Convolution 2D Layer """ - var_x = Conv2D(self._filters, - self._kernel_size, - padding=self._padding, - name=f"{self._name}_conv2d", - **self._kwargs)(inputs) - var_x = Activation(self._activation, dtype="float32", name=self._name)(var_x) - return var_x + var_x = self._conv(inputs) + return self._activation(var_x) -class Conv2DBlock(): # pylint:disable=too-few-public-methods +class Conv2DBlock(): # pylint:disable=too-many-instance-attributes """ A standard Convolution 2D layer which applies user specified configuration to the layer. @@ -273,14 +309,10 @@ def __init__(self, use_depthwise: bool = False, relu_alpha: float = 0.1, **kwargs) -> None: - self._name = kwargs.pop("name") if "name" in kwargs else _get_name(f"conv_{filters}") + logger.debug(parse_class_init(locals())) - logger.debug("name: %s, filters: %s, kernel_size: %s, strides: %s, padding: %s, " - "normalization: %s, activation: %s, use_depthwise: %s, kwargs: %s)", - self._name, filters, kernel_size, strides, padding, normalization, - activation, use_depthwise, kwargs) - - self._use_reflect_padding = _CONFIG["reflect_padding"] + self._name = kwargs.pop("name") if "name" in kwargs else _get_name(f"conv_{filters}") + self._use_reflect_padding = cfg.reflect_padding() kernel_size = (kernel_size, kernel_size) if isinstance(kernel_size, int) else kernel_size self._args = (kernel_size, ) if use_depthwise else (filters, kernel_size) @@ -293,6 +325,8 @@ def __init__(self, self._relu_alpha = relu_alpha self._assert_arguments() + self._layers = self._get_layers() + logger.debug("Initialized %s", self.__class__.__name__) def _assert_arguments(self) -> None: """ Validate the given arguments. """ @@ -301,47 +335,68 @@ def _assert_arguments(self) -> None: assert self._activation in ("leakyrelu", "swish", "prelu", None), ( "activation should be 'leakyrelu', 'prelu', 'swish' or None") - def __call__(self, inputs: Tensor) -> Tensor: - """ Call the Faceswap Convolutional Layer. - - Parameters - ---------- - inputs: Tensor - The input to the layer + def _get_layers(self) -> list[layers.Layer]: + """ Obtain the layer chain for the block Returns ------- - Tensor - The output tensor from the Convolution 2D Layer + list[:class:`keras.layers.Layer] + The layers, in the correct order, to pass the tensor through """ + retval = [] if self._use_reflect_padding: - inputs = ReflectionPadding2D(stride=self._strides[0], - kernel_size=self._args[-1][0], # type:ignore[index] - name=f"{self._name}_reflectionpadding2d")(inputs) - conv: keras.layers.Layer = DepthwiseConv2D if self._use_depthwise else Conv2D - var_x = conv(*self._args, - strides=self._strides, - padding=self._padding, - name=f"{self._name}_{'dw' if self._use_depthwise else ''}conv2d", - **self._kwargs)(inputs) + retval.append(ReflectionPadding2D(stride=self._strides[0], + kernel_size=self._args[-1][0], # type:ignore[index] + name=f"{self._name}_reflectionpadding2d")) + + conv: layers.Layer = ( + DepthwiseConv2D if self._use_depthwise + else Conv2D) # pyright:ignore[reportAssignmentType] + + retval.append(conv(*self._args, + strides=self._strides, + padding=self._padding, + name=f"{self._name}_{'dw' if self._use_depthwise else ''}conv2d", + **self._kwargs)) + # normalization if self._normalization == "instance": - var_x = InstanceNormalization(name=f"{self._name}_instancenorm")(var_x) + retval.append(InstanceNormalization(name=f"{self._name}_instancenorm")) + if self._normalization == "batch": - var_x = BatchNormalization(axis=3, name=f"{self._name}_batchnorm")(var_x) + retval.append(layers.BatchNormalization(axis=3, name=f"{self._name}_batchnorm")) # activation if self._activation == "leakyrelu": - var_x = LeakyReLU(self._relu_alpha, name=f"{self._name}_leakyrelu")(var_x) + retval.append(layers.LeakyReLU(self._relu_alpha, name=f"{self._name}_leakyrelu")) if self._activation == "swish": - var_x = Swish(name=f"{self._name}_swish")(var_x) + retval.append(Swish(name=f"{self._name}_swish")) if self._activation == "prelu": - var_x = PReLU(name=f"{self._name}_prelu")(var_x) + retval.append(layers.PReLU(name=f"{self._name}_prelu")) + + logger.debug("%s layers: %s", self.__class__.__name__, retval) + return retval + + def __call__(self, inputs: KerasTensor) -> KerasTensor: + """ Call the Faceswap Convolutional Layer. + + Parameters + ---------- + inputs: :class:`keras.KerasTensor` + The input to the layer + Returns + ------- + :class:`keras.KerasTensor` + The output tensor from the Convolution 2D Layer + """ + var_x = inputs + for layer in self._layers: + var_x = layer(var_x) return var_x -class SeparableConv2DBlock(): # pylint:disable=too-few-public-methods +class SeparableConv2DBlock(): """ Seperable Convolution Block. Parameters @@ -365,44 +420,43 @@ def __init__(self, filters: int, kernel_size: int | tuple[int, int] = 5, strides: int | tuple[int, int] = 2, **kwargs) -> None: - self._name = _get_name(f"separableconv2d_{filters}") - logger.debug("name: %s, filters: %s, kernel_size: %s, strides: %s, kwargs: %s)", - self._name, filters, kernel_size, strides, kwargs) - - self._filters = filters - self._kernel_size = kernel_size - self._strides = strides + logger.debug(parse_class_init(locals())) initializer = _get_default_initializer(kwargs.pop("kernel_initializer", None)) - kwargs["kernel_initializer"] = initializer - self._kwargs = kwargs - def __call__(self, inputs: Tensor) -> Tensor: + name = _get_name(f"separableconv2d_{filters}") + self._conv = layers.SeparableConv2D( + filters, + kernel_size=kernel_size, + strides=strides, + padding="same", + depthwise_initializer=initializer, # pyright:ignore[reportArgumentType] + pointwise_initializer=initializer, # pyright:ignore[reportArgumentType] + name=f"{name}_seperableconv2d", + **kwargs) + self._activation = layers.Activation("relu", name=f"{name}_relu") + logger.debug("Initialized %s", self.__class__.__name__) + + def __call__(self, inputs: KerasTensor) -> KerasTensor: """ Call the Faceswap Separable Convolutional 2D Block. Parameters ---------- - inputs: Tensor + inputs: :class:`keras.KerasTensor` The input to the layer Returns ------- - Tensor + :class:`keras.KerasTensor` The output tensor from the Upscale Layer """ - var_x = SeparableConv2D(self._filters, - kernel_size=self._kernel_size, - strides=self._strides, - padding="same", - name=f"{self._name}_seperableconv2d", - **self._kwargs)(inputs) - var_x = Activation("relu", name=f"{self._name}_relu")(var_x) - return var_x + var_x = self._conv(inputs) + return self._activation(var_x) # << UPSCALING >> -class UpscaleBlock(): # pylint:disable=too-few-public-methods +class UpscaleBlock(): """ An upscale layer for sub-pixel up-scaling. Adds reflection padding if it has been selected by the user, and other post-processing @@ -441,48 +495,38 @@ def __init__(self, normalization: str | None = None, activation: str | None = "leakyrelu", **kwargs) -> None: - self._name = _get_name(f"upscale_{filters}") - logger.debug("name: %s. filters: %s, kernel_size: %s, padding: %s, scale_factor: %s, " - "normalization: %s, activation: %s, kwargs: %s)", - self._name, filters, kernel_size, padding, scale_factor, normalization, - activation, kwargs) - - self._filters = filters - self._kernel_size = kernel_size - self._padding = padding - self._scale_factor = scale_factor - self._normalization = normalization - self._activation = activation - self._kwargs = kwargs - - def __call__(self, inputs: Tensor) -> Tensor: + logger.debug(parse_class_init(locals())) + name = _get_name(f"upscale_{filters}") + self._conv = Conv2DBlock(filters * scale_factor * scale_factor, + kernel_size, + strides=(1, 1), + padding=padding, + normalization=normalization, + activation=activation, + name=f"{name}_conv2d", + is_upscale=True, + **kwargs) + self._shuffle = PixelShuffler(name=f"{name}_pixelshuffler", size=scale_factor) + logger.debug("Initialized %s", self.__class__.__name__) + + def __call__(self, inputs: KerasTensor) -> KerasTensor: """ Call the Faceswap Convolutional Layer. Parameters ---------- - inputs: Tensor + inputs: :class:`keras.KerasTensor` The input to the layer Returns ------- - Tensor + :class:`keras.KerasTensor` The output tensor from the Upscale Layer """ - var_x = Conv2DBlock(self._filters * self._scale_factor * self._scale_factor, - self._kernel_size, - strides=(1, 1), - padding=self._padding, - normalization=self._normalization, - activation=self._activation, - name=f"{self._name}_conv2d", - is_upscale=True, - **self._kwargs)(inputs) - var_x = PixelShuffler(name=f"{self._name}_pixelshuffler", - size=self._scale_factor)(var_x) - return var_x + var_x = self._conv(inputs) + return self._shuffle(var_x) -class Upscale2xBlock(): # pylint:disable=too-few-public-methods +class Upscale2xBlock(): """ Custom hybrid upscale layer for sub-pixel up-scaling. Most of up-scaling is approximating lighting gradients which can be accurately achieved @@ -520,6 +564,7 @@ class Upscale2xBlock(): # pylint:disable=too-few-public-methods kwargs: dict Any additional Keras standard layer keyword arguments to pass to the Convolutional 2D layer """ + # TODO Class function this def __init__(self, filters: int, kernel_size: int | tuple[int, int] = 3, @@ -529,63 +574,72 @@ def __init__(self, sr_ratio: float = 0.5, scale_factor: int = 2, fast: bool = False, **kwargs) -> None: - self._name = _get_name(f"upscale2x_{filters}_{'fast' if fast else 'hyb'}") + logger.debug(parse_class_init(locals())) self._fast = fast - self._filters = filters if self._fast else filters - int(filters * sr_ratio) - self._kernel_size = kernel_size - self._padding = padding - self._interpolation = interpolation - self._activation = activation - self._scale_factor = scale_factor - self._kwargs = kwargs + self._filters = filters if fast else filters - int(filters * sr_ratio) + + name = _get_name(f"upscale2x_{filters}_{'fast' if fast else 'hyb'}") + + self._upscale = UpscaleBlock(self._filters, + kernel_size=kernel_size, + padding=padding, + scale_factor=scale_factor, + activation=activation, + **kwargs) + + if self._fast or (not self._fast and self._filters > 0): + self._conv = Conv2D(self._filters, + 3, + padding=padding, + is_upscale=True, + name=f"{name}_conv2d", + **kwargs) + self._upsample = layers.UpSampling2D(size=(scale_factor, scale_factor), + interpolation=interpolation, + name=f"{name}_upsampling2D") - def __call__(self, inputs: Tensor) -> Tensor: + self._joiner = layers.Add() if self._fast else layers.Concatenate( + name=f"{name}_concatenate") + + logger.debug("Initialized %s", self.__class__.__name__) + + def __call__(self, inputs: KerasTensor) -> KerasTensor: """ Call the Faceswap Upscale 2x Layer. Parameters ---------- - inputs: Tensor + inputs: :class:`keras.KerasTensor` The input to the layer Returns ------- - Tensor + :class:`keras.KerasTensor` The output tensor from the Upscale Layer """ var_x = inputs + var_x_sr = None if not self._fast: - var_x_sr = UpscaleBlock(self._filters, - kernel_size=self._kernel_size, - padding=self._padding, - scale_factor=self._scale_factor, - activation=self._activation, - **self._kwargs)(var_x) + var_x_sr = self._upscale(var_x) if self._fast or (not self._fast and self._filters > 0): - var_x2 = Conv2D(self._filters, 3, - padding=self._padding, - is_upscale=True, - name=f"{self._name}_conv2d", - **self._kwargs)(var_x) - var_x2 = UpSampling2D(size=(self._scale_factor, self._scale_factor), - interpolation=self._interpolation, - name=f"{self._name}_upsampling2D")(var_x2) + + var_x2 = self._conv(var_x) + var_x2 = self._upsample(var_x2) + if self._fast: - var_x1 = UpscaleBlock(self._filters, - kernel_size=self._kernel_size, - padding=self._padding, - scale_factor=self._scale_factor, - activation=self._activation, - **self._kwargs)(var_x) - var_x = Add()([var_x2, var_x1]) + var_x1 = self._upscale(var_x) + var_x = self._joiner([var_x2, var_x1]) else: - var_x = Concatenate(name=f"{self._name}_concatenate")([var_x_sr, var_x2]) + var_x = self._joiner([var_x_sr, var_x2]) + else: + assert var_x_sr is not None var_x = var_x_sr + return var_x -class UpscaleResizeImagesBlock(): # pylint:disable=too-few-public-methods +class UpscaleResizeImagesBlock(): """ Upscale block that uses the Keras Backend function resize_images to perform the up scaling Similar in methodology to the :class:`Upscale2xBlock` @@ -621,53 +675,59 @@ def __init__(self, activation: str | None = "leakyrelu", scale_factor: int = 2, interpolation: T.Literal["nearest", "bilinear"] = "bilinear") -> None: - self._name = _get_name(f"upscale_ri_{filters}") - self._interpolation = interpolation - self._size = scale_factor - self._filters = filters - self._kernel_size = kernel_size - self._padding = padding - self._activation = activation - - def __call__(self, inputs: Tensor) -> Tensor: + logger.debug(parse_class_init(locals())) + name = _get_name(f"upscale_ri_{filters}") + + self._resize = KResizeImages(size=scale_factor, + interpolation=interpolation, + name=f"{name}_resize") + self._conv = Conv2D(filters, + kernel_size, + strides=1, + padding=padding, + is_upscale=True, + name=f"{name}_conv") + self._conv_trans = layers.Conv2DTranspose(filters, + 3, + strides=2, + padding=padding, + name=f"{name}_convtrans") + self._add = layers.Add() + + if activation == "leakyrelu": + self._acivation = layers.LeakyReLU(0.2, name=f"{name}_leakyrelu") + if activation == "swish": + self._acivation = Swish(name=f"{name}_swish") + if activation == "prelu": + self._acivation = layers.PReLU(name=f"{name}_prelu") + logger.debug("Initialized %s", self.__class__.__name__) + + def __call__(self, inputs: KerasTensor) -> KerasTensor: """ Call the Faceswap Resize Images Layer. Parameters ---------- - inputs: Tensor + inputs: :class:`keras.KerasTensor` The input to the layer Returns ------- - Tensor + :class:`keras.KerasTensor` The output tensor from the Upscale Layer """ var_x = inputs - var_x_sr = KResizeImages(size=self._size, - interpolation=self._interpolation, - name=f"{self._name}_resize")(var_x) - var_x_sr = Conv2D(self._filters, self._kernel_size, - strides=1, - padding=self._padding, - is_upscale=True, - name=f"{self._name}_conv")(var_x_sr) - var_x_us = Conv2DTranspose(self._filters, 3, - strides=2, - padding=self._padding, - name=f"{self._name}_convtrans")(var_x) - var_x = Add()([var_x_sr, var_x_us]) + var_x_sr = self._resize(var_x) + var_x_sr = self._conv(var_x_sr) - if self._activation == "leakyrelu": - var_x = LeakyReLU(0.2, name=f"{self._name}_leakyrelu")(var_x) - if self._activation == "swish": - var_x = Swish(name=f"{self._name}_swish")(var_x) - if self._activation == "prelu": - var_x = PReLU(name=f"{self._name}_prelu")(var_x) - return var_x + var_x_us = self._conv_trans(var_x) + var_x = self._add([var_x_sr, var_x_us]) -class UpscaleDNYBlock(): # pylint:disable=too-few-public-methods + return self._acivation(var_x) + + +class UpscaleDNYBlock(): """ Upscale block that implements methodology similar to the Disney Research Paper using an upsampling2D block and 2 x convolutions @@ -707,34 +767,44 @@ def __init__(self, size: int = 2, interpolation: str = "bilinear", **kwargs) -> None: - self._name = _get_name(f"upscale_dny_{filters}") - self._interpolation = interpolation - self._size = size - self._filters = filters - self._kernel_size = kernel_size - self._padding = padding - self._activation = activation - self._kwargs = kwargs + logger.debug(parse_class_init(locals())) + name = _get_name(f"upscale_dny_{filters}") + self._upsample = layers.UpSampling2D(size=size, + interpolation=interpolation, + name=f"{name}_upsample2d") + self._convs = [Conv2DBlock(filters, + kernel_size, + strides=1, + padding=padding, + activation=activation, + relu_alpha=0.2, + name=f"{name}_conv2d_{idx + 1}", + is_upscale=True, + **kwargs) + for idx in range(2)] + logger.debug("Initialized %s", self.__class__.__name__) + + def __call__(self, inputs: KerasTensor) -> KerasTensor: + """ Call the UpscaleDNY block - def __call__(self, inputs: Tensor) -> Tensor: - var_x = UpSampling2D(size=self._size, - interpolation=self._interpolation, - name=f"{self._name}_upsample2d")(inputs) - for idx in range(2): - var_x = Conv2DBlock(self._filters, - self._kernel_size, - strides=1, - padding=self._padding, - activation=self._activation, - relu_alpha=0.2, - name=f"{self._name}_conv2d_{idx + 1}", - is_upscale=True, - **self._kwargs)(var_x) + Parameters + ---------- + inputs: :class:`keras.KerasTensor` + The input to the block + + Returns + ------- + :class:`keras.KerasTensor` + The output from the block + """ + var_x = self._upsample(inputs) + for conv in (self._convs): + var_x = conv(var_x) return var_x # << OTHER BLOCKS >> -class ResidualBlock(): # pylint:disable=too-few-public-methods +class ResidualBlock(): """ Residual block from dfaker. Parameters @@ -761,10 +831,10 @@ def __init__(self, kernel_size: int | tuple[int, int] = 3, padding: str = "same", **kwargs) -> None: + logger.debug(parse_class_init(locals())) + self._name = _get_name(f"residual_{filters}") - logger.debug("name: %s, filters: %s, kernel_size: %s, padding: %s, kwargs: %s)", - self._name, filters, kernel_size, padding, kwargs) - self._use_reflect_padding = _CONFIG["reflect_padding"] + self._use_reflect_padding = cfg.reflect_padding() self._filters = filters self._kernel_size = (kernel_size, @@ -772,46 +842,70 @@ def __init__(self, self._padding = "valid" if self._use_reflect_padding else padding self._kwargs = kwargs - def __call__(self, inputs: Tensor) -> Tensor: + self._layers = self._get_layers() + self._add = layers.Add() + self._activation = layers.LeakyReLU(negative_slope=0.2, name=f"{self._name}_leakyrelu_3") + logger.debug("Initialized %s", self.__class__.__name__) + + def _get_layers(self) -> list[layers.Layer]: + """ Obtain the layer chain for the block + + Returns + ------- + list[:class:`keras.layers.Layer] + The layers, in the correct order, to pass the tensor through + """ + retval: list[layers.Layer] = [] + if self._use_reflect_padding: + retval.append(ReflectionPadding2D(stride=1, + kernel_size=self._kernel_size[0], + name=f"{self._name}_reflectionpadding2d_0")) + + retval.append(Conv2D(self._filters, # pyright:ignore[reportArgumentType] + kernel_size=self._kernel_size, + padding=self._padding, + name=f"{self._name}_conv2d_0", + **self._kwargs)) + retval.append(layers.LeakyReLU(negative_slope=0.2, name=f"{self._name}_leakyrelu_1")) + + if self._use_reflect_padding: + retval.append(ReflectionPadding2D(stride=1, + kernel_size=self._kernel_size[0], + name=f"{self._name}_reflectionpadding2d_1")) + + kwargs = {key: val for key, val in self._kwargs.items() if key != "kernel_initializer"} + if not cfg.conv_aware_init(): + kwargs["kernel_initializer"] = initializers.VarianceScaling(scale=0.2, + mode="fan_in", + distribution="uniform") + retval.append(Conv2D(self._filters, # pyright:ignore[reportArgumentType] + kernel_size=self._kernel_size, + padding=self._padding, + name=f"{self._name}_conv2d_1", + **kwargs)) + + logger.debug("%s layers: %s", self.__class__.__name__, retval) + return retval + + def __call__(self, inputs: KerasTensor) -> KerasTensor: """ Call the Faceswap Residual Block. Parameters ---------- - inputs: Tensor + inputs: :class:`keras.KerasTensor` The input to the layer Returns ------- - Tensor + :class:`keras.KerasTensor` The output tensor from the Upscale Layer """ var_x = inputs - if self._use_reflect_padding: - var_x = ReflectionPadding2D(stride=1, - kernel_size=self._kernel_size[0], - name=f"{self._name}_reflectionpadding2d_0")(var_x) - var_x = Conv2D(self._filters, - kernel_size=self._kernel_size, - padding=self._padding, - name=f"{self._name}_conv2d_0", - **self._kwargs)(var_x) - var_x = LeakyReLU(alpha=0.2, name=f"{self._name}_leakyrelu_1")(var_x) - if self._use_reflect_padding: - var_x = ReflectionPadding2D(stride=1, - kernel_size=self._kernel_size[0], - name=f"{self._name}_reflectionpadding2d_1")(var_x) + for layer in self._layers: + var_x = layer(var_x) - kwargs = {key: val for key, val in self._kwargs.items() if key != "kernel_initializer"} - if not _CONFIG["conv_aware_init"]: - kwargs["kernel_initializer"] = VarianceScaling(scale=0.2, - mode="fan_in", - distribution="uniform") - var_x = Conv2D(self._filters, - kernel_size=self._kernel_size, - padding=self._padding, - name=f"{self._name}_conv2d_1", - **kwargs)(var_x) - - var_x = Add()([var_x, inputs]) - var_x = LeakyReLU(alpha=0.2, name=f"{self._name}_leakyrelu_3")(var_x) - return var_x + var_x = self._add([var_x, inputs]) + return self._activation(var_x) + + +__all__ = get_module_objects(__name__) diff --git a/lib/model/normalization.py b/lib/model/normalization.py index fcef640fe9..5cbde8f049 100644 --- a/lib/model/normalization.py +++ b/lib/model/normalization.py @@ -1,18 +1,24 @@ #!/usr/bin/env python3 -""" Normalization methods for faceswap.py specific to Tensorflow backend """ +""" Normalization methods for faceswap.py specific to Torch backend """ +from __future__ import annotations + import inspect +import logging import sys +import typing as T + +from keras import constraints, initializers, InputSpec, layers, ops, regularizers, saving -import tensorflow as tf +from lib.logger import parse_class_init +from lib.utils import get_module_objects -# Fix intellisense/linting for tf.keras' thoroughly broken import system -from tensorflow.python.keras.utils.conv_utils import normalize_data_format # noqa:E501 # pylint:disable=no-name-in-module -keras = tf.keras -layers = keras.layers -K = keras.backend +if T.TYPE_CHECKING: + from keras import KerasTensor +logger = logging.getLogger(__name__) -class AdaInstanceNormalization(layers.Layer): # type:ignore[name-defined] + +class AdaInstanceNormalization(layers.Layer): # pylint:disable=too-many-ancestors,abstract-method """ Adaptive Instance Normalization Layer for Keras. Parameters @@ -40,20 +46,28 @@ class AdaInstanceNormalization(layers.Layer): # type:ignore[name-defined] Arbitrary Style Transfer in Real-time with Adaptive Instance Normalization - \ https://arxiv.org/abs/1703.06868 """ - def __init__(self, axis=-1, momentum=0.99, epsilon=1e-3, center=True, scale=True, **kwargs): + def __init__(self, + axis: int = -1, + momentum: float = 0.99, + epsilon: float = 1e-3, + center: bool = True, + scale: bool = True, + **kwargs) -> None: + logger.debug(parse_class_init(locals())) super().__init__(**kwargs) self.axis = axis self.momentum = momentum self.epsilon = epsilon self.center = center self.scale = scale + logger.debug("Initialized %s", self.__class__.__name__) - def build(self, input_shape): + def build(self, input_shape: tuple[tuple[int, ...], ...]) -> None: """Creates the layer weights. Parameters ---------- - input_shape: tensor + input_shape: tuple[int, ...] Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to reference for weight shape computations. """ @@ -66,20 +80,21 @@ def build(self, input_shape): super().build(input_shape) - def call(self, inputs, training=None): # pylint:disable=unused-argument,arguments-differ + def call(self, inputs: KerasTensor # pylint:disable=arguments-differ + ) -> KerasTensor: """This is where the layer's logic lives. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` Input tensor, or list/tuple of input tensors Returns ------- - tensor + :class:`keras.KerasTensor` A tensor or list/tuple of tensors """ - input_shape = K.int_shape(inputs[0]) + input_shape = inputs[0].shape reduction_axes = list(range(0, len(input_shape))) beta = inputs[1] @@ -89,20 +104,20 @@ def call(self, inputs, training=None): # pylint:disable=unused-argument,argumen del reduction_axes[self.axis] del reduction_axes[0] - mean = K.mean(inputs[0], reduction_axes, keepdims=True) - stddev = K.std(inputs[0], reduction_axes, keepdims=True) + self.epsilon + mean = ops.mean(inputs[0], reduction_axes, keepdims=True) + stddev = ops.std(inputs[0], reduction_axes, keepdims=True) + self.epsilon normed = (inputs[0] - mean) / stddev return normed * gamma + beta - def get_config(self): + def get_config(self) -> dict[str, T.Any]: """Returns the config of the layer. The Keras configuration for the layer. Returns -------- - dict + dict[str, Any] A python dictionary containing the layer configuration """ config = { @@ -115,7 +130,8 @@ def get_config(self): base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) - def compute_output_shape(self, input_shape): + def compute_output_shape(self, input_shape: tuple[int, ...] # pylint:disable=arguments-differ + ) -> int: """ Calculate the output shape from this layer. Parameters @@ -131,7 +147,7 @@ def compute_output_shape(self, input_shape): return input_shape[0] -class GroupNormalization(layers.Layer): # type:ignore[name-defined] +class GroupNormalization(layers.Layer): # pylint:disable=too-many-ancestors,abstract-method """ Group Normalization Parameters @@ -164,32 +180,42 @@ class GroupNormalization(layers.Layer): # type:ignore[name-defined] Shaoanlu GAN: https://github.com/shaoanlu/faceswap-GAN """ # pylint:disable=too-many-instance-attributes - def __init__(self, axis=-1, gamma_init='one', beta_init='zero', gamma_regularizer=None, - beta_regularizer=None, epsilon=1e-6, group=32, data_format=None, **kwargs): + def __init__(self, + axis: int = -1, + gamma_init: str = 'one', + beta_init: str = 'zero', + gamma_regularizer: T.Any = None, + beta_regularizer: T.Any = None, + epsilon: float = 1e-6, + group: int = 32, + data_format: str | None = None, + **kwargs) -> None: + logger.debug(parse_class_init(locals())) self.beta = None self.gamma = None super().__init__(**kwargs) self.axis = axis if isinstance(axis, (list, tuple)) else [axis] - self.gamma_init = keras.initializers.get(gamma_init) - self.beta_init = keras.initializers.get(beta_init) - self.gamma_regularizer = keras.regularizers.get(gamma_regularizer) - self.beta_regularizer = keras.regularizers.get(beta_regularizer) + self.gamma_init = initializers.get(gamma_init) + self.beta_init = initializers.get(beta_init) + self.gamma_regularizer = regularizers.get(gamma_regularizer) + self.beta_regularizer = regularizers.get(beta_regularizer) self.epsilon = epsilon self.group = group - self.data_format = normalize_data_format(data_format) + self.data_format = "channels_last" if data_format is None else data_format self.supports_masking = True + logger.debug("Initialized %s", self.__class__.__name__) - def build(self, input_shape): + def build(self, input_shape: tuple[int, ...]) -> None: """Creates the layer weights. Parameters ---------- - input_shape: tensor + input_shape: tuple[int, ...] Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to reference for weight shape computations. """ - input_spec = [layers.InputSpec(shape=input_shape)] + input_spec = [InputSpec(shape=input_shape)] self.input_spec = input_spec # pylint:disable=attribute-defined-outside-init shape = [1 for _ in input_shape] if self.data_format == 'channels_last': @@ -210,105 +236,137 @@ def build(self, input_shape): name='beta') self.built = True # pylint:disable=attribute-defined-outside-init - def call(self, inputs, *args, **kwargs): # noqa:C901 + def _process_4_channel(self, inputs: KerasTensor) -> KerasTensor: + """ Logic for processing 4 channel inputs + + Parameters + ---------- + inputs: :class:`keras.KerasTensor` + The input to the layer + + Returns + ------- + :class:`keras.KerasTensor` + A tensor or list/tuple of tensors + """ + input_shape = inputs.shape + if self.data_format == 'channels_last': + batch_size, height, width, channels = input_shape + if batch_size is None: + batch_size = -1 + + if channels < self.group: + raise ValueError('Input channels should be larger than group size' + + '; Received input channels: ' + str(channels) + + '; Group size: ' + str(self.group)) + + var_x = ops.reshape(inputs, (batch_size, + height, + width, + self.group, + channels // self.group)) + mean = ops.mean(var_x, axis=[1, 2, 4], keepdims=True) + std = ops.sqrt(ops.var(var_x, axis=[1, 2, 4], keepdims=True) + self.epsilon) + var_x = (var_x - mean) / std + + var_x = ops.reshape(var_x, (batch_size, height, width, channels)) + return self.gamma * var_x + self.beta + + # Channels first + batch_size, channels, height, width = input_shape + if batch_size is None: + batch_size = -1 + + if channels < self.group: + raise ValueError('Input channels should be larger than group size' + + '; Received input channels: ' + str(channels) + + '; Group size: ' + str(self.group)) + + var_x = ops.reshape(inputs, (batch_size, + self.group, + channels // self.group, + height, + width)) + mean = ops.mean(var_x, axis=[2, 3, 4], keepdims=True) + std = ops.sqrt(ops.var(var_x, axis=[2, 3, 4], keepdims=True) + self.epsilon) + var_x = (var_x - mean) / std + + var_x = ops.reshape(var_x, (batch_size, channels, height, width)) + return self.gamma * var_x + self.beta + + def compute_output_shape(self, input_shape: tuple[int, ...] # pylint:disable=arguments-differ + ) -> tuple[int, ...]: + """ Calculate the output shape from this layer. + + Parameters + ---------- + input_shape: tuple + The input shape to the layer + + Returns + ------- + int + The output shape to the layer + """ + return input_shape + + def call(self, inputs: KerasTensor, *args, **kwargs # pylint:disable=arguments-differ + ) -> KerasTensor: """This is where the layer's logic lives. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` Input tensor, or list/tuple of input tensors Returns ------- - tensor + :class:`keras.KerasTensor` A tensor or list/tuple of tensors """ - input_shape = K.int_shape(inputs) + input_shape = inputs.shape if len(input_shape) != 4 and len(input_shape) != 2: raise ValueError('Inputs should have rank ' + str(4) + " or " + str(2) + '; Received input shape:', str(input_shape)) if len(input_shape) == 4: - if self.data_format == 'channels_last': - batch_size, height, width, channels = input_shape - if batch_size is None: - batch_size = -1 - - if channels < self.group: - raise ValueError('Input channels should be larger than group size' + - '; Received input channels: ' + str(channels) + - '; Group size: ' + str(self.group)) - - var_x = K.reshape(inputs, (batch_size, - height, - width, - self.group, - channels // self.group)) - mean = K.mean(var_x, axis=[1, 2, 4], keepdims=True) - std = K.sqrt(K.var(var_x, axis=[1, 2, 4], keepdims=True) + self.epsilon) - var_x = (var_x - mean) / std - - var_x = K.reshape(var_x, (batch_size, height, width, channels)) - retval = self.gamma * var_x + self.beta - elif self.data_format == 'channels_first': - batch_size, channels, height, width = input_shape - if batch_size is None: - batch_size = -1 - - if channels < self.group: - raise ValueError('Input channels should be larger than group size' + - '; Received input channels: ' + str(channels) + - '; Group size: ' + str(self.group)) - - var_x = K.reshape(inputs, (batch_size, - self.group, - channels // self.group, - height, - width)) - mean = K.mean(var_x, axis=[2, 3, 4], keepdims=True) - std = K.sqrt(K.var(var_x, axis=[2, 3, 4], keepdims=True) + self.epsilon) - var_x = (var_x - mean) / std - - var_x = K.reshape(var_x, (batch_size, channels, height, width)) - retval = self.gamma * var_x + self.beta - - elif len(input_shape) == 2: - reduction_axes = list(range(0, len(input_shape))) - del reduction_axes[0] - batch_size, _ = input_shape - if batch_size is None: - batch_size = -1 + return self._process_4_channel(inputs) + + reduction_axes = list(range(0, len(input_shape))) + del reduction_axes[0] + batch_size, _ = input_shape + if batch_size is None: + batch_size = -1 - mean = K.mean(inputs, keepdims=True) - std = K.sqrt(K.var(inputs, keepdims=True) + self.epsilon) - var_x = (inputs - mean) / std + mean = ops.mean(inputs, keepdims=True) + std = ops.sqrt(ops.var(inputs, keepdims=True) + self.epsilon) + var_x = (inputs - mean) / std - retval = self.gamma * var_x + self.beta - return retval + return self.gamma * var_x + self.beta - def get_config(self): + def get_config(self) -> dict[str, T.Any]: """Returns the config of the layer. The Keras configuration for the layer. Returns -------- - dict + dict[str, Any]: A python dictionary containing the layer configuration """ config = {'epsilon': self.epsilon, 'axis': self.axis, - 'gamma_init': keras.initializers.serialize(self.gamma_init), - 'beta_init': keras.initializers.serialize(self.beta_init), - 'gamma_regularizer': keras.regularizers.serialize(self.gamma_regularizer), - 'beta_regularizer': keras.regularizers.serialize(self.gamma_regularizer), + 'gamma_init': initializers.serialize(self.gamma_init), + 'beta_init': initializers.serialize(self.beta_init), + 'gamma_regularizer': regularizers.serialize(self.gamma_regularizer), + 'beta_regularizer': regularizers.serialize(self.gamma_regularizer), 'group': self.group} base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) -class InstanceNormalization(layers.Layer): # type:ignore[name-defined] +class InstanceNormalization(layers.Layer): # pylint:disable=too-many-ancestors,abstract-method """Instance normalization layer (Lei Ba et al, 2016, Ulyanov et al., 2016). Normalize the activations of the previous layer at each step, i.e. applies a transformation @@ -351,19 +409,20 @@ class InstanceNormalization(layers.Layer): # type:ignore[name-defined] - Instance Normalization: The Missing Ingredient for Fast Stylization - \ https://arxiv.org/abs/1607.08022 """ - # pylint:disable=too-many-instance-attributes,too-many-arguments + # pylint:disable=too-many-instance-attributes,too-many-arguments,too-many-positional-arguments def __init__(self, - axis=None, - epsilon=1e-3, - center=True, - scale=True, - beta_initializer="zeros", - gamma_initializer="ones", - beta_regularizer=None, - gamma_regularizer=None, - beta_constraint=None, - gamma_constraint=None, - **kwargs): + axis: int | None = None, + epsilon: float = 1e-3, + center: bool = True, + scale: bool = True, + beta_initializer: str = "zeros", + gamma_initializer: str = "ones", + beta_regularizer: T.Any = None, + gamma_regularizer: T.Any = None, + beta_constraint: T.Any = None, + gamma_constraint: T.Any = None, + **kwargs) -> None: + logger.debug(parse_class_init(locals())) self.beta = None self.gamma = None super().__init__(**kwargs) @@ -372,19 +431,20 @@ def __init__(self, self.epsilon = epsilon self.center = center self.scale = scale - self.beta_initializer = keras.initializers.get(beta_initializer) - self.gamma_initializer = keras.initializers.get(gamma_initializer) - self.beta_regularizer = keras.regularizers.get(beta_regularizer) - self.gamma_regularizer = keras.regularizers.get(gamma_regularizer) - self.beta_constraint = keras.constraints.get(beta_constraint) - self.gamma_constraint = keras.constraints.get(gamma_constraint) - - def build(self, input_shape): + self.beta_initializer = initializers.get(beta_initializer) + self.gamma_initializer = initializers.get(gamma_initializer) + self.beta_regularizer = regularizers.get(beta_regularizer) + self.gamma_regularizer = regularizers.get(gamma_regularizer) + self.beta_constraint = constraints.get(beta_constraint) + self.gamma_constraint = constraints.get(gamma_constraint) + logger.debug("Initialized %s", self.__class__.__name__) + + def build(self, input_shape: tuple[int, ...]) -> None: """Creates the layer weights. Parameters ---------- - input_shape: tensor + input_shape: tuple[int, ...] Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to reference for weight shape computations. """ @@ -395,7 +455,7 @@ def build(self, input_shape): if (self.axis is not None) and (ndim == 2): raise ValueError("Cannot specify axis for rank 1 tensor") - self.input_spec = layers.InputSpec(ndim=ndim) # noqa:E501 pylint:disable=attribute-defined-outside-init + self.input_spec = InputSpec(ndim=ndim) # pylint:disable=attribute-defined-outside-init if self.axis is None: shape = (1,) @@ -420,20 +480,37 @@ def build(self, input_shape): self.beta = None self.built = True # pylint:disable=attribute-defined-outside-init - def call(self, inputs, training=None): # pylint:disable=arguments-differ,unused-argument + def compute_output_shape(self, input_shape: tuple[int, ...] # pylint:disable=arguments-differ + ) -> tuple[int, ...]: + """ Calculate the output shape from this layer. + + Parameters + ---------- + input_shape: tuple + The input shape to the layer + + Returns + ------- + int + The output shape to the layer + """ + return input_shape + + def call(self, inputs: KerasTensor # pylint:disable=arguments-differ + ) -> KerasTensor: """This is where the layer's logic lives. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` Input tensor, or list/tuple of input tensors Returns ------- - tensor + :class:`keras.KerasTensor` A tensor or list/tuple of tensors """ - input_shape = K.int_shape(inputs) + input_shape = inputs.shape reduction_axes = list(range(0, len(input_shape))) if self.axis is not None: @@ -441,8 +518,8 @@ def call(self, inputs, training=None): # pylint:disable=arguments-differ,unused del reduction_axes[0] - mean = K.mean(inputs, reduction_axes, keepdims=True) - stddev = K.std(inputs, reduction_axes, keepdims=True) + self.epsilon + mean = ops.mean(inputs, reduction_axes, keepdims=True) + stddev = ops.std(inputs, reduction_axes, keepdims=True) + self.epsilon normed = (inputs - mean) / stddev broadcast_shape = [1] * len(input_shape) @@ -450,14 +527,14 @@ def call(self, inputs, training=None): # pylint:disable=arguments-differ,unused broadcast_shape[self.axis] = input_shape[self.axis] if self.scale: - broadcast_gamma = K.reshape(self.gamma, broadcast_shape) + broadcast_gamma = ops.reshape(self.gamma, broadcast_shape) normed = normed * broadcast_gamma if self.center: - broadcast_beta = K.reshape(self.beta, broadcast_shape) + broadcast_beta = ops.reshape(self.beta, broadcast_shape) normed = normed + broadcast_beta return normed - def get_config(self): + def get_config(self) -> dict[str, T.Any]: """Returns the config of the layer. A layer config is a Python dictionary (serializable) containing the configuration of a @@ -469,7 +546,7 @@ class name. These are handled by `Network` (one layer of abstraction above). Returns -------- - dict + dict[str, Any] A python dictionary containing the layer configuration """ config = { @@ -477,18 +554,18 @@ class name. These are handled by `Network` (one layer of abstraction above). "epsilon": self.epsilon, "center": self.center, "scale": self.scale, - "beta_initializer": keras.initializers.serialize(self.beta_initializer), - "gamma_initializer": keras.initializers.serialize(self.gamma_initializer), - "beta_regularizer": keras.regularizers.serialize(self.beta_regularizer), - "gamma_regularizer": keras.regularizers.serialize(self.gamma_regularizer), - "beta_constraint": keras.constraints.serialize(self.beta_constraint), - "gamma_constraint": keras.constraints.serialize(self.gamma_constraint) + "beta_initializer": initializers.serialize(self.beta_initializer), + "gamma_initializer": initializers.serialize(self.gamma_initializer), + "beta_regularizer": regularizers.serialize(self.beta_regularizer), + "gamma_regularizer": regularizers.serialize(self.gamma_regularizer), + "beta_constraint": constraints.serialize(self.beta_constraint), + "gamma_constraint": constraints.serialize(self.gamma_constraint) } base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) -class RMSNormalization(layers.Layer): # type:ignore[name-defined] +class RMSNormalization(layers.Layer): # pylint:disable=too-many-ancestors,abstract-method """ Root Mean Square Layer Normalization (Biao Zhang, Rico Sennrich, 2019) RMSNorm is a simplification of the original layer normalization (LayerNorm). LayerNorm is a @@ -522,9 +599,14 @@ class RMSNormalization(layers.Layer): # type:ignore[name-defined] - RMS Normalization - https://arxiv.org/abs/1910.07467 - Official implementation - https://github.com/bzhangGo/rmsnorm """ - def __init__(self, axis=-1, epsilon=1e-8, partial=0.0, bias=False, **kwargs): + def __init__(self, + axis: int = -1, + epsilon: float = 1e-8, + partial: float = 0.0, + bias: bool = False, + **kwargs) -> None: + logger.debug(parse_class_init(locals())) self.scale = None - self.offset = 0 super().__init__(**kwargs) # Checks @@ -539,13 +621,14 @@ def __init__(self, axis=-1, epsilon=1e-8, partial=0.0, bias=False, **kwargs): self.partial = partial self.bias = bias self.offset = 0. + logger.debug("Initialized %s", self.__class__.__name__) - def build(self, input_shape): + def build(self, input_shape: tuple[int, ...]) -> None: """ Validate and populate :attr:`axis` Parameters ---------- - input_shape: tensor + input_shape: tuple[int, ...] Keras tensor (future input to layer) or ``list``/``tuple`` of Keras tensors to reference for weight shape computations. """ @@ -574,53 +657,52 @@ def build(self, input_shape): self.built = True # pylint:disable=attribute-defined-outside-init - def call(self, inputs, *args, **kwargs): + def call(self, inputs: KerasTensor, *args, **kwargs # pylint:disable=arguments-differ + ) -> KerasTensor: """ Call Root Mean Square Layer Normalization Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` Input tensor, or list/tuple of input tensors Returns ------- - tensor + :class:`keras.KerasTensor` A tensor or list/tuple of tensors """ # Compute the axes along which to reduce the mean / variance - input_shape = K.int_shape(inputs) + input_shape = inputs.shape layer_size = input_shape[self.axis] if self.partial in (0.0, 1.0): - mean_square = K.mean(K.square(inputs), axis=self.axis, keepdims=True) + mean_square = ops.mean(ops.square(inputs), axis=self.axis, keepdims=True) else: partial_size = int(layer_size * self.partial) - partial_x, _ = tf.split( # pylint:disable=redundant-keyword-arg,no-value-for-parameter - inputs, - [partial_size, layer_size - partial_size], - axis=self.axis) - mean_square = K.mean(K.square(partial_x), axis=self.axis, keepdims=True) + partial_x, _ = ops.split(inputs, [partial_size], axis=self.axis) + mean_square = ops.mean(ops.square(partial_x), axis=self.axis, keepdims=True) - recip_square_root = tf.math.rsqrt(mean_square + self.epsilon) + recip_square_root = ops.rsqrt(mean_square + self.epsilon) output = self.scale * inputs * recip_square_root + self.offset return output - def compute_output_shape(self, input_shape): + def compute_output_shape(self, input_shape: tuple[int, ...] # pylint:disable=arguments-differ + ) -> tuple[int, ...]: """ The output shape of the layer is the same as the input shape. Parameters ---------- - input_shape: tuple + input_shape: tuple[int, ...] The input shape to the layer Returns ------- - tuple + tuple[int, ...] The output shape to the layer """ return input_shape - def get_config(self): + def get_config(self) -> dict[str, T.Any]: """Returns the config of the layer. A layer config is a Python dictionary (serializable) containing the configuration of a @@ -632,7 +714,7 @@ class name. These are handled by `Network` (one layer of abstraction above). Returns -------- - dict + dict[str, Any]: A python dictionary containing the layer configuration """ base_config = super().get_config() @@ -646,4 +728,7 @@ class name. These are handled by `Network` (one layer of abstraction above). # Update normalization into Keras custom objects for name, obj in inspect.getmembers(sys.modules[__name__]): if inspect.isclass(obj) and obj.__module__ == __name__: - keras.utils.get_custom_objects().update({name: obj}) + saving.get_custom_objects().update({name: obj}) + + +__all__ = get_module_objects(__name__) diff --git a/lib/model/optimizers.py b/lib/model/optimizers.py index 33efe7c8bd..835258ad28 100644 --- a/lib/model/optimizers.py +++ b/lib/model/optimizers.py @@ -1,20 +1,26 @@ #!/usr/bin/env python3 -""" Custom Optimizers for TensorFlow 2.x/tf.keras """ - +""" Custom Optimizers for Torch/keras """ +from __future__ import annotations import inspect +import logging import sys +import typing as T + +from keras import ops, Optimizer, saving -import tensorflow as tf +from lib.logger import parse_class_init +from lib.utils import get_module_objects -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras.optimizers import Adam, Nadam, RMSprop # noqa:E501,F401 pylint:disable=import-error,unused-import -keras = tf.keras +if T.TYPE_CHECKING: + from keras import KerasTensor, Variable +logger = logging.getLogger(__name__) -class AdaBelief(tf.keras.optimizers.Optimizer): + +class AdaBelief(Optimizer): # pylint:disable=too-many-instance-attributes,too-many-ancestors """ Implementation of the AdaBelief Optimizer - Inherits from: tf.keras.optimizers.Optimizer. + Inherits from: keras.optimizers.Optimizer. AdaBelief Optimizer is not a placement of the heuristic warmup, the settings should be kept if warmup has already been employed and tuned in the baseline method. You can enable warmup by @@ -26,7 +32,7 @@ class AdaBelief(tf.keras.optimizers.Optimizer): Parameters ---------- - learning_rate: `Tensor`, float or :class: `tf.keras.optimizers.schedules.LearningRateSchedule` + learning_rate: `Tensor`, float or :class: `keras.optimizers.schedules.LearningRateSchedule` The learning rate. beta_1: float The exponential decay rate for the 1st moment estimates. @@ -34,13 +40,11 @@ class AdaBelief(tf.keras.optimizers.Optimizer): The exponential decay rate for the 2nd moment estimates. epsilon: float A small constant for numerical stability. - weight_decay: `Tensor`, float or :class: `tf.keras.optimizers.schedules.LearningRateSchedule` - Weight decay for each parameter. - rectify: bool - Whether to enable rectification as in RectifiedAdam amsgrad: bool Whether to apply AMSGrad variant of this algorithm from the paper "On the Convergence of Adam and beyond". + rectify: bool + Whether to enable rectification as in RectifiedAdam sma_threshold. float The threshold for simple mean average. total_steps: int @@ -52,22 +56,20 @@ class AdaBelief(tf.keras.optimizers.Optimizer): name: str, optional Name for the operations created when applying gradients. Default: ``"AdaBeliefOptimizer"``. **kwargs: dict - Standard Keras Optimizer keyword arguments. Allowed to be {`clipnorm`, `clipvalue`, `lr`, - `decay`}. `clipnorm` is clip gradients by norm; `clipvalue` is clip gradients by value, - `decay` is included for backward compatibility to allow time inverse decay of learning - rate. `lr` is included for backward compatibility, recommended to use `learning_rate` - instead. + Standard Keras Optimizer keyword arguments. Allowed to be (`weight_decay`, `clipnorm`, + `clipvalue`, `global_clipnorm`, `use_ema`, `ema_momentum`, `ema_overwrite_frequency`, + `loss_scale_factor`, `gradient_accumulation_steps`) Examples -------- - >>> from adabelief_tf import AdaBelief + >>> from optimizers import AdaBelief >>> opt = AdaBelief(lr=1e-3) Example of serialization: >>> optimizer = AdaBelief(learning_rate=lr_scheduler, weight_decay=wd_scheduler) - >>> config = tf.keras.optimizers.serialize(optimizer) - >>> new_optimizer = tf.keras.optimizers.deserialize(config, + >>> config = keras.optimizers.serialize(optimizer) + >>> new_optimizer = keras.optimizers.deserialize(config, ... custom_objects=dict(AdaBelief=AdaBelief)) Example of warm up: @@ -125,275 +127,205 @@ class AdaBelief(tf.keras.optimizers.Optimizer): OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """ - def __init__(self, learning_rate=0.001, beta_1=0.9, beta_2=0.999, epsilon=1e-14, - weight_decay=0.0, rectify=True, amsgrad=False, sma_threshold=5.0, total_steps=0, - warmup_proportion=0.1, min_lr=0.0, name="AdaBeliefOptimizer", **kwargs): - # pylint:disable=too-many-arguments - super().__init__(name, **kwargs) - self._set_hyper("learning_rate", kwargs.get("lr", learning_rate)) - self._set_hyper("beta_1", beta_1) - self._set_hyper("beta_2", beta_2) - self._set_hyper("decay", self._initial_decay) - self._set_hyper("weight_decay", weight_decay) - self._set_hyper("sma_threshold", sma_threshold) - self._set_hyper("total_steps", int(total_steps)) - self._set_hyper("warmup_proportion", warmup_proportion) - self._set_hyper("min_lr", min_lr) - self.epsilon = epsilon or tf.keras.backend.epsilon() + def __init__(self, # pylint:disable=too-many-arguments,too-many-positional-arguments + learning_rate: float = 0.001, + beta_1: float = 0.9, + beta_2: float = 0.999, + epsilon: float = 1e-14, + amsgrad: bool = False, + rectify: bool = True, + sma_threshold: float = 5.0, + total_steps: int = 0, + warmup_proportion: float = 0.1, + min_learning_rate: float = 0.0, + name="AdaBeliefOptimizer", + **kwargs): + logger.debug(parse_class_init(locals())) + super().__init__(learning_rate=learning_rate, name=name, **kwargs) + self.beta_1 = beta_1 + self.beta_2 = beta_2 + self.epsilon = epsilon self.amsgrad = amsgrad self.rectify = rectify - self._has_weight_decay = weight_decay != 0.0 - self._initial_total_steps = total_steps - - def _create_slots(self, var_list): - """ Create slots for the first and second moments + self.sma_threshold = sma_threshold + # TODO change the following 2 to "warm_up_steps" + # TODO Make learning rate warm up a global option + # Or these params can be calculated from a user "warm_up_steps" parameter + self.total_steps = total_steps + self.warmup_proportion = warmup_proportion + self.min_learning_rate = min_learning_rate + logger.debug("Initialized %s", self.__class__.__name__) - Parameters - ---------- - var_list: list - List of tensorflow variables to create slots for - """ - for var in var_list: - self.add_slot(var, "m") - self.add_slot(var, "v") - if self.amsgrad: - self.add_slot(var, "vhat") + self._momentums: list[Variable] = [] + self._velocities: list[Variable] = [] + self._velocity_hats: list[Variable] = [] # Amsgrad only - def set_weights(self, weights): - """ Set the weights of the optimizer. + def build(self, variables: list[Variable]) -> None: + """Initialize optimizer variables. - The weights of an optimizer are its state (IE, variables). This function takes the weight - values associated with this optimizer as a list of Numpy arrays. The first value is always - the iterations count of the optimizer, followed by the optimizers state variables in the - order they are created. The passed values are used to set the new state of the optimizer. + AdaBelief optimizer has 3 types of variables: momentums, velocities and + velocity_hat (only set when amsgrad is applied), Parameters ---------- - weights: list - weight values as a list of numpy arrays. + variables: list[:class:`keras.Variable`] + list of model variables to build AdaBelief variables on. """ - params = self.weights - num_vars = int((len(params) - 1) / 2) - if len(weights) == 3 * num_vars + 1: - weights = weights[: len(params)] - super().set_weights(weights) + if self.built: + return + logger.debug("Building AdaBelief. var_list: %s", variables) + super().build(variables) + + for var in variables: + self._momentums.append(self.add_variable_from_reference( + reference_variable=var, name="momentum")) + self._velocities.append(self.add_variable_from_reference( + reference_variable=var, name="velocity")) + if self.amsgrad: + self._velocity_hats.append(self.add_variable_from_reference( + reference_variable=var, name="velocity_hat")) + logger.debug("Built AdaBelief. momentums: %s, velocities: %s, velocity_hats: %s", + len(self._momentums), len(self._velocities), len(self._velocity_hats)) - def _decayed_wd(self, var_dtype): - """ Set the weight decay + def _maybe_warmup(self, learning_rate: KerasTensor, local_step: KerasTensor) -> KerasTensor: + """ Do learning rate warm up if requested Parameters ---------- - var_dtype: str - The data type to to set up weight decay for + learning_rate: :class:`keras.KerasTensor` + The learning rate + local_step: :class:`keras.KerasTensor` + The current training step Returns ------- - Tensor - The weight decay variable + :class:`keras.KerasTensor` + Either the original learning rate or adjusted learning rate if warmup is requested """ - wd_t = self._get_hyper("weight_decay", var_dtype) - if isinstance(wd_t, tf.keras.optimizers.schedules.LearningRateSchedule): - wd_t = tf.cast(wd_t(self.iterations), var_dtype) - return wd_t - - def _resource_apply_dense(self, grad, handle, apply_state=None): - # pylint:disable=too-many-locals,unused-argument - """ Add ops to apply dense gradients to the variable handle. + if self.total_steps <= 0: + return learning_rate + + total_steps = ops.cast(self.total_steps, learning_rate.dtype) + warmup_steps = total_steps * ops.cast(self.warmup_proportion, learning_rate.dtype) + min_lr = ops.cast(self.min_learning_rate, learning_rate.dtype) + decay_steps = ops.maximum(total_steps - warmup_steps, 1) + decay_rate = ops.divide(min_lr - learning_rate, decay_steps) + return ops.where(local_step <= warmup_steps, + ops.multiply(learning_rate, (ops.divide(local_step, warmup_steps))), + ops.multiply(learning_rate + decay_rate, + ops.minimum(local_step - warmup_steps, decay_steps))) + + def _maybe_rectify(self, + momentum: KerasTensor, + velocity: KerasTensor, + local_step: KerasTensor, + beta_2_power: KerasTensor) -> KerasTensor: + """ Apply rectification, if requested Parameters ---------- - grad: Tensor - A tensor representing the gradient. - handle: Tensor - a Tensor of dtype resource which points to the variable to be updated. - apply_state: dict - A dict which is used across multiple apply calls. + momentum: :class:`keras.KerasTensor` + The momentum update + velocity: :class:`keras.KerasTensor` + The velocity update + local_step: :class:`keras.KerasTensor` + The current training step + beta_2_power + Adjusted exponential decay rate for the 2nd moment estimates. Returns ------- - An Operation which updates the value of the variable. + :class:`keras.KerasTensor` + The standard or rectified update (if rectification enabled) """ - var_dtype = handle.dtype.base_dtype - lr_t = self._decayed_lr(var_dtype) - wd_t = self._decayed_wd(var_dtype) - var_m = self.get_slot(handle, "m") - var_v = self.get_slot(handle, "v") - beta_1_t = self._get_hyper("beta_1", var_dtype) - beta_2_t = self._get_hyper("beta_2", var_dtype) - epsilon_t = tf.convert_to_tensor(self.epsilon, var_dtype) - local_step = tf.cast(self.iterations + 1, var_dtype) - beta_1_power = tf.math.pow(beta_1_t, local_step) - beta_2_power = tf.math.pow(beta_2_t, local_step) - - if self._initial_total_steps > 0: - total_steps = self._get_hyper("total_steps", var_dtype) - warmup_steps = total_steps * self._get_hyper("warmup_proportion", var_dtype) - min_lr = self._get_hyper("min_lr", var_dtype) - decay_steps = tf.maximum(total_steps - warmup_steps, 1) - decay_rate = (min_lr - lr_t) / decay_steps - lr_t = tf.where(local_step <= warmup_steps, - lr_t * (local_step / warmup_steps), - lr_t + decay_rate * tf.minimum(local_step - warmup_steps, decay_steps)) - - m_t = var_m.assign(beta_1_t * var_m + (1.0 - beta_1_t) * grad, - use_locking=self._use_locking) - m_corr_t = m_t / (1.0 - beta_1_power) - - v_t = var_v.assign( - beta_2_t * var_v + (1.0 - beta_2_t) * tf.math.square(grad - m_t) + epsilon_t, - use_locking=self._use_locking) - - if self.amsgrad: - vhat = self.get_slot(handle, "vhat") - vhat_t = vhat.assign(tf.maximum(vhat, v_t), use_locking=self._use_locking) - v_corr_t = tf.math.sqrt(vhat_t / (1.0 - beta_2_power)) - else: - vhat_t = None - v_corr_t = tf.math.sqrt(v_t / (1.0 - beta_2_power)) - - if self.rectify: - sma_inf = 2.0 / (1.0 - beta_2_t) - 1.0 - sma_t = sma_inf - 2.0 * local_step * beta_2_power / (1.0 - beta_2_power) - r_t = tf.math.sqrt((sma_t - 4.0) / (sma_inf - 4.0) * - (sma_t - 2.0) / (sma_inf - 2.0) * - sma_inf / sma_t) - sma_threshold = self._get_hyper("sma_threshold", var_dtype) - var_t = tf.where(sma_t >= sma_threshold, - r_t * m_corr_t / (v_corr_t + epsilon_t), - m_corr_t) - else: - var_t = m_corr_t / (v_corr_t + epsilon_t) - - if self._has_weight_decay: - var_t += wd_t * handle - - var_update = handle.assign_sub(lr_t * var_t, use_locking=self._use_locking) - updates = [var_update, m_t, v_t] - - if self.amsgrad: - updates.append(vhat_t) - return tf.group(*updates) - - def _resource_apply_sparse(self, grad, handle, indices, apply_state=None): - # pylint:disable=too-many-locals, unused-argument - """ Add ops to apply sparse gradients to the variable handle. - - Similar to _apply_sparse, the indices argument to this method has been de-duplicated. - Optimizers which deal correctly with non-unique indices may instead override - :func:`_resource_apply_sparse_duplicate_indices` to avoid this overhead. + if not self.rectify: + return ops.divide(momentum, ops.add(velocity, self.epsilon)) + + sma_inf = 2 / (1 - self.beta_2) - 1 + sma_t = sma_inf - 2 * local_step * beta_2_power / (1 - beta_2_power) + rect = ops.sqrt((sma_t - 4) / (sma_inf - 4) * + (sma_t - 2) / (sma_inf - 2) * + sma_inf / sma_t) + return ops.where(sma_t >= self.sma_threshold, + ops.divide( + ops.multiply(rect, momentum), + (ops.add(velocity, self.epsilon))), + momentum) + + def update_step(self, + gradient: KerasTensor, + variable: Variable, + learning_rate: Variable) -> None: + """Update step given gradient and the associated model variable for AdaBelief. Parameters ---------- - grad: Tensor - a Tensor representing the gradient for the affected indices. - handle: Tensor - a Tensor of dtype resource which points to the variable to be updated. - indices: Tensor - a Tensor of integral type representing the indices for which the gradient is nonzero. - Indices are unique. - apply_state: dict - A dict which is used across multiple apply calls. - - Returns - ------- - An Operation which updates the value of the variable. + gradient :class:`keras.KerasTensor` + The gradient to update + variable: :class:`keras.Variable` + The variable to update + learning_rate: :class:`keras.Variable` + The learning rate """ - var_dtype = handle.dtype.base_dtype - lr_t = self._decayed_lr(var_dtype) - wd_t = self._decayed_wd(var_dtype) - beta_1_t = self._get_hyper("beta_1", var_dtype) - beta_2_t = self._get_hyper("beta_2", var_dtype) - epsilon_t = tf.convert_to_tensor(self.epsilon, var_dtype) - local_step = tf.cast(self.iterations + 1, var_dtype) - beta_1_power = tf.math.pow(beta_1_t, local_step) - beta_2_power = tf.math.pow(beta_2_t, local_step) - - if self._initial_total_steps > 0: - total_steps = self._get_hyper("total_steps", var_dtype) - warmup_steps = total_steps * self._get_hyper("warmup_proportion", var_dtype) - min_lr = self._get_hyper("min_lr", var_dtype) - decay_steps = tf.maximum(total_steps - warmup_steps, 1) - decay_rate = (min_lr - lr_t) / decay_steps - lr_t = tf.where(local_step <= warmup_steps, - lr_t * (local_step / warmup_steps), - lr_t + decay_rate * tf.minimum(local_step - warmup_steps, decay_steps)) - - var_m = self.get_slot(handle, "m") - m_scaled_g_values = grad * (1 - beta_1_t) - m_t = var_m.assign(var_m * beta_1_t, use_locking=self._use_locking) - m_t = self._resource_scatter_add(var_m, indices, m_scaled_g_values) - m_corr_t = m_t / (1.0 - beta_1_power) - - var_v = self.get_slot(handle, "v") - m_t_indices = tf.gather(m_t, indices) # pylint:disable=no-value-for-parameter - v_scaled_g_values = tf.math.square(grad - m_t_indices) * (1 - beta_2_t) - v_t = var_v.assign(var_v * beta_2_t + epsilon_t, use_locking=self._use_locking) - v_t = self._resource_scatter_add(var_v, indices, v_scaled_g_values) + local_step = ops.cast(self.iterations + 1, variable.dtype) + learning_rate = self._maybe_warmup(ops.cast(learning_rate, variable.dtype), local_step) + gradient = ops.cast(gradient, variable.dtype) + beta_1_power = ops.power(ops.cast(self.beta_1, variable.dtype), local_step) + beta_2_power = ops.power(ops.cast(self.beta_2, variable.dtype), local_step) + + # m_t = b1 * m + (1 - b1) * g + # => m_t = m + (g - m) * (1 - b1) + momentum = self._momentums[self._get_variable_index(variable)] + self.assign_add(momentum, ops.multiply(ops.subtract(gradient, momentum), 1 - self.beta_1)) + momentum_corr = ops.divide(momentum, (1 - beta_1_power)) + + # v_t = b2 * v + (1 - b2) * (g - m_t)^2 + e + # => v_t = v + ((g - m_t)^2 - v) * (1 - b2) + e + velocity = self._velocities[self._get_variable_index(variable)] + self.assign_add(velocity, + ops.multiply( + ops.subtract(ops.square(gradient - momentum), velocity), + 1 - self.beta_2) + + self.epsilon) if self.amsgrad: - vhat = self.get_slot(handle, "vhat") - vhat_t = vhat.assign(tf.maximum(vhat, v_t), use_locking=self._use_locking) - v_corr_t = tf.math.sqrt(vhat_t / (1.0 - beta_2_power)) - else: - vhat_t = None - v_corr_t = tf.math.sqrt(v_t / (1.0 - beta_2_power)) - - if self.rectify: - sma_inf = 2.0 / (1.0 - beta_2_t) - 1.0 - sma_t = sma_inf - 2.0 * local_step * beta_2_power / (1.0 - beta_2_power) - r_t = tf.math.sqrt((sma_t - 4.0) / (sma_inf - 4.0) * - (sma_t - 2.0) / (sma_inf - 2.0) * - sma_inf / sma_t) - sma_threshold = self._get_hyper("sma_threshold", var_dtype) - var_t = tf.where(sma_t >= sma_threshold, - r_t * m_corr_t / (v_corr_t + epsilon_t), - m_corr_t) + velocity_hat = self._velocity_hats[self._get_variable_index(variable)] + self.assign(velocity_hat, ops.maximum(velocity, velocity_hat)) + velocity_corr = ops.sqrt(ops.divide(velocity_hat, (1 - beta_2_power))) else: - var_t = m_corr_t / (v_corr_t + epsilon_t) + velocity_corr = ops.sqrt(ops.divide(velocity, (1 - beta_2_power))) - if self._has_weight_decay: - var_t += wd_t * handle + var_t = self._maybe_rectify(momentum_corr, velocity_corr, local_step, beta_2_power) - var_update = self._resource_scatter_add(handle, - indices, - tf.gather( # pylint:disable=no-value-for-parameter - tf.math.negative(lr_t) * var_t, - indices)) + self.assign_sub(variable, ops.multiply(learning_rate, var_t)) - updates = [var_update, m_t, v_t] - if self.amsgrad: - updates.append(vhat_t) - return tf.group(*updates) - - def get_config(self): + def get_config(self) -> dict[str, T.Any]: """ Returns the config of the optimizer. - An optimizer config is a Python dictionary (serializable) containing the configuration of - an optimizer. The same optimizer can be re-instantiated later (without any saved state) - from this configuration. + Optimizer configuration for AdaBelief. Returns ------- - dict + dict[str, Any] The optimizer configuration. """ config = super().get_config() - config.update({"learning_rate": self._serialize_hyperparameter("learning_rate"), - "beta_1": self._serialize_hyperparameter("beta_1"), - "beta_2": self._serialize_hyperparameter("beta_2"), - "decay": self._serialize_hyperparameter("decay"), - "weight_decay": self._serialize_hyperparameter("weight_decay"), - "sma_threshold": self._serialize_hyperparameter("sma_threshold"), + config.update({"beta_1": self.beta_1, + "beta_2": self.beta_2, "epsilon": self.epsilon, "amsgrad": self.amsgrad, "rectify": self.rectify, - "total_steps": self._serialize_hyperparameter("total_steps"), - "warmup_proportion": self._serialize_hyperparameter("warmup_proportion"), - "min_lr": self._serialize_hyperparameter("min_lr")}) + "sma_threshold": self.sma_threshold, + "total_steps": self.total_steps, + "warmup_proportion": self.warmup_proportion, + "min_learning_rate": self.min_learning_rate}) return config -# Update layers into Keras custom objects +# Update Optimizers into Keras custom objects for _name, obj in inspect.getmembers(sys.modules[__name__]): if inspect.isclass(obj) and obj.__module__ == __name__: - keras.utils.get_custom_objects().update({_name: obj}) + saving.get_custom_objects().update({_name: obj}) + + +__all__ = get_module_objects(__name__) diff --git a/lib/model/session.py b/lib/model/session.py deleted file mode 100644 index a400b2fde6..0000000000 --- a/lib/model/session.py +++ /dev/null @@ -1,208 +0,0 @@ -#!/usr/bin python3 -""" Settings manager for Keras Backend """ -from __future__ import annotations -from contextlib import nullcontext -import logging -import typing as T - -import numpy as np -import tensorflow as tf - -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras.layers import Activation # pylint:disable=import-error -from tensorflow.keras.models import load_model as k_load_model, Model # noqa:E501 # pylint:disable=import-error - -from lib.utils import get_backend - -if T.TYPE_CHECKING: - from collections.abc import Callable - -logger = logging.getLogger(__name__) - - -class KSession(): - """ Handles the settings of backend sessions for inference models. - - This class acts as a wrapper for various :class:`keras.Model()` functions, ensuring that - actions performed on a model are handled consistently and can be performed in parallel in - separate threads. - - This is an early implementation of this class, and should be expanded out over time. - - Notes - ----- - The documentation refers to :mod:`keras`. This is a pseudonym for either :mod:`keras` or - :mod:`tensorflow.keras` depending on the backend in use. - - Parameters - ---------- - name: str - The name of the model that is to be loaded - model_path: str - The path to the keras model file - model_kwargs: dict, optional - Any kwargs that need to be passed to :func:`keras.models.load_models()`. Default: ``None`` - allow_growth: bool, optional - Enable the Tensorflow GPU allow_growth configuration option. This option prevents - Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation and - slower performance. Default: ``False`` - exclude_gpus: list, optional - A list of indices correlating to connected GPUs that Tensorflow should not use. Pass - ``None`` to not exclude any GPUs. Default: ``None`` - cpu_mode: bool, optional - ``True`` run the model on CPU. Default: ``False`` - """ - def __init__(self, - name: str, - model_path: str, - model_kwargs: dict | None = None, - allow_growth: bool = False, - exclude_gpus: list[int] | None = None, - cpu_mode: bool = False) -> None: - logger.trace("Initializing: %s (name: %s, model_path: %s, " # type:ignore - "model_kwargs: %s, allow_growth: %s, exclude_gpus: %s, cpu_mode: %s)", - self.__class__.__name__, name, model_path, model_kwargs, allow_growth, - exclude_gpus, cpu_mode) - self._name = name - self._backend = get_backend() - self._context = self._set_session(allow_growth, - [] if exclude_gpus is None else exclude_gpus, - cpu_mode) - self._model_path = model_path - self._model_kwargs = {} if not model_kwargs else model_kwargs - self._model: Model | None = None - logger.trace("Initialized: %s", self.__class__.__name__,) # type:ignore - - def predict(self, - feed: list[np.ndarray] | np.ndarray, - batch_size: int | None = None) -> list[np.ndarray] | np.ndarray: - """ Get predictions from the model. - - This method is a wrapper for :func:`keras.predict()` function. For Tensorflow backends - this is a straight call to the predict function. - - Parameters - ---------- - feed: numpy.ndarray or list - The feed to be provided to the model as input. This should be a :class:`numpy.ndarray` - for single inputs or a `list` of :class:`numpy.ndarray` objects for multiple inputs. - batchsize: int, optional - The batch size to run prediction at. Default ``None`` - - Returns - ------- - :class:`numpy.ndarray` - The predictions from the model - """ - assert self._model is not None - with self._context: - return self._model.predict(feed, verbose=0, batch_size=batch_size) - - def _set_session(self, - allow_growth: bool, - exclude_gpus: list, - cpu_mode: bool) -> T.ContextManager: - """ Sets the backend session options. - - For CPU backends, this hides any GPUs from Tensorflow. - - For Nvidia backends, this hides any GPUs that Tensorflow should not use and applies - any allow growth settings - - Parameters - ---------- - allow_growth: bool - Enable the Tensorflow GPU allow_growth configuration option. This option prevents - Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation - and slower performance - exclude_gpus: list - A list of indices correlating to connected GPUs that Tensorflow should not use. Pass - ``None`` to not exclude any GPUs - cpu_mode: bool - ``True`` run the model on CPU. Default: ``False`` - """ - retval = nullcontext() - if self._backend == "cpu": - logger.verbose("Hiding GPUs from Tensorflow") # type:ignore - tf.config.set_visible_devices([], "GPU") - return retval - - gpus = tf.config.list_physical_devices('GPU') - if exclude_gpus: - gpus = [gpu for idx, gpu in enumerate(gpus) if idx not in exclude_gpus] - logger.debug("Filtering devices to: %s", gpus) - tf.config.set_visible_devices(gpus, "GPU") - - if allow_growth and self._backend == "nvidia": - for gpu in gpus: - logger.info("Setting allow growth for GPU: %s", gpu) - tf.config.experimental.set_memory_growth(gpu, True) - - if cpu_mode: - retval = tf.device("/device:cpu:0") - return retval - - def load_model(self) -> None: - """ Loads a model. - - This method is a wrapper for :func:`keras.models.load_model()`. Loads a model and its - weights from :attr:`model_path` defined during initialization of this class. Any additional - ``kwargs`` to be passed to :func:`keras.models.load_model()` should also be defined during - initialization of the class. - - For Tensorflow backends, the `make_predict_function` method is called on the model to make - it thread safe. - """ - logger.verbose("Initializing plugin model: %s", self._name) # type:ignore - with self._context: - self._model = k_load_model(self._model_path, compile=False, **self._model_kwargs) - self._model.make_predict_function() - - def define_model(self, function: Callable) -> None: - """ Defines a model from the given function. - - This method acts as a wrapper for :class:`keras.models.Model()`. - - Parameters - ---------- - function: function - A function that defines a :class:`keras.Model` and returns it's ``inputs`` and - ``outputs``. The function that generates these results should be passed in, NOT the - results themselves, as the function needs to be executed within the correct context. - """ - with self._context: - self._model = Model(*function()) - - def load_model_weights(self) -> None: - """ Load model weights for a defined model inside the correct session. - - This method is a wrapper for :class:`keras.load_weights()`. Once a model has been defined - in :func:`define_model()` this method can be called to load its weights from the - :attr:`model_path` defined during initialization of this class. - - For Tensorflow backends, the `make_predict_function` method is called on the model to make - it thread safe. - """ - logger.verbose("Initializing plugin model: %s", self._name) # type:ignore - assert self._model is not None - with self._context: - self._model.load_weights(self._model_path) - self._model.make_predict_function() - - def append_softmax_activation(self, layer_index: int = -1) -> None: - """ Append a softmax activation layer to a model - - Occasionally a softmax activation layer needs to be added to a model's output. - This is a convenience function to append this layer to the loaded model. - - Parameters - ---------- - layer_index: int, optional - The layer index of the model to select the output from to use as an input to the - softmax activation layer. Default: `-1` (The final layer of the model) - """ - logger.debug("Appending Softmax Activation to model: (layer_index: %s)", layer_index) - assert self._model is not None - with self._context: - softmax = Activation("softmax", name="softmax")(self._model.layers[layer_index].output) - self._model = Model(inputs=self._model.input, outputs=[softmax]) diff --git a/lib/multithreading.py b/lib/multithreading.py index a2c4300d44..e20862e643 100644 --- a/lib/multithreading.py +++ b/lib/multithreading.py @@ -10,6 +10,8 @@ import threading from types import TracebackType +from lib.utils import get_module_objects + if T.TYPE_CHECKING: from collections.abc import Callable, Generator @@ -298,3 +300,6 @@ def iterator(self) -> Generator: logger.debug("Got EOF OR NONE in BackgroundGenerator") break yield next_item + + +__all__ = get_module_objects(__name__) diff --git a/lib/queue_manager.py b/lib/queue_manager.py index 9fd5122aa4..1dfee26e9f 100644 --- a/lib/queue_manager.py +++ b/lib/queue_manager.py @@ -10,6 +10,8 @@ from queue import Queue, Empty as QueueEmpty # pylint:disable=unused-import; # noqa from time import sleep +from lib.utils import get_module_objects + logger = logging.getLogger(__name__) @@ -29,7 +31,7 @@ def __init__(self, shutdown_event: threading.Event, maxsize: int = 0) -> None: self._shutdown = shutdown_event @property - def shutdown(self) -> threading.Event: + def shutdown_event(self) -> threading.Event: """ :class:`threading.Event`: The global shutdown event """ return self._shutdown @@ -175,3 +177,6 @@ def _debug_queue_sizes(self, update_interval) -> None: queue_manager = _QueueManager() # pylint:disable=invalid-name + + +__all__ = get_module_objects(__name__) diff --git a/lib/serializer.py b/lib/serializer.py index ab48ec129f..0ab2277440 100644 --- a/lib/serializer.py +++ b/lib/serializer.py @@ -13,7 +13,7 @@ import numpy as np -from lib.utils import FaceswapError +from lib.utils import FaceswapError, get_module_objects try: import yaml @@ -278,6 +278,7 @@ def get_serializer(serializer): ------- >>> serializer = get_serializer('json') """ + retval = None if serializer.lower() == "npy": retval = _NPYSerializer() elif serializer.lower() == "compressed": @@ -339,3 +340,6 @@ def get_serializer_from_filename(filename): retval = _JSONSerializer() logger.debug(retval) return retval + + +__all__ = get_module_objects(__name__) diff --git a/lib/system/__init__.py b/lib/system/__init__.py new file mode 100644 index 0000000000..59b7bf0962 --- /dev/null +++ b/lib/system/__init__.py @@ -0,0 +1,5 @@ +#! /usr/env/bin/python3 +""" Contains system information for error reporting and installation.""" + +from .system import Packages, System +from .ml_libs import Cuda, ROCm diff --git a/lib/system/ml_libs.py b/lib/system/ml_libs.py new file mode 100644 index 0000000000..d0c956db30 --- /dev/null +++ b/lib/system/ml_libs.py @@ -0,0 +1,998 @@ +#! /usr/env/bin/python +""" +Queries information about system installed Machine Learning Libraries. +NOTE: Only packages from Python's Standard Library should be imported in this module +""" +from __future__ import annotations + +import json +import logging +import os +import platform +import re +import typing as T + +from abc import ABC, abstractmethod +from shutil import which + +from lib.utils import get_module_objects + +from .system import _lines_from_command + +if platform.system() == "Windows": + import winreg # pylint:disable=import-error +else: + winreg = None # type:ignore[assignment] # pylint:disable=invalid-name + +if T.TYPE_CHECKING: + from winreg import HKEYType # type:ignore[attr-defined] + +logger = logging.getLogger(__name__) + + +_TORCH_ROCM_REQUIREMENTS = {">=2.2.1,<2.4.0": ((6, 0), (6, 0))} +"""dict[str, tuple[tuple[int, int], tuple[int, int]]]: Minumum and maximum ROCm versions """ + + +def _check_dynamic_linker(lib: str) -> list[str]: + """ Locate the folders that contain a given library in ldconfig and $LD_LIBRARY_PATH + + Parameters + ---------- + lib: str The library to locate + + Returns + ------- + list[str] + All real existing folders from ldconfig or $LD_LIBRARY_PATH that contain the given lib + """ + paths: set[str] = set() + ldconfig = which("ldconfig") + if ldconfig: + paths.update({os.path.realpath(os.path.dirname(line.split("=>")[-1].strip())) + for line in _lines_from_command([ldconfig, "-p"]) + if lib in line and "=>" in line}) + + if not os.environ.get("LD_LIBRARY_PATH"): + return list(paths) + + paths.update({os.path.realpath(path) + for path in os.environ["LD_LIBRARY_PATH"].split(":") + if path and os.path.exists(path) + for fname in os.listdir(path) + if lib in fname}) + return list(paths) + + +def _files_from_folder(folder: str, prefix: str) -> list[str]: + """ Obtain all filenames from the given folder that start with the given prefix + + Parameters + ---------- + folder : str + The folder to search for files in + prefix : str + The filename prefix to search for + + Returns + ------- + list[str] + All filenames that exist in the given folder with the given prefic + """ + if not os.path.exists(folder): + return [] + return [f for f in os.listdir(folder) if f.startswith(prefix)] + + +class _Alternatives: + """ Holds output from the update-alternatives command for the given package + + Parameters + ---------- + package : str + The package to query update-alternatives for information + """ + def __init__(self, package: str) -> None: + self._package = package + self._bin = which("update-alternatives") + self._default_marker = "link currently points to" + self._alternatives_marker = "priority" + self._output: list[str] | None = None + + @property + def alternatives(self) -> list[str]: + """ list[str] : Full path to alternatives listed for the given package """ + if self._output is None: + self._query() + if not self._output: + return [] + retval = [line.rsplit(" - ", maxsplit=1)[0] for line in self._output + if self._alternatives_marker in line.lower()] + logger.debug("Versions from 'update-alternatives' for '%s': %s", self._package, retval) + return retval + + @property + def default(self) -> str: + """ str : Full path to the default package """ + if self._output is None: + self._query() + if not self._output: + return "" + retval = next((x for x in self._output + if x.startswith(self._default_marker)), "").replace(self._default_marker, + "").strip() + logger.debug("Default from update-alternatives for '%s': %s", self._package, retval) + return retval + + def _query(self) -> None: + """ Query update-alternatives for the given package and place stripped output into + :attr:`_output` """ + if not self._bin: + self._output = [] + return + cmd = [self._bin, "--display", self._package] + retval = [line.strip() for line in _lines_from_command(cmd)] + logger.debug("update-alternatives output for command %s: %s", + cmd, retval) + self._output = retval + + +class _Cuda(ABC): + """ Find the location of system installed Cuda and cuDNN on Windows and Linux. """ + def __init__(self) -> None: + self.versions: list[tuple[int, int]] = [] + """ list[tuple[int, int]] : All detected globally installed Cuda versions """ + self.version: tuple[int, int] = (0, 0) + """ tuple[int, int] : Default installed Cuda version. (0, 0) if not detected """ + self.cudnn_versions: dict[tuple[int, int], tuple[int, int, int]] = {} + """ dict[tuple[int, int], tuple[int, int, int]] : Detected cuDNN version for each installed + Cuda. key (0, 0) denotes globally installed cudnn """ + self._paths: list[str] = [] + """ list[str] : list of path to Cuda install folders relating to :attr:`versions` """ + + self._version_file = "version.json" + self._lib = "libcudart.so" + self._cudnn_header = "cudnn_version.h" + self._alternatives = _Alternatives("cuda") + self._re_cudnn = re.compile(r"#define CUDNN_(MAJOR|MINOR|PATCHLEVEL)\s+(\d+)") + + if platform.system() in ("Windows", "Linux"): + self._get_versions() + self._get_version() + self._get_cudnn_versions() + + def __repr__(self) -> str: + """ Pretty representation of this class """ + attrs = ", ".join(f"{k}={repr(v)}" for k, v in self.__dict__.items() + if not k.startswith("_")) + return f"{self.__class__.__name__}({attrs})" + + @classmethod + def _tuple_from_string(cls, version: str) -> tuple[int, int] | None: + """ Convert a Cuda version string to a version tuple + + Parameters + ---------- + version : str + The Cuda version string to convert + + Returns + ------- + tuple[int, int] | None + The converted Cuda version string. ``None`` if not a valid version string + """ + if version.startswith("."): + version = version[1:] + split = version.split(".") + if len(split) not in (2, 3): + return None + split = split[:2] + if not all(x.isdigit() for x in split): + return None + return (int(split[0]), int(split[1])) + + @abstractmethod + def get_versions(self) -> dict[tuple[int, int], str]: + """ Overide to Attempt to detect all installed Cuda versions on Linux or Windows systems + + Returns + ------- + dict[tuple[int, int], str] + The Cuda versions to the folder path on the system + """ + + @abstractmethod + def get_version(self) -> tuple[int, int] | None: + """ Override to attempt to locate the default Cuda version on Linux or Windows + + Returns + ------- + tuple[int, int] | None + The Default global Cuda version or ``None`` if not found + """ + + @abstractmethod + def get_cudnn_versions(self) -> dict[tuple[int, int], tuple[int, int, int]]: + """ Override to attempt to locate any installed cuDNN versions + + Returns + ------- + dict[tuple[int, int], tuple[int, int, int]] + Detected cuDNN version for each installed Cuda. key (0, 0) denotes globally installed + cudnn + """ + + def version_from_version_file(self, folder: str) -> tuple[int, int] | None: + """ Attempt to get an installed Cuda version from its version.json file + + Parameters + ---------- + folder : str + Full path to the folder to check for a version file + + Returns + ------- + tuple[int, int] | None + The detected Cuda version or ``None`` if not detected + """ + vers_file = os.path.join(folder, self._version_file) + if not os.path.exists(vers_file): + return None + with open(vers_file, "r", encoding="utf-8", errors="replace") as f: + vers = json.load(f) + retval = self._tuple_from_string(vers.get("cuda_cudart", {}).get("version")) + logger.debug("Version from '%s': %s", vers_file, retval) + return retval + + def _version_from_nvcc(self) -> tuple[int, int] | None: + """ Obtain the version from NVCC output if it is on PATH + + Returns + ------- + tuple[int, int] | None + The detected default Cuda version. ``None`` if not version detected + """ + retval = None + nvcc = which("nvcc") + if not nvcc: + return retval + + for line in _lines_from_command([nvcc, "-V"]): + vers = re.match(r".*release (\d+\.\d+)", line) + if vers is not None: + retval = self._tuple_from_string(vers.group(1)) + break + logger.debug("Version from NVCC '%s': %s", nvcc, retval) + return retval + + def _get_versions(self) -> None: + """ Attempt to detect all installed Cuda versions and populate to :attr:`versions` """ + versions = self.get_versions() + if versions: + logger.debug("Cuda Versions: %s", versions) + self.versions = list(versions) + self._paths = list(versions.values()) + return + logger.debug("Could not locate any Cuda versions") + + def _get_version(self) -> None: + """ Attempt to detect the default Cuda version and populate to :attr:`version` """ + version: tuple[int, int] | None = None + if len(self.versions) == 1: + version = self.versions[0] + logger.debug("Only 1 installed Cuda version: %s", version) + if not version: + version = self._version_from_nvcc() + if not version: + version = self.get_version() + if version: + self.version = version + logger.debug("Cuda version: %s", self.version if version else "not detected") + + def _get_cudnn_versions(self) -> None: + """ Attempt to locate any installed cuDNN versions and add to :attr`cudnn_versions` """ + versions = self.get_cudnn_versions() + if versions: + logger.debug("cudnn versions: %s", versions) + self.cudnn_versions = versions + return + logger.debug("No cudnn versions found") + + def cudnn_version_from_header(self, folder: str) -> tuple[int, int, int] | None: + """ Attempt to detect the cuDNN version from the version header file + + Parameters + ---------- + folder : str + The folder to check for the cuDNN header file + + Returns + ------- + tuple[int, int, int] | None + The cuDNN version found from the given folder or ``None`` if not detected + """ + path = os.path.join(folder, self._cudnn_header) + if not os.path.exists(path): + logger.debug("cudnn file '%s' does not exist", path) + return None + + with open(path, "r", encoding="utf-8", errors="ignore") as f: + file = f.read() + version = {v[0]: int(v[1]) if v[1].isdigit() else 0 + for v in self._re_cudnn.findall(file)} + if not version: + logger.debug("cudnn version could not be found in '%s'", path) + return None + + logger.debug("cudnn version from '%s': %s", path, version) + retval = (version.get("MAJOR", 0), version.get("MINOR", 0), version.get("PATCHLEVEL", 0)) + logger.debug("cudnn versions: %s", retval) + return retval + + +class CudaLinux(_Cuda): + """ Find the location of system installed Cuda and cuDNN on Linux. """ + def __init__(self) -> None: + self._folder_prefix = "cuda-" + super().__init__() + + def _version_from_lib(self, folder: str) -> tuple[int, int] | None: + """ Attempt to locate the version from the existence of libcudart.so within a Cuda + targets/x86_64-linux/lib folder + + Parameters + ---------- + folder : str + Full file path to the Cuda folder + + Returns + ------- + tuple[int, int] | None + The Cuda version identified by the existence of the libcudart.so file. ``None`` if + not detected + """ + lib_folder = os.path.join(folder, "targets", "x86_64-linux", "lib") + lib_versions = [f.replace(self._lib, "") + for f in _files_from_folder(lib_folder, self._lib)] + if not lib_versions: + return None + versions = [self._tuple_from_string(f[1:]) + for f in lib_versions if f and f.startswith(".")] + valid = [v for v in versions if v is not None] + if not valid or not len(set(valid)) == 1: + return None + retval = valid[0] + logger.debug("Version from '%s': %s", os.path.join(lib_folder, self._lib), retval) + return retval + + def _versions_from_usr(self) -> dict[tuple[int, int], str]: + """ Attempt to detect all installed Cuda versions from the /usr/local folder + + Scan /usr/local for cuda-x.x folders containing either a version.json file or + include/lib/libcudart.so.x. + + Returns + ------- + dict[tuple[int, int], str] + A dictionary of detected Cuda versions to their install paths + """ + retval: dict[tuple[int, int], str] = {} + usr = os.path.join(os.sep, "usr", "local") + + for folder in _files_from_folder(usr, self._folder_prefix): + path = os.path.join(usr, folder) + if os.path.islink(path): + continue + version = self.version_from_version_file(path) or self._version_from_lib(path) + if version is not None: + retval[version] = path + return retval + + def _versions_from_alternatives(self) -> dict[tuple[int, int], str]: + """ Attempt to detect all installed Cuda versions from update-alternatives + + Returns + ------- + list[tuple[int, int, int]] + A dictionary of detected Cuda versions to their install paths found in + update-alternatives + """ + retval: dict[tuple[int, int], str] = {} + alts = self._alternatives.alternatives + for path in alts: + vers = self.version_from_version_file(path) or self._version_from_lib(path) + if vers is not None: + retval[vers] = path + logger.debug("Versions from 'update-alternatives': %s", retval) + return retval + + def _parent_from_targets(self, folder: str) -> str: + """ Obtain the Cuda parent folder from a path obtained from child targets folder + + Parameters + ---------- + folder : str + Full path to a folder that has a 'targets' folder in its path + + Returns + ------- + str + The potential parent Cuda folder, or an empty string if not detected + """ + split = folder.split(os.sep) + return os.sep.join(split[:split.index("targets")]) if "targets" in split else "" + + def _versions_from_dynamic_linker(self) -> dict[tuple[int, int], str]: + """ Attempt to detect all installed Cuda versions from ldconfig + + Returns + ------- + dict[tuple[int, int], str] + The Cuda version to the folder path found from ldconfig + """ + retval: dict[tuple[int, int], str] = {} + folders = _check_dynamic_linker(self._lib) + cuda_roots = [self._parent_from_targets(f) for f in folders] + for path in cuda_roots: + if not path: + continue + version = self.version_from_version_file(path) or self._version_from_lib(path) + if version is not None: + retval[version] = path + + logger.debug("Versions from 'ld_config': %s", retval) + return retval + + def get_versions(self) -> dict[tuple[int, int], str]: + """ Attempt to detect all installed Cuda versions on Linux systems + + Returns + ------- + dict[tuple[int, int], str] + The Cuda version to the folder path on Linux + """ + versions = (self._versions_from_usr() | + self._versions_from_alternatives() | + self._versions_from_dynamic_linker()) + return {k: versions[k] for k in sorted(versions)} + + def _version_from_alternatives(self) -> tuple[int, int] | None: + """ Attempt to get the default Cuda version from update-alternatives + + Returns + ------- + tuple[int, int] | None + The detected default Cuda version. ``None`` if not version detected + """ + default = self._alternatives.default + if not default: + return None + retval = self.version_from_version_file(default) or self._version_from_lib(default) + logger.debug("Version from update-alternatives: %s", retval) + return retval + + def _version_from_link(self) -> tuple[int, int] | None: + """ Attempt to get the default Cuda version from the /usr/local/cuda file + + Returns + ------- + tuple[int, int] | None + The detected default Cuda version. ``None`` if not version detected + """ + path = os.path.join(os.sep, "usr", "local", "cuda") + if not os.path.exists(path): + return None + real_path = os.path.abspath(os.path.realpath(path)) if os.path.islink(path) else path + retval = self.version_from_version_file(real_path) or self._version_from_lib(real_path) + logger.debug("Version from symlink: %s", retval) + return retval + + def _version_from_dynamic_linker(self) -> tuple[int, int] | None: + """ Attempt to get the default version from ldconfig or $LD_LIBRARY_PATH + + Returns + ------- + tuple[int, int, int] | None + The detected default ROCm version. ``None`` if not version detected + """ + paths = _check_dynamic_linker(self._lib) + if len(paths) != 1: # Multiple or None + return None + root = self._parent_from_targets(paths[0]) + retval = self.version_from_version_file(root) or self._version_from_lib(root) + logger.debug("Version from ld_config: %s", retval) + return retval + + def get_version(self) -> tuple[int, int] | None: + """ Attempt to locate the default Cuda version on Linux + + Checks, in order: update-alternatives, /usr/local/cuda, ldconfig, nvcc + + Returns + ------- + tuple[int, int] | None + The Default global Cuda version or ``None`` if not found + """ + return (self._version_from_alternatives() or + self._version_from_link() or + self._version_from_dynamic_linker()) + + def get_cudnn_versions(self) -> dict[tuple[int, int], tuple[int, int, int]]: + """ Attempt to locate any installed cuDNN versions on Linux + + Returns + ------- + dict[tuple[int, int], tuple[int, int, int]] + Detected cuDNN version for each installed Cuda. key (0, 0) denotes globally installed + cudnn + """ + retval: dict[tuple[int, int], tuple[int, int, int]] = {} + gbl = ["/usr/include", "/usr/local/include"] + lcl = [os.path.join(f, "include") for f in self._paths] + for root in gbl + lcl: + for folder, _, filenames in os.walk(root): + if self._cudnn_header not in filenames: + continue + version = self.cudnn_version_from_header(folder) + if not version: + continue + cuda_vers = ((0, 0) if root in gbl + else self.versions[self._paths.index(os.path.dirname(root))]) + retval[cuda_vers] = version + return retval + + +class CudaWindows(_Cuda): + """ Find the location of system installed Cuda and cuDNN on Windows. """ + + @classmethod + def _enum_subkeys(cls, key: HKEYType) -> T.Generator[str, None, None]: + """ Iterate through a Registry key's sub-keys + + Parameters + ---------- + key : :class:`winreg.HKEYType` + The Registry key to iterate + + Yields + ------ + str + A sub-key name from the given registry key + """ + assert winreg is not None + i = 0 + while True: + try: + yield winreg.EnumKey(key, i) # type:ignore[attr-defined] + except OSError: + break + i += 1 + + def get_versions(self) -> dict[tuple[int, int], str]: + """ Attempt to detect all installed Cuda versions on Windows systems from the registry + + Returns + ------- + dict[tuple[int, int], str] + The Cuda version to the folder path on Windows + """ + retval: dict[tuple[int, int], str] = {} + assert winreg is not None + reg_key = r"SOFTWARE\NVIDIA Corporation\GPU Computing Toolkit\CUDA" + paths = {k.lower().replace("cuda_path_", "").replace("_", "."): v + for k, v in os.environ.items() + if "cuda_path_v" in k.lower()} + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, # type:ignore[attr-defined] + reg_key) as key: + for version in self._enum_subkeys(key): + vers_tuple = self._tuple_from_string(version[1:]) + if vers_tuple is not None: + retval[vers_tuple] = paths.get(version, "") + except FileNotFoundError: + logger.debug("Could not find Windows Registry key '%s'", reg_key) + return {k: retval[k] for k in sorted(retval)} + + def get_version(self) -> tuple[int, int] | None: + """ Attempt to get the default Cuda version from the Environment Variable + + Returns + ------- + tuple[int, int] | None + The Default global Cuda version or ``None`` if not found + """ + path = os.environ.get("CUDA_PATH") + if not path or path not in self._paths: + return None + + retval = self.versions[self._paths.index(path)] + logger.debug("Version from CUDA_PATH Environment Variable: %s", path) + return retval + + def _get_cudnn_paths(self) -> list[str]: # noqa[C901] + """ Attempt to locate the locations of cuDNN installs for Windows + + Returns + ------- + list[str] + Full path to existing cuDNN installs under Windows + """ + assert winreg is not None + paths: set[str] = set() + cudnn_key = "cudnn_cuda" + reg_key = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + lookups = (winreg.HKEY_LOCAL_MACHINE, # type:ignore[attr-defined] + winreg.HKEY_CURRENT_USER) # type:ignore[attr-defined] + for lookup in lookups: + try: + key = winreg.OpenKey(lookup, reg_key) # type:ignore[attr-defined] + except FileNotFoundError: + continue + for name in self._enum_subkeys(key): + if cudnn_key not in name.lower(): + logger.debug("Skipping subkey '%s'", name) + continue + try: + subkey = winreg.OpenKey(key, name) # type:ignore[attr-defined] + logger.debug("Skipping subkey not found '%s'", name) + except FileNotFoundError: + continue + logger.debug("Parsing cudnn key '%s'", cudnn_key) + try: + path, _ = winreg.QueryValueEx(subkey, # type:ignore[attr-defined] + "InstallLocation") + except (FileNotFoundError, OSError): + logger.debug("Skipping missing InstallLocation for sub-key '%s'", subkey) + continue + if not os.path.isdir(path): + logger.debug("Skipping non-existant path '%s'", path) + continue + paths.add(path) + retval = list(paths) + logger.debug("cudnn install paths: %s", retval) + return retval + + def get_cudnn_versions(self) -> dict[tuple[int, int], tuple[int, int, int]]: + """ Attempt to locate any installed cuDNN versions on Windows + + Returns + ------- + dict[tuple[int, int], tuple[int, int, int]] + Detected cuDNN version for each installed Cuda. key (0, 0) denotes globally installed + cudnn + """ + retval: dict[tuple[int, int], tuple[int, int, int]] = {} + gbl = self._get_cudnn_paths() + lcl = [os.path.join(f, "include") for f in self._paths] + for root in gbl + lcl: + for folder, _, filenames in os.walk(root): + if self._cudnn_header not in filenames: + continue + version = self.cudnn_version_from_header(folder) + if not version: + continue + cuda_vers = ((0, 0) if root in gbl + else self.versions[self._paths.index(os.path.dirname(root))]) + retval[cuda_vers] = version + return retval + + +def get_cuda_finder() -> type[_Cuda]: + """Create a platform-specific CUDA object. + + Returns + ------- + type[_Cuda] + The OS specific finder for system-wide Cuda + """ + if platform.system().lower() == "windows": + return CudaWindows + return CudaLinux + + +Cuda = get_cuda_finder() + + +class ROCm(): + """ Find the location of system installed ROCm on Linux """ + def __init__(self) -> None: + self.version_min = min(v[0] for v in _TORCH_ROCM_REQUIREMENTS.values()) + self.version_max = max(v[1] for v in _TORCH_ROCM_REQUIREMENTS.values()) + self.versions: list[tuple[int, int, int]] = [] + """ list[tuple[int, int, int]] : All detected ROCm installed versions """ + self.version: tuple[int, int, int] = (0, 0, 0) + """ tuple[int, int, int] : Default ROCm installed version. (0, 0, 0) if not detected """ + + self._folder_prefix = "rocm-" + self._version_files = ["version-rocm", "version"] + self._lib = "librocm-core.so" + self._alternatives = _Alternatives("rocm") + self._re_version = re.compile(r"(\d+\.\d+\.\d+)(?=$|[-.])") + self._re_config = re.compile(r"\sroc-(\d+\.\d+\.\d+)(?=\s|[-.])") + if platform.system() == "Linux": + self._rocm_check() + + def __repr__(self) -> str: + """ Pretty representation of this class """ + attrs = ", ".join(f"{k}={repr(v)}" for k, v in self.__dict__.items() + if not k.startswith("_")) + return f"{self.__class__.__name__}({attrs})" + + @property + def valid_versions(self) -> list[tuple[int, int, int]]: + """ list[tuple[int, int, int]] """ + return [v for v in self.versions if self.version_min <= v[:2] <= self.version_max] + + @property + def valid_installed(self) -> bool: + """ bool : ``True`` if a valid version of ROCm is installed """ + return any(self.valid_versions) + + @property + def is_valid(self): + """ bool : ``True`` if the default ROCm version is valid """ + return self.version_min <= self.version[:2] <= self.version_max + + @classmethod + def _tuple_from_string(cls, version: str) -> tuple[int, int, int] | None: + """ Convert a ROCm version string to a version tuple + + Parameters + ---------- + version : str + The ROCm version string to convert + + Returns + ------- + tuple[int, int, int] | None + The converted ROCm version string. ``None`` if not a valid version string + """ + split = version.split(".") + if len(split) != 3: + return None + if not all(x.isdigit() for x in split): + return None + return (int(split[0]), int(split[1]), int(split[2])) + + def _version_from_string(self, string: str) -> tuple[int, int, int] | None: + """ Obtain the ROCm version from the end of a string + + Parameters + ---------- + string : str + The string to test for a valid ROCm version + + Returns + ------- + tuple[int, int, int] | None + The ROCm version from the end of the string or ``None`` if not detected + """ + re_vers = self._re_version.search(string) + if re_vers is None: + return None + return self._tuple_from_string(re_vers.group(1)) + + def _version_from_info(self, folder: str) -> tuple[int, int, int] | None: + """ Attempt to locate the version from a version file within a ROCm .info folder + + Parameters + ---------- + file_path : str + Full path to the ROCm .info folder + + Returns + ------- + tuple[int, int, int] | None + The ROCm version extracted from a version file within the .info folder. ``None`` if + not detected + """ + info_loc = [os.path.join(folder, ".info", v) for v in self._version_files] + for info_file in info_loc: + if not os.path.exists(info_file): + continue + with open(info_file, "r", encoding="utf-8") as f: + vers_string = f.read().strip() + if not vers_string: + continue + retval = self._tuple_from_string(vers_string.split("-", maxsplit=1)[0]) + if retval is None: + continue + logger.debug("Version from '%s': %s", info_file, retval) + return retval + return None + + def _version_from_lib(self, folder: str) -> tuple[int, int, int] | None: + """ Attempt to locate the version from the existence of librocm-core.so within a ROCm + lib folder + + Parameters + ---------- + folder : str + Full file path to the ROCm folder + + Returns + ------- + tuple[int, int, int] | None + The ROCm version identified by the existence of the librocm-core.so file. ``None`` if + not detected + """ + lib_folder = os.path.join(folder, "lib") + lib_files = _files_from_folder(lib_folder, self._lib) + if not lib_files: + return None + + # librocm-core naming is librocm-core.so.1.0.##### which is ambiguous. Get from folder + rocm_folder = os.path.basename(folder) + if not rocm_folder.startswith(self._folder_prefix): + return None + retval = self._version_from_string(rocm_folder) + logger.debug("Version from '%s': %s", os.path.join(lib_folder, self._lib), retval) + return retval + + def _versions_from_opt(self) -> list[tuple[int, int, int]]: + """ Attempt to detect all installed ROCm versions from the /opt folder + + Scan /opt for rocm.x.x.x folders containing either .info or lib/librocm-core.so.x + + Returns + ------- + list[tuple[int, int, int]] + Any ROCm versions found in the /opt folder + """ + retval: list[tuple[int, int, int]] = [] + opt = os.path.join(os.sep, "opt") + + for folder in _files_from_folder(opt, self._folder_prefix): + path = os.path.join(opt, folder) + version = self._version_from_info(path) or self._version_from_lib(path) + if version is not None: + retval.append(version) + + return retval + + def _versions_from_alternatives(self) -> list[tuple[int, int, int]]: + """ Attempt to detect all installed ROCm versions from update-alternatives + + Returns + ------- + list[tuple[int, int, int]] + Any ROCm versions found in update-alternatives + """ + alts = self._alternatives.alternatives + if not alts: + return [] + versions = [self._version_from_string(c) for c in alts] + retval = list(set(v for v in versions if v is not None)) + logger.debug("Versions from 'update-alternatives': %s", retval) + return retval + + def _versions_from_dynamic_linker(self) -> list[tuple[int, int, int]]: + """ Attempt to detect all installed ROCm versions from ldconfig + + Returns + ------- + dict[tuple[int, int], str] + The ROCm versions found from ldconfig + """ + retval: list[tuple[int, int, int]] = [] + folders = _check_dynamic_linker(self._lib) + for folder in folders: + path = os.path.dirname(folder) + version = self._version_from_info(path) or self._version_from_lib(path) + if version is not None: + retval.append(version) + + logger.debug("Versions from 'ld_config': %s", retval) + return retval + + def _get_versions(self) -> None: + """ Attempt to detect all installed ROCm versions and populate to :attr:`rocm_versions` """ + versions = list(sorted(set(self._versions_from_opt()) | + set(self._versions_from_alternatives()) | + set(self._versions_from_dynamic_linker()))) + if versions: + logger.debug("ROCm Versions: %s", versions) + self.versions = versions + return + logger.debug("Could not locate any ROCm versions") + + def _version_from_hipconfig(self) -> tuple[int, int, int] | None: + """ Attempt to get the default version from hipconfig + + Returns + ------- + tuple[int, int, int] | None + The detected default ROCm version. ``None`` if not version detected + """ + retval: tuple[int, int, int] | None = None + exe = which("hipconfig") + if not exe: + return retval + lines = _lines_from_command([exe, "--full"]) + if not lines: + return retval + for line in lines: + line = line.strip() + if line.startswith("ROCM_PATH"): + path = line.split(":", maxsplit=1)[-1] + retval = self._version_from_info(path) or self._version_from_lib(path) + match = self._re_config.search(line) + + if match is not None: + retval = self._tuple_from_string(match.group(1)) + + logger.debug("Version from hipconfig: %s", retval) + return retval + + def _version_from_alternatives(self) -> tuple[int, int, int] | None: + """ Attempt to get the default version from update-alternatives + + Returns + ------- + tuple[int, int, int] | None + The detected default ROCm version. ``None`` if not version detected + """ + default = self._alternatives.default + if not default: + return None + retval = self._version_from_string(default.rsplit(os.sep, maxsplit=1)[-1]) + logger.debug("Version from update-alternatives: %s", retval) + return retval + + def _version_from_link(self) -> tuple[int, int, int] | None: + """ Attempt to get the default version from the /opt/rocm file + + Returns + ------- + tuple[int, int, int] | None + The detected default ROCm version. ``None`` if not version detected + """ + path = os.path.join(os.sep, "opt", "rocm") + if not os.path.exists(path): + return None + real_path = os.path.abspath(os.path.realpath(path)) if os.path.islink(path) else path + retval = self._version_from_info(real_path) or self._version_from_lib(real_path) + logger.debug("Version from symlink: %s", retval) + return retval + + def _version_from_dynamic_linker(self) -> tuple[int, int, int] | None: + """ Attempt to get the default version from ldconfig or $LD_LIBRARY_PATH + + Returns + ------- + tuple[int, int, int] | None + The detected default ROCm version. ``None`` if not version detected + """ + paths = _check_dynamic_linker("librocm-core.so.") + if len(paths) != 1: # Multiple or None + return None + path = os.path.dirname(paths[0]) + retval = self._version_from_info(path) or self._version_from_lib(path) + logger.debug("Version from ld_config: %s", retval) + return retval + + def _get_version(self) -> None: + """ Attempt to detect the default ROCm version """ + version = (self._version_from_hipconfig() or + self._version_from_alternatives() or + self._version_from_link() or + self._version_from_dynamic_linker()) + if version is not None: + logger.debug("ROCm default version: %s", version) + self.version = version + return + logger.debug("Could not locate default ROCm version") + + def _rocm_check(self) -> None: + """ Attempt to locate the installed ROCm versions and the default ROCm version """ + self._get_versions() + self._get_version() + logger.debug("ROCm Versions: %s, Version: %s", self.versions, self.version) + + +__all__ = get_module_objects(__name__) + + +if __name__ == "__main__": + print(Cuda()) + print(ROCm()) diff --git a/lib/sysinfo.py b/lib/system/sysinfo.py similarity index 69% rename from lib/sysinfo.py rename to lib/system/sysinfo.py index 7eda070361..28f067eda9 100644 --- a/lib/sysinfo.py +++ b/lib/system/sysinfo.py @@ -2,19 +2,23 @@ """ Obtain information about the running system, environment and GPU. """ import json -import locale import os import platform import sys from subprocess import PIPE, Popen -import psutil - from lib.git import git -from lib.gpu_stats import GPUStats, GPUInfo -from lib.utils import get_backend -from setup import CudaCheck +from lib.gpu_stats import GPUInfo, GPUStats +from lib.utils import get_backend, get_module_objects, PROJECT_ROOT + +from .ml_libs import Cuda, ROCm +from .system import Packages, System + +try: + import psutil +except ImportError: + psutil = None # type:ignore[assignment] class _SysInfo(): @@ -22,138 +26,112 @@ class _SysInfo(): def __init__(self) -> None: self._state_file = _State().state_file self._configs = _Configs().configs - self._system = {"platform": platform.platform(), - "system": platform.system().lower(), - "machine": platform.machine(), - "release": platform.release(), - "processor": platform.processor(), - "cpu_count": os.cpu_count()} + self._system = System() self._python = {"implementation": platform.python_implementation(), "version": platform.python_version()} + self._packages = Packages() self._gpu = self._get_gpu_info() - self._cuda_check = CudaCheck() - - @property - def _encoding(self) -> str: - """ str: The system preferred encoding """ - return locale.getpreferredencoding() - - @property - def _is_conda(self) -> bool: - """ bool: `True` if running in a Conda environment otherwise ``False``. """ - return ("conda" in sys.version.lower() or - os.path.exists(os.path.join(sys.prefix, 'conda-meta'))) - - @property - def _is_linux(self) -> bool: - """ bool: `True` if running on a Linux system otherwise ``False``. """ - return self._system["system"] == "linux" - - @property - def _is_macos(self) -> bool: - """ bool: `True` if running on a macOS system otherwise ``False``. """ - return self._system["system"] == "darwin" - - @property - def _is_windows(self) -> bool: - """ bool: `True` if running on a Windows system otherwise ``False``. """ - return self._system["system"] == "windows" - - @property - def _is_virtual_env(self) -> bool: - """ bool: `True` if running inside a virtual environment otherwise ``False``. """ - if not self._is_conda: - retval = (hasattr(sys, "real_prefix") or - (hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix)) - else: - prefix = os.path.dirname(sys.prefix) - retval = os.path.basename(prefix) == "envs" - return retval + self._cuda = Cuda() + self._rocm = ROCm() @property def _ram_free(self) -> int: - """ int: The amount of free RAM in bytes. """ + """ int : The amount of free RAM in bytes. """ + if psutil is None: + return -1 return psutil.virtual_memory().free @property def _ram_total(self) -> int: - """ int: The amount of total RAM in bytes. """ + """ int : The amount of total RAM in bytes. """ + if psutil is None: + return -1 return psutil.virtual_memory().total @property def _ram_available(self) -> int: - """ int: The amount of available RAM in bytes. """ + """ int : The amount of available RAM in bytes. """ + if psutil is None: + return -1 return psutil.virtual_memory().available @property def _ram_used(self) -> int: - """ int: The amount of used RAM in bytes. """ + """ int : The amount of used RAM in bytes. """ + if psutil is None: + return -1 return psutil.virtual_memory().used @property def _fs_command(self) -> str: - """ str: The command line command used to execute faceswap. """ + """ str : The command line command used to execute faceswap. """ return " ".join(sys.argv) - @property - def _installed_pip(self) -> str: - """ str: The list of installed pip packages within Faceswap's scope. """ - with Popen(f"{sys.executable} -m pip freeze", shell=True, stdout=PIPE) as pip: - installed = pip.communicate()[0].decode(self._encoding, errors="replace").splitlines() - return "\n".join(installed) - - @property - def _installed_conda(self) -> str: - """ str: The list of installed Conda packages within Faceswap's scope. """ - if not self._is_conda: - return "" - with Popen("conda list", shell=True, stdout=PIPE, stderr=PIPE) as conda: - stdout, stderr = conda.communicate() - if stderr: - return "Could not get package list" - installed = stdout.decode(self._encoding, errors="replace").splitlines() - return "\n".join(installed) - @property def _conda_version(self) -> str: - """ str: The installed version of Conda, or `N/A` if Conda is not installed. """ - if not self._is_conda: + """ str : The installed version of Conda, or `N/A` if Conda is not installed. """ + if not self._system.is_conda: return "N/A" with Popen("conda --version", shell=True, stdout=PIPE, stderr=PIPE) as conda: stdout, stderr = conda.communicate() if stderr: return "Conda is used, but version not found" - version = stdout.decode(self._encoding, errors="replace").splitlines() + version = stdout.decode(self._system.encoding, errors="replace").splitlines() return "\n".join(version) @property def _git_commits(self) -> str: - """ str: The last 5 git commits for the currently running Faceswap. """ + """ str : The last 5 git commits for the currently running Faceswap. """ commits = git.get_commits(3) if not commits: return "Not Found" return " | ".join(commits) + @property + def _cuda_versions(self) -> str: + """ str : The globally installed Cuda versions""" + if not self._cuda.versions: + return "No global Cuda versions found" + return ", ".join(".".join(str(x) for x in v) for v in self._cuda.versions) + @property def _cuda_version(self) -> str: - """ str: The installed CUDA version. """ - # TODO Handle multiple CUDA installs - retval = self._cuda_check.cuda_version - if not retval: + """ str : The installed CUDA version. """ + if self._cuda.version == (0, 0): retval = "No global version found" - if self._is_conda: + if self._system.is_conda: retval += ". Check Conda packages for Conda Cuda" - return retval + return retval + return ".".join(str(x) for x in self._cuda.version) @property - def _cudnn_version(self) -> str: - """ str: The installed cuDNN version. """ - retval = self._cuda_check.cudnn_version - if not retval: + def _cudnn_versions(self) -> str: + """ str : The installed cuDNN versions. """ + if not self._cuda.cudnn_versions: retval = "No global version found" - if self._is_conda: + if self._system.is_conda: retval += ". Check Conda packages for Conda cuDNN" - return retval + return retval + retval = "" + for k, v in self._cuda.cudnn_versions.items(): + retval += f"{'.'.join(str(x) for x in v)}" + retval += f"({'global' if k == (0, 0) else '.'.join(str(x) for x in k)}), " + + return retval[:-2] + + @property + def _rocm_version(self) -> str: + """ str : The default ROCm version """ + if self._rocm.version == (0, 0, 0): + return "No default ROCm version found" + return ".".join(str(x) for x in self._rocm.version) + + @property + def _rocm_versions(self) -> str: + """ str : The installed ROCm versions """ + if not self._rocm.versions: + return "No ROCm versions found" + return ", ".join(".".join(str(x) for x in v) for v in self._rocm.versions) def _get_gpu_info(self) -> GPUInfo: """ Obtain GPU Stats. If an error is raised, swallow the error, and add to GPUInfo output @@ -163,6 +141,12 @@ def _get_gpu_info(self) -> GPUInfo: :class:`~lib.gpu_stats.GPUInfo` The information on connected GPUs """ + if GPUStats is None: + return GPUInfo(vram=[], + vram_free=[], + driver="N/A", + devices=["Error obtaining GPU Stats: 'GPUStats import error'"], + devices_active=[]) try: retval = GPUStats(log=False).sys_info except Exception as err: # pylint:disable=broad-except @@ -174,6 +158,21 @@ def _get_gpu_info(self) -> GPUInfo: devices_active=[]) return retval + def _format_ram(self) -> str: + """ Format the RAM stats into Megabytes to make it more readable. + + Returns + ------- + str + The total, available, used and free RAM displayed in Megabytes + """ + retval = [] + for name in ("total", "available", "used", "free"): + value = getattr(self, f"_ram_{name}") + value = int(value / (1024 * 1024)) + retval.append(f"{name.capitalize()}: {value}MB") + return ", ".join(retval) + def full_info(self) -> str: """ Obtain extensive system information stats, formatted into a human readable format. @@ -185,22 +184,25 @@ def full_info(self) -> str: """ retval = "\n============ System Information ============\n" sys_info = {"backend": get_backend(), - "os_platform": self._system["platform"], - "os_machine": self._system["machine"], - "os_release": self._system["release"], + "os_platform": self._system.platform, + "os_machine": self._system.machine, + "os_release": self._system.release, "py_conda_version": self._conda_version, - "py_implementation": self._python["implementation"], - "py_version": self._python["version"], + "py_implementation": self._system.python_implementation, + "py_version": self._system.python_version, "py_command": self._fs_command, - "py_virtual_env": self._is_virtual_env, - "sys_cores": self._system["cpu_count"], - "sys_processor": self._system["processor"], + "py_virtual_env": self._system.is_virtual_env, + "sys_cores": self._system.cpu_count, + "sys_processor": self._system.processor, "sys_ram": self._format_ram(), - "encoding": self._encoding, + "encoding": self._system.encoding, "git_branch": git.branch, "git_commits": self._git_commits, + "gpu_cuda_versions": self._cuda_versions, "gpu_cuda": self._cuda_version, - "gpu_cudnn": self._cudnn_version, + "gpu_cudnn": self._cudnn_versions, + "gpu_rocm_versions": self._rocm_versions, + "gpu_rocm_version": self._rocm_version, "gpu_driver": self._gpu.driver, "gpu_devices": ", ".join([f"GPU_{idx}: {device}" for idx, device in enumerate(self._gpu.devices)]), @@ -213,30 +215,15 @@ def full_info(self) -> str: for key in sorted(sys_info.keys()): retval += (f"{key + ':':<20} {sys_info[key]}\n") retval += "\n=============== Pip Packages ===============\n" - retval += self._installed_pip - if self._is_conda: + retval += self._packages.installed_python_pretty + if self._system.is_conda: retval += "\n\n============== Conda Packages ==============\n" - retval += self._installed_conda + retval += self._packages.installed_conda_pretty retval += self._state_file retval += "\n\n================= Configs ==================" retval += self._configs return retval - def _format_ram(self) -> str: - """ Format the RAM stats into Megabytes to make it more readable. - - Returns - ------- - str - The total, available, used and free RAM displayed in Megabytes - """ - retval = [] - for name in ("total", "available", "used", "free"): - value = getattr(self, f"_ram_{name}") - value = int(value / (1024 * 1024)) - retval.append(f"{name.capitalize()}: {value}MB") - return ", ".join(retval) - def get_sysinfo() -> str: """ Obtain extensive system information stats, formatted into a human readable format. @@ -262,7 +249,7 @@ class _Configs(): # pylint:disable=too-few-public-methods in a human readable format. """ def __init__(self) -> None: - self.config_dir = os.path.join(os.path.abspath(os.path.dirname(sys.argv[0])), "config") + self.config_dir = os.path.join(PROJECT_ROOT, "config") self.configs = self._get_configs() def _get_configs(self) -> str: @@ -287,7 +274,7 @@ def _parse_configs(self, config_files: list[str]) -> str: Parameters ---------- - config_files: list + config_files : list[str] A list of paths to the faceswap config files Returns @@ -311,7 +298,7 @@ def _parse_ini(self, config_file: str) -> str: Parameters ---------- - config_file: str + config_file : str The path to the config.ini file Returns @@ -337,7 +324,7 @@ def _parse_json(self, config_file: str) -> str: Parameters ---------- - config_file: str + config_file : str The path to the config.json file Returns @@ -358,9 +345,9 @@ def _format_text(key: str, value: str) -> str: Parameters ---------- - key: str + key : str The label for this display item - value: str + value : str The value for this display item Returns @@ -381,7 +368,7 @@ def __init__(self) -> None: @property def _is_training(self) -> bool: - """ bool: ``True`` if this function has been called during a training session + """ bool : ``True`` if this function has been called during a training session otherwise ``False``. """ return len(sys.argv) > 1 and sys.argv[1].lower() == "train" @@ -397,7 +384,9 @@ def _get_arg(*args: str) -> str | None: cmd = sys.argv for opt in args: if opt in cmd: - return cmd[cmd.index(opt) + 1] + idx = cmd.index(opt) + 1 + if len(cmd) > idx: + return cmd[idx] return None def _get_state_file(self) -> str: @@ -421,3 +410,10 @@ def _get_state_file(self) -> str: sysinfo = get_sysinfo() # pylint:disable=invalid-name + + +__all__ = get_module_objects(__name__) + + +if __name__ == "__main__": + print(sysinfo) diff --git a/lib/system/system.py b/lib/system/system.py new file mode 100644 index 0000000000..9469fc1e68 --- /dev/null +++ b/lib/system/system.py @@ -0,0 +1,299 @@ +#! /usr/env/bin/python3 +""" +Holds information about the running system. Used in setup.py and lib.sysinfo +NOTE: Only packages from Python's Standard Library should be imported in this module +""" +from __future__ import annotations + +import ctypes +import locale +import logging +import os +import platform +import re +import sys +import typing as T + +from shutil import which +from subprocess import CalledProcessError, run + +from lib.utils import get_module_objects + +logger = logging.getLogger(__name__) + + +VALID_PYTHON = ((3, 11), (3, 13)) +""" tuple[tuple[int, int], tuple[int, int]] : The minimum and maximum versions of Python that can +run Faceswap """ +VALID_TORCH = ((2, 3), (2, 9)) +""" tuple[tuple[int, int], tuple[int, int]] : The minimum and maximum versions of Torch that can +run Faceswap """ +VALID_KERAS = ((3, 12), (3, 12)) +""" tuple[tuple[int, int], tuple[int, int]] : The minimum and maximum versions of Keras that can +run Faceswap """ + + +def _lines_from_command(command: list[str]) -> list[str]: + """ Output stdout lines from an executed command. + + Parameters + ---------- + command : list[str] + The command to run + + Returns + ------- + list[str] + The output lines from the given command + """ + logger.debug("Running command %s", command) + try: + proc = run(command, + capture_output=True, + check=True, + encoding=locale.getpreferredencoding(), + errors="replace") + except (FileNotFoundError, CalledProcessError) as err: + logger.debug("Error from command: %s", str(err)) + return [] + return proc.stdout.splitlines() + + +class System: # pylint:disable=too-many-instance-attributes + """ Holds information about the currently running system and environment """ + def __init__(self) -> None: + self.platform = platform.platform() + """ str : Human readable platform identifier """ + self.system: T.Literal["darwin", "linux", "windows"] = T.cast( + T.Literal["darwin", "linux", "windows"], platform.system().lower()) + """ str : The system (OS type) that this code is running on. Always lowercase """ + self.machine = platform.machine() + """ str : The machine type (eg: "x86_64") """ + self.release = platform.release() + """ str : The OS Release that this code is running on """ + self.processor = platform.processor() + """ str : The processor in use, if detected """ + self.cpu_count = os.cpu_count() + """ int : The number of CPU cores on the system """ + self.python_implementation = platform.python_implementation() + """ str : The python implementation in use""" + self.python_version = platform.python_version() + """ str : The .. version of Python that is running """ + self.python_architecture = platform.architecture()[0] + """ str : The Python architecture that is running (eg: 64bit/32bit)""" + self.encoding = locale.getpreferredencoding() + """ str : The system encoding """ + self.is_conda = ("conda" in sys.version.lower() or + os.path.exists(os.path.join(sys.prefix, 'conda-meta'))) + """ bool : ``True`` if running under Conda otherwise ``False`` """ + self.is_admin = self._get_permissions() + """ bool : ``True`` if we are running with Admin privileges """ + self.is_virtual_env = self._check_virtual_env() + """ bool : ``True`` if Python is being run inside a virtual environment """ + + @property + def is_linux(self) -> bool: + """ bool : `True` if running on a Linux system otherwise ``False``. """ + return self.system == "linux" + + @property + def is_macos(self) -> bool: + """ bool : `True` if running on a macOS system otherwise ``False``. """ + return self.system == "darwin" + + @property + def is_windows(self) -> bool: + """ bool : `True` if running on a Windows system otherwise ``False``. """ + return self.system == "windows" + + def __repr__(self) -> str: + """ Pretty print the system information for logging """ + attrs = ", ".join(f"{k}={repr(v)}" for k, v in self.__dict__.items() + if not k.startswith("_")) + return f"{self.__class__.__name__}({attrs})" + + def _get_permissions(self) -> bool: + """ Check whether user is admin + + Returns + ------- + bool + ``True`` if we are running with Admin privileges + """ + if self.is_windows: + retval = ctypes.windll.shell32.IsUserAnAdmin() != 0 # type:ignore[attr-defined] + else: + retval = os.getuid() == 0 # type:ignore[attr-defined] # pylint:disable=no-member + return retval + + def _check_virtual_env(self) -> bool: + """ Check whether we are in a virtual environment + + Returns + ------- + bool + ``True`` if Python is being run inside a virtual environment + """ + if not self.is_conda: + retval = (hasattr(sys, "real_prefix") or + (hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix)) + else: + prefix = os.path.dirname(sys.prefix) + retval = os.path.basename(prefix) == "envs" + return retval + + def validate_python(self, max_version: tuple[int, int] | None = None) -> bool: + """ Check that the running Python version is valid + + Parameters + ---------- + max_version: tuple[int, int] | None, Optional + The max version to validate Python against. ``None`` for the project Maximum. + Default: ``None`` (project maximum) + + Returns + ------- + bool + ``True`` if the running Python version is valid, otherwise logs an error and exits + """ + max_python = VALID_PYTHON[1] if max_version is None else max_version + retval = (VALID_PYTHON[0] <= sys.version_info[:2] <= max_python + and self.python_architecture == "64bit") + logger.debug("Python version %s(%s) within %s - %s(64bit): %s", + self.python_version, + self.python_architecture, + VALID_PYTHON[0], + max_python, + retval) + if not retval: + print() + logger.error("Your Python version %s(%s) is unsupported. Please run with Python " + "version %s to %s 64bit.", + self.python_version, + self.python_architecture, + ".".join(str(x) for x in VALID_PYTHON[0]), + ".".join(str(x) for x in max_python)) + print() + logger.error("If you have recently upgraded faceswap, then you will need to create a " + "new virtual environment.") + logger.error("The easiest way to do this is to run the latest version of the Faceswap " + "installer from:") + logger.error("https://github.com/deepfakes/faceswap/releases") + print() + input("Press to close") + sys.exit(1) + + return retval + + def validate(self) -> None: + """ Perform validation that the running system can be used for faceswap. Log an error and + exit if it cannot """ + if not any((self.is_linux, self.is_macos, self.is_windows)): + logger.error("Your system %s is not supported!", self.system.title()) + sys.exit(1) + if self.is_macos and self.machine == "arm64" and not self.is_conda: + logger.error("Setting up Faceswap for Apple Silicon outside of a Conda " + "environment is unsupported") + sys.exit(1) + self.validate_python() + + +class Packages(): + """ Holds information about installed python and conda packages. + + Note: Packaging library is lazy loaded as it may not be available during setup.py + """ + def __init__(self) -> None: + self._conda_exe = which("conda") + self._installed_python = self._get_installed_python() + self._installed_conda: list[str] | None = None + self._get_installed_conda() + + @property + def installed_python(self) -> dict[str, str]: + """ dict[str, str] : Installed Python package names to Python package versions """ + return self._installed_python + + @property + def installed_python_pretty(self) -> str: + """ str: A pretty printed representation of installed Python packages """ + pkgs = self._installed_python + align = max(len(x) for x in pkgs) + 1 + return "\n".join(f"{k.ljust(align)} {v}" for k, v in pkgs.items()) + + @property + def installed_conda(self) -> dict[str, tuple[str, str, str]]: + """ dict[str, tuple[str, str]] : Installed Conda package names to the version and + channel """ + if not self._installed_conda: + return {} + + installed = [re.sub(" +", " ", line.strip()) + for line in self._installed_conda if not line.startswith("#")] + retval = {} + for pkg in installed: + item = pkg.split(" ") + assert len(item) == 4 + retval[item[0]] = T.cast(tuple[str, str, str], tuple(item[1:])) + return retval + + @property + def installed_conda_pretty(self) -> str: + """ str: A pretty printed representation of installed conda packages """ + if not self._installed_conda: + return "Could not get Conda package list" + return "\n".join(self._installed_conda) + + def __repr__(self) -> str: + """ Pretty print the installed packages for logging """ + props = ", ".join( + f"{k}={repr(getattr(self, k))}" + for k, v in self.__class__.__dict__.items() + if isinstance(v, property) and not k.startswith("_") and "pretty" not in k) + return f"{self.__class__.__name__}({props})" + + def _get_installed_python(self) -> dict[str, str]: + """ Parse the installed python modules + + Returns + ------- + dict[str, str] + Installed Python package names to Python package versions + """ + installed = _lines_from_command([sys.executable, "-m", "pip", "freeze", "--local"]) + retval = {} + for pkg in installed: + if "==" not in pkg: + continue + item = pkg.split("==") + retval[item[0].lower()] = item[1] + logger.debug("Installed Python packages: %s", retval) + return retval + + def _get_installed_conda(self) -> None: + """ Collect the output from 'conda list' for the installed Conda packages and + populate :attr:`_installed_conda` + + Returns + ------- + list[str] + Each line of output from the 'conda list' command + """ + if not self._conda_exe: + logger.debug("Conda not found. Not collecting packages") + return + + lines = _lines_from_command([self._conda_exe, "list", "--show-channel-urls"]) + if not lines: + self._installed_conda = ["Could not get Conda package list"] + return + self._installed_conda = lines + logger.debug("Installed Conda packages: %s", self.installed_conda) + + +__all__ = get_module_objects(__name__) + + +if __name__ == "__main__": + print(System()) + print(Packages()) diff --git a/lib/training/augmentation.py b/lib/training/augmentation.py index 8fd911969b..4856be6525 100644 --- a/lib/training/augmentation.py +++ b/lib/training/augmentation.py @@ -2,7 +2,7 @@ """ Processes the augmentation of images for feeding into a Faceswap model. """ from __future__ import annotations import logging -import typing as T +from dataclasses import dataclass import cv2 import numexpr as ne @@ -11,159 +11,297 @@ from lib.image import batch_convert_color from lib.logger import parse_class_init +from lib.utils import get_module_objects +from plugins.train.trainer import trainer_config as cfg -if T.TYPE_CHECKING: - from lib.config import ConfigValueType logger = logging.getLogger(__name__) -class AugConstants: # pylint:disable=too-many-instance-attributes,too-few-public-methods - """ Dataclass for holding constants for Image Augmentation. +@dataclass +class ConstantsColor: + """ Dataclass for holding constants for enhancing an image (ie contrast/color adjustment) Parameters ---------- - config: dict[str, ConfigValueType] - The user training configuration options - processing_size: int: - The size of image to augment the data for - batch_size: int - The batch size that augmented data is being prepared for + clahe_base_contrast : int + The base number for Contrast Limited Adaptive Histogram Equalization + clahe_chance : float + Probability to perform Contrast Limited Adaptive Histogram Equilization + clahe_max_size : int + Maximum clahe window size + lab_adjust : :class:`numpy.ndarray` + Adjustment amounts for L*A*B augmentation """ - def __init__(self, - config: dict[str, ConfigValueType], - processing_size: int, - batch_size: int) -> None: - logger.debug(parse_class_init(locals())) - self.clahe_base_contrast: int = 0 - """int: The base number for Contrast Limited Adaptive Histogram Equalization""" - self.clahe_chance: float = 0.0 - """float: Probability to perform Contrast Limited Adaptive Histogram Equilization""" - self.clahe_max_size: int = 0 - """int: Maximum clahe window size""" - - self.lab_adjust: np.ndarray - """:class:`numpy.ndarray`: Adjustment amounts for L*A*B augmentation""" - self.transform_rotation: int = 0 - """int: Rotation range for transformations""" - self.transform_zoom: float = 0.0 - """float: Zoom range for transformations""" - self.transform_shift: float = 0.0 - """float: Shift range for transformations""" - self.warp_maps: np.ndarray - """:class:`numpy.ndarray`The stacked (x, y) mappings for image warping""" - self.warp_pad: tuple[int, int] = (0, 0) - """:tuple[int, int]: The padding to apply for image warping""" - self.warp_slices: slice - """:slice: The slices for extracting a warped image""" - self.warp_lm_edge_anchors: np.ndarray - """::class:`numpy.ndarray`: The edge anchors for landmark based warping""" - self.warp_lm_grids: np.ndarray - """::class:`numpy.ndarray`: The grids for landmark based warping""" - - self._config = config - self._size = processing_size - self._load_config(batch_size) - logger.debug("Initialized: %s", self.__class__.__name__) - - def _load_clahe(self) -> None: - """ Load the CLAHE constants from user config """ - color_clahe_chance = self._config.get("color_clahe_chance", 50) - color_clahe_max_size = self._config.get("color_clahe_max_size", 4) - assert isinstance(color_clahe_chance, int) - assert isinstance(color_clahe_max_size, int) - - self.clahe_base_contrast = max(2, self._size // 128) - self.clahe_chance = color_clahe_chance / 100 - self.clahe_max_size = color_clahe_max_size + clahe_base_contrast: int + """ int : The base number for Contrast Limited Adaptive Histogram Equalization """ + clahe_chance: float + """ float : Probability to perform Contrast Limited Adaptive Histogram Equilization """ + clahe_max_size: int + """ int : Maximum clahe window size""" + lab_adjust: np.ndarray + """ :class:`numpy.ndarray` : Adjustment amounts for L*A*B augmentation """ + + +@dataclass +class ConstantsTransform: + """ Dataclass for holding constants for transforming an image + + Parameters + ---------- + rotation : int + Rotation range for transformations + zoom : float + Zoom range for transformations + shift : float + Shift range for transformations + """ + rotation: int + """ int : Rotation range for transformations """ + zoom: float + """ float : Zoom range for transformations """ + shift: float + """ float : Shift range for transformations """ + flip: float + """ float : The chance to flip an image """ + + +@dataclass +class ConstantsWarp: + """ Dataclass for holding constants for warping an image + + Parameters + ---------- + maps : :class:`numpy.ndarray` + The stacked (x, y) mappings for image warping + pad : tuple[int, int] + The padding to apply for image warping + slices : slice + The slices for extracting a warped image + lm_edge_anchors : :class:`numpy.ndarray` + The edge anchors for landmark based warping + lm_grids : :class:`numpy.ndarray` + The grids for landmark based warping + """ + maps: np.ndarray + """ :class:`numpy.ndarray` : The stacked (x, y) mappings for image warping """ + pad: tuple[int, int] + """ :tuple[int, int] : The padding to apply for image warping """ + slices: slice + """ slice : The slices for extracting a warped image """ + scale: float + """ float : The scaling to apply to standard warping """ + lm_edge_anchors: np.ndarray + """ :class:`numpy.ndarray` : The edge anchors for landmark based warping """ + lm_grids: np.ndarray + """ :class:`numpy.ndarray` : The grids for landmark based warping """ + lm_scale: float + """ float : The scaling to apply to landmark based warping """ + + def __repr__(self) -> str: + """ Display shape/type information for arrays in __repr__ """ + params = {k: f"array[shape: {v.shape}, dtype: {v.dtype}]" + if isinstance(v, np.ndarray) else v + for k, v in self.__dict__.items()} + str_params = ", ".join(f"{k}={v}" for k, v in params.items()) + return f"{self.__class__.__name__}({str_params})" + + +@dataclass +class ConstantsAugmentation: + """ Dataclass for holding constants for Image Augmentation. + + Attributes + ---------- + color : :class:`ConstantsColor` + The constants for adjusting color/contrast in an image + transform : :class:`ConstantsTransform` + The constants for image transformation + warp : :class:`ConstantsTransform` + The constants for image warping + + Dataclass should be initialized using its :func:`from_config` method: + + Example + ------- + >>> constants = ConstantsAugmentation.from_config(processing_size=256, + ... batch_size=16) + """ + color: ConstantsColor + """ :class:`ConstantsColor` : The constants for adjusting color/contrast in an image """ + transform: ConstantsTransform + """ :class:`ConstantsTransform` : The constants for image transformation """ + warp: ConstantsWarp + """ :class:`ConstantsTransform` : The constants for image warping """ + + @classmethod + def _get_clahe(cls, size: int) -> tuple[int, float, int]: + """ Get the CLAHE constants from user config + + Parameters + ---------- + size : int + The size of image to augment the data for + + Returns + ------- + clahe_base_contrast : int + The base number for Contrast Limited Adaptive Histogram Equalization + clahe_chance : float + Probability to perform Contrast Limited Adaptive Histogram Equilization + clahe_max_size : int + Maximum clahe window size + """ + clahe_base_contrast = max(2, size // 128) + clahe_chance = cfg.color_clahe_chance() / 100 + clahe_max_size = cfg.color_clahe_max_size() logger.debug("clahe_base_contrast: %s, clahe_chance: %s, clahe_max_size: %s", - self.clahe_base_contrast, self.clahe_chance, self.clahe_max_size) - - def _load_lab(self) -> None: - """ Load the random L*A*B augmentation constants """ - color_lightness = self._config.get("color_lightness", 30) - color_ab = self._config.get("color_ab", 8) - assert isinstance(color_lightness, int) - assert isinstance(color_ab, int) - - amount_l = int(color_lightness) / 100 - amount_ab = int(color_ab) / 100 - - self.lab_adjust = np.array([amount_l, amount_ab, amount_ab], dtype="float32") - logger.debug("lab_adjust: %s", self.lab_adjust) - - def _load_transform(self) -> None: - """ Load the random transform constants """ - shift_range = self._config.get("shift_range", 5) - rotation_range = self._config.get("rotation_range", 10) - zoom_amount = self._config.get("zoom_amount", 5) - assert isinstance(shift_range, int) - assert isinstance(rotation_range, int) - assert isinstance(zoom_amount, int) - - self.transform_shift = (shift_range / 100) * self._size - self.transform_rotation = rotation_range - self.transform_zoom = zoom_amount / 100 - logger.debug("transform_shift: %s, transform_rotation: %s, transform_zoom: %s", - self.transform_shift, self.transform_rotation, self.transform_zoom) - - def _load_warp(self, batch_size: int) -> None: - """ Load the warp augmentation constants + clahe_base_contrast, clahe_chance, clahe_max_size) + return clahe_base_contrast, clahe_chance, clahe_max_size + + @classmethod + def _get_lab(cls) -> np.ndarray: + """ Load the random L*A*B augmentation constants + + Returns + ------- + :class:`numpy.ndarray` + Adjustment amounts for L*A*B augmentation + """ + amount_l = cfg.color_lightness() / 100. + amount_ab = cfg.color_ab() / 100. + + lab_adjust = np.array([amount_l, amount_ab, amount_ab], dtype="float32") + logger.debug("lab_adjust: %s", lab_adjust) + return lab_adjust + + @classmethod + def _get_color(cls, size: int) -> ConstantsColor: + """ Get the image enhancements constants from user config Parameters ---------- - batch_size: int - The batch size that augmented data is being prepared for + size : int + The size of image to augment the data for + + Returns + ------- + :class:`ConstantsColor` + The constants for image enhancement """ - warp_range = np.linspace(0, self._size, 5, dtype='float32') - warp_mapx = np.broadcast_to(warp_range, (batch_size, 5, 5)).astype("float32") - warp_mapy = np.broadcast_to(warp_mapx[0].T, (batch_size, 5, 5)).astype("float32") - warp_pad = int(1.25 * self._size) + clahe_base_contrast, clahe_chance, clahe_max_size = cls._get_clahe(size) + retval = ConstantsColor(clahe_base_contrast=clahe_base_contrast, + clahe_chance=clahe_chance, + clahe_max_size=clahe_max_size, + lab_adjust=cls._get_lab()) + logger.debug(retval) + return retval + + @classmethod + def _get_transform(cls, size: int) -> ConstantsTransform: + """ Load the random transform constants - self.warp_maps = np.stack((warp_mapx, warp_mapy), axis=1) - self.warp_pad = (warp_pad, warp_pad) - self.warp_slices = slice(warp_pad // 10, -warp_pad // 10) - logger.debug("warp_maps: (%s, %s), warp_pad: %s, warp_slices: %s", - self.warp_maps.shape, self.warp_maps.dtype, - self.warp_pad, self.warp_slices) + Parameters + ---------- + size : int + The size of image to augment the data for - def _load_warp_to_landmarks(self, batch_size: int) -> None: + Returns + ------- + :class:`ConstantsTransform` + The constants for image transformation + """ + retval = ConstantsTransform(rotation=cfg.rotation_range(), + zoom=cfg.zoom_amount() / 100., + shift=(cfg.shift_range() / 100.) * size, + flip=cfg.flip_chance() / 100.) + logger.debug(retval) + return retval + + @classmethod + def _get_warp_to_landmarks(cls, size: int, batch_size: int) -> tuple[np.ndarray, np.ndarray]: """ Load the warp-to-landmarks augmentation constants Parameters ---------- - batch_size: int + size : int + The size of image to augment the data for + batch_size : int The batch size that augmented data is being prepared for + + Returns + ------- + edge_anchors : :class:`numpy.ndarray` + The edge anchors for landmark based warping + grids : :class:`numpy.ndarray` + The grids for landmark based warping """ - p_mx = self._size - 1 - p_hf = (self._size // 2) - 1 + p_mx = size - 1 + p_hf = (size // 2) - 1 edge_anchors = np.array([(0, 0), (0, p_mx), (p_mx, p_mx), (p_mx, 0), (p_hf, 0), (p_hf, p_mx), (p_mx, p_hf), (0, p_hf)]).astype("int32") edge_anchors = np.broadcast_to(edge_anchors, (batch_size, 8, 2)) - grids = np.mgrid[0: p_mx: complex(self._size), # type:ignore[misc] - 0: p_mx: complex(self._size)] # type:ignore[misc] + grids = np.mgrid[0: p_mx: complex(size), # type:ignore[misc] # pylint:disable=no-member + 0: p_mx: complex(size)].astype("float32") # type:ignore[misc] + + logger.debug("edge_anchors: (%s, %s), grids: (%s, %s)", + edge_anchors.shape, edge_anchors.dtype, + grids.shape, grids.dtype) # pylint:disable=no-member + return edge_anchors, grids - self.warp_lm_edge_anchors = edge_anchors - self.warp_lm_grids = grids - logger.debug("warp_lm_edge_anchors: (%s, %s), warp_lm_grids: (%s, %s)", - self.warp_lm_edge_anchors.shape, self.warp_lm_edge_anchors.dtype, - self.warp_lm_grids.shape, self.warp_lm_grids.dtype) + @classmethod + def _get_warp(cls, size: int, batch_size: int) -> ConstantsWarp: + """ Load the warp augmentation constants - def _load_config(self, batch_size: int) -> None: - """ Load the constants into the class from user config + Parameters + ---------- + size: int + The size of image to augment the data for + batch_size : int + The batch size that augmented data is being prepared for + + Returns + ------- + :class:`ConstantsTransform` + The constants for image warping + """ + lm_edge_anchors, lm_grids = cls._get_warp_to_landmarks(size, batch_size) + + warp_range = np.linspace(0, size, 5, dtype='float32') + warp_mapx = np.broadcast_to(warp_range, (batch_size, 5, 5)).astype("float32") + warp_mapy = np.broadcast_to(warp_mapx[0].T, (batch_size, 5, 5)).astype("float32") + warp_pad = int(1.25 * size) + + retval = ConstantsWarp(maps=np.stack((warp_mapx, warp_mapy), axis=1), + pad=(warp_pad, warp_pad), + slices=slice(warp_pad // 10, -warp_pad // 10), + scale=5 / 256 * size, # Normal random variable scale + lm_edge_anchors=lm_edge_anchors, + lm_grids=lm_grids, + lm_scale=2 / 256 * size) # Normal random variable scale + logger.debug(retval) + return retval + + @classmethod + def from_config(cls, + processing_size: int, + batch_size: int) -> ConstantsAugmentation: + """ Create a new dataclass instance from user config Parameters ---------- - batch_size: int + processing_size : int: + The size of image to augment the data for + batch_size : int The batch size that augmented data is being prepared for """ - logger.debug("Loading augmentation constants") - self._load_clahe() - self._load_lab() - self._load_transform() - self._load_warp(batch_size) - self._load_warp_to_landmarks(batch_size) - logger.debug("Loaded augmentation constants") + logger.debug("Initializing %s(processing_size=%s, batch_size=%s)", + cls.__name__, processing_size, batch_size) + retval = cls(color=cls._get_color(processing_size), + transform=cls._get_transform(processing_size), + warp=cls._get_warp(processing_size, batch_size)) + logger.debug(retval) + return retval class ImageAugmentation(): @@ -171,70 +309,66 @@ class ImageAugmentation(): Parameters ---------- - batch_size: int + batch_size : int The number of images that will be fed through the augmentation functions at once. processing_size: int The largest input or output size of the model. This is the size that images are processed at. - config: dict - The configuration `dict` generated from :file:`config.train.ini` containing the trainer - plugin configuration options. """ - def __init__(self, - batch_size: int, - processing_size: int, - config: dict[str, ConfigValueType]) -> None: + def __init__(self, batch_size: int, processing_size: int) -> None: logger.debug(parse_class_init(locals())) self._processing_size = processing_size self._batch_size = batch_size - - # flip_args - flip_chance = config.get("random_flip", 50) - assert isinstance(flip_chance, int) - self._flip_chance = flip_chance - - # Warp args - self._warp_scale = 5 / 256 * self._processing_size # Normal random variable scale - self._warp_lm_scale = 2 / 256 * self._processing_size # Normal random variable scale - - self._constants = AugConstants(config, processing_size, batch_size) + self._constants = ConstantsAugmentation.from_config(processing_size, batch_size) logger.debug("Initialized %s", self.__class__.__name__) - # <<< COLOR AUGMENTATION >>> # - def color_adjust(self, batch: np.ndarray) -> np.ndarray: - """ Perform color augmentation on the passed in batch. + def __repr__(self) -> str: + """ Pretty print this object """ + return (f"{self.__class__.__name__}(batch_size={self._batch_size}, " + f"processing_size={self._processing_size})") - The color adjustment parameters are set in :file:`config.train.ini` + # <<< COLOR AUGMENTATION >>> # + def _random_lab(self, batch: np.ndarray) -> None: + """ Perform random color/lightness adjustment in L*a*b* color space on a batch of + images Parameters ---------- - batch: :class:`numpy.ndarray` + batch : :class:`numpy.ndarray` The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, - `3`) and in `BGR` format. - - Returns - ---------- - :class:`numpy.ndarray` - A 4-dimensional array of the same shape as :attr:`batch` with color augmentation - applied. + `3`) and in `BGR` format of uint8 dtype. """ - logger.trace("Augmenting color") # type:ignore[attr-defined] - batch = batch_convert_color(batch, "BGR2LAB") - self._random_lab(batch) - self._random_clahe(batch) - batch = batch_convert_color(batch, "LAB2BGR") - return batch + randoms = np.random.uniform(-self._constants.color.lab_adjust, + self._constants.color.lab_adjust, + size=(self._batch_size, 1, 1, 3)).astype("float32") + logger.trace("Random LAB adjustments: %s", randoms) # type:ignore[attr-defined] + # Iterating through the images and channels is much faster than numpy.where and slightly + # faster than numexpr.where. + for image, rand in zip(batch, randoms): + for idx in range(rand.shape[-1]): + adjustment = rand[:, :, idx] + if adjustment >= 0: + image[:, :, idx] = ((255 - image[:, :, idx]) * adjustment) + image[:, :, idx] + else: + image[:, :, idx] = image[:, :, idx] * (1 + adjustment) def _random_clahe(self, batch: np.ndarray) -> None: """ Randomly perform Contrast Limited Adaptive Histogram Equalization on - a batch of images """ - base_contrast = self._constants.clahe_base_contrast + a batch of images + + Parameters + ---------- + batch : :class:`numpy.ndarray` + The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, + `3`) and in `BGR` format of uint8 dtype. + """ + base_contrast = self._constants.color.clahe_base_contrast batch_random = np.random.rand(self._batch_size) - indices = np.where(batch_random < self._constants.clahe_chance)[0] + indices = np.where(batch_random < self._constants.color.clahe_chance)[0] if not np.any(indices): return - grid_bases = np.random.randint(self._constants.clahe_max_size + 1, + grid_bases = np.random.randint(self._constants.color.clahe_max_size + 1, size=indices.shape[0], dtype="uint8") grid_sizes = (grid_bases * (base_contrast // 2)) + base_contrast @@ -247,22 +381,29 @@ def _random_clahe(self, batch: np.ndarray) -> None: for idx, clahe in zip(indices, clahes): batch[idx, :, :, 0] = clahe.apply(batch[idx, :, :, 0], ) - def _random_lab(self, batch: np.ndarray) -> None: - """ Perform random color/lightness adjustment in L*a*b* color space on a batch of - images """ - randoms = np.random.uniform(-self._constants.lab_adjust, - self._constants.lab_adjust, - size=(self._batch_size, 1, 1, 3)).astype("float32") - logger.trace("Random LAB adjustments: %s", randoms) # type:ignore[attr-defined] - # Iterating through the images and channels is much faster than numpy.where and slightly - # faster than numexpr.where. - for image, rand in zip(batch, randoms): - for idx in range(rand.shape[-1]): - adjustment = rand[:, :, idx] - if adjustment >= 0: - image[:, :, idx] = ((255 - image[:, :, idx]) * adjustment) + image[:, :, idx] - else: - image[:, :, idx] = image[:, :, idx] * (1 + adjustment) + def color_adjust(self, batch: np.ndarray) -> np.ndarray: + """ Perform color augmentation on the passed in batch. + + The color adjustment parameters are set in :file:`config.train.ini` + + Parameters + ---------- + batch : :class:`numpy.ndarray` + The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, + `3`) and in `BGR` format of uint8 dtype. + + Returns + ---------- + :class:`numpy.ndarray` + A 4-dimensional array of the same shape as :attr:`batch` with color augmentation + applied. + """ + logger.trace("Augmenting color") # type:ignore[attr-defined] + batch = batch_convert_color(batch, "BGR2LAB") + self._random_lab(batch) + self._random_clahe(batch) + batch = batch_convert_color(batch, "LAB2BGR") + return batch # <<< IMAGE AUGMENTATION >>> # def transform(self, batch: np.ndarray): @@ -272,21 +413,20 @@ def transform(self, batch: np.ndarray): Parameters ---------- - batch: :class:`numpy.ndarray` + batch : :class:`numpy.ndarray` The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `channels`) and in `BGR` format. """ logger.trace("Randomly transforming image") # type:ignore[attr-defined] - - rotation = np.random.uniform(-self._constants.transform_rotation, - self._constants.transform_rotation, + rotation = np.random.uniform(-self._constants.transform.rotation, + self._constants.transform.rotation, size=self._batch_size).astype("float32") - scale = np.random.uniform(1 - self._constants.transform_zoom, - 1 + self._constants.transform_zoom, + scale = np.random.uniform(1 - self._constants.transform.zoom, + 1 + self._constants.transform.zoom, size=self._batch_size).astype("float32") - tform = np.random.uniform(-self._constants.transform_shift, - self._constants.transform_shift, + tform = np.random.uniform(-self._constants.transform.shift, + self._constants.transform.shift, size=(self._batch_size, 2)).astype("float32") mats = np.array( [cv2.getRotationMatrix2D((self._processing_size // 2, self._processing_size // 2), @@ -311,54 +451,23 @@ def random_flip(self, batch: np.ndarray): Parameters ---------- - batch: :class:`numpy.ndarray` + batch : :class:`numpy.ndarray` The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `channels`) and in `BGR` format. """ logger.trace("Randomly flipping image") # type:ignore[attr-defined] randoms = np.random.rand(self._batch_size) - indices = np.where(randoms <= self._flip_chance / 100)[0] + indices = np.where(randoms <= self._constants.transform.flip)[0] batch[indices] = batch[indices, :, ::-1] logger.trace("Randomly flipped %s images of %s", # type:ignore[attr-defined] len(indices), self._batch_size) - def warp(self, batch: np.ndarray, to_landmarks: bool = False, **kwargs) -> np.ndarray: - """ Perform random warping on the passed in batch by one of two methods. - - Parameters - ---------- - batch: :class:`numpy.ndarray` - The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, - `3`) and in `BGR` format. - to_landmarks: bool, optional - If ``False`` perform standard random warping of the input image. If ``True`` perform - warping to semi-random similar corresponding landmarks from the other side. Default: - ``False`` - kwargs: dict - If :attr:`to_landmarks` is ``True`` the following additional kwargs must be passed in: - - * **batch_src_points** (:class:`numpy.ndarray`) - A batch of 68 point landmarks for \ - the source faces. This is a 3-dimensional array in the shape (`batchsize`, `68`, `2`). - - * **batch_dst_points** (:class:`numpy.ndarray`) - A batch of randomly chosen closest \ - match destination faces landmarks. This is a 3-dimensional array in the shape \ - (`batchsize`, `68`, `2`). - - Returns - ---------- - :class:`numpy.ndarray` - A 4-dimensional array of the same shape as :attr:`batch` with warping applied. - """ - if to_landmarks: - return self._random_warp_landmarks(batch, **kwargs) - return self._random_warp(batch) - def _random_warp(self, batch: np.ndarray) -> np.ndarray: """ Randomly warp the input batch Parameters ---------- - batch: :class:`numpy.ndarray` + batch : :class:`numpy.ndarray` The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `3`) and in `BGR` format. @@ -368,11 +477,12 @@ def _random_warp(self, batch: np.ndarray) -> np.ndarray: A 4-dimensional array of the same shape as :attr:`batch` with warping applied. """ logger.trace("Randomly warping batch") # type:ignore[attr-defined] - slices = self._constants.warp_slices + slices = self._constants.warp.slices rands = np.random.normal(size=(self._batch_size, 2, 5, 5), - scale=self._warp_scale).astype("float32") - batch_maps = ne.evaluate("m + r", local_dict={"m": self._constants.warp_maps, "r": rands}) - batch_interp = np.array([[cv2.resize(map_, self._constants.warp_pad)[slices, slices] + scale=self._constants.warp.scale).astype("float32") + batch_maps = ne.evaluate("m + r", local_dict={"m": self._constants.warp.maps, "r": rands}) + + batch_interp = np.array([[cv2.resize(map_, self._constants.warp.pad)[slices, slices] for map_ in maps] for maps in batch_maps]) warped_batch = np.array([cv2.remap(image, interp[0], interp[1], cv2.INTER_LINEAR) @@ -387,13 +497,13 @@ def _random_warp_landmarks(self, batch_dst_points: np.ndarray) -> np.ndarray: """ From dfaker. Warp the image to a similar set of landmarks from the opposite side - batch: :class:`numpy.ndarray` + batch : :class:`numpy.ndarray` The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `3`) and in `BGR` format. - batch_src_points :class:`numpy.ndarray` + batch_src_points : :class:`numpy.ndarray` A batch of 68 point landmarks for the source faces. This is a 3-dimensional array in the shape (`batchsize`, `68`, `2`). - batch_dst_points :class:`numpy.ndarray` + batch_dst_points : :class:`numpy.ndarray` A batch of randomly chosen closest match destination faces landmarks. This is a 3-dimensional array in the shape (`batchsize`, `68`, `2`). @@ -403,11 +513,11 @@ def _random_warp_landmarks(self, A 4-dimensional array of the same shape as :attr:`batch` with warping applied. """ logger.trace("Randomly warping landmarks") # type:ignore[attr-defined] - edge_anchors = self._constants.warp_lm_edge_anchors - grids = self._constants.warp_lm_grids + edge_anchors = self._constants.warp.lm_edge_anchors + grids = self._constants.warp.lm_grids - batch_dst = (batch_dst_points + np.random.normal(size=batch_dst_points.shape, - scale=self._warp_lm_scale)) + batch_dst = batch_dst_points + np.random.normal(size=batch_dst_points.shape, + scale=self._constants.warp.lm_scale) face_cores = [cv2.convexHull(np.concatenate([src[17:], dst[17:]], axis=0)) for src, dst in zip(batch_src_points.astype("int32"), @@ -440,3 +550,44 @@ def _random_warp_landmarks(self, for image, map_ in zip(batch, maps)]) logger.trace("Warped batch shape: %s", warped_batch.shape) # type:ignore[attr-defined] return warped_batch + + def warp(self, + batch: np.ndarray, + to_landmarks: bool = False, + batch_src_points: np.ndarray | None = None, + batch_dst_points: np.ndarray | None = None + ) -> np.ndarray: + + """ Perform random warping on the passed in batch by one of two methods. + + Parameters + ---------- + batch : :class:`numpy.ndarray` + The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, + `3`) and in `BGR` format. + to_landmarks : bool, optional + If ``False`` perform standard random warping of the input image. If ``True`` perform + warping to semi-random similar corresponding landmarks from the other side. Default: + ``False`` + batch_src_points : :class:`numpy.ndarray`, optional + Only used when :attr:`to_landmarks` is ``True``. A batch of 68 point landmarks for the + source faces. This is a 3-dimensional array in the shape (`batchsize`, `68`, `2`). + Default: ``None`` + batch_dst_points : :class:`numpy.ndarray`, optional + Only used when :attr:`to_landmarks` is ``True``. A batch of randomly chosen closest + match destination faces landmarks. This is a 3-dimensional array in the shape + (`batchsize`, `68`, `2`). Default ``None`` + + Returns + ---------- + :class:`numpy.ndarray` + A 4-dimensional array of the same shape as :attr:`batch` with warping applied. + """ + if to_landmarks: + assert batch_src_points is not None + assert batch_dst_points is not None + return self._random_warp_landmarks(batch, batch_src_points, batch_dst_points) + return self._random_warp(batch) + + +__all__ = get_module_objects(__name__) diff --git a/lib/training/cache.py b/lib/training/cache.py index 8915f79ec6..e3429a48f9 100644 --- a/lib/training/cache.py +++ b/lib/training/cache.py @@ -5,6 +5,7 @@ import os import typing as T +from dataclasses import dataclass, field from threading import Lock import cv2 @@ -13,67 +14,238 @@ from lib.align import CenteringType, DetectedFace, LandmarkType from lib.image import read_image_batch, read_image_meta_batch -from lib.utils import FaceswapError +from lib.logger import parse_class_init +from lib.utils import FaceswapError, get_module_objects +from plugins.train import train_config as cfg if T.TYPE_CHECKING: from lib.align.alignments import PNGHeaderAlignmentsDict, PNGHeaderDict - from lib.config import ConfigValueType + from lib import align logger = logging.getLogger(__name__) -_FACE_CACHES: dict[str, "_Cache"] = {} +_FACE_CACHES: dict[str, Cache] = {} + + +@dataclass +class _MaskConfig: + """ Holds the constants required for manipulating training masks """ + # pylint:disable=unnecessary-lambda + penalized: bool = field(default_factory=lambda: cfg.Loss.penalized_mask_loss()) + learn: bool = field(default_factory=lambda: cfg.Loss.learn_mask()) + mask_type: str | None = field(default_factory=lambda: None + if cfg.Loss.mask_type() == "none" + else cfg.Loss.mask_type()) + dilation: float = field(default_factory=lambda: cfg.Loss.mask_dilation()) + kernel: int = field(default_factory=lambda: cfg.Loss.mask_blur_kernel()) + threshold: int = field(default_factory=lambda: cfg.Loss.mask_threshold()) + multiplier_enabled: bool = field( + default_factory=lambda: ((cfg.Loss.eye_multiplier() > 1 or cfg.Loss.mouth_multiplier() > 1) + and cfg.Loss.penalized_mask_loss())) + @property + def mask_enabled(self) -> bool: + """ bool : ``True`` if any of :attr:`penalized` or :attr:`learn` are true and + :attr:`mask_type` is not ``None`` """ + return self.mask_type is not None and (self.learn or self.penalized) -def get_cache(side: T.Literal["a", "b"], - filenames: list[str] | None = None, - config: dict[str, ConfigValueType] | None = None, - size: int | None = None, - coverage_ratio: float | None = None) -> "_Cache": - """ Obtain a :class:`_Cache` object for the given side. If the object does not pre-exist then - create it. + +class _MaskProcessing: + """ Handle the extraction and processing of masks from faceswap PNG headers for caching Parameters ---------- - side: str - `"a"` or `"b"`. The side of the model to obtain the cache for - filenames: list - The filenames of all the images. This can either be the full path or the base name. If the - full paths are passed in, they are stripped to base name for use as the cache key. Must be - passed for the first call of this function for each side. For subsequent calls this - parameter is ignored. Default: ``None`` - config: dict, optional - The user selected training configuration options. Must be passed for the first call of this - function for each side. For subsequent calls this parameter is ignored. Default: ``None`` - size: int, optional - The largest output size of the model. Must be passed for the first call of this function - for each side. For subsequent calls this parameter is ignored. Default: ``None`` - coverage_ratio: float: optional - The coverage ratio that the model is using. Must be passed for the first call of this - function for each side. For subsequent calls this parameter is ignored. Default: ``None`` - - Returns - ------- - :class:`_Cache` - The face meta information cache for the requested side + size : int + The largest output size of the model + coverage_ratio : float + The coverage ratio that the model is using. + centering : Literal["face", "head", "legacy"] """ - if not _FACE_CACHES.get(side): - assert config is not None, ("config must be provided for first call to cache") - assert filenames is not None, ("filenames must be provided for first call to cache") - assert size is not None, ("size must be provided for first call to cache") - assert coverage_ratio is not None, ("coverage_ratio must be provided for first call to " - "cache") - logger.debug("Creating cache. side: %s, size: %s, coverage_ratio: %s", - side, size, coverage_ratio) - _FACE_CACHES[side] = _Cache(filenames, config, size, coverage_ratio) - return _FACE_CACHES[side] + def __init__(self, + size: int, + coverage_ratio: float, + centering: CenteringType) -> None: + + assert isinstance(size, int) + assert isinstance(coverage_ratio, float) + assert centering in T.get_args(CenteringType) + + self._size = size + self._coverage = coverage_ratio + self._centering: CenteringType = centering + + self._config = _MaskConfig() + logger.debug("Initialized %s", self) + + def __repr__(self) -> str: + """ Pretty print for logging """ + params = f"coverage_ratio={repr(self._coverage)}, centering={repr(self._centering)}" + return f"{self.__class__.__name__}({params})" + + def _check_mask_exists(self, filename: str, detected_face: DetectedFace) -> None: + """ Check that the requested mask exists for the current detected face + + Parameters + ---------- + filename : str + The file path for the current image + detected_face : :class:`~lib.align.detected_face.DetectedFace` + The detected face object that holds the masks + + Raises + ------ + FaceswapError + If the requested mask type is not available an error is returned along with a list + of available masks + """ + if self._config.mask_type in detected_face.mask: + return + + exist_masks = list(detected_face.mask) + msg = "No masks exist for this face" + if exist_masks: + msg = f"The masks that exist for this face are: {exist_masks}" + raise FaceswapError( + f"You have selected the mask type '{self._config.mask_type}' but at least one " + "face does not contain the selected mask.\n" + f"The face that failed was: '{filename}'\n{msg}") + + def _preprocess(self, detected_face: DetectedFace, mask_type: str) -> align.aligned_mask.Mask: + """ Apply pre-processing to the mask + + Parameters + ---------- + detected_face : :class:`~lib.align.detected_face.DetectedFace` + The detected face object that holds the masks + mask_type : str + The stored mask type to use + + Returns + ------- + :class:`~lib.align.aligned_mask.Mask` + The pre-processed mask at its stored size and crop + """ + mask = detected_face.mask[mask_type] + mask.set_dilation(self._config.dilation) + mask.set_blur_and_threshold(blur_kernel=self._config.kernel, + threshold=self._config.threshold) + return mask + + def _crop_and_resize(self, + detected_face: DetectedFace, + mask: align.aligned_mask.Mask) -> np.ndarray: + """ Crop and resize the mask to the correct centering and training size + + Parameters + ---------- + detected_face : :class:`~lib.align.detected_face.DetectedFace` + The detected face object that holds the masks + mask : :class:`~lib.align.aligned_mask.Mask` + The pre-processed mask at its stored size and crop + + Returns + ------- + :class:`numpy.ndarray` + The processed, cropped and resized final mask + """ + pose = detected_face.aligned.pose + mask.set_sub_crop(pose.offset[mask.stored_centering], + pose.offset[self._centering], + self._centering, + self._coverage, + detected_face.aligned.y_offset) + face_mask = mask.mask + if self._size != face_mask.shape[0]: + interpolator = cv2.INTER_CUBIC if mask.stored_size < self._size else cv2.INTER_AREA + face_mask = cv2.resize(face_mask, + (self._size, self._size), + interpolation=interpolator)[..., None] + return face_mask + + def _get_face_mask(self, filename: str, detected_face: DetectedFace) -> np.ndarray | None: + """ Obtain the training sized face mask from the DetectedFace for the requested mask type. + + Parameters + ---------- + filename : str + The file path for the current image + detected_face : :class:`~lib.align.detected_face.DetectedFace` + The detected face object that holds the masks + + Returns + ------- + :class:`numpy.ndarray` | None + The face mask used for training or ``None`` if masks are disabled + """ + if not self._config.mask_enabled: + return None + + assert self._config.mask_type is not None + self._check_mask_exists(filename, detected_face) + mask = self._preprocess(detected_face, self._config.mask_type) + retval = self._crop_and_resize(detected_face, mask) + logger.trace("Obtained face mask for: %s %s", # type:ignore[attr-defined] + filename, retval.shape) + return retval + + def _get_localized_mask(self, + filename: str, + detected_face: DetectedFace, + area: T.Literal["eye", "mouth"]) -> np.ndarray | None: + """ Obtain a localized mask for the given area if it is required for training. + Parameters + ---------- + filename : str + The file path for the current image + detected_face : :class:`~lib.align.detected_face.DetectedFace` + The detected face object that holds the masks + area : Literal["eye", "mouth"] + The area of the face to obtain the mask for -def _check_reset(face_cache: "_Cache") -> bool: + Raises + ------ + :class:`~lib.utils.FaceswapError` + If landmark data is not available to generate the localized mask + """ + if not self._config.multiplier_enabled: + return None + + try: + mask = detected_face.get_landmark_mask(area, self._size // 16, 2.5) + except FaceswapError as err: + logger.error(str(err)) + raise FaceswapError("Eye/Mouth multiplier masks could not be generated due to missing " + f"landmark data. The file that failed was: '{filename}'") from err + logger.trace("Caching localized '%s' mask for: %s %s", # type:ignore[attr-defined] + area, filename, mask.shape) + return mask + + def __call__(self, filename: str, detected_face: DetectedFace) -> None: + """ Prepare the masks required for training and compile into a single compressed array + within the given DetectedFaces object + + Parameters + ---------- + filename : str + The file path for the image that masks are to be prepared for + detected_face : :class:`~lib.align.detected_face.DetectedFace` + The detected face object that holds the masks + """ + masks = [(self._get_face_mask(filename, detected_face))] + for area in T.get_args(T.Literal["eye", "mouth"]): + masks.append(self._get_localized_mask(filename, detected_face, area)) + + detected_face.store_training_masks(masks, delete_masks=True) + logger.trace("Stored masks for filename: %s)", filename) # type:ignore[attr-defined] + + +def _check_reset(face_cache: "Cache") -> bool: """ Check whether a given cache needs to be reset because a face centering change has been detected in the other cache. Parameters ---------- - face_cache: :class:`_Cache` + face_cache : :class:`Cache` The cache object that is checking whether it should reset Returns @@ -86,11 +258,22 @@ def _check_reset(face_cache: "_Cache") -> bool: return retval -class _Cache(): - """ A thread safe mechanism for collecting and holding face meta information (masks, " - "alignments data etc.) for multiple :class:`TrainingDataGenerator`s. +@dataclass +class _CacheConfig: + """ Holds the configuration options for the cache """ + size: int + """ int : The size to load images at """ + centering: CenteringType + """ Literal["face", "head", "legacy"] : The centering type to train at """ + coverage: float + """ float : The selected coverage ration for training """ + + +class Cache(): + """ A thread safe mechanism for collecting and holding face meta information (masks, + alignments data etc.) for multiple :class:`~lib.training.generator.TrainingDataGenerator`. - Each side may have up to 3 generators (training, preview and time-lapse). To conserve VRAM + Each side may have up to 3 generators (training, preview and time-lapse). To conserve RAM these need to share access to the same face information for the images they are processing. As the cache is populated at run-time, thread safe writes are required for the first epoch. @@ -102,23 +285,19 @@ class _Cache(): Parameters ---------- - filenames: list + filenames : list[str] The filenames of all the images. This can either be the full path or the base name. If the full paths are passed in, they are stripped to base name for use as the cache key. - config: dict - The user selected training configuration options - size: int + size : int The largest output size of the model - coverage_ratio: float + coverage_ratio : float The coverage ratio that the model is using. """ def __init__(self, filenames: list[str], - config: dict[str, ConfigValueType], size: int, coverage_ratio: float) -> None: - logger.debug("Initializing: %s (filenames: %s, size: %s, coverage_ratio: %s)", - self.__class__.__name__, len(filenames), size, coverage_ratio) + logger.debug(parse_class_init(locals())) self._lock = Lock() self._cache_info = {"cache_full": False, "has_reset": False} self._partially_loaded: list[str] = [] @@ -127,19 +306,17 @@ def __init__(self, self._cache: dict[str, DetectedFace] = {} self._aligned_landmarks: dict[str, np.ndarray] = {} self._extract_version = 0.0 - self._size = size - - assert config["centering"] in T.get_args(CenteringType) - self._centering: CenteringType = T.cast(CenteringType, config["centering"]) - self._config = config - self._coverage_ratio = coverage_ratio + self._config = _CacheConfig(size=size, + centering=T.cast(CenteringType, cfg.centering()), + coverage=coverage_ratio) + self._mask_prepare = _MaskProcessing(size, coverage_ratio, self._config.centering) logger.debug("Initialized: %s", self.__class__.__name__) @property def cache_full(self) -> bool: - """bool: ``True`` if the cache has been fully populated. ``False`` if there are items still - to be cached. """ + """ bool : ``True`` if the cache has been fully populated. ``False`` if there are items + still to be cached. """ if self._cache_info["cache_full"]: return self._cache_info["cache_full"] with self._lock: @@ -147,7 +324,7 @@ def cache_full(self) -> bool: @property def aligned_landmarks(self) -> dict[str, np.ndarray]: - """ dict: The filename as key, aligned landmarks as value. """ + """ dict[str, :class:`numpy.ndarray`] : filename as key, aligned landmarks as value. """ # Note: Aligned landmarks are only used for warp-to-landmarks, so this can safely populate # all of the aligned landmarks for the entire cache. if not self._aligned_landmarks: @@ -160,23 +337,8 @@ def aligned_landmarks(self) -> dict[str, np.ndarray]: @property def size(self) -> int: - """ int: The pixel size of the cropped aligned face """ - return self._size - - def check_reset(self) -> bool: - """ Check whether this cache has been reset due to a face centering change, and reset the - flag if it has. - - Returns - ------- - bool - ``True`` if the cache has been reset because of a face centering change due to - legacy alignments, otherwise ``False``. """ - retval = self._cache_info["has_reset"] - if retval: - logger.debug("Resetting 'has_reset' flag") - self._cache_info["has_reset"] = False - return retval + """ int : The pixel size of the cropped aligned face """ + return self._config.size def get_items(self, filenames: list[str]) -> list[DetectedFace]: """ Obtain the cached items for a list of filenames. The returned list is in the same order @@ -184,143 +346,67 @@ def get_items(self, filenames: list[str]) -> list[DetectedFace]: Parameters ---------- - filenames: list + filenames : list[str] A list of image filenames to obtain the cached data for Returns ------- - list + list[:class:`~lib.align.detected_face.DetectedFace`] List of DetectedFace objects holding the cached metadata. The list returns in the same order as the filenames received """ return [self._cache[os.path.basename(filename)] for filename in filenames] - def cache_metadata(self, filenames: list[str]) -> np.ndarray: - """ Obtain the batch with metadata for items that need caching and cache DetectedFace - objects to :attr:`_cache`. - - Parameters - ---------- - filenames: list - List of full paths to image file names + def check_reset(self) -> bool: + """ Check whether this cache has been reset due to a face centering change, and reset the + flag if it has. Returns ------- - :class:`numpy.ndarray` - The batch of face images loaded from disk - """ - keys = [os.path.basename(filename) for filename in filenames] - with self._lock: - if _check_reset(self): - self._reset_cache(False) - - needs_cache = [filename - for filename, key in zip(filenames, keys) - if key not in self._cache or key in self._partially_loaded] - logger.trace("Needs cache: %s", needs_cache) # type: ignore - - if not needs_cache: - # Don't bother reading the metadata if no images in this batch need caching - logger.debug("All metadata already cached for: %s", keys) - return read_image_batch(filenames) - - try: - batch, metadata = read_image_batch(filenames, with_metadata=True) - except ValueError as err: - if "inhomogeneous" in str(err): - raise FaceswapError( - "There was an error loading a batch of images. This is most likely due to " - "non-faceswap extracted faces in your training folder." - "\nAll training images should be Faceswap extracted faces." - "\nAll training images should be the same size." - f"\nThe files that caused this error are: {filenames}") from err - raise - if len(batch.shape) == 1: - folder = os.path.dirname(filenames[0]) - details = [ - f"{key} ({f'{img.shape[1]}px' if isinstance(img, np.ndarray) else type(img)})" - for key, img in zip(keys, batch)] - msg = (f"There are mismatched image sizes in the folder '{folder}'. All training " - "images for each side must have the same dimensions.\nThe batch that " - f"failed contains the following files:\n{details}.") - raise FaceswapError(msg) - - # Populate items into cache - for filename in needs_cache: - key = os.path.basename(filename) - meta = metadata[filenames.index(filename)] - - # Version Check - self._validate_version(meta, filename) - if self._partially_loaded: # Faces already loaded for Warp-to-landmarks - self._partially_loaded.remove(key) - detected_face = self._cache[key] - else: - detected_face = self._load_detected_face(filename, meta["alignments"]) - - self._prepare_masks(filename, detected_face) - self._cache[key] = detected_face - - # Update the :attr:`cache_full` attribute - cache_full = not self._partially_loaded and len(self._cache) == self._image_count - if cache_full: - logger.verbose("Cache filled: '%s'", os.path.dirname(filenames[0])) # type: ignore - self._cache_info["cache_full"] = cache_full - - return batch + bool + ``True`` if the cache has been reset because of a face centering change due to + legacy alignments, otherwise ``False``. """ + retval = self._cache_info["has_reset"] + if retval: + logger.debug("Resetting 'has_reset' flag") + self._cache_info["has_reset"] = False + return retval - def pre_fill(self, filenames: list[str], side: T.Literal["a", "b"]) -> None: - """ When warp to landmarks is enabled, the cache must be pre-filled, as each side needs - access to the other side's alignments. + def _reset_cache(self, set_flag: bool) -> None: + """ In the event that a legacy extracted face has been seen, and centering is not legacy + the cache will need to be reset for legacy centering. Parameters ---------- - filenames: list - The list of full paths to the images to load the metadata from - side: str - `"a"` or `"b"`. The side of the model being cached. Used for info output - - Raises - ------ - FaceSwapError - If unsupported landmark type exists + set_flag: bool + ``True`` if the flag should be set to indicate that the cache is being reset because of + a legacy face set/centering mismatch. ``False`` if the cache is being reset because it + has detected a reset flag from the opposite cache. """ - with self._lock: - for filename, meta in tqdm(read_image_meta_batch(filenames), - desc=f"WTL: Caching Landmarks ({side.upper()})", - total=len(filenames), - leave=False): - if "itxt" not in meta or "alignments" not in meta["itxt"]: - raise FaceswapError(f"Invalid face image found. Aborting: '{filename}'") - - meta = meta["itxt"] - key = os.path.basename(filename) - # Version Check - self._validate_version(meta, filename) - detected_face = self._load_detected_face(filename, meta["alignments"]) - - aligned = detected_face.aligned - assert aligned is not None - if aligned.landmark_type != LandmarkType.LM_2D_68: - raise FaceswapError("68 Point facial Landmarks are required for Warp-to-" - f"landmarks. The face that failed was: '{filename}'") - - self._cache[key] = detected_face - self._partially_loaded.append(key) + if set_flag: + logger.warning("You are using legacy extracted faces but have selected '%s' centering " + "which is incompatible. Switching centering to 'legacy'", + self._config.centering) + cfg.centering.set("legacy") + self._config.centering = "legacy" + self._cache = {} + self._cache_info["cache_full"] = False + if set_flag: + self._cache_info["has_reset"] = True def _validate_version(self, png_meta: PNGHeaderDict, filename: str) -> None: """ Validate that there are not a mix of v1.0 extracted faces and v2.x faces. Parameters ---------- - png_meta: dict + png_meta : :class:`~lib.align.alignments.PNGHeaderDict` The information held within the Faceswap PNG Header filename: str The full path to the file being validated Raises ------ - FaceswapError + :class:`~lib.utils.FaceswapError` If a version 1.0 face appears in a 2.x set or vice versa """ alignment_version = png_meta["source"]["alignments_version"] @@ -328,7 +414,7 @@ def _validate_version(self, png_meta: PNGHeaderDict, filename: str) -> None: if not self._extract_version: logger.debug("Setting initial extract version: %s", alignment_version) self._extract_version = alignment_version - if alignment_version == 1.0 and self._centering != "legacy": + if alignment_version == 1.0 and self._config.centering != "legacy": self._reset_cache(True) return @@ -340,158 +426,230 @@ def _validate_version(self, png_meta: PNGHeaderDict, filename: str) -> None: self._extract_version = min(alignment_version, self._extract_version) - def _reset_cache(self, set_flag: bool) -> None: - """ In the event that a legacy extracted face has been seen, and centering is not legacy - the cache will need to be reset for legacy centering. - - Parameters - ---------- - set_flag: bool - ``True`` if the flag should be set to indicate that the cache is being reset because of - a legacy face set/centering mismatch. ``False`` if the cache is being reset because it - has detected a reset flag from the opposite cache. - """ - if set_flag: - logger.warning("You are using legacy extracted faces but have selected '%s' centering " - "which is incompatible. Switching centering to 'legacy'", - self._centering) - self._config["centering"] = "legacy" - self._centering = "legacy" - self._cache = {} - self._cache_info["cache_full"] = False - if set_flag: - self._cache_info["has_reset"] = True - def _load_detected_face(self, filename: str, alignments: PNGHeaderAlignmentsDict) -> DetectedFace: - """ Load a :class:`DetectedFace` object and load its associated `aligned` property. + """ Load a :class:`~lib.align.detected_face.DetectedFace` object and load its associated + `aligned` property. Parameters ---------- - filename: str + filename : str The file path for the current image - alignments: dict + alignments : :class:`~lib.align.alignments.PNGHeaderAlignmentsDict` The alignments for a single face, extracted from a PNG header Returns ------- - :class:`lib.align.DetectedFace` + :class:`~lib.align.detected_face.DetectedFace` The loaded Detected Face object """ + y_offset = cfg.vertical_offset() detected_face = DetectedFace() detected_face.from_png_meta(alignments) detected_face.load_aligned(None, - size=self._size, - centering=self._centering, - coverage_ratio=self._coverage_ratio, + size=self._config.size, + centering=self._config.centering, + coverage_ratio=self._config.coverage, + y_offset=y_offset / 100., is_aligned=True, is_legacy=self._extract_version == 1.0) - logger.trace("Cached aligned face for: %s", filename) # type: ignore + logger.trace("Cached aligned face for: %s", filename) # type:ignore[attr-defined] return detected_face - def _prepare_masks(self, filename: str, detected_face: DetectedFace) -> None: - """ Prepare the masks required from training, and compile into a single compressed array + def _populate_cache(self, + needs_cache: list[str], + metadata: list[PNGHeaderDict], + filenames: list[str]) -> None: + """ Populate the given items into the cache Parameters ---------- - filename: str - The file path for the current image - detected_face: :class:`lib.align.DetectedFace` - The detected face object that holds the masks + needs_cache : list[str] + The full path to files within this batch that require caching + metadata : list[:class:`~lib.align.alignments.PNGHeaderDict`] + The faceswap metadata loaded from the image png header + filenames : list[str] + Full path to the filenames that are being loaded in this batch """ - masks = [(self._get_face_mask(filename, detected_face))] - for area in T.get_args(T.Literal["eye", "mouth"]): - masks.append(self._get_localized_mask(filename, detected_face, area)) + for filename in needs_cache: + key = os.path.basename(filename) + meta = metadata[filenames.index(filename)] + + # Version Check + self._validate_version(meta, filename) + if self._partially_loaded: # Faces already loaded for Warp-to-landmarks + self._partially_loaded.remove(key) + detected_face = self._cache[key] + else: + detected_face = self._load_detected_face(filename, meta["alignments"]) - detected_face.store_training_masks(masks, delete_masks=True) - logger.trace("Stored masks for filename: %s)", filename) # type: ignore + self._mask_prepare(filename, detected_face) + self._cache[key] = detected_face - def _get_face_mask(self, filename: str, detected_face: DetectedFace) -> np.ndarray | None: - """ Obtain the training sized face mask from the :class:`DetectedFace` for the requested - mask type. + def _get_batch_with_metadata(self, + filenames: list[str]) -> tuple[np.ndarray, list[PNGHeaderDict]]: + """ Load a batch of images along with their faceswap metadata for loading into the cache Parameters ---------- - filename: str - The file path for the current image - detected_face: :class:`lib.align.DetectedFace` - The detected face object that holds the masks + filenames : list[str] + Full path to the images to be loaded - Raises - ------ - FaceswapError - If the requested mask type is not available an error is returned along with a list - of available masks + Returns + ------- + batch : :class:`numpy.ndarray` + The batch of images in a single array + metadata : :class:`~lib.align.alignments.PNGHeaderDict` + The faceswap metadata corresponding to each image in the batch """ - if not self._config["penalized_mask_loss"] and not self._config["learn_mask"]: - return None + try: + batch, metadata = read_image_batch(filenames, with_metadata=True) + except ValueError as err: + if "inhomogeneous" in str(err): + raise FaceswapError( + "There was an error loading a batch of images. This is most likely due to " + "non-faceswap extracted faces in your training folder." + "\nAll training images should be Faceswap extracted faces." + "\nAll training images should be the same size." + f"\nThe files that caused this error are: {filenames}") from err + raise + if len(batch.shape) == 1: + folder = os.path.dirname(filenames[0]) + keys = [os.path.basename(filename) for filename in filenames] + details = [ + f"{key} ({f'{img.shape[1]}px' if isinstance(img, np.ndarray) else type(img)})" + for key, img in zip(keys, batch)] + msg = (f"There are mismatched image sizes in the folder '{folder}'. All training " + "images for each side must have the same dimensions.\nThe batch that " + f"failed contains the following files:\n{details}.") + raise FaceswapError(msg) + return batch, metadata + + def _update_cache_full(self, filenames: list[str]) -> None: + """ Check if cache is full and update the "cache_full" flag in :attr:`_cache_info` if so - if not self._config["mask_type"]: - logger.debug("No mask selected. Not validating") - return None + Parameters + ---------- + filenames : list[str] + Full path to the filenames being processed in the current batch + """ + cache_full = not self._partially_loaded and len(self._cache) == self._image_count + if cache_full: + logger.verbose("Cache filled: '%s'", # type:ignore[attr-defined] + os.path.dirname(filenames[0])) + self._cache_info["cache_full"] = cache_full - if self._config["mask_type"] not in detected_face.mask: - exist_masks = list(detected_face.mask) - msg = "No masks exist for this face" - if exist_masks: - msg = f"The masks that exist for this face are: {exist_masks}" - raise FaceswapError( - f"You have selected the mask type '{self._config['mask_type']}' but at least one " - "face does not contain the selected mask.\n" - f"The face that failed was: '{filename}'\n{msg}") - - mask = detected_face.mask[str(self._config["mask_type"])] - assert isinstance(self._config["mask_dilation"], float) - assert isinstance(self._config["mask_blur_kernel"], int) - assert isinstance(self._config["mask_threshold"], int) - mask.set_dilation(self._config["mask_dilation"]) - mask.set_blur_and_threshold(blur_kernel=self._config["mask_blur_kernel"], - threshold=self._config["mask_threshold"]) + def cache_metadata(self, filenames: list[str]) -> np.ndarray: + """ Obtain the batch with metadata for items that need caching and cache DetectedFace + objects to :attr:`_cache`. - pose = detected_face.aligned.pose - mask.set_sub_crop(pose.offset[mask.stored_centering], - pose.offset[self._centering], - self._centering, - self._coverage_ratio) - face_mask = mask.mask - if self._size != face_mask.shape[0]: - interpolator = cv2.INTER_CUBIC if mask.stored_size < self._size else cv2.INTER_AREA - face_mask = cv2.resize(face_mask, - (self._size, self._size), - interpolation=interpolator)[..., None] + Parameters + ---------- + filenames : list[str] + List of full paths to image file names - logger.trace("Obtained face mask for: %s %s", filename, face_mask.shape) # type: ignore - return face_mask + Returns + ------- + :class:`numpy.ndarray` + The batch of face images loaded from disk + """ + keys = [os.path.basename(filename) for filename in filenames] + with self._lock: + if _check_reset(self): + self._reset_cache(False) - def _get_localized_mask(self, - filename: str, - detected_face: DetectedFace, - area: T.Literal["eye", "mouth"]) -> np.ndarray | None: - """ Obtain a localized mask for the given area if it is required for training. + needs_cache = [filename for filename, key in zip(filenames, keys) + if key not in self._cache or key in self._partially_loaded] + logger.trace("Needs cache: %s", needs_cache) # type:ignore[attr-defined] + + if not needs_cache: # Metadata already cached. Just get images + logger.debug("All metadata already cached for: %s", keys) + return read_image_batch(filenames) + + batch, metadata = self._get_batch_with_metadata(filenames) + self._populate_cache(needs_cache, metadata, filenames) + self._update_cache_full(filenames) + + return batch + + def pre_fill(self, filenames: list[str], side: T.Literal["a", "b"]) -> None: + """ When warp to landmarks is enabled, the cache must be pre-filled, as each side needs + access to the other side's alignments. Parameters ---------- - filename: str - The file path for the current image - detected_face: :class:`lib.align.DetectedFace` - The detected face object that holds the masks - area: str - `"eye"` or `"mouth"`. The area of the face to obtain the mask for + filenames : list[str] + The list of full paths to the images to load the metadata from + side : Literal["a", "b"] + The side of the model being cached. Used for info output + + Raises + ------ + :class:`~lib.utils.FaceSwapError` + If unsupported landmark type exists or a non-faceswap image is loaded """ - multiplier = self._config[f"{area}_multiplier"] - assert isinstance(multiplier, int) - if not self._config["penalized_mask_loss"] or multiplier <= 1: - return None - try: - mask = detected_face.get_landmark_mask(area, self._size // 16, 2.5) - except FaceswapError as err: - logger.error(str(err)) - raise FaceswapError("Eye/Mouth multiplier masks could not be generated due to missing " - f"landmark data. The file that failed was: '{filename}'") from err - logger.trace("Caching localized '%s' mask for: %s %s", # type: ignore - area, filename, mask.shape) - return mask + with self._lock: + for filename, meta in tqdm(read_image_meta_batch(filenames), + desc=f"WTL: Caching Landmarks ({side.upper()})", + total=len(filenames), + leave=False): + if "itxt" not in meta or "alignments" not in meta["itxt"]: + raise FaceswapError(f"Invalid face image found. Aborting: '{filename}'") + + meta = meta["itxt"] + key = os.path.basename(filename) + self._validate_version(meta, filename) + detected_face = self._load_detected_face(filename, meta["alignments"]) + + aligned = detected_face.aligned + assert aligned is not None + if aligned.landmark_type != LandmarkType.LM_2D_68: + raise FaceswapError("68 Point facial Landmarks are required for Warp-to-" + f"landmarks. The face that failed was: '{filename}'") + + self._cache[key] = detected_face + self._partially_loaded.append(key) + + +def get_cache(side: T.Literal["a", "b"], + filenames: list[str] | None = None, + size: int | None = None, + coverage_ratio: float | None = None) -> Cache: + """ Obtain a :class:`Cache` object for the given side. If the object does not pre-exist then + create it. + + Parameters + ---------- + side : Literal["a", "b"] + The side of the model to obtain the cache for + filenames : list[str] | None, optional + The filenames of all the images. This can either be the full path or the base name. If the + full paths are passed in, they are stripped to base name for use as the cache key. Must be + passed for the first call of this function for each side. For subsequent calls this + parameter is ignored. Default: ``None`` + size: int | None, optional + The largest output size of the model. Must be passed for the first call of this function + for each side. For subsequent calls this parameter is ignored. Default: ``None`` + coverage_ratio : float | None, optional + The coverage ratio that the model is using. Must be passed for the first call of this + function for each side. For subsequent calls this parameter is ignored. Default: ``None`` + + Returns + ------- + :class:`Cache` + The face meta information cache for the requested side + """ + assert side in ("a", "b") + if not _FACE_CACHES.get(side): + assert filenames is not None, "filenames must be provided for first call to cache" + assert size is not None, "size must be provided for first call to cache" + assert coverage_ratio is not None, ("coverage_ratio must be provided for first call to " + "cache") + logger.debug("Creating cache. side: %s, size: %s, coverage_ratio: %s", + side, size, coverage_ratio) + _FACE_CACHES[side] = Cache(filenames, size, coverage_ratio) + return _FACE_CACHES[side] class RingBuffer(): @@ -499,13 +657,13 @@ class RingBuffer(): Parameters ---------- - batch_size: int + batch_size : int The batch size to create the buffer for - image_shape: tuple + image_shape : tuple[int, int, int] The height/width/channels shape of a single image in the batch - buffer_size: int, optional + buffer_size : int, optional The number of arrays to hold in the rolling buffer. Default: `2` - dtype: str, optional + dtype : str, optional The datatype to create the buffer as. Default: `"uint8"` """ def __init__(self, @@ -513,14 +671,21 @@ def __init__(self, image_shape: tuple[int, int, int], buffer_size: int = 2, dtype: str = "uint8") -> None: - logger.debug("Initializing: %s (batch_size: %s, image_shape: %s, buffer_size: %s, " - "dtype: %s", self.__class__.__name__, batch_size, image_shape, buffer_size, - dtype) + logger.debug(parse_class_init(locals())) self._max_index = buffer_size - 1 self._index = 0 self._buffer = [np.empty((batch_size, *image_shape), dtype=dtype) for _ in range(buffer_size)] - logger.debug("Initialized: %s", self.__class__.__name__) # type: ignore + logger.debug("Initialized: %s", self) + + def __repr__(self) -> str: + """ Pretty string representation for logging """ + params = {"batch_size": repr(self._buffer[0].shape[0]), + "image_shape": repr(self._buffer[0].shape[1:]), + "buffer_size": repr(len(self._buffer)), + "dtype": repr(str(self._buffer[0].dtype))} + str_params = [f"{k}={v}" for k, v in params.items()] + return f"{self.__class__.__name__}({', '.join(str_params)})" def __call__(self) -> np.ndarray: """ Obtain the next array from the ring buffer @@ -533,3 +698,6 @@ def __call__(self) -> np.ndarray: retval = self._buffer[self._index] self._index += 1 if self._index < self._max_index else -self._max_index return retval + + +__all__ = get_module_objects(__name__) diff --git a/lib/training/generator.py b/lib/training/generator.py index 0624246e0a..f674e07aae 100644 --- a/lib/training/generator.py +++ b/lib/training/generator.py @@ -15,22 +15,23 @@ from lib.align.aligned_face import CenteringType from lib.image import read_image_batch from lib.multithreading import BackgroundGenerator -from lib.utils import FaceswapError +from lib.utils import FaceswapError, get_module_objects +from plugins.train import train_config as mod_cfg +from plugins.train.trainer import trainer_config as trn_cfg from . import ImageAugmentation from .cache import get_cache, RingBuffer if T.TYPE_CHECKING: from collections.abc import Generator - from lib.config import ConfigValueType from plugins.train.model._base import ModelBase - from .cache import _Cache + from .cache import Cache logger = logging.getLogger(__name__) BatchType = tuple[np.ndarray, list[np.ndarray]] -class DataGenerator(): +class DataGenerator(): # pylint:disable=too-many-instance-attributes """ Parent class for Training and Preview Data Generators. This class is called from :mod:`plugins.train.trainer._base` and launches a background @@ -40,9 +41,6 @@ class DataGenerator(): ---------- model: :class:`~plugins.train.model.ModelBase` The model that this data generator is feeding - config: dict - The configuration `dict` generated from :file:`config.train.ini` containing the trainer - plugin configuration options. side: {'a' or 'b'} The side of the model that this iterator is for. images: list @@ -52,15 +50,13 @@ class DataGenerator(): objects of this size from the iterator. """ def __init__(self, - config: dict[str, ConfigValueType], model: ModelBase, side: T.Literal["a", "b"], images: list[str], batch_size: int) -> None: logger.debug("Initializing %s: (model: %s, side: %s, images: %s , " - "batch_size: %s, config: %s)", self.__class__.__name__, model.name, side, - len(images), batch_size, config) - self._config = config + "batch_size: %s)", self.__class__.__name__, model.name, side, + len(images), batch_size) self._side = side self._images = images self._batch_size = batch_size @@ -71,18 +67,17 @@ def __init__(self, self._coverage_ratio = model.coverage_ratio self._color_order = model.color_order.lower() - self._use_mask = self._config["mask_type"] and (self._config["penalized_mask_loss"] or - self._config["learn_mask"]) + self._use_mask = mod_cfg.Loss.mask_type() and (mod_cfg.Loss.penalized_mask_loss() or + mod_cfg.Loss.learn_mask()) self._validate_samples() self._buffer = RingBuffer(batch_size, (self._process_size, self._process_size, self._total_channels), dtype="uint8") - self._face_cache: _Cache = get_cache(side, - filenames=images, - config=self._config, - size=self._process_size, - coverage_ratio=self._coverage_ratio) + self._face_cache: Cache = get_cache(side, + filenames=images, + size=self._process_size, + coverage_ratio=self._coverage_ratio) logger.debug("Initialized %s", self.__class__.__name__) @property @@ -90,13 +85,16 @@ def _total_channels(self) -> int: """int: The total number of channels, including mask channels that the target image should hold. """ channels = 3 - if self._config["mask_type"] and (self._config["learn_mask"] or - self._config["penalized_mask_loss"]): + if mod_cfg.Loss.mask_type() and (mod_cfg.Loss.learn_mask() or + mod_cfg.Loss.penalized_mask_loss()): channels += 1 - mults = [area for area in ["eye", "mouth"] - if T.cast(int, self._config[f"{area}_multiplier"]) > 1] - if self._config["penalized_mask_loss"] and mults: + mults = [area + for area, amount in zip(["eye", "mouth"], + [mod_cfg.Loss.eye_multiplier(), + mod_cfg.Loss.mouth_multiplier()]) + if amount > 1] + if mod_cfg.Loss.penalized_mask_loss() and mults: channels += len(mults) return channels @@ -207,8 +205,7 @@ def _img_iter(imgs): while True: if do_shuffle: shuffle(imgs) - for img in imgs: - yield img + yield from imgs img_iter = _img_iter(self._images[:]) while True: @@ -401,9 +398,6 @@ class TrainingDataGenerator(DataGenerator): ---------- model: :class:`~plugins.train.model.ModelBase` The model that this data generator is feeding - config: dict - The configuration `dict` generated from :file:`config.train.ini` containing the trainer - plugin configuration options. side: {'a' or 'b'} The side of the model that this iterator is for. images: list @@ -413,12 +407,11 @@ class TrainingDataGenerator(DataGenerator): objects of this size from the iterator. """ def __init__(self, - config: dict[str, ConfigValueType], model: ModelBase, side: T.Literal["a", "b"], images: list[str], batch_size: int) -> None: - super().__init__(config, model, side, images, batch_size) + super().__init__(model, side, images, batch_size) self._augment_color = not model.command_line_arguments.no_augment_color self._no_flip = model.command_line_arguments.no_flip self._no_warp = model.command_line_arguments.no_warp @@ -428,8 +421,7 @@ def __init__(self, if self._warp_to_landmarks: self._face_cache.pre_fill(images, side) self._processing = ImageAugmentation(batch_size, - self._process_size, - self._config) + self._process_size) self._nearest_landmarks: dict[str, tuple[str, ...]] = {} logger.debug("Initialized %s", self.__class__.__name__) @@ -626,9 +618,6 @@ class PreviewDataGenerator(DataGenerator): ---------- model: :class:`~plugins.train.model.ModelBase` The model that this data generator is feeding - config: dict - The configuration `dict` generated from :file:`config.train.ini` containing the trainer - plugin configuration options. side: {'a' or 'b'} The side of the model that this iterator is for. images: list @@ -661,13 +650,16 @@ def _create_samples(self, output_size = self._output_sizes[-1] full_size = 2 * int(np.rint((output_size / self._coverage_ratio) / 2)) - assert self._config["centering"] in T.get_args(CenteringType) + assert mod_cfg.centering() in T.get_args(CenteringType) retval = np.empty((full_size, full_size, 3), dtype="float32") + y_offset = mod_cfg.vertical_offset() + assert isinstance(y_offset, int) retval = self._to_float32(np.array([ AlignedFace(face.landmarks_xy, image=images[idx], centering=T.cast(CenteringType, - self._config["centering"]), + mod_cfg.centering()), + y_offset=y_offset / 100., size=full_size, dtype="uint8", is_aligned=True).face @@ -745,8 +737,6 @@ class Feeder(): The selected model that will be running this trainer batch_size: int The size of the batch to be processed for each side at each iteration - config: dict - The configuration for this trainer include_preview: bool, optional ``True`` to create a feeder for generating previews. Default: ``True`` """ @@ -754,15 +744,13 @@ def __init__(self, images: dict[T.Literal["a", "b"], list[str]], model: ModelBase, batch_size: int, - config: dict[str, ConfigValueType], include_preview: bool = True) -> None: - logger.debug("Initializing %s: num_images: %s, batch_size: %s, config: %s, " - "include_preview: %s)", self.__class__.__name__, - {k: len(v) for k, v in images.items()}, batch_size, config, include_preview) + logger.debug("Initializing %s: num_images: %s, batch_size: %s, include_preview: %s)", + self.__class__.__name__, {k: len(v) for k, v in images.items()}, batch_size, + include_preview) self._model = model self._images = images self._batch_size = batch_size - self._config = config self._feeds = { side: self._load_generator(side, False).minibatch_ab() for side in T.get_args(T.Literal["a", "b"])} @@ -800,8 +788,7 @@ def _load_generator(self, logger.debug("Loading generator, side: %s, is_display: %s, batch_size: %s", side, is_display, batch_size) generator = PreviewDataGenerator if is_display else TrainingDataGenerator - retval = generator(self._config, - self._model, + retval = generator(self._model, side, self._images[side] if images is None else images, self._batch_size if batch_size is None else batch_size) @@ -820,7 +807,7 @@ def _set_preview_feed(self) -> dict[T.Literal["a", "b"], Generator[BatchType, No value. """ retval: dict[T.Literal["a", "b"], Generator[BatchType, None, None]] = {} - num_images = self._config.get("preview_images", 14) + num_images = trn_cfg.preview_images() assert isinstance(num_images, int) for side in T.get_args(T.Literal["a", "b"]): logger.debug("Setting preview feed: (side: '%s')", side) @@ -831,30 +818,41 @@ def _set_preview_feed(self) -> dict[T.Literal["a", "b"], Generator[BatchType, No batch_size=batchsize).minibatch_ab() return retval - def get_batch(self) -> tuple[list[list[np.ndarray]], ...]: + def get_batch(self) -> tuple[np.ndarray, list[np.ndarray]]: """ Get the feed data and the targets for each training side for feeding into the model's train function. Returns ------- - model_inputs: list - The inputs to the model for each side A and B - model_targets: list - The targets for the model for each side A and B + model_inputs : :class:`numpy.ndarray` + The inputs to the model for each side A and B. The array is returned in `(side, + batch_size, *dims)` where `side` 0 is "A" and `side` 1 is "B" + model_targets : list[:class:`numpy.ndarray`] + The targets for the model for each side A and B. For each target resolution output + required an array is inserted to the list in format `(side, batch_size, *dims) + where `side` 0 is "A" and `side` 1 is "B" """ - model_inputs: list[list[np.ndarray]] = [] - model_targets: list[list[np.ndarray]] = [] - for side in ("a", "b"): + model_inputs: list[np.ndarray] = [] + model_targets: tuple[list[np.ndarray], list[np.ndarray]] = ([], []) + for idx, side in enumerate(("a", "b")): side_feed, side_targets = next(self._feeds[side]) - if self._model.config["learn_mask"]: # Add the face mask as it's own target + if mod_cfg.Loss.learn_mask(): # Add the face mask as it's own target side_targets += [side_targets[-1][..., 3][..., None]] logger.trace( # type:ignore[attr-defined] "side: %s, input_shapes: %s, target_shapes: %s", side, side_feed.shape, [i.shape for i in side_targets]) - model_inputs.append([side_feed]) - model_targets.append(side_targets) + model_inputs.append(side_feed) + model_targets[idx].extend(side_targets) - return model_inputs, model_targets + grouped_targets = [] + + for tgt_a, tgt_b in zip(*model_targets): + grouped_targets.append(np.stack([tgt_a, tgt_b], axis=0)) + inputs = np.stack(model_inputs, axis=0) + assert inputs.shape[0] == 2, "1st dimension should represent side A/B" + assert all(x.shape[0] == 2 for x in grouped_targets), ("1st dimension should represent " + "side A/B") + return inputs, grouped_targets def generate_preview(self, is_timelapse: bool = False ) -> dict[T.Literal["a", "b"], list[np.ndarray]]: @@ -923,7 +921,7 @@ def compile_sample(self, The list of samples, targets and masks as :class:`numpy.ndarrays` for creating a preview image """ - num_images = self._config.get("preview_images", 14) + num_images = trn_cfg.preview_images() assert isinstance(num_images, int) num_images = min(image_count, num_images) retval: dict[T.Literal["a", "b"], list[np.ndarray]] = {} @@ -966,3 +964,6 @@ def set_timelapse_feed(self, batch_size=batch_size, images=imgs).minibatch_ab(do_shuffle=False) logger.debug("Set time-lapse feed: %s", self._display_feeds["timelapse"]) + + +__all__ = get_module_objects(__name__) diff --git a/lib/training/lr_finder.py b/lib/training/lr_finder.py index 989f9df873..b12bd677a4 100644 --- a/lib/training/lr_finder.py +++ b/lib/training/lr_finder.py @@ -8,19 +8,18 @@ from datetime import datetime from enum import Enum -import tensorflow as tf import matplotlib import matplotlib.pyplot as plt import numpy as np from tqdm import tqdm -if T.TYPE_CHECKING: - from lib.config import ConfigValueType - from lib.training import Feeder - from plugins.train.model._base import ModelBase +from lib.logger import parse_class_init +from lib.utils import get_module_objects +from plugins.train import train_config as cfg -keras = tf.keras -K = keras.backend +if T.TYPE_CHECKING: + from keras import optimizers + from plugins.train import training logger = logging.getLogger(__name__) @@ -32,42 +31,35 @@ class LRStrength(Enum): EXTREME = 2.5 -class LearningRateFinder: +class LearningRateFinder: # pylint:disable=too-many-instance-attributes """ Learning Rate Finder Parameters ---------- - model: :class:`tensorflow.keras.models.Model` - The keras model to find the optimal learning rate for - config: dict - The configuration options for the model - feeder: :class:`~lib.training.generator.Feeder` - The feeder for training the model - stop_factor: int + trainer : :class:`plugins.train.run_trainer.Trainer` + The training loop with the loaded training plugin + stop_factor : int When to stop finding the optimal learning rate - beta: float + beta : float Amount to smooth loss by, for graphing purposes """ def __init__(self, # pylint:disable=too-many-positional-arguments - model: ModelBase, - config: dict[str, ConfigValueType], - feeder: Feeder, + trainer: training.Trainer, stop_factor: int = 4, beta: float = 0.98) -> None: - logger.debug("Initializing %s: (model: %s, config: %s, feeder: %s, stop_factor: %s, " - "beta: %s)", - self.__class__.__name__, model, config, feeder, stop_factor, beta) - - self._iterations = T.cast(int, config["lr_finder_iterations"]) - self._save_graph = config["lr_finder_mode"] in ("graph_and_set", "graph_and_exit") - self._strength = LRStrength[T.cast(str, config["lr_finder_strength"]).upper()].value - self._config = config + logger.debug(parse_class_init(locals())) + self._iterations = cfg.lr_finder_iterations() + self._save_graph = cfg.lr_finder_mode() in ("graph_and_set", "graph_and_exit") + self._strength = LRStrength[cfg.lr_finder_strength().upper()].value self._start_lr = 1e-10 end_lr = 1e+1 - self._model = model - self._feeder = feeder + self._trainer = trainer + + self._model = trainer._plugin.model + self._optimizer = trainer._plugin.model.model.optimizer + self._stop_factor = stop_factor self._beta = beta self._lr_multiplier: float = (end_lr / self._start_lr) ** (1.0 / self._iterations) @@ -89,7 +81,7 @@ def _on_batch_end(self, iteration: int, loss: float) -> None: loss: float The loss value for the current batch """ - learning_rate = K.get_value(self._model.model.optimizer.lr) + learning_rate = float(self._optimizer.learning_rate.numpy()) self._metrics["learning_rates"].append(learning_rate) self._loss["avg"] = (self._beta * self._loss["avg"]) + ((1 - self._beta) * loss) @@ -107,7 +99,7 @@ def _on_batch_end(self, iteration: int, loss: float) -> None: learning_rate *= self._lr_multiplier - K.set_value(self._model.model.optimizer.lr, learning_rate) + self._optimizer.learning_rate.assign(learning_rate) def _update_description(self, progress_bar: tqdm) -> None: """ Update the description of the progress bar for the current iteration @@ -130,14 +122,32 @@ def _train(self) -> None: desc="Current: N/A Best: N/A ", leave=False) for idx in pbar: - model_inputs, model_targets = self._feeder.get_batch() - loss: list[float] = self._model.model.train_on_batch(model_inputs, y=model_targets) + loss = self._trainer.train_one_batch() + if any(np.isnan(x) for x in loss): logger.warning("NaN detected! Exiting early") break self._on_batch_end(idx, loss[0]) self._update_description(pbar) + def _rebuild_optimizer(self, optimizer: optimizers.Optimizer) -> optimizers.Optimizer: + """ Pass through nested Optimizers (eg LossScaleOptimizer) and create new nested + optimizers based on their original config + + Returns + ------- + :class:`keras.optimizers.Optimizer` + A new optimizer of the same type as the given one, with the same config + """ + logger.debug("Processing optimizer: '%s'", optimizer.name) + config = optimizer.get_config() + if hasattr(optimizer, "inner_optimizer"): + config["inner_optimizer"] = self._rebuild_optimizer(optimizer.inner_optimizer) + retval = optimizer.__class__(**config) + logger.debug("Created optimizer '%s': (old: %s, new: %s)", + optimizer.name, optimizer, retval) + return retval + def _reset_model(self, original_lr: float, new_lr: float) -> None: """ Reset the model's weights to initial values, reset the model's optimizer and set the learning rate @@ -152,19 +162,24 @@ def _reset_model(self, original_lr: float, new_lr: float) -> None: self._model.state.add_lr_finder(new_lr) self._model.state.save() - logger.debug("Loading initial weights") - self._model.model.load_weights(self._model.io.filename) - - if self._config["lr_finder_mode"] == "graph_and_exit": + if cfg.lr_finder_mode() == "graph_and_exit": return - opt_conf = self._model.model.optimizer.get_config() - logger.debug("Recompiling model to reset optimizer state. Optimizer config: %s", opt_conf) - new_opt = self._model.model.optimizer.__class__(**opt_conf) - self._model.model.compile(optimizer=new_opt, loss=self._model.model.loss) + logger.debug("Resetting optimizer") + optimizer = self._rebuild_optimizer(self._optimizer) + del self._optimizer + del self._model.model.optimizer + + logger.info("Loading initial weights") + self._model.model.load_weights(self._model.io.filename) + + self._model.model.compile(optimizer=optimizer, + loss=self._model.model.loss, + metrics=self._model.model.loss) logger.info("Updating Learning Rate from %s to %s", f"{original_lr:.1e}", f"{new_lr:.1e}") - K.set_value(self._model.model.optimizer.lr, new_lr) + self._model.model.optimizer.learning_rate.assign(new_lr) + self._optimizer = self._model.model.optimizer def find(self) -> bool: """ Find the optimal learning rate @@ -177,11 +192,11 @@ def find(self) -> bool: if not self._model.io.model_exists: self._model.io.save() - original_lr = K.get_value(self._model.model.optimizer.lr) - K.set_value(self._model.model.optimizer.lr, self._start_lr) + original_lr = float(self._model.model.optimizer.learning_rate.numpy()) + self._model.model.optimizer.learning_rate.assign(self._start_lr) self._train() - print() + print("\x1b[2K", end="\r") # Clear line best_idx = self._metrics["losses"].index(self._loss["best"]) new_lr = self._metrics["learning_rates"][best_idx] / self._strength @@ -231,3 +246,6 @@ def _plot_loss(self, skip_begin: int = 10, skip_end: int = 1) -> None: output = os.path.join(self._model.io.model_dir, f"learning_rate_finder_{now}.png") logger.info("Saving Learning Rate Finder graph to: '%s'", output) plt.savefig(output) + + +__all__ = get_module_objects(__name__) diff --git a/lib/training/lr_warmup.py b/lib/training/lr_warmup.py index 5044832400..6bbb33ee7e 100644 --- a/lib/training/lr_warmup.py +++ b/lib/training/lr_warmup.py @@ -5,12 +5,12 @@ import logging import typing as T -import keras.backend as K - -logger = logging.getLogger(__name__) +from lib.utils import get_module_objects if T.TYPE_CHECKING: - from keras.models import Model + from keras import models + +logger = logging.getLogger(__name__) class LearningRateWarmup(): @@ -25,7 +25,7 @@ class LearningRateWarmup(): steps : int The number of iterations to warmup the learning rate for """ - def __init__(self, model: Model, target_learning_rate: float, steps: int) -> None: + def __init__(self, model: models.Model, target_learning_rate: float, steps: int) -> None: self._model = model self._target_lr = target_learning_rate self._steps = steps @@ -36,8 +36,12 @@ def __init__(self, model: Model, target_learning_rate: float, steps: int) -> Non def __repr__(self) -> str: """ Pretty string representation for logging """ - params = ", ".join(f"{k}={v}" for k, v in self.__dict__.items()) - return f"{self.__class__.__name__}({params})" + call_args = ", ".join(f"{k}={v}" for k, v in {"model": self._model, + "target_learning_rate": self._target_lr, + "steps": self._steps}.items()) + current_params = ", ".join(f"{k[1:]}: {v}" for k, v in self.__dict__.items() + if k not in ("_model", "_target_lr", "_steps")) + return f"{self.__class__.__name__}({call_args}) [{current_params}]" @classmethod def _format_notation(cls, value: float) -> str: @@ -58,7 +62,7 @@ def _format_notation(cls, value: float) -> str: def _set_learning_rate(self) -> None: """ Set the learning rate for the current step """ self._current_lr = self._current_step / self._steps * self._target_lr - K.set_value(self._model.optimizer.lr, self._current_lr) + self._model.optimizer.learning_rate.assign(self._current_lr) logger.debug("Learning rate set to %s for step %s/%s", self._current_lr, self._current_step, self._steps) @@ -96,3 +100,6 @@ def __call__(self) -> None: self._current_step += 1 self._set_learning_rate() self._output_status() + + +__all__ = get_module_objects(__name__) diff --git a/lib/training/preview_cv.py b/lib/training/preview_cv.py index c0a6458af2..6a0d0a2ff1 100644 --- a/lib/training/preview_cv.py +++ b/lib/training/preview_cv.py @@ -13,6 +13,8 @@ import cv2 +from lib.utils import get_module_objects + if T.TYPE_CHECKING: from collections.abc import Generator import numpy as np @@ -106,6 +108,9 @@ def _launch(self) -> None: :func:`_display_preview` function """ logger.debug("Launching %s", self.__class__.__name__) while True: + if self._should_shutdown: + logger.debug("Shutdown received") + return if not self._buffer.is_updated: logger.debug("Waiting for preview image") sleep(1) @@ -165,7 +170,7 @@ def _check_keypress(self, key: int): return if key == ord("r"): - print("") # Let log print on different line from loss output + print("\x1b[2K", end="\r") # clear last line logger.info("Refresh preview requested...") self._triggers[self._lookup[key]].set() @@ -187,3 +192,6 @@ def _display_preview(self): logger.debug("Shutdown received") break logger.debug("%s shutdown", self.__class__.__name__) + + +__all__ = get_module_objects(__name__) diff --git a/lib/training/preview_tk.py b/lib/training/preview_tk.py index 633ead57e6..c71610231c 100644 --- a/lib/training/preview_tk.py +++ b/lib/training/preview_tk.py @@ -20,6 +20,8 @@ import cv2 +from lib.utils import get_module_objects + from .preview_cv import PreviewBase, TriggerKeysType if T.TYPE_CHECKING: @@ -233,7 +235,7 @@ def destroy_widgets(self) -> None: if self._is_standalone: return - for widget in self._gui_mapped: + for widget in reversed(self._gui_mapped): if widget.winfo_ismapped(): logger.debug("Removing widget: %s", widget) widget.pack_forget() @@ -487,7 +489,7 @@ def set_interpolation(self, interpolation: int) -> bool: """ if self._interpolation == interpolation: return False - logger.debug("Setting interpolation: %s") + logger.debug("Setting interpolation: %s", interpolation) self._interpolation = interpolation return True @@ -511,7 +513,7 @@ def save_preview(self, *args) -> None: now = datetime.now().strftime("%Y-%m-%d_%H.%M.%S") filename = os.path.join(root_path, f"preview_{now}.png") cv2.imwrite(filename, self.source) - print("") + print("\x1b[2K", end="\r") # Clear last line logger.info("Saved preview to: '%s'", filename) if self._is_standalone: @@ -888,7 +890,7 @@ def _on_keypress(self, event: tk.Event) -> None: key = T.cast(TriggerKeysType, keypress) logger.debug("Processing keypress '%s'", key) if key == "r": - print("") # Let log print on different line from loss output + print("\x1b[2K", end="\r") # Clear last line logger.info("Refresh preview requested...") self._triggers[self._keymaps[key]].set() @@ -939,5 +941,8 @@ def main(): PreviewTk(buff) +__all__ = get_module_objects(__name__) + + if __name__ == "__main__": main() diff --git a/lib/training/tensorboard.py b/lib/training/tensorboard.py new file mode 100644 index 0000000000..07ed576a26 --- /dev/null +++ b/lib/training/tensorboard.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +""" Tensorboard call back for PyTorch logging. Hopefully temporary until a native Keras version +is implemented """ +from __future__ import annotations + +import logging +import os +import struct +import typing as T + +import keras +from torch.utils.tensorboard import SummaryWriter + +from lib.logger import parse_class_init +from lib.utils import get_module_objects + +logger = logging.getLogger(__name__) + + +class RecordIterator: + """ A replacement for tensorflow's :func:`compat.v1.io.tf_record_iterator` + + Parameters + ---------- + log_file : str + The event log file to obtain records from + is_live : bool, optional + ``True`` if the log file is for a live training session that will constantly provide data. + Default: ``False`` + """ + def __init__(self, log_file, is_live: bool = False) -> None: + logger.debug(parse_class_init(locals())) + self._file_path = log_file + self._log_file = open(self._file_path, "rb") # pylint:disable=consider-using-with + self._is_live = is_live + self._position = 0 + logger.debug("Initialized %s", self.__class__.__name__) + + def __iter__(self) -> RecordIterator: + """ Iterate over a Tensorboard event file""" + return self + + def _on_file_read(self) -> None: + """ If the file is closed and we are reading live data, re-open the file and seek to the + correct position """ + if not self._is_live or not self._log_file.closed: + return + + logger.trace("Re-opening '%s' and Seeking to %s", # type:ignore[attr-defined] + self._file_path, self._position) + self._log_file = open(self._file_path, "rb") # pylint:disable=consider-using-with + self._log_file.seek(self._position, 0) + + def _on_file_end(self) -> None: + """ Close the event file. If live data, record the current position""" + if self._is_live: + self._position = self._log_file.tell() + logger.trace("Setting live position to %s", # type:ignore[attr-defined] + self._position) + + logger.trace("EOF. Closing '%s'", self._file_path) # type:ignore[attr-defined] + self._log_file.close() + + def __next__(self) -> bytes: + """ Get the next event log from a Tensorboard event file + + Returns + ------- + bytes + A Tensorboard event log + + Raises + ------ + StopIteration + When the event log is fully consumed + """ + self._on_file_read() + + b_header = self._log_file.read(8) + + if not b_header: + self._on_file_end() + raise StopIteration + + read_len = int(struct.unpack('Q', b_header)[0]) + self._log_file.seek(4, 1) + data = self._log_file.read(read_len) + + self._log_file.seek(4, 1) + logger.trace("Returning event data of len %s", read_len) # type:ignore[attr-defined] + + return data + + +class TorchTensorBoard(keras.callbacks.Callback): + """Enable visualizations for TensorBoard. Adapted from Keras' Tensorboard Callback keeping + only the parts we need, and using Torch rather than TensorFlow + + Parameters + ---------- + log_dir str + The path of the directory where to save the log files to be parsed by TensorBoard. e.g., + `log_dir = os.path.join(working_dir, 'logs')`. This directory should not be reused by any + other callbacks. + write_graph: bool (Not supported at this time) + Whether to visualize the graph in TensorBoard. Note that the log file can become quite + large when `write_graph` is set to `True`. + update_freq: Literal["batch", "epoch"] | int + When using `"epoch"`, writes the losses and metrics to TensorBoard after every epoch. + If using an integer, let's say `1000`, all metrics and losses (including custom ones + added by `Model.compile`) will be logged to TensorBoard every 1000 batches. `"batch"` + is a synonym for 1, meaning that they will be written every batch. Note however that + writing too frequently to TensorBoard can slow down your training, especially when used + with distribution strategies as it will incur additional synchronization overhead. Batch- + level summary writing is also available via `train_step` override. Please see [TensorBoard + Scalars + tutorial](https://www.tensorflow.org/tensorboard/scalars_and_keras#batch-level_logging) + """ + + def __init__(self, + log_dir: str = "logs", + write_graph: bool = True, + update_freq: T.Literal["batch", "epoch"] | int = "epoch") -> None: + logger.debug(parse_class_init(locals())) + super().__init__() + self.log_dir = str(log_dir) + self.write_graph = write_graph + self.update_freq = 1 if update_freq == "batch" else update_freq + + self._should_write_train_graph = False + self._train_dir = os.path.join(self.log_dir, "train") + self._train_step = 0 + self._global_train_batch = 0 + self._previous_epoch_iterations = 0 + + self._model: keras.models.Model | None = None + self._writers: dict[str, SummaryWriter] = {} + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def _train_writer(self) -> SummaryWriter: + """:class:`torch.utils.tensorboard.SummaryWriter`: The summary writer """ + if "train" not in self._writers: + self._writers["train"] = SummaryWriter(self._train_dir) + return self._writers["train"] + + def _write_keras_model_summary(self) -> None: + """Writes Keras graph network summary to TensorBoard.""" + assert self._model is not None + summary = self._model.to_json() + self._train_writer.add_text("keras", summary, global_step=0) + + def _write_keras_model_train_graph(self) -> None: + """Writes Keras graph to TensorBoard.""" + # TODO implement + logger.debug("Tensorboard graph logging not yet implemented") + + def set_model(self, model: keras.models.Model) -> None: + """Sets Keras model and writes graph if specified. + + Parameters + ---------- + model: :class:`keras.models.Model` + The model that is being trained + """ + self._model = model + + if self.write_graph: + self._write_keras_model_summary() + self._should_write_train_graph = True + + def on_train_begin(self, logs=None) -> None: + """ Initialize the call back on train start + + Parameters + ---------- + logs: None + Unused + """ + self._global_train_batch = 0 + self._previous_epoch_iterations = 0 + + def on_train_batch_end(self, batch: int, logs: dict[str, float] | None = None) -> None: + """ Update Tensorboard logs on batch end + + Parameters + ---------- + batch: int + The current iteration count + logs: dict[str, float] + The logs to write + """ + assert logs is not None + if self._should_write_train_graph: + self._write_keras_model_train_graph() + self._should_write_train_graph = False + + for key, value in logs.items(): + self._train_writer.add_scalar(f"batch_{key}", + value, + global_step=batch) + + def on_save(self) -> None: + """ Flush data to disk on save """ + logger.debug("Flushing Tensorboard writer") + self._train_writer.flush() + + def on_train_end(self, logs=None) -> None: + """ Close the writer on train completion + + Parameters + ---------- + logs: None + Unused + """ + for writer in self._writers.values(): + writer.flush() + writer.close() + + +__all__ = get_module_objects(__name__) diff --git a/lib/utils.py b/lib/utils.py index be7588fdfd..747f6ca02e 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -1,15 +1,18 @@ #!/usr/bin python3 """ Utilities available across all scripts """ +# NOTE: Do not import keras/pytorch in this script, as it is accessed before they should be loaded + from __future__ import annotations +import inspect import json import logging import os import sys import tkinter as tk import typing as T -import warnings import zipfile +from importlib import import_module from multiprocessing import current_process from re import finditer from socket import timeout as socket_timeout, error as socket_error @@ -17,19 +20,26 @@ from time import time from urllib import request, error as urlliberror -import numpy as np -from tqdm import tqdm +try: + import numpy as np + from tqdm import tqdm +except: # noqa[E722] # pylint:disable=bare-except + # Importing outside of faceswap environment, these packages should not be required + np = None # type:ignore[assignment] # pylint:disable=invalid-name + tqdm = None # pylint:disable=invalid-name if T.TYPE_CHECKING: from argparse import Namespace from http.client import HTTPResponse # Global variables +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +""" str : Full path to the root faceswap folder """ IMAGE_EXTENSIONS = [".bmp", ".jpeg", ".jpg", ".png", ".tif", ".tiff"] VIDEO_EXTENSIONS = [".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv", ".ts", ".vob"] -_TF_VERS: tuple[int, int] | None = None -ValidBackends = T.Literal["nvidia", "cpu", "apple_silicon", "directml", "rocm"] +ValidBackends = T.Literal["nvidia", "cpu", "apple_silicon", "rocm"] +_FS_BACKEND: ValidBackends | None = None class _Backend(): # pylint:disable=too-few-public-methods @@ -39,13 +49,12 @@ class _Backend(): # pylint:disable=too-few-public-methods If file doesn't exist and a variable hasn't been set, create the config file. """ def __init__(self) -> None: self._backends: dict[str, ValidBackends] = {"1": "cpu", - "2": "directml", - "3": "nvidia", - "4": "apple_silicon", - "5": "rocm"} + "2": "nvidia", + "3": "apple_silicon", + "4": "rocm"} self._valid_backends = list(self._backends.values()) self._config_file = self._get_config_file() - self.backend = self._get_backend() + self.backend: ValidBackends = self._get_backend() @classmethod def _get_config_file(cls) -> str: @@ -56,8 +65,7 @@ def _get_config_file(cls) -> str: str The path to the Faceswap configuration file """ - pypath = os.path.dirname(os.path.realpath(sys.argv[0])) - config_file = os.path.join(pypath, "config", ".faceswap") + config_file = os.path.join(PROJECT_ROOT, "config", ".faceswap") return config_file def _get_backend(self) -> ValidBackends: @@ -122,16 +130,13 @@ def _configure_backend(self) -> ValidBackends: return fs_backend -_FS_BACKEND: ValidBackends = _Backend().backend - - def get_backend() -> ValidBackends: """ Get the backend that Faceswap is currently configured to use. Returns ------- str - The backend configuration in use by Faceswap. One of ["cpu", "directml", "nvidia", "rocm", + The backend configuration in use by Faceswap. One of ["cpu", "nvidia", "rocm", "apple_silicon"] Example @@ -140,6 +145,9 @@ def get_backend() -> ValidBackends: >>> get_backend() 'nvidia' """ + global _FS_BACKEND # pylint:disable=global-statement + if _FS_BACKEND is None: + _FS_BACKEND = _Backend().backend return _FS_BACKEND @@ -148,7 +156,7 @@ def set_backend(backend: str) -> None: Parameters ---------- - backend: ["cpu", "directml", "nvidia", "rocm", "apple_silicon"] + backend: ["cpu", "nvidia", "rocm", "apple_silicon"] The backend to set faceswap to Example @@ -161,26 +169,49 @@ def set_backend(backend: str) -> None: _FS_BACKEND = backend -def get_tf_version() -> tuple[int, int]: - """ Obtain the major. minor version of currently installed Tensorflow. +_versions: dict[T.Literal["torch", "keras"], tuple[int, int]] = {} + + +def get_torch_version() -> tuple[int, int]: + """ Obtain the major. minor version of currently installed PyTorch. Returns ------- tuple[int, int] - A tuple of the form (major, minor) representing the version of TensorFlow that is installed + A tuple of the form (major, minor) representing the version of PyTorch that is installed Example ------- - >>> from lib.utils import get_tf_version - >>> get_tf_version() - (2, 10) + >>> from lib.utils import get_torch_version + >>> get_torch_version() + (2, 2) """ - global _TF_VERS # pylint:disable=global-statement - if _TF_VERS is None: - import tensorflow as tf # pylint:disable=import-outside-toplevel - split = tf.__version__.split(".")[:2] - _TF_VERS = (int(split[0]), int(split[1])) - return _TF_VERS + if "torch" not in _versions: + torch = import_module("torch") + split = torch.__version__.split(".")[:2] + _versions["torch"] = (int(split[0]), int(split[1])) + return _versions["torch"] + + +def get_keras_version() -> tuple[int, int]: + """ Obtain the major. minor version of currently installed Keras. + + Returns + ------- + tuple[int, int] + A tuple of the form (major, minor) representing the version of Keras that is installed + + Example + ------- + >>> from lib.utils import get_torch_version + >>> get_torch_version() + (2, 2) + """ + if "keras" not in _versions: + keras = import_module("keras") + split = keras.__version__.split(".")[:2] + _versions["keras"] = (int(split[0]), int(split[1])) + return _versions["keras"] def get_folder(path: str, make_folder: bool = True) -> str: @@ -294,6 +325,29 @@ def get_dpi() -> float | None: return float(dpi) +def get_module_objects(module: str) -> list[str]: + """ Return a list of all public objects within the given module + + Parameters + ---------- + module : str + The module to parse for public objects + + Returns + ------- + list[str] + A list of object names that exist within the given module + + Example + ------- + >>> __all__ = get_module_objects(__name__) + ["foo", "bar", "baz"] + """ + return [name_ for name_, obj in inspect.getmembers(sys.modules[module]) + if getattr(obj, "__module__", None) == module + and not name_.startswith("_")] + + def convert_to_secs(*args: int) -> int: """ Convert time in hours, minutes, and seconds to seconds. @@ -371,39 +425,6 @@ def full_path_split(path: str) -> list[str]: return allparts -def set_system_verbosity(log_level: str): - """ Set the verbosity level of tensorflow and suppresses future and deprecation warnings from - any modules. - - This function sets the `TF_CPP_MIN_LOG_LEVEL` environment variable to control the verbosity of - TensorFlow output, as well as filters certain warning types to be ignored. The log level is - determined based on the input string `log_level`. - - Parameters - ---------- - log_level: str - The requested Faceswap log level. - - References - ---------- - https://stackoverflow.com/questions/35911252/disable-tensorflow-debugging-information - - Example - ------- - >>> from lib.utils import set_system_verbosity - >>> set_system_verbosity('warning') - """ - logger = logging.getLogger(__name__) - from lib.logger import get_loglevel # pylint:disable=import-outside-toplevel - numeric_level = get_loglevel(log_level) - log_level = "3" if numeric_level > 15 else "0" - logger.debug("System Verbosity level: %s", log_level) - os.environ['TF_CPP_MIN_LOG_LEVEL'] = log_level - if log_level != '0': - for warncat in (FutureWarning, DeprecationWarning, UserWarning): - warnings.simplefilter(action='ignore', category=warncat) - - def deprecation_warning(function: str, additional_info: str | None = None) -> None: """ Log a deprecation warning message. @@ -577,7 +598,7 @@ def __init__(self, model_filename: str | list[str], git_model_id: int) -> None: if not isinstance(model_filename, list): model_filename = [model_filename] self._model_filename = model_filename - self._cache_dir = os.path.join(os.path.abspath(os.path.dirname(sys.argv[0])), ".fs_cache") + self._cache_dir = os.path.join(PROJECT_ROOT, ".fs_cache") self._git_model_id = git_model_id self._url_base = "https://github.com/deepfakes-models/faceswap-models/releases/download" self._chunk_size = 1024 # Chunk size for downloading and unzipping @@ -710,6 +731,7 @@ def _write_zipfile(self, response: HTTPResponse, downloaded_size: int) -> None: self.logger.info("Zip already exists. Skipping download") return write_type = "wb" if downloaded_size == 0 else "ab" + assert tqdm is not None with open(self._model_zip_path, write_type) as out_file: pbar = tqdm(desc="Downloading", unit="B", @@ -747,6 +769,7 @@ def _write_model(self, zip_file: zipfile.ZipFile) -> None: length = sum(f.file_size for f in zip_file.infolist()) fnames = zip_file.namelist() self.logger.debug("Zipfile: Filenames: %s, Total Size: %s", fnames, length) + assert tqdm is not None pbar = tqdm(desc="Decompressing", unit="B", total=length, @@ -907,6 +930,7 @@ def summary(self, decimal_places: int = 6, interval: int = 1) -> None: header += f"{self._format_column('Max', time_col)}" if self._display["max"] else "" print(header) print(separator) + assert np is not None for key, val in self._times.items(): num = str(len(val)) contents = f"{self._format_column(key, name_col)}{self._format_column(num, items_col)}" @@ -921,3 +945,6 @@ def summary(self, decimal_places: int = 6, interval: int = 1) -> None: contents += f"{self._format_column(_max, time_col)}" print(contents) self._interval = 1 + + +__all__ = get_module_objects(__name__) diff --git a/lib/vgg_face.py b/lib/vgg_face.py deleted file mode 100644 index d10c957df9..0000000000 --- a/lib/vgg_face.py +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin python3 -""" VGG_Face inference using OpenCV-DNN -Model from: https://www.robots.ox.ac.uk/~vgg/software/vgg_face/ - -Licensed under Creative Commons Attribution License. -https://creativecommons.org/licenses/by-nc/4.0/ -""" - -import logging - -import cv2 -import numpy as np -from fastcluster import linkage - -from lib.utils import GetModel - -logger = logging.getLogger(__name__) - - -class VGGFace(): - """ VGG Face feature extraction. - Input images should be in BGR Order """ - - def __init__(self, backend="CPU"): - logger.debug("Initializing %s: (backend: %s)", self.__class__.__name__, backend) - git_model_id = 7 - model_filename = ["vgg_face_v1.caffemodel", "vgg_face_v1.prototxt"] - self.input_size = 224 - # Average image provided in http://www.robots.ox.ac.uk/~vgg/software/vgg_face/ - self.average_img = [129.1863, 104.7624, 93.5940] - - self.model = self.get_model(git_model_id, model_filename, backend) - logger.debug("Initialized %s", self.__class__.__name__) - - # <<< GET MODEL >>> # - def get_model(self, git_model_id, model_filename, backend): - """ Check if model is available, if not, download and unzip it """ - model = GetModel(model_filename, git_model_id).model_path - model = cv2.dnn.readNetFromCaffe(model[1], model[0]) - model.setPreferableTarget(self.get_backend(backend)) - return model - - @staticmethod - def get_backend(backend): - """ Return the cv2 DNN backend """ - if backend == "OPENCL": - logger.info("Using OpenCL backend. If the process runs, you can safely ignore any of " - "the failure messages.") - retval = getattr(cv2.dnn, f"DNN_TARGET_{backend}") - return retval - - def predict(self, face): - """ Return encodings for given image from vgg_face """ - if face.shape[0] != self.input_size: - face = self.resize_face(face) - blob = cv2.dnn.blobFromImage(face[..., :3], - 1.0, - (self.input_size, self.input_size), - self.average_img, - False, - False) - self.model.setInput(blob) - preds = self.model.forward("fc7")[0, :] - return preds - - def resize_face(self, face): - """ Resize incoming face to model_input_size """ - sizes = (self.input_size, self.input_size) - interpolation = cv2.INTER_CUBIC if face.shape[0] < self.input_size else cv2.INTER_AREA - face = cv2.resize(face, dsize=sizes, interpolation=interpolation) - return face - - @staticmethod - def find_cosine_similiarity(source_face, test_face): - """ Find the cosine similarity between a source face and a test face """ - var_a = np.matmul(np.transpose(source_face), test_face) - var_b = np.sum(np.multiply(source_face, source_face)) - var_c = np.sum(np.multiply(test_face, test_face)) - return 1 - (var_a / (np.sqrt(var_b) * np.sqrt(var_c))) - - def sorted_similarity(self, predictions, method="ward"): - """ Sort a matrix of predictions by similarity Adapted from: - https://gmarti.gitlab.io/ml/2017/09/07/how-to-sort-distance-matrix.html - input: - - predictions is a stacked matrix of vgg_face predictions shape: (x, 4096) - - method = ["ward","single","average","complete"] - output: - - result_order is a list of indices with the order implied by the hierarhical tree - - sorted_similarity transforms a distance matrix into a sorted distance matrix according to - the order implied by the hierarchical tree (dendrogram) - """ - logger.info("Sorting face distances. Depending on your dataset this may take some time...") - num_predictions = predictions.shape[0] - result_linkage = linkage(predictions, method=method, preserve_input=False) - result_order = self.seriation(result_linkage, - num_predictions, - num_predictions + num_predictions - 2) - - return result_order - - def seriation(self, tree, points, current_index): - """ Seriation method for sorted similarity - input: - - tree is a hierarchical tree (dendrogram) - - points is the number of points given to the clustering process - - current_index is the position in the tree for the recursive traversal - output: - - order implied by the hierarchical tree - - seriation computes the order implied by a hierarchical tree (dendrogram) - """ - if current_index < points: - return [current_index] - left = int(tree[current_index-points, 0]) - right = int(tree[current_index-points, 1]) - return self.seriation(tree, points, left) + self.seriation(tree, points, right) diff --git a/locales/es/LC_MESSAGES/lib.cli.args_train.mo b/locales/es/LC_MESSAGES/lib.cli.args_train.mo index 5cb1754eb0b06d0a6bd4252055e0c9ca57ca0829..84370ccc539b17b70015a4cefdb685ca9547aaff 100644 GIT binary patch delta 1165 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 delta 1769 zcmZXSUu;uV9LIkfh)4vS2n^WZhe}X!3qu1k;uJ~1L{L;P_+a8)dq=Oi?QQP8WsaD% zu!?g~>C)A8uAL=}Y}1l7UPeYXUo<`#jfs&5jj#SmdQKmF^v&>ktV-R*FF z<^KYL?q+hHWF=U?;y>+v>2>7YT%HD;}|8@NuC5kbqxC zL?xmHY|C-j*Nd9VYdy>XE83?jQMQGxpl-4j39@FIOA1iNnU)sx6GK0BEf7*>aj)Tq zxLb1rp*=8v+3ibq!88Aa;nrH=<(j@<<$coJuyRdD+rAF&IMDg*fy`*jj}801p4P2R zaZxT;ua;Q$U#Rxy#f^EE$zrv?P|Z$@%LC%_#Inb<_>g2F$CcT#@Md{Ip_mloGqR9m zd9frvn_i}H&zq9tLvrLpS%}}_j!HStvTzG2C{=MSFXu|CWI2+SNh}TIs-J!=(m63W z%;M^(%nqnW=kN6rSoO1>7?RmhmXkwrVyHSgym-5k7R8S$UtQs9HYcWU)umae!)I!z ziwQY?1IuzU!7@823s+=*US*P+!vEKma#CF-!${=CvX~X+gwjx@)MBc)GC6^zg?c9wax9H{7m^vAS!~7P ztHjOX^U}R`!67sT7O;rlPRfgAu8s|e;*_77FTKPY@x_!#&DN}(LEq5NiZ{ORDoKH>irk#yqd{7Igy!=qY0Qj)m)jwD_OGhT3%%GB0cSy?Q^R= J?Z7K5{{zqWj}ZU> diff --git a/locales/kr/LC_MESSAGES/lib.cli.args_train.po b/locales/kr/LC_MESSAGES/lib.cli.args_train.po index 68b661a673..1cc9ad1291 100644 --- a/locales/kr/LC_MESSAGES/lib.cli.args_train.po +++ b/locales/kr/LC_MESSAGES/lib.cli.args_train.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-21 17:32+0000\n" -"PO-Revision-Date: 2025-11-21 17:47+0000\n" +"POT-Creation-Date: 2025-12-15 20:02+0000\n" +"PO-Revision-Date: 2025-12-19 23:26+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ko_KR\n" @@ -16,7 +16,7 @@ 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.6\n" +"X-Generator: Poedit 3.8\n" #: lib/cli/args_train.py:30 msgid "" @@ -157,8 +157,8 @@ msgstr "" "성 옵션이 있을 수 있습니다." #: lib/cli/args_train.py:147 lib/cli/args_train.py:160 -#: lib/cli/args_train.py:174 lib/cli/args_train.py:186 -#: lib/cli/args_train.py:202 lib/cli/args_train.py:211 +#: lib/cli/args_train.py:174 lib/cli/args_train.py:183 +#: lib/cli/args_train.py:190 lib/cli/args_train.py:199 msgid "training" msgstr "훈련" @@ -195,28 +195,11 @@ msgstr "" "학습률 워밍업. 여기에 주어진 반복 횟수에 따라 학습률을 0에서 선택한 목표 속도" "까지 선형적으로 증가시킵니다. 0으로 설정하면 비활성화됩니다." -#: lib/cli/args_train.py:188 -msgid "" -"R|Select the distribution stategy to use.\n" -"L|default: Use Tensorflow's default distribution strategy.\n" -"L|central-storage: Centralizes variables on the CPU whilst operations are " -"performed on 1 or more local GPUs. This can help save some VRAM at the cost " -"of some speed by not storing variables on the GPU. Note: Mixed-Precision is " -"not supported on multi-GPU setups.\n" -"L|mirrored: Supports synchronous distributed training across multiple local " -"GPUs. A copy of the model and all variables are loaded onto each GPU with " -"batches distributed to each GPU at each iteration." -msgstr "" -"R|사용할 배포 상태를 선택합니다.\n" -"L|default: Tensorflow의 기본 배포 전략을 사용합니다.\n" -"L|central-storage: 작업이 1개 이상의 로컬 GPU에서 수행되는 동안 CPU의 변수를 " -"중앙 집중화합니다. 이렇게 하면 GPU에 변수를 저장하지 않음으로써 약간의 속도" -"를 희생하여 일부 VRAM을 절약할 수 있습니다. 참고: 다중 정밀도는 다중 GPU 설정" -"에서 지원되지 않습니다.\n" -"L|mirrored: 여러 로컬 GPU에서 동기화 분산 훈련을 지원합니다. 모델의 복사본과 " -"모든 변수는 각 반복에서 각 GPU에 배포된 배치들와 함께 각 GPU에 로드됩니다." - -#: lib/cli/args_train.py:204 +#: lib/cli/args_train.py:184 +msgid "Use distibuted training on multi-gpu setups." +msgstr "멀티 GPU 환경에서 분산 학습을 활용하세요." + +#: lib/cli/args_train.py:192 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -224,7 +207,7 @@ msgstr "" "텐서보드 로깅을 비활성화합니다. 주의: 로그를 비활성화하면 GUI에서 이 세션에 " "대한 그래프 또는 분석을 사용할 수 없습니다." -#: lib/cli/args_train.py:213 +#: lib/cli/args_train.py:201 msgid "" "Use the Learning Rate Finder to discover the optimal learning rate for " "training. For new models, this will calculate the optimal learning rate for " @@ -237,15 +220,15 @@ msgstr "" "때 발견된 최적의 학습률을 사용합니다. 이 옵션을 설정하면 수동으로 구성된 학습" "률(기차 설정에서 구성 가능)이 무시됩니다." -#: lib/cli/args_train.py:226 lib/cli/args_train.py:236 +#: lib/cli/args_train.py:214 lib/cli/args_train.py:224 msgid "Saving" msgstr "저장" -#: lib/cli/args_train.py:227 +#: lib/cli/args_train.py:215 msgid "Sets the number of iterations between each model save." msgstr "각 모델 저장 사이의 반복 횟수를 설정합니다." -#: lib/cli/args_train.py:238 +#: lib/cli/args_train.py:226 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -253,12 +236,12 @@ msgstr "" "현재 상태에서 모델의 백업 스냅샷을 저장하기 전에 반복할 횟수를 설정합니다. 0" "으로 설정하면 꺼집니다." -#: lib/cli/args_train.py:245 lib/cli/args_train.py:257 -#: lib/cli/args_train.py:269 +#: lib/cli/args_train.py:233 lib/cli/args_train.py:245 +#: lib/cli/args_train.py:257 msgid "timelapse" msgstr "타임랩스" -#: lib/cli/args_train.py:247 +#: lib/cli/args_train.py:235 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -271,7 +254,7 @@ msgstr "" "랩스를 만드는 데 사용할 'A' 얼굴의 입력 폴더여야 합니다. 또한 사용자는 --" "timelapse-output 및 --timelapse-input-B 매개 변수를 제공해야 합니다." -#: lib/cli/args_train.py:259 +#: lib/cli/args_train.py:247 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -284,7 +267,7 @@ msgstr "" "다. 타임 랩스를 만드는 데 사용할 'B' 얼굴의 입력 폴더여야 합니다. 또한 사용자" "는 --timelapse-output 및 --timelapse-input-A 매개 변수를 제공해야 합니다." -#: lib/cli/args_train.py:271 +#: lib/cli/args_train.py:259 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -296,27 +279,27 @@ msgstr "" "다. 입력 폴더가 제공되었지만 출력 폴더가 없는 경우 모델 폴더에/timelapse/로 " "기본 설정됩니다" -#: lib/cli/args_train.py:280 lib/cli/args_train.py:287 +#: lib/cli/args_train.py:268 lib/cli/args_train.py:275 msgid "preview" msgstr "미리보기" -#: lib/cli/args_train.py:281 +#: lib/cli/args_train.py:269 msgid "Show training preview output. in a separate window." msgstr "훈련 미리보기 결과를 각기 다른 창에서 보여줍니다." -#: lib/cli/args_train.py:289 +#: lib/cli/args_train.py:277 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." msgstr "" "훈련 결과를 파일에 씁니다. 이미지는 Faceswap 폴더의 최상위 폴더에 저장됩니다." -#: lib/cli/args_train.py:296 lib/cli/args_train.py:306 -#: lib/cli/args_train.py:316 lib/cli/args_train.py:326 +#: lib/cli/args_train.py:284 lib/cli/args_train.py:294 +#: lib/cli/args_train.py:304 lib/cli/args_train.py:314 msgid "augmentation" msgstr "보정" -#: lib/cli/args_train.py:298 +#: lib/cli/args_train.py:286 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -325,7 +308,7 @@ msgstr "" "무작위로 얼굴을 변환하지 않고 반대쪽 얼굴 세트에서 특징점과 밀접하게 일치하도" "록 훈련 얼굴을 변환해줍니다. 이것은 변환하는 'dfaker' 방식이다." -#: lib/cli/args_train.py:308 +#: lib/cli/args_train.py:296 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -335,7 +318,7 @@ msgstr "" "런 일이 일어나지 않는 것이 바람직합니다. 일반적으로 'fit training' 중을 제외" "하고는 이 작업을 중단해야 합니다." -#: lib/cli/args_train.py:318 +#: lib/cli/args_train.py:306 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -345,7 +328,7 @@ msgstr "" "이 되며, 훈련 시간 비용이 증가합니다. 색상 보저를 사용하지 않으려면 이 옵션" "을 사용합니다." -#: lib/cli/args_train.py:328 +#: lib/cli/args_train.py:316 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -356,3 +339,24 @@ msgstr "" "내위하여 훈련 막바지까지 활성화하여야 합니다. 이것은 '미세 조정'이라고 생각하" "면 됩니다. 처음부터 이 옵션을 활성화하면 모델이 죽을 수있고 끔찍한 결과를 초" "래할 수 있습니다." + +#~ msgid "" +#~ "R|Select the distribution stategy to use.\n" +#~ "L|default: Use Tensorflow's default distribution strategy.\n" +#~ "L|central-storage: Centralizes variables on the CPU whilst operations are " +#~ "performed on 1 or more local GPUs. This can help save some VRAM at the " +#~ "cost of some speed by not storing variables on the GPU. Note: Mixed-" +#~ "Precision is not supported on multi-GPU setups.\n" +#~ "L|mirrored: Supports synchronous distributed training across multiple " +#~ "local GPUs. A copy of the model and all variables are loaded onto each " +#~ "GPU with batches distributed to each GPU at each iteration." +#~ msgstr "" +#~ "R|사용할 배포 상태를 선택합니다.\n" +#~ "L|default: Tensorflow의 기본 배포 전략을 사용합니다.\n" +#~ "L|central-storage: 작업이 1개 이상의 로컬 GPU에서 수행되는 동안 CPU의 변수" +#~ "를 중앙 집중화합니다. 이렇게 하면 GPU에 변수를 저장하지 않음으로써 약간의 " +#~ "속도를 희생하여 일부 VRAM을 절약할 수 있습니다. 참고: 다중 정밀도는 다중 " +#~ "GPU 설정에서 지원되지 않습니다.\n" +#~ "L|mirrored: 여러 로컬 GPU에서 동기화 분산 훈련을 지원합니다. 모델의 복사본" +#~ "과 모든 변수는 각 반복에서 각 GPU에 배포된 배치들와 함께 각 GPU에 로드됩니" +#~ "다." diff --git a/locales/lib.cli.args_train.pot b/locales/lib.cli.args_train.pot index 45f044d80e..40a785e681 100644 --- a/locales/lib.cli.args_train.pot +++ b/locales/lib.cli.args_train.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-21 17:32+0000\n" +"POT-Creation-Date: 2025-12-15 20:02+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -111,8 +111,8 @@ msgid "" msgstr "" #: lib/cli/args_train.py:147 lib/cli/args_train.py:160 -#: lib/cli/args_train.py:174 lib/cli/args_train.py:186 -#: lib/cli/args_train.py:202 lib/cli/args_train.py:211 +#: lib/cli/args_train.py:174 lib/cli/args_train.py:183 +#: lib/cli/args_train.py:190 lib/cli/args_train.py:199 msgid "training" msgstr "" @@ -139,26 +139,17 @@ msgid "" "chosen target rate over the number of iterations given here. 0 to disable." msgstr "" -#: lib/cli/args_train.py:188 -msgid "" -"R|Select the distribution stategy to use.\n" -"L|default: Use Tensorflow's default distribution strategy.\n" -"L|central-storage: Centralizes variables on the CPU whilst operations are " -"performed on 1 or more local GPUs. This can help save some VRAM at the cost " -"of some speed by not storing variables on the GPU. Note: Mixed-Precision is " -"not supported on multi-GPU setups.\n" -"L|mirrored: Supports synchronous distributed training across multiple local " -"GPUs. A copy of the model and all variables are loaded onto each GPU with " -"batches distributed to each GPU at each iteration." -msgstr "" - -#: lib/cli/args_train.py:204 +#: lib/cli/args_train.py:184 +msgid "Use distibuted training on multi-gpu setups." +msgstr "" + +#: lib/cli/args_train.py:192 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." msgstr "" -#: lib/cli/args_train.py:213 +#: lib/cli/args_train.py:201 msgid "" "Use the Learning Rate Finder to discover the optimal learning rate for " "training. For new models, this will calculate the optimal learning rate for " @@ -167,26 +158,26 @@ msgid "" "the manually configured learning rate (configurable in train settings)." msgstr "" -#: lib/cli/args_train.py:226 lib/cli/args_train.py:236 +#: lib/cli/args_train.py:214 lib/cli/args_train.py:224 msgid "Saving" msgstr "" -#: lib/cli/args_train.py:227 +#: lib/cli/args_train.py:215 msgid "Sets the number of iterations between each model save." msgstr "" -#: lib/cli/args_train.py:238 +#: lib/cli/args_train.py:226 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." msgstr "" -#: lib/cli/args_train.py:245 lib/cli/args_train.py:257 -#: lib/cli/args_train.py:269 +#: lib/cli/args_train.py:233 lib/cli/args_train.py:245 +#: lib/cli/args_train.py:257 msgid "timelapse" msgstr "" -#: lib/cli/args_train.py:247 +#: lib/cli/args_train.py:235 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -195,7 +186,7 @@ msgid "" "timelapse-input-B parameter." msgstr "" -#: lib/cli/args_train.py:259 +#: lib/cli/args_train.py:247 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -204,7 +195,7 @@ msgid "" "timelapse-input-A parameter." msgstr "" -#: lib/cli/args_train.py:271 +#: lib/cli/args_train.py:259 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -212,47 +203,47 @@ msgid "" "model folder/timelapse/" msgstr "" -#: lib/cli/args_train.py:280 lib/cli/args_train.py:287 +#: lib/cli/args_train.py:268 lib/cli/args_train.py:275 msgid "preview" msgstr "" -#: lib/cli/args_train.py:281 +#: lib/cli/args_train.py:269 msgid "Show training preview output. in a separate window." msgstr "" -#: lib/cli/args_train.py:289 +#: lib/cli/args_train.py:277 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." msgstr "" -#: lib/cli/args_train.py:296 lib/cli/args_train.py:306 -#: lib/cli/args_train.py:316 lib/cli/args_train.py:326 +#: lib/cli/args_train.py:284 lib/cli/args_train.py:294 +#: lib/cli/args_train.py:304 lib/cli/args_train.py:314 msgid "augmentation" msgstr "" -#: lib/cli/args_train.py:298 +#: lib/cli/args_train.py:286 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " "warping." msgstr "" -#: lib/cli/args_train.py:308 +#: lib/cli/args_train.py:296 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " "left off except for during 'fit training'." msgstr "" -#: lib/cli/args_train.py:318 +#: lib/cli/args_train.py:306 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " "Enable this option to disable color augmentation." msgstr "" -#: lib/cli/args_train.py:328 +#: lib/cli/args_train.py:316 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " diff --git a/locales/lib.config.pot b/locales/lib.config.objects.pot similarity index 80% rename from locales/lib.config.pot rename to locales/lib.config.objects.pot index b1144f809c..1290692e57 100644 --- a/locales/lib.config.pot +++ b/locales/lib.config.objects.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-06-11 23:28+0100\n" +"POT-Creation-Date: 2025-12-11 19:02+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,44 +17,44 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: lib/config.py:393 +#: lib/config/objects.py:115 msgid "" "\n" "This option can be updated for existing models.\n" msgstr "" -#: lib/config.py:395 +#: lib/config/objects.py:117 msgid "" "\n" "If selecting multiple options then each option should be separated by a " "space or a comma (e.g. item1, item2, item3)\n" msgstr "" -#: lib/config.py:398 +#: lib/config/objects.py:120 msgid "" "\n" "Choose from: {}" msgstr "" -#: lib/config.py:400 +#: lib/config/objects.py:122 msgid "" "\n" "Choose from: True, False" msgstr "" -#: lib/config.py:404 +#: lib/config/objects.py:126 msgid "" "\n" "Select an integer between {} and {}" msgstr "" -#: lib/config.py:408 +#: lib/config/objects.py:130 msgid "" "\n" "Select a decimal number between {} and {}" msgstr "" -#: lib/config.py:409 +#: lib/config/objects.py:132 msgid "" "\n" "[Default: {}]" diff --git a/locales/plugins.extract._config.pot b/locales/plugins.extract.extract_config.pot similarity index 76% rename from locales/plugins.extract._config.pot rename to locales/plugins.extract.extract_config.pot index a65b8fda60..fc012eb3ff 100644 --- a/locales/plugins.extract._config.pot +++ b/locales/plugins.extract.extract_config.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-06-08 16:43+0100\n" +"POT-Creation-Date: 2025-12-12 13:11+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,30 +17,18 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: plugins/extract/_config.py:32 +#: plugins/extract/extract_config.py:23 msgid "Options that apply to all extraction plugins" msgstr "" -#: plugins/extract/_config.py:38 -msgid "settings" -msgstr "" - -#: plugins/extract/_config.py:39 -msgid "" -"Enable the Tensorflow GPU `allow_growth` configuration " -"option. This option prevents Tensorflow from allocating all of the GPU VRAM " -"at launch but can lead to higher VRAM fragmentation and slower performance. " -"Should only be enabled if you are having problems running extraction." -msgstr "" - -#: plugins/extract/_config.py:50 plugins/extract/_config.py:64 -#: plugins/extract/_config.py:78 plugins/extract/_config.py:89 -#: plugins/extract/_config.py:99 plugins/extract/_config.py:108 -#: plugins/extract/_config.py:119 +#: plugins/extract/extract_config.py:30 plugins/extract/extract_config.py:45 +#: plugins/extract/extract_config.py:60 plugins/extract/extract_config.py:72 +#: plugins/extract/extract_config.py:85 plugins/extract/extract_config.py:95 +#: plugins/extract/extract_config.py:107 msgid "filters" msgstr "" -#: plugins/extract/_config.py:51 +#: plugins/extract/extract_config.py:32 msgid "" "Filters out faces below this size. This is a multiplier of the minimum " "dimension of the frame (i.e. 1280x720 = 720). If the original face extract " @@ -50,7 +38,7 @@ msgid "" "extreme long-shots. These can be usually be safely discarded." msgstr "" -#: plugins/extract/_config.py:65 +#: plugins/extract/extract_config.py:47 msgid "" "Filters out faces above this size. This is a multiplier of the minimum " "dimension of the frame (i.e. 1280x720 = 720). If the original face extract " @@ -60,14 +48,14 @@ msgid "" "extreme close-ups. These can be usually be safely discarded." msgstr "" -#: plugins/extract/_config.py:79 +#: plugins/extract/extract_config.py:62 msgid "" "Filters out faces who's landmarks are above this distance from an 'average' " "face. Values above 15 tend to be fairly safe. Values above 10 will remove " "more false positives, but may also filter out some faces at extreme angles." msgstr "" -#: plugins/extract/_config.py:90 +#: plugins/extract/extract_config.py:74 msgid "" "Filters out faces who's calculated roll is greater than zero +/- this value " "in degrees. Aligned faces should have a roll value close to zero. Values " @@ -75,14 +63,14 @@ msgid "" "These can usually be safely disgarded." msgstr "" -#: plugins/extract/_config.py:100 +#: plugins/extract/extract_config.py:87 msgid "" "Filters out faces where the lowest point of the aligned face's eye or " "eyebrow is lower than the highest point of the aligned face's mouth. Any " "faces where this occurs are misaligned and can be safely disgarded." msgstr "" -#: plugins/extract/_config.py:109 +#: plugins/extract/extract_config.py:97 msgid "" "If enabled, and 're-feed' has been selected for extraction, then interim " "alignments will be filtered prior to averaging the final landmarks. This can " @@ -91,7 +79,7 @@ msgid "" "disabled, then all re-feed results will be averaged." msgstr "" -#: plugins/extract/_config.py:120 +#: plugins/extract/extract_config.py:109 msgid "" "If enabled, saves any filtered out images into a sub-folder during the " "extraction process. If disabled, filtered faces are deleted. Note: The faces " @@ -99,18 +87,18 @@ msgid "" "you keep the faces or not." msgstr "" -#: plugins/extract/_config.py:129 plugins/extract/_config.py:138 +#: plugins/extract/extract_config.py:118 plugins/extract/extract_config.py:128 msgid "re-align" msgstr "" -#: plugins/extract/_config.py:130 +#: plugins/extract/extract_config.py:120 msgid "" "If enabled, and 're-align' has been selected for extraction, then all re-" "feed iterations are re-aligned. If disabled, then only the final averaged " "output from re-feed will be re-aligned." msgstr "" -#: plugins/extract/_config.py:139 +#: plugins/extract/extract_config.py:130 msgid "" "If enabled, and 're-align' has been selected for extraction, then any " "alignments which would be filtered out will not be re-aligned." diff --git a/locales/plugins.train._config.pot b/locales/plugins.train.train_config.pot similarity index 71% rename from locales/plugins.train._config.pot rename to locales/plugins.train.train_config.pot index 38e94f3152..c9bff80353 100644 --- a/locales/plugins.train._config.pot +++ b/locales/plugins.train.train_config.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-26 17:37+0000\n" +"POT-Creation-Date: 2025-12-13 13:39+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,154 +17,23 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: plugins/train/_config.py:17 +#: plugins/train/train_config.py:21 msgid "" "\n" "NB: Unless specifically stated, values changed here will only take effect " "when creating a new model." msgstr "" -#: plugins/train/_config.py:22 -msgid "" -"Focal Frequency Loss. Analyzes the frequency spectrum of the images rather " -"than the images themselves. This loss function can be used on its own, but " -"the original paper found increased benefits when using it as a complementary " -"loss to another spacial loss function (e.g. MSE). Ref: Focal Frequency Loss " -"for Image Reconstruction and Synthesis https://arxiv.org/pdf/2012.12821.pdf " -"NB: This loss does not currently work on AMD cards." -msgstr "" - -#: plugins/train/_config.py:29 -msgid "" -"Nvidia FLIP. A perceptual loss measure that approximates the difference " -"perceived by humans as they alternate quickly (or flip) between two images. " -"Used on its own and this loss function creates a distinct grid on the " -"output. However it can be helpful when used as a complimentary loss " -"function. Ref: FLIP: A Difference Evaluator for Alternating Images: https://" -"research.nvidia.com/sites/default/files/node/3260/FLIP_Paper.pdf" -msgstr "" - -#: plugins/train/_config.py:36 -msgid "" -"Gradient Magnitude Similarity Deviation seeks to match the global standard " -"deviation of the pixel to pixel differences between two images. Similar in " -"approach to SSIM. Ref: Gradient Magnitude Similarity Deviation: An Highly " -"Efficient Perceptual Image Quality Index https://arxiv.org/ftp/arxiv/" -"papers/1308/1308.3052.pdf" -msgstr "" - -#: plugins/train/_config.py:41 -msgid "" -"The L_inf norm will reduce the largest individual pixel error in an image. " -"As each largest error is minimized sequentially, the overall error is " -"improved. This loss will be extremely focused on outliers." -msgstr "" - -#: plugins/train/_config.py:45 -msgid "" -"Laplacian Pyramid Loss. Attempts to improve results by focussing on edges " -"using Laplacian Pyramids. As this loss function gives priority to edges over " -"other low-frequency information, like color, it should not be used on its " -"own. The original implementation uses this loss as a complimentary function " -"to MSE. Ref: Optimizing the Latent Space of Generative Networks https://" -"arxiv.org/abs/1707.05776" -msgstr "" - -#: plugins/train/_config.py:52 -msgid "" -"LPIPS is a perceptual loss that uses the feature outputs of other pretrained " -"models as a loss metric. Be aware that this loss function will use more " -"VRAM. Used on its own and this loss will create a distinct moire pattern on " -"the output, however it can be helpful as a complimentary loss function. The " -"output of this function is strong, so depending on your chosen primary loss " -"function, you are unlikely going to want to set the weight above about 25%. " -"Ref: The Unreasonable Effectiveness of Deep Features as a Perceptual Metric " -"http://arxiv.org/abs/1801.03924\n" -"This variant uses the AlexNet backbone. A fairly light and old model which " -"performed best in the paper's original implementation.\n" -"NB: For AMD Users the final linear layer is not implemented." -msgstr "" - -#: plugins/train/_config.py:62 -msgid "" -"Same as lpips_alex, but using the SqueezeNet backbone. A more lightweight " -"version of AlexNet.\n" -"NB: For AMD Users the final linear layer is not implemented." -msgstr "" - -#: plugins/train/_config.py:65 -msgid "" -"Same as lpips_alex, but using the VGG16 backbone. A more heavyweight model.\n" -"NB: For AMD Users the final linear layer is not implemented." -msgstr "" - -#: plugins/train/_config.py:68 -msgid "" -"log(cosh(x)) acts similar to MSE for small errors and to MAE for large " -"errors. Like MSE, it is very stable and prevents overshoots when errors are " -"near zero. Like MAE, it is robust to outliers." -msgstr "" - -#: plugins/train/_config.py:72 -msgid "" -"Mean absolute error will guide reconstructions of each pixel towards its " -"median value in the training dataset. Robust to outliers but as a median, it " -"can potentially ignore some infrequent image types in the dataset." -msgstr "" - -#: plugins/train/_config.py:76 -msgid "" -"Mean squared error will guide reconstructions of each pixel towards its " -"average value in the training dataset. As an avg, it will be susceptible to " -"outliers and typically produces slightly blurrier results. Ref: Multi-Scale " -"Structural Similarity for Image Quality Assessment https://www.cns.nyu.edu/" -"pub/eero/wang03b.pdf" -msgstr "" - -#: plugins/train/_config.py:81 -msgid "" -"Multiscale Structural Similarity Index Metric is similar to SSIM except that " -"it performs the calculations along multiple scales of the input image." -msgstr "" - -#: plugins/train/_config.py:84 -msgid "" -"Smooth_L1 is a modification of the MAE loss to correct two of its " -"disadvantages. This loss has improved stability and guidance for small " -"errors. Ref: A General and Adaptive Robust Loss Function https://arxiv.org/" -"pdf/1701.03077.pdf" -msgstr "" - -#: plugins/train/_config.py:88 -msgid "" -"Structural Similarity Index Metric is a perception-based loss that considers " -"changes in texture, luminance, contrast, and local spatial statistics of an " -"image. Potentially delivers more realistic looking images. Ref: Image " -"Quality Assessment: From Error Visibility to Structural Similarity http://" -"www.cns.nyu.edu/pub/eero/wang03-reprint.pdf" -msgstr "" - -#: plugins/train/_config.py:93 -msgid "" -"Instead of minimizing the difference between the absolute value of each " -"pixel in two reference images, compute the pixel to pixel spatial difference " -"in each image and then minimize that difference between two images. Allows " -"for large color shifts, but maintains the structure of the image." -msgstr "" - -#: plugins/train/_config.py:97 -msgid "Do not use an additional loss function." -msgstr "" - -#: plugins/train/_config.py:117 +#: plugins/train/train_config.py:30 msgid "Options that apply to all models" msgstr "" -#: plugins/train/_config.py:126 plugins/train/_config.py:150 +#: plugins/train/train_config.py:43 plugins/train/train_config.py:66 +#: plugins/train/train_config.py:86 msgid "face" msgstr "" -#: plugins/train/_config.py:128 +#: plugins/train/train_config.py:45 msgid "" "How to center the training image. The extracted images are centered on the " "middle of the skull based on the face's estimated pose. A subsection of " @@ -182,7 +51,7 @@ msgid "" "face appearing outside of the training area." msgstr "" -#: plugins/train/_config.py:152 +#: plugins/train/train_config.py:68 msgid "" "How much of the extracted image to train on. A lower coverage will limit the " "model's scope to a zoomed-in central area while higher amounts can include " @@ -197,11 +66,19 @@ msgid "" "\t100.0% is a mugshot." msgstr "" -#: plugins/train/_config.py:168 plugins/train/_config.py:179 +#: plugins/train/train_config.py:88 +msgid "" +"How much to adjust the vertical position of the aligned face as a percentage " +"of face image size. Negative values move the face up (expose more chin and " +"less forehead). Positive values move the face down (expose less chin and " +"more forehead)" +msgstr "" + +#: plugins/train/train_config.py:99 plugins/train/train_config.py:109 msgid "initialization" msgstr "" -#: plugins/train/_config.py:170 +#: plugins/train/train_config.py:101 msgid "" "Use ICNR to tile the default initializer in a repeating pattern. This " "strategy is designed for pairing with sub-pixel / pixel shuffler to reduce " @@ -209,7 +86,7 @@ msgid "" "\t https://arxiv.org/ftp/arxiv/papers/1707/1707.02937.pdf" msgstr "" -#: plugins/train/_config.py:181 +#: plugins/train/train_config.py:111 msgid "" "Use Convolution Aware Initialization for convolutional layers. This can help " "eradicate the vanishing and exploding gradient problem as well as lead to " @@ -217,7 +94,7 @@ msgid "" "NB:\n" "\t This can use more VRAM when creating a new model so you may want to lower " "the batch size for the first run. The batch size can be raised again when " -"reloading the model. \n" +"reloading the model.\n" "\t Multi-GPU is not supported for this option, so you should start the model " "on a single GPU. Once training has started, you can stop training, enable " "multi-GPU and resume.\n" @@ -226,86 +103,18 @@ msgid "" "starting a new model." msgstr "" -#: plugins/train/_config.py:198 plugins/train/_config.py:223 -#: plugins/train/_config.py:238 plugins/train/_config.py:256 -#: plugins/train/_config.py:337 -msgid "optimizer" -msgstr "" - -#: plugins/train/_config.py:202 -msgid "" -"The optimizer to use.\n" -"\t adabelief - Adapting Stepsizes by the Belief in Observed Gradients. An " -"optimizer with the aim to converge faster, generalize better and remain more " -"stable. (https://arxiv.org/abs/2010.07468). NB: Epsilon for AdaBelief needs " -"to be set to a smaller value than other Optimizers. Generally setting the " -"'Epsilon Exponent' to around '-16' should work.\n" -"\t adam - Adaptive Moment Optimization. A stochastic gradient descent method " -"that is based on adaptive estimation of first-order and second-order " -"moments.\n" -"\t nadam - Adaptive Moment Optimization with Nesterov Momentum. Much like " -"Adam but uses a different formula for calculating momentum.\n" -"\t rms-prop - Root Mean Square Propagation. Maintains a moving (discounted) " -"average of the square of the gradients. Divides the gradient by the root of " -"this average." -msgstr "" - -#: plugins/train/_config.py:225 -msgid "" -"Learning rate - how fast your network will learn (how large are the " -"modifications to the model weights after one batch of training). Values that " -"are too large might result in model crashes and the inability of the model " -"to find the best solution. Values that are too small might be unable to " -"escape from dead-ends and find the best global minimum." -msgstr "" - -#: plugins/train/_config.py:240 -msgid "" -"The epsilon adds a small constant to weight updates to attempt to avoid " -"'divide by zero' errors. Unless you are using the AdaBelief Optimizer, then " -"Generally this option should be left at default value, For AdaBelief, " -"setting this to around '-16' should work.\n" -"In all instances if you are getting 'NaN' loss values, and have been unable " -"to resolve the issue any other way (for example, increasing batch size, or " -"lowering learning rate), then raising the epsilon can lead to a more stable " -"model. It may, however, come at the cost of slower training and a less " -"accurate final result.\n" -"NB: The value given here is the 'exponent' to the epsilon. For example, " -"choosing '-7' will set the epsilon to 1e-7. Choosing '-3' will set the " -"epsilon to 0.001 (1e-3)." -msgstr "" - -#: plugins/train/_config.py:262 -msgid "" -"When to save the Optimizer Weights. Saving the optimizer weights is not " -"necessary and will increase the model file size 3x (and by extension the " -"amount of time it takes to save the model). However, it can be useful to " -"save these weights if you want to guarantee that a resumed model carries off " -"exactly from where it left off, rather than spending a few hundred " -"iterations catching up.\n" -"\t never - Don't save optimizer weights.\n" -"\t always - Save the optimizer weights at every save iteration. Model saving " -"will take longer, due to the increased file size, but you will always have " -"the last saved optimizer state in your model file.\n" -"\t exit - Only save the optimizer weights when explicitly terminating a " -"model. This can be when the model is actively stopped or when the target " -"iterations are met. Note: If the training session ends because of another " -"reason (e.g. power outage, Out of Memory Error, NaN detected) then the " -"optimizer weights will NOT be saved." -msgstr "" - -#: plugins/train/_config.py:285 plugins/train/_config.py:297 -#: plugins/train/_config.py:314 +#: plugins/train/train_config.py:126 plugins/train/train_config.py:138 +#: plugins/train/train_config.py:155 msgid "Learning Rate Finder" msgstr "" -#: plugins/train/_config.py:287 +#: plugins/train/train_config.py:128 msgid "" "The number of iterations to process to find the optimal learning rate. " "Higher values will take longer, but will be more accurate." msgstr "" -#: plugins/train/_config.py:299 +#: plugins/train/train_config.py:140 msgid "" "The operation mode for the learning rate finder. Only applicable to new " "models. For existing models this will always default to 'set'.\n" @@ -316,7 +125,7 @@ msgid "" "learning rates and exit." msgstr "" -#: plugins/train/_config.py:316 +#: plugins/train/train_config.py:157 msgid "" "How aggressively to set the Learning Rate. More aggressive can learn faster, " "but is more likely to lead to exploding gradients.\n" @@ -328,21 +137,12 @@ msgid "" "exploding gradients." msgstr "" -#: plugins/train/_config.py:330 -msgid "" -"Apply AutoClipping to the gradients. AutoClip analyzes the gradient weights " -"and adjusts the normalization value dynamically to fit the data. Can help " -"prevent NaNs and improve model optimization at the expense of VRAM. Ref: " -"AutoClip: Adaptive Gradient Clipping for Source Separation Networks https://" -"arxiv.org/abs/2007.14469" -msgstr "" - -#: plugins/train/_config.py:343 plugins/train/_config.py:355 -#: plugins/train/_config.py:369 plugins/train/_config.py:386 +#: plugins/train/train_config.py:172 plugins/train/train_config.py:183 +#: plugins/train/train_config.py:199 msgid "network" msgstr "" -#: plugins/train/_config.py:345 +#: plugins/train/train_config.py:174 msgid "" "Use reflection padding rather than zero padding with convolutions. Each " "convolution must pad the image boundaries to maintain the proper sizing. " @@ -351,23 +151,14 @@ msgid "" "\t http://www-cs.engr.ccny.cuny.edu/~wolberg/cs470/hw/hw2_pad.txt" msgstr "" -#: plugins/train/_config.py:358 -msgid "" -"Enable the Tensorflow GPU 'allow_growth' configuration option. This option " -"prevents Tensorflow from allocating all of the GPU VRAM at launch but can " -"lead to higher VRAM fragmentation and slower performance. Should only be " -"enabled if you are receiving errors regarding 'cuDNN fails to initialize' " -"when commencing training." -msgstr "" - -#: plugins/train/_config.py:371 +#: plugins/train/train_config.py:185 msgid "" "NVIDIA GPUs can run operations in float16 faster than in float32. Mixed " "precision allows you to use a mix of float16 with float32, to get the " "performance benefits from float16 and the numeric stability benefits from " "float32.\n" "\n" -"This is untested on DirectML backend, but will run on most Nvidia models. it " +"This is untested on non-Nvidia cards, but will run on most Nvidia models. it " "will only speed up training on more recent GPUs. Those with compute " "capability 7.0 or higher will see the greatest performance benefit from " "mixed precision because they have Tensor Cores. Older GPUs offer no math " @@ -376,7 +167,7 @@ msgid "" "the most benefit." msgstr "" -#: plugins/train/_config.py:388 +#: plugins/train/train_config.py:201 msgid "" "If a 'NaN' is generated in the model, this means that the model has " "corrupted and the model is likely to start deteriorating from this point on. " @@ -385,11 +176,11 @@ msgid "" "rescue your model." msgstr "" -#: plugins/train/_config.py:401 +#: plugins/train/train_config.py:211 msgid "convert" msgstr "" -#: plugins/train/_config.py:403 +#: plugins/train/train_config.py:213 msgid "" "[GPU Only]. The number of faces to feed through the model at once when " "running the Convert process.\n" @@ -399,35 +190,171 @@ msgid "" "size." msgstr "" -#: plugins/train/_config.py:422 +#: plugins/train/train_config.py:224 +msgid "" +"Focal Frequency Loss. Analyzes the frequency spectrum of the images rather " +"than the images themselves. This loss function can be used on its own, but " +"the original paper found increased benefits when using it as a complementary " +"loss to another spacial loss function (e.g. MSE). Ref: Focal Frequency Loss " +"for Image Reconstruction and Synthesis https://arxiv.org/pdf/2012.12821.pdf " +"NB: This loss does not currently work on AMD cards." +msgstr "" + +#: plugins/train/train_config.py:231 +msgid "" +"Nvidia FLIP. A perceptual loss measure that approximates the difference " +"perceived by humans as they alternate quickly (or flip) between two images. " +"Used on its own and this loss function creates a distinct grid on the " +"output. However it can be helpful when used as a complimentary loss " +"function. Ref: FLIP: A Difference Evaluator for Alternating Images: https://" +"research.nvidia.com/sites/default/files/node/3260/FLIP_Paper.pdf" +msgstr "" + +#: plugins/train/train_config.py:238 +msgid "" +"Gradient Magnitude Similarity Deviation seeks to match the global standard " +"deviation of the pixel to pixel differences between two images. Similar in " +"approach to SSIM. Ref: Gradient Magnitude Similarity Deviation: An Highly " +"Efficient Perceptual Image Quality Index https://arxiv.org/ftp/arxiv/papers/" +"1308/1308.3052.pdf" +msgstr "" + +#: plugins/train/train_config.py:243 +msgid "" +"The L_inf norm will reduce the largest individual pixel error in an image. " +"As each largest error is minimized sequentially, the overall error is " +"improved. This loss will be extremely focused on outliers." +msgstr "" + +#: plugins/train/train_config.py:247 +msgid "" +"Laplacian Pyramid Loss. Attempts to improve results by focussing on edges " +"using Laplacian Pyramids. As this loss function gives priority to edges over " +"other low-frequency information, like color, it should not be used on its " +"own. The original implementation uses this loss as a complimentary function " +"to MSE. Ref: Optimizing the Latent Space of Generative Networks https://" +"arxiv.org/abs/1707.05776" +msgstr "" + +#: plugins/train/train_config.py:254 +msgid "" +"LPIPS is a perceptual loss that uses the feature outputs of other pretrained " +"models as a loss metric. Be aware that this loss function will use more " +"VRAM. Used on its own and this loss will create a distinct moire pattern on " +"the output, however it can be helpful as a complimentary loss function. The " +"output of this function is strong, so depending on your chosen primary loss " +"function, you are unlikely going to want to set the weight above about 25%. " +"Ref: The Unreasonable Effectiveness of Deep Features as a Perceptual Metric " +"http://arxiv.org/abs/1801.03924\n" +"This variant uses the AlexNet backbone. A fairly light and old model which " +"performed best in the paper's original implementation.\n" +"NB: For AMD Users the final linear layer is not implemented." +msgstr "" + +#: plugins/train/train_config.py:264 +msgid "" +"Same as lpips_alex, but using the SqueezeNet backbone. A more lightweight " +"version of AlexNet.\n" +"NB: For AMD Users the final linear layer is not implemented." +msgstr "" + +#: plugins/train/train_config.py:267 +msgid "" +"Same as lpips_alex, but using the VGG16 backbone. A more heavyweight model.\n" +"NB: For AMD Users the final linear layer is not implemented." +msgstr "" + +#: plugins/train/train_config.py:270 +msgid "" +"log(cosh(x)) acts similar to MSE for small errors and to MAE for large " +"errors. Like MSE, it is very stable and prevents overshoots when errors are " +"near zero. Like MAE, it is robust to outliers." +msgstr "" + +#: plugins/train/train_config.py:274 +msgid "" +"Mean absolute error will guide reconstructions of each pixel towards its " +"median value in the training dataset. Robust to outliers but as a median, it " +"can potentially ignore some infrequent image types in the dataset." +msgstr "" + +#: plugins/train/train_config.py:278 +msgid "" +"Mean squared error will guide reconstructions of each pixel towards its " +"average value in the training dataset. As an avg, it will be susceptible to " +"outliers and typically produces slightly blurrier results. Ref: Multi-Scale " +"Structural Similarity for Image Quality Assessment https://www.cns.nyu.edu/" +"pub/eero/wang03b.pdf" +msgstr "" + +#: plugins/train/train_config.py:283 +msgid "" +"Multiscale Structural Similarity Index Metric is similar to SSIM except that " +"it performs the calculations along multiple scales of the input image." +msgstr "" + +#: plugins/train/train_config.py:286 +msgid "" +"Smooth_L1 is a modification of the MAE loss to correct two of its " +"disadvantages. This loss has improved stability and guidance for small " +"errors. Ref: A General and Adaptive Robust Loss Function https://arxiv.org/" +"pdf/1701.03077.pdf" +msgstr "" + +#: plugins/train/train_config.py:290 +msgid "" +"Structural Similarity Index Metric is a perception-based loss that considers " +"changes in texture, luminance, contrast, and local spatial statistics of an " +"image. Potentially delivers more realistic looking images. Ref: Image " +"Quality Assessment: From Error Visibility to Structural Similarity http://" +"www.cns.nyu.edu/pub/eero/wang03-reprint.pdf" +msgstr "" + +#: plugins/train/train_config.py:295 +msgid "" +"Instead of minimizing the difference between the absolute value of each " +"pixel in two reference images, compute the pixel to pixel spatial difference " +"in each image and then minimize that difference between two images. Allows " +"for large color shifts, but maintains the structure of the image." +msgstr "" + +#: plugins/train/train_config.py:299 +msgid "Do not use an additional loss function." +msgstr "" + +#: plugins/train/train_config.py:315 msgid "" "Loss configuration options\n" "Loss is the mechanism by which a Neural Network judges how well it thinks " "that it is recreating a face." msgstr "" -#: plugins/train/_config.py:429 plugins/train/_config.py:441 -#: plugins/train/_config.py:454 plugins/train/_config.py:474 -#: plugins/train/_config.py:486 plugins/train/_config.py:506 -#: plugins/train/_config.py:518 plugins/train/_config.py:538 -#: plugins/train/_config.py:554 plugins/train/_config.py:570 -#: plugins/train/_config.py:587 +#: plugins/train/train_config.py:321 plugins/train/train_config.py:331 +#: plugins/train/train_config.py:343 plugins/train/train_config.py:362 +#: plugins/train/train_config.py:372 plugins/train/train_config.py:391 +#: plugins/train/train_config.py:402 plugins/train/train_config.py:421 +#: plugins/train/train_config.py:436 plugins/train/train_config.py:450 +#: plugins/train/train_config.py:464 msgid "loss" msgstr "" -#: plugins/train/_config.py:433 +#: plugins/train/train_config.py:322 msgid "The loss function to use." msgstr "" -#: plugins/train/_config.py:445 +#: plugins/train/train_config.py:333 msgid "" "The second loss function to use. If using a structural based loss (such as " "SSIM, MS-SSIM or GMSD) it is common to add an L1 regularization(MAE) or L2 " "regularization (MSE) function. You can adjust the weighting of this loss " -"function with the loss_weight_2 option." +"function with the loss_weight_2 option.\n" +"\n" +"\t\n" +"\n" +"\t" msgstr "" -#: plugins/train/_config.py:460 +#: plugins/train/train_config.py:345 msgid "" "The amount of weight to apply to the second loss function.\n" "\n" @@ -445,13 +372,17 @@ msgid "" "\t 0 - Disables the second loss function altogether." msgstr "" -#: plugins/train/_config.py:478 +#: plugins/train/train_config.py:363 msgid "" "The third loss function to use. You can adjust the weighting of this loss " -"function with the loss_weight_3 option." +"function with the loss_weight_3 option.\n" +"\n" +"\t\n" +"\n" +"\t" msgstr "" -#: plugins/train/_config.py:492 +#: plugins/train/train_config.py:374 msgid "" "The amount of weight to apply to the third loss function.\n" "\n" @@ -469,13 +400,17 @@ msgid "" "\t 0 - Disables the third loss function altogether." msgstr "" -#: plugins/train/_config.py:510 +#: plugins/train/train_config.py:393 msgid "" "The fourth loss function to use. You can adjust the weighting of this loss " -"function with the loss_weight_3 option." +"function with the loss_weight_3 option.\n" +"\n" +"\t\n" +"\n" +"\t" msgstr "" -#: plugins/train/_config.py:524 +#: plugins/train/train_config.py:404 msgid "" "The amount of weight to apply to the fourth loss function.\n" "\n" @@ -493,7 +428,7 @@ msgid "" "\t 0 - Disables the fourth loss function altogether." msgstr "" -#: plugins/train/_config.py:543 +#: plugins/train/train_config.py:423 msgid "" "The loss function to use when learning a mask.\n" "\t MAE - Mean absolute error will guide reconstructions of each pixel " @@ -505,7 +440,7 @@ msgid "" "susceptible to outliers and typically produces slightly blurrier results." msgstr "" -#: plugins/train/_config.py:560 +#: plugins/train/train_config.py:438 msgid "" "The amount of priority to give to the eyes.\n" "\n" @@ -518,7 +453,7 @@ msgid "" "NB: Penalized Mask Loss must be enable to use this option." msgstr "" -#: plugins/train/_config.py:576 +#: plugins/train/train_config.py:452 msgid "" "The amount of priority to give to the mouth.\n" "\n" @@ -531,7 +466,7 @@ msgid "" "NB: Penalized Mask Loss must be enable to use this option." msgstr "" -#: plugins/train/_config.py:589 +#: plugins/train/train_config.py:466 msgid "" "Image loss function is weighted by mask presence. For areas of the image " "without the facial mask, reconstruction errors will be ignored while the " @@ -539,13 +474,13 @@ msgid "" "attention on the core face area." msgstr "" -#: plugins/train/_config.py:600 plugins/train/_config.py:643 -#: plugins/train/_config.py:656 plugins/train/_config.py:671 -#: plugins/train/_config.py:680 +#: plugins/train/train_config.py:473 plugins/train/train_config.py:514 +#: plugins/train/train_config.py:525 plugins/train/train_config.py:539 +#: plugins/train/train_config.py:549 msgid "mask" msgstr "" -#: plugins/train/_config.py:603 +#: plugins/train/train_config.py:475 msgid "" "The mask to be used for training. If you have selected 'Learn Mask' or " "'Penalized Mask Loss' you must select a value other than 'none'. The " @@ -583,14 +518,14 @@ msgid "" "performance." msgstr "" -#: plugins/train/_config.py:645 +#: plugins/train/train_config.py:516 msgid "" "Dilate or erode the mask. Negative values erode the mask (make it smaller). " "Positive values dilate the mask (make it larger). The value given is a " "percentage of the total mask size." msgstr "" -#: plugins/train/_config.py:658 +#: plugins/train/train_config.py:527 msgid "" "Apply gaussian blur to the mask input. This has the effect of smoothing the " "edges of the mask, which can help with poorly calculated masks and give less " @@ -600,15 +535,215 @@ msgid "" "number." msgstr "" -#: plugins/train/_config.py:673 +#: plugins/train/train_config.py:541 msgid "" "Sets pixels that are near white to white and near black to black. Set to 0 " "for off." msgstr "" -#: plugins/train/_config.py:682 +#: plugins/train/train_config.py:551 msgid "" "Dedicate a portion of the model to learning how to duplicate the input mask. " "Increases VRAM usage in exchange for learning a quick ability to try to " "replicate more complex mask models." msgstr "" + +#: plugins/train/train_config.py:559 +msgid "" +"Optimizer configuration options\n" +"The optimizer applies the output of the loss function to the model.\n" +msgstr "" + +#: plugins/train/train_config.py:565 plugins/train/train_config.py:600 +#: plugins/train/train_config.py:613 plugins/train/train_config.py:634 +msgid "optimizer" +msgstr "" + +#: plugins/train/train_config.py:567 +msgid "" +"The optimizer to use.\n" +"\t adabelief - Adapting Stepsizes by the Belief in Observed Gradients. An " +"optimizer with the aim to converge faster, generalize better and remain more " +"stable. (https://arxiv.org/abs/2010.07468). NB: Epsilon for AdaBelief needs " +"to be set to a smaller value than other Optimizers. Generally setting the " +"'Epsilon Exponent' to around '-16' should work.\n" +"\t adam - Adaptive Moment Optimization. A stochastic gradient descent method " +"that is based on adaptive estimation of first-order and second-order " +"moments.\n" +"\t adamax - a variant of Adam based on the infinity norm. Due to its " +"capability of adjusting the learning rate based on data characteristics, it " +"is suited to learn time-variant process, parameters follow those provided in " +"the paper\n" +"\t adamw - Like 'adam' but with an added method to decay weights per the " +"techniques discussed in the paper (https://arxiv.org/abs/1711.05101). NB: " +"Weight decay should be set at 0.004 for default implementation.\n" +"\t lion - A method that uses the sign operator to control the magnitude of " +"the update, rather than relying on second-order moments (Adam). saves VRAM " +"by only tracking the momentum. Performance gains should be better with " +"larger batch sizes. A suitable learning rate for Lion is typically 3-10x " +"smaller than that for AdamW. The weight decay for Lion should be 3-10x " +"larger than that for AdamW to maintain a similar strength.\n" +"\t nadam - Adaptive Moment Optimization with Nesterov Momentum. Much like " +"Adam but uses a different formula for calculating momentum.\n" +"\t rms-prop - Root Mean Square Propagation. Maintains a moving (discounted) " +"average of the square of the gradients. Divides the gradient by the root of " +"this average." +msgstr "" + +#: plugins/train/train_config.py:602 +msgid "" +"Learning rate - how fast your network will learn (how large are the " +"modifications to the model weights after one batch of training). Values that " +"are too large might result in model crashes and the inability of the model " +"to find the best solution. Values that are too small might be unable to " +"escape from dead-ends and find the best global minimum." +msgstr "" + +#: plugins/train/train_config.py:615 +msgid "" +"The epsilon adds a small constant to weight updates to attempt to avoid " +"'divide by zero' errors. Unless you are using the AdaBelief Optimizer, then " +"Generally this option should be left at default value, For AdaBelief, " +"setting this to around '-16' should work.\n" +"In all instances if you are getting 'NaN' loss values, and have been unable " +"to resolve the issue any other way (for example, increasing batch size, or " +"lowering learning rate), then raising the epsilon can lead to a more stable " +"model. It may, however, come at the cost of slower training and a less " +"accurate final result.\n" +"Note: The value given here is the 'exponent' to the epsilon. For example, " +"choosing '-7' will set the epsilon to 1e-7. Choosing '-3' will set the " +"epsilon to 0.001 (1e-3).\n" +"Note: Not used by the Lion optimizer" +msgstr "" + +#: plugins/train/train_config.py:636 +msgid "" +"When to save the Optimizer Weights. Saving the optimizer weights is not " +"necessary and will increase the model file size 3x (and by extension the " +"amount of time it takes to save the model). However, it can be useful to " +"save these weights if you want to guarantee that a resumed model carries off " +"exactly from where it left off, rather than spending a few hundred " +"iterations catching up.\n" +"\t never - Don't save optimizer weights.\n" +"\t always - Save the optimizer weights at every save iteration. Model saving " +"will take longer, due to the increased file size, but you will always have " +"the last saved optimizer state in your model file.\n" +"\t exit - Only save the optimizer weights when explicitly terminating a " +"model. This can be when the model is actively stopped or when the target " +"iterations are met. Note: If the training session ends because of another " +"reason (e.g. power outage, Out of Memory Error, NaN detected) then the " +"optimizer weights will NOT be saved." +msgstr "" + +#: plugins/train/train_config.py:657 plugins/train/train_config.py:676 +#: plugins/train/train_config.py:695 +msgid "clipping" +msgstr "" + +#: plugins/train/train_config.py:659 +msgid "" +"Apply clipping to the gradients. Can help prevent NaNs and improve model " +"optimization at the expense of VRAM.\n" +"\tautoclip: Analyzes the gradient weights and adjusts the normalization " +"value dynamically to fit the data\n" +"\tglobal_norm: Clips the gradient of each weight so that the global norm is " +"no higher than the given value.\n" +"\tnorm: Clips the gradient of each weight so that its norm is no higher than " +"the given value.\n" +"\tvalue: Clips the gradient of each weight so that it is no higher than the " +"given value.\n" +"\tnone: Don't perform any clipping to the gradients." +msgstr "" + +#: plugins/train/train_config.py:678 +msgid "" +"The amount of clipping to perform.\n" +"\tautoclip: The percentile to clip at. A value of 1.0 will clip at the 10th " +"percentile a value of 2.5 will clip at the 25th percentile etc. Default: " +"1.0\n" +"\tglobal_norm: The gradient of each weight is clipped so that the global " +"norm is no higher than this value.\n" +"\tnorm: The gradient of each weight is clipped so that its norm is no higher " +"than this value.\n" +"\tvalue: The gradient of each weight is clipped to be no higher than this " +"value.\n" +"\tnone: This option is ignored." +msgstr "" + +#: plugins/train/train_config.py:697 +msgid "" +"The maximum number of prior iterations for autoclipper to analyze when " +"calculating the normalization amount. 0 to always include all prior " +"iterations." +msgstr "" + +#: plugins/train/train_config.py:706 plugins/train/train_config.py:715 +msgid "updates" +msgstr "" + +#: plugins/train/train_config.py:707 +msgid "" +"If set, weight decay is applied. 0.0 for no weight decay. Default is 0.0 for " +"all optimizers except AdamW (0.004)" +msgstr "" + +#: plugins/train/train_config.py:717 +msgid "" +"Values above 1 will enable Gradient Accumulation. Updates will not be at " +"every iteration; instead they will occur every number of iterations given " +"here. The update will be the average value of the gradients since the last " +"update. Can be useful when your batch size is very small, in order to reduce " +"gradient noise at each update iteration." +msgstr "" + +#: plugins/train/train_config.py:728 plugins/train/train_config.py:738 +#: plugins/train/train_config.py:749 +msgid "exponential moving average" +msgstr "" + +#: plugins/train/train_config.py:730 +msgid "" +"Enable exponential moving average (EMA). EMA consists of computing an " +"exponential moving average of the weights of the model (as the weight values " +"change after each training batch), and periodically overwriting the weights " +"with their moving average" +msgstr "" + +#: plugins/train/train_config.py:740 +msgid "" +"Only used if use_ema is enabled. This is the momentum to use when computing " +"the EMA of the model's weights: new_average = ema_momentum * old_average + " +"(1 - ema_momentum) * current_variable_value." +msgstr "" + +#: plugins/train/train_config.py:751 +msgid "" +"Only used if use_ema is enabled. Set the number of iterations, to overwrite " +"the model variable by its moving average. " +msgstr "" + +#: plugins/train/train_config.py:759 plugins/train/train_config.py:770 +#: plugins/train/train_config.py:781 +msgid "optimizer specific" +msgstr "" + +#: plugins/train/train_config.py:761 +msgid "" +"The exponential decay rate for the 1st moment estimates. Used for the " +"following Optimizers: AdaBelief, Adam, Adamax, AdamW, Lion, nAdam. Ignored " +"for all others." +msgstr "" + +#: plugins/train/train_config.py:772 +msgid "" +"The exponential decay rate for the 2nd moment estimates. Used for the " +"following Optimizers: AdaBelief, Adam, Adamax, AdamW, Lion, nAdam. Ignored " +"for all others." +msgstr "" + +#: plugins/train/train_config.py:783 +msgid "" +"Whether to apply AMSGrad variant of the algorithm from the paper 'On the " +"Convergence of Adam and beyond. Used for the following Optimizers: " +"AdaBelief, Adam, AdamW. Ignored for all others.'" +msgstr "" diff --git a/locales/plugins.train.trainer.trainer_config.pot b/locales/plugins.train.trainer.trainer_config.pot new file mode 100644 index 0000000000..8c44e5e618 --- /dev/null +++ b/locales/plugins.train.trainer.trainer_config.pot @@ -0,0 +1,110 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-12-12 20:45+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#: plugins/train/trainer/trainer_config.py:30 +#, python-format +msgid "" +"Data Augmentation Options.\n" +"WARNING: The defaults for augmentation will be fine for 99.9% of use cases. " +"Only change them if you absolutely know what you are doing!" +msgstr "" + +#: plugins/train/trainer/trainer_config.py:42 +#: plugins/train/trainer/trainer_config.py:50 +#: plugins/train/trainer/trainer_config.py:60 +msgid "evaluation" +msgstr "" + +#: plugins/train/trainer/trainer_config.py:43 +msgid "" +"Number of sample faces to display for each side in the preview when training." +msgstr "" + +#: plugins/train/trainer/trainer_config.py:51 +msgid "" +"The opacity of the mask overlay in the training preview. Lower values are " +"more transparent." +msgstr "" + +#: plugins/train/trainer/trainer_config.py:61 +msgid "The RGB hex color to use for the mask overlay in the training preview." +msgstr "" + +#: plugins/train/trainer/trainer_config.py:66 +#: plugins/train/trainer/trainer_config.py:74 +#: plugins/train/trainer/trainer_config.py:82 +#: plugins/train/trainer/trainer_config.py:91 +msgid "image augmentation" +msgstr "" + +#: plugins/train/trainer/trainer_config.py:67 +msgid "Percentage amount to randomly zoom each training image in and out." +msgstr "" + +#: plugins/train/trainer/trainer_config.py:75 +msgid "Percentage amount to randomly rotate each training image." +msgstr "" + +#: plugins/train/trainer/trainer_config.py:83 +msgid "" +"Percentage amount to randomly shift each training image horizontally and " +"vertically." +msgstr "" + +#: plugins/train/trainer/trainer_config.py:92 +msgid "" +"Percentage chance to randomly flip each training image horizontally.\n" +"NB: This is ignored if the 'no-flip' option is enabled" +msgstr "" + +#: plugins/train/trainer/trainer_config.py:100 +#: plugins/train/trainer/trainer_config.py:109 +#: plugins/train/trainer/trainer_config.py:119 +#: plugins/train/trainer/trainer_config.py:130 +msgid "color augmentation" +msgstr "" + +#: plugins/train/trainer/trainer_config.py:101 +msgid "" +"Percentage amount to randomly alter the lightness of each training image.\n" +"NB: This is ignored if the 'no-augment-color' option is enabled" +msgstr "" + +#: plugins/train/trainer/trainer_config.py:110 +msgid "" +"Percentage amount to randomly alter the 'a' and 'b' colors of the L*a*b* " +"color space of each training image.\n" +"NB: This is ignored if the 'no-augment-color' optionis enabled" +msgstr "" + +#: plugins/train/trainer/trainer_config.py:120 +msgid "" +"Percentage chance to perform Contrast Limited Adaptive Histogram " +"Equalization on each training image.\n" +"NB: This is ignored if the 'no-augment-color' option is enabled" +msgstr "" + +#: plugins/train/trainer/trainer_config.py:131 +msgid "" +"The grid size dictates how much Contrast Limited Adaptive Histogram " +"Equalization is performed on any training image selected for clahe. Contrast " +"will be applied randomly with a gridsize of 0 up to the maximum. This value " +"is a multiplier calculated from the training image size.\n" +"NB: This is ignored if the 'no-augment-color' option is enabled" +msgstr "" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args_train.mo b/locales/ru/LC_MESSAGES/lib.cli.args_train.mo index 94837884e0a9b20200e88ce9782e37be12ced462..7ae5608465299f6bf7ad417ab95e202244d48a53 100644 GIT binary patch delta 713 zcmYk4OH30{6o&s+EcgIXkXosBASeVZN^2C*Xasx&Q4?LWKxl9hErhlkqbW6Nf>AN4 zu8bI?iD6eVX{;iZjVm`Z8x4s&gK_OniP3Kg3r}+AyXU`W?s?2C&3fi$JdYlyhs&g= zTP2^QzuTn*^wmj+;B^>JHQX0VuU$(Vl0s(A9nL_X1jwq*ys{s1%A7M5#NR7w+ao$Lc| zTZ3D~|3=THhWP_Z`D?5t@24v3OU;6>WyDM*&C97|Y{W=T44H8=K5C4`jcci^Ni#e; zo-z`#WNJLoZcXp>m2HfKJGzWWv^#RxdcHg0@pM{mL*MGH@5gUcW^+gU*~MeQRwrW@ zoQ!kJ&fD+poO8!;GKRfi=h5HUMSI@44-1?XvWrc%q0-K~_xgqUV I|GDY%fBvhJ*8l(j delta 2092 zcmZ`&-)|IE6h18o+DNgsNDCB?U;=`zY}Fz)QL!-vp{DkSZzN84r`?IOJL}9WwM1QZ zOKJGgA4z>cj1XT;Fd8YKWqniGHdj+CoGv^+X=<7_b$18~7sdH{c?$z9GbWK(~Tu zY8TN{z)gFIZeV{`Blv*xz{i1&dx;(a9$LX8z^(Xh-AfsI42#zgcocX7Xo2WmU^g0D zd>YN+yLul{JMf$RL_LWA`7F`j$ba(yh!B6{AkjYHiRZwxiRgnvSR>DGBD#h6(hCrV z@4-ljNvOGuc>3iG(IqISqeLfxmtQ6N3$5LIov03B)j@O)iN6Cs!T0+oi7o(t0bW8p z`Zm!S;2)>p8VaB4L?bBlX%|rw;$L+WeTn>^d*Bc-(HjcA02F7&Ph$aB={oQUU;$VM z{2qw&v<>g-1Gn|VD_9K_Qq&{SP&<95a%aO1thVEo2Oa4$u#t98k5?XkW~YSLJkYA^ zJ_*DWP+j4E<&V`FKW=i&@_g6oPy3dWWY0Hzb8wh_htr`!LVcUrsHg|OOdClA#w>*v-NsgO#iaq13 z$)1xi`MvJroox8JK-3ZHfpEi1nPw)&{llDeeCT#uD>Z?k@B&%7q7)(xocVtIKEn#PJ-Ds(6K~^oR zGIFk}yTBPC39fFy$hrWsf#ubLpe%;8YbKP1k%JNSttK8*d7YA(*l;w}*Fq8YuNGBF z-NngbkQ0_7Y862vHI{-as>oR6WnQ0WHN%iw28KzZbpfRo7*)fg`QRe>N|JI#EoY<~ z&`bsssFsy{A&W3_P?yymolul2i&zSo*n+~rsN7#OtJ`5o5jMBlWLb0uW1(r|I93L) z1WVdUe29c4l_}$D%He+juk+&ZSV$r%I49)duA0Z54y;(MO3({a!>I0RAsBh6Lq!fo z!2{bN_JPz)enrGh?3Kh-l-F0IivRz|q-|Xj12n5IVh)Ee9@6o@?rCLO*vepB#C{fi zijdH`U<84j%D<&;nGs)u(e=Nlx(U%6)Das>pb<6VDtw*Z-q9+Pj4Ik{xuFGEJ^JuV zyNr4jr$ZnYwV&=F}|aYF_`^L00~_gC)6Z iu`Q2-IrKgs4w;As&4c{u51aQFa>u5&6&5;%w*CWQ3w4PA diff --git a/locales/ru/LC_MESSAGES/lib.cli.args_train.po b/locales/ru/LC_MESSAGES/lib.cli.args_train.po index c511b56940..e78537cc6b 100755 --- a/locales/ru/LC_MESSAGES/lib.cli.args_train.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args_train.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-21 17:32+0000\n" -"PO-Revision-Date: 2025-11-21 17:48+0000\n" +"POT-Creation-Date: 2025-12-15 20:02+0000\n" +"PO-Revision-Date: 2025-12-19 23:27+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -17,7 +17,7 @@ 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.6\n" +"X-Generator: Poedit 3.8\n" #: lib/cli/args_train.py:30 msgid "" @@ -165,8 +165,8 @@ msgstr "" "замораживания других слоев." #: lib/cli/args_train.py:147 lib/cli/args_train.py:160 -#: lib/cli/args_train.py:174 lib/cli/args_train.py:186 -#: lib/cli/args_train.py:202 lib/cli/args_train.py:211 +#: lib/cli/args_train.py:174 lib/cli/args_train.py:183 +#: lib/cli/args_train.py:190 lib/cli/args_train.py:199 msgid "training" msgstr "тренировка" @@ -207,30 +207,13 @@ msgstr "" "выбранного целевого значения за указанное здесь количество итераций. 0 — " "отключить." -#: lib/cli/args_train.py:188 -msgid "" -"R|Select the distribution stategy to use.\n" -"L|default: Use Tensorflow's default distribution strategy.\n" -"L|central-storage: Centralizes variables on the CPU whilst operations are " -"performed on 1 or more local GPUs. This can help save some VRAM at the cost " -"of some speed by not storing variables on the GPU. Note: Mixed-Precision is " -"not supported on multi-GPU setups.\n" -"L|mirrored: Supports synchronous distributed training across multiple local " -"GPUs. A copy of the model and all variables are loaded onto each GPU with " -"batches distributed to each GPU at each iteration." +#: lib/cli/args_train.py:184 +msgid "Use distibuted training on multi-gpu setups." msgstr "" -"R|Выберите стратегию распределения для использования.\n" -"L|default: Использовать стратегию распространения Tensorflow по умолчанию.\n" -"L|central-storage: Централизует переменные на CPU, в то время как операции " -"выполняются на 1 или более локальных GPU. Это может помочь сэкономить " -"немного VRAM за счет некоторой скорости, поскольку переменные не хранятся на " -"GPU. Примечание: Mixed-Precision не поддерживается на многопроцессорных " -"установках.\n" -"L|mirrored: Поддерживает синхронное распределенное обучение на нескольких " -"локальных GPU. Копия модели и все переменные загружаются на каждый GPU с " -"распределением партий на каждый GPU на каждой итерации." - -#: lib/cli/args_train.py:204 +"Используйте распределенное обучение на системах с несколькими графическими " +"процессорами." + +#: lib/cli/args_train.py:192 msgid "" "Disables TensorBoard logging. NB: Disabling logs means that you will not be " "able to use the graph or analysis for this session in the GUI." @@ -239,7 +222,7 @@ msgstr "" "журналов означает, что вы не сможете использовать график или анализ для этой " "сессии в графическом интерфейсе." -#: lib/cli/args_train.py:213 +#: lib/cli/args_train.py:201 msgid "" "Use the Learning Rate Finder to discover the optimal learning rate for " "training. For new models, this will calculate the optimal learning rate for " @@ -254,15 +237,15 @@ msgstr "" "модели. Установка этой опции приведет к игнорированию вручную настроенного " "коэффициента обучения (настраиваемого в параметрах обучения)." -#: lib/cli/args_train.py:226 lib/cli/args_train.py:236 +#: lib/cli/args_train.py:214 lib/cli/args_train.py:224 msgid "Saving" msgstr "Сохранение" -#: lib/cli/args_train.py:227 +#: lib/cli/args_train.py:215 msgid "Sets the number of iterations between each model save." msgstr "Устанавливает количество итераций между каждым сохранением модели." -#: lib/cli/args_train.py:238 +#: lib/cli/args_train.py:226 msgid "" "Sets the number of iterations before saving a backup snapshot of the model " "in it's current state. Set to 0 for off." @@ -271,12 +254,12 @@ msgstr "" "Устанавливает количество итераций перед сохранением резервного снимка модели " "в текущем состоянии. Установите значение 0 для выключения." -#: lib/cli/args_train.py:245 lib/cli/args_train.py:257 -#: lib/cli/args_train.py:269 +#: lib/cli/args_train.py:233 lib/cli/args_train.py:245 +#: lib/cli/args_train.py:257 msgid "timelapse" msgstr "таймлапс" -#: lib/cli/args_train.py:247 +#: lib/cli/args_train.py:235 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -290,7 +273,7 @@ msgstr "" "создания timelapse. Вы также должны указать параметры --timelapse-output и --" "timelapse-input-B." -#: lib/cli/args_train.py:259 +#: lib/cli/args_train.py:247 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. " @@ -304,7 +287,7 @@ msgstr "" "создания timelapse. Вы также должны указать параметры --timelapse-output и --" "timelapse-input-A." -#: lib/cli/args_train.py:271 +#: lib/cli/args_train.py:259 msgid "" "Optional for creating a timelapse. Timelapse will save an image of your " "selected faces into the timelapse-output folder at every save iteration. If " @@ -316,15 +299,15 @@ msgstr "" "указаны входные папки, но нет выходной папки, то по умолчанию будет выбрана " "папка модели/timelapse/" -#: lib/cli/args_train.py:280 lib/cli/args_train.py:287 +#: lib/cli/args_train.py:268 lib/cli/args_train.py:275 msgid "preview" msgstr "предпросмотр" -#: lib/cli/args_train.py:281 +#: lib/cli/args_train.py:269 msgid "Show training preview output. in a separate window." msgstr "Показать вывод предварительного просмотра тренировки в отдельном окне." -#: lib/cli/args_train.py:289 +#: lib/cli/args_train.py:277 msgid "" "Writes the training result to a file. The image will be stored in the root " "of your FaceSwap folder." @@ -332,12 +315,12 @@ msgstr "" "Записывает результат обучения в файл. Изображение будет сохранено в корне " "папки Faceswap." -#: lib/cli/args_train.py:296 lib/cli/args_train.py:306 -#: lib/cli/args_train.py:316 lib/cli/args_train.py:326 +#: lib/cli/args_train.py:284 lib/cli/args_train.py:294 +#: lib/cli/args_train.py:304 lib/cli/args_train.py:314 msgid "augmentation" msgstr "аугментация" -#: lib/cli/args_train.py:298 +#: lib/cli/args_train.py:286 msgid "" "Warps training faces to closely matched Landmarks from the opposite face-set " "rather than randomly warping the face. This is the 'dfaker' way of doing " @@ -347,7 +330,7 @@ msgstr "" "набора лиц вместо случайного искажения лица. Это способ выполнения искажения " "от \"dfaker\" ." -#: lib/cli/args_train.py:308 +#: lib/cli/args_train.py:296 msgid "" "To effectively learn, a random set of images are flipped horizontally. " "Sometimes it is desirable for this not to occur. Generally this should be " @@ -357,7 +340,7 @@ msgstr "" "горизонтали. Иногда желательно, чтобы этого не происходило. Как правило, это " "не нужно делать, за исключением случаев \"тренировки подгонки\"." -#: lib/cli/args_train.py:318 +#: lib/cli/args_train.py:306 msgid "" "Color augmentation helps make the model less susceptible to color " "differences between the A and B sets, at an increased training time cost. " @@ -368,7 +351,7 @@ msgstr "" "времени на обучение. Включите этот параметр для отключения цветовой " "аугментации." -#: lib/cli/args_train.py:328 +#: lib/cli/args_train.py:316 msgid "" "Warping is integral to training the Neural Network. This option should only " "be enabled towards the very end of training to try to bring out more detail. " @@ -380,6 +363,29 @@ msgstr "" "больше деталей. Считайте это \"тонкой настройкой\". Включение этой опции в " "самом начале, скорее всего, погубит модель и приведет к ужасным результатам." +#~ msgid "" +#~ "R|Select the distribution stategy to use.\n" +#~ "L|default: Use Tensorflow's default distribution strategy.\n" +#~ "L|central-storage: Centralizes variables on the CPU whilst operations are " +#~ "performed on 1 or more local GPUs. This can help save some VRAM at the " +#~ "cost of some speed by not storing variables on the GPU. Note: Mixed-" +#~ "Precision is not supported on multi-GPU setups.\n" +#~ "L|mirrored: Supports synchronous distributed training across multiple " +#~ "local GPUs. A copy of the model and all variables are loaded onto each " +#~ "GPU with batches distributed to each GPU at each iteration." +#~ msgstr "" +#~ "R|Выберите стратегию распределения для использования.\n" +#~ "L|default: Использовать стратегию распространения Tensorflow по " +#~ "умолчанию.\n" +#~ "L|central-storage: Централизует переменные на CPU, в то время как " +#~ "операции выполняются на 1 или более локальных GPU. Это может помочь " +#~ "сэкономить немного VRAM за счет некоторой скорости, поскольку переменные " +#~ "не хранятся на GPU. Примечание: Mixed-Precision не поддерживается на " +#~ "многопроцессорных установках.\n" +#~ "L|mirrored: Поддерживает синхронное распределенное обучение на нескольких " +#~ "локальных GPU. Копия модели и все переменные загружаются на каждый GPU с " +#~ "распределением партий на каждый GPU на каждой итерации." + #~ msgid "Global Options" #~ msgstr "Глобальные Настройки" diff --git a/locales/ru/LC_MESSAGES/lib.config.objects.mo b/locales/ru/LC_MESSAGES/lib.config.objects.mo new file mode 100644 index 0000000000000000000000000000000000000000..d10f8be1df0a59a76a73708054d9e1aa1f69e9b3 GIT binary patch literal 1309 zcmah|%WD%s9A33PSV6>-ipqc_b0V6rzD`rU6k5~^G zpiaPcolMVY`e2R(%wc9|yLnQKoX{>gjCiHc_S}Gk1?Cb)&BBiz5){11vB)@sSBd&G zWLQiQN`ewK;pby&dc`6o-7J=m5j$kXM316mn^Na>O~0v9!q#G@U8D|iqvAO85jT9u zkaBv4P}c%=OkHBTA#N_|nuQtEQh#3t>aU7%+%Cx}78oj#VMC^v&7 z_p}k;n?MhYLCd(s`~bCQh(5|no*x>+LEg5EYf(NhvL3caZWyENfh~=UKBUNyWITD< zNF?!*L@EfWSa@|TJ7Ad4fS$0TLu{8R?pNl!Z#OvY_Z-_Fl6W-vfyB^IyY~=0mMKR0Qd4up}6{mqX zHAGX9HZhsyYrMtR#k^#K>6Unc6wMw2n-+h=S9p!!tj6EUsi^MQ$mxpo#mS~LS`u@j zEar(yv5K=g&g$*6Eo`=A9oTB|4OxX!wTi%7qN;K=Nw+k{8nyBbS##!>Y<`GM z#{Pf?sVyS+#)hD!=M;LlhY#=jeEGib_ind73$2e-=o%>@&&rqbnEWOCasqHCyYjvK z8icO;%@9OY?}nkLdVdI>#I?xaj&Jcm`FRw6)PIY?qWBUAMf{42S{TEF1E!cWAZ$WG z%#Xr@F1Sd+wuUZQFfKMHz*GNf3bNw-G*~*nHv{jgPv+oI12)qzBDO_ad=wR*_Ql?lbA6LCob0fKuakKi;?sDGE Z*^c8qo9*Cg-0Mx+ots3%@^%*1{{Vg-F^~WN delta 1313 zcmZuu+e;Kt82>D@)V0D35e9u-UMgKuqbPzhh={PF>w`&-yR$n3ZPZmdW+Uwx7NMbzi^Jv5ncX=h@SeL8Ci@vaOOMTch2{_eDk&U#l_T@=Gte3wF7$( z_EYR#*r6JHunfdk*e3Q{?9gVSHPmypMB9+hZXx=He6fzGhM*Zh*qHe?o?L@73UxAy?$k+A~-2vYFeMD#RKD3``JMzARL`NusOB|X}m`B9> z>Vn|vf);|&>Z-*_fv8oFtqiCMLaV>PPcWz)QeEy)&02k`|MI9AH8s9sTjK*^K5uJ7 zmd>7`^9|ijIBv{x#`wb3n>?slINpuB&X{Kma>TJ?W<2R?p6S@^jHnRi8-|%!Pk6-D zM|ImvYy^tA&M+&W2>R?eYbncOC+71hl3Uk$`&jeX(vo(>;Gv|)5zS^x*P`q>Y?yIF zcY{|kSBnn=KMdAge*SMSs;Gj3Bt_mqJ|y0P)x&YsSr$2+^a zx$ESqW8FuNo=6WgzNzgAcQvHLt%q~r&{%7#U%V2lq9h-RqF80IjN>8)qsP?66O>Ex zk(?6+IVJLpx1Z!Jl1Ho>L`BYsg2>5fIUzFYLKHTR_22&k%y&S_tRtlWwaB8Ppg#fc zqsaPl7DZl8ZA21Txd3%}rB6=Dhm69cQjrCxszaE_h;sF|S}sD^1X>or0F?y>Ne0Jh zIVp-Mk?&CSuwDkl2cI^02jn~`rc_5UPzMci9$XdGAy!ztHXU-Rh}$VWf8mG~8? GDbOEIuOG7j diff --git a/locales/ru/LC_MESSAGES/plugins.extract._config.po b/locales/ru/LC_MESSAGES/plugins.extract.extract_config.po similarity index 82% rename from locales/ru/LC_MESSAGES/plugins.extract._config.po rename to locales/ru/LC_MESSAGES/plugins.extract.extract_config.po index 0500d72cda..9d42c596bf 100644 --- a/locales/ru/LC_MESSAGES/plugins.extract._config.po +++ b/locales/ru/LC_MESSAGES/plugins.extract.extract_config.po @@ -7,45 +7,28 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-06-08 16:43+0100\n" -"PO-Revision-Date: 2023-06-12 19:42+0700\n" +"POT-Creation-Date: 2025-12-12 13:11+0000\n" +"PO-Revision-Date: 2025-12-12 13:14+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru_RU\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Poedit 3.3.1\n" +"X-Generator: Poedit 3.8\n" -#: plugins/extract/_config.py:32 +#: plugins/extract/extract_config.py:23 msgid "Options that apply to all extraction plugins" msgstr "Параметры, применимые ко всем плагинам извлечения" -#: plugins/extract/_config.py:38 -msgid "settings" -msgstr "настройки" - -#: plugins/extract/_config.py:39 -msgid "" -"Enable the Tensorflow GPU `allow_growth` configuration " -"option. This option prevents Tensorflow from allocating all of the GPU VRAM " -"at launch but can lead to higher VRAM fragmentation and slower performance. " -"Should only be enabled if you are having problems running extraction." -msgstr "" -"Включите опцию конфигурации Tensorflow GPU " -"`allow_growth`. Эта опция не позволяет Tensorflow выделять всю видеопамять " -"видеокарты при запуске, но может привести к повышенной фрагментации " -"видеопамяти и снижению производительности. Следует включать только в том " -"случае, если у вас есть проблемы с запуском извлечения." - -#: plugins/extract/_config.py:50 plugins/extract/_config.py:64 -#: plugins/extract/_config.py:78 plugins/extract/_config.py:89 -#: plugins/extract/_config.py:99 plugins/extract/_config.py:108 -#: plugins/extract/_config.py:119 +#: plugins/extract/extract_config.py:30 plugins/extract/extract_config.py:45 +#: plugins/extract/extract_config.py:60 plugins/extract/extract_config.py:72 +#: plugins/extract/extract_config.py:85 plugins/extract/extract_config.py:95 +#: plugins/extract/extract_config.py:107 msgid "filters" msgstr "фильтры" -#: plugins/extract/_config.py:51 +#: plugins/extract/extract_config.py:32 msgid "" "Filters out faces below this size. This is a multiplier of the minimum " "dimension of the frame (i.e. 1280x720 = 720). If the original face extract " @@ -62,7 +45,7 @@ msgstr "" "изображениями, за исключением экстремально длинных снимков. Обычно их можно " "смело отбрасывать." -#: plugins/extract/_config.py:65 +#: plugins/extract/extract_config.py:47 msgid "" "Filters out faces above this size. This is a multiplier of the minimum " "dimension of the frame (i.e. 1280x720 = 720). If the original face extract " @@ -79,7 +62,7 @@ msgstr "" "изображениями, за исключением экстремальных крупных планов. Обычно их можно " "смело отбрасывать." -#: plugins/extract/_config.py:79 +#: plugins/extract/extract_config.py:62 msgid "" "Filters out faces who's landmarks are above this distance from an 'average' " "face. Values above 15 tend to be fairly safe. Values above 10 will remove " @@ -90,7 +73,7 @@ msgstr "" "безопасны. Значения выше 10 устраняют больше ложных срабатываний, но также " "могут отфильтровать некоторые лица под экстремальными углами." -#: plugins/extract/_config.py:90 +#: plugins/extract/extract_config.py:74 msgid "" "Filters out faces who's calculated roll is greater than zero +/- this value " "in degrees. Aligned faces should have a roll value close to zero. Values " @@ -103,7 +86,7 @@ msgstr "" "правило, представляют собой неправильно выровненные изображения. Обычно их " "можно смело отбрасывать." -#: plugins/extract/_config.py:100 +#: plugins/extract/extract_config.py:87 msgid "" "Filters out faces where the lowest point of the aligned face's eye or " "eyebrow is lower than the highest point of the aligned face's mouth. Any " @@ -114,7 +97,7 @@ msgstr "" "которых это происходит, являются неправильно выровненными и могут быть смело " "отброшены." -#: plugins/extract/_config.py:109 +#: plugins/extract/extract_config.py:97 msgid "" "If enabled, and 're-feed' has been selected for extraction, then interim " "alignments will be filtered prior to averaging the final landmarks. This can " @@ -129,7 +112,7 @@ msgstr "" "результатов, а также может помочь выявить сложные выравнивания. Если эта " "функция отключена, то все результаты повторной подачи будут усреднены." -#: plugins/extract/_config.py:120 +#: plugins/extract/extract_config.py:109 msgid "" "If enabled, saves any filtered out images into a sub-folder during the " "extraction process. If disabled, filtered faces are deleted. Note: The faces " @@ -141,11 +124,11 @@ msgstr "" "Примечание: Лица всегда будут отфильтрованы из файла выравнивания, " "независимо от того, сохраняете вы эти лица или нет." -#: plugins/extract/_config.py:129 plugins/extract/_config.py:138 +#: plugins/extract/extract_config.py:118 plugins/extract/extract_config.py:128 msgid "re-align" msgstr "повторное выравнивание" -#: plugins/extract/_config.py:130 +#: plugins/extract/extract_config.py:120 msgid "" "If enabled, and 're-align' has been selected for extraction, then all re-" "feed iterations are re-aligned. If disabled, then only the final averaged " @@ -156,7 +139,7 @@ msgstr "" "отключено, то выравнивается только конечный усредненный результат повторной " "подачи." -#: plugins/extract/_config.py:139 +#: plugins/extract/extract_config.py:130 msgid "" "If enabled, and 're-align' has been selected for extraction, then any " "alignments which would be filtered out will not be re-aligned." @@ -164,3 +147,18 @@ msgstr "" "Если эта функция включена, и для извлечения выбрано 'повторное " "выравнивание'('re-align'), то все выравнивания, которые будут отфильтрованы, " "не будут повторно выравниваться." + +#~ msgid "settings" +#~ msgstr "настройки" + +#~ msgid "" +#~ "Enable the Tensorflow GPU `allow_growth` configuration option. This " +#~ "option prevents Tensorflow from allocating all of the GPU VRAM at launch " +#~ "but can lead to higher VRAM fragmentation and slower performance. Should " +#~ "only be enabled if you are having problems running extraction." +#~ msgstr "" +#~ "Включите опцию конфигурации Tensorflow GPU `allow_growth`. Эта опция не " +#~ "позволяет Tensorflow выделять всю видеопамять видеокарты при запуске, но " +#~ "может привести к повышенной фрагментации видеопамяти и снижению " +#~ "производительности. Следует включать только в том случае, если у вас есть " +#~ "проблемы с запуском извлечения." diff --git a/locales/ru/LC_MESSAGES/plugins.train._config.mo b/locales/ru/LC_MESSAGES/plugins.train.train_config.mo similarity index 55% rename from locales/ru/LC_MESSAGES/plugins.train._config.mo rename to locales/ru/LC_MESSAGES/plugins.train.train_config.mo index a1032a91d1a0e62524d07cf7f2125b2825715bd0..3c4150292605427a5eb929ab658e9fce67368e6a 100644 GIT binary patch delta 12757 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 diff --git a/locales/ru/LC_MESSAGES/plugins.train._config.po b/locales/ru/LC_MESSAGES/plugins.train.train_config.po similarity index 68% rename from locales/ru/LC_MESSAGES/plugins.train._config.po rename to locales/ru/LC_MESSAGES/plugins.train.train_config.po index 04191e5e70..a36c337933 100644 --- a/locales/ru/LC_MESSAGES/plugins.train._config.po +++ b/locales/ru/LC_MESSAGES/plugins.train.train_config.po @@ -7,17 +7,17 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-26 17:37+0000\n" -"PO-Revision-Date: 2024-03-26 17:40+0000\n" +"POT-Creation-Date: 2025-12-13 13:39+0000\n" +"PO-Revision-Date: 2025-12-15 22:01+0700\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru_RU\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Poedit 3.4.2\n" +"X-Generator: Poedit 3.5\n" -#: plugins/train/_config.py:17 +#: plugins/train/train_config.py:21 msgid "" "\n" "NB: Unless specifically stated, values changed here will only take effect " @@ -27,226 +27,16 @@ msgstr "" "Примечание: До тех пор, пока об этом не сказано, значения, измененные здесь, " "будут применены при создании новой модели." -#: plugins/train/_config.py:22 -msgid "" -"Focal Frequency Loss. Analyzes the frequency spectrum of the images rather " -"than the images themselves. This loss function can be used on its own, but " -"the original paper found increased benefits when using it as a complementary " -"loss to another spacial loss function (e.g. MSE). Ref: Focal Frequency Loss " -"for Image Reconstruction and Synthesis https://arxiv.org/pdf/2012.12821.pdf " -"NB: This loss does not currently work on AMD cards." -msgstr "" -"Потеря фокальной частоты. Анализирует частотный спектр изображений, а не " -"сами изображения. Эта функция потерь может использоваться сама по себе, но в " -"оригинальной статье было обнаружено, что она дает больше преимуществ при " -"использовании в качестве дополнительной потери к другой пространственной " -"функции потерь (например, MSE). Ссылка: Focal Frequency Loss for Image " -"Reconstruction and Synthesis [ТОЛЬКО на английском] https://arxiv.org/" -"pdf/2012.12821.pdf NB: Эта потеря в настоящее время не работает на картах " -"AMD." - -#: plugins/train/_config.py:29 -msgid "" -"Nvidia FLIP. A perceptual loss measure that approximates the difference " -"perceived by humans as they alternate quickly (or flip) between two images. " -"Used on its own and this loss function creates a distinct grid on the " -"output. However it can be helpful when used as a complimentary loss " -"function. Ref: FLIP: A Difference Evaluator for Alternating Images: https://" -"research.nvidia.com/sites/default/files/node/3260/FLIP_Paper.pdf" -msgstr "" -"Nvidia FLIP. Мера потерь восприятия, которая приближает разницу, " -"воспринимаемую человеком при быстром чередовании (или перелистывании) двух " -"изображений. Используемая сама по себе, эта функция потерь создает на выходе " -"отчетливую сетку. Однако она может быть полезна при использовании в качестве " -"дополнительной функции потерь. Ссылка: FLIP: A Difference Evaluator for " -"Alternating Images [ТОЛЬКО на английском]: https://research.nvidia.com/sites/" -"default/files/node/3260/FLIP_Paper.pdf" - -#: plugins/train/_config.py:36 -msgid "" -"Gradient Magnitude Similarity Deviation seeks to match the global standard " -"deviation of the pixel to pixel differences between two images. Similar in " -"approach to SSIM. Ref: Gradient Magnitude Similarity Deviation: An Highly " -"Efficient Perceptual Image Quality Index https://arxiv.org/ftp/arxiv/" -"papers/1308/1308.3052.pdf" -msgstr "" -"Отклонение Схожести Магнитуды Градиентов(Gradient Magnitude Similarity " -"Deviation) пытается совместить глобальную стандартную девиацию различий " -"пикселя к пикселю между двумя изображениями. Подход похож на SSIM. Ссылка: " -"Gradient Magnitude Similarity Deviation: An Highly Efficient Perceptual " -"Image Quality Index [ТОЛЬКО на английском] https://arxiv.org/ftp/arxiv/" -"papers/1308/1308.3052.pdf" - -#: plugins/train/_config.py:41 -msgid "" -"The L_inf norm will reduce the largest individual pixel error in an image. " -"As each largest error is minimized sequentially, the overall error is " -"improved. This loss will be extremely focused on outliers." -msgstr "" -"Норма L_inf уменьшает наибольшую ошибку отдельного пикселя в изображении. По " -"мере последовательной минимизации каждой наибольшей ошибки улучшается общая " -"ошибка. Эта потеря будет чрезвычайно сосредоточена на выбросах." - -#: plugins/train/_config.py:45 -msgid "" -"Laplacian Pyramid Loss. Attempts to improve results by focussing on edges " -"using Laplacian Pyramids. As this loss function gives priority to edges over " -"other low-frequency information, like color, it should not be used on its " -"own. The original implementation uses this loss as a complimentary function " -"to MSE. Ref: Optimizing the Latent Space of Generative Networks https://" -"arxiv.org/abs/1707.05776" -msgstr "" -"Потеря пирамиды Лапласиана. Пытается улучшить результаты, концентрируясь на " -"краях с помощью пирамид Лапласиана. Поскольку эта функция потерь отдает " -"приоритет краям, а не другой низкочастотной информации, например, цвету, ее " -"не следует использовать самостоятельно. В оригинальной реализации эта потеря " -"используется как дополнительная функция к MSE. Ссылка: Optimizing the Latent " -"Space of Generative Networks [ТОЛЬКО на английском] https://arxiv.org/" -"abs/1707.05776" - -#: plugins/train/_config.py:52 -msgid "" -"LPIPS is a perceptual loss that uses the feature outputs of other pretrained " -"models as a loss metric. Be aware that this loss function will use more " -"VRAM. Used on its own and this loss will create a distinct moire pattern on " -"the output, however it can be helpful as a complimentary loss function. The " -"output of this function is strong, so depending on your chosen primary loss " -"function, you are unlikely going to want to set the weight above about 25%. " -"Ref: The Unreasonable Effectiveness of Deep Features as a Perceptual Metric " -"http://arxiv.org/abs/1801.03924\n" -"This variant uses the AlexNet backbone. A fairly light and old model which " -"performed best in the paper's original implementation.\n" -"NB: For AMD Users the final linear layer is not implemented." -msgstr "" -"LPIPS - это перцептивная потеря, которая использует в качестве метрики " -"потерь выходные характеристики других предварительно обученных моделей. " -"Имейте в виду, что эта функция потерь использует больше VRAM. При " -"самостоятельном использовании эта потеря создает на выходе отчетливый " -"муаровый рисунок, однако она может быть полезна как дополнительная функция " -"потерь. Вывод этой функции является сильным, поэтому, в зависимости от " -"выбранной вами основной функции потерь, вы вряд ли захотите устанавливать " -"вес выше 25%. Ссылка: The Unreasonable Effectiveness of Deep Features as a " -"Perceptual Metric [ТОЛЬКО на английском] http://arxiv.org/abs/1801.03924.\n" -"Этот вариант использует основу AlexNet. Это довольно легкая и старая модель, " -"которая лучше всего показала себя в оригинальной реализации.\n" -"NB: Для пользователей AMD последний линейный слой не реализован." - -#: plugins/train/_config.py:62 -msgid "" -"Same as lpips_alex, but using the SqueezeNet backbone. A more lightweight " -"version of AlexNet.\n" -"NB: For AMD Users the final linear layer is not implemented." -msgstr "" -"То же, что и lpips_alex, но использует основу SqueezeNet. Более облегченная " -"версия AlexNet.\n" -"NB: Для пользователей AMD последний линейный слой не реализован." - -#: plugins/train/_config.py:65 -msgid "" -"Same as lpips_alex, but using the VGG16 backbone. A more heavyweight model.\n" -"NB: For AMD Users the final linear layer is not implemented." -msgstr "" -"То же, что и lpips_alex, но использует основу VGG16. Более тяжелая модель.\n" -"NB: Для пользователей AMD последний линейный слой не реализован." - -#: plugins/train/_config.py:68 -msgid "" -"log(cosh(x)) acts similar to MSE for small errors and to MAE for large " -"errors. Like MSE, it is very stable and prevents overshoots when errors are " -"near zero. Like MAE, it is robust to outliers." -msgstr "" -"log(cosh(x)) действует аналогично MSE для малых ошибок и MAE для больших " -"ошибок. Как и MSE, он очень стабилен и предотвращает переборы, когда ошибки " -"близки к нулю. Как и MAE, он устойчив к выбросам." - -#: plugins/train/_config.py:72 -msgid "" -"Mean absolute error will guide reconstructions of each pixel towards its " -"median value in the training dataset. Robust to outliers but as a median, it " -"can potentially ignore some infrequent image types in the dataset." -msgstr "" -"Средняя абсолютная погрешность направляет реконструкцию каждого пикселя к " -"его медианному значению в обучающем наборе данных. Устойчив к выбросам, но в " -"качестве медианы может игнорировать некоторые редкие типы изображений в " -"наборе данных." - -#: plugins/train/_config.py:76 -msgid "" -"Mean squared error will guide reconstructions of each pixel towards its " -"average value in the training dataset. As an avg, it will be susceptible to " -"outliers and typically produces slightly blurrier results. Ref: Multi-Scale " -"Structural Similarity for Image Quality Assessment https://www.cns.nyu.edu/" -"pub/eero/wang03b.pdf" -msgstr "" -"Средняя квадратичная погрешность направляет реконструкцию каждого пикселя к " -"его среднему значению в наборе данных для обучения. Как среднее значение, " -"оно будет чувствительно к выбросам и обычно дает немного более размытые " -"результаты. Ссылка: Multi-Scale Structural Similarity for Image Quality " -"Assessment [ТОЛЬКО на английском]https://www.cns.nyu.edu/pub/eero/wang03b.pdf" - -#: plugins/train/_config.py:81 -msgid "" -"Multiscale Structural Similarity Index Metric is similar to SSIM except that " -"it performs the calculations along multiple scales of the input image." -msgstr "" -"Метрика Индекса Многомасштабного Структурного Сходства (Multiscale " -"Structural Similarity Index Metric) похожа на SSIM, за исключением того, что " -"она выполняет вычисления по нескольким масштабам входного изображения." - -#: plugins/train/_config.py:84 -msgid "" -"Smooth_L1 is a modification of the MAE loss to correct two of its " -"disadvantages. This loss has improved stability and guidance for small " -"errors. Ref: A General and Adaptive Robust Loss Function https://arxiv.org/" -"pdf/1701.03077.pdf" -msgstr "" -"Smooth_L1 - это модификация потери MAE для исправления двух ее недостатков. " -"Эта потеря улучшает стабильность и ориентирование при небольших " -"погрешностях. Ссылка: A General and Adaptive Robust Loss Function [ТОЛЬКО на " -"английском] https://arxiv.org/pdf/1701.03077.pdf" - -#: plugins/train/_config.py:88 -msgid "" -"Structural Similarity Index Metric is a perception-based loss that considers " -"changes in texture, luminance, contrast, and local spatial statistics of an " -"image. Potentially delivers more realistic looking images. Ref: Image " -"Quality Assessment: From Error Visibility to Structural Similarity http://" -"www.cns.nyu.edu/pub/eero/wang03-reprint.pdf" -msgstr "" -"Метрика индекса структурного сходства ('Structural Similarity Index Metric') " -"- это основанная на восприятии потеря, которая учитывает изменения в " -"текстуре, яркости, контрасте и локальной пространственной статистике " -"изображения. Потенциально обеспечивает более реалистичный вид изображений. " -"Ссылка: Image Quality Assessment: From Error Visibility to Structural " -"Similarity [ТОЛЬКО на английском] http://www.cns.nyu.edu/pub/eero/wang03-" -"reprint.pdf" - -#: plugins/train/_config.py:93 -msgid "" -"Instead of minimizing the difference between the absolute value of each " -"pixel in two reference images, compute the pixel to pixel spatial difference " -"in each image and then minimize that difference between two images. Allows " -"for large color shifts, but maintains the structure of the image." -msgstr "" -"Вместо того чтобы минимизировать разницу между абсолютным значением каждого " -"пикселя в двух образцовых изображениях, вычислить пространственную разницу " -"между пикселями в каждом изображении и затем минимизировать эту разницу " -"между двумя изображениями. Это позволяет получить большие цветовые сдвиги, " -"но сохраняет структуру изображения." - -#: plugins/train/_config.py:97 -msgid "Do not use an additional loss function." -msgstr "Не использовать функцию дополнительных потерь." - -#: plugins/train/_config.py:117 +#: plugins/train/train_config.py:30 msgid "Options that apply to all models" msgstr "Настройки, применимые ко всем моделям" -#: plugins/train/_config.py:126 plugins/train/_config.py:150 +#: plugins/train/train_config.py:43 plugins/train/train_config.py:66 +#: plugins/train/train_config.py:86 msgid "face" msgstr "лицо" -#: plugins/train/_config.py:128 +#: plugins/train/train_config.py:45 msgid "" "How to center the training image. The extracted images are centered on the " "middle of the skull based on the face's estimated pose. A subsection of " @@ -279,7 +69,7 @@ msgstr "" "изображение ближе к кончику носа без правок. Может привести к тому, что края " "лица будут вне тренировочной зоны." -#: plugins/train/_config.py:152 +#: plugins/train/train_config.py:68 msgid "" "How much of the extracted image to train on. A lower coverage will limit the " "model's scope to a zoomed-in central area while higher amounts can include " @@ -305,11 +95,24 @@ msgstr "" "\t87.5% охватывает от уха до уха.\n" "\t100% - полный снимок." -#: plugins/train/_config.py:168 plugins/train/_config.py:179 +#: plugins/train/train_config.py:88 +msgid "" +"How much to adjust the vertical position of the aligned face as a percentage " +"of face image size. Negative values move the face up (expose more chin and " +"less forehead). Positive values move the face down (expose less chin and " +"more forehead)" +msgstr "" +"На сколько процентов от размера изображения лица сдвигать его по вертикали " +"после выравнивания. Отрицательные значения сдвигают лицо вверх (в кадре " +"становится больше подбородка и шеи, а лба — меньше). Положительные значения " +"сдвигают лицо вниз (в кадре становится больше лба и волос, а подбородка — " +"меньше)." + +#: plugins/train/train_config.py:99 plugins/train/train_config.py:109 msgid "initialization" msgstr "инициализация" -#: plugins/train/_config.py:170 +#: plugins/train/train_config.py:101 msgid "" "Use ICNR to tile the default initializer in a repeating pattern. This " "strategy is designed for pairing with sub-pixel / pixel shuffler to reduce " @@ -323,7 +126,7 @@ msgstr "" "\t [ТОЛЬКО на английском] https://arxiv.org/ftp/arxiv/papers/1707/1707.02937." "pdf" -#: plugins/train/_config.py:181 +#: plugins/train/train_config.py:111 msgid "" "Use Convolution Aware Initialization for convolutional layers. This can help " "eradicate the vanishing and exploding gradient problem as well as lead to " @@ -331,7 +134,7 @@ msgid "" "NB:\n" "\t This can use more VRAM when creating a new model so you may want to lower " "the batch size for the first run. The batch size can be raised again when " -"reloading the model. \n" +"reloading the model.\n" "\t Multi-GPU is not supported for this option, so you should start the model " "on a single GPU. Once training has started, you can stop training, enable " "multi-GPU and resume.\n" @@ -339,143 +142,26 @@ msgid "" "for this initialization technique are expensive. This will only impact " "starting a new model." msgstr "" -"Использовать инициализацию с учетом свертки для сверточных слоев. Это " -"поможет устранить проблему исчезающего и взрывающегося градиента, а также " -"повысить точность, снизить потери и ускорить сходимость.\n" +"Использовать свёрточно-осведомлённую инициализацию для сверточных слоев. " +"Может помочь устранить проблему исчезающего и взрывающегося градиента, а " +"также повысить точность, снизить потери и ускорить сходимость.\n" "Примечание:\n" -"\tПри создании новой модели может потребоваться больше видеопамяти, поэтому " +"\t При создании новой модели может потребоваться больше видеопамяти, поэтому " "для первого запуска лучше уменьшить размер пачки. Размер пачки может быть " "увеличен при перезагрузке модели. \n" -"\tИспользование нескольких видеокарт не поддерживается, поэтому модель " +"\t Использование нескольких видеокарт не поддерживается, поэтому модель " "следует запускать на одной видеокарте. После начала обучения вы можете " "остановить обучение, включить несколько видеокарт и возобновить его.\n" "\t Построение модели, скорее всего, займет несколько минут, поскольку " "вычисления для этой техники инициализации являются дорогостоящими. Это " "повлияет только на запуск новой модели." -#: plugins/train/_config.py:198 plugins/train/_config.py:223 -#: plugins/train/_config.py:238 plugins/train/_config.py:256 -#: plugins/train/_config.py:337 -msgid "optimizer" -msgstr "оптимизатор" - -#: plugins/train/_config.py:202 -msgid "" -"The optimizer to use.\n" -"\t adabelief - Adapting Stepsizes by the Belief in Observed Gradients. An " -"optimizer with the aim to converge faster, generalize better and remain more " -"stable. (https://arxiv.org/abs/2010.07468). NB: Epsilon for AdaBelief needs " -"to be set to a smaller value than other Optimizers. Generally setting the " -"'Epsilon Exponent' to around '-16' should work.\n" -"\t adam - Adaptive Moment Optimization. A stochastic gradient descent method " -"that is based on adaptive estimation of first-order and second-order " -"moments.\n" -"\t nadam - Adaptive Moment Optimization with Nesterov Momentum. Much like " -"Adam but uses a different formula for calculating momentum.\n" -"\t rms-prop - Root Mean Square Propagation. Maintains a moving (discounted) " -"average of the square of the gradients. Divides the gradient by the root of " -"this average." -msgstr "" -"Используемый оптимизатор.\n" -"\t adabelief - Адаптация размеров шагов по убеждению в наблюдаемых " -"градиентах('Adapting Stepsizes by the Belief in Observed Gradients'). " -"Оптимизатор, цель которого - быстрее сходиться, лучше обобщаться и " -"оставаться более стабильным. ([ТОЛЬКО на английском] https://arxiv.org/" -"abs/2010.07468). Примечание: значение Epsilon для AdaBelief должно быть " -"меньше, чем для других оптимизаторов. Как правило, значение 'Epsilon " -"Exponent' должно быть около '-16'.\n" -"\t adam - Адаптивная оптимизация моментов('Adaptive Moment Optimization'). " -"Стохастический метод градиентного спуска, основанный на адаптивной оценке " -"моментов первого и второго порядка.\n" -"\t nadam - Адаптивная оптимизация моментов с моментумом Нестерова ('Adaptive " -"Moment Optimization with Nesterov Momentum'). Похож на Adam, но использует " -"другую формулу для вычисления момента.\n" -"rms-prop - Распространение корневого среднего квадрата ('Root Mean Square " -"Propagation'). Поддерживает скользящее (дисконтированное) среднее квадрата " -"градиентов. Делит градиент на корень из этого среднего." - -#: plugins/train/_config.py:225 -msgid "" -"Learning rate - how fast your network will learn (how large are the " -"modifications to the model weights after one batch of training). Values that " -"are too large might result in model crashes and the inability of the model " -"to find the best solution. Values that are too small might be unable to " -"escape from dead-ends and find the best global minimum." -msgstr "" -"Скорость обучения - насколько быстро ваша модель будет обучаться (насколько " -"огромны изменения весов модели после одной пачки тренировки). Слишком " -"большие значения могут привести к крахам модели и невозможности модели найти " -"лучшее решение. Слишком маленькие значения могут привести к невозможности " -"выбраться из тупиков и найти лучший глобальный минимум." - -#: plugins/train/_config.py:240 -msgid "" -"The epsilon adds a small constant to weight updates to attempt to avoid " -"'divide by zero' errors. Unless you are using the AdaBelief Optimizer, then " -"Generally this option should be left at default value, For AdaBelief, " -"setting this to around '-16' should work.\n" -"In all instances if you are getting 'NaN' loss values, and have been unable " -"to resolve the issue any other way (for example, increasing batch size, or " -"lowering learning rate), then raising the epsilon can lead to a more stable " -"model. It may, however, come at the cost of slower training and a less " -"accurate final result.\n" -"NB: The value given here is the 'exponent' to the epsilon. For example, " -"choosing '-7' will set the epsilon to 1e-7. Choosing '-3' will set the " -"epsilon to 0.001 (1e-3)." -msgstr "" -"Эпсилон добавляет небольшую константу к обновлениям веса, чтобы попытаться " -"избежать ошибок \"деления на ноль\". Если вы не используете оптимизатор " -"AdaBelief, то, как правило, этот параметр следует оставить по умолчанию. Для " -"AdaBelief подойдет значение около '-16'.\n" -"Во всех случаях, если вы получаете значения потерь 'NaN' и не смогли решить " -"проблему другим способом (например, увеличив размер пачки или уменьшив " -"скорость обучения), то увеличение эпсилона может привести к более стабильной " -"модели. Однако это может стоить более медленного обучения и менее точного " -"конечного результата.\n" -"Примечание: Значение, указанное здесь, является \"экспонентой\" к эпсилону. " -"Например, при выборе значения '-7' эпсилон будет равен 1e-7. При выборе " -"значения \"-3\" эпсилон будет равен 0,001 (1e-3)." - -#: plugins/train/_config.py:262 -msgid "" -"When to save the Optimizer Weights. Saving the optimizer weights is not " -"necessary and will increase the model file size 3x (and by extension the " -"amount of time it takes to save the model). However, it can be useful to " -"save these weights if you want to guarantee that a resumed model carries off " -"exactly from where it left off, rather than spending a few hundred " -"iterations catching up.\n" -"\t never - Don't save optimizer weights.\n" -"\t always - Save the optimizer weights at every save iteration. Model saving " -"will take longer, due to the increased file size, but you will always have " -"the last saved optimizer state in your model file.\n" -"\t exit - Only save the optimizer weights when explicitly terminating a " -"model. This can be when the model is actively stopped or when the target " -"iterations are met. Note: If the training session ends because of another " -"reason (e.g. power outage, Out of Memory Error, NaN detected) then the " -"optimizer weights will NOT be saved." -msgstr "" -"Когда сохранять веса оптимизатора. Сохранение весов оптимизатора не является " -"необходимым и увеличит размер файла модели в 3 раза (и соответственно время, " -"необходимое для сохранения модели). Однако может быть полезно сохранить эти " -"веса, если вы хотите гарантировать, что возобновленная модель продолжит " -"работу именно с того места, где она остановилась, а не тратит несколько " -"сотен итераций на догонялки.\n" -"\t never - не сохранять веса оптимизатора.\n" -"\t always - сохранять веса оптимизатора при каждой итерации сохранения. " -"Сохранение модели займет больше времени из-за увеличенного размера файла, но " -"в файле модели всегда будет последнее сохраненное состояние оптимизатора.\n" -"\t exit - сохранять веса оптимизатора только при явном завершении модели. " -"Это может быть, когда модель активно останавливается или когда выполняются " -"целевые итерации. Примечание. Если сеанс обучения завершается по другой " -"причине (например, отключение питания, ошибка нехватки памяти, обнаружение " -"NaN), веса оптимизатора НЕ будут сохранены." - -#: plugins/train/_config.py:285 plugins/train/_config.py:297 -#: plugins/train/_config.py:314 +#: plugins/train/train_config.py:126 plugins/train/train_config.py:138 +#: plugins/train/train_config.py:155 msgid "Learning Rate Finder" msgstr "Инструмент поиска оптимального коэффициента обучения" -#: plugins/train/_config.py:287 +#: plugins/train/train_config.py:128 msgid "" "The number of iterations to process to find the optimal learning rate. " "Higher values will take longer, but will be more accurate." @@ -483,7 +169,7 @@ msgstr "" "Количество итераций для поиска оптимального коэффициента обучения. Большие " "значения займут больше времени, но будут более точными." -#: plugins/train/_config.py:299 +#: plugins/train/train_config.py:140 msgid "" "The operation mode for the learning rate finder. Only applicable to new " "models. For existing models this will always default to 'set'.\n" @@ -502,7 +188,7 @@ msgstr "" "\tgraph_and_exit - Вывод графика в папку обучения с найденными " "коэффициентами обучения с последующим выходом из программы." -#: plugins/train/_config.py:316 +#: plugins/train/train_config.py:157 msgid "" "How aggressively to set the Learning Rate. More aggressive can learn faster, " "but is more likely to lead to exploding gradients.\n" @@ -523,26 +209,12 @@ msgstr "" "\textreme - Наивысший оптимальный коэффициент обучения. Гораздо выше риск " "взрыва градиента." -#: plugins/train/_config.py:330 -msgid "" -"Apply AutoClipping to the gradients. AutoClip analyzes the gradient weights " -"and adjusts the normalization value dynamically to fit the data. Can help " -"prevent NaNs and improve model optimization at the expense of VRAM. Ref: " -"AutoClip: Adaptive Gradient Clipping for Source Separation Networks https://" -"arxiv.org/abs/2007.14469" -msgstr "" -"Применить AutoClipping к градиентам. AutoClip анализирует веса градиентов и " -"динамически корректирует значение нормализации, чтобы оно подходило к " -"данным. Может помочь избежать NaN('не число') и улучшить оптимизацию модели " -"ценой видеопамяти. Ссылка: AutoClip: Adaptive Gradient Clipping for Source " -"Separation Networks [ТОЛЬКО на английском] https://arxiv.org/abs/2007.14469" - -#: plugins/train/_config.py:343 plugins/train/_config.py:355 -#: plugins/train/_config.py:369 plugins/train/_config.py:386 +#: plugins/train/train_config.py:172 plugins/train/train_config.py:183 +#: plugins/train/train_config.py:199 msgid "network" msgstr "сеть" -#: plugins/train/_config.py:345 +#: plugins/train/train_config.py:174 msgid "" "Use reflection padding rather than zero padding with convolutions. Each " "convolution must pad the image boundaries to maintain the proper sizing. " @@ -556,85 +228,281 @@ msgstr "" "изображения.\n" "\t http://www-cs.engr.ccny.cuny.edu/~wolberg/cs470/hw/hw2_pad.txt" -#: plugins/train/_config.py:358 +#: plugins/train/train_config.py:185 +msgid "" +"NVIDIA GPUs can run operations in float16 faster than in float32. Mixed " +"precision allows you to use a mix of float16 with float32, to get the " +"performance benefits from float16 and the numeric stability benefits from " +"float32.\n" +"\n" +"This is untested on non-Nvidia cards, but will run on most Nvidia models. it " +"will only speed up training on more recent GPUs. Those with compute " +"capability 7.0 or higher will see the greatest performance benefit from " +"mixed precision because they have Tensor Cores. Older GPUs offer no math " +"performance benefit for using mixed precision, however memory and bandwidth " +"savings can enable some speedups. Generally RTX GPUs and later will offer " +"the most benefit." +msgstr "" +"Видеокарты от NVIDIA могут оперировать в 'float16' быстрее, чем в 'float32'. " +"Смешанная точность позволяет вам использовать микс float16 с float32, чтобы " +"получить улучшение производительности от float16 и числовую стабильность от " +"float32.\n" +"\n" +"Данная функция не проверенна на DirectML, но будет работать на большенстве " +"моделей Nvidia. Оно только ускорит тренировку на более недавних видеокартах. " +"Те, что имеют возможность вычислений('Compute Capability') 7.0 и выше, " +"получат самое большое ускорение от смешанной точности, потому что у них " +"имеются тензор ядра. Старые видеокарты предлагают никакого ускорения от " +"смешанной точности, однако экономия памяти и бóльшая пропускная способность " +"могут дать небольшое ускорение. В основном RTX видеокарты и позже предлагают " +"самое большое ускорение." + +#: plugins/train/train_config.py:201 +msgid "" +"If a 'NaN' is generated in the model, this means that the model has " +"corrupted and the model is likely to start deteriorating from this point on. " +"Enabling NaN protection will stop training immediately in the event of a " +"NaN. The last save will not contain the NaN, so you may still be able to " +"rescue your model." +msgstr "" +"Если 'Не число'(далее, NaN) сгенерировано в модели - это значит, что модель " +"повреждена и с этого момента, скорее всего, начнет деградировать. Включение " +"защиты от NaN немедленно остановит тренировку, в случае, если был обнаружен " +"NaN. Последнее сохранение не будет содержать в себе NaN, так что у вас будет " +"возможность спасти вашу модель." + +#: plugins/train/train_config.py:211 +msgid "convert" +msgstr "конвертирование" + +#: plugins/train/train_config.py:213 +msgid "" +"[GPU Only]. The number of faces to feed through the model at once when " +"running the Convert process.\n" +"\n" +"NB: Increasing this figure is unlikely to improve convert speed, however, if " +"you are getting Out of Memory errors, then you may want to reduce the batch " +"size." +msgstr "" +"[Только для видеокарт] Количество лиц, проходящих через модель в одно время " +"во время конвертирования\n" +"\n" +"Примечание: Увеличение этого значения вряд ли повлечет за собой ускорение " +"конвертирования, однако, если у вас появляются ошибки 'Out of Memory', тогда " +"стоит снизить размер пачки." + +#: plugins/train/train_config.py:224 +msgid "" +"Focal Frequency Loss. Analyzes the frequency spectrum of the images rather " +"than the images themselves. This loss function can be used on its own, but " +"the original paper found increased benefits when using it as a complementary " +"loss to another spacial loss function (e.g. MSE). Ref: Focal Frequency Loss " +"for Image Reconstruction and Synthesis https://arxiv.org/pdf/2012.12821.pdf " +"NB: This loss does not currently work on AMD cards." +msgstr "" +"Потеря фокальной частоты. Анализирует частотный спектр изображений, а не " +"сами изображения. Эта функция потерь может использоваться сама по себе, но в " +"оригинальной статье было обнаружено, что она дает больше преимуществ при " +"использовании в качестве дополнительной потери к другой пространственной " +"функции потерь (например, MSE). Ссылка: Focal Frequency Loss for Image " +"Reconstruction and Synthesis [ТОЛЬКО на английском] https://arxiv.org/" +"pdf/2012.12821.pdf NB: Эта потеря в настоящее время не работает на картах " +"AMD." + +#: plugins/train/train_config.py:231 +msgid "" +"Nvidia FLIP. A perceptual loss measure that approximates the difference " +"perceived by humans as they alternate quickly (or flip) between two images. " +"Used on its own and this loss function creates a distinct grid on the " +"output. However it can be helpful when used as a complimentary loss " +"function. Ref: FLIP: A Difference Evaluator for Alternating Images: https://" +"research.nvidia.com/sites/default/files/node/3260/FLIP_Paper.pdf" +msgstr "" +"Nvidia FLIP. Мера потерь восприятия, которая приближает разницу, " +"воспринимаемую человеком при быстром чередовании (или перелистывании) двух " +"изображений. Используемая сама по себе, эта функция потерь создает на выходе " +"отчетливую сетку. Однако она может быть полезна при использовании в качестве " +"дополнительной функции потерь. Ссылка: FLIP: A Difference Evaluator for " +"Alternating Images [ТОЛЬКО на английском]: https://research.nvidia.com/sites/" +"default/files/node/3260/FLIP_Paper.pdf" + +#: plugins/train/train_config.py:238 +msgid "" +"Gradient Magnitude Similarity Deviation seeks to match the global standard " +"deviation of the pixel to pixel differences between two images. Similar in " +"approach to SSIM. Ref: Gradient Magnitude Similarity Deviation: An Highly " +"Efficient Perceptual Image Quality Index https://arxiv.org/ftp/arxiv/" +"papers/1308/1308.3052.pdf" +msgstr "" +"Отклонение Схожести Магнитуды Градиентов(Gradient Magnitude Similarity " +"Deviation) пытается совместить глобальную стандартную девиацию различий " +"пикселя к пикселю между двумя изображениями. Подход похож на SSIM. Ссылка: " +"Gradient Magnitude Similarity Deviation: An Highly Efficient Perceptual " +"Image Quality Index [ТОЛЬКО на английском] https://arxiv.org/ftp/arxiv/" +"papers/1308/1308.3052.pdf" + +#: plugins/train/train_config.py:243 +msgid "" +"The L_inf norm will reduce the largest individual pixel error in an image. " +"As each largest error is minimized sequentially, the overall error is " +"improved. This loss will be extremely focused on outliers." +msgstr "" +"Норма L_inf уменьшает наибольшую ошибку отдельного пикселя в изображении. По " +"мере последовательной минимизации каждой наибольшей ошибки улучшается общая " +"ошибка. Эта потеря будет чрезвычайно сосредоточена на выбросах." + +#: plugins/train/train_config.py:247 +msgid "" +"Laplacian Pyramid Loss. Attempts to improve results by focussing on edges " +"using Laplacian Pyramids. As this loss function gives priority to edges over " +"other low-frequency information, like color, it should not be used on its " +"own. The original implementation uses this loss as a complimentary function " +"to MSE. Ref: Optimizing the Latent Space of Generative Networks https://" +"arxiv.org/abs/1707.05776" +msgstr "" +"Потеря пирамиды Лапласиана. Пытается улучшить результаты, концентрируясь на " +"краях с помощью пирамид Лапласиана. Поскольку эта функция потерь отдает " +"приоритет краям, а не другой низкочастотной информации, например, цвету, ее " +"не следует использовать самостоятельно. В оригинальной реализации эта потеря " +"используется как дополнительная функция к MSE. Ссылка: Optimizing the Latent " +"Space of Generative Networks [ТОЛЬКО на английском] https://arxiv.org/" +"abs/1707.05776" + +#: plugins/train/train_config.py:254 +msgid "" +"LPIPS is a perceptual loss that uses the feature outputs of other pretrained " +"models as a loss metric. Be aware that this loss function will use more " +"VRAM. Used on its own and this loss will create a distinct moire pattern on " +"the output, however it can be helpful as a complimentary loss function. The " +"output of this function is strong, so depending on your chosen primary loss " +"function, you are unlikely going to want to set the weight above about 25%. " +"Ref: The Unreasonable Effectiveness of Deep Features as a Perceptual Metric " +"http://arxiv.org/abs/1801.03924\n" +"This variant uses the AlexNet backbone. A fairly light and old model which " +"performed best in the paper's original implementation.\n" +"NB: For AMD Users the final linear layer is not implemented." +msgstr "" +"LPIPS - это перцептивная потеря, которая использует в качестве метрики " +"потерь выходные характеристики других предварительно обученных моделей. " +"Имейте в виду, что эта функция потерь использует больше VRAM. При " +"самостоятельном использовании эта потеря создает на выходе отчетливый " +"муаровый рисунок, однако она может быть полезна как дополнительная функция " +"потерь. Вывод этой функции является сильным, поэтому, в зависимости от " +"выбранной вами основной функции потерь, вы вряд ли захотите устанавливать " +"вес выше 25%. Ссылка: The Unreasonable Effectiveness of Deep Features as a " +"Perceptual Metric [ТОЛЬКО на английском] http://arxiv.org/abs/1801.03924.\n" +"Этот вариант использует основу AlexNet. Это довольно легкая и старая модель, " +"которая лучше всего показала себя в оригинальной реализации.\n" +"NB: Для пользователей AMD последний линейный слой не реализован." + +#: plugins/train/train_config.py:264 +msgid "" +"Same as lpips_alex, but using the SqueezeNet backbone. A more lightweight " +"version of AlexNet.\n" +"NB: For AMD Users the final linear layer is not implemented." +msgstr "" +"То же, что и lpips_alex, но использует основу SqueezeNet. Более облегченная " +"версия AlexNet.\n" +"NB: Для пользователей AMD последний линейный слой не реализован." + +#: plugins/train/train_config.py:267 +msgid "" +"Same as lpips_alex, but using the VGG16 backbone. A more heavyweight model.\n" +"NB: For AMD Users the final linear layer is not implemented." +msgstr "" +"То же, что и lpips_alex, но использует основу VGG16. Более тяжелая модель.\n" +"NB: Для пользователей AMD последний линейный слой не реализован." + +#: plugins/train/train_config.py:270 +msgid "" +"log(cosh(x)) acts similar to MSE for small errors and to MAE for large " +"errors. Like MSE, it is very stable and prevents overshoots when errors are " +"near zero. Like MAE, it is robust to outliers." +msgstr "" +"log(cosh(x)) действует аналогично MSE для малых ошибок и MAE для больших " +"ошибок. Как и MSE, он очень стабилен и предотвращает переборы, когда ошибки " +"близки к нулю. Как и MAE, он устойчив к выбросам." + +#: plugins/train/train_config.py:274 +msgid "" +"Mean absolute error will guide reconstructions of each pixel towards its " +"median value in the training dataset. Robust to outliers but as a median, it " +"can potentially ignore some infrequent image types in the dataset." +msgstr "" +"Средняя абсолютная погрешность направляет реконструкцию каждого пикселя к " +"его медианному значению в обучающем наборе данных. Устойчив к выбросам, но в " +"качестве медианы может игнорировать некоторые редкие типы изображений в " +"наборе данных." + +#: plugins/train/train_config.py:278 +msgid "" +"Mean squared error will guide reconstructions of each pixel towards its " +"average value in the training dataset. As an avg, it will be susceptible to " +"outliers and typically produces slightly blurrier results. Ref: Multi-Scale " +"Structural Similarity for Image Quality Assessment https://www.cns.nyu.edu/" +"pub/eero/wang03b.pdf" +msgstr "" +"Средняя квадратичная погрешность направляет реконструкцию каждого пикселя к " +"его среднему значению в наборе данных для обучения. Как среднее значение, " +"оно будет чувствительно к выбросам и обычно дает немного более размытые " +"результаты. Ссылка: Multi-Scale Structural Similarity for Image Quality " +"Assessment [ТОЛЬКО на английском]https://www.cns.nyu.edu/pub/eero/wang03b.pdf" + +#: plugins/train/train_config.py:283 msgid "" -"Enable the Tensorflow GPU 'allow_growth' configuration option. This option " -"prevents Tensorflow from allocating all of the GPU VRAM at launch but can " -"lead to higher VRAM fragmentation and slower performance. Should only be " -"enabled if you are receiving errors regarding 'cuDNN fails to initialize' " -"when commencing training." +"Multiscale Structural Similarity Index Metric is similar to SSIM except that " +"it performs the calculations along multiple scales of the input image." msgstr "" -"[Только для Nvidia]. Включите опцию конфигурации Tensorflow GPU " -"`allow_growth`. Эта опция не позволяет Tensorflow выделять всю видеопамять " -"видеокарты при запуске, но может привести к повышенной фрагментации " -"видеопамяти и снижению производительности. Следует включать только в том " -"случае, если у вас появляются ошибки, рода 'cuDNN fails to initialize'(cuDNN " -"не может инициализироваться) при начале тренировки." +"Метрика Индекса Многомасштабного Структурного Сходства (Multiscale " +"Structural Similarity Index Metric) похожа на SSIM, за исключением того, что " +"она выполняет вычисления по нескольким масштабам входного изображения." -#: plugins/train/_config.py:371 +#: plugins/train/train_config.py:286 msgid "" -"NVIDIA GPUs can run operations in float16 faster than in float32. Mixed " -"precision allows you to use a mix of float16 with float32, to get the " -"performance benefits from float16 and the numeric stability benefits from " -"float32.\n" -"\n" -"This is untested on DirectML backend, but will run on most Nvidia models. it " -"will only speed up training on more recent GPUs. Those with compute " -"capability 7.0 or higher will see the greatest performance benefit from " -"mixed precision because they have Tensor Cores. Older GPUs offer no math " -"performance benefit for using mixed precision, however memory and bandwidth " -"savings can enable some speedups. Generally RTX GPUs and later will offer " -"the most benefit." +"Smooth_L1 is a modification of the MAE loss to correct two of its " +"disadvantages. This loss has improved stability and guidance for small " +"errors. Ref: A General and Adaptive Robust Loss Function https://arxiv.org/" +"pdf/1701.03077.pdf" msgstr "" -"Видеокарты от NVIDIA могут оперировать в 'float16' быстрее, чем в 'float32'. " -"Смешанная точность позволяет вам использовать микс float16 с float32, чтобы " -"получить улучшение производительности от float16 и числовую стабильность от " -"float32.\n" -"\n" -"Это не было проверено на DirectML, но будет работать на большенстве моделей " -"Nvidia. Оно только ускорит тренировку на более недавних видеокартах. Те, что " -"имеют возможность вычислений('Compute Capability') 7.0 и выше, получат самое " -"большое ускорение от смешанной точности, потому что у них имеются тензор " -"ядра. Старые видеокарты предлагают никакого ускорения от смешанной точности, " -"однако экономия памяти и (хз, честно, словаря нет) могут дать небольшое " -"ускорение. В основном RTX видеокарты и позже предлагают самое большое " -"ускорение." +"Smooth_L1 - это модификация потери MAE для исправления двух ее недостатков. " +"Эта потеря улучшает стабильность и ориентирование при небольших " +"погрешностях. Ссылка: A General and Adaptive Robust Loss Function [ТОЛЬКО на " +"английском] https://arxiv.org/pdf/1701.03077.pdf" -#: plugins/train/_config.py:388 +#: plugins/train/train_config.py:290 msgid "" -"If a 'NaN' is generated in the model, this means that the model has " -"corrupted and the model is likely to start deteriorating from this point on. " -"Enabling NaN protection will stop training immediately in the event of a " -"NaN. The last save will not contain the NaN, so you may still be able to " -"rescue your model." +"Structural Similarity Index Metric is a perception-based loss that considers " +"changes in texture, luminance, contrast, and local spatial statistics of an " +"image. Potentially delivers more realistic looking images. Ref: Image " +"Quality Assessment: From Error Visibility to Structural Similarity http://" +"www.cns.nyu.edu/pub/eero/wang03-reprint.pdf" msgstr "" -"Если 'Не число'(далее, NaN) сгенерировано в модели - это значит, что модель " -"повреждена и с этого момента, скорее всего, начнет деградировать. Включение " -"защиты от NaN немедленно остановит тренировку, в случае, если был обнаружен " -"NaN. Последнее сохранение не будет содержать в себе NaN, так что у вас будет " -"возможность спасти вашу модель." - -#: plugins/train/_config.py:401 -msgid "convert" -msgstr "конвертирование" +"Метрика индекса структурного сходства ('Structural Similarity Index Metric') " +"- это основанная на восприятии потеря, которая учитывает изменения в " +"текстуре, яркости, контрасте и локальной пространственной статистике " +"изображения. Потенциально обеспечивает более реалистичный вид изображений. " +"Ссылка: Image Quality Assessment: From Error Visibility to Structural " +"Similarity [ТОЛЬКО на английском] http://www.cns.nyu.edu/pub/eero/wang03-" +"reprint.pdf" -#: plugins/train/_config.py:403 +#: plugins/train/train_config.py:295 msgid "" -"[GPU Only]. The number of faces to feed through the model at once when " -"running the Convert process.\n" -"\n" -"NB: Increasing this figure is unlikely to improve convert speed, however, if " -"you are getting Out of Memory errors, then you may want to reduce the batch " -"size." +"Instead of minimizing the difference between the absolute value of each " +"pixel in two reference images, compute the pixel to pixel spatial difference " +"in each image and then minimize that difference between two images. Allows " +"for large color shifts, but maintains the structure of the image." msgstr "" -"[Только для видеокарт] Количество лиц, проходящих через модель в одно время " -"во время конвертирования\n" -"\n" -"Примечание: Увеличение этого значения вряд ли повлечет за собой ускорение " -"конвертирования, однако, если у вас появляются ошибки 'Out of Memory', тогда " -"стоит снизить размер пачки." +"Вместо того чтобы минимизировать разницу между абсолютным значением каждого " +"пикселя в двух образцовых изображениях, вычислить пространственную разницу " +"между пикселями в каждом изображении и затем минимизировать эту разницу " +"между двумя изображениями. Это позволяет получить большие цветовые сдвиги, " +"но сохраняет структуру изображения." -#: plugins/train/_config.py:422 +#: plugins/train/train_config.py:299 +msgid "Do not use an additional loss function." +msgstr "Не использовать функцию дополнительных потерь." + +#: plugins/train/train_config.py:315 msgid "" "Loss configuration options\n" "Loss is the mechanism by which a Neural Network judges how well it thinks " @@ -644,32 +512,40 @@ msgstr "" "Потеря - механизм, по которому Нейронная Сеть судит, насколько хорошо она " "воспроизводит лицо." -#: plugins/train/_config.py:429 plugins/train/_config.py:441 -#: plugins/train/_config.py:454 plugins/train/_config.py:474 -#: plugins/train/_config.py:486 plugins/train/_config.py:506 -#: plugins/train/_config.py:518 plugins/train/_config.py:538 -#: plugins/train/_config.py:554 plugins/train/_config.py:570 -#: plugins/train/_config.py:587 +#: plugins/train/train_config.py:321 plugins/train/train_config.py:331 +#: plugins/train/train_config.py:343 plugins/train/train_config.py:362 +#: plugins/train/train_config.py:372 plugins/train/train_config.py:391 +#: plugins/train/train_config.py:402 plugins/train/train_config.py:421 +#: plugins/train/train_config.py:436 plugins/train/train_config.py:450 +#: plugins/train/train_config.py:464 msgid "loss" msgstr "потери" -#: plugins/train/_config.py:433 +#: plugins/train/train_config.py:322 msgid "The loss function to use." msgstr "Какую функцию потерь стоит использовать." -#: plugins/train/_config.py:445 +#: plugins/train/train_config.py:333 msgid "" "The second loss function to use. If using a structural based loss (such as " "SSIM, MS-SSIM or GMSD) it is common to add an L1 regularization(MAE) or L2 " "regularization (MSE) function. You can adjust the weighting of this loss " -"function with the loss_weight_2 option." +"function with the loss_weight_2 option.\n" +"\n" +"\t\n" +"\n" +"\t" msgstr "" "Вторая используемая функция потерь. При использовании потерь, основанных на " "структуре (таких как SSIM, MS-SSIM или GMSD), обычно добавляется функция " "регуляризации L1 (MAE) или регуляризации L2 (MSE). Вы можете настроить вес " -"этой функции потерь с помощью параметра loss_weight_2." +"этой функции потерь с помощью параметра loss_weight_2. \n" +"\n" +"\t\n" +"\n" +"\t" -#: plugins/train/_config.py:460 +#: plugins/train/train_config.py:345 msgid "" "The amount of weight to apply to the second loss function.\n" "\n" @@ -692,23 +568,31 @@ msgstr "" "\n" "Значение задается в процентах и показывает, какой вклад выбранная функция " "должна внести в общую стоимость потерь модели. Например:\n" -"\t 100 - Потери, рассчитанные для четвертой функции потерь, будут применены " -"в полном объеме к общей стоимости потерь. \n" -"\t25 - Потери, рассчитанные для четвертой функции потерь, будут уменьшены на " +"\t 100 - Потери, рассчитанные для второй функции потерь, будут применены в " +"полном объеме к общей стоимости потерь. \n" +"\t25 - Потери, рассчитанные для второй функции потерь, будут уменьшены на " "четверть перед добавлением к общей стоимости потерь. \n" -"\t400 - Потери, рассчитанные для четвертой функции потерь, будут умножены в " -"4 раза перед добавлением к общей оценке потерь. \n" -"\t 0 - Полностью отключает четвертую функцию потерь." +"\t400 - Потери, рассчитанные для второй функции потерь, будут умножены в 4 " +"раза перед добавлением к общей оценке потерь. \n" +"\t 0 - Полностью отключает вторую функцию потерь." -#: plugins/train/_config.py:478 +#: plugins/train/train_config.py:363 msgid "" "The third loss function to use. You can adjust the weighting of this loss " -"function with the loss_weight_3 option." +"function with the loss_weight_3 option.\n" +"\n" +"\t\n" +"\n" +"\t" msgstr "" "Третья используемая функция потерь. Вы можете настроить вес этой функции " -"потерь с помощью параметра loss_weight_3." +"потерь с помощью параметра loss_weight_3.\n" +"\n" +"\t\n" +"\n" +"\t" -#: plugins/train/_config.py:492 +#: plugins/train/train_config.py:374 msgid "" "The amount of weight to apply to the third loss function.\n" "\n" @@ -739,15 +623,23 @@ msgstr "" "4 раза перед добавлением к общей оценке потерь. \n" "\t 0 - Полностью отключает четвертую функцию потерь." -#: plugins/train/_config.py:510 +#: plugins/train/train_config.py:393 msgid "" "The fourth loss function to use. You can adjust the weighting of this loss " -"function with the loss_weight_3 option." +"function with the loss_weight_3 option.\n" +"\n" +"\t\n" +"\n" +"\t" msgstr "" "Четвертая используемая функция потерь. Вы можете настроить вес этой функции " -"потерь с помощью параметра 'loss_weight_4'." +"потерь с помощью параметра 'loss_weight_4'.\n" +"\n" +"\t\n" +"\n" +"\t" -#: plugins/train/_config.py:524 +#: plugins/train/train_config.py:404 msgid "" "The amount of weight to apply to the fourth loss function.\n" "\n" @@ -778,7 +670,7 @@ msgstr "" "4 раза перед добавлением к общей оценке потерь. \n" "\t 0 - Полностью отключает четвертую функцию потерь." -#: plugins/train/_config.py:543 +#: plugins/train/train_config.py:423 msgid "" "The loss function to use when learning a mask.\n" "\t MAE - Mean absolute error will guide reconstructions of each pixel " @@ -799,7 +691,7 @@ msgstr "" "данных. Как среднее значение, оно чувствительно к выбросам и обычно дает " "немного более размытые результаты." -#: plugins/train/_config.py:560 +#: plugins/train/train_config.py:438 msgid "" "The amount of priority to give to the eyes.\n" "\n" @@ -819,7 +711,7 @@ msgstr "" "\n" "NB: Penalized Mask Loss должен быть включен, чтобы использовать эту опцию." -#: plugins/train/_config.py:576 +#: plugins/train/train_config.py:452 msgid "" "The amount of priority to give to the mouth.\n" "\n" @@ -839,7 +731,7 @@ msgstr "" "\n" "NB: Penalized Mask Loss должен быть включен, чтобы использовать эту опцию." -#: plugins/train/_config.py:589 +#: plugins/train/train_config.py:466 msgid "" "Image loss function is weighted by mask presence. For areas of the image " "without the facial mask, reconstruction errors will be ignored while the " @@ -851,13 +743,13 @@ msgstr "" "время как область лица с маской является приоритетной. Может повысить общее " "качество за счет концентрации внимания на основной области лица." -#: plugins/train/_config.py:600 plugins/train/_config.py:643 -#: plugins/train/_config.py:656 plugins/train/_config.py:671 -#: plugins/train/_config.py:680 +#: plugins/train/train_config.py:473 plugins/train/train_config.py:514 +#: plugins/train/train_config.py:525 plugins/train/train_config.py:539 +#: plugins/train/train_config.py:549 msgid "mask" msgstr "маска" -#: plugins/train/_config.py:603 +#: plugins/train/train_config.py:475 msgid "" "The mask to be used for training. If you have selected 'Learn Mask' or " "'Penalized Mask Loss' you must select a value other than 'none'. The " @@ -929,16 +821,16 @@ msgstr "" "сообщества и для дальнейшего описания нуждается в тестировании. Профильные " "лица могут иметь низкую производительность." -#: plugins/train/_config.py:645 +#: plugins/train/train_config.py:516 msgid "" "Dilate or erode the mask. Negative values erode the mask (make it smaller). " "Positive values dilate the mask (make it larger). The value given is a " "percentage of the total mask size." msgstr "" "Расширяет или сужает маску. Отрицательные значения сужают маску (делают её " -"меньше). Положительные значения расширяют маску (делают её больше). " +"меньше). Положительные значения расширяют маску (делают её больше)." -#: plugins/train/_config.py:658 +#: plugins/train/train_config.py:527 msgid "" "Apply gaussian blur to the mask input. This has the effect of smoothing the " "edges of the mask, which can help with poorly calculated masks and give less " @@ -954,7 +846,7 @@ msgstr "" "должно быть нечетным, если передано четное число, то оно будет округлено до " "следующего нечетного числа." -#: plugins/train/_config.py:673 +#: plugins/train/train_config.py:541 msgid "" "Sets pixels that are near white to white and near black to black. Set to 0 " "for off." @@ -962,7 +854,7 @@ msgstr "" "Устанавливает пиксели, которые почти белые - в белые и которые почти черные " "- в черные. Установите 0, чтобы выключить." -#: plugins/train/_config.py:682 +#: plugins/train/train_config.py:551 msgid "" "Dedicate a portion of the model to learning how to duplicate the input mask. " "Increases VRAM usage in exchange for learning a quick ability to try to " @@ -971,3 +863,428 @@ msgstr "" "Выделить частичку модели обучению тому, как дублировать входную маску. " "Увеличивает использование видеопамяти в обмен на обучение быстрой " "способности попытки переделывать более сложные маски." + +#: plugins/train/train_config.py:559 +msgid "" +"Optimizer configuration options\n" +"The optimizer applies the output of the loss function to the model.\n" +msgstr "" +"Настройки оптимизатора\n" +"Оптимизатор использует значения функции потерь для обновления параметров " +"модели.\n" + +#: plugins/train/train_config.py:565 plugins/train/train_config.py:600 +#: plugins/train/train_config.py:613 plugins/train/train_config.py:634 +msgid "optimizer" +msgstr "оптимизатор" + +#: plugins/train/train_config.py:567 +msgid "" +"The optimizer to use.\n" +"\t adabelief - Adapting Stepsizes by the Belief in Observed Gradients. An " +"optimizer with the aim to converge faster, generalize better and remain more " +"stable. (https://arxiv.org/abs/2010.07468). NB: Epsilon for AdaBelief needs " +"to be set to a smaller value than other Optimizers. Generally setting the " +"'Epsilon Exponent' to around '-16' should work.\n" +"\t adam - Adaptive Moment Optimization. A stochastic gradient descent method " +"that is based on adaptive estimation of first-order and second-order " +"moments.\n" +"\t adamax - a variant of Adam based on the infinity norm. Due to its " +"capability of adjusting the learning rate based on data characteristics, it " +"is suited to learn time-variant process, parameters follow those provided in " +"the paper\n" +"\t adamw - Like 'adam' but with an added method to decay weights per the " +"techniques discussed in the paper (https://arxiv.org/abs/1711.05101). NB: " +"Weight decay should be set at 0.004 for default implementation.\n" +"\t lion - A method that uses the sign operator to control the magnitude of " +"the update, rather than relying on second-order moments (Adam). saves VRAM " +"by only tracking the momentum. Performance gains should be better with " +"larger batch sizes. A suitable learning rate for Lion is typically 3-10x " +"smaller than that for AdamW. The weight decay for Lion should be 3-10x " +"larger than that for AdamW to maintain a similar strength.\n" +"\t nadam - Adaptive Moment Optimization with Nesterov Momentum. Much like " +"Adam but uses a different formula for calculating momentum.\n" +"\t rms-prop - Root Mean Square Propagation. Maintains a moving (discounted) " +"average of the square of the gradients. Divides the gradient by the root of " +"this average." +msgstr "" +"Используемый оптимизатор.\n" +"\t adabelief - Адаптация размеров шагов по убеждению в наблюдаемых " +"градиентах('Adapting Stepsizes by the Belief in Observed Gradients'). " +"Оптимизатор, цель которого - быстрее сходиться, лучше обобщаться и " +"оставаться более стабильным. ([ТОЛЬКО на английском] https://arxiv.org/" +"abs/2010.07468). Примечание: значение Epsilon для AdaBelief должно быть " +"меньше, чем для других оптимизаторов. Как правило, значение 'Epsilon " +"Exponent' должно быть около '-16'.\n" +"\t adam - Адаптивная оптимизация моментов('Adaptive Moment Optimization'). " +"Стохастический метод градиентного спуска, основанный на адаптивной оценке " +"моментов первого и второго порядка.\n" +"\t adamax — вариант Adam, основанный на норме бесконечности (infinity norm). " +"Благодаря способности адаптировать скорость обучения в зависимости от " +"характеристик данных, он подходит для обучения процессам с изменяющимися во " +"времени характеристиками (time-variant processes). Параметры следуют " +"значениям, указанным в статье.\n" +"\t adamw — похож на 'Adam', но с добавленным методом затухания весов (weight " +"decay) в соответствии с техниками, описанными в статье. Примечание: Для " +"стандартной реализации коэффициент weight decay рекомендуется установить на " +"0.004.\n" +"\t lion — метод, который использует оператор знака для контроля величины " +"обновления, вместо зависимости от моментов второго порядка (как в Adam). " +"Экономит VRAM, отслеживая только моментум. Прирост производительности лучше " +"проявляется при больших размерах пачки. Подходящая скорость обучения для " +"Lion обычно в 3–10 раз меньше, чем для AdamW. Weight decay для Lion следует " +"делать в 3–10 раз больше, чем для AdamW, чтобы сохранить аналогичную силу " +"регуляризации.\n" +"\t nadam - Адаптивная оптимизация моментов с моментумом Нестерова ('Adaptive " +"Moment Optimization with Nesterov Momentum'). Похож на Adam, но использует " +"другую формулу для вычисления момента.\n" +"rms-prop - Распространение корневого среднего квадрата ('Root Mean Square " +"Propagation'). Поддерживает скользящее (дисконтированное) среднее квадрата " +"градиентов. Делит градиент на корень из этого среднего." + +#: plugins/train/train_config.py:602 +msgid "" +"Learning rate - how fast your network will learn (how large are the " +"modifications to the model weights after one batch of training). Values that " +"are too large might result in model crashes and the inability of the model " +"to find the best solution. Values that are too small might be unable to " +"escape from dead-ends and find the best global minimum." +msgstr "" +"Скорость обучения - насколько быстро ваша модель будет обучаться (насколько " +"огромны изменения весов модели после одной пачки тренировки). Слишком " +"большие значения могут привести к крахам модели и невозможности модели найти " +"лучшее решение. Слишком маленькие значения могут привести к невозможности " +"выбраться из тупиков и найти лучший глобальный минимум." + +#: plugins/train/train_config.py:615 +msgid "" +"The epsilon adds a small constant to weight updates to attempt to avoid " +"'divide by zero' errors. Unless you are using the AdaBelief Optimizer, then " +"Generally this option should be left at default value, For AdaBelief, " +"setting this to around '-16' should work.\n" +"In all instances if you are getting 'NaN' loss values, and have been unable " +"to resolve the issue any other way (for example, increasing batch size, or " +"lowering learning rate), then raising the epsilon can lead to a more stable " +"model. It may, however, come at the cost of slower training and a less " +"accurate final result.\n" +"Note: The value given here is the 'exponent' to the epsilon. For example, " +"choosing '-7' will set the epsilon to 1e-7. Choosing '-3' will set the " +"epsilon to 0.001 (1e-3).\n" +"Note: Not used by the Lion optimizer" +msgstr "" +"Эпсилон добавляет небольшую константу к обновлениям веса, чтобы попытаться " +"избежать ошибок \"деления на ноль\". Если вы не используете оптимизатор " +"AdaBelief, то, как правило, этот параметр следует оставить по умолчанию. Для " +"AdaBelief подойдет значение около '-16'.\n" +"Во всех случаях, если вы получаете значения потерь 'NaN' и не смогли решить " +"проблему другим способом (например, увеличив размер пачки или уменьшив " +"скорость обучения), то увеличение эпсилона может привести к более стабильной " +"модели. Однако это может стоить более медленного обучения и менее точного " +"конечного результата.\n" +"Примечание: Значение, указанное здесь, является \"экспонентой\" к эпсилону. " +"Например, при выборе значения '-7' эпсилон будет равен 1e-7. При выборе " +"значения \"-3\" эпсилон будет равен 0,001 (1e-3).\n" +"Примечание: Не используется оптимизатором Lion" + +#: plugins/train/train_config.py:636 +msgid "" +"When to save the Optimizer Weights. Saving the optimizer weights is not " +"necessary and will increase the model file size 3x (and by extension the " +"amount of time it takes to save the model). However, it can be useful to " +"save these weights if you want to guarantee that a resumed model carries off " +"exactly from where it left off, rather than spending a few hundred " +"iterations catching up.\n" +"\t never - Don't save optimizer weights.\n" +"\t always - Save the optimizer weights at every save iteration. Model saving " +"will take longer, due to the increased file size, but you will always have " +"the last saved optimizer state in your model file.\n" +"\t exit - Only save the optimizer weights when explicitly terminating a " +"model. This can be when the model is actively stopped or when the target " +"iterations are met. Note: If the training session ends because of another " +"reason (e.g. power outage, Out of Memory Error, NaN detected) then the " +"optimizer weights will NOT be saved." +msgstr "" +"Когда сохранять веса оптимизатора. Сохранение весов оптимизатора не является " +"необходимым и увеличит размер файла модели в 3 раза (и соответственно время, " +"необходимое для сохранения модели). Однако может быть полезно сохранить эти " +"веса, если вы хотите гарантировать, что возобновленная модель продолжит " +"работу именно с того места, где она остановилась, а не тратит несколько " +"сотен итераций на догонялки.\n" +"\t never - не сохранять веса оптимизатора.\n" +"\t always - сохранять веса оптимизатора при каждой итерации сохранения. " +"Сохранение модели займет больше времени из-за увеличенного размера файла, но " +"в файле модели всегда будет последнее сохраненное состояние оптимизатора.\n" +"\t exit - сохранять веса оптимизатора только при явном завершении модели. " +"Это может быть, когда модель активно останавливается или когда выполняются " +"целевые итерации. Примечание. Если сеанс обучения завершается по другой " +"причине (например, отключение питания, ошибка нехватки памяти, обнаружение " +"NaN), веса оптимизатора НЕ будут сохранены." + +#: plugins/train/train_config.py:657 plugins/train/train_config.py:676 +#: plugins/train/train_config.py:695 +msgid "clipping" +msgstr "клиппинг" + +#: plugins/train/train_config.py:659 +msgid "" +"Apply clipping to the gradients. Can help prevent NaNs and improve model " +"optimization at the expense of VRAM.\n" +"\tautoclip: Analyzes the gradient weights and adjusts the normalization " +"value dynamically to fit the data\n" +"\tglobal_norm: Clips the gradient of each weight so that the global norm is " +"no higher than the given value.\n" +"\tnorm: Clips the gradient of each weight so that its norm is no higher than " +"the given value.\n" +"\tvalue: Clips the gradient of each weight so that it is no higher than the " +"given value.\n" +"\tnone: Don't perform any clipping to the gradients." +msgstr "" +"Применять клиппинг (обрезку) градиентов. Помогает предотвратить NaN'ы и " +"улучшить оптимизацию модели, но за счёт увеличения расхода VRAM.\n" +"\tautoclip: Анализирует значения градиентов и динамически подстраивает порог " +"нормализации под текущие данные.\n" +"\tglobal_norm: Обрезает градиенты так, чтобы глобальная норма (норма всего " +"вектора градиентов модели) не превышала заданного значения.\n" +"\tnorm: Обрезает градиенты так, чтобы норма не превышала заданного " +"значения.\n" +"\tvalue: Обрезает градиенты по значению — каждый элемент градиента " +"ограничивается диапазоном [-value, value].\n" +"\tnone: Не выполнять обрезку градиентов." + +#: plugins/train/train_config.py:678 +msgid "" +"The amount of clipping to perform.\n" +"\tautoclip: The percentile to clip at. A value of 1.0 will clip at the 10th " +"percentile a value of 2.5 will clip at the 25th percentile etc. Default: " +"1.0\n" +"\tglobal_norm: The gradient of each weight is clipped so that the global " +"norm is no higher than this value.\n" +"\tnorm: The gradient of each weight is clipped so that its norm is no higher " +"than this value.\n" +"\tvalue: The gradient of each weight is clipped to be no higher than this " +"value.\n" +"\tnone: This option is ignored." +msgstr "" +"Величина обрезки градиентов.\n" +"\tautoclip: Процентиль, по которому выполняется обрезка. Значение 1.0 — " +"обрезка по 10-му процентилю, 2.5 — по 25-му процентилю и т.д. По умолчанию: " +"1.0\n" +"\tglobal_norm: Градиенты обрезаются так, чтобы глобальная норма не превышала " +"это значение.\n" +"\tnorm: Градиенты обрезаются так, чтобы норма не превышала это значение.\n" +"\tvalue: Каждый элемент градиента обрезается по абсолютному значению " +"(диапазон [-value, value]).\n" +"\tnone: Эта опция игнорируется." + +#: plugins/train/train_config.py:697 +msgid "" +"The maximum number of prior iterations for autoclipper to analyze when " +"calculating the normalization amount. 0 to always include all prior " +"iterations." +msgstr "" +"Максимальное количество предыдущих итераций, которые автоклиппер анализирует " +"при расчёте величины нормализации. Значение 0 означает, что всегда " +"учитываются все предыдущие итерации." + +#: plugins/train/train_config.py:706 plugins/train/train_config.py:715 +msgid "updates" +msgstr "обновления" + +#: plugins/train/train_config.py:707 +msgid "" +"If set, weight decay is applied. 0.0 for no weight decay. Default is 0.0 for " +"all optimizers except AdamW (0.004)" +msgstr "" +"Если задано значение больше 0, применяется затухание весов (weight decay). " +"Значение 0.0 отключает затухание. По умолчанию 0.0 для всех оптимизаторов, " +"кроме AdamW (0.004)." + +#: plugins/train/train_config.py:717 +msgid "" +"Values above 1 will enable Gradient Accumulation. Updates will not be at " +"every iteration; instead they will occur every number of iterations given " +"here. The update will be the average value of the gradients since the last " +"update. Can be useful when your batch size is very small, in order to reduce " +"gradient noise at each update iteration." +msgstr "" +"Значения больше 1 включают накопление градиентов (Gradient Accumulation). " +"Обновление параметров будет происходить не на каждой итерации, а каждые " +"указанное здесь количество итераций. При обновлении будет использоваться " +"среднее значение градиентов, накопленных с момента последнего обновления. " +"Полезно, когда размер пачки очень мал — позволяет уменьшить шум градиентов " +"на каждом шаге обновления." + +#: plugins/train/train_config.py:728 plugins/train/train_config.py:738 +#: plugins/train/train_config.py:749 +msgid "exponential moving average" +msgstr "экспоненциальная скользящая средняя" + +#: plugins/train/train_config.py:730 +msgid "" +"Enable exponential moving average (EMA). EMA consists of computing an " +"exponential moving average of the weights of the model (as the weight values " +"change after each training batch), and periodically overwriting the weights " +"with their moving average" +msgstr "" +"Включить экспоненциальную скользящую среднюю (EMA) весов. EMA подразумевает " +"расчёт экспоненциальной скользящей средней весов модели по мере их " +"обновления после каждой пачки, с периодической заменой текущих весов на эту " +"среднюю" + +#: plugins/train/train_config.py:740 +msgid "" +"Only used if use_ema is enabled. This is the momentum to use when computing " +"the EMA of the model's weights: new_average = ema_momentum * old_average + " +"(1 - ema_momentum) * current_variable_value." +msgstr "" +"Параметр активен только при включённой EMA. Определяет коэффициент momentum " +"для экспоненциальной скользящей средней весов модели по формуле: new_average " +"= ema_momentum × old_average + (1 - ema_momentum) × current_variable_value." + +#: plugins/train/train_config.py:751 +msgid "" +"Only used if use_ema is enabled. Set the number of iterations, to overwrite " +"the model variable by its moving average. " +msgstr "" +"Активен только при включённой EMA. Указывает интервал в итерациях, после " +"которого веса основной модели заменяются на значения их экспоненциальной " +"скользящей средней. " + +#: plugins/train/train_config.py:759 plugins/train/train_config.py:770 +#: plugins/train/train_config.py:781 +msgid "optimizer specific" +msgstr "параметры, специфичные для оптимизатора" + +#: plugins/train/train_config.py:761 +msgid "" +"The exponential decay rate for the 1st moment estimates. Used for the " +"following Optimizers: AdaBelief, Adam, Adamax, AdamW, Lion, nAdam. Ignored " +"for all others." +msgstr "" +"Коэффициент экспоненциального затухания для среднего градиента первого " +"момента. Применяется только к оптимизаторам: AdaBelief, Adam, Adamax, AdamW, " +"Lion, nAdam. Для остальных оптимизаторов игнорируется." + +#: plugins/train/train_config.py:772 +msgid "" +"The exponential decay rate for the 2nd moment estimates. Used for the " +"following Optimizers: AdaBelief, Adam, Adamax, AdamW, Lion, nAdam. Ignored " +"for all others." +msgstr "" +"Коэффициент экспоненциального затухания для среднего градиента второго " +"момента. Применяется только к оптимизаторам: AdaBelief, Adam, Adamax, " +"AdamW, Lion, nAdam. Для остальных оптимизаторов игнорируется." + +#: plugins/train/train_config.py:783 +msgid "" +"Whether to apply AMSGrad variant of the algorithm from the paper 'On the " +"Convergence of Adam and beyond. Used for the following Optimizers: " +"AdaBelief, Adam, AdamW. Ignored for all others.'" +msgstr "" +"Применять ли вариант AMSGrad алгоритма из статьи «On the Convergence of Adam " +"and Beyond». Используется только для следующих оптимизаторов: AdaBelief, " +"Adam, AdamW. Для всех остальных игнорируется." + +#~ msgid "" +#~ "The amount of weight to apply to the second loss function.\n" +#~ "\n" +#~ "\n" +#~ "\n" +#~ "The value given here is as a percentage denoting how much the selected " +#~ "function should contribute to the overall loss cost of the model. For " +#~ "example:\n" +#~ "\t 100 - The loss calculated for the fourth loss function will be applied " +#~ "at its full amount towards the overall loss score. \n" +#~ "\t 25 - The loss calculated for the fourth loss function will be reduced " +#~ "by a quarter prior to adding to the overall loss score. \n" +#~ "\t 400 - The loss calculated for the fourth loss function will be " +#~ "mulitplied 4 times prior to adding to the overall loss score. \n" +#~ "\t 0 - Disables the fourth loss function altogether." +#~ msgstr "" +#~ "Величина веса, применяемая к второй функции потерь.\n" +#~ "\n" +#~ "\n" +#~ "\n" +#~ "Значение задается в процентах и показывает, какой вклад выбранная функция " +#~ "должна внести в общую стоимость потерь модели. Например:\n" +#~ "\t 100 - Потери, рассчитанные для второй функции потерь, будут применены " +#~ "в полном объеме к общей стоимости потерь. \n" +#~ "\t25 - Потери, рассчитанные для второй функции потерь, будут уменьшены на " +#~ "четверть перед добавлением к общей стоимости потерь. \n" +#~ "\t400 - Потери, рассчитанные для второй функции потерь, будут умножены в " +#~ "4 раза перед добавлением к общей оценке потерь. \n" +#~ "\t 0 - Полностью отключает вторую функцию потерь." + +#, fuzzy +#~| msgid "" +#~| "The amount of weight to apply to the fourth loss function.\n" +#~| "\n" +#~| "\n" +#~| "\n" +#~| "The value given here is as a percentage denoting how much the selected " +#~| "function should contribute to the overall loss cost of the model. For " +#~| "example:\n" +#~| "\t 100 - The loss calculated for the fourth loss function will be " +#~| "applied at its full amount towards the overall loss score. \n" +#~| "\t 25 - The loss calculated for the fourth loss function will be reduced " +#~| "by a quarter prior to adding to the overall loss score. \n" +#~| "\t 400 - The loss calculated for the fourth loss function will be " +#~| "mulitplied 4 times prior to adding to the overall loss score. \n" +#~| "\t 0 - Disables the fourth loss function altogether." +#~ msgid "" +#~ "The amount of weight to apply to the third loss function.\n" +#~ "\n" +#~ "\n" +#~ "\n" +#~ "The value given here is as a percentage denoting how much the selected " +#~ "function should contribute to the overall loss cost of the model. For " +#~ "example:\n" +#~ "\t 100 - The loss calculated for the fourth loss function will be applied " +#~ "at its full amount towards the overall loss score. \n" +#~ "\t 25 - The loss calculated for the fourth loss function will be reduced " +#~ "by a quarter prior to adding to the overall loss score. \n" +#~ "\t 400 - The loss calculated for the fourth loss function will be " +#~ "mulitplied 4 times prior to adding to the overall loss score. \n" +#~ "\t 0 - Disables the fourth loss function altogether." +#~ msgstr "" +#~ "Величина веса, применяемая к четвертой функции потерь.\n" +#~ "\n" +#~ "\n" +#~ "\n" +#~ "Значение задается в процентах и показывает, какой вклад выбранная функция " +#~ "должна внести в общую стоимость потерь модели. Например:\n" +#~ "\t 100 - Потери, рассчитанные для четвертой функции потерь, будут " +#~ "применены в полном объеме к общей стоимости потерь. \n" +#~ "\t25 - Потери, рассчитанные для четвертой функции потерь, будут уменьшены " +#~ "на четверть перед добавлением к общей стоимости потерь. \n" +#~ "\t400 - Потери, рассчитанные для четвертой функции потерь, будут умножены " +#~ "в 4 раза перед добавлением к общей оценке потерь. \n" +#~ "\t 0 - Полностью отключает четвертую функцию потерь." + +#~ msgid "" +#~ "Apply AutoClipping to the gradients. AutoClip analyzes the gradient " +#~ "weights and adjusts the normalization value dynamically to fit the data. " +#~ "Can help prevent NaNs and improve model optimization at the expense of " +#~ "VRAM. Ref: AutoClip: Adaptive Gradient Clipping for Source Separation " +#~ "Networks https://arxiv.org/abs/2007.14469" +#~ msgstr "" +#~ "Применить AutoClipping к градиентам. AutoClip анализирует веса градиентов " +#~ "и динамически корректирует значение нормализации, чтобы оно подходило к " +#~ "данным. Может помочь избежать NaN('не число') и улучшить оптимизацию " +#~ "модели ценой видеопамяти. Ссылка: AutoClip: Adaptive Gradient Clipping " +#~ "for Source Separation Networks [ТОЛЬКО на английском] https://arxiv.org/" +#~ "abs/2007.14469" + +#~ msgid "" +#~ "Enable the Tensorflow GPU 'allow_growth' configuration option. This " +#~ "option prevents Tensorflow from allocating all of the GPU VRAM at launch " +#~ "but can lead to higher VRAM fragmentation and slower performance. Should " +#~ "only be enabled if you are receiving errors regarding 'cuDNN fails to " +#~ "initialize' when commencing training." +#~ msgstr "" +#~ "[Только для Nvidia]. Включите опцию конфигурации Tensorflow GPU " +#~ "`allow_growth`. Эта опция не позволяет Tensorflow выделять всю " +#~ "видеопамять видеокарты при запуске, но может привести к повышенной " +#~ "фрагментации видеопамяти и снижению производительности. Следует включать " +#~ "только в том случае, если у вас появляются ошибки, рода 'cuDNN fails to " +#~ "initialize'(cuDNN не может инициализироваться) при начале тренировки." diff --git a/plugins/convert/_config.py b/plugins/convert/_config.py deleted file mode 100644 index 3deb1857f4..0000000000 --- a/plugins/convert/_config.py +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env python3 -""" Default configurations for convert """ - -import logging -import os - -from lib.config import FaceswapConfig - -logger = logging.getLogger(__name__) - - -class Config(FaceswapConfig): - """ Config File for Convert """ - - def set_defaults(self): - """ Set the default values for config """ - self._defaults_from_plugin(os.path.dirname(__file__)) diff --git a/plugins/convert/color/_base.py b/plugins/convert/color/_base.py index 7f58d45ead..6bbe623e8f 100644 --- a/plugins/convert/color/_base.py +++ b/plugins/convert/color/_base.py @@ -4,46 +4,30 @@ import logging import numpy as np -from plugins.convert._config import Config +from plugins.convert import convert_config logger = logging.getLogger(__name__) -def get_config(plugin_name, configfile=None): - """ Return the config for the requested model """ - return Config(plugin_name, configfile=configfile).config_dict - - class Adjustment(): """ Parent class for adjustments """ def __init__(self, configfile=None, config=None): logger.debug("Initializing %s: (configfile: %s, config: %s)", self.__class__.__name__, configfile, config) - self.config = self.set_config(configfile, config) - logger.debug("config: %s", self.config) + convert_config.load_config(config_file=configfile) logger.debug("Initialized %s", self.__class__.__name__) - def set_config(self, configfile, config): - """ Set the config to either global config or passed in config """ - section = ".".join(self.__module__.split(".")[-2:]) - if config is None: - retval = get_config(section, configfile) - else: - config.section = section - retval = config.config_dict - config.section = None - logger.debug("Config: %s", retval) - return retval - def process(self, old_face, new_face, raw_mask): """ Override for specific color adjustment process """ raise NotImplementedError def run(self, old_face, new_face, raw_mask): """ Perform selected adjustment on face """ - logger.trace("Performing color adjustment") + # pylint:disable=duplicate-code + logger.trace("Performing color adjustment") # type:ignore[attr-defined] # Remove Mask for processing reinsert_mask = False + final_mask = None if new_face.shape[2] == 4: reinsert_mask = True final_mask = new_face[:, :, -1] @@ -52,6 +36,7 @@ def run(self, old_face, new_face, raw_mask): new_face = np.clip(new_face, 0.0, 1.0) if reinsert_mask and new_face.shape[2] != 4: # Reinsert Mask + assert final_mask is not None new_face = np.concatenate((new_face, np.expand_dims(final_mask, axis=-1)), -1) - logger.trace("Performed color adjustment") + logger.trace("Performed color adjustment") # type:ignore[attr-defined] return new_face diff --git a/plugins/convert/color/avg_color.py b/plugins/convert/color/avg_color.py index f62024289e..97a590599d 100644 --- a/plugins/convert/color/avg_color.py +++ b/plugins/convert/color/avg_color.py @@ -2,6 +2,7 @@ """ Average colour adjustment color matching adjustment plugin for faceswap.py converter """ import numpy as np +from lib.utils import get_module_objects from ._base import Adjustment @@ -37,3 +38,6 @@ def process(self, adjustment = diff new_face += adjustment return new_face + + +__all__ = get_module_objects(__name__) diff --git a/plugins/convert/color/color_transfer.py b/plugins/convert/color/color_transfer.py index 4b8080a433..6cb67f01a9 100644 --- a/plugins/convert/color/color_transfer.py +++ b/plugins/convert/color/color_transfer.py @@ -25,7 +25,9 @@ import cv2 import numpy as np +from lib.utils import get_module_objects from ._base import Adjustment +from . import color_transfer_defaults as cfg class Color(Adjustment): @@ -38,7 +40,7 @@ class Color(Adjustment): between Images" paper by Reinhard et al., 2001. """ - def process(self, old_face, new_face, raw_mask): + def process(self, old_face, new_face, raw_mask): # pylint:disable=too-many-locals """ Parameters ---------- @@ -64,8 +66,8 @@ def process(self, old_face, new_face, raw_mask): transfer: NumPy array OpenCV image (w, h, 3) NumPy array (uint8) """ - clip = self.config.get("clip", True) - preserve_paper = self.config.get("preserve_paper", True) + clip = cfg.clip() + preserve_paper = cfg.preserve_paper() # convert the images from the RGB to L*ab* color space, being # sure to utilizing the floating point data type (note: OpenCV @@ -201,3 +203,6 @@ def _scale_array(self, arr, clip=True): scaled = self._min_max_scale(arr, new_range=scale_range) return scaled + + +__all__ = get_module_objects(__name__) diff --git a/plugins/convert/color/color_transfer_defaults.py b/plugins/convert/color/color_transfer_defaults.py index 8e1193db0b..b12931c6f5 100755 --- a/plugins/convert/color/color_transfer_defaults.py +++ b/plugins/convert/color/color_transfer_defaults.py @@ -1,83 +1,55 @@ #!/usr/bin/env python3 -""" - The default options for the faceswap Color_Transfer Color plugin. +""" The default options for the faceswap Color_Transfer Color plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. +The following variable should be defined: - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does - should always be lower text. - dictionary requirements are listed below. +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. """ +from lib.config import ConfigItem -_HELPTEXT = ( +HELPTEXT = ( "Options for transfering the color distribution from the source to the target image using the " "mean and standard deviations of the L*a*b* color space.\nThis implementation is (loosely) " "based on the 'Color Transfer between Images' paper by Reinhard et al., 2001. matching the " - "histograms between the source and destination faces." -) - - -_DEFAULTS = dict( - clip=dict( - default=True, - info="Should components of L*a*b* image be scaled by np.clip before converting back to " - "BGR color space?\nIf False then components will be min-max scaled appropriately.\n" - "Clipping will keep target image brightness truer to the input.\nScaling will adjust " - "image brightness to avoid washed out portions in the resulting color transfer that " - "can be caused by clipping.", - datatype=bool, - group="method", - rounding=None, - min_max=None, - choices=[], - gui_radio=False, - fixed=True, - ), - preserve_paper=dict( - default=True, - info="Should color transfer strictly follow methodology layed out in original paper?\nThe " - "method does not always produce aesthetically pleasing results.\nIf False then " - "L*a*b* components will be scaled using the reciprocal of the scaling factor " - "proposed in the paper. This method seems to produce more consistently aesthetically " - "pleasing results.", - datatype=bool, - group="method", - rounding=None, - min_max=None, - choices=[], - gui_radio=False, - fixed=True, - ), -) + "histograms between the source and destination faces.") + + +clip = ConfigItem( + datatype=bool, + default=True, + group="method", + info="Should components of L*a*b* image be scaled by numpy.clip before converting back to " + "BGR color space?\nIf False then components will be min-max scaled appropriately.\n" + "Clipping will keep target image brightness truer to the input.\nScaling will adjust " + "image brightness to avoid washed out portions in the resulting color transfer that " + "can be caused by clipping.") + +preserve_paper = ConfigItem( + datatype=bool, + group="method", + default=True, + info="Should color transfer strictly follow methodology layed out in original paper?\nThe " + "method does not always produce aesthetically pleasing results.\nIf False then " + "L*a*b* components will be scaled using the reciprocal of the scaling factor " + "proposed in the paper. This method seems to produce more consistently aesthetically " + "pleasing results.") diff --git a/plugins/convert/color/manual_balance.py b/plugins/convert/color/manual_balance.py index 7dc6950bb6..3719bec67a 100644 --- a/plugins/convert/color/manual_balance.py +++ b/plugins/convert/color/manual_balance.py @@ -3,7 +3,9 @@ import cv2 import numpy as np +from lib.utils import get_module_objects from ._base import Adjustment +from . import manual_balance_defaults as cfg class Color(Adjustment): @@ -11,9 +13,9 @@ class Color(Adjustment): def process(self, old_face, new_face, raw_mask): image = self.convert_colorspace(new_face * 255.0) - adjustment = np.array([self.config["balance_1"] / 100.0, - self.config["balance_2"] / 100.0, - self.config["balance_3"] / 100.0]).astype("float32") + adjustment = np.array([cfg.balance_1() / 100.0, + cfg.balance_2() / 100.0, + cfg.balance_3() / 100.0]).astype("float32") for idx in range(3): if adjustment[idx] >= 0: image[:, :, idx] = ((1 - image[:, :, idx]) * adjustment[idx]) + image[:, :, idx] @@ -28,8 +30,8 @@ def adjust_contrast(self, image): """ Adjust image contrast and brightness. """ - contrast = max(-126, int(round(self.config["contrast"] * 1.27))) - brightness = max(-126, int(round(self.config["brightness"] * 1.27))) + contrast = max(-126, int(round(cfg.contrast() * 1.27))) + brightness = max(-126, int(round(cfg.brightness() * 1.27))) if not contrast and not brightness: return image @@ -41,9 +43,12 @@ def adjust_contrast(self, image): def convert_colorspace(self, new_face, to_bgr=False): """ Convert colorspace based on mode or back to bgr """ - mode = self.config["colorspace"].lower() + mode = cfg.colorspace().lower() colorspace = "YCrCb" if mode == "ycrcb" else mode.upper() conversion = f"{colorspace}2BGR" if to_bgr else f"BGR2{colorspace}" image = cv2.cvtColor(new_face.astype("uint8"), # pylint:disable=no-member getattr(cv2, f"COLOR_{conversion}")).astype("float32") / 255.0 return image + + +__all__ = get_module_objects(__name__) diff --git a/plugins/convert/color/manual_balance_defaults.py b/plugins/convert/color/manual_balance_defaults.py index f5347014b0..b3a7dfde6b 100755 --- a/plugins/convert/color/manual_balance_defaults.py +++ b/plugins/convert/color/manual_balance_defaults.py @@ -1,143 +1,109 @@ #!/usr/bin/env python3 +""" The default options for the faceswap Manual_Balance Color plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: + + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does + +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) + +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ - The default options for the faceswap Manual_Balance Color plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} - - should always be lower text. - dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" +from lib.config import ConfigItem + + +HELPTEXT = "Options for manually altering the balance of colors of the swapped face" + + +colorspace = ConfigItem( + datatype=str, + default="HSV", + group="color balance", + info="The colorspace to use for adjustment: The three adjustment sliders will " + "effect the image differently depending on which colorspace is selected:" + "\n\t RGB: Red, Green, Blue. An additive colorspace where colors are obtained " + "by a linear combination of Red, Green, and Blue values. The three channels " + "are correlated by the amount of light hitting the surface. In RGB color " + "space the color information is separated into three channels but the same " + "three channels also encode brightness information." + "\n\t HSV: Hue, Saturation, Value. Hue - Dominant wavelength. Saturation - " + "Purity / shades of color. Value - Intensity. Best thing is that it uses only " + "one channel to describe color (H), making it very intuitive to specify color." + "\n\t LAB: Lightness, A, B. Lightness - Intensity. A - Color range from green " + "to magenta. B - Color range from blue to yellow. The L channel is " + "independent of color information and encodes brightness only. The other two " + "channels encode color." + "\n\t YCrCb: Y - Luminance or Luma component obtained from RGB after gamma " + "correction. Cr - how far is the red component from Luma. Cb - how far is the " + "blue component from Luma. Separates the luminance and chrominance components " + "into different channels.", + choices=["RGB", "HSV", "LAB", "YCrCb"], + gui_radio=True) + +balance_1 = ConfigItem( + datatype=float, + default=0.0, + group="color balance", + info="Balance of channel 1:" + "\n\tRGB: Red" + "\n\tHSV: Hue" + "\n\tLAB: Lightness" + "\n\tYCrCb: Luma", + rounding=1, + min_max=(-100.0, 100.0)) + +balance_2 = ConfigItem( + datatype=float, + default=0.0, + group="color balance", + info="Balance of channel 2:" + "\n\tRGB: Green" + "\n\tHSV: Saturation" + "\n\tLAB: Green > Magenta" + "\n\tYCrCb: Distance of red from Luma", + rounding=1, + min_max=(-100.0, 100.0)) + +balance_3 = ConfigItem( + datatype=float, + default=0.0, + group="color balance", + info="Balance of channel 3:" + "\n\tRGB: Blue" + "\n\tHSV: Intensity" + "\n\tLAB: Blue > Yellow" + "\n\tYCrCb: Distance of blue from Luma", + rounding=1, + min_max=(-100.0, 100.0)) +contrast = ConfigItem( + datatype=float, + default=0.0, + group="brightness contrast", + info="Amount of contrast applied.", + rounding=1, + min_max=(-100.0, 100.0)) -_HELPTEXT = "Options for manually altering the balance of colors of the swapped face" - - -_DEFAULTS = { - "colorspace": { - "default": "HSV", - "info": "The colorspace to use for adjustment: The three adjustment sliders will " - "effect the image differently depending on which colorspace is selected:" - "\n\t RGB: Red, Green, Blue. An additive colorspace where colors are obtained " - "by a linear combination of Red, Green, and Blue values. The three channels " - "are correlated by the amount of light hitting the surface. In RGB color " - "space the color information is separated into three channels but the same " - "three channels also encode brightness information." - "\n\t HSV: Hue, Saturation, Value. Hue - Dominant wavelength. Saturation - " - "Purity / shades of color. Value - Intensity. Best thing is that it uses only " - "one channel to describe color (H), making it very intuitive to specify color." - "\n\t LAB: Lightness, A, B. Lightness - Intensity. A - Color range from green " - "to magenta. B - Color range from blue to yellow. The L channel is " - "independent of color information and encodes brightness only. The other two " - "channels encode color." - "\n\t YCrCb: Y - Luminance or Luma component obtained from RGB after gamma " - "correction. Cr - how far is the red component from Luma. Cb - how far is the " - "blue component from Luma. Separates the luminance and chrominance components " - "into different channels.", - "datatype": str, - "rounding": None, - "min_max": None, - "group": "color balance", - "choices": ["RGB", "HSV", "LAB", "YCrCb"], - "gui_radio": True, - "fixed": True, - }, - "balance_1": { - "default": 0.0, - "info": "Balance of channel 1:" - "\n\tRGB: Red" - "\n\tHSV: Hue" - "\n\tLAB: Lightness" - "\n\tYCrCb: Luma", - "datatype": float, - "rounding": 1, - "min_max": (-100.0, 100.0), - "choices": [], - "group": "color balance", - "gui_radio": False, - "fixed": True, - }, - "balance_2": { - "default": 0.0, - "info": "Balance of channel 2:" - "\n\tRGB: Green" - "\n\tHSV: Saturation" - "\n\tLAB: Green > Magenta" - "\n\tYCrCb: Distance of red from Luma", - "datatype": float, - "rounding": 1, - "min_max": (-100.0, 100.0), - "choices": [], - "gui_radio": False, - "group": "color balance", - "fixed": True, - }, - "balance_3": { - "default": 0.0, - "info": "Balance of channel 3:" - "\n\tRGB: Blue" - "\n\tHSV: Intensity" - "\n\tLAB: Blue > Yellow" - "\n\tYCrCb: Distance of blue from Luma", - "datatype": float, - "rounding": 1, - "min_max": (-100.0, 100.0), - "choices": [], - "gui_radio": False, - "group": "color balance", - "fixed": True, - }, - "contrast": { - "default": 0.0, - "info": "Amount of contrast applied.", - "datatype": float, - "rounding": 1, - "min_max": (-100.0, 100.0), - "choices": [], - "gui_radio": False, - "group": "brightness contrast", - "fixed": True, - }, - "brightness": { - "default": 0.0, - "info": "Amount of brighness applied.", - "datatype": float, - "rounding": 1, - "min_max": (-100.0, 100.0), - "choices": [], - "gui_radio": False, - "group": "brightness contrast", - "fixed": True, - }, -} +brightness = ConfigItem( + datatype=float, + default=0.0, + group="brightness contrast", + info="Amount of brighness applied.", + rounding=1, + min_max=(-100.0, 100.0)) diff --git a/plugins/convert/color/match_hist.py b/plugins/convert/color/match_hist.py index e7c457219c..c743118385 100644 --- a/plugins/convert/color/match_hist.py +++ b/plugins/convert/color/match_hist.py @@ -3,7 +3,9 @@ for faceswap.py converter """ import numpy as np +from lib.utils import get_module_objects from ._base import Adjustment +from . import match_hist_defaults as cfg class Color(Adjustment): @@ -14,7 +16,7 @@ def process(self, old_face, new_face, raw_mask): new_face = [self.hist_match(old_face[:, :, c], new_face[:, :, c], mask_indices, - self.config["threshold"] / 100) + cfg.threshold() / 100) for c in range(3)] new_face = np.stack(new_face, axis=-1) return new_face @@ -39,3 +41,6 @@ def hist_match(old_channel, new_channel, mask_indices, threshold): interp_s_values = np.interp(s_quants, t_quants, t_values) new_channel[mask_indices] = interp_s_values[bin_idx] return new_channel + + +__all__ = get_module_objects(__name__) diff --git a/plugins/convert/color/match_hist_defaults.py b/plugins/convert/color/match_hist_defaults.py index fb733e0a44..19dd891c4a 100755 --- a/plugins/convert/color/match_hist_defaults.py +++ b/plugins/convert/color/match_hist_defaults.py @@ -1,60 +1,41 @@ #!/usr/bin/env python3 +""" The default options for the faceswap Match_Hist Color plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: + + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does + +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) + +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ - The default options for the faceswap Match_Hist Color plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} - - should always be lower text. - dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" +from lib.config import ConfigItem -_HELPTEXT = "Options for matching the histograms between the source and destination faces" +HELPTEXT = "Options for matching the histograms between the source and destination faces" -_DEFAULTS = dict( - threshold=dict( - default=99.0, - info="Adjust the threshold for histogram matching. Can reduce extreme colors leaking in " - "by filtering out colors at the extreme ends of the histogram spectrum.", - datatype=float, - rounding=1, - min_max=(90.0, 100.0), - choices=[], - gui_radio=False, - group="settings", - fixed=True, - ) -) +threshold = ConfigItem( + datatype=float, + default=99.0, + group="settings", + info="Adjust the threshold for histogram matching. Can reduce extreme colors leaking in " + "by filtering out colors at the extreme ends of the histogram spectrum.", + rounding=1, + min_max=(90.0, 100.0)) diff --git a/plugins/convert/color/seamless_clone.py b/plugins/convert/color/seamless_clone.py index dc2f1fe21d..7d6680d5d7 100644 --- a/plugins/convert/color/seamless_clone.py +++ b/plugins/convert/color/seamless_clone.py @@ -1,22 +1,20 @@ #!/usr/bin/env python3 """ Seamless clone adjustment plugin for faceswap.py converter - NB: This probably isn't the best place for this, but it is independent of - color adjustments and does not have a natural home, so here for now - and called as an extra plugin from lib/convert.py +NB: This probably isn't the best place for this, but it is independent of color adjustments and +does not have a natural home, so here for now and called as an extra plugin from lib/convert.py """ - import cv2 import numpy as np +from lib.utils import get_module_objects from ._base import Adjustment class Color(Adjustment): """ Seamless clone the swapped face into the old face with cv2 - NB: This probably isn't the best place for this, but it doesn't work well and - and does not have a natural home, so here for now. + NB: This probably isn't the best place for this, but it doesn't work well and does not have a + natural home, so here for now. """ - - def process(self, old_face, new_face, raw_mask): + def process(self, old_face, new_face, raw_mask): # pylint:disable=too-many-locals height, width, _ = old_face.shape height = height // 2 width = width // 2 @@ -42,3 +40,6 @@ def process(self, old_face, new_face, raw_mask): blended = blended[height:-height, width:-width] return blended.astype("float32") / 255.0 + + +__all__ = get_module_objects(__name__) diff --git a/plugins/convert/convert_config.py b/plugins/convert/convert_config.py new file mode 100644 index 0000000000..9f174c77c1 --- /dev/null +++ b/plugins/convert/convert_config.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +""" Default configurations for convert """ + +import logging +import os + +from lib.config import FaceswapConfig + +logger = logging.getLogger(__name__) + + +class _Config(FaceswapConfig): + """ Config File for Convert """ + + def set_defaults(self, helptext=""): + """ Set the default values for config """ + super().set_defaults(helptext=helptext) + self._defaults_from_plugin(os.path.dirname(__file__)) + + +_CONFIG: _Config | None = None + + +def load_config(config_file: str | None = None) -> _Config: + """ Load the Extraction configuration .ini file + + Parameters + ---------- + config_file : str | None, optional + Path to a custom .ini configuration file to load. Default: ``None`` (use default + configuration file) + + Returns + ------- + :class:`_Config` + The loaded convert config object + """ + global _CONFIG # pylint:disable=global-statement + if _CONFIG is None: + _CONFIG = _Config(configfile=config_file) + return _CONFIG diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index 6683bdf10b..a46014dbd2 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -7,8 +7,10 @@ import numpy as np from lib.align import BlurMask, DetectedFace -from lib.config import FaceswapConfig -from plugins.convert._config import Config +from lib.logger import parse_class_init +from lib.utils import get_module_objects +from plugins.convert import convert_config +from . import mask_blend_defaults as cfg logger = logging.getLogger(__name__) @@ -28,62 +30,24 @@ class Mask(): configfile: str, Optional Optional location of custom configuration ``ini`` file. If ``None`` then use the default config location. Default: ``None`` - config: :class:`lib.config.FaceswapConfig`, Optional - Optional pre-loaded :class:`lib.config.FaceswapConfig`. If passed, then this will be used - over any configuration on disk. If ``None`` then it is ignored. Default: ``None`` - """ def __init__(self, mask_type: str, output_size: int, coverage_ratio: float, - configfile: str | None = None, - config: FaceswapConfig | None = None) -> None: - logger.debug("Initializing %s: (mask_type: '%s', output_size: %s, coverage_ratio: %s, " - "configfile: %s, config: %s)", self.__class__.__name__, mask_type, - coverage_ratio, output_size, configfile, config) + configfile: str | None = None) -> None: + logger.debug(parse_class_init(locals())) self._mask_type = mask_type - self._config = self._set_config(configfile, config) - logger.debug("config: %s", self._config) + convert_config.load_config(config_file=configfile) self._coverage_ratio = coverage_ratio self._box = self._get_box(output_size) - erode_types = [f"erosion{f}" for f in ["", "_left", "_top", "_right", "_bottom"]] - self._erodes = [self._config.get(erode, 0) / 100 for erode in erode_types] + self._erodes = [erode / 100 + for erode in [cfg.erosion(), cfg.erosion_left(), cfg.erosion_top(), + cfg.erosion_right(), cfg.erosion_bottom()]] self._do_erode = any(amount != 0 for amount in self._erodes) - def _set_config(self, - configfile: str | None, - config: FaceswapConfig | None) -> dict: - """ Set the correct configuration for the plugin based on whether a config file - or pre-loaded config has been passed in. - - Parameters - ---------- - configfile: str - Location of custom configuration ``ini`` file. If ``None`` then use the - default config location - config: :class:`lib.config.FaceswapConfig` - Pre-loaded :class:`lib.config.FaceswapConfig`. If passed, then this will be - used over any configuration on disk. If ``None`` then it is ignored. - - Returns - ------- - dict - The configuration in dictionary form for the given from - :attr:`lib.config.FaceswapConfig.config_dict` - """ - section = ".".join(self.__module__.split(".")[-2:]) - if config is None: - retval = Config(section, configfile=configfile).config_dict - else: - config.section = section - retval = config.config_dict - config.section = None - logger.debug("Config: %s", retval) - return retval - def _get_box(self, output_size: int) -> np.ndarray: """ Apply a gradient overlay to the edge of the swap box to smooth out any hard areas that where the face intersects with the edge of the swap area. @@ -105,7 +69,7 @@ def _get_box(self, output_size: int) -> np.ndarray: edge = (output_size // 32) + 1 box[edge:-edge, edge:-edge] = 1.0 - if self._config["type"] is not None: + if cfg.type() != "none": box = BlurMask("gaussian", box, 6, @@ -212,12 +176,12 @@ def _process_predicted_mask(self, mask: np.ndarray) -> np.ndarray: :class:`numpy.ndarray` The processed predicted mask """ - blur_type = self._config["type"].lower() - if blur_type is not None: + blur_type = T.cast(T.Literal["gaussian", "normalized", "none"], cfg.type().lower()) + if blur_type != "none": mask = BlurMask(blur_type, mask, - self._config["kernel_size"], - passes=self._config["passes"]).blurred + cfg.kernel_size(), + passes=cfg.passes()).blurred return mask def _get_stored_mask(self, @@ -244,10 +208,12 @@ def _get_stored_mask(self, The mask sized to Faceswap model output with any requested blurring applied. """ mask = detected_face.mask[self._mask_type] - mask.set_blur_and_threshold(blur_kernel=self._config["kernel_size"], - blur_type=self._config["type"], - blur_passes=self._config["passes"], - threshold=self._config["threshold"]) + blur_type = T.cast(T.Literal["gaussian", "normalized"] | None, cfg.type().lower()) + blur_type = None if blur_type == "none" else blur_type + mask.set_blur_and_threshold(blur_kernel=cfg.kernel_size(), + blur_type=blur_type, + blur_passes=cfg.passes(), + threshold=cfg.threshold()) mask.set_sub_crop(source_offset, target_offset, centering, self._coverage_ratio) face_mask = mask.mask mask_size = face_mask.shape[0] @@ -324,3 +290,6 @@ def _get_erosion_kernels(self, mask: np.ndarray) -> list[np.ndarray]: kernels.append(cv2.getStructuringElement(shape, kernel) if size else np.array(0)) logger.trace("Erosion kernels: %s", [k.shape for k in kernels]) # type: ignore return kernels + + +__all__ = get_module_objects(__name__) diff --git a/plugins/convert/mask/mask_blend_defaults.py b/plugins/convert/mask/mask_blend_defaults.py index f864fb1270..4d926f1443 100755 --- a/plugins/convert/mask/mask_blend_defaults.py +++ b/plugins/convert/mask/mask_blend_defaults.py @@ -1,167 +1,123 @@ #!/usr/bin/env python3 +""" The default options for the faceswap Mask_Blend Mask plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: + + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does + +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) + +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ - The default options for the faceswap Mask_Blend Mask plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} - - should always be lower text. - dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid data types are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and data types this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and data types this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" +from lib.config import ConfigItem + + +HELPTEXT = "Options for blending the edges between the mask and the background image" + + +type = ConfigItem( # pylint:disable=redefined-builtin + datatype=str, + default="normalized", + group="Blending type", + info="The type of blending to use:" + "\n\t gaussian: Blend with Gaussian filter. Slower, but often better than Normalized" + "\n\t normalized: Blend with Normalized box filter. Faster than Gaussian" + "\n\t none: Don't perform blending", + choices=["gaussian", "normalized", "none"]) + +kernel_size = ConfigItem( + datatype=int, + default=3, + group="settings", + info="The kernel size dictates how much blending should occur.\n" + "The size is the diameter of the kernel in pixels (calculated from a 128px mask). " + "This value should be odd, if an even number is passed in then it will be rounded to " + "the next odd number. Higher sizes means more blending.", + rounding=1, + min_max=(1, 9)) + +passes = ConfigItem( + default=4, + datatype=int, + group="settings", + info="The number of passes to perform. Additional passes of the blending algorithm can " + "improve smoothing at a time cost. This is more useful for 'box' type blending.\n" + "Additional passes have exponentially less effect so it's not worth setting this too " + "high.", + rounding=1, + min_max=(1, 8)) + +threshold = ConfigItem( + default=4, + datatype=int, + group="settings", + info="Sets pixels that are near white to white and near black to black. Set to 0 for off.", + rounding=1, + min_max=(0, 50)) + +erosion = ConfigItem( + datatype=float, + default=0.0, + group="settings", + info="Apply erosion to the whole of the face mask.\n" + "Erosion kernel size as a percentage of the mask radius area.\n" + "Positive values apply erosion which reduces the size of the swapped area.\n" + "Negative values apply dilation which increases the swapped area.", + rounding=1, + min_max=(-100.0, 100.0)) + +erosion_top = ConfigItem( + datatype=float, + default=0.0, + group="settings", + info="Apply erosion to the top part of the mask only.\n" + "Positive values apply erosion which pulls the mask into the center.\n" + "Negative values apply dilation which pushes the mask away from the center.", + rounding=1, + min_max=(-100.0, 100.0)) + +erosion_bottom = ConfigItem( + datatype=float, + default=0.0, + group="settings", + info="Apply erosion to the bottom part of the mask only.\n" + "Positive values apply erosion which pulls the mask into the center.\n" + "Negative values apply dilation which pushes the mask away from the center.", + rounding=1, + min_max=(-100.0, 100.0)) +erosion_left = ConfigItem( + default=0.0, + datatype=float, + group="settings", + info="Apply erosion to the left part of the mask only.\n" + "Positive values apply erosion which pulls the mask into the center.\n" + "Negative values apply dilation which pushes the mask away from the center.", + rounding=1, + min_max=(-100.0, 100.0)) -_HELPTEXT = "Options for blending the edges between the mask and the background image" - - -_DEFAULTS = dict( - type=dict( - default="normalized", - info="The type of blending to use:" - "\n\t gaussian: Blend with Gaussian filter. Slower, but often better than Normalized" - "\n\t normalized: Blend with Normalized box filter. Faster than Gaussian" - "\n\t none: Don't perform blending", - datatype=str, - rounding=None, - min_max=None, - choices=["gaussian", "normalized", "none"], - gui_radio=True, - group="Blending type", - fixed=True, - ), - kernel_size=dict( - default=3, - info="The kernel size dictates how much blending should occur.\n" - "The size is the diameter of the kernel in pixels (calculated from a 128px mask). " - "This value should be odd, if an even number is passed in then it will be rounded to " - "the next odd number. Higher sizes means more blending.", - datatype=int, - rounding=1, - min_max=(1, 9), - choices=[], - gui_radio=False, - group="settings", - fixed=True, - ), - passes=dict( - default=4, - info="The number of passes to perform. Additional passes of the blending algorithm can " - "improve smoothing at a time cost. This is more useful for 'box' type blending.\n" - "Additional passes have exponentially less effect so it's not worth setting this too " - "high.", - datatype=int, - rounding=1, - min_max=(1, 8), - choices=[], - gui_radio=False, - group="settings", - fixed=True, - ), - threshold=dict( - default=4, - info="Sets pixels that are near white to white and near black to black. Set to 0 for off.", - datatype=int, - rounding=1, - min_max=(0, 50), - choices=[], - gui_radio=False, - group="settings", - fixed=True, - ), - erosion=dict( - default=0.0, - info="Apply erosion to the whole of the face mask.\n" - "Erosion kernel size as a percentage of the mask radius area.\n" - "Positive values apply erosion which reduces the size of the swapped area.\n" - "Negative values apply dilation which increases the swapped area.", - datatype=float, - rounding=1, - min_max=(-100.0, 100.0), - choices=[], - gui_radio=False, - group="settings", - fixed=True, - ), - erosion_top=dict( - default=0.0, - info="Apply erosion to the top part of the mask only.\n" - "Positive values apply erosion which pulls the mask into the center.\n" - "Negative values apply dilation which pushes the mask away from the center.", - datatype=float, - rounding=1, - min_max=(-100.0, 100.0), - choices=[], - gui_radio=False, - group="settings", - fixed=True, - ), - erosion_bottom=dict( - default=0.0, - info="Apply erosion to the bottom part of the mask only.\n" - "Positive values apply erosion which pulls the mask into the center.\n" - "Negative values apply dilation which pushes the mask away from the center.", - datatype=float, - rounding=1, - min_max=(-100.0, 100.0), - choices=[], - gui_radio=False, - group="settings", - fixed=True, - ), - erosion_left=dict( - default=0.0, - info="Apply erosion to the left part of the mask only.\n" - "Positive values apply erosion which pulls the mask into the center.\n" - "Negative values apply dilation which pushes the mask away from the center.", - datatype=float, - rounding=1, - min_max=(-100.0, 100.0), - choices=[], - gui_radio=False, - group="settings", - fixed=True, - ), - erosion_right=dict( - default=0.0, - info="Apply erosion to the right part of the mask only.\n" - "Positive values apply erosion which pulls the mask into the center.\n" - "Negative values apply dilation which pushes the mask away from the center.", - datatype=float, - rounding=1, - min_max=(-100.0, 100.0), - choices=[], - gui_radio=False, - group="settings", - fixed=True, - ), -) +erosion_right = ConfigItem( + datatype=float, + default=0.0, + group="settings", + info="Apply erosion to the right part of the mask only.\n" + "Positive values apply erosion which pulls the mask into the center.\n" + "Negative values apply dilation which pushes the mask away from the center.", + rounding=1, + min_max=(-100.0, 100.0)) diff --git a/plugins/convert/scaling/_base.py b/plugins/convert/scaling/_base.py index 036ddc1557..db6f74407d 100644 --- a/plugins/convert/scaling/_base.py +++ b/plugins/convert/scaling/_base.py @@ -4,48 +4,30 @@ import logging import numpy as np -from plugins.convert._config import Config +from lib.logger import parse_class_init +from plugins.convert import convert_config logger = logging.getLogger(__name__) -def get_config(plugin_name, configfile=None): - """ Return the config for the requested model """ - return Config(plugin_name, configfile=configfile).config_dict - - class Adjustment(): """ Parent class for scaling adjustments """ - def __init__(self, configfile=None, config=None): - logger.debug("Initializing %s: (configfile: %s, config: %s)", - self.__class__.__name__, configfile, config) - self.config = self.set_config(configfile, config) - logger.debug("config: %s", self.config) + def __init__(self, configfile=None): + logger.debug(parse_class_init(locals())) + convert_config.load_config(config_file=configfile) logger.debug("Initialized %s", self.__class__.__name__) - def set_config(self, configfile, config): - """ Set the config to either global config or passed in config """ - section = ".".join(self.__module__.split(".")[-2:]) - if config is None: - logger.debug("Loading base config") - retval = get_config(section, configfile=configfile) - else: - logger.debug("Loading passed in config") - config.section = section - retval = config.config_dict - config.section = None - logger.debug("Config: %s", retval) - return retval - def process(self, new_face): """ Override for specific scaling adjustment process """ raise NotImplementedError def run(self, new_face): """ Perform selected adjustment on face """ - logger.trace("Performing scaling adjustment") + # pylint:disable=duplicate-code + logger.trace("Performing scaling adjustment") # type:ignore[attr-defined] # Remove Mask for processing reinsert_mask = False + final_mask = None if new_face.shape[2] == 4: reinsert_mask = True final_mask = new_face[:, :, -1] @@ -54,6 +36,7 @@ def run(self, new_face): new_face = np.clip(new_face, 0.0, 1.0) if reinsert_mask and new_face.shape[2] != 4: # Reinsert Mask + assert final_mask is not None new_face = np.concatenate((new_face, np.expand_dims(final_mask, axis=-1)), -1) - logger.trace("Performed scaling adjustment") + logger.trace("Performed scaling adjustment") # type:ignore[attr-defined] return new_face diff --git a/plugins/convert/scaling/sharpen.py b/plugins/convert/scaling/sharpen.py index 0179de9fd6..158165b873 100644 --- a/plugins/convert/scaling/sharpen.py +++ b/plugins/convert/scaling/sharpen.py @@ -3,58 +3,156 @@ import cv2 import numpy as np +from lib.utils import get_module_objects + from ._base import Adjustment, logger +from . import sharpen_defaults as cfg class Scaling(Adjustment): """ Sharpening Adjustments for the face applied after warp to final frame """ - def process(self, new_face): - """ Sharpen using the requested technique """ - amount = self.config["amount"] / 100.0 - kernel_center = self.get_kernel_size(new_face, self.config["radius"]) - new_face = getattr(self, self.config["method"])(new_face, kernel_center, amount) + def process(self, new_face: np.ndarray) -> np.ndarray: + """ Sharpen using the requested technique + + Parameters + ---------- + new_face : :class:`numpy.ndarray` + A batch of swapped image patch that is to have sharpening applied + + Returns + ------- + :class:`numpy.ndarray` + The batch of swapped faces with sharpening applied + """ + if cfg.method() == "none": + return new_face + amount = cfg.amount() / 100.0 + kernel, radius = self.get_kernel_size(new_face, cfg.radius()) + new_face = getattr(self, cfg.method())(new_face, kernel, radius, amount) return new_face - @staticmethod - def get_kernel_size(new_face, radius_percent): + @classmethod + def get_kernel_size(cls, + new_face: np.ndarray, + radius_percent: float) -> tuple[tuple[int, int], int]: """ Return the kernel size and central point for the given radius - relative to frame width """ + relative to frame width. + + Parameters + ---------- + new_face : :class:`numpy.ndarray` + The swapped image patch that is to have sharpening applied + + radius_percent : float + The percentage of the image size to use as the sharpening kernel + + Returns + ------- + kernel_size : tuple[int, int] + The sharpening kernel + radius : int + The pixel radius the kernel + """ radius = max(1, round(new_face.shape[1] * radius_percent / 100)) kernel_size = int((radius * 2) + 1) - kernel_size = (kernel_size, kernel_size) - logger.trace(kernel_size) - return kernel_size, radius - - @staticmethod - def box(new_face, kernel_center, amount): - """ Sharpen using box filter """ - kernel_size, center = kernel_center - kernel = np.zeros(kernel_size, dtype="float32") - kernel[center, center] = 1.0 + full_kernel_size = (kernel_size, kernel_size) + logger.trace(kernel_size) # type:ignore[attr-defined] + return full_kernel_size, radius + + @classmethod + def box(cls, + new_face: np.ndarray, + kernel_size: tuple[int, int], + radius: int, + amount: float) -> np.ndarray: + """ Sharpen using box filter + + Parameters + ---------- + new_face : :class:`numpy.ndarray` + The batch of swapped image patches that is to have sharpening applied + kernel_size : tuple[int, int] + The sharpening kernel size + radius : int + The pixel radius the kernel + amount : float + The amount of sharpening to apply + + Returns + ------- + :class:`numpy.ndarray` + The batch of swapped faces with box sharpening applied + """ + kernel: np.ndarray = np.zeros(kernel_size, dtype="float32") + kernel[radius, radius] = 1.0 box_filter = np.ones(kernel_size, dtype="float32") / kernel_size[0]**2 kernel = kernel + (kernel - box_filter) * amount - new_face = cv2.filter2D(new_face, -1, kernel) # pylint:disable=no-member + new_face = cv2.filter2D(new_face, -1, kernel) return new_face - @staticmethod - def gaussian(new_face, kernel_center, amount): - """ Sharpen using gaussian filter """ - kernel_size = kernel_center[0] - blur = cv2.GaussianBlur(new_face, kernel_size, 0) # pylint:disable=no-member - new_face = cv2.addWeighted(new_face, # pylint:disable=no-member + @classmethod + def gaussian(cls, + new_face: np.ndarray, + kernel_size: tuple[int, int], + radius: float, # pylint:disable=unused-argument + amount: float) -> np.ndarray: + """ Sharpen using gaussian filter + + Parameters + ---------- + new_face : :class:`numpy.ndarray` + The batch of swapped image patches that is to have sharpening applied + kernel_size : tuple[int, int] + The sharpening kernel size + radius : int + The pixel radius the kernel. Unused + amount : float + The amount of sharpening to apply + + Returns + ------- + :class:`numpy.ndarray` + The batch of swapped faces with gaussian sharpening applied + """ + blur = cv2.GaussianBlur(new_face, kernel_size, 0) + new_face = cv2.addWeighted(new_face, 1.0 + (0.5 * amount), blur, -(0.5 * amount), 0) return new_face - def unsharp_mask(self, new_face, kernel_center, amount): - """ Sharpen using unsharp mask """ - kernel_size = kernel_center[0] - threshold = self.config["threshold"] / 255.0 - blur = cv2.GaussianBlur(new_face, kernel_size, 0) # pylint:disable=no-member + @classmethod + def unsharp_mask(cls, + new_face: np.ndarray, + kernel_size: tuple[int, int], + center: float, # pylint:disable=unused-argument + amount: float) -> np.ndarray: + """ Sharpen using unsharp mask + + Parameters + ---------- + new_face : :class:`numpy.ndarray` + The batch of swapped image patches that is to have sharpening applied + kernel_size : tuple[int, int] + The sharpening kernel size + radius : int + The pixel radius the kernel. Unused + amount : float + The amount of sharpening to apply + + Returns + ------- + :class:`numpy.ndarray` + The batch of swapped faces with unsharp-mask sharpening applied + """ + threshold = cfg.threshold() / 255.0 + blur = cv2.GaussianBlur(new_face, kernel_size, 0) low_contrast_mask = (abs(new_face - blur) < threshold).astype("float32") sharpened = (new_face * (1.0 + amount)) + (blur * -amount) new_face = (new_face * (1.0 - low_contrast_mask)) + (sharpened * low_contrast_mask) return new_face + + +__all__ = get_module_objects(__name__) diff --git a/plugins/convert/scaling/sharpen_defaults.py b/plugins/convert/scaling/sharpen_defaults.py index 22a9d0a840..bd0adca59a 100755 --- a/plugins/convert/scaling/sharpen_defaults.py +++ b/plugins/convert/scaling/sharpen_defaults.py @@ -1,109 +1,81 @@ #!/usr/bin/env python3 -""" - The default options for the faceswap Sharpen Scaling plugin. +""" The default options for the faceswap Sharpen Scaling plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) - should always be lower text. - dictionary requirements are listed below. +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ +from lib.config import ConfigItem + + +HELPTEXT = "Options for sharpening the face after placement" + +method = ConfigItem( + datatype=str, + default="none", + group="sharpen type", + info="The type of sharpening to use:" + "\n\t none: Don't perform any sharpening." + "\n\t box: Fastest, but weakest method. Uses a box filter to assess edges." + "\n\t gaussian: Slower, but better than box. Uses a gaussian filter to assess edges." + "\n\t unsharp-mask: Slowest, but most tweakable. Uses the unsharp-mask method to " + "assess edges.", + choices=["none", "box", "gaussian", "unsharp_mask"], + gui_radio=True) -_HELPTEXT = "Options for sharpening the face after placement" +amount = ConfigItem( + datatype=int, + default=150, + group="settings", + info="Percentage that controls the magnitude of each overshoot (how much darker and how " + "much lighter the edge borders become).\nThis can also be thought of as how much " + "contrast is added at the edges. It does not affect the width of the edge rims.", + rounding=1, + min_max=(100, 500)) +radius = ConfigItem( + datatype=float, + default=0.3, + group="settings", + info="Affects the size of the edges to be enhanced or how wide the edge rims become, so a " + "smaller radius enhances smaller-scale detail.\nRadius is set as a percentage of the " + "final frame width and rounded to the nearest pixel. E.g for a 1280 width frame, a " + "0.6 percenatage will give a radius of 8px.\nHigher radius values can cause halos at " + "the edges, a detectable faint light rim around objects. Fine detail needs a smaller " + "radius. \nRadius and amount interact; reducing one allows more of the other.", + rounding=1, + min_max=(0.1, 5.0)) -_DEFAULTS = dict( - method=dict( - default="none", - info="The type of sharpening to use:" - "\n\t none: Don't perform any sharpening." - "\n\t box: Fastest, but weakest method. Uses a box filter to assess edges." - "\n\t gaussian: Slower, but better than box. Uses a gaussian filter to assess edges." - "\n\t unsharp-mask: Slowest, but most tweakable. Uses the unsharp-mask method to " - "assess edges.", - datatype=str, - rounding=None, - min_max=None, - choices=["none", "box", "gaussian", "unsharp_mask"], - gui_radio=True, - group="sharpen type", - fixed=True, - ), - amount=dict( - default=150, - info="Percentage that controls the magnitude of each overshoot (how much darker and how " - "much lighter the edge borders become).\nThis can also be thought of as how much " - "contrast is added at the edges. It does not affect the width of the edge rims.", - datatype=int, - rounding=1, - min_max=(100, 500), - choices=[], - gui_radio=False, - group="settings", - fixed=True, - ), - radius=dict( - default=0.3, - info="Affects the size of the edges to be enhanced or how wide the edge rims become, so a " - "smaller radius enhances smaller-scale detail.\nRadius is set as a percentage of the " - "final frame width and rounded to the nearest pixel. E.g for a 1280 width frame, a " - "0.6 percenatage will give a radius of 8px.\nHigher radius values can cause halos at " - "the edges, a detectable faint light rim around objects. Fine detail needs a smaller " - "radius. \nRadius and amount interact; reducing one allows more of the other.", - datatype=float, - rounding=1, - min_max=(0.1, 5.0), - choices=[], - gui_radio=False, - group="settings", - fixed=True, - ), - threshold=dict( - default=5.0, - info="[unsharp_mask only] Controls the minimal brightness change that will be sharpened " - "or how far apart adjacent tonal values have to be before the filter does anything.\n" - "This lack of action is important to prevent smooth areas from becoming speckled. " - "The threshold setting can be used to sharpen more pronounced edges, while leaving " - "subtler edges untouched. \nLow values should sharpen more because fewer areas are " - "excluded. \nHigher threshold values exclude areas of lower contrast.", - datatype=float, - rounding=1, - min_max=(1.0, 10.0), - choices=[], - gui_radio=False, - group="settings", - fixed=True, - ), -) +threshold = ConfigItem( + datatype=float, + default=5.0, + group="settings", + info="[unsharp_mask only] Controls the minimal brightness change that will be sharpened " + "or how far apart adjacent tonal values have to be before the filter does anything.\n" + "This lack of action is important to prevent smooth areas from becoming speckled. " + "The threshold setting can be used to sharpen more pronounced edges, while leaving " + "subtler edges untouched. \nLow values should sharpen more because fewer areas are " + "excluded. \nHigher threshold values exclude areas of lower contrast.", + rounding=1, + min_max=(1.0, 10.0)) diff --git a/plugins/convert/writer/_base.py b/plugins/convert/writer/_base.py index a67ebee28e..0cce2cf748 100644 --- a/plugins/convert/writer/_base.py +++ b/plugins/convert/writer/_base.py @@ -8,30 +8,12 @@ import numpy as np -from plugins.convert._config import Config +from lib.logger import parse_class_init +from plugins.convert import convert_config logger = logging.getLogger(__name__) -def get_config(plugin_name: str, configfile: str | None = None) -> dict: - """ Obtain the configuration settings for the writer plugin. - - Parameters - ---------- - plugin_name: str - The name of the convert plugin to return configuration settings for - configfile: str, optional - The full path to a custom configuration ini file. If ``None`` is passed - then the file is loaded from the default location. Default: ``None``. - - Returns - ------- - dict - The requested configuration dictionary - """ - return Config(plugin_name, configfile=configfile).config_dict - - class Output(): """ Parent class for writer plugins. @@ -44,11 +26,8 @@ class Output(): then the file is loaded from the default location. Default: ``None``. """ def __init__(self, output_folder: str, configfile: str | None = None) -> None: - logger.debug("Initializing %s: (output_folder: '%s')", - self.__class__.__name__, output_folder) - self.config: dict = get_config(".".join(self.__module__.split(".")[-2:]), - configfile=configfile) - logger.debug("config: %s", self.config) + logger.debug(parse_class_init(locals())) + convert_config.load_config(config_file=configfile) self.output_folder: str = output_folder # For creating subfolders when separate mask is selected @@ -68,6 +47,12 @@ def is_stream(self) -> bool: retval = hasattr(self, "_frame_order") return retval + @property + def output_alpha(self) -> bool: + """ bool : Override if the plugin can output an alpha channel and the user configuration + option is set to use it. Default ``False`` """ + return False + @classmethod def _set_frame_order(cls, total_count: int, @@ -98,17 +83,19 @@ def _set_frame_order(cls, logger.debug("frame_order: %s", retval) return retval - def output_filename(self, filename: str, separate_mask: bool = False) -> list[str]: + def get_output_filename(self, + filename: str, + extension: str, + separate_mask: bool = False) -> list[str]: """ Obtain the full path for the output file, including the correct extension, for the given input filename. - NB: The plugin must have a config item 'format' that contains the file extension to use - this method. - Parameters ---------- - filename: str + filename : str The input frame filename to generate the output file name for + extension : str + The extension to use for the output file separate_mask: bool, optional ``True`` if the mask should be saved out to a sub-folder otherwise ``False`` @@ -118,8 +105,9 @@ def output_filename(self, filename: str, separate_mask: bool = False) -> list[st The full path for the output converted frame to be saved to in position 1. The full path for the mask to be output to in position 2 (if requested) """ + extension = extension.strip(".") filename = os.path.splitext(os.path.basename(filename))[0] - out_filename = f"{filename}.{self.config['format']}" + out_filename = f"{filename}.{extension}" retval = [os.path.join(self.output_folder, out_filename)] if separate_mask: retval.append(os.path.join(self.output_folder, "masks", out_filename)) diff --git a/plugins/convert/writer/ffmpeg.py b/plugins/convert/writer/ffmpeg.py index 92143a13bd..50d7407c68 100644 --- a/plugins/convert/writer/ffmpeg.py +++ b/plugins/convert/writer/ffmpeg.py @@ -11,7 +11,10 @@ import imageio_ffmpeg as im_ffm import numpy as np +from lib.utils import get_module_objects + from ._base import Output, logger +from . import ffmpeg_defaults as cfg if T.TYPE_CHECKING: from collections.abc import Generator @@ -70,22 +73,22 @@ def _video_fps(self) -> float: @property def _output_params(self) -> list[str]: """ list: The FFMPEG Output parameters """ - codec = self.config["codec"] - tune = self.config["tune"] + codec = cfg.codec() + tune = cfg.tune() # Force all frames to the same size output_args = ["-vf", f"scale={self._output_dimensions}"] - output_args.extend(["-crf", str(self.config["crf"])]) - output_args.extend(["-preset", self.config["preset"]]) + output_args.extend(["-crf", str(cfg.crf())]) + output_args.extend(["-preset", cfg.preset()]) if tune is not None and tune in self._valid_tunes[codec]: output_args.extend(["-tune", tune]) - if codec == "libx264" and self.config["profile"] != "auto": - output_args.extend(["-profile:v", self.config["profile"]]) + if codec == "libx264" and cfg.profile() != "auto": + output_args.extend(["-profile:v", cfg.profile()]) - if codec == "libx264" and self.config["level"] != "auto": - output_args.extend(["-level", self.config["level"]]) + if codec == "libx264" and cfg.level() != "auto": + output_args.extend(["-level", cfg.level()]) logger.debug(output_args) return output_args @@ -96,7 +99,7 @@ def _audio_codec(self) -> str | None: or ``None`` if skip muxing has been selected in configuration options, or if frame ranges have been passed in the command line arguments. """ retval: str | None = "copy" - if self.config["skip_mux"]: + if cfg.skip_mux(): logger.info("Skipping audio muxing due to configuration settings.") retval = None elif self._frame_ranges is not None: @@ -163,7 +166,7 @@ def _get_output_filename(self) -> str: """ filename = os.path.basename(self._source_video) filename = os.path.splitext(filename)[0] - ext = self.config["container"] + ext = cfg.container() idx = 0 while True: out_file = f"{filename}_converted{'' if idx == 0 else f'_{idx}'}.{ext}" @@ -189,13 +192,13 @@ def _get_writer(self, frame_dims: tuple[int, int]) -> Generator[None, np.ndarray """ audio_codec = self._audio_codec audio_path = None if audio_codec is None else self._source_video - logger.debug("writer config: %s, audio_path: '%s'", self.config, audio_path) + logger.debug("writer audio_path: '%s'", audio_path) retval = im_ffm.write_frames(self._output_filename, size=(frame_dims[1], frame_dims[0]), fps=self._video_fps, quality=None, - codec=self.config["codec"], + codec=cfg.codec(), macro_block_size=8, ffmpeg_log_level="error", ffmpeg_timeout=10, @@ -261,3 +264,6 @@ def close(self) -> None: """ Close the ffmpeg writer and mux the audio """ if self._writer is not None: self._writer.close() + + +__all__ = get_module_objects(__name__) diff --git a/plugins/convert/writer/ffmpeg_defaults.py b/plugins/convert/writer/ffmpeg_defaults.py index 163d1b7a05..3ea4e820cc 100755 --- a/plugins/convert/writer/ffmpeg_defaults.py +++ b/plugins/convert/writer/ffmpeg_defaults.py @@ -1,148 +1,115 @@ #!/usr/bin/env python3 +""" The default options for the faceswap Ffmpeg Writer plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: + + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does + +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) + +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ - The default options for the faceswap Ffmpeg Writer plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} - - should always be lower text. - dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" +from lib.config import ConfigItem + + +HELPTEXT = "Options for encoding converted frames to video." + + +container = ConfigItem( + datatype=str, + default="mp4", + group="codec", + info="Video container to use.", + choices=["avi", "flv", "mkv", "mov", "mp4", "mpeg", "webm"], + gui_radio=True) + +codec = ConfigItem( + datatype=str, + default="libx264", + group="codec", + info="Video codec to use:" + "\n\t libx264: H.264. A widely supported and commonly used codec." + "\n\t libx265: H.265 / HEVC video encoder application library.", + choices=["libx264", "libx265"], + gui_radio=True) + +crf = ConfigItem( + datatype=int, + default=23, + group="quality", + info="Constant Rate Factor: 0 is lossless and 51 is worst quality possible. A " + "lower value generally leads to higher quality, and a subjectively sane range " + "is 17-28. Consider 17 or 18 to be visually lossless or nearly so; it should " + "look the same or nearly the same as the input but it isn't technically " + "lossless.\nThe range is exponential, so increasing the CRF value +6 results " + "in roughly half the bitrate / file size, while -6 leads to roughly twice the " + "bitrate.", + rounding=1, + min_max=(0, 51)) + +preset = ConfigItem( + datatype=str, + default="medium", + group="quality", + info="A preset is a collection of options that will provide a certain encoding " + "speed to compression ratio.\nA slower preset will provide better compression " + "(compression is quality per filesize).\nUse the slowest preset that you have " + "patience for.", + choices=["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", + "slower", "veryslow"], + gui_radio=True) + +tune = ConfigItem( + datatype=str, + default="none", + group="settings", + info="Change settings based upon the specifics of your input:" + "\n\t none: Don't perform any additional tuning." + "\n\t film: [H.264 only] Use for high quality movie content; lowers deblocking." + "\n\t animation: [H.264 only] Good for cartoons; uses higher deblocking and more " + "reference frames." + "\n\t grain: Preserves the grain structure in old, grainy film material." + "\n\t stillimage: [H.264 only] Good for slideshow-like content." + "\n\t fastdecode: Allows faster decoding by disabling certain filters." + "\n\t zerolatency: Good for fast encoding and low-latency streaming.", + choices=["none", "film", "animation", "grain", "stillimage", "fastdecode", "zerolatency"]) + +profile = ConfigItem( + datatype=str, + default="auto", + group="settings", + info="[H.264 Only] Limit the output to a specific H.264 profile. Don't change this " + "unless your target device only supports a certain profile.", + choices=["auto", "baseline", "main", "high", "high10", "high422", "high444"]) +level = ConfigItem( + datatype=str, + default="auto", + group="settings", + info="[H.264 Only] Set the encoder level, Don't change this unless your target " + "device only supports a certain level.", + choices=["auto", "1", "1b", "1.1", "1.2", "1.3", "2", "2.1", "2.2", "3", "3.1", "3.2", "4", + "4.1", "4.2", "5", "5.1", "5.2", "6", "6.1", "6.2"]) -_HELPTEXT = "Options for encoding converted frames to video." - - -_DEFAULTS = dict( - container=dict( - default="mp4", - info="Video container to use.", - datatype=str, - rounding=None, - min_max=None, - choices=["avi", "flv", "mkv", "mov", "mp4", "mpeg", "webm"], - group="codec", - gui_radio=True, - ), - codec=dict( - default="libx264", - info="Video codec to use:" - "\n\t libx264: H.264. A widely supported and commonly used codec." - "\n\t libx265: H.265 / HEVC video encoder application library.", - datatype=str, - rounding=None, - min_max=None, - choices=["libx264", "libx265"], - group="codec", - gui_radio=True, - ), - crf=dict( - default=23, - info="Constant Rate Factor: 0 is lossless and 51 is worst quality possible. A " - "lower value generally leads to higher quality, and a subjectively sane range " - "is 17-28. Consider 17 or 18 to be visually lossless or nearly so; it should " - "look the same or nearly the same as the input but it isn't technically " - "lossless.\nThe range is exponential, so increasing the CRF value +6 results " - "in roughly half the bitrate / file size, while -6 leads to roughly twice the " - "bitrate.", - datatype=int, - rounding=1, - min_max=(0, 51), - choices=[], - gui_radio=False, - group="quality", - ), - preset=dict( - default="medium", - info="A preset is a collection of options that will provide a certain encoding " - "speed to compression ratio.\nA slower preset will provide better compression " - "(compression is quality per filesize).\nUse the slowest preset that you have " - "patience for.", - datatype=str, - rounding=None, - min_max=None, - choices=["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", - "slower", "veryslow"], - gui_radio=True, - group="quality", - ), - tune=dict( - default="none", - info="Change settings based upon the specifics of your input:" - "\n\t none: Don't perform any additional tuning." - "\n\t film: [H.264 only] Use for high quality movie content; lowers deblocking." - "\n\t animation: [H.264 only] Good for cartoons; uses higher deblocking and more " - "reference frames." - "\n\t grain: Preserves the grain structure in old, grainy film material." - "\n\t stillimage: [H.264 only] Good for slideshow-like content." - "\n\t fastdecode: Allows faster decoding by disabling certain filters." - "\n\t zerolatency: Good for fast encoding and low-latency streaming.", - datatype=str, - rounding=None, - min_max=None, - choices=["none", "film", "animation", "grain", "stillimage", "fastdecode", "zerolatency"], - gui_radio=False, - group="settings", - ), - profile=dict( - default="auto", - info="[H.264 Only] Limit the output to a specific H.264 profile. Don't change this " - "unless your target device only supports a certain profile.", - datatype=str, - rounding=None, - min_max=None, - choices=["auto", "baseline", "main", "high", "high10", "high422", "high444"], - gui_radio=False, - group="settings", - ), - level=dict( - default="auto", - info="[H.264 Only] Set the encoder level, Don't change this unless your target " - "device only supports a certain level.", - datatype=str, - rounding=None, - min_max=None, - choices=["auto", "1", "1b", "1.1", "1.2", "1.3", "2", "2.1", "2.2", "3", "3.1", "3.2", "4", - "4.1", "4.2", "5", "5.1", "5.2", "6", "6.1", "6.2"], - gui_radio=False, - group="settings", - ), - skip_mux=dict( - default=False, - info="Skip muxing audio to the final video output. This will result in a video without an " - "audio track.", - datatype=bool, - group="settings", - ), -) +skip_mux = ConfigItem( + datatype=bool, + default=False, + group="settings", + info="Skip muxing audio to the final video output. This will result in a video without an " + "audio track.") diff --git a/plugins/convert/writer/gif.py b/plugins/convert/writer/gif.py index 7a75ec93f3..d00171f196 100644 --- a/plugins/convert/writer/gif.py +++ b/plugins/convert/writer/gif.py @@ -7,7 +7,10 @@ import cv2 import imageio +from lib.utils import get_module_objects + from ._base import Output, logger +from . import gif_defaults as cfg if T.TYPE_CHECKING: from imageio.core import format as im_format # noqa:F401 @@ -46,7 +49,10 @@ def __init__(self, @property def _gif_params(self) -> dict: """ dict: The selected gif plugin configuration options. """ - kwargs = {key: int(val) for key, val in self.config.items()} + kwargs = {"fps": cfg.fps(), + "loop": cfg.loop(), + "palettesize": cfg.palettesize(), + "subrectangles": cfg.subrectangles()} logger.debug(kwargs) return kwargs @@ -58,7 +64,6 @@ def _get_writer(self) -> im_format.Format.Writer: :class:`imageio.plugins.pillowmulti.GIFFormat.Writer` The imageio GIF writer """ - logger.debug("writer config: %s", self.config) assert self._gif_file is not None return imageio.get_writer(self._gif_file, mode="i", @@ -124,6 +129,7 @@ def _set_dimensions(self, frame_dims: tuple[int, int]) -> None: """ Set the attribute :attr:`_output_dimensions` based on the first frame received. This protects against different sized images coming in and ensure all images get written to the Gif at the sema dimensions. """ + # pylint:disable=duplicate-code logger.debug("input dimensions: %s", frame_dims) self._output_dimensions = (frame_dims[1], frame_dims[0]) logger.debug("Set dimensions: %s", self._output_dimensions) @@ -131,6 +137,7 @@ def _set_dimensions(self, frame_dims: tuple[int, int]) -> None: def _save_from_cache(self) -> None: """ Writes any consecutive frames to the GIF container that are ready to be output from the cache. """ + # pylint:disable=duplicate-code assert self._writer is not None while self._frame_order: if self._frame_order[0] not in self.cache: @@ -146,3 +153,6 @@ def close(self) -> None: """ Close the GIF writer on completion. """ if self._writer is not None: self._writer.close() + + +__all__ = get_module_objects(__name__) diff --git a/plugins/convert/writer/gif_defaults.py b/plugins/convert/writer/gif_defaults.py index ad342b6bee..c0dd27c580 100755 --- a/plugins/convert/writer/gif_defaults.py +++ b/plugins/convert/writer/gif_defaults.py @@ -1,94 +1,63 @@ #!/usr/bin/env python3 -""" - The default options for the faceswap Gif Writer plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. +""" The default options for the faceswap Gif Writer plugin. - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} +Defaults files should be named `_defaults.py` - should always be lower text. - dictionary requirements are listed below. +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" +The following variable should be defined: + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does -_HELPTEXT = "Options for outputting converted frames to an animated gif." +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. -_DEFAULTS = dict( - fps=dict( - default=25, - info="Frames per Second.", - datatype=int, - rounding=1, - min_max=(1, 60), - choices=[], - group="settings", - gui_radio=False, - fixed=True, - ), - loop=dict( - default=0, - info="The number of iterations. Set to 0 to loop indefinitely.", - datatype=int, - rounding=1, - min_max=(0, 100), - choices=[], - group="settings", - gui_radio=False, - fixed=True, - ), - palettesize=dict( - default="256", - info="The number of colors to quantize the image to. Is rounded to the nearest power of " - "two.", - datatype=str, - rounding=None, - min_max=None, - choices=["2", "4", "8", "16", "32", "64", "128", "256"], - group="settings", - gui_radio=False, - fixed=True, - ), - subrectangles=dict( - default=False, - info="If True, will try and optimize the GIF by storing only the rectangular parts of " - "each frame that change with respect to the previous.", - datatype=bool, - rounding=None, - min_max=None, - choices=[], - group="settings", - gui_radio=False, - fixed=True, - ), -) +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem +""" +from lib.config import ConfigItem + + +HELPTEXT = "Options for outputting converted frames to an animated gif." + + +fps = ConfigItem( + datatype=int, + default=25, + group="settings", + info="Frames per Second.", + rounding=1, + min_max=(1, 60)) + +loop = ConfigItem( + datatype=int, + default=0, + group="settings", + info="The number of iterations. Set to 0 to loop indefinitely.", + rounding=1, + min_max=(0, 100)) + +palettesize = ConfigItem( + datatype=str, + default="256", + group="settings", + info="The number of colors to quantize the image to. Is rounded to the nearest power of " + "two.", + choices=["2", "4", "8", "16", "32", "64", "128", "256"]) + +subrectangles = ConfigItem( + datatype=bool, + default=False, + group="settings", + info="If True, will try and optimize the GIF by storing only the rectangular parts of " + "each frame that change with respect to the previous.") diff --git a/plugins/convert/writer/opencv.py b/plugins/convert/writer/opencv.py index 17b025bfd9..29752551af 100644 --- a/plugins/convert/writer/opencv.py +++ b/plugins/convert/writer/opencv.py @@ -2,10 +2,14 @@ """ Image output writer for faceswap.py converter Uses cv2 for writing as in testing this was a lot faster than both Pillow and ImageIO """ +import typing as T + import cv2 import numpy as np +from lib.utils import get_module_objects from ._base import Output, logger +from . import opencv_defaults as cfg class Writer(Output): @@ -21,19 +25,23 @@ class Writer(Output): """ def __init__(self, output_folder: str, **kwargs) -> None: super().__init__(output_folder, **kwargs) - self._extension = f".{self.config['format']}" + self._extension = f".{cfg.format()}" self._check_transparency_format() - self._separate_mask = self.config["draw_transparent"] and self.config["separate_mask"] + self._separate_mask = self.output_alpha and cfg.separate_mask() self._args = self._get_save_args() + @property + def output_alpha(self) -> bool: + """ bool : OpenCV can output alpha channel. """ + return cfg.draw_transparent() + def _check_transparency_format(self) -> None: """ Make sure that the output format is correct if draw_transparent is selected """ - transparent = self.config["draw_transparent"] - if not transparent or (transparent and self.config["format"] == "png"): + if not self.output_alpha or (self.output_alpha and cfg.format() == "png"): return logger.warning("Draw Transparent selected, but the requested format does not support " "transparency. Changing output format to 'png'") - self.config["format"] = "png" + cfg.format.set("png") def _get_save_args(self) -> tuple[int, ...]: """ Obtain the save parameters for the file format. @@ -43,14 +51,14 @@ def _get_save_args(self) -> tuple[int, ...]: tuple The OpenCV specific arguments for the selected file format """ - filetype = self.config["format"] + filetype = cfg.format() args: tuple[int, ...] = tuple() - if filetype == "jpg" and self.config["jpg_quality"] > 0: + if filetype == "jpg" and cfg.jpg_quality() > 0: args = (cv2.IMWRITE_JPEG_QUALITY, - self.config["jpg_quality"]) - if filetype == "png" and self.config["png_compress_level"] > -1: + cfg.jpg_quality()) + if filetype == "png" and cfg.png_compress_level() > -1: args = (cv2.IMWRITE_PNG_COMPRESSION, - self.config["png_compress_level"]) + cfg.png_compress_level()) logger.debug(args) return args @@ -67,7 +75,8 @@ def write(self, filename: str, image: list[bytes]) -> None: or length 2 (containing the image and mask to write out) """ logger.trace("Outputting: (filename: '%s'", filename) # type:ignore - filenames = self.output_filename(filename, self._separate_mask) + filenames = self.get_output_filename(filename, cfg.format(), self._separate_mask) + # pylint:disable=duplicate-code for fname, img in zip(filenames, image): try: with open(fname, "wb") as outfile: @@ -104,8 +113,11 @@ def pre_encode(self, image: np.ndarray, **kwargs) -> list[bytes]: retval.insert(0, cv2.imencode(self._extension, image, self._args)[1]) - return retval + return T.cast(list[bytes], retval) def close(self) -> None: """ Does nothing as OpenCV writer does not need a close method """ return + + +__all__ = get_module_objects(__name__) diff --git a/plugins/convert/writer/opencv_defaults.py b/plugins/convert/writer/opencv_defaults.py index 67022e1ae0..61ea6b9feb 100755 --- a/plugins/convert/writer/opencv_defaults.py +++ b/plugins/convert/writer/opencv_defaults.py @@ -1,120 +1,86 @@ #!/usr/bin/env python3 -""" - The default options for the faceswap Opencv Writer plugin. +""" The default options for the faceswap Opencv Writer plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. +The following variable should be defined: - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does - should always be lower text. - dictionary requirements are listed below. +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ +# pylint:disable=duplicate-code +from lib.config import ConfigItem -_HELPTEXT = ( +HELPTEXT = ( "Options for outputting converted frames to a series of images using OpenCV\n" "OpenCV can be faster than other image writers, but lacks some configuration " "options and formats." ) -_DEFAULTS = dict( - format=dict( - default="png", - info="Image format to use:" - "\n\t bmp: Windows bitmap" - "\n\t jpg: JPEG format" - "\n\t jp2: JPEG 2000 format" - "\n\t png: Portable Network Graphics" - "\n\t ppm: Portable Pixmap Format", - datatype=str, - rounding=None, - min_max=None, - choices=["bmp", "jpg", "jp2", "png", "ppm"], - group="format", - gui_radio=True, - fixed=True, - ), - draw_transparent=dict( - default=False, - info="Place the swapped face on a transparent layer rather than the original frame.\nNB: " - "This is only compatible with images saved in png format. If an incompatible format " - "is selected then the image will be saved as a png.", - datatype=bool, - rounding=None, - min_max=None, - choices=[], - group="format", - gui_radio=False, - fixed=True, - ), - separate_mask=dict( - default=False, - info="Seperate the mask into its own single channel image. This only applies when " - "'draw-transparent' is selected. If enabled, the RGB image will be saved into the " - "selected output folder whilst the masks will be saved into a sub-folder named " - "`masks`. If not enabled then the mask will be included in the alpha-channel of the " - "RGBA output.", - datatype=bool, - rounding=None, - min_max=None, - choices=[], - group="format", - gui_radio=False, - fixed=True, - ), - jpg_quality=dict( - default=75, - info="[jpg only] Set the jpg quality. 1 is worst 95 is best. Higher quality leads to " - "larger file sizes.", - datatype=int, - rounding=1, - min_max=(1, 95), - choices=[], - group="compression", - gui_radio=False, - fixed=True, - ), - png_compress_level=dict( - default=3, - info="[png only] ZLIB compression level, 1 gives best speed, 9 gives best compression, 0 " - "gives no compression at all.", - datatype=int, - rounding=1, - min_max=(0, 9), - choices=[], - group="compression", - gui_radio=False, - fixed=True, - ), -) +format = ConfigItem( # pylint:disable=redefined-builtin + datatype=str, + default="png", + group="format", + info="Image format to use:" + "\n\t bmp: Windows bitmap" + "\n\t jpg: JPEG format" + "\n\t jp2: JPEG 2000 format" + "\n\t png: Portable Network Graphics" + "\n\t ppm: Portable Pixmap Format", + choices=["bmp", "jpg", "jp2", "png", "ppm"], + gui_radio=True) + +draw_transparent = ConfigItem( + datatype=bool, + default=False, + group="format", + info="Place the swapped face on a transparent layer rather than the original frame.\nNB: " + "This is only compatible with images saved in png format. If an incompatible format " + "is selected then the image will be saved as a png.") + +separate_mask = ConfigItem( + datatype=bool, + default=False, + group="format", + info="Seperate the mask into its own single channel image. This only applies when " + "'draw-transparent' is selected. If enabled, the RGB image will be saved into the " + "selected output folder whilst the masks will be saved into a sub-folder named " + "`masks`. If not enabled then the mask will be included in the alpha-channel of the " + "RGBA output.") + +jpg_quality = ConfigItem( + datatype=int, + default=75, + group="compression", + info="[jpg only] Set the jpg quality. 1 is worst 95 is best. Higher quality leads to " + "larger file sizes.", + rounding=1, + min_max=(1, 95)) + +png_compress_level = ConfigItem( + datatype=int, + default=3, + group="compression", + info="[png only] ZLIB compression level, 1 gives best speed, 9 gives best compression, 0 " + "gives no compression at all.", + rounding=1, + min_max=(0, 9)) diff --git a/plugins/convert/writer/patch.py b/plugins/convert/writer/patch.py index fe4a7f4bb1..01f00d7c58 100644 --- a/plugins/convert/writer/patch.py +++ b/plugins/convert/writer/patch.py @@ -6,13 +6,16 @@ import json import logging import re +import typing as T import os import cv2 import numpy as np from lib.image import encode_image, png_read_meta, tiff_read_meta +from lib.utils import get_module_objects from ._base import Output +from . import patch_defaults as cfg logger = logging.getLogger(__name__) @@ -33,17 +36,17 @@ class Writer(Output): def __init__(self, output_folder: str, patch_size: int, **kwargs) -> None: logger.debug("patch_size: %s", patch_size) super().__init__(output_folder, **kwargs) - self._extension = {"png": ".png", "tiff": ".tif"}[self.config["format"]] - self._separate_mask = self.config["separate_mask"] + self._extension = {"png": ".png", "tiff": ".tif"}[cfg.format()] + self._separate_mask = cfg.separate_mask() self._fname_split = re.compile("[^0-9a-zA-Z]") - if self._extension == ".png" and self.config["bit_depth"] not in ("8", "16"): + if self._extension == ".png" and cfg.bit_depth() not in ("8", "16"): logger.warning("Patch Writer: Bit Depth '%s' is unsupported for format '%s'. " - "Updating to '16'", self.config["bit_depth"], self.config["format"]) - self.config["bit_depth"] = "16" + "Updating to '16'", cfg.bit_depth(), cfg.format()) + cfg.bit_depth.set("16") - self._dtype = {"8": np.uint8, "16": np.uint16, "32": np.float32}[self.config["bit_depth"]] - self._multiplier = {"8": 255., "16": 65535., "32": 1.}[self.config["bit_depth"]] + self._dtype = {"8": np.uint8, "16": np.uint16, "32": np.float32}[cfg.bit_depth()] + self._multiplier = {"8": 255., "16": 65535., "32": 1.}[cfg.bit_depth()] self._dummy_patch = np.zeros((1, patch_size, patch_size, 4), dtype=np.float32) @@ -52,9 +55,9 @@ def __init__(self, output_folder: str, patch_size: int, **kwargs) -> None: self._patch_corner = {"top-left": tl_box[0], "top-right": tl_box[1], "bottom-right": tl_box[2], - "bottom-left": tl_box[3]}[self.config["origin"]].copy() + "bottom-left": tl_box[3]}[cfg.origin()].copy() self._box = tl_box - if self.config["origin"] in ("top-right", "bottom-left"): + if cfg.origin() in ("top-right", "bottom-left"): self._box[[1, 3], :] = self._box[[3, 1], :] # keep clockwise from 0,0 self._args = self._get_save_args() @@ -69,11 +72,11 @@ def _get_save_args(self) -> tuple[int, ...]: The OpenCV specific arguments for the selected file format """ args: tuple[int, ...] = tuple() - if self._extension == ".png" and self.config["png_compress_level"] > -1: - args = (cv2.IMWRITE_PNG_COMPRESSION, self.config["png_compress_level"]) - if self._extension == ".tif" and self.config["bit_depth"] != "32": + if self._extension == ".png" and cfg.png_compress_level() > -1: + args = (cv2.IMWRITE_PNG_COMPRESSION, cfg.png_compress_level()) + if self._extension == ".tif" and cfg.bit_depth() != "32": tiff_methods = {"none": 1, "lzw": 5, "deflate": 8} - method = self.config["tiff_compression_method"] + method = cfg.tiff_compression_method() method = "none" if method is None else method args = (cv2.IMWRITE_TIFF_COMPRESSION, tiff_methods[method]) logger.debug(args) @@ -102,21 +105,21 @@ def _get_new_filename(self, filename: str, face_index: int) -> str: split_fname = self._fname_split.split(fname) if split_fname and split_fname[-1].isdigit(): i_frame_no = (int(split_fname[-1]) + - (int(self.config["start_index"]) - 1) + - self.config["index_offset"]) - frame_no = f".{str(i_frame_no).rjust(self.config['number_padding'], '0')}" + (int(cfg.start_index()) - 1) + + cfg.index_offset()) + frame_no = f".{str(i_frame_no).rjust(cfg.number_padding(), '0')}" base_fname = fname[:-len(split_fname[-1]) - 1] else: frame_no = "" base_fname = fname retval = "" - if self.config["include_filename"]: + if cfg.include_filename(): retval += base_fname - if self.config["face_index_location"] == "before": + if cfg.face_index_location() == "before": retval = f"{retval}_{face_idx}" retval += frame_no - if self.config["face_index_location"] == "after": + if cfg.face_index_location() == "after": retval = f"{retval}.{face_idx}" retval += ext logger.trace("source filename: '%s', output filename: '%s'", # type:ignore[attr-defined] @@ -141,16 +144,16 @@ def write(self, filename: str, image: list[list[bytes]]) -> None: read_func = png_read_meta if self._extension == ".png" else tiff_read_meta for idx, face in enumerate(image): new_filename = self._get_new_filename(filename, idx) - filenames = self.output_filename(new_filename, self._separate_mask) + filenames = self.get_output_filename(new_filename, cfg.format(), self._separate_mask) for fname, img in zip(filenames, face): try: with open(fname, "wb") as outfile: outfile.write(img) except Exception as err: # pylint:disable=broad-except logger.error("Failed to save image '%s'. Original Error: %s", filename, err) - if not self.config["json_output"]: + if not cfg.json_output(): continue - mat = read_func(img) + mat = T.cast(dict[str, list[list[float]]], read_func(img)) self._matrices[os.path.splitext(os.path.basename(fname))[0]] = mat @classmethod @@ -188,19 +191,19 @@ def _adjust_to_origin(self, matrices: np.ndarray, canvas_size: tuple[int, int]) canvas_size: tuple[int, int] The size of the canvas width, height) that the transformation matrix applies to. """ - if self.config["origin"] == "top-left": + if cfg.origin() == "top-left": return for mat in matrices: og_cnr = cv2.transform(self._patch_corner[None, None], mat[:2, ...]).squeeze() x_shift, y_shift = og_cnr - if self.config["origin"].split("-")[-1] == "right": + if cfg.origin().split("-")[-1] == "right": x_shift = canvas_size[0] - x_shift - if self.config["origin"].split("-")[0] == "bottom": + if cfg.origin().split("-")[0] == "bottom": y_shift = canvas_size[1] - y_shift mat[:2, 2] = [x_shift, y_shift] - if self.config["origin"] in ("top-right", "bottom-left"): + if cfg.origin() in ("top-right", "bottom-left"): matrices[..., :2, :2] *= [[[1, -1], [-1, 1]]] # switch shear def _get_roi(self, matrices: np.ndarray) -> np.ndarray: @@ -247,7 +250,7 @@ def pre_encode(self, image: np.ndarray, **kwargs) -> list[list[bytes]]: canvas_size: tuple[int, int] = kwargs.get("canvas_size", (1, 1)) matrices: np.ndarray = kwargs.get("matrices", np.array([])) - if not np.any(image) and self.config["empty_frames"] == "blank": + if not np.any(image) and cfg.empty_frames() == "blank": image = self._dummy_patch matrices = self._get_inverse_matrices(matrices) @@ -279,9 +282,12 @@ def pre_encode(self, image: np.ndarray, **kwargs) -> list[list[bytes]]: def close(self) -> None: """ Outputs json file if requested """ - if not self.config["json_output"]: + if not cfg.json_output(): return fname = os.path.join(self.output_folder, "matrices.json") with open(fname, "w", encoding="utf-8") as ofile: json.dump(self._matrices, ofile, indent=2, sort_keys=True) logger.info("Patch matrices written to: '%s'", fname) + + +__all__ = get_module_objects(__name__) diff --git a/plugins/convert/writer/patch_defaults.py b/plugins/convert/writer/patch_defaults.py index 76f3f4c6e2..4febc3f770 100755 --- a/plugins/convert/writer/patch_defaults.py +++ b/plugins/convert/writer/patch_defaults.py @@ -1,182 +1,167 @@ #!/usr/bin/env python3 +""" The default options for the faceswap patch Writer plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: + + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does + +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) + +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ - The default options for the faceswap patch Writer plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} - - should always be lower text. - dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" +from lib.config import ConfigItem + -_HELPTEXT = ( +HELPTEXT = ( "Options for outputting the raw converted face patches from faceswap\n" "The raw face patches are output along with the transformation matrix, per face, to " "transform the face back into the original frame in external tools" ) -_DEFAULTS = { - "start_index": { - "default": "0", - "info": "The starting frame number for the first output frame.", - "datatype": str, - "choices": ["0", "1"], - "group": "file_naming", - "gui_radio": True, - }, - "index_offset": { - "default": 0, - "info": "How much to offset the frame numbering by.", - "datatype": int, - "rounding": 1, - "min_max": (0, 1000), - "group": "file_naming", - }, - "number_padding": { - "default": 6, - "info": "Length to pad the frame numbers by.", - "datatype": int, - "rounding": 6, - "min_max": (0, 10), - "group": "file_naming", - }, - "include_filename": { - "default": True, - "info": "Prefix the filename of the original frame to each face patch's output filename.", - "datatype": bool, - "group": "file_naming", - }, - "face_index_location": { - "default": "before", - "info": "For frames that contain multiple faces, where the face index should appear in " - "the filename:" - "\n\t before: places the face index before the frame number." - "\n\t after: places the face index after the frame number.", - "datatype": str, - "choices": ["before", "after"], - "group": "file_naming", - "gui_radio": True, - }, - "origin": { - "default": "bottom-left", - "info": "The origin (0, 0) location of the software that patches will be imported into. " - "This impacts the transformation matrix that is supplied with the image patch. " - "Setting the correct origin here will make importing into the external tool " - "simpler." - "\n\t top-left: The origin (0, 0) of the external canvas is at the top left " - "corner." - "\n\t bottom-left: The origin (0, 0) of the external canvas is at the bottom " - "left corner." - "\n\t top-right: The origin (0, 0) of the external canvas is at the top right " - "corner." - "\n\t bottom-right: The origin (0, 0) of the external canvas is at the bottom " - "right corner.", - "datatype": str, - "choices": ["top-left", "bottom-left", "top-right", "bottom-right"], - "group": "output", - "gui_radio": True - }, - "empty_frames": { - "default": "blank", - "info": "How to handle the output of frames without faces:" - "\n\t skip: skips any frames that do not have a face within it. This will lead to " - "gaps within the final image sequence." - "\n\t blank: outputs a blank (empty) face patch for any frames without faces. " - "There will be no gaps within the final image sequence, as those gaps will be " - "padded with empty face patches", - "datatype": str, - "choices": ["skip", "blank"], - "group": "output", - "gui_radio": True, - }, - "json_output": { - "default": False, - "info": "The transformation matrix, and other associated metadata, is output within the " - "face images EXIF fields. Some external tools can read this data, others cannot." - "enable this option to output a json file which contains this same metadata " - "mapped to each output face patch's filename.", - "datatype": bool, - "group": "output" - }, - "separate_mask": { - "default": False, - "info": "Seperate the mask into its own single channel patch. If enabled, the RGB image " - "will be saved into the selected output folder whilst the masks will be saved " - "into a sub-folder named `masks`. If not enabled then the mask will be included " - "in the alpha-channel of the RGBA output.", - "datatype": bool, - "group": "output", - }, - "bit_depth": { - "default": "16", - "info": "The bit-depth for the output images:" - "\n\t 8: 8-bit unsigned - Supported by all formats." - "\n\t 16: 16-bit unsigned - Supported by all formats." - "\n\t 32: 32-bit float - Supported by Tiff only.", - "datatype": str, - "choices": ["8", "16", "32"], - "group": "format", - "gui_radio": True, - }, - "format": { - "default": "png", - "info": "File format to save as." - "\n\t png: PNG file format. Transformation matrix is written to the custom iTxt " - "header field 'faceswap'" - "\n\t tiff: TIFF file format. Transformation matrix is written to the " - "'image_description' header field", - "datatype": str, - "choices": ["png", "tiff"], - "group": "format", - "gui_radio": True - }, - "png_compress_level": { - "default": 3, - "info": "ZLIB compression level, 1 gives best speed, 9 gives best compression, 0 gives no " - "compression at all.", - "datatype": int, - "rounding": 1, - "min_max": (0, 9), - "group": "format", - }, - "tiff_compression_method": { - "default": "lzw", - "info": "The compression method to use for Tiff files. Note: For 32bit output, SGILOG " - "compression will always be used regardless of what is selected here.", - "datatype": str, - "choices": ["none", "lzw", "deflate"], - "group": "format", - "gui_radio": True - }, -} +start_index = ConfigItem( + default="0", + info="The starting frame number for the first output frame.", + datatype=str, + choices=["0", "1"], + group="file_naming", + gui_radio=True) + +index_offset = ConfigItem( + default=0, + datatype=int, + group="file_naming", + info="How much to offset the frame numbering by.", + rounding=1, + min_max=(0, 1000)) + +number_padding = ConfigItem( + datatype=int, + default=6, + group="file_naming", + info="Length to pad the frame numbers by.", + rounding=6, + min_max=(0, 10)) + +include_filename = ConfigItem( + datatype=bool, + default=True, + group="file_naming", + info="Prefix the filename of the original frame to each face patch's output filename.") + +face_index_location = ConfigItem( + datatype=str, + default="before", + group="file_naming", + info="For frames that contain multiple faces, where the face index should appear in " + "the filename:" + "\n\t before: places the face index before the frame number." + "\n\t after: places the face index after the frame number.", + choices=["before", "after"], + gui_radio=True) + +origin = ConfigItem( + datatype=str, + default="bottom-left", + group="output", + info="The origin (0, 0) location of the software that patches will be imported into. " + "This impacts the transformation matrix that is supplied with the image patch. " + "Setting the correct origin here will make importing into the external tool " + "simpler." + "\n\t top-left: The origin (0, 0) of the external canvas is at the top left " + "corner." + "\n\t bottom-left: The origin (0, 0) of the external canvas is at the bottom " + "left corner." + "\n\t top-right: The origin (0, 0) of the external canvas is at the top right " + "corner." + "\n\t bottom-right: The origin (0, 0) of the external canvas is at the bottom " + "right corner.", + choices=["top-left", "bottom-left", "top-right", "bottom-right"], + gui_radio=True) + +empty_frames = ConfigItem( + datatype=str, + group="output", + default="blank", + info="How to handle the output of frames without faces:" + "\n\t skip: skips any frames that do not have a face within it. This will lead to " + "gaps within the final image sequence." + "\n\t blank: outputs a blank (empty) face patch for any frames without faces. " + "There will be no gaps within the final image sequence, as those gaps will be " + "padded with empty face patches", + choices=["skip", "blank"], + gui_radio=True) + +json_output = ConfigItem( + datatype=bool, + default=False, + group="output", + info="The transformation matrix, and other associated metadata, is output within the " + "face images EXIF fields. Some external tools can read this data, others cannot." + "enable this option to output a json file which contains this same metadata " + "mapped to each output face patch's filename.") + +separate_mask = ConfigItem( + datatype=bool, + default=False, + group="output", + info="Seperate the mask into its own single channel patch. If enabled, the RGB image " + "will be saved into the selected output folder whilst the masks will be saved " + "into a sub-folder named `masks`. If not enabled then the mask will be included " + "in the alpha-channel of the RGBA output.") + +bit_depth = ConfigItem( + datatype=str, + default="16", + group="format", + info="The bit-depth for the output images:" + "\n\t 8: 8-bit unsigned - Supported by all formats." + "\n\t 16: 16-bit unsigned - Supported by all formats." + "\n\t 32: 32-bit float - Supported by Tiff only.", + choices=["8", "16", "32"], + gui_radio=True) + +format = ConfigItem( # pylint:disable=redefined-builtin + datatype=str, + default="png", + group="format", + info="File format to save as." + "\n\t png: PNG file format. Transformation matrix is written to the custom iTxt " + "header field 'faceswap'" + "\n\t tiff: TIFF file format. Transformation matrix is written to the " + "'image_description' header field", + choices=["png", "tiff"], + gui_radio=True) + +png_compress_level = ConfigItem( + datatype=int, + default=3, + group="format", + info="ZLIB compression level, 1 gives best speed, 9 gives best compression, 0 gives no " + "compression at all.", + rounding=1, + min_max=(0, 9)) + +tiff_compression_method = ConfigItem( + datatype=str, + default="lzw", + group="format", + info="The compression method to use for Tiff files. Note: For 32bit output, SGILOG " + "compression will always be used regardless of what is selected here.", + choices=["none", "lzw", "deflate"], + gui_radio=True) diff --git a/plugins/convert/writer/pillow.py b/plugins/convert/writer/pillow.py index a0bf113f62..7fb1c75e28 100644 --- a/plugins/convert/writer/pillow.py +++ b/plugins/convert/writer/pillow.py @@ -5,7 +5,9 @@ import numpy as np +from lib.utils import get_module_objects from ._base import Output, logger +from . import pillow_defaults as cfg class Writer(Output): @@ -24,17 +26,22 @@ def __init__(self, output_folder: str, **kwargs) -> None: self._check_transparency_format() # Correct format namings for writing to byte stream self._format_dict = {"jpg": "JPEG", "jp2": "JPEG 2000", "tif": "TIFF"} - self._separate_mask = self.config["draw_transparent"] and self.config["separate_mask"] + self._separate_mask = self.output_alpha and cfg.separate_mask() self._kwargs = self._get_save_kwargs() + @property + def output_alpha(self) -> bool: + """ bool : Pillow can output alpha channel. Returns ``True`` """ + return cfg.draw_transparent() + def _check_transparency_format(self) -> None: """ Make sure that the output format is correct if draw_transparent is selected """ - transparent = self.config["draw_transparent"] - if not transparent or (transparent and self.config["format"] in ("png", "tif")): + # pylint:disable=duplicate-code + if not self.output_alpha or (self.output_alpha and cfg.format() in ("png", "tif")): return logger.warning("Draw Transparent selected, but the requested format does not support " "transparency. Changing output format to 'png'") - self.config["format"] = "png" + cfg.format.set("png") def _get_save_kwargs(self) -> dict[str, bool | int | str]: """ Return the save parameters for the file format @@ -44,16 +51,16 @@ def _get_save_kwargs(self) -> dict[str, bool | int | str]: dict The specific keyword arguments for the selected file format """ - filetype = self.config["format"] - kwargs = {} + filetype = cfg.format() + kwargs: dict[str, bool | int | str] = {} if filetype in ("gif", "jpg", "png"): - kwargs["optimize"] = self.config["optimize"] + kwargs["optimize"] = cfg.optimize() if filetype == "gif": - kwargs["interlace"] = self.config["gif_interlace"] + kwargs["interlace"] = cfg.gif_interlace() if filetype == "png": - kwargs["compress_level"] = self.config["png_compress_level"] + kwargs["compress_level"] = cfg.png_compress_level() if filetype == "tif": - kwargs["compression"] = self.config["tif_compression"] + kwargs["compression"] = cfg.tif_compression() logger.debug(kwargs) return kwargs @@ -70,7 +77,7 @@ def write(self, filename: str, image: list[BytesIO]) -> None: or length 2 (containing the image and mask to write out) """ logger.trace("Outputting: (filename: '%s'", filename) # type:ignore - filenames = self.output_filename(filename, self._separate_mask) + filenames = self.get_output_filename(filename, cfg.format(), self._separate_mask) try: for fname, img in zip(filenames, image): with open(fname, "wb") as outfile: @@ -122,7 +129,7 @@ def _encode_image(self, image: np.ndarray) -> BytesIO: :class:`BytesIO` The image as a bytes object ready for writing to disk """ - fmt = self._format_dict.get(self.config["format"], self.config["format"].upper()) + fmt = self._format_dict.get(cfg.format(), cfg.format().upper()) encoded = BytesIO() out_image = Image.fromarray(image) out_image.save(encoded, fmt, **self._kwargs) @@ -132,3 +139,6 @@ def _encode_image(self, image: np.ndarray) -> BytesIO: def close(self) -> None: """ Does nothing as Pillow writer does not need a close method """ return + + +__all__ = get_module_objects(__name__) diff --git a/plugins/convert/writer/pillow_defaults.py b/plugins/convert/writer/pillow_defaults.py index 58bf7e31ea..6995be3acc 100755 --- a/plugins/convert/writer/pillow_defaults.py +++ b/plugins/convert/writer/pillow_defaults.py @@ -1,158 +1,110 @@ #!/usr/bin/env python3 +""" The default options for the faceswap Pillow Writer plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: + + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does + +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) + +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ - The default options for the faceswap Pillow Writer plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dict(ionary containing the options, defaults and meta information. The - dict(ionary should be defined as: - {: {}} - - should always be lower text. - dict(ionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict(: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" +# pylint:disable=duplicate-code +from lib.config import ConfigItem -_HELPTEXT = ( +HELPTEXT = ( "Options for outputting converted frames to a series of images using Pillow\n" "Pillow is more feature rich than OpenCV but can be slower." ) -_DEFAULTS = dict( - format=dict( - default="png", - info="Image format to use:" - "\n\t bmp: Windows bitmap" - "\n\t gif: Graphics Interchange Format (NB: Not animated)" - "\n\t jpg: JPEG format" - "\n\t jp2: JPEG 2000 format" - "\n\t png: Portable Network Graphics" - "\n\t ppm: Portable Pixmap Format" - "\n\t tif: Tag Image File Format", - datatype=str, - rounding=None, - min_max=None, - choices=["bmp", "gif", "jpg", "jp2", "png", "ppm", "tif"], - group="format", - gui_radio=True, - fixed=True, - ), - draw_transparent=dict( - default=False, - info="Place the swapped face on a transparent layer rather than the original frame.\nNB: " - "This is only compatible with images saved in png or tif format. If an incompatible " - "format is selected then the image will be saved as a png.", - datatype=bool, - rounding=None, - min_max=None, - choices=[], - group="format", - gui_radio=False, - fixed=True, - ), - separate_mask=dict( - default=False, - info="Seperate the mask into its own single channel image. This only applies when " - "'draw-transparent' is selected. If enabled, the RGB image will be saved into the " - "selected output folder whilst the masks will be saved into a sub-folder named " - "`masks`. If not enabled then the mask will be included in the alpha-channel of the " - "RGBA output.", - datatype=bool, - rounding=None, - min_max=None, - choices=[], - group="format", - gui_radio=False, - fixed=True, - ), - optimize=dict( - default=False, - info="[gif, jpg and png only] If enabled, indicates that the encoder should make an extra " - "pass over the image in order to select optimal encoder settings.", - datatype=bool, - rounding=None, - min_max=None, - choices=[], - group="settings", - gui_radio=False, - fixed=True, - ), - gif_interlace=dict( - default=True, - info="[gif only] Set whether to save the gif as interlaced or not.", - datatype=bool, - rounding=None, - min_max=None, - choices=[], - group="settings", - gui_radio=False, - fixed=True, - ), - jpg_quality=dict( - default=75, - info="[jpg only] Set the jpg quality. 1 is worst 95 is best. Higher quality leads to " - "larger file sizes.", - datatype=int, - rounding=1, - min_max=(1, 95), - choices=[], - group="compression", - gui_radio=False, - fixed=True, - ), - png_compress_level=dict( - default=3, - info="[png only] ZLIB compression level, 1 gives best speed, 9 gives best compression, 0 " - "gives no compression at all. When optimize option is set to True this has no effect " - "(it is set to 9 regardless of a value passed).", - datatype=int, - rounding=1, - min_max=(0, 9), - choices=[], - group="compression", - gui_radio=False, - fixed=True, - ), - tif_compression=dict( - default="tiff_deflate", - info="[tif only] The desired compression method for the file.", - datatype=str, - rounding=None, - min_max=None, - choices=["none", "tiff_ccitt", "group3", "group4", "tiff_jpeg", "tiff_adobe_deflate", - "tiff_thunderscan", "tiff_deflate", "tiff_sgilog", "tiff_sgilog24", - "tiff_raw_16"], - group="compression", - gui_radio=False, - fixed=True, - ), -) +format = ConfigItem( # pylint:disable=redefined-builtin + group="format", + datatype=str, + default="png", + info="Image format to use:" + "\n\t bmp: Windows bitmap" + "\n\t gif: Graphics Interchange Format (NB: Not animated)" + "\n\t jpg: JPEG format" + "\n\t jp2: JPEG 2000 format" + "\n\t png: Portable Network Graphics" + "\n\t ppm: Portable Pixmap Format" + "\n\t tif: Tag Image File Format", + choices=["bmp", "gif", "jpg", "jp2", "png", "ppm", "tif"], + gui_radio=True) + +draw_transparent = ConfigItem( + datatype=bool, + default=False, + group="format", + info="Place the swapped face on a transparent layer rather than the original frame.\nNB: " + "This is only compatible with images saved in png or tif format. If an incompatible " + "format is selected then the image will be saved as a png.") + +separate_mask = ConfigItem( + datatype=bool, + default=False, + group="format", + info="Seperate the mask into its own single channel image. This only applies when " + "'draw-transparent' is selected. If enabled, the RGB image will be saved into the " + "selected output folder whilst the masks will be saved into a sub-folder named " + "`masks`. If not enabled then the mask will be included in the alpha-channel of the " + "RGBA output.") + +optimize = ConfigItem( + datatype=bool, + default=False, + group="settings", + info="[gif, jpg and png only] If enabled, indicates that the encoder should make an extra " + "pass over the image in order to select optimal encoder settings.") + +gif_interlace = ConfigItem( + datatype=bool, + default=True, + group="settings", + info="[gif only] Set whether to save the gif as interlaced or not.") + +jpg_quality = ConfigItem( + datatype=int, + default=75, + group="compression", + info="[jpg only] Set the jpg quality. 1 is worst 95 is best. Higher quality leads to " + "larger file sizes.", + rounding=1, + min_max=(1, 95)) + +png_compress_level = ConfigItem( + datatype=int, + default=3, + group="compression", + info="[png only] ZLIB compression level, 1 gives best speed, 9 gives best compression, 0 " + "gives no compression at all. When optimize option is set to True this has no effect " + "(it is set to 9 regardless of a value passed).", + rounding=1, + min_max=(0, 9)) + +tif_compression = ConfigItem( + datatype=str, + default="tiff_deflate", + group="compression", + info="[tif only] The desired compression method for the file.", + choices=["none", "tiff_ccitt", "group3", "group4", "tiff_jpeg", "tiff_adobe_deflate", + "tiff_thunderscan", "tiff_deflate", "tiff_sgilog", "tiff_sgilog24", + "tiff_raw_16"]) diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 588394adee..9a881b90bb 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -5,52 +5,30 @@ from __future__ import annotations import logging import typing as T - from dataclasses import dataclass, field import numpy as np -from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa +import torch +from keras import device +from lib.logger import parse_class_init from lib.multithreading import MultiThread from lib.queue_manager import queue_manager -from lib.utils import GetModel, FaceswapError -from ._config import Config +from lib.utils import GetModel +from lib.utils import get_backend +from . import extract_config as cfg from . import ExtractMedia if T.TYPE_CHECKING: from collections.abc import Callable, Generator, Sequence from queue import Queue - import cv2 from lib.align import DetectedFace - from lib.model.session import KSession from .align._base import AlignerBatch from .detect._base import DetectorBatch from .mask._base import MaskerBatch from .recognition._base import RecogBatch logger = logging.getLogger(__name__) -# TODO Run with warnings mode - - -def _get_config(plugin_name: str, configfile: str | None = None) -> dict[str, T.Any]: - """ Return the configuration for the requested model - - Parameters - ---------- - plugin_name: str - The module name of the child plugin. - configfile: str, optional - Path to a :file:`./config/.ini` file for this plugin. Default: use system - configuration. - - Returns - ------- - config_dict, dict - A dictionary of configuration items from the configuration file - """ - return Config(plugin_name, configfile=configfile).config_dict - - BatchType = T.Union["DetectorBatch", "AlignerBatch", "MaskerBatch", "RecogBatch"] @@ -82,8 +60,8 @@ class ExtractorBatch: image: list[np.ndarray] = field(default_factory=list) detected_faces: Sequence[DetectedFace | list[DetectedFace]] = field(default_factory=list) filename: list[str] = field(default_factory=list) - feed: np.ndarray = np.array([]) - prediction: np.ndarray = np.array([]) + feed: np.ndarray = field(default_factory=lambda: np.array([])) + prediction: np.ndarray = field(default_factory=lambda: np.array([])) data: list[dict[str, T.Any]] = field(default_factory=list) def __repr__(self) -> str: @@ -99,7 +77,44 @@ def __repr__(self) -> str: f"data={data}") -class Extractor(): +@dataclass +class PluginInfo: + """ Dataclass to hold information about a plugin instance + + Parameters + ---------- + instance: int + The instance id of the plugin + plugin_type: Literal["align", "detect", "mask", "recognition"] | None, optional + The plugin type that the plugin instance is. Default: ``None`` + is_initialized: bool, optional + ``True`` if the plugin is initialized. Default: ``False`` + """ + instance: int + plugin_type: T.Literal["align", "detect", "mask", "recognition"] | None = None + is_initialized: bool = False + + +@dataclass +class SplitTracker: + """ Dataclass to hold objects for splitting frame's detected faces and rejoining them for + post-detector pliugins + + Parameters + ---------- + faces_per_filename: dict[str, int] + Tracking of faces per filename for recompiling batches + rollover: :class:`ExtractMedia` | None + Batch rollover items + output_faces: list[:class:`~lib.align.detected_face.DetectedFace`] + Recompiled output faces from the plugin + """ + faces_per_filename: dict[str, int] + rollover: ExtractMedia | None + output_faces: list[DetectedFace] + + +class Extractor(): # pylint:disable=too-many-instance-attributes """ Extractor Plugin Object All ``_base`` classes for Aligners, Detectors and Maskers inherit from this class. @@ -120,9 +135,6 @@ class Extractor(): https://github.com/deepfakes-models/faceswap-models for more information model_filename: str The name of the model file to be loaded - exclude_gpus: list, optional - A list of indices correlating to connected GPUs that Tensorflow should not use. Pass - ``None`` to not exclude any GPUs. Default: ``None`` configfile: str, optional Path to a custom configuration ``ini`` file. Default: Use system configfile instance: int, optional @@ -147,9 +159,6 @@ class Extractor(): vram: int Approximate VRAM used by the model at :attr:`input_size`. Used to calculate the :attr:`batchsize`. Be conservative to avoid OOM. - vram_warnings: int - Approximate VRAM used by the model at :attr:`input_size` that will still run, but generates - warnings. Used to calculate the :attr:`batchsize`. Be conservative to avoid OOM. vram_per_batch: int Approximate additional VRAM used by the model for each additional batch. Used to calculate the :attr:`batchsize`. Be conservative to avoid OOM. @@ -165,17 +174,13 @@ class Extractor(): def __init__(self, git_model_id: int | None = None, model_filename: str | list[str] | None = None, - exclude_gpus: list[int] | None = None, configfile: str | None = None, instance: int = 0) -> None: - logger.debug("Initializing %s: (git_model_id: %s, model_filename: %s, exclude_gpus: %s, " - "configfile: %s, instance: %s, )", self.__class__.__name__, git_model_id, - model_filename, exclude_gpus, configfile, instance) - self._is_initialized = False - self._instance = instance - self._exclude_gpus = exclude_gpus - self.config = _get_config(".".join(self.__module__.split(".")[-2:]), configfile=configfile) - """ dict: Config for this plugin, loaded from ``extract.ini`` configfile """ + logger.debug(parse_class_init(locals())) + cfg.load_config(configfile) + + self._info = PluginInfo(instance=instance) + """:class:`PluginInfo`: holds information about the plugin instance""" self.model_path = self._get_model(git_model_id, model_filename) """ str or list: Path to the model file(s) (if required). Multiple model files should @@ -186,14 +191,10 @@ def __init__(self, self.input_size = 0 self.color_format: T.Literal["BGR", "RGB", "GRAY"] = "BGR" self.vram = 0 - self.vram_warnings = 0 # Will run at this with warnings self.vram_per_batch = 0 # << THE FOLLOWING ARE SET IN self.initialize METHOD >> # - self.queue_size = 1 - """ int: Queue size for all internal queues. Set in :func:`initialize()` """ - - self.model: KSession | cv2.dnn.Net | None = None + self.model: T.Any = None """varies: The model for this plugin. Set in the plugin's :func:`init_model()` method """ # For detectors that support batching, this should be set to the calculated batch size @@ -213,15 +214,9 @@ def __init__(self, processed. Stored at input for pairing back up on output of extractor process """ # << THE FOLLOWING PROTECTED ATTRIBUTES ARE SET IN PLUGIN TYPE _base.py >>> # - self._plugin_type: T.Literal["align", "detect", "recognition", "mask"] | None = None - """ str: Plugin type. ``detect`, ``align``, ``recognise`` or ``mask`` set in - ``._base`` """ - - # << Objects for splitting frame's detected faces and rejoining them >> - # << for post-detector pliugins >> - self._faces_per_filename: dict[str, int] = {} # Tracking for recompiling batches - self._rollover: ExtractMedia | None = None # batch rollover items - self._output_faces: list[DetectedFace] = [] # Recompiled output faces from plugin + self._tracker = SplitTracker({}, None, []) + """:class:`SplitTracker`: Holds objects for splitting frame's detected faces and + rejoining them for post-detector pliugins """ logger.debug("Initialized _base %s", self.__class__.__name__) @@ -389,6 +384,36 @@ def get_batch(self, queue: Queue) -> tuple[bool, BatchType]: """ raise NotImplementedError + @classmethod + def get_device_context(cls, cpu: bool) -> T.ContextManager: + """ Get a device context manager for running inference on the CPU + + Parameters + ---------- + cpu: bool + ``True`` to get a context manager for running on the CPU. ``False`` to get a + context manager for the default device + + Returns + ------- + ContextManager + The context manager for running ops on the selected device + """ + if cpu: + logger.debug("CPU mode selected. Returning CPU device context") + return device("cpu") + + # TODO apple_silicon + if get_backend() == "apple_silicon": + pass + + if torch.cuda.is_available(): + logger.debug("Cuda available. Returning Cuda device context") + return device("cuda") + + logger.debug("Cuda not available. Returning CPU device context") + return device("cpu") + # <<< THREADING METHODS >>> # def start(self) -> None: """ Start all threads @@ -419,8 +444,8 @@ def rollover_collector(self, queue: Queue) -> T.Literal["EOF"] | ExtractMedia: batch size mean that faces will need to be split/re-joined with frames. The rollover collector can be used to rollover items that don't fit in a batch. - Collect the item from the :attr:`_rollover` dict or from the queue. Add face count per - frame to self._faces_per_filename for joining batches back up in finalize + Collect the item from the :attr:`_tracker.rollover` dict or from the queue. Add face count + per frame to :attr:`_tracker.faces_per_filename` for joining batches back up in finalize Parameters ---------- @@ -433,20 +458,23 @@ def rollover_collector(self, queue: Queue) -> T.Literal["EOF"] | ExtractMedia: :class:`~plugins.extract.extract_media.ExtractMedia` or EOF The next extract media object, or EOF if pipe has ended """ - if self._rollover is not None: - logger.trace("Getting from _rollover: (filename: `%s`, faces: %s)", # type:ignore - self._rollover.filename, len(self._rollover.detected_faces)) - item: T.Literal["EOF"] | ExtractMedia = self._rollover - self._rollover = None + if self._tracker.rollover is not None: + logger.trace("Getting from _tracker.rollover: " # type:ignore[attr-defined] + "(filename: `%s`, faces: %s)", + self._tracker.rollover.filename, + len(self._tracker.rollover.detected_faces)) + item: T.Literal["EOF"] | ExtractMedia = self._tracker.rollover + self._tracker.rollover = None else: next_item = self._get_item(queue) # Rollover collector should only be used at entry to plugin assert isinstance(next_item, (ExtractMedia, str)) item = next_item if item != "EOF": - logger.trace("Getting from queue: (filename: %s, faces: %s)", # type:ignore + logger.trace("Getting from queue: (filename: %s, " # type:ignore[attr-defined] + "faces: %s)", item.filename, len(item.detected_faces)) - self._faces_per_filename[item.filename] = len(item.detected_faces) + self._tracker.faces_per_filename[item.filename] = len(item.detected_faces) return item # <<< PROTECTED ACCESS METHODS >>> # @@ -473,37 +501,23 @@ def initialize(self, *args, **kwargs) -> None: """ logger.debug("initialize %s: (args: %s, kwargs: %s)", self.__class__.__name__, args, kwargs) - assert self._plugin_type is not None and self.name is not None - if self._is_initialized: + assert self._info.plugin_type is not None and self.name is not None + if self._info.is_initialized: # When batch processing, plugins will be initialized on first job in batch logger.debug("Plugin already initialized: %s (%s)", - self.name, self._plugin_type.title()) + self.name, self._info.plugin_type.title()) return - logger.info("Initializing %s (%s)...", self.name, self._plugin_type.title()) - self.queue_size = 1 + logger.info("Initializing %s (%s)...", self.name, self._info.plugin_type.title()) name = self.name.replace(" ", "_").lower() self._add_queues(kwargs["in_queue"], kwargs["out_queue"], [f"predict_{name}", f"post_{name}"]) self._compile_threads() - try: - self.init_model() - except tf_errors.UnknownError as err: - if "failed to get convolution algorithm" in str(err).lower(): - msg = ("Tensorflow raised an unknown error. This is most likely caused by a " - "failure to launch cuDNN which can occur for some GPU/Tensorflow " - "combinations. You should enable `allow_growth` to attempt to resolve this " - "issue:" - "\nGUI: Go to Settings > Extract Plugins > Global and enable the " - "`allow_growth` option." - "\nCLI: Go to `faceswap/config/extract.ini` and change the `allow_growth " - "option to `True`.") - raise FaceswapError(msg) from err - raise err - self._is_initialized = True + self.init_model() + self._info.is_initialized = True logger.info("Initialized %s (%s) with batchsize of %s", - self.name, self._plugin_type.title(), self.batchsize) + self.name, self._info.plugin_type.title(), self.batchsize) def _add_queues(self, in_queue: Queue, @@ -516,16 +530,16 @@ def _add_queues(self, self._queues["out"] = out_queue for q_name in queues: self._queues[q_name] = queue_manager.get_queue( - name=f"{self._plugin_type}{self._instance}_{q_name}", - maxsize=self.queue_size) + name=f"{self._info.plugin_type}{self._info.instance}_{q_name}", + maxsize=1) # <<< THREAD METHODS >>> # def _compile_threads(self) -> None: """ Compile the threads into self._threads list """ assert self.name is not None - logger.debug("Compiling %s threads", self._plugin_type) + logger.debug("Compiling %s threads", self._info.plugin_type) name = self.name.replace(" ", "_").lower() - base_name = f"{self._plugin_type}_{name}" + base_name = f"{self._info.plugin_type}_{name}" self._add_thread(f"{base_name}_input", self._process_input, self._queues["in"], @@ -538,7 +552,7 @@ def _compile_threads(self) -> None: self._process_output, self._queues[f"post_{name}"], self._queues["out"]) - logger.debug("Compiled %s threads: %s", self._plugin_type, self._threads) + logger.debug("Compiled %s threads: %s", self._info.plugin_type, self._threads) def _add_thread(self, name: str, @@ -615,20 +629,7 @@ def _thread_process(self, break if not batch.filename: # Batch not populated. Possible during re-aligns continue - try: - batch = function(batch) - except tf_errors.UnknownError as err: - if "failed to get convolution algorithm" in str(err).lower(): - msg = ("Tensorflow raised an unknown error. This is most likely caused by a " - "failure to launch cuDNN which can occur for some GPU/Tensorflow " - "combinations. You should enable `allow_growth` to attempt to resolve " - "this issue:" - "\nGUI: Go to Settings > Extract Plugins > Global and enable the " - "`allow_growth` option." - "\nCLI: Go to `faceswap/config/extract.ini` and change the " - "`allow_growth option to `True`.") - raise FaceswapError(msg) from err - raise err + batch = function(batch) if function.__name__ == "_process_output": # Process output items to individual items from batch for item in self.finalize(batch): @@ -643,10 +644,10 @@ def _get_item(self, queue: Queue) -> T.Literal["EOF"] | ExtractMedia | BatchType """ Yield one item from a queue """ item = queue.get() if isinstance(item, ExtractMedia): - logger.trace("filename: '%s', image shape: %s, detected_faces: %s, " # type:ignore - "queue: %s, item: %s", + logger.trace("filename: '%s', image shape: %s, " # type:ignore[attr-defined] + "detected_faces: %s, queue: %s, item: %s", item.filename, item.image_shape, item.detected_faces, queue, item) self._extract_media[item.filename] = item else: - logger.trace("item: %s, queue: %s", item, queue) # type:ignore + logger.trace("item: %s, queue: %s", item, queue) # type:ignore[attr-defined] return item diff --git a/plugins/extract/_config.py b/plugins/extract/_config.py deleted file mode 100644 index 8314cab2f0..0000000000 --- a/plugins/extract/_config.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -""" Default configurations for extract """ - -import gettext -import logging -import os - -from lib.config import FaceswapConfig - -# LOCALES -_LANG = gettext.translation("plugins.extract._config", localedir="locales", fallback=True) -_ = _LANG.gettext - -logger = logging.getLogger(__name__) - - -class Config(FaceswapConfig): - """ Config File for Extraction """ - - def set_defaults(self) -> None: - """ Set the default values for config """ - logger.debug("Setting defaults") - self.set_globals() - self._defaults_from_plugin(os.path.dirname(__file__)) - - def set_globals(self) -> None: - """ - Set the global options for extract - """ - logger.debug("Setting global config") - section = "global" - self.add_section(section, _("Options that apply to all extraction plugins")) - self.add_item( - section=section, - title="allow_growth", - datatype=bool, - default=False, - group=_("settings"), - info=_("Enable the Tensorflow GPU `allow_growth` configuration option. " - "This option prevents Tensorflow from allocating all of the GPU VRAM at launch " - "but can lead to higher VRAM fragmentation and slower performance. Should only " - "be enabled if you are having problems running extraction.")) - self.add_item( - section=section, - title="aligner_min_scale", - datatype=float, - min_max=(0.0, 1.0), - rounding=2, - default=0.07, - group=_("filters"), - info=_("Filters out faces below this size. This is a multiplier of the minimum " - "dimension of the frame (i.e. 1280x720 = 720). If the original face extract " - "box is smaller than the minimum dimension times this multiplier, it is " - "considered a false positive and discarded. Faces which are found to be " - "unusually smaller than the frame tend to be misaligned images, except in " - "extreme long-shots. These can be usually be safely discarded.")) - self.add_item( - section=section, - title="aligner_max_scale", - datatype=float, - min_max=(0.0, 10.0), - rounding=2, - default=2.00, - group=_("filters"), - info=_("Filters out faces above this size. This is a multiplier of the minimum " - "dimension of the frame (i.e. 1280x720 = 720). If the original face extract " - "box is larger than the minimum dimension times this multiplier, it is " - "considered a false positive and discarded. Faces which are found to be " - "unusually larger than the frame tend to be misaligned images except in " - "extreme close-ups. These can be usually be safely discarded.")) - self.add_item( - section=section, - title="aligner_distance", - datatype=float, - min_max=(0.0, 45.0), - rounding=1, - default=22.5, - group=_("filters"), - info=_("Filters out faces who's landmarks are above this distance from an 'average' " - "face. Values above 15 tend to be fairly safe. Values above 10 will remove " - "more false positives, but may also filter out some faces at extreme angles.")) - self.add_item( - section=section, - title="aligner_roll", - datatype=float, - min_max=(0.0, 90.0), - rounding=1, - default=45.0, - group=_("filters"), - info=_("Filters out faces who's calculated roll is greater than zero +/- this value " - "in degrees. Aligned faces should have a roll value close to zero. Values that " - "are a significant distance from 0 degrees tend to be misaligned images. These " - "can usually be safely disgarded.")) - self.add_item( - section=section, - title="aligner_features", - datatype=bool, - default=True, - group=_("filters"), - info=_("Filters out faces where the lowest point of the aligned face's eye or eyebrow " - "is lower than the highest point of the aligned face's mouth. Any faces where " - "this occurs are misaligned and can be safely disgarded.")) - self.add_item( - section=section, - title="filter_refeed", - datatype=bool, - default=True, - group=_("filters"), - info=_("If enabled, and 're-feed' has been selected for extraction, then interim " - "alignments will be filtered prior to averaging the final landmarks. This can " - "help improve the final alignments by removing any obvious misaligns from the " - "interim results, and may also help pick up difficult alignments. If disabled, " - "then all re-feed results will be averaged.")) - self.add_item( - section=section, - title="save_filtered", - datatype=bool, - default=False, - group=_("filters"), - info=_("If enabled, saves any filtered out images into a sub-folder during the " - "extraction process. If disabled, filtered faces are deleted. Note: The faces " - "will always be filtered out of the alignments file, regardless of whether you " - "keep the faces or not.")) - self.add_item( - section=section, - title="realign_refeeds", - datatype=bool, - default=True, - group=_("re-align"), - info=_("If enabled, and 're-align' has been selected for extraction, then all re-feed " - "iterations are re-aligned. If disabled, then only the final averaged output " - "from re-feed will be re-aligned.")) - self.add_item( - section=section, - title="filter_realign", - datatype=bool, - default=True, - group=_("re-align"), - info=_("If enabled, and 're-align' has been selected for extraction, then any " - "alignments which would be filtered out will not be re-aligned.")) diff --git a/plugins/extract/align/_base/aligner.py b/plugins/extract/align/_base/aligner.py index 6746daf631..49cb5d19cf 100644 --- a/plugins/extract/align/_base/aligner.py +++ b/plugins/extract/align/_base/aligner.py @@ -21,12 +21,11 @@ import cv2 import numpy as np - -from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa +from torch.cuda import OutOfMemoryError from lib.align import LandmarkType from lib.utils import FaceswapError -from plugins.extract import ExtractMedia +from plugins.extract import ExtractMedia, extract_config as cfg from plugins.extract._base import BatchType, ExtractorBatch, Extractor from .processing import AlignedFilter, ReAlign @@ -76,10 +75,10 @@ class AlignerBatch(ExtractorBatch): """ batch_id: int = 0 detected_faces: list[DetectedFace] = field(default_factory=list) - landmarks: np.ndarray = np.array([]) + landmarks: np.ndarray = field(default_factory=lambda: np.array([])) refeeds: list[np.ndarray] = field(default_factory=list) second_pass: bool = False - second_pass_masks: np.ndarray = np.array([]) + second_pass_masks: np.ndarray = field(default_factory=lambda: np.array([])) def __repr__(self): """ Prettier repr for debug printing """ @@ -132,7 +131,7 @@ class Aligner(Extractor): # pylint:disable=abstract-method plugins.extract.mask._base : Masker parent class for extraction plugins. """ - def __init__(self, + def __init__(self, # pylint:disable=too-many-positional-arguments git_model_id: int | None = None, model_filename: str | None = None, configfile: str | None = None, @@ -150,7 +149,7 @@ def __init__(self, configfile=configfile, instance=instance, **kwargs) - self._plugin_type = "align" + self._info.plugin_type = "align" self.realign_centering: CenteringType = "face" # overide for plugin specific centering # Override for specific landmark type: @@ -159,19 +158,18 @@ def __init__(self, self._eof_seen = False self._normalize_method: T.Literal["clahe", "hist", "mean"] | None = None self._re_feed = re_feed - self._filter = AlignedFilter(feature_filter=self.config["aligner_features"], - min_scale=self.config["aligner_min_scale"], - max_scale=self.config["aligner_max_scale"], - distance=self.config["aligner_distance"], - roll=self.config["aligner_roll"], - save_output=self.config["save_filtered"], + self._filter = AlignedFilter(feature_filter=cfg.aligner_features(), + min_scale=cfg.aligner_min_scale(), + max_scale=cfg.aligner_max_scale(), + distance=cfg.aligner_distance(), + roll=cfg.aligner_roll(), + save_output=cfg.save_filtered(), disable=disable_filter) self._re_align = ReAlign(re_align, - self.config["realign_refeeds"], - self.config["filter_realign"]) + cfg.realign_refeeds(), + cfg.filter_realign()) self._needs_refeed_masks: bool = self._re_feed > 0 and ( - self.config["filter_refeed"] or (self._re_align.do_refeeds and - self._re_align.do_filter)) + cfg.filter_refeed() or (self._re_align.do_refeeds and self._re_align.do_filter)) self.set_normalize_method(normalize_method) logger.debug("Initialized %s", self.__class__.__name__) @@ -301,14 +299,15 @@ def get_batch(self, queue: Queue) -> tuple[bool, AlignerBatch]: if idx == self.batchsize: frame_faces = len(item.detected_faces) if f_idx + 1 != frame_faces: - self._rollover = ExtractMedia( + self._tracker.rollover = ExtractMedia( item.filename, item.image, detected_faces=item.detected_faces[f_idx + 1:], is_aligned=item.is_aligned) logger.trace("Rolled over %s faces of %s to " # type: ignore[attr-defined] - "next batch for '%s'", len(self._rollover.detected_faces), - frame_faces, item.filename) + "next batch for '%s'", + len(self._tracker.rollover.detected_faces), frame_faces, + item.filename) break if batch.filename: logger.trace("Returning batch: %s", batch) # type: ignore[attr-defined] @@ -366,16 +365,17 @@ def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: logger.trace("Item out: %s", batch) # type: ignore[attr-defined] for frame, filename, face in zip(batch.image, batch.filename, batch.detected_faces): - self._output_faces.append(face) - if len(self._output_faces) != self._faces_per_filename[filename]: + self._tracker.output_faces.append(face) + if len(self._tracker.output_faces) != self._tracker.faces_per_filename[filename]: continue - self._output_faces, folders = self._filter(self._output_faces, min(frame.shape[:2])) + self._tracker.output_faces, folders = self._filter(self._tracker.output_faces, + min(frame.shape[:2])) output = self._extract_media.pop(filename) - output.add_detected_faces(self._output_faces) + output.add_detected_faces(self._tracker.output_faces) output.add_sub_folders(folders) - self._output_faces = [] + self._tracker.output_faces = [] logger.trace("Final Output: (filename: '%s', image " # type: ignore[attr-defined] "shape: %s, detected_faces: %s, item: %s)", output.filename, @@ -448,7 +448,7 @@ def _process_input_first_pass(self, batch: AlignerBatch) -> None: # Place the original bounding box back to detected face objects for face, box in zip(batch.detected_faces, original_boxes): - face.left, face.top, face.width, face.height = box + face.left, face.top, face.width, face.height = box.tolist() def _get_realign_masks(self, batch: AlignerBatch) -> np.ndarray: """ Obtain the masks required for processing re-aligns @@ -533,6 +533,8 @@ def _predict(self, batch: BatchType) -> AlignerBatch: preds = [self.predict(feed) for feed in batch.refeeds] try: batch.prediction = np.array(preds) + logger.trace("Aligner out: %s", # type:ignore[attr-defined] + batch.prediction.shape) except ValueError as err: # If refeed batches are different sizes, Numpy will error, so we need to explicitly # set the dtype to 'object' rather than let it infer @@ -548,8 +550,7 @@ def _predict(self, batch: BatchType) -> AlignerBatch: else: raise - return batch - except tf_errors.ResourceExhaustedError as err: + except OutOfMemoryError as err: msg = ("You do not have enough GPU memory available to run detection at the " "selected batch size. You can try a number of things:" "\n1) Close any other application that is using your GPU (web browsers are " @@ -560,6 +561,8 @@ def _predict(self, batch: BatchType) -> AlignerBatch: "\n3) Enable 'Single Process' mode.") raise FaceswapError(msg) from err + return batch + def _process_refeeds(self, batch: AlignerBatch) -> list[AlignerBatch]: """ Process the output for each selected re-feed diff --git a/plugins/extract/align/cv2_dnn.py b/plugins/extract/align/cv2_dnn.py index f646b7cdc2..a695f7f11d 100644 --- a/plugins/extract/align/cv2_dnn.py +++ b/plugins/extract/align/cv2_dnn.py @@ -30,6 +30,7 @@ import cv2 import numpy as np +from lib.utils import get_module_objects from ._base import Aligner, AlignerBatch, BatchType if T.TYPE_CHECKING: @@ -314,3 +315,6 @@ def get_pts_from_predict(self, batch: AlignerBatch): landmarks.append(points) batch.landmarks = np.array(landmarks) logger.trace("Predicted Landmarks: %s", batch.landmarks) # type:ignore[attr-defined] + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/align/external.py b/plugins/extract/align/external.py index 929e9b11c9..ca5630dc37 100644 --- a/plugins/extract/align/external.py +++ b/plugins/extract/align/external.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """ Import 68 point landmarks or ROI boxes from a json file """ +from __future__ import annotations import logging import typing as T import os @@ -8,11 +9,17 @@ import numpy as np from lib.align import EXTRACT_RATIOS, LandmarkType -from lib.utils import FaceswapError, IMAGE_EXTENSIONS +from lib.utils import get_module_objects, FaceswapError, IMAGE_EXTENSIONS from ._base import BatchType, Aligner, AlignerBatch +from . import external_defaults as cfg + +if T.TYPE_CHECKING: + from lib.align.constants import CenteringType logger = logging.getLogger(__name__) +OriginType = T.Literal["top-left", "bottom-left", "top-right", "bottom-right"] +# pylint:disable=duplicate-code class Align(Aligner): @@ -26,11 +33,11 @@ def __init__(self, **kwargs) -> None: self.name = "External" self.batchsize = 16 - - self._origin: T.Literal["top-left", - "bottom-left", - "top-right", - "bottom-right"] = self.config["origin"] + self.origin: OriginType = T.cast(OriginType, cfg.origin()) + """ Literal["top-left", "bottom-left", "top-right", "bottom-right"] : The origin (0, 0) + location of the co-ordinates system used""" + self.file_name = cfg.file_name() + """ str : The file name to import landmark data from """ self._re_frame_no: re.Pattern = re.compile(r"\d+$") self._is_video: bool = False @@ -44,8 +51,8 @@ def __init__(self, **kwargs) -> None: """dict[Literal["bottom-left", "top-right", "bottom-right"], int]: Amount to roll the points by for different origins when 4 Point ROI landmarks are provided """ - centering = self.config["4_point_centering"] - self._adjustment: float = 1. if centering is None else 1. - EXTRACT_RATIOS[centering] + centering = T.cast("CenteringType", cfg.four_point_centering) + self._adjustment: float = 1. if centering == "none" else 1. - EXTRACT_RATIOS[centering] """float: The amount to adjust 4 point ROI landmarks to standardize the points for a 'head' sized extracted face """ @@ -200,15 +207,15 @@ def _adjust_for_origin(self, landmarks: np.ndarray, frame_dims: tuple[int, int]) :class:`numpy.ndarray` The adjusted landmarks box for a top-left origin """ - if not np.any(landmarks) or self._origin == "top-left": + if not np.any(landmarks) or self.origin == "top-left": return landmarks if LandmarkType.from_shape(landmarks.shape) == LandmarkType.LM_2D_4: - landmarks = np.roll(landmarks, self._roll[self._origin], axis=0) + landmarks = np.roll(landmarks, self._roll[self.origin], axis=0) - if self._origin.startswith("bottom"): + if self.origin.startswith("bottom"): landmarks[:, 1] = frame_dims[0] - landmarks[:, 1] - if self._origin.endswith("right"): + if self.origin.endswith("right"): landmarks[:, 0] = frame_dims[1] - landmarks[:, 0] return landmarks @@ -265,13 +272,16 @@ def on_completion(self) -> None: if self._missing: logger.warning("[ALIGN] %s input frames could not be matched in the import file " "'%s'. Run in verbose mode for a list of frames.", - len(self._missing), self.config["file_name"]) + len(self._missing), cfg.file_name) logger.verbose( # type:ignore[attr-defined] "[ALIGN] Input frames not in import file: %s", self._missing) if self._imported: logger.warning("[ALIGN] %s items in the import file '%s' could not be matched to any " "input frames. Run in verbose mode for a list of items.", - len(self._imported), self.config["file_name"]) + len(self._imported), cfg.file_name) logger.verbose( # type:ignore[attr-defined] "[ALIGN] import file items not in input frames: %s", list(self._imported)) + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/align/external_defaults.py b/plugins/extract/align/external_defaults.py index 875abd01d0..c027bd483a 100644 --- a/plugins/extract/align/external_defaults.py +++ b/plugins/extract/align/external_defaults.py @@ -1,97 +1,77 @@ #!/usr/bin/env python3 -""" - The default options for the faceswap Import Alignments plugin. +""" The default options for the external faceswap Import Alignments plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. +The following variable should be defined: - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does - should always be lower text. - dictionary requirements are listed below. +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid data types are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - group: [optional]. A group for grouping options together in the GUI. If not - provided this will not group this option with any others. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and data types this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and data types this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ +# pylint:disable=duplicate-code +from lib.config import ConfigItem -_HELPTEXT = ( +HELPTEXT = ( "Import Aligner options.\n" "Imports either 68 point 2D landmarks or an aligned bounding box from an external .json file." ) -_DEFAULTS = { - "file_name": { - "default": "import.json", - "info": "The import file should be stored in the same folder as the video (if extracting " - "from a video file) or inside the folder of images (if importing from a folder of images)", - "datatype": str, - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - }, - "origin": { - "default": "top-left", - "info": "The origin (0, 0) location of the co-ordinates system used. " - "\n\t top-left: The origin (0, 0) of the canvas is at the top left " - "corner." - "\n\t bottom-left: The origin (0, 0) of the canvas is at the bottom " - "left corner." - "\n\t top-right: The origin (0, 0) of the canvas is at the top right " - "corner." - "\n\t bottom-right: The origin (0, 0) of the canvas is at the bottom " - "right corner.", - "datatype": str, - "choices": ["top-left", "bottom-left", "top-right", "bottom-right"], - "group": "input", - "gui_radio": True - }, - "4_point_centering": { - "default": "head", - "info": "4 point ROI landmarks only. The approximate centering for the location of the " - "corner points to be imported. Default faceswap extracts are generated at 'head' " - "centering, but it is possible to pass in ROI points at a tighter centering. " - "Refer to https://github.com/deepfakes/faceswap/pull/1095 for a visual guide" - "\n\t head: The ROI points represent a loose crop enclosing the whole head." - "\n\t face: The ROI points represent a medium crop enclosing the face." - "\n\t legacy: The ROI points represent a tight crop enclosing the central face " - "area." - "\n\t none: Only required if importing 4 point ROI landmarks back into faceswap " - "having generated them from the 'alignments' tool 'export' job.", - "datatype": str, - "choices": ["head", "face", "legacy", "none"], - "group": "input", - "gui_radio": True - } +file_name = ConfigItem( + datatype=str, + default="import.json", + group="settings", + info="The import file should be stored in the same folder as the video (if extracting " + "from a video file) or inside the folder of images (if importing from a folder of " + "images)") + +origin = ConfigItem( + datatype=str, + default="top-left", + group="input", + info="The origin (0, 0) location of the co-ordinates system used. " + "\n\t top-left: The origin (0, 0) of the canvas is at the top left " + "corner." + "\n\t bottom-left: The origin (0, 0) of the canvas is at the bottom " + "left corner." + "\n\t top-right: The origin (0, 0) of the canvas is at the top right " + "corner." + "\n\t bottom-right: The origin (0, 0) of the canvas is at the bottom " + "right corner.", + choices=["top-left", "bottom-left", "top-right", "bottom-right"], + gui_radio=True) -} +four_point_centering = ConfigItem( + datatype=str, + default="head", + group="input", + info="4 point ROI landmarks only. The approximate centering for the location of the " + "corner points to be imported. Default faceswap extracts are generated at 'head' " + "centering, but it is possible to pass in ROI points at a tighter centering. " + "Refer to https://github.com/deepfakes/faceswap/pull/1095 for a visual guide" + "\n\t head: The ROI points represent a loose crop enclosing the whole head." + "\n\t face: The ROI points represent a medium crop enclosing the face." + "\n\t legacy: The ROI points represent a tight crop enclosing the central face " + "area." + "\n\t none: Only required if importing 4 point ROI landmarks back into faceswap " + "having generated them from the 'alignments' tool 'export' job.", + choices=["head", "face", "legacy", "none"], + gui_radio=True) diff --git a/plugins/extract/align/fan.py b/plugins/extract/align/fan.py index a829f3bcac..1a38397aa3 100644 --- a/plugins/extract/align/fan.py +++ b/plugins/extract/align/fan.py @@ -10,11 +10,15 @@ import cv2 import numpy as np -from lib.model.session import KSession +from keras.saving import load_model + +from lib.utils import get_module_objects from ._base import Aligner, AlignerBatch, BatchType +from . import fan_defaults as cfg if T.TYPE_CHECKING: from lib.align import DetectedFace + from keras import Model logger = logging.getLogger(__name__) @@ -23,32 +27,30 @@ class Align(Aligner): """ Perform transformation to align and get landmarks """ def __init__(self, **kwargs) -> None: git_model_id = 13 - model_filename = "face-alignment-network_2d4_keras_v2.h5" + model_filename = "face-alignment-network_2d4_keras_v3.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.model: KSession + self.model: Model self.name = "FAN" self.input_size = 256 self.color_format = "RGB" - self.vram = 2240 - self.vram_warnings = 512 # Will run at this with warnings - self.vram_per_batch = 64 + self.vram = 896 # 810 in testing + self.vram_per_batch = 768 # ~720 in testing self.realign_centering = "head" - self.batchsize: int = self.config["batch-size"] + self.batchsize: int = cfg.batch_size() self.reference_scale = 200. / 195. def init_model(self) -> None: """ Initialize FAN model """ assert isinstance(self.name, str) assert isinstance(self.model_path, str) - self.model = KSession(self.name, - self.model_path, - allow_growth=self.config["allow_growth"], - exclude_gpus=self._exclude_gpus) - self.model.load_model() + logging.disable(logging.WARNING) # Disable compile warning from Keras + self.model = load_model(self.model_path, compile=False) + logging.disable(logging.NOTSET) + self.model.make_predict_function() # Feed a placeholder so Aligner is primed for Manual tool placeholder_shape = (self.batchsize, self.input_size, self.input_size, 3) placeholder = np.zeros(placeholder_shape, dtype="float32") - self.model.predict(placeholder) + self.model.predict(placeholder, verbose=False, batch_size=self.batchsize) def faces_to_feed(self, faces: np.ndarray) -> np.ndarray: """ Convert a batch of face images from UINT8 (0-255) to fp32 (0.0-1.0) @@ -221,10 +223,9 @@ def predict(self, feed: np.ndarray) -> np.ndarray: The predictions from the aligner """ logger.trace("Predicting Landmarks") # type:ignore[attr-defined] - # TODO Remove lazy transpose and change points from predict to use the correct - # order - retval = self.model.predict(feed)[-1].transpose(0, 3, 1, 2) - logger.trace(retval.shape) # type:ignore[attr-defined] + retval = self.model.predict(feed, + verbose=False, + batch_size=self.batchsize)[-1].transpose(0, 3, 1, 2) return retval def process_output(self, batch: BatchType) -> None: @@ -279,3 +280,6 @@ def get_pts_from_predict(self, batch: AlignerBatch) -> None: resolution) logger.trace("Obtained points from prediction: %s", # type:ignore[attr-defined] batch.landmarks) + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/align/fan_defaults.py b/plugins/extract/align/fan_defaults.py index 90d5bc4b3a..31072d64c8 100644 --- a/plugins/extract/align/fan_defaults.py +++ b/plugins/extract/align/fan_defaults.py @@ -1,68 +1,48 @@ #!/usr/bin/env python3 -""" - The default options for the faceswap FAN Alignments plugin. +""" The default options for the faceswap FAN Alignments plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) - should always be lower text. - dictionary requirements are listed below. +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid data types are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - group: [optional]. A group for grouping options together in the GUI. If not - provided this will not group this option with any others. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and data types this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and data types this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ +# pylint:disable=duplicate-code +from lib.config import ConfigItem -_HELPTEXT = ( +HELPTEXT = ( "FAN Aligner options.\n" "Fast on GPU, slow on CPU. Best aligner." ) -_DEFAULTS = { - "batch-size": { - "default": 12, - "info": "The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered." - "\n\tAMD users: A batchsize of 8 requires about 4 GB vram.", - "datatype": int, - "rounding": 1, - "min_max": (1, 64), - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - } -} +batch_size = ConfigItem( + datatype=int, + default=12, + group="settings", + info="The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered." + "\n\tAMD users: A batchsize of 8 requires about 4 GB vram.", + rounding=1, + min_max=(1, 64)) diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index 3c3221d84f..61aafbd1f9 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -23,8 +23,7 @@ import cv2 import numpy as np - -from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa +from torch.cuda import OutOfMemoryError from lib.align import DetectedFace from lib.utils import FaceswapError @@ -60,7 +59,7 @@ class DetectorBatch(ExtractorBatch): rotation_matrix: list[np.ndarray] = field(default_factory=list) scale: list[float] = field(default_factory=list) pad: list[tuple[int, int]] = field(default_factory=list) - initial_feed: np.ndarray = np.array([]) + initial_feed: np.ndarray = field(default_factory=lambda: np.array([])) def __repr__(self): """ Prettier repr for debug printing """ @@ -124,7 +123,7 @@ def __init__(self, self.rotation = self._get_rotation_angles(rotation) self.min_size = min_size - self._plugin_type = "detect" + self._info.plugin_type = "detect" logger.debug("Initialized _base %s", self.__class__.__name__) @@ -315,7 +314,7 @@ def _predict(self, batch: BatchType) -> DetectorBatch: logger.trace("angle: %s, filenames: %s, " # type:ignore[attr-defined] "prediction: %s", angle, batch.filename, pred) - except tf_errors.ResourceExhaustedError as err: + except OutOfMemoryError as err: msg = ("You do not have enough GPU memory available to run detection at the " "selected batch size. You can try a number of things:" "\n1) Close any other application that is using your GPU (web browsers are " diff --git a/plugins/extract/detect/cv2_dnn.py b/plugins/extract/detect/cv2_dnn.py index 9f98918e06..7e948eaef6 100644 --- a/plugins/extract/detect/cv2_dnn.py +++ b/plugins/extract/detect/cv2_dnn.py @@ -4,7 +4,9 @@ import numpy as np +from lib.utils import get_module_objects from ._base import BatchType, cv2, Detector, DetectorBatch +from . import cv2_dnn_defaults as cfg logger = logging.getLogger(__name__) @@ -21,7 +23,7 @@ def __init__(self, **kwargs) -> None: self.vram = 0 # CPU Only. Doesn't use VRAM self.vram_per_batch = 0 self.batchsize = 1 - self.confidence = self.config["confidence"] / 100 + self.confidence = cfg.confidence() / 100 def init_model(self) -> None: """ Initialize CV2 DNN Detector Model""" @@ -65,3 +67,6 @@ def finalize_predictions(self, predictions: np.ndarray) -> np.ndarray: def process_output(self, batch: BatchType) -> None: """ Compile found faces for output """ return + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/detect/cv2_dnn_defaults.py b/plugins/extract/detect/cv2_dnn_defaults.py index e50c0ecc49..127fae9370 100755 --- a/plugins/extract/detect/cv2_dnn_defaults.py +++ b/plugins/extract/detect/cv2_dnn_defaults.py @@ -1,66 +1,45 @@ #!/usr/bin/env python3 -""" - The default options for the faceswap Cv2_Dnn Detect plugin. +""" The default options for the faceswap Cv2_Dnn Detect plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) - should always be lower text. - dictionary requirements are listed below. +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid data types are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - group: [optional]. A group for grouping options together in the GUI. If not - provided this will not group this option with any others. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and data types this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and data types this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ +from lib.config import ConfigItem -_HELPTEXT = ( +HELPTEXT = ( "CV2 DNN Detector options.\n" "A CPU only extractor, is the least reliable, but uses least resources and runs fast on CPU. " "Use this if not using a GPU and time is important" ) -_DEFAULTS = { - "confidence": { - "default": 50, - "info": "The confidence level at which the detector has succesfully found a face.\nHigher " - "levels will be more discriminating, lower levels will have more false positives.", - "datatype": int, - "rounding": 5, - "min_max": (25, 100), - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - }, -} +confidence = ConfigItem( + datatype=int, + default=50, + group="settings", + info="The confidence level at which the detector has succesfully found a face.\nHigher " + "levels will be more discriminating, lower levels will have more false positives.", + rounding=5, + min_max=(25, 100)) diff --git a/plugins/extract/detect/external.py b/plugins/extract/detect/external.py index 98876e2a95..074a978796 100644 --- a/plugins/extract/detect/external.py +++ b/plugins/extract/detect/external.py @@ -10,9 +10,10 @@ import numpy as np from lib.align import AlignedFace -from lib.utils import FaceswapError, IMAGE_EXTENSIONS +from lib.utils import get_module_objects, FaceswapError, IMAGE_EXTENSIONS from ._base import Detector +from . import external_defaults as cfg if T.TYPE_CHECKING: from lib.align import DetectedFace @@ -20,6 +21,8 @@ from ._base import BatchType logger = logging.getLogger(__name__) +OriginType = T.Literal["top-left", "bottom-left", "top-right", "bottom-right"] +# pylint:disable=duplicate-code class Detect(Detector): @@ -32,10 +35,11 @@ def __init__(self, **kwargs) -> None: self.name = "External" self.batchsize = 16 - self._origin: T.Literal["top-left", - "bottom-left", - "top-right", - "bottom-right"] = self.config["origin"] + self.origin: OriginType = T.cast(OriginType, cfg.origin()) + """ Literal["top-left", "bottom-left", "top-right", "bottom-right"] : The origin (0, 0) + location of the co-ordinates system used""" + self.file_name = cfg.file_name() + """ str : The file name to import ROI data from """ self._re_frame_no: re.Pattern = re.compile(r"\d+$") self._missing: list[str] = [] @@ -181,10 +185,7 @@ def _bbox_from_landmarks2d(self, landmarks: list[list[float]]) -> np.ndarray: def _import_frame_face(self, face: dict[str, list[int] | list[list[float]]], - align_origin: T.Literal["top-left", - "bottom-left", - "top-right", - "bottom-right"] | None) -> np.ndarray: + align_origin: OriginType | None) -> np.ndarray: """ Import a detected face ROI from the import file Parameters @@ -214,9 +215,9 @@ def _import_frame_face(self, "to poor results") self._log_once = False - if self._log_once and align_origin is not None and align_origin != self._origin: + if self._log_once and align_origin is not None and align_origin != self.origin: logger.info("Updating Detect origin from Aligner config to '%s'", align_origin) - self._origin = align_origin + self.origin = align_origin self._log_once = False return self._bbox_from_landmarks2d(T.cast(list[list[float]], face["landmarks_2d"])) @@ -278,11 +279,11 @@ def _adjust_for_origin(self, box: np.ndarray, frame_dims: tuple[int, int]) -> np :class:`numpy.ndarray` The adjusted bounding box for a top-left origin """ - if not np.any(box) or self._origin == "top-left": + if not np.any(box) or self.origin == "top-left": return box - if self._origin.startswith("bottom"): + if self.origin.startswith("bottom"): box[:, [1, 3]] = frame_dims[0] - box[:, [1, 3]] - if self._origin.endswith("right"): + if self.origin.endswith("right"): box[:, [0, 2]] = frame_dims[1] - box[:, [0, 2]] return box @@ -341,13 +342,16 @@ def on_completion(self) -> None: if self._missing: logger.warning("[DETECT] %s input frames could not be matched in the import file " "'%s'. Run in verbose mode for a list of frames.", - len(self._missing), self.config["file_name"]) + len(self._missing), cfg.file_name()) logger.verbose( # type:ignore[attr-defined] "[DETECT] Input frames not in import file: %s", self._missing) if self._imported: logger.warning("[DETECT] %s items in the import file '%s' could not be matched to any " "input frames. Run in verbose mode for a list of items.", - len(self._imported), self.config["file_name"]) + len(self._imported), cfg.file_name()) logger.verbose( # type:ignore[attr-defined] "[DETECT] import file items not in input frames: %s", list(self._imported)) + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/detect/external_defaults.py b/plugins/extract/detect/external_defaults.py index c444bf419b..dd112566ac 100644 --- a/plugins/extract/detect/external_defaults.py +++ b/plugins/extract/detect/external_defaults.py @@ -1,79 +1,60 @@ #!/usr/bin/env python3 -""" - The default options for the faceswap Import Alignments plugin. +""" The default options for the faceswap Import Alignments plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) - should always be lower text. - dictionary requirements are listed below. +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid data types are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - group: [optional]. A group for grouping options together in the GUI. If not - provided this will not group this option with any others. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and data types this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and data types this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ +# pylint:disable=duplicate-code +from lib.config import ConfigItem -_HELPTEXT = ( +HELPTEXT = ( "Import Detector options.\n" "Imports a detected face bounding box from an external .json file.\n" ) -_DEFAULTS = { - "file_name": { - "default": "import.json", - "info": "The import file should be stored in the same folder as the video (if extracting " - "from a video file) or inside the folder of images (if importing from a folder of images)", - "datatype": str, - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - }, - "origin": { - "default": "top-left", - "info": "The origin (0, 0) location of the co-ordinates system used. " - "\n\t top-left: The origin (0, 0) of the canvas is at the top left " - "corner." - "\n\t bottom-left: The origin (0, 0) of the canvas is at the bottom " - "left corner." - "\n\t top-right: The origin (0, 0) of the canvas is at the top right " - "corner." - "\n\t bottom-right: The origin (0, 0) of the canvas is at the bottom " - "right corner.", - "datatype": str, - "choices": ["top-left", "bottom-left", "top-right", "bottom-right"], - "group": "output", - "gui_radio": True - } -} +file_name = ConfigItem( + datatype=str, + default="import.json", + group="settings", + info="The import file should be stored in the same folder as the video (if extracting " + "from a video file) or inside the folder of images (if importing from a folder of " + "images)") + +origin = ConfigItem( + datatype=str, + default="top-left", + group="output", + info="The origin (0, 0) location of the co-ordinates system used. " + "\n\t top-left: The origin (0, 0) of the canvas is at the top left " + "corner." + "\n\t bottom-left: The origin (0, 0) of the canvas is at the bottom " + "left corner." + "\n\t top-right: The origin (0, 0) of the canvas is at the top right " + "corner." + "\n\t bottom-right: The origin (0, 0) of the canvas is at the bottom " + "right corner.", + choices=["top-left", "bottom-left", "top-right", "bottom-right"], + gui_radio=True) diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py index 78859ca249..16533e382a 100644 --- a/plugins/extract/detect/mtcnn.py +++ b/plugins/extract/detect/mtcnn.py @@ -7,14 +7,14 @@ import cv2 import numpy as np -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras.layers import Conv2D, Dense, Flatten, Input, MaxPool2D, Permute, PReLU # noqa:E501 # pylint:disable=import-error +from keras.models import Model +from keras.layers import Conv2D, Dense, Flatten, Input, MaxPooling2D, Permute, PReLU -from lib.model.session import KSession +from lib.logger import parse_class_init +from lib.utils import get_module_objects from ._base import BatchType, Detector +from . import mtcnn_defaults as cfg -if T.TYPE_CHECKING: - from tensorflow import Tensor logger = logging.getLogger(__name__) @@ -26,24 +26,29 @@ def __init__(self, **kwargs) -> None: model_filename = ["mtcnn_det_v2.1.h5", "mtcnn_det_v2.2.h5", "mtcnn_det_v2.3.h5"] super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) self.name = "MTCNN" + self.model: MTCNN self.input_size = 640 - self.vram = 320 if not self.config["cpu"] else 0 - self.vram_warnings = 64 if not self.config["cpu"] else 0 # Will run at this with warnings - self.vram_per_batch = 32 if not self.config["cpu"] else 0 - self.batchsize = self.config["batch-size"] - self.kwargs = self._validate_kwargs() + self.vram = 128 if not cfg.cpu() else 0 # 66 in testing + self.vram_per_batch = 64 if not cfg.cpu() else 0 # ~50 in testing + self.batchsize = cfg.batch_size() + self._kwargs = self._validate_kwargs() self.color_format = "RGB" - def _validate_kwargs(self) -> dict[str, int | float | list[float]]: + def _validate_kwargs(self) -> dict[T.Literal["minsize", "threshold", "factor", "input_size"], + int | float | list[float]]: """ Validate that config options are correct. If not reset to default """ valid = True - threshold = [self.config["threshold_1"], - self.config["threshold_2"], - self.config["threshold_3"]] - kwargs = {"minsize": self.config["minsize"], - "threshold": threshold, - "factor": self.config["scalefactor"], - "input_size": self.input_size} + threshold = [cfg.threshold_1(), cfg.threshold_2(), cfg.threshold_3()] + kwargs: dict[T.Literal["minsize", "threshold", "factor", "input_size"], + int | float | list[float]] = {"minsize": cfg.minsize(), + "threshold": threshold, + "factor": cfg.scalefactor(), + "input_size": self.input_size} + + assert isinstance(kwargs["input_size"], int) + assert isinstance(kwargs["minsize"], int) + assert isinstance(kwargs["threshold"], list) + assert isinstance(kwargs["factor"], float) if kwargs["minsize"] < 10: valid = False @@ -62,11 +67,22 @@ def _validate_kwargs(self) -> dict[str, int | float | list[float]]: def init_model(self) -> None: """ Initialize MTCNN Model. """ assert isinstance(self.model_path, list) - self.model = MTCNN(self.model_path, - self.config["allow_growth"], - self._exclude_gpus, - self.config["cpu"], - **self.kwargs) # type:ignore + placeholder_shape = (self.batchsize, self.input_size, self.input_size, 3) + placeholder = np.zeros(placeholder_shape, dtype="float32") + + assert isinstance(self._kwargs["input_size"], int) + assert isinstance(self._kwargs["minsize"], int) + assert isinstance(self._kwargs["threshold"], list) + assert isinstance(self._kwargs["factor"], float) + + with self.get_device_context(cfg.cpu()): + self.model = MTCNN(self.model_path, + self.batchsize, + input_size=self._kwargs["input_size"], + minsize=self._kwargs["minsize"], + threshold=self._kwargs["threshold"], + factor=self._kwargs["factor"]) + self.model.detect_faces(placeholder) def process_input(self, batch: BatchType) -> None: """ Compile the detection image(s) for prediction @@ -92,8 +108,9 @@ def predict(self, feed: np.ndarray) -> np.ndarray: The batch with the predictions added to the dictionary """ assert isinstance(self.model, MTCNN) - prediction, points = self.model.detect_faces(feed) - logger.trace("prediction: %s, mtcnn_points: %s", # type:ignore + with self.get_device_context(cfg.cpu()): + prediction, points = self.model.detect_faces(feed) + logger.trace("prediction: %s, mtcnn_points: %s", # type:ignore[attr-defined] prediction, points) return prediction @@ -138,22 +155,15 @@ def process_output(self, batch: BatchType) -> None: # SOFTWARE. -class PNet(KSession): +class PNet(): """ Keras P-Net model for MTCNN Parameters ---------- - model_path: str + weights_path: str The path to the keras model file - allow_growth: bool, optional - Enable the Tensorflow GPU allow_growth configuration option. This option prevents - Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation and - slower performance. Default: ``False`` - exclude_gpus: list, optional - A list of indices correlating to connected GPUs that Tensorflow should not use. Pass - ``None`` to not exclude any GPUs. Default: ``None`` - cpu_mode: bool, optional - ``True`` run the model on CPU. Default: ``False`` + batch_size: int + The batch size to feed the model input_size: int The input size of the model minsize: int, optional @@ -162,22 +172,15 @@ class PNet(KSession): Threshold for P-Net """ def __init__(self, - model_path: str, - allow_growth: bool, - exclude_gpus: list[int] | None, - cpu_mode: bool, + weights_path: str, + batch_size: int, input_size: int, min_size: int, factor: float, threshold: float) -> None: - super().__init__("MTCNN-PNet", - model_path, - allow_growth=allow_growth, - exclude_gpus=exclude_gpus, - cpu_mode=cpu_mode) - - self.define_model(self.model_definition) - self.load_model_weights() + logger.debug(parse_class_init(locals())) + self._batch_size = batch_size + self._model = self._load_model(weights_path) self._input_size = input_size self._threshold = threshold @@ -186,21 +189,37 @@ def __init__(self, self._pnet_sizes = [(int(input_size * scale), int(input_size * scale)) for scale in self._pnet_scales] self._pnet_input: list[np.ndarray] | None = None + logger.debug("Initialized: %s", self.__class__.__name__) @staticmethod - def model_definition() -> tuple[list[Tensor], list[Tensor]]: - """ Keras P-Network Definition for MTCNN """ + def _load_model(weights_path: str) -> Model: + """ Keras P-Network Definition for MTCNN + + Parameters + ---------- + weights_path: str + Full path to the model's weights + + Returns + ------- + :class:`keras.models.Model` + The p-net model + """ input_ = Input(shape=(None, None, 3)) var_x = Conv2D(10, (3, 3), strides=1, padding='valid', name='conv1')(input_) var_x = PReLU(shared_axes=[1, 2], name='PReLU1')(var_x) - var_x = MaxPool2D(pool_size=2)(var_x) + var_x = MaxPooling2D(pool_size=2)(var_x) var_x = Conv2D(16, (3, 3), strides=1, padding='valid', name='conv2')(var_x) var_x = PReLU(shared_axes=[1, 2], name='PReLU2')(var_x) var_x = Conv2D(32, (3, 3), strides=1, padding='valid', name='conv3')(var_x) var_x = PReLU(shared_axes=[1, 2], name='PReLU3')(var_x) classifier = Conv2D(2, (1, 1), activation='softmax', name='conv4-1')(var_x) bbox_regress = Conv2D(4, (1, 1), name='conv4-2')(var_x) - return [input_], [classifier, bbox_regress] + + retval = Model(input_, [classifier, bbox_regress]) + retval.load_weights(weights_path) + retval.make_predict_function() + return retval def _calculate_scales(self, minsize: int, @@ -228,52 +247,9 @@ def _calculate_scales(self, scales += [var_m * np.power(factor, factor_count)] minl = minl * factor factor_count += 1 - logger.trace(scales) # type:ignore + logger.trace(scales) # type:ignore[attr-defined] return scales - def __call__(self, images: np.ndarray) -> list[np.ndarray]: - """ first stage - fast proposal network (p-net) to obtain face candidates - - Parameters - ---------- - images: :class:`numpy.ndarray` - The batch of images to detect faces in - - Returns - ------- - List - List of face candidates from P-Net - """ - batch_size = images.shape[0] - rectangles: list[list[list[int | float]]] = [[] for _ in range(batch_size)] - scores: list[list[np.ndarray]] = [[] for _ in range(batch_size)] - - if self._pnet_input is None: - self._pnet_input = [np.empty((batch_size, rheight, rwidth, 3), dtype="float32") - for rheight, rwidth in self._pnet_sizes] - - for scale, batch, (rheight, rwidth) in zip(self._pnet_scales, - self._pnet_input, - self._pnet_sizes): - _ = [cv2.resize(images[idx], (rwidth, rheight), dst=batch[idx]) - for idx in range(batch_size)] - cls_prob, roi = self.predict(batch) - cls_prob = cls_prob[..., 1] - out_side = max(cls_prob.shape[1:3]) - cls_prob = np.swapaxes(cls_prob, 1, 2) - roi = np.swapaxes(roi, 1, 3) - for idx in range(batch_size): - # first index 0 = class score, 1 = one hot representation - rect, score = self._detect_face_12net(cls_prob[idx, ...], - roi[idx, ...], - out_side, - 1 / scale) - rectangles[idx].extend(rect) - scores[idx].extend(score) - - return [nms(np.array(rect), np.array(score), 0.7, "iou")[0] # don't output scores - for rect, score in zip(rectangles, scores)] - def _detect_face_12net(self, class_probabilities: np.ndarray, roi: np.ndarray, @@ -318,23 +294,59 @@ def _detect_face_12net(self, return nms(rects, scores, 0.3, "iou") + def __call__(self, images: np.ndarray) -> list[np.ndarray]: + """ first stage - fast proposal network (p-net) to obtain face candidates + + Parameters + ---------- + images: :class:`numpy.ndarray` + The batch of images to detect faces in + + Returns + ------- + List + List of face candidates from P-Net + """ + batch_size = images.shape[0] + rectangles: list[list[list[int | float]]] = [[] for _ in range(batch_size)] + scores: list[list[np.ndarray]] = [[] for _ in range(batch_size)] + + if self._pnet_input is None: + self._pnet_input = [np.empty((batch_size, rheight, rwidth, 3), dtype="float32") + for rheight, rwidth in self._pnet_sizes] + + for scale, batch, (rheight, rwidth) in zip(self._pnet_scales, + self._pnet_input, + self._pnet_sizes): + _ = [cv2.resize(images[idx], (rwidth, rheight), dst=batch[idx]) + for idx in range(batch_size)] + cls_prob, roi = self._model.predict(batch, verbose=0, batch_size=self._batch_size) + cls_prob = cls_prob[..., 1] + out_side = max(cls_prob.shape[1:3]) + cls_prob = np.swapaxes(cls_prob, 1, 2) + roi = np.swapaxes(roi, 1, 3) + for idx in range(batch_size): + # first index 0 = class score, 1 = one hot representation + rect, score = self._detect_face_12net(cls_prob[idx, ...], + roi[idx, ...], + out_side, + 1 / scale) + rectangles[idx].extend(rect) + scores[idx].extend(score) + + return [nms(np.array(rect), np.array(score), 0.7, "iou")[0] # don't output scores + for rect, score in zip(rectangles, scores)] -class RNet(KSession): + +class RNet(): """ Keras R-Net model Definition for MTCNN Parameters ---------- - model_path: str + weights_path: str The path to the keras model file - allow_growth: bool, optional - Enable the Tensorflow GPU allow_growth configuration option. This option prevents - Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation and - slower performance. Default: ``False`` - exclude_gpus: list, optional - A list of indices correlating to connected GPUs that Tensorflow should not use. Pass - ``None`` to not exclude any GPUs. Default: ``None`` - cpu_mode: bool, optional - ``True`` run the model on CPU. Default: ``False`` + batch_size: int + The batch size to feed the model input_size: int The input size of the model threshold: list, optional @@ -342,34 +354,39 @@ class RNet(KSession): """ def __init__(self, - model_path: str, - allow_growth: bool, - exclude_gpus: list[int] | None, - cpu_mode: bool, + weights_path: str, + batch_size: int, input_size: int, threshold: float) -> None: - super().__init__("MTCNN-RNet", - model_path, - allow_growth=allow_growth, - exclude_gpus=exclude_gpus, - cpu_mode=cpu_mode) - self.define_model(self.model_definition) - self.load_model_weights() - + logger.debug(parse_class_init(locals())) + self._batch_size = batch_size + self._model = self._load_model(weights_path) self._input_size = input_size self._threshold = threshold + logger.debug("Initialized: %s", self.__class__.__name__) @staticmethod - def model_definition() -> tuple[list[Tensor], list[Tensor]]: - """ Keras R-Network Definition for MTCNN """ + def _load_model(weights_path: str) -> Model: + """ Keras R-Network Definition for MTCNN + + Parameters + ---------- + weights_path: str + Full path to the model's weights + + Returns + ------- + :class:`keras.models.Model` + The r-net model + """ input_ = Input(shape=(24, 24, 3)) var_x = Conv2D(28, (3, 3), strides=1, padding='valid', name='conv1')(input_) var_x = PReLU(shared_axes=[1, 2], name='prelu1')(var_x) - var_x = MaxPool2D(pool_size=3, strides=2, padding='same')(var_x) + var_x = MaxPooling2D(pool_size=3, strides=2, padding='same')(var_x) var_x = Conv2D(48, (3, 3), strides=1, padding='valid', name='conv2')(var_x) var_x = PReLU(shared_axes=[1, 2], name='prelu2')(var_x) - var_x = MaxPool2D(pool_size=3, strides=2)(var_x) + var_x = MaxPooling2D(pool_size=3, strides=2)(var_x) var_x = Conv2D(64, (2, 2), strides=1, padding='valid', name='conv3')(var_x) var_x = PReLU(shared_axes=[1, 2], name='prelu3')(var_x) @@ -379,42 +396,11 @@ def model_definition() -> tuple[list[Tensor], list[Tensor]]: var_x = PReLU(name='prelu4')(var_x) classifier = Dense(2, activation='softmax', name='conv5-1')(var_x) bbox_regress = Dense(4, name='conv5-2')(var_x) - return [input_], [classifier, bbox_regress] - def __call__(self, - images: np.ndarray, - rectangle_batch: list[np.ndarray], - ) -> list[np.ndarray]: - """ second stage - refinement of face candidates with r-net - - Parameters - ---------- - images: :class:`numpy.ndarray` - The batch of images to detect faces in - rectangle_batch: - List of :class:`numpy.ndarray` face candidates from P-Net - - Returns - ------- - List - List of :class:`numpy.ndarray` refined face candidates from R-Net - """ - ret: list[np.ndarray] = [] - for idx, (rectangles, image) in enumerate(zip(rectangle_batch, images)): - if not np.any(rectangles): - ret.append(np.array([])) - continue - - feed_batch = np.empty((rectangles.shape[0], 24, 24, 3), dtype="float32") - - _ = [cv2.resize(image[rect[1]: rect[3], rect[0]: rect[2]], - (24, 24), - dst=feed_batch[idx]) - for idx, rect in enumerate(rectangles)] - - cls_prob, roi_prob = self.predict(feed_batch) - ret.append(self._filter_face_24net(cls_prob, roi_prob, rectangles)) - return ret + retval = Model(input_, [classifier, bbox_regress]) + retval.load_weights(weights_path) + retval.make_predict_function() + return retval def _filter_face_24net(self, class_probabilities: np.ndarray, @@ -449,59 +435,94 @@ def _filter_face_24net(self, bbox = np.clip(rect2square(bbox), 0, self._input_size).astype("int") return nms(bbox, scores, 0.3, "iou")[0] + def __call__(self, + images: np.ndarray, + rectangle_batch: list[np.ndarray], + ) -> list[np.ndarray]: + """ second stage - refinement of face candidates with r-net + + Parameters + ---------- + images: :class:`numpy.ndarray` + The batch of images to detect faces in + rectangle_batch: + List of :class:`numpy.ndarray` face candidates from P-Net -class ONet(KSession): + Returns + ------- + List + List of :class:`numpy.ndarray` refined face candidates from R-Net + """ + ret: list[np.ndarray] = [] + for idx, (rectangles, image) in enumerate(zip(rectangle_batch, images)): + if not np.any(rectangles): + ret.append(np.array([])) + continue + + feed_batch = np.empty((rectangles.shape[0], 24, 24, 3), dtype="float32") + + _ = [cv2.resize(image[rect[1]: rect[3], rect[0]: rect[2]], + (24, 24), + dst=feed_batch[idx]) + for idx, rect in enumerate(rectangles)] + + cls_prob, roi_prob = self._model.predict(feed_batch, + verbose=0, + batch_size=self._batch_size) + ret.append(self._filter_face_24net(cls_prob, roi_prob, rectangles)) + return ret + + +class ONet(): """ Keras O-Net model for MTCNN Parameters ---------- - model_path: str + weights_path: str The path to the keras model file - allow_growth: bool, optional - Enable the Tensorflow GPU allow_growth configuration option. This option prevents - Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation and - slower performance. Default: ``False`` - exclude_gpus: list, optional - A list of indices correlating to connected GPUs that Tensorflow should not use. Pass - ``None`` to not exclude any GPUs. Default: ``None`` - cpu_mode: bool, optional - ``True`` run the model on CPU. Default: ``False`` + batch_size: int + The batch size to feed the model input_size: int The input size of the model threshold: list, optional Threshold for O-Net """ def __init__(self, - model_path: str, - allow_growth: bool, - exclude_gpus: list[int] | None, - cpu_mode: bool, + weights_path: str, + batch_size: int, input_size: int, threshold: float) -> None: - super().__init__("MTCNN-ONet", - model_path, - allow_growth=allow_growth, - exclude_gpus=exclude_gpus, - cpu_mode=cpu_mode) - self.define_model(self.model_definition) - self.load_model_weights() - + logger.debug(parse_class_init(locals())) + self._batch_size = batch_size + self._model = self._load_model(weights_path) self._input_size = input_size self._threshold = threshold + logger.debug("Initialized: %s", self.__class__.__name__) @staticmethod - def model_definition() -> tuple[list[Tensor], list[Tensor]]: - """ Keras O-Network for MTCNN """ + def _load_model(weights_path: str) -> Model: + """ Keras P-Network Definition for MTCNN + + Parameters + ---------- + weights_path: str + Full path to the model's weights + + Returns + ------- + :class:`keras.models.Model` + The p-net model + """ input_ = Input(shape=(48, 48, 3)) var_x = Conv2D(32, (3, 3), strides=1, padding='valid', name='conv1')(input_) var_x = PReLU(shared_axes=[1, 2], name='prelu1')(var_x) - var_x = MaxPool2D(pool_size=3, strides=2, padding='same')(var_x) + var_x = MaxPooling2D(pool_size=3, strides=2, padding='same')(var_x) var_x = Conv2D(64, (3, 3), strides=1, padding='valid', name='conv2')(var_x) var_x = PReLU(shared_axes=[1, 2], name='prelu2')(var_x) - var_x = MaxPool2D(pool_size=3, strides=2)(var_x) + var_x = MaxPooling2D(pool_size=3, strides=2)(var_x) var_x = Conv2D(64, (3, 3), strides=1, padding='valid', name='conv3')(var_x) var_x = PReLU(shared_axes=[1, 2], name='prelu3')(var_x) - var_x = MaxPool2D(pool_size=2)(var_x) + var_x = MaxPooling2D(pool_size=2)(var_x) var_x = Conv2D(128, (2, 2), strides=1, padding='valid', name='conv4')(var_x) var_x = PReLU(shared_axes=[1, 2], name='prelu4')(var_x) var_x = Permute((3, 2, 1))(var_x) @@ -512,42 +533,10 @@ def model_definition() -> tuple[list[Tensor], list[Tensor]]: classifier = Dense(2, activation='softmax', name='conv6-1')(var_x) bbox_regress = Dense(4, name='conv6-2')(var_x) landmark_regress = Dense(10, name='conv6-3')(var_x) - return [input_], [classifier, bbox_regress, landmark_regress] - - def __call__(self, - images: np.ndarray, - rectangle_batch: list[np.ndarray] - ) -> list[tuple[np.ndarray, np.ndarray]]: - """ Third stage - further refinement and facial landmarks positions with o-net - - Parameters - ---------- - images: :class:`numpy.ndarray` - The batch of images to detect faces in - rectangle_batch: - List of :class:`numpy.ndarray` face candidates from R-Net - - Returns - ------- - List - List of refined final candidates, scores and landmark points from O-Net - """ - ret: list[tuple[np.ndarray, np.ndarray]] = [] - for idx, rectangles in enumerate(rectangle_batch): - if not np.any(rectangles): - ret.append((np.empty((0, 5)), np.empty(0))) - continue - image = images[idx] - feed_batch = np.empty((rectangles.shape[0], 48, 48, 3), dtype="float32") - - _ = [cv2.resize(image[rect[1]: rect[3], rect[0]: rect[2]], - (48, 48), - dst=feed_batch[idx]) - for idx, rect in enumerate(rectangles)] - - cls_probs, roi_probs, pts_probs = self.predict(feed_batch) - ret.append(self._filter_face_48net(cls_probs, roi_probs, pts_probs, rectangles)) - return ret + retval = Model(input_, [classifier, bbox_regress, landmark_regress]) + retval.load_weights(weights_path) + retval.make_predict_function() + return retval def _filter_face_48net(self, class_probabilities: np.ndarray, roi: np.ndarray, @@ -595,23 +584,53 @@ def _filter_face_48net(self, class_probabilities: np.ndarray, results, scores = nms(picks, scores, 0.3, "iom") return np.concatenate([results[..., :4], scores[..., None]], axis=-1), results[..., 4:].T + def __call__(self, + images: np.ndarray, + rectangle_batch: list[np.ndarray] + ) -> list[tuple[np.ndarray, np.ndarray]]: + """ Third stage - further refinement and facial landmarks positions with o-net + + Parameters + ---------- + images: :class:`numpy.ndarray` + The batch of images to detect faces in + rectangle_batch: + List of :class:`numpy.ndarray` face candidates from R-Net + + Returns + ------- + List + List of refined final candidates, scores and landmark points from O-Net + """ + ret: list[tuple[np.ndarray, np.ndarray]] = [] + for idx, rectangles in enumerate(rectangle_batch): + if not np.any(rectangles): + ret.append((np.empty((0, 5)), np.empty(0))) + continue + image = images[idx] + feed_batch = np.empty((rectangles.shape[0], 48, 48, 3), dtype="float32") + + _ = [cv2.resize(image[rect[1]: rect[3], rect[0]: rect[2]], + (48, 48), + dst=feed_batch[idx]) + for idx, rect in enumerate(rectangles)] -class MTCNN(): # pylint:disable=too-few-public-methods + cls_probs, roi_probs, pts_probs = self._model.predict(feed_batch, + verbose=0, + batch_size=self._batch_size) + ret.append(self._filter_face_48net(cls_probs, roi_probs, pts_probs, rectangles)) + return ret + + +class MTCNN(): """ MTCNN Detector for face alignment Parameters ---------- - model_path: list + weights_path: list List of paths to the 3 MTCNN subnet weights - allow_growth: bool, optional - Enable the Tensorflow GPU allow_growth configuration option. This option prevents - Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation and - slower performance. Default: ``False`` - exclude_gpus: list, optional - A list of indices correlating to connected GPUs that Tensorflow should not use. Pass - ``None`` to not exclude any GPUs. Default: ``None`` - cpu_mode: bool, optional - ``True`` run the model on CPU. Default: ``False`` + batch_size: int + The batch size to feed the model input_size: int, optional The height, width input size to the model. Default: 640 minsize: int, optional @@ -623,41 +642,28 @@ class MTCNN(): # pylint:disable=too-few-public-methods Default: `0.709` """ def __init__(self, - model_path: list[str], - allow_growth: bool, - exclude_gpus: list[int] | None, - cpu_mode: bool, + weights_path: list[str], + batch_size: int, input_size: int = 640, minsize: int = 20, threshold: list[float] | None = None, factor: float = 0.709) -> None: - logger.debug("Initializing: %s: (model_path: '%s', allow_growth: %s, exclude_gpus: %s, " - "input_size: %s, minsize: %s, threshold: %s, factor: %s)", - self.__class__.__name__, model_path, allow_growth, exclude_gpus, - input_size, minsize, threshold, factor) - + logger.debug(parse_class_init(locals())) threshold = [0.6, 0.7, 0.7] if threshold is None else threshold - self._pnet = PNet(model_path[0], - allow_growth, - exclude_gpus, - cpu_mode, + self._pnet = PNet(weights_path[0], + batch_size, input_size, minsize, factor, threshold[0]) - self._rnet = RNet(model_path[1], - allow_growth, - exclude_gpus, - cpu_mode, + self._rnet = RNet(weights_path[1], + batch_size, input_size, threshold[1]) - self._onet = ONet(model_path[2], - allow_growth, - exclude_gpus, - cpu_mode, + self._onet = ONet(weights_path[2], + batch_size, input_size, threshold[2]) - logger.debug("Initialized: %s", self.__class__.__name__) def detect_faces(self, batch: np.ndarray) -> tuple[np.ndarray, tuple[np.ndarray]]: @@ -752,3 +758,6 @@ def rect2square(rectangles: np.ndarray) -> np.ndarray: rectangles[:, 1] = rectangles[:, 1] + height * 0.5 - length * 0.5 rectangles[:, 2:4] = rectangles[:, 0:2] + np.repeat([length], 2, axis=0).T return rectangles + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/detect/mtcnn_defaults.py b/plugins/extract/detect/mtcnn_defaults.py index 17396669a1..8c4517b7ec 100755 --- a/plugins/extract/detect/mtcnn_defaults.py +++ b/plugins/extract/detect/mtcnn_defaults.py @@ -1,133 +1,98 @@ #!/usr/bin/env python3 +""" The default options for the faceswap Mtcnn Detect plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: + + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does + +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) + +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ - The default options for the faceswap Mtcnn Detect plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} - - should always be lower text. - dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid data types are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - group: [optional]. A group for grouping options together in the GUI. If not - provided this will not group this option with any others. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and data types this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and data types this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" +# pylint:disable=duplicate-code +from lib.config import ConfigItem -_HELPTEXT = ( +HELPTEXT = ( "MTCNN Detector options.\n" "Fast on GPU, slow on CPU. Uses fewer resources than other GPU detectors but can often return " "more false positives." ) -_DEFAULTS = { - "minsize": { - "default": 20, - "info": "The minimum size of a face (in pixels) to be accepted as a positive match." - "\nLower values use significantly more VRAM and will detect more false positives.", - "datatype": int, - "rounding": 10, - "min_max": (20, 1000), - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - }, - "scalefactor": { - "default": 0.709, - "info": "The scale factor for the image pyramid.", - "datatype": float, - "rounding": 3, - "min_max": (0.1, 0.9), - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - }, - "batch-size": { - "default": 8, - "info": "The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered.", - "datatype": int, - "rounding": 1, - "min_max": (1, 64), - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - }, - "cpu": { - "default": True, - "info": "MTCNN detector still runs fairly quickly on CPU on some setups. " - "Enable CPU mode here to use the CPU for this detector to save some VRAM at a " - "speed cost.", - "datatype": bool, - "group": "settings" - }, - "threshold_1": { - "default": 0.6, - "info": "First stage threshold for face detection. This stage obtains face candidates.", - "datatype": float, - "rounding": 2, - "min_max": (0.1, 0.9), - "choices": [], - "group": "threshold", - "gui_radio": False, - "fixed": True, - }, - "threshold_2": { - "default": 0.7, - "info": "Second stage threshold for face detection. This stage refines face candidates.", - "datatype": float, - "rounding": 2, - "min_max": (0.1, 0.9), - "choices": [], - "group": "threshold", - "gui_radio": False, - "fixed": True, - }, - "threshold_3": { - "default": 0.7, - "info": "Third stage threshold for face detection. This stage further refines face " - "candidates.", - "datatype": float, - "rounding": 2, - "min_max": (0.1, 0.9), - "choices": [], - "group": "threshold", - "gui_radio": False, - "fixed": True, - }, -} +minsize = ConfigItem( + datatype=int, + default=20, + group="settings", + info="The minimum size of a face (in pixels) to be accepted as a positive match." + "\nLower values use significantly more VRAM and will detect more false positives.", + rounding=10, + min_max=(20, 1000)) + +scalefactor = ConfigItem( + datatype=float, + default=0.709, + group="settings", + info="The scale factor for the image pyramid.", + rounding=3, + min_max=(0.1, 0.9)) + +batch_size = ConfigItem( + datatype=int, + default=8, + group="settings", + info="The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered.", + rounding=1, + min_max=(1, 64)) + +cpu = ConfigItem( + datatype=bool, + default=True, + group="settings", + info="MTCNN detector still runs fairly quickly on CPU on some setups. " + "Enable CPU mode here to use the CPU for this detector to save some VRAM at a " + "speed cost.") + +threshold_1 = ConfigItem( + datatype=float, + default=0.6, + group="threshold", + info="First stage threshold for face detection. This stage obtains face candidates.", + rounding=2, + min_max=(0.1, 0.9)) + +threshold_2 = ConfigItem( + datatype=float, + default=0.7, + group="threshold", + info="Second stage threshold for face detection. This stage refines face candidates.", + rounding=2, + min_max=(0.1, 0.9)) + +threshold_3 = ConfigItem( + datatype=float, + default=0.7, + group="threshold", + info="Third stage threshold for face detection. This stage further refines face " + "candidates.", + rounding=2, + min_max=(0.1, 0.9)) diff --git a/plugins/extract/detect/s3fd.py b/plugins/extract/detect/s3fd.py index 89d538b76f..43a5a4822f 100644 --- a/plugins/extract/detect/s3fd.py +++ b/plugins/extract/detect/s3fd.py @@ -12,17 +12,17 @@ from scipy.special import logsumexp import numpy as np -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow import keras -from tensorflow.keras import backend as K # pylint:disable=import-error -from tensorflow.keras.layers import ( # pylint:disable=import-error - Concatenate, Conv2D, Input, Maximum, MaxPooling2D, ZeroPadding2D) +from keras.layers import (Concatenate, Conv2D, Input, Layer, Maximum, MaxPooling2D, ZeroPadding2D) +from keras.models import Model +from keras import initializers, ops -from lib.model.session import KSession +from lib.logger import parse_class_init +from lib.utils import get_module_objects from ._base import BatchType, Detector +from . import s3fd_defaults as cfg if T.TYPE_CHECKING: - from tensorflow import Tensor + from keras import KerasTensor logger = logging.getLogger(__name__) @@ -33,23 +33,21 @@ def __init__(self, **kwargs) -> None: git_model_id = 11 model_filename = "s3fd_keras_v2.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) + self.model: S3fd self.name = "S3FD" self.input_size = 640 - self.vram = 4112 - self.vram_warnings = 1024 # Will run at this with warnings - self.vram_per_batch = 208 - self.batchsize = self.config["batch-size"] + self.vram = 1088 # 1034 in testing + self.vram_per_batch = 960 # 922 in testing + self.batchsize = cfg.batch_size() def init_model(self) -> None: """ Initialize S3FD Model""" assert isinstance(self.model_path, str) - confidence = self.config["confidence"] / 100 - model_kwargs = {"custom_objects": {"L2Norm": L2Norm, "SliceO2K": SliceO2K}} - self.model = S3fd(self.model_path, - model_kwargs, - self.config["allow_growth"], - self._exclude_gpus, - confidence) + confidence = cfg.confidence() / 100 + self.model = S3fd(self.model_path, self.batchsize, confidence) + placeholder_shape = (self.batchsize, self.input_size, self.input_size, 3) + placeholder = np.zeros(placeholder_shape, dtype="float32") + self.model(placeholder) def process_input(self, batch: BatchType) -> None: """ Compile the detection image(s) for prediction """ @@ -59,7 +57,7 @@ def process_input(self, batch: BatchType) -> None: def predict(self, feed: np.ndarray) -> np.ndarray: """ Run model to get predictions """ assert isinstance(self.model, S3fd) - predictions = self.model.predict(feed) + predictions = self.model(feed) assert isinstance(predictions, list) return self.model.finalize_predictions(predictions) @@ -71,7 +69,7 @@ def process_output(self, batch) -> None: ################################################################################ # CUSTOM KERAS LAYERS ################################################################################ -class L2Norm(keras.layers.Layer): +class L2Norm(Layer): # pylint:disable=too-many-ancestors,abstract-method """ L2 Normalization layer for S3FD. Parameters @@ -85,27 +83,28 @@ def __init__(self, n_channels: int, scale: float = 1.0, **kwargs) -> None: super().__init__(**kwargs) self._n_channels = n_channels self._scale = scale - self.w = self.add_weight("l2norm", # pylint:disable=invalid-name - (self._n_channels, ), - trainable=True, - initializer=keras.initializers.Constant(value=self._scale), - dtype="float32") - - def call(self, inputs: Tensor) -> Tensor: # pylint:disable=arguments-differ + self.weight = self.add_weight(name="l2norm", + shape=(self._n_channels, ), + trainable=True, + initializer=initializers.Constant(value=self._scale), + dtype="float32") + + def call(self, inputs: KerasTensor, **kwargs # pylint:disable=arguments-differ + ) -> KerasTensor: """ Call the L2 Normalization Layer. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input to the L2 Normalization Layer Returns ------- - tensor: + :class:`keras.KerasTensor`: The output from the L2 Normalization Layer """ - norm = K.sqrt(K.sum(K.pow(inputs, 2), axis=-1, keepdims=True)) + 1e-10 - var_x = inputs / norm * self.w + norm = ops.sqrt(ops.sum(ops.power(inputs, 2), axis=-1, keepdims=True)) + 1e-10 + var_x = inputs / norm * self.weight return var_x def get_config(self) -> dict: @@ -122,7 +121,7 @@ def get_config(self) -> dict: return config -class SliceO2K(keras.layers.Layer): +class SliceO2K(Layer): # pylint:disable=too-many-ancestors,abstract-method """ Custom Keras Slice layer generated by onnx2keras. """ def __init__(self, starts: list[int], @@ -154,7 +153,8 @@ def _get_slices(self, dimensions: int) -> list[tuple[int, ...]]: assert len(axes) == len(steps) == len(self._starts) == len(self._ends) return list(zip(axes, self._starts, self._ends, steps)) - def compute_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]: + def compute_output_shape(self, input_shape: tuple[int, ...] # pylint:disable=arguments-differ + ) -> tuple[int, ...]: """Computes the output shape of the layer. Assumes that the layer will be built to match that input shape provided. @@ -202,8 +202,8 @@ def call(self, inputs, **kwargs): # pylint:disable=unused-argument,arguments-di A tensor or list/tuple of tensors. The layer output """ - ax_map = dict((x[0], slice(*x[1:])) for x in self._get_slices(K.ndim(inputs))) - shape = K.int_shape(inputs) + ax_map = dict((x[0], slice(*x[1:])) for x in self._get_slices(ops.ndim(inputs))) + shape = inputs.shape slices = [(ax_map[a] if a in ax_map else slice(None)) for a in range(len(shape))] retval = inputs[tuple(slices)] return retval @@ -224,103 +224,37 @@ def get_config(self) -> dict: return config -class S3fd(KSession): - """ Keras Network """ - def __init__(self, - model_path: str, - model_kwargs: dict, - allow_growth: bool, - exclude_gpus: list[int] | None, - confidence: float) -> None: - logger.debug("Initializing: %s: (model_path: '%s', model_kwargs: %s, allow_growth: %s, " - "exclude_gpus: %s, confidence: %s)", self.__class__.__name__, model_path, - model_kwargs, allow_growth, exclude_gpus, confidence) - super().__init__("S3FD", - model_path, - model_kwargs=model_kwargs, - allow_growth=allow_growth, - exclude_gpus=exclude_gpus) - self.define_model(self.model_definition) - self.load_model_weights() +class S3fd(): + """ Keras Network + + Parameters + ---------- + weights_path: str + Full path to the S3FD weights file + batch_size: int + The batch size to feed the model + confidence: float + The confidence level to accept detections at + """ + def __init__(self, weights_path: str, batch_size: int, confidence: float) -> None: + logger.debug(parse_class_init(locals())) + self._batch_size = batch_size + self._model = self._load_model(weights_path) self.confidence = confidence self.average_img = np.array([104.0, 117.0, 123.0]) logger.debug("Initialized: %s", self.__class__.__name__) - def model_definition(self) -> tuple[list[Tensor], list[Tensor]]: - """ Keras S3FD Model Definition, adapted from FAN pytorch implementation. """ - input_ = Input(shape=(640, 640, 3)) - var_x = self.conv_block(input_, 64, 1, 2) - var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) - - var_x = self.conv_block(var_x, 128, 2, 2) - var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) - - var_x = self.conv_block(var_x, 256, 3, 3) - f3_3 = var_x - var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) - - var_x = self.conv_block(var_x, 512, 4, 3) - f4_3 = var_x - var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) - - var_x = self.conv_block(var_x, 512, 5, 3) - f5_3 = var_x - var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) - - var_x = ZeroPadding2D(3)(var_x) - var_x = Conv2D(1024, kernel_size=3, strides=1, activation="relu", name="fc6")(var_x) - var_x = Conv2D(1024, kernel_size=1, strides=1, activation="relu", name="fc7")(var_x) - ffc7 = var_x - - f6_2 = self.conv_up(var_x, 256, 6) - f7_2 = self.conv_up(f6_2, 128, 7) - - f3_3 = L2Norm(256, scale=10, name="conv3_3_norm")(f3_3) - f4_3 = L2Norm(512, scale=8, name="conv4_3_norm")(f4_3) - f5_3 = L2Norm(512, scale=5, name="conv5_3_norm")(f5_3) - - f3_3 = ZeroPadding2D(1)(f3_3) - cls1 = Conv2D(4, kernel_size=3, strides=1, name="conv3_3_norm_mbox_conf")(f3_3) - reg1 = Conv2D(4, kernel_size=3, strides=1, name="conv3_3_norm_mbox_loc")(f3_3) - - f4_3 = ZeroPadding2D(1)(f4_3) - cls2 = Conv2D(2, kernel_size=3, strides=1, name="conv4_3_norm_mbox_conf")(f4_3) - reg2 = Conv2D(4, kernel_size=3, strides=1, name="conv4_3_norm_mbox_loc")(f4_3) - - f5_3 = ZeroPadding2D(1)(f5_3) - cls3 = Conv2D(2, kernel_size=3, strides=1, name="conv5_3_norm_mbox_conf")(f5_3) - reg3 = Conv2D(4, kernel_size=3, strides=1, name="conv5_3_norm_mbox_loc")(f5_3) - - ffc7 = ZeroPadding2D(1)(ffc7) - cls4 = Conv2D(2, kernel_size=3, strides=1, name="fc7_mbox_conf")(ffc7) - reg4 = Conv2D(4, kernel_size=3, strides=1, name="fc7_mbox_loc")(ffc7) - - f6_2 = ZeroPadding2D(1)(f6_2) - cls5 = Conv2D(2, kernel_size=3, strides=1, name="conv6_2_mbox_conf")(f6_2) - reg5 = Conv2D(4, kernel_size=3, strides=1, name="conv6_2_mbox_loc")(f6_2) - - f7_2 = ZeroPadding2D(1)(f7_2) - cls6 = Conv2D(2, kernel_size=3, strides=1, name="conv7_2_mbox_conf")(f7_2) - reg6 = Conv2D(4, kernel_size=3, strides=1, name="conv7_2_mbox_loc")(f7_2) - - # max-out background label - chunks = [SliceO2K(starts=[0], ends=[1], axes=[3], steps=None)(cls1), - SliceO2K(starts=[1], ends=[2], axes=[3], steps=None)(cls1), - SliceO2K(starts=[2], ends=[3], axes=[3], steps=None)(cls1), - SliceO2K(starts=[3], ends=[4], axes=[3], steps=None)(cls1)] - - bmax = Maximum()([chunks[0], chunks[1], chunks[2]]) - cls1 = Concatenate()([bmax, chunks[3]]) - - return [input_], [cls1, reg1, cls2, reg2, cls3, reg3, cls4, reg4, cls5, reg5, cls6, reg6] - @classmethod - def conv_block(cls, inputs: Tensor, filters: int, idx: int, recursions: int) -> Tensor: + def conv_block(cls, + inputs: KerasTensor, + filters: int, + idx: int, + recursions: int) -> KerasTensor: """ First round convolutions with zero padding added. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input tensor to the convolution block filters: int The number of filters @@ -331,7 +265,7 @@ def conv_block(cls, inputs: Tensor, filters: int, idx: int, recursions: int) -> Returns ------- - tensor + :class:`keras.KerasTensor` The output tensor from the convolution block """ name = f"conv{idx}" @@ -347,12 +281,12 @@ def conv_block(cls, inputs: Tensor, filters: int, idx: int, recursions: int) -> return var_x @classmethod - def conv_up(cls, inputs: Tensor, filters: int, idx: int) -> Tensor: + def conv_up(cls, inputs: KerasTensor, filters: int, idx: int) -> KerasTensor: """ Convolution up filter blocks with zero padding added. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input tensor to the convolution block filters: int The initial number of filters @@ -361,7 +295,7 @@ def conv_up(cls, inputs: Tensor, filters: int, idx: int) -> Tensor: Returns ------- - tensor + :class:`keras.KerasTensor` The output tensor from the convolution block """ name = f"conv{idx}" @@ -378,6 +312,103 @@ def conv_up(cls, inputs: Tensor, filters: int, idx: int) -> Tensor: name=rec_name)(var_x) return var_x + def _load_model(self, weights_path: str) -> Model: + """ Keras S3FD Model Definition, adapted from FAN pytorch implementation. + + Parameters + ---------- + weights_path: str + Full path to the model's weights + + Returns + ------- + :class:`keras.models.Model` + The S3FD model + """ + input_ = Input(shape=(640, 640, 3)) + var_x = self.conv_block(input_, 64, 1, 2) + var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) + + var_x = self.conv_block(var_x, 128, 2, 2) + var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) + + var_x = self.conv_block(var_x, 256, 3, 3) + f3_3 = var_x + var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) + + var_x = self.conv_block(var_x, 512, 4, 3) + f4_3 = var_x + var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) + + var_x = self.conv_block(var_x, 512, 5, 3) + f5_3 = var_x + var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) + + var_x = ZeroPadding2D(3)(var_x) + var_x = Conv2D(1024, kernel_size=3, strides=1, activation="relu", name="fc6")(var_x) + var_x = Conv2D(1024, kernel_size=1, strides=1, activation="relu", name="fc7")(var_x) + ffc7 = var_x + + f6_2 = self.conv_up(var_x, 256, 6) + f7_2 = self.conv_up(f6_2, 128, 7) + + f3_3 = L2Norm(256, scale=10, name="conv3_3_norm")(f3_3) + f4_3 = L2Norm(512, scale=8, name="conv4_3_norm")(f4_3) + f5_3 = L2Norm(512, scale=5, name="conv5_3_norm")(f5_3) + + classes = [] + regs = [] + + f3_3 = ZeroPadding2D(1)(f3_3) + classes.append(Conv2D(4, kernel_size=3, strides=1, name="conv3_3_norm_mbox_conf")(f3_3)) + regs.append(Conv2D(4, kernel_size=3, strides=1, name="conv3_3_norm_mbox_loc")(f3_3)) + + f4_3 = ZeroPadding2D(1)(f4_3) + classes.append(Conv2D(2, kernel_size=3, strides=1, name="conv4_3_norm_mbox_conf")(f4_3)) + regs.append(Conv2D(4, kernel_size=3, strides=1, name="conv4_3_norm_mbox_loc")(f4_3)) + + f5_3 = ZeroPadding2D(1)(f5_3) + classes.append(Conv2D(2, kernel_size=3, strides=1, name="conv5_3_norm_mbox_conf")(f5_3)) + regs.append(Conv2D(4, kernel_size=3, strides=1, name="conv5_3_norm_mbox_loc")(f5_3)) + + ffc7 = ZeroPadding2D(1)(ffc7) + classes.append(Conv2D(2, kernel_size=3, strides=1, name="fc7_mbox_conf")(ffc7)) + regs.append(Conv2D(4, kernel_size=3, strides=1, name="fc7_mbox_loc")(ffc7)) + + f6_2 = ZeroPadding2D(1)(f6_2) + classes.append(Conv2D(2, kernel_size=3, strides=1, name="conv6_2_mbox_conf")(f6_2)) + regs.append(Conv2D(4, kernel_size=3, strides=1, name="conv6_2_mbox_loc")(f6_2)) + + f7_2 = ZeroPadding2D(1)(f7_2) + classes.append(Conv2D(2, kernel_size=3, strides=1, name="conv7_2_mbox_conf")(f7_2)) + regs.append(Conv2D(4, kernel_size=3, strides=1, name="conv7_2_mbox_loc")(f7_2)) + + # max-out background label + chunks = [SliceO2K(starts=[0], ends=[1], axes=[3], steps=None)(classes[0]), + SliceO2K(starts=[1], ends=[2], axes=[3], steps=None)(classes[0]), + SliceO2K(starts=[2], ends=[3], axes=[3], steps=None)(classes[0]), + SliceO2K(starts=[3], ends=[4], axes=[3], steps=None)(classes[0])] + + bmax = Maximum()([chunks[0], chunks[1], chunks[2]]) + classes[0] = Concatenate()([bmax, chunks[3]]) + + retval = Model(input_, + [classes[0], + regs[0], + classes[1], + regs[1], + classes[2], + regs[2], + classes[3], + regs[3], + classes[4], + regs[4], + classes[5], + regs[5]]) + retval.load_weights(weights_path) + retval.make_predict_function() + return retval + def prepare_batch(self, batch: np.ndarray) -> np.ndarray: """ Prepare a batch for prediction. @@ -413,6 +444,26 @@ def finalize_predictions(self, bounding_boxes_scales: list[np.ndarray]) -> np.nd ret.append(finallist) return np.array(ret, dtype="object") + def _process_bbox(self, + ocls: np.ndarray, + oreg: np.ndarray, + stride: int) -> list[list[np.ndarray]]: + """ Process a bounding box """ + retval = [] + for pos in zip(*np.where(ocls[:, :, :, 1] > 0.05)): + a_c = stride / 2 + pos[2] * stride, stride / 2 + pos[1] * stride + score = ocls[0, pos[1], pos[2], 1] + if score >= self.confidence: + loc = np.ascontiguousarray(oreg[0, pos[1], pos[2], :]).reshape((1, 4)) + priors = np.array([[a_c[0] / 1.0, + a_c[1] / 1.0, + stride * 4 / 1.0, + stride * 4 / 1.0]]) + box = self.decode(loc, priors) + x_1, y_1, x_2, y_2 = box[0] * 1.0 + retval.append([x_1, y_1, x_2, y_2, score]) + return retval + def _post_process(self, bboxlist: list[np.ndarray]) -> np.ndarray: """ Perform post processing on output TODO: do this on the batch. @@ -423,16 +474,8 @@ def _post_process(self, bboxlist: list[np.ndarray]) -> np.ndarray: for i in range(len(bboxlist) // 2): ocls, oreg = bboxlist[i * 2], bboxlist[i * 2 + 1] stride = 2 ** (i + 2) # 4,8,16,32,64,128 - poss = zip(*np.where(ocls[:, :, :, 1] > 0.05)) - for _, hindex, windex in poss: - axc, ayc = stride / 2 + windex * stride, stride / 2 + hindex * stride - score = ocls[0, hindex, windex, 1] - if score >= self.confidence: - loc = np.ascontiguousarray(oreg[0, hindex, windex, :]).reshape((1, 4)) - priors = np.array([[axc / 1.0, ayc / 1.0, stride * 4 / 1.0, stride * 4 / 1.0]]) - box = self.decode(loc, priors) - x_1, y_1, x_2, y_2 = box[0] * 1.0 - retval.append([x_1, y_1, x_2, y_2, score]) + retval.extend(self._process_bbox(ocls, oreg, stride)) + return_numpy = np.array(retval) if len(retval) != 0 else np.zeros((1, 5)) return return_numpy @@ -492,3 +535,21 @@ def _nms(boxes: np.ndarray, threshold: float) -> np.ndarray: non_overlapping_boxes = (iou <= threshold).nonzero()[0] ranked_indices = ranked_indices[non_overlapping_boxes + 1] return boxes[retained_box_indices] + + def __call__(self, inputs: np.ndarray) -> np.ndarray: + """ Get predictions from the S3FD model + + Parameters + ---------- + inputs: :class:`numpy.ndarray` + The input to S3FD + + Returns + ------- + :class:`numpy.ndarray` + The output from S3FD + """ + return self._model.predict(inputs, verbose=0, batch_size=self._batch_size) + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/detect/s3fd_defaults.py b/plugins/extract/detect/s3fd_defaults.py index 5e219766f4..1ecf1948d8 100755 --- a/plugins/extract/detect/s3fd_defaults.py +++ b/plugins/extract/detect/s3fd_defaults.py @@ -1,82 +1,59 @@ #!/usr/bin/env python3 -""" - The default options for the faceswap S3Fd Detect plugin. +""" The default options for the faceswap S3Fd Detect plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) - should always be lower text. - dictionary requirements are listed below. +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid data types are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - group: [optional]. A group for grouping options together in the GUI. If not - provided this will not group this option with any others. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and data types this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and data types this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ +# pylint:disable=duplicate-code +from lib.config import ConfigItem -_HELPTEXT = ( +HELPTEXT = ( "S3FD Detector options.\n" "Fast on GPU, slow on CPU. Can detect more faces and fewer false positives than other GPU " "detectors, but is a lot more resource intensive." ) -_DEFAULTS = { - "confidence": { - "default": 70, - "info": "The confidence level at which the detector has succesfully found a face.\n" - "Higher levels will be more discriminating, lower levels will have more false " - "positives.", - "datatype": int, - "rounding": 5, - "min_max": (25, 100), - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - }, - "batch-size": { - "default": 4, - "info": "The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered." - "\n\tAMD users: A batchsize of 8 requires about 2 GB vram.", - "datatype": int, - "rounding": 1, - "min_max": (1, 64), - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - } -} +confidence = ConfigItem( + datatype=int, + default=70, + group="settings", + info="The confidence level at which the detector has succesfully found a face.\n" + "Higher levels will be more discriminating, lower levels will have more false " + "positives.", + rounding=5, + min_max=(25, 100)) + +batch_size = ConfigItem( + datatype=int, + default=4, + group="settings", + info="The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered." + "\n\tAMD users: A batchsize of 8 requires about 2 GB vram.", + rounding=1, + min_max=(1, 64)) diff --git a/plugins/extract/extract_config.py b/plugins/extract/extract_config.py new file mode 100644 index 0000000000..2361864cc4 --- /dev/null +++ b/plugins/extract/extract_config.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" Default configurations for extract """ + +import gettext +import logging +import os + +from lib.config import FaceswapConfig +from lib.config import ConfigItem + +# LOCALES +_LANG = gettext.translation("plugins.extract.extract_config", localedir="locales", fallback=True) +_ = _LANG.gettext + +logger = logging.getLogger(__name__) + + +class _Config(FaceswapConfig): + """ Config File for Extraction """ + + def set_defaults(self, helptext="") -> None: + """ Set the default values for config """ + super().set_defaults(helptext=_("Options that apply to all extraction plugins")) + self._defaults_from_plugin(os.path.dirname(__file__)) + + +aligner_min_scale = ConfigItem( + datatype=float, + default=0.03, + group=_("filters"), + info=_( + "Filters out faces below this size. This is a multiplier of the minimum dimension of " + "the frame (i.e. 1280x720 = 720). If the original face extract box is smaller than " + "the minimum dimension times this multiplier, it is considered a false positive and " + "discarded. Faces which are found to be unusually smaller than the frame tend to be " + "misaligned images, except in extreme long-shots. These can be usually be safely " + "discarded."), + min_max=(0.0, 1.0), + rounding=2) + + +aligner_max_scale = ConfigItem( + datatype=float, + default=4.00, + group=_("filters"), + info=_( + "Filters out faces above this size. This is a multiplier of the minimum dimension of " + "the frame (i.e. 1280x720 = 720). If the original face extract box is larger than the " + "minimum dimension times this multiplier, it is considered a false positive and " + "discarded. Faces which are found to be unusually larger than the frame tend to be " + "misaligned images except in extreme close-ups. These can be usually be safely " + "discarded."), + min_max=(0.0, 10.0), + rounding=2) + + +aligner_distance = ConfigItem( + datatype=float, + default=40.0, + group=_("filters"), + info=_( + "Filters out faces who's landmarks are above this distance from an 'average' face. " + "Values above 15 tend to be fairly safe. Values above 10 will remove more false " + "positives, but may also filter out some faces at extreme angles."), + min_max=(0.0, 45.0), + rounding=1) + + +aligner_roll = ConfigItem( + datatype=float, + default=0.0, + group=_("filters"), + info=_( + "Filters out faces who's calculated roll is greater than zero +/- this value in " + "degrees. Aligned faces should have a roll value close to zero. Values that are a " + "significant distance from 0 degrees tend to be misaligned images. These can usually " + "be safely disgarded."), + min_max=(0.0, 90.0), + rounding=1) + + +aligner_features = ConfigItem( + datatype=bool, + default=True, + group=_("filters"), + info=_( + "Filters out faces where the lowest point of the aligned face's eye or eyebrow is " + "lower than the highest point of the aligned face's mouth. Any faces where this " + "occurs are misaligned and can be safely disgarded.")) + + +filter_refeed = ConfigItem( + datatype=bool, + default=True, + group=_("filters"), + info=_( + "If enabled, and 're-feed' has been selected for extraction, then interim alignments " + "will be filtered prior to averaging the final landmarks. This can help improve the " + "final alignments by removing any obvious misaligns from the interim results, and may " + "also help pick up difficult alignments. If disabled, then all re-feed results will " + "be averaged.")) + + +save_filtered = ConfigItem( + datatype=bool, + default=False, + group=_("filters"), + info=_( + "If enabled, saves any filtered out images into a sub-folder during the extraction " + "process. If disabled, filtered faces are deleted. Note: The faces will always be " + "filtered out of the alignments file, regardless of whether you keep the faces or " + "not.")) + + +realign_refeeds = ConfigItem( + datatype=bool, + default=True, + group=_("re-align"), + info=_( + "If enabled, and 're-align' has been selected for extraction, then all re-feed " + "iterations are re-aligned. If disabled, then only the final averaged output from re-" + "feed will be re-aligned.")) + + +filter_realign = ConfigItem( + datatype=bool, + default=True, + group=_("re-align"), + info=_( + "If enabled, and 're-align' has been selected for extraction, then any alignments " + "which would be filtered out will not be re-aligned.")) + + +# pylint:disable=duplicate-code +_IS_LOADED: bool = False + + +def load_config(config_file: str | None = None) -> None: + """ Load the Extraction configuration .ini file + + Parameters + ---------- + config_file : str | None, optional + Path to a custom .ini configuration file to load. Default: ``None`` (use default + configuration file) + """ + global _IS_LOADED # pylint:disable=global-statement + if not _IS_LOADED: + _Config(configfile=config_file) + _IS_LOADED = True diff --git a/plugins/extract/extract_media.py b/plugins/extract/extract_media.py index b9d3f84a33..22700914ee 100644 --- a/plugins/extract/extract_media.py +++ b/plugins/extract/extract_media.py @@ -7,6 +7,7 @@ import cv2 from lib.logger import parse_class_init +from lib.utils import get_module_objects if T.TYPE_CHECKING: import numpy as np @@ -208,3 +209,6 @@ def _image_as_gray(self) -> np.ndarray: :class:`numpy.ndarray`: A copy of :attr:`image` in gray-scale color format """ return cv2.cvtColor(self.image.copy(), cv2.COLOR_BGR2GRAY) + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 8b5d71e0ad..4a2fd70543 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -20,8 +20,7 @@ import cv2 import numpy as np - -from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa +from torch.cuda import OutOfMemoryError from lib.align import AlignedFace, LandmarkType, transform_image from lib.utils import FaceswapError @@ -88,6 +87,7 @@ def __init__(self, configfile: str | None = None, instance: int = 0, **kwargs) -> None: + # pylint:disable=duplicate-code logger.debug("Initializing %s: (configfile: %s)", self.__class__.__name__, configfile) super().__init__(git_model_id, model_filename, @@ -97,10 +97,10 @@ def __init__(self, self.input_size = 256 # Override for model specific input_size self.coverage_ratio = 1.0 # Override for model specific coverage_ratio + self._info.plugin_type = "mask" # Override if a specific type of landmark data is required: self.landmark_type: LandmarkType | None = None - self._plugin_type = "mask" self._storage_name = self.__module__.rsplit(".", maxsplit=1)[-1].replace("_", "-") self._storage_centering: CenteringType = "face" # Centering to store the mask at self._storage_size = 128 # Size to store masks at. Leave this at default @@ -208,39 +208,39 @@ def get_batch(self, queue: Queue) -> tuple[bool, MaskerBatch]: if idx == self.batchsize: frame_faces = len(item.detected_faces) if f_idx + 1 != frame_faces: - self._rollover = ExtractMedia( + self._tracker.rollover = ExtractMedia( item.filename, item.image, detected_faces=item.detected_faces[f_idx + 1:], is_aligned=item.is_aligned) - logger.trace("Rolled over %s faces of %s to next batch " # type:ignore - "for '%s'", len(self._rollover.detected_faces), frame_faces, + logger.trace("Rolled over %s faces of %s " # type:ignore[attr-defined] + "to next batch for '%s'", + len(self._tracker.rollover.detected_faces), frame_faces, item.filename) break if batch: - logger.trace("Returning batch: %s", # type:ignore + logger.trace("Returning batch: %s", # type:ignore[attr-defined] {k: len(v) if isinstance(v, (list, np.ndarray)) else v for k, v in batch.__dict__.items()}) else: - logger.trace(item) # type:ignore + logger.trace(item) # type:ignore[attr-defined] return exhausted, batch def _predict(self, batch: BatchType) -> MaskerBatch: """ Just return the masker's predict function """ assert isinstance(batch, MaskerBatch) assert self.name is not None - try: - # slightly hacky workaround to deal with landmarks based masks: - if self.name.lower() in ("components", "extended"): - feed = np.empty(2, dtype="object") - feed[0] = batch.feed - feed[1] = batch.feed_faces - else: - feed = batch.feed + # slightly hacky workaround to deal with landmarks based masks: + if self.name.lower() in ("components", "extended"): + feed = np.empty(2, dtype="object") + feed[0] = batch.feed + feed[1] = batch.feed_faces + else: + feed = batch.feed + try: batch.prediction = self.predict(feed) - return batch - except tf_errors.ResourceExhaustedError as err: + except OutOfMemoryError as err: msg = ("You do not have enough GPU memory available to run detection at the " "selected batch size. You can try a number of things:" "\n1) Close any other application that is using your GPU (web browsers are " @@ -251,6 +251,8 @@ def _predict(self, batch: BatchType) -> MaskerBatch: "\n3) Enable 'Single Process' mode.") raise FaceswapError(msg) from err + return batch + def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: """ Finalize the output from Masker @@ -292,14 +294,14 @@ def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: {key: val.shape if isinstance(val, np.ndarray) else val for key, val in batch.__dict__.items()}) for filename, face in zip(batch.filename, batch.detected_faces): - self._output_faces.append(face) - if len(self._output_faces) != self._faces_per_filename[filename]: + self._tracker.output_faces.append(face) + if len(self._tracker.output_faces) != self._tracker.faces_per_filename[filename]: continue output = self._extract_media.pop(filename) - output.add_detected_faces(self._output_faces) - self._output_faces = [] - logger.trace("Yielding: (filename: '%s', image: %s, " # type:ignore + output.add_detected_faces(self._tracker.output_faces) + self._tracker.output_faces = [] + logger.trace("Yielding: (filename: '%s', image: %s, " # type:ignore[attr-defined] "detected_faces: %s)", output.filename, output.image_shape, len(output.detected_faces)) yield output @@ -313,7 +315,7 @@ def _resize(cls, image: np.ndarray, target_size: int) -> np.ndarray: scale = target_size / image_size if scale == 1.: return image - method = cv2.INTER_CUBIC if scale > 1. else cv2.INTER_AREA # pylint:disable=no-member + method = cv2.INTER_CUBIC if scale > 1. else cv2.INTER_AREA resized = cv2.resize(image, (0, 0), fx=scale, fy=scale, interpolation=method) resized = resized if channels > 1 else resized[..., None] return resized diff --git a/plugins/extract/mask/bisenet_fp.py b/plugins/extract/mask/bisenet_fp.py index cf8a177fe6..b42d62d578 100644 --- a/plugins/extract/mask/bisenet_fp.py +++ b/plugins/extract/mask/bisenet_fp.py @@ -10,67 +10,67 @@ import numpy as np -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras import backend as K # pylint:disable=import-error -from tensorflow.keras.layers import ( # pylint:disable=import-error +import keras.backend as K +from keras.layers import ( Activation, Add, BatchNormalization, Concatenate, Conv2D, GlobalAveragePooling2D, Input, MaxPooling2D, Multiply, Reshape, UpSampling2D, ZeroPadding2D) +from keras.models import Model -from lib.model.session import KSession -from plugins.extract._base import _get_config +from lib.logger import parse_class_init +from lib.utils import get_module_objects +from plugins.extract.extract_config import load_config from ._base import BatchType, Masker, MaskerBatch +from . import bisenet_fp_defaults as cfg if T.TYPE_CHECKING: - from tensorflow import Tensor + from keras import KerasTensor logger = logging.getLogger(__name__) -class Mask(Masker): +class Mask(Masker): # pylint:disable=too-many-instance-attributes """ Neural network to process face image into a segmentation mask of the face """ def __init__(self, **kwargs) -> None: - self._is_faceswap, version = self._check_weights_selection(kwargs.get("configfile")) + # We need access to user config prior to parent being initialized to correctly set the + # model filename + load_config(kwargs.get("configfile")) + self._is_faceswap, version = self._check_weights_selection() git_model_id = 14 model_filename = f"bisnet_face_parsing_v{version}.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.model: KSession + self.model: BiSeNet self.name = "BiSeNet - Face Parsing" self.input_size = 512 self.color_format = "RGB" - self.vram = 2304 if not self.config["cpu"] else 0 - self.vram_warnings = 256 if not self.config["cpu"] else 0 - self.vram_per_batch = 64 if not self.config["cpu"] else 0 - self.batchsize = self.config["batch-size"] + self.vram = 384 if not cfg.cpu() else 0 # 378 in testing + self.vram_per_batch = 384 if not cfg.cpu() else 0 # ~328 in testing + self.batchsize = cfg.batch_size() self._segment_indices = self._get_segment_indices() - self._storage_centering = "head" if self.config["include_hair"] else "face" + self.storage_centering = "head" if cfg.include_hair() else "face" + """ Literal["head", "face"] The mask type/storage centering to use """ # Separate storage for face and head masks self._storage_name = f"{self._storage_name}_{self._storage_centering}" - def _check_weights_selection(self, configfile: str | None) -> tuple[bool, int]: + def _check_weights_selection(self) -> tuple[bool, int]: """ Check which weights have been selected. This is required for passing along the correct file name for the corresponding weights - selection, so config needs to be loaded and scanned prior to parent loading it. - - Parameters - ---------- - configfile: str - Path to a custom configuration ``ini`` file. ``None`` to use system configfile + selection. Returns ------- - tuple (bool, int) - First position is ``True`` if `faceswap` trained weights have been selected. - ``False`` if `original` weights have been selected. - Second position is the version of the model to use (``1`` for non-faceswap, ``1`` if - faceswap and full-head model is required. ``3`` if faceswap and full-face is required) + is_faceswap : bool + ``True`` if `faceswap` trained weights have been selected. ``False`` if `original` + weights have been selected. + version : int + ``1`` for non-faceswap, ``2`` if faceswap and full-head model is required. ``3`` if + faceswap and full-face is required """ - config = _get_config(".".join(self.__module__.split(".")[-2:]), configfile=configfile) - is_faceswap = config.get("weights", "faceswap").lower() == "faceswap" - version = 1 if not is_faceswap else 2 if config.get("include_hair") else 3 + is_faceswap = cfg.weights() == "faceswap" + version = 1 if not is_faceswap else 2 if cfg.include_hair() else 3 return is_faceswap, version def _get_segment_indices(self) -> list[int]: @@ -94,11 +94,11 @@ def _get_segment_indices(self) -> list[int]: """ retval = [1] if self._is_faceswap else [1, 2, 3, 4, 5, 10, 11, 12, 13] - if self.config["include_glasses"]: + if cfg.include_glasses(): retval.append(4 if self._is_faceswap else 6) - if self.config["include_ears"]: + if cfg.include_ears(): retval.extend([2] if self._is_faceswap else [7, 8, 9]) - if self.config["include_hair"]: + if cfg.include_hair(): retval.append(3 if self._is_faceswap else 17) logger.debug("Selected segment indices: %s", retval) return retval @@ -107,16 +107,12 @@ def init_model(self) -> None: """ Initialize the BiSeNet Face Parsing model. """ assert isinstance(self.model_path, str) lbls = 5 if self._is_faceswap else 19 - self.model = BiSeNet(self.model_path, - self.config["allow_growth"], - self._exclude_gpus, - self.input_size, - lbls, - self.config["cpu"]) - placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), dtype="float32") - self.model.predict(placeholder) + + with self.get_device_context(cfg.cpu()): + self.model = BiSeNet(self.model_path, self.batchsize, self.input_size, lbls) + self.model(placeholder) def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction """ @@ -127,11 +123,12 @@ def process_input(self, batch: BatchType) -> None: batch.feed = ((np.array([T.cast(np.ndarray, feed.face)[..., :3] for feed in batch.feed_faces], dtype="float32") / 255.0) - mean) / std - logger.trace("feed shape: %s", batch.feed.shape) # type:ignore + logger.trace("feed shape: %s", batch.feed.shape) # type:ignore[attr-defined] def predict(self, feed: np.ndarray) -> np.ndarray: """ Run model to get predictions """ - return self.model.predict(feed)[0] + with self.get_device_context(cfg.cpu()): + return self.model(feed)[0] def process_output(self, batch: BatchType) -> None: """ Compile found faces for output """ @@ -197,7 +194,7 @@ def _get_name(name: str, start_idx: int = 1) -> str: return retval -class ConvBn(): # pylint:disable=too-few-public-methods +class ConvBn(): """ Convolutional 3D with Batch Normalization block. Parameters @@ -220,7 +217,7 @@ class ConvBn(): # pylint:disable=too-few-public-methods The starting index for naming the layers within the block. See :func:`_get_name` for more information. Default: `1` """ - def __init__(self, filters: int, + def __init__(self, filters: int, # pylint:disable=too-many-positional-arguments kernel_size: int = 3, strides: int = 1, padding: int = 1, @@ -232,20 +229,20 @@ def __init__(self, filters: int, self._strides = strides self._padding = padding self._activation = activation - self._prefix = f"{prefix}." if prefix else prefix + self._prefix = f"{prefix}-" if prefix else prefix self._start_idx = start_idx - def __call__(self, inputs: Tensor) -> Tensor: + def __call__(self, inputs: KerasTensor) -> KerasTensor: """ Call the Convolutional Batch Normalization block. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input to the block Returns ------- - tensor + :class:`keras.KerasTensor` The output from the block """ var_x = inputs @@ -270,17 +267,21 @@ def __call__(self, inputs: Tensor) -> Tensor: return var_x -class ResNet18(): # pylint:disable=too-few-public-methods +class ResNet18(): """ ResNet 18 block. Used at the start of BiSeNet Face Parsing. """ def __init__(self): self._feature_index = 1 if K.image_data_format() == "channels_first" else -1 - def _basic_block(self, inputs: Tensor, prefix: str, filters: int, strides: int = 1) -> Tensor: + def _basic_block(self, + inputs: KerasTensor, + prefix: str, + filters: int, + strides: int = 1) -> KerasTensor: """ The basic building block for ResNet 18. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input to the block prefix: str The prefix to name the layers within the block @@ -292,16 +293,16 @@ def _basic_block(self, inputs: Tensor, prefix: str, filters: int, strides: int = Returns ------- - tensor + :class:`keras.KerasTensor` The output from the block """ res = ConvBn(filters, strides=strides, padding=1, prefix=prefix)(inputs) res = ConvBn(filters, strides=1, padding=1, activation=False, prefix=prefix)(res) shortcut = inputs - filts = (K.int_shape(shortcut)[self._feature_index], K.int_shape(res)[self._feature_index]) + filts = (shortcut.shape[self._feature_index], res.shape[self._feature_index]) if strides != 1 or filts[0] != filts[1]: # Downsample - name = f"{prefix}.downsample." + name = f"{prefix}-downsample-" shortcut = Conv2D(filters, 1, strides=strides, use_bias=False, @@ -309,21 +310,21 @@ def _basic_block(self, inputs: Tensor, prefix: str, filters: int, strides: int = shortcut = BatchNormalization(epsilon=1e-5, name=_get_name(f"{name}", start_idx=0))(shortcut) - var_x = Add(name=f"{prefix}.add")([res, shortcut]) - var_x = Activation("relu", name=f"{prefix}.relu")(var_x) + var_x = Add(name=f"{prefix}-add")([res, shortcut]) + var_x = Activation("relu", name=f"{prefix}-relu")(var_x) return var_x - def _basic_layer(self, - inputs: Tensor, + def _basic_layer(self, # pylint:disable=too-many-positional-arguments + inputs: KerasTensor, prefix: str, filters: int, num_blocks: int, - strides: int = 1) -> Tensor: + strides: int = 1) -> KerasTensor: """ The basic layer for ResNet 18. Recursively builds from :func:`_basic_block`. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input to the block prefix: str The prefix to name the layers within the block @@ -337,40 +338,40 @@ def _basic_layer(self, Returns ------- - tensor + :class:`keras.KerasTensor` The output from the block """ - var_x = self._basic_block(inputs, f"{prefix}.0", filters, strides=strides) + var_x = self._basic_block(inputs, f"{prefix}-0", filters, strides=strides) for i in range(num_blocks - 1): - var_x = self._basic_block(var_x, f"{prefix}.{i + 1}", filters, strides=1) + var_x = self._basic_block(var_x, f"{prefix}-{i + 1}", filters, strides=1) return var_x - def __call__(self, inputs: Tensor) -> Tensor: + def __call__(self, inputs: KerasTensor) -> KerasTensor: """ Call the ResNet 18 block. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input to the block Returns ------- - tensor + :class:`keras.KerasTensor` The output from the block """ - var_x = ConvBn(64, kernel_size=7, strides=2, padding=3, prefix="cp.resnet")(inputs) - var_x = ZeroPadding2D(1, name="cp.resnet.zeropad")(var_x) - var_x = MaxPooling2D(pool_size=3, strides=2, name="cp.resnet.maxpool")(var_x) + var_x = ConvBn(64, kernel_size=7, strides=2, padding=3, prefix="cp-resnet")(inputs) + var_x = ZeroPadding2D(1, name="cp-resnet-zeropad")(var_x) + var_x = MaxPooling2D(pool_size=3, strides=2, name="cp-resnet-maxpool")(var_x) - var_x = self._basic_layer(var_x, "cp.resnet.layer1", 64, 2) - feat8 = self._basic_layer(var_x, "cp.resnet.layer2", 128, 2, strides=2) - feat16 = self._basic_layer(feat8, "cp.resnet.layer3", 256, 2, strides=2) - feat32 = self._basic_layer(feat16, "cp.resnet.layer4", 512, 2, strides=2) + var_x = self._basic_layer(var_x, "cp-resnet-layer1", 64, 2) + feat8 = self._basic_layer(var_x, "cp-resnet-layer2", 128, 2, strides=2) + feat16 = self._basic_layer(feat8, "cp-resnet-layer3", 256, 2, strides=2) + feat32 = self._basic_layer(feat16, "cp-resnet-layer4", 512, 2, strides=2) return feat8, feat16, feat32 -class AttentionRefinementModule(): # pylint:disable=too-few-public-methods +class AttentionRefinementModule(): """ The Attention Refinement block for BiSeNet Face Parsing Parameters @@ -382,72 +383,72 @@ class AttentionRefinementModule(): # pylint:disable=too-few-public-methods def __init__(self, filters: int) -> None: self._filters = filters - def __call__(self, inputs: Tensor, feats: int) -> Tensor: + def __call__(self, inputs: KerasTensor, feats: int) -> KerasTensor: """ Call the Attention Refinement block. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input to the block feats: int The number of features. Used for naming. Returns ------- - tensor + :class:`keras.KerasTensor` The output from the block """ - prefix = f"cp.arm{feats}" - feat = ConvBn(self._filters, prefix=f"{prefix}.conv", start_idx=-1, padding=-1)(inputs) - atten = GlobalAveragePooling2D(name=f"{prefix}.avgpool")(feat) - atten = Reshape((1, 1, K.int_shape(atten)[-1]))(atten) - atten = Conv2D(self._filters, 1, use_bias=False, name=f"{prefix}.conv_atten")(atten) - atten = BatchNormalization(epsilon=1e-5, name=f"{prefix}.bn_atten")(atten) - atten = Activation("sigmoid", name=f"{prefix}.sigmoid")(atten) + prefix = f"cp-arm{feats}" + feat = ConvBn(self._filters, prefix=f"{prefix}-conv", start_idx=-1, padding=-1)(inputs) + atten = GlobalAveragePooling2D(name=f"{prefix}-avgpool")(feat) + atten = Reshape((1, 1, atten.shape[-1]))(atten) + atten = Conv2D(self._filters, 1, use_bias=False, name=f"{prefix}-conv_atten")(atten) + atten = BatchNormalization(epsilon=1e-5, name=f"{prefix}-bn_atten")(atten) + atten = Activation("sigmoid", name=f"{prefix}-sigmoid")(atten) var_x = Multiply(name=f"{prefix}.mul")([feat, atten]) return var_x -class ContextPath(): # pylint:disable=too-few-public-methods +class ContextPath(): """ The Context Path block for BiSeNet Face Parsing. """ def __init__(self): self._resnet = ResNet18() - def __call__(self, inputs: Tensor) -> Tensor: + def __call__(self, inputs: KerasTensor) -> KerasTensor: """ Call the Context Path block. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input to the block Returns ------- - tensor + :class:`keras.KerasTensor` The output from the block """ feat8, feat16, feat32 = self._resnet(inputs) - avg = GlobalAveragePooling2D(name="cp.avgpool")(feat32) - avg = Reshape((1, 1, K.int_shape(avg)[-1]))(avg) - avg = ConvBn(128, kernel_size=1, padding=0, prefix="cp.conv_avg", start_idx=-1)(avg) + avg = GlobalAveragePooling2D(name="cp-avgpool")(feat32) + avg = Reshape((1, 1, avg.shape[-1]))(avg) + avg = ConvBn(128, kernel_size=1, padding=0, prefix="cp-conv_avg", start_idx=-1)(avg) - avg_up = UpSampling2D(size=K.int_shape(feat32)[1:3], name="cp.upsample")(avg) + avg_up = UpSampling2D(size=feat32.shape[1:3], name="cp-upsample")(avg) feat32 = AttentionRefinementModule(128)(feat32, 32) - feat32 = Add(name="cp.add")([feat32, avg_up]) - feat32 = UpSampling2D(name="cp.upsample1")(feat32) - feat32 = ConvBn(128, kernel_size=3, prefix="cp.conv_head32", start_idx=-1)(feat32) + feat32 = Add(name="cp-add")([feat32, avg_up]) + feat32 = UpSampling2D(name="cp-upsample1")(feat32) + feat32 = ConvBn(128, kernel_size=3, prefix="cp-conv_head32", start_idx=-1)(feat32) feat16 = AttentionRefinementModule(128)(feat16, 16) - feat16 = Add(name="cp.add2")([feat16, feat32]) - feat16 = UpSampling2D(name="cp.upsample2")(feat16) - feat16 = ConvBn(128, kernel_size=3, prefix="cp.conv_head16", start_idx=-1)(feat16) + feat16 = Add(name="cp-add2")([feat16, feat32]) + feat16 = UpSampling2D(name="cp-upsample2")(feat16) + feat16 = ConvBn(128, kernel_size=3, prefix="cp-conv_head16", start_idx=-1)(feat16) return feat8, feat16, feat32 -class FeatureFusionModule(): # pylint:disable=too-few-public-methods +class FeatureFusionModule(): """ The Feature Fusion block for BiSeNet Face Parsing Parameters @@ -459,39 +460,39 @@ class FeatureFusionModule(): # pylint:disable=too-few-public-methods def __init__(self, filters: int) -> None: self._filters = filters - def __call__(self, inputs: Tensor) -> Tensor: + def __call__(self, inputs: KerasTensor) -> KerasTensor: """ Call the Feature Fusion block. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input to the block Returns ------- - tensor + :class:`keras.KerasTensor` The output from the block """ - feat = Concatenate(name="ffm.concat")(inputs) + feat = Concatenate(name="ffm-concat")(inputs) feat = ConvBn(self._filters, kernel_size=1, padding=0, - prefix="ffm.convblk", + prefix="ffm-convblk", start_idx=-1)(feat) - atten = GlobalAveragePooling2D(name="ffm.avgpool")(feat) - atten = Reshape((1, 1, K.int_shape(atten)[-1]))(atten) - atten = Conv2D(self._filters // 4, 1, use_bias=False, name="ffm.conv1")(atten) - atten = Activation("relu", name="ffm.relu")(atten) - atten = Conv2D(self._filters, 1, use_bias=False, name="ffm.conv2")(atten) - atten = Activation("sigmoid", name="ffm.sigmoid")(atten) + atten = GlobalAveragePooling2D(name="ffm-avgpool")(feat) + atten = Reshape((1, 1, atten.shape[-1]))(atten) + atten = Conv2D(self._filters // 4, 1, use_bias=False, name="ffm-conv1")(atten) + atten = Activation("relu", name="ffm-relu")(atten) + atten = Conv2D(self._filters, 1, use_bias=False, name="ffm-conv2")(atten) + atten = Activation("sigmoid", name="ffm-sigmoid")(atten) - var_x = Multiply(name="ffm.mul")([feat, atten]) - var_x = Add(name="ffm.add")([var_x, feat]) + var_x = Multiply(name="ffm-mul")([feat, atten]) + var_x = Add(name="ffm-add")([var_x, feat]) return var_x -class BiSeNetOutput(): # pylint:disable=too-few-public-methods +class BiSeNetOutput(): """ The BiSeNet Output block for Face Parsing Parameters @@ -509,94 +510,100 @@ def __init__(self, filters: int, num_classes: int, label: str = "") -> None: self._num_classes = num_classes self._label = label - def __call__(self, inputs: Tensor) -> Tensor: + def __call__(self, inputs: KerasTensor) -> KerasTensor: """ Call the BiSeNet Output block. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input to the block Returns ------- - tensor + :class:`keras.KerasTensor` The output from the block """ - var_x = ConvBn(self._filters, prefix=f"conv_out{self._label}.conv", start_idx=-1)(inputs) + var_x = ConvBn(self._filters, prefix=f"conv_out{self._label}-conv", start_idx=-1)(inputs) var_x = Conv2D(self._num_classes, 1, - use_bias=False, name=f"conv_out{self._label}.conv_out")(var_x) + use_bias=False, name=f"conv_out{self._label}-conv_out")(var_x) return var_x -class BiSeNet(KSession): +class BiSeNet(): """ BiSeNet Face-Parsing Mask from https://github.com/zllrunning/face-parsing.PyTorch PyTorch model implemented in Keras by TorzDF Parameters ---------- - model_path: str - The path to the keras model file - allow_growth: bool - Enable the Tensorflow GPU allow_growth configuration option. This option prevents - Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation and - slower performance - exclude_gpus: list - A list of indices correlating to connected GPUs that Tensorflow should not use. Pass - ``None`` to not exclude any GPUs + weights_path: str + The path to the keras weights file + batch_size: int + The batch size to feed the model input_size: int The input size to the model num_classes: int The number of segmentation classes to create - cpu_mode: bool, optional - ``True`` run the model on CPU. Default: ``False`` """ def __init__(self, - model_path: str, - allow_growth: bool, - exclude_gpus: list[int] | None, + weights_path: str, + batch_size: int, input_size: int, - num_classes: int, - cpu_mode: bool) -> None: - super().__init__("BiSeNet Face Parsing", - model_path, - allow_growth=allow_growth, - exclude_gpus=exclude_gpus, - cpu_mode=cpu_mode) + num_classes: int) -> None: + logger.debug(parse_class_init(locals())) + self._batch_size = batch_size self._input_size = input_size self._num_classes = num_classes self._cp = ContextPath() - self.define_model(self._model_definition) - self.load_model_weights() + self._model = self._load_model(weights_path) + logger.debug("Initialized: %s", self.__class__.__name__) + + def _load_model(self, weights_path: str) -> Model: + """ Definition of the BiSeNet-FP Model. - def _model_definition(self) -> tuple[Tensor, list[Tensor]]: - """ Definition of the VGG Obstructed Model. + Parameters + ---------- + weights_path: str + Full path to the model's weights Returns ------- - tuple - The tensor input to the model and tensor output to the model for compilation by - :func`define_model` + :class:`keras.models.Model` + The BiSeNet-FP model """ input_ = Input((self._input_size, self._input_size, 3)) features = self._cp(input_) # res8, cp8, cp16 feat_fuse = FeatureFusionModule(256)([features[0], features[1]]) - feat_out = BiSeNetOutput(256, self._num_classes)(feat_fuse) - feat_out16 = BiSeNetOutput(64, self._num_classes, label="16")(features[1]) - feat_out32 = BiSeNetOutput(64, self._num_classes, label="32")(features[2]) + feats = [BiSeNetOutput(256, self._num_classes)(feat_fuse), + BiSeNetOutput(64, self._num_classes, label="16")(features[1]), + BiSeNetOutput(64, self._num_classes, label="32")(features[2])] - height, width = K.int_shape(input_)[1:3] - f_h, f_w = K.int_shape(feat_out)[1:3] - f_h16, f_w16 = K.int_shape(feat_out16)[1:3] - f_h32, f_w32 = K.int_shape(feat_out32)[1:3] + height, width = input_.shape[1:3] + output = [UpSampling2D(size=(height // feat.shape[1], width // feat.shape[2]), + interpolation="bilinear")(feat) + for feat in feats] + + retval = Model(input_, output) + retval.load_weights(weights_path) + retval.make_predict_function() + return retval + + def __call__(self, inputs: np.ndarray) -> np.ndarray: + """ Get predictions from the BiSeNet-FP model + + Parameters + ---------- + inputs: :class:`numpy.ndarray` + The input to BiSeNet-FP + + Returns + ------- + :class:`numpy.ndarray` + The output from BiSeNet-FP + """ + return self._model.predict(inputs, verbose=0, batch_size=self._batch_size) - feat_out = UpSampling2D(size=(height // f_h, width // f_w), - interpolation="bilinear")(feat_out) - feat_out16 = UpSampling2D(size=(height // f_h16, width // f_w16), - interpolation="bilinear")(feat_out16) - feat_out32 = UpSampling2D(size=(height // f_h32, width // f_w32), - interpolation="bilinear")(feat_out32) - return input_, [feat_out, feat_out16, feat_out32] +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/mask/bisenet_fp_defaults.py b/plugins/extract/mask/bisenet_fp_defaults.py index 3b0ae79b92..b335b7e493 100644 --- a/plugins/extract/mask/bisenet_fp_defaults.py +++ b/plugins/extract/mask/bisenet_fp_defaults.py @@ -1,107 +1,87 @@ #!/usr/bin/env python3 +""" The default options for the faceswap BiSeNet Face Parsing plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: + + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does + +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) + +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ - The default options for the faceswap BiSeNet Face Parsing plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} - - should always be lower text. - dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid data types are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - group: [optional]. A group for grouping options together in the GUI. If not - provided this will not group this option with any others. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and data types this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and data types this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" +# pylint:disable=duplicate-code +from lib.config import ConfigItem -_HELPTEXT = ( +HELPTEXT = ( "BiSeNet Face Parsing options.\n" "Mask ported from https://github.com/zllrunning/face-parsing.PyTorch." ) -_DEFAULTS = { - "batch-size": { - "default": 8, - "info": "The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered.", - "datatype": int, - "rounding": 1, - "min_max": (1, 64), - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True - }, - "cpu": { - "default": False, - "info": "BiseNet mask still runs fairly quickly on CPU on some setups. Enable " - "CPU mode here to use the CPU for this masker to save some VRAM at a speed cost.", - "datatype": bool, - "group": "settings" - }, - "weights": { - "default": "faceswap", - "info": "The trained weights to use.\n" - "\n\tfaceswap - Weights trained on wildly varied Faceswap extracted data to " - "better handle varying conditions, obstructions, glasses and multiple targets " - "within a single extracted image." - "\n\toriginal - The original weights trained on the CelebAMask-HQ dataset.", - "choices": ["faceswap", "original"], - "datatype": str, - "group": "settings", - "gui_radio": True, - }, - "include_ears": { - "default": False, - "info": "Whether to include ears within the face mask.", - "datatype": bool, - "group": "settings" - }, - "include_hair": { - "default": False, - "info": "Whether to include hair within the face mask.", - "datatype": bool, - "group": "settings" - }, - "include_glasses": { - "default": True, - "info": "Whether to include glasses within the face mask.\n\tFor 'original' weights " - "excluding glasses will mask out the lenses as well as the frames.\n\tFor " - "'faceswap' weights, the model has been trained to mask out lenses if eyes cannot " - "be seen (i.e. dark sunglasses) or just the frames if the eyes can be seen.", - "datatype": bool, - "group": "settings" - }, -} +batch_size = ConfigItem( + datatype=int, + default=8, + group="settings", + info="The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered.", + rounding=1, + min_max=(1, 64)) + +cpu = ConfigItem( + datatype=bool, + default=False, + group="settings", + info="BiseNet mask still runs fairly quickly on CPU on some setups. Enable " + "CPU mode here to use the CPU for this masker to save some VRAM at a speed cost.") + +weights = ConfigItem( + datatype=str, + default="faceswap", + group="settings", + info="The trained weights to use.\n" + "\n\tfaceswap - Weights trained on wildly varied Faceswap extracted data to " + "better handle varying conditions, obstructions, glasses and multiple targets " + "within a single extracted image." + "\n\toriginal - The original weights trained on the CelebAMask-HQ dataset.", + choices=["faceswap", "original"], + gui_radio=True) + +include_ears = ConfigItem( + datatype=bool, + default=False, + group="settings", + info="Whether to include ears within the face mask.") + +include_hair = ConfigItem( + datatype=bool, + default=False, + group="settings", + info="Whether to include hair within the face mask.") + +include_glasses = ConfigItem( + datatype=bool, + default=True, + group="settings", + info="Whether to include glasses within the face mask.\n\tFor 'original' weights " + "excluding glasses will mask out the lenses as well as the frames.\n\tFor " + "'faceswap' weights, the model has been trained to mask out lenses if eyes cannot " + "be seen (i.e. dark sunglasses) or just the frames if the eyes can be seen.") diff --git a/plugins/extract/mask/components.py b/plugins/extract/mask/components.py index 0a71af4866..c785673ebf 100644 --- a/plugins/extract/mask/components.py +++ b/plugins/extract/mask/components.py @@ -8,6 +8,7 @@ import numpy as np from lib.align import LandmarkType +from lib.utils import get_module_objects from ._base import BatchType, Masker @@ -18,7 +19,8 @@ class Mask(Masker): - """ Perform transformation to align and get landmarks """ + # pylint:disable=duplicate-code + """ Apply a landmarks based components mask """ def __init__(self, **kwargs) -> None: git_model_id = None model_filename = None @@ -77,3 +79,6 @@ def parse_parts(landmarks: np.ndarray) -> list[tuple[np.ndarray, ...]]: nose = (landmarks[27:31], landmarks[31:36]) parts = [r_jaw, l_jaw, r_cheek, l_cheek, nose_ridge, r_eye, l_eye, nose] return parts + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/mask/custom.py b/plugins/extract/mask/custom.py index b1e3328471..2d7b353ab7 100644 --- a/plugins/extract/mask/custom.py +++ b/plugins/extract/mask/custom.py @@ -1,15 +1,25 @@ #!/usr/bin/env python3 """ Components Mask for faceswap.py """ +from __future__ import annotations import logging +import typing as T + import numpy as np +from lib.utils import get_module_objects from ._base import BatchType, Masker +from . import custom_defaults as cfg + +if T.TYPE_CHECKING: + from lib.align.constants import CenteringType + logger = logging.getLogger(__name__) class Mask(Masker): """ A mask that fills the whole face area with 1s or 0s (depending on user selected settings) for custom editing. """ + # pylint:disable=duplicate-code def __init__(self, **kwargs): git_model_id = None model_filename = None @@ -18,8 +28,8 @@ def __init__(self, **kwargs): self.name = "Custom" self.vram = 0 # Doesn't use GPU self.vram_per_batch = 0 - self.batchsize = self.config["batch-size"] - self._storage_centering = self.config["centering"] + self.batchsize = cfg.batch_size() + self._storage_centering = T.cast("CenteringType", cfg.centering()) # Separate storage for face and head masks self._storage_name = f"{self._storage_name}_{self._storage_centering}" @@ -33,10 +43,13 @@ def process_input(self, batch: BatchType) -> None: def predict(self, feed: np.ndarray) -> np.ndarray: """ Run model to get predictions """ - if self.config["fill"]: + if cfg.fill(): feed[:] = 1.0 return feed def process_output(self, batch: BatchType) -> None: """ Compile found faces for output """ return + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/mask/custom_defaults.py b/plugins/extract/mask/custom_defaults.py index 9da35416f5..4eea21fcf7 100644 --- a/plugins/extract/mask/custom_defaults.py +++ b/plugins/extract/mask/custom_defaults.py @@ -1,81 +1,63 @@ #!/usr/bin/env python3 -""" - The default options for the faceswap BiSeNet Face Parsing plugin. +""" The default options for the faceswap BiSeNet Face Parsing plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) - should always be lower text. - dictionary requirements are listed below. +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid data types are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - group: [optional]. A group for grouping options together in the GUI. If not - provided this will not group this option with any others. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and data types this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and data types this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ +# pylint:disable=duplicate-code +from lib.config import ConfigItem -_HELPTEXT = ( +HELPTEXT = ( "Custom (dummy) Mask options..\n" "The custom mask just fills a face patch with all 0's (masked out) or all 1's (masked in) for " "later manual editing. It does not use the GPU for creation." ) -_DEFAULTS = { - "batch-size": { - "default": 8, - "info": "The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.", - "datatype": int, - "rounding": 1, - "min_max": (1, 64), - "group": "settings" - }, - "centering": { - "default": "face", - "info": "Whether to create a dummy mask with face or head centering.", - "choices": ["face", "head"], - "datatype": str, - "group": "settings", - "gui_radio": True - }, - "fill": { - "default": False, - "info": "Whether the mask should be filled (True) in which case the custom mask will be " - "created with the whole area masked in (i.e. you would need to manually edit out " - "the background) or unfilled (False) in which case you would need to manually " - "edit in the face.", - "datatype": bool, - "group": "settings", - "gui_radio": True, - }, -} +batch_size = ConfigItem( + datatype=int, + default=8, + group="settings", + info="The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.", + rounding=1, + min_max=(1, 64)) + +centering = ConfigItem( + datatype=str, + group="settings", + default="face", + info="Whether to create a dummy mask with face or head centering.", + choices=["face", "head"], + gui_radio=True) + +fill = ConfigItem( + datatype=bool, + default=False, + group="settings", + info="Whether the mask should be filled (True) in which case the custom mask will be " + "created with the whole area masked in (i.e. you would need to manually edit out " + "the background) or unfilled (False) in which case you would need to manually " + "edit in the face.") diff --git a/plugins/extract/mask/extended.py b/plugins/extract/mask/extended.py index d6970cb0e5..e88ba959dc 100644 --- a/plugins/extract/mask/extended.py +++ b/plugins/extract/mask/extended.py @@ -8,6 +8,7 @@ import numpy as np from lib.align import LandmarkType +from lib.utils import get_module_objects from ._base import BatchType, Masker @@ -18,7 +19,7 @@ class Mask(Masker): - """ Perform transformation to align and get landmarks """ + """ Apply a landmarks based extended mask """ def __init__(self, **kwargs): git_model_id = None model_filename = None @@ -107,3 +108,6 @@ def parse_parts(self, landmarks: np.ndarray) -> list[tuple[np.ndarray, ...]]: nose = (landmarks[27:31], landmarks[31:36]) parts = [r_jaw, l_jaw, r_cheek, l_cheek, nose_ridge, r_eye, l_eye, nose] return parts + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/mask/unet_dfl.py b/plugins/extract/mask/unet_dfl.py index 4ca2f3dc07..ec196b1137 100644 --- a/plugins/extract/mask/unet_dfl.py +++ b/plugins/extract/mask/unet_dfl.py @@ -12,12 +12,22 @@ Model file sourced from... https://github.com/iperov/DeepFaceLab/blob/master/nnlib/FANSeg_256_full_face.h5 """ +from __future__ import annotations + import logging import typing as T import numpy as np -from lib.model.session import KSession +from keras import backend as K, layers as kl, Model + +from lib.logger import parse_class_init +from lib.utils import get_module_objects from ._base import BatchType, Masker, MaskerBatch +from . import unet_dfl_defaults as cfg + +if T.TYPE_CHECKING: + from keras import KerasTensor + logger = logging.getLogger(__name__) @@ -28,26 +38,20 @@ def __init__(self, **kwargs) -> None: git_model_id = 6 model_filename = "DFL_256_sigmoid_v1.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.model: KSession + self.model: UnetDFL self.name = "U-Net" self.input_size = 256 - self.vram = 3424 - self.vram_warnings = 256 - self.vram_per_batch = 80 - self.batchsize = self.config["batch-size"] + self.vram = 320 # 276 in testing + self.vram_per_batch = 256 # ~215 in testing + self.batchsize = cfg.batch_size() self._storage_centering = "legacy" def init_model(self) -> None: assert self.name is not None and isinstance(self.model_path, str) - self.model = KSession(self.name, - self.model_path, - model_kwargs={}, - allow_growth=self.config["allow_growth"], - exclude_gpus=self._exclude_gpus) - self.model.load_model() + self.model = UnetDFL(self.model_path, self.batchsize) placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), dtype="float32") - self.model.predict(placeholder) + self.model(placeholder) def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction """ @@ -58,10 +62,193 @@ def process_input(self, batch: BatchType) -> None: def predict(self, feed: np.ndarray) -> np.ndarray: """ Run model to get predictions """ - retval = self.model.predict(feed) - assert isinstance(retval, np.ndarray) - return retval + return self.model(feed) def process_output(self, batch: BatchType) -> None: """ Compile found faces for output """ return + + +class UnetDFL: + """ UNet DFL Definition for Keras 3 with PyTorch backend + + Parameters + ---------- + weights_path: str + Full path to the location of the weights file for the model + batch_size: int + The batch size to feed the model at + + Note + ---- + Model definition is explicitly stated as there is an incompatibility for certain + Conv2DTranspose combinations when model was trained on one backend but inferred on another: + https://github.com/keras-team/keras-core/issues/774 + The effect of this misaligns the mask and peforms bad inference for this model. + """ + def __init__(self, weights_path: str, batch_size: int) -> None: + logger.debug(parse_class_init(locals())) + self._batch_size = batch_size + self._model = self._load_model(weights_path) + logger.debug("Initialized: %s", self.__class__.__name__) + + @classmethod + def conv_block(cls, + inputs: KerasTensor, + filters: int, + recursions: int, + idx: int) -> KerasTensor: + """ Convolution block for UnetDFL downscales + + Parameters + ---------- + inputs: :class:`keras.KerasTensor` + The inputs to the block + filters: int + The number of filters for the convolution + recursions: int + The number of convolutions to run + idx: The index id of the first convolution (used for naming) + + Returns + ------- + :class:`keras.KerasTensor` + The output from the convolution block + """ + output = inputs + + for _ in range(recursions): + output = kl.Conv2D(filters, + 3, + padding="same", + activation="relu", + kernel_initializer="random_uniform", + name=f"features_{idx}")(output) + idx += 2 + + return output + + @classmethod + def skip_block(cls, # pylint:disable=too-many-positional-arguments + input_1: KerasTensor, + input_2: KerasTensor, + conv_filters: int, + trans_filters: int, + linear: bool, + idx: int) -> KerasTensor: + """ Deconvolution + skip connection for UnetDFL upscales + + Parameters + ---------- + input_1: :class:`keras.KerasTensor` + The input to be upscaled + input_2: :class:`keras.KerasTensor` + The skip connection to be concatenated to the upscaled tensor + conv_filters: int + The number of filters to be used for the convolution + trans_filters: int + The number of filters to be used for the conv-transpose + linear: bool + ``True`` to use linear activation in the convolution, ``False`` to use ReLu + idx: int + The index for naming the layers + + Returns + ------- + :class:`keras.KerasTensor` + The output from the upscaled/skip connection + """ + output = kl.Conv2D(conv_filters, + 3, + padding="same", + activation="linear" if linear else "relu", + kernel_initializer="random_uniform", + name=f"conv2d_{idx}")(input_1) + + # TF vs PyTorch paddng is different. We need to negative pad the output for Torch + padding = "valid" if K.backend() == "torch" else "same" + output = kl.Conv2DTranspose(trans_filters, + 3, + strides=2, + padding=padding, + activation="relu", + kernel_initializer="random_uniform", + name=f"conv2d_transpose_{idx}")(output) + + if K.backend() == "torch": + output = output[:, :-1, :-1, :] + + return kl.Concatenate(name=f"concatenate_{idx}")([output, input_2]) + + def _load_model(self, weights_path: str) -> Model: + """ Definition of the UNet-DFL Model. + + Parameters + ---------- + weights_path: str + Full path to the model's weights + + Returns + ------- + :class:`keras.models.Model` + The VGG-Clear model + """ + features = [] + input_ = kl.Input(shape=(256, 256, 3), name="input_1") + + features.append(self.conv_block(input_, 64, 1, 0)) + var_x = kl.MaxPool2D(pool_size=2, strides=2, name="max_pooling2d_1")(features[-1]) + + features.append(self.conv_block(var_x, 128, 1, 3)) + var_x = kl.MaxPool2D(pool_size=2, strides=2, name="max_pooling2d_2")(features[-1]) + + features.append(self.conv_block(var_x, 256, 2, 6)) + var_x = kl.MaxPool2D(pool_size=2, strides=2, name="max_pooling2d_3")(features[-1]) + + features.append(self.conv_block(var_x, 512, 2, 11)) + var_x = kl.MaxPool2D(pool_size=2, strides=2, name="max_pooling2d_4")(features[-1]) + + features.append(self.conv_block(var_x, 512, 2, 16)) + var_x = kl.MaxPool2D(pool_size=2, strides=2, name="max_pooling2d_5")(features[-1]) + + convs = [512, 512, 512, 256, 128] + for idx, (feats, filts) in enumerate(zip(reversed(features), convs)): + linear = idx == 0 + trans_filts = filts // 2 if idx < 2 else filts // 4 + var_x = self.skip_block(var_x, feats, filts, trans_filts, linear, idx + 1) + + var_x = kl.Conv2D(64, + 3, + padding="same", + activation="relu", + kernel_initializer="random_uniform", + name="conv2d_6")(var_x) + output = kl.Conv2D(1, + 3, + padding="same", + activation="sigmoid", + kernel_initializer="random_uniform", + name="conv2d_7")(var_x) + + model = Model(input_, output) + model.load_weights(weights_path) + model.make_predict_function() + return model + + def __call__(self, inputs: np.ndarray) -> np.ndarray: + """ Obtain predictions from the UNet-DFL Model + + Parameters + ---------- + inputs: :class:`numpy.ndarray` + The input to UNet-DFL + + Returns + ------- + :class:`numpy.ndarray` + The output from UNet-DFL + """ + return self._model.predict(inputs, verbose=0, batch_size=self._batch_size) + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/mask/unet_dfl_defaults.py b/plugins/extract/mask/unet_dfl_defaults.py index 62514c0188..4d20870c4e 100644 --- a/plugins/extract/mask/unet_dfl_defaults.py +++ b/plugins/extract/mask/unet_dfl_defaults.py @@ -1,68 +1,48 @@ #!/usr/bin/env python3 -""" - The default options for the faceswap UNET dfl plugin. +""" The default options for the faceswap UNET dfl plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) - should always be lower text. - dictionary requirements are listed below. +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid data types are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - group: [optional]. A group for grouping options together in the GUI. If not - provided this will not group this option with any others. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and data types this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and data types this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ +# pylint:disable=duplicate-code +from lib.config import ConfigItem -_HELPTEXT = ( +HELPTEXT = ( "UNET_DFL options. Mask designed to provide smart segmentation of mostly frontal faces.\n" "The mask model has been trained by community members. Insert more commentary on testing " "here. Profile faces may result in sub-par performance." ) -_DEFAULTS = { - "batch-size": { - "default": 8, - "info": "The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered.", - "datatype": int, - "rounding": 1, - "min_max": (1, 64), - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - } -} +batch_size = ConfigItem( + datatype=int, + default=8, + group="settings", + info="The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered.", + rounding=1, + min_max=(1, 64)) diff --git a/plugins/extract/mask/vgg_clear.py b/plugins/extract/mask/vgg_clear.py index 50165f8015..dc1a32a73c 100644 --- a/plugins/extract/mask/vgg_clear.py +++ b/plugins/extract/mask/vgg_clear.py @@ -6,16 +6,15 @@ import numpy as np -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras.layers import ( # pylint:disable=import-error - Add, Conv2D, Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, MaxPooling2D, - ZeroPadding2D) +from keras import layers as kl, Model -from lib.model.session import KSession +from lib.logger import parse_class_init +from lib.utils import get_module_objects from ._base import BatchType, Masker, MaskerBatch +from . import vgg_clear_defaults as cfg if T.TYPE_CHECKING: - from tensorflow import Tensor + from keras import KerasTensor logger = logging.getLogger(__name__) @@ -26,23 +25,19 @@ def __init__(self, **kwargs) -> None: git_model_id = 8 model_filename = "Nirkin_300_softmax_v1.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.model: KSession + self.model: VGGClear self.name = "VGG Clear" self.input_size = 300 - self.vram = 2944 - self.vram_warnings = 1088 # at BS 1. OOMs at higher batch sizes - self.vram_per_batch = 400 - self.batchsize = self.config["batch-size"] + self.vram = 1344 # 1308 in testing + self.vram_per_batch = 448 # ~402 in testing + self.batchsize = cfg.batch_size() def init_model(self) -> None: assert isinstance(self.model_path, str) - self.model = VGGClear(self.model_path, - allow_growth=self.config["allow_growth"], - exclude_gpus=self._exclude_gpus) - self.model.append_softmax_activation(layer_index=-1) + self.model = VGGClear(self.model_path, self.batchsize) placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), dtype="float32") - self.model.predict(placeholder) + self.model(placeholder) def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction """ @@ -54,7 +49,7 @@ def process_input(self, batch: BatchType) -> None: def predict(self, feed: np.ndarray) -> np.ndarray: """ Run model to get predictions """ - predictions = self.model.predict(feed) + predictions = self.model(feed) assert isinstance(predictions, np.ndarray) return predictions[..., -1] @@ -63,23 +58,18 @@ def process_output(self, batch: BatchType) -> None: return -class VGGClear(KSession): +class VGGClear(): """ VGG Clear mask for Faceswap. Caffe model re-implemented in Keras by Kyle Vrooman. - Re-implemented for Tensorflow 2 by TorzDF + Re-implemented for Keras by TorzDF Parameters ---------- - model_path: str + weights_path: str The path to the keras model file - allow_growth: bool - Enable the Tensorflow GPU allow_growth configuration option. This option prevents - Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation and - slower performance - exclude_gpus: list - A list of indices correlating to connected GPUs that Tensorflow should not use. Pass - ``None`` to not exclude any GPUs + batch_size: int + The batch size to feed the model References ---------- @@ -91,29 +81,28 @@ class VGGClear(KSession): https://github.com/YuvalNirkin/face_segmentation/releases/download/1.1/face_seg_fcn8s_300_no_aug.zip """ - def __init__(self, - model_path: str, - allow_growth: bool, - exclude_gpus: list[int] | None): - super().__init__("VGG Obstructed", - model_path, - allow_growth=allow_growth, - exclude_gpus=exclude_gpus) - self.define_model(self._model_definition) - self.load_model_weights() + def __init__(self, weights_path: str, batch_size: int) -> None: + logger.debug(parse_class_init(locals())) + self._batch_size = batch_size + self._model = self._load_model(weights_path) + logger.debug("Initialized: %s", self.__class__.__name__) @classmethod - def _model_definition(cls) -> tuple[Tensor, Tensor]: - """ Definition of the VGG Obstructed Model. + def _load_model(cls, weights_path: str) -> Model: + """ Definition of the VGG Clear Model. + + Parameters + ---------- + weights_path: str + Full path to the model's weights Returns ------- - tuple - The tensor input to the model and tensor output to the model for compilation by - :func`define_model` + :class:`keras.models.Model` + The VGG-Clear model """ - input_ = Input(shape=(300, 300, 3)) - var_x = ZeroPadding2D(padding=((100, 100), (100, 100)), name="zero_padding2d_1")(input_) + input_ = kl.Input(shape=(300, 300, 3)) + var_x = kl.ZeroPadding2D(padding=((100, 100), (100, 100)), name="zero_padding2d_1")(input_) var_x = _ConvBlock(1, 64, 2)(var_x) var_x = _ConvBlock(2, 128, 2)(var_x) @@ -124,36 +113,56 @@ def _model_definition(cls) -> tuple[Tensor, Tensor]: score_pool3 = _ScorePool(3, 0.0001, (9, 8))(pool3) score_pool4 = _ScorePool(4, 0.01, (5, 5))(pool4) - var_x = Conv2D(4096, 7, activation="relu", name="fc6")(var_x) - var_x = Dropout(rate=0.5, name="drop6")(var_x) - var_x = Conv2D(4096, 1, activation="relu", name="fc7")(var_x) - var_x = Dropout(rate=0.5, name="drop7")(var_x) - var_x = Conv2D(2, 1, activation="linear", name="score_fr_r")(var_x) - var_x = Conv2DTranspose(2, - 4, - strides=2, - activation="linear", - use_bias=False, name="upscore2_r")(var_x) - - var_x = Add(name="fuse_pool4")([var_x, score_pool4]) - var_x = Conv2DTranspose(2, - 4, - strides=2, - activation="linear", - use_bias=False, - name="upscore_pool4_r")(var_x) - var_x = Add(name="fuse_pool3")([var_x, score_pool3]) - var_x = Conv2DTranspose(2, - 16, - strides=8, - activation="linear", - use_bias=False, - name="upscore8_r")(var_x) - var_x = Cropping2D(cropping=((31, 45), (31, 45)), name="score")(var_x) - return input_, var_x - - -class _ConvBlock(): # pylint:disable=too-few-public-methods + var_x = kl.Conv2D(4096, 7, activation="relu", name="fc6")(var_x) + var_x = kl.Dropout(rate=0.5, name="drop6")(var_x) + var_x = kl.Conv2D(4096, 1, activation="relu", name="fc7")(var_x) + var_x = kl.Dropout(rate=0.5, name="drop7")(var_x) + var_x = kl.Conv2D(2, 1, activation="linear", name="score_fr_r")(var_x) + var_x = kl.Conv2DTranspose(2, + 4, + strides=2, + activation="linear", + use_bias=False, name="upscore2_r")(var_x) + + var_x = kl.Add(name="fuse_pool4")([var_x, score_pool4]) + var_x = kl.Conv2DTranspose(2, + 4, + strides=2, + activation="linear", + use_bias=False, + name="upscore_pool4_r")(var_x) + var_x = kl.Add(name="fuse_pool3")([var_x, score_pool3]) + var_x = kl.Conv2DTranspose(2, + 16, + strides=8, + activation="linear", + use_bias=False, + name="upscore8_r")(var_x) + var_x = kl.Cropping2D(cropping=((31, 45), (31, 45)), name="score")(var_x) + var_x = kl.Activation("softmax", name="softmax")(var_x) + + retval = Model(input_, var_x) + retval.load_weights(weights_path) + retval.make_predict_function() + return retval + + def __call__(self, inputs: np.ndarray) -> np.ndarray: + """ Get predictions from the VGG-Clear model + + Parameters + ---------- + inputs: :class:`numpy.ndarray` + The input to VGG-Clear + + Returns + ------- + :class:`numpy.ndarray` + The output from VGG-Clear + """ + return self._model.predict(inputs, verbose=0, batch_size=self._batch_size) + + +class _ConvBlock(): """ Convolutional loop with max pooling layer for VGG Clear. Parameters @@ -171,34 +180,34 @@ def __init__(self, level: int, filters: int, iterations: int) -> None: self._filters = filters self._iterator = range(1, iterations + 1) - def __call__(self, inputs: Tensor) -> Tensor: + def __call__(self, inputs: KerasTensor) -> KerasTensor: """ Call the convolutional loop. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input tensor to the block Returns ------- - tensor + :class:`keras.KerasTensor` The output tensor from the convolutional block """ var_x = inputs for i in self._iterator: padding = "valid" if self._level == i == 1 else "same" - var_x = Conv2D(self._filters, - 3, - padding=padding, - activation="relu", - name=f"{self._name}{i}")(var_x) - var_x = MaxPooling2D(padding="same", - strides=(2, 2), - name=f"pool{self._level}")(var_x) + var_x = kl.Conv2D(self._filters, + 3, + padding=padding, + activation="relu", + name=f"{self._name}{i}")(var_x) + var_x = kl.MaxPooling2D(padding="same", + strides=(2, 2), + name=f"pool{self._level}")(var_x) return var_x -class _ScorePool(): # pylint:disable=too-few-public-methods +class _ScorePool(): """ Cropped scaling of the pooling layer. Parameters @@ -215,7 +224,7 @@ def __init__(self, level: int, scale: float, crop: tuple[int, int]): self._cropping = (crop, crop) self._scale = scale - def __call__(self, inputs: Tensor) -> Tensor: + def __call__(self, inputs: np.ndarray) -> np.ndarray: """ Score pool block. Parameters @@ -228,7 +237,10 @@ def __call__(self, inputs: Tensor) -> Tensor: tensor The output tensor from the score pool block """ - var_x = Lambda(lambda x: x * self._scale, name="scale" + self._name)(inputs) - var_x = Conv2D(2, 1, activation="linear", name="score" + self._name + "_r")(var_x) - var_x = Cropping2D(cropping=self._cropping, name="score" + self._name + "c")(var_x) + var_x = kl.Lambda(lambda x: x * self._scale, name="scale" + self._name)(inputs) + var_x = kl.Conv2D(2, 1, activation="linear", name="score" + self._name + "_r")(var_x) + var_x = kl.Cropping2D(cropping=self._cropping, name="score" + self._name + "c")(var_x) return var_x + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/mask/vgg_clear_defaults.py b/plugins/extract/mask/vgg_clear_defaults.py index 48c5d1f428..48ee92f329 100644 --- a/plugins/extract/mask/vgg_clear_defaults.py +++ b/plugins/extract/mask/vgg_clear_defaults.py @@ -1,67 +1,47 @@ #!/usr/bin/env python3 -""" - The default options for the faceswap VGG clear plugin. +""" The default options for the faceswap VGG clear plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) - should always be lower text. - dictionary requirements are listed below. +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid data types are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - group: [optional]. A group for grouping options together in the GUI. If not - provided this will not group this option with any others. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and data types this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and data types this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ +# pylint:disable=duplicate-code +from lib.config import ConfigItem -_HELPTEXT = ( +HELPTEXT = ( "VGG_Clear options. Mask designed to provide smart segmentation of mostly frontal faces clear " "of obstructions.\nProfile faces and obstructions may result in sub-par performance." ) -_DEFAULTS = { - "batch-size": { - "default": 6, - "info": "The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered.", - "datatype": int, - "rounding": 1, - "min_max": (1, 64), - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - } -} +batch_size = ConfigItem( + datatype=int, + default=6, + group="settings", + info="The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered.", + rounding=1, + min_max=(1, 64)) diff --git a/plugins/extract/mask/vgg_obstructed.py b/plugins/extract/mask/vgg_obstructed.py index a3f543d7e8..b2733c5bc2 100644 --- a/plugins/extract/mask/vgg_obstructed.py +++ b/plugins/extract/mask/vgg_obstructed.py @@ -6,19 +6,20 @@ import numpy as np -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras.layers import ( # pylint:disable=import-error - Add, Conv2D, Conv2DTranspose, Cropping2D, Dropout, Input, Lambda, MaxPooling2D, - ZeroPadding2D) +from keras import layers as kl, Model -from lib.model.session import KSession +from lib.logger import parse_class_init +from lib.utils import get_module_objects from ._base import BatchType, Masker, MaskerBatch +from . import vgg_obstructed_defaults as cfg if T.TYPE_CHECKING: - from tensorflow import Tensor + from keras import KerasTensor logger = logging.getLogger(__name__) +# pylint:disable=duplicate-code + class Mask(Masker): """ Neural network to process face image into a segmentation mask of the face """ @@ -26,34 +27,30 @@ def __init__(self, **kwargs) -> None: git_model_id = 5 model_filename = "Nirkin_500_softmax_v1.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.model: KSession + self.model: VGGObstructed self.name = "VGG Obstructed" self.input_size = 500 - self.vram = 3936 - self.vram_warnings = 1088 # at BS 1. OOMs at higher batch sizes - self.vram_per_batch = 304 - self.batchsize = self.config["batch-size"] + self.vram = 1728 # 1710 in testing + self.vram_per_batch = 896 # ~886 in testing + self.batchsize = cfg.batch_size() def init_model(self) -> None: assert isinstance(self.model_path, str) - self.model = VGGObstructed(self.model_path, - allow_growth=self.config["allow_growth"], - exclude_gpus=self._exclude_gpus) - self.model.append_softmax_activation(layer_index=-1) + self.model = VGGObstructed(self.model_path, self.batchsize) placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), dtype="float32") - self.model.predict(placeholder) + self.model(placeholder) def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction """ assert isinstance(batch, MaskerBatch) input_ = [T.cast(np.ndarray, feed.face)[..., :3] for feed in batch.feed_faces] batch.feed = input_ - np.mean(input_, axis=(1, 2))[:, None, None, :] - logger.trace("feed shape: %s", batch.feed.shape) # type:ignore + logger.trace("feed shape: %s", batch.feed.shape) # type:ignore[attr-defined] def predict(self, feed: np.ndarray) -> np.ndarray: """ Run model to get predictions """ - predictions = self.model.predict(feed) + predictions = self.model(feed) assert isinstance(predictions, np.ndarray) return predictions[..., 0] * -1.0 + 1.0 @@ -62,23 +59,18 @@ def process_output(self, batch: BatchType) -> None: return -class VGGObstructed(KSession): +class VGGObstructed(): """ VGG Obstructed mask for Faceswap. Caffe model re-implemented in Keras by Kyle Vrooman. - Re-implemented for Tensorflow 2 by TorzDF + Re-implemented for Keras by TorzDF Parameters ---------- - model_path: str + weights_path: str The path to the keras model file - allow_growth: bool - Enable the Tensorflow GPU allow_growth configuration option. This option prevents - Tensorflow from allocating all of the GPU VRAM, but can lead to higher fragmentation and - slower performance - exclude_gpus: list - A list of indices correlating to connected GPUs that Tensorflow should not use. Pass - ``None`` to not exclude any GPUs + batch_size: int + The batch size to feed the model References ---------- @@ -87,29 +79,28 @@ class VGGObstructed(KSession): Model file sourced from: https://github.com/YuvalNirkin/face_segmentation/releases/download/1.0/face_seg_fcn8s.zip """ - def __init__(self, - model_path: str, - allow_growth: bool, - exclude_gpus: list[int] | None) -> None: - super().__init__("VGG Obstructed", - model_path, - allow_growth=allow_growth, - exclude_gpus=exclude_gpus) - self.define_model(self._model_definition) - self.load_model_weights() + def __init__(self, weights_path: str, batch_size: int) -> None: + logger.debug(parse_class_init(locals())) + self._batch_size = batch_size + self._model = self._load_model(weights_path) + logger.debug("Initialized: %s", self.__class__.__name__) @classmethod - def _model_definition(cls) -> tuple[Tensor, Tensor]: + def _load_model(cls, weights_path: str) -> Model: """ Definition of the VGG Obstructed Model. + Parameters + ---------- + weights_path: str + Full path to the model's weights + Returns ------- - tuple - The tensor input to the model and tensor output to the model for compilation by - :func`define_model` + :class:`keras.models.Model` + The VGG-Obstructed model """ - input_ = Input(shape=(500, 500, 3)) - var_x = ZeroPadding2D(padding=((100, 100), (100, 100)))(input_) + input_ = kl.Input(shape=(500, 500, 3)) + var_x = kl.ZeroPadding2D(padding=((100, 100), (100, 100)))(input_) var_x = _ConvBlock(1, 64, 2)(var_x) var_x = _ConvBlock(2, 128, 2)(var_x) @@ -120,39 +111,59 @@ def _model_definition(cls) -> tuple[Tensor, Tensor]: score_pool4 = _ScorePool(4, 0.01, 5)(var_x) var_x = _ConvBlock(5, 512, 3)(var_x) - var_x = Conv2D(4096, 7, padding="valid", activation="relu", name="fc6")(var_x) - var_x = Dropout(rate=0.5)(var_x) - var_x = Conv2D(4096, 1, padding="valid", activation="relu", name="fc7")(var_x) - var_x = Dropout(rate=0.5)(var_x) - - var_x = Conv2D(21, 1, padding="valid", activation="linear", name="score_fr")(var_x) - var_x = Conv2DTranspose(21, - 4, - strides=2, - activation="linear", - use_bias=False, - name="upscore2")(var_x) - - var_x = Add()([var_x, score_pool4]) - var_x = Conv2DTranspose(21, - 4, - strides=2, - activation="linear", - use_bias=False, - name="upscore_pool4")(var_x) - - var_x = Add()([var_x, score_pool3]) - var_x = Conv2DTranspose(21, - 16, - strides=8, - activation="linear", - use_bias=False, - name="upscore8")(var_x) - var_x = Cropping2D(cropping=((31, 37), (31, 37)), name="score")(var_x) - return input_, var_x - - -class _ConvBlock(): # pylint:disable=too-few-public-methods + var_x = kl.Conv2D(4096, 7, padding="valid", activation="relu", name="fc6")(var_x) + var_x = kl.Dropout(rate=0.5)(var_x) + var_x = kl.Conv2D(4096, 1, padding="valid", activation="relu", name="fc7")(var_x) + var_x = kl.Dropout(rate=0.5)(var_x) + + var_x = kl.Conv2D(21, 1, padding="valid", activation="linear", name="score_fr")(var_x) + var_x = kl.Conv2DTranspose(21, + 4, + strides=2, + activation="linear", + use_bias=False, + name="upscore2")(var_x) + + var_x = kl.Add()([var_x, score_pool4]) + var_x = kl.Conv2DTranspose(21, + 4, + strides=2, + activation="linear", + use_bias=False, + name="upscore_pool4")(var_x) + + var_x = kl.Add()([var_x, score_pool3]) + var_x = kl.Conv2DTranspose(21, + 16, + strides=8, + activation="linear", + use_bias=False, + name="upscore8")(var_x) + var_x = kl.Cropping2D(cropping=((31, 37), (31, 37)), name="score")(var_x) + var_x = kl.Activation("softmax", name="softmax")(var_x) + + retval = Model(input_, var_x) + retval.load_weights(weights_path) + retval.make_predict_function() + return retval + + def __call__(self, inputs: np.ndarray) -> np.ndarray: + """ Get predictions from the VGG-Clear model + + Parameters + ---------- + inputs: :class:`numpy.ndarray` + The input to VGG-Obstructed + + Returns + ------- + :class:`numpy.ndarray` + The output from VGG-Obstructed + """ + return self._model.predict(inputs, verbose=0, batch_size=self._batch_size) + + +class _ConvBlock(): """ Convolutional loop with max pooling layer for VGG Obstructed. Parameters @@ -170,34 +181,34 @@ def __init__(self, level: int, filters: int, iterations: int) -> None: self._filters = filters self._iterator = range(1, iterations + 1) - def __call__(self, inputs: Tensor) -> Tensor: + def __call__(self, inputs: KerasTensor) -> KerasTensor: """ Call the convolutional loop. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input tensor to the block Returns ------- - tensor + :class:`keras.KerasTensor` The output tensor from the convolutional block """ var_x = inputs for i in self._iterator: padding = "valid" if self._level == i == 1 else "same" - var_x = Conv2D(self._filters, - 3, - padding=padding, - activation="relu", - name=f"{self._name}{i}")(var_x) - var_x = MaxPooling2D(padding="same", - strides=(2, 2), - name=f"pool{self._level}")(var_x) + var_x = kl.Conv2D(self._filters, + 3, + padding=padding, + activation="relu", + name=f"{self._name}{i}")(var_x) + var_x = kl.MaxPooling2D(padding="same", + strides=(2, 2), + name=f"pool{self._level}")(var_x) return var_x -class _ScorePool(): # pylint:disable=too-few-public-methods +class _ScorePool(): """ Cropped scaling of the pooling layer. Parameters @@ -214,24 +225,27 @@ def __init__(self, level: int, scale: float, crop: int) -> None: self._cropping = ((crop, crop), (crop, crop)) self._scale = scale - def __call__(self, inputs: Tensor) -> Tensor: + def __call__(self, inputs: KerasTensor) -> KerasTensor: """ Score pool block. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input tensor to the block Returns ------- - tensor + :class:`keras.KerasTensor` The output tensor from the score pool block """ - var_x = Lambda(lambda x: x * self._scale, name="scale" + self._name)(inputs) - var_x = Conv2D(21, - 1, - padding="valid", - activation="linear", - name="score" + self._name)(var_x) - var_x = Cropping2D(cropping=self._cropping, name="score" + self._name + "c")(var_x) + var_x = kl.Lambda(lambda x: x * self._scale, name="scale" + self._name)(inputs) + var_x = kl.Conv2D(21, + 1, + padding="valid", + activation="linear", + name="score" + self._name)(var_x) + var_x = kl.Cropping2D(cropping=self._cropping, name="score" + self._name + "c")(var_x) return var_x + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/mask/vgg_obstructed_defaults.py b/plugins/extract/mask/vgg_obstructed_defaults.py index 7d19354289..9a42624a40 100644 --- a/plugins/extract/mask/vgg_obstructed_defaults.py +++ b/plugins/extract/mask/vgg_obstructed_defaults.py @@ -1,68 +1,48 @@ #!/usr/bin/env python3 -""" - The default options for the faceswap VGG obstructed plugin. +""" The default options for the faceswap VGG obstructed plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) - should always be lower text. - dictionary requirements are listed below. +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid data types are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - group: [optional]. A group for grouping options together in the GUI. If not - provided this will not group this option with any others. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and data types this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and data types this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ +# pylint:disable=duplicate-code +from lib.config import ConfigItem -_HELPTEXT = ( +HELPTEXT = ( "VGG_Obstructed options. Mask designed to provide smart segmentation of mostly frontal " "faces.\nThe mask model has been specifically trained to recognize some facial obstructions " "(hands and eyeglasses). Profile faces may result in sub-par performance." ) -_DEFAULTS = { - "batch-size": { - "default": 2, - "info": "The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered.", - "datatype": int, - "rounding": 1, - "min_max": (1, 64), - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True, - } -} +batch_size = ConfigItem( + datatype=int, + default=2, + group="settings", + info="The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered.", + rounding=1, + min_max=(1, 64)) diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 5a051936bb..52ec0e4373 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -2,9 +2,6 @@ """ Return a requested detector/aligner/masker pipeline -Tensorflow does not like to release GPU VRAM, so parallel plugins need to be managed to work -together. - This module sets up a pipeline for the extraction workflow, loading detect, align and mask plugins either in parallel or in series, giving easy access to input and output. """ @@ -18,7 +15,7 @@ from lib.logger import parse_class_init from lib.queue_manager import EventQueue, queue_manager, QueueEmpty from lib.serializer import get_serializer -from lib.utils import get_backend, FaceswapError +from lib.utils import get_backend, get_module_objects, FaceswapError from plugins.plugin_loader import PluginLoader if T.TYPE_CHECKING: @@ -43,7 +40,7 @@ def _get_instance(): return _INSTANCES -class Extractor(): +class Extractor(): # pylint:disable=too-many-instance-attributes """ Creates a :mod:`~plugins.extract.detect`/:mod:`~plugins.extract.align``/\ :mod:`~plugins.extract.mask` pipeline and yields results frame by frame from the :attr:`detected_faces` generator @@ -68,9 +65,6 @@ class Extractor(): multiprocess: bool, optional Whether to attempt processing the plugins in parallel. This may get overridden internally depending on the plugin combination. Default: ``False`` - exclude_gpus: list, optional - A list of indices correlating to connected GPUs that Tensorflow should not use. Pass - ``None`` to not exclude any GPUs. Default: ``None`` rotate_images: str, optional Used to set the :attr:`plugins.extract.detect.rotation` attribute. Pass in a single number to use increments of that size up to 360, or pass in a ``list`` of ``ints`` to enumerate @@ -98,14 +92,13 @@ class Extractor(): The current phase that the pipeline is running. Used in conjunction with :attr:`passes` and :attr:`final_pass` to indicate to the caller which phase is being processed """ - def __init__(self, + def __init__(self, # pylint:disable=too-many-arguments,too-many-positional-arguments detector: str | None, aligner: str | None, masker: str | list[str] | None, recognition: str | None = None, configfile: str | None = None, multiprocess: bool = False, - exclude_gpus: list[int] | None = None, rotate_images: str | None = None, min_size: int = 0, normalize_method: T.Literal["none", "clahe", "hist", "mean"] | None = None, @@ -118,10 +111,6 @@ def __init__(self, masker)] if not isinstance(masker, list) else T.cast(list[str | None], masker) self._flow = self._set_flow(detector, aligner, maskers, recognition) - self._exclude_gpus = exclude_gpus - # We only ever need 1 item in each queue. This is 2 items cached (1 in queue 1 waiting - # for queue) at each point. Adding more just stacks RAM with no speed benefit. - self._queue_size = 1 # TODO Calculate scaling for more plugins than currently exist in _parallel_scaling self._scaling_fallback = 0.4 self._vram_stats = self._get_vram_stats() @@ -134,7 +123,6 @@ def __init__(self, disable_filter) self._recognition = self._load_recognition(recognition, configfile) self._mask = [self._load_mask(mask, configfile) for mask in maskers] - self._is_parallel = self._set_parallel_processing(multiprocess) self._phases = self._set_phases(multiprocess) self._phase_index = 0 self._set_extractor_batchsize() @@ -347,9 +335,8 @@ def import_data(self, input_location: str) -> None: return align_origin = None - assert self.aligner.name is not None - if self.aligner.name.lower() == "external": - align_origin = self.aligner.config["origin"] + if len(import_plugins) == 2: + align_origin = import_plugins[-1].origin logger.info("Importing external data for %s from json file...", " and ".join([p.__class__.__name__ for p in import_plugins])) @@ -359,9 +346,10 @@ def import_data(self, input_location: str) -> None: last_fname = "" is_68_point = True + data = {} for plugin in import_plugins: plugin_type = plugin.__class__.__name__ - path = os.path.join(folder, plugin.config["file_name"]) + path = os.path.join(folder, plugin.file_name) if not os.path.isfile(path): raise FaceswapError(f"{plugin_type} import file could not be found at '{path}'") @@ -548,7 +536,7 @@ def _add_queues(self) -> dict[str, EventQueue]: tasks.append(f"extract{self._instance}_{self._final_phase}_out") for task in tasks: # Limit queue size to avoid stacking ram - queue_manager.add_queue(task, maxsize=self._queue_size) + queue_manager.add_queue(task, maxsize=1) queues[task] = queue_manager.get_queue(task) logger.debug("Queues: %s", queues) return queues @@ -563,6 +551,7 @@ def _get_vram_stats() -> dict[str, int | str]: Statistics on available VRAM """ vram_buffer = 256 # Leave a buffer for VRAM allocation + assert GPUStats is not None gpu_stats = GPUStats() stats = gpu_stats.get_card_most_free() retval: dict[str, int | str] = {"count": gpu_stats.device_count, @@ -680,8 +669,7 @@ def _load_align(self, return None aligner_name = aligner.replace("-", "_").lower() logger.debug("Loading Aligner: '%s'", aligner_name) - plugin = PluginLoader.get_aligner(aligner_name)(exclude_gpus=self._exclude_gpus, - configfile=configfile, + plugin = PluginLoader.get_aligner(aligner_name)(configfile=configfile, normalize_method=normalize_method, re_feed=re_feed, re_align=re_align, @@ -726,8 +714,7 @@ def _load_detect(self, detector_name = aligner logger.debug("Loading Detector: '%s'", detector_name) - plugin = PluginLoader.get_detector(detector_name)(exclude_gpus=self._exclude_gpus, - rotation=rotation, + plugin = PluginLoader.get_detector(detector_name)(rotation=rotation, min_size=min_size, configfile=configfile, instance=self._instance) @@ -755,8 +742,7 @@ def _load_mask(self, return None masker_name = masker.replace("-", "_").lower() logger.debug("Loading Masker: '%s'", masker_name) - plugin = PluginLoader.get_masker(masker_name)(exclude_gpus=self._exclude_gpus, - configfile=configfile, + plugin = PluginLoader.get_masker(masker_name)(configfile=configfile, instance=self._instance) return plugin @@ -769,8 +755,7 @@ def _load_recognition(self, return None recognition_name = recognition.replace("-", "_").lower() logger.debug("Loading Recognition: '%s'", recognition_name) - plugin = PluginLoader.get_recognition(recognition_name)(exclude_gpus=self._exclude_gpus, - configfile=configfile, + plugin = PluginLoader.get_recognition(recognition_name)(configfile=configfile, instance=self._instance) return plugin @@ -793,15 +778,66 @@ def _launch_plugin(self, phase: str) -> None: plugin.start() logger.debug("Launched %s plugin", phase) + def _set_plugins_batchsize(self, gpu_plugins: list[str], vram_free: int) -> None: + """ Set the batch size for the current phase so that it will fit in available VRAM. + + Do not update plugins which have a vram_per_batch of 0 (CPU plugins) due to + zero division error. + + Reduces the batchsize of the plugin which has a batch size > 1 and the largest VRAM + requirements. The final reduction is the plugin which has a batch size > 1 and the + smallest VRAM requirements that would fit the pipeline inside VRAM + + Parameters + ---------- + gpu_plugins: list[str] + The name of the plugins that use the GPU for the current phase + vram_free: int + The amount of available VRAM, in MBs + """ + logger.debug("GPU plugins: %s, Available vram: %s", gpu_plugins, vram_free) + plugins = [self._active_plugins[idx] + for idx, plugin in enumerate(self._current_phase) + if plugin in gpu_plugins] + base_vram = sum(p.vram for p in plugins) + vram_free = vram_free - base_vram + logger.debug("Base vram: %s, remaining vram: %s", base_vram, vram_free) + + to_allocate = [(p.batchsize, p.vram_per_batch) for p in plugins] + excess = sum(a[0] * a[1] for a in to_allocate) - vram_free + logger.debug("Plugins to allocate: %s, excess vram: %s", to_allocate, excess) + + while excess > 0: + chosen = next(p for p in to_allocate + if p[0] > 1 and p[1] == max(p[1] for p in to_allocate if p[0] > 1)) + + if excess - chosen[1] <= 0: + chosen = next(p for p in to_allocate + if p[0] > 1 and p[1] == min(p[1] for p in to_allocate + if p[0] > 1 and p[1] >= excess)) + + excess -= chosen[1] + logger.debug("Reducing batch size for item %s. Remaining %s", chosen, excess) + to_allocate[to_allocate.index(chosen)] = (chosen[0] - 1, chosen[1]) + + msg = [] + for plugin, alloc in zip(plugins, to_allocate): + if plugin.batchsize != alloc[0]: + logger.debug("Updating batchsize for plugin %s from %s to %s", + plugin.name, plugin.batchsize, alloc[0]) + plugin.batchsize = alloc[0] + msg.append(f"{plugin.__class__.__name__}: {plugin.batchsize}") + + logger.info("Reset batch sizes due to available VRAM: %s", ", ".join(msg)) + def _set_extractor_batchsize(self) -> None: """ Sets the batch size of the requested plugins based on their vram, their vram_per_batch_requirements and the number of plugins being loaded in the current phase. - Only adjusts if the the configured batch size requires more vram than is available. Nvidia - only. + Only adjusts if the the configured batch size requires more vram than is available. """ backend = get_backend() - if backend not in ("nvidia", "directml", "rocm"): + if backend not in ("nvidia", "rocm"): logger.debug("Not updating batchsize requirements for backend: '%s'", backend) return if sum(plugin.vram for plugin in self._active_plugins) == 0: @@ -810,62 +846,20 @@ def _set_extractor_batchsize(self) -> None: batch_required = sum(plugin.vram_per_batch * plugin.batchsize for plugin in self._active_plugins) + gpu_plugins = [p for p in self._current_phase if self._vram_per_phase[p] > 0] + scaling = self._parallel_scaling.get(len(gpu_plugins), self._scaling_fallback) plugins_required = sum(self._vram_per_phase[p] for p in gpu_plugins) * scaling - if plugins_required + batch_required <= T.cast(int, self._vram_stats["vram_free"]): + + vram_free = T.cast(int, self._vram_stats["vram_free"]) + total_required = plugins_required + batch_required + if total_required <= vram_free: logger.debug("Plugin requirements within threshold: (plugins_required: %sMB, " "vram_free: %sMB)", plugins_required, self._vram_stats["vram_free"]) return - # Hacky split across plugins that use vram - available_vram = (T.cast(int, self._vram_stats["vram_free"]) - - plugins_required) // len(gpu_plugins) - self._set_plugin_batchsize(gpu_plugins, available_vram) - def _set_plugin_batchsize(self, gpu_plugins: list[str], available_vram: float) -> None: - """ Set the batch size for the given plugin based on given available vram. - Do not update plugins which have a vram_per_batch of 0 (CPU plugins) due to - zero division error. - """ - plugins = [self._active_plugins[idx] - for idx, plugin in enumerate(self._current_phase) - if plugin in gpu_plugins] - vram_per_batch = [plugin.vram_per_batch for plugin in plugins] - ratios = [vram / sum(vram_per_batch) for vram in vram_per_batch] - requested_batchsizes = [plugin.batchsize for plugin in plugins] - batchsizes = [min(requested, max(1, int((available_vram * ratio) / plugin.vram_per_batch))) - for ratio, plugin, requested in zip(ratios, plugins, requested_batchsizes)] - remaining = available_vram - sum(batchsize * plugin.vram_per_batch - for batchsize, plugin in zip(batchsizes, plugins)) - sorted_indices = [i[0] for i in sorted(enumerate(plugins), - key=lambda x: x[1].vram_per_batch, reverse=True)] - - logger.debug("requested_batchsizes: %s, batchsizes: %s, remaining vram: %s", - requested_batchsizes, batchsizes, remaining) - - while remaining > min(plugin.vram_per_batch - for plugin in plugins) and requested_batchsizes != batchsizes: - for idx in sorted_indices: - plugin = plugins[idx] - if plugin.vram_per_batch > remaining: - logger.debug("Not enough VRAM to increase batch size of %s. Required: %sMB, " - "Available: %sMB", plugin, plugin.vram_per_batch, remaining) - continue - if plugin.batchsize == batchsizes[idx]: - logger.debug("Threshold reached for %s. Batch size: %s", - plugin, plugin.batchsize) - continue - logger.debug("Incrementing batch size of %s to %s", plugin, batchsizes[idx] + 1) - batchsizes[idx] += 1 - remaining -= plugin.vram_per_batch - logger.debug("Remaining VRAM to allocate: %sMB", remaining) - - if batchsizes != requested_batchsizes: - text = ", ".join([f"{plugin.__class__.__name__}: {batchsize}" - for plugin, batchsize in zip(plugins, batchsizes)]) - for plugin, batchsize in zip(plugins, batchsizes): - plugin.batchsize = batchsize - logger.info("Reset batch sizes due to available VRAM: %s", text) + self._set_plugins_batchsize(gpu_plugins, vram_free) def _join_threads(self): """ Join threads for current pass """ @@ -876,3 +870,6 @@ def _check_and_raise_error(self) -> None: """ Check all threads for errors and raise if one occurs """ for plugin in self._active_plugins: plugin.check_and_raise_error() + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/recognition/_base.py b/plugins/extract/recognition/_base.py index 61662e623b..e00abbe2a7 100644 --- a/plugins/extract/recognition/_base.py +++ b/plugins/extract/recognition/_base.py @@ -22,12 +22,12 @@ from dataclasses import dataclass, field import numpy as np -from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa +from torch.cuda import OutOfMemoryError from lib.align import AlignedFace, DetectedFace, LandmarkType from lib.image import read_image_meta from lib.utils import FaceswapError -from plugins.extract import ExtractMedia +from plugins.extract import ExtractMedia, extract_config as cfg from plugins.extract._base import BatchType, ExtractorBatch, Extractor if T.TYPE_CHECKING: @@ -84,7 +84,7 @@ def __init__(self, instance: int = 0, **kwargs): logger.debug("Initializing %s", self.__class__.__name__) - super().__init__(git_model_id, + super().__init__(git_model_id, # pylint:disable=duplicate-code model_filename, configfile=configfile, instance=instance, @@ -93,8 +93,8 @@ def __init__(self, self.centering: CenteringType = "legacy" # Override for model specific centering self.coverage_ratio = 1.0 # Override for model specific coverage_ratio - self._plugin_type = "recognition" - self._filter = IdentityFilter(self.config["save_filtered"]) + self._info.plugin_type = "recognition" + self._filter = IdentityFilter(cfg.save_filtered()) logger.debug("Initialized _base %s", self.__class__.__name__) def _get_detected_from_aligned(self, item: ExtractMedia) -> None: @@ -111,7 +111,7 @@ def _get_detected_from_aligned(self, item: ExtractMedia) -> None: if meta: detected_face.from_png_meta(meta) item.add_detected_faces([detected_face]) - self._faces_per_filename[item.filename] += 1 # Track this added face + self._tracker.faces_per_filename[item.filename] += 1 # Track this added face logger.debug("Obtained detected face: (filename: %s, detected_face: %s)", item.filename, item.detected_faces) @@ -162,6 +162,7 @@ def get_batch(self, queue: Queue) -> tuple[bool, RecogBatch]: batch, :class:`~plugins.extract._base.ExtractorBatch` The batch object for the current batch """ + # pylint:disable=duplicate-code exhausted = False batch = RecogBatch() idx = 0 @@ -198,21 +199,22 @@ def get_batch(self, queue: Queue) -> tuple[bool, RecogBatch]: if idx == self.batchsize: frame_faces = len(item.detected_faces) if f_idx + 1 != frame_faces: - self._rollover = ExtractMedia( + self._tracker.rollover = ExtractMedia( item.filename, item.image, detected_faces=item.detected_faces[f_idx + 1:], is_aligned=item.is_aligned) - logger.trace("Rolled over %s faces of %s to next batch " # type:ignore - "for '%s'", len(self._rollover.detected_faces), frame_faces, + logger.trace("Rolled over %s faces of %s to " # type:ignore[attr-defined] + "next batch for '%s'", + len(self._tracker.rollover.detected_faces), frame_faces, item.filename) break if batch: - logger.trace("Returning batch: %s", # type:ignore + logger.trace("Returning batch: %s", # type:ignore[attr-defined] {k: len(v) if isinstance(v, (list, np.ndarray)) else v for k, v in batch.__dict__.items()}) else: - logger.trace(item) # type:ignore + logger.trace(item) # type:ignore[attr-defined] # TODO Move to end of process not beginning if exhausted: @@ -222,12 +224,12 @@ def get_batch(self, queue: Queue) -> tuple[bool, RecogBatch]: def _predict(self, batch: BatchType) -> RecogBatch: """ Just return the recognition's predict function """ + # pylint:disable=duplicate-code assert isinstance(batch, RecogBatch) + # slightly hacky workaround to deal with landmarks based masks: try: - # slightly hacky workaround to deal with landmarks based masks: batch.prediction = self.predict(batch.feed) - return batch - except tf_errors.ResourceExhaustedError as err: + except OutOfMemoryError as err: msg = ("You do not have enough GPU memory available to run recognition at the " "selected batch size. You can try a number of things:" "\n1) Close any other application that is using your GPU (web browsers are " @@ -238,6 +240,8 @@ def _predict(self, batch: BatchType) -> RecogBatch: "\n3) Enable 'Single Process' mode.") raise FaceswapError(msg) from err + return batch + def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: """ Finalize the output from Masker @@ -267,16 +271,17 @@ def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: for key, val in batch.__dict__.items()}) for filename, face in zip(batch.filename, batch.detected_faces): - self._output_faces.append(face) - if len(self._output_faces) != self._faces_per_filename[filename]: + self._tracker.output_faces.append(face) + if len(self._tracker.output_faces) != self._tracker.faces_per_filename[filename]: continue output = self._extract_media.pop(filename) - self._output_faces = self._filter(self._output_faces, output.sub_folders) + self._tracker.output_faces = self._filter(self._tracker.output_faces, + output.sub_folders) - output.add_detected_faces(self._output_faces) - self._output_faces = [] - logger.trace("Yielding: (filename: '%s', image: %s, " # type:ignore + output.add_detected_faces(self._tracker.output_faces) + self._tracker.output_faces = [] + logger.trace("Yielding: (filename: '%s', image: %s, " # type:ignore[attr-defined] "detected_faces: %s)", output.filename, output.image_shape, len(output.detected_faces)) yield output diff --git a/plugins/extract/recognition/vgg_face2.py b/plugins/extract/recognition/vgg_face2.py index acf268bfd4..76776fc702 100644 --- a/plugins/extract/recognition/vgg_face2.py +++ b/plugins/extract/recognition/vgg_face2.py @@ -8,13 +8,19 @@ import numpy as np import psutil from fastcluster import linkage, linkage_vector - -from lib.model.layers import L2_normalize -from lib.model.session import KSession -from lib.utils import FaceswapError +from keras.layers import (Activation, add, AveragePooling2D, BatchNormalization, Conv2D, Dense, + Flatten, Input, MaxPooling2D) +from keras.models import Model +from keras.regularizers import L2 + +from lib.logger import parse_class_init +from lib.model.layers import L2Normalize +from lib.utils import get_module_objects, FaceswapError from ._base import BatchType, RecogBatch, Identity +from . import vgg_face2_defaults as cfg if T.TYPE_CHECKING: + from keras import KerasTensor from collections.abc import Generator logger = logging.getLogger(__name__) @@ -37,20 +43,19 @@ class Recognition(Identity): https://creativecommons.org/licenses/by-nc/4.0/ """ - def __init__(self, *args, **kwargs) -> None: # pylint:disable=unused-argument + def __init__(self, **kwargs) -> None: logger.debug("Initializing %s", self.__class__.__name__) git_model_id = 10 model_filename = "vggface2_resnet50_v2.h5" super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.model: KSession + self.model: Model self.name: str = "VGGFace2" self.input_size = 224 self.color_format = "BGR" - self.vram = 2468 if not self.config["cpu"] else 0 - self.vram_warnings = 192 if not self.config["cpu"] else 0 - self.vram_per_batch = 32 if not self.config["cpu"] else 0 - self.batchsize = self.config["batch-size"] + self.vram = 384 if not cfg.cpu() else 0 # 334 in testing + self.vram_per_batch = 192 if not cfg.cpu() else 0 # ~155 in testing + self.batchsize = cfg.batch_size() # Average image provided in https://github.com/ox-vgg/vgg_face2 self._average_img = np.array([91.4953, 103.8827, 131.0912]) @@ -60,14 +65,12 @@ def __init__(self, *args, **kwargs) -> None: # pylint:disable=unused-argument def init_model(self) -> None: """ Initialize VGG Face 2 Model. """ assert isinstance(self.model_path, str) - model_kwargs = {"custom_objects": {"L2_normalize": L2_normalize}} - self.model = KSession(self.name, - self.model_path, - model_kwargs=model_kwargs, - allow_growth=self.config["allow_growth"], - exclude_gpus=self._exclude_gpus, - cpu_mode=self.config["cpu"]) - self.model.load_model() + placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), + dtype="float32") + + with self.get_device_context(cfg.cpu()): + self.model = VGGFace2(self.input_size, self.model_path, self.batchsize) + self.model(placeholder) def process_input(self, batch: BatchType) -> None: """ Compile the detected faces for prediction """ @@ -75,7 +78,7 @@ def process_input(self, batch: BatchType) -> None: batch.feed = np.array([T.cast(np.ndarray, feed.face)[..., :3] for feed in batch.feed_faces], dtype="float32") - self._average_img - logger.trace("feed shape: %s", batch.feed.shape) # type:ignore + logger.trace("feed shape: %s", batch.feed.shape) # type:ignore[attr-defined] def predict(self, feed: np.ndarray) -> np.ndarray: """ Return encodings for given image from vgg_face2. @@ -90,7 +93,8 @@ def predict(self, feed: np.ndarray) -> np.ndarray: numpy.ndarray The encodings for the face """ - retval = self.model.predict(feed) + with self.get_device_context(cfg.cpu()): + retval = self.model(feed) assert isinstance(retval, np.ndarray) return retval @@ -99,7 +103,283 @@ def process_output(self, batch: BatchType) -> None: return -class Cluster(): # pylint:disable=too-few-public-methods +class ResNet50: + """ ResNet50 imported for VGG-Face2 adapted from + https://github.com/WeidiXie/Keras-VGGFace2-ResNet50 + + Parameters + ---------- + input_shape, Tuple[int, int, int] | None, optional + The input shape for the model. Default: ``None`` + use_truncated: bool, optional + ``True`` to use a truncated version of resnet. Default ``False`` + weight_decay: float + L2 Regularizer weight decay. Default: 1e-4 + trainable: bool, optional + ``True`` if the block should be trainable. Default: ``True`` + """ + def __init__(self, + input_shape: tuple[int, int, int] | None = None, + use_truncated: bool = False, + weight_decay: float = 1e-4, + trainable: bool = True) -> None: + logger.debug("Initializing %s: input_shape: %s, use_truncated: %s, weight_decay: %s, " + "trainable: %s", self.__class__.__name__, input_shape, use_truncated, + weight_decay, trainable) + + self._input_shape = (None, None, 3) if input_shape is None else input_shape + self._weight_decay = weight_decay + self._trainable = trainable + + self._kernel_initializer = "orthogonal" + self._use_bias = False + self._bn_axis = 3 + self._block_suffix = {0: "_reduce", 1: "", 2: "_increase"} + + self._identity_calls = [2, 3, 5, 2] + self._filters = [(64, 64, 256), (128, 128, 512), (256, 256, 1024), (512, 512, 2048)] + if use_truncated: + self._identity_calls = self._identity_calls[:-1] + self._filters = self._filters[:-1] + + logger.debug("Initialized %s", self.__class__.__name__) + + def _identity_block(self, + inputs: KerasTensor, + kernel_size: int, + filters: tuple[int, int, int], + stage: int, + block: int) -> KerasTensor: + """ The identity block is the block that has no conv layer at shortcut. + + Parameters + ---------- + inputs: :class:`keras.KerasTensor` + Input tensor + kernel_size: int + The kernel size of middle conv layer of the block + filters: tuple[int, int, int[ + The filterss of 3 conv layers in the main path + stage: int + The current stage label, used for generating layer names + block: int + The current block label, used for generating layer names + + Returns + ------- + :class:`keras.KerasTensor` + Output tensor for the block + """ + assert len(filters) == 3 + var_x = inputs + + for idx, filts in enumerate(filters): + k_size = kernel_size if idx == 1 else 1 + conv_name = f"conv{stage}_{block}_{k_size}x{k_size}{self._block_suffix[idx]}" + bn_name = f"{conv_name}_bn" + + var_x = Conv2D(filts, + k_size, + padding="same" if idx == 1 else "valid", + kernel_initializer=self._kernel_initializer, + use_bias=self._use_bias, + kernel_regularizer=L2(self._weight_decay), + trainable=self._trainable, + name=conv_name)(var_x) + var_x = BatchNormalization(axis=self._bn_axis, name=bn_name)(var_x) + if idx < 2: + var_x = Activation("relu")(var_x) + + var_x = add([var_x, inputs]) + var_x = Activation("relu")(var_x) + return var_x + + def _conv_block(self, + inputs: KerasTensor, + kernel_size: int, + filters: tuple[int, int, int], + stage: int, + block: int, + strides: tuple[int, int] = (2, 2)) -> KerasTensor: + """ A block that has a conv layer at shortcut. + + Parameters + ---------- + inputs: :class:`keras.KerasTensor` + Input tensor + kernel_size: int + The kernel size of middle conv layer of the block + filters: tuple[int, int, int[ + The filterss of 3 conv layers in the main path + stage: int + The current stage label, used for generating layer names + block: int + The current block label, used for generating layer names + strides: tuple[int, int], optional + The stride length for the first and last convolution. Default: (2, 2) + + Returns + ------- + :class:`keras.KerasTensor` + Output tensor for the block + + Notes + ----- + From stage 3, the first conv layer at main path is with `strides = (2,2)` and the shortcut + should have `strides = (2,2)` as well + """ + assert len(filters) == 3 + var_x = inputs + + for idx, filts in enumerate(filters): + k_size = kernel_size if idx == 1 else 1 + conv_name = f"conv{stage}_{block}_{k_size}x{k_size}{self._block_suffix[idx]}" + bn_name = f"{conv_name}_bn" + + var_x = Conv2D(filts, + k_size, + strides=strides if idx == 0 else (1, 1), + padding="same" if idx == 1 else "valid", + kernel_initializer=self._kernel_initializer, + use_bias=self._use_bias, + kernel_regularizer=L2(self._weight_decay), + trainable=self._trainable, + name=conv_name)(var_x) + var_x = BatchNormalization(axis=self._bn_axis, name=bn_name)(var_x) + if idx < 2: + var_x = Activation("relu")(var_x) + + conv_name = f"conv{stage}_{block}_1x1_proj" + bn_name = f"{conv_name}_bn" + + shortcut = Conv2D(filters[-1], + (1, 1), + strides=strides, + kernel_initializer=self._kernel_initializer, + use_bias=self._use_bias, + kernel_regularizer=L2(self._weight_decay), + trainable=self._trainable, + name=conv_name)(inputs) + shortcut = BatchNormalization(axis=self._bn_axis, name=bn_name)(shortcut) + + var_x = add([var_x, shortcut]) + var_x = Activation("relu")(var_x) + return var_x + + def __call__(self, inputs: KerasTensor) -> KerasTensor: + """ Call the resnet50 Network + + Parameters + ---------- + inputs: :class:`keras.KerasTensor` + Input tensor + + Returns + ------- + :class::class:`keras.KerasTensor` + Output tensor from resnet50 + """ + var_x = Conv2D(64, + (7, 7), + strides=(2, 2), + padding="same", + use_bias=self._use_bias, + kernel_initializer=self._kernel_initializer, + kernel_regularizer=L2(self._weight_decay), + trainable=self._trainable, + name="conv1_7x7_s2")(inputs) + + var_x = BatchNormalization(axis=self._bn_axis, name="conv1_7x7_s2_bn")(var_x) + var_x = Activation("relu")(var_x) + var_x = MaxPooling2D((3, 3), strides=(2, 2))(var_x) + + for idx, (recursuions, filters) in enumerate(zip(self._identity_calls, self._filters)): + stage = idx + 2 + strides = (1, 1) if stage == 2 else (2, 2) + var_x = self._conv_block(var_x, 3, filters, stage=stage, block=1, strides=strides) + + for recursion in range(recursuions): + block = recursion + 2 + var_x = self._identity_block(var_x, 3, filters, stage=stage, block=block) + + return var_x + + +class VGGFace2(): + """ VGG-Face 2 model with resnet 50 backbone. Adapted from + https://github.com/WeidiXie/Keras-VGGFace2-ResNet50 + + Parameters + ---------- + input_size, int + The input size for the model. + weights_path: str + The path to the keras weights file + batch_size: int + The batch size to feed the model + num_class: int, optional + Number of classes to train the model on + weight_decay: float + L2 Regularizer weight decay. Default: 1e-4 + """ + def __init__(self, + input_size: int, + weights_path: str, + batch_size: int, + num_classes: int = 8631, + weight_decay: float = 1e-4) -> None: + logger.debug(parse_class_init(locals())) + self._input_shape = (input_size, input_size, 3) + self._batch_size = batch_size + self._weight_decay = weight_decay + self._num_classes = num_classes + self._resnet = ResNet50(input_shape=self._input_shape, weight_decay=self._weight_decay) + self._model = self._load_model(weights_path) + logger.debug("Initialized %s", self.__class__.__name__) + + def _load_model(self, weights_path: str) -> Model: + """ load the vgg-face2 model + + Parameters + ---------- + weights_path: str + Full path to the model's weights + + Returns + ------- + :class:`keras.models.Model` + The VGG-Obstructed model + """ + inputs = Input(self._input_shape) + var_x = self._resnet(inputs) + + var_x = AveragePooling2D((7, 7), name="avg_pool")(var_x) + var_x = Flatten()(var_x) + var_x = Dense(512, activation="relu", name="dim_proj")(var_x) + var_x = L2Normalize(axis=1)(var_x) + + retval = Model(inputs, var_x) + retval.load_weights(weights_path) + retval.make_predict_function() + return retval + + def __call__(self, inputs: np.ndarray) -> np.ndarray: + """ Get output from the vgg-face2 model + + Parameters + ---------- + inputs: :class:`numpy.ndarray` + The input to vgg-face2 + + Returns + ------- + :class:`numpy.ndarray` + The output from vgg-face2 + """ + return self._model.predict(inputs, verbose=0, batch_size=self._batch_size) + + +class Cluster(): """ Cluster the outputs from a VGG-Face 2 Model Parameters @@ -171,7 +451,7 @@ def _use_vector_linkage(self, dims: int) -> bool: int(free_ram), int(linkage_required), int(vector_required)) if linkage_required < free_ram: - logger.verbose("Using linkage method") # type:ignore + logger.verbose("Using linkage method") # type:ignore[attr-defined] retval = False elif vector_required < free_ram: logger.warning("Not enough RAM to perform linkage clustering. Using vector " @@ -313,3 +593,6 @@ def __call__(self) -> list[tuple[int, int]]: self._num_predictions, self._num_predictions + self._num_predictions - 2) return result_order + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/recognition/vgg_face2_defaults.py b/plugins/extract/recognition/vgg_face2_defaults.py index 67c92783a7..6d32466b0f 100644 --- a/plugins/extract/recognition/vgg_face2_defaults.py +++ b/plugins/extract/recognition/vgg_face2_defaults.py @@ -1,75 +1,55 @@ #!/usr/bin/env python3 -""" - The default options for the faceswap VGG Face2 recognition plugin. +""" The default options for the faceswap VGG Face2 recognition plugin. + + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) - should always be lower text. - dictionary requirements are listed below. +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid data types are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - group: [optional]. A group for grouping options together in the GUI. If not - provided this will not group this option with any others. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and data types this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and data types this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ +from lib.config import ConfigItem -_HELPTEXT = ( +HELPTEXT = ( "VGG Face 2 identity recognition.\n" "A Keras port of the model trained for VGGFace2: A dataset for recognising faces across pose " "and age. (https://arxiv.org/abs/1710.08092)" ) -_DEFAULTS = { - "batch-size": { - "default": 16, - "info": "The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered.", - "datatype": int, - "rounding": 1, - "min_max": (1, 64), - "choices": [], - "group": "settings", - "gui_radio": False, - "fixed": True - }, - "cpu": { - "default": False, - "info": "VGG Face2 still runs fairly quickly on CPU on some setups. Enable " - "CPU mode here to use the CPU for this plugin to save some VRAM at a speed cost.", - "datatype": bool, - "group": "settings" - }, -} +batch_size = ConfigItem( + datatype=int, + default=16, + group="settings", + info="The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.\n" + "\n\tNvidia users: If the batchsize is set higher than the your GPU can " + "accomodate then this will automatically be lowered.", + rounding=1, + min_max=(1, 64)) + +cpu = ConfigItem( + datatype=bool, + default=False, + group="settings", + info="VGG Face2 still runs fairly quickly on CPU on some setups. Enable " + "CPU mode here to use the CPU for this plugin to save some VRAM at a speed cost.") diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 7d47c20680..02c3fb36ca 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -7,6 +7,8 @@ from importlib import import_module +from lib.utils import get_module_objects + if T.TYPE_CHECKING: from collections.abc import Callable from plugins.extract.detect._base import Detector @@ -296,3 +298,6 @@ def get_available_convert_plugins(convert_category: str, add_none: bool = True) if add_none: converters.insert(0, "none") return converters + + +__all__ = get_module_objects(__name__) diff --git a/plugins/train/_config.py b/plugins/train/_config.py deleted file mode 100644 index 1a354d928c..0000000000 --- a/plugins/train/_config.py +++ /dev/null @@ -1,684 +0,0 @@ -#!/usr/bin/env python3 -""" Default configurations for models """ - -import gettext -import logging -import os - -from lib.config import FaceswapConfig -from plugins.plugin_loader import PluginLoader - -# LOCALES -_LANG = gettext.translation("plugins.train._config", localedir="locales", fallback=True) -_ = _LANG.gettext - -logger = logging.getLogger(__name__) - -ADDITIONAL_INFO = _("\nNB: Unless specifically stated, values changed here will only take effect " - "when creating a new model.") - -_LOSS_HELP = { - "ffl": _( - "Focal Frequency Loss. Analyzes the frequency spectrum of the images rather than the " - "images themselves. This loss function can be used on its own, but the original paper " - "found increased benefits when using it as a complementary loss to another spacial loss " - "function (e.g. MSE). Ref: Focal Frequency Loss for Image Reconstruction and Synthesis " - "https://arxiv.org/pdf/2012.12821.pdf NB: This loss does not currently work on AMD " - "cards."), - "flip": _( - "Nvidia FLIP. A perceptual loss measure that approximates the difference perceived by " - "humans as they alternate quickly (or flip) between two images. Used on its own and this " - "loss function creates a distinct grid on the output. However it can be helpful when " - "used as a complimentary loss function. Ref: FLIP: A Difference Evaluator for " - "Alternating Images: " - "https://research.nvidia.com/sites/default/files/node/3260/FLIP_Paper.pdf"), - "gmsd": _( - "Gradient Magnitude Similarity Deviation seeks to match the global standard deviation of " - "the pixel to pixel differences between two images. Similar in approach to SSIM. Ref: " - "Gradient Magnitude Similarity Deviation: An Highly Efficient Perceptual Image Quality " - "Index https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf"), - "l_inf_norm": _( - "The L_inf norm will reduce the largest individual pixel error in an image. As " - "each largest error is minimized sequentially, the overall error is improved. This loss " - "will be extremely focused on outliers."), - "laploss": _( - "Laplacian Pyramid Loss. Attempts to improve results by focussing on edges using " - "Laplacian Pyramids. As this loss function gives priority to edges over other low-" - "frequency information, like color, it should not be used on its own. The original " - "implementation uses this loss as a complimentary function to MSE. " - "Ref: Optimizing the Latent Space of Generative Networks " - "https://arxiv.org/abs/1707.05776"), - "lpips_alex": _( - "LPIPS is a perceptual loss that uses the feature outputs of other pretrained models as a " - "loss metric. Be aware that this loss function will use more VRAM. Used on its own and " - "this loss will create a distinct moire pattern on the output, however it can be helpful " - "as a complimentary loss function. The output of this function is strong, so depending " - "on your chosen primary loss function, you are unlikely going to want to set the weight " - "above about 25%. Ref: The Unreasonable Effectiveness of Deep Features as a Perceptual " - "Metric http://arxiv.org/abs/1801.03924\nThis variant uses the AlexNet backbone. A fairly " - "light and old model which performed best in the paper's original implementation.\nNB: " - "For AMD Users the final linear layer is not implemented."), - "lpips_squeeze": _( - "Same as lpips_alex, but using the SqueezeNet backbone. A more lightweight " - "version of AlexNet.\nNB: For AMD Users the final linear layer is not implemented."), - "lpips_vgg16": _( - "Same as lpips_alex, but using the VGG16 backbone. A more heavyweight model.\n" - "NB: For AMD Users the final linear layer is not implemented."), - "logcosh": _( - "log(cosh(x)) acts similar to MSE for small errors and to MAE for large errors. Like " - "MSE, it is very stable and prevents overshoots when errors are near zero. Like MAE, it " - "is robust to outliers."), - "mae": _( - "Mean absolute error will guide reconstructions of each pixel towards its median value in " - "the training dataset. Robust to outliers but as a median, it can potentially ignore some " - "infrequent image types in the dataset."), - "mse": _( - "Mean squared error will guide reconstructions of each pixel towards its average value in " - "the training dataset. As an avg, it will be susceptible to outliers and typically " - "produces slightly blurrier results. Ref: Multi-Scale Structural Similarity for Image " - "Quality Assessment https://www.cns.nyu.edu/pub/eero/wang03b.pdf"), - "ms_ssim": _( - "Multiscale Structural Similarity Index Metric is similar to SSIM except that it " - "performs the calculations along multiple scales of the input image."), - "smooth_loss": _( - "Smooth_L1 is a modification of the MAE loss to correct two of its disadvantages. " - "This loss has improved stability and guidance for small errors. Ref: A General and " - "Adaptive Robust Loss Function https://arxiv.org/pdf/1701.03077.pdf"), - "ssim": _( - "Structural Similarity Index Metric is a perception-based loss that considers changes in " - "texture, luminance, contrast, and local spatial statistics of an image. Potentially " - "delivers more realistic looking images. Ref: Image Quality Assessment: From Error " - "Visibility to Structural Similarity http://www.cns.nyu.edu/pub/eero/wang03-reprint.pdf"), - "pixel_gradient_diff": _( - "Instead of minimizing the difference between the absolute value of each " - "pixel in two reference images, compute the pixel to pixel spatial difference in each " - "image and then minimize that difference between two images. Allows for large color " - "shifts, but maintains the structure of the image."), - "none": _("Do not use an additional loss function.")} - -_NON_PRIMARY_LOSS = ["flip", "lpips_alex", "lpips_squeeze", "lpips_vgg16", "none"] - - -class Config(FaceswapConfig): - """ Config File for Models """ - # pylint:disable=too-many-statements - def set_defaults(self) -> None: - """ Set the default values for config """ - logger.debug("Setting defaults") - self._set_globals() - self._set_loss() - self._defaults_from_plugin(os.path.dirname(__file__)) - - def _set_globals(self) -> None: - """ Set the global options for training """ - logger.debug("Setting global config") - section = "global" - self.add_section(section, - _("Options that apply to all models") + ADDITIONAL_INFO) - self.add_item( - section=section, - title="centering", - datatype=str, - gui_radio=True, - default="face", - choices=["face", "head", "legacy"], - fixed=True, - group=_("face"), - info=_( - "How to center the training image. The extracted images are centered on the " - "middle of the skull based on the face's estimated pose. A subsection of these " - "images are used for training. The centering used dictates how this subsection " - "will be cropped from the aligned images." - "\n\tface: Centers the training image on the center of the face, adjusting for " - "pitch and yaw." - "\n\thead: Centers the training image on the center of the head, adjusting for " - "pitch and yaw. NB: You should only select head centering if you intend to " - "include the full head (including hair) in the final swap. This may give mixed " - "results. Additionally, it is only worth choosing head centering if you are " - "training with a mask that includes the hair (e.g. BiSeNet-FP-Head)." - "\n\tlegacy: The 'original' extraction technique. Centers the training image " - "near the tip of the nose with no adjustment. Can result in the edges of the " - "face appearing outside of the training area.")) - self.add_item( - section=section, - title="coverage", - datatype=float, - default=87.5, - min_max=(62.5, 100.0), - rounding=2, - fixed=True, - group=_("face"), - info=_( - "How much of the extracted image to train on. A lower coverage will limit the " - "model's scope to a zoomed-in central area while higher amounts can include the " - "entire face. A trade-off exists between lower amounts given more detail " - "versus higher amounts avoiding noticeable swap transitions. For 'Face' " - "centering you will want to leave this above 75%. For Head centering you will " - "most likely want to set this to 100%. Sensible values for 'Legacy' " - "centering are:" - "\n\t62.5% spans from eyebrow to eyebrow." - "\n\t75.0% spans from temple to temple." - "\n\t87.5% spans from ear to ear." - "\n\t100.0% is a mugshot.")) - self.add_item( - section=section, - title="icnr_init", - datatype=bool, - default=False, - group=_("initialization"), - info=_( - "Use ICNR to tile the default initializer in a repeating pattern. " - "This strategy is designed for pairing with sub-pixel / pixel shuffler " - "to reduce the 'checkerboard effect' in image reconstruction. " - "\n\t https://arxiv.org/ftp/arxiv/papers/1707/1707.02937.pdf")) - self.add_item( - section=section, - title="conv_aware_init", - datatype=bool, - default=False, - group=_("initialization"), - info=_( - "Use Convolution Aware Initialization for convolutional layers. " - "This can help eradicate the vanishing and exploding gradient problem " - "as well as lead to higher accuracy, lower loss and faster convergence.\nNB:" - "\n\t This can use more VRAM when creating a new model so you may want to " - "lower the batch size for the first run. The batch size can be raised " - "again when reloading the model. " - "\n\t Multi-GPU is not supported for this option, so you should start the model " - "on a single GPU. Once training has started, you can stop training, enable " - "multi-GPU and resume." - "\n\t Building the model will likely take several minutes as the calculations " - "for this initialization technique are expensive. This will only impact starting " - "a new model.")) - self.add_item( - section=section, - title="optimizer", - datatype=str, - gui_radio=True, - group=_("optimizer"), - default="adam", - choices=["adabelief", "adam", "nadam", "rms-prop"], - info=_( - "The optimizer to use." - "\n\t adabelief - Adapting Stepsizes by the Belief in Observed Gradients. An " - "optimizer with the aim to converge faster, generalize better and remain more " - "stable. (https://arxiv.org/abs/2010.07468). NB: Epsilon for AdaBelief needs to " - "be set to a smaller value than other Optimizers. Generally setting the 'Epsilon " - "Exponent' to around '-16' should work." - "\n\t adam - Adaptive Moment Optimization. A stochastic gradient descent method " - "that is based on adaptive estimation of first-order and second-order moments." - "\n\t nadam - Adaptive Moment Optimization with Nesterov Momentum. Much like " - "Adam but uses a different formula for calculating momentum." - "\n\t rms-prop - Root Mean Square Propagation. Maintains a moving (discounted) " - "average of the square of the gradients. Divides the gradient by the root of " - "this average.")) - self.add_item( - section=section, - title="learning_rate", - datatype=float, - default=5e-5, - min_max=(1e-6, 1e-4), - rounding=6, - fixed=False, - group=_("optimizer"), - info=_( - "Learning rate - how fast your network will learn (how large are the " - "modifications to the model weights after one batch of training). Values that " - "are too large might result in model crashes and the inability of the model to " - "find the best solution. Values that are too small might be unable to escape " - "from dead-ends and find the best global minimum.")) - self.add_item( - section=section, - title="epsilon_exponent", - datatype=int, - default=-7, - min_max=(-20, 0), - rounding=1, - fixed=False, - group=_("optimizer"), - info=_( - "The epsilon adds a small constant to weight updates to attempt to avoid 'divide " - "by zero' errors. Unless you are using the AdaBelief Optimizer, then Generally " - "this option should be left at default value, For AdaBelief, setting this to " - "around '-16' should work.\n" - "In all instances if you are getting 'NaN' loss values, and have been unable to " - "resolve the issue any other way (for example, increasing batch size, or " - "lowering learning rate), then raising the epsilon can lead to a more stable " - "model. It may, however, come at the cost of slower training and a less accurate " - "final result.\n" - "NB: The value given here is the 'exponent' to the epsilon. For example, " - "choosing '-7' will set the epsilon to 1e-7. Choosing '-3' will set the epsilon " - "to 0.001 (1e-3).")) - self.add_item( - section=section, - title="save_optimizer", - datatype=str, - group=_("optimizer"), - default="exit", - fixed=False, - gui_radio=True, - choices=["never", "always", "exit"], - info=_( - "When to save the Optimizer Weights. Saving the optimizer weights is not " - "necessary and will increase the model file size 3x (and by extension the amount " - "of time it takes to save the model). However, it can be useful to save these " - "weights if you want to guarantee that a resumed model carries off exactly from " - "where it left off, rather than spending a few hundred iterations catching up." - "\n\t never - Don't save optimizer weights." - "\n\t always - Save the optimizer weights at every save iteration. Model saving " - "will take longer, due to the increased file size, but you will always have the " - "last saved optimizer state in your model file." - "\n\t exit - Only save the optimizer weights when explicitly terminating a " - "model. This can be when the model is actively stopped or when the target " - "iterations are met. Note: If the training session ends because of another " - "reason (e.g. power outage, Out of Memory Error, NaN detected) then the " - "optimizer weights will NOT be saved.")) - - self.add_item( - section=section, - title="lr_finder_iterations", - datatype=int, - default=1000, - min_max=(100, 10000), - rounding=100, - fixed=True, - group=_("Learning Rate Finder"), - info=_( - "The number of iterations to process to find the optimal learning rate. Higher " - "values will take longer, but will be more accurate.")) - self.add_item( - section=section, - title="lr_finder_mode", - datatype=str, - default="set", - fixed=True, - gui_radio=True, - choices=["set", "graph_and_set", "graph_and_exit"], - group=_("Learning Rate Finder"), - info=_( - "The operation mode for the learning rate finder. Only applicable to new models. " - "For existing models this will always default to 'set'." - "\n\tset - Train with the discovered optimal learning rate." - "\n\tgraph_and_set - Output a graph in the training folder showing the discovered " - "learning rates and train with the optimal learning rate." - "\n\tgraph_and_exit - Output a graph in the training folder with the discovered " - "learning rates and exit.")) - self.add_item( - section=section, - title="lr_finder_strength", - datatype=str, - default="default", - fixed=True, - gui_radio=True, - choices=["default", "aggressive", "extreme"], - group=_("Learning Rate Finder"), - info=_( - "How aggressively to set the Learning Rate. More aggressive can learn faster, but " - "is more likely to lead to exploding gradients." - "\n\tdefault - The default optimal learning rate. A safe choice for nearly all " - "use cases." - "\n\taggressive - Set's a higher learning rate than the default. May learn faster " - "but with a higher chance of exploding gradients." - "\n\textreme - The highest optimal learning rate. A much higher risk of exploding " - "gradients.")) - self.add_item( - section=section, - title="autoclip", - datatype=bool, - default=False, - info=_( - "Apply AutoClipping to the gradients. AutoClip analyzes the " - "gradient weights and adjusts the normalization value dynamically to fit the " - "data. Can help prevent NaNs and improve model optimization at the expense of " - "VRAM. Ref: AutoClip: Adaptive Gradient Clipping for Source Separation Networks " - "https://arxiv.org/abs/2007.14469"), - fixed=False, - gui_radio=True, - group=_("optimizer")) - self.add_item( - section=section, - title="reflect_padding", - datatype=bool, - default=False, - group=_("network"), - info=_( - "Use reflection padding rather than zero padding with convolutions. " - "Each convolution must pad the image boundaries to maintain the proper " - "sizing. More complex padding schemes can reduce artifacts at the " - "border of the image." - "\n\t http://www-cs.engr.ccny.cuny.edu/~wolberg/cs470/hw/hw2_pad.txt")) - self.add_item( - section=section, - title="allow_growth", - datatype=bool, - default=False, - group=_("network"), - fixed=False, - info=_( - "Enable the Tensorflow GPU 'allow_growth' configuration option. " - "This option prevents Tensorflow from allocating all of the GPU VRAM at launch " - "but can lead to higher VRAM fragmentation and slower performance. Should only " - "be enabled if you are receiving errors regarding 'cuDNN fails to initialize' " - "when commencing training.")) - self.add_item( - section=section, - title="mixed_precision", - datatype=bool, - default=False, - fixed=False, - group=_("network"), - info=_( - "NVIDIA GPUs can run operations in float16 faster than in " - "float32. Mixed precision allows you to use a mix of float16 with float32, to " - "get the performance benefits from float16 and the numeric stability benefits " - "from float32.\n\nThis is untested on DirectML backend, but will run on most " - "Nvidia models. it will only speed up training on more recent GPUs. Those with " - "compute capability 7.0 or higher will see the greatest performance benefit from " - "mixed precision because they have Tensor Cores. Older GPUs offer no math " - "performance benefit for using mixed precision, however memory and bandwidth " - "savings can enable some speedups. Generally RTX GPUs and later will offer the " - "most benefit.")) - self.add_item( - section=section, - title="nan_protection", - datatype=bool, - default=True, - group=_("network"), - info=_( - "If a 'NaN' is generated in the model, this means that the model has corrupted " - "and the model is likely to start deteriorating from this point on. Enabling NaN " - "protection will stop training immediately in the event of a NaN. The last save " - "will not contain the NaN, so you may still be able to rescue your model."), - fixed=False) - self.add_item( - section=section, - title="convert_batchsize", - datatype=int, - default=16, - min_max=(1, 32), - rounding=1, - fixed=False, - group=_("convert"), - info=_( - "[GPU Only]. The number of faces to feed through the model at once when running " - "the Convert process.\n\nNB: Increasing this figure is unlikely to improve " - "convert speed, however, if you are getting Out of Memory errors, then you may " - "want to reduce the batch size.")) - - def _set_loss(self) -> None: - # pylint:disable=line-too-long - """ Set the default loss options. - - Loss Documentation - MAE https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine-learners-should-know-4fb140e9d4b0 - MSE https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine-learners-should-know-4fb140e9d4b0 - LogCosh https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine-learners-should-know-4fb140e9d4b0 - L_inf_norm https://medium.com/@montjoile/l0-norm-l1-norm-l2-norm-l-infinity-norm-7a7d18a4f40c - """ # noqa - # pylint:enable=line-too-long - logger.debug("Setting Loss config") - section = "global.loss" - self.add_section(section, - _("Loss configuration options\n" - "Loss is the mechanism by which a Neural Network judges how well it " - "thinks that it is recreating a face.") + ADDITIONAL_INFO) - self.add_item( - section=section, - title="loss_function", - datatype=str, - group=_("loss"), - default="ssim", - fixed=False, - choices=[x for x in sorted(_LOSS_HELP) if x not in _NON_PRIMARY_LOSS], - info=(_("The loss function to use.") + - "\n\n\t" + "\n\n\t".join(f"{k}: {v}" - for k, v in sorted(_LOSS_HELP.items()) - if k not in _NON_PRIMARY_LOSS))) - self.add_item( - section=section, - title="loss_function_2", - datatype=str, - group=_("loss"), - default="mse", - fixed=False, - choices=list(sorted(_LOSS_HELP)), - info=(_("The second loss function to use. If using a structural based loss (such as " - "SSIM, MS-SSIM or GMSD) it is common to add an L1 regularization(MAE) or L2 " - "regularization (MSE) function. You can adjust the weighting of this loss " - "function with the loss_weight_2 option.") + - "\n\n\t" + "\n\n\t".join(f"{k}: {v}" for k, v in sorted(_LOSS_HELP.items())))) - self.add_item( - section=section, - title="loss_weight_2", - datatype=int, - group=_("loss"), - min_max=(0, 400), - rounding=1, - default=100, - fixed=False, - info=_( - "The amount of weight to apply to the second loss function.\n\n" - "\n\nThe value given here is as a percentage denoting how much the selected " - "function should contribute to the overall loss cost of the model. For example:" - "\n\t 100 - The loss calculated for the second loss function will be applied at " - "its full amount towards the overall loss score. " - "\n\t 25 - The loss calculated for the second loss function will be reduced by a " - "quarter prior to adding to the overall loss score. " - "\n\t 400 - The loss calculated for the second loss function will be mulitplied " - "4 times prior to adding to the overall loss score. " - "\n\t 0 - Disables the second loss function altogether.")) - self.add_item( - section=section, - title="loss_function_3", - datatype=str, - group=_("loss"), - default="none", - fixed=False, - choices=list(sorted(_LOSS_HELP)), - info=(_("The third loss function to use. You can adjust the weighting of this loss " - "function with the loss_weight_3 option.") + - "\n\n\t" + - "\n\n\t".join(f"{k}: {v}" for k, v in sorted(_LOSS_HELP.items())))) - self.add_item( - section=section, - title="loss_weight_3", - datatype=int, - group=_("loss"), - min_max=(0, 400), - rounding=1, - default=0, - fixed=False, - info=_( - "The amount of weight to apply to the third loss function.\n\n" - "\n\nThe value given here is as a percentage denoting how much the selected " - "function should contribute to the overall loss cost of the model. For example:" - "\n\t 100 - The loss calculated for the third loss function will be applied at " - "its full amount towards the overall loss score. " - "\n\t 25 - The loss calculated for the third loss function will be reduced by a " - "quarter prior to adding to the overall loss score. " - "\n\t 400 - The loss calculated for the third loss function will be mulitplied 4 " - "times prior to adding to the overall loss score. " - "\n\t 0 - Disables the third loss function altogether.")) - self.add_item( - section=section, - title="loss_function_4", - datatype=str, - group=_("loss"), - default="none", - fixed=False, - choices=list(sorted(_LOSS_HELP)), - info=(_("The fourth loss function to use. You can adjust the weighting of this loss " - "function with the loss_weight_3 option.") + - "\n\n\t" + - "\n\n\t".join(f"{k}: {v}" for k, v in sorted(_LOSS_HELP.items())))) - self.add_item( - section=section, - title="loss_weight_4", - datatype=int, - group=_("loss"), - min_max=(0, 400), - rounding=1, - default=0, - fixed=False, - info=_( - "The amount of weight to apply to the fourth loss function.\n\n" - "\n\nThe value given here is as a percentage denoting how much the selected " - "function should contribute to the overall loss cost of the model. For example:" - "\n\t 100 - The loss calculated for the fourth loss function will be applied at " - "its full amount towards the overall loss score. " - "\n\t 25 - The loss calculated for the fourth loss function will be reduced by a " - "quarter prior to adding to the overall loss score. " - "\n\t 400 - The loss calculated for the fourth loss function will be mulitplied " - "4 times prior to adding to the overall loss score. " - "\n\t 0 - Disables the fourth loss function altogether.")) - self.add_item( - section=section, - title="mask_loss_function", - datatype=str, - group=_("loss"), - default="mse", - fixed=False, - choices=["mae", "mse"], - info=_( - "The loss function to use when learning a mask." - "\n\t MAE - Mean absolute error will guide reconstructions of each pixel " - "towards its median value in the training dataset. Robust to outliers but as " - "a median, it can potentially ignore some infrequent image types in the dataset." - "\n\t MSE - Mean squared error will guide reconstructions of each pixel " - "towards its average value in the training dataset. As an average, it will be " - "susceptible to outliers and typically produces slightly blurrier results.")) - self.add_item( - section=section, - title="eye_multiplier", - datatype=int, - group=_("loss"), - min_max=(1, 40), - rounding=1, - default=3, - fixed=False, - info=_( - "The amount of priority to give to the eyes.\n\nThe value given here is as a " - "multiplier of the main loss score. For example:" - "\n\t 1 - The eyes will receive the same priority as the rest of the face. " - "\n\t 10 - The eyes will be given a score 10 times higher than the rest of the " - "face." - "\n\nNB: Penalized Mask Loss must be enable to use this option.")) - self.add_item( - section=section, - title="mouth_multiplier", - datatype=int, - group=_("loss"), - min_max=(1, 40), - rounding=1, - default=2, - fixed=False, - info=_( - "The amount of priority to give to the mouth.\n\nThe value given here is as a " - "multiplier of the main loss score. For Example:" - "\n\t 1 - The mouth will receive the same priority as the rest of the face. " - "\n\t 10 - The mouth will be given a score 10 times higher than the rest of the " - "face." - "\n\nNB: Penalized Mask Loss must be enable to use this option.")) - self.add_item( - section=section, - title="penalized_mask_loss", - datatype=bool, - default=True, - group=_("loss"), - info=_( - "Image loss function is weighted by mask presence. For areas of " - "the image without the facial mask, reconstruction errors will be " - "ignored while the masked face area is prioritized. May increase " - "overall quality by focusing attention on the core face area.")) - self.add_item( - section=section, - title="mask_type", - datatype=str, - default="extended", - choices=PluginLoader.get_available_extractors("mask", - add_none=True, extend_plugin=True), - group=_("mask"), - gui_radio=True, - info=_( - "The mask to be used for training. If you have selected 'Learn Mask' or " - "'Penalized Mask Loss' you must select a value other than 'none'. The required " - "mask should have been selected as part of the Extract process. If it does not " - "exist in the alignments file then it will be generated prior to training " - "commencing." - "\n\tnone: Don't use a mask." - "\n\tbisenet-fp_face: Relatively lightweight NN based mask that provides more " - "refined control over the area to be masked (configurable in mask settings). " - "Use this version of bisenet-fp if your model is trained with 'face' or " - "'legacy' centering." - "\n\tbisenet-fp_head: Relatively lightweight NN based mask that provides more " - "refined control over the area to be masked (configurable in mask settings). " - "Use this version of bisenet-fp if your model is trained with 'head' centering." - "\n\tcomponents: 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." - "\n\tcustom_face: Custom user created, face centered mask." - "\n\tcustom_head: Custom user created, head centered mask." - "\n\textended: 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." - "\n\tvgg-clear: Mask designed to provide smart segmentation of mostly frontal " - "faces clear of obstructions. Profile faces and obstructions may result in " - "sub-par performance." - "\n\tvgg-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." - "\n\tunet-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.")) - self.add_item( - section=section, - title="mask_dilation", - datatype=float, - min_max=(-5.0, 5.0), - rounding=1, - default=0, - fixed=False, - group=_("mask"), - info=_( - "Dilate or erode the mask. Negative values erode the mask (make it smaller). " - "Positive values dilate the mask (make it larger). The value given is a " - "percentage of the total mask size.")) - self.add_item( - section=section, - title="mask_blur_kernel", - datatype=int, - min_max=(0, 9), - rounding=1, - default=3, - fixed=False, - group=_("mask"), - info=_( - "Apply gaussian blur to the mask input. This has the effect of smoothing the " - "edges of the mask, which can help with poorly calculated masks and give less " - "of a hard edge to the predicted mask. The size is in pixels (calculated from " - "a 128px mask). Set to 0 to not apply gaussian blur. This value should be odd, " - "if an even number is passed in then it will be rounded to the next odd number.")) - self.add_item( - section=section, - title="mask_threshold", - datatype=int, - default=4, - min_max=(0, 50), - rounding=1, - fixed=False, - group=_("mask"), - info=_( - "Sets pixels that are near white to white and near black to black. Set to 0 for " - "off.")) - self.add_item( - section=section, - title="learn_mask", - datatype=bool, - default=False, - group=_("mask"), - info=_( - "Dedicate a portion of the model to learning how to duplicate the input " - "mask. Increases VRAM usage in exchange for learning a quick ability to try " - "to replicate more complex mask models.")) diff --git a/plugins/train/model/_base/inference.py b/plugins/train/model/_base/inference.py new file mode 100644 index 0000000000..2ee4065a64 --- /dev/null +++ b/plugins/train/model/_base/inference.py @@ -0,0 +1,282 @@ +#! /usr/env/bin/python3 +""" Handles the recompilation of a Faceswap model into a version that can be used for inference """ +from __future__ import annotations +import logging +import typing as T + +import keras + +from lib.logger import parse_class_init +from lib.utils import get_module_objects + +if T.TYPE_CHECKING: + import keras.src.ops.node + +logger = logging.getLogger(__name__) + + +class Inference(): + """ Calculates required layers and compiles a saved model for inference. + + Parameters + ---------- + saved_model: :class:`keras.Model` + The saved trained Faceswap model + switch_sides: bool + ``True`` if the swap should be performed "B" > "A" ``False`` if the swap should be + "A" > "B" + """ + def __init__(self, saved_model: keras.Model, switch_sides: bool) -> None: + logger.debug(parse_class_init(locals())) + + self._layers: list[keras.Layer] = [lyr for lyr in saved_model.layers + if not isinstance(lyr, keras.layers.InputLayer)] + """list[:class:`keras.layers.Layer]: All the layers that exist within the model excluding + input layers """ + + self._input = self._get_model_input(saved_model, switch_sides) + """:class:`keras.KerasTensor`: The correct input for the inference model """ + + self._name = f"{saved_model.name}_inference" + """str: The name for the final inference model""" + + self._model = self._build() + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def model(self) -> keras.Model: + """ :class:`keras.Model`: The Faceswap model, compiled for inference. """ + return self._model + + def _get_model_input(self, model: keras.Model, switch_sides: bool) -> list[keras.KerasTensor]: + """ Obtain the inputs for the requested swap direction. + + Parameters + ---------- + saved_model: :class:`keras.Model` + The saved trained Faceswap model + switch_sides: bool + ``True`` if the swap should be performed "B" > "A" ``False`` if the swap should be + "A" > "B" + + Returns + ------- + list[]:class:`keras.KerasTensor`] + The input tensor to feed the model for the requested swap direction + """ + inputs: list[keras.KerasTensor] = model.input + assert len(inputs) == 2, "Faceswap models should have exactly 2 inputs" + idx = 0 if switch_sides else 1 + retval = inputs[idx] + logger.debug("model inputs: %s, idx: %s, inference_input: '%s'", + [(i.name, i.shape[1:]) for i in inputs], idx, retval.name) + return [retval] + + def _get_candidates(self, input_tensors: list[keras.KerasTensor | keras.Layer] + ) -> T.Generator[tuple[keras.Layer, list[keras.src.ops.node.KerasHistory]], + None, None]: + """ Given a list of input tensors, get all layers from the main model which have the given + input tensors marked as Inbound nodes for the model + + Parameters + ---------- + input_tensors: list[:class:`keras.KerasTensor` | :class:`keras.Layer`] + List of Tensors that act as an input to a layer within the model + + Yields + ------ + tuple[:class:`keras.KerasLayer`, list[:class:`keras.src.ops.node.KerasHistory'] + Any layer in the main model that use the given input tensors as an input along with the + corresponding keras inbound history + """ + unique_input_names = set(i.name for i in input_tensors) + for layer in self._layers: + + history = [tensor._keras_history # pylint:disable=protected-access + for node in layer._inbound_nodes # pylint:disable=protected-access + for parent in node.parent_nodes + for tensor in parent.outputs] + + unique_inbound_names = set(h.operation.name for h in history) + if not unique_input_names.issubset(unique_inbound_names): + logger.debug("%s: Skipping candidate '%s' unmatched inputs: %s", + unique_input_names, layer.name, unique_inbound_names) + continue + + logger.debug("%s: Yielding candidate '%s'. History: %s", + unique_input_names, layer.name, [(h.operation.name, h.node_index) + for h in history]) + yield layer, history + + @T.overload + def _group_inputs(self, layer: keras.Layer, inputs: list[tuple[keras.Layer, int]] + ) -> list[list[tuple[keras.Layer, int]]]: + ... + + @T.overload + def _group_inputs(self, layer: keras.Layer, inputs: list[keras.src.ops.node.KerasHistory] + ) -> list[list[keras.src.ops.node.KerasHistory]]: + ... + + def _group_inputs(self, layer, inputs): + """ Layers can have more than one input. In these instances we need to group the inputs + and the layers' inbound nodes to correspond to inputs per instance. + + Parameters + ---------- + layer: :class:`keras.Layer` + The current layer being processed + inputs: list[:class:`keras.KerasTensor`] | list[:class:`keras.src.ops.node.KerasHistory`] + List of input tensors or inbound keras histories to be grouped per layer input + + Returns + ------- + list[list[tuple[:class:`keras.Layer`, int]]] | + list[list[:class:`keras.src.ops.node.KerasHistory`] + A list of list of input layers and the corresponding node index or inbound keras + histories + """ + layer_inputs = 1 if isinstance(layer.input, keras.KerasTensor) else len(layer.input) + num_inputs = len(inputs) + + total_calls = num_inputs / layer_inputs + assert total_calls.is_integer() + total_calls = int(total_calls) + + retval = [inputs[i * layer_inputs: i * layer_inputs + layer_inputs] + for i in range(total_calls)] + + return retval + + def _layers_from_inputs(self, + input_tensors: list[keras.KerasTensor | keras.Layer], + node_indices: list[int] + ) -> tuple[list[keras.Layer], + list[keras.src.ops.node.KerasHistory], + list[int]]: + """ Given a list of input tensors and their corresponding inbound node ids, return all of + the layers for the model that uses the given nodes as their input + + Parameters + ---------- + input_tensors: list[:class:`keras.KerasTensor` | :class:`keras.Layer`] + List of Tensors that act as an input to a layer within the model + node_indices: list[int] + The list of node indices corresponding to the inbound node index of the given layers + + Returns + ------- + list[:class:`keras.layers.Layer`] + Any layers from the model that use the given inputs as its input. Empty list if there + are no matches + list[:class:`keras.src.ops.node.KerasHistory`] + The keras inbound history for the layers + list[int] + The output node index for the layer, used for the inbound node index of the next layer + """ + retval: tuple[list[keras.Layer], + list[keras.src.ops.node.KerasHistory], + list[int]] = ([], [], []) + for layer, history in self._get_candidates(input_tensors): + grp_inputs = self._group_inputs(layer, list(zip(input_tensors, node_indices))) + grp_hist = self._group_inputs(layer, history) + + for input_group in grp_inputs: # pylint:disable=not-an-iterable + have = [(i[0].name, i[1]) for i in input_group] + for out_idx, hist in enumerate(grp_hist): + requires = [(h.operation.name, h.node_index) for h in hist] + if sorted(have) != sorted(requires): + logger.debug("%s: Skipping '%s'. Requires %s. Output node index: %s", + have, layer.name, requires, out_idx) + continue + retval[0].append(layer) + retval[1].append(hist) + retval[2].append(out_idx) + + logger.debug("Got layers %s for input_tensors: %s", + [x.name for x in retval[0]], [t.name for t in input_tensors]) + return retval + + def _build_layers(self, + layers: list[keras.Layer], + history: list[keras.src.ops.node.KerasHistory], + inputs: list[keras.KerasTensor]) -> list[keras.KerasTensor]: + """ Compile the given layers with the given inputs + + Parameters + ---------- + layers: list[:class:`keras.Layer`] + The layers to be called with the given inputs + history: list[:class:`keras.src.ops.node.KerasHistory`] + The corresponding keras inbound history for the layers + inputs: list[:class:`keras.KerasTensor] + The inputs for the given layers + + Returns + ------- + list[:class:`keras.KerasTensor`] + The list of compiled layers + """ + retval = [] + given_order = [i._keras_history.operation.name # pylint:disable=protected-access + for i in inputs] + for layer, hist in zip(layers, history): + layer_input = [inputs[given_order.index(h.operation.name)] + for h in hist if h.operation.name in given_order] + if layer_input != inputs: + logger.debug("Sorted layer inputs %s to %s", + given_order, + [i._keras_history.operation.name # pylint:disable=protected-access + for i in layer_input]) + + if isinstance(layer_input, list) and len(layer_input) == 1: + # Flatten single inputs to stop Keras warnings + actual_input = layer_input[0] + else: + actual_input = layer_input + + built = layer(actual_input) + built = built if isinstance(built, list) else [built] + logger.debug( + "Compiled layer '%s' from input(s) %s", + layer.name, + [i._keras_history.operation.name # pylint:disable=protected-access + for i in layer_input]) + retval.extend(built) + + logger.debug( + "Compiled layers %s from input %s", + [x._keras_history.operation.name for x in retval], # pylint:disable=protected-access + [x._keras_history.operation.name for x in inputs]) # pylint:disable=protected-access + return retval + + def _build(self): + """ Extract the sub-models from the saved model that are required for inference. + + Returns + ------- + :class:`keras.Model` + The model compiled for inference + """ + logger.debug("Compiling inference model") + + layers = self._input + node_index = [0] + built = layers + + while True: + layers, history, node_index = self._layers_from_inputs(layers, node_index) + if not layers: + break + + built = self._build_layers(layers, history, built) + + assert len(self._input) == 1 + assert len(built) == 1 + retval = keras.Model(inputs=self._input[0], outputs=built[0], name=self._name) + logger.debug("Compiled inference model '%s': %s", retval.name, retval) + + return retval + + +__all__ = get_module_objects(__name__) diff --git a/plugins/train/model/_base/io.py b/plugins/train/model/_base/io.py index c52bf53094..a4635f2a7d 100644 --- a/plugins/train/model/_base/io.py +++ b/plugins/train/model/_base/io.py @@ -14,27 +14,29 @@ import os import sys import typing as T +from keras import layers, models as kmodels -import tensorflow as tf - +from lib.logger import parse_class_init from lib.model.backup_restore import Backup -from lib.utils import FaceswapError +from lib.utils import get_module_objects, FaceswapError + +from .update import Legacy, PatchKerasConfig if T.TYPE_CHECKING: from .model import ModelBase + from keras import Optimizer -kmodels = tf.keras.models logger = logging.getLogger(__name__) def get_all_sub_models( - model: tf.keras.models.Model, - models: list[tf.keras.models.Model] | None = None) -> list[tf.keras.models.Model]: + model: kmodels.Model, + models: list[kmodels.Model] | None = None) -> list[kmodels.Model]: """ For a given model, return all sub-models that occur (recursively) as children. Parameters ---------- - model: :class:`tensorflow.keras.models.Model` + model: :class:`keras.models.Model` A Keras model to scan for sub models models: `None` Do not provide this parameter. It is used for recursion @@ -42,7 +44,7 @@ def get_all_sub_models( Returns ------- list - A list of all :class:`tensorflow.keras.models.Model` objects found within the given model. + A list of all :class:`keras.models.Model` objects found within the given model. The provided model will always be returned in the first position """ if models is None: @@ -80,12 +82,16 @@ def __init__(self, model_dir: str, is_predict: bool, save_optimizer: T.Literal["never", "always", "exit"]) -> None: + logger.debug(parse_class_init(locals())) self._plugin = plugin self._is_predict = is_predict self._model_dir = model_dir self._save_optimizer = save_optimizer - self._history: list[list[float]] = [[], []] # Loss histories per save iteration + self._history: list[float] = [] + """list[float]: Loss history for current save iteration """ self._backup = Backup(self._model_dir, self._plugin.name) + self._update_legacy() + logger.debug("Initialized %s", self.__class__.__name__) @property def model_dir(self) -> str: @@ -95,7 +101,7 @@ def model_dir(self) -> str: @property def filename(self) -> str: """str: The filename for this model.""" - return os.path.join(self._model_dir, f"{self._plugin.name}.h5") + return os.path.join(self._model_dir, f"{self._plugin.name}.keras") @property def model_exists(self) -> bool: @@ -105,8 +111,8 @@ def model_exists(self) -> bool: return os.path.isfile(self.filename) @property - def history(self) -> list[list[float]]: - """ list: list of loss histories per side for the current save iteration. """ + def history(self) -> list[float]: + """ list[float]: list of loss history for the current save iteration. """ return self._history @property @@ -114,9 +120,9 @@ def multiple_models_in_folder(self) -> list[str] | None: """ :list: or ``None`` If there are multiple model types in the requested folder, or model types that don't correspond to the requested plugin type, then returns the list of plugin names that exist in the folder, otherwise returns ``None`` """ - plugins = [fname.replace(".h5", "") + plugins = [fname.replace(".keras", "") for fname in os.listdir(self._model_dir) - if fname.endswith(".h5")] + if fname.endswith(".keras")] test_names = plugins + [self._plugin.name] test = False if not test_names else os.path.commonprefix(test_names) == "" retval = None if not test else plugins @@ -124,7 +130,24 @@ def multiple_models_in_folder(self) -> list[str] | None: self._plugin.name, plugins, test, retval) return retval - def load(self) -> tf.keras.models.Model: + def _update_legacy(self) -> None: + """ Look for faceswap 2.x .h5 files in the model folder. If exists, then update to Faceswap + 3 .keras file and backup the original model .h5 file + + Note: Currently disabled as keras hangs trying to load old faceswap models + """ + if self.model_exists: + logger.debug("Existing model file is current: '%s'", os.path.basename(self.filename)) + return + + old_fname = f"{os.path.splitext(self.filename)[0]}.h5" + if not os.path.isfile(old_fname): + logger.debug("No legacy model file to update") + return + + Legacy(old_fname) + + def load(self) -> kmodels.Model: """ Loads the model from disk If the predict function is to be called and the model cannot be found in the model folder @@ -135,7 +158,7 @@ def load(self) -> tf.keras.models.Model: Returns ------- - :class:`tensorflow.keras.models.Model` + :class:`keras.models.Model` The saved model loaded from disk """ logger.debug("Loading model: %s", self.filename) @@ -162,111 +185,166 @@ def load(self) -> tf.keras.models.Model: "should use the Restore Tool to restore your model from backup.\n" f"Original error: {str(err)}") raise FaceswapError(msg) from err + if 'parameter name can\\\'t contain "."' in str(err).lower(): + PatchKerasConfig(self.filename)() + return self.load() + raise err + except TypeError as err: + if any(x in str(err) for x in ("Could not locate class 'Conv2D'", + "Could not locate class 'DepthwiseConv2D'")): + PatchKerasConfig(self.filename)() + return self.load() raise err logger.info("Loaded model from disk: '%s'", self.filename) - return model + return model # pyright:ignore[reportReturnType] - def save(self, - is_exit: bool = False, - force_save_optimizer: bool = False) -> None: - """ Backup and save the model and state file. + def _remove_optimizer(self) -> Optimizer: + """ Keras 3 `.keras` format ignores the `save_optimizer` kwarg. To hack around this we + remove the optimizer from the model prior to saving and then re-attach it to the model + + Returns + ------- + :class:`keras.optimizers.Optimizer` | None + The optimizer for the model, if it should not be saved. ``None`` if it should be saved + """ + retval = self._plugin.model.optimizer + del self._plugin.model.optimizer + logger.debug("Removed optimizer for saving: %s", retval) + return retval + + def _save_model(self, is_exit: bool, force_save_optimizer: bool) -> None: + """ Save the model either with or without the optimizer weights + + Keras 3 ignores 'save_optimizer` so if it should not be saved, we remove it from + the model for saving, then re-attach it Parameters ---------- - is_exit: bool, optional + is_exit: bool ``True`` if the save request has come from an exit process request otherwise ``False``. - Default: ``False`` - force_save_optimizer: bool, optional + force_save_optimizer: bool ``True`` to force saving the optimizer weights with the model, otherwise ``False``. - Default:``False`` - - Notes - ----- - The backup function actually backups the model from the previous save iteration rather than - the current save iteration. This is not a bug, but protection against long save times, as - models can get quite large, so renaming the current model file rather than copying it can - save substantial amount of time. """ - logger.debug("Backing up and saving models") - print("") # Insert a new line to avoid spamming the same row as loss output - save_averages = self._get_save_averages() - if save_averages and self._should_backup(save_averages): - self._backup.backup_model(self.filename) - self._backup.backup_model(self._plugin.state.filename) - include_optimizer = (force_save_optimizer or self._save_optimizer == "always" or (self._save_optimizer == "exit" and is_exit)) - try: - self._plugin.model.save(self.filename, include_optimizer=include_optimizer) - except ValueError as err: - if include_optimizer and "name already exists" in str(err): - logger.warning("Due to a bug in older versions of Tensorflow, optimizer state " - "cannot be saved for this model.") - self._plugin.model.save(self.filename, include_optimizer=False) - else: - raise + optimizer = None + if not include_optimizer: + optimizer = self._remove_optimizer() + self._plugin.model.save(self.filename) self._plugin.state.save() - msg = "[Saved optimizer state for Snapshot]" if force_save_optimizer else "[Saved model]" - if save_averages: - lossmsg = [f"face_{side}: {avg:.5f}" - for side, avg in zip(("a", "b"), save_averages)] - msg += f" - Average loss since last save: {', '.join(lossmsg)}" - logger.info(msg) + if not include_optimizer: + assert optimizer is not None + logger.debug("Re-attaching optimizer: %s", optimizer) + setattr(self._plugin.model, "optimizer", optimizer) - def _get_save_averages(self) -> list[float]: - """ Return the average loss since the last save iteration and reset historical loss """ + def _get_save_average(self) -> float: + """ Return the average loss since the last save iteration and reset historical loss + + Returns + ------- + float + The average loss since the last save iteration + """ logger.debug("Getting save averages") - if not all(loss for loss in self._history): + if not self._history: logger.debug("No loss in history") - retval = [] + retval = 0.0 else: - retval = [sum(loss) / len(loss) for loss in self._history] - self._history = [[], []] # Reset historical loss - logger.debug("Average losses since last save: %s", retval) + retval = sum(self._history) / len(self._history) + self._history = [] # Reset historical loss + logger.debug("Average loss since last save: %s", round(retval, 5)) return retval - def _should_backup(self, save_averages: list[float]) -> bool: - """ Check whether the loss averages for this save iteration is the lowest that has been + def _should_backup(self, save_average: float) -> bool: + """ Check whether the loss average for this save iteration is the lowest that has been seen. - This protects against model corruption by only backing up the model if both sides have - seen a total fall in loss. + This protects against model corruption by only backing up the model if the sum of all loss + functions has fallen. Notes ----- This is by no means a perfect system. If the model corrupts at an iteration close to a save iteration, then the averages may still be pushed lower than a previous - save average, resulting in backing up a corrupted model. + save average, resulting in backing up a corrupted model. Changing loss weighting can also + arteficially impact this Parameters ---------- - save_averages: list - The average loss for each side for this save iteration + save_average: float + The average loss since the last save iteration """ - backup = True - for side, loss in zip(("a", "b"), save_averages): - if not self._plugin.state.lowest_avg_loss.get(side, None): - logger.debug("Set initial save iteration loss average for '%s': %s", side, loss) - self._plugin.state.lowest_avg_loss[side] = loss - continue - backup = loss < self._plugin.state.lowest_avg_loss[side] if backup else backup + if not self._plugin.state.lowest_avg_loss: + logger.debug("Set initial save iteration loss average: %s", save_average) + self._plugin.state.lowest_avg_loss = save_average + return False + + old_average = self._plugin.state.lowest_avg_loss + backup = save_average < old_average if backup: # Update lowest loss values to the state file - # pylint:disable=unnecessary-comprehension - old_avgs = {key: val for key, val in self._plugin.state.lowest_avg_loss.items()} - self._plugin.state.lowest_avg_loss["a"] = save_averages[0] - self._plugin.state.lowest_avg_loss["b"] = save_averages[1] - logger.debug("Updated lowest historical save iteration averages from: %s to: %s", - old_avgs, self._plugin.state.lowest_avg_loss) + self._plugin.state.lowest_avg_loss = save_average + logger.debug("Updated lowest historical save iteration average from: %s to: %s", + old_average, save_average) logger.debug("Should backup: %s", backup) return backup + def _maybe_backup(self) -> tuple[float, bool]: + """ Backup the model if total average loss has dropped for the save iteration + + Returns + ------- + float + The total loss average since the last save iteration + bool + ``True`` if the model was backed up + """ + save_average = self._get_save_average() + should_backup = self._should_backup(save_average) + if not save_average or not should_backup: + logger.debug("Not backing up model (save_average: %s, should_backup: %s)", + save_average, should_backup) + return save_average, False + + logger.debug("Backing up model") + self._backup.backup_model(self.filename) + self._backup.backup_model(self._plugin.state.filename) + return save_average, True + + def save(self, + is_exit: bool = False, + force_save_optimizer: bool = False) -> None: + """ Backup and save the model and state file. + + Parameters + ---------- + is_exit: bool, optional + ``True`` if the save request has come from an exit process request otherwise ``False``. + Default: ``False`` + force_save_optimizer: bool, optional + ``True`` to force saving the optimizer weights with the model, otherwise ``False``. + Default:``False`` + """ + logger.debug("Backing up and saving models") + print("\x1b[2K", end="\r") # Clear last line + logger.info("Saving Model...") + + self._save_model(is_exit, force_save_optimizer) + save_average, backed_up = self._maybe_backup() + + msg = "[Saved optimizer state for Snapshot]" if force_save_optimizer else "[Saved model]" + if save_average: + msg += f" - Average total loss since last save: {save_average:.5f}" + if backed_up: + msg += " [Model backed up]" + logger.info(msg) + def snapshot(self) -> None: """ Perform a model snapshot. @@ -295,15 +373,13 @@ def __init__(self, plugin: ModelBase) -> None: self._do_freeze = plugin._args.freeze_weights self._weights_file = self._check_weights_file(plugin._args.load_weights) - freeze_layers = plugin.config.get("freeze_layers") # Standardized config for freezing - load_layers = plugin.config.get("load_layers") # Standardized config for loading - self._freeze_layers = freeze_layers if freeze_layers else ["encoder"] # No plugin config - self._load_layers = load_layers if load_layers else ["encoder"] # No plugin config + self._freeze_layers = plugin.freeze_layers + self._load_layers = plugin.load_layers logger.debug("Initialized %s", self.__class__.__name__) @classmethod def _check_weights_file(cls, weights_file: str) -> str | None: - """ Validate that we have a valid path to a .h5 file. + """ Validate that we have a valid path to a .keras file. Parameters ---------- @@ -322,9 +398,9 @@ def _check_weights_file(cls, weights_file: str) -> str | None: msg = "" if not os.path.exists(weights_file): msg = f"Load weights selected, but the path '{weights_file}' does not exist." - elif not os.path.splitext(weights_file)[-1].lower() == ".h5": + elif not os.path.splitext(weights_file)[-1].lower() == ".keras": msg = (f"Load weights selected, but the path '{weights_file}' is not a valid Keras " - f"model (.h5) file.") + f"model (.keras) file.") if msg: msg += " Please check and try again." @@ -372,6 +448,8 @@ def load(self, model_exists: bool) -> None: weights_models = self._get_weights_model() all_models = get_all_sub_models(self._model) + loaded_ops = 0 + skipped_ops = 0 for model_name in self._load_layers: sub_model = next((lyr for lyr in all_models if lyr.name == model_name), None) @@ -404,13 +482,13 @@ def load(self, model_exists: bool) -> None: "different settings than you have set for your current model.", skipped_ops) - def _get_weights_model(self) -> list[tf.keras.models.Model]: + def _get_weights_model(self) -> list[kmodels.Model]: """ Obtain a list of all sub-models contained within the weights model. Returns ------- list - List of all models contained within the .h5 file + List of all models contained within the .keras file Raises ------ @@ -418,7 +496,9 @@ def _get_weights_model(self) -> list[tf.keras.models.Model]: In the event of a failure to load the weights, or the weights belonging to a different model """ - retval = get_all_sub_models(kmodels.load_model(self._weights_file, compile=False)) + retval = get_all_sub_models(kmodels.load_model( # pyright:ignore[reportArgumentType] + self._weights_file, + compile=False)) if not retval: raise FaceswapError(f"Error loading weights file {self._weights_file}.") @@ -428,14 +508,14 @@ def _get_weights_model(self) -> list[tf.keras.models.Model]: return retval def _load_layer_weights(self, - layer: tf.keras.layers.Layer, - sub_weights: tf.keras.layers.Layer, + layer: layers.Layer, + sub_weights: layers.Layer, model_name: str) -> T.Literal[-1, 0, 1]: """ Load the weights for a single layer. Parameters ---------- - layer: :class:`tensorflow.keras.layers.Layer` + layer: :class:`keras.layers.Layer` The layer to set the weights for sub_weights: list The list of layers in the weights model to load weights from @@ -468,3 +548,6 @@ def _load_layer_weights(self, logger.verbose("Setting weights for '%s'", layer.name) # type:ignore layer.set_weights(layer_weights.get_weights()) return 1 + + +__all__ = get_module_objects(__name__) diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 35d8b5f4a9..e8274a0548 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -8,35 +8,28 @@ import logging import os import sys -import time import typing as T -from collections import OrderedDict +import keras -import numpy as np -import tensorflow as tf - -from lib.serializer import get_serializer -from lib.model.nn_blocks import set_config as set_nnblock_config -from lib.utils import FaceswapError -from plugins.train._config import Config +from lib.logger import parse_class_init +from lib.utils import get_module_objects, FaceswapError +from plugins.train import train_config as cfg +from .inference import Inference from .io import IO, get_all_sub_models, Weights from .settings import Loss, Optimizer, Settings +from .state import State if T.TYPE_CHECKING: import argparse - from lib.config import ConfigValueType - -keras = tf.keras -K = tf.keras.backend + import numpy as np logger = logging.getLogger(__name__) -_CONFIG: dict[str, ConfigValueType] = {} -class ModelBase(): +class ModelBase(): # pylint:disable=too-many-instance-attributes """ Base class that all model plugins should inherit from. Parameters @@ -58,59 +51,50 @@ class ModelBase(): is the same for both sides of the model, then this can be a single 3 dimensional `tuple`. If the inputs have different sizes for `"A"` and `"B"` this should be a `list` of 2 3 dimensional shape `tuples`, 1 for each side respectively. - trainer: str - Currently there is only one trainer available (`"original"`), so at present this attribute - can be ignored. If/when more trainers are added, then this attribute should be overridden - with the trainer name that a model requires in the model plugin's - :func:`__init__` function. """ def __init__(self, model_dir: str, arguments: argparse.Namespace, predict: bool = False) -> None: - logger.debug("Initializing ModelBase (%s): (model_dir: '%s', arguments: %s, predict: %s)", - self.__class__.__name__, model_dir, arguments, predict) - + logger.debug(parse_class_init(locals())) # Input shape must be set within the plugin after initializing self.input_shape: tuple[int, ...] = () - self.trainer = "original" # Override for plugin specific trainer self.color_order: T.Literal["bgr", "rgb"] = "bgr" # Override for image color channel order self._args = arguments self._is_predict = predict - self._model: tf.keras.models.Model | None = None + self._model: keras.Model | None = None - self._configfile = arguments.configfile if hasattr(arguments, "configfile") else None - self._load_config() + cfg.load_config(config_file=arguments.configfile) - if self.config["penalized_mask_loss"] and self.config["mask_type"] is None: + if cfg.Loss.penalized_mask_loss() and cfg.Loss.mask_type() == "none": raise FaceswapError("Penalized Mask Loss has been selected but you have not chosen a " "Mask to use. Please select a mask or disable Penalized Mask " "Loss.") - if self.config["learn_mask"] and self.config["mask_type"] is None: + if cfg.Loss.learn_mask() and cfg.Loss.mask_type() == "none": raise FaceswapError("'Learn Mask' has been selected but you have not chosen a Mask to " "use. Please select a mask or disable 'Learn Mask'.") - self._mixed_precision = self.config["mixed_precision"] - self._io = IO(self, model_dir, self._is_predict, self.config["save_optimizer"]) + self._mixed_precision = cfg.mixed_precision() + self._io = IO(self, model_dir, + self._is_predict, + T.cast(T.Literal["never", "always", "exit"], cfg.Optimizer.save_optimizer())) self._check_multiple_models() self._state = State(model_dir, self.name, - self._config_changeable_items, False if self._is_predict else self._args.no_logs) self._settings = Settings(self._args, self._mixed_precision, - self.config["allow_growth"], self._is_predict) - self._loss = Loss(self.config, self.color_order) + self._loss = Loss(self.color_order) logger.debug("Initialized ModelBase (%s)", self.__class__.__name__) @property - def model(self) -> tf.keras.models.Model: - """:class:`Keras.models.Model`: The compiled model for this plugin. """ + def model(self) -> keras.Model: + """:class:`keras.Model`: The compiled model for this plugin. """ return self._model @property @@ -129,24 +113,13 @@ def coverage_ratio(self) -> float: To ensure consistent rounding and guaranteed even image size, the calculation for coverage should always be: :math:`(original_size * coverage_ratio // 2) * 2` """ - return self.config.get("coverage", 62.5) / 100 + return cfg.coverage() / 100. @property def io(self) -> IO: # pylint:disable=invalid-name """ :class:`~plugins.train.model.io.IO`: Input/Output operations for the model """ return self._io - @property - def config(self) -> dict: - """ dict: The configuration dictionary for current plugin, as set by the user's - configuration settings. """ - global _CONFIG # pylint:disable=global-statement - if not _CONFIG: - model_name = self._config_section - logger.debug("Loading config for: %s", model_name) - _CONFIG = Config(model_name, configfile=self._configfile).config_dict - return _CONFIG - @property def name(self) -> str: """ str: The name of this model based on the plugin name. """ @@ -163,14 +136,14 @@ def model_name(self) -> str: @property def input_shapes(self) -> list[tuple[None, int, int, int]]: """ list: A flattened list corresponding to all of the inputs to the model. """ - shapes = [T.cast(tuple[None, int, int, int], K.int_shape(inputs)) + shapes = [T.cast(tuple[None, int, int, int], inputs.shape) for inputs in self.model.inputs] return shapes @property def output_shapes(self) -> list[tuple[None, int, int, int]]: """ list: A flattened list corresponding to all of the outputs of the model. """ - shapes = [T.cast(tuple[None, int, int, int], K.int_shape(output)) + shapes = [T.cast(tuple[None, int, int, int], output.shape) for output in self.model.outputs] return shapes @@ -184,6 +157,18 @@ def warmup_steps(self) -> int: """ int : The number of steps to perform learning rate warmup """ return self._args.warmup + @property + def freeze_layers(self) -> list[str]: + """ list[str] : Override to set plugin specific layers that can be frozen. Defaults to + ["encoder"] """ + return ["encoder"] + + @property + def load_layers(self) -> list[str]: + """ list[str] : Override to set plugin specific layers that can be loaded. Defaults to + ["encoder"] """ + return ["encoder"] + # Private properties @property def _config_section(self) -> str: @@ -191,30 +176,11 @@ def _config_section(self) -> str: config file. """ return ".".join(self.__module__.split(".")[-2:]) - @property - def _config_changeable_items(self) -> dict: - """ dict: The configuration options that can be updated after the model has already been - created. """ - return Config(self._config_section, configfile=self._configfile).changeable_items - @property def state(self) -> "State": """:class:`State`: The state settings for the current plugin. """ return self._state - def _load_config(self) -> None: - """ Load the global config for reference in :attr:`config` and set the faceswap blocks - configuration options in `lib.model.nn_blocks` """ - global _CONFIG # pylint:disable=global-statement - if not _CONFIG: - model_name = self._config_section - logger.debug("Loading config for: %s", model_name) - _CONFIG = Config(model_name, configfile=self._configfile).config_dict - - nn_block_keys = ['icnr_init', 'conv_aware_init', 'reflect_padding'] - set_nnblock_config({key: _CONFIG.pop(key) - for key in nn_block_keys}) - def _check_multiple_models(self) -> None: """ Check whether multiple models exist in the model folder, and that no models exist that were trained with a different plugin than the requested plugin. @@ -253,82 +219,35 @@ def build(self) -> None: Finally, a model summary is outputted to the logger at verbose level. """ - self._update_legacy_models() is_summary = hasattr(self._args, "summary") and self._args.summary - with self._settings.strategy_scope(): - if self._io.model_exists: - model = self.io.load() - if self._is_predict: - inference = _Inference(model, self._args.swap_model) - self._model = inference.model - else: - self._model = model + if self._io.model_exists: + model = self.io.load() + if self._is_predict: + inference = Inference(model, self._args.swap_model) + self._model = inference.model else: - self._validate_input_shape() - inputs = self._get_inputs() - if not self._settings.use_mixed_precision and not is_summary: - # Store layer names which can be switched to mixed precision - model, mp_layers = self._settings.get_mixed_precision_layers(self.build_model, - inputs) - self._state.add_mixed_precision_layers(mp_layers) - self._model = model - else: - self._model = self.build_model(inputs) - if not is_summary and not self._is_predict: - self._compile_model() - self._output_summary() - - def _update_legacy_models(self) -> None: - """ Load weights from legacy split models into new unified model, archiving old model files - to a new folder. """ - legacy_mapping = self._legacy_mapping() # pylint:disable=assignment-from-none - if legacy_mapping is None: - return - - if not all(os.path.isfile(os.path.join(self.io.model_dir, fname)) - for fname in legacy_mapping): - return - archive_dir = f"{self.io.model_dir}_TF1_Archived" - if os.path.exists(archive_dir): - raise FaceswapError("We need to update your model files for use with Tensorflow 2.x, " - "but the archive folder already exists. Please remove the " - f"following folder to continue: '{archive_dir}'") - - logger.info("Updating legacy models for Tensorflow 2.x") - logger.info("Your Tensorflow 1.x models will be archived in the following location: '%s'", - archive_dir) - os.rename(self.io.model_dir, archive_dir) - os.mkdir(self.io.model_dir) - new_model = self.build_model(self._get_inputs()) - for model_name, layer_name in legacy_mapping.items(): - old_model: tf.keras.models.Model = keras.models.load_model( - os.path.join(archive_dir, model_name), - compile=False) - layer = [layer for layer in new_model.layers if layer.name == layer_name] - if not layer: - logger.warning("Skipping legacy weights from '%s'...", model_name) - continue - klayer: tf.keras.layers.Layer = layer[0] - logger.info("Updating legacy weights from '%s'...", model_name) - klayer.set_weights(old_model.get_weights()) - filename = self._io.filename - logger.info("Saving Tensorflow 2.x model to '%s'", filename) - new_model.save(filename) - # Penalized Loss and Learn Mask used to be disabled automatically if a mask wasn't - # selected, so disable it if enabled, but mask_type is None - if self.config["mask_type"] is None: - self.config["penalized_mask_loss"] = False - self.config["learn_mask"] = False - self.config["eye_multiplier"] = 1 - self.config["mouth_multiplier"] = 1 - self._state.save() + self._model = model + else: + self._validate_input_shape() + inputs = self._get_inputs() + if not self._settings.use_mixed_precision and not is_summary: + # Store layer names which can be switched to mixed precision + model, mp_layers = self._settings.get_mixed_precision_layers(self.build_model, + inputs) + self._state.add_mixed_precision_layers(mp_layers) + self._model = model + else: + self._model = self.build_model(inputs) + if not is_summary and not self._is_predict: + self._compile_model() + self._output_summary() def _validate_input_shape(self) -> None: """ Validate that the input shape is either a single shape tuple of 3 dimensions or a list of 2 shape tuples of 3 dimensions. """ assert len(self.input_shape) == 3, "Input shape should be a 3 dimensional shape tuple" - def _get_inputs(self) -> list[tf.keras.layers.Input]: + def _get_inputs(self) -> list[keras.layers.Input]: """ Obtain the standardized inputs for the model. The inputs will be returned for the "A" and "B" sides in the shape as defined by @@ -347,7 +266,7 @@ def _get_inputs(self) -> list[tf.keras.layers.Input]: logger.debug("inputs: %s", inputs) return inputs - def build_model(self, inputs: list[tf.keras.layers.Input]) -> tf.keras.models.Model: + def build_model(self, inputs: list[keras.layers.Input]) -> keras.Model: """ Override for Model Specific autoencoder builds. Parameters @@ -358,28 +277,38 @@ def build_model(self, inputs: list[tf.keras.layers.Input]) -> tf.keras.models.Mo Returns ------- - :class:`keras.models.Model` + :class:`keras.Model` See Keras documentation for the correct structure, but note that parameter :attr:`name` is a required rather than an optional argument in Faceswap. You should assign this to the attribute ``self.name`` that is automatically generated from the plugin's filename. """ raise NotImplementedError + def _summary_to_log(self, summary: str) -> None: + """ Function to output Keras model summary to log file at verbose log level + + Parameters + ---------- + summary, str + The model summary output from keras + """ + for line in summary.splitlines(): + logger.verbose(line) # type:ignore[attr-defined] + def _output_summary(self) -> None: """ Output the summary of the model and all sub-models to the verbose logger. """ if hasattr(self._args, "summary") and self._args.summary: print_fn = None # Print straight to stdout else: # print to logger - print_fn = lambda x: logger.verbose("%s", x) #type:ignore[attr-defined] # noqa[E731] # pylint:disable=C3001 - parent = None + print_fn = self._summary_to_log + parent = self.model for idx, model in enumerate(get_all_sub_models(self.model)): if idx == 0: parent = model continue - model.summary(line_length=100, print_fn=print_fn) - assert parent is not None - parent.summary(line_length=100, print_fn=print_fn) + model.summary(print_fn=print_fn) + parent.summary(print_fn=print_fn) def _compile_model(self) -> None: """ Compile the model to include the Optimizer and Loss Function(s). """ @@ -388,10 +317,7 @@ def _compile_model(self) -> None: if self.state.model_needs_rebuild: self._model = self._settings.check_model_precision(self._model, self._state) - optimizer = Optimizer(self.config["optimizer"], - self.config["learning_rate"], - self.config["autoclip"], - 10 ** int(self.config["epsilon_exponent"])).optimizer + optimizer = Optimizer().optimizer if self._settings.use_mixed_precision: optimizer = self._settings.loss_scale_optimizer(optimizer) @@ -400,23 +326,12 @@ def _compile_model(self) -> None: weights.freeze() self._loss.configure(self.model) - self.model.compile(optimizer=optimizer, loss=self._loss.functions) + losses = list(self._loss.functions.values()) + self.model.compile(optimizer=optimizer, loss=losses) self._state.add_session_loss_names(self._loss.names) logger.debug("Compiled Model: %s", self.model) - def _legacy_mapping(self) -> dict | None: - """ The mapping of separate model files to single model layers for transferring of legacy - weights. - - Returns - ------- - dict or ``None`` - Dictionary of original H5 filenames for legacy models mapped to new layer names or - ``None`` if the model did not exist in Faceswap prior to Tensorflow 2 - """ - return None - - def add_history(self, loss: list[float]) -> None: + def add_history(self, loss: np.ndarray) -> None: """ Add the current iteration's loss history to :attr:`_io.history`. Called from the trainer after each iteration, for tracking loss drop over time between @@ -424,552 +339,11 @@ def add_history(self, loss: list[float]) -> None: Parameters ---------- - loss: list + loss : :class:`numpy.ndarray` The loss values for the A and B side for the current iteration. This should be the collated loss values for each side. """ - self._io.history[0].append(loss[0]) - self._io.history[1].append(loss[1]) - - -class State(): - """ Holds state information relating to the plugin's saved model. - - Parameters - ---------- - model_dir: str - The full path to the model save location - model_name: str - The name of the model plugin - config_changeable_items: dict - Configuration options that can be altered when resuming a model, and their current values - no_logs: bool - ``True`` if Tensorboard logs should not be generated, otherwise ``False`` - """ - def __init__(self, - model_dir: str, - model_name: str, - config_changeable_items: dict, - no_logs: bool) -> None: - logger.debug("Initializing %s: (model_dir: '%s', model_name: '%s', " - "config_changeable_items: '%s', no_logs: %s", self.__class__.__name__, - model_dir, model_name, config_changeable_items, no_logs) - self._serializer = get_serializer("json") - filename = f"{model_name}_state.{self._serializer.file_extension}" - self._filename = os.path.join(model_dir, filename) - self._name = model_name - self._iterations = 0 - self._mixed_precision_layers: list[str] = [] - self._lr_finder = -1.0 - self._rebuild_model = False - self._sessions: dict[int, dict] = {} - self._lowest_avg_loss: dict[str, float] = {} - self._config: dict[str, ConfigValueType] = {} - self._load(config_changeable_items) - self._session_id = self._new_session_id() - self._create_new_session(no_logs, config_changeable_items) - logger.debug("Initialized %s:", self.__class__.__name__) - - @property - def filename(self) -> str: - """ str: Full path to the state filename """ - return self._filename - - @property - def loss_names(self) -> list[str]: - """ list: The loss names for the current session """ - return self._sessions[self._session_id]["loss_names"] - - @property - def current_session(self) -> dict: - """ dict: The state dictionary for the current :attr:`session_id`. """ - return self._sessions[self._session_id] - - @property - def iterations(self) -> int: - """ int: The total number of iterations that the model has trained. """ - return self._iterations - - @property - def lowest_avg_loss(self) -> dict: - """dict: The lowest average save interval loss seen for each side. """ - return self._lowest_avg_loss - - @property - def session_id(self) -> int: - """ int: The current training session id. """ - return self._session_id - - @property - def sessions(self) -> dict[int, dict[str, T.Any]]: - """ dict[int, dict[str, Any]]: The session information for each session in the state - file """ - return {int(k): v for k, v in self._sessions.items()} - - @property - def mixed_precision_layers(self) -> list[str]: - """list: Layers that can be switched between mixed-float16 and float32. """ - return self._mixed_precision_layers - - @property - def lr_finder(self) -> float: - """ The value discovered from the learning rate finder. -1 if no value stored """ - return self._lr_finder - - @property - def model_needs_rebuild(self) -> bool: - """bool: ``True`` if mixed precision policy has changed so model needs to be rebuilt - otherwise ``False`` """ - return self._rebuild_model - - def _new_session_id(self) -> int: - """ Generate a new session id. Returns 1 if this is a new model, or the last session id + 1 - if it is a pre-existing model. - - Returns - ------- - int - The newly generated session id - """ - if not self._sessions: - session_id = 1 - else: - session_id = max(int(key) for key in self._sessions.keys()) + 1 - logger.debug(session_id) - return session_id - - def _create_new_session(self, no_logs: bool, config_changeable_items: dict) -> None: - """ Initialize a new session, creating the dictionary entry for the session in - :attr:`_sessions`. - - Parameters - ---------- - no_logs: bool - ``True`` if Tensorboard logs should not be generated, otherwise ``False`` - config_changeable_items: dict - Configuration options that can be altered when resuming a model, and their current - values - """ - logger.debug("Creating new session. id: %s", self._session_id) - self._sessions[self._session_id] = {"timestamp": time.time(), - "no_logs": no_logs, - "loss_names": [], - "batchsize": 0, - "iterations": 0, - "config": config_changeable_items} - - def update_session_config(self, key: str, value: T.Any) -> None: - """ Update a configuration item of the currently loaded session. - - Parameters - ---------- - key: str - The configuration item to update for the current session - value: any - The value to update to - """ - old_val = self.current_session["config"][key] - assert isinstance(value, type(old_val)) - logger.debug("Updating configuration item '%s' from '%s' to '%s'", key, old_val, value) - self.current_session["config"][key] = value - - def add_session_loss_names(self, loss_names: list[str]) -> None: - """ Add the session loss names to the sessions dictionary. - - The loss names are used for Tensorboard logging - - Parameters - ---------- - loss_names: list - The list of loss names for this session. - """ - logger.debug("Adding session loss_names: %s", loss_names) - self._sessions[self._session_id]["loss_names"] = loss_names + self._io.history.append(float(sum(loss))) - def add_session_batchsize(self, batch_size: int) -> None: - """ Add the session batch size to the sessions dictionary. - Parameters - ---------- - batch_size: int - The batch size for the current training session - """ - logger.debug("Adding session batch size: %s", batch_size) - self._sessions[self._session_id]["batchsize"] = batch_size - - def increment_iterations(self) -> None: - """ Increment :attr:`iterations` and session iterations by 1. """ - self._iterations += 1 - self._sessions[self._session_id]["iterations"] += 1 - - def add_mixed_precision_layers(self, layers: list[str]) -> None: - """ Add the list of model's layers that are compatible for mixed precision to the - state dictionary """ - logger.debug("Storing mixed precision layers: %s", layers) - self._mixed_precision_layers = layers - - def add_lr_finder(self, learning_rate: float) -> None: - """ Add the optimal discovered learning rate from the learning rate finder - - Parameters - ---------- - learning_rate : float - The discovered learning rate - """ - logger.debug("Storing learning rate from LR Finder: %s", learning_rate) - self._lr_finder = learning_rate - - def _load(self, config_changeable_items: dict) -> None: - """ Load a state file and set the serialized values to the class instance. - - Updates the model's config with the values stored in the state file. - - Parameters - ---------- - config_changeable_items: dict - Configuration options that can be altered when resuming a model, and their current - values - """ - logger.debug("Loading State") - if not os.path.exists(self._filename): - logger.info("No existing state file found. Generating.") - return - state = self._serializer.load(self._filename) - self._name = state.get("name", self._name) - self._sessions = state.get("sessions", {}) - self._lowest_avg_loss = state.get("lowest_avg_loss", {}) - self._iterations = state.get("iterations", 0) - self._mixed_precision_layers = state.get("mixed_precision_layers", []) - self._lr_finder = state.get("lr_finder", -1.0) - self._config = state.get("config", {}) - logger.debug("Loaded state: %s", state) - self._replace_config(config_changeable_items) - - def save(self) -> None: - """ Save the state values to the serialized state file. """ - logger.debug("Saving State") - state = {"name": self._name, - "sessions": {k: v for k, v in self._sessions.items() - if v.get("iterations", 0) > 0}, - "lowest_avg_loss": self._lowest_avg_loss, - "iterations": self._iterations, - "mixed_precision_layers": self._mixed_precision_layers, - "lr_finder": self._lr_finder, - "config": _CONFIG} - self._serializer.save(self._filename, state) - logger.debug("Saved State") - - def _replace_config(self, config_changeable_items) -> None: - """ Replace the loaded config with the one contained within the state file. - - Check for any `fixed`=``False`` parameter changes and log info changes. - - Update any legacy config items to their current versions. - - Parameters - ---------- - config_changeable_items: dict - Configuration options that can be altered when resuming a model, and their current - values - """ - global _CONFIG # pylint:disable=global-statement - if _CONFIG is None: - return - legacy_update = self._update_legacy_config() - # Add any new items to state config for legacy purposes where the new default may be - # detrimental to an existing model. - legacy_defaults: dict[str, str | int | bool] = {"centering": "legacy", - "mask_loss_function": "mse", - "l2_reg_term": 100, - "optimizer": "adam", - "mixed_precision": False} - for key, val in _CONFIG.items(): - if key not in self._config.keys(): - setting: ConfigValueType = legacy_defaults.get(key, val) - logger.info("Adding new config item to state file: '%s': '%s'", key, setting) - self._config[key] = setting - self._update_changed_config_items(config_changeable_items) - logger.debug("Replacing config. Old config: %s", _CONFIG) - _CONFIG = self._config - if legacy_update: - self.save() - logger.debug("Replaced config. New config: %s", _CONFIG) - logger.info("Using configuration saved in state file") - - def _update_legacy_config(self) -> bool: - """ Legacy updates for new config additions. - - When new config items are added to the Faceswap code, existing model state files need to be - updated to handle these new items. - - Current existing legacy update items: - - * loss - If old `dssim_loss` is ``true`` set new `loss_function` to `ssim` otherwise - set it to `mae`. Remove old `dssim_loss` item - - * l2_reg_term - If this exists, set loss_function_2 to ``mse`` and loss_weight_2 to - the value held in the old ``l2_reg_term`` item - - * masks - If `learn_mask` does not exist then it is set to ``True`` if `mask_type` is - not ``None`` otherwise it is set to ``False``. - - * masks type - Replace removed masks 'dfl_full' and 'facehull' with `components` mask - - * clipnorm - Only existed in 2 models (DFL-SAE + Unbalanced). Replaced with global - option autoclip - - Returns - ------- - bool - ``True`` if legacy items exist and state file has been updated, otherwise ``False`` - """ - logger.debug("Checking for legacy state file update") - priors = ["dssim_loss", "mask_type", "mask_type", "l2_reg_term", "clipnorm"] - new_items = ["loss_function", "learn_mask", "mask_type", "loss_function_2", - "autoclip"] - updated = False - for old, new in zip(priors, new_items): - if old not in self._config: - logger.debug("Legacy item '%s' not in config. Skipping update", old) - continue - - # dssim_loss > loss_function - if old == "dssim_loss": - self._config[new] = "ssim" if self._config[old] else "mae" - del self._config[old] - updated = True - logger.info("Updated config from legacy dssim format. New config loss " - "function: '%s'", self._config[new]) - continue - - # Add learn mask option and set to True if model has "penalized_mask_loss" specified - if old == "mask_type" and new == "learn_mask" and new not in self._config: - self._config[new] = self._config["mask_type"] is not None - updated = True - logger.info("Added new 'learn_mask' config item for this model. Value set to: %s", - self._config[new]) - continue - - # Replace removed masks with most similar equivalent - if old == "mask_type" and new == "mask_type" and self._config[old] in ("facehull", - "dfl_full"): - old_mask = self._config[old] - self._config[new] = "components" - updated = True - logger.info("Updated 'mask_type' from '%s' to '%s' for this model", - old_mask, self._config[new]) - - # Replace l2_reg_term with the correct loss_2_function and update the value of - # loss_2_weight - if old == "l2_reg_term": - self._config[new] = "mse" - self._config["loss_weight_2"] = self._config[old] - del self._config[old] - updated = True - logger.info("Updated config from legacy 'l2_reg_term' to 'loss_function_2'") - - # Replace clipnorm with correct gradient clipping type and value - if old == "clipnorm": - self._config[new] = self._config[old] - del self._config[old] - updated = True - logger.info("Updated config from legacy '%s' to '%s'", old, new) - - logger.debug("State file updated for legacy config: %s", updated) - return updated - - def _update_changed_config_items(self, config_changeable_items: dict) -> None: - """ Update any parameters which are not fixed and have been changed. - - Set the :attr:`model_needs_rebuild` to ``True`` if mixed precision state has changed - - Parameters - ---------- - config_changeable_items: dict - Configuration options that can be altered when resuming a model, and their current - values - """ - rebuild_tasks = ["mixed_precision"] - if not config_changeable_items: - logger.debug("No changeable parameters have been updated") - return - for key, val in config_changeable_items.items(): - old_val = self._config[key] - if old_val == val: - continue - self._config[key] = val - logger.info("Config item: '%s' has been updated from '%s' to '%s'", key, old_val, val) - self._rebuild_model = self._rebuild_model or key in rebuild_tasks - - -class _Inference(): # pylint:disable=too-few-public-methods - """ Calculates required layers and compiles a saved model for inference. - - Parameters - ---------- - saved_model: :class:`keras.models.Model` - The saved trained Faceswap model - switch_sides: bool - ``True`` if the swap should be performed "B" > "A" ``False`` if the swap should be - "A" > "B" - """ - def __init__(self, saved_model: tf.keras.models.Model, switch_sides: bool) -> None: - logger.debug("Initializing: %s (saved_model: %s, switch_sides: %s)", - self.__class__.__name__, saved_model, switch_sides) - self._config = saved_model.get_config() - - self._input_idx = 1 if switch_sides else 0 - self._output_idx = 0 if switch_sides else 1 - - self._input_names = [inp[0] for inp in self._config["input_layers"]] - self._model = self._make_inference_model(saved_model) - logger.debug("Initialized: %s", self.__class__.__name__) - - @property - def model(self) -> tf.keras.models.Model: - """ :class:`keras.models.Model`: The Faceswap model, compiled for inference. """ - return self._model - - def _get_nodes(self, nodes: np.ndarray) -> list[tuple[str, int]]: - """ Given in input list of nodes from a :attr:`keras.models.Model.get_config` dictionary, - filters the layer name(s) and output index of the node, splitting to the correct output - index in the event of multiple inputs. - - Parameters - ---------- - nodes: list - A node entry from the :attr:`keras.models.Model.get_config` dictionary - - Returns - ------- - list - The (node name, output index) for each node passed in - """ - anodes = np.array(nodes, dtype="object")[..., :3] - num_layers = anodes.shape[0] - anodes = anodes[self._output_idx] if num_layers == 2 else anodes[0] - - # Probably better checks for this, but this occurs when DNY preset is used and learn - # mask is enabled (i.e. the mask is created in fully connected layers) - anodes = anodes.squeeze() if anodes.ndim == 3 else anodes - - retval = [(node[0], node[2]) for node in anodes] - return retval - - def _make_inference_model(self, saved_model: tf.keras.models.Model) -> tf.keras.models.Model: - """ Extract the sub-models from the saved model that are required for inference. - - Parameters - ---------- - saved_model: :class:`keras.models.Model` - The saved trained Faceswap model - - Returns - ------- - :class:`keras.models.Model` - The model compiled for inference - """ - logger.debug("Compiling inference model. saved_model: %s", saved_model) - struct = self._get_filtered_structure() - model_inputs = self._get_inputs(saved_model.inputs) - compiled_layers: dict[str, tf.keras.layers.Layer] = {} - for layer in saved_model.layers: - if layer.name not in struct: - logger.debug("Skipping unused layer: '%s'", layer.name) - continue - inbound = struct[layer.name] - logger.debug("Processing layer '%s': (layer: %s, inbound_nodes: %s)", - layer.name, layer, inbound) - if not inbound: - model = model_inputs - logger.debug("Adding model inputs %s: %s", layer.name, model) - else: - layer_inputs = [] - for inp in inbound: - inbound_layer = compiled_layers[inp[0]] - if isinstance(inbound_layer, list) and len(inbound_layer) > 1: - # Multi output inputs - inbound_output_idx = inp[1] - next_input = inbound_layer[inbound_output_idx] - logger.debug("Selecting output index %s from multi output inbound layer: " - "%s (using: %s)", inbound_output_idx, inbound_layer, - next_input) - else: - next_input = inbound_layer - - layer_inputs.append(next_input) - - logger.debug("Compiling layer '%s': layer inputs: %s", layer.name, layer_inputs) - model = layer(layer_inputs) - compiled_layers[layer.name] = model - retval = keras.models.Model(model_inputs, model, name=f"{saved_model.name}_inference") - logger.debug("Compiled inference model '%s': %s", retval.name, retval) - return retval - - def _get_filtered_structure(self) -> OrderedDict: - """ Obtain the structure of the inference model. - - This parses the model config (in reverse) to obtain the required layers for an inference - model. - - Returns - ------- - :class:`collections.OrderedDict` - The layer name as key with the input name and output index as value. - """ - # Filter output layer - out = np.array(self._config["output_layers"], dtype="object") - if out.ndim == 2: - out = np.expand_dims(out, axis=1) # Needs to be expanded for _get_nodes - outputs = self._get_nodes(out) - - # Iterate backwards from the required output to get the reversed model structure - current_layers = [outputs[0]] - next_layers = [] - struct = OrderedDict() - drop_input = self._input_names[abs(self._input_idx - 1)] - switch_input = self._input_names[self._input_idx] - while True: - layer_info = current_layers.pop(0) - current_layer = next(lyr for lyr in self._config["layers"] - if lyr["name"] == layer_info[0]) - inbound = current_layer["inbound_nodes"] - - if not inbound: - break - - inbound_info = self._get_nodes(inbound) - - if any(inb[0] == drop_input for inb in inbound_info): # Switch inputs - inbound_info = [(switch_input if inb[0] == drop_input else inb[0], inb[1]) - for inb in inbound_info] - struct[layer_info[0]] = inbound_info - next_layers.extend(inbound_info) - - if not current_layers: - current_layers = next_layers - next_layers = [] - - struct[switch_input] = [] # Add the input layer - logger.debug("Model structure: %s", struct) - return struct - - def _get_inputs(self, inputs: list) -> list: - """ Obtain the inputs for the requested swap direction. - - Parameters - ---------- - inputs: list - The full list of input tensors to the saved faceswap training model - - Returns - ------- - list - List of input tensors to feed the model for the requested swap direction - """ - input_split = len(inputs) // 2 - start_idx = input_split * self._input_idx - retval = inputs[start_idx: start_idx + input_split] - logger.debug("model inputs: %s, input_split: %s, start_idx: %s, inference_inputs: %s", - inputs, input_split, start_idx, retval) - return retval +__all__ = get_module_objects(__name__) diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index f2a9aba321..1875a1c8bf 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -13,28 +13,24 @@ from __future__ import annotations from dataclasses import dataclass, field import logging -import platform import typing as T -from contextlib import nullcontext +import keras +from keras import config as k_config, dtype_policies, losses as k_losses, optimizers -import tensorflow as tf -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras import losses as k_losses # pylint:disable=import-error -import tensorflow.keras.mixed_precision as mixedprecision # noqa pylint:disable=import-error - -from lib.model import losses, optimizers +from lib.model import losses +from lib.model.optimizers import AdaBelief from lib.model.autoclip import AutoClipper -from lib.utils import get_backend +from lib.model.nn_blocks import reset_naming +from lib.logger import parse_class_init +from lib.utils import get_module_objects +from plugins.train.train_config import Loss as cfg_loss, Optimizer as cfg_opt if T.TYPE_CHECKING: from collections.abc import Callable - from contextlib import AbstractContextManager as ContextManager from argparse import Namespace - from .model import State - -keras = tf.keras -K = keras.backend + from keras import KerasTensor + from .state import State logger = logging.getLogger(__name__) @@ -54,7 +50,8 @@ class LossClass: kwargs: dict Any keyword arguments to supply to the loss function at initialization. """ - function: Callable[[tf.Tensor, tf.Tensor], tf.Tensor] | T.Any = k_losses.mae + function: Callable[[KerasTensor, KerasTensor], + KerasTensor] | T.Any = k_losses.MeanSquaredError init: bool = True kwargs: dict[str, T.Any] = field(default_factory=dict) @@ -64,18 +61,16 @@ class Loss(): Parameters ---------- - config: dict - The configuration options for the current model plugin color_order: str Color order of the model. One of `"BGR"` or `"RGB"` """ - def __init__(self, config: dict, color_order: T.Literal["bgr", "rgb"]) -> None: - logger.debug("Initializing %s: (color_order: %s)", self.__class__.__name__, color_order) - self._config = config + def __init__(self, color_order: T.Literal["bgr", "rgb"]) -> None: + logger.debug(parse_class_init(locals())) self._mask_channels = self._get_mask_channels() - self._inputs: list[tf.keras.layers.Layer] = [] + self._inputs: list[keras.layers.Layer] = [] self._names: list[str] = [] - self._funcs: dict[str, Callable] = {} + self._funcs: dict[str, losses.LossWrapper | T.Callable[[KerasTensor, KerasTensor], + KerasTensor]] = {} self._loss_dict = {"ffl": LossClass(function=losses.FocalFrequencyLoss), "flip": LossClass(function=losses.LDRFLIPLoss, @@ -83,7 +78,7 @@ def __init__(self, config: dict, color_order: T.Literal["bgr", "rgb"]) -> None: "gmsd": LossClass(function=losses.GMSDLoss), "l_inf_norm": LossClass(function=losses.LInfNorm), "laploss": LossClass(function=losses.LaplacianPyramidLoss), - "logcosh": LossClass(function=k_losses.logcosh, init=False), + "logcosh": LossClass(function=k_losses.LogCosh), "lpips_alex": LossClass(function=losses.LPIPSLoss, kwargs={"trunk_network": "alex"}), "lpips_squeeze": LossClass(function=losses.LPIPSLoss, @@ -91,8 +86,8 @@ def __init__(self, config: dict, color_order: T.Literal["bgr", "rgb"]) -> None: "lpips_vgg16": LossClass(function=losses.LPIPSLoss, kwargs={"trunk_network": "vgg16"}), "ms_ssim": LossClass(function=losses.MSSIMLoss), - "mae": LossClass(function=k_losses.mean_absolute_error, init=False), - "mse": LossClass(function=k_losses.mean_squared_error, init=False), + "mae": LossClass(function=k_losses.MeanAbsoluteError), + "mse": LossClass(function=k_losses.MeanSquaredError), "pixel_gradient_diff": LossClass(function=losses.GradientLoss), "ssim": LossClass(function=losses.DSSIMObjective), "smooth_loss": LossClass(function=losses.GeneralizedLoss)} @@ -105,8 +100,10 @@ def names(self) -> list[str]: return self._names @property - def functions(self) -> dict: - """ dict: The loss functions that apply to each model output. """ + def functions(self) -> dict[str, losses.LossWrapper | T.Callable[[KerasTensor, KerasTensor], + KerasTensor]]: + """ dict[str, :class:`~lib.model.losses.LossWrapper` | | Callable[[KerasTensor, + KerasTensor], KerasTensor]]]: The loss functions that apply to each model output. """ return self._funcs @property @@ -122,9 +119,9 @@ def _mask_shapes(self) -> list[tuple] | None: ``None`` if there is no mask input. """ if self._mask_inputs is None: return None - return [K.int_shape(mask_input) for mask_input in self._mask_inputs] + return [mask_input.shape for mask_input in self._mask_inputs] - def configure(self, model: tf.keras.models.Model) -> None: + def configure(self, model: keras.models.Model) -> None: """ Configure the loss functions for the given inputs and outputs. Parameters @@ -137,30 +134,23 @@ def configure(self, model: tf.keras.models.Model) -> None: self._set_loss_functions(model.output_names) self._names.insert(0, "total") - def _set_loss_names(self, outputs: list[tf.Tensor]) -> None: + def _set_loss_names(self, outputs: list[KerasTensor]) -> None: """ Name the losses based on model output. This is used for correct naming in the state file, for display purposes only. Adds the loss names to :attr:`names` - Notes - ----- - TODO Currently there is an issue in Tensorflow that wraps all outputs in an Identity layer - when running in Eager Execution mode, which means we cannot use the name of the output - layers to name the losses (https://github.com/tensorflow/tensorflow/issues/32180). - With this in mind, losses are named based on their shapes - Parameters ---------- - outputs: list + outputs: list[:class:`keras.KerasTensor`] A list of output tensors from the model plugin """ # TODO Use output names if/when these are fixed upstream split_outputs = [outputs[:len(outputs) // 2], outputs[len(outputs) // 2:]] for side, side_output in zip(("a", "b"), split_outputs): output_names = [output.name for output in side_output] - output_shapes = [K.int_shape(output)[1:] for output in side_output] + output_shapes = [output.shape[1:] for output in side_output] output_types = ["mask" if shape[-1] == 1 else "face" for shape in output_shapes] logger.debug("side: %s, output names: %s, output_shapes: %s, output_types: %s", side, output_names, output_shapes, output_types) @@ -169,7 +159,7 @@ def _set_loss_names(self, outputs: list[tf.Tensor]) -> None: self._names.append(f"{name}_{side}{suffix}") logger.debug(self._names) - def _get_function(self, name: str) -> Callable[[tf.Tensor, tf.Tensor], tf.Tensor]: + def _get_function(self, name: str) -> Callable[[KerasTensor, KerasTensor], KerasTensor]: """ Obtain the requested Loss function Parameters @@ -187,32 +177,37 @@ def _get_function(self, name: str) -> Callable[[tf.Tensor, tf.Tensor], tf.Tensor logger.debug("Obtained loss function `%s` (%s)", name, retval) return retval - def _set_loss_functions(self, output_names: list[str]): + def _set_loss_functions(self, output_names: list[str]) -> None: """ Set the loss functions and their associated weights. Adds the loss functions to the :attr:`functions` dictionary. Parameters ---------- - output_names: list + output_names: list[str] The output names from the model """ - face_losses = [(lossname, self._config.get(f"loss_weight_{k[-1]}", 100)) - for k, lossname in sorted(self._config.items()) - if k.startswith("loss_function") - and self._config.get(f"loss_weight_{k[-1]}", 100) != 0 - and lossname is not None] + loss_funcs = [cfg_loss.loss_function(), + cfg_loss.loss_function_2(), + cfg_loss.loss_function_3(), + cfg_loss.loss_function_4()] + loss_amount = [100, + cfg_loss.loss_weight_2(), + cfg_loss.loss_weight_3(), + cfg_loss.loss_weight_4()] + face_losses = [(name, weight) for name, weight in zip(loss_funcs, loss_amount) + if name != "none" and weight > 0] for name, output_name in zip(self._names, output_names): if name.startswith("mask"): - loss_func = self._get_function(self._config["mask_loss_function"]) + loss_func = self._get_function(cfg_loss.mask_loss_function()) else: loss_func = losses.LossWrapper() for func, weight in face_losses: self._add_face_loss_function(loss_func, func, weight / 100.) logger.debug("%s: (output_name: '%s', function: %s)", name, output_name, loss_func) - self._funcs[output_name] = loss_func + self._funcs[name] = loss_func logger.debug("functions: %s", self._funcs) def _add_face_loss_function(self, @@ -237,9 +232,11 @@ def _add_face_loss_function(self, mask_channel=self._mask_channels[0]) channel_idx = 1 - for section in ("eye_multiplier", "mouth_multiplier"): + for section, multiplier in zip( + ("eye_multiplier", "mouth_multiplier"), + (float(cfg_loss.eye_multiplier()), float(cfg_loss.mouth_multiplier()))): mask_channel = self._mask_channels[channel_idx] - multiplier = self._config[section] * 1. + multiplier *= 1. if multiplier > 1.: logger.debug("Adding section loss %s: %s", section, multiplier) loss_wrapper.add_loss(self._get_function(loss_function), @@ -256,17 +253,14 @@ def _get_mask_channels(self) -> list[int]: list: A list of channel indices that contain the mask for the corresponding config item """ - eye_multiplier = self._config["eye_multiplier"] - mouth_multiplier = self._config["mouth_multiplier"] - if not self._config["penalized_mask_loss"] and (eye_multiplier > 1 or - mouth_multiplier > 1): + eye_multiplier = cfg_loss.eye_multiplier() + mouth_multiplier = cfg_loss.mouth_multiplier() + if not cfg_loss.penalized_mask_loss() and (eye_multiplier > 1 or mouth_multiplier > 1): logger.warning("You have selected eye/mouth loss multipliers greater than 1x, but " "Penalized Mask Loss is disabled. Disabling all multipliers.") eye_multiplier = 1 mouth_multiplier = 1 - uses_masks = (self._config["penalized_mask_loss"], - eye_multiplier > 1, - mouth_multiplier > 1) + uses_masks = (cfg_loss.penalized_mask_loss(), eye_multiplier > 1, mouth_multiplier > 1) mask_channels = [-1 for _ in range(len(uses_masks))] current_channel = 3 for idx, mask_required in enumerate(uses_masks): @@ -277,67 +271,151 @@ def _get_mask_channels(self) -> list[int]: return mask_channels -class Optimizer(): # pylint:disable=too-few-public-methods - """ Obtain the selected optimizer with the appropriate keyword arguments. - - Parameters - ---------- - optimizer: str - The selected optimizer name for the plugin - learning_rate: float - The selected learning rate to use - autoclip: bool - ``True`` if AutoClip should be enabled otherwise ``False`` - epsilon: float - The value to use for the epsilon of the optimizer - """ - def __init__(self, - optimizer: str, - learning_rate: float, - autoclip: bool, - epsilon: float) -> None: - logger.debug("Initializing %s: (optimizer: %s, learning_rate: %s, autoclip: %s, " - ", epsilon: %s)", self.__class__.__name__, optimizer, learning_rate, - autoclip, epsilon) - valid_optimizers = {"adabelief": (optimizers.AdaBelief, - {"beta_1": 0.5, "beta_2": 0.99, "epsilon": epsilon}), - "adam": (optimizers.Adam, - {"beta_1": 0.5, "beta_2": 0.99, "epsilon": epsilon}), - "nadam": (optimizers.Nadam, - {"beta_1": 0.5, "beta_2": 0.99, "epsilon": epsilon}), - "rms-prop": (optimizers.RMSprop, {"epsilon": epsilon})} - optimizer_info = valid_optimizers[optimizer] - self._optimizer: Callable = optimizer_info[0] - self._kwargs: dict[str, T.Any] = optimizer_info[1] - - self._configure(learning_rate, autoclip) - logger.verbose("Using %s optimizer", optimizer.title()) # type:ignore[attr-defined] +class Optimizer(): + """ Obtain the selected optimizer with the appropriate keyword arguments. """ + def __init__(self) -> None: + logger.debug(parse_class_init(locals())) + betas = {"ada_beta_1": "beta_1", "ada_beta_2": "beta_2"} + amsgrad = {"ada_amsgrad": "amsgrad"} + self._valid: dict[str, tuple[T.Type[Optimizer], dict[str, T.Any]]] = { + "adabelief": (AdaBelief, betas | amsgrad), + "adam": (optimizers.Adam, betas | amsgrad), + "adamax": (optimizers.Adamax, betas), + "adamw": (optimizers.AdamW, betas | amsgrad), + "lion": (optimizers.Lion, betas), + "nadam": (optimizers.Nadam, betas), + "rms-prop": (optimizers.RMSprop, {})} + + self._optimizer = self._valid[cfg_opt.optimizer()][0] + self._kwargs: dict[str, T.Any] = {"learning_rate": cfg_opt.learning_rate()} + if cfg_opt.optimizer() != "lion": + self._kwargs["epsilon"] = 10 ** int(cfg_opt.epsilon_exponent()) + + self._configure() + logger.info("Using %s optimizer", self._optimizer.__name__) logger.debug("Initialized: %s", self.__class__.__name__) @property - def optimizer(self) -> tf.keras.optimizers.Optimizer: + def optimizer(self) -> optimizers.Optimizer: """ :class:`keras.optimizers.Optimizer`: The requested optimizer. """ - return self._optimizer(**self._kwargs) + return T.cast(optimizers.Optimizer, self._optimizer(**self._kwargs)) - def _configure(self, - learning_rate: float, - autoclip: bool) -> None: - """ Configure the optimizer based on user settings. + def _configure_clipping(self, + method: T.Literal["autoclip", "norm", "value", "none"], + value: float, + history: int) -> None: + """ Configure optimizer clipping related kwargs, if selected Parameters ---------- - learning_rate: float - The selected learning rate to use - autoclip: bool - ``True`` if AutoClip should be enabled otherwise ``False`` + method: Literal["autoclip", "norm", "value", "none"] + The clipping method to use. ``None`` for no clipping + value: float + The value to clip by norm/value by. For autoclip, this is the clip percentile + (a value of 1.0 is a clip percentile of 10%) + history: int + autoclip only: The number of iterations to keep for calculating the normalized value """ - self._kwargs["learning_rate"] = learning_rate - if not autoclip: + logger.debug("method: '%s', value: %s, history: %s", method, value, history) + if method == "none": + logger.debug("clipping disabled") return - logger.info("Enabling AutoClip") - self._kwargs["gradient_transformers"] = [AutoClipper(10, history_size=10000)] - logger.debug("optimizer kwargs: %s", self._kwargs) + logger.info("Enabling Clipping: %s", method.replace("_", " ").replace("_", " ").title()) + clip_types = {"global_norm": "global_clipnorm", "norm": "clipnorm", "value": "clipvalue"} + if method in clip_types: + self._kwargs[clip_types[method]] = value + logger.debug("Setting clipping kwargs for '%s': %s", + method, {k: v for k, v in self._kwargs.items() + if k == clip_types[method]}) + return + + assert method == "autoclip" + # Test for if keras optimizer changes its structure to no longer have _clip_gradients. + # Ensures any tests fails in this situation + assert hasattr(self._optimizer, + "_clip_gradients"), "keras.BaseOptimizer._clip_gradients no longer exists" + + # TODO Keras3 has removed the ""gradient_transformers" kwarg, and there now appears to be + # no standardised method to add custom gradent transformers. Currently, we monkey patch its + # _clip_gradients function, which feels hacky and potentially problematic + setattr(self._optimizer, "_clip_gradients", AutoClipper(int(value * 10), + history_size=history)) + + def _configure_ema(self, enable: bool, momentum: float, frequency: int) -> None: + """ Confihure the optimizer kwargs for exponential moving average updates + + Parameters + ---------- + enable: bool + ``False`` to disable + momentum: float + the momentum to use when computing the EMA of the model's weights: new_average = + momentum * old_average + (1 - momentum) * current_variable_value + frequency: int + the number of iterations, to overwrite the model variable by its moving average. + """ + self._kwargs["use_ema"] = enable + if not enable: + logger.debug("ema disabled.") + return + + logger.info("Enabling EMA") + self._kwargs["ema_momentum"] = momentum + self._kwargs["ema_overwrite_frequency"] = frequency + logger.debug("ema enabled (momentum: %s, frequency: %s)", momentum, frequency) + + def _configure_kwargs(self, weight_decay: float, gradient_accumulation_steps: int) -> None: + """ Configure the remaining global optimizer kwargs + + Parameters + ---------- + weight_decay: float + The amount of weight decay to apply + gradient_accumulation_steps: int + The number of steps to accumulate gradients for before applying the average + """ + if weight_decay > 0.0: + logger.info("Enabling Weight Decay: %s", weight_decay) + self._kwargs["weight_decay"] = weight_decay + else: + logger.debug("weight decay disabled") + + if gradient_accumulation_steps > 1: + logger.info("Enabling Gradient Accumulation: %s", gradient_accumulation_steps) + self._kwargs["gradient_accumulation_steps"] = gradient_accumulation_steps + else: + logger.debug("gradient accumulation disabled") + + def _configure_specific(self) -> None: + """ Configure keyword optimizer specific keyword arguments based on user settings. """ + opts = self._valid[cfg_opt.optimizer()][1] + if not opts: + logger.debug("No additional kwargs to set for '%s'", cfg_opt.optimizer()) + return + + for key, val in opts.items(): + opt_val = getattr(cfg_opt, key)() + logger.debug("Setting kwarg '%s' from '%s' to: %s", val, key, opt_val) + self._kwargs[val] = opt_val + + def _configure(self) -> None: + """ Process the user configuration options into Keras Optimizer kwargs. """ + self._configure_clipping(T.cast(T.Literal["autoclip", "norm", "value", "none"], + cfg_opt.gradient_clipping()), + cfg_opt.clipping_value(), + cfg_opt.autoclip_history()) + + self._configure_ema(cfg_opt.use_ema(), + cfg_opt.ema_momentum(), + cfg_opt.ema_frequency()) + + self._configure_kwargs(cfg_opt.weight_decay(), + cfg_opt.gradient_accumulation()) + + self._configure_specific() + + logger.debug("Configured '%s' optimizer. kwargs: %s", cfg_opt.optimizer(), self._kwargs) class Settings(): @@ -355,8 +433,6 @@ class Settings(): Faceswap's command line arguments mixed_precision: bool ``True`` if Mixed Precision training should be used otherwise ``False`` - allow_growth: bool - ``True`` if the Tensorflow allow_growth parameter should be set otherwise ``False`` is_predict: bool, optional ``True`` if the model is being loaded for inference, ``False`` if the model is being loaded for training. Default: ``False`` @@ -364,23 +440,15 @@ class Settings(): def __init__(self, arguments: Namespace, mixed_precision: bool, - allow_growth: bool, is_predict: bool) -> None: - logger.debug("Initializing %s: (arguments: %s, mixed_precision: %s, allow_growth: %s, " - "is_predict: %s)", self.__class__.__name__, arguments, mixed_precision, - allow_growth, is_predict) - self._set_tf_settings(allow_growth, arguments.exclude_gpus) - + logger.debug("Initializing %s: (arguments: %s, mixed_precision: %s, is_predict: %s)", + self.__class__.__name__, arguments, mixed_precision, is_predict) use_mixed_precision = not is_predict and mixed_precision - self._use_mixed_precision = self._set_keras_mixed_precision(use_mixed_precision) - if self._use_mixed_precision: + self._use_mixed_precision = use_mixed_precision + if use_mixed_precision: logger.info("Enabling Mixed Precision Training.") - if hasattr(arguments, "distribution_strategy"): - strategy = arguments.distribution_strategy - else: - strategy = "default" - self._strategy = self._get_strategy(strategy) + self._set_keras_mixed_precision(use_mixed_precision) logger.debug("Initialized %s", self.__class__.__name__) @property @@ -391,176 +459,155 @@ def use_mixed_precision(self) -> bool: @classmethod def loss_scale_optimizer( cls, - optimizer: tf.keras.optimizers.Optimizer) -> mixedprecision.LossScaleOptimizer: + optimizer: optimizers.Optimizer) -> optimizers.LossScaleOptimizer: """ Optimize loss scaling for mixed precision training. Parameters ---------- - optimizer: :class:`tf.keras.optimizers.Optimizer` + optimizer: :class:`keras.optimizers.Optimizer` The optimizer instance to wrap Returns -------- - :class:`tf.keras.mixed_precision.loss_scale_optimizer.LossScaleOptimizer` + :class:`keras.optimizers.LossScaleOptimizer` The original optimizer with loss scaling applied """ - return mixedprecision.LossScaleOptimizer(optimizer) # pylint:disable=no-member + return optimizers.LossScaleOptimizer(optimizer) @classmethod - def _set_tf_settings(cls, allow_growth: bool, exclude_devices: list[int]) -> None: - """ Specify Devices to place operations on and Allow TensorFlow to manage VRAM growth. - - Enables the Tensorflow allow_growth option if requested in the command line arguments + def _set_keras_mixed_precision(cls, enable: bool) -> None: + """ Enable or disable Keras Mixed Precision. Parameters ---------- - allow_growth: bool - ``True`` if the Tensorflow allow_growth parameter should be set otherwise ``False`` - exclude_devices: list or ``None`` - List of GPU device indices that should not be made available to Tensorflow. Pass - ``None`` if all devices should be made available - """ - backend = get_backend() - if backend == "cpu": - logger.verbose("Hiding GPUs from Tensorflow") # type:ignore[attr-defined] - tf.config.set_visible_devices([], "GPU") - return - - if not exclude_devices and not allow_growth: - logger.debug("Not setting any specific Tensorflow settings") - return + enable: bool + ``True`` to enable mixed precision. ``False`` to disable. - gpus = tf.config.list_physical_devices('GPU') - if exclude_devices: - gpus = [gpu for idx, gpu in enumerate(gpus) if idx not in exclude_devices] - logger.debug("Filtering devices to: %s", gpus) - tf.config.set_visible_devices(gpus, "GPU") - - if allow_growth and backend == "nvidia": - logger.debug("Setting Tensorflow 'allow_growth' option") - for gpu in gpus: - logger.info("Setting allow growth for GPU: %s", gpu) - tf.config.experimental.set_memory_growth(gpu, True) - logger.debug("Set Tensorflow 'allow_growth' option") - - @classmethod - def _set_keras_mixed_precision(cls, use_mixed_precision: bool) -> bool: - """ Enable the Keras experimental Mixed Precision API. - - Enables the Keras experimental Mixed Precision API if requested in the user configuration + Enables or disables the Keras Mixed Precision API if requested in the user configuration file. - - Parameters - ---------- - use_mixed_precision: bool - ``True`` if experimental mixed precision support should be enabled for Nvidia GPUs - otherwise ``False``. - - Returns - ------- - bool - ``True`` if mixed precision has been enabled otherwise ``False`` """ - logger.debug("use_mixed_precision: %s", use_mixed_precision) - if not use_mixed_precision: - policy = mixedprecision.Policy('float32') # pylint:disable=no-member - mixedprecision.set_global_policy(policy) # pylint:disable=no-member - logger.debug("Disabling mixed precision. (Compute dtype: %s, variable_dtype: %s)", - policy.compute_dtype, policy.variable_dtype) - return False - - policy = mixedprecision.Policy('mixed_float16') # pylint:disable=no-member - mixedprecision.set_global_policy(policy) # pylint:disable=no-member - logger.debug("Enabled mixed precision. (Compute dtype: %s, variable_dtype: %s)", + policy = dtype_policies.DTypePolicy("mixed_float16" if enable else "float32") + k_config.set_dtype_policy(policy) + logger.debug("%s mixed precision. (Compute dtype: %s, variable_dtype: %s)", + "Enabling" if enable else "Disabling", policy.compute_dtype, policy.variable_dtype) - return True - def _get_strategy(self, - strategy: T.Literal["default", "central-storage", "mirrored"] - ) -> tf.distribute.Strategy | None: - """ If we are running on Nvidia backend and the strategy is not ``None`` then return - the correct tensorflow distribution strategy, otherwise return ``None``. +# def _get_strategy(self, +# strategy: T.Literal["default", "central-storage", "mirrored"] +# ) -> tf.distribute.Strategy | None: +# """ If we are running on Nvidia backend and the strategy is not ``None`` then return +# the correct tensorflow distribution strategy, otherwise return ``None``. +# +# Notes +# ----- +# By default Tensorflow defaults mirrored strategy to use the Nvidia NCCL method for +# reductions, however this is only available in Linux, so the method used falls back to +# `Hierarchical Copy All Reduce` if the OS is not Linux. +# +# Central Storage strategy is not compatible with Mixed Precision. However, in testing it +# worked fine when using a single GPU, so we monkey-patch out the tests for Mixed-Precision +# when using this strategy with a single GPU +# +# Parameters +# ---------- +# strategy: str +# One of 'default', 'central-storage' or 'mirrored'. +# +# Returns +# ------- +# :class:`tensorflow.distribute.Strategy` or `None` +# The request Tensorflow Strategy if the backend is Nvidia and the strategy is not +# `"Default"` otherwise ``None`` +# """ +# if get_backend() not in ("nvidia", "rocm"): +# retval = None +# elif strategy == "mirrored": +# retval = self._get_mirrored_strategy() +# elif strategy == "central-storage": +# retval = self._get_central_storage_strategy() +# else: +# retval = tf.distribute.get_strategy() +# logger.debug("Using strategy: %s", retval) +# return retval + +# @classmethod +# def _get_mirrored_strategy(cls) -> tf.distribute.MirroredStrategy: +# """ Obtain an instance of a Tensorflow Mirrored Strategy, setting the cross device +# operations appropriate for the OS in use. +# +# Returns +# ------- +# :class:`tensorflow.distribute.MirroredStrategy` +# The Mirrored Distribution Strategy object with correct cross device operations set +# """ +# if platform.system().lower() == "linux": +# cross_device_ops = tf.distribute.NcclAllReduce() +# else: +# cross_device_ops = tf.distribute.HierarchicalCopyAllReduce() +# logger.debug("cross_device_ops: %s", cross_device_ops) +# return tf.distribute.MirroredStrategy(cross_device_ops=cross_device_ops) + +# @classmethod +# def _get_central_storage_strategy(cls) -> tf.distribute.experimental.CentralStorageStrategy: +# """ Obtain an instance of a Tensorflow Central Storage Strategy. If the strategy is being +# run on a single GPU then monkey patch Tensorflows mixed-precision strategy checks to pass +# successfully. +# +# Returns +# ------- +# :class:`tensorflow.distribute.experimental.CentralStorageStrategy` +# The Central Storage Distribution Strategy object +# """ +# gpus = tf.config.get_visible_devices("GPU") +# if len(gpus) == 1: +# # TODO Remove these monkey patches when Strategy supports mixed-precision +# # pylint:disable=import-outside-toplevel +# from keras.mixed_precision import loss_scale_optimizer +# +# # Force a return of True on Loss Scale Optimizer Stategy check +# loss_scale_optimizer.strategy_supports_loss_scaling = lambda: True +# +# # As LossScaleOptimizer aggregates gradients internally, it passes `False` as the value +# # for `experimental_aggregate_gradients` in `OptimizerV2.apply_gradients`. This causes +# # the optimizer to fail when checking against this strategy. We could monkey patch +# # `Optimizer.apply_gradients`, but it is a lot more code to check, so we just switch +# # the `experimental_aggregate_gradients` back to `True`. In brief testing this does not +# # appear to have a negative impact. +# func = lambda s, grads, wvars, name: s._optimizer.apply_gradients( # noqa pylint:disable=protected-access,unnecessary-lambda-assignment +# list(zip(grads, wvars.value)), name, experimental_aggregate_gradients=True) +# loss_scale_optimizer.LossScaleOptimizer._apply_gradients = func # noqa pylint:disable=protected-access + +# return tf.distribute.experimental.CentralStorageStrategy(parameter_device="/cpu:0") - Notes - ----- - By default Tensorflow defaults mirrored strategy to use the Nvidia NCCL method for - reductions, however this is only available in Linux, so the method used falls back to - `Hierarchical Copy All Reduce` if the OS is not Linux. - - Central Storage strategy is not compatible with Mixed Precision. However, in testing it - worked fine when using a single GPU, so we monkey-patch out the tests for Mixed-Precision - when using this strategy with a single GPU + @classmethod + def _dtype_from_config(cls, config: dict[str, T.Any]) -> str: + """ Obtain the dtype of a layer from the given layer config Parameters ---------- - strategy: str - One of 'default', 'central-storage' or 'mirrored'. + config: dict[str, Any] : The Keras layer configuration dictionary Returns ------- - :class:`tensorflow.distribute.Strategy` or `None` - The request Tensorflow Strategy if the backend is Nvidia and the strategy is not - `"Default"` otherwise ``None`` + str + The datatype of the layer """ - if get_backend() not in ("nvidia", "directml", "rocm"): - retval = None - elif strategy == "mirrored": - retval = self._get_mirrored_strategy() - elif strategy == "central-storage": - retval = self._get_central_storage_strategy() - else: - retval = tf.distribute.get_strategy() - logger.debug("Using strategy: %s", retval) + dtype = config["dtype"] + logger.debug("Obtaining layer dtype from config: %s", dtype) + if isinstance(dtype, str): + return dtype + # Fail tests if Keras changes the way it stores dtypes + assert isinstance(dtype, dict) and "config" in dtype, ( + "Keras config dtype storage method has changed") + + dtype_conf = dtype["config"] + # Fail tests if Keras changes the way it stores dtypes + assert isinstance(dtype_conf, dict) and "name" in dtype_conf, ( + "Keras config dtype storage method has changed") + + retval = dtype_conf["name"] return retval - @classmethod - def _get_mirrored_strategy(cls) -> tf.distribute.MirroredStrategy: - """ Obtain an instance of a Tensorflow Mirrored Strategy, setting the cross device - operations appropriate for the OS in use. - - Returns - ------- - :class:`tensorflow.distribute.MirroredStrategy` - The Mirrored Distribution Strategy object with correct cross device operations set - """ - if platform.system().lower() == "linux": - cross_device_ops = tf.distribute.NcclAllReduce() - else: - cross_device_ops = tf.distribute.HierarchicalCopyAllReduce() - logger.debug("cross_device_ops: %s", cross_device_ops) - return tf.distribute.MirroredStrategy(cross_device_ops=cross_device_ops) - - @classmethod - def _get_central_storage_strategy(cls) -> tf.distribute.experimental.CentralStorageStrategy: - """ Obtain an instance of a Tensorflow Central Storage Strategy. If the strategy is being - run on a single GPU then monkey patch Tensorflows mixed-precision strategy checks to pass - successfully. - - Returns - ------- - :class:`tensorflow.distribute.experimental.CentralStorageStrategy` - The Central Storage Distribution Strategy object - """ - gpus = tf.config.get_visible_devices("GPU") - if len(gpus) == 1: - # TODO Remove these monkey patches when Strategy supports mixed-precision - from keras.mixed_precision import loss_scale_optimizer # noqa pylint:disable=import-outside-toplevel - - # Force a return of True on Loss Scale Optimizer Stategy check - loss_scale_optimizer.strategy_supports_loss_scaling = lambda: True - - # As LossScaleOptimizer aggregates gradients internally, it passes `False` as the value - # for `experimental_aggregate_gradients` in `OptimizerV2.apply_gradients`. This causes - # the optimizer to fail when checking against this strategy. We could monkey patch - # `Optimizer.apply_gradients`, but it is a lot more code to check, so we just switch - # the `experimental_aggregate_gradients` back to `True`. In brief testing this does not - # appear to have a negative impact. - func = lambda s, grads, wvars, name: s._optimizer.apply_gradients( # noqa pylint:disable=protected-access,unnecessary-lambda-assignment - list(zip(grads, wvars.value)), name, experimental_aggregate_gradients=True) - loss_scale_optimizer.LossScaleOptimizer._apply_gradients = func # noqa pylint:disable=protected-access - - return tf.distribute.experimental.CentralStorageStrategy(parameter_device="/cpu:0") - def _get_mixed_precision_layers(self, layers: list[dict]) -> list[str]: """ Obtain the names of the layers in a mixed precision model that have their dtype policy explicitly set to mixed-float16. @@ -583,13 +630,21 @@ def _get_mixed_precision_layers(self, layers: list[dict]) -> list[str]: retval.extend(self._get_mixed_precision_layers(config["layers"])) continue - dtype = config["dtype"] - if isinstance(dtype, dict) and dtype["config"]["name"] == "mixed_float16": - logger.debug("Adding supported mixed precision layer: %s %s", layer["name"], dtype) - retval.append(layer["name"]) + if "dtype" not in config: + logger.debug("Skipping unsupported layer: %s %s", + layer.get("name", f"class_name: {layer['class_name']}"), config) + continue + dtype = self._dtype_from_config(config) + logger.debug("layer: '%s', dtype: '%s'", config["name"], dtype) + + if dtype == "mixed_float16": + logger.debug("Adding supported mixed precision layer: %s %s", + layer["config"]["name"], dtype) + retval.append(layer["config"]["name"]) else: logger.debug("Skipping unsupported layer: %s %s", - layer.get("name", f"class_name: {layer['class_name']}"), dtype) + layer["config"].get("name", f"class_name: {layer['class_name']}"), + dtype) return retval def _switch_precision(self, layers: list[dict], compatible: list[str]) -> None: @@ -603,7 +658,6 @@ def _switch_precision(self, layers: list[dict], compatible: list[str]) -> None: A list of layer names that are compatible to have their datatype switched """ dtype = "mixed_float16" if self.use_mixed_precision else "float32" - policy = {"class_name": "Policy", "config": {"name": dtype}} for layer in layers: config = layer["config"] @@ -612,19 +666,19 @@ def _switch_precision(self, layers: list[dict], compatible: list[str]) -> None: self._switch_precision(config["layers"], compatible) continue - if layer["name"] not in compatible: - logger.debug("Skipping incompatible layer: %s", layer["name"]) + if layer["config"]["name"] not in compatible: + logger.debug("Skipping incompatible layer: %s", layer["config"]["name"]) continue logger.debug("Updating dtype for %s from: %s to: %s", - layer["name"], config["dtype"], policy) - config["dtype"] = policy + layer["config"]["name"], config["dtype"], dtype) + config["dtype"] = dtype def get_mixed_precision_layers(self, - build_func: Callable[[list[tf.keras.layers.Layer]], - tf.keras.models.Model], - inputs: list[tf.keras.layers.Layer] - ) -> tuple[tf.keras.models.Model, list[str]]: + build_func: Callable[[list[keras.layers.Layer]], + keras.models.Model], + inputs: list[keras.layers.Layer] + ) -> tuple[keras.models.Model, list[str]]: """ Get and store the mixed precision layers from a full precision enabled model. Parameters @@ -636,31 +690,31 @@ def get_mixed_precision_layers(self, Returns ------- - model: :class:`tensorflow.keras.model` + model: :class:`keras.model` The built model in fp32 list The list of layer names within the full precision model that can be switched to mixed precision """ - logger.info("Storing Mixed Precision compatible layers. Please ignore any following " - "warnings about using mixed precision.") + logger.debug("Storing Mixed Precision compatible layers.") self._set_keras_mixed_precision(True) - with tf.device("CPU"): + with keras.device("CPU"): model = build_func(inputs) layers = self._get_mixed_precision_layers(model.get_config()["layers"]) - tf.keras.backend.clear_session() + del model + keras.backend.clear_session() + self._set_keras_mixed_precision(False) + reset_naming() + model = build_func(inputs) - config = model.get_config() - self._switch_precision(config["layers"], layers) - new_model = model.from_config(config) - del model - return new_model, layers + logger.debug("model: %s, mixed precision layers: %s", model, layers) + return model, layers def check_model_precision(self, - model: tf.keras.models.Model, - state: "State") -> tf.keras.models.Model: + model: keras.models.Model, + state: "State") -> keras.models.Model: """ Check the model's precision. If this is a new model, then @@ -692,6 +746,7 @@ def check_model_precision(self, return model config = model.get_config() + weights = model.get_weights() if not self.use_mixed_precision and not state.mixed_precision_layers: # Switched to Full Precision, get compatible layers from model if not already stored @@ -699,23 +754,14 @@ def check_model_precision(self, self._switch_precision(config["layers"], state.mixed_precision_layers) - new_model = keras.models.Model().from_config(config) - new_model.set_weights(model.get_weights()) - logger.info("Mixed precision has been updated from '%s' to '%s'", - not self.use_mixed_precision, self.use_mixed_precision) del model + keras.backend.clear_session() + new_model = keras.models.Model().from_config(config) + + new_model.set_weights(weights) + logger.info("Mixed precision has been %s", + "enabled" if self.use_mixed_precision else "disabled") return new_model - def strategy_scope(self) -> ContextManager: - """ Return the strategy scope if we have set a strategy, otherwise return a null - context. - Returns - ------- - :func:`tensorflow.python.distribute.Strategy.scope` or :func:`contextlib.nullcontext` - The tensorflow strategy scope if a strategy is valid in the current scenario. A null - context manager if the strategy is not valid in the current scenario - """ - retval = nullcontext() if self._strategy is None else self._strategy.scope() - logger.debug("Using strategy scope: %s", retval) - return retval +__all__ = get_module_objects(__name__) diff --git a/plugins/train/model/_base/state.py b/plugins/train/model/_base/state.py new file mode 100644 index 0000000000..54f119ffe3 --- /dev/null +++ b/plugins/train/model/_base/state.py @@ -0,0 +1,446 @@ +#! /usr/env/bin/python3 +""" Handles the loading and saving of a model's state file """ +from __future__ import annotations + +import logging +import os +import time +import typing as T +from importlib import import_module +from inspect import isclass + +from lib.logger import parse_class_init +from lib.serializer import get_serializer +from lib.utils import get_module_objects + +from lib.config.objects import ConfigItem, GlobalSection +from plugins.train import train_config as cfg + +if T.TYPE_CHECKING: + from lib.config import ConfigValueType + + +logger = logging.getLogger(__name__) + + +class State(): # pylint:disable=too-many-instance-attributes + """ Holds state information relating to the plugin's saved model. + + Parameters + ---------- + model_dir: str + The full path to the model save location + model_name: str + The name of the model plugin + no_logs: bool + ``True`` if Tensorboard logs should not be generated, otherwise ``False`` + """ + def __init__(self, + model_dir: str, + model_name: str, + no_logs: bool) -> None: + logger.debug(parse_class_init(locals())) + self._serializer = get_serializer("json") + filename = f"{model_name}_state.{self._serializer.file_extension}" + self._filename = os.path.join(model_dir, filename) + self._name = model_name + self._iterations = 0 + self._mixed_precision_layers: list[str] = [] + self._lr_finder = -1.0 + self._rebuild_model = False + self._sessions: dict[int, dict] = {} + self.lowest_avg_loss: float = 0.0 + """float: The lowest average loss seen between save intervals. """ + + self._config: dict[str, ConfigValueType] = {} + self._updateable_options: list[str] = [] + + self._load() + self._session_id = self._new_session_id() + self._create_new_session(no_logs) + logger.debug("Initialized %s:", self.__class__.__name__) + + @property + def filename(self) -> str: + """ str: Full path to the state filename """ + return self._filename + + @property + def loss_names(self) -> list[str]: + """ list: The loss names for the current session """ + return self._sessions[self._session_id]["loss_names"] + + @property + def current_session(self) -> dict: + """ dict: The state dictionary for the current :attr:`session_id`. """ + return self._sessions[self._session_id] + + @property + def iterations(self) -> int: + """ int: The total number of iterations that the model has trained. """ + return self._iterations + + @property + def session_id(self) -> int: + """ int: The current training session id. """ + return self._session_id + + @property + def sessions(self) -> dict[int, dict[str, T.Any]]: + """ dict[int, dict[str, Any]]: The session information for each session in the state + file """ + return {int(k): v for k, v in self._sessions.items()} + + @property + def mixed_precision_layers(self) -> list[str]: + """list: Layers that can be switched between mixed-float16 and float32. """ + return self._mixed_precision_layers + + @property + def lr_finder(self) -> float: + """ The value discovered from the learning rate finder. -1 if no value stored """ + return self._lr_finder + + @property + def model_needs_rebuild(self) -> bool: + """bool: ``True`` if mixed precision policy has changed so model needs to be rebuilt + otherwise ``False`` """ + return self._rebuild_model + + def _new_session_id(self) -> int: + """ Generate a new session id. Returns 1 if this is a new model, or the last session id + 1 + if it is a pre-existing model. + + Returns + ------- + int + The newly generated session id + """ + if not self._sessions: + session_id = 1 + else: + session_id = max(int(key) for key in self._sessions.keys()) + 1 + logger.debug(session_id) + return session_id + + def _create_new_session(self, no_logs: bool) -> None: + """ Initialize a new session, creating the dictionary entry for the session in + :attr:`_sessions`. + + Parameters + ---------- + no_logs: bool + ``True`` if Tensorboard logs should not be generated, otherwise ``False`` + """ + logger.debug("Creating new session. id: %s", self._session_id) + self._sessions[self._session_id] = {"timestamp": time.time(), + "no_logs": no_logs, + "loss_names": [], + "batchsize": 0, + "iterations": 0, + "config": {k: v for k, v in self._config.items() + if k in self._updateable_options}} + + def update_session_config(self, key: str, value: T.Any) -> None: + """ Update a configuration item of the currently loaded session. + + Parameters + ---------- + key: str + The configuration item to update for the current session + value: any + The value to update to + """ + old_val = self.current_session["config"][key] + assert isinstance(value, type(old_val)) + logger.debug("Updating configuration item '%s' from '%s' to '%s'", key, old_val, value) + self.current_session["config"][key] = value + + def add_session_loss_names(self, loss_names: list[str]) -> None: + """ Add the session loss names to the sessions dictionary. + + The loss names are used for Tensorboard logging + + Parameters + ---------- + loss_names: list + The list of loss names for this session. + """ + logger.debug("Adding session loss_names: %s", loss_names) + self._sessions[self._session_id]["loss_names"] = loss_names + + def add_session_batchsize(self, batch_size: int) -> None: + """ Add the session batch size to the sessions dictionary. + + Parameters + ---------- + batch_size: int + The batch size for the current training session + """ + logger.debug("Adding session batch size: %s", batch_size) + self._sessions[self._session_id]["batchsize"] = batch_size + + def increment_iterations(self) -> None: + """ Increment :attr:`iterations` and session iterations by 1. """ + self._iterations += 1 + self._sessions[self._session_id]["iterations"] += 1 + + def add_mixed_precision_layers(self, layers: list[str]) -> None: + """ Add the list of model's layers that are compatible for mixed precision to the + state dictionary """ + logger.debug("Storing mixed precision layers: %s", layers) + self._mixed_precision_layers = layers + + def add_lr_finder(self, learning_rate: float) -> None: + """ Add the optimal discovered learning rate from the learning rate finder + + Parameters + ---------- + learning_rate : float + The discovered learning rate + """ + logger.debug("Storing learning rate from LR Finder: %s", learning_rate) + self._lr_finder = learning_rate + + def save(self) -> None: + """ Save the state values to the serialized state file. """ + state = {"name": self._name, + "sessions": {k: v for k, v in self._sessions.items() + if v.get("iterations", 0) > 0}, + "lowest_avg_loss": self.lowest_avg_loss, + "iterations": self._iterations, + "mixed_precision_layers": self._mixed_precision_layers, + "lr_finder": self._lr_finder, + "config": self._config} + logger.debug("Saving State: %s", state) + self._serializer.save(self._filename, state) + logger.debug("Saved State: '%s'", self._filename) + + def _update_legacy_config(self) -> bool: + """ Legacy updates for new config additions. + + When new config items are added to the Faceswap code, existing model state files need to be + updated to handle these new items. + + Current existing legacy update items: + + * loss - If old `dssim_loss` is ``true`` set new `loss_function` to `ssim` otherwise + set it to `mae`. Remove old `dssim_loss` item + + * l2_reg_term - If this exists, set loss_function_2 to ``mse`` and loss_weight_2 to + the value held in the old ``l2_reg_term`` item + + * masks - If `learn_mask` does not exist then it is set to ``True`` if `mask_type` is + not ``None`` otherwise it is set to ``False``. + + * masks type - Replace removed masks 'dfl_full' and 'facehull' with `components` mask + + * clipnorm - Only existed in 2 models (DFL-SAE + Unbalanced). Replaced with global + option autoclip + + * Clip model - layer names have had to be changed to replace dots with underscores, so + replace these + + Returns + ------- + bool + ``True`` if legacy items exist and state file has been updated, otherwise ``False`` + """ + logger.debug("Checking for legacy state file update") + priors = ["dssim_loss", "mask_type", "mask_type", "l2_reg_term", "clipnorm", "autoclip"] + new_items = ["loss_function", "learn_mask", "mask_type", "loss_function_2", + "gradient_clipping", "clipping"] + updated = False + for old, new in zip(priors, new_items): + if old not in self._config: + logger.debug("Legacy item '%s' not in state config. Skipping update", old) + continue + + # dssim_loss > loss_function + if old == "dssim_loss": + self._config[new] = "ssim" if self._config[old] else "mae" + del self._config[old] + updated = True + logger.info("Updated state config from legacy dssim format. New config loss " + "function: '%s'", self._config[new]) + continue + + # Add learn mask option and set to True if model has "penalized_mask_loss" specified + if old == "mask_type" and new == "learn_mask" and new not in self._config: + self._config[new] = self._config["mask_type"] is not None + updated = True + logger.info("Added new 'learn_mask' state config item for this model. Value set " + "to: %s", self._config[new]) + continue + + # Replace removed masks with most similar equivalent + if old == "mask_type" and new == "mask_type" and self._config[old] in ("facehull", + "dfl_full"): + old_mask = self._config[old] + self._config[new] = "components" + updated = True + logger.info("Updated 'mask_type' from '%s' to '%s' for this model", + old_mask, self._config[new]) + + # Replace l2_reg_term with the correct loss_2_function and update the value of + # loss_2_weight + if old == "l2_reg_term": + self._config[new] = "mse" + self._config["loss_weight_2"] = self._config[old] + del self._config[old] + updated = True + logger.info("Updated state config from legacy 'l2_reg_term' to 'loss_function_2'") + + # Replace clipnorm with correct gradient clipping type and value + if old == "clipnorm": + self._config[new] = "norm" + del self._config[old] + updated = True + logger.info("Updated state config from legacy '%s' to '%s: %s'", old, new, old) + + # Replace autoclip with correct gradient clipping type + if old == "autoclip": + self._config[new] = old + del self._config[old] + updated = True + logger.info("Updated state config from legacy '%s' to '%s: %s'", old, new, old) + + # Update Clip layer names from dots to underscores + mixed_precision = self._mixed_precision_layers + if any("." in name for name in mixed_precision): + self._mixed_precision_layers = [x.replace(".", "_") for x in mixed_precision] + updated = True + logger.info("Updated state config for legacy 'mixed_precision' storage of Clip layers") + + logger.debug("State file updated for legacy config: %s", updated) + return updated + + def _get_global_options(self) -> dict[str, ConfigItem]: + """ Obtain all of the current global user config options + + Returns + ------- + dict[str, :class:`lib.config.objects.ConfigItem`] + All of the current global user configuration options + """ + objects = {key: val for key, val in vars(cfg).items() + if isinstance(val, ConfigItem) + or isclass(val) and issubclass(val, GlobalSection) and val != GlobalSection} + + retval: dict[str, ConfigItem] = {} + for key, obj in objects.items(): + if isinstance(obj, ConfigItem): + retval[key] = obj + continue + for name, opt in obj.__dict__.items(): + if isinstance(opt, ConfigItem): + retval[name] = opt + logger.debug("Loaded global config options: %s", {k: v.value for k, v in retval.items()}) + return retval + + def _get_model_options(self) -> dict[str, ConfigItem]: + """ Obtain all of the currently configured model user config options """ + mod_name = f"plugins.train.model.{self._name}_defaults" + try: + mod = import_module(mod_name) + except ModuleNotFoundError: + logger.debug("No plugin specific defaults file found at '%s'", mod_name) + return {} + + retval = {k: v for k, v in vars(mod).items() if isinstance(v, ConfigItem)} + logger.debug("Loaded '%s' config options: %s", + self._name, {k: v.value for k, v in retval.items()}) + return retval + + def _update_config(self) -> None: + """ Update the loaded training config with the one contained within the values loaded + from the state file. + + Check for any `fixed`=``False`` parameter changes and log info changes. + + Update any legacy config items to their current versions. + """ + legacy_update = self._update_legacy_config() + # Add any new items to state config for legacy purposes where the new default may be + # detrimental to an existing model. + legacy_defaults: dict[str, str | int | bool | float] = {"centering": "legacy", + "coverage": 62.5, + "mask_loss_function": "mse", + "optimizer": "adam", + "mixed_precision": False} + rebuild_tasks = ["mixed_precision"] + options = self._get_global_options() | self._get_model_options() + for key, opt in options.items(): + val: ConfigValueType = opt() + + if key not in self._config: + val = legacy_defaults.get(key, val) + logger.info("Adding new config item to state file: '%s': %s", key, repr(val)) + self._config[key] = val + + old_val = self._config[key] + old_val = "none" if old_val is None else old_val # We used to allow NoneType. No more + + if not opt.fixed: + self._updateable_options.append(key) + + if not opt.fixed and val != old_val: + self._config[key] = val + logger.info("Config item: '%s' has been updated from %s to %s", + key, repr(old_val), repr(val)) + self._rebuild_model = self._rebuild_model or key in rebuild_tasks + continue + + if val != old_val: + logger.debug("Fixed config item '%s' Updated from %s to %s from state file", + key, repr(val), repr(old_val)) + opt.set(old_val) + + if legacy_update: + self.save() + logger.info("Using configuration saved in state file") + logger.debug("Updateable items: %s", self._updateable_options) + + def _generate_config(self) -> None: + """ Generate an initial state config based on the currently selected user config """ + options = self._get_global_options() | self._get_model_options() + for key, val in options.items(): + self._config[key] = val.value + if not val.fixed: + self._updateable_options.append(key) + + logger.debug("Generated initial state config for '%s': %s", self._name, self._config) + logger.debug("Updateable items: %s", self._updateable_options) + + def _load(self) -> None: + """ Load a state file and set the serialized values to the class instance. + + Updates the model's config with the values stored in the state file. + """ + logger.debug("Loading State") + + if not os.path.exists(self._filename): + logger.info("No existing state file found. Generating.") + self._generate_config() + return + + state = self._serializer.load(self._filename) + self._name = state.get("name", self._name) + self._sessions = state.get("sessions", {}) + + self.lowest_avg_loss = state.get("lowest_avg_loss", 0.0) + if isinstance(self.lowest_avg_loss, dict): + lowest_avg_loss = sum(self.lowest_avg_loss.values()) + logger.debug("Collating legacy lowest_avg_loss from %s to %s", + self.lowest_avg_loss, lowest_avg_loss) + self.lowest_avg_loss = lowest_avg_loss + + self._iterations = state.get("iterations", 0) + self._mixed_precision_layers = state.get("mixed_precision_layers", []) + self._lr_finder = state.get("lr_finder", -1.0) + self._config = state.get("config", {}) + logger.debug("Loaded state: %s", state) + self._update_config() + + +__all__ = get_module_objects(__name__) diff --git a/plugins/train/model/_base/update.py b/plugins/train/model/_base/update.py new file mode 100644 index 0000000000..7c8b69f2c2 --- /dev/null +++ b/plugins/train/model/_base/update.py @@ -0,0 +1,496 @@ +#! /usr/env/bin/python3 +""" Updating legacy faceswap models to the current version """ +import json +import logging +import os +import typing as T +import zipfile +from shutil import copyfile, copytree + +import h5py +import numpy as np +from keras import models as kmodels + +from lib.logger import parse_class_init +from lib.model.layers import ScalarOp +from lib.model.networks import TypeModelsViT, ViT +from lib.utils import get_module_objects, FaceswapError + +logger = logging.getLogger(__name__) + + +class Legacy: # pylint:disable=too-few-public-methods + """ Handles the updating of Keras 2.x models to Keras 3.x + + Generally Keras 2.x models will open in Keras 3.x. There are a couple of bugs in Keras 3 + legacy loading code which impacts Faceswap models: + - When a model receives a shared functional model as an inbound node, the node index needs + reducing by 1 (non-trivial to fix upstream) + - Keras 3 does not accept nested outputs, so Keras 2 FS models need to have the outputs + flattened + + Parameters + ---------- + model_path: str + Full path to the legacy Keras 2.x model h5 file to upgrade + """ + def __init__(self, model_path: str): + logger.debug(parse_class_init(locals())) + self._old_model_file = model_path + """str: Full path to the old .h5 model file""" + self._new_model_file = f"{os.path.splitext(model_path)[0]}.keras" + """str: Full path to the new .keras model file""" + self._functionals: set[str] = set() + """set[str]: The name of any Functional models discovered in the keras 2 model config""" + + self._upgrade_model() + logger.debug("Initialized %s", self.__class__.__name__) + + def _get_model_config(self) -> dict[str, T.Any]: + """ Obtain a keras 2.x config from a keras 2.x .h5 file. + + As keras 3.x will error out loading the file, we collect it directly from the .h5 file + + Returns + ------- + dict[str, Any] + A keras 2.x model configuration dictionary + + Raises + ------ + FaceswapError + If the file is not a valid Faceswap 2 .h5 model file + """ + h5file = h5py.File(self._old_model_file, "r") + s_version = T.cast(str | None, h5file.attrs.get("keras_version")) + s_config = T.cast(str | None, h5file.attrs.get("model_config")) + if not s_version or not s_config: + raise FaceswapError(f"'{self._old_model_file}' is not a valid Faceswap 2 model file") + + version = s_version.split(".")[:2] + if len(version) != 2 or version[0] != "2": + raise FaceswapError(f"'{self._old_model_file}' is not a valid Faceswap 2 model file") + + retval = json.loads(s_config) + logger.debug("Loaded keras 2.x model config: %s", retval) + return retval + + @classmethod + def _unwrap_outputs(cls, outputs: list[list[T.Any]]) -> list[list[str | int]]: + """ Unwrap nested output tensors from a config dict to be a single list of output tensor + + Parameters + ---------- + outputs: list[list[Any]] + The outputs that exist within the Keras 2 config dict that may be nested + + Returns + ------- + list[list[str | int]] + The output configuration formatted to be compatible with Keras 3 + """ + retval = np.array(outputs).reshape(-1, 3).tolist() + for item in retval: + item[1] = int(item[1]) + item[2] = int(item[2]) + logger.debug("Unwrapped outputs: %s to: %s", outputs, retval) + return retval + + def _get_clip_config(self) -> dict[str, T.Any]: + """ Build a clip model from the configuration information stored in the legacy state file + + Returns + ------- + dict[str, T.Any] + The new keras configuration for a Clip model + + Raises + ------ + FaceswapError + If the clip model cannot be built + """ + state_file = f"{os.path.splitext(self._old_model_file)[0]}_state.json" + if not os.path.isfile(state_file): + raise FaceswapError( + f"The state file '{state_file}' does not exist. This model cannot be ported") + + with open(state_file, "r", encoding="utf-8") as ifile: + config = json.load(ifile) + + logger.debug("Loaded legacy config '%s': %s", state_file, config) + net_name = config.get("config", {}).get("enc_architecture", "") + scaling = config.get("config", {}).get("enc_scaling", 0) / 100 + + # Import here to prevent circular imports + from plugins.train.model.phaze_a import _MODEL_MAPPING # pylint:disable=C0415 + vit_info = _MODEL_MAPPING.get(net_name) + + if not scaling or not vit_info: + raise FaceswapError( + f"Clip network could not be found in '{state_file}'. Discovered network is " + f"'{net_name}' with encoder scaling: {scaling}. This model cannot be ported") + + input_size = int(max(vit_info.min_size, ((vit_info.default_size * scaling) // 16) * 16)) + vit_model = ViT(T.cast(TypeModelsViT, vit_info.keras_name), input_size=input_size)() + + retval = vit_model.get_config() + del vit_model + logger.debug("Got new config for '%s' at input size: %s: %s", net_name, input_size, retval) + return retval + + def _convert_lambda_config(self, layer: dict[str, T.Any]): + """ Keras 2 TFLambdaOps are not compatible with Keras 3. Scalar operations can be + relatively easily substituted with a :class:`~lib.model.layers.ScalarOp` layer + + Parameters + ---------- + layer: dict[str, Any] + An existing Keras 2 TFLambdaOp layer + + Raises + ------ + FaceswapError + If the TFLambdaOp is not currently supported + """ + name = layer["config"]["name"] + operation = name.rsplit(".", maxsplit=1)[-1] + if operation not in ("multiply", "truediv", "add", "subtract"): + raise FaceswapError(f"The TFLambdaOp '{name}' is not supported") + value = layer["inbound_nodes"][0][-1]["y"] + new_layer = ScalarOp(operation, value, name=name, dtype=layer["config"]["dtype"]) + + logger.debug("Converting legacy TFLambdaOp: %s", layer) + + layer["class_name"] = "ScalarOp" + layer["config"] = new_layer.get_config() + for n in layer["inbound_nodes"]: + n[-1] = {} + layer["inbound_nodes"] = [layer["inbound_nodes"]] + logger.debug("Converted legacy TFLambdaOp to %s", layer) + + def _process_deprecations(self, layer: dict[str, T.Any]) -> None: + """ Some layer kwargs are deprecated between Keras 2 and Keras 3. Some are not mission + critical, but updating these here prevents Keras from outputting warnings about deprecated + arguments. Others will fail to load the legacy model (eg Clip) so are replaced with a new + config. Operation is performed in place + + Parameters + ---------- + layer: dict[str, T.Any] + A keras model config item representing a keras layer + """ + if layer["class_name"] == "LeakyReLU": + # Non mission-critical, but prevents scary deprecation messages + config = layer["config"] + old, new = "alpha", "negative_slope" + if old in config: + logger.debug("Updating '%s' kwarg '%s' to '%s'", layer["name"], old, new) + config[new] = config[old] + del config[old] + + if layer["name"] == "visual": + # MultiHeadAttention is not backwards compatible, so get new config for Clip models + logger.debug("Getting new config for 'visual' model") + layer["config"] = self._get_clip_config() + + if layer["class_name"] == "TFOpLambda": + # TFLambdaOp are not supported + self._convert_lambda_config(layer) + + if layer["class_name"] == "DepthwiseConv2D" and "groups" in layer["config"]: + # groups parameter doesn't exist in Keras 3. Hopefully it still works the same + logger.debug("Removing groups from DepthwiseConv2D '%s'", layer["name"]) + del layer["config"]["groups"] + + def _process_inbounds(self, + layer_name: str, + inbound_nodes: list[list[list[str | int]]] | list[list[str | int]] + ) -> None: + """ If the inbound nodes are from a shared functional model, decrement the node index by + one. Operation is performed in place + + Parameters + ---------- + layer_name: str + The name of the layer (for logging) + inbound_nodes: list[list[list[str | int]]] | list[list[str | int]] + The inbound nodes from a Keras 2 config dict to process + """ + to_process = T.cast( + list[list[list[str | int]]], + inbound_nodes if isinstance(inbound_nodes[0][0], list) else [inbound_nodes]) + + for inbound in to_process: + for node in inbound: + name, node_index = node[0], node[1] + assert isinstance(name, str) and isinstance(node_index, int) + if name in self._functionals and node_index > 0: + logger.debug("Updating '%s' inbound node index for '%s' from %s to %s", + layer_name, name, node_index, node_index - 1) + node[1] = node_index - 1 + + def _update_layers(self, layer_list: list[dict[str, T.Any]]) -> None: + """ Given a list of keras layers from a keras 2 config dict, increment the indices for + any inbound nodes that come from a shared Functional model. Flatten any nested output + tensor lists. Operations are performed in place + + Parameters + ---------- + layers: list[dict[str, Any]] + A list of layers that belong to a keras 2 functional model config dictionary + """ + for layer in layer_list: + if layer["class_name"] == "Functional": + logger.debug("Found Functional layer. Keys: %s", list(layer)) + + if layer.get("name"): + logger.debug("Storing layer: '%s'", layer["name"]) + self._functionals.add(layer["name"]) + + layer["config"]["output_layers"] = self._unwrap_outputs( + layer["config"]["output_layers"]) + + self._update_layers(layer["config"]["layers"]) + + if not layer.get("inbound_nodes"): + continue + + self._process_deprecations(layer) + self._process_inbounds(layer["name"], layer["inbound_nodes"]) + + def _archive_model(self) -> str: + """ Archive an existing Keras 2 model to a new archive location + + Raises + ------ + FaceswapError + If the destination archive folder exists and is not empty + + Returns + ------- + str + The path to the archived keras 2 model folder + """ + model_dir = os.path.dirname(self._old_model_file) + dst_path = f"{model_dir}_fs2_backup" + if os.path.exists(dst_path) and os.listdir(dst_path): + raise FaceswapError( + f"The destination archive folder '{dst_path}' already exists. Either delete this " + "folder, select a different model folder, or remove the legacy model files from " + f"your model folder '{model_dir}'.") + + if os.path.exists(dst_path): + logger.info("Removing pre-existing empty folder '%s'", dst_path) + os.rmdir(dst_path) + + logger.info("Archiving model folder '%s' to '%s'", model_dir, dst_path) + os.rename(model_dir, dst_path) + return dst_path + + def _restore_files(self, archive_dir: str) -> None: + """ Copy the state.json file and the logs folder from the archive folder to the new model + folder + + Parameters + ---------- + archive_dir: str + The full path to the archived Keras 2 model + """ + model_dir = os.path.dirname(self._new_model_file) + model_name = os.path.splitext(os.path.basename(self._new_model_file))[0] + logger.debug("Restoring required '%s 'files from '%s' to '%s'", + model_name, archive_dir, model_dir) + + for fname in os.listdir(archive_dir): + fullpath = os.path.join(archive_dir, fname) + new_path = os.path.join(model_dir, fname) + + if fname == f"{model_name}_logs" and os.path.isdir(fullpath): + logger.debug("Restoring '%s' to '%s'", fullpath, new_path) + copytree(fullpath, new_path) + continue + + if fname == f"{model_name}_state.json" and os.path.isfile(fullpath): + logger.debug("Restoring '%s' to '%s'", fullpath, new_path) + copyfile(fullpath, new_path) + continue + + logger.debug("Skipping file: '%s'", fname) + + def _upgrade_model(self) -> None: + """ Get the model configuration of a Faceswap 2 model and upgrade it to Faceswap 3 + compatible """ + logger.info("Upgrading model file from Faceswap 2 to Faceswap 3...") + config = self._get_model_config() + self._update_layers([config]) + + logger.debug("Migrating data to new model...") + model = kmodels.Model.from_config(config["config"]) + model.load_weights(self._old_model_file) + + archive_dir = self._archive_model() + + dirname = os.path.dirname(self._new_model_file) + logger.debug("Saving model '%s'", self._new_model_file) + os.mkdir(dirname) + model.save(self._new_model_file) + logger.debug("Saved model '%s'", self._new_model_file) + + self._restore_files(archive_dir) + logger.info("Model upgraded: '%s'", dirname) + + +class PatchKerasConfig: + """ This class exists to patch breaking changes when moving from older keras 3.x models to + newer versions + + Parameters + ---------- + model_path : str + Full path to the keras model to be patched for the current version + """ + def __init__(self, model_path: str) -> None: + logger.debug(parse_class_init(locals())) + self._model_path = model_path + self._items, self._config = self._load_model() + metadata = json.loads(self._items["metadata.json"]) + self._version = tuple(int(x) for x in metadata['keras_version'].split(".")[:2]) + logger.debug("Initialized: %s", self.__class__.__name__) + + def _load_model(self) -> tuple[dict[str, bytes], dict[str, T.Any]]: + """ Load the objects from the compressed keras model + + Returns + ------- + items : dict[str, bytes] + The filename and file objects within the keras 3 model file that are not the model + config + config : dict[str, Any] + The model configuration dictionary from the keras 3 model file + """ + with zipfile.ZipFile(self._model_path, "r") as zf: + items = {f.filename: zf.read(f) for f in zf.filelist if f.filename != "config.json"} + config = json.loads(zf.read("config.json")) + + logger.debug("Loaded legacy existing items %s and 'config.json' from model '%s'", + list(items), self._model_path) + return items, config + + def _update_nn_blocks(self, layer: dict[str, T.Any]): + """ In older versions of keras our :class:`lib.model.nn_blocks.Conv2D` and + :class:`lib.model.nn_blocks.DepthwiseConv2D` inherited from their respective Keras layers. + Sometime between 3.3.3 and 3.12 (during beta testing) this stopped working, raising a + TypeError. Subsequently we have refactored those classes to no longer inherit, and call the + underlying keras layer directly instead. The keras config needs to be rewritten to reflect + this. + + Parameters + ---------- + layer dict[str, Any] + A layer config dictionary from a keras 3 model + """ + if (layer.get("module") == "lib.model.nn_blocks" and + layer.get("class_name") in ("Conv2D", "DepthwiseConv2D")): + new_module = "keras.layers" + logger.debug("Updating Keras %s layer '%s' to '%s': %s", + ".".join(str(x) for x in self._version), + f"{layer['module']}.{layer['class_name']}", + f"{new_module}.{layer['class_name']}", + layer["name"]) + layer["module"] = new_module + + def _parse_inbound_args(self, inbound: list | dict[str, T.Any]) -> None: + """ Recurse through keras inbound node args until we arrive at a dictionary + + Parameters + ---------- + list[lisr | dict[str, Any]] + A Keras inbound nodes args entry or the nested dictionary + """ + if not isinstance(inbound, (list, dict)): + return + + if isinstance(inbound, list): + for arg in inbound: + self._parse_inbound_args(arg) + return + + arg_conf = inbound["config"] + if "keras_history" not in arg_conf: + return + + if "." in arg_conf["keras_history"][0]: + new_hist = arg_conf["keras_history"][:] + new_hist[0] = new_hist[0].replace(".", "_") + logger.debug("Updating Inbound Keras history from '%s' to '%s'", + arg_conf["keras_history"], new_hist) + arg_conf["keras_history"] = new_hist + + def _update_dot_naming(self, layer: dict[str, T.Any]): + """ Sometime between 3.3.3 and 3.12 (during beta testing) layers with "." in the name + started generating a KeyError. This is odd as the error comes from Torch, but dot naming is + standard. To work around this all dots (.) in layer names have been converted to + underscores (_). The keras config needs to be rewritten to reflect this. This only impacts + FS models that used the CLiP encoder + + Parameters + ---------- + layer dict[str, Any] + A layer config dictionary from a keras 3 model + """ + if "." in layer["name"]: + new_name = layer["name"].replace(".", "_") + logger.debug("Updating Keras layer name from '%s' to '%s'", layer["name"], new_name) + layer["name"] = new_name + + config = layer["config"] + if "." in config["name"]: + new_name = config["name"].replace(".", "_") + logger.debug("Updating Keras config layer name from '%s' to '%s'", + config["name"], new_name) + config["name"] = new_name + + inbound = layer["inbound_nodes"] + for in_ in inbound: + for arg in in_["args"]: + self._parse_inbound_args(arg) + + def _update_config(self, config: dict[str, T.Any]) -> dict[str, T.Any]: + """ Recursively update the `config` dictionary from a full keras config in place + + Parameters + ---------- + config : dict[str, Any] + A 'config' section of keras config + + Returns + ------- + dict[str, Any] + The updated `config` section of a keras config + """ + layer: dict[str, T.Any] + for layer in config["layers"]: + if layer.get("class_name") == "Functional": + self._update_config(layer["config"]) + if self._version <= (3, 3): + self._update_nn_blocks(layer) + self._update_dot_naming(layer) + return config + + def _save_model(self) -> None: + """ Save the updated keras model """ + logger.info("Updating Keras model '%s'...", self._model_path) + with zipfile.ZipFile(self._model_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for filename, data in self._items.items(): + zf.writestr(filename, data) + zf.writestr("config.json", json.dumps(self._config).encode("utf-8")) + + def __call__(self) -> None: + """ Update the keras configuration saved in a keras model file and save over the original + model """ + logger.debug("Updating saved config for keras version %s", self._version) + self._config["config"] = self._update_config(self._config["config"]) + self._save_model() + + +__all__ = get_module_objects(__name__) diff --git a/plugins/train/model/dfaker.py b/plugins/train/model/dfaker.py index 0ad08357fd..fe6fb88711 100644 --- a/plugins/train/model/dfaker.py +++ b/plugins/train/model/dfaker.py @@ -4,28 +4,28 @@ import logging import sys -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras.initializers import RandomNormal # pylint:disable=import-error -from tensorflow.keras.layers import Input, LeakyReLU # pylint:disable=import-error -from tensorflow.keras.models import Model as KModel # pylint:disable=import-error +from keras import initializers, Input, layers, Model as KModel from lib.model.nn_blocks import Conv2DOutput, UpscaleBlock, ResidualBlock +from plugins.train.train_config import Loss as cfg_loss from .original import Model as OriginalModel +from . import dfaker_defaults as cfg logger = logging.getLogger(__name__) +# pylint:disable=duplicate-code class Model(OriginalModel): """ Dfaker Model """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._output_size = self.config["output_size"] + self._output_size = cfg.output_size() if self._output_size not in (128, 256): logger.error("Dfaker output shape should be 128 or 256 px") sys.exit(1) self.input_shape = (self._output_size // 2, self._output_size // 2, 3) self.encoder_dim = 1024 - self.kernel_initializer = RandomNormal(0, 0.02) + self.kernel_initializer = initializers.RandomNormal(0, 0.02) def decoder(self, side): """ Decoder Network """ @@ -34,22 +34,22 @@ def decoder(self, side): if self._output_size == 256: var_x = UpscaleBlock(1024, activation=None)(var_x) - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) var_x = ResidualBlock(1024, kernel_initializer=self.kernel_initializer)(var_x) var_x = UpscaleBlock(512, activation=None)(var_x) - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) var_x = ResidualBlock(512, kernel_initializer=self.kernel_initializer)(var_x) var_x = UpscaleBlock(256, activation=None)(var_x) - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) var_x = ResidualBlock(256, kernel_initializer=self.kernel_initializer)(var_x) var_x = UpscaleBlock(128, activation=None)(var_x) - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) var_x = ResidualBlock(128, kernel_initializer=self.kernel_initializer)(var_x) var_x = UpscaleBlock(64, activation="leakyrelu")(var_x) var_x = Conv2DOutput(3, 5, name=f"face_out_{side}")(var_x) outputs = [var_x] - if self.config.get("learn_mask", False): + if cfg_loss.learn_mask(): var_y = input_ if self._output_size == 256: var_y = UpscaleBlock(1024, activation="leakyrelu")(var_y) diff --git a/plugins/train/model/dfaker_defaults.py b/plugins/train/model/dfaker_defaults.py index bcbbeaaf9a..3ece2836a5 100644 --- a/plugins/train/model/dfaker_defaults.py +++ b/plugins/train/model/dfaker_defaults.py @@ -1,57 +1,43 @@ #!/usr/bin/env python3 +""" The default options for the faceswap Dfl_SAE Model plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: + + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does + +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) + +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ - The default options for the faceswap Dfl_SAE Model plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} - - should always be lower text. - dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid data types are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and data types this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and data types this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" +from lib.config import ConfigItem -_HELPTEXT = "Dfaker Model (Adapted from https://github.com/dfaker/df)" +HELPTEXT = "Dfaker Model (Adapted from https://github.com/dfaker/df)" -_DEFAULTS = dict( - output_size=dict( - default=128, - info="Resolution (in pixels) of the output image to generate on.\n" - "BE AWARE Larger resolution will dramatically increase VRAM requirements.\n" - "Must be 128 or 256.", - datatype=int, - rounding=128, - min_max=(128, 256), - group="size", - fixed=True)) +output_size = ConfigItem( + datatype=int, + default=128, + group="size", + info="Resolution (in pixels) of the output image to generate on.\n" + "BE AWARE Larger resolution will dramatically increase VRAM requirements.\n" + "Must be 128 or 256.", + rounding=128, + min_max=(128, 256), + fixed=True) diff --git a/plugins/train/model/dfl_h128.py b/plugins/train/model/dfl_h128.py index 2bc1e61709..c55bc46a4b 100644 --- a/plugins/train/model/dfl_h128.py +++ b/plugins/train/model/dfl_h128.py @@ -3,12 +3,12 @@ Based on https://github.com/iperov/DeepFaceLab """ -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras.layers import Dense, Flatten, Input, Reshape # noqa:E501 # pylint:disable=import-error -from tensorflow.keras.models import Model as KModel # pylint:disable=import-error +from keras import Input, layers, Model as KModel from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock +from plugins.train.train_config import Loss as cfg_loss from .original import Model as OriginalModel +from . import dfl_h128_defaults as cfg class Model(OriginalModel): @@ -16,7 +16,7 @@ class Model(OriginalModel): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.input_shape = (128, 128, 3) - self.encoder_dim = 256 if self.config["lowmem"] else 512 + self.encoder_dim = 256 if cfg.lowmem() else 512 def encoder(self): """ DFL H128 Encoder """ @@ -25,9 +25,9 @@ def encoder(self): var_x = Conv2DBlock(256, activation="leakyrelu")(var_x) var_x = Conv2DBlock(512, activation="leakyrelu")(var_x) var_x = Conv2DBlock(1024, activation="leakyrelu")(var_x) - var_x = Dense(self.encoder_dim)(Flatten()(var_x)) - var_x = Dense(8 * 8 * self.encoder_dim)(var_x) - var_x = Reshape((8, 8, self.encoder_dim))(var_x) + var_x = layers.Dense(self.encoder_dim)(layers.Flatten()(var_x)) + var_x = layers.Dense(8 * 8 * self.encoder_dim)(var_x) + var_x = layers.Reshape((8, 8, self.encoder_dim))(var_x) var_x = UpscaleBlock(self.encoder_dim, activation="leakyrelu")(var_x) return KModel(input_, var_x, name="encoder") @@ -41,7 +41,7 @@ def decoder(self, side): var_x = Conv2DOutput(3, 5, name=f"face_out_{side}")(var_x) outputs = [var_x] - if self.config.get("learn_mask", False): + if cfg_loss.learn_mask(): var_y = input_ var_y = UpscaleBlock(self.encoder_dim, activation="leakyrelu")(var_y) var_y = UpscaleBlock(self.encoder_dim // 2, activation="leakyrelu")(var_y) diff --git a/plugins/train/model/dfl_h128_defaults.py b/plugins/train/model/dfl_h128_defaults.py index 77283b8103..d1edce7789 100755 --- a/plugins/train/model/dfl_h128_defaults.py +++ b/plugins/train/model/dfl_h128_defaults.py @@ -1,60 +1,41 @@ #!/usr/bin/env python3 +""" The default options for the faceswap Dfl_H128 Model plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: + + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does + +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) + +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ - The default options for the faceswap Dfl_H128 Model plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} - - should always be lower text. - dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" +# pylint:disable=duplicate-code +from lib.config import ConfigItem -_HELPTEXT = "DFL H128 Model (Adapted from https://github.com/iperov/DeepFaceLab)" +HELPTEXT = "DFL H128 Model (Adapted from https://github.com/iperov/DeepFaceLab)" -_DEFAULTS = { - "lowmem": { - "default": False, - "info": "Lower memory mode. Set to 'True' if having issues with VRAM useage.\n" - "NB: Models with a changed lowmem mode are not compatible with each other.", - "datatype": bool, - "rounding": None, - "min_max": None, - "choices": [], - "gui_radio": False, - "group": "settings", - "fixed": True, - }, -} +lowmem = ConfigItem( + datatype=bool, + default=False, + group="settings", + info="Lower memory mode. Set to 'True' if having issues with VRAM useage.\n" + "NB: Models with a changed lowmem mode are not compatible with each other.", + fixed=True) diff --git a/plugins/train/model/dfl_sae.py b/plugins/train/model/dfl_sae.py index 0c54e0031d..f6b6686a76 100644 --- a/plugins/train/model/dfl_sae.py +++ b/plugins/train/model/dfl_sae.py @@ -6,13 +6,13 @@ import numpy as np -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras.layers import Concatenate, Dense, Flatten, Input, LeakyReLU, Reshape # noqa:E501 # pylint:disable=import-error -from tensorflow.keras.models import Model as KModel # pylint:disable=import-error +from keras import Input, layers, Model as KModel from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock +from plugins.train.train_config import Loss as cfg_loss from ._base import ModelBase +from . import dfl_sae_defaults as cfg logger = logging.getLogger(__name__) @@ -21,14 +21,12 @@ class Model(ModelBase): """ SAE Model from DFL """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.input_shape = (self.config["input_size"], self.config["input_size"], 3) - self.architecture = self.config["architecture"].lower() - self.use_mask = self.config.get("learn_mask", False) - self.multiscale_count = 3 if self.config["multiscale_decoder"] else 1 - self.encoder_dim = self.config["encoder_dims"] - self.decoder_dim = self.config["decoder_dims"] - - self._patch_weights_management() + self.input_shape = (cfg.input_size(), cfg.input_size(), 3) + self.architecture = cfg.architecture().lower() + self.use_mask = cfg_loss.learn_mask() + self.multiscale_count = 3 if cfg.multiscale_decoder() else 1 + self.encoder_dim = cfg.encoder_dims() + self.decoder_dim = cfg.decoder_dims() @property def model_name(self): @@ -38,20 +36,20 @@ def model_name(self): @property def ae_dims(self): """ Set the Autoencoder Dimensions or set to default """ - retval = self.config["autoencoder_dims"] + retval = cfg.autoencoder_dims() if retval == 0: retval = 256 if self.architecture == "liae" else 512 return retval - def _patch_weights_management(self): - """ Patch in the correct encoder name into the config dictionary for freezing and loading - weights based on architecture. - """ - self.config["freeze_layers"] = [f"encoder_{self.architecture}"] - self.config["load_layers"] = [f"encoder_{self.architecture}"] - logger.debug("Patched encoder layers to config: %s", - {k: v for k, v in self.config.items() - if k in ("freeze_layers", "load_layers")}) + @property + def freeze_layers(self) -> list[str]: + """ list[str] : The layer name for freezing based on the configured architecture """ + return [f"encoder_{self.architecture}"] + + @property + def load_layers(self) -> list[str]: + """ list[str] : The layer name for loading based on the configured architecture """ + return [f"encoder_{self.architecture}"] def build_model(self, inputs): """ Build the DFL-SAE Model """ @@ -64,15 +62,15 @@ def build_model(self, inputs): inter_both = self.inter_liae("both", enc_output_shape) int_output_shape = (np.array(inter_both.output_shape[1:]) * (1, 1, 2)).tolist() - inter_a = Concatenate()([inter_both(encoder_a), inter_both(encoder_a)]) - inter_b = Concatenate()([self.inter_liae("b", enc_output_shape)(encoder_b), - inter_both(encoder_b)]) + inter_a = layers.Concatenate()([inter_both(encoder_a), inter_both(encoder_a)]) + inter_b = layers.Concatenate()([self.inter_liae("b", enc_output_shape)(encoder_b), + inter_both(encoder_b)]) decoder = self.decoder("both", int_output_shape) - outputs = [decoder(inter_a), decoder(inter_b)] + outputs = decoder(inter_a) + decoder(inter_b) else: - outputs = [self.decoder("a", enc_output_shape)(encoder_a), - self.decoder("b", enc_output_shape)(encoder_b)] + outputs = (self.decoder("a", enc_output_shape)(encoder_a) + + self.decoder("b", enc_output_shape)(encoder_b)) autoencoder = KModel(inputs, outputs, name=self.model_name) return autoencoder @@ -85,9 +83,9 @@ def encoder_df(self): var_x = Conv2DBlock(dims * 2, activation="leakyrelu")(var_x) var_x = Conv2DBlock(dims * 4, activation="leakyrelu")(var_x) var_x = Conv2DBlock(dims * 8, activation="leakyrelu")(var_x) - var_x = Dense(self.ae_dims)(Flatten()(var_x)) - var_x = Dense(lowest_dense_res * lowest_dense_res * self.ae_dims)(var_x) - var_x = Reshape((lowest_dense_res, lowest_dense_res, self.ae_dims))(var_x) + var_x = layers.Dense(self.ae_dims)(layers.Flatten()(var_x)) + var_x = layers.Dense(lowest_dense_res * lowest_dense_res * self.ae_dims)(var_x) + var_x = layers.Reshape((lowest_dense_res, lowest_dense_res, self.ae_dims))(var_x) var_x = UpscaleBlock(self.ae_dims, activation="leakyrelu")(var_x) return KModel(input_, var_x, name="encoder_df") @@ -99,7 +97,7 @@ def encoder_liae(self): var_x = Conv2DBlock(dims * 2, activation="leakyrelu")(var_x) var_x = Conv2DBlock(dims * 4, activation="leakyrelu")(var_x) var_x = Conv2DBlock(dims * 8, activation="leakyrelu")(var_x) - var_x = Flatten()(var_x) + var_x = layers.Flatten()(var_x) return KModel(input_, var_x, name="encoder_liae") def inter_liae(self, side, input_shape): @@ -107,9 +105,9 @@ def inter_liae(self, side, input_shape): input_ = Input(shape=input_shape) lowest_dense_res = self.input_shape[0] // 16 var_x = input_ - var_x = Dense(self.ae_dims)(var_x) - var_x = Dense(lowest_dense_res * lowest_dense_res * self.ae_dims * 2)(var_x) - var_x = Reshape((lowest_dense_res, lowest_dense_res, self.ae_dims * 2))(var_x) + var_x = layers.Dense(self.ae_dims)(var_x) + var_x = layers.Dense(lowest_dense_res * lowest_dense_res * self.ae_dims * 2)(var_x) + var_x = layers.Reshape((lowest_dense_res, lowest_dense_res, self.ae_dims * 2))(var_x) var_x = UpscaleBlock(self.ae_dims * 2, activation="leakyrelu")(var_x) return KModel(input_, var_x, name=f"intermediate_{side}") @@ -122,21 +120,21 @@ def decoder(self, side, input_shape): var_x = input_ var_x1 = UpscaleBlock(dims * 8, activation=None)(var_x) - var_x1 = LeakyReLU(alpha=0.2)(var_x1) + var_x1 = layers.LeakyReLU(negative_slope=0.2)(var_x1) var_x1 = ResidualBlock(dims * 8)(var_x1) var_x1 = ResidualBlock(dims * 8)(var_x1) if self.multiscale_count >= 3: outputs.append(Conv2DOutput(3, 5, name=f"face_out_32_{side}")(var_x1)) var_x2 = UpscaleBlock(dims * 4, activation=None)(var_x1) - var_x2 = LeakyReLU(alpha=0.2)(var_x2) + var_x2 = layers.LeakyReLU(negative_slope=0.2)(var_x2) var_x2 = ResidualBlock(dims * 4)(var_x2) var_x2 = ResidualBlock(dims * 4)(var_x2) if self.multiscale_count >= 2: outputs.append(Conv2DOutput(3, 5, name=f"face_out_64_{side}")(var_x2)) var_x3 = UpscaleBlock(dims * 2, activation=None)(var_x2) - var_x3 = LeakyReLU(alpha=0.2)(var_x3) + var_x3 = layers.LeakyReLU(negative_slope=0.2)(var_x3) var_x3 = ResidualBlock(dims * 2)(var_x3) var_x3 = ResidualBlock(dims * 2)(var_x3) @@ -150,14 +148,3 @@ def decoder(self, side, input_shape): var_y = Conv2DOutput(1, 5, name=f"mask_out_{side}")(var_y) outputs.append(var_y) return KModel(input_, outputs=outputs, name=f"decoder_{side}") - - def _legacy_mapping(self): - """ The mapping of legacy separate model names to single model names """ - mappings = {"df": {f"{self.name}_encoder.h5": "encoder_df", - f"{self.name}_decoder_A.h5": "decoder_a", - f"{self.name}_decoder_B.h5": "decoder_b"}, - "liae": {f"{self.name}_encoder.h5": "encoder_liae", - f"{self.name}_intermediate_B.h5": "intermediate_both", - f"{self.name}_intermediate.h5": "intermediate_b", - f"{self.name}_decoder.h5": "decoder_both"}} - return mappings[self.config["architecture"]] diff --git a/plugins/train/model/dfl_sae_defaults.py b/plugins/train/model/dfl_sae_defaults.py index 38c43d3b66..36564143a5 100644 --- a/plugins/train/model/dfl_sae_defaults.py +++ b/plugins/train/model/dfl_sae_defaults.py @@ -1,102 +1,93 @@ #!/usr/bin/env python3 +""" The default options for the faceswap Dfl_SAE Model plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: + + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does + +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) + +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ - The default options for the faceswap Dfl_SAE Model plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} - - should always be lower text. - dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" +from lib.config import ConfigItem + + +HELPTEXT = "DFL SAE Model (Adapted from https://github.com/iperov/DeepFaceLab)" + + +input_size = ConfigItem( + datatype=int, + default=128, + group="size", + info="Resolution (in pixels) of the input image to train on.\n" + "BE AWARE Larger resolution will dramatically increase VRAM requirements.\n" + "\nMust be divisible by 16.", + rounding=16, + min_max=(64, 256), + fixed=True) + +architecture = ConfigItem( + datatype=str, + default="df", + group="network", + info="Model architecture:" + "\n\t'df': Keeps the faces more natural." + "\n\t'liae': Can help fix overly different face shapes.", + choices=["df", "liae"], + gui_radio=True, + fixed=True) + +autoencoder_dims = ConfigItem( + datatype=int, + default=0, + group="network", + info="Face information is stored in AutoEncoder dimensions. If there are not enough " + "dimensions then certain facial features may not be recognized." + "\nHigher number of dimensions are better, but require more VRAM." + "\nSet to 0 to use the architecture defaults (256 for liae, 512 for df).", + rounding=32, + min_max=(0, 1024), + fixed=True) + +encoder_dims = ConfigItem( + datatype=int, + default=42, + group="network", + info="Encoder dimensions per channel. Higher number of encoder dimensions will help " + "the model to recognize more facial features, but will require more VRAM.", + rounding=1, + min_max=(21, 85), + fixed=True) +decoder_dims = ConfigItem( + datatype=int, + default=21, + group="network", + info="Decoder dimensions per channel. Higher number of decoder dimensions will help " + "the model to improve details, but will require more VRAM.", + rounding=1, + min_max=(10, 85), + fixed=True) -_HELPTEXT = "DFL SAE Model (Adapted from https://github.com/iperov/DeepFaceLab)" - - -_DEFAULTS = dict( - input_size=dict( - default=128, - info="Resolution (in pixels) of the input image to train on.\n" - "BE AWARE Larger resolution will dramatically increase VRAM requirements.\n" - "\nMust be divisible by 16.", - datatype=int, - rounding=16, - min_max=(64, 256), - group="size", - fixed=True), - architecture=dict( - default="df", - info="Model architecture:" - "\n\t'df': Keeps the faces more natural." - "\n\t'liae': Can help fix overly different face shapes.", - datatype=str, - choices=["df", "liae"], - gui_radio=True, - fixed=True, - group="network"), - autoencoder_dims=dict( - default=0, - info="Face information is stored in AutoEncoder dimensions. If there are not enough " - "dimensions then certain facial features may not be recognized." - "\nHigher number of dimensions are better, but require more VRAM." - "\nSet to 0 to use the architecture defaults (256 for liae, 512 for df).", - datatype=int, - rounding=32, - min_max=(0, 1024), - fixed=True, - group="network"), - encoder_dims=dict( - default=42, - info="Encoder dimensions per channel. Higher number of encoder dimensions will help " - "the model to recognize more facial features, but will require more VRAM.", - datatype=int, - rounding=1, - min_max=(21, 85), - fixed=True, - group="network"), - decoder_dims=dict( - default=21, - info="Decoder dimensions per channel. Higher number of decoder dimensions will help " - "the model to improve details, but will require more VRAM.", - datatype=int, - rounding=1, - min_max=(10, 85), - fixed=True, - group="network"), - multiscale_decoder=dict( - default=False, - info="Multiscale decoder can help to obtain better details.", - datatype=bool, - fixed=True, - group="network")) +multiscale_decoder = ConfigItem( + datatype=bool, + default=False, + group="network", + info="Multiscale decoder can help to obtain better details.", + fixed=True) diff --git a/plugins/train/model/dlight.py b/plugins/train/model/dlight.py index 154b090466..07f402bfc1 100644 --- a/plugins/train/model/dlight.py +++ b/plugins/train/model/dlight.py @@ -9,17 +9,15 @@ """ import logging -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras.layers import ( # pylint:disable=import-error - AveragePooling2D, BatchNormalization, Concatenate, Dense, Dropout, Flatten, Input, Reshape, - LeakyReLU, UpSampling2D) -from tensorflow.keras.models import Model as KModel # pylint:disable=import-error +from keras import layers, Input, Model as KModel from lib.model.nn_blocks import (Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock, Upscale2xBlock) from lib.utils import FaceswapError +from plugins.train.train_config import Loss as cfg_loss from ._base import ModelBase +from . import dlight_defaults as cfg logger = logging.getLogger(__name__) @@ -32,25 +30,25 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.input_shape = (128, 128, 3) - self.features = {"lowmem": 0, "fair": 1, "best": 2}[self.config["features"]] + self.features = {"lowmem": 0, "fair": 1, "best": 2}[cfg.features()] self.encoder_filters = 64 if self.features > 0 else 48 bonum_fortunam = 128 self.encoder_dim = {0: 512 + bonum_fortunam, 1: 1024 + bonum_fortunam, 2: 1536 + bonum_fortunam}[self.features] - self.details = {"fast": 0, "good": 1}[self.config["details"]] + self.details = {"fast": 0, "good": 1}[cfg.details()] try: self.upscale_ratio = {128: 2, 256: 4, - 384: 6}[self.config["output_size"]] + 384: 6}[cfg.output_size()] except KeyError as err: logger.error("Config error: output_size must be one of: 128, 256, or 384.") raise FaceswapError("Config error: output_size must be one of: " "128, 256, or 384.") from err logger.debug("output_size: %s, features: %s, encoder_filters: %s, encoder_dim: %s, " - " details: %s, upscale_ratio: %s", self.config["output_size"], self.features, + " details: %s, upscale_ratio: %s", cfg.output_size(), self.features, self.encoder_filters, self.encoder_dim, self.details, self.upscale_ratio) def build_model(self, inputs): @@ -61,7 +59,7 @@ def build_model(self, inputs): decoder_b = self.decoder_b if self.details > 0 else self.decoder_b_fast - outputs = [self.decoder_a()(encoder_a), decoder_b()(encoder_b)] + outputs = self.decoder_a()(encoder_a) + decoder_b()(encoder_b) autoencoder = KModel(inputs, outputs, name=self.model_name) return autoencoder @@ -72,35 +70,35 @@ def encoder(self): var_x = input_ var_x1 = Conv2DBlock(self.encoder_filters // 2, activation="leakyrelu")(var_x) - var_x2 = AveragePooling2D()(var_x) - var_x2 = LeakyReLU(0.1)(var_x2) - var_x = Concatenate()([var_x1, var_x2]) + var_x2 = layers.AveragePooling2D(pool_size=(2, 2))(var_x) + var_x2 = layers.LeakyReLU(0.1)(var_x2) + var_x = layers.Concatenate()([var_x1, var_x2]) var_x1 = Conv2DBlock(self.encoder_filters, activation="leakyrelu")(var_x) - var_x2 = AveragePooling2D()(var_x) - var_x2 = LeakyReLU(0.1)(var_x2) - var_x = Concatenate()([var_x1, var_x2]) + var_x2 = layers.AveragePooling2D(pool_size=(2, 2))(var_x) + var_x2 = layers.LeakyReLU(0.1)(var_x2) + var_x = layers.Concatenate()([var_x1, var_x2]) var_x1 = Conv2DBlock(self.encoder_filters * 2, activation="leakyrelu")(var_x) - var_x2 = AveragePooling2D()(var_x) - var_x2 = LeakyReLU(0.1)(var_x2) - var_x = Concatenate()([var_x1, var_x2]) + var_x2 = layers.AveragePooling2D(pool_size=(2, 2))(var_x) + var_x2 = layers.LeakyReLU(0.1)(var_x2) + var_x = layers.Concatenate()([var_x1, var_x2]) var_x1 = Conv2DBlock(self.encoder_filters * 4, activation="leakyrelu")(var_x) - var_x2 = AveragePooling2D()(var_x) - var_x2 = LeakyReLU(0.1)(var_x2) - var_x = Concatenate()([var_x1, var_x2]) + var_x2 = layers.AveragePooling2D(pool_size=(2, 2))(var_x) + var_x2 = layers.LeakyReLU(0.1)(var_x2) + var_x = layers.Concatenate()([var_x1, var_x2]) var_x1 = Conv2DBlock(self.encoder_filters * 8, activation="leakyrelu")(var_x) - var_x2 = AveragePooling2D()(var_x) - var_x2 = LeakyReLU(0.1)(var_x2) - var_x = Concatenate()([var_x1, var_x2]) + var_x2 = layers.AveragePooling2D(pool_size=(2, 2))(var_x) + var_x2 = layers.LeakyReLU(0.1)(var_x2) + var_x = layers.Concatenate()([var_x1, var_x2]) - var_x = Dense(self.encoder_dim)(Flatten()(var_x)) - var_x = Dropout(0.05)(var_x) - var_x = Dense(4 * 4 * 1024)(var_x) - var_x = Dropout(0.05)(var_x) - var_x = Reshape((4, 4, 1024))(var_x) + var_x = layers.Dense(self.encoder_dim)(layers.Flatten()(var_x)) + var_x = layers.Dropout(0.05)(var_x) + var_x = layers.Dense(4 * 4 * 1024)(var_x) + var_x = layers.Dropout(0.05)(var_x) + var_x = layers.Reshape((4, 4, 1024))(var_x) return KModel(input_, var_x, name="encoder") @@ -111,7 +109,7 @@ def decoder_a(self): mask_complexity = 128 var_xy = input_ - var_xy = UpSampling2D(self.upscale_ratio, interpolation='bilinear')(var_xy) + var_xy = layers.UpSampling2D(self.upscale_ratio, interpolation='bilinear')(var_xy) var_x = var_xy var_x = Upscale2xBlock(dec_a_complexity, activation="leakyrelu", fast=False)(var_x) @@ -123,7 +121,7 @@ def decoder_a(self): outputs = [var_x] - if self.config.get("learn_mask", False): + if cfg_loss.learn_mask(): var_y = var_xy # mask decoder var_y = Upscale2xBlock(mask_complexity, activation="leakyrelu", fast=False)(var_y) var_y = Upscale2xBlock(mask_complexity // 2, activation="leakyrelu", fast=False)(var_y) @@ -157,7 +155,7 @@ def decoder_b_fast(self): outputs = [var_x] - if self.config.get("learn_mask", False): + if cfg_loss.learn_mask(): var_y = var_xy # mask decoder var_y = Upscale2xBlock(mask_complexity, activation="leakyrelu", fast=False)(var_y) @@ -186,31 +184,31 @@ def decoder_b(self): fast=False)(var_xy) var_x = var_xy - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) var_x = ResidualBlock(512, use_bias=True)(var_x) var_x = ResidualBlock(512, use_bias=False)(var_x) var_x = ResidualBlock(512, use_bias=False)(var_x) var_x = Upscale2xBlock(dec_b_complexity, activation=None, fast=False)(var_x) - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) var_x = ResidualBlock(dec_b_complexity, use_bias=True)(var_x) var_x = ResidualBlock(dec_b_complexity, use_bias=False)(var_x) - var_x = BatchNormalization()(var_x) + var_x = layers.BatchNormalization()(var_x) var_x = Upscale2xBlock(dec_b_complexity // 2, activation=None, fast=False)(var_x) - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) var_x = ResidualBlock(dec_b_complexity // 2, use_bias=True)(var_x) var_x = Upscale2xBlock(dec_b_complexity // 4, activation=None, fast=False)(var_x) - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) var_x = ResidualBlock(dec_b_complexity // 4, use_bias=False)(var_x) - var_x = BatchNormalization()(var_x) + var_x = layers.BatchNormalization()(var_x) var_x = Upscale2xBlock(dec_b_complexity // 8, activation="leakyrelu", fast=False)(var_x) var_x = Conv2DOutput(3, 5, name="face_out")(var_x) outputs = [var_x] - if self.config.get("learn_mask", False): + if cfg_loss.learn_mask(): var_y = var_xy # mask decoder - var_y = LeakyReLU(alpha=0.1)(var_y) + var_y = layers.LeakyReLU(negative_slope=0.1)(var_y) var_y = Upscale2xBlock(mask_complexity, activation="leakyrelu", fast=False)(var_y) var_y = Upscale2xBlock(mask_complexity // 2, activation="leakyrelu", fast=False)(var_y) @@ -222,10 +220,3 @@ def decoder_b(self): outputs.append(var_y) return KModel([input_], outputs=outputs, name="decoder_b") - - def _legacy_mapping(self): - """ The mapping of legacy separate model names to single model names """ - decoder_b = "decoder_b" if self.details > 0 else "decoder_b_fast" - return {f"{self.name}_encoder.h5": "encoder", - f"{self.name}_decoder_A.h5": "decoder_a", - f"{self.name}_decoder_B.h5": decoder_b} diff --git a/plugins/train/model/dlight_defaults.py b/plugins/train/model/dlight_defaults.py index b291b813d5..4f9d51b280 100644 --- a/plugins/train/model/dlight_defaults.py +++ b/plugins/train/model/dlight_defaults.py @@ -1,81 +1,63 @@ #!/usr/bin/env python3 -""" - The default options for the faceswap Dfaker Model plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. +""" The default options for the faceswap Dfaker Model plugin. - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} +Defaults files should be named `_defaults.py` - should always be lower text. - dictionary requirements are listed below. +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" +The following variable should be defined: + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does -_HELPTEXT = ("A lightweight, high resolution Dfaker variant " - "(Adapted from https://github.com/dfaker/df)") +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. -_DEFAULTS = dict( - features=dict( - default="best", - info="Higher settings will allow learning more features such as tatoos, piercing and " - "wrinkles.\nStrongly affects VRAM usage.", - datatype=str, - choices=["lowmem", "fair", "best"], - group="settings", - gui_radio=True, - fixed=True, - ), - details=dict( - default="good", - info="Defines detail fidelity. Lower setting can appear 'rugged' while 'good' might take " - "a longer time to train.\nAffects VRAM usage.", - datatype=str, - choices=["fast", "good"], - group="settings", - gui_radio=True, - fixed=True, - ), - output_size=dict( - default=256, - info="Output image resolution (in pixels).\nBe aware that larger resolution will increase " - "VRAM requirements.\nNB: Must be either 128, 256, or 384.", - datatype=int, - rounding=128, - min_max=(128, 384), - choices=[], - group="settings", - gui_radio=False, - fixed=True, - ), -) +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem +""" +from lib.config import ConfigItem + + +HELPTEXT = ("A lightweight, high resolution Dfaker variant " + "(Adapted from https://github.com/dfaker/df)") + + +features = ConfigItem( + datatype=str, + default="best", + group="settings", + info="Higher settings will allow learning more features such as tatoos, piercing and " + "wrinkles.\nStrongly affects VRAM usage.", + choices=["lowmem", "fair", "best"], + gui_radio=True, + fixed=True) + +details = ConfigItem( + datatype=str, + default="good", + group="settings", + info="Defines detail fidelity. Lower setting can appear 'rugged' while 'good' might take " + "a longer time to train.\nAffects VRAM usage.", + choices=["fast", "good"], + gui_radio=True, + fixed=True) + +output_size = ConfigItem( + datatype=int, + default=256, + group="settings", + info="Output image resolution (in pixels).\nBe aware that larger resolution will increase " + "VRAM requirements.\nNB: Must be either 128, 256, or 384.", + rounding=128, + min_max=(128, 384), + fixed=True) diff --git a/plugins/train/model/iae.py b/plugins/train/model/iae.py index d2690dd3be..320ec71bee 100644 --- a/plugins/train/model/iae.py +++ b/plugins/train/model/iae.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 """ Improved autoencoder for faceswap """ -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras.layers import Concatenate, Dense, Flatten, Input, Reshape # noqa:E501 # pylint:disable=import-error -from tensorflow.keras.models import Model as KModel # pylint:disable=import-error +from keras import Input, layers, Model as KModel from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock +from plugins.train.train_config import Loss as cfg_loss from ._base import ModelBase +# pylint:disable=duplicate-code class Model(ModelBase): @@ -28,8 +28,8 @@ def build_model(self, inputs): encoder_a = encoder(inputs[0]) encoder_b = encoder(inputs[1]) - outputs = [decoder(Concatenate()([inter_a(encoder_a), inter_both(encoder_a)])), - decoder(Concatenate()([inter_b(encoder_b), inter_both(encoder_b)]))] + outputs = (decoder(layers.Concatenate()([inter_a(encoder_a), inter_both(encoder_a)])) + + decoder(layers.Concatenate()([inter_b(encoder_b), inter_both(encoder_b)]))) autoencoder = KModel(inputs, outputs, name=self.model_name) return autoencoder @@ -42,15 +42,15 @@ def encoder(self): var_x = Conv2DBlock(256, activation="leakyrelu")(var_x) var_x = Conv2DBlock(512, activation="leakyrelu")(var_x) var_x = Conv2DBlock(1024, activation="leakyrelu")(var_x) - var_x = Flatten()(var_x) + var_x = layers.Flatten()(var_x) return KModel(input_, var_x, name="encoder") def intermediate(self, side): """ Intermediate Network """ input_ = Input(shape=(4 * 4 * 1024, )) - var_x = Dense(self.encoder_dim)(input_) - var_x = Dense(4 * 4 * int(self.encoder_dim/2))(var_x) - var_x = Reshape((4, 4, int(self.encoder_dim/2)))(var_x) + var_x = layers.Dense(self.encoder_dim)(input_) + var_x = layers.Dense(4 * 4 * int(self.encoder_dim/2))(var_x) + var_x = layers.Reshape((4, 4, int(self.encoder_dim/2)))(var_x) return KModel(input_, var_x, name=f"inter_{side}") def decoder(self): @@ -64,7 +64,7 @@ def decoder(self): var_x = Conv2DOutput(3, 5, name="face_out")(var_x) outputs = [var_x] - if self.config.get("learn_mask", False): + if cfg_loss.learn_mask(): var_y = input_ var_y = UpscaleBlock(512, activation="leakyrelu")(var_y) var_y = UpscaleBlock(256, activation="leakyrelu")(var_y) @@ -73,11 +73,3 @@ def decoder(self): var_y = Conv2DOutput(1, 5, name="mask_out")(var_y) outputs.append(var_y) return KModel(input_, outputs=outputs, name="decoder") - - def _legacy_mapping(self): - """ The mapping of legacy separate model names to single model names """ - return {f"{self.name}_encoder.h5": "encoder", - f"{self.name}_intermediate_A.h5": "inter_a", - f"{self.name}_intermediate_B.h5": "inter_b", - f"{self.name}_inter.h5": "inter_both", - f"{self.name}_decoder.h5": "decoder"} diff --git a/plugins/train/model/lightweight.py b/plugins/train/model/lightweight.py index 4feca05ab1..c1fc6acf53 100644 --- a/plugins/train/model/lightweight.py +++ b/plugins/train/model/lightweight.py @@ -4,10 +4,13 @@ Based on the original https://www.reddit.com/r/deepfakes/ code sample + contributions """ -from tensorflow.keras.models import Model as KModel # pylint:disable=import-error +from keras import Input, layers, Model as KModel from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock -from .original import Model as OriginalModel, Dense, Flatten, Input, Reshape +from plugins.train.train_config import Loss as cfg_loss + +from .original import Model as OriginalModel +# pylint:disable=duplicate-code class Model(OriginalModel): @@ -23,9 +26,9 @@ def encoder(self): var_x = Conv2DBlock(128, activation="leakyrelu")(var_x) var_x = Conv2DBlock(256, activation="leakyrelu")(var_x) var_x = Conv2DBlock(512, activation="leakyrelu")(var_x) - var_x = Dense(self.encoder_dim)(Flatten()(var_x)) - var_x = Dense(4 * 4 * 512)(var_x) - var_x = Reshape((4, 4, 512))(var_x) + var_x = layers.Dense(self.encoder_dim)(layers.Flatten()(var_x)) + var_x = layers.Dense(4 * 4 * 512)(var_x) + var_x = layers.Reshape((4, 4, 512))(var_x) var_x = UpscaleBlock(256, activation="leakyrelu")(var_x) return KModel(input_, var_x, name="encoder") @@ -39,7 +42,7 @@ def decoder(self, side): var_x = Conv2DOutput(3, 5, activation="sigmoid", name=f"face_out_{side}")(var_x) outputs = [var_x] - if self.config.get("learn_mask", False): + if cfg_loss.learn_mask(): var_y = input_ var_y = UpscaleBlock(512, activation="leakyrelu")(var_y) var_y = UpscaleBlock(256, activation="leakyrelu")(var_y) diff --git a/plugins/train/model/original.py b/plugins/train/model/original.py index 0613a5d55e..3637a61157 100644 --- a/plugins/train/model/original.py +++ b/plugins/train/model/original.py @@ -6,12 +6,14 @@ from. """ -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras.layers import Dense, Flatten, Reshape, Input # noqa:E501 # pylint:disable=import-error -from tensorflow.keras.models import Model as KModel # pylint:disable=import-error +from keras import Input, layers, Model as KModel from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, UpscaleBlock +from lib.utils import get_module_objects +from plugins.train.train_config import Loss as cfg_loss from ._base import ModelBase +from . import original_defaults as cfg +# pylint:disable=duplicate-code class Model(ModelBase): @@ -43,8 +45,8 @@ class Model(ModelBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.input_shape = (64, 64, 3) - self.low_mem = self.config.get("lowmem", False) - self.learn_mask = self.config["learn_mask"] + self.low_mem = cfg.lowmem() + self.learn_mask = cfg_loss.learn_mask() self.encoder_dim = 512 if self.low_mem else 1024 def build_model(self, inputs): @@ -85,10 +87,10 @@ def build_model(self, inputs): input_b = inputs[1] encoder = self.encoder() - encoder_a = [encoder(input_a)] - encoder_b = [encoder(input_b)] + encoder_a = encoder(input_a) + encoder_b = encoder(input_b) - outputs = [self.decoder("a")(encoder_a), self.decoder("b")(encoder_b)] + outputs = self.decoder("a")(encoder_a) + self.decoder("b")(encoder_b) autoencoder = KModel(inputs, outputs, name=self.model_name) return autoencoder @@ -113,9 +115,9 @@ def encoder(self): var_x = Conv2DBlock(512, activation="leakyrelu")(var_x) if not self.low_mem: var_x = Conv2DBlock(1024, activation="leakyrelu")(var_x) - var_x = Dense(self.encoder_dim)(Flatten()(var_x)) - var_x = Dense(4 * 4 * 1024)(var_x) - var_x = Reshape((4, 4, 1024))(var_x) + var_x = layers.Dense(self.encoder_dim)(layers.Flatten()(var_x)) + var_x = layers.Dense(4 * 4 * 1024)(var_x) + var_x = layers.Reshape((4, 4, 1024))(var_x) var_x = UpscaleBlock(512, activation="leakyrelu")(var_x) return KModel(input_, var_x, name="encoder") @@ -152,8 +154,5 @@ def decoder(self, side): outputs.append(var_y) return KModel(input_, outputs=outputs, name=f"decoder_{side}") - def _legacy_mapping(self): - """ The mapping of legacy separate model names to single model names """ - return {f"{self.name}_encoder.h5": "encoder", - f"{self.name}_decoder_A.h5": "decoder_a", - f"{self.name}_decoder_B.h5": "decoder_b"} + +__all__ = get_module_objects(__name__) diff --git a/plugins/train/model/original_defaults.py b/plugins/train/model/original_defaults.py index 76b8775e1d..3a519ac2f2 100755 --- a/plugins/train/model/original_defaults.py +++ b/plugins/train/model/original_defaults.py @@ -1,63 +1,41 @@ #!/usr/bin/env python3 +""" The default options for the faceswap Original Model plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: + + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does + +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) + +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ - The default options for the faceswap Original Model plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} - - should always be lower text. - dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid data types are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and data types this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and data types this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - You can also pass in a list of discreet values for this item, which should be - of the same data type as the given 'datatype'. This will lock the scale to - only those values displayed in the list. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" +# pylint:disable=duplicate-code +from lib.config import ConfigItem -_HELPTEXT = "Original Faceswap Model." +HELPTEXT = "Original Faceswap Model." -_DEFAULTS = dict( - lowmem=dict( - default=False, - info="Lower memory mode. Set to 'True' if having issues with VRAM useage.\n" - "NB: Models with a changed lowmem mode are not compatible with each other.", - datatype=bool, - rounding=None, - min_max=None, - choices=[], - gui_radio=False, - fixed=True, - group="settings", - ), -) +lowmem = ConfigItem( + datatype=bool, + default=False, + group="settings", + info="Lower memory mode. Set to 'True' if having issues with VRAM useage.\n" + "NB: Models with a changed lowmem mode are not compatible with each other.", + fixed=True) diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index d6169fe252..9ca419ef0c 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -8,24 +8,26 @@ from dataclasses import dataclass import numpy as np -import tensorflow as tf +import keras +from keras import applications as kapp, layers as kl +from lib.logger import parse_class_init from lib.model.nn_blocks import ( Conv2D, Conv2DBlock, Conv2DOutput, ResidualBlock, UpscaleBlock, Upscale2xBlock, UpscaleResizeImagesBlock, UpscaleDNYBlock) from lib.model.normalization import ( AdaInstanceNormalization, GroupNormalization, InstanceNormalization, RMSNormalization) from lib.model.networks import ViT, TypeModelsViT -from lib.utils import get_tf_version, FaceswapError +from lib.utils import get_keras_version, FaceswapError +from plugins.train.train_config import Loss as cfg_loss from ._base import ModelBase, get_all_sub_models +from . import phaze_a_defaults as cfg -logger = logging.getLogger(__name__) +if T.TYPE_CHECKING: + from keras import KerasTensor -K = tf.keras.backend -kapp = tf.keras.applications -kl = tf.keras.layers -keras = tf.keras +logger = logging.getLogger(__name__) @dataclass @@ -39,8 +41,8 @@ class _EncoderInfo: exist in Keras Applications default_size: int The default input size of the encoder - tf_min: float, optional - The lowest version of Tensorflow that the encoder can be used for. Default: `2.0` + keras_min: float, optional + The lowest version of Keras that the encoder can be used for. Default: `3.0` scaling: tuple, optional The float scaling that the encoder expects. Default: `(0, 1)` min_size: int, optional @@ -53,7 +55,7 @@ class _EncoderInfo: """ keras_name: str default_size: int - tf_min: tuple[int, int] = (2, 0) + keras_min: tuple[int, int] = (3, 0) scaling: tuple[int, int] = (0, 1) min_size: int = 32 enforce_for_weights: bool = False @@ -73,6 +75,16 @@ class _EncoderInfo: keras_name="ViT-L-14", default_size=224), "clipv_vit-l-14-336px": _EncoderInfo( keras_name="ViT-L-14-336px", default_size=336), + "convnext_tiny": _EncoderInfo( + keras_name="ConvNeXtTiny", scaling=(0, 255), default_size=224), + "convnext_small": _EncoderInfo( + keras_name="ConvNeXtSmall", scaling=(0, 255), default_size=224), + "convnext_base": _EncoderInfo( + keras_name="ConvNeXtBase", scaling=(0, 255), default_size=224), + "convnext_large": _EncoderInfo( + keras_name="ConvNeXtLarge", scaling=(0, 255), default_size=224), + "convnext_extra_large": _EncoderInfo( + keras_name="ConvNeXtXLarge", scaling=(0, 255), default_size=224), "densenet121": _EncoderInfo( keras_name="DenseNet121", default_size=224), "densenet169": _EncoderInfo( @@ -80,35 +92,35 @@ class _EncoderInfo: "densenet201": _EncoderInfo( keras_name="DenseNet201", default_size=224), "efficientnet_b0": _EncoderInfo( - keras_name="EfficientNetB0", tf_min=(2, 3), scaling=(0, 255), default_size=224), + keras_name="EfficientNetB0", scaling=(0, 255), default_size=224), "efficientnet_b1": _EncoderInfo( - keras_name="EfficientNetB1", tf_min=(2, 3), scaling=(0, 255), default_size=240), + keras_name="EfficientNetB1", scaling=(0, 255), default_size=240), "efficientnet_b2": _EncoderInfo( - keras_name="EfficientNetB2", tf_min=(2, 3), scaling=(0, 255), default_size=260), + keras_name="EfficientNetB2", scaling=(0, 255), default_size=260), "efficientnet_b3": _EncoderInfo( - keras_name="EfficientNetB3", tf_min=(2, 3), scaling=(0, 255), default_size=300), + keras_name="EfficientNetB3", scaling=(0, 255), default_size=300), "efficientnet_b4": _EncoderInfo( - keras_name="EfficientNetB4", tf_min=(2, 3), scaling=(0, 255), default_size=380), + keras_name="EfficientNetB4", scaling=(0, 255), default_size=380), "efficientnet_b5": _EncoderInfo( - keras_name="EfficientNetB5", tf_min=(2, 3), scaling=(0, 255), default_size=456), + keras_name="EfficientNetB5", scaling=(0, 255), default_size=456), "efficientnet_b6": _EncoderInfo( - keras_name="EfficientNetB6", tf_min=(2, 3), scaling=(0, 255), default_size=528), + keras_name="EfficientNetB6", scaling=(0, 255), default_size=528), "efficientnet_b7": _EncoderInfo( - keras_name="EfficientNetB7", tf_min=(2, 3), scaling=(0, 255), default_size=600), + keras_name="EfficientNetB7", scaling=(0, 255), default_size=600), "efficientnet_v2_b0": _EncoderInfo( - keras_name="EfficientNetV2B0", tf_min=(2, 8), scaling=(-1, 1), default_size=224), + keras_name="EfficientNetV2B0", scaling=(-1, 1), default_size=224), "efficientnet_v2_b1": _EncoderInfo( - keras_name="EfficientNetV2B1", tf_min=(2, 8), scaling=(-1, 1), default_size=240), + keras_name="EfficientNetV2B1", scaling=(-1, 1), default_size=240), "efficientnet_v2_b2": _EncoderInfo( - keras_name="EfficientNetV2B2", tf_min=(2, 8), scaling=(-1, 1), default_size=260), + keras_name="EfficientNetV2B2", scaling=(-1, 1), default_size=260), "efficientnet_v2_b3": _EncoderInfo( - keras_name="EfficientNetV2B3", tf_min=(2, 8), scaling=(-1, 1), default_size=300), + keras_name="EfficientNetV2B3", scaling=(-1, 1), default_size=300), "efficientnet_v2_s": _EncoderInfo( - keras_name="EfficientNetV2S", tf_min=(2, 8), scaling=(-1, 1), default_size=384), + keras_name="EfficientNetV2S", scaling=(-1, 1), default_size=384), "efficientnet_v2_m": _EncoderInfo( - keras_name="EfficientNetV2M", tf_min=(2, 8), scaling=(-1, 1), default_size=480), + keras_name="EfficientNetV2M", scaling=(-1, 1), default_size=480), "efficientnet_v2_l": _EncoderInfo( - keras_name="EfficientNetV2L", tf_min=(2, 8), scaling=(-1, 1), default_size=480), + keras_name="EfficientNetV2L", scaling=(-1, 1), default_size=480), "inception_resnet_v2": _EncoderInfo( keras_name="InceptionResNetV2", scaling=(-1, 1), min_size=75, default_size=299), "inception_v3": _EncoderInfo( @@ -118,9 +130,9 @@ class _EncoderInfo: "mobilenet_v2": _EncoderInfo( keras_name="MobileNetV2", scaling=(-1, 1), default_size=224), "mobilenet_v3_large": _EncoderInfo( - keras_name="MobileNetV3Large", tf_min=(2, 4), scaling=(-1, 1), default_size=224), + keras_name="MobileNetV3Large", scaling=(-1, 1), default_size=224), "mobilenet_v3_small": _EncoderInfo( - keras_name="MobileNetV3Small", tf_min=(2, 4), scaling=(-1, 1), default_size=224), + keras_name="MobileNetV3Small", scaling=(-1, 1), default_size=224), "nasnet_large": _EncoderInfo( keras_name="NASNetLarge", scaling=(-1, 1), default_size=331, enforce_for_weights=True), "nasnet_mobile": _EncoderInfo( @@ -163,14 +175,23 @@ class Model(ModelBase): """ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - if self.config["output_size"] % 16 != 0: + if cfg.output_size() % 16 != 0: raise FaceswapError("Phaze-A output shape must be a multiple of 16") self._validate_encoder_architecture() - self.config["freeze_layers"] = self._select_freeze_layers() self.input_shape: tuple[int, int, int] = self._get_input_shape() - self.color_order = _MODEL_MAPPING[self.config["enc_architecture"]].color_order + self.color_order = _MODEL_MAPPING[cfg.enc_architecture()].color_order + + @property + def freeze_layers(self) -> list[str]: + """ list[str] : Valid layers to freeze based on configured options """ + return self._select_real_layers(cfg.freeze_layers()) + + @property + def load_layers(self) -> list[str]: + """ list[str] : Valid layers to load based on configured options """ + return self._select_real_layers(cfg.load_layers()) def build(self) -> None: """ Build the model and assign to :attr:`model`. @@ -185,14 +206,13 @@ def build(self) -> None: self._io.model_exists, self._is_predict, is_summary) super().build() return - with self._settings.strategy_scope(): - model = self.io.load() - model = self._update_dropouts(model) - self._model = model - self._compile_model() - self._output_summary() - - def _update_dropouts(self, model: tf.keras.models.Model) -> tf.keras.models.Model: + model = self.io.load() + model = self._update_dropouts(model) + self._model = model + self._compile_model() + self._output_summary() + + def _update_dropouts(self, model: keras.models.Model) -> keras.models.Model: """ Update the saved model with new dropout rates. Keras, annoyingly, does not actually change the dropout of the underlying layer, so we need @@ -208,8 +228,7 @@ def _update_dropouts(self, model: tf.keras.models.Model) -> tf.keras.models.Mode :class:`keras.models.Model` The loaded Keras Model with the dropout rates updated """ - dropouts = {"fc": self.config["fc_dropout"], - "gblock": self.config["fc_gblock_dropout"]} + dropouts = {"fc": cfg.fc_dropout(), "gblock": cfg.fc_gblock_dropout()} logger.debug("Config dropouts: %s", dropouts) updated = False for mod in get_all_sub_models(model): @@ -238,30 +257,29 @@ def _update_dropouts(self, model: tf.keras.models.Model) -> tf.keras.models.Mode model = new_model return model - def _select_freeze_layers(self) -> list[str]: - """ Process the selected frozen layers and replace the `keras_encoder` option with the - actual keras model name + def _select_real_layers(self, layers: list[str]) -> list[str]: + """ Process the selected freeze or load layers configuration options and replace the + `keras_encoder` option with the actual keras model name for the configured architecture Returns ------- list The selected layers for weight freezing """ - arch = self.config["enc_architecture"] - layers = self.config["freeze_layers"] + arch = cfg.enc_architecture() # EfficientNetV2 is inconsistent with other model's naming conventions keras_name = _MODEL_MAPPING[arch].keras_name.replace("EfficientNetV2", "EfficientNetV2-") # CLIPv model is always called 'visual' regardless of weights/format loaded keras_name = "visual" if arch.startswith("clipv_") else keras_name - if "keras_encoder" not in self.config["freeze_layers"]: + if "keras_encoder" not in cfg.freeze_layers(): retval = layers elif keras_name: - retval = [layer.replace("keras_encoder", keras_name.lower()) for layer in layers] - logger.debug("Substituting 'keras_encoder' for '%s'", arch) + retval = [layer.replace("keras_encoder", keras_name) for layer in layers] + logger.debug("Substituting 'keras_encoder' for '%s'", keras_name) else: retval = [layer for layer in layers if layer != "keras_encoder"] - logger.debug("Removing 'keras_encoder' for '%s'", arch) + logger.debug("Removing 'keras_encoder' for '%s'", keras_name) return retval @@ -281,15 +299,15 @@ def _get_input_shape(self) -> tuple[int, int, int]: tuple The shape tuple for the input size to the Phaze-A model """ - arch = self.config["enc_architecture"] + arch = cfg.enc_architecture() enforce_size = _MODEL_MAPPING[arch].enforce_for_weights default_size = _MODEL_MAPPING[arch].default_size - scaling = self.config["enc_scaling"] / 100 + scaling = cfg.enc_scaling() / 100 min_size = _MODEL_MAPPING[arch].min_size size = int(max(min_size, ((default_size * scaling) // 16) * 16)) - if self.config["enc_load_weights"] and enforce_size and scaling != 1.0: + if cfg.enc_load_weights() and enforce_size and scaling != 1.0: logger.warning("%s requires input size to be %spx when loading imagenet weights. " "Adjusting input size from %spx to %spx", arch, default_size, size, default_size) @@ -306,25 +324,25 @@ def _validate_encoder_architecture(self) -> None: If the selection is not valid, an error is logged and system exits. """ - arch = self.config["enc_architecture"].lower() + arch = cfg.enc_architecture() model = _MODEL_MAPPING.get(arch) if not model: raise FaceswapError(f"'{arch}' is not a valid choice for encoder architecture. Choose " f"one of {list(_MODEL_MAPPING.keys())}.") - tf_ver = get_tf_version() - tf_min = model.tf_min - if tf_ver < tf_min: - raise FaceswapError(f"{arch}' is not compatible with your version of Tensorflow. The " - f"minimum version required is {tf_min} whilst you have version " - f"{tf_ver} installed.") + keras_ver = get_keras_version() + keras_min = model.keras_min + if keras_ver < keras_min: + raise FaceswapError(f"{arch}' is not compatible with your version of Keras. The " + f"minimum version required is {keras_min} whilst you have version " + f"{keras_ver} installed.") - def build_model(self, inputs: list[tf.Tensor]) -> tf.keras.models.Model: + def build_model(self, inputs: list[KerasTensor]) -> keras.models.Model: """ Create the model's structure. Parameters ---------- - inputs: list + inputs: list[:class:`keras.KerasTensor`] A list of input tensors for the model. This will be a list of 2 tensors of shape :attr:`input_shape`, the first for side "a", the second for side "b". @@ -340,16 +358,16 @@ def build_model(self, inputs: list[tf.Tensor]) -> tf.keras.models.Model: decoders = self._build_decoders(g_blocks) # Create Autoencoder - outputs = [decoders["a"], decoders["b"]] + outputs = decoders["a"] + decoders["b"] autoencoder = keras.models.Model(inputs, outputs, name=self.model_name) return autoencoder - def _build_encoders(self, inputs: list[tf.Tensor]) -> dict[str, tf.keras.models.Model]: + def _build_encoders(self, inputs: list[KerasTensor]) -> dict[str, keras.models.Model]: """ Build the encoders for Phaze-A Parameters ---------- - inputs: list + inputs: list[:class:`keras.KerasTensor`] A list of input tensors for the model. This will be a list of 2 tensors of shape :attr:`input_shape`, the first for side "a", the second for side "b". @@ -358,14 +376,14 @@ def _build_encoders(self, inputs: list[tf.Tensor]) -> dict[str, tf.keras.models. dict side as key ('a' or 'b'), encoder for side as value """ - encoder = Encoder(self.input_shape, self.config)() + encoder = Encoder(self.input_shape)() retval = {"a": encoder(inputs[0]), "b": encoder(inputs[1])} logger.debug("Encoders: %s", retval) return retval def _build_fully_connected( self, - inputs: dict[str, tf.keras.models.Model]) -> dict[str, list[tf.keras.models.Model]]: + inputs: dict[str, keras.models.Model]) -> dict[str, list[keras.models.Model]]: """ Build the fully connected layers for Phaze-A Parameters @@ -378,29 +396,33 @@ def _build_fully_connected( dict side as key ('a' or 'b'), fully connected model for side as value """ - input_shapes = K.int_shape(inputs["a"])[1:] + input_shapes = inputs["a"].shape[1:] - if self.config["split_fc"]: - fc_a = FullyConnected("a", input_shapes, self.config)() + fc_a = fc_both = None + if cfg.split_fc(): + fc_a = FullyConnected("a", input_shapes)() inter_a = [fc_a(inputs["a"])] - inter_b = [FullyConnected("b", input_shapes, self.config)()(inputs["b"])] + inter_b = [FullyConnected("b", input_shapes)()(inputs["b"])] else: - fc_both = FullyConnected("both", input_shapes, self.config)() + fc_both = FullyConnected("both", input_shapes)() inter_a = [fc_both(inputs["a"])] inter_b = [fc_both(inputs["b"])] - if self.config["shared_fc"]: - if self.config["shared_fc"] == "full": - fc_shared = FullyConnected("shared", input_shapes, self.config)() - elif self.config["split_fc"]: + shared_fc = None if cfg.shared_fc() == "none" else cfg.shared_fc() + if shared_fc: + if shared_fc == "full": + fc_shared = FullyConnected("shared", input_shapes)() + elif cfg.split_fc(): + assert fc_a is not None fc_shared = fc_a else: + assert fc_both is not None fc_shared = fc_both inter_a = [kl.Concatenate(name="inter_a")([inter_a[0], fc_shared(inputs["a"])])] inter_b = [kl.Concatenate(name="inter_b")([inter_b[0], fc_shared(inputs["b"])])] - if self.config["enable_gblock"]: - fc_gblock = FullyConnected("gblock", input_shapes, self.config)() + if cfg.enable_gblock(): + fc_gblock = FullyConnected("gblock", input_shapes)() inter_a.append(fc_gblock(inputs["a"])) inter_b.append(fc_gblock(inputs["b"])) @@ -410,8 +432,8 @@ def _build_fully_connected( def _build_g_blocks( self, - inputs: dict[str, list[tf.keras.models.Model]] - ) -> dict[str, list[tf.keras.models.Model] | tf.keras.models.Model]: + inputs: dict[str, list[keras.models.Model]] + ) -> dict[str, list[keras.models.Model] | keras.models.Model]: """ Build the g-block layers for Phaze-A. If a g-block has not been selected for this model, then the original `inters` models are @@ -428,24 +450,24 @@ def _build_g_blocks( side as key ('a' or 'b'), g-block model for side as value. If g-block has been disabled then the values will be the fully connected layers """ - if not self.config["enable_gblock"]: + if not cfg.enable_gblock(): logger.debug("No G-Block selected, returning Inters: %s", inputs) return inputs - input_shapes = [K.int_shape(inter)[1:] for inter in inputs["a"]] - if self.config["split_gblock"]: - retval = {"a": GBlock("a", input_shapes, self.config)()(inputs["a"]), - "b": GBlock("b", input_shapes, self.config)()(inputs["b"])} + input_shapes = [inter.shape[1:] for inter in inputs["a"]] + if cfg.split_gblock(): + retval = {"a": GBlock("a", input_shapes)()(inputs["a"]), + "b": GBlock("b", input_shapes)()(inputs["b"])} else: - g_block = GBlock("both", input_shapes, self.config)() + g_block = GBlock("both", input_shapes)() retval = {"a": g_block((inputs["a"])), "b": g_block((inputs["b"]))} logger.debug("G-Blocks: %s", retval) return retval def _build_decoders(self, - inputs: dict[str, list[tf.keras.models.Model] | tf.keras.models.Model] - ) -> dict[str, tf.keras.models.Model]: + inputs: dict[str, list[keras.models.Model] | keras.models.Model] + ) -> dict[str, keras.models.Model]: """ Build the encoders for Phaze-A Parameters @@ -467,28 +489,29 @@ def _build_decoders(self, # If learning a mask and upscales have been placed into FC layer, then the mask will also # come as an input - if self.config["learn_mask"] and self.config["dec_upscales_in_fc"]: + if cfg_loss.learn_mask() and cfg.dec_upscales_in_fc(): input_ = input_[0] - input_shape = K.int_shape(input_)[1:] + input_shape = input_.shape[1:] - if self.config["split_decoders"]: - retval = {"a": Decoder("a", input_shape, self.config)()(inputs["a"]), - "b": Decoder("b", input_shape, self.config)()(inputs["b"])} + if cfg.split_decoders(): + retval = {"a": Decoder("a", input_shape)()(inputs["a"]), + "b": Decoder("b", input_shape)()(inputs["b"])} else: - decoder = Decoder("both", input_shape, self.config)() + decoder = Decoder("both", input_shape)() retval = {"a": decoder(inputs["a"]), "b": decoder(inputs["b"])} logger.debug("Decoders: %s", retval) return retval -def _bottleneck(inputs: tf.Tensor, bottleneck: str, size: int, normalization: str) -> tf.Tensor: +def _bottleneck(inputs: KerasTensor, bottleneck: str, size: int, normalization: str + ) -> KerasTensor: """ The bottleneck fully connected layer. Can be called from Encoder or FullyConnected layers. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input to the bottleneck layer bottleneck: str or ``None`` The type of layer to use for the bottleneck. ``None`` to not use a bottleneck @@ -499,9 +522,10 @@ def _bottleneck(inputs: tf.Tensor, bottleneck: str, size: int, normalization: st Returns ------- - tensor + :class:`keras.KerasTensor` The output from the bottleneck """ + norm = None if normalization == "none" else normalization norms = {"layer": kl.LayerNormalization, "rms": RMSNormalization, "instance": InstanceNormalization} @@ -509,13 +533,13 @@ def _bottleneck(inputs: tf.Tensor, bottleneck: str, size: int, normalization: st "dense": kl.Dense(size), "max_pooling": kl.GlobalMaxPooling2D()} var_x = inputs - if normalization: - var_x = norms[normalization]()(var_x) - if bottleneck == "dense" and K.ndim(var_x) > 2: # Flatten non-1D inputs for dense + if norm: + var_x = norms[norm]()(var_x) + if bottleneck == "dense" and var_x.ndim > 2: # Flatten non-1D inputs for dense var_x = kl.Flatten()(var_x) if bottleneck != "flatten": var_x = bottlenecks[bottleneck](var_x) - if K.ndim(var_x) > 2: + if var_x.ndim > 2: # Flatten prior to fc layers var_x = kl.Flatten()(var_x) return var_x @@ -526,7 +550,7 @@ def _get_upscale_layer(method: T.Literal["resize_images", "subpixel", "upscale_d filters: int, activation: str | None = None, upsamples: int | None = None, - interpolation: str | None = None) -> tf.keras.layers.Layer: + interpolation: str | None = None) -> keras.layers.Layer: """ Obtain an instance of the requested upscale method. Parameters @@ -652,44 +676,42 @@ def _scale_dim(target_resolution: int, original_dim: int) -> int: return new_dim -class Encoder(): # pylint:disable=too-few-public-methods +class Encoder(): """ Encoder. Uses one of pre-existing Keras/Faceswap models or custom encoder. Parameters ---------- input_shape: tuple The shape tuple for the input tensor - config: dict - The model configuration options """ - def __init__(self, input_shape: tuple[int, int, int], config: dict) -> None: + def __init__(self, input_shape: tuple[int, int, int]) -> None: + logger.debug(parse_class_init(locals())) self.input_shape = input_shape - self._config = config self._input_shape = input_shape @property - def _model_kwargs(self) -> dict[str, dict[str, str | bool]]: + def _model_kwargs(self) -> dict[str, dict[str, float | int | bool]]: """ dict: Configuration option for architecture mapped to optional kwargs. """ - return {"mobilenet": {"alpha": self._config["mobilenet_width"], - "depth_multiplier": self._config["mobilenet_depth"], - "dropout": self._config["mobilenet_dropout"]}, - "mobilenet_v2": {"alpha": self._config["mobilenet_width"]}, - "mobilenet_v3": {"alpha": self._config["mobilenet_width"], - "minimalist": self._config["mobilenet_minimalistic"], + return {"mobilenet": {"alpha": cfg.mobilenet_width(), + "depth_multiplier": cfg.mobilenet_depth(), + "dropout": cfg.mobilenet_dropout()}, + "mobilenet_v2": {"alpha": cfg.mobilenet_width()}, + "mobilenet_v3": {"alpha": cfg.mobilenet_width(), + "minimalist": cfg.mobilenet_minimalistic(), "include_preprocessing": False}} @property def _selected_model(self) -> tuple[_EncoderInfo, dict]: """ tuple(dict, :class:`_EncoderInfo`): The selected encoder model and it's associated keyword arguments """ - arch = self._config["enc_architecture"] + arch = cfg.enc_architecture() model = _MODEL_MAPPING[arch] kwargs = self._model_kwargs.get(arch, {}) if arch.startswith("efficientnet_v2"): kwargs["include_preprocessing"] = False return model, kwargs - def __call__(self) -> tf.keras.models.Model: + def __call__(self) -> keras.models.Model: """ Create the Phaze-A Encoder Model. Returns @@ -697,14 +719,14 @@ def __call__(self) -> tf.keras.models.Model: :class:`keras.models.Model` The selected Encoder Model """ - input_ = kl.Input(shape=self._input_shape) + input_ = T.cast("KerasTensor", kl.Input(shape=self._input_shape)) var_x = input_ scaling = self._selected_model[0].scaling if scaling: # Some models expect different scaling. - logger.debug("Scaling to %s for '%s'", scaling, self._config["enc_architecture"]) + logger.debug("Scaling to %s for '%s'", scaling, cfg.enc_architecture()) if scaling == (0, 255): # models expecting inputs from 0 to 255. var_x = var_x * 255. @@ -713,30 +735,17 @@ def __call__(self) -> tf.keras.models.Model: var_x = var_x * 2. var_x = var_x - 1.0 - if (self._config["enc_architecture"].startswith("efficientnet_b") - and self._config["mixed_precision"]): - # There is a bug in EfficientNet pre-processing where the normalized mean for the - # imagenet rgb values are not cast to float16 when mixed precision is enabled. - # We monkeypatch in a cast constant until the issue is resolved - # TODO revert if/when applying Imagenet Normalization works with mixed precision - # confirmed bugged: TF2.10 - logger.debug("Patching efficientnet.IMAGENET_STDDEV_RGB to float16 constant") - from keras.applications import efficientnet # pylint:disable=import-outside-toplevel - setattr(efficientnet, - "IMAGENET_STDDEV_RGB", - K.constant(efficientnet.IMAGENET_STDDEV_RGB, dtype="float16")) - var_x = self._get_encoder_model()(var_x) - if self._config["bottleneck_in_encoder"]: + if cfg.bottleneck_in_encoder(): var_x = _bottleneck(var_x, - self._config["bottleneck_type"], - self._config["bottleneck_size"], - self._config["bottleneck_norm"]) + cfg.bottleneck_type(), + cfg.bottleneck_size(), + cfg.bottleneck_norm()) return keras.models.Model(input_, var_x, name="encoder") - def _get_encoder_model(self) -> tf.keras.models.Model: + def _get_encoder_model(self) -> keras.models.Model: """ Return the model defined by the selected architecture. Returns @@ -745,57 +754,51 @@ def _get_encoder_model(self) -> tf.keras.models.Model: The selected keras model for the chosen encoder architecture """ model, kwargs = self._selected_model - if model.keras_name and self._config["enc_architecture"].startswith("clipv_"): + if model.keras_name and cfg.enc_architecture().startswith("clipv_"): assert model.keras_name in T.get_args(TypeModelsViT) kwargs["input_shape"] = self._input_shape - kwargs["load_weights"] = self._config["enc_load_weights"] + kwargs["load_weights"] = cfg.enc_load_weights() retval = ViT(T.cast(TypeModelsViT, model.keras_name), input_size=self._input_shape[0], - load_weights=self._config["enc_load_weights"])() + load_weights=cfg.enc_load_weights())() elif model.keras_name: kwargs["input_shape"] = self._input_shape kwargs["include_top"] = False - kwargs["weights"] = "imagenet" if self._config["enc_load_weights"] else None + kwargs["weights"] = "imagenet" if cfg.enc_load_weights() else None retval = getattr(kapp, model.keras_name)(**kwargs) else: - retval = _EncoderFaceswap(self._config) + retval = _EncoderFaceswap() return retval -class _EncoderFaceswap(): # pylint:disable=too-few-public-methods - """ A configurable standard Faceswap encoder based off Original model. - - Parameters - ---------- - config: dict - The model configuration options - """ - def __init__(self, config: dict) -> None: - self._config = config - self._type = self._config["enc_architecture"] - self._depth = config[f"{self._type}_depth"] - self._min_filters = config["fs_original_min_filters"] - self._max_filters = config["fs_original_max_filters"] - self._is_alt = config["fs_original_use_alt"] +class _EncoderFaceswap(): + """ A configurable standard Faceswap encoder based off Original model. """ + def __init__(self) -> None: + logger.debug(parse_class_init(locals())) + self._type = cfg.enc_architecture() + self._depth = getattr(cfg, f"{self._type}_depth")() + self._min_filters = cfg.fs_original_min_filters() + self._max_filters = cfg.fs_original_max_filters() + self._is_alt = cfg.fs_original_use_alt() self._relu_alpha = 0.2 if self._is_alt else 0.1 self._kernel_size = 3 if self._is_alt else 5 self._strides = 1 if self._is_alt else 2 - def __call__(self, inputs: tf.Tensor) -> tf.Tensor: + def __call__(self, inputs: KerasTensor) -> KerasTensor: """ Call the original Faceswap Encoder Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input tensor to the Faceswap Encoder Returns ------- - tensor + :class:`keras.KerasTensor` The output tensor from the Faceswap Encoder """ var_x = inputs - filters = self._config["fs_original_min_filters"] + filters = cfg.fs_original_min_filters() if self._is_alt: var_x = Conv2DBlock(filters, @@ -810,7 +813,7 @@ def __call__(self, inputs: tf.Tensor) -> tf.Tensor: strides=self._strides, relu_alpha=self._relu_alpha, name=f"{name}_convblk_{i}")(var_x) - filters = min(self._config["fs_original_max_filters"], filters * 2) + filters = min(cfg.fs_original_max_filters(), filters * 2) if self._is_alt and i == self._depth - 1: var_x = Conv2DBlock(filters, kernel_size=4, @@ -824,11 +827,11 @@ def __call__(self, inputs: tf.Tensor) -> tf.Tensor: strides=self._strides, relu_alpha=self._relu_alpha, name=f"{name}_convblk_{i}_1")(var_x) - var_x = kl.MaxPool2D(2, name=f"{name}_pool_{i}")(var_x) + var_x = kl.MaxPooling2D(2, name=f"{name}_pool_{i}")(var_x) return var_x -class FullyConnected(): # pylint:disable=too-few-public-methods +class FullyConnected(): """ Intermediate Fully Connected layers for Phaze-A Model. Parameters @@ -837,19 +840,14 @@ class FullyConnected(): # pylint:disable=too-few-public-methods The side of the model that the fully connected layers belong to. Used for naming input_shape: tuple The input shape for the fully connected layers - config: dict - The user configuration dictionary """ def __init__(self, side: T.Literal["a", "b", "both", "gblock", "shared"], - input_shape: tuple, - config: dict) -> None: - logger.debug("Initializing: %s (side: %s, input_shape: %s)", - self.__class__.__name__, side, input_shape) + input_shape: tuple) -> None: + logger.debug(parse_class_init(locals())) self._side = side self._input_shape = input_shape - self._config = config - self._final_dims = self._config["fc_dimensions"] * (self._config["fc_upsamples"] + 1) + self._final_dims = cfg.fc_dimensions() * (cfg.fc_upsamples() + 1) self._prefix = "fc_gblock" if self._side == "gblock" else "fc" logger.debug("Initialized: %s (side: %s, min_nodes: %s, max_nodes: %s)", @@ -861,9 +859,9 @@ def _min_nodes(self) -> int: given minimum filters multiplied by the dimensions squared. For g-block layers, this is the given value """ if self._side == "gblock": - return self._config["fc_gblock_min_nodes"] - retval = self._scale_filters(self._config["fc_min_filters"]) - retval = int(retval * self._config["fc_dimensions"] ** 2) + return cfg.fc_gblock_min_nodes() + retval = self._scale_filters(cfg.fc_min_filters()) + retval = int(retval * cfg.fc_dimensions() ** 2) return retval @property @@ -875,9 +873,9 @@ def _max_nodes(self) -> int: For g-block layers, this is the given config value. """ if self._side == "gblock": - return self._config["fc_gblock_max_nodes"] - retval = self._scale_filters(self._config["fc_max_filters"]) - retval = int(retval * self._config["fc_dimensions"] ** 2) + return cfg.fc_gblock_max_nodes() + retval = self._scale_filters(cfg.fc_max_filters()) + retval = int(retval * cfg.fc_dimensions() ** 2) return retval def _scale_filters(self, original_filters: int) -> int: @@ -893,7 +891,7 @@ def _scale_filters(self, original_filters: int) -> int: int The number of filters scaled down for output size """ - scaled_dim = _scale_dim(self._config["output_size"], self._final_dims) + scaled_dim = _scale_dim(cfg.output_size(), self._final_dims) if scaled_dim == self._final_dims: logger.debug("filters don't require scaling. Returning: %s", original_filters) return original_filters @@ -905,22 +903,24 @@ def _scale_filters(self, original_filters: int) -> int: logger.debug("original_filters: %s, scaled_filters: %s", original_filters, retval) return retval - def _do_upsampling(self, inputs: tf.Tensor) -> tf.Tensor: + def _do_upsampling(self, inputs: KerasTensor) -> KerasTensor: """ Perform the upsampling at the end of the fully connected layers. Parameters ---------- - inputs: Tensor + inputs: :class:`keras.KerasTensor` The input to the upsample layers Returns ------- - Tensor + :class:`keras.KerasTensor` The output from the upsample layers """ - upsample_filts = self._scale_filters(self._config["fc_upsample_filters"]) - upsampler = self._config["fc_upsampler"].lower() - num_upsamples = self._config["fc_upsamples"] + upsample_filts = self._scale_filters(cfg.fc_upsample_filters()) + upsampler = T.cast(T.Literal["resize_images", "subpixel", "upscale_dny", "upscale_fast", + "upscale_hybrid", "upsample2d"], + cfg.fc_upsampler().lower()) + num_upsamples = cfg.fc_upsamples() var_x = inputs if upsampler == "upsample2d" and num_upsamples > 1: upscaler = _get_upscale_layer(upsampler, @@ -935,10 +935,10 @@ def _do_upsampling(self, inputs: tf.Tensor) -> tf.Tensor: activation="leakyrelu") var_x = upscaler(var_x) if upsampler == "upsample2d": - var_x = kl.LeakyReLU(alpha=0.1)(var_x) + var_x = kl.LeakyReLU(negative_slope=0.1)(var_x) return var_x - def __call__(self) -> tf.keras.models.Model: + def __call__(self) -> keras.models.Model: """ Call the intermediate layer. Returns @@ -947,39 +947,38 @@ def __call__(self) -> tf.keras.models.Model: The Fully connected model """ input_ = kl.Input(shape=self._input_shape) - var_x = input_ + var_x = T.cast("KerasTensor", input_) node_curve = _get_curve(self._min_nodes, self._max_nodes, - self._config[f"{self._prefix}_depth"], - self._config[f"{self._prefix}_filter_slope"]) + getattr(cfg, f"{self._prefix}_depth")(), + getattr(cfg, f"{self._prefix}_filter_slope")()) - if not self._config["bottleneck_in_encoder"]: + if not cfg.bottleneck_in_encoder(): var_x = _bottleneck(var_x, - self._config["bottleneck_type"], - self._config["bottleneck_size"], - self._config["bottleneck_norm"]) + cfg.bottleneck_type(), + cfg.bottleneck_size(), + cfg.bottleneck_norm()) - dropout = f"{self._prefix}_dropout" + dropout = getattr(cfg, f"{self._prefix}_dropout")() for idx, nodes in enumerate(node_curve): - var_x = kl.Dropout(self._config[dropout], name=f"{dropout}_{idx + 1}")(var_x) + var_x = kl.Dropout(dropout, name=f"{dropout}_{idx + 1}")(var_x) var_x = kl.Dense(nodes)(var_x) if self._side != "gblock": - dim = self._config["fc_dimensions"] + dim = cfg.fc_dimensions() var_x = kl.Reshape((dim, dim, int(self._max_nodes / (dim ** 2))))(var_x) var_x = self._do_upsampling(var_x) - num_upscales = self._config["dec_upscales_in_fc"] + num_upscales = cfg.dec_upscales_in_fc() if num_upscales: var_x = UpscaleBlocks(self._side, - self._config, layer_indicies=(0, num_upscales))(var_x) return keras.models.Model(input_, var_x, name=f"fc_{self._side}") -class UpscaleBlocks(): # pylint:disable=too-few-public-methods +class UpscaleBlocks(): """ Obtain a block of upscalers. This class exists outside of the :class:`Decoder` model, as it is possible to place some of @@ -993,8 +992,6 @@ class UpscaleBlocks(): # pylint:disable=too-few-public-methods ---------- side: ["a", "b", "both", "shared"] The side of the model that the Decoder belongs to. Used for naming - config: dict - The user configuration dictionary layer_indices: tuple, optional The tuple indicies indicating the starting layer index and the ending layer index to generate upscales for. Used for when splitting upscales between the Fully Connected Layers @@ -1005,17 +1002,14 @@ class UpscaleBlocks(): # pylint:disable=too-few-public-methods def __init__(self, side: T.Literal["a", "b", "both", "shared"], - config: dict, layer_indicies: tuple[int, int] | None = None) -> None: - logger.debug("Initializing: %s (side: %s, layer_indicies: %s)", - self.__class__.__name__, side, layer_indicies) + logger.debug(parse_class_init(locals())) self._side = side - self._config = config - self._is_dny = self._config["dec_upscale_method"].lower() == "upscale_dny" + self._is_dny = cfg.dec_upscale_method().lower() == "upscale_dny" self._layer_indicies = layer_indicies logger.debug("Initialized: %s", self.__class__.__name__,) - def _reshape_for_output(self, inputs: tf.Tensor) -> tf.Tensor: + def _reshape_for_output(self, inputs: KerasTensor) -> KerasTensor: """ Reshape the input for arbitrary output sizes. The number of filters in the input will have been scaled to the model output size allowing @@ -1023,37 +1017,37 @@ def _reshape_for_output(self, inputs: tf.Tensor) -> tf.Tensor: Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The tensor that is to be reshaped Returns ------- - tensor + :class:`keras.KerasTensor` The tensor shaped correctly to upscale to output size """ var_x = inputs - old_dim = K.int_shape(inputs)[1] - new_dim = _scale_dim(self._config["output_size"], old_dim) + old_dim = inputs.shape[1] + new_dim = _scale_dim(cfg.output_size(), old_dim) if new_dim != old_dim: - old_shape = K.int_shape(inputs)[1:] + old_shape = inputs.shape[1:] new_shape = (new_dim, new_dim, np.prod(old_shape) // new_dim ** 2) logger.debug("Reshaping tensor from %s to %s for output size %s", - K.int_shape(inputs)[1:], new_shape, self._config["output_size"]) + inputs.shape[1:], new_shape, cfg.output_size()) var_x = kl.Reshape(new_shape)(var_x) return var_x def _upscale_block(self, - inputs: tf.Tensor, + inputs: KerasTensor, filters: int, skip_residual: bool = False, - is_mask: bool = False) -> tf.Tensor: + is_mask: bool = False) -> KerasTensor: """ Upscale block for Phaze-A Decoder. Uses requested upscale method, adds requested regularization and activation function. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input tensor for the upscale block filters: int The number of filters to use for the upscale @@ -1065,81 +1059,86 @@ def _upscale_block(self, Returns ------- - tensor + :class:`keras.KerasTensor` The output tensor from the upscale block """ - upscaler = _get_upscale_layer(self._config["dec_upscale_method"].lower(), + upscaler = _get_upscale_layer(T.cast(T.Literal["resize_images", "subpixel", "upscale_dny", + "upscale_fast", "upscale_hybrid", + "upsample2d"], + cfg.dec_upscale_method()), filters, activation="leakyrelu", upsamples=2, interpolation="bilinear") var_x = upscaler(inputs) - if not is_mask and self._config["dec_gaussian"]: + if not is_mask and cfg.dec_gaussian(): var_x = kl.GaussianNoise(1.0)(var_x) - if not is_mask and self._config["dec_res_blocks"] and not skip_residual: + if not is_mask and cfg.dec_res_blocks() and not skip_residual: var_x = self._normalization(var_x) - var_x = kl.LeakyReLU(alpha=0.2)(var_x) - for _ in range(self._config["dec_res_blocks"]): + var_x = kl.LeakyReLU(negative_slope=0.2)(var_x) + for _ in range(cfg.dec_res_blocks()): var_x = ResidualBlock(filters)(var_x) else: var_x = self._normalization(var_x) if not self._is_dny: - var_x = kl.LeakyReLU(alpha=0.1)(var_x) + var_x = kl.LeakyReLU(negative_slope=0.1)(var_x) return var_x - def _normalization(self, inputs: tf.Tensor) -> tf.Tensor: + def _normalization(self, inputs: KerasTensor) -> KerasTensor: """ Add a normalization layer if requested. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input tensor to apply normalization to. Returns -------- - tensor + :class:`keras.KerasTensor` The tensor with any normalization applied """ - if not self._config["dec_norm"]: + dec_norm: str | None = cfg.dec_norm() + dec_norm = None if dec_norm == "none" else dec_norm + if not dec_norm: return inputs norms = {"batch": kl.BatchNormalization, "group": GroupNormalization, "instance": InstanceNormalization, "layer": kl.LayerNormalization, "rms": RMSNormalization} - return norms[self._config["dec_norm"]]()(inputs) + return norms[dec_norm]()(inputs) - def _dny_entry(self, inputs: tf.Tensor) -> tf.Tensor: + def _dny_entry(self, inputs: KerasTensor) -> KerasTensor: """ Entry convolutions for using the upscale_dny method. Parameters ---------- - inputs: Tensor + inputs: :class:`keras.KerasTensor` The inputs to the dny entry block Returns ------- - Tensor + :class:`keras.KerasTensor` The output from the dny entry block """ - var_x = Conv2DBlock(self._config["dec_max_filters"], + var_x = Conv2DBlock(cfg.dec_max_filters(), kernel_size=4, strides=1, padding="same", relu_alpha=0.2)(inputs) - var_x = Conv2DBlock(self._config["dec_max_filters"], + var_x = Conv2DBlock(cfg.dec_max_filters(), kernel_size=3, strides=1, padding="same", relu_alpha=0.2)(var_x) return var_x - def __call__(self, inputs: tf.Tensor | list[tf.Tensor]) -> tf.Tensor | list[tf.Tensor]: + def __call__(self, inputs: KerasTensor | list[KerasTensor]) -> KerasTensor | list[KerasTensor]: """ Upscale Network. Parameters - inputs: Tensor or list of tensors + inputs: :class:`keras.KerasTensor` | list[:class:`keras.KerasTensor`] Input tensor(s) to upscale block. This will be a single tensor if learn mask is not selected or if this is the first call to the upscale blocks. If learn mask is selected and this is not the first call to upscale blocks, then this will be a list of the face @@ -1147,18 +1146,20 @@ def __call__(self, inputs: tf.Tensor | list[tf.Tensor]) -> tf.Tensor | list[tf.T Returns ------- - Tensor or list of tensors + :class:`keras.KerasTensor` | list[:class:`keras.KerasTensor`] The output of encoder blocks. Either a single tensor (if learn mask is not enabled) or list of tensors (if learn mask is enabled) """ start_idx, end_idx = (0, None) if self._layer_indicies is None else self._layer_indicies end_idx = None if end_idx == -1 else end_idx - if self._config["learn_mask"] and start_idx == 0: + var_x: KerasTensor + var_y: KerasTensor + if cfg_loss.learn_mask() and start_idx == 0: # Mask needs to be created var_x = inputs var_y = inputs - elif self._config["learn_mask"]: + elif cfg_loss.learn_mask(): # Mask has already been created and is an input to upscale blocks var_x, var_y = inputs else: @@ -1168,36 +1169,37 @@ def __call__(self, inputs: tf.Tensor | list[tf.Tensor]) -> tf.Tensor | list[tf.T if start_idx == 0: var_x = self._reshape_for_output(var_x) - if self._config["learn_mask"]: + if cfg_loss.learn_mask(): var_y = self._reshape_for_output(var_y) if self._is_dny: var_x = self._dny_entry(var_x) - if self._is_dny and self._config["learn_mask"]: + if self._is_dny and cfg_loss.learn_mask(): var_y = self._dny_entry(var_y) # De-convolve if not self._filters: - upscales = int(np.log2(self._config["output_size"] / K.int_shape(var_x)[1])) - self._filters.extend(_get_curve(self._config["dec_max_filters"], - self._config["dec_min_filters"], + upscales = int(np.log2(cfg.output_size() / var_x.shape[1])) + self._filters.extend(_get_curve(cfg.dec_max_filters(), + cfg.dec_min_filters(), upscales, - self._config["dec_filter_slope"], - mode=self._config["dec_slope_mode"])) + cfg.dec_filter_slope(), + mode=T.cast(T.Literal["full", "cap_min", "cap_max"], + cfg.dec_slope_mode()))) logger.debug("Generated class filters: %s", self._filters) filters = self._filters[start_idx: end_idx] for idx, filts in enumerate(filters): - skip_res = idx == len(filters) - 1 and self._config["dec_skip_last_residual"] + skip_res = idx == len(filters) - 1 and cfg.dec_skip_last_residual() var_x = self._upscale_block(var_x, filts, skip_residual=skip_res) - if self._config["learn_mask"]: + if cfg_loss.learn_mask(): var_y = self._upscale_block(var_y, filts, is_mask=True) - retval = [var_x, var_y] if self._config["learn_mask"] else var_x + retval = [var_x, var_y] if cfg_loss.learn_mask() else var_x return retval -class GBlock(): # pylint:disable=too-few-public-methods +class GBlock(): """ G-Block model, borrowing from Adain StyleGAN. Parameters @@ -1208,17 +1210,10 @@ class GBlock(): # pylint:disable=too-few-public-methods The shape tuples for the input to the G-Block. The first item is the input from each side's fully connected model, the second item is the input shape from the combined fully connected model. - config: dict - The user configuration dictionary """ - def __init__(self, - side: T.Literal["a", "b", "both"], - input_shapes: list | tuple, - config: dict) -> None: - logger.debug("Initializing: %s (side: %s, input_shapes: %s)", - self.__class__.__name__, side, input_shapes) + def __init__(self, side: T.Literal["a", "b", "both"], input_shapes: list | tuple) -> None: + logger.debug(parse_class_init(locals())) self._side = side - self._config = config self._inputs = [kl.Input(shape=shape) for shape in input_shapes] self._dense_nodes = 512 self._dense_recursions = 3 @@ -1226,17 +1221,17 @@ def __init__(self, @classmethod def _g_block(cls, - inputs: tf.Tensor, - style: tf.Tensor, + inputs: KerasTensor, + style: KerasTensor, filters: int, - recursions: int = 2) -> tf.Tensor: + recursions: int = 2) -> KerasTensor: """ G_block adapted from ADAIN StyleGAN. Parameters ---------- - inputs: tensor + inputs: :class:`keras.KerasTensor` The input tensor to the G-Block model - style: tensor + style: :class:`keras.KerasTensor` The input combined 'style' tensor to the G-Block model filters: int The number of filters to use for the G-Block Convolutional layers @@ -1245,7 +1240,7 @@ def _g_block(cls, Returns ------- - tensor + :class:`keras.KerasTensor` The output tensor from the G-Block model """ var_x = inputs @@ -1262,7 +1257,7 @@ def _g_block(cls, return var_x - def __call__(self) -> tf.keras.models.Model: + def __call__(self) -> keras.models.Model: """ G-Block Network. Returns @@ -1277,14 +1272,14 @@ def __call__(self) -> tf.keras.models.Model: style = kl.LeakyReLU(0.1)(style) # Scale g_block filters to side dense - g_filts = K.int_shape(var_x)[-1] + g_filts = var_x.shape[-1] var_x = Conv2D(g_filts, 3, strides=1, padding="same")(var_x) var_x = kl.GaussianNoise(1.0)(var_x) var_x = self._g_block(var_x, style, g_filts) return keras.models.Model(self._inputs, var_x, name=f"g_block_{self._side}") -class Decoder(): # pylint:disable=too-few-public-methods +class Decoder(): """ Decoder Network. Parameters @@ -1293,21 +1288,16 @@ class Decoder(): # pylint:disable=too-few-public-methods The side of the model that the Decoder belongs to. Used for naming input_shape: tuple The shape tuple for the input to the decoder. - config: dict - The user configuration dictionary """ def __init__(self, side: T.Literal["a", "b", "both"], - input_shape: tuple[int, int, int], - config: dict) -> None: - logger.debug("Initializing: %s (side: %s, input_shape: %s)", - self.__class__.__name__, side, input_shape) + input_shape: tuple[int, int, int]) -> None: + logger.debug(parse_class_init(locals())) self._side = side self._input_shape = input_shape - self._config = config logger.debug("Initialized: %s", self.__class__.__name__,) - def __call__(self) -> tf.keras.models.Model: + def __call__(self) -> keras.models.Model: """ Decoder Network. Returns @@ -1315,28 +1305,26 @@ def __call__(self) -> tf.keras.models.Model: :class:`keras.models.Model` The Decoder model """ - inputs = kl.Input(shape=self._input_shape) + inputs = T.cast("KerasTensor", kl.Input(shape=self._input_shape)) - num_ups_in_fc = self._config["dec_upscales_in_fc"] + num_ups_in_fc = cfg.dec_upscales_in_fc() - if self._config["learn_mask"] and num_ups_in_fc: + if cfg_loss.learn_mask() and num_ups_in_fc: # Mask has already been created in FC and is an output of that model inputs = [inputs, kl.Input(shape=self._input_shape)] indicies = None if not num_ups_in_fc else (num_ups_in_fc, -1) - upscales = UpscaleBlocks(self._side, - self._config, - layer_indicies=indicies)(inputs) + upscales = UpscaleBlocks(self._side, layer_indicies=indicies)(inputs) - if self._config["learn_mask"]: + if cfg_loss.learn_mask(): var_x, var_y = upscales else: var_x = upscales - outputs = [Conv2DOutput(3, self._config["dec_output_kernel"], name="face_out")(var_x)] - if self._config["learn_mask"]: + outputs = [Conv2DOutput(3, cfg.dec_output_kernel(), name="face_out")(var_x)] + if cfg_loss.learn_mask(): outputs.append(Conv2DOutput(1, - self._config["dec_output_kernel"], + cfg.dec_output_kernel(), name="mask_out")(var_y)) return keras.models.Model(inputs, outputs=outputs, name=f"decoder_{self._side}") diff --git a/plugins/train/model/phaze_a_defaults.py b/plugins/train/model/phaze_a_defaults.py index 8cff3d6458..0650d79064 100644 --- a/plugins/train/model/phaze_a_defaults.py +++ b/plugins/train/model/phaze_a_defaults.py @@ -1,46 +1,34 @@ #!/usr/bin/env python3 +""" The default options for the faceswap Phaze-A Model plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: + + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does + +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) + +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ - The default options for the faceswap Phaze-A Model plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - "_HELPTEXT: A string describing what this plugin does - "_DEFAULTS: A dictionary containing the options, defaults and meta information. The - " dictionary should be defined as: - " {: {}} - - " should always be lower text. - " dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - "datatype: [required] A python type class. This limits the type of data that can be - " provided in the .ini file and ensures that the value is returned in the - " correct type to faceswap. Valid data types are: , , - " , . - "default: [required] The default value for this option. - "info: [required] A string describing what this option does. - "choices: [optional] If this option's datatype is of then valid - " selections can be defined here. This validates the option and also enables - " a combobox / radio option in the GUI. - "gui_radio: [optional] If are defined, this indicates that the GUI should use - " radio buttons rather than a combobox to display this option. - "min_max: [partial] For and data types this is required - " otherwise it is ignored. Should be a tuple of min and max accepted values. - " This is used for controlling the GUI slider range. Values are not enforced. - "rounding: [partial] For and data types this is - " required otherwise it is ignored. Used for the GUI slider. For floats, this - " is the number of decimal places to display. For ints this is the step size. - "fixed: [optional] [train only]. Training configurations are fixed when the model is - " created, and then reloaded from the state file. Marking an item as fixed=False - " indicates that this value can be changed for existing models, and will override - " the value saved in the state file with the updated value in config. If not - " provided this will default to True. -""" +from lib.config import ConfigItem -_HELPTEXT: str = ( + +HELPTEXT: str = ( "Phaze-A Model by TorzDF, with thanks to BirbFakes.\n" "Allows for the experimentation of various standard Networks as the encoder and takes " "inspiration from Nvidia's StyleGAN for the Decoder. It is highly recommended to research to " @@ -49,6 +37,7 @@ _ENCODERS: list[str] = sorted([ "clipv_vit-b-16", "clipv_vit-b-32", "clipv_vit-l-14", "clipv_vit-l-14-336px", "clipv_farl-b-16-16", "clipv_farl-b-16-64", + "convnext_tiny", "convnext_small", "convnext_base", "convnext_large", "convnext_extra_large", "densenet121", "densenet169", "densenet201", "efficientnet_b0", "efficientnet_b1", "efficientnet_b2", "efficientnet_b3", "efficientnet_b4", "efficientnet_b5", "efficientnet_b6", "efficientnet_b7", "efficientnet_v2_b0", "efficientnet_v2_b1", "efficientnet_v2_b2", @@ -57,672 +46,669 @@ "mobilenet_v3_small", "nasnet_large", "nasnet_mobile", "resnet50", "resnet50_v2", "resnet101", "resnet101_v2", "resnet152", "resnet152_v2", "vgg16", "vgg19", "xception", "fs_original"]) -_DEFAULTS = { - # General - "output_size": { - "default": 128, - "info": ( - "Resolution (in pixels) of the output image to generate.\n" - "BE AWARE Larger resolution will dramatically increase VRAM requirements."), - "datatype": int, - "rounding": 16, - "min_max": (64, 2048), - "group": "general", - "fixed": True}, - "shared_fc": { - "default": "none", - "info": ( - "Whether to create a shared fully connected layer. This layer will have the same " - "structure as the fully connected layers used for each side of the model. A shared " - "fully connected layer looks for patterns that are common to both sides. NB: " - "Enabling this option only makes sense if 'split fc' is selected." - "\n\tnone - Do not create a Fully Connected layer for shared data. (Original method)" - "\n\tfull - Create an exclusive Fully Connected layer for shared data. (IAE method)" - "\n\thalf - Use the 'fc_a' layer for shared data. This saves VRAM by re-using the " - "'A' side's fully connected model for the shared data. However, this will lead to " - "an 'unbalanced' model and can lead to more identity bleed (DFL method)"), - "datatype": str, - "choices": ["none", "full", "half"], - "gui_radio": True, - "group": "general", - "fixed": True}, - "enable_gblock": { - "default": True, - "info": ( - "Whether to enable the G-Block. If enabled, this will create a shared fully " - "connected layer (configurable in the 'G-Block hidden layers' section) to look for " - "patterns in the combined data, before feeding a block prior to the decoder for " - "merging this shared and combined data." - "\n\tTrue - Use the G-Block in the Decoder. A combined fully connected layer will be " - "created to feed this block which can be configured below." - "\n\tFalse - Don't use the G-Block in the decoder. No combined fully connected layer " - "will be created."), - "datatype": bool, - "group": "general", - "fixed": True}, - "split_fc": { - "default": True, - "info": ( - "Whether to use a single shared Fully Connected layer or separate Fully Connected " - "layers for each side." - "\n\tTrue - Use separate Fully Connected layers for Face A and Face B. This is more " - "similar to the 'IAE' style of model." - "\n\tFalse - Use combined Fully Connected layers for both sides. This is more " - "similar to the original Faceswap architecture."), - "datatype": bool, - "group": "general", - "fixed": True}, - "split_gblock": { - "default": False, - "info": ( - "If the G-Block is enabled, Whether to use a single G-Block shared between both " - "sides, or whether to have a separate G-Block (one for each side). NB: The Fully " - "Connected layer that feeds the G-Block will always be shared." - "\n\tTrue - Use separate G-Blocks for Face A and Face B." - "\n\tFalse - Use a combined G-Block layers for both sides."), - "datatype": bool, - "group": "general", - "fixed": True}, - "split_decoders": { - "default": False, - "info": ( - "Whether to use a single decoder or split decoders." - "\n\tTrue - Use a separate decoder for Face A and Face B. This is more similar to " - "the original Faceswap architecture." - "\n\tFalse - Use a combined Decoder. This is more similar to 'IAE' style " - "architecture."), - "datatype": bool, - "group": "general", - "fixed": True}, - - # Encoder - "enc_architecture": { - "default": "fs_original", - "info": ( - "The encoder architecture to use. See the relevant config sections for specific " - "architecture tweaking.\nNB: For keras based pre-built models, the global " - "initializers and padding options will be ignored for the selected encoder." - "\n\n\tCLIPv: This is an implementation of the Visual encoder from the CLIP " - "transformer. The ViT weights are trained on imagenet whilst the FaRL weights are " - "trained on face related tasks. All have a default input size of 224px except for " - "ViT-L-14-336px that has an input size of 336px. Ref: Learning Transferable Visual " - "Models From Natural Language Supervision (2021): https://arxiv.org/abs/2103.00020" - "\n\n\tdensenet: (32px -224px). Ref: Densely Connected Convolutional Networks " - "(2016): https://arxiv.org/abs/1608.06993?source=post_page" - "\n\n\tefficientnet: [Tensorflow 2.3+ only] EfficientNet has numerous variants (B0 - " - "B8) that increases the model width, depth and dimensional space at each step. The " - "minimum input resolution is 32px for all variants. The maximum input resolution for " - "each variant is: b0: 224px, b1: 240px, b2: 260px, b3: 300px, b4: 380px, b5: 456px, " - "b6: 528px, b7 600px. Ref: Rethinking Model Scaling for Convolutional Neural " - "Networks (2020): https://arxiv.org/abs/1905.11946" - "\n\n\tefficientnet_v2: [Tensorflow 2.8+ only] EfficientNetV2 is the follow up to " - "efficientnet. It has numerous variants (B0 - B3 and Small, Medium and Large) that " - "increases the model width, depth and dimensional space at each step. The minimum " - "input resolution is 32px for all variants. The maximum input resolution for each " - "variant is: b0: 224px, b1: 240px, b2: 260px, b3: 300px, s: 384px, m: 480px, l: " - "480px. Ref: EfficientNetV2: Smaller Models and Faster Training (2021): " - "https://arxiv.org/abs/2104.00298" - "\n\n\tfs_original: (32px - 1024px). A configurable variant of the original facewap " - "encoder. ImageNet weights cannot be loaded for this model. Additional parameters " - "can be configured with the 'fs_enc' options. A version of this encoder is used in " - "the following models: Original, Original (lowmem), Dfaker, DFL-H128, DFL-SAE, IAE, " - "Lightweight." - "\n\n\tinception_resnet_v2: (75px - 299px). Ref: Inception-ResNet and the Impact of " - "Residual Connections on Learning (2016): https://arxiv.org/abs/1602.07261" - "\n\n\tinceptionV3: (75px - 299px). Ref: Rethinking the Inception Architecture for " - "Computer Vision (2015): https://arxiv.org/abs/1512.00567" - "\n\n\tmobilenet: (32px - 224px). Additional MobileNet parameters can be set with " - "the 'mobilenet' options. Ref: MobileNets: Efficient Convolutional Neural Networks " - "for Mobile Vision Applications (2017): https://arxiv.org/abs/1704.04861" - "\n\n\tmobilenet_v2: (32px - 224px). Additional MobileNet parameters can be set with " - "the 'mobilenet' options. Ref: MobileNetV2: Inverted Residuals and Linear " - "Bottlenecks (2018): https://arxiv.org/abs/1801.04381" - "\n\n\tmobilenet_v3: (32px - 224px). Additional MobileNet parameters can be set with " - "the 'mobilenet' options. Ref: Searching for MobileNetV3 (2019): " - "https://arxiv.org/pdf/1905.02244.pdf" - "\n\n\tnasnet: (32px - 331px (large) or 224px (mobile)). Ref: Learning Transferable " - "Architectures for Scalable Image Recognition (2017): " - "https://arxiv.org/abs/1707.07012" - "\n\n\tresnet: (32px - 224px). Deep Residual Learning for Image Recognition (2015): " - "https://arxiv.org/abs/1512.03385" - "\n\n\tvgg: (32px - 224px). Very Deep Convolutional Networks for Large-Scale Image " - "Recognition (2014): https://arxiv.org/abs/1409.1556" - "\n\n\txception: (71px - 229px). Ref: Deep Learning with Depthwise Separable " - "Convolutions (2017): https://arxiv.org/abs/1409.1556.\n"), - "datatype": str, - "choices": _ENCODERS, - "gui_radio": False, - "group": "encoder", - "fixed": True}, - "enc_scaling": { - "default": 7, - "info": ( - "Input scaling for the encoder. Some of the encoders have large input sizes, which " - "often are not helpful for Faceswap. This setting scales the dimensional space that " - "the encoder works in. For example an encoder with a maximum input size of 224px " - "will be input an image of 112px at 50%% scaling. See the Architecture tooltip for " - "the minimum and maximum sizes for each encoder. NB: The input size will be rounded " - "down to the nearest 16 pixels."), - "datatype": int, - "min_max": (0, 200), - "rounding": 1, - "group": "encoder", - "fixed": True}, - "enc_load_weights": { - "default": True, - "info": ( - "Load pre-trained weights trained on ImageNet data. Only available for non-" - "Faceswap encoders (i.e. those not beginning with 'fs'). NB: If you use the global " - "'load weights' option and have selected to load weights from a previous model's " - "'encoder' or 'keras_encoder' then the weights loaded here will be replaced by the " - "weights loaded from your saved model."), - "datatype": bool, - "group": "encoder", - "fixed": True}, - - # Bottleneck - "bottleneck_type": { - "default": "dense", - "info": ( - "The type of layer to use for the bottleneck." - "\n\taverage_pooling: Use a Global Average Pooling 2D layer for the bottleneck." - "\n\tdense: Use a Dense layer for the bottleneck (the traditional Faceswap method). " - "You can set the size of the Dense layer with the 'bottleneck_size' parameter." - "\n\tmax_pooling: Use a Global Max Pooling 2D layer for the bottleneck." - "\n\flatten: Don't use a bottleneck at all. Some encoders output in a size that make " - "a bottleneck unnecessary. This option flattens the output from the encoder, with no " - "further operations"), - "datatype": str, - "group": "bottleneck", - "gui_radio": True, - "choices": ["average_pooling", "dense", "max_pooling", "flatten"], - "fixed": True}, - "bottleneck_norm": { - "default": "none", - "info": ( - "Apply a normalization layer after encoder output and prior to the bottleneck." - "\n\tnone - Do not apply a normalization layer" - "\n\tinstance - Apply Instance Normalization" - "\n\tlayer - Apply Layer Normalization (Ba et al., 2016)" - "\n\trms - Apply Root Mean Squared Layer Normalization (Zhang et al., 2019). A " - "simplified version of Layer Normalization with reduced overhead."), - "datatype": str, - "gui_radio": True, - "choices": ["none", "instance", "layer", "rms"], - "group": "bottleneck", - "fixed": True}, - "bottleneck_size": { - "default": 1024, - "info": ( - "If using a Dense layer for the bottleneck, then this is the number of nodes to " - "use."), - "datatype": int, - "rounding": 128, - "min_max": (128, 4096), - "group": "bottleneck", - "fixed": True}, - "bottleneck_in_encoder": { - "default": True, - "info": ( - "Whether to place the bottleneck in the Encoder or to place it with the other " - "hidden layers. Placing the bottleneck in the encoder means that both sides will " - "share the same bottleneck. Placing it with the other fully connected layers means " - "that each fully connected layer will each get their own bottleneck. This may be " - "combined or split depending on your overall architecture configuration settings."), - "datatype": bool, - "group": "bottleneck", - "fixed": True}, - - # Intermediate Layers - "fc_depth": { - "default": 1, - "info": ( - "The number of consecutive Dense (fully connected) layers to include in each " - "side's intermediate layer."), - "datatype": int, - "rounding": 1, - "min_max": (0, 16), - "group": "hidden layers", - "fixed": True}, - "fc_min_filters": { - "default": 1024, - "info": ( - "The number of filters to use for the initial fully connected layer. The number of " - "nodes actually used is: fc_min_filters x fc_dimensions x fc_dimensions.\nNB: This " - "value may be scaled down, depending on output resolution."), - "datatype": int, - "rounding": 16, - "min_max": (16, 5120), - "group": "hidden layers", - "fixed": True}, - "fc_max_filters": { - "default": 1024, - "info": ( - "This is the number of filters to be used in the final reshape layer at the end of " - "the fully connected layers. The actual number of nodes used for the final fully " - "connected layer is: fc_min_filters x fc_dimensions x fc_dimensions.\nNB: This value " - "may be scaled down, depending on output resolution."), - "datatype": int, - "rounding": 64, - "min_max": (128, 5120), - "group": "hidden layers", - "fixed": True}, - "fc_dimensions": { - "default": 4, - "info": ( - "The height and width dimension for the final reshape layer at the end of the " - "fully connected layers.\nNB: The total number of nodes within the final fully " - "connected layer will be: fc_dimensions x fc_dimensions x fc_max_filters."), - "datatype": int, - "rounding": 1, - "min_max": (1, 16), - "group": "hidden layers", - "fixed": True}, - "fc_filter_slope": { - "default": -0.5, - "info": ( - "The rate that the filters move from the minimum number of filters to the maximum " - "number of filters. EG:\n" - "Negative numbers will change the number of filters quicker at first and slow down " - "each layer.\n" - "Positive numbers will change the number of filters slower at first but then speed " - "up each layer.\n" - "0.0 - This will change at a linear rate (i.e. the same number of filters will be " - "changed at each layer)."), - "datatype": float, - "min_max": (-.99, .99), - "rounding": 2, - "group": "hidden layers", - "fixed": True}, - "fc_dropout": { - "default": 0.0, - "info": ( - "Dropout is a form of regularization that can prevent a model from over-fitting " - "and help to keep neurons 'alive'. 0.5 will dropout half the connections between each " - "fully connected layer, 0.25 will dropout a quarter of the connections etc. Set to " - "0.0 to disable."), - "datatype": float, - "rounding": 2, - "min_max": (0.0, 0.99), - "group": "hidden layers", - "fixed": False}, - "fc_upsampler": { - "default": "upsample2d", - "info": ( - "The type of dimensional upsampling to perform at the end of the fully connected " - "layers, if upsamples > 0. The number of filters used for the upscale layers will be " - "the value given in 'fc_upsample_filters'." - "\n\tupsample2d - A lightweight and VRAM friendly method. 'quick and dirty' but does " - "not learn any parameters" - "\n\tsubpixel - Sub-pixel upscaler using depth-to-space which may require more " - "VRAM." - "\n\tresize_images - Uses the Keras resize_image function to save about half as much " - "vram as the heaviest methods." - "\n\tupscale_fast - Developed by Andenixa. Focusses on speed to upscale, but " - "requires more VRAM." - "\n\tupscale_hybrid - Developed by Andenixa. Uses a combination of PixelShuffler and " - "Upsampling2D to upscale, saving about 1/3rd of VRAM of the heaviest methods."), - "datatype": str, - "choices": ["resize_images", "subpixel", "upscale_fast", "upscale_hybrid", "upsample2d"], - "group": "hidden layers", - "gui_radio": False, - "fixed": True}, - "fc_upsamples": { - "default": 1, - "info": ( - "Some upsampling can occur within the Fully Connected layers rather than in the " - "Decoder to increase the dimensional space. Set how many upscale layers should occur " - "within the Fully Connected layers."), - "datatype": int, - "min_max": (0, 4), - "rounding": 1, - "group": "hidden layers", - "fixed": True}, - "fc_upsample_filters": { - "default": 512, - "info": ( - "If you have selected an upsampler which requires filters (i.e. any upsampler with " - "the exception of Upsampling2D), then this is the number of filters to be used for " - "the upsamplers within the fully connected layers, NB: This value may be scaled " - "down, depending on output resolution. Also note, that this figure will dictate the " - "number of filters used for the G-Block, if selected."), - "datatype": int, - "rounding": 64, - "min_max": (128, 5120), - "group": "hidden layers", - "fixed": True}, - - # G-Block - "fc_gblock_depth": { - "default": 3, - "info": ( - "The number of consecutive Dense (fully connected) layers to include in the " - "G-Block shared layer."), - "datatype": int, - "rounding": 1, - "min_max": (1, 16), - "group": "g-block hidden layers", - "fixed": True}, - "fc_gblock_min_nodes": { - "default": 512, - "info": "The number of nodes to use for the initial G-Block shared fully connected layer.", - "datatype": int, - "rounding": 64, - "min_max": (128, 5120), - "group": "g-block hidden layers", - "fixed": True}, - "fc_gblock_max_nodes": { - "default": 512, - "info": "The number of nodes to use for the final G-Block shared fully connected layer.", - "datatype": int, - "rounding": 64, - "min_max": (128, 5120), - "group": "g-block hidden layers", - "fixed": True}, - "fc_gblock_filter_slope": { - "default": -0.5, - "info": ( - "The rate that the filters move from the minimum number of filters to the maximum " - "number of filters for the G-Block shared layers. EG:\n" - "Negative numbers will change the number of filters quicker at first and slow down " - "each layer.\n" - "Positive numbers will change the number of filters slower at first but then speed " - "up each layer.\n" - "0.0 - This will change at a linear rate (i.e. the same number of filters will be " - "changed at each layer)."), - "datatype": float, - "min_max": (-.99, .99), - "rounding": 2, - "group": "g-block hidden layers", - "fixed": True}, - "fc_gblock_dropout": { - "default": 0.0, - "info": ( - "Dropout is a regularization technique that can prevent a model from over-fitting " - "and help to keep neurons 'alive'. 0.5 will dropout half the connections between " - "each fully connected layer, 0.25 will dropout a quarter of the connections etc. Set " - "to 0.0 to disable."), - "datatype": float, - "rounding": 2, - "min_max": (0.0, 0.99), - "group": "g-block hidden layers", - "fixed": False}, - - # Decoder - "dec_upscale_method": { - "default": "subpixel", - "info": ( - "The method to use for the upscales within the decoder. Images are upscaled " - "multiple times within the decoder as the network learns to reconstruct the face." - "\n\tsubpixel - Sub-pixel upscaler using depth-to-space which requires more " - "VRAM." - "\n\tresize_images - Uses the Keras resize_image function to save about half as much " - "vram as the heaviest methods." - "\n\tupscale_fast - Developed by Andenixa. Focusses on speed to upscale, but " - "requires more VRAM." - "\n\tupscale_hybrid - Developed by Andenixa. Uses a combination of PixelShuffler and " - "Upsampling2D to upscale, saving about 1/3rd of VRAM of the heaviest methods." - "\n\tupscale_dny - An alternative upscale implementation using Upsampling2D to " - "upsale."), - "datatype": str, - "choices": ["subpixel", "resize_images", "upscale_fast", "upscale_hybrid", "upscale_dny"], - "gui_radio": True, - "group": "decoder", - "fixed": True}, - "dec_upscales_in_fc": { - "default": 0, - "min_max": (0, 6), - "rounding": 1, - "info": ( - "It is possible to place some of the upscales at the end of the fully connected " - "model. For models with split decoders, but a shared fully connected layer, this " - "would have the effect of saving some VRAM but possibly at the cost of introducing " - "artefacts. For models with a shared decoder but split fully connected layers, this " - "would have the effect of increasing VRAM usage by processing some of the upscales " - "for each side rather than together."), - "datatype": int, - "group": "decoder", - "fixed": True}, - "dec_norm": { - "default": "none", - "info": ( - "Normalization to apply to apply after each upscale." - "\n\tnone - Do not apply a normalization layer" - "\n\tbatch - Apply Batch Normalization" - "\n\tgroup - Apply Group Normalization" - "\n\tinstance - Apply Instance Normalization" - "\n\tlayer - Apply Layer Normalization (Ba et al., 2016)" - "\n\trms - Apply Root Mean Squared Layer Normalization (Zhang et al., 2019). A " - "simplified version of Layer Normalization with reduced overhead."), - "datatype": str, - "gui_radio": True, - "choices": ["none", "batch", "group", "instance", "layer", "rms"], - "group": "decoder", - "fixed": True}, - "dec_min_filters": { - "default": 64, - "info": ( - "The minimum number of filters to use in decoder upscalers (i.e. the number of " - "filters to use for the final upscale layer)."), - "datatype": int, - "min_max": (16, 512), - "rounding": 16, - "group": "decoder", - "fixed": True}, - "dec_max_filters": { - "default": 512, - "info": ( - "The maximum number of filters to use in decoder upscalers (i.e. the number of " - "filters to use for the first upscale layer)."), - "datatype": int, - "min_max": (256, 5120), - "rounding": 64, - "group": "decoder", - "fixed": True}, - "dec_slope_mode": { - "default": "full", - "info": ( - "Alters the action of the filter slope.\n" - "\n\tfull: The number of filters at each upscale layer will reduce from the chosen " - "max_filters at the first layer to the chosen min_filters at the last layer as " - "dictated by the dec_filter_slope." - "\n\tcap_max: The filters will decline at a fixed rate from each upscale to the next " - "based on the filter_slope setting. If there are more upscales than filters, " - "then the earliest upscales will be capped at the max_filter value until the filters " - "can reduce to the min_filters value at the final upscale. (EG: 512 -> 512 -> 512 -> " - "256 -> 128 -> 64)." - "\n\tcap_min: The filters will decline at a fixed rate from each upscale to the next " - "based on the filter_slope setting. If there are more upscales than filters, then " - "the earliest upscales will drop their filters until the min_filter value is met and " - "repeat the min_filter value for the remaining upscales. (EG: 512 -> 256 -> 128 -> " - "64 -> 64 -> 64)."), - "choices": ["full", "cap_max", "cap_min"], - "group": "decoder", - "fixed": True, - "gui_radio": True}, - "dec_filter_slope": { - "default": -0.45, - "info": ( - "The rate that the filters reduce at each upscale layer.\n" - "\n\tFull Slope Mode: Negative numbers will drop the number of filters quicker at " - "first and slow down each upscale. Positive numbers will drop the number of filters " - "slower at first but then speed up each upscale. A value of 0.0 will reduce at a " - "linear rate (i.e. the same number of filters will be reduced at each upscale).\n" - "\n\tCap Min/Max Slope Mode: Only positive values will work here. Negative values " - "will automatically be converted to their positive counterpart. A value of 0.5 will " - "halve the number of filters at each upscale until the minimum value is reached. A " - "value of 0.33 will be reduce the number of filters by a third until the minimum " - "value is reached etc."), - "datatype": float, - "min_max": (-.99, .99), - "rounding": 2, - "group": "decoder", - "fixed": True}, - "dec_res_blocks": { - "default": 1, - "info": ( - "The number of Residual Blocks to apply to each upscale layer. Set to 0 to disable " - "residual blocks entirely."), - "datatype": int, - "rounding": 1, - "min_max": (0, 8), - "group": "decoder", - "fixed": True}, - "dec_output_kernel": { - "default": 5, - "info": "The kernel size to apply to the final Convolution layer.", - "datatype": int, - "rounding": 2, - "min_max": (1, 9), - "group": "decoder", - "fixed": True}, - "dec_gaussian": { - "default": True, - "info": ( - "Gaussian Noise acts as a regularization technique for preventing overfitting of " - "data." - "\n\tTrue - Apply a Gaussian Noise layer to each upscale." - "\n\tFalse - Don't apply a Gaussian Noise layer to each upscale."), - "datatype": bool, - "group": "decoder", - "fixed": True}, - "dec_skip_last_residual": { - "default": True, - "info": ( - "If Residual blocks have been enabled, enabling this option will not apply a " - "Residual block to the final upscaler." - "\n\tTrue - Don't apply a Residual block to the final upscale." - "\n\tFalse - Apply a Residual block to all upscale layers."), - "datatype": bool, - "group": "decoder", - "fixed": True}, - - # Weight management - "freeze_layers": { - "default": "keras_encoder", - "info": ( - "If the command line option 'freeze-weights' is enabled, then the layers indicated " - "here will be frozen the next time the model starts up. NB: Not all architectures " - "contain all of the layers listed here, so any layers marked for freezing that are " - "not within your chosen architecture will be ignored. EG:\n If 'split fc' has " - "been selected, then 'fc_a' and 'fc_b' are available for freezing. If it has " - "not been selected then 'fc_both' is available for freezing."), - "datatype": list, - "choices": ["encoder", "keras_encoder", "fc_a", "fc_b", "fc_both", "fc_shared", - "fc_gblock", "g_block_a", "g_block_b", "g_block_both", "decoder_a", - "decoder_b", "decoder_both"], - "group": "weights", - "fixed": False}, - "load_layers": { - "default": "encoder", - "info": ( - "If the command line option 'load-weights' is populated, then the layers indicated " - "here will be loaded from the given weights file if starting a new model. NB Not all " - "architectures contain all of the layers listed here, so any layers marked for " - "loading that are not within your chosen architecture will be ignored. EG:\n If " - "'split fc' has been selected, then 'fc_a' and 'fc_b' are available for loading. If " - "it has not been selected then 'fc_both' is available for loading."), - "datatype": list, - "choices": ["encoder", "fc_a", "fc_b", "fc_both", "fc_shared", "fc_gblock", "g_block_a", - "g_block_b", "g_block_both", "decoder_a", "decoder_b", "decoder_both"], - "group": "weights", - "fixed": True}, - - # # SPECIFIC ENCODER SETTINGS # # - # Faceswap Original - "fs_original_depth": { - "default": 4, - "info": "Faceswap Encoder only: The number of convolutions to perform within the encoder.", - "datatype": int, - "min_max": (2, 10), - "rounding": 1, - "group": "faceswap encoder configuration", - "fixed": True}, - "fs_original_min_filters": { - "default": 128, - "info": ( - "Faceswap Encoder only: The minumum number of filters to use for encoder " - "convolutions. (i.e. the number of filters to use for the first encoder layer)."), - "datatype": int, - "min_max": (16, 2048), - "rounding": 64, - "group": "faceswap encoder configuration", - "fixed": True}, - "fs_original_max_filters": { - "default": 1024, - "info": ( - "Faceswap Encoder only: The maximum number of filters to use for encoder " - "convolutions. (i.e. the number of filters to use for the final encoder layer)."), - "datatype": int, - "min_max": (256, 8192), - "rounding": 128, - "group": "faceswap encoder configuration", - "fixed": True}, - "fs_original_use_alt": { - "default": False, - "info": ( - "Use a slightly alternate version of the Faceswap Encoder." - "\n\tTrue - Use the alternate variation of the Faceswap Encoder." - "\n\tFalse - Use the original Faceswap Encoder."), - "datatype": bool, - "group": "faceswap encoder configuration", - "fixed": True}, - - # MobileNet - "mobilenet_width": { - "default": 1.0, - "info": ( - "The width multiplier for mobilenet encoders. Controls the width of the " - "network. Values less than 1.0 proportionally decrease the number of filters within " - "each layer. Values greater than 1.0 proportionally increase the number of filters " - "within each layer. 1.0 is the default number of layers used within the paper.\n" - "NB: This option is ignored for any non-mobilenet encoders.\n" - "NB: If loading ImageNet weights, then for MobilenetV1 only values of '0.25', " - "'0.5', '0.75' or '1.0 can be selected. For MobilenetV2 only values of '0.35', " - "'0.50', '0.75', '1.0', '1.3' or '1.4' can be selected. For mobilenet_v3 only values " - "of '0.75' or '1.0' can be selected"), - "datatype": float, - "min_max": (0.1, 2.0), - "rounding": 2, - "group": "mobilenet encoder configuration", - "fixed": True}, - "mobilenet_depth": { - "default": 1, - "info": ( - "The depth multiplier for MobilenetV1 encoder. This is the depth multiplier " - "for depthwise convolution (known as the resolution multiplier within the original " - "paper).\n" - "NB: This option is only used for MobilenetV1 and is ignored for all other " - "encoders.\n" - "NB: If loading ImageNet weights, this must be set to 1."), - "datatype": int, - "min_max": (1, 10), - "rounding": 1, - "group": "mobilenet encoder configuration", - "fixed": True}, - "mobilenet_dropout": { - "default": 0.001, - "info": ( - "The dropout rate for MobilenetV1 encoder.\n" - "NB: This option is only used for MobilenetV1 and is ignored for all other " - "encoders."), - "datatype": float, - "min_max": (0.001, 2.0), - "rounding": 3, - "group": "mobilenet encoder configuration", - "fixed": True}, - "mobilenet_minimalistic": { - "default": False, - "info": ( - "Use a minimilist version of MobilenetV3.\n" - "In addition to large and small models MobilenetV3 also contains so-called " - "minimalistic models, these models have the same per-layer dimensions characteristic " - "as MobilenetV3 however, they don't utilize any of the advanced blocks " - "(squeeze-and-excite units, hard-swish, and 5x5 convolutions). While these models " - "are less efficient on CPU, they are much more performant on GPU/DSP.\n" - "NB: This option is only used for MobilenetV3 and is ignored for all other " - "encoders.\n"), - "datatype": bool, - "group": "mobilenet encoder configuration", - "fixed": True}, - } + +# General +output_size = ConfigItem( + datatype=int, + default=128, + group="general", + info="Resolution (in pixels) of the output image to generate.\n" + "BE AWARE Larger resolution will dramatically increase VRAM requirements.", + rounding=16, + min_max=(64, 2048), + fixed=True) + +shared_fc = ConfigItem( + datatype=str, + default="none", + group="general", + info="Whether to create a shared fully connected layer. This layer will have the same " + "structure as the fully connected layers used for each side of the model. A shared " + "fully connected layer looks for patterns that are common to both sides. NB: " + "Enabling this option only makes sense if 'split fc' is selected." + "\n\tnone - Do not create a Fully Connected layer for shared data. (Original method)" + "\n\tfull - Create an exclusive Fully Connected layer for shared data. (IAE method)" + "\n\thalf - Use the 'fc_a' layer for shared data. This saves VRAM by re-using the " + "'A' side's fully connected model for the shared data. However, this will lead to " + "an 'unbalanced' model and can lead to more identity bleed (DFL method)", + choices=["none", "full", "half"], + gui_radio=True, + fixed=True) + +enable_gblock = ConfigItem( + datatype=bool, + default=True, + group="general", + info="Whether to enable the G-Block. If enabled, this will create a shared fully " + "connected layer (configurable in the 'G-Block hidden layers' section) to look for " + "patterns in the combined data, before feeding a block prior to the decoder for " + "merging this shared and combined data." + "\n\tTrue - Use the G-Block in the Decoder. A combined fully connected layer will be " + "created to feed this block which can be configured below." + "\n\tFalse - Don't use the G-Block in the decoder. No combined fully connected layer " + "will be created.", + fixed=True) + +split_fc = ConfigItem( + datatype=bool, + default=True, + group="general", + info="Whether to use a single shared Fully Connected layer or separate Fully Connected " + "layers for each side." + "\n\tTrue - Use separate Fully Connected layers for Face A and Face B. This is more " + "similar to the 'IAE' style of model." + "\n\tFalse - Use combined Fully Connected layers for both sides. This is more " + "similar to the original Faceswap architecture.", + fixed=True) + +split_gblock = ConfigItem( + datatype=bool, + default=False, + group="general", + info="If the G-Block is enabled, Whether to use a single G-Block shared between both " + "sides, or whether to have a separate G-Block (one for each side). NB: The Fully " + "Connected layer that feeds the G-Block will always be shared." + "\n\tTrue - Use separate G-Blocks for Face A and Face B." + "\n\tFalse - Use a combined G-Block layers for both sides.", + fixed=True) + +split_decoders = ConfigItem( + datatype=bool, + default=False, + group="general", + info="Whether to use a single decoder or split decoders." + "\n\tTrue - Use a separate decoder for Face A and Face B. This is more similar to " + "the original Faceswap architecture." + "\n\tFalse - Use a combined Decoder. This is more similar to 'IAE' style " + "architecture.", + fixed=True) + +# Encoder +enc_architecture = ConfigItem( + datatype=str, + default="fs_original", + group="encoder", + info="The encoder architecture to use. See the relevant config sections for specific " + "architecture tweaking.\nNB: For keras based pre-built models, the global " + "initializers and padding options will be ignored for the selected encoder." + "\n\n\tCLIPv: This is an implementation of the Visual encoder from the CLIP " + "transformer. The ViT weights are trained on imagenet whilst the FaRL weights are " + "trained on face related tasks. All have a default input size of 224px except for " + "ViT-L-14-336px that has an input size of 336px. Ref: Learning Transferable Visual " + "Models From Natural Language Supervision (2021): https://arxiv.org/abs/2103.00020" + "\n\n\tconvnext: There are 6 varations of increasing complexity. All have a default " + "input size of 224px. Ref: A ConvNet for the 2020s (2022): " + "https://arxiv.org/abs/1608.06993" + "\n\n\tdensenet: (32px-224px). Ref: Densely Connected Convolutional Networks " + "(2016): https://arxiv.org/abs/1608.06993" + "\n\n\tefficientnet: EfficientNet has numerous variants (B0 -B8) that increases the " + "model width, depth and dimensional space at each step. The minimum input resolution " + "is 32px for all variants. The maximum input resolution for each variant is: b0: " + "224px, b1: 240px, b2: 260px, b3: 300px, b4: 380px, b5: 456px, b6: 528px, b7 600px. " + "Ref: Rethinking Model Scaling for Convolutional Neural Networks (2020): " + "https://arxiv.org/abs/1905.11946" + "\n\n\tefficientnet_v2: EfficientNetV2 is the follow up to efficientnet. It has " + "numerous variants (B0 - B3 and Small, Medium and Large) that increases the model " + "width, depth and dimensional space at each step. The minimum input resolution is " + "32px for all variants. The maximum input resolution for each variant is: b0: 224px, " + "b1: 240px, b2: 260px, b3: 300px, s: 384px, m: 480px, l: 480px. Ref: EfficientNetV2: " + "Smaller Models and Faster Training (2021): https://arxiv.org/abs/2104.00298" + "\n\n\tfs_original: (32px - 1024px). A configurable variant of the original facewap " + "encoder. ImageNet weights cannot be loaded for this model. Additional parameters " + "can be configured with the 'fs_enc' options. A version of this encoder is used in " + "the following models: Original, Original (lowmem), Dfaker, DFL-H128, DFL-SAE, IAE, " + "Lightweight." + "\n\n\tinception_resnet_v2: (75px - 299px). Ref: Inception-ResNet and the Impact of " + "Residual Connections on Learning (2016): https://arxiv.org/abs/1602.07261" + "\n\n\tinceptionV3: (75px - 299px). Ref: Rethinking the Inception Architecture for " + "Computer Vision (2015): https://arxiv.org/abs/1512.00567" + "\n\n\tmobilenet: (32px - 224px). Additional MobileNet parameters can be set with " + "the 'mobilenet' options. Ref: MobileNets: Efficient Convolutional Neural Networks " + "for Mobile Vision Applications (2017): https://arxiv.org/abs/1704.04861" + "\n\n\tmobilenet_v2: (32px - 224px). Additional MobileNet parameters can be set with " + "the 'mobilenet' options. Ref: MobileNetV2: Inverted Residuals and Linear " + "Bottlenecks (2018): https://arxiv.org/abs/1801.04381" + "\n\n\tmobilenet_v3: (32px - 224px). Additional MobileNet parameters can be set with " + "the 'mobilenet' options. Ref: Searching for MobileNetV3 (2019): " + "https://arxiv.org/pdf/1905.02244.pdf" + "\n\n\tnasnet: (32px - 331px (large) or 224px (mobile)). Ref: Learning Transferable " + "Architectures for Scalable Image Recognition (2017): " + "https://arxiv.org/abs/1707.07012" + "\n\n\tresnet: (32px - 224px). Deep Residual Learning for Image Recognition (2015): " + "https://arxiv.org/abs/1512.03385" + "\n\n\tvgg: (32px - 224px). Very Deep Convolutional Networks for Large-Scale Image " + "Recognition (2014): https://arxiv.org/abs/1409.1556" + "\n\n\txception: (71px - 229px). Ref: Deep Learning with Depthwise Separable " + "Convolutions (2017): https://arxiv.org/abs/1409.1556.\n", + choices=_ENCODERS, + gui_radio=False, + fixed=True) + +enc_scaling = ConfigItem( + datatype=int, + default=7, + group="encoder", + info="Input scaling for the encoder. Some of the encoders have large input sizes, which " + "often are not helpful for Faceswap. This setting scales the dimensional space that " + "the encoder works in. For example an encoder with a maximum input size of 224px " + "will be input an image of 112px at 50%% scaling. See the Architecture tooltip for " + "the minimum and maximum sizes for each encoder. NB: The input size will be rounded " + "down to the nearest 16 pixels.", + min_max=(0, 200), + rounding=1, + fixed=True) + +enc_load_weights = ConfigItem( + datatype=bool, + default=True, + group="encoder", + info="Load pre-trained weights trained on ImageNet data. Only available for non-" + "Faceswap encoders (i.e. those not beginning with 'fs'). NB: If you use the global " + "'load weights' option and have selected to load weights from a previous model's " + "'encoder' or 'keras_encoder' then the weights loaded here will be replaced by the " + "weights loaded from your saved model.", + fixed=True) + +# Bottleneck +bottleneck_type = ConfigItem( + datatype=str, + default="dense", + group="bottleneck", + info="The type of layer to use for the bottleneck." + "\n\taverage_pooling: Use a Global Average Pooling 2D layer for the bottleneck." + "\n\tdense: Use a Dense layer for the bottleneck (the traditional Faceswap method). " + "You can set the size of the Dense layer with the 'bottleneck_size' parameter." + "\n\tmax_pooling: Use a Global Max Pooling 2D layer for the bottleneck." + "\n\flatten: Don't use a bottleneck at all. Some encoders output in a size that make " + "a bottleneck unnecessary. This option flattens the output from the encoder, with no " + "further operations", + gui_radio=True, + choices=["average_pooling", "dense", "max_pooling", "flatten"], + fixed=True) + +bottleneck_norm = ConfigItem( + datatype=str, + default="none", + group="bottleneck", + info="Apply a normalization layer after encoder output and prior to the bottleneck." + "\n\tnone - Do not apply a normalization layer" + "\n\tinstance - Apply Instance Normalization" + "\n\tlayer - Apply Layer Normalization (Ba et al., 2016)" + "\n\trms - Apply Root Mean Squared Layer Normalization (Zhang et al., 2019). A " + "simplified version of Layer Normalization with reduced overhead.", + gui_radio=True, + choices=["none", "instance", "layer", "rms"], + fixed=True) + +bottleneck_size = ConfigItem( + datatype=int, + default=1024, + group="bottleneck", + info="If using a Dense layer for the bottleneck, then this is the number of nodes to " + "use.", + rounding=128, + min_max=(128, 4096), + fixed=True) + +bottleneck_in_encoder = ConfigItem( + datatype=bool, + default=True, + group="bottleneck", + info="Whether to place the bottleneck in the Encoder or to place it with the other " + "hidden layers. Placing the bottleneck in the encoder means that both sides will " + "share the same bottleneck. Placing it with the other fully connected layers means " + "that each fully connected layer will each get their own bottleneck. This may be " + "combined or split depending on your overall architecture configuration settings.", + fixed=True) + +# Intermediate Layers +fc_depth = ConfigItem( + datatype=int, + default=1, + group="hidden layers", + info="The number of consecutive Dense (fully connected) layers to include in each " + "side's intermediate layer.", + rounding=1, + min_max=(0, 16), + fixed=True) + +fc_min_filters = ConfigItem( + datatype=int, + default=1024, + group="hidden layers", + info="The number of filters to use for the initial fully connected layer. The number of " + "nodes actually used is: fc_min_filters x fc_dimensions x fc_dimensions.\nNB: This " + "value may be scaled down, depending on output resolution.", + rounding=16, + min_max=(16, 5120), + fixed=True) + +fc_max_filters = ConfigItem( + datatype=int, + default=1024, + group="hidden layers", + info="This is the number of filters to be used in the final reshape layer at the end of " + "the fully connected layers. The actual number of nodes used for the final fully " + "connected layer is: fc_min_filters x fc_dimensions x fc_dimensions.\nNB: This value " + "may be scaled down, depending on output resolution.", + rounding=64, + min_max=(128, 5120), + fixed=True) + +fc_dimensions = ConfigItem( + datatype=int, + default=4, + group="hidden layers", + info="The height and width dimension for the final reshape layer at the end of the " + "fully connected layers.\nNB: The total number of nodes within the final fully " + "connected layer will be: fc_dimensions x fc_dimensions x fc_max_filters.", + rounding=1, + min_max=(1, 16), + fixed=True) + +fc_filter_slope = ConfigItem( + datatype=float, + default=-0.5, + group="hidden layers", + info="The rate that the filters move from the minimum number of filters to the maximum " + "number of filters. EG:\n" + "Negative numbers will change the number of filters quicker at first and slow down " + "each layer.\n" + "Positive numbers will change the number of filters slower at first but then speed " + "up each layer.\n" + "0.0 - This will change at a linear rate (i.e. the same number of filters will be " + "changed at each layer).", + min_max=(-.99, .99), + rounding=2, + fixed=True) + +fc_dropout = ConfigItem( + datatype=float, + default=0.0, + group="hidden layers", + info="Dropout is a form of regularization that can prevent a model from over-fitting " + "and help to keep neurons 'alive'. 0.5 will dropout half the connections between " + "each fully connected layer, 0.25 will dropout a quarter of the connections etc. Set " + "to 0.0 to disable.", + rounding=2, + min_max=(0.0, 0.99), + fixed=False) + +fc_upsampler = ConfigItem( + datatype=str, + default="upsample2d", + group="hidden layers", + info="The type of dimensional upsampling to perform at the end of the fully connected " + "layers, if upsamples > 0. The number of filters used for the upscale layers will be " + "the value given in 'fc_upsample_filters'." + "\n\tupsample2d - A lightweight and VRAM friendly method. 'quick and dirty' but does " + "not learn any parameters" + "\n\tsubpixel - Sub-pixel upscaler using depth-to-space which may require more " + "VRAM." + "\n\tresize_images - Uses the Keras resize_image function to save about half as much " + "vram as the heaviest methods." + "\n\tupscale_fast - Developed by Andenixa. Focusses on speed to upscale, but " + "requires more VRAM." + "\n\tupscale_hybrid - Developed by Andenixa. Uses a combination of PixelShuffler and " + "Upsampling2D to upscale, saving about 1/3rd of VRAM of the heaviest methods.", + choices=["resize_images", "subpixel", "upscale_fast", "upscale_hybrid", "upsample2d"], + gui_radio=False, + fixed=True) + +fc_upsamples = ConfigItem( + datatype=int, + default=1, + group="hidden layers", + info="Some upsampling can occur within the Fully Connected layers rather than in the " + "Decoder to increase the dimensional space. Set how many upscale layers should occur " + "within the Fully Connected layers.", + min_max=(0, 4), + rounding=1, + fixed=True) + +fc_upsample_filters = ConfigItem( + datatype=int, + default=512, + group="hidden layers", + info="If you have selected an upsampler which requires filters (i.e. any upsampler with " + "the exception of Upsampling2D), then this is the number of filters to be used for " + "the upsamplers within the fully connected layers, NB: This value may be scaled " + "down, depending on output resolution. Also note, that this figure will dictate the " + "number of filters used for the G-Block, if selected.", + rounding=64, + min_max=(128, 5120), + fixed=True) + +# G-Block +fc_gblock_depth = ConfigItem( + datatype=int, + default=3, + group="g-block hidden layers", + info="The number of consecutive Dense (fully connected) layers to include in the " + "G-Block shared layer.", + rounding=1, + min_max=(1, 16), + fixed=True) + +fc_gblock_min_nodes = ConfigItem( + datatype=int, + default=512, + group="g-block hidden layers", + info="The number of nodes to use for the initial G-Block shared fully connected layer.", + rounding=64, + min_max=(128, 5120), + fixed=True) + +fc_gblock_max_nodes = ConfigItem( + datatype=int, + default=512, + group="g-block hidden layers", + info="The number of nodes to use for the final G-Block shared fully connected layer.", + rounding=64, + min_max=(128, 5120), + fixed=True) + +fc_gblock_filter_slope = ConfigItem( + datatype=float, + default=-0.5, + group="g-block hidden layers", + info="The rate that the filters move from the minimum number of filters to the maximum " + "number of filters for the G-Block shared layers. EG:\n" + "Negative numbers will change the number of filters quicker at first and slow down " + "each layer.\n" + "Positive numbers will change the number of filters slower at first but then speed " + "up each layer.\n" + "0.0 - This will change at a linear rate (i.e. the same number of filters will be " + "changed at each layer).", + min_max=(-.99, .99), + rounding=2, + fixed=True) + +fc_gblock_dropout = ConfigItem( + datatype=float, + default=0.0, + group="g-block hidden layers", + info="Dropout is a regularization technique that can prevent a model from over-fitting " + "and help to keep neurons 'alive'. 0.5 will dropout half the connections between " + "each fully connected layer, 0.25 will dropout a quarter of the connections etc. Set " + "to 0.0 to disable.", + rounding=2, + min_max=(0.0, 0.99), + fixed=False) + +# Decoder +dec_upscale_method = ConfigItem( + datatype=str, + default="subpixel", + group="decoder", + info="The method to use for the upscales within the decoder. Images are upscaled " + "multiple times within the decoder as the network learns to reconstruct the face." + "\n\tsubpixel - Sub-pixel upscaler using depth-to-space which requires more " + "VRAM." + "\n\tresize_images - Uses the Keras resize_image function to save about half as much " + "vram as the heaviest methods." + "\n\tupscale_fast - Developed by Andenixa. Focusses on speed to upscale, but " + "requires more VRAM." + "\n\tupscale_hybrid - Developed by Andenixa. Uses a combination of PixelShuffler and " + "Upsampling2D to upscale, saving about 1/3rd of VRAM of the heaviest methods." + "\n\tupscale_dny - An alternative upscale implementation using Upsampling2D to " + "upsale.", + choices=["subpixel", "resize_images", "upscale_fast", "upscale_hybrid", "upscale_dny"], + gui_radio=True, + fixed=True) + +dec_upscales_in_fc = ConfigItem( + datatype=int, + default=0, + min_max=(0, 6), + rounding=1, + group="decoder", + info="It is possible to place some of the upscales at the end of the fully connected " + "model. For models with split decoders, but a shared fully connected layer, this " + "would have the effect of saving some VRAM but possibly at the cost of introducing " + "artefacts. For models with a shared decoder but split fully connected layers, this " + "would have the effect of increasing VRAM usage by processing some of the upscales " + "for each side rather than together.", + fixed=True) + +dec_norm = ConfigItem( + datatype=str, + default="none", + group="decoder", + info="Normalization to apply to apply after each upscale." + "\n\tnone - Do not apply a normalization layer" + "\n\tbatch - Apply Batch Normalization" + "\n\tgroup - Apply Group Normalization" + "\n\tinstance - Apply Instance Normalization" + "\n\tlayer - Apply Layer Normalization (Ba et al., 2016)" + "\n\trms - Apply Root Mean Squared Layer Normalization (Zhang et al., 2019). A " + "simplified version of Layer Normalization with reduced overhead.", + gui_radio=True, + choices=["none", "batch", "group", "instance", "layer", "rms"], + fixed=True) + +dec_min_filters = ConfigItem( + datatype=int, + default=64, + group="decoder", + info="The minimum number of filters to use in decoder upscalers (i.e. the number of " + "filters to use for the final upscale layer).", + min_max=(16, 512), + rounding=16, + fixed=True) + +dec_max_filters = ConfigItem( + datatype=int, + default=512, + group="decoder", + info="The maximum number of filters to use in decoder upscalers (i.e. the number of " + "filters to use for the first upscale layer).", + min_max=(256, 5120), + rounding=64, + fixed=True) + +dec_slope_mode = ConfigItem( + datatype=str, + default="full", + group="decoder", + info="Alters the action of the filter slope.\n" + "\n\tfull: The number of filters at each upscale layer will reduce from the chosen " + "max_filters at the first layer to the chosen min_filters at the last layer as " + "dictated by the dec_filter_slope." + "\n\tcap_max: The filters will decline at a fixed rate from each upscale to the next " + "based on the filter_slope setting. If there are more upscales than filters, " + "then the earliest upscales will be capped at the max_filter value until the filters " + "can reduce to the min_filters value at the final upscale. (EG: 512 -> 512 -> 512 -> " + "256 -> 128 -> 64)." + "\n\tcap_min: The filters will decline at a fixed rate from each upscale to the next " + "based on the filter_slope setting. If there are more upscales than filters, then " + "the earliest upscales will drop their filters until the min_filter value is met and " + "repeat the min_filter value for the remaining upscales. (EG: 512 -> 256 -> 128 -> " + "64 -> 64 -> 64).", + choices=["full", "cap_max", "cap_min"], + fixed=True, + gui_radio=True) + +dec_filter_slope = ConfigItem( + datatype=float, + default=-0.45, + group="decoder", + info="The rate that the filters reduce at each upscale layer.\n" + "\n\tFull Slope Mode: Negative numbers will drop the number of filters quicker at " + "first and slow down each upscale. Positive numbers will drop the number of filters " + "slower at first but then speed up each upscale. A value of 0.0 will reduce at a " + "linear rate (i.e. the same number of filters will be reduced at each upscale).\n" + "\n\tCap Min/Max Slope Mode: Only positive values will work here. Negative values " + "will automatically be converted to their positive counterpart. A value of 0.5 will " + "halve the number of filters at each upscale until the minimum value is reached. A " + "value of 0.33 will be reduce the number of filters by a third until the minimum " + "value is reached etc.", + min_max=(-.99, .99), + rounding=2, + fixed=True) + +dec_res_blocks = ConfigItem( + datatype=int, + default=1, + group="decoder", + info="The number of Residual Blocks to apply to each upscale layer. Set to 0 to disable " + "residual blocks entirely.", + rounding=1, + min_max=(0, 8), + fixed=True) + +dec_output_kernel = ConfigItem( + datatype=int, + default=5, + group="decoder", + info="The kernel size to apply to the final Convolution layer.", + rounding=2, + min_max=(1, 9), + fixed=True) + +dec_gaussian = ConfigItem( + datatype=bool, + default=True, + group="decoder", + info="Gaussian Noise acts as a regularization technique for preventing overfitting of " + "data." + "\n\tTrue - Apply a Gaussian Noise layer to each upscale." + "\n\tFalse - Don't apply a Gaussian Noise layer to each upscale.", + fixed=True) + +dec_skip_last_residual = ConfigItem( + datatype=bool, + default=True, + group="decoder", + info="If Residual blocks have been enabled, enabling this option will not apply a " + "Residual block to the final upscaler." + "\n\tTrue - Don't apply a Residual block to the final upscale." + "\n\tFalse - Apply a Residual block to all upscale layers.", + fixed=True) + +# Weight management +freeze_layers = ConfigItem( + datatype=list, + default=["keras_encoder"], + group="weights", + info="If the command line option 'freeze-weights' is enabled, then the layers indicated " + "here will be frozen the next time the model starts up. NB: Not all architectures " + "contain all of the layers listed here, so any layers marked for freezing that are " + "not within your chosen architecture will be ignored. EG:\n If 'split fc' has " + "been selected, then 'fc_a' and 'fc_b' are available for freezing. If it has " + "not been selected then 'fc_both' is available for freezing.", + choices=["encoder", "keras_encoder", "fc_a", "fc_b", "fc_both", "fc_shared", + "fc_gblock", "g_block_a", "g_block_b", "g_block_both", "decoder_a", + "decoder_b", "decoder_both"], + fixed=False) + +load_layers = ConfigItem( + datatype=list, + default=["encoder"], + group="weights", + info="If the command line option 'load-weights' is populated, then the layers indicated " + "here will be loaded from the given weights file if starting a new model. NB Not all " + "architectures contain all of the layers listed here, so any layers marked for " + "loading that are not within your chosen architecture will be ignored. EG:\n If " + "'split fc' has been selected, then 'fc_a' and 'fc_b' are available for loading. If " + "it has not been selected then 'fc_both' is available for loading.", + choices=["encoder", "fc_a", "fc_b", "fc_both", "fc_shared", "fc_gblock", "g_block_a", + "g_block_b", "g_block_both", "decoder_a", "decoder_b", "decoder_both"], + fixed=True) + +# # SPECIFIC ENCODER SETTINGS # # +# Faceswap Original +fs_original_depth = ConfigItem( + datatype=int, + default=4, + group="faceswap encoder configuration", + info="Faceswap Encoder only: The number of convolutions to perform within the encoder.", + min_max=(2, 10), + rounding=1, + fixed=True) + +fs_original_min_filters = ConfigItem( + datatype=int, + default=128, + group="faceswap encoder configuration", + info="Faceswap Encoder only: The minumum number of filters to use for encoder " + "convolutions. (i.e. the number of filters to use for the first encoder layer).", + min_max=(16, 2048), + rounding=64, + fixed=True) + +fs_original_max_filters = ConfigItem( + datatype=int, + default=1024, + group="faceswap encoder configuration", + info="Faceswap Encoder only: The maximum number of filters to use for encoder " + "convolutions. (i.e. the number of filters to use for the final encoder layer).", + min_max=(256, 8192), + rounding=128, + fixed=True) + +fs_original_use_alt = ConfigItem( + datatype=bool, + default=False, + group="faceswap encoder configuration", + info="Use a slightly alternate version of the Faceswap Encoder." + "\n\tTrue - Use the alternate variation of the Faceswap Encoder." + "\n\tFalse - Use the original Faceswap Encoder.", + fixed=True) + +# MobileNet +mobilenet_width = ConfigItem( + datatype=float, + default=1.0, + group="mobilenet encoder configuration", + info="The width multiplier for mobilenet encoders. Controls the width of the " + "network. Values less than 1.0 proportionally decrease the number of filters within " + "each layer. Values greater than 1.0 proportionally increase the number of filters " + "within each layer. 1.0 is the default number of layers used within the paper.\n" + "NB: This option is ignored for any non-mobilenet encoders.\n" + "NB: If loading ImageNet weights, then for MobilenetV1 only values of '0.25', " + "'0.5', '0.75' or '1.0 can be selected. For MobilenetV2 only values of '0.35', " + "'0.50', '0.75', '1.0', '1.3' or '1.4' can be selected. For mobilenet_v3 only values " + "of '0.75' or '1.0' can be selected", + min_max=(0.1, 2.0), + rounding=2, + fixed=True) + +mobilenet_depth = ConfigItem( + datatype=int, + default=1, + group="mobilenet encoder configuration", + info="The depth multiplier for MobilenetV1 encoder. This is the depth multiplier " + "for depthwise convolution (known as the resolution multiplier within the original " + "paper).\n" + "NB: This option is only used for MobilenetV1 and is ignored for all other " + "encoders.\n" + "NB: If loading ImageNet weights, this must be set to 1.", + min_max=(1, 10), + rounding=1, + fixed=True) + +mobilenet_dropout = ConfigItem( + datatype=float, + default=0.001, + group="mobilenet encoder configuration", + info="The dropout rate for MobilenetV1 encoder.\n" + "NB: This option is only used for MobilenetV1 and is ignored for all other " + "encoders.", + min_max=(0.001, 2.0), + rounding=3, + fixed=True) + +mobilenet_minimalistic = ConfigItem( + datatype=bool, + default=False, + group="mobilenet encoder configuration", + info="Use a minimilist version of MobilenetV3.\n" + "In addition to large and small models MobilenetV3 also contains so-called " + "minimalistic models, these models have the same per-layer dimensions characteristic " + "as MobilenetV3 however, they don't utilize any of the advanced blocks " + "(squeeze-and-excite units, hard-swish, and 5x5 convolutions). While these models " + "are less efficient on CPU, they are much more performant on GPU/DSP.\n" + "NB: This option is only used for MobilenetV3 and is ignored for all other " + "encoders.\n", + fixed=True) diff --git a/plugins/train/model/realface.py b/plugins/train/model/realface.py index 30d0d7f8f1..c772d63264 100644 --- a/plugins/train/model/realface.py +++ b/plugins/train/model/realface.py @@ -10,13 +10,14 @@ import logging import sys -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras.initializers import RandomNormal # pylint:disable=import-error -from tensorflow.keras.layers import Dense, Flatten, Input, LeakyReLU, Reshape # noqa:E501 # pylint:disable=import-error -from tensorflow.keras.models import Model as KModel # pylint:disable=import-error +from keras import initializers, Input, layers, Model as KModel from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock +from plugins.train.train_config import Loss as cfg_loss + from ._base import ModelBase +from . import realface_defaults as cfg +# pylint:disable=duplicate-code logger = logging.getLogger(__name__) @@ -25,10 +26,10 @@ class Model(ModelBase): """ RealFace(tm) Faceswap Model """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.input_shape = (self.config["input_size"], self.config["input_size"], 3) + self.input_shape = (cfg.input_size(), cfg.input_size(), 3) self.check_input_output() self.dense_width, self.upscalers_no = self.get_dense_width_upscalers_numbers() - self.kernel_initializer = RandomNormal(0, 0.02) + self.kernel_initializer = initializers.RandomNormal(0, 0.02) @property def downscalers_no(self): @@ -47,11 +48,11 @@ def dense_filters(self): def check_input_output(self): """ Confirm valid input and output sized have been provided """ - if not 64 <= self.config["input_size"] <= 128 or self.config["input_size"] % 16 != 0: + if not 64 <= cfg.input_size() <= 128 or cfg.input_size() % 16 != 0: logger.error("Config error: input_size must be between 64 and 128 and be divisible by " "16.") sys.exit(1) - if not 64 <= self.config["output_size"] <= 256 or self.config["output_size"] % 32 != 0: + if not 64 <= cfg.output_size() <= 256 or cfg.output_size() % 32 != 0: logger.error("Config error: output_size must be between 64 and 256 and be divisible " "by 32.") sys.exit(1) @@ -59,10 +60,10 @@ def check_input_output(self): def get_dense_width_upscalers_numbers(self): """ Return the dense width and number of upscale blocks """ - output_size = self.config["output_size"] + output_size = cfg.output_size() sides = [(output_size // 2**n, n) for n in [4, 5] if (output_size // 2**n) < 10] closest = min([x * self._downscale_ratio for x, _ in sides], - key=lambda x: abs(x - self.config["input_size"])) + key=lambda x: abs(x - cfg.input_size())) dense_width, upscalers_no = [(s, n) for s, n in sides if s * self._downscale_ratio == closest][0] logger.debug("dense_width: %s, upscalers_no: %s", dense_width, upscalers_no) @@ -74,7 +75,7 @@ def build_model(self, inputs): encoder_a = encoder(inputs[0]) encoder_b = encoder(inputs[1]) - outputs = [self.decoder_a()(encoder_a), self.decoder_b()(encoder_b)] + outputs = self.decoder_a()(encoder_a) + self.decoder_b()(encoder_b) autoencoder = KModel(inputs, outputs, name=self.model_name) return autoencoder @@ -84,11 +85,11 @@ def encoder(self): input_ = Input(shape=self.input_shape) var_x = input_ - encoder_complexity = self.config["complexity_encoder"] + encoder_complexity = cfg.complexity_encoder() for idx in range(self.downscalers_no - 1): var_x = Conv2DBlock(encoder_complexity * 2**idx, activation=None)(var_x) - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) var_x = ResidualBlock(encoder_complexity * 2**idx, use_bias=True)(var_x) var_x = ResidualBlock(encoder_complexity * 2**idx, use_bias=True)(var_x) @@ -98,25 +99,25 @@ def encoder(self): def decoder_b(self): """ RealFace Decoder Network """ - input_filters = self.config["complexity_encoder"] * 2**(self.downscalers_no-1) - input_width = self.config["input_size"] // self._downscale_ratio + input_filters = cfg.complexity_encoder() * 2**(self.downscalers_no-1) + input_width = cfg.input_size() // self._downscale_ratio input_ = Input(shape=(input_width, input_width, input_filters)) var_xy = input_ - var_xy = Dense(self.config["dense_nodes"])(Flatten()(var_xy)) - var_xy = Dense(self.dense_width * self.dense_width * self.dense_filters)(var_xy) - var_xy = Reshape((self.dense_width, self.dense_width, self.dense_filters))(var_xy) + var_xy = layers.Dense(cfg.dense_nodes())(layers.Flatten()(var_xy)) + var_xy = layers.Dense(self.dense_width * self.dense_width * self.dense_filters)(var_xy) + var_xy = layers.Reshape((self.dense_width, self.dense_width, self.dense_filters))(var_xy) var_xy = UpscaleBlock(self.dense_filters, activation=None)(var_xy) var_x = var_xy - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) var_x = ResidualBlock(self.dense_filters, use_bias=False)(var_x) - decoder_b_complexity = self.config["complexity_decoder"] + decoder_b_complexity = cfg.complexity_decoder() for idx in range(self.upscalers_no - 2): var_x = UpscaleBlock(decoder_b_complexity // 2**idx, activation=None)(var_x) - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) var_x = ResidualBlock(decoder_b_complexity // 2**idx, use_bias=False)(var_x) var_x = ResidualBlock(decoder_b_complexity // 2**idx, use_bias=True)(var_x) var_x = UpscaleBlock(decoder_b_complexity // 2**(idx + 1), activation="leakyrelu")(var_x) @@ -125,9 +126,9 @@ def decoder_b(self): outputs = [var_x] - if self.config.get("learn_mask", False): + if cfg_loss.learn_mask(): var_y = var_xy - var_y = LeakyReLU(alpha=0.1)(var_y) + var_y = layers.LeakyReLU(negative_slope=0.1)(var_y) mask_b_complexity = 384 for idx in range(self.upscalers_no-2): @@ -142,26 +143,26 @@ def decoder_b(self): def decoder_a(self): """ RealFace Decoder (A) Network """ - input_filters = self.config["complexity_encoder"] * 2**(self.downscalers_no-1) - input_width = self.config["input_size"] // self._downscale_ratio + input_filters = cfg.complexity_encoder() * 2**(self.downscalers_no-1) + input_width = cfg.input_size() // self._downscale_ratio input_ = Input(shape=(input_width, input_width, input_filters)) var_xy = input_ - dense_nodes = int(self.config["dense_nodes"]/1.5) + dense_nodes = int(cfg.dense_nodes()/1.5) dense_filters = int(self.dense_filters/1.5) - var_xy = Dense(dense_nodes)(Flatten()(var_xy)) - var_xy = Dense(self.dense_width * self.dense_width * dense_filters)(var_xy) - var_xy = Reshape((self.dense_width, self.dense_width, dense_filters))(var_xy) + var_xy = layers.Dense(dense_nodes)(layers.Flatten()(var_xy)) + var_xy = layers.Dense(self.dense_width * self.dense_width * dense_filters)(var_xy) + var_xy = layers.Reshape((self.dense_width, self.dense_width, dense_filters))(var_xy) var_xy = UpscaleBlock(dense_filters, activation=None)(var_xy) var_x = var_xy - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) var_x = ResidualBlock(dense_filters, use_bias=False)(var_x) - decoder_a_complexity = int(self.config["complexity_decoder"] / 1.5) + decoder_a_complexity = int(cfg.complexity_decoder() / 1.5) for idx in range(self.upscalers_no-2): var_x = UpscaleBlock(decoder_a_complexity // 2**idx, activation="leakyrelu")(var_x) var_x = UpscaleBlock(decoder_a_complexity // 2**(idx + 1), activation="leakyrelu")(var_x) @@ -170,9 +171,9 @@ def decoder_a(self): outputs = [var_x] - if self.config.get("learn_mask", False): + if cfg_loss.learn_mask(): var_y = var_xy - var_y = LeakyReLU(alpha=0.1)(var_y) + var_y = layers.LeakyReLU(negative_slope=0.1)(var_y) mask_a_complexity = 384 for idx in range(self.upscalers_no-2): @@ -184,9 +185,3 @@ def decoder_a(self): outputs += [var_y] return KModel(input_, outputs=outputs, name="decoder_a") - - def _legacy_mapping(self): - """ The mapping of legacy separate model names to single model names """ - return {f"{self.name}_encoder.h5": "encoder", - f"{self.name}_decoder_A.h5": "decoder_a", - f"{self.name}_decoder_B.h5": "decoder_b"} diff --git a/plugins/train/model/realface_defaults.py b/plugins/train/model/realface_defaults.py index cdf001fa1c..2727fb9034 100755 --- a/plugins/train/model/realface_defaults.py +++ b/plugins/train/model/realface_defaults.py @@ -1,114 +1,89 @@ #!/usr/bin/env python3 -""" - The default options for the faceswap Realface Model plugin. +""" The default options for the faceswap Realface Model plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) - should always be lower text. - dictionary requirements are listed below. +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ +# pylint:disable=duplicate-code +from lib.config import ConfigItem -_HELPTEXT = ( +HELPTEXT = ( "An extra detailed variant of Original model.\n" "Incorporates ideas from Bryanlyon and inspiration from the Villain model.\n" "Requires about 6GB-8GB of VRAM (batchsize 8-16).\n" ) -_DEFAULTS = { - "input_size": { - "default": 64, - "info": "Resolution (in pixels) of the input image to train on.\n" - "BE AWARE Larger resolution will dramatically increase VRAM requirements.\n" - "Higher resolutions may increase prediction accuracy, but does not effect the " - "resulting output size.\nMust be between 64 and 128 and be divisible by 16.", - "datatype": int, - "rounding": 16, - "min_max": (64, 128), - "choices": [], - "gui_radio": False, - "fixed": True, - "group": "size" - }, - "output_size": { - "default": 128, - "info": "Output image resolution (in pixels).\nBe aware that larger resolution will " - "increase VRAM requirements.\nNB: Must be between 64 and 256 and be divisible " - "by 16.", - "datatype": int, - "rounding": 16, - "min_max": (64, 256), - "choices": [], - "gui_radio": False, - "fixed": True, - "group": "size" - }, - "dense_nodes": { - "default": 1536, - "info": "Number of nodes for decoder. Might affect your model's ability to learn in " - "general.\nNote that: Lower values will affect the ability to predict " - "details.", - "datatype": int, - "rounding": 64, - "min_max": (768, 2048), - "choices": [], - "gui_radio": False, - "fixed": True, - "group": "network" - }, - "complexity_encoder": { - "default": 128, - "info": "Encoder Convolution Layer Complexity. sensible ranges: 128 to 150.", - "datatype": int, - "rounding": 4, - "min_max": (96, 160), - "choices": [], - "gui_radio": False, - "fixed": True, - "group": "network" - }, - "complexity_decoder": { - "default": 512, - "info": "Decoder Complexity.", - "datatype": int, - "rounding": 4, - "min_max": (512, 544), - "choices": [], - "gui_radio": False, - "fixed": True, - "group": "network" - }, -} +input_size = ConfigItem( + datatype=int, + default=64, + group="size", + info="Resolution (in pixels) of the input image to train on.\n" + "BE AWARE Larger resolution will dramatically increase VRAM requirements.\n" + "Higher resolutions may increase prediction accuracy, but does not effect the " + "resulting output size.\nMust be between 64 and 128 and be divisible by 16.", + rounding=16, + min_max=(64, 128), + fixed=True) + +output_size = ConfigItem( + datatype=int, + default=128, + group="size", + info="Output image resolution (in pixels).\nBe aware that larger resolution will " + "increase VRAM requirements.\nNB: Must be between 64 and 256 and be divisible " + "by 16.", + rounding=16, + min_max=(64, 256), + fixed=True) + +dense_nodes = ConfigItem( + datatype=int, + default=1536, + group="network", + info="Number of nodes for decoder. Might affect your model's ability to learn in " + "general.\nNote that: Lower values will affect the ability to predict " + "details.", + rounding=64, + min_max=(768, 2048), + fixed=True) + +complexity_encoder = ConfigItem( + datatype=int, + default=128, + group="network", + info="Encoder Convolution Layer Complexity. sensible ranges: 128 to 150.", + rounding=4, + min_max=(96, 160), + fixed=True) + +complexity_decoder = ConfigItem( + datatype=int, + default=512, + group="network", + info="Decoder Complexity.", + rounding=4, + min_max=(512, 544), + fixed=True) diff --git a/plugins/train/model/unbalanced.py b/plugins/train/model/unbalanced.py index 6f83166305..2756ed73c9 100644 --- a/plugins/train/model/unbalanced.py +++ b/plugins/train/model/unbalanced.py @@ -3,24 +3,24 @@ Based on the original https://www.reddit.com/r/deepfakes/ code sample + contributions """ -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras.initializers import RandomNormal # pylint:disable=import-error -from tensorflow.keras.layers import ( # pylint:disable=import-error - Dense, Flatten, Input, LeakyReLU, Reshape, SpatialDropout2D) -from tensorflow.keras.models import Model as KModel # pylint:disable=import-error +from keras import initializers, Input, layers, Model as KModel from lib.model.nn_blocks import Conv2DOutput, Conv2DBlock, ResidualBlock, UpscaleBlock +from plugins.train.train_config import Loss as cfg_loss + from ._base import ModelBase +from . import unbalanced_defaults as cfg +# pylint:disable=duplicate-code class Model(ModelBase): """ Unbalanced Faceswap Model """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.input_shape = (self.config["input_size"], self.config["input_size"], 3) - self.low_mem = self.config.get("lowmem", False) - self.encoder_dim = 512 if self.low_mem else self.config["nodes"] - self.kernel_initializer = RandomNormal(0, 0.02) + self.input_shape = (cfg.input_size(), cfg.input_size(), 3) + self.low_mem = cfg.lowmem() + self.encoder_dim = 512 if self.low_mem else cfg.nodes() + self.kernel_initializer = initializers.RandomNormal(0, 0.02) def build_model(self, inputs): """ build the Unbalanced Model. """ @@ -28,7 +28,7 @@ def build_model(self, inputs): encoder_a = encoder(inputs[0]) encoder_b = encoder(inputs[1]) - outputs = [self.decoder_a()(encoder_a), self.decoder_b()(encoder_b)] + outputs = self.decoder_a()(encoder_a) + self.decoder_b()(encoder_b) autoencoder = KModel(inputs, outputs, name=self.model_name) return autoencoder @@ -36,7 +36,7 @@ def build_model(self, inputs): def encoder(self): """ Unbalanced Encoder """ kwargs = {"kernel_initializer": self.kernel_initializer} - encoder_complexity = 128 if self.low_mem else self.config["complexity_encoder"] + encoder_complexity = 128 if self.low_mem else cfg.complexity_encoder() dense_dim = 384 if self.low_mem else 512 dense_shape = self.input_shape[0] // 16 input_ = Input(shape=self.input_shape) @@ -53,17 +53,17 @@ def encoder(self): var_x = Conv2DBlock(encoder_complexity * 4, **kwargs, activation="leakyrelu")(var_x) var_x = Conv2DBlock(encoder_complexity * 6, **kwargs, activation="leakyrelu")(var_x) var_x = Conv2DBlock(encoder_complexity * 8, **kwargs, activation="leakyrelu")(var_x) - var_x = Dense(self.encoder_dim, - kernel_initializer=self.kernel_initializer)(Flatten()(var_x)) - var_x = Dense(dense_shape * dense_shape * dense_dim, - kernel_initializer=self.kernel_initializer)(var_x) - var_x = Reshape((dense_shape, dense_shape, dense_dim))(var_x) + var_x = layers.Dense(self.encoder_dim, + kernel_initializer=self.kernel_initializer)(layers.Flatten()(var_x)) + var_x = layers.Dense(dense_shape * dense_shape * dense_dim, + kernel_initializer=self.kernel_initializer)(var_x) + var_x = layers.Reshape((dense_shape, dense_shape, dense_dim))(var_x) return KModel(input_, var_x, name="encoder") def decoder_a(self): """ Decoder for side A """ kwargs = {"kernel_size": 5, "kernel_initializer": self.kernel_initializer} - decoder_complexity = 320 if self.low_mem else self.config["complexity_decoder_a"] + decoder_complexity = 320 if self.low_mem else cfg.complexity_decoder_a() dense_dim = 384 if self.low_mem else 512 decoder_shape = self.input_shape[0] // 16 input_ = Input(shape=(decoder_shape, decoder_shape, dense_dim)) @@ -71,18 +71,18 @@ def decoder_a(self): var_x = input_ var_x = UpscaleBlock(decoder_complexity, activation="leakyrelu", **kwargs)(var_x) - var_x = SpatialDropout2D(0.25)(var_x) + var_x = layers.SpatialDropout2D(0.25)(var_x) var_x = UpscaleBlock(decoder_complexity, activation="leakyrelu", **kwargs)(var_x) if self.low_mem: - var_x = SpatialDropout2D(0.15)(var_x) + var_x = layers.SpatialDropout2D(0.15)(var_x) else: - var_x = SpatialDropout2D(0.25)(var_x) + var_x = layers.SpatialDropout2D(0.25)(var_x) var_x = UpscaleBlock(decoder_complexity // 2, activation="leakyrelu", **kwargs)(var_x) var_x = UpscaleBlock(decoder_complexity // 4, activation="leakyrelu", **kwargs)(var_x) var_x = Conv2DOutput(3, 5, name="face_out_a")(var_x) outputs = [var_x] - if self.config.get("learn_mask", False): + if cfg_loss.learn_mask(): var_y = input_ var_y = UpscaleBlock(decoder_complexity, activation="leakyrelu")(var_y) var_y = UpscaleBlock(decoder_complexity, activation="leakyrelu")(var_y) @@ -95,7 +95,7 @@ def decoder_a(self): def decoder_b(self): """ Decoder for side B """ kwargs = {"kernel_size": 5, "kernel_initializer": self.kernel_initializer} - decoder_complexity = 384 if self.low_mem else self.config["complexity_decoder_b"] + decoder_complexity = 384 if self.low_mem else cfg.complexity_decoder_b() dense_dim = 384 if self.low_mem else 512 decoder_shape = self.input_shape[0] // 16 input_ = Input(shape=(decoder_shape, decoder_shape, dense_dim)) @@ -108,22 +108,22 @@ def decoder_b(self): var_x = UpscaleBlock(decoder_complexity // 8, activation="leakyrelu", **kwargs)(var_x) else: var_x = UpscaleBlock(decoder_complexity, activation=None, **kwargs)(var_x) - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) var_x = ResidualBlock(decoder_complexity, kernel_initializer=self.kernel_initializer)(var_x) var_x = UpscaleBlock(decoder_complexity, activation=None, **kwargs)(var_x) - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) var_x = ResidualBlock(decoder_complexity, kernel_initializer=self.kernel_initializer)(var_x) var_x = UpscaleBlock(decoder_complexity // 2, activation=None, **kwargs)(var_x) - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) var_x = ResidualBlock(decoder_complexity // 2, kernel_initializer=self.kernel_initializer)(var_x) var_x = UpscaleBlock(decoder_complexity // 4, activation="leakyrelu", **kwargs)(var_x) var_x = Conv2DOutput(3, 5, name="face_out_b")(var_x) outputs = [var_x] - if self.config.get("learn_mask", False): + if cfg_loss.learn_mask(): var_y = input_ var_y = UpscaleBlock(decoder_complexity, activation="leakyrelu")(var_y) if not self.low_mem: @@ -135,9 +135,3 @@ def decoder_b(self): var_y = Conv2DOutput(1, 5, name="mask_out_b")(var_y) outputs.append(var_y) return KModel(input_, outputs=outputs, name="decoder_b") - - def _legacy_mapping(self): - """ The mapping of legacy separate model names to single model names """ - return {f"{self.name}_encoder.h5": "encoder", - f"{self.name}_decoder_A.h5": "decoder_a", - f"{self.name}_decoder_B.h5": "decoder_b"} diff --git a/plugins/train/model/unbalanced_defaults.py b/plugins/train/model/unbalanced_defaults.py index 28bbcfd5da..52a2ca46f9 100755 --- a/plugins/train/model/unbalanced_defaults.py +++ b/plugins/train/model/unbalanced_defaults.py @@ -1,116 +1,94 @@ #!/usr/bin/env python3 +""" The default options for the faceswap Unbalanced Model plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: + + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does + +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) + +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ - The default options for the faceswap Unbalanced Model plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} - - should always be lower text. - dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" +# pylint:disable=duplicate-code +from lib.config import ConfigItem -_HELPTEXT = ( +HELPTEXT = ( "An unbalanced model with adjustable input size options.\n" "This is an unbalanced model so b>a swaps may not work well\n" ) -_DEFAULTS = dict( - input_size=dict( - default=128, - info="Resolution (in pixels) of the image to train on.\n" - "BE AWARE Larger resolution will dramatically increaseVRAM requirements.\n" - "Make sure your resolution is divisible by 64 (e.g. 64, 128, 256 etc.).\n" - "NB: Your faceset must be at least 1.6x larger than your required input " - "size.\n(e.g. 160 is the maximum input size for a 256x256 faceset).", - datatype=int, - rounding=64, - min_max=(64, 512), - choices=[], - gui_radio=False, - group="size", - fixed=True), - lowmem=dict( - default=False, - info="Lower memory mode. Set to 'True' if having issues with VRAM useage.\n" - "NB: Models with a changed lowmem mode are not compatible with each other.\n" - "NB: lowmem will override cutom nodes and complexity settings.", - datatype=bool, - rounding=None, - min_max=None, - choices=[], - gui_radio=False, - group="settings", - fixed=True), - nodes=dict( - default=1024, - info="Number of nodes for decoder. Don't change this unless you know what you are doing!", - datatype=int, - rounding=64, - min_max=(512, 4096), - choices=[], - gui_radio=False, - fixed=True, - group="network"), - complexity_encoder=dict( - default=128, - info="Encoder Convolution Layer Complexity. sensible ranges: 128 to 160.", - datatype=int, - rounding=16, - min_max=(64, 1024), - choices=[], - gui_radio=False, - fixed=True, - group="network"), - complexity_decoder_a=dict( - default=384, - info="Decoder A Complexity.", - datatype=int, - rounding=16, - min_max=(64, 1024), - choices=[], - gui_radio=False, - fixed=True, - group="network"), - complexity_decoder_b=dict( - default=512, - info="Decoder B Complexity.", - datatype=int, - rounding=16, - min_max=(64, 1024), - choices=[], - gui_radio=False, - fixed=True, - group="network")) +input_size = ConfigItem( + datatype=int, + default=128, + group="size", + info="Resolution (in pixels) of the image to train on.\n" + "BE AWARE Larger resolution will dramatically increaseVRAM requirements.\n" + "Make sure your resolution is divisible by 64 (e.g. 64, 128, 256 etc.).\n" + "NB: Your faceset must be at least 1.6x larger than your required input " + "size.\n(e.g. 160 is the maximum input size for a 256x256 faceset).", + rounding=64, + min_max=(64, 512), + fixed=True) + +lowmem = ConfigItem( + datatype=bool, + default=False, + group="settings", + info="Lower memory mode. Set to 'True' if having issues with VRAM useage.\n" + "NB: Models with a changed lowmem mode are not compatible with each other.\n" + "NB: lowmem will override cutom nodes and complexity settings.", + fixed=True) + +nodes = ConfigItem( + datatype=int, + default=1024, + group="network", + info="Number of nodes for decoder. Don't change this unless you know what you are doing!", + rounding=64, + min_max=(512, 4096), + fixed=True) + +complexity_encoder = ConfigItem( + datatype=int, + default=128, + group="network", + info="Encoder Convolution Layer Complexity. sensible ranges: 128 to 160.", + rounding=16, + min_max=(64, 1024), + fixed=True) + +complexity_decoder_a = ConfigItem( + datatype=int, + default=384, + group="network", + info="Decoder A Complexity.", + rounding=16, + min_max=(64, 1024), + fixed=True) + +complexity_decoder_b = ConfigItem( + datatype=int, + default=512, + group="network", + info="Decoder B Complexity.", + rounding=16, + min_max=(64, 1024), + fixed=True) diff --git a/plugins/train/model/villain.py b/plugins/train/model/villain.py index 1d6bfc7f10..e9081d05f3 100644 --- a/plugins/train/model/villain.py +++ b/plugins/train/model/villain.py @@ -3,16 +3,16 @@ Based on the original https://www.reddit.com/r/deepfakes/ code sample + contributions Adapted from a model by VillainGuy (https://github.com/VillainGuy) """ -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras.initializers import RandomNormal # pylint:disable=import-error -from tensorflow.keras.layers import add, Dense, Flatten, Input, LeakyReLU, Reshape # noqa:E501 # pylint:disable=import-error -from tensorflow.keras.models import Model as KModel # pylint:disable=import-error +from keras import initializers, Input, layers, Model as KModel from lib.model.layers import PixelShuffler from lib.model.nn_blocks import (Conv2DOutput, Conv2DBlock, ResidualBlock, SeparableConv2DBlock, UpscaleBlock) +from plugins.train.train_config import Loss as cfg_loss from .original import Model as OriginalModel +from . import villain_defaults as cfg +# pylint:disable=duplicate-code class Model(OriginalModel): @@ -21,7 +21,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.input_shape = (128, 128, 3) self.encoder_dim = 512 if self.low_mem else 1024 - self.kernel_initializer = RandomNormal(0, 0.02) + self.kernel_initializer = initializers.RandomNormal(0, 0.02) def encoder(self): """ Encoder Network """ @@ -35,14 +35,14 @@ def encoder(self): var_x = Conv2DBlock(in_conv_filters, activation=None, **kwargs)(input_) tmp_x = var_x - var_x = LeakyReLU(alpha=0.2)(var_x) - res_cycles = 8 if self.config.get("lowmem", False) else 16 + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) + res_cycles = 8 if cfg.lowmem() else 16 for _ in range(res_cycles): nn_x = ResidualBlock(in_conv_filters, **kwargs)(var_x) var_x = nn_x # consider adding scale before this layer to scale the residual chain - tmp_x = LeakyReLU(alpha=0.1)(tmp_x) - var_x = add([var_x, tmp_x]) + tmp_x = layers.LeakyReLU(negative_slope=0.1)(tmp_x) + var_x = layers.add([var_x, tmp_x]) var_x = Conv2DBlock(128, activation="leakyrelu", **kwargs)(var_x) var_x = PixelShuffler()(var_x) var_x = Conv2DBlock(128, activation="leakyrelu", **kwargs)(var_x) @@ -50,12 +50,12 @@ def encoder(self): var_x = Conv2DBlock(128, activation="leakyrelu", **kwargs)(var_x) var_x = SeparableConv2DBlock(256, **kwargs)(var_x) var_x = Conv2DBlock(512, activation="leakyrelu", **kwargs)(var_x) - if not self.config.get("lowmem", False): + if not cfg.lowmem(): var_x = SeparableConv2DBlock(1024, **kwargs)(var_x) - var_x = Dense(self.encoder_dim, **kwargs)(Flatten()(var_x)) - var_x = Dense(dense_shape * dense_shape * 1024, **kwargs)(var_x) - var_x = Reshape((dense_shape, dense_shape, 1024))(var_x) + var_x = layers.Dense(self.encoder_dim, **kwargs)(layers.Flatten()(var_x)) + var_x = layers.Dense(dense_shape * dense_shape * 1024, **kwargs)(var_x) + var_x = layers.Reshape((dense_shape, dense_shape, 1024))(var_x) var_x = UpscaleBlock(512, activation="leakyrelu", **kwargs)(var_x) return KModel(input_, var_x, name="encoder") @@ -67,18 +67,18 @@ def decoder(self, side): var_x = input_ var_x = UpscaleBlock(512, activation=None, **kwargs)(var_x) - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) var_x = ResidualBlock(512, **kwargs)(var_x) var_x = UpscaleBlock(256, activation=None, **kwargs)(var_x) - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) var_x = ResidualBlock(256, **kwargs)(var_x) var_x = UpscaleBlock(self.input_shape[0], activation=None, **kwargs)(var_x) - var_x = LeakyReLU(alpha=0.2)(var_x) + var_x = layers.LeakyReLU(negative_slope=0.2)(var_x) var_x = ResidualBlock(self.input_shape[0], **kwargs)(var_x) var_x = Conv2DOutput(3, 5, name=f"face_out_{side}")(var_x) outputs = [var_x] - if self.config.get("learn_mask", False): + if cfg_loss.learn_mask(): var_y = input_ var_y = UpscaleBlock(512, activation="leakyrelu")(var_y) var_y = UpscaleBlock(256, activation="leakyrelu")(var_y) diff --git a/plugins/train/model/villain_defaults.py b/plugins/train/model/villain_defaults.py index da3af3eecc..22946c252c 100755 --- a/plugins/train/model/villain_defaults.py +++ b/plugins/train/model/villain_defaults.py @@ -1,63 +1,44 @@ #!/usr/bin/env python3 +""" The default options for the faceswap Villain Model plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: + + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does + +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) + +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem """ - The default options for the faceswap Villain Model plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} - - should always be lower text. - dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" +# pylint:disable=duplicate-code +from lib.config import ConfigItem -_HELPTEXT = ( +HELPTEXT = ( "A Higher resolution version of the Original Model by VillainGuy.\n" "Extremely VRAM heavy. Don't try to run this if you have a small GPU.\n" ) -_DEFAULTS = { - "lowmem": { - "default": False, - "info": "Lower memory mode. Set to 'True' if having issues with VRAM useage.\n" - "NB: Models with a changed lowmem mode are not compatible with each other.", - "datatype": bool, - "rounding": None, - "min_max": None, - "choices": [], - "gui_radio": False, - "group": "settings", - "fixed": True, - }, -} +lowmem = ConfigItem( + datatype=bool, + default=False, + group="settings", + info="Lower memory mode. Set to 'True' if having issues with VRAM useage.\n" + "NB: Models with a changed lowmem mode are not compatible with each other.", + fixed=True) diff --git a/plugins/train/train_config.py b/plugins/train/train_config.py new file mode 100644 index 0000000000..814614f48e --- /dev/null +++ b/plugins/train/train_config.py @@ -0,0 +1,805 @@ +#!/usr/bin/env python3 +""" Default configurations for models """ + +import gettext +import logging +import os + +from dataclasses import dataclass + +from lib.config import ConfigItem, FaceswapConfig, GlobalSection +from plugins.plugin_loader import PluginLoader +from plugins.train.trainer import trainer_config + +# LOCALES +_LANG = gettext.translation("plugins.train._config", localedir="locales", fallback=True) +_ = _LANG.gettext + +logger = logging.getLogger(__name__) + + +_ADDITIONAL_INFO = _("\nNB: Unless specifically stated, values changed here will only take effect " + "when creating a new model.") + + +class _Config(FaceswapConfig): + """ Config File for Models """ + # pylint:disable=too-many-statements + def set_defaults(self, helptext="") -> None: + """ Set the default values for config """ + super().set_defaults(helptext=_("Options that apply to all models") + _ADDITIONAL_INFO) + self._defaults_from_plugin(os.path.dirname(__file__)) + + train_helptext, section, train_opts = trainer_config.get_defaults() + self.add_section(section, train_helptext) + for k, v in train_opts.items(): + self.add_item(section, k, v) + + +centering = ConfigItem( + datatype=str, + default="face", + gui_radio=True, + group=_("face"), + info=_( + "How to center the training image. The extracted images are centered on the middle of the " + "skull based on the face's estimated pose. A subsection of these images are used for " + "training. The centering used dictates how this subsection will be cropped from the " + "aligned images." + "\n\tface: Centers the training image on the center of the face, adjusting for pitch and " + "yaw." + "\n\thead: Centers the training image on the center of the head, adjusting for pitch and " + "yaw. NB: You should only select head centering if you intend to include the full head (" + "including hair) in the final swap. This may give mixed results. Additionally, it is only " + "worth choosing head centering if you are training with a mask that includes the hair (" + "e.g. BiSeNet-FP-Head)." + "\n\tlegacy: The 'original' extraction technique. Centers the training image near the tip " + "of the nose with no adjustment. Can result in the edges of the face appearing outside of " + "the training area."), + choices=["face", "head", "legacy"], + fixed=True) + + +coverage = ConfigItem( + datatype=float, + default=100.0, + group=_("face"), + info=_( + "How much of the extracted image to train on. A lower coverage will limit the model's " + "scope to a zoomed-in central area while higher amounts can include the entire face. A " + "trade-off exists between lower amounts given more detail versus higher amounts avoiding " + "noticeable swap transitions. For 'Face' centering you will want to leave this above 75%. " + "For Head centering you will most likely want to set this to 100%. Sensible values for " + "'Legacy' centering are:" + "\n\t62.5% spans from eyebrow to eyebrow." + "\n\t75.0% spans from temple to temple." + "\n\t87.5% spans from ear to ear." + "\n\t100.0% is a mugshot."), + min_max=(62.5, 100.0), + rounding=2, + fixed=True) + + +vertical_offset = ConfigItem( + datatype=int, + default=0, + group=_("face"), + info=_( + "How much to adjust the vertical position of the aligned face as a percentage of face " + "image size. Negative values move the face up (expose more chin and less forehead). " + "Positive values move the face down (expose less chin and more forehead)"), + min_max=(-25, 25), + rounding=1, + fixed=True) + + +icnr_init = ConfigItem( + datatype=bool, + default=False, + group=_("initialization"), + info=_( + "Use ICNR to tile the default initializer in a repeating pattern. This strategy is " + "designed for pairing with sub-pixel / pixel shuffler to reduce the 'checkerboard effect' " + "in image reconstruction. \n\t https://arxiv.org/ftp/arxiv/papers/1707/1707.02937.pdf")) + + +conv_aware_init = ConfigItem( + datatype=bool, + default=False, + group=_("initialization"), + info=_( + "Use Convolution Aware Initialization for convolutional layers. This can help eradicate " + "the vanishing and exploding gradient problem as well as lead to higher accuracy, lower " + "loss and faster convergence.\nNB:\n\t This can use more VRAM when creating a new model " + "so you may want to lower the batch size for the first run. The batch size can be raised " + "again when reloading the model." + "\n\t Multi-GPU is not supported for this option, so you should start the model on a " + "single GPU. Once training has started, you can stop training, enable multi-GPU and " + "resume." + "\n\t Building the model will likely take several minutes as the calculations for this " + "initialization technique are expensive. This will only impact starting a new model.")) + + +lr_finder_iterations = ConfigItem( + datatype=int, + default=1000, + group=_("Learning Rate Finder"), + info=_( + "The number of iterations to process to find the optimal learning rate. Higher values " + "will take longer, but will be more accurate."), + min_max=(100, 10000), + rounding=100, + fixed=True) + + +lr_finder_mode = ConfigItem( + datatype=str, + default="set", + group=_("Learning Rate Finder"), + info=_( + "The operation mode for the learning rate finder. Only applicable to new models. For " + "existing models this will always default to 'set'." + "\n\tset - Train with the discovered optimal learning rate." + "\n\tgraph_and_set - Output a graph in the training folder showing the discovered " + "learning rates and train with the optimal learning rate." + "\n\tgraph_and_exit - Output a graph in the training folder with the discovered learning " + "rates and exit."), + gui_radio=True, + choices=["set", "graph_and_set", "graph_and_exit"], + fixed=True) + + +lr_finder_strength = ConfigItem( + datatype=str, + default="default", + group=_("Learning Rate Finder"), + info=_( + "How aggressively to set the Learning Rate. More aggressive can learn faster, but is more " + "likely to lead to exploding gradients." + "\n\tdefault - The default optimal learning rate. A safe choice for nearly all use cases." + "\n\taggressive - Set's a higher learning rate than the default. May learn faster but " + "with a higher chance of exploding gradients." + "\n\textreme - The highest optimal learning rate. A much higher risk of exploding " + "gradients."), + gui_radio=True, + choices=["default", "aggressive", "extreme"], + fixed=True) + + +reflect_padding = ConfigItem( + datatype=bool, + default=False, + group=_("network"), + info=_( + "Use reflection padding rather than zero padding with convolutions. Each convolution must " + "pad the image boundaries to maintain the proper sizing. More complex padding schemes can " + "reduce artifacts at the border of the image." + "\n\t http://www-cs.engr.ccny.cuny.edu/~wolberg/cs470/hw/hw2_pad.txt")) + + +mixed_precision = ConfigItem( + datatype=bool, + default=False, + group=_("network"), + info=_( + "NVIDIA GPUs can run operations in float16 faster than in float32. Mixed precision allows " + "you to use a mix of float16 with float32, to get the performance benefits from float16 " + "and the numeric stability benefits from float32.\n\nThis is untested on non-Nvidia " + "cards, but will run on most Nvidia models. it will only speed up training on more recent " + "GPUs. Those with compute capability 7.0 or higher will see the greatest performance " + "benefit from mixed precision because they have Tensor Cores. Older GPUs offer no math " + "performance benefit for using mixed precision, however memory and bandwidth savings can " + "enable some speedups. Generally RTX GPUs and later will offer the most benefit."), + fixed=False) + + +nan_protection = ConfigItem( + datatype=bool, + default=True, + group=_("network"), + info=_( + "If a 'NaN' is generated in the model, this means that the model has corrupted and the " + "model is likely to start deteriorating from this point on. Enabling NaN protection will " + "stop training immediately in the event of a NaN. The last save will not contain the NaN, " + "so you may still be able to rescue your model."), + fixed=False) + + +convert_batchsize = ConfigItem( + datatype=int, + default=16, + group=_("convert"), + info=_( + "[GPU Only]. The number of faces to feed through the model at once when running the " + "Convert process.\n\nNB: Increasing this figure is unlikely to improve convert speed, " + "however, if you are getting Out of Memory errors, then you may want to reduce the batch " + "size."), + min_max=(1, 32), + rounding=1, + fixed=False) + + +_LOSS_HELP = { + "ffl": _( + "Focal Frequency Loss. Analyzes the frequency spectrum of the images rather than the " + "images themselves. This loss function can be used on its own, but the original paper " + "found increased benefits when using it as a complementary loss to another spacial loss " + "function (e.g. MSE). Ref: Focal Frequency Loss for Image Reconstruction and Synthesis " + "https://arxiv.org/pdf/2012.12821.pdf NB: This loss does not currently work on AMD " + "cards."), + "flip": _( + "Nvidia FLIP. A perceptual loss measure that approximates the difference perceived by " + "humans as they alternate quickly (or flip) between two images. Used on its own and this " + "loss function creates a distinct grid on the output. However it can be helpful when " + "used as a complimentary loss function. Ref: FLIP: A Difference Evaluator for " + "Alternating Images: " + "https://research.nvidia.com/sites/default/files/node/3260/FLIP_Paper.pdf"), + "gmsd": _( + "Gradient Magnitude Similarity Deviation seeks to match the global standard deviation of " + "the pixel to pixel differences between two images. Similar in approach to SSIM. Ref: " + "Gradient Magnitude Similarity Deviation: An Highly Efficient Perceptual Image Quality " + "Index https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf"), + "l_inf_norm": _( + "The L_inf norm will reduce the largest individual pixel error in an image. As " + "each largest error is minimized sequentially, the overall error is improved. This loss " + "will be extremely focused on outliers."), + "laploss": _( + "Laplacian Pyramid Loss. Attempts to improve results by focussing on edges using " + "Laplacian Pyramids. As this loss function gives priority to edges over other low-" + "frequency information, like color, it should not be used on its own. The original " + "implementation uses this loss as a complimentary function to MSE. " + "Ref: Optimizing the Latent Space of Generative Networks " + "https://arxiv.org/abs/1707.05776"), + "lpips_alex": _( + "LPIPS is a perceptual loss that uses the feature outputs of other pretrained models as a " + "loss metric. Be aware that this loss function will use more VRAM. Used on its own and " + "this loss will create a distinct moire pattern on the output, however it can be helpful " + "as a complimentary loss function. The output of this function is strong, so depending " + "on your chosen primary loss function, you are unlikely going to want to set the weight " + "above about 25%. Ref: The Unreasonable Effectiveness of Deep Features as a Perceptual " + "Metric http://arxiv.org/abs/1801.03924\nThis variant uses the AlexNet backbone. A fairly " + "light and old model which performed best in the paper's original implementation.\nNB: " + "For AMD Users the final linear layer is not implemented."), + "lpips_squeeze": _( + "Same as lpips_alex, but using the SqueezeNet backbone. A more lightweight " + "version of AlexNet.\nNB: For AMD Users the final linear layer is not implemented."), + "lpips_vgg16": _( + "Same as lpips_alex, but using the VGG16 backbone. A more heavyweight model.\n" + "NB: For AMD Users the final linear layer is not implemented."), + "logcosh": _( + "log(cosh(x)) acts similar to MSE for small errors and to MAE for large errors. Like " + "MSE, it is very stable and prevents overshoots when errors are near zero. Like MAE, it " + "is robust to outliers."), + "mae": _( + "Mean absolute error will guide reconstructions of each pixel towards its median value in " + "the training dataset. Robust to outliers but as a median, it can potentially ignore some " + "infrequent image types in the dataset."), + "mse": _( + "Mean squared error will guide reconstructions of each pixel towards its average value in " + "the training dataset. As an avg, it will be susceptible to outliers and typically " + "produces slightly blurrier results. Ref: Multi-Scale Structural Similarity for Image " + "Quality Assessment https://www.cns.nyu.edu/pub/eero/wang03b.pdf"), + "ms_ssim": _( + "Multiscale Structural Similarity Index Metric is similar to SSIM except that it " + "performs the calculations along multiple scales of the input image."), + "smooth_loss": _( + "Smooth_L1 is a modification of the MAE loss to correct two of its disadvantages. " + "This loss has improved stability and guidance for small errors. Ref: A General and " + "Adaptive Robust Loss Function https://arxiv.org/pdf/1701.03077.pdf"), + "ssim": _( + "Structural Similarity Index Metric is a perception-based loss that considers changes in " + "texture, luminance, contrast, and local spatial statistics of an image. Potentially " + "delivers more realistic looking images. Ref: Image Quality Assessment: From Error " + "Visibility to Structural Similarity http://www.cns.nyu.edu/pub/eero/wang03-reprint.pdf"), + "pixel_gradient_diff": _( + "Instead of minimizing the difference between the absolute value of each " + "pixel in two reference images, compute the pixel to pixel spatial difference in each " + "image and then minimize that difference between two images. Allows for large color " + "shifts, but maintains the structure of the image."), + "none": _("Do not use an additional loss function.")} + +_NON_PRIMARY_LOSS = ["flip", "lpips_alex", "lpips_squeeze", "lpips_vgg16", "none"] + + +@dataclass +class Loss(GlobalSection): + """ global.loss configuration section + Loss Documentation + MAE https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine-learners-should-know-4fb140e9d4b0 + MSE https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine-learners-should-know-4fb140e9d4b0 + LogCosh https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine-learners-should-know-4fb140e9d4b0 + L_inf_norm https://medium.com/@montjoile/l0-norm-l1-norm-l2-norm-l-infinity-norm-7a7d18a4f40c + """ # pylint:disable=line-too-long # noqa[E501] + + helptext = _( + "Loss configuration options\n" + "Loss is the mechanism by which a Neural Network judges how well it thinks that it " + "is recreating a face.") + _ADDITIONAL_INFO + loss_function = ConfigItem( + datatype=str, + default="ssim", + group=_("loss"), + info=(_("The loss function to use.") + + "\n\n\t" + "\n\n\t".join(f"{k}: {v}" + for k, v in sorted(_LOSS_HELP.items()) + if k not in _NON_PRIMARY_LOSS)), + choices=[x for x in sorted(_LOSS_HELP) if x not in _NON_PRIMARY_LOSS], + fixed=False) + loss_function_2 = ConfigItem( + datatype=str, + default="mse", + group=_("loss"), + info=_( + "The second loss function to use. If using a structural based loss (such as " + "SSIM, MS-SSIM or GMSD) it is common to add an L1 regularization(MAE) or L2 " + "regularization (MSE) function. You can adjust the weighting of this loss " + "function with the loss_weight_2 option." + + "\n\n\t" + "\n\n\t".join(f"{k}: {v}" for k, v in sorted(_LOSS_HELP.items()))), + choices=list(sorted(_LOSS_HELP)), + fixed=False) + loss_weight_2 = ConfigItem( + datatype=int, + default=100, + group=_("loss"), + info=_( + "The amount of weight to apply to the second loss function.\n\n" + "\n\nThe value given here is as a percentage denoting how much the selected " + "function should contribute to the overall loss cost of the model. For " + "example:" + "\n\t 100 - The loss calculated for the second loss function will be applied " + "at its full amount towards the overall loss score. " + "\n\t 25 - The loss calculated for the second loss function will be reduced " + "by a quarter prior to adding to the overall loss score. " + "\n\t 400 - The loss calculated for the second loss function will be " + "mulitplied 4 times prior to adding to the overall loss score. " + "\n\t 0 - Disables the second loss function altogether."), + min_max=(0, 400), + rounding=1, + fixed=False) + loss_function_3 = ConfigItem( + datatype=str, + default="none", + group=_("loss"), + info=_("The third loss function to use. You can adjust the weighting of this loss " + "function with the loss_weight_3 option." + + "\n\n\t" + + "\n\n\t".join(f"{k}: {v}" for k, v in sorted(_LOSS_HELP.items()))), + choices=list(sorted(_LOSS_HELP)), + fixed=False) + loss_weight_3 = ConfigItem( + datatype=int, + default=0, + group=_("loss"), + info=_( + "The amount of weight to apply to the third loss function.\n\n" + "\n\nThe value given here is as a percentage denoting how much the selected " + "function should contribute to the overall loss cost of the model. For " + "example:" + "\n\t 100 - The loss calculated for the third loss function will be applied " + "at its full amount towards the overall loss score. " + "\n\t 25 - The loss calculated for the third loss function will be reduced " + "by a quarter prior to adding to the overall loss score. " + "\n\t 400 - The loss calculated for the third loss function will be " + "mulitplied 4 times prior to adding to the overall loss score. " + "\n\t 0 - Disables the third loss function altogether."), + min_max=(0, 400), + rounding=1, + fixed=False) + loss_function_4 = ConfigItem( + datatype=str, + default="none", + group=_("loss"), + info=_( + "The fourth loss function to use. You can adjust the weighting of this " + "loss function with the loss_weight_3 option." + + "\n\n\t" + + "\n\n\t".join(f"{k}: {v}" for k, v in sorted(_LOSS_HELP.items()))), + choices=list(sorted(_LOSS_HELP)), + fixed=False) + loss_weight_4 = ConfigItem( + datatype=int, + default=0, + group=_("loss"), + info=_( + "The amount of weight to apply to the fourth loss function.\n\n" + "\n\nThe value given here is as a percentage denoting how much the selected " + "function should contribute to the overall loss cost of the model. For " + "example:" + "\n\t 100 - The loss calculated for the fourth loss function will be applied " + "at its full amount towards the overall loss score. " + "\n\t 25 - The loss calculated for the fourth loss function will be reduced " + "by a quarter prior to adding to the overall loss score. " + "\n\t 400 - The loss calculated for the fourth loss function will be " + "mulitplied 4 times prior to adding to the overall loss score. " + "\n\t 0 - Disables the fourth loss function altogether."), + min_max=(0, 400), + rounding=1, + fixed=False) + mask_loss_function = ConfigItem( + datatype=str, + default="mse", + group=_("loss"), + info=_( + "The loss function to use when learning a mask." + "\n\t MAE - Mean absolute error will guide reconstructions of each pixel " + "towards its median value in the training dataset. Robust to outliers but as " + "a median, it can potentially ignore some infrequent image types in the " + "dataset." + "\n\t MSE - Mean squared error will guide reconstructions of each pixel " + "towards its average value in the training dataset. As an average, it will be " + "susceptible to outliers and typically produces slightly blurrier results."), + choices=["mae", "mse"], + fixed=False) + eye_multiplier = ConfigItem( + datatype=int, + default=3, + group=_("loss"), + info=_( + "The amount of priority to give to the eyes.\n\nThe value given here is as a " + "multiplier of the main loss score. For example:" + "\n\t 1 - The eyes will receive the same priority as the rest of the face. " + "\n\t 10 - The eyes will be given a score 10 times higher than the rest of " + "the face." + "\n\nNB: Penalized Mask Loss must be enable to use this option."), + min_max=(1, 40), + rounding=1, + fixed=False) + mouth_multiplier = ConfigItem( + datatype=int, + default=2, + group=_("loss"), + info=_( + "The amount of priority to give to the mouth.\n\nThe value given here is as a " + "multiplier of the main loss score. For Example:" + "\n\t 1 - The mouth will receive the same priority as the rest of the face. " + "\n\t 10 - The mouth will be given a score 10 times higher than the rest of " + "the face." + "\n\nNB: Penalized Mask Loss must be enable to use this option."), + min_max=(1, 40), + rounding=1, + fixed=False) + penalized_mask_loss = ConfigItem( + datatype=bool, + default=True, + group=_("loss"), + info=_( + "Image loss function is weighted by mask presence. For areas of " + "the image without the facial mask, reconstruction errors will be " + "ignored while the masked face area is prioritized. May increase " + "overall quality by focusing attention on the core face area.")) + mask_type = ConfigItem( + datatype=str, + default="extended", + group=_("mask"), + info=_( + "The mask to be used for training. If you have selected 'Learn Mask' or " + "'Penalized Mask Loss' you must select a value other than 'none'. The " + "required mask should have been selected as part of the Extract process. If " + "it does not exist in the alignments file then it will be generated prior to " + "training commencing." + "\n\tnone: Don't use a mask." + "\n\tbisenet-fp_face: Relatively lightweight NN based mask that provides more " + "refined control over the area to be masked (configurable in mask settings). " + "Use this version of bisenet-fp if your model is trained with 'face' or " + "'legacy' centering." + "\n\tbisenet-fp_head: Relatively lightweight NN based mask that provides more " + "refined control over the area to be masked (configurable in mask settings). " + "Use this version of bisenet-fp if your model is trained with 'head' " + "centering." + "\n\tcomponents: 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." + "\n\tcustom_face: Custom user created, face centered mask." + "\n\tcustom_head: Custom user created, head centered mask." + "\n\textended: 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." + "\n\tvgg-clear: Mask designed to provide smart segmentation of mostly frontal " + "faces clear of obstructions. Profile faces and obstructions may result in " + "sub-par performance." + "\n\tvgg-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." + "\n\tunet-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."), + choices=PluginLoader.get_available_extractors("mask", + add_none=True, extend_plugin=True), + gui_radio=True) + mask_dilation = ConfigItem( + datatype=float, + default=0.0, + group=_("mask"), + info=_( + "Dilate or erode the mask. Negative values erode the mask (make it smaller). " + "Positive values dilate the mask (make it larger). The value given is a " + "percentage of the total mask size."), + min_max=(-5.0, 5.0), + rounding=1, + fixed=False) + mask_blur_kernel = ConfigItem( + datatype=int, + default=3, + group=_("mask"), + info=_( + "Apply gaussian blur to the mask input. This has the effect of smoothing the " + "edges of the mask, which can help with poorly calculated masks and give less " + "of a hard edge to the predicted mask. The size is in pixels (calculated from " + "a 128px mask). Set to 0 to not apply gaussian blur. This value should be " + "odd, if an even number is passed in then it will be rounded to the next odd " + "number."), + min_max=(0, 9), + rounding=1, + fixed=False) + mask_threshold = ConfigItem( + datatype=int, + default=4, + group=_("mask"), + info=_( + "Sets pixels that are near white to white and near black to black. Set to 0 " + "for off."), + min_max=(0, 50), + rounding=1, + fixed=False) + learn_mask = ConfigItem( + datatype=bool, + default=False, + group=_("mask"), + info=_( + "Dedicate a portion of the model to learning how to duplicate the input " + "mask. Increases VRAM usage in exchange for learning a quick ability to try " + "to replicate more complex mask models.")) + + +@dataclass +class Optimizer(GlobalSection): + """ global.optimizer configuration section """ + helptext = (_("Optimizer configuration options\n" + "The optimizer applies the output of the loss function to the model.\n") + + _ADDITIONAL_INFO) + optimizer = ConfigItem( + datatype=str, + default="adam", + group=_("optimizer"), + info=_( + "The optimizer to use." + "\n\t adabelief - Adapting Stepsizes by the Belief in Observed Gradients. An " + "optimizer with the aim to converge faster, generalize better and remain more " + "stable. (https://arxiv.org/abs/2010.07468). NB: Epsilon for AdaBelief needs " + "to be set to a smaller value than other Optimizers. Generally setting the " + "'Epsilon Exponent' to around '-16' should work." + "\n\t adam - Adaptive Moment Optimization. A stochastic gradient descent " + "method that is based on adaptive estimation of first-order and second-order " + "moments." + "\n\t adamax - a variant of Adam based on the infinity norm. Due to its " + "capability of adjusting the learning rate based on data characteristics, it " + "is suited to learn time-variant process, " + "parameters follow those provided in the paper" + "\n\t adamw - Like 'adam' but with an added method to decay weights per the " + "techniques discussed in the paper (https://arxiv.org/abs/1711.05101). NB: " + "Weight decay should be set at 0.004 for default implementation." + "\n\t lion - A method that uses the sign operator to control the magnitude of " + "the update, rather than relying on second-order moments (Adam). saves VRAM " + "by only tracking the momentum. Performance gains should be better with " + "larger batch sizes. A suitable learning rate for Lion is typically 3-10x " + "smaller than that for AdamW. The weight decay for Lion should be 3-10x " + "larger than that for AdamW to maintain a similar strength." + "\n\t nadam - Adaptive Moment Optimization with Nesterov Momentum. Much like " + "Adam but uses a different formula for calculating momentum." + "\n\t rms-prop - Root Mean Square Propagation. Maintains a moving " + "(discounted) average of the square of the gradients. Divides the gradient by " + "the root of this average."), + choices=["adabelief", "adam", "adamax", "adamw", "lion", "nadam", "rms-prop"], + gui_radio=True, + fixed=True) + learning_rate = ConfigItem( + datatype=float, + default=5e-5, + group=_("optimizer"), + info=_( + "Learning rate - how fast your network will learn (how large are the " + "modifications to the model weights after one batch of training). Values that " + "are too large might result in model crashes and the inability of the model " + "to find the best solution. Values that are too small might be unable to " + "escape from dead-ends and find the best global minimum."), + min_max=(1e-6, 1e-4), + rounding=6, + fixed=False) + epsilon_exponent = ConfigItem( + datatype=int, + default=-7, + group=_("optimizer"), + info=_( + "The epsilon adds a small constant to weight updates to attempt to avoid " + "'divide by zero' errors. Unless you are using the AdaBelief Optimizer, then " + "Generally this option should be left at default value, For AdaBelief, " + "setting this to around '-16' should work.\n" + "In all instances if you are getting 'NaN' loss values, and have been unable " + "to resolve the issue any other way (for example, increasing batch size, or " + "lowering learning rate), then raising the epsilon can lead to a more stable " + "model. It may, however, come at the cost of slower training and a less " + "accurate final result.\n" + "Note: The value given here is the 'exponent' to the epsilon. For example, " + "choosing '-7' will set the epsilon to 1e-7. Choosing '-3' will set the " + "epsilon to 0.001 (1e-3).\n" + "Note: Not used by the Lion optimizer"), + min_max=(-20, 0), + rounding=1, + fixed=False) + save_optimizer = ConfigItem( + datatype=str, + default="exit", + group=_("optimizer"), + info=_( + "When to save the Optimizer Weights. Saving the optimizer weights is not " + "necessary and will increase the model file size 3x (and by extension the " + "amount of time it takes to save the model). However, it can be useful to " + "save these weights if you want to guarantee that a resumed model carries off " + "exactly from where it left off, rather than spending a few hundred " + "iterations catching up." + "\n\t never - Don't save optimizer weights." + "\n\t always - Save the optimizer weights at every save iteration. Model " + "saving will take longer, due to the increased file size, but you will always " + "have the last saved optimizer state in your model file." + "\n\t exit - Only save the optimizer weights when explicitly terminating a " + "model. This can be when the model is actively stopped or when the target " + "iterations are met. Note: If the training session ends because of another " + "reason (e.g. power outage, Out of Memory Error, NaN detected) then the " + "optimizer weights will NOT be saved."), + gui_radio=True, + choices=["never", "always", "exit"], + fixed=False) + gradient_clipping = ConfigItem( + datatype=str, + default="none", + group=_("clipping"), + info=_( + "Apply clipping to the gradients. Can help prevent NaNs and improve model " + "optimization at the expense of VRAM." + "\n\tautoclip: Analyzes the gradient weights and adjusts the normalization " + "value dynamically to fit the data" + "\n\tglobal_norm: Clips the gradient of each weight so that the global norm " + "is no higher than the given value." + "\n\tnorm: Clips the gradient of each weight so that its norm is no higher " + "than the given value." + "\n\tvalue: Clips the gradient of each weight so that it is no higher than " + "the given value." + "\n\tnone: Don't perform any clipping to the gradients."), + choices=["autoclip", "global_norm", "norm", "value", "none"], + gui_radio=True, + fixed=False) + clipping_value = ConfigItem( + datatype=float, + default=1.0, + group=_("clipping"), + info=_( + "The amount of clipping to perform." + "\n\tautoclip: The percentile to clip at. A value of 1.0 will clip at the " + "10th percentile a value of 2.5 will clip at the 25th percentile etc. " + "Default: 1.0" + "\n\tglobal_norm: The gradient of each weight is clipped so that the global " + "norm is no higher than this value." + "\n\tnorm: The gradient of each weight is clipped so that its norm is no " + "higher than this value." + "\n\tvalue: The gradient of each weight is clipped to be no higher than this " + "value." + "\n\tnone: This option is ignored."), + min_max=(0.0, 10.0), + rounding=1, + fixed=False) + autoclip_history = ConfigItem( + datatype=int, + default=10000, + group=_("clipping"), + info=_( + "The maximum number of prior iterations for autoclipper to analyze when " + "calculating the normalization amount. 0 to always include all prior " + "iterations."), + min_max=(0, 100000), + rounding=1000, + fixed=False) + weight_decay = ConfigItem( + datatype=float, + default=0.0, + group=_("updates"), + info=_("If set, weight decay is applied. 0.0 for no weight decay. Default is 0.0 " + "for all optimizers except AdamW (0.004)"), + min_max=(0.0, 1.0), + rounding=4, + fixed=False) + gradient_accumulation = ConfigItem( + datatype=int, + default=1, + group=_("updates"), + info=_( + "Values above 1 will enable Gradient Accumulation. Updates will not be at " + "every iteration; instead they will occur every number of iterations given " + "here. The update will be the average value of the gradients since the last " + "update. Can be useful when your batch size is very small, in order to reduce " + "gradient noise at each update iteration."), + min_max=(1, 100), + rounding=1, + fixed=False) + use_ema = ConfigItem( + datatype=bool, + default=False, + group=_("exponential moving average"), + info=_( + "Enable exponential moving average (EMA). EMA consists of computing an " + "exponential moving average of the weights of the model (as the weight values " + "change after each training batch), and periodically overwriting the weights " + "with their moving average"), + fixed=True) + ema_momentum = ConfigItem( + datatype=float, + default=0.99, + group=_("exponential moving average"), + info=_( + "Only used if use_ema is enabled. This is the momentum to use when computing " + "the EMA of the model's weights: new_average = ema_momentum * old_average + " + "(1 - ema_momentum) * current_variable_value."), + min_max=(0.0, 1.0), + rounding=4, + fixed=True) + ema_frequency = ConfigItem( + datatype=int, + default=100, + group=_("exponential moving average"), + info=_( + "Only used if use_ema is enabled. Set the number of iterations, to overwrite " + "the model variable by its moving average. "), + min_max=(10, 10000), + rounding=10, + fixed=True) + ada_beta_1 = ConfigItem( + datatype=float, + default=0.9, + group=_("optimizer specific"), + info=_( + "The exponential decay rate for the 1st moment estimates. Used for the " + "following Optimizers: AdaBelief, Adam, Adamax, AdamW, Lion, nAdam. Ignored " + "for all others."), + min_max=(0.0, 1.0), + rounding=4, + fixed=True) + ada_beta_2 = ConfigItem( + datatype=float, + default=0.999, + group=_("optimizer specific"), + info=_( + "The exponential decay rate for the 2nd moment estimates. Used for the " + "following Optimizers: AdaBelief, Adam, Adamax, AdamW, Lion, nAdam. Ignored " + "for all others."), + min_max=(0.0, 1.0), + rounding=4, + fixed=True) + ada_amsgrad = ConfigItem( + datatype=bool, + default=False, + group=_("optimizer specific"), + info=_( + "Whether to apply AMSGrad variant of the algorithm from the paper 'On the " + "Convergence of Adam and beyond. Used for the following Optimizers: " + "AdaBelief, Adam, AdamW. Ignored for all others.'"), + fixed=True) + + +# pylint:disable=duplicate-code +_IS_LOADED: bool = False + + +def load_config(config_file: str | None = None) -> None: + """ Load the Train configuration .ini file + + Parameters + ---------- + config_file : str | None, optional + Path to a custom .ini configuration file to load. Default: ``None`` (use default + configuration file) + """ + global _IS_LOADED # pylint:disable=global-statement + if not _IS_LOADED: + _Config(configfile=config_file) + _IS_LOADED = True diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py index 529c0f87a7..aebcce0810 100644 --- a/plugins/train/trainer/_base.py +++ b/plugins/train/trainer/_base.py @@ -7,937 +7,52 @@ with "original" unique code split out to the original plugin. """ from __future__ import annotations +import abc import logging -import os -import time import typing as T -import cv2 -import numpy as np - -import keras.backend as K -import tensorflow as tf -from tensorflow.python.framework import ( # pylint:disable=no-name-in-module - errors_impl as tf_errors) - -from lib.image import hex_to_rgb -from lib.training import Feeder, LearningRateFinder, LearningRateWarmup -from lib.utils import FaceswapError, get_folder, get_image_paths -from plugins.train._config import Config +import torch if T.TYPE_CHECKING: - from collections.abc import Callable from plugins.train.model._base import ModelBase - from lib.config import ConfigValueType logger = logging.getLogger(__name__) -def _get_config(plugin_name: str, - configfile: str | None = None) -> dict[str, ConfigValueType]: - """ Return the configuration for the requested trainer. - - Parameters - ---------- - plugin_name: str - The name of the plugin to load the configuration for - configfile: str, optional - A custom configuration file. If ``None`` then configuration is loaded from the default - :file:`.config.train.ini` file. Default: ``None`` - - Returns - ------- - dict - The configuration dictionary for the requested plugin - """ - return Config(plugin_name, configfile=configfile).config_dict - - -class TrainerBase(): - """ Handles the feeding of training images to Faceswap models, the generation of Tensorboard - logs and the creation of sample/time-lapse preview images. - - All Trainer plugins must inherit from this class. - - Parameters - ---------- - model: plugin from :mod:`plugins.train.model` - The model that will be running this trainer - images: dict - The file paths for the images to be trained on for each side. The dictionary should contain - 2 keys ("a" and "b") with the values being a list of full paths corresponding to each side. - batch_size: int - The requested batch size for iteration to be trained through the model. - configfile: str - The path to a custom configuration file. If ``None`` is passed then configuration is loaded - from the default :file:`.config.train.ini` file. - """ - - def __init__(self, - model: ModelBase, - images: dict[T.Literal["a", "b"], list[str]], - batch_size: int, - configfile: str | None) -> None: - logger.debug("Initializing %s: (model: '%s', batch_size: %s)", - self.__class__.__name__, model, batch_size) - self._model = model - self._config = self._get_config(configfile) - - self._feeder = Feeder(images, model, batch_size, self._config) - - self._exit_early = self._handle_lr_finder() - if self._exit_early: - return - - self._warmup = self._get_warmup() - self._model.state.add_session_batchsize(batch_size) - self._images = images - self._sides = sorted(key for key in self._images.keys()) - - self._tensorboard = self._set_tensorboard() - self._samples = _Samples(self._model, - self._model.coverage_ratio, - T.cast(int, self._config["mask_opacity"]), - T.cast(str, self._config["mask_color"])) - - num_images = self._config.get("preview_images", 14) - assert isinstance(num_images, int) - self._timelapse = _Timelapse(self._model, - self._model.coverage_ratio, - num_images, - T.cast(int, self._config["mask_opacity"]), - T.cast(str, self._config["mask_color"]), - self._feeder, - self._images) - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def exit_early(self) -> bool: - """ True if the trainer should exit early, without perfoming any training steps """ - return self._exit_early - - def _get_config(self, configfile: str | None) -> dict[str, ConfigValueType]: - """ Get the saved training config options. Override any global settings with the setting - provided from the model's saved config. - - Parameters - ----------- - configfile: str - The path to a custom configuration file. If ``None`` is passed then configuration is - loaded from the default :file:`.config.train.ini` file. - - Returns - ------- - dict - The trainer configuration options - """ - config = _get_config(".".join(self.__module__.split(".")[-2:]), - configfile=configfile) - for key, val in config.items(): - if key in self._model.config and val != self._model.config[key]: - new_val = self._model.config[key] - logger.debug("Updating global training config item for '%s' form '%s' to '%s'", - key, val, new_val) - config[key] = new_val - return config - - def _handle_lr_finder(self) -> bool: - """ Handle the learning rate finder. - - If this is a new model, then find the optimal learning rate and return ``True`` if user has - just requested the graph, otherwise return ``False`` to continue training - - If it as existing model, set the learning rate to the value found by the learing rate - finder and return ``False`` to continue training - - Returns - ------- - bool - ``True`` if the learning rate finder options dictate that training should not continue - after finding the optimal leaning rate - """ - if not self._model.command_line_arguments.use_lr_finder: - return False - - if self._model.state.lr_finder > -1: - learning_rate = self._model.state.lr_finder - logger.info("Setting learning rate from Learning Rate Finder to %s", - f"{learning_rate:.1e}") - K.set_value(self._model.model.optimizer.lr, learning_rate) - self._model.state.update_session_config("learning_rate", learning_rate) - return False - - if self._model.state.iterations == 0 and self._model.state.session_id == 1: - lrf = LearningRateFinder(self._model, self._config, self._feeder) - success = lrf.find() - return self._config["lr_finder_mode"] == "graph_and_exit" or not success - - logger.debug("No learning rate finder rate. Not setting") - return False - - def _get_warmup(self) -> LearningRateWarmup: - """ Obtain the learning rate warmup instance - - Returns - ------- - :class:`plugins.train.lr_warmup.LRWarmup` - The Learning Rate Warmup object - """ - target_lr = float(K.get_value(self._model.model.optimizer.lr)) - return LearningRateWarmup(self._model.model, target_lr, self._model.warmup_steps) - - def _set_tensorboard(self) -> tf.keras.callbacks.TensorBoard: - """ Set up Tensorboard callback for logging loss. - - Bypassed if command line option "no-logs" has been selected. - - Returns - ------- - :class:`tf.keras.callbacks.TensorBoard` - Tensorboard object for the the current training session. - """ - if self._model.state.current_session["no_logs"]: - logger.verbose("TensorBoard logging disabled") # type: ignore - return None - logger.debug("Enabling TensorBoard Logging") - - logger.debug("Setting up TensorBoard Logging") - log_dir = os.path.join(str(self._model.io.model_dir), - f"{self._model.name}_logs", - f"session_{self._model.state.session_id}") - tensorboard = tf.keras.callbacks.TensorBoard(log_dir=log_dir, - histogram_freq=0, # Must be 0 or hangs - write_graph=True, - write_images=False, - update_freq="batch", - profile_batch=0, - embeddings_freq=0, - embeddings_metadata=None) - tensorboard.set_model(self._model.model) - tensorboard.on_train_begin(0) - logger.verbose("Enabled TensorBoard Logging") # type: ignore - return tensorboard - - def toggle_mask(self) -> None: - """ Toggle the mask overlay on or off based on user input. """ - self._samples.toggle_mask_display() - - def train_one_step(self, - viewer: Callable[[np.ndarray, str], None] | None, - timelapse_kwargs: dict[T.Literal["input_a", "input_b", "output"], - str] | None) -> None: - """ Running training on a batch of images for each side. - - Triggered from the training cycle in :class:`scripts.train.Train`. - - * Runs a training batch through the model. - - * Outputs the iteration's loss values to the console - - * Logs loss to Tensorboard, if logging is requested. - - * If a preview or time-lapse has been requested, then pushes sample images through the \ - model to generate the previews - - * Creates a snapshot if the total iterations trained so far meet the requested snapshot \ - criteria - - Notes - ----- - As every iteration is called explicitly, the Parameters defined should always be ``None`` - except on save iterations. - - Parameters - ---------- - viewer: :func:`scripts.train.Train._show` or ``None`` - The function that will display the preview image - timelapse_kwargs: dict - The keyword arguments for generating time-lapse previews. If a time-lapse preview is - not required then this should be ``None``. Otherwise all values should be full paths - the keys being `input_a`, `input_b`, `output`. - """ - self._model.state.increment_iterations() - logger.trace("Training one step: (iteration: %s)", self._model.iterations) # type: ignore - snapshot_interval = self._model.command_line_arguments.snapshot_interval - do_snapshot = (snapshot_interval != 0 and - self._model.iterations - 1 >= snapshot_interval and - (self._model.iterations - 1) % snapshot_interval == 0) - - model_inputs, model_targets = self._feeder.get_batch() - self._warmup() - - try: - loss: list[float] = self._model.model.train_on_batch(model_inputs, y=model_targets) - except tf_errors.ResourceExhaustedError as err: - msg = ("You do not have enough GPU memory available to train the selected model at " - "the selected settings. You can try a number of things:" - "\n1) Close any other application that is using your GPU (web browsers are " - "particularly bad for this)." - "\n2) Lower the batchsize (the amount of images fed into the model each " - "iteration)." - "\n3) Try enabling 'Mixed Precision' training." - "\n4) Use a more lightweight model, or select the model's 'LowMem' option " - "(in config) if it has one.") - raise FaceswapError(msg) from err - self._log_tensorboard(loss) - loss = self._collate_and_store_loss(loss[1:]) - self._print_loss(loss) - if do_snapshot: - self._model.io.snapshot() - self._update_viewers(viewer, timelapse_kwargs) - - def _log_tensorboard(self, loss: list[float]) -> None: - """ Log current loss to Tensorboard log files - - Parameters - ---------- - loss: list - The list of loss ``floats`` output from the model - """ - if not self._tensorboard: - return - logger.trace("Updating TensorBoard log") # type: ignore - logs = {log[0]: log[1] - for log in zip(self._model.state.loss_names, loss)} - - # Bug in TF 2.8/2.9/2.10 where batch recording got deleted. - # ref: https://github.com/keras-team/keras/issues/16173 - with tf.summary.record_if(True), self._tensorboard._train_writer.as_default(): # noqa:E501 pylint:disable=protected-access,not-context-manager - for name, value in logs.items(): - tf.summary.scalar( - "batch_" + name, - value, - step=self._tensorboard._train_step) # pylint:disable=protected-access - # TODO revert this code if fixed in tensorflow - # self._tensorboard.on_train_batch_end(self._model.iterations, logs=logs) - - def _collate_and_store_loss(self, loss: list[float]) -> list[float]: - """ Collate the loss into totals for each side. - - The losses are summed into a total for each side. Loss totals are added to - :attr:`model.state._history` to track the loss drop per save iteration for backup purposes. - - If NaN protection is enabled, Checks for NaNs and raises an error if detected. - - Parameters - ---------- - loss: list - The list of loss ``floats`` for each side this iteration (excluding total combined - loss) - - Returns - ------- - list - List of 2 ``floats`` which is the total loss for each side (eg sum of face + mask loss) - - Raises - ------ - FaceswapError - If a NaN is detected, a :class:`FaceswapError` will be raised - """ - # NaN protection - if self._config["nan_protection"] and not all(np.isfinite(val) for val in loss): - logger.critical("NaN Detected. Loss: %s", loss) - raise FaceswapError("A NaN was detected and you have NaN protection enabled. Training " - "has been terminated.") - - split = len(loss) // 2 - combined_loss = [sum(loss[:split]), sum(loss[split:])] - self._model.add_history(combined_loss) - logger.trace("original loss: %s, combined_loss: %s", loss, combined_loss) # type: ignore - return combined_loss - - def _print_loss(self, loss: list[float]) -> None: - """ Outputs the loss for the current iteration to the console. - - Parameters - ---------- - loss: list - The loss for each side. List should contain 2 ``floats`` side "a" in position 0 and - side "b" in position `. - """ - output = ", ".join([f"Loss {side}: {side_loss:.5f}" - for side, side_loss in zip(("A", "B"), loss)]) - timestamp = time.strftime("%H:%M:%S") - output = f"[{timestamp}] [#{self._model.iterations:05d}] {output}" - try: - print(f"\r{output}", end="") - except OSError as err: - logger.warning("Swallowed OS Error caused by Tensorflow distributed training. output " - "line: %s, error: %s", output, str(err)) - - def _update_viewers(self, - viewer: Callable[[np.ndarray, str], None] | None, - timelapse_kwargs: dict[T.Literal["input_a", "input_b", "output"], - str] | None) -> None: - """ Update the preview viewer and timelapse output - - Parameters - ---------- - viewer: :func:`scripts.train.Train._show` or ``None`` - The function that will display the preview image - timelapse_kwargs: dict - The keyword arguments for generating time-lapse previews. If a time-lapse preview is - not required then this should be ``None``. Otherwise all values should be full paths - the keys being `input_a`, `input_b`, `output`. - """ - if viewer is not None: - self._samples.images = self._feeder.generate_preview() - samples = self._samples.show_sample() - if samples is not None: - viewer(samples, - "Training - 'S': Save Now. 'R': Refresh Preview. 'M': Toggle Mask. 'F': " - "Toggle Screen Fit-Actual Size. 'ENTER': Save and Quit") - - if timelapse_kwargs: - self._timelapse.output_timelapse(timelapse_kwargs) - - def clear_tensorboard(self) -> None: - """ Stop Tensorboard logging. - - Tensorboard logging needs to be explicitly shutdown on training termination. Called from - :class:`scripts.train.Train` when training is stopped. - """ - if not self._tensorboard: - return - logger.debug("Ending Tensorboard Session: %s", self._tensorboard) - self._tensorboard.on_train_end(None) - - -class _Samples(): # pylint:disable=too-few-public-methods - """ Compile samples for display for preview and time-lapse +class TrainerBase(abc.ABC): + """ A trainer plugin interface. It must implement the method "train_batch" which takes an input + of inputs to the model and target images for model output. It returns loss per side Parameters ---------- - model: plugin from :mod:`plugins.train.model` - The selected model that will be running this trainer - coverage_ratio: float - Ratio of face to be cropped out of the training image. - mask_opacity: int - The opacity (as a percentage) to use for the mask overlay - mask_color: str - The hex RGB value to use the mask overlay - - Attributes - ---------- - images: dict - The :class:`numpy.ndarray` training images for generating previews on each side. The - dictionary should contain 2 keys ("a" and "b") with the values being the training images - for generating samples corresponding to each side. + model : :class:`plugins.train.model.Base.ModelBase` + The model plugin + batch_size : int + The requested batch size for each iteration to be trained through the model. """ - def __init__(self, - model: ModelBase, - coverage_ratio: float, - mask_opacity: int, - mask_color: str) -> None: - logger.debug("Initializing %s: model: '%s', coverage_ratio: %s, mask_opacity: %s, " - "mask_color: %s)", - self.__class__.__name__, model, coverage_ratio, mask_opacity, mask_color) - self._model = model - self._display_mask = model.config["learn_mask"] or model.config["penalized_mask_loss"] - self.images: dict[T.Literal["a", "b"], list[np.ndarray]] = {} - self._coverage_ratio = coverage_ratio - self._mask_opacity = mask_opacity / 100.0 - self._mask_color = np.array(hex_to_rgb(mask_color))[..., 2::-1] / 255. - logger.debug("Initialized %s", self.__class__.__name__) - - def toggle_mask_display(self) -> None: - """ Toggle the mask overlay on or off depending on user input. """ - if not (self._model.config["learn_mask"] or self._model.config["penalized_mask_loss"]): - return - display_mask = not self._display_mask - print("") # Break to not garble loss output - logger.info("Toggling mask display %s...", "on" if display_mask else "off") - self._display_mask = display_mask - - def show_sample(self) -> np.ndarray: - """ Compile a preview image. + def __init__(self, model: ModelBase, batch_size: int) -> None: + self.model = model + """:class:`plugins.train.model.Base.ModelBase` : The model plugin to train the batch on""" + self.batch_size = batch_size + """int : The batch size for each iteration to be trained through the model.""" - Returns - ------- - :class:`numpy.ndarry` - A compiled preview image ready for display or saving - """ - logger.debug("Showing sample") - feeds: dict[T.Literal["a", "b"], np.ndarray] = {} - for idx, side in enumerate(T.get_args(T.Literal["a", "b"])): - feed = self.images[side][0] - input_shape = self._model.model.input_shape[idx][1:] - if input_shape[0] / feed.shape[1] != 1.0: - feeds[side] = self._resize_sample(side, feed, input_shape[0]) - else: - feeds[side] = feed - - preds = self._get_predictions(feeds["a"], feeds["b"]) - return self._compile_preview(preds) - - @classmethod - def _resize_sample(cls, - side: T.Literal["a", "b"], - sample: np.ndarray, - target_size: int) -> np.ndarray: - """ Resize a given image to the target size. + @abc.abstractmethod + def train_batch(self, inputs: torch.Tensor, targets: list[torch.Tensor]) -> torch.Tensor: + """Override to run a single forward and backwards pass through the model for a single + batch Parameters ---------- - side: str - The side ("a" or "b") that the samples are being generated for - sample: :class:`numpy.ndarray` - The sample to be resized - target_size: int - The size that the sample should be resized to + inputs : :class:`torch.Tensor` + The batch of input image tensors to the model in shape `(side, batch_size, + *dims)` with `side` 0 being input A and `side` 1 being input B + targets : list[:class:`torch.Tensor`] + The corresponding batch of target images for the model for each side's output(s). For + each model output an array should exist in the order of model outputs in the format `( + side, batch_size, *dims)` where `side` 0 is "A" and `side` 1 is "B" Returns ------- - :class:`numpy.ndarray` - The sample resized to the target size + :class:`torch.Tensor` + The loss for each side of this batch in layout (A1, ..., An, B1, ..., Bn) """ - scale = target_size / sample.shape[1] - if scale == 1.0: - # cv2 complains if we don't do this :/ - return np.ascontiguousarray(sample) - logger.debug("Resizing sample: (side: '%s', sample.shape: %s, target_size: %s, scale: %s)", - side, sample.shape, target_size, scale) - interpn = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA - retval = np.array([cv2.resize(img, (target_size, target_size), interpolation=interpn) - for img in sample]) - logger.debug("Resized sample: (side: '%s' shape: %s)", side, retval.shape) - return retval - - def _get_predictions(self, feed_a: np.ndarray, feed_b: np.ndarray) -> dict[str, np.ndarray]: - """ Feed the samples to the model and return predictions - - Parameters - ---------- - feed_a: :class:`numpy.ndarray` - Feed images for the "a" side - feed_a: :class:`numpy.ndarray` - Feed images for the "b" side - - Returns - ------- - list: - List of :class:`numpy.ndarray` of predictions received from the model - """ - logger.debug("Getting Predictions") - preds: dict[str, np.ndarray] = {} - - # Calling model.predict() can lead to both VRAM and system memory leaks, so call model - # directly - standard = self._model.model([feed_a, feed_b]) - swapped = self._model.model([feed_b, feed_a]) - - if self._model.config["learn_mask"]: # Add mask to 4th channel of final output - standard = [np.concatenate(side[-2:], axis=-1) - for side in [[s.numpy() for s in t] for t in standard]] - swapped = [np.concatenate(side[-2:], axis=-1) - for side in [[s.numpy() for s in t] for t in swapped]] - else: # Retrieve final output - standard = [side[-1] if isinstance(side, list) else side - for side in [t.numpy() for t in standard]] - swapped = [side[-1] if isinstance(side, list) else side - for side in [t.numpy() for t in swapped]] - - preds["a_a"] = standard[0] - preds["b_b"] = standard[1] - preds["a_b"] = swapped[0] - preds["b_a"] = swapped[1] - - logger.debug("Returning predictions: %s", {key: val.shape for key, val in preds.items()}) - return preds - - def _compile_preview(self, predictions: dict[str, np.ndarray]) -> np.ndarray: - """ Compile predictions and images into the final preview image. - - Parameters - ---------- - predictions: dict - The predictions from the model - - Returns - ------- - :class:`numpy.ndarry` - A compiled preview image ready for display or saving - """ - figures: dict[T.Literal["a", "b"], np.ndarray] = {} - headers: dict[T.Literal["a", "b"], np.ndarray] = {} - - for side, samples in self.images.items(): - other_side = "a" if side == "b" else "b" - preds = [predictions[f"{side}_{side}"], - predictions[f"{other_side}_{side}"]] - display = self._to_full_frame(side, samples, preds) - headers[side] = self._get_headers(side, display[0].shape[1]) - figures[side] = np.stack([display[0], display[1], display[2], ], axis=1) - if self.images[side][1].shape[0] % 2 == 1: - figures[side] = np.concatenate([figures[side], - np.expand_dims(figures[side][0], 0)]) - - width = 4 - if width // 2 != 1: - headers = self._duplicate_headers(headers, width // 2) - - header = np.concatenate([headers["a"], headers["b"]], axis=1) - figure = np.concatenate([figures["a"], figures["b"]], axis=0) - height = int(figure.shape[0] / width) - figure = figure.reshape((width, height) + figure.shape[1:]) - figure = _stack_images(figure) - figure = np.concatenate((header, figure), axis=0) - - logger.debug("Compiled sample") - return np.clip(figure * 255, 0, 255).astype('uint8') - - def _to_full_frame(self, - side: T.Literal["a", "b"], - samples: list[np.ndarray], - predictions: list[np.ndarray]) -> list[np.ndarray]: - """ Patch targets and prediction images into images of model output size. - - Parameters - ---------- - side: {"a" or "b"} - The side that these samples are for - samples: list - List of :class:`numpy.ndarray` of feed images and sample images - predictions: list - List of :class: `numpy.ndarray` of predictions from the model - - Returns - ------- - list - The images resized and collated for display in the preview frame - """ - logger.debug("side: '%s', number of sample arrays: %s, prediction.shapes: %s)", - side, len(samples), [pred.shape for pred in predictions]) - faces, full = samples[:2] - - if self._model.color_order.lower() == "rgb": # Switch color order for RGB model display - full = full[..., ::-1] - faces = faces[..., ::-1] - predictions = [pred[..., 2::-1] for pred in predictions] - - full = self._process_full(side, full, predictions[0].shape[1], (0., 0., 1.0)) - images = [faces] + predictions - - if self._display_mask: - images = self._compile_masked(images, samples[-1]) - elif self._model.config["learn_mask"]: - # Remove masks when learn mask is selected but mask toggle is off - images = [batch[..., :3] for batch in images] - - images = [self._overlay_foreground(full.copy(), image) for image in images] - - return images - - def _process_full(self, - side: T.Literal["a", "b"], - images: np.ndarray, - prediction_size: int, - color: tuple[float, float, float]) -> np.ndarray: - """ Add a frame overlay to preview images indicating the region of interest. - - This applies the red border that appears in the preview images. - - Parameters - ---------- - side: {"a" or "b"} - The side that these samples are for - images: :class:`numpy.ndarray` - The input training images to to process - prediction_size: int - The size of the predicted output from the model - color: tuple - The (Blue, Green, Red) color to use for the frame - - Returns - ------- - :class:`numpy,ndarray` - The input training images, sized for output and annotated for coverage - """ - logger.debug("full_size: %s, prediction_size: %s, color: %s", - images.shape[1], prediction_size, color) - - display_size = int((prediction_size / self._coverage_ratio // 2) * 2) - images = self._resize_sample(side, images, display_size) # Resize targets to display size - padding = (display_size - prediction_size) // 2 - if padding == 0: - logger.debug("Resized background. Shape: %s", images.shape) - return images - - length = display_size // 4 - t_l, b_r = (padding - 1, display_size - padding) - for img in images: - cv2.rectangle(img, (t_l, t_l), (t_l + length, t_l + length), color, 1) - cv2.rectangle(img, (b_r, t_l), (b_r - length, t_l + length), color, 1) - cv2.rectangle(img, (b_r, b_r), (b_r - length, b_r - length), color, 1) - cv2.rectangle(img, (t_l, b_r), (t_l + length, b_r - length), color, 1) - logger.debug("Overlayed background. Shape: %s", images.shape) - return images - - def _compile_masked(self, faces: list[np.ndarray], masks: np.ndarray) -> list[np.ndarray]: - """ Add the mask to the faces for masked preview. - - Places an opaque red layer over areas of the face that are masked out. - - Parameters - ---------- - faces: list - The :class:`numpy.ndarray` sample faces and predictions that are to have the mask - applied - masks: :class:`numpy.ndarray` - The masks that are to be applied to the faces - - Returns - ------- - list - List of :class:`numpy.ndarray` faces with the opaque mask layer applied - """ - orig_masks = 1. - masks - masks3: list[np.ndarray] | np.ndarray = [] - - if faces[-1].shape[-1] == 4: # Mask contained in alpha channel of predictions - pred_masks = [1. - face[..., -1][..., None] for face in faces[-2:]] - faces[-2:] = [face[..., :-1] for face in faces[-2:]] - masks3 = [orig_masks, *pred_masks] - else: - masks3 = np.repeat(np.expand_dims(orig_masks, axis=0), 3, axis=0) - - retval: list[np.ndarray] = [] - overlays3 = np.ones_like(faces) * self._mask_color - for previews, overlays, compiled_masks in zip(faces, overlays3, masks3): - compiled_masks *= self._mask_opacity - overlays *= compiled_masks - previews *= (1. - compiled_masks) - retval.append(previews + overlays) - logger.debug("masked shapes: %s", [faces.shape for faces in retval]) - return retval - - @classmethod - def _overlay_foreground(cls, backgrounds: np.ndarray, foregrounds: np.ndarray) -> np.ndarray: - """ Overlay the preview images into the center of the background images - - Parameters - ---------- - backgrounds: :class:`numpy.ndarray` - Background images for placing the preview images onto - backgrounds: :class:`numpy.ndarray` - Preview images for placing onto the background images - - Returns - ------- - :class:`numpy.ndarray` - The preview images compiled into the full frame size for each preview - """ - offset = (backgrounds.shape[1] - foregrounds.shape[1]) // 2 - for foreground, background in zip(foregrounds, backgrounds): - background[offset:offset + foreground.shape[0], - offset:offset + foreground.shape[1], :3] = foreground - logger.debug("Overlayed foreground. Shape: %s", backgrounds.shape) - return backgrounds - - @classmethod - def _get_headers(cls, side: T.Literal["a", "b"], width: int) -> np.ndarray: - """ Set header row for the final preview frame - - Parameters - ---------- - side: {"a" or "b"} - The side that the headers should be generated for - width: int - The width of each column in the preview frame - - Returns - ------- - :class:`numpy.ndarray` - The column headings for the given side - """ - logger.debug("side: '%s', width: %s", - side, width) - titles = ("Original", "Swap") if side == "a" else ("Swap", "Original") - height = int(width / 4.5) - total_width = width * 3 - logger.debug("height: %s, total_width: %s", height, total_width) - font = cv2.FONT_HERSHEY_SIMPLEX - texts = [f"{titles[0]} ({side.upper()})", - f"{titles[0]} > {titles[0]}", - f"{titles[0]} > {titles[1]}"] - scaling = (width / 144) * 0.45 - text_sizes = [cv2.getTextSize(texts[idx], font, scaling, 1)[0] - for idx in range(len(texts))] - text_y = int((height + text_sizes[0][1]) / 2) - text_x = [int((width - text_sizes[idx][0]) / 2) + width * idx - for idx in range(len(texts))] - logger.debug("texts: %s, text_sizes: %s, text_x: %s, text_y: %s", - texts, text_sizes, text_x, text_y) - header_box = np.ones((height, total_width, 3), np.float32) - for idx, text in enumerate(texts): - cv2.putText(header_box, - text, - (text_x[idx], text_y), - font, - scaling, - (0, 0, 0), - 1, - lineType=cv2.LINE_AA) - logger.debug("header_box.shape: %s", header_box.shape) - return header_box - - @classmethod - def _duplicate_headers(cls, - headers: dict[T.Literal["a", "b"], np.ndarray], - columns: int) -> dict[T.Literal["a", "b"], np.ndarray]: - """ Duplicate headers for the number of columns displayed for each side. - - Parameters - ---------- - headers: dict - The headers to be duplicated for each side - columns: int - The number of columns that the header needs to be duplicated for - - Returns - ------- - :class:dict - The original headers duplicated by the number of columns for each side - """ - for side, header in headers.items(): - duped = tuple(header for _ in range(columns)) - headers[side] = np.concatenate(duped, axis=1) - logger.debug("side: %s header.shape: %s", side, header.shape) - return headers - - -class _Timelapse(): # pylint:disable=too-few-public-methods - """ Create a time-lapse preview image. - - Parameters - ---------- - model: plugin from :mod:`plugins.train.model` - The selected model that will be running this trainer - coverage_ratio: float - Ratio of face to be cropped out of the training image. - image_count: int - The number of preview images to be displayed in the time-lapse - mask_opacity: int - The opacity (as a percentage) to use for the mask overlay - mask_color: str - The hex RGB value to use the mask overlay - feeder: :class:`~lib.training.generator.Feeder` - The feeder for generating the time-lapse images. - image_paths: dict - The full paths to the training images for each side of the model - """ - def __init__(self, # pylint:disable=too-many-positional-arguments - model: ModelBase, - coverage_ratio: float, - image_count: int, - mask_opacity: int, - mask_color: str, - feeder: Feeder, - image_paths: dict[T.Literal["a", "b"], list[str]]) -> None: - logger.debug("Initializing %s: model: %s, coverage_ratio: %s, image_count: %s, " - "mask_opacity: %s, mask_color: %s, feeder: %s, image_paths: %s)", - self.__class__.__name__, model, coverage_ratio, image_count, mask_opacity, - mask_color, feeder, len(image_paths)) - self._num_images = image_count - self._samples = _Samples(model, coverage_ratio, mask_opacity, mask_color) - self._model = model - self._feeder = feeder - self._image_paths = image_paths - self._output_file = "" - logger.debug("Initialized %s", self.__class__.__name__) - - def _setup(self, input_a: str, input_b: str, output: str) -> None: - """ Setup the time-lapse folder locations and the time-lapse feed. - - Parameters - ---------- - input_a: str - The full path to the time-lapse input folder containing faces for the "a" side - input_b: str - The full path to the time-lapse input folder containing faces for the "b" side - output: str, optional - The full path to the time-lapse output folder. If ``None`` is provided this will - default to the model folder - """ - logger.debug("Setting up time-lapse") - if not output: - output = get_folder(os.path.join(str(self._model.io.model_dir), - f"{self._model.name}_timelapse")) - self._output_file = output - logger.debug("Time-lapse output set to '%s'", self._output_file) - - # Rewrite paths to pull from the training images so mask and face data can be accessed - images: dict[T.Literal["a", "b"], list[str]] = {} - for side, input_ in zip(T.get_args(T.Literal["a", "b"]), (input_a, input_b)): - training_path = os.path.dirname(self._image_paths[side][0]) - images[side] = [os.path.join(training_path, os.path.basename(pth)) - for pth in get_image_paths(input_)] - - batchsize = min(len(images["a"]), - len(images["b"]), - self._num_images) - self._feeder.set_timelapse_feed(images, batchsize) - logger.debug("Set up time-lapse") - - def output_timelapse(self, timelapse_kwargs: dict[T.Literal["input_a", - "input_b", - "output"], str]) -> None: - """ Generate the time-lapse samples and output the created time-lapse to the specified - output folder. - - Parameters - ---------- - timelapse_kwargs: dict: - The keyword arguments for setting up the time-lapse. All values should be full paths - the keys being `input_a`, `input_b`, `output` - """ - logger.debug("Ouputting time-lapse") - if not self._output_file: - self._setup(**T.cast(dict[str, str], timelapse_kwargs)) - - logger.debug("Getting time-lapse samples") - self._samples.images = self._feeder.generate_preview(is_timelapse=True) - logger.debug("Got time-lapse samples: %s", - {side: len(images) for side, images in self._samples.images.items()}) - - image = self._samples.show_sample() - if image is None: - return - filename = os.path.join(self._output_file, str(int(time.time())) + ".jpg") - - cv2.imwrite(filename, image) - logger.debug("Created time-lapse: '%s'", filename) - - -def _stack_images(images: np.ndarray) -> np.ndarray: - """ Stack images evenly for preview. - - Parameters - ---------- - images: :class:`numpy.ndarray` - The preview images to be stacked - - Returns - ------- - :class:`numpy.ndarray` - The stacked preview images - """ - logger.debug("Stack images") - - def get_transpose_axes(num): - if num % 2 == 0: - logger.debug("Even number of images to stack") - y_axes = list(range(1, num - 1, 2)) - x_axes = list(range(0, num - 1, 2)) - else: - logger.debug("Odd number of images to stack") - y_axes = list(range(0, num - 1, 2)) - x_axes = list(range(1, num - 1, 2)) - return y_axes, x_axes, [num - 1] - - images_shape = np.array(images.shape) - new_axes = get_transpose_axes(len(images_shape)) - new_shape = [np.prod(images_shape[x]) for x in new_axes] - logger.debug("Stacked images") - return np.transpose(images, axes=np.concatenate(new_axes)).reshape(new_shape) diff --git a/plugins/train/trainer/_display.py b/plugins/train/trainer/_display.py new file mode 100644 index 0000000000..0e3236cb34 --- /dev/null +++ b/plugins/train/trainer/_display.py @@ -0,0 +1,626 @@ +#!/usr/bin/env python3 +""" Handles the creation of display images for preview window and timelapses """ +from __future__ import annotations + +import logging +import time +import typing as T +import os + +import cv2 +import numpy as np +import torch + +from lib.image import hex_to_rgb +from lib.utils import get_folder, get_image_paths, get_module_objects +from plugins.train import train_config as cfg + +if T.TYPE_CHECKING: + from keras import KerasTensor + from lib.training import Feeder + from plugins.train.model._base import ModelBase + +logger = logging.getLogger(__name__) + + +class Samples(): + """ Compile samples for display for preview and time-lapse + + Parameters + ---------- + model: plugin from :mod:`plugins.train.model` + The selected model that will be running this trainer + coverage_ratio: float + Ratio of face to be cropped out of the training image. + mask_opacity: int + The opacity (as a percentage) to use for the mask overlay + mask_color: str + The hex RGB value to use the mask overlay + + Attributes + ---------- + images: dict + The :class:`numpy.ndarray` training images for generating previews on each side. The + dictionary should contain 2 keys ("a" and "b") with the values being the training images + for generating samples corresponding to each side. + """ + def __init__(self, + model: ModelBase, + coverage_ratio: float, + mask_opacity: int, + mask_color: str) -> None: + logger.debug("Initializing %s: model: '%s', coverage_ratio: %s, mask_opacity: %s, " + "mask_color: %s)", + self.__class__.__name__, model, coverage_ratio, mask_opacity, mask_color) + self._model = model + self._display_mask = cfg.Loss.learn_mask() or cfg.Loss.penalized_mask_loss() + self.images: dict[T.Literal["a", "b"], list[np.ndarray]] = {} + self._coverage_ratio = coverage_ratio + self._mask_opacity = mask_opacity / 100.0 + self._mask_color = np.array(hex_to_rgb(mask_color))[..., 2::-1] / 255. + logger.debug("Initialized %s", self.__class__.__name__) + + def toggle_mask_display(self) -> None: + """ Toggle the mask overlay on or off depending on user input. """ + if not (cfg.Loss.learn_mask() or cfg.Loss.penalized_mask_loss()): + return + display_mask = not self._display_mask + print("\x1b[2K", end="\r") # Clear last line + logger.info("Toggling mask display %s...", "on" if display_mask else "off") + self._display_mask = display_mask + + def show_sample(self) -> np.ndarray: + """ Compile a preview image. + + Returns + ------- + :class:`numpy.ndarry` + A compiled preview image ready for display or saving + """ + logger.debug("Showing sample") + feeds: dict[T.Literal["a", "b"], np.ndarray] = {} + for idx, side in enumerate(T.get_args(T.Literal["a", "b"])): + feed = self.images[side][0] + input_shape = self._model.model.input_shape[idx][1:] + if input_shape[0] / feed.shape[1] != 1.0: + feeds[side] = self._resize_sample(side, feed, input_shape[0]) + else: + feeds[side] = feed + + preds = self._get_predictions(feeds["a"], feeds["b"]) + return self._compile_preview(preds) + + @classmethod + def _resize_sample(cls, + side: T.Literal["a", "b"], + sample: np.ndarray, + target_size: int) -> np.ndarray: + """ Resize a given image to the target size. + + Parameters + ---------- + side: str + The side ("a" or "b") that the samples are being generated for + sample: :class:`numpy.ndarray` + The sample to be resized + target_size: int + The size that the sample should be resized to + + Returns + ------- + :class:`numpy.ndarray` + The sample resized to the target size + """ + scale = target_size / sample.shape[1] + if scale == 1.0: + # cv2 complains if we don't do this :/ + return np.ascontiguousarray(sample) + logger.debug("Resizing sample: (side: '%s', sample.shape: %s, target_size: %s, scale: %s)", + side, sample.shape, target_size, scale) + interpn = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA + retval = np.array([cv2.resize(img, (target_size, target_size), interpolation=interpn) + for img in sample]) + logger.debug("Resized sample: (side: '%s' shape: %s)", side, retval.shape) + return retval + + def _filter_multiscale_output(self, standard: list[KerasTensor], swapped: list[KerasTensor] + ) -> tuple[list[KerasTensor], list[KerasTensor]]: + """ Only return the largest predictions if the model has multi-scaled output + + Parameters + ---------- + standard: list[:class:`keras.KerasTensor`] + The standard output from the model + swapped: list[:class:`keras.KerasTensor`] + The swapped output from the model + + Returns + ------- + standard: list[:class:`keras.KerasTensor`] + The standard output from the model, filtered to just the largest output + swapped: list[:class:`keras.KerasTensor`] + The swapped output from the model, filtered to just the largest output + """ + sizes = T.cast(set[int], set(p.shape[1] for p in standard)) + if len(sizes) == 1: + return standard, swapped + logger.debug("Received outputs. standard: %s, swapped: %s", + [s.shape for s in standard], [s.shape for s in swapped]) + logger.debug("Stripping multi-scale outputs for sizes %s", sizes) + standard = [s for s in standard if s.shape[1] == max(sizes)] + swapped = [s for s in swapped if s.shape[1] == max(sizes)] + logger.debug("Stripped outputs. standard: %s, swapped: %s", + [s.shape for s in standard], [s.shape for s in swapped]) + return standard, swapped + + def _collate_output(self, standard: list[torch.Tensor], swapped: list[torch.Tensor] + ) -> tuple[list[np.ndarray], list[np.ndarray]]: + """ Merge the mask onto the preview image's 4th channel if learn mask is selected. + Return as numpy array + + Parameters + ---------- + standard: list[:class:`torch.Tensor`] + The standard output from the model + swapped: list[:class:`torch.Tensor`] + The swapped output from the model + + Returns + ------- + standard: list[:class:`numpy.ndarray`] + The standard output from the model, with mask merged + swapped: list[:class:`numpy.ndarray`] + The swapped output from the model, with mask merged + """ + logger.debug("Received tensors. standard: %s, swapped: %s", + [s.shape for s in standard], [s.shape for s in swapped]) + + # Pull down outputs + nstandard = [p.cpu().detach().numpy() for p in standard] + nswapped = [p.cpu().detach().numpy() for p in swapped] + + if cfg.Loss.learn_mask(): # Add mask to 4th channel of final output + nstandard = [np.concatenate(nstandard[idx * 2: (idx * 2) + 2], axis=-1) + for idx in range(2)] + nswapped = [np.concatenate(nswapped[idx * 2: (idx * 2) + 2], axis=-1) + for idx in range(2)] + logger.debug("Collated output. standard: %s, swapped: %s", + [(s.shape, s.dtype) for s in nstandard], + [(s.shape, s.dtype) for s in nswapped]) + return nstandard, nswapped + + def _get_predictions(self, feed_a: np.ndarray, feed_b: np.ndarray + ) -> dict[T.Literal["a_a", "a_b", "b_b", "b_a"], np.ndarray]: + """ Feed the samples to the model and return predictions + + Parameters + ---------- + feed_a: :class:`numpy.ndarray` + Feed images for the "a" side + feed_a: :class:`numpy.ndarray` + Feed images for the "b" side + + Returns + ------- + list: + List of :class:`numpy.ndarray` of predictions received from the model + """ + logger.debug("Getting Predictions") + preds: dict[T.Literal["a_a", "a_b", "b_b", "b_a"], np.ndarray] = {} + + with torch.inference_mode(): + standard = self._model.model([feed_a, feed_b]) + swapped = self._model.model([feed_b, feed_a]) + + standard, swapped = self._filter_multiscale_output(standard, swapped) + standard, swapped = self._collate_output(standard, swapped) + + preds["a_a"] = standard[0] + preds["b_b"] = standard[1] + preds["a_b"] = swapped[0] + preds["b_a"] = swapped[1] + + logger.debug("Returning predictions: %s", {key: val.shape for key, val in preds.items()}) + return preds + + def _compile_preview(self, predictions: dict[T.Literal["a_a", "a_b", "b_b", "b_a"], np.ndarray] + ) -> np.ndarray: + """ Compile predictions and images into the final preview image. + + Parameters + ---------- + predictions: dict[Literal["a_a", "a_b", "b_b", "b_a"], np.ndarray + The predictions from the model + + Returns + ------- + :class:`numpy.ndarry` + A compiled preview image ready for display or saving + """ + figures: dict[T.Literal["a", "b"], np.ndarray] = {} + headers: dict[T.Literal["a", "b"], np.ndarray] = {} + + for side, samples in self.images.items(): + other_side = "a" if side == "b" else "b" + preds = [predictions[T.cast(T.Literal["a_a", "a_b", "b_b", "b_a"], + f"{side}_{side}")], + predictions[T.cast(T.Literal["a_a", "a_b", "b_b", "b_a"], + f"{other_side}_{side}")]] + display = self._to_full_frame(side, samples, preds) + headers[side] = self._get_headers(side, display[0].shape[1]) + figures[side] = np.stack([display[0], display[1], display[2], ], axis=1) + if self.images[side][1].shape[0] % 2 == 1: + figures[side] = np.concatenate([figures[side], + np.expand_dims(figures[side][0], 0)]) + + width = 4 + if width // 2 != 1: + headers = self._duplicate_headers(headers, width // 2) + + header = np.concatenate([headers["a"], headers["b"]], axis=1) + figure = np.concatenate([figures["a"], figures["b"]], axis=0) + height = int(figure.shape[0] / width) + figure = figure.reshape((width, height) + figure.shape[1:]) + figure = _stack_images(figure) + figure = np.concatenate((header, figure), axis=0) + + logger.debug("Compiled sample") + return np.clip(figure * 255, 0, 255).astype('uint8') + + def _to_full_frame(self, + side: T.Literal["a", "b"], + samples: list[np.ndarray], + predictions: list[np.ndarray]) -> list[np.ndarray]: + """ Patch targets and prediction images into images of model output size. + + Parameters + ---------- + side: {"a" or "b"} + The side that these samples are for + samples: list + List of :class:`numpy.ndarray` of feed images and sample images + predictions: list + List of :class: `numpy.ndarray` of predictions from the model + + Returns + ------- + list + The images resized and collated for display in the preview frame + """ + logger.debug("side: '%s', number of sample arrays: %s, prediction.shapes: %s)", + side, len(samples), [pred.shape for pred in predictions]) + faces, full = samples[:2] + + if self._model.color_order.lower() == "rgb": # Switch color order for RGB model display + full = full[..., ::-1] + faces = faces[..., ::-1] + predictions = [pred[..., 2::-1] for pred in predictions] + + full = self._process_full(side, full, predictions[0].shape[1], (0., 0., 1.0)) + images = [faces] + predictions + + if self._display_mask: + images = self._compile_masked(images, samples[-1]) + elif cfg.Loss.learn_mask(): + # Remove masks when learn mask is selected but mask toggle is off + images = [batch[..., :3] for batch in images] + + images = [self._overlay_foreground(full.copy(), image) for image in images] + + return images + + def _process_full(self, + side: T.Literal["a", "b"], + images: np.ndarray, + prediction_size: int, + color: tuple[float, float, float]) -> np.ndarray: + """ Add a frame overlay to preview images indicating the region of interest. + + This applies the red border that appears in the preview images. + + Parameters + ---------- + side: {"a" or "b"} + The side that these samples are for + images: :class:`numpy.ndarray` + The input training images to to process + prediction_size: int + The size of the predicted output from the model + color: tuple + The (Blue, Green, Red) color to use for the frame + + Returns + ------- + :class:`numpy,ndarray` + The input training images, sized for output and annotated for coverage + """ + logger.debug("full_size: %s, prediction_size: %s, color: %s", + images.shape[1], prediction_size, color) + + display_size = int((prediction_size / self._coverage_ratio // 2) * 2) + images = self._resize_sample(side, images, display_size) # Resize targets to display size + padding = (display_size - prediction_size) // 2 + if padding == 0: + logger.debug("Resized background. Shape: %s", images.shape) + return images + + length = display_size // 4 + t_l, b_r = (padding - 1, display_size - padding) + for img in images: + cv2.rectangle(img, (t_l, t_l), (t_l + length, t_l + length), color, 1) + cv2.rectangle(img, (b_r, t_l), (b_r - length, t_l + length), color, 1) + cv2.rectangle(img, (b_r, b_r), (b_r - length, b_r - length), color, 1) + cv2.rectangle(img, (t_l, b_r), (t_l + length, b_r - length), color, 1) + logger.debug("Overlayed background. Shape: %s", images.shape) + return images + + def _compile_masked(self, faces: list[np.ndarray], masks: np.ndarray) -> list[np.ndarray]: + """ Add the mask to the faces for masked preview. + + Places an opaque red layer over areas of the face that are masked out. + + Parameters + ---------- + faces: list + The :class:`numpy.ndarray` sample faces and predictions that are to have the mask + applied + masks: :class:`numpy.ndarray` + The masks that are to be applied to the faces + + Returns + ------- + list + List of :class:`numpy.ndarray` faces with the opaque mask layer applied + """ + orig_masks = 1. - masks + masks3: list[np.ndarray] | np.ndarray = [] + + if faces[-1].shape[-1] == 4: # Mask contained in alpha channel of predictions + pred_masks = [1. - face[..., -1][..., None] for face in faces[-2:]] + faces[-2:] = [face[..., :-1] for face in faces[-2:]] + masks3 = [orig_masks, *pred_masks] + else: + masks3 = np.repeat(np.expand_dims(orig_masks, axis=0), 3, axis=0) + + retval: list[np.ndarray] = [] + overlays3 = np.ones_like(faces) * self._mask_color + for previews, overlays, compiled_masks in zip(faces, overlays3, masks3): + compiled_masks *= self._mask_opacity + overlays *= compiled_masks + previews *= (1. - compiled_masks) + retval.append(previews + overlays) + logger.debug("masked shapes: %s", [faces.shape for faces in retval]) + return retval + + @classmethod + def _overlay_foreground(cls, backgrounds: np.ndarray, foregrounds: np.ndarray) -> np.ndarray: + """ Overlay the preview images into the center of the background images + + Parameters + ---------- + backgrounds: :class:`numpy.ndarray` + Background images for placing the preview images onto + backgrounds: :class:`numpy.ndarray` + Preview images for placing onto the background images + + Returns + ------- + :class:`numpy.ndarray` + The preview images compiled into the full frame size for each preview + """ + offset = (backgrounds.shape[1] - foregrounds.shape[1]) // 2 + for foreground, background in zip(foregrounds, backgrounds): + background[offset:offset + foreground.shape[0], + offset:offset + foreground.shape[1], :3] = foreground + logger.debug("Overlayed foreground. Shape: %s", backgrounds.shape) + return backgrounds + + @classmethod + def _get_headers(cls, side: T.Literal["a", "b"], width: int) -> np.ndarray: + """ Set header row for the final preview frame + + Parameters + ---------- + side: {"a" or "b"} + The side that the headers should be generated for + width: int + The width of each column in the preview frame + + Returns + ------- + :class:`numpy.ndarray` + The column headings for the given side + """ + logger.debug("side: '%s', width: %s", + side, width) + titles = ("Original", "Swap") if side == "a" else ("Swap", "Original") + height = int(width / 4.5) + total_width = width * 3 + logger.debug("height: %s, total_width: %s", height, total_width) + font = cv2.FONT_HERSHEY_SIMPLEX + texts = [f"{titles[0]} ({side.upper()})", + f"{titles[0]} > {titles[0]}", + f"{titles[0]} > {titles[1]}"] + scaling = (width / 144) * 0.45 + text_sizes = [cv2.getTextSize(texts[idx], font, scaling, 1)[0] + for idx in range(len(texts))] + text_y = int((height + text_sizes[0][1]) / 2) + text_x = [int((width - text_sizes[idx][0]) / 2) + width * idx + for idx in range(len(texts))] + logger.debug("texts: %s, text_sizes: %s, text_x: %s, text_y: %s", + texts, text_sizes, text_x, text_y) + header_box = np.ones((height, total_width, 3), np.float32) + for idx, text in enumerate(texts): + cv2.putText(header_box, + text, + (text_x[idx], text_y), + font, + scaling, + (0, 0, 0), + 1, + lineType=cv2.LINE_AA) + logger.debug("header_box.shape: %s", header_box.shape) + return header_box + + @classmethod + def _duplicate_headers(cls, + headers: dict[T.Literal["a", "b"], np.ndarray], + columns: int) -> dict[T.Literal["a", "b"], np.ndarray]: + """ Duplicate headers for the number of columns displayed for each side. + + Parameters + ---------- + headers: dict + The headers to be duplicated for each side + columns: int + The number of columns that the header needs to be duplicated for + + Returns + ------- + :class:dict + The original headers duplicated by the number of columns for each side + """ + for side, header in headers.items(): + duped = tuple(header for _ in range(columns)) + headers[side] = np.concatenate(duped, axis=1) + logger.debug("side: %s header.shape: %s", side, header.shape) + return headers + + +class Timelapse(): + """ Create a time-lapse preview image. + + Parameters + ---------- + model: plugin from :mod:`plugins.train.model` + The selected model that will be running this trainer + coverage_ratio: float + Ratio of face to be cropped out of the training image. + image_count: int + The number of preview images to be displayed in the time-lapse + mask_opacity: int + The opacity (as a percentage) to use for the mask overlay + mask_color: str + The hex RGB value to use the mask overlay + feeder: :class:`~lib.training.generator.Feeder` + The feeder for generating the time-lapse images. + image_paths: dict + The full paths to the training images for each side of the model + """ + def __init__(self, + model: ModelBase, + coverage_ratio: float, + image_count: int, + mask_opacity: int, + mask_color: str, + feeder: Feeder, + image_paths: dict[T.Literal["a", "b"], list[str]]) -> None: + logger.debug("Initializing %s: model: %s, coverage_ratio: %s, image_count: %s, " + "mask_opacity: %s, mask_color: %s, feeder: %s, image_paths: %s)", + self.__class__.__name__, model, coverage_ratio, image_count, mask_opacity, + mask_color, feeder, len(image_paths)) + self._num_images = image_count + self._samples = Samples(model, coverage_ratio, mask_opacity, mask_color) + self._model = model + self._feeder = feeder + self._image_paths = image_paths + self._output_file = "" + logger.debug("Initialized %s", self.__class__.__name__) + + def _setup(self, input_a: str, input_b: str, output: str) -> None: + """ Setup the time-lapse folder locations and the time-lapse feed. + + Parameters + ---------- + input_a: str + The full path to the time-lapse input folder containing faces for the "a" side + input_b: str + The full path to the time-lapse input folder containing faces for the "b" side + output: str, optional + The full path to the time-lapse output folder. If ``None`` is provided this will + default to the model folder + """ + logger.debug("Setting up time-lapse") + if not output: + output = get_folder(os.path.join(str(self._model.io.model_dir), + f"{self._model.name}_timelapse")) + self._output_file = output + logger.debug("Time-lapse output set to '%s'", self._output_file) + + # Rewrite paths to pull from the training images so mask and face data can be accessed + images: dict[T.Literal["a", "b"], list[str]] = {} + for side, input_ in zip(T.get_args(T.Literal["a", "b"]), (input_a, input_b)): + training_path = os.path.dirname(self._image_paths[side][0]) + images[side] = [os.path.join(training_path, os.path.basename(pth)) + for pth in get_image_paths(input_)] + + batchsize = min(len(images["a"]), + len(images["b"]), + self._num_images) + self._feeder.set_timelapse_feed(images, batchsize) + logger.debug("Set up time-lapse") + + def output_timelapse(self, timelapse_kwargs: dict[T.Literal["input_a", + "input_b", + "output"], str]) -> None: + """ Generate the time-lapse samples and output the created time-lapse to the specified + output folder. + + Parameters + ---------- + timelapse_kwargs: dict: + The keyword arguments for setting up the time-lapse. All values should be full paths + the keys being `input_a`, `input_b`, `output` + """ + logger.debug("Ouputting time-lapse") + if not self._output_file: + self._setup(**T.cast(dict[str, str], timelapse_kwargs)) + + logger.debug("Getting time-lapse samples") + self._samples.images = self._feeder.generate_preview(is_timelapse=True) + logger.debug("Got time-lapse samples: %s", + {side: len(images) for side, images in self._samples.images.items()}) + + image = self._samples.show_sample() + if image is None: + return + filename = os.path.join(self._output_file, str(int(time.time())) + ".jpg") + + cv2.imwrite(filename, image) + logger.debug("Created time-lapse: '%s'", filename) + + +def _stack_images(images: np.ndarray) -> np.ndarray: + """ Stack images evenly for preview. + + Parameters + ---------- + images: :class:`numpy.ndarray` + The preview images to be stacked + + Returns + ------- + :class:`numpy.ndarray` + The stacked preview images + """ + logger.debug("Stack images") + + def get_transpose_axes(num): + if num % 2 == 0: + logger.debug("Even number of images to stack") + y_axes = list(range(1, num - 1, 2)) + x_axes = list(range(0, num - 1, 2)) + else: + logger.debug("Odd number of images to stack") + y_axes = list(range(0, num - 1, 2)) + x_axes = list(range(1, num - 1, 2)) + return y_axes, x_axes, [num - 1] + + images_shape = np.array(images.shape) + new_axes = get_transpose_axes(len(images_shape)) + new_shape = [np.prod(images_shape[x]) for x in new_axes] + logger.debug("Stacked images") + return np.transpose(images, axes=np.concatenate(new_axes)).reshape(new_shape) + + +__all__ = get_module_objects(__name__) diff --git a/plugins/train/trainer/distributed.py b/plugins/train/trainer/distributed.py new file mode 100644 index 0000000000..ee1a877b42 --- /dev/null +++ b/plugins/train/trainer/distributed.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +""" Original Trainer """ +from __future__ import annotations +import logging +import typing as T +import warnings + +from keras import ops +import torch + + +from lib.utils import get_module_objects +from .original import Trainer as OriginalTrainer + +if T.TYPE_CHECKING: + from plugins.train.model._base import ModelBase + import keras + +logger = logging.getLogger(__name__) + + +class WrappedModel(torch.nn.Module): + """ A torch module that wraps a dual input Faceswap model with a single input version that is + compatible with DataParallel training + + Parameters + ---------- + model : :class:`keras.Model` + The original faceswap model that is to be wrapped + """ + def __init__(self, model: keras.Model): + logger.debug("Wrapping keras model: %s", model.name) + super().__init__() + self._keras_model = model + logger.debug("Wrapped keras model: %s (%s)", model.name, self) + + def forward(self, + input_a: torch.Tensor, + input_b: torch.Tensor, + targets_a: torch.Tensor, + targets_b: torch.Tensor, + *targets: torch.Tensor) -> torch.Tensor: + """ Run the forward pass per GPU + + Parameters + ---------- + input_a : :class:`torch.Tensor` + The A batch of input images for 1 GPU + input_b : :class:`torch.Tensor` + The B batch of input images for 1 GPU + targets_a : :class:`torch.Tensor` | list[torch.Tensor] + The A batch of target images for 1 GPU. If this is a multi-output model then this list + will be the target images per output for all items in the current batch, regardless of + GPU. If we have 1 output, this will be a Tensor for this GPUs current batch output + targets_b : :class:`torch.Tensor` | list[torch.Tensor] + The B batch of target images for 1 GPU. If this is a multi-output model then this list + will be the target images per output for all items in the current batch, regardless of + GPU. If we have 1 output, this will be a Tensor for this GPUs current batch output + targets : :class:`torch.Tensor` | list[torch.Tensor], optional + Used for multi-output models. Any additional outputs can be added here. They should be + added in A-B order + + + Returns + ------- + :class:`torch.Tensor` + The loss outputs for each side of the model for 1 GPU + """ + preds = self._keras_model((input_a, input_b), training=True) + self._keras_model.zero_grad() + + if targets: # Go from [A1, B1, A2, B2, A3, B3] to [A1, A2, A3, B1, B2, B3] + all_targets = [targets_a, targets_b, *targets] + assert len(all_targets) % 2 == 0 + loss_targets = all_targets[0::2] + all_targets[1::2] + else: + loss_targets = [targets_a, targets_b] + + losses = torch.stack([loss_fn(y_true, y_pred) + for loss_fn, y_true, y_pred in zip(self._keras_model.loss, + loss_targets, + preds)]) + logger.trace("Losses: %s", losses) # type:ignore[attr-defined] + return losses + + +class Trainer(OriginalTrainer): + """ Distributed training with torch.nn.DataParallel + + Parameters + ---------- + model : plugin from :mod:`plugins.train.model` + The model that will be running this trainer + batch_size : int + The requested batch size for iteration to be trained through the model. + """ + def __init__(self, model: ModelBase, batch_size: int) -> None: + + self._gpu_count = torch.cuda.device_count() + batch_size = self._validate_batch_size(batch_size) + self._is_multi_out: bool | None = None + + super().__init__(model, batch_size) + + self._distributed_model = self._set_distributed() + + def _validate_batch_size(self, batch_size: int) -> int: + """ Validate that the batch size is suitable for the number of GPUs and update accordingly. + + Parameters + ---------- + batch_size : int + The requested training batch size + + Returns + ------- + int + A valid batch size for the GPU configuration + """ + if batch_size < self._gpu_count: + logger.warning("Batch size (%s) is less than the number of GPUs (%s). Updating batch " + "size to: %s", batch_size, self._gpu_count, self._gpu_count) + batch_size = self._gpu_count + if batch_size % self._gpu_count: + new_batch_size = (batch_size // self._gpu_count) * self._gpu_count + logger.warning("Batch size %s is sub-optimal for %s GPUs. You may want to adjust your " + "batch size to %s or %s.", + batch_size, + self._gpu_count, + new_batch_size, + new_batch_size + self._gpu_count) + return batch_size + + def _handle_torch_gpu_mismatch_warning( + self, warn_messages: list[warnings.WarningMessage] | None) -> None: + """ Handle the warning generated by Torch when significantly mismatched GPUs are used and + remove potentially confusing information not relevant for Faceswap + + Parameters + ---------- + warn_messages : list[:class:`warnings.WarningMessage] + Any qualifying warning messages that may have been generated when wrapping the model + """ + if warn_messages is None or not warn_messages: + return + warn_msg = warn_messages[0] + terminate = "You can do so by" + msg = "" + for x in str(warn_msg.message).split("\n"): + x = x.strip() + if not x: + continue + if terminate in msg: + msg = msg[:msg.find(terminate)] + break + msg += f" {x}" + logger.warning(msg.strip()) + + def _set_distributed(self) -> torch.nn.DataParallel: + """Wrap the loaded model in a torch.nn.DataParallel instance + + Returns + ------- + :class:`torch.nn.Parallel` + A wrapped version of the faceswap model compatible with distributed training + """ + name = self.model.model.name + logger.debug("Setting distributed training for '%s'", name) + + with warnings.catch_warnings(record=True) as w: + warnings.filterwarnings("default", + message="There is an imbalance between your GPUs", + category=UserWarning) + # We already set CUDA_VISIBLE_DEVICES from -X command line flag, so just need to wrap + wrapped = torch.nn.DataParallel(WrappedModel(model=self.model.model)) + self._handle_torch_gpu_mismatch_warning(w) + + logger.info("Distributed training enabled. Model: '%s', devices: %s", + name, wrapped.device_ids) + return wrapped + + def _forward(self, + inputs: torch.Tensor, + targets: list[torch.Tensor]) -> torch.Tensor: + """ Perform the forward pass on the model + + Parameters + ---------- + inputs : :class:`torch.Tensor` + The batch of input image tensors to the model in shape `(side, batch_size, + *dims)` with `side` 0 being input A and `side` 1 being input B + targets : list[:class:`torch.Tensor`] + The corresponding batch of target images for the model for each side's output(s). For + each model output an array should exist in the order of model outputs in the format `( + side, batch_size, *dims)` with `side` 0 being input A and `side` 1 being input B + + Returns + ------- + :class:`torch.Tensor` + The loss for each side of this batch in layout (A1, ..., An, B1, ..., Bn) + """ + if self._is_multi_out is None: + self._is_multi_out = len(targets) > 1 + logger.debug("Setting multi-out to: %s", self._is_multi_out) + + if self._is_multi_out: + multi_targets = tuple(t[i] for t in targets[1:] for i in range(2)) + else: + multi_targets = () + + loss: torch.Tensor = self._distributed_model(inputs[0], + inputs[1], + targets[0][0], + targets[0][1], + *multi_targets) + scaled = T.cast(torch.Tensor, ops.sum(ops.reshape(loss, (self._gpu_count, 2, -1)), + axis=0) / self._gpu_count) + return scaled.flatten() + + +__all__ = get_module_objects(__name__) diff --git a/plugins/train/trainer/original.py b/plugins/train/trainer/original.py index cfa8a602ff..0b5164eef2 100644 --- a/plugins/train/trainer/original.py +++ b/plugins/train/trainer/original.py @@ -1,10 +1,97 @@ #!/usr/bin/env python3 """ Original Trainer """ +from __future__ import annotations +import logging +import typing as T + +from keras import ops +from keras.src.tree import flatten +import torch + +from lib.utils import get_module_objects from ._base import TrainerBase +logger = logging.getLogger(__name__) + + class Trainer(TrainerBase): - """ Original is currently identical to Base """ - def __init__(self, *args, **kwargs): # pylint:disable=useless-super-delegation - super().__init__(*args, **kwargs) + """ Original trainer """ + + def _forward(self, + inputs: torch.Tensor, + targets: list[torch.Tensor]) -> torch.Tensor: + """ Perform the forward pass on the model + + Parameters + ---------- + inputs : :class:`torch.Tensor` + The batch of input image tensors to the model in shape `(side, batch_size, + *dims)` with `side` 0 being input A and `side` 1 being input B + targets : list[:class:`torch.Tensor`] + The corresponding batch of target images for the model for each side's output(s). For + each model output an array should exist in the order of model outputs in the format `( + side, batch_size, *dims)` with `side` 0 being input A and `side` 1 being input B + + Returns + ------- + :class:`torch.Tensor` + The loss for each side of this batch in layout (A1, ..., An, B1, ..., Bn) + """ + feed_targets = [[t[i] for t in targets] for i in range(2)] + preds = self.model.model((inputs[0], inputs[1]), training=True) + self.model.model.zero_grad() + + losses = torch.stack([loss_fn(y_true, y_pred) + for loss_fn, y_true, y_pred in zip(self.model.model.loss, + flatten(feed_targets), + preds)]) + logger.trace("Losses: %s", losses) # type:ignore[attr-defined] + return losses + + def _backwards_and_apply(self, all_loss: torch.Tensor) -> None: + """ Perform the backwards pass on the model + + Parameters + ---------- + all_loss : :class:`torch.Tensor` + The loss for each output from the model + """ + total_loss = T.cast(torch.Tensor, + self.model.model.optimizer.scale_loss(ops.sum(all_loss))) + total_loss.backward() + + trainable_weights = self.model.model.trainable_weights[:] + gradients = [v.value.grad for v in trainable_weights] + + # Update weights + with torch.no_grad(): + self.model.model.optimizer.apply(gradients, trainable_weights) + + def train_batch(self, + inputs: torch.Tensor, + targets: list[torch.Tensor]) -> torch.Tensor: + """Run a single forward and backwards pass through the model for a single batch + + Parameters + ---------- + inputs : :class:`torch.Tensor` + The batch of input image tensors to the model in shape `(side, batch_size, + *dims)` with `side` 0 being input A and `side` 1 being input B + targets : list[:class:`torch.Tensor`] + The corresponding batch of target images for the model for each side's output(s). For + each model output an array should exist in the order of model outputs in the format `( + side, batch_size, *dims)` with `side` 0 being input A and `side` 1 being input B + + Returns + ------- + :class:`torch.Tensor` + The loss for each side of this batch in layout (A1, ..., An, B1, ..., Bn) + """ + loss_tensor = self._forward(inputs, targets) + self._backwards_and_apply(loss_tensor) + return loss_tensor + + +__all__ = get_module_objects(__name__) diff --git a/plugins/train/trainer/original_defaults.py b/plugins/train/trainer/original_defaults.py deleted file mode 100755 index 1cc45e07b1..0000000000 --- a/plugins/train/trainer/original_defaults.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -""" - The default options for the faceswap Original Model plugin. - - Defaults files should be named _defaults.py - Any items placed into this file will automatically get added to the relevant config .ini files - within the faceswap/config folder. - - The following variables should be defined: - _HELPTEXT: A string describing what this plugin does - _DEFAULTS: A dictionary containing the options, defaults and meta information. The - dictionary should be defined as: - {: {}} - - should always be lower text. - dictionary requirements are listed below. - - The following keys are expected for the _DEFAULTS dict: - datatype: [required] A python type class. This limits the type of data that can be - provided in the .ini file and ensures that the value is returned in the - correct type to faceswap. Valid datatypes are: , , - , . - default: [required] The default value for this option. - info: [required] A string describing what this option does. - choices: [optional] If this option's datatype is of then valid - selections can be defined here. This validates the option and also enables - a combobox / radio option in the GUI. - gui_radio: [optional] If are defined, this indicates that the GUI should use - radio buttons rather than a combobox to display this option. - min_max: [partial] For and datatypes this is required - otherwise it is ignored. Should be a tuple of min and max accepted values. - This is used for controlling the GUI slider range. Values are not enforced. - rounding: [partial] For and datatypes this is - required otherwise it is ignored. Used for the GUI slider. For floats, this - is the number of decimal places to display. For ints this is the step size. - fixed: [optional] [train only]. Training configurations are fixed when the model is - created, and then reloaded from the state file. Marking an item as fixed=False - indicates that this value can be changed for existing models, and will override - the value saved in the state file with the updated value in config. If not - provided this will default to True. -""" - - -_HELPTEXT = ("Original Trainer Options.\n" - "WARNING: The defaults for augmentation will be fine for 99.9% of use cases. " - "Only change them if you absolutely know what you are doing!") - - -_DEFAULTS = dict( - preview_images=dict( - default=14, - info="Number of sample faces to display for each side in the preview when training.", - datatype=int, - rounding=2, - min_max=(2, 16), - group="evaluation"), - mask_opacity=dict( - default=30, - info="The opacity of the mask overlay in the training preview. Lower values are more " - "transparent.", - datatype=int, - rounding=2, - min_max=(0, 100), - group="evaluation"), - mask_color=dict( - default="#ff0000", - choices="colorchooser", - info="The RGB hex color to use for the mask overlay in the training preview.", - datatype=str, - group="evaluation"), - zoom_amount=dict( - default=5, - info="Percentage amount to randomly zoom each training image in and out.", - datatype=int, - rounding=1, - min_max=(0, 25), - group="image augmentation"), - rotation_range=dict( - default=10, - info="Percentage amount to randomly rotate each training image.", - datatype=int, - rounding=1, - min_max=(0, 25), - group="image augmentation"), - shift_range=dict( - default=5, - info="Percentage amount to randomly shift each training image horizontally and " - "vertically.", - datatype=int, - rounding=1, - min_max=(0, 25), - group="image augmentation"), - flip_chance=dict( - default=50, - info="Percentage chance to randomly flip each training image horizontally.\n" - "NB: This is ignored if the 'no-flip' option is enabled", - datatype=int, - rounding=1, - min_max=(0, 75), - group="image augmentation"), - - color_lightness=dict( - default=30, - info="Percentage amount to randomly alter the lightness of each training image.\n" - "NB: This is ignored if the 'no-augment-color' option is enabled", - datatype=int, - rounding=1, - min_max=(0, 75), - group="color augmentation"), - color_ab=dict( - default=8, - info="Percentage amount to randomly alter the 'a' and 'b' colors of the L*a*b* color " - "space of each training image.\nNB: This is ignored if the 'no-augment-color' option" - "is enabled", - datatype=int, - rounding=1, - min_max=(0, 50), - group="color augmentation"), - color_clahe_chance=dict( - default=50, - info="Percentage chance to perform Contrast Limited Adaptive Histogram Equalization on " - "each training image.\nNB: This is ignored if the 'no-augment-color' option is " - "enabled", - datatype=int, - rounding=1, - min_max=(0, 75), - fixed=False, - group="color augmentation"), - color_clahe_max_size=dict( - default=4, - info="The grid size dictates how much Contrast Limited Adaptive Histogram Equalization is " - "performed on any training image selected for clahe. Contrast will be applied " - "randomly with a gridsize of 0 up to the maximum. This value is a multiplier " - "calculated from the training image size.\nNB: This is ignored if the " - "'no-augment-color' option is enabled", - datatype=int, - rounding=1, - min_max=(1, 8), - group="color augmentation"), -) diff --git a/plugins/train/trainer/trainer_config.py b/plugins/train/trainer/trainer_config.py new file mode 100644 index 0000000000..08d49656f4 --- /dev/null +++ b/plugins/train/trainer/trainer_config.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" Default configurations for trainers """ +import gettext +import logging + +from lib.config import ConfigItem +from lib.utils import get_module_objects + +logger = logging.getLogger(__name__) + + +# LOCALES +_LANG = gettext.translation("plugins.train.trainer.train_config", + localedir="locales", fallback=True) +_ = _LANG.gettext + + +def get_defaults() -> tuple[str, str, dict[str, ConfigItem]]: + """ Obtain the default values for adding to the config.ini file + + Returns + ------- + helptext : str + The help text for the training config section + section : str + The section name for the config items + defaults : dict[str, :class:`lib.config.objects.ConfigItem`] + The option names and config items + """ + section = "trainer.augmentation" + helptext = _( + "Data Augmentation Options.\n" + "WARNING: The defaults for augmentation will be fine for 99.9% of use cases. " + "Only change them if you absolutely know what you are doing!") + defaults = {k: v for k, v in globals().items() + if isinstance(v, ConfigItem)} + logger.debug("Training config. Helptext: %s, options: %s", helptext, defaults) + return helptext, section, defaults + + +preview_images = ConfigItem( + datatype=int, + default=14, + group=_("evaluation"), + info=_("Number of sample faces to display for each side in the preview when training."), + rounding=2, + min_max=(2, 16)) + +mask_opacity = ConfigItem( + datatype=int, + default=30, + group=_("evaluation"), + info=_("The opacity of the mask overlay in the training preview. Lower values are more " + "transparent."), + rounding=2, + min_max=(0, 100)) + +mask_color = ConfigItem( + datatype=str, + default="#ff0000", + choices="colorchooser", + group=_("evaluation"), + info=_("The RGB hex color to use for the mask overlay in the training preview.")) + +zoom_amount = ConfigItem( + datatype=int, + default=5, + group=_("image augmentation"), + info=_("Percentage amount to randomly zoom each training image in and out."), + rounding=1, + min_max=(0, 25)) + +rotation_range = ConfigItem( + datatype=int, + default=10, + group=_("image augmentation"), + info=_("Percentage amount to randomly rotate each training image."), + rounding=1, + min_max=(0, 25)) + +shift_range = ConfigItem( + datatype=int, + default=5, + group=_("image augmentation"), + info=_("Percentage amount to randomly shift each training image horizontally and " + "vertically."), + rounding=1, + min_max=(0, 25)) + +flip_chance = ConfigItem( + datatype=int, + default=50, + group=_("image augmentation"), + info=_("Percentage chance to randomly flip each training image horizontally.\n" + "NB: This is ignored if the 'no-flip' option is enabled"), + rounding=1, + min_max=(0, 75)) + +color_lightness = ConfigItem( + datatype=int, + default=30, + group=_("color augmentation"), + info=_("Percentage amount to randomly alter the lightness of each training image.\n" + "NB: This is ignored if the 'no-augment-color' option is enabled"), + rounding=1, + min_max=(0, 75)) + +color_ab = ConfigItem( + datatype=int, + default=8, + group=_("color augmentation"), + info=_("Percentage amount to randomly alter the 'a' and 'b' colors of the L*a*b* color " + "space of each training image.\nNB: This is ignored if the 'no-augment-color' option" + "is enabled"), + rounding=1, + min_max=(0, 50)) + +color_clahe_chance = ConfigItem( + datatype=int, + default=50, + group=_("color augmentation"), + info=_("Percentage chance to perform Contrast Limited Adaptive Histogram Equalization on " + "each training image.\nNB: This is ignored if the 'no-augment-color' option is " + "enabled"), + rounding=1, + min_max=(0, 75), + fixed=False) + +color_clahe_max_size = ConfigItem( + datatype=int, + default=4, + group=_("color augmentation"), + info=_("The grid size dictates how much Contrast Limited Adaptive Histogram Equalization is " + "performed on any training image selected for clahe. Contrast will be applied " + "randomly with a gridsize of 0 up to the maximum. This value is a multiplier " + "calculated from the training image size.\nNB: This is ignored if the " + "'no-augment-color' option is enabled"), + rounding=1, + min_max=(1, 8)) + + +__all__ = get_module_objects(__name__) diff --git a/plugins/train/training.py b/plugins/train/training.py new file mode 100644 index 0000000000..74d2953094 --- /dev/null +++ b/plugins/train/training.py @@ -0,0 +1,362 @@ +#! /usr/env/bin/python3 +""" Run the training loop for a training plugin """ +from __future__ import annotations + +import logging +import os +import typing as T +import time + +import numpy as np +import torch + +from torch.cuda import OutOfMemoryError + +from lib.training import Feeder, LearningRateFinder, LearningRateWarmup +from lib.training.tensorboard import TorchTensorBoard +from lib.utils import get_module_objects, FaceswapError +from plugins.train import train_config as mod_cfg +from plugins.train.trainer import trainer_config as trn_cfg + +from plugins.train.trainer._display import Samples, Timelapse + +if T.TYPE_CHECKING: + from collections.abc import Callable + from plugins.train.trainer._base import TrainerBase + +logger = logging.getLogger(__name__) + + +class Trainer: + """ Handles the feeding of training images to Faceswap models, the generation of Tensorboard + logs and the creation of sample/time-lapse preview images. + + All Trainer plugins must inherit from this class. + + Parameters + ---------- + plugin : :class:`TrainerBase` + The plugin that will be processing each batch + images : dict[literal["a", "b"], list[str]] + The file paths for the images to be trained on for each side. The dictionary should contain + 2 keys ("a" and "b") with the values being a list of full paths corresponding to each side. + """ + + def __init__(self, plugin: TrainerBase, images: dict[T.Literal["a", "b"], list[str]]) -> None: + self._batch_size = plugin.batch_size + self._plugin = plugin + self._model = plugin.model + + self._feeder = Feeder(images, plugin.model, plugin.batch_size) + + self._exit_early = self._handle_lr_finder() + if self._exit_early: + logger.debug("Exiting from LR Finder") + return + + self._warmup = self._get_warmup() + self._model.state.add_session_batchsize(plugin.batch_size) + self._images = images + self._sides = sorted(key for key in self._images.keys()) + + self._tensorboard = self._set_tensorboard() + self._samples = Samples(self._model, + self._model.coverage_ratio, + trn_cfg.mask_opacity(), + trn_cfg.mask_color()) + + num_images = trn_cfg.preview_images() + assert isinstance(num_images, int) + self._timelapse = Timelapse(self._model, + self._model.coverage_ratio, + num_images, + trn_cfg.mask_opacity(), + trn_cfg.mask_color(), + self._feeder, + self._images) + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def exit_early(self) -> bool: + """ True if the trainer should exit early, without perfoming any training steps """ + return self._exit_early + + @property + def batch_size(self) -> int: + """int : The batch size that the model is set to train at. """ + return self._batch_size + + def _handle_lr_finder(self) -> bool: + """ Handle the learning rate finder. + + If this is a new model, then find the optimal learning rate and return ``True`` if user has + just requested the graph, otherwise return ``False`` to continue training + + If it as existing model, set the learning rate to the value found by the learing rate + finder and return ``False`` to continue training + + Returns + ------- + bool + ``True`` if the learning rate finder options dictate that training should not continue + after finding the optimal leaning rate + """ + if not self._model.command_line_arguments.use_lr_finder: + return False + + if self._model.state.lr_finder > -1: + learning_rate = self._model.state.lr_finder + logger.info("Setting learning rate from Learning Rate Finder to %s", + f"{learning_rate:.1e}") + self._model.model.optimizer.learning_rate.assign(learning_rate) + self._model.state.update_session_config("learning_rate", learning_rate) + return False + + if self._model.state.iterations == 0 and self._model.state.session_id == 1: + lrf = LearningRateFinder(self) + success = lrf.find() + return mod_cfg.lr_finder_mode() == "graph_and_exit" or not success + + logger.debug("No learning rate finder rate. Not setting") + return False + + def _get_warmup(self) -> LearningRateWarmup: + """ Obtain the learning rate warmup instance + + Returns + ------- + :class:`plugins.train.lr_warmup.LRWarmup` + The Learning Rate Warmup object + """ + target_lr = float(self._model.model.optimizer.learning_rate.value.cpu().numpy()) + return LearningRateWarmup(self._model.model, target_lr, self._model.warmup_steps) + + def _set_tensorboard(self) -> TorchTensorBoard | None: + """ Set up Tensorboard callback for logging loss. + + Bypassed if command line option "no-logs" has been selected. + + Returns + ------- + :class:`keras.callbacks.TensorBoard` | None + Tensorboard object for the the current training session. ``None`` if Tensorboard + logging is not selected + """ + if self._model.state.current_session["no_logs"]: + logger.verbose("TensorBoard logging disabled") # type: ignore + return None + logger.debug("Enabling TensorBoard Logging") + + logger.debug("Setting up TensorBoard Logging") + log_dir = os.path.join(str(self._model.io.model_dir), + f"{self._model.name}_logs", + f"session_{self._model.state.session_id}") + tensorboard = TorchTensorBoard(log_dir=log_dir, + write_graph=True, + update_freq="batch") + tensorboard.set_model(self._model.model) + logger.verbose("Enabled TensorBoard Logging") # type: ignore + return tensorboard + + def toggle_mask(self) -> None: + """ Toggle the mask overlay on or off based on user input. """ + self._samples.toggle_mask_display() + + def train_one_batch(self) -> np.ndarray: + """ Process a single batch through the model and obtain the loss + + Returns + ------- + :class:`numpy.ndarray` + The total loss in the first position then A losses, by output order, then B losses, by + output order + """ + try: + inputs, targets = self._feeder.get_batch() + loss_t = self._plugin.train_batch(torch.from_numpy(inputs), + [torch.from_numpy(t) for t in targets]) + loss_cpu = loss_t.detach().cpu().numpy() + retval = np.array([sum(loss_cpu), *loss_cpu]) + except OutOfMemoryError as err: + msg = ("You do not have enough GPU memory available to train the selected model at " + "the selected settings. You can try a number of things:" + "\n1) Close any other application that is using your GPU (web browsers are " + "particularly bad for this)." + "\n2) Lower the batchsize (the amount of images fed into the model each " + "iteration)." + "\n3) Try enabling 'Mixed Precision' training." + "\n4) Use a more lightweight model, or select the model's 'LowMem' option " + "(in config) if it has one.") + raise FaceswapError(msg) from err + return retval + + def train_one_step(self, + viewer: Callable[[np.ndarray, str], None] | None, + timelapse_kwargs: dict[T.Literal["input_a", "input_b", "output"], + str] | None) -> None: + """ Running training on a batch of images for each side. + + Triggered from the training cycle in :class:`scripts.train.Train`. + + * Runs a training batch through the model. + + * Outputs the iteration's loss values to the console + + * Logs loss to Tensorboard, if logging is requested. + + * If a preview or time-lapse has been requested, then pushes sample images through the \ + model to generate the previews + + * Creates a snapshot if the total iterations trained so far meet the requested snapshot \ + criteria + + Notes + ----- + As every iteration is called explicitly, the Parameters defined should always be ``None`` + except on save iterations. + + Parameters + ---------- + viewer: :func:`scripts.train.Train._show` or ``None`` + The function that will display the preview image + timelapse_kwargs: dict + The keyword arguments for generating time-lapse previews. If a time-lapse preview is + not required then this should be ``None``. Otherwise all values should be full paths + the keys being `input_a`, `input_b`, `output`. + """ + self._model.state.increment_iterations() + logger.trace("Training one step: (iteration: %s)", self._model.iterations) # type: ignore + snapshot_interval = self._model.command_line_arguments.snapshot_interval + do_snapshot = (snapshot_interval != 0 and + self._model.iterations - 1 >= snapshot_interval and + (self._model.iterations - 1) % snapshot_interval == 0) + self._warmup() + loss = self.train_one_batch() + self._log_tensorboard(loss) + loss = self._collate_and_store_loss(loss[1:]) + self._print_loss(loss) + if do_snapshot: + self._model.io.snapshot() + self._update_viewers(viewer, timelapse_kwargs) + + def _log_tensorboard(self, loss: np.ndarray) -> None: + """ Log current loss to Tensorboard log files + + Parameters + ---------- + loss : :class:`numpy.ndarray` + The total loss in the first position then A losses, by output order, then B losses, by + output order + """ + if not self._tensorboard: + return + logger.trace("Updating TensorBoard log") # type: ignore + logs = {log[0]: float(log[1]) + for log in zip(self._model.state.loss_names, loss)} + + self._tensorboard.on_train_batch_end(self._model.iterations, logs=logs) + + def _collate_and_store_loss(self, loss: np.ndarray) -> np.ndarray: + """ Collate the loss into totals for each side. + + The losses are summed into a total for each side. Loss totals are added to + :attr:`model.state._history` to track the loss drop per save iteration for backup purposes. + + If NaN protection is enabled, Checks for NaNs and raises an error if detected. + + Parameters + ---------- + loss : :class:`numpy.ndarray` + The total loss in the first position then A losses, by output order, then B losses, by + output order + + Returns + ------- + :class:`numpy.ndarray` + 2 ``floats`` which is the total loss for each side (eg sum of face + mask loss) + + Raises + ------ + FaceswapError + If a NaN is detected, a :class:`FaceswapError` will be raised + """ + # NaN protection + if mod_cfg.nan_protection() and not all(np.isfinite(val) for val in loss): + logger.critical("NaN Detected. Loss: %s", loss) + raise FaceswapError("A NaN was detected and you have NaN protection enabled. Training " + "has been terminated.") + + split = len(loss) // 2 + combined_loss = np.array([sum(loss[:split]), sum(loss[split:])]) + self._model.add_history(combined_loss) + logger.trace("original loss: %s, combined_loss: %s", loss, combined_loss) # type: ignore + return combined_loss + + def _print_loss(self, loss: np.ndarray) -> None: + """ Outputs the loss for the current iteration to the console. + + Parameters + ---------- + loss : :class`numpy.ndarray` + The loss for each side. List should contain 2 ``floats`` side "a" in position 0 and + side "b" in position `. + """ + output = ", ".join([f"Loss {side}: {side_loss:.5f}" + for side, side_loss in zip(("A", "B"), loss)]) + timestamp = time.strftime("%H:%M:%S") + output = f"[{timestamp}] [#{self._model.iterations:05d}] {output}" + print(f"{output}", end="\r") + + def _update_viewers(self, + viewer: Callable[[np.ndarray, str], None] | None, + timelapse_kwargs: dict[T.Literal["input_a", "input_b", "output"], + str] | None) -> None: + """ Update the preview viewer and timelapse output + + Parameters + ---------- + viewer: :func:`scripts.train.Train._show` or ``None`` + The function that will display the preview image + timelapse_kwargs: dict + The keyword arguments for generating time-lapse previews. If a time-lapse preview is + not required then this should be ``None``. Otherwise all values should be full paths + the keys being `input_a`, `input_b`, `output`. + """ + if viewer is not None: + self._samples.images = self._feeder.generate_preview() + samples = self._samples.show_sample() + if samples is not None: + viewer(samples, + "Training - 'S': Save Now. 'R': Refresh Preview. 'M': Toggle Mask. 'F': " + "Toggle Screen Fit-Actual Size. 'ENTER': Save and Quit") + + if timelapse_kwargs: + self._timelapse.output_timelapse(timelapse_kwargs) + + def _clear_tensorboard(self) -> None: + """ Stop Tensorboard logging. + + Tensorboard logging needs to be explicitly shutdown on training termination. Called from + :class:`scripts.train.Train` when training is stopped. + """ + if not self._tensorboard: + return + logger.debug("Ending Tensorboard Session: %s", self._tensorboard) + self._tensorboard.on_train_end() + + def save(self, is_exit: bool = False) -> None: + """ Save the model + + Parameters + ---------- + is_exit: bool, optional + ``True`` if save has been called on model exit. Default: ``False`` + """ + self._model.io.save(is_exit=is_exit) + assert self._tensorboard is not None + self._tensorboard.on_save() + if is_exit: + self._clear_tensorboard() + + +__all__ = get_module_objects(__name__) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000..71f762d260 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[tool.flake8] +max-line-length = 99 +max-complexity=10 +statistics = true +count = true +exclude = [".git", "__pycache__"] +per-file-ignores = ["__init__.py:F401"] + +[tool.pylint.DESIGN] +min-public-methods = 1 +max-args = 10 +max-attributes = 10 +max-positional-arguments = 10 + +[tool.pylint.TYPECHECK] +generated-members = ["cv2"] + +[[tool.mypy.overrides]] +module = [ + "fastcluster.*", + "ffmpy.*", + "h5py.*", + "imageio_ffmpeg.*", + "keras.*", + "numexpr.*", + "pexpect.*", + "pynvml.*", + "scipy.*", + "sklearn.*", + "tensorboard.*", + "torch.*", + "tqdm.*", + "win32console.*", + "winpty.*",] +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +filterwarnings = ["ignore::DeprecationWarning:keras.*:"] + +[tool.pyright] +reportUnsupportedDunderAll = false diff --git a/requirements/__init__.py b/requirements/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index a3e3990781..b077480b07 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -1,15 +1,20 @@ -tqdm>=4.65 -psutil>=5.9.0 -numexpr>=2.8.7 -numpy>=1.26.0,<2.0.0 -opencv-python>=4.9.0.0,<4.12.0.0 # >=4.12 pulls in numpy2.x -pillow>=9.4.0,<10.0.0 -scikit-learn>=1.3.0 -fastcluster>=1.2.6 -matplotlib>=3.8.0 -imageio>=2.33.1 +packaging>=25.0 +tqdm>=4.67 +psutil>=7.1.0 +numexpr>=2.14.0 +numpy>=2.2.0 +opencv-python>=4.12.0 +pillow>=12.0.0 +scikit-learn>=1.7.2 +fastcluster>=1.3.0 +matplotlib>=3.10.7 +imageio>=2.37.0 # ffmpeg binary >=0.6.0 breaks convert. # TODO fix convert to use latest binary imageio-ffmpeg>=0.4.9,<0.6.0 -ffmpy>=0.3.0 +ffmpy>=0.6.0 pywin32>=305 ; sys_platform == "win32" +#torchvision>=0.18.0,<0.25.0 +torchvision>=0.18.0,<0.25.0 +tensorboard>=2.20.0 +keras>=3.12.0,<3.13.0 diff --git a/requirements/_requirements_dev.txt b/requirements/_requirements_dev.txt new file mode 100644 index 0000000000..3e2a558c9c --- /dev/null +++ b/requirements/_requirements_dev.txt @@ -0,0 +1,11 @@ +# Additional optional development tool requirements +flake8 +flake8-pyproject +mypy +pylint +pytest +pytest-mock +types-setuptools +types-PyYAML +types-psutil +types-tensorflow diff --git a/requirements/requirements.py b/requirements/requirements.py new file mode 100644 index 0000000000..4b573d90ab --- /dev/null +++ b/requirements/requirements.py @@ -0,0 +1,205 @@ +#! /usr/env/bin/python3 +""" Parses the contents of python requirements.txt files and holds the information in a parsable +format + +NOTE: Only packages from the Python Standard Library should be imported in this module +""" +from __future__ import annotations + +import logging +import typing as T +import os + +from importlib import import_module, util as import_util + +if T.TYPE_CHECKING: + from packaging.markers import Marker + from packaging.requirements import Requirement + from packaging.specifiers import Specifier + +logger = logging.getLogger(__name__) + + +PYTHON_VERSIONS: dict[str, tuple[int, int]] = {"rocm_60": (3, 12)} +""" dict[str, tuple[int, int]] : Mapping of requirement file names to the maximum supported +Python version, if below the project maximum """ + + +class Requirements: + """ Parse requirement information + + Parameters + ---------- + include_dev : bool, optional + ``True`` to additionally load requirements from the dev requirements file + """ + def __init__(self, include_dev: bool = False) -> None: + self._include_dev = include_dev + self._marker: type[Marker] | None = None + self._requirement: type[Requirement] | None = None + self._specifier: type[Specifier] | None = None + self._global_options: dict[str, list[str]] = {} + self._requirements: dict[str, list[Requirement]] = {} + + @property + def packaging_available(self) -> bool: + """ bool : ``True`` if the packaging Library is available otherwise ``False`` """ + if self._requirement is not None: + return True + return import_util.find_spec("packaging") is not None + + @property + def requirements(self) -> dict[str, list[Requirement]]: + """ dict[str, list[Requirement]] : backend type as key, list of required packages as + value """ + if not self._requirements: + self._load_requirements() + return self._requirements + + @property + def global_options(self) -> dict[str, list[str]]: + """ dict[str, list[str]] : The global pip install options for each backend """ + if not self._requirements: + self._load_requirements() + return self._global_options + + def __repr__(self) -> str: + """ Pretty print the required packages for logging """ + props = ", ".join( + f"{k}={repr(getattr(self, k))}" + for k, v in self.__class__.__dict__.items() + if isinstance(v, property) and not k.startswith("_")) + return f"{self.__class__.__name__}({props})" + + def _import_packaging(self) -> None: + """ Import the packaging library and set the required classes to class attributes. """ + if self._requirement is not None: + return + + logger.debug("Importing packaging library") + mark_mod = import_module("packaging.markers") + req_mod = import_module("packaging.requirements") + spec_mod = import_module("packaging.specifiers") + self._marker = mark_mod.Marker + self._requirement = req_mod.Requirement + self._specifier = spec_mod.Specifier + + @classmethod + def _parse_file(cls, file_path: str) -> tuple[list[str], list[str]]: + """ Parse a requirements file + + Parameters + ---------- + file_path : str + The full path to a requirements file to parse + + Returns + ------- + global_options : list[str] + Any global options collected from the requirements file + requirements : list[str] + The requirements strings from the requirments file + """ + global_options = [] + requirements = [] + with open(file_path, encoding="utf8") as f: + for line in f: + line = line.strip() # Skip blanks, comments and nested requirement files + if not line or line.startswith(("#", "-r")): + continue + + line = line.split("#", maxsplit=1)[0] # Strip inline comments + + if line.startswith("-"): # Collect global option + global_options.append(line) + continue + requirements.append(line) # Collect requirement + + logger.debug("Parsed requirements file '%s'. global_options: %s, requirements: %s", + os.path.basename(file_path), global_options, requirements) + return global_options, requirements + + def parse_requirements(self, packages: list[str]) -> list[Requirement]: + """ Drop in replacement for deprecated pkg_resources.parse_requirements + + Parameters + ---------- + packages: list[str] + List of packages formatted from a requirements.txt file + + Returns + ------- + list[:class:`packaging.Requirement`] + List of Requirement objects + """ + self._import_packaging() + assert self._requirement is not None + requirements = [self._requirement(p) for p in packages] + retval = [r for r in requirements if r.marker is None or r.marker.evaluate()] + if len(retval) != len(requirements): + logger.debug("Filtered invalid packages %s", + [(r.name, r.marker) for r in set(requirements).difference(set(retval))]) + logger.debug("Parsed requirements %s: %s", packages, retval) + return retval + + def _parse_options(self, options: list[str]) -> list[str]: + """ Parse global options from a requirements file and only return valid options + + Parameters + ---------- + options: list[str] + List of global options formatted from a requirements.txt file + + Returns + ------- + list[str] + List of global options valid for the running system + """ + if not options: + return options + assert self._marker is not None + retval = [] + for opt in options: + if ";" not in opt: + retval.append(opt) + continue + directive, marker = opt.split(";", maxsplit=1) + if not self._marker(marker.strip()).evaluate(): + logger.debug("Filtered invalid option: '%s'", opt) + continue + retval.append(directive.strip()) + + logger.debug("Selected options: %s", retval) + return retval + + def _load_requirements(self) -> None: + """ Parse the requirements files and populate information to :attr:`_requirements` """ + req_path = os.path.dirname(os.path.realpath(__file__)) + base_file = os.path.join(req_path, "_requirements_base.txt") + req_files = [os.path.join(req_path, f) + for f in os.listdir(req_path) + if f.startswith("requirements_") + and os.path.splitext(f)[-1] == ".txt"] + + opts_base, reqs_base = self._parse_file(base_file) + parsed_reqs_base = self.parse_requirements(reqs_base) + parsed_opts_base = self._parse_options(opts_base) + + if self._include_dev: + opts_dev, reqs_dev = self._parse_file(os.path.join(req_path, "_requirements_dev.txt")) + opts_base += opts_dev + parsed_reqs_base += self.parse_requirements(reqs_dev) + parsed_opts_base += self._parse_options(opts_dev) + + for req_file in req_files: + backend = os.path.splitext(os.path.basename(req_file))[0].replace("requirements_", "") + assert backend + opts, reqs = self._parse_file(req_file) + self._requirements[backend] = parsed_reqs_base + self.parse_requirements(reqs) + self._global_options[backend] = parsed_opts_base + self._parse_options(opts) + logger.debug("[%s] Requirements: %s , Options: %s", + backend, self._requirements[backend], self._global_options[backend]) + + +if __name__ == "__main__": + print(Requirements(include_dev=True)) diff --git a/requirements/requirements_apple_silicon.txt b/requirements/requirements_apple-silicon.txt similarity index 56% rename from requirements/requirements_apple_silicon.txt rename to requirements/requirements_apple-silicon.txt index 5732337ea2..48599420c0 100644 --- a/requirements/requirements_apple_silicon.txt +++ b/requirements/requirements_apple-silicon.txt @@ -1,7 +1,5 @@ -r _requirements_base.txt -tensorflow-macos>=2.10.0,<2.11.0 -tensorflow-deps>=2.10.0,<2.11.0 -tensorflow-metal>=0.6.0,<0.7.0 # These next 2 should have been installed, but some users complain of errors decorator cloudpickle +torch>=2.3.0,<2.10.0 diff --git a/requirements/requirements_cpu.txt b/requirements/requirements_cpu.txt index 873e3d3561..e3567a428b 100644 --- a/requirements/requirements_cpu.txt +++ b/requirements/requirements_cpu.txt @@ -1,2 +1,3 @@ -r _requirements_base.txt -tensorflow-cpu>=2.10.0,<2.11.0 +--extra-index-url https://download.pytorch.org/whl/cpu +torch>=2.3.0,<2.10.0 diff --git a/requirements/requirements_directml.txt b/requirements/requirements_directml.txt deleted file mode 100644 index d7e0dbc227..0000000000 --- a/requirements/requirements_directml.txt +++ /dev/null @@ -1,4 +0,0 @@ --r _requirements_base.txt -tensorflow-cpu>=2.10.0,<2.11.0 -tensorflow-directml-plugin -comtypes diff --git a/requirements/requirements_nvidia.txt b/requirements/requirements_nvidia.txt index 45a558911f..70cfd9676e 100644 --- a/requirements/requirements_nvidia.txt +++ b/requirements/requirements_nvidia.txt @@ -1,5 +1,2 @@ --r _requirements_base.txt -# Exclude badly numbered Python2 version of nvidia-ml-py -nvidia-ml-py>=12.535,<300 -pynvx==1.0.0 ; sys_platform == "darwin" -tensorflow>=2.10.0,<2.11.0 +# Meta requirements file for latest Nvidia version +-r _requirements_nvidia_13.txt diff --git a/requirements/requirements_nvidia_11.txt b/requirements/requirements_nvidia_11.txt new file mode 100644 index 0000000000..10dd42b80f --- /dev/null +++ b/requirements/requirements_nvidia_11.txt @@ -0,0 +1,8 @@ +# Cuda compatibility 3.5-9.0 +# GTX7xx - RTX40xx +# Maximum supported Python: 3.13 +-r _requirements_base.txt +# Exclude badly numbered Python2 version of nvidia-ml-py +nvidia-ml-py>=12.535,<300 +--extra-index-url https://download.pytorch.org/whl/cu118 +torch>=2.7.0,<2.8.0 diff --git a/requirements/requirements_nvidia_12.txt b/requirements/requirements_nvidia_12.txt new file mode 100644 index 0000000000..cefd2da151 --- /dev/null +++ b/requirements/requirements_nvidia_12.txt @@ -0,0 +1,7 @@ +# Cuda compatibility 5.0-12.0 +# GTX9xx - RTX50xx +-r _requirements_base.txt +# Exclude badly numbered Python2 version of nvidia-ml-py +nvidia-ml-py>=12.535,<300 +--extra-index-url https://download.pytorch.org/whl/cu126 +torch>=2.7.0,<2.10.0 diff --git a/requirements/requirements_nvidia_13.txt b/requirements/requirements_nvidia_13.txt new file mode 100644 index 0000000000..79ccbcdd0c --- /dev/null +++ b/requirements/requirements_nvidia_13.txt @@ -0,0 +1,7 @@ +# Cuda compatibility 7.5- +# RTX 20xx - +-r _requirements_base.txt +# Exclude badly numbered Python2 version of nvidia-ml-py +nvidia-ml-py>=12.535,<300 +--extra-index-url https://download.pytorch.org/whl/cu130 +torch>=2.9.0,<2.10.0 diff --git a/requirements/requirements_rocm.txt b/requirements/requirements_rocm.txt index b23ce01590..76f61581ea 100644 --- a/requirements/requirements_rocm.txt +++ b/requirements/requirements_rocm.txt @@ -1,2 +1,2 @@ --r _requirements_base.txt -tensorflow-rocm>=2.10.0,<2.11.0 +# Meta requirements file for latest ROCm version +-r _requirements_rocm_64.txt diff --git a/requirements/requirements_rocm_60.txt b/requirements/requirements_rocm_60.txt new file mode 100644 index 0000000000..23d6b4dc3e --- /dev/null +++ b/requirements/requirements_rocm_60.txt @@ -0,0 +1,4 @@ +# Maximum supported Python: 3.12 +-r _requirements_base.txt +--extra-index-url https://download.pytorch.org/whl/rocm6.0 +torch>=2.4.0,<2.5.0 diff --git a/requirements/requirements_rocm_61.txt b/requirements/requirements_rocm_61.txt new file mode 100644 index 0000000000..efaad01b67 --- /dev/null +++ b/requirements/requirements_rocm_61.txt @@ -0,0 +1,4 @@ +# Maximum supported Python: 3.13 +-r _requirements_base.txt +--extra-index-url https://download.pytorch.org/whl/rocm6.1 +torch>=2.5.0,<2.7.0 diff --git a/requirements/requirements_rocm_62.txt b/requirements/requirements_rocm_62.txt new file mode 100644 index 0000000000..0c47e1ae20 --- /dev/null +++ b/requirements/requirements_rocm_62.txt @@ -0,0 +1,5 @@ +# Maximum supported Python: 3.13 +-r _requirements_base.txt +--extra-index-url https://download.pytorch.org/whl/rocm6.2 +--extra-index-url https://download.pytorch.org/whl/rocm6.2.4 +torch>=2.5.0,<2.8.0 diff --git a/requirements/requirements_rocm_63.txt b/requirements/requirements_rocm_63.txt new file mode 100644 index 0000000000..73c53bdb27 --- /dev/null +++ b/requirements/requirements_rocm_63.txt @@ -0,0 +1,3 @@ +-r _requirements_base.txt +--extra-index-url https://download.pytorch.org/whl/rocm6.3 +torch>=2.7.0,<2.10.0 diff --git a/requirements/requirements_rocm_64.txt b/requirements/requirements_rocm_64.txt new file mode 100644 index 0000000000..a4deb56725 --- /dev/null +++ b/requirements/requirements_rocm_64.txt @@ -0,0 +1,3 @@ +-r _requirements_base.txt +--extra-index-url https://download.pytorch.org/whl/rocm6.4 +torch>=2.8.0,<2.10.0 diff --git a/scripts/convert.py b/scripts/convert.py index 7ae3f0eca6..13e5f2ea96 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -14,7 +14,8 @@ import numpy as np from tqdm import tqdm -from scripts.fsmedia import Alignments, PostProcess, finalize +from scripts import fsmedia +from scripts.fsmedia import PostProcess, finalize from lib.serializer import get_serializer from lib.convert import Converter from lib.align import AlignedFace, DetectedFace, update_legacy_png_header @@ -22,9 +23,11 @@ from lib.image import read_image_meta_batch, ImagesLoader from lib.multithreading import MultiThread, total_cpus from lib.queue_manager import queue_manager -from lib.utils import FaceswapError, get_folder, get_image_paths, handle_deprecated_cliopts +from lib.utils import (get_module_objects, FaceswapError, get_folder, + get_image_paths, handle_deprecated_cliopts) from plugins.extract import ExtractMedia, Extractor from plugins.plugin_loader import PluginLoader +from plugins.train import train_config as mod_cfg if T.TYPE_CHECKING: from argparse import Namespace @@ -59,7 +62,7 @@ class ConvertItem: inbound: ExtractMedia feed_faces: list[AlignedFace] = field(default_factory=list) reference_faces: list[AlignedFace] = field(default_factory=list) - swapped_faces: np.ndarray = np.array([]) + swapped_faces: np.ndarray = field(default_factory=lambda: np.array([])) class Convert(): @@ -127,15 +130,15 @@ def _pool_processes(self) -> int: logger.debug(retval) return retval - def _get_alignments(self) -> Alignments: + def _get_alignments(self) -> fsmedia.Alignments: """ Perform validation checks and legacy updates and return alignemnts object Returns ------- - :class:`~lib.align.alignments.Alignments` + :class:`~scripts.fsmedia.Alignments` The alignments file for the extract job """ - retval = Alignments(self._args, False, self._images.is_video) + retval = fsmedia.Alignments(self._args, False, self._images.is_video) if retval.version == 1.0: logger.error("The alignments file format has been updated since the given alignments " "file was generated. You need to update the file to proceed.") @@ -280,7 +283,7 @@ def _check_thread_error(self) -> None: thread.check_and_raise_error() -class DiskIO(): +class DiskIO(): # pylint:disable=too-many-instance-attributes """ Disk Input/Output for the converter process. Background threads to: @@ -289,7 +292,7 @@ class DiskIO(): Parameters ---------- - alignments: :class:`lib.alignmnents.Alignments` + alignments: :class:`scripts.fsmedia.Alignments` The alignments for the input video images: :class:`lib.image.ImagesLoader` The input images @@ -301,7 +304,7 @@ class DiskIO(): """ def __init__(self, - alignments: Alignments, + alignments: fsmedia.Alignments, images: ImagesLoader, predictor: Predict, arguments: Namespace) -> None: @@ -333,9 +336,9 @@ def completion_event(self) -> Event: @property def draw_transparent(self) -> bool: - """ bool: ``True`` if the selected writer's Draw_transparent configuration item is set - otherwise ``False`` """ - return self._writer.config.get("draw_transparent", False) + """ bool: ``True`` if the selected writer can output transparent and it's Draw_transparent + configuration item is set otherwise ``False`` """ + return self._writer.output_alpha @property def pre_encode(self) -> Callable[[np.ndarray, T.Any], list[bytes]] | None: @@ -541,7 +544,7 @@ def _load(self, *args) -> None: # pylint:disable=unused-argument idx = 0 for filename, image in self._images.load(): idx += 1 - if self._queues["load"].shutdown.is_set(): + if self._queues["load"].shutdown_event.is_set(): logger.debug("Load Queue: Stop signal received. Terminating") break if image is None or (not image.any() and image.ndim not in (2, 3)): @@ -703,7 +706,7 @@ def _save(self, completion_event: Event) -> None: preview_image = os.path.join(self._writer.output_folder, ".gui_preview.jpg") logger.debug("Write preview for gui: %s", write_preview) for idx in tqdm(range(self._total_count), desc="Converting", file=sys.stdout): - if self._queues["save"].shutdown.is_set(): + if self._queues["save"].shutdown_event.is_set(): logger.debug("Save Queue: Stop signal received. Terminating") break item: tuple[str, np.ndarray | bytes] | T.Literal["EOF"] = self._queues["save"].get() @@ -722,7 +725,7 @@ def _save(self, completion_event: Event) -> None: logger.debug("Save Faces: Complete") -class Predict(): +class Predict(): # pylint:disable=too-many-instance-attributes """ Obtains the output from the Faceswap model. Parameters @@ -747,7 +750,8 @@ def __init__(self, queue_size: int, arguments: Namespace) -> None: self._batchsize = self._get_batchsize(queue_size) self._sizes = self._get_io_sizes() self._coverage_ratio = self._model.coverage_ratio - self._centering = self._model.config["centering"] + self._y_offset = mod_cfg.vertical_offset() / 100. + self._centering: CenteringType = T.cast("CenteringType", mod_cfg.centering()) self._thread: MultiThread | None = None logger.debug("Initialized %s: (out_queue: %s)", self.__class__.__name__, self._out_queue) @@ -793,7 +797,7 @@ def centering(self) -> CenteringType: @property def has_predicted_mask(self) -> bool: """ bool: ``True`` if the model was trained to learn a mask, otherwise ``False``. """ - return bool(self._model.config.get("learn_mask", False)) + return bool(mod_cfg.Loss.learn_mask()) @property def output_size(self) -> int: @@ -851,8 +855,8 @@ def _get_batchsize(self, queue_size: int) -> int: The batch size that the model is to be fed at. """ logger.debug("Getting batchsize") - is_cpu = GPUStats().device_count == 0 - batchsize = 1 if is_cpu else self._model.config["convert_batchsize"] + is_cpu = GPUStats is None or GPUStats().device_count == 0 + batchsize = 1 if is_cpu else mod_cfg.convert_batchsize() batchsize = min(queue_size, batchsize) logger.debug("Got batchsize: %s", batchsize) return batchsize @@ -1000,6 +1004,7 @@ def load_aligned(self, item: ConvertItem) -> None: centering=self._centering, size=self._sizes["input"], coverage_ratio=self._coverage_ratio, + y_offset=self._y_offset, dtype="float32") if self._sizes["input"] == self._sizes["output"]: reference_faces.append(feed_face) @@ -1009,6 +1014,7 @@ def load_aligned(self, item: ConvertItem) -> None: centering=self._centering, size=self._sizes["output"], coverage_ratio=self._coverage_ratio, + y_offset=self._y_offset, dtype="float32")) feed_faces.append(feed_face) item.feed_faces = feed_faces @@ -1056,10 +1062,12 @@ def _predict(self, feed_faces: np.ndarray, batch_size: int | None = None) -> np. if self._model.color_order.lower() == "rgb": feed_faces = feed_faces[..., ::-1] - feed = [feed_faces] + feed = feed_faces logger.trace("Input shape(s): %s", [item.shape for item in feed]) # type:ignore - inbound = self._model.model.predict(feed, verbose=0, batch_size=batch_size) + inbound = self._model.model.predict(feed, + verbose=0, # pyright:ignore[reportArgumentType] + batch_size=batch_size) predicted: list[np.ndarray] = inbound if isinstance(inbound, list) else [inbound] if self._model.color_order.lower() == "rgb": @@ -1113,18 +1121,18 @@ class OptionalActions(): # pylint:disable=too-few-public-methods Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments : :class:`argparse.Namespace` The arguments that were passed to the convert process as generated from Faceswap's command line arguments - input_images: list + input_images : list[str] List of input image files - alignments: :class:`lib.align.Alignments` + alignments : :class:`scripts.fsmedia.Alignments` The alignments file for this conversion """ def __init__(self, arguments: Namespace, - input_images: list[np.ndarray], - alignments: Alignments) -> None: + input_images: list[str], + alignments: fsmedia.Alignments) -> None: logger.debug("Initializing %s", self.__class__.__name__) self._args = arguments self._input_images = input_images @@ -1196,3 +1204,6 @@ def _get_face_metadata(self) -> dict[str, list[int]]: logger.warning("Aligned directory contains far fewer images than the input " "directory, are you sure this is the right folder?") return retval + + +__all__ = get_module_objects(__name__) diff --git a/scripts/extract.py b/scripts/extract.py index da9edf1d15..23b346f738 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -12,11 +12,13 @@ import numpy as np from tqdm import tqdm +import torch from lib.align.alignments import PNGHeaderDict from lib.image import encode_image, generate_thumbnail, ImagesLoader, ImagesSaver, read_image_meta from lib.multithreading import MultiThread -from lib.utils import get_folder, handle_deprecated_cliopts, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS +from lib.utils import (get_folder, get_module_objects, handle_deprecated_cliopts, + IMAGE_EXTENSIONS, VIDEO_EXTENSIONS) from plugins.extract import ExtractMedia, Extractor from scripts.fsmedia import Alignments, PostProcess, finalize @@ -64,7 +66,6 @@ def __init__(self, arguments: Namespace) -> None: recognition=recognition, configfile=configfile, multiprocess=not self._args.singleprocess, - exclude_gpus=self._args.exclude_gpus, rotate_images=self._args.rotate_images, min_size=self._args.min_size, normalize_method=normalization, @@ -429,7 +430,7 @@ def _identity_from_extractor(self, file_list: list[str], aligned: list[str]) -> self._extractor.launch() desc = "Obtaining reference face Identity" if self._extractor.passes > 1: - desc = (f"{desc } pass {phase + 1} of {self._extractor.passes}: " + desc = (f"{desc} pass {phase + 1} of {self._extractor.passes}: " f"{self._extractor.phase_text}") for extract_media in tqdm(self._extractor.detected_faces(), total=len(file_list), @@ -578,7 +579,7 @@ def _load(self) -> None: logger.debug("Load Images: Start") load_queue = self._extractor.input_queue for filename, image in self._images.load(): - if load_queue.shutdown.is_set(): + if load_queue.shutdown_event.is_set(): logger.debug("Load Queue: Stop signal received. Terminating") break is_aligned = filename in self._aligned_filenames @@ -602,7 +603,7 @@ def _reload(self, detected_faces: dict[str, ExtractMedia]) -> None: logger.debug("Reload Images: Start. Detected Faces Count: %s", len(detected_faces)) load_queue = self._extractor.input_queue for filename, image in self._images.load(): - if load_queue.shutdown.is_set(): + if load_queue.shutdown_event.is_set(): logger.debug("Reload Queue: Stop signal received. Terminating") break logger.trace("Reloading image: '%s'", filename) # type: ignore @@ -741,7 +742,8 @@ def _run_extraction(self) -> None: detected_faces[extract_media.filename] = extract_media if not is_final: - logger.debug("Reloading images") + logger.debug("Reloading images and resetting PyTorch memory cache") + torch.cuda.empty_cache() self._loader.reload(detected_faces) if saver is not None: saver.close() @@ -824,3 +826,6 @@ def _output_faces(self, saver: ImagesSaver | None, extract_media: ExtractMedia) self._alignments.data[os.path.basename(extract_media.filename)] = {"faces": final_faces, "video_meta": {}} del extract_media + + +__all__ = get_module_objects(__name__) diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index 9d1fbdbb4e..913235610f 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -19,7 +19,7 @@ from lib.align import Alignments as AlignmentsBase, get_centered_size from lib.image import count_frames, read_image -from lib.utils import (camel_case_split, get_image_paths, VIDEO_EXTENSIONS) +from lib.utils import camel_case_split, get_image_paths, get_module_objects, VIDEO_EXTENSIONS if T.TYPE_CHECKING: from collections.abc import Generator @@ -616,3 +616,6 @@ def process(self, extract_media: ExtractMedia) -> None: roi = face.aligned.get_cropped_roi(face.aligned.size, self._legacy_size, "legacy") cv2.rectangle(face.aligned.face, tuple(roi[:2]), tuple(roi[2:]), (0, 0, 255), 1) self._print_stats(face.aligned) + + +__all__ = get_module_objects(__name__) diff --git a/scripts/gui.py b/scripts/gui.py index eea446f54b..efacc94068 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -7,26 +7,38 @@ from tkinter import messagebox, ttk from lib.gui import (TaskBar, CliOptions, CommandNotebook, ConsoleOut, DisplayNotebook, - get_images, initialize_images, initialize_config, LastSession, - MainMenuBar, preview_trigger, ProcessWrapper, StatusBar) + get_images, gui_config as cfg, initialize_images, initialize_config, + LastSession, MainMenuBar, preview_trigger, ProcessWrapper, StatusBar) +from lib.utils import get_module_objects logger = logging.getLogger(__name__) class FaceswapGui(tk.Tk): - """ The Graphical User Interface """ + """ The Graphical User Interface - def __init__(self, debug): + Launch the Faceswap GUI + + Parameters + ---------- + debug : bool + Output to the terminal rather than to Faceswap's internal console + config_file : str | None + Path to a custom .ini configuration file. ``None`` to use the default config file + """ + + def __init__(self, debug, config_file): logger.debug("Initializing %s", self.__class__.__name__) super().__init__() + cfg.load_config(config_file) - self._init_args = dict(debug=debug) + self._init_args = {"debug": debug} self._config = self.initialize_globals() self.set_fonts() - self._config.set_geometry(1200, 640, self._config.user_config_dict["fullscreen"]) + self._config.set_geometry(1200, 640, cfg.fullscreen()) self.wrapper = ProcessWrapper() - self.objects = dict() + self.objects = {} get_images().delete_preview() preview_trigger().clear(trigger_type=None) @@ -99,7 +111,7 @@ def add_containers(self): def set_initial_focus(self): """ Set the tab focus from settings """ - tab = self._config.user_config_dict["tab"] + tab = cfg.tab() logger.debug("Setting focus for tab: %s", tab) self._config.set_active_tab_by_name(tab) logger.debug("Focus set to: %s", tab) @@ -107,11 +119,10 @@ def set_initial_focus(self): def set_layout(self): """ Set initial layout """ self.update_idletasks() - config_opts = self._config.user_config_dict r_width = self.winfo_width() r_height = self.winfo_height() - w_ratio = config_opts["options_panel_width"] / 100.0 - h_ratio = 1 - (config_opts["console_panel_height"] / 100.0) + w_ratio = cfg.options_panel_width() / 100.0 + h_ratio = 1 - (cfg.console_panel_height() / 100.0) width = round(r_width * w_ratio) height = round(r_height * h_ratio) logger.debug("Setting Initial Layout: (root_width: %s, root_height: %s, width_ratio: %s, " @@ -125,8 +136,7 @@ def rebuild(self): """ Rebuild the GUI on config change """ logger.debug("Redrawing GUI") session_state = self._last_session.to_dict() - self._config.refresh_config() - get_images().__init__() + get_images().__init__() # pylint:disable=unnecessary-dunder-call self.set_fonts() self.build_gui(rebuild=True) if session_state is not None: @@ -176,8 +186,11 @@ def _confirm_close_on_running_task(self): class Gui(): """ The GUI process. """ def __init__(self, arguments): - self.root = FaceswapGui(arguments.debug) + self.root = FaceswapGui(arguments.debug, arguments.configfile) def process(self): """ Builds the GUI """ self.root.mainloop() + + +__all__ = get_module_objects(__name__) diff --git a/scripts/train.py b/scripts/train.py index bd455bcb4a..bd4e6d6dcd 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -17,15 +17,15 @@ from lib.keypress import KBHit from lib.multithreading import MultiThread, FSThread from lib.training import Preview, PreviewBuffer, TriggerType -from lib.utils import (get_folder, get_image_paths, handle_deprecated_cliopts, +from lib.utils import (get_folder, get_image_paths, get_module_objects, handle_deprecated_cliopts, FaceswapError, IMAGE_EXTENSIONS) from plugins.plugin_loader import PluginLoader +from plugins.train.training import Trainer if T.TYPE_CHECKING: import argparse from collections.abc import Callable from plugins.train.model._base import ModelBase - from plugins.train.trainer._base import TrainerBase logger = logging.getLogger(__name__) @@ -247,6 +247,7 @@ def _end_thread(self, thread: MultiThread, err: bool) -> None: def _training(self) -> None: """ The training process to be run inside a thread. """ + trainer = None try: sleep(0.5) # Let preview instructions flush out to logger logger.debug("Commencing Training") @@ -254,14 +255,15 @@ def _training(self) -> None: model = self._load_model() trainer = self._load_trainer(model) if trainer.exit_early: + logger.debug("Trainer exits early") self._stop = True return - self._run_training_cycle(model, trainer) + self._run_training_cycle(trainer) except KeyboardInterrupt: try: logger.debug("Keyboard Interrupt Caught. Saving Weights and exiting") - model.io.save(is_exit=True) - trainer.clear_tensorboard() + if trainer is not None: + trainer.save(is_exit=True) except KeyboardInterrupt: logger.info("Saving model weights has been cancelled!") sys.exit(0) @@ -286,7 +288,7 @@ def _load_model(self) -> ModelBase: logger.debug("Loaded Model") return model - def _load_trainer(self, model: ModelBase) -> TrainerBase: + def _load_trainer(self, model: ModelBase) -> Trainer: """ Load the trainer requested for training. Parameters @@ -296,19 +298,25 @@ def _load_trainer(self, model: ModelBase) -> TrainerBase: Returns ------- - :file:`plugins.train.trainer` plugin - The requested model trainer plugin + :class:`plugins.train.trainer.run_train.Trainer` + The model training loop with the requested trainer plugin loaded """ logger.debug("Loading Trainer") - base = PluginLoader.get_trainer(model.trainer) - trainer: TrainerBase = base(model, - self._images, - self._args.batch_size, - self._args.configfile) + trainer = "distributed" if self._args.distributed else "original" + if trainer == "distributed": + import torch # pylint:disable=import-outside-toplevel + gpu_count = torch.cuda.device_count() + if gpu_count < 2: + logger.warning("Distributed selected but fewer than 2 GPUs detected. Switching " + "to Original") + trainer = "original" + + retval = Trainer(PluginLoader.get_trainer(trainer)(model, self._args.batch_size), + self._images) logger.debug("Loaded Trainer") - return trainer + return retval - def _run_training_cycle(self, model: ModelBase, trainer: TrainerBase) -> None: + def _run_training_cycle(self, trainer: Trainer) -> None: """ Perform the training cycle. Handles the background training, updating previews/time-lapse on each save interval, @@ -316,8 +324,6 @@ def _run_training_cycle(self, model: ModelBase, trainer: TrainerBase) -> None: Parameters ---------- - model: :file:`plugins.train.model` plugin - The requested model plugin trainer: :file:`plugins.train.trainer` plugin The requested model trainer plugin """ @@ -348,7 +354,7 @@ def _run_training_cycle(self, model: ModelBase, trainer: TrainerBase) -> None: if viewer is not None and not save_iteration: # Spammy but required by GUI to know to update window - print("") + print("\x1b[2K", end="\r") # Clear last line logger.info("[Preview Updated]") if self._stop: @@ -358,13 +364,12 @@ def _run_training_cycle(self, model: ModelBase, trainer: TrainerBase) -> None: if save_iteration or self._save_now: logger.debug("Saving (save_iterations: %s, save_now: %s) Iteration: " "(iteration: %s)", save_iteration, self._save_now, iteration) - model.io.save(is_exit=False) + trainer.save(is_exit=False) self._save_now = False update_preview_images = True logger.debug("Training cycle complete") - model.io.save(is_exit=True) - trainer.clear_tensorboard() + trainer.save(is_exit=True) self._stop = True def _output_startup_info(self) -> None: @@ -425,7 +430,7 @@ def _process_gui_triggers(self) -> dict[T.Literal["mask", "refresh"], bool]: logger.debug("Removing gui trigger file: %s", filename) os.remove(filename) if trigger == "refresh": - print("") # Let log print on different line from loss output + print("\x1b[2K", end="\r") # Clear last line logger.info("Refresh preview requested...") return retval @@ -434,7 +439,7 @@ def _monitor(self, thread: MultiThread) -> bool: Parameters ---------- - thread: :class:~`lib.multithreading.MultiThread` + thread: :class:`~lib.multithreading.MultiThread` The thread containing the training loop Returns @@ -469,6 +474,7 @@ def _monitor(self, thread: MultiThread) -> bool: except KeyboardInterrupt: logger.debug("Keyboard Interrupt received") break + logger.debug("Closing Monitor") self._preview.shutdown() keypress.set_normal_term() logger.debug("Closed Monitor") @@ -614,3 +620,6 @@ def shutdown(self) -> None: return logger.debug("Sending shutdown to preview viewer") self._triggers["shutdown"].set() + + +__all__ = get_module_objects(__name__) diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 6427fca936..0000000000 --- a/setup.cfg +++ /dev/null @@ -1,57 +0,0 @@ -[flake8] -max-line-length = 99 -max-complexity=10 -statistics = True -count = True -exclude = .git, __pycache__ -per-file-ignores = - __init__.py:F401 - lib/gpu_stats/directml.py:E402 - -[mypy] -[mypy-comtypes.*] -ignore_missing_imports = True -[mypy-cv2.*] -ignore_missing_imports = True -[mypy-fastcluster.*] -ignore_missing_imports = True -[mypy-ffmpy.*] -ignore_missing_imports = True -[mypy-imageio.*] -ignore_missing_imports = True -[mypy-imageio_ffmpeg.*] -ignore_missing_imports = True -[mypy-keras.*] -ignore_missing_imports = True -[mypy-matplotlib.*] -ignore_missing_imports = True -[mypy-numexpr.*] -ignore_missing_imports = True -[mypy-numpy.*] -ignore_missing_imports = True -[mypy-numpy.core._multiarray_umath.*] -ignore_missing_imports = True -[mypy-pexpect.*] -ignore_missing_imports = True -[mypy-PIL.*] -ignore_missing_imports = True -[mypy-psutil.*] -ignore_missing_imports = True -[mypy-pynvml.*] -ignore_missing_imports = True -[mypy-pynvx.*] -ignore_missing_imports = True -[mypy-pytest.*] -ignore_missing_imports = True -[mypy-scipy.*] -ignore_missing_imports = True -[mypy-sklearn.*] -ignore_missing_imports = True -[mypy-tensorflow.*] -ignore_missing_imports = True -[mypy-tqdm.*] -ignore_missing_imports = True -[mypy-win32console.*] -ignore_missing_imports = True -[mypy-winpty.*] -ignore_missing_imports = True diff --git a/setup.py b/setup.py index 754f3b209d..32891e929d 100755 --- a/setup.py +++ b/setup.py @@ -1,66 +1,51 @@ #!/usr/bin/env python3 """ Install packages for faceswap.py """ # pylint:disable=too-many-lines +from __future__ import annotations import logging -import ctypes import json -import locale -import platform -import operator import os import re import sys import typing as T +from importlib import import_module from shutil import which -from subprocess import PIPE, Popen, run, STDOUT - -from pkg_resources import parse_requirements +from string import printable +from subprocess import PIPE, Popen from lib.logger import log_setup +from lib.system import Cuda, Packages, ROCm, System +from lib.utils import get_module_objects, PROJECT_ROOT +from requirements.requirements import Requirements, PYTHON_VERSIONS + +if T.TYPE_CHECKING: + from packaging.requirements import Requirement + import pip + import lib.utils as lib_utils logger = logging.getLogger(__name__) -backend_type: T.TypeAlias = T.Literal['nvidia', 'apple_silicon', 'directml', 'cpu', 'rocm', "all"] +BackendType: T.TypeAlias = T.Literal['nvidia', 'apple_silicon', 'cpu', 'rocm', "all"] -_INSTALL_FAILED = False -# Packages that are explicitly required for setup.py -_INSTALLER_REQUIREMENTS: list[tuple[str, str]] = [("pexpect>=4.8.0", "!Windows"), - ("pywinpty==2.0.2", "Windows")] # Conda packages that are required for a specific backend -# TODO zlib-wapi is required on some Windows installs where cuDNN complains: -# Could not locate zlibwapi.dll. Please make sure it is in your library path! -# This only seems to occur on Anaconda cuDNN not conda-forge -_BACKEND_SPECIFIC_CONDA: dict[backend_type, list[str]] = { - "nvidia": ["cudatoolkit", "cudnn", "zlib-wapi"], - "apple_silicon": ["libblas"]} -# Packages that should only be installed through pip -_FORCE_PIP: dict[backend_type, list[str]] = { - "nvidia": ["tensorflow"], - "all": [ - "tensorflow-cpu", # conda-forge leads to flatbuffer errors because of mixed sources - "imageio-ffmpeg"]} # 17/11/23 Conda forge uses incorrect ffmpeg, so fallback to pip -# Revisions of tensorflow GPU and cuda/cudnn requirements. These relate specifically to the -# Tensorflow builds available from pypi -_TENSORFLOW_REQUIREMENTS = {">=2.10.0,<2.11.0": [">=11.2,<11.3", ">=8.1,<8.2"]} -# ROCm min/max version requirements for Tensorflow -_TENSORFLOW_ROCM_REQUIREMENTS = {">=2.10.0,<2.11.0": ((5, 2, 0), (5, 4, 0))} -# TODO tensorflow-metal versioning - -# Mapping of Python packages to their conda names if different from pip or in non-default channel -_CONDA_MAPPING: dict[str, tuple[str, str]] = { - "cudatoolkit": ("cudatoolkit", "conda-forge"), - "cudnn": ("cudnn", "conda-forge"), - "fastcluster": ("fastcluster", "conda-forge"), - "ffmpy": ("ffmpy", "conda-forge"), - # "imageio-ffmpeg": ("imageio-ffmpeg", "conda-forge"), - "nvidia-ml-py": ("nvidia-ml-py", "conda-forge"), - "tensorflow-deps": ("tensorflow-deps", "apple"), - "libblas": ("libblas", "conda-forge"), - "zlib-wapi": ("zlib-wapi", "conda-forge"), - "xorg-libxft": ("xorg-libxft", "conda-forge")} +_CONDA_BACKEND_REQUIRED: dict[BackendType, list[str]] = { + "all": ["tk", "git"]} + +# Conda packages that are required for a specific OS +_CONDA_OS_REQUIRED: dict[T.Literal["darwin", "linux", "windows"], list[str]] = { + "linux": ["xorg-libxft"]} # required to fix TK fonts on Linux + +# Mapping of Conda packages to channel if in not conda-forge +_CONDA_MAPPING: dict[str, str] = {} # Force output to utf-8 -sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type:ignore[attr-defined] +sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type:ignore[union-attr] + + +class _InstallState: # pylint:disable=too-few-public-methods + """ Marker to track if a step has failed installing """ + failed = False + messages: list[str] = [] class Environment(): @@ -68,167 +53,160 @@ class Environment(): Parameters ---------- - updater: bool, Optional + updater : bool, Optional ``True`` if the script is being called by Faceswap's internal updater. ``False`` if full setup is running. Default: ``False`` """ - - _backends = (("nvidia", "apple_silicon", "directml", "rocm", "cpu")) + _backends = (("nvidia", "apple_silicon", "rocm", "cpu")) def __init__(self, updater: bool = False) -> None: self.updater = updater - # Flag that setup is being run by installer so steps can be skipped - self.is_installer: bool = False - self.backend: backend_type | None = None + self.system = System() + logger.debug("Running on: %s", self.system) + if not updater: + self.system.validate() + self.is_installer: bool = False # Flag setup is being run by installer to skip steps + self.include_dev_tools: bool = False + self.backend: T.Literal["nvidia", "apple_silicon", "cpu", "rocm"] | None = None self.enable_docker: bool = False self.cuda_cudnn = ["", ""] + self.requirement_version = "" self.rocm_version: tuple[int, ...] = (0, 0, 0) - self._process_arguments() - self._check_permission() - self._check_system() - self._check_python() self._output_runtime_info() self._check_pip() - self._upgrade_pip() - self._set_env_vars() @property - def encoding(self) -> str: - """ Get system encoding """ - return locale.getpreferredencoding() + def cuda_version(self) -> str: + """ str : The detected globally installed Cuda Version """ + return self.cuda_cudnn[0] @property - def os_version(self) -> tuple[str, str]: - """ Get OS Version """ - return platform.system(), platform.release() + def cudnn_version(self) -> str: + """ str : The detected globally installed cuDNN Version """ + return self.cuda_cudnn[1] - @property - def py_version(self) -> tuple[str, str]: - """ Get Python Version """ - return platform.python_version(), platform.architecture()[0] + def set_backend(self, backend: T.Literal["nvidia", "apple_silicon", "cpu", "rocm"]) -> None: + """ Set the backend to install for - @property - def is_conda(self) -> bool: - """ Check whether using Conda """ - return ("conda" in sys.version.lower() or - os.path.exists(os.path.join(sys.prefix, 'conda-meta'))) + Parameters + ---------- + backend : Literal["nvidia", "apple_silicon", "cpu", "rocm"] + The backend to setup faceswap for + """ + logger.debug("Setting backend to '%s'", backend) + self.backend = backend - @property - def is_admin(self) -> bool: - """ Check whether user is admin """ - try: - retval = os.getuid() == 0 # type: ignore - except AttributeError: - retval = ctypes.windll.shell32.IsUserAnAdmin() != 0 # type: ignore - return retval + def set_requirements(self, requirements: str) -> None: + """ Validate that the requirements are compatible with the running Python version and + set the requirements file version to install use - @property - def cuda_version(self) -> str: - """ str: The detected globally installed Cuda Version """ - return self.cuda_cudnn[0] + Parameters + ---------- + backend : str + The requirements file version to use for install + """ + if requirements in PYTHON_VERSIONS: + self.system.validate_python(max_version=PYTHON_VERSIONS[requirements]) + logger.debug("Setting requirements to '%s'", requirements) + self.requirement_version = requirements - @property - def cudnn_version(self) -> str: - """ str: The detected globally installed cuDNN Version """ - return self.cuda_cudnn[1] + def _parse_backend_from_cli(self, arg: str) -> None: + """ Parse a command line argument and populate :attr:`backend` if valid - @property - def is_virtualenv(self) -> bool: - """ Check whether this is a virtual environment """ - if not self.is_conda: - retval = (hasattr(sys, "real_prefix") or - (hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix)) - else: - prefix = os.path.dirname(sys.prefix) - retval = os.path.basename(prefix) == "envs" - return retval + Parameters + ---------- + arg : str + The command line argument to parse + """ + arg = arg.lower() + if not any(arg.startswith(b) for b in self._backends): + return + self.set_backend(next(b for b in self._backends if arg.startswith(b))) # type:ignore[misc] + if arg == "cpu": + self.set_requirements("cpu") + return + # Get Cuda/ROCm requirements file + assert self.backend is not None + req_files = sorted([os.path.splitext(f)[0].replace("requirements_", "") + for f in os.listdir(os.path.join(PROJECT_ROOT, "requirements")) + if os.path.splitext(f)[-1] == ".txt" + and f.startswith("requirements_") + and self.backend in f]) + if arg == self.backend: # Default to latest + logger.debug("No version specified. Defaulting to latest requirements") + self.set_requirements(req_files[-1]) + return + lookup = [r.replace("_", "") for r in req_files] + if arg not in lookup: + logger.debug("Defaulting to latest requirements for unknown lookup '%s'", arg) + self.set_requirements(req_files[-1]) + return + self.set_requirements(req_files[lookup.index(arg)]) def _process_arguments(self) -> None: """ Process any cli arguments and dummy in cli arguments if calling from updater. """ - args = [arg for arg in sys.argv] # pylint:disable=unnecessary-comprehension + args = sys.argv[:] if self.updater: - from lib.utils import get_backend # pylint:disable=import-outside-toplevel + get_backend = T.cast("lib_utils", # type:ignore[attr-defined,valid-type] + import_module("lib.utils")).get_backend args.append(f"--{get_backend()}") - logger.debug(args) + if self.system.is_macos and self.system.machine == "arm64": + self.set_backend("apple_silicon") + self.set_requirements("apple-silicon") for arg in args: if arg == "--installer": self.is_installer = True - if not self.backend and (arg.startswith("--") and - arg.replace("--", "") in self._backends): - self.backend = arg.replace("--", "").lower() # type:ignore - - def _check_permission(self) -> None: - """ Check for Admin permissions """ - if self.updater: - return - if self.is_admin: - logger.info("Running as Root/Admin") - else: - logger.info("Running without root/admin privileges") - - def _check_system(self) -> None: - """ Check the system """ - if not self.updater: - logger.info("The tool provides tips for installation and installs required python " - "packages") - logger.info("Setup in %s %s", self.os_version[0], self.os_version[1]) - if not self.updater and not self.os_version[0] in ["Windows", "Linux", "Darwin"]: - logger.error("Your system %s is not supported!", self.os_version[0]) - sys.exit(1) - if self.os_version[0].lower() == "darwin" and platform.machine() == "arm64": - self.backend = "apple_silicon" - - if not self.updater and not self.is_conda: - logger.error("Setting up Faceswap for Apple Silicon outside of a Conda " - "environment is unsupported") - sys.exit(1) - - def _check_python(self) -> None: - """ Check python and virtual environment status """ - logger.info("Installed Python: %s %s", self.py_version[0], self.py_version[1]) - - if self.updater: - return - - if not ((3, 10) <= sys.version_info < (3, 11) and self.py_version[1] == "64bit"): - logger.error("Please run this script with Python version 3.10 64bit and try " - "again.") - sys.exit(1) + continue + if arg == "--dev": + self.include_dev_tools = True + continue + if not self.backend and arg.startswith("--"): + self._parse_backend_from_cli(arg[2:]) def _output_runtime_info(self) -> None: """ Output run time info """ - if self.is_conda: + logger.info("Setup in %s %s", self.system.system.title(), self.system.release) + logger.info("Running as %s", "Root/Admin" if self.system.is_admin else "User") + if self.system.is_conda: logger.info("Running in Conda") - if self.is_virtualenv: + if self.system.is_virtual_env: logger.info("Running in a Virtual Environment") - logger.info("Encoding: %s", self.encoding) + logger.info("Encoding: %s", self.system.encoding) def _check_pip(self) -> None: """ Check installed pip version """ - if self.updater: - return try: - import pip # noqa pylint:disable=unused-import,import-outside-toplevel - except ImportError: + _pip = T.cast("pip", import_module("pip")) # type:ignore[valid-type] + except ModuleNotFoundError: logger.error("Import pip failed. Please Install python3-pip and try again") sys.exit(1) + logger.info("Pip version: %s", _pip.__version__) # type:ignore[attr-defined] - def _upgrade_pip(self) -> None: - """ Upgrade pip to latest version """ - if not self.is_conda: - # Don't do this with Conda, as we must use Conda version of pip - logger.info("Upgrading pip...") - pipexe = [sys.executable, "-m", "pip"] - pipexe.extend(["install", "--no-cache-dir", "-qq", "--upgrade"]) - if not self.is_admin and not self.is_virtualenv: - pipexe.append("--user") - pipexe.append("pip") - run(pipexe, check=True) - import pip # pylint:disable=import-outside-toplevel - pip_version = pip.__version__ - logger.info("Installed pip: %s", pip_version) + def _configure_keras(self) -> None: + """ Set up the keras.json file to use Torch as the backend """ + if "KERAS_HOME" in os.environ: + keras_dir = os.environ["KERAS_HOME"] + else: + keras_base_dir = os.path.expanduser("~") + if not os.access(keras_base_dir, os.W_OK): + keras_base_dir = "/tmp" + keras_dir = os.path.join(keras_base_dir, ".keras") + keras_dir = os.path.expanduser(keras_dir) + os.makedirs(keras_dir, exist_ok=True) + conf_file = os.path.join(keras_dir, "keras.json") + config = {} + if os.path.exists(conf_file): + try: + with open(conf_file, "r", encoding="utf-8") as c_file: + config = json.load(c_file) + except ValueError: + pass + config["backend"] = "torch" + with open(conf_file, "w", encoding="utf-8") as c_file: + c_file.write(json.dumps(config, indent=4)) + logger.info("Keras config written to: %s", conf_file) def set_config(self) -> None: """ Set the backend in the faceswap config file """ @@ -238,379 +216,156 @@ def set_config(self) -> None: with open(config_file, "w", encoding="utf8") as cnf: json.dump(config, cnf) logger.info("Faceswap config written to: %s", config_file) + self._configure_keras() - def _set_env_vars(self) -> None: - """ There are some foibles under Conda which need to be worked around in different - situations. - - Linux: - Update the LD_LIBRARY_PATH environment variable when activating a conda environment - and revert it when deactivating. - - Notes - ----- - From Tensorflow 2.7, installing Cuda Toolkit from conda-forge and tensorflow from pip - causes tensorflow to not be able to locate shared libs and hence not use the GPU. - We update the environment variable for all instances using Conda as it shouldn't hurt - anything and may help avoid conflicts with globally installed Cuda - """ - if not self.is_conda: - return - - linux_update = self.os_version[0].lower() == "linux" and self.backend == "nvidia" - if not linux_update: - return - - conda_prefix = os.environ["CONDA_PREFIX"] - activate_folder = os.path.join(conda_prefix, "etc", "conda", "activate.d") - deactivate_folder = os.path.join(conda_prefix, "etc", "conda", "deactivate.d") - os.makedirs(activate_folder, exist_ok=True) - os.makedirs(deactivate_folder, exist_ok=True) - - activate_script = os.path.join(conda_prefix, activate_folder, "env_vars.sh") - deactivate_script = os.path.join(conda_prefix, deactivate_folder, "env_vars.sh") - - if os.path.isfile(activate_script): - # Only create file if it does not already exist. There may be instances where people - # have created their own scripts, but these should be few and far between and those - # people should already know what they are doing. - return - - conda_libs = os.path.join(conda_prefix, "lib") - activate = ["#!/bin/sh\n\n", - "export OLD_LD_LIBRARY_PATH=${LD_LIBRARY_PATH}\n", - f"export LD_LIBRARY_PATH='{conda_libs}':${{LD_LIBRARY_PATH}}\n"] - deactivate = ["#!/bin/sh\n\n", - "export LD_LIBRARY_PATH=${OLD_LD_LIBRARY_PATH}\n", - "unset OLD_LD_LIBRARY_PATH\n"] - logger.info("Cuda search path set to '%s'", conda_libs) - - with open(activate_script, "w", encoding="utf8") as afile: - afile.writelines(activate) - with open(deactivate_script, "w", encoding="utf8") as afile: - afile.writelines(deactivate) - - -class Packages(): +class RequiredPackages(): """ Holds information about installed and required packages. Handles updating dependencies based on running platform/backend Parameters ---------- - environment: :class:`Environment` + environment : :class:`Environment` Environment class holding information about the running system """ def __init__(self, environment: Environment) -> None: self._env = environment - - # Default TK has bad fonts under Linux. There is a better build in Conda-Forge, so set - # channel accordingly - tk_channel = "conda-forge" if self._env.os_version[0].lower() == "linux" else "defaults" - self._conda_required_packages: list[tuple[list[str] | str, str]] = [("tk", tk_channel), - ("git", "defaults")] - self._update_backend_specific_conda() - self._installed_packages = self._get_installed_packages() - self._conda_installed_packages = self._get_installed_conda_packages() - self._required_packages: list[tuple[str, list[tuple[str, str]]]] = [] - self._missing_packages: list[tuple[str, list[tuple[str, str]]]] = [] - self._conda_missing_packages: list[tuple[list[str] | str, str]] = [] - - @property - def prerequisites(self) -> list[tuple[str, list[tuple[str, str]]]]: - """ list: Any required packages that the installer needs prior to installing the faceswap - environment on the specific platform that are not already installed """ - all_installed = self._all_installed_packages - candidates = self._format_requirements( - [pkg for pkg, plat in _INSTALLER_REQUIREMENTS - if self._env.os_version[0] == plat or (plat[0] == "!" and - self._env.os_version[0] != plat[1:])]) - retval = [(pkg, spec) for pkg, spec in candidates - if pkg not in all_installed or ( - pkg in all_installed and - not self._validate_spec(spec, all_installed.get(pkg, "")) - )] - return retval + self._packages = Packages() + self._requirements = Requirements(include_dev=self._env.include_dev_tools) + self._check_packaging() + self.conda = self._get_missing_conda() + self.python = self._get_missing_python( + self._requirements.requirements[self._env.requirement_version]) + self.pip_arguments = [ + x.strip() + for p in self._requirements.global_options[self._env.requirement_version] + for x in p.split()] + """ list[str] : Any additional pip arguments that are required for installing from pip for + the given backend """ @property def packages_need_install(self) -> bool: - """bool: ``True`` if there are packages available that need to be installed """ - return bool(self._missing_packages or self._conda_missing_packages) - - @property - def to_install(self) -> list[tuple[str, list[tuple[str, str]]]]: - """ list: The required packages that need to be installed """ - return self._missing_packages - - @property - def to_install_conda(self) -> list[tuple[list[str] | str, str]]: - """ list: The required conda packages that need to be installed """ - return self._conda_missing_packages - - @property - def _all_installed_packages(self) -> dict[str, str]: - """ dict[str, str]: The package names and version string for all installed packages across - pip and conda """ - return {**self._installed_packages, **self._conda_installed_packages} - - def _update_backend_specific_conda(self) -> None: - """ Add backend specific packages to Conda required packages """ - assert self._env.backend is not None - to_add = _BACKEND_SPECIFIC_CONDA.get(self._env.backend) - if not to_add: - logger.debug("No backend packages to add for '%s'. All optional packages: %s", - self._env.backend, _BACKEND_SPECIFIC_CONDA) - return - - combined_cuda = [] - for pkg in to_add: - pkg, channel = _CONDA_MAPPING.get(pkg, (pkg, "")) - if pkg == "zlib-wapi" and self._env.os_version[0].lower() != "windows": - # TODO move this front and center - continue - if pkg in ("cudatoolkit", "cudnn"): # TODO Handle multiple cuda/cudnn requirements - idx = 0 if pkg == "cudatoolkit" else 1 - pkg = f"{pkg}{list(_TENSORFLOW_REQUIREMENTS.values())[0][idx]}" - - combined_cuda.append(pkg) - continue - - self._conda_required_packages.append((pkg, channel)) - logger.info("Adding conda required package '%s' for backend '%s')", - pkg, self._env.backend) - - if combined_cuda: - self._conda_required_packages.append((combined_cuda, channel)) - logger.info("Adding conda required package '%s' for backend '%s')", - combined_cuda, self._env.backend) - - @classmethod - def _format_requirements(cls, packages: list[str] - ) -> list[tuple[str, list[tuple[str, str]]]]: - """ Parse a list of requirements.txt formatted package strings to a list of pkgresource - formatted requirements """ - return [(package.unsafe_name, package.specs) - for package in parse_requirements(packages) - if package.marker is None or package.marker.evaluate()] - - @classmethod - def _validate_spec(cls, - required: list[tuple[str, str]], - existing: str) -> bool: - """ Validate whether the required specification for a package is met by the installed - version. - - required: list[tuple[str, str]] - The required package version spec to check - existing: str - The version of the installed package - - Returns - ------- - bool - ``True`` if the required specification is met by the existing specification - """ - ops = {"==": operator.eq, ">=": operator.ge, "<=": operator.le, - ">": operator.gt, "<": operator.lt} - if not required: - return True + """bool : ``True`` if there are packages available that need to be installed """ + return bool(self.conda or self.python) + + def _check_packaging(self) -> None: + """ Install packaging if it is not available """ + if self._requirements.packaging_available: + return + cmd = [sys.executable, "-u", "-m", "pip", "install", "--no-cache-dir"] + if self._env.system.is_admin and not self._env.system.is_virtual_env: + cmd.append("--user") + cmd.append("packaging") + logger.info("Installing required package...") + installer = Installer(self._env, ["Packaging"], cmd, False, False) + if installer() != 0: + logger.error("Unable to install package: %s. Process aborted", "packaging") + sys.exit(1) - return all(ops[spec[0]]([int(s) for s in existing.split(".")], - [int(s) for s in spec[1].split(".")]) - for spec in required) + def _get_missing_python(self, requirements: list[Requirement] + ) -> list[dict[T.Literal["name", "package"], str]]: + """ Check for missing Python dependencies - def _get_installed_packages(self) -> dict[str, str]: - """ Get currently installed packages and add to :attr:`_installed_packages` + Parameters + ---------- + requirements : list[:class:`packaging.requirements.Requirement]` + The packages that are required to be installed Returns ------- - dict[str, str] - The installed package name and version string + list[dict[Literal["name", "package"], str]] + List of missing Python packages to install """ - installed_packages = {} - with Popen(f"\"{sys.executable}\" -m pip freeze --local", shell=True, stdout=PIPE) as chk: - installed = chk.communicate()[0].decode(self._env.encoding, - errors="ignore").splitlines() - - for pkg in installed: - if "==" not in pkg: + retval: list[dict[T.Literal["name", "package"], str]] = [] + for req in requirements: + package: dict[T.Literal["name", "package"], str] = { + "name": req.name.title(), + "package": f"{req.name}{req.specifier}"} + installed_version = self._packages.installed_python.get(req.name, "") + if not installed_version: + logger.debug("Adding new Python package '%s'", package["package"]) + retval.append(package) + continue + if not req.specifier.contains(installed_version): + logger.debug("Adding Python package '%s' for specifier change from '%s' to '%s'", + package["package"], installed_version, str(req.specifier)) + retval.append(package) continue - item = pkg.split("==") - installed_packages[item[0]] = item[1] - logger.debug(installed_packages) - return installed_packages + logger.debug("Skipping installed Python package '%s'", package["package"]) + logger.debug("Selected missing Python packages: %s", retval) + return retval - def _get_installed_conda_packages(self) -> dict[str, str]: - """ Get currently installed conda packages + def _get_required_conda(self) -> list[dict[T.Literal["package", "channel"], str]]: + """ Add backend specific packages to Conda required packages Returns ------- - dict[str, str] - The installed package name and version string + list[tuple[Literal["package", "channel"], str]] + List of required Conda package names and the channel to install from """ - if not self._env.is_conda: - return {} - chk = os.popen("conda list").read() - installed = [re.sub(" +", " ", line.strip()) - for line in chk.splitlines() if not line.startswith("#")] - retval = {} - for pkg in installed: - item = pkg.split(" ") - retval[item[0]] = item[1] - logger.debug(retval) + retval: list[dict[T.Literal["package", "channel"], str]] = [] + assert self._env.backend is not None + to_add = (_CONDA_BACKEND_REQUIRED.get(self._env.backend, []) + + _CONDA_BACKEND_REQUIRED.get("all", []) + + _CONDA_OS_REQUIRED.get(self._env.system.system, [])) + if not to_add: + logger.debug("No packages to add for '%s'('%s'). All backend packages: %s. All OS " + "packages: %s", + self._env.backend, self._env.system, + _CONDA_BACKEND_REQUIRED, _CONDA_OS_REQUIRED) + return retval + for pkg in to_add: + channel = _CONDA_MAPPING.get(pkg, "conda-forge") + retval.append({"package": pkg, "channel": channel}) + logger.debug("Adding conda required package '%s' for system '%s'('%s'))", + pkg, self._env.backend, self._env.system.system) return retval - def get_required_packages(self) -> None: - """ Load the requirements from the backend specific requirements list """ - req_files = ["_requirements_base.txt", f"requirements_{self._env.backend}.txt"] - pypath = os.path.dirname(os.path.realpath(__file__)) - requirements = [] - for req_file in req_files: - requirements_file = os.path.join(pypath, "requirements", req_file) - with open(requirements_file, encoding="utf8") as req: - for package in req.readlines(): - package = package.strip() - if package and (not package.startswith(("#", "-r"))): - requirements.append(package) - - self._required_packages = self._format_requirements(requirements) - logger.debug(self._required_packages) - - def _update_tf_dep_nvidia(self) -> None: - """ Update the Tensorflow dependency for global Cuda installs """ - if self._env.is_conda: # Conda handles Cuda and cuDNN so nothing to do here - return - tf_ver = None - cuda_inst = self._env.cuda_version - cudnn_inst = self._env.cudnn_version - if len(cudnn_inst) == 1: # Sometimes only major version is reported - cudnn_inst = f"{cudnn_inst}.0" - for key, val in _TENSORFLOW_REQUIREMENTS.items(): - cuda_req = next(parse_requirements(f"cuda{val[0]}")).specs - cudnn_req = next(parse_requirements(f"cudnn{val[1]}")).specs - if (self._validate_spec(cuda_req, cuda_inst) - and self._validate_spec(cudnn_req, cudnn_inst)): - tf_ver = key - break - - if tf_ver: - # Remove the version of tensorflow in requirements file and add the correct version - # that corresponds to the installed Cuda/cuDNN versions - self._required_packages = [pkg for pkg in self._required_packages - if pkg[0] != "tensorflow"] - tf_ver = f"tensorflow{tf_ver}" - self._required_packages.append(("tensorflow", next(parse_requirements(tf_ver)).specs)) - return - - logger.warning( - "The minimum Tensorflow requirement is 2.10 \n" - "Tensorflow currently has no official prebuild for your CUDA, cuDNN combination.\n" - "Either install a combination that Tensorflow supports or build and install your own " - "tensorflow.\r\n" - "CUDA Version: %s\r\n" - "cuDNN Version: %s\r\n" - "Help:\n" - "Building Tensorflow: https://www.tensorflow.org/install/install_sources\r\n" - "Tensorflow supported versions: " - "https://www.tensorflow.org/install/source#tested_build_configurations", - self._env.cuda_version, self._env.cudnn_version) - - custom_tf = input("Location of custom tensorflow wheel (leave blank to manually " - "install): ") - if not custom_tf: - return - - custom_tf = os.path.realpath(os.path.expanduser(custom_tf)) - global _INSTALL_FAILED # pylint:disable=global-statement - if not os.path.isfile(custom_tf): - logger.error("%s not found", custom_tf) - _INSTALL_FAILED = True - elif os.path.splitext(custom_tf)[1] != ".whl": - logger.error("%s is not a valid pip wheel", custom_tf) - _INSTALL_FAILED = True - elif custom_tf: - self._required_packages.append((custom_tf, [(custom_tf, "")])) - - def _update_tf_dep_rocm(self) -> None: - """ Update the Tensorflow dependency for global ROCm installs """ - if not any(self._env.rocm_version): # ROCm was not found and the install will be aborted - return - - global _INSTALL_FAILED # pylint:disable=global-statement - candidates = [key for key, val in _TENSORFLOW_ROCM_REQUIREMENTS.items() - if val[0] <= self._env.rocm_version <= val[1]] + def _get_missing_conda(self) -> dict[str, list[dict[T.Literal["name", "package"], str]]]: + """ Check for conda missing dependencies - if not candidates: - _INSTALL_FAILED = True - logger.error("No matching Tensorflow candidates found for ROCm %s in %s", - ".".join(str(v) for v in self._env.rocm_version), - _TENSORFLOW_ROCM_REQUIREMENTS) - return - - # set tf_ver to the minimum and maximum compatible range - tf_ver = f"{candidates[0].split(',')[0]},{candidates[-1].split(',')[-1]}" - # Remove the version of tensorflow-rocm in requirements file and add the correct version - # that corresponds to the installed ROCm version - self._required_packages = [pkg for pkg in self._required_packages - if not pkg[0].startswith("tensorflow-rocm")] - tf_ver = f"tensorflow-rocm{tf_ver}" - self._required_packages.append(("tensorflow-rocm", - next(parse_requirements(tf_ver)).specs)) - - def update_tf_dep(self) -> None: - """ Update Tensorflow Dependency. - - Selects a compatible version of Tensorflow for a globally installed GPU library + Returns + ------- + dict[str, list[dict[Literal["name", "package"], str]]] + The Conda packages to install grouped by channel """ - if self._env.backend == "nvidia": - self._update_tf_dep_nvidia() - if self._env.backend == "rocm": - self._update_tf_dep_rocm() - - def _check_conda_missing_dependencies(self) -> None: - """ Check for conda missing dependencies and add to :attr:`_conda_missing_packages` """ - if not self._env.is_conda: - return - for pkg in self._conda_required_packages: - reqs = next(parse_requirements(pkg[0])) # TODO Handle '=' vs '==' for conda - key = reqs.unsafe_name - specs = reqs.specs - - if pkg[0] == "tk" and self._env.os_version[0].lower() == "linux": - # Default tk has bad fonts under Linux. We pull in an explicit build from - # Conda-Forge that is compiled with better fonts. + retval: dict[str, list[dict[T.Literal["name", "package"], str]]] = {} + if not self._env.system.is_conda: + return retval + required = self._get_required_conda() + requirements = self._requirements.parse_requirements( + [p["package"] for p in required]) + channels = [p["channel"] for p in required] + installed = {k: v for k, v in self._packages.installed_conda.items() if v[1] != "pypi"} + for req, channel in zip(requirements, channels): + spec_str = str(req.specifier).replace("==", "=") if req.specifier else "" + package: dict[T.Literal["name", "package"], str] = {"name": req.name.title(), + "package": f"{req.name}{spec_str}"} + exists = installed.get(req.name) + if req.name == "tk" and self._env.system.is_linux: + # Default TK has bad fonts under Linux. # Ref: https://github.com/ContinuumIO/anaconda-issues/issues/6833 - newpkg = (f"{pkg[0]}=*=xft_*", pkg[1]) # Swap out package for explicit XFT version - self._conda_missing_packages.append(newpkg) - # We also need to bring in xorg-libxft incase libXft does not exist on host system - self._conda_missing_packages.append(_CONDA_MAPPING["xorg-libxft"]) + # This versioning will fail in parse_requirements, so we need to do it here + package["package"] = f"{req.name}=*=xft_*" # Swap out for explicit XFT version + if exists is not None and not exists[1].startswith("xft"): # Replace noxft version + exists = None + if not exists: + logger.debug("Adding new Conda package '%s'", package["package"]) + retval.setdefault(channel, []).append(package) continue - - if key not in self._conda_installed_packages: - self._conda_missing_packages.append(pkg) + if exists[-1] != channel: + logger.debug("Adding Conda package '%s' for channel change from '%s' to '%s'", + package["package"], exists[-1], channel) + retval.setdefault(channel, []).append(package) continue - - if not self._validate_spec(specs, self._conda_installed_packages[key]): - self._conda_missing_packages.append(pkg) - logger.debug(self._conda_missing_packages) - - def check_missing_dependencies(self) -> None: - """ Check for missing dependencies and add to :attr:`_missing_packages` """ - for key, specs in self._required_packages: - - if self._env.is_conda: # Get Conda alias for Key - key = _CONDA_MAPPING.get(key, (key, None))[0] - - if key not in self._all_installed_packages: - # Add not installed packages to missing packages list - self._missing_packages.append((key, specs)) + if not req.specifier.contains(exists[0]): + logger.debug("Adding Conda package '%s' for specifier change from '%s' to '%s'", + package["package"], exists[0], spec_str) + retval.setdefault(channel, []).append(package) continue - - if not self._validate_spec(specs, self._all_installed_packages.get(key, "")): - self._missing_packages.append((key, specs)) - - logger.debug(self._missing_packages) - self._check_conda_missing_dependencies() + logger.debug("Skipping installed Conda package '%s'", package["package"]) + logger.debug("Selected missing Conda packages: %s", retval) + return retval class Checks(): # pylint:disable=too-few-public-methods @@ -618,11 +373,11 @@ class Checks(): # pylint:disable=too-few-public-methods Parameters ---------- - environment: :class:`Environment` + environment : :class:`Environment` Environment class holding information about the running system """ def __init__(self, environment: Environment) -> None: - self._env: Environment = environment + self._env: Environment = environment self._tips: Tips = Tips() # Checks not required for installer if self._env.is_installer: @@ -633,49 +388,42 @@ def __init__(self, environment: Environment) -> None: self._user_input() self._check_cuda() self._check_rocm() - if self._env.os_version[0] == "Windows": + if self._env.system.is_windows: self._tips.pip() def _rocm_ask_enable(self) -> None: """ Set backend to 'rocm' if OS is Linux and ROCm support required """ - if self._env.os_version[0] != "Linux": + if not self._env.system.is_linux: return logger.info("ROCm support:\r\nIf you are using an AMD GPU, then select 'yes'." "\r\nCPU/non-AMD GPU users should answer 'no'.\r\n") - i = input("Enable ROCm Support? [y/N] ") - if i in ("Y", "y"): - logger.info("ROCm Support Enabled") - self._env.backend = "rocm" - - def _directml_ask_enable(self) -> None: - """ Set backend to 'directml' if OS is Windows and DirectML support required """ - if self._env.os_version[0] != "Windows": - return - logger.info("DirectML support:\r\nIf you are using an AMD or Intel GPU, then select 'yes'." - "\r\nNvidia users should answer 'no'.") - i = input("Enable DirectML Support? [y/N] ") - if i in ("Y", "y"): - logger.info("DirectML Support Enabled") - self._env.backend = "directml" - - def _user_input(self) -> None: - """ Get user input for AMD/DirectML/ROCm/Cuda/Docker """ - self._directml_ask_enable() - self._rocm_ask_enable() - if not self._env.backend: - self._docker_ask_enable() - self._cuda_ask_enable() - if self._env.os_version[0] != "Linux" and (self._env.enable_docker - and self._env.backend == "nvidia"): - self._docker_confirm() - if self._env.enable_docker: - self._docker_tips() - self._env.set_config() - sys.exit(0) + i = input("Enable ROCm Support? [y/N] ").strip() + if i not in ("", "Y", "y", "n", "N"): + logger.warning("Invalid selection '%s'", i) + self._rocm_ask_enable() + return + if i not in ("Y", "y"): + return + logger.info("ROCm Support Enabled") + self._env.set_backend("rocm") + versions = ["6.0", "6.1", "6.2", "6.3", "6.4"] + i = input(f"Which ROCm version? [{', '.join(versions)}] ").strip() + i = versions[-1] if not i else i + print(i, i in versions, versions) + if i not in versions: + logger.warning("Invalid selection '%s'", i) + self._rocm_ask_enable() + return + logger.info("ROCm Version %s Selected", i) + self._env.set_requirements(f"rocm_{i.replace('.', '')}") def _docker_ask_enable(self) -> None: """ Enable or disable Docker """ - i = input("Enable Docker? [y/N] ") + i = input("Enable Docker? [y/N] ").strip() + if i not in ("", "Y", "y", "n", "N"): + logger.warning("Invalid selection '%s'", i) + self._docker_ask_enable() + return if i in ("Y", "y"): logger.info("Docker Enabled") self._env.enable_docker = True @@ -683,6 +431,28 @@ def _docker_ask_enable(self) -> None: logger.info("Docker Disabled") self._env.enable_docker = False + def _cuda_ask_enable(self) -> None: + """ Enable or disable CUDA """ + i = input("Enable CUDA? [Y/n] ").strip() + if i not in ("", "Y", "y", "n", "N"): + logger.warning("Invalid selection '%s'", i) + self._cuda_ask_enable() + return + if i not in ("", "Y", "y"): + return + logger.info("CUDA Enabled") + self._env.set_backend("nvidia") + versions = ["11", "12", "13"] + i = input("Which Cuda version: 11 (GTX7xx-8xx), 12 (GTX9xx-10xx) or 13 (RTX20xx-)? " + f"[{', '.join(versions)}] ").strip() + i = "13" if not i else i + if i not in versions: + logger.warning("Invalid selection '%s'", i) + self._cuda_ask_enable() + return + logger.info("CUDA Version %s Selected", i) + self._env.set_requirements(f"nvidia_{i}") + def _docker_confirm(self) -> None: """ Warn if nvidia-docker on non-Linux system """ logger.warning("Nvidia-Docker is only supported on Linux.\r\n" @@ -690,7 +460,7 @@ def _docker_confirm(self) -> None: self._docker_ask_enable() if self._env.enable_docker: logger.warning("CUDA Disabled") - self._env.backend = "cpu" + self._env.set_backend("cpu") def _docker_tips(self) -> None: """ Provide tips for Docker use """ @@ -699,957 +469,473 @@ def _docker_tips(self) -> None: else: self._tips.docker_cuda() - def _cuda_ask_enable(self) -> None: - """ Enable or disable CUDA """ - i = input("Enable CUDA? [Y/n] ") - if i in ("", "Y", "y"): - logger.info("CUDA Enabled") - self._env.backend = "nvidia" + def _user_input(self) -> None: + """ Get user input for AMD/ROCm/Cuda/Docker """ + if self._env.backend is None: + self._rocm_ask_enable() + if self._env.backend is None: + self._docker_ask_enable() + self._cuda_ask_enable() + if not self._env.system.is_linux and (self._env.enable_docker + and self._env.backend == "nvidia"): + self._docker_confirm() + if self._env.enable_docker: + self._docker_tips() + self._env.set_config() + sys.exit(0) def _check_cuda(self) -> None: """ Check for Cuda and cuDNN Locations. """ if self._env.backend != "nvidia": logger.debug("Skipping Cuda checks as not enabled") return - - if self._env.is_conda: - logger.info("Skipping Cuda/cuDNN checks for Conda install") - return - - if self._env.os_version[0] in ("Linux", "Windows"): - global _INSTALL_FAILED # pylint:disable=global-statement - check = CudaCheck() - if check.cuda_version: - self._env.cuda_cudnn[0] = check.cuda_version - logger.info("CUDA version: %s", self._env.cuda_version) - else: - logger.error("CUDA not found. Install and try again.\n" - "Recommended version: CUDA 10.1 cuDNN 7.6\n" - "CUDA: https://developer.nvidia.com/cuda-downloads\n" - "cuDNN: https://developer.nvidia.com/rdp/cudnn-download") - _INSTALL_FAILED = True - return - - if check.cudnn_version: - self._env.cuda_cudnn[1] = ".".join(check.cudnn_version.split(".")[:2]) - logger.info("cuDNN version: %s", self._env.cudnn_version) - else: - logger.error("cuDNN not found. See " - "https://github.com/deepfakes/faceswap/blob/master/INSTALL.md#" - "cudnn for instructions") - _INSTALL_FAILED = True - return - - # If we get here we're on MacOS - self._tips.macos() - logger.warning("Cannot find CUDA on macOS") - self._env.cuda_cudnn[0] = input("Manually specify CUDA version: ") + if not any((self._env.system.is_linux, self._env.system.is_windows)): + return + cuda = Cuda() + if cuda.versions: + str_vers = ", ".join(".".join(str(x) for x in v) for v in cuda.versions) + msg = (f"Globally installed Cuda version{'s' if len(cuda.versions) > 1 else ''} " + f"{str_vers} found. PyTorch uses it's own version of Cuda, so if you have " + "GPU issues, you should remove these global installs") + _InstallState.messages.append(msg) + self._env.cuda_cudnn[0] = str_vers + logger.debug("CUDA version: %s", self._env.cuda_version) + if cuda.cudnn_versions: + str_vers = ", ".join(".".join(str(x) for x in v) + for v in cuda.cudnn_versions.values()) + msg = ("Globally installed CuDNN version" + f"{'s' if len(cuda.cudnn_versions) > 1 else ''} {str_vers} found. PyTorch uses " + "its own version of Cuda, so if you have GPU issues, you should remove these " + "global installs") + _InstallState.messages.append(msg) + self._env.cuda_cudnn[1] = str_vers + logger.debug("cuDNN version: %s", self._env.cudnn_version) def _check_rocm(self) -> None: """ Check for ROCm version """ - if self._env.backend != "rocm" or self._env.os_version[0] != "Linux": - logger.info("Skipping ROCm checks as not enabled") + if self._env.backend != "rocm" or not self._env.system.is_linux: + logger.debug("Skipping ROCm checks as not enabled") return + rocm = ROCm() - global _INSTALL_FAILED # pylint:disable=global-statement - check = ROCmCheck() - - str_min = ".".join(str(v) for v in check.version_min) - str_max = ".".join(str(v) for v in check.version_max) - - if check.is_valid: - self._env.rocm_version = check.rocm_version + if rocm.is_valid or rocm.valid_installed: + self._env.rocm_version = max(rocm.valid_versions) logger.info("ROCm version: %s", ".".join(str(v) for v in self._env.rocm_version)) - else: - if check.rocm_version: - msg = f"Incompatible ROCm version: {'.'.join(str(v) for v in check.rocm_version)}" - else: - msg = "ROCm not found" - logger.error("%s.\n" - "A compatible version of ROCm must be installed to proceed.\n" - "ROCm versions between %s and %s are supported.\n" - "ROCm install guide: https://docs.amd.com/bundle/ROCm_Installation_Guide" - "v5.0/page/Overview_of_ROCm_Installation_Methods.html", - msg, - str_min, - str_max) - _INSTALL_FAILED = True - - -def _check_ld_config(lib: str) -> str: - """ Locate a library in ldconfig - - Parameters - ---------- - lib: str The library to locate - - Returns - ------- - str - The library from ldconfig, or empty string if not found - """ - retval = "" - ldconfig = which("ldconfig") - if not ldconfig: - return retval - - retval = next((line.decode("utf-8", errors="replace").strip() - for line in run([ldconfig, "-p"], - capture_output=True, - check=False).stdout.splitlines() - if lib.encode("utf-8") in line), "") - - if retval or (not retval and not os.environ.get("LD_LIBRARY_PATH")): - return retval - - for path in os.environ["LD_LIBRARY_PATH"].split(":"): - if not path or not os.path.exists(path): - continue - - retval = next((fname.strip() for fname in reversed(os.listdir(path)) - if lib in fname), "") - if retval: - break - - return retval - - -class ROCmCheck(): # pylint:disable=too-few-public-methods - """ Find the location of system installed ROCm on Linux """ - def __init__(self) -> None: - self.version_min = min(v[0] for v in _TENSORFLOW_ROCM_REQUIREMENTS.values()) - self.version_max = max(v[1] for v in _TENSORFLOW_ROCM_REQUIREMENTS.values()) - self.rocm_version: tuple[int, ...] = (0, 0, 0) - if platform.system() == "Linux": - self._rocm_check() - - @property - def is_valid(self): - """ bool: `True` if ROCm has been detected and is between the minimum and maximum - compatible versions otherwise ``False`` """ - return self.version_min <= self.rocm_version <= self.version_max - - def _rocm_check(self) -> None: - """ Attempt to locate the installed ROCm version from the dynamic link loader. If not found - with ldconfig then attempt to find it in LD_LIBRARY_PATH. If found, set the - :attr:`rocm_version` to the discovered version - """ - chk = _check_ld_config("librocm-core.so.") - if not chk: - return - - rocm_vers = chk.strip() - version = re.search(r"rocm\-(\d+\.\d+\.\d+)", rocm_vers) - if version is None: - return - try: - self.rocm_version = tuple(int(v) for v in version.groups()[0].split(".")) - except ValueError: - return - - -class CudaCheck(): # pylint:disable=too-few-public-methods - """ Find the location of system installed Cuda and cuDNN on Windows and Linux. """ - - def __init__(self) -> None: - self.cuda_path: str | None = None - self.cuda_version: str | None = None - self.cudnn_version: str | None = None - - self._os: str = platform.system().lower() - self._cuda_keys: list[str] = [key - for key in os.environ - if key.lower().startswith("cuda_path_v")] - self._cudnn_header_files: list[str] = ["cudnn_version.h", "cudnn.h"] - logger.debug("cuda keys: %s, cudnn header files: %s", - self._cuda_keys, self._cudnn_header_files) - if self._os in ("windows", "linux"): - self._cuda_check() - self._cudnn_check() - - def _cuda_check(self) -> None: - """ Obtain the location and version of Cuda and populate :attr:`cuda_version` and - :attr:`cuda_path` - - Initially just calls `nvcc -V` to get the installed version of Cuda currently in use. - If this fails, drills down to more OS specific checking methods. - """ - with Popen("nvcc -V", shell=True, stdout=PIPE, stderr=PIPE) as chk: - stdout, stderr = chk.communicate() - if not stderr: - version = re.search(r".*release (?P\d+\.\d+)", - stdout.decode(locale.getpreferredencoding(), errors="ignore")) - if version is not None: - self.cuda_version = version.groupdict().get("cuda", None) - path = which("nvcc") - if path: - path = path.split("\n")[0] # Split multiple entries and take first found - while True: # Get Cuda root folder - path, split = os.path.split(path) - if split == "bin": - break - self.cuda_path = path + if rocm.is_valid: return - - # Failed to load nvcc, manual check - getattr(self, f"_cuda_check_{self._os}")() - logger.debug("Cuda Version: %s, Cuda Path: %s", self.cuda_version, self.cuda_path) - - def _cuda_check_linux(self) -> None: - """ For Linux check the dynamic link loader for libcudart. If not found with ldconfig then - attempt to find it in LD_LIBRARY_PATH. """ - chk = _check_ld_config("libcudart.so.") - if not chk: # Cuda not found + if rocm.valid_installed: + str_vers = ".".join(str(v) for v in self._env.rocm_version) + _InstallState.messages.append( + f"Valid ROCm version {str_vers} is installed, but is not your default version.\n" + "You may need to change this to enable GPU acceleration") return - cudavers = chk.strip().replace("libcudart.so.", "") - self.cuda_version = cudavers[:cudavers.find(" ")] if " " in cudavers else cudavers - cuda_path = chk[chk.find("=>") + 3:chk.find("targets") - 1] - if os.path.exists(cuda_path): - self.cuda_path = cuda_path - - def _cuda_check_windows(self) -> None: - """ Check Windows CUDA Version and path from Environment Variables""" - if not self._cuda_keys: # Cuda environment variable not found - return - self.cuda_version = self._cuda_keys[0].lower().replace("cuda_path_v", "").replace("_", ".") - self.cuda_path = os.environ[self._cuda_keys[0][0]] - - def _cudnn_check_files(self) -> bool: - """ Check header files for cuDNN version """ - cudnn_checkfiles = getattr(self, f"_get_checkfiles_{self._os}")() - cudnn_checkfile = next((hdr for hdr in cudnn_checkfiles if os.path.isfile(hdr)), None) - logger.debug("cudnn checkfiles: %s", cudnn_checkfile) - if not cudnn_checkfile: - return False - - found = 0 - major = minor = patchlevel = 0 - with open(cudnn_checkfile, "r", encoding="utf8") as ofile: - for line in ofile: - if line.lower().startswith("#define cudnn_major"): - major = line[line.rfind(" ") + 1:].strip() - found += 1 - elif line.lower().startswith("#define cudnn_minor"): - minor = line[line.rfind(" ") + 1:].strip() - found += 1 - elif line.lower().startswith("#define cudnn_patchlevel"): - patchlevel = line[line.rfind(" ") + 1:].strip() - found += 1 - if found == 3: - break - if found != 3: # Full version not determined - return False - - self.cudnn_version = ".".join([str(major), str(minor), str(patchlevel)]) - logger.debug("cudnn version: %s", self.cudnn_version) - return True - - def _cudnn_check(self) -> None: - """ Check Linux or Windows cuDNN Version from cudnn.h and add to :attr:`cudnn_version`. """ - if self._cudnn_check_files(): - return - if self._os == "windows": - return - - chk = _check_ld_config("libcudnn.so.") - if not chk: - return - cudnnvers = chk.strip().replace("libcudnn.so.", "").split()[0] - if not cudnnvers: - return - - self.cudnn_version = cudnnvers - logger.debug("cudnn version: %s", self.cudnn_version) - - def _get_checkfiles_linux(self) -> list[str]: - """ Return the the files to check for cuDNN locations for Linux by querying - the dynamic link loader. - - Returns - ------- - list - List of header file locations to scan for cuDNN versions - """ - chk = _check_ld_config("libcudnn.so.") - chk = chk.strip().replace("libcudnn.so.", "") - if not chk: - return [] - - cudnn_vers = chk[0] - header_files = [f"cudnn_v{cudnn_vers}.h"] + self._cudnn_header_files - - cudnn_path = os.path.realpath(chk[chk.find("=>") + 3:chk.find("libcudnn") - 1]) - cudnn_path = cudnn_path.replace("lib", "include") - cudnn_checkfiles = [os.path.join(cudnn_path, header) for header in header_files] - return cudnn_checkfiles - - def _get_checkfiles_windows(self) -> list[str]: - """ Return the check-file locations for Windows. Just looks inside the include folder of - the discovered :attr:`cuda_path` - - Returns - ------- - list - List of header file locations to scan for cuDNN versions - """ - # TODO A more reliable way of getting the windows location - if not self.cuda_path or not os.path.exists(self.cuda_path): - return [] - scandir = os.path.join(self.cuda_path, "include") - cudnn_checkfiles = [os.path.join(scandir, header) for header in self._cudnn_header_files] - return cudnn_checkfiles + if rocm.versions: + str_vers = ", ".join(".".join(str(x) for x in v) for v in rocm.versions) + msg = f"Incompatible ROCm version{'s' if len(rocm.versions) > 1 else ''}: {str_vers}\n" + else: + msg = "ROCm not found\n" + _InstallState.messages.append(f"{msg}\n") + str_min = ".".join(str(v) for v in rocm.version_min) + str_max = ".".join(str(v) for v in rocm.version_max) + valid = f"{str_min} to {str_max}" if str_min != str_max else str_min + msg += ("The installation can proceed, but you will need to install ROCm version " + f"{valid} to enable GPU acceleration") + _InstallState.messages.append(msg) -class Install(): # pylint:disable=too-few-public-methods - """ Handles installation of Faceswap requirements +class Status(): + """ Simple Status output for intercepting Conda/Pip installs and keeping the terminal clean Parameters ---------- - environment: :class:`Environment` - Environment class holding information about the running system - is_gui: bool, Optional - ``True`` if the caller is the Faceswap GUI. Used to prevent output of progress bars - which get scrambled in the GUI - """ - def __init__(self, environment: Environment, is_gui: bool = False) -> None: - self._env = environment - self._packages = Packages(environment) - self._is_gui = is_gui - - if self._env.os_version[0] == "Windows": - self._installer: type[Installer] = WinPTYInstaller - else: - self._installer = PexpectInstaller - - if not self._env.is_installer and not self._env.updater: - self._ask_continue() - - self._packages.get_required_packages() - self._packages.update_tf_dep() - self._packages.check_missing_dependencies() - - if self._env.updater and not self._packages.packages_need_install: - logger.info("All Dependencies are up to date") - return - - logger.info("Installing Required Python Packages. This may take some time...") - self._install_setup_packages() - self._install_missing_dep() - if self._env.updater: - return - if not _INSTALL_FAILED: - logger.info("All python3 dependencies are met.\r\nYou are good to go.\r\n\r\n" - "Enter: 'python faceswap.py -h' to see the options\r\n" - " 'python faceswap.py gui' to launch the GUI") - else: - logger.error("Some packages failed to install. This may be a temporary error which " - "might be fixed by re-running this script. Otherwise please install " - "these packages manually.") - sys.exit(1) + is_conda : bool + ``True`` if installing packages from Conda. ``False`` if installing from pip + """ + def __init__(self, is_conda: bool): + self._is_conda = is_conda + self._last_line = "" + self._max_width = 79 # Keep short because of NSIS Details window size + self._prefix = "> " + self._conda_tracked: dict[str, dict[T.Literal["size", "done"], float]] = {} + self._re_pip_pkg = re.compile(r"^Downloading\s(?P\w+)\b.*?\s\((?P.+)\)") + self._re_pip_http = re.compile(r"https?://[^\s]*/([^/\s]+)") + self._re_pip_progress = re.compile(r"^Progress\s+(?P\d+).+?(?P\d+)") + self._re_conda = re.compile( + r"(?P^\S+)\s+\|\s+(?P\d+\.?\d*\s\w+).*\|\s+(?P\d+)%") - def _ask_continue(self) -> None: - """ Ask Continue with Install """ - text = "Please ensure your System Dependencies are met" - if self._env.backend == "rocm": - text += ("\r\nROCm users: Please ensure that your AMD GPU is supported by the " - "installed ROCm version before proceeding.") - text += "\r\nContinue? [y/N] " - inp = input(text) - if inp in ("", "N", "n"): - logger.error("Please install system dependencies to continue") - sys.exit(1) + def _clear_line(self) -> None: + """ Clear the last printed line from the console """ + print(" " * self._max_width, end="\r") - @classmethod - def _format_package(cls, package: str, version: list[tuple[str, str]]) -> str: - """ Format a parsed requirement package and version string to a format that can be used by - the installer. + def _print(self, line: str) -> None: + """ Clear the last line and print the new line to the console Parameters ---------- - package: str - The package name - version: list - The parsed requirement version strings - - Returns - ------- - str - The formatted full package and version string + line : str + The line to print """ - retval = f"{package}{','.join(''.join(spec) for spec in version)}" - logger.debug("Formatted package \"%s\" version \"%s\" to \"%s'", package, version, retval) - return retval - - def _install_setup_packages(self) -> None: - """ Install any packages that are required for the setup.py installer to work. This - includes the pexpect package if it is not already installed. - - Subprocess is used as we do not currently have pexpect - """ - for pkg in self._packages.prerequisites: - pkg_str = self._format_package(*pkg) - if self._env.is_conda: - cmd = ["conda", "install", "-y", "-c", "defaults"] - else: - cmd = [sys.executable, "-m", "pip", "install", "--no-cache-dir"] - if self._env.is_admin: - cmd.append("--user") - cmd.append(pkg_str) - - clean_pkg = pkg_str.replace("\"", "") - installer = SubProcInstaller(self._env, clean_pkg, cmd, self._is_gui) - if installer() != 0: - logger.error("Unable to install package: %s. Process aborted", clean_pkg) - sys.exit(1) - - def _install_conda_packages(self) -> None: - """ Install required conda packages """ - logger.info("Installing Required Conda Packages. This may take some time...") - for pkg in self._packages.to_install_conda: - channel = "" if len(pkg) != 2 else pkg[1] - self._from_conda(pkg[0], channel=channel, conda_only=True) - - def _install_python_packages(self) -> None: - """ Install required pip packages """ - conda_only = False - assert self._env.backend is not None - for pkg, version in self._packages.to_install: - if self._env.is_conda: - mapping = _CONDA_MAPPING.get(pkg, (pkg, "")) - channel = "" if mapping[1] is None else mapping[1] - pkg = mapping[0] - pip_only = pkg in _FORCE_PIP.get(self._env.backend, []) or pkg in _FORCE_PIP["all"] - pkg = self._format_package(pkg, version) if version else pkg - if self._env.is_conda and not pip_only: - if self._from_conda(pkg, channel=channel, conda_only=conda_only): - continue - self._from_pip(pkg) - - def _install_missing_dep(self) -> None: - """ Install missing dependencies """ - self._install_conda_packages() # Install conda packages first - self._install_python_packages() - - def _from_conda(self, - package: list[str] | str, - channel: str = "", - conda_only: bool = False) -> bool: - """ Install a conda package + full_line = f"{self._prefix}{line}" + output = full_line + if len(output) > self._max_width: + output = f"{output[:self._max_width - 3]}..." + if len(output) < len(self._last_line): + self._clear_line() + self._last_line = full_line + print(output, end="\r") + + def _parse_size(self, size: str) -> float: + """ Parse the string representation of a package size and return as megabytes Parameters ---------- - package: list[str] | str - The full formatted package(s), with version(s), to be installed - channel: str, optional - The Conda channel to install from. Select empty string for default channel. - Default: ``""`` (empty string) - conda_only: bool, optional - ``True`` if the package is only available in Conda. Default: ``False`` + size : str + The string representation of a package size Returns ------- - bool - ``True`` if the package was succesfully installed otherwise ``False`` - """ - # Packages with special characters need to be enclosed in double quotes - success = True - channel = "defaults" if not channel else channel - condaexe = ["conda", "install", "-y", "-c", channel] - - pkgs = package if isinstance(package, list) else [package] - if pkgs[0].startswith("tk"): - # TODO this is hacky and fragile, but for some reason tk from conda-forge has started - # pulling in the graapy version of Python which breaks opencv install - condaexe.append("--no-deps") - - for i, pkg in enumerate(pkgs): - if any(char in pkg for char in (" ", "<", ">", "*", "|")): - pkgs[i] = f"\"{pkg}\"" - condaexe.extend(pkgs) - - clean_pkg = " ".join([p.replace("\"", "") for p in pkgs]) - installer = self._installer(self._env, clean_pkg, condaexe, self._is_gui) - retcode = installer() - - if retcode != 0 and not conda_only: - logger.info("%s not available in Conda. Installing with pip", package) - elif retcode != 0: - logger.warning("Couldn't install %s with Conda. Please install this package " - "manually", package) - success = retcode == 0 and success - return success - - def _from_pip(self, package: str) -> None: - """ Install a pip package - - Parameters - ---------- - package: str - The full formatted package, with version, to be installed + float + The size in megabytes """ - pipexe = [sys.executable, "-u", "-m", "pip", "install", "--no-cache-dir"] - # install as user to solve perm restriction - if not self._env.is_admin and not self._env.is_virtualenv: - pipexe.append("--user") - pipexe.append(package) - - installer = self._installer(self._env, package, pipexe, self._is_gui) - if installer() != 0: - logger.warning("Couldn't install %s with pip. Please install this package manually", - package) - global _INSTALL_FAILED # pylint:disable=global-statement - _INSTALL_FAILED = True - - -class ProgressBar(): - """ Simple progress bar using STDLib for intercepting Conda installs and keeping the - terminal from getting jumbled """ - def __init__(self): - self._width_desc = 21 - self._width_size = 9 - self._width_bar = 35 - self._width_pct = 4 - self._marker = "█" - - self._cursor_visible = True - self._current_pos = 0 - self._bars = [] - - @classmethod - def _display_cursor(cls, visible: bool) -> None: - """ Sends ANSI code to display or hide the cursor + size, unit = size.strip().split(" ", maxsplit=1) + if unit.lower() == "b": + return float(size) / 1024 / 1024 + if unit.lower() == "kb": + return float(size) / 1024 + if unit.lower() == "mb": + return float(size) + if unit.lower() == "gb": + return float(size) * 1024 + return float(size) # Should never happen, but to prevent error + + def _print_conda(self, line: str) -> None: + """ Output progress for Conda installs Parameters ---------- - visible: bool - ``True`` to display the cursor. ``False`` to hide the cursor + line : str + The conda install line to parse """ - code = "\x1b[?25h" if visible else "\x1b[?25l" - print(code, end="\r") + progress = self._re_conda.match(line) + if progress is None: + self._print(line) + return + info = progress.groupdict() + if info["lib"] not in self._conda_tracked: + self._conda_tracked[info["lib"]] = {"size": self._parse_size(info["tot"]), + "done": float(info["prg"])} + else: + self._conda_tracked[info["lib"]]["done"] = float(info["prg"]) + count = len(self._conda_tracked) + total_size = sum(v["size"] for v in self._conda_tracked.values()) + prog = min(sum(v["done"] for v in self._conda_tracked.values()) / count, 100.) + self._print(f"Downloading {count} packages ({total_size:.1f} MB) {prog:.1f}%") - def _format_bar(self, description: str, size: str, percent: int) -> str: - """ Format the progress bar for display + def _print_pip(self, line: str) -> None: + """ Output progress for Pip installs Parameters ---------- - description: str - The description to display for the progress bar - size: str - The size of the download, including units - percent: int - The percentage progress of the bar + line : str + The pip install line to parse """ - size = size[:self._width_size].ljust(self._width_size) - bar_len = int(self._width_bar * (percent / 100)) - progress = f"{self._marker * bar_len}"[:self._width_bar].ljust(self._width_bar) - pct = f"{percent}%"[:self._width_pct].rjust(self._width_pct) - return f" {description}| {size} | {progress} | {pct}" - - def _move_cursor(self, position: int) -> str: - """ Generate ANSI code for moving the cursor to the given progress bar's position + if (line.lower().startswith("installing collected packages:") and + len(line) > self._max_width): + count = len(line.split(":", maxsplit=1)[-1].split(",")) + line = f"Installing {count} collected packages..." + progress = self._re_pip_progress.match(line) + if progress is None: + self._print(line) + return + info = progress.groupdict() + done = (int(info["done"]) / int(info["total"])) * 100.0 + last_line = self._last_line.strip()[len(self._prefix):] + pkg = self._re_pip_pkg.match(self._re_pip_http.sub(r"\1", last_line)) + if pkg is not None: + info = pkg.groupdict() + last_line = f"Downloading {info['lib']} ({info['size']})" + self._print(f"{last_line} {done:.1f}%") + + def __call__(self, line: str) -> None: + """ Update the output status with the given line Parameters ---------- - position: int - The progress bar position to move to - - Returns - ------- - str - The ansi code to move to the given position + line : str + A cleansed line from either Conda or Pip installers """ - move = position - self._current_pos - retval = "\x1b[A" if move < 0 else "\x1b[B" if move > 0 else "" - retval *= abs(move) - return retval - - def __call__(self, description: str, size: str, percent: int) -> None: - """ Create or update a progress bar - - Parameters - ---------- - description: str - The description to display for the progress bar - size: str - The size of the download, including units - percent: int - The percentage progress of the bar - """ - if self._cursor_visible: - self._display_cursor(visible=False) - - desc = description[:self._width_desc].ljust(self._width_desc) - if desc not in self._bars: - self._bars.append(desc) - - position = self._bars.index(desc) - pbar = self._format_bar(desc, size, percent) - - output = f"{self._move_cursor(position)} {pbar}" - - print(output) - self._current_pos = position + 1 + if self._is_conda: + self._print_conda(line.strip()) + else: + self._print_pip(line.strip()) def close(self) -> None: """ Reset all progress bars and re-enable the cursor """ - print(self._move_cursor(len(self._bars)), end="\r") - self._display_cursor(True) - self._cursor_visible = True - self._current_pos = 0 - self._bars = [] + self._clear_line() class Installer(): - """ Parent class for package installers. - - PyWinPty is used for Windows, Pexpect is used for Linux, as these can provide us with realtime - output. - - Subprocess is used as a fallback if any of the above fail, but this caches output, so it can - look like the process has hung to the end user + """ Uses the python Subprocess module to install packages. Parameters ---------- - environment: :class:`Environment` + environment : :class:`Environment` Environment class holding information about the running system - package: str - The package name that is being installed - command: list + packages : list[str] + The list of package names that are to be installed + command : list The command to run - is_gui: bool + is_conda : bool + ``True`` if conda install command is running. ``False`` if pip install command is running + is_gui : bool ``True`` if the process is being called from the Faceswap GUI """ - def __init__(self, + def __init__(self, # pylint:disable=too-many-positional-arguments environment: Environment, - package: str, + packages: list[str], command: list[str], + is_conda: bool, is_gui: bool) -> None: - logger.info("Installing %s", package) + self._output_information(packages) logger.debug("argv: %s", command) self._env = environment - self._package = package + self._packages = packages self._command = command - self._is_conda = "conda" in command + self._is_conda = is_conda self._is_gui = is_gui - - self._progess_bar = ProgressBar() - self._re_conda = re.compile( - rb"(?P^\S+)\s+\|\s+(?P\d+\.?\d*\s\w+).*\|\s+(?P\d+%)") - self._re_pip_pkg = re.compile(rb"^\s*Downloading\s(?P\w+-.+?)-") - self._re_pip = re.compile(rb"(?P\d+\.?\d*)/(?P\d+\.?\d*\s\w+)") - self._pip_pkg = "" + self._status = Status(is_conda) + self._re_ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') self._seen_lines: set[str] = set() + self.error_lines: list[str] = [] - def __call__(self) -> int: - """ Call the subclassed call function - - Returns - ------- - int - The return code of the package install process - """ - try: - returncode = self.call() - except Exception as err: # pylint:disable=broad-except - logger.debug("Failed to install with %s. Falling back to subprocess. Error: %s", - self.__class__.__name__, str(err)) - self._progess_bar.close() - returncode = SubProcInstaller(self._env, self._package, self._command, self._is_gui)() - - logger.debug("Package: %s, returncode: %s", self._package, returncode) - self._progess_bar.close() - return returncode - - def call(self) -> int: - """ Override for package installer specific logic. - - Returns - ------- - int - The return code of the package install process - """ - raise NotImplementedError() - - def _print_conda(self, text: bytes) -> None: - """ Output progress for Conda installs + @classmethod + def _output_information(cls, packages: list[str]): + """ INFO log the packages to be installed, splitting along multiple lines for long package + lists (68 chars = 79 chars - (log-level spacing + indent)) Parameters ---------- - text: bytes - The text to print + packages : list[str] + The list of package names that are to be installed """ - data = self._re_conda.match(text) - if not data: - return - lib = data.groupdict()["lib"].decode("utf-8", errors="replace") - size = data.groupdict()["tot"].decode("utf-8", errors="replace") - progress = int(data.groupdict()["prg"].decode("utf-8", errors="replace")[:-1]) - self._progess_bar(lib, size, progress) + output = "" + sep = ", " + for pkg in packages: + current = pkg + sep + if len(output) + len(current) > 68: + logger.info(" %s", output) + output = current + else: + output += current + if output: + logger.info(" %s", output[:-len(sep)]) - def _print_pip(self, text: bytes) -> None: - """ Output progress for Pip installs + def _clean_line(self, text: str) -> str: + """Remove ANSI escape sequences and special characters from text. Parameters ---------- - text: bytes - The text to print - """ - pkg = self._re_pip_pkg.match(text) - if pkg: - logger.debug("Collected pip package '%s'", pkg) - self._pip_pkg = pkg.groupdict()["lib"].decode("utf-8", errors="replace") - return - data = self._re_pip.search(text) - if not data: - return - done = float(data.groupdict()["done"].decode("utf-8", errors="replace")) - size = data.groupdict()["tot"].decode("utf-8", errors="replace") - progress = int(round(done / float(size.split()[0]) * 100, 0)) - self._progess_bar(self._pip_pkg, size, progress) + text : str + The text to clean - def _non_gui_print(self, text: bytes) -> None: - """ Print output to console if not running in the GUI - - Parameters - ---------- - text: bytes - The text to print + Returns + ------- + str + The cleansed text """ - if self._is_gui: - return - if self._is_conda: - self._print_conda(text) - else: - self._print_pip(text) + clean = self._re_ansi_escape.sub("", text.rstrip()) + return ''.join(c for c in clean if c in set(printable)) - def _seen_line_log(self, text: str) -> None: + def _seen_line_log(self, text: str, is_error: bool = False) -> str: """ Output gets spammed to the log file when conda is waiting/processing. Only log each unique line once. Parameters ---------- - text: str + text : str The text to log - """ - if text in self._seen_lines: - return - logger.debug(text) - self._seen_lines.add(text) + is_error : bool, optional + ``True`` if the line comes from an error. Default: ``False`` + Returns + ------- + str + The cleansed log line -class PexpectInstaller(Installer): # pylint:disable=too-few-public-methods - """ Package installer for Linux/macOS using Pexpect - - Uses Pexpect for installing packages allowing access to realtime feedback + """ + clean = self._clean_line(text) + if clean in self._seen_lines: + return "" + clean = f"ERROR: {clean}" if is_error else clean + logger.debug(clean) + self._seen_lines.add(clean) + return clean - Parameters - ---------- - environment: :class:`Environment` - Environment class holding information about the running system - package: str - The package name that is being installed - command: list - The command to run - is_gui: bool - ``True`` if the process is being called from the Faceswap GUI - """ - def call(self) -> int: - """ Install a package using the Pexpect module + def __call__(self) -> int: + """ Install a package using the Subprocess module Returns ------- int The return code of the package install process """ - import pexpect # pylint:disable=import-outside-toplevel,import-error - proc = pexpect.spawn(" ".join(self._command), timeout=None) - while True: - try: - proc.expect([b"\r\n", b"\r"]) - line: bytes = proc.before - self._seen_line_log(line.decode("utf-8", errors="replace").rstrip()) - self._non_gui_print(line) - except pexpect.EOF: - break - proc.close() - return proc.exitstatus - + with Popen(self._command, + bufsize=0, stdout=PIPE, stderr=PIPE) as proc: + lines = b"" + while True: + if proc.stdout is not None: + lines = proc.stdout.readline() + returncode = proc.poll() + if lines == b"" and returncode is not None: + break + for line in lines.split(b"\r"): + clean = self._seen_line_log(line.decode("utf-8", errors="replace")) + if not self._is_gui and clean: + self._status(clean) + if returncode and proc.stderr is not None: + for line in proc.stderr.readlines(): + clean = self._seen_line_log(line.decode("utf-8", errors="replace"), + is_error=True) + if clean: + self.error_lines.append(clean.replace("ERROR:", "").strip()) + + logger.debug("Packages: %s, returncode: %s", self._packages, returncode) + if not self._is_gui: + self._status.close() + return returncode -class WinPTYInstaller(Installer): # pylint:disable=too-few-public-methods - """ Package installer for Windows using WinPTY - Spawns a pseudo PTY for installing packages allowing access to realtime feedback +class Install(): # pylint:disable=too-few-public-methods + """ Handles installation of Faceswap requirements Parameters ---------- - environment: :class:`Environment` + environment : :class:`Environment` Environment class holding information about the running system - package: str - The package name that is being installed - command: list - The command to run - is_gui: bool - ``True`` if the process is being called from the Faceswap GUI - """ - def __init__(self, - environment: Environment, - package: str, - command: list[str], - is_gui: bool) -> None: - super().__init__(environment, package, command, is_gui) - self._cmd = which(command[0], path=os.environ.get('PATH', os.defpath)) - self._cmdline = " ".join(command) - logger.debug("cmd: '%s', cmdline: '%s'", self._cmd, self._cmdline) - - self._pbar = re.compile(r"(?:eta\s[\d\W]+)|(?:\s+\|\s+\d+%)\Z") - self._eof = False - self._read_bytes = 1024 + is_gui : bool, Optional + ``True`` if the caller is the Faceswap GUI. Used to prevent output of progress bars + which get scrambled in the GUI + """ + def __init__(self, environment: Environment, is_gui: bool = False) -> None: + self._env = environment + self._is_gui = is_gui + if not self._env.is_installer and not self._env.updater: + self._ask_continue() + self._packages = RequiredPackages(environment) + if self._env.updater and not self._packages.packages_need_install: + logger.info("All Dependencies are up to date") + return + self._install_packages() + self._finalize() - self._lines: list[str] = [] - self._out = "" + def _ask_continue(self) -> None: + """ Ask Continue with Install """ + if _InstallState.messages: + for msg in _InstallState.messages: + logger.warning(msg) + text = "Please ensure your System Dependencies are met." + if self._env.backend == "rocm": + text += ("\r\nPlease ensure that your AMD GPU is supported by the " + "installed ROCm version before proceeding.") + text += "\r\nContinue? [y/N] " + inp = input(text) + if inp in ("", "N", "n"): + logger.info("Installation cancelled") + sys.exit(0) - def _read_from_pty(self, proc: T.Any, winpty_error: T.Any) -> None: - """ Read :attr:`_num_bytes` from WinPTY. If there is an error reading, recursively halve - the number of bytes read until we get a succesful read. If we get down to 1 byte without a - succesful read, assume we are at EOF. + def _from_pip(self, + packages: list[dict[T.Literal["name", "package"], str]], + extra_args: list[str] | None = None) -> None: + """ Install packages from pip Parameters ---------- - proc: :class:`winpty.PTY` - The WinPTY process - winpty_error: :class:`winpty.WinptyError` - The winpty error exception. Passed in as WinPTY is not in global scope + packages : list[dict[T.Literal["name", "package"], str] + The formatted list of packages to be installed + extra_args : list[str] | None, optional + Any extra arguments to provide to pip. Default: ``None`` (no extra arguments) """ - try: - from_pty = proc.read(self._read_bytes) - except winpty_error: - # TODO Reinsert this check - # The error message "pipe has been ended" is language specific so this check - # fails on non english systems. For now we just swallow all errors until no - # bytes are left to read and then check the return code - # if any(val in str(err) for val in ["EOF", "pipe has been ended"]): - # # Get remaining bytes. On a comms error, the buffer remains unread so keep - # # halving buffer amount until down to 1 when we know we have everything - # if self._read_bytes == 1: - # self._eof = True - # from_pty = "" - # self._read_bytes //= 2 - # else: - # raise - - # Get remaining bytes. On a comms error, the buffer remains unread so keep - # halving buffer amount until down to 1 when we know we have everything - if self._read_bytes == 1: - self._eof = True - from_pty = "" - self._read_bytes //= 2 - - self._out += from_pty - - def _out_to_lines(self) -> None: - """ Process the winpty output into separate lines. Roll over any semi-consumed lines to the - next proc call. """ - if "\n" not in self._out: - return - - self._lines.extend(self._out.split("\n")) - - if self._out.endswith("\n") or self._eof: # Ends on newline or is EOF - self._out = "" - else: # roll over semi-consumed line to next read - self._out = self._lines[-1] - self._lines = self._lines[:-1] - - def call(self) -> int: - """ Install a package using the PyWinPTY module - - Returns - ------- - int - The return code of the package install process - """ - import winpty # pylint:disable=import-outside-toplevel,import-error - # For some reason with WinPTY we need to pass in the full command. Probably a bug - proc = winpty.PTY( - 100, - 24, - backend=winpty.enums.Backend.WinPTY, # ConPTY hangs and has lots of Ansi Escapes - agent_config=winpty.enums.AgentConfig.WINPTY_FLAG_PLAIN_OUTPUT) # Strip all Ansi - - if not proc.spawn(self._cmd, cmdline=self._cmdline): - del proc - raise RuntimeError("Failed to spawn winpty") - - while True: - self._read_from_pty(proc, winpty.WinptyError) - self._out_to_lines() - for line in self._lines: - self._seen_line_log(line.rstrip()) - self._non_gui_print(line.encode("utf-8", errors="replace")) - self._lines = [] - - if self._eof: - returncode = proc.get_exitstatus() - break - - del proc - return returncode - - -class SubProcInstaller(Installer): - """ The fallback package installer if either of the OS specific installers fail. - - Uses the python Subprocess module to install packages. Feedback does not return in realtime - so the process can look like it has hung to the end user - - Parameters - ---------- - environment: :class:`Environment` - Environment class holding information about the running system - package: str - The package name that is being installed - command: list - The command to run - is_gui: bool - ``True`` if the process is being called from the Faceswap GUI - """ - def __init__(self, - environment: Environment, - package: str, - command: list[str], - is_gui: bool) -> None: - super().__init__(environment, package, command, is_gui) - self._shell = self._env.os_version[0] == "Windows" and command[0] == "conda" + pipexe = [sys.executable, + "-u", "-m", "pip", "install", "--no-cache-dir", "--progress-bar=raw"] + + if not self._env.system.is_admin and not self._env.system.is_virtual_env: + pipexe.append("--user") # install as user to solve perm restriction + if extra_args is not None: + pipexe.extend(extra_args) + pipexe.extend([p["package"] for p in packages]) + names = [p["name"] for p in packages] + installer = Installer(self._env, names, pipexe, False, self._is_gui) + if installer() != 0: + msg = f"Unable to install Python packages: {', '.join(names)}" + logger.warning("%s. Please install these packages manually", msg) + for line in installer.error_lines: + _InstallState.messages.append(line) + _InstallState.failed = True - def __call__(self) -> int: - """ Override default call function so we don't recursively call ourselves on failure. """ - returncode = self.call() - logger.debug("Package: %s, returncode: %s", self._package, returncode) - return returncode + def _from_conda(self, + packages: list[dict[T.Literal["name", "package"], str]], + channel: str) -> None: + """ Install packages from conda - def call(self) -> int: - """ Install a package using the Subprocess module + Parameters + ---------- + packages : list[dict[T.Literal["name", "package"], str]] + The full formatted packages to be installed + channel : str + The Conda channel to install from. Returns ------- - int - The return code of the package install process + bool + ``True`` if the package was succesfully installed otherwise ``False`` """ - with Popen(self._command, - bufsize=0, stdout=PIPE, stderr=STDOUT, shell=self._shell) as proc: - while True: - if proc.stdout is not None: - lines = proc.stdout.readline() - returncode = proc.poll() - if lines == b"" and returncode is not None: - break - - for line in lines.split(b"\r"): - self._seen_line_log(line.decode("utf-8", errors="replace").rstrip()) - self._non_gui_print(line) - - return returncode + conda = which("conda") + assert conda is not None + condaexe = [conda, "install", "-y", "-c", channel, + "--override-channels", "--strict-channel-priority"] + condaexe += [p["package"] for p in packages] + names = [p["name"] for p in packages] + retcode = Installer(self._env, names, condaexe, True, self._is_gui)() + if retcode != 0: + logger.warning("Unable to install Conda packages: %s. " + "Please install these packages manually", ', '.join(names)) + _InstallState.failed = True + + def _install_packages(self) -> None: + """ Install the required packages """ + if self._packages.conda: + logger.info("Installing Conda packages...") + for channel, packages in self._packages.conda.items(): + self._from_conda(packages, channel) + if self._packages.python: + logger.info("Installing Python packages...") + packages = [p for p in self._packages.python if p["name"] != "Packaging"] + self._from_pip(packages, extra_args=self._packages.pip_arguments) + + def _finalize(self) -> None: + """ Output final information on completion """ + if self._env.updater: + return + if not _InstallState.failed: + if _InstallState.messages: + for msg in _InstallState.messages: + logger.warning(msg) + logger.info("All Faceswap dependencies are met. You are good to go.\r\n\r\n" + "Enter: 'python faceswap.py -h' to see the options\r\n" + " 'python faceswap.py gui' to launch the GUI") + else: + msg = "Some packages failed to install. " + if not _InstallState.messages: + msg += ("This may be temporary and might be fixed by re-running this script. " + "Otherwise check 'faceswap_setup.log' to see which failed and install " + "these packages manually.") + else: + msg += ("Further information can be found in 'faceswap_setup.log'. The following " + "output shows specific error(s) that were collected:\r\n") + msg += "\r\n".join(_InstallState.messages) + logger.error(msg) + sys.exit(1) class Tips(): @@ -1704,16 +990,7 @@ def macos(cls) -> None: "setup.py does not directly support macOS. The following tips should help:\n\n" "1. Install system dependencies:\n" "XCode from the Apple Store\n" - "XQuartz: https://www.xquartz.org/\n\n" - - "2a. It is recommended to use Anaconda for your Python Virtual Environment as this\n" - "will handle the installation of CUDA and cuDNN for you:\n" - "https://www.anaconda.com/distribution/\n\n" - - "2b. If you do not want to use Anaconda you will need to manually install CUDA and " - "cuDNN:\n" - "CUDA: https://developer.nvidia.com/cuda-downloads" - "cuDNN: https://developer.nvidia.com/rdp/cudnn-download\n\n") + "XQuartz: https://www.xquartz.org/\n\n") @classmethod def pip(cls) -> None: @@ -1730,6 +1007,9 @@ def pip(cls) -> None: ENV = Environment() Checks(ENV) ENV.set_config() - if _INSTALL_FAILED: + if _InstallState.failed: sys.exit(1) Install(ENV) + + +__all__ = get_module_objects(__name__) diff --git a/tests/data/imgs/test_img1.jpg b/tests/data/imgs/test_img1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..411f46dce88767cc224d9f927a2d09f4b4b3b39c GIT binary patch literal 154538 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%0D_*0V=iDey?at%pl$4x+&skhG*o>UC)sSH^qZ- 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�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

    g!! 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 PidSg!F_4T_i?RRN6v4G~ literal 0 HcmV?d00001 diff --git a/tests/data/imgs/test_img3.jpg b/tests/data/imgs/test_img3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..039a58b1234cfab7ddc2091b20daae1e84ed5fdd GIT binary patch literal 71388 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 literal 0 HcmV?d00001 diff --git a/tests/data/imgs/test_img4.jpg b/tests/data/imgs/test_img4.jpg new file mode 100644 index 0000000000000000000000000000000000000000..42fe62a02055cf700e8e97b5db402f0c6fff61df GIT binary patch literal 173613 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�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

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

    IrR9h*51b8%4br{GmwPy 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
    TH06xY(trwTP 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%0D~|xutu)uF*0ycIiZP`QdHM&H(Gn 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 None: - """ Test that :func:`~lib.gpu_stats._base.set_exclude_devices` adds devices - - Parameters - ---------- - monkeypatch: :class:`pytest.MonkeyPatch` - Monkey patching _EXCLUDE_DEVICES - """ - monkeypatch.setattr(_base, "_EXCLUDE_DEVICES", []) - assert not _base._EXCLUDE_DEVICES - set_exclude_devices([0, 1]) - assert _base._EXCLUDE_DEVICES == [0, 1] - - @dataclass class _DummyData: """ Dummy data for initializing and testing :class:`~lib.gpu_stats._base._GPUStats` """ @@ -126,19 +111,6 @@ def test__gpu_stats_get_card_most_free(mocker: pytest_mock.MockerFixture, total=2048) -def test__gpu_stats_exclude_all_devices(gpu_stats_instance: _GPUStats) -> None: - """ Ensure that the object correctly returns whether all devices are excluded - - Parameters - ---------- - gpu_stats_instance: :class:`_GPUStats` - Fixture instance of the _GPUStats base class - """ - assert gpu_stats_instance.exclude_all_devices is False - set_exclude_devices([0, 1]) - assert gpu_stats_instance.exclude_all_devices is True - - def test__gpu_stats_no_active_devices( caplog: pytest.LogCaptureFixture, gpu_stats_instance: _GPUStats, # pylint:disable=unused-argument diff --git a/tests/lib/gui/stats/event_reader_test.py b/tests/lib/gui/stats/event_reader_test.py index 216790550b..0e4094aba6 100644 --- a/tests/lib/gui/stats/event_reader_test.py +++ b/tests/lib/gui/stats/event_reader_test.py @@ -14,8 +14,7 @@ import pytest import pytest_mock -import tensorflow as tf -from tensorflow.core.util import event_pb2 # pylint:disable=no-name-in-module +from tensorboard.compat.proto import event_pb2 from lib.gui.analysis.event_reader import (_Cache, _CacheData, _EventParser, _LogFiles, EventData, TensorBoardLogs) @@ -298,7 +297,10 @@ def tensorboardlogs_fixture(self, tblogs_instance = TensorBoardLogs(tmp_path, False) def teardown(): - rmtree(tmp_path) + try: + rmtree(tmp_path) + except PermissionError: + pass request.addfinalizer(teardown) return tblogs_instance @@ -448,8 +450,8 @@ def test_get_loss(tensorboardlogs_instance: TensorBoardLogs, """ tb_logs = tensorboardlogs_instance - with pytest.raises(tf.errors.NotFoundError): # Invalid session id - tb_logs.get_loss(3) + mocker.patch("lib.gui.analysis.event_reader.RecordIterator") + tb_logs.get_loss(3) check_cache = mocker.patch("lib.gui.analysis.event_reader.TensorBoardLogs._check_cache") get_data = mocker.patch("lib.gui.analysis.event_reader._Cache.get_data") @@ -480,8 +482,9 @@ def test_get_timestamps(tensorboardlogs_instance: TensorBoardLogs, Mocker for checking _cache_data is called """ tb_logs = tensorboardlogs_instance - with pytest.raises(tf.errors.NotFoundError): # invalid session_id - tb_logs.get_timestamps(3) + mocker.patch("lib.gui.analysis.event_reader.RecordIterator") + + tb_logs.get_timestamps(3) check_cache = mocker.patch("lib.gui.analysis.event_reader.TensorBoardLogs._check_cache") get_data = mocker.patch("lib.gui.analysis.event_reader._Cache.get_data") @@ -709,11 +712,20 @@ def test__get_outputs(self, event_parser_instance: _EventParser) -> None: model_config = {"output_layers": outputs} expected = np.array([[out] for out in outputs]) - actual = event_parser_instance._get_outputs(model_config) + actual = event_parser_instance._get_outputs(model_config, is_sub_model=False) assert isinstance(actual, np.ndarray) assert actual.shape == (2, 1, 3) np.testing.assert_equal(expected, actual) + outputs = [["encoder", 1, 0]] + model_config = {"output_layers": outputs} + + expected = np.array([outputs]) + actual = event_parser_instance._get_outputs(model_config, is_sub_model=True) + assert isinstance(actual, np.ndarray) + assert actual.shape == (1, 1, 3) + np.testing.assert_equal(expected, actual) + def test__process_event(self, event_parser_instance: _EventParser) -> None: """ Test _process_event works correctly diff --git a/tests/lib/gui/stats/moving_average_test.py b/tests/lib/gui/stats/moving_average_test.py new file mode 100644 index 0000000000..cfa1881253 --- /dev/null +++ b/tests/lib/gui/stats/moving_average_test.py @@ -0,0 +1,111 @@ +#!/usr/bin python3 +""" Pytest unit tests for :mod:`lib.gui.stats.moving_average` """ + +import numpy as np +import pytest + +from lib.gui.analysis.moving_average import ExponentialMovingAverage as EMA + +# pylint:disable=[protected-access,invalid-name] + + +_INIT_PARAMS = ((np.array([1, 2, 3], dtype="float32"), 0.0), + (np.array([4, 5, 6], dtype="float64"), 0.25), + (np.array([7, 8, 9], dtype="uint8"), 1.0), + (np.array([0, np.nan, 1], dtype="float32"), 0.74), + (np.array([2, 3, np.inf], dtype="float32"), 0.33), + (np.array([4, 5, 6], dtype="float32"), -1.0), + (np.array([7, 8, 9], dtype="float32"), 99.0)) +_INIT_IDS = ["float32", "float64", "uint8", "nan", "inf", "amount:-1", "amount:99"] + + +@pytest.mark.parametrize(("data", "amount"), _INIT_PARAMS, ids=_INIT_IDS) +def test_ExponentialMovingAverage_init(data: np.ndarray, amount: float): + """ Test that moving_average.MovingAverage correctly initializes """ + attrs = {"_data": np.ndarray, + "_alpha": float, + "_dtype": str, + "_row_size": int, + "_out": np.ndarray} + + instance = EMA(data, amount) + # Verify required attributes exist and are of the correct type + for attr, attr_type in attrs.items(): + assert attr in instance.__dict__ + assert isinstance(getattr(instance, attr), attr_type) + # Verify we are testing all existing attributes + for key in instance.__dict__: + assert key in attrs + + # Verify numeric sanitization + assert not np.any(np.isnan(instance._data)) + assert not np.any(np.isinf(instance._data)) + + # Check alpha clamp logic + expected_alpha = 1. - min(0.999, max(0.001, amount)) + assert instance._alpha == expected_alpha + + # dtype assignment logic + expected_dtype = "float32" if data.dtype == np.float32 else "float64" + assert instance._dtype == expected_dtype + + # ensure row size is positive and output matches shape and dtype + assert instance._row_size > 0 + assert instance._out.shape == data.shape + assert instance._out.dtype == expected_dtype + + +def naive_ewma(data: np.ndarray, alpha: float) -> np.ndarray: + """ A simple ewma implementation to test for correctness """ + out = np.empty_like(data, dtype=data.dtype) + out[0] = data[0] + for i in range(1, len(data)): + out[i] = alpha * data[i] + (1 - alpha) * out[i - 1] + return out + + +@pytest.mark.parametrize("alpha", [0.001, 0.01, 0.25, 0.33, 0.5, 0.66, 0.75, 0.90, 0.999]) +@pytest.mark.parametrize("dtype", ("float32", "float64")) +def test_ExponentialMovingAverage_matches_naive(alpha: float, dtype: str) -> None: + """ Make sure that we get sane results out for various data sizes against our reference + for various amounts """ + rows = max(5, int(np.random.random() * 25000)) + data = np.random.rand(rows).astype(dtype) + instance = EMA(data, 1 - alpha) + out = instance() + + ref = naive_ewma(data, alpha) + np.testing.assert_allclose(out, ref, rtol=3e-6, atol=3e-6) + + +@pytest.mark.parametrize("dtype", ("float32", "float64")) +def test_ExponentialMovingAverage_small_data(dtype: str) -> None: + """ Make sure we get sane results out of our small path """ + data = np.array([1., 2., 3.], dtype=dtype) + instance = EMA(data, 0.5) + out = instance() + ref = naive_ewma(data, instance._alpha) + np.testing.assert_allclose(out, ref) + + +@pytest.mark.parametrize("dtype", ("float32", "float64")) +def test_ExponentialMovingAverage_large_data_safe_path(dtype: str) -> None: + """ Make sure we get sane results out of our safe path """ + data = np.random.rand(50000).astype(dtype) + instance = EMA(data, 0.1) + # Force safe path + instance._row_size = 10 + + out = instance() + ref = naive_ewma(data, instance._alpha) + + np.testing.assert_allclose(out, ref, rtol=1e-6, atol=1e-6) + + +@pytest.mark.parametrize("dtype", ("float32", "float64")) +def test_ExponentialMovingAverage_empty_input(dtype: str) -> None: + """ Test that we get no data on an empty input """ + data = np.array([], dtype=dtype) + instance = EMA(data, 0.5) + out = instance() + assert out.size == 0 diff --git a/tests/lib/model/initializers_test.py b/tests/lib/model/initializers_test.py index ff52a3369c..5a39eb15fb 100644 --- a/tests/lib/model/initializers_test.py +++ b/tests/lib/model/initializers_test.py @@ -7,8 +7,7 @@ import pytest import numpy as np -from tensorflow.keras import backend as K # pylint:disable=import-error -from tensorflow.keras import initializers as k_initializers # noqa:E501 # pylint:disable=import-error +from keras import device, initializers as k_initializers, Variable from lib.model import initializers from lib.utils import get_backend @@ -19,8 +18,9 @@ def _runner(init, shape, target_mean=None, target_std=None, target_max=None, target_min=None): - variable = K.variable(init(shape)) - output = K.get_value(variable) + with device("cpu"): + variable = Variable(init(shape)) + output = variable.numpy() lim = 3e-2 if target_std is not None: assert abs(output.std() - target_std) < lim @@ -41,13 +41,13 @@ def test_icnr(tensor_shape): tensor_shape: tuple The shape of the tensor to feed to the initializer """ - fan_in, _ = initializers.compute_fans(tensor_shape) - std = np.sqrt(2. / fan_in) - _runner(initializers.ICNR(initializer=k_initializers.he_uniform(), # pylint:disable=no-member - scale=2), - tensor_shape, - target_mean=0, - target_std=std) + with device("cpu"): + fan_in, _ = initializers.compute_fans(tensor_shape) + std = np.sqrt(2. / fan_in) + _runner(initializers.ICNR(initializer=k_initializers.he_uniform(), scale=2), + tensor_shape, + target_mean=0, + target_std=std) @pytest.mark.parametrize('tensor_shape', [CONV_SHAPE], ids=[CONV_ID]) @@ -59,7 +59,8 @@ def test_convolution_aware(tensor_shape): tensor_shape: tuple The shape of the tensor to feed to the initializer """ - fan_in, _ = initializers.compute_fans(tensor_shape) - std = np.sqrt(2. / fan_in) - _runner(initializers.ConvolutionAware(seed=123), tensor_shape, - target_mean=0, target_std=std) + with device("cpu"): + fan_in, _ = initializers.compute_fans(tensor_shape) + std = np.sqrt(2. / fan_in) + _runner(initializers.ConvolutionAware(seed=123), tensor_shape, + target_mean=0, target_std=std) diff --git a/tests/lib/model/layers_test.py b/tests/lib/model/layers_test.py index 7b5ec6ebd7..27743486c7 100644 --- a/tests/lib/model/layers_test.py +++ b/tests/lib/model/layers_test.py @@ -10,95 +10,98 @@ from numpy.testing import assert_allclose -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras import Input, Model, backend as K # pylint:disable=import-error +from keras import device, Input, Model, backend as K from lib.model import layers from lib.utils import get_backend from tests.utils import has_arg -CONV_SHAPE = (3, 3, 256, 2048) -CONV_ID = get_backend().upper() - -def layer_test(layer_cls, kwargs={}, input_shape=None, input_dtype=None, # noqa:C901 - input_data=None, expected_output=None, - expected_output_dtype=None, fixed_batch_size=False): +# pylint:disable=dangerous-default-value,too-many-locals,too-many-branches +def layer_test(layer_cls, # noqa:C901 + kwargs={}, + input_shape=None, + input_dtype=None, + input_data=None, + expected_output=None, + expected_output_dtype=None, + fixed_batch_size=False): """Test routine for a layer with a single input tensor and single output tensor. """ - # generate input data - if input_data is None: - assert input_shape - if not input_dtype: - input_dtype = K.floatx() - input_data_shape = list(input_shape) - for i, var_e in enumerate(input_data_shape): - if var_e is None: - input_data_shape[i] = np.random.randint(1, 4) - input_data = 10 * np.random.random(input_data_shape) - input_data = input_data.astype(input_dtype) - else: - if input_shape is None: - input_shape = input_data.shape - if input_dtype is None: - input_dtype = input_data.dtype - if expected_output_dtype is None: - expected_output_dtype = input_dtype - - # instantiation - layer = layer_cls(**kwargs) - - # test get_weights , set_weights at layer level - weights = layer.get_weights() - layer.set_weights(weights) - - layer.build(input_shape) - expected_output_shape = layer.compute_output_shape(input_shape) - - # test in functional API - if fixed_batch_size: - inp = Input(batch_shape=input_shape, dtype=input_dtype) - else: - inp = Input(shape=input_shape[1:], dtype=input_dtype) - outp = layer(inp) - assert K.dtype(outp) == expected_output_dtype - - # check with the functional API - model = Model(inp, outp) - - actual_output = model.predict(input_data, verbose=0) - actual_output_shape = actual_output.shape - for expected_dim, actual_dim in zip(expected_output_shape, - actual_output_shape): - if expected_dim is not None: - assert expected_dim == actual_dim - - if expected_output is not None: - assert_allclose(actual_output, expected_output, rtol=1e-3) - - # test serialization, weight setting at model level - model_config = model.get_config() - recovered_model = model.__class__.from_config(model_config) - if model.weights: - weights = model.get_weights() - recovered_model.set_weights(weights) - _output = recovered_model.predict(input_data, verbose=0) - assert_allclose(_output, actual_output, rtol=1e-3) - - # test training mode (e.g. useful when the layer has a - # different behavior at training and testing time). - if has_arg(layer.call, 'training'): - model.compile('rmsprop', 'mse') - model.train_on_batch(input_data, actual_output) - - # test instantiation from layer config - layer_config = layer.get_config() - layer_config['batch_input_shape'] = input_shape - layer = layer.__class__.from_config(layer_config) - - # for further checks in the caller function - return actual_output + with device("cpu"): + # generate input data + # pylint:disable=duplicate-code + if input_data is None: + assert input_shape + if not input_dtype: + input_dtype = K.floatx() + input_data_shape = list(input_shape) + for i, var_e in enumerate(input_data_shape): + if var_e is None: + input_data_shape[i] = np.random.randint(1, 4) + input_data = 10 * np.random.random(input_data_shape) + input_data = input_data.astype(input_dtype) + else: + if input_shape is None: + input_shape = input_data.shape + if input_dtype is None: + input_dtype = input_data.dtype + if expected_output_dtype is None: + expected_output_dtype = input_dtype + + # instantiation + layer = layer_cls(**kwargs) + + # test get_weights , set_weights at layer level + weights = layer.get_weights() + layer.set_weights(weights) + + layer.build(input_shape) + expected_output_shape = layer.compute_output_shape(input_shape) + + # test in functional API + if fixed_batch_size: + inp = Input(batch_shape=input_shape, dtype=input_dtype) + else: + inp = Input(shape=input_shape[1:], dtype=input_dtype) + outp = layer(inp) + assert outp.dtype == expected_output_dtype + + # check with the functional API + model = Model(inp, outp) + + actual_output = model.predict(input_data, verbose=0) # type:ignore + actual_output_shape = actual_output.shape + for expected_dim, actual_dim in zip(expected_output_shape, + actual_output_shape): + if expected_dim is not None: + assert expected_dim == actual_dim + + if expected_output is not None: + assert_allclose(actual_output, expected_output, rtol=1e-3) + + # test serialization, weight setting at model level + model_config = model.get_config() + recovered_model = model.__class__.from_config(model_config) + if model.weights: + weights = model.get_weights() + recovered_model.set_weights(weights) + _output = recovered_model.predict(input_data, verbose=0) # type:ignore + assert_allclose(_output, actual_output, rtol=1e-3) + + # test training mode (e.g. useful when the layer has a + # different behavior at training and testing time). + if has_arg(layer.call, 'training'): + model.compile('rmsprop', 'mse') + model.train_on_batch(input_data, actual_output) + + # test instantiation from layer config + layer_config = layer.get_config() + layer = layer.__class__.from_config(layer_config) + + # for further checks in the caller function + return actual_output @pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) @@ -122,7 +125,7 @@ def test_k_resize_images(dummy): # pylint:disable=unused-argument @pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) def test_l2_normalize(dummy): # pylint:disable=unused-argument """ L2 Normalize layer test """ - layer_test(layers.L2_normalize, kwargs={"axis": 1}, input_shape=(2, 4, 4, 1024)) + layer_test(layers.L2Normalize, kwargs={"axis": 1}, input_shape=(2, 4, 4, 1024)) @pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) @@ -143,13 +146,27 @@ def test_reflection_padding_2d(dummy): # pylint:disable=unused-argument layer_test(layers.ReflectionPadding2D, input_shape=(2, 4, 4, 512)) -@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) -def test_subpixel_upscaling(dummy): # pylint:disable=unused-argument - """ Sub Pixel up-scaling layer test """ - layer_test(layers.SubPixelUpscaling, input_shape=(2, 4, 4, 1024)) - - @pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) def test_swish(dummy): # pylint:disable=unused-argument - """ Sub Pixel up-scaling layer test """ + """ Swish activation layer test """ layer_test(layers.Swish, input_shape=(2, 4, 4, 1024)) + + +_PARAMS = ("multiply", "truediv", "add", "subtract") +_IDS = [f"{x}[{get_backend().upper()}]" for x in _PARAMS] + + +@pytest.mark.parametrize("operation", _PARAMS, ids=_IDS) +def test_scalar_op(operation): + """ Scalar operation layer test """ + val = 2.0 + np_ops = {"multiply": np.multiply, + "truediv": np.true_divide, + "add": np.add, + "subtract": np.subtract} + input_data = np.random.random((2, 4, 4, 1024)).astype("float32") + output_data = np_ops[operation](input_data, val) + layer_test(layers.ScalarOp, + kwargs={"operation": operation, "value": val}, + input_data=input_data, + expected_output=output_data) diff --git a/tests/lib/model/losses/feature_loss_test.py b/tests/lib/model/losses/feature_loss_test.py new file mode 100644 index 0000000000..42e4af2a61 --- /dev/null +++ b/tests/lib/model/losses/feature_loss_test.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +""" Tests for Faceswap Feature Losses. Adapted from Keras tests. """ +import pytest +import numpy as np +from keras import device, Variable + +# pylint:disable=import-error +from lib.model.losses.feature_loss import LPIPSLoss +from lib.utils import get_backend + + +_NETS = ("alex", "squeeze", "vgg16") +_IDS = [f"LPIPS_{x}[{get_backend().upper()}]" for x in _NETS] + + +@pytest.mark.parametrize("net", _NETS, ids=_IDS) +def test_loss_output(net): + """ Basic dtype and value tests for loss functions. """ + with device("cpu"): + y_a = Variable(np.random.random((2, 32, 32, 3))) + y_b = Variable(np.random.random((2, 32, 32, 3))) + objective_output = LPIPSLoss(net)(y_a, y_b) + output = objective_output.detach().numpy() # type:ignore + assert output.dtype == "float32" and not np.any(np.isnan(output)) + assert output < 0.1 # LPIPS loss is reduced 10x diff --git a/tests/lib/model/losses/loss_test.py b/tests/lib/model/losses/loss_test.py new file mode 100644 index 0000000000..bf35bb5117 --- /dev/null +++ b/tests/lib/model/losses/loss_test.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" Tests for Faceswap Losses. + +Adapted from Keras tests. +""" + +import pytest +import numpy as np + +from keras import device, losses as k_losses, Variable + +from lib.model.losses.loss import (FocalFrequencyLoss, GeneralizedLoss, GradientLoss, + LaplacianPyramidLoss, LInfNorm, LossWrapper) +from lib.model.losses.feature_loss import LPIPSLoss +from lib.model.losses.perceptual_loss import DSSIMObjective, GMSDLoss, LDRFLIPLoss, MSSIMLoss + +from lib.utils import get_backend + + +_PARAMS = ((FocalFrequencyLoss, 1.0), + (GeneralizedLoss, 1.0), + (GradientLoss, 200.0), + (LaplacianPyramidLoss, 1.0), + (LInfNorm, 1.0)) +_IDS = [f"{x[0].__name__}[{get_backend().upper()}]" for x in _PARAMS] + + +@pytest.mark.parametrize(["loss_func", "max_target"], _PARAMS, ids=_IDS) +def test_loss_output(loss_func, max_target): + """ Basic dtype and value tests for loss functions. """ + with device("cpu"): + y_a = Variable(np.random.random((2, 32, 32, 3))) + y_b = Variable(np.random.random((2, 32, 32, 3))) + objective_output = loss_func()(y_a, y_b) + output = objective_output.detach().numpy() + assert output.dtype == "float32" and not np.any(np.isnan(output)) + assert output < max_target + + +_LWPARAMS = [(FocalFrequencyLoss, ()), + (GeneralizedLoss, ()), + (GradientLoss, ()), + (LaplacianPyramidLoss, ()), + (LInfNorm, ()), + (LPIPSLoss, ("squeeze", )), + (DSSIMObjective, ()), + (GMSDLoss, ()), + (LDRFLIPLoss, ()), + (MSSIMLoss, ()), + (k_losses.LogCosh, ()), + (k_losses.MeanAbsoluteError, ()), + (k_losses.MeanSquaredError, ())] +_LWIDS = [f"{x[0].__name__}[{get_backend().upper()}]" for x in _LWPARAMS] + + +@pytest.mark.parametrize(["loss_func", "func_args"], _LWPARAMS, ids=_LWIDS) +def test_loss_wrapper(loss_func, func_args): + """ Test penalized loss wrapper works as expected """ + with device("cpu"): + p_loss = LossWrapper() + p_loss.add_loss(loss_func(*func_args), 1.0, -1) + p_loss.add_loss(k_losses.MeanSquaredError(), 2.0, 3) + y_a = Variable(np.random.random((2, 32, 32, 4))) + y_b = Variable(np.random.random((2, 32, 32, 3))) + + output = p_loss(y_a, y_b) + output = output.detach().numpy() # type:ignore + assert output.dtype == "float32" and not np.any(np.isnan(output)) diff --git a/tests/lib/model/losses/perceptual_loss_test.py b/tests/lib/model/losses/perceptual_loss_test.py new file mode 100644 index 0000000000..9a37829323 --- /dev/null +++ b/tests/lib/model/losses/perceptual_loss_test.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +""" Tests for Faceswap Feature Losses. Adapted from Keras tests. """ +import pytest +import numpy as np +from keras import device, Variable + +# pylint:disable=import-error +from lib.model.losses.perceptual_loss import DSSIMObjective, GMSDLoss, LDRFLIPLoss, MSSIMLoss +from lib.utils import get_backend + + +_PARAMS = [DSSIMObjective, GMSDLoss, LDRFLIPLoss, MSSIMLoss] +_IDS = [f"{x.__name__}[{get_backend().upper()}]" for x in _PARAMS] + + +@pytest.mark.parametrize("loss_func", _PARAMS, ids=_IDS) +def test_loss_output(loss_func): + """ Basic dtype and value tests for loss functions. """ + with device("cpu"): + y_a = Variable(np.random.random((2, 32, 32, 3))) + y_b = Variable(np.random.random((2, 32, 32, 3))) + objective_output = loss_func()(y_a, y_b) + output = objective_output.detach().numpy() # type:ignore + assert output.dtype == "float32" and not np.any(np.isnan(output)) + assert output < 1.0 diff --git a/tests/lib/model/losses_test.py b/tests/lib/model/losses_test.py deleted file mode 100644 index ae59b38e10..0000000000 --- a/tests/lib/model/losses_test.py +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env python3 -""" Tests for Faceswap Losses. - -Adapted from Keras tests. -""" - -import pytest -import numpy as np - -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras import backend as K, losses as k_losses # noqa:E501 # pylint:disable=import-error - - -from lib.model import losses -from lib.utils import get_backend - -_PARAMS = [(losses.GeneralizedLoss(), (2, 16, 16)), - (losses.GradientLoss(), (2, 16, 16)), - # TODO Make sure these output dimensions are correct - (losses.GMSDLoss(), (2, 1, 1)), - # TODO Make sure these output dimensions are correct - (losses.LInfNorm(), (2, 1, 1))] -_IDS = ["GeneralizedLoss", "GradientLoss", "GMSDLoss", "LInfNorm"] -_IDS = [f"{loss}[{get_backend().upper()}]" for loss in _IDS] - - -@pytest.mark.parametrize(["loss_func", "output_shape"], _PARAMS, ids=_IDS) -def test_loss_output(loss_func, output_shape): - """ Basic shape tests for loss functions. """ - y_a = K.variable(np.random.random((2, 16, 16, 3))) - y_b = K.variable(np.random.random((2, 16, 16, 3))) - objective_output = loss_func(y_a, y_b) - output = objective_output.numpy() - assert output.dtype == "float32" and not np.any(np.isnan(output)) - - -_LWPARAMS = [losses.DSSIMObjective(), - losses.FocalFrequencyLoss(), - losses.GeneralizedLoss(), - losses.GMSDLoss(), - losses.GradientLoss(), - losses.LaplacianPyramidLoss(), - losses.LDRFLIPLoss(), - losses.LInfNorm(), - k_losses.logcosh, # pylint:disable=no-member - k_losses.mean_absolute_error, - k_losses.mean_squared_error, - losses.MSSIMLoss()] -_LWIDS = ["DSSIMObjective", "FocalFrequencyLoss", "GeneralizedLoss", "GMSDLoss", "GradientLoss", - "LaplacianPyramidLoss", "LInfNorm", "LDRFlipLoss", "logcosh", "mae", "mse", "MS-SSIM"] -_LWIDS = [f"{loss}[{get_backend().upper()}]" for loss in _LWIDS] - - -@pytest.mark.parametrize("loss_func", _LWPARAMS, ids=_LWIDS) -def test_loss_wrapper(loss_func): - """ Test penalized loss wrapper works as expected """ - y_a = K.variable(np.random.random((2, 64, 64, 4))) - y_b = K.variable(np.random.random((2, 64, 64, 3))) - p_loss = losses.LossWrapper() - p_loss.add_loss(loss_func, 1.0, -1) - p_loss.add_loss(k_losses.mean_squared_error, 2.0, 3) - output = p_loss(y_a, y_b) - output = output.numpy() - assert output.dtype == "float32" and not np.any(np.isnan(output)) diff --git a/tests/lib/model/nn_blocks_test.py b/tests/lib/model/nn_blocks_test.py index 4793b95ea7..34c0b2fe66 100644 --- a/tests/lib/model/nn_blocks_test.py +++ b/tests/lib/model/nn_blocks_test.py @@ -11,16 +11,21 @@ from numpy.testing import assert_allclose -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras import Input, Model, backend as K # pylint:disable=import-error +from keras import device, Input, Model, backend as K from lib.model import nn_blocks from lib.utils import get_backend +from plugins.train import train_config as cfg +# pylint:disable=unused-import +from tests.lib.config.helpers import patch_config # noqa:[F401] -def block_test(layer_func, kwargs={}, input_shape=None): +def block_test(layer_func, # pylint:disable=dangerous-default-value,too-many-locals + kwargs={}, + input_shape=None): """Test routine for faceswap neural network blocks. """ # generate input data + # pylint:disable=duplicate-code assert input_shape input_dtype = K.floatx() input_data_shape = list(input_shape) @@ -34,12 +39,12 @@ def block_test(layer_func, kwargs={}, input_shape=None): # test in functional API inp = Input(shape=input_shape[1:], dtype=input_dtype) outp = layer_func(inp, **kwargs) - assert K.dtype(outp) == expected_output_dtype + assert outp.dtype == expected_output_dtype # check with the functional API model = Model(inp, outp) - actual_output = model.predict(input_data, verbose=0) + actual_output = model.predict(input_data, verbose=0) # type:ignore # test serialization, weight setting at model level model_config = model.get_config() @@ -47,7 +52,7 @@ def block_test(layer_func, kwargs={}, input_shape=None): if model.weights: weights = model.get_weights() recovered_model.set_weights(weights) - _output = recovered_model.predict(input_data, verbose=0) + _output = recovered_model.predict(input_data, verbose=0) # type:ignore assert_allclose(_output, actual_output, rtol=1e-3) # for further checks in the caller function @@ -61,16 +66,20 @@ def block_test(layer_func, kwargs={}, input_shape=None): @pytest.mark.parametrize(_PARAMS, _VALUES, ids=_IDS) -def test_blocks(use_icnr_init, use_convaware_init, use_reflect_padding): +def test_blocks(use_icnr_init, + use_convaware_init, + use_reflect_padding, + patch_config): # pylint:disable=redefined-outer-name # noqa:[F811] """ Test for all blocks contained within the NNBlocks Class """ config = {"icnr_init": use_icnr_init, "conv_aware_init": use_convaware_init, "reflect_padding": use_reflect_padding} - nn_blocks.set_config(config) - block_test(nn_blocks.Conv2DOutput(64, 3), input_shape=(2, 8, 8, 32)) - block_test(nn_blocks.Conv2DBlock(64), input_shape=(2, 8, 8, 32)) - block_test(nn_blocks.SeparableConv2DBlock(64), input_shape=(2, 8, 8, 32)) - block_test(nn_blocks.UpscaleBlock(64), input_shape=(2, 4, 4, 128)) - block_test(nn_blocks.Upscale2xBlock(64, fast=True), input_shape=(2, 4, 4, 128)) - block_test(nn_blocks.Upscale2xBlock(64, fast=False), input_shape=(2, 4, 4, 128)) - block_test(nn_blocks.ResidualBlock(64), input_shape=(2, 4, 4, 64)) + patch_config(cfg, config) + with device("cpu"): + block_test(nn_blocks.Conv2DOutput(64, 3), input_shape=(2, 8, 8, 32)) + block_test(nn_blocks.Conv2DBlock(64), input_shape=(2, 8, 8, 32)) + block_test(nn_blocks.SeparableConv2DBlock(64), input_shape=(2, 8, 8, 32)) + block_test(nn_blocks.UpscaleBlock(64), input_shape=(2, 4, 4, 128)) + block_test(nn_blocks.Upscale2xBlock(64, fast=True), input_shape=(2, 4, 4, 128)) + block_test(nn_blocks.Upscale2xBlock(64, fast=False), input_shape=(2, 4, 4, 128)) + block_test(nn_blocks.ResidualBlock(64), input_shape=(2, 4, 4, 64)) diff --git a/tests/lib/model/normalization_test.py b/tests/lib/model/normalization_test.py index c447c6e480..d5f03fb396 100644 --- a/tests/lib/model/normalization_test.py +++ b/tests/lib/model/normalization_test.py @@ -8,7 +8,7 @@ import numpy as np import pytest -from tensorflow.keras import regularizers, models, layers # noqa:E501 # pylint:disable=import-error +from keras import device, regularizers, models, layers from lib.model import normalization from lib.utils import get_backend @@ -63,27 +63,28 @@ def test_group_normalization(dummy): # pylint:disable=unused-argument input_shape=(3, 64)) -_PARAMS = ["center", "scale"] -_VALUES = list(product([True, False], repeat=len(_PARAMS))) -_IDS = [f"{'|'.join([_PARAMS[idx] for idx, b in enumerate(v) if b])}[{get_backend().upper()}]" - for v in _VALUES] +_PARAMS_NORM = ["center", "scale"] +_VALUES_NORM = list(product([True, False], repeat=len(_PARAMS_NORM))) +_IDS = [f"{'|'.join([_PARAMS_NORM[idx] for idx, b in enumerate(v) if b])}[{get_backend().upper()}]" + for v in _VALUES_NORM] -@pytest.mark.parametrize(_PARAMS, _VALUES, ids=_IDS) +@pytest.mark.parametrize(_PARAMS_NORM, _VALUES_NORM, ids=_IDS) def test_adain_normalization(center, scale): """ Basic test for Ada Instance Normalization. """ - norm = normalization.AdaInstanceNormalization(center=center, scale=scale) - shapes = [(4, 8, 8, 1280), (4, 1, 1, 1280), (4, 1, 1, 1280)] - norm.build(shapes) - expected_output_shape = norm.compute_output_shape(shapes) - inputs = [layers.Input(shape=shapes[0][1:]), - layers.Input(shape=shapes[1][1:]), - layers.Input(shape=shapes[2][1:])] - model = models.Model(inputs, norm(inputs)) - data = [10 * np.random.random(shape) for shape in shapes] - - actual_output = model.predict(data, verbose=0) - actual_output_shape = actual_output.shape + with device("cpu"): + norm = normalization.AdaInstanceNormalization(center=center, scale=scale) + shapes = [(4, 8, 8, 1280), (4, 1, 1, 1280), (4, 1, 1, 1280)] + norm.build(shapes) + expected_output_shape = norm.compute_output_shape(shapes) + inputs = [layers.Input(shape=shapes[0][1:]), + layers.Input(shape=shapes[1][1:]), + layers.Input(shape=shapes[2][1:])] + model = models.Model(inputs, norm(inputs)) + data = [10 * np.random.random(shape) for shape in shapes] + + actual_output = model.predict(data, verbose=0) + actual_output_shape = actual_output.shape for expected_dim, actual_dim in zip(expected_output_shape, actual_output_shape): diff --git a/tests/lib/model/optimizers_test.py b/tests/lib/model/optimizers_test.py index 34d5335824..3f986ace50 100644 --- a/tests/lib/model/optimizers_test.py +++ b/tests/lib/model/optimizers_test.py @@ -6,11 +6,8 @@ import pytest import numpy as np -from numpy.testing import assert_allclose -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow.keras import optimizers as k_optimizers # pylint:disable=import-error -from tensorflow.keras.layers import Dense, Activation # pylint:disable=import-error -from tensorflow.keras.models import Sequential # pylint:disable=import-error + +from keras import device, layers as kl, optimizers as k_optimizers, Sequential from lib.model import optimizers from lib.utils import get_backend @@ -34,43 +31,29 @@ def _test_optimizer(optimizer, target=0.75): x_train, y_train = get_test_data() model = Sequential() - model.add(Dense(10, input_shape=(x_train.shape[1],))) - model.add(Activation("relu")) - model.add(Dense(y_train.shape[1])) - model.add(Activation("softmax")) + model.add(kl.Input((x_train.shape[1], ))) + model.add(kl.Dense(10)) + model.add(kl.Activation("relu")) + model.add(kl.Dense(y_train.shape[1])) + model.add(kl.Activation("softmax")) model.compile(loss="categorical_crossentropy", optimizer=optimizer, metrics=["accuracy"]) - history = model.fit(x_train, y_train, epochs=2, batch_size=16, verbose=0) + history = model.fit(x_train, y_train, epochs=2, batch_size=16, verbose=0) # type:ignore assert history.history["accuracy"][-1] >= target config = k_optimizers.serialize(optimizer) optim = k_optimizers.deserialize(config) new_config = k_optimizers.serialize(optim) - config["class_name"] = config["class_name"].lower() - new_config["class_name"] = new_config["class_name"].lower() + config["class_name"] = config["class_name"].lower() # type:ignore + new_config["class_name"] = new_config["class_name"].lower() # type:ignore assert config == new_config - # Test constraints. - model = Sequential() - dense = Dense(10, - input_shape=(x_train.shape[1],), - kernel_constraint=lambda x: 0. * x + 1., - bias_constraint=lambda x: 0. * x + 2.,) - model.add(dense) - model.add(Activation("relu")) - model.add(Dense(y_train.shape[1])) - model.add(Activation("softmax")) - model.compile(loss="categorical_crossentropy", - optimizer=optimizer, - metrics=["accuracy"]) - model.train_on_batch(x_train[:10], y_train[:10]) - kernel, bias = dense.get_weights() - assert_allclose(kernel, 1.) - assert_allclose(bias, 2.) - +# TODO remove the next line that supresses a weird pytest bug when it tears down the tempdir +@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") @pytest.mark.parametrize("dummy", [None], ids=[get_backend().upper()]) def test_adabelief(dummy): # pylint:disable=unused-argument """ Test for custom Adam optimizer """ - _test_optimizer(optimizers.AdaBelief(), target=0.20) + with device("cpu"): + _test_optimizer(optimizers.AdaBelief(), target=0.20) diff --git a/tests/lib/sysinfo_test.py b/tests/lib/sysinfo_test.py deleted file mode 100644 index 215e8c9f46..0000000000 --- a/tests/lib/sysinfo_test.py +++ /dev/null @@ -1,438 +0,0 @@ -#!/usr/bin python3 -""" Pytest unit tests for :mod:`lib.sysinfo` """ - -import locale -import os -import platform -import sys -import typing as T - -from collections import namedtuple -from io import StringIO -from unittest.mock import MagicMock - -import pytest -import pytest_mock - -from lib.gpu_stats import GPUInfo -from lib.sysinfo import _Configs, _State, _SysInfo, CudaCheck, get_sysinfo - -# pylint:disable=protected-access - - -# _SysInfo -@pytest.fixture(name="sys_info_instance") -def sys_info_fixture() -> _SysInfo: - """ Single :class:~`lib.utils._SysInfo` object for tests - - Returns - ------- - :class:`~lib.utils.sysinfo._SysInfo` - The class instance for testing - """ - return _SysInfo() - - -def test_init(sys_info_instance: _SysInfo) -> None: - """ Test :class:`~lib.utils.sysinfo._SysInfo` __init__ and attributes - - Parameters - ---------- - sys_info_instance: :class:`~lib.utils.sysinfo._SysInfo` - The class instance to test - """ - assert isinstance(sys_info_instance, _SysInfo) - - assert hasattr(sys_info_instance, "_state_file") - assert isinstance(sys_info_instance._state_file, str) - - assert hasattr(sys_info_instance, "_configs") - assert isinstance(sys_info_instance._configs, str) - - assert hasattr(sys_info_instance, "_system") - assert isinstance(sys_info_instance._system, dict) - assert sys_info_instance._system == {"platform": platform.platform(), - "system": platform.system().lower(), - "machine": platform.machine(), - "release": platform.release(), - "processor": platform.processor(), - "cpu_count": os.cpu_count()} - - assert hasattr(sys_info_instance, "_python") - assert isinstance(sys_info_instance._python, dict) - assert sys_info_instance._python == {"implementation": platform.python_implementation(), - "version": platform.python_version()} - - assert hasattr(sys_info_instance, "_gpu") - assert isinstance(sys_info_instance._gpu, GPUInfo) - - assert hasattr(sys_info_instance, "_cuda_check") - assert isinstance(sys_info_instance._cuda_check, CudaCheck) - - -def test_properties(sys_info_instance: _SysInfo) -> None: - """ Test :class:`~lib.utils.sysinfo._SysInfo` properties - - Parameters - ---------- - sys_info_instance: :class:`~lib.utils.sysinfo._SysInfo` - The class instance to test - """ - assert hasattr(sys_info_instance, "_encoding") - assert isinstance(sys_info_instance._encoding, str) - assert sys_info_instance._encoding == locale.getpreferredencoding() - - assert hasattr(sys_info_instance, "_is_conda") - assert isinstance(sys_info_instance._is_conda, bool) - assert sys_info_instance._is_conda == ("conda" in sys.version.lower() or - os.path.exists(os.path.join(sys.prefix, "conda-meta"))) - - assert hasattr(sys_info_instance, "_is_linux") - assert isinstance(sys_info_instance._is_linux, bool) - if platform.system().lower() == "linux": - assert sys_info_instance._is_linux and sys_info_instance._system["system"] == "linux" - assert not sys_info_instance._is_macos - assert not sys_info_instance._is_windows - - assert hasattr(sys_info_instance, "_is_macos") - assert isinstance(sys_info_instance._is_macos, bool) - if platform.system().lower() == "darwin": - assert sys_info_instance._is_macos and sys_info_instance._system["system"] == "darwin" - assert not sys_info_instance._is_linux - assert not sys_info_instance._is_windows - - assert hasattr(sys_info_instance, "_is_windows") - assert isinstance(sys_info_instance._is_windows, bool) - if platform.system().lower() == "windows": - assert sys_info_instance._is_windows and sys_info_instance._system["system"] == "windows" - assert not sys_info_instance._is_linux - assert not sys_info_instance._is_macos - - assert hasattr(sys_info_instance, "_is_virtual_env") - assert isinstance(sys_info_instance._is_virtual_env, bool) - - assert hasattr(sys_info_instance, "_ram_free") - assert isinstance(sys_info_instance._ram_free, int) - - assert hasattr(sys_info_instance, "_ram_total") - assert isinstance(sys_info_instance._ram_total, int) - - assert hasattr(sys_info_instance, "_ram_available") - assert isinstance(sys_info_instance._ram_available, int) - - assert hasattr(sys_info_instance, "_ram_used") - assert isinstance(sys_info_instance._ram_used, int) - - assert hasattr(sys_info_instance, "_fs_command") - assert isinstance(sys_info_instance._fs_command, str) - - assert hasattr(sys_info_instance, "_installed_pip") - assert isinstance(sys_info_instance._installed_pip, str) - - assert hasattr(sys_info_instance, "_installed_conda") - assert isinstance(sys_info_instance._installed_conda, str) - - assert hasattr(sys_info_instance, "_conda_version") - assert isinstance(sys_info_instance._conda_version, str) - - -def test_full_info(sys_info_instance: _SysInfo) -> None: - """ Test the sys_info method of :class:`~lib.utils.sysinfo._SysInfo` returns as expected - - Parameters - ---------- - sys_info_instance: :class:`~lib.utils.sysinfo._SysInfo` - The class instance to test - """ - assert hasattr(sys_info_instance, "full_info") - sys_info = sys_info_instance.full_info() - assert isinstance(sys_info, str) - assert "backend:" in sys_info - assert "os_platform:" in sys_info - assert "os_machine:" in sys_info - assert "os_release:" in sys_info - assert "py_conda_version:" in sys_info - assert "py_implementation:" in sys_info - assert "py_version:" in sys_info - assert "py_command:" in sys_info - assert "py_virtual_env:" in sys_info - assert "sys_cores:" in sys_info - assert "sys_processor:" in sys_info - assert "sys_ram:" in sys_info - assert "encoding:" in sys_info - assert "git_branch:" in sys_info - assert "git_commits:" in sys_info - assert "gpu_cuda:" in sys_info - assert "gpu_cudnn:" in sys_info - assert "gpu_driver:" in sys_info - assert "gpu_devices:" in sys_info - assert "gpu_vram:" in sys_info - assert "gpu_devices_active:" in sys_info - - -def test__format_ram(sys_info_instance: _SysInfo, monkeypatch: pytest.MonkeyPatch) -> None: - """ Test the _format_ram method of :class:`~lib.utils.sysinfo._SysInfo` returns as expected - - Parameters - ---------- - sys_info_instance: :class:`~lib.utils.sysinfo._SysInfo` - The class instance to test - monkeypatch: :class:`pytest.MonkeyPatch` - Monkey patching psutil.virtual_memory to be consistent - """ - assert hasattr(sys_info_instance, "_format_ram") - svmem = namedtuple("svmem", ["available", "free", "total", "used"]) - data = svmem(12345678, 1234567, 123456789, 123456) - monkeypatch.setattr("psutil.virtual_memory", lambda *args, **kwargs: data) - ram_info = sys_info_instance._format_ram() - - assert isinstance(ram_info, str) - assert ram_info == "Total: 117MB, Available: 11MB, Used: 0MB, Free: 1MB" - - -# get_sys_info -def test_get_sys_info(mocker: pytest_mock.MockerFixture) -> None: - """ Thest that the :func:`~lib.utils.sysinfo.get_sysinfo` function executes correctly - - Parameters - ---------- - mocker: :class:`pytest_mock.MockerFixture` - Mocker for checking full_info called from _SysInfo - """ - sys_info = get_sysinfo() - assert isinstance(sys_info, str) - full_info = mocker.patch("lib.sysinfo._SysInfo.full_info") - get_sysinfo() - assert full_info.called - - -# _Configs -@pytest.fixture(name="configs_instance") -def configs_fixture(): - """ Pytest fixture for :class:`~lib.utils.sysinfo._Configs` - - Returns - ------- - :class:`~lib.utils.sysinfo._Configs` - The class instance for testing - """ - return _Configs() - - -def test__configs__init__(configs_instance: _Configs) -> None: - """ Test __init__ and attributes for :class:`~lib.utils.sysinfo._Configs` - - Parameters - ---------- - configs_instance: :class:`~lib.utils.sysinfo._Configs` - The class instance to test - """ - assert hasattr(configs_instance, "config_dir") - assert isinstance(configs_instance.config_dir, str) - assert hasattr(configs_instance, "configs") - assert isinstance(configs_instance.configs, str) - - -def test__configs__get_configs(configs_instance: _Configs) -> None: - """ Test __init__ and attributes for :class:`~lib.utils.sysinfo._Configs` - - Parameters - ---------- - configs_instance: :class:`~lib.utils.sysinfo._Configs` - The class instance to test - """ - assert hasattr(configs_instance, "_get_configs") - assert isinstance(configs_instance._get_configs(), str) - - -def test__configs__parse_configs(configs_instance: _Configs, - mocker: pytest_mock.MockerFixture) -> None: - """ Test _parse_configs function for :class:`~lib.utils.sysinfo._Configs` - - Parameters - ---------- - configs_instance: :class:`~lib.utils.sysinfo._Configs` - The class instance to test - mocker: :class:`pytest_mock.MockerFixture` - Mocker for dummying in function calls - """ - assert hasattr(configs_instance, "_parse_configs") - assert isinstance(configs_instance._parse_configs([]), str) - configs_instance._parse_ini = T.cast(MagicMock, mocker.MagicMock()) # type:ignore - configs_instance._parse_json = T.cast(MagicMock, mocker.MagicMock()) # type:ignore - configs_instance._parse_configs(config_files=["test.ini", ".faceswap"]) - assert configs_instance._parse_ini.called - assert configs_instance._parse_json.called - - -def test__configs__parse_ini(configs_instance: _Configs, - monkeypatch: pytest.MonkeyPatch) -> None: - """ Test _parse_ini function for :class:`~lib.utils.sysinfo._Configs` - - Parameters - ---------- - configs_instance: :class:`~lib.utils.sysinfo._Configs` - The class instance to test - monkeypatch: :class:`pytest.MonkeyPatch` - Monkey patching :func:`builtins.open` to dummy in ini file - """ - assert hasattr(configs_instance, "_parse_ini") - - file = ("[test.ini_header]\n" - "# Test Header\n\n" - "param = value") - monkeypatch.setattr("builtins.open", lambda *args, **kwargs: StringIO(file)) - - converted = configs_instance._parse_ini("test.ini") - assert isinstance(converted, str) - assert converted == ("\n[test.ini_header]\n" - "param: value\n") - - -def test__configs__parse_json(configs_instance: _Configs, - monkeypatch: pytest.MonkeyPatch) -> None: - """ Test _parse_json function for :class:`~lib.utils.sysinfo._Configs` - - Parameters - ---------- - configs_instance: :class:`~lib.utils.sysinfo._Configs` - The class instance to test - monkeypatch: :class:`pytest.MonkeyPatch` - Monkey patching :func:`builtins.open` to dummy in json file - - """ - assert hasattr(configs_instance, "_parse_json") - file = '{"test": "param"}' - monkeypatch.setattr("builtins.open", lambda *args, **kwargs: StringIO(file)) - - converted = configs_instance._parse_json(".file") - assert isinstance(converted, str) - assert converted == ("test: param\n") - - -def test__configs__format_text(configs_instance: _Configs) -> None: - """ Test _format_text function for :class:`~lib.utils.sysinfo._Configs` - - Parameters - ---------- - configs_instance: :class:`~lib.utils.sysinfo._Configs` - The class instance to test - """ - assert hasattr(configs_instance, "_format_text") - key, val = " test_key ", "test_val " - formatted = configs_instance._format_text(key, val) - assert isinstance(formatted, str) - assert formatted == "test_key: test_val\n" - - -# _State -@pytest.fixture(name="state_instance") -def state_fixture(): - """ Pytest fixture for :class:`~lib.utils.sysinfo._State` - - Returns - ------- - :class:`~lib.utils.sysinfo._State` - The class instance for testing - """ - return _State() - - -def test__state__init__(state_instance: _State) -> None: - """ Test __init__ and attributes for :class:`~lib.utils.sysinfo._State` - - Parameters - ---------- - state_instance: :class:`~lib.utils.sysinfo._State` - The class instance to test - """ - assert hasattr(state_instance, '_model_dir') - assert state_instance._model_dir is None - assert hasattr(state_instance, '_trainer') - assert state_instance._trainer is None - assert hasattr(state_instance, 'state_file') - assert isinstance(state_instance.state_file, str) - - -def test__state__is_training(state_instance: _State, - monkeypatch: pytest.MonkeyPatch) -> None: - """ Test _is_training function for :class:`~lib.utils.sysinfo._State` - - Parameters - ---------- - state_instance: :class:`~lib.utils.sysinfo._State` - The class instance to test - monkeypatch: :class:`pytest.MonkeyPatch` - Monkey patching :func:`sys.argv` to dummy in commandline args - - """ - assert hasattr(state_instance, '_is_training') - assert isinstance(state_instance._is_training, bool) - assert not state_instance._is_training - monkeypatch.setattr("sys.argv", ["faceswap.py", "train"]) - assert state_instance._is_training - monkeypatch.setattr("sys.argv", ["faceswap.py", "extract"]) - assert not state_instance._is_training - - -def test__state__get_arg(state_instance: _State, - monkeypatch: pytest.MonkeyPatch) -> None: - """ Test _get_arg function for :class:`~lib.utils.sysinfo._State` - - Parameters - ---------- - state_instance: :class:`~lib.utils.sysinfo._State` - The class instance to test - monkeypatch: :class:`pytest.MonkeyPatch` - Monkey patching :func:`sys.argv` to dummy in commandline args - :func:`builtins.input` - """ - assert hasattr(state_instance, '_get_arg') - assert state_instance._get_arg("-t", "--test_arg") is None - monkeypatch.setattr("sys.argv", ["test", "command", "-t", "test_option"]) - assert state_instance._get_arg("-t", "--test_arg") == "test_option" - - -def test__state__get_state_file(state_instance: _State, - mocker: pytest_mock.MockerFixture, - monkeypatch: pytest.MonkeyPatch) -> None: - """ Test _get_state_file function for :class:`~lib.utils.sysinfo._State` - - Parameters - ---------- - state_instance: :class:`~lib.utils.sysinfo._State` - The class instance to test - mocker: :class:`pytest_mock.MockerFixture` - Mocker for dummying in function calls - monkeypatch: :class:`pytest.MonkeyPatch` - Monkey patching :func:`sys.argv` to dummy in commandline args - :func:`builtins.input` -` """ - assert hasattr(state_instance, '_get_state_file') - assert isinstance(state_instance._get_state_file(), str) - - mock_is_training = mocker.patch("lib.sysinfo._State._is_training") - - # Not training or missing training arguments - mock_is_training.return_value = False - assert state_instance._get_state_file() == "" - mock_is_training.return_value = False - - monkeypatch.setattr(state_instance, "_model_dir", None) - assert state_instance._get_state_file() == "" - monkeypatch.setattr(state_instance, "_model_dir", "test_dir") - - monkeypatch.setattr(state_instance, "_trainer", None) - assert state_instance._get_state_file() == "" - monkeypatch.setattr(state_instance, "_trainer", "test_trainer") - - # Training but file not found - assert state_instance._get_state_file() == "" - - # State file is just a json dump - file = ('{\n' - ' "test": "json",\n' - '}') - monkeypatch.setattr("os.path.isfile", lambda *args, **kwargs: True) - monkeypatch.setattr("builtins.open", lambda *args, **kwargs: StringIO(file)) - assert state_instance._get_state_file().endswith(file) diff --git a/tests/lib/system/__init__.py b/tests/lib/system/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/system/sysinfo_test.py b/tests/lib/system/sysinfo_test.py new file mode 100644 index 0000000000..ae5bfdf76c --- /dev/null +++ b/tests/lib/system/sysinfo_test.py @@ -0,0 +1,258 @@ +#!/usr/bin python3 +""" Pytest unit tests for :mod:`lib.system.sysinfo` """ + +import platform +import typing as T + +from collections import namedtuple +from io import StringIO +from unittest.mock import MagicMock + +import pytest +import pytest_mock + +# pylint:disable=import-error +from lib.gpu_stats import GPUInfo +from lib.system.sysinfo import _Configs, _State, _SysInfo, get_sysinfo +from lib.system import Cuda, Packages, ROCm, System + +# pylint:disable=protected-access + + +# _SysInfo +@pytest.fixture(name="sys_info_instance") +def sys_info_fixture() -> _SysInfo: + """ Single :class:`~lib.system.sysinfo._SysInfo` object for tests """ + return _SysInfo() + + +def test_init(sys_info_instance: _SysInfo) -> None: + """ Test :class:`lib.system.sysinfo._SysInfo` __init__ and attributes """ + assert isinstance(sys_info_instance, _SysInfo) + + attrs = ["_state_file", "_configs", "_system", + "_python", "_packages", "_gpu", "_cuda", "_rocm"] + assert all(a in sys_info_instance.__dict__ for a in attrs) + assert all(a in attrs for a in sys_info_instance.__dict__) + + assert isinstance(sys_info_instance._state_file, str) + assert isinstance(sys_info_instance._configs, str) + assert isinstance(sys_info_instance._system, System) + assert isinstance(sys_info_instance._python, dict) + assert sys_info_instance._python == {"implementation": platform.python_implementation(), + "version": platform.python_version()} + assert isinstance(sys_info_instance._packages, Packages) + assert isinstance(sys_info_instance._gpu, GPUInfo) + assert isinstance(sys_info_instance._cuda, Cuda) + assert isinstance(sys_info_instance._rocm, ROCm) + + +def test_properties(sys_info_instance: _SysInfo) -> None: + """ Test :class:`lib.system.sysinfo._SysInfo` properties """ + ints = ["_ram_free", "_ram_total", "_ram_available", "_ram_used"] + strs = ["_fs_command", "_conda_version", "_git_commits", "_cuda_versions", + "_cuda_version", "_cudnn_versions", "_rocm_version", "_rocm_versions"] + + for prop in ints: + assert hasattr(sys_info_instance, prop), f"sysinfo missing property '{prop}'" + assert isinstance(getattr(sys_info_instance, prop), + int), f"sysinfo property '{prop}' not int" + + for prop in strs: + assert hasattr(sys_info_instance, prop), f"sysinfo missing property '{prop}'" + assert isinstance(getattr(sys_info_instance, prop), + str), f"sysinfo property '{prop}' not str" + + +def test_get_gpu_info(sys_info_instance: _SysInfo) -> None: + """ Test _get_gpu_info method of :class:`lib.system.sysinfo._SysInfo` returns as expected """ + assert hasattr(sys_info_instance, "_get_gpu_info") + gpu_info = sys_info_instance._get_gpu_info() + assert isinstance(gpu_info, GPUInfo) + + +def test__format_ram(sys_info_instance: _SysInfo, monkeypatch: pytest.MonkeyPatch) -> None: + """ Test the _format_ram method of :class:`lib.system.sysinfo._SysInfo` """ + assert hasattr(sys_info_instance, "_format_ram") + svmem = namedtuple("svmem", ["available", "free", "total", "used"]) + data = svmem(12345678, 1234567, 123456789, 123456) + monkeypatch.setattr("psutil.virtual_memory", lambda *args, **kwargs: data) + ram_info = sys_info_instance._format_ram() + + assert isinstance(ram_info, str) + assert ram_info == "Total: 117MB, Available: 11MB, Used: 0MB, Free: 1MB" + + +def test_full_info(sys_info_instance: _SysInfo) -> None: + """ Test the full_info method of :class:`lib.system.sysinfo._SysInfo` returns as expected """ + assert hasattr(sys_info_instance, "full_info") + sys_info = sys_info_instance.full_info() + assert isinstance(sys_info, str) + + sections = ["System Information", "Pip Packages", "Configs"] + for section in sections: + assert section in sys_info, f"Section {section} not in full_info" + if sys_info_instance._system.is_conda: + assert "Conda Packages" in sys_info + else: + assert "Conda Packages" not in sys_info + + keys = ["backend", "os_platform", "os_machine", "os_release", "py_conda_version", + "py_implementation", "py_version", "py_command", "py_virtual_env", "sys_cores", + "sys_processor", "sys_ram", "encoding", "git_branch", "git_commits", + "gpu_cuda_versions", "gpu_cuda", "gpu_cudnn", "gpu_rocm_versions", "gpu_rocm_version", + "gpu_driver", "gpu_devices", "gpu_vram", "gpu_devices_active"] + for key in keys: + assert f"{key}:" in sys_info, f"'{key}:' not in full_info" + + +# get_sys_info +def test_get_sys_info(mocker: pytest_mock.MockerFixture) -> None: + """ Thest that the :func:`~lib.utils.sysinfo.get_sysinfo` function executes correctly """ + sys_info = get_sysinfo() + assert isinstance(sys_info, str) + full_info = mocker.patch("lib.system.sysinfo._SysInfo.full_info") + get_sysinfo() + assert full_info.called + + +# _Configs +@pytest.fixture(name="configs_instance") +def configs_fixture(): + """ Pytest fixture for :class:`~lib.utils.sysinfo._Configs` """ + return _Configs() + + +def test__configs__init__(configs_instance: _Configs) -> None: + """ Test __init__ and attributes for :class:`~lib.utils.sysinfo._Configs` """ + assert hasattr(configs_instance, "config_dir") + assert isinstance(configs_instance.config_dir, str) + assert hasattr(configs_instance, "configs") + assert isinstance(configs_instance.configs, str) + + +def test__configs__get_configs(configs_instance: _Configs) -> None: + """ Test __init__ and attributes for :class:`~lib.utils.sysinfo._Configs` """ + assert hasattr(configs_instance, "_get_configs") + assert isinstance(configs_instance._get_configs(), str) + + +def test__configs__parse_configs(configs_instance: _Configs, + mocker: pytest_mock.MockerFixture) -> None: + """ Test _parse_configs function for :class:`~lib.utils.sysinfo._Configs` """ + assert hasattr(configs_instance, "_parse_configs") + assert isinstance(configs_instance._parse_configs([]), str) + configs_instance._parse_ini = T.cast(MagicMock, mocker.MagicMock()) # type:ignore + configs_instance._parse_json = T.cast(MagicMock, mocker.MagicMock()) # type:ignore + configs_instance._parse_configs(config_files=["test.ini", ".faceswap"]) + assert configs_instance._parse_ini.called + assert configs_instance._parse_json.called + + +def test__configs__parse_ini(configs_instance: _Configs, + monkeypatch: pytest.MonkeyPatch) -> None: + """ Test _parse_ini function for :class:`~lib.utils.sysinfo._Configs` """ + assert hasattr(configs_instance, "_parse_ini") + + file = ("[test.ini_header]\n" + "# Test Header\n\n" + "param = value") + monkeypatch.setattr("builtins.open", lambda *args, **kwargs: StringIO(file)) + + converted = configs_instance._parse_ini("test.ini") + assert isinstance(converted, str) + assert converted == ("\n[test.ini_header]\n" + "param: value\n") + + +def test__configs__parse_json(configs_instance: _Configs, + monkeypatch: pytest.MonkeyPatch) -> None: + """ Test _parse_json function for :class:`~lib.utils.sysinfo._Configs` """ + assert hasattr(configs_instance, "_parse_json") + file = '{"test": "param"}' + monkeypatch.setattr("builtins.open", lambda *args, **kwargs: StringIO(file)) + + converted = configs_instance._parse_json(".file") + assert isinstance(converted, str) + assert converted == ("test: param\n") + + +def test__configs__format_text(configs_instance: _Configs) -> None: + """ Test _format_text function for :class:`~lib.utils.sysinfo._Configs` """ + assert hasattr(configs_instance, "_format_text") + key, val = " test_key ", "test_val " + formatted = configs_instance._format_text(key, val) + assert isinstance(formatted, str) + assert formatted == "test_key: test_val\n" + + +# _State +@pytest.fixture(name="state_instance") +def state_fixture(): + """ Pytest fixture for :class:`~lib.utils.sysinfo._State` """ + return _State() + + +def test__state__init__(state_instance: _State) -> None: + """ Test __init__ and attributes for :class:`~lib.utils.sysinfo._State` """ + assert hasattr(state_instance, "_model_dir") + assert state_instance._model_dir is None + assert hasattr(state_instance, "_trainer") + assert state_instance._trainer is None + assert hasattr(state_instance, "state_file") + assert isinstance(state_instance.state_file, str) + + +def test__state__is_training(state_instance: _State, + monkeypatch: pytest.MonkeyPatch) -> None: + """ Test _is_training function for :class:`~lib.utils.sysinfo._State` """ + assert hasattr(state_instance, "_is_training") + assert isinstance(state_instance._is_training, bool) + assert not state_instance._is_training + monkeypatch.setattr("sys.argv", ["faceswap.py", "train"]) + assert state_instance._is_training + monkeypatch.setattr("sys.argv", ["faceswap.py", "extract"]) + assert not state_instance._is_training + + +def test__state__get_arg(state_instance: _State, + monkeypatch: pytest.MonkeyPatch) -> None: + """ Test _get_arg function for :class:`~lib.utils.sysinfo._State` """ + assert hasattr(state_instance, "_get_arg") + assert state_instance._get_arg("-t", "--test_arg") is None + monkeypatch.setattr("sys.argv", ["test", "command", "-t", "test_option"]) + assert state_instance._get_arg("-t", "--test_arg") == "test_option" + + +def test__state__get_state_file(state_instance: _State, + mocker: pytest_mock.MockerFixture, + monkeypatch: pytest.MonkeyPatch) -> None: + """ Test _get_state_file function for :class:`~lib.utils.sysinfo._State` """ + assert hasattr(state_instance, "_get_state_file") + assert isinstance(state_instance._get_state_file(), str) + + mock_is_training = mocker.patch("lib.system.sysinfo._State._is_training") + + # Not training or missing training arguments + mock_is_training.return_value = False + assert state_instance._get_state_file() == "" + mock_is_training.return_value = False + + monkeypatch.setattr(state_instance, "_model_dir", None) + assert state_instance._get_state_file() == "" + monkeypatch.setattr(state_instance, "_model_dir", "test_dir") + + monkeypatch.setattr(state_instance, "_trainer", None) + assert state_instance._get_state_file() == "" + monkeypatch.setattr(state_instance, "_trainer", "test_trainer") + + # Training but file not found + assert state_instance._get_state_file() == "" + + # State file is just a json dump + file = ('{\n' + ' "test": "json",\n' + '}') + monkeypatch.setattr("os.path.isfile", lambda *args, **kwargs: True) + monkeypatch.setattr("builtins.open", lambda *args, **kwargs: StringIO(file)) + assert state_instance._get_state_file().endswith(file) diff --git a/tests/lib/system/system_test.py b/tests/lib/system/system_test.py new file mode 100644 index 0000000000..e608d4185c --- /dev/null +++ b/tests/lib/system/system_test.py @@ -0,0 +1,256 @@ +#!/usr/bin python3 +""" Pytest unit tests for :mod:`lib.system.system` """ + +import ctypes +import locale +import os +import platform +import sys + +import pytest +import pytest_mock + +# pylint:disable=import-error +import lib.system.system as system_mod +from lib.system.system import _lines_from_command, VALID_PYTHON, Packages, System +# pylint:disable=protected-access + + +def test_valid_python() -> None: + """ Confirm python version has a min and max and that it is Python 3 """ + assert len(VALID_PYTHON) == 2 + assert all(len(v) == 2 for v in VALID_PYTHON) + assert all(isinstance(x, int) for v in VALID_PYTHON for x in v) + assert all(v[0] == 3 for v in VALID_PYTHON) + assert VALID_PYTHON[0] <= VALID_PYTHON[1] + + +def test_lines_from_command(mocker: pytest_mock.MockerFixture) -> None: + """ Confirm lines from command executes as expected """ + input_ = ["test", "input"] + subproc_out = " this \nis\n test\noutput \n" + mock_run = mocker.patch("lib.system.system.run") + mock_run.return_value.stdout = subproc_out + result = _lines_from_command(input_) + assert mock_run.called + assert result == subproc_out.splitlines() + + +# System +@pytest.fixture(name="system_instance") +def system_fixture() -> System: + """ Single :class:`lib.system.System` object for tests """ + return System() + + +def test_system_init(system_instance: System) -> None: + """ Test :class:`lib.system.System` __init__ and attributes """ + assert isinstance(system_instance, System) + + attrs = ["platform", "system", "machine", "release", "processor", "cpu_count", + "python_implementation", "python_version", "python_architecture", "encoding", + "is_conda", "is_admin", "is_virtual_env"] + assert all(a in system_instance.__dict__ for a in attrs) + assert all(a in attrs for a in system_instance.__dict__) + + assert system_instance.platform == platform.platform() + assert system_instance.system == platform.system().lower() + assert system_instance.machine == platform.machine() + assert system_instance.release == platform.release() + assert system_instance.processor == platform.processor() + assert system_instance.cpu_count == os.cpu_count() + assert system_instance.python_implementation == platform.python_implementation() + assert system_instance.python_version == platform.python_version() + assert system_instance.python_architecture == platform.architecture()[0] + assert system_instance.encoding == locale.getpreferredencoding() + assert system_instance.is_conda == ("conda" in sys.version.lower() or + os.path.exists(os.path.join(sys.prefix, "conda-meta"))) + assert isinstance(system_instance.is_admin, bool) + assert isinstance(system_instance.is_virtual_env, bool) + + +def test_system_properties(system_instance: System) -> None: + """ Test :class:`lib.system.System` properties """ + assert hasattr(system_instance, "is_linux") + assert isinstance(system_instance.is_linux, bool) + if platform.system().lower() == "linux": + assert system_instance.is_linux + assert not system_instance.is_macos + assert not system_instance.is_windows + + assert hasattr(system_instance, "is_macos") + assert isinstance(system_instance.is_macos, bool) + if platform.system().lower() == "darwin": + assert system_instance.is_macos + assert not system_instance.is_linux + assert not system_instance.is_windows + + assert hasattr(system_instance, "is_windows") + assert isinstance(system_instance.is_windows, bool) + if platform.system().lower() == "windows": + assert system_instance.is_windows + assert not system_instance.is_linux + assert not system_instance.is_macos + + +def test_system_get_permissions(system_instance: System) -> None: + """ Test :class:`lib.system.System` _get_permissions method """ + assert hasattr(system_instance, "_get_permissions") + is_admin = system_instance._get_permissions() + if platform.system() == "Windows": + assert is_admin == (ctypes.windll.shell32.IsUserAnAdmin() != 0) # type:ignore + else: + assert is_admin == (os.getuid() == 0) # type:ignore # pylint:disable=no-member + + +def test_system_check_virtual_env(system_instance: System, + monkeypatch: pytest.MonkeyPatch) -> None: + """ Test :class:`lib.system.System` _check_virtual_env method """ + system_instance.is_conda = True + monkeypatch.setattr(system_mod.sys, "prefix", "/home/user/miniconda3/envs/testenv") + assert system_instance._check_virtual_env() + monkeypatch.setattr(system_mod.sys, "prefix", "/home/user/miniconda3/bin/") + assert not system_instance._check_virtual_env() + + system_instance.is_conda = False + monkeypatch.setattr(system_mod.sys, "base_prefix", "/home/user/venv/") + monkeypatch.setattr(system_mod.sys, "prefix", "/usr/bin/") + assert system_instance._check_virtual_env() + monkeypatch.setattr(system_mod.sys, "base_prefix", "/usr/bin/") + assert not system_instance._check_virtual_env() + + +def test_system_validate_python(system_instance: System, + monkeypatch: pytest.MonkeyPatch, + mocker: pytest_mock.MockerFixture) -> None: + """ Test :class:`lib.system.System` _validate_python method """ + monkeypatch.setattr(system_mod, "VALID_PYTHON", (((3, 11), (3, 13)))) + monkeypatch.setattr(system_mod.sys, "version_info", (3, 12, 0)) + monkeypatch.setattr("builtins.input", lambda _: "") + system_instance.python_architecture = "64bit" + + assert system_instance.validate_python() + assert system_instance.validate_python(max_version=(3, 12)) + + sys_exit = mocker.patch("lib.system.system.sys.exit") + system_instance.python_architecture = "32bit" + system_instance.validate_python() + assert sys_exit.called + system_instance.python_architecture = "64bit" + + system_instance.validate_python(max_version=(3, 11)) + assert sys_exit.called + + for vers in ((3, 10, 0), (3, 14, 0)): + monkeypatch.setattr(system_mod.sys, "version_info", vers) + system_instance.validate_python() + assert sys_exit.called + + +@pytest.mark.parametrize("system_name, machine, is_conda, should_exit", [ + ("other", "x86_64", False, True), # Unsupported OS + ("darwin", "arm64", True, False), # Apple Silicon inside conda + ("darwin", "arm64", False, True), # Apple Silicon outside conda + ("linux", "x86_64", True, False), # Supported + ("windows", "x86_64", True, False), # Supported + ]) +def test_system_validate(system_instance: System, + mocker: pytest_mock.MockerFixture, + system_name, + machine, + is_conda, + should_exit) -> None: + """ Test :class:`lib.system.System` _validate method """ + validate_python = mocker.patch("lib.system.System.validate_python") + system_instance.system = system_name + system_instance.machine = machine + system_instance.is_conda = is_conda + sys_exit = mocker.patch("lib.system.system.sys.exit") + system_instance.validate() + if should_exit: + assert sys_exit.called + else: + assert not sys_exit.called + assert validate_python.called + + +# Packages +@pytest.fixture(name="packages_instance") +def packages_fixture() -> Packages: + """ Single :class:`lib.system.Packages` object for tests """ + return Packages() + + +def test_packages_init(packages_instance: Packages, mocker: pytest_mock.MockerFixture) -> None: + """ Test :class:`lib.system.Packages` __init__ and attributes """ + assert isinstance(packages_instance, Packages) + + attrs = ["_conda_exe", "_installed_python", "_installed_conda"] + assert all(a in packages_instance.__dict__ for a in attrs) + assert all(a in attrs for a in packages_instance.__dict__) + + assert isinstance(packages_instance._conda_exe, + str) or packages_instance._conda_exe is None + assert isinstance(packages_instance._installed_python, dict) + assert isinstance(packages_instance._installed_conda, + list) or packages_instance._installed_conda is None + + which = mocker.patch("lib.system.system.which") + Packages() + which.assert_called_once_with("conda") + + +def test_packages_properties(packages_instance: Packages) -> None: + """ Test :class:`lib.system.Packages` properties """ + for prop in ("installed_python", "installed_conda"): + assert hasattr(packages_instance, prop) + assert isinstance(getattr(packages_instance, prop), dict) + pretty = f"{prop}_pretty" + assert hasattr(packages_instance, pretty) + assert isinstance(getattr(packages_instance, pretty), str) + + +def test_packages_get_installed_python(packages_instance: Packages, + mocker: pytest_mock.MockerFixture, + monkeypatch: pytest.MonkeyPatch) -> None: + """ Test :class:`lib.system.Packages` get_installed_python method """ + lines_from_command = mocker.patch("lib.system.system._lines_from_command") + monkeypatch.setattr(system_mod.sys, "executable", "python") + out = packages_instance._get_installed_python() + lines_from_command.assert_called_once_with(["python", "-m", "pip", "freeze", "--local"]) + assert isinstance(out, dict) + + monkeypatch.setattr(system_mod, "_lines_from_command", lambda _: ["pacKage1==1.0.0", + "PACKAGE2==1.1.0", + "# Ignored", + "malformed=1.2.3", + "package3==0.2.1"]) + out = packages_instance._get_installed_python() + assert out == {"package1": "1.0.0", "package2": "1.1.0", "package3": "0.2.1"} + + +def test_packages_get_installed_conda(packages_instance: Packages, + mocker: pytest_mock.MockerFixture, + monkeypatch: pytest.MonkeyPatch) -> None: + """ Test :class:`lib.system.Packages` get_installed_conda method """ + packages_instance._conda_exe = None + packages_instance._installed_conda = None + packages_instance._get_installed_conda() + assert packages_instance._installed_conda is None + + packages_instance._conda_exe = "conda" + lines_from_command = mocker.patch("lib.system.system._lines_from_command") + packages_instance._get_installed_conda() + lines_from_command.assert_called_once_with(["conda", "list", "--show-channel-urls"]) + + monkeypatch.setattr(system_mod, "_lines_from_command", lambda _: []) + packages_instance._get_installed_conda() + assert packages_instance._installed_conda == ["Could not get Conda package list"] + + _pkgs = [ + "package1 4.15.0 pypi_0 pypi", + "pkg2 2025b h78e105d_0 conda-forge", + "Packag3 3.1.3 pypi_0 defaults"] + monkeypatch.setattr(system_mod, "_lines_from_command", lambda _: _pkgs) + packages_instance._get_installed_conda() + assert packages_instance._installed_conda == _pkgs diff --git a/tests/lib/training/__init__.py b/tests/lib/training/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/training/augmentation_test.py b/tests/lib/training/augmentation_test.py new file mode 100644 index 0000000000..9687e6b601 --- /dev/null +++ b/tests/lib/training/augmentation_test.py @@ -0,0 +1,524 @@ +#!/usr/bin python3 +""" Pytest unit tests for :mod:`lib.training.augmentation` """ +import typing as T + +import cv2 +import numpy as np +import pytest +import pytest_mock + +from lib.config import ConfigValueType +from lib.training.augmentation import (ConstantsAugmentation, ConstantsColor, ConstantsTransform, + ConstantsWarp, ImageAugmentation) +from plugins.train.trainer import trainer_config as cfg + +# pylint:disable=unused-import +from tests.lib.config.helpers import patch_config # noqa[F401] + +# pylint:disable=protected-access,redefined-outer-name + + +MODULE_PREFIX = "lib.training.augmentation" + + +# CONSTANTS # +_CLAHE_CONF = (({"color_clahe_chance": 12, "color_clahe_max_size": 2}, 64), + ({"color_clahe_chance": 25, "color_clahe_max_size": 4}, 128), + ({"color_clahe_chance": 50, "color_clahe_max_size": 6}, 256), + ({"color_clahe_chance": 75, "color_clahe_max_size": 8}, 384)) + + +@pytest.mark.parametrize(("config", "size"), _CLAHE_CONF, ids=[x[-1] for x in _CLAHE_CONF]) +def test_constants_get_clahe(config: dict[str, T.Any], + size: int, + patch_config) -> None: # noqa[F811] + """ Test ConstantsAugmentation._get_clahe works as expected """ + patch_config(cfg, config) + contrast, chance, max_size = ConstantsAugmentation._get_clahe(size) + assert isinstance(contrast, int) + assert isinstance(chance, float) + assert isinstance(max_size, int) + assert contrast == max(2, size // 128) + assert chance == config["color_clahe_chance"] / 100. + assert max_size == config["color_clahe_max_size"] + + +_LAB_CONF = ({"color_lightness": 30, "color_ab": 8}, + {"color_lightness": 8, "color_ab": 25}, + {"color_lightness": 63, "color_ab": 12}) + + +@pytest.mark.parametrize(("config"), _LAB_CONF) +def test_constants_get_lab(config: dict[str, T.Any], patch_config) -> None: # noqa[F811] + """ Test ConstantsAugmentation._get_lab works as expected """ + patch_config(cfg, config) + lab_adjust = ConstantsAugmentation._get_lab() + assert isinstance(lab_adjust, np.ndarray) + assert lab_adjust.dtype == np.float32 + assert lab_adjust.shape == (3, ) + assert lab_adjust[0] == config["color_lightness"] / 100. + assert lab_adjust[1] == config["color_ab"] / 100. + assert lab_adjust[2] == config["color_ab"] / 100. + + +_CLAHE_LAB_CONF = ( + {"color_clahe_chance": 50, "color_clahe_max_size": 4.0, "color_lightness": 30, "color_ab": 8}, + {"color_clahe_chance": 30, "color_clahe_max_size": 6.0, "color_lightness": 20, "color_ab": 6}, + {"color_clahe_chance": 75, "color_clahe_max_size": 8.0, "color_lightness": 10, "color_ab": 12}) + + +@pytest.mark.parametrize(("config"), _CLAHE_LAB_CONF) +def test_constants_get_color(config: dict[str, T.Any], + patch_config, # noqa[F811] + mocker: pytest_mock.MockerFixture) -> None: + """ Test ConstantsAugmentation._get_color works as expected """ + patch_config(cfg, config) + clahe_mock = mocker.patch(f"{MODULE_PREFIX}.ConstantsAugmentation._get_clahe", + return_value=(1, 2.0, 3)) + lab_mock = mocker.patch(f"{MODULE_PREFIX}.ConstantsAugmentation._get_lab", + return_value=np.array([1.0, 2.0, 3.0], dtype="float32")) + color = ConstantsAugmentation._get_color(256) + clahe_mock.assert_called_once_with(256) + lab_mock.assert_called_once_with() + assert isinstance(color, ConstantsColor) + assert isinstance(color.clahe_base_contrast, int) + assert isinstance(color.clahe_chance, float) + assert isinstance(color.clahe_max_size, int) + assert isinstance(color.lab_adjust, np.ndarray) + + assert color.clahe_base_contrast == clahe_mock.return_value[0] + assert color.clahe_chance == clahe_mock.return_value[1] + assert color.clahe_max_size == clahe_mock.return_value[2] + assert np.all(color.lab_adjust == lab_mock.return_value) + + +_TRANSFORM_CONF = ( + ({"rotation_range": 25, "zoom_amount": 1, "shift_range": 6, "flip_chance": 10}, 64), + ({"rotation_range": 6, "zoom_amount": 2, "shift_range": 5, "flip_chance": 60}, 96), + ({"rotation_range": 39, "zoom_amount": 3, "shift_range": 4, "flip_chance": 23}, 128), + ({"rotation_range": 12, "zoom_amount": 4, "shift_range": 3, "flip_chance": 52}, 256), + ({"rotation_range": 47, "zoom_amount": 5, "shift_range": 2, "flip_chance": 33}, 384), + ({"rotation_range": 3, "zoom_amount": 6, "shift_range": 1, "flip_chance": 44}, 512)) + + +@pytest.mark.parametrize(("config", "size"), _TRANSFORM_CONF) +def test_constants_get_transform(config: dict[str, T.Any], + size: int, + patch_config) -> None: # noqa[F811] + """ Test ConstantsAugmentation._get_transform works as expected """ + patch_config(cfg, config) + transform = ConstantsAugmentation._get_transform(size) + assert isinstance(transform, ConstantsTransform) + assert isinstance(transform.rotation, int) + assert isinstance(transform.zoom, float) + assert isinstance(transform.shift, float) + assert isinstance(transform.flip, float) + assert transform.rotation == config["rotation_range"] + assert transform.zoom == config["zoom_amount"] / 100. + assert transform.shift == (config["shift_range"] / 100.) * size + assert transform.flip == config["flip_chance"] / 100. + + +@pytest.mark.parametrize(("size", "batch_size"), ((64, 16), (384, 32))) +def test_constants_get_warp_to_landmarks(size: int, batch_size: int) -> None: + """ Test ConstantsAugmentation._get_warp_to_landmarks works as expected """ + anchors, grids = ConstantsAugmentation._get_warp_to_landmarks(size, batch_size) + assert isinstance(anchors, np.ndarray) + assert isinstance(grids, np.ndarray) + + assert anchors.dtype == np.int32 + assert anchors.shape == (batch_size, 8, 2) + assert anchors.min() == 0 + assert anchors.max() == size - 1 + + assert grids.dtype == np.float32 + assert grids.shape == (2, size, size) + assert grids.min() == 0. + assert grids.max() == size - 1 + + +@pytest.mark.parametrize(("size", "batch_size"), ((64, 16), (384, 32))) +def test_constants_get_warp(size: int, batch_size: int, mocker: pytest_mock.MockerFixture) -> None: + """ Test ConstantsAugmentation._get_warp works as expected """ + warp_lm_mock = mocker.patch( + f"{MODULE_PREFIX}.ConstantsAugmentation._get_warp_to_landmarks", + return_value=((np.random.random((batch_size, 8, 2)) * 100).astype("int32"), + (np.random.random((2, size, size))).astype("float32"))) + warp_pad = int(1.25 * size) + + warps = ConstantsAugmentation._get_warp(size, batch_size) + + warp_lm_mock.assert_called_once_with(size, batch_size) + + assert isinstance(warps, ConstantsWarp) + + assert isinstance(warps.maps, np.ndarray) + assert warps.maps.dtype == "float32" + assert warps.maps.shape == (batch_size, 2, 5, 5) + assert warps.maps.min() == 0. + assert warps.maps.mean() == size / 2. + assert warps.maps.max() == size + + assert isinstance(warps.pad, tuple) + assert len(warps.pad) == 2 + assert all(isinstance(x, int) for x in warps.pad) + assert all(x == warp_pad for x in warps.pad) + + assert isinstance(warps.slices, slice) + assert warps.slices.step is None + assert warps.slices.start == warp_pad // 10 + assert warps.slices.stop == -warp_pad // 10 + + assert isinstance(warps.scale, float) + assert warps.scale == 5 / 256 * size + + assert isinstance(warps.lm_edge_anchors, np.ndarray) + assert warps.lm_edge_anchors.dtype == warp_lm_mock.return_value[0].dtype + assert warps.lm_edge_anchors.shape == warp_lm_mock.return_value[0].shape + assert np.all(warps.lm_edge_anchors == warp_lm_mock.return_value[0]) + + assert isinstance(warps.lm_grids, np.ndarray) + assert warps.lm_grids.dtype == warp_lm_mock.return_value[1].dtype + assert warps.lm_grids.shape == warp_lm_mock.return_value[1].shape + assert np.all(warps.lm_grids == warp_lm_mock.return_value[1]) + + assert isinstance(warps.lm_scale, float) + assert warps.lm_scale == 2 / 256 * size + + +_CONFIG = T.cast( + dict[str, ConfigValueType], + {"color_clahe_chance": 50, "color_clahe_max_size": 4, "color_lightness": 30, "color_ab": 8, + "rotation_range": 10, "zoom_amount": 5, "shift_range": 5, "flip_chance": 50}) + + +@pytest.mark.parametrize(("size", "batch_size"), ((64, 16), (384, 32))) +def test_constants_from_config(size: int, + batch_size: int, + patch_config, # noqa[F811] + mocker: pytest_mock.MockerFixture + ) -> None: + """ Test that ConstantsAugmentation.from_config executes correctly """ + patch_config(cfg, _CONFIG) + constants = ConstantsAugmentation.from_config(size, batch_size) + assert isinstance(constants, ConstantsAugmentation) + assert isinstance(constants.color, ConstantsColor) + assert isinstance(constants.transform, ConstantsTransform) + assert isinstance(constants.warp, ConstantsWarp) + + color_mock = mocker.patch(f"{MODULE_PREFIX}.ConstantsAugmentation._get_color") + transform_mock = mocker.patch(f"{MODULE_PREFIX}.ConstantsAugmentation._get_transform") + warp_mock = mocker.patch(f"{MODULE_PREFIX}.ConstantsAugmentation._get_warp") + ConstantsAugmentation.from_config(size, batch_size) + color_mock.assert_called_once_with(size) + transform_mock.assert_called_once_with(size) + warp_mock.assert_called_once_with(size, batch_size) + + +# IMAGE AUGMENTATION # +def get_batch(batch_size, size: int) -> np.ndarray: + """ Obtain a batch of random float32 image data for the given batch size and height/width """ + return (np.random.random((batch_size, size, size, 3)) * 255).astype("uint8") + + +def get_instance(batch_size, size) -> ImageAugmentation: + """ Obtain an ImageAugmentation instance for the given batch size and size """ + return ImageAugmentation(batch_size, size) + + +@pytest.mark.parametrize(("size", "batch_size"), ((64, 16), (384, 32))) +def test_image_augmentation_init(size: int, + batch_size: int, + patch_config) -> None: # noqa[F811] + """ Test ImageAugmentation initializes """ + patch_config(cfg, _CONFIG) + attrs = {"_processing_size": int, + "_batch_size": int, + "_constants": ConstantsAugmentation} + instance = get_instance(batch_size, size) + + assert all(x in instance.__dict__ for x in attrs) + assert all(x in attrs for x in instance.__dict__) + assert isinstance(instance._batch_size, int) + assert isinstance(instance._processing_size, int) + assert isinstance(instance._constants, ConstantsAugmentation) + assert instance._batch_size == batch_size + assert instance._processing_size == size + + +@pytest.mark.parametrize(("size", "batch_size"), ((64, 16), (384, 32))) +def test_image_augmentation_random_lab(size: int, + batch_size: int, + patch_config, # noqa[F811] + mocker: pytest_mock.MockerFixture) -> None: + """ Test that ImageAugmentation._random_lab executes as expected """ + patch_config(cfg, _CONFIG) + batch = get_batch(batch_size, size) + original = batch.copy() + instance = get_instance(batch_size, size) + + instance._random_lab(batch) + assert original.shape == batch.shape + assert original.dtype == batch.dtype + assert not np.allclose(original, batch) + + randoms_mock = mocker.patch(f"{MODULE_PREFIX}.np.random.uniform") + instance._random_lab(batch) + randoms_mock.assert_called_once() + + +@pytest.mark.parametrize(("size", "batch_size"), ((64, 16), (384, 32))) +def test_image_augmentation_random_clahe(size: int, # pylint:disable=too-many-locals + batch_size: int, + patch_config, # noqa[F811] + mocker: pytest_mock.MockerFixture) -> None: + """ Test that ImageAugmentation._random_clahe executes as expected """ + # Expected output + patch_config(cfg, _CONFIG) + batch = get_batch(batch_size, size) + original = batch.copy() + instance = get_instance(batch_size, size) + + instance._random_clahe(batch) + assert original.shape == batch.shape + assert original.dtype == batch.dtype + assert not np.allclose(original, batch) + + # Functions called + rand_ret = np.random.rand(batch_size) + rand_mock = mocker.patch(f"{MODULE_PREFIX}.np.random.rand", + return_value=rand_ret) + + where_ret = np.where(rand_ret < instance._constants.color.clahe_chance) + where_mock = mocker.patch(f"{MODULE_PREFIX}.np.where", + return_value=where_ret) + + randint_ret = np.random.randint(instance._constants.color.clahe_max_size, + size=where_ret[0].shape[0], + dtype="uint8") + randint_mock = mocker.patch(f"{MODULE_PREFIX}.np.random.randint", + return_value=randint_ret) + + grid_sizes = (randint_ret * + (instance._constants.color.clahe_base_contrast // + 2)) + instance._constants.color.clahe_base_contrast + clahe_calls = [mocker.call(clipLimit=2.0, tileGridSize=(grid, grid)) for grid in grid_sizes] + clahe_mock = mocker.patch(f"{MODULE_PREFIX}.cv2.createCLAHE", + return_value=cv2.createCLAHE(clipLimit=2.0, tileGridSize=(3, 3))) + + batch = get_batch(batch_size, size) + instance._random_clahe(batch) + + rand_mock.assert_called_once_with(batch_size) + where_mock.assert_called_once() + randint_mock.assert_called_once_with(instance._constants.color.clahe_max_size + 1, + size=where_ret[0].shape[0], + dtype="uint8") + clahe_mock.assert_has_calls(clahe_calls) # type:ignore + + +@pytest.mark.parametrize(("size", "batch_size"), ((64, 16), (384, 32))) +def test_image_augmentation_color_adjust(size: int, + batch_size: int, + patch_config, # noqa[F811] + mocker: pytest_mock.MockerFixture) -> None: + """ Test that ImageAugmentation._color_adjust executes as expected """ + patch_config(cfg, _CONFIG) + batch = get_batch(batch_size, size) + output = get_instance(batch_size, size).color_adjust(batch) + assert output.shape == batch.shape + assert output.dtype == batch.dtype + assert not np.allclose(output, batch) + + batch_convert_mock = mocker.patch(f"{MODULE_PREFIX}.batch_convert_color") + lab_mock = mocker.patch(f"{MODULE_PREFIX}.ImageAugmentation._random_lab") + clahe_mock = mocker.patch(f"{MODULE_PREFIX}.ImageAugmentation._random_clahe") + + batch = get_batch(batch_size, size) + get_instance(batch_size, size).color_adjust(batch) + + assert batch_convert_mock.call_count == 2 + lab_mock.assert_called_once() + clahe_mock.assert_called_once() + + +@pytest.mark.parametrize(("size", "batch_size"), ((64, 16), (384, 32))) +def test_image_augmentation_transform(size: int, + batch_size: int, + patch_config, # noqa[F811] + mocker: pytest_mock.MockerFixture) -> None: + """ Test that ImageAugmentation.transform executes as expected """ + patch_config(cfg, _CONFIG) + batch = get_batch(batch_size, size) + instance = get_instance(batch_size, size) + original = batch.copy() + instance.transform(batch) + + assert original.shape == batch.shape + assert original.dtype == batch.dtype + assert not np.allclose(original, batch) + + rand_ret = [np.random.uniform(-10, 10, size=batch_size).astype("float32"), + np.random.uniform(.95, 1.05, size=batch_size).astype("float32"), + np.random.uniform(-9.2, 9.2, size=(batch_size, 2)).astype("float32")] + rand_calls = [mocker.call(-instance._constants.transform.rotation, + instance._constants.transform.rotation, + size=batch_size), + mocker.call(1 - instance._constants.transform.zoom, + 1 + instance._constants.transform.zoom, + size=batch_size), + mocker.call(-instance._constants.transform.shift, + instance._constants.transform.shift, + size=(batch_size, 2))] + rand_mock = mocker.patch(f"{MODULE_PREFIX}.np.random.uniform", + side_effect=rand_ret) + + rotmat_mock = mocker.patch( + f"{MODULE_PREFIX}.cv2.getRotationMatrix2D", + return_value=np.array([[1.0, 0.0, -2.0], [-1.0, 1.0, 5.0]]).astype("float32")) + + affine_mock = mocker.patch(f"{MODULE_PREFIX}.cv2.warpAffine") + + batch = get_batch(batch_size, size) + get_instance(batch_size, size).transform(batch) + + rand_mock.assert_has_calls(rand_calls) # type:ignore + assert rotmat_mock.call_count == batch_size + assert affine_mock.call_count == batch_size + + +@pytest.mark.parametrize(("size", "batch_size"), ((64, 16), (384, 32))) +def test_image_augmentation_random_flip(size: int, + batch_size: int, + patch_config, # noqa[F811] + mocker: pytest_mock.MockerFixture) -> None: + """ Test that ImageAugmentation.flip_chance executes as expected """ + patch_config(cfg, _CONFIG) + batch = get_batch(batch_size, size) + original = batch.copy() + get_instance(batch_size, size).random_flip(batch) + + assert original.shape == batch.shape + assert original.dtype == batch.dtype + assert not np.allclose(original, batch) + + rand_ret = np.random.rand(batch_size) + rand_mock = mocker.patch(f"{MODULE_PREFIX}.np.random.rand", return_value=rand_ret) + where_mock = mocker.patch(f"{MODULE_PREFIX}.np.where") + + batch = get_batch(batch_size, size) + get_instance(batch_size, size).random_flip(batch) + + rand_mock.assert_called_once_with(batch_size) + where_mock.assert_called_once() + + +@pytest.mark.parametrize(("size", "batch_size"), ((64, 16), (384, 32))) +def test_image_augmentation_random_warp(size: int, + batch_size: int, + mocker: pytest_mock.MockerFixture) -> None: + """ Test that ImageAugmentation._random_warp executes as expected """ + batch = get_batch(batch_size, size) + instance = get_instance(batch_size, size) + output = instance._random_warp(batch) + + assert output.shape == batch.shape + assert output.dtype == batch.dtype + assert not np.allclose(output, batch) + + rand_ret = np.random.normal(size=(batch_size, 2, 5, 5), scale=0.02).astype("float32") + rand_mock = mocker.patch(f"{MODULE_PREFIX}.np.random.normal", return_value=rand_ret) + + eval_ret = np.ones_like(rand_ret) + eval_mock = mocker.patch(f"{MODULE_PREFIX}.ne.evaluate", return_value=eval_ret) + + resize_ret = np.ones((size, size)).astype("float32") + resize_mock = mocker.patch(f"{MODULE_PREFIX}.cv2.resize", return_value=resize_ret) + + remap_mock = mocker.patch(f"{MODULE_PREFIX}.cv2.remap") + + instance._random_warp(batch) + + rand_mock.assert_called_once_with(size=(batch_size, 2, 5, 5), + scale=instance._constants.warp.scale) + eval_mock.assert_called_once() + assert resize_mock.call_count == batch_size * 2 + assert remap_mock.call_count == batch_size + + +@pytest.mark.parametrize(("size", "batch_size"), ((64, 16), (384, 32))) +def test_image_augmentation_random_warp_landmarks(size: int, + batch_size: int, + mocker: pytest_mock.MockerFixture) -> None: + """ Test that ImageAugmentation._random_warp_landmarks executes as expected """ + src_points = np.random.random(size=(batch_size, 68, 2)).astype("float32") * size + dst_points = np.random.random(size=(batch_size, 68, 2)).astype("float32") * size + + batch = get_batch(batch_size, size) + instance = get_instance(batch_size, size) + output = instance._random_warp_landmarks(batch, src_points, dst_points) + + assert output.shape == batch.shape + assert output.dtype == batch.dtype + assert not np.allclose(output, batch) + + rand_ret = np.random.normal(size=dst_points.shape, scale=0.01) + rand_mock = mocker.patch(f"{MODULE_PREFIX}.np.random.normal", return_value=rand_ret) + + hull_ret = [cv2.convexHull(np.concatenate([src[17:], dst[17:]], axis=0)) + for src, dst in zip(src_points.astype("int32"), + (dst_points + rand_ret).astype("int32"))] + hull_mock = mocker.patch(f"{MODULE_PREFIX}.cv2.convexHull", side_effect=hull_ret) + + remap_mock = mocker.patch(f"{MODULE_PREFIX}.cv2.remap") + + instance._random_warp_landmarks(batch, src_points, dst_points) + + rand_mock.assert_called_once_with(size=(dst_points.shape), + scale=instance._constants.warp.lm_scale) + assert hull_mock.call_count == batch_size + assert remap_mock.call_count == batch_size + + +@pytest.mark.parametrize(("size", "batch_size", "to_landmarks"), + ((64, 16, True), (384, 32, False))) +def test_image_augmentation_warp(size: int, + batch_size: int, + to_landmarks: bool, + mocker: pytest_mock.MockerFixture) -> None: + """ Test that ImageAugmentation.warp executes as expected """ + kwargs = {} + if to_landmarks: + kwargs["batch_src_points"] = np.random.random( + size=(batch_size, 68, 2)).astype("float32") * size + kwargs["batch_dst_points"] = np.random.random( + size=(batch_size, 68, 2)).astype("float32") * size + batch = get_batch(batch_size, size) + output = get_instance(batch_size, size).warp(batch, to_landmarks, **kwargs) + + assert output.shape == batch.shape + assert output.dtype == batch.dtype + assert not np.allclose(output, batch) + + if to_landmarks: + with pytest.raises(AssertionError): + get_instance(batch_size, size).warp(batch, + to_landmarks, + batch_src_points=kwargs["batch_src_points"], + batch_dst_points=None) + with pytest.raises(AssertionError): + get_instance(batch_size, size).warp(batch, + to_landmarks, + batch_src_points=None, + batch_dst_points=kwargs["batch_dst_points"]) + + warp_mock = mocker.patch(f"{MODULE_PREFIX}.ImageAugmentation._random_warp") + warp_lm_mock = mocker.patch(f"{MODULE_PREFIX}.ImageAugmentation._random_warp_landmarks") + + get_instance(batch_size, size).warp(batch, to_landmarks, **kwargs) + if to_landmarks: + warp_mock.assert_not_called() + warp_lm_mock.assert_called_once() + else: + warp_mock.assert_called_once() + warp_lm_mock.assert_not_called() diff --git a/tests/lib/training/cache_test.py b/tests/lib/training/cache_test.py new file mode 100644 index 0000000000..afd3cef079 --- /dev/null +++ b/tests/lib/training/cache_test.py @@ -0,0 +1,964 @@ +#!/usr/bin python3 +""" Pytest unit tests for :mod:`lib.training.cache` """ +import os +import typing as T + +from threading import Lock + +import numpy as np +import pytest +import pytest_mock + +from lib.align.constants import LandmarkType +from lib.training import cache as cache_mod +from lib.utils import FaceswapError +from plugins.train import train_config as cfg + + +from tests.lib.config.helpers import patch_config # # pylint:disable=unused-import # noqa[F401] + +# pylint:disable=protected-access,invalid-name,redefined-outer-name + + +# ## HELPERS ### + +MODULE_PREFIX = "lib.training.cache" +_DUMMY_IMAGE_LIST = ["/path/to/img1.png", "~/img2.png", "img3.png"] + + +def _get_config(centering="face", vertical_offset=0): + """ Return a fresh valid config """ + return {"centering": centering, + "vertical_offset": vertical_offset} + + +STANDARD_CACHE_ARGS = (_DUMMY_IMAGE_LIST, 256, 1.0) +STANDARD_MASK_ARGS = (256, 1.0, "face") + + +# ## MASK PROCESSING ### + +def get_mask_config(penalized_mask_loss=True, + learn_mask=True, + mask_type="extended", + mask_dilation=1.0, + mask_kernel=3, + mask_threshold=4, + mask_eye_multiplier=2, + mask_mouth_multiplier=3): + """ Generate the mask config dictionary with the given arguments """ + return {"penalized_mask_loss": penalized_mask_loss, + "learn_mask": learn_mask, + "mask_type": mask_type, + "mask_dilation": mask_dilation, + "mask_blur_kernel": mask_kernel, + "mask_threshold": mask_threshold, + "eye_multiplier": mask_eye_multiplier, + "mouth_multiplier": mask_mouth_multiplier} + + +_MASK_CONFIG_PARAMS = ( + (get_mask_config(True, True, "extended", 1.0, 3, 4, 2, 3), "pass-penalize|learn"), + (get_mask_config(True, False, "components", 0.0, 5, 4, 1, 2), "pass-penalize"), + (get_mask_config(False, True, "custom", -2.0, 6, 1, 3, 1), "pass-learn"), + (get_mask_config(True, True, None, 1.0, 6, 1, 3, 2), "pass-mask-disable1"), + (get_mask_config(False, False, "extended", 1.0, 6, 1, 3, 2), "pass-mask-disable2"), + (get_mask_config(True, True, "extended", 1.0, 1, 3, 1, 1), "pass-multiplier-disable"), + (get_mask_config("Error", True, "extended", 1.0, 1, 3, 2, 3), "fail-penalize"), + (get_mask_config(True, 1.4, "extended", 1.0, 1, 3, 2, 3), "fail-learn"), + (get_mask_config(True, True, 999, 1.0, 1, 3, 2, 3), "fail-type"), + (get_mask_config(True, True, "extended", 23, 1, 3, 2, 3), "fail-dilation"), + (get_mask_config(True, True, "extended", 1.0, 1.2, 3, 2, 3), "fail-kernel"), + (get_mask_config(True, True, "extended", 1.0, 1, "fail", 2, 3), "fail-threshold"), + (get_mask_config(True, True, "extended", 1.0, 1, 3, 3.9, 3), "fail-eye-multi"), + (get_mask_config(True, True, "extended", 1.0, 1, 3, 2, "fail"), "fail-mouth-multi")) +_MASK_CONFIG_IDS = [x[-1] for x in _MASK_CONFIG_PARAMS] + + +@pytest.mark.parametrize(("config", "status"), _MASK_CONFIG_PARAMS, ids=_MASK_CONFIG_IDS) +def test_MaskConfig(config: dict[str, T.Any], + status: str, + patch_config) -> None: # noqa[F811] + """ Test that cache._MaskConfig dataclass initializes from config """ + patch_config(cfg.Loss, config) + retval = cache_mod._MaskConfig() + if status.startswith("pass-mask-disable"): + assert not retval.mask_enabled + else: + assert retval.mask_enabled + + if status == "pass-multiplier-disable" or not config["penalized_mask_loss"]: + assert not retval.multiplier_enabled + else: + assert retval.multiplier_enabled + + +_MASK_INIT_PARAMS = ((64, 0.5, "face", "pass"), + (128, 0.75, "head", "pass"), + (384, 1.0, "legacy", "pass"), + (69.42, 0.75, "head", "fail-size"), + (128, "fail", "head", "fail-coverage"), + (128, 0.75, "fail", "fail-centering")) +_MASK_INIT_IDS = [x[-1] for x in _MASK_INIT_PARAMS] + + +@pytest.mark.parametrize(("size", "coverage", "centering", "status"), + _MASK_INIT_PARAMS, ids=_MASK_INIT_IDS) +def test_MaskProcessing_init(size, + coverage, + centering, + status: str, + mocker: pytest_mock.MockerFixture) -> None: + """ Test cache._MaskProcessing correctly initializes """ + mock_maskconfig = mocker.MagicMock() + mocker.patch(f"{MODULE_PREFIX}._MaskConfig", new=mock_maskconfig) + + if not status == "pass": + with pytest.raises(AssertionError): + cache_mod._MaskProcessing(size, coverage, centering) + return + + instance = cache_mod._MaskProcessing(size, coverage, centering) + attrs = {"_size": int, + "_coverage": float, + "_centering": str, + "_config": mocker.MagicMock} # Our mocked _MaskConfig + + for attr, dtype in attrs.items(): + assert attr in instance.__dict__ + assert isinstance(instance.__dict__[attr], dtype) + assert all(x in attrs for x in instance.__dict__) + + assert instance._size == size + assert instance._coverage == coverage + assert instance._centering == centering + mock_maskconfig.assert_called_once() + + +def test_MaskProcessing_check_mask_exists(mocker: pytest_mock.MockerFixture) -> None: + """ Test cache._MaskProcessing._check_mask_exists functions as expected """ + mock_det_face = mocker.MagicMock() + mock_det_face.mask = ["extended", "components"] + + instance = cache_mod._MaskProcessing(*STANDARD_MASK_ARGS) # type:ignore[arg-type] + + instance._check_mask_exists("", mock_det_face) + + mock_det_face.mask = [] + with pytest.raises(FaceswapError): + instance._check_mask_exists("", mock_det_face) + + +@pytest.mark.parametrize(("dilation", "kernel", "threshold"), + ((1.0, 3, 4), (-2.5, 5, 2), (3.3, 7, 9))) +def test_MaskProcessing_preprocess(dilation: float, + kernel: int, + threshold: int, + mocker: pytest_mock.MockerFixture, + patch_config) -> None: # noqa[F811] + """ Test cache._MaskProcessing._preprocess functions as expected """ + mock_mask = mocker.MagicMock() + mock_det_face = mocker.MagicMock() + mock_det_face.mask = {"extended": mock_mask} + + patch_config(cfg.Loss, get_mask_config(mask_dilation=dilation, + mask_kernel=kernel, + mask_threshold=threshold)) + + instance = cache_mod._MaskProcessing(*STANDARD_MASK_ARGS) # type:ignore[arg-type] + instance._preprocess(mock_det_face, "extended") + mock_mask.set_dilation.assert_called_once_with(dilation) + mock_mask.set_blur_and_threshold.assert_called_once_with(blur_kernel=kernel, + threshold=threshold) + + +@pytest.mark.parametrize( + ("mask_centering", "train_centering", "coverage", "y_offset", "size", "mask_size"), + (("face", "legacy", 0.75, 0.0, 256, 64), + ("legacy", "head", 0.66, -0.25, 128, 128), + ("head", "face", 1.0, 0.33, 64, 256))) +def test_MaskProcessing_crop_and_resize(mask_centering: str, # pylint:disable=too-many-locals + train_centering: T.Literal["legacy", "face", "head"], + coverage: float, + y_offset: float, + size: int, + mask_size: int, + mocker: pytest_mock.MockerFixture) -> None: + """ Test cache._MaskProcessing._crop_and_resize functions as expected """ + mock_pose = mocker.MagicMock() + mock_pose.offset = {"face": "face_centering", + "legacy": "legacy_centering", + "head": "head_centering"} + + mock_det_face = mocker.MagicMock() + mock_det_face.aligned.pose = mock_pose + mock_det_face.aligned.y_offset = y_offset + + mock_face_mask = mocker.MagicMock() + mock_face_mask.__get_item__ = mock_face_mask + mock_face_mask.shape = (mask_size, mask_size) + + mock_mask = mocker.MagicMock() + mock_mask.stored_centering = mask_centering + mock_mask.stored_size = mask_size + mock_mask.mask = mock_face_mask + + mock_cv2_resize_result = mocker.MagicMock() + mock_cv2_resize_item = mocker.MagicMock() + mock_cv2_resize = mocker.patch(f"{MODULE_PREFIX}.cv2.resize", + return_value=mock_cv2_resize_result) + mock_cv2_resize_result.__getitem__.return_value = mock_cv2_resize_item + + mock_cv2_cubic = mocker.patch(f"{MODULE_PREFIX}.cv2.INTER_CUBIC") + mock_cv2_area = mocker.patch(f"{MODULE_PREFIX}.cv2.INTER_AREA") + + instance = cache_mod._MaskProcessing(size, coverage, train_centering) + + retval = instance._crop_and_resize(mock_det_face, mock_mask) + mock_mask.set_sub_crop.assert_called_once_with(mock_pose.offset[mask_centering], + mock_pose.offset[train_centering], + train_centering, + coverage, + y_offset) + if mask_size == size: + assert retval is mock_face_mask + mock_cv2_resize.assert_not_called() + return + + assert retval is mock_cv2_resize_item + interp_used = mock_cv2_cubic if mask_size < size else mock_cv2_area + mock_cv2_resize.assert_called_once_with(mock_face_mask, + (size, size), + interpolation=interp_used) + + +@pytest.mark.parametrize("mask_type", (None, "extended", "components")) +def test_MaskProcessing_get_face_mask(mask_type: str | None, + mocker: pytest_mock.MockerFixture, + patch_config) -> None: # noqa[F811] + """ Test cache._MaskProcessing._get_face_mask functions as expected """ + patch_config(cfg, _get_config()) + patch_config(cfg.Loss, get_mask_config(mask_type=mask_type)) + instance = cache_mod._MaskProcessing(*STANDARD_MASK_ARGS) # type:ignore[arg-type] + assert instance._config.mask_type == mask_type # sanity check + + instance._check_mask_exists = mocker.MagicMock() # type:ignore[method-assign] + preprocess_return = "test_preprocess_return" + instance._preprocess = mocker.MagicMock( # type:ignore[method-assign] + return_value="test_preprocess_return") + crop_and_resize_return = mocker.MagicMock() + crop_and_resize_return.shape = (256, 256, 1) + instance._crop_and_resize = mocker.MagicMock( # type:ignore[method-assign] + return_value=crop_and_resize_return) + + filename = "test_filename" + detected_face = "test_detected_face" + + if mask_type is None: # Mask disabled + assert not instance._config.mask_enabled + retval1 = instance._get_face_mask(filename, detected_face) # type:ignore[arg-type] + assert retval1 is None + instance._check_mask_exists.assert_not_called() # type:ignore[attr-defined] + instance._preprocess.assert_not_called() # type:ignore[attr-defined] + instance._crop_and_resize.assert_not_called() # type:ignore[attr-defined] + else: # Mask enabled + assert instance._config.mask_enabled + retval2 = instance._get_face_mask(filename, detected_face) # type:ignore[arg-type] + assert retval2 is crop_and_resize_return + instance._check_mask_exists.assert_called_once_with( # type:ignore[attr-defined] + filename, detected_face) + + instance._preprocess.assert_called_once_with( # type:ignore[attr-defined] + detected_face, instance._config.mask_type) + + instance._crop_and_resize.assert_called_once_with( # type:ignore[attr-defined] + detected_face, preprocess_return) + + +@pytest.mark.parametrize(("eye_multiplier", "mouth_multiplier", "size", "enabled"), + ((0, 0, 64, False), + (1, 1, 64, False), + (1, 2, 64, True), + (2, 1, 96, True), + (2, 3, 128, True), + (3, 1, 256, True))) +def test_MaskProcessing_get_localized_mask(eye_multiplier: int, + mouth_multiplier: int, + size: int, + enabled: bool, + mocker: pytest_mock.MockerFixture, + patch_config) -> None: # noqa[F811] + """ Test cache._MaskProcessing._get_localized_mask functions as expected """ + args = STANDARD_MASK_ARGS[1:] + patch_config(cfg.Loss, get_mask_config(mask_eye_multiplier=eye_multiplier, + mask_mouth_multiplier=mouth_multiplier)) + instance = cache_mod._MaskProcessing(size, *args) # type:ignore[arg-type] + + filename = "filename" + detected_face = mocker.MagicMock() + landmark_mask_return_value = mocker.MagicMock() + + detected_face.get_landmark_mask = mocker.MagicMock(return_value=landmark_mask_return_value) + + for area in ("mouth", "eye"): + retval = instance._get_localized_mask(filename, detected_face, area) + if not enabled: + assert retval is None + detected_face.get_landmark_mask.assert_not_called() + else: + assert retval is landmark_mask_return_value + + if enabled: + detected_face.get_landmark_mask.assert_called_with(area, size // 16, 2.5) + if enabled: + assert detected_face.get_landmark_mask.call_count == 2 + + +def test_MaskProcessing_call(mocker: pytest_mock.MockerFixture) -> None: + """ Test cache._MaskProcessing.__call__ functions as expected """ + instance = cache_mod._MaskProcessing(*STANDARD_MASK_ARGS) # type:ignore[arg-type] + face_return = "face_mask" + area_return = "area_mask" + instance._get_face_mask = mocker.MagicMock( # type:ignore[method-assign] + return_value=face_return) # type:ignore[method-assign] + instance._get_localized_mask = mocker.MagicMock( # type:ignore[method-assign] + return_value=area_return) # type:ignore[method-assign] + + filename = "test_filename" + detected_face = mocker.MagicMock() + detected_face.store_training_masks = mocker.MagicMock() + + instance(filename, detected_face) + + instance._get_face_mask.assert_called_once_with( # type:ignore[attr-defined] + filename, detected_face) + + expected_localized_calls = [mocker.call(filename, detected_face, "eye"), + mocker.call(filename, detected_face, "mouth")] + instance._get_localized_mask.assert_has_calls( # type:ignore[attr-defined] + expected_localized_calls, any_order=False) # pyright:ignore[reportArgumentType] + assert instance._get_localized_mask.call_count == 2 # type:ignore[attr-defined] + + detected_face.store_training_masks.assert_called_once_with( + [face_return, area_return, area_return], + delete_masks=True) + + +# ## CACHE PROCESSING ### + +@pytest.fixture +def face_cache_reset_scenario(mocker: pytest_mock.MockerFixture, + request: pytest.FixtureRequest): + """ Build a scenario for cache._check_reset. + + request.param = {"caches": dict(Literal["a", "b"], bool], + "side": Literal["a", "b"]} + + If the key "a" or "b" exist in the caches dict, then that cache exists in the mocked + cache._FACE_CACHES with a mock representing the return value of the cache.Cache.check_reset() + value as given + + The mocked Cache item for the currently testing side is returned, or a default mocked item if + the given side is not meant to be in the _FACE_CACHES dict + """ + cache_dict = {} + for side, val in request.param["caches"].items(): + check_mock = mocker.MagicMock() + check_mock.check_reset.return_value = val + cache_dict[side] = check_mock + mocker.patch(f"{MODULE_PREFIX}._FACE_CACHES", new=cache_dict) + return cache_dict.get(request.param["side"], mocker.MagicMock()) + + +_RESET_PARAMS = [({"side": side, "caches": caches}, expected, f"{name}-{side}") + for side in ("a", "b") + for caches, expected, name in [ + ({}, False, "no-cache"), + ({"a": False}, False, "a-exists"), + ({"b": False}, False, "b-exists"), + ({"a": True, "b": False}, side == "b", "a-reset"), + ({"a": False, "b": True}, side == "a", "b-reset"), + ({"a": True, "b": True}, True, "both-reset"), + ({"a": False, "b": False}, False, "no-reset")]] +_RESET_IDS = [x[-1] for x in _RESET_PARAMS] +_RESET_PARAMS = [x[:-1] for x in _RESET_PARAMS] # type:ignore[misc] + + +@pytest.mark.parametrize(("face_cache_reset_scenario", "expected"), + _RESET_PARAMS, + ids=_RESET_IDS, + indirect=["face_cache_reset_scenario"]) +def test_check_reset(face_cache_reset_scenario, expected): # pylint:disable=redefined-outer-name + """ Test that cache._check_reset functions as expected """ + this_cache = face_cache_reset_scenario + assert cache_mod._check_reset(this_cache) == expected + + +@pytest.mark.parametrize( + ("filenames", "size", "coverage_ratio", "centering"), + [(_DUMMY_IMAGE_LIST, 256, 1.0, "face"), + (_DUMMY_IMAGE_LIST[:-1], 96, .75, "head"), + (_DUMMY_IMAGE_LIST[2:], 384, .66, "legacy")]) +def test_Cache_init(filenames, size, coverage_ratio, centering, patch_config): # noqa[F811] + """ Test that cache.Cache correctly initializes """ + attrs = {"_lock": type(Lock()), + "_cache_info": dict, + "_config": cache_mod._CacheConfig, + "_partially_loaded": list, + "_image_count": int, + "_cache": dict, + "_aligned_landmarks": dict, + "_extract_version": float, + "_mask_prepare": cache_mod._MaskProcessing} + patch_config(cfg, _get_config(centering=centering)) + instance = cache_mod.Cache(filenames, size, coverage_ratio) + + for attr, attr_type in attrs.items(): + assert attr in instance.__dict__ + assert isinstance(getattr(instance, attr), attr_type) + for key in instance.__dict__: + assert key in attrs + + assert set(instance._cache_info) == {"cache_full", "has_reset"} + assert all(x is False for x in instance._cache_info.values()) + + assert not instance._partially_loaded + assert not instance._cache + assert instance._image_count == len(filenames) + assert not instance._aligned_landmarks + assert instance._extract_version == 0.0 + assert instance._config.size == size + assert instance._config.centering == centering + assert instance._config.coverage == coverage_ratio + + +def test_Cache_cache_full(mocker: pytest_mock.MockerFixture): + """ Test that cache.Cache.cache_full property behaves correctly """ + instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) + instance._lock = mocker.MagicMock() + + is_full1 = instance.cache_full + assert not is_full1 + instance._lock.__enter__.assert_called_once() # type:ignore[attr-defined] + instance._lock.__exit__.assert_called_once() # type:ignore[attr-defined] + + instance._cache_info["cache_full"] = True + is_full2 = instance.cache_full + assert is_full2 + # lock not called when cache is full + instance._lock.__enter__.assert_called_once() # type:ignore[attr-defined] + instance._lock.__exit__.assert_called_once() # type:ignore[attr-defined] + + +def test_Cache_aligned_landmarks(mocker: pytest_mock.MockerFixture): + """ Test that cache.Cache.aligned_landmarks property behaves correcly """ + instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) + instance._lock = mocker.MagicMock() + for fname in _DUMMY_IMAGE_LIST: + mock_face = mocker.MagicMock() + mock_face.aligned.landmarks = f"landmarks_for_{fname}" + instance._cache[fname] = mock_face + + retval1 = instance.aligned_landmarks + assert len(_DUMMY_IMAGE_LIST) == len(retval1) + assert retval1 == {fname: f"landmarks_for_{fname}" for fname in _DUMMY_IMAGE_LIST} + instance._lock.__enter__.assert_called_once() # type:ignore[attr-defined] + instance._lock.__exit__.assert_called_once() # type:ignore[attr-defined] + + retval2 = instance.aligned_landmarks + assert len(_DUMMY_IMAGE_LIST) == len(retval1) + assert retval2 == {fname: f"landmarks_for_{fname}" for fname in _DUMMY_IMAGE_LIST} + # lock not called after first call has populated + instance._lock.__enter__.assert_called_once() # type:ignore[attr-defined] + instance._lock.__exit__.assert_called_once() # type:ignore[attr-defined] + + +@pytest.mark.parametrize("size", (64, 96, 128, 256, 384)) +def test_Cache_size(size): + """ Test that cache.Cache.size property returns correctly """ + instance = cache_mod.Cache(_DUMMY_IMAGE_LIST, size, 1.0) + assert instance.size == size + + +def test_Cache_check_reset(): + """ Test that cache.Cache.check_reset behaves correctly """ + instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) + retval1 = instance.check_reset() + assert not retval1 + assert not instance._cache_info["has_reset"] + + instance._cache_info["has_reset"] = True + retval2 = instance.check_reset() + assert retval2 + assert not instance._cache_info["has_reset"] + + +@pytest.mark.parametrize("filenames", + (_DUMMY_IMAGE_LIST, _DUMMY_IMAGE_LIST[:-1], _DUMMY_IMAGE_LIST[2:])) +def test_Cache_get_items(filenames: list[str]) -> None: + """ Test that cache.Cache.get_items returns correctly """ + instance = cache_mod.Cache(filenames, 256, 1.0) + instance._cache = {os.path.basename(f): f"faces_for_{f}" # type:ignore[misc] + for f in filenames} + + retval = instance.get_items(filenames) + assert retval == [f"faces_for_{f}" for f in filenames] + + +@pytest.mark.parametrize("set_flag", (True, False), ids=("set-flag", "no-set-flag")) +def test_Cache_reset_cache(set_flag: bool, + mocker: pytest_mock.MockerFixture, + patch_config) -> None: # noqa[F811] + """ Test that cache.Cache._reset_cache functions correctly """ + patch_config(cfg, _get_config(centering="head")) + mock_warn = mocker.MagicMock() + mocker.patch(f"{MODULE_PREFIX}.logger.warning", mock_warn) + instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) + instance._cache = {"test": "cache"} # type:ignore[dict-item] + instance._cache_info["cache_full"] = True + + assert instance._config.centering != "legacy" + assert instance._cache + assert instance._cache_info["cache_full"] + + instance._reset_cache(set_flag) + + assert instance._config.centering == "legacy" + assert not instance._cache + assert instance._cache_info["cache_full"] is False + + if set_flag: + mock_warn.assert_called_once() + + +@pytest.mark.parametrize("png_meta", + ({"source": {"alignments_version": 1.0}}, + {"source": {"alignments_version": 2.0}}, + {"source": {"alignments_version": 2.2}}), + ids=("v1.0", "v2.0", "v2.2")) +def test_Cache_validate_version(png_meta, mocker): + """ Test that cache.Cache._validate_version executes correctly """ + instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) + instance._reset_cache = mocker.MagicMock() + fname = "test_filename.png" + version = png_meta["source"]["alignments_version"] + + if version == 1.0: + for centering in ("legacy", "face"): + instance._extract_version = 0.0 + instance._config.centering = centering + instance._validate_version(png_meta, fname) + if centering == "legacy": + instance._reset_cache.assert_not_called() + else: + instance._reset_cache.assert_called_once_with(True) + assert instance._extract_version == version + else: + instance._validate_version(png_meta, fname) + instance._reset_cache.assert_not_called() + assert instance._extract_version == version + + instance._extract_version = 1.0 # Legacy alignments have been seen + if version > 1.0: # Newer alignments inbound + with pytest.raises(FaceswapError): + instance._validate_version(png_meta, fname) + else: + instance._validate_version(png_meta, fname) + + instance._extract_version = 2.0 # Newer alignments have been seen + if version < 2.0: # Legacy alignments inbound + with pytest.raises(FaceswapError): + instance._validate_version(png_meta, fname) + return # Exit early on 1.0 because cannot pass any more tests + + instance._validate_version(png_meta, fname) + if version > 2.0: + assert instance._extract_version == 2.0 # Defaulted to lowest version + + instance._extract_version = 2.5 + instance._validate_version(png_meta, fname) + assert instance._extract_version == version # Defaulted to lowest version + + +_DET_FACE_PARAMS = ((64, 0.5, 0, 1.0), + (96, 0.75, 1, 1.0), + (256, 0.66, 2, 2.0), + (384, 1.0, 3.0, 2.2)) +_DET_FACE_IDS = [f"size:{x[0]}|coverage:{x[1]}|y-offset:{x[2]}|extract-vers:{x[3]}" + for x in _DET_FACE_PARAMS] + + +@pytest.mark.parametrize(("size", "coverage", "y_offset", "extract_version"), + _DET_FACE_PARAMS, + ids=_DET_FACE_IDS) +def test_Cache_load_detected_face(size: int, + coverage: float, + y_offset: int | float, + extract_version: float, + mocker: pytest_mock.MockerFixture, + patch_config) -> None: # noqa[F811] + """ Test that cache.Cache._load_detected_faces executes correctly """ + patch_config(cfg, _get_config(vertical_offset=y_offset)) + instance = cache_mod.Cache(_DUMMY_IMAGE_LIST, size, coverage) + instance._extract_version = extract_version + alignments = {} # type:ignore[var-annotated] + + mock_det_face = mocker.MagicMock() + mock_det_face.from_png_meta = mocker.MagicMock() + mock_det_face.load_aligned = mocker.MagicMock() + mocker.patch(f"{MODULE_PREFIX}.DetectedFace", return_value=mock_det_face) + + retval = instance._load_detected_face("", alignments) # type:ignore[arg-type] + assert retval is mock_det_face + mock_det_face.from_png_meta.assert_called_once_with(alignments) + mock_det_face.load_aligned.assert_called_once_with(None, + size=instance._config.size, + centering=instance._config.centering, + coverage_ratio=instance._config.coverage, + y_offset=y_offset / 100., + is_aligned=True, + is_legacy=extract_version == 1.0) + + +@pytest.mark.parametrize("partially_loaded", (True, False), ids=("partial", "full")) +def test_Cache_populate_cache(partially_loaded: bool, + mocker: pytest_mock.MockerFixture) -> None: + """ Test that cache.Cache._populate_cache executes correctly """ + already_cached = ["/path/to/img4.png", "/path/img5.png"] + needs_cache = _DUMMY_IMAGE_LIST + filenames = _DUMMY_IMAGE_LIST + already_cached + metadata = [{"alignments": f"{f}_alignments"} for f in filenames] + + instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) + instance._validate_version = mocker.MagicMock() # type:ignore[method-assign] + instance._mask_prepare = mocker.MagicMock() + instance._cache = {os.path.basename(f): "existing" # type:ignore[misc] + for f in filenames if f not in needs_cache} + + mock_detected_faces = {f: mocker.MagicMock() for f in needs_cache} + + if partially_loaded: + instance._cache.update({os.path.basename(f): mock_detected_faces[f] for f in needs_cache}) + instance._partially_loaded = [os.path.basename(f) for f in filenames] # Add our partials + else: + instance._load_detected_face = mocker.MagicMock( # type:ignore[method-assign] + side_effect=[mock_detected_faces[f] for f in needs_cache]) + + # Call the function + instance._populate_cache(needs_cache, metadata, filenames) # type:ignore[arg-type] + + expected_validate = [mocker.call(metadata[idx], f) for idx, f in enumerate(needs_cache)] + instance._validate_version.assert_has_calls(expected_validate, # type:ignore[attr-defined] + any_order=False) + assert instance._validate_version.call_count == len(needs_cache) # type:ignore[attr-defined] + + expected_mask_prepare = [mocker.call(f, mock_detected_faces[f]) for f in needs_cache] + instance._mask_prepare.assert_has_calls(expected_mask_prepare, # type:ignore[attr-defined] + any_order=False) + assert instance._mask_prepare.call_count == len(needs_cache) # type:ignore[attr-defined] + + assert len(instance._cache) == len(filenames) + for filename in filenames: + key = os.path.basename(filename) + assert key in instance._cache + if filename in needs_cache: # item got added/updated + assert instance._cache[key] == mock_detected_faces[filename] + else: # item pre-existed + assert instance._cache[key] == "existing" + + if partially_loaded: + assert instance._partially_loaded == [os.path.basename(f) for f in filenames + if f not in needs_cache] + + +@pytest.mark.parametrize("scenario", ("read-error", "size-error", "success")) +def test_Cache_get_batch_with_metadata(scenario: str, mocker: pytest_mock.MockerFixture) -> None: + """ Test that cache.Cache._get_batch_with_metadata executes correctly """ + instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) + filenames = ["list", "of", "test", "filenames"] + + mock_read_image_batch = mocker.MagicMock() + if scenario == "read-error": + mock_read_image_batch.side_effect = ValueError("inhomogeneous") + else: + mock_return = (mocker.MagicMock(), {"test": "meta"}) + if scenario == "size-error": + mock_return[0].shape = (len(filenames), ) + else: + mock_return[0].shape = (len(filenames), 64, 64, 3) + mock_read_image_batch.return_value = mock_return + + mocker.patch(f"{MODULE_PREFIX}.read_image_batch", new=mock_read_image_batch) + + if scenario != "success": + with pytest.raises(FaceswapError): + instance._get_batch_with_metadata(filenames) + mock_read_image_batch.assert_called_once_with(filenames, with_metadata=True) + return + + retval = instance._get_batch_with_metadata(filenames) + mock_read_image_batch.assert_called_once_with(filenames, with_metadata=True) + assert retval == mock_return # pyright:ignore[reportPossiblyUnboundVariable] + + +@pytest.mark.parametrize("scenario", ("full", "not-full", "partial")) +def test_Cache_update_cache_full(scenario: bool, mocker: pytest_mock.MockerFixture) -> None: + """ Test that cache.Cache._update_cache_full executes correctly """ + mock_verbose = mocker.patch(f"{MODULE_PREFIX}.logger.verbose") + filenames = ["test", "file", "names"] + instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) + instance._image_count = 10 + + assert instance._cache_info["cache_full"] is False + assert not instance._cache + assert not instance._partially_loaded + + if scenario == "full": + instance._cache = {i: i for i in range(10)} # type:ignore[misc] + if scenario == "patial": + instance._cache = {i: i for i in range(10)} # type:ignore[misc] + instance._partially_loaded = filenames.copy() + + instance._update_cache_full(filenames) + + if scenario == "full": + assert instance._cache_info["cache_full"] is True + mock_verbose.assert_called_once() + else: + assert instance._cache_info["cache_full"] is False + mock_verbose.assert_not_called() + + +@pytest.mark.parametrize("scenario", ("full", "partial", "empty", "needs-reset")) +def test_Cache_cache_metadata(scenario: str, mocker: pytest_mock.MockerFixture) -> None: + """ Test that cache.Cache.cache_metadata executes correctly """ + mock_check_reset = mocker.patch(f"{MODULE_PREFIX}._check_reset") + mock_check_reset.return_value = scenario == "needs-reset" + mock_return_batch = mocker.MagicMock() + + mock_read_image_batch = mocker.patch(f"{MODULE_PREFIX}.read_image_batch", + return_value=mock_return_batch) + + instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) + filenames = _DUMMY_IMAGE_LIST.copy() + + if scenario in ("full", "partial"): + instance._cache = {os.path.basename(f): f for f in filenames} # type:ignore[misc] + if scenario == "partial": + instance._partially_loaded = [os.path.basename(f) for f in filenames] + + instance._lock = mocker.MagicMock() + instance._reset_cache = mocker.MagicMock() # type:ignore[method-assign] + returned_meta = {"test": "meta"} + instance._get_batch_with_metadata = mocker.MagicMock( # type:ignore[method-assign] + return_value=(mock_return_batch, returned_meta)) + instance._populate_cache = mocker.MagicMock() # type:ignore[method-assign] + instance._update_cache_full = mocker.MagicMock() # type:ignore[method-assign] + + retval = instance.cache_metadata(filenames) # Call + + instance._lock.__enter__.assert_called_once() # type:ignore[attr-defined] + instance._lock.__exit__.assert_called_once() # type:ignore[attr-defined] + + mock_check_reset.assert_called_once_with(instance) + + if scenario == "needs-reset": + instance._reset_cache.assert_called_once_with(False) # type:ignore[attr-defined] + else: + instance._reset_cache.assert_not_called() # type:ignore[attr-defined] + + if scenario == "full": + mock_read_image_batch.assert_called_once_with(filenames) + instance._get_batch_with_metadata.assert_not_called() # type:ignore[attr-defined] + instance._populate_cache.assert_not_called() # type:ignore[attr-defined] + instance._update_cache_full.assert_not_called() # type:ignore[attr-defined] + else: + mock_read_image_batch.assert_not_called() + instance._get_batch_with_metadata.assert_called_once_with( # type:ignore[attr-defined] + filenames) + instance._populate_cache.assert_called_once_with( # type:ignore[attr-defined] + filenames, returned_meta, filenames) + instance._update_cache_full.assert_called_once_with(filenames) # type:ignore[attr-defined] + + assert retval is mock_return_batch + + +@pytest.mark.parametrize("scenario", ("fail-meta", "fail-landmarks", "success")) +def test_Cache_pre_fill(scenario: str, mocker: pytest_mock.MockerFixture) -> None: + """ Test that cache.Cache.prefill executes correctly """ + filenames = _DUMMY_IMAGE_LIST.copy() + mock_read_image_batch = mocker.patch(f"{MODULE_PREFIX}.read_image_meta_batch") + side_effect_read_image_batch = [(f, {}) for f in filenames] # type:ignore[var-annotated] + if scenario != "fail-meta": # Set successful return data + for effect in side_effect_read_image_batch: + effect[1]["itxt"] = {"alignments": [1, 2, 3]} + mock_read_image_batch.side_effect = [side_effect_read_image_batch] + + instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) + instance._lock = mocker.MagicMock() + instance._validate_version = mocker.MagicMock() # type:ignore[method-assign] + mock_detected_faces = [mocker.MagicMock() for _ in filenames] + + for m in mock_detected_faces: + m.aligned.landmark_type = (LandmarkType.LM_2D_68 if scenario == "success" else "fail") + instance._load_detected_face = mocker.MagicMock( # type:ignore[method-assign] + side_effect=mock_detected_faces) + + if scenario in ("fail-meta", "fail-landmarks"): + with pytest.raises(FaceswapError): + instance.pre_fill(filenames, "a") + instance._lock.__enter__.assert_called_once() # type:ignore[attr-defined] + instance._lock.__exit__.assert_called_once() # type:ignore[attr-defined] + mock_read_image_batch.assert_called_once_with(filenames) + if scenario == "fail-meta": + instance._validate_version.assert_not_called() # type:ignore[attr-defined] + instance._load_detected_face.assert_not_called() # type:ignore[attr-defined] + else: + meta = side_effect_read_image_batch[0][1]["itxt"] + instance._validate_version.assert_called_once_with( # type:ignore[attr-defined] + meta, filenames[0]) + instance._load_detected_face.assert_called_once_with( # type:ignore[attr-defined] + filenames[0], meta["alignments"]) + return + + # success + instance.pre_fill(filenames, "a") + instance._lock.__enter__.assert_called_once() # type:ignore[attr-defined] + instance._lock.__exit__.assert_called_once() # type:ignore[attr-defined] + mock_read_image_batch.assert_called_once_with(filenames) + + fname_calls = [x[0] for x in side_effect_read_image_batch] + meta_calls = [x[1]["itxt"] for x in side_effect_read_image_batch] + call_validate = [mocker.call(l, f) for f, l in zip(fname_calls, meta_calls)] + call_det_face = [mocker.call(f, l["alignments"]) for f, l in zip(fname_calls, meta_calls)] + + instance._validate_version.assert_has_calls( # type:ignore[attr-defined] + call_validate, any_order=False) # type:ignore[attr-defined] + assert instance._validate_version.call_count == len(filenames) # type:ignore[attr-defined] + instance._load_detected_face.assert_has_calls( # type:ignore[attr-defined] + call_det_face, any_order=False) # type:ignore[attr-defined] + assert instance._load_detected_face.call_count == len(filenames) # type:ignore[attr-defined] + + assert instance._cache == {os.path.basename(f): d for f, d in zip(filenames, + mock_detected_faces)} + assert instance._partially_loaded == [os.path.basename(f) for f in filenames] + + +_PARAMS_GET = (("a", _DUMMY_IMAGE_LIST, 256, 1.), + ("b", _DUMMY_IMAGE_LIST, 256, 1.), + ("c", _DUMMY_IMAGE_LIST, 256, 1.), + ("a", None, 256, 1,), + ("a", _DUMMY_IMAGE_LIST, None, 1.), + ("a", _DUMMY_IMAGE_LIST, 256, None)) +_IDS_GET = ("pass-a", "pass-b", "fail-side", "fail-no-filenames", + "fail-no-size", "fail-no-coverage") + + +@pytest.mark.parametrize(("side", "filenames", "size", "coverage_ratio", "status"), + (x + (y,) for x, y in zip(_PARAMS_GET, _IDS_GET)), + ids=_IDS_GET) +def test_get_cache_initial(side: str, + filenames: list[str], + size: int, + coverage_ratio: float, + status: str, + mocker: pytest_mock.MockerFixture) -> None: + """ Test cache.get_cache function when the cache does not yet exist """ + mocker.patch(f"{MODULE_PREFIX}._FACE_CACHES", new={}) + patched_cache = mocker.patch(f"{MODULE_PREFIX}.Cache") + if status.startswith("fail"): + with pytest.raises(AssertionError): + cache_mod.get_cache(side, filenames, size, coverage_ratio) # type:ignore[arg-type] + patched_cache.assert_not_called() + return + + retval = cache_mod.get_cache(side, filenames, size, coverage_ratio) # type:ignore[arg-type] + assert side in cache_mod._FACE_CACHES + patched_cache.assert_called_once_with(filenames, size, coverage_ratio) + assert cache_mod._FACE_CACHES[side] is patched_cache.return_value + assert retval is patched_cache.return_value + + retval2 = cache_mod.get_cache(side, filenames, size, coverage_ratio) # type:ignore[arg-type] + patched_cache.assert_called_once() # Not called again + assert retval2 is retval + + +_IDS_GET2 = ("pass-a", "pass-b", "fail-side", "pass-no-filenames", + "pass-no-size", "pass-no-coverage") + + +@pytest.mark.parametrize(("side", "filenames", "size", "coverage_ratio", "status"), + (x + (y,) for x, y in zip(_PARAMS_GET, _IDS_GET2)), + ids=_IDS_GET2) +def test_get_cache_exists(side: str, + filenames: list[str], + size: int, + coverage_ratio: float, + status: str, + mocker: pytest_mock.MockerFixture) -> None: + """ Test cache.get_cache function when the cache exists """ + mocker.patch(f"{MODULE_PREFIX}._FACE_CACHES", new={"a": mocker.MagicMock(), + "b": mocker.MagicMock()}) + patched_cache = mocker.patch(f"{MODULE_PREFIX}.Cache") + + if status.startswith("fail"): + with pytest.raises(AssertionError): + cache_mod.get_cache(side, filenames, size, coverage_ratio) # type:ignore[arg-type] + patched_cache.assert_not_called() + return + + retval = cache_mod.get_cache(side, filenames, size, coverage_ratio) # type:ignore[arg-type] + patched_cache.assert_not_called() + assert retval is cache_mod._FACE_CACHES[side] + + +# ## Ring Buffer ## # + +_RING_BUFFER_PARAMS = ((2, (384, 384, 3), 2, "uint8"), + (16, (128, 128, 3), 5, "float32"), + (32, (64, 64, 3), 4, "int32")) +_RING_BUFFER_IDS = [f"bs{x[0]}|{x[1][0]}px|buffer-size{x[2]}|dtype-{x[3]}" + for x in _RING_BUFFER_PARAMS] + + +@pytest.mark.parametrize(("batch_size", "image_shape", "buffer_size", "dtype"), + ((2, (384, 384, 3), 2, "uint8"), + (16, (128, 128, 3), 5, "float32"), + (32, (64, 64, 3), 4, "int32")), + ids=_RING_BUFFER_IDS) +def test_RingBuffer_init(batch_size, image_shape, buffer_size, dtype): + """ test cache.RingBuffer initializes correctly """ + attrs = {"_max_index": int, "_index": int, "_buffer": list} + instance = cache_mod.RingBuffer(batch_size, image_shape, buffer_size, dtype) + + for attr, attr_type in attrs.items(): + assert attr in instance.__dict__ + assert isinstance(getattr(instance, attr), attr_type) + for key in instance.__dict__: + assert key in attrs + + assert instance._max_index == buffer_size - 1 + assert instance._index == 0 + assert len(instance._buffer) == buffer_size + assert all(isinstance(b, np.ndarray) for b in instance._buffer) + assert all(b.shape == (batch_size, *image_shape) for b in instance._buffer) + assert all(b.dtype == dtype for b in instance._buffer) + + +@pytest.mark.parametrize(("batch_size", "image_shape", "buffer_size", "dtype"), + ((2, (384, 384, 3), 2, "uint8"), + (16, (128, 128, 3), 5, "float32"), + (32, (64, 64, 3), 4, "int32")), + ids=_RING_BUFFER_IDS) +def test_RingBuffer_call(batch_size, image_shape, buffer_size, dtype): + """ Test calling cache.RingBuffer works correctly """ + instance = cache_mod.RingBuffer(batch_size, image_shape, buffer_size, dtype) + for i in range(buffer_size * 3): + retval = instance() + assert isinstance(retval, np.ndarray) + assert retval.shape == (batch_size, *image_shape) + assert retval.dtype == dtype + if i % buffer_size == buffer_size - 1: + assert instance._index == 0 + else: + assert instance._index == i % buffer_size + 1 diff --git a/tests/lib/training/lr_finder_test.py b/tests/lib/training/lr_finder_test.py new file mode 100644 index 0000000000..c2914707e6 --- /dev/null +++ b/tests/lib/training/lr_finder_test.py @@ -0,0 +1,270 @@ +#! /usr/env/bin/python3 +""" Unit tests for Learning Rate Finder. """ + +import pytest +import pytest_mock + +import numpy as np + +from lib.training.lr_finder import LearningRateFinder +from plugins.train import train_config as cfg + +# pylint:disable=unused-import +from tests.lib.config.helpers import patch_config # noqa:[F401] + +# pylint:disable=protected-access,invalid-name,redefined-outer-name + + +@pytest.fixture +def _trainer_mock(patch_config, mocker: pytest_mock.MockFixture): # noqa:[F811] + """ Generate a mocked model and feeder object and patch user config items """ + def _apply_patch(iters=1000, mode="default", strength="default"): + patch_config(cfg, {"lr_finder_iterations": iters}) + patch_config(cfg, {"lr_finder_mode": mode}) + patch_config(cfg, {"lr_finder_strength": strength}) + trainer = mocker.MagicMock() + model = mocker.MagicMock() + model.name = "TestModel" + optimizer = mocker.MagicMock() + trainer._plugin.model = model + trainer._plugin.model.model.optimizer = optimizer + return trainer, model, optimizer + return _apply_patch + + +_STRENGTH_LOOKUP = {"default": 10, "aggressive": 5, "extreme": 2.5} + + +_LR_CONF = ((20, "graph_and_set", "default"), + (500, "set", "aggressive"), + (1000, "graph_and_exit", "extreme")) +_LR_CONF_PARAMS = ("iters", "mode", "strength") + +_LR_CMDS = ((4, 0.98), (8, 0.66), (2, 0.33) + ) +_LR_CMDS_PARAMS = ("stop_factor", "beta") +_LR_CMDS_IDS = [f"stop:{x[0]}|beta:{x[1]}" for x in _LR_CMDS] + + +@pytest.mark.parametrize(_LR_CONF_PARAMS, _LR_CONF) +@pytest.mark.parametrize(_LR_CMDS_PARAMS, _LR_CMDS, ids=_LR_CMDS_IDS) +def test_LearningRateFinder_init(iters, mode, strength, stop_factor, beta, _trainer_mock): + """ Test lib.train.LearingRateFinder.__init__ """ + trainer, model, optimizer = _trainer_mock(iters, mode, strength) + lrf = LearningRateFinder(trainer, stop_factor=stop_factor, beta=beta) + assert lrf._trainer is trainer + assert lrf._model is model + assert lrf._optimizer is optimizer + assert lrf._start_lr == 1e-10 + assert lrf._stop_factor == stop_factor + assert lrf._beta == beta + + +_BATCH_END = ((1, 0.01, 1e-5, 0.5), + (27, 0.01, 1e-5, 1e-6), + (42, 0.001, 1e-5, 0.002),) +_BATCH_END_PARAMS = ("iteration", "loss", "learning_rate", "best") +_BATCH_END_IDS = [f"iter:{x[0]}|loss:{x[1]}|lr:{x[2]}" for x in _BATCH_END] + + +@pytest.mark.parametrize(_LR_CMDS_PARAMS, _LR_CMDS, ids=_LR_CMDS_IDS) +@pytest.mark.parametrize(_BATCH_END_PARAMS, _BATCH_END, ids=_BATCH_END_IDS) +def test_LearningRateFinder_on_batch_end(iteration, + loss, + learning_rate, + best, + stop_factor, + beta, + _trainer_mock, + mocker): + """ Test lib.train.LearingRateFinder._on_batch_end """ + trainer, model, optimizer = _trainer_mock() + lrf = LearningRateFinder(trainer, stop_factor=stop_factor, beta=beta) + optimizer.learning_rate.assign = mocker.MagicMock() + optimizer.learning_rate.numpy = mocker.MagicMock(return_value=learning_rate) + + initial_avg = lrf._loss["avg"] + lrf._loss["best"] = best + lrf._on_batch_end(iteration, loss) + + assert lrf._metrics["learning_rates"][-1] == learning_rate + assert lrf._loss["avg"] == (lrf._beta * initial_avg) + ((1 - lrf._beta) * loss) + assert lrf._metrics["losses"][-1] == lrf._loss["avg"] / (1 - (lrf._beta ** iteration)) + + if iteration > 1 and lrf._metrics["losses"][-1] > lrf._stop_factor * lrf._loss["best"]: + assert model.model.stop_training is True + optimizer.learning_rate.assign.assert_not_called() + return + + if iteration == 1: + assert lrf._loss["best"] == lrf._metrics["losses"][-1] + + assert model.model.stop_training is not True + optimizer.learning_rate.assign.assert_called_with( + learning_rate * lrf._lr_multiplier) + + +@pytest.mark.parametrize(_LR_CONF_PARAMS, _LR_CONF) +def test_LearningRateFinder_train(iters, # pylint:disable=too-many-locals + mode, + strength, + _trainer_mock, + mocker): + """ Test lib.train.LearingRateFinder._train """ + trainer, _, _ = _trainer_mock(iters, mode, strength) + + mock_loss_return = np.random.rand(2).tolist() + trainer.train_one_batch = mocker.MagicMock(return_value=mock_loss_return) + + lrf = LearningRateFinder(trainer) + + lrf._on_batch_end = mocker.MagicMock() + lrf._update_description = mocker.MagicMock() + + lrf._train() + + trainer.train_one_batch.assert_called() + assert trainer.train_one_batch.call_count == iters + + train_call_args = [mocker.call(x + 1, mock_loss_return[0]) for x in range(iters)] + assert lrf._on_batch_end.call_args_list == train_call_args + + lrf._update_description.assert_called() + assert lrf._update_description.call_count == iters + + # NaN break + mock_loss_return = (np.nan, np.nan) + trainer.train_one_batch = mocker.MagicMock(return_value=mock_loss_return) + + lrf._train() + + assert trainer.train_one_batch.call_count == 1 # Called once + + assert lrf._update_description.call_count == iters # Not called + assert lrf._on_batch_end.call_count == iters # Not called + + +def test_LearningRateFinder_rebuild_optimizer(_trainer_mock): + """ Test lib.train.LearingRateFinder._rebuild_optimizer """ + trainer, _, _ = _trainer_mock() + lrf = LearningRateFinder(trainer) + + class Dummy: + """ Dummy Optimizer""" + name = "test" + + def get_config(self): + """Dummy get_config""" + return {} + + opt = Dummy() + new_opt = lrf._rebuild_optimizer(opt) + assert isinstance(new_opt, Dummy) and opt is not new_opt + + +@pytest.mark.parametrize(_LR_CONF_PARAMS, _LR_CONF) +@pytest.mark.parametrize("new_lr", (1e-4, 3.5e-5, 9.3e-6)) +def test_LearningRateFinder_reset_model(iters, mode, strength, new_lr, _trainer_mock, mocker): + """ Test lib.train.LearingRateFinder._reset_model """ + trainer, model, optimizer = _trainer_mock(iters, mode, strength) + model.state.add_lr_finder = mocker.MagicMock() + model.state.save = mocker.MagicMock() + model.model.load_weights = mocker.MagicMock() + + old_optimizer = optimizer + new_optimizer = mocker.MagicMock() + + def compile_side_effect(*args, **kwargs): # pylint:disable=unused-argument + """ Side effect for model.compile""" + model.model.optimizer = new_optimizer + + model.model.compile.side_effect = compile_side_effect + + lrf = LearningRateFinder(trainer) + lrf._rebuild_optimizer = mocker.MagicMock() + + lrf._reset_model(1e-5, new_lr) + + model.state.add_lr_finder.assert_called_with(new_lr) + model.state.save.assert_called_once() + + if mode == "graph_and_exit": + lrf._rebuild_optimizer.assert_not_called() + model.model.compile.assert_not_called() + model.model.load_weights.assert_not_called() + assert model.model.optimizer is old_optimizer + new_optimizer.learning_rate.assign.assert_not_called() + else: + lrf._rebuild_optimizer.assert_called_once_with(old_optimizer) + model.model.load_weights.assert_called_once() + model.model.compile.assert_called_once() + assert model.model.optimizer is new_optimizer + new_optimizer.learning_rate.assign.assert_called_once_with(new_lr) + + +_LR_FIND = ( + (True, [0.100, 0.050, 0.025], 0.025, [1e-5, 1e-4, 1e-3], "model_exist"), + (False, [0.100, 0.050, 0.025], 0.025, [1e-5, 1e-4, 1e-3], "no_model"), + (True, [0.100, 0.050, 0.025], 0.025, [1e-5, 1e-4, 1e-10], "low_lr"), + ) +_LR_PARAMS_FIND = ("exists", "losses", "best", "learning_rates") + + +@pytest.mark.parametrize(_LR_PARAMS_FIND, + [x[:-1] for x in _LR_FIND], + ids=[x[-1] for x in _LR_FIND]) +@pytest.mark.parametrize(_LR_CONF_PARAMS, _LR_CONF) +@pytest.mark.parametrize(_LR_CMDS_PARAMS, _LR_CMDS[0:1]) +def test_LearningRateFinder_find(iters, # pylint:disable=too-many-arguments,too-many-positional-arguments # noqa[E501] + mode, + strength, + stop_factor, + beta, + exists, + losses, + best, + learning_rates, + _trainer_mock, + mocker): + """ Test lib.train.LearingRateFinder.find """ + # pylint:disable=too-many-locals + trainer, model, optimizer = _trainer_mock(iters, mode, strength) + model.io.model_exists = exists + model.io.save = mocker.MagicMock() + original_lr = float(np.random.rand()) + optimizer.learning_rate.numpy = mocker.MagicMock(return_value=original_lr) + optimizer.learning_rate.assign = mocker.MagicMock() + mocker.patch("shutil.rmtree") + + lrf = LearningRateFinder(trainer, stop_factor=stop_factor, beta=beta) + + train_mock = mocker.MagicMock() + plot_mock = mocker.MagicMock() + reset_mock = mocker.MagicMock() + lrf._train = train_mock + lrf._plot_loss = plot_mock + lrf._reset_model = reset_mock + + lrf._metrics = {"losses": losses, "learning_rates": learning_rates} + lrf._loss = {"best": best} + + result = lrf.find() + + if exists: + model.io.save_assert_not_called() + else: + model.io.save.assert_called_once() + + optimizer.learning_rate.assign.assert_called_with(lrf._start_lr) + train_mock.assert_called_once() + + new_lr = learning_rates[losses.index(best)] / _STRENGTH_LOOKUP[strength] + if new_lr < 1e-9: + plot_mock.assert_not_called() + reset_mock.assert_not_called() + assert not result + return + + plot_mock.assert_called_once() + reset_mock.assert_called_once_with(original_lr, new_lr) + assert result diff --git a/tests/lib/training/lr_warmup_test.py b/tests/lib/training/lr_warmup_test.py new file mode 100644 index 0000000000..c1150c8dae --- /dev/null +++ b/tests/lib/training/lr_warmup_test.py @@ -0,0 +1,181 @@ +#!/usr/bin python3 +""" Pytest unit tests for :mod:`lib.training.lr_warmup` """ + +import pytest +import pytest_mock + +from keras.layers import Input, Dense +from keras.models import Model +from keras.optimizers import SGD + +from lib.training import LearningRateWarmup + + +# pylint:disable=protected-access,redefined-outer-name + + +@pytest.fixture +def model_fixture(): + """ Model fixture for testing LR Warmup """ + inp = Input((4, 4, 3)) + var_x = Dense(8)(inp) + model = Model(inputs=inp, outputs=var_x) + model.compile(optimizer=SGD(), loss="mse") + return model + + +_LR_STEPS = [(1e-5, 100), + (3.4e-6, 250), + (9e-4, 599), + (6e-5, 1000)] +_LR_STEPS_IDS = [f"lr:{x[0]}|steps:{x[1]}" for x in _LR_STEPS] + + +@pytest.mark.parametrize(("target_lr", "steps"), _LR_STEPS, ids=_LR_STEPS_IDS) +def test_init(model_fixture: Model, target_lr: float, steps: int) -> None: + """ Test class initializes correctly """ + instance = LearningRateWarmup(model_fixture, target_lr, steps) + + attrs = ["_model", "_target_lr", "_steps", "_current_lr", "_current_step", "_reporting_points"] + assert all(a in instance.__dict__ for a in attrs) + assert all(a in attrs for a in instance.__dict__) + assert instance._current_lr == 0.0 + assert instance._current_step == 0 + + assert isinstance(instance._model, Model) + assert instance._target_lr == target_lr + assert instance._steps == steps + + assert len(instance._reporting_points) == 11 + assert all(isinstance(x, int) for x in instance._reporting_points) + assert instance._reporting_points == [int(steps * i / 10) for i in range(11)] + + +_NOTATION = [(1e-5, "1.0e-05"), + (3.45489e-6, "3.5e-06"), + (0.0004, "4.0e-04"), + (0.1234, "1.2e-01")] + + +@pytest.mark.parametrize(("value", "expected"), _NOTATION, ids=[x[1] for x in _NOTATION]) +def test_format_notation(value: float, expected: str) -> None: + """ Test floats format to string correctly """ + result = LearningRateWarmup._format_notation(value) + assert result == expected + + +_LR_STEPS_CURRENT = [(1e-5, 100, 79), + (3.4e-6, 250, 250), + (9e-4, 599, 0), + (6e-5, 1000, 12)] +_LR_STEPS_CURRENT_IDS = [f"lr:{x[0]}|steps:{x[1]}|current_step:{x[2]}" for x in _LR_STEPS_CURRENT] + + +@pytest.mark.parametrize(("target_lr", "steps", "current_step"), + _LR_STEPS_CURRENT, + ids=_LR_STEPS_CURRENT_IDS) +def test_set_current_learning_rate(model_fixture: Model, + target_lr: float, + steps: int, + current_step: int) -> None: + """ Test that learning rate is set correctly """ + instance = LearningRateWarmup(model_fixture, target_lr, steps) + instance._current_step = current_step + instance._set_learning_rate() + + assert instance._current_lr == instance._current_step / instance._steps * instance._target_lr + assert instance._model.optimizer.learning_rate.value.cpu().numpy() == instance._current_lr + + +_STEPS_CURRENT = [(1000, 1, "start"), + (250, 250, "end"), + (500, 69, "unreported"), + (1000, 200, "reported")] +_STEPS_CURRENT_ID = [f"steps:{x[0]}|current_step:{x[1]}|action:{x[2]}" for x in _STEPS_CURRENT] + + +@pytest.mark.parametrize(("steps", "current_step", "action"), + _STEPS_CURRENT, + ids=_STEPS_CURRENT_ID) +def test_output_status(model_fixture: Model, + steps: int, + current_step: int, + action: str, + mocker: pytest_mock.MockerFixture) -> None: + """ Test that information is output correctly """ + mock_logger = mocker.patch("lib.training.lr_warmup.logger.info") + mock_print = mocker.patch("builtins.print") + instance = LearningRateWarmup(model_fixture, 5e-5, steps) + instance._current_step = current_step + instance._format_notation = mocker.MagicMock() # type:ignore[method-assign] + + instance._output_status() + + if action == "unreported": + assert current_step not in instance._reporting_points + mock_logger.assert_not_called() + instance._format_notation.assert_not_called() # type:ignore[attr-defined] + mock_print.assert_not_called() + return + + mock_logger.assert_called_once() + log_message: str = mock_logger.call_args.args[0] + assert log_message.startswith("[Learning Rate Warmup] ") + + instance._format_notation.assert_called() # type:ignore[attr-defined] + notation_args = [ + x.args for x in instance._format_notation.call_args_list] # type:ignore[attr-defined] + assert all(len(a) == 1 for a in notation_args) + assert all(isinstance(a[0], float) for a in notation_args) + + if action == "start": + mock_print.assert_not_called() + assert all(x in log_message for x in ("Start: ", "Target: ", "Steps: ")) + assert instance._format_notation.call_count == 2 # type:ignore[attr-defined] + return + + if action == "end": + mock_print.assert_called() + assert "Final Learning Rate: " in log_message + instance._format_notation.assert_called_once() # type:ignore[attr-defined] + return + + if action == "reported": + mock_print.assert_called() + assert current_step in instance._reporting_points + assert all(x in log_message for x in ("Step: ", "Current: ", "Target: ")) + assert instance._format_notation.call_count == 2 # type:ignore[attr-defined] + + +_STEPS_CURRENT_CALL = [(0, 500, "disabled"), + (1000, 500, "progress"), + (1000, 1000, "completed"), + (1000, 1111, "completed2")] +_STEPS_CURRENT_CALL_ID = [f"steps:{x[0]}|current_step:{x[1]}|action:{x[2]}" + for x in _STEPS_CURRENT_CALL] + + +@pytest.mark.parametrize(("steps", "current_step", "action"), + _STEPS_CURRENT_CALL, + ids=_STEPS_CURRENT_CALL_ID) +def test__call__(model_fixture: Model, + steps: int, + current_step: int, + action: str, + mocker: pytest_mock.MockerFixture) -> None: + """ Test calling the instance works correctly """ + instance = LearningRateWarmup(model_fixture, 5e-5, steps) + instance._current_step = current_step + instance._set_learning_rate = mocker.MagicMock() # type:ignore[method-assign] + instance._output_status = mocker.MagicMock() # type:ignore[method-assign] + + instance() + + if action in ("disabled", "completed", "completed2"): + assert instance._current_step == current_step + instance._set_learning_rate.assert_not_called() # type:ignore[attr-defined] + instance._output_status.assert_not_called() # type:ignore[attr-defined] + else: + assert instance._current_step == current_step + 1 + instance._set_learning_rate.assert_called_once() # type:ignore[attr-defined] + instance._output_status.assert_called_once() # type:ignore[attr-defined] diff --git a/tests/lib/training/tensorboard_test.py b/tests/lib/training/tensorboard_test.py new file mode 100644 index 0000000000..aab7a9bc51 --- /dev/null +++ b/tests/lib/training/tensorboard_test.py @@ -0,0 +1,166 @@ +#! /usr/env/bin/python3 +""" Unit test for :mod:`lib.training.tensorboard` """ +import os + +import pytest + +from keras import layers, Sequential +import numpy as np +from tensorboard.compat.proto import event_pb2 +from torch.utils.tensorboard import SummaryWriter + +from lib.training import tensorboard as mod_tb + +# pylint:disable=protected-access,invalid-name + + +@pytest.fixture() +def _gen_events_file(tmpdir): + log_dir = tmpdir.mkdir("logs") + + def _apply(keys=["test1"], # pylint:disable=dangerous-default-value + values=[0.42], + global_steps=[4]): + writer = SummaryWriter(log_dir) + for key, val, step in zip(keys, values, global_steps): + writer.add_scalar(key, val, global_step=step) + writer.flush() + return os.path.join(log_dir, os.listdir(log_dir)[0]) + + return _apply + + +@pytest.mark.parametrize("entries", ({"loss1": np.random.rand()}, + {f"test{i}": np.random.rand() for i in range(4)}, + {f"another_test{i}": np.random.rand() for i in range(10)})) +@pytest.mark.parametrize("batch", [1, 42, 69, 1024, 143432]) +@pytest.mark.parametrize("is_live", (True, False), ids=("live", "not_live")) +def test_RecordIterator(entries, batch, is_live, _gen_events_file): + """ Test that our :class:`lib.training.tensorboard.RecordIterator` returns expected results """ + keys = list(entries) + vals = list(entries.values()) + batches = [batch + i for i in range(len(keys))] + + file = _gen_events_file(keys, vals, batches) + iterator = mod_tb.RecordIterator(file, is_live=is_live) + + results = list(event_pb2.Event.FromString(v) for v in iterator) + valid = [r for r in results if r.summary.value] + + assert len(valid) == len(keys) + for entry, key, val, btc in zip(valid, keys, vals, batches): + assert len(entry.summary.value) == 1 + assert entry.step == btc + assert entry.summary.value[0].tag == key + assert np.isclose(entry.summary.value[0].simple_value, val) + + if is_live: + assert iterator._is_live is True + assert os.path.getsize(file) == iterator._position # At end of file + else: + assert iterator._is_live is False + assert iterator._position == 0 + + +@pytest.fixture() +def _get_ttb_instance(tmpdir): + log_dir = tmpdir.mkdir("logs") + + def _apply(write_graph=False, update_freq="batch"): + instance = mod_tb.TorchTensorBoard(log_dir=log_dir, + write_graph=write_graph, + update_freq=update_freq) + return log_dir, instance + + return _apply + + +def _get_logs(temp_path): + train_logs = os.path.join(temp_path, "train") + log_files = os.listdir(train_logs) + assert len(log_files) == 1 + records = [event_pb2.Event.FromString(record) + for record in mod_tb.RecordIterator(os.path.join(train_logs, log_files[0]))] + return records + + +@pytest.mark.parametrize("write_graph", (True, False), ids=("write_graph", "no_write_graph")) +def test_TorchTensorBoard_set_model(write_graph, _get_ttb_instance): + """ Test that :class:`lib.training.tensorboard.set_model` functions """ + log_dir, instance = _get_ttb_instance(write_graph=write_graph) + + model = Sequential() + model.add(layers.Input(shape=(8, ))) + model.add(layers.Dense(4)) + model.add(layers.Dense(4)) + + assert not os.path.exists(os.path.join(log_dir, "train")) + instance.set_model(model) + instance.on_save() + + logs = [x for x in _get_logs(os.path.join(log_dir)) + if x.summary.value] + + if not write_graph: + assert not logs + return + + # Only a single logged entry + assert len(logs) == 1 and len(logs[0].summary.value) == 1 + # Should be our Keras model summary + assert logs[0].summary.value[0].tag == "keras/text_summary" + + +def test_TorchTensorBoard_on_train_begin(_get_ttb_instance): + """ Test that :class:`lib.training.tensorboard.on_train_begin` functions """ + _, instance = _get_ttb_instance() + instance.on_train_begin() + assert instance._global_train_batch == 0 + assert instance._previous_epoch_iterations == 0 + + +@pytest.mark.parametrize("batch", (1, 3, 57, 124)) +@pytest.mark.parametrize("logs", ({"loss_a": 2.45, "loss_b": 1.56}, + {"loss_c": 0.54, "loss_d": 0.51}, + {"loss_c": 0.69, "loss_d": 0.42, "loss_g": 2.69})) +def test_TorchTensorBoard_on_train_batch_end(batch, logs, _get_ttb_instance): + """ Test that :class:`lib.training.tensorboard.on_train_batch_end` functions """ + log_dir, instance = _get_ttb_instance() + + assert not os.path.exists(os.path.join(log_dir, "train")) + + instance.on_train_batch_end(batch, logs) + instance.on_save() + + tb_logs = [x for x in _get_logs(os.path.join(log_dir)) + if x.summary.value] + + assert len(tb_logs) == len(logs) + for (k, v), out in zip(logs.items(), tb_logs): + assert len(out.summary.value) == 1 + assert out.summary.value[0].tag == f"batch_{k}" + assert np.isclose(out.summary.value[0].simple_value, v) + assert out.step == batch + + +def test_TorchTensorBoard_on_save(_get_ttb_instance, mocker): + """ Test that :class:`lib.training.tensorboard.on_save` functions """ + # Implicitly checked in other tests, so just make sure it calls flush on the writer + _, instance = _get_ttb_instance() + instance._train_writer.flush = mocker.MagicMock() + + instance.on_save() + instance._train_writer.flush.assert_called_once() + + +def test_TorchTensorBoard_on_train_end(_get_ttb_instance, mocker): + """ Test that :class:`lib.training.tensorboard.on_train_end` functions """ + # Saving is already implicitly checked in other tests, so just make sure it calls flush and + # close on the train writer + _, instance = _get_ttb_instance() + instance._train_writer.flush = mocker.MagicMock() + instance._train_writer.close = mocker.MagicMock() + + instance.on_train_end() + instance._train_writer.flush.assert_called_once() + instance._train_writer.close.assert_called_once() diff --git a/tests/lib/utils_test.py b/tests/lib/utils_test.py index 34f9be5f4f..aa4a8669db 100644 --- a/tests/lib/utils_test.py +++ b/tests/lib/utils_test.py @@ -2,9 +2,10 @@ """ Pytest unit tests for :mod:`lib.utils` """ import os import platform +import sys import time import typing as T -import warnings +import types import zipfile from io import StringIO @@ -19,8 +20,8 @@ from lib import utils from lib.utils import ( _Backend, camel_case_split, convert_to_secs, DebugTimes, deprecation_warning, FaceswapError, - full_path_split, get_backend, get_dpi, get_folder, get_image_paths, get_tf_version, GetModel, - safe_shutdown, set_backend, set_system_verbosity) + full_path_split, get_backend, get_dpi, get_folder, get_image_paths, get_module_objects, + get_torch_version, GetModel, safe_shutdown, set_backend) from lib.logger import log_setup # Need to setup logging to avoid trace/verbose errors @@ -40,8 +41,8 @@ def test_set_backend(monkeypatch: pytest.MonkeyPatch) -> None: Monkey patching _FS_BACKEND """ monkeypatch.setattr(utils, "_FS_BACKEND", "cpu") # _FS_BACKEND already defined - set_backend("directml") - assert utils._FS_BACKEND == "directml" + set_backend("nvidia") + assert utils._FS_BACKEND == "nvidia" monkeypatch.delattr(utils, "_FS_BACKEND") # _FS_BACKEND is not already defined set_backend("rocm") assert utils._FS_BACKEND == "rocm" @@ -79,7 +80,7 @@ def test__backend(monkeypatch: pytest.MonkeyPatch) -> None: assert backend.backend == "cpu" monkeypatch.setattr("os.path.isfile", lambda x: False) # no config file, dummy in user input - monkeypatch.setattr("builtins.input", lambda x: "3") + monkeypatch.setattr("builtins.input", lambda x: "2") backend = _Backend() assert backend._configure_backend() == "nvidia" @@ -148,19 +149,61 @@ def test_get_image_paths(tmp_path: str) -> None: assert sorted(get_image_paths(test_folder, extension=".png")) == sorted(exists) -_PARAMS = [("/path/to/file.txt", ["/", "path", "to", "file.txt"]), # Absolute - ("/path/to/directory/", ["/", "path", "to", "directory"]), - ("/path/to/directory", ["/", "path", "to", "directory"]), - ("path/to/file.txt", ["path", "to", "file.txt"]), # Relative - ("path/to/directory/", ["path", "to", "directory"]), - ("path/to/directory", ["path", "to", "directory"]), - ("", []), # Edge cases - ("/", ["/"]), - (".", ["."]), - ("..", [".."])] +def test_get_module_objects(mocker: pytest_mock.MockerFixture): + """ Test :func:`lib.utils.get_module_objects` returns as expected """ + # pylint:disable=too-few-public-methods,missing-class-docstring + test_module = types.ModuleType("our_mod") + class InternalPublic: + pass + InternalPublic.__module__ = "our_mod" + setattr(test_module, "InternalPublic", InternalPublic) -@pytest.mark.parametrize("path,result", _PARAMS, ids=[f'"{p[0]}"' for p in _PARAMS]) + class _InternalPrivate: + pass + _InternalPrivate.__module__ = "our_mod" + setattr(test_module, "_InternalPrivate", _InternalPrivate) + + class External: + pass + External.__module__ = "other_mod" + setattr(test_module, "External", External) + + def func_public(): + pass + func_public.__module__ = "our_mod" + setattr(test_module, "func_public", func_public) + + def _func_private(): + pass + _func_private.__module__ = "our_mod" + setattr(test_module, "_func_private", _func_private) + + def func_external(): + pass + func_external.__module__ = "other_mod" + setattr(test_module, "func_external", func_external) + + mocker.patch.dict(sys.modules, {"our_mod": test_module}) + + result = get_module_objects("our_mod") + assert sorted(result, key=str.casefold) == ["func_public", "InternalPublic"] + + +_PATHS = ( # type:ignore[var-annotated] + ("/path/to/file.txt", ["/", "path", "to", "file.txt"]), # Absolute + ("/path/to/directory/", ["/", "path", "to", "directory"]), + ("/path/to/directory", ["/", "path", "to", "directory"]), + ("path/to/file.txt", ["path", "to", "file.txt"]), # Relative + ("path/to/directory/", ["path", "to", "directory"]), + ("path/to/directory", ["path", "to", "directory"]), + ("", []), # Edge cases + ("/", ["/"]), + (".", ["."]), + ("..", [".."])) + + +@pytest.mark.parametrize("path,result", _PATHS, ids=[f'"{p[0]}"' for p in _PATHS]) def test_full_path_split(path: str, result: list[str]) -> None: """ Test the :func:`~lib.utils.full_path_split` function works correctly @@ -176,19 +219,19 @@ def test_full_path_split(path: str, result: list[str]) -> None: assert split == result -_PARAMS = [("camelCase", ["camel", "Case"]), - ("camelCaseTest", ["camel", "Case", "Test"]), - ("camelCaseTestCase", ["camel", "Case", "Test", "Case"]), - ("CamelCase", ["Camel", "Case"]), - ("CamelCaseTest", ["Camel", "Case", "Test"]), - ("CamelCaseTestCase", ["Camel", "Case", "Test", "Case"]), - ("CAmelCASETestCase", ["C", "Amel", "CASE", "Test", "Case"]), - ("camelcasetestcase", ["camelcasetestcase"]), - ("CAMELCASETESTCASE", ["CAMELCASETESTCASE"]), - ("", [])] +_CASES = (("camelCase", ["camel", "Case"]), # type:ignore[var-annotated] + ("camelCaseTest", ["camel", "Case", "Test"]), + ("camelCaseTestCase", ["camel", "Case", "Test", "Case"]), + ("CamelCase", ["Camel", "Case"]), + ("CamelCaseTest", ["Camel", "Case", "Test"]), + ("CamelCaseTestCase", ["Camel", "Case", "Test", "Case"]), + ("CAmelCASETestCase", ["C", "Amel", "CASE", "Test", "Case"]), + ("camelcasetestcase", ["camelcasetestcase"]), + ("CAMELCASETESTCASE", ["CAMELCASETESTCASE"]), + ("", [])) -@pytest.mark.parametrize("text, result", _PARAMS, ids=[f'"{p[0]}"' for p in _PARAMS]) +@pytest.mark.parametrize("text, result", _CASES, ids=[f'"{p[0]}"' for p in _CASES]) def test_camel_case_split(text: str, result: list[str]) -> None: """ Test the :func:`~lib.utils.camel_case_spli` function works correctly @@ -204,11 +247,18 @@ def test_camel_case_split(text: str, result: list[str]) -> None: assert split == result +_TORCH_PARAMS = (("2.4.9", (2, 4)), ("2.6", (2, 6)), ("2.8.rc3", (2, 8))) +_TORCH_IDS = [x[0] for x in _TORCH_PARAMS] + + # General utils -def test_get_tf_version() -> None: - """ Test the :func:`~lib.utils.get_tf_version` function version returns correctly in range """ - tf_version = get_tf_version() - assert (2, 10) <= tf_version < (2, 11) +@pytest.mark.parametrize("str_vers, tuple_vers", _TORCH_PARAMS, ids=_TORCH_IDS) +def test_get_torch_version(str_vers, tuple_vers, monkeypatch: pytest.MonkeyPatch) -> None: + """ Test the :func:`~lib.utils.get_torch_version` function version returns correctly """ + monkeypatch.setattr("lib.utils._versions", {}) + monkeypatch.setattr("torch.__version__", str_vers) + torch_version = get_torch_version() + assert torch_version == tuple_vers def test_get_dpi() -> None: @@ -251,31 +301,6 @@ def test_convert_to_secs(args: tuple[int, ...], result: int) -> None: assert secs == result -@pytest.mark.parametrize("log_level", ["DEBUG", "INFO", "WARNING", "ERROR"]) -def test_set_system_verbosity(log_level: str) -> None: - """ Test the :func:`~lib.utils.set_system_verbosity` function works correctly - - Parameters - ---------- - log_level: str - The logging loglevel in upper text format - """ - # Set TF Env Variable - tf_set_level = "0" if log_level == "DEBUG" else "3" - set_system_verbosity(log_level) - tf_get_level = os.environ["TF_CPP_MIN_LOG_LEVEL"] - assert tf_get_level == tf_set_level - warn_filters = [filt for filt in warnings.filters - if filt[0] == "ignore" - and filt[2] in (FutureWarning, DeprecationWarning, UserWarning)] - # Python Warnings - # DeprecationWarning is already ignored by default, so there should be 1 warning for debug - # warning. 3 for the rest - num_warnings = 1 if log_level == "DEBUG" else 3 - warn_count = len(warn_filters) - assert warn_count == num_warnings - - @pytest.mark.parametrize("additional_info", [None, "additional information"]) def test_deprecation_warning(caplog: pytest.LogCaptureFixture, additional_info: str) -> None: """ Test the :func:`~lib.utils.deprecation_warning` function works correctly @@ -488,21 +513,13 @@ def test_get_model__download_model(mocker: pytest_mock.MockerFixture, mock_urlopen.reset_mock() +# TODO remove the next line that supresses a weird pytest bug when it tears down the tempdir +@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") @pytest.mark.parametrize("dl_type", ["complete", "new", "continue"]) def test_get_model__write_zipfile(mocker: pytest_mock.MockerFixture, get_model_instance: GetModel, dl_type: str) -> None: - """ Test :func:`~lib.utils.GetModel._write_zipfile` executes its logic correctly - - Parameters - --------- - mocker: :class:`pytest_mock.MockerFixture` - Mocker for dummying in function calls - get_model_instance: `~lib.utils.GetModel` - The patched instance of the class - dl_type: str - The type of read to attemp - """ + """ Test :func:`~lib.utils.GetModel._write_zipfile` executes its logic correctly """ response = mocker.MagicMock() assert not os.path.isfile(get_model_instance._model_zip_path) @@ -517,7 +534,7 @@ def test_get_model__write_zipfile(mocker: pytest_mock.MockerFixture, if dl_type == "continue": # Write a partial download of the correct size with open(get_model_instance._model_zip_path, "wb") as partial: - partial.write(b"\x00" * sum(chunks)) + partial.write(b"\x00" * sum(chunks)) # type:ignore downloaded = os.path.getsize(get_model_instance._model_zip_path) get_model_instance._write_zipfile(response, downloaded) @@ -526,13 +543,15 @@ def test_get_model__write_zipfile(mocker: pytest_mock.MockerFixture, assert not response.read.called return - assert response.read.call_count == len(data) # all data read + assert response.read.call_count == len(data) # all data read # type:ignore assert os.path.isfile(get_model_instance._model_zip_path) downloaded_size = os.path.getsize(get_model_instance._model_zip_path) downloaded_size = downloaded_size if dl_type == "new" else downloaded_size // 2 - assert downloaded_size == sum(chunks) + assert downloaded_size == sum(chunks) # type:ignore +# TODO remove the next line that supresses a weird pytest bug when it tears down the tempdir +@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") def test_get_model__unzip_model(mocker: pytest_mock.MockerFixture, get_model_instance: GetModel) -> None: """ Test :func:`~lib.utils.GetModel._unzip_model` executes its logic correctly diff --git a/tests/plugins/__init.__.py b/tests/plugins/__init.__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/plugins/train/__init__.py b/tests/plugins/train/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/plugins/train/trainer/__init__.py b/tests/plugins/train/trainer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/plugins/train/trainer/test_distributed.py b/tests/plugins/train/trainer/test_distributed.py new file mode 100644 index 0000000000..0d913377b5 --- /dev/null +++ b/tests/plugins/train/trainer/test_distributed.py @@ -0,0 +1,138 @@ +#!/usr/bin python3 +""" Pytest unit tests for :mod:`plugins.train.trainer.distributed` Trainer plug in """ +# pylint:disable=protected-access, invalid-name + +import numpy as np +import pytest +import pytest_mock +import torch + +from plugins.train.trainer import distributed as mod_distributed +from plugins.train.trainer import original as mod_original +from plugins.train.trainer import _base as mod_base + + +_MODULE_PREFIX = "plugins.train.trainer.distributed" + + +@pytest.mark.parametrize("batch_size", (4, 8, 16, 32, 64)) +@pytest.mark.parametrize("outputs", (1, 2, 4)) +def test_WrappedModel(batch_size, outputs, mocker): + """ Test that the wrapped model calls preds and loss """ + model = mocker.MagicMock() + instance = mod_distributed.WrappedModel(model) + assert instance._keras_model is model + + loss_return = [torch.from_numpy((np.random.random((1, )))) for _ in range(outputs * 2)] + model.loss = [mocker.MagicMock(return_value=ret) for ret in loss_return] + + test_dims = (batch_size, 16, 16, 3) + + inp_a = torch.from_numpy(np.random.random(test_dims)) + inp_b = torch.from_numpy(np.random.random(test_dims)) + targets = [torch.from_numpy(np.random.random(test_dims)) + for _ in range(outputs * 2)] + preds = [*torch.from_numpy(np.random.random((outputs * 2, *test_dims)))] + + model.return_value = preds + + # Call forwards + result = instance.forward(inp_a, inp_b, *targets) + + # Confirm model was called once forward with correct args + model.assert_called_once() + model_args, model_kwargs = model.call_args + assert model_kwargs == {"training": True} + assert len(model_args) == 1 + assert len(model_args[0]) == 2 + for real, expected in zip(model_args[0], [inp_a, inp_b]): + assert np.allclose(real.numpy(), expected.numpy()) + + # Confirm ZeroGrad called + model.zero_grad.assert_called_once() + + # Confirm loss functions correctly called + expected_targets = targets[0::2] + targets[1::2] + + for target, pred, loss in zip(expected_targets, preds, model.loss): + loss.assert_called_once() + loss_args, loss_kwargs = loss.call_args + assert not loss_kwargs + assert len(loss_args) == 2 + for actual, expected in zip(loss_args, [target, pred]): + assert np.allclose(actual.numpy(), expected.numpy()) + + # Check that the result comes out as we put it in + for expected, actual in zip(loss_return, result.squeeze()): + assert np.isclose(expected.numpy(), actual.numpy()) + + +@pytest.fixture +def _trainer_mocked(mocker: pytest_mock.MockFixture): # noqa:[F811] + """ Generate a mocked model and feeder object and patch torch GPU count """ + + def _apply_patch(gpus=2, batch_size=8): + patched_cuda_device = mocker.patch(f"{_MODULE_PREFIX}.torch.cuda.device_count") + patched_cuda_device.return_value = gpus + patched_parallel = mocker.patch(f"{_MODULE_PREFIX}.torch.nn.DataParallel") + patched_parallel.return_value = mocker.MagicMock() + model = mocker.MagicMock() + instance = mod_distributed.Trainer(model, batch_size) + return instance, patched_parallel + + return _apply_patch + + +@pytest.mark.parametrize("gpu_count", (2, 3, 5, 8)) +@pytest.mark.parametrize("batch_size", (4, 8, 16, 32, 64)) +def test_Trainer(gpu_count, batch_size, _trainer_mocked): + """ Test that original trainer creates correctly """ + instance, patched_parallel = _trainer_mocked(gpus=gpu_count, batch_size=batch_size) + assert isinstance(instance, mod_base.TrainerBase) + assert isinstance(instance, mod_original.Trainer) + # Confirms that _validate_batch_size executed correctly + assert instance.batch_size == max(gpu_count, batch_size) + assert hasattr(instance, "train_batch") + # Confirms that _set_distributed executed correctly + assert instance._distributed_model is patched_parallel.return_value + + +@pytest.mark.parametrize("gpu_count", (2, 3, 5, 8), ids=[f"gpus:{x}" for x in (2, 3, 5, 8)]) +@pytest.mark.parametrize("outputs", (1, 2, 4)) +@pytest.mark.parametrize("batch_size", (4, 8, 16, 32, 64)) +def test_Trainer_forward(gpu_count, batch_size, outputs, _trainer_mocked, mocker): + """ Test that original trainer _forward calls the correct model methods """ + instance, _ = _trainer_mocked(gpus=gpu_count, batch_size=batch_size) + + test_dims = (2, batch_size, 16, 16, 3) + + inputs = torch.from_numpy(np.random.random(test_dims)) + targets = [torch.from_numpy(np.random.random(test_dims)) for _ in range(outputs)] + + loss_return = torch.rand((gpu_count * 2 * outputs)) + instance._distributed_model = mocker.MagicMock(return_value=loss_return) + + # Call the forward pass + result = instance._forward(inputs, targets).cpu().numpy() + + # Make sure multi-outs are enabled + if outputs > 1: + assert instance._is_multi_out is True + else: + assert instance._is_multi_out is False + + # Make sure that our wrapped distributed model was called in the correct order + instance._distributed_model.assert_called_once() + call_args, call_kwargs = instance._distributed_model.call_args + assert not call_kwargs + assert len(call_args) == len(inputs) + (len(targets) * 2) + + expected_tgts = [t[i].cpu().numpy() for t in targets for i in range(2)] + + for expected, actual in zip([*inputs, *expected_tgts], call_args): + assert np.allclose(expected, actual) + + # Make sure loss gets grouped, summed and scaled correctly + expected = loss_return.cpu().numpy() + expected = expected.reshape((gpu_count, 2, -1)).sum(axis=0).flatten() / gpu_count + assert np.allclose(result, expected) diff --git a/tests/plugins/train/trainer/test_original.py b/tests/plugins/train/trainer/test_original.py new file mode 100644 index 0000000000..983e691948 --- /dev/null +++ b/tests/plugins/train/trainer/test_original.py @@ -0,0 +1,122 @@ +#!/usr/bin python3 +""" Pytest unit tests for :mod:`plugins.train.trainer.original` Trainer plug in """ +# pylint:disable=protected-access,invalid-name + +import numpy as np +import pytest +import pytest_mock +import torch + +from plugins.train.trainer import original as mod_original +from plugins.train.trainer import _base as mod_base + + +@pytest.fixture +def _trainer_mocked(mocker: pytest_mock.MockFixture): # noqa:[F811] + """ Generate a mocked model and feeder object and patch user config items """ + + def _apply_patch(batch_size=8): + model = mocker.MagicMock() + instance = mod_original.Trainer(model, batch_size) + return instance + + return _apply_patch + + +@pytest.mark.parametrize("batch_size", (4, 8, 16, 32, 64)) +def test_Trainer(batch_size, _trainer_mocked): + """ Test that original trainer creates correctly """ + instance = _trainer_mocked(batch_size=batch_size) + assert isinstance(instance, mod_base.TrainerBase) + assert instance.batch_size == batch_size + assert hasattr(instance, "train_batch") + + +def test_Trainer_train_batch(_trainer_mocked, mocker): + """ Test that original trainer calls the forward and backwards methods """ + instance = _trainer_mocked() + loss_return = float(np.random.rand()) + instance._forward = mocker.MagicMock(return_value=loss_return) + instance._backwards_and_apply = mocker.MagicMock() + + ret_val = instance.train_batch("TEST_INPUT", "TEST_TARGET") + + assert ret_val == loss_return + instance._forward.assert_called_once_with("TEST_INPUT", "TEST_TARGET") + instance._backwards_and_apply.assert_called_once_with(loss_return) + + +@pytest.mark.parametrize("outputs", (1, 2, 4)) +@pytest.mark.parametrize("batch_size", (4, 8, 16, 32, 64)) +def test_Trainer_forward(batch_size, # pylint:disable=too-many-locals + outputs, + _trainer_mocked, + mocker): + """ Test that original trainer _forward calls the correct model methods """ + instance = _trainer_mocked(batch_size=batch_size) + + loss_returns = [torch.from_numpy(np.random.random((1, ))) for _ in range(outputs * 2)] + mock_preds = [torch.from_numpy(np.random.random((batch_size, 16, 16, 3))) + for _ in range(outputs * 2)] + instance.model.model.return_value = mock_preds + instance.model.model.zero_grad = mocker.MagicMock() + instance.model.model.loss = [mocker.MagicMock(return_value=ret) for ret in loss_returns] + + inputs = torch.from_numpy(np.random.random((2, batch_size, 16, 16, 3))) + targets = [torch.from_numpy(np.random.random((2, batch_size, 16, 16, 3))) + for _ in range(outputs)] + + # Call forwards + result = instance._forward(inputs, targets) + + # Output comes from loss functions + assert (np.allclose(e.numpy(), a.numpy()) for e, a in zip(result, loss_returns)) + + # Model was zero'd + instance.model.model.zero_grad.assert_called_once() + + # model forward pass called with inputs split + train_call = instance.model.model + + call_args, call_kwargs = train_call.call_args + assert call_kwargs == {"training": True} + expected_inputs = [a.numpy() for a in inputs] + actual_inputs = [a.numpy() for a in call_args[0]] + assert (np.allclose(e, a) for e, a in zip(expected_inputs, actual_inputs)) + + # losses called with targets split + loss_calls = instance.model.model.loss + expected_targets = [t[i].numpy() for i in range(2) for t in targets] + expected_preds = [p.numpy() for p in mock_preds] + for loss_call, pred, target in zip(loss_calls, expected_preds, expected_targets): + loss_call.assert_called_once() + call_args, call_kwargs = loss_call.call_args + assert not call_kwargs + assert len(call_args) == 2 + + actual_target = call_args[0].numpy() + actual_pred = call_args[1].numpy() + assert np.allclose(pred, actual_pred) + assert np.allclose(target, actual_target) + + +def test_Trainer_backwards_and_apply(_trainer_mocked, mocker): + """ Test that original trainer _backwards_and_apply calls the correct model methods """ + instance = _trainer_mocked() + + mock_loss = mocker.MagicMock() + instance.model.model.optimizer.scale_loss = mocker.MagicMock(return_value=mock_loss) + instance.model.model.optimizer.app = mocker.MagicMock(return_value=mock_loss) + + all_loss = np.random.rand() + instance._backwards_and_apply(all_loss) + + scale_mock = instance.model.model.optimizer.scale_loss + scale_mock.assert_called_once() + assert not scale_mock.call_args[1] + assert len(scale_mock.call_args[0]) == 1 + assert np.isclose(all_loss, scale_mock.call_args[0][0].cpu().numpy()) + + mock_loss.backward.assert_called_once() + + instance.model.model.optimizer.apply.assert_called_once() diff --git a/tests/simple_tests.py b/tests/simple_tests.py index 0e6ea127d5..5db6109a29 100644 --- a/tests/simple_tests.py +++ b/tests/simple_tests.py @@ -9,13 +9,11 @@ import sys from subprocess import check_call, CalledProcessError -import urllib -from urllib.request import urlretrieve import os -from os.path import join as pathjoin, expanduser +from os.path import join as pathjoin, abspath, dirname -FAIL_COUNT = 0 -TEST_COUNT = 0 +_fail_count = 0 +_test_count = 0 _COLORS = { "FAIL": "\033[1;31m", "OK": "\033[1;32m", @@ -52,34 +50,20 @@ def print_status(text): def run_test(name, cmd): """ run a test """ - global FAIL_COUNT, TEST_COUNT # pylint:disable=global-statement + global _fail_count, _test_count # pylint:disable=global-statement print_status(f"[?] running {name}") print(f"Cmd: {' '.join(cmd)}") - TEST_COUNT += 1 + _test_count += 1 try: check_call(cmd) print_ok("[+] Test success") return True except CalledProcessError as err: print_fail(f"[-] Test failed with {err}") - FAIL_COUNT += 1 + _fail_count += 1 return False -def download_file(url, filename): # TODO: retry - """ Download a file from given url """ - if os.path.isfile(filename): - print_status(f"[?] '{url}' already cached as '{filename}'") - return filename - try: - print_status(f"[?] Downloading '{url}' to '{filename}'") - video, _ = urlretrieve(url, filename) - return video - except urllib.error.URLError as err: - print_fail(f"[-] Failed downloading: {err}") - return None - - def extract_args(detector, aligner, in_path, out_path, args=None): """ Extraction command """ py_exe = sys.executable @@ -111,7 +95,7 @@ def convert_args(in_path, out_path, model_path, writer, args=None): def sort_args(in_path, out_path, sortby="face", groupby="hist"): """ Sort command """ py_exe = sys.executable - _sort_args = (f"{py_exe} tools.py sort -i {in_path} -o {out_path} -s {sortby} -g {groupby} -k") + _sort_args = f"{py_exe} tools.py sort -i {in_path} -o {out_path} -s {sortby} -g {groupby} -k" return _sort_args.split() @@ -137,35 +121,22 @@ def set_train_config(value): print_ok(f"Set autoclip and mixed_precision to `{new_val}`") except CalledProcessError as err: print_fail(f"[-] Test failed with {err}") - return False def main(): """ Main testing script """ - vid_src = "https://faceswap.dev/data/test.mp4" - img_src = "https://archive.org/download/GPN-2003-00070/GPN-2003-00070.jpg" - base_dir = pathjoin(expanduser("~"), "cache", "tests") - + base_dir = pathjoin(dirname(abspath(__file__)), "data") vid_base = pathjoin(base_dir, "vid") img_base = pathjoin(base_dir, "imgs") - os.makedirs(vid_base, exist_ok=True) - os.makedirs(img_base, exist_ok=True) py_exe = sys.executable was_trained = False - vid_path = download_file(vid_src, pathjoin(vid_base, "test.mp4")) - if not vid_path: - print_fail("[-] Aborting") - sys.exit(1) + vid_path = pathjoin(vid_base, "test.mp4") vid_extract = run_test( "Extraction video with cv2-dnn detector and cv2-dnn aligner.", extract_args("Cv2-Dnn", "Cv2-Dnn", vid_path, pathjoin(vid_base, "faces")) ) - img_path = download_file(img_src, pathjoin(img_base, "test_img.jpg")) - if not img_path: - print_fail("[-] Aborting") - sys.exit(1) run_test( "Extraction images with cv2-dnn detector and cv2-dnn aligner.", extract_args("Cv2-Dnn", "Cv2-Dnn", img_base, pathjoin(img_base, "faces")) @@ -230,11 +201,11 @@ def main(): ) ) - if FAIL_COUNT == 0: - print_ok(f"[+] Failed {FAIL_COUNT}/{TEST_COUNT} tests.") + if _fail_count == 0: + print_ok(f"[+] Failed {_fail_count}/{_test_count} tests.") sys.exit(0) else: - print_fail(f"[-] Failed {FAIL_COUNT}/{TEST_COUNT} tests.") + print_fail(f"[-] Failed {_fail_count}/{_test_count} tests.") sys.exit(1) diff --git a/tests/startup_test.py b/tests/startup_test.py index 704a96edcf..20e939c84d 100644 --- a/tests/startup_test.py +++ b/tests/startup_test.py @@ -2,27 +2,38 @@ """ Sanity checks for Faceswap. """ import inspect -import pytest +import sys -# Ignore linting errors from Tensorflow's thoroughly broken import system -from tensorflow import keras -from tensorflow.keras import backend as K # pylint:disable=import-error +import pytest +import keras +import torch from lib.utils import get_backend +from lib.system.system import VALID_KERAS, VALID_PYTHON, VALID_TORCH -_BACKEND = get_backend() +_BACKEND = get_backend().upper() +_LIBS = (VALID_KERAS + (keras.__version__, ), + VALID_PYTHON + (sys.version, ), + VALID_TORCH + (torch.__version__, )) +_IDS = [f"{x}[{_BACKEND}" for x in ("keras", "python", "torch")] -@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) -def test_backend(dummy): # pylint:disable=unused-argument - """ Sanity check to ensure that Keras backend is returning the correct object type. """ - test_var = K.variable((1, 1, 4, 4)) - lib = inspect.getmodule(test_var).__name__.split(".")[0] - assert _BACKEND in ("cpu", "directml") and lib == "tensorflow" +@pytest.mark.parametrize(["min_vers", "max_vers", "installed_vers"], _LIBS, ids=_IDS) +def test_libraries(min_vers: tuple[int, int], + max_vers: tuple[int, int], + installed_vers: str) -> None: + """ Sanity check to ensure that we are running on a valid libraries """ + installed = tuple(int(x) for x in installed_vers.split(".")[:2]) + assert min_vers <= installed <= max_vers -@pytest.mark.parametrize('dummy', [None], ids=[get_backend().upper()]) -def test_keras(dummy): # pylint:disable=unused-argument - """ Sanity check to ensure that tensorflow keras is being used for CPU """ - assert (_BACKEND in ("cpu", "directml") - and keras.__version__ in ("2.7.0", "2.8.0", "2.9.0", "2.10.0")) + +@pytest.mark.parametrize('dummy', [None], ids=[_BACKEND]) +def test_backend(dummy): # pylint:disable=unused-argument + """ Sanity check to ensure that Keras backend is returning the correct object type. """ + with keras.device("cpu"): + test_var = keras.Variable((1, 1, 4, 4), trainable=False) + mod = inspect.getmodule(test_var) + assert mod is not None + lib = mod.__name__.split(".")[0] + assert lib == "keras" diff --git a/tests/tools/alignments/media_test.py b/tests/tools/alignments/media_test.py index 17e45a517e..2639759ebb 100644 --- a/tests/tools/alignments/media_test.py +++ b/tests/tools/alignments/media_test.py @@ -273,6 +273,8 @@ def test_load_video_frame(self, vid_cap.set.assert_called_once() np.testing.assert_equal(output, expected) + # TODO remove the next line that supresses a weird pytest bug when it tears down the tempdir + @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") def test_stream(self, media_loader_instance: MediaLoader, mocker: pytest_mock.MockerFixture) -> None: @@ -294,15 +296,16 @@ def test_stream(self, output = list(media_loader.stream()) assert output == expected - loader.reset_mock() - + skip_call = mocker.patch("tools.alignments.media.ImagesLoader.add_skip_list") skip_list = [0] expected = [expected[1]] loader.side_effect = [expected] output = list(media_loader.stream(skip_list)) assert output == expected - assert loader.add_skip_list.called_once_with(skip_list) + skip_call.assert_called_once_with(skip_list) + # TODO remove the next line that supresses a weird pytest bug when it tears down the tempdir + @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") def test_save_image(self, media_loader_instance: MediaLoader, mocker: pytest_mock.MockerFixture) -> None: @@ -762,6 +765,8 @@ def test_get_faces(self, assert extract_face_mock.call_count == 1 assert faces.current_frame == frame + # TODO remove the next line that supresses a weird pytest bug when it tears down the tempdir + @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") def test_extract_one_face(self, extracted_faces_instance: ExtractedFaces, mocker: pytest_mock.MockerFixture) -> None: diff --git a/tests/tools/preview/viewer_test.py b/tests/tools/preview/viewer_test.py index 18fd84d404..2ce814b234 100644 --- a/tests/tools/preview/viewer_test.py +++ b/tests/tools/preview/viewer_test.py @@ -30,13 +30,13 @@ def test__faces(): """ Test the :class:`~tools.preview.viewer._Faces dataclass initializes correctly """ faces = _Faces() - assert faces.filenames == [] - assert faces.matrix == [] - assert faces.src == [] - assert faces.dst == [] + assert isinstance(faces.filenames, list) and not faces.filenames + assert isinstance(faces.matrix, list) and not faces.matrix + assert isinstance(faces.src, list) and not faces.src + assert isinstance(faces.dst, list) and not faces.dst -_PARAMS = [(3, 448), (4, 333), (5, 254), (6, 128)] # columns/face_size +_PARAMS = ((3, 448), (4, 333), (5, 254), (6, 128)) # columns/face_size _IDS = [f"cols:{c},size:{s}[{get_backend().upper()}]" for c, s in _PARAMS] @@ -118,6 +118,8 @@ def test_set_display_dimensions(self) -> None: f_display.set_display_dimensions(dimensions) assert f_display._display_dims == dimensions + # TODO remove the next line that supresses a weird pytest bug when it tears down the tempdir + @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") @pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS) def test_update_tk_image(self, columns: int, @@ -141,7 +143,11 @@ def test_update_tk_image(self, f_display._faces_source = np.zeros((face_size, face_size, 3), dtype=np.uint8) f_display._faces_dest = np.zeros((face_size, face_size, 3), dtype=np.uint8) - tk.Tk() # tkinter instance needed for image creation + try: + tk.Tk() # tkinter instance needed for image creation + except tk.TclError: + # Some Windows runners arbitrarily don't install Tk correctly + pytest.skip("Tk not available on this system") f_display.update_tk_image() f_display._build_faces_image.assert_called_once() diff --git a/tools.py b/tools.py index c47798c7d0..423b58a7d8 100755 --- a/tools.py +++ b/tools.py @@ -14,8 +14,8 @@ _ = _LANG.gettext # Python version check -if sys.version_info < (3, 10): - raise ValueError("This program requires at least python 3.10") +if sys.version_info < (3, 11): + raise ValueError("This program requires at least python 3.11") def bad_args(*args): # pylint:disable=unused-argument diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index 4c6251f1fe..d9d610f290 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -8,7 +8,8 @@ from argparse import Namespace from multiprocessing import Process -from lib.utils import FaceswapError, handle_deprecated_cliopts, VIDEO_EXTENSIONS +from lib.utils import (get_module_objects, FaceswapError, + handle_deprecated_cliopts, VIDEO_EXTENSIONS) from .media import AlignmentData from .jobs import Check, Export, Sort, Spatial # noqa pylint:disable=unused-import from .jobs_faces import FromFaces, RemoveFaces, Rename # noqa pylint:disable=unused-import @@ -317,3 +318,6 @@ def process(self) -> None: job = job(self.alignments, self._args) logger.debug(job) job.process() + + +__all__ = get_module_objects(__name__) diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py index aaf7e7308b..510b8eba53 100644 --- a/tools/alignments/cli.py +++ b/tools/alignments/cli.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ -import argparse import sys import gettext import typing as T from lib.cli.args import FaceSwapArgs from lib.cli.actions import DirOrFileFullPaths, DirFullPaths, FileFullPaths, Radio, Slider +from lib.utils import get_module_objects # LOCALES _LANG = gettext.translation("tools.alignments.cli", localedir="locales", fallback=True) @@ -204,25 +204,7 @@ def get_argument_list() -> list[dict[str, T.Any]]: "that have been resized from 256px or above. Setting to 100 will only extract " "faces that have been resized from 512px or above. A setting of 200 will only " "extract faces that have been downscaled from 1024px or above.")}) - # Deprecated multi-character switches - argument_list.append({ - "opts": ("-fc", ), - "type": str, - "dest": "depr_faces_folder_fc_c", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-fr", ), - "type": str, - "dest": "depr_extract-every-n_een_N", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-een", ), - "type": int, - "dest": "depr_faces_folder_fr_r", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-sz", ), - "type": int, - "dest": "depr_size_sz_z", - "help": argparse.SUPPRESS}) return argument_list + + +__all__ = get_module_objects(__name__) diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index 72130c6d47..78ccce3ba2 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -15,7 +15,7 @@ from lib.logger import parse_class_init from lib.serializer import get_serializer -from lib.utils import FaceswapError +from lib.utils import get_module_objects, FaceswapError from .media import Faces, Frames from .jobs_faces import FaceToFile @@ -34,9 +34,9 @@ class Check: Parameters --------- - alignments: :class:`tools.alignments.media.AlignmentsData` + alignments : :class:`tools.alignments.media.AlignmentsData` The loaded alignments corresponding to the frames to be annotated - arguments: :class:`argparse.Namespace` + arguments : :class:`argparse.Namespace` The command line arguments that have called this job """ def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: @@ -58,7 +58,7 @@ def _get_source_dir(self, arguments: Namespace) -> str: Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments : :class:`argparse.Namespace` The command line arguments for the Alignments tool Returns @@ -87,7 +87,7 @@ def _get_items(self) -> list[dict[str, str]] | list[tuple[str, PNGHeaderDict]]: Returns ------- - list + list[dict[str, str]] | list[tuple[str, :class:`~lib.align.alignments.PNGHeaderDict`]] Sorted list of dictionaries for either faces or frames. If faces the dictionaries have the current filename as key, with the header source data as value. If frames the dictionaries will contain the keys 'frame_fullname', 'frame_name', 'extension'. @@ -128,7 +128,7 @@ def _compile_output(self) -> list[str] | list[tuple[str, int]]: Returns ------- - list + list[str] | list[tuple[str, int]] List of filenames or filenames and face indices for the selected criteria """ action = self._job.replace("-", "_") @@ -160,12 +160,11 @@ def _get_multi_faces(self) -> (Generator[str, None, None] | Yields ------ - str or tuple + str | tuple The frame name of any frames which have multiple faces and potentially the face id """ process_type = getattr(self, f"_get_multi_faces_{self._type}") - for item in process_type(): - yield item + yield from process_type() def _get_multi_faces_frames(self) -> Generator[str, None, None]: """ Return Frames that contain multiple faces @@ -190,7 +189,7 @@ def _get_multi_faces_faces(self) -> Generator[tuple[str, int], None, None]: Yields ------ - tuple + tuple[str, int] The frame name and the face id of any frames which have multiple faces """ self.output_message = "Multiple faces in frame" @@ -243,7 +242,7 @@ def _output_results(self, items_output: list[str] | list[tuple[str, int]]) -> No Parameters ---------- - items_output + items_output : list[str] The list of frame names, and potentially face ids, of any items which met the selection criteria """ @@ -303,9 +302,9 @@ def output_file(self, output_message: str, items_discovered: int) -> None: Parameters ---------- - output_message: str + output_message : str The message to write out to file - items_discovered: int + items_discovered : int The number of items which matched the criteria """ now = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -322,7 +321,7 @@ def _move_file(self, items_output: list[str] | list[tuple[str, int]]) -> None: Parameters ---------- - items_output: list + items_output : list[str] | list[tuple[str, int]] List of items to move """ now = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -341,9 +340,9 @@ def _move_frames(self, output_folder: str, items_output: list[str]) -> None: Parameters ---------- - output_folder: str + output_folder : str The folder to move the output to - items_output: list + items_output : list List of items to move """ logger.info("Moving %s frame(s) to '%s'", len(items_output), output_folder) @@ -358,9 +357,9 @@ def _move_faces(self, output_folder: str, items_output: list[tuple[str, int]]) - Parameters ---------- - output_folder: str + output_folder : str The folder to move the output to - items_output: list + items_output : list List of items and face indices to move """ logger.info("Moving %s faces(s) to '%s'", len(items_output), output_folder) @@ -378,11 +377,11 @@ def _move_faces(self, output_folder: str, items_output: list[tuple[str, int]]) - class Export: """ Export alignments from a Faceswap .fsa file to a json formatted file. - Parameters + Parameters ---------- - alignments: :class:`tools.lib_alignments.media.AlignmentData` + alignments : :class:`tools.lib_alignments.media.AlignmentData` The alignments data loaded from an alignments file for this rename job - arguments: :class:`argparse.Namespace` + arguments : :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in from :mod:`tools.py`. Unused """ def __init__(self, @@ -423,7 +422,7 @@ def _format_face(cls, face: AlignmentFileDict) -> dict[str, list[int] | list[lis Parameters ---------- - face: :class:`~lib.align.alignments.AlignmentFileDict` + face : :class:`~lib.align.alignments.AlignmentFileDict` The alignment dictionary for a face to process Returns @@ -454,9 +453,9 @@ class Sort: Parameters ---------- - alignments: :class:`tools.lib_alignments.media.AlignmentData` + alignments : :class:`tools.lib_alignments.media.AlignmentData` The alignments data loaded from an alignments file for this rename job - arguments: :class:`argparse.Namespace` + arguments : :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in from :mod:`tools.py`. Unused """ def __init__(self, @@ -476,7 +475,13 @@ def process(self) -> None: "processed then you should run the 'Extract' job to regenerate it.") def reindex_faces(self) -> int: - """ Re-Index the faces """ + """ Re-Index the faces + + Returns + ------- + int + The count of re-indexed faces + """ reindexed = 0 for alignment in tqdm(self._alignments.yield_faces(), desc="Sort alignment indexes", @@ -503,9 +508,9 @@ class Spatial: Parameters ---------- - alignments: :class:`tools.lib_alignments.media.AlignmentData` + alignments : :class:`tools.lib_alignments.media.AlignmentData` The alignments data loaded from an alignments file for this rename job - arguments: :class:`argparse.Namespace` + arguments : :class:`argparse.Namespace` The :mod:`argparse` arguments as passed in from :mod:`tools.py` Reference @@ -546,16 +551,16 @@ def _normalize_shapes(shapes_im_coords: np.ndarray Parameters ---------- - shaped_im_coords: :class:`numpy.ndarray` + shaped_im_coords : :class:`numpy.ndarray` The facial landmarks Returns ------- - shapes_normalized: :class:`numpy.ndarray` + shapes_normalized : :class:`numpy.ndarray` The normalized shapes - scale_factors: :class:`numpy.ndarray` + scale_factors : :class:`numpy.ndarray` The scale factors - mean_coords: :class:`numpy.ndarray` + mean_coords : :class:`numpy.ndarray` The mean coordinates """ logger.debug("Normalize shapes") @@ -583,11 +588,11 @@ def _normalized_to_original(shapes_normalized: np.ndarray, Parameters ---------- - shapes_normalized: :class:`numpy.ndarray` + shapes_normalized : :class:`numpy.ndarray` The normalized shapes - scale_factors: :class:`numpy.ndarray` + scale_factors : :class:`numpy.ndarray` The scale factors - mean_coords: :class:`numpy.ndarray` + mean_coords : :class:`numpy.ndarray` The mean coordinates Returns @@ -689,7 +694,7 @@ def _temporally_smooth(landmarks: np.ndarray) -> np.ndarray: Parameters ---------- - landmarks: :class:`numpy.ndarray` + landmarks : :class:`numpy.ndarray` 68 point landmarks to be temporally smoothed Returns @@ -715,7 +720,7 @@ def _update_alignments(self, landmarks: np.ndarray) -> None: Parameters ---------- - landmarks: :class:`numpy.ndarray` + landmarks : :class:`numpy.ndarray` The smoothed landmarks """ logger.debug("Update alignments") @@ -727,3 +732,6 @@ def _update_alignments(self, landmarks: np.ndarray) -> None: logger.trace("Updated: (frame: '%s', landmarks: %s)", # type:ignore frame, landmarks_xy) logger.debug("Updated alignments") + + +__all__ = get_module_objects(__name__) diff --git a/tools/alignments/jobs_faces.py b/tools/alignments/jobs_faces.py index ac2205f89c..066558c39a 100644 --- a/tools/alignments/jobs_faces.py +++ b/tools/alignments/jobs_faces.py @@ -13,6 +13,7 @@ from lib.align import DetectedFace from lib.image import update_existing_metadata # TODO remove +from lib.utils import get_module_objects from scripts.fsmedia import Alignments from .media import Faces @@ -482,3 +483,6 @@ def __call__(self) -> bool: retval = True logger.info("Updated alignments file from PNG Data: %s", self._counts) return retval + + +__all__ = get_module_objects(__name__) diff --git a/tools/alignments/jobs_frames.py b/tools/alignments/jobs_frames.py index 3c25b48121..fcedc13065 100644 --- a/tools/alignments/jobs_frames.py +++ b/tools/alignments/jobs_frames.py @@ -15,6 +15,7 @@ from lib.align import DetectedFace, EXTRACT_RATIOS, LANDMARK_PARTS, LandmarkType from lib.align.alignments import _VERSION, PNGHeaderDict from lib.image import encode_image, generate_thumbnail, ImagesSaver +from lib.utils import get_module_objects from plugins.extract import ExtractMedia, Extractor from .media import ExtractedFaces, Frames @@ -474,3 +475,6 @@ def _pad_legacy_masks(cls, detected_face: DetectedFace) -> None: # Get the affine matrix from recently generated components mask # pylint:disable=protected-access mask._affine_matrix = detected_face.mask["components"].affine_matrix + + +__all__ = get_module_objects(__name__) diff --git a/tools/alignments/media.py b/tools/alignments/media.py index a0d6a94365..b92d233ca0 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -17,7 +17,7 @@ from lib.align import Alignments, DetectedFace, update_legacy_png_header from lib.image import (count_frames, generate_thumbnail, ImagesLoader, png_write_meta, read_image, read_image_meta_batch) -from lib.utils import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, FaceswapError +from lib.utils import get_module_objects, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, FaceswapError if T.TYPE_CHECKING: from collections.abc import Generator @@ -440,8 +440,7 @@ def process_folder(self) -> Generator[dict[str, str], None, None]: The full framename, the filename and the file extension of the frame """ iterator = self.process_video if self.is_video else self.process_frames - for item in iterator(): - yield item + yield from iterator() def process_frames(self) -> Generator[dict[str, str], None, None]: """ Process exported Frames @@ -641,3 +640,6 @@ def get_roi_size_for_frame(self, frame: str) -> list[int]: sizes.append(length) logger.trace("sizes: '%s'", sizes) # type: ignore return sizes + + +__all__ = get_module_objects(__name__) diff --git a/tools/effmpeg/cli.py b/tools/effmpeg/cli.py index ac7647f8e4..8238855606 100644 --- a/tools/effmpeg/cli.py +++ b/tools/effmpeg/cli.py @@ -1,11 +1,10 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ -import argparse import gettext from lib.cli.args import FaceSwapArgs from lib.cli.actions import ContextFullPaths, FileFullPaths, Radio -from lib.utils import IMAGE_EXTENSIONS +from lib.utils import get_module_objects, IMAGE_EXTENSIONS # LOCALES @@ -201,35 +200,7 @@ def get_argument_list(): "default": False, "help": _("Increases output verbosity. If both quiet and verbose are set, verbose " "will override quiet.")}) - # Deprecated multi-character switches - argument_list.append({ - "opts": ('-fps', ), - "type": str, - "dest": "depr_fps_fps_R", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-ef", ), - "type": str, - "choices": IMAGE_EXTENSIONS, - "dest": "depr_extract-filetype_et_E", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ('-tr', ), - "choices": ("(0, 90CounterClockwise&VerticalFlip)", - "(1, 90Clockwise)", - "(2, 90CounterClockwise)", - "(3, 90Clockwise&VerticalFlip)"), - "type": lambda v: __parse_transpose(v), # pylint:disable=unnecessary-lambda - "dest": "depr_transpose_tr_T", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ('-de', ), - "type": str, - "dest": "depr_degrees_de_D", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ('-sc', ), - "type": str, - "dest": "depr_scale_sc_S", - "help": argparse.SUPPRESS}) return argument_list + + +__all__ = get_module_objects(__name__) diff --git a/tools/effmpeg/effmpeg.py b/tools/effmpeg/effmpeg.py index 187f0a08aa..28cce637c0 100644 --- a/tools/effmpeg/effmpeg.py +++ b/tools/effmpeg/effmpeg.py @@ -17,7 +17,8 @@ from ffmpy import FFmpeg, FFRuntimeError # faceswap imports -from lib.utils import handle_deprecated_cliopts, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS +from lib.utils import (get_module_objects, handle_deprecated_cliopts, IMAGE_EXTENSIONS, + VIDEO_EXTENSIONS) logger = logging.getLogger(__name__) @@ -76,8 +77,6 @@ def set_type_ext(self, path=None): self.type = item_type self.ext = item_ext logger.debug("path: '%s', type: '%s', ext: '%s'", self.path, self.type, self.ext) - else: - return def set_dirname(self, path=None): """ Set the folder name """ @@ -452,6 +451,7 @@ def __set_verbosity(cls, quiet, verbose): def __get_default_output(self): """ Set output to the same directory as input if the user didn't specify it. """ + retval = "" if self.args.output == "": if self.args.action in self._actions_have_dir_output: retval = os.path.join(self.input.dirname, "out") @@ -570,3 +570,6 @@ def parse_time(txt): retval = hours + ":" + minutes + ":" + seconds logger.debug("txt: '%s', retval: %s", txt, retval) return retval + + +__all__ = get_module_objects(__name__) diff --git a/tools/manual/cli.py b/tools/manual/cli.py index db27d785b6..bb34c007ba 100644 --- a/tools/manual/cli.py +++ b/tools/manual/cli.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 """ The Command Line Arguments for the Manual Editor tool. """ -import argparse import gettext from lib.cli.args import FaceSwapArgs from lib.cli.actions import DirOrFileFullPaths, FileFullPaths +from lib.utils import get_module_objects # LOCALES _LANG = gettext.translation("tools.manual", localedir="locales", fallback=True) @@ -65,15 +65,7 @@ def get_argument_list(): "video in parallel threads. For some videos, this causes the caching process to " "hang. If this happens, then set this option to generate the thumbnails in a " "slower, but more stable single thread.")}) - # Deprecated multi-character switches - argument_list.append({ - "opts": ("-al", ), - "type": str, - "dest": "depr_alignments_al_a", - "help": argparse.SUPPRESS}) - argument_list.append({ - "opts": ("-fr", ), - "type": str, - "dest": "depr_frames_fr_f", - "help": argparse.SUPPRESS}) return argument_list + + +__all__ = get_module_objects(__name__) diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index 7dcd90fc83..50129b4f83 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -19,7 +19,7 @@ from lib.gui.utils import FileHandler from lib.image import ImagesLoader, ImagesSaver, encode_image, generate_thumbnail from lib.multithreading import MultiThread -from lib.utils import get_folder +from lib.utils import get_folder, get_module_objects if T.TYPE_CHECKING: from . import manual @@ -934,3 +934,6 @@ def post_edit_trigger(self, frame_index: int, face_index: int) -> None: if self._globals.var_filter_mode.get() == "Misaligned Faces": self._detected_faces.tk_face_count_changed.set(True) self._tk_edited.set(True) + + +__all__ = get_module_objects(__name__) diff --git a/tools/manual/faceviewer/frame.py b/tools/manual/faceviewer/frame.py index 5c6c8f024b..30ebbdf7ef 100644 --- a/tools/manual/faceviewer/frame.py +++ b/tools/manual/faceviewer/frame.py @@ -17,6 +17,7 @@ from lib.gui.utils import get_config, get_images from lib.image import hex_to_rgb, rgb_to_hex from lib.logger import parse_class_init +from lib.utils import get_module_objects from .viewport import Viewport @@ -196,16 +197,20 @@ def _add_buttons(self) -> dict[T.Literal["mesh", "mask"], ttk.Button]: frame = ttk.Frame(self) frame.pack(side=tk.TOP, fill=tk.Y) buttons = {} + display: T.Literal["mask", "mesh"] for display in self.key_bindings.values(): var = tk.BooleanVar() var.set(False) self._tk_vars[display] = var lookup = "landmarks" if display == "mesh" else display - button = ttk.Button(frame, - image=get_images().icons[lookup], - command=T.cast(T.Callable, lambda t=display: self.on_click(t)), - style="display_deselected.TButton") + button = ttk.Button( + frame, + image=get_images().icons[lookup], + command=T.cast( + T.Callable, + lambda t=display: self.on_click(t)), # pyright:ignore[reportArgumentType] + style="display_deselected.TButton") button.state(["!pressed", "!focus"]) button.pack() Tooltip(button, text=self._helptext[display]) @@ -329,7 +334,9 @@ def _set_tk_callbacks(self, detected_faces: DetectedFaces): Toggles the face viewer annotations on an optional annotation button press. """ for strvar in (self._globals.var_faces_size, self._globals.var_filter_mode): - strvar.trace_add("write", lambda *e, v=strvar: self.refresh_grid(v)) + strvar.trace_add( + "write", + lambda *e, v=strvar: self.refresh_grid(v)) # pyright:ignore[reportArgumentType] boolvar = detected_faces.tk_face_count_changed boolvar.trace_add("write", lambda *e, v=boolvar: self.refresh_grid(v, retain_position=True)) @@ -342,7 +349,9 @@ def _set_tk_callbacks(self, detected_faces: DetectedFaces): "write", lambda *e: self._update_mask_type()) for opt, var in self._tk_optional_annotations.items(): - var.trace_add("write", lambda *e, o=opt: self._toggle_annotations(o)) + var.trace_add("write", + lambda *e, o=opt: self._toggle_annotations( + o)) # pyright:ignore[reportArgumentType] self.bind("", lambda *e: self._view.update()) @@ -586,7 +595,7 @@ def visible_area(self) -> tuple[np.ndarray, np.ndarray]: Any locations that are not populated by a face will have a frame and face index of -1 """ if not self._is_valid: - retval = np.zeros((4, 0, 0)), np.zeros((0, 0)) + retval: tuple[np.ndarray, np.ndarray] = np.zeros((4, 0, 0)), np.zeros((0, 0)) else: assert self._grid is not None assert self._display_faces is not None @@ -714,11 +723,11 @@ def _get_display_faces(self): columns, rows = self.columns_rows face_count = len(self._raw_indices["frame"]) padding = [None for _ in range(face_count, columns * rows)] - self._display_faces = np.array([None if idx is None else current_faces[idx][face_idx] - for idx, face_idx - in zip(self._raw_indices["frame"] + padding, - self._raw_indices["face"] + padding)], - dtype="object").reshape(rows, columns) + self._display_faces = np.array( + [None if idx is None or face_idx is None else current_faces[idx][face_idx] + for idx, face_idx + in zip(self._raw_indices["frame"] + padding, self._raw_indices["face"] + padding)], + dtype="object").reshape(rows, columns) logger.debug("faces: (shape: %s, dtype: %s)", self._display_faces.shape, self._display_faces.dtype) @@ -791,3 +800,6 @@ def _delete_face(self): "face_id: %s", self._frame_index, self._face_index) self._detected_faces.update.delete(self._frame_index, self._face_index) self._frame_index = self._face_index = None + + +__all__ = get_module_objects(__name__) diff --git a/tools/manual/faceviewer/interact.py b/tools/manual/faceviewer/interact.py index 124629320c..d083f33cb1 100644 --- a/tools/manual/faceviewer/interact.py +++ b/tools/manual/faceviewer/interact.py @@ -9,6 +9,7 @@ import numpy as np from lib.logger import parse_class_init +from lib.utils import get_module_objects if T.TYPE_CHECKING: from lib.align import DetectedFace @@ -421,3 +422,6 @@ def _show_mesh(self, self._canvas.coords(mesh_id, *landmarks[key][idx].flatten()) self._canvas.itemconfig(mesh_id, state=state, **kwarg) self._canvas.addtag_withtag(f"active_mesh_{key}", mesh_id) + + +__all__ = get_module_objects(__name__) diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index 94486a82f8..27ce490aff 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -11,6 +11,7 @@ from lib.align import AlignedFace, LANDMARK_PARTS, LandmarkType from lib.logger import parse_class_init +from lib.utils import get_module_objects from .interact import ActiveFrame, HoverBox @@ -316,6 +317,7 @@ def get_landmarks(self, part of the mesh annotation, from the top left corner location. """ key = f"{frame_index}_{face_index}" + landmarks: dict[T.Literal["polygon", "line"], list[np.ndarray]] | None landmarks = self._landmarks.get(key, None) if not landmarks or refresh: aligned = AlignedFace(face.landmarks_xy, @@ -412,7 +414,7 @@ def recycle_assets(self, asset_ids: list[int]) -> None: """ logger.trace("Recycling %s objects", len(asset_ids)) # type:ignore[attr-defined] for asset_id in asset_ids: - asset_type = self._canvas.type(asset_id) + asset_type = T.cast(T.Literal["image", "line", "polygon"], self._canvas.type(asset_id)) assert asset_type in self._assets coords = (0, 0, 0, 0) if asset_type == "line" else (0, 0) self._canvas.coords(asset_id, *coords) @@ -504,8 +506,8 @@ def __init__(self, viewport: Viewport) -> None: self._visible_grid = np.zeros((4, 0, 0)) self._visible_faces = np.zeros((0, 0)) self._recycler = Recycler(self._canvas) - self._images = np.zeros((0, 0), dtype=np.int64) - self._meshes = np.zeros((0, 0)) + self._images: np.ndarray = np.zeros((0, 0), dtype=np.int64) + self._meshes: np.ndarray = np.zeros((0, 0)) logger.debug("Initialized: %s", self.__class__.__name__) @property @@ -650,8 +652,8 @@ def _add_rows(self, existing_rows: int, required_rows: int) -> None: meshes.append([{} if face is None else self._recycler.get_mesh(face) for face in self._visible_faces[row]]) - a_images = np.array(images) - a_meshes = np.array(meshes) + a_images: np.ndarray = np.array(images) + a_meshes: np.ndarray = np.array(meshes) if not np.any(self._images): logger.debug("Adding initial viewport objects: (image shapes: %s, mesh shapes: %s)", @@ -717,8 +719,8 @@ def __init__(self, face: np.ndarray, size: int = 128, mask: np.ndarray | None = # << PUBLIC PROPERTIES >> # @property - def photo(self) -> tk.PhotoImage: - """ :class:`tkinter.PhotoImage`: The face in a format that can be placed on the + def photo(self) -> ImageTk.PhotoImage: + """ :class:`PIL.ImageTk.PhotoImage`: The face in a format that can be placed on the :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas. """ return self._photo @@ -760,13 +762,14 @@ def _image_from_jpg(self, face: np.ndarray) -> np.ndarray: :class:`numpy.ndarray` The decoded jpg as a 3 channel BGR image """ - face = cv2.imdecode(face, cv2.IMREAD_UNCHANGED) - interp = cv2.INTER_CUBIC if face.shape[0] < self._size else cv2.INTER_AREA - if face.shape[0] != self._size: - face = cv2.resize(face, (self._size, self._size), interpolation=interp) - return face[..., 2::-1] - - def _generate_tk_face_data(self, mask: np.ndarray | None) -> tk.PhotoImage: + retval = cv2.imdecode(face, cv2.IMREAD_UNCHANGED) + assert retval is not None + interp = cv2.INTER_CUBIC if retval.shape[0] < self._size else cv2.INTER_AREA + if retval.shape[0] != self._size: + face = cv2.resize(retval, (self._size, self._size), interpolation=interp) + return retval[..., 2::-1] + + def _generate_tk_face_data(self, mask: np.ndarray | None) -> Image.Image: """ Create the :class:`tkinter.PhotoImage` from the currant :attr:`_face`. Parameters @@ -776,7 +779,7 @@ def _generate_tk_face_data(self, mask: np.ndarray | None) -> tk.PhotoImage: Returns ------- - :class:`tkinter.PhotoImage` + :class:`PIL.Image.Image` The face formatted for the :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas. """ mask = np.ones(self._face.shape[:2], dtype="uint8") * 255 if mask is None else mask @@ -784,3 +787,6 @@ def _generate_tk_face_data(self, mask: np.ndarray | None) -> tk.PhotoImage: mask = cv2.resize(mask, self._face.shape[:2], interpolation=cv2.INTER_AREA) img = np.concatenate((self._face, mask[..., None]), axis=-1) return Image.fromarray(img) + + +__all__ = get_module_objects(__name__) diff --git a/tools/manual/frameviewer/control.py b/tools/manual/frameviewer/control.py index 8315cf6a3a..5bbb681bdf 100644 --- a/tools/manual/frameviewer/control.py +++ b/tools/manual/frameviewer/control.py @@ -10,6 +10,7 @@ from PIL import Image, ImageTk from lib.align import AlignedFace +from lib.utils import get_module_objects logger = logging.getLogger(__name__) @@ -299,3 +300,6 @@ def _resize_frame(self): self._globals.frame_display_dims[1] / 2) img = self._tk_face if self._current_view_mode == "face" else self._tk_frame self._canvas.itemconfig(self._image, image=img) + + +__all__ = get_module_objects(__name__) diff --git a/tools/manual/frameviewer/editor/_base.py b/tools/manual/frameviewer/editor/_base.py index d295c0d847..10906f301a 100644 --- a/tools/manual/frameviewer/editor/_base.py +++ b/tools/manual/frameviewer/editor/_base.py @@ -449,7 +449,7 @@ def _drag(self, event): """ if self._drag_callback is None: return - self._drag_callback(event) + self._drag_callback(event) # pylint:disable=not-callable def _drag_stop(self, event): # pylint:disable=unused-argument """ The action to perform when the user stops clicking and dragging the mouse. diff --git a/tools/manual/frameviewer/editor/bounding_box.py b/tools/manual/frameviewer/editor/bounding_box.py index d546feb172..1f07e76da7 100644 --- a/tools/manual/frameviewer/editor/bounding_box.py +++ b/tools/manual/frameviewer/editor/bounding_box.py @@ -8,6 +8,7 @@ import numpy as np from lib.gui.custom_widgets import RightClickMenu +from lib.utils import get_module_objects from ._base import ControlPanelOption, Editor, logger @@ -408,3 +409,6 @@ def _delete_current_face(self, *args): # pylint:disable=unused-argument return logger.debug("Deleting face. _mouse_location: %s", self._mouse_location) self._det_faces.update.delete(self._globals.frame_index, int(self._mouse_location[1])) + + +__all__ = get_module_objects(__name__) diff --git a/tools/manual/frameviewer/editor/extract_box.py b/tools/manual/frameviewer/editor/extract_box.py index ffe8bf4734..e13cb9f9af 100644 --- a/tools/manual/frameviewer/editor/extract_box.py +++ b/tools/manual/frameviewer/editor/extract_box.py @@ -8,6 +8,7 @@ from lib.align import AlignedFace from lib.gui.custom_widgets import RightClickMenu from lib.gui.utils import get_config +from lib.utils import get_module_objects from ._base import Editor, logger @@ -132,6 +133,7 @@ def _check_cursor_anchors(self): bool ``True`` if cursor is over an anchor point otherwise ``False`` """ + # pylint:disable=duplicate-code anchors = set(self._canvas.find_withtag("eb_anc_grb")) item_ids = set(self._canvas.find_withtag("current")).intersection(anchors) if not item_ids: @@ -192,6 +194,7 @@ def _check_cursor_rotate(self, event): bool ``True`` if cursor is over a rotate point otherwise ``False`` """ + # pylint:disable=duplicate-code distance = 30 boxes = np.array([np.array(self._canvas.coords(item_id)).reshape(4, 2) for item_id in self._canvas.find_withtag("eb_box") @@ -208,6 +211,7 @@ def _check_cursor_rotate(self, event): # Mouse click actions def set_mouse_click_actions(self): """ Add context menu to OS specific right click action. """ + # pylint:disable=duplicate-code super().set_mouse_click_actions() self._canvas.bind("" if platform.system() == "Darwin" else "", self._context_menu) @@ -406,3 +410,6 @@ def _delete_current_face(self, *args): # pylint:disable=unused-argument if self._mouse_location is None or self._mouse_location[0] != "box": return self._det_faces.update.delete(self._globals.frame_index, self._mouse_location[1]) + + +__all__ = get_module_objects(__name__) diff --git a/tools/manual/frameviewer/editor/landmarks.py b/tools/manual/frameviewer/editor/landmarks.py index e59517e7b0..2e502cb57d 100644 --- a/tools/manual/frameviewer/editor/landmarks.py +++ b/tools/manual/frameviewer/editor/landmarks.py @@ -4,6 +4,7 @@ import numpy as np from lib.align import AlignedFace, LANDMARK_PARTS, LandmarkType +from lib.utils import get_module_objects from ._base import Editor, logger # LOCALES @@ -200,7 +201,6 @@ def _update_cursor(self, event): else: self._canvas.config(cursor="") self._mouse_location = None - return def _hide_labels(self): """ Clear all landmark text labels from display """ @@ -431,7 +431,7 @@ class Mesh(Editor): def __init__(self, canvas, detected_faces): super().__init__(canvas, detected_faces, None) - def update_annotation(self): + def update_annotation(self): # pylint:disable=too-many-locals """ Get the latest Landmarks and update the mesh.""" key = "mesh" color = self._control_color @@ -463,3 +463,6 @@ def update_annotation(self): self._object_tracker(key, asset, face_index, pts, kwargs) # Place mesh as bottom annotation self._canvas.tag_raise(self.__class__.__name__, "main_image") + + +__all__ = get_module_objects(__name__) diff --git a/tools/manual/frameviewer/editor/mask.py b/tools/manual/frameviewer/editor/mask.py index fec2c92d13..5101cde3a8 100644 --- a/tools/manual/frameviewer/editor/mask.py +++ b/tools/manual/frameviewer/editor/mask.py @@ -7,6 +7,8 @@ import cv2 from PIL import Image, ImageTk +from lib.utils import get_module_objects + from ._base import ControlPanelOption, Editor, logger # LOCALES @@ -605,3 +607,6 @@ def _adjust_brush_radius(self, increase=True): # pylint:disable=unused-argument for idx, coord in enumerate(current_coords)) logger.trace("Adjusting brush coordinates from %s to %s", current_coords, new_coords) self._canvas.coords(self._mouse_location[0], new_coords) + + +__all__ = get_module_objects(__name__) diff --git a/tools/manual/frameviewer/frame.py b/tools/manual/frameviewer/frame.py index e83f3492f8..e7ed015198 100644 --- a/tools/manual/frameviewer/frame.py +++ b/tools/manual/frameviewer/frame.py @@ -11,6 +11,7 @@ from lib.gui.control_helper import set_slider_rounding from lib.gui.custom_widgets import Tooltip from lib.gui.utils import get_images +from lib.utils import get_module_objects from .control import Navigation, BackgroundImage from .editor import (BoundingBox, ExtractBox, Landmarks, Mask, # noqa pylint:disable=unused-import @@ -822,3 +823,6 @@ def _bind_unbind_keys(self): logger.debug("Binding key '%s' to method %s", key, method) self.winfo_toplevel().bind(key, method) self.key_bindings[key]["bound_to"] = self.selected_action + + +__all__ = get_module_objects(__name__) diff --git a/tools/manual/globals.py b/tools/manual/globals.py index 07843f552e..548ebdc27d 100644 --- a/tools/manual/globals.py +++ b/tools/manual/globals.py @@ -14,7 +14,7 @@ from lib.gui.utils import get_config from lib.logger import parse_class_init -from lib.utils import VIDEO_EXTENSIONS +from lib.utils import get_module_objects, VIDEO_EXTENSIONS logger = logging.getLogger(__name__) @@ -307,3 +307,6 @@ def set_zoomed(self, state: bool) -> None: logger.trace("Setting zoom state from %s to %s", # type:ignore[attr-defined] self.is_zoomed, state) self._tk_vars.is_zoomed.set(state) + + +__all__ = get_module_objects(__name__) diff --git a/tools/manual/manual.py b/tools/manual/manual.py index 2516a62b9c..e426d41704 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -18,7 +18,7 @@ from lib.image import SingleFrameLoader, read_image_meta from lib.logger import parse_class_init from lib.multithreading import MultiThread -from lib.utils import handle_deprecated_cliopts +from lib.utils import get_module_objects, handle_deprecated_cliopts from plugins.extract import ExtractMedia, Extractor from .detected_faces import DetectedFaces @@ -29,7 +29,8 @@ if T.TYPE_CHECKING: from argparse import Namespace - from lib.align import DetectedFace, Mask + from lib import align + from lib.align import DetectedFace from lib.queue_manager import EventQueue logger = logging.getLogger(__name__) @@ -70,7 +71,7 @@ def __init__(self, arguments: Namespace) -> None: self._initialize_tkinter() self._globals = TkGlobals(arguments.frames) - extractor = Aligner(self._globals, arguments.exclude_gpus) + extractor = Aligner(self._globals) self._detected_faces = DetectedFaces(self._globals, arguments.alignments_path, arguments.frames, @@ -440,15 +441,11 @@ class Aligner(): ---------- tk_globals: :class:`~tools.manual.manual.TkGlobals` The tkinter variables that apply to the whole of the GUI - 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. """ - 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) + def __init__(self, tk_globals: TkGlobals) -> None: + logger.debug("Initializing: %s (tk_globals: %s)", + self.__class__.__name__, tk_globals) self._globals = tk_globals - self._exclude_gpus = exclude_gpus self._detected_faces: DetectedFaces | None = None self._frame_index: int | None = None @@ -520,11 +517,9 @@ def _init_aligner(self) -> None: 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 aligner = Extractor(None, plugin, ["components", "extended"], - exclude_gpus=exclude_gpus, multiprocess=True, normalize_method="hist", disable_filter=True) @@ -596,7 +591,7 @@ def _remove_nn_masks(self, detected_face: DetectedFace) -> None: for mask in del_masks: del detected_face.mask[mask] - def get_masks(self, frame_index: int, face_index: int) -> dict[str, Mask]: + def get_masks(self, frame_index: int, face_index: int) -> dict[str, align.aligned_mask.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. @@ -776,3 +771,6 @@ def _set_frame(self, # pylint:disable=unused-argument self._current_idx = position self._globals.var_full_update.set(True) self._globals.var_update_active_viewport.set(True) + + +__all__ = get_module_objects(__name__) diff --git a/tools/manual/thumbnails.py b/tools/manual/thumbnails.py index d1992c83ce..7586b29429 100644 --- a/tools/manual/thumbnails.py +++ b/tools/manual/thumbnails.py @@ -16,6 +16,7 @@ from lib.align import AlignedFace from lib.image import SingleFrameLoader, generate_thumbnail from lib.multithreading import MultiThread +from lib.utils import get_module_objects if T.TYPE_CHECKING: from .detected_faces import DetectedFaces @@ -299,3 +300,6 @@ def _set_thumbail(self, filename: str, frame: np.ndarray, frame_index: int) -> N with self._pbar.lock: assert self._pbar.pbar is not None self._pbar.pbar.update(1) + + +__all__ = get_module_objects(__name__) diff --git a/tools/mask/cli.py b/tools/mask/cli.py index cc14bb1b9d..44a5c6c7ec 100644 --- a/tools/mask/cli.py +++ b/tools/mask/cli.py @@ -1,13 +1,14 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ -import argparse import gettext from lib.cli.args import FaceSwapArgs from lib.cli.actions import (DirOrFileFullPaths, DirFullPaths, FileFullPaths, Radio, Slider) +from lib.utils import get_module_objects from plugins.plugin_loader import PluginLoader +# pylint:disable=duplicate-code # LOCALES _LANG = gettext.translation("tools.mask.cli", localedir="locales", fallback=True) _ = _LANG.gettext @@ -236,10 +237,7 @@ def get_argument_list(): "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.")}) - # Deprecated multi-character switches - argument_list.append({ - "opts": ("-it", ), - "type": str, - "dest": "depr_input-type_it_I", - "help": argparse.SUPPRESS}) return argument_list + + +__all__ = get_module_objects(__name__) diff --git a/tools/mask/loader.py b/tools/mask/loader.py index 8f50d81c48..191fdd46c1 100644 --- a/tools/mask/loader.py +++ b/tools/mask/loader.py @@ -10,13 +10,12 @@ 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.align import alignments, DetectedFace, update_legacy_png_header from lib.image import FacesLoader, ImagesLoader +from lib.utils import get_module_objects from plugins.extract import ExtractMedia if T.TYPE_CHECKING: - from lib.align import Alignments from lib.align.alignments import PNGHeaderDict logger = logging.getLogger(__name__) @@ -38,7 +37,7 @@ def __init__(self, location: str, is_faces: bool) -> None: self._is_faces = is_faces self._loader = FacesLoader(location) if is_faces else ImagesLoader(location) - self._alignments: Alignments | None = None + self._alignments: alignments.Alignments | None = None self._skip_count = 0 logger.debug("Initialized %s", self.__class__.__name__) @@ -64,19 +63,19 @@ def skip_count(self) -> int: file """ return self._skip_count - def add_alignments(self, alignments: Alignments | None) -> None: + def add_alignments(self, alignments_object: alignments.Alignments | None) -> None: """ Add the loaded alignments to :attr:`_alignments` for content matching Parameters ---------- - alignments: :class:`~lib.align.Alignments` | None + alignments_object: :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 + logger.debug("Adding alignments to loader: %s", alignments_object) + self._alignments = alignments_object @classmethod - def _get_detected_face(cls, alignment: AlignmentFileDict) -> DetectedFace: + def _get_detected_face(cls, alignment: alignments.AlignmentFileDict) -> DetectedFace: """ Convert an alignment dict item to a detected_face object Parameters @@ -119,16 +118,16 @@ def _process_face(self, if self._alignments is None: # mask from PNG header lookup_index = 0 - alignments = [T.cast(AlignmentFileDict, metadata["alignments"])] + aligns = [T.cast(alignments.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: + aligns = self._alignments.get_faces_in_frame(frame_name) + if not aligns or face_index > len(aligns) - 1: self._skip_count += 1 logger.warning("Skipping Face not found in alignments file: '%s'", filename) return None - alignment = alignments[lookup_index] + alignment = aligns[lookup_index] detected_face = self._get_detected_face(alignment) retval = ExtractMedia(filename, image, detected_faces=[detected_face], is_aligned=True) @@ -214,9 +213,11 @@ def load(self) -> T.Generator[ExtractMedia, None, None]: else: iterator = self._from_frames - for media in iterator(): - yield media + yield from iterator() if self._skip_count > 0: logger.warning("%s face(s) skipped due to not existing in the alignments file", self._skip_count) + + +__all__ = get_module_objects(__name__) diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 25e129bda2..a849cb0b5a 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -10,7 +10,7 @@ from lib.align import Alignments -from lib.utils import handle_deprecated_cliopts, VIDEO_EXTENSIONS +from lib.utils import get_module_objects, handle_deprecated_cliopts, VIDEO_EXTENSIONS from plugins.extract import ExtractMedia from .loader import Loader @@ -175,8 +175,7 @@ def __init__(self, arguments: Namespace) -> None: self._input_is_faces, self._loader, self._alignments, - arguments.input, - arguments.exclude_gpus) + arguments.input) logger.debug("Initialized %s", self.__class__.__name__) @@ -305,3 +304,6 @@ def process(self) -> None: self._output.close() logger.debug("Completed masker process") + + +__all__ = get_module_objects(__name__) diff --git a/tools/mask/mask_generate.py b/tools/mask/mask_generate.py index eb3cd6af44..ca4971cf38 100644 --- a/tools/mask/mask_generate.py +++ b/tools/mask/mask_generate.py @@ -8,13 +8,15 @@ from lib.image import encode_image, ImagesSaver from lib.multithreading import MultiThread +from lib.utils import get_module_objects from plugins.extract import Extractor if T.TYPE_CHECKING: - from lib.align import Alignments, DetectedFace - from lib.align.alignments import PNGHeaderDict + from lib import align + from lib.align import DetectedFace from lib.queue_manager import EventQueue from plugins.extract import ExtractMedia + from plugins.extract.mask.bisenet_fp import Mask as bfp_mask from .loader import Loader @@ -33,8 +35,6 @@ class MaskGenerator: ``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 """ @@ -43,19 +43,18 @@ def __init__(self, update_all: bool, input_is_faces: bool, loader: Loader, - alignments: Alignments | None, - input_location: str, - exclude_gpus: list[int]) -> None: + alignments: align.alignments.Alignments | None, + input_location: str) -> 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)", + "loader: %s, alignments: %s, input_location: %s)", self.__class__.__name__, mask_type, update_all, input_is_faces, loader, - alignments, input_location, exclude_gpus) + alignments, input_location) self._update_all = update_all self._is_faces = input_is_faces self._alignments = alignments - self._extractor = self._get_extractor(mask_type, exclude_gpus) + self._extractor = self._get_extractor(mask_type) 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 @@ -64,16 +63,13 @@ def __init__(self, logger.debug("Initialized %s", self.__class__.__name__) - def _get_extractor(self, mask_type, exclude_gpus: list[int]) -> Extractor: + def _get_extractor(self, mask_type) -> 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 ------- @@ -81,13 +77,13 @@ def _get_extractor(self, mask_type, exclude_gpus: list[int]) -> Extractor: The launched Extractor """ logger.debug("masker: %s", mask_type) - extractor = Extractor(None, None, mask_type, exclude_gpus=exclude_gpus) + extractor = Extractor(None, None, mask_type) 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 + """ Some masks have multiple variants that they can be saved depending on config options Parameters ---------- @@ -103,10 +99,10 @@ def _set_correct_mask_type(self, mask_type: str) -> str: return mask_type # Hacky look up into masker to get the type of mask - mask_plugin = self._extractor._mask[0] # pylint:disable=protected-access + mask_plugin = T.cast("bfp_mask | None", + 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}" + new_type = f"{mask_type}_{mask_plugin.storage_centering}" logger.debug("Updating '%s' to '%s'", mask_type, new_type) return new_type @@ -208,7 +204,8 @@ def _update_from_face(self, media: ExtractMedia) -> None: 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} + meta: align.alignments.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: @@ -267,3 +264,6 @@ def process(self) -> T.Generator[ExtractMedia, None, None]: self._finalize() logger.debug("Completed MaskGenerator process") + + +__all__ = get_module_objects(__name__) diff --git a/tools/mask/mask_import.py b/tools/mask/mask_import.py index 4192ce03ab..2b382fa56a 100644 --- a/tools/mask/mask_import.py +++ b/tools/mask/mask_import.py @@ -13,14 +13,14 @@ from lib.align import AlignedFace from lib.image import encode_image, ImagesSaver -from lib.utils import get_image_paths +from lib.utils import get_image_paths, get_module_objects if T.TYPE_CHECKING: import numpy as np from .loader import Loader from plugins.extract import ExtractMedia - from lib.align import Alignments, DetectedFace - from lib.align.alignments import PNGHeaderDict + from lib import align + from lib.align import DetectedFace from lib.align.aligned_face import CenteringType logger = logging.getLogger(__name__) @@ -52,7 +52,7 @@ def __init__(self, storage_size: int, input_is_faces: bool, loader: Loader, - alignments: Alignments | None, + alignments: align.alignments.Alignments | None, input_location: str, mask_type: str) -> None: logger.debug("Initializing %s (import_path: %s, centering: %s, storage_size: %s, " @@ -62,7 +62,7 @@ def __init__(self, self._validate_mask_type(mask_type) - self._centering = centering + self._centering: CenteringType = centering self._size = storage_size self._is_faces = input_is_faces self._alignments = alignments @@ -329,7 +329,8 @@ def _store_mask_face(self, media: ExtractMedia, mask: np.ndarray) -> None: 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} + meta: align.alignments.PNGHeaderDict = {"alignments": face.to_png_meta(), + "source": media.frame_metadata} self._saver.save(media.filename, encode_image(media.image, ".png", metadata=meta)) @classmethod @@ -393,7 +394,7 @@ def import_mask(self, media: ExtractMedia) -> None: logger.warning("No mask file found for: '%s'", os.path.basename(media.filename)) return - mask = cv2.imread(mask_file, cv2.IMREAD_GRAYSCALE) + mask = T.cast("np.ndarray", 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) @@ -404,3 +405,6 @@ def import_mask(self, media: ExtractMedia) -> None: self._store_mask_face(media, mask) else: self._store_mask_frame(media, mask) + + +__all__ = get_module_objects(__name__) diff --git a/tools/mask/mask_output.py b/tools/mask/mask_output.py index 79ffb809e3..bf10f98681 100644 --- a/tools/mask/mask_output.py +++ b/tools/mask/mask_output.py @@ -16,11 +16,12 @@ from lib.align.alignments import AlignmentDict from lib.image import ImagesSaver, read_image_meta_batch -from lib.utils import get_folder +from lib.utils import get_folder, get_module_objects from scripts.fsmedia import Alignments as ExtractAlignments if T.TYPE_CHECKING: - from lib.align import Alignments, DetectedFace + from lib import align + from lib.align import DetectedFace from lib.align.aligned_face import CenteringType logger = logging.getLogger(__name__) @@ -33,13 +34,13 @@ class Output: ---------- arguments: :class:`argparse.Namespace` The command line arguments that the mask tool was called with - alignments: :class:~`lib.align.alignments.Alignments` | None + 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, + alignments: align.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)) @@ -112,21 +113,21 @@ def _set_saver(self, output: str | None, processing: str) -> ImagesSaver | None: return retval def _get_alignments(self, - alignments: Alignments | None, - file_list: list[str]) -> Alignments | None: + alignments: align.alignments.Alignments | None, + file_list: list[str]) -> align.alignments.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 + 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 + :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: @@ -517,3 +518,6 @@ def close(self) -> None: return logger.debug("Shutting down saver") self._saver.close() + + +__all__ = get_module_objects(__name__) diff --git a/tools/model/cli.py b/tools/model/cli.py index 68d1e8e455..d7be707daf 100644 --- a/tools/model/cli.py +++ b/tools/model/cli.py @@ -5,6 +5,7 @@ from lib.cli.args import FaceSwapArgs from lib.cli.actions import DirFullPaths, Radio +from lib.utils import get_module_objects # LOCALES _LANG = gettext.translation("tools.restore.cli", localedir="locales", fallback=True) @@ -48,19 +49,6 @@ def get_argument_list() -> list[dict[str, T.Any]]: "format." "\nL|'nan-scan' - Scan the model file for NaNs or Infs (invalid data)." "\nL|'restore' - Restore a model from backup.")}) - argument_list.append({ - "opts": ("-f", "--format"), - "action": Radio, - "type": str, - "choices": ("h5", "saved-model"), - "default": "h5", - "group": _("inference"), - "help": _( - "R|The format to save the model as. Note: Only used for 'inference' job." - "\nL|'h5' - Standard Keras H5 format. Does not store any custom layer " - "information. Layers will need to be loaded from Faceswap to use." - "\nL|'saved-model' - Tensorflow's Saved Model format. Contains all information " - "required to load the model outside of Faceswap.")}) argument_list.append({ "opts": ("-s", "--swap-model"), "action": "store_true", @@ -71,3 +59,6 @@ def get_argument_list() -> list[dict[str, T.Any]]: "Only used for 'inference' job. Generate the inference model for B -> A instead " "of A -> B.")}) return argument_list + + +__all__ = get_module_objects(__name__) diff --git a/tools/model/model.py b/tools/model/model.py index 0cb3a033b3..80c1520a51 100644 --- a/tools/model/model.py +++ b/tools/model/model.py @@ -6,15 +6,18 @@ import sys import typing as T +from keras import saving import numpy as np -import tensorflow as tf -from tensorflow import keras +import keras + from lib.model.backup_restore import Backup +from lib.logger import parse_class_init # Import the following libs for custom objects from lib.model import initializers, layers, normalization # noqa # pylint:disable=unused-import -from plugins.train.model._base.model import _Inference +from lib.utils import get_module_objects +from plugins.train.model._base.model import Inference as FSInference if T.TYPE_CHECKING: @@ -32,19 +35,13 @@ class Model(): The command line arguments calling the model tool """ def __init__(self, arguments: argparse.Namespace) -> None: - logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) - self._configure_tensorflow() + logger.debug(parse_class_init(locals())) self._model_dir = self._check_folder(arguments.model_dir) self._job = self._get_job(arguments) + logger.debug("Initialized %s", self.__class__.__name__) @classmethod - def _configure_tensorflow(cls) -> None: - """ Disable eager execution and force Tensorflow into CPU mode. """ - tf.config.set_visible_devices([], device_type="GPU") - tf.compat.v1.disable_eager_execution() - - @classmethod - def _get_job(cls, arguments: argparse.Namespace) -> T.Any: + def _get_job(cls, arguments: argparse.Namespace) -> Inference | NaNScan | Restore: """ Get the correct object that holds the selected job. Parameters @@ -55,12 +52,13 @@ def _get_job(cls, arguments: argparse.Namespace) -> T.Any: Returns ------- - class + :class:`Inference` | :class:`NaNScan` | :class:`Restore` The object that will perform the selected job """ - jobs = {"inference": Inference, - "nan-scan": NaNScan, - "restore": Restore} + jobs: dict[str, T.Type[Inference | NaNScan | Restore]] = { + "inference": Inference, + "nan-scan": NaNScan, + "restore": Restore} return jobs[arguments.job](arguments) @classmethod @@ -85,7 +83,7 @@ def _check_folder(cls, model_dir: str) -> str: chkfiles = [fname for fname in os.listdir(model_dir) - if fname.endswith(".h5") + if fname.endswith(".keras") and not os.path.splitext(fname)[0].endswith("_inference")] if not chkfiles: @@ -114,9 +112,10 @@ class Inference(): The command line arguments calling the model tool """ def __init__(self, arguments: argparse.Namespace) -> None: + logger.debug(parse_class_init(locals())) self._switch = arguments.swap_model - self._format = arguments.format self._input_file, self._output_file = self._get_output_file(arguments.model_dir) + logger.debug("Initialized %s", self.__class__.__name__) def _get_output_file(self, model_dir: str) -> tuple[str, str]: """ Obtain the full path for the output model file/folder @@ -124,7 +123,7 @@ def _get_output_file(self, model_dir: str) -> tuple[str, str]: Parameters ---------- model_dir: str - The full path to the folder containing the Faceswap trained model .h5 file + The full path to the folder containing the Faceswap trained model .keras file Returns ------- @@ -133,12 +132,13 @@ def _get_output_file(self, model_dir: str) -> tuple[str, str]: str The full path to the inference model save location """ - model_name = next(fname for fname in os.listdir(model_dir) if fname.endswith(".h5")) + model_name = next(fname for fname in os.listdir(model_dir) + if fname.endswith(".keras") + and not fname.endswith("_inference.keras")) in_path = os.path.join(model_dir, model_name) logger.debug("Model input path: '%s'", in_path) - model_name = f"{os.path.splitext(model_name)[0]}_inference" - model_name = f"{model_name}.h5" if self._format == "h5" else model_name + model_name = f"{os.path.splitext(model_name)[0]}_inference.keras" out_path = os.path.join(model_dir, model_name) logger.debug("Inference output path: '%s'", out_path) return in_path, out_path @@ -146,9 +146,9 @@ def _get_output_file(self, model_dir: str) -> tuple[str, str]: def process(self) -> None: """ Run the inference model creation process. """ logger.info("Loading model '%s'", self._input_file) - model = keras.models.load_model(self._input_file, compile=False) + model = saving.load_model(self._input_file, compile=False) logger.info("Creating inference model...") - inference = _Inference(model, self._switch).model + inference = FSInference(model, self._switch).model logger.info("Saving to: '%s'", self._output_file) inference.save(self._output_file) @@ -162,12 +162,13 @@ class NaNScan(): The command line arguments calling the model tool """ def __init__(self, arguments: argparse.Namespace) -> None: - logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) + logger.debug(parse_class_init(locals())) self._model_file = self._get_model_filename(arguments.model_dir) + logger.debug("Initialized %s", self.__class__.__name__) @classmethod def _get_model_filename(cls, model_dir: str) -> str: - """ Obtain the full path the model's .h5 file. + """ Obtain the full path the model's .keras file. Parameters ---------- @@ -179,7 +180,7 @@ def _get_model_filename(cls, model_dir: str) -> str: str The full path to the saved model file """ - model_file = next(fname for fname in os.listdir(model_dir) if fname.endswith(".h5")) + model_file = next(fname for fname in os.listdir(model_dir) if fname.endswith(".keras")) return os.path.join(model_dir, model_file) def _parse_weights(self, @@ -233,7 +234,7 @@ def _parse_output(self, errors: dict, indent: int = 0) -> None: def process(self) -> None: """ Scan the loaded model for NaNs and Infs and output summary. """ logger.info("Loading model...") - model = keras.models.load_model(self._model_file, compile=False) + model = saving.load_model(self._model_file, compile=False) logger.info("Parsing weights for invalid values...") errors = self._parse_weights(model) @@ -254,9 +255,10 @@ class Restore(): The command line arguments calling the model tool """ def __init__(self, arguments: argparse.Namespace) -> None: - logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) + logger.debug(parse_class_init(locals())) self._model_dir = arguments.model_dir self._model_name = self._get_model_name() + logger.debug("Initialized %s", self.__class__.__name__) def process(self) -> None: """ Perform the Restore process """ @@ -272,7 +274,11 @@ def _get_model_name(self) -> str: logger.error("Could not find any backup files in the supplied folder: '%s'", self._model_dir) sys.exit(1) - logger.verbose("Backup files: %s)", bkfiles) # type:ignore + logger.verbose("Backup files: %s)", bkfiles) # type:ignore[attr-defined] + + ext = ".keras.bk" + model_name = next(fname for fname in bkfiles if fname.endswith(ext)) + return model_name[:-len(ext)] + - model_name = next(fname for fname in bkfiles if fname.endswith(".h5.bk")) - return model_name[:-6] +__all__ = get_module_objects(__name__) diff --git a/tools/preview/cli.py b/tools/preview/cli.py index 147f0449cc..d2cbf75d14 100644 --- a/tools/preview/cli.py +++ b/tools/preview/cli.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ -import argparse import gettext import typing as T from lib.cli.args import FaceSwapArgs from lib.cli.actions import DirOrFileFullPaths, DirFullPaths, FileFullPaths +from lib.utils import get_module_objects +# pylint:disable=duplicate-code # LOCALES _LANG = gettext.translation("tools.preview", localedir="locales", fallback=True) _ = _LANG.gettext @@ -72,10 +73,7 @@ def get_argument_list() -> list[dict[str, T.Any]]: "dest": "swap_model", "default": False, "help": _("Swap the model. Instead of A -> B, swap B -> A")}) - # Deprecated multi-character switches - argument_list.append({ - "opts": ("-al", ), - "type": str, - "dest": "depr_alignments_al_a", - "help": argparse.SUPPRESS}) return argument_list + + +__all__ = get_module_objects(__name__) diff --git a/tools/preview/control_panels.py b/tools/preview/control_panels.py index 3dc55ba2e2..6a6947515c 100644 --- a/tools/preview/control_panels.py +++ b/tools/preview/control_panels.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Manages the widgets that hold the bottom 'control' area of the preview tool """ +"""Manages the widgets that hold the bottom 'control' area of the preview tool.""" from __future__ import annotations import gettext import logging @@ -8,13 +8,14 @@ import tkinter as tk from tkinter import ttk -from configparser import ConfigParser from lib.gui.custom_widgets import Tooltip from lib.gui.control_helper import ControlPanel, ControlPanelOption +from lib.logger import parse_class_init from lib.gui.utils import get_images +from lib.utils import get_module_objects from plugins.plugin_loader import PluginLoader -from plugins.convert._config import Config +from plugins.convert import convert_config if T.TYPE_CHECKING: from collections.abc import Callable @@ -28,190 +29,164 @@ class ConfigTools(): - """ Tools for loading, saving, setting and retrieving configuration file values. + """Tools for loading, saving, setting and retrieving configuration file values. + + Parameters + ---------- + config_file : str | None + Path to a custom config .ini file or ``None`` to load the default config file Attributes ---------- - tk_vars: dict + tk_vars : dict[str, dict[str, tk.BooleanVar | tk.StringVar | tk.IntVar | tk.DoubleVar]]] Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` """ - def __init__(self) -> None: - self._config = Config(None) - self.tk_vars: dict[str, dict[str, tk.BooleanVar | tk.StringVar | tk.IntVar | tk.DoubleVar] - ] = {} - self._config_dicts = self._get_config_dicts() # Holds currently saved config - @property - def config(self) -> Config: - """ :class:`plugins.convert._config.Config` The convert configuration """ - return self._config + def __init__(self, config_file: str | None) -> None: + logger.debug(parse_class_init(locals())) + self._config = convert_config.load_config(config_file=config_file) + self.tk_vars: dict[str, dict[str, tk.Variable]] = {} + self._config_dicts = self._get_config_dicts() # Holds currently saved config @property - def config_dicts(self) -> dict[str, T.Any]: - """ dict: The convert configuration options in dictionary form.""" + def config_dicts(self) -> dict[str, dict[str, ControlPanelOption]]: + """dict[str, dict[str, ControlPanelOption]] : The convert configuration options in + dictionary form.""" return self._config_dicts @property def sections(self) -> list[str]: - """ list: The sorted section names that exist within the convert Configuration options. """ - return sorted(set(plugin.split(".")[0] for plugin in self._config.config.sections() - if plugin.split(".")[0] != "writer")) + """list: The sorted section names that exist within the convert Configuration options.""" + return sorted(set(sect.split(".")[0] for sect in self._config.sections + if sect.split(".")[0] != "writer")) @property def plugins_dict(self) -> dict[str, list[str]]: - """ dict: Dictionary of configuration option sections as key with a list of containing - plugins as the value """ - return {section: sorted([plugin.split(".")[1] for plugin in self._config.config.sections() - if plugin.split(".")[0] == section]) + """dict[str, list[str]] : Dictionary of configuration option sections as key with a list + of containing plugin names as the value""" + return {section: sorted([sect.split(".")[1] for sect in self._config.sections + if sect.split(".")[0] == section]) for section in self.sections} - def update_config(self) -> None: - """ Update :attr:`config` with the currently selected values from the GUI. """ - for section, items in self.tk_vars.items(): - for item, value in items.items(): - try: - new_value = str(value.get()) - except tk.TclError as err: - # When manually filling in text fields, blank values will - # raise an error on numeric data types so return 0 - logger.debug("Error getting value. Defaulting to 0. Error: %s", str(err)) - new_value = str(0) - old_value = self._config.config[section][item] - if new_value != old_value: - logger.trace("Updating config: %s, %s from %s to %s", # type: ignore - section, item, old_value, new_value) - self._config.config[section][item] = new_value - - def _get_config_dicts(self) -> dict[str, dict[str, T.Any]]: - """ Obtain a custom configuration dictionary for convert configuration items in use + def _get_config_dicts(self) -> dict[str, dict[str, ControlPanelOption]]: + """Obtain a custom configuration dictionary for convert configuration items in use by the preview tool formatted for control helper. Returns ------- - dict - Each configuration section as keys, with the values as a dict of option: - :class:`lib.gui.control_helper.ControlOption` pairs. """ + dict[str, str | dict[str, ControlPanelOption]] + Each configuration section as keys, with the values as a dict of option_name to + :class:`lib.gui.control_helper.ControlOption`.""" logger.debug("Formatting Config for GUI") - config_dicts: dict[str, dict[str, T.Any]] = {} - for section in self._config.config.sections(): - if section.startswith("writer."): + config_dicts: dict[str, dict[str, ControlPanelOption]] = {} + for section_name, section in self._config.sections.items(): + if section_name.startswith("writer."): continue - for key, val in self._config.defaults[section].items.items(): - if key == "helptext": - config_dicts.setdefault(section, {})[key] = val - continue - cp_option = ControlPanelOption(title=key, - dtype=val.datatype, - group=val.group, - default=val.default, - initial_value=self._config.get(section, key), - choices=val.choices, - is_radio=val.gui_radio, - rounding=val.rounding, - min_max=val.min_max, - helptext=val.helptext) - self.tk_vars.setdefault(section, {})[key] = cp_option.tk_var - config_dicts.setdefault(section, {})[key] = cp_option + cp_options: dict[str, ControlPanelOption] = {} + for option_name, option in section.options.items(): + cp_option = ControlPanelOption.from_config_object(option_name, option) + cp_options[option_name] = cp_option + self.tk_vars.setdefault(section_name, {})[option_name] = cp_option.tk_var + config_dicts[section_name] = cp_options logger.debug("Formatted Config for GUI: %s", config_dicts) return config_dicts + def update_config(self) -> None: + """Update :attr:`config` with the currently selected values from the GUI.""" + for section, options in self.tk_vars.items(): + for option_name, tk_option in options.items(): + try: + new_value = tk_option.get() + except tk.TclError as err: + # When manually filling in text fields, blank values will + # raise an error on numeric data types so return 0 + logger.trace( # type:ignore[attr-defined] + "Error getting value. Defaulting to 0. Error: %s", str(err)) + new_value = "" if isinstance(tk_option, tk.StringVar) else 0 + option = self._config.sections[section].options[option_name] + old_value = option.value + if new_value == old_value or (isinstance(old_value, list) and + set(str(new_value).split()) == set(old_value)): + logger.trace("Skipping unchanged option '%s'", # type:ignore[attr-defined] + option_name) + logger.debug("Updating config: '%s', '%s' from %s to %s", + section, option_name, repr(old_value), repr(new_value)) + option.set(new_value) + def reset_config_to_saved(self, section: str | None = None) -> None: - """ Reset the GUI parameters to their saved values within the configuration file. + """Reset the GUI parameters to their saved values within the configuration file. Parameters ---------- - section: str, optional + section : str | None, optional The configuration section to reset the values for, If ``None`` provided then all sections are reset. Default: ``None`` """ logger.debug("Resetting to saved config: %s", section) sections = [section] if section is not None else list(self.tk_vars.keys()) - for config_section in sections: - for item, options in self._config_dicts[config_section].items(): - if item == "helptext": - continue - val = options.value - if val != self.tk_vars[config_section][item].get(): - self.tk_vars[config_section][item].set(val) - logger.debug("Setting %s - %s to saved value %s", config_section, item, val) + for section_name in sections: + for option_name, tk_option in self._config_dicts[section_name].items(): + val = tk_option.value + if val != self.tk_vars[section_name][option_name].get(): + self.tk_vars[section_name][option_name].set(val) + logger.debug("Setting '%s' - '%s' to saved value %s", + section_name, option_name, repr(val)) logger.debug("Reset to saved config: %s", section) def reset_config_to_default(self, section: str | None = None) -> None: - """ Reset the GUI parameters to their default configuration values. + """Reset the GUI parameters to their default configuration values. Parameters ---------- - section: str, optional + section : str | None, optional The configuration section to reset the values for, If ``None`` provided then all sections are reset. Default: ``None`` """ logger.debug("Resetting to default: %s", section) sections = [section] if section is not None else list(self.tk_vars.keys()) - for config_section in sections: - for item, options in self._config_dicts[config_section].items(): - if item == "helptext": - continue + for section_name in sections: + for option_name, options in self._config_dicts[section_name].items(): default = options.default - if default != self.tk_vars[config_section][item].get(): - self.tk_vars[config_section][item].set(default) - logger.debug("Setting %s - %s to default value %s", - config_section, item, default) + if default != self.tk_vars[section_name][option_name].get(): + self.tk_vars[section_name][option_name].set(default) + logger.debug("Setting '%s' - '%s' to default value %s", + section_name, option_name, repr(default)) logger.debug("Reset to default: %s", section) def save_config(self, section: str | None = None) -> None: - """ Save the configuration ``.ini`` file with the currently stored values. - - Notes - ----- - We cannot edit the existing saved config as comments tend to get removed, so we create - a new config and populate that. + """Save the configuration ``.ini`` file with the currently stored values. Parameters ---------- - section: str, optional + section : str | None, optional The configuration section to save, If ``None`` provided then all sections are saved. Default: ``None`` """ logger.debug("Saving %s config", section) - new_config = ConfigParser(allow_no_value=True) - - for section_name, sect in self._config.defaults.items(): - logger.debug("Adding section: '%s')", section_name) - self._config.insert_config_section(section_name, - sect.helptext, - config=new_config) - for item, options in sect.items.items(): - if item == "helptext": - continue # helptext already written at top - if ((section is not None and section_name != section) - or section_name not in self.tk_vars): - # retain saved values that have not been updated - new_opt = self._config.get(section_name, item) - logger.debug("Retaining option: (item: '%s', value: '%s')", item, new_opt) - else: - new_opt = self.tk_vars[section_name][item].get() - logger.debug("Setting option: (item: '%s', value: '%s')", item, new_opt) - - # Set config_dicts value to new saved value - self._config_dicts[section_name][item].set_initial_value(new_opt) - - helptext = self._config.format_help(options.helptext, is_section=False) - new_config.set(section_name, helptext) - new_config.set(section_name, item, str(new_opt)) + for section_name, sect in self._config.sections.items(): + if section_name not in self._config_dicts: + logger.debug("[%s] Skipping section not in local config", section_name) + continue + if section is not None and section_name != section: + logger.debug("[%s] Skipping section not selected for saving", section_name) + continue + for option_name, option in sect.options.items(): + new_opt = self.tk_vars[section_name][option_name].get() + fmt_opt = str(new_opt).split() if isinstance(option.value, list) else new_opt + logger.debug("[%s] Setting '%s' to %s", section_name, option_name, repr(fmt_opt)) + option.set(new_opt) - self._config.config = new_config self._config.save_config() - logger.info("Saved config: '%s'", self._config.configfile) class BusyProgressBar(): - """ An infinite progress bar for when a thread is running to swap/patch a group of samples """ + """An infinite progress bar for when a thread is running to swap/patch a group of samples""" def __init__(self, parent: ttk.Frame) -> None: self._progress_bar = self._add_busy_indicator(parent) def _add_busy_indicator(self, parent: ttk.Frame) -> ttk.Progressbar: - """ Place progress bar into bottom bar to indicate when processing. + """Place progress bar into bottom bar to indicate when processing. Parameters ---------- @@ -230,7 +205,7 @@ def _add_busy_indicator(self, parent: ttk.Frame) -> ttk.Progressbar: return pbar def stop(self) -> None: - """ Stop and hide progress bar """ + """Stop and hide progress bar""" logger.debug("Stopping busy indicator") if not self._progress_bar.winfo_ismapped(): logger.debug("busy indicator already hidden") @@ -239,7 +214,7 @@ def stop(self) -> None: self._progress_bar.pack_forget() def start(self) -> None: - """ Start and display progress bar """ + """Start and display progress bar""" logger.debug("Starting busy indicator") if self._progress_bar.winfo_ismapped(): logger.debug("busy indicator already started") @@ -250,7 +225,7 @@ def start(self) -> None: class ActionFrame(ttk.Frame): # pylint:disable=too-many-ancestors - """ Frame that holds the left hand side options panel containing the command line options. + """Frame that holds the left hand side options panel containing the command line options. Parameters ---------- @@ -266,7 +241,7 @@ def __init__(self, app: Preview, parent: ttk.Frame) -> None: super().__init__(parent) self.pack(side=tk.LEFT, anchor=tk.N, fill=tk.Y) - self._tk_vars: dict[str, tk.StringVar] = {} + self._tk_vars: dict[str, tk.Variable] = {} self._options = { "color": app._patch.converter.cli_arguments.color_adjustment.replace("-", "_"), @@ -282,7 +257,7 @@ def __init__(self, app: Preview, parent: ttk.Frame) -> None: @property def convert_args(self) -> dict[str, T.Any]: - """ dict: Currently selected Command line arguments from the :class:`ActionFrame`. """ + """dict: Currently selected Command line arguments from the :class:`ActionFrame`.""" retval = {opt if opt != "color" else "color_adjustment": self._format_from_display(self._tk_vars[opt].get()) for opt in self._options if opt != "face_scale"} @@ -291,13 +266,15 @@ def convert_args(self) -> dict[str, T.Any]: @property def busy_progress_bar(self) -> BusyProgressBar: - """ :class:`BusyProgressBar`: The progress bar that appears on the left hand side whilst a - swap/patch is being applied """ + """ + :class:`BusyProgressBar`: The progress bar that appears on the left hand side whilst a + swap/patch is being applied. + """ return self._busy_bar @staticmethod def _format_from_display(var: str) -> str: - """ Format a variable from the display version to the command line action version. + """Format a variable from the display version to the command line action version. Parameters ---------- @@ -313,7 +290,8 @@ def _format_from_display(var: str) -> str: @staticmethod def _format_to_display(var: str) -> str: - """ Format a variable from the command line action version to the display version. + """Format a variable from the command line action version to the display version. + Parameters ---------- var: str @@ -332,7 +310,7 @@ def _build_frame(self, patch_callback: Callable[[], None], available_masks: list[str], has_predicted_mask: bool) -> BusyProgressBar: - """ Build the :class:`ActionFrame`. + """Build the :class:`ActionFrame`. Parameters ---------- @@ -373,8 +351,7 @@ def _add_cli_choices(self, defaults: dict[str, T.Any], available_masks: list[str], has_predicted_mask: bool) -> None: - """ Create :class:`lib.gui.control_helper.ControlPanel` object for the command - line options. + """Create :class:`lib.gui.control_helper.ControlPanel` object for the command line options. parent: :class:`ttk.Frame` The frame to hold the command line choices @@ -393,8 +370,7 @@ def _get_control_panel_options(self, defaults: dict[str, T.Any], available_masks: list[str], has_predicted_mask: bool) -> list[ControlPanelOption]: - """ Create :class:`lib.gui.control_helper.ControlPanelOption` objects for the command - line options. + """Create :class:`lib.gui.control_helper.ControlPanelOption` objects for the cli options. defaults: dict The default command line options @@ -439,7 +415,7 @@ def _create_mask_choices(self, defaults: dict[str, T.Any], available_masks: list[str], has_predicted_mask: bool) -> list[str]: - """ Set the mask choices and default mask based on available masks. + """Set the mask choices and default mask based on available masks. Parameters ---------- @@ -470,7 +446,7 @@ def _create_mask_choices(self, def _add_refresh_button(cls, parent: ttk.Frame, refresh_callback: Callable[[], None]) -> None: - """ Add a button to refresh the images. + """Add a button to refresh the images. Parameters ---------- @@ -481,7 +457,7 @@ def _add_refresh_button(cls, btn.pack(padx=5, pady=5, side=tk.TOP, fill=tk.X, anchor=tk.N) def _add_patch_callback(self, patch_callback: Callable[[], None]) -> None: - """ Add callback to re-patch images on action option change. + """Add callback to re-patch images on action option change. Parameters ---------- @@ -492,7 +468,7 @@ def _add_patch_callback(self, patch_callback: Callable[[], None]) -> None: tk_var.trace("w", patch_callback) def _add_actions(self, parent: ttk.Frame) -> None: - """ Add Action Buttons to the :class:`ActionFrame` + """Add Action Buttons to the :class:`ActionFrame`. Parameters ---------- @@ -502,7 +478,8 @@ def _add_actions(self, parent: ttk.Frame) -> None: logger.debug("Adding util buttons") frame = ttk.Frame(parent) frame.pack(padx=5, pady=(5, 10), side=tk.RIGHT, fill=tk.X, anchor=tk.E) - + text = "" + action: T.Callable[[], T.Any] | None = None for utl in ("save", "clear", "reload"): logger.debug("Adding button: '%s'", utl) img = get_images().icons[utl] @@ -516,8 +493,9 @@ def _add_actions(self, parent: ttk.Frame) -> None: text = _("Reset full config to saved values") action = self._app.config_tools.reset_config_to_saved + assert action is not None btnutl = ttk.Button(frame, - image=img, + image=img, # type:ignore[arg-type] command=action) btnutl.pack(padx=2, side=tk.RIGHT) Tooltip(btnutl, text=text, wrap_length=200) @@ -525,7 +503,8 @@ def _add_actions(self, parent: ttk.Frame) -> None: class OptionsBook(ttk.Notebook): # pylint:disable=too-many-ancestors - """ The notebook that holds the Convert configuration options. + + """The notebook that holds the Convert configuration options. Parameters ---------- @@ -558,7 +537,7 @@ def __init__(self, logger.debug("Initialized %s", self.__class__.__name__) def _build_tabs(self) -> None: - """ Build the notebook tabs for the each configuration section. """ + """Build the notebook tabs for the each configuration section.""" logger.debug("Build Tabs") for section in self.config_tools.sections: tab = ttk.Notebook(self) @@ -566,7 +545,7 @@ def _build_tabs(self) -> None: self.add(tab, text=section.replace("_", " ").title()) def _build_sub_tabs(self) -> None: - """ Build the notebook sub tabs for each convert section's plugin. """ + """Build the notebook sub tabs for each convert section's plugin.""" for section, plugins in self.config_tools.plugins_dict.items(): for plugin in plugins: config_key = ".".join((section, plugin)) @@ -577,7 +556,7 @@ def _build_sub_tabs(self) -> None: T.cast(ttk.Notebook, self._tabs[section]["tab"]).add(tab, text=text) def _add_patch_callback(self, patch_callback: Callable[[], None]) -> None: - """ Add callback to re-patch images on configuration option change. + """Add callback to re-patch images on configuration option change. Parameters ---------- @@ -590,7 +569,7 @@ def _add_patch_callback(self, patch_callback: Callable[[], None]) -> None: class ConfigFrame(ttk.Frame): # pylint:disable=too-many-ancestors - """ Holds the configuration options for a convert plugin inside the :class:`OptionsBook`. + """Holds the configuration options for a convert plugin inside the :class:`OptionsBook`. Parameters ---------- @@ -620,7 +599,7 @@ def __init__(self, logger.debug("Initialized %s", self.__class__.__name__) def _build_frame(self, parent: OptionsBook, config_key: str) -> None: - """ Build the options frame for this command + """Build the options frame for this command. Parameters ---------- @@ -639,14 +618,14 @@ def _build_frame(self, parent: OptionsBook, config_key: str) -> None: logger.debug("Added Config Frame") def _add_frame_separator(self) -> None: - """ Add a separator between top and bottom frames. """ + """Add a separator between top and bottom frames.""" logger.debug("Add frame seperator") sep = ttk.Frame(self._action_frame, height=2, relief=tk.RIDGE) sep.pack(fill=tk.X, pady=5, side=tk.TOP) logger.debug("Added frame seperator") def _add_actions(self, parent: OptionsBook, config_key: str) -> None: - """ Add Action Buttons. + """Add Action Buttons. Parameters ---------- @@ -660,6 +639,8 @@ def _add_actions(self, parent: OptionsBook, config_key: str) -> None: title = config_key.split(".")[1].replace("_", " ").title() btn_frame = ttk.Frame(self._action_frame) btn_frame.pack(padx=5, side=tk.BOTTOM, fill=tk.X) + text = "" + action = None for utl in ("save", "clear", "reload"): logger.debug("Adding button: '%s'", utl) img = get_images().icons[utl] @@ -674,8 +655,11 @@ def _add_actions(self, parent: OptionsBook, config_key: str) -> None: action = parent.config_tools.reset_config_to_saved btnutl = ttk.Button(btn_frame, - image=img, - command=lambda cmd=action: cmd(config_key)) # type: ignore + image=img, # type:ignore[arg-type] + command=lambda cmd=action: cmd(config_key)) # type:ignore[misc] btnutl.pack(padx=2, side=tk.RIGHT) Tooltip(btnutl, text=text, wrap_length=200) logger.debug("Added util buttons") + + +__all__ = get_module_objects(__name__) diff --git a/tools/preview/preview.py b/tools/preview/preview.py index 4ac81b3627..9e888867bb 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -19,7 +19,7 @@ from lib.cli.args_extract_convert import ConvertArgs from lib.gui.utils import get_images, get_config, initialize_config, initialize_images from lib.convert import Converter -from lib.utils import FaceswapError, handle_deprecated_cliopts +from lib.utils import get_module_objects, FaceswapError, handle_deprecated_cliopts from lib.queue_manager import queue_manager from scripts.fsmedia import Alignments, Images from scripts.convert import Predict, ConvertItem @@ -60,7 +60,7 @@ def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) super().__init__() arguments = handle_deprecated_cliopts(arguments) - self._config_tools = ConfigTools() + self._config_tools = ConfigTools(arguments.configfile) self._lock = Lock() self._dispatcher = Dispatcher(self) self._display = FacesDisplay(self, 256, 64) @@ -579,7 +579,7 @@ def _process(self, self._feed_swapped_faces(patch_queue_in, samples) with self._app.lock: self._update_converter_arguments() - self._converter.reinitialize(config=self._app.config_tools.config) + self._converter.reinitialize() swapped = self._patch_faces(patch_queue_in, patch_queue_out, samples.sample_size) with self._app.lock: self._app.display.destination = swapped @@ -651,3 +651,6 @@ def _patch_faces(self, idx += 1 logger.debug("Patched faces") return swapped + + +__all__ = get_module_objects(__name__) diff --git a/tools/preview/viewer.py b/tools/preview/viewer.py index 7abe11b96d..fc5586ba50 100644 --- a/tools/preview/viewer.py +++ b/tools/preview/viewer.py @@ -15,6 +15,7 @@ from lib.align import transform_image from lib.align.aligned_face import CenteringType +from lib.utils import get_module_objects from scripts.convert import ConvertItem @@ -33,7 +34,7 @@ class _Faces: dst: list[np.ndarray] = field(default_factory=list) -class FacesDisplay(): +class FacesDisplay(): # pylint:disable=too-many-instance-attributes """ Compiles the 2 rows of sample faces (original and swapped) into a single image Parameters @@ -118,7 +119,7 @@ def update_tk_image(self) -> None: size = self._get_scale_size(img) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) pilimg = Image.fromarray(img) - pilimg = pilimg.resize(size, Image.ANTIALIAS) + pilimg = pilimg.resize(size, Image.Resampling.BICUBIC) self._tk_image = ImageTk.PhotoImage(pilimg) logger.trace("Updated tk image") # type: ignore @@ -294,3 +295,6 @@ def reload(self) -> None: self._display.update_tk_image() self._canvas.itemconfig(self._displaycanvas, image=self._display.tk_image) logger.debug("Reloaded preview image") + + +__all__ = get_module_objects(__name__) diff --git a/tools/sort/cli.py b/tools/sort/cli.py index d85b9637a5..607bd68a2e 100644 --- a/tools/sort/cli.py +++ b/tools/sort/cli.py @@ -1,13 +1,14 @@ #!/usr/bin/env python3 """ Command Line Arguments for tools """ -import argparse import gettext from lib.cli.args import FaceSwapArgs from lib.cli.actions import DirFullPaths, SaveFileFullPaths, Radio, Slider +from lib.utils import get_module_objects -# LOCALES +# pylint:disable=duplicate-code +# # LOCALES _LANG = gettext.translation("tools.sort.cli", localedir="locales", fallback=True) _ = _LANG.gettext @@ -63,11 +64,11 @@ (("color-black", "color-gray", "color-luma", "color-green", "color-orange"), _GPCOLOR), (("yaw", "pitch", "roll"), _GPDEGREES), (("blur", "blur-fft", "distance", "size"), _GPLINEAR)] -_SORT_HELP = "" +_sort_help = "" _GROUP_HELP = "" for method in sorted(_METHOD_TEXT): - _SORT_HELP += f"\nL|{method}: {_('Sort')} {_METHOD_TEXT[method]}" + _sort_help += f"\nL|{method}: {_('Sort')} {_METHOD_TEXT[method]}" _GROUP_HELP += (f"\nL|{method}: {_('Group')} {_METHOD_TEXT[method]} " f"{next((x[1] for x in _BIN_TYPES if method in x[0]), '')}") @@ -126,7 +127,7 @@ def get_argument_list(): "\nL|'none': Don't sort the images. When a 'group-by' method is selected, " "selecting 'none' means that the files will be moved/copied into their respective " "bins, but the files will keep their original filenames. Selecting 'none' for " - "both 'sort-by' and 'group-by' will do nothing" + _SORT_HELP + "\nDefault: face")}) + "both 'sort-by' and 'group-by' will do nothing" + _sort_help + "\nDefault: face")}) argument_list.append({ "opts": ('-g', '--group-by'), "action": Radio, @@ -221,10 +222,7 @@ def get_argument_list(): "Specify a log file to use for saving the renaming or grouping information. If " "specified extension isn't 'json' or 'yaml', then json will be used as the " "serializer, with the supplied filename. Default: sort_log.json")}) - # Deprecated multi-character switches - argument_list.append({ - "opts": ("-lf", ), - "type": str, - "dest": "depr_log-file_lf_f", - "help": argparse.SUPPRESS}) return argument_list + + +__all__ = get_module_objects(__name__) diff --git a/tools/sort/sort.py b/tools/sort/sort.py index c963f9af3b..80dfc9566e 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -15,7 +15,7 @@ # faceswap imports from lib.serializer import Serializer, get_serializer_from_filename -from lib.utils import handle_deprecated_cliopts +from lib.utils import get_module_objects, handle_deprecated_cliopts from .sort_methods import SortBlur, SortColor, SortFace, SortHistogram, SortMultiMethod from .sort_methods_aligned import SortDistance, SortFaceCNN, SortPitch, SortSize, SortYaw, SortRoll @@ -329,3 +329,6 @@ def _output_non_grouped(self) -> None: dest = os.path.join(output_dir, f"{idx:06d}_{os.path.basename(source)}") self._sort_file(source, dest) + + +__all__ = get_module_objects(__name__) diff --git a/tools/sort/sort_methods.py b/tools/sort/sort_methods.py index f2a4b29526..273f7fe8be 100644 --- a/tools/sort/sort_methods.py +++ b/tools/sort/sort_methods.py @@ -18,7 +18,7 @@ from lib.align import AlignedFace, DetectedFace, LandmarkType from lib.image import FacesLoader, ImagesLoader, read_image_meta_batch, update_existing_metadata -from lib.utils import FaceswapError +from lib.utils import get_module_objects, FaceswapError from plugins.extract.recognition.vgg_face2 import Cluster, Recognition as VGGFace if T.TYPE_CHECKING: @@ -611,7 +611,7 @@ def estimate_blur(self, image: np.ndarray, alignments=None) -> float: image = self._mask_face(image, alignments) if image.ndim == 3: image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) - blur_map = cv2.Laplacian(image, cv2.CV_32F) + blur_map = T.cast(np.ndarray, cv2.Laplacian(image, cv2.CV_32F)) score = np.var(blur_map) / np.sqrt(image.shape[0] * image.shape[1]) return score @@ -851,7 +851,7 @@ class SortFace(SortMethod): def __init__(self, arguments: Namespace, is_group: bool = False) -> None: super().__init__(arguments, loader_type="all", is_group=is_group) - self._vgg_face = VGGFace(exclude_gpus=arguments.exclude_gpus) + self._vgg_face = VGGFace() self._vgg_face.init_model() threshold = arguments.threshold self._output_update_info = True @@ -875,6 +875,7 @@ def score_image(self, alignments: dict or ``None`` The alignments dictionary for the aligned face or ``None`` """ + # pylint:disable=duplicate-code if not alignments: msg = ("The images to be sorted do not contain alignment data. Images must have " "been generated by Faceswap's Extract process.\nIf you are sorting an " @@ -983,25 +984,27 @@ def _calc_histogram(self, def _sort_dissim(self) -> None: """ Sort histograms by dissimilarity """ - img_list_len = len(self._result) + result = T.cast(list[tuple[str, np.ndarray]], self._result) + img_list_len = len(result) for i in tqdm(range(0, img_list_len), desc="Comparing histograms", file=sys.stdout, leave=False): - score_total = 0 + score_total = 0.0 for j in range(0, img_list_len): if i == j: continue - score_total += cv2.compareHist(self._result[i][1], - self._result[j][1], + score_total += cv2.compareHist(result[i][1], + result[j][1], cv2.HISTCMP_BHATTACHARYYA) - self._result[i][2] = score_total + result[i][2] = score_total - self._result = sorted(self._result, key=operator.itemgetter(2), reverse=True) + self._result = sorted(result, key=operator.itemgetter(2), reverse=True) def _sort_sim(self) -> None: """ Sort histograms by similarity """ - img_list_len = len(self._result) + result = T.cast(list[tuple[str, np.ndarray]], self._result) + img_list_len = len(result) for i in tqdm(range(0, img_list_len - 1), desc="Comparing histograms", file=sys.stdout, @@ -1009,14 +1012,13 @@ def _sort_sim(self) -> None: min_score = float("inf") j_min_score = i + 1 for j in range(i + 1, img_list_len): - score = cv2.compareHist(self._result[i][1], - self._result[j][1], + score = cv2.compareHist(result[i][1], + result[j][1], cv2.HISTCMP_BHATTACHARYYA) if score < min_score: min_score = score j_min_score = j - (self._result[i + 1], self._result[j_min_score]) = (self._result[j_min_score], - self._result[i + 1]) + (self._result[i + 1], self._result[j_min_score]) = (result[j_min_score], result[i + 1]) @classmethod def _get_avg_score(cls, image: np.ndarray, references: list[np.ndarray]) -> float: @@ -1042,6 +1044,7 @@ def _get_avg_score(cls, image: np.ndarray, references: list[np.ndarray]) -> floa def binning(self) -> list[list[str]]: """ Group into bins by histogram """ + # pylint:disable=duplicate-code msg = "dissimilarity" if self._is_dissim else "similarity" logger.info("Grouping by %s...", msg) @@ -1108,3 +1111,6 @@ def sort(self) -> None: self._sort_dissim() return self._sort_sim() + + +__all__ = get_module_objects(__name__) diff --git a/tools/sort/sort_methods_aligned.py b/tools/sort/sort_methods_aligned.py index 8f0ff0b8ea..5cb3ba99e1 100644 --- a/tools/sort/sort_methods_aligned.py +++ b/tools/sort/sort_methods_aligned.py @@ -12,7 +12,7 @@ from tqdm import tqdm from lib.align import AlignedFace, LandmarkType -from lib.utils import FaceswapError +from lib.utils import get_module_objects, FaceswapError from .sort_methods import SortMethod if T.TYPE_CHECKING: @@ -391,3 +391,6 @@ def _get_avg_score(cls, face: np.ndarray, references: list[np.ndarray]) -> float score = np.sum(np.absolute((ref - face).flatten())) scores.append(score) return sum(scores) / len(scores) + + +__all__ = get_module_objects(__name__) diff --git a/update_deps.py b/update_deps.py index 8065de564b..0fb48b8be0 100644 --- a/update_deps.py +++ b/update_deps.py @@ -8,6 +8,7 @@ import sys from lib.logger import log_setup +from lib.utils import get_module_objects from setup import Environment, Install logger = logging.getLogger(__name__) @@ -32,3 +33,6 @@ def main(is_gui=False) -> None: logfile = os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), "faceswap_update.log") log_setup("INFO", logfile, "setup") main() + + +__all__ = get_module_objects(__name__) From 030b4fa8795a4c22db58149a3eadb072b9a96487 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 23 Dec 2025 15:10:48 +0000 Subject: [PATCH 933/981] bugfix: Keras 2to3, store dtypes in Keras3 friendly manner --- plugins/train/model/_base/update.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/plugins/train/model/_base/update.py b/plugins/train/model/_base/update.py index 7c8b69f2c2..2ee4ecbb99 100644 --- a/plugins/train/model/_base/update.py +++ b/plugins/train/model/_base/update.py @@ -157,7 +157,12 @@ def _convert_lambda_config(self, layer: dict[str, T.Any]): if operation not in ("multiply", "truediv", "add", "subtract"): raise FaceswapError(f"The TFLambdaOp '{name}' is not supported") value = layer["inbound_nodes"][0][-1]["y"] - new_layer = ScalarOp(operation, value, name=name, dtype=layer["config"]["dtype"]) + + if isinstance(layer["config"]["dtype"], str): + dtype = layer["config"]["dtype"] + else: + dtype = layer["config"]["dtype"]["config"]["name"] + new_layer = ScalarOp(operation, value, name=name, dtype=dtype) logger.debug("Converting legacy TFLambdaOp: %s", layer) @@ -202,6 +207,24 @@ def _process_deprecations(self, layer: dict[str, T.Any]) -> None: logger.debug("Removing groups from DepthwiseConv2D '%s'", layer["name"]) del layer["config"]["groups"] + if "dtype" in layer["config"]: + # Incorrectly stored dtypes error when deserializing the new config. May be a Keras bug + actual_dtype = None + old_dtype = layer["config"]["dtype"] + if isinstance(old_dtype, str): + actual_dtype = layer["config"]["dtype"] + if isinstance(old_dtype, dict) and old_dtype.get("class_name") == "Policy": + actual_dtype = old_dtype["config"]["name"] + + if actual_dtype is not None: + new_dtype = {"module": "keras", + "class_name": "DTypePolicy", + "config": {"name": actual_dtype}, + "registered_name": None} + logger.debug("Updating dtype for '%s' from %s to %s", layer["name"], + old_dtype, new_dtype) + layer["config"]["dtype"] = new_dtype + def _process_inbounds(self, layer_name: str, inbound_nodes: list[list[list[str | int]]] | list[list[str | int]] From c53dcca2876c86b5009c9002ff0d2bce7ee75b64 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 27 Dec 2025 14:01:17 +0000 Subject: [PATCH 934/981] Bugfixes: - Keras 2 to 3, remove 'group' parameter from Conv2DTranspose - Suppress Keras warning about backend padding differences - Flatten non-list Fully Connected inputs to silence Keras warnings --- plugins/train/model/_base/update.py | 5 +++-- plugins/train/model/phaze_a.py | 2 ++ plugins/train/training.py | 7 +++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/plugins/train/model/_base/update.py b/plugins/train/model/_base/update.py index 2ee4ecbb99..2130c0b464 100644 --- a/plugins/train/model/_base/update.py +++ b/plugins/train/model/_base/update.py @@ -202,9 +202,10 @@ def _process_deprecations(self, layer: dict[str, T.Any]) -> None: # TFLambdaOp are not supported self._convert_lambda_config(layer) - if layer["class_name"] == "DepthwiseConv2D" and "groups" in layer["config"]: + if layer["class_name"] in ("DepthwiseConv2D", + "Conv2DTranspose") and "groups" in layer["config"]: # groups parameter doesn't exist in Keras 3. Hopefully it still works the same - logger.debug("Removing groups from DepthwiseConv2D '%s'", layer["name"]) + logger.debug("Removing groups from %s '%s'", layer["class_name"], layer["name"]) del layer["config"]["groups"] if "dtype" in layer["config"]: diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 9ca419ef0c..21903cd619 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -426,6 +426,8 @@ def _build_fully_connected( inter_a.append(fc_gblock(inputs["a"])) inter_b.append(fc_gblock(inputs["b"])) + inter_a = inter_a[0] if len(inter_a) == 1 else inter_a + inter_b = inter_b[0] if len(inter_b) == 1 else inter_b retval = {"a": inter_a, "b": inter_b} logger.debug("Fully Connected: %s", retval) return retval diff --git a/plugins/train/training.py b/plugins/train/training.py index 74d2953094..a74a73e0ab 100644 --- a/plugins/train/training.py +++ b/plugins/train/training.py @@ -6,6 +6,7 @@ import os import typing as T import time +import warnings import numpy as np import torch @@ -27,6 +28,12 @@ logger = logging.getLogger(__name__) +# Suppress non-Faceswap related Keras warning about backend padding mismatches +warnings.filterwarnings("ignore", + message="You might experience inconsistencies", + category=UserWarning) + + class Trainer: """ Handles the feeding of training images to Faceswap models, the generation of Tensorboard logs and the creation of sample/time-lapse preview images. From 81b6002c35c02ff4395a8458a1cde8b5cea433de Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 31 Dec 2025 14:24:16 +0000 Subject: [PATCH 935/981] Bugfixes: - Convert: Fix error when 'learn mask' has been selected - Bisenet-FP mask: Correctly store mask for face or head centering --- plugins/extract/mask/bisenet_fp.py | 2 +- plugins/train/model/_base/inference.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/extract/mask/bisenet_fp.py b/plugins/extract/mask/bisenet_fp.py index b42d62d578..8e95638eb6 100644 --- a/plugins/extract/mask/bisenet_fp.py +++ b/plugins/extract/mask/bisenet_fp.py @@ -49,7 +49,7 @@ def __init__(self, **kwargs) -> None: self.batchsize = cfg.batch_size() self._segment_indices = self._get_segment_indices() - self.storage_centering = "head" if cfg.include_hair() else "face" + self._storage_centering = "head" if cfg.include_hair() else "face" """ Literal["head", "face"] The mask type/storage centering to use """ # Separate storage for face and head masks self._storage_name = f"{self._storage_name}_{self._storage_centering}" diff --git a/plugins/train/model/_base/inference.py b/plugins/train/model/_base/inference.py index 2ee4065a64..d5fa6ef97e 100644 --- a/plugins/train/model/_base/inference.py +++ b/plugins/train/model/_base/inference.py @@ -272,8 +272,9 @@ def _build(self): built = self._build_layers(layers, history, built) assert len(self._input) == 1 - assert len(built) == 1 - retval = keras.Model(inputs=self._input[0], outputs=built[0], name=self._name) + assert len(built) in (1, 2) + out = built[0] if len(built) == 1 else built + retval = keras.Model(inputs=self._input[0], outputs=out, name=self._name) logger.debug("Compiled inference model '%s': %s", retval.name, retval) return retval From 342e19d56ebce4d65096d5ec2a0a8ab3e94a732d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 5 Jan 2026 12:29:50 +0000 Subject: [PATCH 936/981] bugfix: Mask tool for bisenet-fp --- plugins/extract/mask/_base.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 4a2fd70543..62c43adca9 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -106,6 +106,11 @@ def __init__(self, self._storage_size = 128 # Size to store masks at. Leave this at default logger.debug("Initialized %s", self.__class__.__name__) + @property + def storage_centering(self) -> CenteringType: + """ Literal["face", "head", "legacy"] : The centering that the mask is stored at """ + return self._storage_centering + def _maybe_log_warning(self, face: AlignedFace) -> None: """ Log a warning, once, if we do not have full facial landmarks From 3326639c3b0922627d6ff648d81b63f869678834 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:03:57 +0000 Subject: [PATCH 937/981] bugfix: manual tool. Convert np.int32 to int --- tools/manual/frameviewer/editor/bounding_box.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/manual/frameviewer/editor/bounding_box.py b/tools/manual/frameviewer/editor/bounding_box.py index 1f07e76da7..d8e2af081a 100644 --- a/tools/manual/frameviewer/editor/bounding_box.py +++ b/tools/manual/frameviewer/editor/bounding_box.py @@ -385,7 +385,10 @@ def _coords_to_bounding_box(self, coords): coords = self.scale_from_display( np.array(coords).reshape((2, 2))).flatten().astype("int32") logger.trace("out: %s", coords) - return (coords[0], coords[2] - coords[0], coords[1], coords[3] - coords[1]) + return (int(coords[0]), + int(coords[2] - coords[0]), + int(coords[1]), + int(coords[3] - coords[1])) def _context_menu(self, event): """ Create a right click context menu to delete the alignment that is being From 037738cbe20c7c042f06a9adbba4633a29a78628 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:42:35 +0000 Subject: [PATCH 938/981] bugfix: Collect AMD GPU stats when running in WSL2 --- lib/gpu_stats/rocm.py | 139 +++++++++++++++++++++++++----------------- 1 file changed, 82 insertions(+), 57 deletions(-) diff --git a/lib/gpu_stats/rocm.py b/lib/gpu_stats/rocm.py index eddbd4a920..9eaabf07b4 100644 --- a/lib/gpu_stats/rocm.py +++ b/lib/gpu_stats/rocm.py @@ -10,6 +10,9 @@ import os import re from subprocess import run +from shutil import which + +import torch from lib.utils import get_module_objects from ._base import _GPUStats, _EXCLUDE_DEVICES @@ -222,6 +225,7 @@ class ROCm(_GPUStats): def __init__(self, log: bool = True) -> None: self._vendor_id = "0x1002" # AMD VendorID self._sysfs_paths: list[str] = [] + self._is_wsl = which("wslinfo") is not None super().__init__(log=log) def _from_sysfs_file(self, path: str) -> str: @@ -291,8 +295,11 @@ def _initialize(self) -> None: """ if self._is_initialized: return - self._log("debug", "Initializing sysfs for AMDGPU (ROCm).") - self._sysfs_paths = self._get_sysfs_paths() + if self._is_wsl: + self._log("debug", "Running WSL. Obtaining limited info from Torch for AMDGPU (ROCm).") + else: + self._log("debug", "Initializing sysfs for AMDGPU (ROCm).") + self._sysfs_paths = self._get_sysfs_paths() super()._initialize() def _get_device_count(self) -> int: @@ -303,7 +310,10 @@ def _get_device_count(self) -> int: int The total number of GPUs available """ - retval = len(self._sysfs_paths) + if self._is_wsl: + retval = torch.cuda.device_count() + else: + retval = len(self._sysfs_paths) self._log("debug", f"GPU Device count: {retval}") return retval @@ -316,7 +326,10 @@ def _get_handles(self) -> list: list The list of all discovered GPUs """ - handles = self._sysfs_paths + if self._is_wsl: + handles = list(range(self._device_count)) + else: + handles = self._sysfs_paths self._log("debug", f"sysfs GPU Handles found: {handles}") return handles @@ -328,21 +341,24 @@ def _get_driver(self) -> str: str The current AMDGPU driver versions """ - retval = "" - cmd = ["modinfo", "amdgpu"] - try: - proc = run(cmd, - check=True, - timeout=5, - capture_output=True, - encoding="utf-8", - errors="ignore") - for line in proc.stdout.split("\n"): - if line.startswith("version:"): - retval = line.split()[-1] - break - except Exception as err: # pylint:disable=broad-except - self._log("debug", f"Error reading modinfo: '{str(err)}'") + if self._is_wsl: + retval = "unknown (wsl2)" + else: + retval = "" + cmd = ["modinfo", "amdgpu"] + try: + proc = run(cmd, + check=True, + timeout=5, + capture_output=True, + encoding="utf-8", + errors="ignore") + for line in proc.stdout.split("\n"): + if line.startswith("version:"): + retval = line.split()[-1] + break + except Exception as err: # pylint:disable=broad-except + self._log("debug", f"Error reading modinfo: '{str(err)}'") self._log("debug", f"GPU Drivers: {retval}") return retval @@ -356,29 +372,32 @@ def _get_device_names(self) -> list[str]: The list of connected AMD GPU names """ retval = [] - for device in self._sysfs_paths: - name = self._from_sysfs_file(os.path.join(device, "product_name")) - number = self._from_sysfs_file(os.path.join(device, "product_number")) - if name or number: # product_name or product_number populated - self._log("debug", f"Got name from product_name: '{name}', product_number: " - f"'{number}'") - retval.append(f"{name + ' ' if name else ''}{number}") - continue - - device_id = self._from_sysfs_file(os.path.join(device, "device")) - self._log("debug", f"Got device_id: '{device_id}'") - - if not device_id: # Can't get device name - retval.append("Not found") - continue - try: - lookup = int(device_id, 0) - except ValueError: - retval.append(device_id) - continue - - device_name = _DEVICE_LOOKUP.get(lookup, device_id) - retval.append(device_name) + for device in self._handles: + if self._is_wsl: + retval.append(torch.cuda.get_device_name(device)) + else: + name = self._from_sysfs_file(os.path.join(device, "product_name")) + number = self._from_sysfs_file(os.path.join(device, "product_number")) + if name or number: # product_name or product_number populated + self._log("debug", f"Got name from product_name: '{name}', product_number: " + f"'{number}'") + retval.append(f"{name + ' ' if name else ''}{number}") + continue + + device_id = self._from_sysfs_file(os.path.join(device, "device")) + self._log("debug", f"Got device_id: '{device_id}'") + + if not device_id: # Can't get device name + retval.append("Not found") + continue + try: + lookup = int(device_id, 0) + except ValueError: + retval.append(device_id) + continue + + device_name = _DEVICE_LOOKUP.get(lookup, device_id) + retval.append(device_name) self._log("debug", f"Device names: {retval}") return retval @@ -394,7 +413,7 @@ def _get_active_devices(self) -> list[int]: The list of device indices that are available for Faceswap to use """ devices = super()._get_active_devices() - env_devices = os.environ.get("HIP_VISIBLE_DEVICES ") + env_devices = os.environ.get("HIP_VISIBLE_DEVICES") if env_devices: new_devices = [int(i) for i in env_devices.split(",")] devices = [idx for idx in devices if idx in new_devices] @@ -411,13 +430,16 @@ def _get_vram(self) -> list[int]: The VRAM in Megabytes for each connected Nvidia GPU """ retval = [] - for device in self._sysfs_paths: - query = self._from_sysfs_file(os.path.join(device, "mem_info_vram_total")) - try: - vram = int(query) - except ValueError: - self._log("debug", f"Couldn't extract VRAM from string: '{query}'", ) - vram = 0 + for device in self._handles: + if self._is_wsl: + vram = torch.cuda.get_device_properties(device).total_memory + else: + query = self._from_sysfs_file(os.path.join(device, "mem_info_vram_total")) + try: + vram = int(query) + except ValueError: + self._log("debug", f"Couldn't extract VRAM from string: '{query}'", ) + vram = 0 retval.append(int(vram / (1024 * 1024))) self._log("debug", f"GPU VRAM: {retval}") @@ -435,16 +457,19 @@ def _get_free_vram(self) -> list[int]: """ retval = [] total_vram = self._get_vram() - for device, vram in zip(self._sysfs_paths, total_vram): + for device, vram in zip(self._handles, total_vram): if not vram: retval.append(0) continue - query = self._from_sysfs_file(os.path.join(device, "mem_info_vram_used")) - try: - used = int(query) - except ValueError: - self._log("debug", f"Couldn't extract used VRAM from string: '{query}'") - used = 0 + if self._is_wsl: + used = torch.cuda.device_memory_used(device) + else: + query = self._from_sysfs_file(os.path.join(device, "mem_info_vram_used")) + try: + used = int(query) + except ValueError: + self._log("debug", f"Couldn't extract used VRAM from string: '{query}'") + used = 0 retval.append(vram - int(used / (1024 * 1024))) self._log("debug", f"GPU VRAM free: {retval}") From 4c2092623d7b88739345e4c8681d24866c0cc162 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:01:43 +0000 Subject: [PATCH 939/981] bugfix: Don't query amd-smi under WSL2 --- lib/gpu_stats/rocm.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/gpu_stats/rocm.py b/lib/gpu_stats/rocm.py index 9eaabf07b4..7c7c6d4c9a 100644 --- a/lib/gpu_stats/rocm.py +++ b/lib/gpu_stats/rocm.py @@ -462,7 +462,11 @@ def _get_free_vram(self) -> list[int]: retval.append(0) continue if self._is_wsl: - used = torch.cuda.device_memory_used(device) + # Because WSL is such a pile of crap and ROCm is also not great, we cannot actually + # get real VRAM usage as torch queries amd-smi which is not compatible, so we have + # to query the allocator, which is probably going to always be zero, but better + # than crashing + used = torch.cuda.memory_reserved(device) else: query = self._from_sysfs_file(os.path.join(device, "mem_info_vram_used")) try: From aa40efabf39cdfe06b4f07e207b75c32c78e2533 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:30:27 +0000 Subject: [PATCH 940/981] Backported code to support future updates --- docs/conf.py | 5 + lib/align/aligned_face.py | 6 +- lib/align/alignments.py | 243 +++++++++++++++---------------- lib/align/constants.py | 2 +- lib/align/pose.py | 256 ++++++++++++++++++++++++++------- lib/gpu_stats/_base.py | 107 +++++++------- lib/gpu_stats/apple_silicon.py | 62 ++++---- lib/gpu_stats/cpu.py | 41 +++--- lib/gpu_stats/nvidia.py | 58 ++++---- lib/gpu_stats/rocm.py | 69 ++++----- lib/gui/command.py | 4 +- lib/gui/display_page.py | 2 +- lib/image.py | 9 +- lib/logger.py | 232 ++++++++++++++++-------------- lib/system/sysinfo.py | 135 ++++++++--------- 15 files changed, 679 insertions(+), 552 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 0e46e443ca..2e60e3180e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -43,6 +43,11 @@ # -- General configuration --------------------------------------------------- +autodoc_typehints = "both" +autodoc_default_options = { + "members": True, + "special-members": "__next__, __call__", +} # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 0a5c92c66e..19b10bd0e3 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -14,7 +14,7 @@ from lib.logger import parse_class_init from lib.utils import get_module_objects -from .constants import CenteringType, EXTRACT_RATIOS, LandmarkType, _MEAN_FACE +from .constants import CenteringType, EXTRACT_RATIOS, LandmarkType, MEAN_FACE from .pose import PoseEstimate logger = logging.getLogger(__name__) @@ -439,7 +439,7 @@ def average_distance(self) -> float: used for aligning the image. """ with self._cache.lock("average_distance"): if not self._cache.average_distance: - mean_face = _MEAN_FACE[self._mean_lookup] + mean_face = MEAN_FACE[self._mean_lookup] lms = self.normalized_landmarks if self._landmark_type == LandmarkType.LM_2D_68: lms = lms[17:] # 68 point landmarks only use core face items @@ -500,7 +500,7 @@ def _get_default_matrix(self) -> np.ndarray: lms = self._frame_landmarks if self._landmark_type == LandmarkType.LM_2D_68: lms = lms[17:] # 68 point landmarks only use core face items - retval = _umeyama(lms, _MEAN_FACE[self._mean_lookup], True)[0:2] + retval = _umeyama(lms, MEAN_FACE[self._mean_lookup], True)[0:2] logger.trace("Default matrix: %s", retval) # type:ignore[attr-defined] return retval diff --git a/lib/align/alignments.py b/lib/align/alignments.py index aedf0c94ee..c9f325f431 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 -""" Alignments file functions for reading, writing and manipulating the data stored in a +"""Alignments file functions for reading, writing and manipulating the data stored in a serialized alignments file. """ from __future__ import annotations import logging import os +import sys import typing as T from datetime import datetime @@ -36,7 +37,7 @@ # TODO Convert these to Dataclasses class MaskAlignmentsFileDict(T.TypedDict): - """ Typed Dictionary for storing Masks. """ + """Typed Dictionary for storing Masks.""" mask: bytes affine_matrix: list[float] | np.ndarray interpolator: int @@ -45,8 +46,8 @@ class MaskAlignmentsFileDict(T.TypedDict): class PNGHeaderAlignmentsDict(T.TypedDict): - """ Base Dictionary for storing a single faces' Alignment Information in Alignments files and - PNG Headers. """ + """Base Dictionary for storing a single faces' Alignment Information in Alignments files and + PNG Headers.""" x: int y: int w: int @@ -57,12 +58,12 @@ class PNGHeaderAlignmentsDict(T.TypedDict): class AlignmentFileDict(PNGHeaderAlignmentsDict): - """ Typed Dictionary for storing a single faces' Alignment Information in alignments files. """ + """Typed Dictionary for storing a single faces' Alignment Information in alignments files.""" thumb: np.ndarray | None class PNGHeaderSourceDict(T.TypedDict): - """ Dictionary for storing additional meta information in PNG headers """ + """Dictionary for storing additional meta information in PNG headers.""" alignments_version: float original_filename: str face_index: int @@ -72,19 +73,19 @@ class PNGHeaderSourceDict(T.TypedDict): class AlignmentDict(T.TypedDict): - """ Dictionary for holding all of the alignment information within a single alignment file """ + """Dictionary for holding all of the alignment information within a single alignment file.""" faces: list[AlignmentFileDict] video_meta: dict[str, float | int] class PNGHeaderDict(T.TypedDict): - """ Dictionary for storing all alignment and meta information in PNG Headers """ + """Dictionary for storing all alignment and meta information in PNG Headers.""" alignments: PNGHeaderAlignmentsDict source: PNGHeaderSourceDict class Alignments(): # pylint:disable=too-many-public-methods - """ The alignments file is a custom serialized ``.fsa`` file that holds information for each + """The alignments file is a custom serialized ``.fsa`` file that holds information for each frame for a video or series of images. Specifically, it holds a list of faces that appear in each frame. Each face contains @@ -96,9 +97,9 @@ class Alignments(): # pylint:disable=too-many-public-methods Parameters ---------- - folder: str + folder The folder that contains the alignments ``.fsa`` file - filename: str, optional + filename The filename of the ``.fsa`` alignments file. If not provided then the given folder will be checked for a default alignments file filename. Default: "alignments" """ @@ -117,62 +118,61 @@ def __init__(self, folder: str, filename: str = "alignments") -> None: @property def frames_count(self) -> int: - """ int: The number of frames that appear in the alignments :attr:`data`. """ + """The number of frames that appear in the alignments :attr:`data`.""" retval = len(self._data) logger.trace(retval) # type:ignore[attr-defined] return retval @property def faces_count(self) -> int: - """ int: The total number of faces that appear in the alignments :attr:`data`. """ + """The total number of faces that appear in the alignments :attr:`data`""" retval = sum(len(val["faces"]) for val in self._data.values()) logger.trace(retval) # type:ignore[attr-defined] return retval @property def file(self) -> str: - """ str: The full path to the currently loaded alignments file. """ + """The full path to the currently loaded alignments file.""" return self._io.file @property def data(self) -> dict[str, AlignmentDict]: - """ dict: The loaded alignments :attr:`file` in dictionary form. """ + """The loaded alignments :attr:`file` in dictionary form.""" return self._data @property def have_alignments_file(self) -> bool: - """ bool: ``True`` if an alignments file exists at location :attr:`file` otherwise - ``False``. """ + """``True`` if an alignments file exists at location :attr:`file` otherwise ``False``.""" return self._io.have_alignments_file @property def hashes_to_frame(self) -> dict[str, dict[str, int]]: - """ dict: The SHA1 hash of the face mapped to the frame(s) and face index within the frame - that the hash corresponds to. + """The SHA1 hash of the face mapped to the frame(s) and face index within the frame that + the hash corresponds to. Notes ----- - This method is depractated and exists purely for updating legacy hash based alignments + This method is deprecated and exists purely for updating legacy hash based alignments to new png header storage in :class:`lib.align.update_legacy_png_header`. """ return self._legacy.hashes_to_frame @property def hashes_to_alignment(self) -> dict[str, AlignmentFileDict]: - """ dict: The SHA1 hash of the face mapped to the alignment for the face that the hash - corresponds to. The structure of the dictionary is: + """The SHA1 hash of the face mapped to the alignment for the face that the hash + corresponds to. Notes ----- - This method is depractated and exists purely for updating legacy hash based alignments + This method is deprecated and exists purely for updating legacy hash based alignments to new png header storage in :class:`lib.align.update_legacy_png_header`. """ return self._legacy.hashes_to_alignment @property def mask_summary(self) -> dict[str, int]: - """ dict: The mask type names stored in the alignments :attr:`data` as key with the number - of faces which possess the mask type as value. """ + """The mask type names stored in the alignments :attr:`data` as key with the number of + faces which possess the mask type as value.""" masks: dict[str, int] = {} for val in self._data.values(): for face in val["faces"]: @@ -184,8 +184,8 @@ def mask_summary(self) -> dict[str, int]: @property def video_meta_data(self) -> dict[str, list[int] | list[float] | None]: - """ dict: The frame meta data stored in the alignments file. If data does not exist in the - alignments file then ``None`` is returned for each Key """ + """The frame meta data stored in the alignments file. If data does not exist in the + alignments file then ``None`` is returned for each Key""" retval: dict[str, list[int] | list[float] | None] = {"pts_time": None, "keyframes": None} pts_time: list[float] = [] keyframes: list[int] = [] @@ -201,35 +201,33 @@ def video_meta_data(self) -> dict[str, list[int] | list[float] | None]: @property def thumbnails(self) -> Thumbnails: - """ :class:`~lib.align.thumbnails.Thumbnails`: The low resolution thumbnail images that - exist within the alignments file """ + """The low resolution thumbnail images that exist within the alignments file""" return self._thumbnails @property def version(self) -> float: - """ float: The alignments file version number. """ + """float: The alignments file version number. """ return self._io.version def _load(self) -> dict[str, AlignmentDict]: - """ Load the alignments data from the serialized alignments :attr:`file`. + """Load the alignments data from the serialized alignments :attr:`file`. Populates :attr:`_version` with the alignment file's loaded version as well as returning the serialized data. Returns ------- - dict: - The loaded alignments data + The loaded alignments data """ return self._io.load() def save(self) -> None: - """ Write the contents of :attr:`data` and :attr:`_meta` to a serialized ``.fsa`` file at - the location :attr:`file`. """ + """Write the contents of :attr:`data` and :attr:`_meta` to a serialized ``.fsa`` file at + the location :attr:`file`.""" return self._io.save() def backup(self) -> None: - """ Create a backup copy of the alignments :attr:`file`. + """Create a backup copy of the alignments :attr:`file`. Creates a copy of the serialized alignments :attr:`file` appending a timestamp onto the end of the file name and storing in the same folder as @@ -238,7 +236,7 @@ def backup(self) -> None: return self._io.backup() def save_video_meta_data(self, pts_time: list[float], keyframes: list[int]) -> None: - """ Save video meta data to the alignments file. + """Save video meta data to the alignments file. If the alignments file does not have an entry for every frame (e.g. if Extract Every N was used) then the frame is added to the alignments file with no faces, so that they video @@ -246,10 +244,10 @@ def save_video_meta_data(self, pts_time: list[float], keyframes: list[int]) -> N Parameters ---------- - pts_time: list + pts_time A list of presentation timestamps (`float`) in frame index order for every frame in the input video - keyframes: list + keyframes A list of frame indices corresponding to the key frames in the input video """ if pts_time[0] != 0: @@ -288,7 +286,7 @@ def save_video_meta_data(self, pts_time: list[float], keyframes: list[int]) -> N @classmethod def _pad_leading_frames(cls, pts_time: list[float], keyframes: list[int]) -> tuple[list[float], list[int]]: - """ Calculate the number of frames to pad the video by when the first frame is not + """Calculate the number of frames to pad the video by when the first frame is not a key frame. A somewhat crude method by obtaining the gaps between existing frames and calculating @@ -297,17 +295,16 @@ def _pad_leading_frames(cls, pts_time: list[float], keyframes: list[int]) -> tup Parameters ---------- - pts_time: list + pts_time A list of presentation timestamps (`float`) in frame index order for every frame in the input video - keyframes: list + keyframes A list of keyframes (`int`) for the input video Returns ------- - tuple - The presentation time stamps with extra frames padded to the beginning and the - keyframes adjusted to include the new frames + The presentation time stamps with extra frames padded to the beginning and the keyframes + adjusted to include the new frames """ start_pts = pts_time[0] logger.debug("Video not cut on keyframe. Start pts: %s", start_pts) @@ -329,37 +326,35 @@ def _pad_leading_frames(cls, pts_time: list[float], keyframes: list[int]) -> tup # << VALIDATION >> # def frame_exists(self, frame_name: str) -> bool: - """ Check whether a given frame_name exists within the alignments :attr:`data`. + """Check whether a given frame_name exists within the alignments :attr:`data`. Parameters ---------- - frame_name: str + frame_name The frame name to check. This should be the base name of the frame, not the full path Returns ------- - bool - ``True`` if the given frame_name exists within the alignments :attr:`data` - otherwise ``False`` + ``True`` if the given frame_name exists within the alignments :attr:`data` otherwise + ``False`` """ retval = frame_name in self._data.keys() logger.trace("'%s': %s", frame_name, retval) # type:ignore[attr-defined] return retval def frame_has_faces(self, frame_name: str) -> bool: - """ Check whether a given frame_name exists within the alignments :attr:`data` and contains + """Check whether a given frame_name exists within the alignments :attr:`data` and contains at least 1 face. Parameters ---------- - frame_name: str + frame_name The frame name to check. This should be the base name of the frame, not the full path Returns ------- - bool - ``True`` if the given frame_name exists within the alignments :attr:`data` and has at - least 1 face associated with it, otherwise ``False`` + ``True`` if the given frame_name exists within the alignments :attr:`data` and has at least + 1 face associated with it, otherwise ``False`` """ frame_data = self._data.get(frame_name, T.cast(AlignmentDict, {})) retval = bool(frame_data.get("faces", [])) @@ -367,20 +362,19 @@ def frame_has_faces(self, frame_name: str) -> bool: return retval def frame_has_multiple_faces(self, frame_name: str) -> bool: - """ Check whether a given frame_name exists within the alignments :attr:`data` and contains + """Check whether a given frame_name exists within the alignments :attr:`data` and contains more than 1 face. Parameters ---------- - frame_name: str + frame_name The frame_name name to check. This should be the base name of the frame, not the full path Returns ------- - bool - ``True`` if the given frame_name exists within the alignments :attr:`data` and has more - than 1 face associated with it, otherwise ``False`` + ``True`` if the given frame_name exists within the alignments :attr:`data` and has more + than 1 face associated with it, otherwise ``False`` """ if not frame_name: retval = False @@ -391,21 +385,20 @@ def frame_has_multiple_faces(self, frame_name: str) -> bool: return retval def mask_is_valid(self, mask_type: str) -> bool: - """ Ensure the given ``mask_type`` is valid for the alignments :attr:`data`. + """Ensure the given ``mask_type`` is valid for the alignments :attr:`data`. Every face in the alignments :attr:`data` must have the given mask type to successfully pass the test. Parameters ---------- - mask_type: str + mask_type The mask type to check against the current alignments :attr:`data` Returns ------- - bool: - ``True`` if all faces in the current alignments possess the given ``mask_type`` - otherwise ``False`` + ``True`` if all faces in the current alignments possess the given ``mask_type`` otherwise + ``False`` """ retval = all((face.get("mask") is not None and face["mask"].get(mask_type) is not None) @@ -416,36 +409,34 @@ def mask_is_valid(self, mask_type: str) -> bool: # << DATA >> # def get_faces_in_frame(self, frame_name: str) -> list[AlignmentFileDict]: - """ Obtain the faces from :attr:`data` associated with a given frame_name. + """Obtain the faces from :attr:`data` associated with a given frame_name. Parameters ---------- - frame_name: str + frame_name The frame name to return faces for. This should be the base name of the frame, not the full path Returns ------- - list - The list of face dictionaries that appear within the requested frame_name + The list of face dictionaries that appear within the requested frame_name """ logger.trace("Getting faces for frame_name: '%s'", frame_name) # type:ignore[attr-defined] 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: - """ Return number of faces that appear within :attr:`data` for the given frame_name. + """Return number of faces that appear within :attr:`data` for the given frame_name. Parameters ---------- - frame_name: str + frame_name The frame name to return the count for. This should be the base name of the frame, not the full path Returns ------- - int - The number of faces that appear in the given frame_name + The number of faces that appear in the given frame_name """ frame_data = self._data.get(frame_name, T.cast(AlignmentDict, {})) retval = len(frame_data.get("faces", [])) @@ -454,20 +445,19 @@ def count_faces_in_frame(self, frame_name: str) -> int: # << MANIPULATION >> # def delete_face_at_index(self, frame_name: str, face_index: int) -> bool: - """ Delete the face for the given frame_name at the given face index from :attr:`data`. + """Delete the face for the given frame_name at the given face index from :attr:`data`. Parameters ---------- - frame_name: str + frame_name The frame name to remove the face from. This should be the base name of the frame, not the full path - face_index: int + face_index The index number of the face within the given frame_name to remove Returns ------- - bool - ``True`` if a face was successfully deleted otherwise ``False`` + ``True`` if a face was successfully deleted otherwise ``False`` """ logger.debug("Deleting face %s for frame_name '%s'", face_index, frame_name) face_index = int(face_index) @@ -480,21 +470,20 @@ def delete_face_at_index(self, frame_name: str, face_index: int) -> bool: return True def add_face(self, frame_name: str, face: AlignmentFileDict) -> int: - """ Add a new face for the given frame_name in :attr:`data` and return it's index. + """Add a new face for the given frame_name in :attr:`data` and return it's index. Parameters ---------- - frame_name: str + frame_name The frame name to add the face to. This should be the base name of the frame, not the full path - face: dict + face The face information to add to the given frame_name, correctly formatted for storing in :attr:`data` Returns ------- - int - The index of the newly added face within :attr:`data` for the given frame_name + The index of the newly added face within :attr:`data` for the given frame_name """ logger.debug("Adding face to frame_name: '%s'", frame_name) if frame_name not in self._data: @@ -505,16 +494,16 @@ def add_face(self, frame_name: str, face: AlignmentFileDict) -> int: return retval def update_face(self, frame_name: str, face_index: int, face: AlignmentFileDict) -> None: - """ Update the face for the given frame_name at the given face index in :attr:`data`. + """Update the face for the given frame_name at the given face index in :attr:`data`. Parameters ---------- - frame_name: str + frame_name The frame name to update the face for. This should be the base name of the frame, not the full path - face_index: int + face_index The index number of the face within the given frame_name to update - face: dict + face The face information to update to the given frame_name at the given face_index, correctly formatted for storing in :attr:`data` """ @@ -522,13 +511,13 @@ def update_face(self, frame_name: str, face_index: int, face: AlignmentFileDict) self._data[frame_name]["faces"][face_index] = face def filter_faces(self, filter_dict: dict[str, list[int]], filter_out: bool = False) -> None: - """ Remove faces from :attr:`data` based on a given filter list. + """Remove faces from :attr:`data` based on a given filter list. Parameters ---------- - filter_dict: dict + filter_dict Dictionary of source filenames as key with a list of face indices to filter as value. - filter_out: bool, optional + filter_out ``True`` if faces should be removed from :attr:`data` when there is a corresponding match in the given filter_dict. ``False`` if faces should be kept in :attr:`data` when there is a corresponding match in the given filter_dict, but removed if there is no @@ -551,11 +540,11 @@ def filter_faces(self, filter_dict: dict[str, list[int]], filter_out: bool = Fal 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 + """Replace all alignments with the contents of the given dictionary Parameters ---------- - data: dict[str, AlignmentDict] + data The alignments, in correctly formatted dictionary form, to be populated into this :class:`Alignments` """ @@ -564,7 +553,7 @@ def update_from_dict(self, data: dict[str, AlignmentDict]) -> None: # << 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 + """Generator to obtain all faces with meta information from :attr:`data`. The results are yielded by frame. Notes @@ -573,14 +562,14 @@ def yield_faces(self) -> Generator[tuple[str, list[AlignmentFileDict], int, str] Yields ------ - frame_name: str + frame_name The frame name that the face belongs to. This is the base name of the frame, as it appears in :attr:`data`, not the full path - faces: list + faces The list of face `dict` objects that exist for this frame - face_count: int + face_count The number of faces that exist within :attr:`data` for this frame - frame_fullname: str + frame_fullname The full path (folder and filename) for the yielded frame """ for frame_fullname, val in self._data.items(): @@ -592,13 +581,13 @@ def yield_faces(self) -> Generator[tuple[str, list[AlignmentFileDict], int, str] yield frame_name, val["faces"], face_count, frame_fullname def update_legacy_has_source(self, filename: str) -> None: - """ Update legacy alignments files when we have the source filename available. + """Update legacy alignments files when we have the source filename available. Updates here can only be performed when we have the source filename Parameters ---------- - filename: str: + filename The filename/folder of the original source images/video for the current alignments """ updates = [updater.is_updated for updater in (VideoExtension(self, filename), )] @@ -608,15 +597,15 @@ def update_legacy_has_source(self, filename: str) -> None: class _IO(): - """ Class to handle the saving/loading of an alignments file. + """Class to handle the saving/loading of an alignments file. Parameters ---------- - alignments: :class:'~Alignments` + alignments The parent alignments class that these IO operations belong to - folder: str + folder The folder that contains the alignments ``.fsa`` file - filename: str + filename The filename of the ``.fsa`` alignments file. """ def __init__(self, alignments: Alignments, folder: str, filename: str) -> None: @@ -628,36 +617,34 @@ def __init__(self, alignments: Alignments, folder: str, filename: str) -> None: @property def file(self) -> str: - """ str: The full path to the currently loaded alignments file. """ + """The full path to the currently loaded alignments file.""" return self._file @property def version(self) -> float: - """ float: The alignments file version number. """ + """The alignments file version number.""" return self._version @property def have_alignments_file(self) -> bool: - """ bool: ``True`` if an alignments file exists at location :attr:`file` otherwise - ``False``. """ + """``True`` if an alignments file exists at location :attr:`file` otherwise ``False``.""" retval = os.path.exists(self._file) logger.trace(retval) # type:ignore[attr-defined] return retval def _get_location(self, folder: str, filename: str) -> str: - """ Obtains the location of an alignments file. + """Obtains the location of an alignments file. Parameters ---------- - folder: str + folder The folder that the alignments file is located in - filename: str + filename The filename of the alignments file Returns ------- - str - The full path to the alignments file + The full path to the alignments file """ logger.debug("Getting location: (folder: '%s', filename: '%s')", folder, filename) noext_name, extension = os.path.splitext(filename) @@ -673,8 +660,8 @@ def _get_location(self, folder: str, filename: str) -> str: return location def update_legacy(self) -> None: - """ Check whether the alignments are legacy, and if so update them to current alignments - format. """ + """Check whether the alignments are legacy, and if so update them to current alignments + format.""" updates = [updater.is_updated for updater in (FileStructure(self._alignments), LandmarkRename(self._alignments), ListToNumpy(self._alignments), @@ -685,36 +672,44 @@ def update_legacy(self) -> None: self.save() def update_version(self) -> None: - """ Update the version of the alignments file to the latest version """ + """Update the version of the alignments file to the latest version""" self._version = _VERSION logger.info("Updating alignments file to version %s", self._version) def load(self) -> dict[str, AlignmentDict]: - """ Load the alignments data from the serialized alignments :attr:`file`. + """Load the alignments data from the serialized alignments :attr:`file`. Populates :attr:`_version` with the alignment file's loaded version as well as returning the serialized data. Returns ------- - dict: - The loaded alignments data + The loaded alignments data """ logger.debug("Loading alignments") if not self.have_alignments_file: - raise FaceswapError(f"Error: Alignments file not found at {self._file}") + raise FaceswapError(f"Alignments file not found at {self._file}") logger.info("Reading alignments from: '%s'", self._file) data = self._serializer.load(self._file) meta = data.get("__meta__", {"version": 1.0}) self._version = meta["version"] + if self._version < 2.0: + logger.error("This alignments file was generated with a very old legacy extraction " + "method.") + logger.error("Updating these very old files is no longer supported.") + logger.error("To update to a more recent, supported format, you should run the " + "alignments tool's 'extract' job with this file in Faceswap v2.3: " + "https://github.com/deepfakes/faceswap/releases/tag/v2.3.0") + sys.exit(1) + data = data.get("__data__", data) logger.debug("Loaded alignments") return data def save(self) -> None: - """ Write the contents of :attr:`data` and :attr:`_meta` to a serialized ``.fsa`` file at - the location :attr:`file`. """ + """Write the contents of :attr:`data` and :attr:`_meta` to a serialized ``.fsa`` file at + the location :attr:`file`.""" logger.debug("Saving alignments") logger.info("Writing alignments to: '%s'", self._file) data = {"__meta__": {"version": self._version}, @@ -723,7 +718,7 @@ def save(self) -> None: logger.debug("Saved alignments") def backup(self) -> None: - """ Create a backup copy of the alignments :attr:`file`. + """Create a backup copy of the alignments :attr:`file`. Creates a copy of the serialized alignments :attr:`file` appending a timestamp onto the end of the file name and storing in the same folder as @@ -733,10 +728,10 @@ def backup(self) -> None: if not os.path.isfile(self._file): logger.debug("No alignments to back up") return - now = datetime.now().strftime("%Y%m%d_%H%M%S") + now = datetime.now().strftime("%Y-%m-%d_%H.%M.%S") src = self._file split = os.path.splitext(src) - dst = f"{split[0]}_{now}{split[1]}" + dst = f"{split[0]}_bk_{now}{split[1]}" idx = 1 while True: if not os.path.exists(dst): diff --git a/lib/align/constants.py b/lib/align/constants.py index 27f4eb51bf..88b7aa31f1 100644 --- a/lib/align/constants.py +++ b/lib/align/constants.py @@ -51,7 +51,7 @@ def from_shape(cls, shape: tuple[int, ...]) -> LandmarkType: return shapes[shape] -_MEAN_FACE: dict[LandmarkType, np.ndarray] = { +MEAN_FACE: dict[LandmarkType, np.ndarray] = { LandmarkType.LM_2D_4: np.array( [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]), # Clockwise from TL LandmarkType.LM_2D_51: np.array([ diff --git a/lib/align/pose.py b/lib/align/pose.py index cac8337cfd..7df5ccbdff 100644 --- a/lib/align/pose.py +++ b/lib/align/pose.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Holds estimated pose information for a faceswap aligned face """ +"""Holds estimated pose information for a faceswap aligned face """ from __future__ import annotations import logging @@ -11,22 +11,62 @@ from lib.logger import parse_class_init from lib.utils import get_module_objects -from .constants import _MEAN_FACE, LandmarkType +from .constants import MEAN_FACE, LandmarkType logger = logging.getLogger(__name__) if T.TYPE_CHECKING: + import numpy.typing as npt from .constants import CenteringType +_CORE_LMS = np.array([6, 7, 8, 9, 10, 17, 21, 22, 26, 31, 32, 33, 34, + 35, 36, 39, 42, 45, 48, 50, 51, 52, 54, 56, 57, 58], dtype="int32") +"""The indices used from 68 point landmarks to align to a 3D head""" + +_DISTORTION_COEFFICIENTS = np.zeros((4, 1), dtype="float32") +"""The distortion co-efficient for 3D point estimation (assumes no lens distortion)""" + +_MEAN_FACE3D = MEAN_FACE[LandmarkType.LM_3D_26] +"""The (26, 3) 3D landmark points for a "mean" head in 3D normalized space""" + +_CENTER_OFFSETS: dict[CenteringType, npt.NDArray[np.float32]] = { + "legacy": np.array([0.0, 0.0, 0.0], dtype="float32"), + "head": np.array([0.0, 0.0, -2.3], dtype="float32"), + "face": np.array([0.0, -1.5, 4.2], dtype="float32") + } +"""The offsets required to shift the center point of a head in 3D space relative to legacy +centering""" + + +def get_camera_matrix(focal_length: int = 4) -> np.ndarray: + """Obtain an estimate of a camera matrix in normalized space + + Parameters + ---------- + focal_length + The focal length to obtain the matrix for. Default: 4 + + Returns + ------- + An estimated camera matrix + """ + focal_length = 4 + camera_matrix = np.array([[focal_length, 0, 0.5], + [0, focal_length, 0.5], + [0, 0, 1]], dtype="double") + logger.trace("camera_matrix: %s", camera_matrix) # type:ignore[attr-defined] + return camera_matrix + + class PoseEstimate(): - """ Estimates pose from a generic 3D head model for the given 2D face landmarks. + """Estimates pose from a generic 3D head model for the given 2D face landmarks. Parameters ---------- - landmarks: :class:`numpy.ndarry` + landmarks The original 68 point landmarks aligned to 0.0 - 1.0 range - landmarks_type: :class:`~LandmarksType` + landmarks_type The type of landmarks that are generating this face References @@ -38,14 +78,13 @@ class PoseEstimate(): def __init__(self, landmarks: np.ndarray, landmarks_type: LandmarkType) -> None: logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] - self._distortion_coefficients = np.zeros((4, 1)) # Assuming no lens distortion self._xyz_2d: np.ndarray | None = None if landmarks_type != LandmarkType.LM_2D_68: self._log_once("Pose estimation is not available for non-68 point landmarks. Pose and " "offset data will all be returned as the incorrect value of '0'") self._landmarks_type = landmarks_type - self._camera_matrix = self._get_camera_matrix() + self._camera_matrix = get_camera_matrix() self._rotation, self._translation = self._solve_pnp(landmarks) self._offset = self._get_offset() self._pitch_yaw_roll: tuple[float, float, float] = (0, 0, 0) @@ -53,8 +92,8 @@ def __init__(self, landmarks: np.ndarray, landmarks_type: LandmarkType) -> None: @property def xyz_2d(self) -> np.ndarray: - """ :class:`numpy.ndarray` projected (x, y) coordinates for each x, y, z point at a - constant distance from adjusted center of the skull (0.5, 0.5) in the 2D space. """ + """projected (x, y) coordinates for each x, y, z point at a constant distance from adjusted + center of the skull (0.5, 0.5) in the 2D space.""" if self._xyz_2d is None: xyz = cv2.projectPoints(np.array([[6., 0., -2.3], [0., 6., -2.3], @@ -62,86 +101,70 @@ def xyz_2d(self) -> np.ndarray: self._rotation, self._translation, self._camera_matrix, - self._distortion_coefficients)[0].squeeze() + _DISTORTION_COEFFICIENTS)[0].squeeze() self._xyz_2d = xyz - self._offset["head"] return self._xyz_2d @property def offset(self) -> dict[CenteringType, np.ndarray]: - """ dict: The amount to offset a standard 0.0 - 1.0 umeyama transformation matrix for a - from the center of the face (between the eyes) or center of the head (middle of skull) - rather than the nose area. """ + """The amount to offset a standard 0.0 - 1.0 Umeyama transformation matrix from the center + of the face (between the eyes) or center of the head (middle of skull) rather than the nose + area.""" return self._offset @property def pitch(self) -> float: - """ float: The pitch of the aligned face in eular angles """ + """The pitch of the aligned face in Eular angles""" if not any(self._pitch_yaw_roll): self._get_pitch_yaw_roll() return self._pitch_yaw_roll[0] @property def yaw(self) -> float: - """ float: The yaw of the aligned face in eular angles """ + """The yaw of the aligned face in Eular angles""" if not any(self._pitch_yaw_roll): self._get_pitch_yaw_roll() return self._pitch_yaw_roll[1] @property def roll(self) -> float: - """ float: The roll of the aligned face in eular angles """ + """The roll of the aligned face in Eular angles""" if not any(self._pitch_yaw_roll): self._get_pitch_yaw_roll() return self._pitch_yaw_roll[2] @classmethod def _log_once(cls, message: str) -> None: - """ Log a warning about unsupported landmarks if a message has not already been logged """ + """Log a warning about unsupported landmarks if a message has not already been logged""" if cls._logged_once: return logger.warning(message) cls._logged_once = True def _get_pitch_yaw_roll(self) -> None: - """ Obtain the yaw, roll and pitch from the :attr:`_rotation` in eular angles. """ + """Obtain the yaw, roll and pitch from the :attr:`_rotation` in Eular angles.""" proj_matrix = np.zeros((3, 4), dtype="float32") proj_matrix[:3, :3] = cv2.Rodrigues(self._rotation)[0] euler = cv2.decomposeProjectionMatrix(proj_matrix)[-1] self._pitch_yaw_roll = T.cast(tuple[float, float, float], tuple(euler.squeeze())) logger.trace("yaw_pitch: %s", self._pitch_yaw_roll) # type:ignore[attr-defined] - @classmethod - def _get_camera_matrix(cls) -> np.ndarray: - """ Obtain an estimate of the camera matrix based off the original frame dimensions. - - Returns - ------- - :class:`numpy.ndarray` - An estimated camera matrix - """ - focal_length = 4 - camera_matrix = np.array([[focal_length, 0, 0.5], - [0, focal_length, 0.5], - [0, 0, 1]], dtype="double") - logger.trace("camera_matrix: %s", camera_matrix) # type:ignore[attr-defined] - return camera_matrix - def _solve_pnp(self, landmarks: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - """ Solve the Perspective-n-Point for the given landmarks. + """Solve the Perspective-n-Point for the given landmarks. Takes 2D landmarks in world space and estimates the rotation and translation vectors in 3D space. Parameters ---------- - landmarks: :class:`numpy.ndarry` + landmarks The original 68 point landmark co-ordinates relating to the original frame Returns ------- - rotation: :class:`numpy.ndarray` + rotation The solved rotation vector - translation: :class:`numpy.ndarray` + translation The solved translation vector """ if self._landmarks_type != LandmarkType.LM_2D_68: @@ -149,43 +172,166 @@ def _solve_pnp(self, landmarks: np.ndarray) -> tuple[np.ndarray, np.ndarray]: rotation = np.array([[0.0], [0.0], [0.0]]) translation = rotation.copy() else: - points = landmarks[[6, 7, 8, 9, 10, 17, 21, 22, 26, 31, 32, 33, 34, - 35, 36, 39, 42, 45, 48, 50, 51, 52, 54, 56, 57, 58]] - _, rotation, translation = cv2.solvePnP(_MEAN_FACE[LandmarkType.LM_3D_26], + points = landmarks[_CORE_LMS] + _, rotation, translation = cv2.solvePnP(_MEAN_FACE3D, points, self._camera_matrix, - self._distortion_coefficients, + _DISTORTION_COEFFICIENTS, flags=cv2.SOLVEPNP_ITERATIVE) logger.trace("points: %s, rotation: %s, translation: %s", # type:ignore[attr-defined] points, rotation, translation) return rotation, translation - def _get_offset(self) -> dict[CenteringType, np.ndarray]: - """ Obtain the offset between the original center of the extracted face to the new center + def _get_offset(self) -> dict[CenteringType, npt.NDArray[np.float32]]: + """Obtain the offset between the original center of the extracted face to the new center of the head in 2D space. Returns ------- - :class:`numpy.ndarray` - The x, y offset of the new center from the old center. + The x, y offset of the new center from the old center. """ - offset: dict[CenteringType, np.ndarray] = {"legacy": np.array([0.0, 0.0])} + legacy = np.array([0.0, 0.0], dtype="float32") + offset: dict[CenteringType, npt.NDArray[np.float32]] = {} if self._landmarks_type != LandmarkType.LM_2D_68: - offset["face"] = np.array([0.0, 0.0]) - offset["head"] = np.array([0.0, 0.0]) + offset["legacy"] = legacy + offset["face"] = np.array([0.0, 0.0], dtype="float32") + offset["head"] = np.array([0.0, 0.0], dtype="float32") else: - points: dict[T.Literal["face", "head"], tuple[float, ...]] = {"head": (0.0, 0.0, -2.3), - "face": (0.0, -1.5, 4.2)} - for key, pnts in points.items(): - center = cv2.projectPoints(np.array([pnts]).astype("float32"), + for key, points in _CENTER_OFFSETS.items(): + if key == "legacy": + offset[key] = legacy + continue + center = cv2.projectPoints(np.array([points]).astype("float32"), self._rotation, self._translation, self._camera_matrix, - self._distortion_coefficients)[0].squeeze() + _DISTORTION_COEFFICIENTS)[0].squeeze().astype("float32") logger.trace("center %s: %s", key, center) # type:ignore[attr-defined] - offset[key] = center - np.array([0.5, 0.5]) + offset[key] = center - np.array([0.5, 0.5], dtype="float32") logger.trace("offset: %s", offset) # type:ignore[attr-defined] return offset +class Batch3D: + """Functions to perform 3D space calculations on batches """ + _camera_matrix = get_camera_matrix() + _legacy_offset = np.array([[0.0, 0.0]], dtype="float32") + _to_center_shift = np.array([[0.5, 0.5]], dtype="float32") + + @classmethod + def solve_pnp(cls, landmarks: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: + """Estimate rotation and translation from a mean 3D head model + + Parameters + ---------- + landmarks + The (N, 68, 2) 2D normalized landmark points to obtain the rotation and translation + vectors for + + Returns + ------- + The rotation and translation vectors for the given landmarks in format: + ``` + (rotation, N, 3, 1 + translation, N, 3, 1) + ``` + """ + core_lms = np.ascontiguousarray(landmarks[:, _CORE_LMS]) + retval = np.array([cv2.solvePnP(_MEAN_FACE3D, + lms, + cls._camera_matrix, + _DISTORTION_COEFFICIENTS, + flags=cv2.SOLVEPNP_ITERATIVE)[1:] + for lms in core_lms]).astype("float32").swapaxes(0, 1) + return retval + + @classmethod + def rodrigues(cls, vectors: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: + """Perform batch conversion of rotation vectors to rotation matrices + + Parameters + ---------- + vectors + The (N, 3, 1) rotation vectors to convert + + Returns + ------- + The (N, 3, 3) rotation matrices + """ + vectors = vectors.reshape(-1, 3) + theta = np.linalg.norm(vectors, axis=1, keepdims=True) + units = vectors / (theta + 1e-12) + + k = np.zeros((vectors.shape[0], 3, 3), dtype="float32") + k[:, 0, 1] = -units[:, 2] + k[:, 0, 2] = units[:, 1] + k[:, 1, 0] = units[:, 2] + k[:, 1, 2] = -units[:, 0] + k[:, 2, 0] = -units[:, 1] + k[:, 2, 1] = units[:, 0] + + ident = np.eye(3, dtype="float32") + retval = ident + np.sin(theta)[:, None] * k + (1 - np.cos(theta))[:, None] * (k @ k) + return retval + + @classmethod + def project_points(cls, + points: npt.NDArray[np.float32], + rotation_vectors: npt.NDArray[np.float32], + translation_vectors: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: + """Batch protection of points from 3D space to 2D space + + Parameters + ---------- + points + The (N, M, 3) points to project + rotation_vectors + The (N, 3, 1) rotation vectors for projection + translation_vectors + The (N, 3, 1) translation vectors for projection + + Returns + ------- + The (N, M, 2) projected points in 2D space + """ + rot = cls.rodrigues(rotation_vectors) + x_cam = np.einsum('nij,nmj->nmi', rot, points) + translation_vectors.swapaxes(1, 2) + x_y = x_cam[..., :2] / x_cam[..., 2: 3] + + cam = cls._camera_matrix + retval = np.empty_like(x_y) + retval[:, :, 0] = cam[0, 0] * x_y[..., 0] + cam[0, 2] + retval[:, :, 1] = cam[1, 1] * x_y[..., 1] + cam[1, 2] + return retval + + @classmethod + def get_offsets(cls, + centering: CenteringType, + rotation_vectors: npt.NDArray[np.float32], + translation_vectors: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: + """Obtain the offset for moving normalized 68 point landmarks from legacy centering + + Parameters + ---------- + centering + The centering type to obtain the offset for + rotation_vectors + The (N, 3, 1) batch of rotation vectors to receive offsets for + translation_vectors + The (N, 3, 1) batch of translation vectors to receive offsets for + + Returns + ------- + The (N, 2) offsets for the given rotation/translation vector + """ + batch_size = rotation_vectors.shape[0] + if centering == "legacy": + return np.broadcast_to(cls._legacy_offset, (batch_size, 2)) + points3d = np.broadcast_to(_CENTER_OFFSETS[centering][None], (batch_size, 3)) + offsets = cls.project_points(points3d[:, None, :], + rotation_vectors, + translation_vectors)[:, 0] + return offsets - cls._to_center_shift + + __all__ = get_module_objects(__name__) diff --git a/lib/gpu_stats/_base.py b/lib/gpu_stats/_base.py index cc7df62785..03aa313109 100644 --- a/lib/gpu_stats/_base.py +++ b/lib/gpu_stats/_base.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -""" Parent class for obtaining Stats for various GPU/TPU backends. All GPU Stats should inherit -from the :class:`_GPUStats` class contained here. """ +"""Parent class for obtaining Stats for various GPU/TPU backends. All GPU Stats should inherit +from the :class:`_GPUStats` class contained here.""" import logging @@ -17,51 +17,60 @@ class GPUInfo(): Attributes: ---------- - vram: list[int] + vram List of integers representing the total VRAM available on each GPU, in MB. - vram_free: list[int] + vram_free List of integers representing the free VRAM available on each GPU, in MB. - driver: str + driver String representing the driver version being used for the GPUs. - devices: list[str] + devices List of strings representing the names of each GPU device. - devices_active: list[int] + devices_active List of integers representing the indices of the active GPU devices. """ vram: list[int] + """List of integers representing the total VRAM available on each GPU, in MB.""" vram_free: list[int] + """List of integers representing the free VRAM available on each GPU, in MB.""" driver: str + """String representing the driver version being used for the GPUs.""" devices: list[str] + """List of strings representing the names of each GPU device.""" devices_active: list[int] + """List of integers representing the indices of the active GPU devices.""" @dataclass class BiggestGPUInfo(): - """ Dataclass for holding GPU Information about the card with most available VRAM. + """Dataclass for holding GPU Information about the card with most available VRAM. Attributes ---------- - card_id: int + card_id Integer representing the index of the GPU device. - device: str + device The name of the device - free: float + free The amount of available VRAM on the GPU - total: float + total the total amount of VRAM on the GPU """ card_id: int + """Integer representing the index of the GPU device.""" device: str + """The name of the device""" free: float + """The amount of available VRAM on the GPU""" total: float + """the total amount of VRAM on the GPU""" class _GPUStats(): - """ Parent class for collecting GPU device information. + """Parent class for collecting GPU device information. Parameters: ----------- - log : bool, optional + log Flag indicating whether or not to log debug messages. Default: `True`. """ @@ -90,22 +99,22 @@ def __init__(self, log: bool = True) -> None: @property def device_count(self) -> int: - """int: The number of GPU devices discovered on the system. """ + """The number of GPU devices discovered on the system.""" return self._device_count @property def cli_devices(self) -> list[str]: - """ list[str]: Formatted index: name text string for each GPU """ + """Formatted index: name text string for each GPU""" return [f"{idx}: {device}" for idx, device in enumerate(self._device_names)] @property def exclude_all_devices(self) -> bool: - """ bool: ``True`` if all GPU devices have been explicitly disabled otherwise ``False`` """ + """``True`` if all GPU devices have been explicitly disabled otherwise ``False``""" return all(idx in _EXCLUDE_DEVICES for idx in range(self._device_count)) @property def sys_info(self) -> GPUInfo: - """ :class:`GPUInfo`: The GPU Stats that are required for system information logging """ + """The GPU Stats that are required for system information logging""" return GPUInfo(vram=self._vram, vram_free=self._get_free_vram(), driver=self._driver, @@ -113,14 +122,14 @@ def sys_info(self) -> GPUInfo: devices_active=self._active_devices) def _log(self, level: str, message: str) -> None: - """ If the class has been initialized with :attr:`log` as `True` then log the message + """If the class has been initialized with :attr:`log` as `True` then log the message otherwise skip logging. Parameters ---------- - level: str + level The log level to log at - message: str + message The message to log """ if self._logger is None: @@ -129,25 +138,24 @@ def _log(self, level: str, message: str) -> None: logger(message) def _initialize(self) -> None: - """ Override to initialize the GPU device handles and any other necessary resources. """ + """Override to initialize the GPU device handles and any other necessary resources.""" self._is_initialized = True def _shutdown(self) -> None: - """ Override to shutdown the GPU device handles and any other necessary resources. """ + """Override to shutdown the GPU device handles and any other necessary resources.""" self._is_initialized = False def _get_device_count(self) -> int: - """ Override to obtain the number of GPU devices + """Override to obtain the number of GPU devices Returns ------- - int - The total number of GPUs connected to the PC + The total number of GPUs connected to the PC """ raise NotImplementedError() def _get_active_devices(self) -> list[int]: - """ Obtain the indices of active GPUs (those that have not been explicitly excluded in + """Obtain the indices of active GPUs (those that have not been explicitly excluded in the command line arguments). Notes @@ -156,77 +164,69 @@ def _get_active_devices(self) -> list[int]: Returns ------- - list - The list of device indices that are available for Faceswap to use + The list of device indices that are available for Faceswap to use """ devices = [idx for idx in range(self._device_count) if idx not in _EXCLUDE_DEVICES] self._log("debug", f"Active GPU Devices: {devices}") return devices def _get_handles(self) -> list: - """ Override to obtain GPU specific device handles for all connected devices. + """Override to obtain GPU specific device handles for all connected devices. Returns ------- - list - The device handle for each connected GPU + The device handle for each connected GPU """ raise NotImplementedError() def _get_driver(self) -> str: - """ Override to obtain the GPU specific driver version. + """Override to obtain the GPU specific driver version. Returns ------- - str - The GPU driver currently in use + The GPU driver currently in use """ raise NotImplementedError() def _get_device_names(self) -> list[str]: - """ Override to obtain the names of all connected GPUs. The quality of this information + """Override to obtain the names of all connected GPUs. The quality of this information depends on the backend and OS being used, but it should be sufficient for identifying cards. Returns ------- - list - List of device names for connected GPUs as corresponding to the values in - :attr:`_handles` + List of device names for connected GPUs as corresponding to the values in :attr:`_handles` """ raise NotImplementedError() def _get_vram(self) -> list[int]: - """ Override to obtain the total VRAM in Megabytes for each connected GPU. + """Override to obtain the total VRAM in Megabytes for each connected GPU. Returns ------- - list - List of `float`s containing the total amount of VRAM in Megabytes for each - connected GPU as corresponding to the values in :attr:`_handles` + List of `float`s containing the total amount of VRAM in Megabytes for each connected GPU + as corresponding to the values in :attr:`_handles` """ raise NotImplementedError() def _get_free_vram(self) -> list[int]: - """ Override to obtain the amount of VRAM that is available, in Megabytes, for each + """Override to obtain the amount of VRAM that is available, in Megabytes, for each connected GPU. Returns ------- - list - List of `float`s containing the amount of VRAM available, in Megabytes, for each - connected GPU as corresponding to the values in :attr:`_handles + List of `float`s containing the amount of VRAM available, in Megabytes, for each connected + GPU as corresponding to the values in :attr:`_handles """ raise NotImplementedError() def get_card_most_free(self) -> BiggestGPUInfo: - """ Obtain statistics for the GPU with the most available free VRAM. + """Obtain statistics for the GPU with the most available free VRAM. Returns ------- - :class:`BiggestGpuInfo` - If a GPU is not detected then the **card_id** is returned as ``-1`` and the amount - of free and total RAM available is fixed to 2048 Megabytes. + If a GPU is not detected then the **card_id** is returned as ``-1`` and the amount + of free and total RAM available is fixed to 2048 Megabytes. """ if len(self._active_devices) == 0: retval = BiggestGPUInfo(card_id=-1, @@ -245,11 +245,10 @@ def get_card_most_free(self) -> BiggestGPUInfo: return retval def exclude_devices(self, devices: list[int]) -> None: - """ Exclude GPU devices from being used by Faceswap. Override for backend specific logic + """Exclude GPU devices from being used by Faceswap. Override for backend specific logic Parameters ---------- - devices: list[int] - The GPU device IDS to be excluded + The GPU device IDS to be excluded """ raise NotImplementedError diff --git a/lib/gpu_stats/apple_silicon.py b/lib/gpu_stats/apple_silicon.py index 467cc20819..2ee442ce83 100644 --- a/lib/gpu_stats/apple_silicon.py +++ b/lib/gpu_stats/apple_silicon.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Collects and returns Information on available Apple Silicon SoCs in Apple Macs. """ +"""Collects and returns Information on available Apple Silicon SoCs in Apple Macs.""" import typing as T import os @@ -12,11 +12,11 @@ from ._base import _GPUStats -_metal_initialized: bool = False +_METAL_INITIALIZED: bool = False class AppleSiliconStats(_GPUStats): - """ Holds information and statistics about Apple Silicon SoC(s) available on the currently + """Holds information and statistics about Apple Silicon SoC(s) available on the currently running Apple system. Notes @@ -27,7 +27,7 @@ class AppleSiliconStats(_GPUStats): Parameters ---------- - log: bool, optional + log Whether the class should output information to the logger. There may be occasions where the logger has not yet been set up when this class is queried. Attempting to log in these instances will raise an error. If GPU stats are being queried prior to the logger being @@ -41,7 +41,7 @@ def __init__(self, log: bool = True) -> None: super().__init__(log=log) def _initialize(self) -> None: - """ Initialize Metal for Apple Silicon SoC(s). + """Initialize Metal for Apple Silicon SoC(s). If :attr:`_is_initialized` is ``True`` then this function just returns performing no action. Otherwise :attr:`is_initialized` is set to ``True`` after successfully @@ -57,13 +57,11 @@ def _initialize(self) -> None: super()._initialize() def _initialize_metal(self) -> None: - """ Initialize Metal on first call to this class and set global - :attr:``_metal_initialized`` to ``True``. If Metal has already been initialized then return - performing no action. - """ - global _metal_initialized # pylint:disable=global-statement + """Initialize Metal on first call to this class and set global :attr:``_METAL_INITIALIZED`` + to ``True``. If Metal has already been initialized then return performing no action.""" + global _METAL_INITIALIZED # pylint:disable=global-statement - if _metal_initialized: + if _METAL_INITIALIZED: return self._log("debug", "Performing first time Apple SoC setup.") @@ -77,10 +75,10 @@ def _initialize_metal(self) -> None: self._test_torch() - _metal_initialized = True + _METAL_INITIALIZED = True def _test_torch(self) -> None: - """ Test that torch can execute correctly. + """Test that torch can execute correctly. Raises ------ @@ -92,24 +90,23 @@ def _test_torch(self) -> None: self._log("debug", f"Torch initialization test: (mem_info: {meminfo})") except RuntimeError as err: - msg = ("An unhandled exception occured initializing the device via Torch " + msg = ("An unhandled exception occurred initializing the device via Torch " f"Library. Original error: {str(err)}") raise FaceswapError(msg) from err def _get_device_count(self) -> int: - """ Detect the number of SoCs attached to the system. + """Detect the number of SoCs attached to the system. Returns ------- - int - The total number of SoCs available + The total number of SoCs available """ retval = len(self._mps_devices) self._log("debug", f"GPU Device count: {retval}") return retval def _get_handles(self) -> list: - """ Obtain the device handles for all available Apple Silicon SoCs. + """Obtain the device handles for all available Apple Silicon SoCs. Notes ----- @@ -118,15 +115,14 @@ def _get_handles(self) -> list: Returns ------- - list - The list of indices for available Apple Silicon SoCs + The list of indices for available Apple Silicon SoCs """ handles = list(range(self._device_count)) self._log("debug", f"GPU Handles found: {handles}") return handles def _get_driver(self) -> str: - """ Obtain the Apple Silicon driver version currently in use. + """Obtain the Apple Silicon driver version currently in use. Notes ----- @@ -135,34 +131,31 @@ def _get_driver(self) -> str: Returns ------- - str - The current SoC driver version + The current SoC driver version """ driver = "Not Applicable" self._log("debug", f"GPU Driver: {driver}") return driver def _get_device_names(self) -> list[str]: - """ Obtain the list of names of available Apple Silicon SoC(s) as identified in + """Obtain the list of names of available Apple Silicon SoC(s) as identified in :attr:`_handles`. Returns ------- - list - The list of available Apple Silicon SoC names + The list of available Apple Silicon SoC names """ names = [d.type for d in self._mps_devices] self._log("debug", f"GPU Devices: {names}") return names def _get_vram(self) -> list[int]: - """ Obtain the VRAM in Megabytes for each available Apple Silicon SoC(s) as identified in + """Obtain the VRAM in Megabytes for each available Apple Silicon SoC(s) as identified in :attr:`_handles`. Returns ------- - list - The RAM in Megabytes for each available Apple Silicon SoC + The RAM in Megabytes for each available Apple Silicon SoC """ vram = [int((torch.mps.driver_allocated_memory() / self._device_count) / (1024 * 1024)) for _ in range(self._device_count)] @@ -170,14 +163,13 @@ def _get_vram(self) -> list[int]: return vram def _get_free_vram(self) -> list[int]: - """ Obtain the amount of VRAM that is available, in Megabytes, for each available Apple + """Obtain the amount of VRAM that is available, in Megabytes, for each available Apple Silicon SoC. Returns ------- - list - List of `float`s containing the amount of RAM available, in Megabytes, for each - available SoC as corresponding to the values in :attr:`_handles + List of `float`s containing the amount of RAM available, in Megabytes, for each available + SoC as corresponding to the values in :attr:`_handles """ vram = [int((psutil.virtual_memory().available / self._device_count) / (1024 * 1024)) for _ in range(self._device_count)] @@ -185,11 +177,11 @@ def _get_free_vram(self) -> list[int]: return vram def exclude_devices(self, devices: list[int]) -> None: - """ Apple-Silicon does not support excluding devices + """Apple-Silicon does not support excluding devices Parameters ---------- - devices: list[int] + devices The GPU device IDS to be excluded """ self._log("warning", "Apple Silicon does not support excluding GPUs. This option has been " diff --git a/lib/gpu_stats/cpu.py b/lib/gpu_stats/cpu.py index 0a4194ee9b..fae8c93e06 100644 --- a/lib/gpu_stats/cpu.py +++ b/lib/gpu_stats/cpu.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Dummy functions for running faceswap on CPU. """ +"""Dummy functions for running faceswap on CPU.""" from lib.utils import get_module_objects @@ -7,7 +7,7 @@ class CPUStats(_GPUStats): - """ Holds information and statistics about the CPU on the currently running system. + """Holds information and statistics about the CPU on the currently running system. Notes ----- @@ -19,7 +19,7 @@ class CPUStats(_GPUStats): Parameters ---------- - log: bool, optional + log Whether the class should output information to the logger. There may be occasions where the logger has not yet been set up when this class is queried. Attempting to log in these instances will raise an error. If GPU stats are being queried prior to the logger being @@ -28,85 +28,78 @@ class CPUStats(_GPUStats): """ def _get_device_count(self) -> int: - """ Detect the number of GPUs attached to the system. Always returns zero for CPU + """Detect the number of GPUs attached to the system. Always returns zero for CPU backends. Returns ------- - int - The total number of GPUs connected to the PC + The total number of GPUs connected to the PC """ retval = 0 self._log("debug", f"GPU Device count: {retval}") return retval def _get_handles(self) -> list: - """ Obtain the device handles for all connected GPUs. + """Obtain the device handles for all connected GPUs. Returns ------- - list - An empty list for CPU Backends + An empty list for CPU Backends """ handles: list = [] self._log("debug", f"GPU Handles found: {len(handles)}") return handles def _get_driver(self) -> str: - """ Obtain the driver version currently in use. + """Obtain the driver version currently in use. Returns ------- - str - An empty string for CPU backends + An empty string for CPU backends """ driver = "" self._log("debug", f"GPU Driver: {driver}") return driver def _get_device_names(self) -> list[str]: - """ Obtain the list of names of connected GPUs as identified in :attr:`_handles`. + """Obtain the list of names of connected GPUs as identified in :attr:`_handles`. Returns ------- - list - An empty list for CPU backends + An empty list for CPU backends """ names: list[str] = [] self._log("debug", f"GPU Devices: {names}") return names def _get_vram(self) -> list[int]: - """ Obtain the RAM in Megabytes for the running system. + """Obtain the RAM in Megabytes for the running system. Returns ------- - list - An empty list for CPU backends + An empty list for CPU backends """ vram: list[int] = [] self._log("debug", f"GPU VRAM: {vram}") return vram def _get_free_vram(self) -> list[int]: - """ Obtain the amount of RAM that is available, in Megabytes, for the running system. + """Obtain the amount of RAM that is available, in Megabytes, for the running system. Returns ------- - list - An empty list for CPU backends + An empty list for CPU backends """ vram: list[int] = [] self._log("debug", f"GPU VRAM free: {vram}") return vram def exclude_devices(self, devices: list[int]) -> None: - """ CPU does not support excluding devices + """CPU does not support excluding devices Parameters ---------- - devices: list[int] - The GPU device IDS to be excluded + The GPU device IDS to be excluded """ self._log("warning", "CPU does not support excluding GPUs. This option has been ignored") diff --git a/lib/gpu_stats/nvidia.py b/lib/gpu_stats/nvidia.py index 29f1a872f4..9c54ba2b3f 100644 --- a/lib/gpu_stats/nvidia.py +++ b/lib/gpu_stats/nvidia.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Collects and returns Information on available Nvidia GPUs. """ +"""Collects and returns Information on available Nvidia GPUs. """ import os import pynvml # pylint:disable=import-error @@ -10,7 +10,7 @@ class NvidiaStats(_GPUStats): - """ Holds information and statistics about Nvidia GPU(s) available on the currently + """Holds information and statistics about Nvidia GPU(s) available on the currently running system. Notes @@ -20,7 +20,7 @@ class NvidiaStats(_GPUStats): Parameters ---------- - log: bool, optional + log Whether the class should output information to the logger. There may be occasions where the logger has not yet been set up when this class is queried. Attempting to log in these instances will raise an error. If GPU stats are being queried prior to the logger being @@ -29,7 +29,7 @@ class NvidiaStats(_GPUStats): """ def _initialize(self) -> None: - """ Initialize PyNVML for Nvidia GPUs. + """Initialize PyNVML for Nvidia GPUs. If :attr:`_is_initialized` is ``True`` then this function just returns performing no action. Otherwise :attr:`is_initialized` is set to ``True`` after successfully @@ -54,7 +54,7 @@ def _initialize(self) -> None: f"Error: {str(err)}") raise FaceswapError(msg) from err except Exception as err: # pylint:disable=broad-except - msg = ("An unhandled exception occured reading from the Nvidia Machine Learning " + msg = ("An unhandled exception occurred reading from the Nvidia Machine Learning " f"Library. Original error: {str(err)}") raise FaceswapError(msg) from err @@ -62,18 +62,17 @@ def _initialize(self) -> None: super()._initialize() def _shutdown(self) -> None: - """ Cleanly close access to NVML and set :attr:`_is_initialized` back to ``False``. """ + """Cleanly close access to NVML and set :attr:`_is_initialized` back to ``False``. """ self._log("debug", "Shutting down NVML") pynvml.nvmlShutdown() super()._shutdown() def _get_device_count(self) -> int: - """ Detect the number of GPUs attached to the system. + """Detect the number of GPUs attached to the system. Returns ------- - int - The total number of GPUs connected to the PC + The total number of GPUs connected to the PC """ try: retval = pynvml.nvmlDeviceGetCount() @@ -85,14 +84,13 @@ def _get_device_count(self) -> int: return retval def _get_active_devices(self) -> list[int]: - """ Obtain the indices of active GPUs (those that have not been explicitly excluded by + """Obtain the indices of active GPUs (those that have not been explicitly excluded by CUDA_VISIBLE_DEVICES environment variable or explicitly excluded in the command line arguments). Returns ------- - list - The list of device indices that are available for Faceswap to use + The list of device indices that are available for Faceswap to use """ # pylint:disable=duplicate-code devices = super()._get_active_devices() @@ -104,12 +102,11 @@ def _get_active_devices(self) -> list[int]: return devices def _get_handles(self) -> list: - """ Obtain the device handles for all connected Nvidia GPUs. + """Obtain the device handles for all connected Nvidia GPUs. Returns ------- - list - The list of pointers for connected Nvidia GPUs + The list of pointers for connected Nvidia GPUs """ handles = [pynvml.nvmlDeviceGetHandleByIndex(i) for i in range(self._device_count)] @@ -117,12 +114,11 @@ def _get_handles(self) -> list: return handles def _get_driver(self) -> str: - """ Obtain the Nvidia driver version currently in use. + """Obtain the Nvidia driver version currently in use. Returns ------- - str - The current GPU driver version + The current GPU driver version """ try: driver = pynvml.nvmlSystemGetDriverVersion() @@ -133,12 +129,11 @@ def _get_driver(self) -> str: return driver def _get_device_names(self) -> list[str]: - """ Obtain the list of names of connected Nvidia GPUs as identified in :attr:`_handles`. + """Obtain the list of names of connected Nvidia GPUs as identified in :attr:`_handles`. Returns ------- - list - The list of connected Nvidia GPU names + The list of connected Nvidia GPU names """ names = [pynvml.nvmlDeviceGetName(handle) for handle in self._handles] @@ -146,13 +141,12 @@ def _get_device_names(self) -> list[str]: return names def _get_vram(self) -> list[int]: - """ Obtain the VRAM in Megabytes for each connected Nvidia GPU as identified in + """Obtain the VRAM in Megabytes for each connected Nvidia GPU as identified in :attr:`_handles`. Returns ------- - list - The VRAM in Megabytes for each connected Nvidia GPU + The VRAM in Megabytes for each connected Nvidia GPU """ vram = [pynvml.nvmlDeviceGetMemoryInfo(handle).total / (1024 * 1024) for handle in self._handles] @@ -160,14 +154,13 @@ def _get_vram(self) -> list[int]: return vram def _get_free_vram(self) -> list[int]: - """ Obtain the amount of VRAM that is available, in Megabytes, for each connected Nvidia + """Obtain the amount of VRAM that is available, in Megabytes, for each connected Nvidia GPU. Returns ------- - list - List of `float`s containing the amount of VRAM available, in Megabytes, for each - connected GPU as corresponding to the values in :attr:`_handles + List of `float`s containing the amount of VRAM available, in Megabytes, for each connected + GPU as corresponding to the values in :attr:`_handles """ is_initialized = self._is_initialized if not is_initialized: @@ -183,18 +176,17 @@ def _get_free_vram(self) -> list[int]: return vram def exclude_devices(self, devices: list[int]) -> None: - """ Exclude GPU devices from being used by Faceswap. Sets the CUDA_VISIBLE_DEVICES + """Exclude GPU devices from being used by Faceswap. Sets the CUDA_VISIBLE_DEVICES environment variable. This must be called before Torch/Keras are imported Parameters ---------- - devices: list[int] - The GPU device IDS to be excluded + The GPU device IDS to be excluded """ # pylint:disable=duplicate-code if not devices: return - self._log("debug", f"Excluding GPU indicies: {devices}") + self._log("debug", f"Excluding GPU indices: {devices}") _EXCLUDE_DEVICES.extend(devices) @@ -204,7 +196,7 @@ def exclude_devices(self, devices: list[int]) -> None: if d not in _EXCLUDE_DEVICES) env_vars = [f"{k}: {v}" for k, v in os.environ.items() if k.lower().startswith("cuda")] - self._log("debug", f"Cuda environmet variables: {env_vars}") + self._log("debug", f"Cuda environment variables: {env_vars}") __all__ = get_module_objects(__name__) diff --git a/lib/gpu_stats/rocm.py b/lib/gpu_stats/rocm.py index 7c7c6d4c9a..a6e3c2250a 100644 --- a/lib/gpu_stats/rocm.py +++ b/lib/gpu_stats/rocm.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Collects and returns Information about connected AMD GPUs for ROCm using sysfs and from +"""Collects and returns Information about connected AMD GPUs for ROCm using sysfs and from modinfo As no ROCm compatible hardware was available for testing, this just returns information on all AMD @@ -211,11 +211,11 @@ class ROCm(_GPUStats): - """ Holds information and statistics about GPUs connected using sysfs + """Holds information and statistics about GPUs connected using sysfs Parameters ---------- - log: bool, optional + log Whether the class should output information to the logger. There may be occasions where the logger has not yet been set up when this class is queried. Attempting to log in these instances will raise an error. If GPU stats are being queried prior to the logger being @@ -229,37 +229,35 @@ def __init__(self, log: bool = True) -> None: super().__init__(log=log) def _from_sysfs_file(self, path: str) -> str: - """ Obtain the value from a sysfs file. On permission error or file doesn't exist, log and + """Obtain the value from a sysfs file. On permission error or file doesn't exist, log and return empty value Parameters ---------- - path: str + path The path to a sysfs file to obtain the value from Returns ------- - str - The obtained value from the given path + The obtained value from the given path """ if not os.path.isfile(path): self._log("debug", f"File '{path}' does not exist. Returning empty string") return "" try: - with open(path, "r", encoding="utf-8", errors="ignore") as sysfile: - val = sysfile.read().strip() + with open(path, "r", encoding="utf-8", errors="ignore") as sys_file: + val = sys_file.read().strip() except PermissionError: self._log("debug", f"Permission error accessing file '{path}'. Returning empty string") val = "" return val def _get_sysfs_paths(self) -> list[str]: - """ Obtain a list of sysfs paths to AMD branded GPUs connected to the system + """Obtain a list of sysfs paths to AMD branded GPUs connected to the system Returns ------- - list[str] - List of full paths to the sysfs entries for connected AMD GPUs + List of full paths to the sysfs entries for connected AMD GPUs """ base_dir = "/sys/class/drm/" @@ -286,7 +284,7 @@ def _get_sysfs_paths(self) -> list[str]: return retval def _initialize(self) -> None: - """ Initialize sysfs for ROCm backend. + """Initialize sysfs for ROCm backend. If :attr:`_is_initialized` is ``True`` then this function just returns performing no action. @@ -303,12 +301,11 @@ def _initialize(self) -> None: super()._initialize() def _get_device_count(self) -> int: - """ The number of AMD cards found in sysfs + """The number of AMD cards found in sysfs Returns ------- - int - The total number of GPUs available + The total number of GPUs available """ if self._is_wsl: retval = torch.cuda.device_count() @@ -318,13 +315,12 @@ def _get_device_count(self) -> int: return retval def _get_handles(self) -> list: - """ The sysfs doesn't use device handles, so we just return the list of the sysfs locations + """The sysfs doesn't use device handles, so we just return the list of the sysfs locations per card Returns ------- - list - The list of all discovered GPUs + The list of all discovered GPUs """ if self._is_wsl: handles = list(range(self._device_count)) @@ -334,12 +330,11 @@ def _get_handles(self) -> list: return handles def _get_driver(self) -> str: - """ Obtain the driver versions currently in use from modinfo + """Obtain the driver versions currently in use from modinfo Returns ------- - str - The current AMDGPU driver versions + The current AMDGPU driver versions """ if self._is_wsl: retval = "unknown (wsl2)" @@ -364,12 +359,11 @@ def _get_driver(self) -> str: return retval def _get_device_names(self) -> list[str]: - """ Obtain the list of names of connected GPUs as identified in :attr:`_handles`. + """Obtain the list of names of connected GPUs as identified in :attr:`_handles`. Returns ------- - list - The list of connected AMD GPU names + The list of connected AMD GPU names """ retval = [] for device in self._handles: @@ -403,14 +397,13 @@ def _get_device_names(self) -> list[str]: return retval def _get_active_devices(self) -> list[int]: - """ Obtain the indices of active GPUs (those that have not been explicitly excluded by + """Obtain the indices of active GPUs (those that have not been explicitly excluded by HIP_VISIBLE_DEVICES environment variable or explicitly excluded in the command line arguments). Returns ------- - list - The list of device indices that are available for Faceswap to use + The list of device indices that are available for Faceswap to use """ devices = super()._get_active_devices() env_devices = os.environ.get("HIP_VISIBLE_DEVICES") @@ -421,13 +414,12 @@ def _get_active_devices(self) -> list[int]: return devices def _get_vram(self) -> list[int]: - """ Obtain the VRAM in Megabytes for each connected AMD GPU as identified in + """Obtain the VRAM in Megabytes for each connected AMD GPU as identified in :attr:`_handles`. Returns ------- - list - The VRAM in Megabytes for each connected Nvidia GPU + The VRAM in Megabytes for each connected Nvidia GPU """ retval = [] for device in self._handles: @@ -446,14 +438,13 @@ def _get_vram(self) -> list[int]: return retval def _get_free_vram(self) -> list[int]: - """ Obtain the amount of VRAM that is available, in Megabytes, for each connected AMD + """Obtain the amount of VRAM that is available, in Megabytes, for each connected AMD GPU. Returns ------- - list - List of `float`s containing the amount of VRAM available, in Megabytes, for each - connected GPU as corresponding to the values in :attr:`_handles + List of `float`s containing the amount of VRAM available, in Megabytes, for each connected + GPU as corresponding to the values in :attr:`_handles """ retval = [] total_vram = self._get_vram() @@ -480,17 +471,17 @@ def _get_free_vram(self) -> list[int]: return retval def exclude_devices(self, devices: list[int]) -> None: - """ Exclude GPU devices from being used by Faceswap. Sets the HIP_VISIBLE_DEVICES + """Exclude GPU devices from being used by Faceswap. Sets the HIP_VISIBLE_DEVICES environment variable. This must be called before Torch/Keras are imported Parameters ---------- - devices: list[int] + devices The GPU device IDS to be excluded """ if not devices: return - self._log("debug", f"Excluding GPU indicies: {devices}") + self._log("debug", f"Excluding GPU indices: {devices}") _EXCLUDE_DEVICES.extend(devices) @@ -500,7 +491,7 @@ def exclude_devices(self, devices: list[int]) -> None: if d not in _EXCLUDE_DEVICES) env_vars = [f"{k}: {v}" for k, v in os.environ.items() if k.lower().startswith("hip")] - self._log("debug", f"HIP environmet variables: {env_vars}") + self._log("debug", f"HIP environment variables: {env_vars}") __all__ = get_module_objects(__name__) diff --git a/lib/gui/command.py b/lib/gui/command.py index 1f1dbccd18..72a8dbf409 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -146,10 +146,10 @@ def build_tab(self): def add_frame_separator(self): """ Add a separator between top and bottom frames """ - logger.debug("Add frame seperator") + logger.debug("Add frame separator") sep = ttk.Frame(self, height=2, relief=tk.RIDGE) sep.pack(fill=tk.X, pady=(5, 0), side=tk.TOP) - logger.debug("Added frame seperator") + logger.debug("Added frame separator") class ActionFrame(ttk.Frame): # pylint:disable=too-many-ancestors diff --git a/lib/gui/display_page.py b/lib/gui/display_page.py index 19444e57c3..3742fac0b2 100644 --- a/lib/gui/display_page.py +++ b/lib/gui/display_page.py @@ -92,7 +92,7 @@ def set_info(self, msg): def add_frame_separator(self): """ Add a separator between top and bottom frames """ - logger.debug("Adding frame seperator") + logger.debug("Adding frame separator") sep = ttk.Frame(self, height=2, relief=tk.RIDGE) sep.pack(fill=tk.X, pady=(5, 0), side=tk.BOTTOM) diff --git a/lib/image.py b/lib/image.py index 26eb1de46c..5eb3e178bf 100644 --- a/lib/image.py +++ b/lib/image.py @@ -1211,12 +1211,17 @@ def file_list(self) -> list[str]: video then this is a list of dummy filenames as corresponding to an alignments file """ return self._file_list - def add_skip_list(self, skip_list): + @property + def processed_file_list(self) -> list[str]: + """A list of files in the source location with any files that will be skipped removed""" + return [f for i, f in enumerate(self._file_list) if i not in self._skip_list] + + def add_skip_list(self, skip_list: list[int]): """ Add a skip list to this :class:`ImagesLoader` Parameters ---------- - skip_list: list + skip_list: list[int] A list of indices corresponding to the frame indices that should be skipped by the :func:`load` function. """ diff --git a/lib/logger.py b/lib/logger.py index d9dbbb1d3a..d8d4703bb3 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -1,6 +1,8 @@ #!/usr/bin/python -""" Logging Functions for Faceswap. """ +"""Logging Functions for Faceswap.""" # NOTE: Don't import non stdlib packages. This module is accessed by setup.py +from __future__ import annotations + import collections import logging from logging.handlers import RotatingFileHandler @@ -16,64 +18,70 @@ from lib.utils import get_module_objects +if T.TYPE_CHECKING: + import numpy as np -class FaceswapLogger(logging.Logger): - """ A standard :class:`logging.logger` with additional "verbose" and "trace" levels added. """ - def __init__(self, name: str) -> None: - for new_level in (("VERBOSE", 15), ("TRACE", 5)): - level_name, level_num = new_level - if hasattr(logging, level_name): - continue - logging.addLevelName(level_num, level_name) - setattr(logging, level_name, level_num) - super().__init__(name) +# Add our custom levels to logger +for new_level in (("VERBOSE", 15), ("TRACE", 5)): + level_name, level_num = new_level + level_map = logging.getLevelNamesMapping() + if level_name in level_map: + continue + logging.addLevelName(level_num, level_name) + level_map[level_name] = level_num + + +class FaceswapLogger(logging.Logger): + """A standard :class:`logging.logger` with additional "verbose" and "trace" levels added. """ def verbose(self, msg: str, *args, **kwargs) -> None: # pylint:disable=wrong-spelling-in-docstring - """ Create a log message at severity level 15. + """Create a log message at severity level 15. Parameters ---------- - msg: str + msg The log message to be recorded at Verbose level - args: tuple + args Standard logging arguments - kwargs: dict + kwargs Standard logging key word arguments """ if self.isEnabledFor(15): + kwargs.setdefault("stacklevel", 2) self._log(15, msg, args, **kwargs) def trace(self, msg: str, *args, **kwargs) -> None: # pylint:disable=wrong-spelling-in-docstring - """ Create a log message at severity level 5. + """Create a log message at severity level 5. Parameters ---------- - msg: str + msg The log message to be recorded at Trace level - args: tuple + args Standard logging arguments - kwargs: dict + kwargs Standard logging key word arguments """ if self.isEnabledFor(5): + kwargs.setdefault("stacklevel", 2) self._log(5, msg, args, **kwargs) class ColoredFormatter(logging.Formatter): - """ Overrides the stand :class:`logging.Formatter` to enable colored labels for message level + """Overrides the stand :class:`logging.Formatter` to enable colored labels for message level labels on supported platforms Parameters ---------- - fmt: str + fmt The format string for the message as a whole - pad_newlines: bool, Optional + pad_newlines If ``True`` new lines will be padded to appear in line with the log message, if ``False`` they will be left aligned - kwargs: dict + kwargs Standard :class:`logging.Formatter` keyword arguments """ def __init__(self, fmt: str, pad_newlines: bool = False, **kwargs) -> None: @@ -89,13 +97,12 @@ def __init__(self, fmt: str, pad_newlines: bool = False, **kwargs) -> None: @classmethod def _get_color_compatibility(cls) -> bool: - """ Return whether the system supports color ansi codes. Most OSes do other than Windows + """Return whether the system supports color ansi codes. Most OSes do other than Windows below Windows 10 version 1511. Returns ------- - bool - ``True`` if the system supports color ansi codes otherwise ``False`` + ``True`` if the system supports color ansi codes otherwise ``False`` """ if platform.system().lower() != "windows": return True @@ -108,20 +115,19 @@ def _get_color_compatibility(cls) -> bool: return False def _get_newline_padding(self, pad_newlines: bool, fmt: str) -> int: - """ Parses the format string to obtain padding for newlines if requested + """Parses the format string to obtain padding for newlines if requested Parameters ---------- - fmt: str + fmt The format string for the message as a whole - pad_newlines: bool, Optional + pad_newlines If ``True`` new lines will be padded to appear in line with the log message, if ``False`` they will be left aligned Returns ------- - int - The amount of padding to apply to the front of newlines + The amount of padding to apply to the front of newlines """ if not pad_newlines: return 0 @@ -134,36 +140,34 @@ def _get_newline_padding(self, pad_newlines: bool, fmt: str) -> int: return sum(pads) + spaces def _get_sample_time_string(self) -> int: - """ Obtain a sample time string and calculate correct padding. + """Obtain a sample time string and calculate correct padding. This may be inaccurate when ticking over an integer from single to double digits, but that shouldn't be a huge issue. Returns ------- - int - The length of the formatted date-time string + The length of the formatted date-time string """ sample_time = time.time() date_format = self.datefmt if self.datefmt else self.default_time_format - datestring = time.strftime(date_format, logging.Formatter.converter(sample_time)) + date_string = time.strftime(date_format, logging.Formatter.converter(sample_time)) if not self.datefmt and self.default_msec_format: - msecs = (sample_time - int(sample_time)) * 1000 - datestring = self.default_msec_format % (datestring, msecs) - return len(datestring) + m_secs = (sample_time - int(sample_time)) * 1000 + date_string = self.default_msec_format % (date_string, m_secs) + return len(date_string) def format(self, record: logging.LogRecord) -> str: - """ Color the log message level if supported otherwise return the standard log message. + """Color the log message level if supported otherwise return the standard log message. Parameters ---------- - record: :class:`logging.LogRecord` + record The incoming log record to be formatted for entry into the logger. Returns ------- - str - The formatted log message + The formatted log message """ formatted = super().format(record) levelname = record.levelname @@ -178,7 +182,7 @@ def format(self, record: logging.LogRecord) -> str: class FaceswapFormatter(logging.Formatter): - """ Overrides the standard :class:`logging.Formatter`. + """Overrides the standard :class:`logging.Formatter`. Strip newlines from incoming log messages. @@ -186,17 +190,16 @@ class FaceswapFormatter(logging.Formatter): """ def format(self, record: logging.LogRecord) -> str: - """ Strip new lines from log records and rewrite certain warning messages to debug level. + """Strip new lines from log records and rewrite certain warning messages to debug level. Parameters ---------- - record : :class:`logging.LogRecord` + record The incoming log record to be formatted for entry into the logger. Returns ------- - str - The formatted log message + The formatted log message """ record.message = record.getMessage() record = self._lower_external(record) @@ -224,20 +227,19 @@ def format(self, record: logging.LogRecord) -> str: @classmethod def _lower_external(cls, record: logging.LogRecord) -> logging.LogRecord: - """ Some external libs log at a higher level than we would really like, so lower their + """Some external libs log at a higher level than we would really like, so lower their log level. Specifically: Matplotlib font properties Parameters ---------- - record: :class:`logging.LogRecord` + record The log record to check for rewriting Returns ---------- - :class:`logging.LogRecord` - The log rewritten or untouched record + The log rewritten or untouched record """ if (record.levelno == 20 and record.funcName == "__init__" and record.module == "font_manager"): @@ -253,11 +255,11 @@ class RollingBuffer(collections.deque): crash log. """ def write(self, buffer: str) -> None: - """ Splits lines from the incoming buffer and writes them out to the rolling buffer. + """Splits lines from the incoming buffer and writes them out to the rolling buffer. Parameters ---------- - buffer: str + buffer The log messages to write to the rolling buffer """ for line in buffer.rstrip().splitlines(): @@ -265,15 +267,15 @@ def write(self, buffer: str) -> None: class TqdmHandler(logging.StreamHandler): - """ Overrides :class:`logging.StreamHandler` to use :func:`tqdm.tqdm.write` rather than writing + """Overrides :class:`logging.StreamHandler` to use :func:`tqdm.tqdm.write` rather than writing to :func:`sys.stderr` so that log messages do not mess up tqdm progress bars. """ def emit(self, record: logging.LogRecord) -> None: - """ Format the incoming message and pass to :func:`tqdm.tqdm.write`. + """Format the incoming message and pass to :func:`tqdm.tqdm.write`. Parameters ---------- - record : :class:`logging.LogRecord` + record The incoming log record to be formatted for entry into the logger. """ # tqdm is imported here as it won't be installed when setup.py is running @@ -283,17 +285,16 @@ def emit(self, record: logging.LogRecord) -> None: def _set_root_logger(loglevel: int = logging.INFO) -> logging.Logger: - """ Setup the root logger. + """Setup the root logger. Parameters ---------- - loglevel: int, optional + loglevel The log level to set the root logger to. Default :attr:`logging.INFO` Returns ------- - :class:`logging.Logger` - The root logger for Faceswap + The root logger for Faceswap """ rootlogger = logging.getLogger() rootlogger.setLevel(loglevel) @@ -302,21 +303,21 @@ def _set_root_logger(loglevel: int = logging.INFO) -> logging.Logger: def log_setup(loglevel, log_file: str, command: str, is_gui: bool = False) -> None: - """ Set up logging for Faceswap. + """Set up logging for Faceswap. Sets up the root logger, the formatting for the crash logger and the file logger, and sets up the crash, file and stream log handlers. Parameters ---------- - loglevel: str + loglevel The requested log level that Faceswap should be run at. - log_file: str + log_file The location of the log file to write Faceswap's log to - command: str + command The Faceswap command that is being run. Used to dictate whether the log file should have "_gui" appended to the filename or not. - is_gui: bool, optional + is_gui Whether Faceswap is running in the GUI or not. Dictates where the stream handler should output messages to. Default: ``False`` """ @@ -349,24 +350,23 @@ def _file_handler(loglevel, log_file: str, log_format: FaceswapFormatter, command: str) -> RotatingFileHandler: - """ Add a rotating file handler for the current Faceswap session. 1 backup is always kept. + """Add a rotating file handler for the current Faceswap session. 1 backup is always kept. Parameters ---------- - loglevel: str + loglevel The requested log level that messages should be logged at. - log_file: str + log_file The location of the log file to write Faceswap's log to - log_format: :class:`FaceswapFormatter: + log_format The formatting to store log messages as - command: str + command The Faceswap command that is being run. Used to dictate whether the log file should have "_gui" appended to the filename or not. Returns ------- - :class:`logging.RotatingFileHandler` - The logging file handler + The logging file handler """ if log_file: filename = log_file @@ -385,21 +385,20 @@ def _file_handler(loglevel, def _stream_handler(loglevel: int, is_gui: bool) -> logging.StreamHandler | TqdmHandler: - """ Add a stream handler for the current Faceswap session. The stream handler will only ever + """Add a stream handler for the current Faceswap session. The stream handler will only ever output at a maximum of VERBOSE level to avoid spamming the console. Parameters ---------- - loglevel: int + loglevel The requested log level that messages should be logged at. - is_gui: bool, optional + is_gui Whether Faceswap is running in the GUI or not. Dictates where the stream handler should output messages to. Returns ------- - :class:`TqdmHandler` or :class:`logging.StreamHandler` - The stream handler to use + The stream handler to use """ # Don't set stdout to lower than verbose loglevel = max(loglevel, 15) @@ -418,19 +417,18 @@ def _stream_handler(loglevel: int, is_gui: bool) -> logging.StreamHandler | Tqdm def _stream_setup_handler(loglevel: int) -> logging.StreamHandler: - """ Add a stream handler for faceswap's setup.py script + """Add a stream handler for faceswap's setup.py script This stream handler outputs a limited set of easy to use information using colored labels if available. It will only ever output at a minimum of INFO level Parameters ---------- - loglevel: int + loglevel The requested log level that messages should be logged at. Returns ------- - :class:`logging.StreamHandler` - The stream handler to use + The stream handler to use """ loglevel = max(loglevel, 15) log_format = ColoredFormatter("%(levelname)-8s %(message)s", pad_newlines=True) @@ -441,18 +439,17 @@ def _stream_setup_handler(loglevel: int) -> logging.StreamHandler: def _crash_handler(log_format: FaceswapFormatter) -> logging.StreamHandler: - """ Add a handler that stores the last 100 debug lines to :attr:'_DEBUG_BUFFER' for use in + """Add a handler that stores the last 100 debug lines to :attr:'_DEBUG_BUFFER' for use in crash reports. Parameters ---------- - log_format: :class:`FaceswapFormatter: + log_format The formatting to store log messages as Returns ------- - :class:`logging.StreamHandler` - The crash log handler + The crash log handler """ log_crash = logging.StreamHandler(_DEBUG_BUFFER) log_crash.setFormatter(log_format) @@ -461,33 +458,31 @@ def _crash_handler(log_format: FaceswapFormatter) -> logging.StreamHandler: def get_loglevel(loglevel: str) -> int: - """ Check whether a valid log level has been supplied, and return the numeric log level that + """Check whether a valid log level has been supplied, and return the numeric log level that corresponds to the given string level. Parameters ---------- - loglevel: str + loglevel The loglevel that has been requested Returns ------- - int - The numeric representation of the given loglevel + The numeric representation of the given loglevel """ - numeric_level = getattr(logging, loglevel.upper(), None) + numeric_level = logging.getLevelNamesMapping()[loglevel.upper()] if not isinstance(numeric_level, int): raise ValueError(f"Invalid log level: {loglevel}") return numeric_level def crash_log() -> str: - """ On a crash, write out the contents of :func:`_DEBUG_BUFFER` containing the last 100 lines + """On a crash, write out the contents of :func:`_DEBUG_BUFFER` containing the last 100 lines of debug messages to a crash report in the root Faceswap folder. Returns ------- - str - The filename of the file that contains the crash report + The filename of the file that contains the crash report """ original_traceback = traceback.format_exc().encode("utf-8") path = os.path.dirname(os.path.realpath(sys.argv[0])) @@ -505,18 +500,44 @@ def crash_log() -> str: return filename +def format_array(array: np.ndarray) -> str: + """Format arrays to be suitable for logging + + Parameters + ---------- + array + The array to be formatted for logging + + Returns + ------- + String representation of an array for logging + """ + try: + import numpy as np # pylint:disable=import-outside-toplevel + except ImportError: + return repr(array) + + if np.prod(array.shape) <= 10: + retval = "np.array(" + if array.dtype == "object": + retval += f"{[x.tolist() for x in array]}" + else: + retval += str(array.tolist()) + return f"{retval}, dtype='{array.dtype}')" + return f"" + + def _process_value(value: T.Any) -> T.Any: - """ Process the values from a local dict and return in a loggable format + """Process the values from a local dict and return in a format suitable for logging Parameters ---------- - value: Any + value The dictionary value Returns ------- - Any - The original or ammended value + The original or amended value """ if isinstance(value, (list, tuple, set)) and len(value) > 10: return f'[type: "{type(value).__name__}" len: {len(value)}' @@ -526,26 +547,25 @@ def _process_value(value: T.Any) -> T.Any: except ImportError: return repr(value) - if isinstance(value, np.ndarray) and np.prod(value.shape) > 10: - return f'[type: "{type(value).__name__}" shape: {value.shape}, dtype: "{value.dtype}"]' + if isinstance(value, np.ndarray): + return format_array(value) return repr(value) def parse_class_init(locals_dict: dict[str, T.Any]) -> str: - """ Parse a locals dict from a class and return in a format suitable for logging + """Parse a locals dict from a class and return in a format suitable for logging Parameters ---------- - locals_dict: dict[str, T.Any] + locals_dict A locals() dictionary from a newly initialized class Returns ------- - str - The locals information suitable for logging + The locals information suitable for logging """ delimit = {k: _process_value(v) - for k, v in locals_dict.items() if k != "self"} + for k, v in locals_dict.items() if k not in ("self", "__class__")} dsp = ", ".join(f"{k}={v}" for k, v in delimit.items()) dsp = f"({dsp})" if dsp else "" return f"Initializing {locals_dict['self'].__class__.__name__}{dsp}" @@ -555,8 +575,8 @@ def parse_class_init(locals_dict: dict[str, T.Any]) -> str: def _faceswap_logrecord(*args, **kwargs) -> logging.LogRecord: - """ Add a flag to :class:`logging.LogRecord` to not strip formatting from particular - records. """ + """Add a flag to :class:`logging.LogRecord` to not strip formatting from particular + records.""" record = _OLD_FACTORY(*args, **kwargs) record.strip_spaces = True # type:ignore return record diff --git a/lib/system/sysinfo.py b/lib/system/sysinfo.py index 28f067eda9..a01a68c9e0 100644 --- a/lib/system/sysinfo.py +++ b/lib/system/sysinfo.py @@ -1,5 +1,5 @@ #!/usr/bin python3 -""" Obtain information about the running system, environment and GPU. """ +"""Obtain information about the running system, environment and GPU.""" import json import os @@ -22,7 +22,7 @@ class _SysInfo(): - """ Obtain information about the System, Python and GPU """ + """Obtain information about the System, Python and GPU""" def __init__(self) -> None: self._state_file = _State().state_file self._configs = _Configs().configs @@ -36,40 +36,40 @@ def __init__(self) -> None: @property def _ram_free(self) -> int: - """ int : The amount of free RAM in bytes. """ + """The amount of free RAM in bytes.""" if psutil is None: return -1 return psutil.virtual_memory().free @property def _ram_total(self) -> int: - """ int : The amount of total RAM in bytes. """ + """The amount of total RAM in bytes.""" if psutil is None: return -1 return psutil.virtual_memory().total @property def _ram_available(self) -> int: - """ int : The amount of available RAM in bytes. """ + """The amount of available RAM in bytes.""" if psutil is None: return -1 return psutil.virtual_memory().available @property def _ram_used(self) -> int: - """ int : The amount of used RAM in bytes. """ + """The amount of used RAM in bytes.""" if psutil is None: return -1 return psutil.virtual_memory().used @property def _fs_command(self) -> str: - """ str : The command line command used to execute faceswap. """ + """The command line command used to execute faceswap.""" return " ".join(sys.argv) @property def _conda_version(self) -> str: - """ str : The installed version of Conda, or `N/A` if Conda is not installed. """ + """The installed version of Conda, or `N/A` if Conda is not installed.""" if not self._system.is_conda: return "N/A" with Popen("conda --version", shell=True, stdout=PIPE, stderr=PIPE) as conda: @@ -81,7 +81,7 @@ def _conda_version(self) -> str: @property def _git_commits(self) -> str: - """ str : The last 5 git commits for the currently running Faceswap. """ + """The last 5 git commits for the currently running Faceswap.""" commits = git.get_commits(3) if not commits: return "Not Found" @@ -89,14 +89,14 @@ def _git_commits(self) -> str: @property def _cuda_versions(self) -> str: - """ str : The globally installed Cuda versions""" + """The globally installed Cuda versions""" if not self._cuda.versions: return "No global Cuda versions found" return ", ".join(".".join(str(x) for x in v) for v in self._cuda.versions) @property def _cuda_version(self) -> str: - """ str : The installed CUDA version. """ + """The installed CUDA version.""" if self._cuda.version == (0, 0): retval = "No global version found" if self._system.is_conda: @@ -106,7 +106,7 @@ def _cuda_version(self) -> str: @property def _cudnn_versions(self) -> str: - """ str : The installed cuDNN versions. """ + """The installed cuDNN versions.""" if not self._cuda.cudnn_versions: retval = "No global version found" if self._system.is_conda: @@ -121,25 +121,24 @@ def _cudnn_versions(self) -> str: @property def _rocm_version(self) -> str: - """ str : The default ROCm version """ + """The default ROCm version""" if self._rocm.version == (0, 0, 0): return "No default ROCm version found" return ".".join(str(x) for x in self._rocm.version) @property def _rocm_versions(self) -> str: - """ str : The installed ROCm versions """ + """The installed ROCm versions""" if not self._rocm.versions: return "No ROCm versions found" return ", ".join(".".join(str(x) for x in v) for v in self._rocm.versions) def _get_gpu_info(self) -> GPUInfo: - """ Obtain GPU Stats. If an error is raised, swallow the error, and add to GPUInfo output + """Obtain GPU Stats. If an error is raised, swallow the error, and add to GPUInfo output Returns ------- - :class:`~lib.gpu_stats.GPUInfo` - The information on connected GPUs + The information on connected GPUs """ if GPUStats is None: return GPUInfo(vram=[], @@ -159,12 +158,11 @@ def _get_gpu_info(self) -> GPUInfo: return retval def _format_ram(self) -> str: - """ Format the RAM stats into Megabytes to make it more readable. + """Format the RAM stats into Megabytes to make it more readable. Returns ------- - str - The total, available, used and free RAM displayed in Megabytes + The total, available, used and free RAM displayed in Megabytes """ retval = [] for name in ("total", "available", "used", "free"): @@ -174,13 +172,12 @@ def _format_ram(self) -> str: return ", ".join(retval) def full_info(self) -> str: - """ Obtain extensive system information stats, formatted into a human readable format. + """Obtain extensive system information stats, formatted into a human readable format. Returns ------- - str - The system information for the currently running system, formatted for output to - console or a log file. + The system information for the currently running system, formatted for output to console or + a log file. """ retval = "\n============ System Information ============\n" sys_info = {"backend": get_backend(), @@ -226,26 +223,25 @@ def full_info(self) -> str: def get_sysinfo() -> str: - """ Obtain extensive system information stats, formatted into a human readable format. + """Obtain extensive system information stats, formatted into a human readable format. If an error occurs obtaining the system information, then the error message is returned instead. Returns ------- - str - The system information for the currently running system, formatted for output to - console or a log file. + The system information for the currently running system, formatted for output to console or a + log file. """ try: retval = _SysInfo().full_info() except Exception as err: # pylint:disable=broad-except - retval = f"Exception occured trying to retrieve sysinfo: {str(err)}" + retval = f"Exception occurred trying to retrieve sysinfo: {str(err)}" raise return retval class _Configs(): # pylint:disable=too-few-public-methods - """ Parses the config files in /faceswap/config and outputs the information stored within them + """Parses the config files in /faceswap/config and outputs the information stored within them in a human readable format. """ def __init__(self) -> None: @@ -253,62 +249,59 @@ def __init__(self) -> None: self.configs = self._get_configs() def _get_configs(self) -> str: - """ Obtain the formatted configurations from the config folder. + """Obtain the formatted configurations from the config folder. Returns ------- - str - The current configuration in the config files formatted in a human readable format + The current configuration in the config files formatted in a human readable format """ try: - config_files = [os.path.join(self.config_dir, cfile) - for cfile in os.listdir(self.config_dir) - if os.path.basename(cfile) == ".faceswap" - or os.path.splitext(cfile)[1] == ".ini"] + config_files = [os.path.join(self.config_dir, c_file) + for c_file in os.listdir(self.config_dir) + if os.path.basename(c_file) == ".faceswap" + or os.path.splitext(c_file)[1] == ".ini"] return self._parse_configs(config_files) except FileNotFoundError: return "" def _parse_configs(self, config_files: list[str]) -> str: - """ Parse the given list of config files into a human readable format. + """Parse the given list of config files into a human readable format. Parameters ---------- - config_files : list[str] + config_files A list of paths to the faceswap config files Returns ------- - str - The current configuration in the config files formatted in a human readable format + The current configuration in the config files formatted in a human readable format """ formatted = "" - for cfile in config_files: - fname = os.path.basename(cfile) - ext = os.path.splitext(cfile)[1] + for c_file in config_files: + fname = os.path.basename(c_file) + ext = os.path.splitext(c_file)[1] formatted += f"\n--------- {fname} ---------\n" if ext == ".ini": - formatted += self._parse_ini(cfile) + formatted += self._parse_ini(c_file) elif fname == ".faceswap": - formatted += self._parse_json(cfile) + formatted += self._parse_json(c_file) return formatted def _parse_ini(self, config_file: str) -> str: - """ Parse an ``.ini`` formatted config file into a human readable format. + """Parse an ``.ini`` formatted config file into a human readable format. Parameters ---------- - config_file : str + config_file The path to the config.ini file Returns ------- - str - The current configuration in the config file formatted in a human readable format + The current configuration in the config file formatted in a human readable format """ formatted = "" - with open(config_file, "r", encoding="utf-8", errors="replace") as cfile: - for line in cfile.readlines(): + with open(config_file, "r", encoding="utf-8", errors="replace") as c_file: + for line in c_file.readlines(): line = line.strip() if line.startswith("#") or not line: continue @@ -320,21 +313,20 @@ def _parse_ini(self, config_file: str) -> str: return formatted def _parse_json(self, config_file: str) -> str: - """ Parse an ``.json`` formatted config file into a formatted string. + """Parse an ``.json`` formatted config file into a formatted string. Parameters ---------- - config_file : str + config_file The path to the config.json file Returns ------- - dict - The current configuration in the config file formatted as a python dictionary + The current configuration in the config file formatted as a python dictionary """ formatted: str = "" - with open(config_file, "r", encoding="utf-8", errors="replace") as cfile: - conf_dict = json.load(cfile) + with open(config_file, "r", encoding="utf-8", errors="replace") as c_file: + conf_dict = json.load(c_file) for key in sorted(conf_dict.keys()): formatted += self._format_text(key, conf_dict[key]) return formatted @@ -345,21 +337,20 @@ def _format_text(key: str, value: str) -> str: Parameters ---------- - key : str + key The label for this display item - value : str + value The value for this display item Returns ------- - str - The formatted key value pair for display + The formatted key value pair for display """ return f"{key.strip() + ':':<25} {value.strip()}\n" class _State(): # pylint:disable=too-few-public-methods - """ Parses the state file in the current model directory, if the model is training, and + """Parses the state file in the current model directory, if the model is training, and formats the content into a human readable format. """ def __init__(self) -> None: self._model_dir = self._get_arg("-m", "--model-dir") @@ -368,18 +359,17 @@ def __init__(self) -> None: @property def _is_training(self) -> bool: - """ bool : ``True`` if this function has been called during a training session - otherwise ``False``. """ + """``True`` if this function has been called during a training session otherwise + ``False``.""" return len(sys.argv) > 1 and sys.argv[1].lower() == "train" @staticmethod def _get_arg(*args: str) -> str | None: - """ Obtain the value for a given command line option from sys.argv. + """Obtain the value for a given command line option from sys.argv. Returns ------- - str or ``None`` - The value of the given command line option, if it exists, otherwise ``None`` + The value of the given command line option, if it exists, otherwise ``None`` """ cmd = sys.argv for opt in args: @@ -390,12 +380,11 @@ def _get_arg(*args: str) -> str | None: return None def _get_state_file(self) -> str: - """ Parses the model's state file and compiles the contents into a human readable string. + """Parses the model's state file and compiles the contents into a human readable string. Returns ------- - str - The state file formatted into a human readable format + The state file formatted into a human readable format """ if not self._is_training or self._model_dir is None or self._trainer is None: return "" @@ -404,8 +393,8 @@ def _get_state_file(self) -> str: return "" retval = "\n\n=============== State File =================\n" - with open(fname, "r", encoding="utf-8", errors="replace") as sfile: - retval += sfile.read() + with open(fname, "r", encoding="utf-8", errors="replace") as s_file: + retval += s_file.read() return retval From dd2323bc7028a8126ee97255cce4386bf9226a2e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:27:16 +0000 Subject: [PATCH 941/981] Backported updates: - Docs: Centralize sphinx requirements - Logging: Handle Torch warnings and logging within our logging module - Logging: More robust array logging handling - Alignments: Store arrays as list in alignments file - Alignments: Minor constants + Pose updates - Add insightface_resnet network - Update requirements --- docs/full/lib/model.rst | 5 + docs/sphinx_requirements.txt | 2 - lib/align/alignments.py | 93 ++++- lib/align/constants.py | 64 ++-- lib/align/pose.py | 72 +++- lib/align/thumbnails.py | 31 +- lib/align/updater.py | 136 ++++--- lib/gpu_stats/rocm.py | 2 +- lib/logger.py | 118 +++++-- lib/model/networks/insightface_resnet.py | 431 +++++++++++++++++++++++ lib/system/system.py | 4 +- plugins/train/trainer/original.py | 2 +- requirements/_requirements_base.txt | 18 +- requirements/_requirements_dev.txt | 2 + tools/alignments/jobs.py | 2 +- 15 files changed, 816 insertions(+), 166 deletions(-) create mode 100644 lib/model/networks/insightface_resnet.py diff --git a/docs/full/lib/model.rst b/docs/full/lib/model.rst index bd5dea4224..c78aade8e8 100755 --- a/docs/full/lib/model.rst +++ b/docs/full/lib/model.rst @@ -28,6 +28,11 @@ networks package :include-all-objects: :noindex: +| +.. automodapi:: lib.model.networks.insightface_resnet + :include-all-objects: + :noindex: + | .. automodapi:: lib.model.networks.simple_nets :include-all-objects: diff --git a/docs/sphinx_requirements.txt b/docs/sphinx_requirements.txt index c1699c198f..ccd1a3821c 100755 --- a/docs/sphinx_requirements.txt +++ b/docs/sphinx_requirements.txt @@ -2,5 +2,3 @@ # It is for documentation purposes only -r ../requirements/requirements_cpu.txt -r ../requirements/_requirements_dev.txt -sphinx_rtd_theme -sphinx-automodapi diff --git a/lib/align/alignments.py b/lib/align/alignments.py index c9f325f431..9029dd3812 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -6,6 +6,7 @@ import os import sys import typing as T +from dataclasses import dataclass, field from datetime import datetime import numpy as np @@ -14,7 +15,7 @@ from lib.utils import FaceswapError, get_module_objects from .thumbnails import Thumbnails -from .updater import (FileStructure, IdentityAndVideoMeta, LandmarkRename, Legacy, ListToNumpy, +from .updater import (FileStructure, IdentityAndVideoMeta, LandmarkRename, Legacy, NumpyToList, MaskCentering, VideoExtension) if T.TYPE_CHECKING: @@ -84,6 +85,90 @@ class PNGHeaderDict(T.TypedDict): source: PNGHeaderSourceDict +# Dataclass to slowly replace the above +@dataclass +class MaskAlignmentsFile: + """Dataclass for storing Masks in alignments files and PNG Headers""" + mask: bytes + """The zlib compressed UINT8 mask of shape (stored_size, stored_size)""" + affine_matrix: list[float] + """The affine matrix that takes the mask from stored space to frame space""" + interpolator: int + """The interpolator required to take the mask from stored space to frame space""" + stored_size: int + """The size the mask is stored at""" + stored_centering: CenteringType + """The (legacy, face, head) centering type of the mask""" + + +@dataclass +class PNGAlignments: + """Base Dataclass for storing a single faces' Alignment Information in Alignments files and PNG + Headers.""" + x: int + """The left most point of the bounding box""" + y: int + """The top most point of the bounding box""" + w: int + """The width of the bounding box""" + h: int + """The height of the bounding box""" + landmarks_xy: list[list[float]] + """The (x, y) landmark points of the face""" + mask: dict[str, MaskAlignmentsFile] + """The masks stored for the face""" + identity: dict[str, list[float]] + """The identity vectors stored for the face""" + + def __repr__(self) -> str: + """Pretty print for logging""" + params = {} + for k, v in self.__dict__.items(): + if k in ("landmarks_xy", "thumb"): + params[k] = f"{type(v)}[{len(v)}]" + continue + if k == "identity": + params[k] = repr({n: f"{type(i)}[{len(i)}]" for n, i in v.items()}) + continue + params[k] = v + s_params = ", ".join(f"{k}={v}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + + +@dataclass +class PNGSource: + """Dataclass for storing additional meta information in PNG headers.""" + alignments_version: float + """The alignments file version that created the alignments data""" + original_filename: str + """The original filename that this face was saved with""" + face_index: int + """The index of this face within the frame""" + source_filename: str + """The filename of the original frame the face was extracted from""" + source_is_video: bool + """``True`` if the face was extracted from a video. ``False`` if from an image""" + source_frame_dims: tuple[int, int] | None + """The (Height, Width) dimensions of the original frame the face was extracted from""" + + +@dataclass +class PNGHeader: + """Dataclass for storing all alignment and meta information in PNG Headers.""" + alignments: PNGAlignments + """The alignment information for the face""" + source: PNGSource + """The frame source information for the face""" + + +@dataclass +class AlignmentsFace(PNGAlignments): + """Dataclass that holds the same information as PNGAlignments as well as a thumbnail for a + single face""" + thumb: list[int] = field(default_factory=list) + """96px JPEG thumbnail of the aligned face image stored as a list""" + + class Alignments(): # pylint:disable=too-many-public-methods """The alignments file is a custom serialized ``.fsa`` file that holds information for each frame for a video or series of images. @@ -647,11 +732,11 @@ def _get_location(self, folder: str, filename: str) -> str: The full path to the alignments file """ logger.debug("Getting location: (folder: '%s', filename: '%s')", folder, filename) - noext_name, extension = os.path.splitext(filename) + no_ext_name, extension = os.path.splitext(filename) if extension[1:] == self._serializer.file_extension: logger.debug("Valid Alignments filename provided: '%s'", filename) else: - filename = f"{noext_name}.{self._serializer.file_extension}" + filename = f"{no_ext_name}.{self._serializer.file_extension}" logger.debug("File extension set from serializer: '%s'", self._serializer.file_extension) location = os.path.join(str(folder), filename) @@ -664,7 +749,7 @@ def update_legacy(self) -> None: format.""" updates = [updater.is_updated for updater in (FileStructure(self._alignments), LandmarkRename(self._alignments), - ListToNumpy(self._alignments), + NumpyToList(self._alignments), MaskCentering(self._alignments), IdentityAndVideoMeta(self._alignments))] if any(updates): diff --git a/lib/align/constants.py b/lib/align/constants.py index 88b7aa31f1..614c28062b 100644 --- a/lib/align/constants.py +++ b/lib/align/constants.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Constants that are required across faceswap's lib.align package """ +"""Constants that are required across faceswap's lib.align package""" from __future__ import annotations import typing as T @@ -11,38 +11,33 @@ CenteringType = T.Literal["face", "head", "legacy"] -EXTRACT_RATIOS: dict[CenteringType, float] = {"legacy": 0.375, "face": 0.5, "head": 0.625} -"""dict[Literal["legacy", "face", head"] float]: The amount of padding applied to each -centering type when generating aligned faces """ - class LandmarkType(Enum): - """ Enumeration for the landmark types that Faceswap supports """ + """Enumeration for the landmark types that Faceswap supports """ LM_2D_4 = 1 LM_2D_51 = 2 LM_2D_68 = 3 LM_3D_26 = 4 @classmethod - def from_shape(cls, shape: tuple[int, ...]) -> LandmarkType: - """ The landmark type for a given shape + def from_shape(cls, shape: tuple[int, int]) -> LandmarkType: + """The landmark type for a given shape Parameters ---------- - shape: tuple[int, ...] + shape The shape to get the landmark type for Returns ------- - Type[LandmarkType] - The enum for the given shape + The enum for the given shape Raises ------ ValueError If the requested shape is not valid """ - shapes: dict[tuple[int, ...], LandmarkType] = {(4, 2): cls.LM_2D_4, + shapes: dict[tuple[int, int], LandmarkType] = {(4, 2): cls.LM_2D_4, (51, 2): cls.LM_2D_51, (68, 2): cls.LM_2D_68, (26, 3): cls.LM_3D_26} @@ -51,6 +46,10 @@ def from_shape(cls, shape: tuple[int, ...]) -> LandmarkType: return shapes[shape] +EXTRACT_RATIOS: dict[CenteringType, float] = {"legacy": 0.375, "face": 0.5, "head": 0.625} +"""The amount of padding applied to each centering type when generating aligned faces""" + + MEAN_FACE: dict[LandmarkType, np.ndarray] = { LandmarkType.LM_2D_4: np.array( [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]), # Clockwise from TL @@ -95,22 +94,35 @@ def from_shape(cls, shape: tuple[int, ...]) -> LandmarkType: [-0.589441, -8.443925, 6.109526], # 46 mouth bottom R [0.0, -8.601736, 6.097667], # 45 mouth bottom C [0.589441, -8.443925, 6.109526]])} # 44 mouth bottom L -"""dict[:class:`~LandmarkType, np.ndarray]: 'Mean' landmark points for various landmark types. Used -for aligning faces """ +"""'Mean' landmark points for various landmark types. Used for aligning faces""" LANDMARK_PARTS: dict[LandmarkType, dict[str, tuple[int, int, bool]]] = { - LandmarkType.LM_2D_68: {"mouth_outer": (48, 60, True), - "mouth_inner": (60, 68, True), - "right_eyebrow": (17, 22, False), - "left_eyebrow": (22, 27, False), - "right_eye": (36, 42, True), - "left_eye": (42, 48, True), - "nose": (27, 36, False), - "jaw": (0, 17, False), - "chin": (8, 11, False)}, - LandmarkType.LM_2D_4: {"face": (0, 4, True)}} -"""dict[:class:`LandmarkType`, dict[str, tuple[int, int, bool]]: For each landmark type, stores -the (start index, end index, is polygon) information about each part of the face. """ + LandmarkType.LM_2D_68: {"mouth_outer": (48, 60, True), + "mouth_inner": (60, 68, True), + "right_eyebrow": (17, 22, False), + "left_eyebrow": (22, 27, False), + "right_eye": (36, 42, True), + "left_eye": (42, 48, True), + "nose": (27, 36, False), + "jaw": (0, 17, False), + "chin": (8, 11, False)}, + LandmarkType.LM_2D_4: {"face": (0, 4, True)} +} +"""For each landmark type, stores the (start index, end index, is polygon) information about each +part of the face.""" + +LANDMARK_MASK_PARTS: dict[LandmarkType, dict[str, list[tuple[int, int]]]] = { + LandmarkType.LM_2D_68: {"right_jaw": [(0, 9), (17, 18)], + "left_jaw": [(8, 17), (26, 27)], + "right_cheek": [(17, 20), (8, 9)], + "left_cheek": [(24, 27), (8, 9)], + "nose_ridge": [(19, 25), (8, 9)], + "right_eye": [(17, 22), (27, 28), (31, 36), (8, 9)], + "left_eye": [(22, 27), (27, 28), (31, 36), (8, 9)], + "nose": [(27, 31), (31, 36)]} +} +"""For each landmark type, stores the (start index, end index) information about each part of the +face that makes a face mask.""" __all__ = get_module_objects(__name__) diff --git a/lib/align/pose.py b/lib/align/pose.py index 7df5ccbdff..0553847e28 100644 --- a/lib/align/pose.py +++ b/lib/align/pose.py @@ -38,6 +38,9 @@ """The offsets required to shift the center point of a head in 3D space relative to legacy centering""" +_HEAD_CENTER_POINTS = np.array([[6., 0., -2.3], [0., 6., -2.3], [0., 0., 3.7]], dtype=np.float32) +"""Points approximately equidistant from the center of a skull in normalized 3D space""" + def get_camera_matrix(focal_length: int = 4) -> np.ndarray: """Obtain an estimate of a camera matrix in normalized space @@ -59,6 +62,18 @@ def get_camera_matrix(focal_length: int = 4) -> np.ndarray: return camera_matrix +def get_xyz_2d(rotation: npt.NDArray[np.float32], + translation: npt.NDArray[np.float32], + camera_matrix: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: + """projected (x, y) coordinates for each x, y, z point at a constant distance from the adjusted + center of the skull (0.5, 0.5) in 2D space.""" + return cv2.projectPoints(_HEAD_CENTER_POINTS, + rotation, + translation, + camera_matrix, + _DISTORTION_COEFFICIENTS)[0].squeeze(1).astype(np.float32) + + class PoseEstimate(): """Estimates pose from a generic 3D head model for the given 2D face landmarks. @@ -95,13 +110,7 @@ def xyz_2d(self) -> np.ndarray: """projected (x, y) coordinates for each x, y, z point at a constant distance from adjusted center of the skull (0.5, 0.5) in the 2D space.""" if self._xyz_2d is None: - xyz = cv2.projectPoints(np.array([[6., 0., -2.3], - [0., 6., -2.3], - [0., 0., 3.7]]).astype("float32"), - self._rotation, - self._translation, - self._camera_matrix, - _DISTORTION_COEFFICIENTS)[0].squeeze() + xyz = get_xyz_2d(self._rotation, self._translation, self._camera_matrix) self._xyz_2d = xyz - self._offset["head"] return self._xyz_2d @@ -274,6 +283,55 @@ def rodrigues(cls, vectors: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: retval = ident + np.sin(theta)[:, None] * k + (1 - np.cos(theta))[:, None] * (k @ k) return retval + @classmethod + def pitch(cls, vectors: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: + """Obtain the pitch, in degrees, for a batch of rotation matrices + + Parameters + ---------- + vectors + The (N, 3, 1) rotation vectors to convert + + Returns + ------- + The (N, ) pitch, in degrees + """ + rod = cls.rodrigues(vectors) + return np.degrees(np.arctan2(rod[:, 2, 1], rod[:, 2, 2])) + + @classmethod + def roll(cls, vectors: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: + """Obtain the roll, in degrees, for a batch of rotation matrices + + Parameters + ---------- + vectors + The (N, 3, 1) rotation vectors to convert + + Returns + ------- + The (N, ) rolls, in degrees + """ + rod = cls.rodrigues(vectors) + return np.degrees(np.arctan2(rod[:, 1, 0], rod[:, 0, 0])) + + @classmethod + def yaw(cls, vectors: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: + """Obtain the yaw, in degrees, for a batch of rotation matrices + + Parameters + ---------- + vectors + The (N, 3, 1) rotation vectors to convert + + Returns + ------- + The (N, ) yaw, in degrees + """ + rod = cls.rodrigues(vectors) + return np.degrees(np.arctan2(-rod[:, 2, 0], + np.sqrt(rod[:, 2, 1] ** 2 + rod[:, 2, 2] ** 2))) + @classmethod def project_points(cls, points: npt.NDArray[np.float32], diff --git a/lib/align/thumbnails.py b/lib/align/thumbnails.py index ccdfa1ed56..06439b50f1 100644 --- a/lib/align/thumbnails.py +++ b/lib/align/thumbnails.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Handles the generation of thumbnail jpgs for storing inside an alignments file/png header """ +"""Handles the generation of thumbnail JPGs for storing inside an alignments file/png header""" from __future__ import annotations import logging @@ -17,14 +17,14 @@ class Thumbnails(): - """ Thumbnail images stored in the alignments file. + """Thumbnail images stored in the alignments file. - The thumbnails are stored as low resolution (64px), low quality jpg in the alignments file + The thumbnails are stored as low resolution (64px), low quality JPG in the alignments file and are used for the Manual Alignments tool. Parameters ---------- - alignments: :class:'~lib.align.alignments.Alignments` + alignments The parent alignments class that these thumbs belong to """ def __init__(self, alignments: align.alignments.Alignments) -> None: @@ -35,8 +35,8 @@ def __init__(self, alignments: align.alignments.Alignments) -> None: @property def has_thumbnails(self) -> bool: - """ bool: ``True`` if all faces in the alignments file contain thumbnail images - otherwise ``False``. """ + """``True`` if all faces in the alignments file contain thumbnail images otherwise + ``False``.""" retval = all(np.any(T.cast(np.ndarray, face.get("thumb"))) for frame in self._alignments_dict.values() for face in frame["faces"]) @@ -44,19 +44,18 @@ def has_thumbnails(self) -> bool: return retval def get_thumbnail_by_index(self, frame_index: int, face_index: int) -> np.ndarray: - """ Obtain a jpg thumbnail from the given frame index for the given face index + """Obtain a JPG thumbnail from the given frame index for the given face index Parameters ---------- - frame_index: int + frame_index The frame index that contains the thumbnail - face_index: int + face_index The face index within the frame to retrieve the thumbnail for Returns ------- - :class:`numpy.ndarray` - The encoded jpg thumbnail + The encoded JPG thumbnail """ retval = self._alignments_dict[self._frame_list[frame_index]]["faces"][face_index]["thumb"] assert retval is not None @@ -66,16 +65,16 @@ def get_thumbnail_by_index(self, frame_index: int, face_index: int) -> np.ndarra return retval def add_thumbnail(self, frame: str, face_index: int, thumb: np.ndarray) -> None: - """ Add a thumbnail for the given face index for the given frame. + """Add a thumbnail for the given face index for the given frame. Parameters ---------- - frame: str + frame The name of the frame to add the thumbnail for - face_index: int + face_index The face index within the given frame to add the thumbnail for - thumb: :class:`numpy.ndarray` - The encoded jpg thumbnail at 64px to add to the alignments file + thumb + The encoded JPG thumbnail at 64px to add to the alignments file """ logger.debug("frame: %s, face_index: %s, thumb shape: %s thumb dtype: %s", frame, face_index, thumb.shape, thumb.dtype) diff --git a/lib/align/updater.py b/lib/align/updater.py index a877656613..9e98bdc8a6 100644 --- a/lib/align/updater.py +++ b/lib/align/updater.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Handles updating of an alignments file from an older version to the current version. """ +"""Handles updating of an alignments file from an older version to the current version.""" from __future__ import annotations import logging @@ -18,11 +18,11 @@ class _Updater(): - """ Base class for inheriting to test for and update of an alignments file property + """Base class for inheriting to test for and update of an alignments file property Parameters ---------- - alignments : :class:`~lib.align.alignments.Alignments` + alignments The alignments object that is being tested and updated """ def __init__(self, alignments: align.alignments.Alignments) -> None: @@ -35,16 +35,15 @@ def __init__(self, alignments: align.alignments.Alignments) -> None: @property def is_updated(self) -> bool: - """ bool : ``True`` if this updater has been run otherwise ``False`` """ + """``True`` if this updater has been run otherwise ``False``""" return self._needs_update def _test(self) -> bool: - """ Calls the child's :func:`test` method and logs output + """Calls the child's :func:`test` method and logs output Returns ------- - bool - ``True`` if the test condition is met otherwise ``False`` + ``True`` if the test condition is met otherwise ``False`` """ logger.debug("checking %s", self.__class__.__name__) retval = self.test() @@ -52,50 +51,47 @@ def _test(self) -> bool: return retval def test(self) -> bool: - """ Override to set the condition to test for. + """Override to set the condition to test for. Returns ------- - bool - ``True`` if the test condition is met otherwise ``False`` + ``True`` if the test condition is met otherwise ``False`` """ raise NotImplementedError() def _update(self) -> int: - """ Calls the child's :func:`update` method, logs output and sets the + """Calls the child's :func:`update` method, logs output and sets the :attr:`is_updated` flag Returns ------- - int - The number of items that were updated + The number of items that were updated """ retval = self.update() logger.debug("Updated %s: %s", self.__class__.__name__, retval) return retval def update(self) -> int: - """ Override to set the action to perform on the alignments object if the test has + """Override to set the action to perform on the alignments object if the test has passed Returns ------- - int - The number of items that were updated + The number of items that were updated """ raise NotImplementedError() class VideoExtension(_Updater): - """ Alignments files from video files used to have a dummy '.png' extension for each of the + """Alignments files from video files used to have a dummy '.png' extension for each of the keys. This has been changed to be file extension of the original input video (for better) identification of alignments files generated from video files Parameters ---------- - alignments : :class:`~lib.align.alignments.Alignments` + alignments The alignments object that is being tested and updated - video_filename : str + video_filename The video filename that holds these alignments """ def __init__(self, alignments: align.alignments.Alignments, video_filename: str) -> None: @@ -103,13 +99,12 @@ def __init__(self, alignments: align.alignments.Alignments, video_filename: str) super().__init__(alignments) def test(self) -> bool: - """ Requires update if the extension of the key in the alignment file is not the same + """Requires update if the extension of the key in the alignment file is not the same as for the input video file Returns ------- - bool - ``True`` if the key extensions need updating otherwise ``False`` + ``True`` if the key extensions need updating otherwise ``False`` """ # Note: Don't check on alignments file version. It's possible that the file gets updated to # a newer version before this check is run @@ -130,12 +125,12 @@ def test(self) -> bool: return True def update(self) -> int: - """ Update alignments files that have been extracted from videos to have the key end in the + """Update alignments files that have been extracted from videos to have the key end in the video file extension rather than ',png' (the old way) Parameters ---------- - video_filename : str + video_filename The filename of the video file that created these alignments """ updated = 0 @@ -157,47 +152,44 @@ def update(self) -> int: class FileStructure(_Updater): - """ Alignments were structured: {frame_name: }. We need to be able to store + """Alignments were structured: {frame_name: }. We need to be able to store information at the frame level, so new structure is: {frame_name: {faces: }} """ def test(self) -> bool: - """ Test whether the alignments file is laid out in the old structure of + """Test whether the alignments file is laid out in the old structure of `{frame_name: [faces]}` Returns ------- - bool - ``True`` if the file has legacy structure otherwise ``False`` + ``True`` if the file has legacy structure otherwise ``False`` """ return any(isinstance(val, list) for val in self._alignments.data.values()) def update(self) -> int: - """ Update legacy alignments files from the format `{frame_name: [faces}` to the + """Update legacy alignments files from the format `{frame_name: [faces}` to the format `{frame_name: {faces: [faces]}`. Returns ------- - int - The number of items that were updated + The number of items that were updated """ updated = 0 for key, val in self._alignments.data.items(): if not isinstance(val, list): continue - self._alignments.data[key] = {"faces": val} + self._alignments.data[key] = T.cast("align.alignments.AlignmentDict", {"faces": val}) updated += 1 return updated class LandmarkRename(_Updater): - """ Landmarks renamed from landmarksXY to landmarks_xy for PEP compliance """ + """Landmarks renamed from landmarksXY to landmarks_xy for PEP compliance """ def test(self) -> bool: - """ check for legacy landmarksXY keys. + """check for legacy landmarksXY keys. Returns ------- - bool - ``True`` if the alignments file contains legacy `landmarksXY` keys otherwise ``False`` + ``True`` if the alignments file contains legacy `landmarksXY` keys otherwise ``False`` """ return (any(key == "landmarksXY" for val in self._alignments.data.values() @@ -205,12 +197,11 @@ def test(self) -> bool: for key in alignment)) def update(self) -> int: - """ Update legacy `landmarksXY` keys to PEP compliant `landmarks_xy` keys. + """Update legacy `landmarksXY` keys to PEP compliant `landmarks_xy` keys. Returns ------- - int - The number of landmarks keys that were changed + The number of landmarks keys that were changed """ update_count = 0 for val in self._alignments.data.values(): @@ -221,59 +212,61 @@ def update(self) -> int: return update_count -class ListToNumpy(_Updater): - """ Landmarks stored as list instead of numpy array """ +class NumpyToList(_Updater): + """Landmarks stored as a numpy array instead of a list""" def test(self) -> bool: - """ check for legacy landmarks stored as `list` rather than :class:`numpy.ndarray`. + """check for legacy landmarks and thumbnails stored as :class:`numpy.ndarray` rather than + list Returns ------- - bool - ``True`` if not all landmarks are :class:`numpy.ndarray` otherwise ``False`` + ``True`` if any landmarks or thumbnails are a numpy array otherwise ``False`` """ - return not all(isinstance(face["landmarks_xy"], np.ndarray) - for val in self._alignments.data.values() - for face in val["faces"]) + return any(isinstance(face["landmarks_xy"], np.ndarray) + or isinstance(face["thumb"], np.ndarray) + for val in self._alignments.data.values() + for face in val["faces"]) def update(self) -> int: - """ Update landmarks stored as `list` to :class:`numpy.ndarray`. + """Update landmarks and thumbnails stored as :class:`numpy.ndarray` to `list`. Returns ------- - int - The number of landmarks keys that were changed + The number of faces that were changed """ update_count = 0 for val in self._alignments.data.values(): for alignment in val["faces"]: - test = alignment["landmarks_xy"] - if not isinstance(test, np.ndarray): - alignment["landmarks_xy"] = np.array(test, dtype="float32") + test1 = alignment["landmarks_xy"] + test2 = alignment["thumb"] + if isinstance(test1, np.ndarray) or isinstance(test2, np.ndarray): update_count += 1 + if isinstance(test1, np.ndarray): + alignment["landmarks_xy"] = test1.tolist() + if isinstance(test2, np.ndarray): + alignment["thumb"] = test2.tolist() return update_count class MaskCentering(_Updater): - """ Masks not containing the stored_centering parameters. Prior to this implementation all + """Masks not containing the stored_centering parameters. Prior to this implementation all masks were stored with face centering """ def test(self) -> bool: - """ Mask centering was introduced in alignments version 2.2 + """Mask centering was introduced in alignments version 2.2 Returns ------- - bool - ``True`` mask centering requires updating otherwise ``False`` + ``True`` mask centering requires updating otherwise ``False`` """ return self._alignments.version < 2.2 def update(self) -> int: - """ Add the mask key to the alignment file and update the centering of existing masks + """Add the mask key to the alignment file and update the centering of existing masks Returns ------- - int - The number of masks that were updated + The number of masks that were updated """ update_count = 0 for val in self._alignments.data.values(): @@ -287,27 +280,24 @@ def update(self) -> int: class IdentityAndVideoMeta(_Updater): - """ Prior to version 2.3 the identity key did not exist and the video_meta key was not + """Prior to version 2.3 the identity key did not exist and the video_meta key was not compulsory. These should now both always appear, but do not need to be populated. """ - def test(self) -> bool: - """ Identity Key was introduced in alignments version 2.3 + """Identity Key was introduced in alignments version 2.3 Returns ------- - bool - ``True`` identity key needs inserting otherwise ``False`` + ``True`` identity key needs inserting otherwise ``False`` """ return self._alignments.version < 2.3 # Identity information was not previously stored in the alignments file. def update(self) -> int: - """ Add the video_meta and identity keys to the alignment file and leave empty + """Add the video_meta and identity keys to the alignment file and leave empty Returns ------- - int - The number of keys inserted + The number of keys inserted """ update_count = 0 for val in self._alignments.data.values(): @@ -323,13 +313,13 @@ def update(self) -> int: return update_count -class Legacy(): - """ Legacy alignments properties that are no longer used, but are still required for backwards +class Legacy(): # TODO remove this as it is now ancient and likely to lead to issues + """Legacy alignments properties that are no longer used, but are still required for backwards compatibility/upgrading reasons. Parameters ---------- - alignments : :class:`~lib.align.alignments.Alignments` + alignments The alignments object that requires these legacy properties """ def __init__(self, alignments: align.alignments.Alignments) -> None: @@ -339,7 +329,7 @@ def __init__(self, alignments: align.alignments.Alignments) -> None: @property def hashes_to_frame(self) -> dict[str, dict[str, int]]: - """ dict: The SHA1 hash of the face mapped to the frame(s) and face index within the frame + """The SHA1 hash of the face mapped to the frame(s) and face index within the frame that the hash corresponds to. The structure of the dictionary is: {**SHA1_hash** (`str`): {**filename** (`str`): **face_index** (`int`)}}. @@ -362,7 +352,7 @@ def hashes_to_frame(self) -> dict[str, dict[str, int]]: @property def hashes_to_alignment(self) -> dict[str, align.alignments.AlignmentFileDict]: - """ dict: The SHA1 hash of the face mapped to the alignment for the face that the hash + """The SHA1 hash of the face mapped to the alignment for the face that the hash corresponds to. The structure of the dictionary is: Notes diff --git a/lib/gpu_stats/rocm.py b/lib/gpu_stats/rocm.py index a6e3c2250a..317359c94d 100644 --- a/lib/gpu_stats/rocm.py +++ b/lib/gpu_stats/rocm.py @@ -323,7 +323,7 @@ def _get_handles(self) -> list: The list of all discovered GPUs """ if self._is_wsl: - handles = list(range(self._device_count)) + handles = list(str(i) for i in range(self._device_count)) else: handles = self._sysfs_paths self._log("debug", f"sysfs GPU Handles found: {handles}") diff --git a/lib/logger.py b/lib/logger.py index d8d4703bb3..649896e2d8 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -189,6 +189,53 @@ class FaceswapFormatter(logging.Formatter): Rewrites some upstream warning messages to debug level to avoid spamming the console. """ + @classmethod + def _lower_external(cls, record: logging.LogRecord) -> logging.LogRecord: + """Some external libs log at a higher level than we would really like, so lower their + log level. + + Specifically: Matplotlib font properties, pytorch compilation gemm warnings + + Parameters + ---------- + record + The log record to check for rewriting + + Returns + ---------- + The log rewritten or untouched record + """ + if (record.levelno == logging.INFO and record.funcName == "__init__" + and record.module == "font_manager"): + # Matplotlib font manager + record.levelno = 10 + record.levelname = "DEBUG" + return record + + @classmethod + def _format_warnings(cls, record: logging.LogRecord) -> logging.LogRecord: + """Warnings redirected from the warnings module will have new lines inserted. We do not + want this for logging + + Parameters + ---------- + record + The log record to check for rewriting + + Returns + ---------- + The log rewritten or untouched record + """ + if record.levelno != logging.WARNING or record.name != "py.warnings": + return record + + msg = record.getMessage() + # Strip new lines and trailing superfluous information from captured warnings + msg = msg.replace("\n", " ").strip().rstrip("warnings.warn(") + record.msg = msg + record.args = () + return record + def format(self, record: logging.LogRecord) -> str: """Strip new lines from log records and rewrite certain warning messages to debug level. @@ -201,8 +248,9 @@ def format(self, record: logging.LogRecord) -> str: ------- The formatted log message """ - record.message = record.getMessage() record = self._lower_external(record) + record = self._format_warnings(record) + record.message = record.getMessage() # strip newlines if record.levelno < 30 and ("\n" in record.message or "\r" in record.message): record.message = record.message.replace("\n", "\\n").replace("\r", "\\r") @@ -225,29 +273,33 @@ def format(self, record: logging.LogRecord) -> str: msg = msg + self.formatStack(record.stack_info) return msg - @classmethod - def _lower_external(cls, record: logging.LogRecord) -> logging.LogRecord: - """Some external libs log at a higher level than we would really like, so lower their - log level. - Specifically: Matplotlib font properties +class TorchWarningsFilter: + """Filter compilation warnings from Torch out of the console, but allow them to exist in the + log""" + def filter(self, record: logging.LogRecord) -> bool: + """ Filter specific Torch compile warnings from the console Parameters ---------- record - The log record to check for rewriting + The incoming log record to check for filtering Returns - ---------- - The log rewritten or untouched record + ------- + ``True`` if the record should be displayed """ - if (record.levelno == 20 and record.funcName == "__init__" - and record.module == "font_manager"): - # Matplotlib font manager - record.levelno = 10 - record.levelname = "DEBUG" + if record.levelno != logging.WARNING: + return True - return record + if record.name == "torch._inductor.utils" and record.funcName == "is_big_gpu": + # PyTorch: Not enough SMs to use max_autotune_gemm mode + return False + + if record.name != "py.warnings": + return True + + return "/torch/_inductor" not in record.getMessage() class RollingBuffer(collections.deque): @@ -336,14 +388,29 @@ def log_setup(loglevel, log_file: str, command: str, is_gui: bool = False) -> No datefmt="%m/%d/%Y %H:%M:%S") s_handler = _stream_handler(numeric_loglevel, is_gui) f_handler = _file_handler(numeric_loglevel, log_file, log_format, command) + s_handler.addFilter(TorchWarningsFilter()) rootlogger.addHandler(f_handler) rootlogger.addHandler(s_handler) - if command != "setup": - c_handler = _crash_handler(log_format) - rootlogger.addHandler(c_handler) - logging.info("Log level set to: %s", loglevel.upper()) + if command == "setup": + return + + c_handler = _crash_handler(log_format) + rootlogger.addHandler(c_handler) + logging.info("Log level set to: %s", loglevel.upper()) + + try: + import torch # noqa[F401] # pylint:disable=unused-import,import-outside-toplevel + except ImportError: + return + + # Elevate torch loggers to use our loggers + for name in rootlogger.manager.loggerDict: + if name.startswith("torch"): + logger = logging.getLogger(name) + logger.handlers.clear() + logger.propagate = True def _file_handler(loglevel, @@ -517,13 +584,16 @@ def format_array(array: np.ndarray) -> str: except ImportError: return repr(array) - if np.prod(array.shape) <= 10: + if array.dtype == "object": retval = "np.array(" - if array.dtype == "object": - retval += f"{[x.tolist() for x in array]}" - else: - retval += str(array.tolist()) + for sub in array: + retval += f"{format_array(sub)}, " + if array.size: + retval = retval[:-2] return f"{retval}, dtype='{array.dtype}')" + + if np.prod(array.shape) <= 10: + return f"np.array({str(array.tolist())}, dtype='{array.dtype}')" return f"" diff --git a/lib/model/networks/insightface_resnet.py b/lib/model/networks/insightface_resnet.py new file mode 100644 index 0000000000..c149c9ab8d --- /dev/null +++ b/lib/model/networks/insightface_resnet.py @@ -0,0 +1,431 @@ +"""InsightFace ResNet (IR) and InsightFace ResNet Squeeze + Excite (IRSE) for inference + +From: https://github.com/deepinsight/insightface and https://github.com/HuangYG123/CurricularFace + +Released under MIT License +""" +import typing as T + +import torch +from torch import nn + +from lib.utils import get_module_objects + + +class SEModule(nn.Module): + """Squeeze and Excite Block for IRNet + + Parameters + ---------- + in_channels + The number of input channels + reduction + The reduction factor for squeeze and excite + """ + def __init__(self, in_channels: int, reduction: int) -> None: + super().__init__() + out_channels = in_channels // reduction + self.avg_pool = nn.AdaptiveAvgPool2d(1) + self.fc1 = nn.Conv2d(in_channels, out_channels, 1, padding=0, bias=False) + self.relu = nn.ReLU(inplace=True) + self.fc2 = nn.Conv2d(out_channels, in_channels, 1, padding=0, bias=False) + self.sigmoid = nn.Sigmoid() + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + """Forward pass through the IRNet Squeeze and Excite Block""" + x = self.avg_pool(inputs) + x = self.fc1(x) + x = self.relu(x) + x = self.fc2(x) + x = self.sigmoid(x) + return inputs * x + + +class BasicBlockIR(nn.Module): + """A Basic Block for InsightFace ResNet + + Parameters + ---------- + in_channels + The number of input channels to the layer + depth + The depth of the layer + stride + The Convolution stride + use_se + ``True`` to add squeeze and excite layer + """ + def __init__(self, in_channels: int, depth: int, stride: int, use_se: bool) -> None: + super().__init__() + if in_channels == depth: + self.shortcut_layer: nn.MaxPool2d | nn.Sequential = nn.MaxPool2d(1, stride) + else: + self.shortcut_layer = nn.Sequential( + nn.Conv2d(in_channels, depth, 1, stride=stride, bias=False), + nn.BatchNorm2d(depth)) + res_layer = [ + nn.BatchNorm2d(in_channels), + nn.Conv2d(in_channels, depth, 3, stride=1, padding=1, bias=False), + nn.BatchNorm2d(depth), + nn.PReLU(depth), + nn.Conv2d(depth, depth, 3, stride=stride, padding=1, bias=False), + nn.BatchNorm2d(depth)] + if use_se: + res_layer.append(SEModule(depth, 16)) + self.res_layer = nn.Sequential(*res_layer) + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + """Forward pass through the IRNet basic block + + Parameters + ---------- + inputs + The input to the IRNet Block + + Returns + ------- + The output from the IRNet Block + """ + res = self.res_layer(inputs) + shortcut = self.shortcut_layer(inputs) + return res + shortcut + + +class BottleneckIR(nn.Module): + """Bottleneck for IRNet + + Parameters + ---------- + in_channels + The number of input channels to the layer + depth + The depth of the layer + stride + The Convolution stride + use_se + ``True`` to add squeeze and excite layer + """ + def __init__(self, in_channels: int, depth: int, stride: int, use_se: bool) -> None: + super().__init__() + super().__init__() + shrink_channel = depth // 4 + if in_channels == depth: + self.shortcut_layer: nn.MaxPool2d | nn.Sequential = nn.MaxPool2d(1, stride) + else: + self.shortcut_layer = nn.Sequential( + nn.Conv2d(in_channels, depth, 1, stride=stride, bias=False), + nn.BatchNorm2d(depth)) + res_layer = [nn.BatchNorm2d(in_channels), + nn.Conv2d(in_channels, shrink_channel, 1, stride=1, padding=0, bias=False), + nn.BatchNorm2d(shrink_channel), + nn.PReLU(shrink_channel), + nn.Conv2d(shrink_channel, shrink_channel, 3, stride=1, padding=1, bias=False), + nn.BatchNorm2d(shrink_channel), + nn.PReLU(shrink_channel), + nn.Conv2d(shrink_channel, depth, 1, stride=stride, padding=0, bias=False), + nn.BatchNorm2d(depth)] + if use_se: + res_layer.append(SEModule(depth, 16)) + self.res_layer = nn.Sequential(*res_layer) + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + """Forward pass through the IRNet Bottleneck + + Parameters + ---------- + inputs + The input to the IRNet Bottleneck + + Returns + ------- + The output from the IRNet Bottleneck + """ + res = self.res_layer(inputs) + shortcut = self.shortcut_layer(inputs) + return res + shortcut + + +class Flatten(nn.Module): + """Flatten layer for IRNet """ + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + """Flatten the inbound layer + + Parameters + ---------- + inputs + The input layer to be flattened + + Returns + ------- + The flattened input layer + """ + return inputs.reshape(inputs.size(0), -1) + + +class IRNet(nn.Module): + """Implementation if InsightFace ResNet with Squeeze + Excite support + + Parameters + ---------- + input_size + The input size to the model. Must be 112 or 224 + block_filters + The number of in_channels to each block layer for each pass + block_recursions + The number of recursions within each block + num_features + The number of num_features to output. Default: 512 + use_se + ``True`` to use Squeeze and Excite. ``False`` to use standard IR ResNet. Default: ``False`` + use_bottleneck + ``True`` to use the Bottleneck block. ``False`` to use the Basic block. Default: ``False`` + """ + def __init__(self, + input_size: T.Literal[112, 224], + block_filters: tuple[int, int, int, int], + block_recursions: tuple[int, int, int, int], + num_features: int = 512, + use_se: bool = False, + use_bottleneck: bool = False) -> None: + super().__init__() + self.input_layer = nn.Sequential(nn.Conv2d(3, 64, 3, stride=1, padding=1, bias=False), + nn.BatchNorm2d(64), + nn.PReLU(64)) + self.body = self._get_blocks(block_filters, block_recursions, use_se, use_bottleneck) + self.output_layer = self._get_output_layer(input_size, num_features) + + @classmethod + def _get_blocks(cls, + block_filters: tuple[int, int, int, int], + block_recursions: tuple[int, int, int, int], + use_se: bool, + use_bottleneck: bool) -> nn.Sequential: + """Obtain the IRNet Blocks for the given configuration + + Parameters + ---------- + block_filters + The number of in_channels to each block layer for each pass + block_recursions + The number of recursions within each block + use_se + ``True`` to build IRNetSE ``False`` to build IRNet + use_bottleneck + ``True`` to use the Bottleneck block. ``False`` to use the basic block + + Returns + ------- + The configured blocks + """ + depth = 64 + block = BottleneckIR if use_bottleneck else BasicBlockIR + layers = [] + for in_channels, units in zip(block_filters, block_recursions): + layers.append(block(in_channels, depth, 2, use_se)) + for _ in range(units - 1): + layers.append(block(depth, depth, 1, use_se)) + depth *= 2 + return nn.Sequential(*layers) + + @classmethod + def _get_output_layer(cls, input_size: T.Literal[112, 224], num_features: int + ) -> nn.Sequential: + """Obtain the output layer of the model, based on input size and number of layers + + Parameters + ---------- + input_size + The input size to the model. Must be 112 or 224 + num_features + The number of num_features to output + + Returns + ------- + The output layer of the model + """ + fc_scale = 7 * 7 if input_size == 112 else 14 * 14 + return nn.Sequential(nn.BatchNorm2d(num_features), + nn.Dropout(0.4), + Flatten(), + nn.Linear(num_features * fc_scale, 512), + nn.BatchNorm1d(512, affine=False)) + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + """Forward pass through IRNet + + Parameters + ---------- + inputs + The input to IRNet + + Returns + ------- + The output from IRNet + """ + x = self.input_layer(inputs) + x = self.body(x) + x = self.output_layer(x) + return x + + +def ir_18(input_size: T.Literal[112, 224]): + """Obtain an IRNet-18 model + + Parameters + ---------- + input_size + The input size to the model + """ + return IRNet(input_size, + block_filters=(64, 64, 128, 256), + block_recursions=(2, 2, 2, 2), + num_features=512, + use_se=False, + use_bottleneck=False) + + +def ir_34(input_size: T.Literal[112, 224]) -> IRNet: + """Obtain an IRNet-34 model + + Parameters + ---------- + input_size + The input size to the model + """ + return IRNet(input_size, + block_filters=(64, 64, 128, 256), + block_recursions=(3, 4, 6, 3), + num_features=512, + use_se=False, + use_bottleneck=False) + + +def ir_50(input_size: T.Literal[112, 224]) -> IRNet: + """Obtain an IRNet-50 model + + Parameters + ---------- + input_size + The input size to the model + """ + return IRNet(input_size, + block_filters=(64, 64, 128, 256), + block_recursions=(3, 4, 14, 3), + num_features=512, + use_se=False, + use_bottleneck=False) + + +def ir_101(input_size: T.Literal[112, 224]) -> IRNet: + """Obtain an IRNet-101 model + + Parameters + ---------- + input_size + The input size to the model + """ + return IRNet(input_size, + block_filters=(64, 64, 128, 256), + block_recursions=(3, 13, 30, 3), + num_features=512, + use_se=False, + use_bottleneck=False) + + +def ir_152(input_size: T.Literal[112, 224]) -> IRNet: + """Obtain an IRNet-152 model + + Parameters + ---------- + input_size + The input size to the model + """ + return IRNet(input_size, + block_filters=(64, 256, 512, 1024), + block_recursions=(3, 8, 36, 3), + num_features=2048, + use_se=False, + use_bottleneck=True) + + +def ir_200(input_size: T.Literal[112, 224]) -> IRNet: + """Obtain an IRNet-200 model + + Parameters + ---------- + input_size + The input size to the model + """ + return IRNet(input_size, + block_filters=(64, 256, 512, 1024), + block_recursions=(3, 24, 36, 3), + num_features=2048, + use_se=False, + use_bottleneck=True) + + +def ir_se_50(input_size: T.Literal[112, 224]) -> IRNet: + """Obtain an IRNetSE50 model + + Parameters + ---------- + input_size + The input size to the model + """ + return IRNet(input_size, + block_filters=(64, 64, 128, 256), + block_recursions=(3, 4, 14, 3), + num_features=512, + use_se=True, + use_bottleneck=False) + + +def ir_se_101(input_size: T.Literal[112, 224]) -> IRNet: + """Obtain an IRNetSE101 model + + Parameters + ---------- + input_size + The input size to the model + """ + return IRNet(input_size, + block_filters=(64, 64, 128, 256), + block_recursions=(3, 13, 30, 3), + num_features=512, + use_se=True, + use_bottleneck=False) + + +def ir_se_152(input_size: T.Literal[112, 224]) -> IRNet: + """Obtain an IRNetSE152 model + + Parameters + ---------- + input_size + The input size to the model + """ + return IRNet(input_size, + block_filters=(64, 256, 512, 1024), + block_recursions=(3, 8, 36, 3), + num_features=2048, + use_se=True, + use_bottleneck=True) + + +def ir_se_200(input_size: T.Literal[112, 224]) -> IRNet: + """Obtain an IRNetSE200 model + + Parameters + ---------- + input_size + The input size to the model + """ + return IRNet(input_size, + block_filters=(64, 256, 512, 1024), + block_recursions=(3, 24, 36, 3), + num_features=2048, + use_se=True, + use_bottleneck=True) + + +__all__ = get_module_objects(__name__) diff --git a/lib/system/system.py b/lib/system/system.py index 9469fc1e68..c2715fab59 100644 --- a/lib/system/system.py +++ b/lib/system/system.py @@ -25,10 +25,10 @@ VALID_PYTHON = ((3, 11), (3, 13)) """ tuple[tuple[int, int], tuple[int, int]] : The minimum and maximum versions of Python that can run Faceswap """ -VALID_TORCH = ((2, 3), (2, 9)) +VALID_TORCH = ((2, 3), (2, 10)) """ tuple[tuple[int, int], tuple[int, int]] : The minimum and maximum versions of Torch that can run Faceswap """ -VALID_KERAS = ((3, 12), (3, 12)) +VALID_KERAS = ((3, 13), (3, 13)) """ tuple[tuple[int, int], tuple[int, int]] : The minimum and maximum versions of Keras that can run Faceswap """ diff --git a/plugins/train/trainer/original.py b/plugins/train/trainer/original.py index 0b5164eef2..cbd7412ae0 100644 --- a/plugins/train/trainer/original.py +++ b/plugins/train/trainer/original.py @@ -66,7 +66,7 @@ def _backwards_and_apply(self, all_loss: torch.Tensor) -> None: gradients = [v.value.grad for v in trainable_weights] # Update weights - with torch.no_grad(): + with torch.inference_mode(): self.model.model.optimizer.apply(gradients, trainable_weights) def train_batch(self, diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index b077480b07..9daed21789 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -1,20 +1,20 @@ -packaging>=25.0 +packaging>=26.0 tqdm>=4.67 -psutil>=7.1.0 +psutil>=7.2.0 numexpr>=2.14.0 -numpy>=2.2.0 -opencv-python>=4.12.0 -pillow>=12.0.0 -scikit-learn>=1.7.2 +numpy>=2.4.0 +opencv-python>=4.13.0 +pillow>=12.1.0 +scikit-learn>=1.8.0 fastcluster>=1.3.0 -matplotlib>=3.10.7 +matplotlib>=3.10.0 imageio>=2.37.0 # ffmpeg binary >=0.6.0 breaks convert. # TODO fix convert to use latest binary imageio-ffmpeg>=0.4.9,<0.6.0 -ffmpy>=0.6.0 +ffmpy>=1.0.0 pywin32>=305 ; sys_platform == "win32" #torchvision>=0.18.0,<0.25.0 torchvision>=0.18.0,<0.25.0 tensorboard>=2.20.0 -keras>=3.12.0,<3.13.0 +keras>=3.13.0,<3.14.0 diff --git a/requirements/_requirements_dev.txt b/requirements/_requirements_dev.txt index 3e2a558c9c..0cd6fc6ee5 100644 --- a/requirements/_requirements_dev.txt +++ b/requirements/_requirements_dev.txt @@ -9,3 +9,5 @@ types-setuptools types-PyYAML types-psutil types-tensorflow +sphinx_rtd_theme +sphinx-automodapi diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index 78ccce3ba2..414efb7f9b 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -403,7 +403,7 @@ def _get_output_file(self) -> str: Full path to an output json file """ in_file = self._alignments.file - base_filename = f"{os.path.splitext(in_file)[0]}_export" + base_filename = f"{os.path.splitext(in_file)[0]}" out_file = f"{base_filename}.json" idx = 1 while True: From 2ad87b1682d1037cd61ff5e050df7b79fc1ec963 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:32:52 +0000 Subject: [PATCH 942/981] Bugfix: Fix mixed precision --- plugins/train/model/_base/settings.py | 269 ++++++++------------------ plugins/train/trainer/original.py | 26 ++- 2 files changed, 96 insertions(+), 199 deletions(-) diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 1875a1c8bf..4c0cb18393 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -37,17 +37,17 @@ @dataclass class LossClass: - """ Typing class for holding loss functions. + """Typing class for holding loss functions. Parameters ---------- - function: Callable + function The function that takes in the true/predicted images and returns the loss - init: bool, Optional + init Whether the loss object ``True`` needs to be initialized (i.e. it's a class) or ``False`` it does not require initialization (i.e. it's a function). Default ``True`` - kwargs: dict + kwargs Any keyword arguments to supply to the loss function at initialization. """ function: Callable[[KerasTensor, KerasTensor], @@ -57,11 +57,11 @@ class LossClass: class Loss(): - """ Holds loss names and functions for an Autoencoder. + """Holds loss names and functions for an Autoencoder. Parameters ---------- - color_order: str + color_order Color order of the model. One of `"BGR"` or `"RGB"` """ def __init__(self, color_order: T.Literal["bgr", "rgb"]) -> None: @@ -69,8 +69,8 @@ def __init__(self, color_order: T.Literal["bgr", "rgb"]) -> None: self._mask_channels = self._get_mask_channels() self._inputs: list[keras.layers.Layer] = [] self._names: list[str] = [] - self._funcs: dict[str, losses.LossWrapper | T.Callable[[KerasTensor, KerasTensor], - KerasTensor]] = {} + self._functions: dict[str, losses.LossWrapper | T.Callable[[KerasTensor, KerasTensor], + KerasTensor]] = {} self._loss_dict = {"ffl": LossClass(function=losses.FocalFrequencyLoss), "flip": LossClass(function=losses.LDRFLIPLoss, @@ -96,37 +96,36 @@ def __init__(self, color_order: T.Literal["bgr", "rgb"]) -> None: @property def names(self) -> list[str]: - """ list: The list of loss names for the model. """ + """The loss function names""" return self._names @property def functions(self) -> dict[str, losses.LossWrapper | T.Callable[[KerasTensor, KerasTensor], KerasTensor]]: - """ dict[str, :class:`~lib.model.losses.LossWrapper` | | Callable[[KerasTensor, - KerasTensor], KerasTensor]]]: The loss functions that apply to each model output. """ - return self._funcs + """The loss functions that apply to each model output.""" + return self._functions @property def _mask_inputs(self) -> list | None: - """ list: The list of input tensors to the model that contain the mask. Returns ``None`` - if there is no mask input to the model. """ + """The list of input tensors to the model that contain the mask. Returns ``None`` if there + is no mask input to the model.""" mask_inputs = [inp for inp in self._inputs if inp.name.startswith("mask")] return None if not mask_inputs else mask_inputs @property def _mask_shapes(self) -> list[tuple] | None: - """ list: The list of shape tuples for the mask input tensors for the model. Returns - ``None`` if there is no mask input. """ + """The list of shape tuples for the mask input tensors for the model. Returns ``None`` if + there is no mask input.""" if self._mask_inputs is None: return None return [mask_input.shape for mask_input in self._mask_inputs] def configure(self, model: keras.models.Model) -> None: - """ Configure the loss functions for the given inputs and outputs. + """Configure the loss functions for the given inputs and outputs. Parameters ---------- - model: :class:`keras.models.Model` + model The model that is to be trained """ self._inputs = model.inputs @@ -135,7 +134,7 @@ def configure(self, model: keras.models.Model) -> None: self._names.insert(0, "total") def _set_loss_names(self, outputs: list[KerasTensor]) -> None: - """ Name the losses based on model output. + """Name the losses based on model output. This is used for correct naming in the state file, for display purposes only. @@ -143,8 +142,7 @@ def _set_loss_names(self, outputs: list[KerasTensor]) -> None: Parameters ---------- - outputs: list[:class:`keras.KerasTensor`] - A list of output tensors from the model plugin + A list of output tensors from the model plugin """ # TODO Use output names if/when these are fixed upstream split_outputs = [outputs[:len(outputs) // 2], outputs[len(outputs) // 2:]] @@ -160,17 +158,16 @@ def _set_loss_names(self, outputs: list[KerasTensor]) -> None: logger.debug(self._names) def _get_function(self, name: str) -> Callable[[KerasTensor, KerasTensor], KerasTensor]: - """ Obtain the requested Loss function + """Obtain the requested Loss function Parameters ---------- - name: str + name The name of the loss function from the training configuration file Returns ------- - Keras Loss Function - The requested loss function + The requested loss function """ func = self._loss_dict[name] retval = func.function(**func.kwargs) if func.init else func.function # type:ignore @@ -178,24 +175,24 @@ def _get_function(self, name: str) -> Callable[[KerasTensor, KerasTensor], Keras return retval def _set_loss_functions(self, output_names: list[str]) -> None: - """ Set the loss functions and their associated weights. + """Set the loss functions and their associated weights. Adds the loss functions to the :attr:`functions` dictionary. Parameters ---------- - output_names: list[str] + output_names The output names from the model """ - loss_funcs = [cfg_loss.loss_function(), - cfg_loss.loss_function_2(), - cfg_loss.loss_function_3(), - cfg_loss.loss_function_4()] + loss_functions = [cfg_loss.loss_function(), + cfg_loss.loss_function_2(), + cfg_loss.loss_function_3(), + cfg_loss.loss_function_4()] loss_amount = [100, cfg_loss.loss_weight_2(), cfg_loss.loss_weight_3(), cfg_loss.loss_weight_4()] - face_losses = [(name, weight) for name, weight in zip(loss_funcs, loss_amount) + face_losses = [(name, weight) for name, weight in zip(loss_functions, loss_amount) if name != "none" and weight > 0] for name, output_name in zip(self._names, output_names): @@ -207,23 +204,23 @@ def _set_loss_functions(self, output_names: list[str]) -> None: self._add_face_loss_function(loss_func, func, weight / 100.) logger.debug("%s: (output_name: '%s', function: %s)", name, output_name, loss_func) - self._funcs[name] = loss_func - logger.debug("functions: %s", self._funcs) + self._functions[name] = loss_func + logger.debug("functions: %s", self._functions) def _add_face_loss_function(self, loss_wrapper: losses.LossWrapper, loss_function: str, weight: float) -> None: - """ Add the given face loss function at the given weight and apply any mouth and eye + """Add the given face loss function at the given weight and apply any mouth and eye multipliers Parameters ---------- - loss_wrapper: :class:`lib.model.losses.LossWrapper` + loss_wrapper The wrapper loss function that holds the face losses - loss_function: str + loss_function The loss function to add to the loss wrapper - weight: float + weight The amount of weight to apply to the given loss function """ logger.debug("Adding loss function: %s, weight: %s", loss_function, weight) @@ -245,13 +242,12 @@ def _add_face_loss_function(self, channel_idx += 1 def _get_mask_channels(self) -> list[int]: - """ Obtain the channels from the face targets that the masks reside in from the training + """Obtain the channels from the face targets that the masks reside in from the training data generator. Returns ------- - list: - A list of channel indices that contain the mask for the corresponding config item + A list of channel indices that contain the mask for the corresponding config item """ eye_multiplier = cfg_loss.eye_multiplier() mouth_multiplier = cfg_loss.mouth_multiplier() @@ -272,7 +268,7 @@ def _get_mask_channels(self) -> list[int]: class Optimizer(): - """ Obtain the selected optimizer with the appropriate keyword arguments. """ + """Obtain the selected optimizer with the appropriate keyword arguments.""" def __init__(self) -> None: logger.debug(parse_class_init(locals())) betas = {"ada_beta_1": "beta_1", "ada_beta_2": "beta_2"} @@ -297,23 +293,23 @@ def __init__(self) -> None: @property def optimizer(self) -> optimizers.Optimizer: - """ :class:`keras.optimizers.Optimizer`: The requested optimizer. """ + """The requested optimizer.""" return T.cast(optimizers.Optimizer, self._optimizer(**self._kwargs)) def _configure_clipping(self, method: T.Literal["autoclip", "norm", "value", "none"], value: float, history: int) -> None: - """ Configure optimizer clipping related kwargs, if selected + """Configure optimizer clipping related kwargs, if selected Parameters ---------- - method: Literal["autoclip", "norm", "value", "none"] + method The clipping method to use. ``None`` for no clipping - value: float + value The value to clip by norm/value by. For autoclip, this is the clip percentile (a value of 1.0 is a clip percentile of 10%) - history: int + history autoclip only: The number of iterations to keep for calculating the normalized value """ logger.debug("method: '%s', value: %s, history: %s", method, value, history) @@ -337,22 +333,22 @@ def _configure_clipping(self, "_clip_gradients"), "keras.BaseOptimizer._clip_gradients no longer exists" # TODO Keras3 has removed the ""gradient_transformers" kwarg, and there now appears to be - # no standardised method to add custom gradent transformers. Currently, we monkey patch its - # _clip_gradients function, which feels hacky and potentially problematic + # no standardized method to add custom gradient transformers. Currently, we monkey patch + # its _clip_gradients function, which feels hacky and potentially problematic setattr(self._optimizer, "_clip_gradients", AutoClipper(int(value * 10), history_size=history)) def _configure_ema(self, enable: bool, momentum: float, frequency: int) -> None: - """ Confihure the optimizer kwargs for exponential moving average updates + """configure the optimizer kwargs for exponential moving average updates Parameters ---------- - enable: bool + enable ``False`` to disable - momentum: float + momentum the momentum to use when computing the EMA of the model's weights: new_average = momentum * old_average + (1 - momentum) * current_variable_value - frequency: int + frequency the number of iterations, to overwrite the model variable by its moving average. """ self._kwargs["use_ema"] = enable @@ -366,13 +362,13 @@ def _configure_ema(self, enable: bool, momentum: float, frequency: int) -> None: logger.debug("ema enabled (momentum: %s, frequency: %s)", momentum, frequency) def _configure_kwargs(self, weight_decay: float, gradient_accumulation_steps: int) -> None: - """ Configure the remaining global optimizer kwargs + """Configure the remaining global optimizer kwargs Parameters ---------- - weight_decay: float + weight_decay The amount of weight decay to apply - gradient_accumulation_steps: int + gradient_accumulation_steps The number of steps to accumulate gradients for before applying the average """ if weight_decay > 0.0: @@ -388,7 +384,7 @@ def _configure_kwargs(self, weight_decay: float, gradient_accumulation_steps: in logger.debug("gradient accumulation disabled") def _configure_specific(self) -> None: - """ Configure keyword optimizer specific keyword arguments based on user settings. """ + """Configure keyword optimizer specific keyword arguments based on user settings.""" opts = self._valid[cfg_opt.optimizer()][1] if not opts: logger.debug("No additional kwargs to set for '%s'", cfg_opt.optimizer()) @@ -400,7 +396,7 @@ def _configure_specific(self) -> None: self._kwargs[val] = opt_val def _configure(self) -> None: - """ Process the user configuration options into Keras Optimizer kwargs. """ + """Process the user configuration options into Keras Optimizer kwargs.""" self._configure_clipping(T.cast(T.Literal["autoclip", "norm", "value", "none"], cfg_opt.gradient_clipping()), cfg_opt.clipping_value(), @@ -419,21 +415,18 @@ def _configure(self) -> None: class Settings(): - """ Tensorflow core training settings. + """Core training settings. - Sets backend tensorflow settings prior to launching the model. - - Tensorflow 2 uses distribution strategies for multi-GPU/system training. These are context - managers. + Sets backend settings prior to launching the model. Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The arguments that were passed to the train or convert process as generated from Faceswap's command line arguments - mixed_precision: bool + mixed_precision ``True`` if Mixed Precision training should be used otherwise ``False`` - is_predict: bool, optional + is_predict ``True`` if the model is being loaded for inference, ``False`` if the model is being loaded for training. Default: ``False`` """ @@ -453,38 +446,34 @@ def __init__(self, @property def use_mixed_precision(self) -> bool: - """ bool: ``True`` if mixed precision training has been enabled, otherwise ``False``. """ + """``True`` if mixed precision training has been enabled, otherwise ``False``. """ return self._use_mixed_precision @classmethod def loss_scale_optimizer( cls, optimizer: optimizers.Optimizer) -> optimizers.LossScaleOptimizer: - """ Optimize loss scaling for mixed precision training. + """Optimize loss scaling for mixed precision training. Parameters ---------- - optimizer: :class:`keras.optimizers.Optimizer` + optimizer The optimizer instance to wrap Returns -------- - :class:`keras.optimizers.LossScaleOptimizer` - The original optimizer with loss scaling applied + The original optimizer with loss scaling applied """ return optimizers.LossScaleOptimizer(optimizer) @classmethod def _set_keras_mixed_precision(cls, enable: bool) -> None: - """ Enable or disable Keras Mixed Precision. + """Enable or disable Keras Mixed Precision. Parameters ---------- - enable: bool + enable ``True`` to enable mixed precision. ``False`` to disable. - - Enables or disables the Keras Mixed Precision API if requested in the user configuration - file. """ policy = dtype_policies.DTypePolicy("mixed_float16" if enable else "float32") k_config.set_dtype_policy(policy) @@ -492,105 +481,17 @@ def _set_keras_mixed_precision(cls, enable: bool) -> None: "Enabling" if enable else "Disabling", policy.compute_dtype, policy.variable_dtype) -# def _get_strategy(self, -# strategy: T.Literal["default", "central-storage", "mirrored"] -# ) -> tf.distribute.Strategy | None: -# """ If we are running on Nvidia backend and the strategy is not ``None`` then return -# the correct tensorflow distribution strategy, otherwise return ``None``. -# -# Notes -# ----- -# By default Tensorflow defaults mirrored strategy to use the Nvidia NCCL method for -# reductions, however this is only available in Linux, so the method used falls back to -# `Hierarchical Copy All Reduce` if the OS is not Linux. -# -# Central Storage strategy is not compatible with Mixed Precision. However, in testing it -# worked fine when using a single GPU, so we monkey-patch out the tests for Mixed-Precision -# when using this strategy with a single GPU -# -# Parameters -# ---------- -# strategy: str -# One of 'default', 'central-storage' or 'mirrored'. -# -# Returns -# ------- -# :class:`tensorflow.distribute.Strategy` or `None` -# The request Tensorflow Strategy if the backend is Nvidia and the strategy is not -# `"Default"` otherwise ``None`` -# """ -# if get_backend() not in ("nvidia", "rocm"): -# retval = None -# elif strategy == "mirrored": -# retval = self._get_mirrored_strategy() -# elif strategy == "central-storage": -# retval = self._get_central_storage_strategy() -# else: -# retval = tf.distribute.get_strategy() -# logger.debug("Using strategy: %s", retval) -# return retval - -# @classmethod -# def _get_mirrored_strategy(cls) -> tf.distribute.MirroredStrategy: -# """ Obtain an instance of a Tensorflow Mirrored Strategy, setting the cross device -# operations appropriate for the OS in use. -# -# Returns -# ------- -# :class:`tensorflow.distribute.MirroredStrategy` -# The Mirrored Distribution Strategy object with correct cross device operations set -# """ -# if platform.system().lower() == "linux": -# cross_device_ops = tf.distribute.NcclAllReduce() -# else: -# cross_device_ops = tf.distribute.HierarchicalCopyAllReduce() -# logger.debug("cross_device_ops: %s", cross_device_ops) -# return tf.distribute.MirroredStrategy(cross_device_ops=cross_device_ops) - -# @classmethod -# def _get_central_storage_strategy(cls) -> tf.distribute.experimental.CentralStorageStrategy: -# """ Obtain an instance of a Tensorflow Central Storage Strategy. If the strategy is being -# run on a single GPU then monkey patch Tensorflows mixed-precision strategy checks to pass -# successfully. -# -# Returns -# ------- -# :class:`tensorflow.distribute.experimental.CentralStorageStrategy` -# The Central Storage Distribution Strategy object -# """ -# gpus = tf.config.get_visible_devices("GPU") -# if len(gpus) == 1: -# # TODO Remove these monkey patches when Strategy supports mixed-precision -# # pylint:disable=import-outside-toplevel -# from keras.mixed_precision import loss_scale_optimizer -# -# # Force a return of True on Loss Scale Optimizer Stategy check -# loss_scale_optimizer.strategy_supports_loss_scaling = lambda: True -# -# # As LossScaleOptimizer aggregates gradients internally, it passes `False` as the value -# # for `experimental_aggregate_gradients` in `OptimizerV2.apply_gradients`. This causes -# # the optimizer to fail when checking against this strategy. We could monkey patch -# # `Optimizer.apply_gradients`, but it is a lot more code to check, so we just switch -# # the `experimental_aggregate_gradients` back to `True`. In brief testing this does not -# # appear to have a negative impact. -# func = lambda s, grads, wvars, name: s._optimizer.apply_gradients( # noqa pylint:disable=protected-access,unnecessary-lambda-assignment -# list(zip(grads, wvars.value)), name, experimental_aggregate_gradients=True) -# loss_scale_optimizer.LossScaleOptimizer._apply_gradients = func # noqa pylint:disable=protected-access - -# return tf.distribute.experimental.CentralStorageStrategy(parameter_device="/cpu:0") - @classmethod def _dtype_from_config(cls, config: dict[str, T.Any]) -> str: - """ Obtain the dtype of a layer from the given layer config + """Obtain the dtype of a layer from the given layer config Parameters ---------- - config: dict[str, Any] : The Keras layer configuration dictionary + config Returns ------- - str - The datatype of the layer + The datatype of the layer """ dtype = config["dtype"] logger.debug("Obtaining layer dtype from config: %s", dtype) @@ -609,18 +510,17 @@ def _dtype_from_config(cls, config: dict[str, T.Any]) -> str: return retval def _get_mixed_precision_layers(self, layers: list[dict]) -> list[str]: - """ Obtain the names of the layers in a mixed precision model that have their dtype policy + """Obtain the names of the layers in a mixed precision model that have their dtype policy explicitly set to mixed-float16. Parameters ---------- - layers: List + layers The list of layers that appear in a keras's model configuration `dict` Returns ------- - list - A list of layer names within the model that are assigned a float16 policy + A list of layer names within the model that are assigned a float16 policy """ retval = [] for layer in layers: @@ -648,13 +548,13 @@ def _get_mixed_precision_layers(self, layers: list[dict]) -> list[str]: return retval def _switch_precision(self, layers: list[dict], compatible: list[str]) -> None: - """ Switch a model's datatype between mixed-float16 and float32. + """Switch a model's datatype between mixed-float16 and float32. Parameters ---------- - layers: List + layers The list of layers that appear in a keras's model configuration `dict` - compatible: List + compatible A list of layer names that are compatible to have their datatype switched """ dtype = "mixed_float16" if self.use_mixed_precision else "float32" @@ -679,20 +579,20 @@ def get_mixed_precision_layers(self, keras.models.Model], inputs: list[keras.layers.Layer] ) -> tuple[keras.models.Model, list[str]]: - """ Get and store the mixed precision layers from a full precision enabled model. + """Get and store the mixed precision layers from a full precision enabled model. Parameters ---------- - build_func: Callable + build_func The function to be called to compile the newly created model - inputs: + inputs The inputs to the model to be compiled Returns ------- - model: :class:`keras.model` + model The built model in fp32 - list + names The list of layer names within the full precision model that can be switched to mixed precision """ @@ -715,10 +615,10 @@ def get_mixed_precision_layers(self, def check_model_precision(self, model: keras.models.Model, state: "State") -> keras.models.Model: - """ Check the model's precision. + """Check the model's precision. If this is a new model, then - Rewrite an existing model's training precsion mode from mixed-float16 to float32 or + Rewrite an existing model's training precision mode from mixed-float16 to float32 or vice versa. This is not easy to do in keras, so we edit the model's config to change the dtype policy @@ -727,15 +627,14 @@ def check_model_precision(self, Parameters ---------- - model: :class:`keras.models.Model` + model The original saved keras model to rewrite the dtype - state: ~:class:`plugins.train.model._base.model.State` + state The State information for the model Returns ------- - :class:`keras.models.Model` - The original model with the datatype updated + The original model with the datatype updated """ if self.use_mixed_precision and not state.mixed_precision_layers: # Switching to mixed precision on a model which was started in FP32 prior to the diff --git a/plugins/train/trainer/original.py b/plugins/train/trainer/original.py index cbd7412ae0..1060a1d87c 100644 --- a/plugins/train/trainer/original.py +++ b/plugins/train/trainer/original.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Original Trainer """ +"""Original Trainer """ from __future__ import annotations import logging @@ -17,27 +17,26 @@ class Trainer(TrainerBase): - """ Original trainer """ + """Original trainer""" def _forward(self, inputs: torch.Tensor, targets: list[torch.Tensor]) -> torch.Tensor: - """ Perform the forward pass on the model + """Perform the forward pass on the model Parameters ---------- - inputs : :class:`torch.Tensor` + inputs The batch of input image tensors to the model in shape `(side, batch_size, *dims)` with `side` 0 being input A and `side` 1 being input B - targets : list[:class:`torch.Tensor`] + targets The corresponding batch of target images for the model for each side's output(s). For each model output an array should exist in the order of model outputs in the format `( side, batch_size, *dims)` with `side` 0 being input A and `side` 1 being input B Returns ------- - :class:`torch.Tensor` - The loss for each side of this batch in layout (A1, ..., An, B1, ..., Bn) + The loss for each side of this batch in layout (A1, ..., An, B1, ..., Bn) """ feed_targets = [[t[i] for t in targets] for i in range(2)] preds = self.model.model((inputs[0], inputs[1]), training=True) @@ -51,11 +50,11 @@ def _forward(self, return losses def _backwards_and_apply(self, all_loss: torch.Tensor) -> None: - """ Perform the backwards pass on the model + """Perform the backwards pass on the model Parameters ---------- - all_loss : :class:`torch.Tensor` + all_loss The loss for each output from the model """ total_loss = T.cast(torch.Tensor, @@ -66,7 +65,7 @@ def _backwards_and_apply(self, all_loss: torch.Tensor) -> None: gradients = [v.value.grad for v in trainable_weights] # Update weights - with torch.inference_mode(): + with torch.no_grad(): self.model.model.optimizer.apply(gradients, trainable_weights) def train_batch(self, @@ -76,18 +75,17 @@ def train_batch(self, Parameters ---------- - inputs : :class:`torch.Tensor` + inputs The batch of input image tensors to the model in shape `(side, batch_size, *dims)` with `side` 0 being input A and `side` 1 being input B - targets : list[:class:`torch.Tensor`] + targets The corresponding batch of target images for the model for each side's output(s). For each model output an array should exist in the order of model outputs in the format `( side, batch_size, *dims)` with `side` 0 being input A and `side` 1 being input B Returns ------- - :class:`torch.Tensor` - The loss for each side of this batch in layout (A1, ..., An, B1, ..., Bn) + The loss for each side of this batch in layout (A1, ..., An, B1, ..., Bn) """ loss_tensor = self._forward(inputs, targets) self._backwards_and_apply(loss_tensor) From d21781264d54a53d754bf0fab1436a882981468b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:52:37 +0000 Subject: [PATCH 943/981] Extraction Overhaul (#1533) * Extract refactor --- docs/full/lib/align.rst | 4 + docs/full/lib/infer.rst | 48 + docs/full/plugins/extract.rst | 43 +- docs/full/scripts.rst | 2 +- docs/full/tools/manual.rst | 22 +- docs/full/tools/sort.rst | 4 + lib/align/__init__.py | 5 +- lib/align/aligned_face.py | 461 ++--- lib/align/aligned_mask.py | 404 +++-- lib/align/aligned_utils.py | 402 +++++ lib/align/alignments.py | 4 +- lib/align/constants.py | 38 +- lib/align/detected_face.py | 301 ++-- lib/align/pose.py | 16 +- lib/align/thumbnails.py | 2 +- lib/align/updater.py | 2 +- lib/cli/args.py | 3 +- lib/cli/args_extract_convert.py | 223 ++- lib/cli/launcher.py | 38 +- lib/config/config.py | 19 +- lib/convert.py | 56 +- lib/gui/gui_config.py | 14 +- lib/gui/menu.py | 2 +- lib/gui/utils/misc.py | 2 +- lib/image.py | 317 ++-- lib/infer/__init__.py | 7 + lib/infer/align.py | 990 ++++++++++ lib/infer/detect.py | 558 ++++++ lib/infer/handler.py | 485 +++++ lib/infer/identity.py | 705 ++++++++ lib/infer/iterator.py | 719 ++++++++ lib/infer/mask.py | 153 ++ lib/infer/objects.py | 998 +++++++++++ lib/infer/plugin_utils.py | 209 +++ lib/infer/profile.py | 776 ++++++++ lib/infer/runner.py | 805 +++++++++ lib/model/networks/insightface_resnet.py | 4 +- lib/multithreading.py | 186 +- lib/training/augmentation.py | 2 +- lib/training/cache.py | 28 +- lib/utils.py | 320 ++-- locales/es/LC_MESSAGES/lib.cli.args.mo | Bin 1728 -> 1727 bytes locales/es/LC_MESSAGES/lib.cli.args.po | 22 +- .../lib.cli.args_extract_convert.mo | Bin 31746 -> 36062 bytes .../lib.cli.args_extract_convert.po | 385 ++-- locales/es/LC_MESSAGES/tools.manual.mo | Bin 8191 -> 8347 bytes locales/es/LC_MESSAGES/tools.manual.po | 208 +-- locales/es/LC_MESSAGES/tools.mask.cli.mo | Bin 15278 -> 14389 bytes locales/es/LC_MESSAGES/tools.mask.cli.po | 68 +- locales/es/LC_MESSAGES/tools.sort.cli.mo | Bin 11665 -> 15713 bytes locales/es/LC_MESSAGES/tools.sort.cli.po | 137 +- locales/kr/LC_MESSAGES/lib.cli.args.mo | Bin 1723 -> 1722 bytes locales/kr/LC_MESSAGES/lib.cli.args.po | 28 +- .../lib.cli.args_extract_convert.mo | Bin 32085 -> 36434 bytes .../lib.cli.args_extract_convert.po | 364 ++-- .../kr/LC_MESSAGES/tools.alignments.cli.mo | Bin 12482 -> 12504 bytes .../kr/LC_MESSAGES/tools.alignments.cli.po | 99 +- locales/kr/LC_MESSAGES/tools.manual.mo | Bin 8168 -> 8358 bytes locales/kr/LC_MESSAGES/tools.manual.po | 210 +-- locales/kr/LC_MESSAGES/tools.mask.cli.mo | Bin 14531 -> 13678 bytes locales/kr/LC_MESSAGES/tools.mask.cli.po | 65 +- locales/kr/LC_MESSAGES/tools.sort.cli.mo | Bin 11625 -> 15635 bytes locales/kr/LC_MESSAGES/tools.sort.cli.po | 137 +- locales/lib.cli.args.pot | 18 +- locales/lib.cli.args_extract_convert.pot | 275 +-- locales/plugins.extract.extract_config.pot | 81 +- locales/plugins.train.train_config.pot | 112 +- locales/ru/LC_MESSAGES/lib.cli.args.mo | Bin 2173 -> 2172 bytes locales/ru/LC_MESSAGES/lib.cli.args.po | 22 +- .../lib.cli.args_extract_convert.mo | Bin 42768 -> 48684 bytes .../lib.cli.args_extract_convert.po | 379 ++-- .../plugins.extract.extract_config.mo | Bin 8416 -> 9522 bytes .../plugins.extract.extract_config.po | 177 +- .../LC_MESSAGES/plugins.train.train_config.mo | Bin 70393 -> 70411 bytes .../LC_MESSAGES/plugins.train.train_config.po | 141 +- .../ru/LC_MESSAGES/tools.alignments.cli.mo | Bin 16437 -> 16476 bytes .../ru/LC_MESSAGES/tools.alignments.cli.po | 99 +- locales/ru/LC_MESSAGES/tools.manual.mo | Bin 10932 -> 11256 bytes locales/ru/LC_MESSAGES/tools.manual.po | 211 +-- locales/ru/LC_MESSAGES/tools.mask.cli.mo | Bin 18726 -> 17699 bytes locales/ru/LC_MESSAGES/tools.mask.cli.po | 67 +- locales/ru/LC_MESSAGES/tools.sort.cli.mo | Bin 15483 -> 20795 bytes locales/ru/LC_MESSAGES/tools.sort.cli.po | 142 +- locales/tools.alignments.cli.pot | 68 +- locales/tools.manual.pot | 207 ++- locales/tools.mask.cli.pot | 55 +- locales/tools.sort.cli.pot | 96 +- plugins/convert/color/_base.py | 8 +- plugins/convert/convert_config.py | 2 +- plugins/convert/mask/mask_blend.py | 270 +-- plugins/convert/scaling/_base.py | 4 +- plugins/convert/writer/_base.py | 16 +- plugins/convert/writer/opencv.py | 2 +- plugins/convert/writer/patch.py | 6 +- plugins/convert/writer/pillow.py | 2 +- plugins/extract/__init__.py | 4 - plugins/extract/_base.py | 653 ------- plugins/extract/align/_base/__init__.py | 4 - plugins/extract/align/_base/aligner.py | 837 --------- plugins/extract/align/_base/processing.py | 489 ----- plugins/extract/align/cv2_dnn.py | 303 +--- plugins/extract/align/dark_decoder.py | 164 ++ plugins/extract/align/external.py | 287 --- plugins/extract/align/external_defaults.py | 77 - plugins/extract/align/fan.py | 498 +++--- plugins/extract/align/fan_defaults.py | 16 +- plugins/extract/align/hrnet.py | 795 ++++++++ plugins/extract/align/hrnet_defaults.py | 53 + plugins/extract/base.py | 416 +++++ plugins/extract/detect/_base.py | 675 ------- plugins/extract/detect/cv2_dnn.py | 131 +- plugins/extract/detect/cv2_dnn_defaults.py | 2 +- plugins/extract/detect/external.py | 357 ---- plugins/extract/detect/mtcnn.py | 761 ++++---- plugins/extract/detect/mtcnn_defaults.py | 12 +- plugins/extract/detect/retinaface.py | 663 +++++++ plugins/extract/detect/retinaface_defaults.py | 70 + plugins/extract/detect/s3fd.py | 713 +++----- plugins/extract/detect/s3fd_defaults.py | 9 +- plugins/extract/extract_config.py | 223 +-- plugins/extract/extract_media.py | 214 --- .../{recognition => identity}/__init__.py | 0 plugins/extract/identity/t_face.py | 250 +++ .../t_face_defaults.py} | 49 +- plugins/extract/identity/vggface2.py | 245 +++ .../vggface2_defaults.py} | 9 +- plugins/extract/mask/_base.py | 342 ---- plugins/extract/mask/bisenet_fp.py | 704 ++++---- plugins/extract/mask/bisenet_fp_defaults.py | 6 +- plugins/extract/mask/components.py | 84 - plugins/extract/mask/custom.py | 73 +- plugins/extract/mask/custom_defaults.py | 2 +- plugins/extract/mask/extended.py | 113 -- plugins/extract/mask/unet_dfl.py | 343 ++-- plugins/extract/mask/unet_dfl_defaults.py | 10 +- plugins/extract/mask/vgg_clear.py | 338 ++-- plugins/extract/mask/vgg_clear_defaults.py | 6 +- plugins/extract/mask/vgg_obstructed.py | 344 ++-- .../extract/mask/vgg_obstructed_defaults.py | 6 +- plugins/extract/pipeline.py | 875 --------- plugins/extract/recognition/_base.py | 495 ----- plugins/extract/recognition/vgg_face2.py | 598 ------- plugins/plugin_loader.py | 183 +- plugins/train/model/_base/model.py | 6 +- plugins/train/model/phaze_a.py | 5 +- plugins/train/train_config.py | 49 +- pyproject.toml | 1 + scripts/convert.py | 377 ++-- scripts/extract.py | 1592 ++++++++++------- scripts/fs_media.py | 424 +++++ scripts/fsmedia.py | 621 ------- scripts/gui.py | 40 +- scripts/train.py | 4 +- tests/lib/training/augmentation_test.py | 3 +- tests/lib/training/cache_test.py | 33 +- tools/alignments/alignments.py | 6 +- tools/alignments/cli.py | 25 +- tools/alignments/jobs.py | 24 +- tools/alignments/jobs_faces.py | 35 +- tools/alignments/jobs_frames.py | 124 +- tools/effmpeg/effmpeg.py | 4 +- tools/manual/cli.py | 2 +- tools/manual/detected_faces.py | 286 ++- .../{faceviewer => face_viewer}/__init__.py | 0 .../{faceviewer => face_viewer}/frame.py | 2 +- .../{faceviewer => face_viewer}/interact.py | 0 .../{faceviewer => face_viewer}/viewport.py | 0 .../{frameviewer => frame_viewer}/__init__.py | 0 .../{frameviewer => frame_viewer}/control.py | 0 .../editor/__init__.py | 0 .../editor/_base.py | 0 .../editor/bounding_box.py | 10 +- .../editor/extract_box.py | 0 .../editor/landmarks.py | 0 .../editor/mask.py | 315 ++-- .../{frameviewer => frame_viewer}/frame.py | 0 tools/manual/manual.py | 309 ++-- tools/manual/thumbnails.py | 73 +- tools/mask/cli.py | 12 +- tools/mask/loader.py | 93 +- tools/mask/mask.py | 81 +- tools/mask/mask_generate.py | 173 +- tools/mask/mask_import.py | 141 +- tools/mask/mask_output.py | 155 +- tools/preview/control_panels.py | 122 +- tools/preview/preview.py | 166 +- tools/sort/cli.py | 15 + tools/sort/info_loader.py | 194 ++ tools/sort/sort.py | 59 +- tools/sort/sort_methods.py | 623 +++---- update_deps.py | 8 +- 191 files changed, 18474 insertions(+), 14799 deletions(-) create mode 100644 docs/full/lib/infer.rst create mode 100644 lib/align/aligned_utils.py create mode 100644 lib/infer/__init__.py create mode 100644 lib/infer/align.py create mode 100644 lib/infer/detect.py create mode 100644 lib/infer/handler.py create mode 100644 lib/infer/identity.py create mode 100644 lib/infer/iterator.py create mode 100644 lib/infer/mask.py create mode 100644 lib/infer/objects.py create mode 100644 lib/infer/plugin_utils.py create mode 100644 lib/infer/profile.py create mode 100644 lib/infer/runner.py delete mode 100644 plugins/extract/_base.py delete mode 100644 plugins/extract/align/_base/__init__.py delete mode 100644 plugins/extract/align/_base/aligner.py delete mode 100644 plugins/extract/align/_base/processing.py create mode 100644 plugins/extract/align/dark_decoder.py delete mode 100644 plugins/extract/align/external.py delete mode 100644 plugins/extract/align/external_defaults.py create mode 100644 plugins/extract/align/hrnet.py create mode 100644 plugins/extract/align/hrnet_defaults.py create mode 100644 plugins/extract/base.py delete mode 100644 plugins/extract/detect/_base.py delete mode 100644 plugins/extract/detect/external.py create mode 100644 plugins/extract/detect/retinaface.py create mode 100755 plugins/extract/detect/retinaface_defaults.py delete mode 100644 plugins/extract/extract_media.py rename plugins/extract/{recognition => identity}/__init__.py (100%) create mode 100644 plugins/extract/identity/t_face.py rename plugins/extract/{detect/external_defaults.py => identity/t_face_defaults.py} (51%) create mode 100644 plugins/extract/identity/vggface2.py rename plugins/extract/{recognition/vgg_face2_defaults.py => identity/vggface2_defaults.py} (86%) delete mode 100644 plugins/extract/mask/_base.py delete mode 100644 plugins/extract/mask/components.py delete mode 100644 plugins/extract/mask/extended.py delete mode 100644 plugins/extract/pipeline.py delete mode 100644 plugins/extract/recognition/_base.py delete mode 100644 plugins/extract/recognition/vgg_face2.py create mode 100644 scripts/fs_media.py delete mode 100644 scripts/fsmedia.py rename tools/manual/{faceviewer => face_viewer}/__init__.py (100%) rename tools/manual/{faceviewer => face_viewer}/frame.py (99%) rename tools/manual/{faceviewer => face_viewer}/interact.py (100%) rename tools/manual/{faceviewer => face_viewer}/viewport.py (100%) rename tools/manual/{frameviewer => frame_viewer}/__init__.py (100%) rename tools/manual/{frameviewer => frame_viewer}/control.py (100%) rename tools/manual/{frameviewer => frame_viewer}/editor/__init__.py (100%) rename tools/manual/{frameviewer => frame_viewer}/editor/_base.py (100%) rename tools/manual/{frameviewer => frame_viewer}/editor/bounding_box.py (98%) rename tools/manual/{frameviewer => frame_viewer}/editor/extract_box.py (100%) rename tools/manual/{frameviewer => frame_viewer}/editor/landmarks.py (100%) rename tools/manual/{frameviewer => frame_viewer}/editor/mask.py (70%) rename tools/manual/{frameviewer => frame_viewer}/frame.py (100%) create mode 100644 tools/sort/info_loader.py diff --git a/docs/full/lib/align.rst b/docs/full/lib/align.rst index 1b1a5d4479..63f9a3f65f 100644 --- a/docs/full/lib/align.rst +++ b/docs/full/lib/align.rst @@ -16,6 +16,10 @@ The align Package handles detected faces, their alignments and masks. .. automodapi:: lib.align.aligned_mask :include-all-objects: +| +.. automodapi:: lib.align.aligned_utils + :include-all-objects: + | .. automodapi:: lib.align.alignments :include-all-objects: diff --git a/docs/full/lib/infer.rst b/docs/full/lib/infer.rst new file mode 100644 index 0000000000..1320ad2232 --- /dev/null +++ b/docs/full/lib/infer.rst @@ -0,0 +1,48 @@ +***************** +lib.infer package +***************** + +The infer Package contains objects and utilities for extracting faces from images and videos. + +.. contents:: Contents + :local: + :depth: 2 + +.. automodapi:: lib.infer.align + :include-all-objects: + +| +.. automodapi:: lib.infer.detect + :include-all-objects: + +| +.. automodapi:: lib.infer.handler + :include-all-objects: + +| +.. automodapi:: lib.infer.identity + :include-all-objects: + +| +.. automodapi:: lib.infer.iterator + :include-all-objects: + +| +.. automodapi:: lib.infer.mask + :include-all-objects: + +| +.. automodapi:: lib.infer.objects + :include-all-objects: + +| +.. automodapi:: lib.infer.plugin_utils + :include-all-objects: + +| +.. automodapi:: lib.infer.profile + :include-all-objects: + +| +.. automodapi:: lib.infer.runner + :include-all-objects: diff --git a/docs/full/plugins/extract.rst b/docs/full/plugins/extract.rst index 6d990623be..b20cf9233b 100755 --- a/docs/full/plugins/extract.rst +++ b/docs/full/plugins/extract.rst @@ -8,6 +8,11 @@ The Extract Package handles the various plugins available for extracting face se :local: :depth: 2 +.. automodapi:: plugins.extract.base + :include-all-objects: + +| + align package ============= @@ -15,13 +20,17 @@ align package :include-all-objects: | -.. automodapi:: plugins.extract.align.external +.. automodapi:: plugins.extract.align.dark_decoder :include-all-objects: | .. automodapi:: plugins.extract.align.fan :include-all-objects: +| +.. automodapi:: plugins.extract.align.hrnet + :include-all-objects: + detect package ============== @@ -29,11 +38,11 @@ detect package :include-all-objects: | -.. automodapi:: plugins.extract.detect.external +.. automodapi:: plugins.extract.detect.mtcnn :include-all-objects: | -.. automodapi:: plugins.extract.detect.mtcnn +.. automodapi:: plugins.extract.detect.retinaface :include-all-objects: | @@ -46,18 +55,10 @@ mask package .. automodapi:: plugins.extract.mask.bisenet_fp :include-all-objects: -| -.. automodapi:: plugins.extract.mask.components - :include-all-objects: - | .. automodapi:: plugins.extract.mask.custom :include-all-objects: -| -.. automodapi:: plugins.extract.mask.extended - :include-all-objects: - | .. automodapi:: plugins.extract.mask.unet_dfl :include-all-objects: @@ -70,10 +71,14 @@ mask package .. automodapi:: plugins.extract.mask.vgg_obstructed :include-all-objects: -recognition package -=================== +identity package +================ -.. automodapi:: plugins.extract.recognition.vgg_face2 +.. automodapi:: plugins.extract.identity.vggface2 + :include-all-objects: + +| +.. automodapi:: plugins.extract.identity.t_face :include-all-objects: extract package @@ -82,13 +87,3 @@ extract package .. automodapi:: plugins.extract.extract_config :include-all-objects: :no-inheritance-diagram: - -| -.. automodapi:: plugins.extract.extract_media - :include-all-objects: - :no-inheritance-diagram: - -| -.. automodapi:: plugins.extract.pipeline - :include-all-objects: - :no-inheritance-diagram: diff --git a/docs/full/scripts.rst b/docs/full/scripts.rst index c620a8e46a..230605b04d 100644 --- a/docs/full/scripts.rst +++ b/docs/full/scripts.rst @@ -15,7 +15,7 @@ The Scripts Package is the entry point into Faceswap. :include-all-objects: :no-inheritance-diagram: -.. automodapi:: scripts.fsmedia +.. automodapi:: scripts.fs_media :include-all-objects: .. automodapi:: scripts.gui diff --git a/docs/full/tools/manual.rst b/docs/full/tools/manual.rst index 859267a88a..eaa1cc7f8e 100644 --- a/docs/full/tools/manual.rst +++ b/docs/full/tools/manual.rst @@ -6,47 +6,47 @@ tools.manual package :local: :depth: 2 -manual.faceviewer package +manual.face_viewer package ========================= -.. automodapi:: tools.manual.faceviewer.frame +.. automodapi:: tools.manual.face_viewer.frame :include-all-objects: | -.. automodapi:: tools.manual.faceviewer.interact +.. automodapi:: tools.manual.face_viewer.interact :include-all-objects: :no-inheritance-diagram: | -.. automodapi:: tools.manual.faceviewer.viewport +.. automodapi:: tools.manual.face_viewer.viewport :include-all-objects: :no-inheritance-diagram: -manual.frameviewer package +manual.frame_viewer package ========================== -.. automodapi:: tools.manual.frameviewer.control +.. automodapi:: tools.manual.frame_viewer.control :include-all-objects: :no-inheritance-diagram: | -.. automodapi:: tools.manual.frameviewer.frame +.. automodapi:: tools.manual.frame_viewer.frame :include-all-objects: | -.. automodapi:: tools.manual.frameviewer.editor.bounding_box +.. automodapi:: tools.manual.frame_viewer.editor.bounding_box :include-all-objects: | -.. automodapi:: tools.manual.frameviewer.editor.extract_box +.. automodapi:: tools.manual.frame_viewer.editor.extract_box :include-all-objects: | -.. automodapi:: tools.manual.frameviewer.editor.landmarks +.. automodapi:: tools.manual.frame_viewer.editor.landmarks :include-all-objects: | -.. automodapi:: tools.manual.frameviewer.editor.mask +.. automodapi:: tools.manual.frame_viewer.editor.mask :include-all-objects: manual package diff --git a/docs/full/tools/sort.rst b/docs/full/tools/sort.rst index 05aae7ec7e..778cc0e3f1 100644 --- a/docs/full/tools/sort.rst +++ b/docs/full/tools/sort.rst @@ -9,6 +9,10 @@ sort package .. automodapi:: tools.sort.cli :include-all-objects: +| +.. automodapi:: tools.sort.info_loader + :include-all-objects: + | .. automodapi:: tools.sort.sort :include-all-objects: diff --git a/lib/align/__init__.py b/lib/align/__init__.py index 3f5887bcd6..5cee86d2da 100644 --- a/lib/align/__init__.py +++ b/lib/align/__init__.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """ Package for handling alignments files, detected faces and aligned faces along with their associated objects. """ -from .aligned_face import (AlignedFace, get_adjusted_center, get_matrix_scaling, - get_centered_size, transform_image) +from .aligned_face import AlignedFace +from .aligned_utils import (get_adjusted_center, get_centered_size, + get_matrix_scaling, transform_image) from .aligned_mask import BlurMask, LandmarksMask, Mask from .alignments import Alignments from .constants import CenteringType, EXTRACT_RATIOS, LANDMARK_PARTS, LandmarkType diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 19b10bd0e3..8a93c8fc40 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Aligner for faceswap.py """ +"""Aligned faces for faceswap.py""" from __future__ import annotations from dataclasses import dataclass, field @@ -15,201 +15,51 @@ from lib.utils import get_module_objects from .constants import CenteringType, EXTRACT_RATIOS, LandmarkType, MEAN_FACE +from .aligned_utils import (get_adjusted_center, get_centered_size, get_matrix_scaling, + points_to_68, transform_image) +from .aligned_mask import LandmarksMask from .pose import PoseEstimate -logger = logging.getLogger(__name__) - - -def get_matrix_scaling(matrix: np.ndarray) -> tuple[int, int]: - """ Given a matrix, return the cv2 Interpolation method and inverse interpolation method for - applying the matrix on an image. - - Parameters - ---------- - matrix: :class:`numpy.ndarray` - The transform matrix to return the interpolator for - - Returns - ------- - tuple - The interpolator and inverse interpolator for the given matrix. This will be (Cubic, Area) - for an upscale matrix and (Area, Cubic) for a downscale matrix - """ - x_scale = np.sqrt(matrix[0, 0] * matrix[0, 0] + matrix[0, 1] * matrix[0, 1]) - if x_scale == 0: - y_scale = 0. - else: - y_scale = (matrix[0, 0] * matrix[1, 1] - matrix[0, 1] * matrix[1, 0]) / x_scale - avg_scale = (x_scale + y_scale) * 0.5 - if avg_scale >= 1.: - interpolators = cv2.INTER_CUBIC, cv2.INTER_AREA - else: - interpolators = cv2.INTER_AREA, cv2.INTER_CUBIC - logger.trace("interpolator: %s, inverse interpolator: %s", # type:ignore[attr-defined] - interpolators[0], interpolators[1]) - return interpolators - - -def transform_image(image: np.ndarray, - matrix: np.ndarray, - size: int, - padding: int = 0) -> np.ndarray: - """ Perform transformation on an image, applying the given size and padding to the matrix. - - Parameters - ---------- - image: :class:`numpy.ndarray` - The image to transform - matrix: :class:`numpy.ndarray` - The transformation matrix to apply to the image - size: int - The final size of the transformed image - padding: int, optional - The amount of padding to apply to the final image. Default: `0` - - Returns - ------- - :class:`numpy.ndarray` - The transformed image - """ - logger.trace("image shape: %s, matrix: %s, size: %s. padding: %s", # type:ignore[attr-defined] - image.shape, matrix, size, padding) - # transform the matrix for size and padding - mat = matrix * (size - 2 * padding) - mat[:, 2] += padding - - # transform image - interpolators = get_matrix_scaling(mat) - retval = cv2.warpAffine(image, mat, (size, size), flags=interpolators[0]) - logger.trace("transformed matrix: %s, final image shape: %s", # type:ignore[attr-defined] - mat, image.shape) - return retval - -def get_adjusted_center(image_size: int, - source_offset: np.ndarray, - target_offset: np.ndarray, - source_centering: CenteringType, - y_offset: float) -> np.ndarray: - """ Obtain the correct center of a face extracted image to translate between two different - extract centerings. - - Parameters - ---------- - image_size: int - The size of the image at the given :attr:`source_centering` - source_offset: :class:`numpy.ndarray` - The pose offset to translate a base extracted face to source centering - target_offset: :class:`numpy.ndarray` - The pose offset to translate a base extracted face to target centering - source_centering: ["face", "head", "legacy"] - The centering of the source image - y_offset: float - Amount to additionally offset the center of the image along the y-axis - - Returns - ------- - :class:`numpy.ndarray` - The center point of the image at the given size for the target centering - """ - source_size = image_size - (image_size * EXTRACT_RATIOS[source_centering]) - offset = target_offset - source_offset - [0., y_offset] - offset *= source_size - center = np.rint(offset + image_size / 2).astype("int32") - logger.trace( # type:ignore[attr-defined] - "image_size: %s, source_offset: %s, target_offset: %s, source_centering: '%s', " - "y_offset: %s, adjusted_offset: %s, center: %s", - image_size, source_offset, target_offset, source_centering, y_offset, offset, center) - return center - - -def get_centered_size(source_centering: CenteringType, - target_centering: CenteringType, - size: int, - coverage_ratio: float = 1.0) -> int: - """ Obtain the size of a cropped face from an aligned image. - - Given an image of a certain dimensions, returns the dimensions of the sub-crop within that - image for the requested centering at the requested coverage ratio - - Notes - ----- - `"legacy"` places the nose in the center of the image (the original method for aligning). - `"face"` aligns for the nose to be in the center of the face (top to bottom) but the center - of the skull for left to right. `"head"` places the center in the middle of the skull in 3D - space. - - The ROI in relation to the source image is calculated by rounding the padding of one side - to the nearest integer then applying this padding to the center of the crop, to ensure that - any dimensions always have an even number of pixels. - - Parameters - ---------- - source_centering: ["head", "face", "legacy"] - The centering that the original image is aligned at - target_centering: ["head", "face", "legacy"] - The centering that the sub-crop size should be obtained for - size: int - The size of the source image to obtain the cropped size for - coverage_ratio: float, optional - The coverage ratio to be applied to the target image. Default: `1.0` - - Returns - ------- - int - The pixel size of a sub-crop image from a full head aligned image with the given coverage - ratio - """ - if source_centering == target_centering and coverage_ratio == 1.0: - src_size: float | int = size - retval = size - else: - src_size = size - (size * EXTRACT_RATIOS[source_centering]) - retval = 2 * int(np.rint((src_size / (1 - EXTRACT_RATIOS[target_centering]) - * coverage_ratio) / 2)) - logger.trace( # type:ignore[attr-defined] - "source_centering: %s, target_centering: %s, size: %s, coverage_ratio: %s, " - "source_size: %s, crop_size: %s", - source_centering, target_centering, size, coverage_ratio, src_size, retval) - return retval +logger = logging.getLogger(__name__) @dataclass class _FaceCache: # pylint:disable=too-many-instance-attributes - """ Cache for storing items related to a single aligned face. + """Cache for storing items related to a single aligned face. Items are cached so that they are only created the first time they are called. Each item includes a threading lock to make cache creation thread safe. Parameters ---------- - pose: :class:`lib.align.PoseEstimate`, optional + pose The estimated pose in 3D space. Default: ``None`` - original_roi: :class:`numpy.ndarray`, optional + original_roi The location of the extracted face box within the original frame. Default: ``None`` - landmarks: :class:`numpy.ndarray`, optional + landmarks The 68 point facial landmarks aligned to the extracted face box. Default: ``None`` - landmarks_normalized: :class:`numpy.ndarray`: + landmarks_normalized The 68 point facial landmarks normalized to 0.0 - 1.0 as aligned by Umeyama. Default: ``None`` - average_distance: float, optional + average_distance The average distance of the core landmarks (18-67) from the mean face that was used for aligning the image. Default: `0.0` - relative_eye_mouth_position: float, optional + relative_eye_mouth_position A float value representing the relative position of the lowest eye/eye-brow point to the highest mouth point. Positive values indicate that eyes/eyebrows are aligned above the mouth, negative values indicate that eyes/eyebrows are misaligned below the mouth. Default: `0.0` - adjusted_matrix: :class:`numpy.ndarray`, optional + adjusted_matrix The 3x2 transformation matrix for extracting and aligning the core face area out of the original frame with padding and sizing applied. Default: ``None`` - interpolators: tuple, optional + interpolators (`interpolator` and `reverse interpolator`) for the :attr:`adjusted matrix`. Default: `(0, 0)` - cropped_roi, dict, optional + cropped_roi The (`left`, `top`, `right`, `bottom` location of the region of interest within an aligned face centered for each centering. Default: `{}` - cropped_slices: dict, optional + cropped_slices The slices for an input full head image and output cropped image. Default: `{}` """ pose: PoseEstimate | None = None @@ -227,59 +77,58 @@ class _FaceCache: # pylint:disable=too-many-instance-attributes _locks: dict[str, Lock] = field(default_factory=dict) def __post_init__(self): - """ Initialize the locks for the class parameters """ + """Initialize the locks for the class parameters""" self._locks = {name: Lock() for name in self.__dict__} def lock(self, name: str) -> Lock: - """ Obtain the lock for the given property + """Obtain the lock for the given property Parameters ---------- - name: str + name The name of a parameter within the cache Returns ------- - :class:`threading.Lock` - The lock associated with the requested parameter + The lock associated with the requested parameter """ return self._locks[name] class AlignedFace(): # pylint:disable=too-many-instance-attributes - """ Class to align a face. + """Class to align a face. Holds the aligned landmarks and face image, as well as associated matrices and information about an aligned face. Parameters ---------- - landmarks: :class:`numpy.ndarray` + landmarks The original 68 point landmarks that pertain to the given image for this face - image: :class:`numpy.ndarray`, optional + image The original frame that contains the face that is to be aligned. Pass `None` if the aligned face is not to be generated, and just the co-ordinates should be calculated. - centering: ["legacy", "face", "head"], optional + centering The type of extracted face that should be loaded. "legacy" places the nose in the center of the image (the original method for aligning). "face" aligns for the nose to be in the center of the face (top to bottom) but the center of the skull for left to right. "head" aligns for the center of the skull (in 3D space) being the center of the extracted image, with the crop holding the full head. Default: `"face"` - size: int, optional + size The size in pixels, of each edge of the final aligned face. Default: `64` - coverage_ratio: float, optional + coverage_ratio The amount of the aligned image to return. A ratio of 1.0 will return the full contents of the aligned image. A ratio of 0.5 will return an image of the given size, but will crop to the central 50%% of the image. - y_offset: float, optional + y_offset Amount to adjust the aligned face along the y-axis in the range -1. to 1. Default: 0.0 - dtype: str, optional + dtype Set a data type for the final face to be returned as. Passing ``None`` will return a face with the same data type as the original :attr:`image`. Default: ``None`` - is_aligned_face: bool, optional + is_aligned_face Indicates that the :attr:`image` is an aligned face rather than a frame. Default: ``False`` - is_legacy: bool, optional + is_legacy Only used if `is_aligned` is ``True``. ``True`` indicates that the aligned image being loaded is a legacy extracted face rather than a current head extracted face """ @@ -296,7 +145,7 @@ def __init__(self, logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] self._frame_landmarks = landmarks self._landmark_type = LandmarkType.from_shape(landmarks.shape) - self._centering = centering + self._centering: CenteringType = centering self._size = size self._coverage_ratio = coverage_ratio self._y_offset = y_offset @@ -306,7 +155,8 @@ def __init__(self, self._padding = self._padding_from_coverage(size, coverage_ratio) lookup = self._landmark_type - self._mean_lookup = LandmarkType.LM_2D_51 if lookup == LandmarkType.LM_2D_68 else lookup + self._mean_lookup = LandmarkType.LM_2D_51 if lookup in (LandmarkType.LM_2D_68, + LandmarkType.LM_2D_98) else lookup self._cache = _FaceCache() self._matrices: dict[CenteringType, np.ndarray] = {"legacy": self._get_default_matrix()} @@ -318,30 +168,30 @@ def __init__(self, @property def centering(self) -> T.Literal["legacy", "head", "face"]: - """ str: The centering of the Aligned Face. One of `"legacy"`, `"head"`, `"face"`. """ + """The centering of the Aligned Face. One of `"legacy"`, `"head"`, `"face"`.""" return self._centering @property def size(self) -> int: - """ int: The size (in pixels) of one side of the square extracted face image. """ + """The size (in pixels) of one side of the square extracted face image.""" return self._size @property def padding(self) -> int: - """ int: The amount of padding (in pixels) that is applied to each side of the - extracted face image for the selected extract type. """ + """The amount of padding (in pixels) that is applied to each side of the extracted face + image for the selected extract type.""" return self._padding[self._centering] @property def y_offset(self) -> float: - """ float: Additional offset applied to the face along the y-axis in -1. to 1. range """ + """Additional offset applied to the face along the y-axis in -1. to 1. range""" return self._y_offset @property def matrix(self) -> np.ndarray: - """ :class:`numpy.ndarray`: The 3x2 transformation matrix for extracting and aligning the - core face area out of the original frame, with no padding or sizing applied. The returned - matrix is offset for the given :attr:`centering`. """ + """The 3x2 transformation matrix for extracting and aligning the core face area out of the + original frame, with no padding or sizing applied. The returned matrix is offset for the + given :attr:`centering`.""" if self._centering not in self._matrices: matrix = self._matrices["legacy"].copy() matrix[:, 2] -= self.pose.offset[self._centering] @@ -352,7 +202,7 @@ def matrix(self) -> np.ndarray: @property def pose(self) -> PoseEstimate: - """ :class:`lib.align.PoseEstimate`: The estimated pose in 3D space. """ + """The estimated pose in 3D space.""" with self._cache.lock("pose"): if self._cache.pose is None: lms = np.nan_to_num(cv2.transform(np.expand_dims(self._frame_landmarks, axis=1), @@ -362,8 +212,8 @@ def pose(self) -> PoseEstimate: @property def adjusted_matrix(self) -> np.ndarray: - """ :class:`numpy.ndarray`: The 3x2 transformation matrix for extracting and aligning the - core face area out of the original frame with padding and sizing applied. """ + """The 3x2 transformation matrix for extracting and aligning the core face area out of the + original frame with padding and sizing applied.""" with self._cache.lock("adjusted_matrix"): if self._cache.adjusted_matrix is None: matrix = self.matrix.copy() @@ -375,15 +225,14 @@ def adjusted_matrix(self) -> np.ndarray: @property def face(self) -> np.ndarray | None: - """ :class:`numpy.ndarray`: The aligned face at the given :attr:`size` at the specified - :attr:`coverage` in the given :attr:`dtype`. If an :attr:`image` has not been provided - then an the attribute will return ``None``. """ + """The aligned face at the given :attr:`size` at the specified :attr:`coverage` in the + given :attr:`dtype`. If an :attr:`image` has not been provided then an the attribute will + return ``None``. """ return self._face @property def original_roi(self) -> np.ndarray: - """ :class:`numpy.ndarray`: The location of the extracted face box within the original - frame. """ + """The location of the extracted face box within the original frame.""" with self._cache.lock("original_roi"): if self._cache.original_roi is None: roi = np.array([[0, 0], @@ -397,8 +246,7 @@ def original_roi(self) -> np.ndarray: @property def landmarks(self) -> np.ndarray: - """ :class:`numpy.ndarray`: The 68 point facial landmarks aligned to the extracted face - box. """ + """The 68 point facial landmarks aligned to the extracted face box.""" with self._cache.lock("landmarks"): if self._cache.landmarks is None: lms = self.transform_points(self._frame_landmarks) @@ -408,13 +256,12 @@ def landmarks(self) -> np.ndarray: @property def landmark_type(self) -> LandmarkType: - """:class:`~LandmarkType`: The type of landmarks that generated this aligned face """ + """The type of landmarks that generated this aligned face""" return self._landmark_type @property def normalized_landmarks(self) -> np.ndarray: - """ :class:`numpy.ndarray`: The 68 point facial landmarks normalized to 0.0 - 1.0 as - aligned by Umeyama. """ + """The 68 point facial landmarks normalized to 0.0 - 1.0 as aligned by Umeyama.""" with self._cache.lock("landmarks_normalized"): if self._cache.landmarks_normalized is None: lms = np.expand_dims(self._frame_landmarks, axis=1) @@ -425,7 +272,7 @@ def normalized_landmarks(self) -> np.ndarray: @property def interpolators(self) -> tuple[int, int]: - """ tuple: (`interpolator` and `reverse interpolator`) for the :attr:`adjusted matrix`. """ + """(`interpolator` and `reverse interpolator`) for the :attr:`adjusted matrix`.""" with self._cache.lock("interpolators"): if not any(self._cache.interpolators): interpolators = get_matrix_scaling(self.adjusted_matrix) @@ -435,54 +282,59 @@ def interpolators(self) -> tuple[int, int]: @property def average_distance(self) -> float: - """ float: The average distance of the core landmarks (18-67) from the mean face that was - used for aligning the image. """ + """The average distance of the core landmarks (18-67) from the mean face that was used for + aligning the image.""" with self._cache.lock("average_distance"): if not self._cache.average_distance: + if self._landmark_type not in (LandmarkType.LM_2D_68, LandmarkType.LM_2D_98): + return 0.0 mean_face = MEAN_FACE[self._mean_lookup] lms = self.normalized_landmarks - if self._landmark_type == LandmarkType.LM_2D_68: - lms = lms[17:] # 68 point landmarks only use core face items + if self._landmark_type != LandmarkType.LM_2D_68: + lms = points_to_68(lms) + lms = lms[17:] # 68 point landmarks only use core face items average_distance = np.mean(np.abs(lms - mean_face)) logger.trace("average_distance: %s", average_distance) # type:ignore[attr-defined] - self._cache.average_distance = average_distance + self._cache.average_distance = float(average_distance) return self._cache.average_distance @property def relative_eye_mouth_position(self) -> float: - """ float: Value representing the relative position of the lowest eye/eye-brow point to the - highest mouth point. Positive values indicate that eyes/eyebrows are aligned above the - mouth, negative values indicate that eyes/eyebrows are misaligned below the mouth. """ + """Value representing the relative position of the lowest eye/eye-brow point to the highest + mouth point. Positive values indicate that eyes/eyebrows are aligned above the mouth, + negative values indicate that eyes/eyebrows are misaligned below the mouth.""" with self._cache.lock("relative_eye_mouth_position"): if not self._cache.relative_eye_mouth_position: - if self._landmark_type != LandmarkType.LM_2D_68: + if self._landmark_type not in (LandmarkType.LM_2D_68, LandmarkType.LM_2D_98): position = 1.0 # arbitrary positive value else: + lms = self.normalized_landmarks + if self._landmark_type != LandmarkType.LM_2D_68: + lms = points_to_68(lms) lowest_eyes = np.max(self.normalized_landmarks[np.r_[17:27, 36:48], 1]) highest_mouth = np.min(self.normalized_landmarks[48:68, 1]) position = highest_mouth - lowest_eyes - logger.trace("lowest_eyes: %s, highest_mouth: %s, " # type:ignore[attr-defined] - "relative_eye_mouth_position: %s", lowest_eyes, highest_mouth, - position) + logger.trace( # type:ignore[attr-defined] + "lowest_eyes: %s, highest_mouth: %s, relative_eye_mouth_position: %s", + lowest_eyes, highest_mouth, position) self._cache.relative_eye_mouth_position = position return self._cache.relative_eye_mouth_position @classmethod def _padding_from_coverage(cls, size: int, coverage_ratio: float) -> dict[CenteringType, int]: - """ Return the image padding for a face from coverage_ratio set against a - pre-padded training image. + """Return the image padding for a face from coverage_ratio set against a pre-padded + training image. Parameters ---------- - size: int + size The final size of the aligned image in pixels - coverage_ratio: float + coverage_ratio The ratio of the final image to pad to Returns ------- - dict - The padding required, in pixels for 'head', 'face' and 'legacy' face types + The padding required, in pixels for 'head', 'face' and 'legacy' face types """ retval = {_type: round((size * (coverage_ratio - (1 - EXTRACT_RATIOS[_type]))) / 2) for _type in T.get_args(T.Literal["legacy", "face", "head"])} @@ -490,36 +342,36 @@ def _padding_from_coverage(cls, size: int, coverage_ratio: float) -> dict[Center return retval def _get_default_matrix(self) -> np.ndarray: - """ Get the default (legacy) matrix. All subsequent matrices are calculated from this + """Get the default (legacy) matrix. All subsequent matrices are calculated from this Returns ------- - :class:`numpy.ndarray` - The default 'legacy' matrix + The default 'legacy' matrix """ lms = self._frame_landmarks - if self._landmark_type == LandmarkType.LM_2D_68: + if self._landmark_type not in (LandmarkType.LM_2D_68, LandmarkType.LM_2D_4): + lms = points_to_68(lms) + if self._landmark_type in (LandmarkType.LM_2D_68, LandmarkType.LM_2D_98): lms = lms[17:] # 68 point landmarks only use core face items retval = _umeyama(lms, MEAN_FACE[self._mean_lookup], True)[0:2] logger.trace("Default matrix: %s", retval) # type:ignore[attr-defined] return retval def transform_points(self, points: np.ndarray, invert: bool = False) -> np.ndarray: - """ Perform transformation on a series of (x, y) co-ordinates in world space into + """Perform transformation on a series of (x, y) co-ordinates in world space into aligned face space. Parameters ---------- - points: :class:`numpy.ndarray` + points The points to transform - invert: bool, optional + invert ``True`` to reverse the transformation (i.e. transform the points into world space from aligned face space). Default: ``False`` Returns ------- - :class:`numpy.ndarray` - The transformed points + The transformed points """ retval = np.expand_dims(points, axis=1) mat = cv2.invertAffineTransform(self.adjusted_matrix) if invert else self.adjusted_matrix @@ -529,20 +381,19 @@ def transform_points(self, points: np.ndarray, invert: bool = False) -> np.ndarr return retval def extract_face(self, image: np.ndarray | None) -> np.ndarray | None: - """ Extract the face from a source image and populate :attr:`face`. If an image is not + """Extract the face from a source image and populate :attr:`face`. If an image is not provided then ``None`` is returned. Parameters ---------- - image: :class:`numpy.ndarray` or ``None`` + image The original frame to extract the face from. ``None`` if the face should not be extracted Returns ------- - :class:`numpy.ndarray` or ``None`` - The extracted face at the given size, with the given coverage of the given dtype or - ``None`` if no image has been provided. + The extracted face at the given size, with the given coverage of the given dtype or + ``None`` if no image has been provided. """ if image is None: logger.trace("_extract_face called without a loaded " # type:ignore[attr-defined] @@ -554,8 +405,8 @@ def extract_face(self, image: np.ndarray | None) -> np.ndarray | None: image = self._convert_centering(image) if self._is_aligned and image.shape[0] != self._size: # Resize the given aligned face - interp = cv2.INTER_CUBIC if image.shape[0] < self._size else cv2.INTER_AREA - retval = cv2.resize(image, (self._size, self._size), interpolation=interp) + interpolation = cv2.INTER_CUBIC if image.shape[0] < self._size else cv2.INTER_AREA + retval = cv2.resize(image, (self._size, self._size), interpolation=interpolation) elif self._is_aligned: retval = image else: @@ -564,7 +415,7 @@ def extract_face(self, image: np.ndarray | None) -> np.ndarray | None: return retval def _convert_centering(self, image: np.ndarray) -> np.ndarray: - """ When the face being loaded is pre-aligned, the loaded image will have 'head' centering + """When the face being loaded is pre-aligned, the loaded image will have 'head' centering so it needs to be cropped out to the appropriate centering. This function temporarily converts this object to a full head aligned face, extracts the @@ -573,13 +424,12 @@ def _convert_centering(self, image: np.ndarray) -> np.ndarray: Parameters ---------- - image: :class:`numpy.ndarray` + image The original head-centered aligned image Returns ------- - :class:`numpy.ndarray` - The aligned image with the correct centering, scaled to image input size + The aligned image with the correct centering, scaled to image input size """ logger.trace( # type:ignore[attr-defined] "image_size: %s, target_size: %s, coverage_ratio: %s", @@ -603,20 +453,19 @@ def _get_cropped_slices(self, image_size: int, target_size: int, ) -> dict[T.Literal["in", "out"], tuple[slice, slice]]: - """ Obtain the slices to turn a full head extract into an alternatively centered extract. + """Obtain the slices to turn a full head extract into an alternatively centered extract. Parameters ---------- - image_size: int + image_size The size of the full head extracted image loaded from disk - target_size: int + target_size The size of the target centered face with coverage ratio applied in relation to the original image size Returns ------- - dict - The slices for an input full head image and output cropped image + The slices for an input full head image and output cropped image """ with self._cache.lock("cropped_slices"): if not self._cache.cropped_slices.get(self._centering): @@ -636,18 +485,17 @@ def get_cropped_roi(self, image_size: int, target_size: int, centering: CenteringType) -> np.ndarray: - """ Obtain the region of interest within an aligned face set to centered coverage for + """Obtain the region of interest within an aligned face set to centered coverage for an alternative centering Parameters ---------- - image_size: int + image_size The size of the full head extracted image loaded from disk - target_size: int + target_sizes The size of the target centered face with coverage ratio applied in relation to the original image size - - centering: ["legacy", "face"] + centering The type of centering to obtain the region of interest for. "legacy" places the nose in the center of the image (the original method for aligning). "face" aligns for the nose to be in the center of the face (top to bottom) but the center of the skull for @@ -655,7 +503,6 @@ def get_cropped_roi(self, Returns ------- - :class:`numpy.ndarray` The (`left`, `top`, `right`, `bottom` location of the region of interest within an aligned face centered on the head for the given centering """ @@ -675,12 +522,11 @@ def get_cropped_roi(self, return self._cache.cropped_roi[centering] def split_mask(self) -> np.ndarray: - """ Remove the mask from the alpha channel of :attr:`face` and return the mask + """Remove the mask from the alpha channel of :attr:`face` and return the mask Returns ------- - :class:`numpy.ndarray` - The mask that was stored in the :attr:`face`'s alpha channel + The mask that was stored in the :attr:`face`'s alpha channel Raises ------ @@ -693,6 +539,35 @@ def split_mask(self) -> np.ndarray: self._face = self._face[..., :3] return mask + def get_landmark_mask(self, + area: T.Literal["eye", "mouth", "face", "face_extended"], + dilation: float) -> LandmarksMask: + """Obtain a :class:`~lib.align.aligned_mask.LandmarksMask` based mask for this face + + Landmark based masks are generated from Aligned Face landmark points. + + Parameters + ---------- + area + The type of mask to obtain. `face` is a full face mask, `face_extended` is a face mask + that extends above the eyebrows. The others are masks for those specific areas + dilation + The amount of dilation to apply to the mask. as a percentage of the mask size + + Returns + ------- + The requested Landmarks Mask object + """ + logger.trace("area: %s, dilation: %s", area, dilation) # type:ignore[attr-defined] + mask = LandmarksMask(area, + self.landmark_type, + self.landmarks, + self.adjusted_matrix, + storage_size=self.size, + storage_centering=self.centering, + dilation=dilation) + return mask + def _umeyama(source: np.ndarray, destination: np.ndarray, estimate_scale: bool) -> np.ndarray: """Estimate N-D similarity transformation with or without scaling. @@ -703,18 +578,17 @@ def _umeyama(source: np.ndarray, destination: np.ndarray, estimate_scale: bool) Parameters ---------- - source: :class:`numpy.ndarray` + source (M, N) array source coordinates. - destination: :class:`numpy.ndarray` + destination (M, N) array destination coordinates. - estimate_scale: bool + estimate_scale Whether to estimate scaling factor. Returns ------- - :class:`numpy.ndarray` - (N + 1, N + 1) The homogeneous similarity transformation matrix. The matrix contains - NaN values only if the problem is not well-conditioned. + (N + 1, N + 1) The homogeneous similarity transformation matrix. The matrix contains NaN values + only if the problem is not well-conditioned. References ---------- @@ -772,4 +646,65 @@ def _umeyama(source: np.ndarray, destination: np.ndarray, estimate_scale: bool) return retval +def batch_umeyama(source: np.ndarray, destination: np.ndarray, estimate_scale: bool) -> np.ndarray: + """A batch implementation to estimate N-D similarity transformation with or without scaling. + + Parameters + ---------- + source + (B, M, N) array source coordinates. + destination + (M, N) array destination coordinates. + estimate_scale: bool + Whether to estimate scaling factor. + + Returns + ------- + (B, N + 1, N + 1) The homogeneous similarity transformation matrix. The matrix contains NaN + values only if the problem is not well-conditioned. + + References + ---------- + .. [1] "Least-squares estimation of transformation parameters between two + point patterns", Shinji Umeyama, PAMI 1991, :DOI:`10.1109/34.88573` + """ + # pylint:disable=too-many-locals + batch_size, num, dim = source.shape # (B, M, N) + + # Compute mean of source and destination. + src_mean = source.mean(axis=1) # (B, N) + dst_mean = destination.mean(axis=0) # (N, ) + + # Subtract mean from source and destination. + src_demean = source - src_mean[:, None] # (B, M, N) + dst_demean = destination - dst_mean # (M, N) + + # Eq. (38). + a = dst_demean.T @ src_demean / num # (B, N, N) + + # SVD + u, s, vt = np.linalg.svd(a) + + rot = u @ vt + det_rot = np.linalg.det(rot) + # Fix improper rotations + vt[det_rot < 0, -1, :] *= -1 + rot = u @ vt + + if estimate_scale: + # Eq. (41) and (42). + var_src = src_demean.var(axis=1).sum(axis=1) # (B,) + scale = s.sum(axis=1) / var_src + else: + scale = np.ones(batch_size) + + trans = dst_mean - scale[:, None] * ((rot @ src_mean[..., None])[..., 0]) + retval = np.zeros((batch_size, dim + 1, dim + 1), dtype=source.dtype) + retval[:, -1, -1] = 1.0 + + retval[:, :dim, :dim] = scale[:, None, None] * rot + retval[:, :dim, dim] = trans + return retval + + __all__ = get_module_objects(__name__) diff --git a/lib/align/aligned_mask.py b/lib/align/aligned_mask.py index ad5a7e7e1c..06b7937a3f 100644 --- a/lib/align/aligned_mask.py +++ b/lib/align/aligned_mask.py @@ -1,5 +1,5 @@ #!/usr/bin python3 -""" Handles retrieval and storage of Faceswap aligned masks """ +"""Handles retrieval and storage of Faceswap aligned masks""" from __future__ import annotations import logging @@ -11,20 +11,22 @@ import numpy as np from lib.logger import parse_class_init -from lib.utils import get_module_objects +from lib.utils import FaceswapError, get_module_objects +from .aligned_utils import get_adjusted_center, get_centered_size from .alignments import MaskAlignmentsFileDict -from . import get_adjusted_center, get_centered_size +from .constants import LandmarkType, LANDMARK_PARTS, LANDMARK_MASK_PARTS if T.TYPE_CHECKING: from collections.abc import Callable + import numpy.typing as npt from .aligned_face import CenteringType logger = logging.getLogger(__name__) class Mask(): # pylint:disable=too-many-instance-attributes - """ Face Mask information and convenience methods + """Face Mask information and convenience methods Holds a Faceswap mask as generated from :mod:`plugins.extract.mask` and the information required to transform it to its original frame. @@ -33,17 +35,17 @@ class Mask(): # pylint:disable=too-many-instance-attributes Parameters ---------- - storage_size: int, optional + storage_size The size (in pixels) that the mask should be stored at. Default: 128. - storage_centering, str (optional): + storage_centering The centering to store the mask at. One of `"legacy"`, `"face"`, `"head"`. Default: `"face"` Attributes ---------- - stored_size: int + stored_size The size, in pixels, of the stored mask across its height and width. - stored_centering: str + stored_centering The centering that the mask is stored at. One of `"legacy"`, `"face"`, `"head"` """ def __init__(self, @@ -68,10 +70,17 @@ def __init__(self, self.set_blur_and_threshold() logger.trace("Initialized: %s", self.__class__.__name__) # type:ignore[attr-defined] + def __repr__(self) -> str: + """Pretty print for logging""" + params = {k.replace("stored", "storage"): v for k, v in self.__dict__.items() + if k in ("stored_size", "stored_centering")} + s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + @property def mask(self) -> np.ndarray: - """ :class:`numpy.ndarray`: The mask at the size of :attr:`stored_size` with any requested - blurring, threshold amount and centering applied.""" + """The mask at the size of :attr:`stored_size` with any requested blurring, threshold + amount and centering applied.""" mask = self.stored_mask if self._dilation[-1] is not None or self._threshold != 0.0 or self._blur_kernel != 0: mask = mask.copy() @@ -94,47 +103,46 @@ def mask(self) -> np.ndarray: @property def stored_mask(self) -> np.ndarray: - """ :class:`numpy.ndarray`: The mask at the size of :attr:`stored_size` as it is stored - (i.e. with no blurring/centering applied). """ + """The mask at the size of :attr:`stored_size` as it is stored (i.e. with no blurring/ + centering applied).""" assert self._mask is not None dims = (self.stored_size, self.stored_size, 1) - mask = np.frombuffer(decompress(self._mask), dtype="uint8").reshape(dims) + mask = np.frombuffer(decompress(self._mask), dtype=np.uint8).reshape(dims) logger.trace("stored mask shape: %s", mask.shape) # type:ignore[attr-defined] return mask @property def original_roi(self) -> np.ndarray: - """ :class: `numpy.ndarray`: The original region of interest of the mask in the - source frame. """ + """The original region of interest of the mask in the source frame.""" points = np.array([[0, 0], [0, self.stored_size - 1], [self.stored_size - 1, self.stored_size - 1], [self.stored_size - 1, 0]], np.int32).reshape((-1, 1, 2)) - matrix = cv2.invertAffineTransform(self.affine_matrix) + matrix = cv2.invertAffineTransform(self.affine_matrix[:2]) roi = cv2.transform(points, matrix).reshape((4, 2)) logger.trace("Returning: %s", roi) # type:ignore[attr-defined] return roi @property def affine_matrix(self) -> np.ndarray: - """ :class: `numpy.ndarray`: The affine matrix to transpose the mask to a full frame. """ + """The affine matrix to transpose the mask to a full frame.""" assert self._affine_matrix is not None return self._affine_matrix @property def interpolator(self) -> int: - """ int: The cv2 interpolator required to transpose the mask to a full frame. """ + """The cv2 interpolator required to transpose the mask to a full frame.""" assert self._interpolator is not None return self._interpolator def _dilate_mask(self, mask: np.ndarray) -> None: - """ Erode/Dilate the mask. The action is performed in-place on the given mask. + """Erode/Dilate the mask. The action is performed in-place on the given mask. No action is performed if a dilation amount has not been set Parameters ---------- - mask: :class:`numpy.ndarray` + mask The mask to be eroded/dilated """ if self._dilation[-1] is None: @@ -144,22 +152,22 @@ def _dilate_mask(self, mask: np.ndarray) -> None: func(mask, self._dilation[-1], dst=mask, iterations=1) def get_full_frame_mask(self, width: int, height: int) -> np.ndarray: - """ Return the stored mask in a full size frame of the given dimensions + """Return the stored mask in a full size frame of the given dimensions Parameters ---------- - width: int + width The width of the original frame that the mask was extracted from - height: int + height The height of the original frame that the mask was extracted from Returns ------- - :class:`numpy.ndarray`: The mask affined to the original full frame of the given dimensions + The mask affined to the original full frame of the given dimensions """ - frame = np.zeros((width, height, 1), dtype="uint8") + frame = np.zeros((width, height, 1), dtype=np.uint8) mask = cv2.warpAffine(self.mask, - self.affine_matrix, + self.affine_matrix[:2], (width, height), frame, flags=cv2.WARP_INVERSE_MAP | self.interpolator, @@ -168,48 +176,60 @@ def get_full_frame_mask(self, width: int, height: int) -> np.ndarray: "mask max: %s", mask.shape, mask.dtype, mask.min(), mask.max()) return mask - def add(self, mask: np.ndarray, affine_matrix: np.ndarray, interpolator: int) -> None: - """ Add a Faceswap mask to this :class:`Mask`. + def add(self, mask: npt.NDArray[np.uint8], affine_matrix: npt.NDArray[np.float32]) -> T.Self: + """Add a Faceswap mask to this :class:`Mask`. The mask should be the original output from :mod:`plugins.extract.mask` Parameters ---------- - mask: :class:`numpy.ndarray` - The mask that is to be added as output from :mod:`plugins.extract.mask` - It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` - affine_matrix: :class:`numpy.ndarray` - The transformation matrix required to transform the mask to the original frame. - interpolator, int: - The CV2 interpolator required to transform this mask to it's original frame + mask + The mask that is to be added as output from :mod:`plugins.extract.mask` as a UINT8 + image + affine_matrix + The normalized transformation matrix required to transform the mask from (0, 1) to the + original frame. + + Returns + ------- + This mask object """ logger.trace("mask shape: %s, mask dtype: %s, mask min: %s, " # type:ignore[attr-defined] - "mask max: %s, affine_matrix: %s, interpolator: %s)", - mask.shape, mask.dtype, mask.min(), affine_matrix, mask.max(), interpolator) + "mask max: %s, affine_matrix: %s)", + mask.shape, mask.dtype, mask.min(), affine_matrix, mask.max()) self._affine_matrix = self._adjust_affine_matrix(mask.shape[0], affine_matrix) - self._interpolator = interpolator + scale = (self._affine_matrix[0, 0] ** 2 + self._affine_matrix[1, 0] ** 2) ** 0.5 + self._interpolator = cv2.INTER_LINEAR if scale < 1.0 else cv2.INTER_AREA self.replace_mask(mask) + return self - def replace_mask(self, mask: np.ndarray) -> None: - """ Replace the existing :attr:`_mask` with the given mask. + def replace_mask(self, mask: npt.NDArray[np.uint8]) -> None: + """Replace the existing :attr:`_mask` with the given mask. Parameters ---------- - mask: :class:`numpy.ndarray` - The mask that is to be added as output from :mod:`plugins.extract.mask`. - It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` + mask + The mask that is to be added as output from :mod:`plugins.extract.mask` as a UINT8 + image """ - mask = (cv2.resize(mask * 255.0, - (self.stored_size, self.stored_size), - interpolation=cv2.INTER_AREA)).astype("uint8") - self._mask = compress(mask.tobytes()) + assert mask.dtype == np.uint8 + size = mask.shape[0] + if size == self.stored_size: + new_mask = mask + else: + dims = (self.stored_size, self.stored_size) + interpolation = cv2.INTER_AREA if self.stored_size < size else cv2.INTER_LINEAR + new_mask = T.cast("npt.NDArray[np.uint8]", cv2.resize(mask, + dims, + interpolation=interpolation)) + self._mask = compress(new_mask.tobytes()) def set_dilation(self, amount: float) -> None: - """ Set the internal dilation object for returned masks + """Set the internal dilation object for returned masks Parameters ---------- - amount: float + amount The amount of erosion/dilation to apply as a percentage of the total mask size. Negative values erode the mask. Positive values dilate the mask """ @@ -229,19 +249,19 @@ def set_blur_and_threshold(self, blur_type: T.Literal["gaussian", "normalized"] | None = "gaussian", blur_passes: int = 1, threshold: int = 0) -> None: - """ Set the internal blur kernel and threshold amount for returned masks + """Set the internal blur kernel and threshold amount for returned masks Parameters ---------- - blur_kernel: int, optional + blur_kernel The kernel size, in pixels to apply gaussian blurring to the mask. Set to 0 for no blurring. Should be odd, if an even number is passed in (outside of 0) then it is rounded up to the next odd number. Default: 0 - blur_type: ["gaussian", "normalized"], optional + blur_type The blur type to use. ``gaussian`` or ``normalized`` box filter. Default: ``gaussian`` - blur_passes: int, optional + blur_passes The number of passed to perform when blurring. Default: 1 - threshold: int, optional + threshold The threshold amount to minimize/maximize mask values to 0 and 100. Percentage value. Default: 0 """ @@ -261,23 +281,23 @@ def set_sub_crop(self, centering: CenteringType, coverage_ratio: float = 1.0, y_offset: float = 0.0) -> None: - """ Set the internal crop area of the mask to be returned. + """Set the internal crop area of the mask to be returned. This impacts the returned mask from :attr:`mask` if the requested mask is required for different face centering than what has been stored. Parameters ---------- - source_offset: :class:`numpy.ndarray` + source_offset The (x, y) offset for the mask at its stored centering - target_offset: :class:`numpy.ndarray` + target_offset The (x, y) offset for the mask at the requested target centering - centering: str + centering The centering to set the sub crop area for. One of `"legacy"`, `"face"`. `"head"` - coverage_ratio: float, optional + coverage_ratio The coverage ratio to be applied to the target image. ``None`` for default (1.0). Default: ``None`` - y_offset: float, optional + y_offset Amount to additionally adjust the masks's offset along the y-axis. Default: 0.0 """ if centering == self.stored_centering and coverage_ratio == 1.0: @@ -307,43 +327,59 @@ def set_sub_crop(self, "sub_crop_size: %s, sub_crop_slices: %s", roi, coverage_ratio, self._sub_crop_size, self._sub_crop_slices) + @classmethod + def _matrix_2to3(cls, matrix: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: + """ Update a legacy (2x3) affine matrix to (3x3) + + Parameters + ---------- + matrix + The matrix that may require updating + + Returns + ------- + A 3x3 affine matrix + """ + if matrix.shape[0] == 3: + return matrix + return np.concatenate([matrix, np.array([[0., 0., 1.]], dtype=np.float32)]) + def _adjust_affine_matrix(self, mask_size: int, affine_matrix: np.ndarray) -> np.ndarray: - """ Adjust the affine matrix for the mask's storage size + """Adjust the affine matrix for the mask's storage size Parameters ---------- - mask_size: int + mask_size The original size of the mask. - affine_matrix: :class:`numpy.ndarray` + affine_matrix The affine matrix to transform the mask at original size to the parent frame. Returns ------- - affine_matrix: :class:`numpy,ndarray` + affine_matrix The affine matrix adjusted for the mask at its stored dimensions. """ zoom = self.stored_size / mask_size zoom_mat = np.array([[zoom, 0, 0.], [0, zoom, 0.]]) - adjust_mat = np.dot(zoom_mat, np.concatenate((affine_matrix, np.array([[0., 0., 1.]])))) + adjust_mat = np.dot(zoom_mat, self._matrix_2to3(affine_matrix)) logger.trace("storage_size: %s, mask_size: %s, zoom: %s, " # type:ignore[attr-defined] "original matrix: %s, adjusted_matrix: %s", self.stored_size, mask_size, zoom, affine_matrix.shape, adjust_mat.shape) return adjust_mat def to_dict(self, is_png=False) -> MaskAlignmentsFileDict: - """ Convert the mask to a dictionary for saving to an alignments file + """Convert the mask to a dictionary for saving to an alignments file Parameters ---------- - is_png: bool + is_png ``True`` if the dictionary is being created for storage in a png header otherwise ``False``. Default: ``False`` Returns ------- - dict: - The :class:`Mask` for saving to an alignments file. Contains the keys ``mask``, - ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` + The :class:`Mask` for saving to an alignments file. Contains the keys ``mask``, + ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` """ assert self._mask is not None affine_matrix = self.affine_matrix.tolist() if is_png else self.affine_matrix @@ -357,29 +393,29 @@ def to_dict(self, is_png=False) -> MaskAlignmentsFileDict: return retval def to_png_meta(self) -> MaskAlignmentsFileDict: - """ Convert the mask to a dictionary supported by png itxt headers. + """Convert the mask to a dictionary supported by png itxt headers. Returns ------- - dict: - The :class:`Mask` for saving to an alignments file. Contains the keys ``mask``, - ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` + The :class:`Mask` for saving to an alignments file. Contains the keys ``mask``, + ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` """ return self.to_dict(is_png=True) def from_dict(self, mask_dict: MaskAlignmentsFileDict) -> None: - """ Populates the :class:`Mask` from a dictionary loaded from an alignments file. + """Populates the :class:`Mask` from a dictionary loaded from an alignments file. Parameters ---------- - mask_dict: dict + mask_dict A dictionary stored in an alignments file containing the keys ``mask``, ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` """ self._mask = mask_dict["mask"] affine_matrix = mask_dict["affine_matrix"] - self._affine_matrix = (affine_matrix if isinstance(affine_matrix, np.ndarray) - else np.array(affine_matrix, dtype="float64")) + self._affine_matrix = self._matrix_2to3( + affine_matrix if isinstance(affine_matrix, np.ndarray) + else np.array(affine_matrix, dtype=np.float32)) self._interpolator = mask_dict["interpolator"] self.stored_size = mask_dict["stored_size"] centering = mask_dict.get("stored_centering") @@ -389,7 +425,7 @@ def from_dict(self, mask_dict: MaskAlignmentsFileDict) -> None: class LandmarksMask(Mask): - """ Create a single channel mask from aligned landmark points. + """Create a single channel mask from aligned landmark points. Landmarks masks are created on the fly, so the stored centering and size should be the same as the aligned face that the mask will be applied to. As the masks are created on the fly, blur + @@ -402,51 +438,146 @@ class LandmarksMask(Mask): Parameters ---------- - points : list[:class:`numpy.ndarray`] - A list of landmark points that correspond to the given storage_size to create - the mask. Each item in the list should be a :class:`numpy.ndarray` that a filled - convex polygon will be created from - storage_size : int, optional + area + The type of mask to obtain. `face` is a full face mask, `face_extended` is a face mask + that extends above the eyebrows. The others are masks for those specific areas + landmark_type + The type of landmarks that this mask is being created from + landmarks + The landmarks to generate the mask from + affine_matrix + The transformation matrix required to transform the mask to the original frame. + storage_size The size (in pixels) that the compressed mask should be stored at. Default: 128. - storage_centering : str, optional: + storage_centering The centering to store the mask at. One of `"legacy"`, `"face"`, `"head"`. Default: `"face"` - dilation : float, optional + dilation The amount of dilation to apply to the mask. as a percentage of the mask size. Default: 0.0 """ def __init__(self, - points: list[np.ndarray], + area: T.Literal["eye", "mouth", "face", "face_extended"], + landmark_type: LandmarkType, + landmarks: npt.NDArray[np.float32], + affine_matrix: npt.NDArray[np.float32], storage_size: int = 128, storage_centering: CenteringType = "face", dilation: float = 0.0) -> None: super().__init__(storage_size=storage_size, storage_centering=storage_centering) - self._points = points + self._area = area + self._landmark_type = landmark_type + self._lm_matrix = affine_matrix + self._points = self._get_points(landmarks) self.set_dilation(dilation) @property - def mask(self) -> np.ndarray: - """ :class:`numpy.ndarray`: Overrides the default mask property, creating the processed - mask at first call and compressing it. The decompressed mask is returned from this - property. """ + def mask(self) -> npt.NDArray[np.uint8]: + """Overrides the default mask property, creating the processed mask at first call and + compressing it. The decompressed mask is returned from this property.""" return self.stored_mask - def generate_mask(self, affine_matrix: np.ndarray, interpolator: int) -> None: - """ Generate the mask. + def _get_slices(self) -> list[slice] | list[list[slice]]: + """Obtain the slices that will extract the points for the given area and landmark type - Creates the mask applying any requested dilation and blurring and assigns compressed mask - to :attr:`_mask` + Returns + ------- + The slices required to extract landmark points for creating a mask + """ + parts = LANDMARK_PARTS if self._area in ("eye", "mouth") else LANDMARK_MASK_PARTS + if self._landmark_type not in parts: + raise FaceswapError( + f"Landmark based masks cannot be created for {self._landmark_type.name}") + + lm_parts = parts[self._landmark_type] + mapped = {"mouth": ["mouth_outer"], + "eye": ["right_eye", "left_eye"], + "face": list(lm_parts), + "face_extended": list(lm_parts)}[self._area] + + if not all(parts in lm_parts for parts in mapped): + raise FaceswapError( + f"Landmark based masks cannot be created for {self._landmark_type.name}") + + if self._area in ("eye", "mouth"): + retval: list[slice] | list[list[slice]] = [slice(*lm_parts[v][:2]) for v in mapped] + else: + retval = [[slice(*p) for p in T.cast(list[tuple[int, int]], lm_parts[v])] + for v in mapped] + logger.trace("[LM_MASK] area: '%s', slices: %s", # type:ignore[attr-defined] + self._area, retval) + return retval + + def _extend_face_landmarks(self, + landmarks: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: + """Adjust the top of the face mask to extend above eyebrows Parameters ---------- - affine_matrix: :class:`numpy.ndarray` - The transformation matrix required to transform the mask to the original frame. - interpolator, int: - The CV2 interpolator required to transform this mask to it's original frame + landmarks + The 68 point landmarks to be adjusted + + Returns + ------- + The landmarks with the upper eyebrow points adjusted + """ + assert self._landmark_type == LandmarkType.LM_2D_68 + # mid points between the side of face and eye point + ml_pnt = (landmarks[36] + landmarks[0]) // 2 + mr_pnt = (landmarks[16] + landmarks[45]) // 2 + + # mid points between the mid points and eye + ql_pnt = (landmarks[36] + ml_pnt) // 2 + qr_pnt = (landmarks[45] + mr_pnt) // 2 + + # Top of the eye arrays + bot_l = np.array((ql_pnt, landmarks[36], landmarks[37], landmarks[38], landmarks[39])) + bot_r = np.array((landmarks[42], landmarks[43], landmarks[44], landmarks[45], qr_pnt)) + + # Eyebrow arrays + top_l = landmarks[17:22] + top_r = landmarks[22:27] + + retval = landmarks.copy() + + # Adjust eyebrow arrays + retval[17:22] = top_l + ((top_l - bot_l) // 2) + retval[22:27] = top_r + ((top_r - bot_r) // 2) + return retval + + def _get_points(self, landmarks: npt.NDArray[np.float32]) -> list[npt.NDArray[np.int32]]: + """Obtain the points required to create the mask + + Parameters + ---------- + landmarks + The landmarks to obtain the points from + + Returns + ------- + The list of points for creating each section of the mask + """ + slices = self._get_slices() + if self._area == "face_extended": + landmarks = self._extend_face_landmarks(landmarks) + + if self._area in ("eye", "mouth"): + retval = [np.rint(landmarks[zone]).astype(np.int32) + for zone in T.cast(list[slice], slices)] + else: + retval = [np.concatenate([np.rint(landmarks[x]).astype(np.int32) for x in zone]) + for zone in T.cast(list[list[slice]], slices)] + return retval + + def generate_mask(self) -> None: + """Generate the mask. + + Creates the mask applying any requested dilation and blurring and assigns compressed mask + to :attr:`_mask` """ - mask = np.zeros((self.stored_size, self.stored_size, 1), dtype="float32") - for landmarks in self._points: - lms = np.rint(landmarks).astype("int") - cv2.fillConvexPoly(mask, cv2.convexHull(lms), [1.0], lineType=cv2.LINE_AA) + mask = np.zeros((self.stored_size, self.stored_size, 1), dtype=np.uint8) + for pts in self._points: + lms = np.rint(pts).astype("int") + cv2.fillConvexPoly(mask, cv2.convexHull(lms), [255], lineType=cv2.LINE_AA) if self._dilation[-1] is not None: self._dilate_mask(mask) if self._blur_kernel != 0 and self._blur_type is not None: @@ -454,30 +585,30 @@ def generate_mask(self, affine_matrix: np.ndarray, interpolator: int) -> None: mask, self._blur_kernel, passes=self._blur_passes).blurred - logger.trace("mask: (shape: %s, dtype: %s)", # type:ignore[attr-defined] + logger.trace("[LM_MASK] mask: (shape: %s, dtype: %s)", # type:ignore[attr-defined] mask.shape, mask.dtype) - self.add(mask, affine_matrix, interpolator) + self.add(mask, self._lm_matrix) class BlurMask(): - """ Factory class to return the correct blur object for requested blur type. + """Factory class to return the correct blur object for requested blur type. Works for square images only. Currently supports Gaussian and Normalized Box Filters. Parameters ---------- - blur_type: ["gaussian", "normalized"] + blur_type The type of blur to use - mask: :class:`numpy.ndarray` + mask The mask to apply the blur to - kernel: int or float + kernel Either the kernel size (in pixels) or the size of the kernel as a ratio of mask size - is_ratio: bool, optional + is_ratio Whether the given :attr:`kernel` parameter is a ratio or not. If ``True`` then the actual kernel size will be calculated from the given ratio and the mask size. If ``False`` then the kernel size will be set directly from the :attr:`kernel` parameter. Default: ``False`` - passes: int, optional + passes The number of passes to perform when blurring. Default: ``1`` Example @@ -495,7 +626,7 @@ def __init__(self, is_ratio: bool = False, passes: int = 1) -> None: logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] - self._blur_type = blur_type + self._blur_type: T.Literal["gaussian", "normalized"] = blur_type self._mask = mask self._passes = passes kernel_size = self._get_kernel_size(kernel, is_ratio) @@ -504,18 +635,19 @@ def __init__(self, @property def blurred(self) -> np.ndarray: - """ :class:`numpy.ndarray`: The final mask with blurring applied. """ + """The final mask with blurring applied.""" func = self._func_mapping[self._blur_type] kwargs = self._get_kwargs() blurred = self._mask for i in range(self._passes): - assert isinstance(kwargs["ksize"], tuple) - ksize = int(kwargs["ksize"][0]) + k_tup = kwargs["ksize"] + assert isinstance(k_tup, tuple) + k_size = int(k_tup[0]) logger.trace("Pass: %s, kernel_size: %s", # type:ignore[attr-defined] - i + 1, (ksize, ksize)) + i + 1, (k_size, k_size)) blurred = func(blurred, **kwargs) - ksize = int(round(ksize * self._multipass_factor)) - kwargs["ksize"] = self._get_kernel_tuple(ksize) + k_size = int(round(k_size * self._multipass_factor)) + kwargs["ksize"] = self._get_kernel_tuple(k_size) blurred = blurred[..., None] logger.trace("Returning blurred mask. Shape: %s", # type:ignore[attr-defined] blurred.shape) @@ -523,50 +655,49 @@ def blurred(self) -> np.ndarray: @property def _multipass_factor(self) -> float: - """ For multiple passes the kernel must be scaled down. This value is - different for box filter and gaussian """ + """For multiple passes the kernel must be scaled down. This value is + different for box filter and gaussian""" factor = {"gaussian": 0.8, "normalized": 0.5} return factor[self._blur_type] @property def _sigma(self) -> T.Literal[0]: - """ int: The Sigma for Gaussian Blur. Returns 0 to force calculation from kernel size. """ + """The Sigma for Gaussian Blur. Returns 0 to force calculation from kernel size.""" return 0 @property def _func_mapping(self) -> dict[T.Literal["gaussian", "normalized"], Callable]: - """ dict: :attr:`_blur_type` mapped to cv2 Function name. """ + """:attr:`_blur_type` mapped to cv2 Function name.""" return {"gaussian": cv2.GaussianBlur, "normalized": cv2.blur} @property def _kwarg_requirements(self) -> dict[T.Literal["gaussian", "normalized"], list[str]]: - """ dict: :attr:`_blur_type` mapped to cv2 Function required keyword arguments. """ + """:attr:`_blur_type` mapped to cv2 Function required keyword arguments. """ return {"gaussian": ['ksize', 'sigmaX'], "normalized": ['ksize']} @property def _kwarg_mapping(self) -> dict[str, int | tuple[int, int]]: - """ dict: cv2 function keyword arguments mapped to their parameters. """ + """cv2 function keyword arguments mapped to their parameters. """ return {"ksize": self._kernel_size, "sigmaX": self._sigma} def _get_kernel_size(self, kernel: int | float, is_ratio: bool) -> int: - """ Set the kernel size to absolute value. + """Set the kernel size to absolute value. If :attr:`is_ratio` is ``True`` then the kernel size is calculated from the given ratio and the :attr:`_mask` size, otherwise the given kernel size is just returned. Parameters ---------- - kernel: int or float + kernel Either the kernel size (in pixels) or the size of the kernel as a ratio of mask size - is_ratio: bool, optional + is_ratio Whether the given :attr:`kernel` parameter is a ratio or not. If ``True`` then the actual kernel size will be calculated from the given ratio and the mask size. If ``False`` then the kernel size will be set directly from the :attr:`kernel` parameter. Returns ------- - int - The size (in pixels) of the blur kernel + The size (in pixels) of the blur kernel """ if not is_ratio: return int(kernel) @@ -579,17 +710,16 @@ def _get_kernel_size(self, kernel: int | float, is_ratio: bool) -> int: @staticmethod def _get_kernel_tuple(kernel_size: int) -> tuple[int, int]: - """ Make sure kernel_size is odd and return it as a tuple. + """Make sure kernel_size is odd and return it as a tuple. Parameters ---------- - kernel_size: int + kernel_size The size in pixels of the blur kernel Returns ------- - tuple - The kernel size as a tuple of ('int', 'int') + The kernel size as a tuple of ('int', 'int') """ kernel_size += 1 if kernel_size % 2 == 0 else 0 retval = (kernel_size, kernel_size) @@ -597,9 +727,9 @@ def _get_kernel_tuple(kernel_size: int) -> tuple[int, int]: return retval def _get_kwargs(self) -> dict[str, int | tuple[int, int]]: - """ dict: the valid keyword arguments for the requested :attr:`_blur_type` """ - retval = {kword: self._kwarg_mapping[kword] - for kword in self._kwarg_requirements[self._blur_type]} + """the valid keyword arguments for the requested :attr:`_blur_type` """ + retval = {k_word: self._kwarg_mapping[k_word] + for k_word in self._kwarg_requirements[self._blur_type]} logger.trace("BlurMask kwargs: %s", retval) # type:ignore[attr-defined] return retval diff --git a/lib/align/aligned_utils.py b/lib/align/aligned_utils.py new file mode 100644 index 0000000000..3e264e9df0 --- /dev/null +++ b/lib/align/aligned_utils.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python3 +"""Tools for working with aligned faces and aligned masks""" +from __future__ import annotations + +import logging +import typing as T + +import cv2 +import numpy as np + +from lib.utils import get_module_objects + +from .constants import EXTRACT_RATIOS, LandmarkType, MAP_2D_68 + +if T.TYPE_CHECKING: + import numpy.typing as npt + +logger = logging.getLogger(__name__) + + +if T.TYPE_CHECKING: + from .constants import CenteringType + + +def get_adjusted_center(image_size: int, + source_offset: np.ndarray, + target_offset: np.ndarray, + source_centering: CenteringType, + y_offset: float) -> np.ndarray: + """Obtain the correct center of a face extracted image to translate between two different + extract centerings. + + Parameters + ---------- + image_size + The size of the image at the given :attr:`source_centering` + source_offset + The pose offset to translate a base extracted face to source centering + target_offset + The pose offset to translate a base extracted face to target centering + source_centering + The centering of the source image + y_offset + Amount to additionally offset the center of the image along the y-axis + + Returns + ------- + The center point of the image at the given size for the target centering + """ + source_size = image_size - (image_size * EXTRACT_RATIOS[source_centering]) + offset = target_offset - source_offset - [0., y_offset] + offset *= source_size + center = np.rint(offset + image_size / 2).astype("int32") + logger.trace( # type:ignore[attr-defined] + "image_size: %s, source_offset: %s, target_offset: %s, source_centering: '%s', " + "y_offset: %s, adjusted_offset: %s, center: %s", + image_size, source_offset, target_offset, source_centering, y_offset, offset, center) + return center + + +def get_centered_size(source_centering: CenteringType, + target_centering: CenteringType, + size: int, + coverage_ratio: float = 1.0) -> int: + """Obtain the size of a cropped face from an aligned image. + + Given an image of a certain dimensions, returns the dimensions of the sub-crop within that + image for the requested centering at the requested coverage ratio + + Notes + ----- + `"legacy"` places the nose in the center of the image (the original method for aligning). + `"face"` aligns for the nose to be in the center of the face (top to bottom) but the center + of the skull for left to right. `"head"` places the center in the middle of the skull in 3D + space. + + The ROI in relation to the source image is calculated by rounding the padding of one side + to the nearest integer then applying this padding to the center of the crop, to ensure that + any dimensions always have an even number of pixels. + + Parameters + ---------- + source_centering + The centering that the original image is aligned at + target_centering + The centering that the sub-crop size should be obtained for + size + The size of the source image to obtain the cropped size for + coverage_ratio + The coverage ratio to be applied to the target image. Default: `1.0` + + Returns + ------- + The pixel size of a sub-crop image from a full head aligned image with the given coverage ratio + """ + if source_centering == target_centering and coverage_ratio == 1.0: + src_size: float | int = size + retval = size + else: + src_size = size - (size * EXTRACT_RATIOS[source_centering]) + retval = 2 * int(np.rint((src_size / (1 - EXTRACT_RATIOS[target_centering]) + * coverage_ratio) / 2)) + logger.trace( # type:ignore[attr-defined] + "source_centering: %s, target_centering: %s, size: %s, coverage_ratio: %s, " + "source_size: %s, crop_size: %s", + source_centering, target_centering, size, coverage_ratio, src_size, retval) + return retval + + +def get_matrix_scaling(matrix: np.ndarray) -> tuple[int, int]: + """Given a matrix, return the cv2 Interpolation method and inverse interpolation method for + applying the matrix on an image. + + Parameters + ---------- + matrix + The transform matrix to return the interpolator for + + Returns + ------- + The interpolator and inverse interpolator for the given matrix. This will be (Cubic, Area) for + an upscale matrix and (Area, Cubic) for a downscale matrix + """ + x_scale = np.sqrt(matrix[0, 0] * matrix[0, 0] + matrix[0, 1] * matrix[0, 1]) + if x_scale == 0: + y_scale = 0. + else: + y_scale = (matrix[0, 0] * matrix[1, 1] - matrix[0, 1] * matrix[1, 0]) / x_scale + avg_scale = (x_scale + y_scale) * 0.5 + if avg_scale >= 1.: + interpolators = cv2.INTER_CUBIC, cv2.INTER_AREA + else: + interpolators = cv2.INTER_AREA, cv2.INTER_CUBIC + logger.trace("interpolator: %s, inverse interpolator: %s", # type:ignore[attr-defined] + interpolators[0], interpolators[1]) + return interpolators + + +def transform_image(image: np.ndarray, + matrix: np.ndarray, + size: int, + padding: int = 0) -> np.ndarray: + """Perform transformation on an image, applying the given size and padding to the matrix. + + Parameters + ---------- + image + The image to transform + matrix + The transformation matrix to apply to the image + size + The final size of the transformed image + padding + The amount of padding to apply to the final image. Default: `0` + + Returns + ------- + The transformed image + """ + logger.trace("image shape: %s, matrix: %s, size: %s. padding: %s", # type:ignore[attr-defined] + image.shape, matrix, size, padding) + # transform the matrix for size and padding + mat = matrix * (size - 2 * padding) + mat[:, 2] += padding + + # transform image + interpolators = get_matrix_scaling(mat) + retval = cv2.warpAffine(image, mat, (size, size), flags=interpolators[0]) + logger.trace("transformed matrix: %s, final image shape: %s", # type:ignore[attr-defined] + mat, image.shape) + return retval + + +def batch_transform(matrices: npt.NDArray[np.float32], + points: npt.NDArray[np.float32], + in_place: bool = False) -> npt.NDArray[np.float32]: + """Batch transform an array of (N, M, 2) points by the given (N, 3, 3) affine matrices + + Parameters + ---------- + matrices + The matrices to use to transform the points + points + The points to be transformed + in_place + ``True`` to directly transform the given points in place. ``False`` to return a new array + + Returns + ------- + The transformed points + """ + retval = points if in_place else np.empty_like(points) + linear = matrices[:, :2, :2] + translation = matrices[:, :2, 2] + retval[:] = points @ linear.transpose(0, 2, 1) + translation[:, None, :] + return retval + + +def batch_adjust_matrices(matrices: npt.NDArray[np.float32], + size: int, + padding: int, + reverse: bool = False) -> npt.NDArray[np.float32]: + """Adjust a batch of normalized (0, 1) matrices to the given size and padding, or the reverse + + Parameters + ---------- + matrices + The (N, 3, 3) or (N, 2, 3) matrices to adjust + size + The size to adjust the matrices to + padding + The padding to apply to each side of the adjusted matrices + reverse + ``True`` to adjust normalized matrices to the given size. ``False`` to adjust the given + sized matrices to normalized matrices. Default: ``False`` + + Returns + ------- + The adjusted matrices to the given size and padding if reverse is ``False`` or the normalized + matrix if reverse is ``True`` + """ + retval = matrices.copy() + scale = size - 2 * padding + if reverse: + retval[:, :2, 2] -= padding + retval[:, :2] /= scale + else: + retval[:, :2] *= scale + retval[:, :2, 2] += padding + return retval + + +@T.overload +def batch_sub_crop(images: npt.NDArray[np.uint8], + offsets: npt.NDArray[np.int32], + out_size: int, + base_grid: tuple[npt.NDArray[np.int32], npt.NDArray[np.int32]] | None = None + ) -> npt.NDArray[np.uint8]: + ... + + +@T.overload +def batch_sub_crop(images: npt.NDArray[np.float32], + offsets: npt.NDArray[np.int32], + out_size: int, + base_grid: tuple[npt.NDArray[np.int32], npt.NDArray[np.int32]] | None = None + ) -> npt.NDArray[np.float32]: + ... + + +def batch_sub_crop(images: npt.NDArray[np.uint8 | np.float32], + offsets: npt.NDArray[np.int32], + out_size: int, + base_grid: tuple[npt.NDArray[np.int32], npt.NDArray[np.int32]] | None = None + ) -> npt.NDArray[np.uint8 | np.float32]: + """Obtain aligned sub-crops from larger aligned images + + Parameters + ---------- + images + The (N, H, W, C) full size extracted images + offsets + The (N, x, y) offsets to shift the sub-crops. + out_size + The output size of the sub-crop + base_grid + Pre-computed base mesh grid used to build crop indices. Should be a tuple (yy, xx) where + each entry is a numpy array (int32) of shape (out_size, out_size) of row/column indices + starting at 0, Providing this avoids rebuilding the meshgrid on every call. + Default: ``None`` (calculate within the function) + """ + batch_size, height, width, channels = images.shape + + if base_grid is None: + yy, xx = np.meshgrid(np.arange(out_size, dtype="int32"), + np.arange(out_size, dtype="int32"), + indexing="ij") + else: + yy, xx = base_grid + + x_idx = xx[None] + offsets[:, 0, None, None] + y_idx = yy[None] + offsets[:, 1, None, None] + x_idx = np.clip(x_idx, 0, width - 1, out=x_idx) + y_idx = np.clip(y_idx, 0, height - 1, out=y_idx) + lin_idx = y_idx * width + x_idx + + flat = images.reshape(batch_size, height * width, channels) + gathered = np.take_along_axis(flat, + lin_idx.reshape(batch_size, -1)[..., None], + axis=1) + return gathered.reshape(batch_size, out_size, out_size, 3) + + +ImageDTypeT = T.TypeVar("ImageDTypeT", np.uint8, np.float32) + + +def batch_align(images: list[npt.NDArray[ImageDTypeT]], # pylint:disable=too-many-locals + image_ids: npt.NDArray[np.int32], + matrices: npt.NDArray[np.float32], + size: int, + fast_upscale: bool = True) -> npt.NDArray[ImageDTypeT]: + """Obtain a batch of aligned faces from the given images for the given matrices + + Parameters + ---------- + images + The full size images to obtain aligned faces from, either UINT8 or Float32 and 3 or 4 + channels. All images must be the same dtype and have the same number of channels + image_ids + The image id of each image in :attr:`image_ids` for each matrix in :attr:`matrices` + matrices + The adjustment matrices for taking the image patch from the frame for plugin input + size + The size of the returned aligned faces + fast_upscale + ``True`` to use cv2.INTER_LINEAR for upscale, ``False`` to use cv2.INTER_CUBIC. + Default: ``True`` + + Returns + ------- + Batch of aligned face patches of the same dtype as the input images + """ + channels = images[0].shape[-1] + dtype = images[0].dtype + assert all(i.shape[-1] == channels for i in images), ( + "All images must have the same number of channels") + assert all(i.dtype == dtype for i in images), "All images must have the same dtype" + assert np.any(matrices), "No matrices provided" + mats = matrices[:, :2, :] # Crop any Nx3x3 matrices to Nx2x3 + scales = np.hypot(matrices[..., 0, 0], matrices[..., 1, 0]) # Always same x/y scaling + upscale = cv2.INTER_LINEAR if fast_upscale else cv2.INTER_CUBIC + interpolations = np.where(scales > 1.0, cv2.INTER_LINEAR, upscale) + + dims: tuple[int, int] = (size, size) + retval = np.zeros((len(image_ids), *dims, channels), dtype=dtype) + + for idx, (image_id, mat, interpolation) in enumerate(zip(image_ids, mats, interpolations)): + cv2.warpAffine(images[image_id], mat, dims, dst=retval[idx], flags=interpolation) + return retval + + +def batch_resize(images: npt.NDArray[ImageDTypeT], size: int, fast_upscale: bool = True + ) -> npt.NDArray[ImageDTypeT]: + """Resize a batch of square images of the same dimensions to the given size + + Parameters + ---------- + images + The batch of square images to be resized + size + The required final size of the images + fast_upscale + ``True`` to use cv2.INTER_LINEAR for upscale, ``False`` to use cv2.INTER_CUBIC. + Default: ``True`` + + Returns + ------- + The resized images + """ + batch_size, height, width, channels = images.shape + assert height == width, "Images must be square" + if height == size: + return images + + dims: tuple[int, int] = (size, size) + retval = np.empty((batch_size, *dims, channels), dtype=images.dtype) + upscale = cv2.INTER_LINEAR if fast_upscale else cv2.INTER_CUBIC + interpolation = cv2.INTER_AREA if size < height else upscale + for idx, img in enumerate(images): + cv2.resize(img, dims, dst=retval[idx], interpolation=interpolation) + return retval + + +def points_to_68(landmarks: npt.NDArray[np.float32], + landmark_type: LandmarkType | None = None) -> npt.NDArray[np.float32]: + """Map the given non-68 point landmarks to 68 point landmarks + + Parameters + ---------- + landmarks + The non-68 point landmarks, either (N, P, 2) or (P, 2) + landmark_type + The type of landmarks that have been provided or ``None`` if to infer from the input + landmarks. Default: ``None`` + + Returns + ------- + The (N, 68, 2) or (68, 2) mapped landmarks + """ + is_batched = landmarks.ndim == 3 + if not is_batched: + landmarks = landmarks[None] + if landmark_type is None: + landmark_type = LandmarkType.from_shape(landmarks.shape[1:]) + assert landmark_type in MAP_2D_68, f"{landmark_type} not supported" + retval = landmarks[:, MAP_2D_68[landmark_type]] + if is_batched: + return retval + return retval[0] + + +__all__ = get_module_objects(__name__) diff --git a/lib/align/alignments.py b/lib/align/alignments.py index 9029dd3812..79cf054677 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -122,13 +122,13 @@ class PNGAlignments: def __repr__(self) -> str: """Pretty print for logging""" - params = {} + params: dict[str, T.Any] = {} for k, v in self.__dict__.items(): if k in ("landmarks_xy", "thumb"): params[k] = f"{type(v)}[{len(v)}]" continue if k == "identity": - params[k] = repr({n: f"{type(i)}[{len(i)}]" for n, i in v.items()}) + params[k] = {n: f"{type(i)}[{len(i)}]" for n, i in v.items()} continue params[k] = v s_params = ", ".join(f"{k}={v}" for k, v in params.items()) diff --git a/lib/align/constants.py b/lib/align/constants.py index 614c28062b..ef5be23466 100644 --- a/lib/align/constants.py +++ b/lib/align/constants.py @@ -17,7 +17,8 @@ class LandmarkType(Enum): LM_2D_4 = 1 LM_2D_51 = 2 LM_2D_68 = 3 - LM_3D_26 = 4 + LM_2D_98 = 4 + LM_3D_26 = 5 @classmethod def from_shape(cls, shape: tuple[int, int]) -> LandmarkType: @@ -40,6 +41,7 @@ def from_shape(cls, shape: tuple[int, int]) -> LandmarkType: shapes: dict[tuple[int, int], LandmarkType] = {(4, 2): cls.LM_2D_4, (51, 2): cls.LM_2D_51, (68, 2): cls.LM_2D_68, + (98, 2): cls.LM_2D_98, (26, 3): cls.LM_3D_26} if shape not in shapes: raise ValueError(f"The given shape {shape} is not valid. Valid shapes: {list(shapes)}") @@ -49,7 +51,6 @@ def from_shape(cls, shape: tuple[int, int]) -> LandmarkType: EXTRACT_RATIOS: dict[CenteringType, float] = {"legacy": 0.375, "face": 0.5, "head": 0.625} """The amount of padding applied to each centering type when generating aligned faces""" - MEAN_FACE: dict[LandmarkType, np.ndarray] = { LandmarkType.LM_2D_4: np.array( [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]), # Clockwise from TL @@ -105,7 +106,16 @@ def from_shape(cls, shape: tuple[int, int]) -> LandmarkType: "left_eye": (42, 48, True), "nose": (27, 36, False), "jaw": (0, 17, False), - "chin": (8, 11, False)}, + "chin": (7, 9, False)}, + LandmarkType.LM_2D_98: {"mouth_outer": (76, 88, True), + "mouth_inner": (88, 96, True), + "right_eyebrow": (33, 42, True), + "left_eyebrow": (42, 51, True), + "right_eye": (60, 68, True), + "left_eye": (68, 76, True), + "nose": (51, 60, False), + "jaw": (0, 33, False), + "chin": (14, 19, False)}, LandmarkType.LM_2D_4: {"face": (0, 4, True)} } """For each landmark type, stores the (start index, end index, is polygon) information about each @@ -119,10 +129,30 @@ def from_shape(cls, shape: tuple[int, int]) -> LandmarkType: "nose_ridge": [(19, 25), (8, 9)], "right_eye": [(17, 22), (27, 28), (31, 36), (8, 9)], "left_eye": [(22, 27), (27, 28), (31, 36), (8, 9)], - "nose": [(27, 31), (31, 36)]} + "nose": [(27, 31), (31, 36)]}, + LandmarkType.LM_2D_98: {"right_jaw": [(0, 17), (33, 34)], + "left_jaw": [(16, 33), (46, 47)], + "right_cheek": [(33, 36), (16, 17)], + "left_cheek": [(44, 47), (16, 17)], + "nose_ridge": [(35, 45), (16, 17)], + "right_eye": [(33, 38), (51, 52), (55, 60), (16, 17)], + "left_eye": [(42, 47), (51, 52), (55, 60), (16, 17)], + "nose": [(51, 55), (55, 60)]} + } """For each landmark type, stores the (start index, end index) information about each part of the face that makes a face mask.""" +MAP_2D_68 = { + LandmarkType.LM_2D_98: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, # Jaw + 33, 34, 35, 36, 37, # Right eyebrow + 42, 43, 44, 45, 46, # Left eyebrow + 51, 52, 53, 54, 55, 56, 57, 58, 59, # Nose + 60, 61, 63, 64, 65, 67, # Right eye + 68, 69, 71, 72, 73, 75, # Left eye + 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, # Outer mouth + 88, 89, 90, 91, 92, 93, 94, 95] # Inner mouth +} +"""Mapping of non 68 point 2D landmarks to 68 point landmarks""" __all__ = get_module_objects(__name__) diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index 87835257de..d4065a5e4b 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -1,5 +1,5 @@ #!/usr/bin python3 -""" Face and landmarks detection for faceswap.py """ +"""Face and landmarks detection for faceswap.py""" from __future__ import annotations import logging import os @@ -11,22 +11,22 @@ import numpy as np from lib.image import encode_image, read_image -from lib.logger import parse_class_init +from lib.logger import format_array, parse_class_init from lib.utils import FaceswapError, get_module_objects from .alignments import (Alignments, AlignmentFileDict, PNGHeaderAlignmentsDict, PNGHeaderDict, PNGHeaderSourceDict) from .aligned_face import AlignedFace -from .aligned_mask import LandmarksMask, Mask -from .constants import LANDMARK_PARTS +from . import aligned_mask if T.TYPE_CHECKING: + import numpy.typing as npt from .aligned_face import CenteringType logger = logging.getLogger(__name__) class DetectedFace(): # pylint:disable=too-many-instance-attributes - """ Detected face and landmark information + """Detected face and landmark information Holds information about a detected face, it's location in a source image and the face's 68 point landmarks. @@ -35,25 +35,25 @@ class DetectedFace(): # pylint:disable=too-many-instance-attributes Parameters ---------- - image : :class:`numpy.ndarray` | None, optional + image Original frame that holds this face. Optional (not required if just storing coordinates). Default: ``None`` - left : int + left The left most point (in pixels) of the face's bounding box as discovered in :mod:`plugins.extract.detect` - width : int + width The width (in pixels) of the face's bounding box as discovered in :mod:`plugins.extract.detect` - top : int + top The top most point (in pixels) of the face's bounding box as discovered in :mod:`plugins.extract.detect` - height : int + height The height (in pixels) of the face's bounding box as discovered in :mod:`plugins.extract.detect` - landmarks_xy : :class:`numpy.ndarray` + landmarks_xy The 68 point landmarks as discovered in :mod:`plugins.extract.align`. Should be an array of 68 `(x, y)` points of each of the landmark co-ordinates. - mask : dict[str: :class:`~lib.align.aligned_mask.Mask`] + mask The generated mask(s) for the face as generated in :mod:`plugins.extract.mask`. """ def __init__(self, @@ -63,76 +63,88 @@ def __init__(self, top: int | None = None, height: int | None = None, landmarks_xy: np.ndarray | None = None, - mask: dict[str, Mask] | None = None) -> None: + mask: dict[str, aligned_mask.Mask] | None = None, + identity: dict[str, np.ndarray] | None = None) -> None: logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] self.image = image - """ :class:`numpy.ndarray` | None : This is a generic image placeholder that should not be - relied on to be holding a particular image. It may hold the source frame that holds the - face, a cropped face or a scaled image depending on the method using this object. """ + """This is a generic image placeholder that should not be relied on to be holding a + particular image. It may hold the source frame that holds the face, a cropped face or + a scaled image depending on the method using this object.""" self.left = left - """ int : The left most point (in pixels) of the face's bounding box as discovered in - :mod:`plugins.extract.detect` """ + """The left most point (in pixels) of the face's bounding box as discovered in + :mod:`plugins.extract.detect`""" self.width = width - """ int : The width (in pixels) of the face's bounding box as discovered in - :mod:`plugins.extract.detect` """ + """The width (in pixels) of the face's bounding box as discovered in + :mod:`plugins.extract.detect`""" self.top = top - """ int : The top most point (in pixels) of the face's bounding box as discovered in - :mod:`plugins.extract.detect` """ + """The top most point (in pixels) of the face's bounding box as discovered in + :mod:`plugins.extract.detect`""" self.height = height - """ int : The height (in pixels) of the face's bounding box as discovered in - :mod:`plugins.extract.detect` """ + """The height (in pixels) of the face's bounding box as discovered in + :mod:`plugins.extract.detect`""" + self.mask = {} if mask is None else mask + """The generated mask(s) for the face as generated in :mod:`plugins.extract.mask`""" self._landmarks_xy = landmarks_xy - self._identity: dict[str, np.ndarray] = {} + self._identity: dict[str, np.ndarray] = {} if identity is None else identity self.thumbnail: np.ndarray | None = None - self.mask = {} if mask is None else mask - """ dict[str: :class:`~lib.align.aligned_mask.Mask`] : The generated mask(s) for the face - as generated in :mod:`plugins.extract.mask` """ - self._training_masks: tuple[bytes, tuple[int, int, int]] | None = None self._aligned: AlignedFace | None = None logger.trace("Initialized %s", self.__class__.__name__) # type:ignore[attr-defined] + def __repr__(self) -> str: + """Pretty print for logging""" + params = {k: v for k, v in self.__dict__.items() + if k in ("image", "left", "width", "top", + "height", "bottom", "_landmarks_xy", "mask")} + params = { + k[1:] if k.startswith("_") else k: format_array(v) if isinstance(v, np.ndarray) else v + for k, v in params.items() + } + s_params = ", ".join(f"{k}={v}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + @property def aligned(self) -> AlignedFace: - """ :class:`~lib.align.aligned_face.AlignedFace` : The aligned face connected to this - detected face. """ + """The aligned face connected to this detected face.""" assert self._aligned is not None return self._aligned + @property + def has_landmarks(self) -> bool: + """``True`` if this object contains landmarks""" + return self._landmarks_xy is not None + @property def landmarks_xy(self) -> np.ndarray: - """ :class:`numpy.ndarray` : The aligned face connected to this detected face. """ + """The frame space 2D landmarks for this detected face.""" assert self._landmarks_xy is not None return self._landmarks_xy @property def right(self) -> int: - """int : Right point (in pixels) of face detection bounding box within the parent image """ + """Right point (in pixels) of face detection bounding box within the parent image""" assert self.left is not None and self.width is not None return self.left + self.width @property def bottom(self) -> int: - """int : Bottom point (in pixels) of face detection bounding box within the parent - image """ + """Bottom point (in pixels) of face detection bounding box within the parent image""" assert self.top is not None and self.height is not None return self.top + self.height @property def identity(self) -> dict[str, np.ndarray]: - """ dict[str, :class:`numpy.ndarray`] : Identity mechanism as key, identity embedding as - value. """ + """Identity mechanism as key, identity embedding as value""" return self._identity def add_mask(self, name: str, - mask: np.ndarray, + mask: npt.NDArray[np.uint8], affine_matrix: np.ndarray, - interpolator: int, storage_size: int = 128, storage_centering: CenteringType = "face") -> None: - """ Add a :class:`~lib.align.aligned_mask.Mask` to this detected face + """Add a :class:`~lib.align.aligned_mask.Mask` to this detected face The mask should be the original output from :mod:`plugins.extract.mask` If a mask with this name already exists it will be overwritten by the given @@ -140,130 +152,100 @@ def add_mask(self, Parameters ---------- - name : str + name The name of the mask as defined by the :attr:`plugins.extract.mask._base.name` parameter. - mask : :class:`numpy.ndarray` - The mask that is to be added as output from :mod:`plugins.extract.mask` - It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` - affine_matrix : :class:`numpy.ndarray` + mask + The mask that is to be added as output from :mod:`plugins.extract.mask` as a UINT8 + image + affine_matrix The transformation matrix required to transform the mask to the original frame. - interpolator : int - The CV2 interpolator required to transform this mask to it's original frame. - storage_size : int, optional + storage_size The size the mask is to be stored at. Default: 128 - storage_centering : Literal["face", "head", "legacy"], optional: + storage_centering The centering to store the mask at. One of `"legacy"`, `"face"`, `"head"`. Default: `"face"` """ logger.trace("name: '%s', mask shape: %s, affine_matrix: %s, " # type:ignore[attr-defined] - "interpolator: %s, storage_size: %s, storage_centering: %s)", name, - mask.shape, affine_matrix, interpolator, storage_size, storage_centering) - fsmask = Mask(storage_size=storage_size, storage_centering=storage_centering) - fsmask.add(mask, affine_matrix, interpolator) - self.mask[name] = fsmask + "storage_size: %s, storage_centering: %s)", name, + mask.shape, affine_matrix, storage_size, storage_centering) + fs_mask = aligned_mask.Mask(storage_size=storage_size, storage_centering=storage_centering) + fs_mask.add(mask, affine_matrix) + self.mask[name] = fs_mask def add_landmarks_xy(self, landmarks: np.ndarray) -> None: - """ Add landmarks to the detected face object. If landmarks alread exist, they will be + """Add landmarks to the detected face object. If landmarks already exist, they will be overwritten. Parameters ---------- - landmarks : :class:`numpy.ndarray` + landmarks The 68 point face landmarks to add for the face """ logger.trace("landmarks shape: '%s'", landmarks.shape) # type:ignore[attr-defined] self._landmarks_xy = landmarks def add_identity(self, name: str, embedding: np.ndarray, ) -> None: - """ Add an identity embedding to this detected face. If an identity already exists for the + """Add an identity embedding to this detected face. If an identity already exists for the given :attr:`name` it will be overwritten Parameters ---------- - name : str + name The name of the mechanism that calculated the identity - embedding : :class:`numpy.ndarray` + embedding The identity embedding """ logger.trace("name: '%s', embedding shape: %s", # type:ignore[attr-defined] name, embedding.shape) - assert name == "vggface2" - assert embedding.shape[0] == 512 self._identity[name] = embedding def clear_all_identities(self) -> None: - """ Remove all stored identity embeddings """ + """Remove all stored identity embeddings """ self._identity = {} def get_landmark_mask(self, - area: T.Literal["eye", "face", "mouth"], + area: T.Literal["eye", "mouth", "face", "face_extended"], blur_kernel: int, dilation: float) -> np.ndarray: - """ Add a :class:`L~lib.align.aligned_mask.LandmarksMask` to this detected face + """Obtain a :class:`~lib.align.aligned_mask.LandmarksMask` for this face - Landmark based masks are generated from face Aligned Face landmark points. An aligned - face must be loaded. As the data is coming from the already aligned face, no further mask - cropping is required. + Landmark based masks are generated from Aligned Face landmark points. An aligned face must + be loaded. As the data is coming from the already aligned face, no further mask cropping is + required. Parameters ---------- - area : Literal["face", "mouth", "eye"] - The type of mask to obtain. `face` is a full face mask the others are masks for those - specific areas - blur_kernel : int + area + The type of mask to obtain. `face` is a full face mask, `face_extended` is a face mask + that extends above the eyebrows. The others are masks for those specific areas + blur_kernel The size of the kernel for blurring the mask edges - dilation : float + dilation The amount of dilation to apply to the mask. as a percentage of the mask size Returns ------- - :class:`numpy.ndarray` - The generated landmarks mask for the selected area - - Raises - ------ - :class:`lib.utils.FaceSwapError` - If the aligned face does not contain the correct landmarks to generate a landmark mask + The generated landmarks mask for the selected area """ - # TODO Face mask generation from landmarks - logger.trace("area: %s, dilation: %s", area, dilation) # type:ignore[attr-defined] - - lm_type = self.aligned.landmark_type - if lm_type not in LANDMARK_PARTS: - raise FaceswapError(f"Landmark based masks cannot be created for {lm_type.name}") - - lm_parts = LANDMARK_PARTS[self.aligned.landmark_type] - mapped = {"mouth": ["mouth_outer"], "eye": ["right_eye", "left_eye"]} - if not all(part in lm_parts for parts in mapped.values() for part in parts): - raise FaceswapError(f"Landmark based masks cannot be created for {lm_type.name}") - - areas = {key: [slice(*lm_parts[v][:2]) for v in val]for key, val in mapped.items()} - points = [self.aligned.landmarks[zone] for zone in areas[area]] - - lmmask = LandmarksMask(points, - storage_size=self.aligned.size, - storage_centering=self.aligned.centering, - dilation=dilation) - lmmask.set_blur_and_threshold(blur_kernel=blur_kernel) - lmmask.generate_mask( - self.aligned.adjusted_matrix, - self.aligned.interpolators[1]) - return lmmask.mask + mask = self.aligned.get_landmark_mask(area, dilation) + mask.set_blur_and_threshold(blur_kernel=blur_kernel) + mask.generate_mask() + return mask.mask def store_training_masks(self, masks: list[np.ndarray | None], delete_masks: bool = False) -> None: - """ Concatenate and compress the given training masks and store for retrieval. + """Concatenate and compress the given training masks and store for retrieval. Parameters ---------- - masks : list[:class:`numpy.ndarray` | None] + masks : list[ | None] A list of training mask. Must be all be uint-8 3D arrays of the same size in 0-255 range - delete_masks : bool, optional + delete_masks ``True`` to delete any of the :class:`~lib.align.aligned_mask.Mask` objects owned by - this detected face. Use to free up unrequired memory usage. Default: ``False`` + this detected face. Use to free up non-required memory usage. Default: ``False`` """ if delete_masks: del self.mask @@ -273,16 +255,15 @@ def store_training_masks(self, if not valid: return combined = np.concatenate(valid, axis=-1) - self._training_masks = (compress(combined), combined.shape) + self._training_masks = (compress(combined), T.cast(tuple[int, int, int], combined.shape)) def get_training_masks(self) -> np.ndarray | None: - """ Obtain the decompressed combined training masks. + """Obtain the decompressed combined training masks. Returns ------- - :class:`numpy.ndarray` - A 3D array containing the decompressed training masks as uint8 in 0-255 range if - training masks are present otherwise ``None`` + A 3D array containing the decompressed training masks as uint8 in 0-255 range if + training masks are present otherwise ``None`` """ if not self._training_masks: return None @@ -290,49 +271,53 @@ def get_training_masks(self) -> np.ndarray | None: dtype="uint8").reshape(self._training_masks[1]) def to_alignment(self) -> AlignmentFileDict: - """ Return the detected face formatted for an alignments file + """ Return the detected face formatted for an alignments file - returns + Returns ------- - alignment : :class:`lib.align.alignments.AlignmentFileDict` - The alignment dict will be returned with the keys ``x``, ``w``, ``y``, ``h``, - ``landmarks_xy``, ``mask``. The additional key ``thumb`` will be provided if the - detected face object contains a thumbnail. + The alignment dict will be returned with the keys ``x``, ``w``, ``y``, ``h``, + ``landmarks_xy``, ``mask``. The additional key ``thumb`` will be provided if the + detected face object contains a thumbnail. """ if (self.left is None or self.width is None or self.top is None or self.height is None): raise AssertionError("Some detected face variables have not been initialized") + thumb = None if self.thumbnail is None else self.thumbnail.tolist() alignment = AlignmentFileDict(x=self.left, w=self.width, y=self.top, h=self.height, - landmarks_xy=self.landmarks_xy, + landmarks_xy=self.landmarks_xy.tolist(), mask={name: mask.to_dict() for name, mask in self.mask.items()}, identity={k: v.tolist() for k, v in self._identity.items()}, - thumb=self.thumbnail) + thumb=thumb) logger.trace("Returning: %s", alignment) # type:ignore[attr-defined] return alignment def from_alignment(self, alignment: AlignmentFileDict, - image: np.ndarray | None = None, with_thumb: bool = False) -> None: - """ Set the attributes of this class from an alignments file and optionally load the face + image: np.ndarray | None = None, with_thumb: bool = False) -> T.Self: + """Set the attributes of this class from an alignments file and optionally load the face into the ``image`` attribute. Parameters ---------- - alignment : :class:`lib.align.alignments.AlignmentFileDict` + alignment A dictionary entry for a face from an alignments file containing the keys ``x``, ``w``, ``y``, ``h``, ``landmarks_xy``. Optionally the key ``thumb`` will be provided. This is for use in the manual tool and contains the compressed jpg thumbnail of the face to be allocated to :attr:`thumbnail. Optionally the key ``mask`` will be provided, but legacy alignments will not have this key. - image : :class:`numpy.ndarray`, optional + image If an image is passed in, then the ``image`` attribute will be set to the cropped face based on the passed in bounding box co-ordinates - with_thumb : bool, optional + with_thumb Whether to load the jpg thumbnail into the detected face object, if provided. Default: ``False`` + + Returns + ------- + This DetectedFace object populated by the incoming alignment dict """ logger.trace("Creating from alignment: (alignment: %s," # type:ignore[attr-defined] @@ -344,35 +329,40 @@ def from_alignment(self, alignment: AlignmentFileDict, landmarks = alignment["landmarks_xy"] if not isinstance(landmarks, np.ndarray): landmarks = np.array(landmarks, dtype="float32") - self._identity = {T.cast(T.Literal["vggface2"], k): np.array(v, dtype="float32") + self._identity = {k: np.array(v, dtype="float32") for k, v in alignment.get("identity", {}).items()} self._landmarks_xy = landmarks.copy() if with_thumb: # Thumbnails currently only used for manual tool. Default to None - self.thumbnail = alignment.get("thumb") + thumb = alignment.get("thumb") + if isinstance(thumb, list): + self.thumbnail = np.array(thumb, dtype=np.uint8) + # Manual tool and legacy alignments will not have a mask self._aligned = None if alignment.get("mask", None) is not None: self.mask = {} for name, mask_dict in alignment["mask"].items(): - self.mask[name] = Mask() + if name in ("components", "extended"): + continue # Skip legacy stored LM based masks + self.mask[name] = aligned_mask.Mask() self.mask[name].from_dict(mask_dict) if image is not None and image.any(): self._image_to_face(image) logger.trace("Created from alignment: (left: %s, width: %s, " # type:ignore[attr-defined] "top: %s, height: %s, landmarks: %s, mask: %s)", self.left, self.width, self.top, self.height, self.landmarks_xy, self.mask) + return self def to_png_meta(self) -> PNGHeaderAlignmentsDict: - """ Return the detected face formatted for insertion into a png itxt header. + """Return the detected face formatted for insertion into a png itxt header. Returns ------- - :class:`lib.align.alignments.PNGHeaderAlignmentsDict` - The alignments dict will be returned with the keys ``x``, ``w``, ``y``, ``h``, - ``landmarks_xy`` and ``mask`` + The alignments dict will be returned with the keys ``x``, ``w``, ``y``, ``h``, + ``landmarks_xy`` and ``mask`` """ if (self.left is None or self.width is None or self.top is None or self.height is None): raise AssertionError("Some detected face variables have not been initialized") @@ -386,12 +376,12 @@ def to_png_meta(self) -> PNGHeaderAlignmentsDict: identity={k: v.tolist() for k, v in self._identity.items()}) return alignment - def from_png_meta(self, alignment: PNGHeaderAlignmentsDict) -> None: - """ Set the attributes of this class from alignments stored in a png exif header. + def from_png_meta(self, alignment: PNGHeaderAlignmentsDict) -> T.Self: + """Set the attributes of this class from alignments stored in a png exif header. Parameters ---------- - alignment : :class:`lib.align.alignments.PNGHeaderAlignmentsDict` + alignment A dictionary entry for a face from alignments stored in a png exif header containing the keys ``x``, ``w``, ``y``, ``h``, ``landmarks_xy`` and ``mask`` """ @@ -402,23 +392,25 @@ def from_png_meta(self, alignment: PNGHeaderAlignmentsDict) -> None: self._landmarks_xy = np.array(alignment["landmarks_xy"], dtype="float32") self.mask = {} for name, mask_dict in alignment["mask"].items(): - self.mask[name] = Mask() + if name in ("components", "extended"): + continue # Skip legacy stored LM based masks + self.mask[name] = aligned_mask.Mask() self.mask[name].from_dict(mask_dict) self._identity = {} for key, val in alignment.get("identity", {}).items(): - assert key in ["vggface2"] - self._identity[T.cast(T.Literal["vggface2"], key)] = np.array(val, dtype="float32") + self._identity[key] = np.array(val, dtype="float32") logger.trace("Created from png exif header: (left: %s, " # type:ignore[attr-defined] "width: %s, top: %s height: %s, landmarks: %s, mask: %s, identity: %s)", self.left, self.width, self.top, self.height, self.landmarks_xy, self.mask, {k: v.shape for k, v in self._identity.items()}) + return self def _image_to_face(self, image: np.ndarray) -> None: - """ set self.image to be the cropped face from detected bounding box + """set self.image to be the cropped face from detected bounding box Parameters ---------- - image : class:`numpy.ndarray` + image The image to be cropped """ logger.trace("Cropping face from image") # type:ignore[attr-defined] @@ -436,7 +428,7 @@ def load_aligned(self, force: bool = False, is_aligned: bool = False, is_legacy: bool = False) -> None: - """ Align a face from a given image. + """Align a face from a given image. Aligning a face is a relatively expensive task and is not required for all uses of the :class:`~lib.align.DetectedFace` object, so call this function explicitly to @@ -448,32 +440,32 @@ def load_aligned(self, Parameters ---------- - image : :class:`numpy.ndarray` | None, optional + image The image that contains the face to be aligned. Default: ``None`` - size : int, optional + size The size of the output face in pixels. Default: `256` - dtype : str, optional + dtype Optionally set a ``dtype`` for the final face to be formatted in. Default: ``None`` - centering : Literal["legacy", "face", "head"], optional + centering : Literal["legacy", "face", "head"] The type of extracted face that should be loaded. "legacy" places the nose in the center of the image (the original method for aligning). "face" aligns for the nose to be in the center of the face (top to bottom) but the center of the skull for left to right. "head" aligns for the center of the skull (in 3D space) being the center of the extracted image, with the crop holding the full head. Default: `"head"` - coverage_ratio : float, optional + coverage_ratio The amount of the aligned image to return. A ratio of 1.0 will return the full contents of the aligned image. A ratio of 0.5 will return an image of the given size, but will crop to the central 50%% of the image. Default: `1.0` - y_offset : float, optional + y_offset The amount to adjust the aligned face along the y_axis in -1. to 1. range. Default: `0.0` - force : bool, optional + force Force an update of the aligned face, even if it is already loaded. Default: ``False`` - is_aligned : bool, optional + is_aligned Indicates that the :attr:`image` is an aligned face rather than a frame. Default: ``False`` - is_legacy : bool, optional + is_legacy Only used if `is_aligned` is ``True``. ``True`` indicates that the aligned image being loaded is a legacy extracted face rather than a current head extracted face @@ -505,24 +497,23 @@ def load_aligned(self, def update_legacy_png_header(filename: str, alignments: Alignments ) -> PNGHeaderDict | None: - """ Update a legacy extracted face from pre v2.1 alignments by placing the alignment data for + """Update a legacy extracted face from pre v2.1 alignments by placing the alignment data for the face in the png exif header for the given filename with the given alignment data. If the given file is not a .png then a png is created and the original file is removed Parameters ---------- - filename : str + filename The image file to update - alignments : :class:`lib.align.alignments.Alignments` + alignments The alignments data the contains the information to store in the image header. This must be a v2.0 or less alignments file as later versions no longer store the face hash (not required) Returns ------- - :class:`lib.align.alignments.PNGHeaderDict` - The metadata that has been applied to the given image + The metadata that has been applied to the given image """ if alignments.version > 2.0: raise FaceswapError("The faces being passed in do not correspond to the given Alignments " diff --git a/lib/align/pose.py b/lib/align/pose.py index 0553847e28..30d7a6c6c7 100644 --- a/lib/align/pose.py +++ b/lib/align/pose.py @@ -11,6 +11,7 @@ from lib.logger import parse_class_init from lib.utils import get_module_objects +from .aligned_utils import points_to_68 from .constants import MEAN_FACE, LandmarkType logger = logging.getLogger(__name__) @@ -95,12 +96,15 @@ def __init__(self, landmarks: np.ndarray, landmarks_type: LandmarkType) -> None: logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] self._xyz_2d: np.ndarray | None = None - if landmarks_type != LandmarkType.LM_2D_68: - self._log_once("Pose estimation is not available for non-68 point landmarks. Pose and " - "offset data will all be returned as the incorrect value of '0'") + if landmarks_type not in (LandmarkType.LM_2D_68, LandmarkType.LM_2D_98): + self._log_once(f"Pose estimation is not available for {landmarks_type} landmarks. " + "Pose and offset data will all be returned as the incorrect value " + "of '0'",) self._landmarks_type = landmarks_type self._camera_matrix = get_camera_matrix() - self._rotation, self._translation = self._solve_pnp(landmarks) + lms = landmarks if landmarks_type in (LandmarkType.LM_2D_4, + LandmarkType.LM_2D_68) else points_to_68(landmarks) + self._rotation, self._translation = self._solve_pnp(lms) self._offset = self._get_offset() self._pitch_yaw_roll: tuple[float, float, float] = (0, 0, 0) logger.trace("Initialized %s", self.__class__.__name__) # type:ignore[attr-defined] @@ -176,7 +180,7 @@ def _solve_pnp(self, landmarks: np.ndarray) -> tuple[np.ndarray, np.ndarray]: translation The solved translation vector """ - if self._landmarks_type != LandmarkType.LM_2D_68: + if self._landmarks_type not in (LandmarkType.LM_2D_68, LandmarkType.LM_2D_98): points: np.ndarray = np.empty([]) rotation = np.array([[0.0], [0.0], [0.0]]) translation = rotation.copy() @@ -201,7 +205,7 @@ def _get_offset(self) -> dict[CenteringType, npt.NDArray[np.float32]]: """ legacy = np.array([0.0, 0.0], dtype="float32") offset: dict[CenteringType, npt.NDArray[np.float32]] = {} - if self._landmarks_type != LandmarkType.LM_2D_68: + if self._landmarks_type not in (LandmarkType.LM_2D_68, LandmarkType.LM_2D_98): offset["legacy"] = legacy offset["face"] = np.array([0.0, 0.0], dtype="float32") offset["head"] = np.array([0.0, 0.0], dtype="float32") diff --git a/lib/align/thumbnails.py b/lib/align/thumbnails.py index 06439b50f1..9927d90c1e 100644 --- a/lib/align/thumbnails.py +++ b/lib/align/thumbnails.py @@ -78,7 +78,7 @@ def add_thumbnail(self, frame: str, face_index: int, thumb: np.ndarray) -> None: """ logger.debug("frame: %s, face_index: %s, thumb shape: %s thumb dtype: %s", frame, face_index, thumb.shape, thumb.dtype) - self._alignments_dict[frame]["faces"][face_index]["thumb"] = thumb + self._alignments_dict[frame]["faces"][face_index]["thumb"] = thumb.tolist() __all__ = get_module_objects(__name__) diff --git a/lib/align/updater.py b/lib/align/updater.py index 9e98bdc8a6..8c347ab2b9 100644 --- a/lib/align/updater.py +++ b/lib/align/updater.py @@ -223,7 +223,7 @@ def test(self) -> bool: ``True`` if any landmarks or thumbnails are a numpy array otherwise ``False`` """ return any(isinstance(face["landmarks_xy"], np.ndarray) - or isinstance(face["thumb"], np.ndarray) + or isinstance(face.get("thumb"), np.ndarray) for val in self._alignments.data.values() for face in val["faces"]) diff --git a/lib/cli/args.py b/lib/cli/args.py index 10002045f8..39bcf42f2b 100644 --- a/lib/cli/args.py +++ b/lib/cli/args.py @@ -202,9 +202,10 @@ def _get_global_arguments() -> list[dict[str, T.Any]]: "action": FileFullPaths, "filetypes": "ini", "type": str, + "dest": "config_file", "group": _("Global Options"), "help": _( - "Optionally overide the saved config with the path to a custom config file.")}) + "Optionally override the saved config with the path to a custom config file.")}) global_args.append({ "opts": ("-L", "--loglevel"), "type": str.upper, diff --git a/lib/cli/args_extract_convert.py b/lib/cli/args_extract_convert.py index 5c0b3ce903..f96b69c452 100644 --- a/lib/cli/args_extract_convert.py +++ b/lib/cli/args_extract_convert.py @@ -2,6 +2,7 @@ """ The Command Line Argument options for extracting and converting with faceswap.py """ import gettext import typing as T +from argparse import SUPPRESS from lib.utils import get_module_objects from lib.utils import get_backend @@ -48,13 +49,6 @@ def get_argument_list() -> list[dict[str, T.Any]]: "Input directory or video. Either a directory containing the image files you wish " "to process or path to a video file. NB: This should be the source video/frames " "NOT the source faces.")}) - argument_list.append({ - "opts": ("-o", "--output-dir"), - "action": DirFullPaths, - "dest": "output_dir", - "required": True, - "group": _("Data"), - "help": _("Output directory. This is where the converted files will be saved.")}) argument_list.append({ "opts": ("-p", "--alignments"), "action": FileFullPaths, @@ -102,10 +96,18 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: default_detector = "mtcnn" default_aligner = "cv2-dnn" else: - default_detector = "s3fd" - default_aligner = "fan" + default_detector = "retinaface" + default_aligner = "hrnet" argument_list: list[dict[str, T.Any]] = [] + argument_list.append({ + "opts": ("-o", "--output-dir"), + "action": DirFullPaths, + "dest": "output_dir", + "required": False, + "group": _("Data"), + "help": _("Output directory. Location to save extracted faces. If not provided then " + "don't save faces and just create an alignments file")}) argument_list.append({ "opts": ("-b", "--batch-mode"), "action": "store_true", @@ -113,7 +115,7 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "default": False, "group": _("Data"), "help": _( - "R|If selected then the input_dir should be a parent folder containing multiple " + "If selected then the input_dir should be a parent folder containing multiple " "videos and/or folders of images you wish to extract from. The faces will be " "output to separate sub-folders in the output_dir.")}) argument_list.append({ @@ -121,42 +123,44 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "action": Radio, "type": str.lower, "default": default_detector, - "choices": PluginLoader.get_available_extractors("detect"), - "group": _("Plugins"), + "choices": PluginLoader.get_available_extractors("detect") + ["file"], + "group": _("Detect"), "help": _( "R|Detector to use. Some of these have configurable settings in " "'/config/extract.ini' or 'Settings > Configure Extract 'Plugins':" "\nL|cv2-dnn: A CPU only extractor which is the least reliable and least resource " - "intensive. Use this if not using a GPU and time is important." - "\nL|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources than " - "other GPU detectors but can often return more false positives." - "\nL|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " + "intensive. Use this only as a last resort. Both MTCNN and RetinaFace have " + "variants that will perform better on CPU." + "\nL|mtcnn: Average detector. Fast on CPU, faster on GPU. Uses fewer resources " + "than other GPU detectors but can often return more false positives or misses " + "faces." + "\nL|retinaface: Good detector. Faster and lighter than S3FD but of similar " + "quality. A ResNet and MobileNet version are available (configurable in Detect " + "settings). The MobileNet version is light enough to run on CPU." + "\nL|s3fd: Good detector. Slow on CPU, faster on GPU. Can detect more faces and " "fewer false positives than other GPU detectors, but is a lot more resource " - "intensive." - "\nL|external: Import a face detection bounding box from a json file. (" - "configurable in Detect settings)")}) + "intensive.")}) argument_list.append({ "opts": ("-A", "--aligner"), "action": Radio, "type": str.lower, "default": default_aligner, - "choices": PluginLoader.get_available_extractors("align"), - "group": _("Plugins"), + "choices": PluginLoader.get_available_extractors("align") + ["file"], + "group": _("Align"), "help": _( "R|Aligner to use." "\nL|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, but " "less accurate. Only use this if not using a GPU and time is important." - "\nL|fan: Best aligner. Fast on GPU, slow on CPU." - "\nL|external: Import 68 point 2D landmarks or an aligned bounding box from a " - "json file. (configurable in Align settings)")}) + "\nL|fan: Good aligner. Fast on GPU, slow on CPU." + "\nL|hrnet: Best aligner. Faster and more performant than FAN. Trained on a " + "custom set of fully rotated faces. Fast on GPU, slow on CPU")}) argument_list.append({ "opts": ("-M", "--masker"), "action": MultiOption, "type": str.lower, "nargs": "+", - "choices": [mask for mask in PluginLoader.get_available_extractors("mask") - if mask not in ("components", "extended")], - "group": _("Plugins"), + "choices": PluginLoader.get_available_extractors("mask"), + "group": _("Mask"), "help": _( "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -188,6 +192,57 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "positioning of landmark locations. A convex hull is constructed around the " "exterior of the landmarks and the mask is extended upwards onto the forehead." "\n(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)")}) + argument_list.append({ + "opts": ("-I", "--identity"), + "action": MultiOption, + "type": str.lower, + "nargs": "+", + "choices": PluginLoader.get_available_extractors("identity"), + "group": _("Identity"), + "help": _( + "R|Obtain and store face identity encodings. Slows down extract a little but will " + "save time if using 'sort by face'. Required for face filtering." + "\nL|t-face: An InsightFace ResNet based model with a lighter and heavier variant " + "(configurable in settings)." + "\nL|vggface2: An older and lighter, but fairly reliable plugin based on the VGG " + "Network.")}) + argument_list.append({ + "opts": ("-m", "--min-size"), + "action": Slider, + "min_max": (0, 100), + "rounding": 1, + "type": int, + "dest": "min_size", + "default": 0, + "group": _("Detect"), + "help": _( + "Filters out detections below this percentage of the shortest side of the frame " + "along the face detection box's longest edge. (eg: a value of 10 will filter " + "out faces smaller than 72px from a 720p image). 0 for disabled.")}) + argument_list.append({ + "opts": ("-x", "--max-size"), + "action": Slider, + "min_max": (0, 500), + "rounding": 1, + "type": int, + "dest": "max_size", + "default": 0, + "group": _("Detect"), + "help": _( + "Filters out detections above this percentage of the shortest side of the frame " + "along the face detection box's longest edge. (eg: a value of 200 will filter " + "out faces larger than 1440px from a 720p image). 0 for disabled.")}) + argument_list.append({ + "opts": ("-r", "--rotate-images"), + "type": str, + "dest": "rotate_images", + "default": None, + "group": _("Detect"), + "help": _( + "If a face isn't found, rotate the images to try to find a face. Can find more " + "faces at the cost of extraction speed. Pass in a single number to use increments " + "of that size up to 360, or pass in a list of numbers to enumerate exactly what " + "angles to check.")}) argument_list.append({ "opts": ("-O", "--normalization"), "action": Radio, @@ -195,7 +250,7 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "dest": "normalization", "default": "none", "choices": ["none", "clahe", "hist", "mean"], - "group": _("Plugins"), + "group": _("Align"), "help": _( "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -213,7 +268,7 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "type": int, "dest": "re_feed", "default": 0, - "group": _("Plugins"), + "group": _("Align"), "help": _( "The number of times to re-feed the detected face into the aligner. Each time the " "face is re-fed into the aligner the bounding box is adjusted by a small amount. " @@ -226,42 +281,21 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "action": "store_true", "dest": "re_align", "default": False, - "group": _("Plugins"), + "group": _("Align"), "help": _( "Re-feed the initially found aligned face through the aligner. Can help produce " "better alignments for faces that are rotated beyond 45 degrees in the frame or " "are at extreme angles. Slows down extraction.")}) argument_list.append({ - "opts": ("-r", "--rotate-images"), - "type": str, - "dest": "rotate_images", - "default": None, - "group": _("Plugins"), - "help": _( - "If a face isn't found, rotate the images to try to find a face. Can find more " - "faces at the cost of extraction speed. Pass in a single number to use increments " - "of that size up to 360, or pass in a list of numbers to enumerate exactly what " - "angles to check.")}) - argument_list.append({ - "opts": ("-I", "--identity"), + "opts": ("-g", "--align-filters"), "action": "store_true", + "dest": "align_filters", "default": False, - "group": _("Plugins"), - "help": _( - "Obtain and store face identity encodings from VGGFace2. Slows down extract a " - "little, but will save time if using 'sort by face'")}) - argument_list.append({ - "opts": ("-m", "--min-size"), - "action": Slider, - "min_max": (0, 1080), - "rounding": 20, - "type": int, - "dest": "min_size", - "default": 0, - "group": _("Face Processing"), + "group": _("Align"), "help": _( - "Filters out faces detected below this size. Length, in pixels across the " - "diagonal of the bounding box. Set to 0 for off")}) + "Enable aligner filters. This allows the filtering out of faces based on certain " + "statistics and characteristics. Configurable in extract settings. Slows down " + "extraction.")}) argument_list.append({ "opts": ("-n", "--nfilter"), "action": DirOrFilesFullPaths, @@ -269,7 +303,7 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "dest": "nfilter", "default": None, "nargs": "+", - "group": _("Face Processing"), + "group": _("Identity"), "help": _( "Optionally filter out people who you do not wish to extract by passing in images " "of those people. Should be a small variety of images at different angles and in " @@ -282,7 +316,7 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "dest": "filter", "default": None, "nargs": "+", - "group": _("Face Processing"), + "group": _("Identity"), "help": _( "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in different " @@ -296,7 +330,7 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "type": float, "dest": "ref_threshold", "default": 0.60, - "group": _("Face Processing"), + "group": _("Identity"), "help": _( "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter.")}) @@ -325,6 +359,25 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "Extract every 'nth' frame. This option will skip frames when extracting faces. " "For example a value of 1 will extract faces from every frame, a value of 10 will " "extract faces from every 10th frame.")}) + argument_list.append({ + "opts": ("-u", "--min-scale"), + "action": Slider, + "min_max": (0, 200), + "rounding": 1, + "type": int, + "dest": "min_scale", + "default": 0, + "group": _("output"), + "help": _( + "Only output faces that have been resized by this percent or more to meet the " + "specified extract size (`-z`, `--size`). Useful for excluding low-res images " + "from a training set. Set to 0 to output all faces. This only impacts faces that " + "are output to disk. All detected faces will still be saved to the alignments " + "file regardless of what is set here. Eg: For an extract size of 512px, A setting " + "of 50 will only output faces that have been resized from 256px or above. Setting " + "to 100 will only output faces that have been resized from 512px or above. A " + "setting of 200 will only output faces that have been downscaled from 1024px or " + "above.")}) argument_list.append({ "opts": ("-v", "--save-interval"), "action": Slider, @@ -346,17 +399,25 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "dest": "debug_landmarks", "default": False, "group": _("output"), - "help": _("Draw landmarks on the ouput faces for debugging purposes.")}) + "help": _("Draw landmarks on the output faces for debugging purposes.")}) argument_list.append({ - "opts": ("-P", "--singleprocess"), + "opts": ("-c", "--compile"), "action": "store_true", "default": False, - "backend": ("nvidia", "rocm", "apple_silicon"), "group": _("settings"), - "help": _( - "Don't run extraction in parallel. Will run each part of the extraction process " - "separately (one after the other) rather than all at the same time. Useful if " - "VRAM is at a premium.")}) + "help": _("Compile any PyTorch models. This will lead to slower start up time, but " + "faster processing. For large amounts of data this is worth enabling. For " + "smaller extractions it is not.")}) + argument_list.append({ + "opts": ("-k", "--benchmark"), + "action": "store_true", + "default": False, + "backend": ("nvidia", "rocm"), + "group": _("settings"), + "help": _("Benchmark the chosen extract plugins for optimal batch sizes. The " + "benchmark profiler can be configured in settings. Note: This will take a " + "long time, so should be used to find optimal settings for a given plugin " + "combination and type of dataset rather than being used every time.")}) argument_list.append({ "opts": ("-s", "--skip-existing"), "action": "store_true", @@ -372,13 +433,20 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "default": False, "group": _("settings"), "help": _("Skip frames that already have detected faces in the alignments file")}) + # Deprecated options argument_list.append({ "opts": ("-K", "--skip-saving-faces"), "action": "store_true", - "dest": "skip_saving_faces", + "dest": "depr_output-dir_K_o", + "required": False, + "help": SUPPRESS}) + argument_list.append({ + "opts": ("-P", "--singleprocess"), + "action": "store_true", "default": False, - "group": _("settings"), - "help": _("Skip saving the detected faces to disk. Just create an alignments file")}) + "dest": "depr_removed_P_singleprocess", + "required": False, + "help": SUPPRESS}) return argument_list @@ -414,6 +482,13 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: """ argument_list: list[dict[str, T.Any]] = [] + argument_list.append({ + "opts": ("-o", "--output-dir"), + "action": DirFullPaths, + "dest": "output_dir", + "required": True, + "group": _("Data"), + "help": _("Output directory. This is where the converted files will be saved.")}) argument_list.append({ "opts": ("-r", "--reference-video"), "action": FileFullPaths, @@ -463,9 +538,11 @@ def get_optional_arguments() -> list[dict[str, T.Any]]: "type": str.lower, "dest": "mask_type", "default": "extended", - "choices": PluginLoader.get_available_extractors("mask", - add_none=True, - extend_plugin=True) + ["predicted"], + "choices": list(sorted( + ["extended", "components"] + PluginLoader.get_available_extractors( + "mask", + add_none=True, + extend_plugin=True))) + ["predicted"], "group": _("Plugins"), "help": _( "R|Masker to use. NB: The mask you require must exist within the alignments file. " diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index b96b2cc13f..0d1070f310 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Launches the correct script with the given Command Line Arguments """ +"""Launches the correct script with the given Command Line Arguments""" from __future__ import annotations import logging import os @@ -22,22 +22,22 @@ class ScriptExecutor(): - """ Loads the relevant script modules and executes the script. + """Loads the relevant script modules and executes the script. - This class is initialized in each of the argparsers for the relevant + This class is initialized in each of the arg parsers for the relevant command, then execute script is called within their set_default function. Parameters ---------- - command: str + command The faceswap command that is being executed """ def __init__(self, command: str) -> None: self._command = command.lower() def _set_environment_variables(self) -> None: - """ Set the number of threads that numexpr can use. """ + """Set the number of threads that numexpr can use. """ # Allocate a decent number of threads to numexpr to suppress warnings cpu_count = os.cpu_count() allocate = max(1, cpu_count - cpu_count // 3 if cpu_count is not None else 1) @@ -47,18 +47,18 @@ def _set_environment_variables(self) -> None: os.environ.pop("OMP_NUM_THREADS") logger.debug("Setting NUMEXPR_MAX_THREADS to %s", allocate) os.environ["NUMEXPR_MAX_THREADS"] = str(allocate) + os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1" if get_backend() == "apple_silicon": # Let apple put unsupported ops on the CPU logger.debug("Enabling unsupported Ops on CPU for Apple Silicon") os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1" def _import_script(self) -> Callable: - """ Imports the relevant script as indicated by :attr:`_command` from the scripts folder. + """Imports the relevant script as indicated by :attr:`_command` from the scripts folder. Returns ------- - class: Faceswap Script - The uninitialized script from the faceswap scripts folder. + The uninitialized script from the faceswap scripts folder. """ self._set_environment_variables() self._test_for_torch_version() @@ -71,7 +71,7 @@ def _import_script(self) -> Callable: return script def _test_for_torch_version(self) -> None: - """ Check that the required PyTorch version is installed. + """Check that the required PyTorch version is installed. Raises ------ @@ -101,12 +101,12 @@ def _test_for_torch_version(self) -> None: @classmethod def _handle_import_error(cls, message: str) -> None: - """ Display the error message to the console and wait for user input to dismiss it, if + """Display the error message to the console and wait for user input to dismiss it, if running GUI under Windows, otherwise use standard error handling. Parameters ---------- - message: str + message The error message to display """ if "gui" in sys.argv and platform.system() == "Windows": @@ -118,7 +118,7 @@ def _handle_import_error(cls, message: str) -> None: raise FaceswapError(message) def _test_for_gui(self) -> None: - """ If running the gui, performs check to ensure necessary prerequisites are present. """ + """If running the gui, performs check to ensure necessary prerequisites are present.""" if self._command != "gui": return self._test_tkinter() @@ -126,7 +126,7 @@ def _test_for_gui(self) -> None: @classmethod def _test_tkinter(cls) -> None: - """ If the user is running the GUI, test whether the tkinter app is available on their + """If the user is running the GUI, test whether the tkinter app is available on their machine. If not exit gracefully. This avoids having to import every tkinter function within the GUI in a wrapper and @@ -154,14 +154,14 @@ def _test_tkinter(cls) -> None: @classmethod def _check_display(cls) -> None: - """ Check whether there is a display to output the GUI to. + """Check whether there is a display to output the GUI to. If running on Windows then it is assumed that we are not running in headless mode Raises ------ FaceswapError - If a DISPLAY environmental cannot be found + If a DISPLAY environmental variable cannot be found """ if not os.environ.get("DISPLAY", None) and os.name != "nt": if platform.system() == "Darwin": @@ -170,14 +170,14 @@ def _check_display(cls) -> None: raise FaceswapError("No display detected. GUI mode has been disabled.") def execute_script(self, arguments: argparse.Namespace) -> None: - """ Performs final set up and launches the requested :attr:`_command` with the given + """Performs final set up and launches the requested :attr:`_command` with the given command line arguments. Monitors for errors and attempts to shut down the process cleanly on exit. Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The command line arguments to be passed to the executing script. """ is_gui = hasattr(arguments, "redirect_gui") and arguments.redirect_gui @@ -210,7 +210,7 @@ def execute_script(self, arguments: argparse.Namespace) -> None: safe_shutdown(got_error=not success) def _configure_backend(self, arguments: argparse.Namespace) -> None: - """ Configure the backend. + """Configure the backend. Exclude any GPUs for use by Faceswap when requested. @@ -218,7 +218,7 @@ def _configure_backend(self, arguments: argparse.Namespace) -> None: Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The command line arguments passed to Faceswap. """ if not hasattr(arguments, "exclude_gpus"): diff --git a/lib/config/config.py b/lib/config/config.py index 16de36811e..dfd8cf5e9c 100644 --- a/lib/config/config.py +++ b/lib/config/config.py @@ -24,19 +24,19 @@ class FaceswapConfig(): """ Config Items """ - def __init__(self, configfile: str | None = None) -> None: + def __init__(self, config_file: str | None = None) -> None: """ Init Configuration Parameters ---------- - configfile : str, optional + config_file : str, optional Optional path to a config file. ``None`` for default location. Default: ``None`` """ logger.debug("Initializing: %s", self.__class__.__name__) self._plugin_group = self._get_plugin_group() - self._ini = ConfigFile(self._plugin_group, ini_path=configfile) + self._ini = ConfigFile(self._plugin_group, ini_path=config_file) self.sections: dict[str, ConfigSection] = {} """ dict[str, :class:`ConfigSection`] : The Faceswap config sections and options """ @@ -132,9 +132,8 @@ def _defaults_from_plugin(self, plugin_folder: str) -> None: default_files = [fname for fname in filenames if fname.endswith("_defaults.py")] if not default_files: continue - base_path = os.path.dirname(os.path.realpath(sys.argv[0])) # Can't use replace as there is a bug on some Windows installs that lowers some paths - import_path = ".".join(full_path_split(dirpath[len(base_path):])[1:]) + import_path = ".".join(full_path_split(dirpath[len(PROJECT_ROOT):])[1:]) plugin_type = import_path.rsplit(".", maxsplit=1)[-1] for filename in default_files: self._import_defaults_from_module(filename, import_path, plugin_type) @@ -142,7 +141,7 @@ def _defaults_from_plugin(self, plugin_folder: str) -> None: def set_defaults(self, helptext: str = "") -> None: """ Override for plugin specific config defaults. - This method should always be overriden to add the help text for the global plugin group. + This method should always be overridden to add the help text for the global plugin group. If `helptext` is not provided, then it is assumed that there is no global section for this plugin group. @@ -257,10 +256,10 @@ def generate_configs(force: bool = False) -> None: config_file = os.path.join(configs_path, f"{plugin_group}.ini") if not os.path.exists(config_file) or force: - modname = os.path.splitext(filename)[0] - modpath = os.path.join(dirpath.replace(PROJECT_ROOT, ""), - modname)[1:].replace(os.sep, ".") - mod = import_module(modpath) + mod_name = os.path.splitext(filename)[0] + mod_path = os.path.join(dirpath.replace(PROJECT_ROOT, ""), + mod_name)[1:].replace(os.sep, ".") + mod = import_module(mod_path) for obj in vars(mod).values(): if (inspect.isclass(obj) and issubclass(obj, FaceswapConfig) diff --git a/lib/convert.py b/lib/convert.py index 5b41a7817c..6c7b5c42b7 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -69,7 +69,7 @@ class Converter(): # pylint:disable=too-many-instance-attributes arguments: :class:`argparse.Namespace` The arguments that were passed to the convert process as generated from Faceswap's command line arguments - configfile: str, optional + config_file: str, optional Optional location of custom configuration ``ini`` file. If ``None`` then use the default config location. Default: ``None`` """ @@ -80,18 +80,18 @@ def __init__(self, draw_transparent: bool, pre_encode: Callable | None, arguments: Namespace, - configfile: str | None = None) -> None: + config_file: str | None = None) -> None: logger.debug("Initializing %s: (output_size: %s, coverage_ratio: %s, centering: %s, " - "draw_transparent: %s, pre_encode: %s, arguments: %s, configfile: %s)", + "draw_transparent: %s, pre_encode: %s, arguments: %s, config_file: %s)", self.__class__.__name__, output_size, coverage_ratio, centering, - draw_transparent, pre_encode, arguments, configfile) + draw_transparent, pre_encode, arguments, config_file) self._output_size = output_size self._coverage_ratio = coverage_ratio self._centering: CenteringType = centering self._draw_transparent = draw_transparent self._writer_pre_encode = pre_encode self._args = arguments - self._configfile = configfile + self._config_file = config_file self._scale = arguments.output_scale / 100 self._face_scale = 1.0 - arguments.face_scale / 100. @@ -138,18 +138,18 @@ def _load_plugins(self, disable_logging: bool = False) -> None: self._args.mask_type, self._output_size, self._coverage_ratio, - configfile=self._configfile) + config_file=self._config_file) if self._args.color_adjustment is not None: self._adjustments.color = PluginLoader.get_converter("color", self._args.color_adjustment, disable_logging=disable_logging)( - configfile=self._configfile) + config_file=self._config_file) sharpening = PluginLoader.get_converter("scaling", "sharpen", disable_logging=disable_logging)( - configfile=self._configfile) + config_file=self._config_file) self._adjustments.sharpening = sharpening logger.debug("Loaded plugins: %s", self._adjustments) @@ -280,8 +280,7 @@ def _patch_image(self, predicted: ConvertItem) -> np.ndarray | list[bytes]: def _warp_to_frame(self, reference: AlignedFace, face: np.ndarray, - frame: np.ndarray, - multiple_faces: bool) -> None: + frame: np.ndarray) -> None: """ Perform affine transformation to place a face patch onto the given frame. Affine is done in place on the `frame` array, so this function does not return a value @@ -294,19 +293,24 @@ def _warp_to_frame(self, The swapped face patch frame: :class:`numpy.ndarray` The frame to affine the face onto - multiple_faces: bool - Controls the border mode to use. Uses BORDER_CONSTANT if there is only 1 face in - the image, otherwise uses the inferior BORDER_TRANSPARENT """ # Warp face with the mask mat = self._get_warp_matrix(reference.adjusted_matrix, face.shape[0]) - border = cv2.BORDER_TRANSPARENT if multiple_faces else cv2.BORDER_CONSTANT + frame_face = np.zeros_like(frame) cv2.warpAffine(face, mat, (frame.shape[1], frame.shape[0]), - frame, + frame_face, flags=cv2.WARP_INVERSE_MAP | reference.interpolators[1], - borderMode=border) + borderMode=cv2.BORDER_CONSTANT) + background = frame[..., :3] + alpha = frame[..., 3:4] + foreground, mask = np.split(frame_face, # pylint:disable=unbalanced-tuple-unpacking + (3, ), + axis=-1) + background *= (1.0 - mask) + background += (foreground * mask) + alpha += mask * (1.0 - alpha) # Merge masks def _get_new_image(self, predicted: ConvertItem, @@ -353,9 +357,7 @@ def _get_new_image(self, predicted_mask) if self._full_frame_output: - self._warp_to_frame(reference_face, - new_face, placeholder, - len(predicted.swapped_faces) > 1) + self._warp_to_frame(reference_face, new_face, placeholder,) else: assert faces is not None faces.append(new_face) @@ -441,7 +443,14 @@ def _get_image_mask(self, """ logger.trace("Getting mask. Image shape: %s", new_face.shape) # type: ignore[attr-defined] mask_centering: CenteringType - if self._args.mask_type not in ("none", "predicted"): + lm_mask = None + if self._args.mask_type in ("components", "extended"): + mask_centering = reference_face.centering + m_type: T.Literal["face", "face_extended"] = ( + "face" if self._args.mask_type == "components" else "face_extended" + ) + lm_mask = reference_face.get_landmark_mask(m_type, dilation=0.0) + elif self._args.mask_type not in ("none", "predicted"): mask_centering = detected_face.mask[self._args.mask_type].stored_centering else: mask_centering = "face" # Unused but requires a valid value @@ -450,6 +459,7 @@ def _get_image_mask(self, reference_face.pose.offset[mask_centering], reference_face.pose.offset[self._centering], self._centering, + landmarks_mask=lm_mask, predicted_mask=predicted_mask) logger.trace("Adding mask to alpha channel") # type: ignore[attr-defined] new_face = np.concatenate((new_face, mask), -1) @@ -477,7 +487,7 @@ def _post_warp_adjustments(self, background: np.ndarray, new_image: np.ndarray) if self._draw_transparent: frame = new_image - else: + else: # This next code is kinda redundant, but if sharpening is performed it is needed foreground, mask = np.split(new_image, # pylint:disable=unbalanced-tuple-unpacking (3, ), axis=-1) @@ -507,10 +517,10 @@ def _scale_image(self, frame: np.ndarray) -> np.ndarray: if self._scale == 1: return frame logger.trace("source frame: %s", frame.shape) # type: ignore[attr-defined] - interp = cv2.INTER_CUBIC if self._scale > 1 else cv2.INTER_AREA + interpolation = cv2.INTER_CUBIC if self._scale > 1 else cv2.INTER_AREA dims = (round((frame.shape[1] / 2 * self._scale) * 2), round((frame.shape[0] / 2 * self._scale) * 2)) - frame = cv2.resize(frame, dims, interpolation=interp) + frame = cv2.resize(frame, dims, interpolation=interpolation) logger.trace("resized frame: %s", frame.shape) # type: ignore[attr-defined] np.clip(frame, 0.0, 1.0, out=frame) return frame diff --git a/lib/gui/gui_config.py b/lib/gui/gui_config.py index a752e9ab0c..86009f0ad6 100644 --- a/lib/gui/gui_config.py +++ b/lib/gui/gui_config.py @@ -20,7 +20,7 @@ def set_defaults(self, helptext="") -> None: """ Set the default values for config """ logger.debug("Setting defaults") super().set_defaults( - helptext="Faceswap GUI Options.\nConfigure the appearance and behaviour of the GUI") + helptext="Faceswap GUI Options.\nConfigure the appearance and behavior of the GUI") # Font choices cannot be added until tkinter has been launched logger.debug("Adding font list from tkinter") self.sections["global"].options["font"].choices = get_clean_fonts() @@ -38,7 +38,7 @@ def get_commands() -> list[str]: tools_path = os.path.join(PROJECT_ROOT, "tools") commands = [os.path.splitext(item)[0] for item in os.listdir(command_path) if os.path.splitext(item)[1] == ".py" - and os.path.splitext(item)[0] not in ("gui", "fsmedia") + and os.path.splitext(item)[0] not in ("gui", "fs_media") and not os.path.splitext(item)[0].startswith("_")] tools = [os.path.splitext(item)[0] for item in os.listdir(tools_path) if os.path.splitext(item)[1] == ".py" @@ -57,9 +57,9 @@ def get_clean_fonts() -> list[str]: list[str]: A list of valid fonts for the system """ - fmanager = font_manager.FontManager() + f_manager = font_manager.FontManager() fonts: dict[str, dict[str, bool]] = {} - for fnt in fmanager.ttflist: + for fnt in f_manager.ttflist: if str(fnt.weight) in ("400", "normal", "regular"): fonts.setdefault(fnt.name, {})["regular"] = True if str(fnt.weight) in ("700", "bold"): @@ -151,7 +151,7 @@ def get_clean_fonts() -> list[str]: timeout = ConfigItem( datatype=int, default=120, - group="behaviour", + group="behavior", info="Training can take some time to save and shutdown. Set the timeout " "in seconds before giving up and force quitting.", min_max=(10, 600), @@ -161,7 +161,7 @@ def get_clean_fonts() -> list[str]: auto_load_model_stats = ConfigItem( datatype=bool, default=True, - group="behaviour", + group="behavior", info="Auto load model statistics into the Analysis tab when selecting a model " "in Train or Convert tabs.") @@ -175,7 +175,7 @@ def load_config(config_file: str | None = None) -> None: Path to a custom .ini configuration file to load. Default: ``None`` (use default configuration file) """ - _Config(configfile=config_file) + _Config(config_file=config_file) __all__ = get_module_objects(__name__) diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 226b6be462..09894062dc 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -360,7 +360,7 @@ def _update(self) -> None: success = False if self._check_for_updates(): success = self._do_update() - update_deps.main(is_gui=True) + update_deps.update(is_gui=True) if success: logger.info("Please restart Faceswap to complete the update.") self.root.config(cursor="") diff --git a/lib/gui/utils/misc.py b/lib/gui/utils/misc.py index c559e329cc..19f7c4bfe0 100644 --- a/lib/gui/utils/misc.py +++ b/lib/gui/utils/misc.py @@ -52,7 +52,7 @@ def __init__(self, daemon) super().__init__(target=target, name=name, args=args, kwargs=kwargs, daemon=daemon) - self.err: _ErrorType = None + self.err: _ErrorType | None = None self._widget = widget self._config = get_config() self._config.set_cursor_busy(widget=self._widget) diff --git a/lib/image.py b/lib/image.py index 5eb3e178bf..84c3a80769 100644 --- a/lib/image.py +++ b/lib/image.py @@ -13,6 +13,8 @@ from ast import literal_eval from bisect import bisect from concurrent import futures +from queue import Empty as QueueEmpty, Full as QueueFull, Queue +from threading import current_thread, main_thread from zlib import crc32 import cv2 @@ -21,13 +23,15 @@ import numpy as np from tqdm import tqdm -from lib.multithreading import MultiThread -from lib.queue_manager import queue_manager, QueueEmpty +from lib.logger import parse_class_init +from lib.multithreading import FSThread from lib.utils import (convert_to_secs, FaceswapError, get_image_paths, get_module_objects, VIDEO_EXTENSIONS) if T.TYPE_CHECKING: + import numpy.typing as npt + from lib.multithreading import ErrorState from lib.align.alignments import PNGHeaderDict logger = logging.getLogger(__name__) @@ -107,10 +111,10 @@ def get_frame_info(self, frame_pts=None, keyframes=None): frame_pts = [] key_frames = [] last_update = 0 - pbar = tqdm(desc="Analyzing Video", - leave=False, - total=int(self._meta["duration"]), - unit="secs") + p_bar = tqdm(desc="Analyzing Video", + leave=False, + total=int(self._meta["duration"]), + unit="secs") while True: output = process.stdout.readline().strip() if output == "" and process.poll() is not None: @@ -131,9 +135,9 @@ def get_frame_info(self, frame_pts=None, keyframes=None): # Floating points make TQDM display poorly, so only update on full # second increments continue - pbar.update(int(pts_time) - last_update) + p_bar.update(int(pts_time) - last_update) last_update = int(pts_time) - pbar.close() + p_bar.close() return_code = process.poll() frame_count = len(frame_pts) logger.debug("Return code: %s, frame_pts: %s, keyframes: %s, frame_count: %s", @@ -153,7 +157,7 @@ def _previous_keyframe_info(self, index=0): return prev_pts_time, prev_keyframe def _initialize(self, index=0): # noqa:C901 - """ Replace ImageIO _initialize with a version that explictly uses keyframes. + """ Replace ImageIO _initialize with a version that explicitly uses keyframes. Notes ----- @@ -167,18 +171,18 @@ def _initialize(self, index=0): # noqa:C901 if self._read_gen is not None: self._read_gen.close() - iargs = [] - oargs = [] + i_args = [] + o_args = [] skip_frames = 0 # Create input args - iargs += self._arg_input_params + i_args += self._arg_input_params if self.request._video: - iargs += ["-f", CAM_FORMAT] # noqa + i_args += ["-f", CAM_FORMAT] # noqa if self._arg_pixelformat: - iargs += ["-pix_fmt", self._arg_pixelformat] + i_args += ["-pix_fmt", self._arg_pixelformat] if self._arg_size: - iargs += ["-s", self._arg_size] + i_args += ["-s", self._arg_size] elif index > 0: # re-initialize / seek # Note: only works if we initialized earlier, and now have meta. Some info here: # https://trac.ffmpeg.org/wiki/Seeking @@ -206,17 +210,17 @@ def _initialize(self, index=0): # noqa:C901 # We used to have this epsilon earlier, when we did not use # the slow seek. I don't think we need it anymore. # epsilon = -1 / self._meta["fps"] * 0.1 - iargs += ["-ss", "%.06f" % (seek_fast)] + i_args += ["-ss", "%.06f" % (seek_fast)] if not self.use_patch: - oargs += ["-ss", "%.06f" % (seek_slow)] + o_args += ["-ss", "%.06f" % (seek_slow)] # Output args, for writing to pipe if self._arg_size: - oargs += ["-s", self._arg_size] + o_args += ["-s", self._arg_size] if self.request.kwargs.get("fps", None): fps = float(self.request.kwargs["fps"]) - oargs += ["-r", "%.02f" % fps] - oargs += self._arg_output_params + o_args += ["-r", "%.02f" % fps] + o_args += self._arg_output_params # Get pixelformat and bytes per pixel pix_fmt = self._pix_fmt @@ -225,7 +229,7 @@ def _initialize(self, index=0): # noqa:C901 # Create generator rf = self._ffmpeg_api.read_frames self._read_gen = rf( - self._filename, pix_fmt, bpp, input_params=iargs, output_params=oargs + self._filename, pix_fmt, bpp, input_params=i_args, output_params=o_args ) # Read meta data. This start the generator (and ffmpeg subprocess) @@ -265,30 +269,30 @@ def _initialize(self, index=0): # noqa:C901 @T.overload def read_image(filename: str, raise_error: T.Literal[False] = False, - with_metadata: T.Literal[False] = False) -> np.ndarray | None: ... + with_metadata: T.Literal[False] = False) -> npt.NDArray[np.uint8] | None: ... @T.overload def read_image(filename: str, raise_error: T.Literal[True], - with_metadata: T.Literal[False] = False) -> np.ndarray: ... + with_metadata: T.Literal[False] = False) -> npt.NDArray[np.uint8]: ... @T.overload def read_image(filename: str, raise_error: T.Literal[False] = False, *, - with_metadata: T.Literal[True]) -> tuple[np.ndarray, PNGHeaderDict]: ... + with_metadata: T.Literal[True]) -> tuple[npt.NDArray[np.uint8], PNGHeaderDict]: ... @T.overload def read_image(filename: str, raise_error: T.Literal[True], - with_metadata: T.Literal[True]) -> np.ndarray: ... + with_metadata: T.Literal[True]) -> npt.NDArray[np.uint8]: ... -def read_image(filename: str, raise_error: bool = False, with_metadata: bool = False - ) -> np.ndarray | None | tuple[np.ndarray, PNGHeaderDict]: +def read_image(filename: str, raise_error: bool = False, with_metadata: bool = False # noqa[C901] + ) -> np.ndarray | None | tuple[npt.NDArray[np.uint8], PNGHeaderDict]: """ Read an image file from a file location. Extends the functionality of :func:`cv2.imread()` by ensuring that an image was actually @@ -305,15 +309,13 @@ def read_image(filename: str, raise_error: bool = False, with_metadata: bool = F raised. Default: ``False`` with_metadata : bool, optional Only returns a value if the images loaded are extracted Faceswap faces. If ``True`` then - returns the Faceswap metadata stored with in a Face images .png exif header. + returns the Faceswap metadata stored with in a Face images .png EXIF header. Default: ``False`` Returns ------- - Returns - ------- - batch : :class:`numpy.ndarray` - The image in `BGR` channel order for the corresponding :attr:`filename` + image : :class:`numpy.ndarray` + The image in `BGR` channel order as UINT8 for the corresponding :attr:`filename` metadata : :class:`~lib.align.alignments.PNGHeaderDict`, optional The faceswap metadata corresponding to the image. Only returned if `with_metadata` is ``True`` @@ -331,16 +333,32 @@ def read_image(filename: str, raise_error: bool = False, with_metadata: bool = F image = None retval: np.ndarray | tuple[np.ndarray, PNGHeaderDict] | None = None try: - with open(filename, "rb") as infile: - raw_file = infile.read() - image = cv2.imdecode(np.frombuffer(raw_file, dtype="uint8"), cv2.IMREAD_COLOR) - if image is None: - raise ValueError("Image is None") - if with_metadata: - metadata = T.cast("PNGHeaderDict", png_read_meta(raw_file)) - retval = (image, metadata) - else: - retval = image + with open(filename, "rb") as in_file: + raw_file = in_file.read() + image = cv2.imdecode(np.frombuffer(raw_file, dtype=np.uint8), cv2.IMREAD_UNCHANGED) + if image is None: + raise ValueError("Image is None") + if image.ndim == 2: # Convert grayscale to BGR + image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) + elif image.ndim == 2 and image.shape[2] == 4: # Strip mask + image = image[:, :, :3] + + if np.issubdtype(image.dtype, np.integer): + info = np.iinfo(T.cast(np.integer, image.dtype)) # Scale non UINT8 INT images to UINT8 + if info.max != 255: + image = image.astype(np.float32) / info.max * 255.0 + elif np.issubdtype(image.dtype, np.floating): + # Just naively clip floating images to 0-1 for now + image = (np.clip(image, 0.0, 1.0) * 255.).astype(np.float32) + + if image.dtype != np.uint8: + image = np.clip(image, 0, 255).astype(np.uint8) + + if with_metadata: + metadata = T.cast("PNGHeaderDict", png_read_meta(raw_file)) + retval = (image, metadata) + else: + retval = image except TypeError as err: success = False msg = "Error while reading image (TypeError): '{}'".format(filename) @@ -403,7 +421,7 @@ def read_image_batch(filenames: list[str], with_metadata: bool = False Notes ----- As the images are compiled into a batch, they should be all of the same dimensions, otherwise a - homongenous array will be returned + homogenous array will be returned Example ------- @@ -411,7 +429,7 @@ def read_image_batch(filenames: list[str], with_metadata: bool = False >>> images = read_image_batch(image_filenames) >>> print(images.shape) ... (3, 64, 64, 3) - >>> images, metatdata = read_image_batch(image_filenames, with_metadata=True) + >>> images, metadata = read_image_batch(image_filenames, with_metadata=True) >>> print(images.shape) ... (3, 64, 64, 3) >>> print(len(metadata)) @@ -475,16 +493,16 @@ def read_image_meta(filename): """ retval = dict() if os.path.splitext(filename)[-1].lower() != ".png": - # Get the dimensions directly from the image for non-pngs + # Get the dimensions directly from the image for non-png logger.trace( # type:ignore[attr-defined] "Non png found. Loading file for dimensions: '%s'", filename) img = cv2.imread(filename) retval["height"], retval["width"] = img.shape[:2] return retval - with open(filename, "rb") as infile: + with open(filename, "rb") as in_file: try: - chunk = infile.read(8) + chunk = in_file.read(8) except PermissionError: raise PermissionError(f"PermissionError while reading: {filename}") @@ -492,7 +510,7 @@ def read_image_meta(filename): raise ValueError(f"Invalid header found in png: {filename}") while True: - chunk = infile.read(8) + chunk = in_file.read(8) length, field = struct.unpack(">I4s", chunk) logger.trace( # type:ignore[attr-defined] "Read chunk: (chunk: %s, length: %s, field: %s", @@ -501,11 +519,11 @@ def read_image_meta(filename): break if field == b"IHDR": # Get dimensions - chunk = infile.read(8) + chunk = in_file.read(8) retval["width"], retval["height"] = struct.unpack(">II", chunk) length -= 8 elif field == b"iTXt": - keyword, value = infile.read(length).split(b"\0", 1) + keyword, value = in_file.read(length).split(b"\0", 1) if keyword == b"faceswap": retval["itxt"] = literal_eval(value[4:].decode("utf-8", errors="replace")) break @@ -513,7 +531,7 @@ def read_image_meta(filename): logger.trace("Skipping iTXt chunk: '%s'", # type:ignore[attr-defined] keyword.decode("latin-1", errors="ignore")) length = 0 # Reset marker for next chunk - infile.seek(length + 4, 1) + in_file.seek(length + 4, 1) logger.trace("filename: %s, metadata: %s", filename, retval) # type:ignore[attr-defined] return retval @@ -552,7 +570,7 @@ def read_image_meta_batch(filenames): logger.debug("Submitting %s items to executor", len(filenames)) read_meta = {executor.submit(read_image_meta, filename): filename for filename in filenames} - logger.debug("Succesfully submitted %s items to executor", len(filenames)) + logger.debug("Successfully submitted %s items to executor", len(filenames)) for future in futures.as_completed(read_meta): retval = (read_meta[future], future.result()) logger.trace("Yielding: %s", retval) # type:ignore[attr-defined] @@ -714,7 +732,7 @@ def tiff_write_meta(image: bytes, data: PNGHeaderDict | dict[str, T.Any] | bytes Notes ----- This handles a very specific task of adding, and populating, an ImageDescription field in a - Tiff file generated by OpenCV. For any other usecases it will likely fail + Tiff file generated by OpenCV. For any other use cases it will likely fail """ if not isinstance(data, bytes): data = json.dumps(data, ensure_ascii=True).encode("ascii") @@ -866,14 +884,14 @@ def generate_thumbnail(image, size=96, quality=60): image.shape, size, quality) orig_size = image.shape[0] if orig_size != size: - interp = cv2.INTER_AREA if orig_size > size else cv2.INTER_CUBIC - image = cv2.resize(image, (size, size), interpolation=interp) + interpolator = cv2.INTER_AREA if orig_size > size else cv2.INTER_CUBIC + image = cv2.resize(image, (size, size), interpolation=interpolator) retval = cv2.imencode(".jpg", image, [cv2.IMWRITE_JPEG_QUALITY, quality])[1] logger.trace("Output shape: %s", retval.shape) # type:ignore[attr-defined] return retval -def batch_convert_color(batch, colorspace): +def batch_convert_color(batch, color_space): """ Convert a batch of images from one color space to another. Converts a batch of images by reshaping the batch prior to conversion rather than iterating @@ -883,7 +901,7 @@ def batch_convert_color(batch, colorspace): ---------- batch: numpy.ndarray A batch of images. - colorspace: str + color_space: str The OpenCV Color Conversion Code suffix. For example for BGR to LAB this would be ``'BGR2LAB'``. See https://docs.opencv.org/4.1.1/d8/d01/group__imgproc__color__conversions.html for a full @@ -910,20 +928,20 @@ def batch_convert_color(batch, colorspace): before an operation and then convert back. """ logger.trace( # type:ignore[attr-defined] - "Batch converting: (batch shape: %s, colorspace: %s)", - batch.shape, colorspace) + "Batch converting: (batch shape: %s, color_space: %s)", + batch.shape, color_space) original_shape = batch.shape batch = batch.reshape((original_shape[0] * original_shape[1], *original_shape[2:])) - batch = cv2.cvtColor(batch, getattr(cv2, "COLOR_{}".format(colorspace))) + batch = cv2.cvtColor(batch, getattr(cv2, "COLOR_{}".format(color_space))) return batch.reshape(original_shape) -def hex_to_rgb(hexcode): +def hex_to_rgb(hex_code): """ Convert a hex number to it's RGB counterpart. Parameters ---------- - hexcode: str + hex_code: str The hex code to convert (e.g. `"#0d25ac"`) Returns @@ -931,7 +949,7 @@ def hex_to_rgb(hexcode): tuple The hex code as a 3 integer (`R`, `G`, `B`) tuple """ - value = hexcode.lstrip("#") + value = hex_code.lstrip("#") chars = len(value) return tuple(int(value[i:i + chars // 3], 16) for i in range(0, chars, chars // 3)) @@ -996,7 +1014,7 @@ def count_frames(filename, fast=False): stderr=subprocess.STDOUT, stdout=subprocess.PIPE, universal_newlines=True, encoding="utf8") - pbar = None + p_bar = None duration = None init_tqdm = False update = 0 @@ -1015,7 +1033,7 @@ def count_frames(filename, fast=False): logger.debug("frame line: %s", output) if not init_tqdm: logger.debug("Initializing tqdm") - pbar = tqdm(desc="Analyzing Video", leave=False, total=duration, unit="secs") + p_bar = tqdm(desc="Analyzing Video", leave=False, total=duration, unit="secs") init_tqdm = True time_idx = output.find("time=") + len("time=") frame_idx = output.find("frame=") + len("frame=") @@ -1024,9 +1042,9 @@ def count_frames(filename, fast=False): logger.debug("frames: %s, vid_time: %s", frames, vid_time) prev_update = update update = vid_time - pbar.update(update - prev_update) - if pbar is not None: - pbar.close() + p_bar.update(update - prev_update) + if p_bar is not None: + p_bar.close() return_code = process.poll() logger.debug("Return code: %s, frames: %s", return_code, frames) return frames @@ -1055,19 +1073,14 @@ class ImageIO(): """ def __init__(self, path, queue_size, args=None): - logger.debug("Initializing %s: (path: %s, queue_size: %s, args: %s)", - self.__class__.__name__, path, queue_size, args) - + logger.debug(parse_class_init(locals())) + self._name = self.__class__.__name__ self._args = tuple() if args is None else args - self._location = path self._check_location_exists() - - queue_name = queue_manager.add_queue(name=self.__class__.__name__, - maxsize=queue_size, - create_new=True) - self._queue = queue_manager.get_queue(queue_name) + self._queue = Queue(maxsize=queue_size) self._thread = None + self._error_state: ErrorState | None = None @property def location(self): @@ -1090,16 +1103,16 @@ def _check_location_exists(self): def _set_thread(self): """ Set the background thread for the load and save iterators and launch it. """ - logger.trace("Setting thread") # type:ignore[attr-defined] + logger.trace("[%s] Setting thread", self._name) # type:ignore[attr-defined] if self._thread is not None and self._thread.is_alive(): - logger.trace("Thread pre-exists and is alive: %s", # type:ignore[attr-defined] - self._thread) + logger.trace("[%s] Thread pre-exists and is alive: %s", # type:ignore[attr-defined] + self._name, self._thread) return - self._thread = MultiThread(self._process, - self._queue, - name=self.__class__.__name__, - thread_count=1) - logger.debug("Set thread: %s", self._thread) + self._thread = FSThread(self._process, + name=self.__class__.__name__, + args=(self._queue, )) + self._error_state = self._thread.error_state + logger.debug("[%s] Set thread: %s", self._name, self._thread) self._thread.start() def _process(self, queue): @@ -1114,12 +1127,12 @@ def _process(self, queue): def close(self): """ Closes down and joins the internal threads """ - logger.debug("Received Close") + logger.debug("[%s] Received Close", self._name) if self._thread is not None: self._thread.join() del self._thread self._thread = None - logger.debug("Closed") + logger.debug("[%s] Closed", self._name) class ImagesLoader(ImageIO): @@ -1166,15 +1179,11 @@ def __init__(self, fast_count: bool = True, skip_list: list[int] | None = None, count: int | None = None) -> None: - logger.debug("Initializing %s: (path: %s, queue_size: %s, fast_count: %s, skip_list: %s, " - "count: %s)", self.__class__.__name__, path, queue_size, fast_count, - skip_list, count) - + logger.debug(parse_class_init(locals())) super().__init__(path, queue_size=queue_size) self._skip_list = set() if skip_list is None else set(skip_list) self._is_video = self._check_for_video() self._fps = self._get_fps() - self._count = None self._file_list: list[str] = [] self._get_count_and_filelist(fast_count, count) @@ -1225,7 +1234,7 @@ def add_skip_list(self, skip_list: list[int]): A list of indices corresponding to the frame indices that should be skipped by the :func:`load` function. """ - logger.debug(skip_list) + logger.debug("[%s] skip_list: %s", self._name, skip_list) self._skip_list = set(skip_list) def _check_for_video(self): @@ -1247,7 +1256,7 @@ def _check_for_video(self): retval = True else: raise FaceswapError("The input file '{}' is not a valid video".format(self.location)) - logger.debug("Input '%s' is_video: %s", self.location, retval) + logger.debug("[%s] Input '%s' is_video: %s", self._name, self.location, retval) return retval def _get_fps(self): @@ -1266,7 +1275,7 @@ def _get_fps(self): reader.close() else: retval = 25.0 - logger.debug(retval) + logger.debug("[%s] fps: %s", self._name, retval) return retval def _get_count_and_filelist(self, fast_count, count): @@ -1289,16 +1298,15 @@ def _get_count_and_filelist(self, fast_count, count): if self._is_video: self._count = int(count_frames(self.location, fast=fast_count)) if count is None else count - self._file_list = [self._dummy_video_framename(i) for i in range(self.count)] + self._file_list = [self._dummy_video_frame_name(i) for i in range(self.count)] else: if isinstance(self.location, (list, tuple)): self._file_list = self.location else: self._file_list = get_image_paths(self.location) self._count = len(self.file_list) if count is None else count - - logger.debug("count: %s", self.count) - logger.trace("filelist: %s", self.file_list) # type:ignore[attr-defined] + logger.debug("[%s] count: %s", self._name, self.count) + logger.trace("[%s] file_list: %s", self._name, self.file_list) # type:ignore[attr-defined] def _process(self, queue): """ The load thread. @@ -1311,17 +1319,29 @@ def _process(self, queue): The ImageIO Queue """ iterator = self._from_video if self._is_video else self._from_folder - logger.debug("Load iterator: %s", iterator) + logger.debug("[%s] Load iterator: %s", self._name, iterator) + assert self._error_state is not None for retval in iterator(): filename, image = retval[:2] if image is None or (not image.any() and image.ndim not in (2, 3)): # All black frames will return not numpy.any() so check dims too logger.warning("Unable to open image. Skipping: '%s'", filename) continue - logger.trace("Putting to queue: %s", # type:ignore[attr-defined] - [v.shape if isinstance(v, np.ndarray) else v for v in retval]) - queue.put(retval) - logger.trace("Putting EOF") # type:ignore[attr-defined] + logger.trace("[%s] Putting to queue: %s", # type:ignore[attr-defined] + self._name, [v.shape if isinstance(v, np.ndarray) else v for v in retval]) + + while True: + if self._error_state.has_error: + logger.debug("[%s] Thread error detected in worker thread", self._name) + return + try: + queue.put(retval, timeout=0.2) + break + except QueueFull: + logger.trace("[%s] Queue full. Waiting", # type:ignore[attr-defined] + self._name) + continue + logger.trace("[%s] Putting EOF", self._name) # type:ignore[attr-defined] queue.put("EOF") def _from_video(self): @@ -1334,21 +1354,22 @@ def _from_video(self): image: numpy.ndarray The loaded video frame. """ - logger.debug("Loading frames from video: '%s'", self.location) + logger.debug("[%s] Loading frames from video: '%s'", self._name, self.location) reader = imageio.get_reader(self.location, "ffmpeg") for idx, frame in enumerate(reader): if idx in self._skip_list: - logger.trace("Skipping frame %s due to skip list", # type:ignore[attr-defined] - idx) + logger.trace( # type:ignore[attr-defined] + "[%s] Skipping frame %s due to skip list", self._name, idx) continue # Convert to BGR for cv2 compatibility frame = frame[:, :, ::-1] - filename = self._dummy_video_framename(idx) - logger.trace("Loading video frame: '%s'", filename) # type:ignore[attr-defined] + filename = self._dummy_video_frame_name(idx) + logger.trace("[%s] Loading video frame: '%s'", # type:ignore[attr-defined] + self._name, filename) yield filename, frame reader.close() - def _dummy_video_framename(self, index): + def _dummy_video_frame_name(self, index): """ Return a dummy filename for video files. The file name is made up of: _. @@ -1365,8 +1386,8 @@ def _dummy_video_framename(self, index): Returns ------- str: A dummied filename for a video frame """ - vidname, ext = os.path.splitext(os.path.basename(self.location)) - return f"{vidname}_{index + 1:06d}{ext}" + vid_name, ext = os.path.splitext(os.path.basename(self.location)) + return f"{vid_name}_{index + 1:06d}{ext}" def _from_folder(self): """ Generator for loading images from a folder @@ -1378,10 +1399,11 @@ def _from_folder(self): image: numpy.ndarray The loaded image. """ - logger.debug("Loading frames from folder: '%s'", self.location) + logger.debug("[%s] Loading frames from folder: '%s'", self._name, self.location) for idx, filename in enumerate(self.file_list): if idx in self._skip_list: - logger.trace("Skipping frame %s due to skip list") # type:ignore[attr-defined] + logger.trace( # type:ignore[attr-defined] + "[%s] Skipping frame %s due to skip list", self._name, filename) continue image_read = read_image(filename, raise_error=False) retval = filename, image_read @@ -1405,21 +1427,29 @@ def load(self): metadata: dict, (:class:`FacesLoader` only) The Faceswap metadata associated with the loaded image. """ - logger.debug("Initializing Load Generator") + logger.debug("[%s] Initializing Load Generator", self._name) self._set_thread() + assert self._error_state is not None while True: - self._thread.check_and_raise_error() + if self._error_state.has_error: + current = current_thread() + if current is main_thread(): + self._error_state.re_raise() + else: + logger.debug("[%s.%s] Thread error detected in worker thread", + current.name, self._name) + break try: retval = self._queue.get(True, 1) except QueueEmpty: continue if retval == "EOF": - logger.trace("Got EOF") # type:ignore[attr-defined] + logger.trace("[%s] Got EOF", self._name) # type:ignore[attr-defined] break - logger.trace("Yielding: %s", # type:ignore[attr-defined] - [v.shape if isinstance(v, np.ndarray) else v for v in retval]) + logger.trace("[%s] Yielding: %s", # type:ignore[attr-defined] + self._name, [v.shape if isinstance(v, np.ndarray) else v for v in retval]) yield retval - logger.debug("Closing Load Generator") + logger.debug("[%s] Closing Load Generator", self._name) self.close() @@ -1435,8 +1465,7 @@ class FacesLoader(ImagesLoader): >>> """ def __init__(self, path, skip_list=None, count=None): - logger.debug("Initializing %s: (path: %s, count: %s)", self.__class__.__name__, - path, count) + logger.debug(parse_class_init(locals())) super().__init__(path, queue_size=8, skip_list=skip_list, count=count) def _get_count_and_filelist(self, fast_count, count): @@ -1459,8 +1488,8 @@ def _get_count_and_filelist(self, fast_count, count): if os.path.splitext(fname)[-1].lower() == ".png"] self._count = len(self.file_list) if count is None else count - logger.debug("count: %s", self.count) - logger.trace("filelist: %s", self.file_list) # type:ignore[attr-defined] + logger.debug("[%s] count: %s", self._name, self.count) + logger.trace("[%s] file_list: %s", self._name, self.file_list) # type:ignore[attr-defined] def _from_folder(self): """ Generator for loading images from a folder @@ -1476,10 +1505,11 @@ def _from_folder(self): metadata: dict The Faceswap metadata associated with the loaded image. """ - logger.debug("Loading images from folder: '%s'", self.location) + logger.debug("[%s] Loading images from folder: '%s'", self._name, self.location) for idx, filename in enumerate(self.file_list): if idx in self._skip_list: - logger.trace("Skipping face %s due to skip list") # type:ignore[attr-defined] + logger.trace( # type:ignore[attr-defined] + "[%s] Skipping face %s due to skip list", self._name, idx) continue image_read = read_image(filename, raise_error=False, with_metadata=True) retval = filename, *image_read @@ -1504,8 +1534,7 @@ class SingleFrameLoader(ImagesLoader): scanned. Default: ``None`` """ def __init__(self, path, video_meta_data=None): - logger.debug("Initializing %s: (path: %s, video_meta_data: %s)", - self.__class__.__name__, path, video_meta_data) + logger.debug(parse_class_init(locals())) self._video_meta_data = dict() if video_meta_data is None else video_meta_data self._reader = None super().__init__(path, queue_size=1, fast_count=False) @@ -1560,7 +1589,7 @@ def image_from_index(self, index: int) -> tuple[str, np.ndarray]: """ if self.is_video: image = self._reader.get_data(index)[..., ::-1] - filename = self._dummy_video_framename(index) + filename = self._dummy_video_frame_name(index) else: file_list = [f for idx, f in enumerate(self._file_list) if idx not in self._skip_list] if self._skip_list else self._file_list @@ -1568,8 +1597,9 @@ def image_from_index(self, index: int) -> tuple[str, np.ndarray]: filename = file_list[index] image = read_image(filename, raise_error=True) filename = os.path.basename(filename) - logger.trace("index: %s, filename: %s image shape: %s", # type:ignore[attr-defined] - index, filename, image.shape) + logger.trace( # type:ignore[attr-defined] + "[%s] index: %s, filename: %s image shape: %s", + self._name, index, filename, image.shape) return filename, image @@ -1599,9 +1629,7 @@ class ImagesSaver(ImageIO): """ def __init__(self, path, queue_size=8, as_bytes=False): - logger.debug("Initializing %s: (path: %s, queue_size: %s, as_bytes: %s)", - self.__class__.__name__, path, queue_size, as_bytes) - + logger.debug(parse_class_init(locals())) super().__init__(path, queue_size=queue_size) self._as_bytes = as_bytes @@ -1629,12 +1657,17 @@ def _process(self, queue): The ImageIO Queue """ executor = futures.ThreadPoolExecutor(thread_name_prefix=self.__class__.__name__) + assert self._error_state is not None while True: + if self._error_state.has_error: + logger.debug("[%s] Thread error detected in worker thread", self._name) + executor.shutdown(cancel_futures=True) + return item = queue.get() if item == "EOF": - logger.debug("EOF received") + logger.debug("[%s] EOF received", self._name) break - logger.trace("Submitting: '%s'", item[0]) # type:ignore[attr-defined] + logger.trace("[%s] Submitting: '%s'", self._name, item[0]) # type:ignore[attr-defined] executor.submit(self._save, *item) executor.shutdown() @@ -1668,7 +1701,8 @@ def _save(self, else: assert isinstance(image, np.ndarray) cv2.imwrite(filename, image) - logger.trace("Saved image: '%s'", filename) # type:ignore[attr-defined] + logger.trace("[%s] Saved image: '%s'", # type:ignore[attr-defined] + self._name, filename) except Exception as err: # pylint:disable=broad-except logger.error("Failed to save image '%s'. Original Error: %s", filename, str(err)) del image @@ -1693,14 +1727,19 @@ def save(self, If the file should be saved in a subfolder in the output location, the subfolder should be provided here. ``None`` for no subfolder. Default: ``None`` """ + if self._error_state is not None and self._error_state.has_error: + logger.debug("[%s.%s] Thread error detected in worker thread. Not putting", + current_thread().name, self._name) + return self._set_thread() - logger.trace("Putting to save queue: '%s'", filename) # type:ignore[attr-defined] + logger.trace("[%s] Putting to save queue: '%s'", # type:ignore[attr-defined] + self._name, filename) self._queue.put((filename, image, sub_folder)) def close(self): """ Signal to the Save Threads that they should be closed and cleanly shutdown the saver """ - logger.debug("Putting EOF to save queue") + logger.debug("[%s] Putting EOF to save queue", self._name) self._queue.put("EOF") super().close() diff --git a/lib/infer/__init__.py b/lib/infer/__init__.py new file mode 100644 index 0000000000..cd3ca809f7 --- /dev/null +++ b/lib/infer/__init__.py @@ -0,0 +1,7 @@ +"""Parallel batched inference library for faceswap.py""" +from .align import Align +from .detect import Detect +from .handler import FileHandler as File +from .identity import Identity +from .mask import Mask +from .profile import Profiler diff --git a/lib/infer/align.py b/lib/infer/align.py new file mode 100644 index 0000000000..c1ae70e2dd --- /dev/null +++ b/lib/infer/align.py @@ -0,0 +1,990 @@ +#! /usr/env/bin/python3 +"""Handles face landmark detection plugins and runners """ +from __future__ import annotations + +import logging +import typing as T + +import cv2 +import numpy as np + +from lib.align.aligned_face import batch_umeyama +from lib.align.aligned_utils import batch_transform +from lib.align.constants import EXTRACT_RATIOS, LandmarkType, MEAN_FACE +from lib.align.pose import Batch3D +from lib.utils import get_module_objects +from lib.logger import format_array, parse_class_init +from plugins.extract import extract_config as cfg +from plugins.extract.base import ExtractPlugin +from .handler import ExtractHandler +from .objects import ExtractBatch + +if T.TYPE_CHECKING: + import numpy.typing as npt + +logger = logging.getLogger(__name__) + + +class Align(ExtractHandler): + """Responsible for handling align plugins within the extract pipeline + + Parameters + ---------- + plugin + The plugin that this runner is to use + re_feeds + Number of times to jitter detection bounding box and average the result. Default: `0` + re_align + ``True`` to re-align faces based on their first-pass results. Default: ``False`` + normalization + The normalization to perform on aligner input images. Default: ``None`` (no normalization) + filters + ``True`` to enable aligner filters to filter out faces. Default: ``False`` + compile_model + ``True`` to compile any PyTorch models + config_file + Full path to a custom config file to load. ``None`` for default config + """ + def __init__(self, + plugin: str, + re_feeds: int = 0, + re_align: bool = False, + normalization: T.Literal["none", "clahe", "hist", "mean"] | None = None, + filters: bool = False, + compile_model: bool = False, + config_file: str | None = None) -> None: + logger.debug(parse_class_init(locals())) + super().__init__(plugin, compile_model=compile_model, config_file=config_file) + self._landmark_type: LandmarkType | None = None # Populate on first plugin output received + self._re_feed = ReFeed(re_feeds) + self._normalize = Normalize("none" if normalization is None else normalization) + self._re_align = ReAlign(re_align, self.plugin, self._re_feed.beta) + self._filters = AlignedFilter(filters) + + def __repr__(self) -> str: + """Pretty print for logging""" + retval = super().__repr__()[:-1] + retval += (f", re_feeds={self._re_feed._re_feeds}, re_align={self._re_align.enabled}, " + f"normalization={repr(self._normalize.name)}, filters={self._filters.enabled})") + return retval + + # Pre-Processing + def _clamp_roi(self, + batch: ExtractBatch, + roi: npt.NDArray[np.int32]) -> npt.NDArray[np.int32]: + """Adjust the provided ROIs to within frame boundaries + + Parameters + ---------- + batch + The batch object that holds the images and ROI co-ordinates for extracting face + patches for alignments + roi + The ROI co-ordinates for extracting face patches for alignments + + Returns + ------- + The batch ROIs adjusted to fit within the frame's dimensions + """ + imgs_h_w = np.array([batch.images[i].shape[:2] for i in batch.frame_ids]) + if imgs_h_w.shape[0] != roi.shape[0]: # Re-feeds + imgs_h_w = np.repeat(imgs_h_w, self._re_feed.total_feeds, axis=0) + retval = np.empty_like(roi) + retval[:, 0] = np.clip(roi[:, 0], 0, imgs_h_w[:, 1] - 1) + retval[:, 1] = np.clip(roi[:, 1], 0, imgs_h_w[:, 0] - 1) + retval[:, 2] = np.clip(roi[:, 2], 0, imgs_h_w[:, 1] - 1) + retval[:, 3] = np.clip(roi[:, 3], 0, imgs_h_w[:, 0] - 1) + return retval + + @classmethod + def _get_destinations(cls, + original_roi: npt.NDArray[np.int32], + clamped_roi: npt.NDArray[np.int32], + scales: npt.NDArray[np.float64]) -> npt.NDArray[np.int32]: + """Provide the destination ROI for resizing the face patch in to the model input + + Parameters + ---------- + original_roi + The original square ROIs calculated from a detection bounding box + clamped_roi + The same ROIs but with out of bound co-ordinates clamped to frame boundaries + scales + The scaling required to take the original ROIs to model input size + + Returns + ------- + The destination co-ordinates for re-sizing the face box to model input size + """ + retval = np.empty_like(clamped_roi, dtype=np.int32) + retval[:, [0, 2]] = (clamped_roi[:, [0, 2]] - original_roi[:, 0, None]) * scales[:, None] + retval[:, [1, 3]] = (clamped_roi[:, [1, 3]] - original_roi[:, 1, None]) * scales[:, None] + return retval + + def _crop_and_resize(self, # pylint:disable=too-many-locals + images: list[npt.NDArray[np.uint8]], + image_ids: npt.NDArray[np.int32], + roi: npt.NDArray[np.int32], + destinations: npt.NDArray[np.int32], + scales: npt.NDArray[np.float64], + is_final: bool) -> np.ndarray: + """Crop and resize the face images from the ROIs and return as batch at model input size + + Parameters + ---------- + images + The images for the batch + image_ids + The image indexes that correspond to the batch's ROIs + roi + The ROIs required to extract a face from an image + destinations + The ROIs that the resized image should be placed on the destination patch + scales + The scaling required to take each frame ROI to model input size + is_final + ``True`` if this is the final pass through the aligner + + Returns + ------- + A batch of face patches ready for feeding to an aligner + """ + num_imgs = len(image_ids) + total_feeds = self._re_feed.total_feeds if is_final else 1 + batch: np.ndarray = np.zeros((num_imgs, + total_feeds, + self.plugin.input_size, + self.plugin.input_size, 3), + dtype=images[image_ids[0]].dtype) + roi_reshaped = roi.reshape(num_imgs, -1, 4) + dest_reshaped = destinations.reshape(num_imgs, -1, 4) + scales_reshaped = scales.reshape(num_imgs, -1) + interpolations = np.where(scales_reshaped > 1.0, cv2.INTER_CUBIC, cv2.INTER_AREA) + + for batch_id, (image_id, bboxes, dst) in enumerate(zip(image_ids, + roi_reshaped, + dest_reshaped)): + img = images[image_id] + img = img[..., 2::-1] if self.plugin.is_rgb else img + for i, (box, dst) in enumerate(zip(bboxes, dst)): + out = batch[batch_id, i] + cv2.resize(img[box[1]:box[3], box[0]:box[2]], + (dst[2] - dst[0], dst[3] - dst[1]), + dst=out[dst[1]:dst[3], dst[0]:dst[2]], + interpolation=interpolations[batch_id, i]) + retval = batch.reshape((-1, self.plugin.input_size, self.plugin.input_size, 3)) + return retval + + def _prepare_images(self, + batch: ExtractBatch, + roi: npt.NDArray[np.int32], + is_final: bool) -> npt.NDArray[np.float32]: + """Prepare the images from the ROI bounding boxes and model input size for feeding the + model and populate to the batch's data attribute + + Parameters + ---------- + batch + The batch to be fed to the aligner + roi + The square ROI from the original image that plugin's face patch should be created from + is_final + ``True`` if this is the final pass through the aligner + + Returns + ------- + The formatted and resized feed images for the plugin + """ + scale = self.plugin.input_size / batch.matrices[:, 0, 0] + clamped_roi = self._clamp_roi(batch, roi) + destinations = self._get_destinations(roi, clamped_roi, scale) + images = self._crop_and_resize(batch.images, + batch.frame_ids, + clamped_roi, + destinations, + scale, + is_final) + images = self._normalize(images) + return self._format_images(images) + + def _matrices_from_roi(self, roi: npt.NDArray[np.int32]) -> npt.NDArray[np.float32]: + """Convert the ROIs to transformation matrices for mapping predictions back to frame space + + Parameters + ---------- + roi + The square (B, left, top, right, bottom) region of interest in the original frame for + feeding the plugin + + Returns + ------- + The (B, 3, 3) transformation matrices for taking the ROIs back to frame space + """ + assert np.all(roi[:, 3] - roi[:, 1] == roi[:, 2] - roi[:, 0]), ( + f"[{self.plugin.name}.pre_process] All ROI bounding boxes for aligner input must " + "be square") + retval = np.zeros((roi.shape[0], 3, 3), dtype="float32") + retval[:, 0, 0] = roi[:, 2] - roi[:, 0] + retval[:, 1, 1] = roi[:, 3] - roi[:, 1] + retval[:, 0, 2] = roi[:, 0] + retval[:, 1, 2] = roi[:, 1] + retval[:, 2, 2] = 1.0 + return retval + + def _prepare_data(self, batch: ExtractBatch, iteration: int = 1) -> None: + """Prepare the data, in place, for feeding through the model. + + Parameters + ---------- + batch + The aligner batch containing the information required to pre-process data + iteration + The iteration that we are on passing through the model. If re-align is not enabled this + will always be 1. If re-align is enabled this will represent the first or second pass + through the model + """ + is_final = iteration == self._re_align.iterations + # ROIs are adjusted by plugin on first/only pass, otherwise by re-align + # square crop from frame on first pass. Square Affine from aligned data on 2nd pass + if iteration == 1: + # Re-feeds are performed during 2nd pass on aligned bounding box for re-aligns + boxes = batch.bboxes.copy() + roi = self.plugin.pre_process(boxes) + mats = self._matrices_from_roi(roi) + if is_final and self._re_feed.total_feeds > 1: + mats, roi = self._re_feed(mats, with_roi=True) + batch.matrices = mats + batch.data = self._prepare_images(batch, roi, is_final) + else: # If we are here we are re-aligning + if self._re_feed.total_feeds > 1: + mats = self._re_feed(self._re_align.default_crop_matrices, + with_roi=False, + size=self.plugin.input_size) + else: + mats = self._re_align.default_crop_matrices + batch.data = self._re_align.get_images(mats, self._re_feed.total_feeds) + + def pre_process(self, batch: ExtractBatch) -> None: + """Obtain the adjusted square ROIs from the plugin based off the provided detection + bounding boxes. Crop and size the input face images ready for inference from these ROIs + + Parameters + ---------- + batch + The incoming ExtractBatch to use for pre-processing + """ + self._prepare_data(batch, iteration=1) + + # Processing + def _get_predictions(self, is_final: bool, feed: np.ndarray) -> np.ndarray: + """Obtain the predictions from the model. Handles collating any re-feeds + + Parameters + ---------- + is_final + ``True`` if this is the final iteration through the plugin + feed + The input to the model for the batch. + + Returns + ------- + The predictions from the model for the provided feed + """ + batch_size = feed.shape[0] + if is_final: # Re-feeds performed on final pass only + batch_size //= self._re_feed.total_feeds + results = [] + chunks = self._re_feed.total_feeds if is_final else 1 + for idx in range(chunks): + start = idx * batch_size + results.append(self._predict(feed[start: start + batch_size])) + + retval = np.array(results) + return retval.reshape((feed.shape[0], *retval.shape[2:])) + + def process(self, batch: ExtractBatch) -> None: + """Perform inference to get results from the aligner + + Parameters + ---------- + batch + The incoming ExtractBatch to use for processing + """ + result = None + for iteration in range(1, self._re_align.iterations + 1): + is_final = iteration == self._re_align.iterations + + if is_final and self._re_align.enabled: + # Need to get prepared aligned images from first-pass output + self._prepare_data(batch, iteration=iteration) + + assert batch.data is not None + result = self._get_predictions(is_final, batch.data) + + if is_final and not self._re_align.enabled: # Nothing left to do. Just the 1 pass + break + + if self._overridden["post_process"]: # Must make sure we are final (B, 68, 2) lms + result = self.plugin.post_process(result) + + self._re_align(batch, result, iteration) # 1st or 2nd pass re-align op + + assert result is not None + batch.data = result # Final pass predictions + + # Post-Processing + def post_process(self, batch: ExtractBatch) -> None: + """Post-process the landmark predictions from the model: average any re-feeds, scale back + to original frame dimensions, apply any filters + + Parameters + ---------- + batch + The incoming ExtractBatch to use for post-processing + """ + result = batch.data + if self._overridden["post_process"] and not self._re_align.enabled: + result = self.plugin.post_process(result) + assert result.dtype == np.float32, ( + f"[{self.plugin.name}.post_process] Landmarks should be a numpy float32 array") + + batch_transform(batch.matrices, result, in_place=True) # Scale to image space + landmarks = self._re_feed.merge(result) + if self._landmark_type is None: + self._landmark_type = LandmarkType.from_shape(T.cast(tuple[int, int], + landmarks.shape[1:])) + logger.debug("[%s.post_process] Set landmark type to: %s", + self.plugin.name, repr(self._landmark_type.name)) + + batch.landmarks = landmarks + batch.landmark_type = self._landmark_type + self._filters(batch) + + def output_info(self) -> None: + """Output the counts from the aligner filter""" + self._filters.output_counts() + + def set_normalize_method(self, method: T.Literal["none", "clahe", "hist", "mean"] | None + ) -> None: + """Update the normalization method with the given method + + Parameters + ---------- + method + The normalization method to use + """ + self._normalize.set_method(method) + + +class Normalize(): + """Handles the normalization of feed images prior to feeding the model""" + def __init__(self, method: T.Literal["none", "clahe", "hist", "mean"]) -> None: + logger.debug(parse_class_init(locals())) + self.name = method.lower() + assert self.name in ("none", "clahe", "hist", "mean") + self._method = None if self.name == "none" else self.name + self._methods = {"clahe": self._clahe, + "hist": self._hist, + "mean": self._mean} + self._clahe_object = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(4, 4)) + + def _clahe(self, images: npt.NDArray[np.uint8]) -> npt.NDArray[np.uint8]: + """Perform Contrast Limited Adaptive Histogram Equalization + + Parameters + ---------- + images + The images to perform CLAHE normalization on + + Returns + ------- + The normalized images + """ + n, h, w, c = images.shape + reshaped = images.reshape((-1, h, w)) # (N*3, H, W) + retval = np.empty_like(reshaped) + for i in range(reshaped.shape[0]): + retval[i] = self._clahe_object.apply(reshaped[i]) + return retval.reshape((n, h, w, c)) + + def _hist(self, images: npt.NDArray[np.uint8]) -> npt.NDArray[np.uint8]: + """Perform RGB Histogram Equalization + + Parameters + ---------- + images + The images to perform Histogram Equalization on + + Returns + ------- + The normalized images + """ + n, h, w, c = images.shape + reshaped = images.reshape((-1, h, w)) # (N*3, H, W) + retval = np.empty_like(reshaped) + for i in range(reshaped.shape[0]): + retval[i] = cv2.equalizeHist(reshaped[i]) + return retval.reshape((n, h, w, c)) + + def _mean(self, images: npt.NDArray[np.uint8]) -> npt.NDArray[np.uint8]: + """Normalize each channel to its min/max + + Parameters + ---------- + images + The images to mean normalization on + + Returns + ------- + The normalized images + """ + imgs = images.astype("float32") + mins = imgs.min(axis=(1, 2))[:, None, None, :] + maxes = imgs.max(axis=(1, 2))[:, None, None, :] + den = np.maximum(maxes - mins, 1e-6) + out = (imgs - mins) / den * 255. + return out.astype("uint8") + + def set_method(self, method: T.Literal["none", "clahe", "hist", "mean"] | None) -> None: + """Update the normalization method with the given method + + Parameters + ---------- + method + The normalization method to use + """ + self.name = "none" if method is None else method.lower() + assert self.name in ("none", "clahe", "hist", "mean") + logger.debug("[Align.normalization] Set method to %s", self.name) + self._method = None if self.name == "none" else self.name + + def __call__(self, images: npt.NDArray[np.uint8]) -> npt.NDArray[np.uint8]: + """Perform the selected normalization method on the batch of model input images + + Parameters + ---------- + images + The batch of model input images to be normalized + + Returns + ------- + The given images normalized by the chosen method, or the input batch if no method selected + """ + if self._method is None: + return images + return self._methods[self._method](images) + + +class ReAlign: + """Handles re-aligning faces based on first-pass results + + Parameters + ---------- + enabled + ``True`` if realigns are to be performed + plugin + The plugin that will be processing re-aligns + margin + The % amount that re-feed allows bounding box points to drift + """ + def __init__(self, enabled: bool, plugin: ExtractPlugin, margin: float) -> None: + logger.debug(parse_class_init(locals())) + self.enabled = enabled + """``True`` if re-aligns are enabled""" + self.iterations = 2 if enabled else 1 + """The total number of iterations through the align process required for the + selected re-align configuration""" + self._size = plugin.input_size + self._expanded_size = int(round(self._size * (1 + 2 * margin))) # Additional re-feed space + self._image_scale = plugin.scale + self._mean_face = MEAN_FACE[LandmarkType.LM_2D_51] + + self._adjust_matrix = self._get_adjust_matrix() + """Padding and offset for normalized aligned matrix to better represent a face detection + box""" + self._default_crop_matrices = self._get_default_matrix() + """A transform matrix that crops the default (center) image patch out of the expanded image + patch""" + self._matrices = np.empty((0, 3, 3), dtype="float32") + self._images = np.zeros((plugin.batch_size, self._expanded_size, self._expanded_size, 3), + dtype=plugin.dtype) + + @property + def default_crop_matrices(self) -> npt.NDArray[np.float32]: + """The default crop matrices used for calculating re-feeds""" + return np.broadcast_to(self._default_crop_matrices, (self._matrices.shape[0], 3, 3)) + + def _get_adjust_matrix(self) -> npt.NDArray[np.float32]: + """Obtain a transformation matrix that applies padding to better represent a face + detection bounding box location in normalized aligned space for applying to patch space + + Returns + ------- + The (1, 3, 3) transformation matrix for transforming points from normalized aligned + space to image patch space + """ + pad = 0.3 # 30% padding + retval = np.array([[[1.0 - pad, 0, pad / 2], + [0, 1.0 - pad, pad / 2], + [0, 0, 1]]], dtype="float32") + logger.debug("Obtained normalized to image patch matrix: %s", format_array(retval)) + return retval + + def _get_default_matrix(self) -> npt.NDArray[np.float32]: + """Create the default unit-square to patch-space centered sub-crop from the expanded, + aligned matrix + + Returns + ------- + The (N, 3, 3) transformation matrix that takes the central crop in patch space + """ + offset = (self._expanded_size - self._size) / 2 + retval = np.array([[[1.0, 0, offset], + [0, 1.0, offset], + [0., 0., 1.]]], + dtype="float32") + logger.debug("Default bounding box: %s", retval) + return retval + + def get_images(self, # pylint:disable=too-many-locals + matrices: npt.NDArray[np.float32], + feeds: int) -> npt.NDArray[np.float32]: + """Obtain the sub-crops from the main image patches based on the roi stored in the batch + and populate them to the batch's data attribute + + Parameters + ---------- + matrices + The matrices that define the crops to extract from the expanded patch in shape + (N x total_feeds, 3, 3) + feeds + The number of feeds that are to be made through the model for this batch + + Returns + ------- + The aligned images that are to be used for 2nd pass re-align + """ + mats = matrices.reshape(-1, feeds, 3, 3) + all_offsets = np.rint(mats[..., :2, 2]).astype("int32") + all_scales = mats[..., 0, 0] # Always same x/y scaling, always aligned + all_interpolations = np.where(all_scales < 1.0, cv2.INTER_CUBIC, cv2.INTER_AREA) + all_dims = np.rint(self._size / all_scales).astype(np.int32) # Always square + + size = (self._size, self._size) + retval = np.empty((*mats.shape[:2], *size, 3), dtype=self._images.dtype) + + for batch_id, (offsets, scales, interpolations, dims) in enumerate(zip(all_offsets, + all_scales, + all_interpolations, + all_dims)): + img = self._images[batch_id] + for feed_id, offset in enumerate(offsets): + scale = scales[feed_id] + interpolation = interpolations[feed_id] + src_dim = dims[feed_id] + crop = img[offset[1]:offset[1] + src_dim, offset[0]:offset[0] + src_dim] + if scale != 1.: + crop = cv2.resize(crop, size, interpolation=interpolation) + retval[batch_id, feed_id] = crop + + # Add the adjusted matrices to :attr:`_matrices` for warping back to frame downstream + base_mats = self._matrices.reshape(self._matrices.shape[0], -1, 3, 3) + base_mats = base_mats @ mats @ np.diag([self._size, self._size, 1]).astype("float32") + self._matrices = base_mats.reshape(matrices.shape[0], *base_mats.shape[2:]) + + return retval.reshape(matrices.shape[0], *retval.shape[2:]) + + def _get_matrix(self, + landmarks: npt.NDArray[np.float32], + bboxes: npt.NDArray[np.int32], + roi_matrices: npt.NDArray[np.float32]) -> np.ndarray: + """Obtain the (N, 3, 3) transformation matrix to align the landmarks in normalized space + and add to :attr:`_matrices` + + The matrix: + - takes the standard matrix that aligns the face/image via umeyama + - Pads it to better line up with a detection bounding box + - Adjusts with further padding/offsetting based on the plugin's generated ROI output + + Parameters + ---------- + landmarks + The first pass detected landmarks in normalized space + bboxes + The original face detection bounding boxes + roi_matrices + The original matrices used to map the original square ROIs generated by the plugin back + to frame space + + Returns + The (N, 3, 3) transformation matrix that will create an image patch for re-alignment + """ + # Frame space -> Normalized Space -> Aligned space -> Patch Space + # normalized -> aligned + mats = batch_umeyama(landmarks[:, 17:], self._mean_face, True).astype("float32") + + # normalized -> patch + # Get plugin adjustments + roi_sizes = roi_matrices[:, 0, 0, None] + box_sizes = (bboxes[:, 2:] - bboxes[:, :2]).max(axis=1)[..., None] + bb_to_roi_scales = box_sizes / roi_sizes # (N, 1) + + roi_center = roi_matrices[:, :2, 2] + (0.5 * roi_sizes) + bbox_center = (bboxes[:, :2] + bboxes[:, 2:]) / 2. + bb_to_roi_shifts = (bbox_center - roi_center) / box_sizes + + # Convert plugin adjustment to matrix + adj_mat = np.repeat(np.eye(3, dtype="float32")[None, :, :], mats.shape[0], axis=0) + adj_mat[:, 0, 0] = bb_to_roi_scales[:, 0] + adj_mat[:, 1, 1] = bb_to_roi_scales[:, 0] + adj_mat[:, :2, 2] = (1 - bb_to_roi_scales) / 2 + bb_to_roi_shifts + + # Combine plugin and default adjustments + scale + patch_mat = adj_mat @ self._adjust_matrix + patch_mat[:, :2] *= self._expanded_size + + # Store the matrix that takes expanded space to frame space for updating in get_images + self._matrices = (roi_matrices @ + np.linalg.inv(mats) @ + np.linalg.inv(patch_mat)).astype("float32") + # Return the matrix that creates the expanded image sub-crop + return patch_mat @ mats @ np.linalg.inv(roi_matrices) + + def _scale_images(self) -> None: + """Scale all of the images stored in :attr:`_images` to the correct numeric range """ + if self._image_scale == (0, 255): + return + low, high = self._image_scale + im_range = high - low + self._images /= (255. / im_range) + self._images += low + + def _first_pass(self, landmarks: npt.NDArray[np.float32], batch: ExtractBatch) -> None: + """Process the outputs from the model after the first pass. + + We want to adjust the matrix for any padding and offsets added by the plugin to the + original detection box. We then store these padded image in :attr:`_images` for sub- + cropping + + Assumptions: + - The "default" ROI is a square box along the bbox's longest edge at the same center + - Padding is how much wider the actual ROI is than this "default" ROI + - offset is how much the centre of the actual ROI deviates from the "default" ROI + - A dummy padding 'constant' is added to the matrix to cater for detection box + 'looseness' + + The aim is to end up with a face patch which is about similarly framed to the original + bbox. A bit of extra padding is added to match with the amount of offset applied by + re-feed The original 'ROI' will be the square around the center of the image patch that is + of plugin input size + + Parameters + ---------- + landmarks + The (x, y) detected landmarks for a batch in frame space + batch + The batch object being processed for re-aligns + """ + warp_mats = self._get_matrix(landmarks, batch.bboxes, batch.matrices)[:, :2] + scales = np.sqrt(np.abs(np.linalg.det(warp_mats[:, :, :2]))) + interpolations = np.where(scales < 1.0, cv2.INTER_CUBIC, cv2.INTER_AREA) + size = (self._expanded_size, self._expanded_size) + for idx, (frame_id, mat, interpolation) in enumerate(zip(batch.frame_ids, + warp_mats, + interpolations)): + img = batch.images[frame_id] + cv2.warpAffine(img.astype(self._images.dtype), + mat, + size, + dst=self._images[idx], + flags=interpolation, + borderMode=cv2.BORDER_REPLICATE) + self._scale_images() + + def _second_pass(self, batch: ExtractBatch) -> None: + """Add the adjustment matrices to the batch object so downstream can transpose back to + frame space + + Parameters + ---------- + batch + The batch object being processed for re-aligns + """ + batch.matrices = self._matrices + + def __call__(self, + batch: ExtractBatch, + landmarks: npt.NDArray[np.float32], + iteration: int) -> None: + """Process the outputs from the plugin when re-aligning data + + Is called twice. + - First pass: aligns the image based on the first pass landmarks, stores image patches + that next pass' feed will be generated from and creates ROI boxes for this aligned patch + - 2nd pass: Rotates detections back to frame alignment and updates the ROI to correctly + scale and shift the alignments back to frame space downstream + + Parameters + ---------- + batch + The batch object being processed for re-aligns + landmarks + The (x, y) detected landmarks for a batch in mean-space + iteration + The re-align iteration that is being request. Either `1` or `2` + """ + if not self.enabled: + return + assert iteration in (1, 2) + if iteration == 1: + self._first_pass(landmarks, batch) + return + self._second_pass(batch) + + +class ReFeed: + """Handles preparation of images for re-feeding the aligner with minor adjustments to + detection bounding boxes, and averaging the result at the end. + + Parameters + ---------- + re_feeds + The number of re-feeds to be performed. + """ + def __init__(self, re_feeds: int) -> None: + logger.debug(parse_class_init(locals())) + self._re_feeds = re_feeds + self.beta = 0.05 + """The amount each corner point can move relative to the boxes shortest side""" + self.total_feeds = re_feeds + 1 + """The total number of feeds through the model for original boxes plus re-feeds""" + self._corners = np.array([[[0, 0, 1], [1, 1, 1]]], dtype="float32").swapaxes(1, 2) + + @T.overload + def __call__(self, + matrices: npt.NDArray[np.float32], + with_roi: T.Literal[False], + size: int = 0,) -> npt.NDArray[np.float32]: ... + + @T.overload + def __call__(self, + matrices: npt.NDArray[np.float32], + with_roi: T.Literal[True] = True, + size: int = 0) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.int32]]: ... + + def __call__(self, + matrices: npt.NDArray[np.float32], + with_roi: bool = False, + size: int = 0 + ) -> npt.NDArray[np.float32] | tuple[npt.NDArray[np.float32], + npt.NDArray[np.int32]]: + """Obtain an array of adjusted norm to frame matrices based on the number of re-feed + iterations that have been selected and the size of the original ROI. + + Parameters + ---------- + matrices + A batch of norm to frame transformation matrices to be randomly adjust for re-feeding + the model in shape (N, 3, 3) + with_roi + ``True`` to also return the adjusted ROIs. Default: ``False`` + size + The size of the image patch that the matrix creates if it cannot be derived from the + matrices. Default: `0` (derive from matrices) + + Returns + ------- + matrices + The adjusted matrices for taking points from normalized to frame space in shape + ((Num re_feeds * N) + 1, 3, 3), in frame contiguous order (Na, Nb, Nc, Na1, Nb1, + Nc1...) + roi + The ((Num re_feeds * N) + 1, 4) roi for each adjusted feed. Returned if `with_roi` is + ``True`` + """ + if self._re_feeds == 0: + raise NotImplementedError + size_mat = (np.array([size], + dtype="float32") if size != 0 else matrices[:, 0, 0])[:, None, None] + + batch_size = matrices.shape[0] + d_scales = np.random.uniform(1.0 - self.beta, + 1.0 + self.beta, + size=(batch_size, self._re_feeds)) + d_shift = size_mat - np.random.uniform(1.0 - self.beta, + 1.0 + self.beta, + size=(batch_size, self._re_feeds, 2)) * size_mat + + mats = np.broadcast_to(matrices[:, None], (batch_size, self.total_feeds, 3, 3)).copy() + mats[:, 1:, (0, 1), (0, 1)] *= d_scales[:, :, None] + mats[:, 1:, :2, 2] += d_shift + mats = mats.reshape(-1, 3, 3) + if not with_roi: + logger.trace("re-feed. matrices: %s", # type: ignore[attr-defined] + format_array(mats)) + return mats + + tl_br = np.rint((mats @ self._corners).swapaxes(1, 2)) + roi = np.stack([tl_br[:, 0, 0], tl_br[:, 0, 1], tl_br[:, 1, 0], tl_br[:, 1, 1]], + axis=1).astype(np.int32) + logger.trace("re-feed. matrices: %s, roi: %s", # type: ignore[attr-defined] + format_array(mats), format_array(roi)) + return mats, roi + + def merge(self, landmarks: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: + """If re-feeds enabled return the average result from the re-feeds, otherwise the original + array + + Parameters + ---------- + landmarks + The (N x total_feeds, 68, 2) landmarks from the plugin + + Returns + ------- + The final (N, 68, 2) landmarks with any re-feeds merged + """ + if self.total_feeds == 1: + return landmarks + lm_shape = landmarks.shape + rf_shape = (lm_shape[0] // self.total_feeds, self.total_feeds, *lm_shape[1:]) + return landmarks.reshape(rf_shape).mean(axis=1) + + +class AlignedFilter: # pylint:disable=too-many-instance-attributes + """Applies filters to the output of the aligner + + Parameters + ---------- + enabled + ``True`` to enable filters. ``False`` to disable + """ + def __init__(self, enabled: bool) -> None: + logger.debug(parse_class_init(locals())) + self._counts: dict[str, int] = {"features": 0, "scale": 0, "distance": 0, "roll": 0} + self._features = cfg.aligner_features() + self._min_scale = cfg.aligner_min_scale() + self._max_scale = cfg.aligner_max_scale() + self._distance = cfg.aligner_distance() / 100. + self._roll = cfg.aligner_roll() + self.enabled = enabled or (not self._features and + self._min_scale <= 0.0 and + self._max_scale <= 0.0 and + self._distance <= 0.0 and + self._roll <= 0.0) + self._mean_face = MEAN_FACE[LandmarkType.LM_2D_51][None] + self._expansion = 1.0 - EXTRACT_RATIOS["face"] + + def output_counts(self) -> None: + """If filters are enabled info log the number of faces filtered""" + if not self.enabled: + return + counts = [] + for key, count in self._counts.items(): + if not count: + continue + txt = key.title() + if key in ("distance", "roll"): + txt += f" ({getattr(self, f'_{key}')})" + if key == "scale": + txt += f" (min: {self._min_scale}, max: {self._max_scale})" + counts.append(txt + f": {count}") + if counts: + logger.info("[Align filter] %s", ", ".join(counts)) + + def _handle_filtered(self, + key: str, + batch: ExtractBatch, + mask: npt.NDArray[np.bool]) -> None: + """Add the filtered item to the filter counts and update the batch object to remove + filtered faces + + Parameters + ---------- + key: str + The key to use for the filter counts dictionary and the sub_folder name + batch + The batch object to perform filtering on + mask + The mask to apply to filter the faces + + Returns + ------- + The filtered normalized landmarks + """ + if np.all(mask): + return + self._counts[key] += int(sum(~mask)) + batch.apply_mask(mask) + + def _filter_features(self, landmarks: npt.NDArray[np.float32]) -> npt.NDArray[np.bool]: + """Filter faces based on the location of relative eye and mouth features + + Parameters + ---------- + landmarks + The aligned landmarks in normalized (0. - 1.) space + + Returns + ------- + Boolean mask indicating faces to keep + """ + lowest_eyes = np.max(landmarks[:, np.r_[17:27, 36:48], 1], axis=1) + highest_mouth = np.min(landmarks[:, 48:68, 1], axis=1) + return (highest_mouth - lowest_eyes) > 0 + + def _filter_scale(self, batch: ExtractBatch) -> npt.NDArray[np.bool]: + """Filter faces based on the scale of the face relative to min/max thresholds. + + Parameters + ---------- + batch + The batch object to perform filtering on + + Returns + ------- + Boolean mask indicating faces to keep + """ + frames = np.array([i.shape[:2] for i in batch.images]).min(axis=1) + frame_ids = batch.frame_ids + + linear = batch.aligned.matrices[:, :2, 0] + sizes = 1.0 / (self._expansion * np.hypot(linear[:, 0], linear[:, 1])) + mins = (frames * self._min_scale)[frame_ids] + if self._max_scale: + maxes = (frames * self._max_scale)[frame_ids] + else: + maxes = sizes + return (mins <= sizes) & (maxes >= sizes) + + def __call__(self, batch: ExtractBatch) -> None: + """Apply aligner filters to the given batch + + Parameters + ---------- + batch + The batch object to perform filtering on with the landmarks populated + """ + if not self.enabled or batch.landmarks is None: + return + if batch.landmark_type not in (LandmarkType.LM_2D_68, LandmarkType.LM_2D_98): + logger.warning("[Align filter] Filters are not supported for %s landmarks", + batch.landmark_type) + self.enabled = False + return + if self._features: + self._handle_filtered("features", + batch, + self._filter_features(batch.aligned.landmarks_normalized)) + if self._min_scale > 0.0 or self._max_scale > 0.0: + self._handle_filtered("scale", batch, self._filter_scale(batch)) + if self._distance > 0.0: + d_msk = np.abs(batch.aligned.landmarks_normalized[:, 17:] - + self._mean_face).mean(axis=(1, 2)) <= self._distance + self._handle_filtered("distance", batch, d_msk) + if self._roll > 0.0: + r_msk = np.abs(Batch3D.roll(batch.aligned.rotation)) <= self._roll + self._handle_filtered("roll", batch, r_msk) + + +__all__ = get_module_objects(__name__) diff --git a/lib/infer/detect.py b/lib/infer/detect.py new file mode 100644 index 0000000000..69a97a87a6 --- /dev/null +++ b/lib/infer/detect.py @@ -0,0 +1,558 @@ +#! /usr/env/bin/python3 +"""Handles face detection plugins and runners """ +from __future__ import annotations + +import logging +import typing as T + +import cv2 +import numpy as np + + +from lib.logger import format_array, parse_class_init +from lib.utils import get_module_objects + +from .objects import ExtractBatch +from .handler import ExtractHandler + +if T.TYPE_CHECKING: + import numpy.typing as npt + +logger = logging.getLogger(__name__) + + +class Detect(ExtractHandler): + """Responsible for handling Detection plugins within the extract pipeline + + Parameters + ---------- + plugin + The plugin that this runner is to use + rotation | None + The rotation arguments. Either a list of angles between 0 and 360 to rotate at or a single + step size. Default: ``None``, no rotations + min_size + Minimum percentage of the frame's shortest edge to accept as a successful detection along + the detection's longest edge Default: `0` (accept all detections) + max_size + Maximum percentage of the frame's shortest edge to accept as a successful detection along + the detection's longest edge Default: `0` (accept all detections) + compile_model + ``True`` to compile any PyTorch models + config_file + Full path to a custom config file to load. ``None`` for default config + """ + def __init__(self, + plugin: str, + rotation: str | None = None, + min_size: int = 0, + max_size: int = 0, + compile_model: bool = False, + config_file: str | None = None) -> None: + logger.debug(parse_class_init(locals())) + super().__init__(plugin, compile_model=compile_model, config_file=config_file) + self._rotation = rotation + self._rotator = Rotator(rotation, self.plugin.input_size) + """Responsible for rotating feed images for the model""" + self._empty_bbox = np.empty((0, 4), dtype="float32") + """An empty detection result, that will never be used so only needs to be created once""" + self._min_size = min_size / 100. + """The user selected shortest frame dim multiplier to accept for minimum size""" + self._max_size = max_size / 100. + """The user selected shortest frame dim multiplier to accept for maximum size""" + self._filter_counts = 0 + + def __repr__(self) -> str: + """Pretty print for logging""" + retval = super().__repr__()[:-1] + retval += (f", rotation={repr(self._rotation)}, min_size={int(self._min_size * 100)}, " + f"max_size={int(self._max_size * 100)})") + return retval + + # Pre-processing + def _get_matrices(self, + images: list[npt.NDArray[np.uint8]], + filenames: list[str]) -> npt.NDArray[np.float32]: + """Calculate the scales and padding required to take each image in this batch to model + input size and store the matrices in the batch object + + Parameters + ---------- + images + The images to obtain the matrices for + filenames : list[str] + The corresponding file names of the images + + Returns + ------- + The transformation matrices for taking the images to model input size + """ + orig_wh = np.array([x.shape[:2] for x in images])[:, ::-1] + scales = self.plugin.input_size / orig_wh.max(axis=1) + new_wh = np.rint(orig_wh * scales[:, None]).astype(np.int32) + pad_xy = (self.plugin.input_size - new_wh) // 2 + + retval = np.zeros((len(scales), 3, 3), dtype="float32") + retval[:, 0, 0] = scales + retval[:, 1, 1] = scales + retval[:, 0, 2] = pad_xy[:, 0] + retval[:, 1, 2] = pad_xy[:, 1] + retval[:, 2, 2] = 1. + + logger.trace( # type:ignore[attr-defined] + "[%s_pre_process] filenames: %s, matrices: %s", + self.plugin.name, filenames, format_array(retval)) + return retval + + def _scale_images(self, + images: list[npt.NDArray[np.uint8]], + matrices: npt.NDArray[np.float32]) -> npt.NDArray[np.uint8]: + """Scale the image and pad to given size + + Parameters + ---------- + images + The images to scale + matrices + The corresponding warp matrices for scaling the images + + Returns + ------- + The scaled images + """ + retval = np.zeros((len(images), self.plugin.input_size, self.plugin.input_size, 3), + dtype=images[0].dtype) + interpolators = np.where(matrices[:, 0, 0] < 1.0, cv2.INTER_AREA, cv2.INTER_CUBIC) + dims = (self.plugin.input_size, self.plugin.input_size) + warp_mats = matrices[:, :2] + for idx, (image, mat, interpolator) in enumerate(zip(images, warp_mats, interpolators)): + image = image[..., 2::-1] if self.plugin.is_rgb else image + cv2.warpAffine(image, mat, dims, dst=retval[idx], flags=interpolator) + logger.trace("Resized batch shape: %s", retval.shape) # type:ignore[attr-defined] + return retval + + def pre_process(self, batch: ExtractBatch) -> None: + """Perform pre-processing for detection plugins. + + - Gets the scale and padding to take the batch of images to model input size + - Formats the image to the correct color order, dtype and scale for the plugin + - Performs any plugin specific pre-processing + + Parameters + ---------- + batch + The incoming ExtractBatch to use for pre-processing + """ + batch.matrices = self._get_matrices(batch.images, batch.filenames) + images = self._scale_images(batch.images, batch.matrices) + images = self._format_images(images) + batch.data = self.plugin.pre_process(images) + + # Processing + def _process_rotations(self, + predictions: npt.NDArray[np.float32], + mask_requires: npt.NDArray[np.bool_], + indices_angle: npt.NDArray[np.int32], + box_list: list[npt.NDArray[np.float32] | None], + rotation_index: int) -> None: + """Process the output after a rotation, and store the discovered boxes and the angle index + they were discovered at + + Parameters + ---------- + predictions + The predictions from the model + mask_requires + The mask indicating which frames can still be allocated bounding boxes + indices_angle + The array that stores the angle index that each frame's faces was found at + box_list + The list of final bounding boxes to be output + rotation_index + The current angle index we are iterating + """ + bboxes = (self.plugin.post_process(predictions) if self._overridden["post_process"] + else predictions) + mask_found = np.array([np.any(n) for n in bboxes], dtype="bool") + indices_requires = np.flatnonzero(mask_requires) + indices_angle[indices_requires[mask_found]] = rotation_index + mask_requires[indices_requires[mask_found]] = False + for i, box in zip(indices_requires[mask_found], bboxes): + box_list[i] = box + + def process(self, batch: ExtractBatch) -> None: + """Obtain the output from the plugin's model. + + Executes the plugin's predict function and stores the output prior to post-processing. + + If rotations have been selected, plugin post-processing is done as part of this process as + the computed bounding boxes are required for re-feeding the model future rotations + + Parameters + ---------- + batch + The incoming ExtractBatch to use for processing + """ + process = "process" + input_images = batch.data + batch_size = input_images.shape[0] + box_list: list[None | np.ndarray] = [None for _ in range(batch_size)] + boxes: np.ndarray | None = None + indices_angle = np.zeros((batch_size, ), dtype="int32") + + idx = 0 + mask_requires = np.array([True for _ in range(batch_size)]) + while True: + feed = self._rotator.rotate(idx, input_images[mask_requires]) + if feed is None: + logger.trace( # type:ignore[attr-defined] + "[%s.%s] No faces found in %s image(s) of %s after %s rotations: %s", + self.plugin.name, + process, + mask_requires.sum(), + batch_size, + idx, + batch.filenames) + + break + result = self._predict(feed) + if not self._rotator.enabled: + # Not rotating. Do post-processing in next thread + boxes = result + break + + # We are rotating, so we have to do post-processing here, to re-feed model + self._process_rotations(result, mask_requires, indices_angle, box_list, idx) + if not np.any(mask_requires): + logger.trace( # type:ignore[attr-defined] + "[%s.%s] Found faces for all %s images after %s rotations: %s", + self.plugin.name, + process, + batch_size, + idx + 1, + batch.filenames) + break + idx += 1 + + boxes = (np.array([self._empty_bbox if b is None else b for b in box_list], + dtype="object") + if boxes is None else boxes) + batch.data = np.empty(2, dtype="object") + batch.data[0] = indices_angle + batch.data[1] = boxes + + # Post-Processing + def _stack_boxes(self, + batch: ExtractBatch, + predictions: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: + """Stack the detected boxes into a single array, remove any zero sized boxes, collate the + indexing information and add to batch + + Parameters + ---------- + batch + The detector batch being processed + predictions + The face detection bounding boxes received from the plugin + + Returns + ------- + The stacked detection boxes from all frames in the batch. + """ + valid = np.fromiter((i for i, p in enumerate(predictions) if np.any(p)), dtype=np.int32) + if not valid.size: + batch.frame_ids = valid + return self._empty_bbox + + result = [predictions[i] for i in valid] + lengths = np.fromiter((a.shape[0] for a in result), dtype=np.int32) + batch.frame_ids = np.repeat(valid, lengths) + return np.vstack(result).astype(np.float32) + + def _scale_boxes(self, batch: ExtractBatch, predictions: npt.NDArray[np.float32]) -> None: + """Scale the detected faces back out to original image size, round to int and add to the + batch object + + Parameters + ---------- + batch + The detector batch being processed + predictions + The stacked face detection predictions at model input size + """ + if not batch.frame_ids.size: + return + mats = batch.matrices[batch.frame_ids] + + predictions[:, [0, 2]] -= mats[:, 0, 2][:, None] + predictions[:, [1, 3]] -= mats[:, 1, 2][:, None] + predictions /= mats[:, 0, 0][:, None] + np.rint(predictions, out=predictions) + batch.bboxes = predictions.astype("int32") + logger.trace("[%s.out] Finalized batch: %s", # type:ignore[attr-defined] + self.plugin.name, + batch) + + def _filter_boxes(self, batch: ExtractBatch) -> None: + """Filter out any detections that are smaller or larger than :attr:`_min_size` and + :attr:`_max_size` along their longest edge + + Parameters + ---------- + batch + The detector batch being processed with fully scaled bounding boxes + """ + if not self._min_size and not self._max_size: + return + + frames = np.array([i.shape[:2] for i in batch.images]).min(axis=1) + sizes = np.maximum(batch.bboxes[:, 2] - batch.bboxes[:, 0], + batch.bboxes[:, 3] - batch.bboxes[:, 1]) + + mins = (frames * self._min_size).astype("int32")[batch.frame_ids] + if self._max_size: + maxes = (frames * self._max_size).astype("int32")[batch.frame_ids] + else: + maxes = sizes + + keep = np.nonzero(np.logical_and(mins <= sizes, maxes >= sizes))[0] + if len(keep) == len(sizes): + return + + logger.debug( + "[%s.out] Removing %s face(s) from %s detections as outside size thresholds (min: %s, " + "max: %s): %s", + self.plugin.name, + len(sizes) - len(keep), + len(sizes), + int(self._min_size * 100), + int(self._max_size * 100), + batch.filenames) + + batch.bboxes = batch.bboxes[keep] + batch.frame_ids = batch.frame_ids[keep] + self._filter_counts += len(sizes) - len(keep) + + def post_process(self, batch: ExtractBatch) -> None: + """Perform detection post processing. + + If no rotations were requested, any plugin post-processing will be done here. + + Detection boxes are: + - stacked into a single array + - scaled back to frame dimensions, + - filtered for faces which fall outside min/max thresholds + - Added to the batch object along with frame to face mapping information. + + Parameters + ---------- + batch + The incoming ExtractBatch to use for post-processing + """ + indices_angle, result = batch.data + if self._overridden["post_process"] and not self._rotator.enabled: + result = self.plugin.post_process(result) + else: + self._rotator.un_rotate(indices_angle, result) + result = self._stack_boxes(batch, result) + self._scale_boxes(batch, result) + self._filter_boxes(batch) + + def output_info(self) -> None: + """Output the counts of filtered items """ + if not self._filter_counts: + return + logger.info("[Detect filter] Scale (min: %s, max: %s): %s", + f"{int(self._min_size * 100)}%", + f"{int(self._max_size * 100)}%", + self._filter_counts) + + +class Rotator: + """Handles pre-calculation of rotation matrices when rotation angles are requested and + rotating images for feeding the detector. Handles reversing the rotation for any found + detection bounding boxes. + + Parameters + ---------- + rotation + List of requested rotation angles in degrees provided in command line arguments + image_size + The size of the square image to obtain rotation matrices for + """ + def __init__(self, angles: str | None, image_size: int) -> None: + logger.debug(parse_class_init(locals())) + self._size = image_size + self._angles = self._get_angles(angles) + self._matrices = self._pre_compute_matrices() + self._matrices_inverse = self._pre_compute_inverse_matrices() + self._channels_first: bool | None = None + self.enabled = len(self._angles) > 1 + """``True`` if rotations are to be performed """ + + @classmethod + def _angles_from_step(cls, step_size: int) -> npt.NDArray[np.float32]: + """Obtain the required rotation angles when the cli argument has been passed in as a step + size + + Parameters + ---------- + step_size + The requested step size + + Returns + ------- + The rotation angles between 0 and 360 for the given step size + """ + retval = np.arange(0, 360, step_size, dtype="float32") + logger.debug("Setting rotation angles to %s from step size: %s", retval, step_size) + return retval + + def _get_angles(self, rotation: str | None) -> npt.NDArray[np.float32]: + """Set the rotation angles. + + Parameters + ---------- + rotation + List of requested rotation angles in degrees provided in command line arguments + + Returns + ------- + The complete list of rotation angles to apply in degrees + """ + if not rotation: + logger.debug("Not setting rotation angles") + return np.array([0], dtype=np.float32) + + passed_angles = [int(angle) for angle in rotation.split(",") if int(angle) != 0] + if len(passed_angles) == 1: + return self._angles_from_step(passed_angles[0]) + + retval = np.array([0] + passed_angles, dtype=np.float32) + logger.debug("Setting rotation angles to %s from given: %s", retval, rotation) + return retval + + def _pre_compute_matrices(self) -> npt.NDArray[np.float32]: + """Pre-compute the rotation matrices required to perform the requested rotations for the + given square image size + + Returns + ------- + The rotation matrices for the requested rotation angles + """ + theta = np.deg2rad(self._angles) + cos_t = np.cos(theta) + sin_t = np.sin(theta) + cx = (self._size - 1) / 2.0 + cy = (self._size - 1) / 2.0 + + matrices = np.zeros((len(self._angles), 2, 3), dtype=np.float32) + matrices[:, 0, 0] = cos_t + matrices[:, 0, 1] = -sin_t + matrices[:, 1, 0] = sin_t + matrices[:, 1, 1] = cos_t + matrices[:, 0, 2] = (1 - cos_t) * cx + sin_t * cy + matrices[:, 1, 2] = (1 - cos_t) * cy - sin_t * cx + logger.debug("Precomputed rotation matrices: %s", matrices.tolist()) + return matrices + + def _pre_compute_inverse_matrices(self) -> npt.NDArray[np.float32]: + """Pre-compute the inverse rotation matrices required to perform translation from rotated + bounding boxes back to original frame + + Returns + ------- + The rotation matrices for the requested rotation angles + """ + rot = self._matrices[:, :, :2] + trans = self._matrices[:, :, 2] + rot_inv = np.transpose(rot, (0, 2, 1)) + trans_inv = -np.einsum('nij, nj->ni', rot_inv, trans) + retval = np.concatenate([rot_inv, trans_inv[..., None]], axis=2) + logger.debug("Precomputed inverse rotation matrices: %s", retval.tolist()) + return retval + + def rotate(self, rotation_index: int, images: np.ndarray) -> np.ndarray | None: + """Rotate a batch of images by the matrix provided by the given rotation index. Attempts + to detect and handle channels first images as well as channels last + + Parameters + ---------- + rotation_index + The matrix to use. This will be an incrementing index from an enumerated loop that + selects through the matrices stored for each angle + images + The original, correctly orientated, batch of images to rotate + + Returns + ------- + The batch of image rotated by the angle identified by the given rotation index. + ``None`` if the given rotation index is invalid + """ + if rotation_index == 0: + return images + if rotation_index >= len(self._angles): + return None + + if self._channels_first is None: + self._channels_first = images.shape[1] in (1, 3, 4) + logger.debug("Set channels_first to %s", self._channels_first) + + if self._channels_first: + images = images.transpose(0, 2, 3, 1) + + retval = np.empty(images.shape, images.dtype) + mat = self._matrices[rotation_index] + size = (self._size, self._size) + + for i, img in enumerate(images): + cv2.warpAffine(img, + mat, + size, + dst=retval[i], + borderMode=cv2.BORDER_REPLICATE) + + if self._channels_first: + retval = retval.transpose(0, 3, 1, 2) + + return retval + + def un_rotate(self, + indices_angle: npt.NDArray[np.int32], + roi: npt.NDArray[np.float32]) -> None: + """Un-rotate the given bounding boxes for the given angle indices and update in place + + Parameters + ---------- + indices_angle + The angle indices that correlate to the angle each roi was rotated to to obtain the + result + roi + Ragged array of (B, N, 4) detected bounding discovered at the corresponding angle + index + """ + mask_needs_rotate = indices_angle > 0 + if not np.any(mask_needs_rotate): + return + + indices_needs_rotate = np.flatnonzero(mask_needs_rotate) + matrices = self._matrices_inverse[indices_angle[mask_needs_rotate]] + + for pred_idx, mat in zip(indices_needs_rotate, matrices): + bboxes = roi[pred_idx] + pts = np.empty((bboxes.shape[0], 4, 2), dtype="float32") + pts[:, 0] = bboxes[:, [0, 1]] # lt + pts[:, 1] = bboxes[:, [2, 1]] # rt + pts[:, 2] = bboxes[:, [2, 3]] # rb + pts[:, 3] = bboxes[:, [0, 3]] # lb + + pts = pts @ mat[:, :2].T + mat[:, 2] + + # boxes must align on (x, y) planes + bboxes[:, 0] = pts[..., 0].min(axis=1) + bboxes[:, 1] = pts[..., 1].min(axis=1) + bboxes[:, 2] = pts[..., 0].max(axis=1) + bboxes[:, 3] = pts[..., 1].max(axis=1) + + +__all__ = get_module_objects(__name__) diff --git a/lib/infer/handler.py b/lib/infer/handler.py new file mode 100644 index 0000000000..18f4a0a260 --- /dev/null +++ b/lib/infer/handler.py @@ -0,0 +1,485 @@ +#! /usr/env/bin/python3 +"""Handles individual plugins within a plugin runner """ +from __future__ import annotations + +import abc +import logging +import typing as T + +import numpy as np +from torch.cuda import OutOfMemoryError + +from lib.align.aligned_utils import (batch_adjust_matrices, batch_align, batch_resize, + batch_sub_crop) +from lib.align.constants import EXTRACT_RATIOS, LandmarkType +from lib.logger import parse_class_init +from lib.utils import FaceswapError, get_module_objects +from plugins.plugin_loader import PluginLoader +from plugins.extract.base import ExtractPlugin +from plugins.extract.extract_config import load_config +from .plugin_utils import compile_models, get_torch_modules, warmup_plugin + +from .runner import ExtractRunner + + +if T.TYPE_CHECKING: + import numpy.typing as npt + from lib.align.constants import CenteringType + from plugins.extract.base import FacePlugin + from .objects import ExtractBatch + +logger = logging.getLogger(__name__) + + +OOM_MESSAGE = ( + "You do not have enough GPU memory available to run detection at the selected batch size. You" + "can try a number of things:" + "\n1) Close any other application that is using your GPU (web browsers are particularly bad " + "for this)." + "\n2) Try again. Sometimes this can be a transient issue when you are close to VRAM capacity." + "\n3) Lower the batch size (the amount of images fed into the model) by editing the plugin " + "settings (GUI: Settings > Configure extract settings, CLI: Edit the file " + "faceswap/config/extract.ini)." + "\n4) Use lighter weight plugins." + "\n5) Enable fewer plugins." +) + + +class ExtractHandler(abc.ABC): + """Handles the execution of a plugin's pre_process, process and post_process actions + + Parameters + ---------- + plugin + The name of the plugin that this handler is to use + compile_model + ``True`` to compile any PyTorch models + config_file + Full path to a custom config file to load. ``None`` for default config + """ + processors: tuple[T.Literal["pre_process", "process", "post_process"], + ...] = ("pre_process", "process", "post_process") + """The processors which should have thread's launched for this handler""" + + def __init__(self, + plugin: str, + compile_model: bool = False, + config_file: str | None = None) -> None: + self.plugin_type: T.Literal["detect", + "align", + "mask", + "identity", + "file"] = self._get_plugin_type() + """The type of plugin that this handler manages""" + self._config_file = config_file + self.do_compile = compile_model + """``True`` if any managed Torch modules are to be compiled""" + self.plugin_name = plugin + """The name of the plugin that is being handled""" + load_config(config_file) + self.plugin = PluginLoader.get_extractor(self.plugin_type, plugin) + """The extraction plugin that this handler manages""" + self._overridden: dict[T.Literal["pre_process", "process", "post_process"], bool] = { + method: self._is_overridden(method) for method in self.processors} + self._runner: ExtractRunner | None = None + + def __repr__(self) -> str: + """Pretty print for logging""" + params = {"plugin": repr(self.plugin_name), + "compile_model": self.do_compile, + "config_file": repr(self._config_file)} + return f"{self.__class__.__name__}({', '.join(f'{k}={v}' for k, v in params.items())})" + + @property + def batch_size(self) -> int: + """The batch size of the plugin""" + return self.plugin.batch_size + + @property + def runner(self) -> ExtractRunner: + """The runner that runs this handler""" + assert self._runner is not None, "The handler must be called prior to accessing its runner" + return self._runner + + @classmethod + def _get_plugin_type(cls) -> T.Literal["detect", "align", "mask", "identity"]: + """Obtain the type of extraction plugin that this runner is responsible for + + Returns + ------- + The type of plugin that this runner is using + """ + plugin_type = T.cast(T.Literal["detect", "align", "mask", "identity"], + cls.__name__.lower().replace("handler", "")) + assert plugin_type in ("detect", "align", "mask", "identity") + return plugin_type + + def _is_overridden(self, method_name: T.Literal["pre_process", "process", "post_process"] + ) -> bool: + """Test if a plugin method's method has been overridden + + Parameters + ---------- + method_name + The name of the method that is to be checked + + Returns + ------- + ``True`` if the plugin has overridden the given method + """ + plugin_class = type(self.plugin) + retval = ( + method_name in plugin_class.__dict__ + and plugin_class.__dict__[method_name] is not ExtractPlugin.__dict__.get(method_name) + ) + logger.debug("[%s] Overridden method '%s': %s", self.plugin_name, method_name, retval) + return retval + + def init_model(self) -> None: + """Load the model, compile it, if requested, and send a warmup batch through. Called either + from the main thread, if compiling, or from the inference thread if not.""" + logger.debug("[%s.load] Loading model", self.plugin_name) + self.plugin.model = self.plugin.load_model() + + torch_modules = get_torch_modules(self.plugin) + if not torch_modules or not self.do_compile: + logger.debug("[%s.load] Plugin does not need compiling", self.plugin.name) + warmup_plugin(self.plugin, self.plugin.batch_size) + return + logger.debug("[%s.load] Compiling plugin", self.plugin.name) + compile_models(self.plugin, torch_modules) + + def _predict(self, feed: np.ndarray) -> np.ndarray: + """Obtain a prediction from the plugin + + Parameters + ---------- + feed + The batch to feed the model + + Returns + ------- + The prediction from the model + + Raises + ------ + FaceswapError + If an OOM occurs + """ + feed_size = feed.shape[0] + is_padded = self.do_compile and feed_size < self.plugin.batch_size + batch_feed = feed + if is_padded: # Prevent model re-compile on undersized batch + batch_feed = np.empty((self.plugin.batch_size, *feed.shape[1:]), dtype=feed.dtype) + logger.debug("[%s.process] Padding undersized batch of shape %s to %s", + self.plugin.name, feed.shape, batch_feed.shape) + batch_feed[:feed_size] = feed + try: + retval = self.plugin.process(batch_feed) + except OutOfMemoryError as err: + raise FaceswapError(OOM_MESSAGE) from err + if is_padded and retval.dtype == "object": + out = np.empty(retval.shape, dtype="object") + out[:] = [x[:feed_size] for x in retval] + retval = out + elif is_padded: + retval = retval[:feed_size] + return retval + + def _format_images(self, images: npt.NDArray[np.uint8]) -> np.ndarray: + """Format the incoming UINT8 0-255 images to the format specified by the plugin + + Parameters + ---------- + images + The batch of UINT8 images to format + + Returns + ------- + The batch of images formatted and scaled for the plugin + """ + retval = images if self.plugin.dtype == np.uint8 else images.astype(self.plugin.dtype) + if self.plugin.scale == (0, 255): + return retval + low, high = self.plugin.scale + im_range = high - low + retval /= (255. / im_range) + retval += low + return retval + + def output_info(self) -> None: + """Called after the final item is put to the out queue. Override for plugin runner + specific output""" + return + + @abc.abstractmethod + def pre_process(self, batch: ExtractBatch) -> None: + """ Override to perform plugin type specific behavior for pre-processing on the given batch + object, ready for inference. + + Parameters + ---------- + batch + The incoming ExtractBatch to use for pre-processing + """ + + @abc.abstractmethod + def process(self, batch: ExtractBatch) -> None: + """Override to plugin type specific processing to get results from the plugin's inference + for the given batch. + + Parameters + ---------- + batch + The incoming ExtractBatch to use for processing + """ + + @abc.abstractmethod + def post_process(self, batch: ExtractBatch) -> None: + """Perform post-processing on the given batch object, ready for exit from the plugin. + Override for plugin type specific behavior + + Parameters + ---------- + batch + The incoming ExtractBatch to use for post-processing + """ + + def __call__(self, input_plugin: ExtractHandler | ExtractRunner | None = None, + profile: bool = False) -> ExtractRunner: + """Build and start the plugin handler's runner + + Parameters + ---------- + input_plugin + The input plugin handler or it's runner that feeds this handler. ``None`` if data is + to be fed through the handler runner's `put` method (ie, the first handler in an + extraction chain). Default: ``None`` + profile + ``True`` if the runner is to be profiled, indicating that threads will not be started. + Default: ``False`` + + Returns + ------- + The extract plugin handler's runner for this handler + """ + logger.debug("[%s] Initializing runner from handler", self.plugin.name) + runner = ExtractRunner(self) + input_runner = input_plugin.runner if isinstance(input_plugin, + ExtractHandler) else input_plugin + runner(input_runner, profile) + return runner + + +class ExtractHandlerFace(ExtractHandler, abc.ABC): + """Handles an extract plugin. Extended with methods common to plugins that use aligned face + images as input + + Parameters + ---------- + plugin + The name of the plugin that this runner is to use + compile_model + ``True`` to compile any PyTorch models + config_file + Full path to a custom config file to load. ``None`` for default config + """ + _logged_warning: dict[str, bool] = {"mask": False, "identity": False} + """Stores whether a warning has been issued for non-68 point landmarks for this plugin type""" + + def __init__(self, + plugin: str, + compile_model: bool = False, + config_file: str | None = None) -> None: + super().__init__(plugin, compile_model=compile_model, config_file=config_file) + self.plugin: FacePlugin + + self._input_size = self.plugin.input_size + self._centering: CenteringType = self.plugin.centering + self.storage_name = self.plugin.storage_name + """The name that the object will be stored with in the alignments file""" + + self._padding = round((self._input_size * EXTRACT_RATIOS[self._centering]) / 2) + self._aligned_mat_name = ("matrices" if self._centering == "legacy" + else f"matrices_{self._centering}") + + # Aligned handling + self._head_to_base_ratio = (1 - EXTRACT_RATIOS["head"]) / 2 + self._head_to_centering_ratio = ((1 - EXTRACT_RATIOS["head"]) / + (1 - EXTRACT_RATIOS[self._centering]) / 2) + self._aligned_offsets_name = f"offsets_{self._centering}" + + def _maybe_log_warning(self, landmark_type: LandmarkType | None) -> None: + """Log a warning the first time if/when non-68 point landmarks are seen + + Parameters + ---------- + landmark_type + The type of landmarks within the batch + """ + assert landmark_type is not None + if self._logged_warning[self.plugin_type] or landmark_type in (LandmarkType.LM_2D_68, + LandmarkType.LM_2D_98): + return + ptype = "Masks" if self.plugin_type == "mask" else "Identities" + logger.warning("Faces do not contain landmark data. %s are likely to be sub-standard", + ptype) + self._logged_warning[self.plugin_type] = True + + # Pre-processing + def _get_matrices(self, matrices: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: + """Obtain the (N, 2, 3) matrices for the face plugin's centering type + + Parameters + ---------- + matrices + The normalized alignment matrices for aligning faces from the image + + Returns + ------- + The adjustment matrices for taking the image patch from the image for plugin input + """ + return batch_adjust_matrices(matrices, self._input_size, self._padding) + + def _get_faces(self, # pylint:disable=too-many-locals + images: list[npt.NDArray[np.uint8]], + image_ids: npt.NDArray[np.int32], + matrices: npt.NDArray[np.float32], + with_alpha: bool = False) -> npt.NDArray[np.uint8]: + """Obtain the cropped and aligned faces from the batch of images + + Parameters + ---------- + images + The full size frames for the batch + image_ids + The image ids for each detected face + matrices + The adjustment matrices for taking the image patch from the frame for plugin input + with_alpha + ``True`` to add a filled alpha channel to the batch of images prior to warping to + faces. Default: ``False`` + + Returns + ------- + Batch of 3 or 4 channel face patches for feeding the model. If `with_alpha` is selected + then the final channel is an ROI mask indicating areas that go out of bounds + """ + if with_alpha: + images = [np.concatenate([i, np.zeros((*i.shape[:2], 1), dtype=i.dtype) + 255], + axis=-1) + for i in images] + return batch_align(images, image_ids, matrices, self._input_size) + + # Aligned faces as input methods + def _get_faces_aligned(self, + images: list[npt.NDArray[np.uint8]], + image_ids: npt.NDArray[np.int32], + source_padding: npt.NDArray[np.float32], + dest_padding: npt.NDArray[np.float32]) -> npt.NDArray[np.uint8]: + """Obtain the batch of faces when input images are a batch of extracted faceswap faces + + Parameters + ---------- + images + The batch of faceswap extracted faces to obtain the model input images from + image_ids + The image ids for each detected face + source_padding + The normalized (N, x, y) padding used for the aligned image's centering + dest_padding + The normalized (N, x, y) padding used for the plugin's centering + + Returns + ------- + The sub-crop from the aligned faces for feeding the model + """ + imgs = np.array([images[idx] for idx in image_ids] if len(images) != len(image_ids) + else images) + assert imgs.dtype != object, "Aligned images must all be the same size" + if self._centering == "head": + return batch_resize(imgs, self._input_size) + + src_size = imgs.shape[1] + out_size = 2 * int(np.rint(src_size * self._head_to_centering_ratio)) + base_size = 2 * int(np.rint(src_size * self._head_to_base_ratio)) + padding_diff = (src_size - out_size) // 2 + delta = dest_padding - source_padding + offsets = np.rint(delta * base_size + padding_diff).astype(np.int32) + imgs = batch_sub_crop(imgs, offsets, out_size) + return batch_resize(imgs, self._input_size) + + def process(self, batch: ExtractBatch) -> None: + """Perform inference to get results from the plugin for the given batch. Override for + plugin type specific processing + + Parameters + ---------- + batch + The incoming ExtractBatch to use for processing + """ + batch.data = self._predict(batch.data) + + +class FileHandler(ExtractHandler): + """A pseudo handler that passes through data when the pipeline is driven entirely by an + alignments file (ie: no plugins are being loaded). This is effectively a No-op which allows + the data to pass straight from input to output""" + processors: tuple[T.Literal["pre_process", "process", "post_process"], ...] = tuple() + """File handler launches no threads""" + + class Plugin: # pylint:disable=too-few-public-methods + """Dummy plugin with required properties""" + name = "file" + batch_size = 128 # Irrelevant, data is just passed through + + def __init__(self) -> None: # pylint:disable=super-init-not-called + # Don't call super as we are not compatible + logger.debug(parse_class_init(locals())) + self.do_compile = False + self.plugin_type = "file" + self.plugin_name = "file" + self.plugin = self.Plugin # type:ignore[assignment] + self._runner: ExtractRunner | None = None + + def __repr__(self) -> str: + """Pretty print for logging""" + return f"{self.__class__.__name__}()" + + def pre_process(self, batch: ExtractBatch) -> None: + """Not applicable for passthrough plugin.""" + return + + def post_process(self, batch: ExtractBatch) -> None: + """Not applicable for passthrough plugin.""" + return + + def process(self, batch: ExtractBatch) -> None: + """Not applicable for passthrough plugin.""" + return + + def __call__(self, input_plugin: ExtractHandler | ExtractRunner | None = None, + profile: bool = False) -> ExtractRunner: + """Build and start the plugin handler's runner. Overridden to ensure that neither an input + plugin or profile are set + + Parameters + ---------- + input_plugin + The input plugin handler or it's runner that feeds this handler. ``None`` if data is + to be fed through the handler runner's `put` method (ie, the first handler in an + extraction chain). Must be ``None`` for file handler + profile + ``True`` if the runner is to be profiled, indicating that threads will not be started. + Must be ``False`` for file handler + + Returns + ------- + The extract plugin handler's runner for this handler + """ + assert input_plugin is None, "input_plugin must be ``None`` for file handler" + assert not profile, "profile must be ``False`` for file handler" + return super().__call__(input_plugin=None, profile=False) + + +get_module_objects(__name__) diff --git a/lib/infer/identity.py b/lib/infer/identity.py new file mode 100644 index 0000000000..736ec978fc --- /dev/null +++ b/lib/infer/identity.py @@ -0,0 +1,705 @@ +#! /usr/env/bin/python3 +"""Handles face identity plugins and runners""" +from __future__ import annotations + +import logging +import os +import sys +import typing as T + +import cv2 +import numpy as np +import psutil +from fastcluster import linkage, linkage_vector + +from lib.align.detected_face import DetectedFace +from lib.image import png_read_meta +from lib.logger import parse_class_init +from lib.utils import FaceswapError, get_module_objects, IMAGE_EXTENSIONS + +from .objects import ExtractBatch +from .handler import ExtractHandlerFace + +if T.TYPE_CHECKING: + import numpy.typing as npt + from collections.abc import Generator + from lib.align.alignments import PNGHeaderDict + from .runner import ExtractRunner + +logger = logging.getLogger(__name__) + + +class Identity(ExtractHandlerFace): + """Responsible for handling Identity/Recognition plugins within the extract pipeline + + Parameters + ---------- + plugin + The plugin that this runner is to use + filter_threshold + The threshold to use when filtering faces by identity. Default: 0.4 + compile_model + ``True`` to compile any PyTorch models + config_file + Full path to a custom config file to load. ``None`` for default config + """ + def __init__(self, + plugin: str, + threshold: float = 0.4, + compile_model: bool = False, + config_file: str | None = None) -> None: + logger.debug(parse_class_init(locals())) + super().__init__(plugin, compile_model=compile_model, config_file=config_file) + self._filter = IdentityFilter(threshold, self.storage_name) + + def __repr__(self) -> str: + """Pretty print for logging""" + retval = super().__repr__()[:-1] + retval += (f", threshold={self._filter.threshold})") + return retval + + def pre_process(self, batch: ExtractBatch) -> None: + """Obtain the aligned face images at the requested size, centering and image format. + Perform any plugin specific pre-processing + + Parameters + ---------- + batch + The incoming ExtractBatch to use for pre-processing + """ + self._maybe_log_warning(batch.landmark_type) + if batch.is_aligned: + data = self._get_faces_aligned(batch.images, + batch.frame_ids, + batch.aligned.offsets_head, + getattr(batch.aligned, self._aligned_offsets_name)) + else: + matrices = self._get_matrices(getattr(batch.aligned, self._aligned_mat_name)) + data = self._get_faces(batch.images, batch.frame_ids, matrices, with_alpha=False) + data = self._format_images(data) + batch.data = self.plugin.pre_process(data) + + def post_process(self, batch: ExtractBatch) -> None: + """Perform recognition post processing. + + Obtains the final output from the identity plugin and performs any plugin specific post- + processing + + Parameters + ---------- + batch + The incoming ExtractBatch to use for post-processing + """ + identity = batch.data + if self._overridden["post_process"]: + identity = self.plugin.post_process(identity) + batch.identities[self.storage_name] = identity + self._filter(batch) + + def add_filter_identities(self, identities: npt.NDArray[np.float32], is_filter: bool) -> None: + """Add the given identities to the identity filter + + Parameters + ---------- + identities + The identity embeddings to add to the filter + is_filter + ``True`` for filter, ``False`` for nFilter + """ + self._filter.add_identities(identities, is_filter) + + def output_info(self) -> None: + """Output the counts from the identity filter""" + self._filter.output_counts() + + +class IdentityFilter: + """Handles filtering of faces based on provided image files + + Parameters + ---------- + threshold + The threshold value for filtering out items + name + The name of the identity plugin running + """ + def __init__(self, threshold: float, name: str) -> None: + logger.debug(parse_class_init(locals())) + self.threshold = threshold + """The threshold for accepting a filter result""" + self._plugin_name = name + self._name = f"{name}.Filter" + + self._filters = {"filter": np.empty([0], dtype="float32"), + "nfilter": np.empty([0], dtype="float32")} + self._active: set[T.Literal["filter", "nfilter"]] = set() + self._counts = {"filter": 0, "nfilter": 0, "combined": 0} + self._active_count = 0 + self.enabled = False + """``True`` if the identity filter is enabled""" + + def add_identities(self, identities: npt.NDArray[np.float32], is_filter: bool) -> None: + """Add the given identities to the filter + + Parameters + ---------- + identities + The identity embeddings to add to the filter + is_filter + ``True`` for filter, ``False`` for nFilter + """ + logger.debug("[%s] Adding identities: %s, is_filter: %s", + self._name, identities.shape, is_filter) + key: T.Literal["filter", "nfilter"] = "filter" if is_filter else "nfilter" + self._filters[key] = identities + if np.any(identities): + self._active.add(key) + self.enabled = bool(self._active) + self._active_count = len(self._active) + + def output_counts(self) -> None: + """If filter is enabled info log the number of faces filtered""" + # pylint:disable=duplicate-code + if not self.enabled: + return + counts = [] + for key, count in self._counts.items(): + if not count: + continue + txt = key.title() if key != "nfilter" else "nFilter" + counts.append(txt + f": {count}") + if counts: + logger.info("[Identity filter] %s", ", ".join(counts)) + + @classmethod + def _find_cosine_similarity(cls, + source: npt.NDArray[np.float32], + batch: npt.NDArray[np.float32]) -> npt.NDArray[np.float64]: + """Find the cosine similarity between a source face identity and a test face identity + + Parameters + --------- + source + The identity encoding for the source face identities + batch + A batch of face identities to test against the sources + + Returns + ------- + The cosine similarity between the face identities and the source identities + """ + s_norms = source / np.linalg.norm(source, axis=1, keepdims=True) + t_norms = batch / np.linalg.norm(batch, axis=1, keepdims=True) + retval = t_norms @ s_norms.T + return retval + + def __call__(self, batch: ExtractBatch) -> None: + """Apply the identity filter to the given batch + + Parameters + ---------- + batch + The batch object to perform filtering on with the identities populated + """ + if not self.enabled: + return + identities = batch.identities[self._plugin_name] + mask = np.empty((self._active_count, batch.bboxes.shape[0]), dtype="bool") + for idx, f_type in enumerate(sorted(self._active)): + similarities = self._find_cosine_similarity(self._filters[f_type], identities) + matches = np.any(similarities >= self.threshold, axis=1) + mask[idx] = ~matches if f_type == "nfilter" else matches + self._counts[f_type] += int(np.sum(~mask[idx])) + + if np.all(mask): + return + + if self._active_count > 1: + mask = T.cast("npt.NDArray[np.bool_]", mask.all(axis=0)) + self._counts["combined"] += int(np.sum(~mask)) + else: + mask = mask[0] + batch.apply_mask(mask) + + +class FilterLoader: + """Obtains face embeddings from images and loads the IdentityFilter as part of the extraction + pipeline + + Parameters + ---------- + threshold + The threshold value for filtering out items. Default: 0.4 + filter_files + The list of full paths to the files to use for filtering. Default: ``None`` (don't use + filter) + nfilter_files + The list of full paths to the files to use to nfilter. Default: ``None`` (don't use + nfilter) + """ + def __init__(self, + threshold: float, + filter_files: list[str] | None, + nfilter_files: list[str] | None) -> None: + logger.debug(parse_class_init(locals())) + self.threshold = threshold + """The threshold value for filtering out items""" + self.enabled = False + """``True`` if identity face filtering is enabled""" + if not filter_files and not nfilter_files: + return + self.enabled = True + + self._filter_files = self._validate_paths(filter_files, True) + self._nfilter_files = self._validate_paths(nfilter_files, False) + + if self._filter_files.intersection(self._nfilter_files): + logger.error("Filter and nFilter files should be unique. The following path(s) exist " + "in both: %s", self._filter_files.intersection(self._nfilter_files)) + sys.exit(1) + + self._runner: ExtractRunner[ExtractHandlerFace] + + def _validate_paths(self, full_paths: list[str] | None, is_filter: bool) -> set[str]: + """Validates that the given image file paths are valid. Exits if paths are provided but no + images could be found + + Parameters + ---------- + full_paths + The list of full paths to images to validate + is_filter + ``True`` for filter files. ``False`` for nfilter files + + Returns + ------- + The list of validated full paths + """ + if not full_paths: + return set() + name = "Filter" if is_filter else ("nFilter") + retval: list[str] = [] + for file_path in full_paths: + + if os.path.isdir(file_path): + files = [os.path.join(file_path, fname) + for fname in os.listdir(file_path) + if os.path.splitext(fname)[-1].lower() in IMAGE_EXTENSIONS] + if not files: + logger.warning("%s folder '%s' contains no image files", name, file_path) + else: + retval.extend(files) + continue + + if not os.path.splitext(file_path)[-1] in IMAGE_EXTENSIONS: + logger.warning("%s file '%s' is not an image file. Skipping", name, file_path) + continue + if not os.path.isfile(file_path): + logger.warning("%s file '%s' does not exist. Skipping", name, file_path) + continue + retval.append(file_path) + + if not retval: + logger.error("None of the provided %s files are valid.", name) + sys.exit(1) + + unique = set(retval) + logger.debug("[IdentityFilter] %s files: %s", name, unique) + return unique + + def add_identity_plugin(self, runner: ExtractRunner) -> None: + """Add the identity plugin for updating with embedding information + + Parameters + ---------- + runner + The identity runner for the pipeline + """ + logger.debug("[IdentityFilter] Adding identity runner: %s", runner) + self._runner = runner + + @classmethod + def _get_meta(cls, filename: str, image: bytes) -> PNGHeaderDict | None: + """Obtain the embedded meta data from a faceswap aligned image + + Parameters + ---------- + filename + Full path to the image file to load + image + The raw loaded image to obtain the meta data from + + Returns + ------- + The faceswap meta data from a PNG image header + """ + if os.path.splitext(filename)[-1].lower() != ".png": + logger.debug("[IdentityFilter] '%s' not a png", filename) + return None + + try: + meta = png_read_meta(image) + except AssertionError: + logger.debug("[IdentityFilter] '%s' is not a faceswap extracted image", filename) + return None + + if "alignments" not in meta: + logger.debug("[IdentityFilter] '%s' is not a faceswap extracted image", filename) + return None + + return T.cast("PNGHeaderDict", meta) + + def _from_pipeline(self, pipeline: ExtractRunner, images: dict[str, npt.NDArray[np.uint8]] + ) -> dict[str, npt.NDArray[np.float32]]: + """Obtain embeddings from the full extraction pipeline when non-faceswap images have been + provided + + Parameters + ---------- + pipeline + The extraction pipelines for obtaining embeddings from non-faceswap images + images + Dictionary of full file paths to images to run extraction on + + Returns + ------- + The identity embeddings received for each image from the extraction pipeline + """ + retval: dict[str, npt.NDArray[np.float32]] = {} + for file_name, image in images.items(): + logger.debug("[IdentityFilter] Putting to extractor: '%s'", file_name) + retval[file_name] = np.array( + [f.identity[self._runner.handler.storage_name] + for f in pipeline.put(file_name, image, passthrough=True).detected_faces] + ).squeeze(0) + + logger.debug("[IdentityFilter] Identity from extraction: %s", + {k: v.shape for k, v in retval.items()}) + return retval + + def _from_plugin(self, images: dict[str, tuple[PNGHeaderDict, npt.NDArray[np.uint8]]] + ) -> dict[str, npt.NDArray[np.float32]]: + """Obtain embeddings from the identity when faceswap aligned images without identity + information have been provided + + Parameters + ---------- + images + Dictionary of full file paths to the faceswap meta information and aligned images to + obtain identity information for + + Returns + ------- + The identity embeddings received for each image from the extraction pipeline + """ + retval: dict[str, npt.NDArray[np.float32]] = {} + for fname, (meta, image) in images.items(): + logger.debug("[IdentityFilter] Putting to plugin: '%s'", fname) + out = self._runner.put_direct(fname, + image, + [DetectedFace().from_png_meta(meta["alignments"])], + is_aligned=True, + frame_size=meta["source"]["source_frame_dims"]) + retval[fname] = out.identities[self._runner.handler.plugin.storage_name].squeeze(0) + + logger.debug("[IdentityFilter] Identity from plugin: %s", + {k: v.shape for k, v in retval.items()}) + return retval + + def _add_embeds_to_plugin(self, embeds: dict[str, npt.NDArray[np.float32]]) -> None: + """Validate that we have exactly one embedding per image and add to the identity filter + + Parameters + ---------- + embeds + The file name with embeddings to add to the plugin filter + """ + for is_filter, file_list in zip((True, False), (self._filter_files, self._nfilter_files)): + if not file_list: + continue + collated: list[npt.NDArray[np.float32]] = [] + name = "Filter" if is_filter else "nFilter" + for fname in file_list: + embed = embeds.pop(fname) + if not np.any(embed): + logger.warning("%s file '%s' contains no detected faces. Skipping", + name, os.path.basename(fname)) + continue + if embed.ndim != 1 and is_filter: + logger.warning("%s file '%s' contains %s detected faces. Skipping", + name, os.path.basename(fname), embed.shape[0]) + continue + if embed.ndim != 1 and not is_filter: + logger.warning("%s file '%s' contains %s detected faces. All of " + "these identities will be used", + name, os.path.basename(fname), embed.shape[0]) + collated.extend(list(embed)) + continue + collated.append(embed) + if not collated: + logger.error("None of the provided %s files are valid.", name) + sys.exit(1) + logger.info("Adding %s face%s to Identity %s", + len(collated), "s" if len(collated) > 1 else "", name) + T.cast(Identity, self._runner.handler).add_filter_identities( + np.stack(collated, dtype="float32"), is_filter) + + def get_embeddings(self, pipeline: ExtractRunner) -> None: + """Obtain the embeddings that are to be used for face filtering and add to the identity + plugin + + Parameters + ---------- + pipeline + The extraction pipelines for obtaining embeddings from non-faceswap images + """ + embeds: dict[str, npt.NDArray[np.float32]] = {} + non_aligned: dict[str, npt.NDArray[np.uint8]] = {} + aligned: dict[str, tuple[PNGHeaderDict, npt.NDArray[np.uint8]]] = {} + + for filepath in self._filter_files.union(self._nfilter_files): + with open(filepath, "rb") as in_file: + raw_image = in_file.read() + + meta = self._get_meta(filepath, raw_image) + if meta is not None: + idn = T.cast(dict[str, list], meta.get("identity", {})) + embed = np.array(idn.get(self._runner.handler.storage_name, []), + dtype="float32") + if np.any(embed): + logger.debug("[IdentityFilter] Identity from header '%s'. Shape: %s", + filepath, embed.shape) + embeds[filepath] = embed + continue + + image = T.cast("npt.NDArray[np.uint8]", + cv2.imdecode(np.frombuffer(raw_image, dtype="uint8"), cv2.IMREAD_COLOR)) + + if meta is None: + non_aligned[filepath] = image + continue + + logger.debug("[IdentityFilter] No identity in header: '%s'", filepath) + aligned[filepath] = (meta, image) + + if aligned or non_aligned: + logger.info("Extracting faces for Identity Filter...") + if non_aligned: + embeds |= self._from_pipeline(pipeline, non_aligned) + if aligned: + embeds |= self._from_plugin(aligned) + self._add_embeds_to_plugin(embeds) + + +class Cluster(): + """Cluster the outputs from a VGG-Face 2 Model + + Parameters + ---------- + predictions + A stacked matrix of identity predictions of the shape (`N`, `D`) where `N` is the + number of observations and `D` are the number of dimensions. NB: The given + :attr:`predictions` will be overwritten to save memory. If you still require the + original values you should take a copy prior to running this method + method + The clustering method to use. + threshold + The threshold to start creating bins for. Set to ``None`` to disable binning + """ + + def __init__(self, + predictions: np.ndarray, + method: T.Literal["single", "centroid", "median", "ward"], + threshold: float | None = None) -> None: + logger.debug("Initializing: %s (predictions: %s, method: %s, threshold: %s)", + self.__class__.__name__, predictions.shape, method, threshold) + self._num_predictions = predictions.shape[0] + + self._should_output_bins = threshold is not None + self._threshold = 0.0 if threshold is None else threshold + self._bins: dict[int, int] = {} + self._iterator = self._integer_iterator() + + self._result_linkage = self._do_linkage(predictions, method) + logger.debug("Initialized %s", self.__class__.__name__) + + @classmethod + def _integer_iterator(cls) -> Generator[int, None, None]: + """Iterator that just yields consecutive integers""" + i = -1 + while True: + i += 1 + yield i + + def _use_vector_linkage(self, dims: int) -> bool: + """Calculate the RAM that will be required to sort these images and select the appropriate + clustering method. + + From fastcluster documentation: + "While the linkage method requires Θ(N:sup:`2`) memory for clustering of N points, this + [vector] method needs Θ(N D)for N points in RD, which is usually much smaller." + also: + "half the memory can be saved by specifying :attr:`preserve_input`=``False``" + + To avoid under calculating we divide the memory calculation by 1.8 instead of 2 + + Parameters + ---------- + dims + The number of dimensions in the vgg_face output + + Returns + ------- + ``True`` if vector_linkage should be used. ``False`` if linkage should be used + """ + np_float = 24 # bytes size of a numpy float + divider = 1024 * 1024 # bytes to MB + + free_ram = psutil.virtual_memory().available / divider + linkage_required = (((self._num_predictions ** 2) * np_float) / 1.8) / divider + vector_required = ((self._num_predictions * dims) * np_float) / divider + logger.debug("free_ram: %sMB, linkage_required: %sMB, vector_required: %sMB", + int(free_ram), int(linkage_required), int(vector_required)) + + if linkage_required < free_ram: + logger.verbose("Using linkage method") # type:ignore[attr-defined] + retval = False + elif vector_required < free_ram: + logger.warning("Not enough RAM to perform linkage clustering. Using vector " + "clustering. This will be significantly slower. Free RAM: %sMB. " + "Required for linkage method: %sMB", + int(free_ram), int(linkage_required)) + retval = True + else: + raise FaceswapError("Not enough RAM available to sort faces. Try reducing " + f"the size of your dataset. Free RAM: {int(free_ram)}MB. " + f"Required RAM: {int(vector_required)}MB") + logger.debug(retval) + return retval + + def _do_linkage(self, + predictions: np.ndarray, + method: T.Literal["single", "centroid", "median", "ward"]) -> np.ndarray: + """Use FastCluster to perform vector or standard linkage + + Parameters + ---------- + predictions + A stacked matrix of identity predictions of the shape (`N`, `D`) where `N` is the + number of observations and `D` are the number of dimensions. + method + The clustering method to use. + + Returns + ------- + The [`num_predictions`, 4] linkage vector + """ + dims = predictions.shape[-1] + if self._use_vector_linkage(dims): + retval = linkage_vector(predictions, method=method) + else: + retval = linkage(predictions, method=method, preserve_input=False) + logger.debug("Linkage shape: %s", retval.shape) + return retval + + def _process_leaf_node(self, + current_index: int, + current_bin: int) -> list[tuple[int, int]]: + """Process the output when we have hit a leaf node""" + if not self._should_output_bins: + return [(current_index, 0)] + + if current_bin not in self._bins: + next_val = 0 if not self._bins else max(self._bins.values()) + 1 + self._bins[current_bin] = next_val + return [(current_index, self._bins[current_bin])] + + def _get_bin(self, + tree: np.ndarray, + points: int, + current_index: int, + current_bin: int) -> int: + """Obtain the bin that we are currently in. + + If we are not currently below the threshold for binning, get a new bin ID from the integer + iterator. + + Parameters + ---------- + tree + A hierarchical tree (dendrogram) + points + The number of points given to the clustering process + current_index + The position in the tree for the recursive traversal + current_bin + The ID for the bin we are currently in. Only used when binning is enabled + + Returns + ------- + The current bin ID for the node + """ + if tree[current_index - points, 2] >= self._threshold: + current_bin = next(self._iterator) + logger.debug("Creating new bin ID: %s", current_bin) + return current_bin + + def _seriation(self, + tree: np.ndarray, + points: int, + current_index: int, + current_bin: int = 0) -> list[tuple[int, int]]: + """Seriation method for sorted similarity. + + Seriation computes the order implied by a hierarchical tree (dendrogram). + + Parameters + ---------- + tree + A hierarchical tree (dendrogram) + points + The number of points given to the clustering process + current_index + The position in the tree for the recursive traversal + current_bin + The ID for the bin we are currently in. Only used when binning is enabled + + Returns + ------- + The indices in the order implied by the hierarchical tree + """ + if current_index < points: # Output the leaf node + return self._process_leaf_node(current_index, current_bin) + + if self._should_output_bins: + current_bin = self._get_bin(tree, points, current_index, current_bin) + + left = int(tree[current_index-points, 0]) + right = int(tree[current_index-points, 1]) + + serate_left = self._seriation(tree, points, left, current_bin=current_bin) + serate_right = self._seriation(tree, points, right, current_bin=current_bin) + + return serate_left + serate_right # type: ignore + + def __call__(self) -> list[tuple[int, int]]: + """Process the linkages. + + Transforms a distance matrix into a sorted distance matrix according to the order implied + by the hierarchical tree (dendrogram). + + Returns + ------- + List of indices with the order implied by the hierarchical tree or list of tuples of + (`index`, `bin`) if a binning threshold was provided + """ + logger.info("Sorting face distances. Depending on your dataset this may take some time...") + if self._threshold: + self._threshold = self._result_linkage[:, 2].max() * self._threshold + result_order = self._seriation(self._result_linkage, + self._num_predictions, + self._num_predictions + self._num_predictions - 2) + return result_order + + +__all__ = get_module_objects(__name__) diff --git a/lib/infer/iterator.py b/lib/infer/iterator.py new file mode 100644 index 0000000000..53f1d49950 --- /dev/null +++ b/lib/infer/iterator.py @@ -0,0 +1,719 @@ +#! /usr/env/bin/python3 +""" Iterators for ingesting into and passing data through extract plugin runners """ + +from __future__ import annotations + +import abc +import logging +import typing as T + +from queue import Queue, Empty as QueueEmpty + +import numpy as np + +from lib.infer.objects import FrameFaces, ExtractSignal +from lib.logger import parse_class_init +from lib.utils import get_module_objects + +from .objects import ExtractBatch + +if T.TYPE_CHECKING: + from lib.multithreading import ErrorState + + +logger = logging.getLogger(__name__) +QueueItemInT = T.TypeVar("QueueItemInT") +QueueItemOutT = T.TypeVar("QueueItemOutT") + + +class ExtractIterator(T.Generic[QueueItemInT, QueueItemOutT], abc.ABC): + """Base class for iterators within Faceswap's extract pipeline + + Type Parameters + --------------- + QueueItemInT + Type of item received from the input queue. + + QueueItemOutT + Type yielded by the iterator. + + Parameters + ---------- + queue + The inbound queue to the plugin + name + The plugin name and process calling this iterator + plugin_type + The type of extractor plugin that this iterator is serving + batch_size + The batch size that data should be returned from the iterator + error_state + The pipeline threads' global Error State object + """ + def __init__(self, + queue: Queue[QueueItemInT | ExtractSignal], + name: str, + plugin_type: T.Literal["detect", "align", "mask", "identity", "file"], + batch_size: int, + error_state: ErrorState) -> None: + logger.debug(parse_class_init(locals())) + self._queue = queue + self._name = f"{name}.{self.__class__.__name__.replace('Iterator', '').lower()}" + self._plugin_type = plugin_type + self._batch_size = batch_size + self._error_state = error_state + self._fifo: list[QueueItemOutT] = [] + self._zero_detect_threshold = batch_size * 2 + self._flush = False + self._shutdown = False + + def __iter__(self) -> T.Self: + """ This is an iterator """ + return self + + def __repr__(self) -> str: + """ Pretty print for logging """ + params = {k[1:]: repr(v) + for k, v in self.__dict__.items() + if k in ("_queue", + "_batch_size", + "_name", + "_plugin_type")} + s_params = ", ".join(f"{k}={v}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + + def _from_queue(self) -> QueueItemInT | ExtractSignal | None: + """ Get the next item from the queue on a 1 second timeout. + + Returns + ------- + ExtractBatch or FrameFaces or ExtractSignal or None + The next item from the queue or ``None`` if no item is available + """ + try: + retval = self._queue.get(timeout=0.2) + except QueueEmpty: + logger.trace("[%s] No item available", self._name) # type:ignore[attr-defined] + return None + logger.trace("[%s] From queue: %s", # type:ignore[attr-defined] + self._name, retval.name if isinstance(retval, ExtractSignal) else retval) + return retval + + def _has_zero_detections(self) -> bool: + """If we are an input or inbound iterator and we have an item in FIFO that contains a lot + of zero detections (plugin batchsize * 3) then release the item to prevent stacking frames + into RAM and long phases when nothing is being output from the pipeline. + + Returns + ------- + ``True`` if there are a lot of frames with no detections in the FIFO + """ + if len(self._fifo) != 1: # Any more than 1 and batch will be output anyway + return False + if self._plugin_type == "detect": + return False # Will always be 0 detections for detect and will never hit threshold + if self.__class__.__name__ not in ("InputIterator", "InboundIterator"): + return False # We only care about inputs to runners + item = T.cast(ExtractBatch, self._fifo[0]) + zero_detects = len(item.filenames) - len(set(item.frame_ids)) + return zero_detects >= self._zero_detect_threshold + + def _from_fifo(self) -> QueueItemOutT | None: + """ Pop the next item available in the fifo list. 1 item always remains in the list for + appending to and should be flushed at the last iteration + + Returns + ------- + ExtractBatch or FrameFaces or ExtractSignal or None + The next available item or ``None`` if no items are available + """ + if not self._fifo: + logger.trace("[%s.fifo] FIFO empty", self._name) # type:ignore[attr-defined] + return None + if self._has_zero_detections(): + retval = self._fifo.pop(0) + logger.debug("[%s.fifo] Popping from FIFO due to accumulated zero detections " + "(frames: %s, faces: %s)", self._name, + len(T.cast(ExtractBatch, retval).filenames), + len(T.cast(ExtractBatch, retval).frame_ids)) + return retval + if len(self._fifo) <= 1: + logger.trace("[%s.fifo] No items available. batches: %s", # type:ignore[attr-defined] + self._name, len(self._fifo)) + return None + retval = self._fifo.pop(0) + logger.trace("[%s.fifo] Popping: %s", # type:ignore[attr-defined] + self._name, retval) + return retval + + def _handle_signals(self) -> ExtractSignal | None: + """ Check if :attr:`_flush` or :attr:`_eof` have been set. If so, log and reset them. If + flush has been set return the FLUSH enum + + Returns + ------- + :class:`lib.extract.objects.ExtractSignal` | None + The flush enum, if the iterator has received a flush signal or ``None`` if it has not + + Raises + ------ + StopIteration + If EOF has been seen + """ + if self._shutdown: + self._shutdown = False + logger.debug("[%s] EOF Executed", self._name) + raise StopIteration + + if not self._flush: + return None + + self._flush = False + logger.debug("[%s] sending FLUSH downstream", self._name) + return ExtractSignal.FLUSH + + def _handle_inbound_signal(self, inbound: ExtractSignal) -> QueueItemOutT | ExtractSignal: + """ Handle any received signals from the queue + + Parameters + ---------- + inbound + An inbound item from an iterator's in queue + + Returns + ------- + ExtractBatch or FrameFaces or ExtractSignal or None + The inbound item from the iterator's in queue if it is not a signal or if there are + items queued for output + + Raises + ------ + StopIteration + If a shutdown signal has been received and there are no items queued for output + """ + signal = inbound.name + logger.debug("[%s] %s received. FIFO size: %s", self._name, signal, len(self._fifo)) + + if self._fifo: + setattr(self, f"_{signal.lower()}", True) + assert len(self._fifo) == 1 # Final batch should remain + retval = self._fifo.pop(0) + logger.debug("[%s] Returning final queued output item: %s", self._name, retval) + return retval + + if inbound == ExtractSignal.SHUTDOWN: + logger.debug("[%s] SHUTDOWN Executed", self._name) + raise StopIteration + + return inbound + + def _check_error(self) -> None: + """ Check whether there has been a thread error and stop iteration if so + + Raises + ------ + StopIteration + If a thread error has been detected + """ + if self._error_state.has_error: + logger.debug("[%s] Thread error received", self._name) + raise StopIteration + + @abc.abstractmethod + def __next__(self) -> QueueItemOutT | ExtractSignal: + """ Override to return the next batch item from the iterator + + Returns + ------- + ExtractBatch or FrameFaces or ExtractSignal + Batch object for pipeline processing, or a final media object + when exiting the pipeline. + """ + + +class InputIterator(ExtractIterator[FrameFaces, ExtractBatch]): + """ An iterator that processes FrameFaces data that is input to a plugin pipeline to create + ExtractBatch objects at the correct batch size for processing through the pipeline's first + plugin + + Parameters + ---------- + queue + The inbound queue to the plugin pipeline + name + The plugin name and process calling this iterator + plugin_type + The type of extractor plugin that this iterator is serving + batch_size + The batch size that data should be returned from the iterator + """ + def _append_to_fifo(self, batch: ExtractBatch) -> None: + """ Append batch items to :attr:`_fifo` when it is either empty, or the last item in the + FIFO is the correct batch size + + Adds the batch object to FIFO splitting to the plugin's batch size if required + + Parameters + ---------- + batch + The data from the inbound FrameFaces object placed into an ExtractBatch object + """ + num_boxes = len(batch) + if num_boxes <= self._batch_size: + # If this is a detection plugin then boxes will always be 0, but there will only ever + # be a single frame, so this test is fine for both detection + face plugins + self._fifo.append(batch) + logger.trace("[%s] Added to FIFO: %s", self._name, batch) # type:ignore[attr-defined] + return + + i = 0 + while i < num_boxes: + end = i + self._batch_size + self._fifo.append(batch[i:end]) + i += self._fifo[-1].bboxes.shape[0] + logger.trace( # type:ignore[attr-defined] + "[%s] Split batch with %s boxes to FIFO boxes of size: %s", + self._name, num_boxes, [len(b) for b in self._fifo]) + + def _add_data_to_batch(self, media: FrameFaces) -> None: + """ Add the incoming FrameFaces data to either the last existing extractor batch object + or a new one. + + Parameters + ---------- + media + The incoming frame data + + Raises + ------ + ValueError + If aligned and non-aligned images are added to the same extractor batch + """ + in_batch = ExtractBatch.from_frame_faces(media) + if not self._fifo: # Add straight in to a fresh FIFO + self._append_to_fifo(in_batch) + return + + last_fifo = self._fifo[-1] + exist_size = len(last_fifo.filenames) if self._plugin_type == "detect" else len(last_fifo) + + if exist_size == self._batch_size: # Append straight onto the end of FIFO + self._append_to_fifo(in_batch) + return + + capacity = self._batch_size - exist_size + num_boxes = in_batch.bboxes.shape[0] + to_add = len(in_batch.filenames) if self._plugin_type == "detect" else num_boxes + + if media.is_aligned != last_fifo.is_aligned: + raise ValueError("Mixing aligned and non-aligned images is not supported") + + if to_add <= capacity: # Append to the last item in the FIFO + last_fifo.append(in_batch) + logger.trace( # type:ignore[attr-defined] + "[%s] Added batch with %s items to existing batch of %s items", + self._name, to_add, exist_size) + return + + # Only FrameFaces containing detected faces that need to be added to the last item in the + # fifo and then subsequently split will exist here + split_batch = in_batch[0:capacity] + last_fifo.append(split_batch) + logger.trace( # type:ignore[attr-defined] + "[%s] Added batch with %s items to existing batch of %s items", + self._name, capacity, exist_size) + self._append_to_fifo(in_batch[capacity:capacity + (num_boxes - capacity)]) + + def __next__(self) -> ExtractBatch | ExtractSignal: + """ Get the next batch of data from the iterator. Depending on the plugin type calling this + iterator, a batch object will be returned for the given batch size of frames (for detect + plugins) or faces (for all other plugins) + + Returns + ------- + ExtractBatch or ExtractSignal + A new Batch object containing the batch to process through the plugin or a signal + + Raises + ------ + StopIteration + When the input is exhausted + """ + flush = self._handle_signals() + if flush: + return flush + + while True: + self._check_error() + retval = self._from_fifo() + if retval is not None: + return retval + + media = self._from_queue() + if media is None: + continue + + if isinstance(media, ExtractSignal): + return self._handle_inbound_signal(media) + + if media.passthrough: + return ExtractBatch.from_frame_faces(media) + + self._add_data_to_batch(media) + + +class InboundIterator(ExtractIterator[ExtractBatch, ExtractBatch]): + """ An iterator that processes ExtractBatch data from a previous plugin and configures it as + an input for the current plugin. + + An Inbound iterator assumes that the plugin's batch size are the number of faces (not frames) + that it can process at one time. Detect plugins are the only plugins that work with frames + rather than faces, but these will always be the input to the pipeline, so will use an + InputIterator not an InboundIterator + + Parameters + ---------- + queue + The outbound queue from the previous plugin + name + The plugin name and process calling this iterator + plugin_type + The type of extractor plugin that this iterator is serving + batch_size + The batch size that data should be returned from the iterator + """ + def _batch_to_fifo(self, in_batch: ExtractBatch) -> None: + """ Batch the incoming data into an object batched for the current plugin's batch size and + add to :attr:`_fifo` + + Parameters + ---------- + in_batch + The inbound batch to be re-batched for output + """ + if self._fifo and (len(self._fifo[-1]) != self._batch_size): + # Partially filled batch is queued or we are appending frames with no detections + batch = self._fifo[-1] + logger.trace( # type:ignore[attr-defined] + "[%s] Adding %s face(s) from %s image(s) to partial batch with %s face(s)", + self._name, len(in_batch), len(in_batch.images), len(batch)) + batch.append(in_batch) + return + + logger.trace("[%s] Adding new batch for %s face(s)", # type:ignore[attr-defined] + self._name, len(in_batch)) + self._fifo.append(in_batch) + + def _handle_non_split_batch(self, batch: ExtractBatch) -> tuple[int, int]: + """Pass inbound batches with either no boxes or the exact number of boxes required to fill + the next batch straight through + + Parameters + ---------- + batch + The inbound batch to check and potentially pass straight through + + Returns + ------- + num_boxes + The number of boxes that exist within the inbound batch + capacity + The number of free slots in the next outbound batch + """ + partial = self._fifo and len(self._fifo[-1]) != self._batch_size + num_boxes = len(batch) + capacity = self._batch_size - len(self._fifo[-1]) if partial else self._batch_size + if num_boxes not in (0, capacity): # Batch needs splitting + return num_boxes, capacity + + self._batch_to_fifo(batch) + logger.trace( # type:ignore[attr-defined] + "[%s] Passed non-split batch straight through %s(frames=%s, faces=%s) to: %s" + "(frames=%s, faces=%s)", + self._name, + batch.__class__.__name__, + len(batch.filenames), + num_boxes, + self._fifo[-1].__class__.__name__, + len(self._fifo[-1].filenames), + len(self._fifo[-1])) + return 0, 0 + + def _append_no_boxes(self, batch: ExtractBatch) -> None: + """ Incoming batches will only be processed until the last frame containing a face. Append + any frames at the end of the incoming batch, that do not contain any faces, to the last + queued batch + + Parameters + ---------- + batch + The inbound batch to append frames without boxes + """ + start = batch.frame_ids[-1] + 1 + if start >= len(batch.filenames): + return + logger.trace( # type:ignore[attr-defined] + "[%s] Appending %s frames without faces to last batch", + self._name, len(batch.filenames[start:])) + self._batch_to_fifo(ExtractBatch(batch.filenames[start:], + batch.images[start:], + batch.sources[start:])) + + def _rebatch_data(self, batch: ExtractBatch) -> None: # pylint:disable=too-many-locals + """ Process the incoming batch data and re-batch it for the requested plugin batch size + into the correct object and store in :attr:`_fifo` + + Parameters + ---------- + batch + The incoming batch of data to this plugin at the batch size of the previous plugin + """ + num_boxes, capacity = self._handle_non_split_batch(batch) + if num_boxes == 0: + return + + i = count = 0 + while i < num_boxes: + end = i + capacity + in_batch = batch[i:end] + self._batch_to_fifo(in_batch) + i += len(in_batch) + capacity = self._batch_size # New full batch object + count += 1 + + self._append_no_boxes(batch) + logger.trace( # type:ignore[attr-defined] + "[%s] Rebatched %s, %s(frames=%s, faces=%s) to: %s", + self._name, + batch.filenames, + batch.__class__.__name__, + len(batch.filenames), + len(batch), + ", ".join(f"{b.__class__.__name__}(frames={len(b.filenames)}, faces={len(b)})" + for b in self._fifo[-count:])) + + def __next__(self) -> ExtractBatch | ExtractSignal: + """ Get the next batch of data from the iterator. Depending on the plugin type calling this + iterator, a batch object will be returned for the given batch size of frames (for detect + plugins) or faces (for all other plugins) + + Returns + ------- + ExtractBatch or ExtractSignal + A new ExtractBatch object containing the batch to process through the plugin or an + ExtractSignal + + Raises + ------ + StopIteration + When the input is exhausted + """ + flush = self._handle_signals() + if flush: + return flush + + while True: + self._check_error() + retval = self._from_fifo() # In loop as re-batching may need to run multiple times + if retval is not None: + return retval + + batch = self._from_queue() + if batch is None: + continue + + if isinstance(batch, ExtractBatch) and batch.passthrough and self._fifo: + raise RuntimeError("Pipeline must be empty when adding a passthrough object") + + if isinstance(batch, ExtractBatch) and batch.passthrough: + return batch + + if isinstance(batch, ExtractBatch): + self._rebatch_data(batch) + continue + + return self._handle_inbound_signal(batch) + + +class InterimIterator(ExtractIterator[ExtractBatch, ExtractBatch]): + """ An iterator that simply collects interim ExtractBatch objects from the given queue and + yields them + + Parameters + ---------- + queue + The inbound queue to the plugin + name + The plugin name and process calling this iterator + plugin_type + The type of extractor plugin that this iterator is serving + batch_size + The batch size that data should be returned from the iterator + """ + def __next__(self) -> ExtractBatch | ExtractSignal: + """ Get the next batch of data from the iterator + + Returns + ------- + ExtractBatch or ExtractSignal + The next available ExtractBatch object to process through the plugin or an + ExtractSignal + + Raises + ------ + StopIteration + When the input is exhausted + """ + batch: ExtractBatch | ExtractSignal | None = ExtractSignal.SHUTDOWN + while True: + self._check_error() + batch = self._from_queue() + if batch is not None: + break + + if batch == ExtractSignal.SHUTDOWN: + logger.debug("[%s] EOF Received", self._name) + raise StopIteration + + if batch == ExtractSignal.FLUSH: + logger.debug("[%s] FLUSH Received", self._name) + + logger.trace("[%s] Releasing batch: %s", # type:ignore[attr-defined] + self._name, batch.name if isinstance(batch, ExtractSignal) else batch) + return batch + + +class OutputIterator(ExtractIterator[ExtractBatch, FrameFaces]): + """ Handles parsing incoming ExtractBatch objects into FrameFaces objects and yielding one + frame at a time from the pipeline + + Parameters + ---------- + queue + The output queue from the plugin runner + name + The plugin name and process calling this iterator + plugin_type + The type of extractor plugin that this iterator is serving + batch_size + The batch size that data should be returned from the iterator + """ + def _to_extract_media(self, batch: ExtractBatch) -> None: + """ Process the incoming batch data into FrameFaces objects and return the next stored in + local cache for output + + Parameters + ---------- + batch + The output ExtractBatch object from a plugin + """ + merge = self._fifo and batch.filenames[0] == self._fifo[-1].filename + lengths = batch.lengths + starts = np.cumsum(lengths, dtype=np.int32) - lengths + for idx, (filename, image, source, start, length) in enumerate(zip(batch.filenames, + batch.images, + batch.sources, + starts, + lengths)): + + end = start + length + media = FrameFaces( + filename, + image, + bboxes=batch.bboxes[start:end], + identities={k: v[start:end] for k, v in batch.identities.items()}, + masks={k: v[start:end] for k, v in batch.masks.items()}, + source=source, + is_aligned=batch.is_aligned, + frame_metadata=None if batch.frame_metadata is None else batch.frame_metadata[idx], + passthrough=batch.passthrough) + media.aligned = batch.aligned[start:end] + + if merge and idx == 0: + logger.trace( # type:ignore[attr-defined] + "[%s] Merging %s faces to last batch: '%s'", self._name, len(media), filename) + self._fifo[-1].append(media) + else: + self._fifo.append(media) + + logger.trace( # type:ignore[attr-defined] + "[%s] Split to FrameFaces: '%s' (%s faces)", + self._name, + self._fifo[-1].filename, + len(self._fifo[-1])) + + def _handle_passthrough_batch(self, batch: ExtractBatch) -> FrameFaces: + """Handle a batch when it is a passthrough object + + Parameters + ---------- + batch + The batch that contains the passthrough object + + Returns + ------- + The FrameFaces object derived from the incoming ExtractBatch + + Raises + ------ + RuntimeError + If there are items to be queued out of the FIFO + ValueError + If the batch does not contain exactly one frame + """ + if self._fifo: + raise RuntimeError("Pipeline must be empty when adding a passthrough object") + if len(batch.filenames) != 1: + raise ValueError("Exactly 1 image should exist when passing through") + + meta = batch.frame_metadata[0] if batch.frame_metadata else None + retval = FrameFaces(batch.filenames[0], + batch.images[0], + bboxes=batch.bboxes, + identities=batch.identities, + masks=batch.masks, + source=batch.sources[0], + is_aligned=batch.is_aligned, + frame_metadata=meta, + passthrough=batch.passthrough) + retval.aligned = batch.aligned + return retval + + def __next__(self) -> FrameFaces: + """ Get the next batch of data from the iterator + + Returns + ------- + A FrameFaces object for a single frame + + Raises + ------ + StopIteration + When the input is exhausted + """ + self._handle_signals() + while True: + self._check_error() + retval = self._from_fifo() + if retval is not None: + return retval + + batch: ExtractBatch | ExtractSignal | FrameFaces | None = self._from_queue() + if batch is None: + continue + + if isinstance(batch, ExtractSignal): + batch = self._handle_inbound_signal(batch) + if isinstance(batch, FrameFaces): + return batch + if batch == ExtractSignal.FLUSH: + continue # Don't flush to output. Wait for next batch + + assert isinstance(batch, ExtractBatch) + if batch.passthrough: + return self._handle_passthrough_batch(batch) + + self._to_extract_media(batch) + + +__all__ = get_module_objects(__name__) diff --git a/lib/infer/mask.py b/lib/infer/mask.py new file mode 100644 index 0000000000..1521d6e9af --- /dev/null +++ b/lib/infer/mask.py @@ -0,0 +1,153 @@ +#! /usr/env/bin/python3 +"""Handles face masking plugins and runners """ +from __future__ import annotations + +import logging +import typing as T + +import cv2 +import numpy as np + +from lib.logger import parse_class_init +from lib.utils import get_module_objects +from plugins.extract import extract_config as cfg + +from .objects import ExtractBatchMask +from .handler import ExtractHandlerFace + +if T.TYPE_CHECKING: + import numpy.typing as npt + from .objects import ExtractBatch + +logger = logging.getLogger(__name__) + + +class Mask(ExtractHandlerFace): + """Responsible for running Masking plugins within the extract pipeline + + Parameters + ---------- + plugin + The plugin that this runner is to use + compile_model + ``True`` to compile any PyTorch models + config_file + Full path to a custom config file to load. ``None`` for default config + """ + def __init__(self, + plugin: str, + compile_model: bool = False, + config_file: str | None = None) -> None: + logger.debug(parse_class_init(locals())) + self._storage_size = cfg.mask_storage_size() + super().__init__(plugin, compile_model=compile_model, config_file=config_file) + if 0 < self._storage_size < 64: + logger.warning("Updating mask storage size from %s to 64", self._storage_size) + self._storage_size = 64 + + # Pre-processing + def _pre_process_aligned(self, batch: ExtractBatch, matrices: npt.NDArray[np.float32] + ) -> npt.NDArray[np.uint8]: + """Pre-process the data when the input are aligned faces. Sub-crops the feed images from + the aligned images and adds the ROI mask to the alpha channel + + Parameters + ---------- + batch + The inbound batch object containing aligned faces + matrices + The adjustment matrices for taking the image patch from the full frame for plugin input + + Returns + ------- + The prepared images with ROI mask in the alpha channel + """ + assert batch.frame_sizes is not None, ( + "[Mask] Frame sizes must be provided when input is aligned faces") + + dtype = batch.images[0].dtype + retval = np.empty((len(batch.bboxes), self._input_size, self._input_size, 4), dtype=dtype) + retval[..., :3] = self._get_faces_aligned(batch.images, + batch.frame_ids, + batch.aligned.offsets_head, + getattr(batch.aligned, + self._aligned_offsets_name)) + + mats = matrices[:, :2] + linear = mats[:, :, 0] + scales = np.hypot(linear[:, 0], linear[:, 1]) # Always same x/y scaling + interpolations = np.where(scales > 1.0, cv2.INTER_LINEAR, cv2.INTER_AREA) + size = (self._input_size, self._input_size) + for idx, (mat, interpolation) in enumerate(zip(mats, interpolations)): + mask = np.ones((batch.frame_sizes[batch.frame_ids[idx]]), dtype=dtype) * 255 + retval[idx, :, :, 3] = cv2.warpAffine(mask, mat, size, flags=interpolation) + + return retval + + def pre_process(self, batch: ExtractBatch) -> None: + """Obtain the aligned face images at the requested size, centering and image format. + Perform any plugin specific pre-processing + + Parameters + ---------- + batch + The incoming ExtractBatch to use for pre-processing + """ + self._maybe_log_warning(batch.landmark_type) + matrices = self._get_matrices(getattr(batch.aligned, self._aligned_mat_name)) + + if batch.is_aligned: + data = self._pre_process_aligned(batch, matrices) + else: + data = self._get_faces(batch.images, batch.frame_ids, matrices, with_alpha=True) + + data = self._format_images(data) + batch.matrices = data[..., -1] # type:ignore[assignment] # Hacky re-use for ROI + batch.data = self.plugin.pre_process(data[..., :3]) + batch.masks[self.storage_name] = ExtractBatchMask(self._centering, matrices) + + # Post-processing + @classmethod + def _crop_out_of_bounds(cls, masks: npt.NDArray[np.float32], roi_masks: npt.NDArray[np.float32] + ) -> None: + """Un-mask any area of the predicted mask that falls outside of the original frame. + + Parameters + ---------- + masks + The predicted masks from the plugin + roi_mask + The roi masks. In frame is white, out of frame is black + """ + if np.all(roi_masks): + return # All of the masks are within the frame + needs_crop = np.any(roi_masks < 1., axis=(1, 2)) + roi_masks = roi_masks[..., None] if masks.ndim == 4 else roi_masks + masks[needs_crop] *= roi_masks[needs_crop] + + def post_process(self, batch: ExtractBatch) -> None: + """Perform mask post processing. + + Obtains the final output from the mask plugins and masks any part of the face patch that + goes out of bounds + + Parameters + ---------- + batch + The incoming ExtractBatch to use for post-processing + """ + masks = batch.data + if self._overridden["post_process"]: + masks = self.plugin.post_process(masks) + self._crop_out_of_bounds(masks, batch.matrices) + + if self._storage_size == 0: + self._storage_size = masks.shape[1] + logger.debug("[%s.post_process] Updated storage size to %s", + self.plugin.name, self._storage_size) + + batch.masks[self.storage_name].masks = (masks * 255.).astype(np.uint8) + batch.masks[self.storage_name].storage_size = self._storage_size + + +__all__ = get_module_objects(__name__) diff --git a/lib/infer/objects.py b/lib/infer/objects.py new file mode 100644 index 0000000000..cd766ac441 --- /dev/null +++ b/lib/infer/objects.py @@ -0,0 +1,998 @@ +#! /usr/env/bin/python3 +"""Objects used for extraction plugins, runners and pipeline """ +from __future__ import annotations +import logging +import typing as T +from dataclasses import dataclass, field +from enum import IntEnum +from zlib import compress + +import cv2 +import numpy as np +import numpy.typing as npt + +from lib.align.aligned_face import batch_umeyama +from lib.align.aligned_utils import batch_resize, batch_transform, points_to_68 +from lib.align.aligned_mask import Mask +from lib.align.alignments import PNGAlignments, MaskAlignmentsFile +from lib.align.constants import LandmarkType, MEAN_FACE +from lib.align.detected_face import DetectedFace +from lib.align.pose import Batch3D +from lib.logger import parse_class_init, format_array +from lib.utils import get_module_objects + +if T.TYPE_CHECKING: + from lib.align.alignments import PNGHeaderSourceDict + from lib.align.constants import CenteringType + +logger = logging.getLogger(__name__) + + +class ExtractSignal(IntEnum): + """Signals to send to the extraction pipeline""" + FLUSH = 1 + """Flush all queued items""" + SHUTDOWN = 2 + """Flush all queued items and shutdown""" + + +@dataclass +class ExtractBatchAligned: + """Dataclass for working with batches of aligned images + + Parameters + ---------- + landmarks + The face landmarks found for this batch in frame space or ``None`` if not available. + Default: ``None`` (to be populated later) + landmark_type + The type of landmarks that the batch holds or ``None`` if not available. + Default: ``None`` (to be populated later) + """ + landmarks: npt.NDArray[np.float32] | None = None + """The face landmarks found for this batch in frame space or ``None`` if not populated""" + landmark_type: LandmarkType | None = None + """The type of landmarks that the batch holds""" + + # The following "_cache_" attributes are cached on demand and accessed through their + # corresponding "non _cache_" properties + _cache_landmarks_68: npt.NDArray[np.float32] | None = field(init=False, default=None) + _cache_landmarks_normalized: npt.NDArray[np.float32] | None = field(init=False, default=None) + _cache_matrices: npt.NDArray[np.float32] | None = field(init=False, default=None) + _cache_offsets_legacy: npt.NDArray[np.float32] | None = field(init=False, default=None) + _cache_offsets_face: npt.NDArray[np.float32] | None = field(init=False, default=None) + _cache_offsets_head: npt.NDArray[np.float32] | None = field(init=False, default=None) + _cache_rotation: npt.NDArray[np.float32] | None = field(init=False, default=None) + _cache_translation: npt.NDArray[np.float32] | None = field(init=False, default=None) + + def __repr__(self) -> str: + """Pretty print arrays""" + params = {} + for k, v in self.__dict__.items(): + key = k.replace("_cache_", "") + if isinstance(v, np.ndarray): + params[key] = format_array(v) + continue + params[key] = v + s_params = ", ".join(f"{k}={v}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + + @property + def landmarks_68(self) -> npt.NDArray[np.float32]: + """ The stored landmarks as 68 point landmarks if supported, or original landmarks if not ( + 4 point ROI landmarks)""" + if self._cache_landmarks_68 is not None: + return self._cache_landmarks_68 + + if self.landmarks is None or not self.landmarks.size: + return np.empty((0, 68, 2), dtype=np.float32) + + lms = T.cast("npt.NDArray[np.float32]", self.landmarks) + if self.landmark_type not in (LandmarkType.LM_2D_68, LandmarkType.LM_2D_4): + lms = points_to_68(lms, landmark_type=self.landmark_type) + self._cache_landmarks_68 = lms + return self._cache_landmarks_68 + + @property + def landmarks_normalized(self) -> npt.NDArray[np.float32]: + """The normalized, aligned 68 point landmarks""" + if self._cache_landmarks_normalized is not None: + return self._cache_landmarks_normalized + + if self.landmarks is None or not self.landmarks.size: + return np.empty((0, 68, 2), dtype=np.float32) + + self._cache_landmarks_normalized = batch_transform(self.matrices, self.landmarks_68) + return self._cache_landmarks_normalized + + @property + def matrices(self) -> npt.NDArray[np.float32]: + """The face alignment matrices to transform from frame space to normalized (0, 1) space""" + if self._cache_matrices is not None: + return self._cache_matrices + + if self.landmarks is None or not self.landmarks.size: + return np.empty((0, 3, 3), dtype=np.float32) + + if self.landmark_type == LandmarkType.LM_2D_4: + points = self.landmarks + lookup = LandmarkType.LM_2D_4 + else: + points = self.landmarks_68[:, 17:] + lookup = LandmarkType.LM_2D_51 + self._cache_matrices = batch_umeyama(points, MEAN_FACE[lookup], True).astype(np.float32) + return self._cache_matrices + + @property + def matrices_face(self) -> npt.NDArray[np.float32]: + """The alignment matrices to transform from normalized legacy space (0, 1) to normalized + face space""" + mats = self.matrices.copy() + mats[:, :2, 2] -= self.offsets_face + return mats + + @property + def matrices_head(self) -> npt.NDArray[np.float32]: + """The alignment matrices to transform from normalized legacy space (0, 1) to normalized + head space""" + mats = self.matrices.copy() + mats[:, :2, 2] -= self.offsets_head + return mats + + @property + def offsets_legacy(self) -> npt.NDArray[np.float32]: + """The (N, x, y) offsets for normalized (legacy) centering. This is always (0, 0) for all + items in the batch""" + if self._cache_offsets_legacy is not None: + return self._cache_offsets_legacy + + if self.landmarks is None or not self.landmarks.size: + return np.empty((0, 2), dtype=np.float32) + + if self.landmark_type == LandmarkType.LM_2D_4: + num_points = self.landmarks.shape[0] + else: + num_points = self.landmarks_68.shape[0] + + self._cache_offsets_legacy = np.zeros((num_points, 2), dtype=np.float32) + return self._cache_offsets_legacy + + @property + def offsets_face(self) -> npt.NDArray[np.float32]: + """The (N, x, y) offsets required to shift from normalized (legacy) centering to face + centering""" + if self._cache_offsets_face is not None: + return self._cache_offsets_face + + if self.landmarks is None or not self.landmarks.size: + return np.empty((0, 2), dtype=np.float32) + + if self.landmark_type == LandmarkType.LM_2D_4: + offsets = np.zeros((self.landmarks.shape[0], 2), dtype=np.float32) + else: + offsets = Batch3D.get_offsets("face", self.rotation, self.translation) + + self._cache_offsets_face = offsets + return self._cache_offsets_face + + @property + def offsets_head(self) -> npt.NDArray[np.float32]: + """The (N, x, y) offsets required to shift from normalized (legacy) centering to head + centering""" + if self._cache_offsets_head is not None: + return self._cache_offsets_head + + if self.landmarks is None or not self.landmarks.size: + return np.empty((0, 2), dtype=np.float32) + + if self.landmark_type == LandmarkType.LM_2D_4: + offsets = np.zeros((self.landmarks.shape[0], 2), dtype=np.float32) + else: + offsets = Batch3D.get_offsets("head", self.rotation, self.translation) + + self._cache_offsets_head = offsets + return self._cache_offsets_head + + @property + def rotation(self) -> npt.NDArray[np.float32]: + """The estimated (N, 3, 1) rotation vectors""" + if self._cache_rotation is not None: + return self._cache_rotation + + if self.landmarks is None or not self.landmarks.size: + return np.empty((0, 3, 1), dtype=np.float32) + + if self.landmark_type == LandmarkType.LM_2D_4: + rot_trans = np.zeros((2, self.landmarks.shape[0], 3, 1), dtype=np.float32) + else: + rot_trans = Batch3D.solve_pnp(self.landmarks_normalized) + self._cache_rotation = T.cast("npt.NDArray[np.float32]", rot_trans[0]) + self._cache_translation = rot_trans[1] + return self._cache_rotation + + @property + def translation(self) -> npt.NDArray[np.float32]: + """The estimated (N, 3, 1) translation vectors""" + if self._cache_translation is not None: + return self._cache_translation + + if self.landmarks is None or not self.landmarks.size: + return np.empty((0, 3, 1), dtype=np.float32) + + if self.landmark_type == LandmarkType.LM_2D_4: + rot_trans = np.zeros((2, self.landmarks.shape[0], 3, 1), dtype=np.float32) + else: + rot_trans = Batch3D.solve_pnp(self.landmarks_normalized) + + rot_trans = Batch3D.solve_pnp(self.landmarks_normalized) + self._cache_rotation = rot_trans[0] + self._cache_translation = T.cast("npt.NDArray[np.float32]", rot_trans[1]) + return self._cache_translation + + def __getitem__(self, indices: slice) -> ExtractBatchAligned: + """Obtain a subset of this batch object with the data given by the start and end indices + + Parameters + ---------- + indices + The (start, stop, end) slice for extracting from the batch + + Returns + ------- + A batch object containing the data from this object for the given indices + """ + retval = ExtractBatchAligned(landmark_type=self.landmark_type) + if self.landmarks is not None: + retval.landmarks = self.landmarks[indices] + + for k, v in self.__dict__.items(): + if k.startswith("_cache_") and v is not None: + setattr(retval, k, v[indices]) + + return retval + + def append(self, batch: ExtractBatchAligned) -> None: + """Append the data from the given batch object to this batch object + + Parameters + ---------- + batch + The object containing data to be appended to this object + """ + if batch.landmarks is not None: + self.landmarks = (np.concatenate([self.landmarks, batch.landmarks]) + if self.landmarks is not None else batch.landmarks) + if self.landmark_type is None: + self.landmark_type = batch.landmark_type + + for k, v in batch.__dict__.items(): + if k.startswith("_cache_") and v is not None: + exist = getattr(self, k) + val = None if exist is None else np.concatenate([exist, v]) + setattr(self, k, val) + + def apply_mask(self, mask: npt.NDArray[np.bool_]) -> None: + """Apply a boolean mask to the batch object. ``True`` values are kept, ``False`` values + are discarded + + Parameters + ---------- + mask + The boolean mask to apply to the object. Must be of size (landmarks, ) + """ + if np.all(mask): + return + + if self.landmarks is not None: + self.landmarks = self.landmarks[mask] + + for k, v in self.__dict__.items(): + if k.startswith("_cache_") and v is not None: + setattr(self, k, v[mask]) + + +@dataclass +class ExtractBatchMask: + """Dataclass for holding information about masks produced by the extraction pipeline + + Parameters + ---------- + centering + The centering type of the masks + matrices + The normalized matrices required to take the masks from (0, 1) to full frame + storage_size + The pixel size to store the mask at in the alignments file. Default: 0 (must be populated + later) + masks + The masks for this batch. Default: empty array (must be populated later) + """ + centering: CenteringType + """The centering type of the masks""" + matrices: npt.NDArray[np.float32] + """The normalized matrices required to take the masks from (0, 1) to full frame""" + storage_size: int = field(default=0) + """The pixel size to store the mask at in the alignments file""" + masks: npt.NDArray[np.uint8] = field(default_factory=lambda: np.empty((0, 0, 0), + dtype=np.uint8)) + """The masks for this batch""" + + def __repr__(self) -> str: + """Pretty print arrays""" + params = {k: format_array(v) if isinstance(v, np.ndarray) else repr(v) + for k, v in self.__dict__.items()} + s_params = ", ".join(f"{k}={v}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + + def __getitem__(self, indices: slice) -> ExtractBatchMask: + """Basic object slicing for splitting batches + + Parameters + ---------- + indices + The (start, stop, end) slice for extracting from the batch + + Returns + ------- + The sliced data from this batch + """ + return ExtractBatchMask(self.centering, + self.matrices[indices], + storage_size=self.storage_size, + masks=self.masks[indices]) + + def append(self, mask_batch: ExtractBatchMask) -> None: + """Append the given mask batch object to this batch mask object + + Parameters + ---------- + mask_batch + The object containing data to be appended to this object + """ + self.matrices = np.concatenate([self.matrices, mask_batch.matrices], axis=0) + self.masks = np.concatenate([self.masks, mask_batch.masks], axis=0) + + def apply_mask(self, mask: npt.NDArray[np.bool_]) -> None: + """Apply a boolean mask to the batch object. ``True`` values are kept, ``False`` values + are discarded + + Parameters + ---------- + mask + The boolean mask to apply to the object. Must be of size (num_masks, ) + """ + if np.all(mask): + return + self.masks = self.masks[mask] + self.matrices = self.matrices[mask] + + +@dataclass +class ExtractBatch: # pylint:disable=too-many-instance-attributes + """Dataclass for holding a batch flowing through Extraction plugins. + + The batch size for post Detector plugins is not the same as the overall batch size. + An image may contain 0 or more detected faces, and these need to be split and recombined + to be able to utilize a plugin's internal batch size. + + Parameters + ---------- + filenames + The original frame filenames for the batch + images + The original frames + sources + The full path to the source folder or video file. Default: ``[]`` (Not provided) + is_aligned + ``True`` if :attr:`images` contains aligned faces. ``False`` if it contains full frames. + Default: ``False`` + frame_sizes + The original frame (height, width) dimensions that contained the aligned images when + :attr:`images` are aligned faces. Default: ``None`` + frame_metadata + The original frame meta data when aligned faces is ``True`` otherwise ``None`` + passthrough + `True`` if the contents of this item are meant to pass straight through the extraction + pipeline for immediate return + """ + # Input required information + filenames: list[str] = field(default_factory=list) + """The original frame filenames""" + images: list[np.ndarray] = field(default_factory=list) + """The original frames""" + sources: list[str | None] = field(default_factory=list) + """The full paths to the source folder or video file. ``None`` if not provided""" + is_aligned: bool = False + """``True`` if :attr:`images` contains aligned faces. ``False`` for full frames""" + frame_sizes: list[tuple[int, int]] | None = None + """The original frame (heights, widths) when the images are aligned faces""" + frame_metadata: list[PNGHeaderSourceDict] | None = None + """The original frame metadata when aligned faces is ``True`` otherwise ``None``""" + passthrough: bool = False + """Whether this item should pass straight through the pipeline for immediate return""" + + # Final data for output + bboxes: npt.NDArray[np.int32] = field(init=False, + default_factory=lambda: np.empty((0, 4), dtype=np.int32)) + """The bounding boxes found for this batch""" + aligned: ExtractBatchAligned = field(init=False, default_factory=ExtractBatchAligned) + """Holds the face landmarks found for this batch any any aligned data""" + masks: dict[str, ExtractBatchMask] = field(init=False, default_factory=dict) + """The masks for this batch""" + identities: dict[str, npt.NDArray[np.float32]] = field(init=False, default_factory=dict) + """The identity matrices for face recognition found for this batch""" + + # Internal batch structure + frame_ids: npt.NDArray[np.int32] = field(init=False, + default_factory=lambda: np.empty((0, ), + dtype=np.int32)) + """A mapping of each box to which frame they came from""" + + # Internal holder for passing data between processes. Deleted at output from each plugin + data: np.ndarray = field(init=False) + """The data for this batch that has been populated by a processing step for ingestion by the + next processing step. Internally populated. Cleared at the end of each plugin""" + matrices: npt.NDArray[np.float32] = field(init=False) + """Transformation matrices for taking points from model input space to frame space. Cleared at + the end of each plugin""" + + def __repr__(self) -> str: + """Pretty print arrays""" + params: dict[str, T.Any] = {} + for k, v in self.__dict__.items(): + if isinstance(v, (list, tuple)) and v and isinstance(v[0], np.ndarray): + params[k] = [format_array(x) for x in v] + continue + if k == "identities" and isinstance(v, dict): + params[k] = {key: format_array(val) for key, val in v.items()} + continue + if isinstance(v, np.ndarray): + params[k] = format_array(v) + continue + params[k] = v + + s_params = ", ".join(f"{k}={v}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + + def __post__init__(self) -> None: + """Populate sources if not provided""" + if not self.sources: + self.sources = [None for _ in range(len(self.filenames))] + + def __len__(self) -> int: + """The number of faces contained within this object""" + return len(self.bboxes) + + @property + def landmarks(self) -> npt.NDArray[np.float32] | None: + """The face landmarks found for this batch in frame space or ``None`` if not populated""" + return self.aligned.landmarks + + @landmarks.setter + def landmarks(self, value: npt.NDArray) -> None: + """Set the landmarks attribute in the underlining ExtractBatchAlign object + + Parameters + ---------- + value + The landmarks to set + """ + self.aligned.landmarks = value + + @property + def landmark_type(self) -> LandmarkType | None: + """The landmark type found for this batch or ``None`` if not populated""" + return self.aligned.landmark_type + + @landmark_type.setter + def landmark_type(self, value: LandmarkType) -> None: + """Set the landmark_type attribute in the underlining ExtractBatchAlign object + + Parameters + ---------- + value + The landmark_type to set + """ + self.aligned.landmark_type = value + + @property + def lengths(self) -> npt.NDArray[np.int32]: + """The number of bboxes that belong to each frame""" + if self.frame_ids.size == 0: + return np.zeros((len(self.images)), dtype=np.int32) + return np.bincount(self.frame_ids, minlength=len(self.images)).astype(np.int32) + + def __getitem__(self, indices: slice) -> ExtractBatch: + """Obtain a subset of this batch object with the data given by the start and end indices + + Parameters + ---------- + indices + The (start, stop, end) slice for extracting from the batch + + Returns + ------- + A batch object containing the data from this object for the given indices + """ + frame_ids = self.frame_ids[indices].copy() + # If requesting the first bbox, we select all frames from the start + frame_start = 0 if indices.start == 0 else frame_ids[0] + + frame_end = frame_ids[-1] + 1 + if indices.stop < self.bboxes.shape[0] and self.frame_ids[indices.stop] > frame_end: + # catch any zero box frames between now and next split request + frame_end = self.frame_ids[indices.stop] + + frame_sizes = None if self.frame_sizes is None else self.frame_sizes[frame_start:frame_end] + frame_metadata = (None if self.frame_metadata is None + else self.frame_metadata[frame_start:frame_end]) + retval = ExtractBatch(self.filenames[frame_start:frame_end], + self.images[frame_start:frame_end], + sources=self.sources[frame_start:frame_end], + is_aligned=self.is_aligned, + frame_sizes=frame_sizes, + frame_metadata=frame_metadata, + passthrough=self.passthrough) + retval.bboxes = self.bboxes[indices] + retval.aligned = self.aligned[indices] + retval.masks = {k: v[indices] for k, v in self.masks.items()} + retval.identities = {k: v[indices] for k, v in self.identities.items()} + + if indices.start > 0: + frame_ids -= frame_ids[0] # Reset to zero + retval.frame_ids = frame_ids + + if self.landmarks is not None: + retval.landmarks = self.landmarks[indices] + + if hasattr(self, "data"): + retval.data = self.data[indices] + + if hasattr(self, "matrices"): + retval.matrices = self.matrices[indices] + + return retval + + def _populate_batch(self, batch: ExtractBatch) -> None: + """Populate this batch with the data from the incoming batch when this batch is empty + + Parameters + ---------- + batch + The object containing data to populate to this object + """ + for k, v in batch.__dict__.items(): + setattr(self, k, v) + + def append(self, batch: ExtractBatch) -> None: # noqa[C901] + """Append the data from the given batch object to this batch object + + Parameters + ---------- + batch + The object containing data to be appended to this object + """ + if not self.filenames: + self._populate_batch(batch) + return + frame_offset = len(self.filenames) + if self.filenames[-1] == batch.filenames[0]: + frame_offset -= 1 # We are still on the same frame + if not np.any(self.images[-1]) and np.any(batch.images[0]): + # Image was stripped for the faces in this batch, but exist for incoming batch + self.images[-1] = batch.images[0] + batch.frame_ids += frame_offset + + existing_filenames = self.filenames[:] + self.filenames.extend(f for f in batch.filenames if f not in existing_filenames) + self.images.extend(batch.images[i] for i, f in enumerate(batch.filenames) + if f not in existing_filenames) + self.sources.extend(batch.sources[i] for i, f in enumerate(batch.filenames) + if f not in existing_filenames) + + if self.frame_sizes is not None and batch.frame_sizes is not None: + self.frame_sizes.extend(batch.frame_sizes[i] for i, f in enumerate(batch.filenames) + if f not in existing_filenames) + if self.frame_metadata is not None and batch.frame_metadata is not None: + self.frame_metadata.extend(batch.frame_metadata[i] + for i, f in enumerate(batch.filenames) + if f not in existing_filenames) + + self.bboxes = np.concatenate([self.bboxes, batch.bboxes]) + self.frame_ids = np.concatenate([self.frame_ids, batch.frame_ids]) + self.aligned.append(batch.aligned) + + for name, masks in batch.masks.items(): + if name in self.masks: + self.masks[name].append(masks) + else: + self.masks[name] = masks + + for name, identities in batch.identities.items(): + self.identities[name] = (np.concatenate([self.identities[name], identities]) + if name in self.identities + else identities) + + if hasattr(self, "data"): + self.data = np.concatenate([self.data, batch.data]) + + if hasattr(self, "matrices"): + self.matrices = np.concatenate([self.matrices, batch.matrices]) + + @classmethod + def from_frame_faces(cls, media: FrameFaces) -> ExtractBatch: + """Populate a new ExtractBatch with the contents of an FrameFaces object. + + Parameters + ---------- + media + The FrameFaces to populate this batch from + + Returns + ------- + A new ExtractBatch object populated from the given FrameFaces object + """ + retval = cls([media.filename], + [media.image], + sources=[media.source], + is_aligned=media.is_aligned, + frame_sizes=[media.image_size] if media.is_aligned else None, + frame_metadata=[media.frame_metadata] if media.frame_metadata else None, + passthrough=media.passthrough) + retval.frame_ids = np.fromiter((0 for _ in range(len(media.bboxes))), dtype=np.int32) + retval.bboxes = media.bboxes + retval.identities = media.identities + retval.masks = media.masks + retval.aligned = media.aligned + return retval + + def from_detected_faces(self, faces: list[DetectedFace]) -> None: + """Populate an ExtractBatch with the contents of a DetectedFace object. + + Parameters + ---------- + faces + The DetectedFace objects to populate this batch + + Raises + ------ + ValueError + If attempting to add detected faces without pre-populating filename and image or if + bounding boxes pre-exist or if more than one frame is held in this batch + """ + if not self.filenames: + raise ValueError("Filenames must be populated prior to adding detected faces") + if not self.images: + raise ValueError("Images must be populated prior to adding detected faces") + if len(self.filenames) != len(self.images) != 1: + raise ValueError("Only 1 filename and image should be the batch") + if np.any(self.bboxes): + raise ValueError("An empty ExtractBatch object is required to add detected faces") + self.frame_ids = np.fromiter((0 for _ in range(len(faces))), dtype=np.int32) + self.aligned.landmark_type = LandmarkType.from_shape(T.cast(tuple[int, int], + faces[0].landmarks_xy.shape)) + num_faces = len(faces) + self.bboxes = np.empty((num_faces, 4), dtype=np.int32) + self.aligned.landmarks = np.empty((num_faces, *faces[0].landmarks_xy.shape), + dtype=np.float32) + self.identities = {k: np.empty((num_faces, *v.shape), dtype=np.float32) + for k, v in faces[0].identity.items()} + self.masks = { + k: ExtractBatchMask(v.stored_centering, + np.empty((num_faces, 2, 3), dtype=np.float32), + storage_size=v.stored_size, + masks=np.empty((num_faces, v.stored_size, v.stored_size), + dtype=np.uint8)) + for k, v in faces[0].mask.items() + } + for i, f in enumerate(faces): + self.bboxes[i] = np.array([f.left, f.top, f.right, f.bottom], dtype=np.int32) + self.aligned.landmarks[i] = f.landmarks_xy + for k, idn in f.identity.items(): + self.identities[k][i] = idn + for k, m in f.mask.items(): + mask = self.masks[k] + mask.matrices[i] = m.affine_matrix + mask.masks[i] = m.mask[:, :, 0] + + def apply_mask(self, mask: npt.NDArray[np.bool_]) -> None: + """Apply a boolean mask to the batch object. ``True`` values are kept, ``False`` values + are discarded + + Parameters + ---------- + mask + The boolean mask to apply to the object. Must be of size (num_boxes, ) + """ + if np.all(mask): + return + + self.bboxes = self.bboxes[mask] + self.frame_ids = self.frame_ids[mask] + self.aligned.apply_mask(mask) + + if self.masks: + for v in self.masks.values(): + v.apply_mask(mask) + + if self.identities: + self.identities = {k: v[mask] for k, v in self.identities.items()} + + +class FrameFaces: # pylint:disable=too-many-instance-attributes + """An object for holding information about faces in a single frame + + Parameters + ---------- + filename + The original file name of the frame + image + The original frame or a faceswap aligned face image + bboxes + The (N, Left, Top, Right, Bottom) bounding boxes of the faces in the frame. + Default: ``None`` (Not provided) + landmarks + The (N, M, 2) landmarks for each face in the frame, in frame space. + Default: ``None`` (Not provided) + identities + The identity matrices for each face in the frame. Default: ``None`` (Not provided) + masks + The mask objects for each face in the frame. Default: ``None`` (Not provided) + source + The full path to the source folder or video file. Default: ``None`` (Not provided) + is_aligned + ``True`` if the :attr:`image` is an aligned faceswap image otherwise ``False``. Used for + face filtering with vggface2. Aligned faceswap images will automatically skip detection, + alignment and masking. Default: ``False`` + frame_metadata + The frame metadata for aligned images. ``None`` if the image is not an aligned image + passthrough + ``True`` if this item is meant to be passed straight through the extraction pipeline with + no batching or caching. for immediate return. Default: ``False`` + """ + def __init__(self, # pylint:disable=too-many-arguments,too-many-positional-arguments + filename: str, + image: npt.NDArray[np.uint8], + bboxes: npt.NDArray[np.int32] | None = None, + landmarks: npt.NDArray[np.float32] | None = None, + identities: dict[str, npt.NDArray[np.float32]] | None = None, + masks: dict[str, ExtractBatchMask] | None = None, + source: str | None = None, + is_aligned: bool = False, + frame_metadata: PNGHeaderSourceDict | None = None, + passthrough: bool = False) -> None: + logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] + if is_aligned: + assert frame_metadata is not None, "frame_metadata is required for aligned images" + + self.filename = filename + """The original file name of the original frame""" + self.image = image + """The original frame or a faceswap aligned face image""" + self.bboxes = np.empty((0, 4), dtype=np.int32) if bboxes is None else bboxes + """The (N, Left, Top, Right, Bottom) bounding boxes of the faces in the frame""" + self.identities = {} if identities is None else identities + """The identity matrices for each face in the frame""" + self.masks = {} if masks is None else masks + """The mask objects for each face in the frame""" + self.source = source + """The full path to the source folder or video file or ``None`` if not provided""" + self.frame_metadata: PNGHeaderSourceDict | None = frame_metadata + """The frame metadata that has been added from an aligned image. ``None`` if metadata has + not been added""" + self.is_aligned = is_aligned + """``True`` if :attr:`image` is an aligned faceswap image otherwise ``False``""" + self.passthrough = passthrough + """``True`` if the contents of this item are meant to pass straight through the extraction + pipeline for immediate return""" + self.image_shape = self._get_image_shape() + """The shape of the original frame""" + + self.aligned = ExtractBatchAligned( + landmarks=landmarks if landmarks is None else landmarks, + landmark_type=(None if landmarks is None + else LandmarkType.from_shape(T.cast(tuple[int, int], + landmarks.shape[1:])))) + """Holds the face landmarks found for this batch any any aligned data""" + self._name = self.__class__.__name__ + """The name of this object for logging""" + + def __repr__(self) -> str: + """Pretty print for logging""" + params: dict[str, T.Any] = {} + for k, v in self.__dict__.items(): + if k in ("image_shape", "_name"): + continue + if k == "identities": + params[k] = {i: format_array(m) for i, m in v.items()} + continue + if k == "aligned": + lms = v.landmarks + params["landmarks"] = None if lms is None else format_array(lms) + continue + params[k] = format_array(v) if isinstance(v, np.ndarray) else repr(v) + s_params = ", ".join(f"{k}={v}" for k, v in params.items()) + return f"{self._name}({s_params})" + + def __len__(self) -> int: + """The number of faces contained within this object""" + return len(self.bboxes) + + @property + def landmarks(self) -> npt.NDArray[np.float32] | None: + """The (N, M, 2) landmarks for each face in the frame, in frame space""" + return self.aligned.landmarks + + @landmarks.setter + def landmarks(self, value: npt.NDArray[np.float32]) -> None: + """Set the landmarks attribute in the underlining ExtractBatchAlign object + + Parameters + ---------- + value + The landmarks to set + """ + self.aligned.landmarks = value + self.aligned.landmark_type = LandmarkType.from_shape(T.cast(tuple[int, int], + value.shape[1:])) + + @property + def detected_faces(self) -> list[DetectedFace]: + """A list of DetectedFace objects within the :attr:`image`""" + return [DetectedFace(left=int(box[0]), + top=int(box[1]), + width=int(box[2] - box[0]), + height=int(box[3] - box[1]), + landmarks_xy=(None if self.landmarks is None + or not self.landmarks.size + else self.landmarks[idx]), + mask={k: Mask(storage_size=m.storage_size, + storage_centering=m.centering).add( + m.masks[idx], + m.matrices[idx]) + for k, m in self.masks.items()}, + identity={k: i[idx] for k, i in self.identities.items() + if i.size}) + for idx, box in enumerate(self.bboxes)] + + @detected_faces.setter + def detected_faces(self, faces: list[DetectedFace]) -> None: + """Set the underlying properties from a list of DetectedFace objects + + Parameters + ---------- + faces + The DetectedFace objects to populate to this object + + Raises + ------ + ValueError + If the FrameFaces object does not contain a filename and image or if any of the data + fields are populated + """ + if not self.filename or not np.any(self.image): + raise ValueError("Filename and image must be populated before adding DetectedFace " + "objects") + if np.any(self.bboxes) or self.landmarks is not None or self.masks or self.identities: + raise ValueError("The FrameFaces object must not be pre-populated when adding" + "DetectedFace objects") + for face in faces: + if None not in (face.left, face.top, face.width, face.height): + bbox = np.array([[face.left, face.top, face.right, face.bottom]], dtype=np.int32) + self.bboxes = np.concatenate([self.bboxes, bbox]) + if face.has_landmarks: + landmarks = np.array(face.landmarks_xy, dtype=np.float32)[None] + self.landmarks = (landmarks if self.landmarks is None + else np.concatenate([self.landmarks, landmarks])) + for k, m in face.mask.items(): + msk = ExtractBatchMask(m.stored_centering, + m.affine_matrix[None], + m.stored_size, + m.mask[None]) + if k not in self.masks: + self.masks[k] = msk + else: + self.masks[k].append(msk) + for k, i in face.identity.items(): + if k not in self.identities: + self.identities[k] = i[None] + else: + self.identities[k] = np.concatenate([self.identities[k], i[None]]) + + @property + def image_size(self) -> tuple[int, int]: + """The (`height`, `width`) of the stored :attr:`image`""" + return self.image_shape[:2] + + def _get_image_shape(self) -> tuple[int, int, int]: + """Obtain the shape of the original image. Either the given image's shape or the value + stored in the metadata if this is an aligned face object + + Returns + ------- + The shape of the original image + """ + if self.is_aligned: + assert self.frame_metadata is not None + dims = T.cast(tuple[int, int], self.frame_metadata["source_frame_dims"]) + return (*dims, 3) + return T.cast(tuple[int, int, int], self.image.shape) + + def append(self, batch: FrameFaces) -> None: + """Append the data from the given batch object to this batch object + + Parameters + ---------- + batch + The object containing data to be appended to this object + """ + assert batch.filename == self.filename + assert batch.source == self.source + assert batch.passthrough == self.passthrough + assert batch.frame_metadata == self.frame_metadata + + if not np.any(self.image): # Image potentially deleted from previous split batch + self.image = batch.image + self.bboxes = np.concatenate([self.bboxes, batch.bboxes]) + self.aligned.append(batch.aligned) + for name, masks in batch.masks.items(): + if name in self.masks: + self.masks[name].append(masks) + else: + self.masks[name] = masks + + for name, identities in batch.identities.items(): + self.identities[name] = (np.concatenate([self.identities[name], identities]) + if name in self.identities + else identities) + + def remove_image(self) -> None: + """Delete the image and reset :attr:`image` to ``None``.""" + logger.trace("[%s] Removing image for filename: '%s'", # type:ignore[attr-defined] + self._name, self.filename) + del self.image + self.image = np.empty((0, 0, 3), dtype=np.uint8) + + +def frame_faces_to_alignment(media: FrameFaces) -> list[PNGAlignments]: + """Convert the faces in a FrameFaces object into a list of dictionaries (one for each face) + for serializing into image headers and alignments files""" + if not media: + return [] + assert media.landmarks is not None + assert media.landmarks.shape[0] == len(media) + assert all(m.masks.shape[0] == m.matrices.shape[0] == len(media) for m in media.masks.values()) + assert all(i.shape[0] == len(media) for i in media.identities.values()) + + masks = {} + for k, v in media.masks.items(): + scales = np.hypot(v.matrices[..., 0, 0], v.matrices[..., 1, 0]) # Always same x/y scaling + interpolators = np.where(scales > 1.0, cv2.INTER_LINEAR, cv2.INTER_AREA) + store_masks = v.masks + mats = v.matrices + if v.storage_size != v.masks.shape[1]: + store_masks = batch_resize(v.masks[..., None], v.storage_size)[..., 0] + mats = mats.copy() + mats[:, :2] *= v.storage_size / v.masks.shape[1] + masks[k] = {"mask": [compress(m.tobytes()) for m in store_masks], + "mats": mats.tolist(), + "interpolators": interpolators.tolist(), + "size": v.storage_size, + "centering": v.centering} + + return [PNGAlignments(x=int(bbox[0]), + y=int(bbox[1]), + w=int(bbox[2] - bbox[0]), + h=int(bbox[3] - bbox[1]), + landmarks_xy=lms, + mask={k: MaskAlignmentsFile(mask=m["mask"][idx], + affine_matrix=m["mats"][idx], + interpolator=int(m["interpolators"][idx]), + stored_size=m["size"], + stored_centering=m["centering"]) + for k, m in masks.items()}, + identity={k: i[idx].tolist() for k, i in media.identities.items()}) + for idx, (bbox, lms) in enumerate(zip(media.bboxes, media.landmarks.tolist()))] + + +__all__ = get_module_objects(__name__) diff --git a/lib/infer/plugin_utils.py b/lib/infer/plugin_utils.py new file mode 100644 index 0000000000..be01ecebd5 --- /dev/null +++ b/lib/infer/plugin_utils.py @@ -0,0 +1,209 @@ +#!/usr/env/bin/python3 +"""General utility functions for Faceswap inference""" +from __future__ import annotations + +import logging +import typing as T +from collections.abc import Iterable, Mapping +from threading import Event, Lock +from time import sleep + +import cv2 +import numpy as np +import torch + +from lib.utils import get_module_objects + +if T.TYPE_CHECKING: + from plugins.extract.base import ExtractPlugin + + +logger = logging.getLogger(__name__) + + +def random_input_from_plugin(plugin: ExtractPlugin, + batch_size: int, + channels_last: bool) -> np.ndarray: + """Obtain a random input array from a plugin's information for the given batch size + + Parameters + ---------- + plugin + The plugin to obtain the input array for + batch_size : int + The batch size for the input array + channels_last : bool + ``True`` if the data should be formatted channels last + + Returns + ------- + A random input array in the correct format for the given plugin at the given batch size + """ + size = plugin.input_size + low, high = plugin.scale + im_range = high - low + retval = np.random.random((batch_size, 3, size, size)).astype(plugin.dtype) * im_range + retval += low + if channels_last: + retval = retval.transpose(0, 2, 3, 1) + return retval + + +def get_torch_modules(obj: T.Any, # noqa[C901] # pylint:disable=too-many-branches,too-many-return-statements + mod: str | None = None, + seen: set[int] | None = None, + results: list[torch.nn.Module] | None = None) -> list[torch.nn.Module]: + """Recursively search a plugin's model attribute to find any parent :class:`torch.nn.Module`s + + Parameters + ---------- + obj + The object to check if it is a torch Module. This should be a plugin's `model` attribute + mod + The module that the parent model class belongs to. Default: ``None`` (Collected from the + first object entered into the recursive function) + seen + A set of seen object IDs to prevent self-recursion. Default: ``None`` (Created when the + first object enters the recursive function) + results + List of discovered torch modules. Default: ``None`` (Created when the first object enters + the recursive function) + + Returns + ------- + The list of discovered torch Modules + """ + seen = set() if seen is None else seen + retval: list[torch.nn.Module] = [] if results is None else results + mod = obj.__class__.__module__ if mod is None else mod + + obj_id = id(obj) + if obj_id in seen: + return retval + seen.add(obj_id) + + if isinstance(obj, torch.nn.Module): + logger.debug("Torch module found in %s(%s)", obj.__class__.__name__, type(obj)) + retval.append(obj) + return retval + + if isinstance(obj, (str, bytes, int, float, bool, type(None))): + # Fast exit on primitive + return retval + + if hasattr(obj, "__class__") and obj.__class__.__module__ not in (mod, "builtins"): + # Never leave the plugin module + return retval + + if isinstance(obj, Mapping): + # Mapping before iterable as a mapping is also an iterable + for v in obj.values(): + retval = get_torch_modules(v, mod, seen=seen, results=retval) + + if isinstance(obj, Iterable): + for v in obj: + retval = get_torch_modules(v, mod, seen=seen, results=retval) + + if hasattr(obj, "__dict__"): + for v in obj.__dict__.values(): + retval = get_torch_modules(v, mod, seen=seen, results=retval) + return retval + + +def warmup_plugin(plugin: ExtractPlugin, # noqa[C901] + batch_size: int, + channels_last: bool | None = None) -> bool | None: + """Warm up a plugin that contains torch modules. If channels_last is ``None`` then attempt to + send a channels first batch through. If it fails, send a channels last batch through + + Parameters + ---------- + plugin + The plugin to warmup + batch_size + The batch size to put through the model + channels_last + The expected channel order of the plugin or ``None`` to detect + + Returns + ------- + bool + ``True`` if the plugin is detected as channels last, ``False`` for channels first, ``None`` + for could not be detected + """ + cv2_loglevel = None + cv2_setlevel = None + if channels_last is None: + # cv2 outputs scary warnings when we are testing channels first/last with cv2-dnn plugins + # so disable logging + try: # cv2 arbitrarily moves this based on build options :/ + cv2_loglevel = cv2.getLogLevel() # type:ignore[attr-defined] + cv2_setlevel = getattr(cv2, "setLogLevel") + except AttributeError: + try: + cv2_loglevel = cv2.utils.logging.getLogLevel() # type:ignore[attr-defined] + cv2_setlevel = getattr(cv2.utils.logging, "setLogLevel") + except AttributeError: + pass + + chan_list = [False, True] if channels_last is None else [channels_last] + is_chan_last = None + + if cv2_setlevel is not None: + cv2_setlevel(0) + + for chan_last in chan_list: + try: + inp = random_input_from_plugin(plugin, batch_size, chan_last) + plugin.process(inp) + is_chan_last = chan_last + break + except Exception as err: # pylint:disable=broad-except + logger.debug("Exception with channels_last=%s: %s", chan_last, str(err).strip()) + + if cv2_setlevel is not None: + cv2_setlevel(cv2_loglevel) + logger.debug("[%s] Warmed up. channels_last: %s", plugin.name, is_chan_last) + return is_chan_last + + +_COMPILE_LOCK = Lock() +_COMPILE_LOGGED = Event() + + +def compile_models(plugin: ExtractPlugin, modules: list[torch.nn.Module]) -> None: + """Compile any Torch modules in the plugin's `model` attribute + + Parameters + ---------- + plugin + The plugin containing Torch modules to be compiled + modules + The list of Torch modules contained within the plugin's `model` attribute + """ + with _COMPILE_LOCK: + if not _COMPILE_LOGGED.is_set(): + _COMPILE_LOGGED.set() + sleep(0.5) # Let other plugins log their output first + logger.info("Compiling PyTorch models...") + channels_last = warmup_plugin(plugin, 1) # Make sure we don't trace on wrong channel order + for mod in modules: + logger.verbose("Compiling %s (%s)...", # type:ignore[attr-defined] + plugin.name, mod.__class__.__name__) + mod.compile( + fullgraph=True, + dynamic=False, # We handle dynamic BS in code + options={"triton.cudagraphs": True, # Required to stop worker speed back to eager + "triton.cudagraph_trees": False, # Optimize for static shapes + "triton.cudagraph_support_input_mutation": True, + "shape_padding": True, # Pad tensors for Tensor core usage + "epilogue_fusion": True, + "coordinate_descent_tuning": True, # Can sometimes find better kernels + "max_autotune": True, + "max_autotune_report_choices_stats": False}) + # Send the warmup batch here as we need to keep the lock when tracing + warmup_plugin(plugin, plugin.batch_size, channels_last=channels_last) + torch.cuda.empty_cache() # Need to clear cache or we may run out of VRAM + + +__all__ = get_module_objects(__name__) diff --git a/lib/infer/profile.py b/lib/infer/profile.py new file mode 100644 index 0000000000..14eeee5068 --- /dev/null +++ b/lib/infer/profile.py @@ -0,0 +1,776 @@ +#! /usr/env/bin/python3 +"""GPU profiling for throughput optimization""" +from __future__ import annotations + +import logging +import math +import typing as T +from dataclasses import dataclass, InitVar, field +from operator import itemgetter +from threading import Event, Lock +from time import perf_counter + +import numpy as np +import torch +from tqdm import tqdm + +from lib.logger import parse_class_init +from lib.multithreading import FSThread +from lib.utils import get_module_objects +from plugins.extract import extract_config as cfg + +from .runner import get_pipeline +from .plugin_utils import get_torch_modules, random_input_from_plugin, warmup_plugin + +if T.TYPE_CHECKING: + import numpy.typing as npt + from lib.multithreading import ErrorState + from plugins.extract.base import ExtractPlugin + from .handler import ExtractHandler + from .runner import ExtractRunner + +logger = logging.getLogger(__name__) + + +# TODO roll back to max and refine + + +class ModelProfile(): + """Benchmark a single PyTorch GPU plugin for inference + + Parameters + ---------- + plugin + The plugin to benchmark for inference + max_batch_size + The maximum batch size to benchmark to + channels_last + ``True`` if the input to the plugin is channels last + run_time + The amount of time, in seconds, to benchmark the plugin at each batch size + """ + # TODO This is not currently used as information from single model profiling is limited and + # adds additional time to profiling. However this is likely to be useful for deciding on device + # allocation if/when multi-gpu support is added + def __init__(self, + plugin: ExtractPlugin, + max_batch_size: int = 128, + channels_last: bool = False, + run_time: int = 10) -> None: + logger.debug(parse_class_init(locals())) + self.plugin = plugin + self._max_batch_size = max_batch_size + self.channels_last = channels_last + """True if the plugin expects channels last input""" + self._run_time = run_time + + num_tests = int(math.log2(self._max_batch_size)) + 1 + self.batch_sizes = np.fromiter((2 ** i for i in range(num_tests)), dtype=np.int64) + self.iterations = np.zeros((num_tests, ), dtype=np.int64) - 1 + self.vram = np.zeros((2, num_tests), dtype=np.int64) - 1 + + torch.cuda.empty_cache() + plugin.batch_size = 1 + plugin.model = plugin.load_model() + + @property + def run_time(self) -> int: + """The amount of time, in seconds, that benchmarks were ran per batch""" + return self._run_time + + def __repr__(self) -> str: + """Pretty print for logging""" + params = {k[1:]: repr(v) for k, v in self.__dict__.items() + if k in ("_plugin", "_max_batch_size", "_channels_last", "_run_time")} + results = {k: v.tolist() for k, v in self.__dict__.items() + if k in ("batch_sizes", "iterations", "vram")} + s_params = ", ".join(f"{k}={v}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params}) {results}" + + def _predict(self, inputs: np.ndarray, seconds: float) -> int: + """Run inference on a plugin for the given number of seconds + + Parameters + ---------- + inputs + The input to use for benchmarking the plugin + seconds + The number of seconds to run benchmarking + + Returns + ------- + The number of iterations that were processed through the plugin + """ + start = perf_counter() + iters = 0 + while perf_counter() - start < seconds: + self.plugin.process(inputs) + iters += 1 + torch.cuda.synchronize() + return iters + + def _output_stats(self) -> None: + """Print the benchmark results to screen in a format that is easy to read and can be + copy and pasted (fixed width)""" + egs = [(i * b) / self._run_time for i, b in zip(self.iterations, self.batch_sizes)] + bs_str = [str(i) for i in self.batch_sizes] + eg_str = ["N/A" if i < 0 else f"{i:.1f}" for i in egs] + vram_alloc_str = ["N/A" if i < 0 else str(int(round(i / (1024 * 1024)))) + for i in self.vram[0]] + vram_res_str = ["N/A" if i < 0 else str(int(round(i / (1024 * 1024)))) + for i in self.vram[1]] + labels = ["BatchSize", "EG/S", "VRAM(MB) Allocated", "VRAM(MB) Reserved"] + + lbl_width = max(len(i) for i in labels) + col_width = max(len(i) for i in bs_str + eg_str + vram_alloc_str + vram_res_str) + 2 + + for lbl, data in zip(labels, (bs_str, eg_str, vram_alloc_str, vram_res_str)): + dat = "".join([d.rjust(col_width) for d in data]) + print(f" {lbl.ljust(lbl_width)}{dat}") + + def __call__(self) -> None: + """Runs benchmarking through the plugin, stores the data and outputs stats""" + logger.info("Profiling %s", self.plugin.name) + prog_bar = tqdm(self.batch_sizes, desc="Batch size 1", leave=False, smoothing=0) + for idx, batch_size in enumerate(prog_bar): + inputs = random_input_from_plugin(self.plugin, batch_size, self.channels_last) + try: + torch.cuda.empty_cache() + self._predict(inputs, 2) # warmup + torch.cuda.reset_peak_memory_stats() + + iters = self._predict(inputs, self._run_time) + + self.iterations[idx] = iters + self.vram[0, idx] = torch.cuda.max_memory_allocated() + self.vram[1, idx] = torch.cuda.max_memory_reserved() + except torch.cuda.OutOfMemoryError: + logger.debug("Exiting benchmark early as out of VRAM") + break + prog_bar.set_description(f"Batch size {batch_size}") + + self._output_stats() + del self.plugin.model + + +@dataclass +class Events: + """Holds thread events for communicating between main thread and plugins during benchmarking + + Parameters + ---------- + ready + List of events for each plugin in the pipeline to be tested + """ + ready: list[Event] + start = Event() + stop = Event() + continue_ = Event() + + def set_ready(self, index: int): + """Set the ready event for the given index + + Parameters + ---------- + index + The index of the ready event to set + """ + self.ready[index].set() + + def wait_ready(self) -> None: + """Wait for all ready events to set their ready flag and clear the flag""" + for ready in self.ready: + ready.wait() + ready.clear() + + +@dataclass +class DataTracker: # pylint:disable=too-many-instance-attributes + """Stores data from the benchmarking process + + Parameters + ---------- + size + The number of plugins that data is being tracked for + face_scaling + The amount of scaling to apply to downstream non-detection plugins + has_detector + ``True`` if the first plugin in the pipeline is a detector + max_vram + The maximum amount of total VRAM to allow Cuda to reserve when profiling + """ + size: InitVar[int] + max_vram: InitVar[float] + face_scaling: int + has_detector: bool + + vram: list[tuple[int, int]] = field(init=False, default_factory=list) + """list of (max allocated, max reserved) VRAM for each testing phase""" + vram_limit: float = field(init=False) + """The limit that Cuda reserved memory must remain within""" + combos_exhausted: bool = field(init=False, default=False) + """``True`` if we have run out of possible combinations to attempt""" + + _all_batch_sizes: npt.NDArray[np.int64] = field(init=False) + """The processed batch size configurations including failed tests""" + _iterations: npt.NDArray[np.int64] = field(init=False) + """Iterations put through each plugin at each testing phase""" + _batch_size_adjust: npt.NDArray[np.int64] = field(init=False) + """Amount to adjust batch sizes by when we are approaching VRAM limits""" + _success: npt.NDArray[np.bool_] = field(init=False) + """Booleans that show if each test successfully completed or OOM'd""" + + _lock: Lock = field(init=False, default_factory=Lock) + """Threading lock for updating iteration counts""" + _name: str = field(init=False, default="Profile.DataTracker") + """Name of dataclass for logging""" + + def __post_init__(self, size: int, max_vram: float) -> None: + """Create the data storage arrays for the given input size + + Parameters + ---------- + size + The number of plugins that data is being tracked for + max_vram + The maximum amount of total VRAM to allow Cuda to reserve when profiling + """ + self.vram_limit = torch.cuda.get_device_properties().total_memory * max_vram + self._all_batch_sizes = np.ones((1, size), dtype=np.int64) + self._success = np.array([True], dtype=bool) + self._batch_size_adjust = np.zeros((size, ), dtype=np.int64) - 1 + self._iterations = np.zeros((1, size, ), dtype="int") - 1 + + @property + def has_oom(self) -> bool: + """``True`` if the last iteration hit an OOM or fell outside our max VRAM threshold""" + if not self.vram: + return False + return any([np.any(self._iterations[-1] < 0), self.vram[-1][1] > self.vram_limit]) + + @property + def batch_sizes(self) -> npt.NDArray[np.int64]: + """All batch size combinations that did not OOM""" + return self._all_batch_sizes[self._success] + + def update_iterations(self, iterations: int, matrix_id: int) -> None: + """Update the iteration count from a plugin runner in a thread-safe way + + Parameters + ---------- + iterations + The iteration count for the plugin + matrix_id + The column id that belongs to the plugin + """ + with self._lock: + self._iterations[-1, matrix_id] = iterations + + def add_iterations_row(self) -> None: + """Add a new row to the iterations list""" + with self._lock: + new_row = np.zeros((1, len(self._iterations[-1])), dtype="int") - 1 + self._iterations = np.concatenate([self._iterations, new_row]) + + def collect_vram(self) -> None: + """Store the currently allocated and reserved Cuda VRAM stats""" + self.vram.append((torch.cuda.max_memory_allocated(), torch.cuda.max_memory_reserved())) + logger.debug("[%s] VRAM collected: %s", self._name, self.vram[-1]) + + def get_samples(self, index: int | None = None, adjusted: bool = False + ) -> npt.NDArray[np.float64]: + """Obtain the number of sample processed by each plugin for a certain valid batch size + combination + + Parameters + ---------- + index + The testing index to obtain the samples for or ``None`` for all tests + adjusted + ``True`` to obtain results adjusted for any non-detector scaling. Default: ``False`` + + Returns + ------- + The number of samples processed by each plugin + """ + iters = self._iterations[self._success] + batches = self.batch_sizes + if index is not None: + iters = iters[index] + batches = batches[index] + + retval = (iters * batches).astype(np.float64) + if adjusted and self.has_detector and self.face_scaling > 1: + if index is None: + retval[:, 1:] /= self.face_scaling + else: + retval[1:] /= self.face_scaling + logger.debug("[%s] Calculated samples/plugin: %s", self._name, retval.tolist()) + return retval + + def get_samples_stats(self, + method: T.Literal["mean", "min"], + index: int | None = None, + adjusted: bool = False) -> npt.NDArray[np.float64]: + """Obtain the average or minimum samples processed for all plugins for a certain batch size + combination + + Parameters + ---------- + method + ``mean`` to obtain the mean number of samples for all plugins. ``min`` to obtain the + minimum number of samples processed by a plugin + index + The testing index to obtain the average samples for or ``None`` for all tests. + Default: ``None`` + adjusted + ``True`` to obtain results adjusted for any non-detector scaling. Default: ``False`` + + Returns + ------- + The average number of samples processed by all plugins + """ + samples = self.get_samples(index=index, adjusted=adjusted) + dim = 1 if index is None else 0 + if method == "mean": + retval = samples.mean(axis=dim) + else: + retval = samples.min(axis=dim) + logger.debug("[%s] Calculated Average samples/plugin: %s", self._name, retval.tolist()) + return retval + + def _handle_oom(self) -> None: + """Update :attr:`_batch_size_adjust` in cases when we hit an OOM or exceeded our VRAM + threshold. In these instances we will either shrink our search window, or exit if we have + gone as far as we can""" + if not self.has_oom: + return + self._success[-1] = False + + changed_mask = self._all_batch_sizes[-1] != self._all_batch_sizes[-2] + diff = abs(self._all_batch_sizes[-1][changed_mask] - + self._all_batch_sizes[-2][changed_mask]) + if diff <= 4: + logger.debug("[%s] Minimum batch size adjustment hit. All combos exhausted", + self._name) + self.combos_exhausted = True + return + self._batch_size_adjust[changed_mask] = diff // 2 + logger.debug("[%s] batch_size_adjust updated to: %s", + self._name, self._batch_size_adjust.tolist()) + + def add_next_batch_sizes(self) -> None: + """Add the next batch size configuration to the batch size array based on the output from + the last test""" + self._handle_oom() + if self.combos_exhausted: + return + + samples = self.get_samples(-1, adjusted=True) + p_idx = samples.argmin() + _batch_size_adjust = self._batch_size_adjust[p_idx] + + next_batch = self.batch_sizes[-1].copy() + if _batch_size_adjust == -1: + next_batch[p_idx] *= 2 + else: + next_batch[p_idx] += _batch_size_adjust + logger.debug("[%s] next batch sizes: %s", self._name, next_batch.tolist()) + self._all_batch_sizes = np.concatenate([self._all_batch_sizes, next_batch[None]]) + self._success = np.concatenate([self._success, [True]]) + + +class Output: + """Handles outputting of information at each test step + + Parameters + ---------- + plugin_names + The list of plugin names in the order that they are executed + data + The DataTracker object that collects stats + run_time + The amount of time, in seconds, that each test is run + """ + def __init__(self, plugin_names: list[str], data: DataTracker, run_time: int): + logger.debug(parse_class_init(locals())) + self._data = data + self._run_time = run_time + self._header_row = [" " * 18] + plugin_names + ["Average", "Min"] + self._spacer = " " + self._label_widths = [len(h) + 2 for h in self._header_row] + + def _write(self, message_list: list[str], left_justify: bool = False) -> None: + """TQDM write a message with leading indentation + + Parameters + ---------- + message_list + The message to write split over columns + left_justify + ``True`` to left justify the data, ``False`` to right justify the data. + Default: ``False`` + """ + label = message_list[0].ljust(self._label_widths[0]) + message_list = message_list[1:] + if left_justify: + msg = " ".join(m.ljust(l) for m, l in zip(message_list, self._label_widths[1:])) + else: + msg = " ".join(m.rjust(l) for m, l in zip(message_list, self._label_widths[1:])) + tqdm.write(f"{self._spacer}{label}{msg}") + + def __call__(self): + """Output the latest test stats""" + if self._data.has_oom: + return + + self._write(self._header_row) + self._write(["Batch Size"] + [str(int(b)) for b in self._data.batch_sizes[-1]]) + egs = [f"{e:.1f}" for e in self._data.get_samples(-1) / self._run_time] + avg_egs = [f"{(self._data.get_samples_stats('mean', -1) / self._run_time):.1f}"] + min_egs = [f"{(self._data.get_samples_stats('min', -1) / self._run_time):.1f}"] + self._write(["EG/S"] + egs + avg_egs + min_egs) + + if self._data.has_detector and self._data.face_scaling > 1: + lbl = [f"Scaled EG/S ({self._data.face_scaling}x)"] + egs = [f"{e:.1f}" for e in self._data.get_samples(-1, adjusted=True) / self._run_time] + avg_egs = [ + f"{self._data.get_samples_stats('mean', -1, adjusted=True) / self._run_time:.1f}"] + min_egs = [ + f"{self._data.get_samples_stats('min', -1, adjusted=True) / self._run_time:.1f}"] + self._write(lbl + egs + avg_egs + min_egs) + + vram_alloc, vram_res = (str(int(round(v / 1024 / 1024))) for v in self._data.vram[-1]) + vram_res = f"{vram_res}/{str(int(round(self._data.vram_limit / 1024 / 1024)))}" + self._write(["VRAM(MB) Allocated", vram_alloc], left_justify=True) + self._write(["VRAM(MB) Reserved", vram_res], left_justify=True) + + line = "-" * (sum(self._label_widths) + (len(self._label_widths) - 2)) + tqdm.write(f"{self._spacer}{line}") + + +class PipelineProfile(): + """Benchmark multiple PyTorch GPU plugins running simultaneously for inference + + Parameters + ---------- + plugins + The plugins to benchmark for inference + error_state + The global FSThread error state object for the pipeline + channels_last + List indicating whether each model is channels first or last + warmup_time + The amount of time, in seconds, to warmup the plugin at each batch size + run_time + The amount of time, in seconds, to benchmark the plugin at each batch size + has_detector + ``True`` if the first plugin in the pipeline is a detector + face_scaling + The amount of scaling to apply to downstream plugins (ie estimate of average number of + faces per frame). Default: 2 + max_vram + The maximum percentage of total VRAM to allow Cuda to reserve when profiling, Default: 90 + """ + def __init__(self, + plugins: list[ExtractPlugin], + error_state: ErrorState, + channels_last: list[bool], + warmup_time: int, + run_time: int, + has_detector: bool, + face_scaling: int = 2, + max_vram: int = 90) -> None: + logger.debug(parse_class_init(locals())) + self._warmup_time = warmup_time + self._run_time = run_time + self._current_index = 0 + self._plugins = plugins + self._error_state = error_state + + self._events = Events(ready=[Event() for _ in range(len(plugins))]) + self._data = DataTracker(len(plugins), + max_vram / 100., + face_scaling, + has_detector) + self._output_stats = Output([p.name for p in plugins], self._data, run_time) + + self._threads = [FSThread(self._plugin_runner, + name=f"{p.name}_thread", + args=(p, i, c)) + for i, (p, c) in enumerate(zip(plugins, channels_last))] + + @classmethod + def _predict(cls, plugin: ExtractPlugin, inputs: np.ndarray, seconds: float) -> int: + """Run inference on a plugin for the given number of seconds + + Parameters + ---------- + plugin + The plugin to run inference through + inputs + The input to use for benchmarking the plugin + seconds + The number of seconds to run benchmarking + + Returns + ------- + The number of iterations that were processed through the plugin + """ + start = perf_counter() + iters = 0 + while perf_counter() - start < seconds: + plugin.process(inputs) + iters += 1 + torch.cuda.synchronize() + return iters + + def _plugin_runner(self, plugin: ExtractPlugin, matrix_id: int, channels_last: bool) -> None: + """Runs a plugin inside a thread, waits and reports to main thread by means of events + + Parameters + ---------- + plugin + The plugin that this thread will run + matrix_id + The column id to obtain the batch size for this plugin from :attr:`matrix` + channels_last + ``True`` if the input to the plugin is channels last + """ + name = plugin.name + logger.debug("[PipelineProfile] Loading '%s' (id: %s)", name, matrix_id) + plugin.batch_size = 1 + plugin.model = plugin.load_model() + while True: + if self._error_state.has_error: + self._error_state.re_raise() + self._events.start.wait() + if self._events.stop.is_set(): + break + batch_size = self._data.batch_sizes[-1][matrix_id] + inputs = random_input_from_plugin(plugin, batch_size, channels_last) + logger.debug("[PipelineProfile] Running test '%s'. input: %s", name, inputs.shape) + try: + self._predict(plugin, inputs, self._warmup_time) # warmup + self._events.set_ready(matrix_id) + + self._events.continue_.wait() + iters = self._predict(plugin, inputs, self._run_time) + self._data.update_iterations(iters, matrix_id) + self._events.set_ready(matrix_id) + + except torch.cuda.OutOfMemoryError: + logger.debug("[PipelineProfile] Exiting benchmark early as out of VRAM") + self._events.set_ready(matrix_id) + if self._events.stop.is_set(): + break + del plugin.model + + def _update_batch_sizes(self) -> None: + """Output final batch sizes and update the plugins""" + best_idx = self._data.get_samples(adjusted=True).min(axis=1).argmax() + best_batch_sizes = self._data.batch_sizes[best_idx] + plugin_names = [p.name for p in self._plugins] + logger.info("[Profiler] Setting optimal batch sizes: %s", + ", ".join(f"{p}: {b}" for p, b in zip(plugin_names, + self._data.batch_sizes[best_idx]))) + + for plugin, batch_size in zip(self._plugins, best_batch_sizes): + logger.debug("[PipelineProfile] Updating batch size for '%s': %s", + plugin.name, batch_size) + plugin.batch_size = int(batch_size) + + def __call__(self) -> None: + """Runs benchmarking through all plugins concurrently, store the data and output stats""" + prog_length = 5 + for thread in self._threads: + thread.start() + + while True: + if self._error_state.has_error: + self._error_state.re_raise() + + msg = f"[{self._current_index}] Batches {tuple(self._data.batch_sizes[-1].tolist())}" + prog_bar = tqdm(desc=f"Benchmarking Pipeline {msg}", total=prog_length, leave=False) + torch.cuda.empty_cache() + + # Warmup + prog_bar.update() + self._events.start.set() + self._events.wait_ready() + prog_bar.update() + self._events.start.clear() + + # Benchmark + torch.cuda.reset_peak_memory_stats() + self._events.continue_.set() + prog_bar.update() + self._events.wait_ready() + prog_bar.update() + self._events.continue_.clear() + self._data.collect_vram() + + self._output_stats() + + prog_bar.update() + self._data.add_next_batch_sizes() + if self._data.combos_exhausted: + prog_bar.close() + break + + self._data.add_iterations_row() + self._current_index += 1 + prog_bar.close() + + self._events.stop.set() + self._events.start.set() + for thread in self._threads: + thread.join() + self._update_batch_sizes() + + +class Profiler: + """Profiles plugins within a pipeline + + Parameters + ---------- + runner + The output runner from an extract pipeline that is to be profiled + """ + def __init__(self, runner: ExtractRunner) -> None: + logger.debug(parse_class_init(locals())) + logger.info("Profiling models...") + self._chain = T.cast("list[ExtractRunner[ExtractHandler]]", # For intellisense purposes + get_pipeline(runner)) + self._channels_last: list[bool] = [] + self._torch_runners = self._get_torch_indices() + + def _check_for_torch(self, plugin: ExtractPlugin) -> bool: + """Check whether the given runner uses PyTorch. We wait until the plugin is initialized + then recurse through it's :attr:`model` property looking for Torch Modules + + Parameters + ---------- + plugin + The plugin to check for PyTorch usage + + Returns + ------- + bool + ``True`` if the runner uses PyTorch + """ + model = plugin.load_model() + logger.debug("[Profiler] Scanning for torch Module: %s(%s)", + plugin.name, model.__class__.__name__) + modules = get_torch_modules(model) + if not modules: + return False + + plugin.model = model + channels_last = warmup_plugin(plugin, 1) + assert channels_last is not None + self._channels_last.append(channels_last) + del plugin.model + return True + + def _get_torch_indices(self) -> list[int]: + """Obtain the indices within :attr:`_chain` that contain models running on pyTorch on the + GPU + + Returns + ------- + The list of indices of the runners that are running PyTorch models on the GPU + """ + retval: list[int] = [] + for idx, runner in enumerate(self._chain): + if runner.handler.plugin.device.type == "cpu": + logger.debug("[Profiler] Skipping CPU model: '%s'", runner.handler.plugin_name) + continue + if self._check_for_torch(runner.handler.plugin): + logger.debug("[Profiler] Adding: '%s'", runner.handler.plugin.name) + retval.append(idx) + continue + logger.debug("[Profiler] Skipping: '%s'", runner.handler.plugin.name) + + logger.debug("[Profiler] Torch runners indices: %s", retval) + if len(self._channels_last) != len(retval): + raise RuntimeError("Failed to get all channels_last information") + return retval + + def _profile_isolated(self) -> list[ModelProfile]: + """Benchmark the models in isolation and return the benchmark objects + + Returns + ------- + The benchmark object for each plugin tested + """ + retval: list[ModelProfile] = [] + for idx, chan_last in zip(self._torch_runners, self._channels_last): + plugin = self._chain[idx].handler.plugin + profile = ModelProfile(plugin, channels_last=chan_last) + logger.debug("Benchmarking %s (%s/%s)", plugin.name, idx + 1, len(self._torch_runners)) + profile() + retval.append(profile) + return retval + + @classmethod + def _update_config_file(cls, plugins: list[ExtractPlugin]): + """Update the config file if requested in settings + + Parameters + ---------- + The plugins that have had their throughput profiled + """ + if not cfg.profile_save_config(): + return + conf = cfg.load_config() + f_names = [".".join(p.__class__.__module__.rsplit(".", maxsplit=2)[-2:]) for p in plugins] + + is_updated = False + for plugin_name, plugin in zip(f_names, plugins): + opts = conf.sections[plugin_name].options + opt = opts.get("batch_size", opts.get("batch_size")) + if not opt: + logger.warning("Could not update Config file for '%s' as no 'batch_size' " + "entry found", plugin.name) + continue + old_val = opt() + new_val = plugin.batch_size + if old_val == new_val: + logger.debug("[Profiler] Skipping unchanged batch size %s for '%s'", + old_val, plugin_name) + continue + logger.debug("[Profiler] Updating batch size from %s to %s for '%s'", + old_val, new_val, plugin_name) + is_updated = True + opt.set(new_val) + + if not is_updated: + logger.info("No batch sizes were updated from their saved values. " + "Not saving config file") + return + logger.info("Saving config file with updated batch sizes") + conf.save_config() + + def __call__(self) -> None: + """Call the profiler""" + # model_benchmarks = self._profile_isolated() # Unused. Kept for if/when multi-gpu support + plugins = [r.handler.plugin for r in itemgetter(*self._torch_runners)(self._chain)] + has_detector = self._chain[self._torch_runners[0]].handler.plugin_type == "detect" + pipeline_benchmarks = PipelineProfile(plugins, + self._chain[0]._threads.error_state, + self._channels_last, + cfg.profile_warmup_time(), + cfg.profile_test_time(), + has_detector, + cfg.profile_num_faces(), + cfg.profile_max_vram()) + pipeline_benchmarks() + self._update_config_file(plugins) + torch.cuda.empty_cache() + logger.debug("[Profiler] Starting plugin threads") + for runner in self._chain: + runner.start() + + +__all__ = get_module_objects(__name__) diff --git a/lib/infer/runner.py b/lib/infer/runner.py new file mode 100644 index 0000000000..f2b1670948 --- /dev/null +++ b/lib/infer/runner.py @@ -0,0 +1,805 @@ +#! /usr/env/bin/python3 +"""Handles extract plugins and runners """ +from __future__ import annotations + +import logging +import typing as T +from queue import Queue, Empty as QueueEmpty, Full as QueueFull +from threading import current_thread, main_thread +from time import sleep +from uuid import uuid4 + +import numpy as np +import numpy.typing as npt + +from lib.align.constants import LandmarkType +from lib.logger import parse_class_init +from lib.multithreading import ErrorState, FSThread +from lib.utils import get_module_objects +from .iterator import InboundIterator, InputIterator, InterimIterator, OutputIterator +from .objects import ExtractBatch, FrameFaces, ExtractSignal + + +if T.TYPE_CHECKING: + from .handler import ExtractHandler, ExtractHandlerFace + from lib.align.alignments import PNGHeaderSourceDict + from lib.align.detected_face import DetectedFace + +logger = logging.getLogger(__name__) + + +_PLUGIN_REGISTER: dict[str, list[ExtractRunner]] = {} +"""uuid of the input runner to list of runners in the chain. Used to assert build order and when +calling the runner in passthrough mode and tracking multiple pipelines """ + + +class PluginThreads: + """Handles the holding of threads that will run a plugin's various subprocesses. + + Parameters + ---------- + name + The name of the plugin that the threads are being created for + """ + def __init__(self, name: str) -> None: + self._name = name + self._threads: dict[str, FSThread] = {} + self._backup_error_state = ErrorState() + """This is used when a plugin has no threads to run. Specifically the File handler never + has threads, so there will never be a thread error. If running in the main thread it is + safe to return an unused object""" + self._external_error_state: ErrorState | None = None + + @property + def error_state(self) -> ErrorState: + """The global FSThread error state object""" + if not self._threads and self._external_error_state is None: + return self._backup_error_state + if self._external_error_state is not None: + return self._external_error_state + return list(self._threads.values())[0].error_state + + @property + def enabled(self) -> list[str]: + """The thread names that have been registered within this group""" + return list(self._threads) + + def __repr__(self) -> str: + """Pretty print for logging""" + obj = f"{self.__class__.__name__}(name={self._name})" + threads = self.enabled + alive = [x.is_alive() for x in self._threads.values()] + error = None if not threads else list(self._threads.values())[0].error_state.has_error + info = f"[threads: {threads}, alive: {alive}, error: {error}]" + return f"{obj} {info}" + + def register_thread(self, + name: str, + target: T.Callable[[T.Literal["pre_process", "process", "post_process"]], + None]) -> None: + """Register a thread + + Parameters + ---------- + name + The name of the plugin handler's processor that is running in the thread + target + The function to run within the thread + """ + full_name = f"{self._name}.{name}" + logger.debug("[%s] Registering thread: '%s'", self._name, name) + self._threads[name] = FSThread(target=target, name=full_name, args=(name, )) + + def start(self) -> None: + """Start the plugin's threads""" + for key, thread in self._threads.items(): + logger.debug("[%s] Starting thread: '%s'", self._name, key) + thread.start() + + def join(self) -> None: + """Join all of the plugin's threads""" + for key, thread in self._threads.items(): + logger.debug("[%s] Joining thread: '%s'", self._name, key) + thread.join() + + def is_alive(self) -> bool: + """Test if any thread is alive + + Returns + ------- + ``True`` if any thread is alive otherwise False + """ + return any(t.is_alive() for t in self._threads.values()) + + def register_external_error_state(self, state: ErrorState) -> None: + """Register an external error state object. + + If we are not running any threads (specifically, file handler), the pipeline can hang the + calling thread. The error state from the calling thread can be populated here. This can + only be called if no threads have been registered + + Parameters + ---------- + state + The ErrorState object to register + + Raises + ------ + RuntimeError + If an ErrorState object is registered when this object already contains threads + """ + logger.debug("Registering external ErrorState: %s", state) + if self._external_error_state is not None: + logger.debug("Error state already registered: %s", state) + return + if self._threads: + raise RuntimeError("You cannot register an ErrorState object when threads exist") + self._external_error_state = state + + +HandlerT = T.TypeVar("HandlerT", "ExtractHandler", "ExtractHandlerFace") + + +class ExtractRunner(T.Generic[HandlerT]): + """Runs an extract plugin + + Parameters + ---------- + handler + The plugin handler that this runner will execute + """ + def __init__(self, handler: HandlerT) -> None: + logger.debug(parse_class_init(locals())) + self._handler: HandlerT = handler + self._plugin_name = handler.plugin_name + self._queues: dict[str, Queue] = {} + self._is_first = False + self._uuid: str | None = None + """Unique identifier for plugin ordering and multi-plugin tracking. Populated on __call__ + to ensure a plugin is not called prior to it's input runner being called""" + self._threads = self._get_threads() + self._inbound_iterator: InboundIterator | InputIterator + self._output_iterator: OutputIterator | None = None + + def __repr__(self) -> str: + """Pretty print for logging""" + return f"{self.__class__.__name__}(handler={self.handler})" + + def __iter__(self) -> T.Self: + """This is an iterator""" + return self + + def __next__(self) -> FrameFaces: + """Obtain the next item from the plugin's output + + Returns + ------- + The media object with populated detected faces for a frame + """ + if self._output_iterator is None: + raise RuntimeError(f"[{self._plugin_name}] You can only iterate the final runner in a " + "pipeline chain.") + retval = next((self._output_iterator), None) + if self._threads.error_state.has_error: + current = current_thread() + if current is main_thread(): + self._threads.error_state.re_raise() + else: + logger.debug("[%s.%s] Thread error detected in worker thread", + current.name, self.__class__.__name__) + retval = None + if retval is None: + raise StopIteration + return retval + + @property + def handler(self) -> HandlerT: + """The plugin handler that this runner is executing""" + return self._handler + + @property + def out_queue(self) -> Queue[ExtractBatch]: + """The output queue from this plugin runner""" + return self._queues["out"] + + @property + def uuid(self) -> str: + """Unique identifier for plugin ordering and multi-plugin tracking""" + assert self._uuid is not None + return self._uuid + + def _delete_images(self, batch: ExtractBatch) -> None: + """Delete any images from the batch where there are no faces + + Parameters + ---------- + batch + The batch of data to delete images without faces from + """ + no_boxes = [i for i in range(len(batch.images)) if i not in batch.frame_ids] + if not no_boxes: + return + logger.trace( # type:ignore[attr-defined] + "[%s.out] Deleting %s of %s images with no bounding boxes", + self._plugin_name, len(no_boxes), len(batch.images)) + for idx in no_boxes: + batch.images[idx] = np.empty(shape=(0, 0, 3), dtype=np.uint8) + + def _clean_output(self, + batch: ExtractBatch | ExtractSignal, + next_process: T.Literal["process", "post_process", "out"]) -> None: + """Remove any images from the batch that have no detected faces and delete any internal + plugin attributes when outputting from the plugin + + Parameters + ---------- + batch + The batch of data to potentially delete data from or ``None`` for EOF + next_process + The next process for the plugin + """ + if next_process != "out" or isinstance(batch, ExtractSignal): + return + self._delete_images(batch) + if hasattr(batch, "matrices"): + del batch.matrices + if hasattr(batch, "data"): + del batch.data + + def _put_data(self, process: str, batch: ExtractBatch | ExtractSignal) -> None: + """Put data from a plugin's process into the next queue. If this is the first plugin in + the pipeline and we are queueing data out from the plugin, then remove any images which + have no detected faces. + + Parameters + ---------- + process + The name of the process that wishes to output data + batch + The batch of data to put to the next queue or an ExtractSignal after the final + iteration + """ + queue_names = list(self._queues) + queue_index = queue_names.index(process) + 1 + next_process = T.cast(T.Literal["process", "post_process", "out"], + queue_names[queue_index]) + assert next_process in ("process", "post_process", "out") + queue = self._queues[next_process] + self._clean_output(batch, next_process) + logger.trace("[%s.%s] Outputting to '%s': %s", # type:ignore[attr-defined] + self._plugin_name, + process, + next_process, + batch.name if isinstance(batch, ExtractSignal) else batch) + + while True: + if self._threads.error_state.has_error: + logger.debug("[%s.%s] thread error detected. Not putting", + self._plugin_name, process) + return + try: + logger.trace("[%s.%s] Putting to out queue: %s", # type:ignore[attr-defined] + self._plugin_name, + process, + batch.name if isinstance(batch, ExtractSignal) else batch) + queue.put(batch, timeout=0.2) + break + except QueueFull: + logger.trace("[%s.%s] Waiting to put item", # type:ignore[attr-defined] + self._plugin_name, process) + continue + + if next_process == "out" and isinstance(batch, ExtractSignal): + sleep(1) # Wait for downstream plugins to flush + self.handler.output_info() + + def _handle_zero_detections(self, process, batch: ExtractBatch) -> bool: + """Check if the given batch is not a Detect batch and has detected faces. If not, skip the + handler and pass it straight through to the next queue + + Parameters + ---------- + process + The name of the process that is checking for zero detections + batch + The batch of data to check for zero detections + + Returns + ------- + ``True`` if the batch has no face detections and has been passed on. ``False`` if the batch + contains data to be processed + """ + if self.handler.plugin_type == "detect" or batch.frame_ids.size: + return False + logger.trace( # type:ignore[attr-defined] + "[%s.%s] Passing through batch with no detections", self._plugin_name, process) + self._put_data(process, batch) + return True + + def _get_data(self, process: str) -> T.Generator[ExtractBatch, None, None]: + """Get the next batch of data for the thread's process.""" + queue = self._queues[process] + name = f"{self._plugin_name}_{process}" + if list(self._queues).index(process) == 0: + iterator: InboundIterator | InputIterator | InterimIterator = self._inbound_iterator + else: + iterator = InterimIterator(queue, + name, + self.handler.plugin_type, + self.handler.batch_size, + self._threads.error_state) + for batch in iterator: + if batch == ExtractSignal.FLUSH: # pass flush downstream + self._put_data(process, batch) + continue + assert isinstance(batch, ExtractBatch) + if self._handle_zero_detections(process, batch): + continue + yield batch + + def _process_passthrough(self, batch: ExtractBatch) -> ExtractBatch: + """When processing a passthrough batch, it is possible for the batch object to hold more + than the plugin's batch size. In these instances, split the batch to the plugin's batch + size and merge the results back + + Parameters + ---------- + batch : ExtractBatch + The passthrough batch to potentially split + + Returns + ------- + The passthrough batch with the processed predictions + """ + in_size = len(batch.bboxes) + batch_size = self._handler.batch_size + if in_size <= batch_size: + self._handler.process(batch) + return batch + + logger.debug("[%s.process] Splitting passthrough batch of size %s for plugin size of %s", + self._plugin_name, in_size, batch_size) + retval = batch[0:batch_size] + self._handler.process(retval) + + for start in range(batch_size, in_size, batch_size): + feed = batch[start:start + batch_size] + self._handler.process(feed) + retval.append(feed) + return retval + + def _process_batches(self, process: T.Literal["pre_process", "process", "post_process"] + ) -> None: + """Obtain items from inbound queue for the process, pass to the relevant handler's + processor and for output to the next queue + + Parameters + ---------- + process + The handler's processor that will be handling the iterated batch items + """ + if process == "process" and not self.handler.do_compile: + # Non-compiled models launch quicker in the thread + self.handler.init_model() + logger.debug("[%s.%s] Starting process", self._plugin_name, process) + processor = getattr(self.handler, process) + for batch in self._get_data(process): + if process == "process" and batch.passthrough: + batch = self._process_passthrough(batch) + else: + processor(batch) + self._put_data(process, batch) + logger.debug("[%s.%s] Finished process", self._plugin_name, process) + self._put_data(process, ExtractSignal.SHUTDOWN) + + def _get_threads(self) -> PluginThreads: + """Obtain the threads required to each enabled plugin process. + + Returns + ------- + The object that manages the threads for this plugin + """ + retval = PluginThreads(self._plugin_name) + for process in self.handler.processors: + logger.debug("[%s] Adding thread for '%s'", self._plugin_name, process) + retval.register_thread(name=process, target=self._process_batches) + logger.debug("[%s] Threads: %s", self._plugin_name, retval) + return retval + + def _get_queues(self, input_runner: ExtractRunner | None) -> dict[str, Queue]: + """Obtain the in queue to the model and the output queues from each of this plugin's + processes + + Parameters + ---------- + input_runner + The input plugin or queue that feeds this plugin. ``None`` if data is to be fed + through the runner's `put` method. + + Returns + ------- + The plugin inbound queue and the output queue for each of this plugin's processes in + processing order + """ + retval: dict[str, Queue] = {} + in_queue = Queue(maxsize=1) if input_runner is None else input_runner.out_queue + for idx, thread in enumerate(self._threads.enabled): + queue = in_queue if idx == 0 else Queue(maxsize=1) + logger.debug("[%s] Adding in queue for thread '%s'", self._plugin_name, thread) + retval[thread] = queue + logger.debug("[%s] Adding out queue", self._plugin_name) + retval["out"] = Queue(maxsize=1) + logger.debug("[%s] Queues: %s", self._plugin_name, retval) + return retval + + def _get_inbound_iterator(self) -> InboundIterator | InputIterator: + """Obtain the inbound iterator. If this is the first/only plugin in the pipeline, this + will be an InputIterator that splits FrameFaces frame objects into appropriate batches + for the plugin. + + If this is a subsequent plugin, then an InboundIterator will be returned, which takes + already batched data from the previous plugin and re-batches for the current plugin + + Returns + ------- + The iterator to process inbound data for the plugin + """ + retval: InputIterator | InboundIterator + if self._is_first: + retval = InputIterator(list(self._queues.values())[0], + f"{self._plugin_name}", + self.handler.plugin_type, + self.handler.batch_size, + self._threads.error_state) + else: + retval = InboundIterator(list(self._queues.values())[0], + f"{self._plugin_name}", + self.handler.plugin_type, + self.handler.batch_size, + self._threads.error_state) + logger.debug("[%s.in] Got inbound iterator: %s", self._plugin_name, retval) + return retval + + def _put_to_input(self, data: FrameFaces | ExtractBatch | ExtractSignal) -> None: + """Put data to the runner's input queue, monitoring for errors + + Parameters + ---------- + data + The object to put into the runner's in queue + """ + while True: + if self._threads.error_state.has_error: + logger.debug("[%s] Error in worker thread", self._plugin_name) + return + try: + self._queues[list(self._queues)[0]].put(data, timeout=0.2) + break + except QueueFull: + logger.debug("[%s] Waiting on queue", self._plugin_name) + continue + + def put_direct(self, # noqa[C901] + filename: str, + image: npt.NDArray[np.uint8], + detected_faces: list[DetectedFace], + is_aligned: bool = False, + frame_size: tuple[int, int] | None = None) -> ExtractBatch: + """Put an item directly into this runner's plugin and return the result + + Parameters + ---------- + filename + The filename of the frame + image + The loaded frame as UINT8 BGR array + detected_faces + The detected face objects for the frame + is_aligned + ``True`` if the image being passed into the pipeline is an aligned faceswap face. + Default: ``False`` + frame_size + The (height, width) size of the original frame if passing in an aligned image + + Raises + ------ + ValueError + If attempting to put an ExtractBatch object to the first runner in the pipeline or if + providing an aligned image with insufficient data + + Returns + ------- + ExtractBatch + The output from this plugin for the given input + """ + if isinstance(self._inbound_iterator, InputIterator): + raise ValueError("'put_direct' should not be used on the first runner in a " + "pipeline. Use the runner's `put` method") + if self.handler.plugin_type not in ("detect", "align") and not is_aligned: + raise ValueError(f"'{self.handler.plugin_type}' requires aligned input") + if self.handler.plugin_type in ("detect", "align") and is_aligned: + raise ValueError(f"'{self.handler.plugin_type}' requires non-aligned input") + if is_aligned and not frame_size: + raise ValueError("Aligned input must provide the original frame_size") + batch = ExtractBatch(filenames=[filename], images=[image], is_aligned=is_aligned) + batch.bboxes = np.array([[f.left, f.top, f.right, f.bottom] + for f in detected_faces], dtype=np.int32) + batch.frame_ids = np.zeros((batch.bboxes.shape[0], ), dtype=np.int32) + batch.frame_sizes = [frame_size] if frame_size else None + if self.handler.plugin_type not in ("detect", "align"): + landmarks = np.array([f.landmarks_xy for f in detected_faces], dtype=np.float32) + batch.landmarks = landmarks + batch.landmark_type = LandmarkType.from_shape(T.cast(tuple[int, int], + landmarks.shape[1:])) + original_out = self._queues["out"] # Unhook queue from next runner + self._queues["out"] = Queue(maxsize=1) + self._put_to_input(batch) + self._put_to_input(ExtractSignal.FLUSH) + + result: list[ExtractBatch] = [] + while True: + if self._threads.error_state.has_error and current_thread() == main_thread(): + self._threads.error_state.re_raise() + if self._threads.error_state.has_error: + logger.debug("[%s.%s] Thread error detected in worker thread", + current_thread().name, self.__class__.__name__) + break + try: + out = self._queues["out"].get(timeout=0.2) + except QueueEmpty: + continue + if out == ExtractSignal.FLUSH: + break + result.append(out) + + self._queues["out"] = original_out # Re-attach queue to next runner + + retval = result[0] + if len(result) > 1: + for remain in result[1:]: + retval.append(remain) + return retval + + @T.overload + def put(self, + filename: str, + image: npt.NDArray[np.uint8], + detected_faces: list[DetectedFace] | None = None, + source: str | None = None, + is_aligned: bool = False, + frame_metadata: PNGHeaderSourceDict | None = None, + passthrough: T.Literal[False] = False) -> None: ... + + @T.overload + def put(self, + filename: str, + image: npt.NDArray[np.uint8], + detected_faces: list[DetectedFace] | None = None, + source: str | None = None, + is_aligned: bool = False, + frame_metadata: PNGHeaderSourceDict | None = None, + *, + passthrough: T.Literal[True]) -> FrameFaces: ... + + def put(self, + filename: str, + image: npt.NDArray[np.uint8], + detected_faces: list[DetectedFace] | None = None, + source: str | None = None, + is_aligned: bool = False, + frame_metadata: PNGHeaderSourceDict | None = None, + passthrough: bool = False) -> None | FrameFaces: + """Put a frame into the pipeline. + + Note + ---- + When a pipeline is built using the __call__ method, this method will always put items into + the first plugin in the pipeline + + Parameters + ---------- + filename + The filename of the frame + image + The loaded frame as UINT8 BGR array + detected_faces + The detected face objects for the frame. ``None`` if not any. Default: ``None`` + source + The full path to the source folder or video file. Default: ``None`` (Not provided) + is_aligned + ``True`` if the image being passed into the pipeline is an aligned faceswap face. + Default: ``False`` + frame_metadata + If the image is aligned then the original frame metadata can be added here. Some + plugins (eg: mask) require this to be populated for aligned inputs. Default: ``None`` + passthrough + ``True`` if this item is meant to be passed straight through the extraction pipeline + with no caching, for immediate return. Default: ``False`` + + Returns + ------- + If passthrough is ``True`` returns the output FrameFaces object, otherwise ``None`` + """ + item = FrameFaces(filename=filename, + image=image, + source=source, + is_aligned=is_aligned, + frame_metadata=frame_metadata, + passthrough=passthrough) + if detected_faces is not None: + item.detected_faces = detected_faces + self._put_to_input(item) + if passthrough: + return next(_PLUGIN_REGISTER[self.uuid][-1]) + return None + + def put_media(self, media: FrameFaces) -> None | FrameFaces: + """Put a frame into the pipeline that is within a FrameFaces object. + + Note + ---- + When a pipeline is built using the __call__ method, this method will always put items into + the first plugin in the pipeline + + Parameters + ---------- + media + The FrameFaces object to put into the pipeline + + Returns + ------- + If the FrameFaces's passthrough is ``True`` returns the output FrameFaces object, + otherwise ``None`` + """ + self._put_to_input(media) + if media.passthrough: + return next(_PLUGIN_REGISTER[self.uuid][-1]) + return None + + def stop(self) -> None: + """Indicate to the runner that there is no more data to be ingested""" + logger.debug("[%s] Putting EOF to runner", self._plugin_name) + self._put_to_input(ExtractSignal.SHUTDOWN) + logger.debug("[%s] Removing pipeline '%s'", self._plugin_name, self.uuid) + del _PLUGIN_REGISTER[self.uuid] + + def flush(self) -> None: + """Flush all data currently within the pipeline""" + logger.debug("[%s] Putting FLUSH to runner", self._plugin_name) + self._put_to_input(ExtractSignal.FLUSH) + + def _cascade_interfaces(self, input_runner: ExtractRunner | None) -> None: + """On this runner's call method, cascade the public interfaces to be the input runner's + public interfaces, such that calling them from the final plugin in the pipeline actually + interacts with the first plugin in the pipeline. + + Similarly remove the output iterator from the input runner so that attempting to iterate a + runner that is not the final runner in the chain results in a RuntimeError + + Parameters + ---------- + input_runner + The input runner to this runner or ``None`` if this is the first runner in the pipeline + """ + if input_runner is None: + return + setattr(self, "put", input_runner.put) + setattr(self, "put_media", input_runner.put_media) + setattr(self, "stop", input_runner.stop) + setattr(self, "flush", input_runner.flush) + + logger.debug( + "[%s] Set pipeline interfaces to %s", + self.__class__.__name__, + [f"{f.__self__.__class__.__name__}.{f.__func__.__name__}" # type:ignore[union-attr] + for f in (self.put, self.put_media, self.stop, self.flush)] + ) + + del input_runner._output_iterator + input_runner._output_iterator = None # pylint:disable=protected-access + logger.debug("[%s] Removed output iterator from %s", + self.__class__.__name__, input_runner.__class__.__name__) + + def _register_plugin(self, input_runner: ExtractRunner | None = None) -> None: + """Register the plugin into the plugin tracker + + Parameters + ---------- + input_runner + The input plugin that feeds this plugin or ``None`` if data is to be fed through the + runner's `put` method. Default: ``None`` + """ + name = f"{self.__class__.__name__}.{self._plugin_name}" + if input_runner is None: + logger.debug("[%s] Registering new pipeline: '%s'", name, self.uuid) + _PLUGIN_REGISTER[self.uuid] = [self] + return + uid, chain = next((k, v) for k, v in _PLUGIN_REGISTER.items() if input_runner in v) + logger.debug("[%s] Adding to existing pipeline: '%s'", name, uid) + chain.insert(chain.index(input_runner) + 1, self) + + def start(self) -> None: + """Start the threads. Callback for when the profiler has finished executing""" + if self._threads.is_alive(): + logger.warning("Start called on runner '%s' when threads are already active. This is " + "almost definitely not desired", self.__class__.__name__) + return + if self._uuid is None: + raise ValueError(f"Runner '{self.__class__.__name__}' must be called before starting") + + if self.handler.do_compile: + self.handler.init_model() # Need to compile the model in main thread + self._threads.start() + + def __call__(self, input_runner: ExtractRunner | None, profile: bool) -> None: + """Build and start the plugin runner + + Parameters + ---------- + input_runner + The input plugin that feeds this plugin or ``None`` if data is to be fed through the + runner's `put` method. + profile + ``True`` if the runner is to be profiled, indicating that threads will not be started + + Raises + ------ + ValueError + If the input runner has not been called and assigned a UUID or if this runner has + already been called + """ + if input_runner is not None and input_runner._uuid is None: + raise ValueError(f"Input runner '{input_runner.__class__.__name__}' must be called " + f"prior to adding to '{self.__class__.__name__}'") + if self._uuid is not None: + raise ValueError(f"Runner '{self.__class__.__name__}' has already been called") + self._uuid = uuid4().hex + + self._is_first = input_runner is None + self._queues = self._get_queues(input_runner) + + self._inbound_iterator = self._get_inbound_iterator() + self._output_iterator = OutputIterator(self._queues["out"], + f"{self._plugin_name}_out", + self.handler.plugin_type, + self.handler.batch_size, + self._threads.error_state) + self._cascade_interfaces(input_runner) + self._register_plugin(input_runner) + if not profile: + self.start() + + def register_external_error_state(self, state: ErrorState) -> None: + """Register an external error state object. + + If we are not running any threads (specifically, file handler), the pipeline can hang the + calling thread. The error state from the calling thread can be populated here. This can + only be called if no threads have been registered for the runner + + Parameters + ---------- + state + The ErrorState object to register + """ + self._threads.register_external_error_state(state) + + +def get_pipeline(runner: ExtractRunner) -> list[ExtractRunner]: + """Obtain a list of runners in order of input to output of the extraction chain that the given + runner belongs to + + Parameters + ---------- + runner + The initialized runner to obtain the full chain for + + Returns + ------- + The ordered list of runners if the inference chain that the given runner belongs to + """ + retval = next(v for v in _PLUGIN_REGISTER.values() if runner in v) + logger.debug("Obtained plugin chain for runner '%s': %s", runner, retval) + return retval + + +__all__ = get_module_objects(__name__) diff --git a/lib/model/networks/insightface_resnet.py b/lib/model/networks/insightface_resnet.py index c149c9ab8d..0dc4fbe3bb 100644 --- a/lib/model/networks/insightface_resnet.py +++ b/lib/model/networks/insightface_resnet.py @@ -58,7 +58,7 @@ class BasicBlockIR(nn.Module): def __init__(self, in_channels: int, depth: int, stride: int, use_se: bool) -> None: super().__init__() if in_channels == depth: - self.shortcut_layer: nn.MaxPool2d | nn.Sequential = nn.MaxPool2d(1, stride) + self.shortcut_layer: nn.Sequential | nn.MaxPool2d = nn.MaxPool2d(1, stride) else: self.shortcut_layer = nn.Sequential( nn.Conv2d(in_channels, depth, 1, stride=stride, bias=False), @@ -110,7 +110,7 @@ def __init__(self, in_channels: int, depth: int, stride: int, use_se: bool) -> N super().__init__() shrink_channel = depth // 4 if in_channels == depth: - self.shortcut_layer: nn.MaxPool2d | nn.Sequential = nn.MaxPool2d(1, stride) + self.shortcut_layer: nn.Sequential | nn.MaxPool2d = nn.MaxPool2d(1, stride) else: self.shortcut_layer = nn.Sequential( nn.Conv2d(in_channels, depth, 1, stride=stride, bias=False), diff --git a/lib/multithreading.py b/lib/multithreading.py index e20862e643..161a9ffc50 100644 --- a/lib/multithreading.py +++ b/lib/multithreading.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Multithreading/processing utils for faceswap """ +"""Multithreading/processing utils for faceswap""" from __future__ import annotations import logging import typing as T @@ -18,27 +18,26 @@ logger = logging.getLogger(__name__) _ErrorType: T.TypeAlias = tuple[type[BaseException], BaseException, - TracebackType] | tuple[T.Any, T.Any, T.Any] | None + TracebackType] | tuple[T.Any, T.Any, T.Any] _THREAD_NAMES: set[str] = set() -def total_cpus(): - """ Return total number of cpus """ +def total_cpus() -> int: + """Return total number of cpus""" return cpu_count() def _get_name(name: str) -> str: - """ Obtain a unique name for a thread + """Obtain a unique name for a thread Parameters ---------- - name: str + name The requested name Returns ------- - str - The request name with "_#" appended (# being an integer) making the name unique + The request name with "_#" appended (# being an integer) making the name unique """ idx = 0 real_name = name @@ -51,27 +50,75 @@ def _get_name(name: str) -> str: return real_name +class ErrorState: + """An object for tracking error state across threads + + The "set" method should be called from within a thread to set the thread error traceback + + The "check_and_raise" method should be called from the main thread to check for and re-raise + any errors""" + def __init__(self) -> None: + self._lock = threading.Lock() + self.errors: list[_ErrorType] = [] + """list of errors that have been detected within threads""" + + @property + def has_error(self) -> bool: + """Check whether any running FSThread thread has an error. + + Returns + ------- + ``True`` if an FSThread has an error + """ + with self._lock: + return bool(self.errors) + + def set(self, exc_info: _ErrorType) -> None: + """Set the error traceback information to the error state object. Errors are appended to + the error list in the order that they are received + + Parameters + ---------- + The traceback error information to set + """ + with self._lock: + if self.errors: + logger.debug("An error has already been captured and is waiting to be handled.") + logger.debug("Recording error state:", exc_info=exc_info) + self.errors.append(exc_info) + + def re_raise(self) -> None: + """Check if a thread error is stored and re-raise it if so. Should be called from main + thread. Only the first error received is re-raised (in the event of multiple errors)""" + assert self.errors, "No error stored. You must check if :attr:`has_error` first" + logger.debug("Thread error(s) caught: %s", self.errors) + err = self.errors[0] + raise err[1].with_traceback(err[2]) + + def clear(self) -> None: + """Clear any stored errors """ + with self._lock: + self.errors = [] + + class FSThread(threading.Thread): - """ Subclass of thread that passes errors back to parent + """Subclass of thread that passes errors back to parent Parameters ---------- - target: callable object, Optional + target The callable object to be invoked by the run() method. If ``None`` nothing is called. Default: ``None`` - name: str, optional + name The thread name. if ``None`` a unique name is constructed of the form "Thread-N" where N is a small decimal number. Default: ``None`` - args: tuple + args The argument tuple for the target invocation. Default: (). - kwargs: dict + kwargs keyword arguments for the target invocation. Default: {}. """ - _target: Callable - _args: tuple - _kwargs: dict[str, T.Any] - _name: str - + error_state = ErrorState() + """Class attribute to track error state across multiple threads""" def __init__(self, target: Callable | None = None, name: str | None = None, @@ -80,50 +127,58 @@ def __init__(self, *, daemon: bool | None = None) -> None: super().__init__(target=target, name=name, args=args, kwargs=kwargs, daemon=daemon) - self.err: _ErrorType = None + self.target = target + self.args = args + self.kwargs = kwargs = {} if kwargs is None else kwargs def check_and_raise_error(self) -> None: - """ Checks for errors in thread and raises them in caller. + """Checks for errors in thread and raises them in caller. Raises ------ Error Re-raised error from within the thread """ - if not self.err: + if not self.error_state.has_error: return - logger.debug("Thread error caught: %s", self.err) - raise self.err[1].with_traceback(self.err[2]) + self.error_state.re_raise() def run(self) -> None: - """ Runs the target, reraising any errors from within the thread in the caller. """ + """Runs the target, and captures any thread errors for re-raising in the caller. + + Errors are also captured in a class attribute so that threads in any other running + FSThreads can be captured""" try: - if self._target is not None: - self._target(*self._args, **self._kwargs) - except Exception as err: # pylint:disable=broad-except - self.err = sys.exc_info() - logger.debug("Error in thread (%s): %s", self._name, str(err)) + if self.target is not None: + self.target(*self.args, **self.kwargs) + except Exception: # pylint:disable=broad-except + exc_info = sys.exc_info() + self.error_state.set(exc_info) + assert exc_info[0] is not None + logger.critical("Error in thread (%s): %s(%s)", + self.name, exc_info[0].__name__, exc_info[1]) finally: - # Avoid a refcycle if the thread is running a function with + # Avoid a ref-cycle if the thread is running a function with # an argument that has a member that points to the thread. - del self._target, self._args, self._kwargs + del self.target, self.args, self.kwargs + del self._target, self._args, self._kwargs # type:ignore[attr-defined] class MultiThread(): - """ Threading for IO heavy ops. Catches errors in thread and rethrows to parent. + """Threading for IO heavy ops. Catches errors in thread and rethrows to parent. Parameters ---------- - target: callable object + target The callable object to be invoked by the run() method. - args: tuple + args The argument tuple for the target invocation. Default: (). - thread_count: int, optional + thread_count The number of threads to use. Default: 1 - name: str, optional + name The thread name. if ``None`` a unique name is constructed of the form {target.__name__}_N where N is an incrementing integer. Default: ``None`` - kwargs: dict + kwargs keyword arguments for the target invocation. Default: {}. """ def __init__(self, @@ -146,21 +201,25 @@ def __init__(self, @property def has_error(self) -> bool: - """ bool: ``True`` if a thread has errored, otherwise ``False`` """ - return any(thread.err for thread in self._threads) + """``True`` if a thread has errored, otherwise ``False``""" + if not self._threads: + return False + return self._threads[0].error_state.has_error @property def errors(self) -> list[_ErrorType]: - """ list: List of thread error values """ - return [thread.err for thread in self._threads if thread.err] + """list: List of thread error values """ + if not self._threads: + return [] + return self._threads[0].error_state.errors @property def name(self) -> str: - """ :str: The name of the thread """ + """The name of the thread""" return self._name def check_and_raise_error(self) -> None: - """ Checks for errors in thread and raises them in caller. + """Checks for errors in thread and raises them in caller. Raises ------ @@ -175,17 +234,16 @@ def check_and_raise_error(self) -> None: raise error[1].with_traceback(error[2]) def is_alive(self) -> bool: - """ Check if any threads are still alive + """Check if any threads are still alive Returns ------- - bool - ``True`` if any threads are alive. ``False`` if no threads are alive + ``True`` if any threads are alive. ``False`` if no threads are alive """ return any(thread.is_alive() for thread in self._threads) def start(self) -> None: - """ Start all the threads for the given method, args and kwargs """ + """Start all the threads for the given method, args and kwargs """ logger.debug("Starting thread(s): '%s'", self._name) for idx in range(self._thread_count): name = self._name if self._thread_count == 1 else f"{self._name}_{idx}" @@ -201,7 +259,7 @@ def start(self) -> None: logger.debug("Started all threads '%s': %s", self._name, len(self._threads)) def completed(self) -> bool: - """ Check if all threads have completed + """Check if all threads have completed Returns ------- @@ -212,38 +270,37 @@ def completed(self) -> bool: return retval def join(self) -> None: - """ Join the running threads, catching and re-raising any errors + """Join the running threads, catching and re-raising any errors - Clear the list of threads for class instance re-use - """ + Clear the list of threads for class instance re-use""" logger.debug("Joining Threads: '%s'", self._name) for thread in self._threads: - logger.debug("Joining Thread: '%s'", thread._name) # pylint:disable=protected-access + logger.debug("Joining Thread: '%s'", thread.name) # pylint:disable=protected-access thread.join() - if thread.err: + if thread.error_state.has_error: logger.error("Caught exception in thread: '%s'", - thread._name) # pylint:disable=protected-access - raise thread.err[1].with_traceback(thread.err[2]) + thread.name) # pylint:disable=protected-access + thread.error_state.re_raise() del self._threads self._threads = [] logger.debug("Joined all Threads: '%s'", self._name) class BackgroundGenerator(MultiThread): - """ Run a task in the background background and queue data for consumption + """Run a task in the background background and queue data for consumption Parameters ---------- - generator: iterable + generator The generator to run in the background - prefetch, int, optional + prefetch The number of items to pre-fetch from the generator before blocking (see Notes). Default: 1 - name: str, optional + name The thread name. if ``None`` a unique name is constructed of the form {generator.__name__}_N where N is an incrementing integer. Default: ``None`` - args: tuple, Optional + args The argument tuple for generator invocation. Default: ``None``. - kwargs: dict, Optional + kwargs keyword arguments for the generator invocation. Default: ``None``. Notes @@ -270,7 +327,7 @@ def __init__(self, self.start() def _run(self) -> None: - """ Run the :attr:`_generator` and put into the queue until until queue size is reached. + """Run the :attr:`_generator` and put into the queue until until queue size is reached. Raises ------ @@ -286,12 +343,11 @@ def _run(self) -> None: raise def iterator(self) -> Generator: - """ Iterate items out of the queue + """Iterate items out of the queue Yields ------ - Any - The items from the generator + The items from the generator """ while True: next_item = self.queue.get() diff --git a/lib/training/augmentation.py b/lib/training/augmentation.py index 4856be6525..97eae59c64 100644 --- a/lib/training/augmentation.py +++ b/lib/training/augmentation.py @@ -376,7 +376,7 @@ def _random_clahe(self, batch: np.ndarray) -> None: clahes = [cv2.createCLAHE(clipLimit=2.0, tileGridSize=(grid_size, grid_size)) - for grid_size in grid_sizes] + for grid_size in grid_sizes] # type:ignore[attr-defined] for idx, clahe in zip(indices, clahes): batch[idx, :, :, 0] = clahe.apply(batch[idx, :, :, 0], ) diff --git a/lib/training/cache.py b/lib/training/cache.py index e3429a48f9..abd0cee074 100644 --- a/lib/training/cache.py +++ b/lib/training/cache.py @@ -179,10 +179,24 @@ def _get_face_mask(self, filename: str, detected_face: DetectedFace) -> np.ndarr if not self._config.mask_enabled: return None - assert self._config.mask_type is not None - self._check_mask_exists(filename, detected_face) - mask = self._preprocess(detected_face, self._config.mask_type) - retval = self._crop_and_resize(detected_face, mask) + mask_type = self._config.mask_type + assert mask_type is not None + if mask_type in ("components", "extended"): + name = T.cast(T.Literal["face", "face_extended"], + "face_extended" if mask_type == "extended" else "face") + try: + retval = detected_face.get_landmark_mask(name, + self._config.kernel, + self._config.dilation) + except FaceswapError as err: + logger.error(str(err)) + raise FaceswapError(f"'{mask_type}' masks could not be generated due to missing " + f"landmark data. The file that failed was: '{filename}'" + ) from err + else: + self._check_mask_exists(filename, detected_face) + mask = self._preprocess(detected_face, mask_type) + retval = self._crop_and_resize(detected_face, mask) logger.trace("Obtained face mask for: %s %s", # type:ignore[attr-defined] filename, retval.shape) return retval @@ -420,9 +434,9 @@ def _validate_version(self, png_meta: PNGHeaderDict, filename: str) -> None: if (self._extract_version == 1.0 and alignment_version > 1.0) or ( alignment_version == 1.0 and self._extract_version > 1.0): - raise FaceswapError("Mixing legacy and full head extracted facesets is not supported. " - "The following folder contains a mix of extracted face types: " - f"'{os.path.dirname(filename)}'") + raise FaceswapError("Mixing legacy and full head extracted face sets is not " + "supported. The following folder contains a mix of extracted face " + f"types: '{os.path.dirname(filename)}'") self._extract_version = min(alignment_version, self._extract_version) diff --git a/lib/utils.py b/lib/utils.py index 747f6ca02e..91bdbfda60 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -1,5 +1,5 @@ #!/usr/bin python3 -""" Utilities available across all scripts """ +"""Utilities available across all scripts""" # NOTE: Do not import keras/pytorch in this script, as it is accessed before they should be loaded from __future__ import annotations @@ -34,8 +34,8 @@ # Global variables PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) -""" str : Full path to the root faceswap folder """ -IMAGE_EXTENSIONS = [".bmp", ".jpeg", ".jpg", ".png", ".tif", ".tiff"] +"""str : Full path to the root faceswap folder """ +IMAGE_EXTENSIONS = [".bmp", ".exr", ".jpeg", ".jpg", ".png", ".tif", ".tiff"] VIDEO_EXTENSIONS = [".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv", ".ts", ".vob"] ValidBackends = T.Literal["nvidia", "cpu", "apple_silicon", "rocm"] @@ -43,7 +43,7 @@ class _Backend(): # pylint:disable=too-few-public-methods - """ Return the backend from config/.faceswap of from the `FACESWAP_BACKEND` Environment + """Return the backend from config/.faceswap of from the `FACESWAP_BACKEND` Environment Variable. If file doesn't exist and a variable hasn't been set, create the config file. """ @@ -58,25 +58,23 @@ def __init__(self) -> None: @classmethod def _get_config_file(cls) -> str: - """ Obtain the location of the main Faceswap configuration file. + """Obtain the location of the main Faceswap configuration file. Returns ------- - str - The path to the Faceswap configuration file + The path to the Faceswap configuration file """ config_file = os.path.join(PROJECT_ROOT, "config", ".faceswap") return config_file def _get_backend(self) -> ValidBackends: - """ Return the backend from either the `FACESWAP_BACKEND` Environment Variable or from + """Return the backend from either the `FACESWAP_BACKEND` Environment Variable or from the :file:`config/.faceswap` configuration file. If neither of these exist, prompt the user to select a backend. Returns ------- - str - The backend configuration in use by Faceswap + The backend configuration in use by Faceswap """ # Check if environment variable is set, if so use that if "FACESWAP_BACKEND" in os.environ: @@ -106,12 +104,11 @@ def _get_backend(self) -> ValidBackends: return fs_backend def _configure_backend(self) -> ValidBackends: - """ Get user input to select the backend that Faceswap should use. + """Get user input to select the backend that Faceswap should use. Returns ------- - str - The backend configuration in use by Faceswap + The backend configuration in use by Faceswap """ print("First time configuration. Please select the required backend") while True: @@ -131,13 +128,12 @@ def _configure_backend(self) -> ValidBackends: def get_backend() -> ValidBackends: - """ Get the backend that Faceswap is currently configured to use. + """Get the backend that Faceswap is currently configured to use. Returns ------- - str - The backend configuration in use by Faceswap. One of ["cpu", "nvidia", "rocm", - "apple_silicon"] + The backend configuration in use by Faceswap. One of ["cpu", "nvidia", "rocm", + "apple_silicon"] Example ------- @@ -152,11 +148,11 @@ def get_backend() -> ValidBackends: def set_backend(backend: str) -> None: - """ Override the configured backend with the given backend. + """Override the configured backend with the given backend. Parameters ---------- - backend: ["cpu", "nvidia", "rocm", "apple_silicon"] + backend The backend to set faceswap to Example @@ -173,12 +169,11 @@ def set_backend(backend: str) -> None: def get_torch_version() -> tuple[int, int]: - """ Obtain the major. minor version of currently installed PyTorch. + """Obtain the major. minor version of currently installed PyTorch. Returns ------- - tuple[int, int] - A tuple of the form (major, minor) representing the version of PyTorch that is installed + A tuple of the form (major, minor) representing the version of PyTorch that is installed Example ------- @@ -194,12 +189,11 @@ def get_torch_version() -> tuple[int, int]: def get_keras_version() -> tuple[int, int]: - """ Obtain the major. minor version of currently installed Keras. + """Obtain the major. minor version of currently installed Keras. Returns ------- - tuple[int, int] - A tuple of the form (major, minor) representing the version of Keras that is installed + A tuple of the form (major, minor) representing the version of Keras that is installed Example ------- @@ -215,29 +209,28 @@ def get_keras_version() -> tuple[int, int]: def get_folder(path: str, make_folder: bool = True) -> str: - """ Return a path to a folder, creating it if it doesn't exist + """Return a path to a folder, creating it if it doesn't exist Parameters ---------- - path: str + path The path to the folder to obtain - make_folder: bool, optional + make_folder ``True`` if the folder should be created if it does not already exist, ``False`` if the folder should not be created Returns ------- - str or `None` - The path to the requested folder. If `make_folder` is set to ``False`` and the requested - path does not exist, then ``None`` is returned + The path to the requested folder. If `make_folder` is set to ``False`` and the requested path + does not exist, then ``None`` is returned Example ------- >>> from lib.utils import get_folder - >>> get_folder('/tmp/myfolder') - '/tmp/myfolder' + >>> get_folder('/tmp/my_folder') + '/tmp/my_folder' - >>> get_folder('/tmp/myfolder', make_folder=False) + >>> get_folder('/tmp/my_folder', make_folder=False) '' """ logger = logging.getLogger(__name__) @@ -251,7 +244,7 @@ def get_folder(path: str, make_folder: bool = True) -> str: def get_image_paths(directory: str, extension: str | None = None) -> list[str]: - """ Gets the image paths from a given directory. + """Gets the image paths from a given directory. The function searches for files with the specified extension(s) in the given directory, and returns a list of their paths. If no extension is provided, the function will search for files @@ -259,16 +252,15 @@ def get_image_paths(directory: str, extension: str | None = None) -> list[str]: Parameters ---------- - directory: str + directory The directory to search in - extension: str + extension The file extension to search for. If not provided, all image file types will be searched for Returns ------- - list[str] - The list of full paths to the images contained within the given folder + The list of full paths to the images contained within the given folder Example ------- @@ -290,23 +282,22 @@ def get_image_paths(directory: str, extension: str | None = None) -> list[str]: logger.debug("Scanned Folder contains %s files", len(dir_scanned)) logger.trace("Scanned Folder Contents: %s", dir_scanned) # type:ignore[attr-defined] - for chkfile in dir_scanned: - if any(chkfile.name.lower().endswith(ext) for ext in image_extensions): - logger.trace("Adding '%s' to image list", chkfile.path) # type:ignore[attr-defined] - dir_contents.append(chkfile.path) + for chk_file in dir_scanned: + if any(chk_file.name.lower().endswith(ext) for ext in image_extensions): + logger.trace("Adding '%s' to image list", chk_file.path) # type:ignore[attr-defined] + dir_contents.append(chk_file.path) logger.debug("Returning %s images", len(dir_contents)) return dir_contents def get_dpi() -> float | None: - """ Gets the DPI (dots per inch) of the display screen. + """Gets the DPI (dots per inch) of the display screen. Returns ------- - float or ``None`` - The DPI of the display screen or ``None`` if the dpi couldn't be obtained (ie: if the - function is called on a headless system) + The DPI of the display screen or ``None`` if the dpi couldn't be obtained (ie: if the function + is called on a headless system) Example ------- @@ -326,17 +317,16 @@ def get_dpi() -> float | None: def get_module_objects(module: str) -> list[str]: - """ Return a list of all public objects within the given module + """Return a list of all public objects within the given module Parameters ---------- - module : str + module The module to parse for public objects Returns ------- - list[str] - A list of object names that exist within the given module + A list of object names that exist within the given module Example ------- @@ -349,11 +339,11 @@ def get_module_objects(module: str) -> list[str]: def convert_to_secs(*args: int) -> int: - """ Convert time in hours, minutes, and seconds to seconds. + """ Convert time in hours, minutes, and seconds to seconds. Parameters ---------- - *args: int + *args 1, 2 or 3 ints. If 2 ints are supplied, then (`minutes`, `seconds`) is implied. If 3 ints are supplied then (`hours`, `minutes`, `seconds`) is implied. @@ -387,17 +377,16 @@ def convert_to_secs(*args: int) -> int: def full_path_split(path: str) -> list[str]: - """ Split a file path into all of its parts. + """Split a file path into all of its parts. Parameters ---------- - path: str + path The full path to be split Returns ------- - list - The full path split into a separate item for each part + The full path split into a separate item for each part Example ------- @@ -408,34 +397,34 @@ def full_path_split(path: str) -> list[str]: ['relative', 'path', 'to', 'file.txt']] """ logger = logging.getLogger(__name__) - allparts: list[str] = [] + all_parts: list[str] = [] while True: parts = os.path.split(path) if parts[0] == path: # sentinel for absolute paths - allparts.insert(0, parts[0]) + all_parts.insert(0, parts[0]) break if parts[1] == path: # sentinel for relative paths - allparts.insert(0, parts[1]) + all_parts.insert(0, parts[1]) break path = parts[0] - allparts.insert(0, parts[1]) - logger.trace("path: %s, allparts: %s", path, allparts) # type:ignore[attr-defined] + all_parts.insert(0, parts[1]) + logger.trace("path: %s, all_parts: %s", path, all_parts) # type:ignore[attr-defined] # Remove any empty strings which may have got inserted - allparts = [part for part in allparts if part] - return allparts + all_parts = [part for part in all_parts if part] + return all_parts def deprecation_warning(function: str, additional_info: str | None = None) -> None: - """ Log a deprecation warning message. + """Log a deprecation warning message. This function logs a warning message to indicate that the specified function has been deprecated and will be removed in future. An optional additional message can also be included. Parameters ---------- - function: str + function The name of the function that will be deprecated. - additional_info: str, optional + additional_info Any additional information to display with the deprecation message. Default: ``None`` Example @@ -451,24 +440,32 @@ def deprecation_warning(function: str, additional_info: str | None = None) -> No logger.warning(msg) -def handle_deprecated_cliopts(arguments: Namespace) -> Namespace: - """ Handle deprecated command line arguments and update to correct argument. +def handle_deprecated_cli_opts(arguments: Namespace, + additional: dict[str, tuple[str | bool | T.Any, ...]] | None = None + ) -> Namespace: + """Handle deprecated command line arguments and update to correct argument. Deprecated cli opts will be provided in the following format: `"depr___"` Parameters ---------- - arguments: :class:`argpares.Namespace` + arguments The passed in faceswap cli arguments + additional + Additional information in format {deprecated_argument: (additional_text, should_update, + [new_value])} where deprecated_argument is the command line argument, additional_text is + any additional text to display, should_update is whether the deprecated argument should be + replaced with the new argument and new_value is an optional value that can be passed in + that the new argument should be set to. + Default: ``None`` (no additional information) Returns ------- - :class:`argpares.Namespace` - The cli arguments with deprecated values mapped to the correct entry + The cli arguments with deprecated values mapped to the correct entry """ logger = logging.getLogger(__name__) - + additional = {} if additional is None else additional for key, selected in vars(arguments).items(): if not key.startswith("depr_") or key.startswith("depr_") and selected is None: continue # Not a deprecated opt @@ -476,29 +473,48 @@ def handle_deprecated_cliopts(arguments: Namespace) -> Namespace: continue # store-true opt with default value opt, old, new = key.replace("depr_", "").rsplit("_", maxsplit=2) - deprecation_warning(f"Command line option '-{old}'", f"Use '-{new}, --{opt}' instead") + if opt == "removed": + deprecation_warning(f"Command line option '-{old}' ('--{new}')", + "This option no longer performs any action") + continue + + opt_additional = additional.get(old, ("", True)) + add_msg = opt_additional[0] + should_update = opt_additional[1] + assert isinstance(add_msg, str) + assert isinstance(should_update, bool) + value = selected if len(opt_additional) < 3 else opt_additional[2] + + add_msg = f" {add_msg}" if add_msg else "" + msg = f"Use '-{new}, --{opt}' instead{add_msg}" + deprecation_warning(f"Command line option '-{old}'", msg) + + opt = opt.replace("-", "_") exist = getattr(arguments, opt) - if exist == selected: - logger.debug("Keeping existing '%s' value of '%s'", opt, exist) + if not should_update: + logger.debug("Keeping existing '%s' value '%s' from additional dict", opt, exist) + elif exist == value: + logger.debug("Keeping existing '%s' value of %s", opt, repr(exist)) else: - logger.debug("Updating arg '%s' from '%s' to '%s' from deprecated opt", - opt, exist, selected) + log_at_level = logger.info if old in additional else logger.debug + log_at_level("Updating arg '%s' from %s to %s from deprecated option '-%s'", + opt, repr(exist), repr(value), old) + setattr(arguments, opt, value) return arguments def camel_case_split(identifier: str) -> list[str]: - """ Split a camelCase string into a list of its individual parts + """Split a camelCase string into a list of its individual parts Parameters ---------- - identifier: str + identifier The camelCase text to be split Returns ------- - list[str] A list of the individual parts of the camelCase string. References @@ -518,7 +534,7 @@ def camel_case_split(identifier: str) -> list[str]: def safe_shutdown(got_error: bool = False) -> None: - """ Safely shut down the system. + """Safely shut down the system. This function terminates the queue manager and exits the program in a clean and orderly manner. An optional boolean parameter can be used to indicate whether an error occurred during the @@ -526,7 +542,7 @@ def safe_shutdown(got_error: bool = False) -> None: Parameters ---------- - got_error: bool, optional + got_error ``True`` if this function is being called as the result of raised error. Default: ``False`` Example @@ -544,7 +560,7 @@ def safe_shutdown(got_error: bool = False) -> None: class FaceswapError(Exception): - """ Faceswap Error for handling specific errors with useful information. + """Faceswap Error for handling specific errors with useful information. Raises ------ @@ -564,15 +580,15 @@ class FaceswapError(Exception): class GetModel(): - """ Check for models in the cache path. + """Check for models in the cache path. If available, return the path, if not available, get, unzip and install model Parameters ---------- - model_filename: str or list + model_filename The name of the model to be loaded (see notes below) - git_model_id: int + git_model_id The second digit in the github tag that identifies this model. See https://github.com/deepfakes-models/faceswap-models for more information @@ -607,29 +623,29 @@ def __init__(self, model_filename: str | list[str], git_model_id: int) -> None: @property def _model_full_name(self) -> str: - """ str: The full model name from the filename(s). """ + """The full model name from the filename(s).""" common_prefix = os.path.commonprefix(self._model_filename) retval = os.path.splitext(common_prefix)[0] - self.logger.trace(retval) # type:ignore[attr-defined] + self.logger.trace("[GetModel] full name: %s", repr(retval)) # type:ignore[attr-defined] return retval @property def _model_name(self) -> str: - """ str: The model name from the model's full name. """ + """The model name from the model's full name.""" retval = self._model_full_name[:self._model_full_name.rfind("_")] - self.logger.trace(retval) # type:ignore[attr-defined] + self.logger.trace("[GetModel] name: %s", repr(retval)) # type:ignore[attr-defined] return retval @property def _model_version(self) -> int: - """ int: The model's version number from the model full name. """ + """The model's version number from the model full name.""" retval = int(self._model_full_name[self._model_full_name.rfind("_") + 2:]) - self.logger.trace(retval) # type:ignore[attr-defined] + self.logger.trace("[GetModel] id: %s", repr(retval)) # type:ignore[attr-defined] return retval @property def model_path(self) -> str | list[str]: - """ str or list[str]: The model path(s) in the cache folder. + """The model path(s) in the cache folder. Example ------- @@ -640,54 +656,54 @@ def model_path(self) -> str | list[str]: """ paths = [os.path.join(self._cache_dir, fname) for fname in self._model_filename] retval: str | list[str] = paths[0] if len(paths) == 1 else paths - self.logger.trace(retval) # type:ignore[attr-defined] + self.logger.trace("[GetModel] path: %s", repr(retval)) # type:ignore[attr-defined] return retval @property def _model_zip_path(self) -> str: - """ str: The full path to downloaded zip file. """ + """The full path to downloaded zip file.""" retval = os.path.join(self._cache_dir, f"{self._model_full_name}.zip") - self.logger.trace(retval) # type:ignore[attr-defined] + self.logger.trace("[GetModel] zip path: %s", repr(retval)) # type:ignore[attr-defined] return retval @property def _model_exists(self) -> bool: - """ bool: ``True`` if the model exists in the cache folder otherwise ``False``. """ + """``True`` if the model exists in the cache folder otherwise ``False``.""" if isinstance(self.model_path, list): retval = all(os.path.exists(pth) for pth in self.model_path) else: retval = os.path.exists(self.model_path) - self.logger.trace(retval) # type:ignore[attr-defined] + self.logger.trace("[GetModel] exists: %s", repr(retval)) # type:ignore[attr-defined] return retval @property def _url_download(self) -> str: - """ strL Base download URL for models. """ + """Base download URL for models.""" tag = f"v{self._git_model_id}.{self._model_version}" retval = f"{self._url_base}/{tag}/{self._model_full_name}.zip" - self.logger.trace("Download url: %s", retval) # type:ignore[attr-defined] + self.logger.trace("[GetModel] Download url: %s", repr(retval)) # type:ignore[attr-defined] return retval @property def _url_partial_size(self) -> int: - """ int: How many bytes have already been downloaded. """ + """How many bytes have already been downloaded.""" zip_file = self._model_zip_path retval = os.path.getsize(zip_file) if os.path.exists(zip_file) else 0 - self.logger.trace(retval) # type:ignore[attr-defined] + self.logger.trace("[GetModel] Partial size: %s", retval) # type:ignore[attr-defined] return retval def _get(self) -> None: - """ Check the model exists, if not, download the model, unzip it and place it in the - model's cache folder. """ + """Check the model exists, if not, download the model, unzip it and place it in the + model's cache folder.""" if self._model_exists: - self.logger.debug("Model exists: %s", self.model_path) + self.logger.debug("[GetModel] Model exists: %s", repr(self.model_path)) return self._download_model() self._unzip_model() os.remove(self._model_zip_path) def _download_model(self) -> None: - """ Download the model zip from github to the cache folder. """ + """Download the model zip from github to the cache folder.""" self.logger.info("Downloading model: '%s' from: %s", self._model_name, self._url_download) for attempt in range(self._retries): try: @@ -696,8 +712,8 @@ def _download_model(self) -> None: if downloaded_size != 0: req.add_header("Range", f"bytes={downloaded_size}-") with request.urlopen(req, timeout=10) as response: - self.logger.debug("header info: {%s}", response.info()) - self.logger.debug("Return Code: %s", response.getcode()) + self.logger.debug("[GetModel] header info: {%s}", response.info()) + self.logger.debug("[GetModel] Return Code: %s", response.getcode()) self._write_zipfile(response, downloaded_size) break except (socket_error, socket_timeout, @@ -715,13 +731,13 @@ def _download_model(self) -> None: sys.exit(1) def _write_zipfile(self, response: HTTPResponse, downloaded_size: int) -> None: - """ Write the model zip file to disk. + """Write the model zip file to disk. Parameters ---------- - response: :class:`http.client.HTTPResponse` + response The response from the model download task - downloaded_size: int + downloaded_size The amount of bytes downloaded so far """ content_length = response.getheader("content-length") @@ -733,23 +749,23 @@ def _write_zipfile(self, response: HTTPResponse, downloaded_size: int) -> None: write_type = "wb" if downloaded_size == 0 else "ab" assert tqdm is not None with open(self._model_zip_path, write_type) as out_file: - pbar = tqdm(desc="Downloading", - unit="B", - total=length, - unit_scale=True, - unit_divisor=1024) + p_bar = tqdm(desc="Downloading", + unit="B", + total=length, + unit_scale=True, + unit_divisor=1024) if downloaded_size != 0: - pbar.update(downloaded_size) + p_bar.update(downloaded_size) while True: buffer = response.read(self._chunk_size) if not buffer: break - pbar.update(len(buffer)) + p_bar.update(len(buffer)) out_file.write(buffer) - pbar.close() + p_bar.close() def _unzip_model(self) -> None: - """ Unzip the model file to the cache folder """ + """Unzip the model file to the cache folder""" self.logger.info("Extracting: '%s'", self._model_name) try: with zipfile.ZipFile(self._model_zip_path, "r") as zip_file: @@ -759,46 +775,47 @@ def _unzip_model(self) -> None: sys.exit(1) def _write_model(self, zip_file: zipfile.ZipFile) -> None: - """ Extract files from zip file and write, with progress bar. + """Extract files from zip file and write, with progress bar. Parameters ---------- - zip_file: :class:`zipfile.ZipFile` + zip_file The downloaded model zip file """ length = sum(f.file_size for f in zip_file.infolist()) - fnames = zip_file.namelist() - self.logger.debug("Zipfile: Filenames: %s, Total Size: %s", fnames, length) + f_names = zip_file.namelist() + self.logger.debug("[GetModel] Zipfile: Filenames: %s, Total Size: %s", f_names, length) assert tqdm is not None - pbar = tqdm(desc="Decompressing", - unit="B", - total=length, - unit_scale=True, - unit_divisor=1024) - for fname in fnames: + p_bar = tqdm(desc="Decompressing", + unit="B", + total=length, + unit_scale=True, + unit_divisor=1024) + for fname in f_names: out_fname = os.path.join(self._cache_dir, fname) - self.logger.debug("Extracting from: '%s' to '%s'", self._model_zip_path, out_fname) + self.logger.debug("[GetModel] Extracting from: '%s' to '%s'", + self._model_zip_path, out_fname) zipped = zip_file.open(fname) with open(out_fname, "wb") as out_file: while True: buffer = zipped.read(self._chunk_size) if not buffer: break - pbar.update(len(buffer)) + p_bar.update(len(buffer)) out_file.write(buffer) - pbar.close() + p_bar.close() class DebugTimes(): - """ A simple tool to help debug timings. + """A simple tool to help debug timings. Parameters ---------- - min: bool, Optional + min Display minimum time taken in summary stats. Default: ``True`` - mean: bool, Optional + mean Display mean time taken in summary stats. Default: ``True`` - max: bool, Optional + max Display maximum time taken in summary stats. Default: ``True`` Example @@ -822,13 +839,13 @@ def __init__(self, self._display = {"min": show_min, "mean": show_mean, "max": show_max} def step_start(self, name: str, record: bool = True) -> None: - """ Start the timer for the given step name. + """Start the timer for the given step name. Parameters ---------- - name: str + name The name of the step to start the timer for - record: bool, optional + record ``True`` to record the step time, ``False`` to not record it. Used for when you have conditional code to time, but do not want to insert if/else statements in the code. Default: `True` @@ -847,13 +864,13 @@ def step_start(self, name: str, record: bool = True) -> None: self._steps[storename] = time() def step_end(self, name: str, record: bool = True) -> None: - """ Stop the timer and record elapsed time for the given step name. + """Stop the timer and record elapsed time for the given step name. Parameters ---------- - name: str + name The name of the step to end the timer for - record: bool, optional + record ``True`` to record the step time, ``False`` to not record it. Used for when you have conditional code to time, but do not want to insert if/else statements in the code. Default: `True` @@ -873,30 +890,29 @@ def step_end(self, name: str, record: bool = True) -> None: @classmethod def _format_column(cls, text: str, width: int) -> str: - """ Pad the given text to be aligned to the given width. + """Pad the given text to be aligned to the given width. Parameters ---------- - text: str + text The text to be formatted - width: int + width The size of the column to insert the text into Returns ------- - str - The text with the correct amount of padding applied + The text with the correct amount of padding applied """ return f"{text}{' ' * (width - len(text))}" def summary(self, decimal_places: int = 6, interval: int = 1) -> None: - """ Print a summary of step times. + """Print a summary of step times. Parameters ---------- - decimal_places: int, optional + decimal_places The number of decimal places to display the summary elapsed times to. Default: 6 - interval: int, optional + interval How many times summary must be called before printing to console. Default: 1 Example diff --git a/locales/es/LC_MESSAGES/lib.cli.args.mo b/locales/es/LC_MESSAGES/lib.cli.args.mo index 914c7bf3b883d77c89021cb76e5b86b663df1885..31f21626890983d5eced5f5e79d7c1d9c4438a32 100644 GIT binary patch delta 138 zcmX@WyPtQ0NvJmi1A{*!1A`uro(-hkfb=OKeI7^)GBGej0ND?Lv>=dg#lpZa1xWh< zX?Gy~5J;Nr17lr7GX+Bn TD+9~PcFaE*EjC-Sv@-z!vq2Zx delta 140 zcmdnbdw_R>NvIbC1A{*!1A`uro&}`cfb>ZqeI7^)FflMh0of0Lv>=dg&BDMi1xWh> zX?Gy~2uPa%X)RU;hDsnE52T*~>6;Tfl{tZOV8A|c>&ngXjF%aCO>_;6b&V_(3@xk- TO(xqj|6n%JGumvy(#`|`;*A(M diff --git a/locales/es/LC_MESSAGES/lib.cli.args.po b/locales/es/LC_MESSAGES/lib.cli.args.po index d6b64bef41..7cbcb2e912 100755 --- a/locales/es/LC_MESSAGES/lib.cli.args.po +++ b/locales/es/LC_MESSAGES/lib.cli.args.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 18:06+0000\n" -"PO-Revision-Date: 2024-03-28 18:14+0000\n" +"POT-Creation-Date: 2026-03-13 15:17+0000\n" +"PO-Revision-Date: 2026-03-16 18:09+0000\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es\n" @@ -16,14 +16,14 @@ 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.4.2\n" +"X-Generator: Poedit 3.8\n" -#: lib/cli/args.py:188 lib/cli/args.py:199 lib/cli/args.py:208 -#: lib/cli/args.py:219 +#: lib/cli/args.py:194 lib/cli/args.py:206 lib/cli/args.py:215 +#: lib/cli/args.py:226 msgid "Global Options" msgstr "Opciones Globales" -#: lib/cli/args.py:190 +#: lib/cli/args.py:196 msgid "" "R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " "to any GPU(s) that you do not wish to be made available to Faceswap. " @@ -35,12 +35,12 @@ msgstr "" "con Faceswap. Marcar todas las GPUs forzará a Faceswap a usar sólo la CPU,\n" "L|{}" -#: lib/cli/args.py:201 +#: lib/cli/args.py:208 msgid "" -"Optionally overide the saved config with the path to a custom config file." +"Optionally override the saved config with the path to a custom config file." msgstr "Usar un fichero alternativo de configuración, almacenado en esta ruta." -#: lib/cli/args.py:210 +#: lib/cli/args.py:217 msgid "" "Log level. Stick with INFO or VERBOSE unless you need to file an error " "report. Be careful with TRACE as it will generate a lot of data" @@ -48,12 +48,12 @@ msgstr "" "Nivel de registro. Dejarlo en INFO o VERBOSE, a menos que necesite informar " "de un error. Tenga en cuenta que TRACE generará muchísima información" -#: lib/cli/args.py:220 +#: lib/cli/args.py:227 msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "" "Ruta para almacenar el fichero de registro. Dejarlo en blanco para " "almacenarlo en la carpeta pde instalación de faceswap" -#: lib/cli/args.py:319 +#: lib/cli/args.py:311 msgid "Output to Shell console instead of GUI console" msgstr "Salida a la consola Shell en lugar de la consola GUI" diff --git a/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.mo b/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.mo index 5ccd0c2f2106fbffadd06ce06cbd8049936ce9e9..f27d8cf7252a3655a8a6f3a93676c75b297cbc23 100644 GIT binary patch delta 6826 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 delta 2576 zcmaKsYitx%6vxl@0ewJ$Qi1Zk0=8Xfmv*Uu6?wI#rM3lH3qgo-J9~FKWp<{SM;{c} zKnNl+!DK*%ib{#bn36z7`QVF?BF11uKNvzH#4nnd=m%m<&>-=DXO@=|Cwt~M_ug|K z=bZoD8^<#C-3p~{6lNVIJW<$8*hq+|7F?c*7oNr}A_slzK>X-@Hpak<;8`#7(;T9&;dpQ^(MuTbm`8LI=a&uBahGHfaPz zHpv5@hu^2*DR3(4fGW@@&jbr!06#`MYelf(dGHnZ6|5w>hW?>dh#GWO2O0krOudDU zzwvPxd=vSZgbC%fM93Dcs3Y2k_H&?2;njMS1q?MHdW^r`7#uXQj_6gizXv}6<4r`n z!AZ{&t%Cny@CESOW}=ngBBWyzXsk#6(MOjy5TWcec_Yzna1K}qq0DSUs^kO|egaZ` zb{p!7@e3~!9mn{~+aX+(YJN91g*rEDh#r2Q_Yj?d->5~jAGB>EEJc5U?O?MLXz3jX z^+ynN8y%;>dgSK^Z1@X!2fWlDsQHVXL|qs^G#HfRcMy`Gzd*RreAq0Q45AEH0)q~H zVFP|PXMl1}6>KI9@}b$--@hKy!9_3zdmOe9CTlOBoB&jsW*%4!Ljnleq{*V7RH| z-O1_;Wz}(;yA3%j7aMx1%}hsUqTe>TVylMERGsy3lWUsLB5bp&X@LV*x@5oY4rWhJ zF-u4vTWABU%+Lknaa)+o?h?$v%M3FNg6QEo!&;2n{;0*1g4t?PL|BI<;*Q4DINQ{= zx`nBh^x>?>6iL-dMySr@eN5weEXmCs7Q@N><((eK_D<&P4SARI8bUkbToD$F30o+( zh_Ozg8GUfXYL>bayEF?uVRx1LQK|hx!!C+xSeCz7Oyvnf=NdEOe!J6f^q8tASf|k+ zVeJBEHdrN#8zwyB-e=>FW_VjChC*Ix;nNvzqG+2Nt12yEanne$O$`leah9liHm}Hi zyDB$S?!I5O&|5zBShm+Tt1LH_*Srf^7AE$qVe67ThH0}U%UF-0>NbnkJs7IV{H&=7 zU550)p@OYA>$Z@NxT+z*GR4s2YQiyjrzT{Wt2N9+fovO*SYc}0RVTCK?klRf^%XZgK9pR#v&E*Zh%H9f{_7Bem~_vLR^0a<4YjrCJRpx`pCkuEx`{R@6}s5>M+I z3W>0K--nx{Cz--yP#u&lPozPA1{8&}h=*pQTxrqA3|8jooZ-Sim3W8c(wdXtCX3Yw z)`c{gngQv+6WU6uRuU~$pyUxYdfZkuXj7if*FDrLEQMR#D{j0I@;+=zhEfXd)?HEK zbQVN{wM8fI4$fuatTs>b(X&{w+0YZJ?ZnFc2uDvtZ7?rB{HsW?1l z$`MUXsy2@qCQ@SAQbIU<5oTBVe**BZpN{+ zFU|%KXA)uS!u@7o-Z+`Nd)MLSz#=Bo93((S?pU0aT2lU<1HPzAy}>PgW4xj5nfW>O zNSNq0Om}!kb=Up-qWbp*8J0K3(NS%AWm=G8nI~q7xG;sTsGJ!Le`o)V4<}T0fk>r0 zJh-WJsl5hkYG2AW(+}T+;W=_Oe=)`6;!kq+K1XkJG*0#U4kX6> E1DITp!2kdN diff --git a/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.po b/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.po index 4b2e299256..e43fbba981 100755 --- a/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.po +++ b/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-12 11:56+0100\n" -"PO-Revision-Date: 2024-04-12 12:02+0100\n" +"POT-Creation-Date: 2026-03-16 17:40+0000\n" +"PO-Revision-Date: 2026-03-20 22:02+0000\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es\n" @@ -16,15 +16,16 @@ 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.4.2\n" +"X-Generator: Poedit 3.8\n" -#: lib/cli/args_extract_convert.py:46 lib/cli/args_extract_convert.py:56 -#: lib/cli/args_extract_convert.py:64 lib/cli/args_extract_convert.py:122 -#: lib/cli/args_extract_convert.py:483 lib/cli/args_extract_convert.py:492 +#: lib/cli/args_extract_convert.py:47 lib/cli/args_extract_convert.py:58 +#: lib/cli/args_extract_convert.py:108 lib/cli/args_extract_convert.py:116 +#: lib/cli/args_extract_convert.py:488 lib/cli/args_extract_convert.py:496 +#: lib/cli/args_extract_convert.py:505 msgid "Data" msgstr "Datos" -#: lib/cli/args_extract_convert.py:48 +#: lib/cli/args_extract_convert.py:49 msgid "" "Input directory or video. Either a directory containing the image files you " "wish to process or path to a video file. NB: This should be the source video/" @@ -34,12 +35,7 @@ msgstr "" "imagen que desea procesar o la ruta a un archivo de vídeo. NB: Debe ser el " "vídeo/los fotogramas de origen, NO las caras de origen." -#: lib/cli/args_extract_convert.py:57 -msgid "Output directory. This is where the converted files will be saved." -msgstr "" -"Directorio de salida. Aquí es donde se guardarán los archivos convertidos." - -#: lib/cli/args_extract_convert.py:66 +#: lib/cli/args_extract_convert.py:60 msgid "" "Optional path to an alignments file. Leave blank if the alignments file is " "at the default location." @@ -47,7 +43,7 @@ msgstr "" "Ruta opcional a un archivo de alineaciones. Dejar en blanco si el archivo de " "alineaciones está en la ubicación por defecto." -#: lib/cli/args_extract_convert.py:97 +#: lib/cli/args_extract_convert.py:83 msgid "" "Extract faces from image or video sources.\n" "Extraction plugins can be configured in the 'Settings' Menu" @@ -55,38 +51,46 @@ msgstr "" "Extrae caras de fuentes de imagen o video.\n" "Los plugins de extracción pueden ser configuradas en el menú de 'Ajustes'" -#: lib/cli/args_extract_convert.py:124 +#: lib/cli/args_extract_convert.py:109 +msgid "" +"Output directory. Location to save extracted faces. If not provided then " +"don't save faces and just create an alignments file" +msgstr "" +"Directorio de salida. Ubicación donde se guardarán las caras extraídas. Si " +"no se especifica, no se guardarán las caras y solo se creará un archivo de " +"alineaciones." + +#: lib/cli/args_extract_convert.py:118 msgid "" -"R|If selected then the input_dir should be a parent folder containing " -"multiple videos and/or folders of images you wish to extract from. The faces " -"will be output to separate sub-folders in the output_dir." +"If selected then the input_dir should be a parent folder containing multiple " +"videos and/or folders of images you wish to extract from. The faces will be " +"output to separate sub-folders in the output_dir." msgstr "" "Si se selecciona, input_dir debe ser una carpeta principal que contenga " "varios videos y/o carpetas de imágenes de las que desea extraer. Las caras " "se enviarán a subcarpetas separadas en output_dir." -#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:152 -#: lib/cli/args_extract_convert.py:167 lib/cli/args_extract_convert.py:206 -#: lib/cli/args_extract_convert.py:224 lib/cli/args_extract_convert.py:237 -#: lib/cli/args_extract_convert.py:247 lib/cli/args_extract_convert.py:257 -#: lib/cli/args_extract_convert.py:503 lib/cli/args_extract_convert.py:529 -#: lib/cli/args_extract_convert.py:568 -msgid "Plugins" -msgstr "Extensiones" +#: lib/cli/args_extract_convert.py:127 lib/cli/args_extract_convert.py:215 +#: lib/cli/args_extract_convert.py:228 lib/cli/args_extract_convert.py:238 +msgid "Detect" +msgstr "Detectar" -#: lib/cli/args_extract_convert.py:135 +#: lib/cli/args_extract_convert.py:129 msgid "" "R|Detector to use. Some of these have configurable settings in '/config/" "extract.ini' or 'Settings > Configure Extract 'Plugins':\n" "L|cv2-dnn: A CPU only extractor which is the least reliable and least " -"resource intensive. Use this if not using a GPU and time is important.\n" -"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " -"than other GPU detectors but can often return more false positives.\n" -"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " +"resource intensive. Use this only as a last resort. Both MTCNN and " +"RetinaFace have variants that will perform better on CPU.\n" +"L|mtcnn: Average detector. Fast on CPU, faster on GPU. Uses fewer resources " +"than other GPU detectors but can often return more false positives or misses " +"faces.\n" +"L|retinaface: Good detector. Faster and lighter than S3FD but of similar " +"quality. A ResNet and MobileNet version are available (configurable in " +"Detect settings). The MobileNet version is light enough to run on CPU.\n" +"L|s3fd: Good detector. Slow on CPU, faster on GPU. Can detect more faces and " "fewer false positives than other GPU detectors, but is a lot more resource " -"intensive.\n" -"L|external: Import a face detection bounding box from a json file. " -"(configurable in Detect settings)" +"intensive." msgstr "" "R|Detector de caras a usar. Algunos tienen ajustes configurables en '/config/" "extract.ini' o 'Ajustes > Configurar Extensiones de Extracción:\n" @@ -98,27 +102,37 @@ msgstr "" "L|s3fd: El mejor detector. Lento en la CPU, y más rápido en la GPU. Puede " "detectar más caras y tiene menos falsos positivos que otros detectores " "basados en GPU, pero uso muchos más recursos.\n" -"L|external: importe un cuadro de detección de detección de cara desde un " -"archivo JSON. (configurable en la configuración de detección)" +"L|retinaface: Buen detector. Más rápido y ligero que el S3FD, pero de " +"calidad similar. Hay versiones para ResNet y MobileNet disponibles " +"(configurables en los ajustes de detección). La versión para MobileNet es lo " +"suficientemente ligera como para funcionar con la CPU." + +#: lib/cli/args_extract_convert.py:149 lib/cli/args_extract_convert.py:251 +#: lib/cli/args_extract_convert.py:269 lib/cli/args_extract_convert.py:282 +#: lib/cli/args_extract_convert.py:292 +msgid "Align" +msgstr "Alinear" -#: lib/cli/args_extract_convert.py:154 +#: lib/cli/args_extract_convert.py:151 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " "but less accurate. Only use this if not using a GPU and time is important.\n" -"L|fan: Best aligner. Fast on GPU, slow on CPU.\n" -"L|external: Import 68 point 2D landmarks or an aligned bounding box from a " -"json file. (configurable in Align settings)" +"L|fan: Best aligner. Fast on GPU, slow on CPU." msgstr "" "R|Alineador a usar.\n" "L|cv2-dnn: Detector que usa sólo la CPU. Más rápido, usa menos recursos, " "pero es menos preciso. Elegir este si necesita rapidez y no usar la GPU.\n" -"L|fan: El mejor alineador. Rápido en la GPU, y lento en la CPU.\n" -"L|external: importar 68 puntos 2D Modos de referencia o un cuadro " -"delimitador alineado de un archivo JSON. (configurable en la configuración " -"alineada)" +"L|fan: Buen alineador. Rápido en la GPU, y lento en la CPU.\n" +"L|hrnet: El mejor alineador. Más rápido y con mejor rendimiento que FAN. " +"Entrenado con un conjunto personalizado de caras completamente rotadas. " +"Rápido en GPU, lento en CPU." + +#: lib/cli/args_extract_convert.py:161 +msgid "Mask" +msgstr "Mascarilla" -#: lib/cli/args_extract_convert.py:169 +#: lib/cli/args_extract_convert.py:163 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -188,7 +202,63 @@ msgstr "" "referencia y la máscara se extiende hacia arriba en la frente.\n" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -#: lib/cli/args_extract_convert.py:208 +#: lib/cli/args_extract_convert.py:199 lib/cli/args_extract_convert.py:304 +#: lib/cli/args_extract_convert.py:317 lib/cli/args_extract_convert.py:331 +msgid "Identity" +msgstr "Identidad" + +#: lib/cli/args_extract_convert.py:201 +msgid "" +"R|Obtain and store face identity encodings. Slows down extract a little but " +"will save time if using 'sort by face'. Required for face filtering.\n" +"L|t-face: An InsightFace ResNet based model with a lighter and heavier " +"variant (configurable in settings).\n" +"L|vggface2: An older and lighter, but fairly reliable plugin based on the " +"VGG Network." +msgstr "" +"R|Obtiene y almacena las codificaciones de identidad facial. Ralentiza un " +"poco la extracción, pero ahorra tiempo si se usa la opción \"ordenar por " +"rostro\". Necesario para el filtrado facial.\n" +"L|t-face: Un modelo InsightFace basado en ResNet con una variante más ligera " +"y otra más pesada (configurable en los ajustes).\n" +"L|vggface2: Un complemento más antiguo y ligero, pero bastante fiable, " +"basado en la red VGG." + +#: lib/cli/args_extract_convert.py:217 +msgid "" +"Filters out detections below this percentage of the shortest side of the " +"frame along the face detection box's longest edge. (eg: a value of 10 will " +"filter out faces smaller than 72px from a 720p image). 0 for disabled." +msgstr "" +"Filtra las detecciones por debajo de este porcentaje del lado más corto del " +"marco a lo largo del borde más largo del cuadro de detección de rostros. " +"(Por ejemplo: un valor de 10 filtrará los rostros de menos de 72 píxeles en " +"una imagen de 720p). 0 para deshabilitado." + +#: lib/cli/args_extract_convert.py:230 +msgid "" +"Filters out detections above this percentage of the shortest side of the " +"frame along the face detection box's longest edge. (eg: a value of 200 will " +"filter out faces larger than 1440px from a 720p image). 0 for disabled." +msgstr "" +"Filtra las detecciones que superen este porcentaje del lado más corto del " +"marco a lo largo del borde más largo del cuadro de detección de rostros. " +"(Por ejemplo: un valor de 200 filtrará los rostros de más de 1440 píxeles en " +"una imagen de 720p). 0 para deshabilitado." + +#: lib/cli/args_extract_convert.py:240 +msgid "" +"If a face isn't found, rotate the images to try to find a face. Can find " +"more faces at the cost of extraction speed. Pass in a single number to use " +"increments of that size up to 360, or pass in a list of numbers to enumerate " +"exactly what angles to check." +msgstr "" +"Si no se encuentra una cara, gira las imágenes para intentar encontrar una " +"cara. Puede encontrar más caras a costa de la velocidad de extracción. Pase " +"un solo número para usar incrementos de ese tamaño hasta 360, o pase una " +"lista de números para enumerar exactamente qué ángulos comprobar." + +#: lib/cli/args_extract_convert.py:253 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -211,7 +281,7 @@ msgstr "" "L|hist: Iguala los histogramas de los canales RGB.\n" "L|mean: Normalizar los colores de la cara a la media." -#: lib/cli/args_extract_convert.py:226 +#: lib/cli/args_extract_convert.py:271 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -228,7 +298,7 @@ msgstr "" "más veces se vuelva a introducir la cara en el alineador, menos " "microfluctuaciones se producirán, pero la extracción será más larga." -#: lib/cli/args_extract_convert.py:239 +#: lib/cli/args_extract_convert.py:284 msgid "" "Re-feed the initially found aligned face through the aligner. Can help " "produce better alignments for faces that are rotated beyond 45 degrees in " @@ -239,44 +309,17 @@ msgstr "" "se giran más de 45 grados en el marco o se encuentran en ángulos extremos. " "Ralentiza la extracción." -#: lib/cli/args_extract_convert.py:249 -msgid "" -"If a face isn't found, rotate the images to try to find a face. Can find " -"more faces at the cost of extraction speed. Pass in a single number to use " -"increments of that size up to 360, or pass in a list of numbers to enumerate " -"exactly what angles to check." -msgstr "" -"Si no se encuentra una cara, gira las imágenes para intentar encontrar una " -"cara. Puede encontrar más caras a costa de la velocidad de extracción. Pase " -"un solo número para usar incrementos de ese tamaño hasta 360, o pase una " -"lista de números para enumerar exactamente qué ángulos comprobar." - -#: lib/cli/args_extract_convert.py:259 -msgid "" -"Obtain and store face identity encodings from VGGFace2. Slows down extract a " -"little, but will save time if using 'sort by face'" -msgstr "" -"Obtenga y almacene codificaciones de identidad facial de VGGFace2. Ralentiza " -"un poco la extracción, pero ahorrará tiempo si usa 'sort by face'" - -#: lib/cli/args_extract_convert.py:269 lib/cli/args_extract_convert.py:280 -#: lib/cli/args_extract_convert.py:293 lib/cli/args_extract_convert.py:307 -#: lib/cli/args_extract_convert.py:614 lib/cli/args_extract_convert.py:623 -#: lib/cli/args_extract_convert.py:638 lib/cli/args_extract_convert.py:651 -#: lib/cli/args_extract_convert.py:665 -msgid "Face Processing" -msgstr "Proceso de Caras" - -#: lib/cli/args_extract_convert.py:271 +#: lib/cli/args_extract_convert.py:294 msgid "" -"Filters out faces detected below this size. Length, in pixels across the " -"diagonal of the bounding box. Set to 0 for off" +"Enable aligner filters. This allows the filtering out of faces based on " +"certain statistics and characteristics. Configurable in extract settings. " +"Slows down extraction." msgstr "" -"Filtra las caras detectadas por debajo de este tamaño. Longitud, en píxeles " -"a lo largo de la diagonal del cuadro delimitador. Establecer a 0 para " -"desactivar" +"Habilitar filtros de alineación. Esto permite filtrar rostros según ciertas " +"estadísticas y características. Se puede configurar en los ajustes de " +"extracción. Ralentiza la extracción." -#: lib/cli/args_extract_convert.py:282 +#: lib/cli/args_extract_convert.py:306 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -289,7 +332,7 @@ msgstr "" "contenga las imágenes requeridas o múltiples archivos de imágenes, separados " "por espacios." -#: lib/cli/args_extract_convert.py:295 +#: lib/cli/args_extract_convert.py:319 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -302,7 +345,7 @@ msgstr "" "contenga las imágenes requeridas o múltiples archivos de imágenes, separados " "por espacios." -#: lib/cli/args_extract_convert.py:309 +#: lib/cli/args_extract_convert.py:333 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." @@ -310,12 +353,13 @@ msgstr "" "Para usar con los archivos nfilter/filter opcionales. Umbral para el " "reconocimiento facial positivo. Los valores más altos son más estrictos." -#: lib/cli/args_extract_convert.py:318 lib/cli/args_extract_convert.py:331 -#: lib/cli/args_extract_convert.py:344 lib/cli/args_extract_convert.py:356 +#: lib/cli/args_extract_convert.py:342 lib/cli/args_extract_convert.py:355 +#: lib/cli/args_extract_convert.py:368 lib/cli/args_extract_convert.py:387 +#: lib/cli/args_extract_convert.py:399 msgid "output" msgstr "salida" -#: lib/cli/args_extract_convert.py:320 +#: lib/cli/args_extract_convert.py:344 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -325,7 +369,7 @@ msgstr "" "pretende entrenar admite el tamaño deseado. Esto sólo tendrá que ser " "cambiado para los modelos de alta resolución." -#: lib/cli/args_extract_convert.py:333 +#: lib/cli/args_extract_convert.py:357 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -335,7 +379,31 @@ msgstr "" "extraer las caras. Por ejemplo, un valor de 1 extraerá las caras de cada " "fotograma, un valor de 10 extraerá las caras de cada 10 fotogramas." -#: lib/cli/args_extract_convert.py:346 +#: lib/cli/args_extract_convert.py:370 +msgid "" +"Only output faces that have been resized by this percent or more to meet the " +"specified extract size (`-z`, `--size`). Useful for excluding low-res images " +"from a training set. Set to 0 to output all faces. This only impacts faces " +"that are output to disk. All detected faces will still be saved to the " +"alignments file regardless of what is set here. Eg: For an extract size of " +"512px, A setting of 50 will only output faces that have been resized from " +"256px or above. Setting to 100 will only output faces that have been resized " +"from 512px or above. A setting of 200 will only output faces that have been " +"downscaled from 1024px or above." +msgstr "" +"Solo se mostrarán las caras que se hayan redimensionado en este porcentaje o " +"más para cumplir con el tamaño de extracción especificado (`-z`, `--size`). " +"Útil para excluir imágenes de baja resolución de un conjunto de " +"entrenamiento. Establezca en 0 para mostrar todas las caras. Esto solo " +"afecta a las caras que se guardan en el disco. Todas las caras detectadas se " +"guardarán en el archivo de alineaciones independientemente de lo que se " +"establezca aquí. Por ejemplo: para un tamaño de extracción de 512 px, una " +"configuración de 50 mostrará solo las caras que se hayan redimensionado " +"desde 256 px o más. Establecer en 100 mostrará solo las caras que se hayan " +"redimensionado desde 512 px o más. Una configuración de 200 mostrará solo " +"las caras que se hayan reducido de escala desde 1024 px o más." + +#: lib/cli/args_extract_convert.py:389 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -351,29 +419,43 @@ msgstr "" "ADVERTENCIA: No interrumpa el script al escribir el archivo porque podría " "corromperse. Poner a 0 para desactivar" -#: lib/cli/args_extract_convert.py:357 -msgid "Draw landmarks on the ouput faces for debugging purposes." +#: lib/cli/args_extract_convert.py:400 +msgid "Draw landmarks on the output faces for debugging purposes." msgstr "" "Dibujar puntos de referencia en las caras de salida para fines de depuración." -#: lib/cli/args_extract_convert.py:363 lib/cli/args_extract_convert.py:373 -#: lib/cli/args_extract_convert.py:381 lib/cli/args_extract_convert.py:388 -#: lib/cli/args_extract_convert.py:678 lib/cli/args_extract_convert.py:691 -#: lib/cli/args_extract_convert.py:712 lib/cli/args_extract_convert.py:718 +#: lib/cli/args_extract_convert.py:405 lib/cli/args_extract_convert.py:414 +#: lib/cli/args_extract_convert.py:424 lib/cli/args_extract_convert.py:432 +#: lib/cli/args_extract_convert.py:693 lib/cli/args_extract_convert.py:706 +#: lib/cli/args_extract_convert.py:727 lib/cli/args_extract_convert.py:733 msgid "settings" msgstr "ajustes" -#: lib/cli/args_extract_convert.py:365 +#: lib/cli/args_extract_convert.py:406 msgid "" -"Don't run extraction in parallel. Will run each part of the extraction " -"process separately (one after the other) rather than all at the same time. " -"Useful if VRAM is at a premium." +"Compile any PyTorch models. This will lead to slower start up time, but " +"faster processing. For large amounts of data this is worth enabling. For " +"smaller extractions it is not." msgstr "" -"No ejecute la extracción en paralelo. Ejecutará cada parte del proceso de " -"extracción por separado (una tras otra) en lugar de hacerlo todo al mismo " -"tiempo. Útil si la VRAM es escasa." +"Compila cualquier modelo de PyTorch. Esto ralentizará el inicio, pero " +"acelerará el procesamiento. Para grandes cantidades de datos, vale la pena " +"habilitar esta opción. Para extracciones más pequeñas, no." -#: lib/cli/args_extract_convert.py:375 +#: lib/cli/args_extract_convert.py:415 +msgid "" +"Benchmark the chosen extract plugins for optimal batch sizes. The benchmark " +"profiler can be configured in settings. Note: This will take a long time, so " +"should be used to find optimal settings for a given plugin combination and " +"type of dataset rather than being used every time." +msgstr "" +"Evalúe el rendimiento de los complementos de extracción seleccionados para " +"determinar el tamaño óptimo de los lotes. El analizador de rendimiento se " +"puede configurar en los ajustes. Nota: Este proceso puede tardar bastante, " +"por lo que se recomienda usarlo para encontrar la configuración óptima para " +"una combinación específica de complementos y un tipo de conjunto de datos " +"determinado, en lugar de usarlo siempre." + +#: lib/cli/args_extract_convert.py:426 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -381,19 +463,13 @@ msgstr "" "Omite los fotogramas que ya han sido extraídos y que existen en el archivo " "de alineaciones" -#: lib/cli/args_extract_convert.py:382 +#: lib/cli/args_extract_convert.py:433 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" "Omitir los fotogramas que ya tienen caras detectadas en el archivo de " "alineaciones" -#: lib/cli/args_extract_convert.py:389 -msgid "Skip saving the detected faces to disk. Just create an alignments file" -msgstr "" -"No guardar las caras detectadas en el disco. Crear sólo un archivo de " -"alineaciones" - -#: lib/cli/args_extract_convert.py:463 +#: lib/cli/args_extract_convert.py:469 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -403,7 +479,12 @@ msgstr "" "Los plugins de conversión pueden ser configurados en el menú " "\"Configuración\"" -#: lib/cli/args_extract_convert.py:485 +#: lib/cli/args_extract_convert.py:489 +msgid "Output directory. This is where the converted files will be saved." +msgstr "" +"Directorio de salida. Aquí es donde se guardarán los archivos convertidos." + +#: lib/cli/args_extract_convert.py:498 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -413,7 +494,7 @@ msgstr "" "original del que se extrajeron los fotogramas de origen (para extraer los " "fps y el audio)." -#: lib/cli/args_extract_convert.py:494 +#: lib/cli/args_extract_convert.py:507 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -421,7 +502,12 @@ msgstr "" "Directorio del modelo. El directorio que contiene el modelo entrenado que " "desea utilizar para la conversión." -#: lib/cli/args_extract_convert.py:505 +#: lib/cli/args_extract_convert.py:516 lib/cli/args_extract_convert.py:544 +#: lib/cli/args_extract_convert.py:583 +msgid "Plugins" +msgstr "Extensiones" + +#: lib/cli/args_extract_convert.py:518 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -461,7 +547,7 @@ msgstr "" "colores. Generalmente no da resultados muy satisfactorios.\n" "L|none: No realice el ajuste de color." -#: lib/cli/args_extract_convert.py:531 +#: lib/cli/args_extract_convert.py:546 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -537,7 +623,7 @@ msgstr "" "L|predicted: Si la opción 'Learn Mask' se habilitó durante el entrenamiento, " "esto usará la máscara que fue creada por el modelo entrenado." -#: lib/cli/args_extract_convert.py:570 +#: lib/cli/args_extract_convert.py:585 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -570,12 +656,12 @@ msgstr "" "L|pillow: [images] Más lento que opencv, pero tiene más opciones y soporta " "más formatos." -#: lib/cli/args_extract_convert.py:591 lib/cli/args_extract_convert.py:600 -#: lib/cli/args_extract_convert.py:703 +#: lib/cli/args_extract_convert.py:606 lib/cli/args_extract_convert.py:615 +#: lib/cli/args_extract_convert.py:718 msgid "Frame Processing" msgstr "Proceso de fotogramas" -#: lib/cli/args_extract_convert.py:593 +#: lib/cli/args_extract_convert.py:608 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -585,7 +671,7 @@ msgstr "" "a los fotogramas a las dimensiones de origen. 50%% a la mitad de tamaño. " "200%% al doble de tamaño" -#: lib/cli/args_extract_convert.py:602 +#: lib/cli/args_extract_convert.py:617 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -599,7 +685,13 @@ msgstr "" "imágenes, ¡los nombres de los archivos deben terminar con el número de " "fotograma!" -#: lib/cli/args_extract_convert.py:616 +#: lib/cli/args_extract_convert.py:629 lib/cli/args_extract_convert.py:638 +#: lib/cli/args_extract_convert.py:653 lib/cli/args_extract_convert.py:666 +#: lib/cli/args_extract_convert.py:680 +msgid "Face Processing" +msgstr "Proceso de Caras" + +#: lib/cli/args_extract_convert.py:631 msgid "" "Scale the swapped face by this percentage. Positive values will enlarge the " "face, Negative values will shrink the face." @@ -607,7 +699,7 @@ msgstr "" "Escale la cara intercambiada según este porcentaje. Los valores positivos " "agrandarán la cara, los valores negativos la reducirán." -#: lib/cli/args_extract_convert.py:625 +#: lib/cli/args_extract_convert.py:640 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -623,7 +715,7 @@ msgstr "" "especificada. Si se deja en blanco, se convertirán todas las caras que " "existan en el archivo de alineaciones." -#: lib/cli/args_extract_convert.py:640 +#: lib/cli/args_extract_convert.py:655 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -637,7 +729,7 @@ msgstr "" "uso del filtro de caras disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args_extract_convert.py:653 +#: lib/cli/args_extract_convert.py:668 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -651,7 +743,7 @@ msgstr "" "del filtro facial disminuirá significativamente la velocidad de extracción y " "no se puede garantizar su precisión." -#: lib/cli/args_extract_convert.py:667 +#: lib/cli/args_extract_convert.py:682 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -663,7 +755,7 @@ msgstr "" "NB: El uso del filtro facial disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args_extract_convert.py:680 +#: lib/cli/args_extract_convert.py:695 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -680,7 +772,7 @@ msgstr "" "procesos que los disponibles en su sistema. Si 'singleprocess' está " "habilitado, este ajuste será ignorado." -#: lib/cli/args_extract_convert.py:693 +#: lib/cli/args_extract_convert.py:708 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -695,7 +787,7 @@ msgstr "" "de baja calidad. Si se encuentra un archivo de alineaciones, esta opción " "será ignorada." -#: lib/cli/args_extract_convert.py:705 +#: lib/cli/args_extract_convert.py:720 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -703,15 +795,46 @@ msgstr "" "Cuando se usa con --frame-ranges, la salida incluye los fotogramas no " "procesados en vez de descartarlos." -#: lib/cli/args_extract_convert.py:713 +#: lib/cli/args_extract_convert.py:728 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Intercambiar el modelo. En vez de convertir de A a B, convierte de B a A" -#: lib/cli/args_extract_convert.py:719 +#: lib/cli/args_extract_convert.py:734 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Desactiva el multiproceso. Es más lento, pero usa menos recursos." +#~ msgid "" +#~ "Obtain and store face identity encodings from VGGFace2. Slows down " +#~ "extract a little, but will save time if using 'sort by face'" +#~ msgstr "" +#~ "Obtenga y almacene codificaciones de identidad facial de VGGFace2. " +#~ "Ralentiza un poco la extracción, pero ahorrará tiempo si usa 'sort by " +#~ "face'" + +#~ msgid "" +#~ "Filters out faces detected below this size. Length, in pixels across the " +#~ "diagonal of the bounding box. Set to 0 for off" +#~ msgstr "" +#~ "Filtra las caras detectadas por debajo de este tamaño. Longitud, en " +#~ "píxeles a lo largo de la diagonal del cuadro delimitador. Establecer a 0 " +#~ "para desactivar" + +#~ msgid "" +#~ "Don't run extraction in parallel. Will run each part of the extraction " +#~ "process separately (one after the other) rather than all at the same " +#~ "time. Useful if VRAM is at a premium." +#~ msgstr "" +#~ "No ejecute la extracción en paralelo. Ejecutará cada parte del proceso de " +#~ "extracción por separado (una tras otra) en lugar de hacerlo todo al mismo " +#~ "tiempo. Útil si la VRAM es escasa." + +#~ msgid "" +#~ "Skip saving the detected faces to disk. Just create an alignments file" +#~ msgstr "" +#~ "No guardar las caras detectadas en el disco. Crear sólo un archivo de " +#~ "alineaciones" + #~ msgid "" #~ "[LEGACY] This only needs to be selected if a legacy model is being loaded " #~ "or if there are multiple models in the model folder" diff --git a/locales/es/LC_MESSAGES/tools.manual.mo b/locales/es/LC_MESSAGES/tools.manual.mo index 33cfdb81424b4d1f7db679954f14cdd7262eb57a..e958cd0d2feaf8c7e2fafbe2b7253afe1b6ad66b 100644 GIT binary patch delta 1195 zcmXxjPe>F|9Ki9P?P{8u?pkVU^)$^)E2AK@LKe#Y7#95n3vzTvomyvPXCN<4-dKp%z)bPP4H zaaJd0nZ#d=PvcS6mx%(AC@}o5c(K{srpq41b&1^R8v(<0o6v-oweh8>_*-3Zt~7tavHS+ z1Iv!#APe0`&0sE*kZq~NR?bfycJbWJYMbymYK9lE0l!p{|0Zst>xmNEmhDD9GRCD1 zAK`6Wz&aL;6V*7+cd63=MhqRVA?H!N*~OhEr*vP({bGaB@A|` zY1332y6#AW)Rbw{+L5`Sk)WWa4e9Do$*2~kmad`vZ(F(19O{YER!67a!r=;X4ngDZ z5Hvl)dfK#U&b9H^3kuA^3fg9_^>nS3#7xTb5?o9=6SDG-oa(ew#&YB7l5i|LHDLR8 z=6v{FII?Zf>UW*K5yO}{=h#;>>+|MAm6q`btRdUjoX=%8m4z@IwR?779P7U zn1~>0l%OnhF)Inugb1tBu57xY3nHQxG9tnMyDcYk&+nXbAKy9OIdd!Wr#(9zcCQJg znX8H`u|Q-WK3zyf8FfTX;zPWFt}D{QGs9Rxe`1lyYW#pp@fWVe;v$i9tj7}Ej!Uo= z3wRitM6wbo7TM3hS?t15ti%GY!q8%o71)S6-ijl50#{-Imtz@GG;j^NOmGkCgxZM8 zT;&M31#@2@_v(H28+x$66f2I-I~7oW(U*Udqa`8P8xhhWX$t?4|#{OyoF5 zn2k>8DwPJlgRDg!;CCFuOFB+^#)&VDA(1D1=o9J|+$FD@aU698U$7Ai$WQ7>hpv1l zYKD$tH}ChLzW1D)2k|qW!WJIV?_NR8(BpaEU{+W5k;WAMMqTkF8_Kt27B%HXRDA@f z#W4LLULw148~1Wf9-*F*nRO!Tv4d!u(HJ&iA8x_xXz(Rg<4hI#Z>QnVyNCy{fk3bE zGX1a2<}_y57m_2>$oZ0Qr~!T;j|W9FW9FK{K_f=+Y~B2rPP~~*535d!U1Pg9&8>vC zF!`Y9UXh6ZMfXs05vL-%kw!h2PUTT6x{%V21RqM zMK@VDlmir$4b%->ntO?ubTXNsGPx3&Y{S$`~~-&o9{1q>*S-Q_Z(-()@`|x z&~PcYOtL3!{aEV7NbXr^m(>wJY5FrZX6J6%l<^XA-+Iw_(zgR9W~0_m(-<`8GI5)- zhWB|W<9nvZ8?Yws$D?sC!Ch+-8QYsQ44(G}J>wZ0i>JL*zB9bLD1WnZ(#;Q5mpOj{ Dp_Gvc diff --git a/locales/es/LC_MESSAGES/tools.manual.po b/locales/es/LC_MESSAGES/tools.manual.po index 0e03295c00..7f13344f61 100644 --- a/locales/es/LC_MESSAGES/tools.manual.po +++ b/locales/es/LC_MESSAGES/tools.manual.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 23:55+0000\n" +"POT-Creation-Date: 2026-03-20 22:06+0000\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: \n" @@ -14,7 +14,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Poedit 3.4.2\n" +"X-Generator: Poedit 3.8\n" #: tools/manual/cli.py:13 msgid "" @@ -75,26 +75,101 @@ msgstr "" "extracción se cuelgue. Si esto sucede, entonces configure esta opción para " "generar las miniaturas en un solo hilo más lento, pero más estable." -#: tools/manual\faceviewer\frame.py:163 +#: tools/manual/face_viewer/frame.py:175 msgid "Display the landmarks mesh" msgstr "Mostrar la malla de puntos de referencia" -#: tools/manual\faceviewer\frame.py:164 +#: tools/manual/face_viewer/frame.py:176 msgid "Display the mask" msgstr "Mostrar la máscara" -#: tools/manual\frameviewer\editor\_base.py:628 -#: tools/manual\frameviewer\editor\landmarks.py:44 -#: tools/manual\frameviewer\editor\mask.py:75 +#: tools/manual/frame_viewer/frame.py:79 +msgid "Play/Pause (SPACE)" +msgstr "Reproducir/Pausa (BARRA DE ESPACIO)" + +#: tools/manual/frame_viewer/frame.py:80 +msgid "Go to First Frame (HOME)" +msgstr "Ir al primer cuadro (INICIO)" + +#: tools/manual/frame_viewer/frame.py:81 +msgid "Go to Previous Frame (Z)" +msgstr "Ir al cuadro anterior (Z)" + +#: tools/manual/frame_viewer/frame.py:82 +msgid "Go to Next Frame (X)" +msgstr "Ir al siguiente cuadro (X)" + +#: tools/manual/frame_viewer/frame.py:83 +msgid "Go to Last Frame (END)" +msgstr "Ir al último cuadro (FIN)" + +#: tools/manual/frame_viewer/frame.py:84 +msgid "Extract the faces to a folder... (Ctrl+E)" +msgstr "Extraer las caras a una carpeta... (Ctrl+E)" + +#: tools/manual/frame_viewer/frame.py:85 +msgid "Save the Alignments file (Ctrl+S)" +msgstr "Guardar el fichero de alineamientos (Ctrl+S)" + +#: tools/manual/frame_viewer/frame.py:86 +msgid "Filter Frames to only those Containing the Selected Item (F)" +msgstr "Mostrar cuadros que contenga únicamente el elemento seleccionado (F)" + +#: tools/manual/frame_viewer/frame.py:87 +msgid "" +"Set the distance from an 'average face' to be considered misaligned. Higher " +"distances are more restrictive" +msgstr "" +"Establezca la distancia desde una 'cara promedio' para que se considere " +"desalineada. Las distancias más altas son más restrictivas" + +#: tools/manual/frame_viewer/frame.py:392 +msgid "View alignments" +msgstr "Ver alineamientos" + +#: tools/manual/frame_viewer/frame.py:393 +msgid "Bounding box editor" +msgstr "Editor de cuadro delimitador" + +#: tools/manual/frame_viewer/frame.py:394 +msgid "Location editor" +msgstr "Editor de ubicación" + +#: tools/manual/frame_viewer/frame.py:395 +msgid "Mask editor" +msgstr "Editor de máscara" + +#: tools/manual/frame_viewer/frame.py:396 +msgid "Landmark point editor" +msgstr "Editor de puntos de referencia" + +#: tools/manual/frame_viewer/frame.py:471 +msgid "Previous" +msgstr "Anterior" + +#: tools/manual/frame_viewer/frame.py:472 +msgid "Next" +msgstr "Siguiente" + +#: tools/manual/frame_viewer/frame.py:483 +msgid "Revert to saved Alignments ({})" +msgstr "Volver a los alineamientos guardados ({})" + +#: tools/manual/frame_viewer/frame.py:489 +msgid "Copy {} Alignments ({})" +msgstr "Copiar los alineamientos del cuadro {} ({})" + +#: tools/manual/frame_viewer/editor/_base.py:632 +#: tools/manual/frame_viewer/editor/landmarks.py:45 msgid "Magnify/Demagnify the View" msgstr "Ampliar/Reducir la vista" -#: tools/manual\frameviewer\editor\bounding_box.py:33 -#: tools/manual\frameviewer\editor\extract_box.py:32 +#: tools/manual/frame_viewer/editor/bounding_box.py:34 +#: tools/manual/frame_viewer/editor/extract_box.py:33 msgid "Delete Face" msgstr "Borrar cara" -#: tools/manual\frameviewer\editor\bounding_box.py:36 +#: tools/manual/frame_viewer/editor/bounding_box.py:37 msgid "" "Bounding Box Editor\n" "Edit the bounding box being fed into the aligner to recalculate the " @@ -116,16 +191,17 @@ msgstr "" " - Haga clic con el botón derecho del ratón en un cuadro delimitador para " "eliminar una cara." -#: tools/manual\frameviewer\editor\bounding_box.py:70 +#: tools/manual/frame_viewer/editor/bounding_box.py:71 msgid "" -"Aligner to use. FAN will obtain better alignments, but cv2-dnn can be useful " -"if FAN cannot get decent alignments and you want to set a base to edit from." +"Aligner to use. HRNet and FAN will obtain better alignments, but cv2-dnn can " +"be useful if these cannot get decent alignments and you want to set a base " +"to edit from." msgstr "" -"Alineador a utilizar. FAN obtendrá mejores alineaciones, pero cv2-dnn puede " -"ser útil si FAN no puede obtener alineaciones decentes y quiere tener una " -"base inicial que luego se vaya a editar." +"Alineador a utilizar. HRNet y FAN obtendrán mejores alineaciones, pero cv2-" +"dnn puede ser útil si estos no logran alineaciones decentes y se desea " +"establecer una base para la edición." -#: tools/manual\frameviewer\editor\bounding_box.py:83 +#: tools/manual/frame_viewer/editor/bounding_box.py:84 msgid "" "Normalization method to use for feeding faces to the aligner. This can help " "the aligner better align faces with difficult lighting conditions. Different " @@ -148,7 +224,7 @@ msgstr "" "\thist: Iguala los histogramas en los canales RGB.\n" "\tmean: Normaliza los colores de la cara a la media." -#: tools/manual\frameviewer\editor\extract_box.py:35 +#: tools/manual/frame_viewer/editor/extract_box.py:36 msgid "" "Extract Box Editor\n" "Move the extract box that has been generated by the aligner. Click and " @@ -167,7 +243,7 @@ msgstr "" "referencia.\n" " - Fuera de las esquinas para girar los puntos de referencia." -#: tools/manual\frameviewer\editor\landmarks.py:27 +#: tools/manual/frame_viewer/editor/landmarks.py:28 msgid "" "Landmark Point Editor\n" "Edit the individual landmark points.\n" @@ -181,7 +257,7 @@ msgstr "" " - Haga clic y arrastre los puntos individuales para reubicarlos.\n" " - Dibuje un cuadro para seleccionar varios puntos para reubicarlos." -#: tools/manual\frameviewer\editor\mask.py:33 +#: tools/manual/frame_viewer/editor/mask.py:43 msgid "" "Mask Editor\n" "Edit the mask.\n" @@ -198,98 +274,30 @@ msgstr "" "Cualquier cambio en los puntos de referencia después de editar la máscara " "anulará sus ediciones manuales." -#: tools/manual\frameviewer\editor\mask.py:77 +#: tools/manual/frame_viewer/editor/mask.py:91 +msgid "Magnify/De-magnify the View" +msgstr "Ampliar/Reducir la vista" + +#: tools/manual/frame_viewer/editor/mask.py:93 msgid "Draw Tool" msgstr "Herramienta de dibujo" -#: tools/manual\frameviewer\editor\mask.py:78 +#: tools/manual/frame_viewer/editor/mask.py:94 msgid "Erase Tool" msgstr "Herramienta de borrado" -#: tools/manual\frameviewer\editor\mask.py:97 +#: tools/manual/frame_viewer/editor/mask.py:115 msgid "Select which mask to edit" msgstr "Seleccionar máscara a editar" -#: tools/manual\frameviewer\editor\mask.py:104 +#: tools/manual/frame_viewer/editor/mask.py:122 msgid "Set the brush size. ([ - decrease, ] - increase)" msgstr "Seleccionar el tamaño del pincel ([ - disminuir, ] - aumentar)" -#: tools/manual\frameviewer\editor\mask.py:111 +#: tools/manual/frame_viewer/editor/mask.py:129 msgid "Select the brush cursor color." msgstr "Seleccionar el color del pincel." -#: tools/manual\frameviewer\frame.py:78 -msgid "Play/Pause (SPACE)" -msgstr "Reproducir/Pausa (BARRA DE ESPACIO)" - -#: tools/manual\frameviewer\frame.py:79 -msgid "Go to First Frame (HOME)" -msgstr "Ir al primer cuadro (INICIO)" - -#: tools/manual\frameviewer\frame.py:80 -msgid "Go to Previous Frame (Z)" -msgstr "Ir al cuadro anterior (Z)" - -#: tools/manual\frameviewer\frame.py:81 -msgid "Go to Next Frame (X)" -msgstr "Ir al siguiente cuadro (X)" - -#: tools/manual\frameviewer\frame.py:82 -msgid "Go to Last Frame (END)" -msgstr "Ir al último cuadro (FIN)" - -#: tools/manual\frameviewer\frame.py:83 -msgid "Extract the faces to a folder... (Ctrl+E)" -msgstr "Extraer las caras a una carpeta... (Ctrl+E)" - -#: tools/manual\frameviewer\frame.py:84 -msgid "Save the Alignments file (Ctrl+S)" -msgstr "Guardar el fichero de alineamientos (Ctrl+S)" - -#: tools/manual\frameviewer\frame.py:85 -msgid "Filter Frames to only those Containing the Selected Item (F)" -msgstr "Mostrar cuadros que contenga únicamente el elemento seleccionado (F)" - -#: tools/manual\frameviewer\frame.py:86 -msgid "" -"Set the distance from an 'average face' to be considered misaligned. Higher " -"distances are more restrictive" -msgstr "" -"Establezca la distancia desde una 'cara promedio' para que se considere " -"desalineada. Las distancias más altas son más restrictivas" - -#: tools/manual\frameviewer\frame.py:391 -msgid "View alignments" -msgstr "Ver alineamientos" - -#: tools/manual\frameviewer\frame.py:392 -msgid "Bounding box editor" -msgstr "Editor de cuadro delimitador" - -#: tools/manual\frameviewer\frame.py:393 -msgid "Location editor" -msgstr "Editor de ubicación" - -#: tools/manual\frameviewer\frame.py:394 -msgid "Mask editor" -msgstr "Editor de máscara" - -#: tools/manual\frameviewer\frame.py:395 -msgid "Landmark point editor" -msgstr "Editor de puntos de referencia" - -#: tools/manual\frameviewer\frame.py:470 -msgid "Next" -msgstr "Siguiente" - -#: tools/manual\frameviewer\frame.py:470 -msgid "Previous" -msgstr "Anterior" - -#: tools/manual\frameviewer\frame.py:481 -msgid "Revert to saved Alignments ({})" -msgstr "Volver a los alineamientos guardados ({})" - -#: tools/manual\frameviewer\frame.py:487 -msgid "Copy {} Alignments ({})" -msgstr "Copiar los alineamientos del cuadro {} ({})" +#: tools/manual/frame_viewer/editor/mask.py:136 +msgid "Select a shape for masking cursor." +msgstr "Seleccionar el color del pincel." diff --git a/locales/es/LC_MESSAGES/tools.mask.cli.mo b/locales/es/LC_MESSAGES/tools.mask.cli.mo index ad86f74687970000a91cee1fd14ce3bb86455225..a9378bda8ad72eec3f132579802ea6df8d67e440 100644 GIT binary patch delta 371 zcmXZW%_~Gv7zXg?_!?8j_?$atjJYCT<6igD9TO|%U!YJjJlr>B$S0r-DF`w zSt^@@m5r4wY;5c#3)v}VF=gW!Ih)^k-t(T`_j2=kwXmKmRssX{fKvg)n}BJ0*aF-F zcn<-)^jraEX@3}~qC2#P?*Ch$KIUUOCxBBrLx0+UdDf?*fW~}9i_CBBfWrJ7Lk`<4 ze0BhN>c)WyK77+W7qdEnaZbGH2E1zEs}FeLf_pm9%erj>PyBaa0grSc34Bm}6kt1) z6i`cJw7hT+EibB3jzJKfD?th-M~LGc`7BB@DwmiQK6%YIwl}O;=1igqrVTg@|oe5DBO@`e$SlQb-XZH9>Odtk( z>@2+I&G+5xoAmhQo9y<(tVZC?+Xk$Hh6!W;l#ytTn5ceGeM)}7IaiI=)cN`eui&stpH~GT-9$=jU zzYhRi?5`JrxBRYt2zWz$Py9rDItlUJF1bCWJtbflu#)^6?Xm@i2$kfE?@qRgI*109KRX_YT{h$oe(XdAe0 z5KRbO5}}OzvNTg7)Lweq7R0FVbTqAI#bh-MMPOZNV^j5F(nzDLk)Kkj*;tuC8^`0Q z$<>yUSrKdD86{&Sq$o=}Rr}cTq0!xseC7WSLWeem(tfJliF8)CG>U3vMjGF8Z1m#X zm9$ZlO8Uv`?8Szhpl!)s=qn>iMwXR5H68d$CtYo8nLWk6{pS_6}_&#*m~hg^D=FG$b!rO(NlQtlEl7l`^cWj4COkBBoprm(^A@ zb4pYr;YsUp@M(Bj$j~TGn?ijRrpD5+eq-$1PAMwV$TT@aK1R3t*m`UwpSwFUjhAl! zoKCH$cjQmme8cUQ$K8_UfXk;-@zRRwc;Im@xuvOtf8rPx# diff --git a/locales/es/LC_MESSAGES/tools.mask.cli.po b/locales/es/LC_MESSAGES/tools.mask.cli.po index 1d70c85ba9..62a042ef7e 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: 2024-06-28 13:45+0100\n" -"PO-Revision-Date: 2024-06-28 13:47+0100\n" +"POT-Creation-Date: 2026-03-13 15:17+0000\n" +"PO-Revision-Date: 2026-03-16 18:25+0000\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es_ES\n" @@ -16,9 +16,9 @@ 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.4.4\n" +"X-Generator: Poedit 3.8\n" -#: tools/mask/cli.py:15 +#: tools/mask/cli.py:16 msgid "" "This tool allows you to generate, import, export or preview masks for " "existing alignments." @@ -28,7 +28,7 @@ msgstr "" "Genere, importe, exporte o obtenga una vista previa de máscaras para " "archivos de alineaciones existentes." -#: tools/mask/cli.py:25 +#: tools/mask/cli.py:26 msgid "" "Mask tool\n" "Generate, import, export or preview masks for existing alignments files." @@ -37,12 +37,12 @@ msgstr "" "Genere, importe, exporte o obtenga una vista previa de máscaras para " "archivos de alineaciones existentes." -#: tools/mask/cli.py:35 tools/mask/cli.py:47 tools/mask/cli.py:58 -#: tools/mask/cli.py:69 +#: tools/mask/cli.py:36 tools/mask/cli.py:48 tools/mask/cli.py:59 +#: tools/mask/cli.py:70 msgid "data" msgstr "datos" -#: tools/mask/cli.py:39 +#: tools/mask/cli.py:40 msgid "" "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 " @@ -54,15 +54,15 @@ 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:51 +#: tools/mask/cli.py:52 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:61 +#: tools/mask/cli.py:62 msgid "" -"R|Whether the `input` is a folder of faces or a folder frames/video\n" +"R|Whether the `input` is a folder of faces/frames or a video file\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 "" @@ -70,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:71 +#: tools/mask/cli.py:72 msgid "" "R|Run the mask tool on multiple sources. If selected then the other options " "should be set as follows:\n" @@ -95,27 +95,20 @@ msgstr "" "con 'caras' como tipo de entrada, solo se actualizará el encabezado PNG " "dentro de las caras extraídas." -#: tools/mask/cli.py:87 tools/mask/cli.py:119 +#: tools/mask/cli.py:88 tools/mask/cli.py:114 msgid "process" msgstr "proceso" -#: tools/mask/cli.py:89 +#: tools/mask/cli.py:90 msgid "" "R|Masker to use.\n" "L|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).\n" -"L|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.\n" "L|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.\n" -"L|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.\n" "L|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.\n" @@ -131,18 +124,11 @@ msgstr "" "L|bisenet-fp: Máscara relativamente ligera basada en NN que proporciona un " "control más refinado sobre el área a enmascarar, incluido el enmascaramiento " "completo de la cabeza (configurable en la configuración de la máscara).\n" -"L|components: Máscara diseñada para proporcionar una segmentación facial " -"basada en la posición de los puntos de referencia. Se construye un casco " -"convexo alrededor del exterior de los puntos de referencia para crear una " -"máscara.\n" "L|custom: Una máscara ficticia que llena el área de la máscara con 1 o 0 " "(configurable en la configuración). Esto solo es necesario si tiene la " "intención de editar manualmente las máscaras personalizadas usted mismo en " "la herramienta manual. Esta máscara no utiliza la GPU.\n" -"L|extended: Máscara diseñada para proporcionar una segmentación facial " -"basada en el posicionamiento de las ubicaciones de los puntos de referencia. " -"Se construye un casco convexo alrededor del exterior de los puntos de " -"referencia y la máscara se extiende hacia arriba en la frente.\n" +"máscara se extiende hacia arriba en la frente.\n" "L|vgg-clear: Máscara diseñada para proporcionar una segmentación inteligente " "de rostros principalmente frontales y libres de obstrucciones. Los rostros " "de perfil y las obstrucciones pueden dar lugar a un rendimiento inferior.\n" @@ -157,7 +143,7 @@ msgstr "" "descripción. Los rostros de perfil pueden dar lugar a un rendimiento " "inferior." -#: tools/mask/cli.py:121 +#: tools/mask/cli.py:116 msgid "" "R|The Mask tool process to perform.\n" "L|all: Update the mask for all faces in the alignments file for the selected " @@ -181,11 +167,11 @@ msgstr "" "файл выравниваний. Примечание. «custom» должен быть выбранным «masker», а " "маски должны быть в том же формате, что и «input-type» (frames или faces)." -#: tools/mask/cli.py:135 tools/mask/cli.py:154 tools/mask/cli.py:176 +#: tools/mask/cli.py:130 tools/mask/cli.py:149 tools/mask/cli.py:171 msgid "import" msgstr "importar" -#: tools/mask/cli.py:137 +#: tools/mask/cli.py:132 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, " @@ -214,7 +200,7 @@ msgstr "" "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 +#: tools/mask/cli.py:151 msgid "" "R|Import/Output only. When importing masks, this is the centering to use. " "For output this is only used for outputting custom imported masks, and " @@ -249,7 +235,7 @@ msgstr "" "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:181 +#: tools/mask/cli.py:176 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 " @@ -261,12 +247,12 @@ msgstr "" "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:189 tools/mask/cli.py:197 tools/mask/cli.py:211 -#: tools/mask/cli.py:225 tools/mask/cli.py:235 +#: tools/mask/cli.py:184 tools/mask/cli.py:192 tools/mask/cli.py:206 +#: tools/mask/cli.py:220 tools/mask/cli.py:230 msgid "output" msgstr "salida" -#: tools/mask/cli.py:191 +#: tools/mask/cli.py:186 msgid "" "Optional output location. If provided, a preview of the masks created will " "be output in the given folder." @@ -274,7 +260,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:202 +#: tools/mask/cli.py:197 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 " @@ -287,7 +273,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:216 +#: tools/mask/cli.py:211 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 " @@ -298,7 +284,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:227 +#: tools/mask/cli.py:222 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" @@ -313,7 +299,7 @@ msgstr "" "enmascarada.\n" "L|mask: Sólo emite la máscara como una imagen de un solo canal." -#: tools/mask/cli.py:237 +#: tools/mask/cli.py:232 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/locales/es/LC_MESSAGES/tools.sort.cli.mo b/locales/es/LC_MESSAGES/tools.sort.cli.mo index 1eea2cf248ed6c875de7ceac4ad29537a7e3c7ba..8971a276a4f6ffc099e823da7f9103cba61167cf 100644 GIT binary patch delta 4620 zcmai%{fk^z8OKkujZN03(ex!Rw#`XxoTiM!6w!HtFb=NzT+6~?V-V2@tYv42B-@q5a`?3A(^-6sk{MkmOF05Cj>P#y2 zLl(SwgHkVJ^o7kz9b>_Vw<>ib`20;uaa(=mW~KIlm%%@RzXuP4FWrJ6*1ZYJx~H}& z^*%5FFN4nwe%H4v#TGRO?gl&H&pBT`$Cq8)_{a{W_@}!3`7!u;@J8@MJC*trcnbUs z_!{_0@Ui#62KWas1$RN8F!M!_;8b$EQpBPbz$bC=S@2iIIopD zD+_T;sQe8m>9`6`gZ}}iz{Z_QZCj_*$HB8;{$8afNYk(HQtEf$ZwdQl)_sG`-@?%2 z_b6pp_iv0G1)qXG$W~w9uhc>CC9s_2!C(0zk^KDthw}R%PsNcPhg=;3vS{;1h?H!Z&pRyb68|d>5GAPa46`g5Lyp&%*y#`SQzI=mf(f zN_`G|1zd+CM?atxdQlI2P^nwNM?r2Yf*oCCNs$qSOcX?T(EW ziNEq#@+6eMQ|@tX;v<^a!-sT^F42QfDA!g#5+&*M`IZRf+ERgcc<|K+q4_}(`0Hl= zqW8-9-Tqdk-U+^=N3Y=9m!>AZuzthw)D^v@pKNf~-*V>Si6pmeo9cNJ7q+1v&urK~ zJF%+_QfqP>>Xu7&TQ)>VTQ?VHwMiCrtr-_-O?PeHabZ^1wZO%0<%VWYtz`l$E7B+! zt<-nd!YIp464)BMvgm@X?ZslKYc7p!lAAno$q}}d-ZJYK&4MiLMS0Mv?QQ5suq`gQ zyo1*x&jzmO%v#M8U7N~l?4Fv+JE_e&E)F@!^~8K*^5jLaa%~S~b2CcHOq0(_kbfUP5Dwf;h@#2uI_bVft<)cFeTHGwT9cj03CT=dH zjKl}X6sDVty5kmfw+K3V-{Bd8Yo`_s_%)KLu*$-T7LVobFqpa=n`mp;xW*r&_YDo{ zD4_!j$ZD0R*tw^N%IrE~BwZB`t$aFk3ke)ps<{%;TUZzxlP&+- zU>cR}PCi}q8v4`&vwF^TeW_$VjgVO-JQ~(Q=yc*>Ji;Sv2WgbsG%^i6`H*dyBF?Lx zGpp}YXD-h9L`xW^Dr%v(D(WicuA8^h2Ljhyl%f=x6XL9NEy)m>H4WWMQ;%}qknR5K z8+Vja19CI!QZ7_SMd*q!GnWohI=+<-#r>(~Vog)%l^+rkv0^Y0A#-eQO(@0}M=Bgx z^Bx+VMag6v4OXe{|7g?k@-cBGB()J0(u<3>xaV|{S;-Z$=UBLy9tLM&d&BqIn1})a@c-tE*7d9N{kQ-F#@%=0j662d4HNqF`nZ?CZz3{%if=$^N>XJNl=#XG{Os z)|BB@^xV=DJ3qZ~=b4LV4P%E6LUgX7=d7k3$I%7TzqIXMKfywm>7GfAHf{EqRQFsu ztAoPC=Zgpyy360m9YPJ67{0aCSKS&kX1D655HcEU*BodJNSBs=o(0kJk_@!!oa`C{ z?X$v%#qk0=No_J6v4XIaf4Lt-2Hbibk^EX4^#) zvPR~f(PLu|1yThJeuG^pY0=+XIG#7b@)gHx6Jq44K3_1*3(Fzs*l+fX)B4wD=X_DZ zGQE;EQ;#45=YU)ZtyYMe{jhC4_54v^5% zzPHcj*xOvq=d@(LLbV^BC`uv^3OJ(cAvsAg?a+_Wb~>Kixk#R`gv%1jz`*{{wP6-f zz-VH65im)IwoaOIw0>rwjy0*5z`Ie3@O^|G6GU`QqoWJHQ-o&PG|f5p|Ea39x7K?3 zHs*QxvOx#qDKC@EMj5OoMqg`F?*+n318>jLyuE@LEo0(MJeB5LAurg{_QgasbX|6| zF6qek(KwF+i0)g%h1AeLXnx7fD; z>8C3R3!SK>mbk=Hg{R!86HC=aaNVZ&P|GK)B9y}PU5M9h+K?|J5ev3!*~f;{c-%Z? zBvn6%zH0Lbi+9Y3dy-ZJG-eH%6$wS`Rtlqk%dTVP!!g3>_G16a-J#(qX)`{3K_*k35Aegrk|` Zjmgt%Bs7@f50cTSMgL#=)19wO{udYgiWmR@ delta 876 zcmZY8O-NKx7{>9(nQ_LM^4pryG{+=$Z0a^MN)tlsRwalaeyrRzgcxpSa}_}Z7aHoW z7E%jwVbC?)vtW5{b{O&pT-1olsJ!cBJ`-ARwO<-BH zR{A-5Pl?opLq2|Jj$e9$S8*K+_z4#R(k;xEN-=znow$u{7zs+dn8XY*qakS(d&{I8 zv44i84awESNrJO5UM_9n5MIMn1q*o~i@ZzUDy56~9apfnsyKdzb&Nk_4EOPcxnC`v z$1OaI2e^q9XNnja2gZnVbo<|fs1}TYLNVt=60j>4*N*|DGS?Lq%?lS z27k*X_0dCV5vz_)k@yRnD;AUEAZLTC;@;5xc7E@s;$VrmWZ_y(z> z9+CC~+`z}!8yST!Hdo;cQSp;E<6In=f^=3sEX z#96Wb2(XPaswTQQND*(@KkxJ6!Mh=ErLHSno=!NaL^9LtIM%NvJmi1A{*!1A`uro(-hkfb=OKeI7^)GBGg70ND?Lv;dI*kC}mC9gyZ{ zVPHrA())n44Um>+WnhQ~(lJ1q5vYF0#7<>SAfJI9h*>6XUAZ}y@dzWYnXZAcuA!NN Vp@o&9!DKz=GmI9S|1$4m0sxRe7pMRL delta 140 zcmdnRyPJ1{NvIbC1A{*!1A`uro&}`cfb>ZqeI7^)FflO50of0Lv;dI*pP7MS9Y~yo zfgu4%?+4O0Kw5#7fgu`5#{y|ap!%H?JC!+sa$vwdaqG&>@r;KVc};W;jCGAH6bvn_ U49zF&GoN8L(KFiohj||p0KLx`5&!@I diff --git a/locales/kr/LC_MESSAGES/lib.cli.args.po b/locales/kr/LC_MESSAGES/lib.cli.args.po index ec473df1f1..d5d623adec 100644 --- a/locales/kr/LC_MESSAGES/lib.cli.args.po +++ b/locales/kr/LC_MESSAGES/lib.cli.args.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 18:06+0000\n" -"PO-Revision-Date: 2024-03-28 18:17+0000\n" +"POT-Creation-Date: 2026-03-13 15:17+0000\n" +"PO-Revision-Date: 2026-03-16 18:10+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ko_KR\n" @@ -16,31 +16,31 @@ 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.4.2\n" +"X-Generator: Poedit 3.8\n" -#: lib/cli/args.py:188 lib/cli/args.py:199 lib/cli/args.py:208 -#: lib/cli/args.py:219 +#: lib/cli/args.py:194 lib/cli/args.py:206 lib/cli/args.py:215 +#: lib/cli/args.py:226 msgid "Global Options" msgstr "전역 옵션들" -#: lib/cli/args.py:190 +#: lib/cli/args.py:196 msgid "" "R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " "to any GPU(s) that you do not wish to be made available to Faceswap. " "Selecting all GPUs here will force Faceswap into CPU mode.\n" "L|{}" msgstr "" -"R|Faceswap에서 사용되는 GPUs를 제외합니다. Faceswap에서 사용되게 하고 싶지 " -"않은 GPU(s)에 해당하는 번호를 선택하세요. 모든 GPUs를 선택하면 Faceswap으로 " -"하여금 CPU mode를 강제로 사용하게 합니다.\n" +"R|Faceswap에서 사용되는 GPUs를 제외합니다. Faceswap에서 사용되게 하고 싶지 않" +"은 GPU(s)에 해당하는 번호를 선택하세요. 모든 GPUs를 선택하면 Faceswap으로 하" +"여금 CPU mode를 강제로 사용하게 합니다.\n" "L|{}" -#: lib/cli/args.py:201 +#: lib/cli/args.py:208 msgid "" -"Optionally overide the saved config with the path to a custom config file." +"Optionally override the saved config with the path to a custom config file." msgstr "선택적으로 저장된 설정을 경로와 함께 개인 설정 파일에 덮어씌웁니다." -#: lib/cli/args.py:210 +#: lib/cli/args.py:217 msgid "" "Log level. Stick with INFO or VERBOSE unless you need to file an error " "report. Be careful with TRACE as it will generate a lot of data" @@ -48,10 +48,10 @@ msgstr "" "로그 레벨. 오류 리포트가 필요하지 않다면 INFO와 VERBOSE를 사용하세요. 단, 굉" "장히 많은 데이터를 생성할 수 있는 TRACE는 조심하세요" -#: lib/cli/args.py:220 +#: lib/cli/args.py:227 msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "로그파일을 저장할 경로. faceswap 폴더에 저장하고 싶으면 비워두세요" -#: lib/cli/args.py:319 +#: lib/cli/args.py:311 msgid "Output to Shell console instead of GUI console" msgstr "결과를 GUI 콘솔이 아닌 쉘 콘솔에 출력합니다" diff --git a/locales/kr/LC_MESSAGES/lib.cli.args_extract_convert.mo b/locales/kr/LC_MESSAGES/lib.cli.args_extract_convert.mo index 1f0c43722c48ec666523c447dd823fe02f1d811e..9c403ee8dfd04c73ed33eb52b45956a1602de632 100644 GIT binary patch delta 6793 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 YwWIZL1;%tBC3$Y=D>o&7Rb4;$KNodKpa1{> delta 2662 zcmaKse{56N6~~W5k|rVHX9#O(=^5>k7!ofD8SPTmmLwyPLe|1yV_mnt*w4Y`*-oFI z3C*%P0e59dT7sTUL+db?Q37jFO&?_56hyTlO)G5Grv1<~fwn&uO@7zf`d_t4)xOs@ ztd+K2<@odNz2}{C&-b2}-zJKF|1U@6){5ddVYI^Tfz3LII>F70@Po0fm?(_3?I8Z> zY6+eSrO& z>ro-pdB2gU3XC;lAAYAd<||y^mRImJxE1RQU@zG8h5P}A!BNbwf^!iB+_DKTgC9N! zV&iq_=?5QaCxV(Z3qnn@z_sw32UFmmA4XMJpZF5dUho8X3(SI#f;S$4a`3Cfq#FF$ zV^9?9f82tDgWrN2k+XQv@Nf)V*$HXj_(M=$$y;4SPh&n0c7e59p$_=%Zd?QQKio#7 zg70n5Ur}=p(G2Dbpa#D3I4Xz#zrp9ho}D-tcnxy+!Qv;NKMqU!lldk|feV&5wP+Q(XWws(@?&fONJ2y`|shW6zu+Pp1mO4 z=mFThF!U}p!0KSjVNyjoR>9W5&_>h(y9b8$p|!}L`#fsF&%+q@S=a`cOeBZAdpYX! zpbGGAn7sRSu$8deqY@8je7XV2-xv0~s7qG{SyKWYzoH)qF;12X zA07xB)*EG`4(obFx8s0UajPNrsD|n`R1f#5T5u4Kh!*kq=F;$a-p-!(r90 zc(};yul}a#X{lerUOnjNz1`hgah6u|Vnwz2gO+kfqj|1ngSC0}$r5YVy$$7&iXFpH zS=EuRowxf3g1W(*9^!$ZFJN$M*T+lMIj5ShKOjr`*rD>N6n-`YIlMj%1va>Y0k5w= ztSfz*D$CrW;XPcCVW5(blXeYvNp;M_bvMm*b&ZSthC2{w=k8$8W4>Si!V(t_7TW0b zv~!0VGRzL=iO&CZRE4F~1*K)5eEPY}n+@w0A-B12`E~10&au0!xz@!MtsGJgN`d87 z-nq2=9YSi4FLc1gJ>ihSZe2xtK?iGg^IuDrwKX-jHMO*IOKW>`^M>XYbGqY~#ci(E z3QO;N$5C#-oD$QQveA_JZui$~#av9xCB$@;ZR@z5oVDLbuoyp+J@I|>pIxgW`EAa| zqSz48v=}=hrl&ZQ8fANK#GZ@h-aK|vj3>malWhO!oPB(<5Fi_$vd3badFKf7v4}_Q z@uT+iJYG)NR#YTMMPeRN8>HXiDG@vGyz}CD={kPKRhR2``MNy8*;)JTw0IRc(};RS zyq02-NQ&3aTUFZ}Mb@pJw;U{8M`ttX6vNk^naJnh?2$<^Gh$CB%<B!Sdd#ETe>C3Ud~QTi0S#ldGJkTkIh2Q>{wcUSN5rdZ6?^B zISLWko|=>wD00ae;(byt8%HnHGIhBi3O5wIMf8kFnhWk4YrVgA(KkNr5cx_GN?y_J z6m}|(Ui&{OWbAn11v9CyGM&3WF!%LUT8}@swA`v4PA%@ZbK#s1pX<|D3^7reyj+o# jVnIilRK&-`*qs_OKTc!UJl Configure Extract 'Plugins':\n" "L|cv2-dnn: A CPU only extractor which is the least reliable and least " -"resource intensive. Use this if not using a GPU and time is important.\n" -"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " -"than other GPU detectors but can often return more false positives.\n" -"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " +"resource intensive. Use this only as a last resort. Both MTCNN and " +"RetinaFace have variants that will perform better on CPU.\n" +"L|mtcnn: Average detector. Fast on CPU, faster on GPU. Uses fewer resources " +"than other GPU detectors but can often return more false positives or misses " +"faces.\n" +"L|retinaface: Good detector. Faster and lighter than S3FD but of similar " +"quality. A ResNet and MobileNet version are available (configurable in " +"Detect settings). The MobileNet version is light enough to run on CPU.\n" +"L|s3fd: Good detector. Slow on CPU, faster on GPU. Can detect more faces and " "fewer false positives than other GPU detectors, but is a lot more resource " -"intensive.\n" -"L|external: Import a face detection bounding box from a json file. " -"(configurable in Detect settings)" +"intensive." msgstr "" "R|사용할 감지기. 몇몇 감지기들은 '/config/extract.ini' 또는 '설정 > 추출 플러" "그인 설정'에서 설정이 가능합니다:\n" @@ -96,26 +100,36 @@ msgstr "" "L|s3fd: 가장 좋은 감지기. CPU에선 느리고 GPU에선 빠릅니다. 다른 GPU 감지기들" "보다 더 많은 얼굴들을 감지할 수 있고 과 더 적은 false positives를 돌려주지만 " "자원을 굉장히 많이 사용합니다.\n" -"L|external: JSON 파일에서 얼굴 감지 경계 박스를 가져옵니다. (설정 감지에서 구" -"성 가능)" +"L|retinaface: 훌륭한 검출기입니다. S3FD보다 빠르고 가볍지만 품질은 비슷합니" +"다. ResNet 및 MobileNet 버전이 제공되며 (검출 설정에서 구성 가능), MobileNet " +"버전은 CPU에서도 실행될 만큼 가볍습니다." -#: lib/cli/args_extract_convert.py:154 +#: lib/cli/args_extract_convert.py:149 lib/cli/args_extract_convert.py:251 +#: lib/cli/args_extract_convert.py:269 lib/cli/args_extract_convert.py:282 +#: lib/cli/args_extract_convert.py:292 +msgid "Align" +msgstr "맞추다" + +#: lib/cli/args_extract_convert.py:151 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " "but less accurate. Only use this if not using a GPU and time is important.\n" -"L|fan: Best aligner. Fast on GPU, slow on CPU.\n" -"L|external: Import 68 point 2D landmarks or an aligned bounding box from a " -"json file. (configurable in Align settings)" +"L|fan: Best aligner. Fast on GPU, slow on CPU." msgstr "" "R|사용할 Aligner.\n" "L|cv2-dnn: CPU만을 사용하는 특징점 감지기. 빠르고 자원을 덜 사용하지만 부정확" "합니다. GPU를 사용하지 않고 시간이 중요할 때에만 사용하세요.\n" -"L|fan: 가장 좋은 aligner. GPU에선 빠르고 CPU에선 느립니다.\n" -"L|external: JSON 파일에서 68 포인트 2D 랜드 마크 또는 정렬 된 경계 상자를 가" -"져옵니다. (정렬 설정에서 구성 가능)" +"L|fan: 훌륭한 치아 정렬 도구입니다. aligner. GPU에선 빠르고 CPU에선 느립니" +"다\n" +"L|hrnet: 최고의 치아 정렬 도구. FAN보다 빠르고 성능이 뛰어납니다. 완전히 회전" +"된 얼굴 데이터셋으로 학습되었습니다. GPU에서는 빠르고 CPU에서는 느립니다." + +#: lib/cli/args_extract_convert.py:161 +msgid "Mask" +msgstr "마스크" -#: lib/cli/args_extract_convert.py:169 +#: lib/cli/args_extract_convert.py:163 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -176,7 +190,59 @@ msgstr "" "로 뻗어 있습ㄴ다.\n" "(예: '-M unet-dfl vgg-clear', '--masker vgg-obstructed')" -#: lib/cli/args_extract_convert.py:208 +#: lib/cli/args_extract_convert.py:199 lib/cli/args_extract_convert.py:304 +#: lib/cli/args_extract_convert.py:317 lib/cli/args_extract_convert.py:331 +msgid "Identity" +msgstr "신원" + +#: lib/cli/args_extract_convert.py:201 +msgid "" +"R|Obtain and store face identity encodings. Slows down extract a little but " +"will save time if using 'sort by face'. Required for face filtering.\n" +"L|t-face: An InsightFace ResNet based model with a lighter and heavier " +"variant (configurable in settings).\n" +"L|vggface2: An older and lighter, but fairly reliable plugin based on the " +"VGG Network." +msgstr "" +"R|얼굴 식별 인코딩을 획득하고 저장합니다. 추출 속도는 약간 느려지지만 '얼굴" +"별 정렬'을 사용할 경우 시간을 절약할 수 있습니다. 얼굴 필터링에 필요합니다.\n" +"L|t-face: InsightFace ResNet 기반 모델로, 경량 버전과 중량 버전이 있습니다(설" +"정에서 구성 가능).\n" +"L|vggface2: VGG 네트워크 기반의 구형 플러그인으로, 경량이지만 상당히 안정적입" +"니다." + +#: lib/cli/args_extract_convert.py:217 +msgid "" +"Filters out detections below this percentage of the shortest side of the " +"frame along the face detection box's longest edge. (eg: a value of 10 will " +"filter out faces smaller than 72px from a 720p image). 0 for disabled." +msgstr "" +"얼굴 감지 박스의 가장 긴 변을 따라 프레임의 가장 짧은 변의 길이가 이 비율보" +"다 작은 얼굴 감지를 필터링합니다. (예: 10이라는 값은 720p 이미지에서 72픽셀보" +"다 작은 얼굴을 필터링합니다.) 0으로 설정하면 비활성화됩니다." + +#: lib/cli/args_extract_convert.py:230 +msgid "" +"Filters out detections above this percentage of the shortest side of the " +"frame along the face detection box's longest edge. (eg: a value of 200 will " +"filter out faces larger than 1440px from a 720p image). 0 for disabled." +msgstr "" +"얼굴 감지 박스의 가장 긴 변을 따라 프레임의 가장 짧은 변의 길이의 이 비율보" +"다 큰 얼굴 감지를 필터링합니다. (예: 200이라는 값은 720p 이미지에서 1440픽셀" +"보다 큰 얼굴을 필터링합니다.) 0으로 설정하면 비활성화됩니다." + +#: lib/cli/args_extract_convert.py:240 +msgid "" +"If a face isn't found, rotate the images to try to find a face. Can find " +"more faces at the cost of extraction speed. Pass in a single number to use " +"increments of that size up to 360, or pass in a list of numbers to enumerate " +"exactly what angles to check." +msgstr "" +"얼굴이 발견되지 않으면 이미지를 회전하여 얼굴을 찾습니다. 추출 속도를 희생하" +"면서 더 많은 얼굴을 찾을 수 있습니다. 단일 숫자를 입력하여 해당 크기의 증분" +"을 360까지 사용하거나 숫자 목록을 입력하여 확인할 각도를 정확하게 열거합니다." + +#: lib/cli/args_extract_convert.py:253 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -197,7 +263,7 @@ msgstr "" "L|hist: RGB 채널의 히스토그램을 동일하게 합니다.\n" "L|mean: 얼굴 색상을 평균으로 정규화합니다." -#: lib/cli/args_extract_convert.py:226 +#: lib/cli/args_extract_convert.py:271 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -212,7 +278,7 @@ msgstr "" "다. 얼굴이 aligner에 다시 공급되는 횟수가 많을수록 micro-jitter 적게 발생하지" "만 추출에 더 오랜 시간이 걸립니다." -#: lib/cli/args_extract_convert.py:239 +#: lib/cli/args_extract_convert.py:284 msgid "" "Re-feed the initially found aligned face through the aligner. Can help " "produce better alignments for faces that are rotated beyond 45 degrees in " @@ -222,42 +288,16 @@ msgstr "" "회전하거나 극단적인 각도에 있는 얼굴을 더 잘 정렬할 수 있습니다. 추출 속도가 " "느려집니다." -#: lib/cli/args_extract_convert.py:249 +#: lib/cli/args_extract_convert.py:294 msgid "" -"If a face isn't found, rotate the images to try to find a face. Can find " -"more faces at the cost of extraction speed. Pass in a single number to use " -"increments of that size up to 360, or pass in a list of numbers to enumerate " -"exactly what angles to check." +"Enable aligner filters. This allows the filtering out of faces based on " +"certain statistics and characteristics. Configurable in extract settings. " +"Slows down extraction." msgstr "" -"얼굴이 발견되지 않으면 이미지를 회전하여 얼굴을 찾습니다. 추출 속도를 희생하" -"면서 더 많은 얼굴을 찾을 수 있습니다. 단일 숫자를 입력하여 해당 크기의 증분" -"을 360까지 사용하거나 숫자 목록을 입력하여 확인할 각도를 정확하게 열거합니다." +"정렬 필터를 활성화합니다. 특정 통계 및 특징을 기준으로 얼굴을 필터링할 수 있" +"습니다. 추출 설정에서 구성 가능합니다. 추출 속도가 느려질 수 있습니다." -#: lib/cli/args_extract_convert.py:259 -msgid "" -"Obtain and store face identity encodings from VGGFace2. Slows down extract a " -"little, but will save time if using 'sort by face'" -msgstr "" -"VGGFace2에서 얼굴 식별 인코딩을 가져와 저장합니다. 추출 속도를 약간 늦추지만 " -"'얼굴별로 정렬'을 사용하면 시간을 절약할 수 있습니다." - -#: lib/cli/args_extract_convert.py:269 lib/cli/args_extract_convert.py:280 -#: lib/cli/args_extract_convert.py:293 lib/cli/args_extract_convert.py:307 -#: lib/cli/args_extract_convert.py:614 lib/cli/args_extract_convert.py:623 -#: lib/cli/args_extract_convert.py:638 lib/cli/args_extract_convert.py:651 -#: lib/cli/args_extract_convert.py:665 -msgid "Face Processing" -msgstr "얼굴 처리" - -#: lib/cli/args_extract_convert.py:271 -msgid "" -"Filters out faces detected below this size. Length, in pixels across the " -"diagonal of the bounding box. Set to 0 for off" -msgstr "" -"이 크기 미만으로 탐지된 얼굴을 필터링합니다. 길이, 경계 상자의 대각선에 걸친 " -"픽셀 단위입니다. 0으로 설정하면 꺼집니다" - -#: lib/cli/args_extract_convert.py:282 +#: lib/cli/args_extract_convert.py:306 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -269,7 +309,7 @@ msgstr "" "지들 또는 공백으로 구분된 여러 이미지 파일이 들어 있는 폴더를 선택할 수 있습" "니다." -#: lib/cli/args_extract_convert.py:295 +#: lib/cli/args_extract_convert.py:319 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -280,7 +320,7 @@ msgstr "" "와 조건이 다른 작은 다양한 이미지여야 합니다. 추출할 때 필요한 이미지들 또는 " "공백으로 구분된 여러 이미지 파일이 들어 있는 폴더를 선택할 수 있습니다." -#: lib/cli/args_extract_convert.py:309 +#: lib/cli/args_extract_convert.py:333 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." @@ -288,12 +328,13 @@ msgstr "" "옵션인 nfilter/filter 파일과 함께 사용합니다. 긍정적인 얼굴 인식을 위한 임계" "값. 값이 높을수록 엄격합니다." -#: lib/cli/args_extract_convert.py:318 lib/cli/args_extract_convert.py:331 -#: lib/cli/args_extract_convert.py:344 lib/cli/args_extract_convert.py:356 +#: lib/cli/args_extract_convert.py:342 lib/cli/args_extract_convert.py:355 +#: lib/cli/args_extract_convert.py:368 lib/cli/args_extract_convert.py:387 +#: lib/cli/args_extract_convert.py:399 msgid "output" msgstr "출력" -#: lib/cli/args_extract_convert.py:320 +#: lib/cli/args_extract_convert.py:344 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -302,7 +343,7 @@ msgstr "" "추출된 얼굴의 출력 크기입니다. 훈련하려는 모델이 필요한 크기를 지원하는지 꼭 " "확인하세요. 이것은 고해상도 모델에 대해서만 변경하면 됩니다." -#: lib/cli/args_extract_convert.py:333 +#: lib/cli/args_extract_convert.py:357 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -312,7 +353,28 @@ msgstr "" "설정합니다. 예를 들어, 값이 1이면 모든 프레임에서 얼굴이 추출되고, 값이 10이" "면 모든 10번째 프레임에서 얼굴이 추출됩니다." -#: lib/cli/args_extract_convert.py:346 +#: lib/cli/args_extract_convert.py:370 +msgid "" +"Only output faces that have been resized by this percent or more to meet the " +"specified extract size (`-z`, `--size`). Useful for excluding low-res images " +"from a training set. Set to 0 to output all faces. This only impacts faces " +"that are output to disk. All detected faces will still be saved to the " +"alignments file regardless of what is set here. Eg: For an extract size of " +"512px, A setting of 50 will only output faces that have been resized from " +"256px or above. Setting to 100 will only output faces that have been resized " +"from 512px or above. A setting of 200 will only output faces that have been " +"downscaled from 1024px or above." +msgstr "" +"지정된 추출 크기(`-z`, `--size`)에 맞춰 이 비율 이상으로 크기가 조정된 얼굴" +"만 출력합니다. 저해상도 이미지를 학습 데이터 세트에서 제외하는 데 유용합니" +"다. 모든 얼굴을 출력하려면 0으로 설정하십시오. 이 설정은 디스크에 출력되는 얼" +"굴에만 영향을 미칩니다. 여기에 설정된 값과 관계없이 감지된 모든 얼굴은 정렬 " +"파일에 저장됩니다. 예: 추출 크기가 512px인 경우, 50으로 설정하면 256px 이상에" +"서 크기가 조정된 얼굴만 출력됩니다. 100으로 설정하면 512px 이상에서 크기가 조" +"정된 얼굴만 출력됩니다. 200으로 설정하면 1024px 이상에서 크기가 조정된 얼굴" +"만 출력됩니다." + +#: lib/cli/args_extract_convert.py:389 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -327,43 +389,50 @@ msgstr "" "을 쓸 때 스크립트가 손상될 수 있으므로 스크립트를 중단하지 마십시오. 해제하려" "면 0으로 설정" -#: lib/cli/args_extract_convert.py:357 -msgid "Draw landmarks on the ouput faces for debugging purposes." +#: lib/cli/args_extract_convert.py:400 +msgid "Draw landmarks on the output faces for debugging purposes." msgstr "디버깅을 위해 출력 얼굴에 특징점을 그립니다." -#: lib/cli/args_extract_convert.py:363 lib/cli/args_extract_convert.py:373 -#: lib/cli/args_extract_convert.py:381 lib/cli/args_extract_convert.py:388 -#: lib/cli/args_extract_convert.py:678 lib/cli/args_extract_convert.py:691 -#: lib/cli/args_extract_convert.py:712 lib/cli/args_extract_convert.py:718 +#: lib/cli/args_extract_convert.py:405 lib/cli/args_extract_convert.py:414 +#: lib/cli/args_extract_convert.py:424 lib/cli/args_extract_convert.py:432 +#: lib/cli/args_extract_convert.py:693 lib/cli/args_extract_convert.py:706 +#: lib/cli/args_extract_convert.py:727 lib/cli/args_extract_convert.py:733 msgid "settings" msgstr "설정" -#: lib/cli/args_extract_convert.py:365 +#: lib/cli/args_extract_convert.py:406 msgid "" -"Don't run extraction in parallel. Will run each part of the extraction " -"process separately (one after the other) rather than all at the same time. " -"Useful if VRAM is at a premium." +"Compile any PyTorch models. This will lead to slower start up time, but " +"faster processing. For large amounts of data this is worth enabling. For " +"smaller extractions it is not." msgstr "" -"추출을 병렬로 실행하지 마십시오. 추출 프로세스의 각 부분을 동시에 모두 실행하" -"는 것이 아니라 개별적으로(하나씩) 실행합니다. VRAM이 프리미엄인 경우 유용합니" -"다." +"PyTorch 모델을 컴파일하세요. 이렇게 하면 시작 시간은 느려지지만 처리 속도는 " +"빨라집니다. 데이터 양이 많은 경우에는 이 기능을 활성화하는 것이 좋습니다. 하" +"지만 데이터 추출량이 적은 경우에는 필요하지 않습니다." -#: lib/cli/args_extract_convert.py:375 +#: lib/cli/args_extract_convert.py:415 +msgid "" +"Benchmark the chosen extract plugins for optimal batch sizes. The benchmark " +"profiler can be configured in settings. Note: This will take a long time, so " +"should be used to find optimal settings for a given plugin combination and " +"type of dataset rather than being used every time." +msgstr "" +"선택한 추출 플러그인의 최적 배치 크기를 벤치마킹합니다. 벤치마킹 프로파일러" +"는 설정에서 구성할 수 있습니다. 참고: 이 작업은 시간이 오래 걸리므로 매번 사" +"용하기보다는 특정 플러그인 조합과 데이터셋 유형에 대한 최적 설정을 찾을 때 사" +"용하는 것이 좋습니다." + +#: lib/cli/args_extract_convert.py:426 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" msgstr "이미 추출되었거나 alignments 파일에 존재하는 프레임들을 스킵합니다" -#: lib/cli/args_extract_convert.py:382 +#: lib/cli/args_extract_convert.py:433 msgid "Skip frames that already have detected faces in the alignments file" msgstr "이미 얼굴을 탐지하여 alignments 파일에 존재하는 프레임들을 스킵합니다" -#: lib/cli/args_extract_convert.py:389 -msgid "Skip saving the detected faces to disk. Just create an alignments file" -msgstr "" -"탐지된 얼굴을 디스크에 저장하지 않습니다. 그저 alignments 파일을 만듭니다" - -#: lib/cli/args_extract_convert.py:463 +#: lib/cli/args_extract_convert.py:469 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -371,7 +440,11 @@ msgstr "" "원본 비디오/이미지의 원래 얼굴을 최종 얼굴으로 바꿉니다.\n" "변환 플러그인은 '설정' 메뉴에서 구성할 수 있습니다" -#: lib/cli/args_extract_convert.py:485 +#: lib/cli/args_extract_convert.py:489 +msgid "Output directory. This is where the converted files will be saved." +msgstr "출력 폴더. 변환된 파일들이 저장될 곳입니다." + +#: lib/cli/args_extract_convert.py:498 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -380,14 +453,19 @@ msgstr "" "이미지에서 비디오로 변환하는 경우에만 필요합니다. 소스 프레임이 추출된 원본 " "비디오(fps 및 오디오 추출용)를 입력하세요." -#: lib/cli/args_extract_convert.py:494 +#: lib/cli/args_extract_convert.py:507 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." msgstr "" "모델 폴더. 당신이 변환에 사용하고자 하는 훈련된 모델을 가진 폴더입니다." -#: lib/cli/args_extract_convert.py:505 +#: lib/cli/args_extract_convert.py:516 lib/cli/args_extract_convert.py:544 +#: lib/cli/args_extract_convert.py:583 +msgid "Plugins" +msgstr "플러그인들" + +#: lib/cli/args_extract_convert.py:518 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -423,7 +501,7 @@ msgstr "" "공하지 않습니다.\n" "L|none: 색상 조정을 수행하지 않습니다." -#: lib/cli/args_extract_convert.py:531 +#: lib/cli/args_extract_convert.py:546 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -487,7 +565,7 @@ msgstr "" "L|predicted: 교육 중에 'Learn Mask(마스크 학습)' 옵션이 활성화된 경우에는 교" "육을 받은 모델이 만든 마스크가 사용됩니다." -#: lib/cli/args_extract_convert.py:570 +#: lib/cli/args_extract_convert.py:585 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -517,12 +595,12 @@ msgstr "" "L|pillow: [images] opencv보다 느리지만 더 많은 옵션이 있고 더 많은 형식을 지" "원합니다." -#: lib/cli/args_extract_convert.py:591 lib/cli/args_extract_convert.py:600 -#: lib/cli/args_extract_convert.py:703 +#: lib/cli/args_extract_convert.py:606 lib/cli/args_extract_convert.py:615 +#: lib/cli/args_extract_convert.py:718 msgid "Frame Processing" msgstr "프레임 처리" -#: lib/cli/args_extract_convert.py:593 +#: lib/cli/args_extract_convert.py:608 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -531,7 +609,7 @@ msgstr "" "최종 출력 프레임의 크기를 이 양만큼 조정합니다. 100%%는 원본의 차원에서 프레" "임을 출력합니다. 50%%는 절반 크기에서, 200%%는 두 배 크기에서" -#: lib/cli/args_extract_convert.py:602 +#: lib/cli/args_extract_convert.py:617 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -543,7 +621,13 @@ msgstr "" "으면 선택한 범위를 벗어나는 프레임이 삭제됩니다. NB: 이미지에서 변환하는 경" "우 파일 이름은 프레임 번호로 끝나야 합니다!" -#: lib/cli/args_extract_convert.py:616 +#: lib/cli/args_extract_convert.py:629 lib/cli/args_extract_convert.py:638 +#: lib/cli/args_extract_convert.py:653 lib/cli/args_extract_convert.py:666 +#: lib/cli/args_extract_convert.py:680 +msgid "Face Processing" +msgstr "얼굴 처리" + +#: lib/cli/args_extract_convert.py:631 msgid "" "Scale the swapped face by this percentage. Positive values will enlarge the " "face, Negative values will shrink the face." @@ -551,7 +635,7 @@ msgstr "" "이 백분율로 교체된 면의 크기를 조정합니다. 양수 값은 얼굴을 확대하고, 음수 값" "은 얼굴을 축소합니다." -#: lib/cli/args_extract_convert.py:625 +#: lib/cli/args_extract_convert.py:640 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -565,7 +649,7 @@ msgstr "" "alignments 파일 내에 존재하거나 지정된 폴더 내에 존재하는 얼굴만 변환됩니다. " "이 항목을 공백으로 두면 alignments 파일 내에 있는 모든 얼굴이 변환됩니다." -#: lib/cli/args_extract_convert.py:640 +#: lib/cli/args_extract_convert.py:655 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -578,7 +662,7 @@ msgstr "" "분하여 추가할 수 있습니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감소" "하므로 정확성을 보장할 수 없습니다." -#: lib/cli/args_extract_convert.py:653 +#: lib/cli/args_extract_convert.py:668 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -591,7 +675,7 @@ msgstr "" "가할 수 있습니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감소하므로 정" "확성을 보장할 수 없습니다." -#: lib/cli/args_extract_convert.py:667 +#: lib/cli/args_extract_convert.py:682 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -602,7 +686,7 @@ msgstr "" "값. 낮은 값이 더 엄격합니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감" "소하므로 정확성을 보장할 수 없습니다." -#: lib/cli/args_extract_convert.py:680 +#: lib/cli/args_extract_convert.py:695 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -618,7 +702,7 @@ msgstr "" "를 사용하려고 시도하지 않습니다. 단일 프로세스가 활성화된 경우 이 설정은 무시" "됩니다." -#: lib/cli/args_extract_convert.py:693 +#: lib/cli/args_extract_convert.py:708 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -632,7 +716,7 @@ msgstr "" "하고 표준 이하의 결과로 이어질 것입니다. alignments 파일이 발견되면 이 옵션" "은 무시됩니다." -#: lib/cli/args_extract_convert.py:705 +#: lib/cli/args_extract_convert.py:720 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -640,14 +724,42 @@ msgstr "" "사용시 --frame-ranges 인자를 사용하면 변경되지 않은 프레임을 버리지 않은 결과" "가 출력됩니다." -#: lib/cli/args_extract_convert.py:713 +#: lib/cli/args_extract_convert.py:728 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "모델을 바꿉니다. A -> B에서 변환하는 대신 B -> A로 변환" -#: lib/cli/args_extract_convert.py:719 +#: lib/cli/args_extract_convert.py:734 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "멀티프로세싱을 쓰지 않습니다. 느리지만 자원을 덜 소모합니다." +#~ msgid "" +#~ "Obtain and store face identity encodings from VGGFace2. Slows down " +#~ "extract a little, but will save time if using 'sort by face'" +#~ msgstr "" +#~ "VGGFace2에서 얼굴 식별 인코딩을 가져와 저장합니다. 추출 속도를 약간 늦추지" +#~ "만 '얼굴별로 정렬'을 사용하면 시간을 절약할 수 있습니다." + +#~ msgid "" +#~ "Filters out faces detected below this size. Length, in pixels across the " +#~ "diagonal of the bounding box. Set to 0 for off" +#~ msgstr "" +#~ "이 크기 미만으로 탐지된 얼굴을 필터링합니다. 길이, 경계 상자의 대각선에 걸" +#~ "친 픽셀 단위입니다. 0으로 설정하면 꺼집니다" + +#~ msgid "" +#~ "Don't run extraction in parallel. Will run each part of the extraction " +#~ "process separately (one after the other) rather than all at the same " +#~ "time. Useful if VRAM is at a premium." +#~ msgstr "" +#~ "추출을 병렬로 실행하지 마십시오. 추출 프로세스의 각 부분을 동시에 모두 실" +#~ "행하는 것이 아니라 개별적으로(하나씩) 실행합니다. VRAM이 프리미엄인 경우 " +#~ "유용합니다." + +#~ msgid "" +#~ "Skip saving the detected faces to disk. Just create an alignments file" +#~ msgstr "" +#~ "탐지된 얼굴을 디스크에 저장하지 않습니다. 그저 alignments 파일을 만듭니다" + #~ msgid "" #~ "[LEGACY] This only needs to be selected if a legacy model is being loaded " #~ "or if there are multiple models in the model folder" diff --git a/locales/kr/LC_MESSAGES/tools.alignments.cli.mo b/locales/kr/LC_MESSAGES/tools.alignments.cli.mo index 0c4f74d355c1a51a4af04ca112aea39840c87ff6..dd72cdf093b7c9daef12bb1b9d21b129dde2c59f 100644 GIT binary patch delta 688 zcmZvYPe@cz6vn^jznRq0ar`?Xc{k02Y+g@88QO$2Lv5lzC<M zRv@MUFGGOc0(|NMZUMO53mlVMWM>m_zYn;^_sRgkGQ1@N3J9de;oAW?TZA8C)1T z7-y%4Np%}s8af#f2K=%XY>j2}1*HqSu$IePj>xVSMMk+Q?J|*8M#w@wXQ`t1qwQ{U zT3X(P!0V-jWa`en)b;6^RB}dqb#4Zc(OPUC`E5l1_AU zRDsq)uQ!#BaF^9ti_;kl;Xe=F>;7o}Gp#}1AJ_>`#my`6 z@hLH$xH@5unWmQ<{H7)R@X&l9vQT;QuJUSMRNlNe*(sgumKOco=!a0GQRW!uH=a_@mT`xjiBqig^G delta 662 zcmXw#%WD%+6vn^wm1wQ$V`xqBaaT=6XPngdwhM7%sV=AnAVd&1 zb|cJIm!i0Isvz#%xvq<%DB`Aqxbh$Hn`wKv_x#R19KLhz*_5B;GpGN(02GITxg79! z3}_DlMiICM;K>xQL2i&30A3lu0>jtS0L$==%#%OKz2tB5Fm?WsXZfBy0MyB4vO~Tl zACmJW;1c<=gg)Le*kU4W_+l2gO@TjTm5R#;fmOaA%>iH8akC7#tV@mqCFT#Atm9y3 zEMSp&&j$98Z^#jHeTN@OmSdbZHU1ZqS+tt@%#H4ZTta5)�XVrediL@^WF>Z@Hw* zCg{^`q_&SYUDp2OL#{AfTxzw0P?ts9>oi2Iw}x%WTYGs_(FPa!*3~H3rA{ciIt`s2_7lDK#O}F zw{};Bl##v&mFXz0YTET1+dV9CnfbPiB5~FAvu0&eDZeb{&E08C)m357^pCPGoj|8@ z0b6@Lu{&^9B7EK49@0uq7p{!PYg1oh&*nHeax=J i#?0rzs?;|q-w!#P!?cY7M4^P z0pm6qD;-2_Eb$L7T|&HKG`hWMrcatIdl<$?V|s6OZ}T5!-`}O%3Fm&!J-zpw-~Ig# z54M~SMDO{$aiP`FOX=UdB(fRrzRZR8yGNu2Z{j(u@QT#&%q=YD{#lNQ9}8a*c@?X0 zIX2*8Y{Fb@#XJn-W9-Fhk*IVo5P64*acssva2ZxC6e+=XaS850oe$$A4&YL(VD&=W zge!0tY5;BMCD4A!wTrk$8aIzPlcxE6oGXeEO`8R(bJ zWi4m*+uR3GH}H{n-JlZnxwo=@gdGIhhx*(D(n49~IeyIf@lb~l_pL;4@H*=6_pur? zrQ}~1^sk&xX=B#i$e(m_c>}-0VZ4gl*>Ed$NN|6QI(>p`&3tBtk#b8Mb)(bB<8IRc zbuVp{-d#UO-kVR6PU;!Z^zt#Xp00<+&~?EYx|Tqj){aEEP0OG)t&Xko6h}3w6?E$N ze_O{$PmP`^ZENV7OAeQNOu=m$t~NcwdfK#6KezF(=N@$qmfI@1R?+noO2G7Xb#-tt zG0PLo-SI(FsMiD|hhoLvKu+)BP*2F(=e^@Cs_hK6N5XAi8|KX3aOf**W$vtJMbPvd z4t^doZC%}_GuZQ4IMQwoe$n02)xF`&-}#<`H*4z}YU}Gvef_3}jj_2x+w*pw#9ovg zh&31aqfRnzCsW2A8BEWl>~q(QGnmYLo5;i`oWaY^iLnjFjVEngGus1APU48Me;70N zz=RI7FQ+d|^Sk?U@>JTI(U+%=*i+xv7$BC9#qE-0%1I0w z=jW>~M<YSK12fwVZJrs!$wN>uB Ql4D)+Kk-`e(%qha0cZR4vj6}9 delta 1187 zcmYk*T}YEr7{KwTHZ!Z4u2L&MUi&ml8)_F;T8pUcqPmEXjKWe(vl%Jf6wJzK^o2GT zR!(M&W>-opFjTq=f!yfqqP>HuU$=QbI_&|W z6!YZrw9XRQg5$G!QHE?Hhw%~KMY~!_+w<8Mp$|upH-M z6((>Gt`qT!J5^*W9j9Xu5b?9Wm7uZDmL%PTT zbh8>=&~08C_yMvPiC`7KA4U)DIO+LBd^wUSa-V^%&SWazlUF^8@5tQZVEdZl<)|qy zM@{YSf7aqJ+ReBU8%T@p(1*477K?B_>0E;cQ8#!Vy;=!UnZoC&0lJB%0m7)`Pf_p1 zH0t9~r2a2J8D zFdC%&iu@nN7S2U8@e(;!nMMup8Tq+YbYVuaYF#v@67HfESi4ECb;;R-wJ4phK-MNF zq%JJpDtcBVVy5UBir;Np1a2DU50sQ%)-}am;*jD(iw}~*kJH70~5)QpBQH7q8akV1O5Mf>p$Sk+7Z7s zYDRjj@2~fp13mFT$oMuoVum}-P`B0H6*fbGc%Uy93dgSX8s8pInD<7E6X(iG8ay5| V_#`^$e3lZukTY$Mp3bYZ{RMdx#^3+| diff --git a/locales/kr/LC_MESSAGES/tools.manual.po b/locales/kr/LC_MESSAGES/tools.manual.po index 0fbcc99572..1f7aea14e4 100644 --- a/locales/kr/LC_MESSAGES/tools.manual.po +++ b/locales/kr/LC_MESSAGES/tools.manual.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 23:55+0000\n" -"PO-Revision-Date: 2024-03-29 00:05+0000\n" +"POT-Creation-Date: 2026-03-20 22:06+0000\n" +"PO-Revision-Date: 2026-03-20 22:31+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ko_KR\n" @@ -16,7 +16,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 3.4.2\n" +"X-Generator: Poedit 3.8\n" #: tools/manual/cli.py:13 msgid "" @@ -71,26 +71,101 @@ msgstr "" "합니다. 일부 비디오의 경우 캐싱 프로세스가 중단될 수 있습니다. 이런 경우 이 " "옵션을 설정하여 더 느리지만 안정적인 단일 스레드에서 썸네일를 생성하십시오." -#: tools/manual\faceviewer\frame.py:163 +#: tools/manual/face_viewer/frame.py:175 msgid "Display the landmarks mesh" msgstr "특징점 망 보이기" -#: tools/manual\faceviewer\frame.py:164 +#: tools/manual/face_viewer/frame.py:176 msgid "Display the mask" msgstr "마스크 보이기" -#: tools/manual\frameviewer\editor\_base.py:628 -#: tools/manual\frameviewer\editor\landmarks.py:44 -#: tools/manual\frameviewer\editor\mask.py:75 +#: tools/manual/frame_viewer/frame.py:79 +msgid "Play/Pause (SPACE)" +msgstr "재생/멈춤 (스페이스 바)" + +#: tools/manual/frame_viewer/frame.py:80 +msgid "Go to First Frame (HOME)" +msgstr "첫 번째 프레임으로 이동 (HOME)" + +#: tools/manual/frame_viewer/frame.py:81 +msgid "Go to Previous Frame (Z)" +msgstr "이전 프레임으로 이동 (Z)" + +#: tools/manual/frame_viewer/frame.py:82 +msgid "Go to Next Frame (X)" +msgstr "다음 프레임으로 이동 (X)" + +#: tools/manual/frame_viewer/frame.py:83 +msgid "Go to Last Frame (END)" +msgstr "마지막 프레임으로 이동 (END)" + +#: tools/manual/frame_viewer/frame.py:84 +msgid "Extract the faces to a folder... (Ctrl+E)" +msgstr "폴더에 얼굴 추출... (Ctrl+E)" + +#: tools/manual/frame_viewer/frame.py:85 +msgid "Save the Alignments file (Ctrl+S)" +msgstr "_Alignments file 저장 (Ctrl + S" + +#: tools/manual/frame_viewer/frame.py:86 +msgid "Filter Frames to only those Containing the Selected Item (F)" +msgstr "오로지 선택된 아이템들을 가지고 있는 필터 프레임 (F)" + +#: tools/manual/frame_viewer/frame.py:87 +msgid "" +"Set the distance from an 'average face' to be considered misaligned. Higher " +"distances are more restrictive" +msgstr "" +"'평균 얼굴'로부터의 거리를 잘못 정렬된 것으로 간주하도록 설정. 먼 거리에서 조" +"금 더 제한적입니다" + +#: tools/manual/frame_viewer/frame.py:392 +msgid "View alignments" +msgstr "보기 정렬" + +#: tools/manual/frame_viewer/frame.py:393 +msgid "Bounding box editor" +msgstr "경계 상자 편집기" + +#: tools/manual/frame_viewer/frame.py:394 +msgid "Location editor" +msgstr "위치 편집기" + +#: tools/manual/frame_viewer/frame.py:395 +msgid "Mask editor" +msgstr "마스크 편집기" + +#: tools/manual/frame_viewer/frame.py:396 +msgid "Landmark point editor" +msgstr "특징점 편집기" + +#: tools/manual/frame_viewer/frame.py:471 +msgid "Previous" +msgstr "이전" + +#: tools/manual/frame_viewer/frame.py:472 +msgid "Next" +msgstr "다음" + +#: tools/manual/frame_viewer/frame.py:483 +msgid "Revert to saved Alignments ({})" +msgstr "저장된 Alignments로 돌아가기 ({})" + +#: tools/manual/frame_viewer/frame.py:489 +msgid "Copy {} Alignments ({})" +msgstr "{} Alignments를 복사 ({})" + +#: tools/manual/frame_viewer/editor/_base.py:632 +#: tools/manual/frame_viewer/editor/landmarks.py:45 msgid "Magnify/Demagnify the View" msgstr "보기를 확대/축소 합니다" -#: tools/manual\frameviewer\editor\bounding_box.py:33 -#: tools/manual\frameviewer\editor\extract_box.py:32 +#: tools/manual/frame_viewer/editor/bounding_box.py:34 +#: tools/manual/frame_viewer/editor/extract_box.py:33 msgid "Delete Face" msgstr "얼굴 삭제" -#: tools/manual\frameviewer\editor\bounding_box.py:36 +#: tools/manual/frame_viewer/editor/bounding_box.py:37 msgid "" "Bounding Box Editor\n" "Edit the bounding box being fed into the aligner to recalculate the " @@ -109,16 +184,17 @@ msgstr "" "- 빈 공간을 클릭하여 새 경계 상자를 만듭니다.\n" "- 경계 상자를 마우스 오른쪽 단추로 클릭하여 얼굴을 삭제합니다." -#: tools/manual\frameviewer\editor\bounding_box.py:70 +#: tools/manual/frame_viewer/editor/bounding_box.py:71 msgid "" -"Aligner to use. FAN will obtain better alignments, but cv2-dnn can be useful " -"if FAN cannot get decent alignments and you want to set a base to edit from." +"Aligner to use. HRNet and FAN will obtain better alignments, but cv2-dnn can " +"be useful if these cannot get decent alignments and you want to set a base " +"to edit from." msgstr "" -"사용할 aligner. FAN은 더 나은 alignments을 얻을 수 있지만, 만약 FAN이 적절한 " -"alignments을 얻을 수 없고 편집을 시작할 기준점을 설정하려는 경우 cv2-dnn이 유" -"용할 수 있습니다." +"사용할 정렬 도구를 선택하세요. HRNet과 FAN은 더 나은 정렬 결과를 제공하지만, " +"이 두 도구로 적절한 정렬을 얻을 수 없고 기준점을 설정하여 편집하려는 경우 " +"cv2-dnn도 유용할 수 있습니다." -#: tools/manual\frameviewer\editor\bounding_box.py:83 +#: tools/manual/frame_viewer/editor/bounding_box.py:84 msgid "" "Normalization method to use for feeding faces to the aligner. This can help " "the aligner better align faces with difficult lighting conditions. Different " @@ -140,7 +216,7 @@ msgstr "" "\thist: RGB 채널의 히스토그램을 균등화합니다.\n" "\tmean: 얼굴 색상을 평균으로 정규화합니다." -#: tools/manual\frameviewer\editor\extract_box.py:35 +#: tools/manual/frame_viewer/editor/extract_box.py:36 msgid "" "Extract Box Editor\n" "Move the extract box that has been generated by the aligner. Click and " @@ -157,7 +233,7 @@ msgstr "" "- 특징점들의 크기를 조정하는 corner anchors.\n" "- 모서리를 벗어나 특징점을 회전합니다." -#: tools/manual\frameviewer\editor\landmarks.py:27 +#: tools/manual/frame_viewer/editor/landmarks.py:28 msgid "" "Landmark Point Editor\n" "Edit the individual landmark points.\n" @@ -171,7 +247,7 @@ msgstr "" " - 개별 특징점들을 클릭 & 드래그 하여 재배치합니다.\n" " - 재배치할 여러개의 점들을 박스를 그려서 선택합니다." -#: tools/manual\frameviewer\editor\mask.py:33 +#: tools/manual/frame_viewer/editor/mask.py:43 msgid "" "Mask Editor\n" "Edit the mask.\n" @@ -186,98 +262,30 @@ msgstr "" "보다는 특징점이 올바른지 확인하는 것이 좋습니다. 마스크를 편집한 후 특징점들 " "변경하면 변경된 특징점들이 수동으로 편집한 마스크에 덮어 씌워집니다." -#: tools/manual\frameviewer\editor\mask.py:77 +#: tools/manual/frame_viewer/editor/mask.py:91 +msgid "Magnify/De-magnify the View" +msgstr "보기를 확대/축소 합니다" + +#: tools/manual/frame_viewer/editor/mask.py:93 msgid "Draw Tool" msgstr "그리기 도구" -#: tools/manual\frameviewer\editor\mask.py:78 +#: tools/manual/frame_viewer/editor/mask.py:94 msgid "Erase Tool" msgstr "지우개 도구" -#: tools/manual\frameviewer\editor\mask.py:97 +#: tools/manual/frame_viewer/editor/mask.py:115 msgid "Select which mask to edit" msgstr "편집할 마스크를 선택" -#: tools/manual\frameviewer\editor\mask.py:104 +#: tools/manual/frame_viewer/editor/mask.py:122 msgid "Set the brush size. ([ - decrease, ] - increase)" msgstr "붓 크기 설정. ([ - decrease, ] - increase)" -#: tools/manual\frameviewer\editor\mask.py:111 +#: tools/manual/frame_viewer/editor/mask.py:129 msgid "Select the brush cursor color." msgstr "붓 커서 색깔 선택." -#: tools/manual\frameviewer\frame.py:78 -msgid "Play/Pause (SPACE)" -msgstr "재생/멈춤 (스페이스 바)" - -#: tools/manual\frameviewer\frame.py:79 -msgid "Go to First Frame (HOME)" -msgstr "첫 번째 프레임으로 이동 (HOME)" - -#: tools/manual\frameviewer\frame.py:80 -msgid "Go to Previous Frame (Z)" -msgstr "이전 프레임으로 이동 (Z)" - -#: tools/manual\frameviewer\frame.py:81 -msgid "Go to Next Frame (X)" -msgstr "다음 프레임으로 이동 (X)" - -#: tools/manual\frameviewer\frame.py:82 -msgid "Go to Last Frame (END)" -msgstr "마지막 프레임으로 이동 (END)" - -#: tools/manual\frameviewer\frame.py:83 -msgid "Extract the faces to a folder... (Ctrl+E)" -msgstr "폴더에 얼굴 추출... (Ctrl+E)" - -#: tools/manual\frameviewer\frame.py:84 -msgid "Save the Alignments file (Ctrl+S)" -msgstr "_Alignments file 저장 (Ctrl + S" - -#: tools/manual\frameviewer\frame.py:85 -msgid "Filter Frames to only those Containing the Selected Item (F)" -msgstr "오로지 선택된 아이템들을 가지고 있는 필터 프레임 (F)" - -#: tools/manual\frameviewer\frame.py:86 -msgid "" -"Set the distance from an 'average face' to be considered misaligned. Higher " -"distances are more restrictive" -msgstr "" -"'평균 얼굴'로부터의 거리를 잘못 정렬된 것으로 간주하도록 설정. 먼 거리에서 조" -"금 더 제한적입니다" - -#: tools/manual\frameviewer\frame.py:391 -msgid "View alignments" -msgstr "보기 정렬" - -#: tools/manual\frameviewer\frame.py:392 -msgid "Bounding box editor" -msgstr "경계 상자 편집기" - -#: tools/manual\frameviewer\frame.py:393 -msgid "Location editor" -msgstr "위치 편집기" - -#: tools/manual\frameviewer\frame.py:394 -msgid "Mask editor" -msgstr "마스크 편집기" - -#: tools/manual\frameviewer\frame.py:395 -msgid "Landmark point editor" -msgstr "특징점 편집기" - -#: tools/manual\frameviewer\frame.py:470 -msgid "Next" -msgstr "다음" - -#: tools/manual\frameviewer\frame.py:470 -msgid "Previous" -msgstr "이전" - -#: tools/manual\frameviewer\frame.py:481 -msgid "Revert to saved Alignments ({})" -msgstr "저장된 Alignments로 돌아가기 ({})" - -#: tools/manual\frameviewer\frame.py:487 -msgid "Copy {} Alignments ({})" -msgstr "{} Alignments를 복사 ({})" +#: tools/manual/frame_viewer/editor/mask.py:136 +msgid "Select a shape for masking cursor." +msgstr "붓 커서 색깔 선택." diff --git a/locales/kr/LC_MESSAGES/tools.mask.cli.mo b/locales/kr/LC_MESSAGES/tools.mask.cli.mo index 9de146e0bc584bfbabcb11e7be832b123be9de9c..c4d74e601c0baf61c744929ca073883ae4762e6d 100644 GIT binary patch delta 371 zcmXZWzb`{k6bJC{r92h9QndO*pAbQnSFfsU5{rdRXe2bzN*nUhM@&s>Kw|Qe7ZH<$ zjY*yXX(DC7XfQL0$>b04ZS*ELpLsa(Un<^oX+b)Z6vvnnZ+PT&l$upWUQk&^5MK_d`vXfm) u>q;iOk+z?`cfu8qs9Gc%SE7lju~=ADEz|cZ685tHUA7&=L1BxL$HqS+AwNt2 delta 1048 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 delta 876 zcmZY7OGs2v9LMorGd{;>S4;CTIXYTSPF$}eXa(ZVNQj^or4+SFkQPE4H&<~PlrYSu zO}cYqNjC{Z3?f3Pm5MI3fj|%;7eyEZLf>;7+;rg1=bSs|p2z?HPmk~4^RrG~MzlCj z1JAWnQa28V_@TLB=?!+`Yy6JeIGrcm#L;{yhRb*fvv>g`1=22h*vpzVh0+|}Dw4)o z`){%IPV)7%l*O5tD3dmE7<;g*oQZreitMGm3aJf$;4D^E9*-BXn(+?C@F#vW|DTr5 z<0hWNLwt{wj>M8$#Yy(p7M&?x+^v!(@Ecyo`>Z;Kb7*c{e@5c)`h%QNIx9svU^#wZ zn5;S8_yG4AFVspy_>$dCWDcDlnlSYjy%3}&C5@farKiM8AZB NvDmAkpwHWg`~%mdVgCRC diff --git a/locales/kr/LC_MESSAGES/tools.sort.cli.po b/locales/kr/LC_MESSAGES/tools.sort.cli.po index 19d99c1628..f1dd184ca7 100644 --- a/locales/kr/LC_MESSAGES/tools.sort.cli.po +++ b/locales/kr/LC_MESSAGES/tools.sort.cli.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 23:53+0000\n" -"PO-Revision-Date: 2024-03-29 00:04+0000\n" +"POT-Creation-Date: 2026-03-13 15:17+0000\n" +"PO-Revision-Date: 2026-03-16 18:31+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ko_KR\n" @@ -16,19 +16,19 @@ 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.4.2\n" +"X-Generator: Poedit 3.8\n" -#: tools/sort/cli.py:15 +#: tools/sort/cli.py:17 msgid "This command lets you sort images using various methods." msgstr "이 명령어는 다양한 메소드를 이용하여 이미지를 정렬해줍니다." -#: tools/sort/cli.py:21 +#: tools/sort/cli.py:23 msgid "" " Adjust the '-t' ('--threshold') parameter to control the strength of " "grouping." msgstr " 그룹화의 강도를 제어하기 위해 '-t' ('--threshold') 인자를 조정하세요." -#: tools/sort/cli.py:22 +#: tools/sort/cli.py:24 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. Each image is allocated to a bin by the percentage of color pixels " @@ -37,7 +37,7 @@ msgstr "" " '-b'('--bins') 매개 변수를 조정하여 그룹화할 bins의 수를 제어합니다. 각 이미" "지는 이미지에 나타나는 색상 픽셀의 백분율에 따라 bin에 할당됩니다." -#: tools/sort/cli.py:25 +#: tools/sort/cli.py:27 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. Each image is allocated to a bin by the number of degrees the face " @@ -46,7 +46,7 @@ msgstr "" " '-b'('--bins') 매개 변수를 조정하여 그룹화할 bins의 수를 제어합니다. 각 이미" "지는 얼굴이 이미지 중심에서 떨어진 각도에 따라 bin에 할당됩니다." -#: tools/sort/cli.py:28 +#: tools/sort/cli.py:30 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. The minimum and maximum values are taken for the chosen sort " @@ -56,15 +56,15 @@ msgstr "" "정렬 방법에 대해 최소값과 최대값이 사용됩니다. 그런 다음 bins가 그룹 정렬의 " "결과로 채워집니다." -#: tools/sort/cli.py:32 +#: tools/sort/cli.py:34 msgid "faces by blurriness." msgstr "흐릿한 얼굴." -#: tools/sort/cli.py:33 +#: tools/sort/cli.py:35 msgid "faces by fft filtered blurriness." msgstr "fft 필터링된 흐릿한 얼굴." -#: tools/sort/cli.py:34 +#: tools/sort/cli.py:36 msgid "" "faces by the estimated distance of the alignments from an 'average' face. " "This can be useful for eliminating misaligned faces. Sorts from most like an " @@ -74,7 +74,7 @@ msgstr "" "된 얼굴을 제거하는 데 유용할 수 있습니다. 가장 평균 얼굴에서 가장 덜 평균 얼" "굴순으로 정렬합니다." -#: tools/sort/cli.py:37 +#: tools/sort/cli.py:39 msgid "" "faces using VGG Face2 by face similarity. This uses a pairwise clustering " "algorithm to check the distances between 512 features on every face in your " @@ -84,23 +84,23 @@ msgstr "" "알고리즘을 사용하여 세트의 모든 얼굴에서 512개의 특징 사이의 거리를 확인하고 " "적절하게 정렬합니다." -#: tools/sort/cli.py:40 +#: tools/sort/cli.py:42 msgid "faces by their landmarks." msgstr "특징점이 있는 얼굴." -#: tools/sort/cli.py:41 +#: tools/sort/cli.py:43 msgid "Like 'face-cnn' but sorts by dissimilarity." msgstr "'face-cnn'과 비슷하지만 비유사성에 따라 정렬된." -#: tools/sort/cli.py:42 +#: tools/sort/cli.py:44 msgid "faces by Yaw (rotation left to right)." msgstr "yaw (왼쪽에서 오른쪽으로 회전)에 의한 얼굴." -#: tools/sort/cli.py:43 +#: tools/sort/cli.py:45 msgid "faces by Pitch (rotation up and down)." msgstr "pitch (위에서 아래로 회전)에 의한 얼굴." -#: tools/sort/cli.py:44 +#: tools/sort/cli.py:46 msgid "" "faces by Roll (rotation). Aligned faces should have a roll value close to " "zero. The further the Roll value from zero the higher liklihood the face is " @@ -109,20 +109,20 @@ msgstr "" "이동 (회전)에 의한 얼굴. 정렬된 얼굴들은 0에 가까운 이동 값을 가져야 한다. 이" "동 값이 0에서 멀수록 얼굴들이 잘못 정렬되었을 가능성이 높습니다." -#: tools/sort/cli.py:46 +#: tools/sort/cli.py:48 msgid "faces by their color histogram." msgstr "색상 히스토그램에 의한 얼굴." -#: tools/sort/cli.py:47 +#: tools/sort/cli.py:49 msgid "Like 'hist' but sorts by dissimilarity." msgstr "'hist' 같지만 비유사성에 따라 정렬된." -#: tools/sort/cli.py:48 +#: tools/sort/cli.py:50 msgid "" "images by the average intensity of the converted grayscale color channel." msgstr "변환된 회색 계열 색상 채널의 평균 강도에 따른 이미지." -#: tools/sort/cli.py:49 +#: tools/sort/cli.py:51 msgid "" "images by their number of black pixels. Useful when faces are near borders " "and a large part of the image is black." @@ -130,7 +130,7 @@ msgstr "" "검은색 픽셀의 개수에 따른 이미지들. 얼굴이 테두리 근처에 있고 이미지의 대부분" "이 검은색일 때 유용합니다." -#: tools/sort/cli.py:51 +#: tools/sort/cli.py:53 msgid "" "images by the average intensity of the converted Y color channel. Bright " "lighting and oversaturated images will be ranked first." @@ -138,7 +138,7 @@ msgstr "" "변환된 Y 색상 채널의 평균 강도를 기준으로 한 이미지. 밝은 조명과 과포화 이미" "지가 1위를 차지할 것이다." -#: tools/sort/cli.py:53 +#: tools/sort/cli.py:55 msgid "" "images by the average intensity of the converted Cg color channel. Green " "images will be ranked first and red images will be last." @@ -146,7 +146,7 @@ msgstr "" "변환된 Cg 컬러 채널의 평균 강도를 기준으로 한 이미지. 녹색 이미지가 먼저 순위" "가 매겨지고 빨간색 이미지가 마지막 순위가 됩니다." -#: tools/sort/cli.py:55 +#: tools/sort/cli.py:57 msgid "" "images by the average intensity of the converted Co color channel. Orange " "images will be ranked first and blue images will be last." @@ -154,7 +154,7 @@ msgstr "" "변환된 Co 색상 채널의 평균 강도를 기준으로 한 이미지. 주황색 이미지가 먼저 순" "위가 매겨지고 파란색 이미지가 마지막 순위가 됩니다." -#: tools/sort/cli.py:57 +#: tools/sort/cli.py:59 msgid "" "images by their size in the original frame. Faces further from the camera " "and from lower resolution sources will be sorted first, whilst faces closer " @@ -164,20 +164,28 @@ msgstr "" "해상도 원본에서 온 얼굴이 먼저 정렬되고, 카메라에 더 가까이 있고 고해상도 원" "본에서 온 얼굴이 마지막으로 정렬됩니다." -#: tools/sort/cli.py:81 +#: tools/sort/cli.py:72 +msgid "Sort" +msgstr "종류" + +#: tools/sort/cli.py:73 +msgid "Group" +msgstr "그룹" + +#: tools/sort/cli.py:83 msgid "Sort faces using a number of different techniques" msgstr "얼굴을 정렬하는데 사용되는 서로 다른 기술들의 개수" -#: tools/sort/cli.py:91 tools/sort/cli.py:98 tools/sort/cli.py:110 -#: tools/sort/cli.py:150 +#: tools/sort/cli.py:93 tools/sort/cli.py:100 tools/sort/cli.py:112 +#: tools/sort/cli.py:152 msgid "data" msgstr "데이터" -#: tools/sort/cli.py:92 +#: tools/sort/cli.py:94 msgid "Input directory of aligned faces." msgstr "정렬된 얼굴들의 입력 디렉토리." -#: tools/sort/cli.py:100 +#: tools/sort/cli.py:102 msgid "" "Output directory for sorted aligned faces. If not provided and 'keep' is " "selected then a new folder called 'sorted' will be created within the input " @@ -190,7 +198,7 @@ msgstr "" "다. 제공되지 않고 'keep'을 선택하지 않으면 이미지가 제자리에 정렬되어 " "'input_dir'의 원래 내용을 덮어씁니다." -#: tools/sort/cli.py:112 +#: tools/sort/cli.py:114 msgid "" "R|If selected then the input_dir should be a parent folder containing " "multiple folders of faces you wish to sort. The faces will be output to " @@ -199,11 +207,11 @@ msgstr "" "R|선택되면 input_dir는 정렬할 여러 개의 얼굴 폴더를 포함하는 상위 폴더여야 합" "니다. 얼굴은 output_dir의 별도 하위 폴더로 출력됩니다" -#: tools/sort/cli.py:121 +#: tools/sort/cli.py:123 msgid "sort settings" msgstr "정렬 설정" -#: tools/sort/cli.py:124 +#: tools/sort/cli.py:126 msgid "" "R|Choose how images are sorted. Selecting a sort method gives the images a " "new filename based on the order the image appears within the given method.\n" @@ -219,19 +227,11 @@ msgstr "" "유지합니다. 'sort-by' 및 'group-by' 모두에 대해 'none'을 선택해도 아무 효과" "가 없습니다" -#: tools/sort/cli.py:136 tools/sort/cli.py:164 tools/sort/cli.py:184 +#: tools/sort/cli.py:138 tools/sort/cli.py:166 tools/sort/cli.py:186 msgid "group settings" msgstr "그룹 설정" -#: tools/sort/cli.py:139 -#, fuzzy -#| msgid "" -#| "R|Selecting a group by method will move/copy files into numbered bins " -#| "based on the selected method.\n" -#| "L|'none': Don't bin the images. Folders will be sorted by the selected " -#| "'sort-by' but will not be binned, instead they will be sorted into a " -#| "single folder. Selecting 'none' for both 'sort-by' and 'group-by' will " -#| "do nothing" +#: tools/sort/cli.py:141 msgid "" "R|Selecting a group by method will move/copy files into numbered bins based " "on the selected method.\n" @@ -245,7 +245,7 @@ msgstr "" "만 버려지진 않고 단일 폴더로 정렬됩니다. 'sort-by' 및 'group-by' 모두에 대해 " "'none'을 선택해도 아무 효과가 없습니다" -#: tools/sort/cli.py:152 +#: tools/sort/cli.py:154 msgid "" "Whether to keep the original files in their original location. Choosing a " "'sort-by' method means that the files have to be renamed. Selecting 'keep' " @@ -259,7 +259,7 @@ msgstr "" "된 파일이 지정된 출력 폴더에 생성됩니다. keep을 선택취소하면 선택한 정렬/그" "룹 기준에 따라 원래 파일이 이동되고 이름이 변경됩니다." -#: tools/sort/cli.py:167 +#: tools/sort/cli.py:169 msgid "" "R|Float value. Minimum threshold to use for grouping comparison with 'face-" "cnn' 'hist' and 'face' methods.\n" @@ -284,29 +284,8 @@ msgstr "" "이미지가 많은 디렉터리에서 너무 극단적인 값을 설정하면 폴더가 많이 생성될 수 " "있으므로 주의하십시오. 기본값: face-cnn 7.2, hist 0.3, face 0.25" -#: tools/sort/cli.py:187 -#, fuzzy, python-format -#| msgid "" -#| "R|Integer value. Used to control the number of bins created for grouping " -#| "by: any 'blur' methods, 'color' methods or 'face metric' methods " -#| "('distance', 'size') and 'orientation; methods ('yaw', 'pitch'). For any " -#| "other grouping methods see the '-t' ('--threshold') option.\n" -#| "L|For 'face metric' methods the bins are filled, according the the " -#| "distribution of faces between the minimum and maximum chosen metric.\n" -#| "L|For 'color' methods the number of bins represents the divider of the " -#| "percentage of colored pixels. Eg. For a bin number of '5': The first " -#| "folder will have the faces with 0%% to 20%% colored pixels, second 21%% " -#| "to 40%%, etc. Any empty bins will be deleted, so you may end up with " -#| "fewer bins than selected.\n" -#| "L|For 'blur' methods folder 0 will be the least blurry, while the last " -#| "folder will be the blurriest.\n" -#| "L|For 'orientation' methods the number of bins is dictated by how much " -#| "180 degrees is divided. Eg. If 18 is selected, then each folder will be a " -#| "10 degree increment. Folder 0 will contain faces looking the most to the " -#| "left/down whereas the last folder will contain the faces looking the most " -#| "to the right/up. NB: Some bins may be empty if faces do not fit the " -#| "criteria.\n" -#| "Default value: 5" +#: tools/sort/cli.py:189 +#, python-format msgid "" "R|Integer value. Used to control the number of bins created for grouping by: " "any 'blur' methods, 'color' methods or 'face metric' methods ('distance', " @@ -346,11 +325,27 @@ msgstr "" "니다. 주의: 얼굴이 기준에 맞지 않으면 일부 bins가 비어 있을 수 있습니다.\n" "기본값: 5" -#: tools/sort/cli.py:207 tools/sort/cli.py:217 +#: tools/sort/cli.py:211 tools/sort/cli.py:223 tools/sort/cli.py:233 msgid "settings" msgstr "설정" -#: tools/sort/cli.py:210 +#: tools/sort/cli.py:214 +msgid "" +"R|The identity plugin to use when sorting/grouping by face. \n" +"L|t-face: An InsightFace ResNet based model with a lighter and heavier " +"variant (configurable in settings).\n" +"L|vggface2: An older and lighter, but fairly reliable plugin based on the " +"VGG Network.\n" +"Default: t-face" +msgstr "" +"R|얼굴을 기준으로 정렬/그룹화할 때 사용할 ID 플러그인입니다.\n" +"L|t-face: InsightFace ResNet 기반 모델로, 경량 버전과 중량 버전이 있습니다(설" +"정에서 구성 가능).\n" +"L|vggface2: VGG 네트워크 기반의 구형 플러그인으로, 경량이지만 상당히 안정적입" +"니다.\n" +"기본값: t-face" + +#: tools/sort/cli.py:226 msgid "" "Logs file renaming changes if grouping by renaming, or it logs the file " "copying/movement if grouping by folders. If no log file is specified with " @@ -361,7 +356,7 @@ msgstr "" "로 그룹화하는 경우 파일 복사/이동을 기록합니다. '--log-file'로 로그 파일을 지" "정하지 않으면 'sort_log.json' 파일이 입력 디렉토리에 생성됩니다." -#: tools/sort/cli.py:221 +#: tools/sort/cli.py:237 msgid "" "Specify a log file to use for saving the renaming or grouping information. " "If specified extension isn't 'json' or 'yaml', then json will be used as the " diff --git a/locales/lib.cli.args.pot b/locales/lib.cli.args.pot index 03a77c5a74..4a4e0e0751 100644 --- a/locales/lib.cli.args.pot +++ b/locales/lib.cli.args.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 18:06+0000\n" +"POT-Creation-Date: 2026-03-13 15:17+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,12 +17,12 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: lib/cli/args.py:188 lib/cli/args.py:199 lib/cli/args.py:208 -#: lib/cli/args.py:219 +#: lib/cli/args.py:194 lib/cli/args.py:206 lib/cli/args.py:215 +#: lib/cli/args.py:226 msgid "Global Options" msgstr "" -#: lib/cli/args.py:190 +#: lib/cli/args.py:196 msgid "" "R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " "to any GPU(s) that you do not wish to be made available to Faceswap. " @@ -30,21 +30,21 @@ msgid "" "L|{}" msgstr "" -#: lib/cli/args.py:201 +#: lib/cli/args.py:208 msgid "" -"Optionally overide the saved config with the path to a custom config file." +"Optionally override the saved config with the path to a custom config file." msgstr "" -#: lib/cli/args.py:210 +#: lib/cli/args.py:217 msgid "" "Log level. Stick with INFO or VERBOSE unless you need to file an error " "report. Be careful with TRACE as it will generate a lot of data" msgstr "" -#: lib/cli/args.py:220 +#: lib/cli/args.py:227 msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "" -#: lib/cli/args.py:319 +#: lib/cli/args.py:311 msgid "Output to Shell console instead of GUI console" msgstr "" diff --git a/locales/lib.cli.args_extract_convert.pot b/locales/lib.cli.args_extract_convert.pot index d650f81997..2d638713ee 100644 --- a/locales/lib.cli.args_extract_convert.pot +++ b/locales/lib.cli.args_extract_convert.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-12 11:56+0100\n" +"POT-Creation-Date: 2026-03-20 21:50+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,77 +17,89 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: lib/cli/args_extract_convert.py:46 lib/cli/args_extract_convert.py:56 -#: lib/cli/args_extract_convert.py:64 lib/cli/args_extract_convert.py:122 -#: lib/cli/args_extract_convert.py:483 lib/cli/args_extract_convert.py:492 +#: lib/cli/args_extract_convert.py:47 lib/cli/args_extract_convert.py:58 +#: lib/cli/args_extract_convert.py:108 lib/cli/args_extract_convert.py:116 +#: lib/cli/args_extract_convert.py:490 lib/cli/args_extract_convert.py:498 +#: lib/cli/args_extract_convert.py:507 msgid "Data" msgstr "" -#: lib/cli/args_extract_convert.py:48 +#: lib/cli/args_extract_convert.py:49 msgid "" "Input directory or video. Either a directory containing the image files you " "wish to process or path to a video file. NB: This should be the source video/" "frames NOT the source faces." msgstr "" -#: lib/cli/args_extract_convert.py:57 -msgid "Output directory. This is where the converted files will be saved." -msgstr "" - -#: lib/cli/args_extract_convert.py:66 +#: lib/cli/args_extract_convert.py:60 msgid "" "Optional path to an alignments file. Leave blank if the alignments file is " "at the default location." msgstr "" -#: lib/cli/args_extract_convert.py:97 +#: lib/cli/args_extract_convert.py:83 msgid "" "Extract faces from image or video sources.\n" "Extraction plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args_extract_convert.py:124 +#: lib/cli/args_extract_convert.py:109 msgid "" -"R|If selected then the input_dir should be a parent folder containing " -"multiple videos and/or folders of images you wish to extract from. The faces " -"will be output to separate sub-folders in the output_dir." +"Output directory. Location to save extracted faces. If not provided then " +"don't save faces and just create an alignments file" msgstr "" -#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:152 -#: lib/cli/args_extract_convert.py:167 lib/cli/args_extract_convert.py:206 -#: lib/cli/args_extract_convert.py:224 lib/cli/args_extract_convert.py:237 -#: lib/cli/args_extract_convert.py:247 lib/cli/args_extract_convert.py:257 -#: lib/cli/args_extract_convert.py:503 lib/cli/args_extract_convert.py:529 -#: lib/cli/args_extract_convert.py:568 -msgid "Plugins" +#: lib/cli/args_extract_convert.py:118 +msgid "" +"If selected then the input_dir should be a parent folder containing multiple " +"videos and/or folders of images you wish to extract from. The faces will be " +"output to separate sub-folders in the output_dir." +msgstr "" + +#: lib/cli/args_extract_convert.py:127 lib/cli/args_extract_convert.py:217 +#: lib/cli/args_extract_convert.py:230 lib/cli/args_extract_convert.py:240 +msgid "Detect" msgstr "" -#: lib/cli/args_extract_convert.py:135 +#: lib/cli/args_extract_convert.py:129 msgid "" "R|Detector to use. Some of these have configurable settings in '/config/" "extract.ini' or 'Settings > Configure Extract 'Plugins':\n" "L|cv2-dnn: A CPU only extractor which is the least reliable and least " -"resource intensive. Use this if not using a GPU and time is important.\n" -"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " -"than other GPU detectors but can often return more false positives.\n" -"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " +"resource intensive. Use this only as a last resort. Both MTCNN and " +"RetinaFace have variants that will perform better on CPU.\n" +"L|mtcnn: Average detector. Fast on CPU, faster on GPU. Uses fewer resources " +"than other GPU detectors but can often return more false positives or misses " +"faces.\n" +"L|retinaface: Good detector. Faster and lighter than S3FD but of similar " +"quality. A ResNet and MobileNet version are available (configurable in " +"Detect settings). The MobileNet version is light enough to run on CPU.\n" +"L|s3fd: Good detector. Slow on CPU, faster on GPU. Can detect more faces and " "fewer false positives than other GPU detectors, but is a lot more resource " -"intensive.\n" -"L|external: Import a face detection bounding box from a json file. " -"(configurable in Detect settings)" +"intensive." +msgstr "" + +#: lib/cli/args_extract_convert.py:149 lib/cli/args_extract_convert.py:253 +#: lib/cli/args_extract_convert.py:271 lib/cli/args_extract_convert.py:284 +#: lib/cli/args_extract_convert.py:294 +msgid "Align" msgstr "" -#: lib/cli/args_extract_convert.py:154 +#: lib/cli/args_extract_convert.py:151 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " "but less accurate. Only use this if not using a GPU and time is important.\n" -"L|fan: Best aligner. Fast on GPU, slow on CPU.\n" -"L|external: Import 68 point 2D landmarks or an aligned bounding box from a " -"json file. (configurable in Align settings)" +"L|fan: Good aligner. Fast on GPU, slow on CPU.\n" +"L|hrnet: Best aligner. Faster and more performant than FAN. Trained on a " +"custom set of fully rotated faces. Fast on GPU, slow on CPU" +msgstr "" + +#: lib/cli/args_extract_convert.py:163 +msgid "Mask" msgstr "" -#: lib/cli/args_extract_convert.py:169 +#: lib/cli/args_extract_convert.py:165 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -122,7 +134,44 @@ msgid "" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" msgstr "" -#: lib/cli/args_extract_convert.py:208 +#: lib/cli/args_extract_convert.py:201 lib/cli/args_extract_convert.py:306 +#: lib/cli/args_extract_convert.py:319 lib/cli/args_extract_convert.py:333 +msgid "Identity" +msgstr "" + +#: lib/cli/args_extract_convert.py:203 +msgid "" +"R|Obtain and store face identity encodings. Slows down extract a little but " +"will save time if using 'sort by face'. Required for face filtering.\n" +"L|t-face: An InsightFace ResNet based model with a lighter and heavier " +"variant (configurable in settings).\n" +"L|vggface2: An older and lighter, but fairly reliable plugin based on the " +"VGG Network." +msgstr "" + +#: lib/cli/args_extract_convert.py:219 +msgid "" +"Filters out detections below this percentage of the shortest side of the " +"frame along the face detection box's longest edge. (eg: a value of 10 will " +"filter out faces smaller than 72px from a 720p image). 0 for disabled." +msgstr "" + +#: lib/cli/args_extract_convert.py:232 +msgid "" +"Filters out detections above this percentage of the shortest side of the " +"frame along the face detection box's longest edge. (eg: a value of 200 will " +"filter out faces larger than 1440px from a 720p image). 0 for disabled." +msgstr "" + +#: lib/cli/args_extract_convert.py:242 +msgid "" +"If a face isn't found, rotate the images to try to find a face. Can find " +"more faces at the cost of extraction speed. Pass in a single number to use " +"increments of that size up to 360, or pass in a list of numbers to enumerate " +"exactly what angles to check." +msgstr "" + +#: lib/cli/args_extract_convert.py:255 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -135,7 +184,7 @@ msgid "" "L|mean: Normalize the face colors to the mean." msgstr "" -#: lib/cli/args_extract_convert.py:226 +#: lib/cli/args_extract_convert.py:273 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -145,42 +194,21 @@ msgid "" "occur but the longer extraction will take." msgstr "" -#: lib/cli/args_extract_convert.py:239 +#: lib/cli/args_extract_convert.py:286 msgid "" "Re-feed the initially found aligned face through the aligner. Can help " "produce better alignments for faces that are rotated beyond 45 degrees in " "the frame or are at extreme angles. Slows down extraction." msgstr "" -#: lib/cli/args_extract_convert.py:249 +#: lib/cli/args_extract_convert.py:296 msgid "" -"If a face isn't found, rotate the images to try to find a face. Can find " -"more faces at the cost of extraction speed. Pass in a single number to use " -"increments of that size up to 360, or pass in a list of numbers to enumerate " -"exactly what angles to check." -msgstr "" - -#: lib/cli/args_extract_convert.py:259 -msgid "" -"Obtain and store face identity encodings from VGGFace2. Slows down extract a " -"little, but will save time if using 'sort by face'" -msgstr "" - -#: lib/cli/args_extract_convert.py:269 lib/cli/args_extract_convert.py:280 -#: lib/cli/args_extract_convert.py:293 lib/cli/args_extract_convert.py:307 -#: lib/cli/args_extract_convert.py:614 lib/cli/args_extract_convert.py:623 -#: lib/cli/args_extract_convert.py:638 lib/cli/args_extract_convert.py:651 -#: lib/cli/args_extract_convert.py:665 -msgid "Face Processing" +"Enable aligner filters. This allows the filtering out of faces based on " +"certain statistics and characteristics. Configurable in extract settings. " +"Slows down extraction." msgstr "" -#: lib/cli/args_extract_convert.py:271 -msgid "" -"Filters out faces detected below this size. Length, in pixels across the " -"diagonal of the bounding box. Set to 0 for off" -msgstr "" - -#: lib/cli/args_extract_convert.py:282 +#: lib/cli/args_extract_convert.py:308 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -188,7 +216,7 @@ msgid "" "or multiple image files, space separated, can be selected." msgstr "" -#: lib/cli/args_extract_convert.py:295 +#: lib/cli/args_extract_convert.py:321 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -196,32 +224,46 @@ msgid "" "image files, space separated, can be selected." msgstr "" -#: lib/cli/args_extract_convert.py:309 +#: lib/cli/args_extract_convert.py:335 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." msgstr "" -#: lib/cli/args_extract_convert.py:318 lib/cli/args_extract_convert.py:331 -#: lib/cli/args_extract_convert.py:344 lib/cli/args_extract_convert.py:356 +#: lib/cli/args_extract_convert.py:344 lib/cli/args_extract_convert.py:357 +#: lib/cli/args_extract_convert.py:370 lib/cli/args_extract_convert.py:389 +#: lib/cli/args_extract_convert.py:401 msgid "output" msgstr "" -#: lib/cli/args_extract_convert.py:320 +#: lib/cli/args_extract_convert.py:346 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" "res models." msgstr "" -#: lib/cli/args_extract_convert.py:333 +#: lib/cli/args_extract_convert.py:359 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " "of 10 will extract faces from every 10th frame." msgstr "" -#: lib/cli/args_extract_convert.py:346 +#: lib/cli/args_extract_convert.py:372 +msgid "" +"Only output faces that have been resized by this percent or more to meet the " +"specified extract size (`-z`, `--size`). Useful for excluding low-res images " +"from a training set. Set to 0 to output all faces. This only impacts faces " +"that are output to disk. All detected faces will still be saved to the " +"alignments file regardless of what is set here. Eg: For an extract size of " +"512px, A setting of 50 will only output faces that have been resized from " +"256px or above. Setting to 100 will only output faces that have been resized " +"from 512px or above. A setting of 200 will only output faces that have been " +"downscaled from 1024px or above." +msgstr "" + +#: lib/cli/args_extract_convert.py:391 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -231,58 +273,71 @@ msgid "" "turn off" msgstr "" -#: lib/cli/args_extract_convert.py:357 -msgid "Draw landmarks on the ouput faces for debugging purposes." +#: lib/cli/args_extract_convert.py:402 +msgid "Draw landmarks on the output faces for debugging purposes." msgstr "" -#: lib/cli/args_extract_convert.py:363 lib/cli/args_extract_convert.py:373 -#: lib/cli/args_extract_convert.py:381 lib/cli/args_extract_convert.py:388 -#: lib/cli/args_extract_convert.py:678 lib/cli/args_extract_convert.py:691 -#: lib/cli/args_extract_convert.py:712 lib/cli/args_extract_convert.py:718 +#: lib/cli/args_extract_convert.py:407 lib/cli/args_extract_convert.py:416 +#: lib/cli/args_extract_convert.py:426 lib/cli/args_extract_convert.py:434 +#: lib/cli/args_extract_convert.py:695 lib/cli/args_extract_convert.py:708 +#: lib/cli/args_extract_convert.py:729 lib/cli/args_extract_convert.py:735 msgid "settings" msgstr "" -#: lib/cli/args_extract_convert.py:365 +#: lib/cli/args_extract_convert.py:408 +msgid "" +"Compile any PyTorch models. This will lead to slower start up time, but " +"faster processing. For large amounts of data this is worth enabling. For " +"smaller extractions it is not." +msgstr "" + +#: lib/cli/args_extract_convert.py:417 msgid "" -"Don't run extraction in parallel. Will run each part of the extraction " -"process separately (one after the other) rather than all at the same time. " -"Useful if VRAM is at a premium." +"Benchmark the chosen extract plugins for optimal batch sizes. The benchmark " +"profiler can be configured in settings. Note: This will take a long time, so " +"should be used to find optimal settings for a given plugin combination and " +"type of dataset rather than being used every time." msgstr "" -#: lib/cli/args_extract_convert.py:375 +#: lib/cli/args_extract_convert.py:428 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" msgstr "" -#: lib/cli/args_extract_convert.py:382 +#: lib/cli/args_extract_convert.py:435 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" -#: lib/cli/args_extract_convert.py:389 -msgid "Skip saving the detected faces to disk. Just create an alignments file" -msgstr "" - -#: lib/cli/args_extract_convert.py:463 +#: lib/cli/args_extract_convert.py:471 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args_extract_convert.py:485 +#: lib/cli/args_extract_convert.py:491 +msgid "Output directory. This is where the converted files will be saved." +msgstr "" + +#: lib/cli/args_extract_convert.py:500 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " "audio)." msgstr "" -#: lib/cli/args_extract_convert.py:494 +#: lib/cli/args_extract_convert.py:509 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." msgstr "" -#: lib/cli/args_extract_convert.py:505 +#: lib/cli/args_extract_convert.py:518 lib/cli/args_extract_convert.py:546 +#: lib/cli/args_extract_convert.py:585 +msgid "Plugins" +msgstr "" + +#: lib/cli/args_extract_convert.py:520 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -303,7 +358,7 @@ msgid "" "L|none: Don't perform color adjustment." msgstr "" -#: lib/cli/args_extract_convert.py:531 +#: lib/cli/args_extract_convert.py:548 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -340,7 +395,7 @@ msgid "" "will use the mask that was created by the trained model." msgstr "" -#: lib/cli/args_extract_convert.py:570 +#: lib/cli/args_extract_convert.py:587 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -359,19 +414,19 @@ msgid "" "more formats." msgstr "" -#: lib/cli/args_extract_convert.py:591 lib/cli/args_extract_convert.py:600 -#: lib/cli/args_extract_convert.py:703 +#: lib/cli/args_extract_convert.py:608 lib/cli/args_extract_convert.py:617 +#: lib/cli/args_extract_convert.py:720 msgid "Frame Processing" msgstr "" -#: lib/cli/args_extract_convert.py:593 +#: lib/cli/args_extract_convert.py:610 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " "at source dimensions. 50%% at half size 200%% at double size" msgstr "" -#: lib/cli/args_extract_convert.py:602 +#: lib/cli/args_extract_convert.py:619 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -379,13 +434,19 @@ msgid "" "converting from images, then the filenames must end with the frame-number!" msgstr "" -#: lib/cli/args_extract_convert.py:616 +#: lib/cli/args_extract_convert.py:631 lib/cli/args_extract_convert.py:640 +#: lib/cli/args_extract_convert.py:655 lib/cli/args_extract_convert.py:668 +#: lib/cli/args_extract_convert.py:682 +msgid "Face Processing" +msgstr "" + +#: lib/cli/args_extract_convert.py:633 msgid "" "Scale the swapped face by this percentage. Positive values will enlarge the " "face, Negative values will shrink the face." msgstr "" -#: lib/cli/args_extract_convert.py:625 +#: lib/cli/args_extract_convert.py:642 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -395,7 +456,7 @@ msgid "" "alignments file." msgstr "" -#: lib/cli/args_extract_convert.py:640 +#: lib/cli/args_extract_convert.py:657 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -404,7 +465,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args_extract_convert.py:653 +#: lib/cli/args_extract_convert.py:670 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -413,7 +474,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args_extract_convert.py:667 +#: lib/cli/args_extract_convert.py:684 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -421,7 +482,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args_extract_convert.py:680 +#: lib/cli/args_extract_convert.py:697 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -431,7 +492,7 @@ msgid "" "your system. If singleprocess is enabled this setting will be ignored." msgstr "" -#: lib/cli/args_extract_convert.py:693 +#: lib/cli/args_extract_convert.py:710 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -440,16 +501,16 @@ msgid "" "alignments file is found, this option will be ignored." msgstr "" -#: lib/cli/args_extract_convert.py:705 +#: lib/cli/args_extract_convert.py:722 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." msgstr "" -#: lib/cli/args_extract_convert.py:713 +#: lib/cli/args_extract_convert.py:730 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" -#: lib/cli/args_extract_convert.py:719 +#: lib/cli/args_extract_convert.py:736 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "" diff --git a/locales/plugins.extract.extract_config.pot b/locales/plugins.extract.extract_config.pot index fc012eb3ff..4cefc6cb2b 100644 --- a/locales/plugins.extract.extract_config.pot +++ b/locales/plugins.extract.extract_config.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-12-12 13:11+0000\n" +"POT-Creation-Date: 2026-03-13 15:17+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -21,11 +21,10 @@ msgstr "" msgid "Options that apply to all extraction plugins" msgstr "" -#: plugins/extract/extract_config.py:30 plugins/extract/extract_config.py:45 -#: plugins/extract/extract_config.py:60 plugins/extract/extract_config.py:72 -#: plugins/extract/extract_config.py:85 plugins/extract/extract_config.py:95 -#: plugins/extract/extract_config.py:107 -msgid "filters" +#: plugins/extract/extract_config.py:30 plugins/extract/extract_config.py:44 +#: plugins/extract/extract_config.py:57 plugins/extract/extract_config.py:68 +#: plugins/extract/extract_config.py:80 +msgid "align" msgstr "" #: plugins/extract/extract_config.py:32 @@ -38,7 +37,7 @@ msgid "" "extreme long-shots. These can be usually be safely discarded." msgstr "" -#: plugins/extract/extract_config.py:47 +#: plugins/extract/extract_config.py:46 msgid "" "Filters out faces above this size. This is a multiplier of the minimum " "dimension of the frame (i.e. 1280x720 = 720). If the original face extract " @@ -48,58 +47,76 @@ msgid "" "extreme close-ups. These can be usually be safely discarded." msgstr "" -#: plugins/extract/extract_config.py:62 +#: plugins/extract/extract_config.py:59 msgid "" "Filters out faces who's landmarks are above this distance from an 'average' " "face. Values above 15 tend to be fairly safe. Values above 10 will remove " "more false positives, but may also filter out some faces at extreme angles." msgstr "" -#: plugins/extract/extract_config.py:74 +#: plugins/extract/extract_config.py:70 msgid "" "Filters out faces who's calculated roll is greater than zero +/- this value " "in degrees. Aligned faces should have a roll value close to zero. Values " "that are a significant distance from 0 degrees tend to be misaligned images. " -"These can usually be safely disgarded." +"These can usually be safely discarded." msgstr "" -#: plugins/extract/extract_config.py:87 +#: plugins/extract/extract_config.py:82 msgid "" "Filters out faces where the lowest point of the aligned face's eye or " "eyebrow is lower than the highest point of the aligned face's mouth. Any " -"faces where this occurs are misaligned and can be safely disgarded." +"faces where this occurs are misaligned and can be safely discarded." +msgstr "" + +#: plugins/extract/extract_config.py:89 +msgid "mask" +msgstr "" + +#: plugins/extract/extract_config.py:90 +msgid "" +"The size to store masks at. Set to 0 to store at the mask model's output " +"size." +msgstr "" + +#: plugins/extract/extract_config.py:97 plugins/extract/extract_config.py:106 +#: plugins/extract/extract_config.py:115 plugins/extract/extract_config.py:127 +#: plugins/extract/extract_config.py:139 +msgid "profile" msgstr "" -#: plugins/extract/extract_config.py:97 +#: plugins/extract/extract_config.py:98 msgid "" -"If enabled, and 're-feed' has been selected for extraction, then interim " -"alignments will be filtered prior to averaging the final landmarks. This can " -"help improve the final alignments by removing any obvious misaligns from the " -"interim results, and may also help pick up difficult alignments. If " -"disabled, then all re-feed results will be averaged." +"The number of seconds to warmup the model for at each batch size. Higher " +"times will take longer but will collect better data." msgstr "" -#: plugins/extract/extract_config.py:109 +#: plugins/extract/extract_config.py:107 msgid "" -"If enabled, saves any filtered out images into a sub-folder during the " -"extraction process. If disabled, filtered faces are deleted. Note: The faces " -"will always be filtered out of the alignments file, regardless of whether " -"you keep the faces or not." +"The number of seconds to profile the pipeline for at each batch size. Higher " +"times will take longer but will collect better data." msgstr "" -#: plugins/extract/extract_config.py:118 plugins/extract/extract_config.py:128 -msgid "re-align" +#: plugins/extract/extract_config.py:116 +msgid "" +"The average number of faces expected to be detected in each frame. " +"Throughput of detector plugins are dictated by 1 image = 1 sample, however " +"throughput of downstream plugins (align, mask etc) is dependant on how many " +"faces are expected to be seen in each frame. This will vary from source to " +"source. Setting this correctly will lead to better optimization." msgstr "" -#: plugins/extract/extract_config.py:120 +#: plugins/extract/extract_config.py:128 msgid "" -"If enabled, and 're-align' has been selected for extraction, then all re-" -"feed iterations are re-aligned. If disabled, then only the final averaged " -"output from re-feed will be re-aligned." +"The maximum amount of total GPU VRAM to allow Cuda to reserve when searching " +"for optimal batch sizes. The closer to 100% the more risk of Out of Memory " +"errors whilst extracting. Anything 90% (85% if compiling) or below should be " +"relatively safe for dedicated use, or set the value lower if you wish to " +"keep VRAM free for other applications." msgstr "" -#: plugins/extract/extract_config.py:130 +#: plugins/extract/extract_config.py:140 msgid "" -"If enabled, and 're-align' has been selected for extraction, then any " -"alignments which would be filtered out will not be re-aligned." +"Whether to save the discovered plugin batch sizes to Faceswap's config for " +"future use." msgstr "" diff --git a/locales/plugins.train.train_config.pot b/locales/plugins.train.train_config.pot index c9bff80353..43d7cc294b 100644 --- a/locales/plugins.train.train_config.pot +++ b/locales/plugins.train.train_config.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-12-13 13:39+0000\n" +"POT-Creation-Date: 2026-03-13 15:17+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -289,8 +289,8 @@ msgstr "" #: plugins/train/train_config.py:283 msgid "" -"Multiscale Structural Similarity Index Metric is similar to SSIM except that " -"it performs the calculations along multiple scales of the input image." +"Multi-scale Structural Similarity Index Metric is similar to SSIM except " +"that it performs the calculations along multiple scales of the input image." msgstr "" #: plugins/train/train_config.py:286 @@ -367,7 +367,7 @@ msgid "" "its full amount towards the overall loss score. \n" "\t 25 - The loss calculated for the second loss function will be reduced by " "a quarter prior to adding to the overall loss score. \n" -"\t 400 - The loss calculated for the second loss function will be mulitplied " +"\t 400 - The loss calculated for the second loss function will be multiplied " "4 times prior to adding to the overall loss score. \n" "\t 0 - Disables the second loss function altogether." msgstr "" @@ -395,7 +395,7 @@ msgid "" "its full amount towards the overall loss score. \n" "\t 25 - The loss calculated for the third loss function will be reduced by a " "quarter prior to adding to the overall loss score. \n" -"\t 400 - The loss calculated for the third loss function will be mulitplied " +"\t 400 - The loss calculated for the third loss function will be multiplied " "4 times prior to adding to the overall loss score. \n" "\t 0 - Disables the third loss function altogether." msgstr "" @@ -423,7 +423,7 @@ msgid "" "its full amount towards the overall loss score. \n" "\t 25 - The loss calculated for the fourth loss function will be reduced by " "a quarter prior to adding to the overall loss score. \n" -"\t 400 - The loss calculated for the fourth loss function will be mulitplied " +"\t 400 - The loss calculated for the fourth loss function will be multiplied " "4 times prior to adding to the overall loss score. \n" "\t 0 - Disables the fourth loss function altogether." msgstr "" @@ -474,9 +474,9 @@ msgid "" "attention on the core face area." msgstr "" -#: plugins/train/train_config.py:473 plugins/train/train_config.py:514 -#: plugins/train/train_config.py:525 plugins/train/train_config.py:539 -#: plugins/train/train_config.py:549 +#: plugins/train/train_config.py:473 plugins/train/train_config.py:515 +#: plugins/train/train_config.py:526 plugins/train/train_config.py:540 +#: plugins/train/train_config.py:550 msgid "mask" msgstr "" @@ -487,45 +487,45 @@ msgid "" "required mask should have been selected as part of the Extract process. If " "it does not exist in the alignments file then it will be generated prior to " "training commencing.\n" -"\tnone: Don't use a mask.\n" -"\tbisenet-fp_face: Relatively lightweight NN based mask that provides more " +"\t none: Don't use a mask.\n" +"\t bisenet-fp_face: Relatively lightweight NN based mask that provides more " "refined control over the area to be masked (configurable in mask settings). " "Use this version of bisenet-fp if your model is trained with 'face' or " "'legacy' centering.\n" -"\tbisenet-fp_head: Relatively lightweight NN based mask that provides more " +"\t bisenet-fp_head: Relatively lightweight NN based mask that provides more " "refined control over the area to be masked (configurable in mask settings). " "Use this version of bisenet-fp if your model is trained with 'head' " "centering.\n" -"\tcomponents: Mask designed to provide facial segmentation based on the " +"\t 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.\n" -"\tcustom_face: Custom user created, face centered mask.\n" -"\tcustom_head: Custom user created, head centered mask.\n" -"\textended: Mask designed to provide facial segmentation based on the " +"\t custom_face: Custom user created, face centered mask.\n" +"\t custom_head: Custom user created, head centered mask.\n" +"\t 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.\n" -"\tvgg-clear: Mask designed to provide smart segmentation of mostly frontal " +"\t 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.\n" -"\tvgg-obstructed: Mask designed to provide smart segmentation of mostly " +"\t 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.\n" -"\tunet-dfl: Mask designed to provide smart segmentation of mostly frontal " +"\t 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." msgstr "" -#: plugins/train/train_config.py:516 +#: plugins/train/train_config.py:517 msgid "" "Dilate or erode the mask. Negative values erode the mask (make it smaller). " "Positive values dilate the mask (make it larger). The value given is a " "percentage of the total mask size." msgstr "" -#: plugins/train/train_config.py:527 +#: plugins/train/train_config.py:528 msgid "" "Apply gaussian blur to the mask input. This has the effect of smoothing the " "edges of the mask, which can help with poorly calculated masks and give less " @@ -535,34 +535,34 @@ msgid "" "number." msgstr "" -#: plugins/train/train_config.py:541 +#: plugins/train/train_config.py:542 msgid "" "Sets pixels that are near white to white and near black to black. Set to 0 " "for off." msgstr "" -#: plugins/train/train_config.py:551 +#: plugins/train/train_config.py:552 msgid "" "Dedicate a portion of the model to learning how to duplicate the input mask. " "Increases VRAM usage in exchange for learning a quick ability to try to " "replicate more complex mask models." msgstr "" -#: plugins/train/train_config.py:559 +#: plugins/train/train_config.py:560 msgid "" "Optimizer configuration options\n" "The optimizer applies the output of the loss function to the model.\n" msgstr "" -#: plugins/train/train_config.py:565 plugins/train/train_config.py:600 -#: plugins/train/train_config.py:613 plugins/train/train_config.py:634 +#: plugins/train/train_config.py:566 plugins/train/train_config.py:601 +#: plugins/train/train_config.py:614 plugins/train/train_config.py:635 msgid "optimizer" msgstr "" -#: plugins/train/train_config.py:567 +#: plugins/train/train_config.py:568 msgid "" "The optimizer to use.\n" -"\t adabelief - Adapting Stepsizes by the Belief in Observed Gradients. An " +"\t adabelief - Adapting Step-sizes by the Belief in Observed Gradients. An " "optimizer with the aim to converge faster, generalize better and remain more " "stable. (https://arxiv.org/abs/2010.07468). NB: Epsilon for AdaBelief needs " "to be set to a smaller value than other Optimizers. Generally setting the " @@ -590,7 +590,7 @@ msgid "" "this average." msgstr "" -#: plugins/train/train_config.py:602 +#: plugins/train/train_config.py:603 msgid "" "Learning rate - how fast your network will learn (how large are the " "modifications to the model weights after one batch of training). Values that " @@ -599,7 +599,7 @@ msgid "" "escape from dead-ends and find the best global minimum." msgstr "" -#: plugins/train/train_config.py:615 +#: plugins/train/train_config.py:616 msgid "" "The epsilon adds a small constant to weight updates to attempt to avoid " "'divide by zero' errors. Unless you are using the AdaBelief Optimizer, then " @@ -616,7 +616,7 @@ msgid "" "Note: Not used by the Lion optimizer" msgstr "" -#: plugins/train/train_config.py:636 +#: plugins/train/train_config.py:637 msgid "" "When to save the Optimizer Weights. Saving the optimizer weights is not " "necessary and will increase the model file size 3x (and by extension the " @@ -635,27 +635,27 @@ msgid "" "optimizer weights will NOT be saved." msgstr "" -#: plugins/train/train_config.py:657 plugins/train/train_config.py:676 -#: plugins/train/train_config.py:695 +#: plugins/train/train_config.py:658 plugins/train/train_config.py:677 +#: plugins/train/train_config.py:696 msgid "clipping" msgstr "" -#: plugins/train/train_config.py:659 +#: plugins/train/train_config.py:660 msgid "" "Apply clipping to the gradients. Can help prevent NaNs and improve model " "optimization at the expense of VRAM.\n" -"\tautoclip: Analyzes the gradient weights and adjusts the normalization " +"\t autoclip: Analyzes the gradient weights and adjusts the normalization " "value dynamically to fit the data\n" -"\tglobal_norm: Clips the gradient of each weight so that the global norm is " +"\t global_norm: Clips the gradient of each weight so that the global norm is " "no higher than the given value.\n" -"\tnorm: Clips the gradient of each weight so that its norm is no higher than " -"the given value.\n" -"\tvalue: Clips the gradient of each weight so that it is no higher than the " +"\t norm: Clips the gradient of each weight so that its norm is no higher " +"than the given value.\n" +"\t value: Clips the gradient of each weight so that it is no higher than the " "given value.\n" -"\tnone: Don't perform any clipping to the gradients." +"\t none: Don't perform any clipping to the gradients." msgstr "" -#: plugins/train/train_config.py:678 +#: plugins/train/train_config.py:679 msgid "" "The amount of clipping to perform.\n" "\tautoclip: The percentile to clip at. A value of 1.0 will clip at the 10th " @@ -670,24 +670,24 @@ msgid "" "\tnone: This option is ignored." msgstr "" -#: plugins/train/train_config.py:697 +#: plugins/train/train_config.py:698 msgid "" -"The maximum number of prior iterations for autoclipper to analyze when " +"The maximum number of prior iterations for auto-clipper to analyze when " "calculating the normalization amount. 0 to always include all prior " "iterations." msgstr "" -#: plugins/train/train_config.py:706 plugins/train/train_config.py:715 +#: plugins/train/train_config.py:707 plugins/train/train_config.py:716 msgid "updates" msgstr "" -#: plugins/train/train_config.py:707 +#: plugins/train/train_config.py:708 msgid "" "If set, weight decay is applied. 0.0 for no weight decay. Default is 0.0 for " "all optimizers except AdamW (0.004)" msgstr "" -#: plugins/train/train_config.py:717 +#: plugins/train/train_config.py:718 msgid "" "Values above 1 will enable Gradient Accumulation. Updates will not be at " "every iteration; instead they will occur every number of iterations given " @@ -696,12 +696,12 @@ msgid "" "gradient noise at each update iteration." msgstr "" -#: plugins/train/train_config.py:728 plugins/train/train_config.py:738 -#: plugins/train/train_config.py:749 +#: plugins/train/train_config.py:729 plugins/train/train_config.py:739 +#: plugins/train/train_config.py:750 msgid "exponential moving average" msgstr "" -#: plugins/train/train_config.py:730 +#: plugins/train/train_config.py:731 msgid "" "Enable exponential moving average (EMA). EMA consists of computing an " "exponential moving average of the weights of the model (as the weight values " @@ -709,39 +709,39 @@ msgid "" "with their moving average" msgstr "" -#: plugins/train/train_config.py:740 +#: plugins/train/train_config.py:741 msgid "" "Only used if use_ema is enabled. This is the momentum to use when computing " "the EMA of the model's weights: new_average = ema_momentum * old_average + " "(1 - ema_momentum) * current_variable_value." msgstr "" -#: plugins/train/train_config.py:751 +#: plugins/train/train_config.py:752 msgid "" "Only used if use_ema is enabled. Set the number of iterations, to overwrite " "the model variable by its moving average. " msgstr "" -#: plugins/train/train_config.py:759 plugins/train/train_config.py:770 -#: plugins/train/train_config.py:781 +#: plugins/train/train_config.py:760 plugins/train/train_config.py:771 +#: plugins/train/train_config.py:782 msgid "optimizer specific" msgstr "" -#: plugins/train/train_config.py:761 +#: plugins/train/train_config.py:762 msgid "" "The exponential decay rate for the 1st moment estimates. Used for the " "following Optimizers: AdaBelief, Adam, Adamax, AdamW, Lion, nAdam. Ignored " "for all others." msgstr "" -#: plugins/train/train_config.py:772 +#: plugins/train/train_config.py:773 msgid "" "The exponential decay rate for the 2nd moment estimates. Used for the " "following Optimizers: AdaBelief, Adam, Adamax, AdamW, Lion, nAdam. Ignored " "for all others." msgstr "" -#: plugins/train/train_config.py:783 +#: plugins/train/train_config.py:784 msgid "" "Whether to apply AMSGrad variant of the algorithm from the paper 'On the " "Convergence of Adam and beyond. Used for the following Optimizers: " diff --git a/locales/ru/LC_MESSAGES/lib.cli.args.mo b/locales/ru/LC_MESSAGES/lib.cli.args.mo index 5b51831151df6115c8704fc668f97c3a1a4c5f4c..4bb81ce322ffc75b822e88092a9b2621a6678425 100644 GIT binary patch delta 139 zcmew>@JC>RNvJmi1A{*!1A`uro(-hkfb=OKeI7^)GBGe@0@)9Nv^tRQ%>tx>{9+(| z6r`S&fx!t#C$cdxbOY%nK-wE9Z!ob_nG?uoUZqeI7^)FflM>1KAINv^tRQ!vds%{1PC2 z6v&^$%D~_Rq?6bf7`lP=2 && " "n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" -"X-Generator: Poedit 3.4.2\n" +"X-Generator: Poedit 3.8\n" -#: lib/cli/args.py:188 lib/cli/args.py:199 lib/cli/args.py:208 -#: lib/cli/args.py:219 +#: lib/cli/args.py:194 lib/cli/args.py:206 lib/cli/args.py:215 +#: lib/cli/args.py:226 msgid "Global Options" msgstr "Глобальные Настройки" -#: lib/cli/args.py:190 +#: lib/cli/args.py:196 msgid "" "R|Exclude GPUs from use by Faceswap. Select the number(s) which correspond " "to any GPU(s) that you do not wish to be made available to Faceswap. " @@ -36,14 +36,14 @@ msgstr "" "Если выбрать здесь все GPU, Faceswap перейдет в режим CPU.\n" "L|{}" -#: lib/cli/args.py:201 +#: lib/cli/args.py:208 msgid "" -"Optionally overide the saved config with the path to a custom config file." +"Optionally override the saved config with the path to a custom config file." msgstr "" "Опционально переопределите сохраненную конфигурацию, указав путь к " "пользовательскому файлу конфигурации." -#: lib/cli/args.py:210 +#: lib/cli/args.py:217 msgid "" "Log level. Stick with INFO or VERBOSE unless you need to file an error " "report. Be careful with TRACE as it will generate a lot of data" @@ -52,12 +52,12 @@ msgstr "" "нужно отправить отчет об ошибке. Будьте осторожны с TRACE, поскольку он " "генерирует много данных" -#: lib/cli/args.py:220 +#: lib/cli/args.py:227 msgid "Path to store the logfile. Leave blank to store in the faceswap folder" msgstr "" "Путь для хранения файла журнала. Оставьте пустым, чтобы хранить в папке " "faceswap" -#: lib/cli/args.py:319 +#: lib/cli/args.py:311 msgid "Output to Shell console instead of GUI console" msgstr "Вывод в консоль Shell вместо консоли GUI" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.mo b/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.mo index 51d7f9676fcfebc7feab5b9dedf7ce6c1c5e45cd..9eae8c03f3424f4092a930ef0a0e34bac63ab38e 100644 GIT binary patch delta 8105 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 delta 2817 zcmaizYitzP701sSI{_Pr?HC-Oz>T4PfW5(wki?LJ!NlPiYGUU>A&l3vX3Kil-JOk{ z0I@M7gd{bo#)>TDaF@s^T#;AU56%-AVZVeZd%+CY3s!OH9QXkk0=E{3EW-Z-@L}+yLXp{^6p2)Ta~6nf zVq3dV}`5+6I!Y%3Pof#X}tL@s8D{C2s>PjP&;MC2s)mK7p@!hUlV9m3Af zD@EpkgKMzkcd^#*a8;ep;6?B;^uGglfmILs1snm7v7Z93_7bpREr-FsJp^Jn2cMnb zSL#JzQ!at9DGqoDzj3W6aXI$@N%Fu3pON+8T5Sgn^q+4O`4Nq@G>Gg&zo=2Vv z@D0{eUGH2BPx5gOXf%oZ0{q6~zQguyA{z)`f<@r>c8Gioj?s<|_?@qcj1uV2&Hnix z?4fA%Q%`bD=x?=%l;H2SiW~+%f10>poO?V7p4tchT{wRBoXC7IbO6c;kN}sV*w7~O z6CC&BRL#JZCq??PKiea6KlYn1i1fh3zu^BJuBhjA?iaseTO=31?Qemi%1;tC>WjGk~)OnGO1U~w%$TI|*d=F-@-}gR&!OuYEOa2LB zmX*j-gxQsHq!cMYm`!gj;CBf^jZ%&1m&W+WGV*)RV=?#uqLBNM)yQ3lUi$9!D)YfB zU=iQEBq>4eoz`p6^Pme`j@*anRpf&}j4^X}|p0p~_;Q{Yh82$@OMZdzu@GTT*~8I5=2NVH_+MY8NLV;$DPO0TO(j~OLP zC=pL4yzw?$sx@_0)itWRy8gjO zR;|YetyvUz(EfB_xpTC2WsWnvwrE?r>G>`By1=yi;hksLc7CJ%-uXvMODK1eHv0+QN87_B z^O&QIX*%5T5~nM&DbM+S>UzOE<(|_SGOrSE)NZ)8#6EMfe#U!6J+AxKG4-p3NPmQg ztsgIROzMdt*AyAG_lP$|{T^-Jka>2hNc(I-!E|j*0%Yrp(Svm7F74x^1uLnXtlrfz z#(uw<6J#GH%#hl)=ZRfGTMZQ1-#lDmuP}-O6;95nw(MPZ-tWxXH6P63D3yBCH*;=o zUl>dbsF`^)$L+Iw)WZLhymerq^W%Ld0?w}nO6NQ8U--DN@H|<(t9Y3fc%a;V?QnU% WH(3l2rZL9tg1tYr&gmPzl=p8Eq1IIZ diff --git a/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.po b/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.po index e95bf84dd7..22ec6f0a60 100755 --- a/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-12 11:56+0100\n" -"PO-Revision-Date: 2024-04-12 11:59+0100\n" +"POT-Creation-Date: 2026-03-20 21:50+0000\n" +"PO-Revision-Date: 2026-03-20 22:02+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -17,15 +17,16 @@ 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.4.2\n" +"X-Generator: Poedit 3.8\n" -#: lib/cli/args_extract_convert.py:46 lib/cli/args_extract_convert.py:56 -#: lib/cli/args_extract_convert.py:64 lib/cli/args_extract_convert.py:122 -#: lib/cli/args_extract_convert.py:483 lib/cli/args_extract_convert.py:492 +#: lib/cli/args_extract_convert.py:47 lib/cli/args_extract_convert.py:58 +#: lib/cli/args_extract_convert.py:108 lib/cli/args_extract_convert.py:116 +#: lib/cli/args_extract_convert.py:490 lib/cli/args_extract_convert.py:498 +#: lib/cli/args_extract_convert.py:507 msgid "Data" msgstr "Данные" -#: lib/cli/args_extract_convert.py:48 +#: lib/cli/args_extract_convert.py:49 msgid "" "Input directory or video. Either a directory containing the image files you " "wish to process or path to a video file. NB: This should be the source video/" @@ -35,11 +36,7 @@ msgstr "" "вы хотите обработать, либо путь к видеофайлу. ПРИМЕЧАНИЕ: Это должно быть " "исходное видео/кадры, а не исходные лица." -#: lib/cli/args_extract_convert.py:57 -msgid "Output directory. This is where the converted files will be saved." -msgstr "Выходная папка. Здесь будут сохранены преобразованные файлы." - -#: lib/cli/args_extract_convert.py:66 +#: lib/cli/args_extract_convert.py:60 msgid "" "Optional path to an alignments file. Leave blank if the alignments file is " "at the default location." @@ -47,7 +44,7 @@ msgstr "" "Необязательный путь к файлу выравниваний. Оставьте пустым, если файл " "выравнивания находится в месте по умолчанию." -#: lib/cli/args_extract_convert.py:97 +#: lib/cli/args_extract_convert.py:83 msgid "" "Extract faces from image or video sources.\n" "Extraction plugins can be configured in the 'Settings' Menu" @@ -55,38 +52,45 @@ msgstr "" "Извлечение лиц из источников изображений или видео.\n" "Плагины извлечения можно настроить в меню \"Настройки\"" -#: lib/cli/args_extract_convert.py:124 +#: lib/cli/args_extract_convert.py:109 +msgid "" +"Output directory. Location to save extracted faces. If not provided then " +"don't save faces and just create an alignments file" +msgstr "" +"Выходной каталог. Место для сохранения извлеченных граней. Если не указано, " +"то не сохранять грани, а просто создать файл выравнивания." + +#: lib/cli/args_extract_convert.py:118 msgid "" -"R|If selected then the input_dir should be a parent folder containing " -"multiple videos and/or folders of images you wish to extract from. The faces " -"will be output to separate sub-folders in the output_dir." +"If selected then the input_dir should be a parent folder containing multiple " +"videos and/or folders of images you wish to extract from. The faces will be " +"output to separate sub-folders in the output_dir." msgstr "" "R|Если выбрано, то input_dir должен быть родительской папкой, содержащей " "несколько видео и/или папок с изображениями, из которых вы хотите извлечь " "изображение. Лица будут выведены в отдельные вложенные папки в output_dir." -#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:152 -#: lib/cli/args_extract_convert.py:167 lib/cli/args_extract_convert.py:206 -#: lib/cli/args_extract_convert.py:224 lib/cli/args_extract_convert.py:237 -#: lib/cli/args_extract_convert.py:247 lib/cli/args_extract_convert.py:257 -#: lib/cli/args_extract_convert.py:503 lib/cli/args_extract_convert.py:529 -#: lib/cli/args_extract_convert.py:568 -msgid "Plugins" -msgstr "Плагины" +#: lib/cli/args_extract_convert.py:127 lib/cli/args_extract_convert.py:217 +#: lib/cli/args_extract_convert.py:230 lib/cli/args_extract_convert.py:240 +msgid "Detect" +msgstr "Обнаружить" -#: lib/cli/args_extract_convert.py:135 +#: lib/cli/args_extract_convert.py:129 msgid "" "R|Detector to use. Some of these have configurable settings in '/config/" "extract.ini' or 'Settings > Configure Extract 'Plugins':\n" "L|cv2-dnn: A CPU only extractor which is the least reliable and least " -"resource intensive. Use this if not using a GPU and time is important.\n" -"L|mtcnn: Good detector. Fast on CPU, faster on GPU. Uses fewer resources " -"than other GPU detectors but can often return more false positives.\n" -"L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " +"resource intensive. Use this only as a last resort. Both MTCNN and " +"RetinaFace have variants that will perform better on CPU.\n" +"L|mtcnn: Average detector. Fast on CPU, faster on GPU. Uses fewer resources " +"than other GPU detectors but can often return more false positives or misses " +"faces.\n" +"L|retinaface: Good detector. Faster and lighter than S3FD but of similar " +"quality. A ResNet and MobileNet version are available (configurable in " +"Detect settings). The MobileNet version is light enough to run on CPU.\n" +"L|s3fd: Good detector. Slow on CPU, faster on GPU. Can detect more faces and " "fewer false positives than other GPU detectors, but is a lot more resource " -"intensive.\n" -"L|external: Import a face detection bounding box from a json file. " -"(configurable in Detect settings)" +"intensive." msgstr "" "R|Детектор для использования. Некоторые из них имеют настраиваемые параметры " "в '/config/extract.ini' или 'Settings > Configure Extract 'Plugins':\n" @@ -99,28 +103,40 @@ msgstr "" "L|s3fd: Лучший детектор. Медленный на CPU, более быстрый на GPU. Может " "обнаружить больше лиц и меньше ложных срабатываний, чем другие детекторы на " "GPU, но требует гораздо больше ресурсов.\n" -"L|external: импортируйте ограничивающую коробку обнаружения лица из файла " -"JSON. (настраивается в настройках обнаружения)" +"L|retinaface: Хороший детектор. Быстрее и легче, чем S3FD, но аналогичного " +"качества. Доступны версии ResNet и MobileNet (настраиваются в параметрах " +"обнаружения). Версия MobileNet достаточно легкая, чтобы работать на " +"процессоре." -#: lib/cli/args_extract_convert.py:154 +#: lib/cli/args_extract_convert.py:149 lib/cli/args_extract_convert.py:253 +#: lib/cli/args_extract_convert.py:271 lib/cli/args_extract_convert.py:284 +#: lib/cli/args_extract_convert.py:294 +msgid "Align" +msgstr "Выровнять" + +#: lib/cli/args_extract_convert.py:151 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " "but less accurate. Only use this if not using a GPU and time is important.\n" -"L|fan: Best aligner. Fast on GPU, slow on CPU.\n" -"L|external: Import 68 point 2D landmarks or an aligned bounding box from a " -"json file. (configurable in Align settings)" +"L|fan: Good aligner. Fast on GPU, slow on CPU.\n" +"L|hrnet: Best aligner. Faster and more performant than FAN. Trained on a " +"custom set of fully rotated faces. Fast on GPU, slow on CPU" msgstr "" "R|Выравниватель для использования.\n" "L|cv2-dnn: Детектор ориентиров только для процессора. Быстрее, менее " "ресурсоемкий, но менее точный. Используйте его, только если не используется " "GPU и важно время.\n" -"L|fan: Лучший выравниватель. Быстрый на GPU, медленный на CPU.\n" -"L|external: импорт 68 баллов 2D достопримечательности или выровненная " -"ограничивающая коробка из файла JSON. (настраивается в настройках " -"выравнивания)" +"L|fan:Хороший выравниватель. Быстрый на GPU, медленный на CPU.\n" +"L|hrnet: Лучший алгоритм выравнивания. Быстрее и производительнее, чем FAN. " +"Обучен на пользовательском наборе полностью повернутых граней. Быстро " +"работает на GPU, медленно на CPU" + +#: lib/cli/args_extract_convert.py:163 +msgid "Mask" +msgstr "Маска" -#: lib/cli/args_extract_convert.py:169 +#: lib/cli/args_extract_convert.py:165 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -187,7 +203,63 @@ msgstr "" "и маска расширяется вверх на лоб.\n" "(например: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -#: lib/cli/args_extract_convert.py:208 +#: lib/cli/args_extract_convert.py:201 lib/cli/args_extract_convert.py:306 +#: lib/cli/args_extract_convert.py:319 lib/cli/args_extract_convert.py:333 +msgid "Identity" +msgstr "Личность" + +#: lib/cli/args_extract_convert.py:203 +msgid "" +"R|Obtain and store face identity encodings. Slows down extract a little but " +"will save time if using 'sort by face'. Required for face filtering.\n" +"L|t-face: An InsightFace ResNet based model with a lighter and heavier " +"variant (configurable in settings).\n" +"L|vggface2: An older and lighter, but fairly reliable plugin based on the " +"VGG Network." +msgstr "" +"R|Получение и сохранение кодировок идентификации лиц. Немного замедляет " +"извлечение, но сэкономит время при использовании функции «сортировка по " +"лицу». Необходимо для фильтрации лиц.\n" +"L|t-face: модель на основе ResNet от InsightFace с более лёгким и более " +"тяжёлым вариантами (настраивается в параметрах).\n" +"L|vggface2: более старый и лёгкий, но достаточно надёжный плагин на основе " +"сети VGG." + +#: lib/cli/args_extract_convert.py:219 +msgid "" +"Filters out detections below this percentage of the shortest side of the " +"frame along the face detection box's longest edge. (eg: a value of 10 will " +"filter out faces smaller than 72px from a 720p image). 0 for disabled." +msgstr "" +"Отфильтровывает лица, размер которых меньше указанного процента от самой " +"короткой стороны рамки вдоль самой длинной стороны области обнаружения лица. " +"(например, значение 10 отфильтрует лица размером менее 72 пикселей на " +"изображении 720p). 0 означает отключение." + +#: lib/cli/args_extract_convert.py:232 +msgid "" +"Filters out detections above this percentage of the shortest side of the " +"frame along the face detection box's longest edge. (eg: a value of 200 will " +"filter out faces larger than 1440px from a 720p image). 0 for disabled." +msgstr "" +"Отфильтровывает обнаружения, превышающие этот процент от самой короткой " +"стороны рамки вдоль самой длинной стороны области обнаружения лица. " +"(например, значение 200 отфильтрует лица размером более 1440 пикселей на " +"изображении 720p). 0 означает отключение." + +#: lib/cli/args_extract_convert.py:242 +msgid "" +"If a face isn't found, rotate the images to try to find a face. Can find " +"more faces at the cost of extraction speed. Pass in a single number to use " +"increments of that size up to 360, or pass in a list of numbers to enumerate " +"exactly what angles to check." +msgstr "" +"Если лицо не найдено, поворачивает изображения, чтобы попытаться найти лицо. " +"Может найти больше лиц ценой снижения скорости извлечения. Передайте одно " +"число, чтобы использовать приращения этого размера до 360, или передайте " +"список чисел, чтобы перечислить, какие именно углы нужно проверить." + +#: lib/cli/args_extract_convert.py:255 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -209,7 +281,7 @@ msgstr "" "L|hist: Уравнять гистограммы в каналах RGB.\n" "L|mean: Нормализовать цвета лица к среднему значению." -#: lib/cli/args_extract_convert.py:226 +#: lib/cli/args_extract_convert.py:273 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -226,7 +298,7 @@ msgstr "" "в выравниватель, тем меньше микро-дрожание, но тем больше времени займет " "извлечение." -#: lib/cli/args_extract_convert.py:239 +#: lib/cli/args_extract_convert.py:286 msgid "" "Re-feed the initially found aligned face through the aligner. Can help " "produce better alignments for faces that are rotated beyond 45 degrees in " @@ -237,44 +309,17 @@ msgstr "" "в кадре более чем на 45 градусов или расположенных под экстремальными " "углами. Замедляет извлечение." -#: lib/cli/args_extract_convert.py:249 -msgid "" -"If a face isn't found, rotate the images to try to find a face. Can find " -"more faces at the cost of extraction speed. Pass in a single number to use " -"increments of that size up to 360, or pass in a list of numbers to enumerate " -"exactly what angles to check." -msgstr "" -"Если лицо не найдено, поворачивает изображения, чтобы попытаться найти лицо. " -"Может найти больше лиц ценой снижения скорости извлечения. Передайте одно " -"число, чтобы использовать приращения этого размера до 360, или передайте " -"список чисел, чтобы перечислить, какие именно углы нужно проверить." - -#: lib/cli/args_extract_convert.py:259 -msgid "" -"Obtain and store face identity encodings from VGGFace2. Slows down extract a " -"little, but will save time if using 'sort by face'" -msgstr "" -"Получение и хранение кодировок идентификации лица из VGGFace2. Немного " -"замедляет извлечение, но экономит время при использовании \"сортировки по " -"лицам\"." - -#: lib/cli/args_extract_convert.py:269 lib/cli/args_extract_convert.py:280 -#: lib/cli/args_extract_convert.py:293 lib/cli/args_extract_convert.py:307 -#: lib/cli/args_extract_convert.py:614 lib/cli/args_extract_convert.py:623 -#: lib/cli/args_extract_convert.py:638 lib/cli/args_extract_convert.py:651 -#: lib/cli/args_extract_convert.py:665 -msgid "Face Processing" -msgstr "Обработка лиц" - -#: lib/cli/args_extract_convert.py:271 +#: lib/cli/args_extract_convert.py:296 msgid "" -"Filters out faces detected below this size. Length, in pixels across the " -"diagonal of the bounding box. Set to 0 for off" +"Enable aligner filters. This allows the filtering out of faces based on " +"certain statistics and characteristics. Configurable in extract settings. " +"Slows down extraction." msgstr "" -"Отфильтровывает лица, обнаруженные ниже этого размера. Длина в пикселях по " -"диагонали ограничивающего поля. Установите значение 0, чтобы выключить" +"Включите фильтры выравнивания. Это позволяет отфильтровывать лица на основе " +"определенных статистических данных и характеристик. Настраивается в " +"параметрах извлечения. Замедляет процесс извлечения." -#: lib/cli/args_extract_convert.py:282 +#: lib/cli/args_extract_convert.py:308 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -287,7 +332,7 @@ msgstr "" "необходимые изображения, или несколько файлов изображений, разделенных " "пробелами." -#: lib/cli/args_extract_convert.py:295 +#: lib/cli/args_extract_convert.py:321 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -299,7 +344,7 @@ msgstr "" "углами и в разных условиях. Можно выбрать папку, содержащую необходимые " "изображения, или несколько файлов изображений, разделенных пробелами." -#: lib/cli/args_extract_convert.py:309 +#: lib/cli/args_extract_convert.py:335 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." @@ -308,12 +353,13 @@ msgstr "" "положительного распознавания лица. Более высокие значения являются более " "строгими." -#: lib/cli/args_extract_convert.py:318 lib/cli/args_extract_convert.py:331 -#: lib/cli/args_extract_convert.py:344 lib/cli/args_extract_convert.py:356 +#: lib/cli/args_extract_convert.py:344 lib/cli/args_extract_convert.py:357 +#: lib/cli/args_extract_convert.py:370 lib/cli/args_extract_convert.py:389 +#: lib/cli/args_extract_convert.py:401 msgid "output" msgstr "вывод" -#: lib/cli/args_extract_convert.py:320 +#: lib/cli/args_extract_convert.py:346 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -323,7 +369,7 @@ msgstr "" "собираетесь тренировать, поддерживает требуемый размер. Это необходимо " "изменить только для моделей высокого разрешения." -#: lib/cli/args_extract_convert.py:333 +#: lib/cli/args_extract_convert.py:359 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -333,7 +379,30 @@ msgstr "" "лиц. Например, значение 1 будет извлекать лица из каждого кадра, значение 10 " "будет извлекать лица из каждого 10-го кадра." -#: lib/cli/args_extract_convert.py:346 +#: lib/cli/args_extract_convert.py:372 +msgid "" +"Only output faces that have been resized by this percent or more to meet the " +"specified extract size (`-z`, `--size`). Useful for excluding low-res images " +"from a training set. Set to 0 to output all faces. This only impacts faces " +"that are output to disk. All detected faces will still be saved to the " +"alignments file regardless of what is set here. Eg: For an extract size of " +"512px, A setting of 50 will only output faces that have been resized from " +"256px or above. Setting to 100 will only output faces that have been resized " +"from 512px or above. A setting of 200 will only output faces that have been " +"downscaled from 1024px or above." +msgstr "" +"Выводить только те лица, размер которых был изменен на указанный процент или " +"более, чтобы соответствовать заданному размеру извлечения (`-z`, `--size`). " +"Полезно для исключения изображений с низким разрешением из обучающего " +"набора. Установите значение 0, чтобы вывести все лица. Это влияет только на " +"лица, которые сохраняются на диск. Все обнаруженные лица все равно будут " +"сохранены в файл выравнивания независимо от значения параметра. Например: " +"для размера извлечения 512 пикселей значение 50 выведет только лица, размер " +"которых был изменен с 256 пикселей или выше. Значение 100 выведет только " +"лица, размер которых был изменен с 512 пикселей или выше. Значение 200 " +"выведет только лица, размер которых был уменьшен с 1024 пикселей или выше." + +#: lib/cli/args_extract_convert.py:391 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -349,28 +418,41 @@ msgstr "" "ПРЕДУПРЕЖДЕНИЕ: Не прерывайте работу скрипта при записи файла, так как он " "может быть поврежден. Установите значение 0, чтобы отключить" -#: lib/cli/args_extract_convert.py:357 -msgid "Draw landmarks on the ouput faces for debugging purposes." +#: lib/cli/args_extract_convert.py:402 +msgid "Draw landmarks on the output faces for debugging purposes." msgstr "Нарисуйте ориентиры на выходящих гранях для отладки." -#: lib/cli/args_extract_convert.py:363 lib/cli/args_extract_convert.py:373 -#: lib/cli/args_extract_convert.py:381 lib/cli/args_extract_convert.py:388 -#: lib/cli/args_extract_convert.py:678 lib/cli/args_extract_convert.py:691 -#: lib/cli/args_extract_convert.py:712 lib/cli/args_extract_convert.py:718 +#: lib/cli/args_extract_convert.py:407 lib/cli/args_extract_convert.py:416 +#: lib/cli/args_extract_convert.py:426 lib/cli/args_extract_convert.py:434 +#: lib/cli/args_extract_convert.py:695 lib/cli/args_extract_convert.py:708 +#: lib/cli/args_extract_convert.py:729 lib/cli/args_extract_convert.py:735 msgid "settings" msgstr "настройки" -#: lib/cli/args_extract_convert.py:365 +#: lib/cli/args_extract_convert.py:408 +msgid "" +"Compile any PyTorch models. This will lead to slower start up time, but " +"faster processing. For large amounts of data this is worth enabling. For " +"smaller extractions it is not." +msgstr "" +"Скомпилируйте все модели PyTorch. Это приведет к замедлению времени запуска, " +"но ускорит обработку. Для больших объемов данных это целесообразно включить. " +"Для небольших объемов данных это не нужно." + +#: lib/cli/args_extract_convert.py:417 msgid "" -"Don't run extraction in parallel. Will run each part of the extraction " -"process separately (one after the other) rather than all at the same time. " -"Useful if VRAM is at a premium." +"Benchmark the chosen extract plugins for optimal batch sizes. The benchmark " +"profiler can be configured in settings. Note: This will take a long time, so " +"should be used to find optimal settings for a given plugin combination and " +"type of dataset rather than being used every time." msgstr "" -"Не запускать извлечение параллельно. Каждая часть процесса извлечения будет " -"выполняться отдельно (одна за другой), а не одновременно. Полезно, если " -"память VRAM ограничена." +"Проведите сравнительный анализ выбранных плагинов извлечения данных для " +"определения оптимальных размеров пакетов. Профилировщик производительности " +"можно настроить в параметрах. Примечание: это займет много времени, поэтому " +"его следует использовать для поиска оптимальных настроек для данной " +"комбинации плагинов и типа набора данных, а не каждый раз." -#: lib/cli/args_extract_convert.py:375 +#: lib/cli/args_extract_convert.py:428 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -378,17 +460,12 @@ msgstr "" "Пропускает кадры, которые уже были извлечены и существуют в файле " "выравнивания" -#: lib/cli/args_extract_convert.py:382 +#: lib/cli/args_extract_convert.py:435 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" "Пропустить кадры, в которых уже есть обнаруженные лица в файле выравнивания" -#: lib/cli/args_extract_convert.py:389 -msgid "Skip saving the detected faces to disk. Just create an alignments file" -msgstr "" -"Не сохранять обнаруженные лица на диск. Просто создать файл выравнивания" - -#: lib/cli/args_extract_convert.py:463 +#: lib/cli/args_extract_convert.py:471 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -396,7 +473,11 @@ msgstr "" "Поменять исходные лица в исходном видео/изображении на ваши конечные лица.\n" "Плагины конвертирования можно настроить в меню \"Настройки\"" -#: lib/cli/args_extract_convert.py:485 +#: lib/cli/args_extract_convert.py:491 +msgid "Output directory. This is where the converted files will be saved." +msgstr "Выходная папка. Здесь будут сохранены преобразованные файлы." + +#: lib/cli/args_extract_convert.py:500 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -406,7 +487,7 @@ msgstr "" "исходное видео, из которого были извлечены исходные кадры (для извлечения " "кадров в секунду и звука)." -#: lib/cli/args_extract_convert.py:494 +#: lib/cli/args_extract_convert.py:509 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -414,7 +495,12 @@ msgstr "" "Папка модели. Папка, содержащая обученную модель, которую вы хотите " "использовать для преобразования." -#: lib/cli/args_extract_convert.py:505 +#: lib/cli/args_extract_convert.py:518 lib/cli/args_extract_convert.py:546 +#: lib/cli/args_extract_convert.py:585 +msgid "Plugins" +msgstr "Плагины" + +#: lib/cli/args_extract_convert.py:520 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -454,7 +540,7 @@ msgstr "" "Обычно дает не очень удовлетворительные результаты.\n" "L|none: Не выполнять коррекцию цвета." -#: lib/cli/args_extract_convert.py:531 +#: lib/cli/args_extract_convert.py:548 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -525,7 +611,7 @@ msgstr "" "L|predicted: Если во время обучения была включена опция 'Изучить Маску', то " "будет использоваться маска, созданная обученной моделью." -#: lib/cli/args_extract_convert.py:570 +#: lib/cli/args_extract_convert.py:587 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -558,12 +644,12 @@ msgstr "" "L|pillow: [изображения] Медленнее, чем opencv, но имеет больше опций и " "поддерживает больше форматов." -#: lib/cli/args_extract_convert.py:591 lib/cli/args_extract_convert.py:600 -#: lib/cli/args_extract_convert.py:703 +#: lib/cli/args_extract_convert.py:608 lib/cli/args_extract_convert.py:617 +#: lib/cli/args_extract_convert.py:720 msgid "Frame Processing" msgstr "Обработка лиц" -#: lib/cli/args_extract_convert.py:593 +#: lib/cli/args_extract_convert.py:610 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -573,7 +659,7 @@ msgstr "" "кадры в исходном размере. 50%% при половинном размере 200%% при двойном " "размере" -#: lib/cli/args_extract_convert.py:602 +#: lib/cli/args_extract_convert.py:619 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -586,7 +672,13 @@ msgstr "" "keep-unchanged). Примечание: Если вы конвертируете из изображений, то имена " "файлов должны заканчиваться номером кадра!" -#: lib/cli/args_extract_convert.py:616 +#: lib/cli/args_extract_convert.py:631 lib/cli/args_extract_convert.py:640 +#: lib/cli/args_extract_convert.py:655 lib/cli/args_extract_convert.py:668 +#: lib/cli/args_extract_convert.py:682 +msgid "Face Processing" +msgstr "Обработка лиц" + +#: lib/cli/args_extract_convert.py:633 msgid "" "Scale the swapped face by this percentage. Positive values will enlarge the " "face, Negative values will shrink the face." @@ -594,7 +686,7 @@ msgstr "" "Увеличить масштаб нового лица на этот процент. Положительные значения " "увеличат лицо, в то время как отрицательные значения уменьшат его." -#: lib/cli/args_extract_convert.py:625 +#: lib/cli/args_extract_convert.py:642 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -610,7 +702,7 @@ msgstr "" "Если оставить этот параметр пустым, будут преобразованы все лица, " "существующие в файле выравнивания." -#: lib/cli/args_extract_convert.py:640 +#: lib/cli/args_extract_convert.py:657 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -624,7 +716,7 @@ msgstr "" "разделенных пробелами. Примечание: Использование фильтра лиц значительно " "снизит скорость извлечения, а его точность не гарантируется." -#: lib/cli/args_extract_convert.py:653 +#: lib/cli/args_extract_convert.py:670 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -638,7 +730,7 @@ msgstr "" "Примечание: Использование фильтра лиц значительно снизит скорость " "извлечения, а его точность не гарантируется." -#: lib/cli/args_extract_convert.py:667 +#: lib/cli/args_extract_convert.py:684 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -650,7 +742,7 @@ msgstr "" "строгими. Примечание: Использование фильтра лиц значительно снизит скорость " "извлечения, а его точность не гарантируется." -#: lib/cli/args_extract_convert.py:680 +#: lib/cli/args_extract_convert.py:697 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -668,7 +760,7 @@ msgstr "" "процессов, чем доступно в вашей системе. Если включена однопоточная " "обработка, этот параметр будет проигнорирован." -#: lib/cli/args_extract_convert.py:693 +#: lib/cli/args_extract_convert.py:710 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -683,7 +775,7 @@ msgstr "" "приведет к некачественным результатам. Если файл выравнивания найден, этот " "параметр будет проигнорирован." -#: lib/cli/args_extract_convert.py:705 +#: lib/cli/args_extract_convert.py:722 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -691,16 +783,45 @@ msgstr "" "При использовании с --frame-ranges выводит неизмененные кадры, которые не " "были обработаны, вместо того, чтобы отбрасывать их." -#: lib/cli/args_extract_convert.py:713 +#: lib/cli/args_extract_convert.py:730 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Поменять модель местами. Вместо преобразования из A -> B, преобразуется B -> " "A" -#: lib/cli/args_extract_convert.py:719 +#: lib/cli/args_extract_convert.py:736 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Отключение многопоточной обработки. Медленнее, но менее ресурсоемко." +#~ msgid "" +#~ "Obtain and store face identity encodings from VGGFace2. Slows down " +#~ "extract a little, but will save time if using 'sort by face'" +#~ msgstr "" +#~ "Получение и хранение кодировок идентификации лица из VGGFace2. Немного " +#~ "замедляет извлечение, но экономит время при использовании \"сортировки по " +#~ "лицам\"." + +#~ msgid "" +#~ "Filters out faces detected below this size. Length, in pixels across the " +#~ "diagonal of the bounding box. Set to 0 for off" +#~ msgstr "" +#~ "Отфильтровывает лица, обнаруженные ниже этого размера. Длина в пикселях " +#~ "по диагонали ограничивающего поля. Установите значение 0, чтобы выключить" + +#~ msgid "" +#~ "Don't run extraction in parallel. Will run each part of the extraction " +#~ "process separately (one after the other) rather than all at the same " +#~ "time. Useful if VRAM is at a premium." +#~ msgstr "" +#~ "Не запускать извлечение параллельно. Каждая часть процесса извлечения " +#~ "будет выполняться отдельно (одна за другой), а не одновременно. Полезно, " +#~ "если память VRAM ограничена." + +#~ msgid "" +#~ "Skip saving the detected faces to disk. Just create an alignments file" +#~ msgstr "" +#~ "Не сохранять обнаруженные лица на диск. Просто создать файл выравнивания" + #~ msgid "" #~ "[LEGACY] This only needs to be selected if a legacy model is being loaded " #~ "or if there are multiple models in the model folder" diff --git a/locales/ru/LC_MESSAGES/plugins.extract.extract_config.mo b/locales/ru/LC_MESSAGES/plugins.extract.extract_config.mo index 411175f38bdfbd280ac359005a5d680b49c517b6..14de763c859d0cf8bf98265cea5fb6cd3354f31b 100644 GIT binary patch literal 9522 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 VIaj;WSs1Nob3QaE+*a4P?>|c5E_46@ diff --git a/locales/ru/LC_MESSAGES/plugins.train.train_config.po b/locales/ru/LC_MESSAGES/plugins.train.train_config.po index a36c337933..2993220eac 100644 --- a/locales/ru/LC_MESSAGES/plugins.train.train_config.po +++ b/locales/ru/LC_MESSAGES/plugins.train.train_config.po @@ -7,15 +7,15 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-12-13 13:39+0000\n" -"PO-Revision-Date: 2025-12-15 22:01+0700\n" +"POT-Creation-Date: 2026-03-13 15:17+0000\n" +"PO-Revision-Date: 2026-03-16 18:16+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru_RU\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Poedit 3.5\n" +"X-Generator: Poedit 3.8\n" #: plugins/train/train_config.py:21 msgid "" @@ -123,8 +123,8 @@ msgstr "" "повторяющемся шаблоне. Эта стратегия предназначена для использования в паре " "с субпиксельным/пиксельным перетасовщиком для уменьшения \"эффекта шахматной " "доски\" при реконструкции изображения. \n" -"\t [ТОЛЬКО на английском] https://arxiv.org/ftp/arxiv/papers/1707/1707.02937." -"pdf" +"\t [ТОЛЬКО на английском] https://arxiv.org/ftp/arxiv/papers/" +"1707/1707.02937.pdf" #: plugins/train/train_config.py:111 msgid "" @@ -305,9 +305,8 @@ msgstr "" "оригинальной статье было обнаружено, что она дает больше преимуществ при " "использовании в качестве дополнительной потери к другой пространственной " "функции потерь (например, MSE). Ссылка: Focal Frequency Loss for Image " -"Reconstruction and Synthesis [ТОЛЬКО на английском] https://arxiv.org/" -"pdf/2012.12821.pdf NB: Эта потеря в настоящее время не работает на картах " -"AMD." +"Reconstruction and Synthesis [ТОЛЬКО на английском] https://arxiv.org/pdf/" +"2012.12821.pdf NB: Эта потеря в настоящее время не работает на картах AMD." #: plugins/train/train_config.py:231 msgid "" @@ -331,8 +330,8 @@ msgid "" "Gradient Magnitude Similarity Deviation seeks to match the global standard " "deviation of the pixel to pixel differences between two images. Similar in " "approach to SSIM. Ref: Gradient Magnitude Similarity Deviation: An Highly " -"Efficient Perceptual Image Quality Index https://arxiv.org/ftp/arxiv/" -"papers/1308/1308.3052.pdf" +"Efficient Perceptual Image Quality Index https://arxiv.org/ftp/arxiv/papers/" +"1308/1308.3052.pdf" msgstr "" "Отклонение Схожести Магнитуды Градиентов(Gradient Magnitude Similarity " "Deviation) пытается совместить глобальную стандартную девиацию различий " @@ -365,8 +364,8 @@ msgstr "" "приоритет краям, а не другой низкочастотной информации, например, цвету, ее " "не следует использовать самостоятельно. В оригинальной реализации эта потеря " "используется как дополнительная функция к MSE. Ссылка: Optimizing the Latent " -"Space of Generative Networks [ТОЛЬКО на английском] https://arxiv.org/" -"abs/1707.05776" +"Space of Generative Networks [ТОЛЬКО на английском] https://arxiv.org/abs/" +"1707.05776" #: plugins/train/train_config.py:254 msgid "" @@ -450,8 +449,8 @@ msgstr "" #: plugins/train/train_config.py:283 msgid "" -"Multiscale Structural Similarity Index Metric is similar to SSIM except that " -"it performs the calculations along multiple scales of the input image." +"Multi-scale Structural Similarity Index Metric is similar to SSIM except " +"that it performs the calculations along multiple scales of the input image." msgstr "" "Метрика Индекса Многомасштабного Структурного Сходства (Multiscale " "Structural Similarity Index Metric) похожа на SSIM, за исключением того, что " @@ -558,7 +557,7 @@ msgid "" "its full amount towards the overall loss score. \n" "\t 25 - The loss calculated for the second loss function will be reduced by " "a quarter prior to adding to the overall loss score. \n" -"\t 400 - The loss calculated for the second loss function will be mulitplied " +"\t 400 - The loss calculated for the second loss function will be multiplied " "4 times prior to adding to the overall loss score. \n" "\t 0 - Disables the second loss function altogether." msgstr "" @@ -605,7 +604,7 @@ msgid "" "its full amount towards the overall loss score. \n" "\t 25 - The loss calculated for the third loss function will be reduced by a " "quarter prior to adding to the overall loss score. \n" -"\t 400 - The loss calculated for the third loss function will be mulitplied " +"\t 400 - The loss calculated for the third loss function will be multiplied " "4 times prior to adding to the overall loss score. \n" "\t 0 - Disables the third loss function altogether." msgstr "" @@ -652,7 +651,7 @@ msgid "" "its full amount towards the overall loss score. \n" "\t 25 - The loss calculated for the fourth loss function will be reduced by " "a quarter prior to adding to the overall loss score. \n" -"\t 400 - The loss calculated for the fourth loss function will be mulitplied " +"\t 400 - The loss calculated for the fourth loss function will be multiplied " "4 times prior to adding to the overall loss score. \n" "\t 0 - Disables the fourth loss function altogether." msgstr "" @@ -743,9 +742,9 @@ msgstr "" "время как область лица с маской является приоритетной. Может повысить общее " "качество за счет концентрации внимания на основной области лица." -#: plugins/train/train_config.py:473 plugins/train/train_config.py:514 -#: plugins/train/train_config.py:525 plugins/train/train_config.py:539 -#: plugins/train/train_config.py:549 +#: plugins/train/train_config.py:473 plugins/train/train_config.py:515 +#: plugins/train/train_config.py:526 plugins/train/train_config.py:540 +#: plugins/train/train_config.py:550 msgid "mask" msgstr "маска" @@ -756,32 +755,32 @@ msgid "" "required mask should have been selected as part of the Extract process. If " "it does not exist in the alignments file then it will be generated prior to " "training commencing.\n" -"\tnone: Don't use a mask.\n" -"\tbisenet-fp_face: Relatively lightweight NN based mask that provides more " +"\t none: Don't use a mask.\n" +"\t bisenet-fp_face: Relatively lightweight NN based mask that provides more " "refined control over the area to be masked (configurable in mask settings). " "Use this version of bisenet-fp if your model is trained with 'face' or " "'legacy' centering.\n" -"\tbisenet-fp_head: Relatively lightweight NN based mask that provides more " +"\t bisenet-fp_head: Relatively lightweight NN based mask that provides more " "refined control over the area to be masked (configurable in mask settings). " "Use this version of bisenet-fp if your model is trained with 'head' " "centering.\n" -"\tcomponents: Mask designed to provide facial segmentation based on the " +"\t 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.\n" -"\tcustom_face: Custom user created, face centered mask.\n" -"\tcustom_head: Custom user created, head centered mask.\n" -"\textended: Mask designed to provide facial segmentation based on the " +"\t custom_face: Custom user created, face centered mask.\n" +"\t custom_head: Custom user created, head centered mask.\n" +"\t 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.\n" -"\tvgg-clear: Mask designed to provide smart segmentation of mostly frontal " +"\t 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.\n" -"\tvgg-obstructed: Mask designed to provide smart segmentation of mostly " +"\t 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.\n" -"\tunet-dfl: Mask designed to provide smart segmentation of mostly frontal " +"\t 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." @@ -821,7 +820,7 @@ msgstr "" "сообщества и для дальнейшего описания нуждается в тестировании. Профильные " "лица могут иметь низкую производительность." -#: plugins/train/train_config.py:516 +#: plugins/train/train_config.py:517 msgid "" "Dilate or erode the mask. Negative values erode the mask (make it smaller). " "Positive values dilate the mask (make it larger). The value given is a " @@ -830,7 +829,7 @@ msgstr "" "Расширяет или сужает маску. Отрицательные значения сужают маску (делают её " "меньше). Положительные значения расширяют маску (делают её больше)." -#: plugins/train/train_config.py:527 +#: plugins/train/train_config.py:528 msgid "" "Apply gaussian blur to the mask input. This has the effect of smoothing the " "edges of the mask, which can help with poorly calculated masks and give less " @@ -846,7 +845,7 @@ msgstr "" "должно быть нечетным, если передано четное число, то оно будет округлено до " "следующего нечетного числа." -#: plugins/train/train_config.py:541 +#: plugins/train/train_config.py:542 msgid "" "Sets pixels that are near white to white and near black to black. Set to 0 " "for off." @@ -854,7 +853,7 @@ msgstr "" "Устанавливает пиксели, которые почти белые - в белые и которые почти черные " "- в черные. Установите 0, чтобы выключить." -#: plugins/train/train_config.py:551 +#: plugins/train/train_config.py:552 msgid "" "Dedicate a portion of the model to learning how to duplicate the input mask. " "Increases VRAM usage in exchange for learning a quick ability to try to " @@ -864,7 +863,7 @@ msgstr "" "Увеличивает использование видеопамяти в обмен на обучение быстрой " "способности попытки переделывать более сложные маски." -#: plugins/train/train_config.py:559 +#: plugins/train/train_config.py:560 msgid "" "Optimizer configuration options\n" "The optimizer applies the output of the loss function to the model.\n" @@ -873,15 +872,15 @@ msgstr "" "Оптимизатор использует значения функции потерь для обновления параметров " "модели.\n" -#: plugins/train/train_config.py:565 plugins/train/train_config.py:600 -#: plugins/train/train_config.py:613 plugins/train/train_config.py:634 +#: plugins/train/train_config.py:566 plugins/train/train_config.py:601 +#: plugins/train/train_config.py:614 plugins/train/train_config.py:635 msgid "optimizer" msgstr "оптимизатор" -#: plugins/train/train_config.py:567 +#: plugins/train/train_config.py:568 msgid "" "The optimizer to use.\n" -"\t adabelief - Adapting Stepsizes by the Belief in Observed Gradients. An " +"\t adabelief - Adapting Step-sizes by the Belief in Observed Gradients. An " "optimizer with the aim to converge faster, generalize better and remain more " "stable. (https://arxiv.org/abs/2010.07468). NB: Epsilon for AdaBelief needs " "to be set to a smaller value than other Optimizers. Generally setting the " @@ -912,10 +911,10 @@ msgstr "" "\t adabelief - Адаптация размеров шагов по убеждению в наблюдаемых " "градиентах('Adapting Stepsizes by the Belief in Observed Gradients'). " "Оптимизатор, цель которого - быстрее сходиться, лучше обобщаться и " -"оставаться более стабильным. ([ТОЛЬКО на английском] https://arxiv.org/" -"abs/2010.07468). Примечание: значение Epsilon для AdaBelief должно быть " -"меньше, чем для других оптимизаторов. Как правило, значение 'Epsilon " -"Exponent' должно быть около '-16'.\n" +"оставаться более стабильным. ([ТОЛЬКО на английском] https://arxiv.org/abs/" +"2010.07468). Примечание: значение Epsilon для AdaBelief должно быть меньше, " +"чем для других оптимизаторов. Как правило, значение 'Epsilon Exponent' " +"должно быть около '-16'.\n" "\t adam - Адаптивная оптимизация моментов('Adaptive Moment Optimization'). " "Стохастический метод градиентного спуска, основанный на адаптивной оценке " "моментов первого и второго порядка.\n" @@ -942,7 +941,7 @@ msgstr "" "Propagation'). Поддерживает скользящее (дисконтированное) среднее квадрата " "градиентов. Делит градиент на корень из этого среднего." -#: plugins/train/train_config.py:602 +#: plugins/train/train_config.py:603 msgid "" "Learning rate - how fast your network will learn (how large are the " "modifications to the model weights after one batch of training). Values that " @@ -956,7 +955,7 @@ msgstr "" "лучшее решение. Слишком маленькие значения могут привести к невозможности " "выбраться из тупиков и найти лучший глобальный минимум." -#: plugins/train/train_config.py:615 +#: plugins/train/train_config.py:616 msgid "" "The epsilon adds a small constant to weight updates to attempt to avoid " "'divide by zero' errors. Unless you are using the AdaBelief Optimizer, then " @@ -986,7 +985,7 @@ msgstr "" "значения \"-3\" эпсилон будет равен 0,001 (1e-3).\n" "Примечание: Не используется оптимизатором Lion" -#: plugins/train/train_config.py:636 +#: plugins/train/train_config.py:637 msgid "" "When to save the Optimizer Weights. Saving the optimizer weights is not " "necessary and will increase the model file size 3x (and by extension the " @@ -1020,24 +1019,24 @@ msgstr "" "причине (например, отключение питания, ошибка нехватки памяти, обнаружение " "NaN), веса оптимизатора НЕ будут сохранены." -#: plugins/train/train_config.py:657 plugins/train/train_config.py:676 -#: plugins/train/train_config.py:695 +#: plugins/train/train_config.py:658 plugins/train/train_config.py:677 +#: plugins/train/train_config.py:696 msgid "clipping" msgstr "клиппинг" -#: plugins/train/train_config.py:659 +#: plugins/train/train_config.py:660 msgid "" "Apply clipping to the gradients. Can help prevent NaNs and improve model " "optimization at the expense of VRAM.\n" -"\tautoclip: Analyzes the gradient weights and adjusts the normalization " +"\t autoclip: Analyzes the gradient weights and adjusts the normalization " "value dynamically to fit the data\n" -"\tglobal_norm: Clips the gradient of each weight so that the global norm is " +"\t global_norm: Clips the gradient of each weight so that the global norm is " "no higher than the given value.\n" -"\tnorm: Clips the gradient of each weight so that its norm is no higher than " -"the given value.\n" -"\tvalue: Clips the gradient of each weight so that it is no higher than the " +"\t norm: Clips the gradient of each weight so that its norm is no higher " +"than the given value.\n" +"\t value: Clips the gradient of each weight so that it is no higher than the " "given value.\n" -"\tnone: Don't perform any clipping to the gradients." +"\t none: Don't perform any clipping to the gradients." msgstr "" "Применять клиппинг (обрезку) градиентов. Помогает предотвратить NaN'ы и " "улучшить оптимизацию модели, но за счёт увеличения расхода VRAM.\n" @@ -1051,7 +1050,7 @@ msgstr "" "ограничивается диапазоном [-value, value].\n" "\tnone: Не выполнять обрезку градиентов." -#: plugins/train/train_config.py:678 +#: plugins/train/train_config.py:679 msgid "" "The amount of clipping to perform.\n" "\tautoclip: The percentile to clip at. A value of 1.0 will clip at the 10th " @@ -1076,9 +1075,9 @@ msgstr "" "(диапазон [-value, value]).\n" "\tnone: Эта опция игнорируется." -#: plugins/train/train_config.py:697 +#: plugins/train/train_config.py:698 msgid "" -"The maximum number of prior iterations for autoclipper to analyze when " +"The maximum number of prior iterations for auto-clipper to analyze when " "calculating the normalization amount. 0 to always include all prior " "iterations." msgstr "" @@ -1086,11 +1085,11 @@ msgstr "" "при расчёте величины нормализации. Значение 0 означает, что всегда " "учитываются все предыдущие итерации." -#: plugins/train/train_config.py:706 plugins/train/train_config.py:715 +#: plugins/train/train_config.py:707 plugins/train/train_config.py:716 msgid "updates" msgstr "обновления" -#: plugins/train/train_config.py:707 +#: plugins/train/train_config.py:708 msgid "" "If set, weight decay is applied. 0.0 for no weight decay. Default is 0.0 for " "all optimizers except AdamW (0.004)" @@ -1099,7 +1098,7 @@ msgstr "" "Значение 0.0 отключает затухание. По умолчанию 0.0 для всех оптимизаторов, " "кроме AdamW (0.004)." -#: plugins/train/train_config.py:717 +#: plugins/train/train_config.py:718 msgid "" "Values above 1 will enable Gradient Accumulation. Updates will not be at " "every iteration; instead they will occur every number of iterations given " @@ -1114,12 +1113,12 @@ msgstr "" "Полезно, когда размер пачки очень мал — позволяет уменьшить шум градиентов " "на каждом шаге обновления." -#: plugins/train/train_config.py:728 plugins/train/train_config.py:738 -#: plugins/train/train_config.py:749 +#: plugins/train/train_config.py:729 plugins/train/train_config.py:739 +#: plugins/train/train_config.py:750 msgid "exponential moving average" msgstr "экспоненциальная скользящая средняя" -#: plugins/train/train_config.py:730 +#: plugins/train/train_config.py:731 msgid "" "Enable exponential moving average (EMA). EMA consists of computing an " "exponential moving average of the weights of the model (as the weight values " @@ -1131,7 +1130,7 @@ msgstr "" "обновления после каждой пачки, с периодической заменой текущих весов на эту " "среднюю" -#: plugins/train/train_config.py:740 +#: plugins/train/train_config.py:741 msgid "" "Only used if use_ema is enabled. This is the momentum to use when computing " "the EMA of the model's weights: new_average = ema_momentum * old_average + " @@ -1141,7 +1140,7 @@ msgstr "" "для экспоненциальной скользящей средней весов модели по формуле: new_average " "= ema_momentum × old_average + (1 - ema_momentum) × current_variable_value." -#: plugins/train/train_config.py:751 +#: plugins/train/train_config.py:752 msgid "" "Only used if use_ema is enabled. Set the number of iterations, to overwrite " "the model variable by its moving average. " @@ -1150,12 +1149,12 @@ msgstr "" "которого веса основной модели заменяются на значения их экспоненциальной " "скользящей средней. " -#: plugins/train/train_config.py:759 plugins/train/train_config.py:770 -#: plugins/train/train_config.py:781 +#: plugins/train/train_config.py:760 plugins/train/train_config.py:771 +#: plugins/train/train_config.py:782 msgid "optimizer specific" msgstr "параметры, специфичные для оптимизатора" -#: plugins/train/train_config.py:761 +#: plugins/train/train_config.py:762 msgid "" "The exponential decay rate for the 1st moment estimates. Used for the " "following Optimizers: AdaBelief, Adam, Adamax, AdamW, Lion, nAdam. Ignored " @@ -1165,7 +1164,7 @@ msgstr "" "момента. Применяется только к оптимизаторам: AdaBelief, Adam, Adamax, AdamW, " "Lion, nAdam. Для остальных оптимизаторов игнорируется." -#: plugins/train/train_config.py:772 +#: plugins/train/train_config.py:773 msgid "" "The exponential decay rate for the 2nd moment estimates. Used for the " "following Optimizers: AdaBelief, Adam, Adamax, AdamW, Lion, nAdam. Ignored " @@ -1175,7 +1174,7 @@ msgstr "" "момента. Применяется только к оптимизаторам: AdaBelief, Adam, Adamax, " "AdamW, Lion, nAdam. Для остальных оптимизаторов игнорируется." -#: plugins/train/train_config.py:783 +#: plugins/train/train_config.py:784 msgid "" "Whether to apply AMSGrad variant of the algorithm from the paper 'On the " "Convergence of Adam and beyond. Used for the following Optimizers: " diff --git a/locales/ru/LC_MESSAGES/tools.alignments.cli.mo b/locales/ru/LC_MESSAGES/tools.alignments.cli.mo index 5277c793d260d26c4963accfbb4ab83e4ad9ab1d..0572d19a590c141823fcd313dc9fbcb71c542972 100644 GIT binary patch delta 721 zcmZ{g&1(};6vfXN+eXva+K;hTF%N7y3rS2=VoVoRnusn0KU!SWScc@K4TkBE85@g8 z>qbFc6o;asLj8h@3k!pw8xi~ie2a>kf)@NkJV`7_u@CP2-n;M4J?GsI^V-_HwoshA z8w5I9fI$s-6b9_=z~^1SIRJ;dfnVe;vV9wHwFemH`I-)}4DZM`a)sPUek9B6^Oel< z9P0xtvP!-t*U9(foxMPbO!Z?9r}@M4exSz0?|r}kg|6%eE_DL84*=hI|2@tc8hw)h zp0aM_1kE!4lT6b|Z3viQeueJZN#i6yuQ*0_k!MMkHx}E1?`;?cE;p0^emMkmFzPR( z7wllPGB^YT81#V*Ju`l! z_nx*X{Z_xwfl)hsbqk)a8CsEOGcg*HM3rRbptMewFwlS#64vzzQrQUgNj z!HWee>`}!_L2n)wK@WoefroliLA@x5Ab9iSv)lTzJHMG{-g&=1t!uB=wMUK7=M<0` z15RkbuSuYr0Q3y74B&ni*e170>;azZz#O+Pasb`%ksK$#kQ3w&a+PuZke7JAl?NPh zi`*i!M}U{)YqCkUr!m43AH1Ii)+pqT0YxTyNS>bp!sEQ-{qHjHl!d;l0FUW+a~`-y z{VzGgO77Nx73$9xf&C;dunA(4JVefrbO%Gm#(rZRxUs7c>D?`d5L?t4Yr#JL$yfpQ zlB^@{mP3l|AlGObkwwX)VI2tHXsNNxl}^|7)Jf5``z_1ygYU_!i8af%gWC9$(KT6; zca&$=m1vrE>5Hc4c7$q6;k$jWE_pUP(my@08qyWFOj=}fT24ngN;FJmmc{k9 zwCI`|R3P-hf$|;BEQkT=gM)pHs>jTXXzv&OOV3^yOs+KC%k})p~W8sW3TQNfY ra8659%X1S!FJBE;@=NLPZK0S8Hfv|YQwz&lxLGSCcq#-x7kmEz+ii@a diff --git a/locales/ru/LC_MESSAGES/tools.alignments.cli.po b/locales/ru/LC_MESSAGES/tools.alignments.cli.po index 3f68a44acf..782d7bdd77 100644 --- a/locales/ru/LC_MESSAGES/tools.alignments.cli.po +++ b/locales/ru/LC_MESSAGES/tools.alignments.cli.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-19 11:28+0100\n" -"PO-Revision-Date: 2024-04-19 11:31+0100\n" +"POT-Creation-Date: 2026-03-13 15:17+0000\n" +"PO-Revision-Date: 2026-03-16 18:21+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -17,7 +17,7 @@ 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.4.2\n" +"X-Generator: Poedit 3.8\n" #: tools/alignments/cli.py:16 msgid "" @@ -55,8 +55,8 @@ msgstr "" #: tools/alignments/cli.py:47 msgid "" -" Must Pass in a frames folder/source video file AND a faces folder (-r and -" -"c)." +" Must Pass in a frames folder/source video file AND a faces folder (-r and " +"-c)." msgstr "" " Должно передаваться либо в папку с кадрами/исходным видеофайлом И в папку с " "лицами (-r и -c)." @@ -65,7 +65,7 @@ msgstr "" msgid " Use the output option (-o) to process results." msgstr " Используйте опцию вывода (-o) для обработки результатов." -#: tools/alignments/cli.py:58 tools/alignments/cli.py:104 +#: tools/alignments/cli.py:58 tools/alignments/cli.py:103 msgid "processing" msgstr "обработка" @@ -78,14 +78,13 @@ msgid "" "will be created within the frames folder to hold the output.{0}\n" "L|'export': Export the contents of an alignments file to a json file. Can be " "used for editing alignment information in external tools and then re-" -"importing by using Faceswap's Extract 'Import' plugins. Note: masks and " -"identity vectors will not be included in the exported file, so will be re-" -"generated when the json file is imported back into Faceswap. All data is " -"exported with the origin (0, 0) at the top left of the canvas.\n" -"L|'extract': Re-extract faces from the source frames/video based on " -"alignment data. This is a lot quicker than re-detecting faces. Can pass in " -"the '-een' (--extract-every-n) parameter to only extract every nth frame." -"{1}\n" +"importing by using Faceswap's Extract 'file' plugins for detector and " +"aligner. Note: masks and identity vectors will not be included in the " +"exported file, so can be re-generated when the json file is imported back " +"into Faceswap. All data is exported with the origin (0, 0) at the top left " +"of the canvas.\n" +"L|'extract': [DEPRECATED] Use 'python faceswap.py extract' instead and " +"select 'file' as the aligner plugin. {1}\n" "L|'from-faces': Generate alignment file(s) from a folder of extracted faces. " "if the folder of faces comes from multiple sources, then multiple alignments " "files will be created. NB: for faces which have been extracted from folders " @@ -122,10 +121,10 @@ msgstr "" "включены в экспортированный файл, поэтому будут повторно сгенерированы, " "когда файл JSON будет импортирован обратно в Faceswap. Все данные " "экспортируются с началом координат (0, 0) в верхнем левом углу холста.\n" -"L|'extract': Повторное извлечение лиц из исходных кадров/видео на основе " -"данных о выравнивании. Это намного быстрее, чем повторное обнаружение лиц. " -"Можно передать параметр '-een' (--extract-every-n), чтобы извлекать только " -"каждый n-й кадр.{1}\n" +"L|'extract': [УСТАРЕВШИЙ] Повторное извлечение лиц из исходных кадров/видео " +"на основе данных о выравнивании. Это намного быстрее, чем повторное " +"обнаружение лиц. Можно передать параметр '-een' (--extract-every-n), чтобы " +"извлекать только каждый n-й кадр.{1}\n" "L|'from-faces': Создать файл(ы) выравнивания из папки с извлеченными лицами. " "Если папка с лицами получена из нескольких источников, то будет создано " "несколько файлов выравнивания. Примечание: для лиц, которые были извлечены " @@ -152,7 +151,7 @@ msgstr "" "L|'spatial': Выполнить пространственную и временную фильтрацию для " "сглаживания выравниваний (ЭКСПЕРИМЕНТАЛЬНО!)." -#: tools/alignments/cli.py:107 +#: tools/alignments/cli.py:106 msgid "" "R|How to output discovered items ('faces' and 'frames' only):\n" "L|'console': Print the list of frames to the screen. (DEFAULT)\n" @@ -167,12 +166,12 @@ msgstr "" "каталоге).\n" "L|'move': Переместить обнаруженные элементы в подпапку в исходном каталоге." -#: tools/alignments/cli.py:118 tools/alignments/cli.py:141 -#: tools/alignments/cli.py:148 +#: tools/alignments/cli.py:117 tools/alignments/cli.py:140 +#: tools/alignments/cli.py:147 msgid "data" msgstr "данные" -#: tools/alignments/cli.py:125 +#: tools/alignments/cli.py:124 msgid "" "Full path to the alignments file to be processed. If you have input a " "'frames_dir' and don't provide this option, the process will try to find the " @@ -186,11 +185,11 @@ msgstr "" "задания 'from-faces', когда файл выравнивания будет создан в указанной папке " "с лицами." -#: tools/alignments/cli.py:142 +#: tools/alignments/cli.py:141 msgid "Directory containing source frames that faces were extracted from." msgstr "Папка, содержащая исходные кадры, из которых были извлечены лица." -#: tools/alignments/cli.py:150 +#: tools/alignments/cli.py:149 msgid "" "R|Run the aligmnents tool on multiple sources. The following jobs support " "batch mode:\n" @@ -233,40 +232,42 @@ msgstr "" "выравнивания должен существовать в месте по умолчанию. Для всех остальных " "заданий этот параметр игнорируется." -#: tools/alignments/cli.py:176 tools/alignments/cli.py:188 -#: tools/alignments/cli.py:198 +#: tools/alignments/cli.py:175 tools/alignments/cli.py:187 +#: tools/alignments/cli.py:197 msgid "extract" msgstr "извлечение" -#: tools/alignments/cli.py:178 +#: tools/alignments/cli.py:177 msgid "" -"[Extract only] Extract every 'nth' frame. This option will skip frames when " -"extracting faces. For example a value of 1 will extract faces from every " -"frame, a value of 10 will extract faces from every 10th frame." +"[DEPRECTATED. Extract only] Extract every 'nth' frame. This option will skip " +"frames when extracting faces. For example a value of 1 will extract faces " +"from every frame, a value of 10 will extract faces from every 10th frame." msgstr "" -"[Только извлечение] Извлекать каждый \"n-й\" кадр. Этот параметр пропускает " -"кадры при извлечении лиц. Например, значение 1 будет извлекать лица из " -"каждого кадра, значение 10 будет извлекать лица из каждого 10-го кадра." +"[УСТАРЕВШИЙ. Только извлечение] Извлекать каждый \"n-й\" кадр. Этот параметр " +"пропускает кадры при извлечении лиц. Например, значение 1 будет извлекать " +"лица из каждого кадра, значение 10 будет извлекать лица из каждого 10-го " +"кадра." -#: tools/alignments/cli.py:189 -msgid "[Extract only] The output size of extracted faces." -msgstr "[Только извлечение] Выходной размер извлеченных лиц." +#: tools/alignments/cli.py:188 +msgid "[DEPRECTATED. Extract only] The output size of extracted faces." +msgstr "[УСТАРЕВШИЙ. Только извлечение] Выходной размер извлеченных лиц." -#: tools/alignments/cli.py:200 +#: tools/alignments/cli.py:199 msgid "" -"[Extract only] Only extract faces that have been resized by this percent or " -"more to meet the specified extract size (`-sz`, `--size`). Useful for " -"excluding low-res images from a training set. Set to 0 to extract all faces. " -"Eg: For an extract size of 512px, A setting of 50 will only include faces " -"that have been resized from 256px or above. Setting to 100 will only extract " -"faces that have been resized from 512px or above. A setting of 200 will only " -"extract faces that have been downscaled from 1024px or above." +"[DEPRECTATED. Extract only] Only extract faces that have been resized by " +"this percent or more to meet the specified extract size (`-z`, `--size`). " +"Useful for excluding low-res images from a training set. Set to 0 to extract " +"all faces. Eg: For an extract size of 512px, A setting of 50 will only " +"include faces that have been resized from 256px or above. Setting to 100 " +"will only extract faces that have been resized from 512px or above. A " +"setting of 200 will only extract faces that have been downscaled from 1024px " +"or above." msgstr "" -"[Только извлечение] Извлекать только те лица, размер которых был изменен на " -"данный процент или более, чтобы соответствовать заданному размеру извлечения " -"(`-sz`, `--size`). Полезно для исключения изображений с низким разрешением " -"из обучающего набора. Установите значение 0, чтобы извлечь все лица. " -"Например: Для размера экстракта 512px, при установке значения 50 будут " +"[УСТАРЕВШИЙ. Только извлечение] Извлекать только те лица, размер которых был " +"изменен на данный процент или более, чтобы соответствовать заданному размеру " +"извлечения (`-sz`, `--size`). Полезно для исключения изображений с низким " +"разрешением из обучающего набора. Установите значение 0, чтобы извлечь все " +"лица. Например: Для размера экстракта 512px, при установке значения 50 будут " "извлечены только лица, размер которых был изменен с 256px или выше. При " "значении 100 будут извлечены только лица, размер которых был изменен с 512px " "или выше. При значении 200 будут извлечены только лица, уменьшенные с 1024px " diff --git a/locales/ru/LC_MESSAGES/tools.manual.mo b/locales/ru/LC_MESSAGES/tools.manual.mo index 6e724e6f9d41624a2e5ebc7ae1b68992d799efe8..8eda235827924182ec46da8059acb19742059ea9 100644 GIT binary patch delta 1379 zcmY+@Z)}rA9Ki8k*LE<*m~9z2aPu}>VD69IrZ@|jH8Fy`7{u@|goG66wyIm_HZ?|* zc3lwS&?wC^qA$!scxTL@Z78q}eM5j(chT^|8UKV=yugsCaYUE+y{F3(FX{8SyXT&} z-~H~IKe%`}^|RkIDU=p|Px1TEC9(s5D&s-9?G`zTckmJhJtASo+{FOxf4m|avASHO z61U+t~E{sc~A25T_L z?9~{-&A1nB0KMoT&@r@ueZ=a-Ea&kt{ZsfF>&xeBMf|*2K<1R+@oD@U8*pQlNF%<4 z^>_?x@dFIwB));S(9gu}>qPo!zd}^|@oTgJF7dDdRFM|;D1JPf;)6|8exc){Ph^(% zIMMt;`n%b7g#PEr?+chjo9atEw&NVy0RP0TxPkOMgD>M>%yR0`nAZ6Xh8ho9y6 zu)bO3Ih;XGO@2j=R~{n!aEiUGHe0sXckD^Lvb&Tr+iC9rN9bHKzH=^(LhjGXzk{lC zcJ^Z0Lgt+_ZOS&d11X{swkn+CN+Ck$*4P_m3p;iG7dA}JVWpHW*K8gem+SW0+d?Xx ztIox83fXWD+}=Wos?lWfEgmYHbH^(7yxtQZRk6WS*?=eN9qo^g#B+x|#^Y-pi1iI7 zdf!*{sY8kQd%3!b1^4Ed8tIP>#Z_-|SPjHR-b@Vksgv&vk0giNELnuz6HJhTAl46}$VhSt zGNQ2PLkKii%Sbn0>LtRt^pt@GzScuXu+mHaa~FgL_Wth7ot-&z=0uNA9t|#f+;@dm z%x??7jyRFMI1|r<_QEA{0!MKe-EI*da~`6X{(OSSMqI)a{EIo5xJD!$^Dqg^F&P^$ ziU+VrBq)JIk$MI$U@N}BO&G-un6XwQ6AMwt8*l`VVHQSlJ*KgXZk&s50<1+1sF7WX zRa$U`@m6f6-@H!5!~PQDg*D_EZo@I$j`Ns{zcCxrQ#m;nV>_Ni4-0<8bM(Kai5$iN z(P%(JJaps7$XVn$-emq5ey3lT!5TpZay%l-r27*2tm8vF$F9VusHy#iRk(~ASTTdU z@F4EMZv4YK_izvW+eF)ib9f*9q{WY)u^MHwNDb6rP&e%2E9 zhA9O21eqdZq=}g26OK?9i};9s57F#HYiI1r&SDGwE7*V^P&anv$JVXSC;x2>TxDPd ze_#e1Efk3SqW`l{#WBrx42B zY21NxdD_vk)~02urAz@ijWh|FaLQc*R(EG-2M;Us#dSK==x%cD4;%{jyKQ&)Vv=!% z&!k4=2 && " "n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" "Generated-By: pygettext.py 1.5\n" -"X-Generator: Poedit 3.4.2\n" +"X-Generator: Poedit 3.8\n" #: tools/manual/cli.py:13 msgid "" @@ -78,26 +78,101 @@ msgstr "" "кэширования. Если это происходит, установите этот параметр, чтобы " "генерировать эскизы в более медленном, но более стабильном одном потоке." -#: tools/manual\faceviewer\frame.py:163 +#: tools/manual/face_viewer/frame.py:175 msgid "Display the landmarks mesh" msgstr "Отображение сетки ориентиров" -#: tools/manual\faceviewer\frame.py:164 +#: tools/manual/face_viewer/frame.py:176 msgid "Display the mask" msgstr "Отображение маски" -#: tools/manual\frameviewer\editor\_base.py:628 -#: tools/manual\frameviewer\editor\landmarks.py:44 -#: tools/manual\frameviewer\editor\mask.py:75 +#: tools/manual/frame_viewer/frame.py:79 +msgid "Play/Pause (SPACE)" +msgstr "Воспроизвести/Приостановить (ПРОБЕЛ)" + +#: tools/manual/frame_viewer/frame.py:80 +msgid "Go to First Frame (HOME)" +msgstr "Перейти к первому кадру (HOME)" + +#: tools/manual/frame_viewer/frame.py:81 +msgid "Go to Previous Frame (Z)" +msgstr "Перейти к предыдущему кадру (Z/Я)" + +#: tools/manual/frame_viewer/frame.py:82 +msgid "Go to Next Frame (X)" +msgstr "Перейти к следующему кадру (X/Ч)" + +#: tools/manual/frame_viewer/frame.py:83 +msgid "Go to Last Frame (END)" +msgstr "Перейти к последнему кадру (END)" + +#: tools/manual/frame_viewer/frame.py:84 +msgid "Extract the faces to a folder... (Ctrl+E)" +msgstr "Извлечь лица в папку... (Ctrl+E)" + +#: tools/manual/frame_viewer/frame.py:85 +msgid "Save the Alignments file (Ctrl+S)" +msgstr "Сохранить файл выравнивания (Ctrl+S)" + +#: tools/manual/frame_viewer/frame.py:86 +msgid "Filter Frames to only those Containing the Selected Item (F)" +msgstr "Отфильтровать кадры, содержащие только выбранный элемент (F/А)" + +#: tools/manual/frame_viewer/frame.py:87 +msgid "" +"Set the distance from an 'average face' to be considered misaligned. Higher " +"distances are more restrictive" +msgstr "" +"Установить расстояние от \"среднего лица\", на котором оно будет считаться " +"смещенным. Большие расстояния являются более ограничительными" + +#: tools/manual/frame_viewer/frame.py:392 +msgid "View alignments" +msgstr "Просмотреть выравнивания" + +#: tools/manual/frame_viewer/frame.py:393 +msgid "Bounding box editor" +msgstr "Редактор ограничительных рамок" + +#: tools/manual/frame_viewer/frame.py:394 +msgid "Location editor" +msgstr "Редактор расположения" + +#: tools/manual/frame_viewer/frame.py:395 +msgid "Mask editor" +msgstr "Редактор маски" + +#: tools/manual/frame_viewer/frame.py:396 +msgid "Landmark point editor" +msgstr "Редактор точек ориентира" + +#: tools/manual/frame_viewer/frame.py:471 +msgid "Previous" +msgstr "Предыдущий" + +#: tools/manual/frame_viewer/frame.py:472 +msgid "Next" +msgstr "Следующий" + +#: tools/manual/frame_viewer/frame.py:483 +msgid "Revert to saved Alignments ({})" +msgstr "Откатить до сохраненных выравниваний ({})" + +#: tools/manual/frame_viewer/frame.py:489 +msgid "Copy {} Alignments ({})" +msgstr "Копировать {} выравнивания ({})" + +#: tools/manual/frame_viewer/editor/_base.py:632 +#: tools/manual/frame_viewer/editor/landmarks.py:45 msgid "Magnify/Demagnify the View" msgstr "Увеличение/уменьшение изображения" -#: tools/manual\frameviewer\editor\bounding_box.py:33 -#: tools/manual\frameviewer\editor\extract_box.py:32 +#: tools/manual/frame_viewer/editor/bounding_box.py:34 +#: tools/manual/frame_viewer/editor/extract_box.py:33 msgid "Delete Face" msgstr "Удалить лицо" -#: tools/manual\frameviewer\editor\bounding_box.py:36 +#: tools/manual/frame_viewer/editor/bounding_box.py:37 msgid "" "Bounding Box Editor\n" "Edit the bounding box being fed into the aligner to recalculate the " @@ -118,16 +193,18 @@ msgstr "" "рамку.\n" "- Щелкните правой кнопкой мыши ограничительную рамку, чтобы удалить лицо." -#: tools/manual\frameviewer\editor\bounding_box.py:70 +#: tools/manual/frame_viewer/editor/bounding_box.py:71 msgid "" -"Aligner to use. FAN will obtain better alignments, but cv2-dnn can be useful " -"if FAN cannot get decent alignments and you want to set a base to edit from." +"Aligner to use. HRNet and FAN will obtain better alignments, but cv2-dnn can " +"be useful if these cannot get decent alignments and you want to set a base " +"to edit from." msgstr "" -"Выравниватель для использования. FAN получит лучшие выравнивания, но cv2-dnn " -"может быть полезен, если FAN не может получить достойные выравнивания, и вы " -"хотите установить базу для редактирования." +"Инструмент выравнивания, который следует использовать. HRNet и FAN обеспечат " +"лучшее выравнивание, но cv2-dnn может быть полезен, если эти инструменты не " +"могут обеспечить приемлемое выравнивание, и вы хотите задать базовую модель " +"для редактирования." -#: tools/manual\frameviewer\editor\bounding_box.py:83 +#: tools/manual/frame_viewer/editor/bounding_box.py:84 msgid "" "Normalization method to use for feeding faces to the aligner. This can help " "the aligner better align faces with difficult lighting conditions. Different " @@ -149,7 +226,7 @@ msgstr "" "\thist: Выравнивание гистограмм по каналам RGB.\n" "\tmean: Нормализовать цвета лица к среднему значению." -#: tools/manual\frameviewer\editor\extract_box.py:35 +#: tools/manual/frame_viewer/editor/extract_box.py:36 msgid "" "Extract Box Editor\n" "Move the extract box that has been generated by the aligner. Click and " @@ -167,7 +244,7 @@ msgstr "" "- По угловым опорам для изменения размера опорных точек.\n" "- За пределами углов, чтобы повернуть опорные точки." -#: tools/manual\frameviewer\editor\landmarks.py:27 +#: tools/manual/frame_viewer/editor/landmarks.py:28 msgid "" "Landmark Point Editor\n" "Edit the individual landmark points.\n" @@ -181,7 +258,7 @@ msgstr "" " - Щелкните и перетащите отдельные точки для перемещения.\n" " - Нарисуйте рамку, чтобы выбрать несколько точек для перемещения." -#: tools/manual\frameviewer\editor\mask.py:33 +#: tools/manual/frame_viewer/editor/mask.py:43 msgid "" "Mask Editor\n" "Edit the mask.\n" @@ -197,98 +274,30 @@ msgstr "" "маску напрямую. Любое изменение ориентиров после редактирования маски " "отменит ваши ручные правки." -#: tools/manual\frameviewer\editor\mask.py:77 +#: tools/manual/frame_viewer/editor/mask.py:91 +msgid "Magnify/De-magnify the View" +msgstr "Увеличение/уменьшение изображения" + +#: tools/manual/frame_viewer/editor/mask.py:93 msgid "Draw Tool" msgstr "Инструмент рисования" -#: tools/manual\frameviewer\editor\mask.py:78 +#: tools/manual/frame_viewer/editor/mask.py:94 msgid "Erase Tool" msgstr "Инструмент \"Ластик\"" -#: tools/manual\frameviewer\editor\mask.py:97 +#: tools/manual/frame_viewer/editor/mask.py:115 msgid "Select which mask to edit" msgstr "Выбрать, какую маску редактировать" -#: tools/manual\frameviewer\editor\mask.py:104 +#: tools/manual/frame_viewer/editor/mask.py:122 msgid "Set the brush size. ([ - decrease, ] - increase)" msgstr "Установить размер кисти. ([ - уменьшение, ] - увеличение)" -#: tools/manual\frameviewer\editor\mask.py:111 +#: tools/manual/frame_viewer/editor/mask.py:129 msgid "Select the brush cursor color." msgstr "Установить цвет курсора кисти." -#: tools/manual\frameviewer\frame.py:78 -msgid "Play/Pause (SPACE)" -msgstr "Воспроизвести/Приостановить (ПРОБЕЛ)" - -#: tools/manual\frameviewer\frame.py:79 -msgid "Go to First Frame (HOME)" -msgstr "Перейти к первому кадру (HOME)" - -#: tools/manual\frameviewer\frame.py:80 -msgid "Go to Previous Frame (Z)" -msgstr "Перейти к предыдущему кадру (Z/Я)" - -#: tools/manual\frameviewer\frame.py:81 -msgid "Go to Next Frame (X)" -msgstr "Перейти к следующему кадру (X/Ч)" - -#: tools/manual\frameviewer\frame.py:82 -msgid "Go to Last Frame (END)" -msgstr "Перейти к последнему кадру (END)" - -#: tools/manual\frameviewer\frame.py:83 -msgid "Extract the faces to a folder... (Ctrl+E)" -msgstr "Извлечь лица в папку... (Ctrl+E)" - -#: tools/manual\frameviewer\frame.py:84 -msgid "Save the Alignments file (Ctrl+S)" -msgstr "Сохранить файл выравнивания (Ctrl+S)" - -#: tools/manual\frameviewer\frame.py:85 -msgid "Filter Frames to only those Containing the Selected Item (F)" -msgstr "Отфильтровать кадры, содержащие только выбранный элемент (F/А)" - -#: tools/manual\frameviewer\frame.py:86 -msgid "" -"Set the distance from an 'average face' to be considered misaligned. Higher " -"distances are more restrictive" -msgstr "" -"Установить расстояние от \"среднего лица\", на котором оно будет считаться " -"смещенным. Большие расстояния являются более ограничительными" - -#: tools/manual\frameviewer\frame.py:391 -msgid "View alignments" -msgstr "Просмотреть выравнивания" - -#: tools/manual\frameviewer\frame.py:392 -msgid "Bounding box editor" -msgstr "Редактор ограничительных рамок" - -#: tools/manual\frameviewer\frame.py:393 -msgid "Location editor" -msgstr "Редактор расположения" - -#: tools/manual\frameviewer\frame.py:394 -msgid "Mask editor" -msgstr "Редактор маски" - -#: tools/manual\frameviewer\frame.py:395 -msgid "Landmark point editor" -msgstr "Редактор точек ориентира" - -#: tools/manual\frameviewer\frame.py:470 -msgid "Next" -msgstr "Следующий" - -#: tools/manual\frameviewer\frame.py:470 -msgid "Previous" -msgstr "Предыдущий" - -#: tools/manual\frameviewer\frame.py:481 -msgid "Revert to saved Alignments ({})" -msgstr "Откатить до сохраненных выравниваний ({})" - -#: tools/manual\frameviewer\frame.py:487 -msgid "Copy {} Alignments ({})" -msgstr "Копировать {} выравнивания ({})" +#: tools/manual/frame_viewer/editor/mask.py:136 +msgid "Select a shape for masking cursor." +msgstr "Установить цвет курсора кисти." diff --git a/locales/ru/LC_MESSAGES/tools.mask.cli.mo b/locales/ru/LC_MESSAGES/tools.mask.cli.mo index 89631d0cd9acc64dbaf1c7f1051efc88e585f690..5b322c821f832ed8be3bc913a8e4dd1c0983b43f 100644 GIT binary patch delta 382 zcmXZXKS)AB90&04^+z)*?VtaOeDrKck)9S}4nbj%Q$*0vCixz`CNJQ{+Qps1lyf`*_;!l|Jlf}pR^EuXvl{qB2rm#3n7EUewqS33~t0}5?GXaHEF zCquw3fQA=1qE#QTPAC092R)~nv0ly$D3=@aYqG2nq# z6qHbB!^Sw!q$w46WkC%CKfLL80*EsCI|AgpfN%mZxL-~K_YC};12pzu&-3xLu>ia< z$B_Yg=o{r5(4^h8a}nSl;h}7Y8+K6yU7UgG7`D%Z>S`TqjX`-QqP=#_{Vq5AGfv-> zQOs@YJ4&IbN{)Pb%?LIx!Q~G-(CBLZsx?NG%`_k{?1= OW>?(y@>0NdWM?=K0?Dd*0`J-uIiy8_J^z z<+hWWC?VQkPGoK%%2yJd1GZKX6^Q6c4bd3zWG&Ho;45G;u(6J4BXHj;27#rB4*_Ee z(J^2jbf)TwhLHcZ6Z?pbT|~>+A7n&}lDC^u^csYcMxxKacfhyUSlmnW9Vb0(B5H>6 zgBIjA5iNBRMUcOBl;|Y{zn&lpf}ianY5;yZP4osjfnK7`!1q9Sp%oy?sjQD^JFpT6 zI#p8IQtASdT85X>ca=SX9dd=I!hNAMZwZ)*0n^}yW2Y}}t4~GLu&&0L&HEF`sg7+?W5-$8GzR$)izef7rrR>K9V;1j zAfsAl($IV(rkCMxFSX*1ZI^M<|4!j(Z@%zD>--q^aTQ1eFp1G2yodG?HVQ!Jo<4yH1` z88Hn}IP|U|nQ9TYQ0AOZWJv{&d;BvY;*N?Fg;v=H;y^3 h%}3rHQW&Uzqd8Gj5Yz6xuCo=&{q&hrHRh93sZEdKxi diff --git a/locales/ru/LC_MESSAGES/tools.mask.cli.po b/locales/ru/LC_MESSAGES/tools.mask.cli.po index 6cabf81c53..f7bab3c9f6 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: 2024-06-28 13:45+0100\n" -"PO-Revision-Date: 2024-06-28 13:48+0100\n" +"POT-Creation-Date: 2026-03-13 15:17+0000\n" +"PO-Revision-Date: 2026-03-16 18:24+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -17,9 +17,9 @@ 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.4.4\n" +"X-Generator: Poedit 3.8\n" -#: tools/mask/cli.py:15 +#: tools/mask/cli.py:16 msgid "" "This tool allows you to generate, import, export or preview masks for " "existing alignments." @@ -27,7 +27,7 @@ msgstr "" "Этот инструмент позволяет создавать, импортировать, экспортировать или " "просматривать маски для существующих трасс." -#: tools/mask/cli.py:25 +#: tools/mask/cli.py:26 msgid "" "Mask tool\n" "Generate, import, export or preview masks for existing alignments files." @@ -36,12 +36,12 @@ msgstr "" "Создавайте, импортируйте, экспортируйте или просматривайте маски для " "существующих файлов трасс." -#: tools/mask/cli.py:35 tools/mask/cli.py:47 tools/mask/cli.py:58 -#: tools/mask/cli.py:69 +#: tools/mask/cli.py:36 tools/mask/cli.py:48 tools/mask/cli.py:59 +#: tools/mask/cli.py:70 msgid "data" msgstr "данные" -#: tools/mask/cli.py:39 +#: tools/mask/cli.py:40 msgid "" "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 " @@ -53,13 +53,13 @@ msgstr "" "обновить соответствующий файл выравнивания, то вы должны указать значение " "здесь, так как местоположение не может быть определено автоматически." -#: tools/mask/cli.py:51 +#: tools/mask/cli.py:52 msgid "Directory containing extracted faces, source frames, or a video file." msgstr "Папка, содержащая извлеченные лица, исходные кадры или видеофайл." -#: tools/mask/cli.py:61 +#: tools/mask/cli.py:62 msgid "" -"R|Whether the `input` is a folder of faces or a folder frames/video\n" +"R|Whether the `input` is a folder of faces/frames or a video file\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 "" @@ -67,7 +67,7 @@ msgstr "" "L|faces: Входом является папка, содержащая извлеченные лица.\n" "L|frames: Входом является папка с кадрами или видео" -#: tools/mask/cli.py:71 +#: tools/mask/cli.py:72 msgid "" "R|Run the mask tool on multiple sources. If selected then the other options " "should be set as follows:\n" @@ -91,27 +91,20 @@ msgstr "" "При пакетной обработке масок с типом входа \"лица\" будут обновлены только " "заголовки PNG в извлеченных лицах." -#: tools/mask/cli.py:87 tools/mask/cli.py:119 +#: tools/mask/cli.py:88 tools/mask/cli.py:114 msgid "process" msgstr "обработка" -#: tools/mask/cli.py:89 +#: tools/mask/cli.py:90 msgid "" "R|Masker to use.\n" "L|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).\n" -"L|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.\n" "L|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.\n" -"L|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.\n" "L|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.\n" @@ -127,16 +120,10 @@ msgstr "" "L|bisenet-fp: Относительно легкая маска на основе NN, которая обеспечивает " "более точный контроль над маскируемой областью, включая полное маскирование " "головы (настраивается в настройках маски).\n" -"L|components: Маска, разработанная для сегментации лица на основе " -"расположения ориентиров. Для создания маски вокруг внешних ориентиров " -"строится выпуклая оболочка.\n" "L|custom (пользовательская): Фиктивная маска, которая заполняет область " "маски всеми 1 или 0 (настраивается в настройках). Она необходима только в " "том случае, если вы собираетесь вручную редактировать пользовательские маски " "в ручном инструменте. Эта маска не использует GPU.\n" -"L|extended: Маска предназначена для сегментации лица на основе расположения " -"ориентиров. Выпуклая оболочка строится вокруг внешних ориентиров, и маска " -"расширяется вверх на лоб.\n" "L|vgg-clear: Маска предназначена для интеллектуальной сегментации " "преимущественно фронтальных лиц без препятствий. Профильные лица и " "препятствия могут привести к снижению производительности.\n" @@ -149,7 +136,7 @@ msgstr "" "сообщества и для дальнейшего описания нуждается в тестировании. Профильные " "лица могут иметь низкую производительность." -#: tools/mask/cli.py:121 +#: tools/mask/cli.py:116 msgid "" "R|The Mask tool process to perform.\n" "L|all: Update the mask for all faces in the alignments file for the selected " @@ -175,11 +162,11 @@ msgstr "" "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:176 +#: tools/mask/cli.py:130 tools/mask/cli.py:149 tools/mask/cli.py:171 msgid "import" -msgstr "Импортировать" +msgstr "импортировать" -#: tools/mask/cli.py:137 +#: tools/mask/cli.py:132 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, " @@ -206,7 +193,7 @@ msgstr "" "кадра должен правильно соответствовать номеру кадра в исходном видео " "(начиная с кадра 1)." -#: tools/mask/cli.py:156 +#: tools/mask/cli.py:151 msgid "" "R|Import/Output only. When importing masks, this is the centering to use. " "For output this is only used for outputting custom imported masks, and " @@ -241,7 +228,7 @@ msgstr "" "приближает ее к лицу. Это может привести к тому, что края маски окажутся за " "пределами тренировочной зоны." -#: tools/mask/cli.py:181 +#: tools/mask/cli.py:176 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 " @@ -252,12 +239,12 @@ msgstr "" "использования. Большие размеры приведут к увеличению размера файлов " "выравниваний и более длительной обработке." -#: tools/mask/cli.py:189 tools/mask/cli.py:197 tools/mask/cli.py:211 -#: tools/mask/cli.py:225 tools/mask/cli.py:235 +#: tools/mask/cli.py:184 tools/mask/cli.py:192 tools/mask/cli.py:206 +#: tools/mask/cli.py:220 tools/mask/cli.py:230 msgid "output" msgstr "вывод" -#: tools/mask/cli.py:191 +#: tools/mask/cli.py:186 msgid "" "Optional output location. If provided, a preview of the masks created will " "be output in the given folder." @@ -265,7 +252,7 @@ msgstr "" "Необязательное местоположение вывода. Если указано, предварительный просмотр " "созданных масок будет выведен в указанную папку." -#: tools/mask/cli.py:202 +#: tools/mask/cli.py:197 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 " @@ -278,7 +265,7 @@ msgstr "" "Примечание: влияет только на предварительный просмотр. Установите значение 0 " "для выключения" -#: tools/mask/cli.py:216 +#: tools/mask/cli.py:211 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 " @@ -289,7 +276,7 @@ msgstr "" "часть маски. Примечание: влияет только на предварительный просмотр. " "Установите значение 0 для выключения" -#: tools/mask/cli.py:227 +#: tools/mask/cli.py:222 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" @@ -302,7 +289,7 @@ msgstr "" "L|masked: Вывести лицо/кадр как изображение rgba с маскированным лицом.\n" "L|mask: Выводить только маску как одноканальное изображение." -#: tools/mask/cli.py:237 +#: tools/mask/cli.py:232 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/locales/ru/LC_MESSAGES/tools.sort.cli.mo b/locales/ru/LC_MESSAGES/tools.sort.cli.mo index 6b832be91e8345d476597eee9b2038f8d2e4d451..97bf71f9c954b2ff1a02268c1c58c2e889d9e53d 100644 GIT binary patch delta 5368 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 delta 878 zcmZY7OGp(_7{KwrdebyDUu9~VH|=FvSFc&u#F7@(rl?khxhjZ~7C}&g+#=cql_0ce zkwM_97Sm(V18QZoE3lxF5}}BMLdYnH{xkRDrUNs-IgdFr=li}#w{rfRO;1+DMzA$| zE%)lqfz8^N>&=$PgS*bAUkHQl z{kstEL7FqgZtlXC5}43F-Kxn_7y810`Yk>$hSmB_N42sn)4#7(_TOt;FgT*1;5W5A1*p@s1nZW2i>Fn?s{6sG(2(L67c7lQ~wc`!byLyRFCOk zHJsCr%ix9{uFbqZR|j1#KVf)x>22MqGx{{`!nS%hHzeO1+}Q9I$Ki!Xu5E_B&QGs} zBfgMqfmimg>5%=S8{muYO{qcTI7`Jf57lI>9F?d1HJ70FHLWA;8GI& z2G4ZJiLbiO{&5Q-*Zy$UkxuKx{`D*lgM_seweU3Y>~n~b#H?VgVbtfF!HH64258KVF3K9E z5Kn6QFzT%I3Jb&fqTI{q&$lqVW+y77qF^NNN^a1Sf2trDty)!B($Showzj7l6N#=w dXYjhdCMU0BV|y^uFdGYIn#N0l*`D1M{{YljWitQ( diff --git a/locales/ru/LC_MESSAGES/tools.sort.cli.po b/locales/ru/LC_MESSAGES/tools.sort.cli.po index 9b76d494ae..c4874865ec 100644 --- a/locales/ru/LC_MESSAGES/tools.sort.cli.po +++ b/locales/ru/LC_MESSAGES/tools.sort.cli.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 23:53+0000\n" -"PO-Revision-Date: 2024-03-29 00:06+0000\n" +"POT-Creation-Date: 2026-03-13 15:17+0000\n" +"PO-Revision-Date: 2026-03-16 18:32+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -17,20 +17,20 @@ 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.4.2\n" +"X-Generator: Poedit 3.8\n" -#: tools/sort/cli.py:15 +#: tools/sort/cli.py:17 msgid "This command lets you sort images using various methods." msgstr "Эта команда позволяет сортировать изображения различными методами." -#: tools/sort/cli.py:21 +#: tools/sort/cli.py:23 msgid "" " Adjust the '-t' ('--threshold') parameter to control the strength of " "grouping." msgstr "" " Настройте параметр '-t' ('--threshold') для контроля силы группировки." -#: tools/sort/cli.py:22 +#: tools/sort/cli.py:24 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. Each image is allocated to a bin by the percentage of color pixels " @@ -40,7 +40,7 @@ msgstr "" "группировки. Каждое изображение распределяется по корзинкам в зависимости от " "процента цветных пикселей, присутствующих в изображении." -#: tools/sort/cli.py:25 +#: tools/sort/cli.py:27 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. Each image is allocated to a bin by the number of degrees the face " @@ -50,7 +50,7 @@ msgstr "" "группировки. Каждое изображение распределяется по корзинам по количеству " "градусов, на которые лицо ориентировано от центра." -#: tools/sort/cli.py:28 +#: tools/sort/cli.py:30 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. The minimum and maximum values are taken for the chosen sort " @@ -61,15 +61,15 @@ msgstr "" "максимальное значения. Затем корзины заполняются результатами групповой " "сортировки." -#: tools/sort/cli.py:32 +#: tools/sort/cli.py:34 msgid "faces by blurriness." msgstr "лица по размытости." -#: tools/sort/cli.py:33 +#: tools/sort/cli.py:35 msgid "faces by fft filtered blurriness." msgstr "лица по размытости с фильтрацией fft." -#: tools/sort/cli.py:34 +#: tools/sort/cli.py:36 msgid "" "faces by the estimated distance of the alignments from an 'average' face. " "This can be useful for eliminating misaligned faces. Sorts from most like an " @@ -79,7 +79,7 @@ msgstr "" "быть полезно для устранения неправильно расположенных лиц. Сортирует от " "наиболее похожего на среднее лицо к наименее похожему на среднее лицо." -#: tools/sort/cli.py:37 +#: tools/sort/cli.py:39 msgid "" "faces using VGG Face2 by face similarity. This uses a pairwise clustering " "algorithm to check the distances between 512 features on every face in your " @@ -89,23 +89,23 @@ msgstr "" "парной кластеризации для проверки расстояний между 512 признаками на каждом " "лице в вашем наборе и их упорядочивания соответствующим образом." -#: tools/sort/cli.py:40 +#: tools/sort/cli.py:42 msgid "faces by their landmarks." msgstr "лица по их ориентирам." -#: tools/sort/cli.py:41 +#: tools/sort/cli.py:43 msgid "Like 'face-cnn' but sorts by dissimilarity." msgstr "Как 'face-cnn', но сортирует по непохожести." -#: tools/sort/cli.py:42 +#: tools/sort/cli.py:44 msgid "faces by Yaw (rotation left to right)." msgstr "лица по Yaw (вращение слева направо)." -#: tools/sort/cli.py:43 +#: tools/sort/cli.py:45 msgid "faces by Pitch (rotation up and down)." msgstr "лица по Pitch (вращение вверх и вниз)." -#: tools/sort/cli.py:44 +#: tools/sort/cli.py:46 msgid "" "faces by Roll (rotation). Aligned faces should have a roll value close to " "zero. The further the Roll value from zero the higher liklihood the face is " @@ -115,22 +115,22 @@ msgstr "" "близкое к нулю. Чем дальше значение Roll от нуля, тем выше вероятность того, " "что лицо неправильно выровнено." -#: tools/sort/cli.py:46 +#: tools/sort/cli.py:48 msgid "faces by their color histogram." msgstr "лица по их цветовой гистограмме." -#: tools/sort/cli.py:47 +#: tools/sort/cli.py:49 msgid "Like 'hist' but sorts by dissimilarity." msgstr "Как 'hist', но сортирует по непохожести." -#: tools/sort/cli.py:48 +#: tools/sort/cli.py:50 msgid "" "images by the average intensity of the converted grayscale color channel." msgstr "" "изображения по средней интенсивности преобразованного полутонового цветового " "канала." -#: tools/sort/cli.py:49 +#: tools/sort/cli.py:51 msgid "" "images by their number of black pixels. Useful when faces are near borders " "and a large part of the image is black." @@ -138,7 +138,7 @@ msgstr "" "изображения по количеству черных пикселей. Полезно, когда лица находятся " "вблизи границ и большая часть изображения черная." -#: tools/sort/cli.py:51 +#: tools/sort/cli.py:53 msgid "" "images by the average intensity of the converted Y color channel. Bright " "lighting and oversaturated images will be ranked first." @@ -147,7 +147,7 @@ msgstr "" "Яркое освещение и перенасыщенные изображения будут ранжироваться в первую " "очередь." -#: tools/sort/cli.py:53 +#: tools/sort/cli.py:55 msgid "" "images by the average intensity of the converted Cg color channel. Green " "images will be ranked first and red images will be last." @@ -155,7 +155,7 @@ msgstr "" "изображений по средней интенсивности преобразованного цветового канала Cg. " "Зеленые изображения занимают первое место, а красные - последнее." -#: tools/sort/cli.py:55 +#: tools/sort/cli.py:57 msgid "" "images by the average intensity of the converted Co color channel. Orange " "images will be ranked first and blue images will be last." @@ -163,7 +163,7 @@ msgstr "" "изображений по средней интенсивности преобразованного цветового канала Co. " "Оранжевые изображения занимают первое место, а синие - последнее." -#: tools/sort/cli.py:57 +#: tools/sort/cli.py:59 msgid "" "images by their size in the original frame. Faces further from the camera " "and from lower resolution sources will be sorted first, whilst faces closer " @@ -174,20 +174,28 @@ msgstr "" "первыми, а лица, расположенные ближе к камере и полученные из источников с " "высоким разрешением, будут отсортированы последними." -#: tools/sort/cli.py:81 +#: tools/sort/cli.py:72 +msgid "Sort" +msgstr "Сортировка" + +#: tools/sort/cli.py:73 +msgid "Group" +msgstr "Группа" + +#: tools/sort/cli.py:83 msgid "Sort faces using a number of different techniques" msgstr "Сортировка лиц с использованием различных методов" -#: tools/sort/cli.py:91 tools/sort/cli.py:98 tools/sort/cli.py:110 -#: tools/sort/cli.py:150 +#: tools/sort/cli.py:93 tools/sort/cli.py:100 tools/sort/cli.py:112 +#: tools/sort/cli.py:152 msgid "data" msgstr "данные" -#: tools/sort/cli.py:92 +#: tools/sort/cli.py:94 msgid "Input directory of aligned faces." msgstr "Входная папка соотнесенных лиц." -#: tools/sort/cli.py:100 +#: tools/sort/cli.py:102 msgid "" "Output directory for sorted aligned faces. If not provided and 'keep' is " "selected then a new folder called 'sorted' will be created within the input " @@ -201,7 +209,7 @@ msgstr "" "'keep', то изображения будут отсортированы на месте, перезаписывая исходное " "содержимое 'input_dir'." -#: tools/sort/cli.py:112 +#: tools/sort/cli.py:114 msgid "" "R|If selected then the input_dir should be a parent folder containing " "multiple folders of faces you wish to sort. The faces will be output to " @@ -211,11 +219,11 @@ msgstr "" "несколько папок с лицами, которые вы хотите отсортировать. Лица будут " "выведены в отдельные вложенные папки в output_dir" -#: tools/sort/cli.py:121 +#: tools/sort/cli.py:123 msgid "sort settings" msgstr "настройки сортировки" -#: tools/sort/cli.py:124 +#: tools/sort/cli.py:126 msgid "" "R|Choose how images are sorted. Selecting a sort method gives the images a " "new filename based on the order the image appears within the given method.\n" @@ -232,19 +240,11 @@ msgstr "" "корзины, но файлы сохранят свои оригинальные имена. Выбор значения 'none' " "как для 'sort-by', так и для 'group-by' ничего не даст" -#: tools/sort/cli.py:136 tools/sort/cli.py:164 tools/sort/cli.py:184 +#: tools/sort/cli.py:138 tools/sort/cli.py:166 tools/sort/cli.py:186 msgid "group settings" msgstr "настройки группировки" -#: tools/sort/cli.py:139 -#, fuzzy -#| msgid "" -#| "R|Selecting a group by method will move/copy files into numbered bins " -#| "based on the selected method.\n" -#| "L|'none': Don't bin the images. Folders will be sorted by the selected " -#| "'sort-by' but will not be binned, instead they will be sorted into a " -#| "single folder. Selecting 'none' for both 'sort-by' and 'group-by' will " -#| "do nothing" +#: tools/sort/cli.py:141 msgid "" "R|Selecting a group by method will move/copy files into numbered bins based " "on the selected method.\n" @@ -259,7 +259,7 @@ msgstr "" "отсортированы в одну папку. Выбор значения 'none' как для 'sort-by', так и " "для 'group-by' ничего не даст" -#: tools/sort/cli.py:152 +#: tools/sort/cli.py:154 msgid "" "Whether to keep the original files in their original location. Choosing a " "'sort-by' method means that the files have to be renamed. Selecting 'keep' " @@ -275,7 +275,7 @@ msgstr "" "что исходные файлы будут перемещены и переименованы в соответствии с " "выбранными критериями сортировки/группировки." -#: tools/sort/cli.py:167 +#: tools/sort/cli.py:169 msgid "" "R|Float value. Minimum threshold to use for grouping comparison with 'face-" "cnn' 'hist' and 'face' methods.\n" @@ -303,29 +303,8 @@ msgstr "" "количеством изображений, так как это может привести к созданию большого " "количества папок. По умолчанию: face-cnn 7.2, hist 0.3, face 0.25" -#: tools/sort/cli.py:187 -#, fuzzy, python-format -#| msgid "" -#| "R|Integer value. Used to control the number of bins created for grouping " -#| "by: any 'blur' methods, 'color' methods or 'face metric' methods " -#| "('distance', 'size') and 'orientation; methods ('yaw', 'pitch'). For any " -#| "other grouping methods see the '-t' ('--threshold') option.\n" -#| "L|For 'face metric' methods the bins are filled, according the the " -#| "distribution of faces between the minimum and maximum chosen metric.\n" -#| "L|For 'color' methods the number of bins represents the divider of the " -#| "percentage of colored pixels. Eg. For a bin number of '5': The first " -#| "folder will have the faces with 0%% to 20%% colored pixels, second 21%% " -#| "to 40%%, etc. Any empty bins will be deleted, so you may end up with " -#| "fewer bins than selected.\n" -#| "L|For 'blur' methods folder 0 will be the least blurry, while the last " -#| "folder will be the blurriest.\n" -#| "L|For 'orientation' methods the number of bins is dictated by how much " -#| "180 degrees is divided. Eg. If 18 is selected, then each folder will be a " -#| "10 degree increment. Folder 0 will contain faces looking the most to the " -#| "left/down whereas the last folder will contain the faces looking the most " -#| "to the right/up. NB: Some bins may be empty if faces do not fit the " -#| "criteria.\n" -#| "Default value: 5" +#: tools/sort/cli.py:189 +#, python-format msgid "" "R|Integer value. Used to control the number of bins created for grouping by: " "any 'blur' methods, 'color' methods or 'face metric' methods ('distance', " @@ -350,8 +329,8 @@ msgstr "" "R| Целочисленное значение. Используется для управления количеством бинов, " "создаваемых для группировки: любыми методами 'размытия', 'цвета' или " "методами 'метрики лица' ('расстояние', 'размер') и 'ориентации; методы " -"('yaw', 'pitch'). Для любых других методов группировки смотрите опцию '-" -"t' ('--threshold').\n" +"('yaw', 'pitch'). Для любых других методов группировки смотрите опцию '-t' " +"('--threshold').\n" "L|Для методов 'face metric' бины заполняются в соответствии с распределением " "лиц между минимальной и максимальной выбранной метрикой.\n" "L|Для методов 'color' количество бинов представляет собой делитель процента " @@ -369,11 +348,28 @@ msgstr "" "лица не соответствуют критериям.\n" "Значение по умолчанию: 5" -#: tools/sort/cli.py:207 tools/sort/cli.py:217 +#: tools/sort/cli.py:211 tools/sort/cli.py:223 tools/sort/cli.py:233 msgid "settings" msgstr "настройки" -#: tools/sort/cli.py:210 +#: tools/sort/cli.py:214 +msgid "" +"R|The identity plugin to use when sorting/grouping by face. \n" +"L|t-face: An InsightFace ResNet based model with a lighter and heavier " +"variant (configurable in settings).\n" +"L|vggface2: An older and lighter, but fairly reliable plugin based on the " +"VGG Network.\n" +"Default: t-face" +msgstr "" +"R|Плагин идентификации для использования при сортировке/группировке по " +"лицу.\n" +"L|t-face: Модель на основе ResNet от InsightFace с более лёгким и более " +"тяжёлым вариантами (настраивается в параметрах).\n" +"L|vggface2: Более старый и лёгкий, но достаточно надёжный плагин на основе " +"сети VGG.\n" +"По умолчанию: t-face" + +#: tools/sort/cli.py:226 msgid "" "Logs file renaming changes if grouping by renaming, or it logs the file " "copying/movement if grouping by folders. If no log file is specified with " @@ -385,7 +381,7 @@ msgstr "" "папкам. Если файл журнала не указан с помощью '--log-file', то в каталоге " "ввода будет создан файл 'sort_log.json'." -#: tools/sort/cli.py:221 +#: tools/sort/cli.py:237 msgid "" "Specify a log file to use for saving the renaming or grouping information. " "If specified extension isn't 'json' or 'yaml', then json will be used as the " diff --git a/locales/tools.alignments.cli.pot b/locales/tools.alignments.cli.pot index 4f1e02ae15..8fb5279453 100644 --- a/locales/tools.alignments.cli.pot +++ b/locales/tools.alignments.cli.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-19 11:28+0100\n" +"POT-Creation-Date: 2026-03-13 15:17+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -45,15 +45,15 @@ msgstr "" #: tools/alignments/cli.py:47 msgid "" -" Must Pass in a frames folder/source video file AND a faces folder (-r and -" -"c)." +" Must Pass in a frames folder/source video file AND a faces folder (-r and " +"-c)." msgstr "" #: tools/alignments/cli.py:49 msgid " Use the output option (-o) to process results." msgstr "" -#: tools/alignments/cli.py:58 tools/alignments/cli.py:104 +#: tools/alignments/cli.py:58 tools/alignments/cli.py:103 msgid "processing" msgstr "" @@ -66,14 +66,13 @@ msgid "" "will be created within the frames folder to hold the output.{0}\n" "L|'export': Export the contents of an alignments file to a json file. Can be " "used for editing alignment information in external tools and then re-" -"importing by using Faceswap's Extract 'Import' plugins. Note: masks and " -"identity vectors will not be included in the exported file, so will be re-" -"generated when the json file is imported back into Faceswap. All data is " -"exported with the origin (0, 0) at the top left of the canvas.\n" -"L|'extract': Re-extract faces from the source frames/video based on " -"alignment data. This is a lot quicker than re-detecting faces. Can pass in " -"the '-een' (--extract-every-n) parameter to only extract every nth frame." -"{1}\n" +"importing by using Faceswap's Extract 'file' plugins for detector and " +"aligner. Note: masks and identity vectors will not be included in the " +"exported file, so can be re-generated when the json file is imported back " +"into Faceswap. All data is exported with the origin (0, 0) at the top left " +"of the canvas.\n" +"L|'extract': [DEPRECATED] Use 'python faceswap.py extract' instead and " +"select 'file' as the aligner plugin. {1}\n" "L|'from-faces': Generate alignment file(s) from a folder of extracted faces. " "if the folder of faces comes from multiple sources, then multiple alignments " "files will be created. NB: for faces which have been extracted from folders " @@ -100,7 +99,7 @@ msgid "" "(EXPERIMENTAL!)" msgstr "" -#: tools/alignments/cli.py:107 +#: tools/alignments/cli.py:106 msgid "" "R|How to output discovered items ('faces' and 'frames' only):\n" "L|'console': Print the list of frames to the screen. (DEFAULT)\n" @@ -110,12 +109,12 @@ msgid "" "directory." msgstr "" -#: tools/alignments/cli.py:118 tools/alignments/cli.py:141 -#: tools/alignments/cli.py:148 +#: tools/alignments/cli.py:117 tools/alignments/cli.py:140 +#: tools/alignments/cli.py:147 msgid "data" msgstr "" -#: tools/alignments/cli.py:125 +#: tools/alignments/cli.py:124 msgid "" "Full path to the alignments file to be processed. If you have input a " "'frames_dir' and don't provide this option, the process will try to find the " @@ -124,11 +123,11 @@ msgid "" "generated in the specified faces folder." msgstr "" -#: tools/alignments/cli.py:142 +#: tools/alignments/cli.py:141 msgid "Directory containing source frames that faces were extracted from." msgstr "" -#: tools/alignments/cli.py:150 +#: tools/alignments/cli.py:149 msgid "" "R|Run the aligmnents tool on multiple sources. The following jobs support " "batch mode:\n" @@ -150,29 +149,30 @@ msgid "" "ignored." msgstr "" -#: tools/alignments/cli.py:176 tools/alignments/cli.py:188 -#: tools/alignments/cli.py:198 +#: tools/alignments/cli.py:175 tools/alignments/cli.py:187 +#: tools/alignments/cli.py:197 msgid "extract" msgstr "" -#: tools/alignments/cli.py:178 +#: tools/alignments/cli.py:177 msgid "" -"[Extract only] Extract every 'nth' frame. This option will skip frames when " -"extracting faces. For example a value of 1 will extract faces from every " -"frame, a value of 10 will extract faces from every 10th frame." +"[DEPRECTATED. Extract only] Extract every 'nth' frame. This option will skip " +"frames when extracting faces. For example a value of 1 will extract faces " +"from every frame, a value of 10 will extract faces from every 10th frame." msgstr "" -#: tools/alignments/cli.py:189 -msgid "[Extract only] The output size of extracted faces." +#: tools/alignments/cli.py:188 +msgid "[DEPRECTATED. Extract only] The output size of extracted faces." msgstr "" -#: tools/alignments/cli.py:200 +#: tools/alignments/cli.py:199 msgid "" -"[Extract only] Only extract faces that have been resized by this percent or " -"more to meet the specified extract size (`-sz`, `--size`). Useful for " -"excluding low-res images from a training set. Set to 0 to extract all faces. " -"Eg: For an extract size of 512px, A setting of 50 will only include faces " -"that have been resized from 256px or above. Setting to 100 will only extract " -"faces that have been resized from 512px or above. A setting of 200 will only " -"extract faces that have been downscaled from 1024px or above." +"[DEPRECTATED. Extract only] Only extract faces that have been resized by " +"this percent or more to meet the specified extract size (`-z`, `--size`). " +"Useful for excluding low-res images from a training set. Set to 0 to extract " +"all faces. Eg: For an extract size of 512px, A setting of 50 will only " +"include faces that have been resized from 256px or above. Setting to 100 " +"will only extract faces that have been resized from 512px or above. A " +"setting of 200 will only extract faces that have been downscaled from 1024px " +"or above." msgstr "" diff --git a/locales/tools.manual.pot b/locales/tools.manual.pot index 4e3fe2e9ab..8517dcae92 100644 --- a/locales/tools.manual.pot +++ b/locales/tools.manual.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 23:55+0000\n" +"POT-Creation-Date: 2026-03-20 22:06+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -62,163 +62,184 @@ msgid "" "thumbnails in a slower, but more stable single thread." msgstr "" -#: tools/manual\faceviewer\frame.py:163 +#: tools/manual/face_viewer/frame.py:175 msgid "Display the landmarks mesh" msgstr "" -#: tools/manual\faceviewer\frame.py:164 +#: tools/manual/face_viewer/frame.py:176 msgid "Display the mask" msgstr "" -#: tools/manual\frameviewer\editor\_base.py:628 -#: tools/manual\frameviewer\editor\landmarks.py:44 -#: tools/manual\frameviewer\editor\mask.py:75 -msgid "Magnify/Demagnify the View" +#: tools/manual/frame_viewer/frame.py:79 +msgid "Play/Pause (SPACE)" msgstr "" -#: tools/manual\frameviewer\editor\bounding_box.py:33 -#: tools/manual\frameviewer\editor\extract_box.py:32 -msgid "Delete Face" +#: tools/manual/frame_viewer/frame.py:80 +msgid "Go to First Frame (HOME)" msgstr "" -#: tools/manual\frameviewer\editor\bounding_box.py:36 -msgid "" -"Bounding Box Editor\n" -"Edit the bounding box being fed into the aligner to recalculate the landmarks.\n" -"\n" -" - Grab the corner anchors to resize the bounding box.\n" -" - Click and drag the bounding box to relocate.\n" -" - Click in empty space to create a new bounding box.\n" -" - Right click a bounding box to delete a face." +#: tools/manual/frame_viewer/frame.py:81 +msgid "Go to Previous Frame (Z)" msgstr "" -#: tools/manual\frameviewer\editor\bounding_box.py:70 -msgid "Aligner to use. FAN will obtain better alignments, but cv2-dnn can be useful if FAN cannot get decent alignments and you want to set a base to edit from." +#: tools/manual/frame_viewer/frame.py:82 +msgid "Go to Next Frame (X)" msgstr "" -#: tools/manual\frameviewer\editor\bounding_box.py:83 -msgid "" -"Normalization method to use for feeding faces to the aligner. This can help the aligner better align faces with difficult lighting conditions. Different methods will yield different results on different sets. NB: This does not impact the output face, just the input to the aligner.\n" -"\tnone: Don't perform normalization on the face.\n" -"\tclahe: Perform Contrast Limited Adaptive Histogram Equalization on the face.\n" -"\thist: Equalize the histograms on the RGB channels.\n" -"\tmean: Normalize the face colors to the mean." +#: tools/manual/frame_viewer/frame.py:83 +msgid "Go to Last Frame (END)" msgstr "" -#: tools/manual\frameviewer\editor\extract_box.py:35 -msgid "" -"Extract Box Editor\n" -"Move the extract box that has been generated by the aligner. Click and drag:\n" -"\n" -" - Inside the bounding box to relocate the landmarks.\n" -" - The corner anchors to resize the landmarks.\n" -" - Outside of the corners to rotate the landmarks." +#: tools/manual/frame_viewer/frame.py:84 +msgid "Extract the faces to a folder... (Ctrl+E)" msgstr "" -#: tools/manual\frameviewer\editor\landmarks.py:27 -msgid "" -"Landmark Point Editor\n" -"Edit the individual landmark points.\n" -"\n" -" - Click and drag individual points to relocate.\n" -" - Draw a box to select multiple points to relocate." +#: tools/manual/frame_viewer/frame.py:85 +msgid "Save the Alignments file (Ctrl+S)" msgstr "" -#: tools/manual\frameviewer\editor\mask.py:33 +#: tools/manual/frame_viewer/frame.py:86 +msgid "Filter Frames to only those Containing the Selected Item (F)" +msgstr "" + +#: tools/manual/frame_viewer/frame.py:87 msgid "" -"Mask Editor\n" -"Edit the mask.\n" -" - NB: For Landmark based masks (e.g. components/extended) it is better to make sure the landmarks are correct rather than editing the mask directly. Any change to the landmarks after editing the mask will override your manual edits." +"Set the distance from an 'average face' to be considered misaligned. Higher " +"distances are more restrictive" msgstr "" -#: tools/manual\frameviewer\editor\mask.py:77 -msgid "Draw Tool" +#: tools/manual/frame_viewer/frame.py:392 +msgid "View alignments" msgstr "" -#: tools/manual\frameviewer\editor\mask.py:78 -msgid "Erase Tool" +#: tools/manual/frame_viewer/frame.py:393 +msgid "Bounding box editor" msgstr "" -#: tools/manual\frameviewer\editor\mask.py:97 -msgid "Select which mask to edit" +#: tools/manual/frame_viewer/frame.py:394 +msgid "Location editor" msgstr "" -#: tools/manual\frameviewer\editor\mask.py:104 -msgid "Set the brush size. ([ - decrease, ] - increase)" +#: tools/manual/frame_viewer/frame.py:395 +msgid "Mask editor" msgstr "" -#: tools/manual\frameviewer\editor\mask.py:111 -msgid "Select the brush cursor color." +#: tools/manual/frame_viewer/frame.py:396 +msgid "Landmark point editor" msgstr "" -#: tools/manual\frameviewer\frame.py:78 -msgid "Play/Pause (SPACE)" +#: tools/manual/frame_viewer/frame.py:471 +msgid "Previous" msgstr "" -#: tools/manual\frameviewer\frame.py:79 -msgid "Go to First Frame (HOME)" +#: tools/manual/frame_viewer/frame.py:472 +msgid "Next" msgstr "" -#: tools/manual\frameviewer\frame.py:80 -msgid "Go to Previous Frame (Z)" +#: tools/manual/frame_viewer/frame.py:483 +msgid "Revert to saved Alignments ({})" msgstr "" -#: tools/manual\frameviewer\frame.py:81 -msgid "Go to Next Frame (X)" +#: tools/manual/frame_viewer/frame.py:489 +msgid "Copy {} Alignments ({})" msgstr "" -#: tools/manual\frameviewer\frame.py:82 -msgid "Go to Last Frame (END)" +#: tools/manual/frame_viewer/editor/_base.py:632 +#: tools/manual/frame_viewer/editor/landmarks.py:45 +msgid "Magnify/Demagnify the View" msgstr "" -#: tools/manual\frameviewer\frame.py:83 -msgid "Extract the faces to a folder... (Ctrl+E)" +#: tools/manual/frame_viewer/editor/bounding_box.py:34 +#: tools/manual/frame_viewer/editor/extract_box.py:33 +msgid "Delete Face" msgstr "" -#: tools/manual\frameviewer\frame.py:84 -msgid "Save the Alignments file (Ctrl+S)" +#: tools/manual/frame_viewer/editor/bounding_box.py:37 +msgid "" +"Bounding Box Editor\n" +"Edit the bounding box being fed into the aligner to recalculate the " +"landmarks.\n" +"\n" +" - Grab the corner anchors to resize the bounding box.\n" +" - Click and drag the bounding box to relocate.\n" +" - Click in empty space to create a new bounding box.\n" +" - Right click a bounding box to delete a face." msgstr "" -#: tools/manual\frameviewer\frame.py:85 -msgid "Filter Frames to only those Containing the Selected Item (F)" +#: tools/manual/frame_viewer/editor/bounding_box.py:71 +msgid "" +"Aligner to use. HRNet and FAN will obtain better alignments, but cv2-dnn can " +"be useful if these cannot get decent alignments and you want to set a base " +"to edit from." msgstr "" -#: tools/manual\frameviewer\frame.py:86 -msgid "Set the distance from an 'average face' to be considered misaligned. Higher distances are more restrictive" +#: tools/manual/frame_viewer/editor/bounding_box.py:84 +msgid "" +"Normalization method to use for feeding faces to the aligner. This can help " +"the aligner better align faces with difficult lighting conditions. Different " +"methods will yield different results on different sets. NB: This does not " +"impact the output face, just the input to the aligner.\n" +"\tnone: Don't perform normalization on the face.\n" +"\tclahe: Perform Contrast Limited Adaptive Histogram Equalization on the " +"face.\n" +"\thist: Equalize the histograms on the RGB channels.\n" +"\tmean: Normalize the face colors to the mean." msgstr "" -#: tools/manual\frameviewer\frame.py:391 -msgid "View alignments" +#: tools/manual/frame_viewer/editor/extract_box.py:36 +msgid "" +"Extract Box Editor\n" +"Move the extract box that has been generated by the aligner. Click and " +"drag:\n" +"\n" +" - Inside the bounding box to relocate the landmarks.\n" +" - The corner anchors to resize the landmarks.\n" +" - Outside of the corners to rotate the landmarks." msgstr "" -#: tools/manual\frameviewer\frame.py:392 -msgid "Bounding box editor" +#: tools/manual/frame_viewer/editor/landmarks.py:28 +msgid "" +"Landmark Point Editor\n" +"Edit the individual landmark points.\n" +"\n" +" - Click and drag individual points to relocate.\n" +" - Draw a box to select multiple points to relocate." msgstr "" -#: tools/manual\frameviewer\frame.py:393 -msgid "Location editor" +#: tools/manual/frame_viewer/editor/mask.py:43 +msgid "" +"Mask Editor\n" +"Edit the mask.\n" +" - NB: For Landmark based masks (e.g. components/extended) it is better to " +"make sure the landmarks are correct rather than editing the mask directly. " +"Any change to the landmarks after editing the mask will override your manual " +"edits." msgstr "" -#: tools/manual\frameviewer\frame.py:394 -msgid "Mask editor" +#: tools/manual/frame_viewer/editor/mask.py:91 +msgid "Magnify/De-magnify the View" msgstr "" -#: tools/manual\frameviewer\frame.py:395 -msgid "Landmark point editor" +#: tools/manual/frame_viewer/editor/mask.py:93 +msgid "Draw Tool" msgstr "" -#: tools/manual\frameviewer\frame.py:470 -msgid "Next" +#: tools/manual/frame_viewer/editor/mask.py:94 +msgid "Erase Tool" msgstr "" -#: tools/manual\frameviewer\frame.py:470 -msgid "Previous" +#: tools/manual/frame_viewer/editor/mask.py:115 +msgid "Select which mask to edit" msgstr "" -#: tools/manual\frameviewer\frame.py:481 -msgid "Revert to saved Alignments ({})" +#: tools/manual/frame_viewer/editor/mask.py:122 +msgid "Set the brush size. ([ - decrease, ] - increase)" msgstr "" -#: tools/manual\frameviewer\frame.py:487 -msgid "Copy {} Alignments ({})" +#: tools/manual/frame_viewer/editor/mask.py:129 +msgid "Select the brush cursor color." +msgstr "" + +#: tools/manual/frame_viewer/editor/mask.py:136 +msgid "Select a shape for masking cursor." msgstr "" diff --git a/locales/tools.mask.cli.pot b/locales/tools.mask.cli.pot index f8024c88ee..010a6a1acf 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: 2024-06-28 13:45+0100\n" +"POT-Creation-Date: 2026-03-13 15:17+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,24 +17,24 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: tools/mask/cli.py:15 +#: tools/mask/cli.py:16 msgid "" "This tool allows you to generate, import, export or preview masks for " "existing alignments." msgstr "" -#: tools/mask/cli.py:25 +#: tools/mask/cli.py:26 msgid "" "Mask tool\n" "Generate, import, export or preview masks for existing alignments files." msgstr "" -#: tools/mask/cli.py:35 tools/mask/cli.py:47 tools/mask/cli.py:58 -#: tools/mask/cli.py:69 +#: tools/mask/cli.py:36 tools/mask/cli.py:48 tools/mask/cli.py:59 +#: tools/mask/cli.py:70 msgid "data" msgstr "" -#: tools/mask/cli.py:39 +#: tools/mask/cli.py:40 msgid "" "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 " @@ -42,18 +42,18 @@ msgid "" "location cannot be automatically detected." msgstr "" -#: tools/mask/cli.py:51 +#: tools/mask/cli.py:52 msgid "Directory containing extracted faces, source frames, or a video file." msgstr "" -#: tools/mask/cli.py:61 +#: tools/mask/cli.py:62 msgid "" -"R|Whether the `input` is a folder of faces or a folder frames/video\n" +"R|Whether the `input` is a folder of faces/frames or a video file\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:71 +#: tools/mask/cli.py:72 msgid "" "R|Run the mask tool on multiple sources. If selected then the other options " "should be set as follows:\n" @@ -67,27 +67,20 @@ msgid "" "within the extracted faces will be updated." msgstr "" -#: tools/mask/cli.py:87 tools/mask/cli.py:119 +#: tools/mask/cli.py:88 tools/mask/cli.py:114 msgid "process" msgstr "" -#: tools/mask/cli.py:89 +#: tools/mask/cli.py:90 msgid "" "R|Masker to use.\n" "L|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).\n" -"L|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.\n" "L|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.\n" -"L|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.\n" "L|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.\n" @@ -100,7 +93,7 @@ msgid "" "may result in sub-par performance." msgstr "" -#: tools/mask/cli.py:121 +#: tools/mask/cli.py:116 msgid "" "R|The Mask tool process to perform.\n" "L|all: Update the mask for all faces in the alignments file for the selected " @@ -114,11 +107,11 @@ msgid "" "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:176 +#: tools/mask/cli.py:130 tools/mask/cli.py:149 tools/mask/cli.py:171 msgid "import" msgstr "" -#: tools/mask/cli.py:137 +#: tools/mask/cli.py:132 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, " @@ -133,7 +126,7 @@ msgid "" "number in the original video (starting from frame 1)." msgstr "" -#: tools/mask/cli.py:156 +#: tools/mask/cli.py:151 msgid "" "R|Import/Output only. When importing masks, this is the centering to use. " "For output this is only used for outputting custom imported masks, and " @@ -152,25 +145,25 @@ msgid "" "mask appearing outside of the training area." msgstr "" -#: tools/mask/cli.py:181 +#: tools/mask/cli.py:176 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:189 tools/mask/cli.py:197 tools/mask/cli.py:211 -#: tools/mask/cli.py:225 tools/mask/cli.py:235 +#: tools/mask/cli.py:184 tools/mask/cli.py:192 tools/mask/cli.py:206 +#: tools/mask/cli.py:220 tools/mask/cli.py:230 msgid "output" msgstr "" -#: tools/mask/cli.py:191 +#: tools/mask/cli.py:186 msgid "" "Optional output location. If provided, a preview of the masks created will " "be output in the given folder." msgstr "" -#: tools/mask/cli.py:202 +#: tools/mask/cli.py:197 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 " @@ -178,14 +171,14 @@ msgid "" "to the next odd number. NB: Only effects the output preview. Set to 0 for off" msgstr "" -#: tools/mask/cli.py:216 +#: tools/mask/cli.py:211 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:227 +#: tools/mask/cli.py:222 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" @@ -193,7 +186,7 @@ msgid "" "L|mask: Only output the mask as a single channel image." msgstr "" -#: tools/mask/cli.py:237 +#: tools/mask/cli.py:232 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/locales/tools.sort.cli.pot b/locales/tools.sort.cli.pot index 8a963636d0..d0152c9af5 100644 --- a/locales/tools.sort.cli.pot +++ b/locales/tools.sort.cli.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 23:53+0000\n" +"POT-Creation-Date: 2026-03-13 15:17+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,140 +17,148 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: tools/sort/cli.py:15 +#: tools/sort/cli.py:17 msgid "This command lets you sort images using various methods." msgstr "" -#: tools/sort/cli.py:21 +#: tools/sort/cli.py:23 msgid "" " Adjust the '-t' ('--threshold') parameter to control the strength of " "grouping." msgstr "" -#: tools/sort/cli.py:22 +#: tools/sort/cli.py:24 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. Each image is allocated to a bin by the percentage of color pixels " "that appear in the image." msgstr "" -#: tools/sort/cli.py:25 +#: tools/sort/cli.py:27 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. Each image is allocated to a bin by the number of degrees the face " "is orientated from center." msgstr "" -#: tools/sort/cli.py:28 +#: tools/sort/cli.py:30 msgid "" " Adjust the '-b' ('--bins') parameter to control the number of bins for " "grouping. The minimum and maximum values are taken for the chosen sort " "metric. The bins are then populated with the results from the group sorting." msgstr "" -#: tools/sort/cli.py:32 +#: tools/sort/cli.py:34 msgid "faces by blurriness." msgstr "" -#: tools/sort/cli.py:33 +#: tools/sort/cli.py:35 msgid "faces by fft filtered blurriness." msgstr "" -#: tools/sort/cli.py:34 +#: tools/sort/cli.py:36 msgid "" "faces by the estimated distance of the alignments from an 'average' face. " "This can be useful for eliminating misaligned faces. Sorts from most like an " "average face to least like an average face." msgstr "" -#: tools/sort/cli.py:37 +#: tools/sort/cli.py:39 msgid "" "faces using VGG Face2 by face similarity. This uses a pairwise clustering " "algorithm to check the distances between 512 features on every face in your " "set and order them appropriately." msgstr "" -#: tools/sort/cli.py:40 +#: tools/sort/cli.py:42 msgid "faces by their landmarks." msgstr "" -#: tools/sort/cli.py:41 +#: tools/sort/cli.py:43 msgid "Like 'face-cnn' but sorts by dissimilarity." msgstr "" -#: tools/sort/cli.py:42 +#: tools/sort/cli.py:44 msgid "faces by Yaw (rotation left to right)." msgstr "" -#: tools/sort/cli.py:43 +#: tools/sort/cli.py:45 msgid "faces by Pitch (rotation up and down)." msgstr "" -#: tools/sort/cli.py:44 +#: tools/sort/cli.py:46 msgid "" "faces by Roll (rotation). Aligned faces should have a roll value close to " "zero. The further the Roll value from zero the higher liklihood the face is " "misaligned." msgstr "" -#: tools/sort/cli.py:46 +#: tools/sort/cli.py:48 msgid "faces by their color histogram." msgstr "" -#: tools/sort/cli.py:47 +#: tools/sort/cli.py:49 msgid "Like 'hist' but sorts by dissimilarity." msgstr "" -#: tools/sort/cli.py:48 +#: tools/sort/cli.py:50 msgid "" "images by the average intensity of the converted grayscale color channel." msgstr "" -#: tools/sort/cli.py:49 +#: tools/sort/cli.py:51 msgid "" "images by their number of black pixels. Useful when faces are near borders " "and a large part of the image is black." msgstr "" -#: tools/sort/cli.py:51 +#: tools/sort/cli.py:53 msgid "" "images by the average intensity of the converted Y color channel. Bright " "lighting and oversaturated images will be ranked first." msgstr "" -#: tools/sort/cli.py:53 +#: tools/sort/cli.py:55 msgid "" "images by the average intensity of the converted Cg color channel. Green " "images will be ranked first and red images will be last." msgstr "" -#: tools/sort/cli.py:55 +#: tools/sort/cli.py:57 msgid "" "images by the average intensity of the converted Co color channel. Orange " "images will be ranked first and blue images will be last." msgstr "" -#: tools/sort/cli.py:57 +#: tools/sort/cli.py:59 msgid "" "images by their size in the original frame. Faces further from the camera " "and from lower resolution sources will be sorted first, whilst faces closer " "to the camera and from higher resolution sources will be sorted last." msgstr "" -#: tools/sort/cli.py:81 +#: tools/sort/cli.py:72 +msgid "Sort" +msgstr "" + +#: tools/sort/cli.py:73 +msgid "Group" +msgstr "" + +#: tools/sort/cli.py:83 msgid "Sort faces using a number of different techniques" msgstr "" -#: tools/sort/cli.py:91 tools/sort/cli.py:98 tools/sort/cli.py:110 -#: tools/sort/cli.py:150 +#: tools/sort/cli.py:93 tools/sort/cli.py:100 tools/sort/cli.py:112 +#: tools/sort/cli.py:152 msgid "data" msgstr "" -#: tools/sort/cli.py:92 +#: tools/sort/cli.py:94 msgid "Input directory of aligned faces." msgstr "" -#: tools/sort/cli.py:100 +#: tools/sort/cli.py:102 msgid "" "Output directory for sorted aligned faces. If not provided and 'keep' is " "selected then a new folder called 'sorted' will be created within the input " @@ -159,18 +167,18 @@ msgid "" "'input_dir'" msgstr "" -#: tools/sort/cli.py:112 +#: tools/sort/cli.py:114 msgid "" "R|If selected then the input_dir should be a parent folder containing " "multiple folders of faces you wish to sort. The faces will be output to " "separate sub-folders in the output_dir" msgstr "" -#: tools/sort/cli.py:121 +#: tools/sort/cli.py:123 msgid "sort settings" msgstr "" -#: tools/sort/cli.py:124 +#: tools/sort/cli.py:126 msgid "" "R|Choose how images are sorted. Selecting a sort method gives the images a " "new filename based on the order the image appears within the given method.\n" @@ -180,11 +188,11 @@ msgid "" "'none' for both 'sort-by' and 'group-by' will do nothing" msgstr "" -#: tools/sort/cli.py:136 tools/sort/cli.py:164 tools/sort/cli.py:184 +#: tools/sort/cli.py:138 tools/sort/cli.py:166 tools/sort/cli.py:186 msgid "group settings" msgstr "" -#: tools/sort/cli.py:139 +#: tools/sort/cli.py:141 msgid "" "R|Selecting a group by method will move/copy files into numbered bins based " "on the selected method.\n" @@ -193,7 +201,7 @@ msgid "" "folder. Selecting 'none' for both 'sort-by' and 'group-by' will do nothing" msgstr "" -#: tools/sort/cli.py:152 +#: tools/sort/cli.py:154 msgid "" "Whether to keep the original files in their original location. Choosing a " "'sort-by' method means that the files have to be renamed. Selecting 'keep' " @@ -203,7 +211,7 @@ msgid "" "criteria." msgstr "" -#: tools/sort/cli.py:167 +#: tools/sort/cli.py:169 msgid "" "R|Float value. Minimum threshold to use for grouping comparison with 'face-" "cnn' 'hist' and 'face' methods.\n" @@ -218,7 +226,7 @@ msgid "" "face-cnn 7.2, hist 0.3, face 0.25" msgstr "" -#: tools/sort/cli.py:187 +#: tools/sort/cli.py:189 #, python-format msgid "" "R|Integer value. Used to control the number of bins created for grouping by: " @@ -242,11 +250,21 @@ msgid "" "Default value: 5" msgstr "" -#: tools/sort/cli.py:207 tools/sort/cli.py:217 +#: tools/sort/cli.py:211 tools/sort/cli.py:223 tools/sort/cli.py:233 msgid "settings" msgstr "" -#: tools/sort/cli.py:210 +#: tools/sort/cli.py:214 +msgid "" +"R|The identity plugin to use when sorting/grouping by face. \n" +"L|t-face: An InsightFace ResNet based model with a lighter and heavier " +"variant (configurable in settings).\n" +"L|vggface2: An older and lighter, but fairly reliable plugin based on the " +"VGG Network.\n" +"Default: t-face" +msgstr "" + +#: tools/sort/cli.py:226 msgid "" "Logs file renaming changes if grouping by renaming, or it logs the file " "copying/movement if grouping by folders. If no log file is specified with " @@ -254,7 +272,7 @@ msgid "" "directory." msgstr "" -#: tools/sort/cli.py:221 +#: tools/sort/cli.py:237 msgid "" "Specify a log file to use for saving the renaming or grouping information. " "If specified extension isn't 'json' or 'yaml', then json will be used as the " diff --git a/plugins/convert/color/_base.py b/plugins/convert/color/_base.py index 6bbe623e8f..41d8cc6556 100644 --- a/plugins/convert/color/_base.py +++ b/plugins/convert/color/_base.py @@ -11,10 +11,10 @@ class Adjustment(): """ Parent class for adjustments """ - def __init__(self, configfile=None, config=None): - logger.debug("Initializing %s: (configfile: %s, config: %s)", - self.__class__.__name__, configfile, config) - convert_config.load_config(config_file=configfile) + def __init__(self, config_file=None, config=None): + logger.debug("Initializing %s: (config_file: %s, config: %s)", + self.__class__.__name__, config_file, config) + convert_config.load_config(config_file=config_file) logger.debug("Initialized %s", self.__class__.__name__) def process(self, old_face, new_face, raw_mask): diff --git a/plugins/convert/convert_config.py b/plugins/convert/convert_config.py index 9f174c77c1..3db71c2490 100644 --- a/plugins/convert/convert_config.py +++ b/plugins/convert/convert_config.py @@ -37,5 +37,5 @@ def load_config(config_file: str | None = None) -> _Config: """ global _CONFIG # pylint:disable=global-statement if _CONFIG is None: - _CONFIG = _Config(configfile=config_file) + _CONFIG = _Config(config_file=config_file) return _CONFIG diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index a46014dbd2..22889b7ce4 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 """ Plugin to blend the edges of the face between the swap and the original face. """ +from __future__ import annotations + import logging import typing as T @@ -7,11 +9,15 @@ import numpy as np from lib.align import BlurMask, DetectedFace +from lib.align.aligned_mask import LandmarksMask from lib.logger import parse_class_init from lib.utils import get_module_objects from plugins.convert import convert_config from . import mask_blend_defaults as cfg +if T.TYPE_CHECKING: + import numpy.typing as npt + logger = logging.getLogger(__name__) @@ -21,13 +27,13 @@ class Mask(): Parameters ---------- - mask_type: str + mask_type : str The mask type to use for this plugin - output_size: int + output_size : int The size of the output from the Faceswap model. - coverage_ratio: float + coverage_ratio : float The coverage ratio that the Faceswap model was trained at. - configfile: str, Optional + config_file : str, Optional Optional location of custom configuration ``ini`` file. If ``None`` then use the default config location. Default: ``None`` """ @@ -35,10 +41,10 @@ def __init__(self, mask_type: str, output_size: int, coverage_ratio: float, - configfile: str | None = None) -> None: + config_file: str | None = None) -> None: logger.debug(parse_class_init(locals())) self._mask_type = mask_type - convert_config.load_config(config_file=configfile) + convert_config.load_config(config_file=config_file) self._coverage_ratio = coverage_ratio self._box = self._get_box(output_size) @@ -48,7 +54,7 @@ def __init__(self, cfg.erosion_right(), cfg.erosion_bottom()]] self._do_erode = any(amount != 0 for amount in self._erodes) - def _get_box(self, output_size: int) -> np.ndarray: + def _get_box(self, output_size: int) -> npt.NDArray[np.float32]: """ Apply a gradient overlay to the edge of the swap box to smooth out any hard areas that where the face intersects with the edge of the swap area. @@ -57,7 +63,7 @@ def _get_box(self, output_size: int) -> np.ndarray: Parameters ---------- - output_size: int + output_size : int The size of the box that contains the swapped face Returns @@ -76,99 +82,12 @@ def _get_box(self, output_size: int) -> np.ndarray: is_ratio=True).blurred return box - def run(self, - detected_face: DetectedFace, - source_offset: np.ndarray, - target_offset: np.ndarray, - centering: T.Literal["legacy", "face", "head"], - predicted_mask: np.ndarray | None = None) -> tuple[np.ndarray, np.ndarray]: - """ Obtain the requested mask type and perform any defined mask manipulations. - - Parameters - ---------- - detected_face: :class:`lib.align.DetectedFace` - The DetectedFace object as returned from :class:`scripts.convert.Predictor`. - source_offset: :class:`numpy.ndarray` - The (x, y) offset for the mask at its stored centering - target_offset: :class:`numpy.ndarray` - The (x, y) offset for the mask at the requested target centering - centering: [`"legacy"`, `"face"`, `"head"`] - The centering to obtain the mask for - predicted_mask: :class:`numpy.ndarray`, optional - The predicted mask as output from the Faceswap Model, if the model was trained - with a mask, otherwise ``None``. Default: ``None``. - - Returns - ------- - mask: :class:`numpy.ndarray` - The mask with all requested manipulations applied - raw_mask: :class:`numpy.ndarray` - The mask with no erosion/dilation applied - """ - logger.trace("Performing mask adjustment: (detected_face: %s, " # type: ignore - "source_offset: %s, target_offset: %s, centering: '%s', predicted_mask: %s", - detected_face, source_offset, target_offset, centering, - predicted_mask is not None) - mask = self._get_mask(detected_face, - predicted_mask, - centering, - source_offset, - target_offset) - raw_mask = mask.copy() - - if self._mask_type != "none": - out = self._erode(mask) if self._do_erode else mask - out = np.minimum(out, self._box) - else: - out = mask - - logger.trace( # type: ignore - "mask shape: %s, raw_mask shape: %s", mask.shape, raw_mask.shape) - return out, raw_mask - - def _get_mask(self, - detected_face: DetectedFace, - predicted_mask: np.ndarray | None, - centering: T.Literal["legacy", "face", "head"], - source_offset: np.ndarray, - target_offset: np.ndarray) -> np.ndarray: - """ Return the requested mask with any requested blurring applied. - - Parameters - ---------- - detected_face: :class:`lib.align.DetectedFace` - The DetectedFace object as returned from :class:`scripts.convert.Predictor`. - predicted_mask: :class:`numpy.ndarray` - The predicted mask as output from the Faceswap Model if the model was trained - with a mask, otherwise ``None`` - centering: [`"legacy"`, `"face"`, `"head"`] - The centering to obtain the mask for - source_offset: :class:`numpy.ndarray` - The (x, y) offset for the mask at its stored centering - target_offset: :class:`numpy.ndarray` - The (x, y) offset for the mask at the requested target centering - - Returns - ------- - :class:`numpy.ndarray` - The requested mask. - """ - if self._mask_type == "none": - mask = np.ones_like(self._box) # Return a dummy mask if not using a mask - elif self._mask_type == "predicted" and predicted_mask is not None: - mask = self._process_predicted_mask(predicted_mask) - else: - mask = self._get_stored_mask(detected_face, centering, source_offset, target_offset) - - logger.trace(mask.shape) # type: ignore - return mask - def _process_predicted_mask(self, mask: np.ndarray) -> np.ndarray: """ Process blurring of the predicted mask Parameters ---------- - mask: :class:`numpy.ndarray` + mask : :class:`numpy.ndarray` The predicted mask as output from the Faceswap Model Returns @@ -186,6 +105,7 @@ def _process_predicted_mask(self, mask: np.ndarray) -> np.ndarray: def _get_stored_mask(self, detected_face: DetectedFace, + landmarks_mask: LandmarksMask | None, centering: T.Literal["legacy", "face", "head"], source_offset: np.ndarray, target_offset: np.ndarray) -> np.ndarray: @@ -193,13 +113,15 @@ def _get_stored_mask(self, Parameters ---------- - detected_face: :class:`lib.align.DetectedFace` + detected_face : :class:`lib.align.DetectedFace` The DetectedFace object as returned from :class:`scripts.convert.Predictor`. + landmarks_mask : :class:`lib.align.aligned_mask.LandmarksMask` | None, optional + The landmarks mask object, if requested otherwise ``None`` centering: [`"legacy"`, `"face"`, `"head"`] The centering to obtain the mask for - source_offset: :class:`numpy.ndarray` + source_offset : :class:`numpy.ndarray` The (x, y) offset for the mask at its stored centering - target_offset: :class:`numpy.ndarray` + target_offset : :class:`numpy.ndarray` The (x, y) offset for the mask at the requested target centering Returns @@ -207,7 +129,7 @@ def _get_stored_mask(self, :class:`numpy.ndarray` The mask sized to Faceswap model output with any requested blurring applied. """ - mask = detected_face.mask[self._mask_type] + mask = detected_face.mask[self._mask_type] if landmarks_mask is None else landmarks_mask blur_type = T.cast(T.Literal["gaussian", "normalized"] | None, cfg.type().lower()) blur_type = None if blur_type == "none" else blur_type mask.set_blur_and_threshold(blur_kernel=cfg.kernel_size(), @@ -215,25 +137,100 @@ def _get_stored_mask(self, blur_passes=cfg.passes(), threshold=cfg.threshold()) mask.set_sub_crop(source_offset, target_offset, centering, self._coverage_ratio) + if isinstance(mask, LandmarksMask): + mask.generate_mask() face_mask = mask.mask mask_size = face_mask.shape[0] face_size = self._box.shape[0] if mask_size != face_size: - interp = cv2.INTER_CUBIC if mask_size < face_size else cv2.INTER_AREA + interpolation = cv2.INTER_CUBIC if mask_size < face_size else cv2.INTER_AREA face_mask = cv2.resize(face_mask, self._box.shape[:2], - interpolation=interp)[..., None].astype("float32") / 255. + interpolation=interpolation)[..., None].astype("float32") / 255. else: face_mask = face_mask.astype("float32") / 255. return face_mask + def _get_mask(self, + detected_face: DetectedFace, + landmarks_mask: LandmarksMask | None, + predicted_mask: np.ndarray | None, + centering: T.Literal["legacy", "face", "head"], + source_offset: np.ndarray, + target_offset: np.ndarray) -> np.ndarray: + """ Return the requested mask with any requested blurring applied. + + Parameters + ---------- + detected_face : :class:`lib.align.DetectedFace` + The DetectedFace object as returned from :class:`scripts.convert.Predictor`. + landmarks_mask : :class:`lib.align.aligned_mask.LandmarksMask` | None, optional + The landmarks mask object, if requested otherwise ``None`` + predicted_mask : :class:`numpy.ndarray` + The predicted mask as output from the Faceswap Model if the model was trained + with a mask, otherwise ``None`` + centering : [`"legacy"`, `"face"`, `"head"`] + The centering to obtain the mask for + source_offset : :class:`numpy.ndarray` + The (x, y) offset for the mask at its stored centering + target_offset : :class:`numpy.ndarray` + The (x, y) offset for the mask at the requested target centering + + Returns + ------- + :class:`numpy.ndarray` + The requested mask. + """ + if self._mask_type == "none": + mask = np.ones_like(self._box) # Return a dummy mask if not using a mask + elif self._mask_type == "predicted" and predicted_mask is not None: + mask = self._process_predicted_mask(predicted_mask) + else: + mask = self._get_stored_mask(detected_face, + landmarks_mask, + centering, + source_offset, + target_offset) + + logger.trace(mask.shape) # type: ignore + return mask + # MASK MANIPULATIONS + def _get_erosion_kernels(self, mask: np.ndarray) -> list[np.ndarray]: + """ Get the erosion kernels for each of the center, left, top right and bottom erosions. + + An approximation is made based on the number of positive pixels within the mask to create + an ellipse to act as kernel. + + Parameters + ---------- + mask : :class:`numpy.ndarray` + The mask to be eroded or dilated + + Returns + ------- + list[:class:`numpy.ndarray`] + The erosion kernels to be used for erosion/dilation + """ + mask_radius = np.sqrt(np.sum(mask)) / 2 + kernel_sizes = [max(0, int(abs(ratio * mask_radius))) for ratio in self._erodes] + kernels = [] + for idx, size in enumerate(kernel_sizes): + kernel = [size, size] + shape = cv2.MORPH_ELLIPSE if idx == 0 else cv2.MORPH_RECT + if idx > 1: + pos = 0 if idx % 2 == 0 else 1 + kernel[pos] = 1 # Set x/y to 1px based on whether eroding top/bottom, left/right + kernels.append(cv2.getStructuringElement(shape, kernel) if size else np.array(0)) + logger.trace("Erosion kernels: %s", [k.shape for k in kernels]) # type: ignore + return kernels + def _erode(self, mask: np.ndarray) -> np.ndarray: """ Erode or dilate mask the mask based on configuration options. Parameters ---------- - mask: :class:`numpy.ndarray` + mask : :class:`numpy.ndarray` The mask to be eroded or dilated Returns @@ -262,34 +259,59 @@ def _erode(self, mask: np.ndarray) -> np.ndarray: return eroded[..., None] - def _get_erosion_kernels(self, mask: np.ndarray) -> list[np.ndarray]: - """ Get the erosion kernels for each of the center, left, top right and bottom erosions. - - An approximation is made based on the number of positive pixels within the mask to create - an ellipse to act as kernel. + def run(self, + detected_face: DetectedFace, + source_offset: np.ndarray, + target_offset: np.ndarray, + centering: T.Literal["legacy", "face", "head"], + landmarks_mask: LandmarksMask | None = None, + predicted_mask: np.ndarray | None = None) -> tuple[np.ndarray, np.ndarray]: + """ Obtain the requested mask type and perform any defined mask manipulations. Parameters ---------- - mask: :class:`numpy.ndarray` - The mask to be eroded or dilated + detected_face : :class:`lib.align.detected_face.DetectedFace` + The DetectedFace object as returned from :class:`scripts.convert.Predictor`. + source_offset : :class:`numpy.ndarray` + The (x, y) offset for the mask at its stored centering + target_offset : :class:`numpy.ndarray` + The (x, y) offset for the mask at the requested target centering + centering : [`"legacy"`, `"face"`, `"head"`] + The centering to obtain the mask for + landmarks_mask : :class:`lib.align.aligned_mask.LandmarksMask` | None, optional + The landmarks mask object, if requested or ``None``. Default: ``None`` + predicted_mask : :class:`numpy.ndarray` | None, optional + The predicted mask as output from the Faceswap Model, if the model was trained + with a mask, otherwise ``None``. Default: ``None``. Returns ------- - list - The erosion kernels to be used for erosion/dilation + mask : :class:`numpy.ndarray` + The mask with all requested manipulations applied + raw_mask : :class:`numpy.ndarray` + The mask with no erosion/dilation applied """ - mask_radius = np.sqrt(np.sum(mask)) / 2 - kernel_sizes = [max(0, int(abs(ratio * mask_radius))) for ratio in self._erodes] - kernels = [] - for idx, size in enumerate(kernel_sizes): - kernel = [size, size] - shape = cv2.MORPH_ELLIPSE if idx == 0 else cv2.MORPH_RECT - if idx > 1: - pos = 0 if idx % 2 == 0 else 1 - kernel[pos] = 1 # Set x/y to 1px based on whether eroding top/bottom, left/right - kernels.append(cv2.getStructuringElement(shape, kernel) if size else np.array(0)) - logger.trace("Erosion kernels: %s", [k.shape for k in kernels]) # type: ignore - return kernels + logger.trace("Performing mask adjustment: (detected_face: %s, " # type: ignore + "source_offset: %s, target_offset: %s, centering: '%s', predicted_mask: %s", + detected_face, source_offset, target_offset, centering, + predicted_mask is not None) + mask = self._get_mask(detected_face, + landmarks_mask, + predicted_mask, + centering, + source_offset, + target_offset) + raw_mask = mask.copy() + + if self._mask_type != "none": + out = self._erode(mask) if self._do_erode else mask + out = np.minimum(out, self._box) + else: + out = mask + + logger.trace( # type: ignore + "mask shape: %s, raw_mask shape: %s", mask.shape, raw_mask.shape) + return out, raw_mask __all__ = get_module_objects(__name__) diff --git a/plugins/convert/scaling/_base.py b/plugins/convert/scaling/_base.py index db6f74407d..e28f627aa8 100644 --- a/plugins/convert/scaling/_base.py +++ b/plugins/convert/scaling/_base.py @@ -12,9 +12,9 @@ class Adjustment(): """ Parent class for scaling adjustments """ - def __init__(self, configfile=None): + def __init__(self, config_file=None): logger.debug(parse_class_init(locals())) - convert_config.load_config(config_file=configfile) + convert_config.load_config(config_file=config_file) logger.debug("Initialized %s", self.__class__.__name__) def process(self, new_face): diff --git a/plugins/convert/writer/_base.py b/plugins/convert/writer/_base.py index 0cce2cf748..6559d638ef 100644 --- a/plugins/convert/writer/_base.py +++ b/plugins/convert/writer/_base.py @@ -21,17 +21,17 @@ class Output(): ---------- output_folder: str The full path to the output folder where the converted media should be saved - configfile: str, optional + config_file: str, optional The full path to a custom configuration ini file. If ``None`` is passed then the file is loaded from the default location. Default: ``None``. """ - def __init__(self, output_folder: str, configfile: str | None = None) -> None: + def __init__(self, output_folder: str, config_file: str | None = None) -> None: logger.debug(parse_class_init(locals())) - convert_config.load_config(config_file=configfile) + convert_config.load_config(config_file=config_file) self.output_folder: str = output_folder - # For creating subfolders when separate mask is selected - self._subfolders_created: bool = False + # For creating sub_folders when separate mask is selected + self._sub_folders_created: bool = False # Methods for making sure frames are written out in frame order self.re_search = re.compile(r"(\d+)(?=\.\w+$)") # Identify frame numbers @@ -42,7 +42,7 @@ def __init__(self, output_folder: str, configfile: str | None = None) -> None: def is_stream(self) -> bool: """ bool: Whether the writer outputs a stream or a series images. - Writers that write to a stream have a frame_order paramater to dictate + Writers that write to a stream have a frame_order parameter to dictate the order in which frames should be written out (eg. gif/ffmpeg) """ retval = hasattr(self, "_frame_order") return retval @@ -112,7 +112,7 @@ def get_output_filename(self, if separate_mask: retval.append(os.path.join(self.output_folder, "masks", out_filename)) - if separate_mask and not self._subfolders_created: + if separate_mask and not self._sub_folders_created: locations = [os.path.dirname(loc) for loc in retval] logger.debug("Creating sub-folders: %s", locations) for location in locations: @@ -171,7 +171,7 @@ def pre_encode(self, image: np.ndarray, **kwargs) -> T.Any: # pylint:disable=un ------- Any or ``None`` If ``None`` then the writer does not support pre-encoding, otherwise return output of - the plugin specific pre-enccode function + the plugin specific pre-encode function """ return None diff --git a/plugins/convert/writer/opencv.py b/plugins/convert/writer/opencv.py index 29752551af..003e730438 100644 --- a/plugins/convert/writer/opencv.py +++ b/plugins/convert/writer/opencv.py @@ -19,7 +19,7 @@ class Writer(Output): ---------- output_folder: str The full path to the output folder where the converted media should be saved - configfile: str, optional + config_file: str, optional The full path to a custom configuration ini file. If ``None`` is passed then the file is loaded from the default location. Default: ``None``. """ diff --git a/plugins/convert/writer/patch.py b/plugins/convert/writer/patch.py index 01f00d7c58..7bae7243ae 100644 --- a/plugins/convert/writer/patch.py +++ b/plugins/convert/writer/patch.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Face patch output writer for faceswap.py converter Extracts the swapped Face Patch from faceswap rather than the final composited frame along with - the transformation matrix for re-inserting the face into the origial frame + the transformation matrix for re-inserting the face into the original frame """ import json import logging @@ -26,10 +26,10 @@ class Writer(Output): Parameters ---------- output_folder: str - The full path to the output folder where the face patches should besaved + The full path to the output folder where the face patches should be saved patch_size: int The size of the face patch output from the model - configfile: str, optional + config_file: str, optional The full path to a custom configuration ini file. If ``None`` is passed then the file is loaded from the default location. Default: ``None``. """ diff --git a/plugins/convert/writer/pillow.py b/plugins/convert/writer/pillow.py index 7fb1c75e28..10c940818d 100644 --- a/plugins/convert/writer/pillow.py +++ b/plugins/convert/writer/pillow.py @@ -17,7 +17,7 @@ class Writer(Output): ---------- output_folder: str The full path to the output folder where the converted media should be saved - configfile: str, optional + config_file: str, optional The full path to a custom configuration ini file. If ``None`` is passed then the file is loaded from the default location. Default: ``None``. """ diff --git a/plugins/extract/__init__.py b/plugins/extract/__init__.py index 3bffbe70b8..e69de29bb2 100644 --- a/plugins/extract/__init__.py +++ b/plugins/extract/__init__.py @@ -1,4 +0,0 @@ -#!/usr/bin/env python3 -""" Package for Faceswap's extraction pipeline """ -from .extract_media import ExtractMedia -from .pipeline import Extractor diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py deleted file mode 100644 index 9a881b90bb..0000000000 --- a/plugins/extract/_base.py +++ /dev/null @@ -1,653 +0,0 @@ -#!/usr/bin/env python3 -""" Base class for Faceswap :mod:`~plugins.extract.detect`, :mod:`~plugins.extract.align` and -:mod:`~plugins.extract.mask` Plugins -""" -from __future__ import annotations -import logging -import typing as T -from dataclasses import dataclass, field - -import numpy as np -import torch -from keras import device - -from lib.logger import parse_class_init -from lib.multithreading import MultiThread -from lib.queue_manager import queue_manager -from lib.utils import GetModel -from lib.utils import get_backend -from . import extract_config as cfg -from . import ExtractMedia - -if T.TYPE_CHECKING: - from collections.abc import Callable, Generator, Sequence - from queue import Queue - from lib.align import DetectedFace - from .align._base import AlignerBatch - from .detect._base import DetectorBatch - from .mask._base import MaskerBatch - from .recognition._base import RecogBatch - -logger = logging.getLogger(__name__) -BatchType = T.Union["DetectorBatch", "AlignerBatch", "MaskerBatch", "RecogBatch"] - - -@dataclass -class ExtractorBatch: - """ Dataclass for holding a batch flowing through post Detector plugins. - - The batch size for post Detector plugins is not the same as the overall batch size. - An image may contain 0 or more detected faces, and these need to be split and recombined - to be able to utilize a plugin's internal batch size. - - Plugin types will inherit from this class and add required keys. - - Parameters - ---------- - image: list - List of :class:`numpy.ndarray` containing the original frames - detected_faces: list - List of :class:`~lib.align.DetectedFace` objects - filename: list - List of original frame filenames for the batch - feed: :class:`numpy.ndarray` - Batch of feed images to feed the net with - prediction: :class:`numpy.nd.array` - Batch of predictions. Direct output from the aligner net - data: dict - Any specific data required during the processing phase for a particular plugin - """ - image: list[np.ndarray] = field(default_factory=list) - detected_faces: Sequence[DetectedFace | list[DetectedFace]] = field(default_factory=list) - filename: list[str] = field(default_factory=list) - feed: np.ndarray = field(default_factory=lambda: np.array([])) - prediction: np.ndarray = field(default_factory=lambda: np.array([])) - data: list[dict[str, T.Any]] = field(default_factory=list) - - def __repr__(self) -> str: - """ Prettier repr for debug printing """ - data = [{k: (v.shape, v.dtype) if isinstance(v, np.ndarray) else v for k, v in dat.items()} - for dat in self.data] - return (f"{self.__class__.__name__}(" - f"image={[(img.shape, img.dtype) for img in self.image]}, " - f"detected_faces={self.detected_faces}, " - f"filename={self.filename}, " - f"feed={[(f.shape, f.dtype) for f in self.feed]}, " - f"prediction=({self.prediction.shape}, {self.prediction.dtype}), " - f"data={data}") - - -@dataclass -class PluginInfo: - """ Dataclass to hold information about a plugin instance - - Parameters - ---------- - instance: int - The instance id of the plugin - plugin_type: Literal["align", "detect", "mask", "recognition"] | None, optional - The plugin type that the plugin instance is. Default: ``None`` - is_initialized: bool, optional - ``True`` if the plugin is initialized. Default: ``False`` - """ - instance: int - plugin_type: T.Literal["align", "detect", "mask", "recognition"] | None = None - is_initialized: bool = False - - -@dataclass -class SplitTracker: - """ Dataclass to hold objects for splitting frame's detected faces and rejoining them for - post-detector pliugins - - Parameters - ---------- - faces_per_filename: dict[str, int] - Tracking of faces per filename for recompiling batches - rollover: :class:`ExtractMedia` | None - Batch rollover items - output_faces: list[:class:`~lib.align.detected_face.DetectedFace`] - Recompiled output faces from the plugin - """ - faces_per_filename: dict[str, int] - rollover: ExtractMedia | None - output_faces: list[DetectedFace] - - -class Extractor(): # pylint:disable=too-many-instance-attributes - """ Extractor Plugin Object - - All ``_base`` classes for Aligners, Detectors and Maskers inherit from this class. - - This class sets up a pipeline for working with ML plugins. - - Plugins are split into 3 threads, to utilize Numpy and CV2s parallel processing, as well as - allow the predict function of the model to sit in a dedicated thread. - A plugin is expected to have 3 core functions, each in their own thread: - - :func:`process_input()` - Prepare the data for feeding into a model - - :func:`predict` - Feed the data through the model - - :func:`process_output()` - Perform any data post-processing - - Parameters - ---------- - git_model_id: int - The second digit in the github tag that identifies this model. See - https://github.com/deepfakes-models/faceswap-models for more information - model_filename: str - The name of the model file to be loaded - configfile: str, optional - Path to a custom configuration ``ini`` file. Default: Use system configfile - instance: int, optional - If this plugin is being executed multiple times (i.e. multiple pipelines have been - launched), the instance of the plugin must be passed in for naming convention reasons. - Default: 0 - - - The following attributes should be set in the plugin's :func:`__init__` method after - initializing the parent. - - Attributes - ---------- - name: str - Name of this plugin. Used for display purposes. - input_size: int - The input size to the model in pixels across one edge. The input size should always be - square. - color_format: str - Color format for model. Must be ``'BGR'``, ``'RGB'`` or ``'GRAY'``. Defaults to ``'BGR'`` - if not explicitly set. - vram: int - Approximate VRAM used by the model at :attr:`input_size`. Used to calculate the - :attr:`batchsize`. Be conservative to avoid OOM. - vram_per_batch: int - Approximate additional VRAM used by the model for each additional batch. Used to calculate - the :attr:`batchsize`. Be conservative to avoid OOM. - - See Also - -------- - plugins.extract.detect._base : Detector parent class for extraction plugins. - plugins.extract.align._base : Aligner parent class for extraction plugins. - plugins.extract.mask._base : Masker parent class for extraction plugins. - plugins.extract.pipeline : The extract pipeline that configures and calls all plugins - - """ - def __init__(self, - git_model_id: int | None = None, - model_filename: str | list[str] | None = None, - configfile: str | None = None, - instance: int = 0) -> None: - logger.debug(parse_class_init(locals())) - cfg.load_config(configfile) - - self._info = PluginInfo(instance=instance) - """:class:`PluginInfo`: holds information about the plugin instance""" - - self.model_path = self._get_model(git_model_id, model_filename) - """ str or list: Path to the model file(s) (if required). Multiple model files should - be a list of strings """ - - # << SET THE FOLLOWING IN PLUGINS __init__ IF DIFFERENT FROM DEFAULT >> # - self.name: str | None = None - self.input_size = 0 - self.color_format: T.Literal["BGR", "RGB", "GRAY"] = "BGR" - self.vram = 0 - self.vram_per_batch = 0 - - # << THE FOLLOWING ARE SET IN self.initialize METHOD >> # - self.model: T.Any = None - """varies: The model for this plugin. Set in the plugin's :func:`init_model()` method """ - - # For detectors that support batching, this should be set to the calculated batch size - # that the amount of available VRAM will support. - self.batchsize = 1 - """ int: Batchsize for feeding this model. The number of images the model should - feed through at once. """ - - self._queues: dict[str, Queue] = {} - """ dict: in + out queues and internal queues for this plugin, """ - - self._threads: list[MultiThread] = [] - """ list: Internal threads for this plugin """ - - self._extract_media: dict[str, ExtractMedia] = {} - """ dict: The :class:`~plugins.extract.extract_media.ExtractMedia` objects currently being - processed. Stored at input for pairing back up on output of extractor process """ - - # << THE FOLLOWING PROTECTED ATTRIBUTES ARE SET IN PLUGIN TYPE _base.py >>> # - self._tracker = SplitTracker({}, None, []) - """:class:`SplitTracker`: Holds objects for splitting frame's detected faces and - rejoining them for post-detector pliugins """ - - logger.debug("Initialized _base %s", self.__class__.__name__) - - # <<< OVERIDABLE METHODS >>> # - def init_model(self) -> None: - """ **Override method** - - Override this method to execute the specific model initialization method """ - raise NotImplementedError - - def process_input(self, batch: BatchType) -> None: - """ **Override method** - - Override this method for specific extractor pre-processing of image - - Parameters - ---------- - batch : :class:`ExtractorBatch` - Contains the batch that is currently being passed through the plugin process - """ - raise NotImplementedError - - def predict(self, feed: np.ndarray) -> np.ndarray: - """ **Override method** - - Override this method for specific extractor model prediction function - - Parameters - ---------- - feed: :class:`numpy.ndarray` - The feed images for the batch - - Notes - ----- - Input for :func:`predict` should have been set in :func:`process_input` - - Output from the model should populate the key :attr:`prediction` of the :attr:`batch`. - - For Detect: - the expected output for the :attr:`prediction` of the :attr:`batch` should be a - ``list`` of :attr:`batchsize` of detected face points. These points should be either - a ``list``, ``tuple`` or ``numpy.ndarray`` with the first 4 items being the `left`, - `top`, `right`, `bottom` points, in that order - """ - raise NotImplementedError - - def process_output(self, batch: BatchType) -> None: - """ **Override method** - - Override this method for specific extractor model post predict function - - Parameters - ---------- - batch: :class:`ExtractorBatch` - Contains the batch that is currently being passed through the plugin process - - Notes - ----- - For Align: - The :attr:`landmarks` must be populated in :attr:`batch` from this method. - This should be a ``list`` or :class:`numpy.ndarray` of :attr:`batchsize` containing a - ``list``, ``tuple`` or :class:`numpy.ndarray` of `(x, y)` coordinates of the 68 point - landmarks as calculated from the :attr:`model`. - """ - raise NotImplementedError - - def on_completion(self) -> None: - """ Override to perform an action when the extract process has completed. By default, no - action is undertaken """ - return - - def _predict(self, batch: BatchType) -> BatchType: - """ **Override method** (at `` level) - - This method should be overridden at the `` level (IE. - ``plugins.extract.detect._base`` or ``plugins.extract.align._base``) and should not - be overridden within plugins themselves. - - It acts as a wrapper for the plugin's ``self.predict`` method and handles any - predict processing that is consistent for all plugins within the `plugin_type` - - Parameters - ---------- - batch: :class:`ExtractorBatch` - Contains the batch that is currently being passed through the plugin process - """ - raise NotImplementedError - - def _process_input(self, batch: BatchType) -> BatchType: - """ **Override method** (at `` level) - - This method should be overridden at the `` level (IE. - ``plugins.extract.detect._base`` or ``plugins.extract.align._base``) and should not - be overridden within plugins themselves. - - It acts as a wrapper for the plugin's :func:`process_input` method and handles any - input processing that is consistent for all plugins within the `plugin_type`. - - If this method is not overridden then the plugin's :func:`process_input` is just called. - - Parameters - ---------- - batch: :class:`ExtractorBatch` - Contains the batch that is currently being passed through the plugin process - - Notes - ----- - When preparing an input to the model a the attribute :attr:`feed` must be added - to the :attr:`batch` which contains this input. - """ - self.process_input(batch) - return batch - - def _process_output(self, batch: BatchType) -> BatchType: - """ **Override method** (at `` level) - - This method should be overridden at the `` level (IE. - ``plugins.extract.detect._base`` or ``plugins.extract.align._base``) and should not - be overridden within plugins themselves. - - It acts as a wrapper for the plugin's :func:`process_output` method and handles any - output processing that is consistent for all plugins within the `plugin_type`. - - If this method is not overridden then the plugin's :func:`process_output` is just called. - - Parameters - ---------- - batch: :class:`ExtractorBatch` - Contains the batch that is currently being passed through the plugin process - """ - self.process_output(batch) - return batch - - def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: - """ **Override method** (at `` level) - - This method should be overridden at the `` level (IE. - :mod:`plugins.extract.detect._base`, :mod:`plugins.extract.align._base` or - :mod:`plugins.extract.mask._base`) and should not be overridden within plugins themselves. - - Handles consistent finalization for all plugins that exist within that plugin type. Its - input is always the output from :func:`process_output()` - - Parameters - ---------- - batch: :class:`ExtractorBatch` - Contains the batch that is currently being passed through the plugin process - """ - raise NotImplementedError - - def get_batch(self, queue: Queue) -> tuple[bool, BatchType]: - """ **Override method** (at `` level) - - This method should be overridden at the `` level (IE. - :mod:`plugins.extract.detect._base`, :mod:`plugins.extract.align._base` or - :mod:`plugins.extract.mask._base`) and should not be overridden within plugins themselves. - - Get :class:`~plugins.extract.extract_media.ExtractMedia` items from the queue in batches of - :attr:`batchsize` - - Parameters - ---------- - queue : queue.Queue() - The ``queue`` that the batch will be fed from. This will be the input to the plugin. - """ - raise NotImplementedError - - @classmethod - def get_device_context(cls, cpu: bool) -> T.ContextManager: - """ Get a device context manager for running inference on the CPU - - Parameters - ---------- - cpu: bool - ``True`` to get a context manager for running on the CPU. ``False`` to get a - context manager for the default device - - Returns - ------- - ContextManager - The context manager for running ops on the selected device - """ - if cpu: - logger.debug("CPU mode selected. Returning CPU device context") - return device("cpu") - - # TODO apple_silicon - if get_backend() == "apple_silicon": - pass - - if torch.cuda.is_available(): - logger.debug("Cuda available. Returning Cuda device context") - return device("cuda") - - logger.debug("Cuda not available. Returning CPU device context") - return device("cpu") - - # <<< THREADING METHODS >>> # - def start(self) -> None: - """ Start all threads - - Exposed for :mod:`~plugins.extract.pipeline` to start plugin's threads - """ - for thread in self._threads: - thread.start() - - def join(self) -> None: - """ Join all threads - - Exposed for :mod:`~plugins.extract.pipeline` to join plugin's threads - """ - for thread in self._threads: - thread.join() - - def check_and_raise_error(self) -> None: - """ Check all threads for errors - - Exposed for :mod:`~plugins.extract.pipeline` to check plugin's threads for errors - """ - for thread in self._threads: - thread.check_and_raise_error() - - def rollover_collector(self, queue: Queue) -> T.Literal["EOF"] | ExtractMedia: - """ For extractors after the Detectors, the number of detected faces per frame vs extractor - batch size mean that faces will need to be split/re-joined with frames. The rollover - collector can be used to rollover items that don't fit in a batch. - - Collect the item from the :attr:`_tracker.rollover` dict or from the queue. Add face count - per frame to :attr:`_tracker.faces_per_filename` for joining batches back up in finalize - - Parameters - ---------- - queue: :class:`queue.Queue` - The input queue to the aligner. Should contain - :class:`~plugins.extract.extract_media.ExtractMedia` objects - - Returns - ------- - :class:`~plugins.extract.extract_media.ExtractMedia` or EOF - The next extract media object, or EOF if pipe has ended - """ - if self._tracker.rollover is not None: - logger.trace("Getting from _tracker.rollover: " # type:ignore[attr-defined] - "(filename: `%s`, faces: %s)", - self._tracker.rollover.filename, - len(self._tracker.rollover.detected_faces)) - item: T.Literal["EOF"] | ExtractMedia = self._tracker.rollover - self._tracker.rollover = None - else: - next_item = self._get_item(queue) - # Rollover collector should only be used at entry to plugin - assert isinstance(next_item, (ExtractMedia, str)) - item = next_item - if item != "EOF": - logger.trace("Getting from queue: (filename: %s, " # type:ignore[attr-defined] - "faces: %s)", - item.filename, len(item.detected_faces)) - self._tracker.faces_per_filename[item.filename] = len(item.detected_faces) - return item - - # <<< PROTECTED ACCESS METHODS >>> # - # <<< INIT METHODS >>> # - @classmethod - def _get_model(cls, - git_model_id: int | None, - model_filename: str | list[str] | None) -> str | list[str] | None: - """ Check if model is available, if not, download and unzip it """ - if model_filename is None: - logger.debug("No model_filename specified. Returning None") - return None - if git_model_id is None: - logger.debug("No git_model_id specified. Returning None") - return None - model = GetModel(model_filename, git_model_id) - return model.model_path - - # <<< PLUGIN INITIALIZATION >>> # - def initialize(self, *args, **kwargs) -> None: - """ Initialize the extractor plugin - - Should be called from :mod:`~plugins.extract.pipeline` - """ - logger.debug("initialize %s: (args: %s, kwargs: %s)", - self.__class__.__name__, args, kwargs) - assert self._info.plugin_type is not None and self.name is not None - if self._info.is_initialized: - # When batch processing, plugins will be initialized on first job in batch - logger.debug("Plugin already initialized: %s (%s)", - self.name, self._info.plugin_type.title()) - return - - logger.info("Initializing %s (%s)...", self.name, self._info.plugin_type.title()) - name = self.name.replace(" ", "_").lower() - self._add_queues(kwargs["in_queue"], - kwargs["out_queue"], - [f"predict_{name}", f"post_{name}"]) - self._compile_threads() - self.init_model() - self._info.is_initialized = True - logger.info("Initialized %s (%s) with batchsize of %s", - self.name, self._info.plugin_type.title(), self.batchsize) - - def _add_queues(self, - in_queue: Queue, - out_queue: Queue, - queues: list[str]) -> None: - """ Add the queues - in_queue and out_queue should be previously created queue manager queues. - queues should be a list of queue names """ - self._queues["in"] = in_queue - self._queues["out"] = out_queue - for q_name in queues: - self._queues[q_name] = queue_manager.get_queue( - name=f"{self._info.plugin_type}{self._info.instance}_{q_name}", - maxsize=1) - - # <<< THREAD METHODS >>> # - def _compile_threads(self) -> None: - """ Compile the threads into self._threads list """ - assert self.name is not None - logger.debug("Compiling %s threads", self._info.plugin_type) - name = self.name.replace(" ", "_").lower() - base_name = f"{self._info.plugin_type}_{name}" - self._add_thread(f"{base_name}_input", - self._process_input, - self._queues["in"], - self._queues[f"predict_{name}"]) - self._add_thread(f"{base_name}_predict", - self._predict, - self._queues[f"predict_{name}"], - self._queues[f"post_{name}"]) - self._add_thread(f"{base_name}_output", - self._process_output, - self._queues[f"post_{name}"], - self._queues["out"]) - logger.debug("Compiled %s threads: %s", self._info.plugin_type, self._threads) - - def _add_thread(self, - name: str, - function: Callable[[BatchType], BatchType], - in_queue: Queue, - out_queue: Queue) -> None: - """ Add a MultiThread thread to self._threads """ - logger.debug("Adding thread: (name: %s, function: %s, in_queue: %s, out_queue: %s)", - name, function, in_queue, out_queue) - self._threads.append(MultiThread(target=self._thread_process, - name=name, - function=function, - in_queue=in_queue, - out_queue=out_queue)) - logger.debug("Added thread: %s", name) - - def _obtain_batch_item(self, function: Callable[[BatchType], BatchType], - in_queue: Queue, - out_queue: Queue) -> BatchType | None: - """ Obtain the batch item from the in queue for the current process. - - Parameters - ---------- - function: callable - The current plugin function being run - in_queue: :class:`queue.Queue` - The input queue for the function - out_queue: :class:`queue.Queue` - The output queue from the function - - Returns - ------- - :class:`ExtractorBatch` or ``None`` - The batch, if one exists, or ``None`` if queue is exhausted - """ - batch: T.Literal["EOF"] | BatchType | ExtractMedia - if function.__name__ == "_process_input": # Process input items to batches - exhausted, batch = self.get_batch(in_queue) - if exhausted: - if batch.filename: - # Put the final batch - batch = function(batch) - out_queue.put(batch) - return None - else: - batch = self._get_item(in_queue) - if batch == "EOF": - return None - - # ExtractMedia should only ever be the output of _get_item at the entry to a - # plugin's pipeline (ie in _process_input) - assert not isinstance(batch, ExtractMedia) - return batch - - def _thread_process(self, - function: Callable[[BatchType], BatchType], - in_queue: Queue, - out_queue: Queue) -> None: - """ Perform a plugin function in a thread - - Parameters - ---------- - function: callable - The current plugin function being run - in_queue: :class:`queue.Queue` - The input queue for the function - out_queue: :class:`queue.Queue` - The output queue from the function - """ - logger.debug("threading: (function: '%s')", function.__name__) - while True: - batch = self._obtain_batch_item(function, in_queue, out_queue) - if batch is None: - break - if not batch.filename: # Batch not populated. Possible during re-aligns - continue - batch = function(batch) - if function.__name__ == "_process_output": - # Process output items to individual items from batch - for item in self.finalize(batch): - out_queue.put(item) - else: - out_queue.put(batch) - logger.debug("Putting EOF") - out_queue.put("EOF") - - # <<< QUEUE METHODS >>> # - def _get_item(self, queue: Queue) -> T.Literal["EOF"] | ExtractMedia | BatchType: - """ Yield one item from a queue """ - item = queue.get() - if isinstance(item, ExtractMedia): - logger.trace("filename: '%s', image shape: %s, " # type:ignore[attr-defined] - "detected_faces: %s, queue: %s, item: %s", - item.filename, item.image_shape, item.detected_faces, queue, item) - self._extract_media[item.filename] = item - else: - logger.trace("item: %s, queue: %s", item, queue) # type:ignore[attr-defined] - return item diff --git a/plugins/extract/align/_base/__init__.py b/plugins/extract/align/_base/__init__.py deleted file mode 100644 index 6e32deea62..0000000000 --- a/plugins/extract/align/_base/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env python3 -""" Base class for Aligner plugins ALL aligners should at least inherit from this class. """ - -from .aligner import Aligner, AlignerBatch, BatchType diff --git a/plugins/extract/align/_base/aligner.py b/plugins/extract/align/_base/aligner.py deleted file mode 100644 index 49cb5d19cf..0000000000 --- a/plugins/extract/align/_base/aligner.py +++ /dev/null @@ -1,837 +0,0 @@ -#!/usr/bin/env python3 -""" Base class for Face Aligner plugins - -All Aligner Plugins should inherit from this class. -See the override methods for which methods are required. - -The plugin will receive a :class:`~plugins.extract.extract_media.ExtractMedia` object. - -For each source item, the plugin must pass a dict to finalize containing: - ->>> {"filename": [], ->>> "landmarks": [list of 68 point face landmarks] ->>> "detected_faces": []} -""" -from __future__ import annotations -import logging -import typing as T - -from dataclasses import dataclass, field -from time import sleep - -import cv2 -import numpy as np -from torch.cuda import OutOfMemoryError - -from lib.align import LandmarkType -from lib.utils import FaceswapError -from plugins.extract import ExtractMedia, extract_config as cfg -from plugins.extract._base import BatchType, ExtractorBatch, Extractor -from .processing import AlignedFilter, ReAlign - -if T.TYPE_CHECKING: - from collections.abc import Generator - from queue import Queue - from lib.align import DetectedFace - from lib.align.aligned_face import CenteringType - -logger = logging.getLogger(__name__) -_BATCH_IDX: int = 0 - - -def _get_new_batch_id() -> int: - """ Obtain the next available batch index - - Returns - ------- - int - The next available unique batch id - """ - global _BATCH_IDX # pylint:disable=global-statement - _BATCH_IDX += 1 - return _BATCH_IDX - - -@dataclass -class AlignerBatch(ExtractorBatch): - """ Dataclass for holding items flowing through the aligner. - - Inherits from :class:`~plugins.extract._base.ExtractorBatch` - - Parameters - ---------- - batch_id: int - A unique integer for tracking this batch - landmarks: list - List of 68 point :class:`numpy.ndarray` landmark points returned from the aligner - refeeds: list - List of :class:`numpy.ndarrays` for holding each of the feeds that will be put through the - model for each refeed - second_pass: bool, optional - ``True`` if this batch is passing through the aligner for a second time as re-align has - been selected otherwise ``False``. Default: ``False`` - second_pass_masks: :class:`numpy.ndarray`, optional - The masks used to filter out re-feed values for passing to the re-aligner. - """ - batch_id: int = 0 - detected_faces: list[DetectedFace] = field(default_factory=list) - landmarks: np.ndarray = field(default_factory=lambda: np.array([])) - refeeds: list[np.ndarray] = field(default_factory=list) - second_pass: bool = False - second_pass_masks: np.ndarray = field(default_factory=lambda: np.array([])) - - def __repr__(self): - """ Prettier repr for debug printing """ - retval = super().__repr__() - retval += (f", batch_id={self.batch_id}, " - f"landmarks=[({self.landmarks.shape}, {self.landmarks.dtype})], " - f"refeeds={[(f.shape, f.dtype) for f in self.refeeds]}, " - f"second_pass={self.second_pass}, " - f"second_pass_masks={self.second_pass_masks})") - return retval - - def __post_init__(self): - """ Make sure that we have been given a non-zero ID """ - assert self.batch_id != 0, ("A batch ID must be specified for Aligner Batches") - - -class Aligner(Extractor): # pylint:disable=abstract-method - """ Aligner plugin _base Object - - All Aligner plugins must inherit from this class - - Parameters - ---------- - git_model_id: int - The second digit in the github tag that identifies this model. See - https://github.com/deepfakes-models/faceswap-models for more information - model_filename: str - The name of the model file to be loaded - normalize_method: {`None`, 'clahe', 'hist', 'mean'}, optional - Normalize the images fed to the aligner. Default: ``None`` - re_feed: int, optional - The number of times to re-feed a slightly adjusted bounding box into the aligner. - Default: `0` - re_align: bool, optional - ``True`` to obtain landmarks by passing the initially aligned face back through the - aligner. Default ``False`` - disable_filter: bool, optional - Disable all aligner filters regardless of config option. Default: ``False`` - Other Parameters - ---------------- - configfile: str, optional - Path to a custom configuration ``ini`` file. Default: Use system configfile - - See Also - -------- - plugins.extract.pipeline : The extraction pipeline for calling plugins - plugins.extract.align : Aligner plugins - plugins.extract._base : Parent class for all extraction plugins - plugins.extract.detect._base : Detector parent class for extraction plugins. - plugins.extract.mask._base : Masker parent class for extraction plugins. - """ - - def __init__(self, # pylint:disable=too-many-positional-arguments - git_model_id: int | None = None, - model_filename: str | None = None, - configfile: str | None = None, - instance: int = 0, - normalize_method: T.Literal["none", "clahe", "hist", "mean"] | None = None, - re_feed: int = 0, - re_align: bool = False, - disable_filter: bool = False, - **kwargs) -> None: - logger.debug("Initializing %s: (normalize_method: %s, re_feed: %s, re_align: %s, " - "disable_filter: %s)", self.__class__.__name__, normalize_method, re_feed, - re_align, disable_filter) - super().__init__(git_model_id, - model_filename, - configfile=configfile, - instance=instance, - **kwargs) - self._info.plugin_type = "align" - self.realign_centering: CenteringType = "face" # overide for plugin specific centering - - # Override for specific landmark type: - self.landmark_type = LandmarkType.LM_2D_68 - - self._eof_seen = False - self._normalize_method: T.Literal["clahe", "hist", "mean"] | None = None - self._re_feed = re_feed - self._filter = AlignedFilter(feature_filter=cfg.aligner_features(), - min_scale=cfg.aligner_min_scale(), - max_scale=cfg.aligner_max_scale(), - distance=cfg.aligner_distance(), - roll=cfg.aligner_roll(), - save_output=cfg.save_filtered(), - disable=disable_filter) - self._re_align = ReAlign(re_align, - cfg.realign_refeeds(), - cfg.filter_realign()) - self._needs_refeed_masks: bool = self._re_feed > 0 and ( - cfg.filter_refeed() or (self._re_align.do_refeeds and self._re_align.do_filter)) - self.set_normalize_method(normalize_method) - - logger.debug("Initialized %s", self.__class__.__name__) - - def set_normalize_method(self, method: T.Literal["none", "clahe", "hist", "mean"] | None - ) -> None: - """ Set the normalization method for feeding faces into the aligner. - - Parameters - ---------- - method: {"none", "clahe", "hist", "mean"} - The normalization method to apply to faces prior to feeding into the model - """ - method = None if method is None or method.lower() == "none" else method - self._normalize_method = T.cast(T.Literal["clahe", "hist", "mean"] | None, method) - - def initialize(self, *args, **kwargs) -> None: - """ Add a call to add model input size to the re-aligner """ - self._re_align.set_input_size_and_centering(self.input_size, self.realign_centering) - super().initialize(*args, **kwargs) - - def _handle_realigns(self, queue: Queue) -> tuple[bool, AlignerBatch] | None: - """ Handle any items waiting for a second pass through the aligner. - - If EOF has been recieved and items are still being processed through the first pass - then wait for a short time and try again to collect them. - - On EOF return exhausted flag with an empty batch - - Parameters - ---------- - queue : queue.Queue() - The ``queue`` that the plugin will be fed from. - - Returns - ------- - ``None`` or tuple - If items are processed then returns (`bool`, :class:`AlignerBatch`) containing the - exhausted flag and the batch to be processed. If no items are processed returns - ``None`` - """ - if not self._re_align.active: - return None - - exhausted = False - if self._re_align.items_queued: - batch = self._re_align.get_batch() - logger.trace("Re-align batch: %s", batch) # type: ignore[attr-defined] - return exhausted, batch - - if self._eof_seen and self._re_align.items_tracked: - # EOF seen and items still being processed on first pass - logger.debug("Tracked re-align items waiting to be flushed, retrying...") - sleep(0.25) - return self.get_batch(queue) - - if self._eof_seen: - exhausted = True - logger.debug("All items processed. Returning empty batch") - self._filter.output_counts() - self._eof_seen = False # Reset for plugin re-use - return exhausted, AlignerBatch(batch_id=-1) - - return None - - def get_batch(self, queue: Queue) -> tuple[bool, AlignerBatch]: - """ Get items for inputting into the aligner from the queue in batches - - Items are returned from the ``queue`` in batches of - :attr:`~plugins.extract._base.Extractor.batchsize` - - Items are received as :class:`~plugins.extract.extract_media.ExtractMedia` objects and - converted to ``dict`` for internal processing. - - To ensure consistent batch sizes for aligner the items are split into separate items for - each :class:`~lib.align.DetectedFace` object. - - Remember to put ``'EOF'`` to the out queue after processing - the final batch - - Outputs items in the following format. All lists are of length - :attr:`~plugins.extract._base.Extractor.batchsize`: - - >>> {'filename': [], - >>> 'image': [], - >>> 'detected_faces': [[ np.ndarray: - """ Overide for specific plugin processing to convert a batch of face images from UINT8 - (0-255) into the correct format for the plugin's inference - - Parameters - ---------- - faces: :class:`numpy.ndarray` - The batch of faces in UINT8 format - - Returns - ------- - class: `numpy.ndarray` - The batch of faces in the format to feed through the plugin - """ - raise NotImplementedError() - - # <<< FINALIZE METHODS >>> # - def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: - """ Finalize the output from Aligner - - This should be called as the final task of each `plugin`. - - Pairs the detected faces back up with their original frame before yielding each frame. - - Parameters - ---------- - batch : :class:`AlignerBatch` - The final batch item from the `plugin` process. - - Yields - ------ - :class:`~plugins.extract.extract_media.ExtractMedia` - The :attr:`DetectedFaces` list will be populated for this class with the bounding boxes - and landmarks for the detected faces found in the frame. - """ - assert isinstance(batch, AlignerBatch) - if not batch.second_pass and self._re_align.active: - # Add the batch for second pass re-alignment and return - self._re_align.add_batch(batch) - return - for face, landmarks in zip(batch.detected_faces, batch.landmarks): - if not isinstance(landmarks, np.ndarray): - landmarks = np.array(landmarks) - face.add_landmarks_xy(landmarks) - - logger.trace("Item out: %s", batch) # type: ignore[attr-defined] - - for frame, filename, face in zip(batch.image, batch.filename, batch.detected_faces): - self._tracker.output_faces.append(face) - if len(self._tracker.output_faces) != self._tracker.faces_per_filename[filename]: - continue - - self._tracker.output_faces, folders = self._filter(self._tracker.output_faces, - min(frame.shape[:2])) - - output = self._extract_media.pop(filename) - output.add_detected_faces(self._tracker.output_faces) - output.add_sub_folders(folders) - self._tracker.output_faces = [] - - logger.trace("Final Output: (filename: '%s', image " # type: ignore[attr-defined] - "shape: %s, detected_faces: %s, item: %s)", output.filename, - output.image_shape, output.detected_faces, output) - yield output - self._re_align.untrack_batch(batch.batch_id) - - def on_completion(self) -> None: - """ Output the filter counts when process has completed """ - self._filter.output_counts() - - # <<< PROTECTED METHODS >>> # - # << PROCESS_INPUT WRAPPER >> - def _get_adjusted_boxes(self, original_boxes: np.ndarray) -> np.ndarray: - """ Obtain an array of adjusted bounding boxes based on the number of re-feed iterations - that have been selected and the minimum dimension of the original bounding box. - - Parameters - ---------- - original_boxes: :class:`numpy.ndarray` - The original ('x', 'y', 'w', 'h') detected face boxes corresponding to the incoming - detected face objects - - Returns - ------- - :class:`numpy.ndarray` - The original boxes (in position 0) and the randomly adjusted bounding boxes - """ - if self._re_feed == 0: - return original_boxes[None, ...] - beta = 0.05 - max_shift = np.min(original_boxes[..., 2:], axis=1) * beta - rands = np.random.rand(self._re_feed, *original_boxes.shape) * 2 - 1 - new_boxes = np.rint(original_boxes + (rands * max_shift[None, :, None])).astype("int32") - retval = np.concatenate((original_boxes[None, ...], new_boxes)) - logger.trace(retval) # type: ignore[attr-defined] - return retval - - def _process_input_first_pass(self, batch: AlignerBatch) -> None: - """ Standard pre-processing for aligners for first pass (if re-align selected) or the - only pass. - - Process the input to the aligner model multiple times based on the user selected - `re-feed` command line option. This adjusts the bounding box for the face to be fed - into the model by a random amount within 0.05 pixels of the detected face's shortest axis. - - References - ---------- - https://studios.disneyresearch.com/2020/06/29/high-resolution-neural-face-swapping-for-visual-effects/ - - Parameters - ---------- - batch: :class:`AlignerBatch` - Contains the batch that is currently being passed through the plugin process - """ - original_boxes = np.array([(face.left, face.top, face.width, face.height) - for face in batch.detected_faces]) - adjusted_boxes = self._get_adjusted_boxes(original_boxes) - - # Put in random re-feed data to the bounding boxes - for bounding_boxes in adjusted_boxes: - for face, box in zip(batch.detected_faces, bounding_boxes): - face.left, face.top, face.width, face.height = box - - self.process_input(batch) - batch.feed = self.faces_to_feed(self._normalize_faces(batch.feed)) - # Move the populated feed into the batch refeed list. It will be overwritten at next - # iteration - batch.refeeds.append(batch.feed) - - # Place the original bounding box back to detected face objects - for face, box in zip(batch.detected_faces, original_boxes): - face.left, face.top, face.width, face.height = box.tolist() - - def _get_realign_masks(self, batch: AlignerBatch) -> np.ndarray: - """ Obtain the masks required for processing re-aligns - - Parameters - ---------- - batch: :class:`AlignerBatch` - Contains the batch that is currently being passed through the plugin process - - Returns - ------- - :class:`numpy.ndarray` - The filter masks required for masking the re-aligns - """ - if self._re_align.do_refeeds: - retval = batch.second_pass_masks # Masks already calculated during re-feed - elif self._re_align.do_filter: - retval = self._filter.filtered_mask(batch)[None, ...] - else: - retval = np.zeros((batch.landmarks.shape[0], ), dtype="bool")[None, ...] - return retval - - def _process_input_second_pass(self, batch: AlignerBatch) -> None: - """ Process the input for 2nd-pass re-alignment - - Parameters - ---------- - batch: :class:`AlignerBatch` - Contains the batch that is currently being passed through the plugin process - """ - batch.second_pass_masks = self._get_realign_masks(batch) - - if not self._re_align.do_refeeds: - # Expand the dimensions for re-aligns for consistent handling of code - batch.landmarks = batch.landmarks[None, ...] - - refeeds = self._re_align.process_batch(batch) - batch.refeeds = [self.faces_to_feed(self._normalize_faces(faces)) for faces in refeeds] - - def _process_input(self, batch: BatchType) -> AlignerBatch: - """ Perform pre-processing depending on whether this is the first/only pass through the - aligner or the 2nd pass when re-align has been selected - - Parameters - ---------- - batch: :class:`AlignerBatch` - Contains the batch that is currently being passed through the plugin process - - Returns - ------- - :class:`AlignerBatch` - The batch with input processed - """ - assert isinstance(batch, AlignerBatch) - if batch.second_pass: - self._process_input_second_pass(batch) - else: - self._process_input_first_pass(batch) - return batch - - # <<< PREDICT WRAPPER >>> # - def _predict(self, batch: BatchType) -> AlignerBatch: - """ Just return the aligner's predict function - - Parameters - ---------- - batch: :class:`AlignerBatch` - The current batch to find alignments for - - Returns - ------- - :class:`AlignerBatch` - The batch item with the :attr:`prediction` populated - - Raises - ------ - FaceswapError - If GPU resources are exhausted - """ - assert isinstance(batch, AlignerBatch) - try: - preds = [self.predict(feed) for feed in batch.refeeds] - try: - batch.prediction = np.array(preds) - logger.trace("Aligner out: %s", # type:ignore[attr-defined] - batch.prediction.shape) - except ValueError as err: - # If refeed batches are different sizes, Numpy will error, so we need to explicitly - # set the dtype to 'object' rather than let it infer - # numpy error: - # ValueError: setting an array element with a sequence. The requested array has an - # inhomogeneous shape after 1 dimensions. The detected shape was (9,) + - # inhomogeneous part - if "inhomogeneous" in str(err): - logger.trace( # type:ignore[attr-defined] - "Mismatched array sizes, setting dtype to object: %s", - [p.shape for p in preds]) - batch.prediction = np.array(preds, dtype="object") - else: - raise - - except OutOfMemoryError as err: - msg = ("You do not have enough GPU memory available to run detection at the " - "selected batch size. You can try a number of things:" - "\n1) Close any other application that is using your GPU (web browsers are " - "particularly bad for this)." - "\n2) Lower the batchsize (the amount of images fed into the model) by " - "editing the plugin settings (GUI: Settings > Configure extract settings, " - "CLI: Edit the file faceswap/config/extract.ini)." - "\n3) Enable 'Single Process' mode.") - raise FaceswapError(msg) from err - - return batch - - def _process_refeeds(self, batch: AlignerBatch) -> list[AlignerBatch]: - """ Process the output for each selected re-feed - - Parameters - ---------- - batch: :class:`AlignerBatch` - The batch object passing through the aligner - - Returns - ------- - list - List of :class:`AlignerBatch` objects. Each object in the list contains the - results for each selected re-feed - """ - retval: list[AlignerBatch] = [] - if batch.second_pass: - # Re-insert empty sub-patches for re-population in ReAlign for filtered out batches - selected_idx = 0 - for mask in batch.second_pass_masks: - all_filtered = np.all(mask) - if not all_filtered: - feed = batch.refeeds[selected_idx] - pred = batch.prediction[selected_idx] - data = batch.data[selected_idx] if batch.data else {} - selected_idx += 1 - else: # All resuts have been filtered out - feed = pred = np.array([]) - data = {} - - subbatch = AlignerBatch(batch_id=batch.batch_id, - image=batch.image, - detected_faces=batch.detected_faces, - filename=batch.filename, - feed=feed, - prediction=pred, - data=[data], - second_pass=batch.second_pass) - - if not all_filtered: - self.process_output(subbatch) - - retval.append(subbatch) - else: - b_data = batch.data if batch.data else [{}] - for feed, pred, dat in zip(batch.refeeds, batch.prediction, b_data): - subbatch = AlignerBatch(batch_id=batch.batch_id, - image=batch.image, - detected_faces=batch.detected_faces, - filename=batch.filename, - feed=feed, - prediction=pred, - data=[dat], - second_pass=batch.second_pass) - self.process_output(subbatch) - retval.append(subbatch) - return retval - - def _get_refeed_filter_masks(self, - subbatches: list[AlignerBatch], - original_masks: np.ndarray | None = None) -> np.ndarray: - """ Obtain the boolean mask array for masking out failed re-feed results if filter refeed - has been selected - - Parameters - ---------- - subbatches: list - List of sub-batch results for each re-feed performed - original_masks: :class:`numpy.ndarray`, Optional - If passing in the second pass landmarks, these should be the original filter masks so - that we don't calculate the mask again for already filtered faces. Default: ``None`` - - Returns - ------- - :class:`numpy.ndarray` - boolean values for every detected face indicating whether the interim landmarks have - passed the filter test - """ - retval = np.zeros((len(subbatches), subbatches[0].landmarks.shape[0]), dtype="bool") - - if not self._needs_refeed_masks: - return retval - - retval = retval if original_masks is None else original_masks - for subbatch, masks in zip(subbatches, retval): - masks[:] = self._filter.filtered_mask(subbatch, np.flatnonzero(masks)) - return retval - - def _get_mean_landmarks(self, landmarks: np.ndarray, masks: np.ndarray) -> np.ndarray: - """ Obtain the averaged landmarks from the re-fed alignments. If config option - 'filter_refeed' is enabled, then average those results which have not been filtered out - otherwise average all results - - Parameters - ---------- - landmarks: :class:`numpy.ndarray` - The batch of re-fed alignments - masks: :class:`numpy.ndarray` - List of boolean values indicating whether each re-fed alignments passed or failed - the filter test - - Returns - ------- - :class:`numpy.ndarray` - The final averaged landmarks - """ - if any(np.all(masked) for masked in masks.T): - # hacky fix for faces which entirely failed the filter - # We just unmask one value as it is junk anyway and will be discarded on output - for idx, masked in enumerate(masks.T): - if np.all(masked): - masks[0, idx] = False - - masks = np.broadcast_to(np.reshape(masks, (*landmarks.shape[:2], 1, 1)), - landmarks.shape) - return np.ma.array(landmarks, mask=masks).mean(axis=0).data.astype("float32") - - def _process_output_first_pass(self, subbatches: list[AlignerBatch]) -> tuple[np.ndarray, - np.ndarray]: - """ Process the output from the aligner if this is the first or only pass. - - Parameters - ---------- - subbatches: list - List of sub-batch results for each re-feed performed - - Returns - ------- - landmarks: :class:`numpy.ndarray` - If re-align is not selected or if re-align has been selected but only on the final - output (ie: realign_reefeeds is ``False``) then the averaged batch of landmarks for all - re-feeds is returned. - If re-align_refeeds has been selected, then this will output each batch of re-feed - landmarks. - masks: :class:`numpy.ndarray` - Boolean mask corresponding to the re-fed landmarks output indicating any values which - should be filtered out prior to further processing - """ - masks = self._get_refeed_filter_masks(subbatches) - all_landmarks = np.array([sub.landmarks for sub in subbatches]) - - # re-align not selected or not filtering the re-feeds - if not self._re_align.do_refeeds: - retval = self._get_mean_landmarks(all_landmarks, masks) - return retval, masks - - # Re-align selected with filter re-feeds - return all_landmarks, masks - - def _process_output_second_pass(self, - subbatches: list[AlignerBatch], - masks: np.ndarray) -> np.ndarray: - """ Process the output from the aligner if this is the first or only pass. - - Parameters - ---------- - subbatches: list - List of sub-batch results for each re-aligned re-feed performed - masks: :class:`numpy.ndarray` - The original re-feed filter masks from the first pass - """ - self._re_align.process_output(subbatches, masks) - masks = self._get_refeed_filter_masks(subbatches, original_masks=masks) - all_landmarks = np.array([sub.landmarks for sub in subbatches]) - return self._get_mean_landmarks(all_landmarks, masks) - - def _process_output(self, batch: BatchType) -> AlignerBatch: - """ Process the output from the aligner model multiple times based on the user selected - `re-feed amount` configuration option, then average the results for final prediction. - - If the config option 'filter_refeed' is enabled, then mask out any returned alignments - that fail a filter test - - Parameters - ---------- - batch : :class:`AlignerBatch` - Contains the batch that is currently being passed through the plugin process - - Returns - ------- - :class:`AlignerBatch` - The batch item with :attr:`landmarks` populated - """ - assert isinstance(batch, AlignerBatch) - subbatches = self._process_refeeds(batch) - if batch.second_pass: - batch.landmarks = self._process_output_second_pass(subbatches, batch.second_pass_masks) - else: - landmarks, masks = self._process_output_first_pass(subbatches) - batch.landmarks = landmarks - batch.second_pass_masks = masks - return batch - - # <<< FACE NORMALIZATION METHODS >>> # - def _normalize_faces(self, faces: np.ndarray) -> np.ndarray: - """ Normalizes the face for feeding into model - The normalization method is dictated by the normalization command line argument - - Parameters - ---------- - faces: :class:`numpy.ndarray` - The batch of faces to normalize - - Returns - ------- - :class:`numpy.ndarray` - The normalized faces - """ - if self._normalize_method is None: - return faces - logger.trace("Normalizing faces") # type: ignore[attr-defined] - meth = getattr(self, f"_normalize_{self._normalize_method.lower()}") - faces = np.array([meth(face) for face in faces]) - logger.trace("Normalized faces") # type: ignore[attr-defined] - return faces - - @classmethod - def _normalize_mean(cls, face: np.ndarray) -> np.ndarray: - """ Normalize Face to the Mean - - Parameters - ---------- - face: :class:`numpy.ndarray` - The face to normalize - - Returns - ------- - :class:`numpy.ndarray` - The normalized face - """ - face = face / 255.0 - for chan in range(3): - layer = face[:, :, chan] - layer = (layer - layer.min()) / (layer.max() - layer.min()) - face[:, :, chan] = layer - return face * 255.0 - - @classmethod - def _normalize_hist(cls, face: np.ndarray) -> np.ndarray: - """ Equalize the RGB histogram channels - - Parameters - ---------- - face: :class:`numpy.ndarray` - The face to normalize - - Returns - ------- - :class:`numpy.ndarray` - The normalized face - """ - for chan in range(3): - face[:, :, chan] = cv2.equalizeHist(face[:, :, chan]) - return face - - @classmethod - def _normalize_clahe(cls, face: np.ndarray) -> np.ndarray: - """ Perform Contrast Limited Adaptive Histogram Equalization - - Parameters - ---------- - face: :class:`numpy.ndarray` - The face to normalize - - Returns - ------- - :class:`numpy.ndarray` - The normalized face - """ - clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(4, 4)) - for chan in range(3): - face[:, :, chan] = clahe.apply(face[:, :, chan]) - return face diff --git a/plugins/extract/align/_base/processing.py b/plugins/extract/align/_base/processing.py deleted file mode 100644 index efdeec9468..0000000000 --- a/plugins/extract/align/_base/processing.py +++ /dev/null @@ -1,489 +0,0 @@ -#!/usr/bin/env python3 -""" Processing methods for aligner plugins """ -from __future__ import annotations -import logging -import typing as T - -from threading import Lock - -import numpy as np - -from lib.align import AlignedFace - -if T.TYPE_CHECKING: - from lib.align import DetectedFace - from .aligner import AlignerBatch - from lib.align.aligned_face import CenteringType - -logger = logging.getLogger(__name__) - - -class AlignedFilter(): - """ Applies filters on the output of the aligner - - Parameters - ---------- - feature_filter: bool - ``True`` to enable filter to check relative position of eyes/eyebrows and mouth. ``False`` - to disable. - min_scale: float - Filters out faces that have been aligned at below this value as a multiplier of the - minimum frame dimension. Set to ``0`` for off. - max_scale: float - Filters out faces that have been aligned at above this value as a multiplier of the - minimum frame dimension. Set to ``0`` for off. - distance: float - Filters out faces that are further than this distance from an "average" face. Set to - ``0`` for off. - roll: float - Filters out faces with a roll value outside of 0 +/- the value given here. Set to ``0`` - for off. - save_output: bool - ``True`` if the filtered faces should be kept as they are being saved. ``False`` if they - should be deleted - disable: bool, Optional - ``True`` to disable the filter regardless of config options. Default: ``False`` - """ - def __init__(self, - feature_filter: bool, - min_scale: float, - max_scale: float, - distance: float, - roll: float, - save_output: bool, - disable: bool = False) -> None: - logger.debug("Initializing %s: (feature_filter: %s, min_scale: %s, max_scale: %s, " - "distance: %s, roll, %s, save_output: %s, disable: %s)", - self.__class__.__name__, feature_filter, min_scale, max_scale, distance, roll, - save_output, disable) - self._features = feature_filter - self._min_scale = min_scale - self._max_scale = max_scale - self._distance = distance / 100. - self._roll = roll - self._save_output = save_output - self._active = not disable and (feature_filter or - max_scale > 0.0 or - min_scale > 0.0 or - distance > 0.0 or - roll > 0.0) - self._counts: dict[str, int] = {"features": 0, - "min_scale": 0, - "max_scale": 0, - "distance": 0, - "roll": 0} - logger.debug("Initialized %s: ", self.__class__.__name__) - - def _scale_test(self, - face: AlignedFace, - minimum_dimension: int) -> T.Literal["min", "max"] | None: - """ Test if a face is below or above the min/max size thresholds. Returns as soon as a test - fails. - - Parameters - ---------- - face: :class:`~lib.aligned.AlignedFace` - The aligned face to test the original size of. - - minimum_dimension: int - The minimum (height, width) of the original frame - - Returns - ------- - "min", "max" or ``None`` - Returns min or max if the face failed the minimum or maximum test respectively. - ``None`` if all tests passed - """ - - if self._min_scale <= 0.0 and self._max_scale <= 0.0: - return None - - roi = face.original_roi.astype("int64") - size = ((roi[1][0] - roi[0][0]) ** 2 + (roi[1][1] - roi[0][1]) ** 2) ** 0.5 - - if self._min_scale > 0.0 and size < minimum_dimension * self._min_scale: - return "min" - - if self._max_scale > 0.0 and size > minimum_dimension * self._max_scale: - return "max" - - return None - - def _handle_filtered(self, - key: str, - face: DetectedFace, - faces: list[DetectedFace], - sub_folders: list[str | None], - sub_folder_index: int) -> None: - """ Add the filtered item to the filter counts. - - If config option `save_filtered` has been enabled then add the face to the output faces - list and update the sub_folder list with the correct name for this face. - - Parameters - ---------- - key: str - The key to use for the filter counts dictionary and the sub_folder name - face: :class:`~lib.align.detected_face.DetectedFace` - The detected face object to be filtered out - faces: list - The list of faces that will be returned from the filter - sub_folders: list - List of sub folder names corresponding to the list of detected face objects - sub_folder_index: int - The index within the sub-folder list that the filtered face belongs to - """ - self._counts[key] += 1 - if not self._save_output: - return - - faces.append(face) - sub_folders[sub_folder_index] = f"_align_filt_{key}" - - def __call__(self, faces: list[DetectedFace], minimum_dimension: int - ) -> tuple[list[DetectedFace], list[str | None]]: - """ Apply the filter to the incoming batch - - Parameters - ---------- - faces: list - List of detected face objects to filter out on size - minimum_dimension: int - The minimum (height, width) of the original frame - - Returns - ------- - detected_faces: list - The filtered list of detected face objects, if saving filtered faces has not been - selected or the full list of detected faces - sub_folders: list - List of ``Nones`` if saving filtered faces has not been selected or list of ``Nones`` - and sub folder names corresponding the filtered face location - """ - sub_folders: list[str | None] = [None for _ in range(len(faces))] - if not self._active: - return faces, sub_folders - - retval: list[DetectedFace] = [] - for idx, face in enumerate(faces): - aligned = AlignedFace(landmarks=face.landmarks_xy, centering="face") - - if self._features and aligned.relative_eye_mouth_position < 0.0: - self._handle_filtered("features", face, retval, sub_folders, idx) - continue - - min_max = self._scale_test(aligned, minimum_dimension) - if min_max in ("min", "max"): - self._handle_filtered(f"{min_max}_scale", face, retval, sub_folders, idx) - continue - - if 0.0 < self._distance < aligned.average_distance: - self._handle_filtered("distance", face, retval, sub_folders, idx) - continue - - if self._roll != 0.0 and not 0.0 < abs(aligned.pose.roll) < self._roll: - self._handle_filtered("roll", face, retval, sub_folders, idx) - continue - - retval.append(face) - return retval, sub_folders - - def filtered_mask(self, - batch: AlignerBatch, - skip: np.ndarray | list[int] | None = None) -> np.ndarray: - """ Obtain a list of boolean values for the given batch indicating whether they pass the - filter test. - - Parameters - ---------- - batch: :class:`AlignerBatch` - The batch of face to obtain masks for - skip: list or :class:`numpy.ndarray`, optional - List or 1D numpy array of indices indicating faces that have already been filter - masked and so should not be filtered again. Values in these index positions will be - returned as ``True`` - - Returns - ------- - :class:`numpy.ndarray` - Boolean mask array corresponding to any of the input DetectedFace objects that passed a - test. ``False`` the face passed the test. ``True`` it failed - """ - skip = [] if skip is None else skip - retval = np.ones((len(batch.detected_faces), ), dtype="bool") - for idx, (landmarks, image) in enumerate(zip(batch.landmarks, batch.image)): - if idx in skip: - continue - face = AlignedFace(landmarks) - if self._features and face.relative_eye_mouth_position < 0.0: - continue - if self._scale_test(face, min(image.shape[:2])) is not None: - continue - if 0.0 < self._distance < face.average_distance: - continue - if self._roll != 0.0 and not 0.0 < abs(face.pose.roll) < self._roll: - continue - retval[idx] = False - return retval - - def output_counts(self): - """ Output the counts of filtered items """ - if not self._active: - return - counts = [f"{key} ({getattr(self, f'_{key}'):.2f}): {count}" - for key, count in self._counts.items() - if count > 0] - if counts: - logger.info("Aligner filtered: (%s)", ", ".join(counts)) - - -class ReAlign(): - """ Holds data and methods for 2nd pass re-aligns - - Parameters - ---------- - active: bool - ``True`` if re-alignment has been requested otherwise ``False`` - do_refeeds: bool - ``True`` if re-feeds should be re-aligned, ``False`` if just the final output of the - re-feeds should be aligned - do_filter: bool - ``True`` if aligner filtered out faces should not be re-aligned. ``False`` if all faces - should be re-aligned - """ - def __init__(self, active: bool, do_refeeds: bool, do_filter: bool) -> None: - logger.debug("Initializing %s: (active: %s, do_refeeds: %s, do_filter: %s)", - self.__class__.__name__, active, do_refeeds, do_filter) - self._active = active - self._do_refeeds = do_refeeds - self._do_filter = do_filter - self._centering: CenteringType = "face" - self._size = 0 - self._tracked_lock = Lock() - self._tracked_batchs: dict[int, - dict[T.Literal["filtered_landmarks"], list[np.ndarray]]] = {} - # TODO. Probably does not need to be a list, just alignerbatch - self._queue_lock = Lock() - self._queued: list[AlignerBatch] = [] - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def active(self) -> bool: - """bool: ``True`` if re_aligns have been selected otherwise ``False``""" - return self._active - - @property - def do_refeeds(self) -> bool: - """bool: ``True`` if re-aligning is active and re-aligning re-feeds has been selected - otherwise ``False``""" - return self._active and self._do_refeeds - - @property - def do_filter(self) -> bool: - """bool: ``True`` if re-aligning is active and faces which failed the aligner filter test - should not be re-aligned otherwise ``False``""" - return self._active and self._do_filter - - @property - def items_queued(self) -> bool: - """bool: ``True`` if re-align is active and items are queued for a 2nd pass otherwise - ``False`` """ - with self._queue_lock: - return self._active and bool(self._queued) - - @property - def items_tracked(self) -> bool: - """bool: ``True`` if items exist in the tracker so still need to be processed """ - with self._tracked_lock: - return bool(self._tracked_batchs) - - def set_input_size_and_centering(self, input_size: int, centering: CenteringType) -> None: - """ Set the input size of the loaded plugin once the model has been loaded - - Parameters - ---------- - input_size: int - The input size, in pixels, of the aligner plugin - centering: ["face", "head" or "legacy"] - The centering to align the image at for re-aligning - """ - logger.debug("input_size: %s, centering: %s", input_size, centering) - self._size = input_size - self._centering = centering - - def track_batch(self, batch_id: int) -> None: - """ Add newly seen batch id from the aligner to the batch tracker, so that we can keep - track of whether there are still batches to be processed when the aligner hits 'EOF' - - Parameters - ---------- - batch_id: int - The batch id to add to batch tracking - """ - if not self._active: - return - logger.trace("Tracking batch id: %s", batch_id) # type: ignore[attr-defined] - with self._tracked_lock: - self._tracked_batchs[batch_id] = {} - - def untrack_batch(self, batch_id: int) -> None: - """ Remove the tracked batch from the tracker once the batch has been fully processed - - Parameters - ---------- - batch_id: int - The batch id to remove from batch tracking - """ - if not self._active: - return - logger.trace("Removing batch id from tracking: %s", batch_id) # type: ignore[attr-defined] - with self._tracked_lock: - del self._tracked_batchs[batch_id] - - def add_batch(self, batch: AlignerBatch) -> None: - """ Add first pass alignments to the queue for picking up for re-alignment, update their - :attr:`second_pass` attribute to ``True`` and clear attributes not required. - - Parameters - ---------- - batch: :class:`AlignerBatch` - aligner batch to perform re-alignment on - """ - with self._queue_lock: - logger.trace("Queueing for second pass: %s", batch) # type: ignore[attr-defined] - batch.second_pass = True - batch.feed = np.array([]) - batch.prediction = np.array([]) - batch.refeeds = [] - batch.data = [] - self._queued.append(batch) - - def get_batch(self) -> AlignerBatch: - """ Retrieve the next batch currently queued for re-alignment - - Returns - ------- - :class:`AlignerBatch` - The next :class:`AlignerBatch` for re-alignment - """ - with self._queue_lock: - retval = self._queued.pop(0) - logger.trace("Retrieving for second pass: %s", # type: ignore[attr-defined] - retval.filename) - return retval - - def process_batch(self, batch: AlignerBatch) -> list[np.ndarray]: - """ Pre process a batch object for re-aligning through the aligner. - - Parameters - ---------- - batch: :class:`AlignerBatch` - aligner batch to perform pre-processing on - - Returns - ------- - list - List of UINT8 aligned faces batch for each selected refeed - """ - logger.trace("Processing batch: %s, landmarks: %s", # type: ignore[attr-defined] - batch.filename, [b.shape for b in batch.landmarks]) - retval: list[np.ndarray] = [] - filtered_landmarks: list[np.ndarray] = [] - for landmarks, masks in zip(batch.landmarks, batch.second_pass_masks): - if not np.all(masks): # At least one face has not already been filtered - aligned_faces = [AlignedFace(lms, - image=image, - size=self._size, - centering=self._centering) - for image, lms, msk in zip(batch.image, landmarks, masks) - if not msk] - faces = np.array([aligned.face for aligned in aligned_faces - if aligned.face is not None]) - retval.append(faces) - batch.data.append({"aligned_faces": aligned_faces}) - - if np.any(masks): - # Track the original landmarks for re-insertion on the other side - filtered_landmarks.append(landmarks[masks]) - - with self._tracked_lock: - self._tracked_batchs[batch.batch_id] = {"filtered_landmarks": filtered_landmarks} - batch.landmarks = np.array([]) # Clear the old landmarks - return retval - - def _transform_to_frame(self, batch: AlignerBatch) -> np.ndarray: - """ Transform the predicted landmarks from the aligned face image back into frame - co-ordinates - - Parameters - ---------- - batch: :class:`AlignerBatch` - An aligner batch containing the aligned faces in the data field and the face - co-ordinate landmarks in the landmarks field - - Returns - ------- - :class:`numpy.ndarray` - The landmarks transformed to frame space - """ - faces: list[AlignedFace] = batch.data[0]["aligned_faces"] - retval = np.array([aligned.transform_points(landmarks, invert=True) - for landmarks, aligned in zip(batch.landmarks, faces)]) - logger.trace("Transformed points: original max: %s, " # type: ignore[attr-defined] - "new max: %s", batch.landmarks.max(), retval.max()) - return retval - - def _re_insert_filtered(self, batch: AlignerBatch, masks: np.ndarray) -> np.ndarray: - """ Re-insert landmarks that were filtered out from the re-align process back into the - landmark results - - Parameters - ---------- - batch: :class:`AlignerBatch` - An aligner batch containing the aligned faces in the data field and the landmarks in - frame space in the landmarks field - masks: np.ndarray - The original filter masks for this batch - - Returns - ------- - :class:`numpy.ndarray` - The full batch of landmarks with filtered out values re-inserted - """ - if not np.any(masks): - logger.trace("No landmarks to re-insert: %s", masks) # type: ignore[attr-defined] - return batch.landmarks - - with self._tracked_lock: - filtered = self._tracked_batchs[batch.batch_id]["filtered_landmarks"].pop(0) - - if np.all(masks): - retval = filtered - else: - retval = np.empty((masks.shape[0], *filtered.shape[1:]), dtype=filtered.dtype) - retval[~masks] = batch.landmarks - retval[masks] = filtered - - logger.trace("Filtered re-inserted: old shape: %s, " # type: ignore[attr-defined] - "new shape: %s)", batch.landmarks.shape, retval.shape) - - return retval - - def process_output(self, subbatches: list[AlignerBatch], batch_masks: np.ndarray) -> None: - """ Process the output from the re-align pass. - - - Transform landmarks from aligned face space to face space - - Re-insert faces that were filtered out from the re-align process back into the - landmarks list - - Parameters - ---------- - subbatches: list - List of sub-batch results for each re-aligned re-feed performed - batch_masks: :class:`numpy.ndarray` - The original re-feed filter masks from the first pass - """ - for batch, masks in zip(subbatches, batch_masks): - if not np.all(masks): - batch.landmarks = self._transform_to_frame(batch) - batch.landmarks = self._re_insert_filtered(batch, masks) diff --git a/plugins/extract/align/cv2_dnn.py b/plugins/extract/align/cv2_dnn.py index a695f7f11d..428b0ade99 100644 --- a/plugins/extract/align/cv2_dnn.py +++ b/plugins/extract/align/cv2_dnn.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" CV2 DNN landmarks extractor for faceswap.py +"""CV2 DNN landmarks extractor for faceswap.py Adapted from: https://github.com/yinguobing/cnn-facial-landmark MIT License @@ -25,296 +25,87 @@ """ from __future__ import annotations import logging -import typing as T import cv2 import numpy as np -from lib.utils import get_module_objects -from ._base import Aligner, AlignerBatch, BatchType - -if T.TYPE_CHECKING: - from lib.align.detected_face import DetectedFace +from lib.utils import get_module_objects, GetModel +from plugins.extract.base import ExtractPlugin logger = logging.getLogger(__name__) -class Align(Aligner): - """ Perform transformation to align and get landmarks """ - def __init__(self, **kwargs) -> None: - git_model_id = 1 - model_filename = "cnn-facial-landmark_v1.pb" - super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - +class CV2DNNAlign(ExtractPlugin): + """CV2 DNN Plugin for face alignment """ + def __init__(self) -> None: + # pylint:disable=duplicate-code + super().__init__(input_size=128, + batch_size=1, + is_rgb=True, + dtype="float32", + scale=(0, 255)) self.model: cv2.dnn.Net - self.model_path: str - self.name = "cv2-DNN Aligner" - self.input_size = 128 - self.color_format = "RGB" - self.vram = 0 # Doesn't use GPU - self.vram_per_batch = 0 - self.batchsize = 1 - self.realign_centering = "legacy" - - def init_model(self) -> None: - """ Initialize CV2 DNN Detector Model""" - self.model = cv2.dnn.readNetFromTensorflow(self.model_path) - self.model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) - - def faces_to_feed(self, faces: np.ndarray) -> np.ndarray: - """ Convert a batch of face images from UINT8 (0-255) to fp32 (0.0-255.0) - - Parameters - ---------- - faces: :class:`numpy.ndarray` - The batch of faces in UINT8 format - - Returns - ------- - class: `numpy.ndarray` - The batch of faces as fp32 - """ - return faces.astype("float32").transpose((0, 3, 1, 2)) - - def process_input(self, batch: BatchType) -> None: - """ Compile the detected faces for prediction - - Parameters - ---------- - batch: :class:`AlignerBatch` - The current batch to process input for - - Returns - ------- - :class:`AlignerBatch` - The batch item with the :attr:`feed` populated and any required :attr:`data` added - """ - assert isinstance(batch, AlignerBatch) - lfaces, roi, offsets = self.align_image(batch) - batch.feed = np.array(lfaces)[..., :3] - batch.data.append({"roi": roi, "offsets": offsets}) - - def _get_box_and_offset(self, face: DetectedFace) -> tuple[list[int], int]: - """Obtain the bounding box and offset from a detected face. - - - Parameters - ---------- - face: :class:`~lib.align.DetectedFace` - The detected face object to obtain the bounding box and offset from - - Returns - ------- - box: list - The [left, top, right, bottom] bounding box - offset: int - The offset of the box (difference between half width vs height) - """ - - box = T.cast(list[int], [face.left, - face.top, - face.right, - face.bottom]) - diff_height_width = T.cast(int, face.height) - T.cast(int, face.width) - offset = int(abs(diff_height_width / 2)) - return box, offset - - def align_image(self, batch: AlignerBatch) -> tuple[list[np.ndarray], - list[list[int]], - list[tuple[int, int]]]: - """ Align the incoming image for prediction - - Parameters - ---------- - batch: :class:`AlignerBatch` - The current batch to align the input for - - Returns - ------- - faces: list - List of feed faces for the aligner - rois: list - List of roi's for the faces - offsets: list - List of offsets for the faces - """ - logger.trace("Aligning image around center") # type:ignore[attr-defined] - sizes = (self.input_size, self.input_size) - rois = [] - faces = [] - offsets = [] - for det_face, image in zip(batch.detected_faces, batch.image): - box, offset_y = self._get_box_and_offset(det_face) - box_moved = self.move_box(box, (0, offset_y)) - # Make box square. - roi = self.get_square_box(box_moved) - - # Pad the image and adjust roi if face is outside of boundaries - image, offset = self.pad_image(roi, image) - face = image[roi[1] + abs(offset[1]): roi[3] + abs(offset[1]), - roi[0] + abs(offset[0]): roi[2] + abs(offset[0])] - interpolation = cv2.INTER_CUBIC if face.shape[0] < self.input_size else cv2.INTER_AREA - face = cv2.resize(face, dsize=sizes, interpolation=interpolation) - faces.append(face) - rois.append(roi) - offsets.append(offset) - return faces, rois, offsets - - @classmethod - def move_box(cls, - box: list[int], - offset: tuple[int, int]) -> list[int]: - """Move the box to direction specified by vector offset - Parameters - ---------- - box: list - The (`left`, `top`, `right`, `bottom`) box positions - offset: tuple - (x, y) offset to move the box + def load_model(self) -> cv2.dnn.Net: + """Load the CV2 DNN Aligner Model Returns ------- - list - The original box shifted by the offset + The loaded cv2-DNN model """ - left = box[0] + offset[0] - top = box[1] + offset[1] - right = box[2] + offset[0] - bottom = box[3] + offset[1] - return [left, top, right, bottom] + weights = GetModel(model_filename="cnn-facial-landmark_v1.pb", git_model_id=1) + model_path = weights.model_path + assert isinstance(model_path, str) + model = cv2.dnn.readNetFromTensorflow(model_path) + model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) + return model - @staticmethod - def get_square_box(box: list[int]) -> list[int]: - """Get a square box out of the given box, by expanding it. + def pre_process(self, batch: np.ndarray) -> np.ndarray: + """Format the ROI faces detection boxes for prediction Parameters ---------- - box: list - The (`left`, `top`, `right`, `bottom`) box positions + batch + The batch of face detection bounding boxes as (bs, l, t, r, b) Returns ------- - list - The original box but made square + The face detection bounding boxes formatted to take an image patch for prediction """ - left = box[0] - top = box[1] - right = box[2] - bottom = box[3] - - box_width = right - left - box_height = bottom - top - - # Check if box is already a square. If not, make it a square. - diff = box_height - box_width - delta = int(abs(diff) / 2) + heights = batch[..., 3] - batch[..., 1] + widths = batch[..., 2] - batch[..., 0] - if diff == 0: # Already a square. - return box - if diff > 0: # Height > width, a slim box. - left -= delta - right += delta - if diff % 2 == 1: - right += 1 - else: # Width > height, a short box. - top -= delta - bottom += delta - if diff % 2 == 1: - bottom += 1 + diff_height_width = widths - heights + offset = np.abs(diff_height_width // 2) + batch[:, [1, 3]] += offset[:, None] - # Make sure box is always square. - assert ((right - left) == (bottom - top)), 'Box is not square.' + cx = (batch[:, 0] + batch[:, 2]) // 2 + cy = (batch[:, 1] + batch[:, 3]) // 2 - return [left, top, right, bottom] - - @classmethod - def pad_image(cls, box: list[int], image: np.ndarray) -> tuple[np.ndarray, tuple[int, int]]: - """Pad image if face-box falls outside of boundaries - - Parameters - ---------- - box: list - The (`left`, `top`, `right`, `bottom`) roi box positions - image: :class:`numpy.ndarray` - The image to be padded + size = np.maximum(widths, heights) + half = size // 2 - Returns - ------- - :class:`numpy.ndarray` - The padded image - """ - height, width = image.shape[:2] - pad_l = 1 - box[0] if box[0] < 0 else 0 - pad_t = 1 - box[1] if box[1] < 0 else 0 - pad_r = box[2] - width if box[2] > width else 0 - pad_b = box[3] - height if box[3] > height else 0 - logger.trace("Padding: (l: %s, t: %s, r: %s, b: %s)", # type:ignore[attr-defined] - pad_l, pad_t, pad_r, pad_b) - padded_image = cv2.copyMakeBorder(image.copy(), - pad_t, - pad_b, - pad_l, - pad_r, - cv2.BORDER_CONSTANT, - value=(0, 0, 0)) - offsets = (pad_l - pad_r, pad_t - pad_b) - logger.trace("image_shape: %s, Padded shape: %s, box: %s, " # type:ignore[attr-defined] - "offsets: %s", - image.shape, padded_image.shape, box, offsets) - return padded_image, offsets + retval = batch.copy() + retval[:, 0] = cx - half + retval[:, 1] = cy - half + retval[:, 2] = retval[:, 0] + size + retval[:, 3] = retval[:, 1] + size + return retval - def predict(self, feed: np.ndarray) -> np.ndarray: - """ Predict the 68 point landmarks + def process(self, batch: np.ndarray) -> np.ndarray: + """Predict the 68 point landmarks Parameters ---------- - feed: :class:`numpy.ndarray` + feed The batch to feed into the aligner Returns ------- - :class:`numpy.ndarray` - The predictions from the aligner - """ - assert isinstance(self.model, cv2.dnn.Net) - self.model.setInput(feed) - retval = self.model.forward() - return retval - - def process_output(self, batch: BatchType) -> None: - """ Process the output from the model - - Parameters - ---------- - batch: :class:`AlignerBatch` - The current batch from the model with :attr:`predictions` populated - """ - assert isinstance(batch, AlignerBatch) - self.get_pts_from_predict(batch) - - def get_pts_from_predict(self, batch: AlignerBatch): - """ Get points from predictor and populates the :attr:`landmarks` property - - Parameters - ---------- - batch: :class:`AlignerBatch` - The current batch from the model with :attr:`predictions` populated + The predictions from the aligner """ - landmarks = [] - if batch.second_pass: - batch.landmarks = batch.prediction.reshape(self.batchsize, -1, 2) * self.input_size - else: - for prediction, roi, offset in zip(batch.prediction, - batch.data[0]["roi"], - batch.data[0]["offsets"]): - points = np.reshape(prediction, (-1, 2)) - points *= (roi[2] - roi[0]) - points[:, 0] += (roi[0] - offset[0]) - points[:, 1] += (roi[1] - offset[1]) - landmarks.append(points) - batch.landmarks = np.array(landmarks) - logger.trace("Predicted Landmarks: %s", batch.landmarks) # type:ignore[attr-defined] + self.model.setInput(batch.transpose((0, 3, 1, 2))) + return self.model.forward().reshape(batch.shape[0], -1, 2) __all__ = get_module_objects(__name__) diff --git a/plugins/extract/align/dark_decoder.py b/plugins/extract/align/dark_decoder.py new file mode 100644 index 0000000000..e115f39d24 --- /dev/null +++ b/plugins/extract/align/dark_decoder.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""DARK heatmap decoding for heatmap based aligners.""" +import logging + +import cv2 +import numpy as np + +from lib.logger import parse_class_init +from lib.utils import get_module_objects + +logger = logging.getLogger(__name__) + + +class Dark: + """Dark heatmap decoding + + https://github.com/ilovepose/DarkPose + + Parameters + ---------- + num_points + The number of landmarks output from the model + size + The size of the heatmap + """ + def __init__(self, num_points: int, size: int, blur_kernel: int = 11): + logger.debug(parse_class_init(locals())) + self._num_points = num_points + self._size = size + self._blur_kernel = blur_kernel + self._border = (blur_kernel - 1) // 2 + + def get_max_preds(self, batch_heatmaps: np.ndarray) -> np.ndarray: + """ get predictions from score maps + + Parameters + ---------- + heatmaps + Heatmap to derive points from ([batch_size, num_joints, height, width]) + + Returns + ------- + coords + The derived points from the heatmaps (B, N, 2) + """ + assert isinstance(batch_heatmaps, np.ndarray), "batch_heatmaps should be numpy.ndarray" + assert batch_heatmaps.ndim == 4, "batch_images should be 4-ndim" + + batch = batch_heatmaps.shape[0] + heatmaps_reshaped = batch_heatmaps.reshape((batch, self._num_points, -1)) + idx = np.argmax(heatmaps_reshaped, 2) + max_vals = np.amax(heatmaps_reshaped, 2) + + max_vals = max_vals.reshape((batch, self._num_points, 1)) + idx = idx.reshape((batch, self._num_points, 1)) + + preds = np.zeros((batch, self._num_points, 2), dtype=np.float32) + preds[:, :, 0] = idx[..., 0] % self._size + preds[:, :, 1] = idx[..., 0] // self._size + + pred_mask = np.repeat(np.greater(max_vals, 0.0), 2, axis=2) + pred_mask = pred_mask.astype(np.float32) + + preds *= pred_mask + return preds + + def gaussian_blur(self, heatmap: np.ndarray) -> np.ndarray: + """Perform gaussian blurring on the heatmaps + + Parameters + ---------- + heatmap + Batch of heatmaps to blur (N, points, size, size) + + Returns + ------- + The blurred heatmaps + """ + batch_size = heatmap.shape[0] + origin_max = heatmap.reshape(batch_size, self._num_points, -1).max(axis=2) + padded = np.pad(heatmap, + ((0, 0), + (0, 0), + (self._border, self._border), + (self._border, self._border)), + mode="constant") + reshaped = padded.reshape( # pylint:disable=too-many-function-args) + batch_size * self._num_points, + self._size + 2 * self._border, + self._size + 2 * self._border + ) + blurred = np.stack([cv2.GaussianBlur(img, (self._blur_kernel, self._blur_kernel), 0) + for img in reshaped]) + blurred = blurred.reshape(batch_size, + self._num_points, + self._size + 2 * self._border, + self._size + 2 * self._border) + cropped = blurred[:, :, self._border:-self._border, self._border:-self._border] + new_max = cropped.reshape(batch_size, self._num_points, -1).max(axis=2) + scale = origin_max / (new_max + 1e-8) # avoid division by zero + scale = scale[:, :, None, None] + return cropped * scale + + def taylor(self, heatmap: np.ndarray, coords: np.ndarray # pylint:disable=too-many-locals + ) -> np.ndarray: + """Sub-pixel refine the predictions + + Parameters + ---------- + heatmap + The processed heatmaps for refinement + coords + The coordinates to be refined + + Returns + ------- + The refined coordinates + """ + batch = heatmap.shape[0] + px = np.clip(coords[..., 0], 2, self._size - 3).astype(np.int32) + py = np.clip(coords[..., 1], 2, self._size - 3).astype(np.int32) + + flat_idx = np.arange(batch * self._num_points) + hm = heatmap.reshape(batch * self._num_points, self._size, self._size) + px_f = px.reshape(-1) + py_f = py.reshape(-1) + + dx = 0.5 * (hm[flat_idx, py_f, px_f + 1] - hm[flat_idx, py_f, px_f - 1]) + dy = 0.5 * (hm[flat_idx, py_f + 1, px_f] - hm[flat_idx, py_f - 1, px_f]) + dxx = 0.25 * (hm[flat_idx, py_f, px_f + 2] - 2 * + hm[flat_idx, py_f, px_f] + hm[flat_idx, py_f, px_f - 2]) + dyy = 0.25 * (hm[flat_idx, py_f + 2, px_f] - 2 * + hm[flat_idx, py_f, px_f] + hm[flat_idx, py_f - 2, px_f]) + dxy = 0.25 * (hm[flat_idx, py_f + 1, px_f + 1] - hm[flat_idx, py_f - 1, px_f + 1] - + hm[flat_idx, py_f + 1, px_f - 1] + hm[flat_idx, py_f - 1, px_f - 1]) + + dx = dx.reshape(batch, self._num_points) + dy = dy.reshape(batch, self._num_points) + dxx = dxx.reshape(batch, self._num_points) + dyy = dyy.reshape(batch, self._num_points) + dxy = dxy.reshape(batch, self._num_points) + + det = dxx * dyy - dxy ** 2 + inv_det = 1.0 / (det + 1e-8) + + offset_x = -inv_det * (dyy * dx - dxy * dy) + offset_y = -inv_det * (-dxy * dx + dxx * dy) + coords[..., 0] += offset_x + coords[..., 1] += offset_y + + return coords + + def __call__(self, heatmap: np.ndarray): + coords = self.get_max_preds(heatmap) + + # post-processing + heatmap = self.gaussian_blur(heatmap) + heatmap = np.maximum(heatmap, 1e-10) + heatmap = np.log(heatmap) + coords = self.taylor(heatmap, coords) + return coords + + +get_module_objects(__name__) diff --git a/plugins/extract/align/external.py b/plugins/extract/align/external.py deleted file mode 100644 index ca5630dc37..0000000000 --- a/plugins/extract/align/external.py +++ /dev/null @@ -1,287 +0,0 @@ -#!/usr/bin/env python3 -""" Import 68 point landmarks or ROI boxes from a json file """ -from __future__ import annotations -import logging -import typing as T -import os -import re - -import numpy as np - -from lib.align import EXTRACT_RATIOS, LandmarkType -from lib.utils import get_module_objects, FaceswapError, IMAGE_EXTENSIONS - -from ._base import BatchType, Aligner, AlignerBatch -from . import external_defaults as cfg - -if T.TYPE_CHECKING: - from lib.align.constants import CenteringType - -logger = logging.getLogger(__name__) -OriginType = T.Literal["top-left", "bottom-left", "top-right", "bottom-right"] -# pylint:disable=duplicate-code - - -class Align(Aligner): - """ Import face detection bounding boxes from an external json file """ - def __init__(self, **kwargs) -> None: - kwargs["normalize_method"] = None # Disable normalization - kwargs["re_feed"] = 0 # Disable re-feed - kwargs["re_align"] = False # Disablle re-align - kwargs["disable_filter"] = True # Disable aligner filters - super().__init__(git_model_id=None, model_filename=None, **kwargs) - - self.name = "External" - self.batchsize = 16 - self.origin: OriginType = T.cast(OriginType, cfg.origin()) - """ Literal["top-left", "bottom-left", "top-right", "bottom-right"] : The origin (0, 0) - location of the co-ordinates system used""" - self.file_name = cfg.file_name() - """ str : The file name to import landmark data from """ - - self._re_frame_no: re.Pattern = re.compile(r"\d+$") - self._is_video: bool = False - self._imported: dict[str | int, tuple[int, np.ndarray]] = {} - """dict[str | int, tuple[int, np.ndarray]]: filename as key, value of [number of faces - remaining for the frame, all landmarks in the frame] """ - - self._missing: list[str] = [] - self._roll: dict[T.Literal["bottom-left", "top-right", "bottom-right"], int] = { - "bottom-left": 3, "top-right": 1, "bottom-right": 2} - """dict[Literal["bottom-left", "top-right", "bottom-right"], int]: Amount to roll the - points by for different origins when 4 Point ROI landmarks are provided """ - - centering = T.cast("CenteringType", cfg.four_point_centering) - self._adjustment: float = 1. if centering == "none" else 1. - EXTRACT_RATIOS[centering] - """float: The amount to adjust 4 point ROI landmarks to standardize the points for a - 'head' sized extracted face """ - - def init_model(self) -> None: - """ No initialization to perform """ - logger.debug("No aligner model to initialize") - - def _check_for_video(self, filename: str) -> None: - """ Check a sample filename from the import file for a file extension to set - :attr:`_is_video` - - Parameters - ---------- - filename: str - A sample file name from the imported data - """ - logger.debug("Checking for video from '%s'", filename) - ext = os.path.splitext(filename)[-1] - if ext.lower() not in IMAGE_EXTENSIONS: - self._is_video = True - logger.debug("Set is_video to %s from extension '%s'", self._is_video, ext) - - def _get_key(self, key: str) -> str | int: - """ Obtain the key for the item in the lookup table. If the input are images, the key will - be the image filename. If the input is a video, the key will be the frame number - - Parameters - ---------- - key: str - The initial key value from import data or an import image/frame - - Returns - ------- - str | int - The filename is the input data is images, otherwise the frame number of a video - """ - if not self._is_video: - return key - original_name = os.path.splitext(key)[0] - matches = self._re_frame_no.findall(original_name) - if not matches or len(matches) > 1: - raise FaceswapError(f"Invalid import name: '{key}'. For video files, the key should " - "end with the frame number.") - retval = int(matches[0]) - logger.trace("Obtained frame number %s from key '%s'", # type:ignore[attr-defined] - retval, key) - return retval - - def _import_face(self, face: dict[str, list[int] | list[list[float]]]) -> np.ndarray: - """ Import the landmarks from a single face - - Parameters - ---------- - face: dict[str, list[int] | list[list[float]]] - An import dictionary item for a face - - Returns - ------- - :class:`numpy.ndarray` - The landmark data imported from the json file - - Raises - ------ - FaceSwapError - If the landmarks_2d key does not exist or the landmarks are in an incorrect format - """ - landmarks = face.get("landmarks_2d") - if landmarks is None: - raise FaceswapError("The provided import file is the required key 'landmarks_2d") - if len(landmarks) not in (4, 68): - raise FaceswapError("Imported 'landmarks_2d' should be either 68 facial feature " - "landmarks or 4 ROI corner locations") - retval = np.array(landmarks, dtype="float32") - if retval.shape[-1] != 2: - raise FaceswapError("Imported 'landmarks_2d' should be formatted as a list of (x, y) " - "co-ordinates") - if retval.shape[0] == 4: # Adjust ROI landmarks based on centering selected - center = np.mean(retval, axis=0) - retval = (retval - center) * self._adjustment + center - - return retval - - def import_data(self, data: dict[str, list[dict[str, list[int] | list[list[float]]]]]) -> None: - """ Import the aligner data from the json import file and set to :attr:`_imported` - - Parameters - ---------- - data: dict[str, list[dict[str, list[int] | list[list[float]]]]] - The data to be imported - """ - logger.debug("Data length: %s", len(data)) - self._check_for_video(list(data)[0]) - for key, faces in data.items(): - try: - lms = np.array([self._import_face(face) for face in faces], dtype="float32") - if not np.any(lms): - logger.trace("Skipping frame '%s' with no faces") # type:ignore[attr-defined] - continue - - store_key = self._get_key(key) - self._imported[store_key] = (lms.shape[0], lms) - except FaceswapError as err: - logger.error(str(err)) - msg = f"The imported frame key that failed was '{key}'" - raise FaceswapError(msg) from err - lm_shape = set(v[1].shape[1:] for v in self._imported.values() if v[0] > 0) - if len(lm_shape) > 1: - raise FaceswapError("All external data should have the same number of landmarks. " - f"Found landmarks of shape: {lm_shape}") - if (4, 2) in lm_shape: - self.landmark_type = LandmarkType.LM_2D_4 - - def process_input(self, batch: BatchType) -> None: - """ Put the filenames and original frame dimensions into `batch.feed` so they can be - collected for mapping in `.predict` - - Parameters - ---------- - batch: :class:`~plugins.extract.detect._base.AlignerBatch` - The batch to be processed by the plugin - """ - batch.feed = np.array([(self._get_key(os.path.basename(f)), i.shape[:2]) - for f, i in zip(batch.filename, batch.image)], dtype="object") - - def faces_to_feed(self, faces: np.ndarray) -> np.ndarray: - """ No action required for import plugin - - Parameters - ---------- - faces: :class:`numpy.ndarray` - The batch of faces in UINT8 format - - Returns - ------- - class: `numpy.ndarray` - the original batch of faces - """ - return faces - - def _adjust_for_origin(self, landmarks: np.ndarray, frame_dims: tuple[int, int]) -> np.ndarray: - """ Adjust the landmarks to be top-left orientated based on the selected import origin - - Parameters - ---------- - landmarks: :class:`np.ndarray` - The imported facial landmarks box at original (0, 0) origin - frame_dims: tuple[int, int] - The (rows, columns) dimensions of the original frame - - Returns - ------- - :class:`numpy.ndarray` - The adjusted landmarks box for a top-left origin - """ - if not np.any(landmarks) or self.origin == "top-left": - return landmarks - - if LandmarkType.from_shape(landmarks.shape) == LandmarkType.LM_2D_4: - landmarks = np.roll(landmarks, self._roll[self.origin], axis=0) - - if self.origin.startswith("bottom"): - landmarks[:, 1] = frame_dims[0] - landmarks[:, 1] - if self.origin.endswith("right"): - landmarks[:, 0] = frame_dims[1] - landmarks[:, 0] - - return landmarks - - def predict(self, feed: np.ndarray) -> np.ndarray: - """ Pair the input filenames to the import file - - Parameters - ---------- - feed: :class:`numpy.ndarray` - The filenames in the batch to return imported alignments for - - Returns - ------- - :class:`numpy.ndarray` - The predictions for the given filenames - """ - preds = [] - for key, frame_dims in feed: - if key not in self._imported: - self._missing.append(key) - continue - - remaining, all_lms = self._imported[key] - preds.append(self._adjust_for_origin(all_lms[all_lms.shape[0] - remaining], - frame_dims)) - - if remaining == 1: - del self._imported[key] - else: - self._imported[key] = (remaining - 1, all_lms) - - return np.array(preds, dtype="float32") - - def process_output(self, batch: BatchType) -> None: - """ Process the imported data to the landmarks attribute - - Parameters - ---------- - batch: :class:`AlignerBatch` - The current batch from the model with :attr:`predictions` populated - """ - assert isinstance(batch, AlignerBatch) - batch.landmarks = batch.prediction - logger.trace("Imported landmarks: %s", batch.landmarks) # type:ignore[attr-defined] - - def on_completion(self) -> None: - """ Output information if: - - Imported items were not matched in input data - - Input data was not matched in imported items - """ - super().on_completion() - - if self._missing: - logger.warning("[ALIGN] %s input frames could not be matched in the import file " - "'%s'. Run in verbose mode for a list of frames.", - len(self._missing), cfg.file_name) - logger.verbose( # type:ignore[attr-defined] - "[ALIGN] Input frames not in import file: %s", self._missing) - - if self._imported: - logger.warning("[ALIGN] %s items in the import file '%s' could not be matched to any " - "input frames. Run in verbose mode for a list of items.", - len(self._imported), cfg.file_name) - logger.verbose( # type:ignore[attr-defined] - "[ALIGN] import file items not in input frames: %s", list(self._imported)) - - -__all__ = get_module_objects(__name__) diff --git a/plugins/extract/align/external_defaults.py b/plugins/extract/align/external_defaults.py deleted file mode 100644 index c027bd483a..0000000000 --- a/plugins/extract/align/external_defaults.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python3 -""" The default options for the external faceswap Import Alignments plugin. - -Defaults files should be named `_defaults.py` - -Any qualifying items placed into this file will automatically get added to the relevant config -.ini files within the faceswap/config folder and added to the relevant GUI settings page. - -The following variable should be defined: - - Parameters - ---------- - HELPTEXT: str - A string describing what this plugin does - -Further plugin configuration options are assigned using: ->>> = ConfigItem(...) - -where is the name of the configuration option to be added (lower-case, alpha-numeric -+ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the -option. - -See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. -Items will be grouped together as per their `group` parameter, but otherwise will be processed in -the order that they are added to this module. -from lib.config import ConfigItem -""" -# pylint:disable=duplicate-code -from lib.config import ConfigItem - - -HELPTEXT = ( - "Import Aligner options.\n" - "Imports either 68 point 2D landmarks or an aligned bounding box from an external .json file." - ) - - -file_name = ConfigItem( - datatype=str, - default="import.json", - group="settings", - info="The import file should be stored in the same folder as the video (if extracting " - "from a video file) or inside the folder of images (if importing from a folder of " - "images)") - -origin = ConfigItem( - datatype=str, - default="top-left", - group="input", - info="The origin (0, 0) location of the co-ordinates system used. " - "\n\t top-left: The origin (0, 0) of the canvas is at the top left " - "corner." - "\n\t bottom-left: The origin (0, 0) of the canvas is at the bottom " - "left corner." - "\n\t top-right: The origin (0, 0) of the canvas is at the top right " - "corner." - "\n\t bottom-right: The origin (0, 0) of the canvas is at the bottom " - "right corner.", - choices=["top-left", "bottom-left", "top-right", "bottom-right"], - gui_radio=True) - -four_point_centering = ConfigItem( - datatype=str, - default="head", - group="input", - info="4 point ROI landmarks only. The approximate centering for the location of the " - "corner points to be imported. Default faceswap extracts are generated at 'head' " - "centering, but it is possible to pass in ROI points at a tighter centering. " - "Refer to https://github.com/deepfakes/faceswap/pull/1095 for a visual guide" - "\n\t head: The ROI points represent a loose crop enclosing the whole head." - "\n\t face: The ROI points represent a medium crop enclosing the face." - "\n\t legacy: The ROI points represent a tight crop enclosing the central face " - "area." - "\n\t none: Only required if importing 4 point ROI landmarks back into faceswap " - "having generated them from the 'alignments' tool 'export' job.", - choices=["head", "face", "legacy", "none"], - gui_radio=True) diff --git a/plugins/extract/align/fan.py b/plugins/extract/align/fan.py index 1a38397aa3..bb4a1f9ba1 100644 --- a/plugins/extract/align/fan.py +++ b/plugins/extract/align/fan.py @@ -1,285 +1,355 @@ #!/usr/bin/env python3 -""" Facial landmarks extractor for faceswap.py - Code adapted and modified from: - https://github.com/1adrianb/face-alignment +"""Facial landmarks extractor for faceswap.py + Code adapted and modified from: + https://github.com/1adrianb/face-alignment """ from __future__ import annotations import logging import typing as T -import cv2 import numpy as np -from keras.saving import load_model +import torch +from torch import nn +from torch.nn import functional as F + +from lib.utils import get_module_objects, GetModel +from plugins.extract.base import ExtractPlugin -from lib.utils import get_module_objects -from ._base import Aligner, AlignerBatch, BatchType from . import fan_defaults as cfg +from . dark_decoder import Dark -if T.TYPE_CHECKING: - from lib.align import DetectedFace - from keras import Model logger = logging.getLogger(__name__) -class Align(Aligner): - """ Perform transformation to align and get landmarks """ - def __init__(self, **kwargs) -> None: - git_model_id = 13 - model_filename = "face-alignment-network_2d4_keras_v3.h5" - super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.model: Model - self.name = "FAN" - self.input_size = 256 - self.color_format = "RGB" - self.vram = 896 # 810 in testing - self.vram_per_batch = 768 # ~720 in testing +class FAN(ExtractPlugin): + """FAN Face alignment""" + def __init__(self) -> None: + super().__init__(input_size=256, + batch_size=cfg.batch_size(), + is_rgb=True, + dtype="float32", + scale=(0, 1)) + self.model: FaceAlignmentNetwork self.realign_centering = "head" - self.batchsize: int = cfg.batch_size() - self.reference_scale = 200. / 195. - - def init_model(self) -> None: - """ Initialize FAN model """ - assert isinstance(self.name, str) - assert isinstance(self.model_path, str) - logging.disable(logging.WARNING) # Disable compile warning from Keras - self.model = load_model(self.model_path, compile=False) - logging.disable(logging.NOTSET) - self.model.make_predict_function() - # Feed a placeholder so Aligner is primed for Manual tool - placeholder_shape = (self.batchsize, self.input_size, self.input_size, 3) - placeholder = np.zeros(placeholder_shape, dtype="float32") - self.model.predict(placeholder, verbose=False, batch_size=self.batchsize) - - def faces_to_feed(self, faces: np.ndarray) -> np.ndarray: - """ Convert a batch of face images from UINT8 (0-255) to fp32 (0.0-1.0) + # Original reference scale leads to some fairly unsatisfying landmarks so tightened up + # self._reference_scale = 200. / 195. + self._reference_scale = 0.8 + self._dark = Dark(68, 64) if cfg.dark_decoder() else None - Parameters - ---------- - faces: :class:`numpy.ndarray` - The batch of faces in UINT8 format + def load_model(self) -> FaceAlignmentNetwork: + """Load the FAN model Returns ------- - class: `numpy.ndarray` - The batch of faces as fp32 in 0.0 to 1.0 range + The loaded FAN model """ - return faces.astype("float32") / 255. - - def process_input(self, batch: BatchType) -> None: - """ Compile the detected faces for prediction + weights = GetModel("face-alignment-network_2d4_v4.pth", 13).model_path + assert isinstance(weights, str) + model = T.cast(FaceAlignmentNetwork, + self.load_torch_model(FaceAlignmentNetwork(num_stack=4, + num_modules=1, + hg_depth=4, + num_features=256, + num_classes=68), + weights, + return_indices=[-1])) + return model + + def pre_process(self, batch: np.ndarray) -> np.ndarray: + """Format the ROI faces detection boxes for prediction Parameters ---------- - batch: :class:`AlignerBatch` - The current batch to process input for + batch + The batch of face detection bounding boxes as (bs, l, t, r, b) + + Returns + ------- + The face detection bounding boxes formatted to take an image patch for prediction """ - assert isinstance(batch, AlignerBatch) - logger.trace("Aligning faces around center") # type:ignore[attr-defined] - center_scale = self.get_center_scale(batch.detected_faces) - batch.feed = np.array(self.crop(batch, center_scale))[..., :3] - batch.data.append({"center_scale": center_scale}) - logger.trace("Aligned image around center") # type:ignore[attr-defined] + heights = batch[:, 3] - batch[:, 1] + widths = batch[:, 2] - batch[:, 0] + ctr_x = np.rint((batch[:, 0] + batch[:, 2]) * 0.5).astype("int32") + # This y-shift only really makes sense for derived bounding boxes, so removed: + # ctr_y = np.rint((batch[:, 1] + batch[:, 3]) * 0.5 - heights * 0.12).astype("int32") + ctr_y = np.rint((batch[:, 1] + batch[:, 3]) * 0.5).astype("int32") + size = (widths + heights) * self._reference_scale + half = np.rint(size * 0.5).astype("int32") + # Original implementation is (1, 1) top left, not (0, 0) + tl_offset = np.rint(size / self.input_size).astype("int32") + + retval = np.empty((batch.shape[0], 4), dtype=np.int32) + retval[:, 0] = ctr_x - half + tl_offset + retval[:, 1] = ctr_y - half + tl_offset + retval[:, 2] = ctr_x + half + retval[:, 3] = ctr_y + half + return retval - def get_center_scale(self, detected_faces: list[DetectedFace]) -> np.ndarray: - """ Get the center and set scale of bounding box + def process(self, batch: np.ndarray) -> np.ndarray: + """Predict the 68 point landmarks Parameters ---------- - detected_faces: list - List of :class:`~lib.align.DetectedFace` objects for the batch + batch + The batch to feed into the aligner Returns ------- - :class:`numpy.ndarray` - The center and scale of the bounding box + The predictions from the aligner """ - logger.trace("Calculating center and scale") # type:ignore[attr-defined] - center_scale = np.empty((len(detected_faces), 68, 3), dtype='float32') - for index, face in enumerate(detected_faces): - x_ctr = (T.cast(int, face.left) + face.right) / 2.0 - y_ctr = (T.cast(int, face.top) + face.bottom) / 2.0 - T.cast(int, face.height) * 0.12 - scale = (T.cast(int, face.width) + T.cast(int, face.height)) * self.reference_scale - center_scale[index, :, 0] = np.full(68, x_ctr, dtype='float32') - center_scale[index, :, 1] = np.full(68, y_ctr, dtype='float32') - center_scale[index, :, 2] = np.full(68, scale, dtype='float32') - logger.trace("Calculated center and scale: %s", center_scale) # type:ignore[attr-defined] - return center_scale - - def _crop_image(self, - image: np.ndarray, - top_left: np.ndarray, - bottom_right: np.ndarray) -> np.ndarray: - """ Crop a single image + return self.from_torch(batch.transpose(0, 3, 1, 2)) + + def post_process(self, batch: np.ndarray) -> np.ndarray: # pylint:disable=too-many-locals + """Process the output from the model Parameters ---------- - image: :class:`numpy.ndarray` - The image to crop - top_left: :class:`numpy.ndarray` - The top left (x, y) point to crop from - bottom_right: :class:`numpy.ndarray` - The bottom right (x, y) point to crop to + batch + The predictions from the aligner Returns ------- - :class:`numpy.ndarray` - The cropped image + The final landmarks in 0-1 space """ - bottom_right_width, bottom_right_height = bottom_right[0].astype('int32') - top_left_width, top_left_height = top_left[0].astype('int32') - new_dim = (bottom_right_height - top_left_height, - bottom_right_width - top_left_width, - 3 if image.ndim > 2 else 1) - new_img = np.zeros(new_dim, dtype=np.uint8) - - new_x = slice(max(0, -top_left_width), - min(bottom_right_width, image.shape[1]) - top_left_width) - new_y = slice(max(0, -top_left_height), - min(bottom_right_height, image.shape[0]) - top_left_height) - old_x = slice(max(0, top_left_width), min(bottom_right_width, image.shape[1])) - old_y = slice(max(0, top_left_height), min(bottom_right_height, image.shape[0])) - new_img[new_y, new_x] = image[old_y, old_x] - - interp = cv2.INTER_CUBIC if new_dim[0] < self.input_size else cv2.INTER_AREA - return cv2.resize(new_img, - dsize=(self.input_size, self.input_size), - interpolation=interp) - - def crop(self, batch: AlignerBatch, center_scale: np.ndarray) -> list[np.ndarray]: - """ Crop image around the center point + if self._dark is not None: + return self._dark(batch) / 64. + num_images, num_landmarks, height, width = batch.shape + assert height == width, "Heatmaps must be square" + resolution = height + + image_slice = np.arange(num_images)[:, None] + landmark_slice = np.arange(num_landmarks)[None, :] + + subpixel_landmarks = np.ones((num_images, num_landmarks, 2), dtype='float32') + + indices = np.array(np.unravel_index(batch.reshape(num_images, + num_landmarks, + -1).argmax(-1), + (batch.shape[2], # height + batch.shape[3]))) # width + min_clipped = np.minimum(indices + 1, batch.shape[2] - 1) + max_clipped = np.maximum(indices - 1, 0) + + offsets = [(image_slice, landmark_slice, indices[0], min_clipped[1]), # Right + (image_slice, landmark_slice, indices[0], max_clipped[1]), # Left + (image_slice, landmark_slice, min_clipped[0], indices[1]), # Down + (image_slice, landmark_slice, max_clipped[0], indices[1])] # Up + right, left = batch[offsets[0]], batch[offsets[1]] + down, up = batch[offsets[2]], batch[offsets[3]] + epsilon = 1e-6 # Small epsilon to avoid zero div + x_delta = np.clip((right - left) / (right + left + epsilon), -0.5, 0.5) + y_delta = np.clip((down - up) / (down + up + epsilon), -0.5, 0.5) + + subpixel_landmarks[..., 0] = indices[1] + x_delta + 0.5 + subpixel_landmarks[..., 1] = indices[0] + y_delta + 0.5 + subpixel_landmarks /= resolution + return subpixel_landmarks + + +class ConvBlock(nn.Module): + """Convolution block for FAN + + Parameters + ---------- + num_in + The number of in channels + num_out + The number of out channels + """ + def __init__(self, num_in: int, num_out: int) -> None: + super().__init__() + self.bn1 = nn.BatchNorm2d(num_in) + self.conv1 = nn.Conv2d(num_in, num_out // 2, 3, stride=1, padding=1, bias=False) + self.bn2 = nn.BatchNorm2d(num_out // 2) + self.conv2 = nn.Conv2d(num_out // 2, num_out // 4, 3, stride=1, padding=1, bias=False) + self.bn3 = nn.BatchNorm2d(num_out // 4) + self.conv3 = nn.Conv2d(num_out // 4, num_out // 4, 3, stride=1, padding=1, bias=False) + self.downsample = None + if num_in != num_out: + self.downsample = nn.Sequential(nn.BatchNorm2d(num_in), + nn.ReLU(inplace=True), + nn.Conv2d(num_in, num_out, 1, stride=1, bias=False)) + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + """Forward pass through FAN's conv block Parameters ---------- - batch: :class:`AlignerBatch` - The current batch to crop the image for - center_scale: :class:`numpy.ndarray` - The center and scale for the bounding box + inputs + Input to the conv block Returns ------- - list - List of cropped images for the batch + Output from the conv block """ - logger.trace("Cropping images") # type:ignore[attr-defined] - batch_shape = center_scale.shape[:2] - resolutions = np.full(batch_shape, self.input_size, dtype='float32') - matrix_ones = np.ones(batch_shape + (3,), dtype='float32') - matrix_size = np.full(batch_shape + (3,), self.input_size, dtype='float32') - matrix_size[..., 2] = 1.0 - upper_left = self.transform(matrix_ones, center_scale, resolutions) - bot_right = self.transform(matrix_size, center_scale, resolutions) - - # TODO second pass .. convert to matrix - new_images = [self._crop_image(image, top_left, bottom_right) - for image, top_left, bottom_right in zip(batch.image, upper_left, bot_right)] - logger.trace("Cropped images") # type:ignore[attr-defined] - return new_images - - @classmethod - def transform(cls, - points: np.ndarray, - center_scales: np.ndarray, - resolutions: np.ndarray) -> np.ndarray: - """ Transform Image + residual = inputs if self.downsample is None else self.downsample(inputs) + var_x = self.conv1(F.relu(self.bn1(inputs), inplace=True)) + var_y = self.conv2(F.relu(self.bn2(var_x), inplace=True)) + var_z = self.conv3(F.relu(self.bn3(var_y), inplace=True)) + out = torch.cat((var_x, var_y, var_z), dim=1) + residual + return out + + +class HourGlass(nn.Module): + """Hour-glass module for FAN + + Parameters + ---------- + num_modules + The number of modules in the hour-glass network + depth + The depth of the hour-glass network + num_features + The number of features to generate + """ + def __init__(self, num_modules: int, depth: int, num_features: int) -> None: + super().__init__() + self._num_modules = num_modules + self._num_features = num_features + self._depth = depth + self._generate_network(depth) + + def _generate_network(self, level: int) -> None: + """Recursively generate the hour-glass network Parameters ---------- - points: :class:`numpy.ndarray` - The points to transform - center_scales: :class:`numpy.ndarray` - The calculated centers and scales for the batch - resolutions: :class:`numpy.ndarray` - The resolutions + level + The depth of the hour-glass network """ - logger.trace("Transforming Points") # type:ignore[attr-defined] - num_images, num_landmarks = points.shape[:2] - transform_matrix = np.eye(3, dtype='float32') - transform_matrix = np.repeat(transform_matrix[None, :], num_landmarks, axis=0) - transform_matrix = np.repeat(transform_matrix[None, :, :], num_images, axis=0) - scales = center_scales[:, :, 2] / resolutions - translations = center_scales[..., 2:3] * -0.5 + center_scales[..., :2] - transform_matrix[:, :, 0, 0] = scales # x scale - transform_matrix[:, :, 1, 1] = scales # y scale - transform_matrix[:, :, 0, 2] = translations[:, :, 0] # x translation - transform_matrix[:, :, 1, 2] = translations[:, :, 1] # y translation - new_points = np.einsum('abij, abj -> abi', transform_matrix, points, optimize='greedy') - retval = new_points[:, :, :2].astype('float32') - logger.trace("Transformed Points: %s", retval) # type:ignore[attr-defined] - return retval + for i in range(self._num_modules): + self.add_module(f"b1_{level}_{i}", ConvBlock(self._num_features, self._num_features)) + for i in range(self._num_modules): + self.add_module(f"b2_{level}_{i}", ConvBlock(self._num_features, self._num_features)) + + if level > 1: + self._generate_network(level - 1) + else: + for i in range(self._num_modules): + self.add_module(f"b2_plus_{level}_{i}", + ConvBlock(self._num_features, self._num_features)) + + for i in range(self._num_modules): + self.add_module(f"b3_{level}_{i}", ConvBlock(self._num_features, self._num_features)) - def predict(self, feed: np.ndarray) -> np.ndarray: - """ Predict the 68 point landmarks + def _forward(self, level: int, inputs: torch.Tensor) -> torch.Tensor: + """Forward pass through FAN's hour-glass network Parameters ---------- - batch: :class:`numpy.ndarray` - The batch to feed into the aligner + inputs + Input to the hour-glass network Returns ------- - :class:`numpy.ndarray` - The predictions from the aligner + Output from the hour-glass network """ - logger.trace("Predicting Landmarks") # type:ignore[attr-defined] - retval = self.model.predict(feed, - verbose=False, - batch_size=self.batchsize)[-1].transpose(0, 3, 1, 2) - return retval + up1 = inputs + for i in range(self._num_modules): + up1 = getattr(self, f"b1_{level}_{i}")(up1) - def process_output(self, batch: BatchType) -> None: - """ Process the output from the model + lo1 = F.avg_pool2d(inputs, 2, stride=2) # pylint:disable=not-callable + for i in range(self._num_modules): + lo1 = getattr(self, f"b2_{level}_{i}")(lo1) + + if level > 1: + lo2 = self._forward(level - 1, lo1) + else: + lo2 = lo1 + for i in range(self._num_modules): + lo2 = getattr(self, f"b2_plus_{level}_{i}")(lo2) + + lo3 = lo2 + for i in range(self._num_modules): + lo3 = getattr(self, f"b3_{level}_{i}")(lo3) + + up2 = F.interpolate(lo3, scale_factor=2, mode="nearest") + return up1 + up2 + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + """Forward pass through FAN's hour-glass network Parameters ---------- - batch: :class:`AlignerBatch` - The current batch from the model with :attr:`predictions` populated - """ - assert isinstance(batch, AlignerBatch) - self.get_pts_from_predict(batch) + inputs + Input to the hour-glass network - def get_pts_from_predict(self, batch: AlignerBatch) -> None: - """ Get points from predictor and populate the :attr:`landmarks` property of the - :class:`AlignerBatch` + Returns + ------- + Output from the hour-glass network + """ + return self._forward(self._depth, inputs) + + +class FaceAlignmentNetwork(nn.Module): + """2D FAN alignment for faceswap""" + def __init__(self, + num_stack: int = 4, + num_modules: int = 1, + hg_depth: int = 4, + num_features: int = 256, + num_classes: int = 68) -> None: + super().__init__() + self._num_stacks = num_stack + self._num_modules = num_modules + + self.conv1 = nn.Conv2d(3, 64, 7, stride=2, padding=3) + self.bn1 = nn.BatchNorm2d(64) + self.conv2 = ConvBlock(64, 128) + self.conv3 = ConvBlock(128, 128) + self.conv4 = ConvBlock(128, num_features) + + for i in range(self._num_stacks): + self.add_module(f"m{i}", HourGlass(num_modules, hg_depth, num_features)) + for j in range(num_modules): + # backwards labelled in original impl: + self.add_module(f"top_m{j}_{i}", ConvBlock(num_features, num_features)) + self.add_module(f"conv_last{i}", nn.Conv2d(num_features, num_features, 1)) + self.add_module(f"bn_end{i}", nn.BatchNorm2d(num_features)) + self.add_module(f"l{i}", nn.Conv2d(num_features, num_classes, 1)) + if i == self._num_stacks - 1: + continue + self.add_module(f"bl{i}", nn.Conv2d(num_features, num_features, 1)) + self.add_module(f"al{i}", nn.Conv2d(num_classes, num_features, 1)) + + def forward(self, inputs: torch.Tensor) -> list[torch.Tensor]: + """Forward pass through FAN face alignment Parameters ---------- - batch: :class:`AlignerBatch` - The current batch from the model with :attr:`predictions` populated + inputs + Input to FAN + + Returns + ------- + Output from FAN """ - logger.trace("Obtain points from prediction") # type:ignore[attr-defined] - num_images, num_landmarks = batch.prediction.shape[:2] - image_slice = np.repeat(np.arange(num_images)[:, None], num_landmarks, axis=1) - landmark_slice = np.repeat(np.arange(num_landmarks)[None, :], num_images, axis=0) - resolution = np.full((num_images, num_landmarks), 64, dtype='int32') - subpixel_landmarks = np.ones((num_images, num_landmarks, 3), dtype='float32') - - indices = np.array(np.unravel_index(batch.prediction.reshape(num_images, - num_landmarks, - -1).argmax(-1), - (batch.prediction.shape[2], # height - batch.prediction.shape[3]))) # width - min_clipped = np.minimum(indices + 1, batch.prediction.shape[2] - 1) - max_clipped = np.maximum(indices - 1, 0) - offsets = [(image_slice, landmark_slice, indices[0], min_clipped[1]), - (image_slice, landmark_slice, indices[0], max_clipped[1]), - (image_slice, landmark_slice, min_clipped[0], indices[1]), - (image_slice, landmark_slice, max_clipped[0], indices[1])] - x_subpixel_shift = batch.prediction[offsets[0]] - batch.prediction[offsets[1]] - y_subpixel_shift = batch.prediction[offsets[2]] - batch.prediction[offsets[3]] - # TODO improve rudimentary sub-pixel logic to centroid of 3x3 window algorithm - subpixel_landmarks[:, :, 0] = indices[1] + np.sign(x_subpixel_shift) * 0.25 + 0.5 - subpixel_landmarks[:, :, 1] = indices[0] + np.sign(y_subpixel_shift) * 0.25 + 0.5 - - if batch.second_pass: # Transformation handled by plugin parent for re-aligned faces - batch.landmarks = subpixel_landmarks[..., :2] * 4. - else: - batch.landmarks = self.transform(subpixel_landmarks, - batch.data[0]["center_scale"], - resolution) - logger.trace("Obtained points from prediction: %s", # type:ignore[attr-defined] - batch.landmarks) + var_x = F.relu(self.bn1(self.conv1(inputs)), inplace=True) + var_x = F.avg_pool2d(self.conv2(var_x), 2, stride=2) # pylint:disable=not-callable + var_x = self.conv4(self.conv3(var_x)) + + out = [] + inter = var_x + + for i in range(self._num_stacks): + hg = getattr(self, f"m{i}")(inter) + ll = hg + for j in range(self._num_modules): + ll = getattr(self, f"top_m{j}_{i}")(ll) + + ll = F.relu(getattr(self, f"bn_end{i}")(getattr(self, f"conv_last{i}")(ll)), + inplace=True) + + out.append(getattr(self, f"l{i}")(ll)) + + if i == self._num_stacks - 1: + continue + ll = getattr(self, f"bl{i}")(ll) + inter = inter + ll + getattr(self, f"al{i}")(out[-1]) + + return out __all__ = get_module_objects(__name__) diff --git a/plugins/extract/align/fan_defaults.py b/plugins/extract/align/fan_defaults.py index 31072d64c8..dcf1800376 100644 --- a/plugins/extract/align/fan_defaults.py +++ b/plugins/extract/align/fan_defaults.py @@ -37,12 +37,16 @@ batch_size = ConfigItem( datatype=int, - default=12, + default=16, group="settings", info="The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered." - "\n\tAMD users: A batchsize of 8 requires about 4 GB vram.", + "but setting it too high can harm performance.", rounding=1, - min_max=(1, 64)) + min_max=(1, 256)) + +dark_decoder = ConfigItem( + datatype=bool, + default=True, + group="settings", + info=("Use DARK decoder. A more refined method for obtaining landmarks from generated " + "heatmaps. (Ref: https://arxiv.org/abs/1910.06278).")) diff --git a/plugins/extract/align/hrnet.py b/plugins/extract/align/hrnet.py new file mode 100644 index 0000000000..548038042b --- /dev/null +++ b/plugins/extract/align/hrnet.py @@ -0,0 +1,795 @@ +#!/usr/bin/env python3 +"""Facial landmarks extractor for faceswap.pnt_y + Code adapted and modified from: + https://github.com/1adrianb/face-alignment +""" +from __future__ import annotations +import logging +import typing as T +from dataclasses import dataclass + +import numpy as np + +import torch +from torch import nn +from torch.nn import functional as F + +from lib.utils import get_module_objects, GetModel +from plugins.extract.base import ExtractPlugin +from . import hrnet_defaults as cfg +from .dark_decoder import Dark + +if T.TYPE_CHECKING: + import numpy.typing as npt + + +logger = logging.getLogger(__name__) +# pylint:disable=duplicate-code + + +@dataclass +class HRNetStageConfig: + """Configuration settings for each stage of HRNet""" + num_modules: int + num_branches: int + num_blocks: list[int] + num_channels: list[int] + use_bottleneck: bool + + +class HRNet(ExtractPlugin): + """HRNet Face alignment""" + def __init__(self) -> None: + super().__init__(input_size=256, + batch_size=cfg.batch_size(), + is_rgb=True, + dtype="float32", + scale=(0, 1)) + self._stage_2_config = HRNetStageConfig(num_modules=1, + num_branches=2, + num_blocks=[4, 4], + num_channels=[18, 36], + use_bottleneck=False) + self._stage_3_config = HRNetStageConfig(num_modules=4, + num_branches=3, + num_blocks=[4, 4, 4], + num_channels=[18, 36, 72], + use_bottleneck=False) + self._stage_4_config = HRNetStageConfig(num_modules=3, + num_branches=4, + num_blocks=[4, 4, 4, 4], + num_channels=[18, 36, 72, 144], + use_bottleneck=False) + + self.model: HighResolutionNet + self.realign_centering = "legacy" + + self._mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) + self._std = np.array([0.229, 0.224, 0.225], dtype=np.float32) + self._dark = Dark(68, 64) if cfg.dark_decoder() else None + + def load_model(self) -> HighResolutionNet: + """Load the HRNet model + + Returns + ------- + The loaded HRNet model + """ + weights = GetModel("hrnet_landmark_v1.pth", 34).model_path + assert isinstance(weights, str) + model = T.cast(HighResolutionNet, self.load_torch_model( + HighResolutionNet(num_joints=68, + final_conv_kernel=1, + stage_2_config=self._stage_2_config, + stage_3_config=self._stage_3_config, + stage_4_config=self._stage_4_config), + weights)) + return model + + def pre_process(self, batch: np.ndarray) -> np.ndarray: + """Format the ROI faces detection boxes for prediction + + Parameters + ---------- + batch + The batch of face detection bounding boxes as (bs, l, t, r, b) + + Returns + ------- + The face detection bounding boxes formatted to take an image patch for prediction + """ + heights = batch[:, 3] - batch[:, 1] + widths = batch[:, 2] - batch[:, 0] + ctr_x = np.rint((batch[:, 0] + batch[:, 2]) * 0.5).astype("int32") + ctr_y = np.rint((batch[:, 1] + batch[:, 3]) * 0.5).astype("int32") + size = np.maximum(widths, heights) + half = np.rint(size * 0.5).astype("int32") + retval = np.empty((batch.shape[0], 4), dtype=np.int32) + retval[:, 0] = ctr_x - half + retval[:, 1] = ctr_y - half + retval[:, 2] = ctr_x + half + retval[:, 3] = ctr_y + half + return retval + + def process(self, batch: np.ndarray) -> np.ndarray: + """Predict the 68 point landmarks + + Parameters + ---------- + batch + The batch to feed into the aligner + + Returns + ------- + The predictions from the aligner + """ + batch -= self._mean + batch /= self._std + return self.from_torch(batch.transpose(0, 3, 1, 2)) + + def _get_predictions(self, scores: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: + """Convert the score maps from the model into predictions + + Parameters + ---------- + scores + The score maps from the model + + Returns + ------- + Predictions from the score maps + """ + batch_size, num_points, size = scores.shape[:3] + scores_r = scores.reshape((batch_size, num_points, -1)) + + max_val: npt.NDArray[np.float32] = scores_r.max(axis=2) + idx: npt.NDArray[np.int64] = scores_r.argmax(axis=2) + + retval = np.empty((batch_size, num_points, 2), dtype=np.float32) + retval[:, :, 0] = (idx - 1) % size + 1 + retval[:, :, 1] = np.floor((idx - 1) / size) + 1 + + mask = max_val[..., None] > 0. + retval *= mask + return retval + + def post_process(self, batch: np.ndarray) -> np.ndarray: # pylint:disable=too-many-locals + """Process the output from the model + + Parameters + ---------- + batch + The predictions from the aligner + + Returns + ------- + The final landmarks in 0-1 space + """ + if self._dark is not None: + return self._dark(batch) / 64. + batch_size, num_points, height, width = batch.shape + assert height == width, "Heatmaps must be square" + resolution = height + + coords = self._get_predictions(batch) + pnt_x = coords[..., 0].astype(np.int32) + pnt_y = coords[..., 1].astype(np.int32) + mask = (pnt_x > 1) & (pnt_x < resolution) & (pnt_y > 1) & (pnt_y < resolution) + pnt_x = np.clip(pnt_x, 2, resolution - 1) + pnt_y = np.clip(pnt_x, 2, resolution - 1) + idx_batch = np.arange(batch_size)[:, None] + idx_pnt = np.arange(num_points)[None, :] + delta_x = (batch[idx_batch, idx_pnt, pnt_y - 1, pnt_x] - + batch[idx_batch, idx_pnt, pnt_y - 1, pnt_x - 2]) + delta_y = (batch[idx_batch, idx_pnt, pnt_y, pnt_x - 1] - + batch[idx_batch, idx_pnt, pnt_y - 2, pnt_x - 1]) + diff = np.stack([delta_x, delta_y], axis=-1) + coords += (np.sign(diff) * 0.25 * mask[..., None]) + 0.5 + coords /= resolution + return coords + + +class BasicBlock(nn.Module): + """ Basic block for HRNet + + Parameters + ---------- + in_channels + The number of in channels + out_channels + The number of out channels + stride + The stride for the first 3x3 conv block. Default: 1 + downsample + The module to use for downsampling or ``None`` for no downsample. Default: ``None`` + """ + expansion = 1 + + def __init__(self, + in_channels: int, + out_channels: int, + stride: int = 1, + downsample: nn.Module | None = None) -> None: + super().__init__() + self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride=stride, padding=1, bias=False) + self.bn1 = nn.BatchNorm2d(out_channels, momentum=0.01) + self.relu = nn.ReLU(inplace=True) + self.conv2 = nn.Conv2d(in_channels, out_channels, 3, stride=1, padding=1, bias=False) + self.bn2 = nn.BatchNorm2d(out_channels, momentum=0.01) + self.downsample = downsample + self.stride = stride + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + """Forward pass through HRNet's basic block + + Parameters + ---------- + inputs + Input to the conv block + + Returns + ------- + Output from the conv block + """ + residual = inputs + + out = self.conv1(inputs) + out = self.bn1(out) + out = self.relu(out) + + out = self.conv2(out) + out = self.bn2(out) + + if self.downsample is not None: + residual = self.downsample(inputs) + + out += residual + out = self.relu(out) + + return out + + +class Bottleneck(nn.Module): + """ Bottleneck for HRNet + + Parameters + ---------- + in_channels + The number of in channels + out_channels + The number of out channels + stride + The stride for the first 3x3 conv block. Default: 1 + downsample + The module to use for downsampling or ``None`` for no downsample. Default: ``None`` + """ + expansion = 4 + + def __init__(self, + in_channels: int, + out_channels: int, + stride: int = 1, + downsample: nn.Module | None = None) -> None: + super().__init__() + self.conv1 = nn.Conv2d(in_channels, out_channels, 1, bias=False) + self.bn1 = nn.BatchNorm2d(out_channels, momentum=0.01) + self.conv2 = nn.Conv2d(out_channels, out_channels, 3, stride=stride, padding=1, bias=False) + self.bn2 = nn.BatchNorm2d(out_channels, momentum=0.01) + self.conv3 = nn.Conv2d(out_channels, out_channels * self.expansion, 1, bias=False) + self.bn3 = nn.BatchNorm2d(out_channels * self.expansion, momentum=0.01) + self.relu = nn.ReLU(inplace=True) + self.downsample = downsample + self.stride = stride + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + """Forward pass through HRNet's basic block + + Parameters + ---------- + inputs + Input to the conv block + + Returns + ------- + Output from the conv block + """ + residual = inputs + + out = self.conv1(inputs) + out = self.bn1(out) + out = self.relu(out) + + out = self.conv2(out) + out = self.bn2(out) + out = self.relu(out) + + out = self.conv3(out) + out = self.bn3(out) + + if self.downsample is not None: + residual = self.downsample(inputs) + + out += residual + out = self.relu(out) + + return out + + +class HighResolutionModule(nn.Module): + """ High Resolution Module for HRNet + + Parameters + ---------- + num_branches + The number of branches to use + blocks + The block object to use + num_blocks + The number of blocks in each branch + num_in_channels + The number of input channels in each branch + num_channels + The number of channels in each branch + multi_scale_output + ``True`` to output multi-scaled + """ + def __init__(self, + num_branches: int, + block: type[Bottleneck] | type[BasicBlock], + num_blocks: list[int], + num_in_channels: list[int], + num_channels: list[int], + multi_scale_output: bool = True) -> None: + super().__init__() + self._check_branches(num_branches, + num_blocks, + num_in_channels, + num_channels) + + self.num_in_channels = num_in_channels + self.num_branches = num_branches + self.multi_scale_output = multi_scale_output + self.branches = nn.ModuleList(self._make_one_branch(i, + block, + num_blocks[i], + num_channels[i]) + for i in range(num_branches)) + + self.fuse_layers = self._make_fuse_layers() + self.relu = nn.ReLU(inplace=True) + + def _check_branches(self, + num_branches: int, + num_blocks: list[int], + num_in_channels: list[int], + num_channels: list[int]) -> None: + """Check that the branch configuration is valid + + Parameters + ---------- + num_branches + The number of branches to use + num_blocks + The number of blocks in each branch + num_in_channels + The number of input channels in each branch + num_channels + The number of channels in each branch + + Raises + ------ + ValueError + On an invalid configuration + """ + if num_branches != len(num_blocks): + raise ValueError(f"NUM_BRANCHES({num_branches}) <> NUM_BLOCKS({len(num_blocks)})") + if num_branches != len(num_channels): + raise ValueError(f"NUM_BRANCHES({num_branches}) <> NUM_CHANNELS({len(num_channels)})") + if num_branches != len(num_in_channels): + raise ValueError(f"NUM_BRANCHES({num_branches}) <> " + f"NUM_IN_CHANNELS({len(num_in_channels)})") + + def _make_one_branch(self, + branch_index: int, + block: type[Bottleneck] | type[BasicBlock], + num_blocks: int, + num_channels: int, + stride: int = 1) -> nn.Sequential: + """ Make a single branch + + Parameters + ---------- + branch_index + The index of the branch to make + block + The block object to use + num_blocks + The number of blocks in each branch + num_in_channels + The number of input channels in each branch + num_channels + The number of channels in each branch + multi_scale_output + ``True`` to output multi-scaled + + Returns + ------- + The sequential modules for the branch + """ + downsample = None + if stride != 1 or self.num_in_channels[branch_index] != num_channels * block.expansion: + downsample = nn.Sequential(nn.Conv2d(self.num_in_channels[branch_index], + num_channels * block.expansion, + 1, + stride=stride, + bias=False), + nn.BatchNorm2d(num_channels * block.expansion, + momentum=0.01)) + + layers = [] + layers.append(block(self.num_in_channels[branch_index], + num_channels, + stride, + downsample=downsample)) + self.num_in_channels[branch_index] = num_channels * block.expansion + for _ in range(1, num_blocks): + layers.append(block(self.num_in_channels[branch_index], num_channels)) + + return nn.Sequential(*layers) + + def _make_fuse_layers(self) -> nn.ModuleList | None: + """Make the fuse layers for the HR Module + + Returns + ------- + The fuse layers module list or ``None`` if layers are not to be fused + """ + if self.num_branches == 1: + return None + + num_branches = self.num_branches + num_in_channels = self.num_in_channels + fuse_layers = [] + for i in range(num_branches if self.multi_scale_output else 1): + fuse_layer = [] + for j in range(num_branches): + if j > i: + fuse_layer.append(nn.Sequential(nn.Conv2d(num_in_channels[j], + num_in_channels[i], + 1, + stride=1, + padding=0, + bias=False), + nn.BatchNorm2d(num_in_channels[i], + momentum=0.01))) + elif j == i: + fuse_layer.append(None) # type:ignore[arg-type] + else: + conv3x3s = [] + for k in range(i - j): + if k == i - j - 1: + num_out_channels_conv3x3 = num_in_channels[i] + conv3x3s.append(nn.Sequential(nn.Conv2d(num_in_channels[j], + num_out_channels_conv3x3, + 3, + stride=2, + padding=1, + bias=False), + nn.BatchNorm2d(num_out_channels_conv3x3, + momentum=0.01))) + else: + num_out_channels_conv3x3 = num_in_channels[j] + conv3x3s.append(nn.Sequential(nn.Conv2d(num_in_channels[j], + num_out_channels_conv3x3, + 3, + stride=2, + padding=1, + bias=False), + nn.BatchNorm2d(num_out_channels_conv3x3, + momentum=0.01), + nn.ReLU(inplace=True))) + fuse_layer.append(nn.Sequential(*conv3x3s)) + fuse_layers.append(nn.ModuleList(fuse_layer)) + + return nn.ModuleList(fuse_layers) + + def get_num_in_channels(self) -> list[int]: + """Obtain the number of input channels to the module + + Returns + ------- + The number of input channels to the module + """ + return self.num_in_channels + + def forward(self, inputs: list[torch.Tensor]) -> list[torch.Tensor]: + """Forward pass through the HR Module + + Parameters + ---------- + inputs + Input to the HR Module + + Returns + ------- + Output from the HR Module + """ + x = inputs + if self.num_branches == 1: + return [self.branches[0](x[0])] + assert self.fuse_layers is not None + + for i in range(self.num_branches): + x[i] = self.branches[i](x[i]) + + x_fuse = [] + for i, fuse_layer in enumerate(T.cast(list[nn.ModuleList], self.fuse_layers)): + y = x[0] if i == 0 else fuse_layer[0](x[0]) + for j in range(1, self.num_branches): + if i == j: + y = y + x[j] + elif j > i: + y = y + F.interpolate(fuse_layer[j](x[j]), + size=[x[i].shape[2], x[i].shape[3]], + mode="bilinear") + else: + y = y + fuse_layer[j](x[j]) + x_fuse.append(self.relu(y)) + return x_fuse + + +class HighResolutionNet(nn.Module): # pylint:disable=too-many-instance-attributes + """The HRNet Landmark Detection model + + Parameters + ---------- + num_joints + The number of joints in the model + final_conv_kernel + Kernel size of the final convolution + stage_2_config + Configuration settings for stage 2 layers + stage_3_config + Configuration settings for stage 3 layers + stage_4_config + Configuration settings for stage 4 layers + """ + def __init__(self, + num_joints: int, + final_conv_kernel: int, + stage_2_config: HRNetStageConfig, + stage_3_config: HRNetStageConfig, + stage_4_config: HRNetStageConfig) -> None: + self.in_channels = 64 + super().__init__() + self.stage_2_config = stage_2_config + self.stage_3_config = stage_3_config + self.stage_4_config = stage_4_config + + # stem net + self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=2, padding=1, + bias=False) + self.bn1 = nn.BatchNorm2d(64, momentum=0.01) + self.conv2 = nn.Conv2d(64, 64, kernel_size=3, stride=2, padding=1, + bias=False) + self.bn2 = nn.BatchNorm2d(64, momentum=0.01) + self.relu = nn.ReLU(inplace=True) + self.sf = nn.Softmax(dim=1) + self.layer1 = self._make_layer(Bottleneck, 64, 64, 4) + + num_channels = stage_2_config.num_channels + block = Bottleneck if stage_2_config.use_bottleneck else BasicBlock + num_channels = [num_channels[i] * block.expansion for i in range(len(num_channels))] + self.transition1 = self._make_transition_layer([256], num_channels) + self.stage2, pre_stage_channels = self._make_stage(stage_2_config, num_channels) + + num_channels = stage_3_config.num_channels + block = Bottleneck if stage_3_config.use_bottleneck else BasicBlock + num_channels = [num_channels[i] * block.expansion for i in range(len(num_channels))] + self.transition2 = self._make_transition_layer(pre_stage_channels, num_channels) + self.stage3, pre_stage_channels = self._make_stage(stage_3_config, num_channels) + + num_channels = stage_4_config.num_channels + block = Bottleneck if stage_4_config.use_bottleneck else BasicBlock + num_channels = [num_channels[i] * block.expansion for i in range(len(num_channels))] + self.transition3 = self._make_transition_layer(pre_stage_channels, num_channels) + self.stage4, pre_stage_channels = self._make_stage(stage_4_config, + num_channels, + multi_scale_output=True) + + final_inp_channels = sum(pre_stage_channels) + + self.head = nn.Sequential(nn.Conv2d(final_inp_channels, + final_inp_channels, + 1, + stride=1, + padding=1 if final_conv_kernel == 3 else 0), + nn.BatchNorm2d(final_inp_channels, momentum=0.01), + nn.ReLU(inplace=True), + nn.Conv2d(final_inp_channels, + num_joints, + final_conv_kernel, + stride=1, + padding=1 if final_conv_kernel == 3 else 0)) + + def _make_transition_layer(self, + num_channels_pre_layer: list[int], + num_channels_cur_layer: list[int]) -> nn.ModuleList: + """Make an HRNet transition layer + + Parameters + ---------- + num_channels_pre_layer + The number of channels from the previous layer + num_channels_cur_layer + The number of channels from the current layer + + Returns + ------- + The transition layer module list + """ + num_branches_cur = len(num_channels_cur_layer) + num_branches_pre = len(num_channels_pre_layer) + + transition_layers = [] + for i in range(num_branches_cur): + if i < num_branches_pre: + if num_channels_cur_layer[i] != num_channels_pre_layer[i]: + transition_layers.append(nn.Sequential( + nn.Conv2d(num_channels_pre_layer[i], + num_channels_cur_layer[i], + 3, + stride=1, + padding=1, + bias=False), + nn.BatchNorm2d(num_channels_cur_layer[i], momentum=0.01), + nn.ReLU(inplace=True))) + else: + transition_layers.append(None) # type:ignore[arg-type] + continue + conv3x3s = [] + for j in range(i + 1 - num_branches_pre): + in_channels = num_channels_pre_layer[-1] + out_channels = (num_channels_cur_layer[i] if j == i - num_branches_pre + else in_channels) + conv3x3s.append(nn.Sequential( + nn.Conv2d(in_channels, out_channels, 3, stride=2, padding=1, bias=False), + nn.BatchNorm2d(out_channels, momentum=0.01), + nn.ReLU(inplace=True))) + transition_layers.append(nn.Sequential(*conv3x3s)) + + return nn.ModuleList(transition_layers) + + def _make_layer(self, + block: type[BasicBlock] | type[Bottleneck], + in_channels: int, + out_channels: int, + blocks: int, + stride: int = 1) -> nn.Sequential: + """Make an HRNet layer + + Parameters + ---------- + block + The type of block to use for the layer + in_channels + The number of input channels + out_channels + The number of output channels + blocks + The number of blocks + stride + The stride size. Default: 1 + + Returns + ------- + The sequential layer + """ + downsample = None + if stride != 1 or in_channels != out_channels * block.expansion: + downsample = nn.Sequential(nn.Conv2d(in_channels, + out_channels * block.expansion, + 1, + stride=stride, + bias=False), + nn.BatchNorm2d(out_channels * block.expansion, + momentum=0.01)) + + layers = [] + layers.append(block(in_channels, out_channels, stride, downsample)) + in_channels = out_channels * block.expansion + for _ in range(1, blocks): + layers.append(block(in_channels, out_channels)) + + return nn.Sequential(*layers) + + def _make_stage(self, + layer_config: HRNetStageConfig, + num_in_channels: list[int], + multi_scale_output: bool = True) -> tuple[nn.Sequential, list[int]]: + """Make a stage for HRNet + + Parameters + ---------- + layer_config + The configuration for the stage + num_in_channels + The input channels for the stage + multi_scale_output + ``True`` to output multi scale + + Returns + ------- + sequential + The stage Sequential Modules + num_in_channels + The number of input channels from the final module + """ + num_modules = layer_config.num_modules + num_branches = layer_config.num_branches + num_blocks = layer_config.num_blocks + num_channels = layer_config.num_channels + block = Bottleneck if layer_config.use_bottleneck else BasicBlock + + modules: list[HighResolutionModule] = [] + for i in range(num_modules): + if not multi_scale_output and i == num_modules - 1: + reset_multi_scale_output = False + else: + reset_multi_scale_output = True + modules.append(HighResolutionModule(num_branches, + block, + num_blocks, + num_in_channels, + num_channels, + reset_multi_scale_output)) + num_in_channels = modules[-1].get_num_in_channels() + + return nn.Sequential(*modules), num_in_channels + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + """Forward pass through HRNet + + Parameters + ---------- + inputs + Input to HRNet + + Returns + ------- + Output from HRNet + """ + x = self.conv1(inputs) + x = self.bn1(x) + x = self.relu(x) + x = self.conv2(x) + x = self.bn2(x) + x = self.relu(x) + x = self.layer1(x) + + x_list = [self.transition1[i](x) if self.transition1[i] is not None else x + for i in range(self.stage_2_config.num_branches)] + y_list = self.stage2(x_list) + + x_list = [self.transition2[i](y_list[-1]) if self.transition2[i] is not None + else y_list[i] + for i in range(self.stage_3_config.num_branches)] + y_list = self.stage3(x_list) + + x_list = [self.transition3[i](y_list[-1]) if self.transition3[i] is not None + else y_list[i] + for i in range(self.stage_4_config.num_branches)] + x = self.stage4(x_list) + + # Head Part + height, width = x[0].size(2), x[0].size(3) + x1 = F.interpolate(x[1], size=(height, width), mode="bilinear", align_corners=False) + x2 = F.interpolate(x[2], size=(height, width), mode="bilinear", align_corners=False) + x3 = F.interpolate(x[3], size=(height, width), mode="bilinear", align_corners=False) + x = torch.cat([x[0], x1, x2, x3], 1) + x = self.head(x) + + return x + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/align/hrnet_defaults.py b/plugins/extract/align/hrnet_defaults.py new file mode 100644 index 0000000000..38e3e2b381 --- /dev/null +++ b/plugins/extract/align/hrnet_defaults.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +""" The default options for the faceswap HRNet Alignments plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: + + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does + +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) + +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem +""" +# pylint:disable=duplicate-code +from lib.config import ConfigItem + + +HELPTEXT = ( + "HRNet Aligner options.\n" + "Trained on 128k heavily augmented faces with full 360 degree rotation. Fast on GPU, slow on " + "CPU." + ) + + +batch_size = ConfigItem( + datatype=int, + default=16, + group="settings", + info="The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.", + rounding=1, + min_max=(1, 256)) + +dark_decoder = ConfigItem( + datatype=bool, + default=True, + group="settings", + info=("Use DARK decoder. A more refined method for obtaining landmarks from generated " + "heatmaps. (Ref: https://arxiv.org/abs/1910.06278).")) diff --git a/plugins/extract/base.py b/plugins/extract/base.py new file mode 100644 index 0000000000..68f6f5e59b --- /dev/null +++ b/plugins/extract/base.py @@ -0,0 +1,416 @@ +#! /usr/env/bin/python3 +"""Interfaces for Faceswap extract plugins""" +from __future__ import annotations + +import abc +import logging +import typing as T +from operator import itemgetter + +import numpy as np +import numpy.typing as npt +import torch + +from lib.logger import parse_class_init +from lib.utils import get_module_objects + +if T.TYPE_CHECKING: + import cv2 + from lib.align.constants import CenteringType + + +logger = logging.getLogger(__name__) + + +class _TorchInfer(): + """Handles loading PyTorch models and handling data transfer for plugins that use PyTorch + + Parameters + ---------- + plugin_name + The name of the plugin using this object for interfacing with Torch + force_cpu + For Torch models, force running on the CPU, rather than the accelerated device. Sets the + :class:`torch.device` to :attr:`device`. Default: ``False`` + """ + def __init__(self, name: str, force_cpu: bool) -> None: + logger.debug(parse_class_init(locals())) + self._name = f"{self.__class__.__name__[1:]}.{name}" + self.device = self._get_device(cpu=force_cpu) + self._model: torch.nn.Module | None = None + self._first_batch_seen = False + self._output_is_list = False + self._output_length = 0 + self._return_indices: list[int] = [] + self._use_pinned = torch.cuda.is_available() and self.device.type == "cuda" + + def __repr__(self) -> str: + """Pretty print for logging""" + name = repr(self._name.rsplit(".", maxsplit=1)[-1]) + force_cpu = self.device.type == "cpu" + return f"{self.__class__.__name__}(name={name}, force_cpu={force_cpu})" + + def _get_device(self, cpu: bool = False) -> torch.device: + """Get the correctly configured device for running inference + + Parameters + ---------- + cpu + ``True`` to force running on the CPU. + + Returns + ------- + The device that torch should use + """ + if cpu: + logger.debug("[%s] CPU mode selected. Returning CPU device context", self._name) + return torch.device("cpu") + + if torch.cuda.is_available(): + logger.debug("[%s] Cuda available. Returning Cuda device context", self._name) + return torch.device("cuda") + + if torch.backends.mps.is_available(): + logger.debug("[%s] MPS available. Returning MPS device context", self._name) + return torch.device("mps") + + logger.debug("[%s] No backends available. Returning CPU device context", self._name) + return torch.device("cpu") + + def load_torch_model(self, + model: torch.nn.Module, + weights_path: str, + return_indices: list[int] | None) -> torch.nn.Module: + """Load a PyTorch model, apply the weights and pass a warmup batch through + + Parameters + ---------- + model + The Torch model to load + weights_path + Full path to the weights file to load + return_indices + If the model outputs multiple items, just copy and return these indices from the GPU. + ``None`` to return all data + + Returns + ------- + The loaded model ready for inference + """ + if return_indices is not None: + logger.debug("[%s] Setting return indices: %s", self._name, return_indices) + self._return_indices = return_indices + + weights = torch.load(weights_path, map_location=self.device) + model.load_state_dict(weights) + model.to(self.device, memory_format=torch.channels_last) # type:ignore[call-overload] + model.eval() + + self._model = model + logger.debug("[%s] Loaded model", self._name) + return model + + def _process_first_batch(self, batch: torch.Tensor | list[torch.Tensor]) -> None: + """Validate the first batch received from the model confirms with the given configuration + and set appropriate class attributes + + Parameters + ---------- + batch + The first batch received from the model. This should be the warmup batch + """ + if self._return_indices: + assert all(abs(x) < len(batch) for x in self._return_indices) + batch = itemgetter(*self._return_indices)(batch) + + if not isinstance(batch, torch.Tensor): + assert isinstance(batch, (list, tuple)) + logger.debug("[%s] Setting _output_is_list to True for %s (length: %s)", + self._name, type(batch), len(batch)) + self._output_is_list = True + self._output_length = len(batch) + + self._first_batch_seen = True + + def predict(self, batch: np.ndarray) -> np.ndarray: + """Run inference on a PyTorch model. + + Parameters + ---------- + batch + The batch array to feed to the PyTorch model + + Returns + ------- + The result from the PyTorch model + """ + if self._model is None: + raise ValueError("Plugin function 'load_torch_model' must have been called to use " + "this function") + + with torch.inference_mode(): + if self._use_pinned: + feed = torch.from_numpy(batch).pin_memory().to(self.device, + non_blocking=True, + memory_format=torch.channels_last) + else: + feed = torch.from_numpy(batch).to(self.device, memory_format=torch.channels_last) + out = self._model(feed) + + if not self._first_batch_seen: + self._process_first_batch(out) + + if self._return_indices: + out = itemgetter(*self._return_indices)(out) + + out = [x.to("cpu").numpy() + for x in out] if self._output_is_list else out.to("cpu").numpy() + + if self._output_is_list: + retval = np.empty((self._output_length, ), dtype="object") + retval[:] = out + return retval + return T.cast(np.ndarray, out) + + +class ExtractPlugin(abc.ABC): + """Base extract plugin that all plugins must inherit from. + + Parameters + ---------- + input_size + The size of the input required by the plugin. The input will always be square at these + dimensions + batch_size + The batch size that the plugin processes data at. Note: Only the `process` method is + guaranteed to receive data at this batch size (or less). The other processes may receive + higher batch sizes for re-processing reasons. Do not rely on this when processing data. + Default: `1` + is_rgb + ``True`` if the plugin expects input images to be RGB rather than BGR. Default: ``False`` + dtype + A valid datatype that the plugin expects to receive the image at. Default: "float32" + scale + The scale that the plugin expects to receive the image at eg: (0, 255) for uint8 images. + Default: (0, 1) + force_cpu + For Torch models, force running on the CPU, rather than the accelerated device. Sets the + :class:`torch.device` to :attr:`device`. Default: ``False`` + """ + def __init__(self, + input_size: int, + batch_size: int = 1, + is_rgb: bool = False, + dtype: str = "float32", + scale: tuple[int, int] = (0, 1), + force_cpu: bool = False) -> None: + logger.debug(parse_class_init(locals())) + self.input_size = input_size + """The size of the plugin's input in pixels""" + self.name = self.__class__.__name__ + """The name of the plugin. Derived from the module name""" + self.batch_size = batch_size + """The maximum batch size that this plugin's 'process' method will receive""" + self.is_rgb = is_rgb + """``True`` if the plugin expects RGB images. ``False`` for BGR""" + self.dtype = dtype + """The datatype that the plugin expects images at""" + self.scale = scale + """The numeric range that the plugin expects images to be in""" + self._torch = _TorchInfer(self.name, force_cpu) + """Handles interfacing with an underlying Torch model""" + self.model: torch.nn.Module | cv2.dnn.Net | T.Any + """The loaded model for the plugin""" + + def __repr__(self) -> str: + """Pretty print for logging""" + params = {k: v for k, v in self.__dict__.items() + if k in ["input_size", "batch_size", "is_rgb", "dtype", "scale"]} + params["force_cpu"] = self.device.type == "cpu" + s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + + @property + def device(self) -> torch.device: + """The selected device to run torch ops on""" + return self._torch.device + + @abc.abstractmethod + def load_model(self) -> torch.nn.Module | cv2.dnn.Net | T.Any: + """Override to perform any model initialization code + + Returns + ------- + The loaded model that will be accessible from :attr:`Model` + """ + + def pre_process(self, batch: np.ndarray) -> np.ndarray: + """Override to perform pre-processing + + Parameters + ---------- + batch + For detection plugins, this will be a batch of square, padded, images at model input + size in the plugin's color order, image format and data range. + + For align plugins this will be a face detection ROI bounding box (batch size, left, + top, right, bottom) as INT32. + + For all other plugins this will be a batch of aligned face images at model input + size in the plugin's color order, image format and data range + + Returns + ------- + For align plugins, this should be an adjustment of the detected face's bounding box to cut + a square out of the original image for feeding the model. Out of bounds values are allowed, + as these will be handled. This bounding box will be used to prepare the image at the + correct size for feeding the model. + + For all other plugins, any pre-processing (eg normalization) should be applied ready for + feeding the model. + """ + return batch + + @abc.abstractmethod + def process(self, batch: np.ndarray) -> np.ndarray: + """Override to perform processing. This is where the model should be called + + Parameters + ---------- + batch + For detection plugins, this will be a batch of square, padded, images at model input + size in the correct format for feeding the model + + For align, mask and identity plugins this will be a batch of square face patches at + model input size in the correct format for feeding the model + + Returns + ------- + This can return any numpy array, but it must be a numpy array. For detect plugins that can + return several results, usually in a list, then this must be an object array + """ + + def post_process(self, batch: np.ndarray) -> npt.NDArray[np.float32]: + """Override to perform post-processing + + Parameters + ---------- + batch + This will be the output from the previous 'process' step + + Returns + ------- + For detect plugins this must be an (N, M, left, top, right, bottom) bounding boxes for + detected faces scaled to model input size as float32. N is the batch size, M is the number + of detections per batch + + For align plugins this must be an (N, 68, 2) float32 array for each (x, y) landmark point + for each face in the batch. co-ordinates should be normalized to 0.0 to 1.0 range + + For mask plugins this must be an (N, size, size) float32 image in range 0. - 1.0 for each + face in the batch + + For identity plugins this must be an (N, M) float32 identity embedding + """ + return batch + + def load_torch_model(self, + model: torch.nn.Module, + weights_path: str, + return_indices: list[int] | None = None) -> torch.nn.Module: + """Load a PyTorch model, apply the weights and pass a warmup batch through + + This function does not need to be used, but some default Faceswap optimizations are + performed here, so without using this function you will either need to apply them yourself + or not have them applied + + Parameters + ---------- + model + The Torch model to load + weights_path + Full path to the weights file to load + return_indices + If the model outputs multiple items, but you only require some of them, the indices of + the required items can be placed here so that when calling `from_torch` any extra data + is not copied from the GPU. Default: ``None`` (return all data) + + Returns + ------- + The loaded model ready for inference + """ + return self._torch.load_torch_model(model, weights_path, return_indices) + + def from_torch(self, batch: np.ndarray) -> np.ndarray: + """Run inference on a PyTorch model. + + This function does not need to be used, however it handles torch backend for better + throughput, so it is recommended. Must have used `self.load_torch_model` to load the Torch + model to use this function. + + Parameters + ---------- + batch + The batch array to feed to the PyTorch model + + Returns + ------- + The result from the PyTorch model + """ + return self._torch.predict(batch) + + +class FacePlugin(ExtractPlugin): + """Base extract plugin that all plugins that work with aligned faces must inherit from. + + Parameters + ---------- + input_size + The size of the input required by the plugin. The input will always be square at these + dimensions + batch_size + The batch size that the plugin processes data at. Note: Only the `process` method is + guaranteed to receive data at this batch size (or less). The other processes may receive + higher batch sizes for re-processing reasons. Do not rely on this when processing data. + Default: `1` + is_rgb + ``True`` if the plugin expects input images to be RGB rather than BGR. Default: ``False`` + dtype + A valid datatype that the plugin expects to receive the image at. Default: "float32" + scale + The scale that the plugin expects to receive the image at eg: (0, 255) for uint8 images. + Default: (0, 1) + force_cpu + For Torch models, force running on the CPU, rather than the accelerated device. Sets the + :class:`torch.device` to :attr:`device`. Default: ``False`` + centering + The centering that the mask should be stored at + """ + def __init__(self, + input_size: int, + batch_size: int = 1, + is_rgb: bool = False, + dtype: str = "float32", + scale: tuple[int, int] = (0, 1), + force_cpu: bool = False, + centering: T.Literal["face", "head", "legacy"] = "face") -> None: + super().__init__( # pylint:disable=too-many-arguments,too-many-positional-arguments + input_size=input_size, + batch_size=batch_size, + is_rgb=is_rgb, + dtype=dtype, + scale=scale, + force_cpu=force_cpu) + + self.centering: CenteringType = centering + """The aligned centering of the image patch to feed the model""" + self.storage_name = self.__module__.rsplit(".", maxsplit=1)[-1].replace("_", "-") + """str : Dictionary safe name for storing the serialized data""" + + def __repr__(self) -> str: + """Pretty print for logging""" + retval = super().__repr__()[:-1] + return retval + f", centering={repr(self.centering)})" + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py deleted file mode 100644 index 61aafbd1f9..0000000000 --- a/plugins/extract/detect/_base.py +++ /dev/null @@ -1,675 +0,0 @@ -#!/usr/bin/env python3 -""" Base class for Face Detector plugins - -All Detector Plugins should inherit from this class. -See the override methods for which methods are required. - -The plugin will receive a :class:`~plugins.extract.extract_media.ExtractMedia` object. - -For each source frame, the plugin must pass a dict to finalize containing: - ->>> {'filename': , ->>> 'detected_faces': >> face = self._to_detected_face(, , , ) -""" -from __future__ import annotations -import logging -import typing as T - -from dataclasses import dataclass, field - -import cv2 -import numpy as np -from torch.cuda import OutOfMemoryError - -from lib.align import DetectedFace -from lib.utils import FaceswapError - -from plugins.extract._base import BatchType, Extractor, ExtractorBatch -from plugins.extract import ExtractMedia - -if T.TYPE_CHECKING: - from collections.abc import Generator - from queue import Queue - -logger = logging.getLogger(__name__) - - -@dataclass -class DetectorBatch(ExtractorBatch): - """ Dataclass for holding items flowing through the aligner. - - Inherits from :class:`~plugins.extract._base.ExtractorBatch` - - Parameters - ---------- - rotation_matrix: :class:`numpy.ndarray` - The rotation matrix for any requested rotations - scale: float - The scaling factor to take the input image back to original size - pad: tuple - The amount of padding to apply to the image to feed the network - initial_feed: :class:`numpy.ndarray` - Used to hold the initial :attr:`feed` when rotate images is enabled - """ - detected_faces: list[list["DetectedFace"]] = field(default_factory=list) - rotation_matrix: list[np.ndarray] = field(default_factory=list) - scale: list[float] = field(default_factory=list) - pad: list[tuple[int, int]] = field(default_factory=list) - initial_feed: np.ndarray = field(default_factory=lambda: np.array([])) - - def __repr__(self): - """ Prettier repr for debug printing """ - retval = super().__repr__() - retval += (f", rotation_matrix={self.rotation_matrix}, " - f"scale={self.scale}, " - f"pad={self.pad}, " - f"initial_feed=({self.initial_feed.shape}, {self.initial_feed.dtype})") - return retval - - -class Detector(Extractor): # pylint:disable=abstract-method - """ Detector Object - - Parent class for all Detector plugins - - Parameters - ---------- - git_model_id: int - The second digit in the github tag that identifies this model. See - https://github.com/deepfakes-models/faceswap-models for more information - model_filename: str - The name of the model file to be loaded - rotation: str, optional - Pass in a single number to use increments of that size up to 360, or pass in a ``list`` of - ``ints`` to enumerate exactly what angles to check. Can also pass in ``'on'`` to increment - at 90 degree intervals. Default: ``None`` - min_size: int, optional - Filters out faces detected below this size. Length, in pixels across the diagonal of the - bounding box. Set to ``0`` for off. Default: ``0`` - - Other Parameters - ---------------- - configfile: str, optional - Path to a custom configuration ``ini`` file. Default: Use system configfile - - See Also - -------- - plugins.extract.pipeline : The extraction pipeline for calling plugins - plugins.extract.detect : Detector plugins - plugins.extract._base : Parent class for all extraction plugins - plugins.extract.align._base : Aligner parent class for extraction plugins. - plugins.extract.mask._base : Masker parent class for extraction plugins. - """ - - def __init__(self, - git_model_id: int | None = None, - model_filename: str | list[str] | None = None, - configfile: str | None = None, - instance: int = 0, - rotation: str | None = None, - min_size: int = 0, - **kwargs) -> None: - logger.debug("Initializing %s: (rotation: %s, min_size: %s)", self.__class__.__name__, - rotation, min_size) - super().__init__(git_model_id, - model_filename, - configfile=configfile, - instance=instance, - **kwargs) - self.rotation = self._get_rotation_angles(rotation) - self.min_size = min_size - - self._info.plugin_type = "detect" - - logger.debug("Initialized _base %s", self.__class__.__name__) - - # <<< QUEUE METHODS >>> # - def get_batch(self, queue: Queue) -> tuple[bool, DetectorBatch]: - """ Get items for inputting to the detector plugin in batches - - Items are received as :class:`~plugins.extract.extract_media.ExtractMedia` objects and - converted to ``dict`` for internal processing. - - Items are returned from the ``queue`` in batches of - :attr:`~plugins.extract._base.Extractor.batchsize` - - Remember to put ``'EOF'`` to the out queue after processing - the final batch - - Outputs items in the following format. All lists are of length - :attr:`~plugins.extract._base.Extractor.batchsize`: - - >>> {'filename': [], - >>> 'image': , - >>> 'scale': [], - >>> 'pad': [], - >>> 'detected_faces': [[>> # - def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: - """ Finalize the output from Detector - - This should be called as the final task of each ``plugin``. - - Parameters - ---------- - batch : :class:`~plugins.extract._base.ExtractorBatch` - The batch object for the current batch - - Yields - ------ - :class:`~plugins.extract.extract_media.ExtractMedia` - The :attr:`DetectedFaces` list will be populated for this class with the bounding boxes - for the detected faces found in the frame. - """ - assert isinstance(batch, DetectorBatch) - logger.trace("Item out: %s", # type:ignore[attr-defined] - {k: len(v) if isinstance(v, (list, np.ndarray)) else v - for k, v in batch.__dict__.items()}) - - batch_faces = [[self._to_detected_face(face[0], face[1], face[2], face[3]) - for face in faces] - for faces in batch.prediction] - # Rotations - if any(m.any() for m in batch.rotation_matrix) and any(batch_faces): - batch_faces = [[self._rotate_face(face, rotmat) if rotmat.any() else face - for face in faces] - for faces, rotmat in zip(batch_faces, batch.rotation_matrix)] - - # Remove zero sized faces - batch_faces = self._remove_zero_sized_faces(batch_faces) - - # Scale back out to original frame - batch.detected_faces = [[self._to_detected_face((face.left - pad[0]) / scale, - (face.top - pad[1]) / scale, - (face.right - pad[0]) / scale, - (face.bottom - pad[1]) / scale) - for face in faces - if face.left is not None and face.top is not None] - for scale, pad, faces in zip(batch.scale, - batch.pad, - batch_faces)] - - if self.min_size > 0 and batch.detected_faces: - batch.detected_faces = self._filter_small_faces(batch.detected_faces) - - for idx, filename in enumerate(batch.filename): - output = self._extract_media.pop(filename) - output.add_detected_faces(batch.detected_faces[idx]) - - logger.trace("final output: (filename: '%s', " # type:ignore[attr-defined] - "image shape: %s, detected_faces: %s, item: %s", - output.filename, output.image_shape, output.detected_faces, output) - yield output - - @staticmethod - def _to_detected_face(left: float, top: float, right: float, bottom: float) -> DetectedFace: - """ Convert a bounding box to a detected face object - - Parameters - ---------- - left: float - The left point of the detection bounding box - top: float - The top point of the detection bounding box - right: float - The right point of the detection bounding box - bottom: float - The bottom point of the detection bounding box - - Returns - ------- - class:`~lib.align.DetectedFace` - The detected face object for the given bounding box - """ - return DetectedFace(left=int(round(left)), - width=int(round(right - left)), - top=int(round(top)), - height=int(round(bottom - top))) - - # <<< PROTECTED ACCESS METHODS >>> # - # <<< PREDICT WRAPPER >>> # - def _predict(self, batch: BatchType) -> DetectorBatch: - """ Wrap models predict function in rotations """ - assert isinstance(batch, DetectorBatch) - batch.rotation_matrix = [np.array([]) for _ in range(len(batch.feed))] - found_faces: list[np.ndarray] = [np.array([]) for _ in range(len(batch.feed))] - for angle in self.rotation: - # Rotate the batch and insert placeholders for already found faces - self._rotate_batch(batch, angle) - try: - pred = self.predict(batch.feed) - if angle == 0: - batch.prediction = pred - else: - try: - batch.prediction = np.array([b if b.any() else p - for b, p in zip(batch.prediction, pred)]) - except ValueError as err: - # If batches are different sizes after rotation Numpy will error, so we - # need to explicitly set the dtype to 'object' rather than let it infer - # numpy error: - # ValueError: setting an array element with a sequence. The requested array - # has an inhomogeneous shape after 1 dimensions. The detected shape was - # (8,) + inhomogeneous part - if "inhomogeneous" in str(err): - batch.prediction = np.array([b if b.any() else p - for b, p in zip(batch.prediction, pred)], - dtype="object") - logger.trace( # type:ignore[attr-defined] - "Mismatched array sizes, setting dtype to object: %s", - [p.shape for p in batch.prediction]) - else: - raise - - logger.trace("angle: %s, filenames: %s, " # type:ignore[attr-defined] - "prediction: %s", - angle, batch.filename, pred) - except OutOfMemoryError as err: - msg = ("You do not have enough GPU memory available to run detection at the " - "selected batch size. You can try a number of things:" - "\n1) Close any other application that is using your GPU (web browsers are " - "particularly bad for this)." - "\n2) Lower the batchsize (the amount of images fed into the model) by " - "editing the plugin settings (GUI: Settings > Configure extract settings, " - "CLI: Edit the file faceswap/config/extract.ini)." - "\n3) Enable 'Single Process' mode.") - raise FaceswapError(msg) from err - - if angle != 0 and any(face.any() for face in batch.prediction): - logger.verbose("found face(s) by rotating image %s " # type:ignore[attr-defined] - "degrees", - angle) - - found_faces = T.cast(list[np.ndarray], ([face if not found.any() else found - for face, found in zip(batch.prediction, - found_faces)])) - if all(face.any() for face in found_faces): - logger.trace("Faces found for all images") # type:ignore[attr-defined] - break - - batch.prediction = np.array(found_faces, dtype="object") - logger.trace("detect_prediction output: (filenames: %s, " # type:ignore[attr-defined] - "prediction: %s, rotmat: %s)", - batch.filename, batch.prediction, batch.rotation_matrix) - return batch - - # <<< DETECTION IMAGE COMPILATION METHODS >>> # - def _compile_detection_image(self, item: ExtractMedia - ) -> tuple[np.ndarray, float, tuple[int, int]]: - """ Compile the detection image for feeding into the model - - Parameters - ---------- - item: :class:`~plugins.extract.extract_media.ExtractMedia` - The input item from the pipeline - - Returns - ------- - image: :class:`numpy.ndarray` - The original image formatted for detection - scale: float - The scaling factor for the image - pad: int - The amount of padding applied to the image - """ - image = item.get_image_copy(self.color_format) - scale = self._set_scale(item.image_size) - pad = self._set_padding(item.image_size, scale) - - image = self._scale_image(image, item.image_size, scale) - image = self._pad_image(image) - logger.trace("compiled: (images shape: %s, " # type:ignore[attr-defined] - "scale: %s, pad: %s)", - image.shape, scale, pad) - return image, scale, pad - - def _set_scale(self, image_size: tuple[int, int]) -> float: - """ Set the scale factor for incoming image - - Parameters - ---------- - image_size: tuple - The (height, width) of the original image - - Returns - ------- - float - The scaling factor from original image size to model input size - """ - scale = self.input_size / max(image_size) - logger.trace("Detector scale: %s", scale) # type:ignore[attr-defined] - return scale - - def _set_padding(self, image_size: tuple[int, int], scale: float) -> tuple[int, int]: - """ Set the image padding for non-square images - - Parameters - ---------- - image_size: tuple - The (height, width) of the original image - scale: float - The scaling factor from original image size to model input size - - Returns - ------- - tuple - The amount of padding to apply to the x and y axes - """ - pad_left = int(self.input_size - int(image_size[1] * scale)) // 2 - pad_top = int(self.input_size - int(image_size[0] * scale)) // 2 - return pad_left, pad_top - - @staticmethod - def _scale_image(image: np.ndarray, image_size: tuple[int, int], scale: float) -> np.ndarray: - """ Scale the image and optional pad to given size - - Parameters - ---------- - image: :class:`numpy.ndarray` - The image to be scalued - image_size: tuple - The image (height, width) - scale: float - The scaling factor to apply to the image - - Returns - ------- - :class:`numpy.ndarray` - The scaled image - """ - interpln = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA - if scale != 1.0: - dims = (int(image_size[1] * scale), int(image_size[0] * scale)) - logger.trace("Resizing detection image from %s to %s. " # type:ignore[attr-defined] - "Scale=%s", - "x".join(str(i) for i in reversed(image_size)), - "x".join(str(i) for i in dims), scale) - image = cv2.resize(image, dims, interpolation=interpln) - logger.trace("Resized image shape: %s", image.shape) # type:ignore[attr-defined] - return image - - def _pad_image(self, image: np.ndarray) -> np.ndarray: - """ Pad a resized image to input size - - Parameters - ---------- - image: :class:`numpy.ndarray` - The image to have padding applied - - Returns - ------- - :class:`numpy.ndarray` - The image with padding applied - """ - height, width = image.shape[:2] - if width < self.input_size or height < self.input_size: - pad_l = (self.input_size - width) // 2 - pad_r = (self.input_size - width) - pad_l - pad_t = (self.input_size - height) // 2 - pad_b = (self.input_size - height) - pad_t - image = cv2.copyMakeBorder(image, - pad_t, - pad_b, - pad_l, - pad_r, - cv2.BORDER_CONSTANT) - logger.trace("Padded image shape: %s", image.shape) # type:ignore[attr-defined] - return image - - # <<< FINALIZE METHODS >>> # - def _remove_zero_sized_faces(self, batch_faces: list[list[DetectedFace]] - ) -> list[list[DetectedFace]]: - """ Remove items from batch_faces where detected face is of zero size or face falls - entirely outside of image - - Parameters - ---------- - batch_faces: list - List of detected face objects - - Returns - ------- - list - List of detected face objects with filtered out faces removed - """ - logger.trace("Input sizes: %s", [len(face) for face in batch_faces]) # type: ignore - retval = [[face - for face in faces - if face.right > 0 and face.left is not None and face.left < self.input_size - and face.bottom > 0 and face.top is not None and face.top < self.input_size] - for faces in batch_faces] - logger.trace("Output sizes: %s", [len(face) for face in retval]) # type: ignore - return retval - - def _filter_small_faces(self, detected_faces: list[list[DetectedFace]] - ) -> list[list[DetectedFace]]: - """ Filter out any faces smaller than the min size threshold - - Parameters - ---------- - detected_faces: list - List of detected face objects - - Returns - ------- - list - List of detected face objects with filtered out faces removed - """ - retval = [] - for faces in detected_faces: - this_image = [] - for face in faces: - assert face.width is not None and face.height is not None - face_size = (face.width ** 2 + face.height ** 2) ** 0.5 - if face_size < self.min_size: - logger.debug("Removing detected face: (face_size: %s, min_size: %s", - face_size, self.min_size) - continue - this_image.append(face) - retval.append(this_image) - return retval - - # <<< IMAGE ROTATION METHODS >>> # - @staticmethod - def _get_rotation_angles(rotation: str | None) -> list[int]: - """ Set the rotation angles. - - Parameters - ---------- - str - List of requested rotation angles - - Returns - ------- - list - The complete list of rotation angles to apply - """ - rotation_angles = [0] - - if not rotation: - logger.debug("Not setting rotation angles") - return rotation_angles - - passed_angles = [int(angle) - for angle in rotation.split(",") - if int(angle) != 0] - if len(passed_angles) == 1: - rotation_step_size = passed_angles[0] - rotation_angles.extend(range(rotation_step_size, - 360, - rotation_step_size)) - elif len(passed_angles) > 1: - rotation_angles.extend(passed_angles) - - logger.debug("Rotation Angles: %s", rotation_angles) - return rotation_angles - - def _rotate_batch(self, batch: DetectorBatch, angle: int) -> None: - """ Rotate images in a batch by given angle - - if any faces have already been detected for a batch, store the existing rotation - matrix and replace the feed image with a placeholder - - Parameters - ---------- - batch: :class:`DetectorBatch` - The batch to apply rotation to - angle: int - The amount of degrees to rotate the image by - """ - if angle == 0: - # Set the initial batch so we always rotate from zero - batch.initial_feed = batch.feed.copy() - return - - feeds: list[np.ndarray] = [] - rotmats: list[np.ndarray] = [] - for img, faces, rotmat in zip(batch.initial_feed, - batch.prediction, - batch.rotation_matrix): - if faces.any(): - image = np.zeros_like(img) - matrix = rotmat - else: - image, matrix = self._rotate_image_by_angle(img, angle) - feeds.append(image) - rotmats.append(matrix) - batch.feed = np.array(feeds, dtype="float32") - batch.rotation_matrix = rotmats - - @staticmethod - def _rotate_face(face: DetectedFace, rotation_matrix: np.ndarray) -> DetectedFace: - """ Rotates the detection bounding box around the given rotation matrix. - - Parameters - ---------- - face: :class:`DetectedFace` - A :class:`DetectedFace` containing the `x`, `w`, `y`, `h` detection bounding box - points. - rotation_matrix: numpy.ndarray - The rotation matrix to rotate the given object by. - - Returns - ------- - :class:`DetectedFace` - The same class with the detection bounding box points rotated by the given matrix. - """ - logger.trace("Rotating face: (face: %s, rotation_matrix: %s)", # type: ignore - face, rotation_matrix) - bounding_box = [[face.left, face.top], - [face.right, face.top], - [face.right, face.bottom], - [face.left, face.bottom]] - rotation_matrix = cv2.invertAffineTransform(rotation_matrix) - - points = np.array(bounding_box, "int32") - points = np.expand_dims(points, axis=0) - transformed = cv2.transform(points, rotation_matrix).astype("int32") - rotated = transformed.squeeze() - - # Bounding box should follow x, y planes, so get min/max for non-90 degree rotations - pt_x = min(pnt[0] for pnt in rotated) - pt_y = min(pnt[1] for pnt in rotated) - pt_x1 = max(pnt[0] for pnt in rotated) - pt_y1 = max(pnt[1] for pnt in rotated) - width = pt_x1 - pt_x - height = pt_y1 - pt_y - - face.left = int(pt_x) - face.top = int(pt_y) - face.width = int(width) - face.height = int(height) - return face - - def _rotate_image_by_angle(self, - image: np.ndarray, - angle: int) -> tuple[np.ndarray, np.ndarray]: - """ Rotate an image by a given angle. - - Parameters - ---------- - image: :class:`numpy.ndarray` - The image to be rotated - angle: int - The angle, in degrees, to rotate the image by - - Returns - ------- - image: :class:`numpy.ndarray` - The rotated image - rotation_matrix: :class:`numpy.ndarray` - The rotation matrix used to rotate the image - - Reference - --------- - https://stackoverflow.com/questions/22041699 - """ - - logger.trace("Rotating image: (image: %s, angle: %s)", # type:ignore[attr-defined] - image.shape, angle) - channels_first = image.shape[0] <= 4 - if channels_first: - image = np.moveaxis(image, 0, 2) - - height, width = image.shape[:2] - image_center = (width/2, height/2) - rotation_matrix = cv2.getRotationMatrix2D(image_center, -1.*angle, 1.) - rotation_matrix[0, 2] += self.input_size / 2 - image_center[0] - rotation_matrix[1, 2] += self.input_size / 2 - image_center[1] - logger.trace("Rotated image: (rotation_matrix: %s", # type:ignore[attr-defined] - rotation_matrix) - image = cv2.warpAffine(image, rotation_matrix, (self.input_size, self.input_size)) - if channels_first: - image = np.moveaxis(image, 2, 0) - - return image, rotation_matrix diff --git a/plugins/extract/detect/cv2_dnn.py b/plugins/extract/detect/cv2_dnn.py index 7e948eaef6..3e6d8fee1d 100644 --- a/plugins/extract/detect/cv2_dnn.py +++ b/plugins/extract/detect/cv2_dnn.py @@ -1,72 +1,91 @@ #!/usr/bin/env python3 -""" OpenCV DNN Face detection plugin """ +"""OpenCV DNN Face detection plugin""" import logging +import cv2 import numpy as np -from lib.utils import get_module_objects -from ._base import BatchType, cv2, Detector, DetectorBatch +from lib.utils import get_module_objects, GetModel +from plugins.extract.base import ExtractPlugin from . import cv2_dnn_defaults as cfg logger = logging.getLogger(__name__) -class Detect(Detector): - """ CV2 DNN detector for face recognition """ - def __init__(self, **kwargs) -> None: - git_model_id = 4 - model_filename = ["resnet_ssd_v1.caffemodel", "resnet_ssd_v1.prototxt"] - super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.name = "cv2-DNN Detector" - self.input_size = 300 - self.vram = 0 # CPU Only. Doesn't use VRAM - self.vram_per_batch = 0 - self.batchsize = 1 +class CV2DNNDetect(ExtractPlugin): + """CV2 DNN detector for face recognition""" + def __init__(self) -> None: + super().__init__(input_size=300, + batch_size=1, + is_rgb=False, + dtype="float32", + scale=(0, 255)) + self.model: cv2.dnn.Net self.confidence = cfg.confidence() / 100 + self._average_image = np.array([104, 117, 123], dtype="float32") - def init_model(self) -> None: - """ Initialize CV2 DNN Detector Model""" - assert isinstance(self.model_path, list) - self.model = cv2.dnn.readNetFromCaffe(self.model_path[1], - self.model_path[0]) - self.model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) - - def process_input(self, batch: BatchType) -> None: - """ Compile the detection image(s) for prediction """ - assert isinstance(batch, DetectorBatch) - batch.feed = cv2.dnn.blobFromImages(batch.image, - scalefactor=1.0, - size=(self.input_size, self.input_size), - mean=[104, 117, 123], - swapRB=False, - crop=False) - - def predict(self, feed: np.ndarray) -> np.ndarray: - """ Run model to get predictions """ - assert isinstance(self.model, cv2.dnn.Net) - self.model.setInput(feed) - predictions = self.model.forward() - return self.finalize_predictions(predictions) - - def finalize_predictions(self, predictions: np.ndarray) -> np.ndarray: - """ Filter faces based on confidence level """ - faces = [] - for i in range(predictions.shape[2]): - confidence = predictions[0, 0, i, 2] - if confidence >= self.confidence: - logger.trace("Accepting due to confidence %s >= %s", # type:ignore[attr-defined] - confidence, self.confidence) - faces.append([(predictions[0, 0, i, 3] * self.input_size), - (predictions[0, 0, i, 4] * self.input_size), - (predictions[0, 0, i, 5] * self.input_size), - (predictions[0, 0, i, 6] * self.input_size)]) - logger.trace("faces: %s", faces) # type:ignore[attr-defined] - return np.array(faces)[None, ...] - - def process_output(self, batch: BatchType) -> None: - """ Compile found faces for output """ - return + def load_model(self) -> cv2.dnn.Net: + """Load the CV2 DNN Detector Model + + Returns + ------- + The loaded cv2-DNN model + """ + weights = GetModel(model_filename=["resnet_ssd_v1.caffemodel", "resnet_ssd_v1.prototxt"], + git_model_id=4) + model_path = weights.model_path + assert isinstance(model_path, list) + model = cv2.dnn.readNetFromCaffe(model_path[1], model_path[0]) + model.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) + return model + + def pre_process(self, batch: np.ndarray) -> np.ndarray: + """Compile the detection image(s) for prediction + + Parameters + ---------- + batch + The input batch of images at model input size in the correct color order + + Returns + ------- + The batch of images ready for feeding the model + """ + return (batch - self._average_image).transpose(0, 3, 1, 2) + + def process(self, batch: np.ndarray) -> np.ndarray: + """Run model to get predictions + + Parameters + ---------- + batch + A batch of images ready to feed the model + + Returns + ------- + The batch of detection results from the model + """ + self.model.setInput(batch) + result = self.model.forward() + return result.reshape(batch.shape[0], 200, 7) + + def post_process(self, batch: np.ndarray) -> np.ndarray: + """Compile found faces for output + + Parameters + ---------- + batch + The detection results for the model + + Returns + ------- + The processed detection bounding box from the model at model input size + """ + confidence_mask = batch[..., 2] >= self.confidence + boxes = [batch[b, ..., 3:7][confidence_mask[b]] * self.input_size + for b in range(batch.shape[0])] + return np.array(boxes, dtype="object") __all__ = get_module_objects(__name__) diff --git a/plugins/extract/detect/cv2_dnn_defaults.py b/plugins/extract/detect/cv2_dnn_defaults.py index 127fae9370..f1f80187fe 100755 --- a/plugins/extract/detect/cv2_dnn_defaults.py +++ b/plugins/extract/detect/cv2_dnn_defaults.py @@ -39,7 +39,7 @@ datatype=int, default=50, group="settings", - info="The confidence level at which the detector has succesfully found a face.\nHigher " + info="The confidence level at which the detector has successfully found a face.\nHigher " "levels will be more discriminating, lower levels will have more false positives.", rounding=5, min_max=(25, 100)) diff --git a/plugins/extract/detect/external.py b/plugins/extract/detect/external.py deleted file mode 100644 index 074a978796..0000000000 --- a/plugins/extract/detect/external.py +++ /dev/null @@ -1,357 +0,0 @@ -#!/usr/bin/env python3 -""" Import face detection ROI boxes from a json file """ -from __future__ import annotations - -import logging -import os -import re -import typing as T - -import numpy as np - -from lib.align import AlignedFace -from lib.utils import get_module_objects, FaceswapError, IMAGE_EXTENSIONS - -from ._base import Detector -from . import external_defaults as cfg - -if T.TYPE_CHECKING: - from lib.align import DetectedFace - from plugins.extract import ExtractMedia - from ._base import BatchType - -logger = logging.getLogger(__name__) -OriginType = T.Literal["top-left", "bottom-left", "top-right", "bottom-right"] -# pylint:disable=duplicate-code - - -class Detect(Detector): - """ Import face detection bounding boxes from an external json file """ - def __init__(self, **kwargs) -> None: - kwargs["rotation"] = None # Disable rotation - kwargs["min_size"] = 0 # Disable min_size - super().__init__(git_model_id=None, model_filename=None, **kwargs) - - self.name = "External" - self.batchsize = 16 - - self.origin: OriginType = T.cast(OriginType, cfg.origin()) - """ Literal["top-left", "bottom-left", "top-right", "bottom-right"] : The origin (0, 0) - location of the co-ordinates system used""" - self.file_name = cfg.file_name() - """ str : The file name to import ROI data from """ - - self._re_frame_no: re.Pattern = re.compile(r"\d+$") - self._missing: list[str] = [] - self._log_once = True - self._is_video = False - self._imported: dict[str | int, np.ndarray] = {} - """dict[str | int, np.ndarray]: The imported data from external .json file""" - - def init_model(self) -> None: - """ No initialization to perform """ - logger.debug("No detector model to initialize") - - def _compile_detection_image(self, item: ExtractMedia - ) -> tuple[np.ndarray, float, tuple[int, int]]: - """ Override _compile_detection_image method, to obtain the source frame dimensions - - Parameters - ---------- - item: :class:`~plugins.extract.extract_media.ExtractMedia` - The input item from the pipeline - - Returns - ------- - image: :class:`numpy.ndarray` - dummy empty array - scale: float - The scaling factor for the image (1.0) - pad: int - The amount of padding applied to the image (0, 0) - """ - return np.array(item.image_shape[:2], dtype="int64"), 1.0, (0, 0) - - def _check_for_video(self, filename: str) -> None: - """ Check a sample filename from the import file for a file extension to set - :attr:`_is_video` - - Parameters - ---------- - filename: str - A sample file name from the imported data - """ - logger.debug("Checking for video from '%s'", filename) - ext = os.path.splitext(filename)[-1] - if ext.lower() not in IMAGE_EXTENSIONS: - self._is_video = True - logger.debug("Set is_video to %s from extension '%s'", self._is_video, ext) - - def _get_key(self, key: str) -> str | int: - """ Obtain the key for the item in the lookup table. If the input are images, the key will - be the image filename. If the input is a video, the key will be the frame number - - Parameters - ---------- - key: str - The initial key value from import data or an import image/frame - - Returns - ------- - str | int - The filename is the input data is images, otherwise the frame number of a video - """ - if not self._is_video: - return key - original_name = os.path.splitext(key)[0] - matches = self._re_frame_no.findall(original_name) - if not matches or len(matches) > 1: - raise FaceswapError(f"Invalid import name: '{key}'. For video files, the key should " - "end with the frame number.") - retval = int(matches[0]) - logger.trace("Obtained frame number %s from key '%s'", # type:ignore[attr-defined] - retval, key) - return retval - - @classmethod - def _bbox_from_detected(cls, bounding_box: list[int]) -> np.ndarray: - """ Import the detected face roi from a `detected` item in the import file - - Parameters - ---------- - bounding_box: list[int] - a bounding box contained within the import file - - Returns - ------- - :class:`numpy.ndarray` - The "left", "top", "right", "bottom" bounding box for the face - - Raises - ------ - FaceSwapError - If the number of bounding box co-ordinates is incorrect - """ - if len(bounding_box) != 4: - raise FaceswapError("Imported 'detected' bounding boxes should be a list of 4 numbers " - "representing the 'left', 'top', 'right', `bottom` of a face.") - return np.rint(bounding_box) - - def _validate_landmarks(self, landmarks: list[list[float]]) -> np.ndarray: - """ Validate that the there are 4 or 68 landmarks and are a complete list of (x, y) - co-ordinates - - Parameters - ---------- - landmarks: list[float] - The 4 point ROI or 68 point 2D landmarks that are being imported - - Returns - ------- - :class:`numpy.ndarray` - The original landmarks as a numpy array - - Raises - ------ - FaceSwapError - If the landmarks being imported are not correct - """ - if len(landmarks) not in (4, 68): - raise FaceswapError("Imported 'landmarks_2d' should be either 68 facial feature " - "landmarks or 4 ROI corner locations") - retval = np.array(landmarks, dtype="float32") - if retval.shape[-1] != 2: - raise FaceswapError("Imported 'landmarks_2d' should be formatted as a list of (x, y) " - "co-ordinates") - return retval - - def _bbox_from_landmarks2d(self, landmarks: list[list[float]]) -> np.ndarray: - """ Import the detected face roi by estimating from imported landmarks - - Parameters - ---------- - landmarks: list[float] - The 4 point ROI or 68 point 2D landmarks that are being imported - - Returns - ------- - :class:`numpy.ndarray` - The "left", "top", "right", "bottom" bounding box for the face - """ - n_landmarks = self._validate_landmarks(landmarks) - face = AlignedFace(n_landmarks, centering="legacy", coverage_ratio=0.75) - return np.concatenate([np.min(face.original_roi, axis=0), - np.max(face.original_roi, axis=0)]) - - def _import_frame_face(self, - face: dict[str, list[int] | list[list[float]]], - align_origin: OriginType | None) -> np.ndarray: - """ Import a detected face ROI from the import file - - Parameters - ---------- - face: dict[str, list[int] | list[list[float]]] - The data that exists within the import file for the frame - align_origin: Literal["top-left", "bottom-left", "top-right", "bottom-right"] | None - The origin of the imported aligner data. Used if the detected ROI is being estimated - from imported aligner data - - Returns - ------- - :class:`numpy.ndarray` - The "left", "top", "right", "bottom" bounding box for the face - - Raises - ------ - FaceSwapError - If the required keys for the bounding boxes are not present for the face - """ - if "detected" in face: - return self._bbox_from_detected(T.cast(list[int], face["detected"])) - if "landmarks_2d" in face: - if self._log_once and align_origin is None: - logger.warning("You are importing Detection data, but have only provided " - "Alignment data. This is most likely incorrect and will lead " - "to poor results") - self._log_once = False - - if self._log_once and align_origin is not None and align_origin != self.origin: - logger.info("Updating Detect origin from Aligner config to '%s'", align_origin) - self.origin = align_origin - self._log_once = False - - return self._bbox_from_landmarks2d(T.cast(list[list[float]], face["landmarks_2d"])) - - raise FaceswapError("The provided import file is missing both of the required keys " - "'detected' and 'landmarks_2d") - - def import_data(self, - data: dict[str, list[dict[str, list[int] | list[list[float]]]]], - align_origin: T.Literal["top-left", - "bottom-left", - "top-right", - "bottom-right"] | None) -> None: - """ Import the detection data from the json import file and set to :attr:`_imported` - - Parameters - ---------- - data: dict[str, list[dict[str, list[int] | list[list[float]]]]] - The data to be imported - align_origin: Literal["top-left", "bottom-left", "top-right", "bottom-right"] | None - The origin of the imported aligner data. Used if the detected ROI is being estimated - from imported aligner data - """ - logger.debug("Data length: %s, align_origin: %s", len(data), align_origin) - self._check_for_video(list(data)[0]) - for key, faces in data.items(): - try: - store_key = self._get_key(key) - self._imported[store_key] = np.array([self._import_frame_face(face, align_origin) - for face in faces], dtype="int32") - except FaceswapError as err: - logger.error(str(err)) - msg = f"The imported frame key that failed was '{key}'" - raise FaceswapError(msg) from err - - def process_input(self, batch: BatchType) -> None: - """ Put the lookup key into `batch.feed` so they can be collected for mapping in `.predict` - - Parameters - ---------- - batch: :class:`~plugins.extract.detect._base.DetectorBatch` - The batch to be processed by the plugin - """ - batch.feed = np.array([(self._get_key(os.path.basename(f)), i) - for f, i in zip(batch.filename, batch.image)], dtype="object") - - def _adjust_for_origin(self, box: np.ndarray, frame_dims: tuple[int, int]) -> np.ndarray: - """ Adjust the bounding box to be top-left orientated based on the selected import origin - - Parameters - ---------- - box: :class:`np.ndarray` - The imported bounding box at original (0, 0) origin - frame_dims: tuple[int, int] - The (rows, columns) dimensions of the original frame - - Returns - ------- - :class:`numpy.ndarray` - The adjusted bounding box for a top-left origin - """ - if not np.any(box) or self.origin == "top-left": - return box - if self.origin.startswith("bottom"): - box[:, [1, 3]] = frame_dims[0] - box[:, [1, 3]] - if self.origin.endswith("right"): - box[:, [0, 2]] = frame_dims[1] - box[:, [0, 2]] - - return box - - def predict(self, feed: np.ndarray) -> list[np.ndarray]: # type:ignore[override] - """ Pair the input filenames to the import file - - Parameters - ---------- - feed: :class:`numpy.ndarray` - The filenames with original frame dimensions to obtain the imported bounding boxes for - - Returns - ------- - list[]:class:`numpy.ndarray`] - The bounding boxes for the given filenames - """ - self._missing.extend(f[0] for f in feed if f[0] not in self._imported) - return [self._adjust_for_origin(self._imported.pop(f[0], np.array([], dtype="int32")), - f[1]) - for f in feed] - - def process_output(self, batch: BatchType) -> None: - """ No output processing required for import plugin - - Parameters - ---------- - batch: :class:`~plugins.extract.detect._base.DetectorBatch` - The batch to be processed by the plugin - """ - logger.trace("No output processing for import plugin") # type:ignore[attr-defined] - - def _remove_zero_sized_faces(self, batch_faces: list[list[DetectedFace]] - ) -> list[list[DetectedFace]]: - """ Override _remove_zero_sized_faces to just return the faces that have been imported - - Parameters - ---------- - batch_faces: list[list[DetectedFace] - List of detected face objects - - Returns - ------- - list[list[DetectedFace] - Original list of detected face objects - """ - return batch_faces - - def on_completion(self) -> None: - """ Output information if: - - Imported items were not matched in input data - - Input data was not matched in imported items - """ - super().on_completion() - - if self._missing: - logger.warning("[DETECT] %s input frames could not be matched in the import file " - "'%s'. Run in verbose mode for a list of frames.", - len(self._missing), cfg.file_name()) - logger.verbose( # type:ignore[attr-defined] - "[DETECT] Input frames not in import file: %s", self._missing) - - if self._imported: - logger.warning("[DETECT] %s items in the import file '%s' could not be matched to any " - "input frames. Run in verbose mode for a list of items.", - len(self._imported), cfg.file_name()) - logger.verbose( # type:ignore[attr-defined] - "[DETECT] import file items not in input frames: %s", list(self._imported)) - - -__all__ = get_module_objects(__name__) diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py index 16533e382a..d4440b4ceb 100644 --- a/plugins/extract/detect/mtcnn.py +++ b/plugins/extract/detect/mtcnn.py @@ -1,134 +1,143 @@ #!/usr/bin/env python3 -""" MTCNN Face detection plugin """ +"""MTCNN Face detection plugin""" from __future__ import annotations import logging -import typing as T import cv2 import numpy as np -from keras.models import Model -from keras.layers import Conv2D, Dense, Flatten, Input, MaxPooling2D, Permute, PReLU +import torch +from torch import nn from lib.logger import parse_class_init -from lib.utils import get_module_objects -from ._base import BatchType, Detector +from lib.utils import get_module_objects, GetModel +from plugins.extract.base import ExtractPlugin + from . import mtcnn_defaults as cfg logger = logging.getLogger(__name__) -class Detect(Detector): - """ MTCNN detector for face recognition. """ - def __init__(self, **kwargs) -> None: - git_model_id = 2 - model_filename = ["mtcnn_det_v2.1.h5", "mtcnn_det_v2.2.h5", "mtcnn_det_v2.3.h5"] - super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.name = "MTCNN" - self.model: MTCNN - self.input_size = 640 - self.vram = 128 if not cfg.cpu() else 0 # 66 in testing - self.vram_per_batch = 64 if not cfg.cpu() else 0 # ~50 in testing - self.batchsize = cfg.batch_size() - self._kwargs = self._validate_kwargs() - self.color_format = "RGB" - - def _validate_kwargs(self) -> dict[T.Literal["minsize", "threshold", "factor", "input_size"], - int | float | list[float]]: - """ Validate that config options are correct. If not reset to default """ - valid = True +class MTCNN(ExtractPlugin): + """MTCNN detector for face recognition.""" + def __init__(self) -> None: + super().__init__(input_size=640, + batch_size=cfg.batch_size(), + is_rgb=True, + dtype="float32", + scale=(-1, 1), + force_cpu=cfg.cpu()) + self.model: MTCNNModel + self._validate_config() + + def _validate_config(self) -> None: + """Validate that config options are correct. If not reset to default""" + if cfg.min_size() < 10: + logger.warning("Invalid MTCNN config value 'min_size': %s. Reset to %s", + cfg.min_size(), cfg.min_size.default) + cfg.min_size.set(cfg.min_size.default) + + for idx, threshold in enumerate((cfg.threshold_1, cfg.threshold_2, cfg.threshold_3)): + if not 0.0 < threshold() <= 1.0: + logger.warning("Invalid MTCNN config value 'threshold_%s': %s. Reset to %s", + idx + 1, threshold(), threshold.default) + threshold.set(threshold.default) + + if not 0.0 < cfg.scalefactor() < 1.0: + logger.warning("Invalid MTCNN config value 'scalefactor': %s. Reset to %s", + cfg.scalefactor(), cfg.scalefactor.default) + cfg.scalefactor.set(cfg.scalefactor.default) + + def _get_weights_path(self) -> list[str]: + """Download the weights, if required, and return the path to the weights files + + Returns + ------- + The paths to the downloaded MTCNN weights files + """ + model = GetModel( + model_filename=["mtcnn_det_v3.1.pt", "mtcnn_det_v3.2.pt", "mtcnn_det_v3.3.pt"], + git_model_id=2) + model_path = model.model_path + assert isinstance(model_path, list) + return model_path + + def load_model(self) -> MTCNNModel: + """Load the model + + Returns + ------- + The loaded MTCNN model + """ + weights = self._get_weights_path() threshold = [cfg.threshold_1(), cfg.threshold_2(), cfg.threshold_3()] - kwargs: dict[T.Literal["minsize", "threshold", "factor", "input_size"], - int | float | list[float]] = {"minsize": cfg.minsize(), - "threshold": threshold, - "factor": cfg.scalefactor(), - "input_size": self.input_size} - - assert isinstance(kwargs["input_size"], int) - assert isinstance(kwargs["minsize"], int) - assert isinstance(kwargs["threshold"], list) - assert isinstance(kwargs["factor"], float) - - if kwargs["minsize"] < 10: - valid = False - elif not all(0.0 < threshold <= 1.0 for threshold in kwargs['threshold']): - valid = False - elif not 0.0 < kwargs['factor'] < 1.0: - valid = False - - if not valid: - kwargs = {} - logger.warning("Invalid MTCNN options in config. Running with defaults") - - logger.debug("Using mtcnn kwargs: %s", kwargs) - return kwargs - - def init_model(self) -> None: - """ Initialize MTCNN Model. """ - assert isinstance(self.model_path, list) - placeholder_shape = (self.batchsize, self.input_size, self.input_size, 3) + model = MTCNNModel(weights, + self.device, + input_size=self.input_size, + min_size=cfg.min_size(), + threshold=threshold, + factor=cfg.scalefactor()) + + placeholder_shape = (self.batch_size, self.input_size, self.input_size, 3) placeholder = np.zeros(placeholder_shape, dtype="float32") - assert isinstance(self._kwargs["input_size"], int) - assert isinstance(self._kwargs["minsize"], int) - assert isinstance(self._kwargs["threshold"], list) - assert isinstance(self._kwargs["factor"], float) - - with self.get_device_context(cfg.cpu()): - self.model = MTCNN(self.model_path, - self.batchsize, - input_size=self._kwargs["input_size"], - minsize=self._kwargs["minsize"], - threshold=self._kwargs["threshold"], - factor=self._kwargs["factor"]) - self.model.detect_faces(placeholder) + model.detect_faces(placeholder) + logger.debug("[%s] Loaded model", self.name) + return model - def process_input(self, batch: BatchType) -> None: - """ Compile the detection image(s) for prediction + def pre_process(self, batch: np.ndarray) -> np.ndarray: + """Compile the detection image(s) for prediction. No further pre-processing required for + MTCNN Parameters ---------- - batch: :class:`~plugins.extract.detect._base.DetectorBatch` - Contains the batch that is currently being passed through the plugin process + batch + The input batch of images at model input size in the correct color order + + Returns + ------- + The batch of images ready for feeding the model """ - batch.feed = (np.array(batch.image, dtype="float32") - 127.5) / 127.5 + return batch - def predict(self, feed: np.ndarray) -> np.ndarray: - """ Run model to get predictions + def process(self, batch: np.ndarray) -> np.ndarray: + """Run model to get predictions Parameters ---------- - batch: :class:`~plugins.extract.detect._base.DetectorBatch` - Contains the batch to pass through the MTCNN model + batch + A batch of images ready to feed the model Returns ------- - dict - The batch with the predictions added to the dictionary + The batch of detection results from the model """ - assert isinstance(self.model, MTCNN) - with self.get_device_context(cfg.cpu()): - prediction, points = self.model.detect_faces(feed) + prediction, points = self.model.detect_faces(batch) logger.trace("prediction: %s, mtcnn_points: %s", # type:ignore[attr-defined] prediction, points) return prediction - def process_output(self, batch: BatchType) -> None: - """ MTCNN performs no post processing so the original batch is returned + def post_process(self, batch: np.ndarray) -> np.ndarray: + """Remove confidences from output Parameters ---------- - batch: :class:`~plugins.extract.detect._base.DetectorBatch` - Contains the batch to apply postprocessing to + batch + The detection results for the model + + Returns + ------- + The processed detection bounding box from the model at model input size """ - return + return np.array([p[..., :4] for p in batch], dtype="object") # MTCNN Detector -# Code adapted from: https://github.com/xiangrufan/keras-mtcnn +# Code adapted from: https://github.com/xiangrufan/keras-mtcnn and +# https://github.com/timesler/facenet-pytorch/blob/master/models/mtcnn.py # -# Keras implementation of the face detection / alignment algorithm +# Keras implementation of the face detection / alignment algorithm also # found at # https://github.com/kpzhang93/MTCNN_face_detection_alignment # @@ -155,32 +164,81 @@ def process_output(self, batch: BatchType) -> None: # SOFTWARE. -class PNet(): - """ Keras P-Net model for MTCNN +class PNet(nn.Module): + """PyTorch P-Net model for MTCNN + + Parameters + ---------- + weights_path + The path to the keras model file + """ + def __init__(self, weights_path: str) -> None: + super().__init__() + self.conv1 = nn.Conv2d(3, 10, 3) + self.prelu1 = nn.PReLU(10) + self.pool1 = nn.MaxPool2d(2, 2, ceil_mode=True) + self.conv2 = nn.Conv2d(10, 16, 3) + self.prelu2 = nn.PReLU(16) + self.conv3 = nn.Conv2d(16, 32, 3) + self.prelu3 = nn.PReLU(32) + self.conv4_1 = nn.Conv2d(32, 2, 1) + self.softmax4_1 = nn.Softmax(dim=1) + self.conv4_2 = nn.Conv2d(32, 4, 1) + self.load_state_dict(torch.load(weights_path, map_location="cpu")) + + def forward(self, inputs: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + """PyTorch P-Network Definition for MTCNN + + Parameters + ---------- + inputs + The input tensor to PNet + + Returns + ------- + classifier + The result from PNet classifier + bbox_regress + The result from PNet bbox regression + """ + var_x = self.pool1(self.prelu1(self.conv1(inputs))) + var_x = self.prelu2(self.conv2(var_x)) + var_x = self.prelu3(self.conv3(var_x)) + + classifier = self.softmax4_1(self.conv4_1(var_x)) + bbox_regress = self.conv4_2(var_x) + + return classifier, bbox_regress + + +class PNetRunner(): + """Runner for PyTorch P-Net model for MTCNN Parameters ---------- - weights_path: str + weights_path The path to the keras model file - batch_size: int - The batch size to feed the model - input_size: int + device + The device to use for model inference + input_size The input size of the model - minsize: int, optional + min_size The minimum size of a face to accept as a detection. Default: `20` - threshold: list, optional + threshold Threshold for P-Net """ def __init__(self, weights_path: str, - batch_size: int, + device: torch.device, input_size: int, min_size: int, factor: float, threshold: float) -> None: logger.debug(parse_class_init(locals())) - self._batch_size = batch_size - self._model = self._load_model(weights_path) + self._model = PNet(weights_path) + self._model.to(device, + memory_format=torch.channels_last) # type:ignore[call-overload] + self.device = device self._input_size = input_size self._threshold = threshold @@ -188,64 +246,30 @@ def __init__(self, self._pnet_scales = self._calculate_scales(min_size, factor) self._pnet_sizes = [(int(input_size * scale), int(input_size * scale)) for scale in self._pnet_scales] - self._pnet_input: list[np.ndarray] | None = None logger.debug("Initialized: %s", self.__class__.__name__) - @staticmethod - def _load_model(weights_path: str) -> Model: - """ Keras P-Network Definition for MTCNN + def _calculate_scales(self, min_size: int, factor: float) -> list[float]: + """Calculate multi-scale Parameters ---------- - weights_path: str - Full path to the model's weights - - Returns - ------- - :class:`keras.models.Model` - The p-net model - """ - input_ = Input(shape=(None, None, 3)) - var_x = Conv2D(10, (3, 3), strides=1, padding='valid', name='conv1')(input_) - var_x = PReLU(shared_axes=[1, 2], name='PReLU1')(var_x) - var_x = MaxPooling2D(pool_size=2)(var_x) - var_x = Conv2D(16, (3, 3), strides=1, padding='valid', name='conv2')(var_x) - var_x = PReLU(shared_axes=[1, 2], name='PReLU2')(var_x) - var_x = Conv2D(32, (3, 3), strides=1, padding='valid', name='conv3')(var_x) - var_x = PReLU(shared_axes=[1, 2], name='PReLU3')(var_x) - classifier = Conv2D(2, (1, 1), activation='softmax', name='conv4-1')(var_x) - bbox_regress = Conv2D(4, (1, 1), name='conv4-2')(var_x) - - retval = Model(input_, [classifier, bbox_regress]) - retval.load_weights(weights_path) - retval.make_predict_function() - return retval - - def _calculate_scales(self, - minsize: int, - factor: float) -> list[float]: - """ Calculate multi-scale - - Parameters - ---------- - minsize: int + min_size Minimum size for a face to be accepted - factor: float + factor Scaling factor Returns ------- - list - List of scale floats + List of scale floats """ factor_count = 0 - var_m = 12.0 / minsize - minl = self._input_size * var_m + var_m = 12.0 / min_size + min_l = self._input_size * var_m # create scale pyramid scales = [] - while minl >= 12: + while min_l >= 12: scales += [var_m * np.power(factor, factor_count)] - minl = minl * factor + min_l = min_l * factor factor_count += 1 logger.trace(scales) # type:ignore[attr-defined] return scales @@ -255,76 +279,77 @@ def _detect_face_12net(self, roi: np.ndarray, size: int, scale: float) -> tuple[np.ndarray, np.ndarray]: - """ Detect face position and calibrate bounding box on 12net feature map(matrix version) + """Detect face position and calibrate bounding box on 12net feature map(matrix version) Parameters ---------- - class_probabilities: :class:`numpy.ndarray` + class_probabilities softmax feature map for face classify - roi: :class:`numpy.ndarray` + roi feature map for regression - size: int + size feature map's largest size - scale: float + scale current input image scale in multi-scales Returns ------- - list - Calibrated face candidates + Calibrated face candidates """ in_side = 2 * size + 11 stride = 0. if size == 1 else float(in_side - 12) / (size - 1) (var_x, var_y) = np.nonzero(class_probabilities >= self._threshold) - boundingbox = np.array([var_x, var_y]).T + bbox = np.array([var_x, var_y]).T - boundingbox = np.concatenate((np.fix((stride * (boundingbox) + 0) * scale), - np.fix((stride * (boundingbox) + 11) * scale)), axis=1) + bbox = np.concatenate((np.fix((stride * (bbox) + 0) * scale), + np.fix((stride * (bbox) + 11) * scale)), axis=1) offset = roi[:4, var_x, var_y].T - boundingbox = boundingbox + offset * 12.0 * scale - rectangles = np.concatenate((boundingbox, + bbox = bbox + offset * 12.0 * scale + rectangles = np.concatenate((bbox, np.array([class_probabilities[var_x, var_y]]).T), axis=1) rectangles = rect2square(rectangles) np.clip(rectangles[..., :4], 0., self._input_size, out=rectangles[..., :4]) pick = np.where(np.logical_and(rectangles[..., 2] > rectangles[..., 0], rectangles[..., 3] > rectangles[..., 1]))[0] - rects = rectangles[pick, :4].astype("int") + rect = rectangles[pick, :4].astype("int") scores = rectangles[pick, 4] - return nms(rects, scores, 0.3, "iou") + return nms(rect, scores, 0.3, "iou") - def __call__(self, images: np.ndarray) -> list[np.ndarray]: - """ first stage - fast proposal network (p-net) to obtain face candidates + def __call__(self, images: np.ndarray) -> list[np.ndarray]: # pylint:disable=too-many-locals + """first stage - fast proposal network (p-net) to obtain face candidates Parameters ---------- - images: :class:`numpy.ndarray` + images The batch of images to detect faces in Returns ------- - List - List of face candidates from P-Net + List of face candidates from P-Net """ batch_size = images.shape[0] rectangles: list[list[list[int | float]]] = [[] for _ in range(batch_size)] scores: list[list[np.ndarray]] = [[] for _ in range(batch_size)] - if self._pnet_input is None: - self._pnet_input = [np.empty((batch_size, rheight, rwidth, 3), dtype="float32") - for rheight, rwidth in self._pnet_sizes] - - for scale, batch, (rheight, rwidth) in zip(self._pnet_scales, - self._pnet_input, - self._pnet_sizes): - _ = [cv2.resize(images[idx], (rwidth, rheight), dst=batch[idx]) - for idx in range(batch_size)] - cls_prob, roi = self._model.predict(batch, verbose=0, batch_size=self._batch_size) - cls_prob = cls_prob[..., 1] + pnet_input = [np.empty((batch_size, r_height, r_width, 3), dtype="float32") + for r_height, r_width in self._pnet_sizes] + + for scale, batch, (r_height, r_width) in zip(self._pnet_scales, + pnet_input, + self._pnet_sizes): + for idx in range(batch_size): + cv2.resize(images[idx], (r_width, r_height), dst=batch[idx]) + + feed = torch.from_numpy(batch.transpose(0, 3, 1, 2)).to( + self.device, + memory_format=torch.channels_last) + with torch.inference_mode(): + cls_prob, roi = (t.cpu().numpy() for t in self._model(feed)) + cls_prob = cls_prob[:, 1] out_side = max(cls_prob.shape[1:3]) cls_prob = np.swapaxes(cls_prob, 1, 2) - roi = np.swapaxes(roi, 1, 3) for idx in range(batch_size): # first index 0 = class score, 1 = one hot representation rect, score = self._detect_face_12net(cls_prob[idx, ...], @@ -338,90 +363,103 @@ def __call__(self, images: np.ndarray) -> list[np.ndarray]: for rect, score in zip(rectangles, scores)] -class RNet(): - """ Keras R-Net model Definition for MTCNN +class RNet(nn.Module): # pylint:disable=too-many-instance-attributes + """PyTorch R-Net model Definition for MTCNN Parameters ---------- - weights_path: str + weights_path + The path to the torch weights file + """ + def __init__(self, weights_path: str) -> None: + super().__init__() + self.conv1 = nn.Conv2d(3, 28, 3) + self.prelu1 = nn.PReLU(28) + self.pool1 = nn.MaxPool2d(3, 2, ceil_mode=True) + self.conv2 = nn.Conv2d(28, 48, 3) + self.prelu2 = nn.PReLU(48) + self.pool2 = nn.MaxPool2d(3, 2, ceil_mode=True) + self.conv3 = nn.Conv2d(48, 64, 2) + self.prelu3 = nn.PReLU(64) + self.dense4 = nn.Linear(576, 128) + self.prelu4 = nn.PReLU(128) + self.dense5_1 = nn.Linear(128, 2) + self.softmax5_1 = nn.Softmax(dim=1) + self.dense5_2 = nn.Linear(128, 4) + self.load_state_dict(torch.load(weights_path, map_location="cpu")) + + def forward(self, inputs: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + """Keras R-Network Definition for MTCNN + + Parameters + ---------- + inputs + The input to RNet + + Returns + ------- + classifier + The result from RNet classifier + bbox_regress + The result from RNet bbox regression + """ + var_x = self.pool1(self.prelu1(self.conv1(inputs))) + var_x = self.pool2(self.prelu2(self.conv2(var_x))) + var_x = self.prelu3(self.conv3(var_x)) + var_x = var_x.permute(0, 3, 2, 1).contiguous() + var_x = self.prelu4(self.dense4(var_x.view(var_x.shape[0], -1))) + classifier = self.softmax5_1(self.dense5_1(var_x)) + bbox_regress = self.dense5_2(var_x) + return classifier, bbox_regress + + +class RNetRunner(): + """Runner for PyTorch R-Net for MTCNN + + Parameters + ---------- + weights_path The path to the keras model file - batch_size: int - The batch size to feed the model - input_size: int + device + The device to run inference on + input_size The input size of the model - threshold: list, optional + threshold Threshold for R-Net - """ def __init__(self, weights_path: str, - batch_size: int, + device: torch.device, input_size: int, threshold: float) -> None: logger.debug(parse_class_init(locals())) - self._batch_size = batch_size - self._model = self._load_model(weights_path) + self._model = RNet(weights_path) + self._model.to(device, + memory_format=torch.channels_last) # type:ignore[call-overload] + self.device = device self._input_size = input_size self._threshold = threshold logger.debug("Initialized: %s", self.__class__.__name__) - @staticmethod - def _load_model(weights_path: str) -> Model: - """ Keras R-Network Definition for MTCNN - - Parameters - ---------- - weights_path: str - Full path to the model's weights - - Returns - ------- - :class:`keras.models.Model` - The r-net model - """ - input_ = Input(shape=(24, 24, 3)) - var_x = Conv2D(28, (3, 3), strides=1, padding='valid', name='conv1')(input_) - var_x = PReLU(shared_axes=[1, 2], name='prelu1')(var_x) - var_x = MaxPooling2D(pool_size=3, strides=2, padding='same')(var_x) - - var_x = Conv2D(48, (3, 3), strides=1, padding='valid', name='conv2')(var_x) - var_x = PReLU(shared_axes=[1, 2], name='prelu2')(var_x) - var_x = MaxPooling2D(pool_size=3, strides=2)(var_x) - - var_x = Conv2D(64, (2, 2), strides=1, padding='valid', name='conv3')(var_x) - var_x = PReLU(shared_axes=[1, 2], name='prelu3')(var_x) - var_x = Permute((3, 2, 1))(var_x) - var_x = Flatten()(var_x) - var_x = Dense(128, name='conv4')(var_x) - var_x = PReLU(name='prelu4')(var_x) - classifier = Dense(2, activation='softmax', name='conv5-1')(var_x) - bbox_regress = Dense(4, name='conv5-2')(var_x) - - retval = Model(input_, [classifier, bbox_regress]) - retval.load_weights(weights_path) - retval.make_predict_function() - return retval - def _filter_face_24net(self, class_probabilities: np.ndarray, roi: np.ndarray, rectangles: np.ndarray, ) -> np.ndarray: - """ Filter face position and calibrate bounding box on 12net's output + """Filter face position and calibrate bounding box on 12net's output Parameters ---------- - class_probabilities: class:`np.ndarray` + class_probabilities Softmax feature map for face classify - roi: :class:`numpy.ndarray` + roi Feature map for regression - rectangles: list + rectangles 12net's predict Returns ------- - list - rectangles in the format [[x, y, x1, y1, score]] + Rectangles in the format [[x, y, x1, y1, score]] """ prob = class_probabilities[:, 1] pick = np.nonzero(prob >= self._threshold) @@ -439,19 +477,18 @@ def __call__(self, images: np.ndarray, rectangle_batch: list[np.ndarray], ) -> list[np.ndarray]: - """ second stage - refinement of face candidates with r-net + """second stage - refinement of face candidates with r-net Parameters ---------- - images: :class:`numpy.ndarray` + images The batch of images to detect faces in - rectangle_batch: - List of :class:`numpy.ndarray` face candidates from P-Net + rectangle_batch + face candidates from P-Net Returns ------- - List - List of :class:`numpy.ndarray` refined face candidates from R-Net + Refined face candidates from R-Net """ ret: list[np.ndarray] = [] for idx, (rectangles, image) in enumerate(zip(rectangle_batch, images)): @@ -459,107 +496,129 @@ def __call__(self, ret.append(np.array([])) continue - feed_batch = np.empty((rectangles.shape[0], 24, 24, 3), dtype="float32") + batch = np.empty((rectangles.shape[0], 24, 24, 3), dtype="float32") - _ = [cv2.resize(image[rect[1]: rect[3], rect[0]: rect[2]], - (24, 24), - dst=feed_batch[idx]) - for idx, rect in enumerate(rectangles)] + for idx, rect in enumerate(rectangles): + cv2.resize(image[rect[1]: rect[3], rect[0]: rect[2]], (24, 24), dst=batch[idx]) + + feed = torch.from_numpy(batch.transpose(0, 3, 1, 2)).to( + self.device, + memory_format=torch.channels_last) + with torch.inference_mode(): + cls_prob, roi_prob = (t.cpu().numpy() for t in self._model(feed)) - cls_prob, roi_prob = self._model.predict(feed_batch, - verbose=0, - batch_size=self._batch_size) ret.append(self._filter_face_24net(cls_prob, roi_prob, rectangles)) return ret -class ONet(): - """ Keras O-Net model for MTCNN +class ONet(nn.Module): # pylint:disable=too-many-instance-attributes + """PyTorch O-Net model Definition for MTCNN Parameters ---------- - weights_path: str + weights_path + The path to the torch weights file + """ + def __init__(self, weights_path: str) -> None: + super().__init__() + self.conv1 = nn.Conv2d(3, 32, 3) + self.prelu1 = nn.PReLU(32) + self.pool1 = nn.MaxPool2d(3, 2, ceil_mode=True) + self.conv2 = nn.Conv2d(32, 64, 3) + self.prelu2 = nn.PReLU(64) + self.pool2 = nn.MaxPool2d(3, 2, ceil_mode=True) + self.conv3 = nn.Conv2d(64, 64, 3) + self.prelu3 = nn.PReLU(64) + self.pool3 = nn.MaxPool2d(2, 2, ceil_mode=True) + self.conv4 = nn.Conv2d(64, 128, 2) + self.prelu4 = nn.PReLU(128) + self.dense5 = nn.Linear(1152, 256) + self.prelu5 = nn.PReLU(256) + self.dense6_1 = nn.Linear(256, 2) + self.softmax6_1 = nn.Softmax(dim=1) + self.dense6_2 = nn.Linear(256, 4) + self.dense6_3 = nn.Linear(256, 10) + self.load_state_dict(torch.load(weights_path, map_location="cpu")) + + def forward(self, inputs: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Keras O-Network Definition for MTCNN + + Parameters + ---------- + inputs + The input to ONet + + Returns + ------- + classifier + The result from ONet classifier + bbox_regress + The result from ONet bbox regression + landmark_regress + The result from ONet landmark regression + """ + var_x = self.pool1(self.prelu1(self.conv1(inputs))) + var_x = self.pool2(self.prelu2(self.conv2(var_x))) + var_x = self.pool3(self.prelu3(self.conv3(var_x))) + var_x = self.prelu4(self.conv4(var_x)) + var_x = var_x.permute(0, 3, 2, 1).contiguous() + var_x = self.prelu5(self.dense5(var_x.view(var_x.shape[0], -1))) + classifier = self.softmax6_1(self.dense6_1(var_x)) + bbox_regress = self.dense6_2(var_x) + landmark_regress = self.dense6_3(var_x) + return classifier, bbox_regress, landmark_regress + + +class ONetRunner(): + """Keras O-Net model for MTCNN + + Parameters + ---------- + weights_path The path to the keras model file - batch_size: int - The batch size to feed the model - input_size: int + device + The device to run inference on + input_size The input size of the model - threshold: list, optional + threshold Threshold for O-Net """ def __init__(self, weights_path: str, - batch_size: int, + device: torch.device, input_size: int, threshold: float) -> None: logger.debug(parse_class_init(locals())) - self._batch_size = batch_size - self._model = self._load_model(weights_path) + self._model = ONet(weights_path) + self._model.to(device, + memory_format=torch.channels_last) # type:ignore[call-overload] + self.device = device self._input_size = input_size self._threshold = threshold logger.debug("Initialized: %s", self.__class__.__name__) - @staticmethod - def _load_model(weights_path: str) -> Model: - """ Keras P-Network Definition for MTCNN - - Parameters - ---------- - weights_path: str - Full path to the model's weights - - Returns - ------- - :class:`keras.models.Model` - The p-net model - """ - input_ = Input(shape=(48, 48, 3)) - var_x = Conv2D(32, (3, 3), strides=1, padding='valid', name='conv1')(input_) - var_x = PReLU(shared_axes=[1, 2], name='prelu1')(var_x) - var_x = MaxPooling2D(pool_size=3, strides=2, padding='same')(var_x) - var_x = Conv2D(64, (3, 3), strides=1, padding='valid', name='conv2')(var_x) - var_x = PReLU(shared_axes=[1, 2], name='prelu2')(var_x) - var_x = MaxPooling2D(pool_size=3, strides=2)(var_x) - var_x = Conv2D(64, (3, 3), strides=1, padding='valid', name='conv3')(var_x) - var_x = PReLU(shared_axes=[1, 2], name='prelu3')(var_x) - var_x = MaxPooling2D(pool_size=2)(var_x) - var_x = Conv2D(128, (2, 2), strides=1, padding='valid', name='conv4')(var_x) - var_x = PReLU(shared_axes=[1, 2], name='prelu4')(var_x) - var_x = Permute((3, 2, 1))(var_x) - var_x = Flatten()(var_x) - var_x = Dense(256, name='conv5')(var_x) - var_x = PReLU(name='prelu5')(var_x) - - classifier = Dense(2, activation='softmax', name='conv6-1')(var_x) - bbox_regress = Dense(4, name='conv6-2')(var_x) - landmark_regress = Dense(10, name='conv6-3')(var_x) - retval = Model(input_, [classifier, bbox_regress, landmark_regress]) - retval.load_weights(weights_path) - retval.make_predict_function() - return retval - def _filter_face_48net(self, class_probabilities: np.ndarray, roi: np.ndarray, points: np.ndarray, rectangles: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - """ Filter face position and calibrate bounding box on 12net's output + """Filter face position and calibrate bounding box on 12net's output Parameters ---------- - class_probabilities: :class:`numpy.ndarray` : class_probabilities[1] is face possibility - Array of face probabilities - roi: :class:`numpy.ndarray` + class_probabilities + class_probabilities[1] is face possibility. Array of face probabilities + roi offset - points: :class:`numpy.ndarray` + points 5 point face landmark - rectangles: :class:`numpy.ndarray` + rectangles 12net's predict, rectangles[i][0:3] is the position, rectangles[i][4] is score Returns ------- - boxes: :class:`numpy.ndarray` + boxes The [l, t, r, b, score] bounding boxes - points: :class:`numpy.ndarray` + points The 5 point landmarks """ prob = class_probabilities[:, 1] @@ -588,19 +647,18 @@ def __call__(self, images: np.ndarray, rectangle_batch: list[np.ndarray] ) -> list[tuple[np.ndarray, np.ndarray]]: - """ Third stage - further refinement and facial landmarks positions with o-net + """Third stage - further refinement and facial landmarks positions with o-net Parameters ---------- - images: :class:`numpy.ndarray` + images The batch of images to detect faces in - rectangle_batch: + rectangle_batch List of :class:`numpy.ndarray` face candidates from R-Net Returns ------- - List - List of refined final candidates, scores and landmark points from O-Net + List of refined final candidates, scores and landmark points from O-Net """ ret: list[tuple[np.ndarray, np.ndarray]] = [] for idx, rectangles in enumerate(rectangle_batch): @@ -608,62 +666,63 @@ def __call__(self, ret.append((np.empty((0, 5)), np.empty(0))) continue image = images[idx] - feed_batch = np.empty((rectangles.shape[0], 48, 48, 3), dtype="float32") + batch = np.empty((rectangles.shape[0], 48, 48, 3), dtype="float32") - _ = [cv2.resize(image[rect[1]: rect[3], rect[0]: rect[2]], - (48, 48), - dst=feed_batch[idx]) - for idx, rect in enumerate(rectangles)] + for i, rect in enumerate(rectangles): + cv2.resize(image[rect[1]: rect[3], rect[0]: rect[2]], (48, 48), dst=batch[i]) - cls_probs, roi_probs, pts_probs = self._model.predict(feed_batch, - verbose=0, - batch_size=self._batch_size) + feed = torch.from_numpy(batch.transpose(0, 3, 1, 2)).to( + self.device, + memory_format=torch.channels_last) + with torch.inference_mode(): + cls_probs, roi_probs, pts_probs = (t.cpu().numpy() + for t in self._model(feed)) ret.append(self._filter_face_48net(cls_probs, roi_probs, pts_probs, rectangles)) return ret -class MTCNN(): - """ MTCNN Detector for face alignment +class MTCNNModel(): + """MTCNN Detector for face alignment Parameters ---------- - weights_path: list + weights_path List of paths to the 3 MTCNN subnet weights - batch_size: int - The batch size to feed the model - input_size: int, optional + device + The device to run inference on + input_size The height, width input size to the model. Default: 640 - minsize: int, optional + min_size The minimum size of a face to accept as a detection. Default: `20` - threshold: list, optional + threshold List of floats for the three steps, Default: `[0.6, 0.7, 0.7]` - factor: float, optional + factor The factor used to create a scaling pyramid of face sizes to detect in the image. Default: `0.709` """ def __init__(self, weights_path: list[str], - batch_size: int, + device: torch.device, input_size: int = 640, - minsize: int = 20, + min_size: int = 20, threshold: list[float] | None = None, factor: float = 0.709) -> None: logger.debug(parse_class_init(locals())) threshold = [0.6, 0.7, 0.7] if threshold is None else threshold - self._pnet = PNet(weights_path[0], - batch_size, - input_size, - minsize, - factor, - threshold[0]) - self._rnet = RNet(weights_path[1], - batch_size, - input_size, - threshold[1]) - self._onet = ONet(weights_path[2], - batch_size, - input_size, - threshold[2]) + self._pnet = PNetRunner(weights_path[0], + device, + input_size, + min_size, + factor, + threshold[0]) + self._rnet = RNetRunner(weights_path[1], + device, + input_size, + threshold[1]) + self._onet = ONetRunner(weights_path[2], + device, + input_size, + threshold[2]) logger.debug("Initialized: %s", self.__class__.__name__) def detect_faces(self, batch: np.ndarray) -> tuple[np.ndarray, tuple[np.ndarray]]: @@ -671,14 +730,12 @@ def detect_faces(self, batch: np.ndarray) -> tuple[np.ndarray, tuple[np.ndarray] Parameters ---------- - batch: :class:`numpy.ndarray` + batch The input batch of images to detect face in Returns ------- - List - list of numpy arrays containing the bounding box and 5 point landmarks - of detected faces + List of numpy arrays containing the bounding box and 5 point landmarks of detected faces """ rectangles = self._pnet(batch) rectangles = self._rnet(batch, rectangles) @@ -691,24 +748,23 @@ def nms(rectangles: np.ndarray, scores: np.ndarray, threshold: float, method: str = "iom") -> tuple[np.ndarray, np.ndarray]: - """ apply non-maximum suppression on ROIs in same scale(matrix version) + """Apply non-maximum suppression on ROIs in same scale(matrix version) Parameters ---------- - rectangles: :class:`np.ndarray` + rectangles The [b, l, t, r, b] bounding box detection candidates - threshold: float - Threshold for succesful match - method: str, optional - "iom" method or default. Defalt: "iom" + threshold + Threshold for successful match + method + "iom" method or default. default: "iom" Returns ------- - rectangles: :class:`np.ndarray` + rectangles The [b, l, t, r, b] bounding boxes - scores :class:`np.ndarray` + scores The associated scores for the rectangles - """ if not np.any(rectangles): return rectangles, scores @@ -739,17 +795,16 @@ def nms(rectangles: np.ndarray, def rect2square(rectangles: np.ndarray) -> np.ndarray: - """ change rectangles into squares (matrix version) + """change rectangles into squares (matrix version) Parameters ---------- - rectangles: :class:`numpy.ndarray` + rectangles [b, x, y, x1, y1] rectangles Return ------ - list - Original rectangle changed to a square + Original rectangle changed to a square """ width = rectangles[:, 2] - rectangles[:, 0] height = rectangles[:, 3] - rectangles[:, 1] diff --git a/plugins/extract/detect/mtcnn_defaults.py b/plugins/extract/detect/mtcnn_defaults.py index 8c4517b7ec..f08f9cf3a9 100755 --- a/plugins/extract/detect/mtcnn_defaults.py +++ b/plugins/extract/detect/mtcnn_defaults.py @@ -31,12 +31,12 @@ HELPTEXT = ( "MTCNN Detector options.\n" - "Fast on GPU, slow on CPU. Uses fewer resources than other GPU detectors but can often return " - "more false positives." + "Fast on CPU, Faster on GPU. Uses fewer resources than other GPU detectors but can often " + "return more false positives." ) -minsize = ConfigItem( +min_size = ConfigItem( datatype=int, default=20, group="settings", @@ -58,11 +58,9 @@ default=8, group="settings", info="The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered.", + "but setting it too high can harm performance.", rounding=1, - min_max=(1, 64)) + min_max=(1, 256)) cpu = ConfigItem( datatype=bool, diff --git a/plugins/extract/detect/retinaface.py b/plugins/extract/detect/retinaface.py new file mode 100644 index 0000000000..60da05c72d --- /dev/null +++ b/plugins/extract/detect/retinaface.py @@ -0,0 +1,663 @@ +#! /usr/env/bin/python3 +"""Retina face detector adapted from: https://github.com/biubug6/Pytorch_Retinaface + +MIT License + +Copyright (c) 2019 Sefik Ilkin Serengil + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from __future__ import annotations + +import typing as T +from itertools import product +from math import ceil + +import numpy as np +import torch +from torch import nn +from torch.nn import functional as F + +import torchvision.models as tv_models +import torchvision.models._utils as tv_utils + +from lib.utils import get_module_objects, GetModel +from plugins.extract.base import ExtractPlugin + +from . import retinaface_defaults as cfg + +if T.TYPE_CHECKING: + import numpy.typing as npt +# pylint:disable=duplicate-code + + +class RetinaFace(ExtractPlugin): + """RetinaFace detector for face detection""" + def __init__(self) -> None: + super().__init__(input_size=640, + batch_size=cfg.batch_size(), + is_rgb=True, + dtype="float32", + scale=(0, 255), + force_cpu=cfg.cpu()) + self.model: RetinaFaceModel + self._average_img = np.array([[104.0, 117.0, 123.0]], dtype="float32") + self._confidence = cfg.confidence() / 100 + self._variance = [0.1, 0.2] + self._priors = self._generate_priors() + self._keep_top_k = 750 + self._nms_threshold = 0.4 + + def _generate_priors(self, clip: bool = False # pylint:disable=too-many-locals + ) -> npt.NDArray[np.float32]: + """Generate the anchor boxes for the image size + + Parameters + ---------- + clip + ``True`` to clip the output to 0-1. Default: ``False`` + + Returns + ------- + The pre-computed priors in center-offset form shape: (1, num_priors, 4) + """ + steps = [8, 16, 32] + min_sizes = [[16, 32], [64, 128], [256, 512]] + feature_maps = [[ceil(self.input_size / step), ceil(self.input_size / step)] + for step in steps] + anchors = [] + + for sizes, feats, step in zip(min_sizes, feature_maps, steps): + for i, j in product(range(feats[0]), range(feats[1])): + for min_size in sizes: + s_kx = min_size / self.input_size + s_ky = min_size / self.input_size + dense_cx = [x * step / self.input_size for x in [j + 0.5]] + dense_cy = [y * step / self.input_size for y in [i + 0.5]] + for cy, cx in product(dense_cy, dense_cx): + anchors += [cx, cy, s_kx, s_ky] + + output = np.array(anchors, dtype="float32").reshape(-1, 4) + if clip: + output.clip(0, 1) + return output[None] + + def load_model(self) -> RetinaFaceModel: + """Initialize RetinaFace Model + + Returns + ------- + The loaded RetinaFace model + """ + backbone = T.cast(T.Literal["resnet", "mobilenet"], cfg.backbone()) + assert backbone in ("resnet", "mobilenet") + vers = 1 if backbone == "resnet" else 2 + weights = GetModel(f"retinaface_v{vers}.pth", 32).model_path + assert isinstance(weights, str) + return T.cast(RetinaFaceModel, self.load_torch_model(RetinaFaceModel(backbone), weights)) + + def pre_process(self, batch: np.ndarray) -> np.ndarray: + """Compile the detection image(s) for prediction + + Parameters + ---------- + batch + The input batch of images at model input size in the correct color order, dtype and + scale + + Returns + ------- + The batch of images ready for feeding the model + """ + return (batch - self._average_img).transpose(0, 3, 1, 2) + + def process(self, batch: np.ndarray) -> np.ndarray: + """Run model to get predictions + + Parameters + ---------- + batch + A batch of images ready to feed the model + + Returns + ------- + The batch of detection results from the model + """ + return self.from_torch(batch) + + def _decode(self, locations: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: + """Decode locations from the model using priors to undo the encoding we did for offset + regression at train time. + + Parameters + ---------- + locations + batch of location predictions from the model filtered by score, Shape: + [batch_size, filtered_num_priors, 4] + priors + The pre-computed priors filtered by score, Shape: [1, filtered_num_priors, 4] + + Returns + ------- + Decoded bounding box predictions + """ + boxes = np.concatenate([ + self._priors[..., :2] + locations[..., :2] * self._variance[0] * self._priors[..., 2:], + self._priors[..., 2:] * np.exp(locations[..., 2:] * self._variance[1])], axis=2) + boxes[..., :2] -= boxes[..., 2:] / 2 + boxes[..., 2:] += boxes[..., :2] + return T.cast("npt.NDArray[np.float32]", boxes) + + def _nms(self, boxes: npt.NDArray[np.float32] # pylint:disable=too-many-locals + ) -> npt.NDArray[np.float32]: + """Perform Non-Maximum Suppression + + Parameters + ---------- + boxes + The detection bounding boxes to process + + Returns + ------- + The final bounding boxes + """ + x1 = boxes[:, 0] + y1 = boxes[:, 1] + x2 = boxes[:, 2] + y2 = boxes[:, 3] + scores = boxes[:, 4] + areas = (x2 - x1 + 1) * (y2 - y1 + 1) + order = scores.argsort()[::-1] + keep = [] + while order.size > 0: + i = order[0] + keep.append(i) + xx1 = np.maximum(x1[i], x1[order[1:]]) + yy1 = np.maximum(y1[i], y1[order[1:]]) + xx2 = np.minimum(x2[i], x2[order[1:]]) + yy2 = np.minimum(y2[i], y2[order[1:]]) + + w = np.maximum(0.0, xx2 - xx1 + 1) + h = np.maximum(0.0, yy2 - yy1 + 1) + inter = w * h + ovr = inter / (areas[i] + areas[order[1:]] - inter) + + order = order[1:][ovr <= self._nms_threshold] + + return boxes[keep] + + def post_process(self, batch: np.ndarray) -> np.ndarray: + """Process the output from the model to bounding boxes + + Parameters + ---------- + batch + The output predictions from the S3FD model + + Returns + ------- + The processed detection bounding box from the model at model input size + """ + locs, confidence = batch + batch_boxes = self._decode(locs) * self.input_size + batch_scores = T.cast("npt.NDArray[np.float32]", confidence[:, :, 1]) + batch_mask = batch_scores > self._confidence + final_boxes = [] + for boxes, scores, mask in zip(batch_boxes, batch_scores, batch_mask): + scores = scores[mask] + if scores.size == 0: + final_boxes.append(np.empty((0, 5), dtype="float32")) + continue + + boxes = boxes[mask] + order = np.argsort(scores)[::-1][:self._keep_top_k] + detections = np.hstack([boxes[order], scores[order][:, None]]) + final_boxes.append(self._nms(detections)[..., :4]) + + retval = np.empty(len(final_boxes), dtype=object) + retval[:] = final_boxes + return retval + + +def conv_bn(in_channels: int, + out_channels: int, + kernel: int = 3, + stride: int = 1, + padding: int = 1, + use_relu: bool = False, + leaky: float = 0.0 + ) -> torch.nn.Sequential: + """Generates a Conv Batch Norm sequential module for RetinaFace + + Parameters + ---------- + in_channels + The number of input channels + out_channels + The number of output channels + kernel + The kernel size. Default: 3 + stride + The number of strides. Default: 1 + padding + The padding to apply. Default: 1 + use_relu + ``True`` to use LeakyReLU activation + leaky + The negative float value for the LeakyReLU. Default: 0.0 + + Returns + ------- + The built sequential module + """ + layers = [nn.Conv2d(in_channels, out_channels, kernel, stride, padding, bias=False), + nn.BatchNorm2d(out_channels)] + if use_relu: + layers.append(nn.LeakyReLU(negative_slope=leaky, inplace=True)) + return nn.Sequential(*layers) + + +def conv_dw(in_channels: int, out_channels: int, stride: int, leaky=0.1) -> torch.nn.Sequential: + """Generates a double Conv Batch Norm sequential module for RetinaFace + + Parameters + ---------- + in_channels + The number of input channels + out_channels + The number of output channels + stride + The number of strides. Default: 1 + leaky + The negative float value for the LeakyReLU. Default: 0.0 + + Returns + ------- + The built sequential module + """ + return nn.Sequential( + nn.Conv2d(in_channels, in_channels, 3, stride, 1, groups=in_channels, bias=False), + nn.BatchNorm2d(in_channels), + nn.LeakyReLU(negative_slope=leaky, inplace=True), + nn.Conv2d(in_channels, out_channels, 1, 1, 0, bias=False), + nn.BatchNorm2d(out_channels), + nn.LeakyReLU(negative_slope=leaky, inplace=True), + ) + + +class MobileNetV1(nn.Module): + """MobileNet V1 for use with RetinaFace""" + def __init__(self) -> None: + super().__init__() + self.stage1 = nn.Sequential( + conv_bn(3, 8, kernel=3, stride=2, use_relu=True, leaky=0.1), + conv_dw(8, 16, 1), + conv_dw(16, 32, 2), + conv_dw(32, 32, 1), + conv_dw(32, 64, 2), + conv_dw(64, 64, 1), + ) + self.stage2 = nn.Sequential( + conv_dw(64, 128, 2), + conv_dw(128, 128, 1), + conv_dw(128, 128, 1), + conv_dw(128, 128, 1), + conv_dw(128, 128, 1), + conv_dw(128, 128, 1), + ) + self.stage3 = nn.Sequential( + conv_dw(128, 256, 2), + conv_dw(256, 256, 1), + ) + self.avg = nn.AdaptiveAvgPool2d((1, 1)) + self.fc = nn.Linear(256, 1000) + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + """Forward pass through MobileNetV1 + + Parameters + ---------- + inputs + The input to MobileNetV1 + + Returns + ------- + The output from MobileNetV1 + """ + x = self.stage1(inputs) + x = self.stage2(x) + x = self.stage3(x) + x = self.avg(x) + x = x.view(-1, 256) + return self.fc(x) + + +class SSH(nn.Module): + """SSH Module for RetinaFace + + Parameters + ---------- + in_channels + The number of input channels + out_channels + The number of output channels + """ + def __init__(self, in_channels: int, out_channel: int) -> None: + super().__init__() + assert out_channel % 4 == 0 + leaky = 0.0 + if out_channel <= 64: + leaky = 0.1 + self.conv3X3 = conv_bn(in_channels, # pylint:disable=invalid-name + out_channel // 2, + stride=1, + use_relu=False) + self.conv5X5_1 = conv_bn(in_channels, # pylint:disable=invalid-name + out_channel // 4, + stride=1, + use_relu=True, + leaky=leaky) + self.conv5X5_2 = conv_bn(out_channel // 4, # pylint:disable=invalid-name + out_channel // 4, + stride=1, + use_relu=False) + self.conv7X7_2 = conv_bn(out_channel // 4, # pylint:disable=invalid-name + out_channel // 4, + stride=1, + use_relu=True, + leaky=leaky) + self.conv7x7_3 = conv_bn(out_channel // 4, out_channel // 4, stride=1, use_relu=False) + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + """Forward pass through SSH Module + + Parameters + ---------- + inputs + The input to the SSH Module + + Returns + ------- + The output from SSH Module + """ + conv3x3 = self.conv3X3(inputs) + + conv5x5_1 = self.conv5X5_1(inputs) + conv5x5 = self.conv5X5_2(conv5x5_1) + + conv7x7_2 = self.conv7X7_2(conv5x5_1) + conv7x7 = self.conv7x7_3(conv7x7_2) + + out = torch.cat([conv3x3, conv5x5, conv7x7], dim=1) + return F.relu(out) + + +class FPN(nn.Module): + """FPN Module for RetinaFace + + Parameters + ---------- + in_channels_list + The number of input channels + out_channels + The number of output channels + """ + def __init__(self, in_channels_list: list[int], out_channels: int) -> None: + super().__init__() + leaky = 0.0 + if out_channels <= 64: + leaky = 0.1 + self.output1 = conv_bn(in_channels_list[0], + out_channels, + kernel=1, + stride=1, + padding=0, + use_relu=True, + leaky=leaky) + self.output2 = conv_bn(in_channels_list[1], + out_channels, + kernel=1, + stride=1, + padding=0, + use_relu=True, + leaky=leaky) + self.output3 = conv_bn(in_channels_list[2], + out_channels, + kernel=1, + stride=1, + padding=0, + use_relu=True, + leaky=leaky) + + self.merge1 = conv_bn(out_channels, out_channels, use_relu=True, leaky=leaky) + self.merge2 = conv_bn(out_channels, out_channels, use_relu=True, leaky=leaky) + + def forward(self, inputs: torch.Tensor) -> list[torch.Tensor]: + """Forward pass through FPN Module + + Parameters + ---------- + inputs + The input to the FPN Module + + Returns + ------- + The output from FPN Module + """ + l_inputs = list(inputs.values()) + + output1 = self.output1(l_inputs[0]) + output2 = self.output2(l_inputs[1]) + output3 = self.output3(l_inputs[2]) + + up3 = F.interpolate(output3, size=[output2.size(2), output2.size(3)], mode="nearest") + output2 = output2 + up3 + output2 = self.merge2(output2) + + up2 = F.interpolate(output2, size=[output1.size(2), output1.size(3)], mode="nearest") + output1 = output1 + up2 + output1 = self.merge1(output1) + return [output1, output2, output3] + + +class ClassHead(nn.Module): + """Class Head Module for RetinaFace + + Parameters + ---------- + in_channels + The number of input channels. Default: 512 + num_anchors + The number of anchors. Default: 3 + """ + def __init__(self, in_channels: int = 512, num_anchors: int = 3) -> None: + super().__init__() + self.num_anchors = num_anchors + self.conv1x1 = nn.Conv2d(in_channels, + self.num_anchors * 2, + kernel_size=(1, 1), + stride=1, + padding=0) + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + """Forward pass through ClassHead Module + + Parameters + ---------- + inputs + The input to the ClassHead Module + + Returns + ------- + The output from ClassHead Module + """ + x = self.conv1x1(inputs) + x = x.permute(0, 2, 3, 1).contiguous() + return x.view(x.shape[0], -1, 2) + + +class BboxHead(nn.Module): + """Bounding Box Head Module for RetinaFace + + Parameters + ---------- + in_channels + The number of input channels. Default: 512 + num_anchors + The number of anchors. Default: 3 + """ + def __init__(self, in_channels: int = 512, num_anchors: int = 3) -> None: + super().__init__() + self.conv1x1 = nn.Conv2d(in_channels, + num_anchors * 4, + kernel_size=(1, 1), + stride=1, + padding=0) + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + """Forward pass through BboxHead Module + + Parameters + ---------- + inputs + The input to the BboxHead Module + + Returns + ------- + The output from BboxHead Module + """ + x = self.conv1x1(inputs) + x = x.permute(0, 2, 3, 1).contiguous() + return x.view(x.shape[0], -1, 4) + + +class RetinaFaceModel(nn.Module): + """RetinaFace Model + + Parameters + ---------- + backbone + The backbone to use for RetinaFace + """ + def __init__(self, backbone: T.Literal["mobilenet", "resnet"]) -> None: + super().__init__() + b_bone_cfg = {"mobilenet": {"in_channels": 32, + "out_channel": 64, + "return_layers": {'stage1': 1, 'stage2': 2, 'stage3': 3}}, + "resnet": {"in_channels": 256, + "out_channel": 256, + 'return_layers': {'layer2': 1, 'layer3': 2, 'layer4': 3}}} + self._config = b_bone_cfg[backbone] + self.body = tv_utils.IntermediateLayerGetter( + tv_models.resnet50() if backbone == "resnet" else MobileNetV1(), + self._config["return_layers"] + ) + in_channels_stage2 = T.cast(int, self._config["in_channels"]) + in_channels_list = [ + in_channels_stage2 * 2, + in_channels_stage2 * 4, + in_channels_stage2 * 8, + ] + out_channels = T.cast(int, self._config["out_channel"]) + self.fpn = FPN(in_channels_list, out_channels) + self.ssh1 = SSH(out_channels, out_channels) + self.ssh2 = SSH(out_channels, out_channels) + self.ssh3 = SSH(out_channels, out_channels) + + self.ClassHead = self._make_class_head( # pylint:disable=invalid-name + fpn_num=3, in_channels=out_channels) + self.BboxHead = self._make_bbox_head( # pylint:disable=invalid-name + fpn_num=3, in_channels=out_channels) + + def _make_class_head(self, fpn_num: int = 3, in_channels: int = 64, anchor_num: int = 2 + ) -> torch.nn.ModuleList: + """Make the Class Head for RetinaFace + + Parameters + ---------- + fpn_num + The number of FPN modules. Default: 3 + in_channels + The number of input channels. Default: 64 + num_anchors + The number of anchors. Default: 2 + + Returns + ------- + The Class Head module list + """ + class_head = nn.ModuleList() + for _ in range(fpn_num): + class_head.append(ClassHead(in_channels, anchor_num)) + return class_head + + def _make_bbox_head(self, fpn_num: int = 3, in_channels: int = 64, anchor_num: int = 2 + ) -> torch.nn.ModuleList: + """Make the Bounding Box Head for RetinaFace + + Parameters + ---------- + fpn_num + The number of FPN modules. Default: 3 + in_channels + The number of input channels. Default: 64 + num_anchors + The number of anchors. Default: 2 + + Returns + ------- + The Bounding Box Head module list + """ + bbox_head = nn.ModuleList() + for _ in range(fpn_num): + bbox_head.append(BboxHead(in_channels, anchor_num)) + return bbox_head + + def forward(self, inputs: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + """Forward pass through RetinaFace + + Parameters + ---------- + inputs + The input to the RetinaFace Module + + Returns + ------- + The output from RetinaFace Module + """ + out = self.body(inputs) + + # FPN + fpn = self.fpn(out) + + # SSH + feature1 = self.ssh1(fpn[0]) + feature2 = self.ssh2(fpn[1]) + feature3 = self.ssh3(fpn[2]) + features = [feature1, feature2, feature3] + + bbox_regressions = torch.cat([self.BboxHead[i](feature) + for i, feature in enumerate(features)], dim=1) + classifications = torch.cat([self.ClassHead[i](feature) + for i, feature in enumerate(features)], dim=1) + output = (bbox_regressions, F.softmax(classifications, dim=-1)) + return output + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/detect/retinaface_defaults.py b/plugins/extract/detect/retinaface_defaults.py new file mode 100755 index 0000000000..f23c321dc6 --- /dev/null +++ b/plugins/extract/detect/retinaface_defaults.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +""" The default options for the faceswap S3Fd Detect plugin. + +Defaults files should be named `_defaults.py` + +Any qualifying items placed into this file will automatically get added to the relevant config +.ini files within the faceswap/config folder and added to the relevant GUI settings page. + +The following variable should be defined: + + Parameters + ---------- + HELPTEXT: str + A string describing what this plugin does + +Further plugin configuration options are assigned using: +>>> = ConfigItem(...) + +where is the name of the configuration option to be added (lower-case, alpha-numeric ++ underscore only) and ConfigItem(...) is the [`~lib.config.objects.ConfigItem`] data for the +option. + +See the docstring/ReadtheDocs documentation required parameters for the ConfigItem object. +Items will be grouped together as per their `group` parameter, but otherwise will be processed in +the order that they are added to this module. +from lib.config import ConfigItem +""" +# pylint:disable=duplicate-code +from lib.config import ConfigItem + + +HELPTEXT = ( + "RetinaFace Detector options.\n" + "GPU and CPU versions available." + ) + +cpu = ConfigItem( + datatype=bool, + default=False, + group="settings", + info="Enable CPU mode here to use the CPU for this detector to save some VRAM at a " + "speed cost.") + +backbone = ConfigItem( + datatype=str, + default="resnet", + group="settings", + info="The backbone to use. Resnet is heavier but more reliable, MobileNet is light enough to " + "run on CPU.", + choices=["resnet", "mobilenet"], + gui_radio=True) + +confidence = ConfigItem( + datatype=int, + default=70, + group="settings", + info="The confidence level at which the detector has successfully found a face.\n" + "Higher levels will be more discriminating, lower levels will have more false " + "positives.", + rounding=5, + min_max=(25, 100)) + +batch_size = ConfigItem( + datatype=int, + default=4, + group="settings", + info="The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.", + rounding=1, + min_max=(1, 128)) diff --git a/plugins/extract/detect/s3fd.py b/plugins/extract/detect/s3fd.py index 43a5a4822f..11b8e09464 100644 --- a/plugins/extract/detect/s3fd.py +++ b/plugins/extract/detect/s3fd.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" S3FD Face detection plugin +"""S3FD Face detection plugin https://arxiv.org/abs/1708.05237 Adapted from S3FD Port in FAN: @@ -9,508 +9,177 @@ import logging import typing as T -from scipy.special import logsumexp import numpy as np -from keras.layers import (Concatenate, Conv2D, Input, Layer, Maximum, MaxPooling2D, ZeroPadding2D) -from keras.models import Model -from keras import initializers, ops +import torch +from torch import nn +from torch.nn import functional as F -from lib.logger import parse_class_init -from lib.utils import get_module_objects -from ._base import BatchType, Detector +from lib.utils import get_module_objects, GetModel +from plugins.extract.base import ExtractPlugin from . import s3fd_defaults as cfg -if T.TYPE_CHECKING: - from keras import KerasTensor logger = logging.getLogger(__name__) +# pylint:disable=duplicate-code -class Detect(Detector): - """ S3FD detector for face recognition """ - def __init__(self, **kwargs) -> None: - git_model_id = 11 - model_filename = "s3fd_keras_v2.h5" - super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.model: S3fd - self.name = "S3FD" - self.input_size = 640 - self.vram = 1088 # 1034 in testing - self.vram_per_batch = 960 # 922 in testing - self.batchsize = cfg.batch_size() - - def init_model(self) -> None: - """ Initialize S3FD Model""" - assert isinstance(self.model_path, str) - confidence = cfg.confidence() / 100 - self.model = S3fd(self.model_path, self.batchsize, confidence) - placeholder_shape = (self.batchsize, self.input_size, self.input_size, 3) - placeholder = np.zeros(placeholder_shape, dtype="float32") - self.model(placeholder) - - def process_input(self, batch: BatchType) -> None: - """ Compile the detection image(s) for prediction """ - assert isinstance(self.model, S3fd) - batch.feed = self.model.prepare_batch(np.array(batch.image)) - - def predict(self, feed: np.ndarray) -> np.ndarray: - """ Run model to get predictions """ - assert isinstance(self.model, S3fd) - predictions = self.model(feed) - assert isinstance(predictions, list) - return self.model.finalize_predictions(predictions) - - def process_output(self, batch) -> None: - """ Compile found faces for output """ - return +class S3FD(ExtractPlugin): + """S3FD detector for face detection""" + def __init__(self) -> None: + super().__init__(input_size=640, + batch_size=cfg.batch_size(), + is_rgb=False, + dtype="float32", + scale=(0, 255)) + self.model: S3FDModel + self._model_path = self._get_weights_path() + self._average_img = np.array([104.0, 117.0, 123.0], dtype="float32") + self._confidence = cfg.confidence() / 100 - -################################################################################ -# CUSTOM KERAS LAYERS -################################################################################ -class L2Norm(Layer): # pylint:disable=too-many-ancestors,abstract-method - """ L2 Normalization layer for S3FD. - - Parameters - ---------- - n_channels: int - The number of channels to normalize - scale: float, optional - The scaling for initial weights. Default: `1.0` - """ - def __init__(self, n_channels: int, scale: float = 1.0, **kwargs) -> None: - super().__init__(**kwargs) - self._n_channels = n_channels - self._scale = scale - self.weight = self.add_weight(name="l2norm", - shape=(self._n_channels, ), - trainable=True, - initializer=initializers.Constant(value=self._scale), - dtype="float32") - - def call(self, inputs: KerasTensor, **kwargs # pylint:disable=arguments-differ - ) -> KerasTensor: - """ Call the L2 Normalization Layer. - - Parameters - ---------- - inputs: :class:`keras.KerasTensor` - The input to the L2 Normalization Layer + def _get_weights_path(self) -> str: + """Download the weights, if required, and return the path to the weights files Returns ------- - :class:`keras.KerasTensor`: - The output from the L2 Normalization Layer + The path to the downloaded S3FD weights file """ - norm = ops.sqrt(ops.sum(ops.power(inputs, 2), axis=-1, keepdims=True)) + 1e-10 - var_x = inputs / norm * self.weight - return var_x + model = GetModel(model_filename="s3fd_torch_v3.pth", git_model_id=11) + model_path = model.model_path + assert isinstance(model_path, str) + return model_path - def get_config(self) -> dict: - """ Returns the config of the layer. + def load_model(self) -> S3FDModel: + """Load the S3FD Model Returns ------- - dict - The configuration for the layer + The loaded S3FD model """ - config = super().get_config() - config.update({"n_channels": self._n_channels, - "scale": self._scale}) - return config - - -class SliceO2K(Layer): # pylint:disable=too-many-ancestors,abstract-method - """ Custom Keras Slice layer generated by onnx2keras. """ - def __init__(self, - starts: list[int], - ends: list[int], - axes: list[int] | None = None, - steps: list[int] | None = None, - **kwargs) -> None: - self._starts = starts - self._ends = ends - self._axes = axes - self._steps = steps - super().__init__(**kwargs) - - def _get_slices(self, dimensions: int) -> list[tuple[int, ...]]: - """ Obtain slices for the given number of dimensions. + weights = GetModel(model_filename="s3fd_torch_v3.pth", git_model_id=11).model_path + assert isinstance(weights, str) + return T.cast(S3FDModel, self.load_torch_model(S3FDModel(), weights)) - Parameters - ---------- - dimensions: int - The number of dimensions to obtain slices for - - Returns - ------- - list - The slices for the given number of dimensions - """ - axes = tuple(range(dimensions)) if self._axes is None else self._axes - steps = (1,) * len(axes) if self._steps is None else self._steps - assert len(axes) == len(steps) == len(self._starts) == len(self._ends) - return list(zip(axes, self._starts, self._ends, steps)) - - def compute_output_shape(self, input_shape: tuple[int, ...] # pylint:disable=arguments-differ - ) -> tuple[int, ...]: - """Computes the output shape of the layer. - - Assumes that the layer will be built to match that input shape provided. + def pre_process(self, batch: np.ndarray) -> np.ndarray: + """Compile the detection image(s) for prediction Parameters ---------- - input_shape: tuple or list of tuples - Shape tuple (tuple of integers) or list of shape tuples (one per output tensor of the - layer). Shape tuples can include ``None`` for free dimensions, instead of an integer. + batch + The input batch of images at model input size in the correct color order, dtype and + scale Returns ------- - tuple - An output shape tuple. + The batch of images ready for feeding the model """ - in_shape = list(input_shape) - for a_x, start, end, steps in self._get_slices(len(in_shape)): - size = in_shape[a_x] - if a_x == 0: - raise AttributeError("Can not slice batch axis.") - if size is None: - if start < 0 or end < 0: - raise AttributeError("Negative slices not supported on symbolic axes") - logger.warning("Slicing symbolic axis might lead to problems.") - in_shape[a_x] = (end - start) // steps - continue - if start < 0: - start = size - start - if end < 0: - end = size - end - in_shape[a_x] = (min(size, end) - start) // steps - return tuple(in_shape) + return (batch - self._average_img).transpose(0, 3, 1, 2) - def call(self, inputs, **kwargs): # pylint:disable=unused-argument,arguments-differ - """This is where the layer's logic lives. + def process(self, batch: np.ndarray) -> np.ndarray: + """Run model to get predictions Parameters ---------- - inputs: Input tensor, or list/tuple of input tensors. - The input to the layer - **kwargs: Additional keyword arguments. - Required for parent class but unused - Returns - ------- - A tensor or list/tuple of tensors. - The layer output - """ - ax_map = dict((x[0], slice(*x[1:])) for x in self._get_slices(ops.ndim(inputs))) - shape = inputs.shape - slices = [(ax_map[a] if a in ax_map else slice(None)) for a in range(len(shape))] - retval = inputs[tuple(slices)] - return retval - - def get_config(self) -> dict: - """ Returns the config of the layer. + batch + A batch of images ready to feed the model Returns ------- - dict - The configuration for the layer + The batch of detection results from the model """ - config = super().get_config() - config.update({"starts": self._starts, - "ends": self._ends, - "axes": self._axes, - "steps": self._steps}) - return config - + return self.from_torch(batch) -class S3fd(): - """ Keras Network - - Parameters - ---------- - weights_path: str - Full path to the S3FD weights file - batch_size: int - The batch size to feed the model - confidence: float - The confidence level to accept detections at - """ - def __init__(self, weights_path: str, batch_size: int, confidence: float) -> None: - logger.debug(parse_class_init(locals())) - self._batch_size = batch_size - self._model = self._load_model(weights_path) - self.confidence = confidence - self.average_img = np.array([104.0, 117.0, 123.0]) - logger.debug("Initialized: %s", self.__class__.__name__) - - @classmethod - def conv_block(cls, - inputs: KerasTensor, - filters: int, - idx: int, - recursions: int) -> KerasTensor: - """ First round convolutions with zero padding added. + @staticmethod + def decode(location: np.ndarray, priors: np.ndarray) -> np.ndarray: + """Decode locations from predictions using priors to undo the encoding we did for offset + regression at train time. Parameters ---------- - inputs: :class:`keras.KerasTensor` - The input tensor to the convolution block - filters: int - The number of filters - idx: int - The layer index for naming - recursions: int - The number of recursions of the block to perform + location + location predictions for location layers, + priors + Prior boxes in center-offset form. Returns ------- - :class:`keras.KerasTensor` - The output tensor from the convolution block + Decoded bounding box predictions """ - name = f"conv{idx}" - var_x = inputs - for i in range(1, recursions + 1): - rec_name = f"{name}_{i}" - var_x = ZeroPadding2D(1, name=f"{rec_name}.zeropad")(var_x) - var_x = Conv2D(filters, - kernel_size=3, - strides=1, - activation="relu", - name=rec_name)(var_x) - return var_x - - @classmethod - def conv_up(cls, inputs: KerasTensor, filters: int, idx: int) -> KerasTensor: - """ Convolution up filter blocks with zero padding added. - - Parameters - ---------- - inputs: :class:`keras.KerasTensor` - The input tensor to the convolution block - filters: int - The initial number of filters - idx: int - The layer index for naming + variances = [0.1, 0.2] + boxes = np.concatenate((priors[:, :2] + location[:, :2] * variances[0] * priors[:, 2:], + priors[:, 2:] * np.exp(location[:, 2:] * variances[1])), axis=1) + boxes[:, :2] -= boxes[:, 2:] / 2 + boxes[:, 2:] += boxes[:, :2] + return boxes - Returns - ------- - :class:`keras.KerasTensor` - The output tensor from the convolution block - """ - name = f"conv{idx}" - var_x = inputs - for i in range(1, 3): - rec_name = f"{name}_{i}" - size = 1 if i == 1 else 3 - if i == 2: - var_x = ZeroPadding2D(1, name=f"{rec_name}.zeropad")(var_x) - var_x = Conv2D(filters * i, - kernel_size=size, - strides=i, - activation="relu", - name=rec_name)(var_x) - return var_x - - def _load_model(self, weights_path: str) -> Model: - """ Keras S3FD Model Definition, adapted from FAN pytorch implementation. + def _process_bbox(self, # pylint:disable=too-many-locals + o_cls: np.ndarray, + o_reg: np.ndarray, + stride: int) -> list[list[np.ndarray]]: + """Process a bounding box Parameters ---------- - weights_path: str - Full path to the model's weights + o_cls + The class outputs from S3FD + o_reg + The reg outputs from S3FD + stride + The stride to use Returns ------- - :class:`keras.models.Model` - The S3FD model + The bounding boxes with scores """ - input_ = Input(shape=(640, 640, 3)) - var_x = self.conv_block(input_, 64, 1, 2) - var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) - - var_x = self.conv_block(var_x, 128, 2, 2) - var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) - - var_x = self.conv_block(var_x, 256, 3, 3) - f3_3 = var_x - var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) - - var_x = self.conv_block(var_x, 512, 4, 3) - f4_3 = var_x - var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) - - var_x = self.conv_block(var_x, 512, 5, 3) - f5_3 = var_x - var_x = MaxPooling2D(pool_size=2, strides=2)(var_x) - - var_x = ZeroPadding2D(3)(var_x) - var_x = Conv2D(1024, kernel_size=3, strides=1, activation="relu", name="fc6")(var_x) - var_x = Conv2D(1024, kernel_size=1, strides=1, activation="relu", name="fc7")(var_x) - ffc7 = var_x - - f6_2 = self.conv_up(var_x, 256, 6) - f7_2 = self.conv_up(f6_2, 128, 7) - - f3_3 = L2Norm(256, scale=10, name="conv3_3_norm")(f3_3) - f4_3 = L2Norm(512, scale=8, name="conv4_3_norm")(f4_3) - f5_3 = L2Norm(512, scale=5, name="conv5_3_norm")(f5_3) - - classes = [] - regs = [] - - f3_3 = ZeroPadding2D(1)(f3_3) - classes.append(Conv2D(4, kernel_size=3, strides=1, name="conv3_3_norm_mbox_conf")(f3_3)) - regs.append(Conv2D(4, kernel_size=3, strides=1, name="conv3_3_norm_mbox_loc")(f3_3)) - - f4_3 = ZeroPadding2D(1)(f4_3) - classes.append(Conv2D(2, kernel_size=3, strides=1, name="conv4_3_norm_mbox_conf")(f4_3)) - regs.append(Conv2D(4, kernel_size=3, strides=1, name="conv4_3_norm_mbox_loc")(f4_3)) - - f5_3 = ZeroPadding2D(1)(f5_3) - classes.append(Conv2D(2, kernel_size=3, strides=1, name="conv5_3_norm_mbox_conf")(f5_3)) - regs.append(Conv2D(4, kernel_size=3, strides=1, name="conv5_3_norm_mbox_loc")(f5_3)) - - ffc7 = ZeroPadding2D(1)(ffc7) - classes.append(Conv2D(2, kernel_size=3, strides=1, name="fc7_mbox_conf")(ffc7)) - regs.append(Conv2D(4, kernel_size=3, strides=1, name="fc7_mbox_loc")(ffc7)) - - f6_2 = ZeroPadding2D(1)(f6_2) - classes.append(Conv2D(2, kernel_size=3, strides=1, name="conv6_2_mbox_conf")(f6_2)) - regs.append(Conv2D(4, kernel_size=3, strides=1, name="conv6_2_mbox_loc")(f6_2)) - - f7_2 = ZeroPadding2D(1)(f7_2) - classes.append(Conv2D(2, kernel_size=3, strides=1, name="conv7_2_mbox_conf")(f7_2)) - regs.append(Conv2D(4, kernel_size=3, strides=1, name="conv7_2_mbox_loc")(f7_2)) - - # max-out background label - chunks = [SliceO2K(starts=[0], ends=[1], axes=[3], steps=None)(classes[0]), - SliceO2K(starts=[1], ends=[2], axes=[3], steps=None)(classes[0]), - SliceO2K(starts=[2], ends=[3], axes=[3], steps=None)(classes[0]), - SliceO2K(starts=[3], ends=[4], axes=[3], steps=None)(classes[0])] - - bmax = Maximum()([chunks[0], chunks[1], chunks[2]]) - classes[0] = Concatenate()([bmax, chunks[3]]) - - retval = Model(input_, - [classes[0], - regs[0], - classes[1], - regs[1], - classes[2], - regs[2], - classes[3], - regs[3], - classes[4], - regs[4], - classes[5], - regs[5]]) - retval.load_weights(weights_path) - retval.make_predict_function() + retval = [] + for _, h_idx, w_idx in zip(*np.where(o_cls[:, 1, :, :] > 0.05)): + axc, ayc = stride / 2 + w_idx * stride, stride / 2 + h_idx * stride + score = o_cls[0, 1, h_idx, w_idx] + if score < self._confidence: + continue + loc = o_reg[:, :, h_idx, w_idx].copy() + priors = np.array([[axc / 1.0, ayc / 1.0, stride * 4 / 1.0, stride * 4 / 1.0]]) + box = self.decode(loc, priors) + x_1, y_1, x_2, y_2 = box[0] * 1.0 + retval.append([x_1, y_1, x_2, y_2, score]) return retval - def prepare_batch(self, batch: np.ndarray) -> np.ndarray: - """ Prepare a batch for prediction. - - Normalizes the feed images. + def _post_process(self, bbox_list: list[np.ndarray]) -> np.ndarray: + """Perform post processing on output Parameters ---------- - batch: class:`numpy.ndarray` - The batch to be fed to the model + bbox_list + The class and reg outputs from the S3FD model Returns ------- - class:`numpy.ndarray` - The normalized images for feeding to the model + The [N, left, top, right, bottom, score] bounding boxes from the model """ - batch = batch - self.average_img - return batch - - def finalize_predictions(self, bounding_boxes_scales: list[np.ndarray]) -> np.ndarray: - """ Process the output from the model to obtain faces - - Parameters - ---------- - bounding_boxes_scales: list - The output predictions from the S3FD model - """ - ret = [] - batch_size = range(bounding_boxes_scales[0].shape[0]) - for img in batch_size: - bboxlist = [scale[img:img+1] for scale in bounding_boxes_scales] - boxes = self._post_process(bboxlist) - finallist = self._nms(boxes, 0.5) - ret.append(finallist) - return np.array(ret, dtype="object") - - def _process_bbox(self, - ocls: np.ndarray, - oreg: np.ndarray, - stride: int) -> list[list[np.ndarray]]: - """ Process a bounding box """ retval = [] - for pos in zip(*np.where(ocls[:, :, :, 1] > 0.05)): - a_c = stride / 2 + pos[2] * stride, stride / 2 + pos[1] * stride - score = ocls[0, pos[1], pos[2], 1] - if score >= self.confidence: - loc = np.ascontiguousarray(oreg[0, pos[1], pos[2], :]).reshape((1, 4)) - priors = np.array([[a_c[0] / 1.0, - a_c[1] / 1.0, - stride * 4 / 1.0, - stride * 4 / 1.0]]) - box = self.decode(loc, priors) - x_1, y_1, x_2, y_2 = box[0] * 1.0 - retval.append([x_1, y_1, x_2, y_2, score]) - return retval - - def _post_process(self, bboxlist: list[np.ndarray]) -> np.ndarray: - """ Perform post processing on output - TODO: do this on the batch. - """ - retval = [] - for i in range(len(bboxlist) // 2): - bboxlist[i * 2] = self.softmax(bboxlist[i * 2], axis=3) - for i in range(len(bboxlist) // 2): - ocls, oreg = bboxlist[i * 2], bboxlist[i * 2 + 1] + for i in range(len(bbox_list) // 2): + o_cls, o_reg = bbox_list[i * 2], bbox_list[i * 2 + 1] stride = 2 ** (i + 2) # 4,8,16,32,64,128 - retval.extend(self._process_bbox(ocls, oreg, stride)) + retval.extend(self._process_bbox(o_cls, o_reg, stride)) return_numpy = np.array(retval) if len(retval) != 0 else np.zeros((1, 5)) return return_numpy @staticmethod - def softmax(inp, axis: int) -> np.ndarray: - """Compute softmax values for each sets of scores in x.""" - return np.exp(inp - logsumexp(inp, axis=axis, keepdims=True)) - - @staticmethod - def decode(location: np.ndarray, priors: np.ndarray) -> np.ndarray: - """Decode locations from predictions using priors to undo the encoding we did for offset - regression at train time. + def _nms(boxes: np.ndarray, threshold: float) -> np.ndarray: + """Perform Non-Maximum Suppression Parameters ---------- - location: tensor - location predictions for location layers, - priors: tensor - Prior boxes in center-offset form. + boxes + The detection bounding boxes to process + threshold + The threshold to accept boxes Returns ------- - :class:`numpy.ndarray` - decoded bounding box predictions + The final bounding boxes """ - variances = [0.1, 0.2] - boxes = np.concatenate((priors[:, :2] + location[:, :2] * variances[0] * priors[:, 2:], - priors[:, 2:] * np.exp(location[:, 2:] * variances[1])), axis=1) - boxes[:, :2] -= boxes[:, 2:] / 2 - boxes[:, 2:] += boxes[:, :2] - return boxes - - @staticmethod - def _nms(boxes: np.ndarray, threshold: float) -> np.ndarray: - """ Perform Non-Maximum Suppression """ retained_box_indices = [] areas = (boxes[:, 2] - boxes[:, 0] + 1) * (boxes[:, 3] - boxes[:, 1] + 1) @@ -536,20 +205,192 @@ def _nms(boxes: np.ndarray, threshold: float) -> np.ndarray: ranked_indices = ranked_indices[non_overlapping_boxes + 1] return boxes[retained_box_indices] - def __call__(self, inputs: np.ndarray) -> np.ndarray: - """ Get predictions from the S3FD model + def post_process(self, batch: np.ndarray) -> np.ndarray: + """Process the output from the model to bounding boxes Parameters ---------- - inputs: :class:`numpy.ndarray` - The input to S3FD + batch + The output predictions from the S3FD model Returns ------- - :class:`numpy.ndarray` - The output from S3FD + The processed detection bounding box from the model at model input size """ - return self._model.predict(inputs, verbose=0, batch_size=self._batch_size) + ret = [] + batch_size = range(batch[0].shape[0]) + for img in batch_size: + bbox_list = [scale[img:img+1] for scale in batch] + boxes = self._post_process(bbox_list) + final_list = self._nms(boxes, 0.5) + ret.append(final_list[..., :4]) + retval = np.empty(len(ret), dtype=object) + retval[:] = ret + return retval + + +################################################################################ +# S3FD Net +################################################################################ +class L2Norm(nn.Module): + """L2 Normalization layer for S3FD. + + Parameters + ---------- + n_channels + The number of channels to normalize + scale + The scaling for initial weights. Default: `1.0` + """ + def __init__(self, n_channels: int, scale: float) -> None: + super().__init__() + self.n_channels = n_channels + self.gamma = scale + self.eps = 1e-10 + self.weight = nn.Parameter(torch.Tensor(self.n_channels)) + + def forward(self, inputs: torch.Tensor): + """Call the L2 Normalization Layer. + + Parameters + ---------- + inputs + The input to the L2 Normalization Layer + + Returns + ------- + The output from the L2 Normalization Layer + """ + norm = inputs.pow(2).sum(dim=1, keepdim=True).sqrt() + self.eps + x = inputs / norm * self.weight.view(1, -1, 1, 1) + return x + + +class S3FDModel(nn.Module): # pylint:disable=too-many-instance-attributes + """The S3FD Model, adapted from https://github.com/1adrianb/face-alignment""" + def __init__(self) -> None: + super().__init__() + self.conv1_1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1) + self.conv1_2 = nn.Conv2d(64, 64, kernel_size=3, stride=1, padding=1) + + self.conv2_1 = nn.Conv2d(64, 128, kernel_size=3, stride=1, padding=1) + self.conv2_2 = nn.Conv2d(128, 128, kernel_size=3, stride=1, padding=1) + + self.conv3_1 = nn.Conv2d(128, 256, kernel_size=3, stride=1, padding=1) + self.conv3_2 = nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1) + self.conv3_3 = nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1) + + self.conv4_1 = nn.Conv2d(256, 512, kernel_size=3, stride=1, padding=1) + self.conv4_2 = nn.Conv2d(512, 512, kernel_size=3, stride=1, padding=1) + self.conv4_3 = nn.Conv2d(512, 512, kernel_size=3, stride=1, padding=1) + + self.conv5_1 = nn.Conv2d(512, 512, kernel_size=3, stride=1, padding=1) + self.conv5_2 = nn.Conv2d(512, 512, kernel_size=3, stride=1, padding=1) + self.conv5_3 = nn.Conv2d(512, 512, kernel_size=3, stride=1, padding=1) + + self.fc6 = nn.Conv2d(512, 1024, kernel_size=3, stride=1, padding=3) + self.fc7 = nn.Conv2d(1024, 1024, kernel_size=1, stride=1, padding=0) + + self.conv6_1 = nn.Conv2d(1024, 256, kernel_size=1, stride=1, padding=0) + self.conv6_2 = nn.Conv2d(256, 512, kernel_size=3, stride=2, padding=1) + + self.conv7_1 = nn.Conv2d(512, 128, kernel_size=1, stride=1, padding=0) + self.conv7_2 = nn.Conv2d(128, 256, kernel_size=3, stride=2, padding=1) + + self.conv3_3_norm = L2Norm(256, scale=10) + self.conv4_3_norm = L2Norm(512, scale=8) + self.conv5_3_norm = L2Norm(512, scale=5) + + self.conv3_3_norm_mbox_conf = nn.Conv2d(256, 4, kernel_size=3, stride=1, padding=1) + self.conv3_3_norm_mbox_loc = nn.Conv2d(256, 4, kernel_size=3, stride=1, padding=1) + self.conv4_3_norm_mbox_conf = nn.Conv2d(512, 2, kernel_size=3, stride=1, padding=1) + self.conv4_3_norm_mbox_loc = nn.Conv2d(512, 4, kernel_size=3, stride=1, padding=1) + self.conv5_3_norm_mbox_conf = nn.Conv2d(512, 2, kernel_size=3, stride=1, padding=1) + self.conv5_3_norm_mbox_loc = nn.Conv2d(512, 4, kernel_size=3, stride=1, padding=1) + + self.fc7_mbox_conf = nn.Conv2d(1024, 2, kernel_size=3, stride=1, padding=1) + self.fc7_mbox_loc = nn.Conv2d(1024, 4, kernel_size=3, stride=1, padding=1) + self.conv6_2_mbox_conf = nn.Conv2d(512, 2, kernel_size=3, stride=1, padding=1) + self.conv6_2_mbox_loc = nn.Conv2d(512, 4, kernel_size=3, stride=1, padding=1) + self.conv7_2_mbox_conf = nn.Conv2d(256, 2, kernel_size=3, stride=1, padding=1) + self.conv7_2_mbox_loc = nn.Conv2d(256, 4, kernel_size=3, stride=1, padding=1) + + def forward(self, # pylint:disable=too-many-locals,too-many-statements + inputs: torch.Tensor) -> list[torch.Tensor]: + """Run the forward pass through S3FD + + Parameters + ---------- + inputs + The (N, C, H, W) batch of images to process + + Returns + ------- + The predictions from the S3FD model + """ + h = F.relu(self.conv1_1(inputs), inplace=True) + h = F.relu(self.conv1_2(h), inplace=True) + h = F.max_pool2d(h, 2, 2) + + h = F.relu(self.conv2_1(h), inplace=True) + h = F.relu(self.conv2_2(h), inplace=True) + h = F.max_pool2d(h, 2, 2) + + h = F.relu(self.conv3_1(h), inplace=True) + h = F.relu(self.conv3_2(h), inplace=True) + h = F.relu(self.conv3_3(h), inplace=True) + f3_3 = h + h = F.max_pool2d(h, 2, 2) + + h = F.relu(self.conv4_1(h), inplace=True) + h = F.relu(self.conv4_2(h), inplace=True) + h = F.relu(self.conv4_3(h), inplace=True) + f4_3 = h + h = F.max_pool2d(h, 2, 2) + + h = F.relu(self.conv5_1(h), inplace=True) + h = F.relu(self.conv5_2(h), inplace=True) + h = F.relu(self.conv5_3(h), inplace=True) + f5_3 = h + h = F.max_pool2d(h, 2, 2) + + h = F.relu(self.fc6(h), inplace=True) + h = F.relu(self.fc7(h), inplace=True) + ffc7 = h + h = F.relu(self.conv6_1(h), inplace=True) + h = F.relu(self.conv6_2(h), inplace=True) + f6_2 = h + h = F.relu(self.conv7_1(h), inplace=True) + h = F.relu(self.conv7_2(h), inplace=True) + f7_2 = h + + f3_3 = self.conv3_3_norm(f3_3) + f4_3 = self.conv4_3_norm(f4_3) + f5_3 = self.conv5_3_norm(f5_3) + + cls1 = self.conv3_3_norm_mbox_conf(f3_3) + reg1 = self.conv3_3_norm_mbox_loc(f3_3) + cls2 = self.conv4_3_norm_mbox_conf(f4_3) + reg2 = self.conv4_3_norm_mbox_loc(f4_3) + cls3 = self.conv5_3_norm_mbox_conf(f5_3) + reg3 = self.conv5_3_norm_mbox_loc(f5_3) + cls4 = self.fc7_mbox_conf(ffc7) + reg4 = self.fc7_mbox_loc(ffc7) + cls5 = self.conv6_2_mbox_conf(f6_2) + reg5 = self.conv6_2_mbox_loc(f6_2) + cls6 = self.conv7_2_mbox_conf(f7_2) + reg6 = self.conv7_2_mbox_loc(f7_2) + + # max-out background label + chunk = torch.chunk(cls1, 4, 1) + b_max = torch.max(torch.max(chunk[0], chunk[1]), chunk[2]) + cls1 = torch.cat([b_max, chunk[3]], dim=1) + + outputs = [cls1, reg1, cls2, reg2, cls3, reg3, cls4, reg4, cls5, reg5, cls6, reg6] + for i in range(len(outputs) // 2): + outputs[i * 2] = F.softmax(outputs[i * 2], dim=1) + + return outputs __all__ = get_module_objects(__name__) diff --git a/plugins/extract/detect/s3fd_defaults.py b/plugins/extract/detect/s3fd_defaults.py index 1ecf1948d8..8ef5eeadd4 100755 --- a/plugins/extract/detect/s3fd_defaults.py +++ b/plugins/extract/detect/s3fd_defaults.py @@ -40,7 +40,7 @@ datatype=int, default=70, group="settings", - info="The confidence level at which the detector has succesfully found a face.\n" + info="The confidence level at which the detector has successfully found a face.\n" "Higher levels will be more discriminating, lower levels will have more false " "positives.", rounding=5, @@ -51,9 +51,6 @@ default=4, group="settings", info="The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered." - "\n\tAMD users: A batchsize of 8 requires about 2 GB vram.", + "but setting it too high can harm performance.", rounding=1, - min_max=(1, 64)) + min_max=(1, 128)) diff --git a/plugins/extract/extract_config.py b/plugins/extract/extract_config.py index 2361864cc4..7b7d418079 100644 --- a/plugins/extract/extract_config.py +++ b/plugins/extract/extract_config.py @@ -25,126 +25,139 @@ def set_defaults(self, helptext="") -> None: aligner_min_scale = ConfigItem( - datatype=float, - default=0.03, - group=_("filters"), - info=_( - "Filters out faces below this size. This is a multiplier of the minimum dimension of " - "the frame (i.e. 1280x720 = 720). If the original face extract box is smaller than " - "the minimum dimension times this multiplier, it is considered a false positive and " - "discarded. Faces which are found to be unusually smaller than the frame tend to be " - "misaligned images, except in extreme long-shots. These can be usually be safely " - "discarded."), - min_max=(0.0, 1.0), - rounding=2) - + datatype=float, + default=0.03, + group=_("align"), + info=_( + "Filters out faces below this size. This is a multiplier of the minimum dimension of the " + "frame (i.e. 1280x720 = 720). If the original face extract box is smaller than the " + "minimum dimension times this multiplier, it is considered a false positive and " + "discarded. Faces which are found to be unusually smaller than the frame tend to be " + "misaligned images, except in extreme long-shots. These can be usually be safely " + "discarded."), + min_max=(0.0, 1.0), + rounding=2) aligner_max_scale = ConfigItem( - datatype=float, - default=4.00, - group=_("filters"), - info=_( - "Filters out faces above this size. This is a multiplier of the minimum dimension of " - "the frame (i.e. 1280x720 = 720). If the original face extract box is larger than the " - "minimum dimension times this multiplier, it is considered a false positive and " - "discarded. Faces which are found to be unusually larger than the frame tend to be " - "misaligned images except in extreme close-ups. These can be usually be safely " - "discarded."), - min_max=(0.0, 10.0), - rounding=2) - + datatype=float, + default=4.00, + group=_("align"), + info=_( + "Filters out faces above this size. This is a multiplier of the minimum dimension of the " + "frame (i.e. 1280x720 = 720). If the original face extract box is larger than the minimum " + "dimension times this multiplier, it is considered a false positive and discarded. Faces " + "which are found to be unusually larger than the frame tend to be misaligned images " + "except in extreme close-ups. These can be usually be safely discarded."), + min_max=(0.0, 10.0), + rounding=2) aligner_distance = ConfigItem( - datatype=float, - default=40.0, - group=_("filters"), - info=_( - "Filters out faces who's landmarks are above this distance from an 'average' face. " - "Values above 15 tend to be fairly safe. Values above 10 will remove more false " - "positives, but may also filter out some faces at extreme angles."), - min_max=(0.0, 45.0), - rounding=1) - + datatype=float, + default=40.0, + group=_("align"), + info=_( + "Filters out faces who's landmarks are above this distance from an 'average' face. Values " + "above 15 tend to be fairly safe. Values above 10 will remove more false positives, but " + "may also filter out some faces at extreme angles."), + min_max=(0.0, 45.0), + rounding=1) aligner_roll = ConfigItem( - datatype=float, - default=0.0, - group=_("filters"), - info=_( - "Filters out faces who's calculated roll is greater than zero +/- this value in " - "degrees. Aligned faces should have a roll value close to zero. Values that are a " - "significant distance from 0 degrees tend to be misaligned images. These can usually " - "be safely disgarded."), - min_max=(0.0, 90.0), - rounding=1) - + datatype=float, + default=0.0, + group=_("align"), + info=_( + "Filters out faces who's calculated roll is greater than zero +/- this value in degrees. " + "Aligned faces should have a roll value close to zero. Values that are a significant " + "distance from 0 degrees tend to be misaligned images. These can usually be safely " + "discarded."), + min_max=(0.0, 90.0), + rounding=1) aligner_features = ConfigItem( - datatype=bool, - default=True, - group=_("filters"), - info=_( - "Filters out faces where the lowest point of the aligned face's eye or eyebrow is " - "lower than the highest point of the aligned face's mouth. Any faces where this " - "occurs are misaligned and can be safely disgarded.")) - - -filter_refeed = ConfigItem( - datatype=bool, - default=True, - group=_("filters"), - info=_( - "If enabled, and 're-feed' has been selected for extraction, then interim alignments " - "will be filtered prior to averaging the final landmarks. This can help improve the " - "final alignments by removing any obvious misaligns from the interim results, and may " - "also help pick up difficult alignments. If disabled, then all re-feed results will " - "be averaged.")) - - -save_filtered = ConfigItem( - datatype=bool, - default=False, - group=_("filters"), - info=_( - "If enabled, saves any filtered out images into a sub-folder during the extraction " - "process. If disabled, filtered faces are deleted. Note: The faces will always be " - "filtered out of the alignments file, regardless of whether you keep the faces or " - "not.")) - - -realign_refeeds = ConfigItem( - datatype=bool, - default=True, - group=_("re-align"), - info=_( - "If enabled, and 're-align' has been selected for extraction, then all re-feed " - "iterations are re-aligned. If disabled, then only the final averaged output from re-" - "feed will be re-aligned.")) - - -filter_realign = ConfigItem( - datatype=bool, - default=True, - group=_("re-align"), - info=_( - "If enabled, and 're-align' has been selected for extraction, then any alignments " - "which would be filtered out will not be re-aligned.")) + datatype=bool, + default=True, + group=_("align"), + info=_( + "Filters out faces where the lowest point of the aligned face's eye or eyebrow is lower " + "than the highest point of the aligned face's mouth. Any faces where this occurs are " + "misaligned and can be safely discarded.")) + +mask_storage_size = ConfigItem( + datatype=int, + default=128, + group=_("mask"), + info=_("The size to store masks at. Set to 0 to store at the mask model's output size."), + min_max=(0, 1028), + rounding=64) + +profile_warmup_time = ConfigItem( + datatype=int, + default=2, + group=_("profile"), + info=_("The number of seconds to warmup the model for at each batch size. Higher times will " + "take longer but will collect better data."), + min_max=(1, 10), + rounding=1) + +profile_test_time = ConfigItem( + datatype=int, + default=10, + group=_("profile"), + info=_("The number of seconds to profile the pipeline for at each batch size. Higher times " + "will take longer but will collect better data."), + min_max=(8, 30), + rounding=2) + +profile_num_faces = ConfigItem( + datatype=int, + default=2, + group=_("profile"), + info=_("The average number of faces expected to be detected in each frame. Throughput of " + "detector plugins are dictated by 1 image = 1 sample, however throughput of downstream " + "plugins (align, mask etc) is dependant on how many faces are expected to be seen in " + "each frame. This will vary from source to source. Setting this correctly will lead to " + "better optimization."), + min_max=(1, 10), + rounding=1) + +profile_max_vram = ConfigItem( + datatype=int, + default=85, + group=_("profile"), + info=_("The maximum amount of total GPU VRAM to allow Cuda to reserve when searching for " + "optimal batch sizes. The closer to 100% the more risk of Out of Memory errors whilst " + r"extracting. Anything 90% (85% if compiling) or below should be relatively safe for " + "dedicated use, or set the value lower if you wish to keep VRAM free for other " + "applications."), + min_max=(25, 95), + rounding=1) + +profile_save_config = ConfigItem( + datatype=bool, + default=False, + group=_("profile"), + info=_("Whether to save the discovered plugin batch sizes to Faceswap's config for future " + "use.")) # pylint:disable=duplicate-code -_IS_LOADED: bool = False +_CONFIG: _Config | None = None -def load_config(config_file: str | None = None) -> None: +def load_config(config_file: str | None = None) -> _Config: """ Load the Extraction configuration .ini file Parameters ---------- - config_file : str | None, optional - Path to a custom .ini configuration file to load. Default: ``None`` (use default - configuration file) + Path to a custom .ini configuration file to load. Default: ``None`` (use default configuration + file) + + Returns + ------- + The loaded convert config object """ - global _IS_LOADED # pylint:disable=global-statement - if not _IS_LOADED: - _Config(configfile=config_file) - _IS_LOADED = True + global _CONFIG # pylint:disable=global-statement + if _CONFIG is None: + _CONFIG = _Config(config_file=config_file) + return _CONFIG diff --git a/plugins/extract/extract_media.py b/plugins/extract/extract_media.py deleted file mode 100644 index 22700914ee..0000000000 --- a/plugins/extract/extract_media.py +++ /dev/null @@ -1,214 +0,0 @@ -#!/usr/bin/env python3 -""" Object for holding and manipulating media passing through a faceswap extraction pipeline """ -from __future__ import annotations -import logging -import typing as T - -import cv2 - -from lib.logger import parse_class_init -from lib.utils import get_module_objects - -if T.TYPE_CHECKING: - import numpy as np - from lib.align.alignments import PNGHeaderSourceDict - from lib.align.detected_face import DetectedFace - -logger = logging.getLogger(__name__) - - -class ExtractMedia: - """ An object that passes through the :class:`~plugins.extract.pipeline.Extractor` pipeline. - - Parameters - ---------- - filename: str - The base name of the original frame's filename - image: :class:`numpy.ndarray` - The original frame or a faceswap aligned face image - detected_faces: list, optional - A list of :class:`~lib.align.DetectedFace` objects. Detected faces can be added - later with :func:`add_detected_faces`. Setting ``None`` will default to an empty list. - Default: ``None`` - is_aligned: bool, optional - ``True`` if the :attr:`image` is an aligned faceswap image otherwise ``False``. Used for - face filtering with vggface2. Aligned faceswap images will automatically skip detection, - alignment and masking. Default: ``False`` - """ - - def __init__(self, - filename: str, - image: np.ndarray, - detected_faces: list[DetectedFace] | None = None, - is_aligned: bool = False) -> None: - logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] - self._filename = filename - self._image: np.ndarray | None = image - self._image_shape = T.cast(tuple[int, int, int], image.shape) - self._detected_faces: list[DetectedFace] = ([] if detected_faces is None - else detected_faces) - self._is_aligned = is_aligned - self._frame_metadata: PNGHeaderSourceDict | None = None - self._sub_folders: list[str | None] = [] - - @property - def filename(self) -> str: - """ str: The base name of the :attr:`image` filename. """ - return self._filename - - @property - def image(self) -> np.ndarray: - """ :class:`numpy.ndarray`: The source frame for this object. """ - assert self._image is not None - return self._image - - @property - def image_shape(self) -> tuple[int, int, int]: - """ tuple: The shape of the stored :attr:`image`. """ - return self._image_shape - - @property - def image_size(self) -> tuple[int, int]: - """ tuple: The (`height`, `width`) of the stored :attr:`image`. """ - return self._image_shape[:2] - - @property - def detected_faces(self) -> list[DetectedFace]: - """list: A list of :class:`~lib.align.DetectedFace` objects in the :attr:`image`. """ - return self._detected_faces - - @property - def is_aligned(self) -> bool: - """ bool. ``True`` if :attr:`image` is an aligned faceswap image otherwise ``False`` """ - return self._is_aligned - - @property - def frame_metadata(self) -> PNGHeaderSourceDict: - """ dict: The frame metadata that has been added from an aligned image. This property - should only be called after :func:`add_frame_metadata` has been called when processing - an aligned face. For all other instances an assertion error will be raised. - - Raises - ------ - AssertionError - If frame metadata has not been populated from an aligned image - """ - assert self._frame_metadata is not None - return self._frame_metadata - - @property - def sub_folders(self) -> list[str | None]: - """ list: The sub_folders that the faces should be output to. Used when binning filter - output is enabled. The list corresponds to the list of detected faces - """ - return self._sub_folders - - def get_image_copy(self, color_format: T.Literal["BGR", "RGB", "GRAY"]) -> np.ndarray: - """ Get a copy of the image in the requested color format. - - Parameters - ---------- - color_format: ['BGR', 'RGB', 'GRAY'] - The requested color format of :attr:`image` - - Returns - ------- - :class:`numpy.ndarray`: - A copy of :attr:`image` in the requested :attr:`color_format` - """ - logger.trace("Requested color format '%s' for frame '%s'", # type:ignore[attr-defined] - color_format, self._filename) - image = getattr(self, f"_image_as_{color_format.lower()}")() - return image - - def add_detected_faces(self, faces: list[DetectedFace]) -> None: - """ Add detected faces to the object. Called at the end of each extraction phase. - - Parameters - ---------- - faces: list - A list of :class:`~lib.align.DetectedFace` objects - """ - logger.trace("Adding detected faces for filename: '%s'. " # type:ignore[attr-defined] - "(faces: %s, lrtb: %s)", self._filename, faces, - [(face.left, face.right, face.top, face.bottom) for face in faces]) - self._detected_faces = faces - - def add_sub_folders(self, folders: list[str | None]) -> None: - """ Add detected faces to the object. Called at the end of each extraction phase. - - Parameters - ---------- - folders: list - A list of str sub folder names or ``None`` if no sub folder is required. Should - correspond to the detected faces list - """ - logger.trace("Adding sub folders for filename: '%s'. " # type:ignore[attr-defined] - "(folders: %s)", self._filename, folders,) - self._sub_folders = folders - - def remove_image(self) -> None: - """ Delete the image and reset :attr:`image` to ``None``. - - Required for multi-phase extraction to avoid the frames stacking RAM. - """ - logger.trace("Removing image for filename: '%s'", # type:ignore[attr-defined] - self._filename) - del self._image - self._image = None - - def set_image(self, image: np.ndarray) -> None: - """ Add the image back into :attr:`image` - - Required for multi-phase extraction adds the image back to this object. - - Parameters - ---------- - image: :class:`numpy.ndarry` - The original frame to be re-applied to for this :attr:`filename` - """ - logger.trace("Reapplying image: (filename: `%s`, " # type:ignore[attr-defined] - "image shape: %s)", self._filename, image.shape) - self._image = image - - def add_frame_metadata(self, metadata: PNGHeaderSourceDict) -> None: - """ Add the source frame metadata from an aligned PNG's header data. - - metadata: dict - The contents of the 'source' field in the PNG header - """ - logger.trace("Adding PNG Source data for '%s': %s", # type:ignore[attr-defined] - self._filename, metadata) - dims = T.cast(tuple[int, int], metadata["source_frame_dims"]) - self._image_shape = (*dims, 3) - self._frame_metadata = metadata - - def _image_as_bgr(self) -> np.ndarray: - """ Get a copy of the source frame in BGR format. - - Returns - ------- - :class:`numpy.ndarray`: - A copy of :attr:`image` in BGR color format """ - return self.image[..., :3].copy() - - def _image_as_rgb(self) -> np.ndarray: - """ Get a copy of the source frame in RGB format. - - Returns - ------- - :class:`numpy.ndarray`: - A copy of :attr:`image` in RGB color format """ - return self.image[..., 2::-1].copy() - - def _image_as_gray(self) -> np.ndarray: - """ Get a copy of the source frame in gray-scale format. - - Returns - ------- - :class:`numpy.ndarray`: - A copy of :attr:`image` in gray-scale color format """ - return cv2.cvtColor(self.image.copy(), cv2.COLOR_BGR2GRAY) - - -__all__ = get_module_objects(__name__) diff --git a/plugins/extract/recognition/__init__.py b/plugins/extract/identity/__init__.py similarity index 100% rename from plugins/extract/recognition/__init__.py rename to plugins/extract/identity/__init__.py diff --git a/plugins/extract/identity/t_face.py b/plugins/extract/identity/t_face.py new file mode 100644 index 0000000000..aae7b9d1b9 --- /dev/null +++ b/plugins/extract/identity/t_face.py @@ -0,0 +1,250 @@ +#!/usr/bin python3 +"""Tencent TFace inference""" + +from __future__ import annotations +import logging +import typing as T + +import numpy as np + +from lib.utils import get_module_objects, GetModel +from lib.model.networks.insightface_resnet import ir_50, ir_101 +from plugins.extract.base import FacePlugin +from . import t_face_defaults as cfg + +if T.TYPE_CHECKING: + from lib.model.networks.insightface_resnet import IRNet + +logger = logging.getLogger(__name__) + + +class TFace(FacePlugin): + """Tencent TFace with the IR 50 and IR 101 backbones + + Extracts feature vectors from faces in order to compare similarity. + + From: https://github.com/Tencent/TFace + """ + + def __init__(self) -> None: + super().__init__(input_size=112, + batch_size=cfg.batch_size(), + is_rgb=True, + dtype="float32", + scale=(0, 1), + force_cpu=cfg.cpu(), + centering="legacy") + self._backbone = cfg.backbone() + self.storage_name = f"{self.storage_name}_{self._backbone}" + self.model: IRNet + logger.debug("Initialized %s", self.__class__.__name__) + + def load_model(self) -> IRNet: + """Initialize TFace Model + + Returns + ------- + The loaded TFace model + """ + # pylint:disable=duplicate-code + model = ir_50 if self._backbone == "ir-50" else ir_101 + vers = 1 if self._backbone == "ir-50" else 2 + weights = GetModel(f"tface_v{vers}.pth", 33).model_path + assert isinstance(weights, str) + input_size = T.cast(T.Literal[112, 224], self.input_size) + assert input_size == 112 + return T.cast("IRNet", self.load_torch_model(model(input_size), weights)) + + def pre_process(self, batch: np.ndarray) -> np.ndarray: + """Format the detected faces for prediction + + Parameters + ---------- + batch + The batch of aligned faces in the correct format for the model + + Returns + ------- + The updated images for feeding the model + """ + return batch.transpose(0, 3, 1, 2) + + def process(self, batch: np.ndarray) -> np.ndarray: + """Get the identity matrix from the model + + Parameters + ---------- + batch + The batch to feed into the recognition plugin + + Returns + ------- + The predictions from the plugin + """ + return self.from_torch(batch) + + +__all__ = get_module_objects(__name__) + + +# LICENSE +""" +Copyright (C) 2025 Tencent. All rights reserved. + +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by +Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is +granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are +controlled by, or are under common control with that entity. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the direction or management of such +entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this +License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to +software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a +Source form, including but not limited to compiled object code, generated documentation, and +conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under +the License, as indicated by a copyright notice that is included in or attached to the work (an +example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or +derived from) the Work and for which the editorial revisions, annotations, elaborations, or other +modifications represent, as a whole, an original work of authorship. For the purposes of this +License, Derivative Works shall not include works that remain separable from, or merely link (or +bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and +any modifications or additions to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal +Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, +"submitted" means any form of electronic, verbal, or written communication sent to the Licensor or +its representatives, including but not limited to communication on electronic mailing lists, source +code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor +for the purpose of discussing and improving the Work, but excluding communication that is +conspicuously marked or otherwise designated in writing by the copyright owner as "Not a +Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a +Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. + +Subject to the terms and conditions of this License, each Contributor hereby grants to You a +perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to +reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and +distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. + +Subject to the terms and conditions of this License, each Contributor hereby grants to You a +perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this +section) patent license to make, have made, use, offer to sell, sell, import, and otherwise +transfer the Work, where such license applies only to those patent claims licensable by such +Contributor that are necessarily infringed by their Contribution(s) alone or by combination of +their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute +patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) +alleging that the Work or a Contribution incorporated within the Work constitutes direct or +contributory patent infringement, then any patent licenses granted to You under this License for +that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. + +You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with +or without modifications, and in Source or Object form, provided that You meet the following +conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and +You must cause any modified files to carry prominent notices stating that You changed the files; +and +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, +patent, trademark, and attribution notices from the Source form of the Work, excluding those +notices that do not pertain to any part of the Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works +that You distribute must include a readable copy of the attribution notices contained within such +NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at +least one of the following places: within a NOTICE text file distributed as part of the Derivative +Works; within the Source form or documentation, if provided along with the Derivative Works; or, +within a display generated by the Derivative Works, if and wherever such third-party notices +normally appear. The contents of the NOTICE file are for informational purposes only and do not +modify the License. You may add Your own attribution notices within Derivative Works that You +distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such +additional attribution notices cannot be construed as modifying the License. +You may add Your own copyright statement to Your modifications and may provide additional or +different license terms and conditions for use, reproduction, or distribution of Your +modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and +distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. + +Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in +the Work by You to the Licensor shall be under the terms and conditions of this License, without +any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or +modify the terms of any separate license agreement you may have executed with Licensor regarding +such Contributions. + +6. Trademarks. + +This License does not grant permission to use the trade names, trademarks, service marks, or +product names of the Licensor, except as required for reasonable and customary use in describing +the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. + +Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +ANY KIND, either express or implied, including, without limitation, any warranties or conditions of +TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely +responsible for determining the appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. + +In no event and under no legal theory, whether in tort (including negligence), contract, or +otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or +agreed to in writing, shall any Contributor be liable to You for damages, including any direct, +indirect, special, incidental, or consequential damages of any character arising as a result of +this License or out of the use or inability to use the Work (including but not limited to damages +for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other +commercial damages or losses), even if such Contributor has been advised of the possibility of +such damages. + +9. Accepting Warranty or Additional Liability. + +While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a +fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights +consistent with this License. However, in accepting such obligations, You may act only on Your own +behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or +claims asserted against, such Contributor by reason of your accepting any such warranty or +additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields +enclosed by brackets "[]" replaced with your own identifying information. (Don't include the +brackets!) The text should be enclosed in the appropriate comment syntax for the file format. +We also recommend that a file or class name and description of purpose be included on the same +"printed page" as the copyright notice for easier identification within third-party archives. + +TFace-可信人脸算法框架 is licensed under the Apache License, Version 2.0 +""" diff --git a/plugins/extract/detect/external_defaults.py b/plugins/extract/identity/t_face_defaults.py similarity index 51% rename from plugins/extract/detect/external_defaults.py rename to plugins/extract/identity/t_face_defaults.py index dd112566ac..72f2063fce 100644 --- a/plugins/extract/detect/external_defaults.py +++ b/plugins/extract/identity/t_face_defaults.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 -""" The default options for the faceswap Import Alignments plugin. +""" The default options for the faceswap VGG Face2 recognition plugin. + Defaults files should be named `_defaults.py` @@ -30,31 +31,35 @@ HELPTEXT = ( - "Import Detector options.\n" - "Imports a detected face bounding box from an external .json file.\n" + "Tencent TFace identity recognition.\n" + "(https://github.com/Tencent/TFace)" ) -file_name = ConfigItem( - datatype=str, - default="import.json", +batch_size = ConfigItem( + datatype=int, + default=16, group="settings", - info="The import file should be stored in the same folder as the video (if extracting " - "from a video file) or inside the folder of images (if importing from a folder of " - "images)") + info="The batch size to use. To a point, higher batch sizes equal better performance, " + "but setting it too high can harm performance.", + rounding=1, + min_max=(1, 256)) -origin = ConfigItem( +cpu = ConfigItem( + datatype=bool, + default=False, + group="settings", + info="The IR-50 backbone still runs fairly quickly on CPU on some setups. Enable " + "CPU mode here to use the CPU for this plugin to save some VRAM at a speed cost.") + +backbone = ConfigItem( datatype=str, - default="top-left", - group="output", - info="The origin (0, 0) location of the co-ordinates system used. " - "\n\t top-left: The origin (0, 0) of the canvas is at the top left " - "corner." - "\n\t bottom-left: The origin (0, 0) of the canvas is at the bottom " - "left corner." - "\n\t top-right: The origin (0, 0) of the canvas is at the top right " - "corner." - "\n\t bottom-right: The origin (0, 0) of the canvas is at the bottom " - "right corner.", - choices=["top-left", "bottom-left", "top-right", "bottom-right"], + default="ir-101", + group="settings", + info="The model backbone to use." + "\n\tir-50 - InsightFace ResNet-50 (50 layers). Can run at a reasonable speed " + r"on CPU. Reports 95%-96% accuracy." + "\n\tir-101 - InsightFace ResNet-101 (100 layers). " + r"Reports ~97% accuracy", + choices=["ir-50", "ir-101"], gui_radio=True) diff --git a/plugins/extract/identity/vggface2.py b/plugins/extract/identity/vggface2.py new file mode 100644 index 0000000000..47db40d5c5 --- /dev/null +++ b/plugins/extract/identity/vggface2.py @@ -0,0 +1,245 @@ +#!/usr/bin python3 +"""VGGFace inference""" + +from __future__ import annotations +import logging +import typing as T + +import numpy as np + +from torch import nn +from torch.nn import functional as F + +from lib.utils import get_module_objects, GetModel +from plugins.extract.base import FacePlugin +from . import vggface2_defaults as cfg + +if T.TYPE_CHECKING: + from torch import Tensor + +logger = logging.getLogger(__name__) + + +class VGGFace2(FacePlugin): + """VGGFace2 feature extraction. + + Extracts feature vectors from faces in order to compare similarity. + + Notes + ----- + Input images should be in BGR Order + + Model exported from: https://github.com/WeidiXie/Keras-VGGFace2-ResNet50 which is based on: + https://www.robots.ox.ac.uk/~vgg/software/vgg_face/ + + + Licensed under Creative Commons Attribution License. + https://creativecommons.org/licenses/by-nc/4.0/ + """ + + def __init__(self) -> None: + super().__init__(input_size=224, + batch_size=cfg.batch_size(), + is_rgb=False, + dtype="float32", + scale=(0, 255), + force_cpu=cfg.cpu(), + centering="legacy") + self.model: VGGFace2Model + + # Average image provided in https://github.com/ox-vgg/vgg_face2 + self._average_img = np.array([91.4953, 103.8827, 131.0912], dtype="float32") + logger.debug("Initialized %s", self.__class__.__name__) + + def load_model(self) -> VGGFace2Model: + """Initialize VGG Face 2 Model. + + Returns + ------- + The loaded VGGFace2 model + """ + # pylint:disable=duplicate-code + weights = GetModel("vggface2_resnet50_v3.pth", 10).model_path + assert isinstance(weights, str) + return T.cast(VGGFace2Model, self.load_torch_model(VGGFace2Model(), weights)) + + def pre_process(self, batch: np.ndarray) -> np.ndarray: + """Format the detected faces for prediction + + Parameters + ---------- + batch + The batch of aligned faces in the correct format for the model + + Returns + ------- + The updated images for feeding the model + """ + return (batch - self._average_img).transpose(0, 3, 1, 2) + + def process(self, batch: np.ndarray) -> np.ndarray: + """Get the identity matrix from the model + + Parameters + ---------- + batch + The batch to feed into the recognition plugin + + Returns + ------- + The predictions from the plugin + """ + return self.from_torch(batch) + + +# Model definition +class ConvBlock(nn.Module): + """Convolution block for ResNet50 + + Parameters + ---------- + in_channels + The number of input channels + filters + The filters for the 1st and 2nd conv layers in the main path + kernel + The kernel size of middle conv layer of the block + stride + The stride length for the first and last convolution + """ + def __init__(self, in_channels: int, filters: int, kernel: int, stride: int = 2) -> None: + super().__init__() + bottleneck = filters // 4 + self.reduce_conv = nn.Conv2d(in_channels, bottleneck, 1, stride=stride, bias=False) + self.reduce_bn = nn.BatchNorm2d(bottleneck, eps=0.001, momentum=0.01) + self.conv = nn.Conv2d(bottleneck, bottleneck, kernel, stride=1, padding=1, bias=False) + self.bn = nn.BatchNorm2d(bottleneck, eps=0.001, momentum=0.01) + self.increase_conv = nn.Conv2d(bottleneck, filters, 1, stride=1, bias=False) + self.increase_bn = nn.BatchNorm2d(filters, eps=0.001, momentum=0.01) + self.proj = nn.Conv2d(in_channels, filters, 1, stride=stride, bias=False) + self.proj_bn = nn.BatchNorm2d(filters, eps=0.001, momentum=0.01) + + def forward(self, inputs: Tensor) -> Tensor: + """Call the resnet50 ConvBlock + + Parameters + ---------- + inputs + Input tensor + + Returns + ------- + Output tensor from the ConvBlock + """ + x = F.relu(self.reduce_bn(self.reduce_conv(inputs)), inplace=True) + x = F.relu(self.bn(self.conv(x)), inplace=True) + x = self.increase_bn(self.increase_conv(x)) + residual = self.proj_bn(self.proj(inputs)) + return F.relu(x + residual, inplace=True) + + +class IdentityBlock(nn.Module): + """Identity block for ResNet50 + + Parameters + ---------- + in_channels + The number of input channels + filters + The filters for the 1st and 2nd conv layers in the main path + kernel + The kernel size of middle conv layer of the block + """ + def __init__(self, in_channels: int, filters: int, kernel: int) -> None: + super().__init__() + self.reduce_conv = nn.Conv2d(in_channels, filters, 1, bias=False) + self.reduce_bn = nn.BatchNorm2d(filters, eps=0.001, momentum=0.01) + self.conv = nn.Conv2d(filters, filters, kernel, padding=1, bias=False) + self.bn = nn.BatchNorm2d(filters, eps=0.001, momentum=0.01) + self.increase_conv = nn.Conv2d(filters, in_channels, 1, bias=False) + self.increase_bn = nn.BatchNorm2d(in_channels, eps=0.001, momentum=0.01) + + def forward(self, inputs: Tensor) -> Tensor: + """Call the resnet50 Identity block + + Parameters + ---------- + inputs + Input tensor + + Returns + ------- + Output tensor from the Identity block + """ + x = F.relu(self.reduce_bn(self.reduce_conv(inputs))) + x = F.relu(self.bn(self.conv(x))) + x = self.increase_bn(self.increase_conv(x)) + return F.relu(x + inputs, inplace=True) + + +class ResNet50(nn.Module): + """ResNet50 imported for VGG-Face2 adapted from + https://github.com/WeidiXie/Keras-VGGFace2-ResNet50 + """ + def __init__(self) -> None: + super().__init__() + self.conv = nn.Conv2d(3, 64, 7, stride=2, bias=False) + self.bn = nn.BatchNorm2d(64, eps=0.001, momentum=0.01) + self.block1 = ConvBlock(64, 256, 3, stride=1) + self.id1 = nn.Sequential(*[IdentityBlock(256, 64, 3) for _ in range(2)]) + self.block2 = ConvBlock(256, 512, 3, stride=2) + self.id2 = nn.Sequential(*[IdentityBlock(512, 128, 3) for _ in range(3)]) + self.block3 = ConvBlock(512, 1024, 3, stride=2) + self.id3 = nn.Sequential(*[IdentityBlock(1024, 256, 3) for _ in range(5)]) + self.block4 = ConvBlock(1024, 2048, 3, stride=2) + self.id4 = nn.Sequential(*[IdentityBlock(2048, 512, 3) for _ in range(2)]) + + def forward(self, inputs: Tensor) -> Tensor: + """Call the resnet50 Network + + Parameters + ---------- + inputs + Input tensor + + Returns + ------- + Output tensor from resnet50 + """ + x = F.pad(inputs, (2, 3, 2, 3), mode="constant") + x = F.relu(self.bn(self.conv(x)), inplace=True) + x = F.max_pool2d(x, 3, stride=2) + x = self.id1(self.block1(x)) + x = self.id2(self.block2(x)) + x = self.id3(self.block3(x)) + return self.id4(self.block4(x)) + + +class VGGFace2Model(nn.Module): + """VGG-Face 2 model with resnet 50 backbone. Adapted from + https://github.com/WeidiXie/Keras-VGGFace2-ResNet50 + """ + def __init__(self) -> None: + super().__init__() + self.resnet = ResNet50() + self.dim_proj = nn.Linear(2048, 512) + + def forward(self, inputs: Tensor) -> Tensor: + """Forward pass through the VGGFace2 model + + Parameters + ---------- + inputs + Input to the VGGFace2 Model + + Returns + ------- + Output from the VGGFace2 Model + """ + x = self.resnet(inputs) + x = F.avg_pool2d(x, 7, stride=1) # pylint:disable=not-callable + x = F.relu(self.dim_proj(x.view(x.size(0), -1)), inplace=True) + return F.normalize(x, p=2, dim=1) + + +__all__ = get_module_objects(__name__) diff --git a/plugins/extract/recognition/vgg_face2_defaults.py b/plugins/extract/identity/vggface2_defaults.py similarity index 86% rename from plugins/extract/recognition/vgg_face2_defaults.py rename to plugins/extract/identity/vggface2_defaults.py index 6d32466b0f..abd0b61587 100644 --- a/plugins/extract/recognition/vgg_face2_defaults.py +++ b/plugins/extract/identity/vggface2_defaults.py @@ -26,12 +26,13 @@ the order that they are added to this module. from lib.config import ConfigItem """ +# pylint:disable=duplicate-code from lib.config import ConfigItem HELPTEXT = ( "VGG Face 2 identity recognition.\n" - "A Keras port of the model trained for VGGFace2: A dataset for recognising faces across pose " + "A Keras port of the model trained for VGGFace2: A dataset for recognizing faces across pose " "and age. (https://arxiv.org/abs/1710.08092)" ) @@ -41,11 +42,9 @@ default=16, group="settings", info="The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered.", + "but setting it too high can harm performance.", rounding=1, - min_max=(1, 64)) + min_max=(1, 256)) cpu = ConfigItem( datatype=bool, diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py deleted file mode 100644 index 62c43adca9..0000000000 --- a/plugins/extract/mask/_base.py +++ /dev/null @@ -1,342 +0,0 @@ -#!/usr/bin/env python3 -""" Base class for Face Masker plugins - -Plugins should inherit from this class - -See the override methods for which methods are required. - -The plugin will receive a :class:`~plugins.extract.extract_media.ExtractMedia` object. - -For each source item, the plugin must pass a dict to finalize containing: - ->>> {"filename": , ->>> "detected_faces": } -""" -from __future__ import annotations -import logging -import typing as T - -from dataclasses import dataclass, field - -import cv2 -import numpy as np -from torch.cuda import OutOfMemoryError - -from lib.align import AlignedFace, LandmarkType, transform_image -from lib.utils import FaceswapError -from plugins.extract import ExtractMedia -from plugins.extract._base import BatchType, ExtractorBatch, Extractor - -if T.TYPE_CHECKING: - from collections.abc import Generator - from queue import Queue - from lib.align import DetectedFace - from lib.align.aligned_face import CenteringType - -logger = logging.getLogger(__name__) - - -@dataclass -class MaskerBatch(ExtractorBatch): - """ Dataclass for holding items flowing through the aligner. - - Inherits from :class:`~plugins.extract._base.ExtractorBatch` - - Parameters - ---------- - roi_masks: list - The region of interest masks for the batch - """ - detected_faces: list[DetectedFace] = field(default_factory=list) - roi_masks: list[np.ndarray] = field(default_factory=list) - feed_faces: list[AlignedFace] = field(default_factory=list) - - -class Masker(Extractor): # pylint:disable=abstract-method - """ Masker plugin _base Object - - All Masker plugins must inherit from this class - - Parameters - ---------- - git_model_id: int - The second digit in the github tag that identifies this model. See - https://github.com/deepfakes-models/faceswap-models for more information - model_filename: str - The name of the model file to be loaded - - Other Parameters - ---------------- - configfile: str, optional - Path to a custom configuration ``ini`` file. Default: Use system configfile - - See Also - -------- - plugins.extract.pipeline : The extraction pipeline for calling plugins - plugins.extract.align : Aligner plugins - plugins.extract._base : Parent class for all extraction plugins - plugins.extract.detect._base : Detector parent class for extraction plugins. - plugins.extract.align._base : Aligner parent class for extraction plugins. - """ - - _logged_lm_count_once = False - - def __init__(self, - git_model_id: int | None = None, - model_filename: str | None = None, - configfile: str | None = None, - instance: int = 0, - **kwargs) -> None: - # pylint:disable=duplicate-code - logger.debug("Initializing %s: (configfile: %s)", self.__class__.__name__, configfile) - super().__init__(git_model_id, - model_filename, - configfile=configfile, - instance=instance, - **kwargs) - self.input_size = 256 # Override for model specific input_size - self.coverage_ratio = 1.0 # Override for model specific coverage_ratio - - self._info.plugin_type = "mask" - # Override if a specific type of landmark data is required: - self.landmark_type: LandmarkType | None = None - - self._storage_name = self.__module__.rsplit(".", maxsplit=1)[-1].replace("_", "-") - self._storage_centering: CenteringType = "face" # Centering to store the mask at - self._storage_size = 128 # Size to store masks at. Leave this at default - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def storage_centering(self) -> CenteringType: - """ Literal["face", "head", "legacy"] : The centering that the mask is stored at """ - return self._storage_centering - - def _maybe_log_warning(self, face: AlignedFace) -> None: - """ Log a warning, once, if we do not have full facial landmarks - - Parameters - ---------- - face: :class:`~lib.align.aligned_face.AlignedFace` - The aligned face object to test the landmark type for - """ - if face.landmark_type != LandmarkType.LM_2D_4 or self._logged_lm_count_once: - return - - msg = "are likely to be sub-standard" - msg = "can not be be generated" if self.name in ("Components", "Extended") else msg - - logger.warning("Extracted faces do not contain facial landmark data. '%s' masks %s.", - self.name, msg) - self._logged_lm_count_once = True - - def get_batch(self, queue: Queue) -> tuple[bool, MaskerBatch]: - """ Get items for inputting into the masker from the queue in batches - - Items are returned from the ``queue`` in batches of - :attr:`~plugins.extract._base.Extractor.batchsize` - - Items are received as :class:`~plugins.extract.extract_media.ExtractMedia` objects and - converted to ``dict`` for internal processing. - - To ensure consistent batch sizes for masker the items are split into separate items for - each :class:`~lib.align.DetectedFace` object. - - Remember to put ``'EOF'`` to the out queue after processing - the final batch - - Outputs items in the following format. All lists are of length - :attr:`~plugins.extract._base.Extractor.batchsize`: - - >>> {'filename': [], - >>> 'detected_faces': [[ MaskerBatch: - """ Just return the masker's predict function """ - assert isinstance(batch, MaskerBatch) - assert self.name is not None - # slightly hacky workaround to deal with landmarks based masks: - if self.name.lower() in ("components", "extended"): - feed = np.empty(2, dtype="object") - feed[0] = batch.feed - feed[1] = batch.feed_faces - else: - feed = batch.feed - - try: - batch.prediction = self.predict(feed) - except OutOfMemoryError as err: - msg = ("You do not have enough GPU memory available to run detection at the " - "selected batch size. You can try a number of things:" - "\n1) Close any other application that is using your GPU (web browsers are " - "particularly bad for this)." - "\n2) Lower the batchsize (the amount of images fed into the model) by " - "editing the plugin settings (GUI: Settings > Configure extract settings, " - "CLI: Edit the file faceswap/config/extract.ini)." - "\n3) Enable 'Single Process' mode.") - raise FaceswapError(msg) from err - - return batch - - def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: - """ Finalize the output from Masker - - This should be called as the final task of each `plugin`. - - Pairs the detected faces back up with their original frame before yielding each frame. - - Parameters - ---------- - batch : dict - The final ``dict`` from the `plugin` process. It must contain the `keys`: - ``detected_faces``, ``filename``, ``feed_faces``, ``roi_masks`` - - Yields - ------ - :class:`~plugins.extract.extract_media.ExtractMedia` - The :attr:`DetectedFaces` list will be populated for this class with the bounding - boxes, landmarks and masks for the detected faces found in the frame. - """ - assert isinstance(batch, MaskerBatch) - for mask, face, feed_face, roi_mask in zip(batch.prediction, - batch.detected_faces, - batch.feed_faces, - batch.roi_masks): - if self.name in ("Components", "Extended") and not np.any(mask): - # Components/Extended masks can return empty when called from the manual tool with - # 4 Point ROI landmarks - continue - self._crop_out_of_bounds(mask, roi_mask) - face.add_mask(self._storage_name, - mask, - feed_face.adjusted_matrix, - feed_face.interpolators[1], - storage_size=self._storage_size, - storage_centering=self._storage_centering) - del batch.feed - - logger.trace("Item out: %s", # type: ignore - {key: val.shape if isinstance(val, np.ndarray) else val - for key, val in batch.__dict__.items()}) - for filename, face in zip(batch.filename, batch.detected_faces): - self._tracker.output_faces.append(face) - if len(self._tracker.output_faces) != self._tracker.faces_per_filename[filename]: - continue - - output = self._extract_media.pop(filename) - output.add_detected_faces(self._tracker.output_faces) - self._tracker.output_faces = [] - logger.trace("Yielding: (filename: '%s', image: %s, " # type:ignore[attr-defined] - "detected_faces: %s)", output.filename, output.image_shape, - len(output.detected_faces)) - yield output - - # <<< PROTECTED ACCESS METHODS >>> # - @classmethod - def _resize(cls, image: np.ndarray, target_size: int) -> np.ndarray: - """ resize input and output of mask models appropriately """ - height, width, channels = image.shape - image_size = max(height, width) - scale = target_size / image_size - if scale == 1.: - return image - method = cv2.INTER_CUBIC if scale > 1. else cv2.INTER_AREA - resized = cv2.resize(image, (0, 0), fx=scale, fy=scale, interpolation=method) - resized = resized if channels > 1 else resized[..., None] - return resized - - @classmethod - def _crop_out_of_bounds(cls, mask: np.ndarray, roi_mask: np.ndarray) -> None: - """ Un-mask any area of the predicted mask that falls outside of the original frame. - - Parameters - ---------- - masks: :class:`numpy.ndarray` - The predicted masks from the plugin - roi_mask: :class:`numpy.ndarray` - The roi mask. In frame is white, out of frame is black - """ - if np.all(roi_mask): - return # The whole of the face is within the frame - roi_mask = roi_mask[..., None] if mask.ndim == 3 else roi_mask - mask *= roi_mask diff --git a/plugins/extract/mask/bisenet_fp.py b/plugins/extract/mask/bisenet_fp.py index 8e95638eb6..9aa6d91dcc 100644 --- a/plugins/extract/mask/bisenet_fp.py +++ b/plugins/extract/mask/bisenet_fp.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" BiSeNet Face-Parsing mask plugin +"""BiSeNet Face-Parsing mask plugin Architecture and Pre-Trained Model ported from PyTorch to Keras by TorzDF from https://github.com/zllrunning/face-parsing.PyTorch @@ -9,78 +9,70 @@ import typing as T import numpy as np +import torch +from torch import nn +from torch.nn import functional as F -import keras.backend as K -from keras.layers import ( - Activation, Add, BatchNormalization, Concatenate, Conv2D, GlobalAveragePooling2D, Input, - MaxPooling2D, Multiply, Reshape, UpSampling2D, ZeroPadding2D) -from keras.models import Model - -from lib.logger import parse_class_init -from lib.utils import get_module_objects -from plugins.extract.extract_config import load_config -from ._base import BatchType, Masker, MaskerBatch +from lib.utils import get_module_objects, GetModel +from plugins.extract.base import FacePlugin from . import bisenet_fp_defaults as cfg if T.TYPE_CHECKING: - from keras import KerasTensor + from torch import Tensor logger = logging.getLogger(__name__) - - -class Mask(Masker): # pylint:disable=too-many-instance-attributes - """ Neural network to process face image into a segmentation mask of the face """ - def __init__(self, **kwargs) -> None: - # We need access to user config prior to parent being initialized to correctly set the - # model filename - load_config(kwargs.get("configfile")) - self._is_faceswap, version = self._check_weights_selection() - - git_model_id = 14 - model_filename = f"bisnet_face_parsing_v{version}.h5" - super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - +# pylint:disable=duplicate-code + + +class BiSeNetFP(FacePlugin): + """Neural network to process face image into a segmentation mask of the face""" + def __init__(self) -> None: + super().__init__(input_size=512, + batch_size=cfg.batch_size(), + is_rgb=True, + dtype="float32", + scale=(0, 1), + force_cpu=cfg.cpu(), + centering="head" if cfg.include_hair() else "face") self.model: BiSeNet - self.name = "BiSeNet - Face Parsing" - self.input_size = 512 - self.color_format = "RGB" - self.vram = 384 if not cfg.cpu() else 0 # 378 in testing - self.vram_per_batch = 384 if not cfg.cpu() else 0 # ~328 in testing - self.batchsize = cfg.batch_size() - + self._is_faceswap, self._git_version = self._check_weights_selection() self._segment_indices = self._get_segment_indices() self._storage_centering = "head" if cfg.include_hair() else "face" - """ Literal["head", "face"] The mask type/storage centering to use """ + """The mask type/storage centering to use""" # Separate storage for face and head masks - self._storage_name = f"{self._storage_name}_{self._storage_centering}" + self.storage_name = f"{self.storage_name}_{self.centering}" + + mean = (0.384, 0.314, 0.279) if self._is_faceswap else (0.485, 0.456, 0.406) + std = (0.324, 0.286, 0.275) if self._is_faceswap else (0.229, 0.224, 0.225) + self._mean = np.array(mean, dtype="float32") + self._std = np.array(std, dtype="float32") def _check_weights_selection(self) -> tuple[bool, int]: - """ Check which weights have been selected. + """Check which weights have been selected. This is required for passing along the correct file name for the corresponding weights selection. Returns ------- - is_faceswap : bool + is_faceswap ``True`` if `faceswap` trained weights have been selected. ``False`` if `original` weights have been selected. - version : int + version ``1`` for non-faceswap, ``2`` if faceswap and full-head model is required. ``3`` if faceswap and full-face is required """ is_faceswap = cfg.weights() == "faceswap" - version = 1 if not is_faceswap else 2 if cfg.include_hair() else 3 + version = 4 if not is_faceswap else 5 if cfg.include_hair() else 6 return is_faceswap, version def _get_segment_indices(self) -> list[int]: - """ Obtain the segment indices to include within the face mask area based on user + """Obtain the segment indices to include within the face mask area based on user configuration settings. Returns ------- - list - The segment indices to include within the face mask area + The segment indices to include within the face mask area Notes ----- @@ -103,37 +95,62 @@ def _get_segment_indices(self) -> list[int]: logger.debug("Selected segment indices: %s", retval) return retval - def init_model(self) -> None: - """ Initialize the BiSeNet Face Parsing model. """ - assert isinstance(self.model_path, str) - lbls = 5 if self._is_faceswap else 19 - placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), - dtype="float32") + def load_model(self) -> BiSeNet: + """Initialize the BiSeNet Face Parsing model. - with self.get_device_context(cfg.cpu()): - self.model = BiSeNet(self.model_path, self.batchsize, self.input_size, lbls) - self.model(placeholder) + Returns + ------- + The loaded BiSeNetFP model + """ + weights = GetModel(f"bisenet_face_parsing_v{self._git_version}.pth", 14).model_path + assert isinstance(weights, str) + return T.cast(BiSeNet, self.load_torch_model(BiSeNet(5 if self._is_faceswap else 19), + weights, + return_indices=[0])) - def process_input(self, batch: BatchType) -> None: - """ Compile the detected faces for prediction """ - assert isinstance(batch, MaskerBatch) - mean = (0.384, 0.314, 0.279) if self._is_faceswap else (0.485, 0.456, 0.406) - std = (0.324, 0.286, 0.275) if self._is_faceswap else (0.229, 0.224, 0.225) + def pre_process(self, batch: np.ndarray) -> np.ndarray: + """Format the detected faces for prediction - batch.feed = ((np.array([T.cast(np.ndarray, feed.face)[..., :3] - for feed in batch.feed_faces], - dtype="float32") / 255.0) - mean) / std - logger.trace("feed shape: %s", batch.feed.shape) # type:ignore[attr-defined] + Parameters + ---------- + batch + The batch of aligned faces in the correct format for the model + + Returns + ------- + The updated images for feeding the model + """ + return ((batch - self._mean) / self._std).transpose(0, 3, 1, 2) + + def process(self, batch: np.ndarray) -> np.ndarray: + """Get the masks from the model + + Parameters + ---------- + batch + The batch to feed into the masker - def predict(self, feed: np.ndarray) -> np.ndarray: - """ Run model to get predictions """ - with self.get_device_context(cfg.cpu()): - return self.model(feed)[0] + Returns + ------- - def process_output(self, batch: BatchType) -> None: - """ Compile found faces for output """ - pred = batch.prediction.argmax(-1).astype("uint8") - batch.prediction = np.isin(pred, self._segment_indices).astype("float32") + The predicted masks from the plugin + """ + return self.from_torch(batch).transpose(0, 2, 3, 1) + + def post_process(self, batch: np.ndarray) -> np.ndarray: + """Process the output from the model + + Parameters + ---------- + batch + The predictions from the masker + + Returns + ------- + The final masks + """ + pred = batch.argmax(-1).astype("uint8") + return np.isin(pred, self._segment_indices).astype("float32") # BiSeNet Face-Parsing Model @@ -160,450 +177,351 @@ def process_output(self, batch: BatchType) -> None: # SOFTWARE. -_NAME_TRACKER: set[str] = set() - - -def _get_name(name: str, start_idx: int = 1) -> str: - """ Auto numbering to keep track of layer names. - - Names are kept the same as the PyTorch original model, to enable easier porting of weights. - - Names are tracked and auto-appended with an integer to ensure they are unique. - - Parameters - ---------- - name: str - The name of the layer to get auto named. - start_idx - The first index number to start auto naming layers with the same name. Usually 0 or 1. - Pass -1 if the name should not be auto-named (i.e. should not have an integer appended - to the end) - - Returns - ------- - str - A unique version of the original name - """ - i = start_idx - while True: - retval = f"{name}{i}" if i != -1 else name - if retval not in _NAME_TRACKER: - break - i += 1 - _NAME_TRACKER.add(retval) - return retval - - -class ConvBn(): - """ Convolutional 3D with Batch Normalization block. +# Resnet18 +class BasicBlock(nn.Module): + """The basic building block for ResNet 18. Parameters ---------- - filters: int + in_channels + The number of input channels + filters The dimensionality of the output space (i.e. the number of output filters in the convolution). - kernel_size: int, optional - The height and width of the 2D convolution window. Default: `3` - strides: int, optional + stride The strides of the convolution along the height and width. Default: `1` - padding: int, optional - The amount of padding to apply prior to the first Convolutional Layer. Default: `1` - activation: bool - Whether to include ReLu Activation at the end of the block. Default: ``True`` - prefix: str, optional - The prefix to name the layers within the block. Default: ``""`` (empty string, i.e. no - prefix) - start_idx: int, optional - The starting index for naming the layers within the block. See :func:`_get_name` for - more information. Default: `1` """ - def __init__(self, filters: int, # pylint:disable=too-many-positional-arguments - kernel_size: int = 3, - strides: int = 1, - padding: int = 1, - activation: int = True, - prefix: str = "", - start_idx: int = 1) -> None: - self._filters = filters - self._kernel_size = kernel_size - self._strides = strides - self._padding = padding - self._activation = activation - self._prefix = f"{prefix}-" if prefix else prefix - self._start_idx = start_idx - - def __call__(self, inputs: KerasTensor) -> KerasTensor: - """ Call the Convolutional Batch Normalization block. + def __init__(self, in_channels: int, filters: int, stride: int = 1): + super().__init__() + self.conv1 = nn.Conv2d(in_channels, filters, 3, stride=stride, padding=1, bias=False) + self.bn1 = nn.BatchNorm2d(filters) + self.conv2 = nn.Conv2d(filters, filters, 3, stride=1, padding=1, bias=False) + self.bn2 = nn.BatchNorm2d(filters) + self.relu = nn.ReLU(inplace=True) + self.downsample = None + if in_channels != filters or stride != 1: + self.downsample = nn.Sequential( + nn.Conv2d(in_channels, filters, 1, stride=stride, bias=False), + nn.BatchNorm2d(filters), + ) + + def forward(self, inputs: Tensor) -> Tensor: + """Call the ResNet 18 basic block. Parameters ---------- - inputs: :class:`keras.KerasTensor` + inputs The input to the block Returns ------- - :class:`keras.KerasTensor` - The output from the block + The output from the block """ - var_x = inputs - if self._padding > 0 and self._kernel_size != 1: - var_x = ZeroPadding2D(self._padding, - name=_get_name(f"{self._prefix}zeropad", - start_idx=self._start_idx))(var_x) - padding = "valid" if self._padding != -1 else "same" - var_x = Conv2D(self._filters, - self._kernel_size, - strides=self._strides, - padding=padding, - use_bias=False, - name=_get_name(f"{self._prefix}conv", start_idx=self._start_idx))(var_x) - var_x = BatchNormalization(epsilon=1e-5, - name=_get_name(f"{self._prefix}bn", - start_idx=self._start_idx))(var_x) - if self._activation: - var_x = Activation("relu", - name=_get_name(f"{self._prefix}relu", - start_idx=self._start_idx))(var_x) - return var_x - - -class ResNet18(): - """ ResNet 18 block. Used at the start of BiSeNet Face Parsing. """ - def __init__(self): - self._feature_index = 1 if K.image_data_format() == "channels_first" else -1 + residual = F.relu(self.bn1(self.conv1(inputs))) + residual = self.bn2(self.conv2(residual)) + shortcut = inputs if self.downsample is None else self.downsample(inputs) + out = self.relu(shortcut + residual) + return out - def _basic_block(self, - inputs: KerasTensor, - prefix: str, - filters: int, - strides: int = 1) -> KerasTensor: - """ The basic building block for ResNet 18. - Parameters - ---------- - inputs: :class:`keras.KerasTensor` - The input to the block - prefix: str - The prefix to name the layers within the block - filters: int - The dimensionality of the output space (i.e. the number of output filters in the - convolution). - strides: int, optional - The strides of the convolution along the height and width. Default: `1` - - Returns - ------- - :class:`keras.KerasTensor` - The output from the block - """ - res = ConvBn(filters, strides=strides, padding=1, prefix=prefix)(inputs) - res = ConvBn(filters, strides=1, padding=1, activation=False, prefix=prefix)(res) - - shortcut = inputs - filts = (shortcut.shape[self._feature_index], res.shape[self._feature_index]) - if strides != 1 or filts[0] != filts[1]: # Downsample - name = f"{prefix}-downsample-" - shortcut = Conv2D(filters, 1, - strides=strides, - use_bias=False, - name=_get_name(f"{name}", start_idx=0))(shortcut) - shortcut = BatchNormalization(epsilon=1e-5, - name=_get_name(f"{name}", start_idx=0))(shortcut) - - var_x = Add(name=f"{prefix}-add")([res, shortcut]) - var_x = Activation("relu", name=f"{prefix}-relu")(var_x) - return var_x - - def _basic_layer(self, # pylint:disable=too-many-positional-arguments - inputs: KerasTensor, - prefix: str, +class ResNet18(nn.Module): + """ResNet 18 block. Used at the start of BiSeNet Face Parsing. """ + def __init__(self): + super().__init__() + self.conv1 = nn.Conv2d(3, 64, 7, stride=2, padding=3, bias=False) + self.bn1 = nn.BatchNorm2d(64) + self.maxpool = nn.MaxPool2d(3, stride=2, padding=1) + self.layer1 = self._basic_layer(64, 64, 2, stride=1) + self.layer2 = self._basic_layer(64, 128, 2, stride=2) + self.layer3 = self._basic_layer(128, 256, 2, stride=2) + self.layer4 = self._basic_layer(256, 512, 2, stride=2) + + @classmethod + def _basic_layer(cls, + in_channels: int, filters: int, num_blocks: int, - strides: int = 1) -> KerasTensor: - """ The basic layer for ResNet 18. Recursively builds from :func:`_basic_block`. + stride: int = 1) -> nn.Sequential: + """The basic layer for ResNet 18. Recursively builds from :func:`_basic_block`. Parameters ---------- - inputs: :class:`keras.KerasTensor` - The input to the block - prefix: str - The prefix to name the layers within the block - filters: int + in_channels + The number of input channels + filters The dimensionality of the output space (i.e. the number of output filters in the convolution). - num_blocks: int + num_blocks The number of basic blocks to recursively build - strides: int, optional + stride The strides of the convolution along the height and width. Default: `1` Returns ------- - :class:`keras.KerasTensor` - The output from the block + The basic layer module """ - var_x = self._basic_block(inputs, f"{prefix}-0", filters, strides=strides) - for i in range(num_blocks - 1): - var_x = self._basic_block(var_x, f"{prefix}-{i + 1}", filters, strides=1) - return var_x + layers = [BasicBlock(in_channels, filters, stride=stride)] + for _ in range(num_blocks - 1): + layers.append(BasicBlock(filters, filters, stride=1)) + return nn.Sequential(*layers) - def __call__(self, inputs: KerasTensor) -> KerasTensor: - """ Call the ResNet 18 block. + def forward(self, inputs: Tensor) -> tuple[Tensor, Tensor, Tensor]: + """Call the ResNet 18 block. Parameters ---------- - inputs: :class:`keras.KerasTensor` - The input to the block + inputs + The input to the ResNet 18 Returns ------- - :class:`keras.KerasTensor` - The output from the block + The feature outputs from ResNet 18 """ - var_x = ConvBn(64, kernel_size=7, strides=2, padding=3, prefix="cp-resnet")(inputs) - var_x = ZeroPadding2D(1, name="cp-resnet-zeropad")(var_x) - var_x = MaxPooling2D(pool_size=3, strides=2, name="cp-resnet-maxpool")(var_x) - - var_x = self._basic_layer(var_x, "cp-resnet-layer1", 64, 2) - feat8 = self._basic_layer(var_x, "cp-resnet-layer2", 128, 2, strides=2) - feat16 = self._basic_layer(feat8, "cp-resnet-layer3", 256, 2, strides=2) - feat32 = self._basic_layer(feat16, "cp-resnet-layer4", 512, 2, strides=2) - + x = self.maxpool(F.relu(self.bn1(self.conv1(inputs)))) + feat8 = self.layer2(self.layer1(x)) + feat16 = self.layer3(feat8) + feat32 = self.layer4(feat16) return feat8, feat16, feat32 -class AttentionRefinementModule(): - """ The Attention Refinement block for BiSeNet Face Parsing +# bisenet +class ConvBNReLU(nn.Module): + """Convolutional 3D with Batch Normalization block. Parameters ---------- - filters: int + in_channels + The number of input channels + filters The dimensionality of the output space (i.e. the number of output filters in the convolution). + kernel_size + The height and width of the 2D convolution window. Default: `3` + strides + The strides of the convolution along the height and width. Default: `1` + padding + The amount of padding to apply prior to the first Convolutional Layer. Default: `1` """ - def __init__(self, filters: int) -> None: - self._filters = filters - - def __call__(self, inputs: KerasTensor, feats: int) -> KerasTensor: - """ Call the Attention Refinement block. + def __init__(self, + in_channels: int, + filters: int, + kernel_size: int = 3, + strides: int = 1, + padding: int = 1) -> None: + super().__init__() + self.conv = nn.Conv2d(in_channels, + filters, + kernel_size, + stride=strides, + padding=padding, + bias=False) + self.bn = nn.BatchNorm2d(filters) + + def forward(self, inputs: Tensor) -> Tensor: + """Call the Convolutional Batch Normalization block. Parameters ---------- - inputs: :class:`keras.KerasTensor` + inputs The input to the block - feats: int - The number of features. Used for naming. Returns ------- - :class:`keras.KerasTensor` - The output from the block + The output from the block """ - prefix = f"cp-arm{feats}" - feat = ConvBn(self._filters, prefix=f"{prefix}-conv", start_idx=-1, padding=-1)(inputs) - atten = GlobalAveragePooling2D(name=f"{prefix}-avgpool")(feat) - atten = Reshape((1, 1, atten.shape[-1]))(atten) - atten = Conv2D(self._filters, 1, use_bias=False, name=f"{prefix}-conv_atten")(atten) - atten = BatchNormalization(epsilon=1e-5, name=f"{prefix}-bn_atten")(atten) - atten = Activation("sigmoid", name=f"{prefix}-sigmoid")(atten) - var_x = Multiply(name=f"{prefix}.mul")([feat, atten]) - return var_x - - -class ContextPath(): - """ The Context Path block for BiSeNet Face Parsing. """ - def __init__(self): - self._resnet = ResNet18() + return F.relu(self.bn(self.conv(inputs))) + + +class BiSeNetOutput(nn.Module): + """The BiSeNet Output block for Face Parsing + + Parameters + ---------- + in_channels + The number of input channels + filters + The dimensionality of the output space (i.e. the number of output filters in the + convolution). + num_class + The number of classes to generate + """ + def __init__(self, in_channels: int, filters: int, num_classes: int) -> None: + super().__init__() + self.conv1 = ConvBNReLU(in_channels, filters, kernel_size=3, strides=1, padding=1) + self.conv_out = nn.Conv2d(filters, num_classes, 1, bias=False) - def __call__(self, inputs: KerasTensor) -> KerasTensor: - """ Call the Context Path block. + def forward(self, inputs: Tensor) -> Tensor: + """Call the BiSeNet Output block. Parameters ---------- - inputs: :class:`keras.KerasTensor` + inputs The input to the block Returns ------- - :class:`keras.KerasTensor` - The output from the block + The output from the block """ - feat8, feat16, feat32 = self._resnet(inputs) - - avg = GlobalAveragePooling2D(name="cp-avgpool")(feat32) - avg = Reshape((1, 1, avg.shape[-1]))(avg) - avg = ConvBn(128, kernel_size=1, padding=0, prefix="cp-conv_avg", start_idx=-1)(avg) - - avg_up = UpSampling2D(size=feat32.shape[1:3], name="cp-upsample")(avg) + return self.conv_out(self.conv1(inputs)) - feat32 = AttentionRefinementModule(128)(feat32, 32) - feat32 = Add(name="cp-add")([feat32, avg_up]) - feat32 = UpSampling2D(name="cp-upsample1")(feat32) - feat32 = ConvBn(128, kernel_size=3, prefix="cp-conv_head32", start_idx=-1)(feat32) - feat16 = AttentionRefinementModule(128)(feat16, 16) - feat16 = Add(name="cp-add2")([feat16, feat32]) - feat16 = UpSampling2D(name="cp-upsample2")(feat16) - feat16 = ConvBn(128, kernel_size=3, prefix="cp-conv_head16", start_idx=-1)(feat16) - - return feat8, feat16, feat32 - - -class FeatureFusionModule(): - """ The Feature Fusion block for BiSeNet Face Parsing +class AttentionRefinementModule(nn.Module): + """The Attention Refinement block for BiSeNet Face Parsing Parameters ---------- - filters: int + in_channels + The number of input channels to the block + filters The dimensionality of the output space (i.e. the number of output filters in the convolution). """ - def __init__(self, filters: int) -> None: - self._filters = filters + def __init__(self, in_channels: int, filters: int) -> None: + super().__init__() + self.conv = ConvBNReLU(in_channels, filters, kernel_size=3, strides=1, padding=1) + self.conv_atten = nn.Conv2d(filters, filters, kernel_size=1, bias=False) + self.bn_atten = nn.BatchNorm2d(filters) + self.sigmoid_atten = nn.Sigmoid() - def __call__(self, inputs: KerasTensor) -> KerasTensor: - """ Call the Feature Fusion block. + def forward(self, inputs: Tensor) -> Tensor: + """Call the Attention Refinement block. Parameters ---------- - inputs: :class:`keras.KerasTensor` + inputs The input to the block Returns ------- - :class:`keras.KerasTensor` - The output from the block + The output from the block """ - feat = Concatenate(name="ffm-concat")(inputs) - feat = ConvBn(self._filters, - kernel_size=1, - padding=0, - prefix="ffm-convblk", - start_idx=-1)(feat) - - atten = GlobalAveragePooling2D(name="ffm-avgpool")(feat) - atten = Reshape((1, 1, atten.shape[-1]))(atten) - atten = Conv2D(self._filters // 4, 1, use_bias=False, name="ffm-conv1")(atten) - atten = Activation("relu", name="ffm-relu")(atten) - atten = Conv2D(self._filters, 1, use_bias=False, name="ffm-conv2")(atten) - atten = Activation("sigmoid", name="ffm-sigmoid")(atten) - - var_x = Multiply(name="ffm-mul")([feat, atten]) - var_x = Add(name="ffm-add")([var_x, feat]) - return var_x - + feat = self.conv(inputs) + attention = F.avg_pool2d(feat, feat.size()[2:]) # pylint:disable=not-callable + attention = self.sigmoid_atten(self.bn_atten(self.conv_atten(attention))) + out = torch.mul(feat, attention) + return out -class BiSeNetOutput(): - """ The BiSeNet Output block for Face Parsing - Parameters - ---------- - filters: int - The dimensionality of the output space (i.e. the number of output filters in the - convolution). - num_class: int - The number of classes to generate - label, str, optional - The label for this output (for naming). Default: `""` (i.e. empty string, or no label) - """ - def __init__(self, filters: int, num_classes: int, label: str = "") -> None: - self._filters = filters - self._num_classes = num_classes - self._label = label +class ContextPath(nn.Module): + """The Context Path block for BiSeNet Face Parsing. """ + def __init__(self): + super().__init__() + self.resnet = ResNet18() + self.arm16 = AttentionRefinementModule(256, 128) + self.arm32 = AttentionRefinementModule(512, 128) + self.conv_head32 = ConvBNReLU(128, 128, kernel_size=3, strides=1, padding=1) + self.conv_head16 = ConvBNReLU(128, 128, kernel_size=3, strides=1, padding=1) + self.conv_avg = ConvBNReLU(512, 128, kernel_size=1, strides=1, padding=0) - def __call__(self, inputs: KerasTensor) -> KerasTensor: - """ Call the BiSeNet Output block. + def forward(self, inputs: Tensor) -> tuple[Tensor, Tensor, Tensor]: + """Call the Context Path block. Parameters ---------- - inputs: :class:`keras.KerasTensor` + inputs The input to the block Returns ------- - :class:`keras.KerasTensor` - The output from the block + The feature outputs from ResNet 18 """ - var_x = ConvBn(self._filters, prefix=f"conv_out{self._label}-conv", start_idx=-1)(inputs) - var_x = Conv2D(self._num_classes, 1, - use_bias=False, name=f"conv_out{self._label}-conv_out")(var_x) - return var_x - + feat8, feat16, feat32 = self.resnet(inputs) + dim_8 = feat8.size()[2:] + dim_16 = feat16.size()[2:] + dim_32 = feat32.size()[2:] + avg = F.interpolate(self.conv_avg(F.avg_pool2d(feat32, # pylint:disable=not-callable + feat32.size()[2:])), + dim_32, + mode='nearest') + feat32 = self.conv_head32(F.interpolate(self.arm32(feat32) + avg, dim_16, mode='nearest')) + feat16 = self.conv_head16(F.interpolate(self.arm16(feat16) + feat32, + dim_8, + mode='nearest')) + return feat8, feat16, feat32 -class BiSeNet(): - """ BiSeNet Face-Parsing Mask from https://github.com/zllrunning/face-parsing.PyTorch - PyTorch model implemented in Keras by TorzDF +class FeatureFusionModule(nn.Module): + """The Feature Fusion block for BiSeNet Face Parsing Parameters ---------- - weights_path: str - The path to the keras weights file - batch_size: int - The batch size to feed the model - input_size: int - The input size to the model - num_classes: int - The number of segmentation classes to create + in_channels + The number of input channels to the module + filters + The dimensionality of the output space (i.e. the number of output filters in the + convolution). """ - def __init__(self, - weights_path: str, - batch_size: int, - input_size: int, - num_classes: int) -> None: - logger.debug(parse_class_init(locals())) - self._batch_size = batch_size - self._input_size = input_size - self._num_classes = num_classes - self._cp = ContextPath() - self._model = self._load_model(weights_path) - logger.debug("Initialized: %s", self.__class__.__name__) + def __init__(self, in_channels: int, filters: int) -> None: + super().__init__() + self.convblk = ConvBNReLU(in_channels, filters, kernel_size=1, strides=1, padding=0) + self.conv1 = nn.Conv2d(filters, filters // 4, 1, stride=1, padding=0, bias=False) + self.conv2 = nn.Conv2d(filters // 4, filters, 1, stride=1, padding=0, bias=False) + self.relu = nn.ReLU(inplace=True) + self.sigmoid = nn.Sigmoid() - def _load_model(self, weights_path: str) -> Model: - """ Definition of the BiSeNet-FP Model. + def forward(self, feat_spatial: Tensor, feat_context: Tensor) -> Tensor: + """Call the Feature Fusion block. Parameters ---------- - weights_path: str - Full path to the model's weights + feat_spatial + The spatial features input to the block + feat_context + The context features input to the block Returns ------- - :class:`keras.models.Model` - The BiSeNet-FP model + The output from the block """ - input_ = Input((self._input_size, self._input_size, 3)) + feat = self.convblk(torch.cat([feat_spatial, feat_context], dim=1)) + attention = self.sigmoid(self.conv2(self.relu(self.conv1( + F.avg_pool2d(feat, feat.size()[2:]))))) # pylint:disable=not-callable - features = self._cp(input_) # res8, cp8, cp16 - feat_fuse = FeatureFusionModule(256)([features[0], features[1]]) + return torch.mul(feat, attention) + feat - feats = [BiSeNetOutput(256, self._num_classes)(feat_fuse), - BiSeNetOutput(64, self._num_classes, label="16")(features[1]), - BiSeNetOutput(64, self._num_classes, label="32")(features[2])] - height, width = input_.shape[1:3] - output = [UpSampling2D(size=(height // feat.shape[1], width // feat.shape[2]), - interpolation="bilinear")(feat) - for feat in feats] +class BiSeNet(nn.Module): + """BiSeNet Face-Parsing Mask from https://github.com/zllrunning/face-parsing.PyTorch - retval = Model(input_, output) - retval.load_weights(weights_path) - retval.make_predict_function() - return retval + PyTorch model implemented in Keras and then back to pytorch by TorzDF, because why not? + + Parameters + ---------- + num_classes + The number of segmentation classes to create + """ + def __init__(self, num_classes: int) -> None: + super().__init__() + self.cp = ContextPath() + self.ffm = FeatureFusionModule(256, 256) + self.conv_out = BiSeNetOutput(256, 256, num_classes) + self.conv_out16 = BiSeNetOutput(128, 64, num_classes) + self.conv_out32 = BiSeNetOutput(128, 64, num_classes) + logger.debug("Initialized: %s", self.__class__.__name__) - def __call__(self, inputs: np.ndarray) -> np.ndarray: - """ Get predictions from the BiSeNet-FP model + def forward(self, inputs: Tensor) -> tuple[Tensor, Tensor, Tensor]: + """Get predictions from the BiSeNet-FP model Parameters ---------- - inputs: :class:`numpy.ndarray` + inputs The input to BiSeNet-FP Returns ------- - :class:`numpy.ndarray` - The output from BiSeNet-FP + The outputs from BiSeNet-FP """ - return self._model.predict(inputs, verbose=0, batch_size=self._batch_size) + dims = inputs.size()[2:] + feat_sp, feat_cp8, feat_cp16 = self.cp(inputs) + feat_fuse = self.ffm(feat_sp, feat_cp8) + + feats = [self.conv_out(feat_fuse), + self.conv_out16(feat_cp8), + self.conv_out32(feat_cp16)] + output = tuple(F.interpolate(feat, dims, mode='bilinear', align_corners=True) + for feat in feats) + assert len(output) == 3 + return output __all__ = get_module_objects(__name__) diff --git a/plugins/extract/mask/bisenet_fp_defaults.py b/plugins/extract/mask/bisenet_fp_defaults.py index b335b7e493..bf2c551586 100644 --- a/plugins/extract/mask/bisenet_fp_defaults.py +++ b/plugins/extract/mask/bisenet_fp_defaults.py @@ -40,11 +40,9 @@ default=8, group="settings", info="The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered.", + "but setting it too high can harm performance.", rounding=1, - min_max=(1, 64)) + min_max=(1, 128)) cpu = ConfigItem( datatype=bool, diff --git a/plugins/extract/mask/components.py b/plugins/extract/mask/components.py deleted file mode 100644 index c785673ebf..0000000000 --- a/plugins/extract/mask/components.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python3 -""" Components Mask for faceswap.py """ -from __future__ import annotations -import logging -import typing as T - -import cv2 -import numpy as np - -from lib.align import LandmarkType -from lib.utils import get_module_objects - -from ._base import BatchType, Masker - -if T.TYPE_CHECKING: - from lib.align.aligned_face import AlignedFace - -logger = logging.getLogger(__name__) - - -class Mask(Masker): - # pylint:disable=duplicate-code - """ Apply a landmarks based components mask """ - def __init__(self, **kwargs) -> None: - git_model_id = None - model_filename = None - super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.input_size = 256 - self.name = "Components" - self.vram = 0 # Doesn't use GPU - self.vram_per_batch = 0 - self.batchsize = 1 - self.landmark_type = LandmarkType.LM_2D_68 - - def init_model(self) -> None: - logger.debug("No mask model to initialize") - - def process_input(self, batch: BatchType) -> None: - """ Compile the detected faces for prediction """ - batch.feed = np.zeros((self.batchsize, self.input_size, self.input_size, 1), - dtype="float32") - - def predict(self, feed: np.ndarray) -> np.ndarray: - """ Run model to get predictions """ - faces: list[AlignedFace] = feed[1] - feed = feed[0] - for mask, face in zip(feed, faces): - if LandmarkType.from_shape(face.landmarks.shape) != self.landmark_type: - # Called from the manual tool. # TODO This will only work with BS1 - feed = np.zeros_like(feed) - continue - parts = self.parse_parts(np.array(face.landmarks)) - for item in parts: - a_item = np.rint(np.concatenate(item)).astype("int32") - hull = cv2.convexHull(a_item) - cv2.fillConvexPoly(mask, hull, [1.0], lineType=cv2.LINE_AA) - return feed - - def process_output(self, batch: BatchType) -> None: - """ Compile found faces for output """ - return - - @staticmethod - def parse_parts(landmarks: np.ndarray) -> list[tuple[np.ndarray, ...]]: - """ Component face hull mask """ - r_jaw = (landmarks[0:9], landmarks[17:18]) - l_jaw = (landmarks[8:17], landmarks[26:27]) - r_cheek = (landmarks[17:20], landmarks[8:9]) - l_cheek = (landmarks[24:27], landmarks[8:9]) - nose_ridge = (landmarks[19:25], landmarks[8:9],) - r_eye = (landmarks[17:22], - landmarks[27:28], - landmarks[31:36], - landmarks[8:9]) - l_eye = (landmarks[22:27], - landmarks[27:28], - landmarks[31:36], - landmarks[8:9]) - nose = (landmarks[27:31], landmarks[31:36]) - parts = [r_jaw, l_jaw, r_cheek, l_cheek, nose_ridge, r_eye, l_eye, nose] - return parts - - -__all__ = get_module_objects(__name__) diff --git a/plugins/extract/mask/custom.py b/plugins/extract/mask/custom.py index 2d7b353ab7..c031ef62e4 100644 --- a/plugins/extract/mask/custom.py +++ b/plugins/extract/mask/custom.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 -""" Components Mask for faceswap.py """ +"""Custom Mask for faceswap.py""" from __future__ import annotations import logging import typing as T import numpy as np from lib.utils import get_module_objects -from ._base import BatchType, Masker +from plugins.extract.base import FacePlugin from . import custom_defaults as cfg @@ -16,40 +16,55 @@ logger = logging.getLogger(__name__) -class Mask(Masker): - """ A mask that fills the whole face area with 1s or 0s (depending on user selected settings) - for custom editing. """ +class Custom(FacePlugin): + """A mask that fills the whole face area with 1s or 0s (depending on user selected settings) + for custom editing.""" # pylint:disable=duplicate-code - def __init__(self, **kwargs): - git_model_id = None - model_filename = None - super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.input_size = 256 - self.name = "Custom" - self.vram = 0 # Doesn't use GPU - self.vram_per_batch = 0 - self.batchsize = cfg.batch_size() - self._storage_centering = T.cast("CenteringType", cfg.centering()) + + def __init__(self): + super().__init__(input_size=256, + batch_size=cfg.batch_size(), + is_rgb=False, + dtype="uint8", + scale=(0, 255), + centering=T.cast("CenteringType", cfg.centering())) # Separate storage for face and head masks - self._storage_name = f"{self._storage_name}_{self._storage_centering}" + self.storage_name = f"{self.storage_name}_{self.centering}" + self._fill = cfg.fill() - def init_model(self) -> None: + def load_model(self) -> None: + """No model to load, just return""" logger.debug("No mask model to initialize") - def process_input(self, batch: BatchType) -> None: - """ Compile the detected faces for prediction """ - batch.feed = np.zeros((self.batchsize, self.input_size, self.input_size, 1), - dtype="float32") + def pre_process(self, batch: np.ndarray) -> np.ndarray: + """ Return a zero array of the same shape and dtype as the input array + + Parameters + ---------- + batch + The batch of aligned faces in the correct format for the model + + Returns + ------- + A zero'd array of the same shape and dtype as the input + """ + return np.zeros(batch.shape[:3], dtype="uint8") + + def process(self, batch: np.ndarray) -> np.ndarray: + """Get the masks from the model - def predict(self, feed: np.ndarray) -> np.ndarray: - """ Run model to get predictions """ - if cfg.fill(): - feed[:] = 1.0 - return feed + Parameters + ---------- + batch + The batch to process - def process_output(self, batch: BatchType) -> None: - """ Compile found faces for output """ - return + Returns + ------- + The processed empty masks + """ + if self._fill: + batch[:] = 255 + return batch __all__ = get_module_objects(__name__) diff --git a/plugins/extract/mask/custom_defaults.py b/plugins/extract/mask/custom_defaults.py index 4eea21fcf7..aa24ff45e1 100644 --- a/plugins/extract/mask/custom_defaults.py +++ b/plugins/extract/mask/custom_defaults.py @@ -38,7 +38,7 @@ batch_size = ConfigItem( datatype=int, - default=8, + default=16, group="settings", info="The batch size to use. To a point, higher batch sizes equal better performance, " "but setting it too high can harm performance.", diff --git a/plugins/extract/mask/extended.py b/plugins/extract/mask/extended.py deleted file mode 100644 index e88ba959dc..0000000000 --- a/plugins/extract/mask/extended.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python3 -""" Extended Mask for faceswap.py """ -from __future__ import annotations -import logging -import typing as T - -import cv2 -import numpy as np - -from lib.align import LandmarkType -from lib.utils import get_module_objects - -from ._base import BatchType, Masker - -logger = logging.getLogger(__name__) - -if T.TYPE_CHECKING: - from lib.align.aligned_face import AlignedFace - - -class Mask(Masker): - """ Apply a landmarks based extended mask """ - def __init__(self, **kwargs): - git_model_id = None - model_filename = None - super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.input_size = 256 - self.name = "Extended" - self.vram = 0 # Doesn't use GPU - self.vram_per_batch = 0 - self.batchsize = 1 - self.landmark_type = LandmarkType.LM_2D_68 - - def init_model(self) -> None: - logger.debug("No mask model to initialize") - - def process_input(self, batch: BatchType) -> None: - """ Compile the detected faces for prediction """ - batch.feed = np.zeros((self.batchsize, self.input_size, self.input_size, 1), - dtype="float32") - - def predict(self, feed: np.ndarray) -> np.ndarray: - """ Run model to get predictions """ - faces: list[AlignedFace] = feed[1] - feed = feed[0] - for mask, face in zip(feed, faces): - if LandmarkType.from_shape(face.landmarks.shape) != self.landmark_type: - # Called from the manual tool. # TODO This will only work with BS1 - feed = np.zeros_like(feed) - continue - parts = self.parse_parts(np.array(face.landmarks)) - for item in parts: - a_item = np.rint(np.concatenate(item)).astype("int32") - hull = cv2.convexHull(a_item) - cv2.fillConvexPoly(mask, hull, [1.0], lineType=cv2.LINE_AA) - return feed - - def process_output(self, batch: BatchType) -> None: - """ Compile found faces for output """ - return - - @classmethod - def _adjust_mask_top(cls, landmarks: np.ndarray) -> None: - """ Adjust the top of the mask to extend above eyebrows - - Parameters - ---------- - landmarks: :class:`numpy.ndarray` - The 68 point landmarks to be adjusted - """ - # mid points between the side of face and eye point - ml_pnt = (landmarks[36] + landmarks[0]) // 2 - mr_pnt = (landmarks[16] + landmarks[45]) // 2 - - # mid points between the mid points and eye - ql_pnt = (landmarks[36] + ml_pnt) // 2 - qr_pnt = (landmarks[45] + mr_pnt) // 2 - - # Top of the eye arrays - bot_l = np.array((ql_pnt, landmarks[36], landmarks[37], landmarks[38], landmarks[39])) - bot_r = np.array((landmarks[42], landmarks[43], landmarks[44], landmarks[45], qr_pnt)) - - # Eyebrow arrays - top_l = landmarks[17:22] - top_r = landmarks[22:27] - - # Adjust eyebrow arrays - landmarks[17:22] = top_l + ((top_l - bot_l) // 2) - landmarks[22:27] = top_r + ((top_r - bot_r) // 2) - - def parse_parts(self, landmarks: np.ndarray) -> list[tuple[np.ndarray, ...]]: - """ Extended face hull mask """ - self._adjust_mask_top(landmarks) - - r_jaw = (landmarks[0:9], landmarks[17:18]) - l_jaw = (landmarks[8:17], landmarks[26:27]) - r_cheek = (landmarks[17:20], landmarks[8:9]) - l_cheek = (landmarks[24:27], landmarks[8:9]) - nose_ridge = (landmarks[19:25], landmarks[8:9],) - r_eye = (landmarks[17:22], - landmarks[27:28], - landmarks[31:36], - landmarks[8:9]) - l_eye = (landmarks[22:27], - landmarks[27:28], - landmarks[31:36], - landmarks[8:9]) - nose = (landmarks[27:31], landmarks[31:36]) - parts = [r_jaw, l_jaw, r_cheek, l_cheek, nose_ridge, r_eye, l_eye, nose] - return parts - - -__all__ = get_module_objects(__name__) diff --git a/plugins/extract/mask/unet_dfl.py b/plugins/extract/mask/unet_dfl.py index ec196b1137..0dba511c34 100644 --- a/plugins/extract/mask/unet_dfl.py +++ b/plugins/extract/mask/unet_dfl.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" UNET DFL face mask plugin +"""UNET DFL face mask plugin Architecture and Pre-Trained Model based on... TernausNet: U-Net with VGG11 Encoder Pre-Trained on ImageNet for Image Segmentation @@ -18,237 +18,192 @@ import typing as T import numpy as np -from keras import backend as K, layers as kl, Model +import torch +from torch import nn +from torch.nn import functional as F -from lib.logger import parse_class_init -from lib.utils import get_module_objects -from ._base import BatchType, Masker, MaskerBatch +from lib.utils import get_module_objects, GetModel +from plugins.extract.base import FacePlugin from . import unet_dfl_defaults as cfg if T.TYPE_CHECKING: - from keras import KerasTensor + from torch import Tensor logger = logging.getLogger(__name__) +# pylint:disable=duplicate-code + + +class UNetDFL(FacePlugin): + """Neural network to process face image into a segmentation mask of the face""" + def __init__(self) -> None: + super().__init__(input_size=256, + batch_size=cfg.batch_size(), + is_rgb=False, + dtype="float32", + scale=(0, 1), + centering="legacy") + self.model: UnetDFL + def load_model(self) -> UnetDFL: + """Initialize the UNet-DFL Model -class Mask(Masker): - """ Neural network to process face image into a segmentation mask of the face """ - def __init__(self, **kwargs) -> None: - git_model_id = 6 - model_filename = "DFL_256_sigmoid_v1.h5" - super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.model: UnetDFL - self.name = "U-Net" - self.input_size = 256 - self.vram = 320 # 276 in testing - self.vram_per_batch = 256 # ~215 in testing - self.batchsize = cfg.batch_size() - self._storage_centering = "legacy" - - def init_model(self) -> None: - assert self.name is not None and isinstance(self.model_path, str) - self.model = UnetDFL(self.model_path, self.batchsize) - placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), - dtype="float32") - self.model(placeholder) - - def process_input(self, batch: BatchType) -> None: - """ Compile the detected faces for prediction """ - assert isinstance(batch, MaskerBatch) - batch.feed = np.array([T.cast(np.ndarray, feed.face)[..., :3] - for feed in batch.feed_faces], dtype="float32") / 255.0 - logger.trace("feed shape: %s", batch.feed.shape) # type: ignore - - def predict(self, feed: np.ndarray) -> np.ndarray: - """ Run model to get predictions """ - return self.model(feed) - - def process_output(self, batch: BatchType) -> None: - """ Compile found faces for output """ - return - - -class UnetDFL: - """ UNet DFL Definition for Keras 3 with PyTorch backend + Returns + ------- + The loaded UnetDFL model + """ + weights = GetModel("DFL_256_sigmoid_v2.pth", 6).model_path + assert isinstance(weights, str) + return T.cast(UnetDFL, self.load_torch_model(UnetDFL(), weights)) - Parameters - ---------- - weights_path: str - Full path to the location of the weights file for the model - batch_size: int - The batch size to feed the model at - - Note - ---- - Model definition is explicitly stated as there is an incompatibility for certain - Conv2DTranspose combinations when model was trained on one backend but inferred on another: - https://github.com/keras-team/keras-core/issues/774 - The effect of this misaligns the mask and peforms bad inference for this model. - """ - def __init__(self, weights_path: str, batch_size: int) -> None: - logger.debug(parse_class_init(locals())) - self._batch_size = batch_size - self._model = self._load_model(weights_path) - logger.debug("Initialized: %s", self.__class__.__name__) - - @classmethod - def conv_block(cls, - inputs: KerasTensor, - filters: int, - recursions: int, - idx: int) -> KerasTensor: - """ Convolution block for UnetDFL downscales + def process(self, batch: np.ndarray) -> np.ndarray: + """Get the masks from the model Parameters ---------- - inputs: :class:`keras.KerasTensor` - The inputs to the block - filters: int - The number of filters for the convolution - recursions: int - The number of convolutions to run - idx: The index id of the first convolution (used for naming) + batch + The batch to feed into the masker Returns ------- - :class:`keras.KerasTensor` - The output from the convolution block + The predicted masks from the plugin """ - output = inputs - - for _ in range(recursions): - output = kl.Conv2D(filters, - 3, - padding="same", - activation="relu", - kernel_initializer="random_uniform", - name=f"features_{idx}")(output) - idx += 2 - - return output - - @classmethod - def skip_block(cls, # pylint:disable=too-many-positional-arguments - input_1: KerasTensor, - input_2: KerasTensor, - conv_filters: int, - trans_filters: int, - linear: bool, - idx: int) -> KerasTensor: - """ Deconvolution + skip connection for UnetDFL upscales + return self.from_torch(batch.transpose(0, 3, 1, 2)).transpose(0, 2, 3, 1) + + +class ConvBlock(nn.Module): + """Convolution block for UnetDFL down-scales + + Parameters + ---------- + in_channels + The number of input channels to the block + filters + The number of filters for the convolution + recursions: int + The number of convolutions to run + """ + def __init__(self, in_channels: int, filters: int, recursions: int) -> None: + super().__init__() + layers = [nn.Conv2d(in_channels, filters, 3, padding=1), + nn.ReLU(inplace=True)] + for _ in range(recursions - 1): + layers.extend([nn.Conv2d(filters, filters, 3, padding=1), + nn.ReLU(inplace=True)]) + self.convs = nn.Sequential(*layers) + + def forward(self, inputs: Tensor) -> Tensor: + """Convolution Block forward pass Parameters ---------- - input_1: :class:`keras.KerasTensor` - The input to be upscaled - input_2: :class:`keras.KerasTensor` - The skip connection to be concatenated to the upscaled tensor - conv_filters: int - The number of filters to be used for the convolution - trans_filters: int - The number of filters to be used for the conv-transpose - linear: bool - ``True`` to use linear activation in the convolution, ``False`` to use ReLu - idx: int - The index for naming the layers + inputs + The input to the convolution block Returns ------- - :class:`keras.KerasTensor` - The output from the upscaled/skip connection + The output from the convolution block """ - output = kl.Conv2D(conv_filters, - 3, - padding="same", - activation="linear" if linear else "relu", - kernel_initializer="random_uniform", - name=f"conv2d_{idx}")(input_1) - - # TF vs PyTorch paddng is different. We need to negative pad the output for Torch - padding = "valid" if K.backend() == "torch" else "same" - output = kl.Conv2DTranspose(trans_filters, - 3, - strides=2, - padding=padding, - activation="relu", - kernel_initializer="random_uniform", - name=f"conv2d_transpose_{idx}")(output) - - if K.backend() == "torch": - output = output[:, :-1, :-1, :] - - return kl.Concatenate(name=f"concatenate_{idx}")([output, input_2]) - - def _load_model(self, weights_path: str) -> Model: - """ Definition of the UNet-DFL Model. + return self.convs(inputs) + + +class DecoderBlock(nn.Module): + """Decoder Block for UnetDFL + + Parameters + ---------- + in_channels + The number of input channels to the block + middle_channels + The number of filters for the first convolution + out_channels + The number of filters for the second convolution + relu + ``True`` to use ReLU activation on the first conv. ``False`` to use no activation + """ + def __init__(self, + in_channels: int, + middle_channels: int, + out_channels: int, + relu: bool) -> None: + super().__init__() + self._use_relu = relu + self.conv = nn.Conv2d(in_channels, middle_channels, 3, padding=1) + self.conv_trans = nn.ConvTranspose2d(middle_channels, + out_channels, + 3, + stride=2, + padding=0, + output_padding=0) + + def forward(self, inputs: Tensor) -> Tensor: + """Decoder block forward pass Parameters ---------- - weights_path: str - Full path to the model's weights + inputs + The input to the decoder block Returns ------- - :class:`keras.models.Model` - The VGG-Clear model + The output from the decoder block """ - features = [] - input_ = kl.Input(shape=(256, 256, 3), name="input_1") - - features.append(self.conv_block(input_, 64, 1, 0)) - var_x = kl.MaxPool2D(pool_size=2, strides=2, name="max_pooling2d_1")(features[-1]) - - features.append(self.conv_block(var_x, 128, 1, 3)) - var_x = kl.MaxPool2D(pool_size=2, strides=2, name="max_pooling2d_2")(features[-1]) - - features.append(self.conv_block(var_x, 256, 2, 6)) - var_x = kl.MaxPool2D(pool_size=2, strides=2, name="max_pooling2d_3")(features[-1]) - - features.append(self.conv_block(var_x, 512, 2, 11)) - var_x = kl.MaxPool2D(pool_size=2, strides=2, name="max_pooling2d_4")(features[-1]) - - features.append(self.conv_block(var_x, 512, 2, 16)) - var_x = kl.MaxPool2D(pool_size=2, strides=2, name="max_pooling2d_5")(features[-1]) - - convs = [512, 512, 512, 256, 128] - for idx, (feats, filts) in enumerate(zip(reversed(features), convs)): - linear = idx == 0 - trans_filts = filts // 2 if idx < 2 else filts // 4 - var_x = self.skip_block(var_x, feats, filts, trans_filts, linear, idx + 1) - - var_x = kl.Conv2D(64, - 3, - padding="same", - activation="relu", - kernel_initializer="random_uniform", - name="conv2d_6")(var_x) - output = kl.Conv2D(1, - 3, - padding="same", - activation="sigmoid", - kernel_initializer="random_uniform", - name="conv2d_7")(var_x) - - model = Model(input_, output) - model.load_weights(weights_path) - model.make_predict_function() - return model - - def __call__(self, inputs: np.ndarray) -> np.ndarray: - """ Obtain predictions from the UNet-DFL Model + x = self.conv(inputs) + if self._use_relu: + x = F.relu(x, inplace=True) + x = F.relu(self.conv_trans(x), inplace=True) + return x[:, :, :-1, :-1] + + +class UnetDFL(nn.Module): # pylint:disable=too-many-instance-attributes + """UNet DFL Definition for PyTorch""" + def __init__(self) -> None: + super().__init__() + self.features_0 = ConvBlock(3, 64, 1) + self.features_3 = ConvBlock(64, 128, 1) + self.features_8 = ConvBlock(128, 256, 2) + self.features_13 = ConvBlock(256, 512, 2) + self.features_18 = ConvBlock(512, 512, 2) + self.dec1 = DecoderBlock(512, 512, 256, False) + self.dec2 = DecoderBlock(768, 512, 256, True) + self.dec3 = DecoderBlock(768, 512, 128, True) + self.dec4 = DecoderBlock(384, 256, 64, True) + self.dec5 = DecoderBlock(192, 128, 32, True) + self.conv2d_6 = nn.Conv2d(96, 64, 3, padding=1) + self.conv2d_7 = nn.Conv2d(64, 1, 3, padding=1) + + def forward(self, inputs: Tensor) -> Tensor: + """UnetDFL forward pass Parameters ---------- - inputs: :class:`numpy.ndarray` - The input to UNet-DFL + inputs + The input to UnetDFL Returns ------- - :class:`numpy.ndarray` - The output from UNet-DFL + The output from UnetDFL """ - return self._model.predict(inputs, verbose=0, batch_size=self._batch_size) + features = [] + features.append(self.features_0(inputs)) + x = F.max_pool2d(features[-1], 2, stride=2) + features.append(self.features_3(x)) + x = F.max_pool2d(features[-1], 2, stride=2) + features.append(self.features_8(x)) + x = F.max_pool2d(features[-1], 2, stride=2) + features.append(self.features_13(x)) + x = F.max_pool2d(features[-1], 2, stride=2) + features.append(self.features_18(x)) + x = F.max_pool2d(features[-1], 2, stride=2) + + x = torch.cat([self.dec1(x), features[4]], dim=1) + x = torch.cat([self.dec2(x), features[3]], dim=1) + x = torch.cat([self.dec3(x), features[2]], dim=1) + x = torch.cat([self.dec4(x), features[1]], dim=1) + x = torch.cat([self.dec5(x), features[0]], dim=1) + + x = F.relu(self.conv2d_6(x), inplace=True) + return F.sigmoid(self.conv2d_7(x)) __all__ = get_module_objects(__name__) diff --git a/plugins/extract/mask/unet_dfl_defaults.py b/plugins/extract/mask/unet_dfl_defaults.py index 4d20870c4e..b555ee25c2 100644 --- a/plugins/extract/mask/unet_dfl_defaults.py +++ b/plugins/extract/mask/unet_dfl_defaults.py @@ -31,8 +31,8 @@ HELPTEXT = ( "UNET_DFL options. Mask designed to provide smart segmentation of mostly frontal faces.\n" - "The mask model has been trained by community members. Insert more commentary on testing " - "here. Profile faces may result in sub-par performance." + "The mask model has been trained by community members. Profile faces may result in sub-par " + "performance." ) @@ -41,8 +41,6 @@ default=8, group="settings", info="The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered.", + "but setting it too high can harm performance.", rounding=1, - min_max=(1, 64)) + min_max=(1, 128)) diff --git a/plugins/extract/mask/vgg_clear.py b/plugins/extract/mask/vgg_clear.py index dc1a32a73c..7d95ab4162 100644 --- a/plugins/extract/mask/vgg_clear.py +++ b/plugins/extract/mask/vgg_clear.py @@ -1,246 +1,224 @@ #!/usr/bin/env python3 -""" VGG Clear face mask plugin. """ +"""VGG Clear face mask plugin.""" from __future__ import annotations import logging import typing as T import numpy as np +from torch import nn +from torch.nn import functional as F -from keras import layers as kl, Model - -from lib.logger import parse_class_init -from lib.utils import get_module_objects -from ._base import BatchType, Masker, MaskerBatch +from lib.utils import get_module_objects, GetModel +from plugins.extract.base import FacePlugin from . import vgg_clear_defaults as cfg if T.TYPE_CHECKING: - from keras import KerasTensor + from torch import Tensor logger = logging.getLogger(__name__) +# pylint:disable=duplicate-code -class Mask(Masker): - """ Neural network to process face image into a segmentation mask of the face """ - def __init__(self, **kwargs) -> None: - git_model_id = 8 - model_filename = "Nirkin_300_softmax_v1.h5" - super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.model: VGGClear - self.name = "VGG Clear" - self.input_size = 300 - self.vram = 1344 # 1308 in testing - self.vram_per_batch = 448 # ~402 in testing - self.batchsize = cfg.batch_size() - - def init_model(self) -> None: - assert isinstance(self.model_path, str) - self.model = VGGClear(self.model_path, self.batchsize) - placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), - dtype="float32") - self.model(placeholder) - - def process_input(self, batch: BatchType) -> None: - """ Compile the detected faces for prediction """ - assert isinstance(batch, MaskerBatch) - input_ = np.array([T.cast(np.ndarray, feed.face)[..., :3] - for feed in batch.feed_faces], dtype="float32") - batch.feed = input_ - np.mean(input_, axis=(1, 2))[:, None, None, :] - logger.trace("feed shape: %s", batch.feed.shape) # type: ignore - - def predict(self, feed: np.ndarray) -> np.ndarray: - """ Run model to get predictions """ - predictions = self.model(feed) - assert isinstance(predictions, np.ndarray) - return predictions[..., -1] - - def process_output(self, batch: BatchType) -> None: - """ Compile found faces for output """ - return - - -class VGGClear(): - """ VGG Clear mask for Faceswap. - - Caffe model re-implemented in Keras by Kyle Vrooman. - Re-implemented for Keras by TorzDF - - Parameters - ---------- - weights_path: str - The path to the keras model file - batch_size: int - The batch size to feed the model - - References - ---------- - On Face Segmentation, Face Swapping, and Face Perception (https://arxiv.org/abs/1704.06729) +class VGGClear(FacePlugin): + """Neural network to process face image into a segmentation mask of the face""" + def __init__(self) -> None: + super().__init__(input_size=300, + batch_size=cfg.batch_size(), + is_rgb=False, + dtype="float32", + scale=(0, 255), + centering="face") + self.model: VGGClearModel - Source Implementation: https://github.com/YuvalNirkin/face_segmentation + def load_model(self) -> VGGClearModel: + """Initialize the VGG Clear Mask model. - Model file sourced from: - https://github.com/YuvalNirkin/face_segmentation/releases/download/1.1/face_seg_fcn8s_300_no_aug.zip - - """ - def __init__(self, weights_path: str, batch_size: int) -> None: - logger.debug(parse_class_init(locals())) - self._batch_size = batch_size - self._model = self._load_model(weights_path) - logger.debug("Initialized: %s", self.__class__.__name__) + Returns + ------- + The loaded VGGClear model + """ + weights = GetModel("Nirkin_300_softmax_v2.pth", 8).model_path + assert isinstance(weights, str) + return T.cast(VGGClearModel, self.load_torch_model(VGGClearModel(), + weights, + return_indices=[-1])) - @classmethod - def _load_model(cls, weights_path: str) -> Model: - """ Definition of the VGG Clear Model. + def pre_process(self, batch: np.ndarray) -> np.ndarray: + """Format the detected faces for prediction Parameters ---------- - weights_path: str - Full path to the model's weights + batch + The batch of aligned faces in the correct format for the model Returns ------- - :class:`keras.models.Model` - The VGG-Clear model + The updated images for feeding the model """ - input_ = kl.Input(shape=(300, 300, 3)) - var_x = kl.ZeroPadding2D(padding=((100, 100), (100, 100)), name="zero_padding2d_1")(input_) - - var_x = _ConvBlock(1, 64, 2)(var_x) - var_x = _ConvBlock(2, 128, 2)(var_x) - pool3 = _ConvBlock(3, 256, 3)(var_x) - pool4 = _ConvBlock(4, 512, 3)(pool3) - var_x = _ConvBlock(5, 512, 3)(pool4) - - score_pool3 = _ScorePool(3, 0.0001, (9, 8))(pool3) - score_pool4 = _ScorePool(4, 0.01, (5, 5))(pool4) - - var_x = kl.Conv2D(4096, 7, activation="relu", name="fc6")(var_x) - var_x = kl.Dropout(rate=0.5, name="drop6")(var_x) - var_x = kl.Conv2D(4096, 1, activation="relu", name="fc7")(var_x) - var_x = kl.Dropout(rate=0.5, name="drop7")(var_x) - var_x = kl.Conv2D(2, 1, activation="linear", name="score_fr_r")(var_x) - var_x = kl.Conv2DTranspose(2, - 4, - strides=2, - activation="linear", - use_bias=False, name="upscore2_r")(var_x) - - var_x = kl.Add(name="fuse_pool4")([var_x, score_pool4]) - var_x = kl.Conv2DTranspose(2, - 4, - strides=2, - activation="linear", - use_bias=False, - name="upscore_pool4_r")(var_x) - var_x = kl.Add(name="fuse_pool3")([var_x, score_pool3]) - var_x = kl.Conv2DTranspose(2, - 16, - strides=8, - activation="linear", - use_bias=False, - name="upscore8_r")(var_x) - var_x = kl.Cropping2D(cropping=((31, 45), (31, 45)), name="score")(var_x) - var_x = kl.Activation("softmax", name="softmax")(var_x) - - retval = Model(input_, var_x) - retval.load_weights(weights_path) - retval.make_predict_function() - return retval - - def __call__(self, inputs: np.ndarray) -> np.ndarray: - """ Get predictions from the VGG-Clear model + return (batch - np.mean(batch, axis=(1, 2))[:, None, None, :]).transpose(0, 3, 1, 2) + + def process(self, batch: np.ndarray) -> np.ndarray: + """Get the masks from the model Parameters ---------- - inputs: :class:`numpy.ndarray` - The input to VGG-Clear + batch + The batch to feed into the masker Returns ------- - :class:`numpy.ndarray` - The output from VGG-Clear + The predicted masks from the plugin """ - return self._model.predict(inputs, verbose=0, batch_size=self._batch_size) + return self.from_torch(batch) -class _ConvBlock(): - """ Convolutional loop with max pooling layer for VGG Clear. +class ConvBlock(nn.Module): + """Convolutional loop with max pooling layer for VGG Clear. Parameters ---------- - level: int - For naming. The current level for this convolutional loop - filters: int + in_channels + The number of input channels to the model + filters The number of filters that should appear in each Conv2D layer - iterations: int + iterations The number of consecutive Conv2D layers to create + padding + The amount of padding to apply to the first convolution """ - def __init__(self, level: int, filters: int, iterations: int) -> None: - self._name = f"conv{level}_" - self._level = level - self._filters = filters - self._iterator = range(1, iterations + 1) - - def __call__(self, inputs: KerasTensor) -> KerasTensor: - """ Call the convolutional loop. + def __init__(self, in_channels: int, filters: int, iterations: int, padding: int = 1) -> None: + super().__init__() + layers = [nn.Conv2d(in_channels, filters, 3, padding=padding), + nn.ReLU(inplace=True)] + for _ in range(iterations - 1): + layers.append(nn.Conv2d(filters, filters, 3, padding=1)) + layers.append(nn.ReLU(inplace=True)) + self.convs = nn.Sequential(*layers) + self._pool_padding = 0 if in_channels == filters else padding + + def forward(self, inputs: Tensor) -> Tensor: + """Call the convolutional loop. Parameters ---------- - inputs: :class:`keras.KerasTensor` + inputs The input tensor to the block Returns ------- - :class:`keras.KerasTensor` + The output tensor from the convolutional block """ - var_x = inputs - for i in self._iterator: - padding = "valid" if self._level == i == 1 else "same" - var_x = kl.Conv2D(self._filters, - 3, - padding=padding, - activation="relu", - name=f"{self._name}{i}")(var_x) - var_x = kl.MaxPooling2D(padding="same", - strides=(2, 2), - name=f"pool{self._level}")(var_x) - return var_x - - -class _ScorePool(): - """ Cropped scaling of the pooling layer. + x = self.convs(inputs) + x = F.max_pool2d(x, 2, stride=2, padding=self._pool_padding) + return x + + +class ScorePool(nn.Module): + """Cropped scaling of the pooling layer. Parameters ---------- - level: int - For naming. The current level for this score pool - scale: float + in_channels + The number of input channels to the model + scale : float The scaling to apply to the pool - crop: tuple - The amount of 2D cropping to apply. Tuple of `ints` + crop : tuple[int, int] + The amount of 2D cropping to apply. Tuple of (Left/Top, Right/Bottom) `ints` """ - def __init__(self, level: int, scale: float, crop: tuple[int, int]): - self._name = f"_pool{level}" - self._cropping = (crop, crop) + def __init__(self, in_channels: int, scale: float, crop: tuple[int, int]) -> None: + super().__init__() self._scale = scale + self.conv = nn.Conv2d(in_channels, 2, 1) + self._crop = crop - def __call__(self, inputs: np.ndarray) -> np.ndarray: - """ Score pool block. + def forward(self, inputs: Tensor) -> Tensor: + """Call the score pool layer. Parameters ---------- - inputs: tensor + inputs The input tensor to the block Returns ------- - tensor - The output tensor from the score pool block + The output tensor from the block + """ + x = inputs * self._scale + x = self.conv(x) + x = x[:, :, self._crop[0]:-self._crop[1], self._crop[0]:-self._crop[1]] + return x + + +class VGGClearModel(nn.Module): # pylint:disable=too-many-instance-attributes + """VGG Clear mask for Faceswap. + + Caffe model re-implemented in Keras by Kyle Vrooman. + Re-implemented for torch by TorzDF + + References + ---------- + On Face Segmentation, Face Swapping, and Face Perception (https://arxiv.org/abs/1704.06729) + + Source Implementation: https://github.com/YuvalNirkin/face_segmentation + + Model file sourced from: + https://github.com/YuvalNirkin/face_segmentation/releases/download/1.1/face_seg_fcn8s_300_no_aug.zip + + """ + def __init__(self) -> None: + super().__init__() + self.zeropad = nn.ZeroPad2d(100) + self.conv1 = ConvBlock(3, 64, 2, padding=0) + self.conv2 = ConvBlock(64, 128, 2) + self.conv3 = ConvBlock(128, 256, 3) + self.conv4 = ConvBlock(256, 512, 3) + self.conv5 = ConvBlock(512, 512, 3) + self.fc6 = nn.Conv2d(512, 4096, 7) + self.fc7 = nn.Conv2d(4096, 4096, 1) + self.score_fr_r = nn.Conv2d(4096, 2, 1) + self.upscore2_r = nn.ConvTranspose2d(2, 2, 4, stride=2, bias=False) + self.score_pool4 = ScorePool(512, 0.01, (5, 5)) + self.upscore_pool4_r = nn.ConvTranspose2d(2, 2, 4, stride=2, bias=False) + self.score_pool3 = ScorePool(256, 0.0001, (9, 8)) + self.upscore8_r = nn.ConvTranspose2d(2, 2, 16, stride=8, bias=False) + + def forward(self, inputs: Tensor) -> Tensor: + """Call the VGG Clear Model. + + Parameters + ---------- + inputs + The input to the model + + Returns + ------- + The output from the VGG-Clear model """ - var_x = kl.Lambda(lambda x: x * self._scale, name="scale" + self._name)(inputs) - var_x = kl.Conv2D(2, 1, activation="linear", name="score" + self._name + "_r")(var_x) - var_x = kl.Cropping2D(cropping=self._cropping, name="score" + self._name + "c")(var_x) - return var_x + x = self.zeropad(inputs) + x = self.conv1(x) + x = self.conv2(x) + pool3 = self.conv3(x) + pool4 = self.conv4(pool3) + x = self.conv5(pool4) + + x = F.relu(self.fc6(x), inplace=True) + x = F.dropout(x, 0.5) + x = F.relu(self.fc7(x), inplace=True) + x = F.dropout(x, 0.5) + + x = self.score_fr_r(x) + x = self.upscore2_r(x) + score_pool4 = self.score_pool4(pool4) + x = x + score_pool4 + + x = self.upscore_pool4_r(x) + score_pool3 = self.score_pool3(pool3) + x = x + score_pool3 + + x = self.upscore8_r(x) + x = x[:, :, 31:-45, 31:-45] + return F.softmax(x, dim=1).swapaxes(0, 1) __all__ = get_module_objects(__name__) diff --git a/plugins/extract/mask/vgg_clear_defaults.py b/plugins/extract/mask/vgg_clear_defaults.py index 48ee92f329..37412b8577 100644 --- a/plugins/extract/mask/vgg_clear_defaults.py +++ b/plugins/extract/mask/vgg_clear_defaults.py @@ -40,8 +40,6 @@ default=6, group="settings", info="The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered.", + "but setting it too high can harm performance.", rounding=1, - min_max=(1, 64)) + min_max=(1, 128)) diff --git a/plugins/extract/mask/vgg_obstructed.py b/plugins/extract/mask/vgg_obstructed.py index b2733c5bc2..166ba3b547 100644 --- a/plugins/extract/mask/vgg_obstructed.py +++ b/plugins/extract/mask/vgg_obstructed.py @@ -1,251 +1,229 @@ #!/usr/bin/env python3 -""" VGG Obstructed face mask plugin """ +"""VGG Obstructed face mask plugin""" from __future__ import annotations import logging import typing as T import numpy as np -from keras import layers as kl, Model +from torch import nn +from torch.nn import functional as F -from lib.logger import parse_class_init -from lib.utils import get_module_objects -from ._base import BatchType, Masker, MaskerBatch +from lib.utils import get_module_objects, GetModel +from plugins.extract.base import FacePlugin from . import vgg_obstructed_defaults as cfg if T.TYPE_CHECKING: - from keras import KerasTensor + from torch import Tensor logger = logging.getLogger(__name__) # pylint:disable=duplicate-code -class Mask(Masker): - """ Neural network to process face image into a segmentation mask of the face """ - def __init__(self, **kwargs) -> None: - git_model_id = 5 - model_filename = "Nirkin_500_softmax_v1.h5" - super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.model: VGGObstructed - self.name = "VGG Obstructed" - self.input_size = 500 - self.vram = 1728 # 1710 in testing - self.vram_per_batch = 896 # ~886 in testing - self.batchsize = cfg.batch_size() - - def init_model(self) -> None: - assert isinstance(self.model_path, str) - self.model = VGGObstructed(self.model_path, self.batchsize) - placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), - dtype="float32") - self.model(placeholder) - - def process_input(self, batch: BatchType) -> None: - """ Compile the detected faces for prediction """ - assert isinstance(batch, MaskerBatch) - input_ = [T.cast(np.ndarray, feed.face)[..., :3] for feed in batch.feed_faces] - batch.feed = input_ - np.mean(input_, axis=(1, 2))[:, None, None, :] - logger.trace("feed shape: %s", batch.feed.shape) # type:ignore[attr-defined] - - def predict(self, feed: np.ndarray) -> np.ndarray: - """ Run model to get predictions """ - predictions = self.model(feed) - assert isinstance(predictions, np.ndarray) - return predictions[..., 0] * -1.0 + 1.0 - - def process_output(self, batch: BatchType) -> None: - """ Compile found faces for output """ - return - - -class VGGObstructed(): - """ VGG Obstructed mask for Faceswap. +class VGGObstructed(FacePlugin): + """Neural network to process face image into a segmentation mask of the face""" + def __init__(self) -> None: + super().__init__(input_size=500, + batch_size=cfg.batch_size(), + is_rgb=False, + dtype="float32", + scale=(0, 255), + centering="face") + self.model: VGGObstructedModel - Caffe model re-implemented in Keras by Kyle Vrooman. - Re-implemented for Keras by TorzDF + def load_model(self) -> VGGObstructedModel: + """Initialize the VGGObstructed Mask model. - Parameters - ---------- - weights_path: str - The path to the keras model file - batch_size: int - The batch size to feed the model - - References - ---------- - On Face Segmentation, Face Swapping, and Face Perception (https://arxiv.org/abs/1704.06729) - Source Implementation: https://github.com/YuvalNirkin/face_segmentation - Model file sourced from: - https://github.com/YuvalNirkin/face_segmentation/releases/download/1.0/face_seg_fcn8s.zip - """ - def __init__(self, weights_path: str, batch_size: int) -> None: - logger.debug(parse_class_init(locals())) - self._batch_size = batch_size - self._model = self._load_model(weights_path) - logger.debug("Initialized: %s", self.__class__.__name__) + Returns + ------- + The loaded VGGObstructed model + """ + weights = GetModel("Nirkin_500_softmax_v2.pth", 8).model_path + assert isinstance(weights, str) + return T.cast(VGGObstructedModel, self.load_torch_model(VGGObstructedModel(), + weights, + return_indices=[0])) - @classmethod - def _load_model(cls, weights_path: str) -> Model: - """ Definition of the VGG Obstructed Model. + def pre_process(self, batch: np.ndarray) -> np.ndarray: + """Format the detected faces for prediction Parameters ---------- - weights_path: str - Full path to the model's weights + batch + The batch of aligned faces in the correct format for the model Returns ------- - :class:`keras.models.Model` - The VGG-Obstructed model + The updated images for feeding the model """ - input_ = kl.Input(shape=(500, 500, 3)) - var_x = kl.ZeroPadding2D(padding=((100, 100), (100, 100)))(input_) - - var_x = _ConvBlock(1, 64, 2)(var_x) - var_x = _ConvBlock(2, 128, 2)(var_x) - var_x = _ConvBlock(3, 256, 3)(var_x) - - score_pool3 = _ScorePool(3, 0.0001, 9)(var_x) - var_x = _ConvBlock(4, 512, 3)(var_x) - score_pool4 = _ScorePool(4, 0.01, 5)(var_x) - var_x = _ConvBlock(5, 512, 3)(var_x) - - var_x = kl.Conv2D(4096, 7, padding="valid", activation="relu", name="fc6")(var_x) - var_x = kl.Dropout(rate=0.5)(var_x) - var_x = kl.Conv2D(4096, 1, padding="valid", activation="relu", name="fc7")(var_x) - var_x = kl.Dropout(rate=0.5)(var_x) - - var_x = kl.Conv2D(21, 1, padding="valid", activation="linear", name="score_fr")(var_x) - var_x = kl.Conv2DTranspose(21, - 4, - strides=2, - activation="linear", - use_bias=False, - name="upscore2")(var_x) - - var_x = kl.Add()([var_x, score_pool4]) - var_x = kl.Conv2DTranspose(21, - 4, - strides=2, - activation="linear", - use_bias=False, - name="upscore_pool4")(var_x) - - var_x = kl.Add()([var_x, score_pool3]) - var_x = kl.Conv2DTranspose(21, - 16, - strides=8, - activation="linear", - use_bias=False, - name="upscore8")(var_x) - var_x = kl.Cropping2D(cropping=((31, 37), (31, 37)), name="score")(var_x) - var_x = kl.Activation("softmax", name="softmax")(var_x) - - retval = Model(input_, var_x) - retval.load_weights(weights_path) - retval.make_predict_function() - return retval - - def __call__(self, inputs: np.ndarray) -> np.ndarray: - """ Get predictions from the VGG-Clear model + return (batch - np.mean(batch, axis=(1, 2))[:, None, None, :]).transpose(0, 3, 1, 2) + + def process(self, batch: np.ndarray) -> np.ndarray: + """Get the masks from the model Parameters ---------- - inputs: :class:`numpy.ndarray` - The input to VGG-Obstructed + batch + The batch to feed into the masker Returns ------- - :class:`numpy.ndarray` - The output from VGG-Obstructed + The predicted masks from the plugin """ - return self._model.predict(inputs, verbose=0, batch_size=self._batch_size) + return self.from_torch(batch) * -1.0 + 1.0 -class _ConvBlock(): - """ Convolutional loop with max pooling layer for VGG Obstructed. +class ConvBlock(nn.Module): + """Convolutional loop with max pooling layer for VGG Obstructed. Parameters ---------- - level: int - For naming. The current level for this convolutional loop - filters: int + in_channels + The number of input channels to the model + filters The number of filters that should appear in each Conv2D layer - iterations: int + iterations The number of consecutive Conv2D layers to create + padding + The amount of padding to apply to the first convolution. Default: 1 + pool_padding + The amount of padding to apply to the max pooling layer. Default: 1 """ - def __init__(self, level: int, filters: int, iterations: int) -> None: - self._name = f"conv{level}_" - self._level = level - self._filters = filters - self._iterator = range(1, iterations + 1) - - def __call__(self, inputs: KerasTensor) -> KerasTensor: - """ Call the convolutional loop. + def __init__(self, + in_channels: int, + filters: int, + iterations: int, + padding: int = 1, + pool_padding: int = 1) -> None: + super().__init__() + layers = [nn.Conv2d(in_channels, filters, 3, padding=padding), + nn.ReLU(inplace=True)] + for _ in range(iterations - 1): + layers.append(nn.Conv2d(filters, filters, 3, padding=1)) + layers.append(nn.ReLU(inplace=True)) + self.convs = nn.Sequential(*layers) + self._pool_padding = pool_padding + + def forward(self, inputs: Tensor) -> Tensor: + """Call the convolutional loop. Parameters ---------- - inputs: :class:`keras.KerasTensor` + inputs The input tensor to the block Returns ------- - :class:`keras.KerasTensor` - The output tensor from the convolutional block + The output tensor from the convolutional block """ - var_x = inputs - for i in self._iterator: - padding = "valid" if self._level == i == 1 else "same" - var_x = kl.Conv2D(self._filters, - 3, - padding=padding, - activation="relu", - name=f"{self._name}{i}")(var_x) - var_x = kl.MaxPooling2D(padding="same", - strides=(2, 2), - name=f"pool{self._level}")(var_x) - return var_x - - -class _ScorePool(): - """ Cropped scaling of the pooling layer. + x = self.convs(inputs) + x = F.max_pool2d(x, 2, stride=2, padding=self._pool_padding) + return x + + +class ScorePool(nn.Module): + """Cropped scaling of the pooling layer. Parameters ---------- - level: int - For naming. The current level for this score pool - scale: float + in_channels + The number of input channels to the model + scale : float The scaling to apply to the pool - crop: int - The amount of 2D cropping to apply + crop : tuple[int, int] + The amount of 2D cropping to apply. Tuple of (Left/Top, Right/Bottom) `ints` """ - def __init__(self, level: int, scale: float, crop: int) -> None: - self._name = f"_pool{level}" - self._cropping = ((crop, crop), (crop, crop)) + def __init__(self, in_channels: int, scale: float, crop: tuple[int, int]) -> None: + super().__init__() self._scale = scale + self.conv = nn.Conv2d(in_channels, 21, 1) + self._crop = crop - def __call__(self, inputs: KerasTensor) -> KerasTensor: - """ Score pool block. + def forward(self, inputs: Tensor) -> Tensor: + """Call the score pool layer. Parameters ---------- - inputs: :class:`keras.KerasTensor` + inputs The input tensor to the block Returns ------- - :class:`keras.KerasTensor` - The output tensor from the score pool block + The output tensor from the block + """ + x = inputs * self._scale + x = self.conv(x) + x = x[:, :, self._crop[0]:-self._crop[1], self._crop[0]:-self._crop[1]] + return x + + +class VGGObstructedModel(nn.Module): # pylint:disable=too-many-instance-attributes + """VGG Obstructed mask for Faceswap. + + Caffe model re-implemented in Keras by Kyle Vrooman. + Re-implemented for Pytorch by TorzDF + + References + ---------- + On Face Segmentation, Face Swapping, and Face Perception (https://arxiv.org/abs/1704.06729) + Source Implementation: https://github.com/YuvalNirkin/face_segmentation + Model file sourced from: + https://github.com/YuvalNirkin/face_segmentation/releases/download/1.0/face_seg_fcn8s.zip + """ + def __init__(self) -> None: + super().__init__() + self.zeropad = nn.ZeroPad2d(100) + self.conv1 = ConvBlock(3, 64, 2, padding=0, pool_padding=0) + self.conv2 = ConvBlock(64, 128, 2) + self.conv3 = ConvBlock(128, 256, 3) + self.conv4 = ConvBlock(256, 512, 3, pool_padding=0) + self.conv5 = ConvBlock(512, 512, 3, pool_padding=0) + self.fc6 = nn.Conv2d(512, 4096, 7) + self.fc7 = nn.Conv2d(4096, 4096, 1) + self.score_fr = nn.Conv2d(4096, 21, 1) + self.upscore2 = nn.ConvTranspose2d(21, 21, 4, stride=2, bias=False) + self.score_pool4 = ScorePool(512, 0.01, (5, 5)) + self.upscore_pool4 = nn.ConvTranspose2d(21, 21, 4, stride=2, bias=False) + self.score_pool3 = ScorePool(256, 0.0001, (9, 9)) + self.upscore8 = nn.ConvTranspose2d(21, 21, 16, stride=8, bias=False) + + def forward(self, inputs: Tensor) -> Tensor: + """Call the VGG Obstructed Model. + + Parameters + ---------- + inputs + The input to the model + + Returns + ------- + The output from the VGG Obstructed model """ - var_x = kl.Lambda(lambda x: x * self._scale, name="scale" + self._name)(inputs) - var_x = kl.Conv2D(21, - 1, - padding="valid", - activation="linear", - name="score" + self._name)(var_x) - var_x = kl.Cropping2D(cropping=self._cropping, name="score" + self._name + "c")(var_x) - return var_x + x = self.zeropad(inputs) + x = self.conv1(x) + x = self.conv2(x) + pool3 = self.conv3(x) + pool4 = self.conv4(pool3) + x = self.conv5(pool4) + + x = F.relu(self.fc6(x), inplace=True) + x = F.dropout(x, 0.5) + x = F.relu(self.fc7(x), inplace=True) + x = F.dropout(x, 0.5) + + x = self.score_fr(x) + x = self.upscore2(x) + score_pool4 = self.score_pool4(pool4) + x = x + score_pool4 + + x = self.upscore_pool4(x) + score_pool3 = self.score_pool3(pool3) + x = x + score_pool3 + + x = self.upscore8(x) + x = x[:, :, 31:-37, 31:-37] + return F.softmax(x, dim=1).swapaxes(0, 1) __all__ = get_module_objects(__name__) diff --git a/plugins/extract/mask/vgg_obstructed_defaults.py b/plugins/extract/mask/vgg_obstructed_defaults.py index 9a42624a40..22e1350c93 100644 --- a/plugins/extract/mask/vgg_obstructed_defaults.py +++ b/plugins/extract/mask/vgg_obstructed_defaults.py @@ -41,8 +41,6 @@ default=2, group="settings", info="The batch size to use. To a point, higher batch sizes equal better performance, " - "but setting it too high can harm performance.\n" - "\n\tNvidia users: If the batchsize is set higher than the your GPU can " - "accomodate then this will automatically be lowered.", + "but setting it too high can harm performance.", rounding=1, - min_max=(1, 64)) + min_max=(1, 128)) diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py deleted file mode 100644 index 52ec0e4373..0000000000 --- a/plugins/extract/pipeline.py +++ /dev/null @@ -1,875 +0,0 @@ -#!/usr/bin/env python3 -""" -Return a requested detector/aligner/masker pipeline - -This module sets up a pipeline for the extraction workflow, loading detect, align and mask -plugins either in parallel or in series, giving easy access to input and output. -""" -from __future__ import annotations -import logging -import os -import typing as T - -from lib.align import LandmarkType -from lib.gpu_stats import GPUStats -from lib.logger import parse_class_init -from lib.queue_manager import EventQueue, queue_manager, QueueEmpty -from lib.serializer import get_serializer -from lib.utils import get_backend, get_module_objects, FaceswapError -from plugins.plugin_loader import PluginLoader - -if T.TYPE_CHECKING: - from collections.abc import Generator - from ._base import Extractor as PluginExtractor - from .align._base import Aligner - from .align.external import Align as AlignImport - from .detect._base import Detector - from .detect.external import Detect as DetectImport - from .mask._base import Masker - from .recognition._base import Identity - from . import ExtractMedia - -logger = logging.getLogger(__name__) -_INSTANCES = -1 # Tracking for multiple instances of pipeline - - -def _get_instance(): - """ Increment the global :attr:`_INSTANCES` and obtain the current instance value """ - global _INSTANCES # pylint:disable=global-statement - _INSTANCES += 1 - return _INSTANCES - - -class Extractor(): # pylint:disable=too-many-instance-attributes - """ Creates a :mod:`~plugins.extract.detect`/:mod:`~plugins.extract.align``/\ - :mod:`~plugins.extract.mask` pipeline and yields results frame by frame from the - :attr:`detected_faces` generator - - :attr:`input_queue` is dynamically set depending on the current :attr:`phase` of extraction - - Parameters - ---------- - detector: str or ``None`` - The name of a detector plugin as exists in :mod:`plugins.extract.detect` - aligner: str or ``None`` - The name of an aligner plugin as exists in :mod:`plugins.extract.align` - masker: str or list or ``None`` - The name of a masker plugin(s) as exists in :mod:`plugins.extract.mask`. - This can be a single masker or a list of multiple maskers - recognition: str or ``None`` - The name of the recognition plugin to use. ``None`` to not do face recognition. - Default: ``None`` - configfile: str, optional - The path to a custom ``extract.ini`` configfile. If ``None`` then the system - :file:`config/extract.ini` file will be used. - multiprocess: bool, optional - Whether to attempt processing the plugins in parallel. This may get overridden - internally depending on the plugin combination. Default: ``False`` - rotate_images: str, optional - Used to set the :attr:`plugins.extract.detect.rotation` attribute. Pass in a single number - to use increments of that size up to 360, or pass in a ``list`` of ``ints`` to enumerate - exactly what angles to check. Can also pass in ``'on'`` to increment at 90 degree - intervals. Default: ``None`` - min_size: int, optional - Used to set the :attr:`plugins.extract.detect.min_size` attribute. Filters out faces - detected below this size. Length, in pixels across the diagonal of the bounding box. Set - to ``0`` for off. Default: ``0`` - normalize_method: {`None`, 'clahe', 'hist', 'mean'}, optional - Used to set the :attr:`plugins.extract.align.normalize_method` attribute. Normalize the - images fed to the aligner.Default: ``None`` - re_feed: int - The number of times to re-feed a slightly adjusted bounding box into the aligner. - Default: `0` - re_align: bool, optional - ``True`` to obtain landmarks by passing the initially aligned face back through the - aligner. Default ``False`` - disable_filter: bool, optional - Disable all aligner filters regardless of config option. Default: ``False`` - - Attributes - ---------- - phase: str - The current phase that the pipeline is running. Used in conjunction with :attr:`passes` and - :attr:`final_pass` to indicate to the caller which phase is being processed - """ - def __init__(self, # pylint:disable=too-many-arguments,too-many-positional-arguments - detector: str | None, - aligner: str | None, - masker: str | list[str] | None, - recognition: str | None = None, - configfile: str | None = None, - multiprocess: bool = False, - rotate_images: str | None = None, - min_size: int = 0, - normalize_method: T.Literal["none", "clahe", "hist", "mean"] | None = None, - re_feed: int = 0, - re_align: bool = False, - disable_filter: bool = False) -> None: - logger.debug(parse_class_init(locals())) - self._instance = _get_instance() - maskers = [T.cast(str | None, - masker)] if not isinstance(masker, list) else T.cast(list[str | None], - masker) - self._flow = self._set_flow(detector, aligner, maskers, recognition) - # TODO Calculate scaling for more plugins than currently exist in _parallel_scaling - self._scaling_fallback = 0.4 - self._vram_stats = self._get_vram_stats() - self._detect = self._load_detect(detector, aligner, rotate_images, min_size, configfile) - self._align = self._load_align(aligner, - configfile, - normalize_method, - re_feed, - re_align, - disable_filter) - self._recognition = self._load_recognition(recognition, configfile) - self._mask = [self._load_mask(mask, configfile) for mask in maskers] - self._phases = self._set_phases(multiprocess) - self._phase_index = 0 - self._set_extractor_batchsize() - self._queues = self._add_queues() - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def input_queue(self) -> EventQueue: - """ queue: Return the correct input queue depending on the current phase - - The input queue is the entry point into the extraction pipeline. An :class:`ExtractMedia` - object should be put to the queue. - - For detect/single phase operations the :attr:`ExtractMedia.filename` and - :attr:`~ExtractMedia.image` attributes should be populated. - - For align/mask (2nd/3rd pass operations) the :attr:`ExtractMedia.detected_faces` should - also be populated by calling :func:`ExtractMedia.set_detected_faces`. - """ - qname = f"extract{self._instance}_{self._current_phase[0]}_in" - retval = self._queues[qname] - logger.trace("%s: %s", qname, retval) # type: ignore - return retval - - @property - def passes(self) -> int: - """ int: Returns the total number of passes the extractor needs to make. - - This is calculated on several factors (vram available, plugin choice, - :attr:`multiprocess` etc.). It is useful for iterating over the pipeline - and handling accordingly. - - Example - ------- - >>> for phase in extractor.passes: - >>> if phase == 1: - >>> extract_media = ExtractMedia("path/to/image/file", image) - >>> extractor.input_queue.put(extract_media) - >>> else: - >>> extract_media.set_image(image) - >>> extractor.input_queue.put(extract_media) - """ - retval = len(self._phases) - logger.trace(retval) # type: ignore - return retval - - @property - def phase_text(self) -> str: - """ str: The plugins that are running in the current phase, formatted for info text - output. """ - plugin_types = set(self._get_plugin_type_and_index(phase)[0] - for phase in self._current_phase) - retval = ", ".join(plugin_type.title() for plugin_type in list(plugin_types)) - logger.trace(retval) # type: ignore - return retval - - @property - def final_pass(self) -> bool: - """ bool, Return ``True`` if this is the final extractor pass otherwise ``False`` - - Useful for iterating over the pipeline :attr:`passes` or :func:`detected_faces` and - handling accordingly. - - Example - ------- - >>> for face in extractor.detected_faces(): - >>> if extractor.final_pass: - >>> - >>> else: - >>> extract_media.set_image(image) - >>> - >>> extractor.input_queue.put(extract_media) - """ - retval = self._phase_index == len(self._phases) - 1 - logger.trace(retval) # type:ignore[attr-defined] - return retval - - @property - def aligner(self) -> Aligner: - """ The currently selected aligner plugin """ - assert self._align is not None - return self._align - - @property - def recognition(self) -> Identity: - """ The currently selected recognition plugin """ - assert self._recognition is not None - return self._recognition - - def reset_phase_index(self) -> None: - """ Reset the current phase index back to 0. Used for when batch processing is used in - extract. """ - self._phase_index = 0 - - def set_batchsize(self, - plugin_type: T.Literal["align", "detect"], - batchsize: int) -> None: - """ Set the batch size of a given :attr:`plugin_type` to the given :attr:`batchsize`. - - This should be set prior to :func:`launch` if the batch size is to be manually overridden - - Parameters - ---------- - plugin_type: {'align', 'detect'} - The plugin_type to be overridden - batchsize: int - The batch size to use for this plugin type - """ - logger.debug("Overriding batchsize for plugin_type: %s to: %s", plugin_type, batchsize) - plugin = getattr(self, f"_{plugin_type}") - plugin.batchsize = batchsize - - def launch(self) -> None: - """ Launches the plugin(s) - - This launches the plugins held in the pipeline, and should be called at the beginning - of each :attr:`phase`. To ensure VRAM is conserved, It will only launch the plugin(s) - required for the currently running phase - - Example - ------- - >>> for phase in extractor.passes: - >>> extractor.launch(): - >>> - """ - for phase in self._current_phase: - self._launch_plugin(phase) - - def detected_faces(self) -> Generator[ExtractMedia, None, None]: - """ Generator that returns results, frame by frame from the extraction pipeline - - This is the exit point for the extraction pipeline and is used to obtain the output - of any pipeline :attr:`phase` - - Yields - ------ - faces: :class:`~plugins.extract.extract_media.ExtractMedia` - The populated extracted media object. - - Example - ------- - >>> for extract_media in extractor.detected_faces(): - >>> filename = extract_media.filename - >>> image = extract_media.image - >>> detected_faces = extract_media.detected_faces - """ - logger.debug("Running Detection. Phase: '%s'", self._current_phase) - # If not multiprocessing, intercept the align in queue for - # detection phase - out_queue = self._output_queue - while True: - try: - self._check_and_raise_error() - faces = out_queue.get(True, 1) - if faces == "EOF": - break - except QueueEmpty: - continue - yield faces - - self._join_threads() - if self.final_pass: - for plugin in self._all_plugins: - plugin.on_completion() - logger.debug("Detection Complete") - else: - self._phase_index += 1 - logger.debug("Switching to phase: %s", self._current_phase) - - def _disable_lm_maskers(self) -> None: - """ Disable any 68 point landmark based maskers if alignment data is not 2D 68 - point landmarks and update the process flow/phases accordingly """ - logger.warning("Alignment data is not 68 point 2D landmarks. Some Faceswap functionality " - "will be unavailable for these faces") - - rem_maskers = [m.name for m in self._mask - if m is not None and m.landmark_type == LandmarkType.LM_2D_68] - self._mask = [m for m in self._mask if m is None or m.name not in rem_maskers] - - self._flow = [ - item for item in self._flow - if not item.startswith("mask") - or item.startswith("mask") and int(item.rsplit("_", maxsplit=1)[-1]) < len(self._mask)] - - self._phases = [[s for s in p if s in self._flow] for p in self._phases - if any(t in p for t in self._flow)] - - for queue in self._queues: - queue_manager.del_queue(queue) - del self._queues - self._queues = self._add_queues() - - logger.warning("The following maskers have been disabled due to unsupported landmarks: %s", - rem_maskers) - - def import_data(self, input_location: str) -> None: - """ Import json data to the detector and/or aligner if 'import' plugin has been selected - - Parameters - ---------- - input_location: str - Full path to the input location for the extract process - """ - assert self._detect is not None - import_plugins: list[DetectImport | AlignImport] = [ - p for p in (self._detect, self.aligner) # type:ignore[misc] - if T.cast(str, p.name).lower() == "external"] - - if not import_plugins: - return - - align_origin = None - if len(import_plugins) == 2: - align_origin = import_plugins[-1].origin - - logger.info("Importing external data for %s from json file...", - " and ".join([p.__class__.__name__ for p in import_plugins])) - - folder = input_location - folder = folder if os.path.isdir(folder) else os.path.dirname(folder) - - last_fname = "" - is_68_point = True - data = {} - for plugin in import_plugins: - plugin_type = plugin.__class__.__name__ - path = os.path.join(folder, plugin.file_name) - if not os.path.isfile(path): - raise FaceswapError(f"{plugin_type} import file could not be found at '{path}'") - - if path != last_fname: # Different import file for aligner data - last_fname = path - data = get_serializer("json").load(path) - - if plugin_type == "Detect": - plugin.import_data(data, align_origin) # type:ignore[call-arg] - else: - plugin.import_data(data) # type:ignore[call-arg] - is_68_point = plugin.landmark_type == LandmarkType.LM_2D_68 # type:ignore[union-attr] # noqa:E501 # pylint:disable="line-too-long" - - if not is_68_point: - self._disable_lm_maskers() - - logger.info("Imported external data") - - # <<< INTERNAL METHODS >>> # - @property - def _parallel_scaling(self) -> dict[int, float]: - """ dict: key is number of parallel plugins being loaded, value is the scaling factor that - the total base vram for those plugins should be scaled by - - Notes - ----- - VRAM for parallel plugins does not stack in a linear manner. Calculating the precise - scaling for any given plugin combination is non trivial, however the following are - calculations based on running 2-5 plugins in parallel using s3fd, fan, unet, vgg-clear - and vgg-obstructed. The worst ratio is selected for each combination, plus a little extra - to ensure that vram is not used up. - - If OOM errors are being reported, then these ratios should be relaxed some more - """ - retval = {0: 1.0, - 1: 1.0, - 2: 0.7, - 3: 0.55, - 4: 0.5, - 5: 0.4} - logger.trace(retval) # type: ignore - return retval - - @property - def _vram_per_phase(self) -> dict[str, float]: - """ dict: The amount of vram required for each phase in :attr:`_flow`. """ - retval = {} - for phase in self._flow: - plugin_type, idx = self._get_plugin_type_and_index(phase) - attr = getattr(self, f"_{plugin_type}") - attr = attr[idx] if idx is not None else attr - retval[phase] = attr.vram - logger.trace(retval) # type: ignore - return retval - - @property - def _total_vram_required(self) -> float: - """ Return vram required for all phases plus the buffer """ - vrams = self._vram_per_phase - vram_required_count = sum(1 for p in vrams.values() if p > 0) - logger.debug("VRAM requirements: %s. Plugins requiring VRAM: %s", - vrams, vram_required_count) - retval = (sum(vrams.values()) * - self._parallel_scaling.get(vram_required_count, self._scaling_fallback)) - logger.debug("Total VRAM required: %s", retval) - return retval - - @property - def _current_phase(self) -> list[str]: - """ list: The current phase from :attr:`_phases` that is running through the extractor. """ - retval = self._phases[self._phase_index] - logger.trace(retval) # type: ignore - return retval - - @property - def _final_phase(self) -> str: - """ Return the final phase from the flow list """ - retval = self._flow[-1] - logger.trace(retval) # type: ignore - return retval - - @property - def _output_queue(self) -> EventQueue: - """ Return the correct output queue depending on the current phase """ - if self.final_pass: - qname = f"extract{self._instance}_{self._final_phase}_out" - else: - qname = f"extract{self._instance}_{self._phases[self._phase_index + 1][0]}_in" - retval = self._queues[qname] - logger.trace("%s: %s", qname, retval) # type: ignore - return retval - - @property - def _all_plugins(self) -> list[PluginExtractor]: - """ Return list of all plugin objects in this pipeline """ - retval = [] - for phase in self._flow: - plugin_type, idx = self._get_plugin_type_and_index(phase) - attr = getattr(self, f"_{plugin_type}") - attr = attr[idx] if idx is not None else attr - retval.append(attr) - logger.trace("All Plugins: %s", retval) # type: ignore - return retval - - @property - def _active_plugins(self) -> list[PluginExtractor]: - """ Return the plugins that are currently active based on pass """ - retval = [] - for phase in self._current_phase: - plugin_type, idx = self._get_plugin_type_and_index(phase) - attr = getattr(self, f"_{plugin_type}") - retval.append(attr[idx] if idx is not None else attr) - logger.trace("Active plugins: %s", retval) # type: ignore - return retval - - @staticmethod - def _set_flow(detector: str | None, - aligner: str | None, - masker: list[str | None], - recognition: str | None) -> list[str]: - """ Set the flow list based on the input plugins - - Parameters - ---------- - detector: str or ``None`` - The name of a detector plugin as exists in :mod:`plugins.extract.detect` - aligner: str or ``None - The name of an aligner plugin as exists in :mod:`plugins.extract.align` - masker: str or list or ``None - The name of a masker plugin(s) as exists in :mod:`plugins.extract.mask`. - This can be a single masker or a list of multiple maskers - recognition: str or ``None`` - The name of the recognition plugin to use. ``None`` to not do face recognition. - """ - logger.debug("detector: %s, aligner: %s, masker: %s recognition: %s", - detector, aligner, masker, recognition) - retval = [] - if detector is not None and detector.lower() != "none": - retval.append("detect") - if aligner is not None and aligner.lower() != "none": - retval.append("align") - if recognition is not None and recognition.lower() != "none": - retval.append("recognition") - retval.extend([f"mask_{idx}" - for idx, mask in enumerate(masker) - if mask is not None and mask.lower() != "none"]) - logger.debug("flow: %s", retval) - return retval - - @staticmethod - def _get_plugin_type_and_index(flow_phase: str) -> tuple[str, int | None]: - """ Obtain the plugin type and index for the plugin for the given flow phase. - - When multiple plugins for the same phase are allowed (e.g. Mask) this will return - the plugin type and the index of the plugin required. If only one plugin is allowed - then the plugin type will be returned and the index will be ``None``. - - Parameters - ---------- - flow_phase: str - The phase within :attr:`_flow` that is to have the plugin type and index returned - - Returns - ------- - plugin_type: str - The plugin type for the given flow phase - index: int - The index of this plugin type within the flow, if there are multiple plugins in use - otherwise ``None`` if there is only 1 plugin in use for the given phase - """ - sidx = flow_phase.split("_")[-1] - if sidx.isdigit(): - idx: int | None = int(sidx) - plugin_type = "_".join(flow_phase.split("_")[:-1]) - else: - plugin_type = flow_phase - idx = None - return plugin_type, idx - - def _add_queues(self) -> dict[str, EventQueue]: - """ Add the required processing queues to Queue Manager """ - queues = {} - tasks = [f"extract{self._instance}_{phase}_in" for phase in self._flow] - tasks.append(f"extract{self._instance}_{self._final_phase}_out") - for task in tasks: - # Limit queue size to avoid stacking ram - queue_manager.add_queue(task, maxsize=1) - queues[task] = queue_manager.get_queue(task) - logger.debug("Queues: %s", queues) - return queues - - @staticmethod - def _get_vram_stats() -> dict[str, int | str]: - """ Obtain statistics on available VRAM and subtract a constant buffer from available vram. - - Returns - ------- - dict - Statistics on available VRAM - """ - vram_buffer = 256 # Leave a buffer for VRAM allocation - assert GPUStats is not None - gpu_stats = GPUStats() - stats = gpu_stats.get_card_most_free() - retval: dict[str, int | str] = {"count": gpu_stats.device_count, - "device": stats.device, - "vram_free": int(stats.free - vram_buffer), - "vram_total": int(stats.total)} - logger.debug(retval) - return retval - - def _set_parallel_processing(self, multiprocess: bool) -> bool: - """ Set whether to run detect, align, and mask together or separately. - - Parameters - ---------- - multiprocess: bool - ``True`` if the single-process command line flag has not been set otherwise ``False`` - """ - if not multiprocess: - logger.debug("Parallel processing disabled by cli.") - return False - - if self._vram_stats["count"] == 0: - logger.debug("No GPU detected. Enabling parallel processing.") - return True - - logger.verbose("%s - %sMB free of %sMB", # type: ignore - self._vram_stats["device"], - self._vram_stats["vram_free"], - self._vram_stats["vram_total"]) - if T.cast(int, self._vram_stats["vram_free"]) <= self._total_vram_required: - logger.warning("Not enough free VRAM for parallel processing. " - "Switching to serial") - return False - return True - - def _set_phases(self, multiprocess: bool) -> list[list[str]]: - """ If not enough VRAM is available, then chunk :attr:`_flow` up into phases that will fit - into VRAM, otherwise return the single flow. - - Parameters - ---------- - multiprocess: bool - ``True`` if the single-process command line flag has not been set otherwise ``False`` - - Returns - ------- - list: - The jobs to be undertaken split into phases that fit into GPU RAM - """ - phases: list[list[str]] = [] - current_phase: list[str] = [] - available = T.cast(int, self._vram_stats["vram_free"]) - for phase in self._flow: - num_plugins = len([p for p in current_phase if self._vram_per_phase[p] > 0]) - num_plugins += 1 if self._vram_per_phase[phase] > 0 else 0 - scaling = self._parallel_scaling.get(num_plugins, self._scaling_fallback) - required = sum(self._vram_per_phase[p] for p in current_phase + [phase]) * scaling - logger.debug("Num plugins for phase: %s, scaling: %s, vram required: %s", - num_plugins, scaling, required) - if required <= available and multiprocess: - logger.debug("Required: %s, available: %s. Adding phase '%s' to current phase: %s", - required, available, phase, current_phase) - current_phase.append(phase) - elif len(current_phase) == 0 or not multiprocess: - # Amount of VRAM required to run a single plugin is greater than available. We add - # it anyway, and hope it will run with warnings, as the alternative is to not run - # at all. - # This will also run if forcing single process - logger.debug("Required: %s, available: %s. Single plugin has higher requirements " - "than available or forcing single process: '%s'", - required, available, phase) - phases.append([phase]) - else: - logger.debug("Required: %s, available: %s. Adding phase to flow: %s", - required, available, current_phase) - phases.append(current_phase) - current_phase = [phase] - if current_phase: - phases.append(current_phase) - logger.debug("Total phases: %s, Phases: %s", len(phases), phases) - return phases - - # << INTERNAL PLUGIN HANDLING >> # - def _load_align(self, - aligner: str | None, - configfile: str | None, - normalize_method: T.Literal["none", "clahe", "hist", "mean"] | None, - re_feed: int, - re_align: bool, - disable_filter: bool) -> Aligner | None: - """ Set global arguments and load aligner plugin - - Parameters - ---------- - aligner: str - The aligner plugin to load or ``None`` for no aligner - configfile: str - Optional full path to custom config file - normalize_method: str - Optional normalization method to use - re_feed: int - The number of times to adjust the image and re-feed to get an average score - re_align: bool - ``True`` to obtain landmarks by passing the initially aligned face back through the - aligner. - disable_filter: bool - Disable all aligner filters regardless of config option - - Returns - ------- - Aligner plugin if one is specified otherwise ``None`` - """ - if aligner is None or aligner.lower() == "none": - logger.debug("No aligner selected. Returning None") - return None - aligner_name = aligner.replace("-", "_").lower() - logger.debug("Loading Aligner: '%s'", aligner_name) - plugin = PluginLoader.get_aligner(aligner_name)(configfile=configfile, - normalize_method=normalize_method, - re_feed=re_feed, - re_align=re_align, - disable_filter=disable_filter, - instance=self._instance) - return plugin - - def _load_detect(self, - detector: str | None, - aligner: str | None, - rotation: str | None, - min_size: int, - configfile: str | None) -> Detector | None: - """ Set global arguments and load detector plugin - - Parameters - ---------- - detector: str | None - The name of the face detection plugin to use. ``None`` for no detection - aligner: str | None - The name of the face aligner plugin to use. ``None`` for no aligner - rotation: str | None - The rotation to perform on detection. ``None`` for no rotation - min_size: int - The minimum size of detected faces to accept - configfile: str | None - Full path to a custom config file to use. ``None`` for default config - - Returns - ------- - :class:`~plugins.extract.detect._base.Detector` | None - The face detection plugin to use, or ``None`` if no detection to be performed - """ - if detector is None or detector.lower() == "none": - logger.debug("No detector selected. Returning None") - return None - detector_name = detector.replace("-", "_").lower() - - if aligner == "external" and detector_name != "external": - logger.warning("Unsupported '%s' detector selected for 'External' aligner. Switching " - "detector to 'External'", detector_name) - detector_name = aligner - - logger.debug("Loading Detector: '%s'", detector_name) - plugin = PluginLoader.get_detector(detector_name)(rotation=rotation, - min_size=min_size, - configfile=configfile, - instance=self._instance) - return plugin - - def _load_mask(self, - masker: str | None, - configfile: str | None) -> Masker | None: - """ Set global arguments and load masker plugin - - Parameters - ---------- - masker: str or ``none`` - The name of the masker plugin to use or ``None`` if no masker - configfile: str - Full path to custom config.ini file or ``None`` to use default - - Returns - ------- - :class:`~plugins.extract.mask._base.Masker` or ``None`` - The masker plugin to use or ``None`` if no masker selected - """ - if masker is None or masker.lower() == "none": - logger.debug("No masker selected. Returning None") - return None - masker_name = masker.replace("-", "_").lower() - logger.debug("Loading Masker: '%s'", masker_name) - plugin = PluginLoader.get_masker(masker_name)(configfile=configfile, - instance=self._instance) - return plugin - - def _load_recognition(self, - recognition: str | None, - configfile: str | None) -> Identity | None: - """ Set global arguments and load recognition plugin """ - if recognition is None or recognition.lower() == "none": - logger.debug("No recognition selected. Returning None") - return None - recognition_name = recognition.replace("-", "_").lower() - logger.debug("Loading Recognition: '%s'", recognition_name) - plugin = PluginLoader.get_recognition(recognition_name)(configfile=configfile, - instance=self._instance) - return plugin - - def _launch_plugin(self, phase: str) -> None: - """ Launch an extraction plugin """ - logger.debug("Launching %s plugin", phase) - in_qname = f"extract{self._instance}_{phase}_in" - if phase == self._final_phase: - out_qname = f"extract{self._instance}_{self._final_phase}_out" - else: - next_phase = self._flow[self._flow.index(phase) + 1] - out_qname = f"extract{self._instance}_{next_phase}_in" - logger.debug("in_qname: %s, out_qname: %s", in_qname, out_qname) - kwargs = {"in_queue": self._queues[in_qname], "out_queue": self._queues[out_qname]} - - plugin_type, idx = self._get_plugin_type_and_index(phase) - plugin = getattr(self, f"_{plugin_type}") - plugin = plugin[idx] if idx is not None else plugin - plugin.initialize(**kwargs) - plugin.start() - logger.debug("Launched %s plugin", phase) - - def _set_plugins_batchsize(self, gpu_plugins: list[str], vram_free: int) -> None: - """ Set the batch size for the current phase so that it will fit in available VRAM. - - Do not update plugins which have a vram_per_batch of 0 (CPU plugins) due to - zero division error. - - Reduces the batchsize of the plugin which has a batch size > 1 and the largest VRAM - requirements. The final reduction is the plugin which has a batch size > 1 and the - smallest VRAM requirements that would fit the pipeline inside VRAM - - Parameters - ---------- - gpu_plugins: list[str] - The name of the plugins that use the GPU for the current phase - vram_free: int - The amount of available VRAM, in MBs - """ - logger.debug("GPU plugins: %s, Available vram: %s", gpu_plugins, vram_free) - plugins = [self._active_plugins[idx] - for idx, plugin in enumerate(self._current_phase) - if plugin in gpu_plugins] - base_vram = sum(p.vram for p in plugins) - vram_free = vram_free - base_vram - logger.debug("Base vram: %s, remaining vram: %s", base_vram, vram_free) - - to_allocate = [(p.batchsize, p.vram_per_batch) for p in plugins] - excess = sum(a[0] * a[1] for a in to_allocate) - vram_free - logger.debug("Plugins to allocate: %s, excess vram: %s", to_allocate, excess) - - while excess > 0: - chosen = next(p for p in to_allocate - if p[0] > 1 and p[1] == max(p[1] for p in to_allocate if p[0] > 1)) - - if excess - chosen[1] <= 0: - chosen = next(p for p in to_allocate - if p[0] > 1 and p[1] == min(p[1] for p in to_allocate - if p[0] > 1 and p[1] >= excess)) - - excess -= chosen[1] - logger.debug("Reducing batch size for item %s. Remaining %s", chosen, excess) - to_allocate[to_allocate.index(chosen)] = (chosen[0] - 1, chosen[1]) - - msg = [] - for plugin, alloc in zip(plugins, to_allocate): - if plugin.batchsize != alloc[0]: - logger.debug("Updating batchsize for plugin %s from %s to %s", - plugin.name, plugin.batchsize, alloc[0]) - plugin.batchsize = alloc[0] - msg.append(f"{plugin.__class__.__name__}: {plugin.batchsize}") - - logger.info("Reset batch sizes due to available VRAM: %s", ", ".join(msg)) - - def _set_extractor_batchsize(self) -> None: - """ - Sets the batch size of the requested plugins based on their vram, their - vram_per_batch_requirements and the number of plugins being loaded in the current phase. - Only adjusts if the the configured batch size requires more vram than is available. - """ - backend = get_backend() - if backend not in ("nvidia", "rocm"): - logger.debug("Not updating batchsize requirements for backend: '%s'", backend) - return - if sum(plugin.vram for plugin in self._active_plugins) == 0: - logger.debug("No plugins use VRAM. Not updating batchsize requirements.") - return - - batch_required = sum(plugin.vram_per_batch * plugin.batchsize - for plugin in self._active_plugins) - - gpu_plugins = [p for p in self._current_phase if self._vram_per_phase[p] > 0] - - scaling = self._parallel_scaling.get(len(gpu_plugins), self._scaling_fallback) - plugins_required = sum(self._vram_per_phase[p] for p in gpu_plugins) * scaling - - vram_free = T.cast(int, self._vram_stats["vram_free"]) - total_required = plugins_required + batch_required - if total_required <= vram_free: - logger.debug("Plugin requirements within threshold: (plugins_required: %sMB, " - "vram_free: %sMB)", plugins_required, self._vram_stats["vram_free"]) - return - - self._set_plugins_batchsize(gpu_plugins, vram_free) - - def _join_threads(self): - """ Join threads for current pass """ - for plugin in self._active_plugins: - plugin.join() - - def _check_and_raise_error(self) -> None: - """ Check all threads for errors and raise if one occurs """ - for plugin in self._active_plugins: - plugin.check_and_raise_error() - - -__all__ = get_module_objects(__name__) diff --git a/plugins/extract/recognition/_base.py b/plugins/extract/recognition/_base.py deleted file mode 100644 index e00abbe2a7..0000000000 --- a/plugins/extract/recognition/_base.py +++ /dev/null @@ -1,495 +0,0 @@ -#!/usr/bin/env python3 -""" Base class for Face Recognition plugins - -All Recognition Plugins should inherit from this class. -See the override methods for which methods are required. - -The plugin will receive a :class:`~plugins.extract.extract_media.ExtractMedia` object. - -For each source frame, the plugin must pass a dict to finalize containing: - ->>> {'filename': , ->>> 'detected_faces': >> face = self.to_detected_face(, , , ) -""" -from __future__ import annotations -import logging -import typing as T - -from dataclasses import dataclass, field - -import numpy as np -from torch.cuda import OutOfMemoryError - -from lib.align import AlignedFace, DetectedFace, LandmarkType -from lib.image import read_image_meta -from lib.utils import FaceswapError -from plugins.extract import ExtractMedia, extract_config as cfg -from plugins.extract._base import BatchType, ExtractorBatch, Extractor - -if T.TYPE_CHECKING: - from collections.abc import Generator - from queue import Queue - from lib.align.aligned_face import CenteringType - -logger = logging.getLogger(__name__) - - -@dataclass -class RecogBatch(ExtractorBatch): - """ Dataclass for holding items flowing through the aligner. - - Inherits from :class:`~plugins.extract._base.ExtractorBatch` - """ - detected_faces: list[DetectedFace] = field(default_factory=list) - feed_faces: list[AlignedFace] = field(default_factory=list) - - -class Identity(Extractor): # pylint:disable=abstract-method - """ Face Recognition Object - - Parent class for all Recognition plugins - - Parameters - ---------- - git_model_id: int - The second digit in the github tag that identifies this model. See - https://github.com/deepfakes-models/faceswap-models for more information - model_filename: str - The name of the model file to be loaded - - Other Parameters - ---------------- - configfile: str, optional - Path to a custom configuration ``ini`` file. Default: Use system configfile - - See Also - -------- - plugins.extract.pipeline : The extraction pipeline for calling plugins - plugins.extract.detect : Detector plugins - plugins.extract._base : Parent class for all extraction plugins - plugins.extract.align._base : Aligner parent class for extraction plugins. - plugins.extract.mask._base : Masker parent class for extraction plugins. - """ - - _logged_lm_count_once = False - - def __init__(self, - git_model_id: int | None = None, - model_filename: str | None = None, - configfile: str | None = None, - instance: int = 0, - **kwargs): - logger.debug("Initializing %s", self.__class__.__name__) - super().__init__(git_model_id, # pylint:disable=duplicate-code - model_filename, - configfile=configfile, - instance=instance, - **kwargs) - self.input_size = 256 # Override for model specific input_size - self.centering: CenteringType = "legacy" # Override for model specific centering - self.coverage_ratio = 1.0 # Override for model specific coverage_ratio - - self._info.plugin_type = "recognition" - self._filter = IdentityFilter(cfg.save_filtered()) - logger.debug("Initialized _base %s", self.__class__.__name__) - - def _get_detected_from_aligned(self, item: ExtractMedia) -> None: - """ Obtain detected face objects for when loading in aligned faces and a detected face - object does not exist - - Parameters - ---------- - item: :class:`~plugins.extract.extract_media.ExtractMedia` - The extract media to populate the detected face for - """ - detected_face = DetectedFace() - meta = read_image_meta(item.filename).get("itxt", {}).get("alignments") - if meta: - detected_face.from_png_meta(meta) - item.add_detected_faces([detected_face]) - self._tracker.faces_per_filename[item.filename] += 1 # Track this added face - logger.debug("Obtained detected face: (filename: %s, detected_face: %s)", - item.filename, item.detected_faces) - - def _maybe_log_warning(self, face: AlignedFace) -> None: - """ Log a warning, once, if we do not have full facial landmarks - - Parameters - ---------- - face: :class:`~lib.align.aligned_face.AlignedFace` - The aligned face object to test the landmark type for - """ - if face.landmark_type != LandmarkType.LM_2D_4 or self._logged_lm_count_once: - return - logger.warning("Extracted faces do not contain facial landmark data. '%s' " - "identity data is likely to be sub-standard.", self.name) - self._logged_lm_count_once = True - - def get_batch(self, queue: Queue) -> tuple[bool, RecogBatch]: - """ Get items for inputting into the recognition from the queue in batches - - Items are returned from the ``queue`` in batches of - :attr:`~plugins.extract._base.Extractor.batchsize` - - Items are received as :class:`~plugins.extract.extract_media.ExtractMedia` objects and - converted to :class:`RecogBatch` for internal processing. - - To ensure consistent batch sizes for masker the items are split into separate items for - each :class:`~lib.align.DetectedFace` object. - - Remember to put ``'EOF'`` to the out queue after processing - the final batch - - Outputs items in the following format. All lists are of length - :attr:`~plugins.extract._base.Extractor.batchsize`: - - >>> {'filename': [], - >>> 'detected_faces': [[ RecogBatch: - """ Just return the recognition's predict function """ - # pylint:disable=duplicate-code - assert isinstance(batch, RecogBatch) - # slightly hacky workaround to deal with landmarks based masks: - try: - batch.prediction = self.predict(batch.feed) - except OutOfMemoryError as err: - msg = ("You do not have enough GPU memory available to run recognition at the " - "selected batch size. You can try a number of things:" - "\n1) Close any other application that is using your GPU (web browsers are " - "particularly bad for this)." - "\n2) Lower the batchsize (the amount of images fed into the model) by " - "editing the plugin settings (GUI: Settings > Configure extract settings, " - "CLI: Edit the file faceswap/config/extract.ini)." - "\n3) Enable 'Single Process' mode.") - raise FaceswapError(msg) from err - - return batch - - def finalize(self, batch: BatchType) -> Generator[ExtractMedia, None, None]: - """ Finalize the output from Masker - - This should be called as the final task of each `plugin`. - - Pairs the detected faces back up with their original frame before yielding each frame. - - Parameters - ---------- - batch : :class:`RecogBatch` - The final batch item from the `plugin` process. - - Yields - ------ - :class:`~plugins.extract.extract_media.ExtractMedia` - The :attr:`DetectedFaces` list will be populated for this class with the bounding - boxes, landmarks and masks for the detected faces found in the frame. - """ - assert isinstance(batch, RecogBatch) - assert isinstance(self.name, str) - for identity, face in zip(batch.prediction, batch.detected_faces): - face.add_identity(self.name.lower(), identity) - del batch.feed - - logger.trace("Item out: %s", # type: ignore - {key: val.shape if isinstance(val, np.ndarray) else val - for key, val in batch.__dict__.items()}) - - for filename, face in zip(batch.filename, batch.detected_faces): - self._tracker.output_faces.append(face) - if len(self._tracker.output_faces) != self._tracker.faces_per_filename[filename]: - continue - - output = self._extract_media.pop(filename) - self._tracker.output_faces = self._filter(self._tracker.output_faces, - output.sub_folders) - - output.add_detected_faces(self._tracker.output_faces) - self._tracker.output_faces = [] - logger.trace("Yielding: (filename: '%s', image: %s, " # type:ignore[attr-defined] - "detected_faces: %s)", output.filename, output.image_shape, - len(output.detected_faces)) - yield output - - def add_identity_filters(self, - filters: np.ndarray, - nfilters: np.ndarray, - threshold: float) -> None: - """ Add identity encodings to filter by identity in the recognition plugin - - Parameters - ---------- - filters: :class:`numpy.ndarray` - The array of filter embeddings to use - nfilters: :class:`numpy.ndarray` - The array of nfilter embeddings to use - threshold: float - The threshold for a positive filter match - """ - logger.debug("Adding identity filters") - self._filter.add_filters(filters, nfilters, threshold) - logger.debug("Added identity filters") - - -class IdentityFilter(): - """ Applies filters on the output of the recognition plugin - - Parameters - ---------- - save_output: bool - ``True`` if the filtered faces should be kept as they are being saved. ``False`` if they - should be deleted - """ - def __init__(self, save_output: bool) -> None: - logger.debug("Initializing %s: (save_output: %s)", self.__class__.__name__, save_output) - self._save_output = save_output - self._filter: np.ndarray | None = None - self._nfilter: np.ndarray | None = None - self._threshold = 0.0 - self._filter_enabled: bool = False - self._nfilter_enabled: bool = False - self._active: bool = False - self._counts = 0 - logger.debug("Initialized %s", self.__class__.__name__) - - def add_filters(self, filters: np.ndarray, nfilters: np.ndarray, threshold) -> None: - """ Add identity encodings to the filter and set whether each filter is enabled - - Parameters - ---------- - filters: :class:`numpy.ndarray` - The array of filter embeddings to use - nfilters: :class:`numpy.ndarray` - The array of nfilter embeddings to use - threshold: float - The threshold for a positive filter match - """ - logger.debug("Adding filters: %s, nfilters: %s, threshold: %s", - filters.shape, nfilters.shape, threshold) - self._filter = filters - self._nfilter = nfilters - self._threshold = threshold - self._filter_enabled = bool(np.any(self._filter)) - self._nfilter_enabled = bool(np.any(self._nfilter)) - self._active = self._filter_enabled or self._nfilter_enabled - logger.debug("filter active: %s, nfilter active: %s, all active: %s", - self._filter_enabled, self._nfilter_enabled, self._active) - - @classmethod - def _find_cosine_similiarity(cls, - source_identities: np.ndarray, - test_identity: np.ndarray) -> np.ndarray: - """ Find the cosine similarity between a source face identity and a test face identity - - Parameters - --------- - source_identities: :class:`numpy.ndarray` - The identity encoding for the source face identities - test_identity: :class:`numpy.ndarray` - The identity encoding for the face identity to test against the sources - - Returns - ------- - :class:`numpy.ndarray`: - The cosine similarity between a face identity and the source identities - """ - s_norm = np.linalg.norm(source_identities, axis=1) - i_norm = np.linalg.norm(test_identity) - retval = source_identities @ test_identity / (s_norm * i_norm) - return retval - - def _get_matches(self, - filter_type: T.Literal["filter", "nfilter"], - identities: np.ndarray) -> np.ndarray: - """ Obtain the average and minimum distances for each face against the source identities - to test against - - Parameters - ---------- - filter_type ["filter", "nfilter"] - The filter type to use for calculating the distance - identities: :class:`numpy.ndarray` - The identity encodings for the current face(s) being checked - - Returns - ------- - :class:`numpy.ndarray` - Boolean array. ``True`` if identity should be filtered otherwise ``False`` - """ - encodings = self._filter if filter_type == "filter" else self._nfilter - assert encodings is not None - distances = np.array([self._find_cosine_similiarity(encodings, identity) - for identity in identities]) - is_match = np.any(distances >= self._threshold, axis=-1) - # Invert for filter (set the `True` match to `False` for should filter) - retval = np.invert(is_match) if filter_type == "filter" else is_match - logger.trace("filter_type: %s, distances shape: %s, is_match: %s, ", # type: ignore - "retval: %s", filter_type, distances.shape, is_match, retval) - return retval - - def _filter_faces(self, - faces: list[DetectedFace], - sub_folders: list[str | None], - should_filter: list[bool]) -> list[DetectedFace]: - """ Filter the detected faces, either removing filtered faces from the list of detected - faces or setting the output subfolder to `"_identity_filt"` for any filtered faces if - saving output is enabled. - - Parameters - ---------- - faces: list - List of detected face objects to filter out on size - sub_folders: list - List of subfolder locations for any faces that have already been filtered when - config option `save_filtered` has been enabled. - should_filter: list - List of 'bool' corresponding to face that have not already been marked for filtering. - ``True`` indicates face should be filtered, ``False`` indicates face should be kept - - Returns - ------- - detected_faces: list - The filtered list of detected face objects, if saving filtered faces has not been - selected or the full list of detected faces - """ - retval: list[DetectedFace] = [] - self._counts += sum(should_filter) - for idx, face in enumerate(faces): - fldr = sub_folders[idx] - if fldr is not None: - # Saving to sub folder is selected and face is already filtered - # so this face was excluded from identity check - retval.append(face) - continue - to_filter = should_filter.pop(0) - if not to_filter or self._save_output: - # Keep the face if not marked as filtered or we are to output to a subfolder - retval.append(face) - if to_filter and self._save_output: - sub_folders[idx] = "_identity_filt" - - return retval - - def __call__(self, - faces: list[DetectedFace], - sub_folders: list[str | None]) -> list[DetectedFace]: - """ Call the identity filter function - - Parameters - ---------- - faces: list - List of detected face objects to filter out on size - sub_folders: list - List of subfolder locations for any faces that have already been filtered when - config option `save_filtered` has been enabled. - - Returns - ------- - detected_faces: list - The filtered list of detected face objects, if saving filtered faces has not been - selected or the full list of detected faces - """ - if not self._active: - return faces - - identities = np.array([face.identity["vggface2"] for face, fldr in zip(faces, sub_folders) - if fldr is None]) - logger.trace("face_count: %s, already_filtered: %s, identity_shape: %s", # type: ignore - len(faces), sum(x is not None for x in sub_folders), identities.shape) - - if not np.any(identities): - logger.trace("All faces already filtered: %s", sub_folders) # type: ignore - return faces - - should_filter: list[np.ndarray] = [] - for f_type in T.get_args(T.Literal["filter", "nfilter"]): - if not getattr(self, f"_{f_type}_enabled"): - continue - should_filter.append(self._get_matches(f_type, identities)) - - # If any of the filter or nfilter evaluate to 'should filter' then filter out face - final_filter: list[bool] = np.array(should_filter).max(axis=0).tolist() - logger.trace("should_filter: %s, final_filter: %s", # type: ignore - should_filter, final_filter) - return self._filter_faces(faces, sub_folders, final_filter) - - def output_counts(self): - """ Output the counts of filtered items """ - if not self._active or not self._counts: - return - logger.info("Identity filtered (%s): %s", self._threshold, self._counts) diff --git a/plugins/extract/recognition/vgg_face2.py b/plugins/extract/recognition/vgg_face2.py deleted file mode 100644 index 76776fc702..0000000000 --- a/plugins/extract/recognition/vgg_face2.py +++ /dev/null @@ -1,598 +0,0 @@ -#!/usr/bin python3 -""" VGG_Face2 inference and sorting """ - -from __future__ import annotations -import logging -import typing as T - -import numpy as np -import psutil -from fastcluster import linkage, linkage_vector -from keras.layers import (Activation, add, AveragePooling2D, BatchNormalization, Conv2D, Dense, - Flatten, Input, MaxPooling2D) -from keras.models import Model -from keras.regularizers import L2 - -from lib.logger import parse_class_init -from lib.model.layers import L2Normalize -from lib.utils import get_module_objects, FaceswapError -from ._base import BatchType, RecogBatch, Identity -from . import vgg_face2_defaults as cfg - -if T.TYPE_CHECKING: - from keras import KerasTensor - from collections.abc import Generator - -logger = logging.getLogger(__name__) - - -class Recognition(Identity): - """ VGG Face feature extraction. - - Extracts feature vectors from faces in order to compare similarity. - - Notes - ----- - Input images should be in BGR Order - - Model exported from: https://github.com/WeidiXie/Keras-VGGFace2-ResNet50 which is based on: - https://www.robots.ox.ac.uk/~vgg/software/vgg_face/ - - - Licensed under Creative Commons Attribution License. - https://creativecommons.org/licenses/by-nc/4.0/ - """ - - def __init__(self, **kwargs) -> None: - logger.debug("Initializing %s", self.__class__.__name__) - git_model_id = 10 - model_filename = "vggface2_resnet50_v2.h5" - super().__init__(git_model_id=git_model_id, model_filename=model_filename, **kwargs) - self.model: Model - self.name: str = "VGGFace2" - self.input_size = 224 - self.color_format = "BGR" - - self.vram = 384 if not cfg.cpu() else 0 # 334 in testing - self.vram_per_batch = 192 if not cfg.cpu() else 0 # ~155 in testing - self.batchsize = cfg.batch_size() - - # Average image provided in https://github.com/ox-vgg/vgg_face2 - self._average_img = np.array([91.4953, 103.8827, 131.0912]) - logger.debug("Initialized %s", self.__class__.__name__) - - # <<< GET MODEL >>> # - def init_model(self) -> None: - """ Initialize VGG Face 2 Model. """ - assert isinstance(self.model_path, str) - placeholder = np.zeros((self.batchsize, self.input_size, self.input_size, 3), - dtype="float32") - - with self.get_device_context(cfg.cpu()): - self.model = VGGFace2(self.input_size, self.model_path, self.batchsize) - self.model(placeholder) - - def process_input(self, batch: BatchType) -> None: - """ Compile the detected faces for prediction """ - assert isinstance(batch, RecogBatch) - batch.feed = np.array([T.cast(np.ndarray, feed.face)[..., :3] - for feed in batch.feed_faces], - dtype="float32") - self._average_img - logger.trace("feed shape: %s", batch.feed.shape) # type:ignore[attr-defined] - - def predict(self, feed: np.ndarray) -> np.ndarray: - """ Return encodings for given image from vgg_face2. - - Parameters - ---------- - batch: numpy.ndarray - The face to be fed through the predictor. Should be in BGR channel order - - Returns - ------- - numpy.ndarray - The encodings for the face - """ - with self.get_device_context(cfg.cpu()): - retval = self.model(feed) - assert isinstance(retval, np.ndarray) - return retval - - def process_output(self, batch: BatchType) -> None: - """ No output processing for vgg_face2 """ - return - - -class ResNet50: - """ ResNet50 imported for VGG-Face2 adapted from - https://github.com/WeidiXie/Keras-VGGFace2-ResNet50 - - Parameters - ---------- - input_shape, Tuple[int, int, int] | None, optional - The input shape for the model. Default: ``None`` - use_truncated: bool, optional - ``True`` to use a truncated version of resnet. Default ``False`` - weight_decay: float - L2 Regularizer weight decay. Default: 1e-4 - trainable: bool, optional - ``True`` if the block should be trainable. Default: ``True`` - """ - def __init__(self, - input_shape: tuple[int, int, int] | None = None, - use_truncated: bool = False, - weight_decay: float = 1e-4, - trainable: bool = True) -> None: - logger.debug("Initializing %s: input_shape: %s, use_truncated: %s, weight_decay: %s, " - "trainable: %s", self.__class__.__name__, input_shape, use_truncated, - weight_decay, trainable) - - self._input_shape = (None, None, 3) if input_shape is None else input_shape - self._weight_decay = weight_decay - self._trainable = trainable - - self._kernel_initializer = "orthogonal" - self._use_bias = False - self._bn_axis = 3 - self._block_suffix = {0: "_reduce", 1: "", 2: "_increase"} - - self._identity_calls = [2, 3, 5, 2] - self._filters = [(64, 64, 256), (128, 128, 512), (256, 256, 1024), (512, 512, 2048)] - if use_truncated: - self._identity_calls = self._identity_calls[:-1] - self._filters = self._filters[:-1] - - logger.debug("Initialized %s", self.__class__.__name__) - - def _identity_block(self, - inputs: KerasTensor, - kernel_size: int, - filters: tuple[int, int, int], - stage: int, - block: int) -> KerasTensor: - """ The identity block is the block that has no conv layer at shortcut. - - Parameters - ---------- - inputs: :class:`keras.KerasTensor` - Input tensor - kernel_size: int - The kernel size of middle conv layer of the block - filters: tuple[int, int, int[ - The filterss of 3 conv layers in the main path - stage: int - The current stage label, used for generating layer names - block: int - The current block label, used for generating layer names - - Returns - ------- - :class:`keras.KerasTensor` - Output tensor for the block - """ - assert len(filters) == 3 - var_x = inputs - - for idx, filts in enumerate(filters): - k_size = kernel_size if idx == 1 else 1 - conv_name = f"conv{stage}_{block}_{k_size}x{k_size}{self._block_suffix[idx]}" - bn_name = f"{conv_name}_bn" - - var_x = Conv2D(filts, - k_size, - padding="same" if idx == 1 else "valid", - kernel_initializer=self._kernel_initializer, - use_bias=self._use_bias, - kernel_regularizer=L2(self._weight_decay), - trainable=self._trainable, - name=conv_name)(var_x) - var_x = BatchNormalization(axis=self._bn_axis, name=bn_name)(var_x) - if idx < 2: - var_x = Activation("relu")(var_x) - - var_x = add([var_x, inputs]) - var_x = Activation("relu")(var_x) - return var_x - - def _conv_block(self, - inputs: KerasTensor, - kernel_size: int, - filters: tuple[int, int, int], - stage: int, - block: int, - strides: tuple[int, int] = (2, 2)) -> KerasTensor: - """ A block that has a conv layer at shortcut. - - Parameters - ---------- - inputs: :class:`keras.KerasTensor` - Input tensor - kernel_size: int - The kernel size of middle conv layer of the block - filters: tuple[int, int, int[ - The filterss of 3 conv layers in the main path - stage: int - The current stage label, used for generating layer names - block: int - The current block label, used for generating layer names - strides: tuple[int, int], optional - The stride length for the first and last convolution. Default: (2, 2) - - Returns - ------- - :class:`keras.KerasTensor` - Output tensor for the block - - Notes - ----- - From stage 3, the first conv layer at main path is with `strides = (2,2)` and the shortcut - should have `strides = (2,2)` as well - """ - assert len(filters) == 3 - var_x = inputs - - for idx, filts in enumerate(filters): - k_size = kernel_size if idx == 1 else 1 - conv_name = f"conv{stage}_{block}_{k_size}x{k_size}{self._block_suffix[idx]}" - bn_name = f"{conv_name}_bn" - - var_x = Conv2D(filts, - k_size, - strides=strides if idx == 0 else (1, 1), - padding="same" if idx == 1 else "valid", - kernel_initializer=self._kernel_initializer, - use_bias=self._use_bias, - kernel_regularizer=L2(self._weight_decay), - trainable=self._trainable, - name=conv_name)(var_x) - var_x = BatchNormalization(axis=self._bn_axis, name=bn_name)(var_x) - if idx < 2: - var_x = Activation("relu")(var_x) - - conv_name = f"conv{stage}_{block}_1x1_proj" - bn_name = f"{conv_name}_bn" - - shortcut = Conv2D(filters[-1], - (1, 1), - strides=strides, - kernel_initializer=self._kernel_initializer, - use_bias=self._use_bias, - kernel_regularizer=L2(self._weight_decay), - trainable=self._trainable, - name=conv_name)(inputs) - shortcut = BatchNormalization(axis=self._bn_axis, name=bn_name)(shortcut) - - var_x = add([var_x, shortcut]) - var_x = Activation("relu")(var_x) - return var_x - - def __call__(self, inputs: KerasTensor) -> KerasTensor: - """ Call the resnet50 Network - - Parameters - ---------- - inputs: :class:`keras.KerasTensor` - Input tensor - - Returns - ------- - :class::class:`keras.KerasTensor` - Output tensor from resnet50 - """ - var_x = Conv2D(64, - (7, 7), - strides=(2, 2), - padding="same", - use_bias=self._use_bias, - kernel_initializer=self._kernel_initializer, - kernel_regularizer=L2(self._weight_decay), - trainable=self._trainable, - name="conv1_7x7_s2")(inputs) - - var_x = BatchNormalization(axis=self._bn_axis, name="conv1_7x7_s2_bn")(var_x) - var_x = Activation("relu")(var_x) - var_x = MaxPooling2D((3, 3), strides=(2, 2))(var_x) - - for idx, (recursuions, filters) in enumerate(zip(self._identity_calls, self._filters)): - stage = idx + 2 - strides = (1, 1) if stage == 2 else (2, 2) - var_x = self._conv_block(var_x, 3, filters, stage=stage, block=1, strides=strides) - - for recursion in range(recursuions): - block = recursion + 2 - var_x = self._identity_block(var_x, 3, filters, stage=stage, block=block) - - return var_x - - -class VGGFace2(): - """ VGG-Face 2 model with resnet 50 backbone. Adapted from - https://github.com/WeidiXie/Keras-VGGFace2-ResNet50 - - Parameters - ---------- - input_size, int - The input size for the model. - weights_path: str - The path to the keras weights file - batch_size: int - The batch size to feed the model - num_class: int, optional - Number of classes to train the model on - weight_decay: float - L2 Regularizer weight decay. Default: 1e-4 - """ - def __init__(self, - input_size: int, - weights_path: str, - batch_size: int, - num_classes: int = 8631, - weight_decay: float = 1e-4) -> None: - logger.debug(parse_class_init(locals())) - self._input_shape = (input_size, input_size, 3) - self._batch_size = batch_size - self._weight_decay = weight_decay - self._num_classes = num_classes - self._resnet = ResNet50(input_shape=self._input_shape, weight_decay=self._weight_decay) - self._model = self._load_model(weights_path) - logger.debug("Initialized %s", self.__class__.__name__) - - def _load_model(self, weights_path: str) -> Model: - """ load the vgg-face2 model - - Parameters - ---------- - weights_path: str - Full path to the model's weights - - Returns - ------- - :class:`keras.models.Model` - The VGG-Obstructed model - """ - inputs = Input(self._input_shape) - var_x = self._resnet(inputs) - - var_x = AveragePooling2D((7, 7), name="avg_pool")(var_x) - var_x = Flatten()(var_x) - var_x = Dense(512, activation="relu", name="dim_proj")(var_x) - var_x = L2Normalize(axis=1)(var_x) - - retval = Model(inputs, var_x) - retval.load_weights(weights_path) - retval.make_predict_function() - return retval - - def __call__(self, inputs: np.ndarray) -> np.ndarray: - """ Get output from the vgg-face2 model - - Parameters - ---------- - inputs: :class:`numpy.ndarray` - The input to vgg-face2 - - Returns - ------- - :class:`numpy.ndarray` - The output from vgg-face2 - """ - return self._model.predict(inputs, verbose=0, batch_size=self._batch_size) - - -class Cluster(): - """ Cluster the outputs from a VGG-Face 2 Model - - Parameters - ---------- - predictions: numpy.ndarray - A stacked matrix of vgg_face2 predictions of the shape (`N`, `D`) where `N` is the - number of observations and `D` are the number of dimensions. NB: The given - :attr:`predictions` will be overwritten to save memory. If you still require the - original values you should take a copy prior to running this method - method: ['single','centroid','median','ward'] - The clustering method to use. - threshold: float, optional - The threshold to start creating bins for. Set to ``None`` to disable binning - """ - - def __init__(self, - predictions: np.ndarray, - method: T.Literal["single", "centroid", "median", "ward"], - threshold: float | None = None) -> None: - logger.debug("Initializing: %s (predictions: %s, method: %s, threshold: %s)", - self.__class__.__name__, predictions.shape, method, threshold) - self._num_predictions = predictions.shape[0] - - self._should_output_bins = threshold is not None - self._threshold = 0.0 if threshold is None else threshold - self._bins: dict[int, int] = {} - self._iterator = self._integer_iterator() - - self._result_linkage = self._do_linkage(predictions, method) - logger.debug("Initialized %s", self.__class__.__name__) - - @classmethod - def _integer_iterator(cls) -> Generator[int, None, None]: - """ Iterator that just yields consecutive integers """ - i = -1 - while True: - i += 1 - yield i - - def _use_vector_linkage(self, dims: int) -> bool: - """ Calculate the RAM that will be required to sort these images and select the appropriate - clustering method. - - From fastcluster documentation: - "While the linkage method requires Θ(N:sup:`2`) memory for clustering of N points, this - [vector] method needs Θ(N D)for N points in RD, which is usually much smaller." - also: - "half the memory can be saved by specifying :attr:`preserve_input`=``False``" - - To avoid under calculating we divide the memory calculation by 1.8 instead of 2 - - Parameters - ---------- - dims: int - The number of dimensions in the vgg_face output - - Returns - ------- - bool: - ``True`` if vector_linkage should be used. ``False`` if linkage should be used - """ - np_float = 24 # bytes size of a numpy float - divider = 1024 * 1024 # bytes to MB - - free_ram = psutil.virtual_memory().available / divider - linkage_required = (((self._num_predictions ** 2) * np_float) / 1.8) / divider - vector_required = ((self._num_predictions * dims) * np_float) / divider - logger.debug("free_ram: %sMB, linkage_required: %sMB, vector_required: %sMB", - int(free_ram), int(linkage_required), int(vector_required)) - - if linkage_required < free_ram: - logger.verbose("Using linkage method") # type:ignore[attr-defined] - retval = False - elif vector_required < free_ram: - logger.warning("Not enough RAM to perform linkage clustering. Using vector " - "clustering. This will be significantly slower. Free RAM: %sMB. " - "Required for linkage method: %sMB", - int(free_ram), int(linkage_required)) - retval = True - else: - raise FaceswapError("Not enough RAM available to sort faces. Try reducing " - f"the size of your dataset. Free RAM: {int(free_ram)}MB. " - f"Required RAM: {int(vector_required)}MB") - logger.debug(retval) - return retval - - def _do_linkage(self, - predictions: np.ndarray, - method: T.Literal["single", "centroid", "median", "ward"]) -> np.ndarray: - """ Use FastCluster to perform vector or standard linkage - - Parameters - ---------- - predictions: :class:`numpy.ndarray` - A stacked matrix of vgg_face2 predictions of the shape (`N`, `D`) where `N` is the - number of observations and `D` are the number of dimensions. - method: ['single','centroid','median','ward'] - The clustering method to use. - - Returns - ------- - :class:`numpy.ndarray` - The [`num_predictions`, 4] linkage vector - """ - dims = predictions.shape[-1] - if self._use_vector_linkage(dims): - retval = linkage_vector(predictions, method=method) - else: - retval = linkage(predictions, method=method, preserve_input=False) - logger.debug("Linkage shape: %s", retval.shape) - return retval - - def _process_leaf_node(self, - current_index: int, - current_bin: int) -> list[tuple[int, int]]: - """ Process the output when we have hit a leaf node """ - if not self._should_output_bins: - return [(current_index, 0)] - - if current_bin not in self._bins: - next_val = 0 if not self._bins else max(self._bins.values()) + 1 - self._bins[current_bin] = next_val - return [(current_index, self._bins[current_bin])] - - def _get_bin(self, - tree: np.ndarray, - points: int, - current_index: int, - current_bin: int) -> int: - """ Obtain the bin that we are currently in. - - If we are not currently below the threshold for binning, get a new bin ID from the integer - iterator. - - Parameters - ---------- - tree: numpy.ndarray - A hierarchical tree (dendrogram) - points: int - The number of points given to the clustering process - current_index: int - The position in the tree for the recursive traversal - current_bin int, optional - The ID for the bin we are currently in. Only used when binning is enabled - - Returns - ------- - int - The current bin ID for the node - """ - if tree[current_index - points, 2] >= self._threshold: - current_bin = next(self._iterator) - logger.debug("Creating new bin ID: %s", current_bin) - return current_bin - - def _seriation(self, - tree: np.ndarray, - points: int, - current_index: int, - current_bin: int = 0) -> list[tuple[int, int]]: - """ Seriation method for sorted similarity. - - Seriation computes the order implied by a hierarchical tree (dendrogram). - - Parameters - ---------- - tree: numpy.ndarray - A hierarchical tree (dendrogram) - points: int - The number of points given to the clustering process - current_index: int - The position in the tree for the recursive traversal - current_bin int, optional - The ID for the bin we are currently in. Only used when binning is enabled - - Returns - ------- - list: - The indices in the order implied by the hierarchical tree - """ - if current_index < points: # Output the leaf node - return self._process_leaf_node(current_index, current_bin) - - if self._should_output_bins: - current_bin = self._get_bin(tree, points, current_index, current_bin) - - left = int(tree[current_index-points, 0]) - right = int(tree[current_index-points, 1]) - - serate_left = self._seriation(tree, points, left, current_bin=current_bin) - serate_right = self._seriation(tree, points, right, current_bin=current_bin) - - return serate_left + serate_right # type: ignore - - def __call__(self) -> list[tuple[int, int]]: - """ Process the linkages. - - Transforms a distance matrix into a sorted distance matrix according to the order implied - by the hierarchical tree (dendrogram). - - Returns - ------- - list: - List of indices with the order implied by the hierarchical tree or list of tuples of - (`index`, `bin`) if a binning threshold was provided - """ - logger.info("Sorting face distances. Depending on your dataset this may take some time...") - if self._threshold: - self._threshold = self._result_linkage[:, 2].max() * self._threshold - result_order = self._seriation(self._result_linkage, - self._num_predictions, - self._num_predictions + self._num_predictions - 2) - return result_order - - -__all__ = get_module_objects(__name__) diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 02c3fb36ca..13ae98c57e 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -1,26 +1,65 @@ #!/usr/bin/env python3 """ Plugin loader for Faceswap extract, training and convert tasks """ from __future__ import annotations +import ast import logging import os import typing as T from importlib import import_module -from lib.utils import get_module_objects +from lib.utils import full_path_split, get_module_objects, PROJECT_ROOT if T.TYPE_CHECKING: from collections.abc import Callable - from plugins.extract.detect._base import Detector - from plugins.extract.align._base import Aligner - from plugins.extract.mask._base import Masker - from plugins.extract.recognition._base import Identity + from plugins.extract.base import ExtractPlugin from plugins.train.model._base import ModelBase from plugins.train.trainer._base import TrainerBase logger = logging.getLogger(__name__) +def get_extractors() -> dict[str, list[str]]: # noqa[C901] + """ Obtain a dictionary of all available extraction plugins by plugin type + + Returns + ------- + dict[str, list[:class:`plugins.extract._base.ExtractPlugin`]] + A list of all available plugins for each extraction plugin type + """ + root = os.path.join(PROJECT_ROOT, "plugins", "extract") + folders = sorted(os.path.join(root, fldr) for fldr in os.listdir(root) + if os.path.isdir(os.path.join(root, fldr)) + and not fldr.startswith("_")) + retval: dict[str, list[str]] = {} + for fldr in folders: + files = sorted(os.path.join(fldr, fname) for fname in os.listdir(fldr) + if os.path.isfile(os.path.join(fldr, fname)) + and fname.endswith(".py") + and not fname.startswith("_") + and not fname.endswith("_defaults.py")) + mods = [] + for fpath in files: + try: + with open(fpath, "r", encoding="utf-8") as pfile: + tree = ast.parse(pfile.read()) + except Exception: # pylint:disable=broad-except + continue + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + for base in node.bases: + if not isinstance(base, ast.Name): + continue + if base.id in ("ExtractPlugin", "FacePlugin"): + rel_path = os.path.splitext(fpath.replace(PROJECT_ROOT, "")[1:])[0] + mods.append(".".join(full_path_split(rel_path) + [node.name])) + if mods: + retval[os.path.basename(fldr)] = list(sorted(mods)) + logger.debug("Extraction plugins: %s", retval) + return retval + + class PluginLoader(): """ Retrieve, or get information on, Faceswap plugins @@ -33,81 +72,48 @@ class PluginLoader(): >>> align_plugins = PluginLoader.get_available_extractors('align') >>> aligner = PluginLoader.get_aligner('cv2-dnn') """ - @staticmethod - def get_detector(name: str, disable_logging: bool = False) -> type[Detector]: - """ Return requested detector plugin - - Parameters - ---------- - name: str - The name of the requested detector plugin - disable_logging: bool, optional - Whether to disable the INFO log message that the plugin is being imported. - Default: `False` - - Returns - ------- - :class:`plugins.extract.detect` object: - An extraction detector plugin - """ - return PluginLoader._import("extract.detect", name, disable_logging) + extract_plugins = get_extractors() - @staticmethod - def get_aligner(name: str, disable_logging: bool = False) -> type[Aligner]: - """ Return requested aligner plugin + @classmethod + def get_extractor(cls, + plugin_type: T.Literal["align", "detect", "identity", "mask"], + name: str) -> ExtractPlugin: + """ Return requested extractor plugin Parameters ---------- + type : Literal["align", "detect", "identity", "mask"] + The type of extractor plugin to obtain name: str - The name of the requested aligner plugin - disable_logging: bool, optional - Whether to disable the INFO log message that the plugin is being imported. - Default: `False` + The name of the requested extractor plugin Returns ------- - :class:`plugins.extract.align` object: - An extraction aligner plugin - """ - return PluginLoader._import("extract.align", name, disable_logging) - - @staticmethod - def get_masker(name: str, disable_logging: bool = False) -> type[Masker]: - """ Return requested masker plugin + type[:class:`plugins.extract.ExtractPlugin`] + An extraction plugin - Parameters - ---------- - name: str - The name of the requested masker plugin - disable_logging: bool, optional - Whether to disable the INFO log message that the plugin is being imported. - Default: `False` - - Returns - ------- - :class:`plugins.extract.mask` object: - An extraction masker plugin + Raises + ------ + ValueError + If an invalid plugin type or plugin name is selected """ - return PluginLoader._import("extract.mask", name, disable_logging) + if plugin_type not in cls.extract_plugins: + raise ValueError(f"{plugin_type} is not a valid plugin type. Select from " + f"{list(cls.extract_plugins)}") + plugins = cls.extract_plugins[plugin_type] + mods = [p.split(".")[-2] for p in plugins] + real_name = name.lower().replace("-", "_") + if real_name not in mods: + raise ValueError(f"{name} is not a valid {plugin_type} plugin. Select from {mods}") + + mod, obj = plugins[mods.index(real_name)].rsplit(".", maxsplit=1) + logger.debug("Loading '%s' from '%s'", plugin_type, name) - @staticmethod - def get_recognition(name: str, disable_logging: bool = False) -> type[Identity]: - """ Return requested recognition plugin - - Parameters - ---------- - name: str - The name of the requested reccognition plugin - disable_logging: bool, optional - Whether to disable the INFO log message that the plugin is being imported. - Default: `False` + module = import_module(mod) - Returns - ------- - :class:`plugins.extract.recognition` object: - An extraction recognition plugin - """ - return PluginLoader._import("extract.recognition", name, disable_logging) + retval = getattr(module, obj)() + logger.info("Loading %s from %s", plugin_type.title(), retval.name) + return retval @staticmethod def get_model(name: str, disable_logging: bool = False) -> type[ModelBase]: @@ -177,7 +183,7 @@ def _import(attr: str, name: str, disable_logging: bool): Parameters ---------- name: str - The name of the requested converter plugin + The name of the requested plugin disable_logging: bool Whether to disable the INFO log message that the plugin is being imported. @@ -195,15 +201,16 @@ def _import(attr: str, name: str, disable_logging: bool): module = import_module(mod) return getattr(module, ttl) - @staticmethod - def get_available_extractors(extractor_type: T.Literal["align", "detect", "mask"], + @classmethod + def get_available_extractors(cls, + extractor_type: T.Literal["align", "detect", "identity", "mask"], add_none: bool = False, extend_plugin: bool = False) -> list[str]: """ Return a list of available extractors of the given type Parameters ---------- - extractor_type: {'align', 'detect', 'mask'} + extractor_type : Literal["align", "detect", "identity", "mask"] The type of extractor to return the plugins for add_none: bool, optional Append "none" to the list of returned plugins. Default: False @@ -220,25 +227,21 @@ def get_available_extractors(extractor_type: T.Literal["align", "detect", "mask" list: A list of the available extractor plugin names for the given type """ - extractpath = os.path.join(os.path.dirname(__file__), - "extract", - extractor_type) - extractors = [item.name.replace(".py", "").replace("_", "-") - for item in os.scandir(extractpath) - if not item.name.startswith("_") - and not item.name.endswith("defaults.py") - and item.name.endswith(".py")] - extendable = ["bisenet-fp", "custom"] - if extend_plugin and extractor_type == "mask" and any(ext in extendable - for ext in extractors): - for msk in extendable: - extractors.remove(msk) - extractors.extend([f"{msk}_face", f"{msk}_head"]) - - extractors = sorted(extractors) + if extractor_type not in cls.extract_plugins: + raise ValueError(f"{extractor_type} is not a valid plugin type. Select from " + f"{list(cls.extract_plugins)}") + plugins = [x.split(".")[-2].replace("_", "-") for x in cls.extract_plugins[extractor_type]] + if extend_plugin and extractor_type == "mask": + extendable = ["bisenet-fp", "custom"] + for plugin in extendable: + if plugin not in plugins: + continue + plugins.remove(plugin) + plugins.extend([f"{plugin}_face", f"{plugin}_head"]) + plugins = sorted(plugins) if add_none: - extractors.insert(0, "none") - return extractors + plugins.insert(0, "none") + return plugins @staticmethod def get_available_models() -> list[str]: diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index e8274a0548..f205d7d602 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -65,7 +65,7 @@ def __init__(self, self._is_predict = predict self._model: keras.Model | None = None - cfg.load_config(config_file=arguments.configfile) + cfg.load_config(config_file=arguments.config_file) if cfg.Loss.penalized_mask_loss() and cfg.Loss.mask_type() == "none": raise FaceswapError("Penalized Mask Loss has been selected but you have not chosen a " @@ -201,8 +201,8 @@ def _check_multiple_models(self) -> None: f"for the '{multiple_models[0]}' plugin already exists in the folder " f"'{self.io.model_dir}'.\nPlease select a different model folder.") else: - ptypes = "', '".join(multiple_models) - msg = (f"There are multiple plugin types ('{ptypes}') stored in the model folder '" + p_types = "', '".join(multiple_models) + msg = (f"There are multiple plugin types ('{p_types}') stored in the model folder '" f"{self.io.model_dir}'. This is not supported.\nPlease split the model files " "into their own folders before proceeding") raise FaceswapError(msg) diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index 21903cd619..e10ad642f3 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -633,8 +633,9 @@ def _get_curve(start_y: int, start_y, end_y, num_points, scale, mode) if mode == "full": x_axis = np.linspace(0., 1., num=num_points) - y_axis = (x_axis - x_axis * scale) / (scale - abs(x_axis) * 2 * scale + 1) - y_axis = y_axis * (end_y - start_y) + start_y + y_axis: np.ndarray | list[int] = (x_axis - x_axis * scale) / (scale - abs(x_axis) + * 2 * scale + 1) + y_axis = T.cast(np.ndarray, y_axis) * (end_y - start_y) + start_y retval = [int((y // 8) * 8) for y in y_axis] else: y_axis = [start_y] diff --git a/plugins/train/train_config.py b/plugins/train/train_config.py index 814614f48e..f2eccfa91b 100644 --- a/plugins/train/train_config.py +++ b/plugins/train/train_config.py @@ -280,7 +280,7 @@ def set_defaults(self, helptext="") -> None: "produces slightly blurrier results. Ref: Multi-Scale Structural Similarity for Image " "Quality Assessment https://www.cns.nyu.edu/pub/eero/wang03b.pdf"), "ms_ssim": _( - "Multiscale Structural Similarity Index Metric is similar to SSIM except that it " + "Multi-scale Structural Similarity Index Metric is similar to SSIM except that it " "performs the calculations along multiple scales of the input image."), "smooth_loss": _( "Smooth_L1 is a modification of the MAE loss to correct two of its disadvantages. " @@ -351,7 +351,7 @@ class Loss(GlobalSection): "\n\t 25 - The loss calculated for the second loss function will be reduced " "by a quarter prior to adding to the overall loss score. " "\n\t 400 - The loss calculated for the second loss function will be " - "mulitplied 4 times prior to adding to the overall loss score. " + "multiplied 4 times prior to adding to the overall loss score. " "\n\t 0 - Disables the second loss function altogether."), min_max=(0, 400), rounding=1, @@ -380,7 +380,7 @@ class Loss(GlobalSection): "\n\t 25 - The loss calculated for the third loss function will be reduced " "by a quarter prior to adding to the overall loss score. " "\n\t 400 - The loss calculated for the third loss function will be " - "mulitplied 4 times prior to adding to the overall loss score. " + "multiplied 4 times prior to adding to the overall loss score. " "\n\t 0 - Disables the third loss function altogether."), min_max=(0, 400), rounding=1, @@ -410,7 +410,7 @@ class Loss(GlobalSection): "\n\t 25 - The loss calculated for the fourth loss function will be reduced " "by a quarter prior to adding to the overall loss score. " "\n\t 400 - The loss calculated for the fourth loss function will be " - "mulitplied 4 times prior to adding to the overall loss score. " + "multiplied 4 times prior to adding to the overall loss score. " "\n\t 0 - Disables the fourth loss function altogether."), min_max=(0, 400), rounding=1, @@ -477,36 +477,37 @@ class Loss(GlobalSection): "required mask should have been selected as part of the Extract process. If " "it does not exist in the alignments file then it will be generated prior to " "training commencing." - "\n\tnone: Don't use a mask." - "\n\tbisenet-fp_face: Relatively lightweight NN based mask that provides more " + "\n\t none: Don't use a mask." + "\n\t bisenet-fp_face: Relatively lightweight NN based mask that provides more " "refined control over the area to be masked (configurable in mask settings). " "Use this version of bisenet-fp if your model is trained with 'face' or " "'legacy' centering." - "\n\tbisenet-fp_head: Relatively lightweight NN based mask that provides more " + "\n\t bisenet-fp_head: Relatively lightweight NN based mask that provides more " "refined control over the area to be masked (configurable in mask settings). " "Use this version of bisenet-fp if your model is trained with 'head' " "centering." - "\n\tcomponents: Mask designed to provide facial segmentation based on the " + "\n\t 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." - "\n\tcustom_face: Custom user created, face centered mask." - "\n\tcustom_head: Custom user created, head centered mask." - "\n\textended: Mask designed to provide facial segmentation based on the " + "\n\t custom_face: Custom user created, face centered mask." + "\n\t custom_head: Custom user created, head centered mask." + "\n\t 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." - "\n\tvgg-clear: Mask designed to provide smart segmentation of mostly frontal " + "\n\t 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." - "\n\tvgg-obstructed: Mask designed to provide smart segmentation of mostly " + "\n\t 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." - "\n\tunet-dfl: Mask designed to provide smart segmentation of mostly frontal " + "\n\t 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."), - choices=PluginLoader.get_available_extractors("mask", - add_none=True, extend_plugin=True), + choices=list(sorted(["extended", "components"] + PluginLoader.get_available_extractors( + "mask", + add_none=True, extend_plugin=True))), gui_radio=True) mask_dilation = ConfigItem( datatype=float, @@ -565,7 +566,7 @@ class Optimizer(GlobalSection): group=_("optimizer"), info=_( "The optimizer to use." - "\n\t adabelief - Adapting Stepsizes by the Belief in Observed Gradients. An " + "\n\t adabelief - Adapting Step-sizes by the Belief in Observed Gradients. An " "optimizer with the aim to converge faster, generalize better and remain more " "stable. (https://arxiv.org/abs/2010.07468). NB: Epsilon for AdaBelief needs " "to be set to a smaller value than other Optimizers. Generally setting the " @@ -658,15 +659,15 @@ class Optimizer(GlobalSection): info=_( "Apply clipping to the gradients. Can help prevent NaNs and improve model " "optimization at the expense of VRAM." - "\n\tautoclip: Analyzes the gradient weights and adjusts the normalization " + "\n\t autoclip: Analyzes the gradient weights and adjusts the normalization " "value dynamically to fit the data" - "\n\tglobal_norm: Clips the gradient of each weight so that the global norm " + "\n\t global_norm: Clips the gradient of each weight so that the global norm " "is no higher than the given value." - "\n\tnorm: Clips the gradient of each weight so that its norm is no higher " + "\n\t norm: Clips the gradient of each weight so that its norm is no higher " "than the given value." - "\n\tvalue: Clips the gradient of each weight so that it is no higher than " + "\n\t value: Clips the gradient of each weight so that it is no higher than " "the given value." - "\n\tnone: Don't perform any clipping to the gradients."), + "\n\t none: Don't perform any clipping to the gradients."), choices=["autoclip", "global_norm", "norm", "value", "none"], gui_radio=True, fixed=False) @@ -694,7 +695,7 @@ class Optimizer(GlobalSection): default=10000, group=_("clipping"), info=_( - "The maximum number of prior iterations for autoclipper to analyze when " + "The maximum number of prior iterations for auto-clipper to analyze when " "calculating the normalization amount. 0 to always include all prior " "iterations."), min_max=(0, 100000), @@ -801,5 +802,5 @@ def load_config(config_file: str | None = None) -> None: """ global _IS_LOADED # pylint:disable=global-statement if not _IS_LOADED: - _Config(configfile=config_file) + _Config(config_file=config_file) _IS_LOADED = True diff --git a/pyproject.toml b/pyproject.toml index 71f762d260..a315ebc5c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ module = [ "sklearn.*", "tensorboard.*", "torch.*", + "torchvision.*", "tqdm.*", "win32console.*", "winpty.*",] diff --git a/scripts/convert.py b/scripts/convert.py index 13e5f2ea96..19ddc44ec1 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -1,5 +1,5 @@ #!/usr/bin python3 -""" Main entry point to the convert process of FaceSwap """ +"""Main entry point to the convert process of FaceSwap""" from __future__ import annotations from dataclasses import dataclass, field import logging @@ -14,18 +14,19 @@ import numpy as np from tqdm import tqdm -from scripts import fsmedia -from scripts.fsmedia import PostProcess, finalize +from scripts import fs_media +from scripts.fs_media import finalize from lib.serializer import get_serializer from lib.convert import Converter from lib.align import AlignedFace, DetectedFace, update_legacy_png_header +from lib.infer.objects import FrameFaces from lib.gpu_stats import GPUStats from lib.image import read_image_meta_batch, ImagesLoader from lib.multithreading import MultiThread, total_cpus from lib.queue_manager import queue_manager from lib.utils import (get_module_objects, FaceswapError, get_folder, - get_image_paths, handle_deprecated_cliopts) -from plugins.extract import ExtractMedia, Extractor + get_image_paths, handle_deprecated_cli_opts) +from lib.infer import Detect, Align from plugins.plugin_loader import PluginLoader from plugins.train import train_config as mod_cfg @@ -35,6 +36,8 @@ from plugins.convert.writer._base import Output from plugins.train.model._base import ModelBase from lib.align.aligned_face import CenteringType + from lib.infer.runner import ExtractRunner + from lib.infer.handler import ExtractHandler from lib.queue_manager import EventQueue @@ -43,30 +46,30 @@ @dataclass class ConvertItem: - """ A single frame with associated objects passing through the convert process. + """A single frame with associated objects passing through the convert process. Parameters ---------- - input: :class:`~plugins.extract.extract_media.ExtractMedia` - The ExtractMedia object holding the :attr:`filename`, :attr:`image` and attr:`list` of + input + The FrameFaces object holding the :attr:`filename`, :attr:`image` and attr:`list` of :class:`~lib.align.DetectedFace` objects loaded from disk - feed_faces: list, Optional + feed_faces list of :class:`lib.align.AlignedFace` objects for feeding into the model's predict function - reference_faces: list, Optional + reference_faces list of :class:`lib.align.AlignedFace` objects at model output sized for using as reference - in the convert functionfor feeding into the model's predict - swapped_faces: :class:`np.ndarray` + in the convert function for feeding into the model's predict + swapped_faces The swapped faces returned from the model's predict function """ - inbound: ExtractMedia + inbound: FrameFaces feed_faces: list[AlignedFace] = field(default_factory=list) reference_faces: list[AlignedFace] = field(default_factory=list) swapped_faces: np.ndarray = field(default_factory=lambda: np.array([])) class Convert(): - """ The Faceswap Face Conversion Process. + """The Faceswap Face Conversion Process. The conversion process is responsible for swapping the faces on source frames with the output from a trained model. @@ -79,13 +82,13 @@ class Convert(): Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The arguments to be passed to the convert process as generated from Faceswap's command line arguments """ def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: (args: %s)", self.__class__.__name__, arguments) - self._args = handle_deprecated_cliopts(arguments) + self._args = handle_deprecated_cli_opts(arguments) self._images = ImagesLoader(self._args.input_dir, fast_count=True) self._alignments = self._get_alignments() @@ -98,27 +101,27 @@ def __init__(self, arguments: Namespace) -> None: self._validate() get_folder(self._args.output_dir) - configfile = self._args.configfile if hasattr(self._args, "configfile") else None + config_file = self._args.config_file if hasattr(self._args, "config_file") else None self._converter = Converter(self._predictor.output_size, self._predictor.coverage_ratio, self._predictor.centering, self._disk_io.draw_transparent, self._disk_io.pre_encode, arguments, - configfile=configfile) + config_file=config_file) self._patch_threads = self._get_threads() logger.debug("Initialized %s", self.__class__.__name__) @property def _queue_size(self) -> int: - """ int: Size of the converter queues. 2 for single process otherwise 4 """ + """Size of the converter queues. 2 for single process otherwise 4""" retval = 2 if self._args.singleprocess or self._args.jobs == 1 else 4 logger.debug(retval) return retval @property def _pool_processes(self) -> int: - """ int: The number of threads to run in parallel. Based on user options and number of + """The number of threads to run in parallel. Based on user options and number of available processors. """ if self._args.singleprocess: retval = 1 @@ -130,26 +133,22 @@ def _pool_processes(self) -> int: logger.debug(retval) return retval - def _get_alignments(self) -> fsmedia.Alignments: - """ Perform validation checks and legacy updates and return alignemnts object + def _get_alignments(self) -> fs_media.Alignments: + """Perform validation checks and legacy updates and return alignments object Returns ------- - :class:`~scripts.fsmedia.Alignments` - The alignments file for the extract job + The alignments file for the extract job """ - retval = fsmedia.Alignments(self._args, False, self._images.is_video) - if retval.version == 1.0: - logger.error("The alignments file format has been updated since the given alignments " - "file was generated. You need to update the file to proceed.") - logger.error("To do this run the 'Alignments Tool' > 'Extract' Job.") - sys.exit(1) - + retval = fs_media.Alignments(self._args.alignments_path, + self._args.input_dir, + is_extract=False, + input_is_video=self._images.is_video) retval.update_legacy_has_source(os.path.basename(self._args.input_dir)) return retval def _validate(self) -> None: - """ Validate the Command Line Options. + """Validate the Command Line Options. Ensure that certain cli selections are valid and won't result in an error. Checks: * If frames have been passed in with video output, ensure user supplies reference @@ -178,7 +177,7 @@ def _validate(self) -> None: self._args.mask_type = "extended" if (not self._args.on_the_fly and - self._args.mask_type not in ("none", "predicted") and + self._args.mask_type not in ("none", "predicted", "extended", "components") and not self._alignments.mask_is_valid(self._args.mask_type)): msg = (f"You have selected the Mask Type `{self._args.mask_type}` but at least one " "face does not have this mask stored in the Alignments File.\nYou should " @@ -202,17 +201,17 @@ def _validate(self) -> None: self._args.mask_type = mask_type def _add_queues(self) -> None: - """ Add the queues for in, patch and out. """ + """Add the queues for in, patch and out.""" logger.debug("Adding queues. Queue size: %s", self._queue_size) for qname in ("convert_in", "convert_out", "patch"): queue_manager.add_queue(qname, self._queue_size) def _get_threads(self) -> MultiThread: - """ Get the threads for patching the converted faces onto the frames. + """Get the threads for patching the converted faces onto the frames. Returns - :class:`lib.multithreading.MultiThread` - The threads that perform the patching of swapped faces onto the output frames + ------- + The threads that perform the patching of swapped faces onto the output frames """ save_queue = queue_manager.get_queue("convert_out") patch_queue = queue_manager.get_queue("patch") @@ -220,7 +219,7 @@ def _get_threads(self) -> MultiThread: thread_count=self._pool_processes, name="patch") def process(self) -> None: - """ The entry point for triggering the Conversion Process. + """The entry point for triggering the Conversion Process. Should only be called from :class:`lib.cli.launcher.ScriptExecutor` @@ -243,14 +242,14 @@ def process(self) -> None: except MemoryError as err: msg = ("Faceswap ran out of RAM running convert. Conversion is very system RAM " "heavy, so this can happen in certain circumstances when you have a lot of " - "cpus but not enough RAM to support them all." + "CPUs but not enough RAM to support them all." "\nYou should lower the number of processes in use by either setting the " "'singleprocess' flag (-sp) or lowering the number of parallel jobs (-j).") raise FaceswapError(msg) from err def _convert_images(self) -> None: - """ Start the multi-threaded patching process, monitor all threads for errors and join on - completion. """ + """Start the multi-threaded patching process, monitor all threads for errors and join on + completion.""" logger.debug("Converting images") self._patch_threads.start() while True: @@ -269,7 +268,7 @@ def _convert_images(self) -> None: logger.debug("Converted images") def _check_thread_error(self) -> None: - """ Monitor all running threads for errors, and raise accordingly. + """Monitor all running threads for errors, and raise accordingly. Raises ------ @@ -284,7 +283,7 @@ def _check_thread_error(self) -> None: class DiskIO(): # pylint:disable=too-many-instance-attributes - """ Disk Input/Output for the converter process. + """Disk Input/Output for the converter process. Background threads to: * Load images from disk and get the detected faces @@ -292,19 +291,19 @@ class DiskIO(): # pylint:disable=too-many-instance-attributes Parameters ---------- - alignments: :class:`scripts.fsmedia.Alignments` + alignments The alignments for the input video - images: :class:`lib.image.ImagesLoader` + images The input images - predictor: :class:`Predict` + predictor The object for generating predictions from the model - arguments: :class:`argparse.Namespace` + arguments The arguments that were passed to the convert process as generated from Faceswap's command line arguments """ def __init__(self, - alignments: fsmedia.Alignments, + alignments: fs_media.Alignments, images: ImagesLoader, predictor: Predict, arguments: Namespace) -> None: @@ -313,11 +312,10 @@ def __init__(self, self._alignments = alignments self._images = images self._args = arguments - self._pre_process = PostProcess(arguments) self._completion_event = Event() # For frame skipping - self._imageidxre = re.compile(r"(\d+)(?!.*\d\.)(?=\.\w+$)") + self._image_idx_re = re.compile(r"(\d+)(?!.*\d\.)(?=\.\w+$)") self._frame_ranges = self._get_frame_ranges() self._writer = self._get_writer(predictor) @@ -331,19 +329,19 @@ def __init__(self, @property def completion_event(self) -> Event: - """ :class:`event.Event`: Event is set when the DiskIO Save task is complete """ + """Event is set when the DiskIO Save task is complete""" return self._completion_event @property def draw_transparent(self) -> bool: - """ bool: ``True`` if the selected writer can output transparent and it's Draw_transparent - configuration item is set otherwise ``False`` """ + """``True`` if the selected writer can output transparent and it's Draw_transparent + configuration item is set otherwise ``False``""" return self._writer.output_alpha @property def pre_encode(self) -> Callable[[np.ndarray, T.Any], list[bytes]] | None: - """ python function: Selected writer's pre-encode function, if it has one, - otherwise ``None`` """ + """python function: Selected writer's pre-encode function, if it has one, + otherwise ``None``""" dummy = np.zeros((20, 20, 3), dtype="uint8") test = self._writer.pre_encode(dummy) retval: Callable | None = None if test is None else self._writer.pre_encode @@ -352,25 +350,22 @@ def pre_encode(self) -> Callable[[np.ndarray, T.Any], list[bytes]] | None: @property def save_thread(self) -> MultiThread: - """ :class:`lib.multithreading.MultiThread`: The thread that is running the image writing - operation. """ + """The thread that is running the image writing operation.""" return self._threads["save"] @property def load_thread(self) -> MultiThread: - """ :class:`lib.multithreading.MultiThread`: The thread that is running the image loading - operation. """ + """The thread that is running the image loading operation.""" return self._threads["load"] @property def load_queue(self) -> EventQueue: - """ :class:`~lib.queue_manager.EventQueue`: The queue that images and detected faces are " - "loaded into. """ + """The queue that images and detected faces are loaded into.""" return self._queues["load"] @property def _total_count(self) -> int: - """ int: The total number of frames to be converted """ + """The total number of frames to be converted""" if self._frame_ranges and not self._args.keep_unchanged: retval = sum(fr[1] - fr[0] + 1 for fr in self._frame_ranges) else: @@ -380,17 +375,16 @@ def _total_count(self) -> int: # Initialization def _get_writer(self, predictor: Predict) -> Output: - """ Load the selected writer plugin. + """Load the selected writer plugin. Parameters ---------- - predictor: :class:`Predict` + predictor The object for generating predictions from the model Returns ------- - :mod:`plugins.convert.writer` plugin - The requested writer plugin + The requested writer plugin """ args = [self._args.output_dir] if self._args.writer in ("ffmpeg", "gif"): @@ -403,36 +397,34 @@ def _get_writer(self, predictor: Predict) -> Output: if self._args.writer == "patch": args.append(predictor.output_size) logger.debug("Writer args: %s", args) - configfile = self._args.configfile if hasattr(self._args, "configfile") else None + config_file = self._args.config_file if hasattr(self._args, "config_file") else None return PluginLoader.get_converter("writer", self._args.writer)(*args, - configfile=configfile) + config_file=config_file) def _get_frame_ranges(self) -> list[tuple[int, int]] | None: - """ Obtain the frame ranges that are to be converted. + """Obtain the frame ranges that are to be converted. If frame ranges have been specified, then split the command line formatted arguments into ranges that can be used. Returns - list or ``None`` - A list of frames to be processed, or ``None`` if the command line argument was not - used + A list of frames to be processed, or ``None`` if the command line argument was not used """ if not self._args.frame_ranges: logger.debug("No frame range set") return None - minframe, maxframe = None, None + min_frame, max_frame = None, None if self._images.is_video: - minframe, maxframe = 1, self._images.count + min_frame, max_frame = 1, self._images.count else: - indices = [int(self._imageidxre.findall(os.path.basename(filename))[0]) + indices = [int(self._image_idx_re.findall(os.path.basename(filename))[0]) for filename in self._images.file_list] if indices: - minframe, maxframe = min(indices), max(indices) - logger.debug("minframe: %s, maxframe: %s", minframe, maxframe) + min_frame, max_frame = min(indices), max(indices) + logger.debug("min_frame: %s, max_frame: %s", min_frame, max_frame) - if minframe is None or maxframe is None: + if min_frame is None or max_frame is None: raise FaceswapError("Frame Ranges specified, but could not determine frame numbering " "from filenames") @@ -441,20 +433,19 @@ def _get_frame_ranges(self) -> list[tuple[int, int]] | None: if "-" not in rng: raise FaceswapError("Frame Ranges not specified in the correct format") start, end = rng.split("-") - retval.append((max(int(start), minframe), min(int(end), maxframe))) + retval.append((max(int(start), min_frame), min(int(end), max_frame))) logger.debug("frame ranges: %s", retval) return retval - def _load_extractor(self) -> Extractor | None: - """ Load the CV2-DNN Face Extractor Chain. + def _load_extractor(self) -> ExtractRunner[ExtractHandler] | None: + """Load the CV2-DNN Face Extractor Chain. For On-The-Fly conversion we use a CPU based extractor to avoid stacking the GPU. Results are poor. Returns ------- - :class:`plugins.extract.Pipeline.Extractor` - The face extraction chain to be used for on-the-fly conversion + The face extraction chain to be used for on-the-fly conversion """ if not self._alignments.have_alignments_file and not self._args.on_the_fly: logger.error("No alignments file found. Please provide an alignments file for your " @@ -474,18 +465,12 @@ def _load_extractor(self) -> Extractor | None: "extraction and will produce poor results.") logger.warning("It is recommended to generate an alignments file for your destination " "video with Extract first for superior results.") - extractor = Extractor(detector="cv2-dnn", - aligner="cv2-dnn", - masker=self._args.mask_type, - multiprocess=True, - rotate_images=None, - min_size=20) - extractor.launch() + retval = Align("cv2-dnn")(Detect("cv2-dnn", min_size=3)()) logger.debug("Loaded extractor") - return extractor + return retval def _init_threads(self) -> None: - """ Initialize queues and threads. + """Initialize queues and threads. Creates the load and save queues and the load and save threads. Starts the threads. """ @@ -496,11 +481,11 @@ def _init_threads(self) -> None: logger.debug("Initialized DiskIO Threads") def _add_queue(self, task: T.Literal["load", "save"]) -> None: - """ Add the queue to queue_manager and to :attr:`self._queues` for the given task. + """Add the queue to queue_manager and to :attr:`self._queues` for the given task. Parameters ---------- - task: {"load", "save"} + task The task that the queue is to be added for """ logger.debug("Adding queue for task: '%s'", task) @@ -514,11 +499,11 @@ def _add_queue(self, task: T.Literal["load", "save"]) -> None: logger.debug("Added queue for task: '%s'", task) def _start_thread(self, task: T.Literal["load", "save"]) -> None: - """ Create the thread for the given task, add it it :attr:`self._threads` and start it. + """Create the thread for the given task, add it it :attr:`self._threads` and start it. Parameters ---------- - task: {"load", "save"} + task The task that the thread is to be created for """ logger.debug("Starting thread: '%s'", task) @@ -531,7 +516,7 @@ def _start_thread(self, task: T.Literal["load", "save"]) -> None: # Loading tasks def _load(self, *args) -> None: # pylint:disable=unused-argument - """ Load frames from disk. + """Load frames from disk. In a background thread: * Loads frames from disk. @@ -551,7 +536,7 @@ def _load(self, *args) -> None: # pylint:disable=unused-argument # All black frames will return not numpy.any() so check dims too logger.warning("Unable to open image. Skipping: '%s'", filename) continue - if self._check_skipframe(filename): + if self._check_skip_frame(filename): if self._args.keep_unchanged: logger.trace("Saving unchanged frame: %s", filename) # type:ignore out_file = os.path.join(self._args.output_dir, os.path.basename(filename)) @@ -561,56 +546,56 @@ def _load(self, *args) -> None: # pylint:disable=unused-argument continue detected_faces = self._get_detected_faces(filename, image) - item = ConvertItem(ExtractMedia(filename, image, detected_faces)) - self._pre_process.do_actions(item.inbound) + frame_faces = FrameFaces(filename, image) + frame_faces.detected_faces = detected_faces + item = ConvertItem(frame_faces) + self._queues["load"].put(item) logger.debug("Putting EOF") self._queues["load"].put("EOF") logger.debug("Load Images: Complete") - def _check_skipframe(self, filename: str) -> bool: - """ Check whether a frame is to be skipped. + def _check_skip_frame(self, filename: str) -> bool: + """Check whether a frame is to be skipped. Parameters ---------- - filename: str + filename The filename of the frame to check Returns ------- - bool - ``True`` if the frame is to be skipped otherwise ``False`` + ``True`` if the frame is to be skipped otherwise ``False`` """ if not self._frame_ranges: return False - indices = self._imageidxre.findall(filename) + indices = self._image_idx_re.findall(filename) if not indices: logger.warning("Could not determine frame number. Frame will be converted: '%s'", filename) return False idx = int(indices[0]) - skipframe = not any(map(lambda b: b[0] <= idx <= b[1], self._frame_ranges)) - logger.trace("idx: %s, skipframe: %s", idx, skipframe) # type: ignore[attr-defined] - return skipframe + skip_frame = not any(map(lambda b: b[0] <= idx <= b[1], self._frame_ranges)) + logger.trace("idx: %s, skip_frame: %s", idx, skip_frame) # type: ignore[attr-defined] + return skip_frame def _get_detected_faces(self, filename: str, image: np.ndarray) -> list[DetectedFace]: - """ Return the detected faces for the given image. + """Return the detected faces for the given image. If we have an alignments file, then the detected faces are created from that file. If we're running On-The-Fly then they will be extracted from the extractor. Parameters ---------- - filename: str + filename The filename to return the detected faces for - image: :class:`numpy.ndarray` + image The frame that the detected faces exist in Returns ------- - list - List of :class:`lib.align.DetectedFace` objects + List of :class:`lib.align.DetectedFace` objects """ logger.trace("Getting faces for: '%s'", filename) # type:ignore if not self._extractor: @@ -621,19 +606,18 @@ def _get_detected_faces(self, filename: str, image: np.ndarray) -> list[Detected return detected_faces def _alignments_faces(self, frame_name: str, image: np.ndarray) -> list[DetectedFace]: - """ Return detected faces from an alignments file. + """Return detected faces from an alignments file. Parameters ---------- - frame_name: str + frame_name The name of the frame to return the detected faces for - image: :class:`numpy.ndarray` + image The frame that the detected faces exist in Returns ------- - list - List of :class:`lib.align.DetectedFace` objects + List of :class:`lib.align.DetectedFace` objects """ if not self._check_alignments(frame_name): return [] @@ -641,26 +625,25 @@ def _alignments_faces(self, frame_name: str, image: np.ndarray) -> list[Detected faces = self._alignments.get_faces_in_frame(frame_name) detected_faces = [] - for rawface in faces: + for raw_face in faces: face = DetectedFace() - face.from_alignment(rawface, image=image) + face.from_alignment(raw_face, image=image) detected_faces.append(face) return detected_faces def _check_alignments(self, frame_name: str) -> bool: - """ Ensure that we have alignments for the current frame. + """Ensure that we have alignments for the current frame. If we have no alignments for this image, skip it and output a message. Parameters ---------- - frame_name: str + frame_name The name of the frame to check that we have alignments for Returns ------- - bool - ``True`` if we have alignments for this face, otherwise ``False`` + ``True`` if we have alignments for this face, otherwise ``False`` """ have_alignments = self._alignments.frame_exists(frame_name) if not have_alignments: @@ -668,38 +651,36 @@ def _check_alignments(self, frame_name: str) -> bool: return have_alignments def _detect_faces(self, filename: str, image: np.ndarray) -> list[DetectedFace]: - """ Extract the face from a frame for On-The-Fly conversion. + """Extract the face from a frame for On-The-Fly conversion. Pulls detected faces out of the Extraction pipeline. Parameters ---------- - filename: str + filename The filename to return the detected faces for - image: :class:`numpy.ndarray` + image The frame that the detected faces exist in Returns ------- - list - List of :class:`lib.align.DetectedFace` objects - """ + List of :class:`lib.align.DetectedFace` objects + """ assert self._extractor is not None - self._extractor.input_queue.put(ExtractMedia(filename, image)) - faces = next(self._extractor.detected_faces()) + faces = self._extractor.put(filename, image, passthrough=True) return faces.detected_faces # Saving tasks def _save(self, completion_event: Event) -> None: - """ Save the converted images. + """Save the converted images. Puts the selected writer into a background thread and feeds it from the output of the patch queue. Parameters ---------- - completion_event: :class:`event.Event` - An even that this process triggers when it has finished saving + completion_event + An event that this process triggers when it has finished saving """ logger.debug("Save Images: Start") write_preview = self._args.redirect_gui and self._writer.is_stream @@ -721,18 +702,20 @@ def _save(self, completion_event: Event) -> None: cv2.imwrite(preview_image, image) self._writer.write(filename, image) self._writer.close() + if self._extractor is not None: + self._extractor.stop() completion_event.set() logger.debug("Save Faces: Complete") class Predict(): # pylint:disable=too-many-instance-attributes - """ Obtains the output from the Faceswap model. + """Obtains the output from the Faceswap model. Parameters ---------- - queue_size: int + queue_size The maximum size of the input queue - arguments: :class:`argparse.Namespace` + arguments The arguments that were passed to the convert process as generated from Faceswap's command line arguments """ @@ -758,59 +741,57 @@ def __init__(self, queue_size: int, arguments: Namespace) -> None: @property def thread(self) -> MultiThread: - """ :class:`~lib.multithreading.MultiThread`: The thread that is running the prediction - function from the Faceswap model. """ + """The thread that is running the prediction function from the Faceswap model.""" assert self._thread is not None return self._thread @property def in_queue(self) -> EventQueue: - """ :class:`~lib.queue_manager.EventQueue`: The input queue to the predictor. """ + """The input queue to the predictor.""" assert self._in_queue is not None return self._in_queue @property def out_queue(self) -> EventQueue: - """ :class:`~lib.queue_manager.EventQueue`: The output queue from the predictor. """ + """The output queue from the predictor.""" return self._out_queue @property def faces_count(self) -> int: - """ int: The total number of faces seen by the Predictor. """ + """The total number of faces seen by the Predictor.""" return self._faces_count @property def verify_output(self) -> bool: - """ bool: ``True`` if multiple faces have been found in frames, otherwise ``False``. """ + """``True`` if multiple faces have been found in frames, otherwise ``False``.""" return self._verify_output @property def coverage_ratio(self) -> float: - """ float: The coverage ratio that the model was trained at. """ + """float: The coverage ratio that the model was trained at.""" return self._coverage_ratio @property def centering(self) -> CenteringType: - """ str: The centering that the model was trained on (`"head", "face"` or `"legacy"`) """ + """The centering that the model was trained on (`"head", "face"` or `"legacy"`)""" return self._centering @property def has_predicted_mask(self) -> bool: - """ bool: ``True`` if the model was trained to learn a mask, otherwise ``False``. """ + """``True`` if the model was trained to learn a mask, otherwise ``False``.""" return bool(mod_cfg.Loss.learn_mask()) @property def output_size(self) -> int: - """ int: The size in pixels of the Faceswap model output. """ + """The size in pixels of the Faceswap model output.""" return self._sizes["output"] def _get_io_sizes(self) -> dict[str, int]: - """ Obtain the input size and output size of the model. + """Obtain the input size and output size of the model. Returns ------- - dict - input_size in pixels and output_size in pixels + input_size in pixels and output_size in pixels """ input_shape = self._model.model.input_shape input_shape = [input_shape] if not isinstance(input_shape, list) else input_shape @@ -821,12 +802,11 @@ def _get_io_sizes(self) -> dict[str, int]: return retval def _load_model(self) -> ModelBase: - """ Load the Faceswap model. + """Load the Faceswap model. Returns ------- - :mod:`plugins.train.model` plugin - The trained model in the specified model folder + The trained model in the specified model folder """ logger.debug("Loading Model") model_dir = get_folder(self._args.model_dir, make_folder=False) @@ -839,20 +819,19 @@ def _load_model(self) -> ModelBase: return model def _get_batchsize(self, queue_size: int) -> int: - """ Get the batch size for feeding the model. + """Get the batch size for feeding the model. Sets the batch size to 1 if inference is being run on CPU, otherwise the minimum of the input queue size and the model's `convert_batchsize` configuration option. Parameters ---------- - queue_size: int + queue_size The queue size that is feeding the predictor Returns ------- - int - The batch size that the model is to be fed at. + The batch size that the model is to be fed at. """ logger.debug("Getting batchsize") is_cpu = GPUStats is None or GPUStats().device_count == 0 @@ -862,29 +841,27 @@ def _get_batchsize(self, queue_size: int) -> int: return batchsize def _get_model_name(self, model_dir: str) -> str: - """ Return the name of the Faceswap model used. + """Return the name of the Faceswap model used. Retrieve the name of the model from the model's state file. Parameters ---------- - model_dir: str + model_dir The folder that contains the trained Faceswap model Returns ------- - str - The name of the Faceswap model being used. - + The name of the Faceswap model being used. """ - statefiles = [fname for fname in os.listdir(str(model_dir)) - if fname.endswith("_state.json")] - if len(statefiles) != 1: + state_files = [fname for fname in os.listdir(str(model_dir)) + if fname.endswith("_state.json")] + if len(state_files) != 1: raise FaceswapError("There should be 1 state file in your model folder. " - f"{len(statefiles)} were found.") - statefile = os.path.join(str(model_dir), statefiles[0]) + f"{len(state_files)} were found.") + state_file = os.path.join(str(model_dir), state_files[0]) - state = self._serializer.load(statefile) + state = self._serializer.load(state_file) trainer = state.get("name", None) if not trainer: @@ -893,13 +870,13 @@ def _get_model_name(self, model_dir: str) -> str: return trainer def launch(self, load_queue: EventQueue) -> None: - """ Launch the prediction process in a background thread. + """Launch the prediction process in a background thread. Starts the prediction thread and returns the thread. Parameters ---------- - load_queue: :class:`~lib.queue_manager.EventQueue` + load_queue The queue that contains images and detected faces for feeding the model """ self._in_queue = load_queue @@ -907,7 +884,7 @@ def launch(self, load_queue: EventQueue) -> None: self._thread.start() def _predict_faces(self) -> None: - """ Run Prediction on the Faceswap model in a background thread. + """Run Prediction on the Faceswap model in a background thread. Reads from the :attr:`self._in_queue`, prepares images for prediction then puts the predictions back to the :attr:`self.out_queue` @@ -958,19 +935,18 @@ def _predict_faces(self) -> None: logger.debug("Load queue complete") def _process_batch(self, batch: list[ConvertItem], faces_seen: int): - """ Predict faces on the given batch of images and queue out to patch thread + """Predict faces on the given batch of images and queue out to patch thread Parameters ---------- - batch: list + batch List of :class:`ConvertItem` objects for the current batch - faces_seen: int + faces_seen The number of faces seen in the current batch Returns ------- - :class:`np.narray` - The predicted faces for the current batch + The predicted faces for the current batch """ logger.trace("Batching to predictor. Frames: %s, Faces: %s", # type:ignore len(batch), faces_seen) @@ -985,15 +961,15 @@ def _process_batch(self, batch: list[ConvertItem], faces_seen: int): self._queue_out_frames(batch, predicted) def load_aligned(self, item: ConvertItem) -> None: - """ Load the model's feed faces and the reference output faces. + """Load the model's feed faces and the reference output faces. For each detected face in the incoming item, load the feed face and reference face images, correctly sized for input and output respectively. Parameters ---------- - item: :class:`ConvertMedia` - The convert media object, containing the ExctractMedia for the current image + item + The convert media object, containing the FrameFaces for the current image """ logger.trace("Loading aligned faces: '%s'", item.inbound.filename) # type:ignore feed_faces = [] @@ -1023,17 +999,16 @@ def load_aligned(self, item: ConvertItem) -> None: @staticmethod def _compile_feed_faces(feed_faces: list[AlignedFace]) -> np.ndarray: - """ Compile a batch of faces for feeding into the Predictor. + """Compile a batch of faces for feeding into the Predictor. Parameters ---------- - feed_faces: list + feed_faces List of :class:`~lib.align.AlignedFace` objects sized for feeding into the model Returns ------- - :class:`numpy.ndarray` - A batch of faces ready for feeding into the Faceswap model. + A batch of faces ready for feeding into the Faceswap model. """ logger.trace("Compiling feed face. Batchsize: %s", len(feed_faces)) # type:ignore retval = np.stack([T.cast(np.ndarray, feed_face.face)[..., :3] @@ -1042,20 +1017,19 @@ def _compile_feed_faces(feed_faces: list[AlignedFace]) -> np.ndarray: return retval def _predict(self, feed_faces: np.ndarray, batch_size: int | None = None) -> np.ndarray: - """ Run the Faceswap models' prediction function. + """Run the Faceswap models' prediction function. Parameters ---------- - feed_faces: :class:`numpy.ndarray` + feed_faces The batch to be fed into the model - batch_size: int, optional + batch_size Used for plaidml only. Indicates to the model what batch size is being processed. Default: ``None`` Returns ------- - :class:`numpy.ndarray` - The swapped faces for the given batch + The swapped faces for the given batch """ logger.trace("Predicting: Batchsize: %s", len(feed_faces)) # type:ignore @@ -1086,16 +1060,16 @@ def _predict(self, feed_faces: np.ndarray, batch_size: int | None = None) -> np. return retval def _queue_out_frames(self, batch: list[ConvertItem], swapped_faces: np.ndarray) -> None: - """ Compile the batch back to original frames and put to the Out Queue. + """Compile the batch back to original frames and put to the Out Queue. For batching, faces are split away from their frames. This compiles all detected faces back to their parent frame before putting each frame to the out queue in batches. Parameters ---------- - batch: dict + batch The batch that was used as the input for the model predict function - swapped_faces: :class:`numpy.ndarray` + swapped_faces The predictions returned from the model's predict function """ logger.trace("Queueing out batch. Batchsize: %s", len(batch)) # type:ignore @@ -1115,24 +1089,24 @@ def _queue_out_frames(self, batch: list[ConvertItem], swapped_faces: np.ndarray) class OptionalActions(): # pylint:disable=too-few-public-methods - """ Process specific optional actions for Convert. + """Process specific optional actions for Convert. Currently only handles skip faces. This class should probably be (re)moved. Parameters ---------- - arguments : :class:`argparse.Namespace` + arguments The arguments that were passed to the convert process as generated from Faceswap's command line arguments - input_images : list[str] + input_images List of input image files - alignments : :class:`scripts.fsmedia.Alignments` + alignments The alignments file for this conversion """ def __init__(self, arguments: Namespace, input_images: list[str], - alignments: fsmedia.Alignments) -> None: + alignments: fs_media.Alignments) -> None: logger.debug("Initializing %s", self.__class__.__name__) self._args = arguments self._input_images = input_images @@ -1143,7 +1117,7 @@ def __init__(self, # SKIP FACES # def _remove_skipped_faces(self) -> None: - """ If the user has specified an input aligned directory, remove any non-matching faces + """If the user has specified an input aligned directory, remove any non-matching faces from the alignments file. """ logger.debug("Filtering Faces") accept_dict = self._get_face_metadata() @@ -1155,13 +1129,12 @@ def _remove_skipped_faces(self) -> None: logger.info("Faces filtered out: %s", pre_face_count - self._alignments.faces_count) def _get_face_metadata(self) -> dict[str, list[int]]: - """ Check for the existence of an aligned directory for identifying which faces in the + """Check for the existence of an aligned directory for identifying which faces in the target frames should be swapped. If it exists, scan the folder for face's metadata Returns ------- - dict - Dictionary of source frame names with a list of associated face indices to be skipped + Dictionary of source frame names with a list of associated face indices to be skipped """ retval: dict[str, list[int]] = {} input_aligned_dir = self._args.input_aligned_dir diff --git a/scripts/extract.py b/scripts/extract.py index 23b346f738..e49a680a84 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -1,104 +1,195 @@ #!/usr/bin python3 """ Main entry point to the extract process of FaceSwap """ - from __future__ import annotations + import logging import os import sys import typing as T +from time import sleep -from argparse import Namespace -from multiprocessing import Process +from dataclasses import asdict, dataclass +import cv2 import numpy as np -from tqdm import tqdm import torch -from lib.align.alignments import PNGHeaderDict +from tqdm import tqdm -from lib.image import encode_image, generate_thumbnail, ImagesLoader, ImagesSaver, read_image_meta -from lib.multithreading import MultiThread -from lib.utils import (get_folder, get_module_objects, handle_deprecated_cliopts, +from lib.align.aligned_utils import (batch_adjust_matrices, batch_align, batch_resize, + batch_transform, get_adjusted_center, get_centered_size) +from lib.align.alignments import AlignmentsFace, PNGHeader, PNGSource +from lib.align.constants import EXTRACT_RATIOS, LandmarkType, MEAN_FACE +from lib.align.detected_face import DetectedFace +from lib.align.pose import get_camera_matrix, get_xyz_2d, Batch3D +from lib.infer import Detect, Align, Identity, Mask, File, Profiler +from lib.infer.identity import FilterLoader +from lib.infer.objects import FrameFaces, frame_faces_to_alignment +from lib.image import encode_image, ImagesLoader, ImagesSaver +from lib.logger import parse_class_init +from lib.multithreading import FSThread +from lib.utils import (get_folder, get_module_objects, handle_deprecated_cli_opts, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS) -from plugins.extract import ExtractMedia, Extractor -from scripts.fsmedia import Alignments, PostProcess, finalize + +from .fs_media import Alignments, finalize if T.TYPE_CHECKING: - from lib.align.alignments import PNGHeaderAlignmentsDict + import numpy.typing as npt + from argparse import Namespace + from lib.align.alignments import AlignmentDict, AlignmentFileDict, PNGAlignments + from lib.infer.runner import ExtractRunner + from lib.multithreading import ErrorState -# tqdm.monitor_interval = 0 # workaround for TqdmSynchronisationWarning # TODO? logger = logging.getLogger(__name__) -class Extract(): - """ The Faceswap Face Extraction Process. +@dataclass +class BatchInfo: + """ Holds information about each input batch being processed through extract + + Parameters + ---------- + loader + The images loader for the batch + alignments + The alignments for the input + """ + loader: Loader + """The images loader for the batch""" + alignments: Alignments + """The alignments for the input""" - The extraction process is responsible for detecting faces in a series of images/video, aligning - these faces and then generating a mask. - It leverages a series of user selected plugins, chained together using - :mod:`plugins.extract.pipeline`. +class Extract: + """ The Faceswap Face Extraction Process. - The extract process is self contained and should not be referenced by any other scripts, so it - contains no public properties. + The extraction process is responsible for detecting faces in a series of images/video, aligning + them and optionally collecting further data about each face leveraging various user selected + plugins Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The arguments to be passed to the extraction process as generated from Faceswap's command line arguments """ def __init__(self, arguments: Namespace) -> None: - logger.debug("Initializing %s: (args: %s", self.__class__.__name__, arguments) - self._args = handle_deprecated_cliopts(arguments) - self._input_locations = self._get_input_locations() - self._validate_batchmode() - - configfile = self._args.configfile if hasattr(self._args, "configfile") else None - normalization = None if self._args.normalization == "none" else self._args.normalization - maskers = ["components", "extended"] - maskers += self._args.masker if self._args.masker else [] - recognition = ("vgg_face2" - if arguments.identity or arguments.filter or arguments.nfilter - else None) - self._extractor = Extractor(self._args.detector, - self._args.aligner, - maskers, - recognition=recognition, - configfile=configfile, - multiprocess=not self._args.singleprocess, - rotate_images=self._args.rotate_images, - min_size=self._args.min_size, - normalize_method=normalization, - re_feed=self._args.re_feed, - re_align=self._args.re_align) - self._filter = Filter(self._args.ref_threshold, - self._args.filter, - self._args.nfilter, - self._extractor) - - def _get_input_locations(self) -> list[str]: + logger.debug(parse_class_init(locals())) + args = handle_deprecated_cli_opts(arguments, + additional={"K": ("to skip saving faces", True, None)}) + args = self._validate_compatible_args(args) + input_locations = self._get_input_locations(args.input_dir, args.batch_mode) + self._validate_batch_mode(args.batch_mode, input_locations, args) + self._configure_torch(args.compile) + self._face_filter = FilterLoader(args.ref_threshold, args.filter, args.nfilter) + self._pipeline = self._load_pipeline(args) + + file_input = args.detector == "file" or args.aligner == "file" + save_alignments = self._should_save_alignments(args) + self._batches = [BatchInfo(ld := Loader(self._pipeline, + input_location, + file_input, + args.extract_every_n, + args.skip_existing, + args.skip_faces, + idx == len(input_locations) - 1), + Alignments(args.alignments_path, + ld.location, + is_extract=True, + skip_existing_frames=args.skip_existing, + skip_existing_faces=arguments.skip_faces, + plugin_is_file=file_input, + save_alignments=save_alignments, + input_is_video=ld.is_video)) + for idx, input_location in enumerate(input_locations)] + + self._output = Output(self._pipeline, + args.output_dir, + args.size, + args.min_scale, + self._batches, + args.save_interval, + args.debug_landmarks) + + @classmethod + def _get_input_locations(cls, input_location: str, batch_mode: bool) -> list[str]: """ Obtain the full path to input locations. Will be a list of locations if batch mode is - selected, or a containing a single location if batch mode is not selected. + selected, or a list containing a single location if batch mode is not selected. + + Parameters + ---------- + input_location + The full path to the input location. Either a video file, a folder of images or a + folder containing either/or videos and sub-folders of images (if batch mode is + selected) + batch_mode + ``True`` if extract is running in batch mode Returns ------- - list: - The list of input location paths + The list of input location paths """ - if not self._args.batch_mode or os.path.isfile(self._args.input_dir): - return [self._args.input_dir] # Not batch mode or a single file + if not batch_mode: + return [input_location] - retval = [os.path.join(self._args.input_dir, fname) - for fname in os.listdir(self._args.input_dir) - if (os.path.isdir(os.path.join(self._args.input_dir, fname)) # folder images + if os.path.isfile(input_location): + logger.warning("Batch mode selected but input is not a folder. Switching to normal " + "mode") + return [input_location] + + retval = [os.path.join(input_location, fname) + for fname in os.listdir(input_location) + if (os.path.isdir(os.path.join(input_location, fname)) # folder images and any(os.path.splitext(iname)[-1].lower() in IMAGE_EXTENSIONS - for iname in os.listdir(os.path.join(self._args.input_dir, fname)))) + for iname in os.listdir(os.path.join(input_location, fname)))) or os.path.splitext(fname)[-1].lower() in VIDEO_EXTENSIONS] # video - logger.debug("Input locations: %s", retval) + retval = list(sorted(retval)) + logger.debug("[Extract] Input locations: %s", retval) return retval - def _validate_batchmode(self) -> None: + @classmethod + def _validate_compatible_args(cls, args: Namespace) -> Namespace: + """Some cli arguments are not compatible with each other. If conflicting arguments have + been selected, log a warning and make necessary changes + + Parameters + ---------- + args + The command line arguments to be checked and updated for conflicts + + Returns + ------- + The updated command line arguments + """ + # Can't run a detector if importing landmarks + if args.aligner == "file" and args.detector != "file": + logger.warning("Detecting faces is not compatible with importing landmarks from a " + "file. Setting Detector to 'file'") + args.detector = "file" + # Impossible to skip existing when not running detection + if args.skip_existing and args.detector == "file": + logger.warning("Skipping existing frames is not compatible with importing from a file " + "for detection. Disabling 'skip_existing'") + args.skip_existing = False + # Impossible to get missing faces when we do not have a detector or aligner + if args.skip_faces and (args.detector == "file" or args.aligner == "file"): + logger.warning("Skipping existing faces is not compatible with importing from a file. " + "Disabling 'skip_existing_faces'") + args.skip_faces = False + # Face filtering needs a recognition plugin + if (args.filter or args.nfilter) and not args.identity: + logger.warning("Face-filtering is enabled, but an identity plugin has not been " + "selected. Selecting 'T-Face' plugin") + args.identity = ["t-face"] + # We can only use 1 identity for face filtering, so we select the first given + if (args.filter or args.nfilter) and len(args.identity) > 1: + logger.warning("Face-filtering is enabled, but multiple identity plugins have been " + "selected. Using '%s' for filtering", args.identity[0]) + return args + + def _validate_batch_mode(self, batch_mode: bool, + input_locations: list[str], + args: Namespace) -> None: """ Validate the command line arguments. If batch-mode selected and there is only one object to extract from, then batch mode is @@ -107,725 +198,866 @@ def _validate_batchmode(self) -> None: If processing in batch mode, some of the given arguments may not make sense, in which case a warning is shown and those options are reset. + Parameters + ---------- + batch_mode + ``True`` if extract is running in batch mode + input_locations + The discovered input locations within the input folder + args + The passed in command line arguments that may require amending """ - if not self._args.batch_mode: + if not batch_mode: return - if os.path.isfile(self._args.input_dir): - logger.warning("Batch mode selected but input is not a folder. Switching to normal " - "mode") - self._args.batch_mode = False - - if not self._input_locations: + if not input_locations: logger.error("Batch mode selected, but no valid files found in input location: '%s'. " - "Exiting.", self._args.input_dir) + "Exiting.", args.input_dir) sys.exit(1) - if self._args.alignments_path: + if args.alignments_path: logger.warning("Custom alignments path not supported for batch mode. " "Reverting to default.") - self._args.alignments_path = None - - def _output_for_input(self, input_location: str) -> str: - """ Obtain the path to an output folder for faces for a given input location. + args.alignments_path = None - If not running in batch mode, then the user supplied output location will be returned, - otherwise a sub-folder within the user supplied output location will be returned based on - the input filename + @classmethod + def _configure_torch(cls, compile_models: bool) -> None: + """Set various Torch switches for inference optimization Parameters ---------- - input_location: str - The full path to an input video or folder of images + compile_models + ``True`` if model compilation has been requested """ - if not self._args.batch_mode: - return self._args.output_dir + torch.backends.cudnn.benchmark = True + torch.use_deterministic_algorithms(False) + torch.backends.cuda.matmul.allow_tf32 = True + torch.backends.cudnn.allow_tf32 = True + if not compile_models: + return + # pylint:disable=protected-access + torch._dynamo.config.cache_size_limit = 512 - retval = os.path.join(self._args.output_dir, - os.path.splitext(os.path.basename(input_location))[0]) - logger.debug("Returning output: '%s' for input: '%s'", retval, input_location) - return retval + def _should_save_alignments(self, arguments: Namespace) -> bool: + """ Decide whether alignments should be saved from the given command line arguments and + output suitable information - def process(self) -> None: - """ The entry point for triggering the Extraction Process. + Parameters + --------- + arguments + The arguments generated from Faceswap's command line arguments - Should only be called from :class:`lib.cli.launcher.ScriptExecutor` + Returns + ------- + ``True`` if alignments should be saved """ - logger.info('Starting, this may take a while...') - if self._args.batch_mode: - logger.info("Batch mode selected processing: %s", self._input_locations) - for job_no, location in enumerate(self._input_locations): - if self._args.batch_mode: - logger.info("Processing job %s of %s: '%s'", - job_no + 1, len(self._input_locations), location) - arguments = Namespace(**self._args.__dict__) - arguments.input_dir = location - arguments.output_dir = self._output_for_input(location) - else: - arguments = self._args - extract = _Extract(self._extractor, arguments) - if sys.platform == "linux" and len(self._input_locations) > 1: - # TODO - Running this in a process is hideously hacky. However, there is a memory - # leak in some instances when running in batch mode. Many days have been spent - # trying to track this down to no avail (most likely coming from C-code.) Running - # the extract job inside a process prevents the memory leak in testing. This should - # be replaced if/when the memory leak is found - # Only done for Linux as not reported elsewhere and this new process won't work in - # Windows because it can't fork. - proc = Process(target=extract.process) - proc.start() - proc.join() - else: - extract.process() - self._extractor.reset_phase_index() - - -class Filter(): - """ Obtains and holds face identity embeddings for any filter/nfilter image files - passed in from the command line. + if arguments.detector == arguments.aligner == "file" and ( + arguments.masker is None and arguments.identity is None): + logger.debug("[Extract] Extracting directly from file. Not saving alignments") + return False + if arguments.detector == arguments.aligner == "file" and arguments.extract_every_n > 1: + logger.warning("Alignments loaded from file, EEN > 1 and additional plugins selected.") + logger.warning("The extracted faces will contain the additional plugin data, but an " + "updated Alignments File will not be saved.") + return False + if arguments.detector == arguments.aligner == "file": + logger.info("Alignments file will be updated with data from additional plugins") + return True + + def _load_pipeline(self, arguments: Namespace) -> ExtractRunner: # noqa[C901] + """ Create the extraction pipeline and run profiling, if selected + + Parameters + --------- + arguments + The arguments generated from Faceswap's command line arguments + + Returns + ------- + The final runner, with input interfaces, from the pipeline + """ + retval = None + conf_file = arguments.config_file + profile = arguments.benchmark + try: + if arguments.detector != "file": + retval = Detect(arguments.detector, + rotation=arguments.rotate_images, + min_size=arguments.min_size, + max_size=arguments.max_size, + compile_model=arguments.compile, + config_file=conf_file)(retval, profile=profile) + if arguments.aligner != "file": + retval = Align(arguments.aligner, + re_feeds=arguments.re_feed, + re_align=arguments.re_align, + normalization=arguments.normalization, + filters=arguments.align_filters, + compile_model=arguments.compile, + config_file=conf_file)(retval, profile=profile) + if arguments.masker is not None: + for masker in arguments.masker: + retval = Mask(masker, + compile_model=arguments.compile, + config_file=conf_file)(retval, profile=profile) + if arguments.identity: + for idx, identity in enumerate(arguments.identity): + retval = Identity(identity, + self._face_filter.threshold, + compile_model=arguments.compile, + config_file=conf_file)(retval, profile=profile) + if self._face_filter.enabled and idx == 0: + # Add the first selected identity plugin + self._face_filter.add_identity_plugin(retval) + + if retval is not None and profile: + Profiler(retval)() + + retval = File()() if retval is None else retval + + except Exception: + logger.debug("[Extract] Error during pipeline initialization") + if retval is not None: + retval.stop() + raise + logger.debug("[Extract] Pipeline output: %s", retval) + return retval + + def process(self) -> None: + """ Run the extraction process """ + try: + if self._face_filter.enabled: + self._face_filter.get_embeddings(self._pipeline) + self._output.start() + for batch in self._batches: + batch.loader.start(batch.alignments.data) + batch.loader.join() + if batch.loader.error_state.has_error: + batch.loader.error_state.re_raise() + self._output.join() + except Exception: + self._output.join() + self._pipeline.stop() + raise + + +class Loader: # pylint:disable=too-many-instance-attributes + """ Loads images/video frames from disks and puts to queue for feeding the extraction pipeline Parameters ---------- - filter_files: list or ``None`` - The list of filter file(s) passed in as command line arguments - nfilter_files: list or ``None`` - The list of nfilter file(s) passed in as command line arguments - extractor: :class:`~plugins.extract.pipeline.Extractor` - The extractor pipeline for obtaining face identity from images + pipeline + The final plugin in the extraction pipeline + input_path + Full path to a folder of images or a video file + input_is_file + ``True`` if the input plugin to the pipeline is an alignments file (fsa or json) so + detected faces should be loaded from the file and passed into the pipeline + extract_every + The number of frames to extract from the source. 1 will extract every frame, 5 every 5th + frame etc + skip_existing_frames + ``True`` if existing extracted frames should be skipped + skip_existing_faces + ``True`` if frames with existing face detections should be skipped + is_final + ``True`` if this loader is for the final batch being processed """ def __init__(self, - threshold: float, - filter_files: list[str] | None, - nfilter_files: list[str] | None, - extractor: Extractor) -> None: - logger.debug("Initializing %s: (threshold: %s, filter_files: %s, nfilter_files: %s " - "extractor: %s)", self.__class__.__name__, threshold, filter_files, - nfilter_files, extractor) - self._threshold = threshold - self._filter_files, self._nfilter_files = self._validate_inputs(filter_files, - nfilter_files) - - if not self._filter_files and not self._nfilter_files: - logger.debug("Filter not selected. Exiting %s", self.__class__.__name__) - return - - self._embeddings: list[np.ndarray] = [np.array([]) for _ in self._filter_files] - self._nembeddings: list[np.ndarray] = [np.array([]) for _ in self._nfilter_files] - self._extractor = extractor - - self._get_embeddings() - self._extractor.recognition.add_identity_filters(self.embeddings, - self.n_embeddings, - self._threshold) - logger.debug("Initialized %s", self.__class__.__name__) + pipeline: ExtractRunner, + input_path: str, + input_is_file: bool, + extract_every: int, + skip_existing_frames: bool, + skip_existing_faces: bool, + is_final: bool) -> None: + logger.debug(parse_class_init(locals())) + self.location = input_path + """Full path to the input location for the loader""" + self.existing_count = 0 + """The number of frames that pre-exist within the alignments file that will be skipped + because skip_existing/skip_existing_faces has been selected""" + + self._input_is_file = input_is_file + self._pipeline = pipeline + self._is_final = is_final + self._extract_every = extract_every + self._skip_frames = skip_existing_frames + self._skip_faces = skip_existing_faces + + self._images = ImagesLoader(input_path) + self._thread = FSThread(self._load, name="ExtractLoader") + self._alignments: dict[str, AlignmentDict] = {} + self._missing_count = 0 + self._seen: set[str] = set() + self._ready = False @property - def active(self): - """ bool: ``True`` if filter files have been passed in command line arguments. ``False`` if - no filter files have been provided """ - return bool(self._filter_files) or bool(self._nfilter_files) + def count(self) -> int: + """The number of frames to be processed""" + # Wait until skip list has been processed before allowing another thread to call the count + while True: + if self._ready: + break + sleep(0.25) + continue + return self._images.process_count @property - def embeddings(self) -> np.ndarray: - """ :class:`numpy.ndarray`: The filter embeddings""" - if self._embeddings and all(np.any(e) for e in self._embeddings): - retval = np.concatenate(self._embeddings, axis=0) - else: - retval = np.array([]) - return retval + def is_video(self) -> bool: + """``True`` if the input location is a video file, ``False`` for folder of images""" + return self._images.is_video @property - def n_embeddings(self) -> np.ndarray: - """ :class:`numpy.ndarray`: The n-filter embeddings""" - if self._nembeddings and all(np.any(e) for e in self._nembeddings): - retval = np.concatenate(self._nembeddings, axis=0) - else: - retval = np.array([]) - return retval + def error_state(self) -> ErrorState: + """The global FSThread error state object""" + return self._thread.error_state - @classmethod - def _files_from_folder(cls, input_location: list[str]) -> list[str]: - """ Test whether the input location is a folder and if so, return the list of contained - image files, otherwise return the original input location - - Parameters - --------- - input_files: list - A list of full paths to individual files or to a folder location + def _set_skip_list(self) -> None: + """ Add the skip list to the image loader - Returns - ------- - bool - Either the original list of files provided, or the image files that exist in the - provided folder location + Checks against `extract_every_n` and the existence of alignments data (can exist if + `skip_existing` or `skip_existing_faces` has been provided) and compiles a list of frame + indices that should not be processed, providing these to :class:`lib.image.ImagesLoader`. """ - if not input_location or len(input_location) > 1: - return input_location - - test_folder = input_location[0] - if not os.path.isdir(test_folder): - logger.debug("'%s' is not a folder. Returning original list", test_folder) - return input_location - - retval = [os.path.join(test_folder, fname) - for fname in os.listdir(test_folder) - if os.path.splitext(fname)[-1].lower() in IMAGE_EXTENSIONS] - logger.info("Collected files from folder '%s': %s", test_folder, - [os.path.basename(f) for f in retval]) - return retval + existing = list(self._alignments) + if self._extract_every == 1 and not existing: + logger.debug("[Extract.Loader] No frames to be skipped") + self._ready = True + return - def _validate_inputs(self, - filter_files: list[str] | None, - nfilter_files: list[str] | None) -> tuple[list[str], list[str]]: - """ Validates that the given filter/nfilter files exist, are image files and are unique + skip_een = set(i for i in range(self._images.count) if i % self._extract_every != 0) + + file_names = ([os.path.basename(f) for f in self._images.file_list] + if self._skip_frames or self._skip_faces else []) + skip_frames = set(i for i, f in enumerate(file_names) + if f in existing) if self._skip_frames else set() + skip_faces = ( + set(i for i, f in enumerate(file_names) + if self._alignments.get(f, {}).get("faces")) # type:ignore[call-overload] + if self._skip_faces else set() + ) + skip_exist = skip_frames.union(skip_faces) + + if self._extract_every > 1: + logger.info("Skipping %s frames of %s for extract every %s", + len(skip_een), self._images.count, self._extract_every) + if skip_exist: + self.existing_count = len(skip_exist.difference(skip_een)) + logger.info("Skipping %s frames of %s for skip existing frames/faces", + self.existing_count, self._images.count - len(skip_een)) + + skip = list(skip_exist.union(skip_een)) + logger.debug("[Extract.Loader] Total skip count: %s", len(skip)) + self._images.add_skip_list(skip) + self._ready = True + + def _get_detected_faces(self, file_path: str) -> list[DetectedFace] | None: + """When importing data, obtain the existing detected face objects for passing through the + pipeline Parameters ---------- - filter_files: list or ``None`` - The list of filter file(s) passed in as command line arguments - nfilter_files: list or ``None`` - The list of nfilter file(s) passed in as command line arguments + file_path + The full path to the image being loaded Returns ------- - filter_files: list - List of full paths to filter files - nfilter_files: list - List of full paths to nfilter files + list[DetectedFace] | None + The imported detected face objects or ``None`` if data is not being imported """ - error = False - retval: list[list[str]] = [] - - for files in (filter_files, nfilter_files): - filt_files = [] if files is None else self._files_from_folder(files) - for file in filt_files: - if (not os.path.isfile(file) or - os.path.splitext(file)[-1].lower() not in IMAGE_EXTENSIONS): - logger.warning("Filter file '%s' does not exist or is not an image file", file) - error = True - retval.append(filt_files) - - filters = retval[0] - nfilters = retval[1] - f_fnames = set(os.path.basename(fname) for fname in filters) - n_fnames = set(os.path.basename(fname) for fname in nfilters) - if f_fnames.intersection(n_fnames): - error = True - logger.warning("filter and nfilter filenames should be unique. The following " - "filenames exist in both folders: %s", f_fnames.intersection(n_fnames)) - - if error: - logger.error("There was a problem processing filter files. See the above warnings for " - "details") - sys.exit(1) - logger.debug("filter_files: %s, nfilter_files: %s", retval[0], retval[1]) + if not self._input_is_file: + return None + fname = os.path.basename(file_path) + self._seen.add(fname) + if fname not in self._alignments: + self._missing_count += 1 + logger.verbose( # type:ignore[attr-defined] + "Adding frame with no detections as does not exist in import file: '%s'", fname) + return [] + retval = [DetectedFace().from_alignment(a) + for a in self._alignments[fname].get("faces", [])] + logger.trace( # type:ignore[attr-defined] + "[Extract.Loader] importing %s faces for file '%s'", len(retval), fname) + return retval - return filters, nfilters + def _finalize(self) -> None: + """Actions to run when the loader is exhausted""" + if self._is_final: + self._pipeline.stop() + if self._missing_count > 0: + logger.warning("%s images did not exist in the import file. Run in verbose mode to " + "see which files have been added with no detected faces.", + self._missing_count) + processed_files = set(self._images.processed_file_list) + if self._input_is_file and len(self._seen) != len(processed_files): + logger.warning("%s images exist in the import file but do not exist on disk. Run in " + "verbose mode to see which files are missing.", + len(processed_files) - len(self._seen)) + logger.verbose( # type:ignore[attr-defined] + "Files in import file that do not exist on disk: %s", + list(sorted(processed_files.difference(self._seen)))) - @classmethod - def _identity_from_extracted(cls, filename) -> tuple[np.ndarray, bool]: - """ Test whether the given image is a faceswap extracted face and contains identity - information. If so, return the identity embedding + def _load(self) -> None: + """ Load images from disk and pass to a queue for the extraction pipeline """ + logger.debug("[Extract.Loader] start") + for filename, image in self._images.load(): + faces = self._get_detected_faces(filename) + self._pipeline.put(filename, image, source=self.location, detected_faces=faces) + if self.error_state.has_error: + logger.debug("[Extract.Loader] Thread error OUT detected in worker thread") + return + self._finalize() + logger.debug("[Extract.Loader] end") + + def start(self, alignments: dict[str, AlignmentDict]) -> None: + """ Set the skip list and start loading images from disk Parameters ---------- - filename: str - Full path to the image file to load - - Returns - ------- - :class:`numpy.ndarray` - The identity embeddings, if they can be obtained from the image header, otherwise an - empty array - bool - ``True`` if the image is a faceswap extracted image otherwise ``False`` + alignments + Dictionary of existing alignments data for use when importing or skipping existing data """ - if os.path.splitext(filename)[-1].lower() != ".png": - logger.debug("'%s' not a png. Returning empty array", filename) - return np.array([]), False - - meta = read_image_meta(filename) - if "itxt" not in meta or "alignments" not in meta["itxt"]: - logger.debug("'%s' does not contain faceswap data. Returning empty array", filename) - return np.array([]), False - - align: "PNGHeaderAlignmentsDict" = meta["itxt"]["alignments"] - if "identity" not in align or "vggface2" not in align["identity"]: - logger.debug("'%s' does not contain identity data. Returning empty array", filename) - return np.array([]), True - - retval = np.array(align["identity"]["vggface2"]) - logger.debug("Obtained identity for '%s'. Shape: %s", filename, retval.shape) + self._alignments = alignments + self._set_skip_list() + logger.debug("[Extract.Loader] start thread") + if isinstance(self._pipeline.handler, File): + self._pipeline.register_external_error_state(self._thread.error_state) + self._thread.start() - return retval, True + def join(self) -> None: + """ Join the image loading thread and monitor for keyboard interrupts""" + logger.debug("[Extract.Loader] join thread") + while self._thread.is_alive(): + try: + self._thread.join(timeout=0.2) + except KeyboardInterrupt: + logger.debug("Terminate signal received. Stopping...") + raise + logger.debug("[Extract.Loader] joined thread") - def _process_extracted(self, item: ExtractMedia) -> None: - """ Process the output from the extraction pipeline. - If no face has been detected, or multiple faces are detected for the inclusive filter, - embeddings and filenames are removed from the filter. +class DebugLandmarks(): + """Draw debug landmarks on face output. - if a single face is detected or multiple faces are detected for the exclusive filter, - embeddings are added to the relevent filter list + Parameters + ---------- + size + The size of the extracted face image + """ + def __init__(self, size: int) -> None: + logger.debug(parse_class_init(locals())) + self._size = size + self._face_size = get_centered_size("head", "face", size) + self._legacy_size = get_centered_size("head", "legacy", size) + self._camera_matrix = get_camera_matrix() + self._mean_face = MEAN_FACE[LandmarkType.LM_2D_51][None] + self._face_expansion = 1.0 - EXTRACT_RATIOS["face"] + self._font = cv2.FONT_HERSHEY_SIMPLEX + self._font_scale = size / 512 + self._font_pad = size // 64 + + def _border_text(self, + image: np.ndarray, + text: str, + color: tuple[int, int, int], + position: tuple[int, int]) -> None: + """Create text on an image with a black border Parameters ---------- - item: :class:`plugins.extract.Pipeline.ExtracMedia` - The output from the extraction pipeline containing the identity encodings + image + The image to put bordered text on to + text + The text to place the image + color + The color of the text + position + The (x, y) co-ordinates to place the text """ - is_filter = item.filename in self._filter_files - lbl = "filter" if is_filter else "nfilter" - filelist = self._filter_files if is_filter else self._nfilter_files - embeddings = self._embeddings if is_filter else self._nembeddings - identities = np.array([face.identity["vggface2"] for face in item.detected_faces]) - idx = filelist.index(item.filename) - - if len(item.detected_faces) == 0: - logger.warning("No faces detected for %s in file '%s'. Image will not be used", - lbl, os.path.basename(item.filename)) - filelist.pop(idx) - embeddings.pop(idx) - return - - if len(item.detected_faces) == 1: - logger.debug("Adding identity for %s from file '%s'", lbl, item.filename) - embeddings[idx] = identities - return - - if len(item.detected_faces) > 1 and is_filter: - logger.warning("%s faces detected for filter in '%s'. These identies will not be used", - len(item.detected_faces), os.path.basename(item.filename)) - filelist.pop(idx) - embeddings.pop(idx) - return - - if len(item.detected_faces) > 1 and not is_filter: - logger.warning("%s faces detected for nfilter in '%s'. All of these identies will be " - "used", len(item.detected_faces), os.path.basename(item.filename)) - embeddings[idx] = identities - return - - def _identity_from_extractor(self, file_list: list[str], aligned: list[str]) -> None: - """ Obtain the identity embeddings from the extraction pipeline + thickness = 2 + for idx in range(2): + text_color = (0, 0, 0) if idx == 0 else color + cv2.putText(image, + text, + position, + self._font, + self._font_scale, + text_color, + thickness, + lineType=cv2.LINE_AA) + thickness //= 2 + + def _annotate_face_box(self, + face: npt.NDArray[np.uint8], + offset_head: npt.NDArray[np.float32], + offset_face: npt.NDArray[np.float32], + face_size) -> None: + """Annotate the face extract box and print the original size in pixels Parameters ---------- - filesile_list: list - List of full path to images to run through the extraction pipeline - aligned: list - List of full path to images that exist in attr:`filelist` that are faceswap aligned - images + face + The face image to annotate + offset_head + The (X, Y) offset for the head centered extract + offset_face + The (X, Y) offset for the face centered extract + face_size + The size of the face box in the original frame """ - logger.info("Extracting faces to obtain identity from images") - logger.debug("Files requiring full extraction: %s", - [fname for fname in file_list if fname not in aligned]) - logger.debug("Aligned files requiring identity info: %s", aligned) - - loader = PipelineLoader(file_list, self._extractor, aligned_filenames=aligned) - loader.launch() - - for phase in range(self._extractor.passes): - is_final = self._extractor.final_pass - detected_faces: dict[str, ExtractMedia] = {} - self._extractor.launch() - desc = "Obtaining reference face Identity" - if self._extractor.passes > 1: - desc = (f"{desc} pass {phase + 1} of {self._extractor.passes}: " - f"{self._extractor.phase_text}") - for extract_media in tqdm(self._extractor.detected_faces(), - total=len(file_list), - file=sys.stdout, - desc=desc): - if is_final: - self._process_extracted(extract_media) - else: - extract_media.remove_image() - # cache extract_media for next run - detected_faces[extract_media.filename] = extract_media - - if not is_final: - logger.debug("Reloading images") - loader.reload(detected_faces) - - self._extractor.reset_phase_index() - - def _get_embeddings(self) -> None: - """ Obtain the embeddings for the given filter lists """ - needs_extraction: list[str] = [] - aligned: list[str] = [] - - for files, embed in zip((self._filter_files, self._nfilter_files), - (self._embeddings, self._nembeddings)): - for idx, file in enumerate(files): - identity, is_aligned = self._identity_from_extracted(file) - if np.any(identity): - logger.debug("Obtained identity from png header: '%s'", file) - embed[idx] = identity[None, ...] - continue - - needs_extraction.append(file) - if is_aligned: - aligned.append(file) - - if needs_extraction: - self._identity_from_extractor(needs_extraction, aligned) - - if not self._nfilter_files and not self._filter_files: - logger.error("No faces were detected from your selected identity filter files") - sys.exit(1) + color = (0, 255, 0) + center = get_adjusted_center(self._size, offset_head, offset_face, "head", 0) + padding = self._face_size // 2 + roi = np.array([center - padding, center + padding]).tolist() + cv2.rectangle(face, roi[0], roi[1], color, 1) + # Size in top right corner + text_img = face.copy() + text = f"{face_size}px" + text_size = cv2.getTextSize(text, self._font, self._font_scale, 1)[0] + pos_x = roi[1][0] - (text_size[0] + self._font_pad) + pos_y = roi[0][1] + text_size[1] + self._font_pad + self._border_text(text_img, text, color, (pos_x, pos_y)) + cv2.addWeighted(text_img, 0.75, face, 0.25, 0, face) + + def _print_stats(self, + face: npt.NDArray[np.uint8], + distance: float, + pitch: float, + roll: float, + yaw: float) -> None: + """Print various metrics on the output face images - logger.debug("Filter: (filenames: %s, shape: %s), nFilter: (filenames: %s, shape: %s)", - [os.path.basename(f) for f in self._filter_files], - self.embeddings.shape, - [os.path.basename(f) for f in self._nfilter_files], - self.n_embeddings.shape) + Parameters + ---------- + face + The face image to annotate + distance + The distance of the face from a 'mean' face + pitch + The pitch of the face in degrees + roll + The roll of the face in degrees + yaw + The yaw of the face in degrees + """ + text_image = face.copy() + texts = [f"pitch: {pitch:.2f}", + f"yaw: {yaw:.2f}", + f"roll: {roll: .2f}", + f"distance: {distance:.2f}"] + colors = [(255, 0, 0), (0, 0, 255), (0, 255, 0), (255, 255, 255)] + text_sizes = [cv2.getTextSize(text, self._font, self._font_scale, 1)[0] for text in texts] + final_y = self._size - text_sizes[-1][1] + pos_y = [(size[1] + self._font_pad) * (idx + 1) + for idx, size in enumerate(text_sizes)][:-1] + [final_y] + pos_x = self._font_pad + for idx, text in enumerate(texts): + self._border_text(text_image, text, colors[idx], (pos_x, pos_y[idx])) + # Apply text to face + cv2.addWeighted(text_image, 0.75, face, 0.25, 0, face) + + def __call__(self, # pylint:disable=too-many-locals + faces: npt.NDArray[np.uint8], + matrices: npt.NDArray[np.float32], + media: FrameFaces) -> None: + """Draw debug annotations on extracted face images + Parameters + ---------- + faces + The aligned face images that are to be saved to disk + matrices + The adjustment matrices for transforming from frame space to face space + media + The corresponding FrameFaces media object for the faces + """ + if not np.any(faces): + return -class PipelineLoader(): - """ Handles loading and reloading images into the extraction pipeline. + landmarks = batch_transform( + matrices, T.cast("npt.NDArray[np.float32]", media.landmarks)).astype("int32") + aligned = media.aligned + norm_mats = aligned.matrices[:, :2, 0] + sizes = np.rint(1.0 / (self._face_expansion * + np.hypot(norm_mats[:, 0], norm_mats[:, 1]))).astype(np.int32) + dists = np.abs(aligned.landmarks_normalized[:, 17:] - + self._mean_face).mean(axis=(1, 2)) + pry = (Batch3D.pitch(aligned.rotation), + Batch3D.roll(aligned.rotation), + Batch3D.yaw(aligned.rotation)) + for idx, (face, lms) in enumerate(zip(faces, landmarks)): + # Landmarks + for (pos_x, pos_y) in lms: + cv2.circle(face, (pos_x, pos_y), 1, (0, 255, 255), -1) + # Pose + center = (self._size // 2, self._size // 2) + xyz = get_xyz_2d(aligned.rotation[idx], + aligned.translation[idx], + self._camera_matrix) - aligned.offsets_head[idx] + points = (xyz * self._size).astype("int32") + cv2.line(face, center, tuple(points[1]), (0, 255, 0), 1) + cv2.line(face, center, tuple(points[0]), (255, 0, 0), 1) + cv2.line(face, center, tuple(points[2]), (0, 0, 255), 1) + # Face centering + self._annotate_face_box(face, + aligned.offsets_head[idx], + aligned.offsets_face[idx], + int(sizes[idx])) + # Legacy centering + center_a = get_adjusted_center(self._size, + aligned.offsets_head[idx], + aligned.offsets_legacy[idx], + "head", + 0) + padding = self._legacy_size // 2 + roi = np.array([center_a - padding, center_a + padding]).tolist() + cv2.rectangle(face, roi[0], roi[1], (0, 0, 255), 1) + # Pitch/roll/yaw/distance + self._print_stats(face, + float(dists[idx]), + float(pry[0][idx]), + float(pry[1][idx]), + float(pry[2][idx])) + + +class Output: # pylint:disable=too-many-instance-attributes + """ Handles output processing and saving of extracted faces Parameters ---------- - path: str or list of str - Full path to a folder of images or a video file or a list of image files - extractor: :class:`~plugins.extract.pipeline.Extractor` - The extractor pipeline for obtaining face identity from images - aligned_filenames: list, optional - Used for when the loader is used for getting face filter embeddings. List of full path to - image files that exist in :attr:`path` that are aligned faceswap images + pipeline + The output runner from the extraction pipeline + output_folder + The full path to the output folder to save extracted faces. ``None`` to not save faces + size + The size to save extracted faces at + min_scale + The minimum percentage of the output size that should be accepted for outputting a face + to disk + batches + The information about each batch that is to be processed + save_interval + How often to save the alignments file + debug_landmarks + ``True`` to annotate the output images with debug data """ def __init__(self, - path: str | list[str], - extractor: Extractor, - aligned_filenames: list[str] | None = None) -> None: - logger.debug("Initializing %s: (path: %s, extractor: %s, aligned_filenames: %s)", - self.__class__.__name__, path, extractor, aligned_filenames) - self._images = ImagesLoader(path, fast_count=True) - self._extractor = extractor - self._threads: list[MultiThread] = [] - self._aligned_filenames = [] if aligned_filenames is None else aligned_filenames - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def is_video(self) -> bool: - """ bool: ``True`` if the input location is a video file, ``False`` if it is a folder of - images """ - return self._images.is_video - - @property - def file_list(self) -> list[str]: - """ list: A full list of files in the source location. If the input is a video - then this is a list of dummy filenames as corresponding to an alignments file """ - return self._images.file_list - - @property - def process_count(self) -> int: - """ int: The number of images or video frames to be processed (IE the total count less - items that are to be skipped from the :attr:`skip_list`)""" - return self._images.process_count + pipeline: ExtractRunner, + output_folder: str | None, + size: int, + min_scale: int, + batches: list[BatchInfo], + save_interval: int, + debug_landmarks: bool) -> None: + logger.debug(parse_class_init(locals())) + self._pipeline = pipeline + self._size = size + self._batches = batches + self._save_interval = save_interval + self._min_size = self._get_min_size(size, min_scale) + self._saver: None | ImagesSaver = None + self._outputs = self._get_outputs(output_folder) + self._thread = FSThread(self._process, name="ExtractOutput") + self._debug = DebugLandmarks(size) if debug_landmarks else None + self._counts = {"verify": False, "faces": 0, "scale_skip": 0} + self._align = {"padding": round((size * EXTRACT_RATIOS["head"]) / 2), + "padding_thumbnail": round((96 * EXTRACT_RATIOS["head"]) / 2), + "empty_faces": np.empty((0, size, size, 3), dtype=np.uint8)} - def add_skip_list(self, skip_list: list[int]) -> None: - """ Add a skip list to the :class:`ImagesLoader` + @classmethod + def _get_min_size(cls, extract_size: int, min_scale: int) -> int: + """ Obtain the minimum size that a face has been resized from to be included as a valid + extract. Parameters ---------- - skip_list: list - A list of indices corresponding to the frame indices that should be skipped by the - :func:`load` function. - """ - self._images.add_skip_list(skip_list) + extract_size + The requested size of the extracted images + min_scale + The percentage amount that has been supplied for valid faces (as a percentage of + extract size) - def launch(self) -> None: - """ Launch the image loading pipeline """ - self._threaded_redirector("load") - - def reload(self, detected_faces: dict[str, ExtractMedia]) -> None: - """ Reload images for multiple pipeline passes """ - self._threaded_redirector("reload", (detected_faces, )) - - def check_thread_error(self) -> None: - """ Check if any errors have occurred in the running threads and raise their errors """ - for thread in self._threads: - thread.check_and_raise_error() - - def join(self) -> None: - """ Join all open loader threads """ - for thread in self._threads: - thread.join() + Returns + ------- + The minimum size, in pixels, that a face is resized from to be considered valid + """ + retval = 0 if min_scale == 0 else max(4, int(extract_size * (min_scale / 100.))) + logger.debug("[Extract.Output] Extract size: %s, min percentage size: %s, min_size: %s", + extract_size, min_scale, retval) + return retval - def _threaded_redirector(self, task: str, io_args: tuple | None = None) -> None: - """ Redirect image input/output tasks to relevant queues in background thread + def _get_outputs(self, output_folder: str | None) -> list[str | None]: + """ Obtain the locations to save the output for each batch input location Parameters ---------- - task: str - The name of the task to be put into a background thread - io_args: tuple, optional - Any arguments that need to be provided to the background function - """ - logger.debug("Threading task: (Task: '%s')", task) - io_args = tuple() if io_args is None else io_args - func = getattr(self, f"_{task}") - io_thread = MultiThread(func, *io_args, thread_count=1) - io_thread.start() - self._threads.append(io_thread) + output_folder + The full path to the output folder to save extracted faces. ``None`` to not save faces - def _load(self) -> None: - """ Load the images - - Loads images from :class:`lib.image.ImagesLoader`, formats them into a dict compatible - with :class:`plugins.extract.Pipeline.Extractor` and passes them into the extraction queue. + Returns + ------- + The output locations for each input batch. ``None`` if faces are not to be saved """ - logger.debug("Load Images: Start") - load_queue = self._extractor.input_queue - for filename, image in self._images.load(): - if load_queue.shutdown_event.is_set(): - logger.debug("Load Queue: Stop signal received. Terminating") - break - is_aligned = filename in self._aligned_filenames - item = ExtractMedia(filename, image[..., :3], is_aligned=is_aligned) - load_queue.put(item) - load_queue.put("EOF") - logger.debug("Load Images: Complete") - - def _reload(self, detected_faces: dict[str, ExtractMedia]) -> None: - """ Reload the images and pair to detected face + num_batches = len(self._batches) + retval: list[str | None] + if not output_folder: + logger.debug("[Extract.Output] No save location selected") + return [None for _ in range(num_batches)] + out_folder = get_folder(output_folder) + if num_batches == 1: + logger.debug("[Extract.Output] Single save location: '%s'", out_folder) + return [out_folder] + retval = [os.path.join(out_folder, + os.path.splitext(os.path.basename(b.loader.location))[0]) + for b in self._batches] + logger.debug("[Extract.Output] Save locations: %s", retval) + return retval - When the extraction pipeline is running in serial mode, images are reloaded from disk, - paired with their extraction data and passed back into the extraction queue + def _should_output(self, matrices: npt.NDArray[np.float32]) -> npt.NDArray[np.bool_]: + """Test which of the faces should be saved based on the given minimum scale option Parameters ---------- - detected_faces: dict - Dictionary of :class:`~plugins.extract.extract_media.ExtractMedia` with the filename as - the key for repopulating the image attribute. - """ - logger.debug("Reload Images: Start. Detected Faces Count: %s", len(detected_faces)) - load_queue = self._extractor.input_queue - for filename, image in self._images.load(): - if load_queue.shutdown_event.is_set(): - logger.debug("Reload Queue: Stop signal received. Terminating") - break - logger.trace("Reloading image: '%s'", filename) # type: ignore - extract_media = detected_faces.pop(filename, None) - if not extract_media: - logger.warning("Couldn't find faces for: %s", filename) - continue - extract_media.set_image(image) - load_queue.put(extract_media) - load_queue.put("EOF") - logger.debug("Reload Images: Complete") - - -class _Extract(): - """ The Actual extraction process. + matrices + The normalized aligned matrices to check for original face size - This class is called by the parent :class:`Extract` process - - Parameters - ---------- - extractor: :class:`~plugins.extract.pipeline.Extractor` - The extractor pipeline for running extractions - arguments: :class:`argparse.Namespace` - The arguments to be passed to the extraction process as generated from Faceswap's command - line arguments - """ - def __init__(self, - extractor: Extractor, - arguments: Namespace) -> None: - logger.debug("Initializing %s: (extractor: %s, args: %s)", self.__class__.__name__, - extractor, arguments) - self._args = arguments - self._output_dir = None if self._args.skip_saving_faces else get_folder( - self._args.output_dir) - - logger.info("Output Directory: %s", self._output_dir) - self._loader = PipelineLoader(self._args.input_dir, extractor) - - self._alignments = Alignments(self._args, True, self._loader.is_video) - self._extractor = extractor - self._extractor.import_data(self._args.input_dir) - - self._existing_count = 0 - self._set_skip_list() - - self._post_process = PostProcess(arguments) - self._verify_output = False - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def _save_interval(self) -> int | None: - """ int: The number of frames to be processed between each saving of the alignments file if - it has been provided, otherwise ``None`` """ - if hasattr(self._args, "save_interval"): - return self._args.save_interval - return None - - @property - def _skip_num(self) -> int: - """ int: Number of frames to skip if extract_every_n has been provided """ - return self._args.extract_every_n if hasattr(self._args, "extract_every_n") else 1 - - def _set_skip_list(self) -> None: - """ Add the skip list to the image loader + Returns + ------- + Mask array containing ``True`` for each face that should be output. + """ + if self._min_size <= 0: + return np.fromiter((True for _ in range(len(matrices))), dtype=bool) + linear = matrices[:, :2, 0] + sizes = 1.0 / ((1.0 - EXTRACT_RATIOS["face"]) * np.hypot(linear[:, 0], linear[:, 1])) + return sizes >= self._min_size + + def _save_faces(self, # pylint:disable=too-many-locals + faces: npt.NDArray[np.uint8], + matrices: npt.NDArray[np.float32], + basename: str, + meta: list[PNGAlignments], + frame_size: tuple[int, int], + alignments_version: float, + is_video: bool) -> None: + """Encode the aligned faces with PNG Header information and save to disk - Checks against `extract_every_n` and the existence of alignments data (can exist if - `skip_existing` or `skip_existing_faces` has been provided) and compiles a list of frame - indices that should not be processed, providing these to :class:`lib.image.ImagesLoader`. + Parameters + ---------- + faces + The correctly sized and aligned faces to save for a frame + matrices + The normalized aligned affine matrices for the faces + basename + The base filename (without full path) of the original frame + meta + The meta data to add to each of the PNG headers + frame_size + The (height, width) of the original frame + alignments_version + The current alignments file version + is_video + ``True`` if the input is a video otherwise ``False`` """ - if self._skip_num == 1 and not self._alignments.data: - logger.debug("No frames to be skipped") + if self._saver is None: return - skip_list = [] - for idx, filename in enumerate(self._loader.file_list): - if idx % self._skip_num != 0: - logger.trace("Adding image '%s' to skip list due to " # type: ignore - "extract_every_n = %s", filename, self._skip_num) - skip_list.append(idx) - # Items may be in the alignments file if skip-existing[-faces] is selected - elif os.path.basename(filename) in self._alignments.data: - self._existing_count += 1 - logger.trace("Removing image: '%s' due to previously existing", # type: ignore - filename) - skip_list.append(idx) - if self._existing_count != 0: - logger.info("Skipping %s frames due to skip_existing/skip_existing_faces.", - self._existing_count) - logger.debug("Adding skip list: %s", skip_list) - self._loader.add_skip_list(skip_list) + split_name = os.path.splitext(basename)[0] + for idx, (face, data, save) in enumerate(zip(faces, meta, self._should_output(matrices))): + if not save: + self._counts["scale_skip"] += 1 + continue + img_name = f"{split_name}_{idx}.png" + header = PNGHeader(alignments=data, + source=PNGSource(alignments_version=alignments_version, + original_filename=img_name, + face_index=idx, + source_filename=basename, + source_is_video=is_video, + source_frame_dims=frame_size)) + img = encode_image(face, ".png", metadata=asdict(header)) + self._saver.save(img_name, img) + + def _get_faces_and_thumbs(self, media: FrameFaces + ) -> tuple[npt.NDArray[np.uint8], list[npt.NDArray[np.uint8]]]: + """Obtain the aligned faces and jpeg thumbnails from the given media object - def process(self) -> None: - """ The entry point for triggering the Extraction Process. + Parameters + ---------- + media + The FrameFaces object output from the extraction pipeline - Should only be called from :class:`lib.cli.launcher.ScriptExecutor` - """ - # from lib.queue_manager import queue_manager ; queue_manager.debug_monitor(3) - self._loader.launch() - self._run_extraction() - self._loader.join() - self._alignments.save() - finalize(self._loader.process_count + self._existing_count, - self._alignments.faces_count, - self._verify_output) - - def _run_extraction(self) -> None: - """ The main Faceswap Extraction process - - Receives items from :class:`plugins.extract.Pipeline.Extractor` and either saves out the - faces and data (if on the final pass) or reprocesses data through the pipeline for serial - processing. + Returns + ------- + faces + The (N, size, size, 3) aligned face images from the media object + thumbnails + The (N, 96, 96, 3) jpeg thumbnails for the media object """ - size = self._args.size if hasattr(self._args, "size") else 256 - saver = None if self._args.skip_saving_faces else ImagesSaver(self._output_dir, - as_bytes=True) - for phase in range(self._extractor.passes): - is_final = self._extractor.final_pass - detected_faces: dict[str, ExtractMedia] = {} - self._extractor.launch() - self._loader.check_thread_error() - ph_desc = "Extraction" if self._extractor.passes == 1 else self._extractor.phase_text - desc = f"Running pass {phase + 1} of {self._extractor.passes}: {ph_desc}" - for idx, extract_media in enumerate(tqdm(self._extractor.detected_faces(), - total=self._loader.process_count, - file=sys.stdout, - desc=desc, - leave=False)): - self._loader.check_thread_error() - if is_final: - self._output_processing(extract_media, size) - self._output_faces(saver, extract_media) - if self._save_interval and (idx + 1) % self._save_interval == 0: - self._alignments.save() - else: - extract_media.remove_image() - # cache extract_media for next run - detected_faces[extract_media.filename] = extract_media - - if not is_final: - logger.debug("Reloading images and resetting PyTorch memory cache") - torch.cuda.empty_cache() - self._loader.reload(detected_faces) - if saver is not None: - saver.close() - - def _output_processing(self, extract_media: ExtractMedia, size: int) -> None: - """ Prepare faces for output - - Loads the aligned face, generate the thumbnail, perform any processing actions and verify - the output. + if not media: + return (T.cast("npt.NDArray[np.uint8]", self._align["empty_faces"]), []) + image_ids = np.fromiter((0 for _ in range(len(media))), dtype=np.int32) + if self._saver is None: + faces = np.empty((0, self._size, self._size, 3), dtype=np.uint8) + mats = batch_adjust_matrices(media.aligned.matrices_head, + 96, + T.cast(int, self._align["padding_thumbnail"])) + thumbs = batch_align([media.image], image_ids, mats, 96) + else: + mats = batch_adjust_matrices(media.aligned.matrices_head, + self._size, + T.cast(int, self._align["padding"])) + faces = batch_align([media.image], + image_ids, + mats, + self._size, + fast_upscale=False) + thumbs = batch_resize(faces, 96) + if self._debug is not None: + self._debug(faces, mats, media) + + thumbnails = [cv2.imencode(".jpg", t, [cv2.IMWRITE_JPEG_QUALITY, 60])[1] + for t in thumbs] + return faces, thumbnails + + def _process_faces(self, media: FrameFaces, alignments: Alignments, is_video: bool) -> None: + """ Process the detected face objects into aligned faces, generate the thumbnails and run + any post process actions Parameters ---------- - extract_media: :class:`~plugins.extract.extract_media.ExtractMedia` - Output from :class:`plugins.extract.pipeline.Extractor` - size: int - The size that the aligned face should be created at + media + The FrameFaces object output from the extraction pipeline + alignments + The alignments object that is to contain these faces + is_video + ``True`` if the input is a video otherwise ``False`` """ - for face in extract_media.detected_faces: - face.load_aligned(extract_media.image, - size=size, - centering="head") - face.thumbnail = generate_thumbnail(face.aligned.face, size=96, quality=60) - self._post_process.do_actions(extract_media) - extract_media.remove_image() - - faces_count = len(extract_media.detected_faces) + basename = os.path.basename(media.filename) + faces, thumbnails = self._get_faces_and_thumbs(media) + media.remove_image() # Spare the RAM + meta = frame_faces_to_alignment(media) + self._save_faces(faces, + media.aligned.matrices, + basename, + meta, + media.image_size, + alignments.version, + is_video) + alignments_faces = T.cast(list["AlignmentFileDict"], + [asdict(AlignmentsFace(**aln.__dict__, thumb=thumb.tolist())) + for aln, thumb in zip(meta, thumbnails)]) + alignments.data[basename] = {"faces": alignments_faces, "video_meta": {}} + faces_count = len(media) if faces_count == 0: - logger.verbose("No faces were detected in image: %s", # type: ignore - os.path.basename(extract_media.filename)) + logger.verbose("No faces were detected in image: %s", basename) # type: ignore + if not self._counts["verify"] and faces_count > 1: + self._counts["verify"] = True + self._counts["faces"] += faces_count - if not self._verify_output and faces_count > 1: - self._verify_output = True + def _set_saver(self, output: str | None) -> None: + """Close the currently active saver and set the next :attr:`_saver` for the given output - def _output_faces(self, saver: ImagesSaver | None, extract_media: ExtractMedia) -> None: - """ Output faces to save thread + Parameters + ---------- + output + The full path to the next output location + """ + if self._saver is not None: + self._saver.close() + if output is None: + self._saver = None + else: + self._saver = ImagesSaver(get_folder(output), as_bytes=True) + logger.debug("[Extract.Output] Set image saver to location: %s", + repr(self._saver if self._saver is None else self._saver.location)) - Set the face filename based on the frame name and put the face to the - :class:`~lib.image.ImagesSaver` save queue and add the face information to the alignments - data. + def _finalize_batch(self, batch: BatchInfo, batch_index: int) -> None: + """ Actions to perform when an input batch has finished processing. Parameters ---------- - saver: :class:`lib.images.ImagesSaver` or ``None`` - The background saver for saving the image or ``None`` if faces are not to be saved - extract_media: :class:`~plugins.extract.extract_media.ExtractMedia` - The output from :class:`~plugins.extract.Pipeline.Extractor` + batch + The information about the batch that has finished processing + batch_index + The index of the batch in :attr:`_self._batches` """ - logger.trace("Outputting faces for %s", extract_media.filename) # type: ignore - final_faces = [] - filename = os.path.splitext(os.path.basename(extract_media.filename))[0] - - skip_idx = 0 - for face_id, face in enumerate(extract_media.detected_faces): - real_face_id = face_id - skip_idx - output_filename = f"{filename}_{real_face_id}.png" - aligned = face.aligned.face - assert aligned is not None - meta: PNGHeaderDict = { - "alignments": face.to_png_meta(), - "source": {"alignments_version": self._alignments.version, - "original_filename": output_filename, - "face_index": real_face_id, - "source_filename": os.path.basename(extract_media.filename), - "source_is_video": self._loader.is_video, - "source_frame_dims": extract_media.image_size}} - image = encode_image(aligned, ".png", metadata=meta) - - sub_folder = extract_media.sub_folders[face_id] - # Binned faces shouldn't risk filename clash, so just use original id - out_name = output_filename if not sub_folder else f"{filename}_{face_id}.png" - - if saver is not None: - saver.save(out_name, image, sub_folder) - - if sub_folder: # This is a filtered out face being binned - skip_idx += 1 - continue - final_faces.append(face.to_alignment()) + logger.debug("[Extract.Output] Finalizing batch: %s", batch) + if batch.alignments.save_alignments: + if not self._save_interval: + batch.alignments.backup() + batch.alignments.save() + count = batch.loader.count - batch.loader.existing_count + if self._counts["scale_skip"] > 0: + logger.info("%s faces not output as they are below the minimum size of %spx. These " + "still exist in the alignments file.", + self._counts["scale_skip"], self._min_size) + finalize(count, T.cast(int, self._counts["faces"]), T.cast(bool, self._counts["verify"])) + self._counts["verify"] = False + output = None if batch_index == len(self._outputs) - 1 else self._outputs[batch_index + 1] + self._set_saver(output) + self._counts["faces"] = 0 + self._counts["scale_skip"] = 0 + del batch.alignments + + def _process(self) -> None: # noqa[C901] + """ Process the output from the extraction pipeline within a thread """ + logger.debug("[Extract.Output] start") + total_batches = len(self._batches) + self._set_saver(self._outputs[0]) + if self._saver is not None and self._min_size > 0: + logger.info("Only outputting faces that have been resized from a minimum resolution " + "of %spx", self._min_size) + + for batch_idx, batch in enumerate(self._batches): + msg = f" job {batch_idx + 1} of {total_batches}" if total_batches > 1 else "" + logger.info("Processing%s: '%s'", msg, batch.loader.location) + if self._saver is not None: + logger.info("Faces output: '%s'", self._saver.location) + has_started = False + save_interval = 0 if not batch.alignments.save_alignments else self._save_interval + with tqdm(desc="Extracting faces", + total=batch.loader.count, + leave=True, + smoothing=0) as prog_bar: + if batch_idx > 0: # Update for batch picked up at end of previous batch + prog_bar.update(1) + + for idx, media in enumerate(self._pipeline): + if not has_started: + prog_bar.reset() # Delay before first output, reset timer for better it/s + has_started = True + + if media.source != batch.loader.location: + self._finalize_batch(batch, batch_idx) + next_batch = self._batches[batch_idx + 1] + self._process_faces(media, next_batch.alignments, + next_batch.loader.is_video) + break + + self._process_faces(media, batch.alignments, batch.loader.is_video) + if save_interval and (idx + 1) % save_interval == 0: + batch.alignments.save() + if prog_bar.n + 1 > prog_bar.total: + # Don't switch to unknown mode when frame count is under + prog_bar.total += 1 + prog_bar.update(1) + + if self._thread.error_state.has_error: + logger.debug("[Extract.Output] Thread error detected in worker thread") + return + self._finalize_batch(self._batches[-1], len(self._batches) - 1) + logger.debug("[Extract.Output] end") - self._alignments.data[os.path.basename(extract_media.filename)] = {"faces": final_faces, - "video_meta": {}} - del extract_media + def start(self) -> None: + """ Start the output thread """ + logger.debug("[Extract.Output] start thread") + self._thread.start() + + def join(self) -> None: + """ Join the output thread """ + logger.debug("[Extract.Output] join thread") + self._thread.join() + logger.debug("[Extract.Output] joined thread") __all__ = get_module_objects(__name__) diff --git a/scripts/fs_media.py b/scripts/fs_media.py new file mode 100644 index 0000000000..60f278d480 --- /dev/null +++ b/scripts/fs_media.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python3 +""" Helper functions for :mod:`~scripts.extract` and :mod:`~scripts.convert`. + +Holds the classes for the 2 main Faceswap 'media' objects: Images and Alignments. + +Holds optional pre/post processing functions for convert and extract. +""" +from __future__ import annotations +import logging +import os +import sys +import typing as T + +from collections.abc import Iterator + +import numpy as np +import imageio + +from lib.align import Alignments as AlignmentsBase +from lib.image import count_frames, read_image +from lib.logger import parse_class_init +from lib.serializer import get_serializer +from lib.utils import get_image_paths, get_module_objects, VIDEO_EXTENSIONS + +if T.TYPE_CHECKING: + from collections.abc import Generator + from argparse import Namespace + from lib.align.alignments import AlignmentFileDict + +logger = logging.getLogger(__name__) + + +def finalize(images_found: int, num_faces_detected: int, verify_output: bool) -> None: + """ Output summary statistics at the end of the extract or convert processes. + + Parameters + ---------- + images_found: int + The number of images/frames that were processed + num_faces_detected: int + The number of faces that have been detected + verify_output: bool + ``True`` if multiple faces were detected in frames otherwise ``False``. + """ + logger.info("-------------------------") + logger.info("Images found: %s", images_found) + logger.info("Faces detected: %s", num_faces_detected) + if verify_output: + logger.info("Note: Multiple faces were detected in one or more pictures. " + "Double check your results.") + logger.info("-------------------------") + + +class Alignments(AlignmentsBase): + """Override :class:`lib.align.Alignments` to add custom loading based on command + line arguments. + + Parameters + ---------- + location + Full path to the alignments file. ``None`` to derive from the source file location + source_location + Full path to the source media for the alignments file. Either a folder of images or a video + file + arguments + The command line arguments that were passed to Faceswap + is_extract + ``True`` if the process calling this class is extraction. Default: ``False`` + skip_existing_frames + For extracting, indicates that 'skip existing' frames has been selected. Default: ``False`` + skip_existing_faces + For extracting, indicates that 'skip existing faces' has been selected. Default: ``False`` + plugin_is_file + ``True`` if 'File' has been selected for either/or a detector or aligner, indicating that + information may be being loaded from a json file + save_alignments + ``True`` if the alignments are to be saved at the end of the running process, based on + the selected extraction plugins + input_is_video + ``True`` if the input to the process is a video, ``False`` if it is a folder of images. + Default: ``False`` + """ + def __init__(self, + location: str | None, + source_location: str, + is_extract: bool, + skip_existing_frames: bool = False, + skip_existing_faces: bool = False, + plugin_is_file: bool = False, + save_alignments: bool = False, + input_is_video: bool = False) -> None: + logger.debug(parse_class_init(locals())) + self._is_extract = is_extract + self._skip_existing_frames = skip_existing_frames + self._skip_existing_faces = skip_existing_faces + self._plugin_is_file = plugin_is_file + self._save_alignments = save_alignments + folder, filename, self._import_json = self._set_folder_filename(location, + source_location, + input_is_video) + super().__init__(folder, filename=filename) + self._import_from_json() + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def save_alignments(self) -> bool: + """``True`` if the alignments should be saved at the end of the running process""" + return self._save_alignments or self._import_json + + def _set_folder_filename(self, + location: str | None, + source_location: str, + input_is_video: bool) -> tuple[str, str, bool]: + """Return the folder and the filename for the alignments file. + + If the location is not provided then for videos, the alignments file will be stored in the + same folder as the video, with filename `_alignments`. For a folder of images, + the alignments file will be stored in folder with the images and just be called + 'alignments' + + Parameters + ---------- + location + Full path to the alignments file. ``None`` to derive from the source file location + source_location + Full path to the source media for the alignments file. Either a folder of images or a + video file + input_is_video: + ``True`` if the input to the process is a video, ``False`` if it is a folder of images. + + Returns + ------- + folder + The folder where the alignments file will be stored + filename + The filename of the alignments file + needs_import + ``True`` if a 'file' plugin is being used for detect/align and the provided file is a + .json file + """ + if location: + logger.debug("Alignments File provided: '%s'", location) + folder, filename = os.path.split(str(location)) + if not self._plugin_is_file and os.path.splitext(filename)[-1].lower() == ".json": + logger.error("Json files are only valid with 'File' detect/align plugins.") + sys.exit(1) + elif input_is_video: + logger.debug("Alignments from Video File: '%s'", source_location) + folder, filename = os.path.split(source_location) + filename = f"{os.path.splitext(filename)[0]}_alignments" + else: + logger.debug("Alignments from Input Folder: '%s'", source_location) + folder = str(source_location) + filename = "alignments" + logger.debug("Setting Alignments: (folder: '%s' filename: '%s')", folder, filename) + + if not self._plugin_is_file: + return folder, filename, False + + full_path = os.path.join(folder, filename) + for ext in (".json", ".fsa"): + if os.path.splitext(filename)[-1].lower() in ext and os.path.exists(full_path): + return folder, os.path.splitext(filename)[0], ext == ".json" + full_file = f"{full_path}{ext}" + if os.path.exists(full_file): + return folder, filename, ext == ".json" + + logger.error("'File' has been selected for a Detect or Align plugin, but no alignments " + "file could be found. Check your paths.") + sys.exit(1) + + def _load(self) -> dict[str, T.Any]: + """Override the parent :func:`~lib.align.Alignments._load` to handle skip existing + frames and faces on extract. + + If skip existing has been selected, existing alignments are loaded and returned to the + calling script. + + Returns + ------- + dict + Any alignments that have already been extracted if skip existing has been selected + otherwise an empty dictionary + """ + data: dict[str, T.Any] = {} + if not self._is_extract and not self.have_alignments_file: + return data + if not self._is_extract: + data = super()._load() + return data + + if (not self._skip_existing_frames + and not self._skip_existing_faces + and not self._plugin_is_file): + logger.debug("No previous alignments file required. Returning empty dictionary") + return data + + file_exists = self.have_alignments_file or self._import_json + + if not file_exists and (self._skip_existing_frames or self._skip_existing_faces): + logger.warning("Skip Existing/Skip Faces selected, but no alignments file found!") + if not file_exists: + return data + + if self._import_json and self.have_alignments_file: + logger.warning("Importing alignments from json, but alignments file exists: '%s'", + self._io.file) + self.backup() + if self._import_json: + return data + + data = super()._load() + return data + + def _import_from_json(self) -> None: + """Import data from a JSON file when 'file' align/detect has been selected and a json file + has been provided """ + if not self._import_json: + return + json_file = f"{os.path.splitext(self._io.file)[0]}.json" + if self.data: + logger.warning("Importing alignments from json file '%s', but data pre-exists in file " + "'%s'. Any matching frames will be overwritten.", + json_file, self._io.file) + data = get_serializer("json").load(json_file) + for k, v in data.items(): + faces: list[AlignmentFileDict] = [] + for face in v: + if "detected" not in face: + lms = np.array(face["landmarks_2d"], dtype="float32") + assert len(lms) == 4, ( + "Missing detection boxes are only valid for ROI 4 point landmarks") + # Just place the box corners in the same location as the ROI box + mins = np.rint(lms.min(axis=0)).astype(np.int32).tolist() + maxes = np.rint(lms.max(axis=0)).astype(np.int32).tolist() + face["detected"] = mins + maxes + faces.append(T.cast("AlignmentFileDict", { + "x": face["detected"][0], + "y": face["detected"][1], + "w": face["detected"][2] - face["detected"][0], + "h": face["detected"][3] - face["detected"][1], + "landmarks_xy": np.array(face["landmarks_2d"], dtype="float32"), + "mask": {}, + "identity": {}})) + self._data[k] = {"faces": faces, "video_meta": {}} + logger.info("Imported %s frames from '%s'", len(data), json_file) + + +class Images(): + """Handles the loading of frames from a folder of images or a video file for extract + and convert processes. + + Parameters + ---------- + arguments + The command line arguments that were passed to Faceswap + """ + def __init__(self, arguments: Namespace) -> None: + logger.debug("Initializing %s", self.__class__.__name__) + self._args = arguments + self._is_video = self._check_input_folder() + self._input_images = self._get_input_images() + self._images_found = self._count_images() + logger.debug("Initialized %s", self.__class__.__name__) + + @property + def is_video(self) -> bool: + """``True`` if the input is a video file otherwise ``False``. """ + return self._is_video + + @property + def input_images(self) -> str | list[str]: + """Path to the video file if the input is a video otherwise list of image paths.""" + return self._input_images + + @property + def images_found(self) -> int: + """The number of frames that exist in the video file, or the folder of images.""" + return self._images_found + + def _count_images(self) -> int: + """Get the number of Frames from a video file or folder of images. + + Returns + ------- + The number of frames in the image source + """ + if self._is_video: + retval = int(count_frames(self._args.input_dir, fast=True)) + else: + retval = len(self._input_images) + return retval + + def _check_input_folder(self) -> bool: + """Check whether the input is a folder or video. + + Returns + ------- + ``True`` if the input is a video otherwise ``False`` + """ + if not os.path.exists(self._args.input_dir): + logger.error("Input location %s not found.", self._args.input_dir) + sys.exit(1) + if (os.path.isfile(self._args.input_dir) and + os.path.splitext(self._args.input_dir)[1].lower() in VIDEO_EXTENSIONS): + logger.info("Input Video: %s", self._args.input_dir) + retval = True + else: + logger.info("Input Directory: %s", self._args.input_dir) + retval = False + return retval + + def _get_input_images(self) -> str | list[str]: + """Return the list of images or path to video file that is to be processed. + + Returns + ------- + Path to the video file if the input is a video otherwise list of image paths. + """ + if self._is_video: + input_images = self._args.input_dir + else: + input_images = get_image_paths(self._args.input_dir) + + return input_images + + def load(self) -> Generator[tuple[str, np.ndarray], None, None]: + """Generator to load frames from a folder of images or from a video file. + + Yields + ------ + filename + The filename of the current frame + image + A single frame + """ + iterator = self._load_video_frames if self._is_video else self._load_disk_frames + for filename, image in iterator(): + yield filename, image + + def _load_disk_frames(self) -> Generator[tuple[str, np.ndarray], None, None]: + """Generator to load frames from a folder of images. + + Yields + ------ + filename + The filename of the current frame + image + A single frame + """ + logger.debug("Input is separate Frames. Loading images") + for filename in self._input_images: + image = read_image(filename, raise_error=False) + if image is None: + continue + yield filename, image + + def _load_video_frames(self) -> Generator[tuple[str, np.ndarray], None, None]: + """Generator to load frames from a video file. + + Yields + ------ + filename + The filename of the current frame + image + A single frame + """ + logger.debug("Input is video. Capturing frames") + vid_name, ext = os.path.splitext(os.path.basename(self._args.input_dir)) + reader = imageio.get_reader(self._args.input_dir, "ffmpeg") # type:ignore[arg-type] + for i, frame in enumerate(T.cast(Iterator[np.ndarray], reader)): + # Convert to BGR for cv2 compatibility + frame = frame[:, :, ::-1] + filename = f"{vid_name}_{i + 1:06d}{ext}" + logger.trace("Loading video frame: '%s'", filename) # type:ignore[attr-defined] + yield filename, frame + reader.close() + + def load_one_image(self, filename) -> np.ndarray: + """Obtain a single image for the given filename. + + Parameters + ---------- + filename + The filename to return the image for + + Returns + ------ + The image for the requested filename, + """ + logger.trace("Loading image: '%s'", filename) # type:ignore[attr-defined] + if self._is_video: + if filename.isdigit(): + frame_no = filename + else: + frame_no = os.path.splitext(filename)[0][filename.rfind("_") + 1:] + logger.trace( # type:ignore[attr-defined] + "Extracted frame_no %s from filename '%s'", frame_no, filename) + retval = self._load_one_video_frame(int(frame_no)) + else: + retval = read_image(filename, raise_error=True) + return retval + + def _load_one_video_frame(self, frame_no: int) -> np.ndarray: + """Obtain a single frame from a video file. + + Parameters + ---------- + frame_no + The frame index for the required frame + + Returns + ------ + The image for the requested frame index, + """ + logger.trace("Loading video frame: %s", frame_no) # type:ignore[attr-defined] + reader = imageio.get_reader(self._args.input_dir, "ffmpeg") # type:ignore[arg-type] + reader.set_image_index(frame_no - 1) + frame = reader.get_next_data()[:, :, ::-1] # type:ignore[index] + reader.close() + return frame + + +__all__ = get_module_objects(__name__) diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py deleted file mode 100644 index 913235610f..0000000000 --- a/scripts/fsmedia.py +++ /dev/null @@ -1,621 +0,0 @@ -#!/usr/bin/env python3 -""" Helper functions for :mod:`~scripts.extract` and :mod:`~scripts.convert`. - -Holds the classes for the 2 main Faceswap 'media' objects: Images and Alignments. - -Holds optional pre/post processing functions for convert and extract. -""" -from __future__ import annotations -import logging -import os -import sys -import typing as T - -from collections.abc import Iterator - -import cv2 -import numpy as np -import imageio - -from lib.align import Alignments as AlignmentsBase, get_centered_size -from lib.image import count_frames, read_image -from lib.utils import camel_case_split, get_image_paths, get_module_objects, VIDEO_EXTENSIONS - -if T.TYPE_CHECKING: - from collections.abc import Generator - from argparse import Namespace - from lib.align import AlignedFace - from plugins.extract import ExtractMedia - -logger = logging.getLogger(__name__) - - -def finalize(images_found: int, num_faces_detected: int, verify_output: bool) -> None: - """ Output summary statistics at the end of the extract or convert processes. - - Parameters - ---------- - images_found: int - The number of images/frames that were processed - num_faces_detected: int - The number of faces that have been detected - verify_output: bool - ``True`` if multiple faces were detected in frames otherwise ``False``. - """ - logger.info("-------------------------") - logger.info("Images found: %s", images_found) - logger.info("Faces detected: %s", num_faces_detected) - logger.info("-------------------------") - - if verify_output: - logger.info("Note:") - logger.info("Multiple faces were detected in one or more pictures.") - logger.info("Double check your results.") - logger.info("-------------------------") - - logger.info("Process Successfully Completed. Shutting Down...") - - -class Alignments(AlignmentsBase): - """ Override :class:`lib.align.Alignments` to add custom loading based on command - line arguments. - - Parameters - ---------- - arguments: :class:`argparse.Namespace` - The command line arguments that were passed to Faceswap - is_extract: bool - ``True`` if the process calling this class is extraction otherwise ``False`` - input_is_video: bool, optional - ``True`` if the input to the process is a video, ``False`` if it is a folder of images. - Default: False - """ - def __init__(self, - arguments: Namespace, - is_extract: bool, - input_is_video: bool = False) -> None: - logger.debug("Initializing %s: (is_extract: %s, input_is_video: %s)", - self.__class__.__name__, is_extract, input_is_video) - self._args = arguments - self._is_extract = is_extract - folder, filename = self._set_folder_filename(input_is_video) - super().__init__(folder, filename=filename) - logger.debug("Initialized %s", self.__class__.__name__) - - def _set_folder_filename(self, input_is_video: bool) -> tuple[str, str]: - """ Return the folder and the filename for the alignments file. - - If the input is a video, the alignments file will be stored in the same folder - as the video, with filename `_alignments`. - - If the input is a folder of images, the alignments file will be stored in folder with - the images and just be called 'alignments' - - Parameters - ---------- - input_is_video: bool, optional - ``True`` if the input to the process is a video, ``False`` if it is a folder of images. - - Returns - ------- - folder: str - The folder where the alignments file will be stored - filename: str - The filename of the alignments file - """ - if self._args.alignments_path: - logger.debug("Alignments File provided: '%s'", self._args.alignments_path) - folder, filename = os.path.split(str(self._args.alignments_path)) - elif input_is_video: - logger.debug("Alignments from Video File: '%s'", self._args.input_dir) - folder, filename = os.path.split(self._args.input_dir) - filename = f"{os.path.splitext(filename)[0]}_alignments.fsa" - else: - logger.debug("Alignments from Input Folder: '%s'", self._args.input_dir) - folder = str(self._args.input_dir) - filename = "alignments" - logger.debug("Setting Alignments: (folder: '%s' filename: '%s')", folder, filename) - return folder, filename - - def _load(self) -> dict[str, T.Any]: - """ Override the parent :func:`~lib.align.Alignments._load` to handle skip existing - frames and faces on extract. - - If skip existing has been selected, existing alignments are loaded and returned to the - calling script. - - Returns - ------- - dict - Any alignments that have already been extracted if skip existing has been selected - otherwise an empty dictionary - """ - data: dict[str, T.Any] = {} - if not self._is_extract and not self.have_alignments_file: - return data - if not self._is_extract: - data = super()._load() - return data - - skip_existing = hasattr(self._args, 'skip_existing') and self._args.skip_existing - skip_faces = hasattr(self._args, 'skip_faces') and self._args.skip_faces - - if not skip_existing and not skip_faces: - logger.debug("No skipping selected. Returning empty dictionary") - return data - - if not self.have_alignments_file and (skip_existing or skip_faces): - logger.warning("Skip Existing/Skip Faces selected, but no alignments file found!") - return data - - data = super()._load() - - if skip_faces: - # Remove items from alignments that have no faces so they will - # be re-detected - del_keys = [key for key, val in data.items() if not val["faces"]] - logger.debug("Frames with no faces selected for redetection: %s", len(del_keys)) - for key in del_keys: - if key in data: - logger.trace("Selected for redetection: '%s'", # type:ignore[attr-defined] - key) - del data[key] - return data - - -class Images(): - """ Handles the loading of frames from a folder of images or a video file for extract - and convert processes. - - Parameters - ---------- - arguments: :class:`argparse.Namespace` - The command line arguments that were passed to Faceswap - """ - def __init__(self, arguments: Namespace) -> None: - logger.debug("Initializing %s", self.__class__.__name__) - self._args = arguments - self._is_video = self._check_input_folder() - self._input_images = self._get_input_images() - self._images_found = self._count_images() - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def is_video(self) -> bool: - """bool: ``True`` if the input is a video file otherwise ``False``. """ - return self._is_video - - @property - def input_images(self) -> str | list[str]: - """str or list: Path to the video file if the input is a video otherwise list of - image paths. """ - return self._input_images - - @property - def images_found(self) -> int: - """int: The number of frames that exist in the video file, or the folder of images. """ - return self._images_found - - def _count_images(self) -> int: - """ Get the number of Frames from a video file or folder of images. - - Returns - ------- - int - The number of frames in the image source - """ - if self._is_video: - retval = int(count_frames(self._args.input_dir, fast=True)) - else: - retval = len(self._input_images) - return retval - - def _check_input_folder(self) -> bool: - """ Check whether the input is a folder or video. - - Returns - ------- - bool - ``True`` if the input is a video otherwise ``False`` - """ - if not os.path.exists(self._args.input_dir): - logger.error("Input location %s not found.", self._args.input_dir) - sys.exit(1) - if (os.path.isfile(self._args.input_dir) and - os.path.splitext(self._args.input_dir)[1].lower() in VIDEO_EXTENSIONS): - logger.info("Input Video: %s", self._args.input_dir) - retval = True - else: - logger.info("Input Directory: %s", self._args.input_dir) - retval = False - return retval - - def _get_input_images(self) -> str | list[str]: - """ Return the list of images or path to video file that is to be processed. - - Returns - ------- - str or list - Path to the video file if the input is a video otherwise list of image paths. - """ - if self._is_video: - input_images = self._args.input_dir - else: - input_images = get_image_paths(self._args.input_dir) - - return input_images - - def load(self) -> Generator[tuple[str, np.ndarray], None, None]: - """ Generator to load frames from a folder of images or from a video file. - - Yields - ------ - filename: str - The filename of the current frame - image: :class:`numpy.ndarray` - A single frame - """ - iterator = self._load_video_frames if self._is_video else self._load_disk_frames - for filename, image in iterator(): - yield filename, image - - def _load_disk_frames(self) -> Generator[tuple[str, np.ndarray], None, None]: - """ Generator to load frames from a folder of images. - - Yields - ------ - filename: str - The filename of the current frame - image: :class:`numpy.ndarray` - A single frame - """ - logger.debug("Input is separate Frames. Loading images") - for filename in self._input_images: - image = read_image(filename, raise_error=False) - if image is None: - continue - yield filename, image - - def _load_video_frames(self) -> Generator[tuple[str, np.ndarray], None, None]: - """ Generator to load frames from a video file. - - Yields - ------ - filename: str - The filename of the current frame - image: :class:`numpy.ndarray` - A single frame - """ - logger.debug("Input is video. Capturing frames") - vidname, ext = os.path.splitext(os.path.basename(self._args.input_dir)) - reader = imageio.get_reader(self._args.input_dir, "ffmpeg") # type:ignore[arg-type] - for i, frame in enumerate(T.cast(Iterator[np.ndarray], reader)): - # Convert to BGR for cv2 compatibility - frame = frame[:, :, ::-1] - filename = f"{vidname}_{i + 1:06d}{ext}" - logger.trace("Loading video frame: '%s'", filename) # type:ignore[attr-defined] - yield filename, frame - reader.close() - - def load_one_image(self, filename) -> np.ndarray: - """ Obtain a single image for the given filename. - - Parameters - ---------- - filename: str - The filename to return the image for - - Returns - ------ - :class:`numpy.ndarray` - The image for the requested filename, - - """ - logger.trace("Loading image: '%s'", filename) # type:ignore[attr-defined] - if self._is_video: - if filename.isdigit(): - frame_no = filename - else: - frame_no = os.path.splitext(filename)[0][filename.rfind("_") + 1:] - logger.trace( # type:ignore[attr-defined] - "Extracted frame_no %s from filename '%s'", frame_no, filename) - retval = self._load_one_video_frame(int(frame_no)) - else: - retval = read_image(filename, raise_error=True) - return retval - - def _load_one_video_frame(self, frame_no: int) -> np.ndarray: - """ Obtain a single frame from a video file. - - Parameters - ---------- - frame_no: int - The frame index for the required frame - - Returns - ------ - :class:`numpy.ndarray` - The image for the requested frame index, - """ - logger.trace("Loading video frame: %s", frame_no) # type:ignore[attr-defined] - reader = imageio.get_reader(self._args.input_dir, "ffmpeg") # type:ignore[arg-type] - reader.set_image_index(frame_no - 1) - frame = reader.get_next_data()[:, :, ::-1] # type:ignore[index] - reader.close() - return frame - - -class PostProcess(): - """ Optional pre/post processing tasks for convert and extract. - - Builds a pipeline of actions that have optionally been requested to be performed - in this session. - - Parameters - ---------- - arguments: :class:`argparse.Namespace` - The command line arguments that were passed to Faceswap - """ - def __init__(self, arguments: Namespace) -> None: - logger.debug("Initializing %s", self.__class__.__name__) - self._args = arguments - self._actions = self._set_actions() - logger.debug("Initialized %s", self.__class__.__name__) - - def _set_actions(self) -> list[PostProcessAction]: - """ Compile the requested actions to be performed into a list - - Returns - ------- - list - The list of :class:`PostProcessAction` to be performed - """ - postprocess_items = self._get_items() - actions: list["PostProcessAction"] = [] - for action, options in postprocess_items.items(): - options = {} if options is None else options - args = options.get("args", tuple()) - kwargs = options.get("kwargs", {}) - args = args if isinstance(args, tuple) else tuple() - kwargs = kwargs if isinstance(kwargs, dict) else {} - task = globals()[action](*args, **kwargs) - if task.valid: - logger.debug("Adding Postprocess action: '%s'", task) - actions.append(task) - - for ppaction in actions: - action_name = camel_case_split(ppaction.__class__.__name__) - logger.info("Adding post processing item: %s", " ".join(action_name)) - - return actions - - def _get_items(self) -> dict[str, dict[str, tuple | dict] | None]: - """ Check the passed in command line arguments for requested actions, - - For any requested actions, add the item to the actions list along with - any relevant arguments and keyword arguments. - - Returns - ------- - dict - The name of the action to be performed as the key. Any action specific - arguments and keyword arguments as the value. - """ - postprocess_items: dict[str, dict[str, tuple | dict] | None] = {} - # Debug Landmarks - if (hasattr(self._args, 'debug_landmarks') and self._args.debug_landmarks): - postprocess_items["DebugLandmarks"] = None - - logger.debug("Postprocess Items: %s", postprocess_items) - return postprocess_items - - def do_actions(self, extract_media: ExtractMedia) -> None: - """ Perform the requested optional post-processing actions on the given image. - - Parameters - ---------- - extract_media: :class:`~plugins.extract.extract_media.ExtractMedia` - The :class:`~plugins.extract.extract_media.ExtractMedia` object to perform the - action on. - - Returns - ------- - :class:`~plugins.extract.extract_media.ExtractMedia` - The original :class:`~plugins.extract.extract_media.ExtractMedia` with any actions - applied - """ - for action in self._actions: - logger.debug("Performing postprocess action: '%s'", action.__class__.__name__) - action.process(extract_media) - - -class PostProcessAction(): - """ Parent class for Post Processing Actions. - - Usable in Extract or Convert or both depending on context. Any post-processing actions should - inherit from this class. - - Parameters - ----------- - args: tuple - Varies for specific post process action - kwargs: dict - Varies for specific post process action - """ - def __init__(self, *args, **kwargs) -> None: - logger.debug("Initializing %s: (args: %s, kwargs: %s)", - self.__class__.__name__, args, kwargs) - self._valid = True # Set to False if invalid parameters passed in to disable - logger.debug("Initialized base class %s", self.__class__.__name__) - - @property - def valid(self) -> bool: - """bool: ``True`` if the action if the parameters passed in for this action are valid, - otherwise ``False`` """ - return self._valid - - def process(self, extract_media: ExtractMedia) -> None: - """ Override for specific post processing action - - Parameters - ---------- - extract_media: :class:`~plugins.extract.extract_media.ExtractMedia` - The :class:`~plugins.extract.extract_media.ExtractMedia` object to perform the - action on. - """ - raise NotImplementedError - - -class DebugLandmarks(PostProcessAction): - """ Draw debug landmarks on face output. Extract Only """ - def __init__(self, *args, **kwargs) -> None: - super().__init__(self, *args, **kwargs) - self._face_size = 0 - self._legacy_size = 0 - self._font = cv2.FONT_HERSHEY_SIMPLEX - self._font_scale = 0.0 - self._font_pad = 0 - - def _initialize_font(self, size: int) -> None: - """ Set the font scaling sizes on first call - - Parameters - ---------- - size: int - The pixel size of the saved aligned face - """ - self._font_scale = size / 512 - self._font_pad = size // 64 - - def _border_text(self, - image: np.ndarray, - text: str, - color: tuple[int, int, int], - position: tuple[int, int]) -> None: - """ Create text on an image with a black border - - Parameters - ---------- - image: :class:`numpy.ndarray` - The image to put bordered text on to - text: str - The text to place the image - color: tuple - The color of the text - position: tuple - The (x, y) co-ordinates to place the text - """ - thickness = 2 - for idx in range(2): - text_color = (0, 0, 0) if idx == 0 else color - cv2.putText(image, - text, - position, - self._font, - self._font_scale, - text_color, - thickness, - lineType=cv2.LINE_AA) - thickness //= 2 - - def _annotate_face_box(self, face: AlignedFace) -> None: - """ Annotate the face extract box and print the original size in pixels - - face: :class:`~lib.align.AlignedFace` - The object containing the aligned face to annotate - """ - assert face.face is not None - color = (0, 255, 0) - roi = face.get_cropped_roi(face.size, self._face_size, "face") - cv2.rectangle(face.face, tuple(roi[:2]), tuple(roi[2:]), color, 1) - - # Size in top right corner - roi_pnts = np.array([[roi[0], roi[1]], - [roi[0], roi[3]], - [roi[2], roi[3]], - [roi[2], roi[1]]]) - orig_roi = face.transform_points(roi_pnts, invert=True) - size = int(round(((orig_roi[1][0] - orig_roi[0][0]) ** 2 + - (orig_roi[1][1] - orig_roi[0][1]) ** 2) ** 0.5)) - text_img = face.face.copy() - text = f"{size}px" - text_size = cv2.getTextSize(text, self._font, self._font_scale, 1)[0] - pos_x = roi[2] - (text_size[0] + self._font_pad) - pos_y = roi[1] + text_size[1] + self._font_pad - - self._border_text(text_img, text, color, (pos_x, pos_y)) - cv2.addWeighted(text_img, 0.75, face.face, 0.25, 0, face.face) - - def _print_stats(self, face: AlignedFace) -> None: - """ Print various metrics on the output face images - - Parameters - ---------- - face: :class:`~lib.align.AlignedFace` - The loaded aligned face - """ - assert face.face is not None - text_image = face.face.copy() - texts = [f"pitch: {face.pose.pitch:.2f}", - f"yaw: {face.pose.yaw:.2f}", - f"roll: {face.pose.roll: .2f}", - f"distance: {face.average_distance:.2f}"] - colors = [(255, 0, 0), (0, 0, 255), (0, 255, 0), (255, 255, 255)] - text_sizes = [cv2.getTextSize(text, self._font, self._font_scale, 1)[0] for text in texts] - - final_y = face.size - text_sizes[-1][1] - pos_y = [(size[1] + self._font_pad) * (idx + 1) - for idx, size in enumerate(text_sizes)][:-1] + [final_y] - pos_x = self._font_pad - - for idx, text in enumerate(texts): - self._border_text(text_image, text, colors[idx], (pos_x, pos_y[idx])) - - # Apply text to face - cv2.addWeighted(text_image, 0.75, face.face, 0.25, 0, face.face) - - def process(self, extract_media: ExtractMedia) -> None: - """ Draw landmarks on a face. - - Parameters - ---------- - extract_media: :class:`~plugins.extract.extract_media.ExtractMedia` - The :class:`~plugins.extract.extract_media.ExtractMedia` object that contains the faces - to draw the landmarks on to - """ - frame = os.path.splitext(os.path.basename(extract_media.filename))[0] - for idx, face in enumerate(extract_media.detected_faces): - if not self._face_size: - self._face_size = get_centered_size(face.aligned.centering, - "face", - face.aligned.size) - logger.debug("set face size: %s", self._face_size) - if not self._legacy_size: - self._legacy_size = get_centered_size(face.aligned.centering, - "legacy", - face.aligned.size) - logger.debug("set legacy size: %s", self._legacy_size) - if not self._font_scale: - self._initialize_font(face.aligned.size) - - logger.trace("Drawing Landmarks. Frame: '%s'. Face: %s", # type:ignore[attr-defined] - frame, idx) - # Landmarks - assert face.aligned.face is not None - for (pos_x, pos_y) in face.aligned.landmarks.astype("int32"): - cv2.circle(face.aligned.face, (pos_x, pos_y), 1, (0, 255, 255), -1) - # Pose - center = (face.aligned.size // 2, face.aligned.size // 2) - points = (face.aligned.pose.xyz_2d * face.aligned.size).astype("int32") - cv2.line(face.aligned.face, center, tuple(points[1]), (0, 255, 0), 1) - cv2.line(face.aligned.face, center, tuple(points[0]), (255, 0, 0), 1) - cv2.line(face.aligned.face, center, tuple(points[2]), (0, 0, 255), 1) - # Face centering - self._annotate_face_box(face.aligned) - # Legacy centering - roi = face.aligned.get_cropped_roi(face.aligned.size, self._legacy_size, "legacy") - cv2.rectangle(face.aligned.face, tuple(roi[:2]), tuple(roi[2:]), (0, 0, 255), 1) - self._print_stats(face.aligned) - - -__all__ = get_module_objects(__name__) diff --git a/scripts/gui.py b/scripts/gui.py index efacc94068..e6e43c3d23 100644 --- a/scripts/gui.py +++ b/scripts/gui.py @@ -49,9 +49,9 @@ def __init__(self, debug, config_file): def initialize_globals(self): """ Initialize config and images global constants """ - cliopts = CliOptions() + cli_opts = CliOptions() statusbar = StatusBar(self) - config = initialize_config(self, cliopts, statusbar) + config = initialize_config(self, cli_opts, statusbar) initialize_images() return config @@ -91,21 +91,21 @@ def add_containers(self): """ Add the paned window containers that hold each main area of the gui """ logger.debug("Adding containers") - maincontainer = ttk.PanedWindow(self, - orient=tk.VERTICAL, - name="pw_main") - maincontainer.pack(fill=tk.BOTH, expand=True) - - topcontainer = ttk.PanedWindow(maincontainer, - orient=tk.HORIZONTAL, - name="pw_top") - maincontainer.add(topcontainer) - - bottomcontainer = ttk.Frame(maincontainer, name="frame_bottom") - maincontainer.add(bottomcontainer) - self.objects["container_main"] = maincontainer - self.objects["container_top"] = topcontainer - self.objects["container_bottom"] = bottomcontainer + main_container = ttk.PanedWindow(self, + orient=tk.VERTICAL, + name="pw_main") + main_container.pack(fill=tk.BOTH, expand=True) + + top_container = ttk.PanedWindow(main_container, + orient=tk.HORIZONTAL, + name="pw_top") + main_container.add(top_container) + + bottom_container = ttk.Frame(main_container, name="frame_bottom") + main_container.add(bottom_container) + self.objects["container_main"] = main_container + self.objects["container_top"] = top_container + self.objects["container_bottom"] = bottom_container logger.debug("Added containers") @@ -175,8 +175,8 @@ def _confirm_close_on_running_task(self): logger.debug("No tasks currently running") return True - confirmtxt = "Processes are still running.\n\nAre you sure you want to exit?" - if not messagebox.askokcancel("Close", confirmtxt, default="cancel", icon="warning"): + confirm_txt = "Processes are still running.\n\nAre you sure you want to exit?" + if not messagebox.askokcancel("Close", confirm_txt, default="cancel", icon="warning"): logger.debug("Close Cancelled") return False logger.debug("Close confirmed") @@ -186,7 +186,7 @@ def _confirm_close_on_running_task(self): class Gui(): """ The GUI process. """ def __init__(self, arguments): - self.root = FaceswapGui(arguments.debug, arguments.configfile) + self.root = FaceswapGui(arguments.debug, arguments.config_file) def process(self): """ Builds the GUI """ diff --git a/scripts/train.py b/scripts/train.py index bd4e6d6dcd..2164154e82 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -17,7 +17,7 @@ from lib.keypress import KBHit from lib.multithreading import MultiThread, FSThread from lib.training import Preview, PreviewBuffer, TriggerType -from lib.utils import (get_folder, get_image_paths, get_module_objects, handle_deprecated_cliopts, +from lib.utils import (get_folder, get_image_paths, get_module_objects, handle_deprecated_cli_opts, FaceswapError, IMAGE_EXTENSIONS) from plugins.plugin_loader import PluginLoader from plugins.train.training import Trainer @@ -48,7 +48,7 @@ class Train(): """ def __init__(self, arguments: argparse.Namespace) -> None: logger.debug("Initializing %s: (args: %s", self.__class__.__name__, arguments) - self._args = handle_deprecated_cliopts(arguments) + self._args = handle_deprecated_cli_opts(arguments) if self._args.summary: # If just outputting summary we don't need to initialize everything diff --git a/tests/lib/training/augmentation_test.py b/tests/lib/training/augmentation_test.py index 9687e6b601..45e238c70d 100644 --- a/tests/lib/training/augmentation_test.py +++ b/tests/lib/training/augmentation_test.py @@ -302,7 +302,8 @@ def test_image_augmentation_random_clahe(size: int, # pylint:disable=too-many-l grid_sizes = (randint_ret * (instance._constants.color.clahe_base_contrast // 2)) + instance._constants.color.clahe_base_contrast - clahe_calls = [mocker.call(clipLimit=2.0, tileGridSize=(grid, grid)) for grid in grid_sizes] + clahe_calls = [mocker.call(clipLimit=2.0, tileGridSize=(grid, grid)) + for grid in grid_sizes] # type:ignore clahe_mock = mocker.patch(f"{MODULE_PREFIX}.cv2.createCLAHE", return_value=cv2.createCLAHE(clipLimit=2.0, tileGridSize=(3, 3))) diff --git a/tests/lib/training/cache_test.py b/tests/lib/training/cache_test.py index afd3cef079..6dee7817f5 100644 --- a/tests/lib/training/cache_test.py +++ b/tests/lib/training/cache_test.py @@ -243,36 +243,23 @@ def test_MaskProcessing_get_face_mask(mask_type: str | None, assert instance._config.mask_type == mask_type # sanity check instance._check_mask_exists = mocker.MagicMock() # type:ignore[method-assign] - preprocess_return = "test_preprocess_return" - instance._preprocess = mocker.MagicMock( # type:ignore[method-assign] - return_value="test_preprocess_return") - crop_and_resize_return = mocker.MagicMock() - crop_and_resize_return.shape = (256, 256, 1) - instance._crop_and_resize = mocker.MagicMock( # type:ignore[method-assign] - return_value=crop_and_resize_return) + instance._preprocess = mocker.MagicMock() # type:ignore[method-assign] + instance._crop_and_resize = mocker.MagicMock() # type:ignore[method-assign] filename = "test_filename" - detected_face = "test_detected_face" + detected_face = mocker.MagicMock() + + instance._check_mask_exists.assert_not_called() # type:ignore[attr-defined] + instance._preprocess.assert_not_called() # type:ignore[attr-defined] + instance._crop_and_resize.assert_not_called() # type:ignore[attr-defined] + retval = instance._get_face_mask(filename, detected_face) # type:ignore[arg-type] if mask_type is None: # Mask disabled assert not instance._config.mask_enabled - retval1 = instance._get_face_mask(filename, detected_face) # type:ignore[arg-type] - assert retval1 is None - instance._check_mask_exists.assert_not_called() # type:ignore[attr-defined] - instance._preprocess.assert_not_called() # type:ignore[attr-defined] - instance._crop_and_resize.assert_not_called() # type:ignore[attr-defined] + assert retval is None else: # Mask enabled assert instance._config.mask_enabled - retval2 = instance._get_face_mask(filename, detected_face) # type:ignore[arg-type] - assert retval2 is crop_and_resize_return - instance._check_mask_exists.assert_called_once_with( # type:ignore[attr-defined] - filename, detected_face) - - instance._preprocess.assert_called_once_with( # type:ignore[attr-defined] - detected_face, instance._config.mask_type) - - instance._crop_and_resize.assert_called_once_with( # type:ignore[attr-defined] - detected_face, preprocess_return) + assert retval is detected_face.get_landmark_mask() @pytest.mark.parametrize(("eye_multiplier", "mouth_multiplier", "size", "enabled"), diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index d9d610f290..0953edb1ee 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -9,7 +9,7 @@ from multiprocessing import Process from lib.utils import (get_module_objects, FaceswapError, - handle_deprecated_cliopts, VIDEO_EXTENSIONS) + handle_deprecated_cli_opts, VIDEO_EXTENSIONS) from .media import AlignmentData from .jobs import Check, Export, Sort, Spatial # noqa pylint:disable=unused-import from .jobs_faces import FromFaces, RemoveFaces, Rename # noqa pylint:disable=unused-import @@ -43,7 +43,7 @@ def __init__(self, arguments: Namespace) -> None: "missing-frames", "no-faces"] - self._args = handle_deprecated_cliopts(arguments) + self._args = handle_deprecated_cli_opts(arguments) self._batch_mode = self._validate_batch_mode() self._locations = self._get_locations() @@ -293,7 +293,7 @@ def _find_alignments(self) -> str: fname = "alignments.fsa" if os.path.isdir(frames) and os.path.exists(os.path.join(frames, fname)): - return fname + return os.path.join(frames, fname) if os.path.isdir(frames) or os.path.splitext(frames)[-1] not in VIDEO_EXTENSIONS: logger.error("Can't find a valid alignments file in location: %s", frames) diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py index 510b8eba53..099b73af95 100644 --- a/tools/alignments/cli.py +++ b/tools/alignments/cli.py @@ -64,13 +64,12 @@ def get_argument_list() -> list[dict[str, T.Any]]: "subfolder will be created within the frames folder to hold the output.{0}" "\nL|'export': Export the contents of an alignments file to a json file. Can be " "used for editing alignment information in external tools and then re-importing " - "by using Faceswap's Extract 'Import' plugins. Note: masks and identity vectors " - "will not be included in the exported file, so will be re-generated when the json " - "file is imported back into Faceswap. All data is exported with the origin (0, 0) " - "at the top left of the canvas." - "\nL|'extract': Re-extract faces from the source frames/video based on " - "alignment data. This is a lot quicker than re-detecting faces. Can pass in " - "the '-een' (--extract-every-n) parameter to only extract every nth frame.{1}" + "by using Faceswap's Extract 'file' plugins for detector and aligner. Note: masks " + "and identity vectors will not be included in the exported file, so can be re-" + "generated when the json file is imported back into Faceswap. All data is " + "exported with the origin (0, 0) at the top left of the canvas." + "\nL|'extract': [DEPRECATED] Use 'python faceswap.py extract' instead and select " + "'file' as the aligner plugin. {1}" "\nL|'from-faces': Generate alignment file(s) from a folder of extracted " "faces. if the folder of faces comes from multiple sources, then multiple " "alignments files will be created. NB: for faces which have been extracted " @@ -175,9 +174,9 @@ def get_argument_list() -> list[dict[str, T.Any]]: "rounding": 1, "group": _("extract"), "help": _( - "[Extract only] Extract every 'nth' frame. This option will skip frames when " - "extracting faces. For example a value of 1 will extract faces from every frame, " - "a value of 10 will extract faces from every 10th frame.")}) + "[DEPRECTATED. Extract only] Extract every 'nth' frame. This option will skip " + "frames when extracting faces. For example a value of 1 will extract faces from " + "every frame, a value of 10 will extract faces from every 10th frame.")}) argument_list.append({ "opts": ("-z", "--size"), "type": int, @@ -186,7 +185,7 @@ def get_argument_list() -> list[dict[str, T.Any]]: "rounding": 64, "default": 512, "group": _("extract"), - "help": _("[Extract only] The output size of extracted faces.")}) + "help": _("[DEPRECTATED. Extract only] The output size of extracted faces.")}) argument_list.append({ "opts": ("-m", "--min-size"), "type": int, @@ -197,8 +196,8 @@ def get_argument_list() -> list[dict[str, T.Any]]: "dest": "min_size", "group": _("extract"), "help": _( - "[Extract only] Only extract faces that have been resized by this percent or " - "more to meet the specified extract size (`-sz`, `--size`). Useful for " + "[DEPRECTATED. Extract only] Only extract faces that have been resized by this " + "percent or more to meet the specified extract size (`-z`, `--size`). Useful for " "excluding low-res images from a training set. Set to 0 to extract all faces. " "Eg: For an extract size of 512px, A setting of 50 will only include faces " "that have been resized from 256px or above. Setting to 100 will only extract " diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index 414efb7f9b..9915d426fe 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -431,12 +431,13 @@ def _format_face(cls, face: AlignmentFileDict) -> dict[str, list[int] | list[lis The face formatted for exporting to a json file """ lms = face["landmarks_xy"] - assert isinstance(lms, np.ndarray) - retval = {"detected": [int(round(face["x"], 0)), - int(round(face["y"], 0)), - int(round(face["x"] + face["w"], 0)), - int(round(face["y"] + face["h"], 0))], - "landmarks_2d": lms.tolist()} + assert isinstance(lms, list) + box = [int(round(face["x"], 0)), + int(round(face["y"], 0)), + int(round(face["x"] + face["w"], 0)), + int(round(face["y"] + face["h"], 0))] + retval = T.cast(dict[str, list[int] | list[list[float]]], + {"detected": box, "landmarks_2d": lms}) return retval def process(self) -> None: @@ -528,7 +529,7 @@ def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: def process(self) -> None: """ Perform spatial filtering """ - logger.info("[SPATIO-TEMPORAL FILTERING]") # Tidy up cli output + logger.info("[SPATIAL-TEMPORAL FILTERING]") # Tidy up cli output logger.info("NB: The process only processes the alignments for the first " "face it finds for any given frame. For best results only run this when " "there is only a single face in the alignments file and all false positives " @@ -677,7 +678,7 @@ def _spatially_filter(self) -> np.ndarray: # Project onto shapes model and reconstruct landmarks_norm_table_rec = self._shapes_model.inverse_transform( self._shapes_model.transform(landmarks_norm_table)) - # Convert back to shapes (numKeypoint, num_dims, numFrames) + # Convert back to shapes (num key points, num_dims, numFrames) landmarks_norm_rec = np.reshape(landmarks_norm_table_rec.T, [68, 2, landmarks_norm.shape[2]]) # Transform back to image co-ordinates @@ -707,9 +708,10 @@ def _temporally_smooth(landmarks: np.ndarray) -> np.ndarray: temporal_filter = np.ones((1, 1, 2 * filter_half_length + 1)) temporal_filter = temporal_filter / temporal_filter.sum() - start_tileblock = np.tile(landmarks[:, :, 0][:, :, np.newaxis], [1, 1, filter_half_length]) - end_tileblock = np.tile(landmarks[:, :, -1][:, :, np.newaxis], [1, 1, filter_half_length]) - landmarks_padded = np.dstack((start_tileblock, landmarks, end_tileblock)) + start_tile_block = np.tile(landmarks[:, :, 0][:, :, np.newaxis], + [1, 1, filter_half_length]) + end_tile_block = np.tile(landmarks[:, :, -1][:, :, np.newaxis], [1, 1, filter_half_length]) + landmarks_padded = np.dstack((start_tile_block, landmarks, end_tile_block)) retval = signal.convolve(landmarks_padded, temporal_filter, mode='valid', method='fft') logger.debug("Temporally Smoothed: %s", retval) diff --git a/tools/alignments/jobs_faces.py b/tools/alignments/jobs_faces.py index 066558c39a..0ccf90c675 100644 --- a/tools/alignments/jobs_faces.py +++ b/tools/alignments/jobs_faces.py @@ -14,7 +14,7 @@ from lib.align import DetectedFace from lib.image import update_existing_metadata # TODO remove from lib.utils import get_module_objects -from scripts.fsmedia import Alignments +from scripts.fs_media import Alignments from .media import Faces @@ -106,7 +106,7 @@ def _extract_alignment(self, metadata: dict) -> tuple[str, int, AlignmentFileDic tuple The alignment's source frame name in position 0. The index of the face within the alignment file in position 1. The alignment data correctly formatted for writing to an - alignments file in positin 2 + alignments file in position 2 """ alignment = metadata["alignments"] alignment["landmarks_xy"] = np.array(alignment["landmarks_xy"], dtype="float32") @@ -136,7 +136,7 @@ def _sort_alignments(self, alignments: dict The unsorted alignments file(s) as generated from the face PNG headers, including the face index of the face within it's respective frame, the original face filename and - the orignal face header source information + the original face header source information Returns ------- @@ -149,12 +149,12 @@ def _sort_alignments(self, this_file: dict[str, AlignmentDict] = {} for frame in tqdm(sorted(frames), desc=f"Sorting {fname}", leave=False): this_file[frame] = {"video_meta": {}, "faces": []} - for real_idx, (f_id, almt, f_path, f_src) in enumerate(sorted(frames[frame], - key=itemgetter(0))): + for real_idx, (f_id, aln, f_path, f_src) in enumerate(sorted(frames[frame], + key=itemgetter(0))): if real_idx != f_id: full_path = os.path.join(self._faces_dir, f_path) - self._update_png_header(full_path, real_idx, almt, f_src) - this_file[frame]["faces"].append(almt) + self._update_png_header(full_path, real_idx, aln, f_src) + this_file[frame]["faces"].append(aln) aln_sorted[fname] = this_file return aln_sorted @@ -197,7 +197,7 @@ def _update_png_header(cls, def _save_alignments(self, all_alignments: dict[str, dict[str, AlignmentDict]], versions: dict[str, float]) -> None: - """ Save the newely generated alignments file(s). + """ Save the newly generated alignments file(s). If an alignments file already exists in the source faces folder, back it up rather than overwriting @@ -214,8 +214,7 @@ def _save_alignments(self, for fname, alignments in all_alignments.items(): version = versions[fname] alignments_path = os.path.join(self._faces_dir, fname) - dummy_args = Namespace(alignments_path=alignments_path) - aln = Alignments(dummy_args, is_extract=True) + aln = Alignments(alignments_path, "", is_extract=True) aln.update_from_dict(alignments) aln._io._version = version # pylint:disable=protected-access aln._io.update_legacy() # pylint:disable=protected-access @@ -267,8 +266,8 @@ def process(self) -> None: logger.info("%s faces renamed", rename_count) filelist = T.cast(list[tuple[str, "PNGHeaderDict"]], self._faces.file_list_sorted) - copyback = FaceToFile(self._alignments, [val[1] for val in filelist]) - if copyback(): + copy_back = FaceToFile(self._alignments, [val[1] for val in filelist]) + if copy_back(): self._alignments.save() def _rename_faces(self, filename_mappings: list[tuple[str, str]]) -> int: @@ -368,15 +367,15 @@ def _update_png_headers(self) -> None: Notes ----- - This could be quicker if parellizing in threads, however, Windows (at least) does not seem - to like this and has a tendency to throw permission errors, so this remains single threaded - for now. + This could be quicker if parallelizing in threads, however, Windows (at least) does not + seem to like this and has a tendency to throw permission errors, so this remains single + threaded for now. """ items = T.cast(dict[str, list[int]], self._items.items) - srcs = [(x[0], x[1]["source"]) - for x in T.cast(list[tuple[str, "PNGHeaderDict"]], self._items.file_list_sorted)] + src = [(x[0], x[1]["source"]) + for x in T.cast(list[tuple[str, "PNGHeaderDict"]], self._items.file_list_sorted)] to_update = [ # Items whose face index has changed - x for x in srcs + x for x in src if x[1]["face_index"] != items[x[1]["source_filename"]].index(x[1]["face_index"])] for item in tqdm(to_update, desc="Updating PNG Headers", leave=False): diff --git a/tools/alignments/jobs_frames.py b/tools/alignments/jobs_frames.py index fcedc13065..e29cbd6828 100644 --- a/tools/alignments/jobs_frames.py +++ b/tools/alignments/jobs_frames.py @@ -12,11 +12,10 @@ import numpy as np from tqdm import tqdm -from lib.align import DetectedFace, EXTRACT_RATIOS, LANDMARK_PARTS, LandmarkType -from lib.align.alignments import _VERSION, PNGHeaderDict -from lib.image import encode_image, generate_thumbnail, ImagesSaver -from lib.utils import get_module_objects -from plugins.extract import ExtractMedia, Extractor +from lib.align import DetectedFace, LANDMARK_PARTS, LandmarkType +from lib.align.alignments import PNGHeaderDict +from lib.image import encode_image, ImagesSaver +from lib.utils import get_module_objects, deprecation_warning from .media import ExtractedFaces, Frames if T.TYPE_CHECKING: @@ -177,10 +176,10 @@ class Extract(): """ def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) + deprecation_warning("'Extract' job", "Use 'python faceswap.py extract' instead, selecting " + "the 'file' aligner plugin.") self._arguments = arguments self._alignments = alignments - self._is_legacy = self._alignments.version == 1.0 # pylint:disable=protected-access - self._mask_pipeline: Extractor | None = None self._faces_dir = arguments.faces_dir self._min_size = self._get_min_size(arguments.size, arguments.min_size) @@ -238,8 +237,6 @@ def process(self) -> None: """ Run the re-extraction from Alignments file process""" logger.info("[EXTRACT FACES]") # Tidy up cli output self._check_folder() - if self._is_legacy: - self._legacy_check() self._saver = ImagesSaver(self._faces_dir, as_bytes=True) if self._min_size > 0: @@ -263,35 +260,6 @@ def _check_folder(self) -> None: sys.exit(0) logger.verbose("Creating output folder at '%s'", self._faces_dir) # type:ignore - def _legacy_check(self) -> None: - """ Check whether the alignments file was created with the legacy extraction method. - - If so, force user to re-extract all faces if any options have been specified, otherwise - raise the appropriate warnings and set the legacy options. - """ - if self._min_size > 0 or self._arguments.extract_every_n != 1: - logger.warning("This alignments file was generated with the legacy extraction method.") - logger.warning("You should run this extraction job, but with 'min_size' set to 0 and " - "'extract-every-n' set to 1 to update the alignments file.") - logger.warning("You can then re-run this extraction job with your chosen options.") - sys.exit(0) - - maskers = ["components", "extended"] - nn_masks = [mask for mask in list(self._alignments.mask_summary) if mask not in maskers] - logtype = logger.warning if nn_masks else logger.info - logtype("This alignments file was created with the legacy extraction method and will be " - "updated.") - logtype("Faces will be extracted using the new method and landmarks based masks will be " - "regenerated.") - if nn_masks: - logtype("However, the NN based masks '%s' will be cropped to the legacy extraction " - "method, so you may want to run the mask tool to regenerate these " - "masks.", "', '".join(nn_masks)) - self._mask_pipeline = Extractor(None, None, maskers, multiprocess=True) - self._mask_pipeline.launch() - # Update alignments versioning - self._alignments._io._version = _VERSION # pylint:disable=protected-access - def _export_faces(self) -> None: """ Export the faces to the output folder. """ extracted_faces = 0 @@ -306,8 +274,6 @@ def _export_faces(self) -> None: logger.verbose("Skipping '%s' - Alignments not found", frame_name) # type:ignore continue extracted_faces += self._output_faces(frame_name, image) - if self._is_legacy and extracted_faces != 0 and self._min_size == 0: - self._alignments.save() logger.info("%s face(s) extracted", extracted_faces) def _set_skip_list(self) -> list[int] | None: @@ -355,8 +321,6 @@ def _output_faces(self, filename: str, image: np.ndarray) -> int: assert self._saver is not None if not faces: return face_count - if self._is_legacy: - faces = self._process_legacy(filename, image, faces) for idx, face in enumerate(faces): output = f"{frame_name}_{idx}.png" @@ -370,9 +334,6 @@ def _output_faces(self, filename: str, image: np.ndarray) -> int: "source_frame_dims": T.cast(tuple[int, int], image.shape[:2])}} assert face.aligned.face is not None self._saver.save(output, encode_image(face.aligned.face, ".png", metadata=meta)) - if self._min_size == 0 and self._is_legacy: - face.thumbnail = generate_thumbnail(face.aligned.face, size=96, quality=60) - self._alignments.data[filename]["faces"][idx] = face.to_alignment() face_count += 1 self._saver.close() return face_count @@ -403,78 +364,5 @@ def _select_valid_faces(self, frame: str, image: np.ndarray) -> list[DetectedFac frame, len(faces), len(valid_faces)) return valid_faces - def _process_legacy(self, - filename: str, - image: np.ndarray, - detected_faces: list[DetectedFace]) -> list[DetectedFace]: - """ Process legacy face extractions to new extraction method. - - Updates stored masks to new extract size - - Parameters - ---------- - filename: str - The current frame filename - image: :class:`numpy.ndarray` - The current image the contains the faces - detected_faces: list - list of :class:`lib.align.DetectedFace` objects for the current frame - - Returns - ------- - list - The updated list of :class:`lib.align.DetectedFace` objects for the current frame - """ - # Update landmarks based masks for face centering - assert self._mask_pipeline is not None - mask_item = ExtractMedia(filename, image, detected_faces=detected_faces) - self._mask_pipeline.input_queue.put(mask_item) - faces = next(self._mask_pipeline.detected_faces()).detected_faces - - # Pad and shift Neural Network based masks to face centering - for face in faces: - self._pad_legacy_masks(face) - return faces - - @classmethod - def _pad_legacy_masks(cls, detected_face: DetectedFace) -> None: - """ Recenter legacy Neural Network based masks from legacy centering to face centering - and pad accordingly. - - Update the masks back into the detected face objects. - - Parameters - ---------- - detected_face: :class:`lib.align.DetectedFace` - The detected face to update the masks for - """ - offset = detected_face.aligned.pose.offset["face"] - for name, mask in detected_face.mask.items(): # Re-center mask and pad to face size - if name in ("components", "extended"): - continue - old_mask = mask.mask.astype("float32") / 255.0 - size = old_mask.shape[0] - new_size = int(size + (size * EXTRACT_RATIOS["face"]) / 2) - - shift = np.rint(offset * (size - (size * EXTRACT_RATIOS["face"]))).astype("int32") - pos = np.array([(new_size // 2 - size // 2) - shift[1], - (new_size // 2) + (size // 2) - shift[1], - (new_size // 2 - size // 2) - shift[0], - (new_size // 2) + (size // 2) - shift[0]]) - bounds = np.array([max(0, pos[0]), min(new_size, pos[1]), - max(0, pos[2]), min(new_size, pos[3])]) - - slice_in = [slice(0 - (pos[0] - bounds[0]), size - (pos[1] - bounds[1])), - slice(0 - (pos[2] - bounds[2]), size - (pos[3] - bounds[3]))] - slice_out = [slice(bounds[0], bounds[1]), slice(bounds[2], bounds[3])] - - new_mask = np.zeros((new_size, new_size, 1), dtype="float32") - new_mask[slice_out[0], slice_out[1], :] = old_mask[slice_in[0], slice_in[1], :] - - mask.replace_mask(new_mask) - # Get the affine matrix from recently generated components mask - # pylint:disable=protected-access - mask._affine_matrix = detected_face.mask["components"].affine_matrix - __all__ = get_module_objects(__name__) diff --git a/tools/effmpeg/effmpeg.py b/tools/effmpeg/effmpeg.py index 28cce637c0..1e1ca81419 100644 --- a/tools/effmpeg/effmpeg.py +++ b/tools/effmpeg/effmpeg.py @@ -17,7 +17,7 @@ from ffmpy import FFmpeg, FFRuntimeError # faceswap imports -from lib.utils import (get_module_objects, handle_deprecated_cliopts, IMAGE_EXTENSIONS, +from lib.utils import (get_module_objects, handle_deprecated_cli_opts, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS) logger = logging.getLogger(__name__) @@ -148,7 +148,7 @@ class Effmpeg(): def __init__(self, arguments): logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - self.args = handle_deprecated_cliopts(arguments) + self.args = handle_deprecated_cli_opts(arguments) self.exe = im_ffm.get_ffmpeg_exe() self.input = DataItem() self.output = DataItem() diff --git a/tools/manual/cli.py b/tools/manual/cli.py index bb34c007ba..45a591af3a 100644 --- a/tools/manual/cli.py +++ b/tools/manual/cli.py @@ -48,7 +48,7 @@ def get_argument_list(): argument_list.append({ "opts": ("-t", "--thumb-regen"), "action": "store_true", - "dest": "thumb_regen", + "dest": "thumb_regenerate", "default": False, "group": _("options"), "help": _( diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index 50129b4f83..ad5135ea85 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -1,11 +1,10 @@ #!/usr/bin/env python3 -""" Alignments handling for Faceswap's Manual Adjustments tool. Handles the conversion of +"""Alignments handling for Faceswap's Manual Adjustments tool. Handles the conversion of alignments data to :class:`~lib.align.DetectedFace` objects, and the update of these faces when edits are made in the GUI. """ from __future__ import annotations import logging import os -import sys import tkinter as tk import typing as T from copy import deepcopy @@ -29,20 +28,20 @@ class DetectedFaces(): - """ Handles the manipulation of :class:`~lib.align.DetectedFace` objects stored + """Handles the manipulation of :class:`~lib.align.DetectedFace` objects stored in the alignments file. Acts as a parent class for the IO operations (saving and loading from an alignments file), the face update operations (when changes are made to alignments in the GUI) and the face filters (when a user changes the filter navigation mode.) Parameters ---------- - tk_globals: :class:`~tools.manual.manual.TkGlobals` + tk_globals The tkinter variables that apply to the whole of the GUI - alignments_path: str + alignments_path The full path to the alignments file - input_location: str + input_location The location of the input folder of frames or video file - extractor: :class:`~tools.manual.manual.Aligner` + extractor The pipeline for passing faces through the aligner and retrieving results """ def __init__(self, @@ -71,69 +70,65 @@ def __init__(self, # <<<< PUBLIC PROPERTIES >>>> # @property def extractor(self) -> manual.Aligner: - """ :class:`~tools.manual.manual.Aligner`: The pipeline for passing faces through the - aligner and retrieving results. """ + """The pipeline for passing faces through the aligner and retrieving results.""" return self._extractor @property def filter(self) -> Filter: - """ :class:`Filter`: Handles returning of faces and stats based on the current user set - navigation mode filter. """ + """Handles returning of faces and stats based on the current user set navigation mode + filter.""" return self._filter @property def update(self) -> FaceUpdate: - """ :class:`FaceUpdate`: Handles the adding, removing and updating of - :class:`~lib.align.DetectedFace` stored within the alignments file. """ + """Handles the adding, removing and updating of :class:`~lib.align.DetectedFace` stored + within the alignments file.""" return self._update # << TKINTER VARIABLES >> # @property def tk_unsaved(self) -> tk.BooleanVar: - """ :class:`tkinter.BooleanVar`: The variable indicating whether the alignments have been - updated since the last save. """ + """The variable indicating whether the alignments have been updated since the last save.""" return self._tk_vars["unsaved"] @property def tk_edited(self) -> tk.BooleanVar: - """ :class:`tkinter.BooleanVar`: The variable indicating whether an edit has occurred - meaning a GUI redraw needs to be triggered. """ + """The variable indicating whether an edit has occurred meaning a GUI redraw needs to be + triggered.""" return self._tk_vars["edited"] @property def tk_face_count_changed(self) -> tk.BooleanVar: - """ :class:`tkinter.BooleanVar`: The variable indicating whether a face has been added or - removed meaning the :class:`FaceViewer` grid redraw needs to be triggered. """ + """The variable indicating whether a face has been added or removed meaning the + :class:`FaceViewer` grid redraw needs to be triggered.""" return self._tk_vars["face_count_changed"] # << STATISTICS >> # @property def frame_list(self) -> list[str]: - """ list[str]: The list of all frame names that appear in the alignments file """ + """The list of all frame names that appear in the alignments file """ return list(self._alignments.data) @property def available_masks(self) -> dict[str, int]: - """ dict[str, int]: The mask type names stored in the alignments; type as key with the - number of faces which possess the mask type as value. """ + """The mask type names stored in the alignments; type as key with the number of faces which + possess the mask type as value.""" return self._alignments.mask_summary @property def current_faces(self) -> list[list[DetectedFace]]: - """ list[list[:class:`~lib.align.DetectedFace`]]: The most up to date full list of detected - face objects. """ + """The most up to date full list of detected face objects.""" return self._frame_faces @property def video_meta_data(self) -> dict[str, list[int] | list[float] | None]: - """ dict[str, list[int] | list[float] | None]: The frame meta data stored in the alignments - file. If data does not exist in the alignments file then ``None`` is returned for each - Key """ + """The frame meta data stored in the alignments file. If data does not exist in the + alignments file then ``None`` is returned for each Key""" return self._alignments.video_meta_data @property def face_count_per_index(self) -> list[int]: - """ list[int]: Count of faces for each frame. List is in frame index order. + """Count of faces for each frame. List is in frame index order. The list needs to be calculated on the fly as the number of faces in a frame can change based on user actions. """ @@ -141,54 +136,53 @@ def face_count_per_index(self) -> list[int]: # <<<< PUBLIC METHODS >>>> # def is_frame_updated(self, frame_index: int) -> bool: - """ Check whether the given frame index has been updated + """Check whether the given frame index has been updated Parameters ---------- - frame_index: int + frame_index The frame index to check Returns ------- - bool: - ``True`` if the given frame index has updated faces within it otherwise ``False`` + ``True`` if the given frame index has updated faces within it otherwise ``False`` """ return frame_index in self._updated_frame_indices def load_faces(self) -> None: - """ Load the faces as :class:`~lib.align.DetectedFace` objects from the alignments - file. """ + """Load the faces as :class:`~lib.align.DetectedFace` objects from the alignments + file.""" self._io.load() def save(self) -> None: - """ Save the alignments file with the latest edits. """ + """Save the alignments file with the latest edits.""" self._io.save() def revert_to_saved(self, frame_index): - """ Revert the frame's alignments to their saved version for the given frame index. + """Revert the frame's alignments to their saved version for the given frame index. Parameters ---------- - frame_index: int + frame_index The frame that should have their faces reverted to their saved version """ self._io.revert_to_saved(frame_index) def extract(self) -> None: - """ Extract the faces in the current video to a user supplied folder. """ + """Extract the faces in the current video to a user supplied folder.""" self._io.extract() def save_video_meta_data(self, pts_time: list[float], keyframes: list[int]) -> None: - """ Save video meta data to the alignments file. This is executed if the video meta data + """Save video meta data to the alignments file. This is executed if the video meta data does not already exist in the alignments file, so the video does not need to be scanned on every use of the Manual Tool. Parameters ---------- - pts_time: list[float] + pts_time A list of presentation timestamps in frame index order for every frame in the input video - keyframes: list[int] + keyframes A list of frame indices corresponding to the key frames in the input video. """ if self._globals.is_video: @@ -199,7 +193,7 @@ def save_video_meta_data(self, pts_time: list[float], keyframes: list[int]) -> N @staticmethod def _set_tk_vars() -> dict[T.Literal["unsaved", "edited", "face_count_changed"], tk.BooleanVar]: - """ Set the required tkinter variables. + """Set the required tkinter variables. The alignments specific `unsaved` and `edited` are set here. The global variables are added into the dictionary with `None` as value, so the @@ -207,8 +201,7 @@ def _set_tk_vars() -> dict[T.Literal["unsaved", "edited", "face_count_changed"], Returns ------- - dict - The internal variable name as key with the tkinter variable as value + The internal variable name as key with the tkinter variable as value """ retval = {} for name in T.get_args(T.Literal["unsaved", "edited", "face_count_changed"]): @@ -219,20 +212,19 @@ def _set_tk_vars() -> dict[T.Literal["unsaved", "edited", "face_count_changed"], return retval def _get_alignments(self, alignments_path: str, input_location: str) -> Alignments: - """ Get the :class:`~lib.align.Alignments` object for the given location. + """Get the :class:`~lib.align.Alignments` object for the given location. Parameters ---------- - alignments_path: str + alignments_path Full path to the alignments file. If empty string is passed then location is calculated from the source folder - input_location: str + input_location The location of the input folder of frames or video file Returns ------- - :class:`~lib.align.Alignments` - The alignments object for the given input location + The alignments object for the given input location """ logger.debug("alignments_path: %s, input_location: %s", alignments_path, input_location) if alignments_path: @@ -245,25 +237,20 @@ def _get_alignments(self, alignments_path: str, input_location: str) -> Alignmen else: folder = input_location retval = Alignments(folder, filename) - if retval.version == 1.0: - logger.error("The Manual Tool is not compatible with legacy Alignments files.") - logger.info("You can update legacy Alignments files by using the Extract job in the " - "Alignments tool to re-extract the faces in full-head format.") - sys.exit(0) logger.debug("folder: %s, filename: %s, alignments: %s", folder, filename, retval) return retval class _DiskIO(): - """ Handles the loading of :class:`~lib.align.DetectedFaces` from the alignments file + """Handles the loading of :class:`~lib.align.DetectedFaces` from the alignments file into :class:`DetectedFaces` and the saving of this data (in the opposite direction) to an alignments file. Parameters ---------- - detected_faces: :class:`DetectedFaces` + detected_faces The parent :class:`DetectedFaces` object - input_location: str + input_location The location of the input folder of frames or video file """ def __init__(self, detected_faces: DetectedFaces, input_location: str) -> None: @@ -283,8 +270,8 @@ def __init__(self, detected_faces: DetectedFaces, input_location: str) -> None: logger.debug("Initialized %s", self.__class__.__name__) def load(self) -> None: - """ Load the faces from the alignments file, convert to - :class:`~lib.align.DetectedFace`. objects and add to :attr:`_frame_faces`. """ + """Load the faces from the alignments file, convert to + :class:`~lib.align.DetectedFace`. objects and add to :attr:`_frame_faces`.""" for key in sorted(self._alignments.data): this_frame_faces: list[DetectedFace] = [] for item in self._alignments.data[key]["faces"]: @@ -297,8 +284,8 @@ def load(self) -> None: self._sorted_frame_names = sorted(self._alignments.data) def save(self) -> None: - """ Convert updated :class:`~lib.align.DetectedFace` objects to alignments format - and save the alignments file. """ + """Convert updated :class:`~lib.align.DetectedFace` objects to alignments format + and save the alignments file.""" if not self._tk_unsaved.get(): logger.debug("Alignments not updated. Returning") return @@ -317,11 +304,11 @@ def save(self) -> None: self._tk_unsaved.set(False) def revert_to_saved(self, frame_index: int) -> None: - """ Revert the frame's alignments to their saved version for the given frame index. + """Revert the frame's alignments to their saved version for the given frame index. Parameters ---------- - frame_index: int + frame_index The frame that should have their faces reverted to their saved version """ if frame_index not in self._updated_frame_indices: @@ -353,21 +340,20 @@ def revert_to_saved(self, frame_index: int) -> None: def _add_remove_faces(cls, alignments: list[AlignmentFileDict], faces: list[DetectedFace]) -> bool: - """ On a revert, ensure that the alignments and detected face object counts for each frame + """On a revert, ensure that the alignments and detected face object counts for each frame are in sync. Parameters ---------- - alignments: list[:class:`~lib.align.alignments.AlignmentFileDict`] + alignments Alignments stored for a frame - faces: list[:class:`~lib.align.DetectedFace`] + faces List of detected faces for a frame Returns ------- - bool - ``True`` if a face was added or removed otherwise ``False`` + ``True`` if a face was added or removed otherwise ``False`` """ num_alignments = len(alignments) num_faces = len(faces) @@ -382,7 +368,7 @@ def _add_remove_faces(cls, return retval def extract(self) -> None: - """ Extract the current faces to a folder. + """Extract the current faces to a folder. To stop the GUI becoming completely unresponsive (particularly in Windows) the extract is done in a background thread, with the process count passed back in a queue to the main @@ -405,17 +391,17 @@ def _monitor_extract(self, thread: MultiThread, queue: Queue, progress_bar: PopupProgress) -> None: - """ Monitor the extraction thread, and update the progress bar. + """Monitor the extraction thread, and update the progress bar. On completion, save alignments and clear progress bar. Parameters ---------- - thread: :class:`~lib.multithreading.MultiThread` + thread The thread that is performing the extraction task - queue: :class:`queue.Queue` + queue The queue that the worker thread is putting it's incremental counts to - progress_bar: :class:`~lib.gui.custom_widget.PopupProgress` + progress_bar The popped up progress bar """ thread.check_and_raise_error() @@ -432,13 +418,13 @@ def _monitor_extract(self, progress_bar.after(100, self._monitor_extract, thread, queue, progress_bar) def _background_extract(self, output_folder: str, progress_queue: Queue) -> None: - """ Perform the background extraction in a thread so GUI doesn't become unresponsive. + """Perform the background extraction in a thread so GUI doesn't become unresponsive. Parameters ---------- - output_folder: str + output_folder The location to save the output faces to - progress_queue: :class:`queue.Queue` + progress_queue The queue to place incremental counts to for updating the GUI's progress bar """ saver = ImagesSaver(get_folder(output_folder), as_bytes=True) @@ -470,12 +456,12 @@ def _background_extract(self, output_folder: str, progress_queue: Queue) -> None class Filter(): - """ Returns stats and frames for filtered frames based on the user selected navigation mode + """Returns stats and frames for filtered frames based on the user selected navigation mode filter. Parameters ---------- - detected_faces: :class:`DetectedFaces` + detected_faces The parent :class:`DetectedFaces` object """ def __init__(self, detected_faces: DetectedFaces) -> None: @@ -487,8 +473,7 @@ def __init__(self, detected_faces: DetectedFaces) -> None: @property def frame_meets_criteria(self) -> bool: - """ bool: ``True`` if the current frame meets the selected filter criteria otherwise - ``False`` """ + """``True`` if the current frame meets the selected filter criteria otherwise ``False``.""" filter_mode = self._globals.var_filter_mode.get() frame_faces = self._detected_faces.current_faces[self._globals.frame_index] distance = self._filter_distance @@ -507,7 +492,7 @@ def frame_meets_criteria(self) -> bool: @property def _filter_distance(self) -> float: - """ float: The currently selected distance when Misaligned Faces filter is selected. """ + """The currently selected distance when Misaligned Faces filter is selected.""" try: retval = self._globals.var_filter_distance.get() except tk.TclError: @@ -517,8 +502,8 @@ def _filter_distance(self) -> float: @property def count(self) -> int: - """ int: The number of frames that meet the filter criteria returned by - :attr:`~tools.manual.manual.TkGlobals.var_filter_mode.get()`. """ + """The number of frames that meet the filter criteria returned by + :attr:`~tools.manual.manual.TkGlobals.var_filter_mode.get()`.""" face_count_per_index = self._detected_faces.face_count_per_index if self._globals.var_filter_mode.get() == "No Faces": retval = sum(1 for fcount in face_count_per_index if fcount == 0) @@ -538,8 +523,8 @@ def count(self) -> int: @property def raw_indices(self) -> dict[T.Literal["frame", "face"], list[int]]: - """ dict[str, int]: The frame and face indices that meet the current filter criteria for - each displayed face. """ + """The frame and face indices that meet the current filter criteria for each displayed + face.""" frame_indices: list[int] = [] face_indices: list[int] = [] face_counts = self._detected_faces.face_count_per_index # Copy to avoid recalculations @@ -557,7 +542,7 @@ def raw_indices(self) -> dict[T.Literal["frame", "face"], list[int]]: @property def frames_list(self) -> list[int]: - """ list[int]: The list of frame indices that meet the filter criteria returned by + """The list of frame indices that meet the filter criteria returned by :attr:`~tools.manual.manual.TkGlobals.var_filter_mode.get()`. """ face_count_per_index = self._detected_faces.face_count_per_index if self._globals.var_filter_mode.get() == "No Faces": @@ -578,12 +563,12 @@ def frames_list(self) -> list[int]: class FaceUpdate(): - """ Perform updates on :class:`~lib.align.DetectedFace` objects stored in + """Perform updates on :class:`~lib.align.DetectedFace` objects stored in :class:`DetectedFaces` when changes are made within the GUI. Parameters ---------- - detected_faces: :class:`DetectedFaces` + detected_faces The parent :class:`DetectedFaces` object """ def __init__(self, detected_faces: DetectedFaces) -> None: @@ -599,8 +584,8 @@ def __init__(self, detected_faces: DetectedFaces) -> None: @property def _tk_edited(self) -> tk.BooleanVar: - """ :class:`tkinter.BooleanVar`: The variable indicating whether an edit has occurred - meaning a GUI redraw needs to be triggered. + """The variable indicating whether an edit has occurred meaning a GUI redraw needs to be + triggered. Notes ----- @@ -610,8 +595,8 @@ def _tk_edited(self) -> tk.BooleanVar: @property def _tk_face_count_changed(self) -> tk.BooleanVar: - """ :class:`tkinter.BooleanVar`: The variable indicating whether an edit has occurred - meaning a GUI redraw needs to be triggered. + """The variable indicating whether an edit has occurred meaning a GUI redraw needs to be + triggered. Notes ----- @@ -620,19 +605,18 @@ def _tk_face_count_changed(self) -> tk.BooleanVar: return self._detected_faces.tk_face_count_changed def _faces_at_frame_index(self, frame_index: int) -> list[DetectedFace]: - """ Checks whether the frame has already been added to :attr:`_updated_frame_indices` and + """Checks whether the frame has already been added to :attr:`_updated_frame_indices` and adds it. Triggers the unsaved variable if this is the first edited frame. Returns the detected face objects for the given frame. Parameters ---------- - frame_index: int + frame_index The frame index to check whether there are updated alignments available Returns ------- - list - The :class:`~lib.align.DetectedFace` objects for the requested frame + The :class:`~lib.align.DetectedFace` objects for the requested frame """ if not self._updated_frame_indices and not self._tk_unsaved.get(): self._tk_unsaved.set(True) @@ -641,20 +625,20 @@ def _faces_at_frame_index(self, frame_index: int) -> list[DetectedFace]: return retval def add(self, frame_index: int, pnt_x: int, width: int, pnt_y: int, height: int) -> None: - """ Add a :class:`~lib.align.DetectedFace` object to the current frame with the + """Add a :class:`~lib.align.DetectedFace` object to the current frame with the given dimensions. Parameters ---------- - frame_index: int + frame_index The frame that the face is being set for - pnt_x: int + pnt_x The left point of the bounding box - width: int + width The width of the bounding box - pnt_y: int + pnt_y The top point of the bounding box - height: int + height The height of the bounding box """ face = DetectedFace() @@ -667,14 +651,14 @@ def add(self, frame_index: int, pnt_x: int, width: int, pnt_y: int, height: int) self._tk_face_count_changed.set(True) def delete(self, frame_index: int, face_index: int) -> None: - """ Delete the :class:`~lib.align.DetectedFace` object for the given frame and face + """Delete the :class:`~lib.align.DetectedFace` object for the given frame and face indices. Parameters ---------- - frame_index: int + frame_index The frame that the face is being set for - face_index: int + face_index The face index within the frame """ logger.debug("Deleting face at frame index: %s face index: %s", frame_index, face_index) @@ -690,27 +674,27 @@ def bounding_box(self, width: int, pnt_y: int, height: int, - aligner: manual.TypeManualExtractor = "FAN") -> None: - """ Update the bounding box for the :class:`~lib.align.DetectedFace` object at the + aligner: T.Literal["FAN", "HRNet", "cv2-dnn"] = "HRNet") -> None: + """Update the bounding box for the :class:`~lib.align.DetectedFace` object at the given frame and face indices, with the given dimensions and update the 68 point landmarks from the :class:`~tools.manual.manual.Aligner` for the updated bounding box. Parameters ---------- - frame_index: int + frame_index The frame that the face is being set for - face_index: int + face_index The face index within the frame - pnt_x: int + pnt_x The left point of the bounding box - width: int + width The width of the bounding box - pnt_y: int + pnt_y The top point of the bounding box - height: int + height The height of the bounding box - aligner: ["cv2-dnn", "FAN"], optional - The aligner to use to generate the landmarks. Default: "FAN" + aligner + The aligner to use to generate the landmarks. Default: "HRNet" """ logger.trace("frame_index: %s, face_index %s, pnt_x %s, " # type:ignore[attr-defined] "width %s, pnt_y %s, height %s, aligner: %s", @@ -729,23 +713,23 @@ def landmark(self, shift_x: int, shift_y: int, is_zoomed: bool) -> None: - """ Shift a single landmark point for the :class:`~lib.align.DetectedFace` object + """Shift a single landmark point for the :class:`~lib.align.DetectedFace` object at the given frame and face indices by the given x and y values. Parameters ---------- - frame_index: int + frame_index The frame that the face is being set for - face_index: int + face_index The face index within the frame - landmark_index: int or list + landmark_index The landmark index to shift. If a list is provided, this should be a list of landmark indices to be shifted - shift_x: int + shift_x The amount to shift the landmark by along the x axis - shift_y: int + shift_y The amount to shift the landmark by along the y axis - is_zoomed: bool + is_zoomed ``True`` if landmarks are being adjusted on a zoomed image otherwise ``False`` """ face = self._faces_at_frame_index(frame_index)[face_index] @@ -771,19 +755,19 @@ def landmark(self, self._globals.var_full_update.set(True) def landmarks(self, frame_index: int, face_index: int, shift_x: int, shift_y: int) -> None: - """ Shift all of the landmarks and bounding box for the + """Shift all of the landmarks and bounding box for the :class:`~lib.align.DetectedFace` object at the given frame and face indices by the given x and y values and update the masks. Parameters ---------- - frame_index: int + frame_index The frame that the face is being set for - face_index: int + face_index The face index within the frame - shift_x: int + shift_x The amount to shift the landmarks by along the x axis - shift_y: int + shift_y The amount to shift the landmarks by along the y axis Notes @@ -803,19 +787,19 @@ def landmarks_rotate(self, face_index: int, angle: float, center: np.ndarray) -> None: - """ Rotate the landmarks on an Extract Box rotate for the + """Rotate the landmarks on an Extract Box rotate for the :class:`~lib.align.DetectedFace` object at the given frame and face indices for the given angle from the given center point. Parameters ---------- - frame_index: int + frame_index The frame that the face is being set for - face_index: int + face_index The face index within the frame - angle: float + angle The angle, in radians to rotate the points by - center: :class:`numpy.ndarray` + center The center point of the Landmark's Extract Box """ face = self._faces_at_frame_index(frame_index)[face_index] @@ -829,19 +813,19 @@ def landmarks_scale(self, face_index: int, scale: np.ndarray, center: np.ndarray) -> None: - """ Scale the landmarks on an Extract Box resize for the + """Scale the landmarks on an Extract Box resize for the :class:`~lib.align.DetectedFace` object at the given frame and face indices from the given center point. Parameters ---------- - frame_index: int + frame_index The frame that the face is being set for - face_index: int + face_index The face index within the frame - scale: float + scale The amount to scale the landmarks by - center: :class:`numpy.ndarray` + center The center point of the Landmark's Extract Box """ face = self._faces_at_frame_index(frame_index)[face_index] @@ -849,18 +833,18 @@ def landmarks_scale(self, self._globals.var_full_update.set(True) def mask(self, frame_index: int, face_index: int, mask: np.ndarray, mask_type: str) -> None: - """ Update the mask on an edit for the :class:`~lib.align.DetectedFace` object at + """Update the mask on an edit for the :class:`~lib.align.DetectedFace` object at the given frame and face indices, for the given mask and mask type. Parameters ---------- - frame_index: int + frame_index The frame that the face is being set for - face_index: int + face_index The face index within the frame - mask: class:`numpy.ndarray`: + mask The mask to replace - mask_type: str + mask_type The name of the mask that is to be replaced """ face = self._faces_at_frame_index(frame_index)[face_index] @@ -869,14 +853,14 @@ def mask(self, frame_index: int, face_index: int, mask: np.ndarray, mask_type: s self._globals.var_full_update.set(True) def copy(self, frame_index: int, direction: T.Literal["prev", "next"]) -> None: - """ Copy the alignments from the previous or next frame that has alignments + """Copy the alignments from the previous or next frame that has alignments to the current frame. Parameters ---------- - frame_index: int + frame_index The frame that the needs to have alignments copied to it - direction: ["prev", "next"] + direction Whether to copy alignments from the previous frame with alignments, or the next frame with alignments """ @@ -910,20 +894,22 @@ def copy(self, frame_index: int, direction: T.Literal["prev", "next"]) -> None: self._globals.var_full_update.set(True) def post_edit_trigger(self, frame_index: int, face_index: int) -> None: - """ Update the jpg thumbnail, the viewport thumbnail, the landmark masks and the aligned + """Update the jpg thumbnail, the viewport thumbnail, the landmark masks and the aligned face on a face edit. Parameters ---------- - frame_index: int + frame_index The frame that the face is being set for - face_index: int + face_index The face index within the frame """ face = self._frame_faces[frame_index][face_index] face.load_aligned(None, force=True) # Update average distance - face.mask = self._extractor.get_masks(frame_index, face_index) - face.clear_all_identities() + if face.mask: + face.mask = {} + if face.identity: + face.clear_all_identities() aligned = AlignedFace(face.landmarks_xy, image=self._globals.current_frame.image, diff --git a/tools/manual/faceviewer/__init__.py b/tools/manual/face_viewer/__init__.py similarity index 100% rename from tools/manual/faceviewer/__init__.py rename to tools/manual/face_viewer/__init__.py diff --git a/tools/manual/faceviewer/frame.py b/tools/manual/face_viewer/frame.py similarity index 99% rename from tools/manual/faceviewer/frame.py rename to tools/manual/face_viewer/frame.py index 30ebbdf7ef..02fed58044 100644 --- a/tools/manual/faceviewer/frame.py +++ b/tools/manual/face_viewer/frame.py @@ -23,7 +23,7 @@ if T.TYPE_CHECKING: from tools.manual.detected_faces import DetectedFaces - from tools.manual.frameviewer.frame import DisplayFrame + from tools.manual.frame_viewer.frame import DisplayFrame from tools.manual.manual import TkGlobals logger = logging.getLogger(__name__) diff --git a/tools/manual/faceviewer/interact.py b/tools/manual/face_viewer/interact.py similarity index 100% rename from tools/manual/faceviewer/interact.py rename to tools/manual/face_viewer/interact.py diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/face_viewer/viewport.py similarity index 100% rename from tools/manual/faceviewer/viewport.py rename to tools/manual/face_viewer/viewport.py diff --git a/tools/manual/frameviewer/__init__.py b/tools/manual/frame_viewer/__init__.py similarity index 100% rename from tools/manual/frameviewer/__init__.py rename to tools/manual/frame_viewer/__init__.py diff --git a/tools/manual/frameviewer/control.py b/tools/manual/frame_viewer/control.py similarity index 100% rename from tools/manual/frameviewer/control.py rename to tools/manual/frame_viewer/control.py diff --git a/tools/manual/frameviewer/editor/__init__.py b/tools/manual/frame_viewer/editor/__init__.py similarity index 100% rename from tools/manual/frameviewer/editor/__init__.py rename to tools/manual/frame_viewer/editor/__init__.py diff --git a/tools/manual/frameviewer/editor/_base.py b/tools/manual/frame_viewer/editor/_base.py similarity index 100% rename from tools/manual/frameviewer/editor/_base.py rename to tools/manual/frame_viewer/editor/_base.py diff --git a/tools/manual/frameviewer/editor/bounding_box.py b/tools/manual/frame_viewer/editor/bounding_box.py similarity index 98% rename from tools/manual/frameviewer/editor/bounding_box.py rename to tools/manual/frame_viewer/editor/bounding_box.py index d8e2af081a..f5f91d941b 100644 --- a/tools/manual/frameviewer/editor/bounding_box.py +++ b/tools/manual/frame_viewer/editor/bounding_box.py @@ -65,12 +65,12 @@ def _add_controls(self): "Aligner", str, group="Aligner", - choices=["cv2-dnn", "FAN"], - default="FAN", + choices=["cv2-dnn", "FAN", "HRNet"], + default="HRNet", is_radio=True, - helptext=_("Aligner to use. FAN will obtain better alignments, but cv2-dnn can be " - "useful if FAN cannot get decent alignments and you want to set a base to " - "edit from.")) + helptext=_("Aligner to use. HRNet and FAN will obtain better alignments, but cv2-dnn " + "can be useful if these cannot get decent alignments and you want to set a " + "base to edit from.")) self._tk_aligner = align_ctl.tk_var self._add_control(align_ctl) diff --git a/tools/manual/frameviewer/editor/extract_box.py b/tools/manual/frame_viewer/editor/extract_box.py similarity index 100% rename from tools/manual/frameviewer/editor/extract_box.py rename to tools/manual/frame_viewer/editor/extract_box.py diff --git a/tools/manual/frameviewer/editor/landmarks.py b/tools/manual/frame_viewer/editor/landmarks.py similarity index 100% rename from tools/manual/frameviewer/editor/landmarks.py rename to tools/manual/frame_viewer/editor/landmarks.py diff --git a/tools/manual/frameviewer/editor/mask.py b/tools/manual/frame_viewer/editor/mask.py similarity index 70% rename from tools/manual/frameviewer/editor/mask.py rename to tools/manual/frame_viewer/editor/mask.py index 5101cde3a8..ff3e3df2b0 100644 --- a/tools/manual/frameviewer/editor/mask.py +++ b/tools/manual/frame_viewer/editor/mask.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 -""" Mask Editor for the manual adjustments tool """ +"""Mask Editor for the manual adjustments tool """ +from __future__ import annotations import gettext import tkinter as tk +import typing as T import numpy as np import cv2 @@ -11,26 +13,32 @@ from ._base import ControlPanelOption, Editor, logger +if T.TYPE_CHECKING: + import numpy.typing as npt + from lib import align + from tools.manual import detected_faces + + # LOCALES _LANG = gettext.translation("tools.manual", localedir="locales", fallback=True) _ = _LANG.gettext class Mask(Editor): - """ The mask Editor. + """The mask Editor. Edit a mask in the alignments file. Parameters ---------- - canvas: :class:`tkinter.Canvas` + canvas The canvas that holds the image and annotations - detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` + detected_faces The _detected_faces data for this manual session """ - def __init__(self, canvas, detected_faces): - self._meta = [] - self._tk_faces = [] + def __init__(self, canvas: tk.Canvas, detected_faces: detected_faces.DetectedFaces) -> None: + self._meta: dict[str, T.Any] = {} + self._tk_faces: list[ImageTk.PhotoImage] = [] self._internal_size = 512 control_text = _("Mask Editor\nEdit the mask." "\n - NB: For Landmark based masks (e.g. components/extended) it is " @@ -49,38 +57,38 @@ def __init__(self, canvas, detected_faces): self._get_cursor_shape(), False] @property - def _opacity(self): - """ float: The mask opacity setting from the control panel from 0.0 - 1.0. """ + def _opacity(self) -> float: + """The mask opacity setting from the control panel from 0.0 - 1.0.""" annotation = self.__class__.__name__ return self._annotation_formats[annotation]["mask_opacity"].get() / 100.0 @property - def _brush_radius(self): - """ int: The radius of the brush to use as set in control panel options """ + def _brush_radius(self) -> int: + """The radius of the brush to use as set in control panel options""" return self._control_vars["brush"]["BrushSize"].get() @property - def _edit_mode(self): - """ str: The currently selected edit mode based on optional action button. + def _edit_mode(self) -> str: + """The currently selected edit mode based on optional action button. One of "draw" or "erase" """ action = [name for name, option in self._actions.items() if option["group"] == "paint" and option["tk_var"].get()] return "draw" if not action else action[0] @property - def _cursor_color(self): - """ str: The hex code for the selected cursor color """ + def _cursor_color(self) -> str: + """The hex code for the selected cursor color""" return self._control_vars["brush"]["CursorColor"].get() @property - def _cursor_shape_name(self): - """ str: The selected cursor shape """ + def _cursor_shape_name(self) -> str: + """The selected cursor shape """ return self._control_vars["display"]["CursorShape"].get() - def _add_actions(self): - """ Add the optional action buttons to the viewer. Current actions are Draw, Erase + def _add_actions(self) -> None: + """Add the optional action buttons to the viewer. Current actions are Draw, Erase and Zoom. """ - self._add_action("magnify", "zoom", _("Magnify/Demagnify the View"), + self._add_action("magnify", "zoom", _("Magnify/De-magnify the View"), group=None, hotkey="M") self._add_action("draw", "draw", _("Draw Tool"), group="paint", hotkey="D") self._add_action("erase", "erase", _("Erase Tool"), group="paint", hotkey="E") @@ -88,8 +96,8 @@ def _add_actions(self): "w", lambda *e: self._globals.var_full_update.set(True)) - def _add_controls(self): - """ Add the mask specific control panel controls. + def _add_controls(self) -> None: + """Add the mask specific control panel controls. Current controls are: - the mask type to edit @@ -127,23 +135,24 @@ def _add_controls(self): is_radio=True, helptext=_("Select a shape for masking cursor."))) - def _set_tk_mask_change_callback(self): - """ Add a trace to change the displayed mask on a mask type change. """ + def _set_tk_mask_change_callback(self) -> tk.StringVar: + """Add a trace to change the displayed mask on a mask type change.""" var = self._control_vars["display"]["MaskType"] var.trace("w", lambda *e: self._on_mask_type_change()) return var.get() - def _set_tk_cursor_shape_change_callback(self): - """ Add a trace to change the displayed cursor on a cursor shape type change. """ + def _set_tk_cursor_shape_change_callback(self) -> tk.StringVar: + """Add a trace to change the displayed cursor on a cursor shape type change.""" var = self._control_vars["display"]["CursorShape"] var.trace("w", lambda *e: self._on_cursor_shape_change()) return var.get() - def _on_cursor_shape_change(self): + def _on_cursor_shape_change(self) -> None: + """Set the cursor shape""" self._mouse_location[0] = self._get_cursor_shape() - def _on_mask_type_change(self): - """ Update the displayed mask on a mask type change """ + def _on_mask_type_change(self) -> None: + """Update the displayed mask on a mask type change""" mask_type = self._control_vars["display"]["MaskType"].get() if mask_type == self._mask_type: return @@ -151,13 +160,13 @@ def _on_mask_type_change(self): self._mask_type = mask_type self._globals.var_full_update.set(True) - def hide_annotation(self, tag=None): - """ Clear the mask :attr:`_meta` dict when hiding the annotation. """ + def hide_annotation(self, tag=None) -> None: + """Clear the mask :attr:`_meta` dict when hiding the annotation.""" super().hide_annotation() self._meta = {} - def update_annotation(self): - """ Update the mask annotation with the latest mask. """ + def update_annotation(self) -> None: + """Update the mask annotation with the latest mask.""" position = self._globals.frame_index if position != self._meta.get("position", -1): # Reset meta information when moving to a new frame @@ -178,21 +187,22 @@ def update_annotation(self): self._update_roi_box(mask, face_idx, roi_color) self._canvas.tag_raise(self._mouse_location[0]) # Always keep brush cursor on top - logger.trace("Updated mask annotation") + logger.trace("Updated mask annotation") # type:ignore[attr-defined] - def _set_face_meta_data(self, mask, face_index): - """ Set the metadata for the current face if it has changed or is new. + def _set_face_meta_data(self, mask: align.Mask, face_index: int) -> None: + """Set the metadata for the current face if it has changed or is new. Parameters ---------- - mask: :class:`numpy.ndarray` + mask The one channel mask cropped to the ROI - face_index: int + face_index The index pertaining to the current face """ masks = self._meta.get("mask", None) if masks is not None and len(masks) - 1 == face_index: - logger.trace("Meta information already defined for face: %s", face_index) + logger.trace( # type:ignore[attr-defined] + "Meta information already defined for face: %s", face_index) return logger.debug("Defining meta information for face: %s", face_index) @@ -205,14 +215,16 @@ def _set_face_meta_data(self, mask, face_index): if self.zoomed_centering != mask.stored_centering: self.zoomed_centering = mask.stored_centering - def _set_full_frame_meta(self, mask, mask_scale): - """ Sets the meta information for displaying the mask in full frame mode. + def _set_full_frame_meta(self, # pylint:disable=too-many-locals + mask: align.Mask, + mask_scale: float) -> None: + """Sets the meta information for displaying the mask in full frame mode. Parameters ---------- - mask: :class:`lib.align.Mask` + mask The mask object - mask_scale: float + mask_scale The scaling factor from the stored mask size to the internal mask size Sets the following parameters to :attr:`_meta`: @@ -238,11 +250,13 @@ def _set_full_frame_meta(self, mask, mask_scale): # Create a bounding box rectangle ROI roi_dims = np.rint((min_max["max"][1] - min_max["min"][1], min_max["max"][0] - min_max["min"][0])).astype("uint16") - roi = {"mask": np.zeros(roi_dims, dtype="uint8")[..., None], - "corners": np.expand_dims(scaled_mask_roi - min_max["min"], axis=0)} + roi_mask = np.zeros(roi_dims, dtype="uint8")[..., None] + corners = T.cast(T.Sequence[np.ndarray], + np.expand_dims(scaled_mask_roi - min_max["min"], axis=0)) # Block out areas outside of the actual mask ROI polygon - cv2.fillPoly(roi["mask"], roi["corners"], 255) - logger.trace("Setting Full Frame mask ROI. shape: %s", roi["mask"].shape) + cv2.fillPoly(roi_mask, corners, 255) + logger.trace( # type:ignore[attr-defined] + "Setting Full Frame mask ROI. shape: %s", roi_mask.shape) # obtain the slices for cropping mask from full frame xy_slices = (slice(int(round(min_max["min"][1])), int(round(min_max["max"][1]))), @@ -253,8 +267,7 @@ def _set_full_frame_meta(self, mask, mask_scale): np.array([[1 / self._globals.current_frame.scale, 0., 0.], [0., 1 / self._globals.current_frame.scale, 0.], [0., 0., 1.]])) - in_matrix = np.dot(adjustments[0], - np.concatenate((mask.affine_matrix, np.array([[0., 0., 1.]])))) + in_matrix = np.dot(adjustments[0], mask.affine_matrix) affine_matrix = np.dot(in_matrix, adjustments[1]) # Get the size of the mask roi box in the frame @@ -262,25 +275,29 @@ def _set_full_frame_meta(self, mask, mask_scale): scaled_mask_roi[1][1] - scaled_mask_roi[0][1]) mask_roi_size = (side_sizes[0] ** 2 + side_sizes[1] ** 2) ** 0.5 - self._meta.setdefault("roi_mask", []).append(roi["mask"]) + self._meta.setdefault("roi_mask", []).append(roi_mask) self._meta.setdefault("affine_matrix", []).append(affine_matrix) self._meta.setdefault("interpolator", []).append(mask.interpolator) self._meta.setdefault("slices", []).append(xy_slices) self._meta.setdefault("top_left", []).append(min_max["min"] + self._canvas.offset) self._meta.setdefault("mask_roi_size", []).append(mask_roi_size) - def _update_mask_image(self, key, face_index, rgb_color, opacity): - """ Obtain a mask, overlay over image and add to canvas or update. + def _update_mask_image(self, + key: str, + face_index: int, + rgb_color: npt.NDArray[np.int32], + opacity: float) -> None: + """Obtain a mask, overlay over image and add to canvas or update. Parameters ---------- - key: str + key The base annotation name for creating tags - face_index: int + face_index The index of the face within the current frame - rgb_color: tuple + rgb_color The color that the mask should be displayed as - opacity: float + opacity The opacity to apply to the mask """ mask = (self._meta["mask"][face_index] * opacity).astype("uint8") @@ -295,14 +312,15 @@ def _update_mask_image(self, key, face_index, rgb_color, opacity): top_left = self._meta["top_left"][face_index] if len(self._tk_faces) < face_index + 1: - logger.trace("Adding new Photo Image for face index: %s", face_index) + logger.trace("Adding new Photo Image for face index: %s", # type:ignore[attr-defined] + face_index) self._tk_faces.append(ImageTk.PhotoImage(display_image)) elif self._tk_faces[face_index].width() != display_image.width: - logger.trace("Replacing existing Photo Image on width change for face index: %s", - face_index) + logger.trace( # type:ignore[attr-defined] + "Replacing existing Photo Image on width change for face index: %s", face_index) self._tk_faces[face_index] = ImageTk.PhotoImage(display_image) else: - logger.trace("Updating existing image") + logger.trace("Updating existing image") # type:ignore[attr-defined] self._tk_faces[face_index].paste(display_image) self._object_tracker(key, @@ -311,68 +329,71 @@ def _update_mask_image(self, key, face_index, rgb_color, opacity): top_left, {"image": self._tk_faces[face_index], "anchor": tk.NW}) - def _update_mask_image_zoomed(self, mask, rgb_color): - """ Update the mask image when zoomed in. + def _update_mask_image_zoomed(self, + mask: npt.NDArray[np.uint8], + rgb_color: npt.NDArray[np.int32]) -> Image.Image: + """Update the mask image when zoomed in. Parameters ---------- - mask: :class:`numpy.ndarray` + mask The raw mask - rgb_color: tuple + rgb_color The rgb color selected for the mask Returns ------- - :class: `PIL.Image` - The zoomed mask image formatted for display + The zoomed mask image formatted for display """ rgb = np.tile(rgb_color, self._zoomed_dims + (1, )).astype("uint8") - mask = cv2.resize(mask, - tuple(reversed(self._zoomed_dims)), - interpolation=cv2.INTER_CUBIC)[..., None] - rgba = np.concatenate((rgb, mask), axis=2) + out = cv2.resize(mask, + tuple(reversed(self._zoomed_dims)), + interpolation=cv2.INTER_CUBIC)[..., None] + rgba = np.concatenate((rgb, out), axis=2) return Image.fromarray(rgba) - def _update_mask_image_full_frame(self, mask, rgb_color, face_index): - """ Update the mask image when in full frame view. + def _update_mask_image_full_frame(self, + mask: npt.NDArray[np.uint8], + rgb_color: npt.NDArray[np.int32], + face_index: int) -> Image.Image: + """Update the mask image when in full frame view. Parameters ---------- - mask: :class:`numpy.ndarray` + mask The raw mask - rgb_color: tuple + rgb_color The rgb color selected for the mask - face_index: int + face_index The index of the face being displayed Returns ------- - :class: `PIL.Image` - The full frame mask image formatted for display + The full frame mask image formatted for display """ frame_dims = self._globals.current_frame.display_dims frame = np.zeros(frame_dims + (1, ), dtype="uint8") interpolator = self._meta["interpolator"][face_index] slices = self._meta["slices"][face_index] - mask = cv2.warpAffine(mask, - self._meta["affine_matrix"][face_index], - frame_dims, - frame, - flags=cv2.WARP_INVERSE_MAP | interpolator, - borderMode=cv2.BORDER_CONSTANT)[slices[0], slices[1]] - mask = mask[..., None] if mask.ndim == 2 else mask - rgb = np.tile(rgb_color, mask.shape).astype("uint8") - rgba = np.concatenate((rgb, np.minimum(mask, self._meta["roi_mask"][face_index])), axis=2) + out = cv2.warpAffine(mask, + self._meta["affine_matrix"][face_index], + frame_dims, + frame, + flags=cv2.WARP_INVERSE_MAP | interpolator, + borderMode=cv2.BORDER_CONSTANT)[slices[0], slices[1]] + out = out[..., None] if out.ndim == 2 else out + rgb = np.tile(rgb_color, out.shape).astype("uint8") + rgba = np.concatenate((rgb, np.minimum(out, self._meta["roi_mask"][face_index])), axis=2) return Image.fromarray(rgba) - def _update_roi_box(self, mask, face_index, color): - """ Update the region of interest box for the current mask. + def _update_roi_box(self, mask: align.Mask, face_index: int, color: str) -> None: + """Update the region of interest box for the current mask. - mask: :class:`~lib.align.Mask` + mask The current mask object to create an ROI box for - face_index: int + face_index The index of the face within the current frame - color: str + color The hex color code that the mask should be displayed as """ if self._globals.is_zoomed: @@ -381,7 +402,9 @@ def _update_roi_box(self, mask, face_index, color): else: box = self._scale_to_display(mask.original_roi).flatten() top_left = box[:2] - 10 - kwargs = {"fill": color, "font": ("Default", 20, "bold"), "text": str(face_index)} + kwargs: dict[str, T.Any] = {"fill": color, + "font": ("Default", 20, "bold"), + "text": str(face_index)} self._object_tracker("mask_text", "text", face_index, top_left, kwargs) kwargs = {"fill": "", "outline": color, "width": 1} self._object_tracker("mask_roi", "polygon", face_index, box, kwargs) @@ -391,8 +414,8 @@ def _update_roi_box(self, mask, face_index, color): # << MOUSE HANDLING >> # Mouse cursor display - def _update_cursor(self, event): - """ Set the cursor action. + def _update_cursor(self, event: tk.Event) -> None: + """Set the cursor action. Update :attr:`_mouse_location` with the current cursor position and display appropriate icon. @@ -401,7 +424,7 @@ def _update_cursor(self, event): Parameters ---------- - event: :class:`tkinter.Event` + event The current tkinter mouse event """ roi_boxes = self._canvas.find_withtag("mask_roi") @@ -425,8 +448,8 @@ def _update_cursor(self, event): self._mouse_location[1] = face_idx self._canvas.update_idletasks() - def _control_click(self, event): - """ The action to perform when the user starts clicking and dragging the mouse whilst + def _control_click(self, event: tk.Event) -> None: + """The action to perform when the user starts clicking and dragging the mouse whilst pressing the control button. For editing the mask this will activate the opposite action than what is currently selected @@ -434,21 +457,23 @@ def _control_click(self, event): Parameters ---------- - event: :class:`tkinter.Event` + event The tkinter mouse event. """ self._drag_start(event, control_click=True) - def _drag_start(self, event, control_click=False): # pylint:disable=arguments-differ - """ The action to perform when the user starts clicking and dragging the mouse. + def _drag_start(self, # pylint:disable=arguments-differ + event: tk.Event, + control_click: bool = False) -> None: + """The action to perform when the user starts clicking and dragging the mouse. Paints on the mask with the appropriate draw or erase action. Parameters ---------- - event: :class:`tkinter.Event` + event The tkinter mouse event. - control_click: bool, optional + control_click Indicates whether the control button is depressed when drag has commenced. If ``True`` then the opposite of the selected action is performed. Default: ``False`` """ @@ -468,12 +493,12 @@ def _drag_start(self, event, control_click=False): # pylint:disable=arguments-d face_idx) self._drag_callback = self._paint - def _paint(self, event): - """ Paint or erase from Mask and update cursor on click and drag. + def _paint(self, event: tk.Event) -> None: + """Paint or erase from Mask and update cursor on click and drag. Parameters ---------- - event: :class:`tkinter.Event` + event The tkinter mouse event. """ face_idx = self._mouse_location[1] @@ -495,14 +520,15 @@ def _paint(self, event): self._drag_data["starting_location"] = np.array((event.x, event.y)) self._update_cursor(event) - def _transform_points(self, face_index, points): - """ Transform the edit points from a full frame or zoomed view back to the mask. + def _transform_points(self, face_index: int, points: npt.NDArray[np.float32] + ) -> tuple[npt.NDArray[np.int32], float]: + """Transform the edit points from a full frame or zoomed view back to the mask. Parameters ---------- - face_index: int + face_index The index of the face within the current frame - points: :class:`numpy.ndarray` + points The points that are to be translated from the viewer to the underlying Detected Face """ @@ -515,18 +541,18 @@ def _transform_points(self, face_index, points): t_points = np.expand_dims(points - self._canvas.offset, axis=0) t_points = cv2.transform(t_points, self._meta["affine_matrix"][face_index]).squeeze() t_points = np.rint(t_points).astype("int32") - logger.trace("original points: %s, transformed points: %s, scale: %s", - points, t_points, scale) + logger.trace( # type:ignore[attr-defined] + "original points: %s, transformed points: %s, scale: %s", points, t_points, scale) return t_points, scale - def _drag_stop(self, event): - """ The action to perform when the user stops clicking and dragging the mouse. + def _drag_stop(self, event: tk.Event) -> None: + """The action to perform when the user stops clicking and dragging the mouse. If a line hasn't been drawn then draw a circle. Update alignments. Parameters ---------- - event: :class:`tkinter.Event` + event The tkinter mouse event. Unused but required """ if not self._drag_data: @@ -539,15 +565,19 @@ def _drag_stop(self, event): self._drag_data = {} self._update_cursor(event) - def _get_cursor_shape_mark(self, img, location, face_idx): - """ Draw object depending on the cursor shape selection. Defaults to circle. + def _get_cursor_shape_mark(self, + img: npt.NDArray[np.uint8], + location: npt.NDArray[np.float32], + face_idx: int) -> None: + """Draw object depending on the cursor shape selection. Defaults to circle. Parameters ---------- - img: Image to draw on (mask) - location: Cursor location coordinates that will be transformed to correct - coordinates - face_index: int + img + Image to draw on (mask) + location + Cursor location coordinates that will be transformed to correct coordinates + face_index The index of the face within the current frame """ points, scale = self._transform_points(face_idx, location) @@ -566,37 +596,61 @@ def _get_cursor_shape_mark(self, img, location, face_idx): else: cv2.circle(img, tuple(points), radius, color, thickness=-1) - def _get_cursor_shape(self, x_1=0, y_1=0, x_2=0, y_2=0, outline="black", state="hidden"): + def _get_cursor_shape(self, + x_1: int = 0, + y_1: int = 0, + x_2: int = 0, + y_2: int = 0, + outline: str = "black", + state: T.Literal["normal", "hidden", "disabled"] = "hidden") -> int: + """Create the object for the cursor + + Parameters + ---------- + x_1 + left position + y_1 + top position + x_2 + right position + y_2 + bottom position + outline + Color of the cursor outline + state + The visibility state of the cursor + """ if self._cursor_shape_name == "Rectangle": return self._canvas.create_rectangle(x_1, y_1, x_2, y_2, outline=outline, state=state) return self._canvas.create_oval(x_1, y_1, x_2, y_2, outline=outline, state=state) - def _mask_to_alignments(self, face_index): - """ Update the annotated mask to alignments. + def _mask_to_alignments(self, face_index: int) -> None: + """Update the annotated mask to alignments. Parameters ---------- - face_index: int + face_index The index of the face in the current frame """ mask_type = self._control_vars["display"]["MaskType"].get().lower() - mask = self._meta["mask"][face_index].astype("float32") / 255.0 + mask = self._meta["mask"][face_index] self._det_faces.update.mask(self._globals.frame_index, face_index, mask, mask_type) - def _adjust_brush_radius(self, increase=True): # pylint:disable=unused-argument - """ Adjust the brush radius up or down by 2px. + def _adjust_brush_radius(self, increase: bool = True): + """Adjust the brush radius up or down by 2px. Sets the control panel option for brush radius to 2 less or 2 more than its current value Parameters ---------- - increase: bool, optional + increase ``True`` to increment brush radius, ``False`` to decrement. Default: ``True`` """ radius_var = self._control_vars["brush"]["BrushSize"] current_val = radius_var.get() new_val = min(100, current_val + 2) if increase else max(1, current_val - 2) - logger.trace("Adjusting brush radius from %s to %s", current_val, new_val) + logger.trace("Adjusting brush radius from %s to %s", # type:ignore[attr-defined] + current_val, new_val) radius_var.set(new_val) delta = new_val - current_val @@ -605,7 +659,8 @@ def _adjust_brush_radius(self, increase=True): # pylint:disable=unused-argument current_coords = self._canvas.coords(self._mouse_location[0]) new_coords = tuple(coord - delta if idx < 2 else coord + delta for idx, coord in enumerate(current_coords)) - logger.trace("Adjusting brush coordinates from %s to %s", current_coords, new_coords) + logger.trace("Adjusting brush coordinates from %s to %s", # type:ignore[attr-defined] + current_coords, new_coords) self._canvas.coords(self._mouse_location[0], new_coords) diff --git a/tools/manual/frameviewer/frame.py b/tools/manual/frame_viewer/frame.py similarity index 100% rename from tools/manual/frameviewer/frame.py rename to tools/manual/frame_viewer/frame.py diff --git a/tools/manual/manual.py b/tools/manual/manual.py index e426d41704..70f534e89b 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Main entry point for the Manual Tool. A GUI app for editing alignments files """ +"""Main entry point for the Manual Tool. A GUI app for editing alignments files """ from __future__ import annotations import logging @@ -18,39 +18,35 @@ from lib.image import SingleFrameLoader, read_image_meta from lib.logger import parse_class_init from lib.multithreading import MultiThread -from lib.utils import get_module_objects, handle_deprecated_cliopts -from plugins.extract import ExtractMedia, Extractor +from lib.utils import get_module_objects, handle_deprecated_cli_opts +from lib.infer.align import Align from .detected_faces import DetectedFaces -from .faceviewer.frame import FacesFrame -from .frameviewer.frame import DisplayFrame +from .face_viewer.frame import FacesFrame +from .frame_viewer.frame import DisplayFrame from .globals import TkGlobals from .thumbnails import ThumbsCreator if T.TYPE_CHECKING: from argparse import Namespace - from lib import align - from lib.align import DetectedFace - from lib.queue_manager import EventQueue + from lib.infer.runner import ExtractRunner logger = logging.getLogger(__name__) -TypeManualExtractor = T.Literal["FAN", "cv2-dnn", "mask"] - @dataclass class _Containers: - """ Dataclass for holding the main area containers in the GUI """ + """Dataclass for holding the main area containers in the GUI""" main: ttk.PanedWindow - """:class:`tkinter.ttk.PanedWindow`: The main window holding the full GUI """ + """The main window holding the full GUI""" top: ttk.Frame - """:class:`tkinter.ttk.Frame: The top part (frame viewer) of the GUI""" + """The top part (frame viewer) of the GUI""" bottom: ttk.Frame - """:class:`tkinter.ttk.Frame: The bottom part (face viewer) of the GUI""" + """The bottom part (face viewer) of the GUI""" class Manual(tk.Tk): - """ The main entry point for Faceswap's Manual Editor Tool. This tool is part of the Faceswap + """The main entry point for Faceswap's Manual Editor Tool. This tool is part of the Faceswap Tools suite and should be called from ``python tools.py manual`` command. Allows for visual interaction with frames, faces and alignments file to perform various @@ -58,14 +54,14 @@ class Manual(tk.Tk): Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The :mod:`argparse` arguments as passed in from :mod:`tools.py` """ def __init__(self, arguments: Namespace) -> None: logger.debug(parse_class_init(locals())) super().__init__() - arguments = handle_deprecated_cliopts(arguments) + arguments = handle_deprecated_cli_opts(arguments) self._validate_non_faces(arguments.frames) self._initialize_tkinter() @@ -93,7 +89,9 @@ def __init__(self, arguments: Namespace) -> None: if not valid_meta: # If meta data needs updating, load faces after other threads self._detected_faces.load_faces() - self._generate_thumbs(arguments.frames, arguments.thumb_regen, arguments.single_process) + self._generate_thumbs(arguments.frames, + arguments.thumb_regenerate, + arguments.single_process) self._display = DisplayFrame(self._containers.top, self._globals, @@ -112,8 +110,8 @@ def __init__(self, arguments: Namespace) -> None: @classmethod def _validate_non_faces(cls, frames_folder: str) -> None: - """ Quick check on the input to make sure that a folder of extracted faces is not being - passed in. """ + """Quick check on the input to make sure that a folder of extracted faces is not being + passed in.""" if not os.path.isdir(frames_folder): logger.debug("Input '%s' is not a folder", frames_folder) return @@ -122,7 +120,7 @@ def _validate_non_faces(cls, frames_folder: str) -> None: if os.path.splitext(fname)[-1].lower() == ".png"), None) if not test_file: - logger.debug("Input '%s' does not contain any .pngs", frames_folder) + logger.debug("Input '%s' does not contain any .PNGs", frames_folder) return test_file = os.path.join(frames_folder, test_file) meta = read_image_meta(test_file) @@ -135,16 +133,16 @@ def _validate_non_faces(cls, frames_folder: str) -> None: logger.debug("Test input file '%s' does not contain Faceswap header data", test_file) def _wait_for_threads(self, extractor: Aligner, loader: FrameLoader, valid_meta: bool) -> None: - """ The :class:`Aligner` and :class:`FramesLoader` are launched in background threads. + """The :class:`Aligner` and :class:`FramesLoader` are launched in background threads. Wait for them to be initialized prior to proceeding. Parameters ---------- - extractor: :class:`Aligner` + extractor The extraction pipeline for the Manual Tool - loader: :class:`FramesLoader` + loader The frames loader for the Manual Tool - valid_meta: bool + valid_meta Whether the input video had valid meta-data on import, or if it had to be created. ``True`` if valid meta data existed previously, ``False`` if it needed to be created @@ -159,7 +157,7 @@ def _wait_for_threads(self, extractor: Aligner, loader: FrameLoader, valid_meta: extractor_init = extractor_init if extractor_init else extractor.is_initialized frames_init = frames_init if frames_init else loader.is_initialized if extractor_init and frames_init: - logger.debug("Threads inialized") + logger.debug("Threads initialized") break logger.debug("Threads not initialized. Waiting...") sleep(1) @@ -171,16 +169,16 @@ def _wait_for_threads(self, extractor: Aligner, loader: FrameLoader, valid_meta: **loader.video_meta_data) # type:ignore[arg-type] def _generate_thumbs(self, input_location: str, force: bool, single_process: bool) -> None: - """ Check whether thumbnails are stored in the alignments file and if not generate them. + """Check whether thumbnails are stored in the alignments file and if not generate them. Parameters ---------- - input_location: str + input_location The input video or folder of images - force: bool + force ``True`` if the thumbnails should be regenerated even if they exist, otherwise ``False`` - single_process: bool + single_process ``True`` will extract thumbs from a video in a single process, ``False`` will run parallel threads """ @@ -192,7 +190,7 @@ def _generate_thumbs(self, input_location: str, force: bool, single_process: boo logger.debug("Generated thumbnails cache") def _initialize_tkinter(self) -> None: - """ Initialize a standalone tkinter instance. """ + """Initialize a standalone tkinter instance. """ logger.debug("Initializing tkinter") for widget in ("TButton", "TCheckbutton", "TRadiobutton"): self.unbind_class(widget, "") @@ -203,12 +201,11 @@ def _initialize_tkinter(self) -> None: logger.debug("Initialized tkinter") def _create_containers(self) -> _Containers: - """ Create the paned window containers for various GUI elements + """Create the paned window containers for various GUI elements Returns ------- - :class:`_Containers`: - The main containers of the manual tool. + The main containers of the manual tool. """ logger.debug("Creating containers") @@ -229,11 +226,11 @@ def _create_containers(self) -> _Containers: return retval def _handle_key_press(self, event: tk.Event) -> None: - """ Keyboard shortcuts + """Keyboard shortcuts Parameters ---------- - event: :class:`tkinter.Event()` + event The tkinter key press event Notes @@ -254,10 +251,12 @@ def _handle_key_press(self, event: tk.Event) -> None: "space": self._display.navigation.handle_play_button, "home": self._display.navigation.goto_first_frame, "end": self._display.navigation.goto_last_frame, - "down": lambda d="down": self._faces_frame.canvas_scroll(d), - "up": lambda d="up": self._faces_frame.canvas_scroll(d), - "next": lambda d="page-down": self._faces_frame.canvas_scroll(d), - "prior": lambda d="page-up": self._faces_frame.canvas_scroll(d), + "down": lambda d="down": self._faces_frame.canvas_scroll(T.cast(T.Literal["down"], d)), + "up": lambda d="up": self._faces_frame.canvas_scroll(T.cast(T.Literal["up"], d)), + "next": lambda d="page-down": self._faces_frame.canvas_scroll( + T.cast(T.Literal["page-down"], d)), + "prior": lambda d="page-up": self._faces_frame.canvas_scroll( + T.cast(T.Literal["page-up"], d)), "f": self._display.cycle_filter_mode, "f1": lambda k=event.keysym: self._display.set_action(k), "f2": lambda k=event.keysym: self._display.set_action(k), @@ -266,8 +265,10 @@ def _handle_key_press(self, event: tk.Event) -> None: "f5": lambda k=event.keysym: self._display.set_action(k), "f9": lambda k=event.keysym: self._faces_frame.set_annotation_display(k), "f10": lambda k=event.keysym: self._faces_frame.set_annotation_display(k), - "c": lambda f=globs.frame_index, d="prev": self._detected_faces.update.copy(f, d), - "v": lambda f=globs.frame_index, d="next": self._detected_faces.update.copy(f, d), + "c": lambda f=globs.frame_index, d="prev": self._detected_faces.update.copy( + f, T.cast(T.Literal["prev"], d)), + "v": lambda f=globs.frame_index, d="next": self._detected_faces.update.copy( + f, T.cast(T.Literal["next"], d)), "ctrl_s": self._detected_faces.save, "r": lambda f=globs.frame_index: self._detected_faces.revert_to_saved(f)} @@ -283,7 +284,7 @@ def _handle_key_press(self, event: tk.Event) -> None: bindings[key_press.lower()]() def _set_initial_layout(self) -> None: - """ Set the favicon and the bottom frame position to correct location to display full + """Set the favicon and the bottom frame position to correct location to display full frame window. Notes @@ -301,7 +302,7 @@ def _set_initial_layout(self) -> None: self.update_idletasks() def process(self) -> None: - """ The entry point for the Visual Alignments tool from :mod:`lib.tools.manual.cli`. + """The entry point for the Visual Alignments tool from :mod:`lib.tools.manual.cli`. Launch the tkinter Visual Alignments Window and run main loop. """ @@ -310,16 +311,16 @@ def process(self) -> None: class _Options(ttk.Frame): # pylint:disable=too-many-ancestors - """ Control panel options for currently displayed Editor. This is the right hand panel of the + """Control panel options for currently displayed Editor. This is the right hand panel of the GUI that holds editor specific settings and annotation display settings. Parameters ---------- - parent: :class:`tkinter.ttk.Frame` + parent The parent frame for the control panel options - tk_globals: :class:`~tools.manual.manual.TkGlobals` + tk_globals The tkinter variables that apply to the whole of the GUI - display_frame: :class:`DisplayFrame` + display_frame The frame that holds the editors """ def __init__(self, @@ -338,7 +339,7 @@ def __init__(self, logger.debug("Initialized %s", self.__class__.__name__) def _initialize(self) -> dict[str, ControlPanel]: - """ Initialize all of the control panels, then display the default panel. + """Initialize all of the control panels, then display the default panel. Adds the control panel to :attr:`_control_panels` and sets the traceback to update display when a panel option has been changed. @@ -353,8 +354,7 @@ def _initialize(self) -> dict[str, ControlPanel]: Returns ------- - dict[str, :class:`~lib.gui.control_helper.ControlPanel`] - The configured control panels + The configured control panels """ self._initialize_face_options() frame = ttk.Frame(self) @@ -377,7 +377,7 @@ def _initialize(self) -> dict[str, ControlPanel]: return panels def _initialize_face_options(self) -> None: - """ Set the Face Viewer options panel, beneath the standard control options. """ + """Set the Face Viewer options panel, beneath the standard control options.""" frame = ttk.Frame(self) frame.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=5) size_frame = ttk.Frame(frame) @@ -392,7 +392,7 @@ def _initialize_face_options(self) -> None: cmb.pack(side=tk.RIGHT, padx=5) def _set_tk_callbacks(self) -> None: - """ Sets the callback to change to the relevant control panel options when the selected + """Sets the callback to change to the relevant control panel options when the selected editor is changed, and the display update on panel option change.""" self._display_frame.tk_selected_action.trace("w", self._update_options) seen_controls = set() @@ -408,15 +408,10 @@ def _set_tk_callbacks(self) -> None: ctl.tk_var.trace("w", lambda *e: self._globals.var_full_update.set(True)) def _update_options(self, *args) -> None: # pylint:disable=unused-argument - """ Update the control panel display for the current editor. + """Update the control panel display for the current editor. If the options have not already been set, then adds the control panel to :attr:`_control_panels`. Displays the current editor's control panel - - Parameters - ---------- - args: tuple - Unused but required for tkinter variable callback """ self._clear_options_frame() editor = self._display_frame.tk_selected_action.get() @@ -424,7 +419,7 @@ def _update_options(self, *args) -> None: # pylint:disable=unused-argument self._control_panels[editor].pack(expand=True, fill=tk.BOTH) def _clear_options_frame(self) -> None: - """ Hides the currently displayed control panel """ + """Hides the currently displayed control panel""" for editor, panel in self._control_panels.items(): if panel.winfo_ismapped(): logger.debug("Hiding control panel for: %s", editor) @@ -432,56 +427,29 @@ def _clear_options_frame(self) -> None: class Aligner(): - """ The :class:`Aligner` class sets up an extraction pipeline for each of the current Faceswap + """The :class:`Aligner` class sets up an extraction pipeline for each of the current Faceswap Aligners, along with the Landmarks based Maskers. When new landmarks are required, the bounding boxes from the GUI are passed to this class for pushing through the pipeline. The resulting Landmarks and Masks are then returned. Parameters ---------- - tk_globals: :class:`~tools.manual.manual.TkGlobals` + tk_globals The tkinter variables that apply to the whole of the GUI """ def __init__(self, tk_globals: TkGlobals) -> None: logger.debug("Initializing: %s (tk_globals: %s)", self.__class__.__name__, tk_globals) self._globals = tk_globals - - 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._aligners: dict[T.Literal["FAN", "HRNet", "cv2-dnn"], # type:ignore[type-var] + ExtractRunner[Align]] = {} + self._detected_faces: DetectedFaces self._init_thread = self._background_init_aligner() logger.debug("Initialized: %s", self.__class__.__name__) - @property - def _in_queue(self) -> EventQueue: - """ :class:`queue.Queue` - The input queue to the extraction pipeline. """ - aligner = self._aligners[self._aligner] - assert aligner is not None - return aligner.input_queue - - @property - def _feed_face(self) -> ExtractMedia: - """ :class:`~plugins.extract.extract_media.ExtractMedia`: The current face for feeding into - the aligner, formatted for the pipeline """ - assert self._frame_index is not None - assert self._face_index is not None - assert self._detected_faces is not None - face = self._detected_faces.current_faces[self._frame_index][self._face_index] - return ExtractMedia( - self._globals.current_frame.filename, - self._globals.current_frame.image, - detected_faces=[face]) - @property def is_initialized(self) -> bool: - """ bool: The Aligners are initialized in a background thread so that other tasks can be + """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() @@ -494,13 +462,12 @@ def is_initialized(self) -> bool: return not thread_is_alive def _background_init_aligner(self) -> MultiThread: - """ Launch the aligner in a background thread so we can run other tasks whilst + """Launch the aligner in a background thread so we can run other tasks whilst waiting for initialization Returns ------- - :class:`lib.multithreading.MultiThread - The background aligner loader thread + The background aligner loader thread """ logger.debug("Launching aligner initialization thread") thread = MultiThread(self._init_aligner, @@ -511,26 +478,15 @@ def _background_init_aligner(self) -> MultiThread: return thread def _init_aligner(self) -> None: - """ Initialize Aligner in a background thread, and set it to :attr:`_aligner`. """ + """Initialize Aligner in a background thread, and set it to :attr:`_aligners`.""" logger.debug("Initialize Aligner") - # Make sure non-GPU aligner is allocated first - for model in T.get_args(TypeManualExtractor): - logger.debug("Initializing aligner: %s", model) - plugin = None if model == "mask" else model - aligner = Extractor(None, - plugin, - ["components", "extended"], - multiprocess=True, - normalize_method="hist", - disable_filter=True) - if plugin: - aligner.set_batchsize("align", 1) # Set the batchsize to 1 - aligner.launch() - logger.debug("Initialized %s Extractor", model) - self._aligners[model] = aligner + for plugin in ("cv2-dnn", "FAN", "HRNet"): + logger.debug("Initializing Aligner: %s", plugin) + self._aligners[plugin] = Align(plugin, normalization="hist")() + logger.debug("Initialized '%s' Aligner", plugin) 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 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. @@ -539,121 +495,72 @@ def link_faces(self, detected_faces: DetectedFaces) -> None: Parameters ---------- - detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` + detected_faces The class that holds the :class:`~lib.align.DetectedFace` objects for the current Manual session """ logger.debug("Linking detected_faces: %s", detected_faces) self._detected_faces = detected_faces - 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. + def get_landmarks(self, + frame_index: int, + face_index: int, + aligner: T.Literal["FAN", "HRNet", "cv2-dnn"]) -> 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. Parameters ---------- - frame_index: int + frame_index The frame index to extract the aligned face for - face_index: int + face_index The face index within the current frame to extract the face for - aligner: Literal["FAN", "cv2-dnn"] + aligner The aligner to use to extract the face Returns ------- - :class:`numpy.ndarray` - The 68 point landmark alignments + The 68 point landmark alignments """ 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) - 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 _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.debug("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, align.aligned_mask.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. - - Parameters - ---------- - frame_index: int - The frame index to extract the aligned face for - face_index: int - The face index within the current frame to extract the face for - - Returns - ------- - dict[str, :class:`~lib.align.aligned_mask.Mask`] - The updated masks - """ - 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 + face = self._aligners[aligner].put( + self._globals.current_frame.filename, + self._globals.current_frame.image, + detected_faces=[self._detected_faces.current_faces[frame_index][face_index]], + passthrough=True).detected_faces[0] + logger.trace("landmarks: %s", face.landmarks_xy) # type:ignore[attr-defined] + return face.landmarks_xy def set_normalization_method(self, method: T.Literal["none", "clahe", "hist", "mean"]) -> None: - """ Change the normalization method for faces fed into the aligner. + """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: Literal["none", "clahe", "hist", "mean"] + method 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) - aligner.aligner.set_normalize_method(method) + logger.debug("Setting '%s' to: '%s'", plugin, method) + T.cast("Align", aligner.handler).set_normalize_method(method) class FrameLoader(): - """ Loads the frames, sets the frame count to :attr:`TkGlobals.frame_count` and handles the + """Loads the frames, sets the frame count to :attr:`TkGlobals.frame_count` and handles the return of the correct frame for the GUI. Parameters ---------- - tk_globals: :class:`~tools.manual.manual.TkGlobals` + tk_globals The tkinter variables that apply to the whole of the GUI - frames_location: str + frames_location The path to the input frames - video_meta_data: dict + video_meta_data The meta data held within the alignments file, if it exists and the input is a video - file_list: list[str] + file_list The list of filenames that exist within the alignments file """ def __init__(self, @@ -673,7 +580,7 @@ def __init__(self, @property def is_initialized(self) -> bool: - """ bool: ``True`` if the Frame Loader has completed initialization. """ + """``True`` if the Frame Loader has completed initialization. """ thread_is_alive = self._init_thread.is_alive() if thread_is_alive: self._init_thread.check_and_raise_error() @@ -684,7 +591,7 @@ def is_initialized(self) -> bool: @property def video_meta_data(self) -> dict[str, list[int] | list[float] | None]: - """ dict: The pts_time and key frames for the loader. """ + """The pts_time and key frames for the loader. """ assert self._loader is not None return self._loader.video_meta_data @@ -692,16 +599,16 @@ def _background_init_frames(self, frames_location: str, video_meta_data: dict[str, list[int] | list[float] | None], frame_list: list[str]) -> MultiThread: - """ Launch the images loader in a background thread so we can run other tasks whilst + """Launch the images loader in a background thread so we can run other tasks whilst waiting for initialization. Parameters ---------- - frame_location: str + frame_location The location of the source video file/frames folder - video_meta_data: dict + video_meta_data The meta data for video file sources - frame_list: list[str] + frame_list The list of frames that exist in the alignments file """ thread = MultiThread(self._load_images, @@ -717,15 +624,15 @@ def _load_images(self, frames_location: str, video_meta_data: dict[str, list[int] | list[float] | None], frame_list: list[str]) -> None: - """ Load the images in a background thread. + """Load the images in a background thread. Parameters ---------- - frame_location: str + frame_location The location of the source video file/frames folder - video_meta_data: dict + video_meta_data The meta data for video file sources - frame_list: list[str] + frame_list The list of frames that exist in the alignments file """ self._loader = SingleFrameLoader(frames_location, video_meta_data=video_meta_data) @@ -740,16 +647,16 @@ def _load_images(self, def _set_frame(self, # pylint:disable=unused-argument *args, initialize: bool = False) -> None: - """ Set the currently loaded frame to :attr:`_current_frame` and trigger a full GUI update. + """Set the currently loaded frame to :attr:`_current_frame` and trigger a full GUI update. If the loader has not been initialized, or the navigation position is the same as the current position and the face is not zoomed in, then this returns having done nothing. Parameters ---------- - args: tuple + args :class:`tkinter.Event` arguments. Required but not used. - initialize: bool, optional + initialize ``True`` if initializing for the first frame to be displayed otherwise ``False``. Default: ``False`` """ diff --git a/tools/manual/thumbnails.py b/tools/manual/thumbnails.py index 7586b29429..3d4c9a6a18 100644 --- a/tools/manual/thumbnails.py +++ b/tools/manual/thumbnails.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Thumbnail generator for the manual tool """ +"""Thumbnail generator for the manual tool""" from __future__ import annotations import logging import typing as T @@ -26,20 +26,20 @@ @dataclass class ProgressBar: - """ Thread-safe progress bar for tracking thumbnail generation progress """ + """Thread-safe progress bar for tracking thumbnail generation progress""" pbar: tqdm | None = None lock = Lock() @dataclass class VideoMeta: - """ Holds meta information about a video file + """Holds meta information about a video file Parameters ---------- - key_frames: list[int] + key_frames List of key frame indices for the video - pts_times: list[float] + pts_times List of presentation timestams for the video """ key_frames: list[int] | None = None @@ -47,16 +47,16 @@ class VideoMeta: class ThumbsCreator(): - """ Background loader to generate thumbnails for the alignments file. Generates low resolution + """Background loader to generate thumbnails for the alignments file. Generates low resolution thumbnails in parallel threads for faster processing. Parameters ---------- - detected_faces: :class:`~tool.manual.faces.DetectedFaces` + detected_faces The :class:`~lib.align.DetectedFace` objects for this video - input_location: str + input_location The location of the input folder of frames or video file - single_process: bool + single_process ``True`` to generated thumbs in a single process otherwise ``False`` """ def __init__(self, @@ -94,13 +94,12 @@ def __init__(self, @property def has_thumbs(self) -> bool: - """ bool: ``True`` if the underlying alignments file holds thumbnail images - otherwise ``False``. """ + """``True`` if the alignments file holds thumbnail images otherwise ``False``.""" return self._alignments.thumbnails.has_thumbnails def generate_cache(self) -> None: - """ Extract the face thumbnails from a video or folder of images into the - alignments file. """ + """Extract the face thumbnails from a video or folder of images into the + alignments file""" self._pbar.pbar = tqdm(desc="Caching Thumbnails", leave=False, total=len(self._frame_faces)) @@ -119,24 +118,29 @@ def generate_cache(self) -> None: # << PRIVATE METHODS >> # def _check_and_raise_error(self) -> None: - """ Monitor the loading threads for errors and raise if any occur. """ + """Monitor the loading threads for errors and raise if any occur.""" for thread in self._threads: thread.check_and_raise_error() def _join_threads(self) -> None: - """ Join the loading threads """ + """Join the loading threads""" logger.debug("Joining face viewer loading threads") for thread in self._threads: thread.join() def _launch_video(self) -> None: - """ Launch multiple :class:`lib.multithreading.MultiThread` objects to load faces from + """Launch multiple :class:`lib.multithreading.MultiThread` objects to load faces from a video file. Splits the video into segments and passes each of these segments to separate background threads for some speed up. """ key_frames = self._meta.key_frames + assert key_frames is not None + if key_frames[0] != 0: + logger.warning("Your video does not start on a Key Frame. This can lead to issues.") + key_frames = key_frames[:] + key_frames[0] = 0 pts_times = self._meta.pts_times assert key_frames is not None and pts_times is not None key_frame_split = len(key_frames) // self._num_threads @@ -164,7 +168,7 @@ def _launch_video(self) -> None: self._threads.append(thread) def _launch_folder(self) -> None: - """ Launch :class:`lib.multithreading.MultiThread` to retrieve faces from a + """Launch :class:`lib.multithreading.MultiThread` to retrieve faces from a folder of images. Goes through the file list one at a time, passing each file to a separate background @@ -192,20 +196,20 @@ def _load_from_video(self, pts_end: float, start_index: int, segment_count: int) -> None: - """ Loads faces from video for the given segment of the source video. + """Loads faces from video for the given segment of the source video. Each segment of the video is extracted from in a different background thread. Parameters ---------- - pts_start: float + pts_start The start time to cut the segment out of the video - pts_end: float + pts_end The end time to cut the segment out of the video - start_index: int + start_index The frame index that this segment starts from. Used for calculating the actual frame index of each frame extracted - segment_count: int + segment_count The number of frames that appear in this segment. Used for ending early in case more frames come out of the segment than should appear (sometimes more frames are picked up at the end of the segment, so these are discarded) @@ -229,19 +233,18 @@ def _load_from_video(self, start_index, idx) def _get_reader(self, pts_start: float, pts_end: float): - """ Get an imageio iterator for this thread's segment. + """Get an imageio iterator for this thread's segment. Parameters ---------- - pts_start: float + pts_start The start time to cut the segment out of the video - pts_end: float + pts_end The end time to cut the segment out of the video Returns ------- - :class:`imageio.Reader` - A reader iterator for the requested segment of video + A reader iterator for the requested segment of video """ input_params = ["-ss", str(pts_start)] if pts_end: @@ -256,17 +259,17 @@ def _load_from_folder(self, reader: SingleFrameLoader, start_index: int, end_index: int) -> None: - """ Loads faces from the given range of frame indices from a folder of images. + """Loads faces from the given range of frame indices from a folder of images. Each frame range is extracted in a different background thread. Parameters ---------- - reader: :class:`lib.image.SingleFrameLoader` + reader The reader that is used to retrieve the requested frame - start_index: int + start_index The starting frame index for the images to extract faces from - end_index: int + end_index The end frame index for the images to extract faces from """ logger.debug("reader: %s, start_index: %s, end_index: %s", @@ -278,15 +281,15 @@ def _load_from_folder(self, start_index, end_index - start_index) def _set_thumbail(self, filename: str, frame: np.ndarray, frame_index: int) -> None: - """ Extracts the faces from the frame and adds to alignments file + """Extracts the faces from the frame and adds to alignments file Parameters ---------- - filename: str + filename The filename of the frame within the alignments file - frame: :class:`numpy.ndarray` + frame The frame that contains the faces - frame_index: int + frame_index The frame index of this frame in the :attr:`_frame_faces` """ for face_idx, face in enumerate(self._frame_faces[frame_index]): diff --git a/tools/mask/cli.py b/tools/mask/cli.py index 44a5c6c7ec..226283d04d 100644 --- a/tools/mask/cli.py +++ b/tools/mask/cli.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Command Line Arguments for tools """ +"""Command Line Arguments for tools""" import gettext from lib.cli.args import FaceSwapArgs @@ -59,7 +59,7 @@ def get_argument_list(): "group": _("data"), "default": "frames", "help": _( - "R|Whether the `input` is a folder of faces or a folder frames/video" + "R|Whether the `input` is a folder of faces/frames or a video file" "\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({ @@ -84,23 +84,17 @@ def get_argument_list(): "action": Radio, "type": str.lower, "choices": PluginLoader.get_available_extractors("mask"), - "default": "extended", + "default": "bisenet-fp", "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." diff --git a/tools/mask/loader.py b/tools/mask/loader.py index 191fdd46c1..50c44837eb 100644 --- a/tools/mask/loader.py +++ b/tools/mask/loader.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -""" Handles loading of faces/frames from source locations and pairing with alignments -information """ +"""Handles loading of faces/frames from source locations and pairing with alignments +information""" from __future__ import annotations import logging @@ -13,7 +13,7 @@ from lib.align import alignments, DetectedFace, update_legacy_png_header from lib.image import FacesLoader, ImagesLoader from lib.utils import get_module_objects -from plugins.extract import ExtractMedia +from lib.infer.objects import FrameFaces if T.TYPE_CHECKING: from lib.align.alignments import PNGHeaderDict @@ -21,14 +21,14 @@ class Loader: - """ Loader for reading source data from disk, and yielding the output paired with alignment + """Loader for reading source data from disk, and yielding the output paired with alignment information Parameters ---------- - location: str + location Full path to the source files location - is_faces: bool + is_faces ``True`` if the source is a folder of faceswap extracted faces """ def __init__(self, location: str, is_faces: bool) -> None: @@ -44,74 +44,54 @@ def __init__(self, location: str, is_faces: bool) -> None: @property def file_list(self) -> list[str]: - """list[str]: Full file list of source files to be loaded """ + """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`` """ + """``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 """ + """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 """ + """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_object: alignments.Alignments | None) -> None: - """ Add the loaded alignments to :attr:`_alignments` for content matching + """Add the loaded alignments to :attr:`_alignments` for content matching Parameters ---------- - alignments_object: :class:`~lib.align.Alignments` | None + alignments_object The alignments file object or ``None`` if not provided """ logger.debug("Adding alignments to loader: %s", alignments_object) self._alignments = alignments_object - @classmethod - def _get_detected_face(cls, alignment: alignments.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 + metadata: PNGHeaderDict) -> FrameFaces | None: + """Process a single face when masking from face images Parameters ---------- - filename: str + filename the filename currently being processed - image: :class:`numpy.ndarray` + image The current face being processed - metadata: dict + metadata 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 + 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"] @@ -128,19 +108,16 @@ def _process_face(self, return None alignment = aligns[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"]) + retval = FrameFaces(filename, image, is_aligned=True, frame_metadata=metadata["source"]) + retval.detected_faces = [DetectedFace().from_alignment(alignment)] return retval - def _from_faces(self) -> T.Generator[ExtractMedia, None, None]: - """ Load content from pre-aligned faces and pair with corresponding metadata + def _from_faces(self) -> T.Generator[FrameFaces, 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 + The extract media object for the processed face """ log_once = False for filename, image, metadata in tqdm(self._loader.load(), total=self._loader.count): @@ -174,13 +151,12 @@ def _from_faces(self) -> T.Generator[ExtractMedia, None, None]: yield retval - def _from_frames(self) -> T.Generator[ExtractMedia, None, None]: - """ Load content from frames and and pair with corresponding metadata + def _from_frames(self) -> T.Generator[FrameFaces, 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 + 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): @@ -196,17 +172,20 @@ def _from_frames(self) -> T.Generator[ExtractMedia, None, None]: 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) + detected_faces = [DetectedFace().from_alignment(alignment) + for alignment in faces_in_frame] + + retval = FrameFaces(filename, image) + retval.detected_faces = detected_faces + yield retval - def load(self) -> T.Generator[ExtractMedia, None, None]: - """ Load content from source and pair with corresponding alignment data + def load(self) -> T.Generator[FrameFaces, 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 + The extract media object for the processed face """ if self._is_faces: iterator = self._from_faces diff --git a/tools/mask/mask.py b/tools/mask/mask.py index a849cb0b5a..66a5f47d99 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -1,29 +1,32 @@ #!/usr/bin/env python3 -""" Tool to generate masks and previews of masks for existing alignments file """ +"""Tool to generate masks and previews of masks for existing alignments file""" from __future__ import annotations import logging import os import sys +import typing as T from argparse import Namespace from multiprocessing import Process from lib.align import Alignments -from lib.utils import get_module_objects, handle_deprecated_cliopts, VIDEO_EXTENSIONS -from plugins.extract import ExtractMedia +from lib.utils import get_module_objects, handle_deprecated_cli_opts, VIDEO_EXTENSIONS 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.alignments import PNGHeaderSourceDict + from lib.infer.objects import FrameFaces logger = logging.getLogger(__name__) class Mask: - """ This tool is part of the Faceswap Tools suite and should be called from + """This tool is part of the Faceswap Tools suite and should be called from ``python tools.py mask`` command. Faceswap Masks tool. Generate masks from existing alignments files, and output masks @@ -33,7 +36,7 @@ class Mask: Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The :mod:`argparse` arguments as passed in from :mod:`tools.py` """ def __init__(self, arguments: Namespace) -> None: @@ -46,13 +49,12 @@ def __init__(self, arguments: Namespace) -> None: self._input_locations = self._get_input_locations() def _get_input_locations(self) -> list[str]: - """ Obtain the full path to input locations. Will be a list of locations if batch mode is + """Obtain the full path to input locations. Will be a list of locations if batch mode is selected, or containing a single location if batch mode is not selected. Returns ------- - list: - The list of input location paths + The list of input location paths """ if not self._args.batch_mode: return [self._args.input] @@ -69,14 +71,14 @@ def _get_input_locations(self) -> list[str]: return retval def _get_output_location(self, input_location: str) -> str: - """ Obtain the path to an output folder for faces for a given input location. + """Obtain the path to an output folder for faces for a given input location. A sub-folder within the user supplied output location will be returned based on the input filename Parameters ---------- - input_location: str + input_location The full path to an input video or folder of images """ retval = os.path.join(self._args.output, @@ -86,14 +88,14 @@ def _get_output_location(self, input_location: str) -> str: @staticmethod def _run_mask_process(arguments: Namespace) -> None: - """ The mask process to be run in a spawned process. + """The mask process to be run in a spawned process. In some instances, batch-mode memory leaks. Launching each job in a separate process prevents this leak. Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The :mod:`argparse` arguments to be used for the given job """ logger.debug("Starting process: (arguments: %s)", arguments) @@ -102,7 +104,7 @@ def _run_mask_process(arguments: Namespace) -> None: logger.debug("Finished process: (arguments: %s)", arguments) def process(self) -> None: - """ The entry point for triggering the Extraction Process. + """The entry point for triggering the Extraction Process. Should only be called from :class:`lib.cli.launcher.ScriptExecutor` """ @@ -129,7 +131,7 @@ def process(self) -> None: class _Mask: - """ This tool is part of the Faceswap Tools suite and should be called from + """This tool is part of the Faceswap Tools suite and should be called from ``python tools.py mask`` command. Faceswap Masks tool. Generate masks from existing alignments files, and output masks @@ -137,12 +139,12 @@ class _Mask: Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The :mod:`argparse` arguments as passed in from :mod:`tools.py` """ def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - arguments = handle_deprecated_cliopts(arguments) + arguments = handle_deprecated_cli_opts(arguments) self._update_type = arguments.processing self._input_is_faces = arguments.input_type == "faces" self._check_input(arguments.input) @@ -175,16 +177,17 @@ def __init__(self, arguments: Namespace) -> None: self._input_is_faces, self._loader, self._alignments, - arguments.input) + arguments.input, + arguments.config_file) logger.debug("Initialized %s", self.__class__.__name__) def _check_input(self, mask_input: str) -> None: - """ Check the input is valid. If it isn't exit with a logged error + """Check the input is valid. If it isn't exit with a logged error Parameters ---------- - mask_input: str + mask_input Path to the input folder/video """ if not os.path.exists(mask_input): @@ -197,21 +200,20 @@ def _check_input(self, mask_input: str) -> None: logger.debug("input '%s' is valid", mask_input) def _get_alignments(self, alignments: str | None, input_location: str) -> Alignments | None: - """ Obtain the alignments from either the given alignments location or the default + """Obtain the alignments from either the given alignments location or the default location. Parameters ---------- - alignments: str | None - Full path to the alignemnts file if provided or ``None`` if not - input_location: str + alignments + Full path to the alignments file if provided or ``None`` if not + input_location Full path to the source files to be used by the mask tool Returns ------- - ``None`` or :class:`~lib.align.alignments.Alignments`: - If output is requested, returns a :class:`~lib.align.alignments.Alignments` otherwise - returns ``None`` + If output is requested, returns a :class:`~lib.align.alignments.Alignments` otherwise + returns ``None`` """ if alignments: logger.debug("Alignments location provided: %s", alignments) @@ -237,24 +239,29 @@ def _get_alignments(self, alignments: str | None, input_location: str) -> Alignm retval = Alignments(folder, filename=filename) return retval - def _save_output(self, media: ExtractMedia) -> None: - """ Output masks to disk + def _save_output(self, media: FrameFaces) -> None: + """Output masks to disk Parameters ---------- - media: :class:`~plugins.extract.extract_media.ExtractMedia` + media The extract media holding the faces to output """ - 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 + if self._input_is_faces: + assert media.frame_metadata is not None + filename = os.path.basename(media.frame_metadata["source_filename"]) + dims = media.frame_metadata["source_frame_dims"] + else: + filename = os.path.basename(media.filename) + dims = None for idx, face in enumerate(media.detected_faces): - face_idx = media.frame_metadata["face_index"] if self._input_is_faces else idx + face_idx = T.cast("PNGHeaderSourceDict", + 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 """ + """Generate masks from a mask plugin""" assert self._mask_gen is not None logger.info("Generating masks") @@ -264,7 +271,7 @@ def _generate_masks(self) -> None: self._save_output(media) def _import_masks(self) -> None: - """ Import masks that have been generated outside of faceswap """ + """Import masks that have been generated outside of faceswap""" assert self._import is not None logger.info("Importing masks") @@ -285,12 +292,12 @@ def _import_masks(self) -> None: self._import.update_count, self._import.update_count + self._import.skip_count) def _output_masks(self) -> None: - """ Output masks to selected output folder """ + """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 """ + """The entry point for the Mask tool from :file:`lib.tools.cli`. Runs the Mask process""" logger.debug("Starting masker process") if self._update_type in ("all", "missing"): diff --git a/tools/mask/mask_generate.py b/tools/mask/mask_generate.py index ca4971cf38..698d67b1fb 100644 --- a/tools/mask/mask_generate.py +++ b/tools/mask/mask_generate.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Handles the generation of masks from faceswap for upating into an alignments file """ +"""Handles the generation of masks from faceswap for updating into an alignments file""" from __future__ import annotations import logging @@ -7,55 +7,56 @@ import typing as T from lib.image import encode_image, ImagesSaver -from lib.multithreading import MultiThread +from lib.logger import parse_class_init +from lib.multithreading import FSThread from lib.utils import get_module_objects -from plugins.extract import Extractor +from lib.infer import Mask if T.TYPE_CHECKING: from lib import align from lib.align import DetectedFace - from lib.queue_manager import EventQueue - from plugins.extract import ExtractMedia - from plugins.extract.mask.bisenet_fp import Mask as bfp_mask - from .loader import Loader + from lib.infer.objects import FrameFaces + from lib.infer.runner import ExtractRunner + from lib.infer.handler import ExtractHandlerFace + from . import loader logger = logging.getLogger(__name__) class MaskGenerator: - """ Uses faceswap's extract pipeline to generate masks and update them into the alignments file + """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 + mask_type The mask type to generate - update_all: bool + update_all ``True`` to update all faces, ``False`` to only update faces missing masks - input_is_faces: bool + input_is_faces ``True`` if the input are faceswap extracted faces otherwise ``False`` - loader: :class:`tools.mask.loader.Loader` + loader The loader for loading source images/video from disk + config_file + Full path to a custom config file to load. ``None`` for default config """ def __init__(self, mask_type: str, update_all: bool, input_is_faces: bool, - loader: Loader, + loader: loader.Loader, alignments: align.alignments.Alignments | None, - input_location: str) -> None: - logger.debug("Initializing %s (mask_type: %s, update_all: %s, input_is_faces: %s, " - "loader: %s, alignments: %s, input_location: %s)", - self.__class__.__name__, mask_type, update_all, input_is_faces, loader, - alignments, input_location) - + input_location: str, + config_file: str | None) -> None: + logger.debug(parse_class_init(locals())) self._update_all = update_all self._is_faces = input_is_faces self._alignments = alignments - self._extractor = self._get_extractor(mask_type) - self._mask_type = self._set_correct_mask_type(mask_type) + self._extractor = T.cast("ExtractRunner[ExtractHandlerFace]", + Mask(mask_type, config_file=config_file)()) + self._mask_type = self._extractor.handler.plugin.storage_name self._input_thread = self._set_loader_thread(loader) self._saver = ImagesSaver(input_location, as_bytes=True) if input_is_faces else None @@ -63,65 +64,21 @@ def __init__(self, logger.debug("Initialized %s", self.__class__.__name__) - def _get_extractor(self, mask_type) -> Extractor: - """ Obtain a Mask extractor plugin and launch it - - Parameters - ---------- - mask_type: str - The mask type to generate - - Returns - ------- - :class:`plugins.extract.pipeline.Extractor`: - The launched Extractor - """ - logger.debug("masker: %s", mask_type) - extractor = Extractor(None, None, mask_type) - 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 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 = T.cast("bfp_mask | None", - self._extractor._mask[0]) # pylint:disable=protected-access - assert mask_plugin is not None - new_type = f"{mask_type}_{mask_plugin.storage_centering}" - 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 + """Check if the mask for the current alignment needs updating for the requested mask_type Parameters ---------- - frame: str + frame The frame name in the alignments file - idx: int + idx The index of the face for this frame in the alignments file - face: :class:`~lib.align.DetectedFace` - The dected face object to check + face + The detected face object to check Returns ------- - bool: - ``True`` if the mask needs to be updated otherwise ``False`` + ``True`` if the mask needs to be updated otherwise ``False`` """ if self._update_all: return True @@ -132,21 +89,22 @@ def _needs_update(self, frame: str, idx: int, face: DetectedFace) -> bool: retval, frame, idx) return retval - def _feed_extractor(self, loader: Loader, extract_queue: EventQueue) -> None: - """ Process to feed the extractor from inside a thread + def _feed_extractor(self, loader: loader.Loader) -> None: + """Process to feed the extractor from inside a thread Parameters ---------- - loader: class:`tools.mask.loader.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._input_thread.error_state.has_error: + self._input_thread.error_state.re_raise() + self._counts["face"] += len(media) if self._is_faces: - assert len(media.detected_faces) == 1 + assert media.frame_metadata is not None + assert len(media) == 1 needs_update = self._needs_update(media.frame_metadata["source_filename"], media.frame_metadata["face_index"], media.detected_faces[0]) @@ -154,7 +112,9 @@ def _feed_extractor(self, loader: Loader, extract_queue: EventQueue) -> None: # 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) + needs_update = any(self._needs_update(os.path.basename(media.filename), + idx, + detected_face) for idx, detected_face in enumerate(media.detected_faces)) if not needs_update: @@ -163,37 +123,37 @@ def _feed_extractor(self, loader: Loader, extract_queue: EventQueue) -> None: continue logger.trace("Passing to extractor: '%s'", media.filename) # type:ignore[attr-defined] - extract_queue.put(media) + self._extractor.put_media(media) logger.debug("Terminating loader thread") - extract_queue.put("EOF") + self._extractor.stop() - def _set_loader_thread(self, loader: Loader) -> MultiThread: - """ Set the iterator to load ExtractMedia objects into the mask extraction pipeline + def _set_loader_thread(self, loader: loader.Loader) -> FSThread: + """Set the iterator to load FrameFaces objects into the mask extraction pipeline so we can just iterate through the output masks Parameters ---------- - loader: class:`tools.mask.loader.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) + logger.debug("Starting load thread: (loader: %s)", loader) + in_thread = FSThread(self._feed_extractor, args=(loader, )) 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 + def _update_from_face(self, media: FrameFaces) -> None: + """Update the alignments file and/or the extracted face Parameters ---------- - media: :class:`~lib.extract.pipeline.ExtractMedia` - The ExtractMedia object with updated masks + media + The FrameFaces object with updated masks """ - assert len(media.detected_faces) == 1 + assert len(media) == 1 assert self._saver is not None + assert media.frame_metadata is not None fname = media.frame_metadata["source_filename"] idx = media.frame_metadata["face_index"] @@ -206,25 +166,26 @@ def _update_from_face(self, media: ExtractMedia) -> None: logger.trace("Updating extracted face: '%s'", media.filename) # type:ignore[attr-defined] meta: align.alignments.PNGHeaderDict = {"alignments": face.to_png_meta(), "source": media.frame_metadata} - self._saver.save(media.filename, encode_image(media.image, ".png", metadata=meta)) + self._saver.save(os.path.basename(media.filename), + encode_image(media.image, ".png", metadata=meta)) - def _update_from_frame(self, media: ExtractMedia) -> None: - """ Update the alignments file + def _update_from_frame(self, media: FrameFaces) -> None: + """Update the alignments file Parameters ---------- - media: :class:`~lib.extract.pipeline.ExtractMedia` - The ExtractMedia object with updated masks + media + The FrameFaces 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) + len(media), 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 """ + """Close thread and save alignments on completion """ logger.debug("Finalizing MaskGenerator") self._input_thread.join() @@ -243,17 +204,17 @@ def _finalize(self) -> None: 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 + def process(self) -> T.Generator[FrameFaces, None, None]: + """Process the output from the extractor pipeline Yields ------ - :class:`~lib.extract.pipeline.ExtractMedia` - The ExtractMedia object with updated masks + The FrameFaces 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) + for media in self._extractor: + if self._input_thread.error_state.has_error: + self._input_thread.error_state.re_raise() + self._counts["update"] += len(media) if self._is_faces: self._update_from_face(media) diff --git a/tools/mask/mask_import.py b/tools/mask/mask_import.py index 2b382fa56a..b0b9ce581b 100644 --- a/tools/mask/mask_import.py +++ b/tools/mask/mask_import.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Import mask processing for faceswap's mask tool """ +"""Import mask processing for faceswap's mask tool""" from __future__ import annotations import logging @@ -17,33 +17,34 @@ if T.TYPE_CHECKING: import numpy as np - from .loader import Loader - from plugins.extract import ExtractMedia + from lib.infer.objects import FrameFaces from lib import align from lib.align import DetectedFace from lib.align.aligned_face import CenteringType + from . import loader logger = logging.getLogger(__name__) +# pylint:disable=duplicate-code class Import: - """ Import masks from disk into an Alignments file + """Import masks from disk into an Alignments file Parameters ---------- - import_path: str + import_path The path to the input images - centering: Literal["face", "head", "legacy"] + centering The centering to store the mask at - storage_size: int + storage_size The size to store the mask at - input_is_faces: bool + input_is_faces ``True`` if the input is aligned faces otherwise ``False`` - loader: :class:`~tools.mask.loader.Loader` + loader The source file loader object - alignments: :class:`~lib.align.alignments.Alignments` | None + alignments The alignments file object for the faces, if provided - mask_type: str + mask_type The mask type to update to """ def __init__(self, @@ -51,7 +52,7 @@ def __init__(self, centering: CenteringType, storage_size: int, input_is_faces: bool, - loader: Loader, + loader: loader.Loader, alignments: align.alignments.Alignments | None, input_location: str, mask_type: str) -> None: @@ -76,22 +77,22 @@ def __init__(self, @property def skip_count(self) -> int: - """ int: Number of masks that were skipped as they do not exist for given faces """ + """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 """ + """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 + """Validate that the mask type is 'custom' to ensure user does not accidentally overwrite + existing masks they may have edited Parameters ---------- - mask_type: str + mask_type The mask type that has been selected """ if mask_type == "custom": @@ -102,17 +103,16 @@ def _validate_mask_type(cls, mask_type: str) -> None: @classmethod def _get_file_list(cls, path: str) -> list[str]: - """ Check the nask folder exists and obtain the list of images + """Check the mask folder exists and obtain the list of images Parameters ---------- - path: str + path 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 + 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) @@ -124,12 +124,12 @@ def _get_file_list(cls, path: str) -> list[str]: 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 + """Generate a warning for each mask that exists that does not correspond to a match in the source input Parameters ---------- - file_list: list[str] + file_list List of mask files that could not be mapped to a source image """ if not file_list: @@ -143,17 +143,16 @@ def _warn_extra_masks(self, file_list: list[str]) -> None: "(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 + """Extract frame numbers from mask file names and return as a dictionary Parameters ---------- - file_list: list[str] + file_list List of full paths to masks to extract frame number from Returns ------- - dict[int, str] - Dictionary of frame numbers to filenames + Dictionary of frame numbers to filenames """ retval: dict[int, str] = {} for filename in file_list: @@ -164,35 +163,34 @@ def _file_list_to_frame_number(self, file_list: list[str]) -> dict[int, str]: "Check your filenames", os.path.basename(filename)) sys.exit(1) - fnum = int(frame_num[0]) + f_num = int(frame_num[0]) - if fnum in retval: + if f_num 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])) + f_num, os.path.basename(filename), os.path.basename(retval[f_num])) sys.exit(1) - retval[fnum] = filename + retval[f_num] = 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 + """Generate the mapping between the source data and the masks to be imported for video sources Parameters ---------- - file_list: list[str] + file_list List of full paths to masks to be imported - source_files: list[str] + source_files list of filenames withing the source file Returns ------- - dict[str, str] - Source filenames mapped to full path location of mask to be imported + Source filenames mapped to full path location of mask to be imported """ retval = {} unmapped = [] @@ -216,20 +214,19 @@ def _map_video(self, file_list: list[str], source_files: list[str]) -> dict[str, 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 + """Generate the mapping between the source data and the masks to be imported for folder of image sources Parameters ---------- - file_list: list[str] + file_list List of full paths to masks to be imported - source_files: list[str] + source_files list of filenames withing the source file Returns ------- - dict[str, str] - Source filenames mapped to full path location of mask to be imported + Source filenames mapped to full path location of mask to be imported """ mask_count = len(file_list) retval = {} @@ -254,20 +251,19 @@ def _map_images(self, file_list: list[str], source_files: list[str]) -> dict[str 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 + def _generate_mapping(self, import_path: str, loader: loader.Loader) -> dict[str, str]: + """Generate the mapping between the source data and the masks to be imported Parameters ---------- - import_path: str + import_path The path to the input images - loader: :class:`~tools.mask.loader.Loader` + loader The source file loader object Returns ------- - dict[str, str] - Source filenames mapped to full path location of mask to be imported + Source filenames mapped to full path location of mask to be imported """ file_list = self._get_file_list(import_path) if loader.is_video: @@ -278,13 +274,13 @@ def _generate_mapping(self, import_path: str, loader: Loader) -> dict[str, str]: return retval def _store_mask(self, face: DetectedFace, mask: np.ndarray) -> None: - """ Store the mask to the given DetectedFace object + """Store the mask to the given DetectedFace object Parameters ---------- - face: :class:`~lib.align.detected_face.DetectedFace` + face The detected face object to store the mask to - mask: :class:`numpy.ndarray` + mask The mask to store """ aligned = AlignedFace(face.landmarks_xy, @@ -292,28 +288,27 @@ def _store_mask(self, face: DetectedFace, mask: np.ndarray) -> None: centering=self._centering, size=self._size, is_aligned=self._is_faces, - dtype="float32") + dtype="uint8") assert aligned.face is not None face.add_mask(f"custom_{self._centering}", - aligned.face / 255., + aligned.face, 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 + def _store_mask_face(self, media: FrameFaces, mask: np.ndarray) -> None: + """Store the mask when the input is aligned faceswap faces Parameters ---------- - media: :class:`~plugins.extract.extract_media.ExtractMedia` + media The extract media object containing the face(s) to import the mask for - - mask: :class:`numpy.ndarray` + mask The mask loaded from disk """ assert self._saver is not None assert len(media.detected_faces) == 1 + assert media.frame_metadata is not None logger.trace("Adding mask for '%s'", media.filename) # type:ignore[attr-defined] @@ -331,41 +326,41 @@ def _store_mask_face(self, media: ExtractMedia, mask: np.ndarray) -> None: logger.trace("Updating extracted face: '%s'", media.filename) # type:ignore[attr-defined] meta: align.alignments.PNGHeaderDict = {"alignments": face.to_png_meta(), "source": media.frame_metadata} - self._saver.save(media.filename, encode_image(media.image, ".png", metadata=meta)) + self._saver.save(os.path.basename(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 + """Resize a mask to the given dimensions Parameters ---------- - mask: :class:`numpy.ndarray` + mask The mask to resize - dims: tuple[int, int] + dims The (height, width) target size Returns ------- - :class:`numpy.ndarray` - The resized mask, or the original mask if no resizing required + 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 + interpolator = cv2.INTER_AREA if mask.shape[0] > dims[0] else cv2.INTER_CUBIC - mask = cv2.resize(mask, tuple(reversed(dims)), interpolation=interp) + mask = cv2.resize(mask, tuple(reversed(dims)), interpolation=interpolator) return mask - def _store_mask_frame(self, media: ExtractMedia, mask: np.ndarray) -> None: - """ Store the mask when the input is frames + def _store_mask_frame(self, media: FrameFaces, mask: np.ndarray) -> None: + """Store the mask when the input is frames Parameters ---------- - media: :class:`~plugins.extract.extract_media.ExtractMedia` + media The extract media object containing the face(s) to import the mask for - mask: :class:`numpy.ndarray` + mask The mask loaded from disk """ assert self._alignments is not None @@ -380,12 +375,12 @@ def _store_mask_frame(self, media: ExtractMedia, mask: np.ndarray) -> None: idx, face.to_alignment()) - def import_mask(self, media: ExtractMedia) -> None: - """ Import the mask for the given Extract Media object + def import_mask(self, media: FrameFaces) -> None: + """Import the mask for the given Extract Media object Parameters ---------- - media: :class:`~plugins.extract.extract_media.ExtractMedia` + media The extract media object containing the face(s) to import the mask for """ mask_file = self._mapping.get(os.path.basename(media.filename)) diff --git a/tools/mask/mask_output.py b/tools/mask/mask_output.py index bf10f98681..139240f0af 100644 --- a/tools/mask/mask_output.py +++ b/tools/mask/mask_output.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Output processing for faceswap's mask tool """ +"""Output processing for faceswap's mask tool""" from __future__ import annotations import logging @@ -17,7 +17,7 @@ from lib.image import ImagesSaver, read_image_meta_batch from lib.utils import get_folder, get_module_objects -from scripts.fsmedia import Alignments as ExtractAlignments +from scripts.fs_media import Alignments as ExtractAlignments if T.TYPE_CHECKING: from lib import align @@ -28,15 +28,15 @@ class Output: - """ Handles outputting of masks for preview/editting to disk + """Handles outputting of masks for preview/editing to disk Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The command line arguments that the mask tool was called with - alignments: :class:`~lib.align.alignments.Alignments` | None + alignments The alignments file object (or ``None`` if not provided and input is faces) - file_list: list[str] + file_list Full file list for the loader. Used for extracting alignments from faces """ def __init__(self, arguments: Namespace, @@ -62,22 +62,21 @@ def __init__(self, arguments: Namespace, @property def should_save(self) -> bool: - """bool: ``True`` if mask images should be output otherwise ``False`` """ + """``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 + """Obtain a subfolder within the output folder to save the output based on selected output options. Parameters ---------- - output: str + output Full path to the root output folder Returns ------- - str: - The full path to where masks should be saved + The full path to where masks should be saved """ out_type = "frame" if self._full_frame else "face" retval = os.path.join(output, @@ -86,20 +85,18 @@ def _get_subfolder(self, output: str) -> str: return retval def _set_saver(self, output: str | None, processing: str) -> ImagesSaver | None: - """ set the saver in a background thread + """set the saver in a background thread Parameters ---------- - output: str + output Full path to the root output folder if provided - processing: str + processing 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 requested, returns a :class:`lib.image.ImagesSaver` otherwise returns ``None`` """ if output is None or not output: if processing == "output": @@ -115,20 +112,19 @@ def _set_saver(self, output: str | None, processing: str) -> ImagesSaver | None: def _get_alignments(self, alignments: align.alignments.Alignments | None, file_list: list[str]) -> align.alignments.Alignments | None: - """ Obtain the alignments file. If input is faces and full frame output is requested then + """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 + alignments The alignments file object (or ``None`` if not provided and input is faces) - file_list: list[str] + file_list 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`` + The alignments file if provided and/or is required otherwise ``None`` """ if alignments is not None or not self._full_frame: return alignments @@ -144,14 +140,13 @@ def _get_alignments(self, 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 = ExtractAlignments("/dummy/alignments.fsa", "", 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 + """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 @@ -159,15 +154,14 @@ def _get_background_frame(self, detected_faces: list[DetectedFace], frame_dims: Parameters ---------- - detected_faces: list[:class:`~lib.align.detected_face.DetectedFace`] + detected_faces Detected face objects for the output image - frame_dims: tuple[int, int] + frame_dims The size of the original frame Returns ------- - :class:`numpy.ndarray` - The full frame background image for applying masks to + The full frame background image for applying masks to """ if self._type == "mask": return np.zeros(frame_dims, dtype="uint8") @@ -199,24 +193,23 @@ 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 + """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 Detected face object for the output image - mask_centering: Literal["face", "head", "legacy"] + mask_centering The centering of the stored mask - mask_size: int + mask_size 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 + 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") @@ -230,12 +223,9 @@ def _get_background_face(self, 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, + centering=mask_centering, force=True) retval = detected_face.aligned.face @@ -247,23 +237,22 @@ def _get_background(self, 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 + """Obtain the background image that the final output will be placed on Parameters ---------- - detected_faces: list[:class:`~lib.align.detected_face.DetectedFace`] + detected_faces Detected face objects for the output image - frame_dims: tuple[int, int] + frame_dims The size of the original frame - mask_centering: Literal["face", "head", "legacy"] + mask_centering The centering of the stored mask - mask_size: int + mask_size The pixel size of the stored mask Returns ------- - :class:`numpy.ndarray` - The background image for the mask output + The background image for the mask output """ if self._full_frame: retval = self._get_background_frame(detected_faces, frame_dims) @@ -279,21 +268,20 @@ 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 + """Generate the mask to be applied to the final output frame Parameters ---------- - detected_faces: list[:class:`~lib.align.detected_face.DetectedFace`] + detected_faces Detected face objects to generate the masks from - mask_type: str + mask_type The mask-type to use - mask_dims : tuple[int, int] + mask_dims The size of the mask to output Returns ------- - :class:`numpy.ndarray` - The final mask to apply to the output image + The final mask to apply to the output image """ retval = np.zeros(mask_dims, dtype="uint8") for face in detected_faces: @@ -310,20 +298,19 @@ def _get_mask(self, 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 + """Collate the mask and images for the final output image, depending on selected output type Parameters ---------- - background: :class:`numpy.ndarray` + background The image that the mask will be applied to - mask: :class:`numpy.ndarray` + mask The mask to output Returns ------- - :class:`numpy.ndarray` - The final output image + The final output image """ if self._type == "mask": return mask @@ -346,24 +333,23 @@ 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 + """Create a mask preview image for saving out to disk Parameters ---------- - detected_faces: list[:class:`~lib.align.detected_face.DetectedFace`] + detected_faces Detected face objects for the output image - mask_type: str + mask_type The mask_type to process - frame_dims: tuple[int, int] | None + frame_dims 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 + 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], @@ -387,22 +373,21 @@ 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 + """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 + frame The frame name in the alignments file - idx: int + idx The index of the face for this frame in the alignments file - detected_face: :class:`~lib.align.detected_face.DetectedFace` + detected_face 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 + Face index and detected face objects to be processed for this output, if any """ if not self._full_frame: return [(idx, detected_face)] @@ -425,22 +410,21 @@ def _handle_cache(self, 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 + """Get the mask type names for the select mask type. Remove any detected faces where the selected mask does not exist Parameters ---------- - frame: str + frame The frame name in the alignments file - idx: int + idx The index of the face for this frame in the alignments file - detected_face: list[tuple[int, :class:`~lib.align.detected_face.DetectedFace`] + detected_face The face index and detected_face object for output Returns ------- - list[str] - List of mask type names to be processed + 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")] @@ -470,17 +454,17 @@ def save(self, idx: int, detected_face: DetectedFace, frame_dims: tuple[int, int] | None = None) -> None: - """ Build the mask preview image and save + """Build the mask preview image and save Parameters ---------- - frame: str + frame The frame name in the alignments file - idx: int + idx The index of the face for this frame in the alignments file - detected_face: :class:`~lib.align.detected_face.DetectedFace` + detected_face A detected_face object for a face - frame_dims: tuple[int, int] | None, optional + frame_dims The size of the original frame, if input is faces otherwise ``None``. Default: ``None`` """ assert self._saver is not None @@ -509,11 +493,12 @@ def save(self, 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 + logger.trace("filename: '%s', image_shape: %s", # type:ignore[attr-defined] + filename, image.shape) self._saver.save(filename, image) def close(self) -> None: - """ Shut down the image saver if it is open """ + """Shut down the image saver if it is open""" if self._saver is None: return logger.debug("Shutting down saver") diff --git a/tools/preview/control_panels.py b/tools/preview/control_panels.py index 6a6947515c..16b3006a1b 100644 --- a/tools/preview/control_panels.py +++ b/tools/preview/control_panels.py @@ -33,15 +33,14 @@ class ConfigTools(): Parameters ---------- - config_file : str | None + config_file Path to a custom config .ini file or ``None`` to load the default config file Attributes ---------- - tk_vars : dict[str, dict[str, tk.BooleanVar | tk.StringVar | tk.IntVar | tk.DoubleVar]]] + tk_vars Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar` """ - def __init__(self, config_file: str | None) -> None: logger.debug(parse_class_init(locals())) self._config = convert_config.load_config(config_file=config_file) @@ -50,20 +49,19 @@ def __init__(self, config_file: str | None) -> None: @property def config_dicts(self) -> dict[str, dict[str, ControlPanelOption]]: - """dict[str, dict[str, ControlPanelOption]] : The convert configuration options in - dictionary form.""" + """The convert configuration options in dictionary form.""" return self._config_dicts @property def sections(self) -> list[str]: - """list: The sorted section names that exist within the convert Configuration options.""" + """The sorted section names that exist within the convert Configuration options.""" return sorted(set(sect.split(".")[0] for sect in self._config.sections if sect.split(".")[0] != "writer")) @property def plugins_dict(self) -> dict[str, list[str]]: - """dict[str, list[str]] : Dictionary of configuration option sections as key with a list - of containing plugin names as the value""" + """Dictionary of configuration option sections as key with a list of containing plugin + names as the value""" return {section: sorted([sect.split(".")[1] for sect in self._config.sections if sect.split(".")[0] == section]) for section in self.sections} @@ -74,9 +72,8 @@ def _get_config_dicts(self) -> dict[str, dict[str, ControlPanelOption]]: Returns ------- - dict[str, str | dict[str, ControlPanelOption]] - Each configuration section as keys, with the values as a dict of option_name to - :class:`lib.gui.control_helper.ControlOption`.""" + Each configuration section as keys, with the values as a dict of option_name to + :class:`lib.gui.control_helper.ControlOption`.""" logger.debug("Formatting Config for GUI") config_dicts: dict[str, dict[str, ControlPanelOption]] = {} for section_name, section in self._config.sections.items(): @@ -118,7 +115,7 @@ def reset_config_to_saved(self, section: str | None = None) -> None: Parameters ---------- - section : str | None, optional + section The configuration section to reset the values for, If ``None`` provided then all sections are reset. Default: ``None`` """ @@ -138,7 +135,7 @@ def reset_config_to_default(self, section: str | None = None) -> None: Parameters ---------- - section : str | None, optional + section The configuration section to reset the values for, If ``None`` provided then all sections are reset. Default: ``None`` """ @@ -158,7 +155,7 @@ def save_config(self, section: str | None = None) -> None: Parameters ---------- - section : str | None, optional + section The configuration section to save, If ``None`` provided then all sections are saved. Default: ``None`` """ @@ -190,13 +187,12 @@ def _add_busy_indicator(self, parent: ttk.Frame) -> ttk.Progressbar: Parameters ---------- - parent: tkinter object + parent The tkinter object that holds the busy indicator Returns ------- - ttk.Progressbar - A Progress bar to indicate that the Preview tool is busy + A Progress bar to indicate that the Preview tool is busy """ logger.debug("Placing busy indicator") pbar = ttk.Progressbar(parent, mode="indeterminate") @@ -229,9 +225,9 @@ class ActionFrame(ttk.Frame): # pylint:disable=too-many-ancestors Parameters ---------- - app: :class:`Preview` + app The main tkinter Preview app - parent: tkinter object + parent The parent tkinter object that holds the Action Frame """ def __init__(self, app: Preview, parent: ttk.Frame) -> None: @@ -257,7 +253,7 @@ def __init__(self, app: Preview, parent: ttk.Frame) -> None: @property def convert_args(self) -> dict[str, T.Any]: - """dict: Currently selected Command line arguments from the :class:`ActionFrame`.""" + """Currently selected Command line arguments from the :class:`ActionFrame`.""" retval = {opt if opt != "color" else "color_adjustment": self._format_from_display(self._tk_vars[opt].get()) for opt in self._options if opt != "face_scale"} @@ -266,10 +262,7 @@ def convert_args(self) -> dict[str, T.Any]: @property def busy_progress_bar(self) -> BusyProgressBar: - """ - :class:`BusyProgressBar`: The progress bar that appears on the left hand side whilst a - swap/patch is being applied. - """ + """The progress bar on the left hand side whilst a swap/patch is being applied.""" return self._busy_bar @staticmethod @@ -278,13 +271,12 @@ def _format_from_display(var: str) -> str: Parameters ---------- - var: str + var The variable name to format Returns ------- - str - The formatted variable name + The formatted variable name """ return var.replace(" ", "_").lower() @@ -294,13 +286,12 @@ def _format_to_display(var: str) -> str: Parameters ---------- - var: str + var The variable name to format Returns ------- - str - The formatted variable name + The formatted variable name """ return var.replace("_", " ").replace("-", " ").title() @@ -314,21 +305,20 @@ def _build_frame(self, Parameters ---------- - defaults: dict + defaults The default command line options - patch_callback: python function + patch_callback The function to execute when a patch callback is received - refresh_callback: python function + refresh_callback The function to execute when a refresh callback is received - available_masks: list + available_masks The available masks that exist within the alignments file - has_predicted_mask: bool + has_predicted_mask Whether the model was trained with a mask Returns ------- - ttk.Progressbar - A Progress bar to indicate that the Preview tool is busy + A Progress bar to indicate that the Preview tool is busy """ logger.debug("Building Action frame") @@ -353,13 +343,13 @@ def _add_cli_choices(self, has_predicted_mask: bool) -> None: """Create :class:`lib.gui.control_helper.ControlPanel` object for the command line options. - parent: :class:`ttk.Frame` + parent The frame to hold the command line choices - defaults: dict + defaults The default command line options - available_masks: list + available_masks The available masks that exist within the alignments file - has_predicted_mask: bool + has_predicted_mask Whether the model was trained with a mask """ cp_options = self._get_control_panel_options(defaults, available_masks, has_predicted_mask) @@ -372,17 +362,16 @@ def _get_control_panel_options(self, has_predicted_mask: bool) -> list[ControlPanelOption]: """Create :class:`lib.gui.control_helper.ControlPanelOption` objects for the cli options. - defaults: dict + defaults The default command line options - available_masks: list + available_masks The available masks that exist within the alignments file - has_predicted_mask: bool + has_predicted_mask Whether the model was trained with a mask Returns ------- - list - The list of `lib.gui.control_helper.ControlPanelOption` objects for the Action Frame + The list of `lib.gui.control_helper.ControlPanelOption` objects for the Action Frame """ cp_options: list[ControlPanelOption] = [] for opt in self._options: @@ -419,11 +408,11 @@ def _create_mask_choices(self, Parameters ---------- - defaults: dict + defaults The default command line options - available_masks: list + available_masks The available masks that exist within the alignments file - has_predicted_mask: bool + has_predicted_mask Whether the model was trained with a mask Returns @@ -432,6 +421,7 @@ def _create_mask_choices(self, The masks that are available to use from the alignments file """ logger.debug("Initial mask choices: %s", available_masks) + available_masks += ["components", "extended"] if has_predicted_mask: available_masks += ["predicted"] if "none" not in available_masks: @@ -450,7 +440,7 @@ def _add_refresh_button(cls, Parameters ---------- - refresh_callback: python function + refresh_callback The function to execute when the refresh button is pressed """ btn = ttk.Button(parent, text="Update Samples", command=refresh_callback) @@ -461,7 +451,7 @@ def _add_patch_callback(self, patch_callback: Callable[[], None]) -> None: Parameters ---------- - patch_callback: python function + patch_callback The function to execute when the images require patching """ for tk_var in self._tk_vars.values(): @@ -472,7 +462,7 @@ def _add_actions(self, parent: ttk.Frame) -> None: Parameters ---------- - parent: tkinter object + parent The tkinter object that holds the action buttons """ logger.debug("Adding util buttons") @@ -508,16 +498,16 @@ class OptionsBook(ttk.Notebook): # pylint:disable=too-many-ancestors Parameters ---------- - parent: tkinter object + parent The parent tkinter object that holds the Options book - config_tools: :class:`ConfigTools` + config_tools Tools for loading and saving configuration files - patch_callback: python function + patch_callback The function to execute when a patch callback is received Attributes ---------- - config_tools: :class:`ConfigTools` + config_tools Tools for loading and saving configuration files """ def __init__(self, @@ -560,7 +550,7 @@ def _add_patch_callback(self, patch_callback: Callable[[], None]) -> None: Parameters ---------- - patch_callback: python function + patch_callback The function to execute when the images require patching """ for plugins in self.config_tools.tk_vars.values(): @@ -573,11 +563,11 @@ class ConfigFrame(ttk.Frame): # pylint:disable=too-many-ancestors Parameters ---------- - parent: tkinter object + parent The tkinter object that will hold this configuration frame - config_key: str + config_key The section/plugin key for these configuration options - options: dict + options The options for this section/plugin """ @@ -603,9 +593,9 @@ def _build_frame(self, parent: OptionsBook, config_key: str) -> None: Parameters ---------- - parent: tkinter object + parent The tkinter object that will hold this configuration frame - config_key: str + config_key The section/plugin key for these configuration options """ logger.debug("Add Config Frame") @@ -619,19 +609,19 @@ def _build_frame(self, parent: OptionsBook, config_key: str) -> None: def _add_frame_separator(self) -> None: """Add a separator between top and bottom frames.""" - logger.debug("Add frame seperator") + logger.debug("Add frame separator") sep = ttk.Frame(self._action_frame, height=2, relief=tk.RIDGE) sep.pack(fill=tk.X, pady=5, side=tk.TOP) - logger.debug("Added frame seperator") + logger.debug("Added frame separator") def _add_actions(self, parent: OptionsBook, config_key: str) -> None: """Add Action Buttons. Parameters ---------- - parent: tkinter object + parent The tkinter object that will hold this configuration frame - config_key: str + config_key The section/plugin key for these configuration options """ logger.debug("Adding util buttons") diff --git a/tools/preview/preview.py b/tools/preview/preview.py index 9e888867bb..ca1bcddaf4 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Tool to preview swaps and tweak configuration prior to running a convert """ +"""Tool to preview swaps and tweak configuration prior to running a convert """ from __future__ import annotations import gettext import logging @@ -18,14 +18,14 @@ from lib.align import DetectedFace from lib.cli.args_extract_convert import ConvertArgs from lib.gui.utils import get_images, get_config, initialize_config, initialize_images +from lib.infer.objects import FrameFaces from lib.convert import Converter -from lib.utils import get_module_objects, FaceswapError, handle_deprecated_cliopts +from lib.utils import get_module_objects, FaceswapError, handle_deprecated_cli_opts from lib.queue_manager import queue_manager -from scripts.fsmedia import Alignments, Images +# TODO this is the last reference to Images. Remove if possible: +from scripts.fs_media import Alignments, Images from scripts.convert import Predict, ConvertItem -from plugins.extract import ExtractMedia - from .control_panels import ActionFrame, ConfigTools, OptionsBook from .viewer import FacesDisplay, ImagesCanvas @@ -42,7 +42,7 @@ class Preview(tk.Tk): - """ This tool is part of the Faceswap Tools suite and should be called from + """This tool is part of the Faceswap Tools suite and should be called from ``python tools.py preview`` command. Loads up 5 semi-random face swaps and displays them, cropped, in place in the final frame. @@ -51,7 +51,7 @@ class Preview(tk.Tk): Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The :mod:`argparse` arguments as passed in from :mod:`tools.py` """ _w: str @@ -59,8 +59,8 @@ class Preview(tk.Tk): def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: '%s'", self.__class__.__name__, arguments) super().__init__() - arguments = handle_deprecated_cliopts(arguments) - self._config_tools = ConfigTools(arguments.configfile) + arguments = handle_deprecated_cli_opts(arguments) + self._config_tools = ConfigTools(arguments.config_file) self._lock = Lock() self._dispatcher = Dispatcher(self) self._display = FacesDisplay(self, 256, 64) @@ -75,43 +75,39 @@ def __init__(self, arguments: Namespace) -> None: @property def config_tools(self) -> "ConfigTools": - """ :class:`ConfigTools`: The object responsible for parsing configuration options and - updating to/from the GUI """ + """The object responsible for parsing configuration options and updating to/from the GUI""" return self._config_tools @property def dispatcher(self) -> "Dispatcher": - """ :class:`Dispatcher`: The object responsible for triggering events and variables and - handling global GUI state """ + """Responsible for triggering events and variables and handling global GUI state""" return self._dispatcher @property def display(self) -> FacesDisplay: - """ :class:`~tools.preview.viewer.FacesDisplay`: The object that holds the sample, - converted and patched faces """ + """The object that holds the sample, converted and patched faces""" return self._display @property def lock(self) -> Lock: - """ :class:`threading.Lock`: The threading lock object for the Preview GUI """ + """The threading lock object for the Preview GUI""" return self._lock @property def progress_bar(self) -> BusyProgressBar: - """ :class:`~tools.preview.control_panels.BusyProgressBar`: The progress bar that indicates - a swap/patch thread is running """ + """The progress bar that indicates a swap/patch thread is running""" assert self._cli_frame is not None return self._cli_frame.busy_progress_bar def update_display(self): - """ Update the images in the canvas and redraw """ + """Update the images in the canvas and redraw""" if not hasattr(self, "_image_canvas"): # On first call object not yet created return assert self._image_canvas is not None self._image_canvas.reload() def _initialize_tkinter(self) -> None: - """ Initialize a standalone tkinter instance. """ + """Initialize a standalone tkinter instance.""" logger.debug("Initializing tkinter") initialize_config(self, None, None) initialize_images() @@ -125,7 +121,7 @@ def _initialize_tkinter(self) -> None: logger.debug("Initialized tkinter") def process(self) -> None: - """ The entry point for the Preview tool from :file:`lib.tools.cli`. + """The entry point for the Preview tool from :file:`lib.tools.cli`. Launch the tkinter preview Window and run main loop. """ @@ -133,11 +129,11 @@ def process(self) -> None: self.mainloop() def _refresh(self, *args) -> None: - """ Patch faces with current convert settings. + """Patch faces with current convert settings. Parameters ---------- - *args: tuple + *args Unused, but required for tkinter callback. """ logger.debug("Patching swapped faces. args: %s", args) @@ -151,7 +147,7 @@ def _refresh(self, *args) -> None: logger.debug("Patched swapped faces") def _build_ui(self) -> None: - """ Build the elements for displaying preview images and options panels. """ + """Build the elements for displaying preview images and options panels.""" container = ttk.PanedWindow(self, orient=tk.VERTICAL) container.pack(fill=tk.BOTH, expand=True) @@ -170,32 +166,32 @@ def _build_ui(self) -> None: class Dispatcher(): - """ Handles the app level tk.Variables and the threading events. Dispatches events to the + """Handles the app level tk.Variables and the threading events. Dispatches events to the correct location and handles GUI state whilst events are handled Parameters ---------- - app: :class:`Preview` + app The main tkinter Preview app """ def __init__(self, app: Preview): logger.debug("Initializing %s: (app: %s)", self.__class__.__name__, app) self._app = app self._tk_busy = tk.BooleanVar(value=False) - self._evnt_needs_patch = Event() + self._event_needs_patch = Event() self._is_updating = False self._stacked_event = False logger.debug("Initialized %s", self.__class__.__name__) @property def needs_patch(self) -> Event: - """:class:`threading.Event`. Set by the parent and cleared by the child. Informs the child - patching thread that a run needs to be processed """ - return self._evnt_needs_patch + """Set by the parent and cleared by the child. Informs the child patching thread that a + run needs to be processed""" + return self._event_needs_patch # TKInter Variables def set_busy(self) -> None: - """ Set the tkinter busy variable to ``True`` and display the busy progress bar """ + """Set the tkinter busy variable to ``True`` and display the busy progress bar""" if self._tk_busy.get(): logger.debug("Busy event is already set. Doing nothing") return @@ -209,7 +205,7 @@ def set_busy(self) -> None: self._app.update_idletasks() def _unset_busy(self) -> None: - """ Set the tkinter busy variable to ``False`` and hide the busy progress bar """ + """Set the tkinter busy variable to ``False`` and hide the busy progress bar""" self._is_updating = False if not self._tk_busy.get(): logger.debug("busy unset when already unset. Doing nothing") @@ -221,10 +217,10 @@ def _unset_busy(self) -> None: # Threading Events def _wait_for_patch(self) -> None: - """ Wait for a patch thread to complete before triggering a display refresh and unsetting - the busy indicators """ + """Wait for a patch thread to complete before triggering a display refresh and unsetting + the busy indicators""" logger.debug("Checking for patch completion...") - if self._evnt_needs_patch.is_set(): + if self._event_needs_patch.is_set(): logger.debug("Samples not patched. Waiting...") self._app.after(1000, self._wait_for_patch) return @@ -241,20 +237,20 @@ def _wait_for_patch(self) -> None: return def set_needs_patch(self) -> None: - """ Sends a trigger to the patching thread that it needs to be run. Waits for the patching - to complete prior to triggering a display refresh and unsetting the busy indicators """ + """Sends a trigger to the patching thread that it needs to be run. Waits for the patching + to complete prior to triggering a display refresh and unsetting the busy indicators""" if self._is_updating: logger.debug("Request to run patch when it is already running. Adding stacked event.") self._stacked_event = True return self._is_updating = True logger.debug("Triggering patch") - self._evnt_needs_patch.set() + self._event_needs_patch.set() self._wait_for_patch() class Samples(): - """ The display samples. + """The display samples. Obtains and holds :attr:`sample_size` semi random test faces for displaying in the preview GUI. @@ -265,11 +261,11 @@ class Samples(): Parameters ---------- - app: :class:`Preview` + app The main tkinter Preview app - arguments: :class:`argparse.Namespace` + arguments The :mod:`argparse` arguments as passed in from :mod:`tools.py` - sample_size: int + sample_size The number of samples to take from the input video/images """ @@ -282,14 +278,10 @@ def __init__(self, app: Preview, arguments: Namespace, sample_size: int) -> None self._predicted_images: list[tuple[ConvertItem, np.ndarray]] = [] self._images = Images(arguments) - self._alignments = Alignments(arguments, + self._alignments = Alignments(arguments.alignments_path, + arguments.input_dir, is_extract=False, input_is_video=self._images.is_video) - if self._alignments.version == 1.0: - logger.error("The alignments file format has been updated since the given alignments " - "file was generated. You need to update the file to proceed.") - logger.error("To do this run the 'Alignments Tool' > 'Extract' Job.") - sys.exit(1) if not self._alignments.have_alignments_file: logger.error("Alignments file not found at: '%s'", self._alignments.file) @@ -311,7 +303,7 @@ def __init__(self, app: Preview, arguments: Namespace, sample_size: int) -> None @property def available_masks(self) -> list[str]: - """ list: The mask names that are available for every face in the alignments file """ + """The mask names that are available for every face in the alignments file""" retval = [key for key, val in self.alignments.mask_summary.items() if val == self.alignments.faces_count] @@ -319,33 +311,33 @@ def available_masks(self) -> list[str]: @property def sample_size(self) -> int: - """ int: The number of samples to take from the input video/images """ + """The number of samples to take from the input video/images""" return self._sample_size @property def predicted_images(self) -> list[tuple[ConvertItem, np.ndarray]]: - """ list: The predicted faces output from the Faceswap model """ + """The predicted faces output from the Faceswap model""" return self._predicted_images @property def alignments(self) -> Alignments: - """ :class:`~lib.align.Alignments`: The alignments for the preview faces """ + """The alignments for the preview faces""" return self._alignments @property def predictor(self) -> Predict: - """ :class:`~scripts.convert.Predict`: The Predictor for the Faceswap model """ + """The Predictor for the Faceswap model""" return self._predictor @property def _random_choice(self) -> list[int]: - """ list: Random indices from the :attr:`_indices` group """ + """Random indices from the :attr:`_indices` group""" retval = [random.choice(indices) for indices in self._indices] logger.debug(retval) return retval def _get_filelist(self) -> list[str]: - """ Get a list of files for the input, filtering out those frames which do + """Get a list of files for the input, filtering out those frames which do not contain faces. Returns @@ -374,15 +366,14 @@ def _get_filelist(self) -> list[str]: return retval def _get_indices(self) -> list[list[int]]: - """ Get indices for each sample group. + """Get indices for each sample group. Obtain :attr:`self.sample_size` evenly sized groups of indices pertaining to the filtered :attr:`self._file_list` Returns ------- - list - list of indices relating to the filtered file list, split into groups + list of indices relating to the filtered file list, split into groups """ # Remove start and end values to get a list divisible by self.sample_size no_files = len(self._filelist) @@ -400,7 +391,7 @@ def _get_indices(self) -> list[list[int]]: return retval def generate(self) -> None: - """ Generate a sample set. + """Generate a sample set. Selects :attr:`sample_size` random faces. Runs them through prediction to obtain the swap, then trigger the patch event to run the faces through patching. @@ -413,7 +404,7 @@ def generate(self) -> None: logger.debug("Generated new random samples") def _load_frames(self) -> None: - """ Load a sample of random frames. + """Load a sample of random frames. * Picks a random face from each indices group. @@ -431,7 +422,8 @@ def _load_frames(self) -> None: face = self._alignments.get_faces_in_frame(filename)[0] detected_face = DetectedFace() detected_face.from_alignment(face, image=image) - inbound = ExtractMedia(filename=filename, image=image, detected_faces=[detected_face]) + inbound = FrameFaces(filename=filename, image=image) + inbound.detected_faces = [detected_face] self._input_images.append(ConvertItem(inbound=inbound)) self._app.display.source = self._input_images self._app.display.update_source = True @@ -439,7 +431,7 @@ def _load_frames(self) -> None: [frame.inbound.filename for frame in self._input_images]) def _predict(self) -> None: - """ Predict from the loaded frames. + """Predict from the loaded frames. With a threading lock (to prevent stacking), run the selected faces through the Faceswap model predict function and add the output to :attr:`predicted` @@ -464,21 +456,21 @@ def _predict(self) -> None: class Patch(): - """ The Patch pipeline + """The Patch pipeline Runs in it's own thread. Takes the output from the Faceswap model predictor and runs the faces through the convert pipeline using the currently selected options. Parameters ---------- - app: :class:`Preview` + app The main tkinter Preview app - arguments: :class:`argparse.Namespace` + arguments The :mod:`argparse` arguments as passed in from :mod:`tools.py` Attributes ---------- - converter_arguments: dict + converter_arguments The currently selected converter command line arguments for the patch queue """ def __init__(self, app: Preview, arguments: Namespace) -> None: @@ -488,7 +480,7 @@ def __init__(self, app: Preview, arguments: Namespace) -> None: self._queue_patch_in = queue_manager.get_queue("preview_patch_in") self.converter_arguments: dict[str, T.Any] | None = None # Updated converter args - configfile = arguments.configfile if hasattr(arguments, "configfile") else None + config_file = arguments.config_file if hasattr(arguments, "config_file") else None self._converter = Converter(output_size=app._samples.predictor.output_size, coverage_ratio=app._samples.predictor.coverage_ratio, centering=app._samples.predictor.centering, @@ -497,7 +489,7 @@ def __init__(self, app: Preview, arguments: Namespace) -> None: arguments=self._generate_converter_arguments( arguments, app._samples.available_masks), - configfile=configfile) + config_file=config_file) self._thread = Thread(target=self._process, name="patch_thread", args=(self._queue_patch_in, @@ -509,26 +501,25 @@ def __init__(self, app: Preview, arguments: Namespace) -> None: @property def converter(self) -> Converter: - """ :class:`lib.convert.Converter`: The converter to use for patching the images. """ + """The converter to use for patching the images.""" return self._converter @staticmethod def _generate_converter_arguments(arguments: Namespace, available_masks: list[str]) -> Namespace: - """ Add the default converter arguments to the initial arguments. Ensure the mask selection + """Add the default converter arguments to the initial arguments. Ensure the mask selection is available. Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The :mod:`argparse` arguments as passed in from :mod:`tools.py` available_masks: list The masks that are available for convert + Returns ---------- - arguments: :class:`argparse.Namespace` - The :mod:`argparse` arguments as passed in with converter default - arguments added + The :mod:`argparse` arguments as passed in with converter default arguments added """ valid_masks = available_masks + ["none"] converter_arguments = ConvertArgs(None, "convert").get_optional_arguments() # type: ignore @@ -553,18 +544,18 @@ def _process(self, patch_queue_in: EventQueue, trigger_event: Event, samples: Samples) -> None: - """ The face patching process. + """The face patching process. Runs in a thread, and waits for an event to be set. Once triggered, runs a patching cycle and sets the :class:`Display` destination images. Parameters ---------- - patch_queue_in: :class:`~lib.queue_manager.EventQueue` + patch_queue_in The input queue for the patching process - trigger_event: :class:`threading.Event` + trigger_event The event that indicates a patching run needs to be processed - samples: :class:`Samples` + samples The Samples for display. """ logger.debug("Launching patch process thread: (patch_queue_in: %s, trigger_event: %s, " @@ -590,7 +581,7 @@ def _process(self, logger.debug("Closed patch process thread") def _update_converter_arguments(self) -> None: - """ Update the converter arguments to the currently selected values. """ + """Update the converter arguments to the currently selected values.""" logger.debug("Updating Converter cli arguments") if self.converter_arguments is None: logger.debug("No arguments to update") @@ -602,13 +593,13 @@ def _update_converter_arguments(self) -> None: @staticmethod def _feed_swapped_faces(patch_queue_in: EventQueue, samples: Samples) -> None: - """ Feed swapped faces to the converter's in-queue. + """Feed swapped faces to the converter's in-queue. Parameters ---------- - patch_queue_in: :class:`~lib.queue_manager.EventQueue` + patch_queue_in The input queue for the patching process - samples: :class:`Samples` + samples The Samples for display. """ logger.debug("feeding swapped faces to converter") @@ -623,21 +614,20 @@ def _patch_faces(self, queue_in: EventQueue, queue_out: EventQueue, sample_size: int) -> list[np.ndarray]: - """ Patch faces. + """Patch faces. Run the convert process on the swapped faces and return the patched faces. - patch_queue_in: :class:`~lib.queue_manager.EventQueue` + patch_queue_in The input queue for the patching process - queue_out: :class:`~lib.queue_manager.EventQueue` + queue_out The output queue from the patching process - sample_size: int + sample_size The number of samples to be displayed Returns ------- - list - The swapped faces patched with the selected convert settings + The swapped faces patched with the selected convert settings """ logger.debug("Patching faces") self._converter.process(queue_in, queue_out) diff --git a/tools/sort/cli.py b/tools/sort/cli.py index 607bd68a2e..f5ac8aeef3 100644 --- a/tools/sort/cli.py +++ b/tools/sort/cli.py @@ -5,6 +5,7 @@ from lib.cli.args import FaceSwapArgs from lib.cli.actions import DirFullPaths, SaveFileFullPaths, Radio, Slider from lib.utils import get_module_objects +from plugins.plugin_loader import PluginLoader # pylint:disable=duplicate-code @@ -202,6 +203,20 @@ def get_argument_list(): "increment. Folder 0 will contain faces looking the most to the left/down whereas " "the last folder will contain the faces looking the most to the right/up. NB: " "Some bins may be empty if faces do not fit the criteria. \nDefault value: 5")}) + argument_list.append({ + "opts": ('-I', '--identity'), + "action": Radio, + "type": str, + "choices": PluginLoader.get_available_extractors("identity"), + "group": _("settings"), + "default": "t-face", + "help": _( + "R|The identity plugin to use when sorting/grouping by face. " + "\nL|t-face: An InsightFace ResNet based model with a lighter and heavier variant " + "(configurable in settings)." + "\nL|vggface2: An older and lighter, but fairly reliable plugin based on the VGG " + "Network." + "\nDefault: t-face")}) argument_list.append({ "opts": ('-l', '--log-changes'), "action": 'store_true', diff --git a/tools/sort/info_loader.py b/tools/sort/info_loader.py new file mode 100644 index 0000000000..a82f658487 --- /dev/null +++ b/tools/sort/info_loader.py @@ -0,0 +1,194 @@ +"""Loads images with metadata from disk for the sort tool""" +from __future__ import annotations + +import logging +import sys +import typing as T +from collections.abc import Generator + +import numpy as np +from tqdm import tqdm + +from lib.image import FacesLoader, ImagesLoader, read_image_meta_batch, update_existing_metadata +from lib.utils import get_module_objects + +if T.TYPE_CHECKING: + from lib.align.alignments import PNGHeaderAlignmentsDict, PNGHeaderSourceDict + +logger = logging.getLogger(__name__) + + +ImgMetaType: T.TypeAlias = Generator[tuple[str, + np.ndarray | None, + T.Union["PNGHeaderAlignmentsDict", None]], None, None] + + +class InfoLoader(): + """Loads aligned faces and/or face metadata + + Parameters + ---------- + input_dir + Full path to containing folder of faces to be supported + loader_type + Dictates the type of iterator that will be used. "face" just loads the image with the + filename, "meta" just loads the image alignment data with the filename. "all" loads + the image and the alignment data with the filename + """ + def __init__(self, + input_dir: str, + info_type: T.Literal["face", "meta", "all"]) -> None: + logger.debug("Initializing: %s (input_dir: %s, info_type: %s)", + self.__class__.__name__, input_dir, info_type) + self._info_type = info_type + self._iterator = None + self._description = "Reading image statistics..." + self._loader = ImagesLoader(input_dir) if info_type == "face" else FacesLoader(input_dir) + self.cached_source_data: dict[str, PNGHeaderSourceDict] = {} + """The source data read from the PNG header for each processed face""" + if self._loader.count == 0: + logger.error("No images to process in location: '%s'", input_dir) + sys.exit(1) + + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def filelist_count(self) -> int: + """The number of files to be processed """ + return len(self._loader.file_list) + + def _get_iterator(self) -> ImgMetaType: + """Obtain the iterator for the selected :attr:`info_type`. + + Returns + ------- + The correct generator for the given info_type + """ + if self._info_type == "all": + return self._full_data_reader() + if self._info_type == "meta": + return self._metadata_reader() + return self._image_data_reader() + + def __call__(self) -> ImgMetaType: + """Return the selected iterator + + The resulting generator: + + Yields + ------ + filename + The filename that has been read + image + The aligned face image loaded from disk for 'face' and 'all' info_types + otherwise ``None`` + alignments + The alignments dict for 'all' and 'meta' infor_types otherwise ``None`` + """ + iterator = self._get_iterator() + return iterator + + def _get_alignments(self, + filename: str, + metadata: dict[str, T.Any]) -> PNGHeaderAlignmentsDict | None: + """Obtain the alignments from a PNG Header. + + The other image metadata is cached locally in case a sort method needs to write back to the + PNG header + + Parameters + ---------- + filename + Full path to the image PNG file + metadata + The header data from a PNG file + + Returns + ------- + The alignments dictionary from the PNG header, if it exists, otherwise ``None`` + """ + if not metadata or not metadata.get("alignments") or not metadata.get("source"): + return None + self.cached_source_data[filename] = metadata["source"] + return metadata["alignments"] + + def _metadata_reader(self) -> ImgMetaType: + """Load metadata from saved aligned faces + + Yields + ------ + filename + The filename that has been read + image + This will always be ``None`` with the metadata reader + alignments + The alignment data for the given face or ``None`` if no alignments found + """ + for filename, metadata in tqdm(read_image_meta_batch(self._loader.file_list), + total=self._loader.count, + desc=self._description, + leave=False): + alignments = self._get_alignments(filename, metadata.get("itxt", {})) + yield filename, None, alignments + + def _full_data_reader(self) -> ImgMetaType: + """Load the image and metadata from a folder of aligned faces + + Yields + ------ + filename + The filename that has been read + image + The aligned face image loaded from disk + alignments + The alignment data for the given face or ``None`` if no alignments found + """ + for filename, image, metadata in tqdm(self._loader.load(), + desc=self._description, + total=self._loader.count, + leave=False): + alignments = self._get_alignments(filename, metadata) + yield filename, image, alignments + + def _image_data_reader(self) -> ImgMetaType: + """Just loads the images with their filenames + + Yields + ------ + filename + The filename that has been read + image + The aligned face image loaded from disk + alignments + Alignments will always be ``None`` with the image data reader + """ + for filename, image in tqdm(self._loader.load(), + desc=self._description, + total=self._loader.count, + leave=False): + yield filename, image, None + + def update_png_header(self, filename: str, alignments: PNGHeaderAlignmentsDict) -> None: + """Update the PNG header of the given file with the given alignments. + + NB: Header information can only be updated if the face is already on at least alignment + version 2.2. If below this version, then the header is not updated + + + Parameters + ---------- + filename + Full path to the PNG file to update + alignments: dict + The alignments to update into the PNG header + """ + vers = self.cached_source_data[filename]["alignments_version"] + if vers < 2.2: + return + + self.cached_source_data[filename]["alignments_version"] = 2.3 if vers == 2.2 else vers + header = {"alignments": alignments, "source": self.cached_source_data[filename]} + update_existing_metadata(filename, header) + + +__all__ = get_module_objects(__name__) diff --git a/tools/sort/sort.py b/tools/sort/sort.py index 80dfc9566e..b3e9ee02ff 100644 --- a/tools/sort/sort.py +++ b/tools/sort/sort.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 -""" -A tool that allows for sorting and grouping images in different ways. -""" +"""A tool that allows for sorting and grouping images in different ways.""" from __future__ import annotations import logging import os @@ -15,7 +13,7 @@ # faceswap imports from lib.serializer import Serializer, get_serializer_from_filename -from lib.utils import get_module_objects, handle_deprecated_cliopts +from lib.utils import get_module_objects, handle_deprecated_cli_opts from .sort_methods import SortBlur, SortColor, SortFace, SortHistogram, SortMultiMethod from .sort_methods_aligned import SortDistance, SortFaceCNN, SortPitch, SortSize, SortYaw, SortRoll @@ -27,30 +25,29 @@ class Sort(): - """ Sorts folders of faces based on input criteria + """Sorts folders of faces based on input criteria Wrapper for the sort process to run in either batch mode or single use mode Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The arguments to be passed to the extraction process as generated from Faceswap's command line arguments """ def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing: %s (args: %s)", self.__class__.__name__, arguments) - self._args = handle_deprecated_cliopts(arguments) + self._args = handle_deprecated_cli_opts(arguments) self._input_locations = self._get_input_locations() logger.debug("Initialized: %s", self.__class__.__name__) def _get_input_locations(self) -> list[str]: - """ Obtain the full path to input locations. Will be a list of locations if batch mode is + """Obtain the full path to input locations. Will be a list of locations if batch mode is selected, or a containing a single location if batch mode is not selected. Returns ------- - list: - The list of input location paths + The list of input location paths """ if not self._args.batch_mode: return [self._args.input_dir] @@ -62,7 +59,7 @@ def _get_input_locations(self) -> list[str]: return retval def _output_for_input(self, input_location: str) -> str: - """ Obtain the path to an output folder for faces for a given input location. + """Obtain the path to an output folder for faces for a given input location. If not running in batch mode, then the user supplied output location will be returned, otherwise a sub-folder within the user supplied output location will be returned based on @@ -70,7 +67,7 @@ def _output_for_input(self, input_location: str) -> str: Parameters ---------- - input_location: str + input_location The full path to an input video or folder of images """ if not self._args.batch_mode or self._args.output_dir is None: @@ -81,7 +78,7 @@ def _output_for_input(self, input_location: str) -> str: return retval def process(self) -> None: - """ The entry point for triggering the Sort Process. + """The entry point for triggering the Sort Process. Should only be called from :class:`lib.cli.launcher.ScriptExecutor` """ @@ -102,7 +99,7 @@ def process(self) -> None: class _Sort(): - """ Sorts folders of faces based on input criteria """ + """Sorts folders of faces based on input criteria""" def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: arguments: %s", self.__class__.__name__, arguments) self._processes = {"blur": SortBlur, @@ -134,16 +131,16 @@ def __init__(self, arguments: Namespace) -> None: logger.debug("Initialized %s", self.__class__.__name__) def _set_output_folder(self, arguments): - """ Set the output folder correctly if it has not been provided + """Set the output folder correctly if it has not been provided + Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The command line arguments passed to the sort process Returns ------- - :class:`argparse.Namespace` - The command line arguments with output folder correctly set + The command line arguments with output folder correctly set """ logger.debug("setting output folder: %s", arguments.output_dir) input_dir = arguments.input_dir @@ -168,17 +165,16 @@ def _set_output_folder(self, arguments): return arguments def _parse_arguments(self, arguments): - """ Parse the arguments and update/format relevant choices + """Parse the arguments and update/format relevant choices Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The command line arguments passed to the sort process Returns ------- - :class:`argparse.Namespace` - The formatted command line arguments + The formatted command line arguments """ logger.debug("Cleaning arguments: %s", arguments) if arguments.sort_method == "none" and arguments.group_method == "none": @@ -199,12 +195,11 @@ def _parse_arguments(self, arguments): return arguments def _get_sorter(self) -> SortMethod: - """ Obtain a sorter/grouper combo for the selected sort/group by options + """Obtain a sorter/grouper combo for the selected sort/group by options Returns ------- - :class:`SortMethod` - The sorter or combined sorter for sorting and grouping based on user selections + The sorter or combined sorter for sorting and grouping based on user selections """ sort_method = self._args.sort_method group_method = self._args.group_method @@ -226,12 +221,12 @@ def _get_sorter(self) -> SortMethod: return retval def _write_to_log(self, changes): - """ Write the changes to log file """ + """Write the changes to log file """ logger.info("Writing sort log to: '%s'", self._args.log_file_path) self.serializer.save(self._args.log_file_path, changes) def process(self) -> None: - """ Main processing function of the sort tool + """Main processing function of the sort tool This method dynamically assigns the functions that will be used to run the core process of sorting, optionally grouping, renaming/moving into @@ -249,14 +244,14 @@ def process(self) -> None: logger.info("Done.") def _sort_file(self, source: str, destination: str) -> None: - """ Copy or move a file based on whether 'keep original' has been selected and log changes + """Copy or move a file based on whether 'keep original' has been selected and log changes if required. Parameters ---------- - source: str + source The full path to the source file that is being sorted - destination: str + destination The full path to where the source file should be moved/renamed """ try: @@ -272,7 +267,7 @@ def _sort_file(self, source: str, destination: str) -> None: self._changes[source] = destination def _output_groups(self) -> None: - """ Move the files to folders. + """Move the files to folders. Obtains the bins and original filenames from :attr:`_sorter` and outputs into appropriate bins in the output location @@ -314,7 +309,7 @@ def _output_groups(self) -> None: # Output methods def _output_non_grouped(self) -> None: - """ Output non-grouped files. + """Output non-grouped files. These are files which are sorted but not binned, so just the filename gets updated """ diff --git a/tools/sort/sort_methods.py b/tools/sort/sort_methods.py index 273f7fe8be..c23adca58a 100644 --- a/tools/sort/sort_methods.py +++ b/tools/sort/sort_methods.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -""" Sorting methods for the sorting tool. +"""Sorting methods for the sorting tool. -All sorting methods inherit from :class:`SortMethod` and control functions for scorting one item, +All sorting methods inherit from :class:`SortMethod` and control functions for sorting one item, sorting a full list of scores and binning based on those sorted scores. """ from __future__ import annotations @@ -9,211 +9,41 @@ import operator import sys import typing as T - -from collections.abc import Generator +from queue import Queue import cv2 import numpy as np from tqdm import tqdm -from lib.align import AlignedFace, DetectedFace, LandmarkType -from lib.image import FacesLoader, ImagesLoader, read_image_meta_batch, update_existing_metadata +from lib.align import DetectedFace, LandmarkType +from lib.multithreading import FSThread from lib.utils import get_module_objects, FaceswapError -from plugins.extract.recognition.vgg_face2 import Cluster, Recognition as VGGFace +from lib.infer.identity import Cluster, Identity + +from .info_loader import InfoLoader if T.TYPE_CHECKING: from argparse import Namespace - from lib.align.alignments import PNGHeaderAlignmentsDict, PNGHeaderSourceDict + import numpy.typing as npt + from lib.align.alignments import PNGHeaderAlignmentsDict + from lib.infer.runner import ExtractRunner + from lib.infer.handler import ExtractHandlerFace logger = logging.getLogger(__name__) -ImgMetaType: T.TypeAlias = Generator[tuple[str, - np.ndarray | None, - T.Union["PNGHeaderAlignmentsDict", None]], None, None] - - -class InfoLoader(): - """ Loads aligned faces and/or face metadata - - Parameters - ---------- - input_dir: str - Full path to containing folder of faces to be supported - loader_type: ["face", "meta", "all"] - Dictates the type of iterator that will be used. "face" just loads the image with the - filename, "meta" just loads the image alignment data with the filename. "all" loads - the image and the alignment data with the filename - """ - def __init__(self, - input_dir: str, - info_type: T.Literal["face", "meta", "all"]) -> None: - logger.debug("Initializing: %s (input_dir: %s, info_type: %s)", - self.__class__.__name__, input_dir, info_type) - self._info_type = info_type - self._iterator = None - self._description = "Reading image statistics..." - self._loader = ImagesLoader(input_dir) if info_type == "face" else FacesLoader(input_dir) - self._cached_source_data: dict[str, PNGHeaderSourceDict] = {} - if self._loader.count == 0: - logger.error("No images to process in location: '%s'", input_dir) - sys.exit(1) - - logger.debug("Initialized: %s", self.__class__.__name__) - - @property - def filelist_count(self) -> int: - """ int: The number of files to be processed """ - return len(self._loader.file_list) - - def _get_iterator(self) -> ImgMetaType: - """ Obtain the iterator for the selected :attr:`info_type`. - - Returns - ------- - generator - The correct generator for the given info_type - """ - if self._info_type == "all": - return self._full_data_reader() - if self._info_type == "meta": - return self._metadata_reader() - return self._image_data_reader() - - def __call__(self) -> ImgMetaType: - """ Return the selected iterator - - The resulting generator: - - Yields - ------ - filename: str - The filename that has been read - image: :class:`numpy.ndarray or ``None`` - The aligned face image loaded from disk for 'face' and 'all' info_types - otherwise ``None`` - alignments: dict or ``None`` - The alignments dict for 'all' and 'meta' infor_types otherwise ``None`` - """ - iterator = self._get_iterator() - return iterator - - def _get_alignments(self, - filename: str, - metadata: dict[str, T.Any]) -> PNGHeaderAlignmentsDict | None: - """ Obtain the alignments from a PNG Header. - - The other image metadata is cached locally in case a sort method needs to write back to the - PNG header - - Parameters - ---------- - filename: str - Full path to the image PNG file - metadata: dict - The header data from a PNG file - - Returns - ------- - dict or ``None`` - The alignments dictionary from the PNG header, if it exists, otherwise ``None`` - """ - if not metadata or not metadata.get("alignments") or not metadata.get("source"): - return None - self._cached_source_data[filename] = metadata["source"] - return metadata["alignments"] - - def _metadata_reader(self) -> ImgMetaType: - """ Load metadata from saved aligned faces - - Yields - ------ - filename: str - The filename that has been read - image: None - This will always be ``None`` with the metadata reader - alignments: dict or ``None`` - The alignment data for the given face or ``None`` if no alignments found - """ - for filename, metadata in tqdm(read_image_meta_batch(self._loader.file_list), - total=self._loader.count, - desc=self._description, - leave=False): - alignments = self._get_alignments(filename, metadata.get("itxt", {})) - yield filename, None, alignments - - def _full_data_reader(self) -> ImgMetaType: - """ Load the image and metadata from a folder of aligned faces - - Yields - ------ - filename: str - The filename that has been read - image: :class:`numpy.ndarray - The aligned face image loaded from disk - alignments: dict or ``None`` - The alignment data for the given face or ``None`` if no alignments found - """ - for filename, image, metadata in tqdm(self._loader.load(), - desc=self._description, - total=self._loader.count, - leave=False): - alignments = self._get_alignments(filename, metadata) - yield filename, image, alignments - - def _image_data_reader(self) -> ImgMetaType: - """ Just loads the images with their filenames - - Yields - ------ - filename: str - The filename that has been read - image: :class:`numpy.ndarray - The aligned face image loaded from disk - alignments: ``None`` - Alignments will always be ``None`` with the image data reader - """ - for filename, image in tqdm(self._loader.load(), - desc=self._description, - total=self._loader.count, - leave=False): - yield filename, image, None - - def update_png_header(self, filename: str, alignments: PNGHeaderAlignmentsDict) -> None: - """ Update the PNG header of the given file with the given alignments. - - NB: Header information can only be updated if the face is already on at least alignment - version 2.2. If below this version, then the header is not updated - - - Parameters - ---------- - filename: str - Full path to the PNG file to update - alignments: dict - The alignments to update into the PNG header - """ - vers = self._cached_source_data[filename]["alignments_version"] - if vers < 2.2: - return - - self._cached_source_data[filename]["alignments_version"] = 2.3 if vers == 2.2 else vers - header = {"alignments": alignments, "source": self._cached_source_data[filename]} - update_existing_metadata(filename, header) - - class SortMethod(): - """ Parent class for sort methods. All sort methods should inherit from this class + """Parent class for sort methods. All sort methods should inherit from this class Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The command line arguments passed to the sort process - loader_type: ["face", "meta", "all"] + loader_type The type of image loader to use. "face" just loads the image with the filename, "meta" just loads the image alignment data with the filename. "all" loads the image and the alignment data with the filename - is_group: bool, optional + is_group Set to ``True`` if this class is going to be called exclusively for binning. Default: ``False`` """ @@ -232,7 +62,7 @@ def __init__(self, self._num_bins: int = arguments.num_bins self._bin_names: list[str] = [] - self._loader_type = loader_type + self._loader_type: T.Literal["face", "meta", "all"] = loader_type self._iterator = self._get_file_iterator(arguments.input_dir) self._result: list[tuple[str, float | np.ndarray]] = [] @@ -241,12 +71,12 @@ def __init__(self, @property def loader_type(self) -> T.Literal["face", "meta", "all"]: - """ ["face", "meta", "all"]: The loader that this sorter uses """ + """ ["face", "meta", "all"]: The loader that this sorter uses""" return self._loader_type @property def binned(self) -> list[list[str]]: - """ list: List of bins (list) containing the filenames belonging to the bin. The binning + """List of bins (list) containing the filenames belonging to the bin. The binning process is called when this property is first accessed""" if not self._binned: self._binned = self._binning() @@ -255,8 +85,8 @@ def binned(self) -> list[list[str]]: @property def sorted_filelist(self) -> list[str]: - """ list: List of sorted filenames for given sorter in a single list. The sort process is - called when this property is first accessed """ + """List of sorted filenames for given sorter in a single list. The sort process is + called when this property is first accessed""" if not self._result: self._sort_filelist() retval = [item[0] for item in self._result] @@ -267,34 +97,32 @@ def sorted_filelist(self) -> list[str]: @property def bin_names(self) -> list[str]: - """ list: The name of each created bin, if they exist, otherwise an empty list """ + """The name of each created bin, if they exist, otherwise an empty list""" return self._bin_names def _get_file_iterator(self, input_dir: str) -> InfoLoader: - """ Override for method specific iterators. + """Override for method specific iterators. Parameters ---------- - input_dir: str + input_dir Full path to containing folder of faces to be supported Returns ------- - :class:`InfoLoader` - The correct InfoLoader iterator for the current sort method + The correct InfoLoader iterator for the current sort method """ return InfoLoader(input_dir, self.loader_type) def _sort_filelist(self) -> None: - """ Call the sort method's logic to populate the :attr:`_results` attribute. + """Call the sort method's logic to populate the :attr:`_results` attribute. Put logic for scoring an individual frame in in :attr:`score_image` of the child Returns ------- - list - The sorted file. A list of tuples with the filename in the first position and score in - the second position + The sorted file. A list of tuples with the filename in the first position and score in + the second position """ for filename, image, alignments in self._iterator(): self.score_image(filename, image, alignments) @@ -305,20 +133,19 @@ def _sort_filelist(self) -> None: @classmethod def _get_unique_labels(cls, numbers: np.ndarray) -> list[str]: - """ For a list of threshold values for displaying in the bin name, get the lowest number of + """For a list of threshold values for displaying in the bin name, get the lowest number of decimal figures (down to int) required to have a unique set of folder names and return the formatted numbers. Parameters ---------- - numbers: :class:`numpy.ndarray` + numbers The list of floating point threshold numbers being used as boundary points Returns ------- - list[str] - The string formatted numbers at the lowest precision possible to represent them - uniquely + The string formatted numbers at the lowest precision possible to represent them + uniquely """ i = 0 while True: @@ -338,25 +165,24 @@ def _get_unique_labels(cls, numbers: np.ndarray) -> list[str]: return retval def _binning_linear_threshold(self, units: str = "", multiplier: int = 1) -> list[list[str]]: - """ Standard linear binning method for binning by threshold. + """Standard linear binning method for binning by threshold. The minimum and maximum result from :attr:`_result` are taken, A range is created between these min and max values and is divided to get the number of bins to hold the data Parameters ---------- - units, str, optional + units The units to use for the bin name for displaying the threshold values. This this should correspond the value in position 1 of :attr:`_result`. Default: "" (no units) - multiplier: int, optional + multiplier The amount to multiply the contents in position 1 of :attr:`_results` for displaying in the bin folder name Returns ------- - list - List of bins of filenames + List of bins of filenames """ sizes = np.array([i[1] for i in self._result]) thresholds = np.linspace(sizes.min(), sizes.max(), self._num_bins + 1) @@ -375,13 +201,12 @@ def _binning_linear_threshold(self, units: str = "", multiplier: int = 1) -> lis return bins def _binning(self) -> list[list[str]]: - """ Called when :attr:`binning` is first accessed. Checks if sorting has been done, if not + """Called when :attr:`binning` is first accessed. Checks if sorting has been done, if not triggers it, then does binning Returns ------- - list - List of bins of filenames + List of bins of filenames """ if not self._result: self._sort_filelist() @@ -395,7 +220,7 @@ def _binning(self) -> list[list[str]]: return retval def sort(self) -> None: - """ Override for method specific logic for sorting the loaded statistics + """Override for method specific logic for sorting the loaded statistics. The scored list :attr:`_result` should be sorted in place """ @@ -405,22 +230,22 @@ def score_image(self, filename: str, image: np.ndarray | None, alignments: PNGHeaderAlignmentsDict | None) -> None: - """ Override for sort method's specificic logic. This method should be executed to get a + """Override for sort method's specific logic. This method should be executed to get a single score from a single image and add the result to :attr:`_result` Parameters ---------- - filename: str + filename The filename of the currently processing image - image: :class:`np.ndarray` or ``None`` + image A face image loaded from disk or ``None`` - alignments: dict or ``None`` + alignments The alignments dictionary for the aligned face or ``None`` """ raise NotImplementedError() def binning(self) -> list[list[str]]: - """ Group into bins by their sorted score. Override for method specific binning techniques. + """Group into bins by their sorted score. Override for method specific binning techniques. Binning takes the results from :attr:`_result` compiled during :func:`_sort_filelist` and organizes into bins for output. @@ -434,59 +259,55 @@ def binning(self) -> list[list[str]]: @classmethod def _mask_face(cls, image: np.ndarray, alignments: PNGHeaderAlignmentsDict) -> np.ndarray: - """ Function for applying the mask to an aligned face if both the face image and alignment + """Function for applying the mask to an aligned face if both the face image and alignment data are available. Parameters ---------- - image: :class:`numpy.ndarray` + image The aligned face image loaded from disk - alignments: Dict + alignments The alignments data corresponding to the loaded image Returns ------- - :class:`numpy.ndarray` - The original image with the mask applied - """ - det_face = DetectedFace() - det_face.from_png_meta(alignments) - aln_face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), - image=image, - centering="legacy", - size=256, - is_aligned=True) - assert aln_face.face is not None - - mask = det_face.mask.get("components", det_face.mask.get("extended", None)) + The original image with the mask applied + """ + det_face = DetectedFace().from_png_meta(alignments) + det_face.load_aligned(image, + size=256, + centering="legacy", + is_aligned=True) + aln_face = det_face.aligned + if aln_face.landmark_type != LandmarkType.LM_2D_68: + mask = None + else: + mask = det_face.get_landmark_mask("face", 0, 0) if mask is None and not cls._log_mask_once: - logger.warning("No masks are available for masking the data. Results are likely to be " - "sub-standard") + logger.warning("Masks cannot be generated for the available landmark types. Results " + "are likely to be sub-standard") cls._log_mask_once = True + assert aln_face.face is not None if mask is None: return aln_face.face - mask.set_sub_crop(aln_face.pose.offset[mask.stored_centering], - aln_face.pose.offset["legacy"], - centering="legacy") - nmask = cv2.resize(mask.mask, (256, 256), interpolation=cv2.INTER_CUBIC)[..., None] - return np.minimum(aln_face.face, nmask) + return np.minimum(aln_face.face, mask) class SortMultiMethod(SortMethod): - """ A Parent sort method that runs 2 different underlying methods (one for sorting one for + """A Parent sort method that runs 2 different underlying methods (one for sorting one for binning) in instances where grouping has been requested, but the sort method is different from the group method Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The command line arguments passed to the sort process - sort_method: :class:`SortMethod` + sort_method A sort method object for sorting the images - group_method: :class:`SortMethod` + group_method A sort method object used for sorting and binning the images """ def __init__(self, @@ -499,20 +320,19 @@ def __init__(self, super().__init__(arguments) def _get_file_iterator(self, input_dir: str) -> InfoLoader: - """ Override to get a group specific iterator. If the sorter and grouper use the same kind + """Override to get a group specific iterator. If the sorter and grouper use the same kind of iterator, use that. Otherwise return the 'all' iterator, as which ever way it is cut all outputs will be required. Monkey patch the actual loader used into the children in case of any callbacks. Parameters ---------- - input_dir: str + input_dir Full path to containing folder of faces to be supported Returns ------- - :class:`InfoLoader` - The correct InfoLoader iterator for the current sort method + The correct InfoLoader iterator for the current sort method """ if self._sorter.loader_type == self._grouper.loader_type: retval = InfoLoader(input_dir, self._sorter.loader_type) @@ -526,23 +346,22 @@ def score_image(self, filename: str, image: np.ndarray | None, alignments: PNGHeaderAlignmentsDict | None) -> None: - """ Score a single image for sort method: "distance", "yaw" "pitch" or "size" and add the - result to :attr:`_result` + """Score a single image for sort method and add the result to :attr:`_result` Parameters ---------- - filename: str + filename The filename of the currently processing image - image: :class:`np.ndarray` or ``None`` + image A face image loaded from disk or ``None`` - alignments: dict or ``None`` + alignments The alignments dictionary for the aligned face or ``None`` """ self._sorter.score_image(filename, image, alignments) self._grouper.score_image(filename, image, alignments) def sort(self) -> None: - """ Sort the sorter and grouper methods """ + """Sort the sorter and grouper methods""" logger.debug("Sorting") self._sorter.sort() self._result = self._sorter.sorted_filelist # type:ignore @@ -552,7 +371,7 @@ def sort(self) -> None: logger.debug("Sorted") def binning(self) -> list[list[str]]: - """ Override standard binning, to bin by the group-by method and sort by the sorting + """Override standard binning, to bin by the group-by method and sort by the sorting method. Go through the grouped binned results, and reorder each bin contents based on the @@ -560,8 +379,7 @@ def binning(self) -> list[list[str]]: Returns ------- - list - List of bins of filenames + List of bins of filenames """ sorted_ = self._result output: list[list[str]] = [] @@ -574,13 +392,13 @@ def binning(self) -> list[list[str]]: class SortBlur(SortMethod): - """ Sort images by blur or blur-fft amount + """Sort images by blur or blur-fft amount Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The command line arguments passed to the sort process - is_group: bool, optional + is_group Set to ``True`` if this class is going to be called exclusively for binning. Default: ``False`` """ @@ -590,22 +408,21 @@ def __init__(self, arguments: Namespace, is_group: bool = False) -> None: self._use_fft = method == "blur_fft" def estimate_blur(self, image: np.ndarray, alignments=None) -> float: - """ Estimate the amount of blur an image has with the variance of the Laplacian. + """Estimate the amount of blur an image has with the variance of the Laplacian. Normalize by pixel number to offset the effect of image size on pixel gradients & variance. Parameters ---------- - image: :class:`numpy.ndarray` + image The face image to calculate blur for - alignments: dict, optional + alignments The metadata for the face image or ``None`` if no metadata is available. If metadata is provided the face will be masked by the "components" mask prior to calculating blur. Default:``None`` Returns ------- - float - The estimated blur score for the face + The estimated blur score for the face """ if alignments is not None: image = self._mask_face(image, alignments) @@ -618,25 +435,24 @@ def estimate_blur(self, image: np.ndarray, alignments=None) -> float: def estimate_blur_fft(self, image: np.ndarray, alignments: PNGHeaderAlignmentsDict | None = None) -> float: - """ Estimate the amount of blur a fft filtered image has. + """Estimate the amount of blur a fft filtered image has. Parameters ---------- - image: :class:`numpy.ndarray` + image Use Fourier Transform to analyze the frequency characteristics of the masked face using 2D Discrete Fourier Transform (DFT) filter to find the frequency domain. A mean value is assigned to the magnitude spectrum and returns a blur score. Adapted from https://www.pyimagesearch.com/2020/06/15/ opencv-fast-fourier-transform-fft-for-blur-detection-in-images-and-video-streams/ - alignments: dict, optional + alignments The metadata for the face image or ``None`` if no metadata is available. If metadata is provided the face will be masked by the "components" mask prior to calculating blur. Default:``None`` Returns ------- - float - The estimated fft blur score for the face + The estimated fft blur score for the face """ if alignments is not None: image = self._mask_face(image, alignments) @@ -652,7 +468,7 @@ def estimate_blur_fft(self, ifft_shift = np.fft.ifftshift(fft_shift) shift_back = np.fft.ifft2(ifft_shift) magnitude = np.log(np.abs(shift_back)) - score = np.mean(magnitude) + score = float(np.mean(magnitude)) return score @@ -660,51 +476,50 @@ def score_image(self, filename: str, image: np.ndarray | None, alignments: PNGHeaderAlignmentsDict | None) -> None: - """ Score a single image for blur or blur-fft and add the result to :attr:`_result` + """Score a single image for blur or blur-fft and add the result to :attr:`_result` Parameters ---------- - filename: str + filename The filename of the currently processing image - image: :class:`np.ndarray` + image A face image loaded from disk - alignments: dict or ``None`` + alignments The alignments dictionary for the aligned face or ``None`` """ assert image is not None if self._log_once: msg = "Grouping" if self._is_group else "Sorting" - inf = "fft_filtered " if self._use_fft else " " - logger.info("%s by estimated %simage blur...", msg, inf) + inf = " fft_filtered" if self._use_fft else "" + logger.info("%s by estimated%s image blur...", msg, inf) self._log_once = False estimator = self.estimate_blur_fft if self._use_fft else self.estimate_blur self._result.append((filename, estimator(image, alignments))) def sort(self) -> None: - """ Sort by metric score. Order in reverse for distance sort. """ + """Sort by metric score. Order in reverse for distance sort.""" logger.info("Sorting...") self._result = sorted(self._result, key=operator.itemgetter(1), reverse=True) def binning(self) -> list[list[str]]: - """ Create bins to split linearly from the lowest to the highest sample value + """Create bins to split linearly from the lowest to the highest sample value Returns ------- - list - List of bins of filenames + List of bins of filenames """ return self._binning_linear_threshold(multiplier=100) class SortColor(SortMethod): - """ Score by channel average intensity or black pixels. + """Score by channel average intensity or black pixels. Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The command line arguments passed to the sort process - is_group: bool, optional + is_group Set to ``True`` if this class is going to be called exclusively for binning. Default: ``False`` """ @@ -716,17 +531,16 @@ def __init__(self, arguments: Namespace, is_group: bool = False) -> None: self._method = method.replace("color_", "") def _convert_color(self, image: np.ndarray) -> np.ndarray: - """ Helper function to convert color spaces + """Helper function to convert color spaces Parameters ---------- - image: :class:`numpy.ndarray` + image The original image to convert color space for Returns ------- - :class:`numpy.ndarray` - The color converted image + The color converted image """ if self._method == 'gray': conversion = np.array([[0.0722], [0.7152], [0.2126]]) @@ -738,17 +552,16 @@ def _convert_color(self, image: np.ndarray) -> np.ndarray: return np.einsum(operation, image[..., :3], conversion, optimize=path).astype('float32') def _near_split(self, bin_range: int) -> list[int]: - """ Obtain the split for the given number of bins for the given range + """Obtain the split for the given number of bins for the given range Parameters ---------- - bin_range: int + bin_range The range of data to separate into bins Returns ------- - list - The split dividers for the given number of bins for the given range + The split dividers for the given number of bins for the given range """ quotient, remainder = divmod(bin_range, self._num_bins) seps = [quotient + 1] * remainder + [quotient] * (self._num_bins - remainder) @@ -760,7 +573,7 @@ def _near_split(self, bin_range: int) -> list[int]: return bins def binning(self) -> list[list[str]]: - """ Group into bins by percentage of black pixels """ + """Group into bins by percentage of black pixels""" # TODO. Only grouped by black pixels. Check color logger.info("Grouping by percentage of %s...", self._method) @@ -783,15 +596,15 @@ def score_image(self, filename: str, image: np.ndarray | None, alignments: PNGHeaderAlignmentsDict | None) -> None: - """ Score a single image for color + """Score a single image for color Parameters ---------- - filename: str + filename The filename of the currently processing image - image: :class:`np.ndarray` + image A face image loaded from disk - alignments: dict or ``None`` + alignments The alignments dictionary for the aligned face or ``None`` """ if self._log_once: @@ -811,16 +624,16 @@ def score_image(self, self._result.append((filename, score)) def sort(self) -> None: - """ Sort by metric score. Order in reverse for distance sort. """ + """Sort by metric score. Order in reverse for distance sort.""" if self._method == "black": self._sort_black_pixels() return self._result = sorted(self._result, key=operator.itemgetter(1), reverse=True) def _sort_black_pixels(self) -> None: - """ Sort by percentage of black pixels + """Sort by percentage of black pixels - Calculates the sum of black pixels, gets the percentage X 3 channels + Calculates the sum of black pixels, gets the percentage X 3 channels """ img_list_len = len(self._result) for i in tqdm(range(0, img_list_len - 1), @@ -834,119 +647,163 @@ def _sort_black_pixels(self) -> None: class SortFace(SortMethod): - """ Sort by identity similarity using VGG Face 2 + """Sort by identity similarity using an Identity plugin Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The command line arguments passed to the sort process - is_group: bool, optional + is_group Set to ``True`` if this class is going to be called exclusively for binning. Default: ``False`` """ - - _logged_lm_count_once = False - _warning = ("Extracted faces do not contain facial landmark data. Results sorted by this " - "method are likely to be sub-standard.") - def __init__(self, arguments: Namespace, is_group: bool = False) -> None: super().__init__(arguments, loader_type="all", is_group=is_group) - self._vgg_face = VGGFace() - self._vgg_face.init_model() + self._plugin = Identity(arguments.identity, config_file=arguments.config_file) + self._runner: ExtractRunner[ExtractHandlerFace] | None = None + self._storage_name = self._plugin.storage_name threshold = arguments.threshold - self._output_update_info = True self._threshold: float | None = 0.25 if threshold < 0 else threshold + self._count_seen = 0 + self._plugin_thread = FSThread(self._score_from_plugin) + self._from_plugin: list[tuple[str, npt.NDArray[np.float32]]] = [] + self._alignment_queue: Queue[tuple[str, PNGHeaderAlignmentsDict]] = Queue( + maxsize=self._plugin.batch_size * 3) + + def _score_from_header(self, + filename: str, + alignments: PNGHeaderAlignmentsDict) -> bool: + """Reads header information from the PNG file to look for the identity embedding + + Parameters + ---------- + filename + The filename of the currently processing image + alignments + The alignments dictionary for the aligned face or ``None`` + + Returns + ------- + ``True`` if embedding information was read from the PNG header, otherwise ``False`` + """ + if not alignments.get("identity", {}).get(self._storage_name): + return False + embedding = np.array(alignments["identity"][self._storage_name], dtype="float32") + self._result.append((filename, embedding)) + return True + + def _score_from_plugin(self): + """Obtain the embedding from the identity plugin""" + logger.info("%s Embeddings are being written to the image header. " + "Sorting by this method should be quicker next time", + self._storage_name.title()) + + assert self._runner is not None + for media in self._runner: + if self._plugin_thread.error_state.has_error: + self._plugin_thread.error_state.re_raise() + embedding = media.detected_faces[0].identity[self._storage_name] + self._from_plugin.append((media.filename, embedding)) + filename, alignments = self._alignment_queue.get() + assert filename == media.filename + alignments.setdefault("identity", {})[self._storage_name] = embedding.tolist() + self._iterator.update_png_header(filename, alignments) + + def _handle_plugin(self) -> None: + """Check if we have seen all input and shutdown the plugin if so""" + if self._count_seen != self._iterator.filelist_count: + return + + if not self._plugin_thread.is_alive(): + return + assert self._runner is not None + logger.debug("Shutting down Identity plugin") + self._runner.stop() + self._plugin_thread.join() + self._result += self._from_plugin + def score_image(self, filename: str, image: np.ndarray | None, alignments: PNGHeaderAlignmentsDict | None) -> None: - """ Processing logic for sort by face method. - - Reads header information from the PNG file to look for VGGFace2 embedding. If it does not - exist, the embedding is obtained and added back into the PNG Header. + """Score a single image for sort method and add the result to :attr:`_result`. Attempts + to pull identity information from the PNG metadata. If not available, pulls the information + from the Identity plugin and stores in the PNG header for future use Parameters ---------- - filename: str + filename The filename of the currently processing image - image: :class:`np.ndarray` - A face image loaded from disk - alignments: dict or ``None`` + image + A face image loaded from disk or ``None`` + alignments The alignments dictionary for the aligned face or ``None`` """ - # pylint:disable=duplicate-code if not alignments: msg = ("The images to be sorted do not contain alignment data. Images must have " "been generated by Faceswap's Extract process.\nIf you are sorting an " - "older faceset, then you should re-extract the faces from your source " + "older face set, then you should re-extract the faces from your source " "alignments file to generate this data.") raise FaceswapError(msg) - if self._log_once: - msg = "Grouping" if self._is_group else "Sorting" - logger.info("%s by identity similarity...", msg) - self._log_once = False - - if alignments.get("identity", {}).get("vggface2"): - embedding = np.array(alignments["identity"]["vggface2"], dtype="float32") - - if not self._logged_lm_count_once and len(alignments["landmarks_xy"]) == 4: - logger.warning(self._warning) - self._logged_lm_count_once = True + if self._plugin_thread.error_state.has_error: + self._plugin_thread.error_state.re_raise() - self._result.append((filename, embedding)) + self._count_seen += 1 + if self._score_from_header(filename, alignments): + self._handle_plugin() return - if self._output_update_info: - logger.info("VGG Face2 Embeddings are being written to the image header. " - "Sorting by this method will be quicker next time") - self._output_update_info = False - - a_face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), - image=image, - centering="legacy", - size=self._vgg_face.input_size, - is_aligned=True) - - if a_face.landmark_type == LandmarkType.LM_2D_4 and not self._logged_lm_count_once: - logger.warning(self._warning) - self._logged_lm_count_once = True - - face = a_face.face - assert face is not None - embedding = self._vgg_face.predict(face[None, ...])[0] - alignments.setdefault("identity", {})["vggface2"] = embedding.tolist() - self._iterator.update_png_header(filename, alignments) - self._result.append((filename, embedding)) + if not self._plugin_thread.is_alive(): + logger.debug("Starting Identity plugin") + self._runner = self._plugin() + self._plugin_thread.start() + + self._alignment_queue.put((filename, alignments)) + + face = DetectedFace(left=alignments["x"], # Only include required items + width=alignments["w"], + top=alignments["y"], + height=alignments["h"], + landmarks_xy=np.array(alignments["landmarks_xy"], dtype="float32")) + assert self._runner is not None + try: + self._runner.put(filename, + T.cast("npt.NDArray[np.uint8]", image), + [face], + is_aligned=True, + frame_metadata=self._iterator.cached_source_data[filename]) + except Exception: + self._plugin_thread.error_state.set(sys.exc_info()) + raise + self._handle_plugin() def sort(self) -> None: - """ Sort by dendogram. + """Sort by dendrogram. Parameters ---------- - matched_list: list + matched_list The list of tuples with filename in first position and face encoding in the 2nd Returns ------- - list - The original list, sorted for this metric + The original list, sorted for this metric """ logger.info("Sorting by ward linkage. This may take some time...") - preds = np.array([item[1] for item in self._result]) - indices = Cluster(np.array(preds), "ward", threshold=self._threshold)() + results = np.array([item[1] for item in self._result]) + indices = Cluster(np.array(results), "ward", threshold=self._threshold)() self._result = [(self._result[idx][0], float(score)) for idx, score in indices] def binning(self) -> list[list[str]]: - """ Group into bins by their sorted score + """Group into bins by their sorted score - The bin ID has been output in the 2nd column of :attr:`_result` so use that for binnin + The bin ID has been output in the 2nd column of :attr:`_result` so use that for binning Returns ------- - list - List of bins of filenames + List of bins of filenames """ num_bins = len(set(int(i[1]) for i in self._result)) logger.info("Grouping by %s...", self.__class__.__name__.replace("Sort", "")) @@ -959,13 +816,13 @@ def binning(self) -> list[list[str]]: class SortHistogram(SortMethod): - """ Sort by image histogram similarity or dissimilarity + """Sort by image histogram similarity or dissimilarity Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The command line arguments passed to the sort process - is_group: bool, optional + is_group Set to ``True`` if this class is going to be called exclusively for binning. Default: ``False`` """ @@ -983,7 +840,7 @@ def _calc_histogram(self, return cv2.calcHist([image], [0], None, [256], [0, 256]) def _sort_dissim(self) -> None: - """ Sort histograms by dissimilarity """ + """Sort histograms by dissimilarity""" result = T.cast(list[tuple[str, np.ndarray]], self._result) img_list_len = len(result) for i in tqdm(range(0, img_list_len), @@ -1002,7 +859,7 @@ def _sort_dissim(self) -> None: self._result = sorted(result, key=operator.itemgetter(2), reverse=True) def _sort_sim(self) -> None: - """ Sort histograms by similarity """ + """Sort histograms by similarity""" result = T.cast(list[tuple[str, np.ndarray]], self._result) img_list_len = len(result) for i in tqdm(range(0, img_list_len - 1), @@ -1022,19 +879,18 @@ def _sort_sim(self) -> None: @classmethod def _get_avg_score(cls, image: np.ndarray, references: list[np.ndarray]) -> float: - """ Return the average histogram score between a face and reference images + """Return the average histogram score between a face and reference images Parameters ---------- - image: :class:`numpy.ndarray` + image The image to test references: list List of reference images to test the original image against Returns ------- - float - The average score between the histograms + The average score between the histograms """ scores = [] for img2 in references: @@ -1043,7 +899,7 @@ def _get_avg_score(cls, image: np.ndarray, references: list[np.ndarray]) -> floa return sum(scores) / len(scores) def binning(self) -> list[list[str]]: - """ Group into bins by histogram """ + """Group into bins by histogram""" # pylint:disable=duplicate-code msg = "dissimilarity" if self._is_dissim else "similarity" logger.info("Grouping by %s...", msg) @@ -1067,16 +923,17 @@ def binning(self) -> list[list[str]]: leave=False): current_key = -1 current_score = float("inf") + img = T.cast(np.ndarray, self._result[i][1]) for key, value in reference_groups.items(): - score = self._get_avg_score(self._result[i][1], value) + score = self._get_avg_score(img, value) if score < current_score: current_key, current_score = key, score if current_score < threshold: - reference_groups[T.cast(int, current_key)].append(self._result[i][1]) + reference_groups[T.cast(int, current_key)].append(img) bins[current_key].append(self._result[i][0]) else: - reference_groups[len(reference_groups)] = [self._result[i][1]] + reference_groups[len(reference_groups)] = [img] bins.append([self._result[i][0]]) return bins @@ -1085,15 +942,15 @@ def score_image(self, filename: str, image: np.ndarray | None, alignments: PNGHeaderAlignmentsDict | None) -> None: - """ Collect the histogram for the given face + """Collect the histogram for the given face Parameters ---------- filename: str The filename of the currently processing image - image: :class:`np.ndarray` + image A face image loaded from disk - alignments: dict or ``None`` + alignments The alignments dictionary for the aligned face or ``None`` """ if self._log_once: @@ -1105,7 +962,7 @@ def score_image(self, self._result.append((filename, self._calc_histogram(image, alignments))) def sort(self) -> None: - """ Sort by histogram. """ + """Sort by histogram.""" logger.info("Comparing histograms and sorting...") if self._is_dissim: self._sort_dissim() diff --git a/update_deps.py b/update_deps.py index 0fb48b8be0..79fe19415f 100644 --- a/update_deps.py +++ b/update_deps.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) -def main(is_gui=False) -> None: +def update(is_gui=False) -> None: """ Check for and update dependencies Parameters @@ -24,15 +24,15 @@ def main(is_gui=False) -> None: which get scrambled in the GUI """ logger.info("Updating dependencies...") - update = Environment(updater=True) - Install(update, is_gui=is_gui) + updater = Environment(updater=True) + Install(updater, is_gui=is_gui) logger.info("Dependencies updated") if __name__ == "__main__": logfile = os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), "faceswap_update.log") log_setup("INFO", logfile, "setup") - main() + update() __all__ = get_module_objects(__name__) From 7c0665fffa522af3acf8dddf2287b5dc84d5db4d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:25:12 +0000 Subject: [PATCH 944/981] Replace imageio with pyav/ffmpeg-binaries (#1534) --- docs/full/lib/video.rst | 2 + lib/align/alignments.py | 67 +- lib/align/updater.py | 3 +- lib/image.py | 657 +++++--------------- lib/logger.py | 8 +- lib/utils.py | 4 +- lib/video.py | 826 +++++++++++++++++++++++++ plugins/convert/writer/_base.py | 62 +- plugins/convert/writer/ffmpeg.py | 267 +++----- plugins/convert/writer/gif.py | 227 ++++--- plugins/convert/writer/gif_defaults.py | 12 +- pyproject.toml | 4 +- requirements/_requirements_base.txt | 6 +- scripts/convert.py | 3 +- scripts/extract.py | 7 +- scripts/fs_media.py | 183 +----- tools/alignments/alignments.py | 4 +- tools/alignments/jobs_frames.py | 5 +- tools/alignments/media.py | 276 ++++----- tools/effmpeg/effmpeg.py | 136 ++-- tools/manual/detected_faces.py | 7 +- tools/manual/globals.py | 3 +- tools/manual/manual.py | 18 +- tools/manual/thumbnails.py | 164 ++--- tools/mask/mask.py | 3 +- tools/preview/preview.py | 54 +- 26 files changed, 1596 insertions(+), 1412 deletions(-) create mode 100755 docs/full/lib/video.rst create mode 100644 lib/video.py diff --git a/docs/full/lib/video.rst b/docs/full/lib/video.rst new file mode 100755 index 0000000000..506aee7369 --- /dev/null +++ b/docs/full/lib/video.rst @@ -0,0 +1,2 @@ +.. automodapi:: lib.video + :include-all-objects: diff --git a/lib/align/alignments.py b/lib/align/alignments.py index 79cf054677..c517815bd9 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -76,7 +76,7 @@ class PNGHeaderSourceDict(T.TypedDict): class AlignmentDict(T.TypedDict): """Dictionary for holding all of the alignment information within a single alignment file.""" faces: list[AlignmentFileDict] - video_meta: dict[str, float | int] + video_meta: dict[str, int] class PNGHeaderDict(T.TypedDict): @@ -268,17 +268,20 @@ def mask_summary(self) -> dict[str, int]: return masks @property - def video_meta_data(self) -> dict[str, list[int] | list[float] | None]: + def video_meta_data(self) -> dict[T.Literal["pts_time", "keyframes"], list[int]] | None: """The frame meta data stored in the alignments file. If data does not exist in the - alignments file then ``None`` is returned for each Key""" - retval: dict[str, list[int] | list[float] | None] = {"pts_time": None, "keyframes": None} - pts_time: list[float] = [] + alignments file then ``None`` is returned""" + retval: dict[T.Literal["pts_time", "keyframes"], list[int]] = {} + pts_time: list[int] = [] keyframes: list[int] = [] for idx, key in enumerate(sorted(self.data)): if not self.data[key].get("video_meta", {}): - return retval + return None meta = self.data[key]["video_meta"] - pts_time.append(T.cast(float, meta["pts_time"])) + if not isinstance(meta["pts_time"], int): + # pts_time is now stored as ints so let it regenerate + return None + pts_time.append(meta["pts_time"]) if meta["keyframe"]: keyframes.append(idx) retval = {"pts_time": pts_time, "keyframes": keyframes} @@ -320,7 +323,7 @@ def backup(self) -> None: """ return self._io.backup() - def save_video_meta_data(self, pts_time: list[float], keyframes: list[int]) -> None: + def save_video_meta_data(self, pts_time: list[int], keyframes: list[int]) -> None: """Save video meta data to the alignments file. If the alignments file does not have an entry for every frame (e.g. if Extract Every N @@ -330,14 +333,11 @@ def save_video_meta_data(self, pts_time: list[float], keyframes: list[int]) -> N Parameters ---------- pts_time - A list of presentation timestamps (`float`) in frame index order for every frame in + A list of presentation timestamps (`int`) in frame index order for every frame in the input video keyframes A list of frame indices corresponding to the key frames in the input video """ - if pts_time[0] != 0: - pts_time, keyframes = self._pad_leading_frames(pts_time, keyframes) - sample_filename = next(fname for fname in self.data) basename = sample_filename[:sample_filename.rfind("_")] ext = os.path.splitext(sample_filename)[-1] @@ -346,7 +346,7 @@ def save_video_meta_data(self, pts_time: list[float], keyframes: list[int]) -> N logger.info("Saving video meta information to Alignments file") for idx, pts in enumerate(pts_time): - meta: dict[str, float | int] = {"pts_time": pts, "keyframe": idx in keyframes} + meta = {"pts_time": pts, "keyframe": idx in keyframes} key = f"{basename}_{idx + 1:06d}{ext}" if key not in self.data: self.data[key] = {"video_meta": meta, "faces": []} @@ -368,47 +368,6 @@ def save_video_meta_data(self, pts_time: list[float], keyframes: list[int]) -> N "alignments file for your requested video.") self._io.save() - @classmethod - def _pad_leading_frames(cls, pts_time: list[float], keyframes: list[int]) -> tuple[list[float], - list[int]]: - """Calculate the number of frames to pad the video by when the first frame is not - a key frame. - - A somewhat crude method by obtaining the gaps between existing frames and calculating - how many frames should be inserted at the beginning based on the first presentation - timestamp. - - Parameters - ---------- - pts_time - A list of presentation timestamps (`float`) in frame index order for every frame in - the input video - keyframes - A list of keyframes (`int`) for the input video - - Returns - ------- - The presentation time stamps with extra frames padded to the beginning and the keyframes - adjusted to include the new frames - """ - start_pts = pts_time[0] - logger.debug("Video not cut on keyframe. Start pts: %s", start_pts) - gaps: list[float] = [] - prev_time = None - for item in pts_time: - if prev_time is not None: - gaps.append(item - prev_time) - prev_time = item - data_points = len(gaps) - avg_gap = sum(gaps) / data_points - frame_count = int(round(start_pts / avg_gap)) - pad_pts = [avg_gap * i for i in range(frame_count)] - logger.debug("data_points: %s, avg_gap: %s, frame_count: %s, pad_pts: %s", - data_points, avg_gap, frame_count, pad_pts) - pts_time = pad_pts + pts_time - keyframes = [i + frame_count for i in keyframes] - return pts_time, keyframes - # << VALIDATION >> # def frame_exists(self, frame_name: str) -> bool: """Check whether a given frame_name exists within the alignments :attr:`data`. diff --git a/lib/align/updater.py b/lib/align/updater.py index 8c347ab2b9..bc97499db2 100644 --- a/lib/align/updater.py +++ b/lib/align/updater.py @@ -9,7 +9,8 @@ import numpy as np from lib.logger import parse_class_init -from lib.utils import get_module_objects, VIDEO_EXTENSIONS +from lib.utils import get_module_objects +from lib.video import VIDEO_EXTENSIONS logger = logging.getLogger(__name__) diff --git a/lib/image.py b/lib/image.py index 84c3a80769..c6e4046376 100644 --- a/lib/image.py +++ b/lib/image.py @@ -1,32 +1,25 @@ #!/usr/bin python3 -""" Utilities for working with images and videos """ +""" Utilities for working with images """ from __future__ import annotations import json import logging -import re -import subprocess import os import struct -import sys import typing as T from ast import literal_eval -from bisect import bisect from concurrent import futures from queue import Empty as QueueEmpty, Full as QueueFull, Queue from threading import current_thread, main_thread from zlib import crc32 import cv2 -import imageio -import imageio_ffmpeg as im_ffm import numpy as np -from tqdm import tqdm from lib.logger import parse_class_init from lib.multithreading import FSThread -from lib.utils import (convert_to_secs, FaceswapError, get_image_paths, - get_module_objects, VIDEO_EXTENSIONS) +from lib.utils import FaceswapError, get_image_paths, get_module_objects +from lib.video import check_for_video, VideoReader if T.TYPE_CHECKING: @@ -36,236 +29,8 @@ logger = logging.getLogger(__name__) -# ################### # -# <<< IMAGE UTILS >>> # -# ################### # - - -# <<< IMAGE IO >>> # - -class FfmpegReader(imageio.plugins.ffmpeg.FfmpegFormat.Reader): # type:ignore - """ Monkey patch imageio ffmpeg to use keyframes whilst seeking """ - def __init__(self, format, request): - super().__init__(format, request) - self._frame_pts = None - self._keyframes = None - self.use_patch = False - - def get_frame_info(self, frame_pts=None, keyframes=None): - """ Store the source video's keyframes in :attr:`_frame_info" for the current video for use - in :func:`initialize`. - - Parameters - ---------- - frame_pts: list, optional - A list corresponding to the video frame count of the pts_time per frame. If this and - `keyframes` are provided, then analyzing the video is skipped and the values from the - given lists are used. Default: ``None`` - keyframes: list, optional - A list containing the frame numbers of each key frame. if this and `frame_pts` are - provided, then analyzing the video is skipped and the values from the given lists are - used. Default: ``None`` - """ - if frame_pts is not None and keyframes is not None: - logger.debug("Video meta information provided. Not analyzing video") - self._frame_pts = frame_pts - self._keyframes = keyframes - return len(frame_pts), dict(pts_time=self._frame_pts, keyframes=self._keyframes) - - assert isinstance(self._filename, str), "Video path must be a string" - - # NB: The below video filter applies the detected frame rate prior to showinfo. This - # appears to help prevent an issue where the number of timestamp entries generated by - # showinfo does not correspond to the number of frames that the video file generates. - # This is because the demuxer will duplicate frames to meet the required frame rate. - # This **may** cause issues so be aware. - - # Also, drop frame rates (i.e 23.98, 29.97 and 59.94) will introduce rounding errors which - # means sync will drift on generated pts. These **should** be the only 'drop-frame rates' - # that appear in video files, but this is video files, and nothing is guaranteed. - # (The actual values for these should be 24000/1001, 30000/1001 and 60000/1001 - # respectively). The solutions to round these values is hacky at best, so: - # TODO find a more robust method for extracting/handling drop-frame rates. - - fps = self._meta["fps"] - rounded_fps = round(fps, 0) - if 0.01 < rounded_fps - fps < 0.10: # 0.90 - 0.99 - new_fps = f"{int(rounded_fps * 1000)}/1001" - logger.debug("Adjusting drop-frame fps: %s to %s", fps, new_fps) - fps = new_fps - - cmd = [im_ffm.get_ffmpeg_exe(), - "-hide_banner", - "-copyts", - "-i", self._filename, - "-vf", f"fps=fps={fps},showinfo", - "-start_number", "0", - "-an", - "-f", "null", - "-"] - logger.debug("FFMPEG Command: '%s'", " ".join(cmd)) - process = subprocess.Popen(cmd, - stderr=subprocess.STDOUT, - stdout=subprocess.PIPE, - universal_newlines=True) - frame_pts = [] - key_frames = [] - last_update = 0 - p_bar = tqdm(desc="Analyzing Video", - leave=False, - total=int(self._meta["duration"]), - unit="secs") - while True: - output = process.stdout.readline().strip() - if output == "" and process.poll() is not None: - break - if "iskey" not in output: - continue - logger.trace("Keyframe line: %s", output) # type:ignore[attr-defined] - line = re.split(r"\s+|:\s*", output) - pts_time = float(line[line.index("pts_time") + 1]) - frame_no = int(line[line.index("n") + 1]) - frame_pts.append(pts_time) - if "iskey:1" in output: - key_frames.append(frame_no) - - logger.trace("pts_time: %s, frame_no: %s", # type:ignore[attr-defined] - pts_time, frame_no) - if int(pts_time) == last_update: - # Floating points make TQDM display poorly, so only update on full - # second increments - continue - p_bar.update(int(pts_time) - last_update) - last_update = int(pts_time) - p_bar.close() - return_code = process.poll() - frame_count = len(frame_pts) - logger.debug("Return code: %s, frame_pts: %s, keyframes: %s, frame_count: %s", - return_code, frame_pts, key_frames, frame_count) - - self._frame_pts = frame_pts - self._keyframes = key_frames - return frame_count, dict(pts_time=self._frame_pts, keyframes=self._keyframes) - - def _previous_keyframe_info(self, index=0): - """ Return the previous keyframe's pts_time and frame number """ - prev_keyframe_idx = bisect(self._keyframes, index) - 1 - prev_keyframe = self._keyframes[prev_keyframe_idx] - prev_pts_time = self._frame_pts[prev_keyframe] - logger.trace("keyframe pts_time: %s, keyframe: %s", # type:ignore[attr-defined] - prev_pts_time, prev_keyframe) - return prev_pts_time, prev_keyframe - - def _initialize(self, index=0): # noqa:C901 - """ Replace ImageIO _initialize with a version that explicitly uses keyframes. - - Notes - ----- - This introduces a minor change by seeking fast to the previous keyframe and then discarding - subsequent frames until the desired frame is reached. In testing, setting -ss flag either - prior to input, or both prior (fast) and after (slow) would not always bring back the - correct frame for all videos. Navigating to the previous keyframe then discarding frames - until the correct frame is reached appears to work well. - """ - # pylint:disable-all - if self._read_gen is not None: - self._read_gen.close() - - i_args = [] - o_args = [] - skip_frames = 0 - - # Create input args - i_args += self._arg_input_params - if self.request._video: - i_args += ["-f", CAM_FORMAT] # noqa - if self._arg_pixelformat: - i_args += ["-pix_fmt", self._arg_pixelformat] - if self._arg_size: - i_args += ["-s", self._arg_size] - elif index > 0: # re-initialize / seek - # Note: only works if we initialized earlier, and now have meta. Some info here: - # https://trac.ffmpeg.org/wiki/Seeking - # There are two ways to seek, one before -i (input_params) and after (output_params). - # The former is fast, because it uses keyframes, the latter is slow but accurate. - # According to the article above, the fast method should also be accurate from ffmpeg - # version 2.1, however in version 4.1 our tests start failing again. Not sure why, but - # we can solve this by combining slow and fast. - # Further note: The old method would go back 10 seconds and then seek slow. This was - # still somewhat unresponsive and did not always land on the correct frame. This monkey - # patched version goes to the previous keyframe then discards frames until the correct - # frame is landed on. - if self.use_patch and self._frame_pts is None: - self.get_frame_info() - - if self.use_patch: - keyframe_pts, keyframe = self._previous_keyframe_info(index) - seek_fast = keyframe_pts - skip_frames = index - keyframe - else: - starttime = index / self._meta["fps"] - seek_slow = min(10, starttime) - seek_fast = starttime - seek_slow - - # We used to have this epsilon earlier, when we did not use - # the slow seek. I don't think we need it anymore. - # epsilon = -1 / self._meta["fps"] * 0.1 - i_args += ["-ss", "%.06f" % (seek_fast)] - if not self.use_patch: - o_args += ["-ss", "%.06f" % (seek_slow)] - - # Output args, for writing to pipe - if self._arg_size: - o_args += ["-s", self._arg_size] - if self.request.kwargs.get("fps", None): - fps = float(self.request.kwargs["fps"]) - o_args += ["-r", "%.02f" % fps] - o_args += self._arg_output_params - - # Get pixelformat and bytes per pixel - pix_fmt = self._pix_fmt - bpp = self._depth * self._bytes_per_channel - - # Create generator - rf = self._ffmpeg_api.read_frames - self._read_gen = rf( - self._filename, pix_fmt, bpp, input_params=i_args, output_params=o_args - ) - - # Read meta data. This start the generator (and ffmpeg subprocess) - if self.request._video: - # With cameras, catch error and turn into IndexError - try: - meta = self._read_gen.__next__() - except IOError as err: - err_text = str(err) - if "darwin" in sys.platform: - if "Unknown input format: 'avfoundation'" in err_text: - err_text += ( - "Try installing FFMPEG using " - "home brew to get a version with " - "support for cameras." - ) - raise IndexError( - "No camera at {}.\n\n{}".format(self.request._video, err_text) - ) - else: - self._meta.update(meta) - elif index == 0: - self._meta.update(self._read_gen.__next__()) - else: - if self.use_patch: - frames_skipped = 0 - while skip_frames != frames_skipped: - # Skip frames that are not the desired frame - _ = self._read_gen.__next__() - frames_skipped += 1 - self._read_gen.__next__() # we already have meta data - - -imageio.plugins.ffmpeg.FfmpegFormat.Reader = FfmpegReader # type: ignore - +# Image I/O @T.overload def read_image(filename: str, raise_error: T.Literal[False] = False, @@ -291,7 +56,9 @@ def read_image(filename: str, with_metadata: T.Literal[True]) -> npt.NDArray[np.uint8]: ... -def read_image(filename: str, raise_error: bool = False, with_metadata: bool = False # noqa[C901] +def read_image(filename: str, # noqa[C901] # pylint:disable=too-many-statements,too-many-branches + raise_error: bool = False, + with_metadata: bool = False ) -> np.ndarray | None | tuple[npt.NDArray[np.uint8], PNGHeaderDict]: """ Read an image file from a file location. @@ -361,25 +128,25 @@ def read_image(filename: str, raise_error: bool = False, with_metadata: bool = F retval = image except TypeError as err: success = False - msg = "Error while reading image (TypeError): '{}'".format(filename) - msg += ". Original error message: {}".format(str(err)) + msg = f"Error while reading image (TypeError): '{filename}'" + msg += f". Original error message: {str(err)}" logger.error(msg) if raise_error: - raise Exception(msg) + raise TypeError(msg) from err except ValueError as err: success = False msg = ("Error while reading image. This can be caused by special characters in the " - "filename or a corrupt image file: '{}'".format(filename)) - msg += ". Original error message: {}".format(str(err)) + f"filename or a corrupt image file: '{filename}'") + msg += f". Original error message: {str(err)}" logger.error(msg) if raise_error: - raise Exception(msg) + raise ValueError(msg) from err except Exception as err: # pylint:disable=broad-except success = False - msg = "Failed to load image '{}'. Original Error: {}".format(filename, str(err)) + msg = f"Failed to load image '{filename}'. Original Error: {str(err)}" logger.error(msg) if raise_error: - raise Exception(msg) + raise Exception(msg) from err # pylint:disable=broad-exception-raised logger.trace("Loaded image: '%s'. Success: %s", filename, success) # type:ignore[attr-defined] return retval @@ -491,20 +258,21 @@ def read_image_meta(filename): >>> height = metadata["height"] >>> faceswap_info = metadata["itxt"] """ - retval = dict() + retval = {} if os.path.splitext(filename)[-1].lower() != ".png": # Get the dimensions directly from the image for non-png logger.trace( # type:ignore[attr-defined] "Non png found. Loading file for dimensions: '%s'", filename) img = cv2.imread(filename) + assert img is not None retval["height"], retval["width"] = img.shape[:2] return retval with open(filename, "rb") as in_file: try: chunk = in_file.read(8) - except PermissionError: - raise PermissionError(f"PermissionError while reading: {filename}") + except PermissionError as exc: + raise PermissionError(f"PermissionError while reading: {filename}") from exc if chunk != b"\x89PNG\r\n\x1a\n": raise ValueError(f"Invalid header found in png: {filename}") @@ -527,10 +295,9 @@ def read_image_meta(filename): if keyword == b"faceswap": retval["itxt"] = literal_eval(value[4:].decode("utf-8", errors="replace")) break - else: - logger.trace("Skipping iTXt chunk: '%s'", # type:ignore[attr-defined] - keyword.decode("latin-1", errors="ignore")) - length = 0 # Reset marker for next chunk + logger.trace("Skipping iTXt chunk: '%s'", # type:ignore[attr-defined] + keyword.decode("latin-1", errors="ignore")) + length = 0 # Reset marker for next chunk in_file.seek(length + 4, 1) logger.trace("filename: %s, metadata: %s", filename, retval) # type:ignore[attr-defined] return retval @@ -718,7 +485,8 @@ def png_write_meta(image: bytes, data: PNGHeaderDict | dict[str, T.Any] | bytes) return retval -def tiff_write_meta(image: bytes, data: PNGHeaderDict | dict[str, T.Any] | bytes) -> bytes: +def tiff_write_meta(image: bytes, # pylint:disable=too-many-locals + data: PNGHeaderDict | dict[str, T.Any] | bytes) -> bytes: """ Write Faceswap information to a tiff's image_description field. Parameters @@ -783,7 +551,7 @@ def tiff_write_meta(image: bytes, data: PNGHeaderDict | dict[str, T.Any] | bytes return rendered -def tiff_read_meta(image: bytes) -> dict[str, T.Any]: +def tiff_read_meta(image: bytes) -> dict[str, T.Any]: # pylint:disable=too-many-locals """ Read information stored in a Tiff's Image Description field Returns @@ -932,7 +700,7 @@ def batch_convert_color(batch, color_space): batch.shape, color_space) original_shape = batch.shape batch = batch.reshape((original_shape[0] * original_shape[1], *original_shape[2:])) - batch = cv2.cvtColor(batch, getattr(cv2, "COLOR_{}".format(color_space))) + batch = cv2.cvtColor(batch, getattr(cv2, f"COLOR_{color_space}")) return batch.reshape(original_shape) @@ -967,89 +735,13 @@ def rgb_to_hex(rgb): str: The 6 digit hex code with leading `#` applied """ - return "#{:02x}{:02x}{:02x}".format(*rgb) + return f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}" # ################### # # <<< VIDEO UTILS >>> # # ################### # -def count_frames(filename, fast=False): - """ Count the number of frames in a video file - - There is no guaranteed accurate way to get a count of video frames without iterating through - a video and decoding every frame. - - :func:`count_frames` can return an accurate count (albeit fairly slowly) or a possibly less - accurate count, depending on the :attr:`fast` parameter. A progress bar is displayed. - - Parameters - ---------- - filename: str - Full path to the video to return the frame count from. - fast: bool, optional - Whether to count the frames without decoding them. This is significantly faster but - accuracy is not guaranteed. Default: ``False``. - - Returns - ------- - int: - The number of frames in the given video file. - - Example - ------- - >>> filename = "/path/to/video.mp4" - >>> frame_count = count_frames(filename) - """ - logger.debug("filename: %s, fast: %s", filename, fast) - assert isinstance(filename, str), "Video path must be a string" - - cmd = [im_ffm.get_ffmpeg_exe(), "-i", filename, "-map", "0:v:0"] - if fast: - cmd.extend(["-c", "copy"]) - cmd.extend(["-f", "null", "-"]) - - logger.debug("FFMPEG Command: '%s'", " ".join(cmd)) - process = subprocess.Popen(cmd, - stderr=subprocess.STDOUT, - stdout=subprocess.PIPE, - universal_newlines=True, encoding="utf8") - p_bar = None - duration = None - init_tqdm = False - update = 0 - frames = 0 - while True: - output = process.stdout.readline().strip() - if output == "" and process.poll() is not None: - break - - if output.startswith("Duration:"): - logger.debug("Duration line: %s", output) - idx = output.find("Duration:") + len("Duration:") - duration = int(convert_to_secs(*output[idx:].split(",", 1)[0].strip().split(":"))) - logger.debug("duration: %s", duration) - if output.startswith("frame="): - logger.debug("frame line: %s", output) - if not init_tqdm: - logger.debug("Initializing tqdm") - p_bar = tqdm(desc="Analyzing Video", leave=False, total=duration, unit="secs") - init_tqdm = True - time_idx = output.find("time=") + len("time=") - frame_idx = output.find("frame=") + len("frame=") - frames = int(output[frame_idx:].strip().split(" ")[0].strip()) - vid_time = int(convert_to_secs(*output[time_idx:].split(" ")[0].strip().split(":"))) - logger.debug("frames: %s, vid_time: %s", frames, vid_time) - prev_update = update - update = vid_time - p_bar.update(update - prev_update) - if p_bar is not None: - p_bar.close() - return_code = process.poll() - logger.debug("Return code: %s, frames: %s", return_code, frames) - return frames - - class ImageIO(): """ Perform disk IO for images or videos in a background thread. @@ -1096,7 +788,7 @@ def _check_location_exists(self): If the given location does not exist """ if isinstance(self.location, str) and not os.path.exists(self.location): - raise FaceswapError("The location '{}' does not exist".format(self.location)) + raise FaceswapError(f"The location '{self.location}' does not exist") if isinstance(self.location, (list, tuple)) and not all(os.path.exists(location) for location in self.location): raise FaceswapError("Not all locations in the input list exist") @@ -1136,7 +828,7 @@ def close(self): class ImagesLoader(ImageIO): - """ Perform image loading from a folder of images or a video. + """Perform image loading from a folder of images or a video. Images will be loaded and returned in the order that they appear in the folder, or in the video to ensure deterministic ordering. Loading occurs in a background thread, caching 8 images at a @@ -1146,23 +838,28 @@ class ImagesLoader(ImageIO): Parameters ---------- - path: str or list + path The path to load images from. This can be a folder which contains images a video file or a list of image files. - queue_size: int, optional + queue_size The amount of images to hold in the internal buffer. Default: 8. - fast_count: bool, optional + fast_count When loading from video, the video needs to be parsed frame by frame to get an accurate count. This can be done quite quickly without guaranteed accuracy, or slower with guaranteed accuracy. Set to ``True`` to count quickly, or ``False`` to count slower but accurately. Default: ``True``. - skip_list: list, optional + skip_list Optional list of frame/image indices to not load. Any indices provided here will be skipped when executing the :func:`load` function from the given location. Default: ``None`` - count: int, optional + count If the number of images that the loader will encounter is already known, it can be passed in here to skip the image counting step, which can save time at launch. Set to ``None`` if the count is not already known. Default: ``None`` + pts + The Presentation Timestamps if the source is a video and they are available. Default: + ``None`` + keyframes + The Keyframes if the source is a video and they are available. Default: ``None`` Examples -------- @@ -1172,52 +869,50 @@ class ImagesLoader(ImageIO): >>> for filename, image in loader.load(): >>> """ - def __init__(self, path: str | list[str], queue_size: int = 8, fast_count: bool = True, skip_list: list[int] | None = None, - count: int | None = None) -> None: + count: int | None = None, + pts: list[int] | None = None, + keyframes: list[int] | None = None) -> None: logger.debug(parse_class_init(locals())) super().__init__(path, queue_size=queue_size) self._skip_list = set() if skip_list is None else set(skip_list) - self._is_video = self._check_for_video() - self._fps = self._get_fps() - self._count = None + self._is_video = check_for_video(self.location) + self._count: int | None = None self._file_list: list[str] = [] - self._get_count_and_filelist(fast_count, count) + self._reader = VideoReader(self.location, + fast_count=fast_count, + pts=pts, + keyframes=keyframes) if self._is_video else None + self._get_count_and_filelist(count) @property def count(self) -> int: - """ int: The number of images or video frames in the source location. This count includes - any files that will ultimately be skipped if a :attr:`skip_list` has been provided. See - also: :attr:`process_count`""" + """The number of images or video frames in the source location. This count includes any + files that will ultimately be skipped if a :attr:`skip_list` has been provided. See also + :attr:`process_count`""" assert self._count is not None return self._count @property def process_count(self) -> int: - """ int: The number of images or video frames to be processed (IE the total count less - items that are to be skipped from the :attr:`skip_list`)""" + """The number of images or video frames to be processed (IE the total count less items that + are to be skipped from the :attr:`skip_list`)""" return self.count - len(self._skip_list) @property - def is_video(self): - """ bool: ``True`` if the input is a video, ``False`` if it is not """ + def is_video(self) -> bool: + """``True`` if the input is a video, ``False`` if it is not""" return self._is_video - @property - def fps(self): - """ float: For an input folder of images, this will always return 25fps. If the input is a - video, then the fps of the video will be returned. """ - return self._fps - @property def file_list(self) -> list[str]: - """ list[str]: A full list of files in the source location. This includes any files that - will ultimately be skipped if a :attr:`skip_list` has been provided. If the input is a - video then this is a list of dummy filenames as corresponding to an alignments file """ + """A full list of files in the source location. This includes any files that will + ultimately be skipped if a :attr:`skip_list` has been provided. If the input is a video + then this is a list of dummy filenames as corresponding to an alignments file """ return self._file_list @property @@ -1225,97 +920,50 @@ def processed_file_list(self) -> list[str]: """A list of files in the source location with any files that will be skipped removed""" return [f for i, f in enumerate(self._file_list) if i not in self._skip_list] - def add_skip_list(self, skip_list: list[int]): - """ Add a skip list to this :class:`ImagesLoader` + def add_skip_list(self, skip_list: list[int]) -> None: + """Add a skip list to this :class:`ImagesLoader` Parameters ---------- - skip_list: list[int] + skip_list A list of indices corresponding to the frame indices that should be skipped by the :func:`load` function. """ logger.debug("[%s] skip_list: %s", self._name, skip_list) self._skip_list = set(skip_list) - def _check_for_video(self): - """ Check whether the input is a video - - Returns - ------- - bool: 'True' if input is a video 'False' if it is a folder. - - Raises - ------ - FaceswapError - If the given location is a file and does not have a valid video extension. - - """ - if not isinstance(self.location, str) or os.path.isdir(self.location): - retval = False - elif os.path.splitext(self.location)[1].lower() in VIDEO_EXTENSIONS: - retval = True - else: - raise FaceswapError("The input file '{}' is not a valid video".format(self.location)) - logger.debug("[%s] Input '%s' is_video: %s", self._name, self.location, retval) - return retval - - def _get_fps(self): - """ Get the Frames per Second. - - If the input is a folder of images than 25.0 will be returned, as it is not possible to - calculate the fps just from frames alone. For video files the correct FPS will be returned. - - Returns - ------- - float: The Frames per Second of the input sources - """ - if self._is_video: - reader = imageio.get_reader(self.location, "ffmpeg") - retval = reader.get_meta_data()["fps"] - reader.close() - else: - retval = 25.0 - logger.debug("[%s] fps: %s", self._name, retval) - return retval - - def _get_count_and_filelist(self, fast_count, count): - """ Set the count of images to be processed and set the file list - - If the input is a video, a dummy file list is created for checking against an - alignments file, otherwise it will be a list of full filenames. + def _get_count_and_filelist(self, count: int | None) -> None: + """Set the count of images to be processed and set the file list. If the input is a video, + a dummy file list is created for checking against an alignments file, otherwise it will be + a list of full filenames. Parameters ---------- - fast_count: bool - When loading from video, the video needs to be parsed frame by frame to get an accurate - count. This can be done quite quickly without guaranteed accuracy, or slower with - guaranteed accuracy. Set to ``True`` to count quickly, or ``False`` to count slower - but accurately. count: int The number of images that the loader will encounter if already known, otherwise ``None`` """ if self._is_video: - self._count = int(count_frames(self.location, - fast=fast_count)) if count is None else count + assert self._reader is not None + self._count = len(self._reader) self._file_list = [self._dummy_video_frame_name(i) for i in range(self.count)] else: if isinstance(self.location, (list, tuple)): - self._file_list = self.location + self._file_list = list(self.location) else: self._file_list = get_image_paths(self.location) self._count = len(self.file_list) if count is None else count logger.debug("[%s] count: %s", self._name, self.count) logger.trace("[%s] file_list: %s", self._name, self.file_list) # type:ignore[attr-defined] - def _process(self, queue): - """ The load thread. + def _process(self, queue: Queue) -> None: + """The load thread. Loads from a folder of images or from a video and puts to a queue Parameters ---------- - queue: queue.Queue() + queue The ImageIO Queue """ iterator = self._from_video if self._is_video else self._from_folder @@ -1344,60 +992,64 @@ def _process(self, queue): logger.trace("[%s] Putting EOF", self._name) # type:ignore[attr-defined] queue.put("EOF") - def _from_video(self): - """ Generator for loading frames from a video + def _dummy_video_frame_name(self, index: int) -> str: + """Return a dummy filename for video files. The file name is made up of: + _. + + Notes + ----- + Indexes start at 0, frame numbers start at 1, so index is incremented by 1 + when creating the filename + + Parameters + ---------- + index + The index number for the frame in the video file + + Returns + ------- + A dummied filename for a video frame + """ + vid_name, ext = os.path.splitext(os.path.basename(self.location)) + return f"{vid_name}_{index + 1:06d}{ext}" + + def _from_video(self) -> T.Generator[tuple[str, npt.NDArray[np.uint8]], None, None]: + """Generator for loading frames from a video Yields ------ - filename: str + filename The dummy filename of the loaded video frame. - image: numpy.ndarray + image The loaded video frame. """ + assert self._reader is not None logger.debug("[%s] Loading frames from video: '%s'", self._name, self.location) - reader = imageio.get_reader(self.location, "ffmpeg") - for idx, frame in enumerate(reader): + for idx, frame in enumerate(self._reader): if idx in self._skip_list: logger.trace( # type:ignore[attr-defined] "[%s] Skipping frame %s due to skip list", self._name, idx) continue - # Convert to BGR for cv2 compatibility - frame = frame[:, :, ::-1] + image = T.cast("npt.NDArray[np.uint8]", + frame.to_ndarray(channel_last=True, format="bgr24")) filename = self._dummy_video_frame_name(idx) logger.trace("[%s] Loading video frame: '%s'", # type:ignore[attr-defined] self._name, filename) - yield filename, frame - reader.close() + yield filename, image - def _dummy_video_frame_name(self, index): - """ Return a dummy filename for video files. The file name is made up of: - _. - - Parameters - ---------- - index: int - The index number for the frame in the video file - - Notes - ----- - Indexes start at 0, frame numbers start at 1, so index is incremented by 1 - when creating the filename - - Returns - ------- - str: A dummied filename for a video frame """ - vid_name, ext = os.path.splitext(os.path.basename(self.location)) - return f"{vid_name}_{index + 1:06d}{ext}" - - def _from_folder(self): - """ Generator for loading images from a folder + def _from_folder(self) -> T.Generator[tuple[str, npt.NDArray[np.uint8]] | + tuple[str, npt.NDArray[np.uint8], PNGHeaderDict], + None, None]: + """Generator for loading images from a folder Yields ------ - filename: str + filename The filename of the loaded image. - image: numpy.ndarray + image The loaded image. + metadata + The Faceswap metadata associated with the loaded image. (:class:`FacesLoader` only) """ logger.debug("[%s] Loading frames from folder: '%s'", self._name, self.location) for idx, filename in enumerate(self.file_list): @@ -1406,26 +1058,26 @@ def _from_folder(self): "[%s] Skipping frame %s due to skip list", self._name, filename) continue image_read = read_image(filename, raise_error=False) - retval = filename, image_read - if retval[1] is None: + if image_read is None: logger.warning("Frame not loaded: '%s'", filename) continue - yield retval + yield filename, image_read - def load(self): - """ Generator for loading images from the given :attr:`location` + def load(self) -> T.Generator[tuple[str, npt.NDArray[np.uint8]] | + tuple[str, npt.NDArray[np.uint8], PNGHeaderDict], None, None]: + """Generator for loading images from the given :attr:`location` If :class:`FacesLoader` is in use then the Faceswap metadata of the image stored in the image exif file is added as the final item in the output `tuple`. Yields ------ - filename: str + filename The filename of the loaded image. - image: numpy.ndarray + image The loaded image. - metadata: dict, (:class:`FacesLoader` only) - The Faceswap metadata associated with the loaded image. + metadata + The Faceswap metadata associated with the loaded image. (:class:`FacesLoader` only) """ logger.debug("[%s] Initializing Load Generator", self._name) self._set_thread() @@ -1468,13 +1120,11 @@ def __init__(self, path, skip_list=None, count=None): logger.debug(parse_class_init(locals())) super().__init__(path, queue_size=8, skip_list=skip_list, count=count) - def _get_count_and_filelist(self, fast_count, count): + def _get_count_and_filelist(self, count): """ Override default implementation to only return png files from the source folder Parameters ---------- - fast_count: bool - Not used for faces loader count: int The number of images that the loader will encounter if already known, otherwise ``None`` @@ -1520,28 +1170,35 @@ def _from_folder(self): class SingleFrameLoader(ImagesLoader): - """ Allows direct access to a frame by filename or frame index. + """Allows direct access to a frame by filename or frame index. As we are interested in instant access to frames, there is no requirement to process in a background thread, as either way we need to wait for the frame to load. Parameters ---------- - video_meta_data: dict, optional + path + Full path to the input media + video_meta_data Existing video meta information containing the pts_time and iskey flags for the given video. Used in conjunction with single_frame_reader for faster seeks. Providing this means that the video does not need to be scanned again. Set to ``None`` if the video is to be scanned. Default: ``None`` """ - def __init__(self, path, video_meta_data=None): + def __init__(self, + path: str, + video_meta_data: dict[T.Literal["pts_time", "keyframes"], list[int]] | None = None + ) -> None: logger.debug(parse_class_init(locals())) - self._video_meta_data = dict() if video_meta_data is None else video_meta_data - self._reader = None - super().__init__(path, queue_size=1, fast_count=False) + self._video_meta_data: dict[T.Literal["pts_time", "keyframes"], + list[int]] | None = video_meta_data + pts = None if video_meta_data is None else video_meta_data["pts_time"] + keyframes = None if video_meta_data is None else video_meta_data["keyframes"] + super().__init__(path, queue_size=1, fast_count=False, pts=pts, keyframes=keyframes) @property - def video_meta_data(self): - """ dict: For videos contains the keys `frame_pts` holding a list of time stamps for each + def video_meta_data(self) -> dict[T.Literal["pts_time", "keyframes"], list[int]] | None: + """For videos contains the keys `frame_pts` holding a list of time stamps for each frame and `keyframes` holding the frame index of each key frame. Notes @@ -1549,20 +1206,16 @@ def video_meta_data(self): Only populated if the input is a video and single frame reader is being used, otherwise returns ``None``. """ - return self._video_meta_data + if self._reader is None: + return None + return {"pts_time": self._reader.info.pts.tolist(), + "keyframes": self._reader.info.keyframes.tolist()} - def _get_count_and_filelist(self, fast_count, count): - if self._is_video: - self._reader = imageio.get_reader(self.location, "ffmpeg") - self._reader.use_patch = True - count, video_meta_data = self._reader.get_frame_info( - frame_pts=self._video_meta_data.get("pts_time", None), - keyframes=self._video_meta_data.get("keyframes", None)) - self._video_meta_data = video_meta_data - super()._get_count_and_filelist(fast_count, count) + def image_from_index(self, index: int) -> tuple[str, npt.NDArray[np.uint8]]: + """Return a single image from :attr:`file_list` for the given index. We do not use a + background thread for this task, as it is assumed that requesting an image by index will be + done when required. - def image_from_index(self, index: int) -> tuple[str, np.ndarray]: - """ Return a single image from :attr:`file_list` for the given index. Parameters ---------- @@ -1576,19 +1229,11 @@ def image_from_index(self, index: int) -> tuple[str, np.ndarray]: The filename of the returned image image: :class:`numpy.ndarray` The image for the given index - - Notes - ----- - Retrieving frames from video files can be slow as the whole video file needs to be - iterated to retrieve the requested frame. If a frame has already been retrieved, then - retrieving frames of a higher index will be quicker than retrieving frames of a lower - index, as iteration needs to start from the beginning again when navigating backwards. - - We do not use a background thread for this task, as it is assumed that requesting an image - by index will be done when required. """ if self.is_video: - image = self._reader.get_data(index)[..., ::-1] + assert self._reader is not None + image = T.cast("npt.NDArray[np.uint8]", + self._reader.get(index).to_ndarray(channel_last=True, format="bgr24")) filename = self._dummy_video_frame_name(index) else: file_list = [f for idx, f in enumerate(self._file_list) @@ -1602,6 +1247,12 @@ def image_from_index(self, index: int) -> tuple[str, np.ndarray]: self._name, index, filename, image.shape) return filename, image + def close(self) -> None: + """Shut down the video reader""" + if self._reader is not None: + self._reader.close() + super().close() + class ImagesSaver(ImageIO): """ Perform image saving to a destination folder. @@ -1643,10 +1294,10 @@ def _check_location_exists(self): """ if not isinstance(self.location, str): raise FaceswapError("The output location must be a string not a " - "{}".format(type(self.location))) + f"{type(self.location)}") super()._check_location_exists() if not os.path.isdir(self.location): - raise FaceswapError("The output location '{}' is not a folder".format(self.location)) + raise FaceswapError(f"The output location '{self.location}' is not a folder") def _process(self, queue): """ Saves images from the save queue to the given :attr:`location` inside a thread. diff --git a/lib/logger.py b/lib/logger.py index 649896e2d8..95495eaf2f 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -194,7 +194,7 @@ def _lower_external(cls, record: logging.LogRecord) -> logging.LogRecord: """Some external libs log at a higher level than we would really like, so lower their log level. - Specifically: Matplotlib font properties, pytorch compilation gemm warnings + Specifically: Matplotlib font properties and libav output Parameters ---------- @@ -205,9 +205,7 @@ def _lower_external(cls, record: logging.LogRecord) -> logging.LogRecord: ---------- The log rewritten or untouched record """ - if (record.levelno == logging.INFO and record.funcName == "__init__" - and record.module == "font_manager"): - # Matplotlib font manager + if record.levelno == logging.INFO and record.name.startswith(("libav.", "matplotlib.")): record.levelno = 10 record.levelname = "DEBUG" return record @@ -610,7 +608,7 @@ def _process_value(value: T.Any) -> T.Any: The original or amended value """ if isinstance(value, (list, tuple, set)) and len(value) > 10: - return f'[type: "{type(value).__name__}" len: {len(value)}' + return f'[type: "{type(value).__name__}" len: {len(value)}]' try: import numpy as np # pylint:disable=import-outside-toplevel diff --git a/lib/utils.py b/lib/utils.py index 91bdbfda60..cacf149ffe 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -36,8 +36,6 @@ PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) """str : Full path to the root faceswap folder """ IMAGE_EXTENSIONS = [".bmp", ".exr", ".jpeg", ".jpg", ".png", ".tif", ".tiff"] -VIDEO_EXTENSIONS = [".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv", - ".ts", ".vob"] ValidBackends = T.Literal["nvidia", "cpu", "apple_silicon", "rocm"] _FS_BACKEND: ValidBackends | None = None @@ -338,7 +336,7 @@ def get_module_objects(module: str) -> list[str]: and not name_.startswith("_")] -def convert_to_secs(*args: int) -> int: +def convert_to_secs(*args: int | str) -> int: """ Convert time in hours, minutes, and seconds to seconds. Parameters diff --git a/lib/video.py b/lib/video.py new file mode 100644 index 0000000000..4a91e6c175 --- /dev/null +++ b/lib/video.py @@ -0,0 +1,826 @@ +#!/usr/bin python3 +"""Utilities for working with videos""" +from __future__ import annotations + +import logging +import os +import subprocess +import typing as T + +from collections import deque +from fractions import Fraction +from math import ceil + +import av +import av.error +import av.filter +import av.logging +import ffmpeg +import numpy as np +from tqdm import tqdm + +from lib.logger import parse_class_init +from lib.utils import convert_to_secs, FaceswapError, get_module_objects + + +if T.TYPE_CHECKING: + from av.container import InputContainer, OutputContainer + import numpy.typing as npt + +logger = logging.getLogger(__name__) +av.logging.set_level(av.logging.VERBOSE) +logging.getLogger("libav").setLevel(logger.getEffectiveLevel()) + + +VIDEO_EXTENSIONS = [".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv", + ".ts", ".vob"] +"""List of lowercase valid Video extensions with preceding period""" + + +def check_for_video(input_location: str) -> bool: + """Check whether the given input is a video file or a folder + + Parameters + ---------- + input_location + Full path to an input file + + Returns + ------- + bool: 'True' if input is a video 'False' if it is a folder. + + Raises + ------ + FaceswapError + If the given location is a file and does not have a valid video extension. + """ + if not isinstance(input_location, str) or os.path.isdir(input_location): + retval = False + elif os.path.splitext(input_location)[1].lower() in VIDEO_EXTENSIONS: + retval = True + else: + raise FaceswapError(f"The input file '{input_location}' is not a valid video") + logger.debug("Input '%s' is_video: %s", input_location, retval) + return retval + + +def validate_video_file(file_path: str) -> str: + """Validates that a given file exists and is a valid video format + + Parameters + ---------- + file_path + The full path to the video file to validate + + Returns + ------- + The full expanded video file path + + Raises + ------ + FaceswapError + If the given video file is not valid + """ + file_path = os.path.expanduser(os.path.abspath(file_path)) + if not os.path.isfile(file_path): + raise FaceswapError(f"Video file '{file_path}' does not exist") + if os.path.splitext(file_path)[-1].lower() not in VIDEO_EXTENSIONS: + raise FaceswapError(f"File '{file_path}' is not a valid video file") + return file_path + + +# TODO look for instances of this and see if we can roll it into VideoInfo +def count_frames(filename, fast=False): + """ Count the number of frames in a video file + + There is no guaranteed accurate way to get a count of video frames without iterating through + a video and decoding every frame. + + :func:`count_frames` can return an accurate count (albeit fairly slowly) or a possibly less + accurate count, depending on the :attr:`fast` parameter. A progress bar is displayed. + + Parameters + ---------- + filename: str + Full path to the video to return the frame count from. + fast: bool, optional + Whether to count the frames without decoding them. This is significantly faster but + accuracy is not guaranteed. Default: ``False``. + + Returns + ------- + int: + The number of frames in the given video file. + + Example + ------- + >>> filename = "/path/to/video.mp4" + >>> frame_count = count_frames(filename) + """ + logger.debug("filename: %s, fast: %s", filename, fast) + assert isinstance(filename, str), "Video path must be a string" + cmd = [str(ffmpeg.FFMPEG_PATH), "-i", filename, "-map", "0:v:0"] + if fast: + cmd.extend(["-c", "copy"]) + cmd.extend(["-f", "null", "-"]) + + logger.debug("FFMPEG Command: '%s'", " ".join(cmd)) + process = subprocess.Popen(cmd, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + universal_newlines=True, encoding="utf8") + p_bar = None + duration = None + update = 0 + frames = 0 + stdout = process.stdout + assert stdout is not None + while True: + + output = stdout.readline().strip() + if output == "" and process.poll() is not None: + break + + if output.startswith("Duration:"): + logger.debug("Duration line: %s", output) + idx = output.find("Duration:") + len("Duration:") + duration = int(convert_to_secs(*output[idx:].split(",", 1)[0].strip().split(":"))) + logger.debug("duration: %s", duration) + if output.startswith("frame="): + logger.debug("frame line: %s", output) + if p_bar is None: + logger.debug("Initializing tqdm") + p_bar = tqdm(desc="Analyzing Video", leave=False, total=duration, unit="secs") + time_idx = output.find("time=") + len("time=") + frame_idx = output.find("frame=") + len("frame=") + frames = int(output[frame_idx:].strip().split(" ")[0].strip()) + vid_time = int(convert_to_secs(*output[time_idx:].split(" ")[0].strip().split(":"))) + logger.debug("frames: %s, vid_time: %s", frames, vid_time) + prev_update = update + update = vid_time + p_bar.update(update - prev_update) + if p_bar is not None: + p_bar.close() + return_code = process.poll() + logger.debug("Return code: %s, frames: %s", return_code, frames) + return frames + + +class VideoInfo: + """Collects and stores information about video files + + Parameters + ---------- + video_file + Full path to a video file + fast_count + Whether to obtain the count of frames quickly, but inaccurately or slowly but accurately. + If pts and keyframes are provided then the count will be derived from the provided pts + file. Default: ``True`` + stream_index + The stream index to select from the video file. Default: 0 + pts + The Presentation Timestamps if available or ``None`` to retrieve from the video. + Default: ``None`` + keyframes + The keyframe frame indices if available or ``None`` to retrieve from the video. + Default: ``None`` + """ + def __init__(self, + video_file: str, + fast_count: bool = True, + stream_index: int = 0, + pts: list[int] | None = None, + keyframes: list[int] | None = None) -> None: + logger.debug(parse_class_init(locals())) + self._video_file = validate_video_file(video_file) + self._fast_count = fast_count + self._stream_index = stream_index + self._pts = None if pts is None else np.array(pts, dtype=np.int64) + self._keyframes = None if keyframes is None else np.array(keyframes, dtype=np.int64) + self._num_keyframes = -1 + + self._duration = self._get_duration() + self._count: int | None = None + + def __repr__(self) -> str: + """Pretty print for logging""" + params = {k[1:]: v.tolist() if isinstance(v, np.ndarray) else v + for k, v in self.__dict__.items() + if k in ("_video_file", + "_fast_count", + "_stream_index", + "_pts", + "_keyframes")} + s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + + @property + def duration(self) -> int: + """The duration of the video file in seconds""" + return self._duration + + @property + def count(self) -> int: + """The number of frames in the video""" + if self._count is not None: + return self._count + if self._pts is not None: + self._count = len(self._pts) + return self._count + if self._fast_count: + self._count = count_frames(self._video_file, fast=True) + return self._count + self._count = len(self.pts) + return self._count + + @property + def keyframes_count(self) -> int: + """The number of keyframes that exist in the video""" + if self._num_keyframes < 0: + self._num_keyframes = len(self.keyframes) + return self._num_keyframes + + @property + def pts(self) -> npt.NDArray[np.int64]: + """The Presentation Time Stamp for each frame in the video""" + if self._pts is None: + self._get_pts_and_keyframes() + assert self._pts is not None + return self._pts + + @property + def keyframes(self) -> npt.NDArray[np.int64]: + """The frame index of each key frame in the video""" + if self._keyframes is None: + self._get_pts_and_keyframes() + assert self._keyframes is not None + return self._keyframes + + def _get_stream(self, container: InputContainer) -> av.VideoStream: + """Obtain the first video stream from the given container and set threading + + Parameters + ---------- + container + The opened video container + + Returns + ------- + stream + The first video stream within the container with AUTO threading mode set + + Raises + ------ + FaceswapError + If time_base is not stored within the stream + """ + stream = container.streams.video[self._stream_index] + stream.thread_type = "AUTO" + if stream.time_base is None: + raise FaceswapError(f"Video file '{self._video_file}' cannot be processed. Missing " + "duration metadata") + return stream + + def _get_duration(self) -> int: + """Obtain the duration of the video, in seconds. First attempt to obtain it from the + stream. If this does not exist attempt to obtain it from the container. If this also + does not exist, raise an error + + Parameters + ---------- + stream + The stream to attempt to obtain the duration from + + Returns + ------- + The duration of the stream in seconds + + Raises + ------ + FaceswapError + If the duration of the video could not be obtained + """ + with av.open(self._video_file, "r") as container: + stream = self._get_stream(container) + if stream.duration is not None and stream.time_base is not None: + duration = int(stream.duration * stream.time_base) + logger.debug("[%s] '%s' duration from stream: %s", + self.__class__.__name__, self._video_file, duration) + elif container.duration is None: + raise FaceswapError(f"Video file '{self._video_file}' cannot be processed. " + "Missing duration metadata") + else: + duration = int(container.duration / 1000000) + logger.debug("[%s] '%s' duration from container: %s", + self.__class__.__name__, self._video_file, duration) + return duration + + def _get_pts_and_keyframes(self) -> None: + """Parse the video for Presentation Time Stamps and keyframes and populate to :attr:`_pts` + and :attr:`_keyframes""" + logger.debug("[%s] Parsing video for PTS and keyframes: '%s'", + self.__class__.__name__, self._video_file) + pts: list[int] = [] + keyframes: list[int] = [] + with av.open(self._video_file, "r") as container: + stream = self._get_stream(container) + assert stream.time_base is not None + + p_bar = tqdm(desc="Analyzing Video", leave=False, total=self.duration, unit="secs") + i = last_update = offset = 0 + decoder = container.decode(stream) + while True: + try: + frame = next(decoder) + except StopIteration: + break + except av.error.InvalidDataError: + logger.warning("Invalid data encountered at frame %s in video '%s'", + i, self._video_file) + continue + assert frame.pts is not None + if i == 0: + offset = frame.pts + pts.append(frame.pts) + if frame.key_frame: # pyright:ignore[reportAttributeAccessIssue] + keyframes.append(i) + cur_sec = int((frame.pts - offset) * stream.time_base) + i += 1 + if cur_sec == last_update: + continue + p_bar.update(cur_sec - last_update) + last_update = cur_sec + self._pts = np.array(pts, dtype=np.int64) + self._keyframes = np.array(keyframes, dtype=np.int64) + logger.debug("[%s] '%s' frame_pts: %s, keyframes: %s, frame_count: %s", + self.__class__.__name__, self._video_file, pts, keyframes, len(pts)) + + +class VideoReader: + """A wrapper around pyAV that allows obtaining frames by frame index and iterating video files + + Parameters + ---------- + video_file + Full path to a video file + fast_count + Whether to obtain the count of frames quickly, but inaccurately or slowly but accurately. + If pts and keyframes are provided then the count will be derived from the provided pts + file. Default: ``True`` + stream_index + The stream index to select from the video file. Default: 0 + pts + The Presentation Timestamps if available or ``None`` to retrieve from the video. + Default: ``None`` + keyframes + The keyframe frame indices if available or ``None`` to retrieve from the video. + Default: ``None`` + """ + def __init__(self, + video_file: str, + fast_count: bool = True, + stream_index: int = 0, + pts: list[int] | None = None, + keyframes: list[int] | None = None) -> None: + logger.debug(parse_class_init(locals())) + self._video_file = validate_video_file(video_file) + self._stream_index = stream_index + self._info = VideoInfo(self._video_file, + fast_count, + self._stream_index, + pts, + keyframes) + + self._container = av.open(self._video_file, "r") + self._stream = self._container.streams.video[stream_index] + self._stream.thread_type = "AUTO" + self._decoder = self._container.decode(self._stream) + + self._count: int | None = None + self._current_pts = 0 + self._current_index = 0 + """The index of the next frame to be returned from the frame iterator""" + + @property + def info(self) -> VideoInfo: + """The metadata information for the video file""" + return self._info + + def __iter__(self) -> T.Self: + """ This is an iterator """ + return self + + def __repr__(self) -> str: + """ Pretty print for logging """ + pts = self._info._pts + keyframes = self._info._keyframes + params = {"video_file": self._video_file, + "fast_count": self._info._fast_count, + "stream_index": self._stream_index, + "pts": pts if pts is None else pts.tolist(), + "keyframes": keyframes if keyframes is None else keyframes.tolist()} + s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + + def __len__(self) -> int: + """The number of frames in the video file. Either inaccurate (if fast_count is ``True``) + or accurate (if fast_count is ``False`` or pts and keyframes were provided)""" + return self._info.count + + def close(self) -> None: + """Shut down the AV Container object""" + logger.debug("[%s] '%s' Closing container", self.__class__.__name__, self._video_file) + self._container.close() + + def __next__(self) -> av.VideoFrame: + """Obtain the next video frame object + + Returns + ------- + The next available video frame object + """ + frame = None + while True: + try: + frame = next(self._decoder) + break + except StopIteration: + break + except av.error.InvalidDataError: + logger.warning("Invalid data encountered at frame %s. Skipping.", + self._current_index) + continue + if frame is None: + logger.debug("[%s] Closing Frame Iterator", self.__class__.__name__) + self.close() + raise StopIteration + self._current_index += 1 + return frame + + def _get_previous_keyframe(self, index: int) -> int: + """Obtain the keyframe that appears directly prior to the given frame index + + Parameters + ---------- + index + The target frame that is being navigated to + + Returns + The keyframe that appears directly prior to the given target frame + """ + if index in self._info.keyframes: + logger.trace("[%s] Index is keyframe: %s", # type:ignore[attr-defined] + self.__class__.__name__, index) + return index + keyframe_index = np.searchsorted(self._info.keyframes, index, side="left") - 1 + keyframe = int(self._info.keyframes[keyframe_index]) + logger.trace("[%s] Previous keyframe for frame %s: %s", # type:ignore[attr-defined] + self.__class__.__name__, index, keyframe) + return keyframe + + def _jump_to_keyframe(self, index: int, target_pts: int) -> None: + """Jump the iterator to the first keyframe prior to the requested frame, or leave it where + it is if the next requested frame is before the next keyframe. If we are seeking we always + replace our iterator with a new one due to possible internal pyAV logic getting scrambled + + Parameters + ---------- + index + The frame index of the requested frame to retrieve + target_pts + The Presentation Timestamp of the requested frame + """ + if index == self._current_index: + logger.trace( # type:ignore[attr-defined] + "[%s] Requested frame is next queued. Not seeking: %s", + self.__class__.__name__, index) + return + + if index < self._current_index: # Moving backwards + logger.trace("[%s] Seeking backwards from %s to %s", # type:ignore[attr-defined] + self.__class__.__name__, self._current_index, index) + self._container.seek(target_pts, backward=True, any_frame=False, stream=self._stream) + self._decoder = self._container.decode(self._stream) + self._current_index = self._get_previous_keyframe(index) + return + + next_key_index = np.searchsorted(self._info.keyframes, self._current_index, side="right") + next_keyframe = self._info.keyframes[next_key_index] + + if next_keyframe > index: + logger.trace( # type:ignore[attr-defined] + "[%s] Next keyframe is past target. Not seeking: %s", + self.__class__.__name__, next_keyframe) + return + + next_keyframe = self._get_previous_keyframe(index) + logger.trace("[%s] Seeking forwards to %s", # type:ignore[attr-defined] + self.__class__.__name__, next_keyframe) + self._container.seek(target_pts, backward=True, any_frame=False, stream=self._stream) + self._decoder = self._container.decode(self._stream) + self._current_index = next_keyframe + + def get(self, index: int) -> av.VideoFrame: + """Obtain the video frame at the given frame index + + Parameters + ---------- + index + The index number of the frame to retrieve + + Returns + ------- + The pyAV frame object for the given index + """ + target_pts = int(self._info.pts[index]) + logger.trace( # type:ignore[attr-defined] + "[%s] Requested frame: %s, current frame: %s, target pts: %s", + self.__class__.__name__, index, self._current_index, target_pts) + self._jump_to_keyframe(index, target_pts) + frame = next(self) + assert frame.pts is not None + current_pts = frame.pts + while current_pts < target_pts: + frame = next(self) + assert frame.pts is not None + current_pts = frame.pts + logger.trace("[%s] Returning frame: %s", # type:ignore[attr-defined] + self.__class__.__name__, frame) + return frame + + +class VideoMux: # pylint:disable=too-many-instance-attributes + """A basic muxer for muxing converted faceswap frames to a video file using the original video + as a reference + + Parameters + ---------- + source_video + The path to the source video to use as a reference for Audio and FPS + destination_video + The full path to save the final video to + codec + The codec to use to encode the video + codec_parameters + The options to use for the codec + mux_audio + ``True`` to mux order from the source video to the output + """ + def __init__(self, + source_video: str, + destination_video: str, + codec: T.Literal["libx264", "libx265"], + codec_parameters: dict[str, str], + mux_audio: bool = True) -> None: + logger.debug(parse_class_init(locals())) + self._source_video = validate_video_file(source_video) + self._destination_video = destination_video + self._codec = codec + self._codec_parameters = codec_parameters + self._mux_audio = mux_audio + + self._containers: dict[T.Literal["src", "dst"], InputContainer | OutputContainer] = { + "src": av.open(self._source_video, "r"), + "dst": av.open(self._destination_video, "w") + } + + self._next_audio_packet: av.Packet | None = None + self._audio_packets, self._fps = self._analyze_source() + self._video_packets: deque[av.Packet] = deque() + self._streams = self._set_output_streams() + + self._graph: av.filter.Graph | None = None + self._initialized = False + self._frame_index = 0 + + def __repr__(self) -> str: + """ Pretty print for logging """ + opts = ["_source_video", "_destination_video", "_codec", "_codec_parameters", "_mux_audio"] + params = {k[1:]: v for k, v in self.__dict__.items() if k in opts} + s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + + def _analyze_source(self) -> tuple[T.Generator[av.Packet, None, None] | None, Fraction]: + """Analyze the source to obtain the audio packets and the frame rate + + Returns + ------- + audio_packets + A generator containing audio packets from the source video, if audio is to be muxed + otherwise ``None`` + fps + The framerate of the original video + """ + src = T.cast("InputContainer", self._containers["src"]) + fps = src.streams.video[0].average_rate + assert fps is not None + logger.debug("[%s] Source fps: %s", self.__class__.__name__, fps) + + if not self._mux_audio: + logger.debug("[%s] Not muxing audio due to input parameters", self.__class__.__name__) + return None, fps + + audio = next((s for s in src.streams if s.type == "audio"), None) + if audio is None: + logger.warning("No audio stream could be found in the source video '%s'. Audio mux " + "will be disabled.", self._source_video) + self._mux_audio = False + return None, fps + + packets = (p for p in src.demux(audio) if p.dts is not None) + logger.debug("[%s] Muxing audio from source: %s", self.__class__.__name__, packets) + self._next_audio_packet = next(packets) + logger.debug("[%s] Queued first audio packet: %s", + self.__class__.__name__, self._next_audio_packet) + return packets, fps + + def _set_output_streams(self) -> dict[T.Literal["audio", "video"], + av.AudioStream | av.VideoStream]: + """Set the output audio and video streams + + Returns + ------- + The output streams. Audio stream is only included if muxing audio is selected and supported + """ + retval: dict[T.Literal["audio", "video"], av.AudioStream | av.VideoStream] = {} + dst = T.cast("OutputContainer", self._containers["dst"]) + video = dst.add_stream(self._codec, rate=self._fps, options=self._codec_parameters) + assert isinstance(video, av.VideoStream) + video.thread_type = "AUTO" + video.pix_fmt = "yuv420p" + retval["video"] = video + + if self._mux_audio: + src = self._containers["src"] + src_audio = next(s for s in src.streams if s.type == "audio") + audio = dst.add_stream_from_template(src_audio) + assert isinstance(audio, av.AudioStream) + retval["audio"] = audio + logger.debug("[%s] Added output streams: %s", self.__class__.__name__, retval) + return retval + + def _add_rescale_filter(self, + input_dimensions: tuple[int, int], + output_dimensions: tuple[int, int], + pixel_format: str) -> None: + """Add a rescale filter if the input dimensions are not divisible by 16 + + Parameters + ---------- + input_dimensions + The (W, H) size of the input frames to the video + output_dimensions + The (W, H) size of the output video + pixel_format + The pixel format of the output video + """ + if input_dimensions == output_dimensions: + return + self._graph = av.filter.Graph() + str_dims = f"{output_dimensions[0]}:{output_dimensions[1]}" + filters = [self._graph.add_buffer(width=input_dimensions[0], + height=input_dimensions[1], + format=av.VideoFormat(pixel_format), + time_base=Fraction(1, self._fps)), + self._graph.add("scale", f"{str_dims}:force_original_aspect_ratio=1"), + self._graph.add("pad", f"{str_dims}:(ow-iw)/2:(oh-ih)/2"), + self._graph.add("buffersink")] + for i in range(len(filters) - 1): + filters[i].link_to(filters[i + 1]) + self._graph.configure() + logger.debug("[%s] Created scale filter: %s", self.__class__.__name__, self._graph) + + def _initialize_video(self, image: npt.NDArray[np.uint8]) -> None: + """Initialize the video dimensions based on the first frame seen. We scale dimensions to be + divisible by 16 due to macro-blocking. + + Parameters + ---------- + image + The first frame passed into the muxer + """ + vid = T.cast(av.VideoStream, self._streams["video"]) + input_dimensions = (image.shape[1], image.shape[0]) + output_dimensions = (int(ceil(input_dimensions[0] / 16) * 16), + int(ceil(input_dimensions[1] / 16) * 16)) + vid.width = output_dimensions[0] + vid.height = output_dimensions[1] + logger.debug("[%s] Set video dimensions for first frame input: %s output: %s (%s)", + self.__class__.__name__, input_dimensions, output_dimensions, vid) + self._add_rescale_filter(input_dimensions, output_dimensions, T.cast(str, vid.pix_fmt)) + + logger.debug("[%s] Initialized video stream", self.__class__.__name__) + self._initialized = True + + def _encode_frame(self, image: npt.NDArray[np.uint8]) -> None: + """Encode the frame into packets and add the packets to the list of encoded packets to be + muxed + + Parameters + ---------- + image + The image to be encoded + """ + vid = T.cast(av.VideoStream, self._streams["video"]) + frame = av.VideoFrame.from_ndarray(image, format="bgr24") + frame.pts = self._frame_index + frame.time_base = Fraction(1, self._fps) + + if self._graph is not None: + # Need to convert to output format before running through filter graph + self._graph.push(frame.reformat(format=vid.pix_fmt)) + frame = T.cast(av.VideoFrame, self._graph.pull()) + + logger.trace("[%s] Encoded frame of shape %s to: %s", # type:ignore[attr-defined] + self.__class__.__name__, image.shape, frame) + + packets = vid.encode(frame) + self._video_packets.extend(packets) + logger.trace("[%s] Added video packets: %s", # type:ignore[attr-defined] + self.__class__.__name__, packets) + self._frame_index += 1 + + def _timestamp(self, packet: av.Packet) -> float: + """Obtain the standardized time stamp for the given packet + + Parameters + ---------- + packet + The packet to obtain the timestamp for + + Returns + ------- + The standardized timestamp + """ + assert packet.pts is not None + return float(packet.pts * packet.time_base) + + def _get_audio_packet(self, timestamp: float) -> av.Packet | None: + """Obtain the next audio packet if it should be output prior to the current timestamp and + queue the next audio packet for output + + Parameters + ---------- + timestamp + The timestamp of the next video packet to be output + """ + assert self._next_audio_packet is not None + next_ts = self._timestamp(self._next_audio_packet) + if next_ts >= timestamp: + logger.trace( # type:ignore[attr-defined] + "[%s] Next audio timestamp %s >= video timestamp %s. No audio to stream", + self.__class__.__name__, next_ts, timestamp) + return None + + assert self._audio_packets is not None + retval = self._next_audio_packet + self._next_audio_packet = next(self._audio_packets) + logger.trace( # type:ignore[attr-defined] + "[%s] Returning audio packet %s for timestamp %s < video timestamp: %s. Next queued " + "packet: %s", + self.__class__.__name__, retval, next_ts, timestamp, self._next_audio_packet) + retval.stream = self._streams["audio"] + return retval + + def _mux(self) -> None: + """Mux any audio and video packets that are ready to be output""" + out = T.cast("OutputContainer", self._containers["dst"]) + while self._video_packets: + video = self._video_packets.popleft() + if self._mux_audio: + while True: + audio = self._get_audio_packet(self._timestamp(video)) + if audio is None: + break + logger.trace("[%s] Muxing audio: %s", # type:ignore[attr-defined] + self.__class__.__name__, audio) + out.mux(audio) + logger.trace("[%s] Muxing video: %s", # type:ignore[attr-defined] + self.__class__.__name__, video) + out.mux(video) + + def encode(self, image: npt.NDArray[np.uint8] | None) -> None: + """Encode a frame to the video + + Parameters + ---------- + image + The 3 channel BGR UINT8 image to encode to the video or ``None`` to finalize the video + """ + if image is None: + logger.debug("[%s] EOF Received. Flushing", self.__class__.__name__) + self._video_packets.extend(self._streams["video"].encode()) + self._mux() + for container in self._containers.values(): + container.close() + return + + if not self._initialized: + self._initialize_video(image) + + self._encode_frame(image) + self._mux() + + +get_module_objects(__name__) diff --git a/plugins/convert/writer/_base.py b/plugins/convert/writer/_base.py index 6559d638ef..6d8730a4c8 100644 --- a/plugins/convert/writer/_base.py +++ b/plugins/convert/writer/_base.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 -""" Parent class for output writers for faceswap.py converter """ +"""Parent class for output writers for faceswap.py converter""" import logging import os import re import typing as T +from collections import deque import numpy as np @@ -15,13 +16,13 @@ class Output(): - """ Parent class for writer plugins. + """Parent class for writer plugins. Parameters ---------- - output_folder: str + output_folder The full path to the output folder where the converted media should be saved - config_file: str, optional + config_file The full path to a custom configuration ini file. If ``None`` is passed then the file is loaded from the default location. Default: ``None``. """ @@ -40,7 +41,7 @@ def __init__(self, output_folder: str, config_file: str | None = None) -> None: @property def is_stream(self) -> bool: - """ bool: Whether the writer outputs a stream or a series images. + """Whether the writer outputs a stream or a series images. Writers that write to a stream have a frame_order parameter to dictate the order in which frames should be written out (eg. gif/ffmpeg) """ @@ -49,35 +50,34 @@ def is_stream(self) -> bool: @property def output_alpha(self) -> bool: - """ bool : Override if the plugin can output an alpha channel and the user configuration + """Override if the plugin can output an alpha channel and the user configuration option is set to use it. Default ``False`` """ return False @classmethod def _set_frame_order(cls, total_count: int, - frame_ranges: list[tuple[int, int]] | None) -> list[int]: - """ Obtain the full list of frames to be converted in order. + frame_ranges: list[tuple[int, int]] | None) -> deque[int]: + """Obtain the full list of frames to be converted in order. Used for FFMPEG and Gif writers to ensure correct frame order Parameters ---------- - total_count: int + total_count The total number of frames to be converted - frame_ranges: list or ``None`` + frame_ranges List of tuples for starting and end values of each frame range to be converted or ``None`` if all frames are to be converted Returns ------- - list - Full list of all frame indices to be converted + Full Deque of all frame indices to be converted """ if frame_ranges is None: - retval = list(range(1, total_count + 1)) + retval = deque(range(1, total_count + 1)) else: - retval = [] + retval = deque() for rng in frame_ranges: retval.extend(list(range(rng[0], rng[1] + 1))) logger.debug("frame_order: %s", retval) @@ -87,23 +87,22 @@ def get_output_filename(self, filename: str, extension: str, separate_mask: bool = False) -> list[str]: - """ Obtain the full path for the output file, including the correct extension, for the + """Obtain the full path for the output file, including the correct extension, for the given input filename. Parameters ---------- - filename : str + filename The input frame filename to generate the output file name for - extension : str + extension The extension to use for the output file - separate_mask: bool, optional + separate_mask ``True`` if the mask should be saved out to a sub-folder otherwise ``False`` Returns ------- - list - The full path for the output converted frame to be saved to in position 1. The full - path for the mask to be output to in position 2 (if requested) + The full path for the output converted frame to be saved to in position 1. The full path + for the mask to be output to in position 2 (if requested) """ extension = extension.strip(".") filename = os.path.splitext(os.path.basename(filename))[0] @@ -122,16 +121,16 @@ def get_output_filename(self, return retval def cache_frame(self, filename: str, image: np.ndarray) -> None: - """ Add the incoming converted frame to the cache ready for writing out. + """Add the incoming converted frame to the cache ready for writing out. Used for ffmpeg and gif writers to ensure that the frames are written out in the correct order. Parameters ---------- - filename: str + filename The filename of the incoming frame, where the frame index can be extracted from - image: class:`numpy.ndarray` + image The converted frame corresponding to the given filename """ re_frame = re.search(self.re_search, filename) @@ -142,13 +141,13 @@ def cache_frame(self, filename: str, image: np.ndarray) -> None: logger.trace("Current cache: %s", sorted(self.cache.keys())) # type:ignore def write(self, filename: str, image: T.Any) -> None: - """ Override for specific frame writing method. + """Override for specific frame writing method. Parameters ---------- - filename: str + filename The incoming frame filename. - image: Any + image The converted image to be written. Could be a numpy array, a bytes encoded image or any other plugin specific format """ @@ -164,17 +163,16 @@ def pre_encode(self, image: np.ndarray, **kwargs) -> T.Any: # pylint:disable=un Parameters ---------- - image: :class:`numpy.ndarray` + image The converted image that is to be run through the pre-encoding function Returns ------- - Any or ``None`` - If ``None`` then the writer does not support pre-encoding, otherwise return output of - the plugin specific pre-encode function + If ``None`` then the writer does not support pre-encoding, otherwise return output of the + plugin specific pre-encode function """ return None def close(self) -> None: - """ Override for specific converted frame writing close methods """ + """Override for specific converted frame writing close methods""" raise NotImplementedError diff --git a/plugins/convert/writer/ffmpeg.py b/plugins/convert/writer/ffmpeg.py index 50d7407c68..c3606923a8 100644 --- a/plugins/convert/writer/ffmpeg.py +++ b/plugins/convert/writer/ffmpeg.py @@ -1,27 +1,28 @@ #!/usr/bin/env python3 """ Video output writer for faceswap.py converter """ from __future__ import annotations -import os -import typing as T - -from math import ceil -from subprocess import CalledProcessError, check_output, STDOUT -import imageio -import imageio_ffmpeg as im_ffm -import numpy as np +import logging +import typing as T +import os +from collections import deque +from lib.logger import parse_class_init from lib.utils import get_module_objects +from lib.video import VideoMux -from ._base import Output, logger +from ._base import Output from . import ffmpeg_defaults as cfg if T.TYPE_CHECKING: - from collections.abc import Generator + import numpy as np + import numpy.typing as npt + +logger = logging.getLogger(__name__) class Writer(Output): - """ Video output writer using imageio-ffmpeg. + """ Video output writer using pyAV. Parameters ---------- @@ -44,15 +45,10 @@ def __init__(self, source_video: str, **kwargs) -> None: super().__init__(output_folder, **kwargs) - logger.debug("total_count: %s, frame_ranges: %s, source_video: '%s'", - total_count, frame_ranges, source_video) - self._source_video: str = source_video - self._output_filename: str = self._get_output_filename() + logger.debug(parse_class_init(locals())) self._frame_ranges: list[tuple[int, int]] | None = frame_ranges - self._frame_order: list[int] = self._set_frame_order(total_count, frame_ranges) - self._output_dimensions: str | None = None # Fix dims on 1st received frame - # Need to know dimensions of first frame, so set writer then - self._writer: Generator[None, np.ndarray, None] | None = None + self._frame_order: deque[int] = self._set_frame_order(total_count, frame_ranges) + self._muxer = self._get_muxer(source_video) @property def _valid_tunes(self) -> dict: @@ -61,97 +57,7 @@ def _valid_tunes(self) -> dict: "zerolatency"], "libx265": ["grain", "fastdecode", "zerolatency"]} - @property - def _video_fps(self) -> float: - """ float: The fps of the source video. """ - reader = imageio.get_reader(self._source_video, "ffmpeg") # type:ignore[arg-type] - retval = reader.get_meta_data()["fps"] - reader.close() - logger.debug(retval) - return retval - - @property - def _output_params(self) -> list[str]: - """ list: The FFMPEG Output parameters """ - codec = cfg.codec() - tune = cfg.tune() - # Force all frames to the same size - output_args = ["-vf", f"scale={self._output_dimensions}"] - - output_args.extend(["-crf", str(cfg.crf())]) - output_args.extend(["-preset", cfg.preset()]) - - if tune is not None and tune in self._valid_tunes[codec]: - output_args.extend(["-tune", tune]) - - if codec == "libx264" and cfg.profile() != "auto": - output_args.extend(["-profile:v", cfg.profile()]) - - if codec == "libx264" and cfg.level() != "auto": - output_args.extend(["-level", cfg.level()]) - - logger.debug(output_args) - return output_args - - @property - def _audio_codec(self) -> str | None: - """ str or ``None``: The audio codec to use. This will either be ``"copy"`` (the default) - or ``None`` if skip muxing has been selected in configuration options, or if frame ranges - have been passed in the command line arguments. """ - retval: str | None = "copy" - if cfg.skip_mux(): - logger.info("Skipping audio muxing due to configuration settings.") - retval = None - elif self._frame_ranges is not None: - logger.warning("Muxing audio is not supported for limited frame ranges." - "The output video will be created but you will need to mux audio " - "manually.") - retval = None - elif not self._test_for_audio_stream(): - logger.warning("No audio stream could be found in the source video '%s'. Muxing audio " - "will be disabled.", self._source_video) - retval = None - logger.debug("Audio codec: %s", retval) - return retval - - def _test_for_audio_stream(self) -> bool: - """ Check whether the source video file contains an audio stream. - - If we attempt to mux audio from a source video that does not contain an audio stream - ffmpeg will crash faceswap in a fairly ugly manner. - - Returns - ------- - bool - ``True`` if an audio stream is found in the source video file, otherwise ``False`` - - Raises - ------ - ValueError - If a subprocess error is raised scanning the input video file - """ - exe = im_ffm.get_ffmpeg_exe() - cmd = [exe, "-hide_banner", "-i", self._source_video, "-f", "ffmetadata", "-"] - - try: - out = check_output(cmd, stderr=STDOUT) - except CalledProcessError as err: - err_out = err.output.decode(errors="ignore") - msg = f"Error checking audio stream. Status: {err.returncode}\n{err_out}" - raise ValueError(msg) from err - - retval = False - for line in out.splitlines(): - if not line.strip().startswith(b"Stream #"): - continue - logger.debug("scanning Stream line: %s", line.decode(errors="ignore").strip()) - if b"Audio" in line: - retval = True - break - logger.debug("Audio found: %s", retval) - return retval - - def _get_output_filename(self) -> str: + def _get_output_filename(self, source_filename: str) -> str: """ Return full path to video output file. The filename is the same as the input video with `"_converted"` appended to the end. The @@ -159,12 +65,16 @@ def _get_output_filename(self) -> str: given filename, then `"_1"` is appended to the end of the filename. This number iterates until a valid filename that does not exist is found. + Parameters + ---------- + The filename of the source/reference video + Returns ------- str The full path to the output video filename """ - filename = os.path.basename(self._source_video) + filename = os.path.basename(source_filename) filename = os.path.splitext(filename)[0] ext = cfg.container() idx = 0 @@ -174,43 +84,83 @@ def _get_output_filename(self) -> str: if not os.path.exists(retval): break idx += 1 - logger.info("Outputting to: '%s'", retval) + logger.info("[FFMPEG] Outputting to: '%s'", retval) return retval - def _get_writer(self, frame_dims: tuple[int, int]) -> Generator[None, np.ndarray, None]: - """ Add the requested encoding options and return the writer. + def _get_codec_parameters(self) -> dict[str, str]: + """Obtain the selected video codec parameters - Parameters - ---------- - frame_dims: tuple - The (rows, colums) shape of the input image + Returns + ------- + Parameter option name to parameter value for the codec options + """ + codec = cfg.codec() + tune = cfg.tune() + + output_args = {"crf": str(cfg.crf()), + "preset": cfg.preset()} + + if tune is not None and tune in self._valid_tunes[codec]: + output_args["tune"] = tune + + if codec == "libx264" and cfg.profile() != "auto": + output_args["profile"] = cfg.profile() + + if codec == "libx264" and cfg.level() != "auto": + output_args["level"] = cfg.level() + + logger.debug("[FFMPEG] codec_params: %s", output_args) + return output_args + + def _should_mux_audio(self) -> bool: + """Test if audio should be muxed based on selected parameters Returns ------- - generator - The imageio ffmpeg writer + ``True`` if audio should be muxed """ - audio_codec = self._audio_codec - audio_path = None if audio_codec is None else self._source_video - logger.debug("writer audio_path: '%s'", audio_path) - - retval = im_ffm.write_frames(self._output_filename, - size=(frame_dims[1], frame_dims[0]), - fps=self._video_fps, - quality=None, - codec=cfg.codec(), - macro_block_size=8, - ffmpeg_log_level="error", - ffmpeg_timeout=10, - output_params=self._output_params, - audio_path=audio_path, - audio_codec=audio_codec) - logger.debug("FFMPEG Writer created: %s", retval) - retval.send(None) + if cfg.skip_mux(): + logger.info("Skipping audio muxing due to configuration settings.") + return False - return retval + if self._frame_ranges is not None: + logger.warning("Muxing audio is not supported for limited frame ranges." + "The output video will be created but you will need to mux audio " + "manually.") + return False + + logger.debug("[FFMPEG] Audio will be muxed") + return True - def write(self, filename: str, image: np.ndarray) -> None: + def _get_muxer(self, source_filename: str) -> VideoMux: + """Obtain the VideoMux object for encoding the video + + Parameters + ---------- + source_filename + The filename of the reference source video + """ + out_file = self._get_output_filename(source_filename) + params = self._get_codec_parameters() + mux_audio = self._should_mux_audio() + codec = T.cast(T.Literal["libx264", "libx265"], cfg.codec()) + return VideoMux(source_filename, out_file, codec, params, mux_audio) + + def _save_from_cache(self) -> None: + """Sends any any consecutive frames to the muxer that are ready to be output from cache.""" + while self._frame_order: + if self._frame_order[0] not in self.cache: + logger.trace("Next frame not ready. Continuing") # type:ignore[attr-defined] + break + save_no = self._frame_order.popleft() + save_image = self.cache.pop(save_no) + logger.trace( # type:ignore[attr-defined] + "[FFMPEG] Rendering from cache. Frame no: %s", save_no) + self._muxer.encode(save_image) + logger.trace("[FFMPEG] Current cache size: %s", # type:ignore[attr-defined] + len(self.cache)) + + def write(self, filename: str, image: npt.NDArray[np.uint8]) -> None: """ Frames come from the pool in arbitrary order, so frames are cached for writing out in the correct order. @@ -223,47 +173,12 @@ def write(self, filename: str, image: np.ndarray) -> None: """ logger.trace("Received frame: (filename: '%s', shape: %s", # type:ignore[attr-defined] filename, image.shape) - if not self._output_dimensions: - input_dims = T.cast(tuple[int, int], image.shape[:2]) - self._set_dimensions(input_dims) - self._writer = self._get_writer(input_dims) self.cache_frame(filename, image) self._save_from_cache() - def _set_dimensions(self, frame_dims: tuple[int, int]) -> None: - """ Set the attribute :attr:`_output_dimensions` based on the first frame received. - This protects against different sized images coming in and ensures all images are written - to ffmpeg at the same size. Dimensions are mapped to a macro block size 8. - - Parameters - ---------- - frame_dims: tuple - The (rows, colums) shape of the input image - """ - logger.debug("input dimensions: %s", frame_dims) - self._output_dimensions = (f"{int(ceil(frame_dims[1] / 8) * 8)}:" - f"{int(ceil(frame_dims[0] / 8) * 8)}") - logger.debug("Set dimensions: %s", self._output_dimensions) - - def _save_from_cache(self) -> None: - """ Writes any consecutive frames to the video container that are ready to be output - from the cache. """ - assert self._writer is not None - while self._frame_order: - if self._frame_order[0] not in self.cache: - logger.trace("Next frame not ready. Continuing") # type:ignore[attr-defined] - break - save_no = self._frame_order.pop(0) - save_image = self.cache.pop(save_no) - logger.trace("Rendering from cache. Frame no: %s", # type:ignore[attr-defined] - save_no) - self._writer.send(np.ascontiguousarray(save_image[:, :, ::-1])) - logger.trace("Current cache size: %s", len(self.cache)) # type:ignore[attr-defined] - def close(self) -> None: - """ Close the ffmpeg writer and mux the audio """ - if self._writer is not None: - self._writer.close() + """ Close the ffmpeg writer""" + self._muxer.encode(None) __all__ = get_module_objects(__name__) diff --git a/plugins/convert/writer/gif.py b/plugins/convert/writer/gif.py index d00171f196..6dc2694ca5 100644 --- a/plugins/convert/writer/gif.py +++ b/plugins/convert/writer/gif.py @@ -1,35 +1,44 @@ #!/usr/bin/env python3 -""" Animated GIF writer for faceswap.py converter """ +"""Animated GIF writer for faceswap.py converter""" from __future__ import annotations + +import logging import os import typing as T +from collections import deque import cv2 -import imageio +import numpy as np +from PIL import Image +from scipy.spatial import cKDTree # type:ignore[attr-defined] +from sklearn.cluster import MiniBatchKMeans +from lib.logger import parse_class_init from lib.utils import get_module_objects -from ._base import Output, logger +from ._base import Output from . import gif_defaults as cfg if T.TYPE_CHECKING: - from imageio.core import format as im_format # noqa:F401 + import numpy.typing as npt + +logger = logging.getLogger(__name__) class Writer(Output): - """ GIF output writer using imageio. + """GIF output writer using PIL. Parameters ---------- - output_folder: str + output_folder The folder to save the output gif to - total_count: int + total_count The total number of frames to be converted - frame_ranges: list or ``None`` + frame_ranges List of tuples for starting and end values of each frame range to be converted or ``None`` if all frames are to be converted - kwargs: dict + kwargs Any additional standard :class:`plugins.convert.writer._base.Output` key word arguments. """ def __init__(self, @@ -37,62 +46,17 @@ def __init__(self, total_count: int, frame_ranges: list[tuple[int, int]] | None, **kwargs) -> None: - logger.debug("total_count: %s, frame_ranges: %s", total_count, frame_ranges) + logger.debug(parse_class_init(locals())) super().__init__(output_folder, **kwargs) - self._frame_order: list[int] = self._set_frame_order(total_count, frame_ranges) + self._frame_order: deque[int] = self._set_frame_order(total_count, frame_ranges) # Fix dims on 1st received frame - self._output_dimensions: tuple[int, int] | None = None - # Need to know dimensions of first frame, so set writer then - self._writer: imageio.plugins.pillowmulti.GIFFormat.Writer | None = None + self._dimensions = (0, 0) + self._images: list[np.ndarray] = [] + self._palette: dict[int, int] = {} self._gif_file: str | None = None # Set filename based on first file seen - @property - def _gif_params(self) -> dict: - """ dict: The selected gif plugin configuration options. """ - kwargs = {"fps": cfg.fps(), - "loop": cfg.loop(), - "palettesize": cfg.palettesize(), - "subrectangles": cfg.subrectangles()} - logger.debug(kwargs) - return kwargs - - def _get_writer(self) -> im_format.Format.Writer: - """ Obtain the GIF writer with the requested GIF encoding options. - - Returns - ------- - :class:`imageio.plugins.pillowmulti.GIFFormat.Writer` - The imageio GIF writer - """ - assert self._gif_file is not None - return imageio.get_writer(self._gif_file, - mode="i", - **self._gif_params) - - def write(self, filename: str, image) -> None: - """ Frames come from the pool in arbitrary order, so frames are cached for writing out - in the correct order. - - Parameters - ---------- - filename: str - The incoming frame filename. - image: :class:`numpy.ndarray` - The converted image to be written - """ - logger.trace("Received frame: (filename: '%s', shape: %s", # type: ignore - filename, image.shape) - if not self._gif_file: - self._set_gif_filename(filename) - self._set_dimensions(image.shape[:2]) - self._writer = self._get_writer() - if (image.shape[1], image.shape[0]) != self._output_dimensions: - image = cv2.resize(image, self._output_dimensions) # pylint:disable=no-member - self.cache_frame(filename, image) - self._save_from_cache() - def _set_gif_filename(self, filename: str) -> None: - """ Set the full path to GIF output file to :attr:`_gif_file` + """Set the full path to GIF output file to :attr:`_gif_file` The filename is the created from the source filename of the first input image received with `"_converted"` appended to the end and a .gif extension. If a file already exists with the @@ -101,11 +65,11 @@ def _set_gif_filename(self, filename: str) -> None: Parameters ---------- - filename: str + filename The incoming frame filename. """ - logger.debug("sample filename: '%s'", filename) + logger.debug("[GIF] sample filename: '%s'", filename) filename = os.path.splitext(os.path.basename(filename))[0] snip = len(filename) for char in list(filename[::-1]): @@ -123,36 +87,129 @@ def _set_gif_filename(self, filename: str) -> None: idx += 1 self._gif_file = retval - logger.info("Outputting to: '%s'", self._gif_file) - - def _set_dimensions(self, frame_dims: tuple[int, int]) -> None: - """ Set the attribute :attr:`_output_dimensions` based on the first frame received. This - protects against different sized images coming in and ensure all images get written to the - Gif at the sema dimensions. """ - # pylint:disable=duplicate-code - logger.debug("input dimensions: %s", frame_dims) - self._output_dimensions = (frame_dims[1], frame_dims[0]) - logger.debug("Set dimensions: %s", self._output_dimensions) + logger.info("[GIF] Outputting to: '%s'", self._gif_file) def _save_from_cache(self) -> None: - """ Writes any consecutive frames to the GIF container that are ready to be output - from the cache. """ - # pylint:disable=duplicate-code - assert self._writer is not None + """Writes any consecutive frames to the GIF container that are ready to be output + from the cache.""" while self._frame_order: if self._frame_order[0] not in self.cache: - logger.trace("Next frame not ready. Continuing") # type: ignore + logger.trace( # type: ignore[attr-defined] + "[GIF] Next frame not ready. Continuing") break - save_no = self._frame_order.pop(0) - save_image = self.cache.pop(save_no) - logger.trace("Rendering from cache. Frame no: %s", save_no) # type: ignore - self._writer.append_data(save_image[:, :, ::-1]) - logger.trace("Current cache size: %s", len(self.cache)) # type: ignore + save_no = self._frame_order.popleft() + logger.trace("[GIF] Rendering from cache. Frame no: %s", # type: ignore[attr-defined] + save_no) + img = self.cache.pop(save_no) + if img.size != self._dimensions: + img = cv2.resize(img, self._dimensions) + self._images.append(img) + logger.trace("[GIF] Current cache size: %s", len(self.cache)) # type: ignore[attr-defined] + + def write(self, filename: str, image: npt.NDArray[np.uint8]) -> None: + """Frames come from the pool in arbitrary order, so frames are cached for writing out + in the correct order. + + Parameters + ---------- + filename + The incoming frame filename. + image + The converted image to be written + """ + logger.trace( # type: ignore[attr-defined] + "[GIF] Received frame: (filename: '%s', shape: %s", filename, image.shape) + dimensions = (image.shape[1], image.shape[0]) + if not self._gif_file: + self._set_gif_filename(filename) + self._dimensions = dimensions + img = image[:, :, ::-1] + self.cache_frame(filename, img) + self._save_from_cache() + + def _build_palette(self, images: npt.NDArray[np.uint8]): + """Obtain a color palette from the images to be saved + + Parameters + ---------- + images + The converted images batched into a single array + """ + palette_size = int(cfg.palette_size()) + logger.info("[GIF] Generating palette of size %s...", palette_size) + pixels = images.reshape(-1, 3) + num_samples = 100000 + + if pixels.shape[0] > num_samples: + idx = np.random.choice(pixels.shape[0], num_samples, replace=False) + pixels = pixels[idx] + + k_means = MiniBatchKMeans(n_clusters=palette_size, batch_size=4096) + k_means.fit(pixels) + + palette = k_means.cluster_centers_.astype(np.uint8) + return palette + + def _quantize_frame(self, mapped: np.ndarray, palette: bytes) -> Image.Image: + """Quantize a frame and convert to PIL Image + + Parameters + ---------- + mapped + The mapped frame to quantize + tree + The K-Means tree to use for quantization + palette + The palette to apply to the frame + + Returns + ------- + The quantized PIL image + """ + img = Image.fromarray(mapped, mode='P') + del mapped + img.putpalette(palette) + if cfg.dithering(): + meth = Image.FLOYDSTEINBERG # type:ignore[attr-defined] # pylint:disable=no-member + img = img.convert("P", dither=meth) + return img + + def _quantize_images(self) -> list[Image.Image]: + """Quantize the images for writing to GIF + + Returns + ------- + The list of quantized images + """ + images = np.stack(self._images) + im_shape = images.shape + del self._images + palette = self._build_palette(images) + tree = cKDTree(palette) + logger.info("[GIF] Mapping colors...") + _, mapped_flat = tree.query(images.reshape(-1, 3)) + del images + mapped = T.cast("npt.NDArray[np.uint8]", + mapped_flat.reshape(im_shape[:3]).astype(np.uint8)) + flat_palette = palette.flatten().tobytes() + imgs = [self._quantize_frame(im, flat_palette) for im in mapped] + return imgs def close(self) -> None: - """ Close the GIF writer on completion. """ - if self._writer is not None: - self._writer.close() + """Close the GIF writer on completion.""" + if not self._images: + return + assert self._gif_file is not None + logger.info("[GIF] Creating GIF. Depending on the number of frames this may take a " + "while...") + imgs = self._quantize_images() + assert self._gif_file is not None + logger.info("[GIF] Saving...") + imgs[0].save(self._gif_file, + save_all=True, + append_images=imgs[1:], + duration=1000 / cfg.fps(), + loop=cfg.loop()) __all__ = get_module_objects(__name__) diff --git a/plugins/convert/writer/gif_defaults.py b/plugins/convert/writer/gif_defaults.py index c0dd27c580..25303e1a5d 100755 --- a/plugins/convert/writer/gif_defaults.py +++ b/plugins/convert/writer/gif_defaults.py @@ -28,7 +28,9 @@ from lib.config import ConfigItem -HELPTEXT = "Options for outputting converted frames to an animated gif." +HELPTEXT = ("Options for outputting converted frames to an animated GIF.\n" + "Note: GIF creation needs to load all images into RAM so you should only use short " + "sequences") fps = ConfigItem( @@ -47,7 +49,7 @@ rounding=1, min_max=(0, 100)) -palettesize = ConfigItem( +palette_size = ConfigItem( datatype=str, default="256", group="settings", @@ -55,9 +57,9 @@ "two.", choices=["2", "4", "8", "16", "32", "64", "128", "256"]) -subrectangles = ConfigItem( +dithering = ConfigItem( datatype=bool, default=False, group="settings", - info="If True, will try and optimize the GIF by storing only the rectangular parts of " - "each frame that change with respect to the previous.") + info="Apply dithering. Improves gradients but adds noise. Good for natural images, bad for " + "sharp images.") diff --git a/pyproject.toml b/pyproject.toml index a315ebc5c5..e721cd4d7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,14 +13,14 @@ max-attributes = 10 max-positional-arguments = 10 [tool.pylint.TYPECHECK] -generated-members = ["cv2"] +generated-members = ["av", "cv2"] [[tool.mypy.overrides]] module = [ "fastcluster.*", + "ffmpeg.*", "ffmpy.*", "h5py.*", - "imageio_ffmpeg.*", "keras.*", "numexpr.*", "pexpect.*", diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index 9daed21789..9b6c8e285e 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -8,10 +8,8 @@ pillow>=12.1.0 scikit-learn>=1.8.0 fastcluster>=1.3.0 matplotlib>=3.10.0 -imageio>=2.37.0 -# ffmpeg binary >=0.6.0 breaks convert. -# TODO fix convert to use latest binary -imageio-ffmpeg>=0.4.9,<0.6.0 +av>=17.0 +ffmpeg-binaries>=1.1 ffmpy>=1.0.0 pywin32>=305 ; sys_platform == "win32" #torchvision>=0.18.0,<0.25.0 diff --git a/scripts/convert.py b/scripts/convert.py index 19ddc44ec1..2c95bc879c 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -527,8 +527,9 @@ def _load(self, *args) -> None: # pylint:disable=unused-argument """ logger.debug("Load Images: Start") idx = 0 - for filename, image in self._images.load(): + for filename_image in self._images.load(): idx += 1 + filename, image = filename_image[:2] if self._queues["load"].shutdown_event.is_set(): logger.debug("Load Queue: Stop signal received. Terminating") break diff --git a/scripts/extract.py b/scripts/extract.py index e49a680a84..b61be6079a 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -27,8 +27,8 @@ from lib.image import encode_image, ImagesLoader, ImagesSaver from lib.logger import parse_class_init from lib.multithreading import FSThread -from lib.utils import (get_folder, get_module_objects, handle_deprecated_cli_opts, - IMAGE_EXTENSIONS, VIDEO_EXTENSIONS) +from lib.utils import get_folder, get_module_objects, handle_deprecated_cli_opts, IMAGE_EXTENSIONS +from lib.video import VIDEO_EXTENSIONS from .fs_media import Alignments, finalize @@ -501,7 +501,8 @@ def _finalize(self) -> None: def _load(self) -> None: """ Load images from disk and pass to a queue for the extraction pipeline """ logger.debug("[Extract.Loader] start") - for filename, image in self._images.load(): + for filename_image in self._images.load(): + filename, image = filename_image[:2] faces = self._get_detected_faces(filename) self._pipeline.put(filename, image, source=self.location, detected_faces=faces) if self.error_state.has_error: diff --git a/scripts/fs_media.py b/scripts/fs_media.py index 60f278d480..36ffe91a47 100644 --- a/scripts/fs_media.py +++ b/scripts/fs_media.py @@ -11,20 +11,14 @@ import sys import typing as T -from collections.abc import Iterator - import numpy as np -import imageio from lib.align import Alignments as AlignmentsBase -from lib.image import count_frames, read_image from lib.logger import parse_class_init from lib.serializer import get_serializer -from lib.utils import get_image_paths, get_module_objects, VIDEO_EXTENSIONS +from lib.utils import get_module_objects if T.TYPE_CHECKING: - from collections.abc import Generator - from argparse import Namespace from lib.align.alignments import AlignmentFileDict logger = logging.getLogger(__name__) @@ -246,179 +240,4 @@ def _import_from_json(self) -> None: logger.info("Imported %s frames from '%s'", len(data), json_file) -class Images(): - """Handles the loading of frames from a folder of images or a video file for extract - and convert processes. - - Parameters - ---------- - arguments - The command line arguments that were passed to Faceswap - """ - def __init__(self, arguments: Namespace) -> None: - logger.debug("Initializing %s", self.__class__.__name__) - self._args = arguments - self._is_video = self._check_input_folder() - self._input_images = self._get_input_images() - self._images_found = self._count_images() - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def is_video(self) -> bool: - """``True`` if the input is a video file otherwise ``False``. """ - return self._is_video - - @property - def input_images(self) -> str | list[str]: - """Path to the video file if the input is a video otherwise list of image paths.""" - return self._input_images - - @property - def images_found(self) -> int: - """The number of frames that exist in the video file, or the folder of images.""" - return self._images_found - - def _count_images(self) -> int: - """Get the number of Frames from a video file or folder of images. - - Returns - ------- - The number of frames in the image source - """ - if self._is_video: - retval = int(count_frames(self._args.input_dir, fast=True)) - else: - retval = len(self._input_images) - return retval - - def _check_input_folder(self) -> bool: - """Check whether the input is a folder or video. - - Returns - ------- - ``True`` if the input is a video otherwise ``False`` - """ - if not os.path.exists(self._args.input_dir): - logger.error("Input location %s not found.", self._args.input_dir) - sys.exit(1) - if (os.path.isfile(self._args.input_dir) and - os.path.splitext(self._args.input_dir)[1].lower() in VIDEO_EXTENSIONS): - logger.info("Input Video: %s", self._args.input_dir) - retval = True - else: - logger.info("Input Directory: %s", self._args.input_dir) - retval = False - return retval - - def _get_input_images(self) -> str | list[str]: - """Return the list of images or path to video file that is to be processed. - - Returns - ------- - Path to the video file if the input is a video otherwise list of image paths. - """ - if self._is_video: - input_images = self._args.input_dir - else: - input_images = get_image_paths(self._args.input_dir) - - return input_images - - def load(self) -> Generator[tuple[str, np.ndarray], None, None]: - """Generator to load frames from a folder of images or from a video file. - - Yields - ------ - filename - The filename of the current frame - image - A single frame - """ - iterator = self._load_video_frames if self._is_video else self._load_disk_frames - for filename, image in iterator(): - yield filename, image - - def _load_disk_frames(self) -> Generator[tuple[str, np.ndarray], None, None]: - """Generator to load frames from a folder of images. - - Yields - ------ - filename - The filename of the current frame - image - A single frame - """ - logger.debug("Input is separate Frames. Loading images") - for filename in self._input_images: - image = read_image(filename, raise_error=False) - if image is None: - continue - yield filename, image - - def _load_video_frames(self) -> Generator[tuple[str, np.ndarray], None, None]: - """Generator to load frames from a video file. - - Yields - ------ - filename - The filename of the current frame - image - A single frame - """ - logger.debug("Input is video. Capturing frames") - vid_name, ext = os.path.splitext(os.path.basename(self._args.input_dir)) - reader = imageio.get_reader(self._args.input_dir, "ffmpeg") # type:ignore[arg-type] - for i, frame in enumerate(T.cast(Iterator[np.ndarray], reader)): - # Convert to BGR for cv2 compatibility - frame = frame[:, :, ::-1] - filename = f"{vid_name}_{i + 1:06d}{ext}" - logger.trace("Loading video frame: '%s'", filename) # type:ignore[attr-defined] - yield filename, frame - reader.close() - - def load_one_image(self, filename) -> np.ndarray: - """Obtain a single image for the given filename. - - Parameters - ---------- - filename - The filename to return the image for - - Returns - ------ - The image for the requested filename, - """ - logger.trace("Loading image: '%s'", filename) # type:ignore[attr-defined] - if self._is_video: - if filename.isdigit(): - frame_no = filename - else: - frame_no = os.path.splitext(filename)[0][filename.rfind("_") + 1:] - logger.trace( # type:ignore[attr-defined] - "Extracted frame_no %s from filename '%s'", frame_no, filename) - retval = self._load_one_video_frame(int(frame_no)) - else: - retval = read_image(filename, raise_error=True) - return retval - - def _load_one_video_frame(self, frame_no: int) -> np.ndarray: - """Obtain a single frame from a video file. - - Parameters - ---------- - frame_no - The frame index for the required frame - - Returns - ------ - The image for the requested frame index, - """ - logger.trace("Loading video frame: %s", frame_no) # type:ignore[attr-defined] - reader = imageio.get_reader(self._args.input_dir, "ffmpeg") # type:ignore[arg-type] - reader.set_image_index(frame_no - 1) - frame = reader.get_next_data()[:, :, ::-1] # type:ignore[index] - reader.close() - return frame - - __all__ = get_module_objects(__name__) diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index 0953edb1ee..efeef4323a 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -8,8 +8,8 @@ from argparse import Namespace from multiprocessing import Process -from lib.utils import (get_module_objects, FaceswapError, - handle_deprecated_cli_opts, VIDEO_EXTENSIONS) +from lib.utils import get_module_objects, FaceswapError, handle_deprecated_cli_opts +from lib.video import VIDEO_EXTENSIONS from .media import AlignmentData from .jobs import Check, Export, Sort, Spatial # noqa pylint:disable=unused-import from .jobs_faces import FromFaces, RemoveFaces, Rename # noqa pylint:disable=unused-import diff --git a/tools/alignments/jobs_frames.py b/tools/alignments/jobs_frames.py index e29cbd6828..0d4448479c 100644 --- a/tools/alignments/jobs_frames.py +++ b/tools/alignments/jobs_frames.py @@ -225,9 +225,10 @@ def _get_count(self) -> int | None: frames is returned. In all other cases ``None`` is returned """ meta = self._alignments.video_meta_data - has_meta = all(val is not None for val in meta.values()) + has_meta = meta is not None and all(val is not None for val in meta.values()) if has_meta: - retval: int | None = len(T.cast(dict[str, list[int] | list[float]], meta["pts_time"])) + assert meta is not None + retval: int | None = len(T.cast(dict[str, list[int]], meta["pts_time"])) else: retval = None logger.debug("Frame count from alignments file: (has_meta: %s, %s", has_meta, retval) diff --git a/tools/alignments/media.py b/tools/alignments/media.py index b92d233ca0..519db4c6d3 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" Media items (Alignments, Faces, Frames) - for alignments tool """ +"""Media items (Alignments, Faces, Frames) for alignments tool""" from __future__ import annotations import logging from operator import itemgetter @@ -11,13 +10,11 @@ import cv2 from tqdm import tqdm -# TODO imageio single frame seek seems slow. Look into this -# import imageio - from lib.align import Alignments, DetectedFace, update_legacy_png_header -from lib.image import (count_frames, generate_thumbnail, ImagesLoader, - png_write_meta, read_image, read_image_meta_batch) -from lib.utils import get_module_objects, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, FaceswapError +from lib.image import (generate_thumbnail, ImagesLoader, png_write_meta, read_image, + read_image_meta_batch) +from lib.utils import get_module_objects, IMAGE_EXTENSIONS, FaceswapError +from lib.video import count_frames, VIDEO_EXTENSIONS if T.TYPE_CHECKING: from collections.abc import Generator @@ -28,11 +25,11 @@ class AlignmentData(Alignments): - """ Class to hold the alignment data + """Class to hold the alignment data Parameters ---------- - alignments_file: str + alignments_file Full path to an alignments file """ def __init__(self, alignments_file: str) -> None: @@ -41,23 +38,23 @@ def __init__(self, alignments_file: str) -> None: logger.info("[ALIGNMENT DATA]") # Tidy up cli output folder, filename = self.check_file_exists(alignments_file) super().__init__(folder, filename=filename) - logger.verbose("%s items loaded", self.frames_count) # type: ignore + logger.verbose("%s items loaded", self.frames_count) # type:ignore[attr-defined] logger.debug("Initialized %s", self.__class__.__name__) @staticmethod def check_file_exists(alignments_file: str) -> tuple[str, str]: - """ Check if the alignments file exists, and returns a tuple of the folder and filename. + """ Check if the alignments file exists, and returns a tuple of the folder and filename. Parameters ---------- - alignments_file: str + alignments_file Full path to an alignments file Returns ------- - folder: str + folder The full path to the folder containing the alignments file - filename: str + filename The filename of the alignments file """ folder, filename = os.path.split(alignments_file) @@ -65,23 +62,24 @@ def check_file_exists(alignments_file: str) -> tuple[str, str]: logger.error("ERROR: alignments file not found at: '%s'", alignments_file) sys.exit(0) if folder: - logger.verbose("Alignments file exists at '%s'", alignments_file) # type: ignore + logger.verbose( # type:ignore[attr-defined] + "Alignments file exists at '%s'", alignments_file) return folder, filename def save(self) -> None: - """ Backup copy of old alignments and save new alignments """ + """Backup copy of old alignments and save new alignments """ self.backup() super().save() class MediaLoader(): - """ Class to load images. + """Class to load images. Parameters ---------- - folder: str + folder The folder of images or video file to load images from - count: int or ``None``, optional + count If the total frame count is known it can be passed in here which will skip analyzing a video file. If the count is not passed in, it will be calculated. Default: ``None`` @@ -94,17 +92,17 @@ def __init__(self, folder: str, count: int | None = None): self._vid_reader = self.check_input_folder() self.file_list_sorted = self.sorted_items() self.items = self.load_items() - logger.verbose("%s items loaded", self.count) # type: ignore + logger.verbose("%s items loaded", self.count) # type:ignore[attr-defined] logger.debug("Initialized %s", self.__class__.__name__) @property def is_video(self) -> bool: - """ bool: Return whether source is a video or not """ + """Whether source is a video or not""" return self._vid_reader is not None @property def count(self) -> int: - """ int: Number of faces or frames """ + """Number of faces or frames""" if self._count is not None: return self._count if self.is_video: @@ -114,153 +112,156 @@ def count(self) -> int: return self._count def check_input_folder(self) -> cv2.VideoCapture | None: - """ Ensure that the frames or faces folder exists and is valid. - If frames folder contains a video file return imageio reader object + """Ensure that the frames or faces folder exists and is valid. If frames folder contains a + video file return cv2 reader object Returns ------- - :class:`cv2.VideoCapture` - Object for reading a video stream + Object for reading a video stream """ err = None - loadtype = self.__class__.__name__ + load_type = self.__class__.__name__ if not self.folder: - err = f"ERROR: A {loadtype} folder must be specified" + err = f"ERROR: A {load_type} folder must be specified" elif not os.path.exists(self.folder): - err = f"ERROR: The {loadtype} location {self.folder} could not be found" + err = f"ERROR: The {load_type} location {self.folder} could not be found" if err: logger.error(err) sys.exit(0) - if (loadtype == "Frames" and + if (load_type == "Frames" and os.path.isfile(self.folder) and os.path.splitext(self.folder)[1].lower() in VIDEO_EXTENSIONS): - logger.verbose("Video exists at: '%s'", self.folder) # type: ignore - retval = cv2.VideoCapture(self.folder) # pylint:disable=no-member - # TODO ImageIO single frame seek seems slow. Look into this - # retval = imageio.get_reader(self.folder, "ffmpeg") + logger.verbose("Video exists at: '%s'", self.folder) # type:ignore[attr-defined] + retval = cv2.VideoCapture(self.folder) else: - logger.verbose("Folder exists at '%s'", self.folder) # type: ignore + logger.verbose("Folder exists at '%s'", self.folder) # type:ignore[attr-defined] retval = None return retval @staticmethod def valid_extension(filename) -> bool: - """ bool: Check whether passed in file has a valid extension """ + """Check whether passed in file has a valid extension""" extension = os.path.splitext(filename)[1] retval = extension.lower() in IMAGE_EXTENSIONS - logger.trace("Filename has valid extension: '%s': %s", filename, retval) # type: ignore + logger.trace("Filename has valid extension: '%s': %s", # type:ignore[attr-defined] + filename, retval) return retval def sorted_items(self) -> list[dict[str, str]] | list[tuple[str, PNGHeaderDict]]: - """ Override for specific folder processing """ + """Override for specific folder processing""" raise NotImplementedError() def process_folder(self) -> (Generator[dict[str, str], None, None] | Generator[tuple[str, PNGHeaderDict], None, None]): - """ Override for specific folder processing """ + """Override for specific folder processing""" raise NotImplementedError() def load_items(self) -> dict[str, list[int]] | dict[str, tuple[str, str]]: - """ Override for specific item loading """ + """Override for specific item loading""" raise NotImplementedError() def load_image(self, filename: str) -> np.ndarray: - """ Load an image + """Load an image Parameters ---------- - filename: str + filename The filename of the image to load Returns ------- - :class:`numpy.ndarray` - The loaded image + The loaded image """ if self.is_video: image = self.load_video_frame(filename) else: src = os.path.join(self.folder, filename) - logger.trace("Loading image: '%s'", src) # type: ignore + logger.trace("Loading image: '%s'", src) # type:ignore[attr-defined] image = read_image(src, raise_error=True) return image def load_video_frame(self, filename: str) -> np.ndarray: - """ Load a requested frame from video + """Load a requested frame from video Parameters ---------- - filename: str + filename The frame name to load Returns ------- - :class:`numpy.ndarray` - The loaded image + The loaded image """ assert self._vid_reader is not None frame = os.path.splitext(filename)[0] - logger.trace("Loading video frame: '%s'", frame) # type: ignore + logger.trace("Loading video frame: '%s'", frame) # type:ignore[attr-defined] frame_no = int(frame[frame.rfind("_") + 1:]) - 1 - self._vid_reader.set(cv2.CAP_PROP_POS_FRAMES, frame_no) # pylint:disable=no-member + self._vid_reader.set(cv2.CAP_PROP_POS_FRAMES, frame_no) _, image = self._vid_reader.read() - # TODO imageio single frame seek seems slow. Look into this - # self._vid_reader.set_image_index(frame_no) - # image = self._vid_reader.get_next_data()[:, :, ::-1] return image def stream(self, skip_list: list[int] | None = None ) -> Generator[tuple[str, np.ndarray], None, None]: - """ Load the images in :attr:`folder` in the order they are received from + """Load the images in :attr:`folder` in the order they are received from :class:`lib.image.ImagesLoader` in a background thread. Parameters ---------- - skip_list: list, optional + skip_list A list of frame indices that should not be loaded. Pass ``None`` if all images should be loaded. Default: ``None`` Yields ------ - str + filename The filename of the image that is being returned - numpy.ndarray + image The image that has been loaded from disk """ loader = ImagesLoader(self.folder, queue_size=32, count=self._count) if skip_list is not None: loader.add_skip_list(skip_list) - for filename, image in loader.load(): - yield filename, image + for filename_image in loader.load(): + yield filename_image[0], filename_image[1] @staticmethod def save_image(output_folder: str, filename: str, image: np.ndarray, metadata: PNGHeaderDict | None = None) -> None: - """ Save an image """ + """Save an image + + Parameters + ---------- + filename + The filename of the image to save + image + The image to save + metadata + Any faceswap metadata that should be saved + """ output_file = os.path.join(output_folder, filename) output_file = os.path.splitext(output_file)[0] + ".png" - logger.trace("Saving image: '%s'", output_file) # type: ignore + logger.trace("Saving image: '%s'", output_file) # type:ignore[attr-defined] if metadata: encoded = cv2.imencode(".png", image)[1] encoded_image = png_write_meta(encoded.tobytes(), metadata) with open(output_file, "wb") as out_file: out_file.write(encoded_image) else: - cv2.imwrite(output_file, image) # pylint:disable=no-member + cv2.imwrite(output_file, image) class Faces(MediaLoader): - """ Object to load Extracted Faces from a folder. + """Object to load Extracted Faces from a folder. Parameters ---------- - folder: str + folder The folder to load faces from - alignments: :class:`lib.align.Alignments`, optional + alignments The alignments object that contains the faces. This can be used for 2 purposes: - To update legacy hash based faces for None: super().__init__(folder) def _handle_legacy(self, fullpath: str, log: bool = False) -> PNGHeaderDict: - """Handle facesets that are legacy (i.e. do not contain alignment information in the + """Handle face sets that are legacy (i.e. do not contain alignment information in the header data) Parameters ---------- - fullpath : str + fullpath The full path to the extracted face image - log : bool, optional + log Whether to log a message that legacy updating is occurring Returns ------- - :class:`~lib.align.alignments.PNGHeaderDict` - The Alignments information from the face in PNG Header dict format + The Alignments information from the face in PNG Header dict format Raises ------ @@ -312,24 +312,23 @@ def _handle_duplicate(self, fullpath: str, header_dict: PNGHeaderDict, seen: dict[str, list[int]]) -> bool: - """ Check whether the given face has already been seen for the source frame and face index + """Check whether the given face has already been seen for the source frame and face index from an existing face. Can happen when filenames have changed due to sorting etc. and users have done multiple extractions/copies and placed all of the faces in the same folder Parameters ---------- - fullpath : str + fullpath The full path to the face image that is being checked - header_dict : class:`~lib.align.alignments.PNGHeaderDict` + header_dict The PNG header dictionary for the given face - seen : dict[str, list[int]] + seen Dictionary of original source filename and face indices that have already been seen and will be updated with the face processing now Returns ------- - bool - ``True`` if the face was a duplicate and has been removed, otherwise ``False`` + ``True`` if the face was a duplicate and has been removed, otherwise ``False`` """ src_filename = header_dict["source"]["source_filename"] face_index = header_dict["source"]["face_index"] @@ -346,13 +345,12 @@ def _handle_duplicate(self, return False def process_folder(self) -> Generator[tuple[str, PNGHeaderDict], None, None]: - """ Iterate through the faces folder pulling out various information for each face. + """Iterate through the faces folder pulling out various information for each face. Yields ------ - dict - A dictionary for each face found containing the keys returned from - :class:`lib.image.read_image_meta_batch` + A dictionary for each face found containing the keys returned from + :class:`lib.image.read_image_meta_batch` """ logger.info("Loading file list from %s", self.folder) filter_count = 0 @@ -401,54 +399,50 @@ def process_folder(self) -> Generator[tuple[str, PNGHeaderDict], None, None]: dupe_count, os.path.join(self.folder, "_duplicates")) def load_items(self) -> dict[str, list[int]]: - """ Load the face names into dictionary. + """Load the face names into dictionary. Returns ------- - dict - The source filename as key with list of face indices for the frame as value + The source filename as key with list of face indices for the frame as value """ faces: dict[str, list[int]] = {} for face in T.cast(list[tuple[str, "PNGHeaderDict"]], self.file_list_sorted): src = face[1]["source"] faces.setdefault(src["source_filename"], []).append(src["face_index"]) - logger.trace(faces) # type: ignore + logger.trace(faces) # type:ignore[attr-defined] return faces def sorted_items(self) -> list[tuple[str, PNGHeaderDict]]: - """ Return the items sorted by the saved file name. + """Return the items sorted by the saved file name. Returns -------- - list - List of `dict` objects for each face found, sorted by the face's current filename + List of `dict` objects for each face found, sorted by the face's current filename """ items = sorted(self.process_folder(), key=itemgetter(0)) - logger.trace(items) # type: ignore + logger.trace(items) # type:ignore[attr-defined] return items class Frames(MediaLoader): - """ Object to hold the frames that are to be checked against """ + """Object to hold the frames that are to be checked against """ def process_folder(self) -> Generator[dict[str, str], None, None]: - """ Iterate through the frames folder pulling the base filename + """Iterate through the frames folder pulling the base filename Yields ------ - dict - The full framename, the filename and the file extension of the frame + The full frame name, the filename and the file extension of the frame """ iterator = self.process_video if self.is_video else self.process_frames yield from iterator() def process_frames(self) -> Generator[dict[str, str], None, None]: - """ Process exported Frames + """Process exported Frames Yields ------ - dict - The full framename, the filename and the file extension of the frame + The full frame name, the filename and the file extension of the frame """ logger.info("Loading file list from %s", self.folder) for frame in os.listdir(self.folder): @@ -460,7 +454,7 @@ def process_frames(self) -> Generator[dict[str, str], None, None]: retval = {"frame_fullname": frame, "frame_name": filename, "frame_extension": file_extension} - logger.trace(retval) # type: ignore + logger.trace(retval) # type:ignore[attr-defined] yield retval def process_video(self) -> Generator[dict[str, str], None, None]: @@ -468,63 +462,60 @@ def process_video(self) -> Generator[dict[str, str], None, None]: Yields ------ - dict - The full framename, the filename and the file extension of the frame + The full frame name, the filename and the file extension of the frame """ logger.info("Loading video frames from %s", self.folder) - vidname, ext = os.path.splitext(os.path.basename(self.folder)) + vid_name, ext = os.path.splitext(os.path.basename(self.folder)) for i in range(self.count): idx = i + 1 # Keep filename format for outputted face - filename = f"{vidname}_{idx:06d}" + filename = f"{vid_name}_{idx:06d}" retval = {"frame_fullname": f"{filename}{ext}", "frame_name": filename, "frame_extension": ext} - logger.trace(retval) # type: ignore + logger.trace(retval) # type:ignore[attr-defined] yield retval def load_items(self) -> dict[str, tuple[str, str]]: - """ Load the frame info into dictionary + """Load the frame info into dictionary Returns ------- - dict - Fullname as key, tuple of frame name and extension as value + Fullname as key, tuple of frame name and extension as value """ frames: dict[str, tuple[str, str]] = {} for frame in T.cast(list[dict[str, str]], self.file_list_sorted): frames[frame["frame_fullname"]] = (frame["frame_name"], frame["frame_extension"]) - logger.trace(frames) # type: ignore + logger.trace(frames) # type:ignore[attr-defined] return frames def sorted_items(self) -> list[dict[str, str]]: - """ Return the items sorted by filename + """Return the items sorted by filename Returns ------- - list - The sorted list of frame information + The sorted list of frame information """ items = sorted(self.process_folder(), key=lambda x: (x["frame_name"])) - logger.trace(items) # type: ignore + logger.trace(items) # type:ignore[attr-defined] return items class ExtractedFaces(): - """ Holds the extracted faces and matrix for alignments + """Holds the extracted faces and matrix for alignments Parameters ---------- - frames: :class:`Frames` + frames The frames object to extract faces from - alignments: :class:`AlignmentData` + alignments The alignment data corresponding to the frames - size: int, optional + size The extract face size. Default: 512 """ def __init__(self, frames: Frames, alignments: AlignmentData, size: int = 512) -> None: - logger.trace("Initializing %s: size: %s", # type: ignore + logger.trace("Initializing %s: size: %s", # type:ignore[attr-defined] self.__class__.__name__, size) self.size = size self.padding = int(size * 0.1875) @@ -532,25 +523,25 @@ def __init__(self, frames: Frames, alignments: AlignmentData, size: int = 512) - self.frames = frames self.current_frame: str | None = None self.faces: list[DetectedFace] = [] - logger.trace("Initialized %s", self.__class__.__name__) # type: ignore + logger.trace("Initialized %s", self.__class__.__name__) # type:ignore[attr-defined] def get_faces(self, frame: str, image: np.ndarray | None = None) -> None: - """ Obtain faces and transformed landmarks for each face in a given frame with its + """Obtain faces and transformed landmarks for each face in a given frame with its alignments Parameters ---------- - frame: str + frame The frame name to obtain faces for - image: :class:`numpy.ndarray`, optional + image The image to extract the face from, if we already have it, otherwise ``None`` to load the image. Default: ``None`` """ - logger.trace("Getting faces for frame: '%s'", frame) # type: ignore + logger.trace("Getting faces for frame: '%s'", frame) # type:ignore[attr-defined] self.current_frame = None alignments = self.alignments.get_faces_in_frame(frame) - logger.trace("Alignments for frame: (frame: '%s', alignments: %s)", # type: ignore - frame, alignments) + logger.trace( # type:ignore[attr-defined] + "Alignments for frame: (frame: '%s', alignments: %s)", frame, alignments) if not alignments: self.faces = [] return @@ -561,22 +552,21 @@ def get_faces(self, frame: str, image: np.ndarray | None = None) -> None: def extract_one_face(self, alignment: AlignmentFileDict, image: np.ndarray) -> DetectedFace: - """ Extract one face from image + """Extract one face from image Parameters ---------- - alignment: dict + alignment The alignment for a single face - image: :class:`numpy.ndarray` + image The image to extract the face from Returns ------- - :class:`~lib.align.DetectedFace` - The detected face object for the given alignment with the aligned face loaded + The detected face object for the given alignment with the aligned face loaded """ - logger.trace("Extracting one face: (frame: '%s', alignment: %s)", # type: ignore - self.current_frame, alignment) + logger.trace( # type:ignore[attr-defined] + "Extracting one face: (frame: '%s', alignment: %s)", self.current_frame, alignment) face = DetectedFace() face.from_alignment(alignment, image=image) face.load_aligned(image, size=self.size, centering="head") @@ -587,44 +577,42 @@ def get_faces_in_frame(self, frame: str, update: bool = False, image: np.ndarray | None = None) -> list[DetectedFace]: - """ Return the faces for the selected frame + """Return the faces for the selected frame Parameters ---------- - frame: str + frame The frame name to get the faces for - update: bool, optional + update ``True`` if the faces should be refreshed regardless of current frame. ``False`` to not force a refresh. Default ``False`` - image: :class:`numpy.ndarray`, optional + image Image to load faces from if it exists, otherwise ``None`` to load the image. Default: ``None`` Returns ------- - list - List of :class:`~lib.align.DetectedFace` objects for the frame, with the aligned face - loaded + List of :class:`~lib.align.DetectedFace` objects for the frame, with the aligned face + loaded """ - logger.trace("frame: '%s', update: %s", frame, update) # type: ignore + logger.trace("frame: '%s', update: %s", frame, update) # type:ignore[attr-defined] if self.current_frame != frame or update: self.get_faces(frame, image=image) return self.faces def get_roi_size_for_frame(self, frame: str) -> list[int]: - """ Return the size of the original extract box for the selected frame. + """Return the size of the original extract box for the selected frame. Parameters ---------- - frame: str + frame The frame to obtain the original sized bounding boxes for Returns ------- - list - List of original pixel sizes of faces held within the frame + List of original pixel sizes of faces held within the frame """ - logger.trace("frame: '%s'", frame) # type: ignore + logger.trace("frame: '%s'", frame) # type:ignore[attr-defined] if self.current_frame != frame: self.get_faces(frame) sizes = [] @@ -638,7 +626,7 @@ def get_roi_size_for_frame(self, frame: str) -> list[int]: else: length = int(((len_x ** 2) + (len_y ** 2)) ** 0.5) sizes.append(length) - logger.trace("sizes: '%s'", sizes) # type: ignore + logger.trace("sizes: '%s'", sizes) # type:ignore[attr-defined] return sizes diff --git a/tools/effmpeg/effmpeg.py b/tools/effmpeg/effmpeg.py index 1e1ca81419..9ce3ca1cbc 100644 --- a/tools/effmpeg/effmpeg.py +++ b/tools/effmpeg/effmpeg.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# vim: set fileencoding=utf-8 : """ Created on 2018-03-16 15:14 @@ -9,27 +8,26 @@ import os import subprocess import sys +import typing as T import datetime from collections import OrderedDict -import imageio -import imageio_ffmpeg as im_ffm +import av +import ffmpeg from ffmpy import FFmpeg, FFRuntimeError # faceswap imports -from lib.utils import (get_module_objects, handle_deprecated_cli_opts, IMAGE_EXTENSIONS, - VIDEO_EXTENSIONS) +from lib.utils import get_module_objects, handle_deprecated_cli_opts, IMAGE_EXTENSIONS +from lib.video import VIDEO_EXTENSIONS logger = logging.getLogger(__name__) class DataItem(): - """ - A simple class used for storing the media data items and directories that - Effmpeg uses for 'input', 'output' and 'ref_vid'. - """ + """A simple class used for storing the media data items and directories that Effmpeg uses for + 'input', 'output' and 'ref_vid'.""" vid_ext = VIDEO_EXTENSIONS - # future option in effmpeg to use audio file for muxing + # future option in effmpeg to use audio file for mux audio_ext = [".aiff", ".flac", ".mp3", ".wav"] img_ext = IMAGE_EXTENSIONS @@ -51,7 +49,7 @@ def __init__(self, path=None, name=None, item_type=None, ext=None, logger.debug("Initialized %s", self.__class__.__name__) def set_name(self, name=None): - """ Set the name """ + """Set the name""" if name is None and self.path is not None: self.name = os.path.basename(self.path) elif name is not None and self.path is None: @@ -63,7 +61,7 @@ def set_name(self, name=None): logger.debug(self.name) def set_type_ext(self, path=None): - """ Set the extension """ + """Set the extension""" if path is not None: self.path = path if self.path is not None: @@ -79,7 +77,7 @@ def set_type_ext(self, path=None): logger.debug("path: '%s', type: '%s', ext: '%s'", self.path, self.type, self.ext) def set_dirname(self, path=None): - """ Set the folder name """ + """Set the folder name""" if path is None and self.path is not None: self.dirname = os.path.dirname(self.path) elif path is not None and self.path is None: @@ -91,7 +89,7 @@ def set_dirname(self, path=None): logger.debug("path: '%s', dirname: '%s'", path, self.dirname) def is_type(self, item_type=None): - """ Get the type """ + """Get the type""" if item_type == "media": chk_type = self.type in "vid audio" elif item_type == "dir": @@ -108,7 +106,7 @@ def is_type(self, item_type=None): return chk_type def set_fps(self): - """ Set the Frames Per Second """ + """Set the Frames Per Second""" try: self.fps = Effmpeg.get_fps(self.path) except FFRuntimeError: @@ -117,11 +115,8 @@ def set_fps(self): class Effmpeg(): - """ - Class that allows for "easy" ffmpeg use. It provides a nice cli interface - for common video operations. - """ - + """Class that allows for "easy" ffmpeg use. It provides a nice cli interface for common video + operations. """ _actions_req_fps = ["extract", "gen_vid"] _actions_req_ref_video = ["mux_audio"] _actions_can_use_ref_video = ["gen_vid"] @@ -134,7 +129,7 @@ class Effmpeg(): "rotate", "slice"] # Class variable that stores the target executable (ffmpeg or ffplay) - _executable = im_ffm.get_ffmpeg_exe() + _executable = str(ffmpeg.FFMPEG_PATH) # Class variable that stores the common ffmpeg arguments based on verbosity __common_ffmpeg_args_dict = {"normal": "-hide_banner ", @@ -149,7 +144,7 @@ class Effmpeg(): def __init__(self, arguments): logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) self.args = handle_deprecated_cli_opts(arguments) - self.exe = im_ffm.get_ffmpeg_exe() + self.exe = str(ffmpeg.FFMPEG_PATH) self.input = DataItem() self.output = DataItem() self.ref_vid = DataItem() @@ -160,7 +155,7 @@ def __init__(self, arguments): logger.debug("Initialized %s", self.__class__.__name__) def _set_output(self) -> None: - """ Set :attr:`output` based on input arguments """ + """Set :attr:`output` based on input arguments""" if self.args.action in self._actions_have_dir_output: self.output = DataItem(path=self.__get_default_output()) elif self.args.action in self._actions_have_vid_output: @@ -171,21 +166,20 @@ def _set_output(self) -> None: self.output = DataItem(path=self.__get_default_output()) def _set_ref_video(self) -> None: - """ Set :attr:`ref_vid` based on input arguments """ + """Set :attr:`ref_vid` based on input arguments""" if self.args.ref_vid is None or self.args.ref_vid == "": self.args.ref_vid = None self.ref_vid = DataItem(path=self.args.ref_vid) def _check_inputs(self) -> None: - """ Validate provided arguments are valid + """Validate provided arguments are valid Raises ------ ValueError If provided arguments are not valid """ - if self.args.action in self._actions_have_dir_input and not self.input.is_type("dir"): raise ValueError("The chosen action requires a directory as its input, but you " f"entered: {self.input.path}") @@ -211,7 +205,7 @@ def _check_inputs(self) -> None: "intentional then ignore this warning.") def _set_times(self) -> None: - """Set start, end and duration attributes """ + """Set start, end and duration attributes""" self.start = self.parse_time(self.args.start) self.end = self.parse_time(self.args.end) if not self.__check_equals_time(self.args.end, "00:00:00"): @@ -220,7 +214,7 @@ def _set_times(self) -> None: self.duration = self.parse_time(str(self.args.duration)) def _set_fps(self) -> None: - """ Set :attr:`arguments.fps` based on input arguments""" + """Set :attr:`arguments.fps` based on input arguments""" # If fps was left blank in gui, set it to default -1.0 value if self.args.fps == "": self.args.fps = str(-1.0) @@ -241,7 +235,7 @@ def _set_fps(self) -> None: self.args.fps = self.input.fps def process(self): - """ EFFMPEG Process """ + """EFFMPEG Process""" logger.debug("Running Effmpeg") # Format action to match the method name self.args.action = self.args.action.replace("-", "_") @@ -296,7 +290,7 @@ def process(self): logger.debug("Finished Effmpeg process") def effmpeg_process(self): - """ The effmpeg process """ + """The effmpeg process""" kwargs = {"input_": self.input, "output": self.output, "ref_vid": self.ref_vid, @@ -316,7 +310,7 @@ def effmpeg_process(self): @staticmethod def extract(input_=None, output=None, fps=None, # pylint:disable=unused-argument extract_ext=None, start=None, duration=None, **kwargs): - """ Extract video to image frames """ + """Extract video to image frames""" logger.debug("input_: %s, output: %s, fps: %s, extract_ext: '%s', start: %s, duration: %s", input_, output, fps, extract_ext, start, duration) _input_opts = Effmpeg._common_ffmpeg_args[:] @@ -333,7 +327,7 @@ def extract(input_=None, output=None, fps=None, # pylint:disable=unused-argumen @staticmethod def gen_vid(input_=None, output=None, fps=None, # pylint:disable=unused-argument mux_audio=False, ref_vid=None, exe=None, **kwargs): - """ Generate Video """ + """Generate Video""" logger.debug("input: %s, output: %s, fps: %s, mux_audio: %s, ref_vid: '%s'exe: '%s'", input, output, fps, mux_audio, ref_vid, exe) filename = Effmpeg.__get_extracted_filename(input_.path) @@ -354,39 +348,59 @@ def gen_vid(input_=None, output=None, fps=None, # pylint:disable=unused-argumen @staticmethod def get_fps(input_=None, print_=False, **kwargs): - """ Get Frames per Second """ + """Get Frames per Second""" logger.debug("input_: %s, print_: %s, kwargs: %s", input_, print_, kwargs) input_ = input_ if isinstance(input_, str) else input_.path logger.debug("input: %s", input_) - reader = imageio.get_reader(input_, "ffmpeg") - _fps = reader.get_meta_data()["fps"] + with av.open(input_, "r") as container: + _fps = container.streams.video[0].average_rate + assert _fps is not None + _fps = float(_fps) logger.debug(_fps) - reader.close() if print_: logger.info("Video fps: %s", _fps) return _fps @staticmethod def get_info(input_=None, print_=False, **kwargs): - """ Get video Info """ + """Get video Info""" logger.debug("input_: %s, print_: %s, kwargs: %s", input_, print_, kwargs) input_ = input_ if isinstance(input_, str) else input_.path logger.debug("input: %s", input_) - reader = imageio.get_reader(input_, "ffmpeg") - out = reader.get_meta_data() - logger.debug(out) - reader.close() + data: dict[str, list[dict[str, T.Any]]] = {} + with av.open(input_, "r") as container: + for stream in container.streams: + info: dict[str, T.Any] = stream.metadata + info["frames"] = stream.frames + info["codec"] = stream.codec_context.name + info["profile"] = stream.profile + info["bitrate"] = stream.codec_context.bit_rate + if stream.duration and stream.time_base: + info["duration"] = float(stream.duration * stream.time_base) + if stream.type == "video": + codec = T.cast(av.VideoCodecContext, stream.codec_context) + if stream.average_rate: + info["fps"] = float(stream.average_rate) + info["pix_fmt"] = codec.pix_fmt + info["size"] = (codec.width, codec.height) + data.setdefault(stream.type, []).append(info) + + logger.debug(data) if print_: logger.info("======== Video Info ========",) logger.info("path: %s", input_) - for key, val in out.items(): - logger.info("%s: %s", key, val) - return out + for stream_type, info in data.items(): + logger.info("---- %s ----", stream_type) + for idx, stream_data in enumerate(info): + logger.info("index: %s", idx) + for key, val in stream_data.items(): + logger.info(" %s: %s", key, val) +# return out @staticmethod def rescale(input_=None, output=None, scale=None, # pylint:disable=unused-argument exe=None, **kwargs): - """ Rescale Video """ + """Rescale Video""" _input_opts = Effmpeg._common_ffmpeg_args[:] _output_opts = '-y -vf scale="' + str(scale) + '"' _inputs = {input_.path: _input_opts} @@ -396,7 +410,7 @@ def rescale(input_=None, output=None, scale=None, # pylint:disable=unused-argum @staticmethod def rotate(input_=None, output=None, degrees=None, # pylint:disable=unused-argument transpose=None, exe=None, **kwargs): - """ Rotate Video """ + """Rotate Video""" if transpose is None and degrees is None: raise ValueError("You have not supplied a valid transpose or degrees value:\n" f"transpose: {transpose}\ndegrees: {degrees}") @@ -419,7 +433,7 @@ def rotate(input_=None, output=None, degrees=None, # pylint:disable=unused-argu @staticmethod def mux_audio(input_=None, output=None, ref_vid=None, # pylint:disable=unused-argument exe=None, **kwargs): - """ Mux Audio """ + """Mux Audio""" _input_opts = Effmpeg._common_ffmpeg_args[:] _ref_vid_opts = None _output_opts = "-y -c copy -map 0:0 -map 1:1 -shortest" @@ -430,7 +444,7 @@ def mux_audio(input_=None, output=None, ref_vid=None, # pylint:disable=unused-a @staticmethod def slice(input_=None, output=None, start=None, # pylint:disable=unused-argument duration=None, exe=None, **kwargs): - """ Slice Video """ + """Slice Video""" _input_opts = Effmpeg._common_ffmpeg_args[:] _input_opts += "-ss " + start _output_opts = "-t " + duration + " " @@ -449,8 +463,7 @@ def __set_verbosity(cls, quiet, verbose): cls._common_ffmpeg_args = cls.__common_ffmpeg_args_dict["normal"] def __get_default_output(self): - """ Set output to the same directory as input - if the user didn't specify it. """ + """Set output to the same directory as input if the user didn't specify it.""" retval = "" if self.args.output == "": if self.args.action in self._actions_have_dir_output: @@ -480,8 +493,8 @@ def __check_have_fps(self, items): return all(getattr(self, i).fps is None for i in items_to_check) @staticmethod - def __run_ffmpeg(exe=im_ffm.get_ffmpeg_exe(), inputs=None, outputs=None): - """ Run ffmpeg """ + def __run_ffmpeg(exe=str(ffmpeg.FFMPEG_PATH), inputs=None, outputs=None): + """Run ffmpeg""" logger.debug("Running ffmpeg: (exe: '%s', inputs: %s, outputs: %s", exe, inputs, outputs) ffm = FFmpeg(executable=exe, inputs=inputs, outputs=outputs) try: @@ -498,7 +511,7 @@ def __run_ffmpeg(exe=im_ffm.get_ffmpeg_exe(), inputs=None, outputs=None): @staticmethod def __convert_fps(fps): - """ Convert to Frames per Second """ + """Convert to Frames per Second""" if "/" in fps: _fps = fps.split("/") retval = float(_fps[0]) / float(_fps[1]) @@ -509,7 +522,7 @@ def __convert_fps(fps): @staticmethod def __get_duration(start_time, end_time): - """ Get the duration """ + """Get the duration""" start = [int(i) for i in start_time.split(":")] end = [int(i) for i in end_time.split(":")] start = datetime.timedelta(hours=start[0], minutes=start[1], seconds=start[2]) @@ -522,7 +535,7 @@ def __get_duration(start_time, end_time): @staticmethod def __get_extracted_filename(path): - """ Get the extracted filename """ + """Get the extracted filename""" logger.debug("path: '%s'", path) filename = "" for file in os.listdir(path): @@ -541,11 +554,12 @@ def __get_extracted_filename(path): @staticmethod def __get_zero_pad(filename): - """ Return the starting position of zero padding from a filename """ - chkstring = filename[::-1] - logger.trace("filename: %s, chkstring: %s", filename, chkstring) + """Return the starting position of zero padding from a filename""" + chk_string = filename[::-1] + logger.trace("filename: %s, chk_string: %s", # type:ignore[attr-defined] + filename, chk_string) pos = 0 - for char in chkstring: + for char in chk_string: if not char.isdigit(): break logger.debug("filename: '%s', pos: %s", filename, pos) @@ -553,7 +567,7 @@ def __get_zero_pad(filename): @staticmethod def __check_equals_time(value, time): - """ Check equals time """ + """Check equals time""" val = value.replace(":", "") tme = time.replace(":", "") retval = val.zfill(6) == tme.zfill(6) @@ -562,7 +576,7 @@ def __check_equals_time(value, time): @staticmethod def parse_time(txt): - """ Parse Time """ + """Parse Time""" clean_txt = txt.replace(":", "") hours = clean_txt[0:2] minutes = clean_txt[2:4] diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index ad5135ea85..1abb558439 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -121,7 +121,7 @@ def current_faces(self) -> list[list[DetectedFace]]: return self._frame_faces @property - def video_meta_data(self) -> dict[str, list[int] | list[float] | None]: + def video_meta_data(self) -> dict[T.Literal["pts_time", "keyframes"], list[int]] | None: """The frame meta data stored in the alignments file. If data does not exist in the alignments file then ``None`` is returned for each Key""" return self._alignments.video_meta_data @@ -172,7 +172,7 @@ def extract(self) -> None: """Extract the faces in the current video to a user supplied folder.""" self._io.extract() - def save_video_meta_data(self, pts_time: list[float], keyframes: list[int]) -> None: + def save_video_meta_data(self, pts_time: list[int], keyframes: list[int]) -> None: """Save video meta data to the alignments file. This is executed if the video meta data does not already exist in the alignments file, so the video does not need to be scanned on every use of the Manual Tool. @@ -429,7 +429,8 @@ def _background_extract(self, output_folder: str, progress_queue: Queue) -> None """ saver = ImagesSaver(get_folder(output_folder), as_bytes=True) loader = ImagesLoader(self._input_location, count=self._alignments.frames_count) - for frame_idx, (filename, image) in enumerate(loader.load()): + for frame_idx, filename_image in enumerate(loader.load()): + filename, image = filename_image[:2] logger.trace("Outputting frame: %s: %s", # type:ignore[attr-defined] frame_idx, filename) src_filename = os.path.basename(filename) diff --git a/tools/manual/globals.py b/tools/manual/globals.py index 548ebdc27d..e1666d9828 100644 --- a/tools/manual/globals.py +++ b/tools/manual/globals.py @@ -14,7 +14,8 @@ from lib.gui.utils import get_config from lib.logger import parse_class_init -from lib.utils import get_module_objects, VIDEO_EXTENSIONS +from lib.utils import get_module_objects +from lib.video import VIDEO_EXTENSIONS logger = logging.getLogger(__name__) diff --git a/tools/manual/manual.py b/tools/manual/manual.py index 70f534e89b..0ce53825f3 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -74,7 +74,8 @@ def __init__(self, arguments: Namespace) -> None: extractor) video_meta_data = self._detected_faces.video_meta_data - valid_meta = all(val is not None for val in video_meta_data.values()) + valid_meta = video_meta_data is not None and all(val is not None + for val in video_meta_data.values()) loader = FrameLoader(self._globals, arguments.frames, @@ -566,7 +567,7 @@ class FrameLoader(): def __init__(self, tk_globals: TkGlobals, frames_location: str, - video_meta_data: dict[str, list[int] | list[float] | None], + video_meta_data: dict[T.Literal["pts_time", "keyframes"], list[int]] | None, file_list: list[str]) -> None: logger.debug(parse_class_init(locals())) self._globals = tk_globals @@ -590,15 +591,16 @@ def is_initialized(self) -> bool: return not thread_is_alive @property - def video_meta_data(self) -> dict[str, list[int] | list[float] | None]: + def video_meta_data(self) -> dict[T.Literal["pts_time", "keyframes"], list[int]] | None: """The pts_time and key frames for the loader. """ assert self._loader is not None return self._loader.video_meta_data - def _background_init_frames(self, - frames_location: str, - video_meta_data: dict[str, list[int] | list[float] | None], - frame_list: list[str]) -> MultiThread: + def _background_init_frames( + self, + frames_location: str, + video_meta_data: dict[T.Literal["pts_time", "keyframes"], list[int]] | None, + frame_list: list[str]) -> MultiThread: """Launch the images loader in a background thread so we can run other tasks whilst waiting for initialization. @@ -622,7 +624,7 @@ def _background_init_frames(self, def _load_images(self, frames_location: str, - video_meta_data: dict[str, list[int] | list[float] | None], + video_meta_data: dict[T.Literal["pts_time", "keyframes"], list[int]], frame_list: list[str]) -> None: """Load the images in a background thread. diff --git a/tools/manual/thumbnails.py b/tools/manual/thumbnails.py index 3d4c9a6a18..c4a05bdf6f 100644 --- a/tools/manual/thumbnails.py +++ b/tools/manual/thumbnails.py @@ -9,12 +9,12 @@ from time import sleep from threading import Lock -import imageio import numpy as np from tqdm import tqdm from lib.align import AlignedFace from lib.image import SingleFrameLoader, generate_thumbnail +from lib.logger import parse_class_init from lib.multithreading import MultiThread from lib.utils import get_module_objects @@ -27,7 +27,7 @@ @dataclass class ProgressBar: """Thread-safe progress bar for tracking thumbnail generation progress""" - pbar: tqdm | None = None + p_bar: tqdm | None = None lock = Lock() @@ -40,10 +40,10 @@ class VideoMeta: key_frames List of key frame indices for the video pts_times - List of presentation timestams for the video + List of presentation timestamps for the video """ key_frames: list[int] | None = None - pts_times: list[float] | None = None + pts_times: list[int] | None = None class ThumbsCreator(): @@ -63,34 +63,24 @@ def __init__(self, detected_faces: DetectedFaces, input_location: str, single_process: bool) -> None: - logger.debug("Initializing %s: (detected_faces: %s, input_location: %s, " - "single_process: %s)", self.__class__.__name__, detected_faces, - input_location, single_process) - self._size = 80 - self._pbar = ProgressBar() - self._meta = VideoMeta( - key_frames=T.cast(list[int] | None, - detected_faces.video_meta_data.get("keyframes", None)), - pts_times=T.cast(list[float] | None, - detected_faces.video_meta_data.get("pts_time", None))) + logger.debug(parse_class_init(locals())) + self._p_bar = ProgressBar() + self._meta = detected_faces.video_meta_data self._location = input_location self._alignments = detected_faces._alignments self._frame_faces = detected_faces._frame_faces - self._is_video = self._meta.pts_times is not None and self._meta.key_frames is not None + self._is_video = self._meta is not None cpu_count = os.cpu_count() self._num_threads = 1 if cpu_count is None or cpu_count <= 2 else cpu_count - 2 if self._is_video and single_process: self._num_threads = 1 - elif self._is_video and not single_process: - assert self._meta.key_frames is not None - self._num_threads = min(self._num_threads, len(self._meta.key_frames)) else: self._num_threads = max(self._num_threads, 32) self._threads: list[MultiThread] = [] - logger.debug("Initialized %s", self.__class__.__name__) + logger.debug("[THUMBS] Initialized %s", self.__class__.__name__) @property def has_thumbs(self) -> bool: @@ -100,9 +90,9 @@ def has_thumbs(self) -> bool: def generate_cache(self) -> None: """Extract the face thumbnails from a video or folder of images into the alignments file""" - self._pbar.pbar = tqdm(desc="Caching Thumbnails", - leave=False, - total=len(self._frame_faces)) + self._p_bar.p_bar = tqdm(desc="Caching Thumbnails", + leave=False, + total=len(self._frame_faces)) if self._is_video: self._launch_video() else: @@ -113,7 +103,7 @@ def generate_cache(self) -> None: break sleep(1) self._join_threads() - self._pbar.pbar.close() + self._p_bar.p_bar.close() self._alignments.save() # << PRIVATE METHODS >> # @@ -124,7 +114,7 @@ def _check_and_raise_error(self) -> None: def _join_threads(self) -> None: """Join the loading threads""" - logger.debug("Joining face viewer loading threads") + logger.debug("[THUMBS] Joining face viewer loading threads") for thread in self._threads: thread.join() @@ -135,35 +125,24 @@ def _launch_video(self) -> None: Splits the video into segments and passes each of these segments to separate background threads for some speed up. """ - key_frames = self._meta.key_frames - assert key_frames is not None - if key_frames[0] != 0: + assert self._meta is not None + if self._meta["keyframes"][0] != 0: logger.warning("Your video does not start on a Key Frame. This can lead to issues.") - key_frames = key_frames[:] - key_frames[0] = 0 - pts_times = self._meta.pts_times - assert key_frames is not None and pts_times is not None - key_frame_split = len(key_frames) // self._num_threads + + frame_face_indices = [i for i, v in enumerate(self._alignments.data.values()) + if v["faces"]] + num_frames = len(frame_face_indices) + num_threads = min(num_frames, self._num_threads) + window = num_frames // num_threads for idx in range(self._num_threads): is_final = idx == self._num_threads - 1 - start_idx: int = idx * key_frame_split - keyframe_idx = len(key_frames) - 1 if is_final else start_idx + key_frame_split - end_idx = key_frames[keyframe_idx] - start_pts = pts_times[key_frames[start_idx]] - end_pts = False if idx + 1 == self._num_threads else pts_times[end_idx] - starting_index = pts_times.index(start_pts) - if end_pts: - segment_count = len(pts_times[key_frames[start_idx]:end_idx]) - else: - segment_count = len(pts_times[key_frames[start_idx]:]) - logger.debug("thread index: %s, start_idx: %s, end_idx: %s, start_pts: %s, " - "end_pts: %s, starting_index: %s, segment_count: %s", idx, start_idx, - end_idx, start_pts, end_pts, starting_index, segment_count) - thread = MultiThread(self._load_from_video, - start_pts, - end_pts, - starting_index, - segment_count) + start = idx * window + end = num_frames + 1 if is_final else start + window + indices = frame_face_indices[start:end] + logger.debug("[THUMBS] thread index: %s, start_idx: %s, end_idx: %s, frame_start: %s, " + "frame_end: %s, segment_count: %s", + idx, start, end, indices[0], indices[-1], len(indices)) + thread = MultiThread(self._load_from_video, indices) thread.start() self._threads.append(thread) @@ -181,7 +160,7 @@ def _launch_folder(self) -> None: reader.add_skip_list(skip_list) num_threads = min(reader.process_count, self._num_threads) frame_split = reader.process_count // self._num_threads - logger.debug("total images: %s, num_threads: %s, frames_per_thread: %s", + logger.debug("[THUMBS] total images: %s, num_threads: %s, frames_per_thread: %s", reader.process_count, num_threads, frame_split) for idx in range(num_threads): is_final = idx == num_threads - 1 @@ -191,69 +170,28 @@ def _launch_folder(self) -> None: thread.start() self._threads.append(thread) - def _load_from_video(self, - pts_start: float, - pts_end: float, - start_index: int, - segment_count: int) -> None: + def _load_from_video(self, indices: list[int]) -> None: """Loads faces from video for the given segment of the source video. Each segment of the video is extracted from in a different background thread. Parameters ---------- - pts_start - The start time to cut the segment out of the video - pts_end - The end time to cut the segment out of the video - start_index - The frame index that this segment starts from. Used for calculating the actual frame - index of each frame extracted - segment_count - The number of frames that appear in this segment. Used for ending early in case more - frames come out of the segment than should appear (sometimes more frames are picked up - at the end of the segment, so these are discarded) + indices + The frame indices to process for for this segment """ - logger.debug("pts_start: %s, pts_end: %s, start_index: %s, segment_count: %s", - pts_start, pts_end, start_index, segment_count) - reader = self._get_reader(pts_start, pts_end) - idx = 0 - sample_filename, ext = os.path.splitext(next(fname for fname in self._alignments.data)) - vidname = sample_filename[:sample_filename.rfind("_")] - for idx, frame in enumerate(reader): - frame_idx = idx + start_index - filename = f"{vidname}_{frame_idx + 1:06d}{ext}" - self._set_thumbail(filename, frame[..., ::-1], frame_idx) - if idx == segment_count - 1: - # Sometimes extra frames are picked up at the end of a segment, so stop - # processing when segment frame count has been hit. - break + logger.debug("[THUMBS] Segment start: frame_start: %s, frame_end: %s, segment_count: %s", + list(indices)[0], list(indices)[-1], len(indices)) + assert self._meta is not None + reader = SingleFrameLoader(self._location, video_meta_data=self._meta) + proc_count = 0 + for frame_index in indices: + filename, image = reader.image_from_index(frame_index) + self._set_thumbnail(filename, image, frame_index) + proc_count += 1 reader.close() - logger.debug("Segment complete: (starting_frame_index: %s, processed_count: %s)", - start_index, idx) - - def _get_reader(self, pts_start: float, pts_end: float): - """Get an imageio iterator for this thread's segment. - - Parameters - ---------- - pts_start - The start time to cut the segment out of the video - pts_end - The end time to cut the segment out of the video - - Returns - ------- - A reader iterator for the requested segment of video - """ - input_params = ["-ss", str(pts_start)] - if pts_end: - input_params.extend(["-to", str(pts_end)]) - logger.debug("pts_start: %s, pts_end: %s, input_params: %s", - pts_start, pts_end, input_params) - return imageio.get_reader(self._location, - "ffmpeg", # type:ignore[arg-type] - input_params=input_params) + logger.debug("[THUMBS] Segment complete: (starting_frame_index: %s, processed_count: %s)", + indices[0], proc_count) def _load_from_folder(self, reader: SingleFrameLoader, @@ -272,15 +210,15 @@ def _load_from_folder(self, end_index The end frame index for the images to extract faces from """ - logger.debug("reader: %s, start_index: %s, end_index: %s", + logger.debug("[THUMBS] reader: %s, start_index: %s, end_index: %s", reader, start_index, end_index) for frame_index in range(start_index, end_index): filename, frame = reader.image_from_index(frame_index) - self._set_thumbail(filename, frame, frame_index) - logger.debug("Segment complete: (start_index: %s, processed_count: %s)", + self._set_thumbnail(filename, frame, frame_index) + logger.debug("[THUMBS] Segment complete: (start_index: %s, processed_count: %s)", start_index, end_index - start_index) - def _set_thumbail(self, filename: str, frame: np.ndarray, frame_index: int) -> None: + def _set_thumbnail(self, filename: str, frame: np.ndarray, frame_index: int) -> None: """Extracts the faces from the frame and adds to alignments file Parameters @@ -300,9 +238,9 @@ def _set_thumbail(self, filename: str, frame: np.ndarray, frame_index: int) -> N face.thumbnail = generate_thumbnail(aligned.face, size=96) assert face.thumbnail is not None self._alignments.thumbnails.add_thumbnail(filename, face_idx, face.thumbnail) - with self._pbar.lock: - assert self._pbar.pbar is not None - self._pbar.pbar.update(1) + with self._p_bar.lock: + assert self._p_bar.p_bar is not None + self._p_bar.p_bar.update(1) __all__ = get_module_objects(__name__) diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 66a5f47d99..fdbb63b14d 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -11,7 +11,8 @@ from lib.align import Alignments -from lib.utils import get_module_objects, handle_deprecated_cli_opts, VIDEO_EXTENSIONS +from lib.utils import get_module_objects, handle_deprecated_cli_opts +from lib.video import VIDEO_EXTENSIONS from .loader import Loader from .mask_import import Import diff --git a/tools/preview/preview.py b/tools/preview/preview.py index ca1bcddaf4..2f433a7568 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -18,12 +18,13 @@ from lib.align import DetectedFace from lib.cli.args_extract_convert import ConvertArgs from lib.gui.utils import get_images, get_config, initialize_config, initialize_images +from lib.image import SingleFrameLoader from lib.infer.objects import FrameFaces from lib.convert import Converter from lib.utils import get_module_objects, FaceswapError, handle_deprecated_cli_opts from lib.queue_manager import queue_manager -# TODO this is the last reference to Images. Remove if possible: -from scripts.fs_media import Alignments, Images +from lib.video import check_for_video +from scripts.fs_media import Alignments from scripts.convert import Predict, ConvertItem from .control_panels import ActionFrame, ConfigTools, OptionsBook @@ -277,19 +278,26 @@ def __init__(self, app: Preview, arguments: Namespace, sample_size: int) -> None self._input_images: list[ConvertItem] = [] self._predicted_images: list[tuple[ConvertItem, np.ndarray]] = [] - self._images = Images(arguments) + is_video = check_for_video(arguments.input_dir) self._alignments = Alignments(arguments.alignments_path, arguments.input_dir, is_extract=False, - input_is_video=self._images.is_video) + input_is_video=is_video) if not self._alignments.have_alignments_file: logger.error("Alignments file not found at: '%s'", self._alignments.file) sys.exit(1) + video_meta = self._alignments.video_meta_data + self._images = SingleFrameLoader(arguments.input_dir, video_meta_data=video_meta) + + if is_video and video_meta is None: + video_meta = self._images.video_meta_data + assert video_meta is not None + self._alignments.save_video_meta_data(video_meta["pts_time"], video_meta["keyframes"]) + if self._images.is_video: - assert isinstance(self._images.input_images, str) - self._alignments.update_legacy_has_source(os.path.basename(self._images.input_images)) + self._alignments.update_legacy_has_source(os.path.basename(arguments.input_dir)) self._filelist = self._get_filelist() self._indices = self._get_indices() @@ -346,16 +354,9 @@ def _get_filelist(self) -> list[str]: A list of filenames of frames that contain faces. """ logger.debug("Filtering file list to frames with faces") - if isinstance(self._images.input_images, str): - vid_name, ext = os.path.splitext(self._images.input_images) - filelist = [f"{vid_name}_{frame_no:06d}{ext}" - for frame_no in range(1, self._images.images_found + 1)] - else: - filelist = self._images.input_images - - retval = [filename for filename in filelist + retval = [filename for filename in self._images.file_list if self._alignments.frame_has_faces(os.path.basename(filename))] - logger.debug("Filtered out frames: %s", self._images.images_found - len(retval)) + logger.debug("Filtered out frames: %s", self._images.count - len(retval)) try: assert retval except AssertionError as err: @@ -408,21 +409,32 @@ def _load_frames(self) -> None: * Picks a random face from each indices group. - * Takes the first face from the image (if there are multiple faces). Adds the images to \ + * Takes the first face from the image (if there are multiple faces). Adds the images to :attr:`self._input_images`. - * Sets :attr:`_display.source` to the input images and flags that the display should be \ + * Sets :attr:`_display.source` to the input images and flags that the display should be updated """ self._input_images = [] for selection in self._random_choice: - filename = os.path.basename(self._filelist[selection]) - image = self._images.load_one_image(self._filelist[selection]) + filename = self._filelist[selection] + basename = os.path.basename(filename) + + if self._images.is_video and basename.isdigit(): + frame_no = int(basename) + elif self._images.is_video: + frame_no = int(os.path.splitext(basename)[0][filename.rfind("_") + 1:]) + logger.trace( # type:ignore[attr-defined] + "Extracted frame_no %s from filename '%s'", frame_no, basename) + else: + frame_no = self._images.file_list.index(filename) + + _, image = self._images.image_from_index(frame_no) # Get first face only - face = self._alignments.get_faces_in_frame(filename)[0] + face = self._alignments.get_faces_in_frame(basename)[0] detected_face = DetectedFace() detected_face.from_alignment(face, image=image) - inbound = FrameFaces(filename=filename, image=image) + inbound = FrameFaces(filename=basename, image=image) inbound.detected_faces = [detected_face] self._input_images.append(ConvertItem(inbound=inbound)) self._app.display.source = self._input_images From c8abd1b428d1bf795bf6519800a246853aec4a31 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:36:01 +0000 Subject: [PATCH 945/981] bugfix: Inference model. Make more robust to esoteric layouts --- plugins/train/model/_base/inference.py | 329 ++++++++++--------------- plugins/train/model/_base/model.py | 105 ++++---- tools/model/model.py | 102 ++++---- 3 files changed, 224 insertions(+), 312 deletions(-) diff --git a/plugins/train/model/_base/inference.py b/plugins/train/model/_base/inference.py index d5fa6ef97e..eb5e45178f 100644 --- a/plugins/train/model/_base/inference.py +++ b/plugins/train/model/_base/inference.py @@ -10,7 +10,7 @@ from lib.utils import get_module_objects if T.TYPE_CHECKING: - import keras.src.ops.node + from keras.src.ops import node logger = logging.getLogger(__name__) @@ -28,255 +28,180 @@ class Inference(): """ def __init__(self, saved_model: keras.Model, switch_sides: bool) -> None: logger.debug(parse_class_init(locals())) - - self._layers: list[keras.Layer] = [lyr for lyr in saved_model.layers - if not isinstance(lyr, keras.layers.InputLayer)] - """list[:class:`keras.layers.Layer]: All the layers that exist within the model excluding - input layers """ - - self._input = self._get_model_input(saved_model, switch_sides) - """:class:`keras.KerasTensor`: The correct input for the inference model """ - self._name = f"{saved_model.name}_inference" - """str: The name for the final inference model""" + self._side_idx = 0 if switch_sides else 1 - self._model = self._build() - logger.debug("Initialized: %s", self.__class__.__name__) + self._input = self._get_input(saved_model) + self._valid_layer_inputs = self._get_valid_layer_inputs(saved_model) + self._output = self._get_output_layer(saved_model) + self._filtered_layers = self._backwards_recurse(self._output) - @property - def model(self) -> keras.Model: - """ :class:`keras.Model`: The Faceswap model, compiled for inference. """ - return self._model + logger.debug("Initialized: %s", self.__class__.__name__) - def _get_model_input(self, model: keras.Model, switch_sides: bool) -> list[keras.KerasTensor]: - """ Obtain the inputs for the requested swap direction. + def _get_input(self, model: keras.models.Model) -> keras.KerasTensor: + """Obtain the input to the model. We select the input for the side of the model we are + swapping to as this maps correctly within valid_layer_inputs. The actual input does not + matter, it is the layers within the model and what is input that dictate which weights will + be loaded and if we are swapping. Parameters ---------- - saved_model: :class:`keras.Model` - The saved trained Faceswap model - switch_sides: bool - ``True`` if the swap should be performed "B" > "A" ``False`` if the swap should be - "A" > "B" + layer + The layer to obtain the inputs for Returns ------- - list[]:class:`keras.KerasTensor`] - The input tensor to feed the model for the requested swap direction + the input to the inference model """ - inputs: list[keras.KerasTensor] = model.input - assert len(inputs) == 2, "Faceswap models should have exactly 2 inputs" - idx = 0 if switch_sides else 1 - retval = inputs[idx] - logger.debug("model inputs: %s, idx: %s, inference_input: '%s'", - [(i.name, i.shape[1:]) for i in inputs], idx, retval.name) - return [retval] - - def _get_candidates(self, input_tensors: list[keras.KerasTensor | keras.Layer] - ) -> T.Generator[tuple[keras.Layer, list[keras.src.ops.node.KerasHistory]], - None, None]: - """ Given a list of input tensors, get all layers from the main model which have the given - input tensors marked as Inbound nodes for the model + assert len(model.input) == 2, f"Unexpected input count: {len(model.input)} ({model.input})" + input_tensor = model.input[self._side_idx] + logger.debug("[Inference] '%s' model input for side index %s: '%s'", + model.name, self._side_idx, input_tensor.name) + return input_tensor - Parameters - ---------- - input_tensors: list[:class:`keras.KerasTensor` | :class:`keras.Layer`] - List of Tensors that act as an input to a layer within the model - - Yields - ------ - tuple[:class:`keras.KerasLayer`, list[:class:`keras.src.ops.node.KerasHistory'] - Any layer in the main model that use the given input tensors as an input along with the - corresponding keras inbound history - """ - unique_input_names = set(i.name for i in input_tensors) - for layer in self._layers: - - history = [tensor._keras_history # pylint:disable=protected-access - for node in layer._inbound_nodes # pylint:disable=protected-access - for parent in node.parent_nodes - for tensor in parent.outputs] - - unique_inbound_names = set(h.operation.name for h in history) - if not unique_input_names.issubset(unique_inbound_names): - logger.debug("%s: Skipping candidate '%s' unmatched inputs: %s", - unique_input_names, layer.name, unique_inbound_names) - continue - - logger.debug("%s: Yielding candidate '%s'. History: %s", - unique_input_names, layer.name, [(h.operation.name, h.node_index) - for h in history]) - yield layer, history - - @T.overload - def _group_inputs(self, layer: keras.Layer, inputs: list[tuple[keras.Layer, int]] - ) -> list[list[tuple[keras.Layer, int]]]: - ... - - @T.overload - def _group_inputs(self, layer: keras.Layer, inputs: list[keras.src.ops.node.KerasHistory] - ) -> list[list[keras.src.ops.node.KerasHistory]]: - ... - - def _group_inputs(self, layer, inputs): - """ Layers can have more than one input. In these instances we need to group the inputs - and the layers' inbound nodes to correspond to inputs per instance. + def _get_valid_inputs_for_layer(self, layer) -> list[keras.Layer]: + """For the given layer obtain the inputs that can be valid for the given swap direction Parameters ---------- - layer: :class:`keras.Layer` - The current layer being processed - inputs: list[:class:`keras.KerasTensor`] | list[:class:`keras.src.ops.node.KerasHistory`] - List of input tensors or inbound keras histories to be grouped per layer input + layer + The layer to obtain the inputs for Returns ------- - list[list[tuple[:class:`keras.Layer`, int]]] | - list[list[:class:`keras.src.ops.node.KerasHistory`] - A list of list of input layers and the corresponding node index or inbound keras - histories + The list of potentially valid inputs. This will be either the inputs for the correct swap + direction, if there are 2 potential inputs for the layer, or the sole inputs to the layer + if it only has one input """ - layer_inputs = 1 if isinstance(layer.input, keras.KerasTensor) else len(layer.input) - num_inputs = len(inputs) - - total_calls = num_inputs / layer_inputs - assert total_calls.is_integer() - total_calls = int(total_calls) - - retval = [inputs[i * layer_inputs: i * layer_inputs + layer_inputs] - for i in range(total_calls)] - + inbound: list[node.Node] = layer._inbound_nodes # pylint:disable=protected-access + logger.debug("[Inference] '%s' inbound_nodes: %s", layer.name, inbound) + tensors = [i.input_tensors for i in inbound] + logger.debug("[Inference] '%s' input tensors: %s", layer.name, tensors) + assert len(tensors) in (1, 2), f"Unexpected input tensor count: {len(tensors)}" + side_tensors = tensors[self._side_idx] if len(tensors) == 2 else tensors[0] + retval: list[keras.Layer] = [t._keras_history.operation # pylint:disable=protected-access + for t in side_tensors] + logger.debug("[Inference] '%s' valid inputs: %s", layer.name, retval) return retval - def _layers_from_inputs(self, - input_tensors: list[keras.KerasTensor | keras.Layer], - node_indices: list[int] - ) -> tuple[list[keras.Layer], - list[keras.src.ops.node.KerasHistory], - list[int]]: - """ Given a list of input tensors and their corresponding inbound node ids, return all of - the layers for the model that uses the given nodes as their input + def _get_valid_layer_inputs(self, model: keras.models.Model + ) -> dict[keras.Layer, list[keras.Layer]]: + """Obtain a dictionary of all layers within the model to a list of inputs that are + potentially valid for the swap direction that is being performed Parameters ---------- - input_tensors: list[:class:`keras.KerasTensor` | :class:`keras.Layer`] - List of Tensors that act as an input to a layer within the model - node_indices: list[int] - The list of node indices corresponding to the inbound node index of the given layers + model + The faceswap model that is to be converted for inference Returns ------- - list[:class:`keras.layers.Layer`] - Any layers from the model that use the given inputs as its input. Empty list if there - are no matches - list[:class:`keras.src.ops.node.KerasHistory`] - The keras inbound history for the layers - list[int] - The output node index for the layer, used for the inbound node index of the next layer + A dictionary of all layers with in the model to a list of inputs that are potentially valid + for the swap direction """ - retval: tuple[list[keras.Layer], - list[keras.src.ops.node.KerasHistory], - list[int]] = ([], [], []) - for layer, history in self._get_candidates(input_tensors): - grp_inputs = self._group_inputs(layer, list(zip(input_tensors, node_indices))) - grp_hist = self._group_inputs(layer, history) - - for input_group in grp_inputs: # pylint:disable=not-an-iterable - have = [(i[0].name, i[1]) for i in input_group] - for out_idx, hist in enumerate(grp_hist): - requires = [(h.operation.name, h.node_index) for h in hist] - if sorted(have) != sorted(requires): - logger.debug("%s: Skipping '%s'. Requires %s. Output node index: %s", - have, layer.name, requires, out_idx) - continue - retval[0].append(layer) - retval[1].append(hist) - retval[2].append(out_idx) - - logger.debug("Got layers %s for input_tensors: %s", - [x.name for x in retval[0]], [t.name for t in input_tensors]) + retval = {layer: self._get_valid_inputs_for_layer(layer) + for layer in T.cast(list[keras.Layer], model.layers) + if not isinstance(layer, keras.layers.InputLayer)} + logger.debug("[Inference] '%s' layer valid inputs for side index %s: %s", + model.name, + self._side_idx, + {k.name: [o.name for o in v] for k, v in retval.items()}) return retval - def _build_layers(self, - layers: list[keras.Layer], - history: list[keras.src.ops.node.KerasHistory], - inputs: list[keras.KerasTensor]) -> list[keras.KerasTensor]: - """ Compile the given layers with the given inputs + def _get_output_layer(self, model: keras.models.Model) -> keras.Layer: + """Obtain the layer that acts as the output for the swap direction of the model Parameters ---------- - layers: list[:class:`keras.Layer`] - The layers to be called with the given inputs - history: list[:class:`keras.src.ops.node.KerasHistory`] - The corresponding keras inbound history for the layers - inputs: list[:class:`keras.KerasTensor] - The inputs for the given layers + model + The faceswap model that is to be converted for inference Returns ------- - list[:class:`keras.KerasTensor`] - The list of compiled layers + The layer that acts as output for the model. This will either be a layer unique to the swap + side, if split decoders, or the shared output layer if shared decoder """ - retval = [] - given_order = [i._keras_history.operation.name # pylint:disable=protected-access - for i in inputs] - for layer, hist in zip(layers, history): - layer_input = [inputs[given_order.index(h.operation.name)] - for h in hist if h.operation.name in given_order] - if layer_input != inputs: - logger.debug("Sorted layer inputs %s to %s", - given_order, - [i._keras_history.operation.name # pylint:disable=protected-access - for i in layer_input]) - - if isinstance(layer_input, list) and len(layer_input) == 1: - # Flatten single inputs to stop Keras warnings - actual_input = layer_input[0] - else: - actual_input = layer_input - - built = layer(actual_input) - built = built if isinstance(built, list) else [built] - logger.debug( - "Compiled layer '%s' from input(s) %s", - layer.name, - [i._keras_history.operation.name # pylint:disable=protected-access - for i in layer_input]) - retval.extend(built) - - logger.debug( - "Compiled layers %s from input %s", - [x._keras_history.operation.name for x in retval], # pylint:disable=protected-access - [x._keras_history.operation.name for x in inputs]) # pylint:disable=protected-access + history: list[node.KerasHistory] = [t._keras_history # pylint:disable=protected-access + for t in model.output] + logger.debug("[Inference] '%s' output history: %s", model.name, history) + + layers = [h.operation for h in history] + outputs_count = len(layers) + layer_count = len(set(o.name for o in layers)) + logger.debug("[Inference] '%s' outputs count: %s, output layer count: %s", + model.name, outputs_count, layer_count) + assert layer_count in (1, 2), f"Unexpected output layers count: {layer_count}" + + if layer_count == 1: + retval = layers[0] + else: + split = outputs_count // 2 + out_layers = layers[:split] if self._side_idx == 0 else layers[split:] + out_layer_count = len(set(o.name for o in out_layers)) + assert out_layer_count == 1, f"Unexpected output layer count: {out_layer_count}" + retval = out_layers[0] + + logger.debug("[Inference] '%s' output layer for side index %s: '%s'", + model.name, self._side_idx, retval.name) + return retval - def _build(self): - """ Extract the sub-models from the saved model that are required for inference. + def _backwards_recurse(self, layer: keras.Layer, seen: set[keras.Layer] | None = None + ) -> list[keras.Layer]: + """Work backwards from the output to filter out layers that are not in the requested swap + path and update to :attr:`_valid_layers` Returns ------- - :class:`keras.Model` - The model compiled for inference + A list of layers that exist within the requested swap path of the training model. Note: + whilst the order is generally from last to first, due to multiple path splitting, order + is not guaranteed """ - logger.debug("Compiling inference model") + seen = set() if seen is None else seen + if layer in seen: + logger.debug("[Inference] Skipping seen layer '%s'.", layer.name) + return [] - layers = self._input - node_index = [0] - built = layers + seen.add(layer) + retval = [layer] - while True: - layers, history, node_index = self._layers_from_inputs(layers, node_index) - if not layers: - break + if layer not in self._valid_layer_inputs: + logger.debug("[Inference] No inputs for '%s'. Returning", layer.name) + return retval - built = self._build_layers(layers, history, built) + next_layers = self._valid_layer_inputs[layer] + logger.debug("[Inference] Got inputs for '%s': %s", + layer.name, [n.name for n in next_layers]) - assert len(self._input) == 1 - assert len(built) in (1, 2) - out = built[0] if len(built) == 1 else built - retval = keras.Model(inputs=self._input[0], outputs=out, name=self._name) - logger.debug("Compiled inference model '%s': %s", retval.name, retval) + for lyr in next_layers: + retval.extend(self._backwards_recurse(lyr, seen=seen)) + + logger.debug("[Inference] Final inputs for '%s': %s", layer.name, retval) + return retval + def __call__(self) -> keras.models.Model: + """Obtain the inference model. + + Returns + ------- + The built Keras inference model for the requested swap side + """ + built = {self._input.name: self._input} + to_build = {k: v for k, v in self._valid_layer_inputs.items() + if k in self._filtered_layers} + logger.debug("[Inference] Building inference model from '%s' with layers %s", + self._input.name, [k.name for k in to_build]) + for layer, inputs in to_build.items(): + name = layer.name + input_names = [i.name for i in inputs] + logger.debug("[Inference] Building layer '%s' with inputs %s", name, input_names) + assert all(i in built for i in input_names) + input_layers = [built[n] for n in input_names] + built[layer.name] = layer(input_layers if len(input_layers) > 1 else input_layers[0]) + + output = built[self._output.name] + retval = keras.Model(inputs=self._input, outputs=output, name=self._name) + logger.debug("[Inference] Built model: %s", retval.name) return retval diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index f205d7d602..7dbaca472a 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Base class for Models. ALL Models should at least inherit from this class. +"""Base class for Models. ALL Models should at least inherit from this class. See :mod:`~plugins.train.model.original` for an annotated example for how to create model plugins. """ @@ -30,27 +29,18 @@ class ModelBase(): # pylint:disable=too-many-instance-attributes - """ Base class that all model plugins should inherit from. + """Base class that all model plugins should inherit from. Parameters ---------- - model_dir: str + model_dir The full path to the model save location - arguments: :class:`argparse.Namespace` + arguments The arguments that were passed to the train or convert process as generated from Faceswap's command line arguments - predict: bool, optional + predict ``True`` if the model is being loaded for inference, ``False`` if the model is being loaded for training. Default: ``False`` - - Attributes - ---------- - input_shape: tuple or list - A `tuple` of `ints` defining the shape of the faces that the model takes as input. This - should be overridden by model plugins in their :func:`__init__` function. If the input size - is the same for both sides of the model, then this can be a single 3 dimensional `tuple`. - If the inputs have different sizes for `"A"` and `"B"` this should be a `list` of 2 3 - dimensional shape `tuples`, 1 for each side respectively. """ def __init__(self, model_dir: str, @@ -59,6 +49,12 @@ def __init__(self, logger.debug(parse_class_init(locals())) # Input shape must be set within the plugin after initializing self.input_shape: tuple[int, ...] = () + """A `tuple` of `ints` defining the shape of the faces that the model takes as input. This + should be overridden by model plugins in their :func:`__init__` function. If the input size + is the same for both sides of the model, then this can be a single 3 dimensional `tuple`. + If the inputs have different sizes for `"A"` and `"B"` this should be a `list` of 2 3 + dimensional shape `tuples`, 1 for each side respectively.""" + self.color_order: T.Literal["bgr", "rgb"] = "bgr" # Override for image color channel order self._args = arguments @@ -94,95 +90,92 @@ def __init__(self, @property def model(self) -> keras.Model: - """:class:`keras.Model`: The compiled model for this plugin. """ + """The compiled model for this plugin.""" return self._model @property def command_line_arguments(self) -> argparse.Namespace: - """ :class:`argparse.Namespace`: The command line arguments passed to the model plugin from - either the train or convert script """ + """The command line arguments passed to the model plugin from either the train or convert + script""" return self._args @property def coverage_ratio(self) -> float: - """ float: The ratio of the training image to crop out and train on as defined in user + """The ratio of the training image to crop out and train on as defined in user configuration options. NB: The coverage ratio is a raw float, but will be applied to integer pixel images. To ensure consistent rounding and guaranteed even image size, the calculation for coverage - should always be: :math:`(original_size * coverage_ratio // 2) * 2` - """ + should always be: :math:`(original_size * coverage_ratio // 2) * 2`""" return cfg.coverage() / 100. @property def io(self) -> IO: # pylint:disable=invalid-name - """ :class:`~plugins.train.model.io.IO`: Input/Output operations for the model """ + """Input/Output operations for the model""" return self._io @property def name(self) -> str: - """ str: The name of this model based on the plugin name. """ + """The name of this model based on the plugin name.""" _name = sys.modules[self.__module__].__file__ assert isinstance(_name, str) return os.path.splitext(os.path.basename(_name))[0].lower() @property def model_name(self) -> str: - """ str: The name of the keras model. Generally this will be the same as :attr:`name` - but some plugins will override this when they contain multiple architectures """ + """The name of the keras model. Generally this will be the same as :attr:`name` + but some plugins will override this when they contain multiple architectures""" return self.name @property def input_shapes(self) -> list[tuple[None, int, int, int]]: - """ list: A flattened list corresponding to all of the inputs to the model. """ + """A flattened list corresponding to all of the inputs to the model.""" shapes = [T.cast(tuple[None, int, int, int], inputs.shape) for inputs in self.model.inputs] return shapes @property def output_shapes(self) -> list[tuple[None, int, int, int]]: - """ list: A flattened list corresponding to all of the outputs of the model. """ + """A flattened list corresponding to all of the outputs of the model.""" shapes = [T.cast(tuple[None, int, int, int], output.shape) for output in self.model.outputs] return shapes @property def iterations(self) -> int: - """ int: The total number of iterations that the model has trained. """ + """The total number of iterations that the model has trained.""" return self._state.iterations @property def warmup_steps(self) -> int: - """ int : The number of steps to perform learning rate warmup """ + """The number of steps to perform learning rate warmup""" return self._args.warmup @property def freeze_layers(self) -> list[str]: - """ list[str] : Override to set plugin specific layers that can be frozen. Defaults to - ["encoder"] """ + """Override to set plugin specific layers that can be frozen. Defaults to ["encoder"]""" return ["encoder"] @property def load_layers(self) -> list[str]: - """ list[str] : Override to set plugin specific layers that can be loaded. Defaults to - ["encoder"] """ + """Override to set plugin specific layers that can be loaded. Defaults to ["encoder"]""" return ["encoder"] # Private properties @property def _config_section(self) -> str: - """ str: The section name for the current plugin for loading configuration options from the - config file. """ + """The section name for the current plugin for loading configuration options from the + config file""" return ".".join(self.__module__.split(".")[-2:]) @property def state(self) -> "State": - """:class:`State`: The state settings for the current plugin. """ + """The state settings for the current plugin.""" return self._state def _check_multiple_models(self) -> None: - """ Check whether multiple models exist in the model folder, and that no models exist that + """Check whether multiple models exist in the model folder, and that no models exist that were trained with a different plugin than the requested plugin. Raises @@ -208,7 +201,7 @@ def _check_multiple_models(self) -> None: raise FaceswapError(msg) def build(self) -> None: - """ Build the model and assign to :attr:`model`. + """Build the model and assign to :attr:`model`. Within the defined strategy scope, either builds the model from scratch or loads an existing model if one exists. @@ -224,7 +217,7 @@ def build(self) -> None: model = self.io.load() if self._is_predict: inference = Inference(model, self._args.swap_model) - self._model = inference.model + self._model = inference() else: self._model = model else: @@ -243,21 +236,20 @@ def build(self) -> None: self._output_summary() def _validate_input_shape(self) -> None: - """ Validate that the input shape is either a single shape tuple of 3 dimensions or - a list of 2 shape tuples of 3 dimensions. """ + """Validate that the input shape is either a single shape tuple of 3 dimensions or + a list of 2 shape tuples of 3 dimensions.""" assert len(self.input_shape) == 3, "Input shape should be a 3 dimensional shape tuple" def _get_inputs(self) -> list[keras.layers.Input]: - """ Obtain the standardized inputs for the model. + """Obtain the standardized inputs for the model. The inputs will be returned for the "A" and "B" sides in the shape as defined by :attr:`input_shape`. Returns ------- - list - A list of :class:`keras.layers.Input` tensors. This will be a list of 2 tensors (one - for each side) each of shapes :attr:`input_shape`. + A list of :class:`keras.layers.Input` tensors. This will be a list of 2 tensors (one for + each side) each of shapes :attr:`input_shape`. """ logger.debug("Getting inputs") input_shapes = [self.input_shape, self.input_shape] @@ -267,36 +259,35 @@ def _get_inputs(self) -> list[keras.layers.Input]: return inputs def build_model(self, inputs: list[keras.layers.Input]) -> keras.Model: - """ Override for Model Specific autoencoder builds. + """Override for Model Specific autoencoder builds. Parameters ---------- - inputs: list + inputs A list of :class:`keras.layers.Input` tensors. This will be a list of 2 tensors (one for each side) each of shapes :attr:`input_shape`. Returns ------- - :class:`keras.Model` - See Keras documentation for the correct structure, but note that parameter :attr:`name` - is a required rather than an optional argument in Faceswap. You should assign this to - the attribute ``self.name`` that is automatically generated from the plugin's filename. + See Keras documentation for the correct structure, but note that parameter :attr:`name` + is a required rather than an optional argument in Faceswap. You should assign this to + the attribute ``self.name`` that is automatically generated from the plugin's filename. """ raise NotImplementedError def _summary_to_log(self, summary: str) -> None: - """ Function to output Keras model summary to log file at verbose log level + """Function to output Keras model summary to log file at verbose log level Parameters ---------- - summary, str + summary The model summary output from keras """ for line in summary.splitlines(): logger.verbose(line) # type:ignore[attr-defined] def _output_summary(self) -> None: - """ Output the summary of the model and all sub-models to the verbose logger. """ + """Output the summary of the model and all sub-models to the verbose logger.""" if hasattr(self._args, "summary") and self._args.summary: print_fn = None # Print straight to stdout else: @@ -311,7 +302,7 @@ def _output_summary(self) -> None: parent.summary(print_fn=print_fn) def _compile_model(self) -> None: - """ Compile the model to include the Optimizer and Loss Function(s). """ + """Compile the model to include the Optimizer and Loss Function(s).""" logger.debug("Compiling Model") if self.state.model_needs_rebuild: @@ -332,14 +323,14 @@ def _compile_model(self) -> None: logger.debug("Compiled Model: %s", self.model) def add_history(self, loss: np.ndarray) -> None: - """ Add the current iteration's loss history to :attr:`_io.history`. + """Add the current iteration's loss history to :attr:`_io.history`. Called from the trainer after each iteration, for tracking loss drop over time between save iterations. Parameters ---------- - loss : :class:`numpy.ndarray` + loss The loss values for the A and B side for the current iteration. This should be the collated loss values for each side. """ diff --git a/tools/model/model.py b/tools/model/model.py index 80c1520a51..c88862f4ea 100644 --- a/tools/model/model.py +++ b/tools/model/model.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Tool to restore models from backup """ +"""Tool to restore models from backup""" from __future__ import annotations import logging import os @@ -27,11 +27,11 @@ class Model(): - """ Tool to perform actions on a model file. + """Tool to perform actions on a model file. Parameters ---------- - :class:`argparse.Namespace` + arguments The command line arguments calling the model tool """ def __init__(self, arguments: argparse.Namespace) -> None: @@ -42,18 +42,17 @@ def __init__(self, arguments: argparse.Namespace) -> None: @classmethod def _get_job(cls, arguments: argparse.Namespace) -> Inference | NaNScan | Restore: - """ Get the correct object that holds the selected job. + """Get the correct object that holds the selected job. Parameters ---------- - arguments: :class:`argparse.Namespace` + arguments The command line arguments received for the Model tool which will be used to initiate the selected job Returns ------- - :class:`Inference` | :class:`NaNScan` | :class:`Restore` - The object that will perform the selected job + The object that will perform the selected job """ jobs: dict[str, T.Type[Inference | NaNScan | Restore]] = { "inference": Inference, @@ -63,52 +62,51 @@ def _get_job(cls, arguments: argparse.Namespace) -> Inference | NaNScan | Restor @classmethod def _check_folder(cls, model_dir: str) -> str: - """ Check that the passed in model folder exists and contains a valid model. + """Check that the passed in model folder exists and contains a valid model. If the passed in value fails any checks, process exits. Parameters ---------- - model_dir: str + model_dir The model folder to be checked Returns ------- - str - The confirmed location of the model folder. + The confirmed location of the model folder. """ if not os.path.exists(model_dir): logger.error("Model folder does not exist: '%s'", model_dir) sys.exit(1) - chkfiles = [fname - for fname in os.listdir(model_dir) - if fname.endswith(".keras") - and not os.path.splitext(fname)[0].endswith("_inference")] + chk_files = [fname + for fname in os.listdir(model_dir) + if fname.endswith(".keras") + and not os.path.splitext(fname)[0].endswith("_inference")] - if not chkfiles: + if not chk_files: logger.error("Could not find a model in the supplied folder: '%s'", model_dir) sys.exit(1) - if len(chkfiles) > 1: + if len(chk_files) > 1: logger.error("More than one model file found in the model folder: '%s'", model_dir) sys.exit(1) - model_name = os.path.splitext(chkfiles[0])[0].title() + model_name = os.path.splitext(chk_files[0])[0].title() logger.info("%s Model found", model_name) return model_dir def process(self) -> None: - """ Call the selected model job.""" + """Call the selected model job.""" self._job.process() class Inference(): - """ Save an inference model from a trained Faceswap model. + """Save an inference model from a trained Faceswap model. Parameters ---------- - :class:`argparse.Namespace` + arguments The command line arguments calling the model tool """ def __init__(self, arguments: argparse.Namespace) -> None: @@ -118,18 +116,18 @@ def __init__(self, arguments: argparse.Namespace) -> None: logger.debug("Initialized %s", self.__class__.__name__) def _get_output_file(self, model_dir: str) -> tuple[str, str]: - """ Obtain the full path for the output model file/folder + """Obtain the full path for the output model file/folder Parameters ---------- - model_dir: str + model_dir The full path to the folder containing the Faceswap trained model .keras file Returns ------- - str + source_model The full path to the source model file - str + inference_model The full path to the inference model save location """ model_name = next(fname for fname in os.listdir(model_dir) @@ -144,21 +142,21 @@ def _get_output_file(self, model_dir: str) -> tuple[str, str]: return in_path, out_path def process(self) -> None: - """ Run the inference model creation process. """ + """Run the inference model creation process.""" logger.info("Loading model '%s'", self._input_file) model = saving.load_model(self._input_file, compile=False) logger.info("Creating inference model...") - inference = FSInference(model, self._switch).model + inference = FSInference(model, self._switch)() logger.info("Saving to: '%s'", self._output_file) inference.save(self._output_file) class NaNScan(): - """ Tool to scan for NaN and Infs in model weights. + """Tool to scan for NaN and Infs in model weights. Parameters ---------- - :class:`argparse.Namespace` + arguments The command line arguments calling the model tool """ def __init__(self, arguments: argparse.Namespace) -> None: @@ -168,24 +166,23 @@ def __init__(self, arguments: argparse.Namespace) -> None: @classmethod def _get_model_filename(cls, model_dir: str) -> str: - """ Obtain the full path the model's .keras file. + """Obtain the full path the model's .keras file. Parameters ---------- - model_dir: str + model_dir The full path to the folder containing the model file Returns ------- - str - The full path to the saved model file + The full path to the saved model file """ model_file = next(fname for fname in os.listdir(model_dir) if fname.endswith(".keras")) return os.path.join(model_dir, model_file) def _parse_weights(self, layer: keras.models.Model | keras.layers.Layer) -> dict: - """ Recursively pass through sub-models to scan layer weights""" + """Recursively pass through sub-models to scan layer weights""" weights = layer.get_weights() logger.debug("Processing weights for layer '%s', length: '%s'", layer.name, len(weights)) @@ -194,7 +191,7 @@ def _parse_weights(self, logger.debug("Skipping layer with no weights: %s", layer.name) return {} - if hasattr(layer, "layers"): # Must be a submodel + if hasattr(layer, "layers"): # Must be a sub-model retval = {} for lyr in layer.layers: info = self._parse_weights(lyr) @@ -211,28 +208,27 @@ def _parse_weights(self, return {"nans": nans, "infs": infs} def _parse_output(self, errors: dict, indent: int = 0) -> None: - """ Parse the output of the errors dictionary and print a pretty summary. + """Parse the output of the errors dictionary and print a pretty summary. Parameters ---------- - errors: dict + errors The nested dictionary of errors found when parsing the weights - - indent: int, optional + indent How far should the current printed line be indented. Default: `0` """ for key, val in errors.items(): - logline = f"|{'--' * indent} " - logline += key.ljust(50 - len(logline)) + log_line = f"|{'--' * indent} " + log_line += key.ljust(50 - len(log_line)) if isinstance(val, dict) and "nans" not in val: - logger.info(logline) + logger.info(log_line) self._parse_output(val, indent + 1) elif isinstance(val, dict) and "nans" in val: - logline += f"nans: {val['nans']}, infs: {val['infs']}" - logger.info(logline.ljust(30)) + log_line += f"nans: {val['nans']}, infs: {val['infs']}" + logger.info(log_line.ljust(30)) def process(self) -> None: - """ Scan the loaded model for NaNs and Infs and output summary. """ + """Scan the loaded model for NaNs and Infs and output summary.""" logger.info("Loading model...") model = saving.load_model(self._model_file, compile=False) logger.info("Parsing weights for invalid values...") @@ -247,11 +243,11 @@ def process(self) -> None: class Restore(): - """ Restore a model from backup. + """Restore a model from backup. Parameters ---------- - :class:`argparse.Namespace` + arguments The command line arguments calling the model tool """ def __init__(self, arguments: argparse.Namespace) -> None: @@ -261,23 +257,23 @@ def __init__(self, arguments: argparse.Namespace) -> None: logger.debug("Initialized %s", self.__class__.__name__) def process(self) -> None: - """ Perform the Restore process """ + """Perform the Restore process""" logger.info("Starting Model Restore...") backup = Backup(self._model_dir, self._model_name) backup.restore() logger.info("Completed Model Restore") def _get_model_name(self) -> str: - """ Additional checks to make sure that a backup exists in the model location. """ - bkfiles = [fname for fname in os.listdir(self._model_dir) if fname.endswith(".bk")] - if not bkfiles: + """Additional checks to make sure that a backup exists in the model location.""" + bk_files = [fname for fname in os.listdir(self._model_dir) if fname.endswith(".bk")] + if not bk_files: logger.error("Could not find any backup files in the supplied folder: '%s'", self._model_dir) sys.exit(1) - logger.verbose("Backup files: %s)", bkfiles) # type:ignore[attr-defined] + logger.verbose("Backup files: %s)", bk_files) # type:ignore[attr-defined] ext = ".keras.bk" - model_name = next(fname for fname in bkfiles if fname.endswith(ext)) + model_name = next(fname for fname in bk_files if fname.endswith(ext)) return model_name[:-len(ext)] From 6ae823141c267205932bb148cedbba59a1c1c53d Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:11:05 +0000 Subject: [PATCH 946/981] bugfix: vertical-offset in convert and preview --- lib/align/aligned_face.py | 6 +- scripts/convert.py | 5 ++ tools/preview/preview.py | 3 +- tools/preview/viewer.py | 124 ++++++++++++++++++-------------------- 4 files changed, 72 insertions(+), 66 deletions(-) diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 8a93c8fc40..d9dce47a8e 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -410,7 +410,11 @@ def extract_face(self, image: np.ndarray | None) -> np.ndarray | None: elif self._is_aligned: retval = image else: - retval = transform_image(image, self.matrix, self._size, self.padding) + mat = self.matrix + if self._y_offset: + mat = self.matrix.copy() + mat[1, 2] += self.y_offset + retval = transform_image(image, mat, self._size, self.padding) retval = retval if self._dtype is None else retval.astype(self._dtype) return retval diff --git a/scripts/convert.py b/scripts/convert.py index 2c95bc879c..6e5b7b4e45 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -787,6 +787,11 @@ def output_size(self) -> int: """The size in pixels of the Faceswap model output.""" return self._sizes["output"] + @property + def y_offset(self) -> float: + """The selected model y-offset value""" + return self._y_offset + def _get_io_sizes(self) -> dict[str, int]: """Obtain the input size and output size of the model. diff --git a/tools/preview/preview.py b/tools/preview/preview.py index 2f433a7568..1fddbe4d16 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -304,7 +304,8 @@ def __init__(self, app: Preview, arguments: Namespace, sample_size: int) -> None self._predictor = Predict(self._sample_size, arguments) self._predictor.launch(queue_manager.get_queue("preview_predict_in")) - self._app._display.set_centering(self._predictor.centering) + self._app._display.set_centering_offset(self._predictor.centering, + self._predictor.y_offset) self.generate() logger.debug("Initialized %s", self.__class__.__name__) diff --git a/tools/preview/viewer.py b/tools/preview/viewer.py index fc5586ba50..bf2871ea1d 100644 --- a/tools/preview/viewer.py +++ b/tools/preview/viewer.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Manages the widgets that hold the top 'viewer' area of the preview tool """ +"""Manages the widgets that hold the top 'viewer' area of the preview tool""" from __future__ import annotations import logging import os @@ -27,7 +27,7 @@ @dataclass class _Faces: - """ Dataclass for holding faces """ + """Dataclass for holding faces""" filenames: list[str] = field(default_factory=list) matrix: list[np.ndarray] = field(default_factory=list) src: list[np.ndarray] = field(default_factory=list) @@ -35,27 +35,16 @@ class _Faces: class FacesDisplay(): # pylint:disable=too-many-instance-attributes - """ Compiles the 2 rows of sample faces (original and swapped) into a single image + """Compiles the 2 rows of sample faces (original and swapped) into a single image Parameters ---------- - app: :class:`Preview` + app The main tkinter Preview app - size: int + size The size of each individual face sample in pixels - padding: int + padding The amount of extra padding to apply to the outside of the face - - Attributes - ---------- - update_source: bool - Flag to indicate that the source images for the preview have been updated, so the preview - should be recompiled. - source: list - The list of :class:`numpy.ndarray` source preview images for top row of display - destination: list - The list of :class:`numpy.ndarray` swapped and patched preview images for bottom row of - display """ def __init__(self, app: Preview, size: int, padding: int) -> None: logger.trace("Initializing %s: (app: %s, size: %s, padding: %s)", # type: ignore @@ -67,81 +56,85 @@ def __init__(self, app: Preview, size: int, padding: int) -> None: self._faces = _Faces() self._centering: CenteringType | None = None + self._y_offset = 0.0 self._faces_source: np.ndarray = np.array([]) self._faces_dest: np.ndarray = np.array([]) self._tk_image: ImageTk.PhotoImage | None = None # Set from Samples - self.update_source = False + self.update_source: bool = False + """Flag to indicate that the source images for the preview have been updated, so the + preview should be recompiled.""" self.source: list[ConvertItem] = [] # Source images, filenames + detected faces + """The list of :class:`numpy.ndarray` source preview images for top row of display""" # Set from Patch self.destination: list[np.ndarray] = [] # Swapped + patched images + """The list of :class:`numpy.ndarray` swapped and patched preview images for bottom row of + display""" logger.trace("Initialized %s", self.__class__.__name__) # type: ignore @property def tk_image(self) -> ImageTk.PhotoImage | None: - """ :class:`PIL.ImageTk.PhotoImage`: The compiled preview display in tkinter display - format """ + """The compiled preview display in tkinter display format""" return self._tk_image @property def _total_columns(self) -> int: - """ int: The total number of images that are being displayed """ + """The total number of images that are being displayed""" return len(self.source) - def set_centering(self, centering: CenteringType) -> None: - """ The centering that the model uses is not known at initialization time. - Set :attr:`_centering` when the model has been loaded. + def set_centering_offset(self, centering: CenteringType, y_offset: float) -> None: + """The centering and y-offset that the model uses is not known at initialization time. + Set :attr:`_centering` and y_offset when the model has been loaded. Parameters ---------- - centering: str + centering The centering that the model was trained on """ self._centering = centering + self._y_offset = y_offset def set_display_dimensions(self, dimensions: tuple[int, int]) -> None: - """ Adjust the size of the frame that will hold the preview samples. + """Adjust the size of the frame that will hold the preview samples. Parameters ---------- - dimensions: tuple + dimensions The (`width`, `height`) of the frame that holds the preview """ self._display_dims = dimensions def update_tk_image(self) -> None: - """ Build the full preview images and compile :attr:`tk_image` for display. """ + """Build the full preview images and compile :attr:`tk_image` for display.""" logger.trace("Updating tk image") # type: ignore self._build_faces_image() img = np.vstack((self._faces_source, self._faces_dest)) size = self._get_scale_size(img) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) - pilimg = Image.fromarray(img) - pilimg = pilimg.resize(size, Image.Resampling.BICUBIC) - self._tk_image = ImageTk.PhotoImage(pilimg) + pil_img = Image.fromarray(img) + pil_img = pil_img.resize(size, Image.Resampling.BICUBIC) + self._tk_image = ImageTk.PhotoImage(pil_img) logger.trace("Updated tk image") # type: ignore def _get_scale_size(self, image: np.ndarray) -> tuple[int, int]: - """ Get the size that the full preview image should be resized to fit in the + """Get the size that the full preview image should be resized to fit in the display window. Parameters ---------- - image: :class:`numpy.ndarray` + image The full sized compiled preview image Returns ------- - tuple - The (`width`, `height`) that the display image should be sized to fit in the display - window + The (`width`, `height`) that the display image should be sized to fit in the display window """ - frameratio = float(self._display_dims[0]) / float(self._display_dims[1]) - imgratio = float(image.shape[1]) / float(image.shape[0]) + frame_ratio = float(self._display_dims[0]) / float(self._display_dims[1]) + img_ratio = float(image.shape[1]) / float(image.shape[0]) - if frameratio <= imgratio: + if frame_ratio <= img_ratio: scale = self._display_dims[0] / float(image.shape[1]) size = (self._display_dims[0], max(1, int(image.shape[0] * scale))) else: @@ -151,7 +144,7 @@ def _get_scale_size(self, image: np.ndarray) -> tuple[int, int]: return size def _build_faces_image(self) -> None: - """ Compile the source and destination rows of the preview image. """ + """Compile the source and destination rows of the preview image.""" logger.trace("Building Faces Image") # type: ignore update_all = self.update_source self._faces_from_frames() @@ -164,7 +157,7 @@ def _build_faces_image(self) -> None: self._faces_dest.shape, self._faces_source.shape) def _faces_from_frames(self) -> None: - """ Extract the preview faces from the source frames and apply the requisite padding. """ + """Extract the preview faces from the source frames and apply the requisite padding.""" logger.debug("Extracting faces from frames: Number images: %s", len(self.source)) if self.update_source: self._crop_source_faces() @@ -173,8 +166,8 @@ def _faces_from_frames(self) -> None: {k: len(v) for k, v in self._faces.__dict__.items()}) def _crop_source_faces(self) -> None: - """ Extract the source faces from the source frames, along with their filenames and the - transformation matrix used to extract the faces. """ + """Extract the source faces from the source frames, along with their filenames and the + transformation matrix used to extract the faces.""" logger.debug("Updating source faces") self._faces = _Faces() # Init new class for item in self.source: @@ -182,17 +175,21 @@ def _crop_source_faces(self) -> None: src_img = item.inbound.image detected_face.load_aligned(src_img, size=self._size, - centering=T.cast(CenteringType, self._centering)) + centering=T.cast(CenteringType, self._centering), + y_offset=self._y_offset) matrix = detected_face.aligned.matrix self._faces.filenames.append(os.path.splitext(item.inbound.filename)[0]) self._faces.matrix.append(matrix) + if self._y_offset: + matrix = detected_face.aligned.matrix.copy() + matrix[1, 2] += self._y_offset self._faces.src.append(transform_image(src_img, matrix, self._size, self._padding)) self.update_source = False logger.debug("Updated source faces") def _crop_destination_faces(self) -> None: - """ Extract the swapped faces from the swapped frames using the source face destination - matrices. """ + """Extract the swapped faces from the swapped frames using the source face destination + matrices.""" logger.debug("Updating destination faces") self._faces.dst = [] destination = self.destination if self.destination else [np.ones_like(src.inbound.image) @@ -205,12 +202,11 @@ def _crop_destination_faces(self) -> None: logger.debug("Updated destination faces") def _header_text(self) -> np.ndarray: - """ Create the header text displaying the frame name for each preview column. + """Create the header text displaying the frame name for each preview column. Returns ------- - :class:`numpy.ndarray` - The header row of the preview image containing the frame names for each column + The header row of the preview image containing the frame names for each column """ font_scale = self._size / 640 height = self._size // 8 @@ -241,16 +237,16 @@ def _header_text(self) -> np.ndarray: return header_box def _draw_rect(self, image: np.ndarray) -> np.ndarray: - """ Place a white border around a given image. + """Place a white border around a given image. Parameters ---------- - image: :class:`numpy.ndarray` + image The image to place a border on to + Returns ------- - :class:`numpy.ndarray` - The given image with a border drawn around the outside + The given image with a border drawn around the outside """ cv2.rectangle(image, (0, 0), (self._size - 1, self._size - 1), (255, 255, 255), 1) image = np.clip(image, 0.0, 255.0) @@ -258,13 +254,13 @@ def _draw_rect(self, image: np.ndarray) -> np.ndarray: class ImagesCanvas(ttk.Frame): # pylint:disable=too-many-ancestors - """ tkinter Canvas that holds the preview images. + """tkinter Canvas that holds the preview images. Parameters ---------- - app: :class:`Preview` + app The main tkinter Preview app - parent: tkinter object + parent The parent tkinter object that holds the canvas """ def __init__(self, app: Preview, parent: ttk.PanedWindow) -> None: @@ -276,24 +272,24 @@ def __init__(self, app: Preview, parent: ttk.PanedWindow) -> None: self._display: FacesDisplay = parent.preview_display # type: ignore self._canvas = tk.Canvas(self, bd=0, highlightthickness=0) self._canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True) - self._displaycanvas = self._canvas.create_image(0, 0, - image=self._display.tk_image, - anchor=tk.NW) + self._display_canvas = self._canvas.create_image(0, 0, + image=self._display.tk_image, + anchor=tk.NW) self.bind("", self._resize) logger.debug("Initialized %s", self.__class__.__name__) def _resize(self, event: tk.Event) -> None: - """ Resize the image to fit the frame, maintaining aspect ratio """ + """Resize the image to fit the frame, maintaining aspect ratio.""" logger.debug("Resizing preview image") - framesize = (event.width, event.height) - self._display.set_display_dimensions(framesize) + frame_size = (event.width, event.height) + self._display.set_display_dimensions(frame_size) self.reload() def reload(self) -> None: - """ Update the images in the canvas and redraw """ + """Update the images in the canvas and redraw.""" logger.debug("Reloading preview image") self._display.update_tk_image() - self._canvas.itemconfig(self._displaycanvas, image=self._display.tk_image) + self._canvas.itemconfig(self._display_canvas, image=self._display.tk_image) logger.debug("Reloaded preview image") From 28ebca5c8879fe572dec66c5c46415ba60d84a78 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:57:00 +0000 Subject: [PATCH 947/981] Fix Preview viewer test --- tests/tools/preview/viewer_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/tools/preview/viewer_test.py b/tests/tools/preview/viewer_test.py index 2ce814b234..9e8b250831 100644 --- a/tests/tools/preview/viewer_test.py +++ b/tests/tools/preview/viewer_test.py @@ -103,12 +103,13 @@ def test__total_columns(self, columns: int, face_size: int) -> None: assert f_display._total_columns == columns def test_set_centering(self) -> None: - """ Test :class:`~tools.preview.viewer.FacesDisplay` set_centering method """ + """ Test :class:`~tools.preview.viewer.FacesDisplay` set_centering_offset method """ f_display = self.get_faces_display_instance() assert f_display._centering is None centering: CenteringType = "legacy" - f_display.set_centering(centering) + f_display.set_centering_offset(centering, 0.80) assert f_display._centering == centering + assert f_display._y_offset == 0.80 def test_set_display_dimensions(self) -> None: """ Test :class:`~tools.preview.viewer.FacesDisplay` set_display_dimensions method """ From 0a0d1fabf027b504853ff6c1c23f769b7de2d6ae Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:13:10 +0000 Subject: [PATCH 948/981] convert bugfixes: - ffmpeg writer: Exit audio buffer when no audio available - convert: correctly apply y-offset - Fix color adjust when "none" selected --- lib/convert.py | 145 ++++++++++++++--------------- lib/video.py | 5 +- tests/tools/preview/viewer_test.py | 33 ++++--- tools/preview/preview.py | 5 +- tools/preview/viewer.py | 65 ++++++++----- 5 files changed, 137 insertions(+), 116 deletions(-) diff --git a/lib/convert.py b/lib/convert.py index 6c7b5c42b7..a2f3d137bf 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Converter for Faceswap """ +"""Converter for Faceswap""" from __future__ import annotations import logging import typing as T @@ -28,17 +28,17 @@ @dataclass class Adjustments: - """ Dataclass to hold the optional processing plugins + """Dataclass to hold the optional processing plugins Parameters ---------- - color: :class:`~plugins.color._base.Adjustment`, Optional + color The selected color processing plugin. Default: `None` - mask: :class:`~plugins.mask_blend.Mask`, Optional + mask The selected mask processing plugin. Default: `None` - seamless: :class:`~plugins.color.seamless_clone.Color`, Optional + seamless The selected mask processing plugin. Default: `None` - sharpening: :class:`~plugins.scaling._base.Adjustment`, Optional + sharpening The selected mask processing plugin. Default: `None` """ color: ColorAdjust | None = None @@ -48,28 +48,28 @@ class Adjustments: class Converter(): # pylint:disable=too-many-instance-attributes - """ The converter is responsible for swapping the original face(s) in a frame with the output + """The converter is responsible for swapping the original face(s) in a frame with the output of a trained Faceswap model. Parameters ---------- - output_size: int + output_size The size of the face, in pixels, that is output from the Faceswap model - coverage_ratio: float + coverage_ratio The ratio of the training image that was used for training the Faceswap model - centering: str + centering The extracted face centering that the model was trained on (`"face"` or "`legacy`") - draw_transparent: bool + draw_transparent Whether the final output should be drawn onto a transparent layer rather than the original frame. Only available with certain writer plugins. - pre_encode: python function + pre_encode Some writer plugins support the pre-encoding of images prior to saving out. As patching is done in multiple threads, but writing is done in a single thread, it can speed up the process to do any pre-encoding as part of the converter process. - arguments: :class:`argparse.Namespace` + arguments The arguments that were passed to the convert process as generated from Faceswap's command line arguments - config_file: str, optional + config_file Optional location of custom configuration ``ini`` file. If ``None`` then use the default config location. Default: ``None`` """ @@ -103,12 +103,11 @@ def __init__(self, @property def cli_arguments(self) -> Namespace: - """:class:`argparse.Namespace`: The command line arguments passed to the convert - process """ + """The command line arguments passed to the convert process""" return self._args def reinitialize(self) -> None: - """ Reinitialize this :class:`Converter`. + """Reinitialize this :class:`Converter`. Called as part of the :mod:`~tools.preview` tool. Resets all adjustments then loads the plugins as specified in the current config. @@ -120,14 +119,14 @@ def reinitialize(self) -> None: logger.debug("Reinitialized converter") def _load_plugins(self, disable_logging: bool = False) -> None: - """ Load the requested adjustment plugins. + """Load the requested adjustment plugins. Loads the :mod:`plugins.converter` plugins that have been requested for this conversion session. Parameters ---------- - config: :class:`lib.config.FaceswapConfig`, optional + config Optional pre-loaded :class:`lib.config.FaceswapConfig`. If passed, then this will be used over any configuration on disk. If ``None`` then it is ignored. Default: ``None`` """ @@ -140,7 +139,7 @@ def _load_plugins(self, disable_logging: bool = False) -> None: self._coverage_ratio, config_file=self._config_file) - if self._args.color_adjustment is not None: + if self._args.color_adjustment is not None and self._args.color_adjustment != "none": self._adjustments.color = PluginLoader.get_converter("color", self._args.color_adjustment, disable_logging=disable_logging)( @@ -154,17 +153,17 @@ def _load_plugins(self, disable_logging: bool = False) -> None: logger.debug("Loaded plugins: %s", self._adjustments) def process(self, in_queue: EventQueue, out_queue: EventQueue): - """ Main convert process. + """Main convert process. Takes items from the in queue, runs the relevant adjustments, patches faces to final frame and outputs patched frame to the out queue. Parameters ---------- - in_queue: :class:`~lib.queue_manager.EventQueue` + in_queue The output from :class:`scripts.convert.Predictor`. Contains detected faces from the Faceswap model as well as the frame to be patched. - out_queue: :class:`~lib.queue_manager.EventQueue` + out_queue The queue to place patched frames into for writing by one of Faceswap's :mod:`plugins.convert.writer` plugins. """ @@ -204,48 +203,47 @@ def process(self, in_queue: EventQueue, out_queue: EventQueue): out_queue.put((item.inbound.filename, image)) logger.debug("Completed convert process") - def _get_warp_matrix(self, matrix: np.ndarray, size: int) -> np.ndarray: - """ Obtain the final scaled warp transformation matrix based on face scaling from the + def _get_warp_matrix(self, matrix: np.ndarray, size: int, y_offset: float = 0.0) -> np.ndarray: + """Obtain the final scaled warp transformation matrix based on face scaling from the original transformation matrix Parameters ---------- - matrix: :class:`numpy.ndarray` + matrix The transformation for patching the swapped face back onto the output frame - size: int + size The size of the face patch, in pixels + y_offset + The amount of offset to apply on the y-axis. Default: 0.0 (no offset) Returns ------- - :class:`numpy.ndarray` - The final transformation matrix with any scaling applied + The final transformation matrix with any scaling and y-offset applied """ - if self._face_scale == 1.0: - mat = matrix - else: + mat = matrix.copy() if self._scale != 1.0 or y_offset else matrix + if self._face_scale != 1.0: mat = matrix * self._face_scale patch_center = (size / 2, size / 2) mat[..., 2] += (1 - self._face_scale) * np.array(patch_center) - + if y_offset: + mat[1, 2] += (y_offset * size) return mat def _patch_image(self, predicted: ConvertItem) -> np.ndarray | list[bytes]: - """ Patch a swapped face onto a frame. + """Patch a swapped face onto a frame. Run selected adjustments and swap the faces in a frame. Parameters ---------- - predicted: :class:`~scripts.convert.ConvertItem` + predicted The output from :class:`scripts.convert.Predictor`. Returns ------- - :class: `numpy.ndarray` or pre-encoded image output - The final frame ready for writing by a :mod:`plugins.convert.writer` plugin. - Frame is either an array, or the pre-encoded output from the writer's pre-encode - function (if it has one) - + The final frame ready for writing by a :mod:`plugins.convert.writer` plugin. Frame is + either an array, or the pre-encoded output from the writer's pre-encode function (if it + has one) """ logger.trace("Patching image: '%s'", # type: ignore[attr-defined] predicted.inbound.filename) @@ -269,7 +267,8 @@ def _patch_image(self, predicted: ConvertItem) -> np.ndarray | list[bytes]: if self.cli_arguments.writer == "patch": kwargs["canvas_size"] = (background.shape[1], background.shape[0]) kwargs["matrices"] = np.array([self._get_warp_matrix(face.adjusted_matrix, - patched_face.shape[1]) + patched_face.shape[1], + face.y_offset) for face in predicted.reference_faces], dtype="float32") retval = self._writer_pre_encode(patched_face, **kwargs) @@ -281,21 +280,21 @@ def _warp_to_frame(self, reference: AlignedFace, face: np.ndarray, frame: np.ndarray) -> None: - """ Perform affine transformation to place a face patch onto the given frame. + """Perform affine transformation to place a face patch onto the given frame. Affine is done in place on the `frame` array, so this function does not return a value Parameters ---------- - reference: :class:`lib.align.AlignedFace` + reference The object holding the original aligned face - face: :class:`numpy.ndarray` + face The swapped face patch - frame: :class:`numpy.ndarray` + frame The frame to affine the face onto """ # Warp face with the mask - mat = self._get_warp_matrix(reference.adjusted_matrix, face.shape[0]) + mat = self._get_warp_matrix(reference.adjusted_matrix, face.shape[0], reference.y_offset) frame_face = np.zeros_like(frame) cv2.warpAffine(face, mat, @@ -315,23 +314,23 @@ def _warp_to_frame(self, def _get_new_image(self, predicted: ConvertItem, frame_size: tuple[int, int]) -> tuple[np.ndarray, np.ndarray]: - """ Get the new face from the predictor and apply pre-warp manipulations. + """Get the new face from the predictor and apply pre-warp manipulations. Applies any requested adjustments to the raw output of the Faceswap model before transforming the image into the target frame. Parameters ---------- - predicted: :class:`~scripts.convert.ConvertItem` + predicted The output from :class:`scripts.convert.Predictor`. - frame_size: tuple + frame_size The (`width`, `height`) of the final frame in pixels Returns ------- - placeholder: :class: `numpy.ndarray` + placeholder The original frame with the swapped faces patched onto it - background: :class: `numpy.ndarray` + background The original frame """ logger.trace("Getting: (filename: '%s', faces: %s)", # type: ignore[attr-defined] @@ -375,7 +374,7 @@ def _pre_warp_adjustments(self, detected_face: DetectedFace, reference_face: AlignedFace, predicted_mask: np.ndarray | None) -> np.ndarray: - """ Run any requested adjustments that can be performed on the raw output from the Faceswap + """Run any requested adjustments that can be performed on the raw output from the Faceswap model. Any adjustments that can be performed before warping the face into the final frame are @@ -383,21 +382,19 @@ def _pre_warp_adjustments(self, Parameters ---------- - new_face: :class:`numpy.ndarray` + new_face The swapped face received from the faceswap model. - detected_face: :class:`~lib.align.DetectedFace` + detected_face The detected_face object as defined in :class:`scripts.convert.Predictor` - reference_face: :class:`~lib.align.AlignedFace` + reference_face The aligned face object sized to the model output of the original face for reference - predicted_mask: :class:`numpy.ndarray` or ``None`` + predicted_mask The predicted mask output from the Faceswap model. ``None`` if the model did not learn a mask Returns ------- - :class:`numpy.ndarray` - The face output from the Faceswap Model with any requested pre-warp adjustments - performed. + The face output from the Faceswap Model with any requested pre-warp adjustments performed. """ logger.trace("new_face shape: %s, predicted_mask shape: %s", # type: ignore[attr-defined] new_face.shape, predicted_mask.shape if predicted_mask is not None else None) @@ -418,27 +415,27 @@ def _get_image_mask(self, detected_face: DetectedFace, predicted_mask: np.ndarray | None, reference_face: AlignedFace) -> tuple[np.ndarray, np.ndarray]: - """ Return any selected image mask + """Return any selected image mask Places the requested mask into the new face's Alpha channel. Parameters ---------- - new_face: :class:`numpy.ndarray` + new_face The swapped face received from the faceswap model. - detected_face: :class:`~lib.DetectedFace` + detected_face The detected_face object as defined in :class:`scripts.convert.Predictor` - predicted_mask: :class:`numpy.ndarray` or ``None`` + predicted_mask The predicted mask output from the Faceswap model. ``None`` if the model did not learn a mask - reference_face: :class:`~lib.align.AlignedFace` + reference_face The aligned face object sized to the model output of the original face for reference Returns ------- - :class:`numpy.ndarray` + swapped_face The swapped face with the requested mask added to the Alpha channel - :class:`numpy.ndarray` + raw_mask The raw mask with no erosion or blurring applied """ logger.trace("Getting mask. Image shape: %s", new_face.shape) # type: ignore[attr-defined] @@ -467,20 +464,19 @@ def _get_image_mask(self, return new_face, raw_mask def _post_warp_adjustments(self, background: np.ndarray, new_image: np.ndarray) -> np.ndarray: - """ Perform any requested adjustments to the swapped faces after they have been transformed + """Perform any requested adjustments to the swapped faces after they have been transformed into the final frame. Parameters ---------- - background: :class:`numpy.ndarray` + background The original frame - new_image: :class:`numpy.ndarray` + new_image A blank frame of original frame size with the faces warped onto it Returns ------- - :class:`numpy.ndarray` - The final merged and swapped frame with any requested post-warp adjustments applied + The final merged and swapped frame with any requested post-warp adjustments applied """ if self._adjustments.sharpening is not None: new_image = self._adjustments.sharpening.run(new_image) @@ -499,20 +495,19 @@ def _post_warp_adjustments(self, background: np.ndarray, new_image: np.ndarray) return frame def _scale_image(self, frame: np.ndarray) -> np.ndarray: - """ Scale the final image if requested. + """Scale the final image if requested. If output scale has been requested in command line arguments, scale the output otherwise return the final frame. Parameters ---------- - frame: :class:`numpy.ndarray` + frame The final frame with faces swapped Returns ------- - :class:`numpy.ndarray` - The final frame scaled by the requested scaling factor + The final frame scaled by the requested scaling factor """ if self._scale == 1: return frame diff --git a/lib/video.py b/lib/video.py index 4a91e6c175..81663d22d2 100644 --- a/lib/video.py +++ b/lib/video.py @@ -765,7 +765,8 @@ def _get_audio_packet(self, timestamp: float) -> av.Packet | None: timestamp The timestamp of the next video packet to be output """ - assert self._next_audio_packet is not None + if self._next_audio_packet is None: + return None next_ts = self._timestamp(self._next_audio_packet) if next_ts >= timestamp: logger.trace( # type:ignore[attr-defined] @@ -775,7 +776,7 @@ def _get_audio_packet(self, timestamp: float) -> av.Packet | None: assert self._audio_packets is not None retval = self._next_audio_packet - self._next_audio_packet = next(self._audio_packets) + self._next_audio_packet = next((self._audio_packets), None) logger.trace( # type:ignore[attr-defined] "[%s] Returning audio packet %s for timestamp %s < video timestamp: %s. Next queued " "packet: %s", diff --git a/tests/tools/preview/viewer_test.py b/tests/tools/preview/viewer_test.py index 9e8b250831..e44b3f42cd 100644 --- a/tests/tools/preview/viewer_test.py +++ b/tests/tools/preview/viewer_test.py @@ -29,11 +29,11 @@ def test__faces(): """ Test the :class:`~tools.preview.viewer._Faces dataclass initializes correctly """ - faces = _Faces() + faces = _Faces(5, 64) assert isinstance(faces.filenames, list) and not faces.filenames - assert isinstance(faces.matrix, list) and not faces.matrix - assert isinstance(faces.src, list) and not faces.src - assert isinstance(faces.dst, list) and not faces.dst + assert isinstance(faces.matrix, np.ndarray) + assert isinstance(faces.src, np.ndarray) + assert isinstance(faces.dst, np.ndarray) _PARAMS = ((3, 448), (4, 333), (5, 254), (6, 128)) # columns/face_size @@ -61,11 +61,8 @@ def get_faces_display_instance(self, columns: int = 5, face_size: int = 256) -> An instance of the FacesDisplay class at the given settings """ app = MagicMock() - retval = FacesDisplay(app, face_size, self._padding) - retval._faces = _Faces( - matrix=[np.random.rand(2, 3) for _ in range(columns)], - src=[np.random.rand(face_size, face_size, 3) for _ in range(columns)], - dst=[np.random.rand(face_size, face_size, 3) for _ in range(columns)]) + retval = FacesDisplay(app, face_size, self._padding, columns) + retval._faces = _Faces(columns, face_size) return retval def test_init(self) -> None: @@ -284,16 +281,18 @@ def test__crop_source_faces(self, f_display = self.get_faces_display_instance(columns, face_size) f_display._centering = "face" f_display.update_source = True - f_display._faces.src = [] - transform_image_mock = mocker.MagicMock() + transform_image_mock = mocker.MagicMock(return_value=np.zeros((face_size, face_size, 3), + dtype=np.uint8)) monkeypatch.setattr("tools.preview.viewer.transform_image", transform_image_mock) + + mats = np.random.random((columns, 2, 3)).astype(np.float32) f_display.source = [mocker.MagicMock() for _ in range(columns)] for idx, mock in enumerate(f_display.source): assert isinstance(mock, MagicMock) mock.inbound.detected_faces.__getitem__ = lambda self, x, y=mock: y - mock.aligned.matrix = f"test_matrix_{idx}" + mock.aligned.matrix = mats[idx] mock.inbound.filename = f"test_filename_{idx}.txt" f_display._crop_source_faces() @@ -306,12 +305,13 @@ def test__crop_source_faces(self, for idx in range(columns): assert f_display._faces.filenames[idx] == f"test_filename_{idx}" - assert f_display._faces.matrix[idx] == f"test_matrix_{idx}" + assert np.all(f_display._faces.matrix[idx] == mats[idx]) @pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS) def test__crop_destination_faces(self, columns: int, face_size: int, + monkeypatch: pytest.MonkeyPatch, mocker: pytest_mock.MockerFixture) -> None: """ Test :class:`~tools.preview.viewer.FacesDisplay` _crop_destination_faces method @@ -326,13 +326,18 @@ def test__crop_destination_faces(self, """ f_display = self.get_faces_display_instance(columns, face_size) f_display._centering = "face" - f_display._faces.dst = [] # empty object and test populated correctly + + transform_image_mock = mocker.MagicMock(return_value=np.zeros((face_size, face_size, 3), + dtype=np.uint8)) + monkeypatch.setattr("tools.preview.viewer.transform_image", transform_image_mock) + f_display.source = [mocker.MagicMock() for _ in range(columns)] for item in f_display.source: # type ignore item.inbound.image = np.random.rand(1280, 720, 3) # type:ignore f_display._crop_destination_faces() + assert transform_image_mock.call_count == columns assert len(f_display._faces.dst) == columns assert all(f.shape == (face_size, face_size, 3) for f in f_display._faces.dst) diff --git a/tools/preview/preview.py b/tools/preview/preview.py index 1fddbe4d16..0287877b77 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -64,8 +64,9 @@ def __init__(self, arguments: Namespace) -> None: self._config_tools = ConfigTools(arguments.config_file) self._lock = Lock() self._dispatcher = Dispatcher(self) - self._display = FacesDisplay(self, 256, 64) - self._samples = Samples(self, arguments, 5) + num_faces = 5 + self._display = FacesDisplay(self, 256, 64, num_faces) + self._samples = Samples(self, arguments, num_faces) self._patch = Patch(self, arguments) self._initialize_tkinter() diff --git a/tools/preview/viewer.py b/tools/preview/viewer.py index bf2871ea1d..e2b6be23a6 100644 --- a/tools/preview/viewer.py +++ b/tools/preview/viewer.py @@ -7,7 +7,7 @@ import typing as T from tkinter import ttk -from dataclasses import dataclass, field +from dataclasses import dataclass, field, InitVar import cv2 import numpy as np @@ -15,11 +15,13 @@ from lib.align import transform_image from lib.align.aligned_face import CenteringType +from lib.logger import parse_class_init from lib.utils import get_module_objects from scripts.convert import ConvertItem if T.TYPE_CHECKING: + import numpy.typing as npt from .preview import Preview logger = logging.getLogger(__name__) @@ -27,11 +29,28 @@ @dataclass class _Faces: - """Dataclass for holding faces""" + """Dataclass for holding faces + + Parameters + ---------- + size + The size of each individual face sample in pixels + num_faces + The number of faces to be displayed in the preview window + """ + num_faces: InitVar[int] + size: InitVar[int] + filenames: list[str] = field(default_factory=list) - matrix: list[np.ndarray] = field(default_factory=list) - src: list[np.ndarray] = field(default_factory=list) - dst: list[np.ndarray] = field(default_factory=list) + matrix: npt.NDArray[np.float32] = field(init=False) + src: npt.NDArray[np.uint8] = field(init=False) + dst: npt.NDArray[np.uint8] = field(init=False) + + def __post_init__(self, num_faces: int, size: int) -> None: + """Initialize the matrices based on input sizes""" + self.matrix = np.empty((num_faces, 2, 3), dtype=np.float32) + self.src = np.empty((num_faces, size, size, 3), dtype=np.uint8) + self.dst = np.empty((num_faces, size, size, 3), dtype=np.uint8) class FacesDisplay(): # pylint:disable=too-many-instance-attributes @@ -45,16 +64,18 @@ class FacesDisplay(): # pylint:disable=too-many-instance-attributes The size of each individual face sample in pixels padding The amount of extra padding to apply to the outside of the face + num_faces + The number of faces to be displayed in the preview window """ - def __init__(self, app: Preview, size: int, padding: int) -> None: - logger.trace("Initializing %s: (app: %s, size: %s, padding: %s)", # type: ignore - self.__class__.__name__, app, size, padding) + def __init__(self, app: Preview, size: int, padding: int, num_faces: int) -> None: + logger.debug(parse_class_init(locals())) self._size = size self._display_dims = (1, 1) self._app = app self._padding = padding + self._num_faces = num_faces - self._faces = _Faces() + self._faces = _Faces(num_faces=num_faces, size=size) self._centering: CenteringType | None = None self._y_offset = 0.0 self._faces_source: np.ndarray = np.array([]) @@ -169,21 +190,20 @@ def _crop_source_faces(self) -> None: """Extract the source faces from the source frames, along with their filenames and the transformation matrix used to extract the faces.""" logger.debug("Updating source faces") - self._faces = _Faces() # Init new class - for item in self.source: + self._faces = _Faces(num_faces=self._num_faces, size=self._size) # Init new class + for i, item in enumerate(self.source): detected_face = item.inbound.detected_faces[0] src_img = item.inbound.image detected_face.load_aligned(src_img, size=self._size, - centering=T.cast(CenteringType, self._centering), - y_offset=self._y_offset) + centering=T.cast(CenteringType, self._centering)) matrix = detected_face.aligned.matrix - self._faces.filenames.append(os.path.splitext(item.inbound.filename)[0]) - self._faces.matrix.append(matrix) if self._y_offset: - matrix = detected_face.aligned.matrix.copy() + matrix = matrix.copy() matrix[1, 2] += self._y_offset - self._faces.src.append(transform_image(src_img, matrix, self._size, self._padding)) + self._faces.filenames.append(os.path.splitext(item.inbound.filename)[0]) + self._faces.matrix[i] = matrix + self._faces.src[i] = transform_image(src_img, matrix, self._size, self._padding) self.update_source = False logger.debug("Updated source faces") @@ -191,14 +211,13 @@ def _crop_destination_faces(self) -> None: """Extract the swapped faces from the swapped frames using the source face destination matrices.""" logger.debug("Updating destination faces") - self._faces.dst = [] destination = self.destination if self.destination else [np.ones_like(src.inbound.image) for src in self.source] - for idx, image in enumerate(destination): - self._faces.dst.append(transform_image(image, - self._faces.matrix[idx], - self._size, - self._padding)) + for i, image in enumerate(destination): + self._faces.dst[i] = transform_image(image, + self._faces.matrix[i], + self._size, + self._padding) logger.debug("Updated destination faces") def _header_text(self) -> np.ndarray: From 4a5ac8f9a040fe5fe3e5e76b8fc564f816c1c4d4 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:47:52 +0100 Subject: [PATCH 949/981] Bugfix: Extract/Convert - Don't error when an input frame is black --- lib/infer/objects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/infer/objects.py b/lib/infer/objects.py index cd766ac441..4686e04753 100644 --- a/lib/infer/objects.py +++ b/lib/infer/objects.py @@ -870,7 +870,7 @@ def detected_faces(self, faces: list[DetectedFace]) -> None: If the FrameFaces object does not contain a filename and image or if any of the data fields are populated """ - if not self.filename or not np.any(self.image): + if not self.filename or not self.image.size: raise ValueError("Filename and image must be populated before adding DetectedFace " "objects") if np.any(self.bboxes) or self.landmarks is not None or self.masks or self.identities: From cb2eace57293c5481c5235bafc4c714fec15947f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:03:05 +0100 Subject: [PATCH 950/981] Remove legacy face set support --- lib/align/__init__.py | 2 +- lib/align/alignments.py | 27 +--------- lib/align/detected_face.py | 77 +--------------------------- lib/align/updater.py | 58 --------------------- scripts/convert.py | 21 ++------ tests/tools/alignments/media_test.py | 57 +------------------- tools/alignments/media.py | 50 +++--------------- tools/mask/loader.py | 24 +++------ 8 files changed, 23 insertions(+), 293 deletions(-) diff --git a/lib/align/__init__.py b/lib/align/__init__.py index 5cee86d2da..ccc6ee3617 100644 --- a/lib/align/__init__.py +++ b/lib/align/__init__.py @@ -7,4 +7,4 @@ from .aligned_mask import BlurMask, LandmarksMask, Mask from .alignments import Alignments from .constants import CenteringType, EXTRACT_RATIOS, LANDMARK_PARTS, LandmarkType -from .detected_face import DetectedFace, update_legacy_png_header +from .detected_face import DetectedFace diff --git a/lib/align/alignments.py b/lib/align/alignments.py index c517815bd9..626186dbc4 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -15,7 +15,7 @@ from lib.utils import FaceswapError, get_module_objects from .thumbnails import Thumbnails -from .updater import (FileStructure, IdentityAndVideoMeta, LandmarkRename, Legacy, NumpyToList, +from .updater import (FileStructure, IdentityAndVideoMeta, LandmarkRename, NumpyToList, MaskCentering, VideoExtension) if T.TYPE_CHECKING: @@ -195,7 +195,6 @@ def __init__(self, folder: str, filename: str = "alignments") -> None: self._data = self._load() self._io.update_legacy() - self._legacy = Legacy(self) self._thumbnails = Thumbnails(self) logger.debug("Initialized %s", self.__class__.__name__) @@ -230,30 +229,6 @@ def have_alignments_file(self) -> bool: """``True`` if an alignments file exists at location :attr:`file` otherwise ``False``.""" return self._io.have_alignments_file - @property - def hashes_to_frame(self) -> dict[str, dict[str, int]]: - """The SHA1 hash of the face mapped to the frame(s) and face index within the frame that - the hash corresponds to. - - Notes - ----- - This method is deprecated and exists purely for updating legacy hash based alignments - to new png header storage in :class:`lib.align.update_legacy_png_header`. - """ - return self._legacy.hashes_to_frame - - @property - def hashes_to_alignment(self) -> dict[str, AlignmentFileDict]: - """The SHA1 hash of the face mapped to the alignment for the face that the hash - corresponds to. - - Notes - ----- - This method is deprecated and exists purely for updating legacy hash based alignments - to new png header storage in :class:`lib.align.update_legacy_png_header`. - """ - return self._legacy.hashes_to_alignment - @property def mask_summary(self) -> dict[str, int]: """The mask type names stored in the alignments :attr:`data` as key with the number of diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index d4065a5e4b..3aeb3676ca 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -2,19 +2,15 @@ """Face and landmarks detection for faceswap.py""" from __future__ import annotations import logging -import os import typing as T -from hashlib import sha1 from zlib import compress, decompress import numpy as np -from lib.image import encode_image, read_image from lib.logger import format_array, parse_class_init -from lib.utils import FaceswapError, get_module_objects -from .alignments import (Alignments, AlignmentFileDict, PNGHeaderAlignmentsDict, - PNGHeaderDict, PNGHeaderSourceDict) +from lib.utils import get_module_objects +from .alignments import AlignmentFileDict, PNGHeaderAlignmentsDict from .aligned_face import AlignedFace from . import aligned_mask @@ -492,73 +488,4 @@ def load_aligned(self, is_legacy=is_aligned and is_legacy) -_HASHES_SEEN: dict[str, dict[str, int]] = {} - - -def update_legacy_png_header(filename: str, alignments: Alignments - ) -> PNGHeaderDict | None: - """Update a legacy extracted face from pre v2.1 alignments by placing the alignment data for - the face in the png exif header for the given filename with the given alignment data. - - If the given file is not a .png then a png is created and the original file is removed - - Parameters - ---------- - filename - The image file to update - alignments - The alignments data the contains the information to store in the image header. This must be - a v2.0 or less alignments file as later versions no longer store the face hash (not - required) - - Returns - ------- - The metadata that has been applied to the given image - """ - if alignments.version > 2.0: - raise FaceswapError("The faces being passed in do not correspond to the given Alignments " - "file. Please double check your sources and try again.") - # Track hashes for multiple files with the same hash. Not the most robust but should be - # effective enough - folder = os.path.dirname(filename) - if folder not in _HASHES_SEEN: - _HASHES_SEEN[folder] = {} - hashes_seen = _HASHES_SEEN[folder] - - in_image = read_image(filename, raise_error=True) - in_hash = sha1(T.cast(bytes, in_image)).hexdigest() - hashes_seen[in_hash] = hashes_seen.get(in_hash, -1) + 1 - - alignment = alignments.hashes_to_alignment.get(in_hash) - if not alignment: - logger.debug("Alignments not found for image: '%s'", filename) - return None - - detected_face = DetectedFace() - detected_face.from_alignment(alignment) - # For dupe hash handling, make sure we get a different filename for repeat hashes - src_fname, face_idx = list(alignments.hashes_to_frame[in_hash].items())[hashes_seen[in_hash]] - orig_filename = f"{os.path.splitext(src_fname)[0]}_{face_idx}.png" - meta = PNGHeaderDict(alignments=detected_face.to_png_meta(), - source=PNGHeaderSourceDict( - alignments_version=alignments.version, - original_filename=orig_filename, - face_index=face_idx, - source_filename=src_fname, - source_is_video=False, # Can't check so set false - source_frame_dims=None)) - - out_filename = f"{os.path.splitext(filename)[0]}.png" # Make sure saved file is png - out_image = encode_image(in_image, ".png", metadata=meta) - - with open(out_filename, "wb") as out_file: - out_file.write(out_image) - - if filename != out_filename: # Remove the old non-png: - logger.debug("Removing replaced face with deprecated extension: '%s'", filename) - os.remove(filename) - - return meta - - __all__ = get_module_objects(__name__) diff --git a/lib/align/updater.py b/lib/align/updater.py index bc97499db2..8899930c92 100644 --- a/lib/align/updater.py +++ b/lib/align/updater.py @@ -314,62 +314,4 @@ def update(self) -> int: return update_count -class Legacy(): # TODO remove this as it is now ancient and likely to lead to issues - """Legacy alignments properties that are no longer used, but are still required for backwards - compatibility/upgrading reasons. - - Parameters - ---------- - alignments - The alignments object that requires these legacy properties - """ - def __init__(self, alignments: align.alignments.Alignments) -> None: - self._alignments = alignments - self._hashes_to_frame: dict[str, dict[str, int]] = {} - self._hashes_to_alignment: dict[str, align.alignments.AlignmentFileDict] = {} - - @property - def hashes_to_frame(self) -> dict[str, dict[str, int]]: - """The SHA1 hash of the face mapped to the frame(s) and face index within the frame - that the hash corresponds to. The structure of the dictionary is: - - {**SHA1_hash** (`str`): {**filename** (`str`): **face_index** (`int`)}}. - - Notes - ----- - This method is deprecated and exists purely for updating legacy hash based alignments - to new png header storage in :class:`lib.align.update_legacy_png_header`. - - The first time this property is referenced, the dictionary will be created and cached. - Subsequent references will be made to this cached dictionary. - """ - if not self._hashes_to_frame: - logger.debug("Generating hashes to frame") - for frame_name, val in self._alignments.data.items(): - for idx, face in enumerate(val["faces"]): - self._hashes_to_frame.setdefault( - face["hash"], {})[frame_name] = idx # type:ignore - return self._hashes_to_frame - - @property - def hashes_to_alignment(self) -> dict[str, align.alignments.AlignmentFileDict]: - """The SHA1 hash of the face mapped to the alignment for the face that the hash - corresponds to. The structure of the dictionary is: - - Notes - ----- - This method is deprecated and exists purely for updating legacy hash based alignments - to new png header storage in :class:`lib.align.update_legacy_png_header`. - - The first time this property is referenced, the dictionary will be created and cached. - Subsequent references will be made to this cached dictionary. - """ - if not self._hashes_to_alignment: - logger.debug("Generating hashes to alignment") - self._hashes_to_alignment = {face["hash"]: face # type:ignore - for val in self._alignments.data.values() - for face in val["faces"]} - return self._hashes_to_alignment - - __all__ = get_module_objects(__name__) diff --git a/scripts/convert.py b/scripts/convert.py index 6e5b7b4e45..95068833bc 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -18,7 +18,7 @@ from scripts.fs_media import finalize from lib.serializer import get_serializer from lib.convert import Converter -from lib.align import AlignedFace, DetectedFace, update_legacy_png_header +from lib.align import AlignedFace, DetectedFace from lib.infer.objects import FrameFaces from lib.gpu_stats import GPUStats from lib.image import read_image_meta_batch, ImagesLoader @@ -1154,27 +1154,16 @@ def _get_face_metadata(self) -> dict[str, list[int]]: "alignments file will be converted") return retval - log_once = False filelist = get_image_paths(input_aligned_dir) for fullpath, metadata in tqdm(read_image_meta_batch(filelist), total=len(filelist), desc="Reading Face Data", leave=False): if "itxt" not in metadata or "source" not in metadata["itxt"]: - # UPDATE LEGACY FACES FROM ALIGNMENTS FILE - if not log_once: - logger.warning("Legacy faces discovered in '%s'. These faces will be updated", - input_aligned_dir) - log_once = True - data = update_legacy_png_header(fullpath, self._alignments) - if not data: - raise FaceswapError( - f"Some of the faces being passed in from '{input_aligned_dir}' could not " - f"be matched to the alignments file '{self._alignments.file}'\n" - "Please double check your sources and try again.") - meta = data["source"] - else: - meta = metadata["itxt"]["source"] + logger.warning("Non-Faceswap extracted face found. Image skipped: '%s'", + fullpath) + continue + meta = metadata["itxt"]["source"] retval.setdefault(meta["source_filename"], []).append(meta["face_index"]) if not retval: diff --git a/tests/tools/alignments/media_test.py b/tests/tools/alignments/media_test.py index 2639759ebb..4eb7cec6ba 100644 --- a/tests/tools/alignments/media_test.py +++ b/tests/tools/alignments/media_test.py @@ -17,7 +17,6 @@ log_setup("DEBUG", f"{__name__}.log", "PyTest, False") # pylint:disable=wrong-import-position,protected-access -from lib.utils import FaceswapError # noqa:E402 from tools.alignments.media import (AlignmentData, Faces, ExtractedFaces, # noqa:E402 Frames, MediaLoader) @@ -387,54 +386,6 @@ def test_init(self, Faces(folder, alignments_mock) parent_mock.assert_called_once() - def test__handle_legacy(self, - faces_instance: Faces, - mocker: pytest_mock.MockerFixture, - caplog: pytest.LogCaptureFixture) -> None: - """ Test for :class:`~tools.alignments.media.Faces` _handle_legacy method - - Parameters - ---------- - faces_instance: :class:`~tools.alignments.media.Faces` - Test class instance - mocker: :class:`pytest_mock.MockerFixture` - Fixture for mocking various objects - caplog: :class:`pytest.LogCaptureFixture - For capturing logging messages - """ - faces = faces_instance - folder = faces.folder - legacy_file = os.path.join(folder, "a.png") - - # No alignments file - with pytest.raises(FaceswapError): - faces._handle_legacy(legacy_file) - - # No returned metadata - alignments_mock = mocker.patch("tools.alignments.media.AlignmentData") - alignments_mock.version = 2.1 - update_mock = mocker.patch("tools.alignments.media.update_legacy_png_header", - return_value={}) - faces = Faces(folder, alignments_mock) - faces.folder = folder - with pytest.raises(FaceswapError): - faces._handle_legacy(legacy_file) - update_mock.assert_called_once_with(legacy_file, alignments_mock) - - # Correct data with logging - caplog.clear() - update_mock.reset_mock() - update_mock.return_value = {"test": "data"} - faces._handle_legacy(legacy_file, log=True) - assert "Legacy faces discovered" in caplog.text - - # Correct data without logging - caplog.clear() - update_mock.reset_mock() - update_mock.return_value = {"test": "data"} - faces._handle_legacy(legacy_file, log=False) - assert "Legacy faces discovered" not in caplog.text - def test__handle_duplicate(self, faces_instance: Faces) -> None: """ Test for :class:`~tools.alignments.media.Faces` _handle_duplicate method @@ -489,8 +440,6 @@ def test_process_folder(self, expected = [(fname, meta_data["itxt"]) for fname in os.listdir(faces.folder)] read_image_meta_mock.side_effect = [[(src, meta_data) for src in img_sources]] - legacy_mock = mocker.patch("tools.alignments.media.Faces._handle_legacy", - return_value=meta_data["itxt"]) dupe_mock = mocker.patch("tools.alignments.media.Faces._handle_duplicate", return_value=False) @@ -498,7 +447,6 @@ def test_process_folder(self, output = list(faces.process_folder()) assert read_image_meta_mock.call_count == 1 assert dupe_mock.call_count == 2 - assert not legacy_mock.called assert output == expected dupe_mock.reset_mock() @@ -521,9 +469,8 @@ def test_process_folder(self, read_image_meta_mock.side_effect = [[(src, {}) for src in img_sources]] output = list(faces.process_folder()) assert read_image_meta_mock.call_count == 1 - assert legacy_mock.call_count == 2 - assert dupe_mock.call_count == 2 - assert output == expected + assert dupe_mock.call_count == 0 + assert not output def test_load_items(self, faces_instance: Faces) -> None: diff --git a/tools/alignments/media.py b/tools/alignments/media.py index 519db4c6d3..6255df8822 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -10,10 +10,10 @@ import cv2 from tqdm import tqdm -from lib.align import Alignments, DetectedFace, update_legacy_png_header +from lib.align import Alignments, DetectedFace from lib.image import (generate_thumbnail, ImagesLoader, png_write_meta, read_image, read_image_meta_batch) -from lib.utils import get_module_objects, IMAGE_EXTENSIONS, FaceswapError +from lib.utils import get_module_objects, IMAGE_EXTENSIONS from lib.video import count_frames, VIDEO_EXTENSIONS if T.TYPE_CHECKING: @@ -271,43 +271,6 @@ def __init__(self, folder: str, alignments: Alignments | None = None) -> None: self._alignments = alignments super().__init__(folder) - def _handle_legacy(self, fullpath: str, log: bool = False) -> PNGHeaderDict: - """Handle face sets that are legacy (i.e. do not contain alignment information in the - header data) - - Parameters - ---------- - fullpath - The full path to the extracted face image - log - Whether to log a message that legacy updating is occurring - - Returns - ------- - The Alignments information from the face in PNG Header dict format - - Raises - ------ - FaceswapError - If legacy faces can't be updated because the alignments file does not exist or some of - the faces do not appear in the provided alignments file - """ - if self._alignments is None: # Can't update legacy - raise FaceswapError(f"The folder '{self.folder}' contains images that do not include " - "Faceswap metadata.\nAll images in the provided folder should " - "contain faces generated from Faceswap's extraction process.\n" - "Please double check the source and try again.") - if log: - logger.warning("Legacy faces discovered. These faces will be updated") - - data = update_legacy_png_header(fullpath, self._alignments) - if not data: - raise FaceswapError( - f"Some of the faces being passed in from '{self.folder}' could not be " - f"matched to the alignments file '{self._alignments.file}'\nPlease double " - "check your sources and try again.") - return data - def _handle_duplicate(self, fullpath: str, header_dict: PNGHeaderDict, @@ -366,16 +329,15 @@ def process_folder(self) -> Generator[tuple[str, PNGHeaderDict], None, None]: for face in os.listdir(self.folder) if os.path.splitext(face)[-1] == ".png"] - log_once = False for fullpath, metadata in tqdm(read_image_meta_batch(filelist), total=len(filelist), desc="Reading Face Data"): if "itxt" not in metadata or "source" not in metadata["itxt"]: - sub_dict = self._handle_legacy(fullpath, not log_once) - log_once = True - else: - sub_dict = T.cast("PNGHeaderDict", metadata["itxt"]) + logger.warning("Non-Faceswap extracted face found. Image skipped: '%s'", + fullpath) + continue + sub_dict = T.cast("PNGHeaderDict", metadata["itxt"]) if self._handle_duplicate(fullpath, sub_dict, seen): dupe_count += 1 diff --git a/tools/mask/loader.py b/tools/mask/loader.py index 50c44837eb..abfa14cac5 100644 --- a/tools/mask/loader.py +++ b/tools/mask/loader.py @@ -10,7 +10,7 @@ import numpy as np from tqdm import tqdm -from lib.align import alignments, DetectedFace, update_legacy_png_header +from lib.align import alignments, DetectedFace from lib.image import FacesLoader, ImagesLoader from lib.utils import get_module_objects from lib.infer.objects import FrameFaces @@ -119,24 +119,12 @@ def _from_faces(self) -> T.Generator[FrameFaces, None, None]: ------ 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 not metadata: + self._skip_count += 1 + logger.warning("Non-Faceswap extracted face found. Image skipped: '%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 " From 6ca67ce25b12233dae5fd06fa31eaceea044ab05 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:49:00 +0100 Subject: [PATCH 951/981] Bugfix: Manual tool. Correctly detect frames folder on load --- tools/manual/manual.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/manual/manual.py b/tools/manual/manual.py index 0ce53825f3..986fbc2040 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -164,10 +164,12 @@ def _wait_for_threads(self, extractor: Aligner, loader: FrameLoader, valid_meta: sleep(1) extractor.link_faces(self._detected_faces) - if not valid_meta: + if not valid_meta and loader.video_meta_data: logger.debug("Saving video meta data to alignments file") self._detected_faces.save_video_meta_data( - **loader.video_meta_data) # type:ignore[arg-type] + pts_time=loader.video_meta_data["pts_time"], + keyframes=loader.video_meta_data["keyframes"] + ) def _generate_thumbs(self, input_location: str, force: bool, single_process: bool) -> None: """Check whether thumbnails are stored in the alignments file and if not generate them. From 32f427c00c19b855b4d5f55ef27b54107564404b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 5 Apr 2026 12:10:05 +0100 Subject: [PATCH 952/981] Alignments: Migrate typed-dicts to dataclasses --- docs/full/lib/align.rst | 5 + lib/align/aligned_mask.py | 39 ++-- lib/align/alignments.py | 265 +++++++------------------ lib/align/detected_face.py | 83 ++++---- lib/align/objects.py | 283 +++++++++++++++++++++++++++ lib/align/thumbnails.py | 8 +- lib/align/updater.py | 55 +++--- lib/image.py | 112 ++++++----- lib/infer/identity.py | 18 +- lib/infer/objects.py | 15 +- lib/infer/runner.py | 8 +- lib/training/cache.py | 48 ++--- scripts/extract.py | 20 +- scripts/fs_media.py | 39 ++-- tests/lib/training/cache_test.py | 71 ++++--- tests/tools/alignments/media_test.py | 53 +++-- tests/tools/preview/viewer_test.py | 4 +- tools/alignments/jobs.py | 231 ++++++++++------------ tools/alignments/jobs_faces.py | 231 +++++++++++----------- tools/alignments/jobs_frames.py | 110 +++++------ tools/alignments/media.py | 31 +-- tools/manual/detected_faces.py | 41 ++-- tools/manual/thumbnails.py | 3 +- tools/mask/loader.py | 19 +- tools/mask/mask.py | 10 +- tools/mask/mask_generate.py | 14 +- tools/mask/mask_import.py | 8 +- tools/mask/mask_output.py | 7 +- tools/sort/info_loader.py | 48 +++-- tools/sort/sort_methods.py | 45 ++--- tools/sort/sort_methods_aligned.py | 30 +-- 31 files changed, 1052 insertions(+), 902 deletions(-) create mode 100644 lib/align/objects.py diff --git a/docs/full/lib/align.rst b/docs/full/lib/align.rst index 63f9a3f65f..1a87f86862 100644 --- a/docs/full/lib/align.rst +++ b/docs/full/lib/align.rst @@ -34,6 +34,11 @@ The align Package handles detected faces, their alignments and masks. :include-all-objects: :no-inheritance-diagram: +| +.. automodapi:: lib.align.objects + :include-all-objects: + :no-inheritance-diagram: + | .. automodapi:: lib.align.pose :include-all-objects: diff --git a/lib/align/aligned_mask.py b/lib/align/aligned_mask.py index 06b7937a3f..f97548a15f 100644 --- a/lib/align/aligned_mask.py +++ b/lib/align/aligned_mask.py @@ -14,7 +14,7 @@ from lib.utils import FaceswapError, get_module_objects from .aligned_utils import get_adjusted_center, get_centered_size -from .alignments import MaskAlignmentsFileDict +from .objects import MaskAlignmentsFile from .constants import LandmarkType, LANDMARK_PARTS, LANDMARK_MASK_PARTS if T.TYPE_CHECKING: @@ -367,7 +367,7 @@ def _adjust_affine_matrix(self, mask_size: int, affine_matrix: np.ndarray) -> np affine_matrix.shape, adjust_mat.shape) return adjust_mat - def to_dict(self, is_png=False) -> MaskAlignmentsFileDict: + def to_dict(self, is_png=False) -> MaskAlignmentsFile: """Convert the mask to a dictionary for saving to an alignments file Parameters @@ -383,16 +383,15 @@ def to_dict(self, is_png=False) -> MaskAlignmentsFileDict: """ assert self._mask is not None affine_matrix = self.affine_matrix.tolist() if is_png else self.affine_matrix - retval = MaskAlignmentsFileDict(mask=self._mask, - affine_matrix=affine_matrix, - interpolator=self.interpolator, - stored_size=self.stored_size, - stored_centering=self.stored_centering) - logger.trace({k: v if k != "mask" else type(v) # type:ignore[attr-defined] - for k, v in retval.items()}) + retval = MaskAlignmentsFile(mask=self._mask, + affine_matrix=affine_matrix, + interpolator=self.interpolator, + stored_size=self.stored_size, + stored_centering=self.stored_centering) + logger.trace(retval) # type:ignore[attr-defined] return retval - def to_png_meta(self) -> MaskAlignmentsFileDict: + def to_png_meta(self) -> MaskAlignmentsFile: """Convert the mask to a dictionary supported by png itxt headers. Returns @@ -402,26 +401,20 @@ def to_png_meta(self) -> MaskAlignmentsFileDict: """ return self.to_dict(is_png=True) - def from_dict(self, mask_dict: MaskAlignmentsFileDict) -> None: + def from_dict(self, mask: MaskAlignmentsFile) -> None: """Populates the :class:`Mask` from a dictionary loaded from an alignments file. Parameters ---------- - mask_dict A dictionary stored in an alignments file containing the keys ``mask``, ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` """ - self._mask = mask_dict["mask"] - affine_matrix = mask_dict["affine_matrix"] - self._affine_matrix = self._matrix_2to3( - affine_matrix if isinstance(affine_matrix, np.ndarray) - else np.array(affine_matrix, dtype=np.float32)) - self._interpolator = mask_dict["interpolator"] - self.stored_size = mask_dict["stored_size"] - centering = mask_dict.get("stored_centering") - self.stored_centering = "face" if centering is None else centering - logger.trace({k: v if k != "mask" else type(v) # type:ignore[attr-defined] - for k, v in mask_dict.items()}) + self._mask = mask.mask + self._affine_matrix = self._matrix_2to3(mask.affine_matrix) + self._interpolator = mask.interpolator + self.stored_size = mask.stored_size + self.stored_centering = mask.stored_centering + logger.trace(mask) # type:ignore[attr-defined] class LandmarksMask(Mask): diff --git a/lib/align/alignments.py b/lib/align/alignments.py index 626186dbc4..785c5ac53d 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -6,21 +6,20 @@ import os import sys import typing as T -from dataclasses import dataclass, field from datetime import datetime -import numpy as np from lib.serializer import get_serializer from lib.utils import FaceswapError, get_module_objects +from .objects import AlignmentsEntry, FileAlignments + from .thumbnails import Thumbnails from .updater import (FileStructure, IdentityAndVideoMeta, LandmarkRename, NumpyToList, MaskCentering, VideoExtension) if T.TYPE_CHECKING: from collections.abc import Generator - from .aligned_face import CenteringType logger = logging.getLogger(__name__) _VERSION = 2.4 @@ -36,139 +35,6 @@ # 2.4 - Update video file alignment keys to end in the video extension rather than '.png' -# TODO Convert these to Dataclasses -class MaskAlignmentsFileDict(T.TypedDict): - """Typed Dictionary for storing Masks.""" - mask: bytes - affine_matrix: list[float] | np.ndarray - interpolator: int - stored_size: int - stored_centering: CenteringType - - -class PNGHeaderAlignmentsDict(T.TypedDict): - """Base Dictionary for storing a single faces' Alignment Information in Alignments files and - PNG Headers.""" - x: int - y: int - w: int - h: int - landmarks_xy: list[list[float]] | np.ndarray - mask: dict[str, MaskAlignmentsFileDict] - identity: dict[str, list[float]] - - -class AlignmentFileDict(PNGHeaderAlignmentsDict): - """Typed Dictionary for storing a single faces' Alignment Information in alignments files.""" - thumb: np.ndarray | None - - -class PNGHeaderSourceDict(T.TypedDict): - """Dictionary for storing additional meta information in PNG headers.""" - alignments_version: float - original_filename: str - face_index: int - source_filename: str - source_is_video: bool - source_frame_dims: tuple[int, int] | None - - -class AlignmentDict(T.TypedDict): - """Dictionary for holding all of the alignment information within a single alignment file.""" - faces: list[AlignmentFileDict] - video_meta: dict[str, int] - - -class PNGHeaderDict(T.TypedDict): - """Dictionary for storing all alignment and meta information in PNG Headers.""" - alignments: PNGHeaderAlignmentsDict - source: PNGHeaderSourceDict - - -# Dataclass to slowly replace the above -@dataclass -class MaskAlignmentsFile: - """Dataclass for storing Masks in alignments files and PNG Headers""" - mask: bytes - """The zlib compressed UINT8 mask of shape (stored_size, stored_size)""" - affine_matrix: list[float] - """The affine matrix that takes the mask from stored space to frame space""" - interpolator: int - """The interpolator required to take the mask from stored space to frame space""" - stored_size: int - """The size the mask is stored at""" - stored_centering: CenteringType - """The (legacy, face, head) centering type of the mask""" - - -@dataclass -class PNGAlignments: - """Base Dataclass for storing a single faces' Alignment Information in Alignments files and PNG - Headers.""" - x: int - """The left most point of the bounding box""" - y: int - """The top most point of the bounding box""" - w: int - """The width of the bounding box""" - h: int - """The height of the bounding box""" - landmarks_xy: list[list[float]] - """The (x, y) landmark points of the face""" - mask: dict[str, MaskAlignmentsFile] - """The masks stored for the face""" - identity: dict[str, list[float]] - """The identity vectors stored for the face""" - - def __repr__(self) -> str: - """Pretty print for logging""" - params: dict[str, T.Any] = {} - for k, v in self.__dict__.items(): - if k in ("landmarks_xy", "thumb"): - params[k] = f"{type(v)}[{len(v)}]" - continue - if k == "identity": - params[k] = {n: f"{type(i)}[{len(i)}]" for n, i in v.items()} - continue - params[k] = v - s_params = ", ".join(f"{k}={v}" for k, v in params.items()) - return f"{self.__class__.__name__}({s_params})" - - -@dataclass -class PNGSource: - """Dataclass for storing additional meta information in PNG headers.""" - alignments_version: float - """The alignments file version that created the alignments data""" - original_filename: str - """The original filename that this face was saved with""" - face_index: int - """The index of this face within the frame""" - source_filename: str - """The filename of the original frame the face was extracted from""" - source_is_video: bool - """``True`` if the face was extracted from a video. ``False`` if from an image""" - source_frame_dims: tuple[int, int] | None - """The (Height, Width) dimensions of the original frame the face was extracted from""" - - -@dataclass -class PNGHeader: - """Dataclass for storing all alignment and meta information in PNG Headers.""" - alignments: PNGAlignments - """The alignment information for the face""" - source: PNGSource - """The frame source information for the face""" - - -@dataclass -class AlignmentsFace(PNGAlignments): - """Dataclass that holds the same information as PNGAlignments as well as a thumbnail for a - single face""" - thumb: list[int] = field(default_factory=list) - """96px JPEG thumbnail of the aligned face image stored as a list""" - - class Alignments(): # pylint:disable=too-many-public-methods """The alignments file is a custom serialized ``.fsa`` file that holds information for each frame for a video or series of images. @@ -193,8 +59,6 @@ def __init__(self, folder: str, filename: str = "alignments") -> None: self.__class__.__name__, folder, filename) self._io = _IO(self, folder, filename) self._data = self._load() - self._io.update_legacy() - self._thumbnails = Thumbnails(self) logger.debug("Initialized %s", self.__class__.__name__) @@ -210,7 +74,7 @@ def frames_count(self) -> int: @property def faces_count(self) -> int: """The total number of faces that appear in the alignments :attr:`data`""" - retval = sum(len(val["faces"]) for val in self._data.values()) + retval = sum(len(val.faces) for val in self._data.values()) logger.trace(retval) # type:ignore[attr-defined] return retval @@ -220,7 +84,7 @@ def file(self) -> str: return self._io.file @property - def data(self) -> dict[str, AlignmentDict]: + def data(self) -> dict[str, AlignmentsEntry]: """The loaded alignments :attr:`file` in dictionary form.""" return self._data @@ -235,10 +99,10 @@ def mask_summary(self) -> dict[str, int]: faces which possess the mask type as value.""" masks: dict[str, int] = {} for val in self._data.values(): - for face in val["faces"]: - if face.get("mask", None) is None: + for face in val.faces: + if not face.mask: masks["none"] = masks.get("none", 0) + 1 - for key in face.get("mask", {}): + for key in face.mask: masks[key] = masks.get(key, 0) + 1 return masks @@ -250,9 +114,9 @@ def video_meta_data(self) -> dict[T.Literal["pts_time", "keyframes"], list[int]] pts_time: list[int] = [] keyframes: list[int] = [] for idx, key in enumerate(sorted(self.data)): - if not self.data[key].get("video_meta", {}): + if not self.data[key].video_meta: return None - meta = self.data[key]["video_meta"] + meta = self.data[key].video_meta if not isinstance(meta["pts_time"], int): # pts_time is now stored as ints so let it regenerate return None @@ -272,7 +136,7 @@ def version(self) -> float: """float: The alignments file version number. """ return self._io.version - def _load(self) -> dict[str, AlignmentDict]: + def _load(self) -> dict[str, AlignmentsEntry]: """Load the alignments data from the serialized alignments :attr:`file`. Populates :attr:`_version` with the alignment file's loaded version as well as returning @@ -321,12 +185,13 @@ def save_video_meta_data(self, pts_time: list[int], keyframes: list[int]) -> Non logger.info("Saving video meta information to Alignments file") for idx, pts in enumerate(pts_time): - meta = {"pts_time": pts, "keyframe": idx in keyframes} + meta: dict[T.Literal["pts_time", "keyframe"], int] = {"pts_time": pts, + "keyframe": idx in keyframes} key = f"{basename}_{idx + 1:06d}{ext}" if key not in self.data: - self.data[key] = {"video_meta": meta, "faces": []} + self.data[key] = AlignmentsEntry(video_meta=meta) else: - self.data[key]["video_meta"] = meta + self.data[key].video_meta = meta logger.debug("Alignments count: %s, timestamp count: %s", len(self.data), len(pts_time)) if len(self.data) != len(pts_time): @@ -375,8 +240,8 @@ def frame_has_faces(self, frame_name: str) -> bool: ``True`` if the given frame_name exists within the alignments :attr:`data` and has at least 1 face associated with it, otherwise ``False`` """ - frame_data = self._data.get(frame_name, T.cast(AlignmentDict, {})) - retval = bool(frame_data.get("faces", [])) + frame_data = self._data.get(frame_name, AlignmentsEntry()) + retval = bool(frame_data.faces) logger.trace("'%s': %s", frame_name, retval) # type:ignore[attr-defined] return retval @@ -398,8 +263,8 @@ def frame_has_multiple_faces(self, frame_name: str) -> bool: if not frame_name: retval = False else: - frame_data = self._data.get(frame_name, T.cast(AlignmentDict, {})) - retval = bool(len(frame_data.get("faces", [])) > 1) + frame_data = self._data.get(frame_name, AlignmentsEntry) + retval = bool(len(frame_data.faces) > 1) logger.trace("'%s': %s", frame_name, retval) # type:ignore[attr-defined] return retval @@ -419,15 +284,14 @@ def mask_is_valid(self, mask_type: str) -> bool: ``True`` if all faces in the current alignments possess the given ``mask_type`` otherwise ``False`` """ - retval = all((face.get("mask") is not None and - face["mask"].get(mask_type) is not None) + retval = all(face.mask.get(mask_type) is not None for val in self._data.values() - for face in val["faces"]) + for face in val.faces) logger.debug(retval) return retval # << DATA >> # - def get_faces_in_frame(self, frame_name: str) -> list[AlignmentFileDict]: + def get_faces_in_frame(self, frame_name: str) -> list[FileAlignments]: """Obtain the faces from :attr:`data` associated with a given frame_name. Parameters @@ -441,8 +305,8 @@ def get_faces_in_frame(self, frame_name: str) -> list[AlignmentFileDict]: The list of face dictionaries that appear within the requested frame_name """ logger.trace("Getting faces for frame_name: '%s'", frame_name) # type:ignore[attr-defined] - frame_data = self._data.get(frame_name, T.cast(AlignmentDict, {})) - return frame_data.get("faces", T.cast(list[AlignmentFileDict], [])) + frame_data = self._data.get(frame_name, AlignmentsEntry()) + return frame_data.faces def count_faces_in_frame(self, frame_name: str) -> int: """Return number of faces that appear within :attr:`data` for the given frame_name. @@ -457,8 +321,8 @@ def count_faces_in_frame(self, frame_name: str) -> int: ------- The number of faces that appear in the given frame_name """ - frame_data = self._data.get(frame_name, T.cast(AlignmentDict, {})) - retval = len(frame_data.get("faces", [])) + frame_data = self._data.get(frame_name, AlignmentsEntry()) + retval = len(frame_data.faces) logger.trace(retval) # type:ignore[attr-defined] return retval @@ -484,11 +348,11 @@ def delete_face_at_index(self, frame_name: str, face_index: int) -> bool: logger.debug("No face to delete: (frame_name: '%s', face_index %s)", frame_name, face_index) return False - del self._data[frame_name]["faces"][face_index] + del self._data[frame_name].faces[face_index] logger.debug("Deleted face: (frame_name: '%s', face_index %s)", frame_name, face_index) return True - def add_face(self, frame_name: str, face: AlignmentFileDict) -> int: + def add_face(self, frame_name: str, face: FileAlignments) -> int: """Add a new face for the given frame_name in :attr:`data` and return it's index. Parameters @@ -506,13 +370,13 @@ def add_face(self, frame_name: str, face: AlignmentFileDict) -> int: """ logger.debug("Adding face to frame_name: '%s'", frame_name) if frame_name not in self._data: - self._data[frame_name] = {"faces": [], "video_meta": {}} - self._data[frame_name]["faces"].append(face) + self._data[frame_name] = AlignmentsEntry() + self._data[frame_name].faces.append(face) retval = self.count_faces_in_frame(frame_name) - 1 logger.debug("Returning new face index: %s", retval) return retval - def update_face(self, frame_name: str, face_index: int, face: AlignmentFileDict) -> None: + def update_face(self, frame_name: str, face_index: int, face: FileAlignments) -> None: """Update the face for the given frame_name at the given face index in :attr:`data`. Parameters @@ -527,7 +391,7 @@ def update_face(self, frame_name: str, face_index: int, face: AlignmentFileDict) correctly formatted for storing in :attr:`data` """ logger.debug("Updating face %s for frame_name '%s'", face_index, frame_name) - self._data[frame_name]["faces"][face_index] = face + self._data[frame_name].faces[face_index] = face def filter_faces(self, filter_dict: dict[str, list[int]], filter_out: bool = False) -> None: """Remove faces from :attr:`data` based on a given filter list. @@ -548,7 +412,7 @@ def filter_faces(self, filter_dict: dict[str, list[int]], filter_out: bool = Fal if filter_out: filter_list = face_indices else: - filter_list = [idx for idx in range(len(frame_data["faces"])) + filter_list = [idx for idx in range(len(frame_data.faces)) if idx not in face_indices] logger.trace("frame: '%s', filter_list: %s", # type:ignore[attr-defined] source_frame, filter_list) @@ -556,9 +420,9 @@ def filter_faces(self, filter_dict: dict[str, list[int]], filter_out: bool = Fal for face_idx in reversed(sorted(filter_list)): logger.verbose( # type:ignore[attr-defined] "Filtering out face: (filename: %s, index: %s)", source_frame, face_idx) - del frame_data["faces"][face_idx] + del frame_data.faces[face_idx] - def update_from_dict(self, data: dict[str, AlignmentDict]) -> None: + def update_from_dict(self, data: dict[str, AlignmentsEntry]) -> None: """Replace all alignments with the contents of the given dictionary Parameters @@ -571,7 +435,7 @@ def update_from_dict(self, data: dict[str, AlignmentDict]) -> None: self._data = data # << GENERATORS >> # - def yield_faces(self) -> Generator[tuple[str, list[AlignmentFileDict], int, str], None, None]: + def yield_faces(self) -> Generator[tuple[str, list[FileAlignments], int, str], None, None]: """Generator to obtain all faces with meta information from :attr:`data`. The results are yielded by frame. @@ -593,11 +457,11 @@ def yield_faces(self) -> Generator[tuple[str, list[AlignmentFileDict], int, str] """ for frame_fullname, val in self._data.items(): frame_name = os.path.splitext(frame_fullname)[0] - face_count = len(val["faces"]) + face_count = len(val.faces) logger.trace( # type:ignore[attr-defined] "Yielding: (frame: '%s', faces: %s, frame_fullname: '%s')", frame_name, face_count, frame_fullname) - yield frame_name, val["faces"], face_count, frame_fullname + yield frame_name, val.faces, face_count, frame_fullname def update_legacy_has_source(self, filename: str) -> None: """Update legacy alignments files when we have the source filename available. @@ -609,7 +473,8 @@ def update_legacy_has_source(self, filename: str) -> None: filename The filename/folder of the original source images/video for the current alignments """ - updates = [updater.is_updated for updater in (VideoExtension(self, filename), )] + updates = [updater.is_updated + for updater in (VideoExtension(self._data, self.version, filename), )] if any(updates): self._io.update_version() self.save() @@ -666,6 +531,7 @@ def _get_location(self, folder: str, filename: str) -> str: The full path to the alignments file """ logger.debug("Getting location: (folder: '%s', filename: '%s')", folder, filename) + assert self._serializer is not None no_ext_name, extension = os.path.splitext(filename) if extension[1:] == self._serializer.file_extension: logger.debug("Valid Alignments filename provided: '%s'", filename) @@ -678,24 +544,37 @@ def _get_location(self, folder: str, filename: str) -> str: logger.verbose("Alignments filepath: '%s'", location) # type:ignore[attr-defined] return location - def update_legacy(self) -> None: - """Check whether the alignments are legacy, and if so update them to current alignments - format.""" - updates = [updater.is_updated for updater in (FileStructure(self._alignments), - LandmarkRename(self._alignments), - NumpyToList(self._alignments), - MaskCentering(self._alignments), - IdentityAndVideoMeta(self._alignments))] - if any(updates): - self.update_version() - self.save() - def update_version(self) -> None: """Update the version of the alignments file to the latest version""" self._version = _VERSION logger.info("Updating alignments file to version %s", self._version) - def load(self) -> dict[str, AlignmentDict]: + def _update_legacy(self, alignments_dict: dict[str, T.Any]) -> bool: + """Check whether the alignments are legacy, and if so update them to current alignments + format. + + Parameters + ---------- + alignments_dict + The serialized alignments data loaded from disk + version + The alignments file version that has been loaded + + Returns + ------- + ``True`` if the alignments were updated otherwise ``False`` + """ + updates = [updater.is_updated for updater in ( + FileStructure(alignments_dict, self._version), + LandmarkRename(alignments_dict, self._version), + NumpyToList(alignments_dict, self._version), + MaskCentering(alignments_dict, self._version), + IdentityAndVideoMeta(alignments_dict, self._version))] + if any(updates): + self.update_version() + return any(updates) + + def load(self) -> dict[str, AlignmentsEntry]: """Load the alignments data from the serialized alignments :attr:`file`. Populates :attr:`_version` with the alignment file's loaded version as well as returning @@ -722,9 +601,15 @@ def load(self) -> dict[str, AlignmentDict]: "https://github.com/deepfakes/faceswap/releases/tag/v2.3.0") sys.exit(1) - data = data.get("__data__", data) + alignments = data["__data__"] + if self._update_legacy(alignments): + logger.info("Writing alignments to: '%s'", self._file) + self._serializer.save(self._file, {"__meta__": {"version": self._version}, + "__data__": {alignments}}) + retval: dict[str, AlignmentsEntry] + retval = {k: AlignmentsEntry.from_dict(v) for k, v in alignments.items()} logger.debug("Loaded alignments") - return data + return retval def save(self) -> None: """Write the contents of :attr:`data` and :attr:`_meta` to a serialized ``.fsa`` file at @@ -732,7 +617,7 @@ def save(self) -> None: logger.debug("Saving alignments") logger.info("Writing alignments to: '%s'", self._file) data = {"__meta__": {"version": self._version}, - "__data__": self._alignments.data} + "__data__": {k: v.to_dict() for k, v in self._alignments.data.items()}} self._serializer.save(self._file, data) logger.debug("Saved alignments") diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index 3aeb3676ca..4ca4e08ea2 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -10,7 +10,7 @@ from lib.logger import format_array, parse_class_init from lib.utils import get_module_objects -from .alignments import AlignmentFileDict, PNGHeaderAlignmentsDict +from .objects import FileAlignments, PNGAlignments from .aligned_face import AlignedFace from . import aligned_mask @@ -266,7 +266,7 @@ def get_training_masks(self) -> np.ndarray | None: return np.frombuffer(decompress(self._training_masks[0]), dtype="uint8").reshape(self._training_masks[1]) - def to_alignment(self) -> AlignmentFileDict: + def to_alignment(self) -> FileAlignments: """ Return the detected face formatted for an alignments file Returns @@ -278,19 +278,19 @@ def to_alignment(self) -> AlignmentFileDict: if (self.left is None or self.width is None or self.top is None or self.height is None): raise AssertionError("Some detected face variables have not been initialized") thumb = None if self.thumbnail is None else self.thumbnail.tolist() - alignment = AlignmentFileDict(x=self.left, - w=self.width, - y=self.top, - h=self.height, - landmarks_xy=self.landmarks_xy.tolist(), - mask={name: mask.to_dict() - for name, mask in self.mask.items()}, - identity={k: v.tolist() for k, v in self._identity.items()}, - thumb=thumb) + alignment = FileAlignments(x=self.left, + w=self.width, + y=self.top, + h=self.height, + landmarks_xy=self.landmarks_xy.tolist(), + mask={name: mask.to_dict() + for name, mask in self.mask.items()}, + identity=self._identity, + thumb=thumb) logger.trace("Returning: %s", alignment) # type:ignore[attr-defined] return alignment - def from_alignment(self, alignment: AlignmentFileDict, + def from_alignment(self, alignment: FileAlignments | PNGAlignments, image: np.ndarray | None = None, with_thumb: bool = False) -> T.Self: """Set the attributes of this class from an alignments file and optionally load the face into the ``image`` attribute. @@ -298,12 +298,7 @@ def from_alignment(self, alignment: AlignmentFileDict, Parameters ---------- alignment - A dictionary entry for a face from an alignments file containing the keys - ``x``, ``w``, ``y``, ``h``, ``landmarks_xy``. - Optionally the key ``thumb`` will be provided. This is for use in the manual tool and - contains the compressed jpg thumbnail of the face to be allocated to :attr:`thumbnail. - Optionally the key ``mask`` will be provided, but legacy alignments will not have - this key. + The alignment object to obtain the alignments from image If an image is passed in, then the ``image`` attribute will be set to the cropped face based on the passed in bounding box co-ordinates @@ -318,33 +313,25 @@ def from_alignment(self, alignment: AlignmentFileDict, logger.trace("Creating from alignment: (alignment: %s," # type:ignore[attr-defined] " has_image: %s)", alignment, bool(image is not None)) - self.left = alignment["x"] - self.width = alignment["w"] - self.top = alignment["y"] - self.height = alignment["h"] - landmarks = alignment["landmarks_xy"] - if not isinstance(landmarks, np.ndarray): - landmarks = np.array(landmarks, dtype="float32") - self._identity = {k: np.array(v, dtype="float32") - for k, v in alignment.get("identity", {}).items()} - self._landmarks_xy = landmarks.copy() - - if with_thumb: - # Thumbnails currently only used for manual tool. Default to None - thumb = alignment.get("thumb") - if isinstance(thumb, list): - self.thumbnail = np.array(thumb, dtype=np.uint8) + self.left = alignment.x + self.width = alignment.w + self.top = alignment.y + self.height = alignment.h + self._identity = alignment.identity + self._landmarks_xy = alignment.landmarks_xy + if with_thumb and isinstance(alignment, FileAlignments): + self.thumbnail = alignment.thumb # Manual tool and legacy alignments will not have a mask self._aligned = None - if alignment.get("mask", None) is not None: + if alignment.mask: self.mask = {} - for name, mask_dict in alignment["mask"].items(): + for name, mask in alignment.mask.items(): if name in ("components", "extended"): continue # Skip legacy stored LM based masks self.mask[name] = aligned_mask.Mask() - self.mask[name].from_dict(mask_dict) + self.mask[name].from_dict(mask) if image is not None and image.any(): self._image_to_face(image) logger.trace("Created from alignment: (left: %s, width: %s, " # type:ignore[attr-defined] @@ -352,7 +339,7 @@ def from_alignment(self, alignment: AlignmentFileDict, self.left, self.width, self.top, self.height, self.landmarks_xy, self.mask) return self - def to_png_meta(self) -> PNGHeaderAlignmentsDict: + def to_png_meta(self) -> PNGAlignments: """Return the detected face formatted for insertion into a png itxt header. Returns @@ -362,17 +349,17 @@ def to_png_meta(self) -> PNGHeaderAlignmentsDict: """ if (self.left is None or self.width is None or self.top is None or self.height is None): raise AssertionError("Some detected face variables have not been initialized") - alignment = PNGHeaderAlignmentsDict( + alignment = PNGAlignments( x=self.left, w=self.width, y=self.top, h=self.height, landmarks_xy=self.landmarks_xy.tolist(), mask={name: mask.to_png_meta() for name, mask in self.mask.items()}, - identity={k: v.tolist() for k, v in self._identity.items()}) + identity=self._identity) return alignment - def from_png_meta(self, alignment: PNGHeaderAlignmentsDict) -> T.Self: + def from_png_meta(self, alignment: PNGAlignments) -> T.Self: """Set the attributes of this class from alignments stored in a png exif header. Parameters @@ -381,19 +368,19 @@ def from_png_meta(self, alignment: PNGHeaderAlignmentsDict) -> T.Self: A dictionary entry for a face from alignments stored in a png exif header containing the keys ``x``, ``w``, ``y``, ``h``, ``landmarks_xy`` and ``mask`` """ - self.left = alignment["x"] - self.width = alignment["w"] - self.top = alignment["y"] - self.height = alignment["h"] - self._landmarks_xy = np.array(alignment["landmarks_xy"], dtype="float32") + self.left = alignment.x + self.width = alignment.w + self.top = alignment.y + self.height = alignment.h + self._landmarks_xy = alignment.landmarks_xy self.mask = {} - for name, mask_dict in alignment["mask"].items(): + for name, mask_dict in alignment.mask.items(): if name in ("components", "extended"): continue # Skip legacy stored LM based masks self.mask[name] = aligned_mask.Mask() self.mask[name].from_dict(mask_dict) self._identity = {} - for key, val in alignment.get("identity", {}).items(): + for key, val in alignment.identity.items(): self._identity[key] = np.array(val, dtype="float32") logger.trace("Created from png exif header: (left: %s, " # type:ignore[attr-defined] "width: %s, top: %s height: %s, landmarks: %s, mask: %s, identity: %s)", diff --git a/lib/align/objects.py b/lib/align/objects.py new file mode 100644 index 0000000000..2d0697200e --- /dev/null +++ b/lib/align/objects.py @@ -0,0 +1,283 @@ +#! /usr/env/bin/python3 +"""Dataclass objects for holding and serializing alignments data""" +from __future__ import annotations + +from dataclasses import dataclass, field, fields, MISSING +import types +import typing as T + +import numpy as np +import numpy.typing as npt + +from lib.logger import format_array + +from .aligned_face import CenteringType + + +@dataclass +class DataclassDict: + """Parent DataClass that has methods for loading to and from a dict for data serialization""" + def __repr__(self) -> str: + """Pretty print for logging""" + params = {k: format_array(v) if isinstance(v, np.ndarray) else v + for k, v in self.__dict__.items()} + s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + + @classmethod + def _object_to_serial(cls, obj: T.Any) -> T.Any: + """Convert object lists or DataclassDicts serializable items + + Parameters + ---------- + obj + The object to convert + + Returns + ------- + The converted object or original object if not to be converted + """ + if isinstance(obj, np.ndarray): + return obj.tolist() + if isinstance(obj, DataclassDict): + return obj.to_dict() + return obj + + def to_dict(self) -> dict[str, T.Any]: + """Obtain the contents of the dataclass object as a python dictionary + + Returns + ------- + The dataclass object as a python dictionary, with numpy arrays converted to lists + """ + retval: dict[str, T.Any] = {} + for k, v in self.__dict__.items(): + if isinstance(v, (list, tuple)): + retval[k] = [self._object_to_serial(x) for x in v] + elif isinstance(v, dict): + retval[k] = {x: self._object_to_serial(y) for x, y in v.items()} + else: + retval[k] = self._object_to_serial(v) + return retval + + @classmethod + def _convert_dtype(cls, data_type: T.Any, val: T.Any) -> DataclassDict | np.ndarray | None: + """Convert a serialized dict to a DataclassDict or list to a numpy array of the correct + dtype + + Parameters + ---------- + field_type + The field type for the incoming value + value + The list to convert to a numpy array or DataclassDict + + Returns + ------- + The inbound item to a DataclassDict or numpy array. ``None`` if the item does not convert + """ + if isinstance(data_type, type) and issubclass(data_type, DataclassDict): + return data_type.from_dict(val) + + origin = T.get_origin(data_type) + if origin is types.UnionType: + args = tuple(a for a in T.get_args(data_type) if a is not types.NoneType) + assert len(args) == 1 + if val is None: + return val + data_type = args[0] + origin = T.get_origin(data_type) + + if origin is not np.ndarray: + return None + + args = T.get_args(data_type) + dtype = T.get_args(args[1])[0] + return np.array(val, dtype=dtype) + + @classmethod + def _parse_dict(cls, field_type: T.Any, value: dict[str, T.Any]) -> dict[str, T.Any]: + """Parse incoming serialized dicts into their correct nested objects + + Parameters + ---------- + field_type + The field type for the incoming value + value + The dictionary to parse + + Returns + ------- + The dictionary with its values converted to the correct datatype + """ + assert T.get_origin(field_type) is dict + dtype = T.get_args(field_type)[1] + retval = {} + for k, v in value.items(): + converted = cls._convert_dtype(dtype, v) + if converted is not None: + retval[k] = converted + continue + retval[k] = v + return retval + + @classmethod + def _parse_list(cls, field_type: T.Any, value: list[T.Any] | tuple[T.Any] + ) -> list[T.Any] | tuple[T.Any]: + """Parse incoming serialized lists into their correct nested objects + + Parameters + ---------- + field_type + The field type for the incoming value + value + The list to parse + + Returns + ------- + The list with its values converted to the correct datatype + """ + origin = T.get_origin(field_type) + assert origin in (list, tuple), ( + f"value: {type(value)} field: {T.get_origin(field_type)}") + dtype = T.get_args(field_type)[0] + items = [] + for v in value: + converted = cls._convert_dtype(dtype, v) + if converted is not None: + items.append(converted) + continue + items.append(v) + retval = T.cast(list[T.Any] | tuple[T.Any], tuple(items) if origin is tuple else items) + return retval + + @classmethod + def from_dict(cls, data_dict: dict[str, T.Any]) -> T.Self: + """Load the contents from a serialized python dict into this dataclass + + Parameters + ---------- + data_dict + The data to load into the dataclass + """ + inbound = set(data_dict) + all_fields = set(f.name for f in fields(cls)) + required = set(f.name for f in fields(cls) + if f.default is MISSING and f.default_factory is MISSING) + if not inbound.issubset(all_fields): + raise ValueError(f"Dictionary keys {sorted(inbound)} should be a subset of dataclass " + f"params {sorted(all_fields)}") + if not required.issubset(inbound): + raise ValueError(f"Dataclass params {sorted(required)} should be a subset of " + f"dictionary keys {sorted(inbound)}") + type_hints = T.get_type_hints(cls) + kwargs: dict[str, T.Any] = {} + for f in fields(cls): + if f.name not in data_dict: + continue + field_type = type_hints.get(f.name) + val = data_dict[f.name] + converted = cls._convert_dtype(field_type, val) + if converted is not None: + kwargs[f.name] = converted + continue + if isinstance(val, dict): + kwargs[f.name] = cls._parse_dict(field_type, val) + continue + if isinstance(val, (list, tuple)): + kwargs[f.name] = cls._parse_list(field_type, val) + continue + kwargs[f.name] = val + return cls(**kwargs) + + +@dataclass(repr=False) +class MaskAlignmentsFile(DataclassDict): + """Dataclass for storing Masks in alignments files and PNG Headers""" + mask: bytes + """The zlib compressed UINT8 mask of shape (stored_size, stored_size)""" + affine_matrix: npt.NDArray[np.float32] + """The affine matrix that takes the mask from stored space to frame space""" + interpolator: int + """The interpolator required to take the mask from stored space to frame space""" + stored_size: int + """The size the mask is stored at""" + stored_centering: CenteringType + """The (legacy, face, head) centering type of the mask""" + + +@dataclass(repr=False) +class PNGAlignments(DataclassDict): + """Base Dataclass for storing a single faces' Alignment Information in Alignments files and PNG + Headers.""" + x: int + """The left most point of the bounding box""" + y: int + """The top most point of the bounding box""" + w: int + """The width of the bounding box""" + h: int + """The height of the bounding box""" + landmarks_xy: npt.NDArray[np.float32] + """The (x, y) landmark points of the face""" + mask: dict[str, MaskAlignmentsFile] + """The masks stored for the face""" + identity: dict[str, npt.NDArray[np.float32]] + """The identity vectors stored for the face""" + + def __repr__(self) -> str: + """Pretty print for logging""" + params: dict[str, T.Any] = {} + for k, v in self.__dict__.items(): + if k in ("landmarks_xy", "thumb"): + params[k] = None if v is None else f"{type(v)}[{len(v)}]" + continue + if k == "identity": + params[k] = {n: f"{type(i)}[{len(i)}]" for n, i in v.items()} + continue + params[k] = v + s_params = ", ".join(f"{k}={v}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + + +@dataclass(repr=False) +class PNGSource(DataclassDict): + """Dataclass for storing additional meta information in PNG headers.""" + alignments_version: float + """The alignments file version that created the alignments data""" + original_filename: str + """The original filename that this face was saved with""" + face_index: int + """The index of this face within the frame""" + source_filename: str + """The filename of the original frame the face was extracted from""" + source_is_video: bool + """``True`` if the face was extracted from a video. ``False`` if from an image""" + source_frame_dims: tuple[int, int] + """The (Height, Width) dimensions of the original frame the face was extracted from""" + + +@dataclass(repr=False) +class PNGHeader(DataclassDict): + """Dataclass for storing all alignment and meta information in PNG Headers.""" + alignments: PNGAlignments + """The alignment information for the face""" + source: PNGSource + """The frame source information for the face""" + + +@dataclass(repr=False) +class FileAlignments(PNGAlignments): + """Dataclass that holds the same information as PNGAlignments as well as a thumbnail for a + single face""" + thumb: npt.NDArray[np.uint8] | None = None + """96px JPEG thumbnail of the aligned face image stored as a list""" + + +@dataclass(repr=False) +class AlignmentsEntry(DataclassDict): + """Holds the alignments entry for a single frame in the Alignments data dictionary""" + faces: list[FileAlignments] = field(default_factory=list) + """The detected faces in a frame""" + video_meta: dict[T.Literal["pts_time", "keyframe"], int] = field(default_factory=dict) + """The keyframe to pts timestamp mapping for video data""" diff --git a/lib/align/thumbnails.py b/lib/align/thumbnails.py index 9927d90c1e..fd774acbb8 100644 --- a/lib/align/thumbnails.py +++ b/lib/align/thumbnails.py @@ -37,9 +37,9 @@ def __init__(self, alignments: align.alignments.Alignments) -> None: def has_thumbnails(self) -> bool: """``True`` if all faces in the alignments file contain thumbnail images otherwise ``False``.""" - retval = all(np.any(T.cast(np.ndarray, face.get("thumb"))) + retval = all(np.any(T.cast(np.ndarray, face.thumb is not None)) for frame in self._alignments_dict.values() - for face in frame["faces"]) + for face in frame.faces) logger.trace(retval) # type:ignore[attr-defined] return retval @@ -57,7 +57,7 @@ def get_thumbnail_by_index(self, frame_index: int, face_index: int) -> np.ndarra ------- The encoded JPG thumbnail """ - retval = self._alignments_dict[self._frame_list[frame_index]]["faces"][face_index]["thumb"] + retval = self._alignments_dict[self._frame_list[frame_index]].faces[face_index].thumb assert retval is not None logger.trace( # type:ignore[attr-defined] "frame index: %s, face_index: %s, thumb shape: %s", @@ -78,7 +78,7 @@ def add_thumbnail(self, frame: str, face_index: int, thumb: np.ndarray) -> None: """ logger.debug("frame: %s, face_index: %s, thumb shape: %s thumb dtype: %s", frame, face_index, thumb.shape, thumb.dtype) - self._alignments_dict[frame]["faces"][face_index]["thumb"] = thumb.tolist() + self._alignments_dict[frame].faces[face_index].thumb = thumb __all__ = get_module_objects(__name__) diff --git a/lib/align/updater.py b/lib/align/updater.py index 8899930c92..da217f877f 100644 --- a/lib/align/updater.py +++ b/lib/align/updater.py @@ -12,10 +12,9 @@ from lib.utils import get_module_objects from lib.video import VIDEO_EXTENSIONS -logger = logging.getLogger(__name__) +from .objects import AlignmentsEntry -if T.TYPE_CHECKING: - from lib import align +logger = logging.getLogger(__name__) class _Updater(): @@ -24,11 +23,14 @@ class _Updater(): Parameters ---------- alignments - The alignments object that is being tested and updated + The serialized alignments that have been loaded from disk + version + The alignments file version that has been loaded """ - def __init__(self, alignments: align.alignments.Alignments) -> None: + def __init__(self, alignments: dict[str, T.Any], version: float) -> None: logger.debug(parse_class_init(locals())) self._alignments = alignments + self._version = version self._needs_update = self._test() if self._needs_update: self._update() @@ -91,13 +93,16 @@ class VideoExtension(_Updater): Parameters ---------- alignments - The alignments object that is being tested and updated + The serialized alignments that have been loaded from disk + version + The alignments file version that has been loaded video_filename The video filename that holds these alignments """ - def __init__(self, alignments: align.alignments.Alignments, video_filename: str) -> None: + def __init__(self, alignments: dict[str, T.Any], version: float, video_filename: str) -> None: self._video_name, self._extension = os.path.splitext(video_filename) - super().__init__(alignments) + super().__init__(alignments, version) + self._alignments: dict[str, AlignmentsEntry] def test(self) -> bool: """Requires update if the extension of the key in the alignment file is not the same @@ -112,7 +117,7 @@ def test(self) -> bool: if self._extension.lower() not in VIDEO_EXTENSIONS: return False - exts = set(os.path.splitext(k)[-1] for k in self._alignments.data) + exts = set(os.path.splitext(k)[-1] for k in self._alignments) if len(exts) != 1: logger.debug("Alignments file has multiple key extensions. Skipping") return False @@ -122,7 +127,7 @@ def test(self) -> bool: return False logger.debug("Needs update for video extension (version: %s, extension: %s)", - self._alignments.version, self._extension) + self._version, self._extension) return True def update(self) -> int: @@ -135,16 +140,16 @@ def update(self) -> int: The filename of the video file that created these alignments """ updated = 0 - for key in list(self._alignments.data): + for key in list(self._alignments): fname = os.path.splitext(key)[0] if fname.rsplit("_", maxsplit=1)[0] != self._video_name: continue # Key is from a different source - val = self._alignments.data[key] + val = self._alignments[key] new_key = f"{fname}{self._extension}" - del self._alignments.data[key] - self._alignments.data[new_key] = val + del self._alignments[key] + self._alignments[new_key] = val updated += 1 @@ -164,7 +169,7 @@ def test(self) -> bool: ------- ``True`` if the file has legacy structure otherwise ``False`` """ - return any(isinstance(val, list) for val in self._alignments.data.values()) + return any(isinstance(val, list) for val in self._alignments.values()) def update(self) -> int: """Update legacy alignments files from the format `{frame_name: [faces}` to the @@ -175,10 +180,10 @@ def update(self) -> int: The number of items that were updated """ updated = 0 - for key, val in self._alignments.data.items(): + for key, val in self._alignments.items(): if not isinstance(val, list): continue - self._alignments.data[key] = T.cast("align.alignments.AlignmentDict", {"faces": val}) + self._alignments[key] = {"faces": val} updated += 1 return updated @@ -193,7 +198,7 @@ def test(self) -> bool: ``True`` if the alignments file contains legacy `landmarksXY` keys otherwise ``False`` """ return (any(key == "landmarksXY" - for val in self._alignments.data.values() + for val in self._alignments.values() for alignment in val["faces"] for key in alignment)) @@ -205,7 +210,7 @@ def update(self) -> int: The number of landmarks keys that were changed """ update_count = 0 - for val in self._alignments.data.values(): + for val in self._alignments.values(): for alignment in val["faces"]: if "landmarksXY" in alignment: alignment["landmarks_xy"] = alignment.pop("landmarksXY") # type:ignore @@ -225,7 +230,7 @@ def test(self) -> bool: """ return any(isinstance(face["landmarks_xy"], np.ndarray) or isinstance(face.get("thumb"), np.ndarray) - for val in self._alignments.data.values() + for val in self._alignments.values() for face in val["faces"]) def update(self) -> int: @@ -236,7 +241,7 @@ def update(self) -> int: The number of faces that were changed """ update_count = 0 - for val in self._alignments.data.values(): + for val in self._alignments.values(): for alignment in val["faces"]: test1 = alignment["landmarks_xy"] test2 = alignment["thumb"] @@ -260,7 +265,7 @@ def test(self) -> bool: ------- ``True`` mask centering requires updating otherwise ``False`` """ - return self._alignments.version < 2.2 + return self._version < 2.2 def update(self) -> int: """Add the mask key to the alignment file and update the centering of existing masks @@ -270,7 +275,7 @@ def update(self) -> int: The number of masks that were updated """ update_count = 0 - for val in self._alignments.data.values(): + for val in self._alignments.values(): for alignment in val["faces"]: if "mask" not in alignment: alignment["mask"] = {} @@ -290,7 +295,7 @@ def test(self) -> bool: ------- ``True`` identity key needs inserting otherwise ``False`` """ - return self._alignments.version < 2.3 + return self._version < 2.3 # Identity information was not previously stored in the alignments file. def update(self) -> int: @@ -301,7 +306,7 @@ def update(self) -> int: The number of keys inserted """ update_count = 0 - for val in self._alignments.data.values(): + for val in self._alignments.values(): this_update = 0 if "video_meta" not in val: val["video_meta"] = {} diff --git a/lib/image.py b/lib/image.py index c6e4046376..8b79a062fd 100644 --- a/lib/image.py +++ b/lib/image.py @@ -16,6 +16,7 @@ import cv2 import numpy as np +from lib.align.objects import PNGHeader from lib.logger import parse_class_init from lib.multithreading import FSThread from lib.utils import FaceswapError, get_image_paths, get_module_objects @@ -25,7 +26,6 @@ if T.TYPE_CHECKING: import numpy.typing as npt from lib.multithreading import ErrorState - from lib.align.alignments import PNGHeaderDict logger = logging.getLogger(__name__) @@ -47,7 +47,7 @@ def read_image(filename: str, def read_image(filename: str, raise_error: T.Literal[False] = False, *, - with_metadata: T.Literal[True]) -> tuple[npt.NDArray[np.uint8], PNGHeaderDict]: ... + with_metadata: T.Literal[True]) -> tuple[npt.NDArray[np.uint8], PNGHeader]: ... @T.overload @@ -59,8 +59,8 @@ def read_image(filename: str, def read_image(filename: str, # noqa[C901] # pylint:disable=too-many-statements,too-many-branches raise_error: bool = False, with_metadata: bool = False - ) -> np.ndarray | None | tuple[npt.NDArray[np.uint8], PNGHeaderDict]: - """ Read an image file from a file location. + ) -> np.ndarray | None | tuple[npt.NDArray[np.uint8], PNGHeader]: + """Read an image file from a file location. Extends the functionality of :func:`cv2.imread()` by ensuring that an image was actually loaded. Errors can be logged and ignored so that the process can continue on an image load @@ -68,22 +68,22 @@ def read_image(filename: str, # noqa[C901] # pylint:disable=too-many-statement Parameters ---------- - filename : str + filename Full path to the image to be loaded. - raise_error: bool, optional + raise_error If ``True`` then any failures (including the returned image being ``None``) will be raised. If ``False`` then an error message will be logged, but the error will not be raised. Default: ``False`` - with_metadata : bool, optional + with_metadata Only returns a value if the images loaded are extracted Faceswap faces. If ``True`` then returns the Faceswap metadata stored with in a Face images .png EXIF header. Default: ``False`` Returns ------- - image : :class:`numpy.ndarray` + image The image in `BGR` channel order as UINT8 for the corresponding :attr:`filename` - metadata : :class:`~lib.align.alignments.PNGHeaderDict`, optional + metadata The faceswap metadata corresponding to the image. Only returned if `with_metadata` is ``True`` @@ -98,7 +98,7 @@ def read_image(filename: str, # noqa[C901] # pylint:disable=too-many-statement logger.trace("Requested image: '%s'", filename) # type:ignore[attr-defined] success = True image = None - retval: np.ndarray | tuple[np.ndarray, PNGHeaderDict] | None = None + retval: np.ndarray | tuple[np.ndarray, PNGHeader] | None = None try: with open(filename, "rb") as in_file: raw_file = in_file.read() @@ -122,7 +122,8 @@ def read_image(filename: str, # noqa[C901] # pylint:disable=too-many-statement image = np.clip(image, 0, 255).astype(np.uint8) if with_metadata: - metadata = T.cast("PNGHeaderDict", png_read_meta(raw_file)) + metadata = png_read_meta(raw_file) + assert isinstance(metadata, PNGHeader) retval = (image, metadata) else: retval = image @@ -158,30 +159,30 @@ def read_image_batch(filenames: list[str], with_metadata: T.Literal[False] = Fal @T.overload def read_image_batch(filenames: list[str], with_metadata: T.Literal[True] - ) -> tuple[np.ndarray, list[PNGHeaderDict]]: ... + ) -> tuple[np.ndarray, list[PNGHeader]]: ... def read_image_batch(filenames: list[str], with_metadata: bool = False - ) -> np.ndarray | tuple[np.ndarray, list[PNGHeaderDict]]: - """ Load a batch of images from the given file locations. + ) -> np.ndarray | tuple[np.ndarray, list[PNGHeader]]: + """Load a batch of images from the given file locations. Leverages multi-threading to load multiple images from disk at the same time leading to vastly reduced image read times. Parameters ---------- - filenames : list[str] + filenames A of full paths to the images to be loaded. - with_metadata : bool, optional + with_metadata Only returns a value if the images loaded are extracted Faceswap faces. If ``True`` then returns the Faceswap metadata stored within each Face's .png exif header. Default: ``False`` Returns ------- - batch : :class:`numpy.ndarray` + batch The batch of images in `BGR` channel order returned in the order of :attr:`filenames` - metadata : list[:class:`~lib.align.alignments.PNGHeaderDict`], optional + metadata The faceswap metadata corresponding to each image in the batch. Only returned if `with_metadata` is ``True`` @@ -204,7 +205,7 @@ def read_image_batch(filenames: list[str], with_metadata: bool = False """ logger.trace("Requested batch: '%s'", filenames) # type:ignore[attr-defined] batch: list[np.ndarray | None] = [None for _ in range(len(filenames))] - meta: list[PNGHeaderDict | None] = [None for _ in range(len(filenames))] + meta: list[PNGHeader | None] = [None for _ in range(len(filenames))] with futures.ThreadPoolExecutor() as executor: images = {executor.submit( # NOTE submit strips positionals, breaking type-checking @@ -215,7 +216,7 @@ def read_image_batch(filenames: list[str], with_metadata: bool = False for idx, filename in enumerate(filenames)} for future in futures.as_completed(images): - result = T.cast(np.ndarray | tuple[np.ndarray, "PNGHeaderDict"], future.result()) + result = T.cast(np.ndarray | tuple[np.ndarray, "PNGHeader"], future.result()) ret_idx = images[future] if with_metadata: assert isinstance(result, tuple) @@ -225,9 +226,9 @@ def read_image_batch(filenames: list[str], with_metadata: bool = False batch[ret_idx] = result arr_batch = np.array(batch) - retval: np.ndarray | tuple[np.ndarray, list[PNGHeaderDict]] + retval: np.ndarray | tuple[np.ndarray, list[PNGHeader]] if with_metadata: - retval = (arr_batch, T.cast(list["PNGHeaderDict"], meta)) + retval = (arr_batch, T.cast(list["PNGHeader"], meta)) else: retval = arr_batch @@ -344,19 +345,20 @@ def read_image_meta_batch(filenames): yield retval -def pack_to_itxt(metadata): +def pack_to_itxt(metadata: PNGHeader | dict[str, T.Any] | bytes) -> bytes: """ Pack the given metadata dictionary to a PNG iTXt header field. Parameters ---------- - metadata: dict or bytes + metadata The dictionary to write to the header. Can be pre-encoded as utf-8. Returns ------- - bytes - A byte encoded PNG iTXt field, including chunk header and CRC + A byte encoded PNG iTXt field, including chunk header and CRC """ + if isinstance(metadata, PNGHeader): + metadata = metadata.to_dict() if not isinstance(metadata, bytes): metadata = str(metadata).encode("utf-8", "strict") key = "faceswap".encode("latin-1", "strict") @@ -368,16 +370,18 @@ def pack_to_itxt(metadata): return retval -def update_existing_metadata(filename, metadata): +def update_existing_metadata(filename: str, metadata: PNGHeader | bytes) -> None: """ Update the png header metadata for an existing .png extracted face file on the filesystem. Parameters ---------- - filename: str + filename The full path to the face to be updated - metadata: dict or bytes + metadata The dictionary to write to the header. Can be pre-encoded as utf-8. """ + if not isinstance(metadata, bytes): + metadata = str(metadata.to_dict()).encode("utf-8", errors="strict") tmp_filename = filename + "~" with open(filename, "rb") as png, open(tmp_filename, "wb") as tmp: @@ -421,18 +425,18 @@ def update_existing_metadata(filename, metadata): def encode_image(image: np.ndarray, extension: str, encoding_args: tuple[int, ...] | None = None, - metadata: PNGHeaderDict | dict[str, T.Any] | bytes | None = None) -> bytes: - """ Encode an image. + metadata: PNGHeader | dict[str, T.Any] | bytes | None = None) -> bytes: + """Encode an image. Parameters ---------- - image: numpy.ndarray + image The image to be encoded in `BGR` channel order. - extension: str + extension A compatible `cv2` image file extension that the final image is to be saved to. - encoding_args: tuple[int, ...], optional + encoding_args Any encoding arguments to pass to cv2's imencode function - metadata: dict or bytes, optional + metadata Metadata for the image. If provided, and the extension is png or tiff, this information will be written to the PNG itxt header. Default:``None`` Can be provided as a python dict or pre-encoded @@ -459,14 +463,14 @@ def encode_image(image: np.ndarray, return retval -def png_write_meta(image: bytes, data: PNGHeaderDict | dict[str, T.Any] | bytes) -> bytes: - """ Write Faceswap information to a png's iTXt field. +def png_write_meta(image: bytes, data: PNGHeader | dict[str, T.Any] | bytes) -> bytes: + """Write Faceswap information to a png's iTXt field. Parameters ---------- - image: bytes + image The bytes encoded png file to write header data to - data: dict or bytes + data The dictionary to write to the header. Can be pre-encoded as utf-8. Notes @@ -478,7 +482,6 @@ def png_write_meta(image: bytes, data: PNGHeaderDict | dict[str, T.Any] | bytes) References ---------- PNG Specification: https://www.w3.org/TR/2003/REC-PNG-20031110/ - """ split = image.find(b"IDAT") - 4 retval = image[:split] + pack_to_itxt(data) + image[split:] @@ -486,14 +489,14 @@ def png_write_meta(image: bytes, data: PNGHeaderDict | dict[str, T.Any] | bytes) def tiff_write_meta(image: bytes, # pylint:disable=too-many-locals - data: PNGHeaderDict | dict[str, T.Any] | bytes) -> bytes: - """ Write Faceswap information to a tiff's image_description field. + data: PNGHeader | dict[str, T.Any] | bytes) -> bytes: + """Write Faceswap information to a tiff's image_description field. Parameters ---------- - png: bytes + png The bytes encoded tiff file to write header data to - data: dict or bytes + data The data to write to the image-description field. If provided as a dict, then it should be a json serializable object, otherwise it should be data encoded as ascii bytes @@ -502,6 +505,8 @@ def tiff_write_meta(image: bytes, # pylint:disable=too-many-locals This handles a very specific task of adding, and populating, an ImageDescription field in a Tiff file generated by OpenCV. For any other use cases it will likely fail """ + if isinstance(data, PNGHeader): + data = data.to_dict() if not isinstance(data, bytes): data = json.dumps(data, ensure_ascii=True).encode("ascii") @@ -591,19 +596,18 @@ def tiff_read_meta(image: bytes) -> dict[str, T.Any]: # pylint:disable=too-many return retval -def png_read_meta(image: bytes) -> PNGHeaderDict | dict[str, T.Any]: +def png_read_meta(image: bytes) -> PNGHeader | dict[str, T.Any]: """ Read the Faceswap information stored in a png's iTXt field. Parameters ---------- - image: bytes + image The bytes encoded png file to read header data from Returns ------- - :class:`~lib.align.alignments.PNGHeaderDict` | dict[str, Any] - The Faceswap information stored in the PNG header. This will either be a PNGHeaderDict - if an extracted face, or other arbitrary information (for example for the Patch Writer) + The Faceswap information stored in the PNG header. This will either be a PNGHeader object if an + extracted face, or other arbitrary information (for example for the Patch Writer) Notes ----- @@ -611,7 +615,7 @@ def png_read_meta(image: bytes) -> PNGHeaderDict | dict[str, T.Any]: task. OpenCV will not write any iTXt headers to the PNG file, so we make the assumption that the only iTXt header that exists is the one that Faceswap created for storing alignments. """ - retval: PNGHeaderDict | None = None + retval: PNGHeader | dict[str, T.Any] | None = None pointer = 0 while True: pointer = image.find(b"iTXt", pointer) - 4 @@ -622,7 +626,7 @@ def png_read_meta(image: bytes) -> PNGHeaderDict | dict[str, T.Any]: pointer += 8 keyword, value = image[pointer:pointer + length].split(b"\0", 1) if keyword == b"faceswap": - retval = literal_eval(value[4:].decode("utf-8", errors="ignore")) + retval = PNGHeader.from_dict(literal_eval(value[4:].decode("utf-8", errors="ignore"))) break logger.trace("Skipping iTXt chunk: '%s'", # type:ignore[attr-defined] keyword.decode("latin-1", errors="ignore")) @@ -1038,7 +1042,7 @@ def _from_video(self) -> T.Generator[tuple[str, npt.NDArray[np.uint8]], None, No yield filename, image def _from_folder(self) -> T.Generator[tuple[str, npt.NDArray[np.uint8]] | - tuple[str, npt.NDArray[np.uint8], PNGHeaderDict], + tuple[str, npt.NDArray[np.uint8], PNGHeader], None, None]: """Generator for loading images from a folder @@ -1064,7 +1068,7 @@ def _from_folder(self) -> T.Generator[tuple[str, npt.NDArray[np.uint8]] | yield filename, image_read def load(self) -> T.Generator[tuple[str, npt.NDArray[np.uint8]] | - tuple[str, npt.NDArray[np.uint8], PNGHeaderDict], None, None]: + tuple[str, npt.NDArray[np.uint8], PNGHeader], None, None]: """Generator for loading images from the given :attr:`location` If :class:`FacesLoader` is in use then the Faceswap metadata of the image stored in the @@ -1180,7 +1184,7 @@ class SingleFrameLoader(ImagesLoader): path Full path to the input media video_meta_data - Existing video meta information containing the pts_time and iskey flags for the given + Existing video meta information containing the pts_time and is_key flags for the given video. Used in conjunction with single_frame_reader for faster seeks. Providing this means that the video does not need to be scanned again. Set to ``None`` if the video is to be scanned. Default: ``None`` diff --git a/lib/infer/identity.py b/lib/infer/identity.py index 736ec978fc..a535bbb882 100644 --- a/lib/infer/identity.py +++ b/lib/infer/identity.py @@ -13,6 +13,7 @@ from fastcluster import linkage, linkage_vector from lib.align.detected_face import DetectedFace +from lib.align.objects import PNGHeader from lib.image import png_read_meta from lib.logger import parse_class_init from lib.utils import FaceswapError, get_module_objects, IMAGE_EXTENSIONS @@ -23,7 +24,6 @@ if T.TYPE_CHECKING: import numpy.typing as npt from collections.abc import Generator - from lib.align.alignments import PNGHeaderDict from .runner import ExtractRunner logger = logging.getLogger(__name__) @@ -319,7 +319,7 @@ def add_identity_plugin(self, runner: ExtractRunner) -> None: self._runner = runner @classmethod - def _get_meta(cls, filename: str, image: bytes) -> PNGHeaderDict | None: + def _get_meta(cls, filename: str, image: bytes) -> PNGHeader | None: """Obtain the embedded meta data from a faceswap aligned image Parameters @@ -343,11 +343,11 @@ def _get_meta(cls, filename: str, image: bytes) -> PNGHeaderDict | None: logger.debug("[IdentityFilter] '%s' is not a faceswap extracted image", filename) return None - if "alignments" not in meta: + if not isinstance(meta, PNGHeader): logger.debug("[IdentityFilter] '%s' is not a faceswap extracted image", filename) return None - return T.cast("PNGHeaderDict", meta) + return meta def _from_pipeline(self, pipeline: ExtractRunner, images: dict[str, npt.NDArray[np.uint8]] ) -> dict[str, npt.NDArray[np.float32]]: @@ -377,7 +377,7 @@ def _from_pipeline(self, pipeline: ExtractRunner, images: dict[str, npt.NDArray[ {k: v.shape for k, v in retval.items()}) return retval - def _from_plugin(self, images: dict[str, tuple[PNGHeaderDict, npt.NDArray[np.uint8]]] + def _from_plugin(self, images: dict[str, tuple[PNGHeader, npt.NDArray[np.uint8]]] ) -> dict[str, npt.NDArray[np.float32]]: """Obtain embeddings from the identity when faceswap aligned images without identity information have been provided @@ -397,9 +397,9 @@ def _from_plugin(self, images: dict[str, tuple[PNGHeaderDict, npt.NDArray[np.uin logger.debug("[IdentityFilter] Putting to plugin: '%s'", fname) out = self._runner.put_direct(fname, image, - [DetectedFace().from_png_meta(meta["alignments"])], + [DetectedFace().from_png_meta(meta.alignments)], is_aligned=True, - frame_size=meta["source"]["source_frame_dims"]) + frame_size=meta.source.source_frame_dims) retval[fname] = out.identities[self._runner.handler.plugin.storage_name].squeeze(0) logger.debug("[IdentityFilter] Identity from plugin: %s", @@ -455,7 +455,7 @@ def get_embeddings(self, pipeline: ExtractRunner) -> None: """ embeds: dict[str, npt.NDArray[np.float32]] = {} non_aligned: dict[str, npt.NDArray[np.uint8]] = {} - aligned: dict[str, tuple[PNGHeaderDict, npt.NDArray[np.uint8]]] = {} + aligned: dict[str, tuple[PNGHeader, npt.NDArray[np.uint8]]] = {} for filepath in self._filter_files.union(self._nfilter_files): with open(filepath, "rb") as in_file: @@ -463,7 +463,7 @@ def get_embeddings(self, pipeline: ExtractRunner) -> None: meta = self._get_meta(filepath, raw_image) if meta is not None: - idn = T.cast(dict[str, list], meta.get("identity", {})) + idn = meta.alignments.identity embed = np.array(idn.get(self._runner.handler.storage_name, []), dtype="float32") if np.any(embed): diff --git a/lib/infer/objects.py b/lib/infer/objects.py index 4686e04753..1b75a32ddb 100644 --- a/lib/infer/objects.py +++ b/lib/infer/objects.py @@ -14,7 +14,7 @@ from lib.align.aligned_face import batch_umeyama from lib.align.aligned_utils import batch_resize, batch_transform, points_to_68 from lib.align.aligned_mask import Mask -from lib.align.alignments import PNGAlignments, MaskAlignmentsFile +from lib.align.objects import PNGAlignments, MaskAlignmentsFile from lib.align.constants import LandmarkType, MEAN_FACE from lib.align.detected_face import DetectedFace from lib.align.pose import Batch3D @@ -22,7 +22,7 @@ from lib.utils import get_module_objects if T.TYPE_CHECKING: - from lib.align.alignments import PNGHeaderSourceDict + from lib.align.objects import PNGSource from lib.align.constants import CenteringType logger = logging.getLogger(__name__) @@ -406,7 +406,7 @@ class ExtractBatch: # pylint:disable=too-many-instance-attributes """``True`` if :attr:`images` contains aligned faces. ``False`` for full frames""" frame_sizes: list[tuple[int, int]] | None = None """The original frame (heights, widths) when the images are aligned faces""" - frame_metadata: list[PNGHeaderSourceDict] | None = None + frame_metadata: list[PNGSource] | None = None """The original frame metadata when aligned faces is ``True`` otherwise ``None``""" passthrough: bool = False """Whether this item should pass straight through the pipeline for immediate return""" @@ -759,7 +759,7 @@ def __init__(self, # pylint:disable=too-many-arguments,too-many-positional-argu masks: dict[str, ExtractBatchMask] | None = None, source: str | None = None, is_aligned: bool = False, - frame_metadata: PNGHeaderSourceDict | None = None, + frame_metadata: PNGSource | None = None, passthrough: bool = False) -> None: logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] if is_aligned: @@ -777,7 +777,7 @@ def __init__(self, # pylint:disable=too-many-arguments,too-many-positional-argu """The mask objects for each face in the frame""" self.source = source """The full path to the source folder or video file or ``None`` if not provided""" - self.frame_metadata: PNGHeaderSourceDict | None = frame_metadata + self.frame_metadata: PNGSource | None = frame_metadata """The frame metadata that has been added from an aligned image. ``None`` if metadata has not been added""" self.is_aligned = is_aligned @@ -913,8 +913,9 @@ def _get_image_shape(self) -> tuple[int, int, int]: The shape of the original image """ if self.is_aligned: - assert self.frame_metadata is not None - dims = T.cast(tuple[int, int], self.frame_metadata["source_frame_dims"]) + assert (self.frame_metadata is not None and + self.frame_metadata.source_frame_dims is not None) + dims = self.frame_metadata.source_frame_dims return (*dims, 3) return T.cast(tuple[int, int, int], self.image.shape) diff --git a/lib/infer/runner.py b/lib/infer/runner.py index f2b1670948..5cf85f4cfc 100644 --- a/lib/infer/runner.py +++ b/lib/infer/runner.py @@ -22,7 +22,7 @@ if T.TYPE_CHECKING: from .handler import ExtractHandler, ExtractHandlerFace - from lib.align.alignments import PNGHeaderSourceDict + from lib.align.objects import PNGSource from lib.align.detected_face import DetectedFace logger = logging.getLogger(__name__) @@ -567,7 +567,7 @@ def put(self, detected_faces: list[DetectedFace] | None = None, source: str | None = None, is_aligned: bool = False, - frame_metadata: PNGHeaderSourceDict | None = None, + frame_metadata: PNGSource | None = None, passthrough: T.Literal[False] = False) -> None: ... @T.overload @@ -577,7 +577,7 @@ def put(self, detected_faces: list[DetectedFace] | None = None, source: str | None = None, is_aligned: bool = False, - frame_metadata: PNGHeaderSourceDict | None = None, + frame_metadata: PNGSource | None = None, *, passthrough: T.Literal[True]) -> FrameFaces: ... @@ -587,7 +587,7 @@ def put(self, detected_faces: list[DetectedFace] | None = None, source: str | None = None, is_aligned: bool = False, - frame_metadata: PNGHeaderSourceDict | None = None, + frame_metadata: PNGSource | None = None, passthrough: bool = False) -> None | FrameFaces: """Put a frame into the pipeline. diff --git a/lib/training/cache.py b/lib/training/cache.py index abd0cee074..6b413c2511 100644 --- a/lib/training/cache.py +++ b/lib/training/cache.py @@ -13,13 +13,14 @@ from tqdm import tqdm from lib.align import CenteringType, DetectedFace, LandmarkType +from lib.align.objects import PNGHeader from lib.image import read_image_batch, read_image_meta_batch from lib.logger import parse_class_init from lib.utils import FaceswapError, get_module_objects from plugins.train import train_config as cfg if T.TYPE_CHECKING: - from lib.align.alignments import PNGHeaderAlignmentsDict, PNGHeaderDict + from lib.align.objects import PNGAlignments from lib import align logger = logging.getLogger(__name__) @@ -408,14 +409,14 @@ def _reset_cache(self, set_flag: bool) -> None: if set_flag: self._cache_info["has_reset"] = True - def _validate_version(self, png_meta: PNGHeaderDict, filename: str) -> None: - """ Validate that there are not a mix of v1.0 extracted faces and v2.x faces. + def _validate_version(self, png_meta: PNGHeader, filename: str) -> None: + """Validate that there are not a mix of v1.0 extracted faces and v2.x faces. Parameters ---------- - png_meta : :class:`~lib.align.alignments.PNGHeaderDict` + png_meta The information held within the Faceswap PNG Header - filename: str + filename The full path to the file being validated Raises @@ -423,7 +424,7 @@ def _validate_version(self, png_meta: PNGHeaderDict, filename: str) -> None: :class:`~lib.utils.FaceswapError` If a version 1.0 face appears in a 2.x set or vice versa """ - alignment_version = png_meta["source"]["alignments_version"] + alignment_version = png_meta.source.alignments_version if not self._extract_version: logger.debug("Setting initial extract version: %s", alignment_version) @@ -442,21 +443,20 @@ def _validate_version(self, png_meta: PNGHeaderDict, filename: str) -> None: def _load_detected_face(self, filename: str, - alignments: PNGHeaderAlignmentsDict) -> DetectedFace: - """ Load a :class:`~lib.align.detected_face.DetectedFace` object and load its associated + alignments: PNGAlignments) -> DetectedFace: + """Load a :class:`~lib.align.detected_face.DetectedFace` object and load its associated `aligned` property. Parameters ---------- - filename : str + filename The file path for the current image - alignments : :class:`~lib.align.alignments.PNGHeaderAlignmentsDict` + alignments The alignments for a single face, extracted from a PNG header Returns ------- - :class:`~lib.align.detected_face.DetectedFace` - The loaded Detected Face object + The loaded Detected Face object """ y_offset = cfg.vertical_offset() detected_face = DetectedFace() @@ -473,17 +473,17 @@ def _load_detected_face(self, def _populate_cache(self, needs_cache: list[str], - metadata: list[PNGHeaderDict], + metadata: list[PNGHeader], filenames: list[str]) -> None: - """ Populate the given items into the cache + """Populate the given items into the cache Parameters ---------- - needs_cache : list[str] + needs_cache The full path to files within this batch that require caching - metadata : list[:class:`~lib.align.alignments.PNGHeaderDict`] + metadata The faceswap metadata loaded from the image png header - filenames : list[str] + filenames Full path to the filenames that are being loaded in this batch """ for filename in needs_cache: @@ -496,25 +496,25 @@ def _populate_cache(self, self._partially_loaded.remove(key) detected_face = self._cache[key] else: - detected_face = self._load_detected_face(filename, meta["alignments"]) + detected_face = self._load_detected_face(filename, meta.alignments) self._mask_prepare(filename, detected_face) self._cache[key] = detected_face def _get_batch_with_metadata(self, - filenames: list[str]) -> tuple[np.ndarray, list[PNGHeaderDict]]: + filenames: list[str]) -> tuple[np.ndarray, list[PNGHeader]]: """ Load a batch of images along with their faceswap metadata for loading into the cache Parameters ---------- - filenames : list[str] + filenames Full path to the images to be loaded Returns ------- - batch : :class:`numpy.ndarray` + batch The batch of images in a single array - metadata : :class:`~lib.align.alignments.PNGHeaderDict` + metadata The faceswap metadata corresponding to each image in the batch """ try: @@ -611,10 +611,10 @@ def pre_fill(self, filenames: list[str], side: T.Literal["a", "b"]) -> None: if "itxt" not in meta or "alignments" not in meta["itxt"]: raise FaceswapError(f"Invalid face image found. Aborting: '{filename}'") - meta = meta["itxt"] + meta = PNGHeader.from_dict(meta["itxt"]) key = os.path.basename(filename) self._validate_version(meta, filename) - detected_face = self._load_detected_face(filename, meta["alignments"]) + detected_face = self._load_detected_face(filename, meta.alignments) aligned = detected_face.aligned assert aligned is not None diff --git a/scripts/extract.py b/scripts/extract.py index b61be6079a..54656e5406 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -17,7 +17,7 @@ from lib.align.aligned_utils import (batch_adjust_matrices, batch_align, batch_resize, batch_transform, get_adjusted_center, get_centered_size) -from lib.align.alignments import AlignmentsFace, PNGHeader, PNGSource +from lib.align.objects import AlignmentsEntry, FileAlignments, PNGHeader, PNGSource from lib.align.constants import EXTRACT_RATIOS, LandmarkType, MEAN_FACE from lib.align.detected_face import DetectedFace from lib.align.pose import get_camera_matrix, get_xyz_2d, Batch3D @@ -35,7 +35,7 @@ if T.TYPE_CHECKING: import numpy.typing as npt from argparse import Namespace - from lib.align.alignments import AlignmentDict, AlignmentFileDict, PNGAlignments + from lib.align.objects import PNGAlignments from lib.infer.runner import ExtractRunner from lib.multithreading import ErrorState @@ -387,7 +387,7 @@ def __init__(self, self._images = ImagesLoader(input_path) self._thread = FSThread(self._load, name="ExtractLoader") - self._alignments: dict[str, AlignmentDict] = {} + self._alignments: dict[str, AlignmentsEntry] = {} self._missing_count = 0 self._seen: set[str] = set() self._ready = False @@ -434,7 +434,7 @@ def _set_skip_list(self) -> None: if f in existing) if self._skip_frames else set() skip_faces = ( set(i for i, f in enumerate(file_names) - if self._alignments.get(f, {}).get("faces")) # type:ignore[call-overload] + if f in self._alignments and self._alignments[f].faces) if self._skip_faces else set() ) skip_exist = skip_frames.union(skip_faces) @@ -475,8 +475,7 @@ def _get_detected_faces(self, file_path: str) -> list[DetectedFace] | None: logger.verbose( # type:ignore[attr-defined] "Adding frame with no detections as does not exist in import file: '%s'", fname) return [] - retval = [DetectedFace().from_alignment(a) - for a in self._alignments[fname].get("faces", [])] + retval = [DetectedFace().from_alignment(a) for a in self._alignments[fname].faces] logger.trace( # type:ignore[attr-defined] "[Extract.Loader] importing %s faces for file '%s'", len(retval), fname) return retval @@ -511,7 +510,7 @@ def _load(self) -> None: self._finalize() logger.debug("[Extract.Loader] end") - def start(self, alignments: dict[str, AlignmentDict]) -> None: + def start(self, alignments: dict[str, AlignmentsEntry]) -> None: """ Set the skip list and start loading images from disk Parameters @@ -944,10 +943,9 @@ def _process_faces(self, media: FrameFaces, alignments: Alignments, is_video: bo media.image_size, alignments.version, is_video) - alignments_faces = T.cast(list["AlignmentFileDict"], - [asdict(AlignmentsFace(**aln.__dict__, thumb=thumb.tolist())) - for aln, thumb in zip(meta, thumbnails)]) - alignments.data[basename] = {"faces": alignments_faces, "video_meta": {}} + alignments_faces = [FileAlignments(**aln.__dict__, thumb=thumb) + for aln, thumb in zip(meta, thumbnails)] + alignments.data[basename] = AlignmentsEntry(faces=alignments_faces) faces_count = len(media) if faces_count == 0: logger.verbose("No faces were detected in image: %s", basename) # type: ignore diff --git a/scripts/fs_media.py b/scripts/fs_media.py index 36ffe91a47..9fa09f8d0d 100644 --- a/scripts/fs_media.py +++ b/scripts/fs_media.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Helper functions for :mod:`~scripts.extract` and :mod:`~scripts.convert`. +"""Helper functions for :mod:`~scripts.extract` and :mod:`~scripts.convert`. Holds the classes for the 2 main Faceswap 'media' objects: Images and Alignments. @@ -14,26 +14,24 @@ import numpy as np from lib.align import Alignments as AlignmentsBase +from lib.align.objects import AlignmentsEntry, FileAlignments from lib.logger import parse_class_init from lib.serializer import get_serializer from lib.utils import get_module_objects -if T.TYPE_CHECKING: - from lib.align.alignments import AlignmentFileDict - logger = logging.getLogger(__name__) def finalize(images_found: int, num_faces_detected: int, verify_output: bool) -> None: - """ Output summary statistics at the end of the extract or convert processes. + """Output summary statistics at the end of the extract or convert processes. Parameters ---------- - images_found: int + images_found The number of images/frames that were processed - num_faces_detected: int + num_faces_detected The number of faces that have been detected - verify_output: bool + verify_output ``True`` if multiple faces were detected in frames otherwise ``False``. """ logger.info("-------------------------") @@ -172,9 +170,8 @@ def _load(self) -> dict[str, T.Any]: Returns ------- - dict - Any alignments that have already been extracted if skip existing has been selected - otherwise an empty dictionary + Any alignments that have already been extracted if skip existing has been selected + otherwise an empty dictionary """ data: dict[str, T.Any] = {} if not self._is_extract and not self.have_alignments_file: @@ -218,7 +215,7 @@ def _import_from_json(self) -> None: json_file, self._io.file) data = get_serializer("json").load(json_file) for k, v in data.items(): - faces: list[AlignmentFileDict] = [] + faces: list[FileAlignments] = [] for face in v: if "detected" not in face: lms = np.array(face["landmarks_2d"], dtype="float32") @@ -228,15 +225,15 @@ def _import_from_json(self) -> None: mins = np.rint(lms.min(axis=0)).astype(np.int32).tolist() maxes = np.rint(lms.max(axis=0)).astype(np.int32).tolist() face["detected"] = mins + maxes - faces.append(T.cast("AlignmentFileDict", { - "x": face["detected"][0], - "y": face["detected"][1], - "w": face["detected"][2] - face["detected"][0], - "h": face["detected"][3] - face["detected"][1], - "landmarks_xy": np.array(face["landmarks_2d"], dtype="float32"), - "mask": {}, - "identity": {}})) - self._data[k] = {"faces": faces, "video_meta": {}} + faces.append(FileAlignments( + x=face["detected"][0], + y=face["detected"][1], + w=face["detected"][2] - face["detected"][0], + h=face["detected"][3] - face["detected"][1], + landmarks_xy=np.array(face["landmarks_2d"], dtype="float32"), + mask={}, + identity={})) + self._data[k] = AlignmentsEntry(faces=faces) logger.info("Imported %s frames from '%s'", len(data), json_file) diff --git a/tests/lib/training/cache_test.py b/tests/lib/training/cache_test.py index 6dee7817f5..7a0a0da946 100644 --- a/tests/lib/training/cache_test.py +++ b/tests/lib/training/cache_test.py @@ -110,8 +110,8 @@ def test_MaskProcessing_init(size, status: str, mocker: pytest_mock.MockerFixture) -> None: """ Test cache._MaskProcessing correctly initializes """ - mock_maskconfig = mocker.MagicMock() - mocker.patch(f"{MODULE_PREFIX}._MaskConfig", new=mock_maskconfig) + mock_mask_config = mocker.MagicMock() + mocker.patch(f"{MODULE_PREFIX}._MaskConfig", new=mock_mask_config) if not status == "pass": with pytest.raises(AssertionError): @@ -132,7 +132,7 @@ def test_MaskProcessing_init(size, assert instance._size == size assert instance._coverage == coverage assert instance._centering == centering - mock_maskconfig.assert_called_once() + mock_mask_config.assert_called_once() def test_MaskProcessing_check_mask_exists(mocker: pytest_mock.MockerFixture) -> None: @@ -226,10 +226,10 @@ def test_MaskProcessing_crop_and_resize(mask_centering: str, # pylint:disable=t return assert retval is mock_cv2_resize_item - interp_used = mock_cv2_cubic if mask_size < size else mock_cv2_area + interpolation_used = mock_cv2_cubic if mask_size < size else mock_cv2_area mock_cv2_resize.assert_called_once_with(mock_face_mask, (size, size), - interpolation=interp_used) + interpolation=interpolation_used) @pytest.mark.parametrize("mask_type", (None, "extended", "components")) @@ -438,7 +438,7 @@ def test_Cache_cache_full(mocker: pytest_mock.MockerFixture): def test_Cache_aligned_landmarks(mocker: pytest_mock.MockerFixture): - """ Test that cache.Cache.aligned_landmarks property behaves correcly """ + """ Test that cache.Cache.aligned_landmarks property behaves correctly """ instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) instance._lock = mocker.MagicMock() for fname in _DUMMY_IMAGE_LIST: @@ -518,52 +518,48 @@ def test_Cache_reset_cache(set_flag: bool, mock_warn.assert_called_once() -@pytest.mark.parametrize("png_meta", - ({"source": {"alignments_version": 1.0}}, - {"source": {"alignments_version": 2.0}}, - {"source": {"alignments_version": 2.2}}), - ids=("v1.0", "v2.0", "v2.2")) -def test_Cache_validate_version(png_meta, mocker): +@pytest.mark.parametrize("version", (1.0, 2.0, 2.2), ids=("v1.0", "v2.0", "v2.2")) +def test_Cache_validate_version(version, mocker): """ Test that cache.Cache._validate_version executes correctly """ instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) instance._reset_cache = mocker.MagicMock() fname = "test_filename.png" - version = png_meta["source"]["alignments_version"] - + mock_meta = mocker.MagicMock() + mock_meta.source.alignments_version = version if version == 1.0: for centering in ("legacy", "face"): instance._extract_version = 0.0 instance._config.centering = centering - instance._validate_version(png_meta, fname) + instance._validate_version(mock_meta, fname) if centering == "legacy": instance._reset_cache.assert_not_called() else: instance._reset_cache.assert_called_once_with(True) assert instance._extract_version == version else: - instance._validate_version(png_meta, fname) + instance._validate_version(mock_meta, fname) instance._reset_cache.assert_not_called() assert instance._extract_version == version instance._extract_version = 1.0 # Legacy alignments have been seen if version > 1.0: # Newer alignments inbound with pytest.raises(FaceswapError): - instance._validate_version(png_meta, fname) + instance._validate_version(mock_meta, fname) else: - instance._validate_version(png_meta, fname) + instance._validate_version(mock_meta, fname) instance._extract_version = 2.0 # Newer alignments have been seen if version < 2.0: # Legacy alignments inbound with pytest.raises(FaceswapError): - instance._validate_version(png_meta, fname) + instance._validate_version(mock_meta, fname) return # Exit early on 1.0 because cannot pass any more tests - instance._validate_version(png_meta, fname) + instance._validate_version(mock_meta, fname) if version > 2.0: assert instance._extract_version == 2.0 # Defaulted to lowest version instance._extract_version = 2.5 - instance._validate_version(png_meta, fname) + instance._validate_version(mock_meta, fname) assert instance._extract_version == version # Defaulted to lowest version @@ -614,7 +610,10 @@ def test_Cache_populate_cache(partially_loaded: bool, already_cached = ["/path/to/img4.png", "/path/img5.png"] needs_cache = _DUMMY_IMAGE_LIST filenames = _DUMMY_IMAGE_LIST + already_cached - metadata = [{"alignments": f"{f}_alignments"} for f in filenames] + + mock_meta = [mocker.MagicMock() for _ in range(len(filenames))] + for meta, fname in zip(mock_meta, filenames): + meta.alignments = f"{fname}_alignments" instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) instance._validate_version = mocker.MagicMock() # type:ignore[method-assign] @@ -632,9 +631,9 @@ def test_Cache_populate_cache(partially_loaded: bool, side_effect=[mock_detected_faces[f] for f in needs_cache]) # Call the function - instance._populate_cache(needs_cache, metadata, filenames) # type:ignore[arg-type] + instance._populate_cache(needs_cache, mock_meta, filenames) # type:ignore[arg-type] - expected_validate = [mocker.call(metadata[idx], f) for idx, f in enumerate(needs_cache)] + expected_validate = [mocker.call(mock_meta[idx], f) for idx, f in enumerate(needs_cache)] instance._validate_version.assert_has_calls(expected_validate, # type:ignore[attr-defined] any_order=False) assert instance._validate_version.call_count == len(needs_cache) # type:ignore[attr-defined] @@ -702,7 +701,7 @@ def test_Cache_update_cache_full(scenario: bool, mocker: pytest_mock.MockerFixtu if scenario == "full": instance._cache = {i: i for i in range(10)} # type:ignore[misc] - if scenario == "patial": + if scenario == "partial": instance._cache = {i: i for i in range(10)} # type:ignore[misc] instance._partially_loaded = filenames.copy() @@ -771,11 +770,17 @@ def test_Cache_cache_metadata(scenario: str, mocker: pytest_mock.MockerFixture) @pytest.mark.parametrize("scenario", ("fail-meta", "fail-landmarks", "success")) -def test_Cache_pre_fill(scenario: str, mocker: pytest_mock.MockerFixture) -> None: +def test_Cache_pre_fill(scenario: str, + mocker: pytest_mock.MockerFixture, + monkeypatch: pytest.MonkeyPatch) -> None: """ Test that cache.Cache.prefill executes correctly """ filenames = _DUMMY_IMAGE_LIST.copy() mock_read_image_batch = mocker.patch(f"{MODULE_PREFIX}.read_image_meta_batch") side_effect_read_image_batch = [(f, {}) for f in filenames] # type:ignore[var-annotated] + + png_mock = mocker.MagicMock() + monkeypatch.setattr("lib.training.cache.PNGHeader.from_dict", lambda x: png_mock) + if scenario != "fail-meta": # Set successful return data for effect in side_effect_read_image_batch: effect[1]["itxt"] = {"alignments": [1, 2, 3]} @@ -801,11 +806,10 @@ def test_Cache_pre_fill(scenario: str, mocker: pytest_mock.MockerFixture) -> Non instance._validate_version.assert_not_called() # type:ignore[attr-defined] instance._load_detected_face.assert_not_called() # type:ignore[attr-defined] else: - meta = side_effect_read_image_batch[0][1]["itxt"] instance._validate_version.assert_called_once_with( # type:ignore[attr-defined] - meta, filenames[0]) + png_mock, filenames[0]) instance._load_detected_face.assert_called_once_with( # type:ignore[attr-defined] - filenames[0], meta["alignments"]) + filenames[0], png_mock.alignments) return # success @@ -814,16 +818,11 @@ def test_Cache_pre_fill(scenario: str, mocker: pytest_mock.MockerFixture) -> Non instance._lock.__exit__.assert_called_once() # type:ignore[attr-defined] mock_read_image_batch.assert_called_once_with(filenames) - fname_calls = [x[0] for x in side_effect_read_image_batch] - meta_calls = [x[1]["itxt"] for x in side_effect_read_image_batch] - call_validate = [mocker.call(l, f) for f, l in zip(fname_calls, meta_calls)] - call_det_face = [mocker.call(f, l["alignments"]) for f, l in zip(fname_calls, meta_calls)] - instance._validate_version.assert_has_calls( # type:ignore[attr-defined] - call_validate, any_order=False) # type:ignore[attr-defined] + png_mock, any_order=False) # type:ignore[attr-defined] assert instance._validate_version.call_count == len(filenames) # type:ignore[attr-defined] instance._load_detected_face.assert_has_calls( # type:ignore[attr-defined] - call_det_face, any_order=False) # type:ignore[attr-defined] + png_mock.alignments, any_order=False) # type:ignore[attr-defined] assert instance._load_detected_face.call_count == len(filenames) # type:ignore[attr-defined] assert instance._cache == {os.path.basename(f): d for f, d in zip(filenames, diff --git a/tests/tools/alignments/media_test.py b/tests/tools/alignments/media_test.py index 4eb7cec6ba..40d2b85eed 100644 --- a/tests/tools/alignments/media_test.py +++ b/tests/tools/alignments/media_test.py @@ -42,8 +42,8 @@ def alignments_file(self, tmp_path: str) -> Generator[str, None, None]: Path to a dummy alignments file """ alignments_file = os.path.join(tmp_path, "alignments.fsa") - with open(alignments_file, "w", encoding="utf8") as afile: - afile.write("test") + with open(alignments_file, "w", encoding="utf8") as a_file: + a_file.write("test") yield alignments_file os.remove(alignments_file) @@ -272,7 +272,7 @@ def test_load_video_frame(self, vid_cap.set.assert_called_once() np.testing.assert_equal(output, expected) - # TODO remove the next line that supresses a weird pytest bug when it tears down the tempdir + # TODO remove the next line that suppresses a weird pytest bug when it tears down the tempdir @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") def test_stream(self, media_loader_instance: MediaLoader, @@ -303,7 +303,7 @@ def test_stream(self, assert output == expected skip_call.assert_called_once_with(skip_list) - # TODO remove the next line that supresses a weird pytest bug when it tears down the tempdir + # TODO remove the next line that suppresses a weird pytest bug when it tears down the tempdir @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") def test_save_image(self, media_loader_instance: MediaLoader, @@ -386,7 +386,8 @@ def test_init(self, Faces(folder, alignments_mock) parent_mock.assert_called_once() - def test__handle_duplicate(self, faces_instance: Faces) -> None: + def test__handle_duplicate(self, faces_instance: Faces, mocker: pytest_mock.MockerFixture + ) -> None: """ Test for :class:`~tools.alignments.media.Faces` _handle_duplicate method Parameters @@ -399,8 +400,9 @@ def test__handle_duplicate(self, faces_instance: Faces) -> None: src_filename = "test_0001.png" src_face_idx = 0 paths = [os.path.join(faces.folder, fname) for fname in os.listdir(faces.folder)] - data = {"source": {"source_filename": src_filename, - "face_index": src_face_idx}} + data = mocker.MagicMock() + data.source.source_filename = src_filename + data.source.face_index = src_face_idx seen: dict[str, list[int]] = {} # New item @@ -423,7 +425,8 @@ def test__handle_duplicate(self, faces_instance: Faces) -> None: def test_process_folder(self, faces_instance: Faces, - mocker: pytest_mock.MockerFixture) -> None: + mocker: pytest_mock.MockerFixture, + monkeypatch: pytest.MonkeyPatch) -> None: """ Test for :class:`~tools.alignments.media.Faces` process_folder method Parameters @@ -436,8 +439,13 @@ def test_process_folder(self, faces = faces_instance read_image_meta_mock = mocker.patch("tools.alignments.media.read_image_meta_batch") img_sources = [os.path.join(faces.folder, fname) for fname in os.listdir(faces.folder)] + meta_data = {"itxt": {"source": ({"source_filename": "data.png"})}} - expected = [(fname, meta_data["itxt"]) for fname in os.listdir(faces.folder)] + png_mock = mocker.MagicMock() + png_mock.source.source_filename = "data.png" + monkeypatch.setattr("lib.training.cache.PNGHeader.from_dict", lambda x: png_mock) + + expected = [(fname, png_mock) for fname in os.listdir(faces.folder)] read_image_meta_mock.side_effect = [[(src, meta_data) for src in img_sources]] dupe_mock = mocker.patch("tools.alignments.media.Faces._handle_duplicate", @@ -452,7 +460,7 @@ def test_process_folder(self, dupe_mock.reset_mock() read_image_meta_mock.reset_mock() - # valid itxt with alignemnts data + # valid itxt with alignments data read_image_meta_mock.side_effect = [[(src, meta_data) for src in img_sources]] faces._alignments = mocker.MagicMock(AlignmentData) faces._alignments.version = 2.1 # type:ignore @@ -473,7 +481,8 @@ def test_process_folder(self, assert not output def test_load_items(self, - faces_instance: Faces) -> None: + faces_instance: Faces, + mocker: pytest_mock.MockerFixture) -> None: """ Test for :class:`~tools.alignments.media.Faces` load_items method Parameters @@ -482,17 +491,25 @@ def test_load_items(self, The class instance for testing """ faces = faces_instance - data = [(f"file{idx}.png", {"source": {"source_filename": f"src{idx}.png", - "face_index": 0}}) - for idx in range(4)] + data = [] + for idx in range(4): + mock = mocker.MagicMock() + mock.source.source_filename = f"src{idx}.png" + mock.source.face_index = 0 + data.append((f"file{idx}.png", mock)) + faces.file_list_sorted = data # type: ignore expected = {"src0.png": [0], "src1.png": [0], "src2.png": [0], "src3.png": [0]} result = faces.load_items() assert result == expected - data = [(f"file{idx}.png", {"source": {"source_filename": f"src{idx // 2}.png", - "face_index": 0 if idx % 2 == 0 else 1}}) - for idx in range(4)] + data = [] + for idx in range(4): + mock = mocker.MagicMock() + mock.source.source_filename = f"src{idx // 2}.png" + mock.source.face_index = 0 if idx % 2 == 0 else 1 + data.append((f"file{idx}.png", mock)) + faces.file_list_sorted = data # type: ignore expected = {"src0.png": [0, 1], "src1.png": [0, 1]} result = faces.load_items() @@ -712,7 +729,7 @@ def test_get_faces(self, assert extract_face_mock.call_count == 1 assert faces.current_frame == frame - # TODO remove the next line that supresses a weird pytest bug when it tears down the tempdir + # TODO remove the next line that suppresses a weird pytest bug when it tears down the tempdir @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") def test_extract_one_face(self, extracted_faces_instance: ExtractedFaces, diff --git a/tests/tools/preview/viewer_test.py b/tests/tools/preview/viewer_test.py index e44b3f42cd..f8dd9d6461 100644 --- a/tests/tools/preview/viewer_test.py +++ b/tests/tools/preview/viewer_test.py @@ -116,7 +116,7 @@ def test_set_display_dimensions(self) -> None: f_display.set_display_dimensions(dimensions) assert f_display._display_dims == dimensions - # TODO remove the next line that supresses a weird pytest bug when it tears down the tempdir + # TODO remove the next line that suppresses a weird pytest bug when it tears down the tempdir @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") @pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS) def test_update_tk_image(self, @@ -286,7 +286,6 @@ def test__crop_source_faces(self, dtype=np.uint8)) monkeypatch.setattr("tools.preview.viewer.transform_image", transform_image_mock) - mats = np.random.random((columns, 2, 3)).astype(np.float32) f_display.source = [mocker.MagicMock() for _ in range(columns)] for idx, mock in enumerate(f_display.source): @@ -331,7 +330,6 @@ def test__crop_destination_faces(self, dtype=np.uint8)) monkeypatch.setattr("tools.preview.viewer.transform_image", transform_image_mock) - f_display.source = [mocker.MagicMock() for _ in range(columns)] for item in f_display.source: # type ignore item.inbound.image = np.random.rand(1280, 720, 3) # type:ignore diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index 9915d426fe..3e6a1a8720 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Tools for manipulating the alignments serialized file """ +"""Tools for manipulating the alignments serialized file """ from __future__ import annotations import logging import os @@ -23,20 +23,20 @@ if T.TYPE_CHECKING: from collections.abc import Generator from argparse import Namespace - from lib.align.alignments import AlignmentFileDict, PNGHeaderDict + from lib.align.objects import FileAlignments, PNGHeader from .media import AlignmentData logger = logging.getLogger(__name__) class Check: - """ Frames and faces checking tasks. + """Frames and faces checking tasks. Parameters --------- - alignments : :class:`tools.alignments.media.AlignmentsData` + alignments The loaded alignments corresponding to the frames to be annotated - arguments : :class:`argparse.Namespace` + arguments The command line arguments that have called this job """ def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: @@ -54,17 +54,16 @@ def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: logger.debug("Initialized %s", self.__class__.__name__) def _get_source_dir(self, arguments: Namespace) -> str: - """ Set the correct source folder + """Set the correct source folder Parameters ---------- - arguments : :class:`argparse.Namespace` + arguments The command line arguments for the Alignments tool Returns ------- - str - Full path to the source folder + Full path to the source folder """ if (hasattr(arguments, "faces_dir") and arguments.faces_dir and hasattr(arguments, "frames_dir") and arguments.frames_dir): @@ -82,30 +81,29 @@ def _get_source_dir(self, arguments: Namespace) -> str: logger.debug("type: '%s', source_dir: '%s'", self._type, source_dir) return source_dir - def _get_items(self) -> list[dict[str, str]] | list[tuple[str, PNGHeaderDict]]: - """ Set the correct items to process + def _get_items(self) -> list[dict[str, str]] | list[tuple[str, PNGHeader]]: + """Set the correct items to process Returns ------- - list[dict[str, str]] | list[tuple[str, :class:`~lib.align.alignments.PNGHeaderDict`]] - Sorted list of dictionaries for either faces or frames. If faces the dictionaries - have the current filename as key, with the header source data as value. If frames - the dictionaries will contain the keys 'frame_fullname', 'frame_name', 'extension'. + Sorted list of dictionaries for either faces or frames. If faces the dictionaries + have the current filename as key, with the header source data as value. If frames + the dictionaries will contain the keys 'frame_fullname', 'frame_name', 'extension'. """ assert self._type is not None items: Frames | Faces = globals()[self._type.title()](self._source_dir) self._is_video = items.is_video - return T.cast(list[dict[str, str]] | list[tuple[str, "PNGHeaderDict"]], + return T.cast(list[dict[str, str]] | list[tuple[str, "PNGHeader"]], items.file_list_sorted) def process(self) -> None: - """ Process the frames check against the alignments file """ + """Process the frames check against the alignments file """ assert self._type is not None logger.info("[CHECK %s]", self._type.upper()) items_output = self._compile_output() if self._type == "faces": - filelist = T.cast(list[tuple[str, "PNGHeaderDict"]], self._items) + filelist = T.cast(list[tuple[str, "PNGHeader"]], self._items) check_update = FaceToFile(self._alignments, [val[1] for val in filelist]) if check_update(): self._alignments.save() @@ -113,7 +111,7 @@ def process(self) -> None: self._output_results(items_output) def _validate(self) -> None: - """ Check that the selected type is valid for selected task and job """ + """Check that the selected type is valid for selected task and job""" if self._job == "missing-frames" and self._output == "move": logger.warning("Missing_frames was selected with move output, but there will " "be nothing to move. Defaulting to output: console") @@ -124,12 +122,11 @@ def _validate(self) -> None: sys.exit(1) def _compile_output(self) -> list[str] | list[tuple[str, int]]: - """ Compile list of frames that meet criteria + """Compile list of frames that meet criteria Returns ------- - list[str] | list[tuple[str, int]] - List of filenames or filenames and face indices for the selected criteria + List of filenames or filenames and face indices for the selected criteria """ action = self._job.replace("-", "_") processor = getattr(self, f"_get_{action}") @@ -137,12 +134,11 @@ def _compile_output(self) -> list[str] | list[tuple[str, int]]: return [item for item in processor()] # pylint:disable=unnecessary-comprehension def _get_no_faces(self) -> Generator[str, None, None]: - """ yield each frame that has no face match in alignments file + """yield each frame that has no face match in alignments file Yields ------ - str - The frame name of any frames which have no faces + The frame name of any frames which have no faces """ self.output_message = "Frames with no faces" for frame in tqdm(T.cast(list[dict[str, str]], self._items), @@ -156,23 +152,21 @@ def _get_no_faces(self) -> Generator[str, None, None]: def _get_multi_faces(self) -> (Generator[str, None, None] | Generator[tuple[str, int], None, None]): - """ yield each frame or face that has multiple faces matched in alignments file + """yield each frame or face that has multiple faces matched in alignments file Yields ------ - str | tuple - The frame name of any frames which have multiple faces and potentially the face id + The frame name of any frames which have multiple faces and potentially the face id """ process_type = getattr(self, f"_get_multi_faces_{self._type}") yield from process_type() def _get_multi_faces_frames(self) -> Generator[str, None, None]: - """ Return Frames that contain multiple faces + """Return Frames that contain multiple faces Yields ------ - str - The frame name of any frames which have multiple faces + The frame name of any frames which have multiple faces """ self.output_message = "Frames with multiple faces" for item in tqdm(T.cast(list[dict[str, str]], self._items), @@ -185,35 +179,33 @@ def _get_multi_faces_frames(self) -> Generator[str, None, None]: yield filename def _get_multi_faces_faces(self) -> Generator[tuple[str, int], None, None]: - """ Return Faces when there are multiple faces in a frame + """Return Faces when there are multiple faces in a frame Yields ------ - tuple[str, int] - The frame name and the face id of any frames which have multiple faces + The frame name and the face id of any frames which have multiple faces """ self.output_message = "Multiple faces in frame" - for item in tqdm(T.cast(list[tuple[str, "PNGHeaderDict"]], self._items), + for item in tqdm(T.cast(list[tuple[str, "PNGHeader"]], self._items), desc=self.output_message, leave=False): - src = item[1]["source"] - if not self._alignments.frame_has_multiple_faces(src["source_filename"]): + src = item[1].source + if not self._alignments.frame_has_multiple_faces(src.source_filename): continue - retval = (item[0], src["face_index"]) + retval = (item[0], src.face_index) logger.trace("Returning: '%s'", retval) # type:ignore yield retval def _get_missing_alignments(self) -> Generator[str, None, None]: - """ yield each frame that does not exist in alignments file + """yield each frame that does not exist in alignments file Yields ------ - str - The frame name of any frames missing alignments + The frame name of any frames missing alignments """ self.output_message = "Frames missing from alignments file" exclude_filetypes = set(["yaml", "yml", "p", "json", "txt"]) - for frame in tqdm(T.cast(dict[str, str], self._items), + for frame in tqdm(T.cast(list[dict[str, str]], self._items), desc=self.output_message, leave=False): frame_name = frame["frame_fullname"] @@ -223,12 +215,11 @@ def _get_missing_alignments(self) -> Generator[str, None, None]: yield frame_name def _get_missing_frames(self) -> Generator[str, None, None]: - """ yield each frame in alignments that does not have a matching file + """yield each frame in alignments that does not have a matching file Yields ------ - str - The frame name of any frames in alignments with no matching file + The frame name of any frames in alignments with no matching file """ self.output_message = "Missing frames that are in alignments file" frames = set(item["frame_fullname"] for item in T.cast(list[dict[str, str]], self._items)) @@ -238,11 +229,11 @@ def _get_missing_frames(self) -> Generator[str, None, None]: yield frame def _output_results(self, items_output: list[str] | list[tuple[str, int]]) -> None: - """ Output the results in the requested format + """Output the results in the requested format Parameters ---------- - items_output : list[str] + items_output The list of frame names, and potentially face ids, of any items which met the selection criteria """ @@ -273,38 +264,36 @@ def _output_results(self, items_output: list[str] | list[tuple[str, int]]) -> No self.output_file(output_message, len(final_output)) def _get_output_folder(self) -> str: - """ Return output folder. Needs to be in the root if input is a video and processing + """Return output folder. Needs to be in the root if input is a video and processing frames Returns ------- - str - Full path to the output folder + Full path to the output folder """ if self._is_video and self._type == "frames": return os.path.dirname(self._source_dir) return self._source_dir def _get_filename_prefix(self) -> str: - """ Video name needs to be prefixed to filename if input is a video and processing frames + """Video name needs to be prefixed to filename if input is a video and processing frames Returns ------- - str - The common filename prefix to use + The common filename prefix to use """ if self._is_video and self._type == "frames": return f"{os.path.basename(self._source_dir)}_" return "" def output_file(self, output_message: str, items_discovered: int) -> None: - """ Save the output to a text file in the frames directory + """Save the output to a text file in the frames directory Parameters ---------- - output_message : str + output_message The message to write out to file - items_discovered : int + items_discovered The number of items which matched the criteria """ now = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -317,11 +306,11 @@ def output_file(self, output_message: str, items_discovered: int) -> None: f_output.write(output_message) def _move_file(self, items_output: list[str] | list[tuple[str, int]]) -> None: - """ Move the identified frames to a new sub folder + """Move the identified frames to a new sub folder Parameters ---------- - items_output : list[str] | list[tuple[str, int]] + items_output List of items to move """ now = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -336,13 +325,13 @@ def _move_file(self, items_output: list[str] | list[tuple[str, int]]) -> None: move(output_folder, items_output) def _move_frames(self, output_folder: str, items_output: list[str]) -> None: - """ Move frames into single sub folder + """Move frames into single sub folder Parameters ---------- - output_folder : str + output_folder The folder to move the output to - items_output : list + items_output List of items to move """ logger.info("Moving %s frame(s) to '%s'", len(items_output), output_folder) @@ -353,13 +342,13 @@ def _move_frames(self, output_folder: str, items_output: list[str]) -> None: os.rename(src, dst) def _move_faces(self, output_folder: str, items_output: list[tuple[str, int]]) -> None: - """ Make additional sub folders for each face that appears Enables easier manual sorting + """Make additional sub folders for each face that appears Enables easier manual sorting Parameters ---------- - output_folder : str + output_folder The folder to move the output to - items_output : list + items_output List of items and face indices to move """ logger.info("Moving %s faces(s) to '%s'", len(items_output), output_folder) @@ -375,13 +364,13 @@ def _move_faces(self, output_folder: str, items_output: list[tuple[str, int]]) - class Export: - """ Export alignments from a Faceswap .fsa file to a json formatted file. + """Export alignments from a Faceswap .fsa file to a json formatted file. Parameters ---------- - alignments : :class:`tools.lib_alignments.media.AlignmentData` + alignments The alignments data loaded from an alignments file for this rename job - arguments : :class:`argparse.Namespace` + arguments The :mod:`argparse` arguments as passed in from :mod:`tools.py`. Unused """ def __init__(self, @@ -394,13 +383,12 @@ def __init__(self, logger.debug("Initialized %s", self.__class__.__name__) def _get_output_file(self) -> str: - """ Obtain the name of an output file. If a file of the request name exists, then append a + """Obtain the name of an output file. If a file of the request name exists, then append a digit to the end until a unique filename is found Returns ------- - str - Full path to an output json file + Full path to an output json file """ in_file = self._alignments.file base_filename = f"{os.path.splitext(in_file)[0]}" @@ -416,47 +404,46 @@ def _get_output_file(self) -> str: return out_file @classmethod - def _format_face(cls, face: AlignmentFileDict) -> dict[str, list[int] | list[list[float]]]: - """ Format the relevant keys from an alignment file's face into the correct format for + def _format_face(cls, face: FileAlignments) -> dict[str, list[int] | list[list[float]]]: + """Format the relevant keys from an alignment file's face into the correct format for export/import Parameters ---------- - face : :class:`~lib.align.alignments.AlignmentFileDict` + face The alignment dictionary for a face to process Returns ------- - dict[str, list[int] | list[list[float]]] - The face formatted for exporting to a json file + The face formatted for exporting to a json file """ - lms = face["landmarks_xy"] + lms = face.landmarks_xy assert isinstance(lms, list) - box = [int(round(face["x"], 0)), - int(round(face["y"], 0)), - int(round(face["x"] + face["w"], 0)), - int(round(face["y"] + face["h"], 0))] + box = [int(round(face.x, 0)), + int(round(face.y, 0)), + int(round(face.x + face.w, 0)), + int(round(face.y + face.h, 0))] retval = T.cast(dict[str, list[int] | list[list[float]]], {"detected": box, "landmarks_2d": lms}) return retval def process(self) -> None: - """ Parse the imported alignments file and output relevant information to a json file """ + """Parse the imported alignments file and output relevant information to a json file""" logger.info("[EXPORTING ALIGNMENTS]") # Tidy up cli output - formatted = {key: [self._format_face(face) for face in val["faces"]] + formatted = {key: [self._format_face(face) for face in val.faces] for key, val in self._alignments.data.items()} logger.info("Saving export alignments to '%s'...", self._output_file) self._serializer.save(self._output_file, formatted) class Sort: - """ Sort alignments' index by the order they appear in an image in left to right order. + """Sort alignments' index by the order they appear in an image in left to right order. Parameters ---------- - alignments : :class:`tools.lib_alignments.media.AlignmentData` + alignments The alignments data loaded from an alignments file for this rename job - arguments : :class:`argparse.Namespace` + arguments The :mod:`argparse` arguments as passed in from :mod:`tools.py`. Unused """ def __init__(self, @@ -467,7 +454,7 @@ def __init__(self, logger.debug("Initialized %s", self.__class__.__name__) def process(self) -> None: - """ Execute the sort process """ + """Execute the sort process""" logger.info("[SORT INDEXES]") # Tidy up cli output reindexed = self.reindex_faces() if reindexed: @@ -476,12 +463,11 @@ def process(self) -> None: "processed then you should run the 'Extract' job to regenerate it.") def reindex_faces(self) -> int: - """ Re-Index the faces + """Re-Index the faces Returns ------- - int - The count of re-indexed faces + The count of re-indexed faces """ reindexed = 0 for alignment in tqdm(self._alignments.yield_faces(), @@ -492,26 +478,26 @@ def reindex_faces(self) -> int: if count <= 1: logger.trace("0 or 1 face in frame. Not sorting: '%s'", frame) # type:ignore continue - sorted_alignments = sorted(alignments, key=lambda x: (x["x"])) + sorted_alignments = sorted(alignments, key=lambda a: (a.x)) if sorted_alignments == alignments: logger.trace("Alignments already in correct order. Not " # type:ignore "sorting: '%s'", frame) continue logger.trace("Sorting alignments for frame: '%s'", frame) # type:ignore - self._alignments.data[key]["faces"] = sorted_alignments + self._alignments.data[key].faces = sorted_alignments reindexed += 1 logger.info("%s Frames had their faces reindexed", reindexed) return reindexed class Spatial: - """ Apply spatial temporal filtering to landmarks + """Apply spatial temporal filtering to landmarks Parameters ---------- - alignments : :class:`tools.lib_alignments.media.AlignmentData` + alignments The alignments data loaded from an alignments file for this rename job - arguments : :class:`argparse.Namespace` + arguments The :mod:`argparse` arguments as passed in from :mod:`tools.py` Reference @@ -528,7 +514,7 @@ def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: logger.debug("Initialized %s", self.__class__.__name__) def process(self) -> None: - """ Perform spatial filtering """ + """Perform spatial filtering """ logger.info("[SPATIAL-TEMPORAL FILTERING]") # Tidy up cli output logger.info("NB: The process only processes the alignments for the first " "face it finds for any given frame. For best results only run this when " @@ -548,20 +534,20 @@ def process(self) -> None: @staticmethod def _normalize_shapes(shapes_im_coords: np.ndarray ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """ Normalize a 2D or 3D shape + """Normalize a 2D or 3D shape Parameters ---------- - shaped_im_coords : :class:`numpy.ndarray` + shaped_im_coords The facial landmarks Returns ------- - shapes_normalized : :class:`numpy.ndarray` + shapes_normalized The normalized shapes - scale_factors : :class:`numpy.ndarray` + scale_factors The scale factors - mean_coords : :class:`numpy.ndarray` + mean_coords The mean coordinates """ logger.debug("Normalize shapes") @@ -585,21 +571,20 @@ def _normalize_shapes(shapes_im_coords: np.ndarray def _normalized_to_original(shapes_normalized: np.ndarray, scale_factors: np.ndarray, mean_coords: np.ndarray) -> np.ndarray: - """ Transform a normalized shape back to original image coordinates + """Transform a normalized shape back to original image coordinates Parameters ---------- - shapes_normalized : :class:`numpy.ndarray` + shapes_normalized The normalized shapes - scale_factors : :class:`numpy.ndarray` + scale_factors The scale factors - mean_coords : :class:`numpy.ndarray` + mean_coords The mean coordinates Returns ------- - :class:`numpy.ndarray` - The normalized shape transformed back to original coordinates + The normalized shape transformed back to original coordinates """ logger.debug("Normalize to original") (num_pts, num_dims, _) = shapes_normalized.shape @@ -613,12 +598,12 @@ def _normalized_to_original(shapes_normalized: np.ndarray, return shapes_im_coords def _normalize(self) -> None: - """ Compile all original and normalized alignments """ + """Compile all original and normalized alignments""" logger.debug("Normalize") - count = sum(1 for val in self._alignments.data.values() if val["faces"]) + count = sum(1 for val in self._alignments.data.values() if val.faces) - sample_lm = next((val["faces"][0]["landmarks_xy"] - for val in self._alignments.data.values() if val["faces"]), 68) + sample_lm = next((val.faces[0].landmarks_xy + for val in self._alignments.data.values() if val.faces), 68) assert isinstance(sample_lm, np.ndarray) lm_count = sample_lm.shape[0] if lm_count != 68: @@ -628,12 +613,12 @@ def _normalize(self) -> None: end = 0 for key in tqdm(sorted(self._alignments.data.keys()), desc="Compiling", leave=False): - val = self._alignments.data[key]["faces"] + val = self._alignments.data[key].faces if not val: continue # We should only be normalizing a single face, so just take # the first landmarks found - landmarks = np.array(val[0]["landmarks_xy"]).reshape((lm_count, 2, 1)) + landmarks = np.array(val[0].landmarks_xy).reshape((lm_count, 2, 1)) start = end end = start + landmarks.shape[2] # Store in one big array @@ -649,7 +634,7 @@ def _normalize(self) -> None: logger.debug("Normalized: %s", self._normalized) def _shape_model(self) -> None: - """ build 2D shape model """ + """build 2D shape model""" logger.debug("Shape model") landmarks_norm = self._normalized["landmarks"] num_components = 20 @@ -663,12 +648,11 @@ def _shape_model(self) -> None: logger.debug("Shaped model") def _spatially_filter(self) -> np.ndarray: - """ interpret the shapes using our shape model (project and reconstruct) + """interpret the shapes using our shape model (project and reconstruct) Returns ------- - :class:`numpy.ndarray` - The filtered landmarks in original coordinate space + The filtered landmarks in original coordinate space """ logger.debug("Spatially Filter") assert self._shapes_model is not None @@ -691,17 +675,16 @@ def _spatially_filter(self) -> np.ndarray: @staticmethod def _temporally_smooth(landmarks: np.ndarray) -> np.ndarray: - """ apply temporal filtering on the 2D points + """apply temporal filtering on the 2D points Parameters ---------- - landmarks : :class:`numpy.ndarray` + landmarks 68 point landmarks to be temporally smoothed Returns ------- - :class: `numpy.ndarray` - The temporally smoothed landmarks + The temporally smoothed landmarks """ logger.debug("Temporally Smooth") filter_half_length = 2 @@ -718,19 +701,19 @@ def _temporally_smooth(landmarks: np.ndarray) -> np.ndarray: return retval def _update_alignments(self, landmarks: np.ndarray) -> None: - """ Update smoothed landmarks back to alignments + """Update smoothed landmarks back to alignments Parameters ---------- - landmarks : :class:`numpy.ndarray` + landmarks The smoothed landmarks """ logger.debug("Update alignments") for idx, frame in tqdm(self._mappings.items(), desc="Updating", leave=False): logger.trace("Updating: (frame: %s)", frame) # type:ignore landmarks_update = landmarks[:, :, idx] - landmarks_xy = landmarks_update.reshape(68, 2).tolist() - self._alignments.data[frame]["faces"][0]["landmarks_xy"] = landmarks_xy + landmarks_xy = landmarks_update.reshape(68, 2) + self._alignments.data[frame].faces[0].landmarks_xy = landmarks_xy logger.trace("Updated: (frame: '%s', landmarks: %s)", # type:ignore frame, landmarks_xy) logger.debug("Updated alignments") diff --git a/tools/alignments/jobs_faces.py b/tools/alignments/jobs_faces.py index 0ccf90c675..3854216240 100644 --- a/tools/alignments/jobs_faces.py +++ b/tools/alignments/jobs_faces.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Tools for manipulating the alignments using extracted Faces as a source """ +"""Tools for manipulating the alignments using extracted Faces as a source """ from __future__ import annotations import logging import os @@ -8,10 +8,10 @@ from argparse import Namespace from operator import itemgetter -import numpy as np from tqdm import tqdm from lib.align import DetectedFace +from lib.align.objects import AlignmentsEntry, PNGHeader, PNGSource from lib.image import update_existing_metadata # TODO remove from lib.utils import get_module_objects from scripts.fs_media import Alignments @@ -20,20 +20,19 @@ if T.TYPE_CHECKING: from .media import AlignmentData - from lib.align.alignments import (AlignmentDict, AlignmentFileDict, - PNGHeaderDict, PNGHeaderAlignmentsDict) + from lib.align.objects import FileAlignments, PNGAlignments logger = logging.getLogger(__name__) class FromFaces(): - """ Scan a folder of Faceswap Extracted Faces and re-create the associated alignments file(s) + """Scan a folder of Faceswap Extracted Faces and re-create the associated alignments file(s) Parameters ---------- - alignments: NoneType + alignments Parameter included for standard job naming convention, but not used for this process. - arguments: :class:`argparse.Namespace` + arguments The :mod:`argparse` arguments as passed in from :mod:`tools.py` """ def __init__(self, alignments: None, arguments: Namespace) -> None: @@ -44,76 +43,73 @@ def __init__(self, alignments: None, arguments: Namespace) -> None: logger.debug("Initialized %s", self.__class__.__name__) def process(self) -> None: - """ Run the job to read faces from a folder to create alignments file(s). """ + """Run the job to read faces from a folder to create alignments file(s).""" logger.info("[CREATE ALIGNMENTS FROM FACES]") # Tidy up cli output all_versions: dict[str, list[float]] = {} - d_align: dict[str, dict[str, list[tuple[int, AlignmentFileDict, str, dict]]]] = {} - filelist = T.cast(list[tuple[str, "PNGHeaderDict"]], self._faces.file_list_sorted) + d_align: dict[str, dict[str, list[tuple[int, FileAlignments, str, PNGSource]]]] = {} + filelist = T.cast(list[tuple[str, "PNGHeader"]], self._faces.file_list_sorted) for filename, meta in tqdm(filelist, desc="Generating Alignments", total=len(filelist), leave=False): - align_fname = self._get_alignments_filename(meta["source"]) + align_fname = self._get_alignments_filename(meta.source) source_name, f_idx, alignment = self._extract_alignment(meta) - full_info = (f_idx, alignment, filename, meta["source"]) + full_info = (f_idx, alignment, filename, meta.source) d_align.setdefault(align_fname, {}).setdefault(source_name, []).append(full_info) - all_versions.setdefault(align_fname, []).append(meta["source"]["alignments_version"]) + all_versions.setdefault(align_fname, []).append(meta.source.alignments_version) versions = {k: min(v) for k, v in all_versions.items()} alignments = self._sort_alignments(d_align) self._save_alignments(alignments, versions) @classmethod - def _get_alignments_filename(cls, source_data: dict) -> str: - """ Obtain the name of the alignments file from the source information contained within the + def _get_alignments_filename(cls, source_data: PNGSource) -> str: + """Obtain the name of the alignments file from the source information contained within the PNG metadata. Parameters ---------- - source_data: dict + source_data The source information contained within a Faceswap extracted PNG Returns ------- - str: - If the face was generated from a video file, the filename will be - `'_alignments.fsa'`. If it was extracted from an image file it will be + If the face was generated from a video file, the filename will be + `'_alignments.fsa'`. If it was extracted from an image file it will be `'alignments.fsa'` """ - is_video = source_data["source_is_video"] - src_name = source_data["source_filename"] + is_video = source_data.source_is_video + src_name = source_data.source_filename prefix = f"{src_name.rpartition('_')[0]}_" if is_video else "" retval = f"{prefix}alignments.fsa" logger.trace("Extracted alignments file filename: '%s'", retval) # type:ignore return retval - def _extract_alignment(self, metadata: dict) -> tuple[str, int, AlignmentFileDict]: - """ Extract alignment data from a PNG image's itxt header. + def _extract_alignment(self, metadata: PNGHeader) -> tuple[str, int, FileAlignments]: + """Extract alignment data from a PNG image's itxt header. Formats the landmarks into a numpy array and adds in mask centering information if it is from an older extract. Parameters ---------- - metadata: dict + metadata An extracted faces PNG Header data Returns ------- - tuple - The alignment's source frame name in position 0. The index of the face within the - alignment file in position 1. The alignment data correctly formatted for writing to an - alignments file in position 2 + The alignment's source frame name in position 0. The index of the face within the alignment + file in position 1. The alignment data correctly formatted for writing to an alignments + file in position 2 """ - alignment = metadata["alignments"] - alignment["landmarks_xy"] = np.array(alignment["landmarks_xy"], dtype="float32") + alignment = T.cast("FileAlignments", metadata.alignments) - src = metadata["source"] - frame_name = src["source_filename"] - face_index = int(src["face_index"]) + src = metadata.source + frame_name = src.source_filename + face_index = src.face_index logger.trace("Extracted alignment for frame: '%s', face index: %s", # type:ignore frame_name, face_index) @@ -121,11 +117,11 @@ def _extract_alignment(self, metadata: dict) -> tuple[str, int, AlignmentFileDic def _sort_alignments(self, alignments: dict[str, dict[str, list[tuple[int, - AlignmentFileDict, + FileAlignments, str, - dict]]]] - ) -> dict[str, dict[str, AlignmentDict]]: - """ Sort the faces into face index order as they appeared in the original alignments file. + PNGSource]]]] + ) -> dict[str, dict[str, AlignmentsEntry]]: + """Sort the faces into face index order as they appeared in the original alignments file. If the face index stored in the png header does not match it's position in the alignments file (i.e. A face has been removed from a frame) then update the header of the @@ -133,28 +129,27 @@ def _sort_alignments(self, Parameters ---------- - alignments: dict + alignments The unsorted alignments file(s) as generated from the face PNG headers, including the face index of the face within it's respective frame, the original face filename and the original face header source information Returns ------- - dict - The alignments file dictionaries sorted into the correct face order, ready for saving + The alignments file dictionaries sorted into the correct face order, ready for saving """ logger.info("Sorting and checking faces...") - aln_sorted: dict[str, dict[str, AlignmentDict]] = {} + aln_sorted: dict[str, dict[str, AlignmentsEntry]] = {} for fname, frames in alignments.items(): - this_file: dict[str, AlignmentDict] = {} + this_file: dict[str, AlignmentsEntry] = {} for frame in tqdm(sorted(frames), desc=f"Sorting {fname}", leave=False): - this_file[frame] = {"video_meta": {}, "faces": []} + this_file[frame] = AlignmentsEntry(faces=[], video_meta={}) for real_idx, (f_id, aln, f_path, f_src) in enumerate(sorted(frames[frame], key=itemgetter(0))): if real_idx != f_id: full_path = os.path.join(self._faces_dir, f_path) self._update_png_header(full_path, real_idx, aln, f_src) - this_file[frame]["faces"].append(aln) + this_file[frame].faces.append(aln) aln_sorted[fname] = this_file return aln_sorted @@ -162,9 +157,9 @@ def _sort_alignments(self, def _update_png_header(cls, face_path: str, new_index: int, - alignment: AlignmentFileDict, - source_info: dict) -> None: - """ Update the PNG header for faces where the stored index does not correspond with the + alignment: FileAlignments, + source_info: PNGSource) -> None: + """Update the PNG header for faces where the stored index does not correspond with the alignments file. This can occur when frames with multiple faces have had some faces deleted from the faces folder. @@ -172,42 +167,42 @@ def _update_png_header(cls, Parameters ---------- - face_path: str + face_path Full path to the saved face image that requires updating - new_index: int + new_index The new index as it appears in the newly generated alignments file - alignment: dict + alignment The alignment information to store in the png header - source_info: dict + source_info The face source information as extracted from the original face png file """ face = DetectedFace() face.from_alignment(alignment) - new_filename = f"{os.path.splitext(source_info['source_filename'])[0]}_{new_index}.png" + new_filename = f"{os.path.splitext(source_info.source_filename)[0]}_{new_index}.png" logger.trace("Updating png header for '%s': (face index from %s to %s, " # type:ignore - "original filename from '%s' to '%s'", face_path, source_info["face_index"], - new_index, source_info["original_filename"], new_filename) + "original filename from '%s' to '%s'", face_path, source_info.face_index, + new_index, source_info.original_filename, new_filename) - source_info["face_index"] = new_index - source_info["original_filename"] = new_filename - meta = {"alignments": face.to_png_meta(), "source": source_info} + source_info.face_index = new_index + source_info.original_filename = new_filename + meta = PNGHeader(alignments=face.to_png_meta(), source=source_info) update_existing_metadata(face_path, meta) def _save_alignments(self, - all_alignments: dict[str, dict[str, AlignmentDict]], + all_alignments: dict[str, dict[str, AlignmentsEntry]], versions: dict[str, float]) -> None: - """ Save the newly generated alignments file(s). + """Save the newly generated alignments file(s). If an alignments file already exists in the source faces folder, back it up rather than overwriting Parameters ---------- - all_alignments: dict + all_alignments The alignment(s) dictionaries found in the faces folder. Alignment filename as key, corresponding alignments as value. - versions: dict + versions The minimum version number that exists in a face set for each alignments file to be generated """ @@ -217,21 +212,20 @@ def _save_alignments(self, aln = Alignments(alignments_path, "", is_extract=True) aln.update_from_dict(alignments) aln._io._version = version # pylint:disable=protected-access - aln._io.update_legacy() # pylint:disable=protected-access aln.backup() aln.save() class Rename(): - """ Rename faces in a folder to match their filename as stored in an alignments file. + """Rename faces in a folder to match their filename as stored in an alignments file. Parameters ---------- - alignments: :class:`tools.lib_alignments.media.AlignmentData` + alignments The alignments data loaded from an alignments file for this rename job - arguments: :class:`argparse.Namespace` + arguments The :mod:`argparse` arguments as passed in from :mod:`tools.py` - faces: :class:`tools.lib_alignments.media.Faces`, Optional + faces An optional faces object, if the rename task is being called by another job. Default: ``None`` """ @@ -255,36 +249,34 @@ def __init__(self, logger.debug("Initialized %s", self.__class__.__name__) def process(self) -> None: - """ Process the face renaming """ + """Process the face renaming """ logger.info("[RENAME FACES]") # Tidy up cli output - filelist = T.cast(list[tuple[str, "PNGHeaderDict"]], self._faces.file_list_sorted) - rename_mappings = sorted([(face[0], face[1]["source"]["original_filename"]) + filelist = T.cast(list[tuple[str, "PNGHeader"]], self._faces.file_list_sorted) + rename_mappings = sorted([(face[0], face[1].source.original_filename) for face in filelist - if face[0] != face[1]["source"]["original_filename"]], + if face[0] != face[1].source.original_filename], key=lambda x: x[1]) rename_count = self._rename_faces(rename_mappings) logger.info("%s faces renamed", rename_count) - filelist = T.cast(list[tuple[str, "PNGHeaderDict"]], self._faces.file_list_sorted) copy_back = FaceToFile(self._alignments, [val[1] for val in filelist]) if copy_back(): self._alignments.save() def _rename_faces(self, filename_mappings: list[tuple[str, str]]) -> int: - """ Rename faces back to their original name as exists in the alignments file. + """Rename faces back to their original name as exists in the alignments file. If the source and destination filename are the same then skip that file. Parameters ---------- - filename_mappings: list + filename_mappings List of tuples of (`source filename`, `destination filename`) ordered by destination filename Returns ------- - int - The number of faces that have been renamed + The number of faces that have been renamed """ if not filename_mappings: return 0 @@ -320,13 +312,13 @@ def _rename_faces(self, filename_mappings: list[tuple[str, str]]) -> int: class RemoveFaces(): - """ Remove items from alignments file. + """Remove items from alignments file. Parameters --------- - alignments: :class:`tools.alignments.media.AlignmentsData` + alignments The loaded alignments containing faces to be removed - arguments: :class:`argparse.Namespace` + arguments The command line arguments that have called this job """ def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: @@ -337,8 +329,8 @@ def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: logger.debug("Initialized %s", self.__class__.__name__) def process(self) -> None: - """ Run the job to remove faces from an alignments file that do not exist within a faces - folder. """ + """Run the job to remove faces from an alignments file that do not exist within a faces + folder.""" logger.info("[REMOVE FACES FROM ALIGNMENTS]") # Tidy up cli output if not self._items.items: @@ -363,7 +355,7 @@ def process(self) -> None: rename.process() def _update_png_headers(self) -> None: - """ Update the EXIF iTXt field of any face PNGs that have had their face index changed. + """Update the EXIF iTXt field of any face PNGs that have had their face index changed. Notes ----- @@ -372,16 +364,16 @@ def _update_png_headers(self) -> None: threaded for now. """ items = T.cast(dict[str, list[int]], self._items.items) - src = [(x[0], x[1]["source"]) - for x in T.cast(list[tuple[str, "PNGHeaderDict"]], self._items.file_list_sorted)] + src = [(x[0], x[1].source) + for x in T.cast(list[tuple[str, "PNGHeader"]], self._items.file_list_sorted)] to_update = [ # Items whose face index has changed x for x in src - if x[1]["face_index"] != items[x[1]["source_filename"]].index(x[1]["face_index"])] + if x[1].face_index != items[x[1].source_filename].index(x[1].face_index)] for item in tqdm(to_update, desc="Updating PNG Headers", leave=False): filename, file_info = item - frame = file_info["source_filename"] - face_index = file_info["face_index"] + frame = file_info.source_filename + face_index = file_info.face_index new_index = items[frame].index(face_index) fullpath = os.path.join(self._items.folder, filename) @@ -390,35 +382,35 @@ def _update_png_headers(self) -> None: # Update file_list_sorted for rename task orig_filename = f"{os.path.splitext(frame)[0]}_{new_index}.png" - file_info["face_index"] = new_index - file_info["original_filename"] = orig_filename + file_info.face_index = new_index + file_info.original_filename = orig_filename face = DetectedFace() face.from_alignment(self._alignments.get_faces_in_frame(frame)[new_index]) - meta = {"alignments": face.to_png_meta(), - "source": {"alignments_version": file_info["alignments_version"], - "original_filename": orig_filename, - "face_index": new_index, - "source_filename": frame, - "source_is_video": file_info["source_is_video"], - "source_frame_dims": file_info.get("source_frame_dims")}} + meta = PNGHeader(alignments=face.to_png_meta(), + source=PNGSource(alignments_version=file_info.alignments_version, + original_filename=orig_filename, + face_index=new_index, + source_filename=frame, + source_is_video=file_info.source_is_video, + source_frame_dims=file_info.source_frame_dims)) update_existing_metadata(fullpath, meta) logger.info("%s Extracted face(s) had their header information updated", len(to_update)) class FaceToFile(): - """ Updates any optional/missing keys in the alignments file with any data that has been + """Updates any optional/missing keys in the alignments file with any data that has been populated in a PNGHeader. Includes masks and identity fields. Parameters --------- - alignments: :class:`tools.alignments.media.AlignmentsData` + alignments The loaded alignments containing faces to be removed - face_data: list - List of :class:`PNGHeaderDict` objects + face_data + List of :class:`PNGHeader` objects """ - def __init__(self, alignments: AlignmentData, face_data: list[PNGHeaderDict]) -> None: + def __init__(self, alignments: AlignmentData, face_data: list[PNGHeader]) -> None: logger.debug("Initializing %s: alignments: %s, face_data: %s", self.__class__.__name__, alignments, len(face_data)) self._alignments = alignments @@ -428,19 +420,21 @@ def __init__(self, alignments: AlignmentData, face_data: list[PNGHeaderDict]) -> logger.debug("Initialized %s", self.__class__.__name__) def _check_and_update(self, - alignment: PNGHeaderAlignmentsDict, - face: AlignmentFileDict) -> None: - """ Check whether the key requires updating and update it. + alignment: PNGAlignments, + face: FileAlignments) -> None: + """Check whether the key requires updating and update it. - alignment: dict + Parameters + ---------- + alignment The alignment dictionary from the PNG Header - face: dict + face The alignment dictionary for the face from the alignments file """ for key in self._updatable_keys: if key == "mask": - exist_masks = face["mask"] - for mask_name, mask_data in alignment["mask"].items(): + exist_masks = face.mask + for mask_name, mask_data in alignment.mask.items(): if mask_name in exist_masks: continue exist_masks[mask_name] = mask_data @@ -448,33 +442,32 @@ def _check_and_update(self, self._counts[count_key] = self._counts.get(count_key, 0) + 1 continue - if not face.get(key, {}) and alignment.get(key): - face[key] = alignment[key] + if not getattr(face, key) and getattr(alignment, key): + setattr(face, key, getattr(alignment, key)) self._counts[key] = self._counts.get(key, 0) + 1 def __call__(self) -> bool: - """ Parse through the face data updating any entries in the alignments file. + """Parse through the face data updating any entries in the alignments file. Returns ------- - bool - ``True`` if any alignment information was updated otherwise ``False`` + ``True`` if any alignment information was updated otherwise ``False`` """ for meta in tqdm(self._face_alignments, desc="Updating Alignments File from PNG Header", leave=False): - src = meta["source"] - alignment = meta["alignments"] - if not any(alignment.get(key, {}) for key in self._updatable_keys): + src = meta.source + alignment = meta.alignments + if not any(hasattr(alignment, key) for key in self._updatable_keys): continue - faces = self._alignments.get_faces_in_frame(src["source_filename"]) - if len(faces) < src["face_index"] + 1: # list index out of range + faces = self._alignments.get_faces_in_frame(src.source_filename) + if len(faces) < src.face_index + 1: # list index out of range logger.debug("Skipped face '%s'. Index does not exist in alignments file", - src["original_filename"]) + src.original_filename) continue - face = faces[src["face_index"]] + face = faces[src.face_index] self._check_and_update(alignment, face) retval = False diff --git a/tools/alignments/jobs_frames.py b/tools/alignments/jobs_frames.py index 0d4448479c..064c10e5bc 100644 --- a/tools/alignments/jobs_frames.py +++ b/tools/alignments/jobs_frames.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Tools for manipulating the alignments using Frames as a source """ +"""Tools for manipulating the alignments using Frames as a source """ from __future__ import annotations import logging import os @@ -13,7 +13,7 @@ from tqdm import tqdm from lib.align import DetectedFace, LANDMARK_PARTS, LandmarkType -from lib.align.alignments import PNGHeaderDict +from lib.align.objects import PNGHeader, PNGSource from lib.image import encode_image, ImagesSaver from lib.utils import get_module_objects, deprecation_warning from .media import ExtractedFaces, Frames @@ -26,14 +26,14 @@ class Draw(): - """ Draws annotations onto original frames and saves into a sub-folder next to the original + """Draws annotations onto original frames and saves into a sub-folder next to the original frames. Parameters --------- - alignments: :class:`tools.alignments.media.AlignmentsData` + alignments The loaded alignments corresponding to the frames to be annotated - arguments: :class:`argparse.Namespace` + arguments The command line arguments that have called this job """ def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: @@ -44,16 +44,14 @@ def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: logger.debug("Initialized %s", self.__class__.__name__) def _set_output(self) -> str: - """ Set the output folder path. + """Set the output folder path. If annotating a folder of frames, output will be placed in a sub folder within the frames folder. If annotating a video, output will be a folder next to the original video. Returns ------- - str - Full path to the output folder - + Full path to the output folder """ now = datetime.now().strftime("%Y%m%d_%H%M%S") folder_name = f"drawn_landmarks_{now}" @@ -67,10 +65,12 @@ def _set_output(self) -> str: return output_folder def process(self) -> None: - """ Runs the process to draw face annotations onto original source frames. """ + """Runs the process to draw face annotations onto original source frames.""" logger.info("[DRAW LANDMARKS]") # Tidy up cli output frames_drawn = 0 - for frame in tqdm(self._frames.file_list_sorted, desc="Drawing landmarks", leave=False): + for frame in tqdm(T.cast(list[dict[str, str]], self._frames.file_list_sorted), + desc="Drawing landmarks", + leave=False): frame_name = frame["frame_fullname"] if not self._alignments.frame_exists(frame_name): @@ -82,11 +82,11 @@ def process(self) -> None: logger.info("%s Frame(s) output", frames_drawn) def _annotate_image(self, frame_name: str) -> None: - """ Annotate the frame with each face that appears in the alignments file. + """Annotate the frame with each face that appears in the alignments file. Parameters ---------- - frame_name: str + frame_name The full path to the original frame """ logger.trace("Annotating frame: '%s'", frame_name) # type:ignore @@ -106,13 +106,13 @@ def _annotate_image(self, frame_name: str) -> None: self._frames.save_image(self._output_folder, frame_name, image) def _annotate_landmarks(self, image: np.ndarray, landmarks: np.ndarray) -> None: - """ Annotate the extract boxes onto the frame. + """Annotate the extract boxes onto the frame. Parameters ---------- - image: :class:`numpy.ndarray` + image The frame that extract boxes are to be annotated on to - landmarks: :class:`numpy.ndarray` + landmarks The facial landmarks that are to be annotated onto the frame """ # Mesh @@ -124,15 +124,15 @@ def _annotate_landmarks(self, image: np.ndarray, landmarks: np.ndarray) -> None: @classmethod def _annotate_extract_boxes(cls, image: np.ndarray, face: DetectedFace, index: int) -> None: - """ Annotate the mesh and landmarks boxes onto the frame. + """Annotate the mesh and landmarks boxes onto the frame. Parameters ---------- - image: :class:`numpy.ndarray` + image The frame that mesh and landmarks are to be annotated on to - face: :class:`lib.align.DetectedFace` + face The aligned face - index: int + index The face index for the given face """ for area in T.get_args(T.Literal["face", "head"]): @@ -145,13 +145,13 @@ def _annotate_extract_boxes(cls, image: np.ndarray, face: DetectedFace, index: i @classmethod def _annotate_pose(cls, image: np.ndarray, face: DetectedFace) -> None: - """ Annotate the pose onto the frame. + """Annotate the pose onto the frame. Parameters ---------- - image: :class:`numpy.ndarray` + image The frame that pose is to be annotated on to - face: :class:`lib.align.DetectedFace` + face The aligned face loaded for head centering """ center = np.array((face.aligned.size / 2, @@ -165,13 +165,13 @@ def _annotate_pose(cls, image: np.ndarray, face: DetectedFace) -> None: class Extract(): - """ Re-extract faces from source frames based on Alignment data + """Re-extract faces from source frames based on Alignment data Parameters ---------- - alignments: :class:`tools.lib_alignments.media.AlignmentData` + alignments The alignments data loaded from an alignments file for this rename job - arguments: :class:`argparse.Namespace` + arguments The :mod:`argparse` arguments as passed in from :mod:`tools.py` """ def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: @@ -192,21 +192,20 @@ def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: @classmethod def _get_min_size(cls, extract_size: int, min_size: int) -> int: - """ Obtain the minimum size that a face has been resized from to be included as a valid + """Obtain the minimum size that a face has been resized from to be included as a valid extract. Parameters ---------- - extract_size: int + extract_size The requested size of the extracted images - min_size: int + min_size The percentage amount that has been supplied for valid faces (as a percentage of extract size) Returns ------- - int - The minimum size, in pixels, that a face is resized from to be considered valid + The minimum size, in pixels, that a face is resized from to be considered valid """ retval = 0 if min_size == 0 else max(4, int(extract_size * (min_size / 100.))) logger.debug("Extract size: %s, min percentage size: %s, min_size: %s", @@ -214,13 +213,12 @@ def _get_min_size(cls, extract_size: int, min_size: int) -> int: return retval def _get_count(self) -> int | None: - """ If the alignments file has been run through the manual tool, then it will hold video + """If the alignments file has been run through the manual tool, then it will hold video meta information, meaning that the count of frames in the alignment file can be relied on to be accurate. Returns ------- - int or ``None`` For video input which contain video meta-data in the alignments file then the count of frames is returned. In all other cases ``None`` is returned """ @@ -235,7 +233,7 @@ def _get_count(self) -> int | None: return retval def process(self) -> None: - """ Run the re-extraction from Alignments file process""" + """Run the re-extraction from Alignments file process""" logger.info("[EXTRACT FACES]") # Tidy up cli output self._check_folder() self._saver = ImagesSaver(self._faces_dir, as_bytes=True) @@ -247,7 +245,7 @@ def process(self) -> None: self._export_faces() def _check_folder(self) -> None: - """ Check that the faces folder doesn't pre-exist and create. """ + """Check that the faces folder doesn't pre-exist and create.""" err = None if not self._faces_dir: err = "ERROR: Output faces folder not provided." @@ -262,7 +260,7 @@ def _check_folder(self) -> None: logger.verbose("Creating output folder at '%s'", self._faces_dir) # type:ignore def _export_faces(self) -> None: - """ Export the faces to the output folder. """ + """Export the faces to the output folder.""" extracted_faces = 0 skip_list = self._set_skip_list() count = self._frames.count if skip_list is None else self._frames.count - len(skip_list) @@ -278,14 +276,12 @@ def _export_faces(self) -> None: logger.info("%s face(s) extracted", extracted_faces) def _set_skip_list(self) -> list[int] | None: - """ Set the indices for frames that should be skipped based on the `extract_every_n` + """Set the indices for frames that should be skipped based on the `extract_every_n` command line option. Returns ------- - list or ``None`` - A list of indices to be skipped if extract_every_n is not `1` otherwise - returns ``None`` + A list of indices to be skipped if extract_every_n is not `1` otherwise returns ``None`` """ skip_num = self._arguments.extract_every_n if skip_num == 1: @@ -301,19 +297,18 @@ def _set_skip_list(self) -> list[int] | None: return skip_list def _output_faces(self, filename: str, image: np.ndarray) -> int: - """ For each frame save out the faces + """For each frame save out the faces Parameters ---------- - filename: str + filename The filename (without the full path) of the current frame - image: :class:`numpy.ndarray` + image The full frame that faces are to be extracted from Returns ------- - int - The total number of faces that have been extracted + The total number of faces that have been extracted """ logger.trace("Outputting frame: %s", filename) # type:ignore face_count = 0 @@ -325,14 +320,14 @@ def _output_faces(self, filename: str, image: np.ndarray) -> int: for idx, face in enumerate(faces): output = f"{frame_name}_{idx}.png" - meta: PNGHeaderDict = { - "alignments": face.to_png_meta(), - "source": {"alignments_version": self._alignments.version, - "original_filename": output, - "face_index": idx, - "source_filename": filename, - "source_is_video": self._frames.is_video, - "source_frame_dims": T.cast(tuple[int, int], image.shape[:2])}} + meta = PNGHeader( + alignments=face.to_png_meta(), + source=PNGSource(alignments_version=self._alignments.version, + original_filename=output, + face_index=idx, + source_filename=filename, + source_is_video=self._frames.is_video, + source_frame_dims=tuple(image.shape[:2]))) assert face.aligned.face is not None self._saver.save(output, encode_image(face.aligned.face, ".png", metadata=meta)) face_count += 1 @@ -340,19 +335,18 @@ def _output_faces(self, filename: str, image: np.ndarray) -> int: return face_count def _select_valid_faces(self, frame: str, image: np.ndarray) -> list[DetectedFace]: - """ Return the aligned faces from a frame that meet the selection criteria, + """Return the aligned faces from a frame that meet the selection criteria, Parameters ---------- - frame: str + frame The filename (without the full path) of the current frame - image: :class:`numpy.ndarray` + image The full frame that faces are to be extracted from Returns ------- - list: - List of valid :class:`lib,align.DetectedFace` objects + List of valid :class:`lib,align.DetectedFace` objects """ faces = self._extracted_faces.get_faces_in_frame(frame, image=image) if self._min_size == 0: diff --git a/tools/alignments/media.py b/tools/alignments/media.py index 6255df8822..ce1e2eaca5 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -11,6 +11,7 @@ from tqdm import tqdm from lib.align import Alignments, DetectedFace +from lib.align.objects import PNGHeader from lib.image import (generate_thumbnail, ImagesLoader, png_write_meta, read_image, read_image_meta_batch) from lib.utils import get_module_objects, IMAGE_EXTENSIONS @@ -19,7 +20,7 @@ if T.TYPE_CHECKING: from collections.abc import Generator import numpy as np - from lib.align.alignments import AlignmentFileDict, PNGHeaderDict + from lib.align.objects import FileAlignments logger = logging.getLogger(__name__) @@ -148,12 +149,12 @@ def valid_extension(filename) -> bool: filename, retval) return retval - def sorted_items(self) -> list[dict[str, str]] | list[tuple[str, PNGHeaderDict]]: + def sorted_items(self) -> list[dict[str, str]] | list[tuple[str, PNGHeader]]: """Override for specific folder processing""" raise NotImplementedError() def process_folder(self) -> (Generator[dict[str, str], None, None] | - Generator[tuple[str, PNGHeaderDict], None, None]): + Generator[tuple[str, PNGHeader], None, None]): """Override for specific folder processing""" raise NotImplementedError() @@ -230,7 +231,7 @@ def stream(self, skip_list: list[int] | None = None def save_image(output_folder: str, filename: str, image: np.ndarray, - metadata: PNGHeaderDict | None = None) -> None: + metadata: PNGHeader | None = None) -> None: """Save an image Parameters @@ -273,7 +274,7 @@ def __init__(self, folder: str, alignments: Alignments | None = None) -> None: def _handle_duplicate(self, fullpath: str, - header_dict: PNGHeaderDict, + header_dict: PNGHeader, seen: dict[str, list[int]]) -> bool: """Check whether the given face has already been seen for the source frame and face index from an existing face. Can happen when filenames have changed due to sorting etc. and users @@ -293,8 +294,8 @@ def _handle_duplicate(self, ------- ``True`` if the face was a duplicate and has been removed, otherwise ``False`` """ - src_filename = header_dict["source"]["source_filename"] - face_index = header_dict["source"]["face_index"] + src_filename = header_dict.source.source_filename + face_index = header_dict.source.face_index if src_filename in seen and face_index in seen[src_filename]: dupe_dir = os.path.join(self.folder, "_duplicates") @@ -307,7 +308,7 @@ def _handle_duplicate(self, seen.setdefault(src_filename, []).append(face_index) return False - def process_folder(self) -> Generator[tuple[str, PNGHeaderDict], None, None]: + def process_folder(self) -> Generator[tuple[str, PNGHeader], None, None]: """Iterate through the faces folder pulling out various information for each face. Yields @@ -337,14 +338,14 @@ def process_folder(self) -> Generator[tuple[str, PNGHeaderDict], None, None]: logger.warning("Non-Faceswap extracted face found. Image skipped: '%s'", fullpath) continue - sub_dict = T.cast("PNGHeaderDict", metadata["itxt"]) + sub_dict = T.cast(PNGHeader, PNGHeader.from_dict(metadata["itxt"])) if self._handle_duplicate(fullpath, sub_dict, seen): dupe_count += 1 continue if (self._alignments is not None and # filter existing - not self._alignments.frame_exists(sub_dict["source"]["source_filename"])): + not self._alignments.frame_exists(sub_dict.source.source_filename)): filter_count += 1 continue @@ -368,13 +369,13 @@ def load_items(self) -> dict[str, list[int]]: The source filename as key with list of face indices for the frame as value """ faces: dict[str, list[int]] = {} - for face in T.cast(list[tuple[str, "PNGHeaderDict"]], self.file_list_sorted): - src = face[1]["source"] - faces.setdefault(src["source_filename"], []).append(src["face_index"]) + for face in T.cast(list[tuple[str, PNGHeader]], self.file_list_sorted): + src = face[1].source + faces.setdefault(src.source_filename, []).append(src.face_index) logger.trace(faces) # type:ignore[attr-defined] return faces - def sorted_items(self) -> list[tuple[str, PNGHeaderDict]]: + def sorted_items(self) -> list[tuple[str, PNGHeader]]: """Return the items sorted by the saved file name. Returns @@ -512,7 +513,7 @@ def get_faces(self, frame: str, image: np.ndarray | None = None) -> None: self.current_frame = frame def extract_one_face(self, - alignment: AlignmentFileDict, + alignment: FileAlignments, image: np.ndarray) -> DetectedFace: """Extract one face from image diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index 1abb558439..690d1d258b 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -14,6 +14,7 @@ import numpy as np from lib.align import Alignments, AlignedFace, DetectedFace +from lib.align.objects import PNGHeader, PNGSource from lib.gui.custom_widgets import PopupProgress from lib.gui.utils import FileHandler from lib.image import ImagesLoader, ImagesSaver, encode_image, generate_thumbnail @@ -22,7 +23,7 @@ if T.TYPE_CHECKING: from . import manual - from lib.align.alignments import AlignmentFileDict, PNGHeaderDict + from lib.align.objects import FileAlignments logger = logging.getLogger(__name__) @@ -274,7 +275,7 @@ def load(self) -> None: :class:`~lib.align.DetectedFace`. objects and add to :attr:`_frame_faces`.""" for key in sorted(self._alignments.data): this_frame_faces: list[DetectedFace] = [] - for item in self._alignments.data[key]["faces"]: + for item in self._alignments.data[key].faces: face = DetectedFace() face.from_alignment(item, with_thumb=True) face.load_aligned(None) @@ -296,7 +297,7 @@ def save(self) -> None: for idx, faces in zip(frames, np.array(self._frame_faces, dtype="object")[np.array(frames)]): frame = self._sorted_frame_names[idx] - self._alignments.data[frame]["faces"] = [face.to_alignment() for face in faces] + self._alignments.data[frame].faces = [face.to_alignment() for face in faces] self._alignments.backup() self._alignments.save() @@ -316,7 +317,7 @@ def revert_to_saved(self, frame_index: int) -> None: return logger.verbose("Reverting alignments for frame_index %s", # type:ignore[attr-defined] frame_index) - alignments = self._alignments.data[self._sorted_frame_names[frame_index]]["faces"] + alignments = self._alignments.data[self._sorted_frame_names[frame_index]].faces faces = self._frame_faces[frame_index] reset_grid = self._add_remove_faces(alignments, faces) @@ -338,7 +339,7 @@ def revert_to_saved(self, frame_index: int) -> None: @classmethod def _add_remove_faces(cls, - alignments: list[AlignmentFileDict], + alignments: list[FileAlignments], faces: list[DetectedFace]) -> bool: """On a revert, ensure that the alignments and detected face object counts for each frame are in sync. @@ -382,10 +383,10 @@ def extract(self) -> None: logger.debug(dirname) queue: Queue = Queue() - pbar = PopupProgress("Extracting Faces...", self._alignments.frames_count + 1) + p_bar = PopupProgress("Extracting Faces...", self._alignments.frames_count + 1) thread = MultiThread(self._background_extract, dirname, queue) thread.start() - self._monitor_extract(thread, queue, pbar) + self._monitor_extract(thread, queue, p_bar) def _monitor_extract(self, thread: MultiThread, @@ -417,7 +418,9 @@ def _monitor_extract(self, break progress_bar.after(100, self._monitor_extract, thread, queue, progress_bar) - def _background_extract(self, output_folder: str, progress_queue: Queue) -> None: + def _background_extract(self, # pylint:disable=too-many-locals + output_folder: str, + progress_queue: Queue) -> None: """Perform the background extraction in a thread so GUI doesn't become unresponsive. Parameters @@ -442,14 +445,14 @@ def _background_extract(self, output_folder: str, progress_queue: Queue) -> None image=image, centering="head", size=512) # TODO user selectable size - meta: PNGHeaderDict = {"alignments": face.to_png_meta(), - "source": {"alignments_version": self._alignments.version, - "original_filename": output, - "face_index": face_idx, - "source_filename": src_filename, - "source_is_video": self._globals.is_video, - "source_frame_dims": image.shape[:2]}} - + meta = PNGHeader( + alignments=face.to_png_meta(), + source=PNGSource(alignments_version=self._alignments.version, + original_filename=output, + face_index=face_idx, + source_filename=src_filename, + source_is_video=self._globals.is_video, + source_frame_dims=tuple(image.shape[:2]))) assert aligned.face is not None b_image = encode_image(aligned.face, ".png", metadata=meta) saver.save(output, b_image) @@ -507,11 +510,11 @@ def count(self) -> int: :attr:`~tools.manual.manual.TkGlobals.var_filter_mode.get()`.""" face_count_per_index = self._detected_faces.face_count_per_index if self._globals.var_filter_mode.get() == "No Faces": - retval = sum(1 for fcount in face_count_per_index if fcount == 0) + retval = sum(1 for f_count in face_count_per_index if f_count == 0) elif self._globals.var_filter_mode.get() == "Has Face(s)": - retval = sum(1 for fcount in face_count_per_index if fcount != 0) + retval = sum(1 for f_count in face_count_per_index if f_count != 0) elif self._globals.var_filter_mode.get() == "Multiple Faces": - retval = sum(1 for fcount in face_count_per_index if fcount > 1) + retval = sum(1 for f_count in face_count_per_index if f_count > 1) elif self._globals.var_filter_mode.get() == "Misaligned Faces": distance = self._filter_distance retval = sum(1 for frame in self._detected_faces.current_faces diff --git a/tools/manual/thumbnails.py b/tools/manual/thumbnails.py index c4a05bdf6f..daadebd04a 100644 --- a/tools/manual/thumbnails.py +++ b/tools/manual/thumbnails.py @@ -129,8 +129,7 @@ def _launch_video(self) -> None: if self._meta["keyframes"][0] != 0: logger.warning("Your video does not start on a Key Frame. This can lead to issues.") - frame_face_indices = [i for i, v in enumerate(self._alignments.data.values()) - if v["faces"]] + frame_face_indices = [i for i, v in enumerate(self._alignments.data.values()) if v.faces] num_frames = len(frame_face_indices) num_threads = min(num_frames, self._num_threads) window = num_frames // num_threads diff --git a/tools/mask/loader.py b/tools/mask/loader.py index abfa14cac5..cf5a8eebc6 100644 --- a/tools/mask/loader.py +++ b/tools/mask/loader.py @@ -16,7 +16,7 @@ from lib.infer.objects import FrameFaces if T.TYPE_CHECKING: - from lib.align.alignments import PNGHeaderDict + from lib.align.objects import FileAlignments, PNGAlignments, PNGHeader logger = logging.getLogger(__name__) @@ -76,7 +76,7 @@ def add_alignments(self, alignments_object: alignments.Alignments | None) -> Non def _process_face(self, filename: str, image: np.ndarray, - metadata: PNGHeaderDict) -> FrameFaces | None: + metadata: PNGHeader) -> FrameFaces | None: """Process a single face when masking from face images Parameters @@ -93,12 +93,12 @@ def _process_face(self, 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"] + frame_name = metadata.source.source_filename + face_index = metadata.source.face_index if self._alignments is None: # mask from PNG header lookup_index = 0 - aligns = [T.cast(alignments.AlignmentFileDict, metadata["alignments"])] + aligns: list[FileAlignments] | list[PNGAlignments] = [metadata.alignments] else: # mask from Alignments file lookup_index = face_index aligns = self._alignments.get_faces_in_frame(frame_name) @@ -108,7 +108,7 @@ def _process_face(self, return None alignment = aligns[lookup_index] - retval = FrameFaces(filename, image, is_aligned=True, frame_metadata=metadata["source"]) + retval = FrameFaces(filename, image, is_aligned=True, frame_metadata=metadata.source) retval.detected_faces = [DetectedFace().from_alignment(alignment)] return retval @@ -126,13 +126,6 @@ def _from_faces(self) -> T.Generator[FrameFaces, None, None]: 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 diff --git a/tools/mask/mask.py b/tools/mask/mask.py index fdbb63b14d..636012ecd5 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -20,7 +20,7 @@ from .mask_output import Output if T.TYPE_CHECKING: - from lib.align.alignments import PNGHeaderSourceDict + from lib.align.objects import PNGSource from lib.infer.objects import FrameFaces logger = logging.getLogger(__name__) @@ -250,14 +250,14 @@ def _save_output(self, media: FrameFaces) -> None: """ if self._input_is_faces: assert media.frame_metadata is not None - filename = os.path.basename(media.frame_metadata["source_filename"]) - dims = media.frame_metadata["source_frame_dims"] + filename = os.path.basename(media.frame_metadata.source_filename) + dims = media.frame_metadata.source_frame_dims else: filename = os.path.basename(media.filename) dims = None for idx, face in enumerate(media.detected_faces): - face_idx = T.cast("PNGHeaderSourceDict", - media.frame_metadata)["face_index"] if self._input_is_faces else idx + face_idx = T.cast("PNGSource", + 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) diff --git a/tools/mask/mask_generate.py b/tools/mask/mask_generate.py index 698d67b1fb..e4c8e671af 100644 --- a/tools/mask/mask_generate.py +++ b/tools/mask/mask_generate.py @@ -6,6 +6,7 @@ import os import typing as T +from lib.align.objects import PNGHeader from lib.image import encode_image, ImagesSaver from lib.logger import parse_class_init from lib.multithreading import FSThread @@ -105,8 +106,8 @@ def _feed_extractor(self, loader: loader.Loader) -> None: if self._is_faces: assert media.frame_metadata is not None assert len(media) == 1 - needs_update = self._needs_update(media.frame_metadata["source_filename"], - media.frame_metadata["face_index"], + 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 @@ -155,8 +156,8 @@ def _update_from_face(self, media: FrameFaces) -> None: assert self._saver is not None assert media.frame_metadata is not None - fname = media.frame_metadata["source_filename"] - idx = media.frame_metadata["face_index"] + fname = media.frame_metadata.source_filename + idx = media.frame_metadata.face_index face = media.detected_faces[0] if self._alignments is not None: @@ -164,8 +165,7 @@ def _update_from_face(self, media: FrameFaces) -> None: self._alignments.update_face(fname, idx, face.to_alignment()) logger.trace("Updating extracted face: '%s'", media.filename) # type:ignore[attr-defined] - meta: align.alignments.PNGHeaderDict = {"alignments": face.to_png_meta(), - "source": media.frame_metadata} + meta = PNGHeader(alignments=face.to_png_meta(), source=media.frame_metadata) self._saver.save(os.path.basename(media.filename), encode_image(media.image, ".png", metadata=meta)) @@ -185,7 +185,7 @@ def _update_from_frame(self, media: FrameFaces) -> None: self._alignments.update_face(fname, idx, face.to_alignment()) def _finalize(self) -> None: - """Close thread and save alignments on completion """ + """Close thread and save alignments on completion""" logger.debug("Finalizing MaskGenerator") self._input_thread.join() diff --git a/tools/mask/mask_import.py b/tools/mask/mask_import.py index b0b9ce581b..3c6d6aa747 100644 --- a/tools/mask/mask_import.py +++ b/tools/mask/mask_import.py @@ -12,6 +12,7 @@ from tqdm import tqdm from lib.align import AlignedFace +from lib.align.objects import PNGHeader from lib.image import encode_image, ImagesSaver from lib.utils import get_image_paths, get_module_objects @@ -316,16 +317,15 @@ def _store_mask_face(self, media: FrameFaces, mask: np.ndarray) -> None: self._store_mask(face, mask) if self._alignments is not None: - idx = media.frame_metadata["source_filename"] - fname = media.frame_metadata["face_index"] + 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: align.alignments.PNGHeaderDict = {"alignments": face.to_png_meta(), - "source": media.frame_metadata} + meta = PNGHeader(alignments=face.to_png_meta(), source=media.frame_metadata) self._saver.save(os.path.basename(media.filename), encode_image(media.image, ".png", metadata=meta)) diff --git a/tools/mask/mask_output.py b/tools/mask/mask_output.py index 139240f0af..4026eaaa44 100644 --- a/tools/mask/mask_output.py +++ b/tools/mask/mask_output.py @@ -13,7 +13,7 @@ from tqdm import tqdm from lib.align import AlignedFace -from lib.align.alignments import AlignmentDict +from lib.align.objects import AlignmentsEntry from lib.image import ImagesSaver, read_image_meta_batch from lib.utils import get_folder, get_module_objects @@ -130,15 +130,14 @@ def _get_alignments(self, return alignments logger.debug("Generating alignments from faces") - data = T.cast(dict[str, AlignmentDict], {}) + data = T.cast(dict[str, AlignmentsEntry], {}) 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) + data.setdefault(fname, AlignmentsEntry()).faces.append(aln) retval = ExtractAlignments("/dummy/alignments.fsa", "", is_extract=True) retval.update_from_dict(data) diff --git a/tools/sort/info_loader.py b/tools/sort/info_loader.py index a82f658487..c03b53cf3b 100644 --- a/tools/sort/info_loader.py +++ b/tools/sort/info_loader.py @@ -9,18 +9,19 @@ import numpy as np from tqdm import tqdm +from lib.align.objects import PNGHeader from lib.image import FacesLoader, ImagesLoader, read_image_meta_batch, update_existing_metadata from lib.utils import get_module_objects if T.TYPE_CHECKING: - from lib.align.alignments import PNGHeaderAlignmentsDict, PNGHeaderSourceDict + from lib.align.objects import PNGAlignments, PNGSource logger = logging.getLogger(__name__) ImgMetaType: T.TypeAlias = Generator[tuple[str, np.ndarray | None, - T.Union["PNGHeaderAlignmentsDict", None]], None, None] + T.Union["PNGAlignments", None]], None, None] class InfoLoader(): @@ -44,7 +45,7 @@ def __init__(self, self._iterator = None self._description = "Reading image statistics..." self._loader = ImagesLoader(input_dir) if info_type == "face" else FacesLoader(input_dir) - self.cached_source_data: dict[str, PNGHeaderSourceDict] = {} + self.cached_source_data: dict[str, PNGSource] = {} """The source data read from the PNG header for each processed face""" if self._loader.count == 0: logger.error("No images to process in location: '%s'", input_dir) @@ -83,14 +84,14 @@ def __call__(self) -> ImgMetaType: The aligned face image loaded from disk for 'face' and 'all' info_types otherwise ``None`` alignments - The alignments dict for 'all' and 'meta' infor_types otherwise ``None`` + The alignments dict for 'all' and 'meta' info_types otherwise ``None`` """ iterator = self._get_iterator() return iterator def _get_alignments(self, filename: str, - metadata: dict[str, T.Any]) -> PNGHeaderAlignmentsDict | None: + metadata: dict[str, T.Any] | PNGHeader) -> PNGAlignments | None: """Obtain the alignments from a PNG Header. The other image metadata is cached locally in case a sort method needs to write back to the @@ -107,10 +108,15 @@ def _get_alignments(self, ------- The alignments dictionary from the PNG header, if it exists, otherwise ``None`` """ + if isinstance(metadata, PNGHeader): + self.cached_source_data[filename] = metadata.source + return metadata.alignments + if not metadata or not metadata.get("alignments") or not metadata.get("source"): return None - self.cached_source_data[filename] = metadata["source"] - return metadata["alignments"] + metadata = PNGHeader.from_dict(metadata) + self.cached_source_data[filename] = metadata.source + return metadata.alignments def _metadata_reader(self) -> ImgMetaType: """Load metadata from saved aligned faces @@ -143,10 +149,12 @@ def _full_data_reader(self) -> ImgMetaType: alignments The alignment data for the given face or ``None`` if no alignments found """ - for filename, image, metadata in tqdm(self._loader.load(), - desc=self._description, - total=self._loader.count, - leave=False): + for item in tqdm(self._loader.load(), + desc=self._description, + total=self._loader.count, + leave=False): + assert len(item) == 3 + filename, image, metadata = item alignments = self._get_alignments(filename, metadata) yield filename, image, alignments @@ -162,13 +170,15 @@ def _image_data_reader(self) -> ImgMetaType: alignments Alignments will always be ``None`` with the image data reader """ - for filename, image in tqdm(self._loader.load(), - desc=self._description, - total=self._loader.count, - leave=False): + for item in tqdm(self._loader.load(), + desc=self._description, + total=self._loader.count, + leave=False): + assert len(item) == 2 + filename, image = item yield filename, image, None - def update_png_header(self, filename: str, alignments: PNGHeaderAlignmentsDict) -> None: + def update_png_header(self, filename: str, alignments: PNGAlignments) -> None: """Update the PNG header of the given file with the given alignments. NB: Header information can only be updated if the face is already on at least alignment @@ -182,12 +192,12 @@ def update_png_header(self, filename: str, alignments: PNGHeaderAlignmentsDict) alignments: dict The alignments to update into the PNG header """ - vers = self.cached_source_data[filename]["alignments_version"] + vers = self.cached_source_data[filename].alignments_version if vers < 2.2: return - self.cached_source_data[filename]["alignments_version"] = 2.3 if vers == 2.2 else vers - header = {"alignments": alignments, "source": self.cached_source_data[filename]} + self.cached_source_data[filename].alignments_version = 2.3 if vers == 2.2 else vers + header = PNGHeader(alignments=alignments, source=self.cached_source_data[filename]) update_existing_metadata(filename, header) diff --git a/tools/sort/sort_methods.py b/tools/sort/sort_methods.py index c23adca58a..5411b69890 100644 --- a/tools/sort/sort_methods.py +++ b/tools/sort/sort_methods.py @@ -25,7 +25,7 @@ if T.TYPE_CHECKING: from argparse import Namespace import numpy.typing as npt - from lib.align.alignments import PNGHeaderAlignmentsDict + from lib.align.objects import PNGAlignments from lib.infer.runner import ExtractRunner from lib.infer.handler import ExtractHandlerFace @@ -229,7 +229,7 @@ def sort(self) -> None: def score_image(self, filename: str, image: np.ndarray | None, - alignments: PNGHeaderAlignmentsDict | None) -> None: + alignments: PNGAlignments | None) -> None: """Override for sort method's specific logic. This method should be executed to get a single score from a single image and add the result to :attr:`_result` @@ -258,7 +258,7 @@ def binning(self) -> list[list[str]]: raise NotImplementedError() @classmethod - def _mask_face(cls, image: np.ndarray, alignments: PNGHeaderAlignmentsDict) -> np.ndarray: + def _mask_face(cls, image: np.ndarray, alignments: PNGAlignments) -> np.ndarray: """Function for applying the mask to an aligned face if both the face image and alignment data are available. @@ -345,7 +345,7 @@ def _get_file_iterator(self, input_dir: str) -> InfoLoader: def score_image(self, filename: str, image: np.ndarray | None, - alignments: PNGHeaderAlignmentsDict | None) -> None: + alignments: PNGAlignments | None) -> None: """Score a single image for sort method and add the result to :attr:`_result` Parameters @@ -386,7 +386,7 @@ def binning(self) -> list[list[str]]: for bin_ in tqdm(self._binned, desc="Binning and sorting", file=sys.stdout, leave=False): indices: dict[int, str] = {} for filename in bin_: - indices[sorted_.index(filename)] = filename + indices[sorted_.index(filename)] = filename # pyright:ignore[reportArgumentType] output.append([indices[idx] for idx in sorted(indices)]) return output @@ -434,7 +434,7 @@ def estimate_blur(self, image: np.ndarray, alignments=None) -> float: def estimate_blur_fft(self, image: np.ndarray, - alignments: PNGHeaderAlignmentsDict | None = None) -> float: + alignments: PNGAlignments | None = None) -> float: """Estimate the amount of blur a fft filtered image has. Parameters @@ -475,7 +475,7 @@ def estimate_blur_fft(self, def score_image(self, filename: str, image: np.ndarray | None, - alignments: PNGHeaderAlignmentsDict | None) -> None: + alignments: PNGAlignments | None) -> None: """Score a single image for blur or blur-fft and add the result to :attr:`_result` Parameters @@ -595,7 +595,7 @@ def binning(self) -> list[list[str]]: def score_image(self, filename: str, image: np.ndarray | None, - alignments: PNGHeaderAlignmentsDict | None) -> None: + alignments: PNGAlignments | None) -> None: """Score a single image for color Parameters @@ -668,12 +668,12 @@ def __init__(self, arguments: Namespace, is_group: bool = False) -> None: self._count_seen = 0 self._plugin_thread = FSThread(self._score_from_plugin) self._from_plugin: list[tuple[str, npt.NDArray[np.float32]]] = [] - self._alignment_queue: Queue[tuple[str, PNGHeaderAlignmentsDict]] = Queue( + self._alignment_queue: Queue[tuple[str, PNGAlignments]] = Queue( maxsize=self._plugin.batch_size * 3) def _score_from_header(self, filename: str, - alignments: PNGHeaderAlignmentsDict) -> bool: + alignments: PNGAlignments) -> bool: """Reads header information from the PNG file to look for the identity embedding Parameters @@ -687,9 +687,9 @@ def _score_from_header(self, ------- ``True`` if embedding information was read from the PNG header, otherwise ``False`` """ - if not alignments.get("identity", {}).get(self._storage_name): + embedding = alignments.identity.get(self._storage_name) + if embedding is None: return False - embedding = np.array(alignments["identity"][self._storage_name], dtype="float32") self._result.append((filename, embedding)) return True @@ -707,7 +707,7 @@ def _score_from_plugin(self): self._from_plugin.append((media.filename, embedding)) filename, alignments = self._alignment_queue.get() assert filename == media.filename - alignments.setdefault("identity", {})[self._storage_name] = embedding.tolist() + alignments.identity[self._storage_name] = embedding self._iterator.update_png_header(filename, alignments) def _handle_plugin(self) -> None: @@ -726,7 +726,7 @@ def _handle_plugin(self) -> None: def score_image(self, filename: str, image: np.ndarray | None, - alignments: PNGHeaderAlignmentsDict | None) -> None: + alignments: PNGAlignments | None) -> None: """Score a single image for sort method and add the result to :attr:`_result`. Attempts to pull identity information from the PNG metadata. If not available, pulls the information from the Identity plugin and stores in the PNG header for future use @@ -740,6 +740,7 @@ def score_image(self, alignments The alignments dictionary for the aligned face or ``None`` """ + # pylint:disable=duplicate-code if not alignments: msg = ("The images to be sorted do not contain alignment data. Images must have " "been generated by Faceswap's Extract process.\nIf you are sorting an " @@ -762,11 +763,11 @@ def score_image(self, self._alignment_queue.put((filename, alignments)) - face = DetectedFace(left=alignments["x"], # Only include required items - width=alignments["w"], - top=alignments["y"], - height=alignments["h"], - landmarks_xy=np.array(alignments["landmarks_xy"], dtype="float32")) + face = DetectedFace(left=alignments.x, # Only include required items + width=alignments.w, + top=alignments.y, + height=alignments.h, + landmarks_xy=alignments.landmarks_xy) assert self._runner is not None try: self._runner.put(filename, @@ -834,7 +835,7 @@ def __init__(self, arguments: Namespace, is_group: bool = False) -> None: def _calc_histogram(self, image: np.ndarray, - alignments: PNGHeaderAlignmentsDict | None) -> np.ndarray: + alignments: PNGAlignments | None) -> np.ndarray: if alignments: image = self._mask_face(image, alignments) return cv2.calcHist([image], [0], None, [256], [0, 256]) @@ -854,7 +855,7 @@ def _sort_dissim(self) -> None: score_total += cv2.compareHist(result[i][1], result[j][1], cv2.HISTCMP_BHATTACHARYYA) - result[i][2] = score_total + result[i][2] = score_total # pyright:ignore self._result = sorted(result, key=operator.itemgetter(2), reverse=True) @@ -941,7 +942,7 @@ def binning(self) -> list[list[str]]: def score_image(self, filename: str, image: np.ndarray | None, - alignments: PNGHeaderAlignmentsDict | None) -> None: + alignments: PNGAlignments | None) -> None: """Collect the histogram for the given face Parameters diff --git a/tools/sort/sort_methods_aligned.py b/tools/sort/sort_methods_aligned.py index 5cb3ba99e1..9b1d087ba1 100644 --- a/tools/sort/sort_methods_aligned.py +++ b/tools/sort/sort_methods_aligned.py @@ -17,7 +17,7 @@ if T.TYPE_CHECKING: from argparse import Namespace - from lib.align.alignments import PNGHeaderAlignmentsDict + from lib.align.objects import PNGAlignments logger = logging.getLogger(__name__) @@ -62,7 +62,7 @@ def sort(self) -> None: def score_image(self, filename: str, image: np.ndarray | None, - alignments: PNGHeaderAlignmentsDict | None) -> None: + alignments: PNGAlignments | None) -> None: """ Score a single image for sort method: "distance", "yaw", "pitch" or "size" and add the result to :attr:`_result` @@ -83,11 +83,11 @@ def score_image(self, if not alignments: msg = ("The images to be sorted do not contain alignment data. Images must have " "been generated by Faceswap's Extract process.\nIf you are sorting an " - "older faceset, then you should re-extract the faces from your source " + "older face set, then you should re-extract the faces from your source " "alignments file to generate this data.") raise FaceswapError(msg) - face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32")) + face = AlignedFace(alignments.landmarks_xy) if (not self._logged_lm_count_once and face.landmark_type == LandmarkType.LM_2D_4 and self.__class__.__name__ != "SortSize"): @@ -131,7 +131,7 @@ def binning(self) -> list[list[str]]: class SortPitch(SortAlignedMetric): - """ Sorting mechansim for sorting a face by pitch (down to up) """ + """ Sorting mechanism for sorting a face by pitch (down to up) """ def _get_metric(self, aligned_face: AlignedFace) -> float: """ Obtain the pitch metric for the given face @@ -163,7 +163,7 @@ def binning(self) -> list[list[str]]: names = np.flip(thresholds.astype("int")) + 90 self._bin_names = [f"{self._method}_" f"{idx:03d}_{int(names[idx])}" - f"degs_to_{int(names[idx + 1])}degs" + f"degrees_to_{int(names[idx + 1])}" for idx in range(self._num_bins)] bins: list[list[str]] = [[] for _ in range(self._num_bins)] @@ -176,7 +176,7 @@ def binning(self) -> list[list[str]]: class SortYaw(SortPitch): - """ Sorting mechansim for sorting a face by yaw (left to right). Same logic as sort pitch, but + """ Sorting mechanism for sorting a face by yaw (left to right). Same logic as sort pitch, but with different metric """ def _get_metric(self, aligned_face: AlignedFace) -> float: """ Obtain the yaw metric for the given face @@ -195,7 +195,7 @@ def _get_metric(self, aligned_face: AlignedFace) -> float: class SortRoll(SortPitch): - """ Sorting mechansim for sorting a face by roll (rotation). Same logic as sort pitch, but + """ Sorting mechanism for sorting a face by roll (rotation). Same logic as sort pitch, but with different metric """ def _get_metric(self, aligned_face: AlignedFace) -> float: """ Obtain the roll metric for the given face @@ -293,7 +293,8 @@ def _sort_landmarks_ssim(self) -> None: for j in range(i + 1, img_list_len): fl1 = self._result[i][1] fl2 = self._result[j][1] - score = np.sum(np.absolute((fl2 - fl1).flatten())) + score = np.sum(np.absolute( + (fl2 - fl1).flatten())) # pyright:ignore[reportAttributeAccessIssue] if score < min_score: min_score = score j_min_score = j @@ -311,8 +312,8 @@ def _sort_landmarks_dissim(self) -> None: continue fl1 = self._result[i][1] fl2 = self._result[j][1] - score_total += np.sum(np.absolute((fl2 - fl1).flatten())) - self._result[i][2] = score_total + score_total += np.sum(np.absolute((fl2 - fl1).flatten())) # pyright:ignore + self._result[i][2] = score_total # pyright:ignore logger.info("Sorting...") self._result = sorted(self._result, key=operator.itemgetter(2), reverse=True) @@ -353,7 +354,8 @@ def binning(self) -> list[list[str]]: for key, references in reference_groups.items(): try: - score = self._get_avg_score(fl1, references) + score = self._get_avg_score(fl1, # pyright:ignore[reportArgumentType] + references) except TypeError: score = float("inf") except ZeroDivisionError: @@ -362,10 +364,10 @@ def binning(self) -> list[list[str]]: current_key, current_score = key, score if current_score < threshold: - reference_groups[current_key].append(fl1[0]) + reference_groups[current_key].append(fl1[0]) # pyright:ignore[reportIndexIssue] bins[current_key].append(self._result[i][0]) else: - reference_groups[len(reference_groups)] = [self._result[i][1]] + reference_groups[len(reference_groups)] = [self._result[i][1]] # pyright:ignore bins.append([self._result[i][0]]) return bins From c83e3b53fec1d24cd9ded44559e69e0448a4fd4f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:12:30 +0100 Subject: [PATCH 953/981] bugfix: Manual tool - prevent error on no masks --- tools/manual/frame_viewer/editor/mask.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/manual/frame_viewer/editor/mask.py b/tools/manual/frame_viewer/editor/mask.py index ff3e3df2b0..fab8eddbf8 100644 --- a/tools/manual/frame_viewer/editor/mask.py +++ b/tools/manual/frame_viewer/editor/mask.py @@ -104,7 +104,7 @@ def _add_controls(self) -> None: - the size of brush to use - the cursor display color """ - masks = sorted(msk.title() for msk in list(self._det_faces.available_masks) + ["None"]) + masks = sorted(msk.title() for msk in list(self._det_faces.available_masks)) default = masks[0] if len(masks) == 1 else [mask for mask in masks if mask != "None"][0] self._add_control(ControlPanelOption("Mask type", str, From 7203cf3dea1d1af4567c16d055e1554cd27a4a4e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 5 Apr 2026 19:14:34 +0100 Subject: [PATCH 954/981] bugfix: Align - Fix re-feed OOB cropping + loosen HRNet BBox --- lib/infer/align.py | 13 +++++++++---- plugins/extract/align/hrnet.py | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/infer/align.py b/lib/infer/align.py index c1ae70e2dd..01ab79f98e 100644 --- a/lib/infer/align.py +++ b/lib/infer/align.py @@ -96,8 +96,7 @@ def _clamp_roi(self, retval[:, 3] = np.clip(roi[:, 3], 0, imgs_h_w[:, 0] - 1) return retval - @classmethod - def _get_destinations(cls, + def _get_destinations(self, original_roi: npt.NDArray[np.int32], clamped_roi: npt.NDArray[np.int32], scales: npt.NDArray[np.float64]) -> npt.NDArray[np.int32]: @@ -117,8 +116,14 @@ def _get_destinations(cls, The destination co-ordinates for re-sizing the face box to model input size """ retval = np.empty_like(clamped_roi, dtype=np.int32) - retval[:, [0, 2]] = (clamped_roi[:, [0, 2]] - original_roi[:, 0, None]) * scales[:, None] - retval[:, [1, 3]] = (clamped_roi[:, [1, 3]] - original_roi[:, 1, None]) * scales[:, None] + retval[:, [0, 2]] = np.clip(np.round((clamped_roi[:, [0, 2]] - + original_roi[:, 0, None]) * scales[:, None]), + 0, + self.plugin.input_size) + retval[:, [1, 3]] = np.clip(np.round((clamped_roi[:, [1, 3]] - + original_roi[:, 1, None]) * scales[:, None]), + 0, + self.plugin.input_size) return retval def _crop_and_resize(self, # pylint:disable=too-many-locals diff --git a/plugins/extract/align/hrnet.py b/plugins/extract/align/hrnet.py index 548038042b..e1097443b3 100644 --- a/plugins/extract/align/hrnet.py +++ b/plugins/extract/align/hrnet.py @@ -102,7 +102,7 @@ def pre_process(self, batch: np.ndarray) -> np.ndarray: widths = batch[:, 2] - batch[:, 0] ctr_x = np.rint((batch[:, 0] + batch[:, 2]) * 0.5).astype("int32") ctr_y = np.rint((batch[:, 1] + batch[:, 3]) * 0.5).astype("int32") - size = np.maximum(widths, heights) + size = np.maximum(widths, heights) * 1.25 half = np.rint(size * 0.5).astype("int32") retval = np.empty((batch.shape[0], 4), dtype=np.int32) retval[:, 0] = ctr_x - half From 604374100eb21a1ebdc1a2d1a2a98b1f45dd8dc3 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:42:08 +0100 Subject: [PATCH 955/981] bugfix: Aligned face - fix sub-crops + landmark mask mapping for coverage != 1.0 and y-offset != 0.0 --- lib/align/__init__.py | 2 +- lib/align/aligned_face.py | 110 ++++++----------------------- lib/align/aligned_mask.py | 12 +++- lib/align/aligned_utils.py | 140 ++++++++++++++++++++++++++++++++++--- lib/infer/handler.py | 7 +- scripts/extract.py | 6 +- 6 files changed, 168 insertions(+), 109 deletions(-) diff --git a/lib/align/__init__.py b/lib/align/__init__.py index ccc6ee3617..e2893956c6 100644 --- a/lib/align/__init__.py +++ b/lib/align/__init__.py @@ -2,7 +2,7 @@ """ Package for handling alignments files, detected faces and aligned faces along with their associated objects. """ from .aligned_face import AlignedFace -from .aligned_utils import (get_adjusted_center, get_centered_size, +from .aligned_utils import (get_adjusted_center, get_sub_crop_size, get_matrix_scaling, transform_image) from .aligned_mask import BlurMask, LandmarksMask, Mask from .alignments import Alignments diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index d9dce47a8e..4427867167 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -15,8 +15,8 @@ from lib.utils import get_module_objects from .constants import CenteringType, EXTRACT_RATIOS, LandmarkType, MEAN_FACE -from .aligned_utils import (get_adjusted_center, get_centered_size, get_matrix_scaling, - points_to_68, transform_image) +from .aligned_utils import (get_base_size, get_sub_crop_size, get_matrix_scaling, points_to_68, + sub_crop, transform_image) from .aligned_mask import LandmarksMask from .pose import PoseEstimate @@ -216,8 +216,7 @@ def adjusted_matrix(self) -> np.ndarray: original frame with padding and sizing applied.""" with self._cache.lock("adjusted_matrix"): if self._cache.adjusted_matrix is None: - matrix = self.matrix.copy() - mat = matrix * (self._size - 2 * self.padding) + mat = self.matrix * (self._size - 2 * self.padding) mat[:, 2] += self.padding logger.trace("adjusted_matrix: %s", mat) # type:ignore[attr-defined] self._cache.adjusted_matrix = mat @@ -336,7 +335,7 @@ def _padding_from_coverage(cls, size: int, coverage_ratio: float) -> dict[Center ------- The padding required, in pixels for 'head', 'face' and 'legacy' face types """ - retval = {_type: round((size * (coverage_ratio - (1 - EXTRACT_RATIOS[_type]))) / 2) + retval = {_type: round((size * (1 - EXTRACT_RATIOS[_type] / coverage_ratio)) / 2) for _type in T.get_args(T.Literal["legacy", "face", "head"])} logger.trace(retval) # type:ignore[attr-defined] return retval @@ -374,7 +373,11 @@ def transform_points(self, points: np.ndarray, invert: bool = False) -> np.ndarr The transformed points """ retval = np.expand_dims(points, axis=1) - mat = cv2.invertAffineTransform(self.adjusted_matrix) if invert else self.adjusted_matrix + mat = self.adjusted_matrix + if self.y_offset: + mat = mat.copy() + mat[1, 2] += (self.y_offset * (self._size - self.padding * 2)) + mat = cv2.invertAffineTransform(mat) if invert else mat retval = cv2.transform(retval, mat).squeeze() logger.trace( # type:ignore[attr-defined] "invert: %s, Original points: %s, transformed points: %s", invert, points, retval) @@ -411,7 +414,7 @@ def extract_face(self, image: np.ndarray | None) -> np.ndarray | None: retval = image else: mat = self.matrix - if self._y_offset: + if self.y_offset: mat = self.matrix.copy() mat[1, 2] += self.y_offset retval = transform_image(image, mat, self._size, self.padding) @@ -422,10 +425,6 @@ def _convert_centering(self, image: np.ndarray) -> np.ndarray: """When the face being loaded is pre-aligned, the loaded image will have 'head' centering so it needs to be cropped out to the appropriate centering. - This function temporarily converts this object to a full head aligned face, extracts the - sub-cropped face to the correct centering, reverse the sub crop and returns the cropped - face at the selected coverage ratio. - Parameters ---------- image @@ -440,90 +439,21 @@ def _convert_centering(self, image: np.ndarray) -> np.ndarray: image.shape[0], self.size, self._coverage_ratio) img_size = image.shape[0] - target_size = get_centered_size(self._source_centering, + target_size = get_sub_crop_size(self._source_centering, self._centering, img_size, self._coverage_ratio) - out = np.zeros((target_size, target_size, image.shape[-1]), dtype=image.dtype) - - slices = self._get_cropped_slices(img_size, target_size) - out[slices["out"][0], slices["out"][1], :] = image[slices["in"][0], slices["in"][1], :] + base_size = get_base_size(img_size, self._source_centering, 1.0) + padding_diff = (img_size - target_size) / 2 + delta = self.pose.offset[self._centering] - self.pose.offset[self._source_centering] + if self.y_offset: + delta[1] -= self.y_offset + offset = np.rint(delta * base_size + padding_diff).astype("int32") + retval = sub_crop(image, offset, target_size) logger.trace( # type:ignore[attr-defined] "Cropped from aligned extract: (centering: %s, in shape: %s, out shape: %s)", - self._centering, image.shape, out.shape) - return out - - def _get_cropped_slices(self, - image_size: int, - target_size: int, - ) -> dict[T.Literal["in", "out"], tuple[slice, slice]]: - """Obtain the slices to turn a full head extract into an alternatively centered extract. - - Parameters - ---------- - image_size - The size of the full head extracted image loaded from disk - target_size - The size of the target centered face with coverage ratio applied in relation to the - original image size - - Returns - ------- - The slices for an input full head image and output cropped image - """ - with self._cache.lock("cropped_slices"): - if not self._cache.cropped_slices.get(self._centering): - roi = self.get_cropped_roi(image_size, target_size, self._centering) - slice_in = (slice(max(roi[1], 0), max(roi[3], 0)), - slice(max(roi[0], 0), max(roi[2], 0))) - slice_out = (slice(max(roi[1] * -1, 0), - target_size - min(target_size, max(0, roi[3] - image_size))), - slice(max(roi[0] * -1, 0), - target_size - min(target_size, max(0, roi[2] - image_size)))) - self._cache.cropped_slices[self._centering] = {"in": slice_in, "out": slice_out} - logger.trace("centering: %s, cropped_slices: %s", # type:ignore[attr-defined] - self._centering, self._cache.cropped_slices[self._centering]) - return self._cache.cropped_slices[self._centering] - - def get_cropped_roi(self, - image_size: int, - target_size: int, - centering: CenteringType) -> np.ndarray: - """Obtain the region of interest within an aligned face set to centered coverage for - an alternative centering - - Parameters - ---------- - image_size - The size of the full head extracted image loaded from disk - target_sizes - The size of the target centered face with coverage ratio applied in relation to the - original image size - centering - The type of centering to obtain the region of interest for. "legacy" places the nose - in the center of the image (the original method for aligning). "face" aligns for the - nose to be in the center of the face (top to bottom) but the center of the skull for - left to right. - - Returns - ------- - The (`left`, `top`, `right`, `bottom` location of the region of interest within an - aligned face centered on the head for the given centering - """ - with self._cache.lock("cropped_roi"): - if centering not in self._cache.cropped_roi: - center = get_adjusted_center(image_size, - self.pose.offset[self._source_centering], - self.pose.offset[centering], - self._source_centering, - self.y_offset) - padding = target_size // 2 - roi = np.array([center - padding, center + padding]).ravel() - logger.trace( # type:ignore[attr-defined] - "centering: '%s', center: %s, padding: %s, sub roi: %s", - centering, center, padding, roi) - self._cache.cropped_roi[centering] = roi - return self._cache.cropped_roi[centering] + self._centering, image.shape, retval.shape) + return retval def split_mask(self) -> np.ndarray: """Remove the mask from the alpha channel of :attr:`face` and return the mask diff --git a/lib/align/aligned_mask.py b/lib/align/aligned_mask.py index f97548a15f..9d5bff8051 100644 --- a/lib/align/aligned_mask.py +++ b/lib/align/aligned_mask.py @@ -13,7 +13,7 @@ from lib.logger import parse_class_init from lib.utils import FaceswapError, get_module_objects -from .aligned_utils import get_adjusted_center, get_centered_size +from .aligned_utils import get_adjusted_center, get_sub_crop_size from .objects import MaskAlignmentsFile from .constants import LandmarkType, LANDMARK_PARTS, LANDMARK_MASK_PARTS @@ -308,7 +308,7 @@ def set_sub_crop(self, target_offset, self.stored_centering, y_offset) - crop_size = get_centered_size(self.stored_centering, + crop_size = get_sub_crop_size(self.stored_centering, centering, self.stored_size, coverage_ratio=coverage_ratio) @@ -401,13 +401,18 @@ def to_png_meta(self) -> MaskAlignmentsFile: """ return self.to_dict(is_png=True) - def from_dict(self, mask: MaskAlignmentsFile) -> None: + def from_dict(self, mask: MaskAlignmentsFile) -> T.Self: """Populates the :class:`Mask` from a dictionary loaded from an alignments file. Parameters ---------- + mask A dictionary stored in an alignments file containing the keys ``mask``, ``affine_matrix``, ``interpolator``, ``stored_size``, ``stored_centering`` + + Returns + ------- + This loaded Mask object """ self._mask = mask.mask self._affine_matrix = self._matrix_2to3(mask.affine_matrix) @@ -415,6 +420,7 @@ def from_dict(self, mask: MaskAlignmentsFile) -> None: self.stored_size = mask.stored_size self.stored_centering = mask.stored_centering logger.trace(mask) # type:ignore[attr-defined] + return self class LandmarksMask(Mask): diff --git a/lib/align/aligned_utils.py b/lib/align/aligned_utils.py index 3e264e9df0..c1e20dfc7f 100644 --- a/lib/align/aligned_utils.py +++ b/lib/align/aligned_utils.py @@ -58,7 +58,76 @@ def get_adjusted_center(image_size: int, return center -def get_centered_size(source_centering: CenteringType, +def get_base_scale(source_centering: CenteringType, + source_coverage: float = 1.0) -> float: + """For an aligned patch of the given centering and the given coverage, obtain the ratio of the + patch that contains the core central area with no padding applied + + Parameters + ---------- + source_centering + The centering type of the image patch to obtain the core ratio for + source_coverage + The coverage of the source patch to obtain the core ratio for. Default: 1.0 + + Returns + ------- + The ratio of the patch of the given centering and coverage that contains the core patch + """ + return 1 - EXTRACT_RATIOS[source_centering] * source_coverage + + +def get_base_size(size: int, + source_centering: CenteringType, + source_coverage: float = 1.0) -> int: + """For an aligned patch of the given size, centering and coverage, obtain the size of the patch + that contains the core central area with no padding applied + + Parameters + ---------- + size + The size of the larger patch to obtain the core size for + source_centering + The centering type of the image patch to obtain the core size for + source_coverage + The coverage of the source patch to obtain the core size for. Default: 1.0 + + Returns + ------- + The size of the core patch of larger patch of the given size, centering and coverage + """ + scale = get_base_scale(source_centering, source_coverage=source_coverage) + return 2 * int(round(size * scale / 2)) + + +def get_sub_crop_scale(source_centering: CenteringType, + target_centering: CenteringType, + source_coverage: float = 1.0, + target_coverage: float = 1.0) -> float: + """For a source aligned patch of the given centering and the given coverage, obtain the ratio + to obtain a destination patch of the given coverage + + Parameters + ---------- + source_centering + The centering type of the source image patch to obtain the destination ratio for + target_centering + The centering type of the destination image patch to obtain the ratio for + source_coverage + The coverage of the source patch to obtain the destination ratio for. Default: 1.0 + target_coverage + The coverage of the destination patch to obtain the ratio for. Default: 1.0 + + Returns + ------- + The ratio to take the source patch to the destination patch for the given coverage ratios + """ + coverage = target_coverage / source_coverage + return ((1 - EXTRACT_RATIOS[source_centering]) / + (1 - EXTRACT_RATIOS[target_centering]) * coverage) + + +def get_sub_crop_size(source_centering: CenteringType, target_centering: CenteringType, size: int, coverage_ratio: float = 1.0) -> int: @@ -94,16 +163,16 @@ def get_centered_size(source_centering: CenteringType, The pixel size of a sub-crop image from a full head aligned image with the given coverage ratio """ if source_centering == target_centering and coverage_ratio == 1.0: - src_size: float | int = size retval = size else: - src_size = size - (size * EXTRACT_RATIOS[source_centering]) - retval = 2 * int(np.rint((src_size / (1 - EXTRACT_RATIOS[target_centering]) - * coverage_ratio) / 2)) + scale = get_sub_crop_scale(source_centering, + target_centering, + source_coverage=1.0, + target_coverage=coverage_ratio) + retval = 2 * int(round(size * scale / 2)) logger.trace( # type:ignore[attr-defined] "source_centering: %s, target_centering: %s, size: %s, coverage_ratio: %s, " - "source_size: %s, crop_size: %s", - source_centering, target_centering, size, coverage_ratio, src_size, retval) + "crop_size: %s", source_centering, target_centering, size, coverage_ratio, retval) return retval @@ -171,6 +240,60 @@ def transform_image(image: np.ndarray, return retval +@T.overload +def sub_crop(image: npt.NDArray[np.uint8], offset: npt.NDArray[np.int32], out_size: int + ) -> npt.NDArray[np.uint8]: + ... + + +@T.overload +def sub_crop(image: npt.NDArray[np.float32], offset: npt.NDArray[np.int32], out_size: int + ) -> npt.NDArray[np.float32]: + ... + + +def sub_crop(image: npt.NDArray[np.uint8 | np.float32], # pylint:disable=too-many-locals + offset: npt.NDArray[np.int32], + out_size: int + ) -> npt.NDArray[np.uint8 | np.float32]: + """Obtain an aligned sub-crop from a larger aligned image. Handles OOB. Output is zero padded + + Parameters + ---------- + image + The (H, W, C) full size extracted image. + offset + The (x, y) offset to shift the sub-crop. + out_size + The output size of the sub-crop. + """ + height, width, channels = image.shape[:3] + + src_x0 = int(offset[0]) + src_y0 = int(offset[1]) + src_x1 = src_x0 + out_size + src_y1 = src_y0 + out_size + + valid_src_x0 = max(src_x0, 0) + valid_src_y0 = max(src_y0, 0) + valid_src_x1 = min(src_x1, width) + valid_src_y1 = min(src_y1, height) + + out = np.zeros((out_size, out_size, channels), dtype=image.dtype) + + if valid_src_x0 >= valid_src_x1 or valid_src_y0 >= valid_src_y1: + return out # Fully OOB + + dst_x0 = valid_src_x0 - src_x0 + dst_y0 = valid_src_y0 - src_y0 + dst_x1 = dst_x0 + (valid_src_x1 - valid_src_x0) + dst_y1 = dst_y0 + (valid_src_y1 - valid_src_y0) + + out[dst_y0:dst_y1, dst_x0:dst_x1] = image[valid_src_y0:valid_src_y1, valid_src_x0:valid_src_x1] + return out + + +# Batch functions def batch_transform(matrices: npt.NDArray[np.float32], points: npt.NDArray[np.float32], in_place: bool = False) -> npt.NDArray[np.float32]: @@ -253,7 +376,8 @@ def batch_sub_crop(images: npt.NDArray[np.uint8 | np.float32], out_size: int, base_grid: tuple[npt.NDArray[np.int32], npt.NDArray[np.int32]] | None = None ) -> npt.NDArray[np.uint8 | np.float32]: - """Obtain aligned sub-crops from larger aligned images + """Obtain aligned sub-crops from larger aligned images. Handles OOB. Outputs are replicate + padded Parameters ---------- diff --git a/lib/infer/handler.py b/lib/infer/handler.py index 18f4a0a260..79d716726b 100644 --- a/lib/infer/handler.py +++ b/lib/infer/handler.py @@ -10,7 +10,7 @@ from torch.cuda import OutOfMemoryError from lib.align.aligned_utils import (batch_adjust_matrices, batch_align, batch_resize, - batch_sub_crop) + batch_sub_crop, get_base_scale, get_sub_crop_scale) from lib.align.constants import EXTRACT_RATIOS, LandmarkType from lib.logger import parse_class_init from lib.utils import FaceswapError, get_module_objects @@ -304,9 +304,8 @@ def __init__(self, else f"matrices_{self._centering}") # Aligned handling - self._head_to_base_ratio = (1 - EXTRACT_RATIOS["head"]) / 2 - self._head_to_centering_ratio = ((1 - EXTRACT_RATIOS["head"]) / - (1 - EXTRACT_RATIOS[self._centering]) / 2) + self._head_to_base_ratio = get_base_scale("head", 1.0) / 2 + self._head_to_centering_ratio = get_sub_crop_scale("head", self._centering, 1.0, 1.0) / 2 self._aligned_offsets_name = f"offsets_{self._centering}" def _maybe_log_warning(self, landmark_type: LandmarkType | None) -> None: diff --git a/scripts/extract.py b/scripts/extract.py index 54656e5406..1158e7c3d6 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -16,7 +16,7 @@ from tqdm import tqdm from lib.align.aligned_utils import (batch_adjust_matrices, batch_align, batch_resize, - batch_transform, get_adjusted_center, get_centered_size) + batch_transform, get_adjusted_center, get_sub_crop_size) from lib.align.objects import AlignmentsEntry, FileAlignments, PNGHeader, PNGSource from lib.align.constants import EXTRACT_RATIOS, LandmarkType, MEAN_FACE from lib.align.detected_face import DetectedFace @@ -548,8 +548,8 @@ class DebugLandmarks(): def __init__(self, size: int) -> None: logger.debug(parse_class_init(locals())) self._size = size - self._face_size = get_centered_size("head", "face", size) - self._legacy_size = get_centered_size("head", "legacy", size) + self._face_size = get_sub_crop_size("head", "face", size) + self._legacy_size = get_sub_crop_size("head", "legacy", size) self._camera_matrix = get_camera_matrix() self._mean_face = MEAN_FACE[LandmarkType.LM_2D_51][None] self._face_expansion = 1.0 - EXTRACT_RATIOS["face"] From 30d087acb90301e5c0c52f5b424b780f3157ddb9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:16:37 +0100 Subject: [PATCH 956/981] bugfix: alignments datadict: Allow loading missing identities and masks --- lib/align/objects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/align/objects.py b/lib/align/objects.py index 2d0697200e..70ec9ffb91 100644 --- a/lib/align/objects.py +++ b/lib/align/objects.py @@ -220,9 +220,9 @@ class PNGAlignments(DataclassDict): """The height of the bounding box""" landmarks_xy: npt.NDArray[np.float32] """The (x, y) landmark points of the face""" - mask: dict[str, MaskAlignmentsFile] + mask: dict[str, MaskAlignmentsFile] = field(default_factory=dict) """The masks stored for the face""" - identity: dict[str, npt.NDArray[np.float32]] + identity: dict[str, npt.NDArray[np.float32]] = field(default_factory=dict) """The identity vectors stored for the face""" def __repr__(self) -> str: From f403afbfa2dec63abebad7448be0da608765c41c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:21:32 +0100 Subject: [PATCH 957/981] bugfix: legacy alignments conversion --- lib/align/alignments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/align/alignments.py b/lib/align/alignments.py index 785c5ac53d..27a9780700 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -605,7 +605,7 @@ def load(self) -> dict[str, AlignmentsEntry]: if self._update_legacy(alignments): logger.info("Writing alignments to: '%s'", self._file) self._serializer.save(self._file, {"__meta__": {"version": self._version}, - "__data__": {alignments}}) + "__data__": alignments}) retval: dict[str, AlignmentsEntry] retval = {k: AlignmentsEntry.from_dict(v) for k, v in alignments.items()} logger.debug("Loaded alignments") From aa066ad7b2ac567b018696f6543b967e1947a64e Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:02:06 +0100 Subject: [PATCH 958/981] bugfix: Aligned faces. Fix padding calculation --- lib/align/aligned_face.py | 3 ++- tools/alignments/media.py | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 4427867167..79c3957936 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -335,7 +335,8 @@ def _padding_from_coverage(cls, size: int, coverage_ratio: float) -> dict[Center ------- The padding required, in pixels for 'head', 'face' and 'legacy' face types """ - retval = {_type: round((size * (1 - EXTRACT_RATIOS[_type] / coverage_ratio)) / 2) + retval = {_type: round(size * (EXTRACT_RATIOS[_type] + coverage_ratio - 1) / + (2 * coverage_ratio)) for _type in T.get_args(T.Literal["legacy", "face", "head"])} logger.trace(retval) # type:ignore[attr-defined] return retval diff --git a/tools/alignments/media.py b/tools/alignments/media.py index ce1e2eaca5..ccab2f4d78 100644 --- a/tools/alignments/media.py +++ b/tools/alignments/media.py @@ -481,7 +481,6 @@ def __init__(self, frames: Frames, alignments: AlignmentData, size: int = 512) - logger.trace("Initializing %s: size: %s", # type:ignore[attr-defined] self.__class__.__name__, size) self.size = size - self.padding = int(size * 0.1875) self.alignments = alignments self.frames = frames self.current_frame: str | None = None From 453ef58b1df9bca6ec45246d6c9d8efa3fe7cc13 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:07:47 +0100 Subject: [PATCH 959/981] bugfix: alignments tool unit test --- tests/tools/alignments/media_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/tools/alignments/media_test.py b/tests/tools/alignments/media_test.py index 40d2b85eed..69bc6a1b5c 100644 --- a/tests/tools/alignments/media_test.py +++ b/tests/tools/alignments/media_test.py @@ -678,7 +678,6 @@ def test_init(self, extracted_faces_instance: ExtractedFaces) -> None: """ faces = extracted_faces_instance assert faces.size == 512 - assert faces.padding == int(512 * 0.1875) assert faces.current_frame is None assert faces.faces == [] From 83fdd5f938b71a31964a0c014cd0389462be8689 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:19:53 +0100 Subject: [PATCH 960/981] Train: Migrate data loading to Torch (#1540) --- .github/workflows/pytest.yml | 8 +- docs/full/lib/training.rst | 16 +- docs/full/plugins/train.rst | 7 +- lib/align/aligned_face.py | 31 +- lib/align/aligned_mask.py | 126 ++- lib/align/aligned_utils.py | 45 + lib/align/detected_face.py | 28 +- lib/align/objects.py | 13 +- lib/cli/launcher.py | 21 +- lib/config/config.py | 6 +- lib/convert.py | 6 +- lib/gui/popup_configure.py | 4 +- lib/gui/utils/__init__.py | 2 +- lib/gui/utils/config.py | 18 +- lib/gui/utils/image.py | 59 +- lib/infer/detect.py | 27 +- lib/system/system.py | 4 +- lib/training/__init__.py | 3 +- lib/training/cache.py | 717 ------------- .../{augmentation.py => data_augmentation.py} | 328 +++--- lib/training/data_loader.py | 333 ++++++ lib/training/data_set.py | 999 ++++++++++++++++++ lib/training/generator.py | 969 ----------------- lib/training/lr_finder.py | 60 +- lib/training/preview.py | 301 ++++++ lib/training/train.py | 515 +++++++++ .../plugins.train.trainer.trainer_config.pot | 74 +- plugins/convert/mask/mask_blend.py | 29 +- plugins/plugin_loader.py | 113 +- plugins/train/model/_base/model.py | 6 - plugins/train/train_config.py | 11 +- plugins/train/trainer/_base.py | 58 - plugins/train/trainer/_display.py | 626 ----------- plugins/train/trainer/base.py | 119 +++ plugins/train/trainer/distributed.py | 64 +- plugins/train/trainer/original.py | 15 +- plugins/train/trainer/trainer_config.py | 267 +++-- plugins/train/training.py | 369 ------- requirements/_requirements_base.txt | 5 +- requirements/requirements_nvidia_12.txt | 2 +- requirements/requirements_nvidia_13.txt | 2 +- scripts/train.py | 363 +++---- tests/lib/training/cache_test.py | 950 ----------------- ...tion_test.py => data_augmentation_test.py} | 46 +- .../plugins/train/trainer/test_distributed.py | 31 +- tests/plugins/train/trainer/test_original.py | 20 +- tests/tools/alignments/media_test.py | 11 +- tools/sort/sort_methods.py | 2 +- 48 files changed, 3304 insertions(+), 4525 deletions(-) delete mode 100644 lib/training/cache.py rename lib/training/{augmentation.py => data_augmentation.py} (64%) create mode 100644 lib/training/data_loader.py create mode 100644 lib/training/data_set.py delete mode 100644 lib/training/generator.py create mode 100644 lib/training/preview.py create mode 100644 lib/training/train.py delete mode 100644 plugins/train/trainer/_base.py delete mode 100644 plugins/train/trainer/_display.py create mode 100644 plugins/train/trainer/base.py delete mode 100644 plugins/train/training.py delete mode 100644 tests/lib/training/cache_test.py rename tests/lib/training/{augmentation_test.py => data_augmentation_test.py} (94%) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 2b3415a366..3de71eed1f 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -85,12 +85,12 @@ jobs: # These backends will fail as GPU drivers not available if: matrix.backend == 'cpu' run: | - KERAS_BACKEND=torch FACESWAP_BACKEND="${{ matrix.backend }}" py.test -v tests/; + KERAS_BACKEND=torch KERAS_TORCH_DEVICE=CPU FACESWAP_BACKEND="${{ matrix.backend }}" py.test -v tests/; - name: End to End Tests # These backends will fail as GPU drivers not available if: matrix.backend == 'cpu' run: | - KERAS_BACKEND=torch FACESWAP_BACKEND="${{ matrix.backend }}" python tests/simple_tests.py; + KERAS_BACKEND=torch KERAS_TORCH_DEVICE=CPU FACESWAP_BACKEND="${{ matrix.backend }}" python tests/simple_tests.py; build_linux: name: "pip (ubuntu-latest, ${{ matrix.backend }} ${{ matrix.python-version }})" @@ -132,10 +132,10 @@ jobs: run: FACESWAP_BACKEND="${{ matrix.backend }}" python -m lib.system.sysinfo - name: Unit Tests run: | - KERAS_BACKEND=torch FACESWAP_BACKEND="${{ matrix.backend }}" py.test -v tests/; + KERAS_BACKEND=torch KERAS_TORCH_DEVICE=CPU FACESWAP_BACKEND="${{ matrix.backend }}" py.test -v tests/; - name: End to End Tests run: | - KERAS_BACKEND=torch FACESWAP_BACKEND="${{ matrix.backend }}" python tests/simple_tests.py; + KERAS_BACKEND=torch KERAS_TORCH_DEVICE=CPU FACESWAP_BACKEND="${{ matrix.backend }}" python tests/simple_tests.py; build_windows: name: "pip (windows-latest, ${{ matrix.backend }} ${{ matrix.python-version }})" diff --git a/docs/full/lib/training.rst b/docs/full/lib/training.rst index b47349e087..94da96cebe 100644 --- a/docs/full/lib/training.rst +++ b/docs/full/lib/training.rst @@ -8,17 +8,16 @@ The training Package handles libraries to assist with training a model :local: :depth: 2 -.. automodapi:: lib.training.augmentation +.. automodapi:: lib.training.data_augmentation :include-all-objects: :no-inheritance-diagram: | -.. automodapi:: lib.training.cache +.. automodapi:: lib.training.data_loader :include-all-objects: - :no-inheritance-diagram: | -.. automodapi:: lib.training.generator +.. automodapi:: lib.training.data_set :include-all-objects: | @@ -30,6 +29,11 @@ The training Package handles libraries to assist with training a model :include-all-objects: :no-inheritance-diagram: +| +.. automodapi:: lib.training.preview + :include-all-objects: + :no-inheritance-diagram: + | .. automodapi:: lib.training.preview_cv :include-all-objects: @@ -41,3 +45,7 @@ The training Package handles libraries to assist with training a model | .. automodapi:: lib.training.tensorboard :include-all-objects: + +| +.. automodapi:: lib.training.train + :include-all-objects: diff --git a/docs/full/plugins/train.rst b/docs/full/plugins/train.rst index 51d6b55d76..3f7fb894c7 100755 --- a/docs/full/plugins/train.rst +++ b/docs/full/plugins/train.rst @@ -50,12 +50,7 @@ trainer package This package contains the training loop for Faceswap -.. automodapi:: plugins.train.trainer._base - :include-all-objects: - :no-inheritance-diagram: - -| -.. automodapi:: plugins.train.trainer._display +.. automodapi:: plugins.train.trainer.base :include-all-objects: :no-inheritance-diagram: diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 79c3957936..ba566c4dcb 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -20,6 +20,8 @@ from .aligned_mask import LandmarksMask from .pose import PoseEstimate +if T.TYPE_CHECKING: + import numpy.typing as npt logger = logging.getLogger(__name__) @@ -476,7 +478,10 @@ def split_mask(self) -> np.ndarray: def get_landmark_mask(self, area: T.Literal["eye", "mouth", "face", "face_extended"], - dilation: float) -> LandmarksMask: + dilation: float = 0, + blur_kernel: int = 0, + blur_type: T.Literal["gaussian", "normalized"] | None = "gaussian", + blur_passes: int = 1) -> npt.NDArray[np.uint8]: """Obtain a :class:`~lib.align.aligned_mask.LandmarksMask` based mask for this face Landmark based masks are generated from Aligned Face landmark points. @@ -487,21 +492,31 @@ def get_landmark_mask(self, The type of mask to obtain. `face` is a full face mask, `face_extended` is a face mask that extends above the eyebrows. The others are masks for those specific areas dilation - The amount of dilation to apply to the mask. as a percentage of the mask size + The amount of dilation to apply to the mask. as a percentage of the mask size. + Default: 0 + blur_kernel + The kernel size, in pixels to apply gaussian blurring to the mask. Set to 0 for no + blurring. Should be odd, if an even number is passed in (outside of 0) then it is + rounded up to the next odd number. Default: 0 + blur_type + The blur type to use. ``gaussian`` or ``normalized`` box filter. Default: ``gaussian`` + blur_passes + The number of passed to perform when blurring. Default: 1 Returns ------- - The requested Landmarks Mask object + The requested Landmarks Mask """ logger.trace("area: %s, dilation: %s", area, dilation) # type:ignore[attr-defined] mask = LandmarksMask(area, self.landmark_type, self.landmarks, - self.adjusted_matrix, - storage_size=self.size, - storage_centering=self.centering, - dilation=dilation) - return mask + self.size, + dilation=dilation, + blur_kernel=blur_kernel, + blur_type=blur_type, + blur_passes=blur_passes) + return mask.mask def _umeyama(source: np.ndarray, destination: np.ndarray, estimate_scale: bool) -> np.ndarray: diff --git a/lib/align/aligned_mask.py b/lib/align/aligned_mask.py index 9d5bff8051..28a95976c8 100644 --- a/lib/align/aligned_mask.py +++ b/lib/align/aligned_mask.py @@ -10,7 +10,7 @@ import cv2 import numpy as np -from lib.logger import parse_class_init +from lib.logger import format_array, parse_class_init from lib.utils import FaceswapError, get_module_objects from .aligned_utils import get_adjusted_center, get_sub_crop_size @@ -423,7 +423,7 @@ def from_dict(self, mask: MaskAlignmentsFile) -> T.Self: return self -class LandmarksMask(Mask): +class LandmarksMask(): """Create a single channel mask from aligned landmark points. Landmarks masks are created on the fly, so the stored centering and size should be the same as @@ -444,36 +444,59 @@ class LandmarksMask(Mask): The type of landmarks that this mask is being created from landmarks The landmarks to generate the mask from - affine_matrix - The transformation matrix required to transform the mask to the original frame. - storage_size - The size (in pixels) that the compressed mask should be stored at. Default: 128. - storage_centering - The centering to store the mask at. One of `"legacy"`, `"face"`, `"head"`. - Default: `"face"` + size + The size (in pixels) that the compressed mask should be dilation The amount of dilation to apply to the mask. as a percentage of the mask size. Default: 0.0 + blur_kernel + The kernel size, in pixels to apply gaussian blurring to the mask. Set to 0 for no + blurring. Should be odd, if an even number is passed in (outside of 0) then it is rounded + up to the next odd number. Default: 0 + blur_type + The blur type to use. ``gaussian`` or ``normalized`` box filter. Default: ``gaussian`` + blur_passes + The number of passed to perform when blurring. Default: 1 """ def __init__(self, area: T.Literal["eye", "mouth", "face", "face_extended"], landmark_type: LandmarkType, landmarks: npt.NDArray[np.float32], - affine_matrix: npt.NDArray[np.float32], - storage_size: int = 128, - storage_centering: CenteringType = "face", - dilation: float = 0.0) -> None: - super().__init__(storage_size=storage_size, storage_centering=storage_centering) + size: int, + dilation: float = 0.0, + blur_kernel: int = 0, + blur_type: T.Literal["gaussian", "normalized"] | None = "gaussian", + blur_passes: int = 1) -> None: + logger.debug(parse_class_init(locals())) self._area = area self._landmark_type = landmark_type - self._lm_matrix = affine_matrix - self._points = self._get_points(landmarks) - self.set_dilation(dilation) + self._landmarks = landmarks + self._size = size + self._original_mask: npt.NDArray[np.uint8] | None = None + + self.dilation = dilation + """The amount of dilation to apply to the mask. as a percentage of the mask size. + Default: 0.0""" + self.blur_kernel = blur_kernel + """The kernel size, in pixels to apply gaussian blurring to the mask. Set to 0 for no + blurring. Should be odd, if an even number is passed in (outside of 0) then it is rounded + up to the next odd number. Default: 0""" + self.blur_type: T.Literal["gaussian", "normalized"] | None = blur_type + """The blur type to use. ``gaussian``, ``normalized`` box filter or ``None`` for no blur. + Default: ``gaussian``""" + self.blur_passes = blur_passes + """The number of passed to perform when blurring. Default: 1""" + self.mask = self.generate_mask() + """The mask at the size of :attr:`size` with any requested blurring, threshold amount and + centering applied.""" - @property - def mask(self) -> npt.NDArray[np.uint8]: - """Overrides the default mask property, creating the processed mask at first call and - compressing it. The decompressed mask is returned from this property.""" - return self.stored_mask + def __repr__(self) -> str: + """Pretty print for logging""" + params = {f"{k[1:]}": format_array(v) if isinstance(v, np.ndarray) else v + for k, v in self.__dict__.items() + if k in ("_area", "_landmark_type", "_landmarks", "_size", + "_dilation", "_blur_kernel", "_blur_type", "blur_passes")} + s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" def _get_slices(self) -> list[slice] | list[list[slice]]: """Obtain the slices that will extract the points for the given area and landmark type @@ -543,19 +566,15 @@ def _extend_face_landmarks(self, retval[22:27] = top_r + ((top_r - bot_r) // 2) return retval - def _get_points(self, landmarks: npt.NDArray[np.float32]) -> list[npt.NDArray[np.int32]]: + def _get_points(self) -> list[npt.NDArray[np.int32]]: """Obtain the points required to create the mask - Parameters - ---------- - landmarks - The landmarks to obtain the points from - Returns ------- The list of points for creating each section of the mask """ slices = self._get_slices() + landmarks = self._landmarks if self._area == "face_extended": landmarks = self._extend_face_landmarks(landmarks) @@ -567,26 +586,49 @@ def _get_points(self, landmarks: npt.NDArray[np.float32]) -> list[npt.NDArray[np for zone in T.cast(list[list[slice]], slices)] return retval - def generate_mask(self) -> None: + def _dilate(self, mask: npt.NDArray[np.uint8]): + """Perform dilation on the mask + + Parameters + ---------- + mask + The mask to dilate + """ + if self.dilation == 0.0: + return + kernel_size = int(round(self._size * abs(self.dilation / 100.), 0)) + element = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) + func = cv2.erode if self.dilation < 0 else cv2.dilate + func(mask, element, dst=mask, iterations=1) + + def generate_mask(self) -> npt.NDArray[np.uint8]: """Generate the mask. - Creates the mask applying any requested dilation and blurring and assigns compressed mask - to :attr:`_mask` + Creates the mask applying any requested dilation and blurring + + Returns + ------- + The landmarks based mask """ - mask = np.zeros((self.stored_size, self.stored_size, 1), dtype=np.uint8) - for pts in self._points: - lms = np.rint(pts).astype("int") - cv2.fillConvexPoly(mask, cv2.convexHull(lms), [255], lineType=cv2.LINE_AA) - if self._dilation[-1] is not None: - self._dilate_mask(mask) - if self._blur_kernel != 0 and self._blur_type is not None: - mask = BlurMask(self._blur_type, + if self._original_mask is None: + points = self._get_points() + mask = np.zeros((self._size, self._size, 1), dtype=np.uint8) + for pts in points: + lms = np.rint(pts).astype("int") + cv2.fillConvexPoly(mask, cv2.convexHull(lms), [255], lineType=cv2.LINE_AA) + self._original_mask = mask + + mask = self._original_mask.copy() + self._dilate(mask) + + if self.blur_kernel != 0 and self.blur_type is not None: + mask = BlurMask(self.blur_type, mask, - self._blur_kernel, - passes=self._blur_passes).blurred + self.blur_kernel, + passes=self.blur_passes).blurred logger.trace("[LM_MASK] mask: (shape: %s, dtype: %s)", # type:ignore[attr-defined] mask.shape, mask.dtype) - self.add(mask, self._lm_matrix) + return mask class BlurMask(): diff --git a/lib/align/aligned_utils.py b/lib/align/aligned_utils.py index c1e20dfc7f..abe9130bc9 100644 --- a/lib/align/aligned_utils.py +++ b/lib/align/aligned_utils.py @@ -294,6 +294,51 @@ def sub_crop(image: npt.NDArray[np.uint8 | np.float32], # pylint:disable=too-ma # Batch functions +def batch_create_matrices(size: int, + rotation: npt.NDArray[np.float32], + scale: npt.NDArray[np.float32] | None = None, + translation: npt.NDArray[np.float32] | None = None + ) -> npt.NDArray[np.float32]: + """Generate affine transformation matrices for the given rotations, scales and translations + + Parameters + ---------- + size + The size of the image that the matrix is transforming to + rotation + A 1D batch of rotation amounts or ``None`` for no rotation. Default: ``None`` + scale + A 1D batch of scale amounts or ``None`` for no scaling. Default: ``None`` + translation + A 2D batch of (x, y) translation amounts or ``None`` for no translation. Default: ``None`` + + Returns + ------- + The (3, 3) transformation matrices for the requested transform + """ + theta = np.deg2rad(rotation) + cos_t = np.cos(theta) + sin_t = np.sin(theta) + if scale is not None: + cos_t *= scale + sin_t *= scale + + cx = cy = (size - 1) / 2.0 + + matrices = np.zeros((len(rotation), 3, 3), dtype=np.float32) + matrices[:, 0, 0] = cos_t + matrices[:, 0, 1] = sin_t + matrices[:, 1, 0] = -sin_t + matrices[:, 1, 1] = cos_t + matrices[:, 0, 2] = cx * (1 - cos_t) - cy * sin_t + matrices[:, 1, 2] = cx * sin_t + cy * (1 - cos_t) + if translation is not None: + matrices[:, :2, 2] += translation + matrices[:, 2, :] = [0., 0., 1.] + logger.trace("Created affine matrices: %s", matrices.tolist()) # type:ignore[attr-defined] + return matrices + + def batch_transform(matrices: npt.NDArray[np.float32], points: npt.NDArray[np.float32], in_place: bool = False) -> npt.NDArray[np.float32]: diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index 4ca4e08ea2..4f4d3f6734 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -202,8 +202,10 @@ def clear_all_identities(self) -> None: def get_landmark_mask(self, area: T.Literal["eye", "mouth", "face", "face_extended"], - blur_kernel: int, - dilation: float) -> np.ndarray: + dilation: float = 0, + blur_kernel: int = 0, + blur_type: T.Literal["gaussian", "normalized"] | None = "gaussian", + blur_passes: int = 1) -> npt.NDArray[np.uint8]: """Obtain a :class:`~lib.align.aligned_mask.LandmarksMask` for this face Landmark based masks are generated from Aligned Face landmark points. An aligned face must @@ -215,19 +217,27 @@ def get_landmark_mask(self, area The type of mask to obtain. `face` is a full face mask, `face_extended` is a face mask that extends above the eyebrows. The others are masks for those specific areas - blur_kernel - The size of the kernel for blurring the mask edges dilation - The amount of dilation to apply to the mask. as a percentage of the mask size + The amount of dilation to apply to the mask. as a percentage of the mask size. + Default: 0 + blur_kernel + The kernel size, in pixels to apply gaussian blurring to the mask. Set to 0 for no + blurring. Should be odd, if an even number is passed in (outside of 0) then it is + rounded up to the next odd number. Default: 0 + blur_type + The blur type to use. ``gaussian`` or ``normalized`` box filter. Default: ``gaussian`` + blur_passes + The number of passed to perform when blurring. Default: 1 Returns ------- The generated landmarks mask for the selected area """ - mask = self.aligned.get_landmark_mask(area, dilation) - mask.set_blur_and_threshold(blur_kernel=blur_kernel) - mask.generate_mask() - return mask.mask + return self.aligned.get_landmark_mask(area, + dilation=dilation, + blur_kernel=blur_kernel, + blur_type=blur_type, + blur_passes=blur_passes) def store_training_masks(self, masks: list[np.ndarray | None], diff --git a/lib/align/objects.py b/lib/align/objects.py index 70ec9ffb91..b24e5e0c40 100644 --- a/lib/align/objects.py +++ b/lib/align/objects.py @@ -11,7 +11,7 @@ from lib.logger import format_array -from .aligned_face import CenteringType +from .constants import CenteringType @dataclass @@ -19,8 +19,15 @@ class DataclassDict: """Parent DataClass that has methods for loading to and from a dict for data serialization""" def __repr__(self) -> str: """Pretty print for logging""" - params = {k: format_array(v) if isinstance(v, np.ndarray) else v - for k, v in self.__dict__.items()} + params = {} + for k, v in self.__dict__.items(): + if isinstance(v, np.ndarray): + params[k] = format_array(v) + continue + if isinstance(v, bytes): + params[k] = f"{len(v)}b" + continue + params[k] = v s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) return f"{self.__class__.__name__}({s_params})" diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 0d1070f310..4f319cdf3b 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -24,15 +24,14 @@ class ScriptExecutor(): """Loads the relevant script modules and executes the script. - This class is initialized in each of the arg parsers for the relevant - command, then execute script is called within their set_default - function. - - Parameters - ---------- - command - The faceswap command that is being executed - """ + This class is initialized in each of the arg parsers for the relevant command, then execute + script is called within their set_default function. + + Parameters + ---------- + command + The faceswap command that is being executed + """ def __init__(self, command: str) -> None: self._command = command.lower() @@ -76,10 +75,10 @@ def _test_for_torch_version(self) -> None: Raises ------ FaceswapError - If PyTorch is not found, or is not between versions 2.3 and 2.9 + If PyTorch is not found, or is not between versions 2.3 and 2.11 """ min_ver = (2, 3) - max_ver = (2, 9) + max_ver = (2, 11) try: import torch # noqa:F401 pylint:disable=unused-import,import-outside-toplevel except ImportError as err: diff --git a/lib/config/config.py b/lib/config/config.py index dfd8cf5e9c..b8f28f1a31 100644 --- a/lib/config/config.py +++ b/lib/config/config.py @@ -7,6 +7,7 @@ import logging import os import sys +import typing as T from importlib import import_module @@ -186,9 +187,10 @@ def set_defaults(self, helptext: str = "") -> None: # Add global sub-sections for key, val in vars(sys.modules[self.__module__]).items(): if inspect.isclass(val) and issubclass(val, GlobalSection) and val != GlobalSection: + g_val = T.cast(GlobalSection, val) section_name = f"{section}.{key.lower()}" - self.add_section(section_name, val.helptext) - for opt_name, opt in val.__dict__.items(): + self.add_section(section_name, g_val.helptext) + for opt_name, opt in g_val.__dict__.items(): if isinstance(opt, ConfigItem): self.add_item(section=section_name, title=opt_name, config_item=opt) diff --git a/lib/convert.py b/lib/convert.py index a2f3d137bf..c22b6bcb14 100644 --- a/lib/convert.py +++ b/lib/convert.py @@ -8,6 +8,7 @@ import cv2 import numpy as np +from lib.align.aligned_mask import LandmarksMask from lib.utils import get_module_objects from plugins.plugin_loader import PluginLoader @@ -446,7 +447,10 @@ def _get_image_mask(self, m_type: T.Literal["face", "face_extended"] = ( "face" if self._args.mask_type == "components" else "face_extended" ) - lm_mask = reference_face.get_landmark_mask(m_type, dilation=0.0) + lm_mask = LandmarksMask(m_type, + reference_face.landmark_type, + reference_face.landmarks, + reference_face.size) elif self._args.mask_type not in ("none", "predicted"): mask_centering = detected_face.mask[self._args.mask_type].stored_centering else: diff --git a/lib/gui/popup_configure.py b/lib/gui/popup_configure.py index 405082c665..b49450627a 100644 --- a/lib/gui/popup_configure.py +++ b/lib/gui/popup_configure.py @@ -15,7 +15,7 @@ from .control_helper import ControlPanel, ControlPanelOption from .custom_widgets import Tooltip -from .utils import FileHandler, get_config, get_images, PATHCACHE +from .utils import FileHandler, get_config, get_images, PATH_CACHE if T.TYPE_CHECKING: from lib.config import FaceswapConfig @@ -700,7 +700,7 @@ def __init__(self, parent: DisplayArea, top_level: tk.Toplevel): logger.debug(parse_class_init(locals())) self._parent = parent self._popup = top_level - self._base_path = os.path.join(PATHCACHE, "presets") + self._base_path = os.path.join(PATH_CACHE, "presets") self._serializer = get_serializer("json") logger.debug("Initialized: %s", self.__class__.__name__) diff --git a/lib/gui/utils/__init__.py b/lib/gui/utils/__init__.py index 24e46983d7..d71121b787 100644 --- a/lib/gui/utils/__init__.py +++ b/lib/gui/utils/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin python3 """ Utilities for the Faceswap GUI """ -from .config import get_config, initialize_config, PATHCACHE +from .config import get_config, initialize_config, PATH_CACHE from .file_handler import FileHandler from .image import get_images, initialize_images, preview_trigger from .misc import LongRunningTask diff --git a/lib/gui/utils/config.py b/lib/gui/utils/config.py index 92661e5809..8fddd1d4c3 100644 --- a/lib/gui/utils/config.py +++ b/lib/gui/utils/config.py @@ -1,5 +1,5 @@ #!/usr/bin python3 -""" Global configuration optiopns for the Faceswap GUI """ +""" Global configuration options for the Faceswap GUI """ from __future__ import annotations import logging import os @@ -24,7 +24,7 @@ logger = logging.getLogger(__name__) -PATHCACHE = os.path.join(PROJECT_ROOT, "lib", "gui", ".cache") +PATH_CACHE = os.path.join(PROJECT_ROOT, "lib", "gui", ".cache") _CONFIG: Config | None = None @@ -191,7 +191,7 @@ def __init__(self, tasks=Tasks(self, FileHandler), status_bar=statusbar) - self._style = Style(self.default_font, root, PATHCACHE) + self._style = Style(self.default_font, root, PATH_CACHE) self._user_theme = self._style.user_theme logger.debug("Initialized %s", self.__class__.__name__) @@ -209,7 +209,7 @@ def scaling_factor(self) -> float: @property def pathcache(self) -> str: """ str: The path to the GUI cache folder """ - return PATHCACHE + return PATH_CACHE # GUI Objects @property @@ -328,7 +328,7 @@ def set_command_notebook(self, notebook: CommandNotebook) -> None: notebook: :class:`lib.gui.command.CommandNotebook` The main command notebook for the Faceswap GUI """ - logger.debug("Setting commane notebook: %s", notebook) + logger.debug("Setting command notebook: %s", notebook) self._gui_objects.command_notebook = notebook self.project.set_modified_callback() @@ -365,11 +365,11 @@ def set_modified_true(self, command: str) -> None: The command to set the modified state to ``True`` """ - tkvar = self.modified_vars.get(command, None) - if tkvar is None: - logger.debug("No tkvar for command: '%s'", command) + tk_var = self.modified_vars.get(command, None) + if tk_var is None: + logger.debug("No tk_var for command: '%s'", command) return - tkvar.set(True) + tk_var.set(True) logger.debug("Set modified var to True for: '%s'", command) def set_cursor_busy(self, widget: tk.Widget | None = None) -> None: diff --git a/lib/gui/utils/image.py b/lib/gui/utils/image.py index 05305aab75..88089005ee 100644 --- a/lib/gui/utils/image.py +++ b/lib/gui/utils/image.py @@ -13,7 +13,7 @@ from lib.training.preview_cv import PreviewBuffer from lib.utils import get_module_objects -from .config import get_config, PATHCACHE +from .config import get_config, PATH_CACHE if T.TYPE_CHECKING: from collections.abc import Sequence @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) _IMAGES: Images | None = None _PREVIEW_TRIGGER: PreviewTrigger | None = None -TRAININGPREVIEW = ".gui_training_preview.png" +TRAINING_PREVIEW = ".gui_training_preview.png" def initialize_images() -> None: @@ -100,7 +100,7 @@ def load(self) -> bool: logger.trace("Loading Training preview images") # type:ignore image_files = _get_previews(self._cache_path) filename = next((fname for fname in image_files - if os.path.basename(fname) == TRAININGPREVIEW), "") + if os.path.basename(fname) == TRAINING_PREVIEW), "") img: np.ndarray | None = None if not filename: logger.trace("No preview to display") # type:ignore @@ -297,10 +297,10 @@ def _process_samples(self, Returns ------- bool - ``True`` if samples succesfully compiled otherwise ``False`` + ``True`` if samples successfully compiled otherwise ``False`` """ - asamples = np.array(samples) - if not np.any(asamples): + a_samples = np.array(samples) + if not np.any(a_samples): logger.debug("No preview images collected.") return False @@ -309,10 +309,10 @@ def _process_samples(self, if cache is None: logger.debug("Creating new cache") - cache = asamples[-num_images:] + cache = a_samples[-num_images:] else: logger.debug("Appending to existing cache") - cache = np.concatenate((cache, asamples))[-num_images:] + cache = np.concatenate((cache, a_samples))[-num_images:] self._images = cache assert self._images is not None @@ -400,9 +400,9 @@ def _create_placeholder(self, thumbnail_size: int) -> None: placeholder = Image.new("RGB", (thumbnail_size, thumbnail_size)) draw = ImageDraw.Draw(placeholder) draw.rectangle(((0, 0), (thumbnail_size, thumbnail_size)), outline="#E5E5E5", width=1) - nplaceholder = np.array(placeholder) - self._placeholder = nplaceholder - logger.debug("Created placeholder. shape: %s", nplaceholder.shape) + n_placeholder = np.array(placeholder) + self._placeholder = n_placeholder + logger.debug("Created placeholder. shape: %s", n_placeholder.shape) def _place_previews(self, frame_dims: tuple[int, int]) -> Image.Image | None: """ Format the preview thumbnails stored in the cache into a grid fitting the display @@ -462,7 +462,7 @@ def load_latest_preview(self, thumbnail_size: int, frame_dims: tuple[int, int]) Returns ------- bool - ``True`` if a preview was succesfully loaded otherwise ``False`` + ``True`` if a preview was successfully loaded otherwise ``False`` """ logger.debug("Loading preview image: (thumbnail_size: %s, frame_dims: %s)", thumbnail_size, frame_dims) @@ -522,11 +522,10 @@ class Images(): """ def __init__(self) -> None: logger.debug("Initializing %s", self.__class__.__name__) - self._pathpreview = os.path.join(PATHCACHE, "preview") - self._pathoutput: str | None = None + self._path_preview = os.path.join(PATH_CACHE, "preview") self._batch_mode = False - self._preview_train = PreviewTrain(self._pathpreview) - self._preview_extract = PreviewExtract(self._pathpreview) + self._preview_train = PreviewTrain(self._path_preview) + self._preview_extract = PreviewExtract(self._path_preview) self._icons = self._load_icons() logger.debug("Initialized %s", self.__class__.__name__) @@ -569,14 +568,14 @@ def _load_icons() -> dict[str, ImageTk.PhotoImage]: size = cfg.icon_size() size = int(round(size * get_config().scaling_factor)) icons: dict[str, ImageTk.PhotoImage] = {} - pathicons = os.path.join(PATHCACHE, "icons") - for fname in os.listdir(pathicons): + path_icons = os.path.join(PATH_CACHE, "icons") + for fname in os.listdir(path_icons): name, ext = os.path.splitext(fname) if ext != ".png": continue - img = Image.open(os.path.join(pathicons, fname)) - pimg = ImageTk.PhotoImage(img.resize((size, size), resample=Image.Resampling.HAMMING)) - icons[name] = pimg + img = Image.open(os.path.join(path_icons, fname)) + p_img = ImageTk.PhotoImage(img.resize((size, size), resample=Image.Resampling.HAMMING)) + icons[name] = p_img logger.debug(icons) return icons @@ -586,18 +585,18 @@ def delete_preview(self) -> None: Should be called when terminating tasks, or when Faceswap starts up or shuts down. """ logger.debug("Deleting previews") - for item in os.listdir(self._pathpreview): - if item.startswith(os.path.splitext(TRAININGPREVIEW)[0]) and item.endswith((".jpg", + for item in os.listdir(self._path_preview): + if item.startswith(os.path.splitext(TRAINING_PREVIEW)[0]) and item.endswith((".jpg", ".png")): - fullitem = os.path.join(self._pathpreview, item) - logger.debug("Deleting: '%s'", fullitem) - os.remove(fullitem) + full_item = os.path.join(self._path_preview, item) + logger.debug("Deleting: '%s'", full_item) + os.remove(full_item) self._preview_extract.delete_previews() del self._preview_train del self._preview_extract - self._preview_train = PreviewTrain(self._pathpreview) - self._preview_extract = PreviewExtract(self._pathpreview) + self._preview_train = PreviewTrain(self._path_preview) + self._preview_extract = PreviewExtract(self._path_preview) class PreviewTrigger(): @@ -608,8 +607,8 @@ class PreviewTrigger(): """ def __init__(self) -> None: logger.debug("Initializing: %s", self.__class__.__name__) - self._trigger_files = {"update": os.path.join(PATHCACHE, ".preview_trigger"), - "mask_toggle": os.path.join(PATHCACHE, ".preview_mask_toggle")} + self._trigger_files = {"update": os.path.join(PATH_CACHE, ".preview_trigger"), + "mask_toggle": os.path.join(PATH_CACHE, ".preview_mask_toggle")} logger.debug("Initialized: %s (trigger_files: %s)", self.__class__.__name__, self._trigger_files) diff --git a/lib/infer/detect.py b/lib/infer/detect.py index 69a97a87a6..f3f362c2cb 100644 --- a/lib/infer/detect.py +++ b/lib/infer/detect.py @@ -9,6 +9,7 @@ import numpy as np +from lib.align.aligned_utils import batch_create_matrices from lib.logger import format_array, parse_class_init from lib.utils import get_module_objects @@ -384,7 +385,7 @@ def __init__(self, angles: str | None, image_size: int) -> None: logger.debug(parse_class_init(locals())) self._size = image_size self._angles = self._get_angles(angles) - self._matrices = self._pre_compute_matrices() + self._matrices = batch_create_matrices(self._size, rotation=self._angles) self._matrices_inverse = self._pre_compute_inverse_matrices() self._channels_first: bool | None = None self.enabled = len(self._angles) > 1 @@ -432,30 +433,6 @@ def _get_angles(self, rotation: str | None) -> npt.NDArray[np.float32]: logger.debug("Setting rotation angles to %s from given: %s", retval, rotation) return retval - def _pre_compute_matrices(self) -> npt.NDArray[np.float32]: - """Pre-compute the rotation matrices required to perform the requested rotations for the - given square image size - - Returns - ------- - The rotation matrices for the requested rotation angles - """ - theta = np.deg2rad(self._angles) - cos_t = np.cos(theta) - sin_t = np.sin(theta) - cx = (self._size - 1) / 2.0 - cy = (self._size - 1) / 2.0 - - matrices = np.zeros((len(self._angles), 2, 3), dtype=np.float32) - matrices[:, 0, 0] = cos_t - matrices[:, 0, 1] = -sin_t - matrices[:, 1, 0] = sin_t - matrices[:, 1, 1] = cos_t - matrices[:, 0, 2] = (1 - cos_t) * cx + sin_t * cy - matrices[:, 1, 2] = (1 - cos_t) * cy - sin_t * cx - logger.debug("Precomputed rotation matrices: %s", matrices.tolist()) - return matrices - def _pre_compute_inverse_matrices(self) -> npt.NDArray[np.float32]: """Pre-compute the inverse rotation matrices required to perform translation from rotated bounding boxes back to original frame diff --git a/lib/system/system.py b/lib/system/system.py index c2715fab59..a69ee09a29 100644 --- a/lib/system/system.py +++ b/lib/system/system.py @@ -25,10 +25,10 @@ VALID_PYTHON = ((3, 11), (3, 13)) """ tuple[tuple[int, int], tuple[int, int]] : The minimum and maximum versions of Python that can run Faceswap """ -VALID_TORCH = ((2, 3), (2, 10)) +VALID_TORCH = ((2, 3), (2, 11)) """ tuple[tuple[int, int], tuple[int, int]] : The minimum and maximum versions of Torch that can run Faceswap """ -VALID_KERAS = ((3, 13), (3, 13)) +VALID_KERAS = ((3, 13), (3, 14)) """ tuple[tuple[int, int], tuple[int, int]] : The minimum and maximum versions of Keras that can run Faceswap """ diff --git a/lib/training/__init__.py b/lib/training/__init__.py index cc254c0fae..44a7ea1282 100644 --- a/lib/training/__init__.py +++ b/lib/training/__init__.py @@ -4,8 +4,7 @@ from __future__ import annotations import typing as T -from .augmentation import ImageAugmentation -from .generator import Feeder +from .data_augmentation import ImageAugmentation from .lr_finder import LearningRateFinder from .lr_warmup import LearningRateWarmup from .preview_cv import PreviewBuffer, TriggerType diff --git a/lib/training/cache.py b/lib/training/cache.py deleted file mode 100644 index 6b413c2511..0000000000 --- a/lib/training/cache.py +++ /dev/null @@ -1,717 +0,0 @@ -#!/usr/bin/env python3 -""" Holds the data cache for training data generators """ -from __future__ import annotations -import logging -import os -import typing as T - -from dataclasses import dataclass, field -from threading import Lock - -import cv2 -import numpy as np -from tqdm import tqdm - -from lib.align import CenteringType, DetectedFace, LandmarkType -from lib.align.objects import PNGHeader -from lib.image import read_image_batch, read_image_meta_batch -from lib.logger import parse_class_init -from lib.utils import FaceswapError, get_module_objects -from plugins.train import train_config as cfg - -if T.TYPE_CHECKING: - from lib.align.objects import PNGAlignments - from lib import align - -logger = logging.getLogger(__name__) -_FACE_CACHES: dict[str, Cache] = {} - - -@dataclass -class _MaskConfig: - """ Holds the constants required for manipulating training masks """ - # pylint:disable=unnecessary-lambda - penalized: bool = field(default_factory=lambda: cfg.Loss.penalized_mask_loss()) - learn: bool = field(default_factory=lambda: cfg.Loss.learn_mask()) - mask_type: str | None = field(default_factory=lambda: None - if cfg.Loss.mask_type() == "none" - else cfg.Loss.mask_type()) - dilation: float = field(default_factory=lambda: cfg.Loss.mask_dilation()) - kernel: int = field(default_factory=lambda: cfg.Loss.mask_blur_kernel()) - threshold: int = field(default_factory=lambda: cfg.Loss.mask_threshold()) - multiplier_enabled: bool = field( - default_factory=lambda: ((cfg.Loss.eye_multiplier() > 1 or cfg.Loss.mouth_multiplier() > 1) - and cfg.Loss.penalized_mask_loss())) - - @property - def mask_enabled(self) -> bool: - """ bool : ``True`` if any of :attr:`penalized` or :attr:`learn` are true and - :attr:`mask_type` is not ``None`` """ - return self.mask_type is not None and (self.learn or self.penalized) - - -class _MaskProcessing: - """ Handle the extraction and processing of masks from faceswap PNG headers for caching - - Parameters - ---------- - size : int - The largest output size of the model - coverage_ratio : float - The coverage ratio that the model is using. - centering : Literal["face", "head", "legacy"] - """ - def __init__(self, - size: int, - coverage_ratio: float, - centering: CenteringType) -> None: - - assert isinstance(size, int) - assert isinstance(coverage_ratio, float) - assert centering in T.get_args(CenteringType) - - self._size = size - self._coverage = coverage_ratio - self._centering: CenteringType = centering - - self._config = _MaskConfig() - logger.debug("Initialized %s", self) - - def __repr__(self) -> str: - """ Pretty print for logging """ - params = f"coverage_ratio={repr(self._coverage)}, centering={repr(self._centering)}" - return f"{self.__class__.__name__}({params})" - - def _check_mask_exists(self, filename: str, detected_face: DetectedFace) -> None: - """ Check that the requested mask exists for the current detected face - - Parameters - ---------- - filename : str - The file path for the current image - detected_face : :class:`~lib.align.detected_face.DetectedFace` - The detected face object that holds the masks - - Raises - ------ - FaceswapError - If the requested mask type is not available an error is returned along with a list - of available masks - """ - if self._config.mask_type in detected_face.mask: - return - - exist_masks = list(detected_face.mask) - msg = "No masks exist for this face" - if exist_masks: - msg = f"The masks that exist for this face are: {exist_masks}" - raise FaceswapError( - f"You have selected the mask type '{self._config.mask_type}' but at least one " - "face does not contain the selected mask.\n" - f"The face that failed was: '{filename}'\n{msg}") - - def _preprocess(self, detected_face: DetectedFace, mask_type: str) -> align.aligned_mask.Mask: - """ Apply pre-processing to the mask - - Parameters - ---------- - detected_face : :class:`~lib.align.detected_face.DetectedFace` - The detected face object that holds the masks - mask_type : str - The stored mask type to use - - Returns - ------- - :class:`~lib.align.aligned_mask.Mask` - The pre-processed mask at its stored size and crop - """ - mask = detected_face.mask[mask_type] - mask.set_dilation(self._config.dilation) - mask.set_blur_and_threshold(blur_kernel=self._config.kernel, - threshold=self._config.threshold) - return mask - - def _crop_and_resize(self, - detected_face: DetectedFace, - mask: align.aligned_mask.Mask) -> np.ndarray: - """ Crop and resize the mask to the correct centering and training size - - Parameters - ---------- - detected_face : :class:`~lib.align.detected_face.DetectedFace` - The detected face object that holds the masks - mask : :class:`~lib.align.aligned_mask.Mask` - The pre-processed mask at its stored size and crop - - Returns - ------- - :class:`numpy.ndarray` - The processed, cropped and resized final mask - """ - pose = detected_face.aligned.pose - mask.set_sub_crop(pose.offset[mask.stored_centering], - pose.offset[self._centering], - self._centering, - self._coverage, - detected_face.aligned.y_offset) - face_mask = mask.mask - if self._size != face_mask.shape[0]: - interpolator = cv2.INTER_CUBIC if mask.stored_size < self._size else cv2.INTER_AREA - face_mask = cv2.resize(face_mask, - (self._size, self._size), - interpolation=interpolator)[..., None] - return face_mask - - def _get_face_mask(self, filename: str, detected_face: DetectedFace) -> np.ndarray | None: - """ Obtain the training sized face mask from the DetectedFace for the requested mask type. - - Parameters - ---------- - filename : str - The file path for the current image - detected_face : :class:`~lib.align.detected_face.DetectedFace` - The detected face object that holds the masks - - Returns - ------- - :class:`numpy.ndarray` | None - The face mask used for training or ``None`` if masks are disabled - """ - if not self._config.mask_enabled: - return None - - mask_type = self._config.mask_type - assert mask_type is not None - if mask_type in ("components", "extended"): - name = T.cast(T.Literal["face", "face_extended"], - "face_extended" if mask_type == "extended" else "face") - try: - retval = detected_face.get_landmark_mask(name, - self._config.kernel, - self._config.dilation) - except FaceswapError as err: - logger.error(str(err)) - raise FaceswapError(f"'{mask_type}' masks could not be generated due to missing " - f"landmark data. The file that failed was: '{filename}'" - ) from err - else: - self._check_mask_exists(filename, detected_face) - mask = self._preprocess(detected_face, mask_type) - retval = self._crop_and_resize(detected_face, mask) - logger.trace("Obtained face mask for: %s %s", # type:ignore[attr-defined] - filename, retval.shape) - return retval - - def _get_localized_mask(self, - filename: str, - detected_face: DetectedFace, - area: T.Literal["eye", "mouth"]) -> np.ndarray | None: - """ Obtain a localized mask for the given area if it is required for training. - - Parameters - ---------- - filename : str - The file path for the current image - detected_face : :class:`~lib.align.detected_face.DetectedFace` - The detected face object that holds the masks - area : Literal["eye", "mouth"] - The area of the face to obtain the mask for - - Raises - ------ - :class:`~lib.utils.FaceswapError` - If landmark data is not available to generate the localized mask - """ - if not self._config.multiplier_enabled: - return None - - try: - mask = detected_face.get_landmark_mask(area, self._size // 16, 2.5) - except FaceswapError as err: - logger.error(str(err)) - raise FaceswapError("Eye/Mouth multiplier masks could not be generated due to missing " - f"landmark data. The file that failed was: '{filename}'") from err - logger.trace("Caching localized '%s' mask for: %s %s", # type:ignore[attr-defined] - area, filename, mask.shape) - return mask - - def __call__(self, filename: str, detected_face: DetectedFace) -> None: - """ Prepare the masks required for training and compile into a single compressed array - within the given DetectedFaces object - - Parameters - ---------- - filename : str - The file path for the image that masks are to be prepared for - detected_face : :class:`~lib.align.detected_face.DetectedFace` - The detected face object that holds the masks - """ - masks = [(self._get_face_mask(filename, detected_face))] - for area in T.get_args(T.Literal["eye", "mouth"]): - masks.append(self._get_localized_mask(filename, detected_face, area)) - - detected_face.store_training_masks(masks, delete_masks=True) - logger.trace("Stored masks for filename: %s)", filename) # type:ignore[attr-defined] - - -def _check_reset(face_cache: "Cache") -> bool: - """ Check whether a given cache needs to be reset because a face centering change has been - detected in the other cache. - - Parameters - ---------- - face_cache : :class:`Cache` - The cache object that is checking whether it should reset - - Returns - ------- - bool - ``True`` if the given object should reset the cache, otherwise ``False`` - """ - check_cache = next((cache for cache in _FACE_CACHES.values() if cache != face_cache), None) - retval = False if check_cache is None else check_cache.check_reset() - return retval - - -@dataclass -class _CacheConfig: - """ Holds the configuration options for the cache """ - size: int - """ int : The size to load images at """ - centering: CenteringType - """ Literal["face", "head", "legacy"] : The centering type to train at """ - coverage: float - """ float : The selected coverage ration for training """ - - -class Cache(): - """ A thread safe mechanism for collecting and holding face meta information (masks, - alignments data etc.) for multiple :class:`~lib.training.generator.TrainingDataGenerator`. - - Each side may have up to 3 generators (training, preview and time-lapse). To conserve RAM - these need to share access to the same face information for the images they are processing. - - As the cache is populated at run-time, thread safe writes are required for the first epoch. - Following that, the cache is only used for reads, which is thread safe intrinsically. - - It would probably be quicker to set locks on each individual face, but for code complexity - reasons, and the fact that the lock is only taken up during cache population, and it should - only be being read multiple times on save iterations, we lock the whole cache during writes. - - Parameters - ---------- - filenames : list[str] - The filenames of all the images. This can either be the full path or the base name. If the - full paths are passed in, they are stripped to base name for use as the cache key. - size : int - The largest output size of the model - coverage_ratio : float - The coverage ratio that the model is using. - """ - def __init__(self, - filenames: list[str], - size: int, - coverage_ratio: float) -> None: - logger.debug(parse_class_init(locals())) - self._lock = Lock() - self._cache_info = {"cache_full": False, "has_reset": False} - self._partially_loaded: list[str] = [] - - self._image_count = len(filenames) - self._cache: dict[str, DetectedFace] = {} - self._aligned_landmarks: dict[str, np.ndarray] = {} - self._extract_version = 0.0 - - self._config = _CacheConfig(size=size, - centering=T.cast(CenteringType, cfg.centering()), - coverage=coverage_ratio) - self._mask_prepare = _MaskProcessing(size, coverage_ratio, self._config.centering) - logger.debug("Initialized: %s", self.__class__.__name__) - - @property - def cache_full(self) -> bool: - """ bool : ``True`` if the cache has been fully populated. ``False`` if there are items - still to be cached. """ - if self._cache_info["cache_full"]: - return self._cache_info["cache_full"] - with self._lock: - return self._cache_info["cache_full"] - - @property - def aligned_landmarks(self) -> dict[str, np.ndarray]: - """ dict[str, :class:`numpy.ndarray`] : filename as key, aligned landmarks as value. """ - # Note: Aligned landmarks are only used for warp-to-landmarks, so this can safely populate - # all of the aligned landmarks for the entire cache. - if not self._aligned_landmarks: - with self._lock: - # For Warp-To-Landmarks a race condition can occur where this is referenced from - # the opposite side prior to it being populated, so block on a lock. - self._aligned_landmarks = {key: face.aligned.landmarks - for key, face in self._cache.items()} - return self._aligned_landmarks - - @property - def size(self) -> int: - """ int : The pixel size of the cropped aligned face """ - return self._config.size - - def get_items(self, filenames: list[str]) -> list[DetectedFace]: - """ Obtain the cached items for a list of filenames. The returned list is in the same order - as the provided filenames. - - Parameters - ---------- - filenames : list[str] - A list of image filenames to obtain the cached data for - - Returns - ------- - list[:class:`~lib.align.detected_face.DetectedFace`] - List of DetectedFace objects holding the cached metadata. The list returns in the same - order as the filenames received - """ - return [self._cache[os.path.basename(filename)] for filename in filenames] - - def check_reset(self) -> bool: - """ Check whether this cache has been reset due to a face centering change, and reset the - flag if it has. - - Returns - ------- - bool - ``True`` if the cache has been reset because of a face centering change due to - legacy alignments, otherwise ``False``. """ - retval = self._cache_info["has_reset"] - if retval: - logger.debug("Resetting 'has_reset' flag") - self._cache_info["has_reset"] = False - return retval - - def _reset_cache(self, set_flag: bool) -> None: - """ In the event that a legacy extracted face has been seen, and centering is not legacy - the cache will need to be reset for legacy centering. - - Parameters - ---------- - set_flag: bool - ``True`` if the flag should be set to indicate that the cache is being reset because of - a legacy face set/centering mismatch. ``False`` if the cache is being reset because it - has detected a reset flag from the opposite cache. - """ - if set_flag: - logger.warning("You are using legacy extracted faces but have selected '%s' centering " - "which is incompatible. Switching centering to 'legacy'", - self._config.centering) - cfg.centering.set("legacy") - self._config.centering = "legacy" - self._cache = {} - self._cache_info["cache_full"] = False - if set_flag: - self._cache_info["has_reset"] = True - - def _validate_version(self, png_meta: PNGHeader, filename: str) -> None: - """Validate that there are not a mix of v1.0 extracted faces and v2.x faces. - - Parameters - ---------- - png_meta - The information held within the Faceswap PNG Header - filename - The full path to the file being validated - - Raises - ------ - :class:`~lib.utils.FaceswapError` - If a version 1.0 face appears in a 2.x set or vice versa - """ - alignment_version = png_meta.source.alignments_version - - if not self._extract_version: - logger.debug("Setting initial extract version: %s", alignment_version) - self._extract_version = alignment_version - if alignment_version == 1.0 and self._config.centering != "legacy": - self._reset_cache(True) - return - - if (self._extract_version == 1.0 and alignment_version > 1.0) or ( - alignment_version == 1.0 and self._extract_version > 1.0): - raise FaceswapError("Mixing legacy and full head extracted face sets is not " - "supported. The following folder contains a mix of extracted face " - f"types: '{os.path.dirname(filename)}'") - - self._extract_version = min(alignment_version, self._extract_version) - - def _load_detected_face(self, - filename: str, - alignments: PNGAlignments) -> DetectedFace: - """Load a :class:`~lib.align.detected_face.DetectedFace` object and load its associated - `aligned` property. - - Parameters - ---------- - filename - The file path for the current image - alignments - The alignments for a single face, extracted from a PNG header - - Returns - ------- - The loaded Detected Face object - """ - y_offset = cfg.vertical_offset() - detected_face = DetectedFace() - detected_face.from_png_meta(alignments) - detected_face.load_aligned(None, - size=self._config.size, - centering=self._config.centering, - coverage_ratio=self._config.coverage, - y_offset=y_offset / 100., - is_aligned=True, - is_legacy=self._extract_version == 1.0) - logger.trace("Cached aligned face for: %s", filename) # type:ignore[attr-defined] - return detected_face - - def _populate_cache(self, - needs_cache: list[str], - metadata: list[PNGHeader], - filenames: list[str]) -> None: - """Populate the given items into the cache - - Parameters - ---------- - needs_cache - The full path to files within this batch that require caching - metadata - The faceswap metadata loaded from the image png header - filenames - Full path to the filenames that are being loaded in this batch - """ - for filename in needs_cache: - key = os.path.basename(filename) - meta = metadata[filenames.index(filename)] - - # Version Check - self._validate_version(meta, filename) - if self._partially_loaded: # Faces already loaded for Warp-to-landmarks - self._partially_loaded.remove(key) - detected_face = self._cache[key] - else: - detected_face = self._load_detected_face(filename, meta.alignments) - - self._mask_prepare(filename, detected_face) - self._cache[key] = detected_face - - def _get_batch_with_metadata(self, - filenames: list[str]) -> tuple[np.ndarray, list[PNGHeader]]: - """ Load a batch of images along with their faceswap metadata for loading into the cache - - Parameters - ---------- - filenames - Full path to the images to be loaded - - Returns - ------- - batch - The batch of images in a single array - metadata - The faceswap metadata corresponding to each image in the batch - """ - try: - batch, metadata = read_image_batch(filenames, with_metadata=True) - except ValueError as err: - if "inhomogeneous" in str(err): - raise FaceswapError( - "There was an error loading a batch of images. This is most likely due to " - "non-faceswap extracted faces in your training folder." - "\nAll training images should be Faceswap extracted faces." - "\nAll training images should be the same size." - f"\nThe files that caused this error are: {filenames}") from err - raise - if len(batch.shape) == 1: - folder = os.path.dirname(filenames[0]) - keys = [os.path.basename(filename) for filename in filenames] - details = [ - f"{key} ({f'{img.shape[1]}px' if isinstance(img, np.ndarray) else type(img)})" - for key, img in zip(keys, batch)] - msg = (f"There are mismatched image sizes in the folder '{folder}'. All training " - "images for each side must have the same dimensions.\nThe batch that " - f"failed contains the following files:\n{details}.") - raise FaceswapError(msg) - return batch, metadata - - def _update_cache_full(self, filenames: list[str]) -> None: - """ Check if cache is full and update the "cache_full" flag in :attr:`_cache_info` if so - - Parameters - ---------- - filenames : list[str] - Full path to the filenames being processed in the current batch - """ - cache_full = not self._partially_loaded and len(self._cache) == self._image_count - if cache_full: - logger.verbose("Cache filled: '%s'", # type:ignore[attr-defined] - os.path.dirname(filenames[0])) - self._cache_info["cache_full"] = cache_full - - def cache_metadata(self, filenames: list[str]) -> np.ndarray: - """ Obtain the batch with metadata for items that need caching and cache DetectedFace - objects to :attr:`_cache`. - - Parameters - ---------- - filenames : list[str] - List of full paths to image file names - - Returns - ------- - :class:`numpy.ndarray` - The batch of face images loaded from disk - """ - keys = [os.path.basename(filename) for filename in filenames] - with self._lock: - if _check_reset(self): - self._reset_cache(False) - - needs_cache = [filename for filename, key in zip(filenames, keys) - if key not in self._cache or key in self._partially_loaded] - logger.trace("Needs cache: %s", needs_cache) # type:ignore[attr-defined] - - if not needs_cache: # Metadata already cached. Just get images - logger.debug("All metadata already cached for: %s", keys) - return read_image_batch(filenames) - - batch, metadata = self._get_batch_with_metadata(filenames) - self._populate_cache(needs_cache, metadata, filenames) - self._update_cache_full(filenames) - - return batch - - def pre_fill(self, filenames: list[str], side: T.Literal["a", "b"]) -> None: - """ When warp to landmarks is enabled, the cache must be pre-filled, as each side needs - access to the other side's alignments. - - Parameters - ---------- - filenames : list[str] - The list of full paths to the images to load the metadata from - side : Literal["a", "b"] - The side of the model being cached. Used for info output - - Raises - ------ - :class:`~lib.utils.FaceSwapError` - If unsupported landmark type exists or a non-faceswap image is loaded - """ - with self._lock: - for filename, meta in tqdm(read_image_meta_batch(filenames), - desc=f"WTL: Caching Landmarks ({side.upper()})", - total=len(filenames), - leave=False): - if "itxt" not in meta or "alignments" not in meta["itxt"]: - raise FaceswapError(f"Invalid face image found. Aborting: '{filename}'") - - meta = PNGHeader.from_dict(meta["itxt"]) - key = os.path.basename(filename) - self._validate_version(meta, filename) - detected_face = self._load_detected_face(filename, meta.alignments) - - aligned = detected_face.aligned - assert aligned is not None - if aligned.landmark_type != LandmarkType.LM_2D_68: - raise FaceswapError("68 Point facial Landmarks are required for Warp-to-" - f"landmarks. The face that failed was: '{filename}'") - - self._cache[key] = detected_face - self._partially_loaded.append(key) - - -def get_cache(side: T.Literal["a", "b"], - filenames: list[str] | None = None, - size: int | None = None, - coverage_ratio: float | None = None) -> Cache: - """ Obtain a :class:`Cache` object for the given side. If the object does not pre-exist then - create it. - - Parameters - ---------- - side : Literal["a", "b"] - The side of the model to obtain the cache for - filenames : list[str] | None, optional - The filenames of all the images. This can either be the full path or the base name. If the - full paths are passed in, they are stripped to base name for use as the cache key. Must be - passed for the first call of this function for each side. For subsequent calls this - parameter is ignored. Default: ``None`` - size: int | None, optional - The largest output size of the model. Must be passed for the first call of this function - for each side. For subsequent calls this parameter is ignored. Default: ``None`` - coverage_ratio : float | None, optional - The coverage ratio that the model is using. Must be passed for the first call of this - function for each side. For subsequent calls this parameter is ignored. Default: ``None`` - - Returns - ------- - :class:`Cache` - The face meta information cache for the requested side - """ - assert side in ("a", "b") - if not _FACE_CACHES.get(side): - assert filenames is not None, "filenames must be provided for first call to cache" - assert size is not None, "size must be provided for first call to cache" - assert coverage_ratio is not None, ("coverage_ratio must be provided for first call to " - "cache") - logger.debug("Creating cache. side: %s, size: %s, coverage_ratio: %s", - side, size, coverage_ratio) - _FACE_CACHES[side] = Cache(filenames, size, coverage_ratio) - return _FACE_CACHES[side] - - -class RingBuffer(): - """ Rolling buffer for holding training/preview batches - - Parameters - ---------- - batch_size : int - The batch size to create the buffer for - image_shape : tuple[int, int, int] - The height/width/channels shape of a single image in the batch - buffer_size : int, optional - The number of arrays to hold in the rolling buffer. Default: `2` - dtype : str, optional - The datatype to create the buffer as. Default: `"uint8"` - """ - def __init__(self, - batch_size: int, - image_shape: tuple[int, int, int], - buffer_size: int = 2, - dtype: str = "uint8") -> None: - logger.debug(parse_class_init(locals())) - self._max_index = buffer_size - 1 - self._index = 0 - self._buffer = [np.empty((batch_size, *image_shape), dtype=dtype) - for _ in range(buffer_size)] - logger.debug("Initialized: %s", self) - - def __repr__(self) -> str: - """ Pretty string representation for logging """ - params = {"batch_size": repr(self._buffer[0].shape[0]), - "image_shape": repr(self._buffer[0].shape[1:]), - "buffer_size": repr(len(self._buffer)), - "dtype": repr(str(self._buffer[0].dtype))} - str_params = [f"{k}={v}" for k, v in params.items()] - return f"{self.__class__.__name__}({', '.join(str_params)})" - - def __call__(self) -> np.ndarray: - """ Obtain the next array from the ring buffer - - Returns - ------- - :class:`np.ndarray` - A pre-allocated numpy array from the buffer - """ - retval = self._buffer[self._index] - self._index += 1 if self._index < self._max_index else -self._max_index - return retval - - -__all__ = get_module_objects(__name__) diff --git a/lib/training/augmentation.py b/lib/training/data_augmentation.py similarity index 64% rename from lib/training/augmentation.py rename to lib/training/data_augmentation.py index 97eae59c64..69536efe02 100644 --- a/lib/training/augmentation.py +++ b/lib/training/data_augmentation.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 -""" Processes the augmentation of images for feeding into a Faceswap model. """ +"""Processes the augmentation of images for feeding into a Faceswap model.""" from __future__ import annotations import logging +import typing as T from dataclasses import dataclass import cv2 @@ -9,97 +10,100 @@ import numpy as np from scipy.interpolate import griddata +from lib.align.aligned_utils import batch_create_matrices from lib.image import batch_convert_color -from lib.logger import parse_class_init +from lib.logger import format_array, parse_class_init from lib.utils import get_module_objects from plugins.train.trainer import trainer_config as cfg +if T.TYPE_CHECKING: + import numpy.typing as npt logger = logging.getLogger(__name__) @dataclass class ConstantsColor: - """ Dataclass for holding constants for enhancing an image (ie contrast/color adjustment) + """Dataclass for holding constants for enhancing an image (ie contrast/color adjustment) Parameters ---------- - clahe_base_contrast : int + clahe_base_contrast The base number for Contrast Limited Adaptive Histogram Equalization - clahe_chance : float - Probability to perform Contrast Limited Adaptive Histogram Equilization - clahe_max_size : int + clahe_chance + Probability to perform Contrast Limited Adaptive Histogram Equalization + clahe_max_size Maximum clahe window size - lab_adjust : :class:`numpy.ndarray` + lab_adjust Adjustment amounts for L*A*B augmentation """ clahe_base_contrast: int - """ int : The base number for Contrast Limited Adaptive Histogram Equalization """ + """The base number for Contrast Limited Adaptive Histogram Equalization""" clahe_chance: float - """ float : Probability to perform Contrast Limited Adaptive Histogram Equilization """ + """Probability to perform Contrast Limited Adaptive Histogram Equalization""" clahe_max_size: int - """ int : Maximum clahe window size""" + """Maximum clahe window size""" lab_adjust: np.ndarray - """ :class:`numpy.ndarray` : Adjustment amounts for L*A*B augmentation """ + """Adjustment amounts for L*A*B augmentation""" @dataclass class ConstantsTransform: - """ Dataclass for holding constants for transforming an image + """Dataclass for holding constants for transforming an image Parameters ---------- - rotation : int + rotation Rotation range for transformations - zoom : float + zoom Zoom range for transformations - shift : float + shift Shift range for transformations """ rotation: int - """ int : Rotation range for transformations """ + """Rotation range for transformations""" zoom: float - """ float : Zoom range for transformations """ + """Zoom range for transformations""" shift: float - """ float : Shift range for transformations """ + """Shift range for transformations""" flip: float - """ float : The chance to flip an image """ + """The chance to flip an image""" @dataclass class ConstantsWarp: - """ Dataclass for holding constants for warping an image + """Dataclass for holding constants for warping an image Parameters ---------- - maps : :class:`numpy.ndarray` + maps The stacked (x, y) mappings for image warping - pad : tuple[int, int] + pad The padding to apply for image warping - slices : slice + slices The slices for extracting a warped image - lm_edge_anchors : :class:`numpy.ndarray` + lm_edge_anchors The edge anchors for landmark based warping - lm_grids : :class:`numpy.ndarray` + lm_grids The grids for landmark based warping """ maps: np.ndarray - """ :class:`numpy.ndarray` : The stacked (x, y) mappings for image warping """ + """The stacked (x, y) mappings for image warping""" pad: tuple[int, int] - """ :tuple[int, int] : The padding to apply for image warping """ + """The padding to apply for image warping""" slices: slice - """ slice : The slices for extracting a warped image """ + """The slices for extracting a warped image""" scale: float - """ float : The scaling to apply to standard warping """ + """The scaling to apply to standard warping""" lm_edge_anchors: np.ndarray - """ :class:`numpy.ndarray` : The edge anchors for landmark based warping """ + """The edge anchors for landmark based warping""" lm_grids: np.ndarray - """ :class:`numpy.ndarray` : The grids for landmark based warping """ + """The grids for landmark based warping""" lm_scale: float - """ float : The scaling to apply to landmark based warping """ + """The scaling to apply to landmark based warping""" def __repr__(self) -> str: - """ Display shape/type information for arrays in __repr__ """ + """Display shape/type information for arrays in __repr__""" params = {k: f"array[shape: {v.shape}, dtype: {v.dtype}]" if isinstance(v, np.ndarray) else v for k, v in self.__dict__.items()} @@ -109,15 +113,15 @@ def __repr__(self) -> str: @dataclass class ConstantsAugmentation: - """ Dataclass for holding constants for Image Augmentation. + """Dataclass for holding constants for Image Augmentation. Attributes ---------- - color : :class:`ConstantsColor` + color The constants for adjusting color/contrast in an image - transform : :class:`ConstantsTransform` + transform The constants for image transformation - warp : :class:`ConstantsTransform` + warp The constants for image warping Dataclass should be initialized using its :func:`from_config` method: @@ -128,112 +132,109 @@ class ConstantsAugmentation: ... batch_size=16) """ color: ConstantsColor - """ :class:`ConstantsColor` : The constants for adjusting color/contrast in an image """ + """The constants for adjusting color/contrast in an image""" transform: ConstantsTransform - """ :class:`ConstantsTransform` : The constants for image transformation """ + """The constants for image transformation""" warp: ConstantsWarp - """ :class:`ConstantsTransform` : The constants for image warping """ + """The constants for image warping""" @classmethod def _get_clahe(cls, size: int) -> tuple[int, float, int]: - """ Get the CLAHE constants from user config + """Get the CLAHE constants from user config Parameters ---------- - size : int + size The size of image to augment the data for Returns ------- - clahe_base_contrast : int + clahe_base_contrast The base number for Contrast Limited Adaptive Histogram Equalization - clahe_chance : float - Probability to perform Contrast Limited Adaptive Histogram Equilization - clahe_max_size : int + clahe_chance + Probability to perform Contrast Limited Adaptive Histogram Equalization + clahe_max_size Maximum clahe window size """ clahe_base_contrast = max(2, size // 128) - clahe_chance = cfg.color_clahe_chance() / 100 - clahe_max_size = cfg.color_clahe_max_size() - logger.debug("clahe_base_contrast: %s, clahe_chance: %s, clahe_max_size: %s", - clahe_base_contrast, clahe_chance, clahe_max_size) + clahe_chance = cfg.Augmentation.color_clahe_chance() / 100 + clahe_max_size = cfg.Augmentation.color_clahe_max_size() + logger.debug("[AugConstants] clahe_base_contrast: %s, clahe_chance: %s, " + "clahe_max_size: %s", clahe_base_contrast, clahe_chance, clahe_max_size) return clahe_base_contrast, clahe_chance, clahe_max_size @classmethod def _get_lab(cls) -> np.ndarray: - """ Load the random L*A*B augmentation constants + """Load the random L*A*B augmentation constants Returns ------- - :class:`numpy.ndarray` - Adjustment amounts for L*A*B augmentation + Adjustment amounts for L*A*B augmentation """ - amount_l = cfg.color_lightness() / 100. - amount_ab = cfg.color_ab() / 100. + amount_l = cfg.Augmentation.color_lightness() / 100. + amount_ab = cfg.Augmentation.color_ab() / 100. lab_adjust = np.array([amount_l, amount_ab, amount_ab], dtype="float32") - logger.debug("lab_adjust: %s", lab_adjust) + logger.debug("[AugConstants] lab_adjust: %s", lab_adjust) return lab_adjust @classmethod def _get_color(cls, size: int) -> ConstantsColor: - """ Get the image enhancements constants from user config + """Get the image enhancements constants from user config Parameters ---------- - size : int + size The size of image to augment the data for Returns ------- - :class:`ConstantsColor` - The constants for image enhancement + The constants for image enhancement """ clahe_base_contrast, clahe_chance, clahe_max_size = cls._get_clahe(size) retval = ConstantsColor(clahe_base_contrast=clahe_base_contrast, clahe_chance=clahe_chance, clahe_max_size=clahe_max_size, lab_adjust=cls._get_lab()) - logger.debug(retval) + logger.debug("[AugConstants] color: %s", retval) return retval @classmethod def _get_transform(cls, size: int) -> ConstantsTransform: - """ Load the random transform constants + """Load the random transform constants Parameters ---------- - size : int + size The size of image to augment the data for Returns ------- - :class:`ConstantsTransform` - The constants for image transformation + The constants for image transformation """ - retval = ConstantsTransform(rotation=cfg.rotation_range(), - zoom=cfg.zoom_amount() / 100., - shift=(cfg.shift_range() / 100.) * size, - flip=cfg.flip_chance() / 100.) - logger.debug(retval) + retval = ConstantsTransform(rotation=cfg.Augmentation.rotation_range(), + zoom=cfg.Augmentation.zoom_amount() / 100., + shift=(cfg.Augmentation.shift_range() / 100.) * size, + flip=cfg.Augmentation.flip_chance() / 100.) + logger.debug("[AugConstants] transform: %s", retval) return retval @classmethod def _get_warp_to_landmarks(cls, size: int, batch_size: int) -> tuple[np.ndarray, np.ndarray]: - """ Load the warp-to-landmarks augmentation constants + """Load the warp-to-landmarks augmentation constants Parameters ---------- - size : int + size The size of image to augment the data for - batch_size : int + batch_size The batch size that augmented data is being prepared for Returns ------- - edge_anchors : :class:`numpy.ndarray` + edge_anchors The edge anchors for landmark based warping - grids : :class:`numpy.ndarray` + grids The grids for landmark based warping """ p_mx = size - 1 @@ -244,58 +245,57 @@ def _get_warp_to_landmarks(cls, size: int, batch_size: int) -> tuple[np.ndarray, grids = np.mgrid[0: p_mx: complex(size), # type:ignore[misc] # pylint:disable=no-member 0: p_mx: complex(size)].astype("float32") # type:ignore[misc] - logger.debug("edge_anchors: (%s, %s), grids: (%s, %s)", + logger.debug("[AugConstants] edge_anchors: (%s, %s), grids: (%s, %s)", edge_anchors.shape, edge_anchors.dtype, grids.shape, grids.dtype) # pylint:disable=no-member return edge_anchors, grids @classmethod def _get_warp(cls, size: int, batch_size: int) -> ConstantsWarp: - """ Load the warp augmentation constants + """Load the warp augmentation constants Parameters ---------- - size: int + size The size of image to augment the data for - batch_size : int + batch_size The batch size that augmented data is being prepared for Returns ------- - :class:`ConstantsTransform` - The constants for image warping + The constants for image warping """ lm_edge_anchors, lm_grids = cls._get_warp_to_landmarks(size, batch_size) warp_range = np.linspace(0, size, 5, dtype='float32') - warp_mapx = np.broadcast_to(warp_range, (batch_size, 5, 5)).astype("float32") - warp_mapy = np.broadcast_to(warp_mapx[0].T, (batch_size, 5, 5)).astype("float32") + warp_map_x = np.broadcast_to(warp_range, (batch_size, 5, 5)).astype("float32") + warp_map_y = np.broadcast_to(warp_map_x[0].T, (batch_size, 5, 5)).astype("float32") warp_pad = int(1.25 * size) - retval = ConstantsWarp(maps=np.stack((warp_mapx, warp_mapy), axis=1), + retval = ConstantsWarp(maps=np.stack((warp_map_x, warp_map_y), axis=1), pad=(warp_pad, warp_pad), slices=slice(warp_pad // 10, -warp_pad // 10), scale=5 / 256 * size, # Normal random variable scale lm_edge_anchors=lm_edge_anchors, lm_grids=lm_grids, lm_scale=2 / 256 * size) # Normal random variable scale - logger.debug(retval) + logger.debug("[AugConstants] warp constants: %s", retval) return retval @classmethod def from_config(cls, processing_size: int, batch_size: int) -> ConstantsAugmentation: - """ Create a new dataclass instance from user config + """Create a new dataclass instance from user config Parameters ---------- - processing_size : int: + processing_size The size of image to augment the data for - batch_size : int + batch_size The batch size that augmented data is being prepared for """ - logger.debug("Initializing %s(processing_size=%s, batch_size=%s)", + logger.debug("[AugConstants] Initializing %s(processing_size=%s, batch_size=%s)", cls.__name__, processing_size, batch_size) retval = cls(color=cls._get_color(processing_size), transform=cls._get_transform(processing_size), @@ -305,13 +305,13 @@ def from_config(cls, class ImageAugmentation(): - """ Performs augmentation on batches of training images. + """Performs augmentation on batches of training images. Parameters ---------- - batch_size : int + batch_size The number of images that will be fed through the augmentation functions at once. - processing_size: int + processing_size The largest input or output size of the model. This is the size that images are processed at. """ @@ -320,28 +320,28 @@ def __init__(self, batch_size: int, processing_size: int) -> None: self._processing_size = processing_size self._batch_size = batch_size self._constants = ConstantsAugmentation.from_config(processing_size, batch_size) - logger.debug("Initialized %s", self.__class__.__name__) + logger.debug("[Aug] Initialized %s", self.__class__.__name__) def __repr__(self) -> str: - """ Pretty print this object """ + """Pretty print this object""" return (f"{self.__class__.__name__}(batch_size={self._batch_size}, " f"processing_size={self._processing_size})") # <<< COLOR AUGMENTATION >>> # def _random_lab(self, batch: np.ndarray) -> None: - """ Perform random color/lightness adjustment in L*a*b* color space on a batch of + """Perform random color/lightness adjustment in L*a*b* color space on a batch of images Parameters ---------- - batch : :class:`numpy.ndarray` + batch The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `3`) and in `BGR` format of uint8 dtype. """ randoms = np.random.uniform(-self._constants.color.lab_adjust, self._constants.color.lab_adjust, size=(self._batch_size, 1, 1, 3)).astype("float32") - logger.trace("Random LAB adjustments: %s", randoms) # type:ignore[attr-defined] + logger.trace("[Aug] Random LAB adjustments: %s", randoms) # type:ignore[attr-defined] # Iterating through the images and channels is much faster than numpy.where and slightly # faster than numexpr.where. for image, rand in zip(batch, randoms): @@ -353,12 +353,12 @@ def _random_lab(self, batch: np.ndarray) -> None: image[:, :, idx] = image[:, :, idx] * (1 + adjustment) def _random_clahe(self, batch: np.ndarray) -> None: - """ Randomly perform Contrast Limited Adaptive Histogram Equalization on + """Randomly perform Contrast Limited Adaptive Histogram Equalization on a batch of images Parameters ---------- - batch : :class:`numpy.ndarray` + batch The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `3`) and in `BGR` format of uint8 dtype. """ @@ -372,7 +372,8 @@ def _random_clahe(self, batch: np.ndarray) -> None: size=indices.shape[0], dtype="uint8") grid_sizes = (grid_bases * (base_contrast // 2)) + base_contrast - logger.trace("Adjusting Contrast. Grid Sizes: %s", grid_sizes) # type:ignore[attr-defined] + logger.trace("[Aug] Adjusting Contrast. Grid Sizes: %s", # type:ignore[attr-defined] + grid_sizes) clahes = [cv2.createCLAHE(clipLimit=2.0, tileGridSize=(grid_size, grid_size)) @@ -382,23 +383,21 @@ def _random_clahe(self, batch: np.ndarray) -> None: batch[idx, :, :, 0] = clahe.apply(batch[idx, :, :, 0], ) def color_adjust(self, batch: np.ndarray) -> np.ndarray: - """ Perform color augmentation on the passed in batch. + """Perform color augmentation on the passed in batch. The color adjustment parameters are set in :file:`config.train.ini` Parameters ---------- - batch : :class:`numpy.ndarray` + batch The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `3`) and in `BGR` format of uint8 dtype. Returns ---------- - :class:`numpy.ndarray` - A 4-dimensional array of the same shape as :attr:`batch` with color augmentation - applied. + A 4-dimensional array of the same shape as :attr:`batch` with color augmentation applied. """ - logger.trace("Augmenting color") # type:ignore[attr-defined] + logger.trace("[Aug] Augmenting color") # type:ignore[attr-defined] batch = batch_convert_color(batch, "BGR2LAB") self._random_lab(batch) self._random_clahe(batch) @@ -406,18 +405,22 @@ def color_adjust(self, batch: np.ndarray) -> np.ndarray: return batch # <<< IMAGE AUGMENTATION >>> # - def transform(self, batch: np.ndarray): - """ Perform random transformation on the passed in batch. + def transform(self, batch: npt.NDArray[np.uint8], points: npt.NDArray[np.float32] | None + ) -> None: + """Perform random transformation on the passed in batch and optional (x, y) points. The transformation parameters are set in :file:`config.train.ini` Parameters ---------- - batch : :class:`numpy.ndarray` + batch The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `channels`) and in `BGR` format. + points + Any (x, y) points to transform. in shape (batch_size, num_sides, 68, 2). ``None`` if + there are no points to transform """ - logger.trace("Randomly transforming image") # type:ignore[attr-defined] + logger.trace("[Aug] Randomly transforming image") # type:ignore[attr-defined] rotation = np.random.uniform(-self._constants.transform.rotation, self._constants.transform.rotation, size=self._batch_size).astype("float32") @@ -425,45 +428,58 @@ def transform(self, batch: np.ndarray): 1 + self._constants.transform.zoom, size=self._batch_size).astype("float32") - tform = np.random.uniform(-self._constants.transform.shift, - self._constants.transform.shift, - size=(self._batch_size, 2)).astype("float32") - mats = np.array( - [cv2.getRotationMatrix2D((self._processing_size // 2, self._processing_size // 2), - rot, - scl) - for rot, scl in zip(rotation, scale)]).astype("float32") - mats[..., 2] += tform - - for image, mat in zip(batch, mats): + transform = np.random.uniform(-self._constants.transform.shift, + self._constants.transform.shift, + size=(self._batch_size, 2)).astype("float32") + mats = batch_create_matrices(self._processing_size, + rotation, + scale=scale, + translation=transform) + + for image, mat in zip(batch, mats[:, :2, :]): cv2.warpAffine(image, mat, (self._processing_size, self._processing_size), dst=image, borderMode=cv2.BORDER_REPLICATE) - logger.trace("Randomly transformed image") # type:ignore[attr-defined] + logger.trace("[Aug] Randomly transformed image") # type:ignore[attr-defined] + if points is None: + return + ones = np.ones((*points.shape[:-1], 1), dtype=points.dtype) + pts_h = np.concatenate([points, ones], axis=-1) + points[:] = np.einsum('nij,n...j->n...i', mats, pts_h)[..., :2] + logger.trace("[Aug] Randomly transformed points") # type:ignore[attr-defined] - def random_flip(self, batch: np.ndarray): - """ Perform random horizontal flipping on the passed in batch. + def random_flip(self, batch: npt.NDArray[np.uint8], points: npt.NDArray[np.float32] | None + ) -> None: + """Perform random horizontal flipping on the passed in batch. The probability of flipping an image is set in :file:`config.train.ini` Parameters ---------- - batch : :class:`numpy.ndarray` + batch The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `channels`) and in `BGR` format. + points + Any (x, y) points to transform. Can be in any shape but the final dimension should be + shape 2. ``None`` if there are no points to transform """ - logger.trace("Randomly flipping image") # type:ignore[attr-defined] + logger.trace("[Aug] Randomly flipping image") # type:ignore[attr-defined] randoms = np.random.rand(self._batch_size) indices = np.where(randoms <= self._constants.transform.flip)[0] batch[indices] = batch[indices, :, ::-1] - logger.trace("Randomly flipped %s images of %s", # type:ignore[attr-defined] + logger.trace("[Aug] Randomly flipped %s images of %s", # type:ignore[attr-defined] len(indices), self._batch_size) + if points is None: + return + points[indices, ..., 0] = (self._processing_size - 1) - points[indices, ..., 0] + logger.trace("[Aug] Randomly flipped %s points: %s", # type:ignore[attr-defined] + len(indices), format_array(points)) def _random_warp(self, batch: np.ndarray) -> np.ndarray: - """ Randomly warp the input batch + """Randomly warp the input batch Parameters ---------- @@ -473,46 +489,48 @@ def _random_warp(self, batch: np.ndarray) -> np.ndarray: Returns ---------- - :class:`numpy.ndarray` - A 4-dimensional array of the same shape as :attr:`batch` with warping applied. + A 4-dimensional array of the same shape as :attr:`batch` with warping applied. """ - logger.trace("Randomly warping batch") # type:ignore[attr-defined] + logger.trace("[Aug] Randomly warping batch") # type:ignore[attr-defined] slices = self._constants.warp.slices rands = np.random.normal(size=(self._batch_size, 2, 5, 5), scale=self._constants.warp.scale).astype("float32") batch_maps = ne.evaluate("m + r", local_dict={"m": self._constants.warp.maps, "r": rands}) - batch_interp = np.array([[cv2.resize(map_, self._constants.warp.pad)[slices, slices] + interpolators = np.array([[cv2.resize(map_, self._constants.warp.pad)[slices, slices] for map_ in maps] for maps in batch_maps]) - warped_batch = np.array([cv2.remap(image, interp[0], interp[1], cv2.INTER_LINEAR) - for image, interp in zip(batch, batch_interp)]) + warped_batch = np.array([cv2.remap(image, + interpolator[0], + interpolator[1], + cv2.INTER_LINEAR) + for image, interpolator in zip(batch, interpolators)]) - logger.trace("Warped image shape: %s", warped_batch.shape) # type:ignore[attr-defined] + logger.trace("[Aug] Warped image shape: %s", # type:ignore[attr-defined] + warped_batch.shape) return warped_batch def _random_warp_landmarks(self, batch: np.ndarray, batch_src_points: np.ndarray, batch_dst_points: np.ndarray) -> np.ndarray: - """ From dfaker. Warp the image to a similar set of landmarks from the opposite side + """From dfaker. Warp the image to a similar set of landmarks from the opposite side - batch : :class:`numpy.ndarray` + batch The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `3`) and in `BGR` format. - batch_src_points : :class:`numpy.ndarray` + batch_src_points A batch of 68 point landmarks for the source faces. This is a 3-dimensional array in the shape (`batchsize`, `68`, `2`). - batch_dst_points : :class:`numpy.ndarray` + batch_dst_points A batch of randomly chosen closest match destination faces landmarks. This is a 3-dimensional array in the shape (`batchsize`, `68`, `2`). Returns ---------- - :class:`numpy.ndarray` - A 4-dimensional array of the same shape as :attr:`batch` with warping applied. + A 4-dimensional array of the same shape as :attr:`batch` with warping applied. """ - logger.trace("Randomly warping landmarks") # type:ignore[attr-defined] + logger.trace("[Aug] Randomly warping landmarks") # type:ignore[attr-defined] edge_anchors = self._constants.warp.lm_edge_anchors grids = self._constants.warp.lm_grids @@ -532,11 +550,13 @@ def _random_warp_landmarks(self, for src, dst, face_core in zip(batch_src[:, :18, :], batch_dst[:, :18, :], face_cores)] - lbatch_src = [np.delete(src, idxs, axis=0) for idxs, src in zip(rem_indices, batch_src)] - lbatch_dst = [np.delete(dst, idxs, axis=0) for idxs, dst in zip(rem_indices, batch_dst)] + lm_batch_src = [np.delete(src, indices, axis=0) + for indices, src in zip(rem_indices, batch_src)] + lm_batch_dst = [np.delete(dst, indices, axis=0) + for indices, dst in zip(rem_indices, batch_dst)] grid_z = np.array([griddata(dst, src, (grids[0], grids[1]), method="linear") - for src, dst in zip(lbatch_src, lbatch_dst)]) + for src, dst in zip(lm_batch_src, lm_batch_dst)]) maps = grid_z.reshape((self._batch_size, self._processing_size, self._processing_size, @@ -548,7 +568,8 @@ def _random_warp_landmarks(self, cv2.INTER_LINEAR, borderMode=cv2.BORDER_TRANSPARENT) for image, map_ in zip(batch, maps)]) - logger.trace("Warped batch shape: %s", warped_batch.shape) # type:ignore[attr-defined] + logger.trace("[Aug] Warped batch shape: %s", # type:ignore[attr-defined] + warped_batch.shape) return warped_batch def warp(self, @@ -558,30 +579,29 @@ def warp(self, batch_dst_points: np.ndarray | None = None ) -> np.ndarray: - """ Perform random warping on the passed in batch by one of two methods. + """Perform random warping on the passed in batch by one of two methods. Parameters ---------- - batch : :class:`numpy.ndarray` + batch The batch should be a 4-dimensional array of shape (`batchsize`, `height`, `width`, `3`) and in `BGR` format. - to_landmarks : bool, optional + to_landmarks If ``False`` perform standard random warping of the input image. If ``True`` perform warping to semi-random similar corresponding landmarks from the other side. Default: ``False`` - batch_src_points : :class:`numpy.ndarray`, optional + batch_src_points Only used when :attr:`to_landmarks` is ``True``. A batch of 68 point landmarks for the source faces. This is a 3-dimensional array in the shape (`batchsize`, `68`, `2`). Default: ``None`` - batch_dst_points : :class:`numpy.ndarray`, optional + batch_dst_points Only used when :attr:`to_landmarks` is ``True``. A batch of randomly chosen closest match destination faces landmarks. This is a 3-dimensional array in the shape (`batchsize`, `68`, `2`). Default ``None`` Returns ---------- - :class:`numpy.ndarray` - A 4-dimensional array of the same shape as :attr:`batch` with warping applied. + A 4-dimensional array of the same shape as :attr:`batch` with warping applied. """ if to_landmarks: assert batch_src_points is not None diff --git a/lib/training/data_loader.py b/lib/training/data_loader.py new file mode 100644 index 0000000000..4bfb7057c9 --- /dev/null +++ b/lib/training/data_loader.py @@ -0,0 +1,333 @@ +#! /usr/env/bin/python3 +"""Handles the loading of data for training and previews for faceswap models""" +from __future__ import annotations + +import abc +import logging +import os +import typing as T + +import torch +from torch.utils import data as tch_data +from torch.utils.data import DataLoader +from lib.logger import parse_class_init +from lib.utils import get_module_objects +from plugins.train import train_config as mod_cfg +from plugins.train.trainer import trainer_config as trn_cfg + +from .data_set import Collate, get_label, LandmarkMatcher, TrainSet, PreviewSet, MultiDataset + +if T.TYPE_CHECKING: + from lib.align.constants import CenteringType + from plugins.train.trainer.base import TrainConfig + +logger = logging.getLogger(__name__) + +TargetT = T.TypeVar("TargetT") + + +class _Loader(abc.ABC, T.Generic[TargetT]): + """Base class for Training and Preview loaders + + Parameters + ---------- + input_size + The input size to the model + color_order + The color order of the model + sampler + The sampler to use for the data loaders. Default: ``None`` (RandomSampler) + """ + def __init__(self, + input_size: int, + color_order: T.Literal["bgr", "rgb"], + sampler: None | type[tch_data.Sampler] = None) -> None: + self._input_size = input_size + self._color_order: T.Literal["bgr", "rgb"] = T.cast(T.Literal["bgr", "rgb"], + color_order.lower()) + self._sampler = tch_data.RandomSampler if sampler is None else sampler + self._loader = self.get_loader() + self._iterator = iter(self._loader) + + def __iter__(self) -> T.Self: + """This is an iterator""" + return self + + @abc.abstractmethod + def get_loader(self) -> DataLoader: + """Override to obtain the dataloaders for each input/output for the model + + Returns + ------- + The data loaders in side order (A, B, ...) + """ + + @abc.abstractmethod + def __next__(self) -> tuple[torch.Tensor, TargetT]: + """ Obtain the next batch of data for each side for feeding the model + + Returns + ------- + inputs + The inputs to the model for each side of the model. The array is returned in `(side, + batch_size, *dims)` where `side` 0 is "A" and `side` 1 is "B" etc. + targets + The targets for the model for each side of the model. For each target resolution output + required an array is inserted to the list in format `(side, batch_size, *dims) + where `side` 0 is "A" and `side` 1 is "B" etc. + """ + + +class TrainLoader(_Loader[list[torch.Tensor]]): + """Generator for feeding faceswap models with multiple inputs and outputs. Gets the next items + from each of the configured loaders and collates them for feeding into a model + + Parameters + ---------- + input_size + The input size to the model + output_sizes + The output sizes to the model (list as some models have multi-scale outputs) + color_order + The color order of the model + config + The training configuration for feeding the model + sampler + The sampler to use for the data loaders. Default: ``None`` (RandomSampler) + """ + def __init__(self, + input_size: int, + output_sizes: tuple[int, ...], + color_order: T.Literal["bgr", "rgb"], + config: TrainConfig, + sampler: None | type[tch_data.Sampler] = None) -> None: + logger.debug(parse_class_init(locals())) + self._learn_mask = mod_cfg.Loss.learn_mask() + self._output_sizes = output_sizes + self._config = config + self._process_size = max(*self._output_sizes, input_size) + self._landmarks: None | LandmarkMatcher = None + + if config.warp and config.cache_landmarks: + self._landmarks = LandmarkMatcher(config.folders, + self._process_size, + T.cast("CenteringType", mod_cfg.centering()), + mod_cfg.coverage() / 100., + mod_cfg.vertical_offset() / 100.) + super().__init__(input_size, color_order, sampler) + self._iterator: T.Iterator[tuple[torch.Tensor, list[torch.Tensor]]] + self._epoch = 0 + self._sampler: type[tch_data.RandomSampler | tch_data.DistributedSampler] + + def __repr__(self) -> str: + """Pretty print for logging""" + params = {f"{k}"[1:]: v for k, v in self.__dict__.items() + if k in ("_input_size", "_output_sizes", "_color_order", "_config", "_sampler")} + s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + + def get_loader(self) -> DataLoader: + """Obtain the dataloaders for each input/output for the model + + Returns + ------- + The Training data loaders in side order + """ + num_workers = trn_cfg.Loader.num_processes() + max_proc = os.cpu_count() + max_proc = 1 if max_proc is None else max_proc + if num_workers > max_proc: + logger.warning("Data Loader processes set to %s but only %s processors available. " + "Lowering to %s", num_workers, max_proc, max_proc - 1) + num_workers = max_proc - 1 + + data_sets = tuple(TrainSet(get_label(i, len(self._config.folders)), f, self._process_size) + for i, f in enumerate(self._config.folders)) + train_set = MultiDataset(data_sets, is_random=True) + collate_fn = Collate(self._input_size, + self._output_sizes, + self._color_order, + self._config, + landmarks=self._landmarks) + retval = DataLoader(dataset=train_set, + batch_size=self._config.batch_size, + sampler=self._sampler(train_set), + num_workers=num_workers, + prefetch_factor=trn_cfg.Loader.pre_fetch(), + collate_fn=collate_fn, + pin_memory=True, + drop_last=True) + logger.debug("[TrainLoader] Set loader: %s", retval) + return retval + + def _items_from_loader(self) -> tuple[torch.Tensor, list[torch.Tensor]]: + """Obtain the next outputs from the given loader index + + Parameters + ---------- + index + The index of the loader to retrieve data from where `index` 0 is "A" and `index` 1 is + "B" etc. + + Returns + ------- + inputs + The inputs to a side of the model. `(batch_size, *dims)` + targets + The targets for a side of the model. For each target resolution output + required an array is inserted to the list in format `(batch_size, *dims). + """ + try: + inputs, targets = T.cast(tuple[torch.Tensor, list[torch.Tensor]], next(self._iterator)) + except StopIteration: + epoch = self._epoch + logger.debug("[TrainLoader] epoch %s end", epoch) + + if isinstance(self._loader.sampler, tch_data.DistributedSampler): + self._loader.sampler.set_epoch(epoch + 1) + T.cast(MultiDataset, self._loader.dataset).shuffle() + self._iterator = iter(self._loader) + inputs, targets = next(self._iterator) + self._epoch += 1 + + if self._learn_mask: # Add the face mask as it's own target + targets += [targets[-1][..., 3][..., None]] + logger.trace( # type:ignore[attr-defined] + "[TrainLoader] input_shapes: %s, target_shapes: %s", + inputs.shape, [i.shape for i in targets]) + return inputs, targets + + def __next__(self) -> tuple[torch.Tensor, list[torch.Tensor]]: + """ Obtain the next batch of data for each side for feeding the model + + Returns + ------- + inputs + The inputs to the model for each side of the model. The array is returned in `(side, + batch_size, *dims)` where `side` 0 is "A" and `side` 1 is "B" etc. + targets + The targets for the model for each side of the model. For each target resolution output + required an array is inserted to the list in format `(side, batch_size, *dims) + where `side` 0 is "A" and `side` 1 is "B" etc. + """ + items = self._items_from_loader() + inputs = items[0] + targets = items[1] + logger.trace("[TrainLoader] inputs: %s, targets: %s", # type:ignore[attr-defined] + inputs.shape, [t.shape for t in targets]) + return inputs, targets + + +class PreviewLoader(_Loader[torch.Tensor]): + """Generator for feeding faceswap models input data for generating preview images. Gets the + next items from each of the configured loaders and collates them for feeding into a model + + Parameters + ---------- + input_size + The input size to the model + output_sizes + The output sizes to the model (list as some models have multi-scale outputs) + color_order + The color order of the model + input_folders + list of folders to read images from for each side being trained + batch_size + The number of images being displayed in the preview + sampler + The sampler to use for the data loaders. Default: ``None`` (RandomSampler) + num_samples + Set to 0 for random previews from the image folder. Set to a positive integer for this + number of images to use for a static timelapse. Default: 0 + """ + def __init__(self, + input_size: int, + output_size: int, + color_order: T.Literal["bgr", "rgb"], + input_folders: list[str], + batch_size: int, + sampler: None | type[tch_data.Sampler] = None, + num_samples: int = 0) -> None: + self._output_size = output_size + self._input_folders = input_folders + self._batch_size = batch_size + self._num_samples = num_samples + super().__init__(input_size, color_order, sampler) + self._iterator: T.Iterator[tuple[torch.Tensor, torch.Tensor]] + self._sampler: type[tch_data.RandomSampler | tch_data.SequentialSampler] + + def __repr__(self) -> str: + """Pretty print for logging""" + params = ", ".join(f"{k[1:]}={repr(v)}" for k, v in self.__dict__.items() + if k in ("_input_size", "_output_size", "_color_order", + "_input_folders", "_batch_size", "_sampler", "_num_samples")) + return f"{self.__class__.__name__}({params})" + + def get_loader(self) -> DataLoader: + """Obtain the dataloaders for each input/output for the model + + Returns + ------- + The Training data loaders in side order + """ + data_sets = tuple(PreviewSet(get_label(i, len(self._input_folders)), + f, + self._input_size, + self._output_size, + self._color_order, + num_images=self._num_samples) + for i, f in enumerate(self._input_folders)) + preview_set = MultiDataset(data_sets, is_random=self._num_samples == 0) + retval = DataLoader(dataset=preview_set, + batch_size=self._batch_size, + sampler=self._sampler(preview_set), + num_workers=1, # Previews don't need speed + pin_memory=True, + drop_last=True) + logger.debug("[PreviewLoader] Set loader : %s", retval) + return retval + + def _items_from_loader(self) -> tuple[torch.Tensor, torch.Tensor]: + """Obtain the next outputs from the given loader index + + Returns + ------- + feed + The batch of feed images for a side + targets + A batch of full sized, full coverage input images with mask in the 4th channel + """ + try: + inputs, targets = T.cast(tuple[torch.Tensor, torch.Tensor], next(self._iterator)) + + except StopIteration: + logger.debug("[PreviewLoader] end") + self._iterator = iter(self._loader) + inputs, targets = next(self._iterator) + + logger.trace( # type:ignore[attr-defined] + "[PreviewLoader] input_shapes: %s, target_shape: %s", + inputs.shape, targets.shape) + return inputs, targets + + def __next__(self) -> tuple[torch.Tensor, torch.Tensor]: + """ Obtain the next batch of data for each side for feeding the model + + Returns + ------- + inputs + The inputs to the model for each side of the model. The array is returned in `(side, + batch_size, *dims)` where `side` 0 is "A" and `side` 1 is "B" etc. + targets + The full sized source image with mask in 4th channel for each side of the model in + format `(side, batch_size, *dims, 4) where `side` 0 is "A" and `side` 1 is "B" etc. + """ + items = self._items_from_loader() + inputs = items[0].swapaxes(0, 1) + targets = items[1].swapaxes(0, 1) + logger.debug("[PreviewLoader] inputs: %s, targets: %s", # type:ignore[attr-defined] + inputs.shape, targets.shape) + return inputs, targets + + +get_module_objects(__name__) diff --git a/lib/training/data_set.py b/lib/training/data_set.py new file mode 100644 index 0000000000..7294705c43 --- /dev/null +++ b/lib/training/data_set.py @@ -0,0 +1,999 @@ +#!/usr/bin/env python3 +"""Handles Data loading and augmentation for feeding Faceswap Models""" +from __future__ import annotations + +import abc +import logging +import os +import typing as T + +import cv2 +import numexpr as ne + +import numpy as np +import torch +from torch.utils.data import Dataset +from tqdm import tqdm + +from lib.align import AlignedFace, Mask +from lib.align.constants import EXTRACT_RATIOS, LandmarkType, MEAN_FACE +from lib.align.aligned_face import batch_umeyama +from lib.align.aligned_utils import batch_transform +from lib.align.pose import Batch3D +from lib.image import read_image_meta_batch +from lib.logger import format_array, parse_class_init +from lib.image import read_image +from lib.utils import FaceswapError, get_module_objects +from plugins.train import train_config as cfg + +from .data_augmentation import ImageAugmentation + +if T.TYPE_CHECKING: + import numpy.typing as npt + + from lib.align import CenteringType + from lib.align.objects import MaskAlignmentsFile, PNGAlignments, PNGHeader + from lib.align.pose import PoseEstimate + from plugins.train.trainer.base import TrainConfig + +logger = logging.getLogger(__name__) + + +def to_float32(in_array: npt.NDArray[np.uint8]) -> npt.NDArray[np.float32]: + """ Cast an UINT8 array in 0-255 range to float32 in 0.0-1.0 range. + + Parameters + ---------- + in_array + The input uint8 array + + Returns + ------- + The array cast to 0.0 - 1.0 float32 + """ + return ne.evaluate("x / c", + local_dict={"x": in_array, "c": np.float32(255)}, + casting="unsafe") + + +def get_label(index: int, num_identities: int, next_identity: bool = False) -> str: + """Obtain the label for the given current index. Labels start at A at index 0. Values roll. + + Parameters + ---------- + index + The index of the current label + num_identities + The number of identities that belong to the label set + next_identity + ``True`` to return the next identity for the given index. Default: ``False`` + + Returns + ------- + The current or next label. Labels go A-Z,0-9,a-z + """ + identities = [chr(i) for i in range(65, 65 + 26)] + if num_identities > len(identities): + identities += [chr(i) for i in range(48, 48 + 10)] + if num_identities > len(identities): + identities += [chr(i) for i in range(97, 97 + 26)] + if num_identities > len(identities): + raise FaceswapError(f"Too many identities: {num_identities}. Max: {len(identities)}") + identities = identities[:num_identities] + index = index % num_identities + if not next_identity: + return identities[index] + index += 1 if index + 1 < num_identities else -index + return identities[index] + + +def get_sorted_images(folder: str) -> list[str]: + """For the given folder return the sorted list of potential training images + + Parameters + ---------- + folder + The folder containing faceswap training images + + Returns + ------- + The sorted list of full paths to the training images within the folder + """ + return list(sorted(os.path.join(folder, f) for f in os.listdir(folder) + if os.path.splitext(f)[-1] == ".png")) + + +class LandmarkMatcher: + """Prepares landmarks when Warp-to-Landmarks is enabled. + + 2 sides (A/B) only. + + For each side, stores the aligned landmarks for each side and collates the 10 nearest matches + on the other side for random warping + + Parameters + ---------- + folders + Two training folders for sides A and B + size + The aligned face size to transform the landmarks to + centering + The aligned centering to transform the landmarks to + coverage + Additional coverage ratio to be applied + y_offset + Additional vertical offset to be applied + num_choices + Number of choices from the opposite side to cache for each landmark. Default: 10 + """ + def __init__(self, + folders: list[str], + size: int, + centering: CenteringType, + coverage: float, + y_offset: float, + num_choices: int = 10) -> None: + logger.debug(parse_class_init(locals())) + assert len(folders) == 2, ( + f"Warp to landmarks is only compatible with 2 inputs. Got {len(folders)}") + self._folders = folders + self._size = size + self._centering: CenteringType = centering + self._coverage = coverage + self._y_offset = y_offset + self._num_choices = num_choices + + self._padding = round(size * (EXTRACT_RATIOS[centering] + coverage - 1) / (2 * coverage)) + self._scale = self._size - (2 * self._padding) + self._landmarks = self._load_landmarks() + + min_file_count = min([self._landmarks[0].shape[0], self._landmarks[1].shape[0]]) + if self._num_choices > min_file_count: + self._num_choices = min_file_count - 1 + self._closest_indices = self._get_closest_indices() + + def __repr__(self) -> str: + """Pretty print for logging""" + params = {f"{k}"[1:]: v for k, v in self.__dict__.items() + if k in ("_folders", "_size", "_centering", "_coverage", "_y_offset", + "_num_choices")} + s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + + def _landmarks_from_header(self, meta: dict[str, T.Any], filename: str + ) -> npt.NDArray[np.float32]: + """Extract the landmarks from the PNG metadata. + + Returns + ------- + landmarks + The frame space landmarks for a face + filename + The name of the face image that we are loading landmarks for + + Raises + ------ + FaceswapError + If an invalid image is loaded or 68 point landmarks are not used + """ + if "itxt" not in meta or "alignments" not in meta["itxt"]: + raise FaceswapError(f"Invalid face image found. Aborting: '{filename}'") + + retval = np.array(meta["itxt"]["alignments"]["landmarks_xy"], dtype=np.float32) + if LandmarkType.from_shape(retval.shape) != LandmarkType.LM_2D_68: + raise FaceswapError("68 Point facial Landmarks are required for Warp-to-" + f"landmarks. The face that failed was: '{filename}'") + return retval + + def _align_points(self, points: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: + """Normalize and align the landmarks to model input size/coverage/offset + + points + ------ + The (N, 68, 2) landmark points to align + + Returns + ------- + The landmark points aligned to model input + """ + mats = batch_umeyama(points[:, 17:], MEAN_FACE[LandmarkType.LM_2D_51], True) + norm_lms = batch_transform(mats, points) + + rotation, translation = Batch3D.solve_pnp(norm_lms) + offsets = Batch3D.get_offsets(self._centering, rotation, translation) + if self._y_offset: + offsets[:, 1] -= self._y_offset + norm_lms -= offsets[:, None, :] + norm_lms *= self._scale + norm_lms += self._padding + return norm_lms + + def _load_landmarks(self) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.float32]]: + """For each input folder load and align the landmarks for each face + + Returns + ------- + landmarks_a + The aligned landmarks for side A in shape (N, 68, 2) + landmarks_b + The aligned landmarks for side B in shape (N, 68, 2) + """ + landmarks: list[npt.NDArray[np.float32]] = [] + for i, folder in enumerate(self._folders): + side = get_label(i, len(self._folders)) + file_list = get_sorted_images(folder) + lms = np.empty((len(file_list), 68, 2), dtype=np.float32) + for filename, meta in tqdm(read_image_meta_batch(file_list), + desc=f"WTL: Caching Landmarks ({side.upper()})", + total=len(file_list), + leave=False): + lms[file_list.index(filename)] = self._landmarks_from_header(meta, filename) + landmarks.append(self._align_points(lms)) + logger.debug("[LandmarkMatcher] Got landmarks for side %s: %s", + side, format_array(landmarks[-1])) + return landmarks[0], landmarks[1] + + def _get_closest_indices(self) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.int64]]: + """Obtain the closest x number of landmarks from the opposite side + + Returns + ------- + indices_a + Array of size (len(landmarks_a), x) closest B landmarks for each A landmarks + indices_b + Array of size (len(landmarks_b), x) closest A landmarks for each B landmarks + """ + a_count = self._landmarks[0].shape[0] + b_count = self._landmarks[1].shape[0] + lms_a = self._landmarks[0].reshape(a_count, -1) + lms_b = self._landmarks[1].reshape(b_count, -1) + + a_sq = (lms_a ** 2).sum(axis=1, keepdims=True) + b_sq = (lms_b ** 2).sum(axis=1, keepdims=True) + dist2 = a_sq + b_sq.T - 2.0 * (lms_a @ lms_b.T) + np.clip(dist2, 0, None, out=dist2) + matches_a = np.argpartition(dist2, self._num_choices, axis=1)[:, :self._num_choices] + matches_b = np.argpartition(dist2.T, self._num_choices, axis=1)[:, :self._num_choices] + + logger.debug("[TrainLoader] Closest matches. A: %s, B: %s", + format_array(matches_a), format_array(matches_b)) + return matches_a, matches_b + + def get_close_landmarks(self, indices: npt.NDArray[np.int64]) -> npt.NDArray[np.float32]: + """For the given image indices, obtain a randomly selected close match landmarks from the + other side + + Parameters + ---------- + indices + The (num_inputs, landmark_indices) image file indices to obtain the matches for + + Returns + ------- + 2 sets of landmarks in shape (num_sides * batch_size, num_sides, 68, 2) stacked to a batch + of landmark points for augmentation + """ + matches = np.zeros((*indices.shape, 2, 68, 2), dtype=np.float32) + for side_id, ind in enumerate(indices): + src_lms = self._landmarks[side_id][ind] + dst_choices = self._closest_indices[side_id][ind] + idx = np.random.randint(0, dst_choices.shape[1], size=dst_choices.shape[0]) + dst_indices = np.take_along_axis(dst_choices, idx[:, None], axis=1).squeeze(1) + dst_lms = self._landmarks[1 - side_id][dst_indices] + matches[side_id, :, 0] = src_lms + matches[side_id, :, 1] = dst_lms + + retval = matches.reshape((-1, 2, 68, 2)).copy() + logger.trace("[LandmarkMatcher] matched_points: %s", # type:ignore[attr-defined] + format_array(retval)) + return retval + + +class _MaskProcessing: # pylint:disable=too-many-instance-attributes + """ Handle the extraction and processing of masks from faceswap PNG headers + + Parameters + ---------- + side + The side of the model ("A", "B" etc.) + size + The size to return the mask at + coverage_ratio + The coverage ratio that the model is using. + centering + The centering that the model is trained at + y_offset + The amount of vertical offset applied to the training images + """ + def __init__(self, + side: str, + size: int, + coverage_ratio: float, + centering: CenteringType, + y_offset: float) -> None: + logger.debug(parse_class_init(locals())) + self._side = side.upper() + self._name = f"{self.__class__.__name__}.{self._side}" + self._coverage = coverage_ratio + self._centering: CenteringType = centering + self._y_offset = y_offset + self._dims = (size, size) + self._dilation = cfg.Loss.mask_dilation() + self._kernel = cfg.Loss.mask_blur_kernel() + self._threshold = cfg.Loss.mask_threshold() + self._lm_masks: dict[T.Literal["components", "extended", "eye", "mouth"], + T.Literal["face", "face_extended", "eye", "mouth"]] = { + "components": "face", + "extended": "face_extended", + "eye": "eye", + "mouth": "mouth" + } + self._area_dilatation = 2.5 + self._area_kernel = size // 16 + + def __repr__(self) -> str: + """ Pretty print for logging """ + params = (f"side={repr(self._side)}, size={repr(self._dims[0])}, coverage_ratio=" + f"{repr(self._coverage)}, centering={repr(self._centering)}, " + f"y_offset={repr(self._y_offset)}") + return f"{self.__class__.__name__}({params})" + + def _check_mask_exists(self, masks: list[str], mask_type: str, filename: str) -> None: + """ Check that the requested mask exists in the given masks dictionary + + Parameters + ---------- + masks + The list of mask keys that exist for the currently processing face + mask_type + The requested mask type + filename + The name of the extracted face file currently being processed + + Raises + ------ + FaceswapError + If the requested mask type is not available an error is returned along with a list + of available masks + """ + exist_masks = masks + list(self._lm_masks) + if mask_type in exist_masks: + return + msg = (f"The masks that exist for this face are: {exist_masks}" if exist_masks + else "No masks exist for this face") + raise FaceswapError( + f"You have selected the mask type '{mask_type}' but at least one " + "face does not contain the selected mask.\n" + f"The face that failed was: '{filename}'\n{msg}") + + def _get_landmarks_mask(self, + mask_type: T.Literal["face", "face_extended", "eye", "mouth"], + aligned: AlignedFace) -> npt.NDArray[np.uint8]: + """Obtain a landmarks based mask directly from the aligned face object + + Parameters + ---------- + mask_type + The type of landmarks based mask to obtain + aligned + The aligned face object to obtain the mask from + + Returns + ------- + The requested landmarks based mask + """ + if mask_type in ("face", "face_extended"): + dilation = self._dilation + kernel = self._kernel + blur_type: T.Literal["gaussian"] | None = None + else: + dilation = self._area_dilatation + kernel = self._area_kernel + blur_type = "gaussian" + mask = aligned.get_landmark_mask(mask_type, + dilation=dilation, + blur_kernel=kernel, + blur_type=blur_type) + return mask + + def _get_face_mask(self, mask_header: MaskAlignmentsFile, pose: PoseEstimate + ) -> npt.NDArray[np.uint8]: + """Obtain a stored face mask from the PNG image header + + Parameters + ---------- + mask_header + The stored mask information from the PNG Header + pose + The pose estimate for the face + + Returns + ------- + The requested face mask from the PNG Header + """ + mask = Mask().from_dict(mask_header) + mask.set_dilation(self._dilation) + mask.set_blur_and_threshold(blur_kernel=self._kernel, threshold=self._threshold) + mask.set_sub_crop(pose.offset[mask.stored_centering], + pose.offset[self._centering], + self._centering, + self._coverage, + self._y_offset) + face_mask = mask.mask + if face_mask.shape[0] == self._dims[0]: + retval = face_mask + else: + retval = np.empty((*self._dims, 1), dtype=face_mask.dtype) + interpolator = cv2.INTER_CUBIC if mask.stored_size < self._dims[0] else cv2.INTER_AREA + cv2.resize(face_mask, self._dims, interpolation=interpolator, dst=retval) + return retval + + def __call__(self, + masks: dict[str, MaskAlignmentsFile], + mask_type: str, + filename: str, + aligned: AlignedFace) -> npt.NDArray[np.uint8]: + """Obtain the training mask cropped to coverage at maximum model input/output size + + Parameters + ---------- + masks + The masks that exist for the extracted face patch + mask_type + The type of mask to return + filename + The name of the extracted face file currently being processed + aligned + The aligned face object for the current face patch + + Returns + ------- + The mask ready for augmentation + """ + logger.trace( # type:ignore[attr-defined] + "[%s] filename: '%s', mask_type: '%s', masks: %s, aligned: %s", + self._name, filename, mask_type, masks, aligned) + self._check_mask_exists(list(masks), mask_type, filename) + if mask_type in self._lm_masks: + retval = self._get_landmarks_mask(self._lm_masks[mask_type], aligned) + else: + retval = self._get_face_mask(masks[mask_type], aligned.pose) + logger.trace("[%s] Got mask '%s': %s", # type:ignore[attr-defined] + self._name, mask_type, format_array(retval)) + return retval[..., 0] + + +class _BaseSet(Dataset, abc.ABC): + """Base class for Training and Preview dataset loaders to inherit from + + Parameters + ---------- + side + The side of the model ("A", "B" etc.) + image_folder + Full path to a folder containing training images + """ + def __init__(self, side: str, image_folder: str) -> None: + self._image_list = get_sorted_images(image_folder) + self._side = side.upper() + self._image_folder = image_folder + self._name = f"{self.__class__.__name__}.{self._side}" + self._centering: CenteringType = T.cast("CenteringType", cfg.centering()) + self._coverage = cfg.coverage() / 100. + self._y_offset = cfg.vertical_offset() / 100. + self._mask_types = self._get_configured_masks() + + def __repr__(self) -> str: + """ Pretty print for logging """ + params = f"side={repr(self._side)}, image_folder={repr(self._image_folder)}" + return f"{self.__class__.__name__}({params})" + + def __len__(self) -> int: + """Number of items within this dataset""" + return len(self._image_list) + + @abc.abstractmethod + def _get_configured_masks(self) -> list[str]: + """Override to get the required masks + + Returns + ------- + list of configured masks types in the order [, , ] + """ + + def _get_face(self, + image: npt.NDArray[np.uint8], + alignments: PNGAlignments, + size: int, + coverage: float) -> AlignedFace: + """Obtain the face patch cropped to coverage at maximum model input/output size + + Parameters + ---------- + image + The original extracted head centered face patch + alignments + The alignments meta data for the extracted face patch + size + The size to obtain the face object at + coverage + The coverage to obtain the face patch for + + Returns + ------- + The face patch ready for augmentation + """ + logger.trace("[%s] image: %s alignments: %s", # type:ignore[attr-defined] + self._name, format_array(image), alignments) + retval = AlignedFace(alignments.landmarks_xy, + image=image, + centering=self._centering, + size=size, + coverage_ratio=coverage, + y_offset=self._y_offset, + dtype="uint8", + is_aligned=True) + logger.trace("[%s] face: %s", self._name, retval) # type:ignore[attr-defined] + return retval + + +class TrainSet(_BaseSet): + """Base class for Training and Preview dataset loaders to inherit from + + Parameters + ---------- + side + The side of the model ("A", "B" etc.) + image_folder + Full path to a folder containing training images + size + The size to return samples at. This should be the maximum of the model input/output + size for train sets or the model input size for preview sets + """ + def __init__(self, + side: str, + image_folder: str, + size: int) -> None: + logger.debug(parse_class_init(locals())) + super().__init__(side, image_folder) + self._size = size + self._out_shape = (self._size, self._size, 3 + len(self._mask_types)) + self._mask = _MaskProcessing(self._side, + self._size, + self._coverage, + self._centering, + self._y_offset) + + def __repr__(self) -> str: + """ Pretty print for logging """ + return (f"{super().__repr__()[:-1]}, size={repr(self._size)})") + + def _get_configured_masks(self) -> list[str]: + """Obtain a list of configured training masks + + Returns + ------- + list of configured masks types in the order [, , ] + """ + retval = [] + if cfg.Loss.mask_type() is not None and (cfg.Loss.learn_mask() or + cfg.Loss.penalized_mask_loss()): + retval.append(cfg.Loss.mask_type()) + if cfg.Loss.penalized_mask_loss() and cfg.Loss.eye_multiplier() > 1: + retval.append("eye") + if cfg.Loss.penalized_mask_loss() and cfg.Loss.mouth_multiplier() > 1: + retval.append("mouth") + logger.debug("[%s] Configured masks: %s", self._name, retval) + return retval + + def __getitem__(self, index: int) -> tuple[npt.NDArray[np.uint8], int]: + """Obtain the next item from the data loader + + Parameters + ---------- + index + The image index to return the data for + + Returns + ------- + image + The training image and masks for the given index at maximum model input/output size + stacked into a single array + index + The image file index + """ + filename = self._image_list[index] + logger.trace("[%s] Loading image %s: %s", # type:ignore[attr-defined] + self._name, index, filename) + meta: PNGHeader + image, meta = read_image(filename, + raise_error=False, + with_metadata=True) + face = self._get_face(image, meta.alignments, self._size, self._coverage) + img = T.cast("npt.NDArray[np.uint8]", face.face) + retval = np.empty(self._out_shape, dtype=img.dtype) + retval[..., :3] = img + for i, mask_type in enumerate(self._mask_types): + retval[..., 3 + i] = self._mask(meta.alignments.mask, mask_type, filename, face) + + logger.trace("[%s] images and masks: %s", # type:ignore[attr-defined] + self._name, format_array(retval)) + return retval, index + + +class PreviewSet(_BaseSet): + """Preview dataset loader. The dataset loader is responsible for loading images from disk + and preparing them for inference and display in the model preview + + Parameters + ---------- + side + The side of the model ("A", "B" etc.) + image_folder + Full path to a folder containing training images + input_size + The input size to the model + output_size + The largest output size of the model + color_order + The color order the model expects data in + num_images + Set to 0 for random previews from the image folder. Set to a positive integer for this + number of images to use for a static timelapse. Default: 0 + """ + def __init__(self, + side: str, + image_folder: str, + input_size: int, + output_size: int, + color_order: T.Literal["bgr", "rgb"], + num_images: int = 0) -> None: + logger.debug(parse_class_init(locals())) + super().__init__(side, image_folder) + self._input_size = input_size + self._output_size = output_size + self._color_order = color_order + self._num_images = num_images + if num_images and num_images != len(self._image_list): + logger.debug("[%s] Filtering image list of %s for timelapse: %s", + self._name, len(self._image_list), num_images) + self._image_list = self._image_list[:num_images] + + self._full_size = 2 * int(np.rint((self._output_size / self._coverage) / 2)) + self._mask = _MaskProcessing(self._side, + self._full_size, + 1.0, + self._centering, + self._y_offset) + + def __repr__(self) -> str: + """ Pretty print for logging """ + params = (f"input_size={self._input_size}, output_size={self._output_size}, " + f"color_order={repr(self._color_order)}, num_images={self._num_images}") + return f"{super().__repr__()[:-1]}, {params})" + + def _get_configured_masks(self) -> list[str]: + """Obtain the preview mask type if it has been selected + + Returns + ------- + list of configured masks types in the order [, , ] + """ + retval = [] + if cfg.Loss.mask_type() is not None and (cfg.Loss.learn_mask() or + cfg.Loss.penalized_mask_loss()): + retval.append(cfg.Loss.mask_type()) + logger.debug("[%s] Configured masks: %s", self._name, retval) + return retval + + def __getitem__(self, index: int) -> tuple[torch.Tensor, torch.Tensor]: + """Obtain the next item from the preview data loader + + Parameters + ---------- + index + The image index to return the data for + + Returns + ------- + feed + A feed image for preview + target + An output face at full coverage with the mask in the 4th channel + """ + filename = self._image_list[index] + logger.trace("[%s] Loading image %s: %s", # type:ignore[attr-defined] + self._name, index, filename) + meta: PNGHeader + image, meta = read_image(filename, + raise_error=False, + with_metadata=True) + + in_face = self._get_face(image, meta.alignments, self._input_size, self._coverage) + in_img = T.cast("npt.NDArray[np.uint8]", in_face.face) + out_face = self._get_face(image, meta.alignments, self._full_size, 1.0) + out_img = np.empty((self._full_size, self._full_size, 4), dtype=np.uint8) + out_img[..., :3] = T.cast("npt.NDArray[np.uint8]", out_face.face) + + if self._mask_types: + out_img[..., 3] = self._mask(meta.alignments.mask, + self._mask_types[0], + filename, + out_face) + else: + out_img[..., 3] = np.zeros_like(out_img[..., 0])[..., None] + 255 + + if self._color_order == "rgb": + in_img[..., :3] = in_img[..., [2, 1, 0]] + out_img[..., :3] = out_img[..., [2, 1, 0]] + + feed = torch.from_numpy(to_float32(in_img)) + target = torch.from_numpy(to_float32(out_img)) + logger.trace("[%s] feed: %s (%s), target: %s (%s)", # type:ignore[attr-defined] + self._name, feed.shape, feed.dtype, target.shape, target.dtype) + return feed, target + + +class MultiDataset(Dataset): + """Handles processing data for models with multiple inputs. The length is set as the largest + dataset. Shuffling all datasets is handled internally at the end of each + + Parameters + ---------- + datasets + The input specific datasets for feeding the model + is_random + ``True`` if data from each of the datasets should be read randomly. ``False`` if all + datasets should return the item for the given index + """ + def __init__(self, datasets: tuple[_BaseSet, ...], is_random: bool = True) -> None: + super().__init__() + self._datasets = datasets + self._len = max(len(d) for d in datasets) + + self._remainder = [np.empty(0, dtype=np.int64)] * len(self._datasets) + self._indices = self._shuffle_indices() + self._is_random = is_random + + def __repr__(self) -> str: + """ Pretty print for logging """ + params = f"datasets={self._datasets}, is_random={self._is_random}" + return f"{self.__class__.__name__}({params})" + + def __len__(self): + """Number of items within the largest dataset""" + return self._len + + def _shuffle_indices(self) -> npt.NDArray[np.int64]: + """At the end of each epoch build a new indices array for each input. The permutations + for each input are calculated for it's own data length, and random indices are rolled at + the end of each largest epoch to ensure that all data sources have their full list + processed prior to reshuffling + + Returns + ------- + An array of indices of shape (num_datasets, len(self)) of random indices that can be looked + up for each value given to __get_item__ + """ + retval = np.empty((len(self._datasets), self._len), dtype=np.int64) + for idx, ds in enumerate(self._datasets): + ds_len = len(ds) + filled = 0 + remainder = self._remainder[idx] + if len(remainder): + take = min(len(remainder), self._len) + retval[idx, :take] = remainder[:take] + filled = take + self._remainder[idx] = remainder[take:] + + while filled < self._len: + perm = np.random.permutation(ds_len) + take = min(ds_len, self._len - filled) + retval[idx, filled:filled + take] = perm[:take] + filled += take + if take < ds_len: + self._remainder[idx] = perm[take:] + + logger.debug("[MultiDataset] Shuffled dataset indices: %s", format_array(retval)) + return retval + + def shuffle(self) -> None: + """Shuffle all of the contained dataset's data""" + self._indices = self._shuffle_indices() + + def __getitem__(self, index: int) -> tuple[np.ndarray, ...]: + """Obtain the next item from each of the contained datasets + + Returns + ------- + tuple of arrays of shape (num_inputs, ...) for each input dataset's output + """ + if self._is_random: + results: list[tuple[np.ndarray, ...]] = [dataset[self._indices[i][index]] + for i, dataset in enumerate(self._datasets)] + else: + results = [dataset[index] for dataset in self._datasets] + + retval = tuple(np.stack([res[i] for res in results]) + for i in range(len(results[0]))) + return retval + + +class Collate: # pylint:disable=too-many-instance-attributes + """Collation function for processing a batch of samples into input and output tensors applying + augmentation + + Parameters + ---------- + input_size + The pixel size of the model input + output_sizes + The pixel sizes of the model output + color_order + The color order that the model expects + config + The training configuration for the model + landmarks + The landmark matching object for the (A and B) sides of the model if warp_to_landmarks is + enabled otherwise ``None`` + """ + def __init__(self, + input_size: int, + output_sizes: tuple[int, ...], + color_order: T.Literal["bgr", "rgb"], + config: TrainConfig, + landmarks: LandmarkMatcher | None) -> None: + logger.debug(parse_class_init(locals())) + self._name = f"{self.__class__.__name__}" + self._input_size = input_size + self._output_sizes = output_sizes + self._color_order = color_order.lower() + self._config = config + + self._num_inputs = len(config.folders) + self._batch_size = config.batch_size + + # For Warp to Landmarks + self._landmarks = landmarks + + self._process_size = max(*output_sizes, input_size) + self._resize_targets = any(x != self._process_size for x in self._output_sizes) + self._resize_inputs = self._process_size != self._input_size + self._aug = ImageAugmentation(batch_size=self._batch_size * self._num_inputs, + processing_size=self._process_size) + + def __repr__(self) -> str: + """Pretty print for logging""" + params = {f"{k}"[1:]: format_array(v) if isinstance(v, np.ndarray) else v + for k, v in self.__dict__.items() + if k in ("_input_size", "_output_sizes", "_color_order", + "_config", "_landmarks")} + s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + + def _create_targets(self, batch: npt.NDArray[np.uint8]) -> list[npt.NDArray[np.float32]]: + """ Compile target images, with masks, for the model output sizes. + + Parameters + ---------- + batch + This should be a 4-dimensional array of training images in the format (`batch size`, + `height`, `width`, `channels`). Targets should be requested after performing image + transformations but prior to performing warps. The 4th channel should be the mask. + Any channels above the 4th should be any additional area masks (e.g. eye/mouth) that + are required. + + Returns + ------- + list + List of (num_inputs, batch_size, height, width, channels) target images, at all model + output sizes, with masks compiled into channels 3+ for each output size as float32 + 0.0 - 1.0 range + """ + logger.trace("[%s] Compiling targets: batch shape: %s", # type:ignore[attr-defined] + self._name, batch.shape) + if self._resize_targets: + retval = [to_float32(batch if batch.shape[1] == size else + np.array([ + cv2.resize(image, + (size, size), + interpolation=cv2.INTER_AREA) + for image in batch + ])).reshape(self._num_inputs, + self._batch_size, + size, + size, + -1) + for size in self._output_sizes] + else: + retval = [to_float32(batch).reshape(self._num_inputs, + self._batch_size, + *batch.shape[1:]) + for _ in self._output_sizes] + + logger.trace("[%s] Processed targets: %s", # type:ignore[attr-defined] + self._name, [t.shape for t in retval]) + return retval + + def _get_landmarks_pairs(self, indices: npt.NDArray[np.int64] + ) -> npt.NDArray[np.float32] | None: + """Get a pair of matching source landmarks and closely selected destination landmarks + for Warp to Landmarks for each of the inputs + + Parameters + ---------- + indices + The (num_inputs, batch_size) face file image indices to obtain the landmarks pairs for + Returns + ------- + 2 sets of landmarks in shape (num_inputs * batch_size, 2, 68, 2). On the 3rd dimension, + position 0 are the source points. position 1 the randomly selected closest match points. + ``None`` if Warp to Landmarks is disabled + """ + if not self._config.warp or self._landmarks is None: + return None + assert indices.shape[0] == 2, "Only 2 inputs allowed for WTL" + return self._landmarks.get_close_landmarks(indices) + + def __call__(self, data: list[tuple[tuple[npt.NDArray[np.uint8], int], ...]] + ) -> tuple[torch.Tensor, list[torch.Tensor]]: + """Prepare the loaded samples for feeding the model, creating targets and applying + augmentation + + Parameters + ---------- + data + Batch of data tuples with the loaded stacked image and masks from each loader in the + first position and the image file index for each item in the batch in the 2nd + + Returns + ------- + feed + The for the (num_inputs, batch_size, H, W, C) inputs for the model + targets + The for the (num_inputs, batch_size, H, W, C) targets for the model + """ + shape = data[0][0][0].shape + batch = np.empty((self._num_inputs, self._batch_size, *shape), dtype=np.uint8) + indices = np.empty((self._num_inputs, self._batch_size), dtype=np.int64) + for idx in range(self._num_inputs): + batch[idx] = [d[0][idx] for d in data] + indices[idx] = [d[1][idx] for d in data] + + batch = batch.reshape(-1, *shape) + landmarks = self._get_landmarks_pairs(indices) + + if self._config.augment_color: + batch[..., :3] = self._aug.color_adjust(batch[..., :3]) + + self._aug.transform(batch, landmarks) + + if self._config.flip: + self._aug.random_flip(batch, landmarks) + if self._color_order == "rgb": + batch[..., :3] = batch[..., [2, 1, 0]] + + targets = self._create_targets(batch) + + feed = batch[..., :3] + if self._config.warp and landmarks is not None and self._landmarks is not None: + feed = self._aug.warp(feed, + to_landmarks=True, + batch_src_points=landmarks[:, 0], + batch_dst_points=landmarks[:, 1]) + elif self._config.warp: + feed = self._aug.warp(feed, to_landmarks=False) + + if self._resize_inputs: + feed = to_float32(np.array([cv2.resize(image, + (self._input_size, self._input_size), + interpolation=cv2.INTER_AREA) + for image in feed])) + else: + feed = to_float32(feed) + + feed = feed.reshape(self._num_inputs, self._batch_size, *feed.shape[1:]) + return torch.from_numpy(feed), [torch.from_numpy(x) for x in targets] + + +get_module_objects(__name__) diff --git a/lib/training/generator.py b/lib/training/generator.py deleted file mode 100644 index f674e07aae..0000000000 --- a/lib/training/generator.py +++ /dev/null @@ -1,969 +0,0 @@ -#!/usr/bin/env python3 -""" Handles Data Augmentation for feeding Faceswap Models """ -from __future__ import annotations -import logging -import os -import typing as T - -from concurrent import futures -from random import shuffle, choice - -import cv2 -import numpy as np -import numexpr as ne -from lib.align import AlignedFace, DetectedFace -from lib.align.aligned_face import CenteringType -from lib.image import read_image_batch -from lib.multithreading import BackgroundGenerator -from lib.utils import FaceswapError, get_module_objects -from plugins.train import train_config as mod_cfg -from plugins.train.trainer import trainer_config as trn_cfg - -from . import ImageAugmentation -from .cache import get_cache, RingBuffer - -if T.TYPE_CHECKING: - from collections.abc import Generator - from plugins.train.model._base import ModelBase - from .cache import Cache - -logger = logging.getLogger(__name__) -BatchType = tuple[np.ndarray, list[np.ndarray]] - - -class DataGenerator(): # pylint:disable=too-many-instance-attributes - """ Parent class for Training and Preview Data Generators. - - This class is called from :mod:`plugins.train.trainer._base` and launches a background - iterator that compiles augmented data, target data and sample data. - - Parameters - ---------- - model: :class:`~plugins.train.model.ModelBase` - The model that this data generator is feeding - side: {'a' or 'b'} - The side of the model that this iterator is for. - images: list - A list of image paths that will be used to compile the final augmented data from. - batch_size: int - The batch size for this iterator. Images will be returned in :class:`numpy.ndarray` - objects of this size from the iterator. - """ - def __init__(self, - model: ModelBase, - side: T.Literal["a", "b"], - images: list[str], - batch_size: int) -> None: - logger.debug("Initializing %s: (model: %s, side: %s, images: %s , " - "batch_size: %s)", self.__class__.__name__, model.name, side, - len(images), batch_size) - self._side = side - self._images = images - self._batch_size = batch_size - - self._process_size = max(img[1] for img in model.input_shapes + model.output_shapes) - self._output_sizes = self._get_output_sizes(model) - self._model_input_size = max(img[1] for img in model.input_shapes) - - self._coverage_ratio = model.coverage_ratio - self._color_order = model.color_order.lower() - self._use_mask = mod_cfg.Loss.mask_type() and (mod_cfg.Loss.penalized_mask_loss() or - mod_cfg.Loss.learn_mask()) - - self._validate_samples() - self._buffer = RingBuffer(batch_size, - (self._process_size, self._process_size, self._total_channels), - dtype="uint8") - self._face_cache: Cache = get_cache(side, - filenames=images, - size=self._process_size, - coverage_ratio=self._coverage_ratio) - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def _total_channels(self) -> int: - """int: The total number of channels, including mask channels that the target image - should hold. """ - channels = 3 - if mod_cfg.Loss.mask_type() and (mod_cfg.Loss.learn_mask() or - mod_cfg.Loss.penalized_mask_loss()): - channels += 1 - - mults = [area - for area, amount in zip(["eye", "mouth"], - [mod_cfg.Loss.eye_multiplier(), - mod_cfg.Loss.mouth_multiplier()]) - if amount > 1] - if mod_cfg.Loss.penalized_mask_loss() and mults: - channels += len(mults) - return channels - - def _get_output_sizes(self, model: ModelBase) -> list[int]: - """ Obtain the size of each output tensor for the model. - - Parameters - ---------- - model: :class:`~plugins.train.model.ModelBase` - The model that this data generator is feeding - - Returns - ------- - list - A list of integers for the model output size for the current side - """ - out_shapes = model.output_shapes - split = len(out_shapes) // 2 - side_out = out_shapes[:split] if self._side == "a" else out_shapes[split:] - retval = [shape[1] for shape in side_out if shape[-1] != 1] - logger.debug("side: %s, model output shapes: %s, output sizes: %s", - self._side, model.output_shapes, retval) - return retval - - def minibatch_ab(self, do_shuffle: bool = True) -> Generator[BatchType, None, None]: - """ A Background iterator to return augmented images, samples and targets. - - The exit point from this class and the sole attribute that should be referenced. Called - from :mod:`plugins.train.trainer._base`. Returns an iterator that yields images for - training, preview and time-lapses. - - Parameters - ---------- - do_shuffle: bool, optional - Whether data should be shuffled prior to loading from disk. If true, each time the full - list of filenames are processed, the data will be reshuffled to make sure they are not - returned in the same order. Default: ``True`` - - Yields - ------ - feed: list - 4-dimensional array of faces to feed the training the model (:attr:`x` parameter for - :func:`keras.models.model.train_on_batch`.). The array returned is in the format - (`batch size`, `height`, `width`, `channels`). - targets: list - List of 4-dimensional :class:`numpy.ndarray` objects in the order and size of each - output of the model. The format of these arrays will be (`batch size`, `height`, - `width`, `x`). This is the :attr:`y` parameter for - :func:`keras.models.model.train_on_batch`. The number of channels here will vary. - The first 3 channels are (rgb/bgr). The 4th channel is the face mask. Any subsequent - channels are area masks (e.g. eye/mouth masks) - """ - logger.debug("do_shuffle: %s", do_shuffle) - args = (do_shuffle, ) - batcher = BackgroundGenerator(self._minibatch, args=args) - return batcher.iterator() - - # << INTERNAL METHODS >> # - def _validate_samples(self) -> None: - """ Ensures that the total number of images within :attr:`images` is greater or equal to - the selected :attr:`batch_size`. - - Raises - ------ - :class:`FaceswapError` - If the number of images loaded is smaller than the selected batch size - """ - length = len(self._images) - msg = ("Number of images is lower than batch-size (Note that too few images may lead to " - f"bad training). # images: {length}, batch-size: {self._batch_size}") - try: - assert length >= self._batch_size, msg - except AssertionError as err: - msg += ("\nYou should increase the number of images in your training set or lower " - "your batch-size.") - raise FaceswapError(msg) from err - - def _minibatch(self, do_shuffle: bool) -> Generator[BatchType, None, None]: - """ A generator function that yields the augmented, target and sample images for the - current batch on the current side. - - Parameters - ---------- - do_shuffle: bool, optional - Whether data should be shuffled prior to loading from disk. If true, each time the full - list of filenames are processed, the data will be reshuffled to make sure they are not - returned in the same order. Default: ``True`` - - Yields - ------ - feed: list - 4-dimensional array of faces to feed the training the model (:attr:`x` parameter for - :func:`keras.models.model.train_on_batch`.). The array returned is in the format - (`batch size`, `height`, `width`, `channels`). - targets: list - List of 4-dimensional :class:`numpy.ndarray` objects in the order and size of each - output of the model. The format of these arrays will be (`batch size`, `height`, - `width`, `x`). This is the :attr:`y` parameter for - :func:`keras.models.model.train_on_batch`. The number of channels here will vary. - The first 3 channels are (rgb/bgr). The 4th channel is the face mask. Any subsequent - channels are area masks (e.g. eye/mouth masks) - """ - logger.debug("Loading minibatch generator: (image_count: %s, do_shuffle: %s)", - len(self._images), do_shuffle) - - def _img_iter(imgs): - """ Infinite iterator for recursing through image list and reshuffling at each epoch""" - while True: - if do_shuffle: - shuffle(imgs) - yield from imgs - - img_iter = _img_iter(self._images[:]) - while True: - img_paths = [next(img_iter) # pylint:disable=stop-iteration-return - for _ in range(self._batch_size)] - retval = self._process_batch(img_paths) - yield retval - - def _get_images_with_meta(self, filenames: list[str]) -> tuple[np.ndarray, list[DetectedFace]]: - """ Obtain the raw face images with associated :class:`DetectedFace` objects for this - batch. - - If this is the first time a face has been loaded, then it's meta data is extracted - from the png header and added to :attr:`_face_cache`. - - Parameters - ---------- - filenames: list - List of full paths to image file names - - Returns - ------- - raw_faces: :class:`numpy.ndarray` - The full sized batch of training images for the given filenames - list - Batch of :class:`~lib.align.DetectedFace` objects for the given filename including the - aligned face objects for the model output size - """ - if not self._face_cache.cache_full: - raw_faces = self._face_cache.cache_metadata(filenames) - else: - raw_faces = read_image_batch(filenames) - - detected_faces = self._face_cache.get_items(filenames) - logger.trace( # type:ignore[attr-defined] - "filenames: %s, raw_faces: '%s', detected_faces: %s", - filenames, raw_faces.shape, len(detected_faces)) - return raw_faces, detected_faces - - def _crop_to_coverage(self, - filenames: list[str], - images: np.ndarray, - detected_faces: list[DetectedFace], - batch: np.ndarray) -> None: - """ Crops the training image out of the full extract image based on the centering and - coveage used in the user's configuration settings. - - If legacy extract images are being used then this just returns the extracted batch with - their corresponding landmarks. - - Uses thread pool execution for about a 33% speed increase @ 64 batch size - - Parameters - ---------- - filenames: list - The list of filenames that correspond to this batch - images: :class:`numpy.ndarray` - The batch of faces that have been loaded from disk - detected_faces: list - The list of :class:`lib.align.DetectedFace` items corresponding to the batch - batch: :class:`np.ndarray` - The pre-allocated array to hold this batch - """ - logger.trace( # type:ignore[attr-defined] - "Cropping training images info: (filenames: %s, side: '%s')", filenames, self._side) - - with futures.ThreadPoolExecutor() as executor: - proc = {executor.submit(face.aligned.extract_face, img): idx - for idx, (face, img) in enumerate(zip(detected_faces, images))} - - for future in futures.as_completed(proc): - batch[proc[future], ..., :3] = future.result() - - def _apply_mask(self, detected_faces: list[DetectedFace], batch: np.ndarray) -> None: - """ Applies the masks to the 4th channel of the batch. - - If the configuration options `eye_multiplier` and/or `mouth_multiplier` are greater than 1 - then these masks are applied to the final channels of the batch respectively. - - If masks are not being used then this function returns having done nothing - - Parameters - ---------- - detected_face: list - The list of :class:`~lib.align.DetectedFace` objects corresponding to the batch - batch: :class:`numpy.ndarray` - The preallocated array to apply masks to - side: str - '"a"' or '"b"' the side that is being processed - """ - if not self._use_mask: - return - - masks = np.array([face.get_training_masks() for face in detected_faces]) - batch[..., 3:] = masks - - logger.trace("side: %s, masks: %s, batch: %s", # type:ignore[attr-defined] - self._side, masks.shape, batch.shape) - - def _process_batch(self, filenames: list[str]) -> BatchType: - """ Prepares data for feeding through subclassed methods. - - If this is the first time a face has been loaded, then it's meta data is extracted from the - png header and added to :attr:`_face_cache` - - Parameters - ---------- - filenames: list - List of full paths to image file names for a single batch - - Returns - ------- - :class:`numpy.ndarray` - 4-dimensional array of faces to feed the training the model. - list - List of 4-dimensional :class:`numpy.ndarray`. The number of channels here will vary. - The first 3 channels are (rgb/bgr). The 4th channel is the face mask. Any subsequent - channels are area masks (e.g. eye/mouth masks) - """ - raw_faces, detected_faces = self._get_images_with_meta(filenames) - batch = self._buffer() - self._crop_to_coverage(filenames, raw_faces, detected_faces, batch) - self._apply_mask(detected_faces, batch) - feed, targets = self.process_batch(filenames, raw_faces, detected_faces, batch) - - logger.trace( # type:ignore[attr-defined] - "Processed %s batch side %s. (filenames: %s, feed: %s, targets: %s)", - self.__class__.__name__, self._side, filenames, feed.shape, [t.shape for t in targets]) - - return feed, targets - - def process_batch(self, - filenames: list[str], - images: np.ndarray, - detected_faces: list[DetectedFace], - batch: np.ndarray) -> BatchType: - """ Override for processing the batch for the current generator. - - Parameters - ---------- - filenames: list - List of full paths to image file names for a single batch - images: :class:`numpy.ndarray` - The batch of faces corresponding to the filenames - detected_faces: list - List of :class:`~lib.align.DetectedFace` objects with aligned data and masks loaded for - the current batch - batch: :class:`numpy.ndarray` - The pre-allocated batch with images and masks populated for the selected coverage and - centering - - Returns - ------- - list - 4-dimensional array of faces to feed the training the model. - list - List of 4-dimensional :class:`numpy.ndarray`. The number of channels here will vary. - The first 3 channels are (rgb/bgr). The 4th channel is the face mask. Any subsequent - channels are area masks (e.g. eye/mouth masks) - """ - raise NotImplementedError() - - def _set_color_order(self, batch) -> None: - """ Set the color order correctly for the model's input type. - - batch: :class:`numpy.ndarray` - The pre-allocated batch with images in the first 3 channels in BGR order - """ - if self._color_order == "rgb": - batch[..., :3] = batch[..., [2, 1, 0]] - - def _to_float32(self, in_array: np.ndarray) -> np.ndarray: - """ Cast an UINT8 array in 0-255 range to float32 in 0.0-1.0 range. - - in_array: :class:`numpy.ndarray` - The input uint8 array - """ - return ne.evaluate("x / c", - local_dict={"x": in_array, "c": np.float32(255)}, - casting="unsafe") - - -class TrainingDataGenerator(DataGenerator): - """ A Training Data Generator for compiling data for feeding to a model. - - This class is called from :mod:`plugins.train.trainer._base` and launches a background - iterator that compiles augmented data, target data and sample data. - - Parameters - ---------- - model: :class:`~plugins.train.model.ModelBase` - The model that this data generator is feeding - side: {'a' or 'b'} - The side of the model that this iterator is for. - images: list - A list of image paths that will be used to compile the final augmented data from. - batch_size: int - The batch size for this iterator. Images will be returned in :class:`numpy.ndarray` - objects of this size from the iterator. - """ - def __init__(self, - model: ModelBase, - side: T.Literal["a", "b"], - images: list[str], - batch_size: int) -> None: - super().__init__(model, side, images, batch_size) - self._augment_color = not model.command_line_arguments.no_augment_color - self._no_flip = model.command_line_arguments.no_flip - self._no_warp = model.command_line_arguments.no_warp - self._warp_to_landmarks = (not self._no_warp - and model.command_line_arguments.warp_to_landmarks) - - if self._warp_to_landmarks: - self._face_cache.pre_fill(images, side) - self._processing = ImageAugmentation(batch_size, - self._process_size) - self._nearest_landmarks: dict[str, tuple[str, ...]] = {} - logger.debug("Initialized %s", self.__class__.__name__) - - def _create_targets(self, batch: np.ndarray) -> list[np.ndarray]: - """ Compile target images, with masks, for the model output sizes. - - Parameters - ---------- - batch: :class:`numpy.ndarray` - This should be a 4-dimensional array of training images in the format (`batch size`, - `height`, `width`, `channels`). Targets should be requested after performing image - transformations but prior to performing warps. The 4th channel should be the mask. - Any channels above the 4th should be any additional area masks (e.g. eye/mouth) that - are required. - - Returns - ------- - list - List of 4-dimensional target images, at all model output sizes, with masks compiled - into channels 4+ for each output size - """ - logger.trace("Compiling targets: batch shape: %s", # type:ignore[attr-defined] - batch.shape) - if len(self._output_sizes) == 1 and self._output_sizes[0] == self._process_size: - # Rolling buffer here makes next to no difference, so just create array on the fly - retval = [self._to_float32(batch)] - else: - retval = [self._to_float32(np.array([cv2.resize(image, - (size, size), - interpolation=cv2.INTER_AREA) - for image in batch])) - for size in self._output_sizes] - logger.trace("Processed targets: %s", # type:ignore[attr-defined] - [t.shape for t in retval]) - return retval - - def process_batch(self, - filenames: list[str], - images: np.ndarray, - detected_faces: list[DetectedFace], - batch: np.ndarray) -> BatchType: - """ Performs the augmentation and compiles target images and samples. - - Parameters - ---------- - filenames: list - List of full paths to image file names for a single batch - images: :class:`numpy.ndarray` - The batch of faces corresponding to the filenames - detected_faces: list - List of :class:`~lib.align.DetectedFace` objects with aligned data and masks loaded for - the current batch - batch: :class:`numpy.ndarray` - The pre-allocated batch with images and masks populated for the selected coverage and - centering - - Returns - ------- - feed: :class:`numpy.ndarray` - 4-dimensional array of faces to feed the training the model (:attr:`x` parameter for - :func:`keras.models.model.train_on_batch`.). The array returned is in the format - (`batch size`, `height`, `width`, `channels`). - targets: list - List of 4-dimensional :class:`numpy.ndarray` objects in the order and size of each - output of the model. The format of these arrays will be (`batch size`, `height`, - `width`, `x`). This is the :attr:`y` parameter for - :func:`keras.models.model.train_on_batch`. The number of channels here will vary. - The first 3 channels are (rgb/bgr). The 4th channel is the face mask. Any subsequent - channels are area masks (e.g. eye/mouth masks) - """ - logger.trace("Process training: (side: '%s', filenames: '%s', images: %s, " # type:ignore - "batch: %s, detected_faces: %s)", self._side, filenames, images.shape, - batch.shape, len(detected_faces)) - - # Color Augmentation of the image only - if self._augment_color: - batch[..., :3] = self._processing.color_adjust(batch[..., :3]) - - # Random Transform and flip - self._processing.transform(batch) - - if not self._no_flip: - self._processing.random_flip(batch) - - # Switch color order for RGB models - self._set_color_order(batch) - - # Get Targets - targets = self._create_targets(batch) - - # TODO Look at potential for applying mask on input - # Random Warp - if self._warp_to_landmarks: - landmarks = np.array([face.aligned.landmarks for face in detected_faces]) - batch_dst_pts = self._get_closest_match(filenames, landmarks) - warp_kwargs = {"batch_src_points": landmarks, "batch_dst_points": batch_dst_pts} - else: - warp_kwargs = {} - - warped = batch[..., :3] if self._no_warp else self._processing.warp( - batch[..., :3], - self._warp_to_landmarks, - **warp_kwargs) - - if self._model_input_size != self._process_size: - feed = self._to_float32(np.array([cv2.resize(image, - (self._model_input_size, - self._model_input_size), - interpolation=cv2.INTER_AREA) - for image in warped])) - else: - feed = self._to_float32(warped) - - return feed, targets - - def _get_closest_match(self, filenames: list[str], batch_src_points: np.ndarray) -> np.ndarray: - """ Only called if the :attr:`_warp_to_landmarks` is ``True``. Gets the closest - matched 68 point landmarks from the opposite training set. - - Parameters - ---------- - filenames: list - Filenames for current batch - batch_src_points: :class:`np.ndarray` - The source landmarks for the current batch - - Returns - ------- - :class:`np.ndarray` - Randomly selected closest matches from the other side's landmarks - """ - logger.trace( # type:ignore[attr-defined] - "Retrieving closest matched landmarks: (filenames: '%s', src_points: '%s')", - filenames, batch_src_points) - lm_side: T.Literal["a", "b"] = "a" if self._side == "b" else "b" - other_cache = get_cache(lm_side) - landmarks = other_cache.aligned_landmarks - - try: - closest_matches = [self._nearest_landmarks[os.path.basename(filename)] - for filename in filenames] - except KeyError: - # Resize mismatched training image size landmarks - sizes = {side: cache.size for side, cache in zip((self._side, lm_side), - (self._face_cache, other_cache))} - if len(set(sizes.values())) > 1: - scale = sizes[self._side] / sizes[lm_side] - landmarks = {key: lms * scale for key, lms in landmarks.items()} - closest_matches = self._cache_closest_matches(filenames, batch_src_points, landmarks) - - batch_dst_points = np.array([landmarks[choice(fname)] for fname in closest_matches]) - logger.trace("Returning: (batch_dst_points: %s)", # type:ignore[attr-defined] - batch_dst_points.shape) - return batch_dst_points - - def _cache_closest_matches(self, - filenames: list[str], - batch_src_points: np.ndarray, - landmarks: dict[str, np.ndarray]) -> list[tuple[str, ...]]: - """ Cache the nearest landmarks for this batch - - Parameters - ---------- - filenames: list - Filenames for current batch - batch_src_points: :class:`np.ndarray` - The source landmarks for the current batch - landmarks: dict - The destination landmarks with associated filenames - - """ - logger.trace("Caching closest matches") # type:ignore - dst_landmarks = list(landmarks.items()) - dst_points = np.array([lm[1] for lm in dst_landmarks]) - batch_closest_matches: list[tuple[str, ...]] = [] - - for filename, src_points in zip(filenames, batch_src_points): - closest = (np.mean(np.square(src_points - dst_points), axis=(1, 2))).argsort()[:10] - closest_matches = tuple(dst_landmarks[i][0] for i in closest) - self._nearest_landmarks[os.path.basename(filename)] = closest_matches - batch_closest_matches.append(closest_matches) - logger.trace("Cached closest matches") # type:ignore - return batch_closest_matches - - -class PreviewDataGenerator(DataGenerator): - """ Generator for compiling images for generating previews. - - This class is called from :mod:`plugins.train.trainer._base` and launches a background - iterator that compiles sample preview data for feeding the model's predict function and for - display. - - Parameters - ---------- - model: :class:`~plugins.train.model.ModelBase` - The model that this data generator is feeding - side: {'a' or 'b'} - The side of the model that this iterator is for. - images: list - A list of image paths that will be used to compile the final images. - batch_size: int - The batch size for this iterator. Images will be returned in :class:`numpy.ndarray` - objects of this size from the iterator. - """ - def _create_samples(self, - images: np.ndarray, - detected_faces: list[DetectedFace]) -> list[np.ndarray]: - """ Compile the 'sample' images. These are the 100% coverage images which hold the model - output in the preview window. - - Parameters - ---------- - images: :class:`numpy.ndarray` - The original batch of images as loaded from disk. - detected_faces: list - List of :class:`~lib.align.DetectedFace` for the current batch - - Returns - ------- - list - List of 4-dimensional target images, at final model output size - """ - logger.trace( # type:ignore[attr-defined] - "Compiling samples: images shape: %s, detected_faces: %s ", - images.shape, len(detected_faces)) - output_size = self._output_sizes[-1] - full_size = 2 * int(np.rint((output_size / self._coverage_ratio) / 2)) - - assert mod_cfg.centering() in T.get_args(CenteringType) - retval = np.empty((full_size, full_size, 3), dtype="float32") - y_offset = mod_cfg.vertical_offset() - assert isinstance(y_offset, int) - retval = self._to_float32(np.array([ - AlignedFace(face.landmarks_xy, - image=images[idx], - centering=T.cast(CenteringType, - mod_cfg.centering()), - y_offset=y_offset / 100., - size=full_size, - dtype="uint8", - is_aligned=True).face - for idx, face in enumerate(detected_faces)])) - - logger.trace("Processed samples: %s", retval.shape) # type:ignore[attr-defined] - return [retval] - - def process_batch(self, - filenames: list[str], - images: np.ndarray, - detected_faces: list[DetectedFace], - batch: np.ndarray) -> BatchType: - """ Creates the full size preview images and the sub-cropped images for feeding the model's - predict function. - - Parameters - ---------- - filenames: list - List of full paths to image file names for a single batch - images: :class:`numpy.ndarray` - The batch of faces corresponding to the filenames - detected_faces: list - List of :class:`~lib.align.DetectedFace` objects with aligned data and masks loaded for - the current batch - batch: :class:`numpy.ndarray` - The pre-allocated batch with images and masks populated for the selected coverage and - centering - - Returns - ------- - feed: :class:`numpy.ndarray` - List of 4-dimensional :class:`numpy.ndarray` objects at model output size for feeding - the model's predict function. The first 3 channels are (rgb/bgr). The 4th channel is - the face mask. - samples: list - 4-dimensional array containing the 100% coverage images at the model's centering for - for generating previews. The array returned is in the format - (`batch size`, `height`, `width`, `channels`). - """ - logger.trace("Process preview: (side: '%s', filenames: '%s', images: %s, " # type:ignore - "batch: %s, detected_faces: %s)", self._side, filenames, images.shape, - batch.shape, len(detected_faces)) - - # Switch color order for RGB models - self._set_color_order(batch) - self._set_color_order(images) - - if not self._use_mask: - mask = np.zeros_like(batch[..., 0])[..., None] + 255 - batch = np.concatenate([batch, mask], axis=-1) - - feed = self._to_float32(batch[..., :4]) # Don't resize here: we want masks at output res. - - # If user sets model input size as larger than output size, the preview will error, so - # resize in these rare instances - out_size = max(self._output_sizes) - if self._process_size > out_size: - feed = np.array([cv2.resize(img, (out_size, out_size), interpolation=cv2.INTER_AREA) - for img in feed]) - - samples = self._create_samples(images, detected_faces) - - return feed, samples - - -class Feeder(): - """ Handles the processing of a Batch for training the model and generating samples. - - Parameters - ---------- - images: dict - The list of full paths to the training images for this :class:`_Feeder` for each side - model: plugin from :mod:`plugins.train.model` - The selected model that will be running this trainer - batch_size: int - The size of the batch to be processed for each side at each iteration - include_preview: bool, optional - ``True`` to create a feeder for generating previews. Default: ``True`` - """ - def __init__(self, - images: dict[T.Literal["a", "b"], list[str]], - model: ModelBase, - batch_size: int, - include_preview: bool = True) -> None: - logger.debug("Initializing %s: num_images: %s, batch_size: %s, include_preview: %s)", - self.__class__.__name__, {k: len(v) for k, v in images.items()}, batch_size, - include_preview) - self._model = model - self._images = images - self._batch_size = batch_size - self._feeds = { - side: self._load_generator(side, False).minibatch_ab() - for side in T.get_args(T.Literal["a", "b"])} - - self._display_feeds = {"preview": self._set_preview_feed() if include_preview else {}, - "timelapse": {}} - logger.debug("Initialized %s:", self.__class__.__name__) - - def _load_generator(self, - side: T.Literal["a", "b"], - is_display: bool, - batch_size: int | None = None, - images: list[str] | None = None) -> DataGenerator: - """ Load the :class:`~lib.training_data.TrainingDataGenerator` for this feeder. - - Parameters - ---------- - side: ["a", "b"] - The side of the model to load the generator for - is_display: bool - ``True`` if the generator is for creating preview/time-lapse images. ``False`` if it is - for creating training images - batch_size: int, optional - If ``None`` then the batch size selected in command line arguments is used, otherwise - the batch size provided here is used. - images: list, optional. Default: ``None`` - If provided then this will be used as the list of images for the generator. If ``None`` - then the training folder images for the side will be used. Default: ``None`` - - Returns - ------- - :class:`~lib.training_data.TrainingDataGenerator` - The training data generator - """ - logger.debug("Loading generator, side: %s, is_display: %s, batch_size: %s", - side, is_display, batch_size) - generator = PreviewDataGenerator if is_display else TrainingDataGenerator - retval = generator(self._model, - side, - self._images[side] if images is None else images, - self._batch_size if batch_size is None else batch_size) - return retval - - def _set_preview_feed(self) -> dict[T.Literal["a", "b"], Generator[BatchType, None, None]]: - """ Set the preview feed for this feeder. - - Creates a generator from :class:`lib.training_data.PreviewDataGenerator` specifically - for previews for the feeder. - - Returns - ------- - dict - The side ("a" or "b") as key, :class:`~lib.training_data.PreviewDataGenerator` as - value. - """ - retval: dict[T.Literal["a", "b"], Generator[BatchType, None, None]] = {} - num_images = trn_cfg.preview_images() - assert isinstance(num_images, int) - for side in T.get_args(T.Literal["a", "b"]): - logger.debug("Setting preview feed: (side: '%s')", side) - preview_images = min(max(num_images, 2), 16) - batchsize = min(len(self._images[side]), preview_images) - retval[side] = self._load_generator(side, - True, - batch_size=batchsize).minibatch_ab() - return retval - - def get_batch(self) -> tuple[np.ndarray, list[np.ndarray]]: - """ Get the feed data and the targets for each training side for feeding into the model's - train function. - - Returns - ------- - model_inputs : :class:`numpy.ndarray` - The inputs to the model for each side A and B. The array is returned in `(side, - batch_size, *dims)` where `side` 0 is "A" and `side` 1 is "B" - model_targets : list[:class:`numpy.ndarray`] - The targets for the model for each side A and B. For each target resolution output - required an array is inserted to the list in format `(side, batch_size, *dims) - where `side` 0 is "A" and `side` 1 is "B" - """ - model_inputs: list[np.ndarray] = [] - model_targets: tuple[list[np.ndarray], list[np.ndarray]] = ([], []) - for idx, side in enumerate(("a", "b")): - side_feed, side_targets = next(self._feeds[side]) - if mod_cfg.Loss.learn_mask(): # Add the face mask as it's own target - side_targets += [side_targets[-1][..., 3][..., None]] - logger.trace( # type:ignore[attr-defined] - "side: %s, input_shapes: %s, target_shapes: %s", - side, side_feed.shape, [i.shape for i in side_targets]) - model_inputs.append(side_feed) - model_targets[idx].extend(side_targets) - - grouped_targets = [] - - for tgt_a, tgt_b in zip(*model_targets): - grouped_targets.append(np.stack([tgt_a, tgt_b], axis=0)) - inputs = np.stack(model_inputs, axis=0) - assert inputs.shape[0] == 2, "1st dimension should represent side A/B" - assert all(x.shape[0] == 2 for x in grouped_targets), ("1st dimension should represent " - "side A/B") - return inputs, grouped_targets - - def generate_preview(self, is_timelapse: bool = False - ) -> dict[T.Literal["a", "b"], list[np.ndarray]]: - """ Generate the images for preview window or timelapse - - Parameters - ---------- - is_timelapse, bool, optional - ``True`` if preview is to be generated for a Timelapse otherwise ``False``. - Default: ``False`` - - Returns - ------- - dict - Dictionary for side A and B of list of numpy arrays corresponding to the - samples, targets and masks for this preview - """ - logger.debug("Generating preview (is_timelapse: %s)", is_timelapse) - - batchsizes: list[int] = [] - feed: dict[T.Literal["a", "b"], np.ndarray] = {} - samples: dict[T.Literal["a", "b"], np.ndarray] = {} - masks: dict[T.Literal["a", "b"], np.ndarray] = {} - - # MyPy can't recurse into nested dicts to get the type :( - iterator = T.cast(dict[T.Literal["a", "b"], "Generator[BatchType, None, None]"], - self._display_feeds["timelapse" if is_timelapse else "preview"]) - for side in T.get_args(T.Literal["a", "b"]): - side_feed, side_samples = next(iterator[side]) - batchsizes.append(len(side_samples[0])) - samples[side] = side_samples[0] - feed[side] = side_feed[..., :3] - masks[side] = side_feed[..., 3][..., None] - - logger.debug("Generated samples: is_timelapse: %s, images: %s", is_timelapse, - {key: {k: v.shape for k, v in item.items()} - for key, item - in zip(("feed", "samples", "sides"), (feed, samples, masks))}) - return self.compile_sample(min(batchsizes), feed, samples, masks) - - def compile_sample(self, - image_count: int, - feed: dict[T.Literal["a", "b"], np.ndarray], - samples: dict[T.Literal["a", "b"], np.ndarray], - masks: dict[T.Literal["a", "b"], np.ndarray] - ) -> dict[T.Literal["a", "b"], list[np.ndarray]]: - """ Compile the preview samples for display. - - Parameters - ---------- - image_count: int - The number of images to limit the sample output to. - feed: dict - Dictionary for side "a", "b" of :class:`numpy.ndarray`. The images that should be fed - into the model for obtaining a prediction - samples: dict - Dictionary for side "a", "b" of :class:`numpy.ndarray`. The 100% coverage target images - that should be used for creating the preview. - masks: dict - Dictionary for side "a", "b" of :class:`numpy.ndarray`. The masks that should be used - for creating the preview. - - Returns - ------- - list - The list of samples, targets and masks as :class:`numpy.ndarrays` for creating a - preview image - """ - num_images = trn_cfg.preview_images() - assert isinstance(num_images, int) - num_images = min(image_count, num_images) - retval: dict[T.Literal["a", "b"], list[np.ndarray]] = {} - for side in T.get_args(T.Literal["a", "b"]): - logger.debug("Compiling samples: (side: '%s', samples: %s)", side, num_images) - retval[side] = [feed[side][0:num_images], - samples[side][0:num_images], - masks[side][0:num_images]] - logger.debug("Compiled Samples: %s", {k: [i.shape for i in v] for k, v in retval.items()}) - return retval - - def set_timelapse_feed(self, - images: dict[T.Literal["a", "b"], list[str]], - batch_size: int) -> None: - """ Set the time-lapse feed for this feeder. - - Creates a generator from :class:`lib.training_data.PreviewDataGenerator` specifically - for generating time-lapse previews for the feeder. - - Parameters - ---------- - images: dict - The list of full paths to the images for creating the time-lapse for each side - batch_size: int - The number of images to be used to create the time-lapse preview. - """ - logger.debug("Setting time-lapse feed: (input_images: '%s', batch_size: %s)", - images, batch_size) - - # MyPy can't recurse into nested dicts to get the type :( - iterator = T.cast(dict[T.Literal["a", "b"], "Generator[BatchType, None, None]"], - self._display_feeds["timelapse"]) - - for side in T.get_args(T.Literal["a", "b"]): - imgs = images[side] - logger.debug("Setting preview feed: (side: '%s', images: %s)", side, len(imgs)) - - iterator[side] = self._load_generator(side, - True, - batch_size=batch_size, - images=imgs).minibatch_ab(do_shuffle=False) - logger.debug("Set time-lapse feed: %s", self._display_feeds["timelapse"]) - - -__all__ = get_module_objects(__name__) diff --git a/lib/training/lr_finder.py b/lib/training/lr_finder.py index b12bd677a4..f78522c466 100644 --- a/lib/training/lr_finder.py +++ b/lib/training/lr_finder.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Learning Rate Finder for faceswap.py. """ +"""Learning Rate Finder for faceswap.py.""" from __future__ import annotations import logging import os @@ -19,32 +19,32 @@ if T.TYPE_CHECKING: from keras import optimizers - from plugins.train import training + from . import train logger = logging.getLogger(__name__) class LRStrength(Enum): - """ Enum for how aggressively to set the optimal learning rate """ + """Enum for how aggressively to set the optimal learning rate""" DEFAULT = 10 AGGRESSIVE = 5 EXTREME = 2.5 class LearningRateFinder: # pylint:disable=too-many-instance-attributes - """ Learning Rate Finder + """Learning Rate Finder Parameters ---------- - trainer : :class:`plugins.train.run_trainer.Trainer` + trainer The training loop with the loaded training plugin - stop_factor : int + stop_factor When to stop finding the optimal learning rate - beta : float + beta Amount to smooth loss by, for graphing purposes """ def __init__(self, # pylint:disable=too-many-positional-arguments - trainer: training.Trainer, + trainer: train.Trainer, stop_factor: int = 4, beta: float = 0.98) -> None: logger.debug(parse_class_init(locals())) @@ -72,13 +72,13 @@ def __init__(self, # pylint:disable=too-many-positional-arguments logger.debug("Initialized %s", self.__class__.__name__) def _on_batch_end(self, iteration: int, loss: float) -> None: - """ Learning rate actions to perform at the end of a batch + """Learning rate actions to perform at the end of a batch Parameters ---------- - iteration: int + iteration The current iteration - loss: float + loss The loss value for the current batch """ learning_rate = float(self._optimizer.learning_rate.numpy()) @@ -102,11 +102,11 @@ def _on_batch_end(self, iteration: int, loss: float) -> None: self._optimizer.learning_rate.assign(learning_rate) def _update_description(self, progress_bar: tqdm) -> None: - """ Update the description of the progress bar for the current iteration + """Update the description of the progress bar for the current iteration Parameters ---------- - progress_bar: :class:`tqdm.tqdm` + progress_bar The learning rate finder progress bar to update """ current = self._metrics['learning_rates'][-1] @@ -115,29 +115,28 @@ def _update_description(self, progress_bar: tqdm) -> None: progress_bar.set_description(f"Current: {current:.1e} Best: {best:.1e}") def _train(self) -> None: - """ Train the model for the given number of iterations to find the optimal + """Train the model for the given number of iterations to find the optimal learning rate and show progress""" logger.info("Finding optimal learning rate...") - pbar = tqdm(range(1, self._iterations + 1), - desc="Current: N/A Best: N/A ", - leave=False) - for idx in pbar: + p_bar = tqdm(range(1, self._iterations + 1), + desc="Current: N/A Best: N/A ", + leave=False) + for idx in p_bar: loss = self._trainer.train_one_batch() if any(np.isnan(x) for x in loss): logger.warning("NaN detected! Exiting early") break self._on_batch_end(idx, loss[0]) - self._update_description(pbar) + self._update_description(p_bar) def _rebuild_optimizer(self, optimizer: optimizers.Optimizer) -> optimizers.Optimizer: - """ Pass through nested Optimizers (eg LossScaleOptimizer) and create new nested + """Pass through nested Optimizers (eg LossScaleOptimizer) and create new nested optimizers based on their original config Returns ------- - :class:`keras.optimizers.Optimizer` - A new optimizer of the same type as the given one, with the same config + A new optimizer of the same type as the given one, with the same config """ logger.debug("Processing optimizer: '%s'", optimizer.name) config = optimizer.get_config() @@ -149,14 +148,14 @@ def _rebuild_optimizer(self, optimizer: optimizers.Optimizer) -> optimizers.Opti return retval def _reset_model(self, original_lr: float, new_lr: float) -> None: - """ Reset the model's weights to initial values, reset the model's optimizer and set the + """Reset the model's weights to initial values, reset the model's optimizer and set the learning rate Parameters ---------- - original_lr: float + original_lr The model's original learning rate - new_lr: float + new_lr The discovered optimal learning rate """ self._model.state.add_lr_finder(new_lr) @@ -182,12 +181,11 @@ def _reset_model(self, original_lr: float, new_lr: float) -> None: self._optimizer = self._model.model.optimizer def find(self) -> bool: - """ Find the optimal learning rate + """Find the optimal learning rate Returns ------- - bool - ``True`` if the learning rate was succesfully discovered otherwise ``False`` + ``True`` if the learning rate was successfully discovered otherwise ``False`` """ if not self._model.io.model_exists: self._model.io.save() @@ -211,13 +209,13 @@ def find(self) -> bool: return True def _plot_loss(self, skip_begin: int = 10, skip_end: int = 1) -> None: - """ Plot a graph of loss vs learning rate and save to the training folder + """Plot a graph of loss vs learning rate and save to the training folder Parameters ---------- - skip_begin: int, optional + skip_begin Number of iterations to skip at the start. Default: `10` - skip_end: int, optional + skip_end Number of iterations to skip at the end. Default: `1` """ if not self._save_graph: diff --git a/lib/training/preview.py b/lib/training/preview.py new file mode 100644 index 0000000000..34e05a8438 --- /dev/null +++ b/lib/training/preview.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""Handles the creation of display images for preview window and timelapses """ +from __future__ import annotations + +import logging +import typing as T + +import cv2 +import numpy as np + +from lib.logger import format_array, parse_class_init +from lib.image import hex_to_rgb +from lib.utils import get_module_objects +from lib.training.data_set import get_label + +if T.TYPE_CHECKING: + import numpy.typing as npt + +logger = logging.getLogger(__name__) + + +class Samples(): + """Compile samples for display for preview and time-lapse + + Parameters + ---------- + coverage_ratio + Ratio of face to be cropped out of the training image. + has_mask + ``True`` if the model was trained with a mask + mask_opacity + The opacity (as a percentage) to use for the mask overlay + mask_color + The hex RGB value to use the mask overlay + """ + def __init__(self, + coverage_ratio: float, + has_mask: bool, + mask_opacity: int, + mask_color: str) -> None: + logger.debug(parse_class_init(locals())) + self._coverage_ratio = coverage_ratio + self._has_mask = has_mask + self._mask_opacity = mask_opacity / 100.0 + self._mask_color = mask_color + self._mask_color_array = ( + np.array(hex_to_rgb(mask_color), + dtype=np.float32)[..., 2::-1] / 255.).astype(np.float32) + + self._name = self.__class__.__name__ + self._display_mask = has_mask + + def __repr__(self) -> str: + """Pretty print for logging""" + params = ", ".join(f"{k[1:]}={repr(v)}" for k, v in self.__dict__.items() + if k in ("_coverage_ratio", "_has_mask", "_mask_opacity", + "_mask_color")) + return f"{self._name}({params})" + + def toggle_mask_display(self) -> None: + """Toggle the mask overlay on or off depending on user input.""" + if not self._has_mask: + return + display_mask = not self._display_mask + print("\x1b[2K", end="\r") # Clear last line + logger.info("Toggling mask display %s...", "on" if display_mask else "off") + self._display_mask = display_mask + + def _get_background(self, + targets: npt.NDArray[np.float32], + patch_size: int, + padding: int) -> npt.NDArray[np.float32]: + """Obtain the images that will hold the background stacked as (src>dst, samples, width, + height, 3) + + For 100% coverage just the source (ground truth) images will be populated, otherwise all + backgrounds are populated from the ground truth and the crop area box is created + + Parameters + ---------- + targets + The The (BGR) targets shape: (src_side, batch_size, height, width, channels) + patch_size + The size of each final face patch + padding + The padding required to place the prediction within the final patch + + Returns + ------- + The background image patches shaped (src_side, num_src + 1, batch_size, height, width, 3) + """ + num_swaps = targets.shape[0] + assert self._coverage_ratio != 1.0, "Background only required for coverage != 1.0" + retval = np.empty((num_swaps, num_swaps + 1, *targets.shape[1:4], 3), dtype=np.float32) + length = patch_size // 4 + t_l, b_r = (padding - 1, patch_size - padding + 1) + retval[:] = np.repeat(targets[:, None, ..., :3], 3, axis=1) + retval[:, :, :, t_l:t_l + length, t_l:t_l + length] = self._mask_color_array + retval[:, :, :, t_l:t_l + length, b_r - length:b_r] = self._mask_color_array + retval[:, :, :, b_r - length:b_r, b_r - length:b_r] = self._mask_color_array + retval[:, :, :, b_r - length:b_r, t_l:t_l + length] = self._mask_color_array + logger.debug("[%s] Created background display patches: %s", + self._name, format_array(retval)) + return retval + + def _get_foreground(self, + predictions: npt.NDArray[np.float32], + targets: npt.NDArray[np.float32], + patch_size: int, + padding: int) -> npt.NDArray[np.float32]: + """Obtain the foreground patches for overlaying on the backgrounds, with any mask + application applied + + Parameters + ---------- + predictions + The The (BGR) predictions shape: (src_side, dst_side, batch_size, height, width, + channels) + targets + The The (BGR) targets shape: (src_side, batch_size, height, width, channels) + patch_size + The size of each final face patch + padding + The padding required to place the prediction within the final patch + + Returns + ------- + The foreground image patches shaped (src_side, num_src + 1, batch_size, height, width, 3) + """ + num_swaps = predictions.shape[0] + retval = np.empty((num_swaps, num_swaps + 1, *predictions.shape[2:5], 3), + dtype=np.float32) + + retval[:, 1:] = predictions[..., :3] + + if self._coverage_ratio == 1.: + retval[:, 0] = targets[..., :3] + else: + retval[:, 0] = targets[:, + :, + padding:patch_size - padding, + padding:patch_size - padding, + :3] + + logger.debug("[%s] Created foreground display patches: %s", + self._name, format_array(retval)) + return retval + + def _apply_masks(self, + patches: npt.NDArray[np.float32], + predictions: npt.NDArray[np.float32], + targets: npt.NDArray[np.float32], + patch_size: int, + padding: int) -> npt.NDArray[np.float32]: + """Apply the masks to the final patches, if requested + + Parameters + ---------- + image + The image patches shaped (src_side, num_src + 1, batch_size, height, width, 3) to have + masks applied + predictions + The The (BGR) predictions shape: (src_side, dst_side, batch_size, height, width, + channels) + targets + The The (BGR) targets shape: (src_side, batch_size, height, width, channels) + patch_size + The size of each final face patch + padding + The padding required to place the prediction within the final patch + """ + if not self._display_mask: + return patches + + if predictions.shape[-1] == 4: # Learn mask is enabled + masks = np.zeros(patches.shape[:-1], dtype=np.float32) + masks[:, 0] = targets[..., -1] + pred = predictions[..., -1] + + if self._coverage_ratio == 1.0: + masks[:, 1:] = pred + else: + masks[:, 1:, :, padding:patch_size - padding, padding:patch_size - padding] = pred + else: + masks = np.repeat(targets[:, None, ..., -1], 3, axis=1) + masks = 1. - masks + overlay = np.ones_like(patches, dtype=np.float32) * self._mask_color_array + masks *= self._mask_opacity + overlay *= masks[..., None] + patches *= (1. - masks[..., None]) + retval = patches + T.cast("npt.NDArray[np.float32]", overlay) + logger.debug("[%s] Applied masks: %s", self._name, format_array(retval)) + return retval + + def _get_headers(self, num_swaps: int, patch_width: int # pylint:disable=too-many-locals + ) -> npt.NDArray[np.uint8]: + """Set header row for the final preview frame + + Parameters + ---------- + num_swaps + The number of swap instances exist within the model + patch_width + The width of each of the display patches + + Returns + ------- + The column headings for the output image + """ + labels = [ + get_label(i, num_swaps) + (f" > {get_label(i + j, num_swaps, next_identity=True)}" + if j > 0 else "") + for i in range(num_swaps) + for j in range(num_swaps + 1) + ] + cols = len(labels) + height = int(patch_width / 4.5) + headers = np.zeros((cols, height, patch_width, 3), dtype="uint8") + 255 + font = cv2.FONT_HERSHEY_SIMPLEX + scaling = patch_width / 140 + text_sizes = [cv2.getTextSize(labels[idx], font, scaling, 1)[0] + for idx in range(len(labels))] + t_y = int((height + text_sizes[0][1]) / 2) + t_x = [int((patch_width - text_sizes[i][0]) / 2) for i in range(cols)] + thickness = max(1, patch_width // 64) + logger.debug("[%s] labels: %s, text_sizes: %s, text_x: %s, text_y: %s, thickness: %s, " + "scaling: %s", + self._name, labels, text_sizes, t_x, t_y, thickness, scaling) + for idx, (text, header) in enumerate(zip(labels, headers)): + cv2.putText(header, + text, + (t_x[idx], t_y), + font, + scaling, + (0, 0, 0), + thickness, + lineType=cv2.LINE_AA) + retval = headers.swapaxes(0, 1).reshape((height, patch_width * cols, 3)) + logger.debug("[%s] Headers: %s", self._name, format_array(retval)) + return retval + + def _create_image(self, patches: npt.NDArray[np.float32]) -> npt.NDArray[np.uint8]: + """Create the final laid out image display with headers + + Parameters + ---------- + patches + The final image patches shaped (src_side, num_src + 1, batch_size, height, width, 3) + + Returns + ------- + The final preview image + """ + headers = self._get_headers(patches.shape[0], patches.shape[-2]) + src_side, img_count, identities, rows, cols, channels = patches.shape + images = (patches.transpose(2, 3, 0, 1, 4, 5).reshape((rows * identities, + cols * src_side * img_count, + channels)) * 255.).astype(np.uint8) + if images.shape[0] > images.shape[1]: + height = len(images) // 2 + images = np.concatenate([images[:height], images[height:]], axis=1) + headers = np.concatenate([headers, headers], axis=1) + retval = np.concatenate([headers, images], axis=0) + logger.debug("[%s] Created preview: %s", self._name, format_array(retval)) + return retval + + def get_preview(self, predictions: npt.NDArray[np.float32], targets: npt.NDArray[np.float32] + ) -> npt.NDArray[np.uint8]: + """Compile a preview image. + + Predictions + The (BGR) predictions shape: (src_side, dst_side, batch_size, height, width, channels) + targets + Full size BGR face patches at 100% coverage for patching predictions into in + (A, B, ...) order + + Returns + ------- + A compiled preview image ready for display or saving + """ + patch_size = targets.shape[-2] + pad = (patch_size - predictions.shape[-2]) // 2 + + logger.debug("[%s] Showing sample. Predictions: %s, targets: %s, patch_size: %s, " + "padding: %s", + self._name, format_array(predictions), format_array(targets), + patch_size, pad) + + foreground = self._get_foreground(predictions, targets, patch_size, pad) + + if self._coverage_ratio != 1.0: + patches = self._get_background(targets, patch_size, pad) + patches[:, :, :, pad:patch_size - pad, pad:patch_size - pad] = foreground + else: + patches = foreground + + patches = self._apply_masks(patches, predictions, targets, patch_size, pad) + return self._create_image(patches) + + +__all__ = get_module_objects(__name__) diff --git a/lib/training/train.py b/lib/training/train.py new file mode 100644 index 0000000000..16310fbb5e --- /dev/null +++ b/lib/training/train.py @@ -0,0 +1,515 @@ +#! /usr/env/bin/python3 +"""Run the training loop for a training plugin """ +from __future__ import annotations + +import logging +import os +import typing as T +import time +import warnings + +import cv2 +import numpy as np + +import torch +from torch.cuda import OutOfMemoryError + +from lib.logger import format_array, parse_class_init +from lib.training import LearningRateFinder, LearningRateWarmup +from lib.training.preview import Samples +from lib.training.data_loader import PreviewLoader, TrainLoader +from lib.training.tensorboard import TorchTensorBoard +from lib.utils import get_module_objects, FaceswapError +from plugins.train import train_config as mod_cfg +from plugins.train.trainer import trainer_config as trn_cfg + +if T.TYPE_CHECKING: + import numpy.typing as npt + from collections.abc import Callable + from plugins.train.trainer.base import TrainerBase + +logger = logging.getLogger(__name__) + + +# Suppress non-Faceswap related Keras warning about backend padding mismatches +warnings.filterwarnings("ignore", + message="You might experience inconsistencies", + category=UserWarning) + + +class Trainer: # pylint:disable=too-many-instance-attributes + """Handles the feeding of training images to Faceswap models, the generation of Tensorboard + logs and the creation of sample/time-lapse preview images. + + All Trainer plugins must inherit from this class. + + Parameters + ---------- + plugin + The plugin that will be processing each batch + preview + ``True`` to generate previews + timelapse_folders + The input folders to create timelapse images from. Default: ``None`` (no timelapse) + timelapse_output + The folder to output timelapse images. Default: "" (no timelapse) + """ + + def __init__(self, + plugin: TrainerBase, + preview: bool, + timelapse_folders: list[str] | None = None, + timelapse_output: str = "") -> None: + logger.debug(parse_class_init(locals())) + self._plugin = plugin + self._preview = preview + self._timelapse_folders = [] if timelapse_folders is None else timelapse_folders + self._timelapse_output = timelapse_output + + self._model = plugin.model + self._out_size = max(x[1] for x in self._model.output_shapes if x[-1] != 1) + + self._train_loader = self._get_train_loader() + self._preview_loader = self._get_preview_loader() + self._timelapse_loader = self._get_timelapse_loader() + + self._exit_early = self._handle_lr_finder() + if self._exit_early: + logger.debug("[Trainer] Exiting from LR Finder") + return + + self._warmup = self._get_warmup() + self._model.state.add_session_batchsize(plugin.batch_size) + + self._tensorboard = self._set_tensorboard() + self._samples = Samples(self._model.coverage_ratio, + mod_cfg.Loss.learn_mask() or mod_cfg.Loss.penalized_mask_loss(), + trn_cfg.Augmentation.mask_opacity(), + trn_cfg.Augmentation.mask_color()) + + def __repr__(self) -> str: + """Pretty print for logging""" + params = ", ".join(f"{k[1:]}={repr(v)}" for k, v in self.__dict__.items() + if k in ("_plugin", "_preview", "_timelapse_folders", + "_timelapse_output")) + return f"{self.__class__.__name__}({params})" + + @property + def exit_early(self) -> bool: + """``True`` if the trainer should exit early, without performing any training steps""" + return self._exit_early + + def _get_train_loader(self) -> TrainLoader: + """Get the loaders for training the model + + Returns + ------- + The loaders for feeding the model's training loop + """ + input_sizes = [x[1] for x in self._model.input_shapes] + assert len(set(input_sizes)) == 1, f"Multiple input sizes not supported. Got {input_sizes}" + + out_sizes = [x[1] for x in self._model.output_shapes if x[-1] != 1] + num_sides = len(self._plugin.config.folders) + assert len(out_sizes) % num_sides == 0, ( + f"Output count ({len(out_sizes)}) doesn't match number of inputs ({num_sides})") + split = len(out_sizes) // num_sides + split_sizes = [out_sizes[x:x+split] for x in range(0, len(out_sizes), split)] + assert len(set(out_sizes)) == len(set(split_sizes[0])), "Sizes for each output must match" + + retval = TrainLoader(input_sizes[0], + tuple(split_sizes[0]), + self._model.color_order, + self._plugin.config, + self._plugin.sampler) + logger.debug("[Trainer] data loader: %s", retval) + return retval + + def _get_preview_loader(self) -> PreviewLoader | None: + """Get the loader for generating previews whilst training the model + + Returns + ------- + The loader for generating preview images during training or ``None`` if previews are + disabled + """ + if not self._preview: + return None + input_size = self._model.input_shapes[0][1] + retval = PreviewLoader(input_size, + self._out_size, + self._model.color_order, + self._plugin.config.folders, + trn_cfg.Augmentation.preview_images(), + torch.utils.data.RandomSampler) + logger.debug("[Trainer] Preview data loader: %s", retval) + return retval + + def _get_timelapse_loader(self) -> PreviewLoader | None: + """Get the loader for generating timelapse images whilst training the model + + Returns + ------- + The loaders for timelapse preview images during training or ``None`` if previews are + disabled + """ + if not self._timelapse_folders or not self._timelapse_output: + return None + num_images = trn_cfg.Augmentation.preview_images() + avail_images = min(len([fname for fname in os.listdir(folder) + if os.path.splitext(fname)[-1].lower() == ".png"]) + for folder in self._timelapse_folders) + num_samples = min(num_images, avail_images) + logger.debug("[Train] preview count: %s, available_images: %s, timelapse count: %s", + num_images, avail_images, num_samples) + input_size = self._model.input_shapes[0][1] + retval = PreviewLoader(input_size, + self._out_size, + self._model.color_order, + self._timelapse_folders, + trn_cfg.Augmentation.preview_images(), + torch.utils.data.SequentialSampler, + num_samples=num_samples) + logger.debug("[Trainer] Preview data loader: %s", retval) + return retval + + def _handle_lr_finder(self) -> bool: + """Handle the learning rate finder. + + If this is a new model, then find the optimal learning rate and return ``True`` if user has + just requested the graph, otherwise return ``False`` to continue training + + If it as existing model, set the learning rate to the value found by the learning rate + finder and return ``False`` to continue training + + Returns + ------- + ``True`` if the learning rate finder options dictate that training should not continue + after finding the optimal leaning rate + """ + if not self._plugin.config.lr_finder: + return False + + if self._model.state.lr_finder > -1: + learning_rate = self._model.state.lr_finder + logger.info("Setting learning rate from Learning Rate Finder to %s", + f"{learning_rate:.1e}") + self._model.model.optimizer.learning_rate.assign(learning_rate) + self._model.state.update_session_config("learning_rate", learning_rate) + return False + + if self._model.state.iterations == 0 and self._model.state.session_id == 1: + lrf = LearningRateFinder(self) + success = lrf.find() + return mod_cfg.lr_finder_mode() == "graph_and_exit" or not success + + logger.debug("[Trainer] No learning rate finder rate. Not setting") + return False + + def _get_warmup(self) -> LearningRateWarmup: + """Obtain the learning rate warmup instance + + Returns + ------- + The Learning Rate Warmup object + """ + target_lr = float(self._model.model.optimizer.learning_rate.value.cpu().numpy()) + return LearningRateWarmup(self._model.model, target_lr, self._model.warmup_steps) + + def _set_tensorboard(self) -> TorchTensorBoard | None: + """Set up Tensorboard callback for logging loss. + + Bypassed if command line option "no-logs" has been selected. + + Returns + ------- + Tensorboard object for the the current training session. ``None`` if Tensorboard logging is + not selected + """ + if self._model.state.current_session["no_logs"]: + logger.verbose("TensorBoard logging disabled") # type: ignore + return None + logger.debug("[Trainer] Enabling TensorBoard Logging") + + logger.debug("[Trainer] Setting up TensorBoard Logging") + log_dir = os.path.join(str(self._model.io.model_dir), + f"{self._model.name}_logs", + f"session_{self._model.state.session_id}") + tensorboard = TorchTensorBoard(log_dir=log_dir, + write_graph=True, + update_freq="batch") + tensorboard.set_model(self._model.model) + logger.verbose("Enabled TensorBoard Logging") # type: ignore + return tensorboard + + def toggle_mask(self) -> None: + """Toggle the mask overlay on or off based on user input.""" + self._samples.toggle_mask_display() + + def train_one_batch(self) -> np.ndarray: + """Process a single batch through the model and obtain the loss + + Returns + ------- + The total loss in the first position then A losses, by output order, then B losses, by + output order + """ + try: + inputs, targets = next(self._train_loader) + loss_t = self._plugin.train_batch(inputs, targets) + loss_cpu = loss_t.detach().cpu().numpy() + retval = np.array([sum(loss_cpu), *loss_cpu]) + except OutOfMemoryError as err: + msg = ("You do not have enough GPU memory available to train the selected model at " + "the selected settings. You can try a number of things:" + "\n1) Close any other application that is using your GPU (web browsers are " + "particularly bad for this)." + "\n2) Lower the batchsize (the amount of images fed into the model each " + "iteration)." + "\n3) Try enabling 'Mixed Precision' training." + "\n4) Use a more lightweight model, or select the model's 'LowMem' option " + "(in config) if it has one.") + raise FaceswapError(msg) from err + return retval + + def _log_tensorboard(self, loss: np.ndarray) -> None: + """Log current loss to Tensorboard log files + + Parameters + ---------- + loss + The total loss in the first position then A losses, by output order, then B losses, by + output order + """ + if not self._tensorboard: + return + logger.trace("[Trainer] Updating TensorBoard log") # type: ignore + logs = {log[0]: float(log[1]) + for log in zip(self._model.state.loss_names, loss)} + + self._tensorboard.on_train_batch_end(self._model.iterations, logs=logs) + + def _collate_and_store_loss(self, loss: np.ndarray) -> np.ndarray: + """Collate the loss into totals for each side. + + The losses are summed into a total for each side. Loss totals are added to + :attr:`model.state._history` to track the loss drop per save iteration for backup purposes. + + If NaN protection is enabled, Checks for NaNs and raises an error if detected. + + Parameters + ---------- + loss + The total loss in the first position then A losses, by output order, then B losses, by + output order + + Returns + ------- + 2 ``floats`` which is the total loss for each side (eg sum of face + mask loss) + + Raises + ------ + FaceswapError + If a NaN is detected, a :class:`FaceswapError` will be raised + """ + # NaN protection + if mod_cfg.nan_protection() and not all(np.isfinite(val) for val in loss): + logger.critical("NaN Detected. Loss: %s", loss) + raise FaceswapError("A NaN was detected and you have NaN protection enabled. Training " + "has been terminated.") + + split = len(loss) // 2 + combined_loss = np.array([sum(loss[:split]), sum(loss[split:])]) + self._model.add_history(combined_loss) + logger.trace("[Trainer] original loss: %s, combined_loss: %s", # type:ignore[attr-defined] + loss, combined_loss) + return combined_loss + + def _print_loss(self, loss: np.ndarray) -> None: + """Outputs the loss for the current iteration to the console. + + Parameters + ---------- + The loss for each side. List should contain 2 ``floats`` side "a" in position 0 and side + "b" in position 1. + """ + output = ", ".join([f"Loss {side}: {side_loss:.5f}" + for side, side_loss in zip(("A", "B"), loss)]) + timestamp = time.strftime("%H:%M:%S") + output = f"[{timestamp}] [#{self._model.iterations:05d}] {output}" + print(f"{output}", end="\r") + + def _get_predictions(self, feed: torch.Tensor) -> npt.NDArray[np.float32]: + """Obtain preview predictions from the model, chunking feeds into the model's batch size + + Parameters + ---------- + feed + The input tensor to obtain predictions from the model in shape (num_sides, N, height, + width, 3) + + Returns + ------- + The predictions from the model for the given preview feed + """ + batch_size = self._plugin.batch_size + ndim = 4 if mod_cfg.Loss.learn_mask() else 3 + retval = np.empty((feed.shape[0], feed.shape[1], self._out_size, self._out_size, ndim), + dtype=np.float32) + for idx in range(0, feed.shape[1], batch_size): + feed_batch = feed[:, idx:idx + batch_size] + feed_size = feed_batch.shape[1] + is_padded = feed_size < batch_size + + if is_padded: + holder = torch.empty((feed_batch.shape[0], batch_size, *feed_batch.shape[2:]), + dtype=feed.dtype) + logger.debug("[Trainer] Padding undersized batch of shape %s to %s", + feed_batch.shape, holder.shape) + holder[:, :feed_size] = feed_batch + feed_batch = holder + with torch.inference_mode(): + out = [x.cpu().numpy() for x in self._model.model(list(feed_batch)) + if x.shape[1] == self._out_size] # Filter multi-scale output + if mod_cfg.Loss.learn_mask(): # Apply mask to alpha channel + out = [np.concatenate(out[i:i + 2], axis=-1) for i in range(0, len(out), 2)] + out_arr = np.stack(out, axis=0) + if is_padded: + out_arr = out_arr[:, :feed_size] + retval[:, idx:idx + feed_size] = out_arr + return retval + + def _update_viewers(self, # pylint:disable=too-many-locals + viewer: Callable[[np.ndarray, str], None] | None, + do_timelapse: bool = False) -> None: + """Update the preview viewer and timelapse output + + Parameters + ---------- + viewer + The function that will display the preview image + do_timelapse + ``True`` to generate a timelapse preview image + """ + if (viewer is None or self._preview_loader is None) and not do_timelapse: + return + + if do_timelapse: + assert self._timelapse_loader is not None + loader = self._timelapse_loader + else: + assert self._preview_loader is not None + loader = self._preview_loader + feed, target = next(loader) + + num_sides = feed.shape[0] + ndim = 4 if mod_cfg.Loss.learn_mask() else 3 + predictions: npt.NDArray[np.float32] = np.empty((num_sides, + num_sides, + target.shape[1], + self._out_size, + self._out_size, + ndim), + dtype=np.float32) + logger.debug("[Trainer] feed: %s, target: %s, predictions_holder: %s", + feed.shape, target.shape, predictions.shape) + for side_idx in range(num_sides): + rolled_feed = torch.roll(feed, shifts=side_idx, dims=0) + pred = self._get_predictions(rolled_feed) + for input_idx in range(num_sides): + original_idx = (input_idx - side_idx) % num_sides + predictions[original_idx, side_idx] = pred[input_idx] + + targets = target.cpu().numpy() + if self._model.color_order == "rgb": + predictions[..., :3] = predictions[..., 2::-1] + targets[..., :3] = targets[..., 2::-1] + logger.debug("[Trainer] Got preview images: predictions: %s, targets: %s", + format_array(predictions), format_array(targets)) + + samples = self._samples.get_preview(predictions, targets) + + if do_timelapse: + filename = os.path.join(self._timelapse_output, str(int(time.time())) + ".jpg") + cv2.imwrite(filename, samples) + logger.debug("[Trainer] Created time-lapse: '%s'", filename) + return + + if viewer is not None: + viewer(samples, + "Training - 'S': Save Now. 'R': Refresh Preview. 'M': Toggle Mask. 'F': " + "Toggle Screen Fit-Actual Size. 'ENTER': Save and Quit") + + def train_one_step(self, + viewer: Callable[[np.ndarray, str], None] | None, + do_timelapse: bool = False) -> None: + """Running training on a batch of images for each side. + + Triggered from the training cycle in :class:`scripts.train.Train`. + + * Runs a training batch through the model. + + * Outputs the iteration's loss values to the console + + * Logs loss to Tensorboard, if logging is requested. + + * If a preview or time-lapse has been requested, then pushes sample images through the \ + model to generate the previews + + * Creates a snapshot if the total iterations trained so far meet the requested snapshot \ + criteria + + Notes + ----- + As every iteration is called explicitly, the Parameters defined should always be ``None`` + except on save iterations. + + Parameters + ---------- + viewer + The function that will display the preview image + do_timelapse + ``True`` to generate a timelapse preview image + """ + self._model.state.increment_iterations() + logger.trace("[Trainer] Training one step: (iteration: %s)", # type:ignore[attr-defined] + self._model.iterations) + do_snapshot = (self._plugin.config.snapshot_interval != 0 and + self._model.iterations - 1 >= self._plugin.config.snapshot_interval and + (self._model.iterations - 1) % self._plugin.config.snapshot_interval == 0) + self._warmup() + loss = self.train_one_batch() + self._log_tensorboard(loss) + loss = self._collate_and_store_loss(loss[1:]) + self._print_loss(loss) + if do_snapshot: + self._model.io.snapshot() + self._update_viewers(viewer, do_timelapse) + + def _clear_tensorboard(self) -> None: + """Stop Tensorboard logging. + + Tensorboard logging needs to be explicitly shutdown on training termination. Called from + :class:`scripts.train.Train` when training is stopped. + """ + if not self._tensorboard: + return + logger.debug("[Trainer] Ending Tensorboard Session: %s", self._tensorboard) + self._tensorboard.on_train_end() + + def save(self, is_exit: bool = False) -> None: + """Save the model + + Parameters + ---------- + is_exit + ``True`` if save has been called on model exit. Default: ``False`` + """ + self._model.io.save(is_exit=is_exit) + assert self._tensorboard is not None + self._tensorboard.on_save() + if is_exit: + self._clear_tensorboard() + + +__all__ = get_module_objects(__name__) diff --git a/locales/plugins.train.trainer.trainer_config.pot b/locales/plugins.train.trainer.trainer_config.pot index 8c44e5e618..0e9cf994da 100644 --- a/locales/plugins.train.trainer.trainer_config.pot +++ b/locales/plugins.train.trainer.trainer_config.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-12-12 20:45+0000\n" +"POT-Creation-Date: 2026-04-16 03:22+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,7 +17,31 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: plugins/train/trainer/trainer_config.py:30 +#: plugins/train/trainer/trainer_config.py:29 +msgid "" +"Data Loader Options.\n" +"Controls how training data is loaded from disk" +msgstr "" + +#: plugins/train/trainer/trainer_config.py:35 +#: plugins/train/trainer/trainer_config.py:44 +msgid "data loading" +msgstr "" + +#: plugins/train/trainer/trainer_config.py:36 +msgid "" +"Number of processors to use for loading and processing data from disk. 0 to " +"just use the Main process." +msgstr "" + +#: plugins/train/trainer/trainer_config.py:45 +msgid "" +"The Number of items that each loader should pre-fetch and hold in RAM. " +"Default is usually fine unless you have disk contention with variable read " +"speeds." +msgstr "" + +#: plugins/train/trainer/trainer_config.py:56 #, python-format msgid "" "Data Augmentation Options.\n" @@ -25,86 +49,86 @@ msgid "" "Only change them if you absolutely know what you are doing!" msgstr "" -#: plugins/train/trainer/trainer_config.py:42 -#: plugins/train/trainer/trainer_config.py:50 -#: plugins/train/trainer/trainer_config.py:60 +#: plugins/train/trainer/trainer_config.py:63 +#: plugins/train/trainer/trainer_config.py:71 +#: plugins/train/trainer/trainer_config.py:81 msgid "evaluation" msgstr "" -#: plugins/train/trainer/trainer_config.py:43 +#: plugins/train/trainer/trainer_config.py:64 msgid "" "Number of sample faces to display for each side in the preview when training." msgstr "" -#: plugins/train/trainer/trainer_config.py:51 +#: plugins/train/trainer/trainer_config.py:72 msgid "" "The opacity of the mask overlay in the training preview. Lower values are " "more transparent." msgstr "" -#: plugins/train/trainer/trainer_config.py:61 +#: plugins/train/trainer/trainer_config.py:82 msgid "The RGB hex color to use for the mask overlay in the training preview." msgstr "" -#: plugins/train/trainer/trainer_config.py:66 -#: plugins/train/trainer/trainer_config.py:74 -#: plugins/train/trainer/trainer_config.py:82 -#: plugins/train/trainer/trainer_config.py:91 +#: plugins/train/trainer/trainer_config.py:87 +#: plugins/train/trainer/trainer_config.py:95 +#: plugins/train/trainer/trainer_config.py:103 +#: plugins/train/trainer/trainer_config.py:112 msgid "image augmentation" msgstr "" -#: plugins/train/trainer/trainer_config.py:67 +#: plugins/train/trainer/trainer_config.py:88 msgid "Percentage amount to randomly zoom each training image in and out." msgstr "" -#: plugins/train/trainer/trainer_config.py:75 +#: plugins/train/trainer/trainer_config.py:96 msgid "Percentage amount to randomly rotate each training image." msgstr "" -#: plugins/train/trainer/trainer_config.py:83 +#: plugins/train/trainer/trainer_config.py:104 msgid "" "Percentage amount to randomly shift each training image horizontally and " "vertically." msgstr "" -#: plugins/train/trainer/trainer_config.py:92 +#: plugins/train/trainer/trainer_config.py:113 msgid "" "Percentage chance to randomly flip each training image horizontally.\n" "NB: This is ignored if the 'no-flip' option is enabled" msgstr "" -#: plugins/train/trainer/trainer_config.py:100 -#: plugins/train/trainer/trainer_config.py:109 -#: plugins/train/trainer/trainer_config.py:119 +#: plugins/train/trainer/trainer_config.py:121 #: plugins/train/trainer/trainer_config.py:130 +#: plugins/train/trainer/trainer_config.py:140 +#: plugins/train/trainer/trainer_config.py:151 msgid "color augmentation" msgstr "" -#: plugins/train/trainer/trainer_config.py:101 +#: plugins/train/trainer/trainer_config.py:122 msgid "" "Percentage amount to randomly alter the lightness of each training image.\n" "NB: This is ignored if the 'no-augment-color' option is enabled" msgstr "" -#: plugins/train/trainer/trainer_config.py:110 +#: plugins/train/trainer/trainer_config.py:131 msgid "" "Percentage amount to randomly alter the 'a' and 'b' colors of the L*a*b* " "color space of each training image.\n" -"NB: This is ignored if the 'no-augment-color' optionis enabled" +"NB: This is ignored if the 'no-augment-color' option is enabled" msgstr "" -#: plugins/train/trainer/trainer_config.py:120 +#: plugins/train/trainer/trainer_config.py:141 msgid "" "Percentage chance to perform Contrast Limited Adaptive Histogram " "Equalization on each training image.\n" "NB: This is ignored if the 'no-augment-color' option is enabled" msgstr "" -#: plugins/train/trainer/trainer_config.py:131 +#: plugins/train/trainer/trainer_config.py:152 msgid "" "The grid size dictates how much Contrast Limited Adaptive Histogram " "Equalization is performed on any training image selected for clahe. Contrast " -"will be applied randomly with a gridsize of 0 up to the maximum. This value " +"will be applied randomly with a grid-size of 0 up to the maximum. This value " "is a multiplier calculated from the training image size.\n" "NB: This is ignored if the 'no-augment-color' option is enabled" msgstr "" diff --git a/plugins/convert/mask/mask_blend.py b/plugins/convert/mask/mask_blend.py index 22889b7ce4..0a92cf3ae4 100644 --- a/plugins/convert/mask/mask_blend.py +++ b/plugins/convert/mask/mask_blend.py @@ -103,9 +103,27 @@ def _process_predicted_mask(self, mask: np.ndarray) -> np.ndarray: passes=cfg.passes()).blurred return mask + def _get_landmarks_mask(self, mask: LandmarksMask) -> npt.NDArray[np.float32]: + """Obtain a mask that is generated from landmark points + + Parameters + ---------- + landmarks_mask + The LandmarksMask object of the requested mask type (components or extended) at model + output size with the raw mask generated + + Returns + ------- + The landmarks mask with any blur/erosion applied + """ + blur_type = T.cast(T.Literal["gaussian", "normalized"] | None, cfg.type().lower()) + mask.blur_type = None if blur_type == "none" else blur_type + mask.blur_kernel = cfg.kernel_size() + mask.blur_passes = cfg.passes() + return mask.generate_mask().astype(np.float32) / 255. + def _get_stored_mask(self, detected_face: DetectedFace, - landmarks_mask: LandmarksMask | None, centering: T.Literal["legacy", "face", "head"], source_offset: np.ndarray, target_offset: np.ndarray) -> np.ndarray: @@ -115,8 +133,6 @@ def _get_stored_mask(self, ---------- detected_face : :class:`lib.align.DetectedFace` The DetectedFace object as returned from :class:`scripts.convert.Predictor`. - landmarks_mask : :class:`lib.align.aligned_mask.LandmarksMask` | None, optional - The landmarks mask object, if requested otherwise ``None`` centering: [`"legacy"`, `"face"`, `"head"`] The centering to obtain the mask for source_offset : :class:`numpy.ndarray` @@ -129,7 +145,7 @@ def _get_stored_mask(self, :class:`numpy.ndarray` The mask sized to Faceswap model output with any requested blurring applied. """ - mask = detected_face.mask[self._mask_type] if landmarks_mask is None else landmarks_mask + mask = detected_face.mask[self._mask_type] blur_type = T.cast(T.Literal["gaussian", "normalized"] | None, cfg.type().lower()) blur_type = None if blur_type == "none" else blur_type mask.set_blur_and_threshold(blur_kernel=cfg.kernel_size(), @@ -137,8 +153,6 @@ def _get_stored_mask(self, blur_passes=cfg.passes(), threshold=cfg.threshold()) mask.set_sub_crop(source_offset, target_offset, centering, self._coverage_ratio) - if isinstance(mask, LandmarksMask): - mask.generate_mask() face_mask = mask.mask mask_size = face_mask.shape[0] face_size = self._box.shape[0] @@ -185,9 +199,10 @@ def _get_mask(self, mask = np.ones_like(self._box) # Return a dummy mask if not using a mask elif self._mask_type == "predicted" and predicted_mask is not None: mask = self._process_predicted_mask(predicted_mask) + elif landmarks_mask is not None: + mask = self._get_landmarks_mask(landmarks_mask) else: mask = self._get_stored_mask(detected_face, - landmarks_mask, centering, source_offset, target_offset) diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 13ae98c57e..eab3064798 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Plugin loader for Faceswap extract, training and convert tasks """ +"""Plugin loader for Faceswap extract, training and convert tasks""" from __future__ import annotations import ast import logging @@ -14,27 +14,26 @@ from collections.abc import Callable from plugins.extract.base import ExtractPlugin from plugins.train.model._base import ModelBase - from plugins.train.trainer._base import TrainerBase + from plugins.train.trainer.base import TrainerBase logger = logging.getLogger(__name__) def get_extractors() -> dict[str, list[str]]: # noqa[C901] - """ Obtain a dictionary of all available extraction plugins by plugin type + """Obtain a dictionary of all available extraction plugins by plugin type Returns ------- - dict[str, list[:class:`plugins.extract._base.ExtractPlugin`]] - A list of all available plugins for each extraction plugin type + A list of all available plugins for each extraction plugin type """ root = os.path.join(PROJECT_ROOT, "plugins", "extract") - folders = sorted(os.path.join(root, fldr) for fldr in os.listdir(root) - if os.path.isdir(os.path.join(root, fldr)) - and not fldr.startswith("_")) + folders = sorted(os.path.join(root, f) for f in os.listdir(root) + if os.path.isdir(os.path.join(root, f)) + and not f.startswith("_")) retval: dict[str, list[str]] = {} - for fldr in folders: - files = sorted(os.path.join(fldr, fname) for fname in os.listdir(fldr) - if os.path.isfile(os.path.join(fldr, fname)) + for fld in folders: + files = sorted(os.path.join(fld, fname) for fname in os.listdir(fld) + if os.path.isfile(os.path.join(fld, fname)) and fname.endswith(".py") and not fname.startswith("_") and not fname.endswith("_defaults.py")) @@ -55,13 +54,13 @@ def get_extractors() -> dict[str, list[str]]: # noqa[C901] rel_path = os.path.splitext(fpath.replace(PROJECT_ROOT, "")[1:])[0] mods.append(".".join(full_path_split(rel_path) + [node.name])) if mods: - retval[os.path.basename(fldr)] = list(sorted(mods)) + retval[os.path.basename(fld)] = list(sorted(mods)) logger.debug("Extraction plugins: %s", retval) return retval class PluginLoader(): - """ Retrieve, or get information on, Faceswap plugins + """Retrieve, or get information on, Faceswap plugins Return a specific plugin, list available plugins, or get the default plugin for a task. @@ -78,19 +77,18 @@ class PluginLoader(): def get_extractor(cls, plugin_type: T.Literal["align", "detect", "identity", "mask"], name: str) -> ExtractPlugin: - """ Return requested extractor plugin + """Return requested extractor plugin Parameters ---------- - type : Literal["align", "detect", "identity", "mask"] + type The type of extractor plugin to obtain - name: str + name The name of the requested extractor plugin Returns ------- - type[:class:`plugins.extract.ExtractPlugin`] - An extraction plugin + An extraction plugin Raises ------ @@ -117,45 +115,43 @@ def get_extractor(cls, @staticmethod def get_model(name: str, disable_logging: bool = False) -> type[ModelBase]: - """ Return requested training model plugin + """Return requested training model plugin Parameters ---------- - name: str + name The name of the requested training model plugin - disable_logging: bool, optional + disable_logging Whether to disable the INFO log message that the plugin is being imported. Default: `False` Returns ------- - :class:`plugins.train.model` object: - A training model plugin + A training model plugin """ return PluginLoader._import("train.model", name, disable_logging) @staticmethod def get_trainer(name: str, disable_logging: bool = False) -> type[TrainerBase]: - """ Return requested training trainer plugin + """Return requested training trainer plugin Parameters ---------- - name: str + name The name of the requested training trainer plugin - disable_logging: bool, optional + disable_logging Whether to disable the INFO log message that the plugin is being imported. Default: `False` Returns ------- - :class:`plugins.train.trainer` object: - A training trainer plugin + A training trainer plugin """ return PluginLoader._import("train.trainer", name, disable_logging) @staticmethod def get_converter(category: str, name: str, disable_logging: bool = False) -> Callable: - """ Return requested converter plugin + """Return requested converter plugin Converters work slightly differently to other faceswap plugins. They are created to do a specific task (e.g. color adjustment, mask blending etc.), so multiple plugins will be @@ -163,34 +159,32 @@ def get_converter(category: str, name: str, disable_logging: bool = False) -> Ca Parameters ---------- - name: str + name The name of the requested converter plugin - disable_logging: bool, optional + disable_logging Whether to disable the INFO log message that the plugin is being imported. Default: `False` Returns ------- - :class:`plugins.convert` object: - A converter sub plugin + A converter sub plugin """ return PluginLoader._import(f"convert.{category}", name, disable_logging) @staticmethod def _import(attr: str, name: str, disable_logging: bool): - """ Import the plugin's module + """Import the plugin's module Parameters ---------- - name: str + name The name of the requested plugin - disable_logging: bool + disable_logging Whether to disable the INFO log message that the plugin is being imported. Returns ------- - :class:`plugin` object: - A plugin + A plugin """ name = name.replace("-", "_") ttl = attr.split(".")[-1].title() @@ -206,15 +200,15 @@ def get_available_extractors(cls, extractor_type: T.Literal["align", "detect", "identity", "mask"], add_none: bool = False, extend_plugin: bool = False) -> list[str]: - """ Return a list of available extractors of the given type + """Return a list of available extractors of the given type Parameters ---------- - extractor_type : Literal["align", "detect", "identity", "mask"] + extractor_type The type of extractor to return the plugins for - add_none: bool, optional + add_none Append "none" to the list of returned plugins. Default: False - extend_plugin: bool, optional + extend_plugin Some plugins have configuration options that mean that multiple 'pseudo-plugins' can be generated based on their settings. An example of this is the bisenet-fp mask which, whilst selected as 'bisenet-fp' can be stored as 'bisenet-fp-face' and @@ -224,8 +218,7 @@ def get_available_extractors(cls, Returns ------- - list: - A list of the available extractor plugin names for the given type + A list of the available extractor plugin names for the given type """ if extractor_type not in cls.extract_plugins: raise ValueError(f"{extractor_type} is not a valid plugin type. Select from " @@ -245,16 +238,15 @@ def get_available_extractors(cls, @staticmethod def get_available_models() -> list[str]: - """ Return a list of available training models + """Return a list of available training models Returns ------- - list: - A list of the available training model plugin names + A list of the available training model plugin names """ - modelpath = os.path.join(os.path.dirname(__file__), "train", "model") + model_path = os.path.join(os.path.dirname(__file__), "train", "model") models = sorted(item.name.replace(".py", "").replace("_", "-") - for item in os.scandir(modelpath) + for item in os.scandir(model_path) if not item.name.startswith("_") and not item.name.endswith("defaults.py") and item.name.endswith(".py")) @@ -262,39 +254,36 @@ def get_available_models() -> list[str]: @staticmethod def get_default_model() -> str: - """ Return the default training model plugin name + """Return the default training model plugin name Returns ------- - str: - The default faceswap training model - + The default faceswap training model """ models = PluginLoader.get_available_models() return 'original' if 'original' in models else models[0] @staticmethod def get_available_convert_plugins(convert_category: str, add_none: bool = True) -> list[str]: - """ Return a list of available converter plugins in the given category + """Return a list of available converter plugins in the given category Parameters ---------- - convert_category: {'color', 'mask', 'scaling', 'writer'} + convert_category The category of converter plugin to return the plugins for - add_none: bool, optional + add_none Append "none" to the list of returned plugins. Default: True Returns ------- - list - A list of the available converter plugin names in the given category + A list of the available converter plugin names in the given category """ - convertpath = os.path.join(os.path.dirname(__file__), - "convert", - convert_category) + convert_path = os.path.join(os.path.dirname(__file__), + "convert", + convert_category) converters = sorted(item.name.replace(".py", "").replace("_", "-") - for item in os.scandir(convertpath) + for item in os.scandir(convert_path) if not item.name.startswith("_") and not item.name.endswith("defaults.py") and item.name.endswith(".py")) diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 7dbaca472a..c961644c1d 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -93,12 +93,6 @@ def model(self) -> keras.Model: """The compiled model for this plugin.""" return self._model - @property - def command_line_arguments(self) -> argparse.Namespace: - """The command line arguments passed to the model plugin from either the train or convert - script""" - return self._args - @property def coverage_ratio(self) -> float: """The ratio of the training image to crop out and train on as defined in user diff --git a/plugins/train/train_config.py b/plugins/train/train_config.py index f2eccfa91b..d4835946f8 100644 --- a/plugins/train/train_config.py +++ b/plugins/train/train_config.py @@ -29,11 +29,12 @@ def set_defaults(self, helptext="") -> None: """ Set the default values for config """ super().set_defaults(helptext=_("Options that apply to all models") + _ADDITIONAL_INFO) self._defaults_from_plugin(os.path.dirname(__file__)) - - train_helptext, section, train_opts = trainer_config.get_defaults() - self.add_section(section, train_helptext) - for k, v in train_opts.items(): - self.add_item(section, k, v) + for section, opts in trainer_config.get_defaults().items(): + sect = f"trainer.{section.lower()}" + self.add_section(sect, opts.helptext) + for k, v in opts.__dict__.items(): + if isinstance(v, ConfigItem): + self.add_item(sect, k, v) centering = ConfigItem( diff --git a/plugins/train/trainer/_base.py b/plugins/train/trainer/_base.py deleted file mode 100644 index aebcce0810..0000000000 --- a/plugins/train/trainer/_base.py +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env python3 -""" Base Class for Faceswap Trainer plugins. All Trainer plugins should be inherited from -this class. - -At present there is only the :class:`~plugins.train.trainer.original` plugin, so that entirely -inherits from this class. If further plugins are developed, then common code should be kept here, -with "original" unique code split out to the original plugin. -""" -from __future__ import annotations -import abc -import logging -import typing as T - -import torch - -if T.TYPE_CHECKING: - from plugins.train.model._base import ModelBase - -logger = logging.getLogger(__name__) - - -class TrainerBase(abc.ABC): - """ A trainer plugin interface. It must implement the method "train_batch" which takes an input - of inputs to the model and target images for model output. It returns loss per side - - Parameters - ---------- - model : :class:`plugins.train.model.Base.ModelBase` - The model plugin - batch_size : int - The requested batch size for each iteration to be trained through the model. - """ - def __init__(self, model: ModelBase, batch_size: int) -> None: - self.model = model - """:class:`plugins.train.model.Base.ModelBase` : The model plugin to train the batch on""" - self.batch_size = batch_size - """int : The batch size for each iteration to be trained through the model.""" - - @abc.abstractmethod - def train_batch(self, inputs: torch.Tensor, targets: list[torch.Tensor]) -> torch.Tensor: - """Override to run a single forward and backwards pass through the model for a single - batch - - Parameters - ---------- - inputs : :class:`torch.Tensor` - The batch of input image tensors to the model in shape `(side, batch_size, - *dims)` with `side` 0 being input A and `side` 1 being input B - targets : list[:class:`torch.Tensor`] - The corresponding batch of target images for the model for each side's output(s). For - each model output an array should exist in the order of model outputs in the format `( - side, batch_size, *dims)` where `side` 0 is "A" and `side` 1 is "B" - - Returns - ------- - :class:`torch.Tensor` - The loss for each side of this batch in layout (A1, ..., An, B1, ..., Bn) - """ diff --git a/plugins/train/trainer/_display.py b/plugins/train/trainer/_display.py deleted file mode 100644 index 0e3236cb34..0000000000 --- a/plugins/train/trainer/_display.py +++ /dev/null @@ -1,626 +0,0 @@ -#!/usr/bin/env python3 -""" Handles the creation of display images for preview window and timelapses """ -from __future__ import annotations - -import logging -import time -import typing as T -import os - -import cv2 -import numpy as np -import torch - -from lib.image import hex_to_rgb -from lib.utils import get_folder, get_image_paths, get_module_objects -from plugins.train import train_config as cfg - -if T.TYPE_CHECKING: - from keras import KerasTensor - from lib.training import Feeder - from plugins.train.model._base import ModelBase - -logger = logging.getLogger(__name__) - - -class Samples(): - """ Compile samples for display for preview and time-lapse - - Parameters - ---------- - model: plugin from :mod:`plugins.train.model` - The selected model that will be running this trainer - coverage_ratio: float - Ratio of face to be cropped out of the training image. - mask_opacity: int - The opacity (as a percentage) to use for the mask overlay - mask_color: str - The hex RGB value to use the mask overlay - - Attributes - ---------- - images: dict - The :class:`numpy.ndarray` training images for generating previews on each side. The - dictionary should contain 2 keys ("a" and "b") with the values being the training images - for generating samples corresponding to each side. - """ - def __init__(self, - model: ModelBase, - coverage_ratio: float, - mask_opacity: int, - mask_color: str) -> None: - logger.debug("Initializing %s: model: '%s', coverage_ratio: %s, mask_opacity: %s, " - "mask_color: %s)", - self.__class__.__name__, model, coverage_ratio, mask_opacity, mask_color) - self._model = model - self._display_mask = cfg.Loss.learn_mask() or cfg.Loss.penalized_mask_loss() - self.images: dict[T.Literal["a", "b"], list[np.ndarray]] = {} - self._coverage_ratio = coverage_ratio - self._mask_opacity = mask_opacity / 100.0 - self._mask_color = np.array(hex_to_rgb(mask_color))[..., 2::-1] / 255. - logger.debug("Initialized %s", self.__class__.__name__) - - def toggle_mask_display(self) -> None: - """ Toggle the mask overlay on or off depending on user input. """ - if not (cfg.Loss.learn_mask() or cfg.Loss.penalized_mask_loss()): - return - display_mask = not self._display_mask - print("\x1b[2K", end="\r") # Clear last line - logger.info("Toggling mask display %s...", "on" if display_mask else "off") - self._display_mask = display_mask - - def show_sample(self) -> np.ndarray: - """ Compile a preview image. - - Returns - ------- - :class:`numpy.ndarry` - A compiled preview image ready for display or saving - """ - logger.debug("Showing sample") - feeds: dict[T.Literal["a", "b"], np.ndarray] = {} - for idx, side in enumerate(T.get_args(T.Literal["a", "b"])): - feed = self.images[side][0] - input_shape = self._model.model.input_shape[idx][1:] - if input_shape[0] / feed.shape[1] != 1.0: - feeds[side] = self._resize_sample(side, feed, input_shape[0]) - else: - feeds[side] = feed - - preds = self._get_predictions(feeds["a"], feeds["b"]) - return self._compile_preview(preds) - - @classmethod - def _resize_sample(cls, - side: T.Literal["a", "b"], - sample: np.ndarray, - target_size: int) -> np.ndarray: - """ Resize a given image to the target size. - - Parameters - ---------- - side: str - The side ("a" or "b") that the samples are being generated for - sample: :class:`numpy.ndarray` - The sample to be resized - target_size: int - The size that the sample should be resized to - - Returns - ------- - :class:`numpy.ndarray` - The sample resized to the target size - """ - scale = target_size / sample.shape[1] - if scale == 1.0: - # cv2 complains if we don't do this :/ - return np.ascontiguousarray(sample) - logger.debug("Resizing sample: (side: '%s', sample.shape: %s, target_size: %s, scale: %s)", - side, sample.shape, target_size, scale) - interpn = cv2.INTER_CUBIC if scale > 1.0 else cv2.INTER_AREA - retval = np.array([cv2.resize(img, (target_size, target_size), interpolation=interpn) - for img in sample]) - logger.debug("Resized sample: (side: '%s' shape: %s)", side, retval.shape) - return retval - - def _filter_multiscale_output(self, standard: list[KerasTensor], swapped: list[KerasTensor] - ) -> tuple[list[KerasTensor], list[KerasTensor]]: - """ Only return the largest predictions if the model has multi-scaled output - - Parameters - ---------- - standard: list[:class:`keras.KerasTensor`] - The standard output from the model - swapped: list[:class:`keras.KerasTensor`] - The swapped output from the model - - Returns - ------- - standard: list[:class:`keras.KerasTensor`] - The standard output from the model, filtered to just the largest output - swapped: list[:class:`keras.KerasTensor`] - The swapped output from the model, filtered to just the largest output - """ - sizes = T.cast(set[int], set(p.shape[1] for p in standard)) - if len(sizes) == 1: - return standard, swapped - logger.debug("Received outputs. standard: %s, swapped: %s", - [s.shape for s in standard], [s.shape for s in swapped]) - logger.debug("Stripping multi-scale outputs for sizes %s", sizes) - standard = [s for s in standard if s.shape[1] == max(sizes)] - swapped = [s for s in swapped if s.shape[1] == max(sizes)] - logger.debug("Stripped outputs. standard: %s, swapped: %s", - [s.shape for s in standard], [s.shape for s in swapped]) - return standard, swapped - - def _collate_output(self, standard: list[torch.Tensor], swapped: list[torch.Tensor] - ) -> tuple[list[np.ndarray], list[np.ndarray]]: - """ Merge the mask onto the preview image's 4th channel if learn mask is selected. - Return as numpy array - - Parameters - ---------- - standard: list[:class:`torch.Tensor`] - The standard output from the model - swapped: list[:class:`torch.Tensor`] - The swapped output from the model - - Returns - ------- - standard: list[:class:`numpy.ndarray`] - The standard output from the model, with mask merged - swapped: list[:class:`numpy.ndarray`] - The swapped output from the model, with mask merged - """ - logger.debug("Received tensors. standard: %s, swapped: %s", - [s.shape for s in standard], [s.shape for s in swapped]) - - # Pull down outputs - nstandard = [p.cpu().detach().numpy() for p in standard] - nswapped = [p.cpu().detach().numpy() for p in swapped] - - if cfg.Loss.learn_mask(): # Add mask to 4th channel of final output - nstandard = [np.concatenate(nstandard[idx * 2: (idx * 2) + 2], axis=-1) - for idx in range(2)] - nswapped = [np.concatenate(nswapped[idx * 2: (idx * 2) + 2], axis=-1) - for idx in range(2)] - logger.debug("Collated output. standard: %s, swapped: %s", - [(s.shape, s.dtype) for s in nstandard], - [(s.shape, s.dtype) for s in nswapped]) - return nstandard, nswapped - - def _get_predictions(self, feed_a: np.ndarray, feed_b: np.ndarray - ) -> dict[T.Literal["a_a", "a_b", "b_b", "b_a"], np.ndarray]: - """ Feed the samples to the model and return predictions - - Parameters - ---------- - feed_a: :class:`numpy.ndarray` - Feed images for the "a" side - feed_a: :class:`numpy.ndarray` - Feed images for the "b" side - - Returns - ------- - list: - List of :class:`numpy.ndarray` of predictions received from the model - """ - logger.debug("Getting Predictions") - preds: dict[T.Literal["a_a", "a_b", "b_b", "b_a"], np.ndarray] = {} - - with torch.inference_mode(): - standard = self._model.model([feed_a, feed_b]) - swapped = self._model.model([feed_b, feed_a]) - - standard, swapped = self._filter_multiscale_output(standard, swapped) - standard, swapped = self._collate_output(standard, swapped) - - preds["a_a"] = standard[0] - preds["b_b"] = standard[1] - preds["a_b"] = swapped[0] - preds["b_a"] = swapped[1] - - logger.debug("Returning predictions: %s", {key: val.shape for key, val in preds.items()}) - return preds - - def _compile_preview(self, predictions: dict[T.Literal["a_a", "a_b", "b_b", "b_a"], np.ndarray] - ) -> np.ndarray: - """ Compile predictions and images into the final preview image. - - Parameters - ---------- - predictions: dict[Literal["a_a", "a_b", "b_b", "b_a"], np.ndarray - The predictions from the model - - Returns - ------- - :class:`numpy.ndarry` - A compiled preview image ready for display or saving - """ - figures: dict[T.Literal["a", "b"], np.ndarray] = {} - headers: dict[T.Literal["a", "b"], np.ndarray] = {} - - for side, samples in self.images.items(): - other_side = "a" if side == "b" else "b" - preds = [predictions[T.cast(T.Literal["a_a", "a_b", "b_b", "b_a"], - f"{side}_{side}")], - predictions[T.cast(T.Literal["a_a", "a_b", "b_b", "b_a"], - f"{other_side}_{side}")]] - display = self._to_full_frame(side, samples, preds) - headers[side] = self._get_headers(side, display[0].shape[1]) - figures[side] = np.stack([display[0], display[1], display[2], ], axis=1) - if self.images[side][1].shape[0] % 2 == 1: - figures[side] = np.concatenate([figures[side], - np.expand_dims(figures[side][0], 0)]) - - width = 4 - if width // 2 != 1: - headers = self._duplicate_headers(headers, width // 2) - - header = np.concatenate([headers["a"], headers["b"]], axis=1) - figure = np.concatenate([figures["a"], figures["b"]], axis=0) - height = int(figure.shape[0] / width) - figure = figure.reshape((width, height) + figure.shape[1:]) - figure = _stack_images(figure) - figure = np.concatenate((header, figure), axis=0) - - logger.debug("Compiled sample") - return np.clip(figure * 255, 0, 255).astype('uint8') - - def _to_full_frame(self, - side: T.Literal["a", "b"], - samples: list[np.ndarray], - predictions: list[np.ndarray]) -> list[np.ndarray]: - """ Patch targets and prediction images into images of model output size. - - Parameters - ---------- - side: {"a" or "b"} - The side that these samples are for - samples: list - List of :class:`numpy.ndarray` of feed images and sample images - predictions: list - List of :class: `numpy.ndarray` of predictions from the model - - Returns - ------- - list - The images resized and collated for display in the preview frame - """ - logger.debug("side: '%s', number of sample arrays: %s, prediction.shapes: %s)", - side, len(samples), [pred.shape for pred in predictions]) - faces, full = samples[:2] - - if self._model.color_order.lower() == "rgb": # Switch color order for RGB model display - full = full[..., ::-1] - faces = faces[..., ::-1] - predictions = [pred[..., 2::-1] for pred in predictions] - - full = self._process_full(side, full, predictions[0].shape[1], (0., 0., 1.0)) - images = [faces] + predictions - - if self._display_mask: - images = self._compile_masked(images, samples[-1]) - elif cfg.Loss.learn_mask(): - # Remove masks when learn mask is selected but mask toggle is off - images = [batch[..., :3] for batch in images] - - images = [self._overlay_foreground(full.copy(), image) for image in images] - - return images - - def _process_full(self, - side: T.Literal["a", "b"], - images: np.ndarray, - prediction_size: int, - color: tuple[float, float, float]) -> np.ndarray: - """ Add a frame overlay to preview images indicating the region of interest. - - This applies the red border that appears in the preview images. - - Parameters - ---------- - side: {"a" or "b"} - The side that these samples are for - images: :class:`numpy.ndarray` - The input training images to to process - prediction_size: int - The size of the predicted output from the model - color: tuple - The (Blue, Green, Red) color to use for the frame - - Returns - ------- - :class:`numpy,ndarray` - The input training images, sized for output and annotated for coverage - """ - logger.debug("full_size: %s, prediction_size: %s, color: %s", - images.shape[1], prediction_size, color) - - display_size = int((prediction_size / self._coverage_ratio // 2) * 2) - images = self._resize_sample(side, images, display_size) # Resize targets to display size - padding = (display_size - prediction_size) // 2 - if padding == 0: - logger.debug("Resized background. Shape: %s", images.shape) - return images - - length = display_size // 4 - t_l, b_r = (padding - 1, display_size - padding) - for img in images: - cv2.rectangle(img, (t_l, t_l), (t_l + length, t_l + length), color, 1) - cv2.rectangle(img, (b_r, t_l), (b_r - length, t_l + length), color, 1) - cv2.rectangle(img, (b_r, b_r), (b_r - length, b_r - length), color, 1) - cv2.rectangle(img, (t_l, b_r), (t_l + length, b_r - length), color, 1) - logger.debug("Overlayed background. Shape: %s", images.shape) - return images - - def _compile_masked(self, faces: list[np.ndarray], masks: np.ndarray) -> list[np.ndarray]: - """ Add the mask to the faces for masked preview. - - Places an opaque red layer over areas of the face that are masked out. - - Parameters - ---------- - faces: list - The :class:`numpy.ndarray` sample faces and predictions that are to have the mask - applied - masks: :class:`numpy.ndarray` - The masks that are to be applied to the faces - - Returns - ------- - list - List of :class:`numpy.ndarray` faces with the opaque mask layer applied - """ - orig_masks = 1. - masks - masks3: list[np.ndarray] | np.ndarray = [] - - if faces[-1].shape[-1] == 4: # Mask contained in alpha channel of predictions - pred_masks = [1. - face[..., -1][..., None] for face in faces[-2:]] - faces[-2:] = [face[..., :-1] for face in faces[-2:]] - masks3 = [orig_masks, *pred_masks] - else: - masks3 = np.repeat(np.expand_dims(orig_masks, axis=0), 3, axis=0) - - retval: list[np.ndarray] = [] - overlays3 = np.ones_like(faces) * self._mask_color - for previews, overlays, compiled_masks in zip(faces, overlays3, masks3): - compiled_masks *= self._mask_opacity - overlays *= compiled_masks - previews *= (1. - compiled_masks) - retval.append(previews + overlays) - logger.debug("masked shapes: %s", [faces.shape for faces in retval]) - return retval - - @classmethod - def _overlay_foreground(cls, backgrounds: np.ndarray, foregrounds: np.ndarray) -> np.ndarray: - """ Overlay the preview images into the center of the background images - - Parameters - ---------- - backgrounds: :class:`numpy.ndarray` - Background images for placing the preview images onto - backgrounds: :class:`numpy.ndarray` - Preview images for placing onto the background images - - Returns - ------- - :class:`numpy.ndarray` - The preview images compiled into the full frame size for each preview - """ - offset = (backgrounds.shape[1] - foregrounds.shape[1]) // 2 - for foreground, background in zip(foregrounds, backgrounds): - background[offset:offset + foreground.shape[0], - offset:offset + foreground.shape[1], :3] = foreground - logger.debug("Overlayed foreground. Shape: %s", backgrounds.shape) - return backgrounds - - @classmethod - def _get_headers(cls, side: T.Literal["a", "b"], width: int) -> np.ndarray: - """ Set header row for the final preview frame - - Parameters - ---------- - side: {"a" or "b"} - The side that the headers should be generated for - width: int - The width of each column in the preview frame - - Returns - ------- - :class:`numpy.ndarray` - The column headings for the given side - """ - logger.debug("side: '%s', width: %s", - side, width) - titles = ("Original", "Swap") if side == "a" else ("Swap", "Original") - height = int(width / 4.5) - total_width = width * 3 - logger.debug("height: %s, total_width: %s", height, total_width) - font = cv2.FONT_HERSHEY_SIMPLEX - texts = [f"{titles[0]} ({side.upper()})", - f"{titles[0]} > {titles[0]}", - f"{titles[0]} > {titles[1]}"] - scaling = (width / 144) * 0.45 - text_sizes = [cv2.getTextSize(texts[idx], font, scaling, 1)[0] - for idx in range(len(texts))] - text_y = int((height + text_sizes[0][1]) / 2) - text_x = [int((width - text_sizes[idx][0]) / 2) + width * idx - for idx in range(len(texts))] - logger.debug("texts: %s, text_sizes: %s, text_x: %s, text_y: %s", - texts, text_sizes, text_x, text_y) - header_box = np.ones((height, total_width, 3), np.float32) - for idx, text in enumerate(texts): - cv2.putText(header_box, - text, - (text_x[idx], text_y), - font, - scaling, - (0, 0, 0), - 1, - lineType=cv2.LINE_AA) - logger.debug("header_box.shape: %s", header_box.shape) - return header_box - - @classmethod - def _duplicate_headers(cls, - headers: dict[T.Literal["a", "b"], np.ndarray], - columns: int) -> dict[T.Literal["a", "b"], np.ndarray]: - """ Duplicate headers for the number of columns displayed for each side. - - Parameters - ---------- - headers: dict - The headers to be duplicated for each side - columns: int - The number of columns that the header needs to be duplicated for - - Returns - ------- - :class:dict - The original headers duplicated by the number of columns for each side - """ - for side, header in headers.items(): - duped = tuple(header for _ in range(columns)) - headers[side] = np.concatenate(duped, axis=1) - logger.debug("side: %s header.shape: %s", side, header.shape) - return headers - - -class Timelapse(): - """ Create a time-lapse preview image. - - Parameters - ---------- - model: plugin from :mod:`plugins.train.model` - The selected model that will be running this trainer - coverage_ratio: float - Ratio of face to be cropped out of the training image. - image_count: int - The number of preview images to be displayed in the time-lapse - mask_opacity: int - The opacity (as a percentage) to use for the mask overlay - mask_color: str - The hex RGB value to use the mask overlay - feeder: :class:`~lib.training.generator.Feeder` - The feeder for generating the time-lapse images. - image_paths: dict - The full paths to the training images for each side of the model - """ - def __init__(self, - model: ModelBase, - coverage_ratio: float, - image_count: int, - mask_opacity: int, - mask_color: str, - feeder: Feeder, - image_paths: dict[T.Literal["a", "b"], list[str]]) -> None: - logger.debug("Initializing %s: model: %s, coverage_ratio: %s, image_count: %s, " - "mask_opacity: %s, mask_color: %s, feeder: %s, image_paths: %s)", - self.__class__.__name__, model, coverage_ratio, image_count, mask_opacity, - mask_color, feeder, len(image_paths)) - self._num_images = image_count - self._samples = Samples(model, coverage_ratio, mask_opacity, mask_color) - self._model = model - self._feeder = feeder - self._image_paths = image_paths - self._output_file = "" - logger.debug("Initialized %s", self.__class__.__name__) - - def _setup(self, input_a: str, input_b: str, output: str) -> None: - """ Setup the time-lapse folder locations and the time-lapse feed. - - Parameters - ---------- - input_a: str - The full path to the time-lapse input folder containing faces for the "a" side - input_b: str - The full path to the time-lapse input folder containing faces for the "b" side - output: str, optional - The full path to the time-lapse output folder. If ``None`` is provided this will - default to the model folder - """ - logger.debug("Setting up time-lapse") - if not output: - output = get_folder(os.path.join(str(self._model.io.model_dir), - f"{self._model.name}_timelapse")) - self._output_file = output - logger.debug("Time-lapse output set to '%s'", self._output_file) - - # Rewrite paths to pull from the training images so mask and face data can be accessed - images: dict[T.Literal["a", "b"], list[str]] = {} - for side, input_ in zip(T.get_args(T.Literal["a", "b"]), (input_a, input_b)): - training_path = os.path.dirname(self._image_paths[side][0]) - images[side] = [os.path.join(training_path, os.path.basename(pth)) - for pth in get_image_paths(input_)] - - batchsize = min(len(images["a"]), - len(images["b"]), - self._num_images) - self._feeder.set_timelapse_feed(images, batchsize) - logger.debug("Set up time-lapse") - - def output_timelapse(self, timelapse_kwargs: dict[T.Literal["input_a", - "input_b", - "output"], str]) -> None: - """ Generate the time-lapse samples and output the created time-lapse to the specified - output folder. - - Parameters - ---------- - timelapse_kwargs: dict: - The keyword arguments for setting up the time-lapse. All values should be full paths - the keys being `input_a`, `input_b`, `output` - """ - logger.debug("Ouputting time-lapse") - if not self._output_file: - self._setup(**T.cast(dict[str, str], timelapse_kwargs)) - - logger.debug("Getting time-lapse samples") - self._samples.images = self._feeder.generate_preview(is_timelapse=True) - logger.debug("Got time-lapse samples: %s", - {side: len(images) for side, images in self._samples.images.items()}) - - image = self._samples.show_sample() - if image is None: - return - filename = os.path.join(self._output_file, str(int(time.time())) + ".jpg") - - cv2.imwrite(filename, image) - logger.debug("Created time-lapse: '%s'", filename) - - -def _stack_images(images: np.ndarray) -> np.ndarray: - """ Stack images evenly for preview. - - Parameters - ---------- - images: :class:`numpy.ndarray` - The preview images to be stacked - - Returns - ------- - :class:`numpy.ndarray` - The stacked preview images - """ - logger.debug("Stack images") - - def get_transpose_axes(num): - if num % 2 == 0: - logger.debug("Even number of images to stack") - y_axes = list(range(1, num - 1, 2)) - x_axes = list(range(0, num - 1, 2)) - else: - logger.debug("Odd number of images to stack") - y_axes = list(range(0, num - 1, 2)) - x_axes = list(range(1, num - 1, 2)) - return y_axes, x_axes, [num - 1] - - images_shape = np.array(images.shape) - new_axes = get_transpose_axes(len(images_shape)) - new_shape = [np.prod(images_shape[x]) for x in new_axes] - logger.debug("Stacked images") - return np.transpose(images, axes=np.concatenate(new_axes)).reshape(new_shape) - - -__all__ = get_module_objects(__name__) diff --git a/plugins/train/trainer/base.py b/plugins/train/trainer/base.py new file mode 100644 index 0000000000..0a06279037 --- /dev/null +++ b/plugins/train/trainer/base.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Base Class for Faceswap Trainer plugins. All Trainer plugins should be inherited from +this class. + +At present there is only the :class:`~plugins.train.trainer.original` plugin, so that entirely +inherits from this class. If further plugins are developed, then common code should be kept here, +with "original" unique code split out to the original plugin. +""" +from __future__ import annotations +import abc +import logging +import typing as T +from dataclasses import dataclass + +import torch + +if T.TYPE_CHECKING: + from plugins.train.model._base import ModelBase + +logger = logging.getLogger(__name__) + + +@dataclass +class TrainConfig: + """Configuration for training a model + + Parameters + ---------- + image_folders + List of folders to be used as inputs to the model. Folders are provided in processing order + (eg: [A, B, ...]) + batch_size + The batch size to load data from each of the loaders + augment_color + ``True`` to perform color augmentation otherwise ``False`` + flip + ``True`` to perform image flipping otherwise ``False`` + warp + ``False`` to disable warping ``True`` to enable warping + cache_landmarks + ``True`` to cache landmarks from the other side for Warp to landmarks + use_lr_finder + ``True`` to use the learning rate finder. Default: ``False`` + snapshot interval + The number of iterations between snapshots. Default -1 (Disabled) + """ + folders: list[str] + """List of folders to be used as inputs to the model. Folders are provided in processing order + (eg: [A, B, ...])""" + batch_size: int + """The batch size to load data from each of the loaders""" + augment_color: bool + """``True`` to perform color augmentation otherwise ``False``""" + flip: bool + """``False`` to disable warping ``True`` to enable warping""" + warp: bool + """``False`` to disable warping ``True`` to enable warping""" + cache_landmarks: bool + """``True`` to cache landmarks from the other side for Warp to landmarks""" + lr_finder: bool = False + """``True`` to use the learning rate finder""" + snapshot_interval: int = -1 + """The number of iterations between snapshots""" + + +class TrainerBase(abc.ABC): + """A trainer plugin interface. It must implement the method "train_batch" which takes an input + of inputs to the model and target images for model output. It returns loss per side + + Parameters + ---------- + model + The model plugin + config + The Training Configuration options + """ + def __init__(self, model: ModelBase, config: TrainConfig) -> None: + self.model = model + """The model plugin to train the batch on""" + self.batch_size = config.batch_size + """The batch size for each iteration to be trained through the model.""" + self.config = config + """Training configuration options""" + self.sampler = self.get_sampler() + """The data sampler that the data loader should use""" + + def __repr__(self) -> str: + """Pretty print for logging""" + params = f"model={repr(self.model)}, config={repr(self.config)}" + return f"{self.__class__.__name__}({params})" + + @abc.abstractmethod + def get_sampler(self) -> type[torch.utils.data.Sampler]: + """Override to set the sampler that the Torch DataLoader should use + + Returns + ------- + The sampler that the torch DataLoader should use + """ + + @abc.abstractmethod + def train_batch(self, inputs: torch.Tensor, targets: list[torch.Tensor]) -> torch.Tensor: + """Override to run a single forward and backwards pass through the model for a single + batch + + Parameters + ---------- + inputs + The batch of input image tensors to the model in shape `(side, batch_size, + *dims)` with `side` 0 being input A and `side` 1 being input B + targets + The corresponding batch of target images for the model for each side's output(s). For + each model output an array should exist in the order of model outputs in the format `( + side, batch_size, *dims)` where `side` 0 is "A" and `side` 1 is "B" + + Returns + ------- + The loss for each side of this batch in layout (A1, ..., An, B1, ..., Bn) + """ diff --git a/plugins/train/trainer/distributed.py b/plugins/train/trainer/distributed.py index ee1a877b42..b3f0aada3b 100644 --- a/plugins/train/trainer/distributed.py +++ b/plugins/train/trainer/distributed.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Original Trainer """ +"""Original Trainer """ from __future__ import annotations import logging import typing as T @@ -13,6 +13,7 @@ from .original import Trainer as OriginalTrainer if T.TYPE_CHECKING: + from .base import TrainConfig from plugins.train.model._base import ModelBase import keras @@ -20,12 +21,12 @@ class WrappedModel(torch.nn.Module): - """ A torch module that wraps a dual input Faceswap model with a single input version that is + """A torch module that wraps a dual input Faceswap model with a single input version that is compatible with DataParallel training Parameters ---------- - model : :class:`keras.Model` + model The original faceswap model that is to be wrapped """ def __init__(self, model: keras.Model): @@ -40,33 +41,32 @@ def forward(self, targets_a: torch.Tensor, targets_b: torch.Tensor, *targets: torch.Tensor) -> torch.Tensor: - """ Run the forward pass per GPU + """Run the forward pass per GPU Parameters ---------- - input_a : :class:`torch.Tensor` + input_a The A batch of input images for 1 GPU - input_b : :class:`torch.Tensor` + input_b The B batch of input images for 1 GPU - targets_a : :class:`torch.Tensor` | list[torch.Tensor] + targets_a The A batch of target images for 1 GPU. If this is a multi-output model then this list will be the target images per output for all items in the current batch, regardless of GPU. If we have 1 output, this will be a Tensor for this GPUs current batch output - targets_b : :class:`torch.Tensor` | list[torch.Tensor] + targets_b The B batch of target images for 1 GPU. If this is a multi-output model then this list will be the target images per output for all items in the current batch, regardless of GPU. If we have 1 output, this will be a Tensor for this GPUs current batch output - targets : :class:`torch.Tensor` | list[torch.Tensor], optional + targets Used for multi-output models. Any additional outputs can be added here. They should be added in A-B order Returns ------- - :class:`torch.Tensor` - The loss outputs for each side of the model for 1 GPU + The loss outputs for each side of the model for 1 GPU """ - preds = self._keras_model((input_a, input_b), training=True) + predictions = self._keras_model((input_a, input_b), training=True) self._keras_model.zero_grad() if targets: # Go from [A1, B1, A2, B2, A3, B3] to [A1, A2, A3, B1, B2, B3] @@ -79,43 +79,41 @@ def forward(self, losses = torch.stack([loss_fn(y_true, y_pred) for loss_fn, y_true, y_pred in zip(self._keras_model.loss, loss_targets, - preds)]) + predictions)]) logger.trace("Losses: %s", losses) # type:ignore[attr-defined] return losses class Trainer(OriginalTrainer): - """ Distributed training with torch.nn.DataParallel + """Distributed training with torch.nn.DataParallel Parameters ---------- - model : plugin from :mod:`plugins.train.model` + model The model that will be running this trainer - batch_size : int - The requested batch size for iteration to be trained through the model. + config + The Training Configuration options """ - def __init__(self, model: ModelBase, batch_size: int) -> None: + def __init__(self, model: ModelBase, config: TrainConfig) -> None: self._gpu_count = torch.cuda.device_count() - batch_size = self._validate_batch_size(batch_size) self._is_multi_out: bool | None = None - - super().__init__(model, batch_size) + super().__init__(model, config) + self.batch_size = self._validate_batch_size(config.batch_size) self._distributed_model = self._set_distributed() def _validate_batch_size(self, batch_size: int) -> int: - """ Validate that the batch size is suitable for the number of GPUs and update accordingly. + """Validate that the batch size is suitable for the number of GPUs and update accordingly. Parameters ---------- - batch_size : int + batch_size The requested training batch size Returns ------- - int - A valid batch size for the GPU configuration + A valid batch size for the GPU configuration """ if batch_size < self._gpu_count: logger.warning("Batch size (%s) is less than the number of GPUs (%s). Updating batch " @@ -133,12 +131,12 @@ def _validate_batch_size(self, batch_size: int) -> int: def _handle_torch_gpu_mismatch_warning( self, warn_messages: list[warnings.WarningMessage] | None) -> None: - """ Handle the warning generated by Torch when significantly mismatched GPUs are used and + """Handle the warning generated by Torch when significantly mismatched GPUs are used and remove potentially confusing information not relevant for Faceswap Parameters ---------- - warn_messages : list[:class:`warnings.WarningMessage] + warn_messages Any qualifying warning messages that may have been generated when wrapping the model """ if warn_messages is None or not warn_messages: @@ -161,8 +159,7 @@ def _set_distributed(self) -> torch.nn.DataParallel: Returns ------- - :class:`torch.nn.Parallel` - A wrapped version of the faceswap model compatible with distributed training + A wrapped version of the faceswap model compatible with distributed training """ name = self.model.model.name logger.debug("Setting distributed training for '%s'", name) @@ -182,22 +179,21 @@ def _set_distributed(self) -> torch.nn.DataParallel: def _forward(self, inputs: torch.Tensor, targets: list[torch.Tensor]) -> torch.Tensor: - """ Perform the forward pass on the model + """Perform the forward pass on the model Parameters ---------- - inputs : :class:`torch.Tensor` + inputs The batch of input image tensors to the model in shape `(side, batch_size, *dims)` with `side` 0 being input A and `side` 1 being input B - targets : list[:class:`torch.Tensor`] + targets The corresponding batch of target images for the model for each side's output(s). For each model output an array should exist in the order of model outputs in the format `( side, batch_size, *dims)` with `side` 0 being input A and `side` 1 being input B Returns ------- - :class:`torch.Tensor` - The loss for each side of this batch in layout (A1, ..., An, B1, ..., Bn) + The loss for each side of this batch in layout (A1, ..., An, B1, ..., Bn) """ if self._is_multi_out is None: self._is_multi_out = len(targets) > 1 diff --git a/plugins/train/trainer/original.py b/plugins/train/trainer/original.py index 1060a1d87c..5f2acbe491 100644 --- a/plugins/train/trainer/original.py +++ b/plugins/train/trainer/original.py @@ -10,7 +10,7 @@ import torch from lib.utils import get_module_objects -from ._base import TrainerBase +from .base import TrainerBase logger = logging.getLogger(__name__) @@ -19,6 +19,15 @@ class Trainer(TrainerBase): """Original trainer""" + def get_sampler(self) -> type[torch.utils.data.RandomSampler]: + """Obtain a standard random sampler + + Returns + ------- + The Random sampler + """ + return torch.utils.data.RandomSampler + def _forward(self, inputs: torch.Tensor, targets: list[torch.Tensor]) -> torch.Tensor: @@ -39,13 +48,13 @@ def _forward(self, The loss for each side of this batch in layout (A1, ..., An, B1, ..., Bn) """ feed_targets = [[t[i] for t in targets] for i in range(2)] - preds = self.model.model((inputs[0], inputs[1]), training=True) + predictions = self.model.model((inputs[0], inputs[1]), training=True) self.model.model.zero_grad() losses = torch.stack([loss_fn(y_true, y_pred) for loss_fn, y_true, y_pred in zip(self.model.model.loss, flatten(feed_targets), - preds)]) + predictions)]) logger.trace("Losses: %s", losses) # type:ignore[attr-defined] return losses diff --git a/plugins/train/trainer/trainer_config.py b/plugins/train/trainer/trainer_config.py index 08d49656f4..bd4458d0c0 100644 --- a/plugins/train/trainer/trainer_config.py +++ b/plugins/train/trainer/trainer_config.py @@ -1,9 +1,15 @@ #!/usr/bin/env python3 """ Default configurations for trainers """ +from __future__ import annotations + +import inspect import gettext import logging +import typing as T + +from dataclasses import dataclass -from lib.config import ConfigItem +from lib.config import ConfigItem, GlobalSection from lib.utils import get_module_objects logger = logging.getLogger(__name__) @@ -15,128 +21,155 @@ _ = _LANG.gettext -def get_defaults() -> tuple[str, str, dict[str, ConfigItem]]: +@dataclass +class Loader(GlobalSection): + """ trainer.loader section""" + + helptext = _( + "Data Loader Options.\n" + "Controls how training data is loaded from disk") + + num_processes = ConfigItem( + datatype=int, + default=4, + group=_("data loading"), + info=_("Number of processors to use for loading and processing data from disk. 0 to just " + "use the Main process."), + rounding=1, + min_max=(0, 32)) + + pre_fetch = ConfigItem( + datatype=int, + default=2, + group=_("data loading"), + info=_("The Number of items that each loader should pre-fetch and hold in RAM. Default is " + "usually fine unless you have disk contention with variable read speeds."), + rounding=1, + min_max=(1, 10)) + + +@dataclass +class Augmentation(GlobalSection): + """ trainer.augmentation section""" + + helptext = _( + "Data Augmentation Options.\n" + "WARNING: The defaults for augmentation will be fine for 99.9% of use cases. " + "Only change them if you absolutely know what you are doing!") + + preview_images = ConfigItem( + datatype=int, + default=14, + group=_("evaluation"), + info=_("Number of sample faces to display for each side in the preview when training."), + rounding=2, + min_max=(2, 16)) + + mask_opacity = ConfigItem( + datatype=int, + default=30, + group=_("evaluation"), + info=_("The opacity of the mask overlay in the training preview. Lower values are more " + "transparent."), + rounding=2, + min_max=(0, 100)) + + mask_color = ConfigItem( + datatype=str, + default="#ff0000", + choices="colorchooser", + group=_("evaluation"), + info=_("The RGB hex color to use for the mask overlay in the training preview.")) + + zoom_amount = ConfigItem( + datatype=int, + default=5, + group=_("image augmentation"), + info=_("Percentage amount to randomly zoom each training image in and out."), + rounding=1, + min_max=(0, 25)) + + rotation_range = ConfigItem( + datatype=int, + default=10, + group=_("image augmentation"), + info=_("Percentage amount to randomly rotate each training image."), + rounding=1, + min_max=(0, 25)) + + shift_range = ConfigItem( + datatype=int, + default=5, + group=_("image augmentation"), + info=_("Percentage amount to randomly shift each training image horizontally and " + "vertically."), + rounding=1, + min_max=(0, 25)) + + flip_chance = ConfigItem( + datatype=int, + default=50, + group=_("image augmentation"), + info=_("Percentage chance to randomly flip each training image horizontally.\n" + "NB: This is ignored if the 'no-flip' option is enabled"), + rounding=1, + min_max=(0, 75)) + + color_lightness = ConfigItem( + datatype=int, + default=30, + group=_("color augmentation"), + info=_("Percentage amount to randomly alter the lightness of each training image.\n" + "NB: This is ignored if the 'no-augment-color' option is enabled"), + rounding=1, + min_max=(0, 75)) + + color_ab = ConfigItem( + datatype=int, + default=8, + group=_("color augmentation"), + info=_("Percentage amount to randomly alter the 'a' and 'b' colors of the L*a*b* color " + "space of each training image.\nNB: This is ignored if the 'no-augment-color' " + "option is enabled"), + rounding=1, + min_max=(0, 50)) + + color_clahe_chance = ConfigItem( + datatype=int, + default=50, + group=_("color augmentation"), + info=_("Percentage chance to perform Contrast Limited Adaptive Histogram Equalization on " + "each training image.\nNB: This is ignored if the 'no-augment-color' option is " + "enabled"), + rounding=1, + min_max=(0, 75), + fixed=False) + + color_clahe_max_size = ConfigItem( + datatype=int, + default=4, + group=_("color augmentation"), + info=_("The grid size dictates how much Contrast Limited Adaptive Histogram Equalization " + "is performed on any training image selected for clahe. Contrast will be applied " + "randomly with a grid-size of 0 up to the maximum. This value is a multiplier " + "calculated from the training image size.\nNB: This is ignored if the " + "'no-augment-color' option is enabled"), + rounding=1, + min_max=(1, 8)) + + +def get_defaults() -> dict[str, GlobalSection]: """ Obtain the default values for adding to the config.ini file Returns ------- - helptext : str - The help text for the training config section - section : str - The section name for the config items - defaults : dict[str, :class:`lib.config.objects.ConfigItem`] + defaults The option names and config items """ - section = "trainer.augmentation" - helptext = _( - "Data Augmentation Options.\n" - "WARNING: The defaults for augmentation will be fine for 99.9% of use cases. " - "Only change them if you absolutely know what you are doing!") - defaults = {k: v for k, v in globals().items() - if isinstance(v, ConfigItem)} - logger.debug("Training config. Helptext: %s, options: %s", helptext, defaults) - return helptext, section, defaults - - -preview_images = ConfigItem( - datatype=int, - default=14, - group=_("evaluation"), - info=_("Number of sample faces to display for each side in the preview when training."), - rounding=2, - min_max=(2, 16)) - -mask_opacity = ConfigItem( - datatype=int, - default=30, - group=_("evaluation"), - info=_("The opacity of the mask overlay in the training preview. Lower values are more " - "transparent."), - rounding=2, - min_max=(0, 100)) - -mask_color = ConfigItem( - datatype=str, - default="#ff0000", - choices="colorchooser", - group=_("evaluation"), - info=_("The RGB hex color to use for the mask overlay in the training preview.")) - -zoom_amount = ConfigItem( - datatype=int, - default=5, - group=_("image augmentation"), - info=_("Percentage amount to randomly zoom each training image in and out."), - rounding=1, - min_max=(0, 25)) - -rotation_range = ConfigItem( - datatype=int, - default=10, - group=_("image augmentation"), - info=_("Percentage amount to randomly rotate each training image."), - rounding=1, - min_max=(0, 25)) - -shift_range = ConfigItem( - datatype=int, - default=5, - group=_("image augmentation"), - info=_("Percentage amount to randomly shift each training image horizontally and " - "vertically."), - rounding=1, - min_max=(0, 25)) - -flip_chance = ConfigItem( - datatype=int, - default=50, - group=_("image augmentation"), - info=_("Percentage chance to randomly flip each training image horizontally.\n" - "NB: This is ignored if the 'no-flip' option is enabled"), - rounding=1, - min_max=(0, 75)) - -color_lightness = ConfigItem( - datatype=int, - default=30, - group=_("color augmentation"), - info=_("Percentage amount to randomly alter the lightness of each training image.\n" - "NB: This is ignored if the 'no-augment-color' option is enabled"), - rounding=1, - min_max=(0, 75)) - -color_ab = ConfigItem( - datatype=int, - default=8, - group=_("color augmentation"), - info=_("Percentage amount to randomly alter the 'a' and 'b' colors of the L*a*b* color " - "space of each training image.\nNB: This is ignored if the 'no-augment-color' option" - "is enabled"), - rounding=1, - min_max=(0, 50)) - -color_clahe_chance = ConfigItem( - datatype=int, - default=50, - group=_("color augmentation"), - info=_("Percentage chance to perform Contrast Limited Adaptive Histogram Equalization on " - "each training image.\nNB: This is ignored if the 'no-augment-color' option is " - "enabled"), - rounding=1, - min_max=(0, 75), - fixed=False) - -color_clahe_max_size = ConfigItem( - datatype=int, - default=4, - group=_("color augmentation"), - info=_("The grid size dictates how much Contrast Limited Adaptive Histogram Equalization is " - "performed on any training image selected for clahe. Contrast will be applied " - "randomly with a gridsize of 0 up to the maximum. This value is a multiplier " - "calculated from the training image size.\nNB: This is ignored if the " - "'no-augment-color' option is enabled"), - rounding=1, - min_max=(1, 8)) + defaults = {k: T.cast(GlobalSection, v) for k, v in globals().items() + if inspect.isclass(v) and issubclass(v, GlobalSection) and v != GlobalSection} + logger.debug("Training config. options: %s", defaults) + return defaults __all__ = get_module_objects(__name__) diff --git a/plugins/train/training.py b/plugins/train/training.py deleted file mode 100644 index a74a73e0ab..0000000000 --- a/plugins/train/training.py +++ /dev/null @@ -1,369 +0,0 @@ -#! /usr/env/bin/python3 -""" Run the training loop for a training plugin """ -from __future__ import annotations - -import logging -import os -import typing as T -import time -import warnings - -import numpy as np -import torch - -from torch.cuda import OutOfMemoryError - -from lib.training import Feeder, LearningRateFinder, LearningRateWarmup -from lib.training.tensorboard import TorchTensorBoard -from lib.utils import get_module_objects, FaceswapError -from plugins.train import train_config as mod_cfg -from plugins.train.trainer import trainer_config as trn_cfg - -from plugins.train.trainer._display import Samples, Timelapse - -if T.TYPE_CHECKING: - from collections.abc import Callable - from plugins.train.trainer._base import TrainerBase - -logger = logging.getLogger(__name__) - - -# Suppress non-Faceswap related Keras warning about backend padding mismatches -warnings.filterwarnings("ignore", - message="You might experience inconsistencies", - category=UserWarning) - - -class Trainer: - """ Handles the feeding of training images to Faceswap models, the generation of Tensorboard - logs and the creation of sample/time-lapse preview images. - - All Trainer plugins must inherit from this class. - - Parameters - ---------- - plugin : :class:`TrainerBase` - The plugin that will be processing each batch - images : dict[literal["a", "b"], list[str]] - The file paths for the images to be trained on for each side. The dictionary should contain - 2 keys ("a" and "b") with the values being a list of full paths corresponding to each side. - """ - - def __init__(self, plugin: TrainerBase, images: dict[T.Literal["a", "b"], list[str]]) -> None: - self._batch_size = plugin.batch_size - self._plugin = plugin - self._model = plugin.model - - self._feeder = Feeder(images, plugin.model, plugin.batch_size) - - self._exit_early = self._handle_lr_finder() - if self._exit_early: - logger.debug("Exiting from LR Finder") - return - - self._warmup = self._get_warmup() - self._model.state.add_session_batchsize(plugin.batch_size) - self._images = images - self._sides = sorted(key for key in self._images.keys()) - - self._tensorboard = self._set_tensorboard() - self._samples = Samples(self._model, - self._model.coverage_ratio, - trn_cfg.mask_opacity(), - trn_cfg.mask_color()) - - num_images = trn_cfg.preview_images() - assert isinstance(num_images, int) - self._timelapse = Timelapse(self._model, - self._model.coverage_ratio, - num_images, - trn_cfg.mask_opacity(), - trn_cfg.mask_color(), - self._feeder, - self._images) - logger.debug("Initialized %s", self.__class__.__name__) - - @property - def exit_early(self) -> bool: - """ True if the trainer should exit early, without perfoming any training steps """ - return self._exit_early - - @property - def batch_size(self) -> int: - """int : The batch size that the model is set to train at. """ - return self._batch_size - - def _handle_lr_finder(self) -> bool: - """ Handle the learning rate finder. - - If this is a new model, then find the optimal learning rate and return ``True`` if user has - just requested the graph, otherwise return ``False`` to continue training - - If it as existing model, set the learning rate to the value found by the learing rate - finder and return ``False`` to continue training - - Returns - ------- - bool - ``True`` if the learning rate finder options dictate that training should not continue - after finding the optimal leaning rate - """ - if not self._model.command_line_arguments.use_lr_finder: - return False - - if self._model.state.lr_finder > -1: - learning_rate = self._model.state.lr_finder - logger.info("Setting learning rate from Learning Rate Finder to %s", - f"{learning_rate:.1e}") - self._model.model.optimizer.learning_rate.assign(learning_rate) - self._model.state.update_session_config("learning_rate", learning_rate) - return False - - if self._model.state.iterations == 0 and self._model.state.session_id == 1: - lrf = LearningRateFinder(self) - success = lrf.find() - return mod_cfg.lr_finder_mode() == "graph_and_exit" or not success - - logger.debug("No learning rate finder rate. Not setting") - return False - - def _get_warmup(self) -> LearningRateWarmup: - """ Obtain the learning rate warmup instance - - Returns - ------- - :class:`plugins.train.lr_warmup.LRWarmup` - The Learning Rate Warmup object - """ - target_lr = float(self._model.model.optimizer.learning_rate.value.cpu().numpy()) - return LearningRateWarmup(self._model.model, target_lr, self._model.warmup_steps) - - def _set_tensorboard(self) -> TorchTensorBoard | None: - """ Set up Tensorboard callback for logging loss. - - Bypassed if command line option "no-logs" has been selected. - - Returns - ------- - :class:`keras.callbacks.TensorBoard` | None - Tensorboard object for the the current training session. ``None`` if Tensorboard - logging is not selected - """ - if self._model.state.current_session["no_logs"]: - logger.verbose("TensorBoard logging disabled") # type: ignore - return None - logger.debug("Enabling TensorBoard Logging") - - logger.debug("Setting up TensorBoard Logging") - log_dir = os.path.join(str(self._model.io.model_dir), - f"{self._model.name}_logs", - f"session_{self._model.state.session_id}") - tensorboard = TorchTensorBoard(log_dir=log_dir, - write_graph=True, - update_freq="batch") - tensorboard.set_model(self._model.model) - logger.verbose("Enabled TensorBoard Logging") # type: ignore - return tensorboard - - def toggle_mask(self) -> None: - """ Toggle the mask overlay on or off based on user input. """ - self._samples.toggle_mask_display() - - def train_one_batch(self) -> np.ndarray: - """ Process a single batch through the model and obtain the loss - - Returns - ------- - :class:`numpy.ndarray` - The total loss in the first position then A losses, by output order, then B losses, by - output order - """ - try: - inputs, targets = self._feeder.get_batch() - loss_t = self._plugin.train_batch(torch.from_numpy(inputs), - [torch.from_numpy(t) for t in targets]) - loss_cpu = loss_t.detach().cpu().numpy() - retval = np.array([sum(loss_cpu), *loss_cpu]) - except OutOfMemoryError as err: - msg = ("You do not have enough GPU memory available to train the selected model at " - "the selected settings. You can try a number of things:" - "\n1) Close any other application that is using your GPU (web browsers are " - "particularly bad for this)." - "\n2) Lower the batchsize (the amount of images fed into the model each " - "iteration)." - "\n3) Try enabling 'Mixed Precision' training." - "\n4) Use a more lightweight model, or select the model's 'LowMem' option " - "(in config) if it has one.") - raise FaceswapError(msg) from err - return retval - - def train_one_step(self, - viewer: Callable[[np.ndarray, str], None] | None, - timelapse_kwargs: dict[T.Literal["input_a", "input_b", "output"], - str] | None) -> None: - """ Running training on a batch of images for each side. - - Triggered from the training cycle in :class:`scripts.train.Train`. - - * Runs a training batch through the model. - - * Outputs the iteration's loss values to the console - - * Logs loss to Tensorboard, if logging is requested. - - * If a preview or time-lapse has been requested, then pushes sample images through the \ - model to generate the previews - - * Creates a snapshot if the total iterations trained so far meet the requested snapshot \ - criteria - - Notes - ----- - As every iteration is called explicitly, the Parameters defined should always be ``None`` - except on save iterations. - - Parameters - ---------- - viewer: :func:`scripts.train.Train._show` or ``None`` - The function that will display the preview image - timelapse_kwargs: dict - The keyword arguments for generating time-lapse previews. If a time-lapse preview is - not required then this should be ``None``. Otherwise all values should be full paths - the keys being `input_a`, `input_b`, `output`. - """ - self._model.state.increment_iterations() - logger.trace("Training one step: (iteration: %s)", self._model.iterations) # type: ignore - snapshot_interval = self._model.command_line_arguments.snapshot_interval - do_snapshot = (snapshot_interval != 0 and - self._model.iterations - 1 >= snapshot_interval and - (self._model.iterations - 1) % snapshot_interval == 0) - self._warmup() - loss = self.train_one_batch() - self._log_tensorboard(loss) - loss = self._collate_and_store_loss(loss[1:]) - self._print_loss(loss) - if do_snapshot: - self._model.io.snapshot() - self._update_viewers(viewer, timelapse_kwargs) - - def _log_tensorboard(self, loss: np.ndarray) -> None: - """ Log current loss to Tensorboard log files - - Parameters - ---------- - loss : :class:`numpy.ndarray` - The total loss in the first position then A losses, by output order, then B losses, by - output order - """ - if not self._tensorboard: - return - logger.trace("Updating TensorBoard log") # type: ignore - logs = {log[0]: float(log[1]) - for log in zip(self._model.state.loss_names, loss)} - - self._tensorboard.on_train_batch_end(self._model.iterations, logs=logs) - - def _collate_and_store_loss(self, loss: np.ndarray) -> np.ndarray: - """ Collate the loss into totals for each side. - - The losses are summed into a total for each side. Loss totals are added to - :attr:`model.state._history` to track the loss drop per save iteration for backup purposes. - - If NaN protection is enabled, Checks for NaNs and raises an error if detected. - - Parameters - ---------- - loss : :class:`numpy.ndarray` - The total loss in the first position then A losses, by output order, then B losses, by - output order - - Returns - ------- - :class:`numpy.ndarray` - 2 ``floats`` which is the total loss for each side (eg sum of face + mask loss) - - Raises - ------ - FaceswapError - If a NaN is detected, a :class:`FaceswapError` will be raised - """ - # NaN protection - if mod_cfg.nan_protection() and not all(np.isfinite(val) for val in loss): - logger.critical("NaN Detected. Loss: %s", loss) - raise FaceswapError("A NaN was detected and you have NaN protection enabled. Training " - "has been terminated.") - - split = len(loss) // 2 - combined_loss = np.array([sum(loss[:split]), sum(loss[split:])]) - self._model.add_history(combined_loss) - logger.trace("original loss: %s, combined_loss: %s", loss, combined_loss) # type: ignore - return combined_loss - - def _print_loss(self, loss: np.ndarray) -> None: - """ Outputs the loss for the current iteration to the console. - - Parameters - ---------- - loss : :class`numpy.ndarray` - The loss for each side. List should contain 2 ``floats`` side "a" in position 0 and - side "b" in position `. - """ - output = ", ".join([f"Loss {side}: {side_loss:.5f}" - for side, side_loss in zip(("A", "B"), loss)]) - timestamp = time.strftime("%H:%M:%S") - output = f"[{timestamp}] [#{self._model.iterations:05d}] {output}" - print(f"{output}", end="\r") - - def _update_viewers(self, - viewer: Callable[[np.ndarray, str], None] | None, - timelapse_kwargs: dict[T.Literal["input_a", "input_b", "output"], - str] | None) -> None: - """ Update the preview viewer and timelapse output - - Parameters - ---------- - viewer: :func:`scripts.train.Train._show` or ``None`` - The function that will display the preview image - timelapse_kwargs: dict - The keyword arguments for generating time-lapse previews. If a time-lapse preview is - not required then this should be ``None``. Otherwise all values should be full paths - the keys being `input_a`, `input_b`, `output`. - """ - if viewer is not None: - self._samples.images = self._feeder.generate_preview() - samples = self._samples.show_sample() - if samples is not None: - viewer(samples, - "Training - 'S': Save Now. 'R': Refresh Preview. 'M': Toggle Mask. 'F': " - "Toggle Screen Fit-Actual Size. 'ENTER': Save and Quit") - - if timelapse_kwargs: - self._timelapse.output_timelapse(timelapse_kwargs) - - def _clear_tensorboard(self) -> None: - """ Stop Tensorboard logging. - - Tensorboard logging needs to be explicitly shutdown on training termination. Called from - :class:`scripts.train.Train` when training is stopped. - """ - if not self._tensorboard: - return - logger.debug("Ending Tensorboard Session: %s", self._tensorboard) - self._tensorboard.on_train_end() - - def save(self, is_exit: bool = False) -> None: - """ Save the model - - Parameters - ---------- - is_exit: bool, optional - ``True`` if save has been called on model exit. Default: ``False`` - """ - self._model.io.save(is_exit=is_exit) - assert self._tensorboard is not None - self._tensorboard.on_save() - if is_exit: - self._clear_tensorboard() - - -__all__ = get_module_objects(__name__) diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index 9b6c8e285e..e8d372487d 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -4,7 +4,7 @@ psutil>=7.2.0 numexpr>=2.14.0 numpy>=2.4.0 opencv-python>=4.13.0 -pillow>=12.1.0 +pillow>=12.2.0 scikit-learn>=1.8.0 fastcluster>=1.3.0 matplotlib>=3.10.0 @@ -12,7 +12,6 @@ av>=17.0 ffmpeg-binaries>=1.1 ffmpy>=1.0.0 pywin32>=305 ; sys_platform == "win32" -#torchvision>=0.18.0,<0.25.0 -torchvision>=0.18.0,<0.25.0 +torchvision>=0.18.0,<0.27.0 tensorboard>=2.20.0 keras>=3.13.0,<3.14.0 diff --git a/requirements/requirements_nvidia_12.txt b/requirements/requirements_nvidia_12.txt index cefd2da151..153335e50a 100644 --- a/requirements/requirements_nvidia_12.txt +++ b/requirements/requirements_nvidia_12.txt @@ -4,4 +4,4 @@ # Exclude badly numbered Python2 version of nvidia-ml-py nvidia-ml-py>=12.535,<300 --extra-index-url https://download.pytorch.org/whl/cu126 -torch>=2.7.0,<2.10.0 +torch>=2.7.0,<2.12.0 diff --git a/requirements/requirements_nvidia_13.txt b/requirements/requirements_nvidia_13.txt index 79ccbcdd0c..5f1891cb40 100644 --- a/requirements/requirements_nvidia_13.txt +++ b/requirements/requirements_nvidia_13.txt @@ -4,4 +4,4 @@ # Exclude badly numbered Python2 version of nvidia-ml-py nvidia-ml-py>=12.535,<300 --extra-index-url https://download.pytorch.org/whl/cu130 -torch>=2.9.0,<2.10.0 +torch>=2.9.0,<2.12.0 diff --git a/scripts/train.py b/scripts/train.py index 2164154e82..8b7ef48bf1 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -1,5 +1,5 @@ #!/usr/bin python3 -""" Main entry point to the training process of FaceSwap """ +"""Main entry point to the training process of FaceSwap """ from __future__ import annotations import logging import os @@ -12,15 +12,19 @@ import cv2 import numpy as np -from lib.gui.utils.image import TRAININGPREVIEW +from lib.gui.utils.image import TRAINING_PREVIEW from lib.image import read_image_meta from lib.keypress import KBHit +from lib.logger import parse_class_init from lib.multithreading import MultiThread, FSThread from lib.training import Preview, PreviewBuffer, TriggerType +from lib.training.data_set import get_label +from lib.training.train import Trainer from lib.utils import (get_folder, get_image_paths, get_module_objects, handle_deprecated_cli_opts, - FaceswapError, IMAGE_EXTENSIONS) + FaceswapError) from plugins.plugin_loader import PluginLoader -from plugins.train.training import Trainer +from plugins.train.trainer.base import TrainConfig + if T.TYPE_CHECKING: import argparse @@ -32,7 +36,7 @@ class Train(): - """ The Faceswap Training Process. + """The Faceswap Training Process. The training process is responsible for training a model on a set of source faces and a set of destination faces. @@ -42,12 +46,12 @@ class Train(): Parameters ---------- - arguments: argparse.Namespace + arguments The arguments to be passed to the training process as generated from Faceswap's command line arguments """ def __init__(self, arguments: argparse.Namespace) -> None: - logger.debug("Initializing %s: (args: %s", self.__class__.__name__, arguments) + logger.debug(parse_class_init(locals())) self._args = handle_deprecated_cli_opts(arguments) if self._args.summary: @@ -65,51 +69,9 @@ def __init__(self, arguments: argparse.Namespace) -> None: self._save_now: bool = False self._preview = PreviewInterface(self._args.preview) - logger.debug("Initialized %s", self.__class__.__name__) - - def _get_images(self) -> dict[T.Literal["a", "b"], list[str]]: - """ Check the image folders exist and contains valid extracted faces. Obtain image paths. - - Returns - ------- - dict - The image paths for each side. The key is the side, the value is the list of paths - for that side. - """ - logger.debug("Getting image paths") - images = {} - for side in ("a", "b"): - side = T.cast(T.Literal["a", "b"], side) - image_dir = getattr(self._args, f"input_{side}") - if not os.path.isdir(image_dir): - logger.error("Error: '%s' does not exist", image_dir) - sys.exit(1) - - images[side] = get_image_paths(image_dir, ".png") - if not images[side]: - logger.error("Error: '%s' contains no images", image_dir) - sys.exit(1) - # Validate the first image is a detected face - test_image = next(img for img in images[side]) - meta = read_image_meta(test_image) - logger.debug("Test file: (filename: %s, metadata: %s)", test_image, meta) - if "itxt" not in meta or "alignments" not in meta["itxt"]: - logger.error("The input folder '%s' contains images that are not extracted faces.", - image_dir) - logger.error("You can only train a model on faces generated from Faceswap's " - "extract process. Please check your sources and try again.") - sys.exit(1) - - logger.info("Model %s Directory: '%s' (%s images)", - side.upper(), image_dir, len(images[side])) - logger.debug("Got image paths: %s", [(key, str(len(val)) + " images") - for key, val in images.items()]) - self._validate_image_counts(images) - return images - @classmethod - def _validate_image_counts(cls, images: dict[T.Literal["a", "b"], list[str]]) -> None: - """ Validate that there are sufficient images to commence training without raising an + def _validate_image_counts(cls, side: str, num_images: int) -> None: + """Validate that there are sufficient images to commence training without raising an error. Confirms that there are at least 24 images in each folder. Whilst this is not enough images @@ -120,35 +82,80 @@ def _validate_image_counts(cls, images: dict[T.Literal["a", "b"], list[str]]) -> Parameters ---------- - images: dict - The image paths for each side. The key is the side, the value is the list of paths - for that side. + side + The side of the model that we are validating counts for + num_images + The number of images for the side """ - counts = {side: len(paths) for side, paths in images.items()} msg = ("You need to provide a significant number of images to successfully train a Neural " "Network. Aim for between 500 - 5000 images per side.") - if any(count < 25 for count in counts.values()): - logger.error("At least one of your input folders contains fewer than 25 images.") + if num_images < 25: + logger.error("Side %s contains fewer than 25 images.", side) logger.error(msg) sys.exit(1) - if any(count < 250 for count in counts.values()): - logger.warning("At least one of your input folders contains fewer than 250 images. " - "Results are likely to be poor.") + if num_images < 250: + logger.warning("Side %s contains fewer than 250 images. " + "Results are likely to be poor.", side) logger.warning(msg) - def _set_timelapse(self) -> dict[T.Literal["input_a", "input_b", "output"], str]: - """ Set time-lapse paths if requested. + @classmethod + def _validate_faceswap_image(cls, image_path: str) -> None: + """Validate that the given image path is to a faceswap training image. Exits with error + if a non-faceswap image is found + + Parameters + ---------- + image_path + Full path to a faceswap .png to validate + """ + meta = read_image_meta(image_path) + logger.debug("[Train] Test file: (filename: %s, metadata: %s)", image_path, meta) + if "itxt" not in meta or "alignments" not in meta["itxt"]: + logger.error("The input folder '%s' contains images that are not extracted faces.", + os.path.dirname(image_path)) + logger.error("You can only train a model on faces generated from Faceswap's " + "extract process. Please check your sources and try again.") + sys.exit(1) + + def _get_images(self) -> list[str]: + """Check the image folders exist and contains valid extracted faces. Returns ------- - dict - The time-lapse keyword arguments for passing to the trainer + The folder path for each side of the model to be trained + """ + logger.debug("[Train] Getting image paths") + retval: list[str] = [] + input_folders = [self._args.input_a, self._args.input_b] + for idx, image_dir in enumerate(input_folders): + key = get_label(idx, len(input_folders)) + if not os.path.isdir(image_dir): + logger.error("Error: '%s' does not exist", image_dir) + sys.exit(1) + test = get_image_paths(image_dir, ".png") + if not test: + logger.error("Error: '%s' contains no images", image_dir) + sys.exit(1) + # Validate the first image is a detected face + self._validate_faceswap_image(next(img for img in test)) + self._validate_image_counts(key, len(test)) + retval.append(image_dir) + logger.info("Model %s Directory: '%s' (%s images)", key, image_dir, len(test)) + + return retval + + def _set_timelapse(self) -> bool: + """Validate timelapse settings + + Returns + ------- + ``True`` if timelapse is enabled and valid otherwise ``False`` """ if (not self._args.timelapse_input_a and not self._args.timelapse_input_b and not self._args.timelapse_output): - return {} + return False if (not self._args.timelapse_input_a or not self._args.timelapse_input_b or not self._args.timelapse_output): @@ -156,11 +163,11 @@ def _set_timelapse(self) -> dict[T.Literal["input_a", "input_b", "output"], str] "(--timelapse-input-A, --timelapse-input-B and " "--timelapse-output).") - timelapse_output = get_folder(self._args.timelapse_output) + timelapse_folders = [self._args.timelapse_input_a, self._args.timelapse_input_b] + get_folder(self._args.timelapse_output) - for side in ("a", "b"): - side = T.cast(T.Literal["a", "b"], side) - folder = getattr(self._args, f"timelapse_input_{side}") + for idx, folder in enumerate(timelapse_folders): + side = "a" if idx == 0 else "b" if folder is not None and not os.path.isdir(folder): raise FaceswapError(f"The Timelapse path '{folder}' does not exist") @@ -168,67 +175,56 @@ def _set_timelapse(self) -> dict[T.Literal["input_a", "input_b", "output"], str] if folder == training_folder: continue # Time-lapse folder is training folder - filenames = [fname for fname in os.listdir(folder) - if os.path.splitext(fname)[-1].lower() in IMAGE_EXTENSIONS] + filenames = [os.path.join(folder, fname) for fname in os.listdir(folder) + if os.path.splitext(fname)[-1].lower() == ".png"] if not filenames: raise FaceswapError(f"The Timelapse path '{folder}' does not contain any valid " "images") - # Time-lapse images must appear in the training set, as we need access to alignment and - # mask info. Check filenames are there to save failing much later in the process. - training_images = [os.path.basename(img) for img in self._images[side]] - if not all(img in training_images for img in filenames): - raise FaceswapError(f"All images in the Timelapse folder '{folder}' must exist in " - f"the training folder '{training_folder}'") - - TKey = T.Literal["input_a", "input_b", "output"] - kwargs = {T.cast(TKey, "input_a"): self._args.timelapse_input_a, - T.cast(TKey, "input_b"): self._args.timelapse_input_b, - T.cast(TKey, "output"): timelapse_output} - logger.debug("Timelapse enabled: %s", kwargs) - return kwargs + self._validate_faceswap_image(filenames[0]) + logger.debug("[Train] Timelapse enabled") + return True def process(self) -> None: - """ The entry point for triggering the Training Process. + """The entry point for triggering the Training Process. Should only be called from :class:`lib.cli.launcher.ScriptExecutor` """ if self._args.summary: self._load_model() return - logger.debug("Starting Training Process") + logger.debug("[Train] Starting Training Process") logger.info("Training data directory: %s", self._args.model_dir) thread = self._start_thread() # from lib.queue_manager import queue_manager; queue_manager.debug_monitor(1) err = self._monitor(thread) self._end_thread(thread, err) - logger.debug("Completed Training Process") + logger.debug("[Train] Completed Training Process") def _start_thread(self) -> MultiThread: - """ Put the :func:`_training` into a background thread so we can keep control. + """Put the :func:`_training` into a background thread so we can keep control. Returns ------- - :class:`lib.multithreading.MultiThread` - The background thread for running training + The background thread for running training """ - logger.debug("Launching Trainer thread") + logger.debug("[Train] Launching Trainer thread") thread = MultiThread(target=self._training) thread.start() - logger.debug("Launched Trainer thread") + logger.debug("[Train] Launched Trainer thread") return thread def _end_thread(self, thread: MultiThread, err: bool) -> None: - """ Output message and join thread back to main on termination. + """Output message and join thread back to main on termination. Parameters ---------- - thread: :class:`lib.multithreading.MultiThread` + thread The background training thread - err: bool + err Whether an error has been detected in :func:`_monitor` """ - logger.debug("Ending Training thread") + logger.debug("[Train] Ending Training thread") if err: msg = "Error caught! Exiting..." log = logger.critical @@ -243,25 +239,25 @@ def _end_thread(self, thread: MultiThread, err: bool) -> None: self._stop = True thread.join() sys.stdout.flush() - logger.debug("Ended training thread") + logger.debug("[Train] Ended training thread") def _training(self) -> None: - """ The training process to be run inside a thread. """ + """The training process to be run inside a thread.""" trainer = None try: sleep(0.5) # Let preview instructions flush out to logger - logger.debug("Commencing Training") + logger.debug("[Train] Commencing Training") logger.info("Loading data, this may take a while...") model = self._load_model() trainer = self._load_trainer(model) if trainer.exit_early: - logger.debug("Trainer exits early") + logger.debug("[Train] Trainer exits early") self._stop = True return self._run_training_cycle(trainer) except KeyboardInterrupt: try: - logger.debug("Keyboard Interrupt Caught. Saving Weights and exiting") + logger.debug("[Train] Keyboard Interrupt Caught. Saving Weights and exiting") if trainer is not None: trainer.save(is_exit=True) except KeyboardInterrupt: @@ -271,37 +267,35 @@ def _training(self) -> None: raise err def _load_model(self) -> ModelBase: - """ Load the model requested for training. + """Load the model requested for training. Returns ------- - :file:`plugins.train.model` plugin - The requested model plugin + The requested model plugin """ - logger.debug("Loading Model") + logger.debug("[Train] Loading Model") model_dir = get_folder(self._args.model_dir) model: ModelBase = PluginLoader.get_model(self._args.trainer)( model_dir, self._args, predict=False) model.build() - logger.debug("Loaded Model") + logger.debug("[Train] Loaded Model") return model def _load_trainer(self, model: ModelBase) -> Trainer: - """ Load the trainer requested for training. + """Load the trainer requested for training. Parameters ---------- - model: :file:`plugins.train.model` plugin + model The requested model plugin Returns ------- - :class:`plugins.train.trainer.run_train.Trainer` - The model training loop with the requested trainer plugin loaded + The model training loop with the requested trainer plugin loaded """ - logger.debug("Loading Trainer") + logger.debug("[Train] Loading Trainer") trainer = "distributed" if self._args.distributed else "original" if trainer == "distributed": import torch # pylint:disable=import-outside-toplevel @@ -311,23 +305,34 @@ def _load_trainer(self, model: ModelBase) -> Trainer: "to Original") trainer = "original" - retval = Trainer(PluginLoader.get_trainer(trainer)(model, self._args.batch_size), - self._images) - logger.debug("Loaded Trainer") + config = TrainConfig(folders=self._images, + batch_size=self._args.batch_size, + augment_color=not self._args.no_augment_color, + flip=not self._args.no_flip, + warp=not self._args.no_warp, + cache_landmarks=self._args.warp_to_landmarks, + lr_finder=self._args.use_lr_finder, + snapshot_interval=self._args.snapshot_interval) + retval = Trainer(PluginLoader.get_trainer(trainer)(model, config), + self._args.preview or self._args.write_image or self._args.redirect_gui, + timelapse_folders=[self._args.timelapse_input_a, + self._args.timelapse_input_b], + timelapse_output=self._args.timelapse_output) + logger.debug("[Train] Loaded Trainer") return retval def _run_training_cycle(self, trainer: Trainer) -> None: - """ Perform the training cycle. + """Perform the training cycle. Handles the background training, updating previews/time-lapse on each save interval, and saving the model. Parameters ---------- - trainer: :file:`plugins.train.trainer` plugin + trainer The requested model trainer plugin """ - logger.debug("Running Training Cycle") + logger.debug("[Train] Running Training Cycle") update_preview_images = False if self._args.write_image or self._args.redirect_gui or self._args.preview: display_func: Callable | None = self._show @@ -335,7 +340,7 @@ def _run_training_cycle(self, trainer: Trainer) -> None: display_func = None for iteration in range(1, self._args.iterations + 1): - logger.trace("Training iteration: %s", iteration) # type:ignore + logger.trace("[Train] Training iteration: %s", iteration) # type:ignore save_iteration = iteration % self._args.save_interval == 0 or iteration == 1 gui_triggers = self._process_gui_triggers() @@ -349,32 +354,31 @@ def _run_training_cycle(self, trainer: Trainer) -> None: else: viewer = None - timelapse = self._timelapse if save_iteration else {} - trainer.train_one_step(viewer, timelapse) + trainer.train_one_step(viewer, self._timelapse and save_iteration) if viewer is not None and not save_iteration: - # Spammy but required by GUI to know to update window + # Ugly spam but required by GUI to know to update window print("\x1b[2K", end="\r") # Clear last line logger.info("[Preview Updated]") if self._stop: - logger.debug("Stop received. Terminating") + logger.debug("[Train] Stop received. Terminating") break if save_iteration or self._save_now: - logger.debug("Saving (save_iterations: %s, save_now: %s) Iteration: " + logger.debug("[Train] Saving (save_iterations: %s, save_now: %s) Iteration: " "(iteration: %s)", save_iteration, self._save_now, iteration) trainer.save(is_exit=False) self._save_now = False update_preview_images = True - logger.debug("Training cycle complete") + logger.debug("[Train] Training cycle complete") trainer.save(is_exit=True) self._stop = True def _output_startup_info(self) -> None: - """ Print the startup information to the console. """ - logger.debug("Launching Monitor") + """Print the startup information to the console.""" + logger.debug("[Train] Launching Monitor") logger.info("===================================================") logger.info(" Starting") if self._args.preview: @@ -387,23 +391,22 @@ def _output_startup_info(self) -> None: logger.info("===================================================") def _check_keypress(self, keypress: KBHit) -> bool: - """ Check if a keypress has been detected. + """Check if a keypress has been detected. Parameters ---------- - keypress: :class:`lib.keypress.KBHit` + keypress The keypress monitor Returns ------- - bool - ``True`` if an exit keypress has been detected otherwise ``False`` + ``True`` if an exit keypress has been detected otherwise ``False`` """ retval = False if keypress.kbhit(): console_key = keypress.getch() if console_key in ("\n", "\r"): - logger.debug("Exit requested") + logger.debug("[Train] Exit requested") retval = True if console_key in ("s", "S"): logger.info("Save requested") @@ -411,12 +414,11 @@ def _check_keypress(self, keypress: KBHit) -> bool: return retval def _process_gui_triggers(self) -> dict[T.Literal["mask", "refresh"], bool]: - """ Check whether a file drop has occurred from the GUI to manually update the preview. + """Check whether a file drop has occurred from the GUI to manually update the preview. Returns ------- - dict - The trigger name as key and boolean as value + The trigger name as key and boolean as value """ retval: dict[T.Literal["mask", "refresh"], bool] = {key: False for key in self._gui_triggers} @@ -425,9 +427,9 @@ def _process_gui_triggers(self) -> dict[T.Literal["mask", "refresh"], bool]: for trigger, filename in self._gui_triggers.items(): if os.path.isfile(filename): - logger.debug("GUI Trigger received for: '%s'", trigger) + logger.debug("[Train] GUI Trigger received for: '%s'", trigger) retval[trigger] = True - logger.debug("Removing gui trigger file: %s", filename) + logger.debug("[Train] Removing gui trigger file: %s", filename) os.remove(filename) if trigger == "refresh": print("\x1b[2K", end="\r") # Clear last line @@ -435,17 +437,16 @@ def _process_gui_triggers(self) -> dict[T.Literal["mask", "refresh"], bool]: return retval def _monitor(self, thread: MultiThread) -> bool: - """ Monitor the background :func:`_training` thread for key presses and errors. + """Monitor the background :func:`_training` thread for key presses and errors. Parameters ---------- - thread: :class:`~lib.multithreading.MultiThread` + thread The thread containing the training loop Returns ------- - bool - ``True`` if there has been an error in the background thread otherwise ``False`` + ``True`` if there has been an error in the background thread otherwise ``False`` """ self._output_startup_info() keypress = KBHit(is_gui=self._args.redirect_gui) @@ -453,11 +454,11 @@ def _monitor(self, thread: MultiThread) -> bool: while True: try: if thread.has_error: - logger.debug("Thread error detected") + logger.debug("[Train] Thread error detected") err = True break if self._stop: - logger.debug("Stop received") + logger.debug("[Train] Stop received") break # Preview Monitor @@ -472,61 +473,62 @@ def _monitor(self, thread: MultiThread) -> bool: sleep(1) except KeyboardInterrupt: - logger.debug("Keyboard Interrupt received") + logger.debug("[Train] Keyboard Interrupt received") break - logger.debug("Closing Monitor") + logger.debug("[Train] Closing Monitor") self._preview.shutdown() keypress.set_normal_term() - logger.debug("Closed Monitor") + logger.debug("[Train] Closed Monitor") return err def _show(self, image: np.ndarray, name: str = "") -> None: - """ Generate the preview and write preview file output. + """Generate the preview and write preview file output. Handles the output and display of preview images. Parameters ---------- - image: :class:`numpy.ndarray` + image The preview image to be displayed and/or written out - name: str, optional + name The name of the image for saving or display purposes. If an empty string is passed then it will automatically be named. Default: "" """ - logger.debug("Updating preview: (name: %s)", name) + logger.debug("[Train] Updating preview: (name: %s)", name) try: - scriptpath = os.path.realpath(os.path.dirname(sys.argv[0])) + script_path = os.path.realpath(os.path.dirname(sys.argv[0])) if self._args.write_image: - logger.debug("Saving preview to disk") + logger.debug("[Train] Saving preview to disk") img = "training_preview.png" - imgfile = os.path.join(scriptpath, img) - cv2.imwrite(imgfile, image) # pylint:disable=no-member - logger.debug("Saved preview to: '%s'", img) + img_file = os.path.join(script_path, img) + cv2.imwrite(img_file, image) # pylint:disable=no-member + logger.debug("[Train] Saved preview to: '%s'", img) if self._args.redirect_gui: - logger.debug("Generating preview for GUI") - img = TRAININGPREVIEW - imgfile = os.path.join(scriptpath, "lib", "gui", ".cache", "preview", img) - cv2.imwrite(imgfile, image) # pylint:disable=no-member - logger.debug("Generated preview for GUI: '%s'", imgfile) + logger.debug("[Train] Generating preview for GUI") + img = TRAINING_PREVIEW + img_file = os.path.join(script_path, "lib", "gui", ".cache", "preview", img) + cv2.imwrite(img_file, image) # pylint:disable=no-member + logger.debug("[Train] Generated preview for GUI: '%s'", img_file) if self._args.preview: - logger.debug("Generating preview for display: '%s'", name) + logger.debug("[Train] Generating preview for display: '%s'", name) self._preview.buffer.add_image(name, image) - logger.debug("Generated preview for display: '%s'", name) + logger.debug("[Train] Generated preview for display: '%s'", name) except Exception as err: logging.error("could not preview sample") raise err - logger.debug("Updated preview: (name: %s)", name) + logger.debug("[Train] Updated preview: (name: %s)", name) class PreviewInterface(): - """ Run the preview window in a thread and interface with it + """Run the preview window in a thread and interface with it Parameters ---------- - use_preview: bool + use_preview ``True`` if pop-up preview window has been requested otherwise ``False`` """ def __init__(self, use_preview: bool) -> None: + logger.debug(parse_class_init(locals())) self._active = use_preview self._triggers: TriggerType = {"toggle_mask": Event(), "refresh": Event(), @@ -538,48 +540,48 @@ def __init__(self, use_preview: bool) -> None: @property def buffer(self) -> PreviewBuffer: - """ :class:`PreviewBuffer`: The thread save preview image object """ + """The thread save preview image object""" return self._buffer @property def should_toggle_mask(self) -> bool: - """ bool: Check whether the mask should be toggled and return the value. If ``True`` is - returned then resets mask toggle back to ``False`` """ + """Check whether the mask should be toggled and return the value. If ``True`` is returned + then resets mask toggle back to ``False``""" if not self._active: return False retval = self._triggers["toggle_mask"].is_set() if retval: - logger.debug("Sending toggle mask") + logger.debug("[PreviewInterface] Sending toggle mask") self._triggers["toggle_mask"].clear() return retval @property def should_refresh(self) -> bool: - """ bool: Check whether the preview should be updated and return the value. If ``True`` is - returned then resets the refresh trigger back to ``False`` """ + """Check whether the preview should be updated and return the value. If ``True`` is + returned then resets the refresh trigger back to ``False``""" if not self._active: return False retval = self._triggers["refresh"].is_set() if retval: - logger.debug("Sending should refresh") + logger.debug("[PreviewInterface] Sending should refresh") self._triggers["refresh"].clear() return retval @property def should_save(self) -> bool: - """ bool: Check whether a save request has been made. If ``True`` is returned then save - trigger is set back to ``False`` """ + """Check whether a save request has been made. If ``True`` is returned then save + trigger is set back to ``False``""" if not self._active: return False retval = self._triggers["save"].is_set() if retval: - logger.debug("Sending should save") + logger.debug("[PreviewInterface] Sending should save") self._triggers["save"].clear() return retval @property def should_quit(self) -> bool: - """ bool: Check whether an exit request has been made. ``True`` if an exit request has + """Check whether an exit request has been made. ``True`` if an exit request has been made otherwise ``False``. Raises @@ -594,16 +596,15 @@ def should_quit(self) -> bool: retval = self._triggers["quit"].is_set() if retval: - logger.debug("Sending should stop") + logger.debug("[PreviewInterface] Sending should stop") return retval def _launch_thread(self) -> FSThread | None: - """ Launch the preview viewer in it's own thread if preview has been selected + """Launch the preview viewer in it's own thread if preview has been selected Returns ------- - :class:`lib.multithreading.FSThread` or ``None`` - The thread that holds the preview viewer if preview is selected otherwise ``None`` + The thread that holds the preview viewer if preview is selected otherwise ``None`` """ if not self._active: return None @@ -615,10 +616,10 @@ def _launch_thread(self) -> FSThread | None: return thread def shutdown(self) -> None: - """ Send a signal to shutdown the preview window. """ + """Send a signal to shutdown the preview window.""" if not self._active: return - logger.debug("Sending shutdown to preview viewer") + logger.debug("[PreviewInterface] Sending shutdown to preview viewer") self._triggers["shutdown"].set() diff --git a/tests/lib/training/cache_test.py b/tests/lib/training/cache_test.py deleted file mode 100644 index 7a0a0da946..0000000000 --- a/tests/lib/training/cache_test.py +++ /dev/null @@ -1,950 +0,0 @@ -#!/usr/bin python3 -""" Pytest unit tests for :mod:`lib.training.cache` """ -import os -import typing as T - -from threading import Lock - -import numpy as np -import pytest -import pytest_mock - -from lib.align.constants import LandmarkType -from lib.training import cache as cache_mod -from lib.utils import FaceswapError -from plugins.train import train_config as cfg - - -from tests.lib.config.helpers import patch_config # # pylint:disable=unused-import # noqa[F401] - -# pylint:disable=protected-access,invalid-name,redefined-outer-name - - -# ## HELPERS ### - -MODULE_PREFIX = "lib.training.cache" -_DUMMY_IMAGE_LIST = ["/path/to/img1.png", "~/img2.png", "img3.png"] - - -def _get_config(centering="face", vertical_offset=0): - """ Return a fresh valid config """ - return {"centering": centering, - "vertical_offset": vertical_offset} - - -STANDARD_CACHE_ARGS = (_DUMMY_IMAGE_LIST, 256, 1.0) -STANDARD_MASK_ARGS = (256, 1.0, "face") - - -# ## MASK PROCESSING ### - -def get_mask_config(penalized_mask_loss=True, - learn_mask=True, - mask_type="extended", - mask_dilation=1.0, - mask_kernel=3, - mask_threshold=4, - mask_eye_multiplier=2, - mask_mouth_multiplier=3): - """ Generate the mask config dictionary with the given arguments """ - return {"penalized_mask_loss": penalized_mask_loss, - "learn_mask": learn_mask, - "mask_type": mask_type, - "mask_dilation": mask_dilation, - "mask_blur_kernel": mask_kernel, - "mask_threshold": mask_threshold, - "eye_multiplier": mask_eye_multiplier, - "mouth_multiplier": mask_mouth_multiplier} - - -_MASK_CONFIG_PARAMS = ( - (get_mask_config(True, True, "extended", 1.0, 3, 4, 2, 3), "pass-penalize|learn"), - (get_mask_config(True, False, "components", 0.0, 5, 4, 1, 2), "pass-penalize"), - (get_mask_config(False, True, "custom", -2.0, 6, 1, 3, 1), "pass-learn"), - (get_mask_config(True, True, None, 1.0, 6, 1, 3, 2), "pass-mask-disable1"), - (get_mask_config(False, False, "extended", 1.0, 6, 1, 3, 2), "pass-mask-disable2"), - (get_mask_config(True, True, "extended", 1.0, 1, 3, 1, 1), "pass-multiplier-disable"), - (get_mask_config("Error", True, "extended", 1.0, 1, 3, 2, 3), "fail-penalize"), - (get_mask_config(True, 1.4, "extended", 1.0, 1, 3, 2, 3), "fail-learn"), - (get_mask_config(True, True, 999, 1.0, 1, 3, 2, 3), "fail-type"), - (get_mask_config(True, True, "extended", 23, 1, 3, 2, 3), "fail-dilation"), - (get_mask_config(True, True, "extended", 1.0, 1.2, 3, 2, 3), "fail-kernel"), - (get_mask_config(True, True, "extended", 1.0, 1, "fail", 2, 3), "fail-threshold"), - (get_mask_config(True, True, "extended", 1.0, 1, 3, 3.9, 3), "fail-eye-multi"), - (get_mask_config(True, True, "extended", 1.0, 1, 3, 2, "fail"), "fail-mouth-multi")) -_MASK_CONFIG_IDS = [x[-1] for x in _MASK_CONFIG_PARAMS] - - -@pytest.mark.parametrize(("config", "status"), _MASK_CONFIG_PARAMS, ids=_MASK_CONFIG_IDS) -def test_MaskConfig(config: dict[str, T.Any], - status: str, - patch_config) -> None: # noqa[F811] - """ Test that cache._MaskConfig dataclass initializes from config """ - patch_config(cfg.Loss, config) - retval = cache_mod._MaskConfig() - if status.startswith("pass-mask-disable"): - assert not retval.mask_enabled - else: - assert retval.mask_enabled - - if status == "pass-multiplier-disable" or not config["penalized_mask_loss"]: - assert not retval.multiplier_enabled - else: - assert retval.multiplier_enabled - - -_MASK_INIT_PARAMS = ((64, 0.5, "face", "pass"), - (128, 0.75, "head", "pass"), - (384, 1.0, "legacy", "pass"), - (69.42, 0.75, "head", "fail-size"), - (128, "fail", "head", "fail-coverage"), - (128, 0.75, "fail", "fail-centering")) -_MASK_INIT_IDS = [x[-1] for x in _MASK_INIT_PARAMS] - - -@pytest.mark.parametrize(("size", "coverage", "centering", "status"), - _MASK_INIT_PARAMS, ids=_MASK_INIT_IDS) -def test_MaskProcessing_init(size, - coverage, - centering, - status: str, - mocker: pytest_mock.MockerFixture) -> None: - """ Test cache._MaskProcessing correctly initializes """ - mock_mask_config = mocker.MagicMock() - mocker.patch(f"{MODULE_PREFIX}._MaskConfig", new=mock_mask_config) - - if not status == "pass": - with pytest.raises(AssertionError): - cache_mod._MaskProcessing(size, coverage, centering) - return - - instance = cache_mod._MaskProcessing(size, coverage, centering) - attrs = {"_size": int, - "_coverage": float, - "_centering": str, - "_config": mocker.MagicMock} # Our mocked _MaskConfig - - for attr, dtype in attrs.items(): - assert attr in instance.__dict__ - assert isinstance(instance.__dict__[attr], dtype) - assert all(x in attrs for x in instance.__dict__) - - assert instance._size == size - assert instance._coverage == coverage - assert instance._centering == centering - mock_mask_config.assert_called_once() - - -def test_MaskProcessing_check_mask_exists(mocker: pytest_mock.MockerFixture) -> None: - """ Test cache._MaskProcessing._check_mask_exists functions as expected """ - mock_det_face = mocker.MagicMock() - mock_det_face.mask = ["extended", "components"] - - instance = cache_mod._MaskProcessing(*STANDARD_MASK_ARGS) # type:ignore[arg-type] - - instance._check_mask_exists("", mock_det_face) - - mock_det_face.mask = [] - with pytest.raises(FaceswapError): - instance._check_mask_exists("", mock_det_face) - - -@pytest.mark.parametrize(("dilation", "kernel", "threshold"), - ((1.0, 3, 4), (-2.5, 5, 2), (3.3, 7, 9))) -def test_MaskProcessing_preprocess(dilation: float, - kernel: int, - threshold: int, - mocker: pytest_mock.MockerFixture, - patch_config) -> None: # noqa[F811] - """ Test cache._MaskProcessing._preprocess functions as expected """ - mock_mask = mocker.MagicMock() - mock_det_face = mocker.MagicMock() - mock_det_face.mask = {"extended": mock_mask} - - patch_config(cfg.Loss, get_mask_config(mask_dilation=dilation, - mask_kernel=kernel, - mask_threshold=threshold)) - - instance = cache_mod._MaskProcessing(*STANDARD_MASK_ARGS) # type:ignore[arg-type] - instance._preprocess(mock_det_face, "extended") - mock_mask.set_dilation.assert_called_once_with(dilation) - mock_mask.set_blur_and_threshold.assert_called_once_with(blur_kernel=kernel, - threshold=threshold) - - -@pytest.mark.parametrize( - ("mask_centering", "train_centering", "coverage", "y_offset", "size", "mask_size"), - (("face", "legacy", 0.75, 0.0, 256, 64), - ("legacy", "head", 0.66, -0.25, 128, 128), - ("head", "face", 1.0, 0.33, 64, 256))) -def test_MaskProcessing_crop_and_resize(mask_centering: str, # pylint:disable=too-many-locals - train_centering: T.Literal["legacy", "face", "head"], - coverage: float, - y_offset: float, - size: int, - mask_size: int, - mocker: pytest_mock.MockerFixture) -> None: - """ Test cache._MaskProcessing._crop_and_resize functions as expected """ - mock_pose = mocker.MagicMock() - mock_pose.offset = {"face": "face_centering", - "legacy": "legacy_centering", - "head": "head_centering"} - - mock_det_face = mocker.MagicMock() - mock_det_face.aligned.pose = mock_pose - mock_det_face.aligned.y_offset = y_offset - - mock_face_mask = mocker.MagicMock() - mock_face_mask.__get_item__ = mock_face_mask - mock_face_mask.shape = (mask_size, mask_size) - - mock_mask = mocker.MagicMock() - mock_mask.stored_centering = mask_centering - mock_mask.stored_size = mask_size - mock_mask.mask = mock_face_mask - - mock_cv2_resize_result = mocker.MagicMock() - mock_cv2_resize_item = mocker.MagicMock() - mock_cv2_resize = mocker.patch(f"{MODULE_PREFIX}.cv2.resize", - return_value=mock_cv2_resize_result) - mock_cv2_resize_result.__getitem__.return_value = mock_cv2_resize_item - - mock_cv2_cubic = mocker.patch(f"{MODULE_PREFIX}.cv2.INTER_CUBIC") - mock_cv2_area = mocker.patch(f"{MODULE_PREFIX}.cv2.INTER_AREA") - - instance = cache_mod._MaskProcessing(size, coverage, train_centering) - - retval = instance._crop_and_resize(mock_det_face, mock_mask) - mock_mask.set_sub_crop.assert_called_once_with(mock_pose.offset[mask_centering], - mock_pose.offset[train_centering], - train_centering, - coverage, - y_offset) - if mask_size == size: - assert retval is mock_face_mask - mock_cv2_resize.assert_not_called() - return - - assert retval is mock_cv2_resize_item - interpolation_used = mock_cv2_cubic if mask_size < size else mock_cv2_area - mock_cv2_resize.assert_called_once_with(mock_face_mask, - (size, size), - interpolation=interpolation_used) - - -@pytest.mark.parametrize("mask_type", (None, "extended", "components")) -def test_MaskProcessing_get_face_mask(mask_type: str | None, - mocker: pytest_mock.MockerFixture, - patch_config) -> None: # noqa[F811] - """ Test cache._MaskProcessing._get_face_mask functions as expected """ - patch_config(cfg, _get_config()) - patch_config(cfg.Loss, get_mask_config(mask_type=mask_type)) - instance = cache_mod._MaskProcessing(*STANDARD_MASK_ARGS) # type:ignore[arg-type] - assert instance._config.mask_type == mask_type # sanity check - - instance._check_mask_exists = mocker.MagicMock() # type:ignore[method-assign] - instance._preprocess = mocker.MagicMock() # type:ignore[method-assign] - instance._crop_and_resize = mocker.MagicMock() # type:ignore[method-assign] - - filename = "test_filename" - detected_face = mocker.MagicMock() - - instance._check_mask_exists.assert_not_called() # type:ignore[attr-defined] - instance._preprocess.assert_not_called() # type:ignore[attr-defined] - instance._crop_and_resize.assert_not_called() # type:ignore[attr-defined] - - retval = instance._get_face_mask(filename, detected_face) # type:ignore[arg-type] - if mask_type is None: # Mask disabled - assert not instance._config.mask_enabled - assert retval is None - else: # Mask enabled - assert instance._config.mask_enabled - assert retval is detected_face.get_landmark_mask() - - -@pytest.mark.parametrize(("eye_multiplier", "mouth_multiplier", "size", "enabled"), - ((0, 0, 64, False), - (1, 1, 64, False), - (1, 2, 64, True), - (2, 1, 96, True), - (2, 3, 128, True), - (3, 1, 256, True))) -def test_MaskProcessing_get_localized_mask(eye_multiplier: int, - mouth_multiplier: int, - size: int, - enabled: bool, - mocker: pytest_mock.MockerFixture, - patch_config) -> None: # noqa[F811] - """ Test cache._MaskProcessing._get_localized_mask functions as expected """ - args = STANDARD_MASK_ARGS[1:] - patch_config(cfg.Loss, get_mask_config(mask_eye_multiplier=eye_multiplier, - mask_mouth_multiplier=mouth_multiplier)) - instance = cache_mod._MaskProcessing(size, *args) # type:ignore[arg-type] - - filename = "filename" - detected_face = mocker.MagicMock() - landmark_mask_return_value = mocker.MagicMock() - - detected_face.get_landmark_mask = mocker.MagicMock(return_value=landmark_mask_return_value) - - for area in ("mouth", "eye"): - retval = instance._get_localized_mask(filename, detected_face, area) - if not enabled: - assert retval is None - detected_face.get_landmark_mask.assert_not_called() - else: - assert retval is landmark_mask_return_value - - if enabled: - detected_face.get_landmark_mask.assert_called_with(area, size // 16, 2.5) - if enabled: - assert detected_face.get_landmark_mask.call_count == 2 - - -def test_MaskProcessing_call(mocker: pytest_mock.MockerFixture) -> None: - """ Test cache._MaskProcessing.__call__ functions as expected """ - instance = cache_mod._MaskProcessing(*STANDARD_MASK_ARGS) # type:ignore[arg-type] - face_return = "face_mask" - area_return = "area_mask" - instance._get_face_mask = mocker.MagicMock( # type:ignore[method-assign] - return_value=face_return) # type:ignore[method-assign] - instance._get_localized_mask = mocker.MagicMock( # type:ignore[method-assign] - return_value=area_return) # type:ignore[method-assign] - - filename = "test_filename" - detected_face = mocker.MagicMock() - detected_face.store_training_masks = mocker.MagicMock() - - instance(filename, detected_face) - - instance._get_face_mask.assert_called_once_with( # type:ignore[attr-defined] - filename, detected_face) - - expected_localized_calls = [mocker.call(filename, detected_face, "eye"), - mocker.call(filename, detected_face, "mouth")] - instance._get_localized_mask.assert_has_calls( # type:ignore[attr-defined] - expected_localized_calls, any_order=False) # pyright:ignore[reportArgumentType] - assert instance._get_localized_mask.call_count == 2 # type:ignore[attr-defined] - - detected_face.store_training_masks.assert_called_once_with( - [face_return, area_return, area_return], - delete_masks=True) - - -# ## CACHE PROCESSING ### - -@pytest.fixture -def face_cache_reset_scenario(mocker: pytest_mock.MockerFixture, - request: pytest.FixtureRequest): - """ Build a scenario for cache._check_reset. - - request.param = {"caches": dict(Literal["a", "b"], bool], - "side": Literal["a", "b"]} - - If the key "a" or "b" exist in the caches dict, then that cache exists in the mocked - cache._FACE_CACHES with a mock representing the return value of the cache.Cache.check_reset() - value as given - - The mocked Cache item for the currently testing side is returned, or a default mocked item if - the given side is not meant to be in the _FACE_CACHES dict - """ - cache_dict = {} - for side, val in request.param["caches"].items(): - check_mock = mocker.MagicMock() - check_mock.check_reset.return_value = val - cache_dict[side] = check_mock - mocker.patch(f"{MODULE_PREFIX}._FACE_CACHES", new=cache_dict) - return cache_dict.get(request.param["side"], mocker.MagicMock()) - - -_RESET_PARAMS = [({"side": side, "caches": caches}, expected, f"{name}-{side}") - for side in ("a", "b") - for caches, expected, name in [ - ({}, False, "no-cache"), - ({"a": False}, False, "a-exists"), - ({"b": False}, False, "b-exists"), - ({"a": True, "b": False}, side == "b", "a-reset"), - ({"a": False, "b": True}, side == "a", "b-reset"), - ({"a": True, "b": True}, True, "both-reset"), - ({"a": False, "b": False}, False, "no-reset")]] -_RESET_IDS = [x[-1] for x in _RESET_PARAMS] -_RESET_PARAMS = [x[:-1] for x in _RESET_PARAMS] # type:ignore[misc] - - -@pytest.mark.parametrize(("face_cache_reset_scenario", "expected"), - _RESET_PARAMS, - ids=_RESET_IDS, - indirect=["face_cache_reset_scenario"]) -def test_check_reset(face_cache_reset_scenario, expected): # pylint:disable=redefined-outer-name - """ Test that cache._check_reset functions as expected """ - this_cache = face_cache_reset_scenario - assert cache_mod._check_reset(this_cache) == expected - - -@pytest.mark.parametrize( - ("filenames", "size", "coverage_ratio", "centering"), - [(_DUMMY_IMAGE_LIST, 256, 1.0, "face"), - (_DUMMY_IMAGE_LIST[:-1], 96, .75, "head"), - (_DUMMY_IMAGE_LIST[2:], 384, .66, "legacy")]) -def test_Cache_init(filenames, size, coverage_ratio, centering, patch_config): # noqa[F811] - """ Test that cache.Cache correctly initializes """ - attrs = {"_lock": type(Lock()), - "_cache_info": dict, - "_config": cache_mod._CacheConfig, - "_partially_loaded": list, - "_image_count": int, - "_cache": dict, - "_aligned_landmarks": dict, - "_extract_version": float, - "_mask_prepare": cache_mod._MaskProcessing} - patch_config(cfg, _get_config(centering=centering)) - instance = cache_mod.Cache(filenames, size, coverage_ratio) - - for attr, attr_type in attrs.items(): - assert attr in instance.__dict__ - assert isinstance(getattr(instance, attr), attr_type) - for key in instance.__dict__: - assert key in attrs - - assert set(instance._cache_info) == {"cache_full", "has_reset"} - assert all(x is False for x in instance._cache_info.values()) - - assert not instance._partially_loaded - assert not instance._cache - assert instance._image_count == len(filenames) - assert not instance._aligned_landmarks - assert instance._extract_version == 0.0 - assert instance._config.size == size - assert instance._config.centering == centering - assert instance._config.coverage == coverage_ratio - - -def test_Cache_cache_full(mocker: pytest_mock.MockerFixture): - """ Test that cache.Cache.cache_full property behaves correctly """ - instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) - instance._lock = mocker.MagicMock() - - is_full1 = instance.cache_full - assert not is_full1 - instance._lock.__enter__.assert_called_once() # type:ignore[attr-defined] - instance._lock.__exit__.assert_called_once() # type:ignore[attr-defined] - - instance._cache_info["cache_full"] = True - is_full2 = instance.cache_full - assert is_full2 - # lock not called when cache is full - instance._lock.__enter__.assert_called_once() # type:ignore[attr-defined] - instance._lock.__exit__.assert_called_once() # type:ignore[attr-defined] - - -def test_Cache_aligned_landmarks(mocker: pytest_mock.MockerFixture): - """ Test that cache.Cache.aligned_landmarks property behaves correctly """ - instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) - instance._lock = mocker.MagicMock() - for fname in _DUMMY_IMAGE_LIST: - mock_face = mocker.MagicMock() - mock_face.aligned.landmarks = f"landmarks_for_{fname}" - instance._cache[fname] = mock_face - - retval1 = instance.aligned_landmarks - assert len(_DUMMY_IMAGE_LIST) == len(retval1) - assert retval1 == {fname: f"landmarks_for_{fname}" for fname in _DUMMY_IMAGE_LIST} - instance._lock.__enter__.assert_called_once() # type:ignore[attr-defined] - instance._lock.__exit__.assert_called_once() # type:ignore[attr-defined] - - retval2 = instance.aligned_landmarks - assert len(_DUMMY_IMAGE_LIST) == len(retval1) - assert retval2 == {fname: f"landmarks_for_{fname}" for fname in _DUMMY_IMAGE_LIST} - # lock not called after first call has populated - instance._lock.__enter__.assert_called_once() # type:ignore[attr-defined] - instance._lock.__exit__.assert_called_once() # type:ignore[attr-defined] - - -@pytest.mark.parametrize("size", (64, 96, 128, 256, 384)) -def test_Cache_size(size): - """ Test that cache.Cache.size property returns correctly """ - instance = cache_mod.Cache(_DUMMY_IMAGE_LIST, size, 1.0) - assert instance.size == size - - -def test_Cache_check_reset(): - """ Test that cache.Cache.check_reset behaves correctly """ - instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) - retval1 = instance.check_reset() - assert not retval1 - assert not instance._cache_info["has_reset"] - - instance._cache_info["has_reset"] = True - retval2 = instance.check_reset() - assert retval2 - assert not instance._cache_info["has_reset"] - - -@pytest.mark.parametrize("filenames", - (_DUMMY_IMAGE_LIST, _DUMMY_IMAGE_LIST[:-1], _DUMMY_IMAGE_LIST[2:])) -def test_Cache_get_items(filenames: list[str]) -> None: - """ Test that cache.Cache.get_items returns correctly """ - instance = cache_mod.Cache(filenames, 256, 1.0) - instance._cache = {os.path.basename(f): f"faces_for_{f}" # type:ignore[misc] - for f in filenames} - - retval = instance.get_items(filenames) - assert retval == [f"faces_for_{f}" for f in filenames] - - -@pytest.mark.parametrize("set_flag", (True, False), ids=("set-flag", "no-set-flag")) -def test_Cache_reset_cache(set_flag: bool, - mocker: pytest_mock.MockerFixture, - patch_config) -> None: # noqa[F811] - """ Test that cache.Cache._reset_cache functions correctly """ - patch_config(cfg, _get_config(centering="head")) - mock_warn = mocker.MagicMock() - mocker.patch(f"{MODULE_PREFIX}.logger.warning", mock_warn) - instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) - instance._cache = {"test": "cache"} # type:ignore[dict-item] - instance._cache_info["cache_full"] = True - - assert instance._config.centering != "legacy" - assert instance._cache - assert instance._cache_info["cache_full"] - - instance._reset_cache(set_flag) - - assert instance._config.centering == "legacy" - assert not instance._cache - assert instance._cache_info["cache_full"] is False - - if set_flag: - mock_warn.assert_called_once() - - -@pytest.mark.parametrize("version", (1.0, 2.0, 2.2), ids=("v1.0", "v2.0", "v2.2")) -def test_Cache_validate_version(version, mocker): - """ Test that cache.Cache._validate_version executes correctly """ - instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) - instance._reset_cache = mocker.MagicMock() - fname = "test_filename.png" - mock_meta = mocker.MagicMock() - mock_meta.source.alignments_version = version - if version == 1.0: - for centering in ("legacy", "face"): - instance._extract_version = 0.0 - instance._config.centering = centering - instance._validate_version(mock_meta, fname) - if centering == "legacy": - instance._reset_cache.assert_not_called() - else: - instance._reset_cache.assert_called_once_with(True) - assert instance._extract_version == version - else: - instance._validate_version(mock_meta, fname) - instance._reset_cache.assert_not_called() - assert instance._extract_version == version - - instance._extract_version = 1.0 # Legacy alignments have been seen - if version > 1.0: # Newer alignments inbound - with pytest.raises(FaceswapError): - instance._validate_version(mock_meta, fname) - else: - instance._validate_version(mock_meta, fname) - - instance._extract_version = 2.0 # Newer alignments have been seen - if version < 2.0: # Legacy alignments inbound - with pytest.raises(FaceswapError): - instance._validate_version(mock_meta, fname) - return # Exit early on 1.0 because cannot pass any more tests - - instance._validate_version(mock_meta, fname) - if version > 2.0: - assert instance._extract_version == 2.0 # Defaulted to lowest version - - instance._extract_version = 2.5 - instance._validate_version(mock_meta, fname) - assert instance._extract_version == version # Defaulted to lowest version - - -_DET_FACE_PARAMS = ((64, 0.5, 0, 1.0), - (96, 0.75, 1, 1.0), - (256, 0.66, 2, 2.0), - (384, 1.0, 3.0, 2.2)) -_DET_FACE_IDS = [f"size:{x[0]}|coverage:{x[1]}|y-offset:{x[2]}|extract-vers:{x[3]}" - for x in _DET_FACE_PARAMS] - - -@pytest.mark.parametrize(("size", "coverage", "y_offset", "extract_version"), - _DET_FACE_PARAMS, - ids=_DET_FACE_IDS) -def test_Cache_load_detected_face(size: int, - coverage: float, - y_offset: int | float, - extract_version: float, - mocker: pytest_mock.MockerFixture, - patch_config) -> None: # noqa[F811] - """ Test that cache.Cache._load_detected_faces executes correctly """ - patch_config(cfg, _get_config(vertical_offset=y_offset)) - instance = cache_mod.Cache(_DUMMY_IMAGE_LIST, size, coverage) - instance._extract_version = extract_version - alignments = {} # type:ignore[var-annotated] - - mock_det_face = mocker.MagicMock() - mock_det_face.from_png_meta = mocker.MagicMock() - mock_det_face.load_aligned = mocker.MagicMock() - mocker.patch(f"{MODULE_PREFIX}.DetectedFace", return_value=mock_det_face) - - retval = instance._load_detected_face("", alignments) # type:ignore[arg-type] - assert retval is mock_det_face - mock_det_face.from_png_meta.assert_called_once_with(alignments) - mock_det_face.load_aligned.assert_called_once_with(None, - size=instance._config.size, - centering=instance._config.centering, - coverage_ratio=instance._config.coverage, - y_offset=y_offset / 100., - is_aligned=True, - is_legacy=extract_version == 1.0) - - -@pytest.mark.parametrize("partially_loaded", (True, False), ids=("partial", "full")) -def test_Cache_populate_cache(partially_loaded: bool, - mocker: pytest_mock.MockerFixture) -> None: - """ Test that cache.Cache._populate_cache executes correctly """ - already_cached = ["/path/to/img4.png", "/path/img5.png"] - needs_cache = _DUMMY_IMAGE_LIST - filenames = _DUMMY_IMAGE_LIST + already_cached - - mock_meta = [mocker.MagicMock() for _ in range(len(filenames))] - for meta, fname in zip(mock_meta, filenames): - meta.alignments = f"{fname}_alignments" - - instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) - instance._validate_version = mocker.MagicMock() # type:ignore[method-assign] - instance._mask_prepare = mocker.MagicMock() - instance._cache = {os.path.basename(f): "existing" # type:ignore[misc] - for f in filenames if f not in needs_cache} - - mock_detected_faces = {f: mocker.MagicMock() for f in needs_cache} - - if partially_loaded: - instance._cache.update({os.path.basename(f): mock_detected_faces[f] for f in needs_cache}) - instance._partially_loaded = [os.path.basename(f) for f in filenames] # Add our partials - else: - instance._load_detected_face = mocker.MagicMock( # type:ignore[method-assign] - side_effect=[mock_detected_faces[f] for f in needs_cache]) - - # Call the function - instance._populate_cache(needs_cache, mock_meta, filenames) # type:ignore[arg-type] - - expected_validate = [mocker.call(mock_meta[idx], f) for idx, f in enumerate(needs_cache)] - instance._validate_version.assert_has_calls(expected_validate, # type:ignore[attr-defined] - any_order=False) - assert instance._validate_version.call_count == len(needs_cache) # type:ignore[attr-defined] - - expected_mask_prepare = [mocker.call(f, mock_detected_faces[f]) for f in needs_cache] - instance._mask_prepare.assert_has_calls(expected_mask_prepare, # type:ignore[attr-defined] - any_order=False) - assert instance._mask_prepare.call_count == len(needs_cache) # type:ignore[attr-defined] - - assert len(instance._cache) == len(filenames) - for filename in filenames: - key = os.path.basename(filename) - assert key in instance._cache - if filename in needs_cache: # item got added/updated - assert instance._cache[key] == mock_detected_faces[filename] - else: # item pre-existed - assert instance._cache[key] == "existing" - - if partially_loaded: - assert instance._partially_loaded == [os.path.basename(f) for f in filenames - if f not in needs_cache] - - -@pytest.mark.parametrize("scenario", ("read-error", "size-error", "success")) -def test_Cache_get_batch_with_metadata(scenario: str, mocker: pytest_mock.MockerFixture) -> None: - """ Test that cache.Cache._get_batch_with_metadata executes correctly """ - instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) - filenames = ["list", "of", "test", "filenames"] - - mock_read_image_batch = mocker.MagicMock() - if scenario == "read-error": - mock_read_image_batch.side_effect = ValueError("inhomogeneous") - else: - mock_return = (mocker.MagicMock(), {"test": "meta"}) - if scenario == "size-error": - mock_return[0].shape = (len(filenames), ) - else: - mock_return[0].shape = (len(filenames), 64, 64, 3) - mock_read_image_batch.return_value = mock_return - - mocker.patch(f"{MODULE_PREFIX}.read_image_batch", new=mock_read_image_batch) - - if scenario != "success": - with pytest.raises(FaceswapError): - instance._get_batch_with_metadata(filenames) - mock_read_image_batch.assert_called_once_with(filenames, with_metadata=True) - return - - retval = instance._get_batch_with_metadata(filenames) - mock_read_image_batch.assert_called_once_with(filenames, with_metadata=True) - assert retval == mock_return # pyright:ignore[reportPossiblyUnboundVariable] - - -@pytest.mark.parametrize("scenario", ("full", "not-full", "partial")) -def test_Cache_update_cache_full(scenario: bool, mocker: pytest_mock.MockerFixture) -> None: - """ Test that cache.Cache._update_cache_full executes correctly """ - mock_verbose = mocker.patch(f"{MODULE_PREFIX}.logger.verbose") - filenames = ["test", "file", "names"] - instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) - instance._image_count = 10 - - assert instance._cache_info["cache_full"] is False - assert not instance._cache - assert not instance._partially_loaded - - if scenario == "full": - instance._cache = {i: i for i in range(10)} # type:ignore[misc] - if scenario == "partial": - instance._cache = {i: i for i in range(10)} # type:ignore[misc] - instance._partially_loaded = filenames.copy() - - instance._update_cache_full(filenames) - - if scenario == "full": - assert instance._cache_info["cache_full"] is True - mock_verbose.assert_called_once() - else: - assert instance._cache_info["cache_full"] is False - mock_verbose.assert_not_called() - - -@pytest.mark.parametrize("scenario", ("full", "partial", "empty", "needs-reset")) -def test_Cache_cache_metadata(scenario: str, mocker: pytest_mock.MockerFixture) -> None: - """ Test that cache.Cache.cache_metadata executes correctly """ - mock_check_reset = mocker.patch(f"{MODULE_PREFIX}._check_reset") - mock_check_reset.return_value = scenario == "needs-reset" - mock_return_batch = mocker.MagicMock() - - mock_read_image_batch = mocker.patch(f"{MODULE_PREFIX}.read_image_batch", - return_value=mock_return_batch) - - instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) - filenames = _DUMMY_IMAGE_LIST.copy() - - if scenario in ("full", "partial"): - instance._cache = {os.path.basename(f): f for f in filenames} # type:ignore[misc] - if scenario == "partial": - instance._partially_loaded = [os.path.basename(f) for f in filenames] - - instance._lock = mocker.MagicMock() - instance._reset_cache = mocker.MagicMock() # type:ignore[method-assign] - returned_meta = {"test": "meta"} - instance._get_batch_with_metadata = mocker.MagicMock( # type:ignore[method-assign] - return_value=(mock_return_batch, returned_meta)) - instance._populate_cache = mocker.MagicMock() # type:ignore[method-assign] - instance._update_cache_full = mocker.MagicMock() # type:ignore[method-assign] - - retval = instance.cache_metadata(filenames) # Call - - instance._lock.__enter__.assert_called_once() # type:ignore[attr-defined] - instance._lock.__exit__.assert_called_once() # type:ignore[attr-defined] - - mock_check_reset.assert_called_once_with(instance) - - if scenario == "needs-reset": - instance._reset_cache.assert_called_once_with(False) # type:ignore[attr-defined] - else: - instance._reset_cache.assert_not_called() # type:ignore[attr-defined] - - if scenario == "full": - mock_read_image_batch.assert_called_once_with(filenames) - instance._get_batch_with_metadata.assert_not_called() # type:ignore[attr-defined] - instance._populate_cache.assert_not_called() # type:ignore[attr-defined] - instance._update_cache_full.assert_not_called() # type:ignore[attr-defined] - else: - mock_read_image_batch.assert_not_called() - instance._get_batch_with_metadata.assert_called_once_with( # type:ignore[attr-defined] - filenames) - instance._populate_cache.assert_called_once_with( # type:ignore[attr-defined] - filenames, returned_meta, filenames) - instance._update_cache_full.assert_called_once_with(filenames) # type:ignore[attr-defined] - - assert retval is mock_return_batch - - -@pytest.mark.parametrize("scenario", ("fail-meta", "fail-landmarks", "success")) -def test_Cache_pre_fill(scenario: str, - mocker: pytest_mock.MockerFixture, - monkeypatch: pytest.MonkeyPatch) -> None: - """ Test that cache.Cache.prefill executes correctly """ - filenames = _DUMMY_IMAGE_LIST.copy() - mock_read_image_batch = mocker.patch(f"{MODULE_PREFIX}.read_image_meta_batch") - side_effect_read_image_batch = [(f, {}) for f in filenames] # type:ignore[var-annotated] - - png_mock = mocker.MagicMock() - monkeypatch.setattr("lib.training.cache.PNGHeader.from_dict", lambda x: png_mock) - - if scenario != "fail-meta": # Set successful return data - for effect in side_effect_read_image_batch: - effect[1]["itxt"] = {"alignments": [1, 2, 3]} - mock_read_image_batch.side_effect = [side_effect_read_image_batch] - - instance = cache_mod.Cache(*STANDARD_CACHE_ARGS) - instance._lock = mocker.MagicMock() - instance._validate_version = mocker.MagicMock() # type:ignore[method-assign] - mock_detected_faces = [mocker.MagicMock() for _ in filenames] - - for m in mock_detected_faces: - m.aligned.landmark_type = (LandmarkType.LM_2D_68 if scenario == "success" else "fail") - instance._load_detected_face = mocker.MagicMock( # type:ignore[method-assign] - side_effect=mock_detected_faces) - - if scenario in ("fail-meta", "fail-landmarks"): - with pytest.raises(FaceswapError): - instance.pre_fill(filenames, "a") - instance._lock.__enter__.assert_called_once() # type:ignore[attr-defined] - instance._lock.__exit__.assert_called_once() # type:ignore[attr-defined] - mock_read_image_batch.assert_called_once_with(filenames) - if scenario == "fail-meta": - instance._validate_version.assert_not_called() # type:ignore[attr-defined] - instance._load_detected_face.assert_not_called() # type:ignore[attr-defined] - else: - instance._validate_version.assert_called_once_with( # type:ignore[attr-defined] - png_mock, filenames[0]) - instance._load_detected_face.assert_called_once_with( # type:ignore[attr-defined] - filenames[0], png_mock.alignments) - return - - # success - instance.pre_fill(filenames, "a") - instance._lock.__enter__.assert_called_once() # type:ignore[attr-defined] - instance._lock.__exit__.assert_called_once() # type:ignore[attr-defined] - mock_read_image_batch.assert_called_once_with(filenames) - - instance._validate_version.assert_has_calls( # type:ignore[attr-defined] - png_mock, any_order=False) # type:ignore[attr-defined] - assert instance._validate_version.call_count == len(filenames) # type:ignore[attr-defined] - instance._load_detected_face.assert_has_calls( # type:ignore[attr-defined] - png_mock.alignments, any_order=False) # type:ignore[attr-defined] - assert instance._load_detected_face.call_count == len(filenames) # type:ignore[attr-defined] - - assert instance._cache == {os.path.basename(f): d for f, d in zip(filenames, - mock_detected_faces)} - assert instance._partially_loaded == [os.path.basename(f) for f in filenames] - - -_PARAMS_GET = (("a", _DUMMY_IMAGE_LIST, 256, 1.), - ("b", _DUMMY_IMAGE_LIST, 256, 1.), - ("c", _DUMMY_IMAGE_LIST, 256, 1.), - ("a", None, 256, 1,), - ("a", _DUMMY_IMAGE_LIST, None, 1.), - ("a", _DUMMY_IMAGE_LIST, 256, None)) -_IDS_GET = ("pass-a", "pass-b", "fail-side", "fail-no-filenames", - "fail-no-size", "fail-no-coverage") - - -@pytest.mark.parametrize(("side", "filenames", "size", "coverage_ratio", "status"), - (x + (y,) for x, y in zip(_PARAMS_GET, _IDS_GET)), - ids=_IDS_GET) -def test_get_cache_initial(side: str, - filenames: list[str], - size: int, - coverage_ratio: float, - status: str, - mocker: pytest_mock.MockerFixture) -> None: - """ Test cache.get_cache function when the cache does not yet exist """ - mocker.patch(f"{MODULE_PREFIX}._FACE_CACHES", new={}) - patched_cache = mocker.patch(f"{MODULE_PREFIX}.Cache") - if status.startswith("fail"): - with pytest.raises(AssertionError): - cache_mod.get_cache(side, filenames, size, coverage_ratio) # type:ignore[arg-type] - patched_cache.assert_not_called() - return - - retval = cache_mod.get_cache(side, filenames, size, coverage_ratio) # type:ignore[arg-type] - assert side in cache_mod._FACE_CACHES - patched_cache.assert_called_once_with(filenames, size, coverage_ratio) - assert cache_mod._FACE_CACHES[side] is patched_cache.return_value - assert retval is patched_cache.return_value - - retval2 = cache_mod.get_cache(side, filenames, size, coverage_ratio) # type:ignore[arg-type] - patched_cache.assert_called_once() # Not called again - assert retval2 is retval - - -_IDS_GET2 = ("pass-a", "pass-b", "fail-side", "pass-no-filenames", - "pass-no-size", "pass-no-coverage") - - -@pytest.mark.parametrize(("side", "filenames", "size", "coverage_ratio", "status"), - (x + (y,) for x, y in zip(_PARAMS_GET, _IDS_GET2)), - ids=_IDS_GET2) -def test_get_cache_exists(side: str, - filenames: list[str], - size: int, - coverage_ratio: float, - status: str, - mocker: pytest_mock.MockerFixture) -> None: - """ Test cache.get_cache function when the cache exists """ - mocker.patch(f"{MODULE_PREFIX}._FACE_CACHES", new={"a": mocker.MagicMock(), - "b": mocker.MagicMock()}) - patched_cache = mocker.patch(f"{MODULE_PREFIX}.Cache") - - if status.startswith("fail"): - with pytest.raises(AssertionError): - cache_mod.get_cache(side, filenames, size, coverage_ratio) # type:ignore[arg-type] - patched_cache.assert_not_called() - return - - retval = cache_mod.get_cache(side, filenames, size, coverage_ratio) # type:ignore[arg-type] - patched_cache.assert_not_called() - assert retval is cache_mod._FACE_CACHES[side] - - -# ## Ring Buffer ## # - -_RING_BUFFER_PARAMS = ((2, (384, 384, 3), 2, "uint8"), - (16, (128, 128, 3), 5, "float32"), - (32, (64, 64, 3), 4, "int32")) -_RING_BUFFER_IDS = [f"bs{x[0]}|{x[1][0]}px|buffer-size{x[2]}|dtype-{x[3]}" - for x in _RING_BUFFER_PARAMS] - - -@pytest.mark.parametrize(("batch_size", "image_shape", "buffer_size", "dtype"), - ((2, (384, 384, 3), 2, "uint8"), - (16, (128, 128, 3), 5, "float32"), - (32, (64, 64, 3), 4, "int32")), - ids=_RING_BUFFER_IDS) -def test_RingBuffer_init(batch_size, image_shape, buffer_size, dtype): - """ test cache.RingBuffer initializes correctly """ - attrs = {"_max_index": int, "_index": int, "_buffer": list} - instance = cache_mod.RingBuffer(batch_size, image_shape, buffer_size, dtype) - - for attr, attr_type in attrs.items(): - assert attr in instance.__dict__ - assert isinstance(getattr(instance, attr), attr_type) - for key in instance.__dict__: - assert key in attrs - - assert instance._max_index == buffer_size - 1 - assert instance._index == 0 - assert len(instance._buffer) == buffer_size - assert all(isinstance(b, np.ndarray) for b in instance._buffer) - assert all(b.shape == (batch_size, *image_shape) for b in instance._buffer) - assert all(b.dtype == dtype for b in instance._buffer) - - -@pytest.mark.parametrize(("batch_size", "image_shape", "buffer_size", "dtype"), - ((2, (384, 384, 3), 2, "uint8"), - (16, (128, 128, 3), 5, "float32"), - (32, (64, 64, 3), 4, "int32")), - ids=_RING_BUFFER_IDS) -def test_RingBuffer_call(batch_size, image_shape, buffer_size, dtype): - """ Test calling cache.RingBuffer works correctly """ - instance = cache_mod.RingBuffer(batch_size, image_shape, buffer_size, dtype) - for i in range(buffer_size * 3): - retval = instance() - assert isinstance(retval, np.ndarray) - assert retval.shape == (batch_size, *image_shape) - assert retval.dtype == dtype - if i % buffer_size == buffer_size - 1: - assert instance._index == 0 - else: - assert instance._index == i % buffer_size + 1 diff --git a/tests/lib/training/augmentation_test.py b/tests/lib/training/data_augmentation_test.py similarity index 94% rename from tests/lib/training/augmentation_test.py rename to tests/lib/training/data_augmentation_test.py index 45e238c70d..1faff34bf9 100644 --- a/tests/lib/training/augmentation_test.py +++ b/tests/lib/training/data_augmentation_test.py @@ -1,5 +1,5 @@ #!/usr/bin python3 -""" Pytest unit tests for :mod:`lib.training.augmentation` """ +""" Pytest unit tests for :mod:`lib.training.data_augmentation` """ import typing as T import cv2 @@ -8,8 +8,8 @@ import pytest_mock from lib.config import ConfigValueType -from lib.training.augmentation import (ConstantsAugmentation, ConstantsColor, ConstantsTransform, - ConstantsWarp, ImageAugmentation) +from lib.training.data_augmentation import ( + ConstantsAugmentation, ConstantsColor, ConstantsTransform, ConstantsWarp, ImageAugmentation) from plugins.train.trainer import trainer_config as cfg # pylint:disable=unused-import @@ -18,7 +18,7 @@ # pylint:disable=protected-access,redefined-outer-name -MODULE_PREFIX = "lib.training.augmentation" +MODULE_PREFIX = "lib.training.data_augmentation" # CONSTANTS # @@ -33,7 +33,7 @@ def test_constants_get_clahe(config: dict[str, T.Any], size: int, patch_config) -> None: # noqa[F811] """ Test ConstantsAugmentation._get_clahe works as expected """ - patch_config(cfg, config) + patch_config(cfg.Augmentation, config) contrast, chance, max_size = ConstantsAugmentation._get_clahe(size) assert isinstance(contrast, int) assert isinstance(chance, float) @@ -51,7 +51,7 @@ def test_constants_get_clahe(config: dict[str, T.Any], @pytest.mark.parametrize(("config"), _LAB_CONF) def test_constants_get_lab(config: dict[str, T.Any], patch_config) -> None: # noqa[F811] """ Test ConstantsAugmentation._get_lab works as expected """ - patch_config(cfg, config) + patch_config(cfg.Augmentation, config) lab_adjust = ConstantsAugmentation._get_lab() assert isinstance(lab_adjust, np.ndarray) assert lab_adjust.dtype == np.float32 @@ -72,7 +72,7 @@ def test_constants_get_color(config: dict[str, T.Any], patch_config, # noqa[F811] mocker: pytest_mock.MockerFixture) -> None: """ Test ConstantsAugmentation._get_color works as expected """ - patch_config(cfg, config) + patch_config(cfg.Augmentation, config) clahe_mock = mocker.patch(f"{MODULE_PREFIX}.ConstantsAugmentation._get_clahe", return_value=(1, 2.0, 3)) lab_mock = mocker.patch(f"{MODULE_PREFIX}.ConstantsAugmentation._get_lab", @@ -106,7 +106,7 @@ def test_constants_get_transform(config: dict[str, T.Any], size: int, patch_config) -> None: # noqa[F811] """ Test ConstantsAugmentation._get_transform works as expected """ - patch_config(cfg, config) + patch_config(cfg.Augmentation, config) transform = ConstantsAugmentation._get_transform(size) assert isinstance(transform, ConstantsTransform) assert isinstance(transform.rotation, int) @@ -199,7 +199,7 @@ def test_constants_from_config(size: int, mocker: pytest_mock.MockerFixture ) -> None: """ Test that ConstantsAugmentation.from_config executes correctly """ - patch_config(cfg, _CONFIG) + patch_config(cfg.Augmentation, _CONFIG) constants = ConstantsAugmentation.from_config(size, batch_size) assert isinstance(constants, ConstantsAugmentation) assert isinstance(constants.color, ConstantsColor) @@ -231,7 +231,7 @@ def test_image_augmentation_init(size: int, batch_size: int, patch_config) -> None: # noqa[F811] """ Test ImageAugmentation initializes """ - patch_config(cfg, _CONFIG) + patch_config(cfg.Augmentation, _CONFIG) attrs = {"_processing_size": int, "_batch_size": int, "_constants": ConstantsAugmentation} @@ -252,7 +252,7 @@ def test_image_augmentation_random_lab(size: int, patch_config, # noqa[F811] mocker: pytest_mock.MockerFixture) -> None: """ Test that ImageAugmentation._random_lab executes as expected """ - patch_config(cfg, _CONFIG) + patch_config(cfg.Augmentation, _CONFIG) batch = get_batch(batch_size, size) original = batch.copy() instance = get_instance(batch_size, size) @@ -274,7 +274,7 @@ def test_image_augmentation_random_clahe(size: int, # pylint:disable=too-many-l mocker: pytest_mock.MockerFixture) -> None: """ Test that ImageAugmentation._random_clahe executes as expected """ # Expected output - patch_config(cfg, _CONFIG) + patch_config(cfg.Augmentation, _CONFIG) batch = get_batch(batch_size, size) original = batch.copy() instance = get_instance(batch_size, size) @@ -324,7 +324,7 @@ def test_image_augmentation_color_adjust(size: int, patch_config, # noqa[F811] mocker: pytest_mock.MockerFixture) -> None: """ Test that ImageAugmentation._color_adjust executes as expected """ - patch_config(cfg, _CONFIG) + patch_config(cfg.Augmentation, _CONFIG) batch = get_batch(batch_size, size) output = get_instance(batch_size, size).color_adjust(batch) assert output.shape == batch.shape @@ -349,11 +349,11 @@ def test_image_augmentation_transform(size: int, patch_config, # noqa[F811] mocker: pytest_mock.MockerFixture) -> None: """ Test that ImageAugmentation.transform executes as expected """ - patch_config(cfg, _CONFIG) + patch_config(cfg.Augmentation, _CONFIG) batch = get_batch(batch_size, size) instance = get_instance(batch_size, size) original = batch.copy() - instance.transform(batch) + instance.transform(batch, None) assert original.shape == batch.shape assert original.dtype == batch.dtype @@ -374,17 +374,17 @@ def test_image_augmentation_transform(size: int, rand_mock = mocker.patch(f"{MODULE_PREFIX}.np.random.uniform", side_effect=rand_ret) - rotmat_mock = mocker.patch( - f"{MODULE_PREFIX}.cv2.getRotationMatrix2D", - return_value=np.array([[1.0, 0.0, -2.0], [-1.0, 1.0, 5.0]]).astype("float32")) + rotmap_ret = np.random.random((batch_size, 3, 3)).astype(np.float32) + rotmat_mock = mocker.patch(f"{MODULE_PREFIX}.batch_create_matrices", + return_value=rotmap_ret) affine_mock = mocker.patch(f"{MODULE_PREFIX}.cv2.warpAffine") batch = get_batch(batch_size, size) - get_instance(batch_size, size).transform(batch) + get_instance(batch_size, size).transform(batch, None) rand_mock.assert_has_calls(rand_calls) # type:ignore - assert rotmat_mock.call_count == batch_size + rotmat_mock.assert_called_once() assert affine_mock.call_count == batch_size @@ -394,10 +394,10 @@ def test_image_augmentation_random_flip(size: int, patch_config, # noqa[F811] mocker: pytest_mock.MockerFixture) -> None: """ Test that ImageAugmentation.flip_chance executes as expected """ - patch_config(cfg, _CONFIG) + patch_config(cfg.Augmentation, _CONFIG) batch = get_batch(batch_size, size) original = batch.copy() - get_instance(batch_size, size).random_flip(batch) + get_instance(batch_size, size).random_flip(batch, None) assert original.shape == batch.shape assert original.dtype == batch.dtype @@ -408,7 +408,7 @@ def test_image_augmentation_random_flip(size: int, where_mock = mocker.patch(f"{MODULE_PREFIX}.np.where") batch = get_batch(batch_size, size) - get_instance(batch_size, size).random_flip(batch) + get_instance(batch_size, size).random_flip(batch, None) rand_mock.assert_called_once_with(batch_size) where_mock.assert_called_once() diff --git a/tests/plugins/train/trainer/test_distributed.py b/tests/plugins/train/trainer/test_distributed.py index 0d913377b5..10479b67ea 100644 --- a/tests/plugins/train/trainer/test_distributed.py +++ b/tests/plugins/train/trainer/test_distributed.py @@ -1,6 +1,6 @@ #!/usr/bin python3 """ Pytest unit tests for :mod:`plugins.train.trainer.distributed` Trainer plug in """ -# pylint:disable=protected-access, invalid-name +# pylint:disable=protected-access, invalid-name, duplicate-code, too-many-locals import numpy as np import pytest @@ -9,7 +9,7 @@ from plugins.train.trainer import distributed as mod_distributed from plugins.train.trainer import original as mod_original -from plugins.train.trainer import _base as mod_base +from plugins.train.trainer import base as mod_base _MODULE_PREFIX = "plugins.train.trainer.distributed" @@ -18,7 +18,7 @@ @pytest.mark.parametrize("batch_size", (4, 8, 16, 32, 64)) @pytest.mark.parametrize("outputs", (1, 2, 4)) def test_WrappedModel(batch_size, outputs, mocker): - """ Test that the wrapped model calls preds and loss """ + """ Test that the wrapped model calls predictions and loss """ model = mocker.MagicMock() instance = mod_distributed.WrappedModel(model) assert instance._keras_model is model @@ -32,9 +32,9 @@ def test_WrappedModel(batch_size, outputs, mocker): inp_b = torch.from_numpy(np.random.random(test_dims)) targets = [torch.from_numpy(np.random.random(test_dims)) for _ in range(outputs * 2)] - preds = [*torch.from_numpy(np.random.random((outputs * 2, *test_dims)))] + predictions = [*torch.from_numpy(np.random.random((outputs * 2, *test_dims)))] - model.return_value = preds + model.return_value = predictions # Call forwards result = instance.forward(inp_a, inp_b, *targets) @@ -54,7 +54,7 @@ def test_WrappedModel(batch_size, outputs, mocker): # Confirm loss functions correctly called expected_targets = targets[0::2] + targets[1::2] - for target, pred, loss in zip(expected_targets, preds, model.loss): + for target, pred, loss in zip(expected_targets, predictions, model.loss): loss.assert_called_once() loss_args, loss_kwargs = loss.call_args assert not loss_kwargs @@ -77,7 +77,13 @@ def _apply_patch(gpus=2, batch_size=8): patched_parallel = mocker.patch(f"{_MODULE_PREFIX}.torch.nn.DataParallel") patched_parallel.return_value = mocker.MagicMock() model = mocker.MagicMock() - instance = mod_distributed.Trainer(model, batch_size) + conf = mod_base.TrainConfig(folders=["x", "y"], + batch_size=batch_size, + augment_color=False, + flip=False, + warp=False, + cache_landmarks=False) + instance = mod_distributed.Trainer(model, conf) return instance, patched_parallel return _apply_patch @@ -106,10 +112,11 @@ def test_Trainer_forward(gpu_count, batch_size, outputs, _trainer_mocked, mocker test_dims = (2, batch_size, 16, 16, 3) - inputs = torch.from_numpy(np.random.random(test_dims)) - targets = [torch.from_numpy(np.random.random(test_dims)) for _ in range(outputs)] + inputs = torch.from_numpy(np.random.random(test_dims)).to("cpu") + targets = [torch.from_numpy(np.random.random(test_dims)).to("cpu") + for _ in range(outputs)] - loss_return = torch.rand((gpu_count * 2 * outputs)) + loss_return = torch.rand((gpu_count * 2 * outputs), device="cpu") instance._distributed_model = mocker.MagicMock(return_value=loss_return) # Call the forward pass @@ -127,9 +134,9 @@ def test_Trainer_forward(gpu_count, batch_size, outputs, _trainer_mocked, mocker assert not call_kwargs assert len(call_args) == len(inputs) + (len(targets) * 2) - expected_tgts = [t[i].cpu().numpy() for t in targets for i in range(2)] + expected_tgt = [t[i].cpu().numpy() for t in targets for i in range(2)] - for expected, actual in zip([*inputs, *expected_tgts], call_args): + for expected, actual in zip([*inputs, *expected_tgt], call_args): assert np.allclose(expected, actual) # Make sure loss gets grouped, summed and scaled correctly diff --git a/tests/plugins/train/trainer/test_original.py b/tests/plugins/train/trainer/test_original.py index 983e691948..7e6f70420a 100644 --- a/tests/plugins/train/trainer/test_original.py +++ b/tests/plugins/train/trainer/test_original.py @@ -8,7 +8,7 @@ import torch from plugins.train.trainer import original as mod_original -from plugins.train.trainer import _base as mod_base +from plugins.train.trainer import base as mod_base @pytest.fixture @@ -17,7 +17,13 @@ def _trainer_mocked(mocker: pytest_mock.MockFixture): # noqa:[F811] def _apply_patch(batch_size=8): model = mocker.MagicMock() - instance = mod_original.Trainer(model, batch_size) + conf = mod_base.TrainConfig(folders=["x", "y"], + batch_size=batch_size, + augment_color=False, + flip=False, + warp=False, + cache_landmarks=False) + instance = mod_original.Trainer(model, conf) return instance return _apply_patch @@ -56,9 +62,9 @@ def test_Trainer_forward(batch_size, # pylint:disable=too-many-locals instance = _trainer_mocked(batch_size=batch_size) loss_returns = [torch.from_numpy(np.random.random((1, ))) for _ in range(outputs * 2)] - mock_preds = [torch.from_numpy(np.random.random((batch_size, 16, 16, 3))) - for _ in range(outputs * 2)] - instance.model.model.return_value = mock_preds + mock_predictions = [torch.from_numpy(np.random.random((batch_size, 16, 16, 3))) + for _ in range(outputs * 2)] + instance.model.model.return_value = mock_predictions instance.model.model.zero_grad = mocker.MagicMock() instance.model.model.loss = [mocker.MagicMock(return_value=ret) for ret in loss_returns] @@ -87,8 +93,8 @@ def test_Trainer_forward(batch_size, # pylint:disable=too-many-locals # losses called with targets split loss_calls = instance.model.model.loss expected_targets = [t[i].numpy() for i in range(2) for t in targets] - expected_preds = [p.numpy() for p in mock_preds] - for loss_call, pred, target in zip(loss_calls, expected_preds, expected_targets): + expected_predictions = [p.numpy() for p in mock_predictions] + for loss_call, pred, target in zip(loss_calls, expected_predictions, expected_targets): loss_call.assert_called_once() call_args, call_kwargs = loss_call.call_args assert not call_kwargs diff --git a/tests/tools/alignments/media_test.py b/tests/tools/alignments/media_test.py index 69bc6a1b5c..8d5752ed10 100644 --- a/tests/tools/alignments/media_test.py +++ b/tests/tools/alignments/media_test.py @@ -440,10 +440,17 @@ def test_process_folder(self, read_image_meta_mock = mocker.patch("tools.alignments.media.read_image_meta_batch") img_sources = [os.path.join(faces.folder, fname) for fname in os.listdir(faces.folder)] - meta_data = {"itxt": {"source": ({"source_filename": "data.png"})}} + meta_data = {"itxt": {"source": ({"source_filename": "data.png", + "alignments_version": 2.5, + "face_index": 0, + "original_filename": "data.png", + "source_is_video": False, + "source_frame_dims": (1280, 720)}), + "alignments": {"x": 1, "y": 2, "w": 3, "h": 4, + "landmarks_xy": [[0.0, 1.1], [1.1, 2.2]]}}} png_mock = mocker.MagicMock() png_mock.source.source_filename = "data.png" - monkeypatch.setattr("lib.training.cache.PNGHeader.from_dict", lambda x: png_mock) + monkeypatch.setattr("tools.alignments.media.PNGHeader.from_dict", lambda x: png_mock) expected = [(fname, png_mock) for fname in os.listdir(faces.folder)] read_image_meta_mock.side_effect = [[(src, meta_data) for src in img_sources]] diff --git a/tools/sort/sort_methods.py b/tools/sort/sort_methods.py index 5411b69890..aada0f7417 100644 --- a/tools/sort/sort_methods.py +++ b/tools/sort/sort_methods.py @@ -282,7 +282,7 @@ def _mask_face(cls, image: np.ndarray, alignments: PNGAlignments) -> np.ndarray: if aln_face.landmark_type != LandmarkType.LM_2D_68: mask = None else: - mask = det_face.get_landmark_mask("face", 0, 0) + mask = det_face.get_landmark_mask("face") if mask is None and not cls._log_mask_once: logger.warning("Masks cannot be generated for the available landmark types. Results " From e6ae291cd421f9efea24d0ac126faacf3b3b3af9 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 20 Apr 2026 09:15:26 +0100 Subject: [PATCH 961/981] lpips: To torch + crop feature map zero-padding --- docs/full/lib/model.rst | 3 - lib/model/losses/feature_loss.py | 451 +++++++++++--------- lib/model/networks/__init__.py | 1 - lib/model/networks/simple_nets.py | 217 ---------- plugins/train/model/_base/model.py | 32 +- plugins/train/model/_base/settings.py | 20 +- tests/lib/model/losses/feature_loss_test.py | 9 +- 7 files changed, 300 insertions(+), 433 deletions(-) delete mode 100644 lib/model/networks/simple_nets.py diff --git a/docs/full/lib/model.rst b/docs/full/lib/model.rst index c78aade8e8..84e8d49e33 100755 --- a/docs/full/lib/model.rst +++ b/docs/full/lib/model.rst @@ -33,9 +33,6 @@ networks package :include-all-objects: :noindex: -| -.. automodapi:: lib.model.networks.simple_nets - :include-all-objects: model package ============= diff --git a/lib/model/losses/feature_loss.py b/lib/model/losses/feature_loss.py index 9e96841481..71bd774a3a 100644 --- a/lib/model/losses/feature_loss.py +++ b/lib/model/losses/feature_loss.py @@ -1,61 +1,86 @@ #!/usr/bin/env python3 -""" Custom Feature Map Loss Functions for faceswap.py """ +"""Custom Feature Map Loss Functions for faceswap.py""" from __future__ import annotations from dataclasses import dataclass, field import logging import typing as T -import keras -from keras import applications as kapp, layers, Model, ops, Variable - -import numpy as np +import torch +from torch import nn +from torchvision.models import alexnet, squeezenet1_1, vgg16, feature_extraction from lib.logger import parse_class_init -from lib.model.networks import AlexNet, SqueezeNet from lib.utils import get_module_objects, GetModel if T.TYPE_CHECKING: from collections.abc import Callable - from keras import KerasTensor logger = logging.getLogger(__name__) @dataclass class NetInfo: - """ Data class for holding information about Trunk and Linear Layer nets. + """Data class for holding information about Trunk and Linear Layer nets. Parameters ---------- - model_id: int + model_id The model ID for the model stored in the deepfakes Model repo - model_name: str + model_name The filename of the decompressed model/weights file - net: callable, Optional + net The net definition to load, if any. Default:``None`` - init_kwargs: dict, optional - Keyword arguments to initialize any :attr:`net`. Default: empty ``dict`` - needs_init: bool, optional - True if the net needs initializing otherwise False. Default: ``True`` + outputs + For trunk networks the name of the output feature layers. For linear networks the number of + input channels to each layer + pad_amount + For trunk networks, the amount of zero padding applied to each feature output """ model_id: int = 0 model_name: str = "" net: Callable | None = None - init_kwargs: dict[str, T.Any] = field(default_factory=dict) - needs_init: bool = True - outputs: list[str] = field(default_factory=list) - - -class _LPIPSTrunkNet(): - """ Trunk neural network loader for LPIPS Loss function. + outputs: list[str] | list[int] = field(default_factory=list) + pad_amount: list[int] | int = 0 + + +_NETS = {"alex": NetInfo(model_id=15, + model_name="alexnet_imagenet_no_top_v2.pth", + net=alexnet, + outputs=[f"features.{i}" for i in (0, 3, 6, 8, 10)], + pad_amount=[2, 2, 1, 1, 1]), + "squeeze": NetInfo(model_id=16, + model_name="squeezenet_imagenet_no_top_v2.pth", + net=squeezenet1_1, + outputs=[f"features.{i}" for i in (0, 4, 7, 9, 10, 11, 12)], + pad_amount=1), + "vgg16": NetInfo(model_id=17, + model_name="vgg16_imagenet_no_top_v2.pth", + net=vgg16, + outputs=[f"features.{i}" for i in (2, 7, 14, 21, 29)], + pad_amount=1)} + +_LINEAR = {"alex": NetInfo(model_id=18, + model_name="alexnet_lpips_v2.pth", + outputs=[64, 192, 384, 256, 256]), + "squeeze": NetInfo(model_id=19, + model_name="squeezenet_lpips_v2.pth", + outputs=[64, 128, 256, 384, 384, 512, 512]), + "vgg16": NetInfo(model_id=20, + model_name="vgg16_lpips_v2.pth", + outputs=[64, 128, 256, 512, 512])} + + +class _LPIPSTrunkNet(nn.Module): + """Trunk neural network loader for LPIPS Loss function. Loads the trunk network and the + weights and selects the feature layers for output Parameters ---------- - net_name: str + net_name The name of the trunk network to load. One of "alex", "squeeze" or "vgg16" - eval_mode: bool + eval_mode ``True`` for evaluation mode, ``False`` for training mode - load_weights: bool + load_weights ``True`` if pretrained trunk network weights should be loaded, otherwise ``False`` """ def __init__(self, @@ -63,33 +88,48 @@ def __init__(self, eval_mode: bool, load_weights: bool) -> None: logger.debug(parse_class_init(locals())) + super().__init__() + self._net_name = net_name self._eval_mode = eval_mode self._load_weights = load_weights self._net_name = net_name - self._net = self._nets[net_name] + self.net = self._get_net() logger.debug("Initialized: %s ", self.__class__.__name__) - @property - def _nets(self) -> dict[str, NetInfo]: - """ :class:`NetInfo`: The Information about the requested net.""" - return { - "alex": NetInfo(model_id=15, - model_name="alexnet_imagenet_no_top_v1.h5", - net=AlexNet, - outputs=[f"features_{idx}" for idx in (0, 3, 6, 8, 10)]), - "squeeze": NetInfo(model_id=16, - model_name="squeezenet_imagenet_no_top_v1.h5", - net=SqueezeNet, - outputs=[f"features_{idx}" for idx in (0, 4, 7, 9, 10, 11, 12)]), - "vgg16": NetInfo(model_id=17, - model_name="vgg16_imagenet_no_top_v1.h5", - net=kapp.vgg16.VGG16, - init_kwargs={"include_top": False, "weights": None}, - outputs=[f"block{i + 1}_conv{2 if i < 2 else 3}" for i in range(5)])} + def __repr__(self) -> str: + """Pretty print for logging""" + _repr = super().__repr__() + params = ", ".join(f"{k[1:]}={repr(v)}" for k, v in self.__dict__.items() + if k.startswith(("_net_name", "_eval_mode", "_load_weights"))) + pfx = f"{self.__class__.__name__}(" + return f"{pfx}{params})({_repr[len(pfx):]}" + + def _get_net(self) -> nn.Module: + """Load the trunk, set the weights and feature outputs + + Returns + ------- + The loaded trunk network with feature extractor outputs set + """ + net_info = _NETS[self._net_name] + model_def = net_info.net + assert model_def is not None + net = feature_extraction.create_feature_extractor(model_def(), + return_nodes=T.cast(list[str], + net_info.outputs)) + if self._load_weights: + weights_path = GetModel(net_info.model_name, net_info.model_id).model_path + assert isinstance(weights_path, str) + weights = torch.load(weights_path) + net.load_state_dict(weights) + + if self._eval_mode: + net.eval() + return net @classmethod - def _normalize_output(cls, inputs: KerasTensor, epsilon: float = 1e-10) -> KerasTensor: - """ Normalize the output tensors from the trunk network. + def _normalize_output(cls, inputs: torch.Tensor, epsilon: float = 1e-10) -> torch.Tensor: + """Normalize the output tensors from the trunk network. Parameters ---------- @@ -98,140 +138,99 @@ def _normalize_output(cls, inputs: KerasTensor, epsilon: float = 1e-10) -> Keras epsilon: float, optional Epsilon to apply to the normalization operation. Default: `1e-10` """ - norm_factor = ops.sqrt(ops.sum(ops.square(inputs), axis=-1, keepdims=True)) + norm_factor = torch.sqrt(torch.sum(torch.square(inputs), dim=1, keepdim=True)) return inputs / (norm_factor + epsilon) - def _process_weights(self, model: Model) -> Model: - """ Save and lock weights if requested. - - Parameters - ---------- - model :class:`keras.models.Model` - The loaded trunk or linear network + def forward(self, inputs: torch.Tensor) -> list[torch.Tensor]: + """Obtain the normalized features from the trunk net Returns ------- - :class:`keras.models.Model` - The network with weights loaded/not loaded and layers locked/unlocked + The normalized feature outputs from the trunk net """ - if self._load_weights: - weights = GetModel(self._net.model_name, self._net.model_id).model_path - model.load_weights(weights) - - if self._eval_mode: - model.trainable = False - for layer in model.layers: - layer.trainable = False - return model - - def __call__(self) -> Model: - """ Load the Trunk net, add normalization to feature outputs, load weights and set - trainable state. - - Returns - ------- - :class:`keras.models.Model` - The trunk net with normalized feature output layers - """ - if self._net.net is None: - raise ValueError("No net loaded") - - model = self._net.net(**self._net.init_kwargs) - model = model if self._net_name == "vgg16" else model() - out_layers = [self._normalize_output(model.get_layer(name).output) - for name in self._net.outputs] - model = Model(inputs=model.input, outputs=out_layers) - model = self._process_weights(model) - return model + outputs = [self._normalize_output(x) for x in self.net(inputs).values()] + return outputs -class _LPIPSLinearNet(_LPIPSTrunkNet): - """ The Linear Network to be applied to the difference between the true and predicted outputs +class _LPIPSLinearNet(nn.Module): + """The Linear Network to be applied to the difference between the true and predicted outputs of the trunk network. Parameters ---------- - net_name: str + net_name The name of the trunk network in use. One of "alex", "squeeze" or "vgg16" - eval_mode: bool + eval_mode ``True`` for evaluation mode, ``False`` for training mode - load_weights: bool + load_weights ``True`` if pretrained linear network weights should be loaded, otherwise ``False`` - trunk_net: :class:`keras.models.Model` - The trunk net to place the linear layer on. - use_dropout: bool + use_dropout ``True`` if a dropout layer should be used in the Linear network otherwise ``False`` """ def __init__(self, net_name: T.Literal["alex", "squeeze", "vgg16"], eval_mode: bool, load_weights: bool, - trunk_net: Model, use_dropout: bool) -> None: logger.debug(parse_class_init(locals())) - super().__init__(net_name=net_name, eval_mode=eval_mode, load_weights=load_weights) - - self._trunk = trunk_net + super().__init__() + self._net_name = net_name + self._eval_mode = eval_mode + self._load_weights = load_weights self._use_dropout = use_dropout + self.net = self._get_net() - logger.debug("Initialized: %s", self.__class__.__name__) - - @property - def _nets(self) -> dict[str, NetInfo]: - """ :class:`NetInfo`: The Information about the requested net.""" - return { - "alex": NetInfo(model_id=18, - model_name="alexnet_lpips_v1.h5",), - "squeeze": NetInfo(model_id=19, - model_name="squeezenet_lpips_v1.h5"), - "vgg16": NetInfo(model_id=20, - model_name="vgg16_lpips_v1.h5")} - - def _linear_block(self, net_output_layer: KerasTensor) -> tuple[KerasTensor, KerasTensor]: - """ Build a linear block for a trunk network output. - - Parameters - ---------- - net_output_layer: :class:`keras.KerasTensor` - An output from the selected trunk network + def _get_net(self) -> nn.ModuleList: + """Load the linear network, set the weights Returns ------- - :class:`keras.KerasTensor` - The input to the linear block - :class:`keras.KerasTensor` - The output from the linear block + The Linear network for the given trunk network """ - in_shape = net_output_layer.shape[1:] - input_ = T.cast("KerasTensor", layers.Input(in_shape)) - var_x = layers.Dropout(rate=0.5)(input_) if self._use_dropout else input_ - var_x = layers.Conv2D(1, 1, strides=1, padding="valid", use_bias=False)(var_x) - return input_, var_x + net_info = _LINEAR[self._net_name] + layers: list[nn.Module] = [] + for in_channels in net_info.outputs: + assert isinstance(in_channels, int) + conv = nn.Conv2d(in_channels, 1, 1, stride=1, padding=0, bias=False) + if self._use_dropout: + layers.append(nn.Sequential(nn.Dropout(), conv)) + else: + layers.append(conv) + + net = nn.ModuleList(layers) + + if self._load_weights: + weights_path = GetModel(net_info.model_name, net_info.model_id).model_path + assert isinstance(weights_path, str) + weights = torch.load(weights_path) + state = net.state_dict() + assert len(weights) == len(state) + for key, val in zip(list(state), weights.values()): + state[key] = val + + net.load_state_dict(state) - def __call__(self) -> Model: - """ Build the linear network for the given trunk network's outputs. Load in trained weights - and set the model's trainable parameters. + if self._eval_mode: + net.eval() + return net + + def forward(self, inputs: list[torch.Tensor]) -> list[torch.Tensor]: + """Run the linear layers over each trunk network's feature output + + Parameters + ---------- + inputs + The feature maps output from the trunk network Returns ------- - :class:`keras.models.Model` - The compiled Linear Net model + The output of the linear layers applied to the feature map outputs """ - inputs = [] - outputs = [] - - for input_ in self._trunk.outputs: - in_, out = self._linear_block(input_) - inputs.append(in_) - outputs.append(out) - - model = Model(inputs=inputs, outputs=outputs) - model = self._process_weights(model) - return model + return [self.net[i](inp) for i, inp in enumerate(inputs)] -class LPIPSLoss(keras.losses.Loss): - """ LPIPS Loss Function. +class LPIPSLoss(nn.Module): + """LPIPS Loss Function. A perceptual loss function that uses linear outputs from pretrained CNNs feature layers. @@ -245,36 +244,43 @@ class LPIPSLoss(keras.losses.Loss): Parameters ---------- - trunk_network: str + trunk_network The name of the trunk network to use. One of "alex", "squeeze" or "vgg16" - trunk_pretrained: bool, optional + trunk_pretrained ``True`` Load the imagenet pretrained weights for the trunk network. ``False`` randomly initialize the trunk network. Default: ``True`` - trunk_eval_mode: bool, optional + trunk_eval_mode ``True`` for running inference on the trunk network (standard mode), ``False`` for training the trunk network. Default: ``True`` - linear_pretrained: bool, optional + linear_pretrained ``True`` loads the pretrained weights for the linear network layers. ``False`` randomly initializes the layers. Default: ``True`` - linear_eval_mode: bool, optional + linear_eval_mode ``True`` for running inference on the linear network (standard mode), ``False`` for training the linear network. Default: ``True`` - linear_use_dropout: bool, optional + linear_use_dropout ``True`` if a dropout layer should be used in the Linear network otherwise ``False``. Default: ``True`` - lpips: bool, optional + lpips ``True`` to use linear network on top of the trunk network. ``False`` to just average the output from the trunk network. Default ``True`` - spatial: bool, optional + spatial ``True`` output the loss in the spatial domain (i.e. as a grayscale tensor of height and width of the input image). ``Bool`` reduce the spatial dimensions for loss calculation. Default: ``False`` - normalize: bool, optional + normalize ``True`` if the input Tensor needs to be normalized from the 0. to 1. range to the -1. to 1. range. Default: ``True`` - ret_per_layer: bool, optional + ret_per_layer ``True`` to return the loss value per feature output layer otherwise ``False``. Default: ``False`` + crop + Crop the zero-padded borders from the feature maps. Can help reduce moire pattern. + Default: ``False`` + color_order + The RGB/BGR order of the input images + device + The device to place the models onto. Default: `"cpu"` """ def __init__(self, # pylint:disable=too-many-arguments,too-many-positional-arguments trunk_network: T.Literal["alex", "squeeze", "vgg16"], @@ -286,94 +292,128 @@ def __init__(self, # pylint:disable=too-many-arguments,too-many-positional-argu lpips: bool = True, spatial: bool = False, normalize: bool = True, - ret_per_layer: bool = False) -> None: + ret_per_layer: bool = False, + crop: bool = False, + color_order: T.Literal["bgr", "rgb"] = "bgr", + device: T.Literal["cuda", "cpu"] = "cpu") -> None: + super().__init__() logger.debug(parse_class_init(locals())) - super().__init__(name=self.__class__.__name__) self._spatial = spatial self._use_lpips = lpips self._normalize = normalize self._ret_per_layer = ret_per_layer - self._shift = Variable(np.array([-.030, -.088, -.188], - dtype="float32")[None, None, None, :], - trainable=False) - self._scale = Variable(np.array([.458, .448, .450], dtype="float32")[None, None, None, :], - trainable=False) - - # Loss needs to be done as fp32. We could cast at output, but better to update the model - switch_mixed_precision = keras.mixed_precision.global_policy().name == "mixed_float16" - if switch_mixed_precision: - logger.debug("Temporarily disabling mixed precision") - keras.mixed_precision.set_global_policy("float32") - - self._trunk_net = _LPIPSTrunkNet(trunk_network, trunk_eval_mode, trunk_pretrained)() + self._crop_amount = self._get_crop_amount(crop, trunk_network) + + self._is_rgb = color_order == "rgb" + self._shift = torch.Tensor([-.030, -.088, -.188]).to(dtype=torch.float32, + device=device)[None, :, None, None] + self._scale = torch.Tensor([.458, .448, .450]).to(dtype=torch.float32, + device=device)[None, :, None, None] + + self._trunk_net = _LPIPSTrunkNet(trunk_network, + trunk_eval_mode, + trunk_pretrained).to(device) self._linear_net = _LPIPSLinearNet(trunk_network, linear_eval_mode, linear_pretrained, - self._trunk_net, - linear_use_dropout)() - if switch_mixed_precision: - logger.debug("Re-enabling mixed precision") - keras.mixed_precision.set_global_policy("mixed_float16") - logger.debug("Initialized: %s", self.__class__.__name__) + linear_use_dropout).to(device) + if trunk_eval_mode and linear_eval_mode: + self.eval() - def _process_diffs(self, inputs: list[KerasTensor]) -> list[KerasTensor]: - """ Perform processing on the Trunk Network outputs. + @classmethod + def _get_crop_amount(cls, + do_crop: bool, + trunk_network: T.Literal["alex", "squeeze", "vgg16"]) -> list[int]: + """Obtain the amount to crop from the side of each feature map output when cropping is + selected + + Parameters + ---------- + do_crop + ``True`` if cropping is enabled otherwise ``False`` + trunk_network + The truck network to obtain the cropping amount for - If :attr:`use_ldip` is enabled, process the diff values through the linear network, + Returns + ------- + The amount to crop from each side of the feature map outputs. Empty list if no cropping to + be performed + """ + if not do_crop: + retval = [] + else: + info = _NETS[trunk_network] + if isinstance(info.pad_amount, list): + retval = info.pad_amount + elif not info.pad_amount: + retval = [] + else: + retval = [info.pad_amount for _ in range(len(info.outputs))] + logger.debug("[LPIPSLoss] Crop amounts for '%s' do_crop=%s: %s", + trunk_network, do_crop, retval) + return retval + + def _process_diffs(self, inputs: list[torch.Tensor]) -> list[torch.Tensor]: + """Perform processing on the Trunk Network outputs. + + If :attr:`use_lpips` is enabled, process the diff values through the linear network, otherwise return the diff values summed on the channels axis. Parameters ---------- - inputs: list[:class:`keras.KerasTensor`] - List of the squared difference of the true and predicted outputs from the trunk network + List of the squared difference of the true and predicted outputs from the trunk network Returns ------- - list[:class:`keras.KerasTensor`] - List of either the linear network outputs (when using lpips) or summed network outputs + List of either the linear network outputs (when using lpips) or summed network outputs """ if self._use_lpips: return self._linear_net(inputs) - return [T.cast("KerasTensor", ops.sum(x, axis=-1)) for x in inputs] + return [torch.sum(x, dim=1) for x in inputs] - def _process_output(self, inputs: KerasTensor, output_dims: tuple) -> KerasTensor: - """ Process an individual output based on whether :attr:`is_spatial` has been selected. + def _process_output(self, inputs: torch.Tensor, output_dims: tuple) -> torch.Tensor: + """Process an individual output based on whether :attr:`is_spatial` has been selected. When spatial output is selected, all outputs are sized to the shape of the original True input Tensor. When not selected, the mean across the spatial axes (h, w) are returned Parameters ---------- - inputs: :class:`keras.KerasTensor` + inputs An individual diff output tensor from the linear network or summed output - output_dims: tuple + output_dims The (height, width) of the original true image Returns ------- - :class:`keras.KerasTensor` - Either the original tensor resized to the true image dimensions, or the mean - value across the height, width axes. + Either the original tensor resized to the true image dimensions, or the mean value across + the height, width axes. """ if self._spatial: - return layers.Resizing(*output_dims, interpolation="bilinear")(inputs) - return T.cast("KerasTensor", ops.mean(inputs, axis=(1, 2), keepdims=True)) + return nn.Upsample(output_dims, mode="bilinear", align_corners=False)(inputs) + return torch.mean(inputs, dim=(2, 3), keepdim=True) - def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: - """ Perform the LPIPS Loss Function. + def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor + ) -> torch.Tensor | tuple[torch.Tensor, list[torch.Tensor]]: + """Perform the LPIPS Loss Function. Parameters ---------- - y_true: :class:`keras.KerasTensor` + y_true The ground truth batch of images - y_pred: :class:`keras.KerasTensor` + y_pred The predicted batch of images Returns ------- - :class:`keras.KerasTensor` - The final loss value + The final loss value """ + if not self._is_rgb: + y_true = torch.flip(y_true, dims=[-1]) + y_pred = torch.flip(y_pred, dims=[-1]) + y_true = y_true.permute(0, 3, 1, 2) + y_pred = y_pred.permute(0, 3, 1, 2) + if self._normalize: y_true = (y_true * 2.0) - 1.0 y_pred = (y_pred * 2.0) - 1.0 @@ -387,15 +427,22 @@ def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: diffs = [(out_true - out_pred) ** 2 for out_true, out_pred in zip(net_true, net_pred)] - dims = y_true.shape[1:3] + dims = y_true.shape[2:4] + if self._crop_amount: + diffs = [d[:, :, i:-i, i: -i] if i else d + for d, i in zip(diffs, self._crop_amount)] + + dims = dims if self._spatial else y_true.shape[2:4] res = [self._process_output(diff, dims) for diff in self._process_diffs(diffs)] + if self._spatial: + val = torch.stack(res, dim=0).sum(dim=0) + else: + val = T.cast(torch.Tensor, sum(t.sum() for t in res)) - axis = 0 if self._spatial else None - val = T.cast("KerasTensor", ops.sum(res, axis=axis)) + val *= 0.1 # Reduce by factor of 10 'cos this loss is STRONG. # TODO config retval = (val, res) if self._ret_per_layer else val - assert not isinstance(retval, tuple) - return retval / 10.0 # Reduce by factor of 10 'cos this loss is STRONG + return retval __all__ = get_module_objects(__name__) diff --git a/lib/model/networks/__init__.py b/lib/model/networks/__init__.py index e2be872d73..19481b028c 100644 --- a/lib/model/networks/__init__.py +++ b/lib/model/networks/__init__.py @@ -1,4 +1,3 @@ #!/usr/bin/env python3 """ Pre-defined networks for use in faceswap """ -from .simple_nets import AlexNet, SqueezeNet from .clip import ViT, ViTConfig, TypeModels as TypeModelsViT diff --git a/lib/model/networks/simple_nets.py b/lib/model/networks/simple_nets.py deleted file mode 100644 index a33e0337a3..0000000000 --- a/lib/model/networks/simple_nets.py +++ /dev/null @@ -1,217 +0,0 @@ -#!/usr/bin/env python3 -""" Ports of existing NN Architecture for use in faceswap.py """ -from __future__ import annotations -import logging -import typing as T - -from keras import layers -from keras.models import Model - -from lib.logger import parse_class_init -from lib.utils import get_module_objects - -if T.TYPE_CHECKING: - from keras import KerasTensor - -logger = logging.getLogger(__name__) - - -class _net(): # pylint:disable=too-few-public-methods - """ Base class for existing NeuralNet architecture - - Notes - ----- - All architectures assume channels_last format - - Parameters - ---------- - input_shape, Tuple, optional - The input shape for the model. Default: ``None`` - """ - def __init__(self, - input_shape: tuple[int, int, int] | None = None) -> None: - logger.debug(parse_class_init(locals())) - self._input_shape = (None, None, 3) if input_shape is None else input_shape - assert len(self._input_shape) == 3 and self._input_shape[-1] == 3, ( - "Input shape must be in the format (height, width, channels) and the number of " - f"channels must equal 3. Received: {self._input_shape}") - logger.debug("Initialized: %s", self.__class__.__name__) - - -class AlexNet(_net): - """ AlexNet ported from torchvision version. - - Notes - ----- - This port only contains the features portion of the model. - - References - ---------- - https://papers.nips.cc/paper/2012/file/c399862d3b9d6b76c8436e924a68c45b-Paper.pdf - - Parameters - ---------- - input_shape, Tuple, optional - The input shape for the model. Default: ``None`` - """ - def __init__(self, input_shape: tuple[int, int, int] | None = None) -> None: - super().__init__(input_shape) - self._feature_indices = [0, 3, 6, 8, 10] # For naming equivalent to PyTorch - self._filters = [64, 192, 384, 256, 256] # Filters at each block - - @classmethod - def _conv_block(cls, - inputs: KerasTensor, - padding: int, - filters: int, - kernel_size: int, - strides: int, - block_idx: int, - max_pool: bool) -> KerasTensor: - """ - The Convolutional block for AlexNet - - Parameters - ---------- - inputs: :class:`keras.KerasTensor` - The input tensor to the block - padding: int - The amount of zero paddin to apply prior to convolution - filters: int - The number of filters to apply during convolution - kernel_size: int - The kernel size of the convolution - strides: int - The number of strides for the convolution - block_idx: int - The index of the current block (for standardized naming convention) - max_pool: bool - ``True`` to apply a max pooling layer at the beginning of the block otherwise ``False`` - - Returns - ------- - :class:`keras.KerasTensor` - The output of the Convolutional block - """ - name = f"features_{block_idx}" - var_x = inputs - if max_pool: - var_x = layers.MaxPooling2D(pool_size=3, strides=2, name=f"{name}_pool")(var_x) - var_x = layers.ZeroPadding2D(padding=padding, name=f"{name}_pad")(var_x) - var_x = layers.Conv2D(filters, - kernel_size=kernel_size, - strides=strides, - padding="valid", - activation="relu", - name=name)(var_x) - return var_x - - def __call__(self) -> Model: - """ Create the AlexNet Model - - Returns - ------- - :class:`keras.models.Model` - The compiled AlexNet model - """ - inputs = layers.Input(self._input_shape) - var_x = T.cast("KerasTensor", inputs) - kernel_size = 11 - strides = 4 - - for idx, (filters, block_idx) in enumerate(zip(self._filters, self._feature_indices)): - padding = 2 if idx < 2 else 1 - do_max_pool = 0 < idx < 3 - var_x = self._conv_block(var_x, - padding, - filters, - kernel_size, - strides, - block_idx, - do_max_pool) - kernel_size = max(3, kernel_size // 2) - strides = 1 - return Model(inputs=inputs, outputs=[var_x]) - - -class SqueezeNet(_net): - """ SqueezeNet ported from torchvision version. - - Notes - ----- - This port only contains the features portion of the model. - - References - ---------- - https://arxiv.org/abs/1602.07360 - - Parameters - ---------- - input_shape, Tuple, optional - The input shape for the model. Default: ``None`` - """ - - @classmethod - def _fire(cls, - inputs: KerasTensor, - squeeze_planes: int, - expand_planes: int, - block_idx: int) -> KerasTensor: - """ The fire block for SqueezeNet. - - Parameters - ---------- - inputs: :class:`keras.KerasTensor` - The input to the fire block - squeeze_planes: int - The number of filters for the squeeze convolution - expand_planes: int - The number of filters for the expand convolutions - block_idx: int - The index of the current block (for standardized naming convention) - - Returns - ------- - :class:`keras.KerasTensor` - The output of the SqueezeNet fire block - """ - name = f"features_{block_idx}" - squeezed = layers.Conv2D(squeeze_planes, 1, - activation="relu", name=f"{name}_squeeze")(inputs) - expand1 = layers.Conv2D(expand_planes, 1, - activation="relu", name=f"{name}_expand1x1")(squeezed) - expand3 = layers.Conv2D(expand_planes, - 3, - activation="relu", - padding="same", - name=f"{name}_expand3x3")(squeezed) - return layers.Concatenate(axis=-1, name=name)([expand1, expand3]) - - def __call__(self) -> Model: - """ Create the SqueezeNet Model - - Returns - ------- - :class:`keras.models.Model` - The compiled SqueezeNet model - """ - inputs = layers.Input(self._input_shape) - var_x = layers.Conv2D(64, 3, strides=2, activation="relu", name="features_0")(inputs) - - block_idx = 2 - squeeze = 16 - expand = 64 - for idx in range(4): - if idx < 3: - var_x = layers.MaxPooling2D(pool_size=3, strides=2)(var_x) - block_idx += 1 - var_x = self._fire(var_x, squeeze, expand, block_idx) - block_idx += 1 - var_x = self._fire(var_x, squeeze, expand, block_idx) - block_idx += 1 - squeeze += 16 - expand += 64 - return Model(inputs=inputs, outputs=[var_x]) - - -__all__ = get_module_objects(__name__) diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index c961644c1d..a5253bb758 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -9,6 +9,7 @@ import sys import typing as T +import torch import keras from lib.logger import parse_class_init @@ -28,6 +29,35 @@ logger = logging.getLogger(__name__) +# TODO move this useful utility function +def get_device(cpu: bool = False) -> torch.device: + """Get the correctly configured device for running inference + + Parameters + ---------- + cpu + ``True`` to force running on the CPU. + + Returns + ------- + The device that torch should use + """ + if cpu: + logger.debug("CPU mode selected. Returning CPU device context") + return torch.device("cpu") + + if torch.cuda.is_available(): + logger.debug(" Cuda available. Returning Cuda device context") + return torch.device("cuda") + + if torch.backends.mps.is_available(): + logger.debug(" MPS available. Returning MPS device context") + return torch.device("mps") + + logger.debug(" No backends available. Returning CPU device context") + return torch.device("cpu") + + class ModelBase(): # pylint:disable=too-many-instance-attributes """Base class that all model plugins should inherit from. @@ -84,7 +114,7 @@ def __init__(self, self._settings = Settings(self._args, self._mixed_precision, self._is_predict) - self._loss = Loss(self.color_order) + self._loss = Loss(self.color_order, get_device()) logger.debug("Initialized ModelBase (%s)", self.__class__.__name__) diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 4c0cb18393..bb97bff479 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -29,6 +29,7 @@ if T.TYPE_CHECKING: from collections.abc import Callable from argparse import Namespace + import torch from keras import KerasTensor from .state import State @@ -63,8 +64,10 @@ class Loss(): ---------- color_order Color order of the model. One of `"BGR"` or `"RGB"` + device + The device to load the loss function on to, if applicable """ - def __init__(self, color_order: T.Literal["bgr", "rgb"]) -> None: + def __init__(self, color_order: T.Literal["bgr", "rgb"], device: torch.Device) -> None: logger.debug(parse_class_init(locals())) self._mask_channels = self._get_mask_channels() self._inputs: list[keras.layers.Layer] = [] @@ -80,11 +83,20 @@ def __init__(self, color_order: T.Literal["bgr", "rgb"]) -> None: "laploss": LossClass(function=losses.LaplacianPyramidLoss), "logcosh": LossClass(function=k_losses.LogCosh), "lpips_alex": LossClass(function=losses.LPIPSLoss, - kwargs={"trunk_network": "alex"}), + kwargs={"trunk_network": "alex", + "crop": True, + "color_order": color_order, + "device": device}), "lpips_squeeze": LossClass(function=losses.LPIPSLoss, - kwargs={"trunk_network": "squeeze"}), + kwargs={"trunk_network": "squeeze", + "crop": True, + "color_order": color_order, + "device": device}), "lpips_vgg16": LossClass(function=losses.LPIPSLoss, - kwargs={"trunk_network": "vgg16"}), + kwargs={"trunk_network": "vgg16", + "crop": True, + "color_order": color_order, + "device": device}), "ms_ssim": LossClass(function=losses.MSSIMLoss), "mae": LossClass(function=k_losses.MeanAbsoluteError), "mse": LossClass(function=k_losses.MeanSquaredError), diff --git a/tests/lib/model/losses/feature_loss_test.py b/tests/lib/model/losses/feature_loss_test.py index 42e4af2a61..4f2e3172f5 100644 --- a/tests/lib/model/losses/feature_loss_test.py +++ b/tests/lib/model/losses/feature_loss_test.py @@ -2,7 +2,7 @@ """ Tests for Faceswap Feature Losses. Adapted from Keras tests. """ import pytest import numpy as np -from keras import device, Variable +import torch # pylint:disable=import-error from lib.model.losses.feature_loss import LPIPSLoss @@ -16,10 +16,9 @@ @pytest.mark.parametrize("net", _NETS, ids=_IDS) def test_loss_output(net): """ Basic dtype and value tests for loss functions. """ - with device("cpu"): - y_a = Variable(np.random.random((2, 32, 32, 3))) - y_b = Variable(np.random.random((2, 32, 32, 3))) - objective_output = LPIPSLoss(net)(y_a, y_b) + y_a = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() + y_b = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() + objective_output = LPIPSLoss(net)(y_a, y_b) output = objective_output.detach().numpy() # type:ignore assert output.dtype == "float32" and not np.any(np.isnan(output)) assert output < 0.1 # LPIPS loss is reduced 10x From ed70efdf3fb9292c7bc9bb1e5f8c53adf8fccd41 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:09:13 +0100 Subject: [PATCH 962/981] hrnet: Slightly improved weights --- plugins/extract/align/hrnet.py | 187 +++++++++++++++++++++++++++------ 1 file changed, 153 insertions(+), 34 deletions(-) diff --git a/plugins/extract/align/hrnet.py b/plugins/extract/align/hrnet.py index e1097443b3..afed1ab994 100644 --- a/plugins/extract/align/hrnet.py +++ b/plugins/extract/align/hrnet.py @@ -34,39 +34,70 @@ class HRNetStageConfig: num_branches: int num_blocks: list[int] num_channels: list[int] - use_bottleneck: bool + block: T.Literal["ATTENTION", "BLOCK", "BOTTLENECK"] class HRNet(ExtractPlugin): """HRNet Face alignment""" def __init__(self) -> None: - super().__init__(input_size=256, + super().__init__(input_size=256, # if cfg.weights() == "standard" else 512, batch_size=cfg.batch_size(), is_rgb=True, dtype="float32", scale=(0, 1)) - self._stage_2_config = HRNetStageConfig(num_modules=1, - num_branches=2, - num_blocks=[4, 4], - num_channels=[18, 36], - use_bottleneck=False) - self._stage_3_config = HRNetStageConfig(num_modules=4, - num_branches=3, - num_blocks=[4, 4, 4], - num_channels=[18, 36, 72], - use_bottleneck=False) - self._stage_4_config = HRNetStageConfig(num_modules=3, - num_branches=4, - num_blocks=[4, 4, 4, 4], - num_channels=[18, 36, 72, 144], - use_bottleneck=False) - + self._stage_configs = self._get_stage_configs() + self._hm_size = 64 # if cfg.weights() == "standard" else 128 self.model: HighResolutionNet self.realign_centering = "legacy" self._mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) self._std = np.array([0.229, 0.224, 0.225], dtype=np.float32) - self._dark = Dark(68, 64) if cfg.dark_decoder() else None + self._dark = Dark(68, self._hm_size) if cfg.dark_decoder() else None + + @classmethod + def _get_stage_configs(cls) -> tuple[HRNetStageConfig, HRNetStageConfig, HRNetStageConfig]: + """Obtain the model configuration for the chosen weights + + Returns + ------- + The model configuration + """ + # weights = cfg.weights() + weights = "standard" + if weights == "standard": + retval = (HRNetStageConfig(num_modules=1, + num_branches=2, + num_blocks=[4, 4], + num_channels=[18, 36], + block="BLOCK"), + HRNetStageConfig(num_modules=4, + num_branches=3, + num_blocks=[4, 4, 4], + num_channels=[18, 36, 72], + block="ATTENTION"), + HRNetStageConfig(num_modules=3, + num_branches=4, + num_blocks=[4, 4, 4, 4], + num_channels=[18, 36, 72, 144], + block="ATTENTION")) + else: + retval = (HRNetStageConfig(num_modules=1, + num_branches=2, + num_blocks=[4, 4], + num_channels=[32, 64], + block="BLOCK"), + HRNetStageConfig(num_modules=4, + num_branches=3, + num_blocks=[4, 4, 4], + num_channels=[32, 64, 128], + block="ATTENTION"), + HRNetStageConfig(num_modules=3, + num_branches=4, + num_blocks=[4, 4, 4, 4], + num_channels=[32, 64, 128, 256], + block="ATTENTION")) + logger.debug("[HRNet] using config for weights '%s': %s", weights, retval) + return retval def load_model(self) -> HighResolutionNet: """Load the HRNet model @@ -75,14 +106,16 @@ def load_model(self) -> HighResolutionNet: ------- The loaded HRNet model """ - weights = GetModel("hrnet_landmark_v1.pth", 34).model_path + # version = 2 if cfg.weights() == "standard" else 3 + version = 2 + weights = GetModel(f"hrnet_landmark_v{version}.pth", 34).model_path assert isinstance(weights, str) model = T.cast(HighResolutionNet, self.load_torch_model( HighResolutionNet(num_joints=68, final_conv_kernel=1, - stage_2_config=self._stage_2_config, - stage_3_config=self._stage_3_config, - stage_4_config=self._stage_4_config), + stage_2_config=self._stage_configs[0], + stage_3_config=self._stage_configs[1], + stage_4_config=self._stage_configs[2]), weights)) return model @@ -166,7 +199,7 @@ def post_process(self, batch: np.ndarray) -> np.ndarray: # pylint:disable=too-m The final landmarks in 0-1 space """ if self._dark is not None: - return self._dark(batch) / 64. + return self._dark(batch) / self._hm_size batch_size, num_points, height, width = batch.shape assert height == width, "Heatmaps must be square" resolution = height @@ -249,6 +282,86 @@ def forward(self, inputs: torch.Tensor) -> torch.Tensor: return out +class BasicBlockAttention(nn.Module): # pylint:disable=too-many-instance-attributes + """ Custom Basic block for HRNet with Attention + + Parameters + ---------- + in_channels + The number of in channels + out_channels + The number of out channels + stride + The stride for the first 3x3 conv block. Default: 1 + downsample + The module to use for downsampling or ``None`` for no downsample. Default: ``None`` + """ + expansion = 1 + + def __init__(self, in_channels, out_channels, stride=1, downsample=None): + super().__init__() + self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride=stride, padding=1, bias=False) + self.bn1 = nn.BatchNorm2d(out_channels, momentum=0.01) + self.relu = nn.ReLU(inplace=True) + self.conv2 = nn.Conv2d(in_channels, out_channels, 3, stride=1, padding=1, bias=False) + self.bn2 = nn.BatchNorm2d(out_channels, momentum=0.01) + self.downsample = downsample + self.att1 = nn.Sequential(nn.Conv2d(in_channels, + max(1, out_channels // 4), + 1, + stride=1, + bias=False), + nn.BatchNorm2d(out_channels // 4), + nn.ReLU(inplace=True)) + self.att2 = nn.Conv2d(max(1, out_channels // 4), + max(1, out_channels // 4), + 3, + stride=1, + padding=1, + bias=False) + self.att_bn1 = nn.BatchNorm2d(max(1, out_channels // 4), momentum=0.01) + self.att3 = nn.Conv2d(max(1, out_channels // 4), out_channels, 1, 1, bias=False) + self.att_bn2 = nn.BatchNorm2d(out_channels, momentum=0.01) + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + """Forward pass through HRNet's basic block with attention + + Parameters + ---------- + inputs + Input to the conv block + + Returns + ------- + Output from the conv block + """ + residual = inputs + + out = self.conv1(inputs) + out = self.bn1(out) + out = self.relu(out) + + out = self.conv2(out) + out = self.bn2(out) + + att = self.att1(out) + att = self.att2(att) + att = self.att_bn1(att) + att = self.relu(att) + att = self.att3(att) + att = self.att_bn2(att) + att = torch.sigmoid(att) + out = out * att + + if self.downsample is not None: + residual = self.downsample(inputs) + + out += residual + out = self.relu(out) + + return out + + class Bottleneck(nn.Module): """ Bottleneck for HRNet @@ -335,7 +448,7 @@ class HighResolutionModule(nn.Module): """ def __init__(self, num_branches: int, - block: type[Bottleneck] | type[BasicBlock], + block: type[BasicBlockAttention] | type[BasicBlock] | type[Bottleneck], num_blocks: list[int], num_in_channels: list[int], num_channels: list[int], @@ -391,7 +504,7 @@ def _check_branches(self, def _make_one_branch(self, branch_index: int, - block: type[Bottleneck] | type[BasicBlock], + block: type[BasicBlockAttention | BasicBlock | Bottleneck], num_blocks: int, num_channels: int, stride: int = 1) -> nn.Sequential: @@ -577,23 +690,27 @@ def __init__(self, self.sf = nn.Softmax(dim=1) self.layer1 = self._make_layer(Bottleneck, 64, 64, 4) + _blocks: dict[str, type[BasicBlockAttention | BasicBlock | Bottleneck]] = { + "ATTENTION": BasicBlockAttention, "BLOCK": BasicBlock, "BOTTLENECK": Bottleneck} + num_channels = stage_2_config.num_channels - block = Bottleneck if stage_2_config.use_bottleneck else BasicBlock + block = _blocks[stage_2_config.block] num_channels = [num_channels[i] * block.expansion for i in range(len(num_channels))] self.transition1 = self._make_transition_layer([256], num_channels) - self.stage2, pre_stage_channels = self._make_stage(stage_2_config, num_channels) + self.stage2, pre_stage_channels = self._make_stage(block, stage_2_config, num_channels) num_channels = stage_3_config.num_channels - block = Bottleneck if stage_3_config.use_bottleneck else BasicBlock + block = _blocks[stage_3_config.block] num_channels = [num_channels[i] * block.expansion for i in range(len(num_channels))] self.transition2 = self._make_transition_layer(pre_stage_channels, num_channels) - self.stage3, pre_stage_channels = self._make_stage(stage_3_config, num_channels) + self.stage3, pre_stage_channels = self._make_stage(block, stage_3_config, num_channels) num_channels = stage_4_config.num_channels - block = Bottleneck if stage_4_config.use_bottleneck else BasicBlock + block = _blocks[stage_4_config.block] num_channels = [num_channels[i] * block.expansion for i in range(len(num_channels))] self.transition3 = self._make_transition_layer(pre_stage_channels, num_channels) - self.stage4, pre_stage_channels = self._make_stage(stage_4_config, + self.stage4, pre_stage_channels = self._make_stage(block, + stage_4_config, num_channels, multi_scale_output=True) @@ -661,7 +778,7 @@ def _make_transition_layer(self, return nn.ModuleList(transition_layers) def _make_layer(self, - block: type[BasicBlock] | type[Bottleneck], + block: type[BasicBlockAttention | BasicBlock | Bottleneck], in_channels: int, out_channels: int, blocks: int, @@ -704,6 +821,7 @@ def _make_layer(self, return nn.Sequential(*layers) def _make_stage(self, + block: type[BasicBlockAttention | BasicBlock | Bottleneck], layer_config: HRNetStageConfig, num_in_channels: list[int], multi_scale_output: bool = True) -> tuple[nn.Sequential, list[int]]: @@ -711,6 +829,8 @@ def _make_stage(self, Parameters ---------- + block + The type of block to use for the layer layer_config The configuration for the stage num_in_channels @@ -729,7 +849,6 @@ def _make_stage(self, num_branches = layer_config.num_branches num_blocks = layer_config.num_blocks num_channels = layer_config.num_channels - block = Bottleneck if layer_config.use_bottleneck else BasicBlock modules: list[HighResolutionModule] = [] for i in range(num_modules): From 16fea0ebff07795a7b60a8236a631206641d68ca Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:14:55 +0100 Subject: [PATCH 963/981] Most loss functions to torch --- lib/model/losses/feature_loss.py | 34 +- lib/model/losses/loss.py | 432 +++++++------- lib/model/losses/perceptual_loss.py | 526 +++++++++--------- plugins/train/model/_base/model.py | 32 +- plugins/train/model/_base/settings.py | 14 +- tests/lib/model/losses/feature_loss_test.py | 2 +- tests/lib/model/losses/loss_test.py | 16 +- .../lib/model/losses/perceptual_loss_test.py | 9 +- 8 files changed, 492 insertions(+), 573 deletions(-) diff --git a/lib/model/losses/feature_loss.py b/lib/model/losses/feature_loss.py index 71bd774a3a..fac93e6448 100644 --- a/lib/model/losses/feature_loss.py +++ b/lib/model/losses/feature_loss.py @@ -229,7 +229,7 @@ def forward(self, inputs: list[torch.Tensor]) -> list[torch.Tensor]: return [self.net[i](inp) for i, inp in enumerate(inputs)] -class LPIPSLoss(nn.Module): +class LPIPSLoss(nn.Module): # pylint:disable=too-many-instance-attributes """LPIPS Loss Function. A perceptual loss function that uses linear outputs from pretrained CNNs feature layers. @@ -279,8 +279,6 @@ class LPIPSLoss(nn.Module): Default: ``False`` color_order The RGB/BGR order of the input images - device - The device to place the models onto. Default: `"cpu"` """ def __init__(self, # pylint:disable=too-many-arguments,too-many-positional-arguments trunk_network: T.Literal["alex", "squeeze", "vgg16"], @@ -294,8 +292,7 @@ def __init__(self, # pylint:disable=too-many-arguments,too-many-positional-argu normalize: bool = True, ret_per_layer: bool = False, crop: bool = False, - color_order: T.Literal["bgr", "rgb"] = "bgr", - device: T.Literal["cuda", "cpu"] = "cpu") -> None: + color_order: T.Literal["bgr", "rgb"] = "bgr") -> None: super().__init__() logger.debug(parse_class_init(locals())) self._spatial = spatial @@ -305,18 +302,15 @@ def __init__(self, # pylint:disable=too-many-arguments,too-many-positional-argu self._crop_amount = self._get_crop_amount(crop, trunk_network) self._is_rgb = color_order == "rgb" - self._shift = torch.Tensor([-.030, -.088, -.188]).to(dtype=torch.float32, - device=device)[None, :, None, None] - self._scale = torch.Tensor([.458, .448, .450]).to(dtype=torch.float32, - device=device)[None, :, None, None] - - self._trunk_net = _LPIPSTrunkNet(trunk_network, - trunk_eval_mode, - trunk_pretrained).to(device) + self._initialized = False + self._shift = torch.Tensor([-.030, -.088, -.188]).float()[None, :, None, None] + self._scale = torch.Tensor([.458, .448, .450]).float()[None, :, None, None] + + self._trunk_net = _LPIPSTrunkNet(trunk_network, trunk_eval_mode, trunk_pretrained) self._linear_net = _LPIPSLinearNet(trunk_network, linear_eval_mode, linear_pretrained, - linear_use_dropout).to(device) + linear_use_dropout) if trunk_eval_mode and linear_eval_mode: self.eval() @@ -406,8 +400,15 @@ def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor Returns ------- - The final loss value + The final loss value for each item in the batch """ + if not self._initialized: + self._shift = self._shift.to(y_pred.device) + self._scale = self._scale.to(y_pred.device) + self._trunk_net = self._trunk_net.to(y_pred.device) + self._linear_net = self._linear_net.to(y_pred.device) + self._initialized = True + if not self._is_rgb: y_true = torch.flip(y_true, dims=[-1]) y_pred = torch.flip(y_pred, dims=[-1]) @@ -434,10 +435,11 @@ def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor dims = dims if self._spatial else y_true.shape[2:4] res = [self._process_output(diff, dims) for diff in self._process_diffs(diffs)] + if self._spatial: val = torch.stack(res, dim=0).sum(dim=0) else: - val = T.cast(torch.Tensor, sum(t.sum() for t in res)) + val = torch.stack([r.sum(dim=(1, 2, 3)) for r in res]).sum(dim=0) val *= 0.1 # Reduce by factor of 10 'cos this loss is STRONG. # TODO config diff --git a/lib/model/losses/loss.py b/lib/model/losses/loss.py index f8d375df67..83caa1e8c1 100644 --- a/lib/model/losses/loss.py +++ b/lib/model/losses/loss.py @@ -1,22 +1,20 @@ #!/usr/bin/env python3 -""" Custom Loss Functions for faceswap.py """ +"""Custom Loss Functions for faceswap.py""" from __future__ import annotations import logging import typing as T import numpy as np -from keras import Loss, backend as K -from keras import ops, Variable +import torch +from torch import nn +from torch.nn import functional as F +from keras import Loss +from keras import ops from lib.logger import parse_class_init from lib.utils import get_module_objects -if K.backend() == "torch": - import torch # pylint:disable=import-error -else: - import tensorflow as tf # pylint:disable=import-error # type:ignore - if T.TYPE_CHECKING: from collections.abc import Callable from keras import KerasTensor @@ -24,42 +22,32 @@ logger = logging.getLogger(__name__) -class FocalFrequencyLoss(Loss): - """ Focal Frequencey Loss Function. - - A channels last implementation. - - Notes - ----- - There is a bug in this implementation that will do an incorrect FFT if - :attr:`patch_factor` > ``1``, which means incorrect loss will be returned, so keep - patch factor at 1. +class FocalFrequencyLoss(nn.Module): + """Focal frequency Loss Function. Parameters ---------- - alpha: float, Optional + alpha Scaling factor of the spectrum weight matrix for flexibility. Default: ``1.0`` - patch_factor: int, Optional + patch_factor Factor to crop image patches for patch-based focal frequency loss. Default: ``1`` - ave_spectrum: bool, Optional - ``True`` to use minibatch average spectrum otherwise ``False``. Default: ``False`` - log_matrix: bool, Optional + ave_spectrum + ``True`` to use mini-batch average spectrum otherwise ``False``. Default: ``False`` + log_matrix ``True`` to adjust the spectrum weight matrix by logarithm otherwise ``False``. Default: ``False`` - batch_matrix: bool, Optional + batch_matrix ``True`` to calculate the spectrum weight matrix using batch-based statistics otherwise ``False``. Default: ``False`` - epsilon : float, Optional + epsilon Small epsilon for safer weights scaling division. Default: `1e-6` - References ---------- https://arxiv.org/pdf/2012.12821.pdf https://github.com/EndlessSora/focal-frequency-loss """ - def __init__(self, alpha: float = 1.0, patch_factor: int = 1, @@ -68,29 +56,26 @@ def __init__(self, batch_matrix: bool = False, epsilon: float = 1e-6) -> None: logger.debug(parse_class_init(locals())) - super().__init__(name=self.__class__.__name__) + super().__init__() self._alpha = alpha - # TODO Fix bug where FFT will be incorrect if patch_factor > 1 for tensorflow self._patch_factor = patch_factor self._ave_spectrum = ave_spectrum self._log_matrix = log_matrix self._batch_matrix = batch_matrix - self._epsilon = epsilon + self._epsilon = torch.Tensor([epsilon]) self._dims: tuple[int, int] = (0, 0) - logger.debug("Initialized: %s", self.__class__.__name__) - def _get_patches(self, inputs: KerasTensor) -> KerasTensor: - """ Crop the incoming batch of images into patches as defined by :attr:`_patch_factor. + def _get_patches(self, inputs: torch.Tensor) -> torch.Tensor: + """Crop the incoming batch of images into patches as defined by :attr:`_patch_factor. Parameters ---------- - inputs: :class:`keras.KerasTensor` + inputs A batch of images to be converted into patches Returns ------- - :class:`keras.KerasTensor`` - The incoming batch converted into patches + The incoming batch converted into patches """ patch_list = [] patch_rows = self._dims[0] // self._patch_factor @@ -101,123 +86,105 @@ def _get_patches(self, inputs: KerasTensor) -> KerasTensor: row_to = (i + 1) * patch_rows col_from = j * patch_cols col_to = (j + 1) * patch_cols - patch_list.append(inputs[:, row_from: row_to, col_from: col_to, :]) + patch_list.append(inputs[:, row_from: row_to, col_from:col_to, :]) - retval = ops.stack(patch_list, axis=1) - return T.cast("KerasTensor", retval) + retval = torch.stack(patch_list, dim=1) + return retval - def _tensor_to_frequency_spectrum(self, patch: KerasTensor) -> KerasTensor: - """ Perform FFT to create the orthonomalized DFT frequencies. + def _tensor_to_frequency_spectrum(self, patch: torch.Tensor) -> torch.Tensor: + """Perform FFT to create the orthonomalized DFT frequencies. Parameters ---------- - inputs: :class:`keras.KerasTensor` + inputs The incoming batch of patches to convert to the frequency spectrum Returns ------- - :class:`keras.KerasTensor` - The DFT frequencies split into real and imaginary numbers as float32 + The DFT frequencies split into real and imaginary numbers as float32 """ - patch = T.cast("KerasTensor", - ops.transpose(patch, (0, 1, 4, 2, 3))) # move channels to first - - assert K.backend() in ("torch", "tensorflow"), "Only Torch and Tensorflow are supported" - if K.backend() == "torch": - freq = torch.fft.fft2(patch, # pylint:disable=not-callable # type:ignore - norm="ortho") - else: - patch = patch / np.sqrt(self._dims[0] * self._dims[1]) # Orthonormalization - patch = T.cast("KerasTensor", ops.cast(patch, "complex64")) - freq = tf.signal.fft2d(patch)[..., None] # type:ignore - - freq = ops.stack([freq.real, freq.imag], axis=-1) - - if K.backend() == "tensorflow": - freq = ops.cast(freq, "float32") - - freq = ops.transpose(freq, (0, 1, 3, 4, 2, 5)) # channels to last - return T.cast("KerasTensor", freq) + freq = torch.fft.fft2(patch, norm="ortho") # pylint:disable=not-callable + freq = torch.stack([freq.real, freq.imag], dim=-1) + return freq - def _get_weight_matrix(self, freq_true: KerasTensor, freq_pred: KerasTensor) -> KerasTensor: - """ Calculate a continuous, dynamic weight matrix based on current Euclidean distance. + def _get_weight_matrix(self, freq_true: torch.Tensor, freq_pred: torch.Tensor) -> torch.Tensor: + """Calculate a continuous, dynamic weight matrix based on current Euclidean distance. Parameters ---------- - freq_true: :class:`keras.KerasTensor` + freq_true The real and imaginary DFT frequencies for the true batch of images - freq_pred: :class:`keras.KerasTensor` + freq_pred The real and imaginary DFT frequencies for the predicted batch of images Returns ------- - :class:`keras.KerasTensor` - The weights matrix for prioritizing hard frequencies + The weights matrix for prioritizing hard frequencies """ - weights = ops.square(freq_pred - freq_true) - weights = ops.sqrt(weights[..., 0] + weights[..., 1]) - weights = ops.power(weights, self._alpha) + weights = torch.square(freq_pred - freq_true) + weights = torch.sqrt(weights[..., 0] + weights[..., 1]) + weights = torch.pow(weights, self._alpha) if self._log_matrix: # adjust the spectrum weight matrix by logarithm - weights = ops.log(weights + 1.0) + weights = torch.log(weights + 1.0) if self._batch_matrix: # calculate the spectrum weight matrix using batch-based statistics - scale = ops.max(weights) + scale = torch.max(weights) else: - scale = ops.max(weights, axis=(-2, -3), keepdims=True) - weights = weights / ops.maximum(scale, self._epsilon) - - weights = ops.clip(weights, x_min=0.0, x_max=1.0) - - return T.cast("KerasTensor", weights) + scale = torch.amax(weights, dim=(-1, -2), keepdim=True) + weights = weights / torch.maximum(scale, self._epsilon) + return torch.clamp(weights, min=0.0, max=1.0) @classmethod def _calculate_loss(cls, - freq_true: KerasTensor, - freq_pred: KerasTensor, - weight_matrix: KerasTensor) -> KerasTensor: - """ Perform the loss calculation on the DFT spectrum applying the weights matrix. + freq_true: torch.Tensor, + freq_pred: torch.Tensor, + weight_matrix: torch.Tensor) -> torch.Tensor: + """Perform the loss calculation on the DFT spectrum applying the weights matrix. Parameters ---------- - freq_true: :class:`keras.KerasTensor` + freq_true The real and imaginary DFT frequencies for the true batch of images - freq_pred: :class:`keras.KerasTensor` + freq_pred The real and imaginary DFT frequencies for the predicted batch of images Returns - :class:`keras.KerasTensor` - The final loss matrix + ------- + The final loss value for each item in the batch """ - tmp = ops.square(freq_pred - freq_true) # freq distance using squared Euclidean distance + tmp = torch.square(freq_pred - freq_true) # freq distance using squared Euclidean distance freq_distance = tmp[..., 0] + tmp[..., 1] loss = weight_matrix * freq_distance # dynamic spectrum weighting (Hadamard product) + return torch.mean(loss, dim=(1, 2, 3, 4)) - return T.cast("KerasTensor", ops.mean(loss)) - - def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: - """ Call the Focal Frequency Loss Function. + def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: + """Call the Focal Frequency Loss Function. Parameters ---------- - y_true: :class:`keras.KerasTensor` + y_true The ground truth batch of images - y_pred: :class:`keras.KerasTensor` + y_pred The predicted batch of images Returns ------- - :class:`keras.KerasTensor` - The loss for this batch of images + The final loss value for each item in the batch """ + # TODO remove once channels first + y_true = y_true.permute(0, 3, 1, 2) + y_pred = y_pred.permute(0, 3, 1, 2) + if not all(self._dims): - rows, cols = y_true.shape[1:3] + rows, cols = y_true.shape[2:4] assert rows is not None and cols is not None assert cols % self._patch_factor == 0 and rows % self._patch_factor == 0, ( "Patch factor must be a divisor of the image height and width") self._dims = (rows, cols) + self._epsilon = self._epsilon.to(y_pred.device) patches_true = self._get_patches(y_true) patches_pred = self._get_patches(y_pred) @@ -225,16 +192,16 @@ def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: freq_true = self._tensor_to_frequency_spectrum(patches_true) freq_pred = self._tensor_to_frequency_spectrum(patches_pred) - if self._ave_spectrum: # whether to use minibatch average spectrum - freq_true = T.cast("KerasTensor", ops.mean(freq_true, axis=0, keepdims=True)) - freq_pred = T.cast("KerasTensor", ops.mean(freq_pred, axis=0, keepdims=True)) + if self._ave_spectrum: # whether to use mini-batch average spectrum + freq_true = torch.mean(freq_true, dim=0, keepdim=True) + freq_pred = torch.mean(freq_pred, dim=0, keepdim=True) weight_matrix = self._get_weight_matrix(freq_true, freq_pred) return self._calculate_loss(freq_true, freq_pred, weight_matrix) -class GeneralizedLoss(Loss): - """ Generalized function used to return a large variety of mathematical loss functions. +class GeneralizedLoss(nn.Module): + """Generalized function used to return a large variety of mathematical loss functions. The primary benefit is a smooth, differentiable version of L1 loss. @@ -249,44 +216,42 @@ class GeneralizedLoss(Loss): Parameters ---------- - alpha: float, optional + alpha Penalty factor. Larger number give larger weight to large deviations. Default: `1.0` - beta: float, optional + beta Scale factor used to adjust to the input scale (i.e. inputs of mean `1e-4` or `256`). Default: `1.0/255.0` """ def __init__(self, alpha: float = 1.0, beta: float = 1.0/255.0) -> None: logger.debug(parse_class_init(locals())) - super().__init__(name=self.__class__.__name__) + super().__init__() self._alpha = alpha self._beta = beta - logger.debug("Initialized: %s", self.__class__.__name__) - def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: - """ Call the Generalized Loss Function + def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: + """Call the Generalized Loss Function Parameters ---------- - y_true: :class:`keras.KerasTensor` + y_true The ground truth value - y_pred: :class:`keras.KerasTensor` + y_pred The predicted value Returns ------- - :class:`keras.KerasTensor` - The loss value from the results of function(y_pred - y_true) + The final loss value for each item in the batch """ diff = y_pred - y_true - second = (ops.power(ops.power(diff/self._beta, 2.) / ops.abs(2. - self._alpha) + 1., + second = (torch.pow(torch.pow(diff/self._beta, 2.) / abs(2. - self._alpha) + 1., (self._alpha / 2.)) - 1.) - loss = (ops.abs(2. - self._alpha)/self._alpha) * second - loss = ops.mean(loss, axis=-1) * self._beta - return T.cast("KerasTensor", loss) + loss = (abs(2. - self._alpha)/self._alpha) * second + loss = torch.mean(loss, dim=(1, 2, 3)) * self._beta + return loss -class GradientLoss(Loss): - """ Gradient Loss Function. +class GradientLoss(nn.Module): + """Gradient Loss Function. Calculates the first and second order gradient difference between pixels of an image in the x and y dimensions. These gradients are then compared between the ground truth and the predicted @@ -300,103 +265,101 @@ class GradientLoss(Loss): """ def __init__(self) -> None: logger.debug(parse_class_init(locals())) - super().__init__(name=self.__class__.__name__) + super().__init__() self.generalized_loss = GeneralizedLoss(alpha=1.9999) self._tv_weight = 1.0 self._tv2_weight = 1.0 - logger.debug("Initialized: %s", self.__class__.__name__) @classmethod - def _diff_x(cls, img: KerasTensor) -> KerasTensor: - """ X Difference """ + def _diff_x(cls, img: torch.Tensor) -> torch.Tensor: + """X Difference""" x_left = img[:, :, 1:2, :] - img[:, :, 0:1, :] x_inner = img[:, :, 2:, :] - img[:, :, :-2, :] x_right = img[:, :, -1:, :] - img[:, :, -2:-1, :] - x_out = ops.concatenate([x_left, x_inner, x_right], axis=2) - return T.cast("KerasTensor", x_out) * 0.5 + x_out = torch.concatenate([x_left, x_inner, x_right], dim=2) + return x_out * 0.5 @classmethod - def _diff_y(cls, img: KerasTensor) -> KerasTensor: - """ Y Difference """ + def _diff_y(cls, img: torch.Tensor) -> torch.Tensor: + """Y Difference""" y_top = img[:, 1:2, :, :] - img[:, 0:1, :, :] y_inner = img[:, 2:, :, :] - img[:, :-2, :, :] y_bot = img[:, -1:, :, :] - img[:, -2:-1, :, :] - y_out = ops.concatenate([y_top, y_inner, y_bot], axis=1) - return T.cast("KerasTensor", y_out) * 0.5 + y_out = torch.concatenate([y_top, y_inner, y_bot], dim=1) + return y_out * 0.5 @classmethod - def _diff_xx(cls, img: KerasTensor) -> KerasTensor: - """ X-X Difference """ + def _diff_xx(cls, img: torch.Tensor) -> torch.Tensor: + """X-X Difference""" x_left = img[:, :, 1:2, :] + img[:, :, 0:1, :] x_inner = img[:, :, 2:, :] + img[:, :, :-2, :] x_right = img[:, :, -1:, :] + img[:, :, -2:-1, :] - x_out = ops.concatenate([x_left, x_inner, x_right], axis=2) + x_out = torch.concatenate([x_left, x_inner, x_right], dim=2) return x_out - 2.0 * img @classmethod - def _diff_yy(cls, img: KerasTensor) -> KerasTensor: - """ Y-Y Difference """ + def _diff_yy(cls, img: torch.Tensor) -> torch.Tensor: + """Y-Y Difference""" y_top = img[:, 1:2, :, :] + img[:, 0:1, :, :] y_inner = img[:, 2:, :, :] + img[:, :-2, :, :] y_bot = img[:, -1:, :, :] + img[:, -2:-1, :, :] - y_out = ops.concatenate([y_top, y_inner, y_bot], axis=1) + y_out = torch.concatenate([y_top, y_inner, y_bot], dim=1) return y_out - 2.0 * img @classmethod - def _diff_xy(cls, img: KerasTensor) -> KerasTensor: - """ X-Y Difference """ - # xout1 + def _diff_xy(cls, img: torch.Tensor) -> torch.Tensor: + """X-Y Difference""" + # x_out1 # Left top = img[:, 1:2, 1:2, :] + img[:, 0:1, 0:1, :] inner = img[:, 2:, 1:2, :] + img[:, :-2, 0:1, :] bottom = img[:, -1:, 1:2, :] + img[:, -2:-1, 0:1, :] - xy_left = ops.concatenate([top, inner, bottom], axis=1) + xy_left = torch.concatenate([top, inner, bottom], dim=1) # Mid top = img[:, 1:2, 2:, :] + img[:, 0:1, :-2, :] mid = img[:, 2:, 2:, :] + img[:, :-2, :-2, :] bottom = img[:, -1:, 2:, :] + img[:, -2:-1, :-2, :] - xy_mid = ops.concatenate([top, mid, bottom], axis=1) + xy_mid = torch.concatenate([top, mid, bottom], dim=1) # Right top = img[:, 1:2, -1:, :] + img[:, 0:1, -2:-1, :] inner = img[:, 2:, -1:, :] + img[:, :-2, -2:-1, :] bottom = img[:, -1:, -1:, :] + img[:, -2:-1, -2:-1, :] - xy_right = ops.concatenate([top, inner, bottom], axis=1) + xy_right = torch.concatenate([top, inner, bottom], dim=1) - # Xout2 + # X_out2 # Left top = img[:, 0:1, 1:2, :] + img[:, 1:2, 0:1, :] inner = img[:, :-2, 1:2, :] + img[:, 2:, 0:1, :] bottom = img[:, -2:-1, 1:2, :] + img[:, -1:, 0:1, :] - xy_left = ops.concatenate([top, inner, bottom], axis=1) + xy_left = torch.concatenate([top, inner, bottom], dim=1) # Mid top = img[:, 0:1, 2:, :] + img[:, 1:2, :-2, :] mid = img[:, :-2, 2:, :] + img[:, 2:, :-2, :] bottom = img[:, -2:-1, 2:, :] + img[:, -1:, :-2, :] - xy_mid = ops.concatenate([top, mid, bottom], axis=1) + xy_mid = torch.concatenate([top, mid, bottom], dim=1) # Right top = img[:, 0:1, -1:, :] + img[:, 1:2, -2:-1, :] inner = img[:, :-2, -1:, :] + img[:, 2:, -2:-1, :] bottom = img[:, -2:-1, -1:, :] + img[:, -1:, -2:-1, :] - xy_right = ops.concatenate([top, inner, bottom], axis=1) + xy_right = torch.concatenate([top, inner, bottom], dim=1) - xy_out1 = T.cast("KerasTensor", ops.concatenate([xy_left, xy_mid, xy_right], axis=2)) - xy_out2 = T.cast("KerasTensor", ops.concatenate([xy_left, xy_mid, xy_right], axis=2)) + xy_out1 = torch.concatenate([xy_left, xy_mid, xy_right], dim=2) + xy_out2 = torch.concatenate([xy_left, xy_mid, xy_right], dim=2) return (xy_out1 - xy_out2) * 0.25 - def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: - """ Call the gradient loss function. + def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: + """Call the gradient loss function. Parameters ---------- - y_true: :class:`keras.KerasTensor` + y_true The ground truth value - y_pred: :class:`keras.KerasTensor` + y_pred The predicted value Returns ------- - :class:`keras.KerasTensor` - The loss value + The final loss value for each item in the batch """ loss = 0.0 loss += self._tv_weight * (self.generalized_loss(self._diff_x(y_true), @@ -411,11 +374,11 @@ def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: self._diff_xy(y_pred)) * 2.) loss = loss / (self._tv_weight + self._tv2_weight) # TODO simplify to use MSE instead - return T.cast("KerasTensor", loss) + return loss -class LaplacianPyramidLoss(Loss): - """ Laplacian Pyramid Loss Function +class LaplacianPyramidLoss(nn.Module): + """Laplacian Pyramid Loss Function Notes ----- @@ -423,12 +386,14 @@ class LaplacianPyramidLoss(Loss): Parameters ---------- - max_levels: int, Optional + max_levels The max number of laplacian pyramid levels to use. Default: `5` - gaussian_size: int, Optional + gaussian_size The size of the gaussian kernel. Default: `5` - gaussian_sigma: float, optional + gaussian_sigma The gaussian sigma. Default: 2.0 + device + The device to place the variables onto. Default: `"cpu"` References ---------- @@ -440,147 +405,136 @@ def __init__(self, gaussian_size: int = 5, gaussian_sigma: float = 1.0) -> None: logger.debug(parse_class_init(locals())) - super().__init__(name=self.__class__.__name__) + super().__init__() self._max_levels = max_levels - self._weights = Variable([np.power(2., -2 * idx) for idx in range(max_levels + 1)], - trainable=False) - self._gaussian_kernel = self._get_gaussian_kernel(gaussian_size, gaussian_sigma) - logger.debug("Initialized: %s", self.__class__.__name__) + self._gaussian_size = gaussian_size + self._gaussian_sigma = gaussian_sigma + self._gaussian_kernel: torch.Tensor | None = None + self._weight = torch.Tensor([np.power(2., -2 * idx) + for idx in range(max_levels + 1)]) - @classmethod - def _get_gaussian_kernel(cls, size: int, sigma: float) -> KerasTensor: - """ Obtain the base gaussian kernel for the Laplacian Pyramid. + def _generate_gaussian_kernel(self, device: torch.Device) -> None: + """Obtain the base gaussian kernel for the Laplacian Pyramid and set to + :attr:`_gaussian_kernel` Parameters ---------- - size: int, Optional - The size of the gaussian kernel - sigma: float - The gaussian sigma + device + The device to place the Gaussian kernel on to Returns ------- - :class:`keras.KerasTensor` - The base single channel Gaussian kernel + The base three channel Gaussian kernel """ + size = self._gaussian_size assert size % 2 == 1, ("kernel size must be uneven") x_1 = np.linspace(- (size // 2), size // 2, size, dtype="float32") - x_1 /= np.sqrt(2)*sigma + x_1 /= np.sqrt(2) * self._gaussian_sigma x_2 = x_1 ** 2 kernel = np.exp(- x_2[:, None] - x_2[None, :]) kernel /= kernel.sum() - kernel = np.reshape(kernel, (size, size, 1, 1)) - return Variable(kernel, trainable=False) - def _conv_gaussian(self, inputs: KerasTensor) -> KerasTensor: - """ Perform Gaussian convolution on a batch of images. + kernel = np.tile(kernel, (3, 1, 1, 1)) + self._gaussian_kernel = torch.from_numpy(kernel).to(torch.float32).to(device) + + def _conv_gaussian(self, inputs: torch.Tensor) -> torch.Tensor: + """Perform Gaussian convolution on a batch of images. Parameters ---------- - inputs: :class:`keras.KerasTensor` + inputs The input batch of images to perform Gaussian convolution on. Returns ------- - :class:`keras.KerasTensor` - The convolved images + The convolved images """ - channels = inputs.shape[-1] - gauss = ops.tile(self._gaussian_kernel, (1, 1, 1, channels)) - - # TF doesn't implement replication padding like pytorch. This is an inefficient way to - # implement it for a square guassian kernel - # TODO Make this pure pytorch code - gauss_shape = self._gaussian_kernel.shape[1] - assert gauss_shape is not None - size = gauss_shape // 2 - padded_inputs = inputs - for _ in range(size): - padded_inputs = ops.pad(padded_inputs, - ([0, 0], [1, 1], [1, 1], [0, 0]), - mode="symmetric") - - retval = ops.conv(padded_inputs, gauss, strides=1, padding="valid") - return T.cast("KerasTensor", retval) - - def _get_laplacian_pyramid(self, inputs: KerasTensor) -> list[KerasTensor]: - """ Obtain the Laplacian Pyramid. + assert self._gaussian_kernel is not None + gauss_size = self._gaussian_kernel.shape[2] + padded_inputs = F.pad(inputs, + (gauss_size // 2, gauss_size // 2, gauss_size // 2, gauss_size // 2), + mode="replicate") + return F.conv2d(padded_inputs, # pylint:disable=not-callable + self._gaussian_kernel, + groups=3) + + def _get_laplacian_pyramid(self, inputs: torch.Tensor) -> list[torch.Tensor]: + """Obtain the Laplacian Pyramid. Parameters ---------- - inputs: :class:`keras.KerasTensor` + inputs The input batch of images to run through the Laplacian Pyramid Returns ------- - list - The tensors produced from the Laplacian Pyramid + The tensors produced from the Laplacian Pyramid """ pyramid = [] current = inputs for _ in range(self._max_levels): - gauss = self._conv_gaussian(current) - diff = current - gauss + filtered = self._conv_gaussian(current) + diff = current - filtered pyramid.append(diff) - current = ops.average_pool(gauss, (2, 2), strides=(2, 2), padding="valid") + current = F.avg_pool2d(filtered, 2) # pylint:disable=not-callable pyramid.append(current) return pyramid - def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: - """ Calculate the Laplacian Pyramid Loss. + def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: + """Calculate the Laplacian Pyramid Loss. Parameters ---------- - y_true: :class:`keras.KerasTensor` + y_true The ground truth value - y_pred: :class:`keras.KerasTensor` + y_pred The predicted value Returns ------- - :class:`keras.KerasTensor` - The loss value + The final loss value for each item in the batch """ + # TODO remove once channels first + y_true = y_true.permute(0, 3, 1, 2) + y_pred = y_pred.permute(0, 3, 1, 2) + + if self._gaussian_kernel is None: + self._generate_gaussian_kernel(y_pred.device) + self._weight = self._weight.to(y_pred.device) + pyramid_true = self._get_laplacian_pyramid(y_true) pyramid_pred = self._get_laplacian_pyramid(y_pred) - losses = ops.stack( - [ops.sum(ops.abs(ppred - ptrue)) / ops.cast(ops.prod(ops.shape(ptrue)), "float32") - for ptrue, ppred in zip(pyramid_true, pyramid_pred)]) - loss = ops.sum(losses * self._weights) - return T.cast("KerasTensor", loss) + losses = torch.stack([F.l1_loss(o, t, reduction="none").mean(dim=(1, 2, 3)) + for o, t in zip(pyramid_true, pyramid_pred)]).T + losses *= self._weight + return losses.sum(dim=1) -class LInfNorm(Loss): - """ Calculate the L-inf norm as a loss function. """ - def __init__(self, *args, **kwargs) -> None: - logger.debug(parse_class_init(locals())) - super().__init__(*args, name=self.__class__.__name__, **kwargs) - logger.debug("Initialized: %s", self.__class__.__name__) +class LInfNorm(nn.Module): + """Calculate the L-inf norm as a loss function. """ - def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: - """ Call the L-inf norm loss function. + def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: + """Call the L-inf norm loss function. Parameters ---------- - y_true: :class:`keras.KerasTensor` + y_true The ground truth value - y_pred: :class:`keras.KerasTensor` + y_pred The predicted value Returns ------- - :class:`keras.KerasTensor` - The loss value + The final loss value for each item in the batch """ - diff = ops.abs(y_true - y_pred) - max_loss = ops.max(diff, axis=(1, 2), keepdims=True) - loss = ops.mean(max_loss, axis=-1) - return T.cast("KerasTensor", loss) + diff = torch.abs(y_true - y_pred) + loss = diff.amax(dim=(1, 2)).mean(dim=-1) + return loss class LossWrapper(Loss): - """ A wrapper class for multiple keras losses to enable multiple masked weighted loss + """A wrapper class for multiple keras losses to enable multiple masked weighted loss functions on a single output. Notes @@ -611,7 +565,7 @@ def add_loss(self, function: Callable | Loss, weight: float = 1.0, mask_channel: int = -1) -> None: - """ Add the given loss function with the given weight to the loss function chain. + """Add the given loss function with the given weight to the loss function chain. Parameters ---------- @@ -625,13 +579,13 @@ def add_loss(self, """ logger.debug("Adding loss: (function: %s, weight: %s, mask_channel: %s)", function, weight, mask_channel) - # Loss must be compiled inside LossContainer for keras to handle distibuted strategies + # Loss must be compiled inside LossContainer for keras to handle distributed strategies self._loss_functions.append(function) self._loss_weights.append(weight) self._mask_channels.append(mask_channel) def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: - """ Call the sub loss functions for the loss wrapper. + """Call the sub loss functions for the loss wrapper. Loss is returned as the weighted sum of the chosen losses. @@ -668,7 +622,7 @@ def _apply_mask(cls, y_pred: KerasTensor, mask_channel: int, mask_prop: float = 1.0) -> tuple[KerasTensor, KerasTensor]: - """ Apply the mask to the input y_true and y_pred. If a mask is not required then + """Apply the mask to the input y_true and y_pred. If a mask is not required then return the unmasked inputs. Parameters diff --git a/lib/model/losses/perceptual_loss.py b/lib/model/losses/perceptual_loss.py index cbdfa6eed7..230e1ff931 100644 --- a/lib/model/losses/perceptual_loss.py +++ b/lib/model/losses/perceptual_loss.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Keras implementation of Perceptual Loss Functions for faceswap.py """ +"""Keras implementation of Perceptual Loss Functions for faceswap.py """ from __future__ import annotations import logging @@ -7,6 +7,8 @@ import numpy as np import torch +from torch import nn +from torch.nn import functional as F import keras from keras import ops, Variable @@ -17,17 +19,16 @@ if T.TYPE_CHECKING: from keras import KerasTensor - from torch import Tensor logger = logging.getLogger(__name__) -class DSSIMObjective(keras.losses.Loss): - """ DSSIM Loss Functions +class DSSIMObjective(nn.Module): + """DSSIM Loss Functions Difference of Structural Similarity (DSSIM loss function). - Adapted from :func:`tensorflow.image.ssim` for a pure keras implentation. + Adapted from :func:`tensorflow.image.ssim` for a pure keras implementation. Notes ----- @@ -35,15 +36,15 @@ class DSSIMObjective(keras.losses.Loss): Parameters ---------- - k_1: float, optional + k_1 Parameter of the SSIM. Default: `0.01` - k_2: float, optional + k_2 Parameter of the SSIM. Default: `0.03` - filter_size: int, optional + filter_size size of gaussian filter Default: `11` - filter_sigma: float, optional + filter_sigma Width of gaussian filter Default: `1.5` - max_value: float, optional + max_value Max value of the output. Default: `1.0` Notes @@ -57,7 +58,7 @@ def __init__(self, filter_sigma: float = 1.5, max_value: float = 1.0) -> None: logger.debug(parse_class_init(locals())) - super().__init__(name=self.__class__.__name__) + super().__init__() self._filter_size = filter_size self._filter_sigma = filter_sigma self._kernel = self._get_kernel() @@ -65,110 +66,111 @@ def __init__(self, compensation = 1.0 self._c1 = (k_1 * max_value) ** 2 self._c2 = ((k_2 * max_value) ** 2) * compensation - logger.debug("Initialized: %s", self.__class__.__name__) - def _get_kernel(self) -> KerasTensor: - """ Obtain the base kernel for performing depthwise convolution. + def _get_kernel(self) -> torch.Tensor: + """Obtain the base kernel for performing depthwise convolution. Returns ------- - :class:`keras.KerasTensor` - The gaussian kernel based on selected size and sigma + The gaussian kernel based on selected size and sigma """ - coords = np.arange(self._filter_size, dtype="float32") + coords = np.arange(self._filter_size, dtype=np.float32) coords -= (self._filter_size - 1) / 2. kernel = np.square(coords) kernel *= -0.5 / np.square(self._filter_sigma) kernel = np.reshape(kernel, (1, -1)) + np.reshape(kernel, (-1, 1)) - kernel = Variable(np.reshape(kernel, (1, -1)), trainable=False) - kernel = ops.softmax(kernel) - kernel = ops.reshape(kernel, (self._filter_size, self._filter_size, 1, 1)) - return T.cast("KerasTensor", kernel) + kernel_t = torch.from_numpy(np.reshape(kernel, (1, -1))) + kernel_t = torch.softmax(kernel_t, dim=-1) + kernel_t = torch.reshape(kernel_t, (1, 1, self._filter_size, self._filter_size)) + return kernel_t @classmethod - def _depthwise_conv2d(cls, image: KerasTensor, kernel: KerasTensor) -> KerasTensor: - """ Perform a standardized depthwise convolution. + def _depthwise_conv2d(cls, image: torch.Tensor, kernel: torch.Tensor) -> torch.Tensor: + """Perform a standardized depthwise convolution. Parameters ---------- - image: :class:`keras.KerasTensor` + image Batch of images, channels last, to perform depthwise convolution - kernel: :class:`keras.KerasTensor` + kernel convolution kernel Returns ------- - :class:`keras.KerasTensor` - The output from the convolution + The output from the convolution """ - return T.cast("KerasTensor", ops.depthwise_conv(image, kernel, strides=1, padding="valid")) + depth, in_ch, h, w = kernel.shape + kernel = torch.reshape(kernel, (in_ch * depth, 1, h, w)) + return F.conv2d(image, kernel, groups=in_ch) # pylint:disable=not-callable def _get_ssim(self, - y_true: KerasTensor, - y_pred: KerasTensor) -> tuple[KerasTensor, KerasTensor]: - """ Obtain the structural similarity between a batch of true and predicted images. + y_true: torch.Tensor, + y_pred: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + """Obtain the structural similarity between a batch of true and predicted images. Parameters ---------- - y_true: :class:`keras.KerasTensor` + y_true The input batch of ground truth images - y_pred: :class:`keras.KerasTensor` + y_pred The input batch of predicted images Returns ------- - :class:`keras.KerasTensor` + ssim The SSIM for the given images - :class:`keras.KerasTensor` + contrast The Contrast for the given images """ - channels = y_true.shape[-1] - kernel = ops.tile(self._kernel, (1, 1, channels, 1)) + channels = y_true.shape[1] + kernel = torch.tile(self._kernel, (1, channels, 1, 1)) # SSIM luminance measure is (2 * mu_x * mu_y + c1) / (mu_x ** 2 + mu_y ** 2 + c1) mean_true = self._depthwise_conv2d(y_true, kernel) mean_pred = self._depthwise_conv2d(y_pred, kernel) num_lum = mean_true * mean_pred * 2.0 - den_lum = ops.square(mean_true) + ops.square(mean_pred) + den_lum = torch.square(mean_true) + torch.square(mean_pred) luminance = (num_lum + self._c1) / (den_lum + self._c1) # SSIM contrast-structure measure is (2 * cov_{xy} + c2) / (cov_{xx} + cov_{yy} + c2) num_con = self._depthwise_conv2d(y_true * y_pred, kernel) * 2.0 - den_con = self._depthwise_conv2d( - T.cast("KerasTensor", ops.square(y_true) + ops.square(y_pred)), kernel) + den_con = self._depthwise_conv2d(torch.square(y_true) + torch.square(y_pred), kernel) contrast = (num_con - num_lum + self._c2) / (den_con - den_lum + self._c2) # Average over the height x width dimensions axes = (-3, -2) - ssim = T.cast("KerasTensor", ops.mean(luminance * contrast, axis=axes)) - contrast = T.cast("KerasTensor", ops.mean(contrast, axis=axes)) + ssim = torch.mean(luminance * contrast, dim=axes) + contrast = torch.mean(contrast, dim=axes) return ssim, contrast - def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: - """ Call the DSSIM or MS-DSSIM Loss Function. + def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: + """Call the DSSIM or MS-DSSIM Loss Function. Parameters ---------- - y_true: :class:`keras.KerasTensor` + y_true The input batch of ground truth images - y_pred: :class:`keras.KerasTensor` + y_pred The input batch of predicted images Returns ------- - :class:`keras.KerasTensor` - The DSSIM or MS-DSSIM for the given images + The final DSSIM or MS-DSSIM for each item in the batch """ + # TODO remove once channels first + y_true = y_true.permute(0, 3, 1, 2) + y_pred = y_pred.permute(0, 3, 1, 2) + ssim = self._get_ssim(y_true, y_pred)[0] retval = (1. - ssim) / 2.0 - return T.cast("KerasTensor", ops.mean(retval)) + return torch.mean(retval, dim=-1) -class GMSDLoss(keras.losses.Loss): - """ Gradient Magnitude Similarity Deviation Loss. +class GMSDLoss(nn.Module): + """Gradient Magnitude Similarity Deviation Loss. Improved image quality metric over MS-SSIM with easier calculations @@ -177,108 +179,109 @@ class GMSDLoss(keras.losses.Loss): http://www4.comp.polyu.edu.hk/~cslzhang/IQA/GMSD/GMSD.htm https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf """ - - def __init__(self, *args, **kwargs) -> None: + def __init__(self) -> None: logger.debug(parse_class_init(locals())) - super().__init__(*args, name=self.__class__.__name__, **kwargs) - self._scharr_edges = Variable(np.array([[[[0.00070, 0.00070]], - [[0.00520, 0.00370]], - [[0.03700, 0.00000]], - [[0.00520, -0.0037]], - [[0.00070, -0.0007]]], - [[[0.00370, 0.00520]], - [[0.11870, 0.11870]], - [[0.25890, 0.00000]], - [[0.11870, -0.1187]], - [[0.00370, -0.0052]]], - [[[0.00000, 0.03700]], - [[0.00000, 0.25890]], - [[0.00000, 0.00000]], - [[0.00000, -0.2589]], - [[0.00000, -0.0370]]], - [[[-0.0037, 0.00520]], - [[-0.1187, 0.11870]], - [[-0.2589, 0.00000]], - [[-0.1187, -0.1187]], - [[-0.0037, -0.0052]]], - [[[-0.0007, 0.00070]], - [[-0.0052, 0.00370]], - [[-0.0370, 0.00000]], - [[-0.0052, -0.0037]], - [[-0.0007, -0.0007]]]]), - dtype="float32", - trainable=False) - logger.debug("Initialized: %s", self.__class__.__name__) - - def _map_scharr_edges(self, image: KerasTensor, magnitude: bool) -> KerasTensor: - """ Returns a tensor holding modified Scharr edge maps. + super().__init__() + self._scharr_edges = torch.from_numpy( + np.array([[[[0.00070, 0.00070]], + [[0.00520, 0.00370]], + [[0.03700, 0.00000]], + [[0.00520, -0.0037]], + [[0.00070, -0.0007]]], + [[[0.00370, 0.00520]], + [[0.11870, 0.11870]], + [[0.25890, 0.00000]], + [[0.11870, -0.1187]], + [[0.00370, -0.0052]]], + [[[0.00000, 0.03700]], + [[0.00000, 0.25890]], + [[0.00000, 0.00000]], + [[0.00000, -0.2589]], + [[0.00000, -0.0370]]], + [[[-0.0037, 0.00520]], + [[-0.1187, 0.11870]], + [[-0.2589, 0.00000]], + [[-0.1187, -0.1187]], + [[-0.0037, -0.0052]]], + [[[-0.0007, 0.00070]], + [[-0.0052, 0.00370]], + [[-0.0370, 0.00000]], + [[-0.0052, -0.0037]], + [[-0.0007, -0.0007]]]], dtype=np.float32)) + self._initialized = False + + def _map_scharr_edges(self, image: torch.Tensor, magnitude: bool) -> torch.Tensor: + """Returns a tensor holding modified Scharr edge maps. Parameters ---------- - image: :class:`keras.KerasTensor` + image Image tensor with shape [batch_size, h, w, d] and type float32. The image(s) must be 2x2 or larger. - magnitude: bool + magnitude Boolean to determine if the edge magnitude or edge direction is returned Returns ------- - :class:`keras.KerasTensor` - Tensor holding edge maps for each channel. Returns a tensor with shape `[batch_size, h, - w, d, 2]` where the last two dimensions hold `[[dy[0], dx[0]], [dy[1], dx[1]], ..., - [dy[d-1], dx[d-1]]]` calculated using the Scharr filter. + Tensor holding edge maps for each channel. Returns a tensor with shape `[batch_size, h, w, + d, 2]` where the last two dimensions hold `[[dy[0], dx[0]], [dy[1], dx[1]], ..., [dy[d-1], + dx[d-1]]]` calculated using the Scharr filter. """ # Define vertical and horizontal Scharr filters. - image_shape = image.shape - num_kernels = [2] + bs, channels, height, width = image.shape - kernels = ops.tile(self._scharr_edges, [1, 1, image_shape[-1], 1]) + kernel = self._scharr_edges.repeat(1, 1, channels, 1) + h, w, _, depth = kernel.shape + kernel = kernel.permute(3, 2, 0, 1).reshape(channels * depth, 1, h, w) # Use depth-wise convolution to calculate edge maps per channel. # Output tensor has shape [batch_size, h, w, d * num_kernels]. - pad_sizes = [[0, 0], [2, 2], [2, 2], [0, 0]] - padded = ops.pad(image, pad_sizes, mode="reflect") - output = ops.depthwise_conv(padded, kernels) + padded = F.pad(image, (2, 2, 2, 2), mode="reflect") + out = F.conv2d(padded, kernel, groups=channels) # pylint:disable=not-callable if not magnitude: # direction of edges # Reshape to [batch_size, h, w, d, num_kernels]. - shape = ops.concatenate([image_shape, num_kernels], axis=0) - output = ops.reshape(output, shape) - output = ops.reshape(output, ops.concatenate([image_shape, num_kernels])) - output = torch.atan(T.cast("Tensor", - ops.squeeze(output[:, :, :, :, 0] / output[:, :, :, :, 1], - axis=None))) + out = out.reshape(bs, height, width, channels, 2) + gx = out[..., 0] + gy = out[..., 1] + out = torch.atan(gx / gy) # magnitude of edges -- unified x & y edges don't work well with Neural Networks - return T.cast("KerasTensor", output) + return out - def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: - """ Return the Gradient Magnitude Similarity Deviation Loss. + def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: + """Return the Gradient Magnitude Similarity Deviation Loss. Parameters ---------- - y_true: :class:`keras.KerasTensor` + y_true The ground truth value - y_pred: :class:`keras.KerasTensor` + y_pred The predicted value Returns ------- - :class:`keras.KerasTensor` - The loss value + The final loss value for each item in the batch """ + if not self._initialized: + self._scharr_edges = self._scharr_edges.to(y_pred.device) + self._initialized = True + + # TODO remove once channels first + y_true = y_true.permute(0, 3, 1, 2) + y_pred = y_pred.permute(0, 3, 1, 2) + true_edge = self._map_scharr_edges(y_true, True) pred_edge = self._map_scharr_edges(y_pred, True) - ephsilon = 0.0025 + epsilon = 0.0025 upper = 2.0 * true_edge * pred_edge - lower = ops.square(true_edge) + ops.square(pred_edge) - gms = (upper + ephsilon) / (lower + ephsilon) - gmsd = ops.std(gms, axis=(1, 2, 3), keepdims=True) - gmsd = ops.squeeze(gmsd, axis=-1) - return T.cast("KerasTensor", gmsd) + lower = torch.square(true_edge) + torch.square(pred_edge) + gms = (upper + epsilon) / (lower + epsilon) + gmsd = torch.std(gms, dim=(1, 2, 3)) + return gmsd class LDRFLIPLoss(keras.losses.Loss): # pylint:disable=too-many-instance-attributes - """ Computes the LDR-FLIP error map between two LDR images, assuming the images are observed + """Computes the LDR-FLIP error map between two LDR images, assuming the images are observed at a certain number of pixels per degree of visual angle. References @@ -363,7 +366,7 @@ def __init__(self, logger.debug("Initialized: %s ", self.__class__.__name__) def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: - """ Call the LDR Flip Loss Function + """Call the LDR Flip Loss Function Parameters ---------- @@ -394,7 +397,7 @@ def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: return T.cast("KerasTensor", loss) def _color_pipeline(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: - """ Perform the color processing part of the FLIP loss function + """Perform the color processing part of the FLIP loss function Parameters ---------- @@ -420,12 +423,13 @@ def _color_pipeline(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTens delta = self._hyab(preprocessed_true, preprocessed_pred) power_delta = T.cast("KerasTensor", ops.power(delta, self._computed_distance_exponent)) - cmax = T.cast("KerasTensor", ops.power(self._hyab(hunt_adjusted_green, hunt_adjusted_blue), - self._computed_distance_exponent)) - return self._redistribute_errors(power_delta, cmax) + c_max = T.cast("KerasTensor", ops.power(self._hyab(hunt_adjusted_green, + hunt_adjusted_blue), + self._computed_distance_exponent)) + return self._redistribute_errors(power_delta, c_max) def _process_features(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: - """ Perform the color processing part of the FLIP loss function + """Perform the color processing part of the FLIP loss function Parameters ---------- @@ -455,7 +459,7 @@ def _process_features(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTe @classmethod def _hunt_adjustment(cls, image: KerasTensor) -> KerasTensor: - """ Apply Hunt-adjustment to an image in L*a*b* color space + """Apply Hunt-adjustment to an image in L*a*b* color space Parameters ---------- @@ -472,7 +476,7 @@ def _hunt_adjustment(cls, image: KerasTensor) -> KerasTensor: return T.cast("KerasTensor", adjusted) def _hyab(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: - """ Compute the HyAB distance between true and predicted images. + """Compute the HyAB distance between true and predicted images. Parameters ---------- @@ -495,14 +499,14 @@ def _hyab(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: def _redistribute_errors(self, power_delta_e_hyab: KerasTensor, - cmax: KerasTensor) -> KerasTensor: - """ Redistribute exponentiated HyAB errors to the [0,1] range + c_max: KerasTensor) -> KerasTensor: + """Redistribute exponentiated HyAB errors to the [0,1] range Parameters ---------- power_delta_e_hyab: :class:`keras.KerasTensor` The exponentiated HyAb distance - cmax: :class:`keras.KerasTensor` + c_max: :class:`keras.KerasTensor` The exponentiated, maximum HyAB difference between two colors in Hunt-adjusted L*A*B* space @@ -511,16 +515,16 @@ def _redistribute_errors(self, :class:`keras.KerasTensor` The redistributed per-pixel HyAB distances (in range [0,1]) """ - pccmax = self._pc * cmax + pcc_max = self._pc * c_max delta_e_c = ops.where( - power_delta_e_hyab < pccmax, - (self._pt / pccmax) * power_delta_e_hyab, - self._pt + ((power_delta_e_hyab - pccmax) / (cmax - pccmax)) * (1.0 - self._pt)) + power_delta_e_hyab < pcc_max, + (self._pt / pcc_max) * power_delta_e_hyab, + self._pt + ((power_delta_e_hyab - pcc_max) / (c_max - pcc_max)) * (1.0 - self._pt)) return T.cast("KerasTensor", delta_e_c) class _SpatialFilters(): - """ Filters an image with channel specific spatial contrast sensitivity functions and clips + """Filters an image with channel specific spatial contrast sensitivity functions and clips result to the unit cube in linear RGB. For use with LDRFlipLoss. @@ -539,7 +543,7 @@ def __init__(self, pixels_per_degree: float) -> None: logger.debug("Initialized: %s", self.__class__.__name__) def _generate_spatial_filters(self) -> tuple[KerasTensor, int]: - """ Generates spatial contrast sensitivity filters with width depending on the number of + """Generates spatial contrast sensitivity filters with width depending on the number of pixels per degree of visual angle of the observer for channels "A", "RG" and "BY" Returns @@ -562,9 +566,9 @@ def _generate_spatial_filters(self) -> tuple[KerasTensor, int]: weights = np.array([self._generate_weights(mapping[channel], domain) for channel in ("A", "RG", "BY")]) - vweights = Variable(np.moveaxis(weights, 0, -1), dtype="float32", trainable=False) + v_weights = Variable(np.moveaxis(weights, 0, -1), dtype="float32", trainable=False) - return vweights, radius + return v_weights, radius def _get_evaluation_domain(self, b1_a: float, @@ -573,7 +577,7 @@ def _get_evaluation_domain(self, b2_rg: float, b1_by: float, b2_by: float) -> tuple[np.ndarray, int]: - """ TODO docstring """ + """TODO docstring """ max_scale_parameter = max([b1_a, b2_a, b1_rg, b2_rg, b1_by, b2_by]) delta_x = 1.0 / self._pixels_per_degree radius = int(np.ceil(3 * np.sqrt(max_scale_parameter / (2 * np.pi**2)) @@ -584,7 +588,7 @@ def _get_evaluation_domain(self, @classmethod def _generate_weights(cls, channel: dict[str, float], domain: np.ndarray) -> np.ndarray: - """ TODO docstring """ + """TODO docstring """ a_1, b_1, a_2, b_2 = channel["a1"], channel["b1"], channel["a2"], channel["b2"] grad = (a_1 * np.sqrt(np.pi / b_1) * np.exp(-np.pi ** 2 * domain / b_1) + a_2 * np.sqrt(np.pi / b_2) * np.exp(-np.pi ** 2 * domain / b_2)) @@ -593,7 +597,7 @@ def _generate_weights(cls, channel: dict[str, float], domain: np.ndarray) -> np. return grad def __call__(self, image: KerasTensor) -> KerasTensor: - """ Call the spacial filtering. + """Call the spacial filtering. Parameters ---------- @@ -616,7 +620,7 @@ def __call__(self, image: KerasTensor) -> KerasTensor: class _FeatureDetection(): - """ Detect features (i.e. edges and points) in an achromatic YCxCz image. + """Detect features (i.e. edges and points) in an achromatic YCxCz image. For use with LDRFlipLoss. @@ -643,7 +647,7 @@ def __init__(self, pixels_per_degree: float) -> None: logger.debug("Initialized: %s", self.__class__.__name__) def __call__(self, image: KerasTensor, feature_type: str) -> KerasTensor: - """ Run the feature detection + """Run the feature detection Parameters ---------- @@ -680,22 +684,22 @@ def __call__(self, image: KerasTensor, feature_type: str) -> KerasTensor: return T.cast("KerasTensor", features) -class MSSIMLoss(keras.losses.Loss): - """ Multiscale Structural Similarity Loss Function +class MSSIMLoss(nn.Module): + """Multi-scale Structural Similarity Loss Function Parameters ---------- - k_1: float, optional + k_1 Parameter of the SSIM. Default: `0.01` - k_2: float, optional + k_2 Parameter of the SSIM. Default: `0.03` - filter_size: int, optional + filter_size size of gaussian filter Default: `11` - filter_sigma: float, optional + filter_sigma Width of gaussian filter Default: `1.5` - max_value: float, optional + max_value Max value of the output. Default: `1.0` - power_factors: tuple, optional + power_factors Iterable of weights for each of the scales. The number of scales used is the length of the list. Index 0 is the unscaled resolution's weight and each increasing scale corresponds to the image being downsampled by 2. Defaults to the values obtained in the original paper. @@ -704,7 +708,7 @@ class MSSIMLoss(keras.losses.Loss): Notes ------ You should add a regularization term like a l2 loss in addition to this one. - Adapted from Tehnsorflow's ssim_multiscale implementation + Adapted from Tensorflow's ssim_multi-scale implementation """ def __init__(self, k_1: float = 0.01, @@ -715,58 +719,58 @@ def __init__(self, power_factors: tuple[float, ...] = (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) ) -> None: logger.debug(parse_class_init(locals())) - super().__init__(name=self.__class__.__name__) + super().__init__() self.filter_size = filter_size - self._filter_sigma = Variable(filter_sigma, dtype="float32", trainable=False) + self._filter_sigma_sq = filter_sigma ** 2 self._k_1 = k_1 self._k_2 = k_2 self._max_value = max_value - self._power_factors = power_factors - self._divisor = [1, 2, 2, 1] - self._divisor_tensor = Variable(self._divisor[1:], dtype="int32", trainable=False) - logger.debug("Initialized: %s", self.__class__.__name__) + self._power_factors = torch.Tensor(power_factors).float() + self._divisor = [1, 1, 2, 2] + self._divisor_tensor = torch.Tensor(self._divisor[1:]).int() + self._initialized = False @classmethod - def _reducer(cls, image: KerasTensor, kernel: KerasTensor) -> KerasTensor: - """ Computes local averages from a set of images + def _reducer(cls, image: torch.Tensor, kernel: torch.Tensor) -> torch.Tensor: + """Computes local averages from a set of images Parameters ---------- - image: :class:`keras.KerasTensor` - The images to be processed - kernel: :class:`keras.KerasTensor` - The kernel to apply + image + The images to be processed (N,C,H,W) + kernel + The kernel to apply in depthwise format (C,1,H,W) Returns ------- - :class:`keras.KerasTensor` - The reduced image + The reduced image """ shape = image.shape - var_x = ops.reshape(image, (-1, *shape[-3:])) - var_y = ops.nn.depthwise_conv(var_x, kernel, strides=1, padding="valid") - return T.cast("KerasTensor", ops.reshape(var_y, (*shape[:-3], *var_y.shape[1:]))) + channels = shape[-3] + x = image.reshape(-1, *shape[-3:]) + y = F.conv2d(x, kernel, groups=channels) # pylint:disable=not-callable + return y.reshape((*shape[:-3], *y.shape[1:])) def _ssim_helper(self, - image1: KerasTensor, - image2: KerasTensor, - kernel: KerasTensor) -> tuple[KerasTensor, KerasTensor]: - """ Helper function for computing SSIM + image1: torch.Tensor, + image2: torch.Tensor, + kernel: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + """Helper function for computing SSIM Parameters ---------- - image1: :class:`keras.KerasTensor` - The first set of images - image2: :class:`keras.KerasTensor` - The second set of images - kernel: :class:`keras.KerasTensor` - The gaussian kernel + image1 + The first set of images (N,C,H,W) + image2 + The second set of images (N,C,H,W) + kernel + The gaussian kernel in depthwise format (C,1,H,W) Returns ------- - :class:`keras.KerasTensor`: + ssim The channel-wise SSIM - :class:`keras.KerasTensor`: + contrast The channel-wise contrast-structure """ c_1 = (self._k_1 * self._max_value) ** 2 @@ -775,44 +779,41 @@ def _ssim_helper(self, mean0 = self._reducer(image1, kernel) mean1 = self._reducer(image2, kernel) num0 = mean0 * mean1 * 2.0 - den0 = ops.square(mean0) + ops.square(mean1) + den0 = mean0 ** 2 + mean1 ** 2 luminance = (num0 + c_1) / (den0 + c_1) num1 = self._reducer(image1 * image2, kernel) * 2.0 - den1 = self._reducer(T.cast("KerasTensor", ops.square(image1) + ops.square(image2)), - kernel) + den1 = self._reducer(image1 ** 2 + image2 ** 2, kernel) cs_ = (num1 - num0 + c_2) / (den1 - den0 + c_2) return luminance, cs_ - def _fspecial_gauss(self, size: int) -> KerasTensor: + def _fspecial_gauss(self, size: int) -> torch.Tensor: """Function to mimic the 'fspecial' gaussian MATLAB function. Parameters ---------- - filter_size: int + filter_size size of gaussian filter Returns ------- - :class:`keras.KerasTensor` - The gaussian kernel + The gaussian kernel in channels first depthwise format (C,1,H,W) """ - coords = ops.cast(range(size), self._filter_sigma.dtype) - coords -= ops.cast(size - 1, self._filter_sigma.dtype) / 2.0 + coords = torch.arange(0, size, dtype=torch.float32, device=self._divisor_tensor.device) + coords -= size - 1 / 2. - gauss = ops.square(coords) - gauss *= -0.5 / ops.square(self._filter_sigma) + gauss = coords ** 2 * (-0.5 / self._filter_sigma_sq) - gauss = ops.reshape(gauss, [1, -1]) + ops.reshape(gauss, [-1, 1]) - gauss = ops.reshape(gauss, [1, -1]) # For ops.softmax(). - gauss = ops.softmax(gauss) - return T.cast("KerasTensor", ops.reshape(gauss, [size, size, 1, 1])) + gauss = gauss.reshape(1, -1) + gauss.reshape(-1, 1) + gauss = gauss.reshape(1, -1) # For ops.softmax(). + gauss = F.softmax(gauss, dim=-1) + return gauss.reshape(1, 1, size, size) def _ssim_per_channel(self, - image1: KerasTensor, - image2: KerasTensor, - filter_size: int) -> tuple[KerasTensor, KerasTensor]: + image1: torch.Tensor, + image2: torch.Tensor, + filter_size: int) -> tuple[torch.Tensor, torch.Tensor]: """Computes SSIM index between image1 and image2 per color channel. This function matches the standard SSIM implementation from: @@ -822,67 +823,64 @@ def _ssim_per_channel(self, Parameters ---------- - image1: :class:`keras.KerasTensor` - The first image batch - image2: :class:`keras.KerasTensor` - The second image batch. - filter_size: int - size of gaussian filter). + image1 + The first image batch (N,C,H,W) + image2 + The second image batch. (N,C,H,W) + filter_size + size of gaussian filter. Returns ------- - :class:`keras.KerasTensor`: + ssim The channel-wise SSIM - :class:`keras.KerasTensor`: + contrast The channel-wise contrast-structure """ - shape = image1.shape - + channels = image1.shape[-3] kernel = self._fspecial_gauss(filter_size) - kernel = ops.tile(kernel, [1, 1, shape[-1], 1]) - + kernel = kernel.repeat(channels, 1, 1, 1) luminance, cs_ = self._ssim_helper(image1, image2, kernel) - # Average over the second and the third from the last: height, width. - ssim_val = T.cast("KerasTensor", ops.mean(luminance * cs_, [-3, -2])) - cs_ = T.cast("KerasTensor", ops.mean(cs_, [-3, -2])) + # Average over height, width. + ssim_val = (luminance * cs_).mean(dim=[-2, -1]) + cs_ = cs_.mean(dim=[-2, -1]) return ssim_val, cs_ @classmethod - def _do_pad(cls, images: list[KerasTensor], remainder: KerasTensor) -> list[KerasTensor]: - """ Pad images + def _do_pad(cls, images: list[torch.Tensor], remainder: torch.Tensor) -> list[torch.Tensor]: + """Pad images Parameters ---------- - images: list[:class:`keras.KerasTensor`] - Images to pad - remainder: :class:`keras.KerasTensor` - Remainding images to pad + images + Images to pad (N,C,H,W) + remainder + Remaining images to pad (C,H,W) Returns ------- - list[:class:`keras.KerasTensor`] - Padded images + Padded images (N,C,H,W) """ - padding = ops.expand_dims(remainder, axis=-1) - padding = ops.pad(padding, [[1, 0], [1, 0]], mode="constant") - return [ops.pad(x, padding, mode="symmetric") for x in images] + height = int(remainder[1]) + width = int(remainder[2]) + return [F.pad(x, (0, width, 0, height), mode="replicate") for x in images] def _mssism(self, # pylint:disable=too-many-locals - y_true: KerasTensor, - y_pred: KerasTensor, - filter_size: int) -> KerasTensor: - """ Perform the MSSISM calculation. + y_true: torch.Tensor, + y_pred: torch.Tensor, + filter_size: int) -> torch.Tensor: + """Perform the MSSISM calculation. Ported from Tensorflow implementation `image.ssim_multiscale` Parameters ---------- - y_true: :class:`keras.KerasTensor` + y_true The ground truth value - y_pred: :class:`keras.KerasTensor` + y_pred The predicted value - filter_size: int + filter_size The filter size to use """ images = [y_true, y_pred] @@ -895,65 +893,65 @@ def _mssism(self, # pylint:disable=too-many-locals for k in range(len(self._power_factors)): if k > 0: # Avg pool takes rank 4 tensors. Flatten leading dimensions. - flat_images = [T.cast("KerasTensor", ops.reshape(x, (-1, *t))) - for x, t in zip(images, tails)] - remainder = tails[0] % self._divisor_tensor - - need_padding = ops.any(ops.not_equal(remainder, 0)) - padded = ops.cond( - need_padding, - lambda: self._do_pad(flat_images, # pylint:disable=cell-var-from-loop - remainder), # pylint:disable=cell-var-from-loop - lambda: flat_images) # pylint:disable=cell-var-from-loop - - downscaled = [ops.average_pool(x, - self._divisor[1:3], - strides=self._divisor[1:3], - padding='valid') - for x in padded] + flat_images = [(x.reshape(-1, *t)) for x, t in zip(images, tails)] + remainder = torch.tensor(list(tails[0]), + dtype=torch.int32, + device=y_pred.device) % self._divisor_tensor + if (remainder != 0).any(): + flat_images = self._do_pad(flat_images, remainder) + + downscaled = [F.avg_pool2d(x, # pylint:disable=not-callable + self._divisor[1:3], + stride=self._divisor[1:3], + padding=0) + for x in flat_images] tails = [x.shape[1:] for x in downscaled] - images = [T.cast("KerasTensor", ops.reshape(x, (*h, *t))) - for x, h, t in zip(downscaled, heads, tails)] + images = [x.reshape(*h, *t) for x, h, t in zip(downscaled, heads, tails)] # Overwrite previous ssim value since we only need the last one. ssim_per_channel, cs_ = self._ssim_per_channel(images[0], images[1], filter_size) - mcs.append(ops.relu(cs_)) + mcs.append(F.relu(cs_)) mcs.pop() # Remove the cs score for the last scale. + assert ssim_per_channel is not None + mcs_and_ssim = torch.stack(mcs + [F.relu(ssim_per_channel)], dim=-1) + ms_ssim = torch.prod(mcs_and_ssim ** self._power_factors, dim=-1) + return ms_ssim.mean(dim=-1) # Avg over color channels. - mcs_and_ssim = ops.stack(mcs + [ops.relu(ssim_per_channel)], axis=-1) - ms_ssim = ops.prod(ops.power(mcs_and_ssim, self._power_factors), [-1]) - - return T.cast("KerasTensor", ops.mean(ms_ssim, [-1])) # Avg over color channels. - - def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: - """ Call the MS-SSIM Loss Function. + def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: + """Call the MS-SSIM Loss Function. Parameters ---------- - y_true: :class:`keras.KerasTensor` + y_true The ground truth value - y_pred: :class:`keras.KerasTensor` + y_pred The predicted value Returns ------- - :class:`keras.KerasTensor` - The MS-SSIM Loss value + The MS-SSIM Loss value """ - im_size = y_true.shape[1] - assert isinstance(im_size, int) + if not self._initialized: + self._divisor_tensor = self._divisor_tensor.to(y_pred.device) + self._power_factors = self._power_factors.to(y_pred.device) + self._initialized = True + # TODO remove once channels first + y_true = y_true.permute(0, 3, 1, 2) + y_pred = y_pred.permute(0, 3, 1, 2) + + im_size = y_true.shape[2] # filter size cannot be larger than the smallest scale smallest_scale = self._get_smallest_size(im_size, len(self._power_factors) - 1) filter_size = min(self.filter_size, smallest_scale) ms_ssim = self._mssism(y_true, y_pred, filter_size) ms_ssim_loss = 1. - ms_ssim - return T.cast("KerasTensor", ops.mean(ms_ssim_loss)) + return ms_ssim_loss def _get_smallest_size(self, size: int, idx: int) -> int: - """ Recursive function to obtain the smallest size that the image will be scaled to. + """Recursive function to obtain the smallest size that the image will be scaled to. Parameters ---------- diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index a5253bb758..c961644c1d 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -9,7 +9,6 @@ import sys import typing as T -import torch import keras from lib.logger import parse_class_init @@ -29,35 +28,6 @@ logger = logging.getLogger(__name__) -# TODO move this useful utility function -def get_device(cpu: bool = False) -> torch.device: - """Get the correctly configured device for running inference - - Parameters - ---------- - cpu - ``True`` to force running on the CPU. - - Returns - ------- - The device that torch should use - """ - if cpu: - logger.debug("CPU mode selected. Returning CPU device context") - return torch.device("cpu") - - if torch.cuda.is_available(): - logger.debug(" Cuda available. Returning Cuda device context") - return torch.device("cuda") - - if torch.backends.mps.is_available(): - logger.debug(" MPS available. Returning MPS device context") - return torch.device("mps") - - logger.debug(" No backends available. Returning CPU device context") - return torch.device("cpu") - - class ModelBase(): # pylint:disable=too-many-instance-attributes """Base class that all model plugins should inherit from. @@ -114,7 +84,7 @@ def __init__(self, self._settings = Settings(self._args, self._mixed_precision, self._is_predict) - self._loss = Loss(self.color_order, get_device()) + self._loss = Loss(self.color_order) logger.debug("Initialized ModelBase (%s)", self.__class__.__name__) diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index bb97bff479..b516905671 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -29,7 +29,6 @@ if T.TYPE_CHECKING: from collections.abc import Callable from argparse import Namespace - import torch from keras import KerasTensor from .state import State @@ -64,10 +63,8 @@ class Loss(): ---------- color_order Color order of the model. One of `"BGR"` or `"RGB"` - device - The device to load the loss function on to, if applicable """ - def __init__(self, color_order: T.Literal["bgr", "rgb"], device: torch.Device) -> None: + def __init__(self, color_order: T.Literal["bgr", "rgb"]) -> None: logger.debug(parse_class_init(locals())) self._mask_channels = self._get_mask_channels() self._inputs: list[keras.layers.Layer] = [] @@ -85,18 +82,15 @@ def __init__(self, color_order: T.Literal["bgr", "rgb"], device: torch.Device) - "lpips_alex": LossClass(function=losses.LPIPSLoss, kwargs={"trunk_network": "alex", "crop": True, - "color_order": color_order, - "device": device}), + "color_order": color_order}), "lpips_squeeze": LossClass(function=losses.LPIPSLoss, kwargs={"trunk_network": "squeeze", "crop": True, - "color_order": color_order, - "device": device}), + "color_order": color_order}), "lpips_vgg16": LossClass(function=losses.LPIPSLoss, kwargs={"trunk_network": "vgg16", "crop": True, - "color_order": color_order, - "device": device}), + "color_order": color_order}), "ms_ssim": LossClass(function=losses.MSSIMLoss), "mae": LossClass(function=k_losses.MeanAbsoluteError), "mse": LossClass(function=k_losses.MeanSquaredError), diff --git a/tests/lib/model/losses/feature_loss_test.py b/tests/lib/model/losses/feature_loss_test.py index 4f2e3172f5..12eb0981cd 100644 --- a/tests/lib/model/losses/feature_loss_test.py +++ b/tests/lib/model/losses/feature_loss_test.py @@ -21,4 +21,4 @@ def test_loss_output(net): objective_output = LPIPSLoss(net)(y_a, y_b) output = objective_output.detach().numpy() # type:ignore assert output.dtype == "float32" and not np.any(np.isnan(output)) - assert output < 0.1 # LPIPS loss is reduced 10x + assert (output <= 0.1).all() # LPIPS loss is reduced 10x diff --git a/tests/lib/model/losses/loss_test.py b/tests/lib/model/losses/loss_test.py index bf35bb5117..d0492d1255 100644 --- a/tests/lib/model/losses/loss_test.py +++ b/tests/lib/model/losses/loss_test.py @@ -7,7 +7,8 @@ import pytest import numpy as np -from keras import device, losses as k_losses, Variable +from keras import device, losses as k_losses +import torch from lib.model.losses.loss import (FocalFrequencyLoss, GeneralizedLoss, GradientLoss, LaplacianPyramidLoss, LInfNorm, LossWrapper) @@ -28,13 +29,12 @@ @pytest.mark.parametrize(["loss_func", "max_target"], _PARAMS, ids=_IDS) def test_loss_output(loss_func, max_target): """ Basic dtype and value tests for loss functions. """ - with device("cpu"): - y_a = Variable(np.random.random((2, 32, 32, 3))) - y_b = Variable(np.random.random((2, 32, 32, 3))) - objective_output = loss_func()(y_a, y_b) + y_a = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() + y_b = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() + objective_output = loss_func()(y_a, y_b) output = objective_output.detach().numpy() assert output.dtype == "float32" and not np.any(np.isnan(output)) - assert output < max_target + assert (output <= max_target).all() _LWPARAMS = [(FocalFrequencyLoss, ()), @@ -60,8 +60,8 @@ def test_loss_wrapper(loss_func, func_args): p_loss = LossWrapper() p_loss.add_loss(loss_func(*func_args), 1.0, -1) p_loss.add_loss(k_losses.MeanSquaredError(), 2.0, 3) - y_a = Variable(np.random.random((2, 32, 32, 4))) - y_b = Variable(np.random.random((2, 32, 32, 3))) + y_a = torch.Tensor(np.random.random((2, 32, 32, 4))).cpu() + y_b = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() output = p_loss(y_a, y_b) output = output.detach().numpy() # type:ignore diff --git a/tests/lib/model/losses/perceptual_loss_test.py b/tests/lib/model/losses/perceptual_loss_test.py index 9a37829323..0b4754d6ba 100644 --- a/tests/lib/model/losses/perceptual_loss_test.py +++ b/tests/lib/model/losses/perceptual_loss_test.py @@ -2,7 +2,8 @@ """ Tests for Faceswap Feature Losses. Adapted from Keras tests. """ import pytest import numpy as np -from keras import device, Variable +from keras import device +import torch # pylint:disable=import-error from lib.model.losses.perceptual_loss import DSSIMObjective, GMSDLoss, LDRFLIPLoss, MSSIMLoss @@ -17,9 +18,9 @@ def test_loss_output(loss_func): """ Basic dtype and value tests for loss functions. """ with device("cpu"): - y_a = Variable(np.random.random((2, 32, 32, 3))) - y_b = Variable(np.random.random((2, 32, 32, 3))) + y_a = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() + y_b = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() objective_output = loss_func()(y_a, y_b) output = objective_output.detach().numpy() # type:ignore assert output.dtype == "float32" and not np.any(np.isnan(output)) - assert output < 1.0 + assert (output <= 1.0).all() From 4dc652cb7f9b5363a5360d2bdbe435034e2c4db2 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:15:22 +0100 Subject: [PATCH 964/981] bugfix: FS2 to 3 SeparableConv2D update --- plugins/train/model/_base/update.py | 109 ++++++++++++++-------------- 1 file changed, 54 insertions(+), 55 deletions(-) diff --git a/plugins/train/model/_base/update.py b/plugins/train/model/_base/update.py index 2130c0b464..1de35b7f7c 100644 --- a/plugins/train/model/_base/update.py +++ b/plugins/train/model/_base/update.py @@ -1,5 +1,5 @@ #! /usr/env/bin/python3 -""" Updating legacy faceswap models to the current version """ +"""Updating legacy faceswap models to the current version """ import json import logging import os @@ -9,7 +9,7 @@ import h5py import numpy as np -from keras import models as kmodels +from keras import models as k_models from lib.logger import parse_class_init from lib.model.layers import ScalarOp @@ -20,7 +20,7 @@ class Legacy: # pylint:disable=too-few-public-methods - """ Handles the updating of Keras 2.x models to Keras 3.x + """Handles the updating of Keras 2.x models to Keras 3.x Generally Keras 2.x models will open in Keras 3.x. There are a couple of bugs in Keras 3 legacy loading code which impacts Faceswap models: @@ -31,7 +31,7 @@ class Legacy: # pylint:disable=too-few-public-methods Parameters ---------- - model_path: str + model_path Full path to the legacy Keras 2.x model h5 file to upgrade """ def __init__(self, model_path: str): @@ -40,21 +40,18 @@ def __init__(self, model_path: str): """str: Full path to the old .h5 model file""" self._new_model_file = f"{os.path.splitext(model_path)[0]}.keras" """str: Full path to the new .keras model file""" - self._functionals: set[str] = set() + self._functional: set[str] = set() """set[str]: The name of any Functional models discovered in the keras 2 model config""" - self._upgrade_model() - logger.debug("Initialized %s", self.__class__.__name__) def _get_model_config(self) -> dict[str, T.Any]: - """ Obtain a keras 2.x config from a keras 2.x .h5 file. + """Obtain a keras 2.x config from a keras 2.x .h5 file. As keras 3.x will error out loading the file, we collect it directly from the .h5 file Returns ------- - dict[str, Any] - A keras 2.x model configuration dictionary + A keras 2.x model configuration dictionary Raises ------ @@ -77,17 +74,16 @@ def _get_model_config(self) -> dict[str, T.Any]: @classmethod def _unwrap_outputs(cls, outputs: list[list[T.Any]]) -> list[list[str | int]]: - """ Unwrap nested output tensors from a config dict to be a single list of output tensor + """Unwrap nested output tensors from a config dict to be a single list of output tensor Parameters ---------- - outputs: list[list[Any]] + outputs The outputs that exist within the Keras 2 config dict that may be nested Returns ------- - list[list[str | int]] - The output configuration formatted to be compatible with Keras 3 + The output configuration formatted to be compatible with Keras 3 """ retval = np.array(outputs).reshape(-1, 3).tolist() for item in retval: @@ -97,12 +93,11 @@ def _unwrap_outputs(cls, outputs: list[list[T.Any]]) -> list[list[str | int]]: return retval def _get_clip_config(self) -> dict[str, T.Any]: - """ Build a clip model from the configuration information stored in the legacy state file + """Build a clip model from the configuration information stored in the legacy state file Returns ------- - dict[str, T.Any] - The new keras configuration for a Clip model + The new keras configuration for a Clip model Raises ------ @@ -139,12 +134,12 @@ def _get_clip_config(self) -> dict[str, T.Any]: return retval def _convert_lambda_config(self, layer: dict[str, T.Any]): - """ Keras 2 TFLambdaOps are not compatible with Keras 3. Scalar operations can be + """Keras 2 TFLambdaOps are not compatible with Keras 3. Scalar operations can be relatively easily substituted with a :class:`~lib.model.layers.ScalarOp` layer Parameters ---------- - layer: dict[str, Any] + layer An existing Keras 2 TFLambdaOp layer Raises @@ -173,15 +168,15 @@ def _convert_lambda_config(self, layer: dict[str, T.Any]): layer["inbound_nodes"] = [layer["inbound_nodes"]] logger.debug("Converted legacy TFLambdaOp to %s", layer) - def _process_deprecations(self, layer: dict[str, T.Any]) -> None: - """ Some layer kwargs are deprecated between Keras 2 and Keras 3. Some are not mission + def _process_deprecations(self, layer: dict[str, T.Any]) -> None: # noqa[C901] + """Some layer kwargs are deprecated between Keras 2 and Keras 3. Some are not mission critical, but updating these here prevents Keras from outputting warnings about deprecated arguments. Others will fail to load the legacy model (eg Clip) so are replaced with a new config. Operation is performed in place Parameters ---------- - layer: dict[str, T.Any] + layer A keras model config item representing a keras layer """ if layer["class_name"] == "LeakyReLU": @@ -203,11 +198,17 @@ def _process_deprecations(self, layer: dict[str, T.Any]) -> None: self._convert_lambda_config(layer) if layer["class_name"] in ("DepthwiseConv2D", + "SeparableConv2D", "Conv2DTranspose") and "groups" in layer["config"]: # groups parameter doesn't exist in Keras 3. Hopefully it still works the same logger.debug("Removing groups from %s '%s'", layer["class_name"], layer["name"]) del layer["config"]["groups"] + if layer["class_name"] == "SeparableConv2D": + for key in ("kernel_initializer", "kernel_regularizer", "kernel_constraint"): + logger.debug("Removing '%s' from %s '%s'", key, layer["class_name"], layer["name"]) + del layer["config"][key] + if "dtype" in layer["config"]: # Incorrectly stored dtypes error when deserializing the new config. May be a Keras bug actual_dtype = None @@ -230,14 +231,14 @@ def _process_inbounds(self, layer_name: str, inbound_nodes: list[list[list[str | int]]] | list[list[str | int]] ) -> None: - """ If the inbound nodes are from a shared functional model, decrement the node index by + """If the inbound nodes are from a shared functional model, decrement the node index by one. Operation is performed in place Parameters ---------- - layer_name: str + layer_name The name of the layer (for logging) - inbound_nodes: list[list[list[str | int]]] | list[list[str | int]] + inbound_nodes The inbound nodes from a Keras 2 config dict to process """ to_process = T.cast( @@ -248,19 +249,19 @@ def _process_inbounds(self, for node in inbound: name, node_index = node[0], node[1] assert isinstance(name, str) and isinstance(node_index, int) - if name in self._functionals and node_index > 0: + if name in self._functional and node_index > 0: logger.debug("Updating '%s' inbound node index for '%s' from %s to %s", layer_name, name, node_index, node_index - 1) node[1] = node_index - 1 def _update_layers(self, layer_list: list[dict[str, T.Any]]) -> None: - """ Given a list of keras layers from a keras 2 config dict, increment the indices for + """Given a list of keras layers from a keras 2 config dict, increment the indices for any inbound nodes that come from a shared Functional model. Flatten any nested output tensor lists. Operations are performed in place Parameters ---------- - layers: list[dict[str, Any]] + layers A list of layers that belong to a keras 2 functional model config dictionary """ for layer in layer_list: @@ -269,7 +270,7 @@ def _update_layers(self, layer_list: list[dict[str, T.Any]]) -> None: if layer.get("name"): logger.debug("Storing layer: '%s'", layer["name"]) - self._functionals.add(layer["name"]) + self._functional.add(layer["name"]) layer["config"]["output_layers"] = self._unwrap_outputs( layer["config"]["output_layers"]) @@ -283,7 +284,7 @@ def _update_layers(self, layer_list: list[dict[str, T.Any]]) -> None: self._process_inbounds(layer["name"], layer["inbound_nodes"]) def _archive_model(self) -> str: - """ Archive an existing Keras 2 model to a new archive location + """Archive an existing Keras 2 model to a new archive location Raises ------ @@ -292,8 +293,7 @@ def _archive_model(self) -> str: Returns ------- - str - The path to the archived keras 2 model folder + The path to the archived keras 2 model folder """ model_dir = os.path.dirname(self._old_model_file) dst_path = f"{model_dir}_fs2_backup" @@ -312,12 +312,12 @@ def _archive_model(self) -> str: return dst_path def _restore_files(self, archive_dir: str) -> None: - """ Copy the state.json file and the logs folder from the archive folder to the new model + """Copy the state.json file and the logs folder from the archive folder to the new model folder Parameters ---------- - archive_dir: str + archive_dir The full path to the archived Keras 2 model """ model_dir = os.path.dirname(self._new_model_file) @@ -342,14 +342,14 @@ def _restore_files(self, archive_dir: str) -> None: logger.debug("Skipping file: '%s'", fname) def _upgrade_model(self) -> None: - """ Get the model configuration of a Faceswap 2 model and upgrade it to Faceswap 3 - compatible """ + """Get the model configuration of a Faceswap 2 model and upgrade it to Faceswap 3 + compatible""" logger.info("Upgrading model file from Faceswap 2 to Faceswap 3...") config = self._get_model_config() self._update_layers([config]) logger.debug("Migrating data to new model...") - model = kmodels.Model.from_config(config["config"]) + model = k_models.Model.from_config(config["config"]) model.load_weights(self._old_model_file) archive_dir = self._archive_model() @@ -365,12 +365,12 @@ def _upgrade_model(self) -> None: class PatchKerasConfig: - """ This class exists to patch breaking changes when moving from older keras 3.x models to + """This class exists to patch breaking changes when moving from older keras 3.x models to newer versions Parameters ---------- - model_path : str + model_path Full path to the keras model to be patched for the current version """ def __init__(self, model_path: str) -> None: @@ -382,14 +382,14 @@ def __init__(self, model_path: str) -> None: logger.debug("Initialized: %s", self.__class__.__name__) def _load_model(self) -> tuple[dict[str, bytes], dict[str, T.Any]]: - """ Load the objects from the compressed keras model + """Load the objects from the compressed keras model Returns ------- - items : dict[str, bytes] + items The filename and file objects within the keras 3 model file that are not the model config - config : dict[str, Any] + config The model configuration dictionary from the keras 3 model file """ with zipfile.ZipFile(self._model_path, "r") as zf: @@ -401,7 +401,7 @@ def _load_model(self) -> tuple[dict[str, bytes], dict[str, T.Any]]: return items, config def _update_nn_blocks(self, layer: dict[str, T.Any]): - """ In older versions of keras our :class:`lib.model.nn_blocks.Conv2D` and + """In older versions of keras our :class:`lib.model.nn_blocks.Conv2D` and :class:`lib.model.nn_blocks.DepthwiseConv2D` inherited from their respective Keras layers. Sometime between 3.3.3 and 3.12 (during beta testing) this stopped working, raising a TypeError. Subsequently we have refactored those classes to no longer inherit, and call the @@ -410,7 +410,7 @@ def _update_nn_blocks(self, layer: dict[str, T.Any]): Parameters ---------- - layer dict[str, Any] + layer A layer config dictionary from a keras 3 model """ if (layer.get("module") == "lib.model.nn_blocks" and @@ -424,11 +424,11 @@ def _update_nn_blocks(self, layer: dict[str, T.Any]): layer["module"] = new_module def _parse_inbound_args(self, inbound: list | dict[str, T.Any]) -> None: - """ Recurse through keras inbound node args until we arrive at a dictionary + """Recurse through keras inbound node args until we arrive at a dictionary Parameters ---------- - list[lisr | dict[str, Any]] + inbound A Keras inbound nodes args entry or the nested dictionary """ if not isinstance(inbound, (list, dict)): @@ -451,7 +451,7 @@ def _parse_inbound_args(self, inbound: list | dict[str, T.Any]) -> None: arg_conf["keras_history"] = new_hist def _update_dot_naming(self, layer: dict[str, T.Any]): - """ Sometime between 3.3.3 and 3.12 (during beta testing) layers with "." in the name + """Sometime between 3.3.3 and 3.12 (during beta testing) layers with "." in the name started generating a KeyError. This is odd as the error comes from Torch, but dot naming is standard. To work around this all dots (.) in layer names have been converted to underscores (_). The keras config needs to be rewritten to reflect this. This only impacts @@ -459,7 +459,7 @@ def _update_dot_naming(self, layer: dict[str, T.Any]): Parameters ---------- - layer dict[str, Any] + layer A layer config dictionary from a keras 3 model """ if "." in layer["name"]: @@ -480,17 +480,16 @@ def _update_dot_naming(self, layer: dict[str, T.Any]): self._parse_inbound_args(arg) def _update_config(self, config: dict[str, T.Any]) -> dict[str, T.Any]: - """ Recursively update the `config` dictionary from a full keras config in place + """Recursively update the `config` dictionary from a full keras config in place Parameters ---------- - config : dict[str, Any] + config A 'config' section of keras config Returns ------- - dict[str, Any] - The updated `config` section of a keras config + The updated `config` section of a keras config """ layer: dict[str, T.Any] for layer in config["layers"]: @@ -502,7 +501,7 @@ def _update_config(self, config: dict[str, T.Any]) -> dict[str, T.Any]: return config def _save_model(self) -> None: - """ Save the updated keras model """ + """Save the updated keras model""" logger.info("Updating Keras model '%s'...", self._model_path) with zipfile.ZipFile(self._model_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: for filename, data in self._items.items(): @@ -510,8 +509,8 @@ def _save_model(self) -> None: zf.writestr("config.json", json.dumps(self._config).encode("utf-8")) def __call__(self) -> None: - """ Update the keras configuration saved in a keras model file and save over the original - model """ + """Update the keras configuration saved in a keras model file and save over the original + model""" logger.debug("Updating saved config for keras version %s", self._version) self._config["config"] = self._update_config(self._config["config"]) self._save_model() From dd25f4533a9c41cee7e471feda9439d3b599f4ab Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:35:20 +0100 Subject: [PATCH 965/981] All loss functions to Torch --- lib/keras_utils.py | 354 --------------- lib/model/losses/feature_loss.py | 25 +- lib/model/losses/loss.py | 32 +- lib/model/losses/perceptual_loss.py | 406 ++++++++---------- lib/torch_utils.py | 292 +++++++++++++ tests/lib/model/losses/feature_loss_test.py | 3 +- tests/lib/model/losses/loss_test.py | 3 +- .../lib/model/losses/perceptual_loss_test.py | 11 +- 8 files changed, 517 insertions(+), 609 deletions(-) delete mode 100644 lib/keras_utils.py create mode 100644 lib/torch_utils.py diff --git a/lib/keras_utils.py b/lib/keras_utils.py deleted file mode 100644 index 916f3963cd..0000000000 --- a/lib/keras_utils.py +++ /dev/null @@ -1,354 +0,0 @@ -#!/usr/bin/env python3 -""" Common multi-backend Keras utilities """ -from __future__ import annotations -import typing as T - -import numpy as np - -from keras import ops, Variable - -from lib.utils import get_module_objects - -if T.TYPE_CHECKING: - from keras import KerasTensor - -# TODO these can probably be switched to pure pytorch - - -def frobenius_norm(matrix: KerasTensor, - axis: int = -1, - keep_dims: bool = True, - epsilon: float = 1e-15) -> KerasTensor: - """ Frobenius normalization for Keras Tensor - - Parameters - ---------- - matrix: :class:`keras.KerasTensor` - The matrix to normalize - axis: int, optional - The axis to normalize. Default: `-1` - keep_dims: bool, Optional - Whether to retain the original matrix shape or not. Default:``True`` - epsilon: flot, optional - Epsilon to apply to the normalization to preven NaN errors on zero values - - Returns - ------- - :class:`keras.KerasTensor` - The normalized output - """ - return ops.sqrt(ops.sum(ops.power(matrix, 2), axis=axis, keepdims=keep_dims) + epsilon) - - -def replicate_pad(image: KerasTensor, padding: int) -> KerasTensor: - """ Apply replication padding to an input batch of images. Expects 4D tensor in BHWC format. - - Notes - ----- - At the time of writing Keras does not have a native replication padding method. - The implementation here is probably not the most efficient, but it is a pure keras method - which should work ok. - - Parameters - ---------- - image: :class:`keras.KerasTensor` - Image tensor to pad - pad: int - The amount of padding to apply to each side of the input image - - Returns - ------- - :class:`keras.KerasTensor` - The input image with replication padding applied - """ - top_pad = ops.tile(image[:, :1, ...], (1, padding, 1, 1)) - bottom_pad = ops.tile(image[:, -1:, ...], (1, padding, 1, 1)) - pad_top_bottom = ops.concatenate([top_pad, image, bottom_pad], axis=1) - left_pad = ops.tile(pad_top_bottom[..., :1, :], (1, 1, padding, 1)) - right_pad = ops.tile(pad_top_bottom[..., -1:, :], (1, 1, padding, 1)) - padded = ops.concatenate([left_pad, pad_top_bottom, right_pad], axis=2) - return padded - - -class ColorSpaceConvert(): - """ Transforms inputs between different color spaces on the GPU - - Notes - ----- - The following color space transformations are implemented: - - rgb to lab - - rgb to xyz - - srgb to _rgb - - srgb to ycxcz - - xyz to ycxcz - - xyz to lab - - xyz to rgb - - ycxcz to rgb - - ycxcz to xyz - - Parameters - ---------- - from_space: str - One of `"srgb"`, `"rgb"`, `"xyz"` - to_space: str - One of `"lab"`, `"rgb"`, `"ycxcz"`, `"xyz"` - - Raises - ------ - ValueError - If the requested color space conversion is not defined - """ - def __init__(self, from_space: str, to_space: str) -> None: - functions = {"rgb_lab": self._rgb_to_lab, - "rgb_xyz": self._rgb_to_xyz, - "srgb_rgb": self._srgb_to_rgb, - "srgb_ycxcz": self._srgb_to_ycxcz, - "xyz_ycxcz": self._xyz_to_ycxcz, - "xyz_lab": self._xyz_to_lab, - "xyz_rgb": self._xyz_to_rgb, - "ycxcz_rgb": self._ycxcz_to_rgb, - "ycxcz_xyz": self._ycxcz_to_xyz} - func_name = f"{from_space.lower()}_{to_space.lower()}" - if func_name not in functions: - raise ValueError(f"The color transform {from_space} to {to_space} is not defined.") - - self._func = functions[func_name] - self._ref_illuminant = Variable(np.array([[[0.950428545, 1.000000000, 1.088900371]]]), - dtype="float32", - trainable=False) - self._inv_ref_illuminant = 1. / self._ref_illuminant - - self._rgb_xyz_map = self._get_rgb_xyz_map() - self._xyz_multipliers = Variable([116, 500, 200], dtype="float32", trainable=False) - - @classmethod - def _get_rgb_xyz_map(cls) -> tuple[KerasTensor, KerasTensor]: - """ Obtain the mapping and inverse mapping for rgb to xyz color space conversion. - - Returns - ------- - tuple - The mapping and inverse Tensors for rgb to xyz color space conversion - """ - mapping = np.array([[10135552 / 24577794, 8788810 / 24577794, 4435075 / 24577794], - [2613072 / 12288897, 8788810 / 12288897, 887015 / 12288897], - [1425312 / 73733382, 8788810 / 73733382, 70074185 / 73733382]]) - inverse = np.linalg.inv(mapping) - return (Variable(mapping, dtype="float32", trainable=False), - Variable(inverse, dtype="float32", trainable=False)) - - def __call__(self, image: KerasTensor) -> KerasTensor: - """ Call the colorspace conversion function. - - Parameters - ---------- - image: :class:`keras.KerasTensor` - The image tensor in the colorspace defined by :attr:`from_space` - - Returns - ------- - :class:`keras.KerasTensor` - The image tensor in the colorspace defined by :attr:`to_space` - """ - return self._func(image) - - def _rgb_to_lab(self, image: KerasTensor) -> KerasTensor: - """ RGB to LAB conversion. - - Parameters - ---------- - image: :class:`keras.KerasTensor` - The image tensor in RGB format - - Returns - ------- - :class:`keras.KerasTensor` - The image tensor in LAB format - """ - converted = self._rgb_to_xyz(image) - return self._xyz_to_lab(converted) - - def _rgb_xyz_rgb(self, image: KerasTensor, mapping: KerasTensor) -> KerasTensor: - """ RGB to XYZ or XYZ to RGB conversion. - - Notes - ----- - The conversion in both directions is the same, but the mappping matrix for XYZ to RGB is - the inverse of RGB to XYZ. - - References - ---------- - https://www.image-engineering.de/library/technotes/958-how-to-convert-between-srgb-and-ciexyz - - Parameters - ---------- - mapping: :class:`keras.KerasTensor` - The mapping matrix to perform either the XYZ to RGB or RGB to XYZ color space - conversion - - image: :class:`keras.KerasTensor` - The image tensor in RGB format - - Returns - ------- - :class:`keras.KerasTensor` - The image tensor in XYZ format - """ - dim = image.shape - image = ops.transpose(image, (0, 3, 1, 2)) - image = ops.reshape(image, (dim[0], dim[3], dim[1] * dim[2])) - converted = ops.transpose(ops.dot(mapping, image), (0, 2, 1)) - return ops.reshape(converted, dim) - - def _rgb_to_xyz(self, image: KerasTensor) -> KerasTensor: - """ RGB to XYZ conversion. - - Parameters - ---------- - image: :class:`keras.KerasTensor` - The image tensor in RGB format - - Returns - ------- - :class:`keras.KerasTensor` - The image tensor in XYZ format - """ - return self._rgb_xyz_rgb(image, self._rgb_xyz_map[0]) - - @classmethod - def _srgb_to_rgb(cls, image: KerasTensor) -> KerasTensor: - """ SRGB to RGB conversion. - - Notes - ----- - RGB Image is clipped to a small epsilon to stabalize training - - Parameters - ---------- - image: :class:`keras.KerasTensor` - The image tensor in SRGB format - - Returns - ------- - :class:`keras.KerasTensor` - The image tensor in RGB format - """ - limit = np.float32(0.04045) - return ops.where(image > limit, - ops.power((ops.clip(image, limit, np.inf) + 0.055) / 1.055, 2.4), - image / 12.92) - - def _srgb_to_ycxcz(self, image: KerasTensor) -> KerasTensor: - """ SRGB to YcXcZ conversion. - - Parameters - ---------- - image: :class:`keras.KerasTensor` - The image tensor in SRGB format - - Returns - ------- - :class:`keras.KerasTensor` - The image tensor in YcXcZ format - """ - converted = self._srgb_to_rgb(image) - converted = self._rgb_to_xyz(converted) - return self._xyz_to_ycxcz(converted) - - def _xyz_to_lab(self, image: KerasTensor) -> KerasTensor: - """ XYZ to LAB conversion. - - Parameters - ---------- - image: :class:`keras.KerasTensor` - The image tensor in XYZ format - - Returns - ------- - :class:`keras.KerasTensor` - The image tensor in LAB format - """ - image = image * self._inv_ref_illuminant - delta = 6 / 29 - delta_cube = delta ** 3 - factor = 1 / (3 * (delta ** 2)) - - clamped_term = ops.power(ops.clip(image, delta_cube, np.inf), 1.0 / 3.0) - div = factor * image + (4 / 29) - - image = ops.where(image > delta_cube, clamped_term, div) - - return ops.concatenate([self._xyz_multipliers[0] * image[..., 1:2] - 16., - self._xyz_multipliers[1:] * (image[..., :2] - image[..., 1:3])], - axis=-1) - - def _xyz_to_rgb(self, image: KerasTensor) -> KerasTensor: - """ XYZ to YcXcZ conversion. - - Parameters - ---------- - image: :class:`keras.KerasTensor` - The image tensor in XYZ format - - Returns - ------- - :class:`keras.KerasTensor` - The image tensor in RGB format - """ - return self._rgb_xyz_rgb(image, self._rgb_xyz_map[1]) - - def _xyz_to_ycxcz(self, image: KerasTensor) -> KerasTensor: - """ XYZ to YcXcZ conversion. - - Parameters - ---------- - image: :class:`keras.KerasTensor` - The image tensor in XYZ format - - Returns - ------- - :class:`keras.KerasTensor` - The image tensor in YcXcZ format - """ - image = image * self._inv_ref_illuminant - return ops.concatenate([self._xyz_multipliers[0] * image[..., 1:2] - 16., - self._xyz_multipliers[1:] * (image[..., :2] - image[..., 1:3])], - axis=-1) - - def _ycxcz_to_rgb(self, image: KerasTensor) -> KerasTensor: - """ YcXcZ to RGB conversion. - - Parameters - ---------- - image: :class:`keras.KerasTensor` - The image tensor in YcXcZ format - - Returns - ------- - :class:`keras.KerasTensor` - The image tensor in RGB format - """ - converted = self._ycxcz_to_xyz(image) - return self._xyz_to_rgb(converted) - - def _ycxcz_to_xyz(self, image: KerasTensor) -> KerasTensor: - """ YcXcZ to XYZ conversion. - - Parameters - ---------- - image: :class:`keras.KerasTensor` - The image tensor in YcXcZ format - - Returns - ------- - :class:`keras.KerasTensor` - The image tensor in XYZ format - """ - ch_y = (image[..., 0:1] + 16.) / self._xyz_multipliers[0] - return ops.concatenate([ch_y + (image[..., 1:2] / self._xyz_multipliers[1]), - ch_y, - ch_y - (image[..., 2:3] / self._xyz_multipliers[2])], - axis=-1) * self._ref_illuminant - - -__all__ = get_module_objects(__name__) diff --git a/lib/model/losses/feature_loss.py b/lib/model/losses/feature_loss.py index fac93e6448..2c987fd51f 100644 --- a/lib/model/losses/feature_loss.py +++ b/lib/model/losses/feature_loss.py @@ -280,6 +280,9 @@ class LPIPSLoss(nn.Module): # pylint:disable=too-many-instance-attributes color_order The RGB/BGR order of the input images """ + _shift: torch.Tensor + _scale: torch.Tensor + def __init__(self, # pylint:disable=too-many-arguments,too-many-positional-arguments trunk_network: T.Literal["alex", "squeeze", "vgg16"], trunk_pretrained: bool = True, @@ -302,10 +305,11 @@ def __init__(self, # pylint:disable=too-many-arguments,too-many-positional-argu self._crop_amount = self._get_crop_amount(crop, trunk_network) self._is_rgb = color_order == "rgb" - self._initialized = False - self._shift = torch.Tensor([-.030, -.088, -.188]).float()[None, :, None, None] - self._scale = torch.Tensor([.458, .448, .450]).float()[None, :, None, None] + self.register_buffer("_shift", + torch.Tensor([-.030, -.088, -.188]).float()[None, :, None, None]) + self.register_buffer("_scale", + torch.Tensor([.458, .448, .450]).float()[None, :, None, None]) self._trunk_net = _LPIPSTrunkNet(trunk_network, trunk_eval_mode, trunk_pretrained) self._linear_net = _LPIPSLinearNet(trunk_network, linear_eval_mode, @@ -402,19 +406,14 @@ def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor ------- The final loss value for each item in the batch """ - if not self._initialized: - self._shift = self._shift.to(y_pred.device) - self._scale = self._scale.to(y_pred.device) - self._trunk_net = self._trunk_net.to(y_pred.device) - self._linear_net = self._linear_net.to(y_pred.device) - self._initialized = True - - if not self._is_rgb: - y_true = torch.flip(y_true, dims=[-1]) - y_pred = torch.flip(y_pred, dims=[-1]) + # TODO remove once channels first y_true = y_true.permute(0, 3, 1, 2) y_pred = y_pred.permute(0, 3, 1, 2) + if not self._is_rgb: + y_true = torch.flip(y_true, dims=[1]) + y_pred = torch.flip(y_pred, dims=[1]) + if self._normalize: y_true = (y_true * 2.0) - 1.0 y_pred = (y_pred * 2.0) - 1.0 diff --git a/lib/model/losses/loss.py b/lib/model/losses/loss.py index 83caa1e8c1..f95b89fa59 100644 --- a/lib/model/losses/loss.py +++ b/lib/model/losses/loss.py @@ -400,6 +400,9 @@ class LaplacianPyramidLoss(nn.Module): https://arxiv.org/abs/1707.05776 https://github.com/nathanaelbosch/generative-latent-optimization/blob/master/utils.py """ + _weight: torch.Tensor + _kernel: torch.Tensor + def __init__(self, max_levels: int = 5, gaussian_size: int = 5, @@ -407,26 +410,24 @@ def __init__(self, logger.debug(parse_class_init(locals())) super().__init__() self._max_levels = max_levels - self._gaussian_size = gaussian_size self._gaussian_sigma = gaussian_sigma - self._gaussian_kernel: torch.Tensor | None = None - self._weight = torch.Tensor([np.power(2., -2 * idx) - for idx in range(max_levels + 1)]) + self.register_buffer("_weight", + torch.Tensor([np.power(2., -2 * idx) + for idx in range(max_levels + 1)])) + self.register_buffer("_kernel", self._generate_gaussian_kernel(gaussian_size)) - def _generate_gaussian_kernel(self, device: torch.Device) -> None: - """Obtain the base gaussian kernel for the Laplacian Pyramid and set to - :attr:`_gaussian_kernel` + def _generate_gaussian_kernel(self, size: int) -> torch.Tensor: + """Obtain the base gaussian kernel for the Laplacian Pyramid Parameters ---------- - device - The device to place the Gaussian kernel on to + size + The size of the kernel to create Returns ------- The base three channel Gaussian kernel """ - size = self._gaussian_size assert size % 2 == 1, ("kernel size must be uneven") x_1 = np.linspace(- (size // 2), size // 2, size, dtype="float32") x_1 /= np.sqrt(2) * self._gaussian_sigma @@ -435,7 +436,7 @@ def _generate_gaussian_kernel(self, device: torch.Device) -> None: kernel /= kernel.sum() kernel = np.tile(kernel, (3, 1, 1, 1)) - self._gaussian_kernel = torch.from_numpy(kernel).to(torch.float32).to(device) + return torch.from_numpy(kernel).float() def _conv_gaussian(self, inputs: torch.Tensor) -> torch.Tensor: """Perform Gaussian convolution on a batch of images. @@ -449,13 +450,12 @@ def _conv_gaussian(self, inputs: torch.Tensor) -> torch.Tensor: ------- The convolved images """ - assert self._gaussian_kernel is not None - gauss_size = self._gaussian_kernel.shape[2] + gauss_size = self._kernel.shape[2] padded_inputs = F.pad(inputs, (gauss_size // 2, gauss_size // 2, gauss_size // 2, gauss_size // 2), mode="replicate") return F.conv2d(padded_inputs, # pylint:disable=not-callable - self._gaussian_kernel, + self._kernel, groups=3) def _get_laplacian_pyramid(self, inputs: torch.Tensor) -> list[torch.Tensor]: @@ -498,10 +498,6 @@ def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: y_true = y_true.permute(0, 3, 1, 2) y_pred = y_pred.permute(0, 3, 1, 2) - if self._gaussian_kernel is None: - self._generate_gaussian_kernel(y_pred.device) - self._weight = self._weight.to(y_pred.device) - pyramid_true = self._get_laplacian_pyramid(y_true) pyramid_pred = self._get_laplacian_pyramid(y_pred) diff --git a/lib/model/losses/perceptual_loss.py b/lib/model/losses/perceptual_loss.py index 230e1ff931..bec7477321 100644 --- a/lib/model/losses/perceptual_loss.py +++ b/lib/model/losses/perceptual_loss.py @@ -10,15 +10,10 @@ from torch import nn from torch.nn import functional as F -import keras -from keras import ops, Variable - -from lib.keras_utils import ColorSpaceConvert, frobenius_norm, replicate_pad +from lib.torch_utils import ColorSpaceConvert from lib.logger import parse_class_init from lib.utils import get_module_objects -if T.TYPE_CHECKING: - from keras import KerasTensor logger = logging.getLogger(__name__) @@ -179,10 +174,12 @@ class GMSDLoss(nn.Module): http://www4.comp.polyu.edu.hk/~cslzhang/IQA/GMSD/GMSD.htm https://arxiv.org/ftp/arxiv/papers/1308/1308.3052.pdf """ + _scharr_edges: torch.Tensor + def __init__(self) -> None: logger.debug(parse_class_init(locals())) super().__init__() - self._scharr_edges = torch.from_numpy( + self.register_buffer("_scharr_edges", torch.from_numpy( np.array([[[[0.00070, 0.00070]], [[0.00520, 0.00370]], [[0.03700, 0.00000]], @@ -207,8 +204,7 @@ def __init__(self) -> None: [[-0.0052, 0.00370]], [[-0.0370, 0.00000]], [[-0.0052, -0.0037]], - [[-0.0007, -0.0007]]]], dtype=np.float32)) - self._initialized = False + [[-0.0007, -0.0007]]]], dtype=np.float32))) def _map_scharr_edges(self, image: torch.Tensor, magnitude: bool) -> torch.Tensor: """Returns a tensor holding modified Scharr edge maps. @@ -262,10 +258,6 @@ def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: ------- The final loss value for each item in the batch """ - if not self._initialized: - self._scharr_edges = self._scharr_edges.to(y_pred.device) - self._initialized = True - # TODO remove once channels first y_true = y_true.permute(0, 3, 1, 2) y_pred = y_pred.permute(0, 3, 1, 2) @@ -280,7 +272,7 @@ def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: return gmsd -class LDRFLIPLoss(keras.losses.Loss): # pylint:disable=too-many-instance-attributes +class LDRFLIPLoss(nn.Module): # pylint:disable=too-many-instance-attributes """Computes the LDR-FLIP error map between two LDR images, assuming the images are observed at a certain number of pixels per degree of visual angle. @@ -316,25 +308,25 @@ class LDRFLIPLoss(keras.losses.Loss): # pylint:disable=too-many-instance-attrib Parameters ---------- - computed_distance_exponent: float, Optional + computed_distance_exponent The computed distance exponent to apply to Hunt adjusted, filtered colors. (`qc` in original paper). Default: `0.7` - feature_exponent: float, Optional + feature_exponent The feature exponent to apply for increasing the impact of feature difference on the final loss value. (`qf` in original paper). Default: `0.5` - lower_threshold_exponent: float, Optional + lower_threshold_exponent The `pc` exponent for the color pipeline as described in the original paper: Default: `0.4` - upper_threshold_exponent: float, Optional + upper_threshold_exponent The `pt` exponent for the color pipeline as described in the original paper. Default: `0.95` - epsilon: float + epsilon A small value to improve training stability. Default: `1e-15` - pixels_per_degree: float, Optional + pixels_per_degree The estimated number of pixels per degree of visual angle of the observer. This effectively impacts the tolerance when calculating loss. The default corresponds to viewing images on a 0.7m wide 4K monitor at 0.7m from the display. Default: ``None`` - color_order: str - The `"BGR"` or `"RGB"` color order of the incoming images + color_order + The `"bgr"` or `"rgb"` color order of the incoming images """ def __init__(self, computed_distance_exponent: float = 0.7, @@ -345,7 +337,7 @@ def __init__(self, pixels_per_degree: float | None = None, color_order: T.Literal["bgr", "rgb"] = "bgr") -> None: logger.debug(parse_class_init(locals())) - super().__init__(name=self.__class__.__name__) + super().__init__() self._computed_distance_exponent = computed_distance_exponent self._feature_exponent = feature_exponent self._pc = lower_threshold_exponent @@ -358,172 +350,163 @@ def __init__(self, self._pixels_per_degree = pixels_per_degree self._spatial_filters = _SpatialFilters(pixels_per_degree) self._feature_detector = _FeatureDetection(pixels_per_degree) - self._col_conv = {"rgb2lab": ColorSpaceConvert(from_space="rgb", to_space="lab"), - "rgb2ycxcz": ColorSpaceConvert("srgb", "ycxcz")} - self._hunt = {"green": Variable([[[[0.0, 1.0, 0.0]]]], dtype="float32", trainable=False), - "blue": Variable([[[[0.0, 0.0, 1.0]]]], dtype="float32", trainable=False)} - - logger.debug("Initialized: %s ", self.__class__.__name__) + self._rgb2lab = ColorSpaceConvert(from_space="rgb", to_space="lab") + self._rgb2ycxcz = ColorSpaceConvert("srgb", "ycxcz") - def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: - """Call the LDR Flip Loss Function + @classmethod + def _hunt_adjustment(cls, image: torch.Tensor) -> torch.Tensor: + """Apply Hunt-adjustment to an image in L*a*b* color space Parameters ---------- - y_true: :class:`keras.KerasTensor` - The ground truth batch of images - y_pred: :class:`keras.KerasTensor` - The predicted batch of images + image + The batch of images in L*a*b* to adjust Returns ------- - :class::class:`keras.KerasTensor` - The calculated Flip loss value + The hunt adjusted batch of images in L*a*b color space """ - if self._color_order == "bgr": # Switch models training in bgr order to rgb - y_true = y_true[..., [2, 1, 0]] - y_pred = y_pred[..., [2, 1, 0]] + ch_l = image[:, 0:1] + return torch.cat([ch_l, image[:, 1:] * (ch_l * 0.01)], dim=1) + + def _hyab(self, y_true: torch.Tensor, y_pred: torch.Tensor | float) -> torch.Tensor: + """Compute the HyAB distance between true and predicted images. + + Parameters + ---------- + y_true + The ground truth batch of images in standard or Hunt-adjusted L*A*B* color space + y_pred + The predicted batch of images in in standard or Hunt-adjusted L*A*B* color space - y_true = T.cast("KerasTensor", ops.clip(y_true, 0, 1.)) - y_pred = T.cast("KerasTensor", ops.clip(y_pred, 0, 1.)) + Returns + ------- + image tensor containing the per-pixel HyAB distances between true and predicted images + """ + delta = y_true - y_pred + root = torch.sqrt(torch.clamp(torch.pow(delta[:, 0:1], 2), min=self._epsilon)) + delta_norm = torch.norm(delta[:, 1:3], dim=1, keepdim=True) + return root + delta_norm - true_ycxcz = self._col_conv["rgb2ycxcz"](y_true) - pred_ycxcz = self._col_conv["rgb2ycxcz"](y_pred) + def _redistribute_errors(self, + power_delta_e_hyab: torch.Tensor, + c_max: torch.Tensor) -> torch.Tensor: + """Redistribute exponentiated HyAB errors to the [0,1] range - delta_e_color = self._color_pipeline(true_ycxcz, pred_ycxcz) - delta_e_features = self._process_features(true_ycxcz, pred_ycxcz) + Parameters + ---------- + power_delta_e_hyab + The exponentiated HyAb distance + c_max + The exponentiated, maximum HyAB difference between two colors in Hunt-adjusted + L*A*B* space - loss = ops.power(delta_e_color, 1 - delta_e_features) - return T.cast("KerasTensor", loss) + Returns + ------- + The redistributed per-pixel HyAB distances (in range [0,1]) + """ + pcc_max = self._pc * c_max + return torch.where(power_delta_e_hyab < pcc_max, + (self._pt / pcc_max) * power_delta_e_hyab, + self._pt + ((power_delta_e_hyab - pcc_max) / + (c_max - pcc_max)) * (1.0 - self._pt)) - def _color_pipeline(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: + def _color_pipeline(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: """Perform the color processing part of the FLIP loss function Parameters ---------- - y_true: :class:`keras.KerasTensor` + y_true The ground truth batch of images in YCxCz color space - y_pred: :class:`keras.KerasTensor` + y_pred The predicted batch of images in YCxCz color space Returns ------- - :class:`keras.KerasTensor` - The exponentiated, maximum HyAB difference between two colors in Hunt-adjusted - L*A*B* space + The exponentiated, maximum HyAB difference between two colors in Hunt-adjusted L*A*B* space """ filtered_true = self._spatial_filters(y_true) filtered_pred = self._spatial_filters(y_pred) - rgb2lab = self._col_conv["rgb2lab"] - preprocessed_true = self._hunt_adjustment(rgb2lab(filtered_true)) - preprocessed_pred = self._hunt_adjustment(rgb2lab(filtered_pred)) - hunt_adjusted_green = self._hunt_adjustment(rgb2lab(self._hunt["green"])) - hunt_adjusted_blue = self._hunt_adjustment(rgb2lab(self._hunt["blue"])) + preprocessed_true = self._hunt_adjustment(self._rgb2lab(filtered_true)) + preprocessed_pred = self._hunt_adjustment(self._rgb2lab(filtered_pred)) + hunt_adjusted_green = self._hunt_adjustment( + self._rgb2lab(torch.Tensor([[[[0.0]], [[1.0]], [[0.0]]]]).float().to(y_pred.device)) + ) + hunt_adjusted_blue = self._hunt_adjustment( + self._rgb2lab(torch.Tensor([[[[0.0]], [[0.0]], [[1.0]]]]).float().to(y_pred.device)) + ) delta = self._hyab(preprocessed_true, preprocessed_pred) - power_delta = T.cast("KerasTensor", ops.power(delta, self._computed_distance_exponent)) - c_max = T.cast("KerasTensor", ops.power(self._hyab(hunt_adjusted_green, - hunt_adjusted_blue), - self._computed_distance_exponent)) + power_delta = delta ** self._computed_distance_exponent + c_max = self._hyab(hunt_adjusted_green, + hunt_adjusted_blue) ** self._computed_distance_exponent return self._redistribute_errors(power_delta, c_max) - def _process_features(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: + def _process_features(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: """Perform the color processing part of the FLIP loss function Parameters ---------- - y_true: :class:`keras.KerasTensor` + y_true The ground truth batch of images in YCxCz color space - y_pred: :class:`keras.KerasTensor` + y_pred The predicted batch of images in YCxCz color space Returns ------- - :class:`keras.KerasTensor` - The exponentiated features delta + The exponentiated features delta """ - col_y_true = (y_true[..., 0:1] + 16) / 116. - col_y_pred = (y_pred[..., 0:1] + 16) / 116. + col_y_true = (y_true[:, 0:1] + 16) / 116. + col_y_pred = (y_pred[:, 0:1] + 16) / 116. edges_true = self._feature_detector(col_y_true, "edge") points_true = self._feature_detector(col_y_true, "point") edges_pred = self._feature_detector(col_y_pred, "edge") points_pred = self._feature_detector(col_y_pred, "point") - delta = ops.maximum(ops.abs(frobenius_norm(edges_true) - frobenius_norm(edges_pred)), - ops.abs(frobenius_norm(points_pred) - frobenius_norm(points_true))) - - delta = ops.clip(delta, x_min=self._epsilon, x_max=np.inf) - return T.cast("KerasTensor", ops.power(((1 / np.sqrt(2)) * delta), self._feature_exponent)) - - @classmethod - def _hunt_adjustment(cls, image: KerasTensor) -> KerasTensor: - """Apply Hunt-adjustment to an image in L*a*b* color space - - Parameters - ---------- - image: :class:`keras.KerasTensor` - The batch of images in L*a*b* to adjust + delta = torch.maximum(torch.abs(torch.norm(edges_true, dim=1, keepdim=True) - + torch.norm(edges_pred, dim=1, keepdim=True)), + torch.abs(torch.norm(points_pred, dim=1, keepdim=True) - + torch.norm(points_true, dim=1, keepdim=True))) - Returns - ------- - :class:`keras.KerasTensor` - The hunt adjusted batch of images in L*a*b color space - """ - ch_l = image[..., 0:1] - adjusted = ops.concatenate([ch_l, image[..., 1:] * (ch_l * 0.01)], axis=-1) - return T.cast("KerasTensor", adjusted) + delta = torch.clamp(delta, min=self._epsilon) + return ((1 / np.sqrt(2)) * delta) ** self._feature_exponent - def _hyab(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: - """Compute the HyAB distance between true and predicted images. + def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: + """Call the LDR Flip Loss Function Parameters ---------- - y_true: :class:`keras.KerasTensor` - The ground truth batch of images in standard or Hunt-adjusted L*A*B* color space - y_pred: :class:`keras.KerasTensor` - The predicted batch of images in in standard or Hunt-adjusted L*A*B* color space + y_true + The ground truth batch of images + y_pred + The predicted batch of images Returns ------- - :class:`keras.KerasTensor` - image tensor containing the per-pixel HyAB distances between true and predicted images + The calculated Flip loss value """ - delta = y_true - y_pred - root = T.cast("KerasTensor", ops.sqrt(ops.clip(ops.power(delta[..., 0:1], 2), - x_min=self._epsilon, - x_max=np.inf))) - delta_norm = frobenius_norm(delta[..., 1:3]) - return root + delta_norm + # TODO remove once channels first + y_true = y_true.permute(0, 3, 1, 2) + y_pred = y_pred.permute(0, 3, 1, 2) - def _redistribute_errors(self, - power_delta_e_hyab: KerasTensor, - c_max: KerasTensor) -> KerasTensor: - """Redistribute exponentiated HyAB errors to the [0,1] range + if self._color_order == "bgr": # Switch models training in bgr order to rgb + y_true = torch.flip(y_true, dims=[1]) + y_pred = torch.flip(y_pred, dims=[1]) - Parameters - ---------- - power_delta_e_hyab: :class:`keras.KerasTensor` - The exponentiated HyAb distance - c_max: :class:`keras.KerasTensor` - The exponentiated, maximum HyAB difference between two colors in Hunt-adjusted - L*A*B* space + y_true = torch.clamp(y_true, 0, 1.) + y_pred = torch.clamp(y_pred, 0, 1.) + true_ycxcz = self._rgb2ycxcz(y_true) + pred_ycxcz = self._rgb2ycxcz(y_pred) - Returns - ------- - :class:`keras.KerasTensor` - The redistributed per-pixel HyAB distances (in range [0,1]) - """ - pcc_max = self._pc * c_max - delta_e_c = ops.where( - power_delta_e_hyab < pcc_max, - (self._pt / pcc_max) * power_delta_e_hyab, - self._pt + ((power_delta_e_hyab - pcc_max) / (c_max - pcc_max)) * (1.0 - self._pt)) - return T.cast("KerasTensor", delta_e_c) + delta_e_color = self._color_pipeline(true_ycxcz, pred_ycxcz) + delta_e_features = self._process_features(true_ycxcz, pred_ycxcz) + loss = delta_e_color ** (1 - delta_e_features) + return loss -class _SpatialFilters(): +class _SpatialFilters(nn.Module): """Filters an image with channel specific spatial contrast sensitivity functions and clips result to the unit cube in linear RGB. @@ -531,44 +514,19 @@ class _SpatialFilters(): Parameters ---------- - pixels_per_degree: float + pixels_per_degree The estimated number of pixels per degree of visual angle of the observer. This effectively impacts the tolerance when calculating loss. """ + _spatial_filters: torch.Tensor + def __init__(self, pixels_per_degree: float) -> None: logger.debug(parse_class_init(locals())) + super().__init__() self._pixels_per_degree = pixels_per_degree - self._spatial_filters, self._radius = self._generate_spatial_filters() + self._radius: int = 0 # Set when spatial filters are generated + self.register_buffer("_spatial_filters", self._generate_spatial_filters()) self._ycxcz2rgb = ColorSpaceConvert(from_space="ycxcz", to_space="rgb") - logger.debug("Initialized: %s", self.__class__.__name__) - - def _generate_spatial_filters(self) -> tuple[KerasTensor, int]: - """Generates spatial contrast sensitivity filters with width depending on the number of - pixels per degree of visual angle of the observer for channels "A", "RG" and "BY" - - Returns - ------- - dict - the channels ("A" (Achromatic CSF), "RG" (Red-Green CSF) or "BY" (Blue-Yellow CSF)) as - key with the Filter kernel corresponding to the spatial contrast sensitivity function - of channel and kernel's radius - """ - mapping = {"A": {"a1": 1, "b1": 0.0047, "a2": 0, "b2": 1e-5}, - "RG": {"a1": 1, "b1": 0.0053, "a2": 0, "b2": 1e-5}, - "BY": {"a1": 34.1, "b1": 0.04, "a2": 13.5, "b2": 0.025}} - - domain, radius = self._get_evaluation_domain(mapping["A"]["b1"], - mapping["A"]["b2"], - mapping["RG"]["b1"], - mapping["RG"]["b2"], - mapping["BY"]["b1"], - mapping["BY"]["b2"]) - - weights = np.array([self._generate_weights(mapping[channel], domain) - for channel in ("A", "RG", "BY")]) - v_weights = Variable(np.moveaxis(weights, 0, -1), dtype="float32", trainable=False) - - return v_weights, radius def _get_evaluation_domain(self, b1_a: float, @@ -577,7 +535,7 @@ def _get_evaluation_domain(self, b2_rg: float, b1_by: float, b2_by: float) -> tuple[np.ndarray, int]: - """TODO docstring """ + """Get the evaluation domain for the spatial filters""" max_scale_parameter = max([b1_a, b2_a, b1_rg, b2_rg, b1_by, b2_by]) delta_x = 1.0 / self._pixels_per_degree radius = int(np.ceil(3 * np.sqrt(max_scale_parameter / (2 * np.pi**2)) @@ -588,100 +546,118 @@ def _get_evaluation_domain(self, @classmethod def _generate_weights(cls, channel: dict[str, float], domain: np.ndarray) -> np.ndarray: - """TODO docstring """ + """Generate the weights for the spacial filters""" a_1, b_1, a_2, b_2 = channel["a1"], channel["b1"], channel["a2"], channel["b2"] grad = (a_1 * np.sqrt(np.pi / b_1) * np.exp(-np.pi ** 2 * domain / b_1) + a_2 * np.sqrt(np.pi / b_2) * np.exp(-np.pi ** 2 * domain / b_2)) grad = grad / np.sum(grad) - grad = np.reshape(grad, (*grad.shape, 1)) + grad = np.reshape(grad, (1, *grad.shape)) return grad - def __call__(self, image: KerasTensor) -> KerasTensor: + def _generate_spatial_filters(self) -> torch.Tensor: + """Generates spatial contrast sensitivity filters with width depending on the number of + pixels per degree of visual angle of the observer for channels "A", "RG" and "BY" + + Returns + ------- + The spatial filter kernel for the channels ("A" (Achromatic CSF), "RG" (Red-Green CSF) or + "BY" (Blue-Yellow CSF)) corresponding to the spatial contrast sensitivity function + """ + mapping = {"A": {"a1": 1, "b1": 0.0047, "a2": 0, "b2": 1e-5}, + "RG": {"a1": 1, "b1": 0.0053, "a2": 0, "b2": 1e-5}, + "BY": {"a1": 34.1, "b1": 0.04, "a2": 13.5, "b2": 0.025}} + + domain, radius = self._get_evaluation_domain(mapping["A"]["b1"], + mapping["A"]["b2"], + mapping["RG"]["b1"], + mapping["RG"]["b2"], + mapping["BY"]["b1"], + mapping["BY"]["b2"]) + self._radius = radius + weights = np.array([self._generate_weights(mapping[channel], domain) + for channel in ("A", "RG", "BY")]) + return torch.from_numpy(weights).float() + + def forward(self, image: torch.Tensor) -> torch.Tensor: """Call the spacial filtering. Parameters ---------- - image: :class:`keras.KerasTensor` + image Image tensor to filter in YCxCz color space Returns ------- - :class:`keras.KerasTensor` - The input image transformed to linear RGB after filtering with spatial contrast - sensitivity functions + The input image transformed to linear RGB after filtering with spatial contrast sensitivity + functions """ - padded_image = replicate_pad(image, self._radius) - image_tilde_opponent = T.cast("KerasTensor", ops.conv(padded_image, - self._spatial_filters, - strides=1, - padding="valid")) - rgb = ops.clip(self._ycxcz2rgb(image_tilde_opponent), 0., 1.) - return T.cast("KerasTensor", rgb) + img_pad = F.pad(image, (self._radius, self._radius, self._radius, self._radius), + mode="replicate") + image_tilde_opponent = F.conv2d(img_pad, # pylint:disable=not-callable + self._spatial_filters, + groups=3) + return torch.clamp(self._ycxcz2rgb(image_tilde_opponent), 0., 1.) -class _FeatureDetection(): +class _FeatureDetection(nn.Module): """Detect features (i.e. edges and points) in an achromatic YCxCz image. For use with LDRFlipLoss. Parameters ---------- - pixels_per_degree: float + pixels_per_degree The number of pixels per degree of visual angle of the observer """ + _grads_edge: torch.Tensor + _grads_point: torch.Tensor + def __init__(self, pixels_per_degree: float) -> None: logger.debug(parse_class_init(locals())) + super().__init__() width = 0.082 self._std = 0.5 * width * pixels_per_degree self._radius = int(np.ceil(3 * self._std)) + grid = np.meshgrid(range(-self._radius, self._radius + 1), range(-self._radius, self._radius + 1)) - gradient = np.exp(-(grid[0] ** 2 + grid[1] ** 2) / (2 * (self._std ** 2))) - self._grads = { - "edge": Variable(np.multiply(-grid[0], gradient), trainable=False, dtype="float32"), - "point": Variable(np.multiply(grid[0] ** 2 / (self._std ** 2) - 1, gradient), - trainable=False, - dtype="float32")} + self.register_buffer("_grads_edge", + torch.from_numpy(np.multiply(-grid[0], gradient)).float()) + self.register_buffer("_grads_point", + torch.from_numpy(np.multiply(grid[0] ** 2 / (self._std ** 2) - 1, + gradient)).float()) - logger.debug("Initialized: %s", self.__class__.__name__) - - def __call__(self, image: KerasTensor, feature_type: str) -> KerasTensor: + def forward(self, image: torch.Tensor, feature_type: str) -> torch.Tensor: """Run the feature detection Parameters ---------- - image: :class:`keras.KerasTensor` + image Batch of images in YCxCz color space with normalized Y values - feature_type: str + feature_type Type of features to detect (`"edge"` or `"point"`) Returns ------- - :class:`keras.KerasTensor` - Detected features in the 0-1 range + Detected features in the 0-1 range """ feature_type = feature_type.lower() + grad_x = self._grads_edge if feature_type == "edge" else self._grads_point + negative_weights_sum = -grad_x[grad_x < 0].sum() + positive_weights_sum = grad_x[grad_x > 0].sum() - grad_x = self._grads[feature_type] - negative_weights_sum = -ops.sum(grad_x[grad_x < 0]) - positive_weights_sum = ops.sum(grad_x[grad_x > 0]) - - grad_x = ops.where(grad_x < 0, - grad_x / negative_weights_sum, - grad_x / positive_weights_sum) - kernel = ops.expand_dims(ops.expand_dims(grad_x, axis=-1), axis=-1) - features_x = ops.conv(replicate_pad(image, self._radius), - kernel, - strides=1, - padding="valid") - kernel = ops.transpose(kernel, (1, 0, 2, 3)) - features_y = ops.conv(replicate_pad(image, self._radius), - kernel, - strides=1, - padding="valid") - features = ops.concatenate([features_x, features_y], axis=-1) - return T.cast("KerasTensor", features) + grad_x = torch.where(grad_x < 0, + grad_x / negative_weights_sum, + grad_x / positive_weights_sum) + kernel = grad_x[None, None] + pad = (self._radius, self._radius, self._radius, self._radius,) + + features_x = F.conv2d(F.pad(image, pad, mode="replicate"), # pylint:disable=not-callable + kernel) + features_y = F.conv2d(F.pad(image, pad, mode="replicate"), # pylint:disable=not-callable + kernel.swapaxes(2, 3)) + return torch.cat([features_x, features_y], dim=1) class MSSIMLoss(nn.Module): @@ -710,6 +686,9 @@ class MSSIMLoss(nn.Module): You should add a regularization term like a l2 loss in addition to this one. Adapted from Tensorflow's ssim_multi-scale implementation """ + _power_factors: torch.Tensor + _divisor_tensor: torch.Tensor + def __init__(self, k_1: float = 0.01, k_2: float = 0.03, @@ -725,10 +704,9 @@ def __init__(self, self._k_1 = k_1 self._k_2 = k_2 self._max_value = max_value - self._power_factors = torch.Tensor(power_factors).float() self._divisor = [1, 1, 2, 2] - self._divisor_tensor = torch.Tensor(self._divisor[1:]).int() - self._initialized = False + self.register_buffer("_power_factors", torch.Tensor(power_factors).float()) + self.register_buffer("_divisor_tensor", torch.Tensor(self._divisor[1:]).int()) @classmethod def _reducer(cls, image: torch.Tensor, kernel: torch.Tensor) -> torch.Tensor: @@ -933,10 +911,6 @@ def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: ------- The MS-SSIM Loss value """ - if not self._initialized: - self._divisor_tensor = self._divisor_tensor.to(y_pred.device) - self._power_factors = self._power_factors.to(y_pred.device) - self._initialized = True # TODO remove once channels first y_true = y_true.permute(0, 3, 1, 2) y_pred = y_pred.permute(0, 3, 1, 2) diff --git a/lib/torch_utils.py b/lib/torch_utils.py new file mode 100644 index 0000000000..8fd0704bee --- /dev/null +++ b/lib/torch_utils.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python +"""Common multi-backend Torch utilities""" +from __future__ import annotations +import logging +import typing as T + +import numpy as np + +import torch +from torch import nn + +from lib.logger import parse_class_init +from lib.utils import get_module_objects + +logger = logging.getLogger(__name__) + + +class ColorSpaceConvert(nn.Module): + """Transforms inputs between different color spaces on the GPU. Images expected in (N,C,H,W) + order + + Notes + ----- + The following color space transformations are implemented: + - rgb to lab + - rgb to xyz + - srgb to _rgb + - srgb to ycxcz + - xyz to ycxcz + - xyz to lab + - xyz to rgb + - ycxcz to rgb + - ycxcz to xyz + + Parameters + ---------- + from_space + One of "srgb", "rgb", "ycxcz", "xyz" + to_space + One of "lab", "rgb", "ycxcz", "xyz" + + Raises + ------ + ValueError + If the requested color space conversion is not defined + """ + _ref_illuminant: torch.Tensor + _inv_ref_illuminant: torch.Tensor + _rgb_xyz_map: torch.Tensor + + def __init__(self, from_space: T.Literal["srgb", "rgb", "ycxcz", "xyz"], + to_space: T.Literal["lab", "rgb", "ycxcz", "xyz"]) -> None: + functions = {"rgb_lab": self._rgb_to_lab, + "rgb_xyz": self._rgb_to_xyz, + "srgb_rgb": self._srgb_to_rgb, + "srgb_ycxcz": self._srgb_to_ycxcz, + "xyz_ycxcz": self._xyz_to_ycxcz, + "xyz_lab": self._xyz_to_lab, + "xyz_rgb": self._xyz_to_rgb, + "ycxcz_rgb": self._ycxcz_to_rgb, + "ycxcz_xyz": self._ycxcz_to_xyz} + super().__init__() + logger.debug(parse_class_init(locals())) + func_name = f"{from_space.lower()}_{to_space.lower()}" + if func_name not in functions: + raise ValueError(f"The color transform {from_space} to {to_space} is not defined.") + self._func = functions[func_name] + + ref_illuminant = np.array([[[0.950428545]], [[1.000000000]], [[1.088900371]]], + dtype=np.float32) + self.register_buffer("_ref_illuminant", torch.from_numpy(ref_illuminant).float()) + self.register_buffer("_inv_ref_illuminant", torch.from_numpy(1. / ref_illuminant).float()) + self.register_buffer("_rgb_xyz_map", self._get_rgb_xyz_map()) + + @classmethod + def _get_rgb_xyz_map(cls) -> torch.Tensor: + """Obtain the mapping and inverse mapping for rgb to xyz color space conversion. + + Returns + ------- + The mapping and inverse Tensors for rgb to xyz color space conversion + """ + mapping = np.array([[10135552 / 24577794, 8788810 / 24577794, 4435075 / 24577794], + [2613072 / 12288897, 8788810 / 12288897, 887015 / 12288897], + [1425312 / 73733382, 8788810 / 73733382, 70074185 / 73733382]]) + inverse = np.linalg.inv(mapping) + return torch.from_numpy(np.stack([mapping, inverse], axis=0)).float() + + def _rgb_to_lab(self, image: torch.Tensor) -> torch.Tensor: + """RGB to LAB conversion. + + Parameters + ---------- + image + The image tensor in RGB format + + Returns + ------- + The image tensor in LAB format + """ + converted = self._rgb_to_xyz(image) + return self._xyz_to_lab(converted) + + def _rgb_xyz_rgb(self, image: torch.Tensor, mapping: torch.Tensor) -> torch.Tensor: + """RGB to XYZ or XYZ to RGB conversion. + + Notes + ----- + The conversion in both directions is the same, but the mapping matrix for XYZ to RGB is + the inverse of RGB to XYZ. + + References + ---------- + https://www.image-engineering.de/library/technotes/958-how-to-convert-between-srgb-and-ciexyz + + Parameters + ---------- + mapping + The mapping matrix to perform either the XYZ to RGB or RGB to XYZ color space + conversion + + image + The image tensor in RGB format + + Returns + ------- + The image tensor in XYZ format + """ + dim = image.shape + image = image.reshape(dim[0], dim[1], dim[2] * dim[3]) + converted = mapping @ image + return converted.view(dim) + + def _rgb_to_xyz(self, image: torch.Tensor) -> torch.Tensor: + """RGB to XYZ conversion. + + Parameters + ---------- + image + The image tensor in RGB format + + Returns + ------- + The image tensor in XYZ format + """ + return self._rgb_xyz_rgb(image, self._rgb_xyz_map[0]) + + @classmethod + def _srgb_to_rgb(cls, image: torch.Tensor) -> torch.Tensor: + """SRGB to RGB conversion. + + Notes + ----- + RGB Image is clipped to a small epsilon to stabilize training + + Parameters + ---------- + image + The image tensor in SRGB format + + Returns + ------- + The image tensor in RGB format + """ + limit = 0.04045 + return torch.where(image > limit, + ((torch.clamp(image, min=limit) + 0.055) / 1.055) ** 2.4, + image / 12.92) + + def _srgb_to_ycxcz(self, image: torch.Tensor) -> torch.Tensor: + """SRGB to YcXcZ conversion. + + Parameters + ---------- + image + The image tensor in SRGB format + + Returns + ------- + The image tensor in YcXcZ format + """ + converted = self._srgb_to_rgb(image) + converted = self._rgb_to_xyz(converted) + return self._xyz_to_ycxcz(converted) + + def _xyz_to_lab(self, image: torch.Tensor) -> torch.Tensor: + """XYZ to LAB conversion. + + Parameters + ---------- + image + The image tensor in XYZ format + + Returns + ------- + The image tensor in LAB format + """ + image = image * self._inv_ref_illuminant + delta = 6 / 29 + delta_cube = delta ** 3 + factor = 1 / (3 * (delta ** 2)) + + clamped_term = torch.clamp(image, min=delta_cube) ** (1.0 / 3.0) + div = factor * image + (4 / 29) + + image = torch.where(image > delta_cube, clamped_term, div) + return torch.cat([116 * image[:, 1:2] - 16., + 500 * (image[:, 0:1] - image[:, 1:2]), + 200 * (image[:, 1:2] - image[:, 2:3])], + dim=1) + + def _xyz_to_rgb(self, image: torch.Tensor) -> torch.Tensor: + """XYZ to YcXcZ conversion. + + Parameters + ---------- + image + The image tensor in XYZ format + + Returns + ------- + The image tensor in RGB format + """ + return self._rgb_xyz_rgb(image, self._rgb_xyz_map[1]) + + def _xyz_to_ycxcz(self, image: torch.Tensor) -> torch.Tensor: + """XYZ to YcXcZ conversion. + + Parameters + ---------- + image + The image tensor in XYZ format + + Returns + ------- + The image tensor in YcXcZ format + """ + image = image * self._inv_ref_illuminant + return torch.cat([116 * image[:, 1:2] - 16., + 500 * (image[:, 0:1] - image[:, 1:2]), + 200 * (image[:, 1:2] - image[:, 2:3])], + dim=1) + + def _ycxcz_to_rgb(self, image: torch.Tensor) -> torch.Tensor: + """YcXcZ to RGB conversion. + + Parameters + ---------- + image + The image tensor in YcXcZ format + + Returns + ------- + The image tensor in RGB format + """ + converted = self._ycxcz_to_xyz(image) + return self._xyz_to_rgb(converted) + + def _ycxcz_to_xyz(self, image: torch.Tensor) -> torch.Tensor: + """YcXcZ to XYZ conversion. + + Parameters + ---------- + image + The image tensor in YcXcZ format + + Returns + ------- + The image tensor in XYZ format + """ + ch_y = (image[:, 0:1] + 16.) / 116 + return torch.cat([ch_y + (image[:, 1:2] / 500.), + ch_y, + ch_y - (image[:, 2:3] / 200.)], + dim=1) * self._ref_illuminant + + def forward(self, image: torch.Tensor) -> torch.Tensor: + """Call the color-space conversion function. + + Parameters + ---------- + image + The image tensor in the color-space defined by :attr:`from_space` + + Returns + ------- + The image tensor in the color-space defined by :attr:`to_space` + """ + return self._func(image) + + +__all__ = get_module_objects(__name__) diff --git a/tests/lib/model/losses/feature_loss_test.py b/tests/lib/model/losses/feature_loss_test.py index 12eb0981cd..04c6881f70 100644 --- a/tests/lib/model/losses/feature_loss_test.py +++ b/tests/lib/model/losses/feature_loss_test.py @@ -18,7 +18,8 @@ def test_loss_output(net): """ Basic dtype and value tests for loss functions. """ y_a = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() y_b = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() - objective_output = LPIPSLoss(net)(y_a, y_b) + lpips = LPIPSLoss(net).cpu() + objective_output = lpips(y_a, y_b) output = objective_output.detach().numpy() # type:ignore assert output.dtype == "float32" and not np.any(np.isnan(output)) assert (output <= 0.1).all() # LPIPS loss is reduced 10x diff --git a/tests/lib/model/losses/loss_test.py b/tests/lib/model/losses/loss_test.py index d0492d1255..82de70fc8b 100644 --- a/tests/lib/model/losses/loss_test.py +++ b/tests/lib/model/losses/loss_test.py @@ -31,7 +31,8 @@ def test_loss_output(loss_func, max_target): """ Basic dtype and value tests for loss functions. """ y_a = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() y_b = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() - objective_output = loss_func()(y_a, y_b) + metric = loss_func().cpu() + objective_output = metric(y_a, y_b) output = objective_output.detach().numpy() assert output.dtype == "float32" and not np.any(np.isnan(output)) assert (output <= max_target).all() diff --git a/tests/lib/model/losses/perceptual_loss_test.py b/tests/lib/model/losses/perceptual_loss_test.py index 0b4754d6ba..eed7b2c66b 100644 --- a/tests/lib/model/losses/perceptual_loss_test.py +++ b/tests/lib/model/losses/perceptual_loss_test.py @@ -2,10 +2,9 @@ """ Tests for Faceswap Feature Losses. Adapted from Keras tests. """ import pytest import numpy as np -from keras import device import torch -# pylint:disable=import-error +# pylint:disable=import-error,duplicate-code from lib.model.losses.perceptual_loss import DSSIMObjective, GMSDLoss, LDRFLIPLoss, MSSIMLoss from lib.utils import get_backend @@ -17,10 +16,10 @@ @pytest.mark.parametrize("loss_func", _PARAMS, ids=_IDS) def test_loss_output(loss_func): """ Basic dtype and value tests for loss functions. """ - with device("cpu"): - y_a = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() - y_b = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() - objective_output = loss_func()(y_a, y_b) + y_a = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() + y_b = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() + metric = loss_func().cpu() + objective_output = metric(y_a, y_b) output = objective_output.detach().numpy() # type:ignore assert output.dtype == "float32" and not np.any(np.isnan(output)) assert (output <= 1.0).all() From fc1dcd1fd0e6bf0575d32fa43b334dc516dea0cb Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:24:24 +0100 Subject: [PATCH 966/981] bugfix: Loss function device allocation --- lib/model/losses/__init__.py | 2 +- lib/model/losses/loss.py | 23 +++++++++++++++++ lib/model/losses/perceptual_loss.py | 13 ++++++++-- lib/torch_utils.py | 28 +++++++++++++++++++++ plugins/extract/base.py | 3 ++- plugins/train/model/_base/settings.py | 36 +++++++++++++-------------- 6 files changed, 82 insertions(+), 23 deletions(-) diff --git a/lib/model/losses/__init__.py b/lib/model/losses/__init__.py index 751e791011..aff02b11b8 100644 --- a/lib/model/losses/__init__.py +++ b/lib/model/losses/__init__.py @@ -3,5 +3,5 @@ from .feature_loss import LPIPSLoss from .loss import (FocalFrequencyLoss, GeneralizedLoss, GradientLoss, - LaplacianPyramidLoss, LInfNorm, LossWrapper) + LaplacianPyramidLoss, LInfNorm, LogCosh, LossWrapper) from .perceptual_loss import DSSIMObjective, GMSDLoss, LDRFLIPLoss, MSSIMLoss diff --git a/lib/model/losses/loss.py b/lib/model/losses/loss.py index f95b89fa59..f31766d26b 100644 --- a/lib/model/losses/loss.py +++ b/lib/model/losses/loss.py @@ -529,6 +529,29 @@ def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: return loss +class LogCosh(nn.Module): + """Logarithm of the hyperbolic cosine of the prediction error. Ported from Keras implementation + """ + def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: + """Call the LogCosh loss function. + + Parameters + ---------- + y_true + The ground truth value + y_pred + The predicted value + + Returns + ------- + The final loss value for each item in the batch + """ + diff = y_true - y_pred + loss: torch.Tensor = (diff + F.softplus(diff * -2.0) - # pylint:disable=not-callable + np.log(2)) + return loss.mean(dim=(1, 2, 3)) + + class LossWrapper(Loss): """A wrapper class for multiple keras losses to enable multiple masked weighted loss functions on a single output. diff --git a/lib/model/losses/perceptual_loss.py b/lib/model/losses/perceptual_loss.py index bec7477321..012200b2c1 100644 --- a/lib/model/losses/perceptual_loss.py +++ b/lib/model/losses/perceptual_loss.py @@ -46,6 +46,8 @@ class DSSIMObjective(nn.Module): ------ You should add a regularization term like a l2 loss in addition to this one. """ + _kernel: torch.Tensor + def __init__(self, k_1: float = 0.01, k_2: float = 0.03, @@ -56,7 +58,7 @@ def __init__(self, super().__init__() self._filter_size = filter_size self._filter_sigma = filter_sigma - self._kernel = self._get_kernel() + self.register_buffer("_kernel", self._get_kernel()) compensation = 1.0 self._c1 = (k_1 * max_value) ** 2 @@ -327,6 +329,9 @@ class LDRFLIPLoss(nn.Module): # pylint:disable=too-many-instance-attributes 0.7m wide 4K monitor at 0.7m from the display. Default: ``None`` color_order The `"bgr"` or `"rgb"` color order of the incoming images + spatial_output + ``True`` to output the loss function as a HxWx1 image output. ``False`` to reduce to mean + for each item in the batch. Default: ``False`` """ def __init__(self, computed_distance_exponent: float = 0.7, @@ -335,7 +340,8 @@ def __init__(self, upper_threshold_exponent: float = 0.95, epsilon: float = 1e-15, pixels_per_degree: float | None = None, - color_order: T.Literal["bgr", "rgb"] = "bgr") -> None: + color_order: T.Literal["bgr", "rgb"] = "bgr", + spatial_output: bool = False) -> None: logger.debug(parse_class_init(locals())) super().__init__() self._computed_distance_exponent = computed_distance_exponent @@ -344,6 +350,7 @@ def __init__(self, self._pt = upper_threshold_exponent self._epsilon = epsilon self._color_order = color_order.lower() + self._spatial_output = spatial_output if pixels_per_degree is None: pixels_per_degree = (0.7 * 3840 / 0.7) * np.pi / 180 @@ -503,6 +510,8 @@ def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: delta_e_color = self._color_pipeline(true_ycxcz, pred_ycxcz) delta_e_features = self._process_features(true_ycxcz, pred_ycxcz) loss = delta_e_color ** (1 - delta_e_features) + if not self._spatial_output: + loss = loss.mean(dim=(1, 2, 3)) return loss diff --git a/lib/torch_utils.py b/lib/torch_utils.py index 8fd0704bee..70caa61e85 100644 --- a/lib/torch_utils.py +++ b/lib/torch_utils.py @@ -15,6 +15,34 @@ logger = logging.getLogger(__name__) +def get_device(cpu: bool = False) -> torch.device: + """Get the correctly configured device for running Torch + + Parameters + ---------- + cpu + ``True`` to force running on the CPU. + + Returns + ------- + The device that torch should use + """ + if cpu: + logger.debug("CPU mode selected. Returning CPU device") + return torch.device("cpu") + + if torch.cuda.is_available(): + logger.debug("Cuda available. Returning Cuda device") + return torch.device("cuda") + + if torch.backends.mps.is_available(): + logger.debug("MPS available. Returning MPS device context") + return torch.device("mps") + + logger.debug("No backends available. Returning CPU device context") + return torch.device("cpu") + + class ColorSpaceConvert(nn.Module): """Transforms inputs between different color spaces on the GPU. Images expected in (N,C,H,W) order diff --git a/plugins/extract/base.py b/plugins/extract/base.py index 68f6f5e59b..64f70835aa 100644 --- a/plugins/extract/base.py +++ b/plugins/extract/base.py @@ -12,6 +12,7 @@ import torch from lib.logger import parse_class_init +from lib.torch_utils import get_device from lib.utils import get_module_objects if T.TYPE_CHECKING: @@ -36,7 +37,7 @@ class _TorchInfer(): def __init__(self, name: str, force_cpu: bool) -> None: logger.debug(parse_class_init(locals())) self._name = f"{self.__class__.__name__[1:]}.{name}" - self.device = self._get_device(cpu=force_cpu) + self.device = get_device(cpu=force_cpu) self._model: torch.nn.Module | None = None self._first_batch_seen = False self._output_is_list = False diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index b516905671..80a97f9ca1 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -16,13 +16,16 @@ import typing as T import keras -from keras import config as k_config, dtype_policies, losses as k_losses, optimizers +from keras import config as k_config, dtype_policies, optimizers +import torch +from torch import nn from lib.model import losses from lib.model.optimizers import AdaBelief from lib.model.autoclip import AutoClipper from lib.model.nn_blocks import reset_naming from lib.logger import parse_class_init +from lib.torch_utils import get_device from lib.utils import get_module_objects from plugins.train.train_config import Loss as cfg_loss, Optimizer as cfg_opt @@ -41,18 +44,13 @@ class LossClass: Parameters ---------- - function - The function that takes in the true/predicted images and returns the loss - init - Whether the loss object ``True`` needs to be initialized (i.e. it's a class) or - ``False`` it does not require initialization (i.e. it's a function). - Default ``True`` + object + The class object that contains the function that takes in the true/predicted images and + returns the loss kwargs Any keyword arguments to supply to the loss function at initialization. """ - function: Callable[[KerasTensor, KerasTensor], - KerasTensor] | T.Any = k_losses.MeanSquaredError - init: bool = True + function: type[nn.Module] = nn.MSELoss kwargs: dict[str, T.Any] = field(default_factory=dict) @@ -69,8 +67,8 @@ def __init__(self, color_order: T.Literal["bgr", "rgb"]) -> None: self._mask_channels = self._get_mask_channels() self._inputs: list[keras.layers.Layer] = [] self._names: list[str] = [] - self._functions: dict[str, losses.LossWrapper | T.Callable[[KerasTensor, KerasTensor], - KerasTensor]] = {} + self._functions: dict[str, losses.LossWrapper | T.Callable[[torch.Tensor, torch.Tensor], + torch.Tensor]] = {} self._loss_dict = {"ffl": LossClass(function=losses.FocalFrequencyLoss), "flip": LossClass(function=losses.LDRFLIPLoss, @@ -78,7 +76,7 @@ def __init__(self, color_order: T.Literal["bgr", "rgb"]) -> None: "gmsd": LossClass(function=losses.GMSDLoss), "l_inf_norm": LossClass(function=losses.LInfNorm), "laploss": LossClass(function=losses.LaplacianPyramidLoss), - "logcosh": LossClass(function=k_losses.LogCosh), + "logcosh": LossClass(function=losses.LogCosh), "lpips_alex": LossClass(function=losses.LPIPSLoss, kwargs={"trunk_network": "alex", "crop": True, @@ -92,8 +90,8 @@ def __init__(self, color_order: T.Literal["bgr", "rgb"]) -> None: "crop": True, "color_order": color_order}), "ms_ssim": LossClass(function=losses.MSSIMLoss), - "mae": LossClass(function=k_losses.MeanAbsoluteError), - "mse": LossClass(function=k_losses.MeanSquaredError), + "mae": LossClass(function=nn.MSELoss), + "mse": LossClass(function=nn.L1Loss), "pixel_gradient_diff": LossClass(function=losses.GradientLoss), "ssim": LossClass(function=losses.DSSIMObjective), "smooth_loss": LossClass(function=losses.GeneralizedLoss)} @@ -106,8 +104,8 @@ def names(self) -> list[str]: return self._names @property - def functions(self) -> dict[str, losses.LossWrapper | T.Callable[[KerasTensor, KerasTensor], - KerasTensor]]: + def functions(self) -> dict[str, losses.LossWrapper | T.Callable[[torch.Tensor, torch.Tensor], + torch.Tensor]]: """The loss functions that apply to each model output.""" return self._functions @@ -163,7 +161,7 @@ def _set_loss_names(self, outputs: list[KerasTensor]) -> None: self._names.append(f"{name}_{side}{suffix}") logger.debug(self._names) - def _get_function(self, name: str) -> Callable[[KerasTensor, KerasTensor], KerasTensor]: + def _get_function(self, name: str) -> Callable[[torch.Tensor, torch.Tensor], torch.Tensor]: """Obtain the requested Loss function Parameters @@ -176,7 +174,7 @@ def _get_function(self, name: str) -> Callable[[KerasTensor, KerasTensor], Keras The requested loss function """ func = self._loss_dict[name] - retval = func.function(**func.kwargs) if func.init else func.function # type:ignore + retval = func.function(**func.kwargs).to(get_device()) logger.debug("Obtained loss function `%s` (%s)", name, retval) return retval From 758a44a18753cd09a6726651afaaf9e6e22fed9a Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:58:28 +0100 Subject: [PATCH 967/981] bugfix: GUI loading projects with removed options --- .../lib/{keras_utils.rst => torch_utils.rst} | 2 +- lib/gui/command.py | 2 +- lib/gui/menu.py | 2 +- lib/gui/options.py | 18 +- lib/gui/project.py | 628 ++++++++++-------- lib/gui/utils/config.py | 2 +- lib/training/data_loader.py | 3 +- lib/training/data_set.py | 3 +- 8 files changed, 371 insertions(+), 289 deletions(-) rename docs/full/lib/{keras_utils.rst => torch_utils.rst} (62%) diff --git a/docs/full/lib/keras_utils.rst b/docs/full/lib/torch_utils.rst similarity index 62% rename from docs/full/lib/keras_utils.rst rename to docs/full/lib/torch_utils.rst index 03a049a76d..07bacdfbe6 100644 --- a/docs/full/lib/keras_utils.rst +++ b/docs/full/lib/torch_utils.rst @@ -1,3 +1,3 @@ -.. automodapi:: lib.keras_utils +.. automodapi:: lib.torch_utils :include-all-objects: :no-inheritance-diagram: diff --git a/lib/gui/command.py b/lib/gui/command.py index 72a8dbf409..2d98340be5 100644 --- a/lib/gui/command.py +++ b/lib/gui/command.py @@ -132,7 +132,7 @@ def build_tab(self): """ Build the tab """ logger.debug("Build Tab: '%s'", self.command) options = get_config().cli_opts.opts[self.command] - cp_opts = [val.cpanel_option for val in options.values() if isinstance(val, CliOption)] + cp_opts = [val.panel_option for val in options.values() if isinstance(val, CliOption)] ControlPanel(self, cp_opts, label_width=16, diff --git a/lib/gui/menu.py b/lib/gui/menu.py index 09894062dc..40b5269f82 100644 --- a/lib/gui/menu.py +++ b/lib/gui/menu.py @@ -173,7 +173,7 @@ def _build_recent_menu(self) -> None: """ Load recent files into menu bar """ logger.debug("Building Recent Files menu") serializer = get_serializer("json") - menu_file = os.path.join(self._config.pathcache, ".recent.json") + menu_file = os.path.join(self._config.path_cache, ".recent.json") recent_files = [] if not os.path.isfile(menu_file) or os.path.getsize(menu_file) == 0: self._clear_recent_files(serializer, menu_file) diff --git a/lib/gui/options.py b/lib/gui/options.py index c941910a6e..26bff9ec0b 100644 --- a/lib/gui/options.py +++ b/lib/gui/options.py @@ -32,7 +32,7 @@ class CliOption: Parameters ---------- - cpanel_option: :class:`~lib.gui.control_helper.ControlPanelOption`: + panel_option: :class:`~lib.gui.control_helper.ControlPanelOption`: Object to hold information of a command line item for displaying in a GUI :class:`~lib.gui.control_helper.ControlPanel` opts: tuple[str, ...]: @@ -41,7 +41,7 @@ class CliOption: ``None`` for not used. "+" for at least 1 argument required with values to be contained in a list """ - cpanel_option: ControlPanelOption + panel_option: ControlPanelOption """:class:`~lib.gui.control_helper.ControlPanelOption`: Object to hold information of a command line item for displaying in a GUI :class:`~lib.gui.control_helper.ControlPanel`""" opts: tuple[str, ...] @@ -434,7 +434,7 @@ def _process_options(self, command_options: list[dict[str, T.Any]], command: str logger.debug("Skipping suppressed option: %s", opt) continue title = self._set_control_title(opt["opts"]) - cpanel_option = ControlPanelOption( + panel_option = ControlPanelOption( title, self._get_data_type(opt), group=opt.get("group", None), @@ -448,7 +448,7 @@ def _process_options(self, command_options: list[dict[str, T.Any]], command: str helptext=opt["help"], track_modified=True, command=command) - retval[title] = CliOption(cpanel_option=cpanel_option, + retval[title] = CliOption(panel_option=panel_option, opts=opt["opts"], nargs=opt.get("nargs")) logger.debug("Processed: %s", retval) @@ -534,7 +534,7 @@ def reset(self, command: str | None = None) -> None: """ logger.debug("Resetting options to default. (command: '%s'", command) for option in self._options_to_process(command): - cp_opt = option.cpanel_option + cp_opt = option.panel_option default = "" if cp_opt.default is None else cp_opt.default if option.nargs is not None and isinstance(default, (list, tuple)): default = ' '.join(str(val) for val in default) @@ -551,7 +551,7 @@ def clear(self, command: str | None = None) -> None: """ logger.debug("Clearing options. (command: '%s'", command) for option in self._options_to_process(command): - cp_opt = option.cpanel_option + cp_opt = option.panel_option if isinstance(cp_opt.get(), bool): cp_opt.set(False) elif isinstance(cp_opt.get(), (int, float)): @@ -582,7 +582,7 @@ def get_option_values(self, command: str | None = None for key, val in opts.items(): if not isinstance(val, CliOption): continue - cmd_dict[key] = val.cpanel_option.get() + cmd_dict[key] = val.panel_option.get() ctl_dict[cmd] = cmd_dict logger.debug("command: '%s', ctl_dict: %s", command, ctl_dict) return ctl_dict @@ -605,7 +605,7 @@ def get_one_option_variable(self, command: str, title: str) -> Variable | None: """ for opt_title, option in self._gen_command_options(command): if opt_title == title: - return option.cpanel_option.tk_var + return option.panel_option.tk_var return None def gen_cli_arguments(self, command: str) -> T.Generator[tuple[str, ...], None, None]: @@ -625,7 +625,7 @@ def gen_cli_arguments(self, command: str) -> T.Generator[tuple[str, ...], None, switches = "" args = [] for _, option in self._gen_command_options(command): - str_val = str(option.cpanel_option.get()) + str_val = str(option.panel_option.get()) switch = option.opts[0] batch_mode = command == "extract" and switch == "-b" # Check for batch mode if command in ("extract", "convert") and switch == "-o": # Output location for preview diff --git a/lib/gui/project.py b/lib/gui/project.py index 24a4a392fe..1e079991fc 100644 --- a/lib/gui/project.py +++ b/lib/gui/project.py @@ -1,106 +1,121 @@ #!/usr/bin/env python3 -""" Handling of Faceswap GUI Projects, Tasks and Last Session """ +"""Handling of Faceswap GUI Projects, Tasks and Last Session""" +from __future__ import annotations import logging import os import tkinter as tk from tkinter import messagebox +import typing as T from lib.serializer import get_serializer from lib.gui import gui_config as cfg +from lib.logger import parse_class_init from lib.utils import get_module_objects +if T.TYPE_CHECKING: + from .utils.config import Config + from .utils import FileHandler + logger = logging.getLogger(__name__) class _GuiSession(): # pylint:disable=too-few-public-methods - """ Parent class for GUI Session Handlers. + """Parent class for GUI Session Handlers. Parameters ---------- - config: :class:`lib.gui.utils.Config` + config The master GUI config - file_handler: :class:`lib.gui.utils.FileHandler` + file_handler A file handler object """ - def __init__(self, config, file_handler=None): + def __init__(self, config: Config, file_handler: type[FileHandler] | None = None) -> None: # NB file_handler has to be passed in to avoid circular imports - logger.debug("Initializing: %s: (config: %s, file_handler: %s)", - self.__class__.__name__, config, file_handler) + logger.debug(parse_class_init(locals())) self._serializer = get_serializer("json") self._config = config - self._options = None + self._options: dict[str, str | dict[str, bool | int | float | str]] | None = None self._file_handler = file_handler - self._filename = None + self._filename: str | None = None self._saved_tasks = None self._modified = False - logger.debug("Initialized: %s", self.__class__.__name__) @property - def _active_tab(self): - """ str: The name of the currently selected :class:`lib.gui.command.CommandNotebook` - tab. """ + def _active_tab(self) -> str: + """The name of the currently selected :class:`lib.gui.command.CommandNotebook` tab""" notebook = self._config.command_notebook - toolsbook = self._config.tools_notebook + assert notebook is not None + tools_book = self._config.tools_notebook command = notebook.tab(notebook.select(), "text").lower() if command == "tools": - command = toolsbook.tab(toolsbook.select(), "text").lower() + command = tools_book.tab(tools_book.select(), "text").lower() logger.debug("Active tab: %s", command) return command @property - def _modified_vars(self): - """ dict: The tkinter Boolean vars indicating the modified state for each tab. """ + def _modified_vars(self) -> dict[str, tk.BooleanVar]: + """The tkinter Boolean vars indicating the modified state for each tab.""" return self._config.modified_vars @property - def _file_exists(self): - """ bool: ``True`` if :attr:`_filename` exists otherwise ``False``. """ + def _file_exists(self) -> bool: + """``True`` if :attr:`_filename` exists otherwise ``False``.""" return self._filename is not None and os.path.isfile(self._filename) @property - def _cli_options(self): - """ dict: the raw cli options from :attr:`_options` with project fields removed. """ + def _cli_options(self) -> dict[str, dict[str, bool | int | float | str]]: + """The raw cli options from :attr:`_options` with project fields removed. """ + assert self._options is not None return {key: val for key, val in self._options.items() if isinstance(val, dict)} @property - def _default_options(self): - """ dict: The default options for all tabs """ + def _default_options(self) -> dict[str, T.Any]: + """The default options for all tabs""" return self._config.default_options @property - def _dirname(self): - """ str: The folder name that :attr:`_filename` resides in. Returns ``None`` if - filename is ``None``. """ + def _dirname(self) -> str | None: + """The folder name that :attr:`_filename` resides in. Returns ``None`` if filename is + ``None``.""" return os.path.dirname(self._filename) if self._filename is not None else None @property - def _basename(self): - """ str: The base name of :attr:`_filename`. Returns ``None`` if filename is ``None``. """ + def _basename(self) -> str | None: + """The base name of :attr:`_filename`. Returns ``None`` if filename is ``None``.""" return os.path.basename(self._filename) if self._filename is not None else None @property - def _stored_tab_name(self): - """str: The tab_name stored in :attr:`_options` or ``None`` if it does not exist """ + def _stored_tab_name(self) -> str | None: + """The tab_name stored in :attr:`_options` or ``None`` if it does not exist""" if self._options is None: return None - return self._options.get("tab_name", None) + retval = self._options.get("tab_name", None) + assert retval is None or isinstance(retval, str) + return retval @property - def _selected_to_choices(self): - """ dict: The selected value and valid choices for multi-option, radio or combo options. - """ - valid_choices = {cmd: {opt: {"choices": val.cpanel_option.choices, - "is_multi": val.cpanel_option.is_multi_option} - for opt, val in data.items() - if hasattr(val, "cpanel_option") # Filter out helptext - and val.cpanel_option.choices is not None - } - for cmd, data in self._config.cli_opts.opts.items()} - logger.trace("valid_choices: %s", valid_choices) + def _selected_to_choices(self) -> dict[str, dict[str, dict[str, T.Any]]]: + """The selected value and valid choices for multi-option, radio or combo options.""" + # TODO do instance check on CliOption. Not done for now due to circular import + # pylint:disable=line-too-long + valid_choices = { + cmd: { + opt: { + "choices": val.panel_option.choices, # pyright:ignore[reportAttributeAccessIssue] # noqa[E501] + "is_multi": val.panel_option.is_multi_option # pyright:ignore[reportAttributeAccessIssue] # noqa[E501] + } + for opt, val in data.items() + if hasattr(val, "panel_option") # Filter out helptext + and val.panel_option.choices is not None # pyright:ignore[reportAttributeAccessIssue] # noqa[E501] + } + for cmd, data in self._config.cli_opts.opts.items() + } + logger.trace("valid_choices: %s", valid_choices) # type:ignore[attr-defined] + assert self._options is not None retval = {command: {option: {"value": value, "is_multi": valid_choices[command][option]["is_multi"], "choices": valid_choices[command][option]["choices"]} @@ -109,77 +124,83 @@ def _selected_to_choices(self): and option in valid_choices[command]} for command, options in self._options.items() if isinstance(options, dict)} - logger.trace("returning: %s", retval) + logger.trace("returning: %s", retval) # type:ignore[attr-defined] return retval - def _current_gui_state(self, command=None): - """ The current state of the GUI. + def _current_gui_state(self, command: str | None = None + ) -> dict[str, dict[str, bool | int | float | str]]: + """The current state of the GUI. Parameters ---------- - command: str, optional - If provided, returns the state of just the given tab command. If ``None`` returns options - for all tabs. Default ``None`` + command + If provided, returns the state of just the given tab command. If ``None`` returns + options for all tabs. Default ``None`` Returns ------- - dict: The options currently set in the GUI + The options currently set in the GUI """ return self._config.cli_opts.get_option_values(command) - def _set_filename(self, filename=None, sess_type="project"): - """ Set the :attr:`_filename` attribute. + def _set_filename(self, + filename: str | None = None, + session_type: T.Literal["all", "project", "task"] = "project") -> bool: + """Set the :attr:`_filename` attribute. :attr:`_filename` is set either from a given filename or the result from a :attr:`_file_handler`. Parameters ---------- - filename: str, optional + filename An optional filename. If given then this filename will be used otherwise it will be collected by a :attr:`_file_handler` - sess_type: {all, project, task}, optional + session_type The session type that the filename is being set for. Dictates the type of file handler - that is opened. + that is opened. Default: `"Project"` Returns ------- - bool: `True` if filename has been successfully set otherwise ``False`` + ``True`` if filename has been successfully set otherwise ``False`` """ - logger.debug("filename: '%s', sess_type: '%s'", filename, sess_type) - handler = f"config_{sess_type}" - + logger.debug("filename: '%s', session_type: '%s'", filename, session_type) + handler = T.cast(T.Literal["config_all", "config_project", "config_task"], + f"config_{session_type}") if filename is None: logger.debug("Popping file handler") - cfgfile = self._file_handler("open", handler).return_file - if not cfgfile: + assert self._file_handler is not None + cfg_file = self._file_handler("open", handler).return_file + if not cfg_file: logger.debug("No filename given") return False - filename = cfgfile.name - cfgfile.close() + filename = cfg_file.name + cfg_file.close() + assert filename is not None if not os.path.isfile(filename): msg = f"File does not exist: '{filename}'" logger.error(msg) return False ext = os.path.splitext(filename)[1] - if (sess_type == "project" and ext != ".fsw") or (sess_type == "task" and ext != ".fst"): - logger.debug("Invalid file extension for session type: (sess_type: '%s', " - "extension: '%s')", sess_type, ext) + if (session_type == "project" and ext != ".fsw") or (session_type == "task" + and ext != ".fst"): + logger.debug("Invalid file extension for session type: (session_type: '%s', " + "extension: '%s')", session_type, ext) return False logger.debug("Setting filename: '%s'", filename) self._filename = filename return True # GUI STATE SETTING - def _set_options(self, command=None): - """ Set the GUI options based on the currently stored properties of :attr:`_options` + def _set_options(self, command: str | None = None) -> None: + """Set the GUI options based on the currently stored properties of :attr:`_options` and sets the active tab. Parameters ---------- - command: str, optional + command The tab to set the options for. If None then sets options for all tabs. Default: ``None`` """ @@ -190,18 +211,21 @@ def _set_options(self, command=None): return for cmd, opt in opts.items(): self._set_gui_state_for_command(cmd, opt) + assert self._options is not None tab_name = self._options.get("tab_name", None) if command is None else command tab_name = tab_name if tab_name is not None else "extract" logger.debug("tab_name: %s", tab_name) + assert isinstance(tab_name, str) self._config.set_active_tab_by_name(tab_name) - def _get_options_for_command(self, command): - """ Return a single command's options from :attr:`_options` formatted consistently with + def _get_options_for_command(self, command: str + ) -> dict[str, dict[str, bool | int | float | str]] | None: + """Return a single command's options from :attr:`_options` formatted consistently with an all options dict. Parameters ---------- - command: str + command The command to return the options for Returns @@ -210,44 +234,50 @@ def _get_options_for_command(self, command): is not found then returns ``None`` """ logger.debug(command) - opts = self._options.get(command, None) - retval = {command: opts} - if not opts: + assert self._options is not None + opts = T.cast(dict[str, int | float | bool | str] | None, self._options.get(command, None)) + if opts is None: self._config.tk_vars.console_clear.set(True) logger.info("No %s section found in file", command) retval = None + else: + retval = {command: opts} logger.debug(retval) return retval - def _set_gui_state_for_command(self, command, options): - """ Set the GUI state for the given command. + def _set_gui_state_for_command(self, + command: str, + options: dict[str, bool | int | float | str] + ) -> None: + """Set the GUI state for the given command. Parameters ---------- - command: str + command The tab to set the options for - options: dict + options The option values to set the GUI to """ logger.debug("command: %s: options: %s", command, options) if not options: logger.debug("No options provided, not updating GUI") return - for srcopt, srcval in options.items(): - optvar = self._config.cli_opts.get_one_option_variable(command, srcopt) - if not optvar: + for src_opt, src_val in options.items(): + opt_var = self._config.cli_opts.get_one_option_variable(command, src_opt) + if not opt_var: continue - logger.trace("setting option: (srcopt: %s, optvar: %s, srcval: %s)", - srcopt, optvar, srcval) - optvar.set(srcval) + logger.trace( # type:ignore[attr-defined] + "setting option: (src_opt: %s, opt_var: %s, src_val: %s)", + src_opt, opt_var, src_val) + opt_var.set(src_val) - def _reset_modified_var(self, command=None): - """ Reset :attr:`_modified_vars` variables back to unmodified (`False`) for all + def _reset_modified_var(self, command: str | None = None) -> None: + """Reset :attr:`_modified_vars` variables back to unmodified (`False`) for all commands or for the given command. Parameters ---------- - command: str, optional + command The command to reset the modified tkinter variable for. If ``None`` then all tkinter modified variables are reset to `False`. Default: ``None`` """ @@ -257,12 +287,12 @@ def _reset_modified_var(self, command=None): tk_var.set(False) # RECENT FILE HANDLING - def _add_to_recent(self, command=None): - """ Add the file for this session to the recent files list. + def _add_to_recent(self, command: str | None = None) -> None: + """Add the file for this session to the recent files list. Parameters ---------- - command: str, optional + command The command that this session relates to. If `None` then the whole project is added. Default: ``None`` """ @@ -270,44 +300,59 @@ def _add_to_recent(self, command=None): if self._filename is None: logger.debug("No filename for selected file. Not adding to recent.") return - recent_filename = os.path.join(self._config.pathcache, ".recent.json") + recent_filename = os.path.join(self._config.path_cache, ".recent.json") logger.debug("Adding to recent files '%s': (%s, %s)", recent_filename, self._filename, command) if not os.path.exists(recent_filename) or os.path.getsize(recent_filename) == 0: logger.debug("Starting with empty recent_files list") - recent_files = [] + recent_files: list[tuple[str, str]] | None = [] else: logger.debug("loading recent_files list: %s", recent_filename) - recent_files = self._serializer.load(recent_filename) + assert self._serializer is not None + recent_files = self._serializer.load( # pyright:ignore[reportCallIssue] + recent_filename) logger.debug("Initial recent files: %s", recent_files) recent_files = self._del_from_recent(self._filename, recent_files) - ftype = "project" if command is None else command - recent_files.insert(0, (self._filename, ftype)) + assert recent_files is not None + f_type = "project" if command is None else command + recent_files.insert(0, (self._filename, f_type)) recent_files = recent_files[:20] logger.debug("Final recent files: %s", recent_files) - self._serializer.save(recent_filename, recent_files) - def _del_from_recent(self, filename, recent_files=None, save=False): - """ Remove an item from the recent files list. + assert self._serializer is not None + self._serializer.save(recent_filename, recent_files) # pyright:ignore[reportCallIssue] + + def _del_from_recent(self, + filename: str, + recent_files: list[tuple[str, str]] | None = None, + save: bool = False) -> list[tuple[str, str]] | None: + """Remove an item from the recent files list. Parameters ---------- - filename: str + filename The filename to be removed from the recent files list - recent_files: list, optional + recent_files If the recent files list has already been loaded, it can be passed in to avoid loading again. If ``None`` then load the recent files list from disk. Default: ``None`` - save: bool, optional + save Whether the recent files list should be saved after removing the file. ``True`` saves the file, ``False`` does not. Default: ``False`` + + Returns + ------- + List of recent files and their filetypes """ - recent_filename = os.path.join(self._config.pathcache, ".recent.json") + recent_filename = os.path.join(self._config.path_cache, ".recent.json") if recent_files is None: logger.debug("Loading file list from disk: %s", recent_filename) if not os.path.exists(recent_filename) or os.path.getsize(recent_filename) == 0: logger.debug("No recent file list") return None - recent_files = self._serializer.load(recent_filename) + assert self._serializer is not None + recent_files = self._serializer.load( # pyright:ignore[reportCallIssue] + recent_filename) + assert recent_files is not None filenames = [recent[0] for recent in recent_files] if filename in filenames: idx = filenames.index(filename) @@ -315,18 +360,20 @@ def _del_from_recent(self, filename, recent_files=None, save=False): del recent_files[idx] if save: logger.debug("Saving recent files list: %s", recent_filename) - self._serializer.save(recent_filename, recent_files) + assert self._serializer is not None + self._serializer.save(recent_filename, # pyright:ignore[reportCallIssue] + recent_files) else: logger.debug("Filename '%s' does not appear in recent file list", filename) return recent_files - def _get_lone_task(self): - """ Get the sole command name from :attr:`_options`. + def _get_lone_task(self) -> str | None: + """Get the sole command name from :attr:`_options`. Returns ------- - str: The only existing command name in the current :attr:`_options` dict or ``None`` if - there are multiple commands stored. + The only existing command name in the current :attr:`_options` dict or ``None`` if there + are multiple commands stored. """ command = None if len(self._cli_options) == 1: @@ -335,16 +382,18 @@ def _get_lone_task(self): return command # DISK IO - def _load(self): - """ Load GUI options from :attr:`_filename` location and set to :attr:`_options`. + def _load(self) -> bool: + """Load GUI options from :attr:`_filename` location and set to :attr:`_options`. Returns ------- - bool: ``True`` if successfully loaded otherwise ``False`` + ``True`` if successfully loaded otherwise ``False`` """ if self._file_exists: logger.debug("Loading config") - self._options = self._serializer.load(self._filename) + assert self._serializer is not None + self._options = self._serializer.load( # pyright:ignore[reportCallIssue] + self._filename) self._check_valid_choices() retval = True else: @@ -352,26 +401,31 @@ def _load(self): retval = False return retval - def _check_valid_choices(self): - """ Check whether the loaded file has any selected combo/radio/multi-option values that are - no longer valid and remove them so that they are not passed into faceswap. """ + def _check_valid_choices(self) -> None: + """Check whether the loaded file has any selected combo/radio/multi-option values that are + no longer valid and remove them so that they are not passed into faceswap.""" + assert self._options is not None for command, options in self._selected_to_choices.items(): + opts = T.cast(dict[str, bool | int | float | str], self._options[command]) for option, data in options.items(): - if ((data["is_multi"] and all(v in data["choices"] for v in data["value"].split())) - or not data["is_multi"] and data["value"] in data["choices"]): + if not data["is_multi"] and data["value"] in data["choices"]: continue - if data["is_multi"]: + if (data["is_multi"] and + isinstance(data["value"], str) and + all(v in data["choices"] for v in data["value"].split())): + continue + if data["is_multi"] and isinstance(data["value"], str): val = " ".join([v for v in data["value"].split() if v in data["choices"]]) else: val = "" val = self._default_options[command][option] if not val else val logger.debug("Updating invalid value to default: (command: '%s', option: '%s', " "original value: '%s', new value: '%s')", command, option, - self._options[command][option], val) - self._options[command][option] = val + opts[option], val) + opts[option] = val - def _save_as_to_filename(self, session_type): - """ Set :attr:`_filename` from a save as dialog. + def _save_as_to_filename(self, session_type: T.Literal["all", "task", "project"]) -> bool: + """Set :attr:`_filename` from a save as dialog. Parameters ---------- @@ -380,26 +434,28 @@ def _save_as_to_filename(self, session_type): Returns ------- - bool: - True if :attr:`filename` successfully set otherwise ``False`` + True if :attr:`filename` successfully set otherwise ``False`` """ logger.debug("Popping save as file handler. session_type: '%s'", session_type) title = f"Save {f'{session_type.title()} ' if session_type != 'all' else ''}As..." - cfgfile = self._file_handler("save", - f"config_{session_type}", - title=title, - initial_folder=self._dirname).return_file - if not cfgfile: + assert self._file_handler is not None + cfg_file = self._file_handler( + "save", + T.cast(T.Literal["config_all", "config_project", "config_task"], + f"config_{session_type}"), + title=title, + initial_folder=self._dirname).return_file + if not cfg_file: logger.debug("No filename provided. session_type: '%s'", session_type) return False - self._filename = cfgfile.name + self._filename = cfg_file.name logger.debug("Set filename: (session_type: '%s', filename: '%s'", session_type, self._filename) - cfgfile.close() + cfg_file.close() return True - def _save(self, command=None): - """ Collect the options in the current GUI state and save. + def _save(self, command: str | None = None) -> None: + """Collect the options in the current GUI state and save. Obtains the current options set in the GUI with the selected tab and applies them to :attr:`_options`. Saves :attr:`_options` to :attr:`_filename`. Resets :attr:_modified_vars @@ -407,20 +463,22 @@ def _save(self, command=None): Parameters ---------- - command: str, optional + command The tab to collect the current state for. If ``None`` then collects the current state for all tabs. Default: ``None`` """ - self._options = self._current_gui_state(command) + self._options = T.cast(dict[str, str | dict[str, bool | int | float | str]], + self._current_gui_state(command)) self._options["tab_name"] = self._active_tab logger.debug("Saving options: (filename: %s, options: %s", self._filename, self._options) - self._serializer.save(self._filename, self._options) + assert self._serializer is not None + self._serializer.save(self._filename, self._options) # pyright:ignore[reportCallIssue] self._reset_modified_var(command) self._add_to_recent(command) class Tasks(_GuiSession): - """ Faceswap ``.fst`` Task File handling. + """Faceswap ``.fst`` Task File handling. Faceswap tasks handle the management of each individual task tab in the GUI. Unlike :class:`Projects`, Tasks contains all the active tasks currently running, rather than an @@ -428,25 +486,28 @@ class Tasks(_GuiSession): Parameters ---------- - config: :class:`lib.gui.utils.Config` + config The master GUI config - file_handler: :class:`lib.gui.utils.FileHandler` + file_handler A file handler object """ - def __init__(self, config, file_handler): + def __init__(self, config: Config, file_handler: type[FileHandler]): super().__init__(config, file_handler) - self._tasks = {} + self._tasks: dict[ + str, dict[T.Literal["filename", "options", "is_project"], + str | bool | dict[str, str | dict[str, + bool | int | float | str]] | None]] = {} @property - def _is_project(self): - """ str: ``True`` if all tasks are from an overarching session project else ``False``.""" + def _is_project(self) -> bool: + """``True`` if all tasks are from an overarching session project else ``False``.""" retval = False if not self._tasks else all(v.get("is_project", False) for v in self._tasks.values()) return retval @property - def _project_filename(self): - """ str: The overarching session project filename.""" + def _project_filename(self) -> str | None: + """The overarching session project filename.""" fname = None if not self._is_project: return fname @@ -454,23 +515,26 @@ def _project_filename(self): for val in self._tasks.values(): fname = val["filename"] break + assert fname is None or isinstance(fname, str) return fname - def load(self, *args, # pylint:disable=unused-argument - filename=None, current_tab=True): - """ Load a task into this :class:`Tasks` class. + def load(self, # pylint:disable=unused-argument + *args, + filename: str | None = None, + current_tab: bool = True) -> None: + """Load a task into this :class:`Tasks` class. Tasks can be loaded from project ``.fsw`` files or task ``.fst`` files, depending on where this function is being called from. Parameters ---------- - *args: tuple + *args Unused, but needs to be present for arguments passed by tkinter event handling - filename: str, optional + filename If a filename is passed in, This will be used, otherwise a file handler will be launched to select the relevant file. - current_tab: bool, optional + current_tab ``True`` if the task to be loaded must be for the currently selected tab. ``False`` if loading a task into any tab. If current_tab is `True` then tasks can be loaded from ``.fsw`` and ``.fst`` files, otherwise they can only be loaded from ``.fst`` files. @@ -480,16 +544,17 @@ def load(self, *args, # pylint:disable=unused-argument filename, current_tab) # Option to load specific task from project files: - sess_type = "all" if current_tab else "task" + session_type: T.Literal["all", "task"] = "all" if current_tab else "task" is_legacy = (not self._is_project and - filename is not None and sess_type == "task" and + filename is not None and session_type == "task" and os.path.splitext(filename)[1] == ".fsw") if is_legacy: logger.debug("Legacy task found: '%s'", filename) + assert filename is not None filename = self._update_legacy_task(filename) - filename_set = self._set_filename(filename, sess_type=sess_type) + filename_set = self._set_filename(filename, session_type=session_type) if not filename_set: return loaded = self._load() @@ -501,6 +566,7 @@ def load(self, *args, # pylint:disable=unused-argument if command is None: logger.error("Unable to determine task from the given file: '%s'", filename) return + assert self._options is not None if command not in self._options: logger.error("No '%s' task in '%s'", command, self._filename) return @@ -510,7 +576,7 @@ def load(self, *args, # pylint:disable=unused-argument if self._is_project: self._filename = self._project_filename - elif self._filename.endswith(".fsw"): + elif self._filename is not None and self._filename.endswith(".fsw"): self._filename = None self._add_task(command) @@ -519,21 +585,20 @@ def load(self, *args, # pylint:disable=unused-argument logger.debug("Loaded task config: (command: '%s', filename: '%s')", command, filename) - def _update_legacy_task(self, filename): - """ Update legacy ``.fsw`` tasks to ``.fst`` tasks. + def _update_legacy_task(self, filename: str) -> str: + """Update legacy ``.fsw`` tasks to ``.fst`` tasks. Tasks loaded from the recent files menu may be passed in with a ``.fsw`` extension. This renames the file and removes it from the recent file list. Parameters ---------- - filename: str + filename The filename of the `.fsw` file that needs converting Returns ------- - str: - The new filename of the updated tasks file + The new filename of the updated tasks file """ # TODO remove this code after a period of time. Implemented November 2019 logger.debug("original filename: '%s'", filename) @@ -549,15 +614,15 @@ def _update_legacy_task(self, filename): logger.debug("new filename: '%s'", new_filename) return new_filename - def save(self, save_as=False): - """ Save the current GUI state for the active tab to a ``.fst`` faceswap task file. + def save(self, save_as: bool = False) -> None: + """Save the current GUI state for the active tab to a ``.fst`` faceswap task file. Parameters ---------- - save_as: bool, optional + save_as Whether to save to the stored filename, or pop open a file handler to ask for a location. If there is no stored filename, then a file handler will automatically be - popped. + popped. Default: ``False`` """ logger.debug("Saving config...") self._set_active_task() @@ -574,12 +639,12 @@ def save(self, save_as=False): else: logger.debug("Saved project to: '%s'", self._filename) - def clear(self): - """ Reset all GUI options to their default values for the active tab. """ + def clear(self) -> None: + """Reset all GUI options to their default values for the active tab.""" self._config.cli_opts.reset(self._active_tab) - def reload(self): - """ Reset currently selected tab GUI options to their last saved state. """ + def reload(self) -> None: + """Reset currently selected tab GUI options to their last saved state.""" self._set_active_task() if self._options is None: @@ -590,8 +655,8 @@ def reload(self): if self._is_project: self._reset_modified_var(self._active_tab) - def _add_task(self, command): - """ Add the currently active task to the internal :attr:`_tasks` dict. + def _add_task(self, command: str) -> None: + """Add the currently active task to the internal :attr:`_tasks` dict. If the currently stored task is from an overarching session project, then only the options are updated. When resetting a tab to saved a project will always @@ -600,16 +665,15 @@ def _add_task(self, command): Parameters ---------- - command: str + command The tab that pertains to the currently active task - """ self._tasks[command] = {"filename": self._filename, "options": self._options, "is_project": self._is_project} - def clear_tasks(self): - """ Clears all of the stored tasks. + def clear_tasks(self) -> None: + """Clears all of the stored tasks. This is required when loading a task stored in a legacy project file, and is only to be called by :class:`Project` when a project has been loaded which is in fact a task. @@ -617,8 +681,11 @@ def clear_tasks(self): logger.debug("Clearing stored tasks") self._tasks = {} - def add_project_task(self, filename, command, options): - """ Add an individual task from a loaded :class:`Project` to the internal :attr:`_tasks` + def add_project_task(self, + filename: str, + command: str, + options: dict[str, str | dict[str, bool | int | float | str]]) -> None: + """Add an individual task from a loaded :class:`Project` to the internal :attr:`_tasks` dict. Project tasks take priority over any other tasks, so the individual tasks from a new @@ -626,22 +693,22 @@ def add_project_task(self, filename, command, options): Parameters ---------- - filename: str + filename The filename of the session project file - command: str + command The tab that this task's options belong to - options: dict + options The options for this task loaded from the project """ self._tasks[command] = {"filename": filename, "options": options, "is_project": True} - def _set_active_task(self, command=None): - """ Set the active :attr:`_filename` and :attr:`_options` to currently selected tab's + def _set_active_task(self, command: str | None = None) -> None: + """Set the active :attr:`_filename` and :attr:`_options` to currently selected tab's options. Parameters ---------- - command: str, optional + command If a command is passed in then set the given tab to active, If this is none set the tab which currently has focus to active. Default: ``None`` """ @@ -651,51 +718,56 @@ def _set_active_task(self, command=None): if task is None: self._filename, self._options = (None, None) else: - self._filename, self._options = (task.get("filename", None), task.get("options", None)) + filename = task.get("filename", None) + opts = task.get("options", None) + assert filename is None or isinstance(filename, str) + assert opts is None or isinstance(opts, dict) + self._filename = filename + self._options = opts logger.debug("tab: %s, filename: %s, options: %s", self._active_tab, self._filename, self._options) class Project(_GuiSession): - """ Faceswap ``.fsw`` Project File handling. + """Faceswap ``.fsw`` Project File handling. Faceswap projects handle the management of all task tabs in the GUI and updates the main Faceswap title bar with the project name and modified state. Parameters ---------- - config: :class:`lib.gui.utils.Config` + config The master GUI config - file_handler: :class:`lib.gui.utils.FileHandler` + file_handler A file handler object """ - def __init__(self, config, file_handler): + def __init__(self, config: Config, file_handler: type[FileHandler]) -> None: super().__init__(config, file_handler) self._update_root_title() @property - def filename(self): - """ str: The currently active project filename. """ + def filename(self) -> str | None: + """The currently active project filename.""" return self._filename @property - def cli_options(self): - """ dict: the raw cli options from :attr:`_options` with project fields removed. """ + def cli_options(self) -> dict[str, dict[str, bool | int | float | str]]: + """The raw cli options from :attr:`_options` with project fields removed.""" return self._cli_options @property - def _project_modified(self): - """bool: ``True`` if the project has been modified otherwise ``False``. """ + def _project_modified(self) -> bool: + """``True`` if the project has been modified otherwise ``False``. """ return any(var.get() for var in self._modified_vars.values()) @property - def _tasks(self): - """ :class:`Tasks`: The current session's :class:``Tasks``. """ + def _tasks(self) -> Tasks: + """The current session's :class:``Tasks``.""" return self._config.tasks - def set_default_options(self): - """ Set the default options. The Default GUI options are stored on Faceswap startup. + def set_default_options(self) -> None: + """Set the default options. The Default GUI options are stored on Faceswap startup. Exposed as the :attr:`_default_options` for a project cannot be set until after the main Command Tabs have been loaded. @@ -704,21 +776,19 @@ def set_default_options(self): self._options = self._default_options # MODIFIED STATE CALLBACK - def set_modified_callback(self): - """ Adds a callback to each of the :attr:`_modified_vars` tkinter variables + def set_modified_callback(self) -> None: + """Adds a callback to each of the :attr:`_modified_vars` tkinter variables When one of these variables is changed, triggers :func:`_modified_callback` with the command that was changed. This is exposed as the callback can only be added after the main Command Tabs have - been drawn, and their options' initial values have been set. - - """ - for key, tkvar in self._modified_vars.items(): + been drawn, and their options' initial values have been set.""" + for key, tk_var in self._modified_vars.items(): logger.debug("Adding callback for tab: %s", key) - tkvar.trace("w", self._modified_callback) + tk_var.trace("w", self._modified_callback) - def _modified_callback(self, *args): # pylint:disable=unused-argument - """ Update the project modified state on a GUI modification change and + def _modified_callback(self, *args) -> None: # pylint:disable=unused-argument + """Update the project modified state on a GUI modification change and update the Faceswap title bar. """ if self._project_modified and self._current_gui_state() == self._cli_options: logger.debug("Project is same as stored. Setting modified to False") @@ -730,24 +800,26 @@ def _modified_callback(self, *args): # pylint:disable=unused-argument self._modified = self._project_modified self._update_root_title() - def load(self, *args, # pylint:disable=unused-argument - filename=None, last_session=False): - """ Load a project from a saved ``.fsw`` project file. + def load(self, # pylint:disable=unused-argument + *args, + filename: str | None = None, + last_session: bool = False) -> None: + """Load a project from a saved ``.fsw`` project file. Parameters ---------- - *args: tuple + *args Unused, but needs to be present for arguments passed by tkinter event handling - filename: str, optional + filename If a filename is passed in, This will be used, otherwise a file handler will be launched to select the relevant file. - last_session: bool, optional + last_session ``True`` if the project is being loaded from the last opened session ``False`` if the project is being loaded directly from disk. Default: ``False`` """ logger.debug("Loading project config: (filename: '%s', last_session: %s)", filename, last_session) - filename_set = self._set_filename(filename, sess_type="project") + filename_set = self._set_filename(filename, session_type="project") if not filename_set: logger.debug("No filename set") @@ -773,11 +845,10 @@ def load(self, *args, # pylint:disable=unused-argument self._update_root_title() logger.debug("Loaded project config: (command: '%s', filename: '%s')", command, filename) - def _handoff_legacy_task(self): - """ Update legacy tasks saved with the old file extension ``.fsw`` to tasks ``.fst``. + def _handoff_legacy_task(self) -> None: + """Update legacy tasks saved with the old file extension ``.fsw`` to tasks ``.fst``. - Hands off file handling to :class:`Tasks` and resets project to default. - """ + Hands off file handling to :class:`Tasks` and resets project to default.""" logger.debug("Updating legacy task '%s", self._filename) filename = self._filename self._filename = None @@ -786,19 +857,20 @@ def _handoff_legacy_task(self): self._tasks.load(filename=filename, current_tab=False) logger.debug("Updated legacy task and reset project") - def _update_tasks(self): - """ Add the tasks from the loaded project to the :class:`Tasks` class. """ + def _update_tasks(self) -> None: + """Add the tasks from the loaded project to the :class:`Tasks` class.""" + assert self._filename is not None for key, val in self._cli_options.items(): - opts = {key: val} + opts: dict[str, str | dict[str, bool | int | float | str]] = {key: val} opts["tab_name"] = key self._tasks.add_project_task(self._filename, key, opts) - def reload(self, *args): # pylint:disable=unused-argument - """ Reset all GUI's option tabs to their last saved state. + def reload(self, *args) -> None: # pylint:disable=unused-argument + """Reset all GUI's option tabs to their last saved state. Parameters ---------- - *args: tuple + *args Unused, but needs to be present for arguments passed by tkinter event handling """ if self._options is None: @@ -810,15 +882,15 @@ def reload(self, *args): # pylint:disable=unused-argument self._reset_modified_var() self._update_root_title() - def _update_root_title(self): - """ Update the root Window title with the project name. Add a asterisk - if the file is modified. """ + def _update_root_title(self) -> None: + """Update the root Window title with the project name. Add a asterisk if the file is + modified.""" text = "" if self._basename is None else self._basename text += "*" if self._modified else "" self._config.set_root_title(text=text) - def save(self, *args, save_as=False): # pylint:disable=unused-argument - """ Save the current GUI state to a ``.fsw`` project file. + def save(self, *args, save_as: bool = False) -> None: # pylint:disable=unused-argument + """Save the current GUI state to a ``.fsw`` project file. Parameters ---------- @@ -842,42 +914,42 @@ def save(self, *args, save_as=False): # pylint:disable=unused-argument else: logger.debug("Saved project to: '%s'", self._filename) - def new(self, *args): # pylint:disable=unused-argument - """ Create a new project with default options. + def new(self, *args) -> None: # pylint:disable=unused-argument + """Create a new project with default options. Pops a file handler to select location. Parameters ---------- - *args: tuple + *args Unused, but needs to be present for arguments passed by tkinter event handling """ logger.debug("Creating new project") if not self.confirm_close(): logger.debug("Creating new project cancelled") return - - cfgfile = self._file_handler("save", - "config_project", - title="New Project...", - initial_folder=self._basename).return_file - if not cfgfile: + assert self._file_handler is not None + cfg_file = self._file_handler("save", + "config_project", + title="New Project...", + initial_folder=self._basename).return_file + if not cfg_file: logger.debug("No filename selected") return - self._filename = cfgfile.name - cfgfile.close() + self._filename = cfg_file.name + cfg_file.close() self.set_default_options() self._config.cli_opts.reset() self._save() self._update_root_title() - def close(self, *args): # pylint:disable=unused-argument - """ Clear the current project and set all options to default. + def close(self, *args) -> None: # pylint:disable=unused-argument + """Clear the current project and set all options to default. Parameters ---------- - *args: tuple + *args Unused, but needs to be present for arguments passed by tkinter event handling """ logger.debug("Close requested") @@ -891,18 +963,18 @@ def close(self, *args): # pylint:disable=unused-argument self._update_root_title() self._config.set_active_tab_by_name(cfg.tab()) - def confirm_close(self): - """ Pop a message box to get confirmation that an unsaved project should be closed + def confirm_close(self) -> bool: + """Pop a message box to get confirmation that an unsaved project should be closed Returns ------- - bool: ``True`` if user confirms close, ``False`` if user cancels close + ``True`` if user confirms close, ``False`` if user cancels close """ if not self._modified: logger.debug("Project is not modified") return True - confirmtxt = "You have unsaved changes.\n\nAre you sure you want to close the project?" - if messagebox.askokcancel("Close", confirmtxt, default="cancel", icon="warning"): + confirm_txt = "You have unsaved changes.\n\nAre you sure you want to close the project?" + if messagebox.askokcancel("Close", confirm_txt, default="cancel", icon="warning"): logger.debug("Close Cancelled") return True logger.debug("Close confirmed") @@ -910,7 +982,7 @@ def confirm_close(self): class LastSession(_GuiSession): - """ Faceswap Last Session handling. + """Faceswap Last Session handling. Faceswap :class:`LastSession` handles saving the state of the Faceswap GUI at close and reloading the state at launch. @@ -919,13 +991,13 @@ class LastSession(_GuiSession): Parameters ---------- - config: :class:`lib.gui.utils.Config` + config The master GUI config """ - def __init__(self, config): + def __init__(self, config: Config) -> None: super().__init__(config) - self._filename = os.path.join(self._config.pathcache, ".last_session.json") + self._filename = os.path.join(self._config.path_cache, ".last_session.json") if not self._enabled: return @@ -935,12 +1007,12 @@ def __init__(self, config): self.load() @property - def _enabled(self): - """ bool: ``True`` if autosave is enabled otherwise ``False``. """ + def _enabled(self) -> bool: + """``True`` if autosave is enabled otherwise ``False``.""" return cfg.autosave_last_session() != "never" - def from_dict(self, options): - """ Set the :attr:`_options` property based on the given options dictionary + def from_dict(self, options: dict[str, str | dict[str, bool | int | float | str]]) -> None: + """Set the :attr:`_options` property based on the given options dictionary and update the GUI to use these values. This function is required for reloading the GUI state when the GUI has been force @@ -948,72 +1020,79 @@ def from_dict(self, options): Parameters ---------- - options: dict + options The options to set. Should be the output of :func:`to_dict` """ logger.debug("Setting options from dict: %s", options) self._options = options self._set_options() - def to_dict(self): - """ Collect the current GUI options and place them in a dict for retrieval or storage. + def to_dict(self) -> dict[str, str | dict[str, bool | int | float | str]] | None: + """Collect the current GUI options and place them in a dict for retrieval or storage. This function is required for reloading the GUI state when the GUI has been force refreshed on a config change. Returns ------- - dict: The current cli options ready for saving or retrieval by :func:`from_dict` + The current cli options ready for saving or retrieval by :func:`from_dict` """ - opts = self._current_gui_state() + opts = T.cast(dict[str, str | dict[str, bool | int | float | str]], + self._current_gui_state()) logger.debug("Collected opts: %s", opts) if not opts or opts == self._default_options: logger.debug("Default session, or no opts found. Not saving last session.") return None opts["tab_name"] = self._active_tab - opts["project"] = self._config.project.filename + fname = self._config.project.filename + assert fname is not None + opts["project"] = fname logger.debug("Added project items: %s", {k: v for k, v in opts.items() if k in ("tab_name", "project")}) return opts - def ask_load(self): - """ Pop a message box to ask the user if they wish to load their last session. """ + def ask_load(self) -> None: + """Pop a message box to ask the user if they wish to load their last session.""" if not self._file_exists: logger.debug("No last session file found") - elif tk.messagebox.askyesno("Last Session", "Load last session?"): + elif messagebox.askyesno("Last Session", "Load last session?"): logger.debug("Loading last session at user request") self.load() else: logger.debug("Not loading last session at user request") logger.debug("Deleting LastSession file") + assert self._filename is not None os.remove(self._filename) - def load(self): - """ Load the last session. + def load(self) -> None: + """Load the last session. Loads the last saved session options. Checks if a previous project was loaded and whether there have been changes since the last saved version of the project. - Sets the display and :class:`Project` and :class:`Task` objects accordingly. - """ + Sets the display and :class:`Project` and :class:`Task` objects accordingly.""" loaded = self._load() if not loaded: return self._set_project() self._set_options() - def _set_project(self): - """ Set the :class:`Project` if session is resuming from one. """ + def _set_project(self) -> None: + """Set the :class:`Project` if session is resuming from one. """ + assert self._options is not None if self._options.get("project", None) is None: logger.debug("No project stored") else: logger.debug("Loading stored project") - self._config.project.load(filename=self._options["project"], last_session=True) + fname = self._options["project"] + assert isinstance(fname, str) + self._config.project.load(filename=fname, last_session=True) - def save(self): - """ Save a snapshot of currently set GUI config options. + def save(self) -> None: + """Save a snapshot of currently set GUI config options. Called on Faceswap shutdown. """ + assert self._filename is not None if not self._enabled: logger.debug("LastSession not enabled") if os.path.exists(self._filename): @@ -1026,7 +1105,8 @@ def save(self): logger.debug("Last session default or blank. Clearing saved last session.") os.remove(self._filename) if opts is not None: - self._serializer.save(self._filename, opts) + assert self._serializer is not None + self._serializer.save(self._filename, opts) # pyright:ignore[reportCallIssue] logger.debug("Saved last session. (filename: '%s', opts: %s", self._filename, opts) diff --git a/lib/gui/utils/config.py b/lib/gui/utils/config.py index 8fddd1d4c3..44c56ec36f 100644 --- a/lib/gui/utils/config.py +++ b/lib/gui/utils/config.py @@ -207,7 +207,7 @@ def scaling_factor(self) -> float: return self._constants["scaling_factor"] @property - def pathcache(self) -> str: + def path_cache(self) -> str: """ str: The path to the GUI cache folder """ return PATH_CACHE diff --git a/lib/training/data_loader.py b/lib/training/data_loader.py index 4bfb7057c9..a7e9025324 100644 --- a/lib/training/data_loader.py +++ b/lib/training/data_loader.py @@ -47,7 +47,8 @@ def __init__(self, color_order.lower()) self._sampler = tch_data.RandomSampler if sampler is None else sampler self._loader = self.get_loader() - self._iterator = iter(self._loader) + self._iterator = T.cast(T.Iterator[tuple[torch.Tensor, torch.Tensor | list[torch.Tensor]]], + iter(self._loader)) def __iter__(self) -> T.Self: """This is an iterator""" diff --git a/lib/training/data_set.py b/lib/training/data_set.py index 7294705c43..c87339afd6 100644 --- a/lib/training/data_set.py +++ b/lib/training/data_set.py @@ -455,7 +455,8 @@ def __call__(self, self._name, filename, mask_type, masks, aligned) self._check_mask_exists(list(masks), mask_type, filename) if mask_type in self._lm_masks: - retval = self._get_landmarks_mask(self._lm_masks[mask_type], aligned) + retval = self._get_landmarks_mask(self._lm_masks[ + T.cast(T.Literal["components", "extended", "eye", "mouth"], mask_type)], aligned) else: retval = self._get_face_mask(masks[mask_type], aligned.pose) logger.trace("[%s] Got mask '%s': %s", # type:ignore[attr-defined] From f7aa56a0372b458f9ddac1aaad93ea8cdedfa8de Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:12:42 +0100 Subject: [PATCH 968/981] bugfix: Native torch loss functions running in Keras --- lib/model/losses/loss.py | 9 ++++++++- plugins/train/model/_base/settings.py | 6 ++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/model/losses/loss.py b/lib/model/losses/loss.py index f31766d26b..fd209bc88c 100644 --- a/lib/model/losses/loss.py +++ b/lib/model/losses/loss.py @@ -632,7 +632,14 @@ def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: "(func: %s, weight: %s, mask_channel: %s)", func, weight, mask_channel) n_true, n_pred = self._apply_mask(y_true, y_pred, mask_channel) - loss += (func(n_true, n_pred) * weight) + this_loss = func(n_true, n_pred) * weight + if ops.ndim(this_loss) > 1: + # TODO this can go when we remove Keras loss wrapper. For now all sub-functions + # return shape (BS, ) of mean loss per item. Torch built in losses let us either + # reduce to scalar or return the full output, so we have to reduce to item here. + # When everything is all torch this hacky workaround should be removable + this_loss = this_loss.flatten(start_dim=1).mean(dim=1) + loss += this_loss return T.cast("KerasTensor", loss) @classmethod diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 80a97f9ca1..7cb1f5e10f 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -90,8 +90,10 @@ def __init__(self, color_order: T.Literal["bgr", "rgb"]) -> None: "crop": True, "color_order": color_order}), "ms_ssim": LossClass(function=losses.MSSIMLoss), - "mae": LossClass(function=nn.MSELoss), - "mse": LossClass(function=nn.L1Loss), + "mae": LossClass(function=nn.MSELoss, + kwargs={"reduction": "none"}), + "mse": LossClass(function=nn.L1Loss, + kwargs={"reduction": "none"}), "pixel_gradient_diff": LossClass(function=losses.GradientLoss), "ssim": LossClass(function=losses.DSSIMObjective), "smooth_loss": LossClass(function=losses.GeneralizedLoss)} From 4b2a3cdc896b77c4780d779b93e5dda6f1f55db5 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 1 May 2026 10:34:52 +0100 Subject: [PATCH 969/981] bugfixes: - Installer: Force conda-forge for git on linux - GUI project: Fix issue with autosaving current settings --- .install/linux/faceswap_setup_x64.sh | 2 +- lib/gui/project.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.install/linux/faceswap_setup_x64.sh b/.install/linux/faceswap_setup_x64.sh index f9833ff8c8..20bd140c53 100644 --- a/.install/linux/faceswap_setup_x64.sh +++ b/.install/linux/faceswap_setup_x64.sh @@ -431,7 +431,7 @@ install_git() { info "Installing Git..." # TODO On linux version 2.45.2 makes the font fixed TK pull in Python from # graalpy, which breaks pretty much everything - yellow ; conda install "git<2.45" -q -y + yellow ; conda install -c conda-forge "git<2.45" -q -y } delete_faceswap() { diff --git a/lib/gui/project.py b/lib/gui/project.py index 1e079991fc..778f379b62 100644 --- a/lib/gui/project.py +++ b/lib/gui/project.py @@ -1027,7 +1027,7 @@ def from_dict(self, options: dict[str, str | dict[str, bool | int | float | str] self._options = options self._set_options() - def to_dict(self) -> dict[str, str | dict[str, bool | int | float | str]] | None: + def to_dict(self) -> dict[str, str | dict[str, bool | int | float | str] | None] | None: """Collect the current GUI options and place them in a dict for retrieval or storage. This function is required for reloading the GUI state when the GUI has been force @@ -1037,7 +1037,7 @@ def to_dict(self) -> dict[str, str | dict[str, bool | int | float | str]] | None ------- The current cli options ready for saving or retrieval by :func:`from_dict` """ - opts = T.cast(dict[str, str | dict[str, bool | int | float | str]], + opts = T.cast(dict[str, str | dict[str, bool | int | float | str] | None], self._current_gui_state()) logger.debug("Collected opts: %s", opts) if not opts or opts == self._default_options: @@ -1045,7 +1045,6 @@ def to_dict(self) -> dict[str, str | dict[str, bool | int | float | str]] | None return None opts["tab_name"] = self._active_tab fname = self._config.project.filename - assert fname is not None opts["project"] = fname logger.debug("Added project items: %s", {k: v for k, v in opts.items() if k in ("tab_name", "project")}) From d8c52d04e3034b4a33483f5dcbdd2310bda427d7 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 8 May 2026 10:17:48 +0100 Subject: [PATCH 970/981] bugfix: setup.py - ensurepip --- setup.py | 48 +++++++++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/setup.py b/setup.py index 32891e929d..e1d332abba 100755 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ from importlib import import_module from shutil import which from string import printable -from subprocess import PIPE, Popen +from subprocess import PIPE, Popen, check_call from lib.logger import log_setup from lib.system import Cuda, Packages, ROCm, System @@ -177,11 +177,17 @@ def _output_runtime_info(self) -> None: def _check_pip(self) -> None: """ Check installed pip version """ - try: - _pip = T.cast("pip", import_module("pip")) # type:ignore[valid-type] - except ModuleNotFoundError: - logger.error("Import pip failed. Please Install python3-pip and try again") - sys.exit(1) + for i in range(2): + try: + _pip = T.cast("pip", import_module("pip")) # type:ignore[valid-type] + break + except ModuleNotFoundError: + if i == 0: + logger.info("Installing pip...") + check_call([sys.executable, "-m", "ensurepip", "--default-pip"]) + continue + logger.error("Import pip failed. Please Install python3-pip and try again") + sys.exit(1) logger.info("Pip version: %s", _pip.__version__) # type:ignore[attr-defined] def _configure_keras(self) -> None: @@ -211,8 +217,8 @@ def _configure_keras(self) -> None: def set_config(self) -> None: """ Set the backend in the faceswap config file """ config = {"backend": self.backend} - pypath = os.path.dirname(os.path.realpath(__file__)) - config_file = os.path.join(pypath, "config", ".faceswap") + py_path = os.path.dirname(os.path.realpath(__file__)) + config_file = os.path.join(py_path, "config", ".faceswap") with open(config_file, "w", encoding="utf8") as cnf: json.dump(config, cnf) logger.info("Faceswap config written to: %s", config_file) @@ -347,7 +353,7 @@ def _get_missing_conda(self) -> dict[str, list[dict[T.Literal["name", "package"] # Ref: https://github.com/ContinuumIO/anaconda-issues/issues/6833 # This versioning will fail in parse_requirements, so we need to do it here package["package"] = f"{req.name}=*=xft_*" # Swap out for explicit XFT version - if exists is not None and not exists[1].startswith("xft"): # Replace noxft version + if exists is not None and not exists[1].startswith("xft"): # Replace no-xft vers exists = None if not exists: logger.debug("Adding new Conda package '%s'", package["package"]) @@ -856,16 +862,16 @@ def _from_pip(self, extra_args : list[str] | None, optional Any extra arguments to provide to pip. Default: ``None`` (no extra arguments) """ - pipexe = [sys.executable, - "-u", "-m", "pip", "install", "--no-cache-dir", "--progress-bar=raw"] + pip_exe = [sys.executable, + "-u", "-m", "pip", "install", "--no-cache-dir", "--progress-bar=raw"] if not self._env.system.is_admin and not self._env.system.is_virtual_env: - pipexe.append("--user") # install as user to solve perm restriction + pip_exe.append("--user") # install as user to solve perm restriction if extra_args is not None: - pipexe.extend(extra_args) - pipexe.extend([p["package"] for p in packages]) + pip_exe.extend(extra_args) + pip_exe.extend([p["package"] for p in packages]) names = [p["name"] for p in packages] - installer = Installer(self._env, names, pipexe, False, self._is_gui) + installer = Installer(self._env, names, pip_exe, False, self._is_gui) if installer() != 0: msg = f"Unable to install Python packages: {', '.join(names)}" logger.warning("%s. Please install these packages manually", msg) @@ -888,16 +894,16 @@ def _from_conda(self, Returns ------- bool - ``True`` if the package was succesfully installed otherwise ``False`` + ``True`` if the package was successfully installed otherwise ``False`` """ conda = which("conda") assert conda is not None - condaexe = [conda, "install", "-y", "-c", channel, - "--override-channels", "--strict-channel-priority"] - condaexe += [p["package"] for p in packages] + conda_exe = [conda, "install", "-y", "-c", channel, + "--override-channels", "--strict-channel-priority"] + conda_exe += [p["package"] for p in packages] names = [p["name"] for p in packages] - retcode = Installer(self._env, names, condaexe, True, self._is_gui)() - if retcode != 0: + ret_code = Installer(self._env, names, conda_exe, True, self._is_gui)() + if ret_code != 0: logger.warning("Unable to install Conda packages: %s. " "Please install these packages manually", ', '.join(names)) _InstallState.failed = True From a0ff7210040240836b3749d8ad5f3d39c59178b0 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sat, 9 May 2026 19:07:30 +0100 Subject: [PATCH 971/981] Completely remove Keras from loss calculations (#1545) * training: Completely remove Keras from loss calculations --- docs/full/lib/training.rst | 30 +- lib/gui/analysis/event_reader.py | 444 ++++----- lib/gui/display_graph.py | 363 ++++--- lib/model/losses/__init__.py | 53 +- lib/model/losses/feature_loss.py | 12 +- lib/model/losses/flip.py | 409 ++++++++ lib/model/losses/loss.py | 256 ++--- lib/model/losses/perceptual_loss.py | 924 +++++------------- lib/training/__init__.py | 1 - lib/training/data/__init__.py | 5 + .../augmentation.py} | 0 lib/training/data/collate.py | 467 +++++++++ lib/training/{ => data}/data_set.py | 374 +------ .../{data_loader.py => data/loader.py} | 155 +-- lib/training/loss.py | 347 +++++++ lib/training/lr_finder.py | 6 +- lib/training/preview.py | 2 +- lib/training/preview_tk.py | 275 +++--- lib/training/tensorboard.py | 89 +- lib/training/train.py | 98 +- plugins/train/model/_base/model.py | 10 +- plugins/train/model/_base/settings.py | 244 +---- plugins/train/model/_base/state.py | 31 +- plugins/train/trainer/base.py | 39 +- plugins/train/trainer/distributed.py | 124 +-- plugins/train/trainer/original.py | 62 +- scripts/train.py | 23 +- tests/lib/gui/stats/event_reader_test.py | 62 -- tests/lib/model/losses/feature_loss_test.py | 6 +- tests/lib/model/losses/loss_test.py | 42 +- .../lib/model/losses/perceptual_loss_test.py | 11 +- tests/lib/training/data_augmentation_test.py | 6 +- tests/lib/training/lr_finder_test.py | 14 +- .../plugins/train/trainer/test_distributed.py | 56 +- tests/plugins/train/trainer/test_original.py | 26 +- 35 files changed, 2545 insertions(+), 2521 deletions(-) create mode 100644 lib/model/losses/flip.py create mode 100644 lib/training/data/__init__.py rename lib/training/{data_augmentation.py => data/augmentation.py} (100%) create mode 100644 lib/training/data/collate.py rename lib/training/{ => data}/data_set.py (59%) rename lib/training/{data_loader.py => data/loader.py} (70%) create mode 100644 lib/training/loss.py diff --git a/docs/full/lib/training.rst b/docs/full/lib/training.rst index 94da96cebe..cbe7382d34 100644 --- a/docs/full/lib/training.rst +++ b/docs/full/lib/training.rst @@ -8,16 +8,7 @@ The training Package handles libraries to assist with training a model :local: :depth: 2 -.. automodapi:: lib.training.data_augmentation - :include-all-objects: - :no-inheritance-diagram: - -| -.. automodapi:: lib.training.data_loader - :include-all-objects: - -| -.. automodapi:: lib.training.data_set +.. automodapi:: lib.training.loss :include-all-objects: | @@ -49,3 +40,22 @@ The training Package handles libraries to assist with training a model | .. automodapi:: lib.training.train :include-all-objects: + +| +data package +============ + +.. automodapi:: lib.training.data.augmentation + :include-all-objects: + +| +.. automodapi:: lib.training.data.collate + :include-all-objects: + +| +.. automodapi:: lib.training.data.data_set + :include-all-objects: + +| +.. automodapi:: lib.training.data.loader + :include-all-objects: diff --git a/lib/gui/analysis/event_reader.py b/lib/gui/analysis/event_reader.py index 1b348c69bf..c2f6b9df00 100644 --- a/lib/gui/analysis/event_reader.py +++ b/lib/gui/analysis/event_reader.py @@ -1,9 +1,8 @@ #!/usr/bin/env python3 -""" Handles the loading and collation of events from Tensorboard event log files. """ +"""Handles the loading and collation of events from Tensorboard event log files.""" from __future__ import annotations import logging import os -import re import typing as T import zlib @@ -13,7 +12,6 @@ from tensorboard.compat.proto import event_pb2 # type:ignore[import-untyped] from lib.logger import parse_class_init -from lib.serializer import get_serializer from lib.training.tensorboard import RecordIterator from lib.utils import get_module_objects @@ -25,13 +23,13 @@ @dataclass class EventData: - """ Holds data collected from Tensorboard Event Files + """Holds data collected from Tensorboard Event Files Parameters ---------- - timestamp: float + timestamp The timestamp of the event step (iteration) - loss: list[float] + loss The loss values collected for A and B sides for the event step """ timestamp: float = 0.0 @@ -39,33 +37,31 @@ class EventData: class _LogFiles(): - """ Holds the filenames of the Tensorboard Event logs that require parsing. + """Holds the filenames of the Tensorboard Event logs that require parsing. Parameters ---------- - logs_folder: str + logs_folder The folder that contains the Tensorboard log files """ def __init__(self, logs_folder: str) -> None: logger.debug(parse_class_init(locals())) self._logs_folder = logs_folder self._filenames = self._get_log_filenames() - logger.debug("Initialized %s", self.__class__.__name__) @property def session_ids(self) -> list[int]: - """ list[int]: Sorted list of `ints` of available session ids. """ + """Sorted list of `ints` of available session ids.""" return list(sorted(self._filenames)) def _get_log_filenames(self) -> dict[int, str]: - """ Get the Tensorboard event filenames for all existing sessions. + """Get the Tensorboard event filenames for all existing sessions. Returns ------- - dict[int, str] - The full path of each log file for each training session id that has been run + The full path of each log file for each training session id that has been run """ - logger.debug("Loading log filenames. base_dir: '%s'", self._logs_folder) + logger.debug("[LogFiles] Loading log filenames. base_dir: '%s'", self._logs_folder) retval: dict[int, str] = {} for dirpath, _, filenames in os.walk(self._logs_folder): if not any(filename.startswith("events.out.tfevents") for filename in filenames): @@ -75,100 +71,95 @@ def _get_log_filenames(self) -> dict[int, str]: logger.warning("Unable to load session data for model") return retval retval[session_id] = self._get_log_filename(dirpath, filenames) - logger.debug("logfiles: %s", retval) + logger.debug("[LogFiles] log_files: %s", retval) return retval @classmethod def _get_session_id(cls, folder: str) -> int | None: - """ Obtain the session id for the given folder. + """Obtain the session id for the given folder. Parameters ---------- - folder: str + folder The full path to the folder that contains the session's Tensorboard Event Log Returns ------- - int or ``None`` - The session ID for the given folder. If no session id can be determined, return - ``None`` + The session ID for the given folder. If no session id can be determined, return ``None`` """ session = os.path.split(os.path.split(folder)[0])[1] session_id = session[session.rfind("_") + 1:] retval = None if not session_id.isdigit() else int(session_id) - logger.debug("folder: '%s', session_id: %s", folder, retval) + logger.debug("[LogFiles] folder: '%s', session_id: %s", folder, retval) return retval @classmethod def _get_log_filename(cls, folder: str, filenames: list[str]) -> str: - """ Obtain the session log file for the given folder. If multiple log files exist for the + """Obtain the session log file for the given folder. If multiple log files exist for the given folder, then the most recent log file is used, as earlier files are assumed to be obsolete. Parameters ---------- - folder: str + folder The full path to the folder that contains the session's Tensorboard Event Log - filenames: list[str] + filenames List of filenames that exist within the given folder Returns ------- - str - The full path of the selected log file + The full path of the selected log file """ - logfiles = [fname for fname in filenames if fname.startswith("events.out.tfevents")] - retval = os.path.join(folder, sorted(logfiles)[-1]) # Take last item if multi matches - logger.debug("logfiles: %s, selected: '%s'", logfiles, retval) + log_files = [fname for fname in filenames if fname.startswith("events.out.tfevents")] + retval = os.path.join(folder, sorted(log_files)[-1]) # Take last item if multi matches + logger.debug("[LogFiles] log_files: %s, selected: '%s'", log_files, retval) return retval def refresh(self) -> bool: - """ Refresh the list of log filenames. + """Refresh the list of log filenames. Returns ------- - bool - ``True`` if the pre-existing log files are a subset of the new log files, otherwise - ``False`` + ``True`` if the pre-existing log files are a subset of the new log files, otherwise + ``False`` """ - logger.debug("Refreshing log filenames") + logger.debug("[LogFiles] Refreshing log filenames") old_filenames = self._filenames new_filenames = self._get_log_filenames() retval = set(old_filenames.values()).issubset(set(new_filenames.values())) self._filenames = new_filenames - logger.debug("old filenames are %sa subset of new filenames %s", + logger.debug("[LogFiles] old filenames are %sa subset of new filenames %s", "" if retval else "not ", self._filenames) return retval def get(self, session_id: int) -> str: - """ Obtain the log filename for the given session id. + """Obtain the log filename for the given session id. Parameters ---------- - session_id: int + session_id The session id to obtain the log filename for Returns ------- - str - The full path to the log file for the requested session id + The full path to the log file for the requested session id """ retval = self._filenames.get(session_id, "") - logger.debug("session_id: %s, log_filename: '%s'", session_id, retval) + logger.debug("[LogFiles] session_id: %s, log_filename: '%s'", session_id, retval) return retval class _CacheData(): - """ Holds cached data that has been retrieved from Tensorboard Event Files and is compressed + """Holds cached data that has been retrieved from Tensorboard Event Files and is compressed in memory for a single or live training session Parameters ---------- - labels: list[str] + labels The labels for the loss values - timestamps: :class:`np.ndarray` + timestamps The timestamp of the event step (iteration) - loss: :class:`np.ndarray` + loss The loss values collected for A and B sides for the session """ def __init__(self, labels: list[str], timestamps: np.ndarray, loss: np.ndarray) -> None: @@ -180,7 +171,7 @@ def __init__(self, labels: list[str], timestamps: np.ndarray, loss: np.ndarray) @property def loss(self) -> np.ndarray: - """ :class:`numpy.ndarray`: The loss values for this session """ + """The loss values for this session""" retval: np.ndarray = np.frombuffer(zlib.decompress(self._loss), dtype="float32") if len(self._loss_shape) > 1: retval = retval.reshape(-1, *self._loss_shape[1:]) @@ -188,18 +179,18 @@ def loss(self) -> np.ndarray: @property def timestamps(self) -> np.ndarray: - """ :class:`numpy.ndarray`: The timestamps for this session """ + """The timestamps for this session""" retval: np.ndarray = np.frombuffer(zlib.decompress(self._timestamps), dtype="float64") if len(self._timestamps_shape) > 1: retval = retval.reshape(-1, *self._timestamps_shape[1:]) return retval def add_live_data(self, timestamps: np.ndarray, loss: np.ndarray) -> None: - """ Add live data to the end of the stored data + """Add live data to the end of the stored data - loss: :class:`numpy.ndarray` + loss The latest loss values to add to the cache - timestamps: :class:`numpy.ndarray` + timestamps The latest timestamps to add to the cache """ new_buffer: list[bytes] = [] @@ -215,7 +206,7 @@ def add_live_data(self, timestamps: np.ndarray, loss: np.ndarray) -> None: new = np.concatenate((old, data)) - logger.debug("old_shape: %s new_shape: %s", shape, new.shape) + logger.debug("[CacheData] old_shape: %s new_shape: %s", shape, new.shape) new_buffer.append(zlib.compress(new)) new_shapes.append(new.shape) del old @@ -227,26 +218,24 @@ def add_live_data(self, timestamps: np.ndarray, loss: np.ndarray) -> None: class _Cache(): - """ Holds parsed Tensorboard log event data in a compressed cache in memory. """ + """Holds parsed Tensorboard log event data in a compressed cache in memory.""" def __init__(self) -> None: logger.debug(parse_class_init(locals())) self._data: dict[int, _CacheData] = {} self._carry_over: dict[int, EventData] = {} self._loss_labels: list[str] = [] - logger.debug("Initialized %s", self.__class__.__name__) def is_cached(self, session_id: int) -> bool: - """ Check if the given session_id's data is already cached + """Check if the given session_id's data is already cached Parameters ---------- - session_id: int + session_id The session ID to check Returns ------- - bool - ``True`` if the data already exists in the cache otherwise ``False``. + ``True`` if the data already exists in the cache otherwise ``False``. """ return self._data.get(session_id) is not None @@ -255,29 +244,29 @@ def cache_data(self, data: dict[int, EventData], labels: list[str], is_live: bool = False) -> None: - """ Add a full session's worth of event data to :attr:`_data`. + """Add a full session's worth of event data to :attr:`_data`. Parameters ---------- - session_id: int + session_id The session id to add the data for - data[int, :class:`EventData`] + data The extracted event data dictionary generated from :class:`_EventParser` - labels: list[str] + labels List of `str` for the labels of each loss value output - is_live: bool, optional + is_live ``True`` if the data to be cached is from a live training session otherwise ``False``. Default: ``False`` """ - logger.debug("Caching event data: (session_id: %s, labels: %s, data points: %s, " + logger.debug("[Cache] Caching event data: (session_id: %s, labels: %s, data points: %s, " "is_live: %s)", session_id, labels, len(data), is_live) if labels: - logger.debug("Setting loss labels: %s", labels) + logger.debug("[Cache] Setting loss labels: %s", labels) self._loss_labels = labels if not data: - logger.debug("No data to cache") + logger.debug("[Cache] No data to cache") return timestamps, loss = self._to_numpy(data, is_live) @@ -290,7 +279,7 @@ def cache_data(self, def _to_numpy(self, data: dict[int, EventData], is_live: bool) -> tuple[np.ndarray, np.ndarray]: - """ Extract each individual step data into separate numpy arrays for loss and timestamps. + """Extract each individual step data into separate numpy arrays for loss and timestamps. Timestamps are stored float64 as the extra accuracy is needed for correct timings. Arrays are returned at the length of the shortest available data (i.e. truncated records are @@ -298,21 +287,21 @@ def _to_numpy(self, Parameters ---------- - data: dict + data The incoming Tensorboard event data in dictionary form per step - is_live: bool, optional + is_live ``True`` if the data to be cached is from a live training session otherwise ``False``. Default: ``False`` Returns ------- - timestamps: :class:`numpy.ndarray` + timestamps float64 array of all iteration's timestamps - loss: :class:`numpy.ndarray` + loss float32 array of all iteration's loss """ if is_live and self._carry_over: - logger.debug("Processing carry over: %s", self._carry_over) + logger.debug("[Cache] Processing carry over: %s", self._carry_over) self._collect_carry_over(data) times, loss = self._process_data(data, is_live) @@ -330,46 +319,48 @@ def _to_numpy(self, # [1, 2, 2, 2, 2, 2, 2, 2] - 1st loss collection has 1 length # [2, 2, 2, 3, 2, 2, 2] - 4th loss collection has 3 length - logger.debug("Inconsistent loss found in collection: %s", loss) + logger.debug("[Cache] Inconsistent loss found in collection: %s", loss) for idx in reversed(range(len(loss))): if len(loss[idx]) != len(self._loss_labels): - logger.debug("Removing loss/timestamps at position %s", idx) + logger.debug("[Cache] Removing loss/timestamps at position %s", idx) del loss[idx] del times[idx] n_times, n_loss = (np.array(times, dtype="float64"), np.array(loss, dtype="float32")) - logger.debug("Converted to numpy: (data points: %s, timestamps shape: %s, loss shape: %s)", + logger.debug("[Cache] Converted to numpy: (data points: %s, timestamps shape: %s, " + "loss shape: %s)", len(data), n_times.shape, n_loss.shape) return n_times, n_loss def _collect_carry_over(self, data: dict[int, EventData]) -> None: - """ For live data, collect carried over data from the previous update and merge into the + """For live data, collect carried over data from the previous update and merge into the current data dictionary. Parameters ---------- - data: dict[int, :class:`EventData`] + data The latest raw data dictionary """ - logger.debug("Carry over keys: %s, data keys: %s", list(self._carry_over), list(data)) + logger.debug("[Cache] Carry over keys: %s, data keys: %s", + list(self._carry_over), list(data)) for key in list(self._carry_over): if key not in data: - logger.debug("Carry over found for item %s which does not exist in current " - "data: %s. Skipping.", key, list(data)) + logger.debug("[Cache] Carry over found for item %s which does not exist in " + "current data: %s. Skipping.", key, list(data)) continue carry_over = self._carry_over.pop(key) update = data[key] - logger.debug("Merging carry over data: %s in to %s", carry_over, update) + logger.debug("[Cache] Merging carry over data: %s in to %s", carry_over, update) timestamp = update.timestamp update.timestamp = carry_over.timestamp if not timestamp else timestamp update.loss = carry_over.loss + update.loss - logger.debug("Merged carry over data: %s", update) + logger.debug("[Cache] Merged carry over data: %s", update) def _process_data(self, data: dict[int, EventData], is_live: bool) -> tuple[list[float], list[list[float]]]: - """ Process live update data. + """Process live update data. Live data requires different processing as often we will only have partial data for the current step, so we need to cache carried over partial data to be picked up at the next @@ -378,16 +369,16 @@ def _process_data(self, Parameters ---------- - data: dict + data The incoming Tensorboard event data in dictionary form per step - is_live: bool + is_live ``True`` if the data to be cached is from a live training session otherwise ``False``. Returns ------- - timestamps: tuple + timestamps Cleaned list of complete timestamps for the latest live query - loss: list + loss Cleaned list of complete loss for the latest live query """ timestamps, loss = zip(*[(data[idx].timestamp, data[idx].loss) @@ -397,12 +388,12 @@ def _process_data(self, l_timestamps: list[float] = list(timestamps) if len(l_loss[-1]) != len(self._loss_labels): - logger.debug("Truncated loss found. loss count: %s", len(l_loss)) + logger.debug("[Cache] Truncated loss found. loss count: %s", len(l_loss)) idx = sorted(data)[-1] if is_live: - logger.debug("Setting carried over data: %s", data[idx]) + logger.debug("[Cache] Setting carried over data: %s", data[idx]) self._carry_over[idx] = data[idx] - logger.debug("Removing truncated loss: (timestamp: %s, loss: %s)", + logger.debug("[Cache] Removing truncated loss: (timestamp: %s, loss: %s)", l_timestamps[-1], loss[-1]) del l_loss[-1] del l_timestamps[-1] @@ -410,42 +401,42 @@ def _process_data(self, return l_timestamps, l_loss def _add_latest_live(self, session_id: int, loss: np.ndarray, timestamps: np.ndarray) -> None: - """ Append the latest received live training data to the cached data. + """Append the latest received live training data to the cached data. Parameters ---------- - session_id: int + session_id The training session ID to update the cache for - loss: :class:`numpy.ndarray` + loss The latest loss values returned from the iterator - timestamps: :class:`numpy.ndarray` + timestamps The latest time stamps returned from the iterator """ - logger.debug("Adding live data to cache: (session_id: %s, loss: %s, timestamps: %s)", + logger.debug("[Cache] Adding live data to cache: " + "(session_id: %s, loss: %s, timestamps: %s)", session_id, loss.shape, timestamps.shape) if not np.any(loss) and not np.any(timestamps): return self._data[session_id].add_live_data(timestamps, loss) - def get_data(self, session_id: int, metric: T.Literal["loss", "timestamps"] + def get_data(self, session_id: int | None, metric: T.Literal["loss", "timestamps"] ) -> dict[int, dict[str, np.ndarray | list[str]]] | None: - """ Retrieve the decompressed cached data from the cache for the given session id. + """Retrieve the decompressed cached data from the cache for the given session id. Parameters ---------- - session_id: int or ``None`` + session_id If session_id is provided, then the cached data for that session is returned. If session_id is ``None`` then the cached data for all sessions is returned - metric: ['loss', 'timestamps'] + metric The metric to return the data for. Returns ------- - dict or ``None`` - The `session_id`(s) as key, the values are a dictionary containing the requested - metric information for each session returned. ``None`` if no data is stored for the - given session_id + The `session_id`(s) as key, the values are a dictionary containing the requested metric + information for each session returned. ``None`` if no data is stored for the given + session_id """ if session_id is None: raw = self._data @@ -463,15 +454,15 @@ def get_data(self, session_id: int, metric: T.Literal["loss", "timestamps"] val["labels"] = data.labels retval[idx] = val - logger.debug("Obtained cached data: %s", + logger.debug("[Cache] Obtained cached data: %s", {session_id: {k: v.shape if isinstance(v, np.ndarray) else v for k, v in data.items()} for session_id, data in retval.items()}) return retval def reset(self) -> None: - """ Remove all information stored within the cache and reset to default """ - logger.debug("Resetting cache") + """Remove all information stored within the cache and reset to default""" + logger.debug("[Cache] Resetting cache") del self._data del self._carry_over del self._loss_labels @@ -481,7 +472,7 @@ def reset(self) -> None: class TensorBoardLogs(): - """ Parse data from TensorBoard logs. + """Parse data from TensorBoard logs. Process the input logs folder and stores the individual filenames per session. @@ -489,9 +480,9 @@ class TensorBoardLogs(): Parameters ---------- - logs_folder: str + logs_folder The folder that contains the Tensorboard log files - is_training: bool + is_training ``True`` if the events are being read whilst Faceswap is training otherwise ``False`` """ def __init__(self, logs_folder: str, is_training: bool) -> None: @@ -504,52 +495,49 @@ def __init__(self, logs_folder: str, is_training: bool) -> None: self._cache = _Cache() - logger.debug("Initialized %s", self.__class__.__name__) - @property def session_ids(self) -> list[int]: - """ list[int]: Sorted list of integers of available session ids. """ + """Sorted list of integers of available session ids.""" return self._log_files.session_ids def set_training(self, is_training: bool) -> bool: - """ Set the internal training flag to the given `is_training` value. + """Set the internal training flag to the given `is_training` value. If a new training session is being instigated, refresh the log filenames Parameters ---------- - is_training: bool + is_training ``True`` to indicate that the logs to be read are from the currently training session otherwise ``False`` Returns ------- - bool - ``True`` if the session that is starting training belongs to the session already loaded - otherwise ``False`` + ``True`` if the session that is starting training belongs to the session already loaded + otherwise ``False`` """ retval = True if self._is_training == is_training: - logger.debug("Training flag already set to %s. Returning", is_training) + logger.debug("[Cache] Training flag already set to %s. Returning", is_training) return retval - logger.debug("Setting is_training to %s", is_training) + logger.debug("[Cache] Setting is_training to %s", is_training) self._is_training = is_training if is_training: retval = self._log_files.refresh() if not retval: self._cache.reset() log_file = self._log_files.get(self.session_ids[-1]) - logger.debug("Setting training iterator for log file: '%s'", log_file) + logger.debug("[Cache] Setting training iterator for log file: '%s'", log_file) self._training_iterator = RecordIterator(log_file, is_live=True) else: - logger.debug("Removing training iterator") + logger.debug("[Cache] Removing training iterator") del self._training_iterator self._training_iterator = None return retval def _cache_data(self, session_id: int) -> None: - """ Cache TensorBoard logs for the given session ID on first access. + """Cache TensorBoard logs for the given session ID on first access. Populates :attr:`_cache` with timestamps and loss data. @@ -558,7 +546,7 @@ def _cache_data(self, session_id: int) -> None: Parameters ------- - session_id: int + session_id The session ID to cache the data for """ live_data = self._is_training and session_id == max(self.session_ids) @@ -569,17 +557,17 @@ def _cache_data(self, session_id: int) -> None: parser.cache_events(session_id) def _check_cache(self, session_id: int | None = None) -> None: - """ Check if the given session_id has been cached and if not, cache it. + """Check if the given session_id has been cached and if not, cache it. Parameters ---------- - session_id: int, optional + session_id The Session ID to return the data for. Set to ``None`` to return all session data. Default ``None` """ if session_id is not None and not self._cache.is_cached(session_id): self._cache_data(session_id) - elif self._is_training and session_id == self.session_ids[-1]: + elif self._is_training and session_id is not None and session_id == self.session_ids[-1]: self._cache_data(session_id) elif session_id is None: for idx in self.session_ids: @@ -587,21 +575,20 @@ def _check_cache(self, session_id: int | None = None) -> None: self._cache_data(idx) def get_loss(self, session_id: int | None = None) -> dict[int, dict[str, np.ndarray]]: - """ Read the loss from the TensorBoard event logs + """Read the loss from the TensorBoard event logs Parameters ---------- - session_id: int, optional + session_id The Session ID to return the loss for. Set to ``None`` to return all session losses. Default ``None`` Returns ------- - dict - The session id(s) as key, with a further dictionary as value containing the loss name - and list of loss values for each step + The session id(s) as key, with a further dictionary as value containing the loss name and + list of loss values for each step """ - logger.debug("Getting loss: (session_id: %s)", session_id) + logger.debug("[TensorBoardLogs] Getting loss: (session_id: %s)", session_id) retval: dict[int, dict[str, np.ndarray]] = {} for idx in [session_id] if session_id else self.session_ids: self._check_cache(idx) @@ -613,29 +600,28 @@ def get_loss(self, session_id: int | None = None) -> dict[int, dict[str, np.ndar assert isinstance(loss, np.ndarray) retval[idx] = {title: loss[:, idx] for idx, title in enumerate(data["labels"])} - logger.debug({key: {k: v.shape for k, v in val.items()} - for key, val in retval.items()}) + logger.debug("[TensorBoardLogs] %s", {key: {k: v.shape for k, v in val.items()} + for key, val in retval.items()}) return retval def get_timestamps(self, session_id: int | None = None) -> dict[int, np.ndarray]: - """ Read the timestamps from the TensorBoard logs. + """Read the timestamps from the TensorBoard logs. As loss timestamps are slightly different for each loss, we collect the timestamp from the `batch_loss` key. Parameters ---------- - session_id: int, optional + session_id The Session ID to return the timestamps for. Set to ``None`` to return all session timestamps. Default ``None`` Returns ------- - dict - The session id(s) as key with list of timestamps per step as value + The session id(s) as key with list of timestamps per step as value """ - logger.debug("Getting timestamps: (session_id: %s, is_training: %s)", + logger.debug("[TensorBoardLogs] Getting timestamps: (session_id: %s, is_training: %s)", session_id, self._is_training) retval: dict[int, np.ndarray] = {} for idx in [session_id] if session_id else self.session_ids: @@ -646,20 +632,20 @@ def get_timestamps(self, session_id: int | None = None) -> dict[int, np.ndarray] timestamps = data[idx]["timestamps"] assert isinstance(timestamps, np.ndarray) retval[idx] = timestamps - logger.debug({k: v.shape for k, v in retval.items()}) + logger.debug("[TensorBoardLogs] %s", {k: v.shape for k, v in retval.items()}) return retval class _EventParser(): - """ Parses Tensorboard event and populates data to :class:`_Cache`. + """Parses Tensorboard event and populates data to :class:`_Cache`. Parameters ---------- - iterator: :class:`lib.training.tensorboard.RecordIterator` + iterator The iterator to use for reading Tensorboard event logs - cache: :class:`_Cache` + cache The cache object to store the collected parsed events to - live_data: bool + live_data ``True`` if the iterator to be loaded is a training iterator for reading live data otherwise ``False`` """ @@ -668,25 +654,21 @@ def __init__(self, iterator: Iterator[bytes], cache: _Cache, live_data: bool) -> self._live_data = live_data self._cache = cache self._iterator = self._get_latest_live(iterator) if live_data else iterator - self._loss_labels: list[str] = [] - self._num_strip = re.compile(r"_\d+$") - logger.debug("Initialized %s", self.__class__.__name__) @classmethod def _get_latest_live(cls, iterator: Iterator[bytes]) -> Generator[bytes, None, None]: - """ Obtain the latest event logs for live training data. + """Obtain the latest event logs for live training data. The live data iterator remains open so that it can be re-queried Parameters ---------- - iterator: :class:`lib.training.tensorboard.RecordIterator` + iterator The live training iterator to use for reading Tensorboard event logs Yields ------ - dict - A Tensorboard event in dictionary form for a single step + A Tensorboard event in dictionary form for a single step """ i = 0 while True: @@ -694,121 +676,30 @@ def _get_latest_live(cls, iterator: Iterator[bytes]) -> Generator[bytes, None, N yield next(iterator) i += 1 except StopIteration: - logger.debug("End of data reached") + logger.debug("[EventParser] End of data reached") break - logger.debug("Collected %s records from live log file", i) - - def cache_events(self, session_id: int) -> None: - """ Parse the Tensorboard events logs and add to :attr:`_cache`. - - Parameters - ---------- - session_id: int - The session id that the data is being cached for - """ - assert self._iterator is not None - data: dict[int, EventData] = {} - for record in self._iterator: - event = event_pb2.Event.FromString(record) # pylint:disable=no-member - if not event.summary.value: - continue - if event.summary.value[0].tag.split("/", maxsplit=1)[0] == "keras": - self._parse_outputs(event) - if event.summary.value[0].tag.startswith("batch_"): - data[event.step] = self._process_event(event, - data.get(event.step, EventData())) - - self._cache.cache_data(session_id, data, self._loss_labels, is_live=self._live_data) - - def _parse_outputs(self, event: event_pb2.Event) -> None: - """ Parse the outputs from the stored model structure for mapping loss names to - model outputs. - - Loss names are added to :attr:`_loss_labels` - - Notes - ----- - The master model does not actually contain the specified output name, so we dig into the - sub-model to obtain the name of the output layers - - Parameters - ---------- - event: :class:`tensorboard.compat.proto.event_pb2` - The event data containing the keras model structure to be parsed - """ - serializer = get_serializer("json") - structure = event.summary.value[0].tensor.string_val[0] - - config = serializer.unmarshal(structure)["config"] - model_outputs = self._get_outputs(config, False) - - for side_outputs, side in zip(model_outputs, ("a", "b")): - logger.debug("side: '%s', outputs: %s", side, side_outputs) - layer_name = side_outputs[0][0] - - output_config = next(layer for layer in config["layers"] - if layer["name"] == layer_name)["config"] - layer_outputs = self._get_outputs(output_config, True) - logger.debug("Layer name: %s, layer_outputs: %s", layer_name, layer_outputs) - for output in layer_outputs[0]: # Drill into sub-model to get the actual output names - logger.debug("Parsing output: %s", output) - loss_name = self._num_strip.sub("", output[0]) # strip trailing numbers - if loss_name[-2:] not in ("_a", "_b"): # Rename losses to reflect the side output - new_name = f"{loss_name.replace('_both', '')}_{side}" - logger.debug("Renaming loss output from '%s' to '%s'", loss_name, new_name) - loss_name = new_name - if loss_name not in self._loss_labels: - logger.debug("Adding loss name: '%s'", loss_name) - self._loss_labels.append(loss_name) - logger.debug("Collated loss labels: %s", self._loss_labels) - - @classmethod - def _get_outputs(cls, model_config: dict[str, T.Any], is_sub_model: bool) -> np.ndarray: - """ Obtain the output names, instance index and output index for the given model. - - If there is only a single output, the shape of the array is expanded to remain consistent - with multi model outputs - - Parameters - ---------- - model_config: dict - The saved Keras model configuration dictionary - is_sub_model: bool - ``True`` if the model_config is for a sub-model. ``False`` if it is for the main - faceswap model. - - Returns - ------- - :class:`numpy.ndarray` - The layer output names, their instance index and their output index - """ - outputs = np.array(model_config["output_layers"]) - logger.debug("Obtained model outputs. is_sub_model: %s, outputs: %s, shape: %s", - is_sub_model, outputs, outputs.shape) - # Reshape the outputs to (side, outputs per side, output info) - outputs = outputs.reshape((1 if is_sub_model else 2, -1, outputs.shape[-1])) - logger.debug("Reshaped model outputs: %s, shape: %s", outputs, outputs.shape) - return outputs + logger.debug("[EventParser] Collected %s records from live log file", i) @classmethod - def _process_event(cls, event: event_pb2.Event, step: EventData) -> EventData: - """ Process a single Tensorboard event. + def _process_event(cls, + event: event_pb2.Event, # pyright:ignore[reportInvalidTypeForm] + step: EventData) -> EventData: + """Process a single Tensorboard event. Adds timestamp to the step `dict` if a total loss value is received, process the labels for any new loss entries and adds the side loss value to the step `dict`. Parameters ---------- - event: :class:`tensorboard.compat.proto.event_pb2` + event The event data to be processed - step: :class:`EventData` + step The currently processing dictionary to be populated with the extracted data from the Tensorboard event for this step Returns ------- - :class:`EventData` - The given step :class:`EventData` with the given event data added to it. + The given step :class:`EventData` with the given event data added to it. """ summary = event.summary.value[0] @@ -828,5 +719,60 @@ def _process_event(cls, event: event_pb2.Event, step: EventData) -> EventData: return step + @classmethod + def _format_tags(cls, tags: list[str]) -> list[str]: + """Format the raw tags from log files to display names + + Parameters + ---------- + tags + The raw tags extracted from a tensorboard log file + + Returns + ------- + The tags formatted for display + """ + formatted = [t[6:] for t in tags] + formatted = [t[0].upper() + t[1:] for t in formatted] + for idx, tag in enumerate(formatted): + if "/" in tag: + category, loss_name = tag.split("/", maxsplit=1) + formatted[idx] = f"{category}-{loss_name.upper()}" + logger.trace("[EventParser] Formatted tags from %s to %s", # type:ignore[attr-defined] + tags, formatted) + return formatted + + def cache_events(self, session_id: int) -> None: + """Parse the Tensorboard events logs and add to :attr:`_cache`. + + Parameters + ---------- + session_id + The session id that the data is being cached for + """ + assert self._iterator is not None + + data: dict[int, EventData] = {} + tags: list[str] = [] + for record in self._iterator: + event = event_pb2.Event.FromString( # pyright:ignore[reportAttributeAccessIssue] + record + ) + if not event.summary.value: + continue + # filter out loss specific values, just keep totals + if not event.summary.value[0].tag.startswith(("batch_face_", + "batch_mask_", + "batch_total")): + continue + tag = event.summary.value[0].tag + if tag not in tags and tag != "batch_total": + tags.append(tag) + data[event.step] = self._process_event(event, + data.get(event.step, EventData())) + + tags = self._format_tags(tags) + self._cache.cache_data(session_id, data, tags, is_live=self._live_data) + __all__ = get_module_objects(__name__) diff --git a/lib/gui/display_graph.py b/lib/gui/display_graph.py index 9ae83f74a7..2fe5b6ad69 100755 --- a/lib/gui/display_graph.py +++ b/lib/gui/display_graph.py @@ -1,5 +1,5 @@ #!/usr/bin python3 -""" Graph functions for Display Frame area of the Faceswap GUI """ +"""Graph functions for Display Frame area of the Faceswap GUI""" from __future__ import annotations import datetime import logging @@ -14,8 +14,8 @@ import matplotlib from matplotlib import style from matplotlib.figure import Figure -from matplotlib.backends.backend_tkagg import (FigureCanvasTkAgg, - NavigationToolbar2Tk) +from matplotlib.backends.backend_tkagg import ( + FigureCanvasTkAgg, NavigationToolbar2Tk) # pyright:ignore[reportPrivateImportUsage] from matplotlib.backend_bases import NavigationToolbar2 from lib.logger import parse_class_init @@ -31,111 +31,110 @@ class GraphBase(ttk.Frame): # pylint:disable=too-many-ancestors - """ Base class for matplotlib line graphs. + """Base class for matplotlib line graphs. Parameters ---------- - parent: :class:`tkinter.ttk.Frame` + parent The parent frame that holds the graph - data: :class:`lib.gui.analysis.stats.Calculations` + data The statistics class that holds the data to be displayed - ylabel: str + ylabel The data label for the y-axis """ - def __init__(self, parent: ttk.Frame, data, ylabel: str) -> None: + def __init__(self, parent, data, ylabel: str) -> None: super().__init__(parent) matplotlib.use("TkAgg") # Can't be at module level as breaks Github CI style.use("ggplot") self._calcs = data self._ylabel = ylabel - self._colourmaps = ["Reds", "Blues", "Greens", "Purples", "Oranges", "Greys", "copper", + self._color_maps = ["Reds", "Blues", "Greens", "Purples", "Oranges", "Greys", "copper", "summer", "bone", "hot", "cool", "pink", "Wistia", "spring", "winter"] self._lines: list[Line2D] = [] - self._toolbar: "NavigationToolbar" | None = None + self._toolbar: NavigationToolbar | None = None self._fig = Figure(figsize=(4, 4), dpi=75) self._ax1 = self._fig.add_subplot(1, 1, 1) - self._plotcanvas = FigureCanvasTkAgg(self._fig, self) + self._plot_canvas = FigureCanvasTkAgg(self._fig, self) self._initiate_graph() self._update_plot(initiate=True) @property def calcs(self): - """ :class:`lib.gui.analysis.stats.Calculations`. The calculated statistics associated with - this graph. """ + """The calculated statistics associated with this graph.""" return self._calcs def _initiate_graph(self) -> None: - """ Place the graph canvas """ - logger.debug("Setting plotcanvas") - self._plotcanvas.get_tk_widget().pack(side=tk.TOP, padx=5, fill=tk.BOTH, expand=True) + """Place the graph canvas""" + logger.debug("[GraphBase] Setting plot canvas") + self._plot_canvas.get_tk_widget().pack(side=tk.TOP, padx=5, fill=tk.BOTH, expand=True) self._fig.subplots_adjust(left=0.100, bottom=0.100, right=0.95, top=0.95, wspace=0.2, hspace=0.2) - logger.debug("Set plotcanvas") + logger.debug("[GraphBase] Set plot canvas") def _update_plot(self, initiate: bool = True) -> None: - """ Update the plot with incoming data + """Update the plot with incoming data Parameters ---------- - initiate: bool, Optional + initiate Whether the graph should be initialized for the first time (``True``) or data is being updated for an existing graph (``False``). Default: ``True`` """ - logger.trace("Updating plot") # type:ignore[attr-defined] + logger.trace("[GraphBase] Updating plot") # type:ignore[attr-defined] if initiate: - logger.debug("Initializing plot") + logger.debug("[GraphBase] Initializing plot") self._lines = [] self._ax1.clear() self._axes_labels_set() - logger.debug("Initialized plot") + logger.debug("[GraphBase] Initialized plot") - fulldata = list(self._calcs.stats.values()) - self._axes_limits_set(fulldata) + full_data = list(self._calcs.stats.values()) + self._axes_limits_set(full_data) if self._calcs.start_iteration > 0: end_iteration = self._calcs.start_iteration + self._calcs.iterations - xrng = list(range(self._calcs.start_iteration, end_iteration)) + x_rng = list(range(self._calcs.start_iteration, end_iteration)) else: - xrng = list(range(self._calcs.iterations)) + x_rng = list(range(self._calcs.iterations)) keys = list(self._calcs.stats.keys()) for idx, item in enumerate(self._lines_sort(keys)): if initiate: - self._lines.extend(self._ax1.plot(xrng, self._calcs.stats[item[0]], + self._lines.extend(self._ax1.plot(x_rng, self._calcs.stats[item[0]], label=item[1], linewidth=item[2], color=item[3])) else: - self._lines[idx].set_data(xrng, self._calcs.stats[item[0]]) + self._lines[idx].set_data(x_rng, self._calcs.stats[item[0]]) if initiate: self._legend_place() - logger.trace("Updated plot") # type:ignore[attr-defined] + logger.trace("[GraphBase] Updated plot") # type:ignore[attr-defined] def _axes_labels_set(self) -> None: - """ Set the X and Y axes labels. """ - logger.debug("Setting axes labels. y-label: '%s'", self._ylabel) + """Set the X and Y axes labels.""" + logger.debug("[GraphBase] Setting axes labels. y-label: '%s'", self._ylabel) self._ax1.set_xlabel("Iterations") self._ax1.set_ylabel(self._ylabel) def _axes_limits_set_default(self) -> None: - """ Set the default axes limits for the X and Y axes. """ - logger.debug("Setting default axes ranges") + """Set the default axes limits for the X and Y axes.""" + logger.debug("[GraphBase] Setting default axes ranges") self._ax1.set_ylim(0.00, 100.0) self._ax1.set_xlim(0, 1) def _axes_limits_set(self, data: list[float]) -> None: - """ Set the axes limits. + """Set the axes limits. Parameters ---------- - data: list + data The data points for the Y Axis """ xmin = self._calcs.start_iteration @@ -149,235 +148,231 @@ def _axes_limits_set(self, data: list[float]) -> None: ymin, ymax = self._axes_data_get_min_max(data) self._ax1.set_ylim(ymin, ymax) self._ax1.set_xlim(xmin, xmax) - logger.trace("axes ranges: (y: (%s, %s), x:(0, %s)", # type:ignore[attr-defined] - ymin, ymax, xmax) + logger.trace( # type:ignore[attr-defined] + "[GraphBase] axes ranges: (y: (%s, %s), x:(0, %s)", ymin, ymax, xmax) else: self._axes_limits_set_default() @staticmethod def _axes_data_get_min_max(data: list[float]) -> tuple[float, float]: - """ Obtain the minimum and maximum values for the y-axis from the given data points. + """Obtain the minimum and maximum values for the y-axis from the given data points. Parameters ---------- - data: list + data The data points for the Y Axis Returns ------- - tuple - The minimum and maximum values for the y axis + The minimum and maximum values for the y axis """ - ymins, ymaxs = [], [] + y_mins, y_maxes = [], [] for item in data: # TODO Handle as array not loop - ymins.append(np.nanmin(item) * 1000) - ymaxs.append(np.nanmax(item) * 1000) - ymin = floor(min(ymins)) / 1000 - ymax = ceil(max(ymaxs)) / 1000 - logger.trace("ymin: %s, ymax: %s", ymin, ymax) # type:ignore[attr-defined] + y_mins.append(np.nanmin(item) * 1000) + y_maxes.append(np.nanmax(item) * 1000) + ymin = floor(min(y_mins)) / 1000 + ymax = ceil(max(y_maxes)) / 1000 + logger.trace("[GraphBase] ymin: %s, ymax: %s", ymin, ymax) # type:ignore[attr-defined] return ymin, ymax - def _axes_set_yscale(self, scale: str) -> None: - """ Set the Y-Scale to log or linear + def _axes_set_y_scale(self, scale: str) -> None: + """Set the Y-Scale to log or linear Parameters ---------- - scale: str + scale Should be one of ``"log"`` or ``"linear"`` """ - logger.debug("yscale: '%s'", scale) + logger.debug("[GraphBase] y_scale: '%s'", scale) self._ax1.set_yscale(scale) def _lines_sort(self, keys: list[str]) -> list[list[str | int | tuple[float, float, float, float]]]: - """ Sort the data keys into consistent order and set line color map and line width. + """Sort the data keys into consistent order and set line color map and line width. Parameters ---------- - keys: list + keys The list of data point keys Returns ------- - list - list[list[str | int | tuple[float, float, float, float]]] + The sorted data keys """ - logger.trace("Sorting lines") # type:ignore[attr-defined] + logger.trace("[GraphBase] Sorting lines") # type:ignore[attr-defined] raw_lines: list[list[str]] = [] sorted_lines: list[list[str]] = [] for key in sorted(keys): - title = key.replace("_", " ").title() + title = key.replace("_", " ") if key.startswith("raw"): raw_lines.append([key, title]) else: sorted_lines.append([key, title]) - groupsize = self._lines_groupsize(raw_lines, sorted_lines) + group_size = self._lines_group_size(raw_lines, sorted_lines) sorted_lines = raw_lines + sorted_lines - lines = self._lines_style(sorted_lines, groupsize) + lines = self._lines_style(sorted_lines, group_size) return lines @staticmethod - def _lines_groupsize(raw_lines: list[list[str]], sorted_lines: list[list[str]]) -> int: - """ Get the number of items in each group. + def _lines_group_size(raw_lines: list[list[str]], sorted_lines: list[list[str]]) -> int: + """Get the number of items in each group. If raw data isn't selected, then check the length of remaining groups until something is found. Parameters ---------- - raw_lines: list + raw_lines The list of keys for the raw data points - sorted_lines: + sorted_lines The list of sorted line keys to display on the graph Returns ------- - int - The size of each group that exist within the data set. + The size of each group that exist within the data set. """ - groupsize = 1 + group_size = 1 if raw_lines: - groupsize = len(raw_lines) + group_size = len(raw_lines) elif sorted_lines: keys = [key[0][:key[0].find("_")] for key in sorted_lines] distinct_keys = set(keys) - groupsize = len(keys) // len(distinct_keys) - logger.trace(groupsize) # type:ignore[attr-defined] - return groupsize + group_size = len(keys) // len(distinct_keys) + logger.trace("[GraphBase] %s", group_size) # type:ignore[attr-defined] + return group_size - def _lines_style(self, - lines: list[list[str]], - groupsize: int) -> list[list[str | int | tuple[float, float, float, float]]]: - """ Obtain the color map and line width for each group. + def _lines_create_colors(self, + group_size: int, + groups: int) -> list[tuple[float, float, float, float]]: + """Create the color maps. Parameters ---------- - lines: list - The list of sorted line keys to display on the graph - groupsize: int + group_size The size of each group to display in the graph + groups + The total number of groups to graph Returns ------- - list[list[str | int | tuple[float, float, float, float]]] - A list of loss keys with their corresponding line formatting and color information + The colour map for each group """ - logger.trace("Setting lines style") # type:ignore[attr-defined] - groups = int(len(lines) / groupsize) - colours = self._lines_create_colors(groupsize, groups) - widths = list(range(1, groups + 1)) - retval = T.cast(list[list[str | int | tuple[float, float, float, float]]], lines) - for idx, item in enumerate(retval): - linewidth = widths[idx // groupsize] - item.extend((linewidth, colours[idx])) - return retval + colors = [] + for i in range(1, groups + 1): + for colour in self._color_maps[0:group_size]: + c_map = matplotlib.cm.get_cmap( # pyright:ignore[reportAttributeAccessIssue] + colour + ) + c_point = 1 - (i / 5) + colors.append(c_map(c_point)) + logger.trace("[GraphBase] %s", colors) # type:ignore[attr-defined] + return colors - def _lines_create_colors(self, - groupsize: int, - groups: int) -> list[tuple[float, float, float, float]]: - """ Create the color maps. + def _lines_style(self, + lines: list[list[str]], + group_size: int) -> list[list[str | int | tuple[float, float, float, float]]]: + """Obtain the color map and line width for each group. Parameters ---------- - groupsize: int + lines + The list of sorted line keys to display on the graph + group_size The size of each group to display in the graph - groups: int - The total number of groups to graph Returns ------- - list[tuple[float, float, float, float] - The colour map for each group + A list of loss keys with their corresponding line formatting and color information """ - colours = [] - for i in range(1, groups + 1): - for colour in self._colourmaps[0:groupsize]: - cmap = matplotlib.cm.get_cmap(colour) - cpoint = 1 - (i / 5) - colours.append(cmap(cpoint)) - logger.trace(colours) # type:ignore[attr-defined] - return colours + logger.trace("[GraphBase] Setting lines style") # type:ignore[attr-defined] + groups = int(len(lines) / group_size) + colors = self._lines_create_colors(group_size, groups) + widths = list(range(1, groups + 1)) + retval = T.cast(list[list[str | int | tuple[float, float, float, float]]], lines) + for idx, item in enumerate(retval): + linewidth = widths[idx // group_size] + item.extend((linewidth, colors[idx])) + return retval def _legend_place(self) -> None: - """ Place and format the graph legend """ - logger.debug("Placing legend") + """Place and format the graph legend""" + logger.debug("[GraphBase] Placing legend") self._ax1.legend(loc="upper right", ncol=2) - def _toolbar_place(self, parent: ttk.Frame) -> None: - """ Add Graph Navigation toolbar. + def _toolbar_place(self, parent) -> None: + """Add Graph Navigation toolbar. Parameters ---------- - parent: ttk.Frame + parent The parent graph frame to place the toolbar onto """ - logger.debug("Placing toolbar") - self._toolbar = NavigationToolbar(self._plotcanvas, parent) + logger.debug("[GraphBase] Placing toolbar") + self._toolbar = NavigationToolbar(self._plot_canvas, parent) self._toolbar.pack(side=tk.BOTTOM) self._toolbar.update() def clear(self) -> None: - """ Clear the graph plots from RAM """ - logger.debug("Clearing graph from RAM: %s", self) + """Clear the graph plots from RAM """ + logger.debug("[GraphBase] Clearing graph from RAM: %s", self) self._fig.clf() del self._fig class TrainingGraph(GraphBase): # pylint:disable=too-many-ancestors - """ Live graph to be displayed during training. + """Live graph to be displayed during training. Parameters ---------- - parent: :class:`tkinter.ttk.Frame` + parent The parent frame that holds the graph - data: :class:`lib.gui.analysis.stats.Calculations` + data The statistics class that holds the data to be displayed - ylabel: str + ylabel The data label for the y-axis """ - - def __init__(self, parent: ttk.Frame, data, ylabel: str) -> None: + def __init__(self, parent, data, ylabel: str) -> None: logger.debug(parse_class_init(locals())) super().__init__(parent, data, ylabel) self._thread: LongRunningTask | None = None # Thread for LongRunningTask self._displayed_keys: list[str] = [] self._add_callback() - logger.debug("Initialized %s", self.__class__.__name__) def _add_callback(self) -> None: - """ Add the variable trace to update graph on refresh button press or save iteration. """ + """Add the variable trace to update graph on refresh button press or save iteration.""" get_config().tk_vars.refresh_graph.trace("w", self.refresh) # type:ignore def build(self) -> None: - """ Build the Training graph. """ - logger.debug("Building training graph") - self._plotcanvas.draw() - logger.debug("Built training graph") + """Build the Training graph.""" + logger.debug("[TrainingGraph] Building training graph") + self._plot_canvas.draw() + logger.debug("[TrainingGraph] Built training graph") def refresh(self, *args) -> None: # pylint:disable=unused-argument - """ Read the latest loss data and apply to current graph """ + """Read the latest loss data and apply to current graph""" refresh_var = T.cast(tk.BooleanVar, get_config().tk_vars.refresh_graph) if not refresh_var.get() and self._thread is None: return if self._thread is None: - logger.debug("Updating plot data") + logger.debug("[TrainingGraph] Updating plot data") self._thread = LongRunningTask(target=self._calcs.refresh) self._thread.start() self.after(1000, self.refresh) elif not self._thread.complete.is_set(): - logger.debug("Graph Data not yet available") + logger.debug("[TrainingGraph] Graph Data not yet available") self.after(1000, self.refresh) else: - logger.debug("Updating plot with data from background thread") + logger.debug("[TrainingGraph] Updating plot with data from background thread") self._calcs = self._thread.get_result() # Terminate the LongRunningTask object self._thread = None dsp_keys = list(sorted(self._calcs.stats)) if dsp_keys != self._displayed_keys: - logger.debug("Reinitializing graph for keys change. Old keys: %s New keys: %s", + logger.debug("[TrainingGraph] Reinitializing graph for keys change. " + "Old keys: %s New keys: %s", self._displayed_keys, dsp_keys) initiate = True self._displayed_keys = dsp_keys @@ -385,18 +380,18 @@ def refresh(self, *args) -> None: # pylint:disable=unused-argument initiate = False self._update_plot(initiate=initiate) - self._plotcanvas.draw() + self._plot_canvas.draw() refresh_var.set(False) def save_fig(self, location: str) -> None: - """ Save the current graph to file + """Save the current graph to file Parameters ---------- - location: str + location The full path to the folder where the current graph should be saved """ - logger.debug("Saving graph: '%s'", location) + logger.debug("[TrainingGraph] Saving graph: '%s'", location) keys = sorted([key.replace("raw_", "") for key in self._calcs.stats.keys() if key.startswith("raw_")]) filename = " - ".join(keys) @@ -405,93 +400,93 @@ def save_fig(self, location: str) -> None: self._fig.set_size_inches(16, 9) self._fig.savefig(filename, bbox_inches="tight", dpi=120) print(f"Saved graph to {filename}") - logger.debug("Saved graph: '%s'", filename) + logger.debug("[TrainingGraph] Saved graph: '%s'", filename) self._resize_fig() def _resize_fig(self) -> None: - """ Resize the figure to the current canvas size. """ + """Resize the figure to the current canvas size.""" class Event(): # pylint:disable=too-few-public-methods - """ Event class that needs to be passed to plotcanvas.resize """ + """Event class that needs to be passed to plot_canvas.resize""" pass # pylint:disable=unnecessary-pass setattr(Event, "width", self.winfo_width()) setattr(Event, "height", self.winfo_height()) - self._plotcanvas.resize(Event) # pylint:disable=no-value-for-parameter + self._plot_canvas.resize(Event) # pylint:disable=no-value-for-parameter class SessionGraph(GraphBase): # pylint:disable=too-many-ancestors - """ Session Graph for session pop-up. + """Session Graph for session pop-up. Parameters ---------- - parent: :class:`tkinter.ttk.Frame` + parent The parent frame that holds the graph - data: :class:`lib.gui.analysis.stats.Calculations` + data The statistics class that holds the data to be displayed - ylabel: str + ylabel The data label for the y-axis - scale: str + scale Should be one of ``"log"`` or ``"linear"`` """ - def __init__(self, parent: ttk.Frame, data, ylabel: str, scale: str) -> None: + def __init__(self, parent, data, ylabel: str, scale: str) -> None: logger.debug(parse_class_init(locals())) super().__init__(parent, data, ylabel) self._scale = scale - logger.debug("Initialized %s", self.__class__.__name__) def build(self) -> None: - """ Build the session graph """ - logger.debug("Building session graph") + """Build the session graph""" + logger.debug("[SessionGraph] Building session graph") self._toolbar_place(self) - self._plotcanvas.draw() - logger.debug("Built session graph") + self._plot_canvas.draw() + logger.debug("[SessionGraph] Built session graph") def refresh(self, data, ylabel: str, scale: str) -> None: - """ Refresh the Session Graph's data. + """Refresh the Session Graph's data. Parameters ---------- - data: :class:`lib.gui.analysis.stats.Calculations` + data The statistics class that holds the data to be displayed - ylabel: str + ylabel The data label for the y-axis - scale: str + scale Should be one of ``"log"`` or ``"linear"`` """ - logger.debug("Refreshing session graph: (ylabel: '%s', scale: '%s')", ylabel, scale) + logger.debug("[SessionGraph] Refreshing session graph: (ylabel: '%s', scale: '%s')", + ylabel, scale) self._calcs = data self._ylabel = ylabel self.set_yscale_type(scale) - logger.debug("Refreshed session graph") + logger.debug("[SessionGraph] Refreshed session graph") def set_yscale_type(self, scale: str) -> None: - """ Set the scale type for the y-axis and redraw. + """Set the scale type for the y-axis and redraw. Parameters ---------- - scale: str + scale Should be one of ``"log"`` or ``"linear"`` """ scale = scale.lower() - logger.debug("Updating scale type: '%s'", scale) + logger.debug("[SessionGraph] Updating scale type: '%s'", scale) self._scale = scale self._update_plot(initiate=True) - self._axes_set_yscale(self._scale) - self._plotcanvas.draw() - logger.debug("Updated scale type") + self._axes_set_y_scale(self._scale) + self._plot_canvas.draw() + logger.debug("[SessionGraph] Updated scale type") class NavigationToolbar(NavigationToolbar2Tk): # pylint:disable=too-many-ancestors - """ Overrides the default Navigation Toolbar to provide only the buttons we require + """Overrides the default Navigation Toolbar to provide only the buttons we require and to layout the items in a consistent manner with the rest of the GUI for the Analysis Session Graph pop up Window. Parameters ---------- - canvas: :class:`matplotlib.backends.backend_tkagg.FigureCanvasTkAgg` + canvas The canvas that holds the displayed graph and will hold the toolbar - window: :class:`~lib.gui.display_graph.SessionGraph` + window The Session Graph canvas - pack_toolbar: bool, Optional + pack_toolbar Whether to pack the Tool bar or not. Default: ``True`` """ toolitems = tuple(t for t in NavigationToolbar2Tk.toolitems if @@ -499,7 +494,7 @@ class NavigationToolbar(NavigationToolbar2Tk): # pylint:disable=too-many-ancest def __init__(self, # pylint:disable=super-init-not-called canvas: FigureCanvasTkAgg, - window: ttk.Frame, + window, *, pack_toolbar: bool = True) -> None: logger.debug(parse_class_init(locals())) @@ -514,8 +509,8 @@ def __init__(self, # pylint:disable=super-init-not-called sep = ttk.Frame(self, height=2, relief=tk.RIDGE) sep.pack(fill=tk.X, pady=(5, 0), side=tk.TOP) - btnframe = ttk.Frame(self) # Add a button frame to consistently line up GUI - btnframe.pack(fill=tk.X, padx=5, pady=5, side=tk.RIGHT) + btn_frame = ttk.Frame(self) # Add a button frame to consistently line up GUI + btn_frame.pack(fill=tk.X, padx=5, pady=5, side=tk.RIGHT) self._buttons = {} for text, tooltip_text, image_file, callback in self.toolitems: @@ -523,7 +518,7 @@ def __init__(self, # pylint:disable=super-init-not-called assert isinstance(image_file, str) assert isinstance(callback, str) self._buttons[text] = button = self._Button( - btnframe, + btn_frame, text, image_file, toggle=callback in ["zoom", "pan"], @@ -539,40 +534,38 @@ def __init__(self, # pylint:disable=super-init-not-called NavigationToolbar2.__init__(self, canvas) # pylint:disable=non-parent-init-called if pack_toolbar: self.pack(side=tk.BOTTOM, fill=tk.X) - logger.debug("Initialized %s", self.__class__.__name__) @staticmethod - def _Button(frame: ttk.Frame, # type:ignore[override] # pylint:disable=arguments-differ,arguments-renamed # noqa:E501 + def _Button(frame, # type:ignore[override] # pylint:disable=arguments-differ,arguments-renamed # noqa:E501 text: str, image_file: str, toggle: bool, command) -> ttk.Button | ttk.Checkbutton: - """ Override the default button method to use our icons and ttk widgets for + """Override the default button method to use our icons and ttk widgets for consistent GUI layout. Parameters ---------- - frame: :class:`tkinter.ttk.Frame` + frame The frame that holds the buttons - text: str + text The display text for the button - image_file: str + image_file The name of the image file to use - toggle: bool + toggle Whether to use a checkbutton (``True``) or a regular button (``False``) - command: method + command The Navigation Toolbar callback method Returns ------- - :class:`tkinter.ttk.Button` or :class:`tkinter.ttk.Checkbutton` - The widger to use. A button if the option is not toggleable, a checkbutton if the - option is toggleable. + The widget to use. A button if the option can not be toggled, a checkbutton if the option + can be toggled. """ - iconmapping = {"home": "reload", - "filesave": "save", - "zoom_to_rect": "zoom"} - icon = iconmapping[image_file] if iconmapping.get(image_file, None) else image_file + icon_mapping = {"home": "reload", + "filesave": "save", + "zoom_to_rect": "zoom"} + icon = icon_mapping[image_file] if icon_mapping.get(image_file, None) else image_file img = get_images().icons[icon] if not toggle: diff --git a/lib/model/losses/__init__.py b/lib/model/losses/__init__.py index aff02b11b8..98ca417795 100644 --- a/lib/model/losses/__init__.py +++ b/lib/model/losses/__init__.py @@ -1,7 +1,56 @@ #!/usr/bin/env python3 """ Custom Loss Functions for Faceswap """ +import typing as T + +from torch import nn + +from lib.utils import FaceswapError from .feature_loss import LPIPSLoss from .loss import (FocalFrequencyLoss, GeneralizedLoss, GradientLoss, - LaplacianPyramidLoss, LInfNorm, LogCosh, LossWrapper) -from .perceptual_loss import DSSIMObjective, GMSDLoss, LDRFLIPLoss, MSSIMLoss + LaplacianPyramidLoss, LInfNorm, LogCosh) +from .flip import LDRFLIPLoss +from .perceptual_loss import GMSDLoss, MSSIMLoss, SSIMLoss + + +def get_loss_function(name: str, color_order: T.Literal["bgr", "rgb"] = "bgr") -> nn.Module: + """Get the associated log function for the given configuration file name + + Parameters + ---------- + name + The name of the Loss function as specified in the training config file + color_order + For flip/lpips only. The color order that the model is training in + + Returns + ------- + The requested Torch Loss function + """ + valid = {"ffl": FocalFrequencyLoss, + "flip": LDRFLIPLoss, + "gmsd": GMSDLoss, + "l_inf_norm": LInfNorm, + "laploss": LaplacianPyramidLoss, + "logcosh": LogCosh, + "lpips_alex": LPIPSLoss, + "lpips_squeeze": LPIPSLoss, + "lpips_vgg16": LPIPSLoss, + "ms_ssim": MSSIMLoss, + "mae": nn.L1Loss, + "mse": nn.MSELoss, + "pixel_gradient_diff": GradientLoss, + "ssim": SSIMLoss, + "smooth_loss": GeneralizedLoss} + if name not in valid: + raise FaceswapError(f"'{name}' is not a valid Loss function. Choose from: {list(valid)}") + + kwargs: dict[str, T.Any] = {} + if name in ("mae", "mse"): + kwargs["reduction"] = "none" + if name == "flip" or name.startswith("lpips_"): + kwargs["color_order"] = color_order + if name.startswith("lpips_"): + kwargs["trunk_network"] = name.rsplit("_", maxsplit=1)[-1] + kwargs["crop"] = True + return valid[name](**kwargs) diff --git a/lib/model/losses/feature_loss.py b/lib/model/losses/feature_loss.py index 2c987fd51f..a9c1e9092d 100644 --- a/lib/model/losses/feature_loss.py +++ b/lib/model/losses/feature_loss.py @@ -264,10 +264,10 @@ class LPIPSLoss(nn.Module): # pylint:disable=too-many-instance-attributes lpips ``True`` to use linear network on top of the trunk network. ``False`` to just average the output from the trunk network. Default ``True`` - spatial + spatial_output ``True`` output the loss in the spatial domain (i.e. as a grayscale tensor of height and width of the input image). ``Bool`` reduce the spatial dimensions for loss calculation. - Default: ``False`` + Default: ``True`` normalize ``True`` if the input Tensor needs to be normalized from the 0. to 1. range to the -1. to 1. range. Default: ``True`` @@ -291,14 +291,14 @@ def __init__(self, # pylint:disable=too-many-arguments,too-many-positional-argu linear_eval_mode: bool = True, linear_use_dropout: bool = True, lpips: bool = True, - spatial: bool = False, + spatial_output: bool = True, normalize: bool = True, ret_per_layer: bool = False, crop: bool = False, color_order: T.Literal["bgr", "rgb"] = "bgr") -> None: super().__init__() logger.debug(parse_class_init(locals())) - self._spatial = spatial + self._spatial = spatial_output self._use_lpips = lpips self._normalize = normalize self._ret_per_layer = ret_per_layer @@ -406,10 +406,6 @@ def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor ------- The final loss value for each item in the batch """ - # TODO remove once channels first - y_true = y_true.permute(0, 3, 1, 2) - y_pred = y_pred.permute(0, 3, 1, 2) - if not self._is_rgb: y_true = torch.flip(y_true, dims=[1]) y_pred = torch.flip(y_pred, dims=[1]) diff --git a/lib/model/losses/flip.py b/lib/model/losses/flip.py new file mode 100644 index 0000000000..d569f92c1f --- /dev/null +++ b/lib/model/losses/flip.py @@ -0,0 +1,409 @@ +#! /usr/env/bin/python3 +"""LDR FliP loss from Nvidia""" +from __future__ import annotations + +import logging +import typing as T + +import numpy as np +import torch +from torch import nn +from torch.nn import functional as F + +from lib.torch_utils import ColorSpaceConvert +from lib.logger import parse_class_init +from lib.utils import get_module_objects + +logger = logging.getLogger(__name__) + + +class LDRFLIPLoss(nn.Module): # pylint:disable=too-many-instance-attributes + """Computes the LDR-FLIP error map between two LDR images, assuming the images are observed + at a certain number of pixels per degree of visual angle. + + References + ---------- + https://research.nvidia.com/sites/default/files/node/3260/FLIP_Paper.pdf + https://github.com/NVlabs/flip + + License + ------- + BSD 3-Clause License + Copyright (c) 2020-2022, NVIDIA Corporation & AFFILIATES. All rights reserved. + Redistribution and use in source and binary forms, with or without modification, are permitted + provided that the following conditions are met: + Redistributions of source code must retain the above copyright notice, this list of conditions + and the following disclaimer. + Redistributions in binary form must reproduce the above copyright notice, this list of + conditions and the following disclaimer in the documentation and/or other materials provided + with the distribution. + Neither the name of the copyright holder nor the names of its contributors may be used to + endorse or promote products derived from this software without specific prior written + permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR + IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + + Parameters + ---------- + computed_distance_exponent + The computed distance exponent to apply to Hunt adjusted, filtered colors. + (`qc` in original paper). Default: `0.7` + feature_exponent + The feature exponent to apply for increasing the impact of feature difference on the + final loss value. (`qf` in original paper). Default: `0.5` + lower_threshold_exponent + The `pc` exponent for the color pipeline as described in the original paper: Default: `0.4` + upper_threshold_exponent + The `pt` exponent for the color pipeline as described in the original paper. + Default: `0.95` + epsilon + A small value to improve training stability. Default: `1e-15` + pixels_per_degree + The estimated number of pixels per degree of visual angle of the observer. This effectively + impacts the tolerance when calculating loss. The default corresponds to viewing images on a + 0.7m wide 4K monitor at 0.7m from the display. Default: ``None`` + color_order + The `"bgr"` or `"rgb"` color order of the incoming images + spatial_output + ``True`` to output the loss function as a HxWx1 image output. ``False`` to reduce to mean + for each item in the batch. Default: ``False`` + """ + _c_max: torch.Tensor + + def __init__(self, + computed_distance_exponent: float = 0.7, + feature_exponent: float = 0.5, + lower_threshold_exponent: float = 0.4, + upper_threshold_exponent: float = 0.95, + epsilon: float = 1e-15, + pixels_per_degree: float | None = None, + color_order: T.Literal["bgr", "rgb"] = "bgr", + spatial_output: bool = True) -> None: + logger.debug(parse_class_init(locals())) + super().__init__() + self._computed_distance_exponent = computed_distance_exponent + self._feature_exponent = feature_exponent + self._pc = lower_threshold_exponent + self._pt = upper_threshold_exponent + self._epsilon = epsilon + self._color_order = color_order.lower() + self._spatial_output = spatial_output + + if pixels_per_degree is None: + pixels_per_degree = (0.7 * 3840 / 0.7) * np.pi / 180 + self._pixels_per_degree = pixels_per_degree + self._spatial_filters = _SpatialFilters(pixels_per_degree) + self._feature_detector = _FeatureDetection(pixels_per_degree) + self._rgb2lab = ColorSpaceConvert(from_space="rgb", to_space="lab") + self._rgb2ycxcz = ColorSpaceConvert("srgb", "ycxcz") + + hunt_adjusted_green = self._hunt_adjustment( + self._rgb2lab(torch.Tensor([[[[0.0]], [[1.0]], [[0.0]]]]).float()) + ) + hunt_adjusted_blue = self._hunt_adjustment( + self._rgb2lab(torch.Tensor([[[[0.0]], [[0.0]], [[1.0]]]]).float()) + ) + self.register_buffer("_c_max", + self._hyab(hunt_adjusted_green, + hunt_adjusted_blue) ** self._computed_distance_exponent) + + @classmethod + def _hunt_adjustment(cls, image: torch.Tensor) -> torch.Tensor: + """Apply Hunt-adjustment to an image in L*a*b* color space + + Parameters + ---------- + image + The batch of images in L*a*b* to adjust + + Returns + ------- + The hunt adjusted batch of images in L*a*b color space + """ + ch_l = image[:, 0:1] + return torch.cat([ch_l, image[:, 1:] * (ch_l * 0.01)], dim=1) + + def _hyab(self, y_true: torch.Tensor, y_pred: torch.Tensor | float) -> torch.Tensor: + """Compute the HyAB distance between true and predicted images. + + Parameters + ---------- + y_true + The ground truth batch of images in standard or Hunt-adjusted L*A*B* color space + y_pred + The predicted batch of images in in standard or Hunt-adjusted L*A*B* color space + + Returns + ------- + image tensor containing the per-pixel HyAB distances between true and predicted images + """ + delta = y_true - y_pred + root = torch.sqrt(torch.clamp(torch.pow(delta[:, 0:1], 2), min=self._epsilon)) + delta_norm = torch.norm(delta[:, 1:3], dim=1, keepdim=True) + return root + delta_norm + + def _redistribute_errors(self, power_delta_e_hyab: torch.Tensor) -> torch.Tensor: + """Redistribute exponentiated HyAB errors to the [0,1] range + + Parameters + ---------- + power_delta_e_hyab + The exponentiated HyAb distance + + Returns + ------- + The redistributed per-pixel HyAB distances (in range [0,1]) + """ + pcc_max = self._pc * self._c_max + return torch.where(power_delta_e_hyab < pcc_max, + (self._pt / pcc_max) * power_delta_e_hyab, + self._pt + ((power_delta_e_hyab - pcc_max) / + (self._c_max - pcc_max)) * (1.0 - self._pt)) + + def _color_pipeline(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: + """Perform the color processing part of the FLIP loss function + + Parameters + ---------- + y_true + The ground truth batch of images in YCxCz color space + y_pred + The predicted batch of images in YCxCz color space + + Returns + ------- + The exponentiated, maximum HyAB difference between two colors in Hunt-adjusted L*A*B* space + """ + filtered_true = self._spatial_filters(y_true) + filtered_pred = self._spatial_filters(y_pred) + + preprocessed_true = self._hunt_adjustment(self._rgb2lab(filtered_true)) + preprocessed_pred = self._hunt_adjustment(self._rgb2lab(filtered_pred)) + delta = self._hyab(preprocessed_true, preprocessed_pred) + power_delta = delta ** self._computed_distance_exponent + return self._redistribute_errors(power_delta) + + def _process_features(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: + """Perform the color processing part of the FLIP loss function + + Parameters + ---------- + y_true + The ground truth batch of images in YCxCz color space + y_pred + The predicted batch of images in YCxCz color space + + Returns + ------- + The exponentiated features delta + """ + col_y_true = (y_true[:, 0:1] + 16) / 116. + col_y_pred = (y_pred[:, 0:1] + 16) / 116. + + edges_true = self._feature_detector(col_y_true, "edge") + points_true = self._feature_detector(col_y_true, "point") + edges_pred = self._feature_detector(col_y_pred, "edge") + points_pred = self._feature_detector(col_y_pred, "point") + + delta = torch.maximum(torch.abs(torch.norm(edges_true, dim=1, keepdim=True) - + torch.norm(edges_pred, dim=1, keepdim=True)), + torch.abs(torch.norm(points_pred, dim=1, keepdim=True) - + torch.norm(points_true, dim=1, keepdim=True))) + + delta = torch.clamp(delta, min=self._epsilon) + return ((1 / np.sqrt(2)) * delta) ** self._feature_exponent + + def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: + """Call the LDR Flip Loss Function + + Parameters + ---------- + y_true + The ground truth batch of images + y_pred + The predicted batch of images + + Returns + ------- + The calculated Flip loss value + """ + if self._color_order == "bgr": # Switch models training in bgr order to rgb + y_true = torch.flip(y_true, dims=[1]) + y_pred = torch.flip(y_pred, dims=[1]) + + y_true = torch.clamp(y_true, 0, 1.) + y_pred = torch.clamp(y_pred, 0, 1.) + true_ycxcz = self._rgb2ycxcz(y_true) + pred_ycxcz = self._rgb2ycxcz(y_pred) + + delta_e_color = self._color_pipeline(true_ycxcz, pred_ycxcz) + delta_e_features = self._process_features(true_ycxcz, pred_ycxcz) + loss = delta_e_color ** (1 - delta_e_features) + if not self._spatial_output: + loss = loss.mean(dim=(1, 2, 3)) + return loss + + +class _SpatialFilters(nn.Module): + """Filters an image with channel specific spatial contrast sensitivity functions and clips + result to the unit cube in linear RGB. + + For use with LDRFlipLoss. + + Parameters + ---------- + pixels_per_degree + The estimated number of pixels per degree of visual angle of the observer. This effectively + impacts the tolerance when calculating loss. + """ + _spatial_filters: torch.Tensor + + def __init__(self, pixels_per_degree: float) -> None: + logger.debug(parse_class_init(locals())) + super().__init__() + self._pixels_per_degree = pixels_per_degree + self._radius: int = 0 # Set when spatial filters are generated + self.register_buffer("_spatial_filters", self._generate_spatial_filters()) + self._ycxcz2rgb = ColorSpaceConvert(from_space="ycxcz", to_space="rgb") + + def _get_evaluation_domain(self, + b1_a: float, + b2_a: float, + b1_rg: float, + b2_rg: float, + b1_by: float, + b2_by: float) -> tuple[np.ndarray, int]: + """Get the evaluation domain for the spatial filters""" + max_scale_parameter = max([b1_a, b2_a, b1_rg, b2_rg, b1_by, b2_by]) + delta_x = 1.0 / self._pixels_per_degree + radius = int(np.ceil(3 * np.sqrt(max_scale_parameter / (2 * np.pi**2)) + * self._pixels_per_degree)) + ax_x, ax_y = np.meshgrid(range(-radius, radius + 1), range(-radius, radius + 1)) + domain = (ax_x * delta_x) ** 2 + (ax_y * delta_x) ** 2 + return domain, radius + + @classmethod + def _generate_weights(cls, channel: dict[str, float], domain: np.ndarray) -> np.ndarray: + """Generate the weights for the spacial filters""" + a_1, b_1, a_2, b_2 = channel["a1"], channel["b1"], channel["a2"], channel["b2"] + grad = (a_1 * np.sqrt(np.pi / b_1) * np.exp(-np.pi ** 2 * domain / b_1) + + a_2 * np.sqrt(np.pi / b_2) * np.exp(-np.pi ** 2 * domain / b_2)) + grad = grad / np.sum(grad) + grad = np.reshape(grad, (1, *grad.shape)) + return grad + + def _generate_spatial_filters(self) -> torch.Tensor: + """Generates spatial contrast sensitivity filters with width depending on the number of + pixels per degree of visual angle of the observer for channels "A", "RG" and "BY" + + Returns + ------- + The spatial filter kernel for the channels ("A" (Achromatic CSF), "RG" (Red-Green CSF) or + "BY" (Blue-Yellow CSF)) corresponding to the spatial contrast sensitivity function + """ + mapping = {"A": {"a1": 1, "b1": 0.0047, "a2": 0, "b2": 1e-5}, + "RG": {"a1": 1, "b1": 0.0053, "a2": 0, "b2": 1e-5}, + "BY": {"a1": 34.1, "b1": 0.04, "a2": 13.5, "b2": 0.025}} + + domain, radius = self._get_evaluation_domain(mapping["A"]["b1"], + mapping["A"]["b2"], + mapping["RG"]["b1"], + mapping["RG"]["b2"], + mapping["BY"]["b1"], + mapping["BY"]["b2"]) + self._radius = radius + weights = np.array([self._generate_weights(mapping[channel], domain) + for channel in ("A", "RG", "BY")]) + return torch.from_numpy(weights).float() + + def forward(self, image: torch.Tensor) -> torch.Tensor: + """Call the spacial filtering. + + Parameters + ---------- + image + Image tensor to filter in YCxCz color space + + Returns + ------- + The input image transformed to linear RGB after filtering with spatial contrast sensitivity + functions + """ + img_pad = F.pad(image, (self._radius, self._radius, self._radius, self._radius), + mode="replicate") + image_tilde_opponent = F.conv2d(img_pad, # pylint:disable=not-callable + self._spatial_filters, + groups=3) + return torch.clamp(self._ycxcz2rgb(image_tilde_opponent), 0., 1.) + + +class _FeatureDetection(nn.Module): + """Detect features (i.e. edges and points) in an achromatic YCxCz image. + + For use with LDRFlipLoss. + + Parameters + ---------- + pixels_per_degree + The number of pixels per degree of visual angle of the observer + """ + _grads_edge: torch.Tensor + _grads_point: torch.Tensor + + def __init__(self, pixels_per_degree: float) -> None: + logger.debug(parse_class_init(locals())) + super().__init__() + width = 0.082 + self._std = 0.5 * width * pixels_per_degree + self._radius = int(np.ceil(3 * self._std)) + + grid = np.meshgrid(range(-self._radius, self._radius + 1), + range(-self._radius, self._radius + 1)) + gradient = np.exp(-(grid[0] ** 2 + grid[1] ** 2) / (2 * (self._std ** 2))) + self.register_buffer("_grads_edge", + torch.from_numpy(np.multiply(-grid[0], gradient)).float()) + self.register_buffer("_grads_point", + torch.from_numpy(np.multiply(grid[0] ** 2 / (self._std ** 2) - 1, + gradient)).float()) + + def forward(self, image: torch.Tensor, feature_type: str) -> torch.Tensor: + """Run the feature detection + + Parameters + ---------- + image + Batch of images in YCxCz color space with normalized Y values + feature_type + Type of features to detect (`"edge"` or `"point"`) + + Returns + ------- + Detected features in the 0-1 range + """ + feature_type = feature_type.lower() + grad_x = self._grads_edge if feature_type == "edge" else self._grads_point + negative_weights_sum = -grad_x[grad_x < 0].sum() + positive_weights_sum = grad_x[grad_x > 0].sum() + + grad_x = torch.where(grad_x < 0, + grad_x / negative_weights_sum, + grad_x / positive_weights_sum) + kernel = grad_x[None, None] + pad = (self._radius, self._radius, self._radius, self._radius,) + + features_x = F.conv2d(F.pad(image, pad, mode="replicate"), # pylint:disable=not-callable + kernel) + features_y = F.conv2d(F.pad(image, pad, mode="replicate"), # pylint:disable=not-callable + kernel.swapaxes(2, 3)) + return torch.cat([features_x, features_y], dim=1) + + +__all__ = get_module_objects(__name__) diff --git a/lib/model/losses/loss.py b/lib/model/losses/loss.py index fd209bc88c..ea85b1173a 100644 --- a/lib/model/losses/loss.py +++ b/lib/model/losses/loss.py @@ -3,22 +3,15 @@ from __future__ import annotations import logging -import typing as T import numpy as np import torch from torch import nn from torch.nn import functional as F -from keras import Loss -from keras import ops from lib.logger import parse_class_init from lib.utils import get_module_objects -if T.TYPE_CHECKING: - from collections.abc import Callable - from keras import KerasTensor - logger = logging.getLogger(__name__) @@ -42,19 +35,25 @@ class FocalFrequencyLoss(nn.Module): ``False``. Default: ``False`` epsilon Small epsilon for safer weights scaling division. Default: `1e-6` + spatial_output + ``True`` to output the loss values spatially. ``False`` as scalar per item. + Default: ``True`` References ---------- https://arxiv.org/pdf/2012.12821.pdf https://github.com/EndlessSora/focal-frequency-loss """ + _epsilon: torch.Tensor + def __init__(self, alpha: float = 1.0, patch_factor: int = 1, ave_spectrum: bool = False, log_matrix: bool = False, batch_matrix: bool = False, - epsilon: float = 1e-6) -> None: + epsilon: float = 1e-6, + spatial_output: bool = True) -> None: logger.debug(parse_class_init(locals())) super().__init__() self._alpha = alpha @@ -62,8 +61,8 @@ def __init__(self, self._ave_spectrum = ave_spectrum self._log_matrix = log_matrix self._batch_matrix = batch_matrix - self._epsilon = torch.Tensor([epsilon]) - self._dims: tuple[int, int] = (0, 0) + self.register_buffer("_epsilon", torch.Tensor([epsilon]).float()) + self._spatial = spatial_output def _get_patches(self, inputs: torch.Tensor) -> torch.Tensor: """Crop the incoming batch of images into patches as defined by :attr:`_patch_factor. @@ -78,15 +77,18 @@ def _get_patches(self, inputs: torch.Tensor) -> torch.Tensor: The incoming batch converted into patches """ patch_list = [] - patch_rows = self._dims[0] // self._patch_factor - patch_cols = self._dims[1] // self._patch_factor + rows, cols = inputs.shape[2:4] + assert cols % self._patch_factor == 0 and rows % self._patch_factor == 0, ( + "Patch factor must be a divisor of the image height and width") + patch_rows = rows // self._patch_factor + patch_cols = cols // self._patch_factor for i in range(self._patch_factor): for j in range(self._patch_factor): row_from = i * patch_rows row_to = (i + 1) * patch_rows col_from = j * patch_cols col_to = (j + 1) * patch_cols - patch_list.append(inputs[:, row_from: row_to, col_from:col_to, :]) + patch_list.append(inputs[:, :, row_from: row_to, col_from:col_to]) retval = torch.stack(patch_list, dim=1) return retval @@ -135,8 +137,7 @@ def _get_weight_matrix(self, freq_true: torch.Tensor, freq_pred: torch.Tensor) - weights = weights / torch.maximum(scale, self._epsilon) return torch.clamp(weights, min=0.0, max=1.0) - @classmethod - def _calculate_loss(cls, + def _calculate_loss(self, freq_true: torch.Tensor, freq_pred: torch.Tensor, weight_matrix: torch.Tensor) -> torch.Tensor: @@ -158,7 +159,7 @@ def _calculate_loss(cls, freq_distance = tmp[..., 0] + tmp[..., 1] loss = weight_matrix * freq_distance # dynamic spectrum weighting (Hadamard product) - return torch.mean(loss, dim=(1, 2, 3, 4)) + return torch.mean(loss, dim=(1, ) if self._spatial else (1, 2, 3, 4)) def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: """Call the Focal Frequency Loss Function. @@ -174,18 +175,6 @@ def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: ------- The final loss value for each item in the batch """ - # TODO remove once channels first - y_true = y_true.permute(0, 3, 1, 2) - y_pred = y_pred.permute(0, 3, 1, 2) - - if not all(self._dims): - rows, cols = y_true.shape[2:4] - assert rows is not None and cols is not None - assert cols % self._patch_factor == 0 and rows % self._patch_factor == 0, ( - "Patch factor must be a divisor of the image height and width") - self._dims = (rows, cols) - self._epsilon = self._epsilon.to(y_pred.device) - patches_true = self._get_patches(y_true) patches_pred = self._get_patches(y_pred) @@ -221,12 +210,19 @@ class GeneralizedLoss(nn.Module): beta Scale factor used to adjust to the input scale (i.e. inputs of mean `1e-4` or `256`). Default: `1.0/255.0` + spatial_output + ``True`` to output the loss values spatially. ``False`` as scalar per item. + Default: ``True`` """ - def __init__(self, alpha: float = 1.0, beta: float = 1.0/255.0) -> None: + def __init__(self, + alpha: float = 1.0, + beta: float = 1.0 / 255.0, + spatial_output: bool = True) -> None: logger.debug(parse_class_init(locals())) super().__init__() self._alpha = alpha self._beta = beta + self._spatial = spatial_output def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: """Call the Generalized Loss Function @@ -246,8 +242,9 @@ def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: second = (torch.pow(torch.pow(diff/self._beta, 2.) / abs(2. - self._alpha) + 1., (self._alpha / 2.)) - 1.) loss = (abs(2. - self._alpha)/self._alpha) * second - loss = torch.mean(loss, dim=(1, 2, 3)) * self._beta - return loss + if not self._spatial: + loss = torch.mean(loss, dim=(1, 2, 3)) + return loss * self._beta class GradientLoss(nn.Module): @@ -258,17 +255,25 @@ class GradientLoss(nn.Module): image and the difference is taken. When used as a loss, its minimization will result in predicted images approaching the same level of sharpness / blurriness as the ground truth. + Parameters + ---------- + spatial_output + ``True`` to output the loss values spatially. ``False`` as scalar per item. + Default: ``True`` + References ---------- TV+TV2 Regularization with Non-Convex Sparseness-Inducing Penalty for Image Restoration, Chengwu Lu & Hua Huang, 2014 - http://downloads.hindawi.com/journals/mpe/2014/790547.pdf """ - def __init__(self) -> None: + def __init__(self, + spatial_output: bool = True) -> None: logger.debug(parse_class_init(locals())) super().__init__() self.generalized_loss = GeneralizedLoss(alpha=1.9999) self._tv_weight = 1.0 self._tv2_weight = 1.0 + self._spatial = spatial_output @classmethod def _diff_x(cls, img: torch.Tensor) -> torch.Tensor: @@ -331,20 +336,20 @@ def _diff_xy(cls, img: torch.Tensor) -> torch.Tensor: top = img[:, 0:1, 1:2, :] + img[:, 1:2, 0:1, :] inner = img[:, :-2, 1:2, :] + img[:, 2:, 0:1, :] bottom = img[:, -2:-1, 1:2, :] + img[:, -1:, 0:1, :] - xy_left = torch.concatenate([top, inner, bottom], dim=1) + xy1_left = torch.concatenate([top, inner, bottom], dim=1) # Mid top = img[:, 0:1, 2:, :] + img[:, 1:2, :-2, :] mid = img[:, :-2, 2:, :] + img[:, 2:, :-2, :] bottom = img[:, -2:-1, 2:, :] + img[:, -1:, :-2, :] - xy_mid = torch.concatenate([top, mid, bottom], dim=1) + xy1_mid = torch.concatenate([top, mid, bottom], dim=1) # Right top = img[:, 0:1, -1:, :] + img[:, 1:2, -2:-1, :] inner = img[:, :-2, -1:, :] + img[:, 2:, -2:-1, :] bottom = img[:, -2:-1, -1:, :] + img[:, -1:, -2:-1, :] - xy_right = torch.concatenate([top, inner, bottom], dim=1) + xy1_right = torch.concatenate([top, inner, bottom], dim=1) xy_out1 = torch.concatenate([xy_left, xy_mid, xy_right], dim=2) - xy_out2 = torch.concatenate([xy_left, xy_mid, xy_right], dim=2) + xy_out2 = torch.concatenate([xy1_left, xy1_mid, xy1_right], dim=2) return (xy_out1 - xy_out2) * 0.25 def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: @@ -374,6 +379,8 @@ def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: self._diff_xy(y_pred)) * 2.) loss = loss / (self._tv_weight + self._tv2_weight) # TODO simplify to use MSE instead + if not self._spatial: + loss = loss.mean(dim=(1, 2, 3)) return loss @@ -394,6 +401,9 @@ class LaplacianPyramidLoss(nn.Module): The gaussian sigma. Default: 2.0 device The device to place the variables onto. Default: `"cpu"` + spatial_output + ``True`` to output the loss values spatially. ``False`` as scalar per item. + Default: ``True`` References ---------- @@ -406,11 +416,13 @@ class LaplacianPyramidLoss(nn.Module): def __init__(self, max_levels: int = 5, gaussian_size: int = 5, - gaussian_sigma: float = 1.0) -> None: + gaussian_sigma: float = 1.0, + spatial_output: bool = True) -> None: logger.debug(parse_class_init(locals())) super().__init__() self._max_levels = max_levels self._gaussian_sigma = gaussian_sigma + self._spatial = spatial_output self.register_buffer("_weight", torch.Tensor([np.power(2., -2 * idx) for idx in range(max_levels + 1)])) @@ -494,21 +506,25 @@ def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: ------- The final loss value for each item in the batch """ - # TODO remove once channels first - y_true = y_true.permute(0, 3, 1, 2) - y_pred = y_pred.permute(0, 3, 1, 2) - pyramid_true = self._get_laplacian_pyramid(y_true) pyramid_pred = self._get_laplacian_pyramid(y_pred) - losses = torch.stack([F.l1_loss(o, t, reduction="none").mean(dim=(1, 2, 3)) - for o, t in zip(pyramid_true, pyramid_pred)]).T - losses *= self._weight - return losses.sum(dim=1) + losses = [F.l1_loss(o, t, reduction="none") for o, t in zip(pyramid_true, pyramid_pred)] + if self._spatial: + size = y_true.shape[-2:] + loss = torch.stack( + [x if x.shape[-2:] == size else (F.interpolate(x, + size=size, + mode="bilinear", + align_corners=False)) + for x in losses]).swapaxes(0, 1) * self._weight[..., None, None, None] + else: + loss = torch.stack([x.mean(dim=(1, 2, 3)) for x in losses]).T * self._weight + return loss.sum(dim=1) class LInfNorm(nn.Module): - """Calculate the L-inf norm as a loss function. """ + """Calculate the L-inf norm as a loss function.""" def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: """Call the L-inf norm loss function. @@ -531,7 +547,18 @@ def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: class LogCosh(nn.Module): """Logarithm of the hyperbolic cosine of the prediction error. Ported from Keras implementation + + Parameters + ---------- + spatial_output + ``True`` to output the loss values spatially. ``False`` as scalar per item. + Default: ``True`` """ + def __init__(self, spatial_output: bool = True) -> None: + logger.debug(parse_class_init(locals())) + super().__init__() + self._spatial = spatial_output + def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: """Call the LogCosh loss function. @@ -549,140 +576,9 @@ def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: diff = y_true - y_pred loss: torch.Tensor = (diff + F.softplus(diff * -2.0) - # pylint:disable=not-callable np.log(2)) - return loss.mean(dim=(1, 2, 3)) - - -class LossWrapper(Loss): - """A wrapper class for multiple keras losses to enable multiple masked weighted loss - functions on a single output. - - Notes - ----- - Whilst Keras does allow for applying multiple weighted loss functions, it does not allow - for an easy mechanism to add additional data (in our case masks) that are batch specific - but are not fed in to the model. - - This wrapper receives this additional mask data for the batch stacked onto the end of the - color channels of the received :attr:`y_true` batch of images. These masks are then split - off the batch of images and applied to both the :attr:`y_true` and :attr:`y_pred` tensors - prior to feeding into the loss functions. - - For example, for an image of shape (4, 128, 128, 3) 3 additional masks may be stacked onto - the end of y_true, meaning we receive an input of shape (4, 128, 128, 6). This wrapper then - splits off (4, 128, 128, 3:6) from the end of the tensor, leaving the original y_true of - shape (4, 128, 128, 3) ready for masking and feeding through the loss functions. - """ - def __init__(self, name="LossWrapper", reduction="sum_over_batch_size") -> None: - logger.debug(parse_class_init(locals())) - super().__init__(name=name, reduction=reduction) - self._loss_functions: list[Loss | Callable] = [] - self._loss_weights: list[float] = [] - self._mask_channels: list[int] = [] - logger.debug("Initialized: %s", self.__class__.__name__) - - def add_loss(self, - function: Callable | Loss, - weight: float = 1.0, - mask_channel: int = -1) -> None: - """Add the given loss function with the given weight to the loss function chain. - - Parameters - ---------- - function: :class:`keras.losses.Loss` - The loss function to add to the loss chain - weight: float, optional - The weighting to apply to the loss function. Default: `1.0` - mask_channel: int, optional - The channel in the `y_true` image that the mask exists in. Set to `-1` if there is no - mask for the given loss function. Default: `-1` - """ - logger.debug("Adding loss: (function: %s, weight: %s, mask_channel: %s)", - function, weight, mask_channel) - # Loss must be compiled inside LossContainer for keras to handle distributed strategies - self._loss_functions.append(function) - self._loss_weights.append(weight) - self._mask_channels.append(mask_channel) - - def call(self, y_true: KerasTensor, y_pred: KerasTensor) -> KerasTensor: - """Call the sub loss functions for the loss wrapper. - - Loss is returned as the weighted sum of the chosen losses. - - If masks are being applied to the loss function inputs, then they should be included as - additional channels at the end of :attr:`y_true`, so that they can be split off and - applied to the actual inputs to the selected loss function(s). - - Parameters - ---------- - y_true: :class:`keras.KerasTensor` - The ground truth batch of images, with any required masks stacked on the end - y_pred: :class:`keras.KerasTensor` - The batch of model predictions - - Returns - ------- - :class:`keras.KerasTensor` - The final weighted loss - """ - loss = 0.0 - for func, weight, mask_channel in zip(self._loss_functions, - self._loss_weights, - self._mask_channels): - logger.trace("Processing loss function: " # type:ignore[attr-defined] - "(func: %s, weight: %s, mask_channel: %s)", - func, weight, mask_channel) - n_true, n_pred = self._apply_mask(y_true, y_pred, mask_channel) - this_loss = func(n_true, n_pred) * weight - if ops.ndim(this_loss) > 1: - # TODO this can go when we remove Keras loss wrapper. For now all sub-functions - # return shape (BS, ) of mean loss per item. Torch built in losses let us either - # reduce to scalar or return the full output, so we have to reduce to item here. - # When everything is all torch this hacky workaround should be removable - this_loss = this_loss.flatten(start_dim=1).mean(dim=1) - loss += this_loss - return T.cast("KerasTensor", loss) - - @classmethod - def _apply_mask(cls, - y_true: KerasTensor, - y_pred: KerasTensor, - mask_channel: int, - mask_prop: float = 1.0) -> tuple[KerasTensor, KerasTensor]: - """Apply the mask to the input y_true and y_pred. If a mask is not required then - return the unmasked inputs. - - Parameters - ---------- - y_true: :class:`keras.KerasTensor` - The ground truth value - y_pred: :class:`keras.KerasTensor` - The predicted value - mask_channel: int - The channel within y_true that the required mask resides in - mask_prop: float, optional - The amount of mask propagation. Default: `1.0` - - Returns - ------- - :class:`keras.KerasTensor` - The ground truth batch of images, with the required mask applied - :class:`keras.KerasTensor` - The predicted batch of images with the required mask applied - """ - if mask_channel == -1: - logger.trace("No mask to apply") # type:ignore[attr-defined] - return y_true[..., :3], y_pred[..., :3] - - logger.trace("Applying mask from channel %s", mask_channel) # type:ignore[attr-defined] - - mask = ops.tile(ops.expand_dims(y_true[..., mask_channel], axis=-1), (1, 1, 1, 3)) - mask_as_k_inv_prop = 1 - mask_prop - mask = (mask * mask_prop) + mask_as_k_inv_prop - - m_true = y_true[..., :3] * mask - m_pred = y_pred[..., :3] * mask - - return m_true, m_pred + if not self._spatial: + loss = loss.mean(dim=(1, 2, 3)) + return loss __all__ = get_module_objects(__name__) diff --git a/lib/model/losses/perceptual_loss.py b/lib/model/losses/perceptual_loss.py index 012200b2c1..f4f4075c6e 100644 --- a/lib/model/losses/perceptual_loss.py +++ b/lib/model/losses/perceptual_loss.py @@ -3,174 +3,30 @@ from __future__ import annotations import logging -import typing as T import numpy as np import torch from torch import nn from torch.nn import functional as F -from lib.torch_utils import ColorSpaceConvert from lib.logger import parse_class_init -from lib.utils import get_module_objects +from lib.utils import FaceswapError, get_module_objects logger = logging.getLogger(__name__) -class DSSIMObjective(nn.Module): - """DSSIM Loss Functions - - Difference of Structural Similarity (DSSIM loss function). - - Adapted from :func:`tensorflow.image.ssim` for a pure keras implementation. - - Notes - ----- - Channels last only. Assumes all input images are the same size and square - - Parameters - ---------- - k_1 - Parameter of the SSIM. Default: `0.01` - k_2 - Parameter of the SSIM. Default: `0.03` - filter_size - size of gaussian filter Default: `11` - filter_sigma - Width of gaussian filter Default: `1.5` - max_value - Max value of the output. Default: `1.0` - - Notes - ------ - You should add a regularization term like a l2 loss in addition to this one. - """ - _kernel: torch.Tensor - - def __init__(self, - k_1: float = 0.01, - k_2: float = 0.03, - filter_size: int = 11, - filter_sigma: float = 1.5, - max_value: float = 1.0) -> None: - logger.debug(parse_class_init(locals())) - super().__init__() - self._filter_size = filter_size - self._filter_sigma = filter_sigma - self.register_buffer("_kernel", self._get_kernel()) - - compensation = 1.0 - self._c1 = (k_1 * max_value) ** 2 - self._c2 = ((k_2 * max_value) ** 2) * compensation - - def _get_kernel(self) -> torch.Tensor: - """Obtain the base kernel for performing depthwise convolution. - - Returns - ------- - The gaussian kernel based on selected size and sigma - """ - coords = np.arange(self._filter_size, dtype=np.float32) - coords -= (self._filter_size - 1) / 2. - - kernel = np.square(coords) - kernel *= -0.5 / np.square(self._filter_sigma) - kernel = np.reshape(kernel, (1, -1)) + np.reshape(kernel, (-1, 1)) - kernel_t = torch.from_numpy(np.reshape(kernel, (1, -1))) - kernel_t = torch.softmax(kernel_t, dim=-1) - kernel_t = torch.reshape(kernel_t, (1, 1, self._filter_size, self._filter_size)) - return kernel_t - - @classmethod - def _depthwise_conv2d(cls, image: torch.Tensor, kernel: torch.Tensor) -> torch.Tensor: - """Perform a standardized depthwise convolution. - - Parameters - ---------- - image - Batch of images, channels last, to perform depthwise convolution - kernel - convolution kernel - - Returns - ------- - The output from the convolution - """ - depth, in_ch, h, w = kernel.shape - kernel = torch.reshape(kernel, (in_ch * depth, 1, h, w)) - return F.conv2d(image, kernel, groups=in_ch) # pylint:disable=not-callable - - def _get_ssim(self, - y_true: torch.Tensor, - y_pred: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: - """Obtain the structural similarity between a batch of true and predicted images. - - Parameters - ---------- - y_true - The input batch of ground truth images - y_pred - The input batch of predicted images - - Returns - ------- - ssim - The SSIM for the given images - contrast - The Contrast for the given images - """ - channels = y_true.shape[1] - kernel = torch.tile(self._kernel, (1, channels, 1, 1)) - - # SSIM luminance measure is (2 * mu_x * mu_y + c1) / (mu_x ** 2 + mu_y ** 2 + c1) - mean_true = self._depthwise_conv2d(y_true, kernel) - mean_pred = self._depthwise_conv2d(y_pred, kernel) - num_lum = mean_true * mean_pred * 2.0 - den_lum = torch.square(mean_true) + torch.square(mean_pred) - luminance = (num_lum + self._c1) / (den_lum + self._c1) - - # SSIM contrast-structure measure is (2 * cov_{xy} + c2) / (cov_{xx} + cov_{yy} + c2) - num_con = self._depthwise_conv2d(y_true * y_pred, kernel) * 2.0 - den_con = self._depthwise_conv2d(torch.square(y_true) + torch.square(y_pred), kernel) - - contrast = (num_con - num_lum + self._c2) / (den_con - den_lum + self._c2) - - # Average over the height x width dimensions - axes = (-3, -2) - ssim = torch.mean(luminance * contrast, dim=axes) - contrast = torch.mean(contrast, dim=axes) - - return ssim, contrast - - def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: - """Call the DSSIM or MS-DSSIM Loss Function. - - Parameters - ---------- - y_true - The input batch of ground truth images - y_pred - The input batch of predicted images - - Returns - ------- - The final DSSIM or MS-DSSIM for each item in the batch - """ - # TODO remove once channels first - y_true = y_true.permute(0, 3, 1, 2) - y_pred = y_pred.permute(0, 3, 1, 2) - - ssim = self._get_ssim(y_true, y_pred)[0] - retval = (1. - ssim) / 2.0 - return torch.mean(retval, dim=-1) - - class GMSDLoss(nn.Module): """Gradient Magnitude Similarity Deviation Loss. Improved image quality metric over MS-SSIM with easier calculations + Parameters + ---------- + spatial_output + ``True`` to output the loss values spatially. ``False`` as scalar per item. + Default: ``True`` + References ---------- http://www4.comp.polyu.edu.hk/~cslzhang/IQA/GMSD/GMSD.htm @@ -178,9 +34,10 @@ class GMSDLoss(nn.Module): """ _scharr_edges: torch.Tensor - def __init__(self) -> None: + def __init__(self, spatial_output: bool = True) -> None: logger.debug(parse_class_init(locals())) super().__init__() + self._spatial = spatial_output self.register_buffer("_scharr_edges", torch.from_numpy( np.array([[[[0.00070, 0.00070]], [[0.00520, 0.00370]], @@ -242,7 +99,7 @@ def _map_scharr_edges(self, image: torch.Tensor, magnitude: bool) -> torch.Tenso out = out.reshape(bs, height, width, channels, 2) gx = out[..., 0] gy = out[..., 1] - out = torch.atan(gx / gy) + out = torch.atan2(gx, gy) # magnitude of edges -- unified x & y edges don't work well with Neural Networks return out @@ -260,579 +117,347 @@ def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: ------- The final loss value for each item in the batch """ - # TODO remove once channels first - y_true = y_true.permute(0, 3, 1, 2) - y_pred = y_pred.permute(0, 3, 1, 2) - true_edge = self._map_scharr_edges(y_true, True) pred_edge = self._map_scharr_edges(y_pred, True) epsilon = 0.0025 upper = 2.0 * true_edge * pred_edge lower = torch.square(true_edge) + torch.square(pred_edge) gms = (upper + epsilon) / (lower + epsilon) - gmsd = torch.std(gms, dim=(1, 2, 3)) - return gmsd + if self._spatial: + # per-pixel similarity reasonable proxy for spatial loss + loss = 1.0 - gms.mean(dim=1)[:, None] + else: + loss = torch.std(gms, dim=(1, 2, 3)) + return loss -class LDRFLIPLoss(nn.Module): # pylint:disable=too-many-instance-attributes - """Computes the LDR-FLIP error map between two LDR images, assuming the images are observed - at a certain number of pixels per degree of visual angle. - - References - ---------- - https://research.nvidia.com/sites/default/files/node/3260/FLIP_Paper.pdf - https://github.com/NVlabs/flip - - License - ------- - BSD 3-Clause License - Copyright (c) 2020-2022, NVIDIA Corporation & AFFILIATES. All rights reserved. - Redistribution and use in source and binary forms, with or without modification, are permitted - provided that the following conditions are met: - Redistributions of source code must retain the above copyright notice, this list of conditions - and the following disclaimer. - Redistributions in binary form must reproduce the above copyright notice, this list of - conditions and the following disclaimer in the documentation and/or other materials provided - with the distribution. - Neither the name of the copyright holder nor the names of its contributors may be used to - endorse or promote products derived from this software without specific prior written - permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR - IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY - AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR - CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR - SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR - OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - POSSIBILITY OF SUCH DAMAGE. +class _SSIM(nn.Module): # pylint:disable=abstract-method + """Parent class for SSIM and MSSIM loss functions Parameters ---------- - computed_distance_exponent - The computed distance exponent to apply to Hunt adjusted, filtered colors. - (`qc` in original paper). Default: `0.7` - feature_exponent - The feature exponent to apply for increasing the impact of feature difference on the - final loss value. (`qf` in original paper). Default: `0.5` - lower_threshold_exponent - The `pc` exponent for the color pipeline as described in the original paper: Default: `0.4` - upper_threshold_exponent - The `pt` exponent for the color pipeline as described in the original paper. - Default: `0.95` - epsilon - A small value to improve training stability. Default: `1e-15` - pixels_per_degree - The estimated number of pixels per degree of visual angle of the observer. This effectively - impacts the tolerance when calculating loss. The default corresponds to viewing images on a - 0.7m wide 4K monitor at 0.7m from the display. Default: ``None`` - color_order - The `"bgr"` or `"rgb"` color order of the incoming images + max_val + The dynamic range of the images (i.e., the difference between the maximum the and minimum + allowed values). Default `1.0` (0.0 - 1.0) + filter_size + Size of gaussian filter. Default: `11` + filter_sigma: + Width of gaussian filter. Default: 1.5 + k1 + The K1 value. Default: `0.01` + k2 + The K2 value. Default: `0.03` (SSIM is less sensitivity to K2 for lower values, so + it would be better if we took the values in the range of 0 < K2 < 0.4). spatial_output - ``True`` to output the loss function as a HxWx1 image output. ``False`` to reduce to mean - for each item in the batch. Default: ``False`` + ``True`` to output the loss values spatially. ``False`` as scalar per item. + Default: ``True`` + + Reference + --------- + https://github.com/tensorflow/tensorflow/blob/v2.16.1/tensorflow/python/ops/image_ops_impl.py """ + _kernel: torch.Tensor + def __init__(self, - computed_distance_exponent: float = 0.7, - feature_exponent: float = 0.5, - lower_threshold_exponent: float = 0.4, - upper_threshold_exponent: float = 0.95, - epsilon: float = 1e-15, - pixels_per_degree: float | None = None, - color_order: T.Literal["bgr", "rgb"] = "bgr", - spatial_output: bool = False) -> None: - logger.debug(parse_class_init(locals())) + max_val: float = 1.0, + filter_size: int = 11, + filter_sigma: float = 1.5, + k1: float = 0.01, + k2: float = 0.03, + spatial_output: bool = True) -> None: super().__init__() - self._computed_distance_exponent = computed_distance_exponent - self._feature_exponent = feature_exponent - self._pc = lower_threshold_exponent - self._pt = upper_threshold_exponent - self._epsilon = epsilon - self._color_order = color_order.lower() - self._spatial_output = spatial_output - - if pixels_per_degree is None: - pixels_per_degree = (0.7 * 3840 / 0.7) * np.pi / 180 - self._pixels_per_degree = pixels_per_degree - self._spatial_filters = _SpatialFilters(pixels_per_degree) - self._feature_detector = _FeatureDetection(pixels_per_degree) - self._rgb2lab = ColorSpaceConvert(from_space="rgb", to_space="lab") - self._rgb2ycxcz = ColorSpaceConvert("srgb", "ycxcz") + self._max_value = max_val + self._filter_sigma = filter_sigma + self._k1 = k1 + self._k2 = k2 + self._spatial = spatial_output + self.register_buffer("_kernel", self._fspecial_gauss(filter_size, filter_sigma)) - @classmethod - def _hunt_adjustment(cls, image: torch.Tensor) -> torch.Tensor: - """Apply Hunt-adjustment to an image in L*a*b* color space + def _fspecial_gauss(self, size: int, sigma: float) -> torch.Tensor: + """Function to mimic the 'fspecial' gaussian MATLAB function. Parameters ---------- - image - The batch of images in L*a*b* to adjust + filter_size + size of gaussian filter + sigma + width of gaussian filter Returns ------- - The hunt adjusted batch of images in L*a*b color space + The gaussian kernel in channels first depthwise format (1,1,H,W) """ - ch_l = image[:, 0:1] - return torch.cat([ch_l, image[:, 1:] * (ch_l * 0.01)], dim=1) - - def _hyab(self, y_true: torch.Tensor, y_pred: torch.Tensor | float) -> torch.Tensor: - """Compute the HyAB distance between true and predicted images. + coords = torch.arange(0, size, dtype=torch.float32) + coords -= (size - 1) / 2. - Parameters - ---------- - y_true - The ground truth batch of images in standard or Hunt-adjusted L*A*B* color space - y_pred - The predicted batch of images in in standard or Hunt-adjusted L*A*B* color space + gauss = coords ** 2 + gauss *= (-0.5 / (sigma ** 2)) - Returns - ------- - image tensor containing the per-pixel HyAB distances between true and predicted images - """ - delta = y_true - y_pred - root = torch.sqrt(torch.clamp(torch.pow(delta[:, 0:1], 2), min=self._epsilon)) - delta_norm = torch.norm(delta[:, 1:3], dim=1, keepdim=True) - return root + delta_norm + gauss = gauss.reshape(1, -1) + gauss.reshape(-1, 1) + gauss = gauss.reshape(1, -1) # For ops.softmax(). + gauss = F.softmax(gauss, dim=-1) + return gauss.reshape(1, 1, size, size) - def _redistribute_errors(self, - power_delta_e_hyab: torch.Tensor, - c_max: torch.Tensor) -> torch.Tensor: - """Redistribute exponentiated HyAB errors to the [0,1] range + def _reducer(self, image: torch.Tensor) -> torch.Tensor: + """Computes local averages from a set of images Parameters ---------- - power_delta_e_hyab - The exponentiated HyAb distance - c_max - The exponentiated, maximum HyAB difference between two colors in Hunt-adjusted - L*A*B* space + image + The images to be processed (N,C,H,W) Returns ------- - The redistributed per-pixel HyAB distances (in range [0,1]) + The reduced image """ - pcc_max = self._pc * c_max - return torch.where(power_delta_e_hyab < pcc_max, - (self._pt / pcc_max) * power_delta_e_hyab, - self._pt + ((power_delta_e_hyab - pcc_max) / - (c_max - pcc_max)) * (1.0 - self._pt)) + shape = image.shape + channels = shape[-3] + kernel = self._kernel.repeat(channels, 1, 1, 1) + x = image.reshape(-1, *shape[-3:]) + pad = self._kernel.shape[-1] // 2 + if self._spatial: + x = F.pad(x, [pad, pad, pad, pad], mode="reflect") # preserve spatial dims + y = F.conv2d(x, kernel, groups=channels) # pylint:disable=not-callable + return y.reshape((*shape[:-3], *y.shape[1:])) - def _color_pipeline(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: - """Perform the color processing part of the FLIP loss function + def _ssim_helper(self, + image1: torch.Tensor, + image2: torch.Tensor, + compensation: float = 1.0) -> tuple[torch.Tensor, torch.Tensor]: + """Helper function for computing SSIM Parameters ---------- - y_true - The ground truth batch of images in YCxCz color space - y_pred - The predicted batch of images in YCxCz color space + image1 + The first set of images (N,C,H,W) + image2 + The second set of images (N,C,H,W) + compensation + Compensation factor. Default: `1.0` Returns ------- - The exponentiated, maximum HyAB difference between two colors in Hunt-adjusted L*A*B* space + ssim + The channel-wise SSIM + contrast + The channel-wise contrast-structure """ - filtered_true = self._spatial_filters(y_true) - filtered_pred = self._spatial_filters(y_pred) - - preprocessed_true = self._hunt_adjustment(self._rgb2lab(filtered_true)) - preprocessed_pred = self._hunt_adjustment(self._rgb2lab(filtered_pred)) - hunt_adjusted_green = self._hunt_adjustment( - self._rgb2lab(torch.Tensor([[[[0.0]], [[1.0]], [[0.0]]]]).float().to(y_pred.device)) - ) - hunt_adjusted_blue = self._hunt_adjustment( - self._rgb2lab(torch.Tensor([[[[0.0]], [[0.0]], [[1.0]]]]).float().to(y_pred.device)) - ) - - delta = self._hyab(preprocessed_true, preprocessed_pred) - power_delta = delta ** self._computed_distance_exponent - c_max = self._hyab(hunt_adjusted_green, - hunt_adjusted_blue) ** self._computed_distance_exponent - return self._redistribute_errors(power_delta, c_max) - - def _process_features(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: - """Perform the color processing part of the FLIP loss function + c_1 = (self._k1 * self._max_value) ** 2 + c_2 = (self._k2 * self._max_value) ** 2 - Parameters - ---------- - y_true - The ground truth batch of images in YCxCz color space - y_pred - The predicted batch of images in YCxCz color space + mean0 = self._reducer(image1) + mean1 = self._reducer(image2) - Returns - ------- - The exponentiated features delta - """ - col_y_true = (y_true[:, 0:1] + 16) / 116. - col_y_pred = (y_pred[:, 0:1] + 16) / 116. + num0 = mean0 * mean1 * 2.0 + den0 = mean0 ** 2 + mean1 ** 2 + luminance = (num0 + c_1) / (den0 + c_1) - edges_true = self._feature_detector(col_y_true, "edge") - points_true = self._feature_detector(col_y_true, "point") - edges_pred = self._feature_detector(col_y_pred, "edge") - points_pred = self._feature_detector(col_y_pred, "point") + num1 = self._reducer(image1 * image2) * 2.0 + den1 = self._reducer(image1 ** 2 + image2 ** 2) - delta = torch.maximum(torch.abs(torch.norm(edges_true, dim=1, keepdim=True) - - torch.norm(edges_pred, dim=1, keepdim=True)), - torch.abs(torch.norm(points_pred, dim=1, keepdim=True) - - torch.norm(points_true, dim=1, keepdim=True))) + c_2 *= compensation + cs_ = (num1 - num0 + c_2) / ((den1 - den0).clamp(min=0) + c_2) - delta = torch.clamp(delta, min=self._epsilon) - return ((1 / np.sqrt(2)) * delta) ** self._feature_exponent + return luminance, cs_ - def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: - """Call the LDR Flip Loss Function + def _ssim_per_channel(self, + image1: torch.Tensor, + image2: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + """Computes SSIM index between image1 and image2 per color channel. + + This function matches the standard SSIM implementation from: + Wang, Z., Bovik, A. C., Sheikh, H. R., & Simoncelli, E. P. (2004). Image + quality assessment: from error visibility to structural similarity. IEEE + transactions on image processing. Parameters ---------- - y_true - The ground truth batch of images - y_pred - The predicted batch of images + image1 + The first image batch (N,C,H,W) + image2 + The second image batch. (N,C,H,W) + filter_size + size of gaussian filter. Returns ------- - The calculated Flip loss value + ssim + The channel-wise SSIM + contrast + The channel-wise contrast-structure """ - # TODO remove once channels first - y_true = y_true.permute(0, 3, 1, 2) - y_pred = y_pred.permute(0, 3, 1, 2) - - if self._color_order == "bgr": # Switch models training in bgr order to rgb - y_true = torch.flip(y_true, dims=[1]) - y_pred = torch.flip(y_pred, dims=[1]) - - y_true = torch.clamp(y_true, 0, 1.) - y_pred = torch.clamp(y_pred, 0, 1.) - true_ycxcz = self._rgb2ycxcz(y_true) - pred_ycxcz = self._rgb2ycxcz(y_pred) - - delta_e_color = self._color_pipeline(true_ycxcz, pred_ycxcz) - delta_e_features = self._process_features(true_ycxcz, pred_ycxcz) - loss = delta_e_color ** (1 - delta_e_features) - if not self._spatial_output: - loss = loss.mean(dim=(1, 2, 3)) - return loss + luminance, cs_ = self._ssim_helper(image1, image2) + ssim_val = luminance * cs_ + if not self._spatial: # Average over height, width. + ssim_val = ssim_val.mean(dim=(-2, -1)) + cs_ = cs_.mean(dim=(-2, -1)) + return ssim_val, cs_ -class _SpatialFilters(nn.Module): - """Filters an image with channel specific spatial contrast sensitivity functions and clips - result to the unit cube in linear RGB. +class SSIMLoss(_SSIM): + """Computes SSIM index between img1 and img2. - For use with LDRFlipLoss. + This function is based on the standard SSIM implementation from: + Wang, Z., Bovik, A. C., Sheikh, H. R., & Simoncelli, E. P. (2004). Image + quality assessment: from error visibility to structural similarity. IEEE + transactions on image processing. - Parameters - ---------- - pixels_per_degree - The estimated number of pixels per degree of visual angle of the observer. This effectively - impacts the tolerance when calculating loss. - """ - _spatial_filters: torch.Tensor + Note: The true SSIM is only defined on grayscale. This function does not + perform any color-space transform. (If the input is already YUV, then it will + compute YUV SSIM average.) - def __init__(self, pixels_per_degree: float) -> None: - logger.debug(parse_class_init(locals())) - super().__init__() - self._pixels_per_degree = pixels_per_degree - self._radius: int = 0 # Set when spatial filters are generated - self.register_buffer("_spatial_filters", self._generate_spatial_filters()) - self._ycxcz2rgb = ColorSpaceConvert(from_space="ycxcz", to_space="rgb") - - def _get_evaluation_domain(self, - b1_a: float, - b2_a: float, - b1_rg: float, - b2_rg: float, - b1_by: float, - b2_by: float) -> tuple[np.ndarray, int]: - """Get the evaluation domain for the spatial filters""" - max_scale_parameter = max([b1_a, b2_a, b1_rg, b2_rg, b1_by, b2_by]) - delta_x = 1.0 / self._pixels_per_degree - radius = int(np.ceil(3 * np.sqrt(max_scale_parameter / (2 * np.pi**2)) - * self._pixels_per_degree)) - ax_x, ax_y = np.meshgrid(range(-radius, radius + 1), range(-radius, radius + 1)) - domain = (ax_x * delta_x) ** 2 + (ax_y * delta_x) ** 2 - return domain, radius + Details: + - 11x11 Gaussian filter of width 1.5 is used. + - k1 = 0.01, k2 = 0.03 as in the original paper. - @classmethod - def _generate_weights(cls, channel: dict[str, float], domain: np.ndarray) -> np.ndarray: - """Generate the weights for the spacial filters""" - a_1, b_1, a_2, b_2 = channel["a1"], channel["b1"], channel["a2"], channel["b2"] - grad = (a_1 * np.sqrt(np.pi / b_1) * np.exp(-np.pi ** 2 * domain / b_1) + - a_2 * np.sqrt(np.pi / b_2) * np.exp(-np.pi ** 2 * domain / b_2)) - grad = grad / np.sum(grad) - grad = np.reshape(grad, (1, *grad.shape)) - return grad - - def _generate_spatial_filters(self) -> torch.Tensor: - """Generates spatial contrast sensitivity filters with width depending on the number of - pixels per degree of visual angle of the observer for channels "A", "RG" and "BY" + The filter is reduced in size of the image is smaller than 11x11. - Returns - ------- - The spatial filter kernel for the channels ("A" (Achromatic CSF), "RG" (Red-Green CSF) or - "BY" (Blue-Yellow CSF)) corresponding to the spatial contrast sensitivity function - """ - mapping = {"A": {"a1": 1, "b1": 0.0047, "a2": 0, "b2": 1e-5}, - "RG": {"a1": 1, "b1": 0.0053, "a2": 0, "b2": 1e-5}, - "BY": {"a1": 34.1, "b1": 0.04, "a2": 13.5, "b2": 0.025}} - - domain, radius = self._get_evaluation_domain(mapping["A"]["b1"], - mapping["A"]["b2"], - mapping["RG"]["b1"], - mapping["RG"]["b2"], - mapping["BY"]["b1"], - mapping["BY"]["b2"]) - self._radius = radius - weights = np.array([self._generate_weights(mapping[channel], domain) - for channel in ("A", "RG", "BY")]) - return torch.from_numpy(weights).float() - - def forward(self, image: torch.Tensor) -> torch.Tensor: - """Call the spacial filtering. + Reference + --------- + https://github.com/tensorflow/tensorflow/blob/v2.16.1/tensorflow/python/ops/image_ops_impl.py + """ + + def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: + """Call the SSIM Loss Function. Parameters ---------- - image - Image tensor to filter in YCxCz color space + y_true + The input batch of ground truth images + y_pred + The input batch of predicted images Returns ------- - The input image transformed to linear RGB after filtering with spatial contrast sensitivity - functions + The final SSIM for each item in the batch """ - img_pad = F.pad(image, (self._radius, self._radius, self._radius, self._radius), - mode="replicate") - image_tilde_opponent = F.conv2d(img_pad, # pylint:disable=not-callable - self._spatial_filters, - groups=3) - return torch.clamp(self._ycxcz2rgb(image_tilde_opponent), 0., 1.) - + ssim_per_channel, _ = self._ssim_per_channel(y_true, y_pred) + loss = 1.0 - ssim_per_channel + if not self._spatial: + loss = loss.mean(dim=-1) + return loss -class _FeatureDetection(nn.Module): - """Detect features (i.e. edges and points) in an achromatic YCxCz image. - For use with LDRFlipLoss. +class MSSIMLoss(_SSIM): + """Computes the MS-SSIM between img1 and img2. - Parameters - ---------- - pixels_per_degree - The number of pixels per degree of visual angle of the observer - """ - _grads_edge: torch.Tensor - _grads_point: torch.Tensor + This function assumes that `img1` and `img2` are image batches, i.e. the last + three dimensions are [height, width, channels]. - def __init__(self, pixels_per_degree: float) -> None: - logger.debug(parse_class_init(locals())) - super().__init__() - width = 0.082 - self._std = 0.5 * width * pixels_per_degree - self._radius = int(np.ceil(3 * self._std)) - - grid = np.meshgrid(range(-self._radius, self._radius + 1), - range(-self._radius, self._radius + 1)) - gradient = np.exp(-(grid[0] ** 2 + grid[1] ** 2) / (2 * (self._std ** 2))) - self.register_buffer("_grads_edge", - torch.from_numpy(np.multiply(-grid[0], gradient)).float()) - self.register_buffer("_grads_point", - torch.from_numpy(np.multiply(grid[0] ** 2 / (self._std ** 2) - 1, - gradient)).float()) - - def forward(self, image: torch.Tensor, feature_type: str) -> torch.Tensor: - """Run the feature detection + Note: The true SSIM is only defined on grayscale. This function does not + perform any color-space transform. (If the input is already YUV, then it will + compute YUV SSIM average.) - Parameters - ---------- - image - Batch of images in YCxCz color space with normalized Y values - feature_type - Type of features to detect (`"edge"` or `"point"`) - - Returns - ------- - Detected features in the 0-1 range - """ - feature_type = feature_type.lower() - grad_x = self._grads_edge if feature_type == "edge" else self._grads_point - negative_weights_sum = -grad_x[grad_x < 0].sum() - positive_weights_sum = grad_x[grad_x > 0].sum() - - grad_x = torch.where(grad_x < 0, - grad_x / negative_weights_sum, - grad_x / positive_weights_sum) - kernel = grad_x[None, None] - pad = (self._radius, self._radius, self._radius, self._radius,) - - features_x = F.conv2d(F.pad(image, pad, mode="replicate"), # pylint:disable=not-callable - kernel) - features_y = F.conv2d(F.pad(image, pad, mode="replicate"), # pylint:disable=not-callable - kernel.swapaxes(2, 3)) - return torch.cat([features_x, features_y], dim=1) + Original paper: Wang, Zhou, Eero P. Simoncelli, and Alan C. Bovik. "Multiscale + structural similarity for image quality assessment." Signals, Systems and + Computers, 2004. + Details: + - 11x11 Gaussian filter of width 1.5 is used. + - k1 = 0.01, k2 = 0.03 as in the original paper. -class MSSIMLoss(nn.Module): - """Multi-scale Structural Similarity Loss Function + The filter is reduced in size if the smallest image is smaller than 11x11. Parameters ---------- - k_1 - Parameter of the SSIM. Default: `0.01` - k_2 - Parameter of the SSIM. Default: `0.03` + max_val + The dynamic range of the images (i.e., the difference between the maximum the and minimum + allowed values). Default `1.0` (0.0 - 1.0) filter_size - size of gaussian filter Default: `11` - filter_sigma - Width of gaussian filter Default: `1.5` - max_value - Max value of the output. Default: `1.0` + Size of gaussian filter. Default: `11` + filter_sigma: + Width of gaussian filter. Default: 1.5 + k1 + The K1 value. Default: `0.01` + k2 + The K2 value. Default: `0.03` (SSIM is less sensitivity to K2 for lower values, so + it would be better if we took the values in the range of 0 < K2 < 0.4). + spatial_output + ``True`` to output the loss values spatially. ``False`` as scalar per item. + Default: ``True`` power_factors Iterable of weights for each of the scales. The number of scales used is the length of the list. Index 0 is the unscaled resolution's weight and each increasing scale corresponds to the image being downsampled by 2. Defaults to the values obtained in the original paper. Default: (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) - Notes - ------ - You should add a regularization term like a l2 loss in addition to this one. - Adapted from Tensorflow's ssim_multi-scale implementation + Reference + --------- + https://github.com/tensorflow/tensorflow/blob/v2.16.1/tensorflow/python/ops/image_ops_impl.py """ _power_factors: torch.Tensor _divisor_tensor: torch.Tensor def __init__(self, - k_1: float = 0.01, - k_2: float = 0.03, + max_val: float = 1.0, filter_size: int = 11, filter_sigma: float = 1.5, - max_value: float = 1.0, + k1: float = 0.01, + k2: float = 0.03, + spatial_output: bool = True, power_factors: tuple[float, ...] = (0.0448, 0.2856, 0.3001, 0.2363, 0.1333) ) -> None: - logger.debug(parse_class_init(locals())) - super().__init__() - self.filter_size = filter_size - self._filter_sigma_sq = filter_sigma ** 2 - self._k_1 = k_1 - self._k_2 = k_2 - self._max_value = max_value + super().__init__(max_val, filter_size, filter_sigma, k1, k2, spatial_output) self._divisor = [1, 1, 2, 2] self.register_buffer("_power_factors", torch.Tensor(power_factors).float()) self.register_buffer("_divisor_tensor", torch.Tensor(self._divisor[1:]).int()) + self._validated = False - @classmethod - def _reducer(cls, image: torch.Tensor, kernel: torch.Tensor) -> torch.Tensor: - """Computes local averages from a set of images - - Parameters - ---------- - image - The images to be processed (N,C,H,W) - kernel - The kernel to apply in depthwise format (C,1,H,W) - - Returns - ------- - The reduced image - """ - shape = image.shape - channels = shape[-3] - x = image.reshape(-1, *shape[-3:]) - y = F.conv2d(x, kernel, groups=channels) # pylint:disable=not-callable - return y.reshape((*shape[:-3], *y.shape[1:])) - - def _ssim_helper(self, - image1: torch.Tensor, - image2: torch.Tensor, - kernel: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: - """Helper function for computing SSIM - - Parameters - ---------- - image1 - The first set of images (N,C,H,W) - image2 - The second set of images (N,C,H,W) - kernel - The gaussian kernel in depthwise format (C,1,H,W) - - Returns - ------- - ssim - The channel-wise SSIM - contrast - The channel-wise contrast-structure - """ - c_1 = (self._k_1 * self._max_value) ** 2 - c_2 = (self._k_2 * self._max_value) ** 2 - - mean0 = self._reducer(image1, kernel) - mean1 = self._reducer(image2, kernel) - num0 = mean0 * mean1 * 2.0 - den0 = mean0 ** 2 + mean1 ** 2 - luminance = (num0 + c_1) / (den0 + c_1) - - num1 = self._reducer(image1 * image2, kernel) * 2.0 - den1 = self._reducer(image1 ** 2 + image2 ** 2, kernel) - cs_ = (num1 - num0 + c_2) / (den1 - den0 + c_2) - - return luminance, cs_ - - def _fspecial_gauss(self, size: int) -> torch.Tensor: - """Function to mimic the 'fspecial' gaussian MATLAB function. + def _get_smallest_size(self, size: int, idx: int) -> int: + """Recursive function to obtain the smallest size that the image will be scaled to. Parameters ---------- - filter_size - size of gaussian filter + size: int + The current scaled size to iterate through + idx: int + The current iteration to be performed. When iteration hits zero the value will + be returned Returns ------- - The gaussian kernel in channels first depthwise format (C,1,H,W) + int + The smallest size the image will be scaled to based on the original image size and + the amount of scaling factors that will occur """ - coords = torch.arange(0, size, dtype=torch.float32, device=self._divisor_tensor.device) - coords -= size - 1 / 2. - - gauss = coords ** 2 * (-0.5 / self._filter_sigma_sq) - - gauss = gauss.reshape(1, -1) + gauss.reshape(-1, 1) - gauss = gauss.reshape(1, -1) # For ops.softmax(). - gauss = F.softmax(gauss, dim=-1) - return gauss.reshape(1, 1, size, size) - - def _ssim_per_channel(self, - image1: torch.Tensor, - image2: torch.Tensor, - filter_size: int) -> tuple[torch.Tensor, torch.Tensor]: - """Computes SSIM index between image1 and image2 per color channel. + logger.trace("[MSSIM] scale id: %s, size: %s", idx, size) # type:ignore[attr-defined] + if idx > 0: + size = self._get_smallest_size(size // 2, idx - 1) + return size - This function matches the standard SSIM implementation from: - Wang, Z., Bovik, A. C., Sheikh, H. R., & Simoncelli, E. P. (2004). Image - quality assessment: from error visibility to structural similarity. IEEE - transactions on image processing. + def _validate_kernel(self, image: torch.Tensor) -> None: + """Validate that the kernel is an appropriate size for the smallest scale image. If not, + create a new kernel and show warning. Validation is run once on first batch of images seen Parameters ---------- - image1 - The first image batch (N,C,H,W) - image2 - The second image batch. (N,C,H,W) - filter_size - size of gaussian filter. - - Returns - ------- - ssim - The channel-wise SSIM - contrast - The channel-wise contrast-structure + image + A batch of incoming images to perform size validation on """ - channels = image1.shape[-3] - kernel = self._fspecial_gauss(filter_size) - kernel = kernel.repeat(channels, 1, 1, 1) - luminance, cs_ = self._ssim_helper(image1, image2, kernel) - - # Average over height, width. - ssim_val = (luminance * cs_).mean(dim=[-2, -1]) - cs_ = cs_.mean(dim=[-2, -1]) - return ssim_val, cs_ + if self._validated: + return + im_size = image.shape[2] + smallest_scale = self._get_smallest_size(im_size, len(self._power_factors) - 1) + kernel_size = self._kernel.shape[-1] + + if smallest_scale >= kernel_size: + logger.info("[MSSIM] Inbound images are valid. smallest_scale: %s, kernel_size: %s", + smallest_scale, kernel_size) + self._validated = True + return + + logger.warning("[MSSIM] Output size %spx is below 176px. The MS-SSIM kernel must be " + "adjusted to accommodate. You will likely get better results using SSIM.", + im_size) + del self._kernel + flt = smallest_scale - 1 if smallest_scale % 2 == 0 else smallest_scale + if flt < 3: + raise FaceswapError("The output size of the selected model is too small for MS-SSIM. " + "Use SSIM instead.") + logger.debug("[MSSIM] Adjusting filter kernel to %s from %s for smallest scale %s.", + flt, kernel_size, smallest_scale) + self._kernel = self._fspecial_gauss(flt, self._filter_sigma).to(image.device) + self._validated = True @classmethod def _do_pad(cls, images: list[torch.Tensor], remainder: torch.Tensor) -> list[torch.Tensor]: @@ -855,8 +480,7 @@ def _do_pad(cls, images: list[torch.Tensor], remainder: torch.Tensor) -> list[to def _mssism(self, # pylint:disable=too-many-locals y_true: torch.Tensor, - y_pred: torch.Tensor, - filter_size: int) -> torch.Tensor: + y_pred: torch.Tensor) -> torch.Tensor: """Perform the MSSISM calculation. Ported from Tensorflow implementation `image.ssim_multiscale` @@ -867,44 +491,48 @@ def _mssism(self, # pylint:disable=too-many-locals The ground truth value y_pred The predicted value - filter_size - The filter size to use """ images = [y_true, y_pred] shapes = [y_true.shape, y_pred.shape] - heads = [s[:-3] for s in shapes] - tails = [s[-3:] for s in shapes] - + heads = [s[:-3] for s in shapes] # Batch dimensions + tails = [s[-3:] for s in shapes] # Image dimensions mcs = [] ssim_per_channel = None + size = y_true.shape[-1] for k in range(len(self._power_factors)): if k > 0: # Avg pool takes rank 4 tensors. Flatten leading dimensions. flat_images = [(x.reshape(-1, *t)) for x, t in zip(images, tails)] - remainder = torch.tensor(list(tails[0]), - dtype=torch.int32, - device=y_pred.device) % self._divisor_tensor + remainder = torch.tensor(tails[0], device=y_pred.device) % self._divisor_tensor if (remainder != 0).any(): flat_images = self._do_pad(flat_images, remainder) downscaled = [F.avg_pool2d(x, # pylint:disable=not-callable - self._divisor[1:3], - stride=self._divisor[1:3], + self._divisor[2:], + stride=self._divisor[2:], padding=0) for x in flat_images] - tails = [x.shape[1:] for x in downscaled] images = [x.reshape(*h, *t) for x, h, t in zip(downscaled, heads, tails)] # Overwrite previous ssim value since we only need the last one. - ssim_per_channel, cs_ = self._ssim_per_channel(images[0], images[1], filter_size) + ssim_per_channel, cs_ = self._ssim_per_channel(images[0], images[1]) + if self._spatial: + cs_ = F.interpolate(cs_, size=size, mode="bilinear", align_corners=False) mcs.append(F.relu(cs_)) mcs.pop() # Remove the cs score for the last scale. assert ssim_per_channel is not None + if self._spatial: + ssim_per_channel = F.interpolate(ssim_per_channel, + size=size, + mode="bilinear", + align_corners=False) mcs_and_ssim = torch.stack(mcs + [F.relu(ssim_per_channel)], dim=-1) ms_ssim = torch.prod(mcs_and_ssim ** self._power_factors, dim=-1) - return ms_ssim.mean(dim=-1) # Avg over color channels. + if not self._spatial: + ms_ssim = ms_ssim.mean(dim=-1) # Avg over color channels. + return ms_ssim def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: """Call the MS-SSIM Loss Function. @@ -920,40 +548,10 @@ def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor: ------- The MS-SSIM Loss value """ - # TODO remove once channels first - y_true = y_true.permute(0, 3, 1, 2) - y_pred = y_pred.permute(0, 3, 1, 2) - - im_size = y_true.shape[2] - # filter size cannot be larger than the smallest scale - smallest_scale = self._get_smallest_size(im_size, len(self._power_factors) - 1) - filter_size = min(self.filter_size, smallest_scale) - - ms_ssim = self._mssism(y_true, y_pred, filter_size) + self._validate_kernel(y_true) + ms_ssim = self._mssism(y_true, y_pred) ms_ssim_loss = 1. - ms_ssim return ms_ssim_loss - def _get_smallest_size(self, size: int, idx: int) -> int: - """Recursive function to obtain the smallest size that the image will be scaled to. - - Parameters - ---------- - size: int - The current scaled size to iterate through - idx: int - The current iteration to be performed. When iteration hits zero the value will - be returned - - Returns - ------- - int - The smallest size the image will be scaled to based on the original image size and - the amount of scaling factors that will occur - """ - logger.trace("scale id: %s, size: %s", idx, size) # type:ignore[attr-defined] - if idx > 0: - size = self._get_smallest_size(size // 2, idx - 1) - return size - __all__ = get_module_objects(__name__) diff --git a/lib/training/__init__.py b/lib/training/__init__.py index 44a7ea1282..a6433c1e1d 100644 --- a/lib/training/__init__.py +++ b/lib/training/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import typing as T -from .data_augmentation import ImageAugmentation from .lr_finder import LearningRateFinder from .lr_warmup import LearningRateWarmup from .preview_cv import PreviewBuffer, TriggerType diff --git a/lib/training/data/__init__.py b/lib/training/data/__init__.py new file mode 100644 index 0000000000..21c323d58f --- /dev/null +++ b/lib/training/data/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +"""Handles loading and preparation of data for training Faceswap models""" +from .data_set import get_label +from .collate import BatchMeta +from .loader import PreviewLoader, TrainLoader diff --git a/lib/training/data_augmentation.py b/lib/training/data/augmentation.py similarity index 100% rename from lib/training/data_augmentation.py rename to lib/training/data/augmentation.py diff --git a/lib/training/data/collate.py b/lib/training/data/collate.py new file mode 100644 index 0000000000..9ddff48823 --- /dev/null +++ b/lib/training/data/collate.py @@ -0,0 +1,467 @@ +#!/usr/bin/env python3 +"""Handles collation of data for training faceswap models""" +from __future__ import annotations + +import logging +import typing as T +from dataclasses import dataclass + +import cv2 +import numpy as np +import torch +from tqdm import tqdm + +from lib.align.constants import EXTRACT_RATIOS, LandmarkType, MEAN_FACE +from lib.align.aligned_face import batch_umeyama +from lib.align.aligned_utils import batch_transform +from lib.align.pose import Batch3D +from lib.image import read_image_meta_batch +from lib.logger import format_array, parse_class_init +from lib.utils import FaceswapError, get_module_objects + +from .augmentation import ImageAugmentation +from .data_set import get_label, get_sorted_images, to_float32 + +if T.TYPE_CHECKING: + import numpy.typing as npt + from lib.align import CenteringType + from plugins.train.trainer.base import TrainConfig + +logger = logging.getLogger(__name__) + + +@dataclass +class BatchMeta: + """Dataclass that holds meta information required for training a batch of images + + All lists are of len(number model outputs per side) with tensors in shape (batch_size, + num_inputs, 1, H, W) + """ + mask_face: list[torch.Tensor] | None = None + """The selected face mask for penalized loss/learn mask for each output in NCHW order""" + mask_eye: list[torch.Tensor] | None = None + """The eye mask if eye loss multipliers > 1 for each output in NCHW order""" + mask_mouth: list[torch.Tensor] | None = None + """The mouth mask if mouth loss multipliers > 1 for each output in NCHW order""" + + def __repr__(self) -> str: + """Pretty print for logging""" + params = ", ".join(f"{k}={None if v is None else [(x.shape, x.dtype) for x in v]}" + for k, v in self.__dict__.items()) + return f"{self.__class__.__name__}({params})" + + def __getitem__(self, key: int) -> BatchMeta: + """Obtain a copy of the BatchMeta object for a specific model input index + + Parameters + ---------- + key + The input id to obtain data for + + Returns + ------- + The meta data for a specific model input. Data will be populated in lists of + length num_outputs in shape (batch_size, 1, H, W) + """ + return BatchMeta(**{k: None if v is None else [x[:, key] for x in v] + for k, v in self.__dict__.items()}) + + def to(self, device: str | torch.Device) -> T.Self: + """Place all contained tensors onto the given device + + Parameters + ---------- + device + The device to place the tensors on to + + Returns + ------- + This object with the tensors placed on the requested device + """ + for k in list(self.__dict__): + v = self.__dict__[k] + if v is None: + continue + self.__dict__[k] = [x.to(device) for x in v] + return self + + +class LandmarkMatcher: + """Prepares landmarks when Warp-to-Landmarks is enabled. + + 2 sides (A/B) only. + + For each side, stores the aligned landmarks for each side and collates the 10 nearest matches + on the other side for random warping + + Parameters + ---------- + folders + Two training folders for sides A and B + size + The aligned face size to transform the landmarks to + centering + The aligned centering to transform the landmarks to + coverage + Additional coverage ratio to be applied + y_offset + Additional vertical offset to be applied + num_choices + Number of choices from the opposite side to cache for each landmark. Default: 10 + """ + def __init__(self, + folders: list[str], + size: int, + centering: CenteringType, + coverage: float, + y_offset: float, + num_choices: int = 10) -> None: + logger.debug(parse_class_init(locals())) + assert len(folders) == 2, ( + f"Warp to landmarks is only compatible with 2 inputs. Got {len(folders)}") + self._folders = folders + self._size = size + self._centering: CenteringType = centering + self._coverage = coverage + self._y_offset = y_offset + self._num_choices = num_choices + + self._padding = round(size * (EXTRACT_RATIOS[centering] + coverage - 1) / (2 * coverage)) + self._scale = self._size - (2 * self._padding) + self._landmarks = self._load_landmarks() + + min_file_count = min([self._landmarks[0].shape[0], self._landmarks[1].shape[0]]) + if self._num_choices > min_file_count: + self._num_choices = min_file_count - 1 + self._closest_indices = self._get_closest_indices() + + def __repr__(self) -> str: + """Pretty print for logging""" + params = {f"{k}"[1:]: v for k, v in self.__dict__.items() + if k in ("_folders", "_size", "_centering", "_coverage", "_y_offset", + "_num_choices")} + s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + + def _landmarks_from_header(self, meta: dict[str, T.Any], filename: str + ) -> npt.NDArray[np.float32]: + """Extract the landmarks from the PNG metadata. + + Returns + ------- + landmarks + The frame space landmarks for a face + filename + The name of the face image that we are loading landmarks for + + Raises + ------ + FaceswapError + If an invalid image is loaded or 68 point landmarks are not used + """ + if "itxt" not in meta or "alignments" not in meta["itxt"]: + raise FaceswapError(f"Invalid face image found. Aborting: '{filename}'") + + retval = np.array(meta["itxt"]["alignments"]["landmarks_xy"], dtype=np.float32) + if LandmarkType.from_shape(retval.shape) != LandmarkType.LM_2D_68: + raise FaceswapError("68 Point facial Landmarks are required for Warp-to-" + f"landmarks. The face that failed was: '{filename}'") + return retval + + def _align_points(self, points: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: + """Normalize and align the landmarks to model input size/coverage/offset + + points + ------ + The (N, 68, 2) landmark points to align + + Returns + ------- + The landmark points aligned to model input + """ + mats = batch_umeyama(points[:, 17:], MEAN_FACE[LandmarkType.LM_2D_51], True) + norm_lms = batch_transform(mats, points) + + rotation, translation = Batch3D.solve_pnp(norm_lms) + offsets = Batch3D.get_offsets(self._centering, rotation, translation) + if self._y_offset: + offsets[:, 1] -= self._y_offset + norm_lms -= offsets[:, None, :] + norm_lms *= self._scale + norm_lms += self._padding + return norm_lms + + def _load_landmarks(self) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.float32]]: + """For each input folder load and align the landmarks for each face + + Returns + ------- + landmarks_a + The aligned landmarks for side A in shape (N, 68, 2) + landmarks_b + The aligned landmarks for side B in shape (N, 68, 2) + """ + landmarks: list[npt.NDArray[np.float32]] = [] + for i, folder in enumerate(self._folders): + side = get_label(i, len(self._folders)) + file_list = get_sorted_images(folder) + lms = np.empty((len(file_list), 68, 2), dtype=np.float32) + for filename, meta in tqdm(read_image_meta_batch(file_list), + desc=f"WTL: Caching Landmarks ({side.upper()})", + total=len(file_list), + leave=False): + lms[file_list.index(filename)] = self._landmarks_from_header(meta, filename) + landmarks.append(self._align_points(lms)) + logger.debug("[LandmarkMatcher] Got landmarks for side %s: %s", + side, format_array(landmarks[-1])) + return landmarks[0], landmarks[1] + + def _get_closest_indices(self) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.int64]]: + """Obtain the closest x number of landmarks from the opposite side + + Returns + ------- + indices_a + Array of size (len(landmarks_a), x) closest B landmarks for each A landmarks + indices_b + Array of size (len(landmarks_b), x) closest A landmarks for each B landmarks + """ + a_count = self._landmarks[0].shape[0] + b_count = self._landmarks[1].shape[0] + lms_a = self._landmarks[0].reshape(a_count, -1) + lms_b = self._landmarks[1].reshape(b_count, -1) + + a_sq = (lms_a ** 2).sum(axis=1, keepdims=True) + b_sq = (lms_b ** 2).sum(axis=1, keepdims=True) + dist2 = a_sq + b_sq.T - 2.0 * (lms_a @ lms_b.T) + np.clip(dist2, 0, None, out=dist2) + matches_a = np.argpartition(dist2, self._num_choices, axis=1)[:, :self._num_choices] + matches_b = np.argpartition(dist2.T, self._num_choices, axis=1)[:, :self._num_choices] + + logger.debug("[TrainLoader] Closest matches. A: %s, B: %s", + format_array(matches_a), format_array(matches_b)) + return matches_a, matches_b + + def get_close_landmarks(self, indices: npt.NDArray[np.int64]) -> npt.NDArray[np.float32]: + """For the given image indices, obtain a randomly selected close match landmarks from the + other side + + Parameters + ---------- + indices + The (num_inputs, landmark_indices) image file indices to obtain the matches for + + Returns + ------- + 2 sets of landmarks in shape (num_sides * batch_size, num_sides, 68, 2) stacked to a batch + of landmark points for augmentation + """ + matches = np.zeros((*indices.shape, 2, 68, 2), dtype=np.float32) + for side_id, ind in enumerate(indices): + src_lms = self._landmarks[side_id][ind] + dst_choices = self._closest_indices[side_id][ind] + idx = np.random.randint(0, dst_choices.shape[1], size=dst_choices.shape[0]) + dst_indices = np.take_along_axis(dst_choices, idx[:, None], axis=1).squeeze(1) + dst_lms = self._landmarks[1 - side_id][dst_indices] + matches[side_id, :, 0] = src_lms + matches[side_id, :, 1] = dst_lms + + retval = matches.reshape((-1, 2, 68, 2)).copy() + logger.trace("[LandmarkMatcher] matched_points: %s", # type:ignore[attr-defined] + format_array(retval)) + return retval + + +class Collate: # pylint:disable=too-many-instance-attributes + """Collation function for processing a batch of samples into input and output tensors applying + augmentation + + Parameters + ---------- + input_size + The pixel size of the model input + output_sizes + The pixel sizes of the model output + color_order + The color order that the model expects + config + The training configuration for the model + landmarks + The landmark matching object for the (A and B) sides of the model if warp_to_landmarks is + enabled otherwise ``None`` + """ + _mask_types = ("mask_face", "mask_eye", "mask_mouth") + """The masks that are stacked to the end of the targets in the order they are stacked""" + + def __init__(self, + input_size: int, + output_sizes: tuple[int, ...], + color_order: T.Literal["bgr", "rgb"], + config: TrainConfig, + landmarks: LandmarkMatcher | None) -> None: + logger.debug(parse_class_init(locals())) + self._name = f"{self.__class__.__name__}" + self._input_size = input_size + self._output_sizes = output_sizes + self._color_order = color_order.lower() + self._config = config + + self._num_inputs = len(config.folders) + self._batch_size = config.batch_size + + # For Warp to Landmarks + self._landmarks = landmarks + + self._process_size = max(*output_sizes, input_size) + self._resize_targets = any(x != self._process_size for x in self._output_sizes) + self._resize_inputs = self._process_size != self._input_size + self._aug = ImageAugmentation(batch_size=self._batch_size * self._num_inputs, + processing_size=self._process_size) + + def __repr__(self) -> str: + """Pretty print for logging""" + params = {f"{k}"[1:]: format_array(v) if isinstance(v, np.ndarray) else v + for k, v in self.__dict__.items() + if k in ("_input_size", "_output_sizes", "_color_order", + "_config", "_landmarks")} + s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + + def _create_targets(self, batch: npt.NDArray[np.uint8] + ) -> tuple[list[torch.Tensor], BatchMeta]: + """ Compile target images, with masks, for the model output sizes. + + Parameters + ---------- + batch + This should be a 4-dimensional array of training images in the format (`batch size`, + `height`, `width`, `channels`). Targets should be requested after performing image + transformations but prior to performing warps. The 4th channel should be the mask. + Any channels above the 4th should be any additional area masks (e.g. eye/mouth) that + are required. + + Returns + ------- + targets + List of len (num_outputs) of target images in shape (batch_size, num_inputs, height, + width, 3) at all model output sizes as float32 0.0 - 1.0 range + meta + Any additional Meta information relating to the batch required for training the model + """ + logger.trace("[%s] Compiling targets: batch shape: %s", # type:ignore[attr-defined] + self._name, batch.shape) + if self._resize_targets: + reshaped = [to_float32(batch if batch.shape[1] == size else + np.array([ + cv2.resize(image, + (size, size), + interpolation=cv2.INTER_AREA) + for image in batch + ])).reshape(self._num_inputs, + self._batch_size, + size, + size, + -1).swapaxes(0, 1) + for size in self._output_sizes] + else: + reshaped = [to_float32(batch).reshape(self._num_inputs, + self._batch_size, + *batch.shape[1:]).swapaxes(0, 1) + for _ in self._output_sizes] + + targets = [torch.from_numpy(out[..., :3]) for out in reshaped] + masks = BatchMeta( + **{self._mask_types[idx]: [torch.from_numpy(out[..., 3 + idx][:, :, None, :, :]) + for out in reshaped] + for idx in range(reshaped[0].shape[-1] - 3)}) + logger.trace("[%s] Processed targets: %s, masks: %s", # type:ignore[attr-defined] + self._name, [t.shape for t in targets], masks) + return targets, masks + + def _get_landmarks_pairs(self, indices: npt.NDArray[np.int64] + ) -> npt.NDArray[np.float32] | None: + """Get a pair of matching source landmarks and closely selected destination landmarks + for Warp to Landmarks for each of the inputs + + Parameters + ---------- + indices + The (num_inputs, batch_size) face file image indices to obtain the landmarks pairs for + + Returns + ------- + 2 sets of landmarks in shape (num_inputs * batch_size, 2, 68, 2). On the 3rd dimension, + position 0 are the source points. position 1 the randomly selected closest match points. + ``None`` if Warp to Landmarks is disabled + """ + if not self._config.warp or self._landmarks is None: + return None + assert indices.shape[0] == 2, "Only 2 inputs allowed for WTL" + return self._landmarks.get_close_landmarks(indices) + + def __call__(self, data: list[tuple[tuple[npt.NDArray[np.uint8], int], ...]] + ) -> tuple[list[torch.Tensor], list[torch.Tensor], BatchMeta]: + """Prepare the loaded samples for feeding the model, creating targets and applying + augmentation + + Parameters + ---------- + data + Batch of data tuples with the loaded stacked image and masks from each loader in the + first position and the image file index for each item in the batch in the 2nd + + Returns + ------- + feed + list of len (num_inputs) tensors of shape(batch_size, H, W, C) inputs for the model + targets + List of len (num_outputs) of target images in shape (batch_size, num_inputs, height, + width, 3) at all model output sizes as float32 0.0 - 1.0 range + meta + The meta information for the batch + """ + shape = data[0][0][0].shape + batch = np.empty((self._num_inputs, self._batch_size, *shape), dtype=np.uint8) + indices = np.empty((self._num_inputs, self._batch_size), dtype=np.int64) + for idx in range(self._num_inputs): + batch[idx] = [d[0][idx] for d in data] + indices[idx] = [d[1][idx] for d in data] + + batch = batch.reshape(-1, *shape) + landmarks = self._get_landmarks_pairs(indices) + + if self._config.augment_color: + batch[..., :3] = self._aug.color_adjust(batch[..., :3]) + + self._aug.transform(batch, landmarks) + + if self._config.flip: + self._aug.random_flip(batch, landmarks) + if self._color_order == "rgb": + batch[..., :3] = batch[..., [2, 1, 0]] + + targets, masks = self._create_targets(batch) + + feed = batch[..., :3] + if self._config.warp and landmarks is not None and self._landmarks is not None: + feed = self._aug.warp(feed, + to_landmarks=True, + batch_src_points=landmarks[:, 0], + batch_dst_points=landmarks[:, 1]) + elif self._config.warp: + feed = self._aug.warp(feed, to_landmarks=False) + + if self._resize_inputs: + feed = to_float32(np.array([cv2.resize(image, + (self._input_size, self._input_size), + interpolation=cv2.INTER_AREA) + for image in feed])) + else: + feed = to_float32(feed) + + feed = feed.reshape(self._num_inputs, self._batch_size, *feed.shape[1:]) + inputs = [torch.from_numpy(x) for x in feed] + return inputs, targets, masks + + +__all__ = get_module_objects(__name__) diff --git a/lib/training/data_set.py b/lib/training/data/data_set.py similarity index 59% rename from lib/training/data_set.py rename to lib/training/data/data_set.py index c87339afd6..a0cfee4c18 100644 --- a/lib/training/data_set.py +++ b/lib/training/data/data_set.py @@ -13,28 +13,19 @@ import numpy as np import torch from torch.utils.data import Dataset -from tqdm import tqdm from lib.align import AlignedFace, Mask -from lib.align.constants import EXTRACT_RATIOS, LandmarkType, MEAN_FACE -from lib.align.aligned_face import batch_umeyama -from lib.align.aligned_utils import batch_transform -from lib.align.pose import Batch3D -from lib.image import read_image_meta_batch from lib.logger import format_array, parse_class_init from lib.image import read_image from lib.utils import FaceswapError, get_module_objects from plugins.train import train_config as cfg -from .data_augmentation import ImageAugmentation if T.TYPE_CHECKING: import numpy.typing as npt - from lib.align import CenteringType from lib.align.objects import MaskAlignmentsFile, PNGAlignments, PNGHeader from lib.align.pose import PoseEstimate - from plugins.train.trainer.base import TrainConfig logger = logging.getLogger(__name__) @@ -103,192 +94,6 @@ def get_sorted_images(folder: str) -> list[str]: if os.path.splitext(f)[-1] == ".png")) -class LandmarkMatcher: - """Prepares landmarks when Warp-to-Landmarks is enabled. - - 2 sides (A/B) only. - - For each side, stores the aligned landmarks for each side and collates the 10 nearest matches - on the other side for random warping - - Parameters - ---------- - folders - Two training folders for sides A and B - size - The aligned face size to transform the landmarks to - centering - The aligned centering to transform the landmarks to - coverage - Additional coverage ratio to be applied - y_offset - Additional vertical offset to be applied - num_choices - Number of choices from the opposite side to cache for each landmark. Default: 10 - """ - def __init__(self, - folders: list[str], - size: int, - centering: CenteringType, - coverage: float, - y_offset: float, - num_choices: int = 10) -> None: - logger.debug(parse_class_init(locals())) - assert len(folders) == 2, ( - f"Warp to landmarks is only compatible with 2 inputs. Got {len(folders)}") - self._folders = folders - self._size = size - self._centering: CenteringType = centering - self._coverage = coverage - self._y_offset = y_offset - self._num_choices = num_choices - - self._padding = round(size * (EXTRACT_RATIOS[centering] + coverage - 1) / (2 * coverage)) - self._scale = self._size - (2 * self._padding) - self._landmarks = self._load_landmarks() - - min_file_count = min([self._landmarks[0].shape[0], self._landmarks[1].shape[0]]) - if self._num_choices > min_file_count: - self._num_choices = min_file_count - 1 - self._closest_indices = self._get_closest_indices() - - def __repr__(self) -> str: - """Pretty print for logging""" - params = {f"{k}"[1:]: v for k, v in self.__dict__.items() - if k in ("_folders", "_size", "_centering", "_coverage", "_y_offset", - "_num_choices")} - s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) - return f"{self.__class__.__name__}({s_params})" - - def _landmarks_from_header(self, meta: dict[str, T.Any], filename: str - ) -> npt.NDArray[np.float32]: - """Extract the landmarks from the PNG metadata. - - Returns - ------- - landmarks - The frame space landmarks for a face - filename - The name of the face image that we are loading landmarks for - - Raises - ------ - FaceswapError - If an invalid image is loaded or 68 point landmarks are not used - """ - if "itxt" not in meta or "alignments" not in meta["itxt"]: - raise FaceswapError(f"Invalid face image found. Aborting: '{filename}'") - - retval = np.array(meta["itxt"]["alignments"]["landmarks_xy"], dtype=np.float32) - if LandmarkType.from_shape(retval.shape) != LandmarkType.LM_2D_68: - raise FaceswapError("68 Point facial Landmarks are required for Warp-to-" - f"landmarks. The face that failed was: '{filename}'") - return retval - - def _align_points(self, points: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: - """Normalize and align the landmarks to model input size/coverage/offset - - points - ------ - The (N, 68, 2) landmark points to align - - Returns - ------- - The landmark points aligned to model input - """ - mats = batch_umeyama(points[:, 17:], MEAN_FACE[LandmarkType.LM_2D_51], True) - norm_lms = batch_transform(mats, points) - - rotation, translation = Batch3D.solve_pnp(norm_lms) - offsets = Batch3D.get_offsets(self._centering, rotation, translation) - if self._y_offset: - offsets[:, 1] -= self._y_offset - norm_lms -= offsets[:, None, :] - norm_lms *= self._scale - norm_lms += self._padding - return norm_lms - - def _load_landmarks(self) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.float32]]: - """For each input folder load and align the landmarks for each face - - Returns - ------- - landmarks_a - The aligned landmarks for side A in shape (N, 68, 2) - landmarks_b - The aligned landmarks for side B in shape (N, 68, 2) - """ - landmarks: list[npt.NDArray[np.float32]] = [] - for i, folder in enumerate(self._folders): - side = get_label(i, len(self._folders)) - file_list = get_sorted_images(folder) - lms = np.empty((len(file_list), 68, 2), dtype=np.float32) - for filename, meta in tqdm(read_image_meta_batch(file_list), - desc=f"WTL: Caching Landmarks ({side.upper()})", - total=len(file_list), - leave=False): - lms[file_list.index(filename)] = self._landmarks_from_header(meta, filename) - landmarks.append(self._align_points(lms)) - logger.debug("[LandmarkMatcher] Got landmarks for side %s: %s", - side, format_array(landmarks[-1])) - return landmarks[0], landmarks[1] - - def _get_closest_indices(self) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.int64]]: - """Obtain the closest x number of landmarks from the opposite side - - Returns - ------- - indices_a - Array of size (len(landmarks_a), x) closest B landmarks for each A landmarks - indices_b - Array of size (len(landmarks_b), x) closest A landmarks for each B landmarks - """ - a_count = self._landmarks[0].shape[0] - b_count = self._landmarks[1].shape[0] - lms_a = self._landmarks[0].reshape(a_count, -1) - lms_b = self._landmarks[1].reshape(b_count, -1) - - a_sq = (lms_a ** 2).sum(axis=1, keepdims=True) - b_sq = (lms_b ** 2).sum(axis=1, keepdims=True) - dist2 = a_sq + b_sq.T - 2.0 * (lms_a @ lms_b.T) - np.clip(dist2, 0, None, out=dist2) - matches_a = np.argpartition(dist2, self._num_choices, axis=1)[:, :self._num_choices] - matches_b = np.argpartition(dist2.T, self._num_choices, axis=1)[:, :self._num_choices] - - logger.debug("[TrainLoader] Closest matches. A: %s, B: %s", - format_array(matches_a), format_array(matches_b)) - return matches_a, matches_b - - def get_close_landmarks(self, indices: npt.NDArray[np.int64]) -> npt.NDArray[np.float32]: - """For the given image indices, obtain a randomly selected close match landmarks from the - other side - - Parameters - ---------- - indices - The (num_inputs, landmark_indices) image file indices to obtain the matches for - - Returns - ------- - 2 sets of landmarks in shape (num_sides * batch_size, num_sides, 68, 2) stacked to a batch - of landmark points for augmentation - """ - matches = np.zeros((*indices.shape, 2, 68, 2), dtype=np.float32) - for side_id, ind in enumerate(indices): - src_lms = self._landmarks[side_id][ind] - dst_choices = self._closest_indices[side_id][ind] - idx = np.random.randint(0, dst_choices.shape[1], size=dst_choices.shape[0]) - dst_indices = np.take_along_axis(dst_choices, idx[:, None], axis=1).squeeze(1) - dst_lms = self._landmarks[1 - side_id][dst_indices] - matches[side_id, :, 0] = src_lms - matches[side_id, :, 1] = dst_lms - - retval = matches.reshape((-1, 2, 68, 2)).copy() - logger.trace("[LandmarkMatcher] matched_points: %s", # type:ignore[attr-defined] - format_array(retval)) - return retval - - class _MaskProcessing: # pylint:disable=too-many-instance-attributes """ Handle the extraction and processing of masks from faceswap PNG headers @@ -820,181 +625,4 @@ def __getitem__(self, index: int) -> tuple[np.ndarray, ...]: return retval -class Collate: # pylint:disable=too-many-instance-attributes - """Collation function for processing a batch of samples into input and output tensors applying - augmentation - - Parameters - ---------- - input_size - The pixel size of the model input - output_sizes - The pixel sizes of the model output - color_order - The color order that the model expects - config - The training configuration for the model - landmarks - The landmark matching object for the (A and B) sides of the model if warp_to_landmarks is - enabled otherwise ``None`` - """ - def __init__(self, - input_size: int, - output_sizes: tuple[int, ...], - color_order: T.Literal["bgr", "rgb"], - config: TrainConfig, - landmarks: LandmarkMatcher | None) -> None: - logger.debug(parse_class_init(locals())) - self._name = f"{self.__class__.__name__}" - self._input_size = input_size - self._output_sizes = output_sizes - self._color_order = color_order.lower() - self._config = config - - self._num_inputs = len(config.folders) - self._batch_size = config.batch_size - - # For Warp to Landmarks - self._landmarks = landmarks - - self._process_size = max(*output_sizes, input_size) - self._resize_targets = any(x != self._process_size for x in self._output_sizes) - self._resize_inputs = self._process_size != self._input_size - self._aug = ImageAugmentation(batch_size=self._batch_size * self._num_inputs, - processing_size=self._process_size) - - def __repr__(self) -> str: - """Pretty print for logging""" - params = {f"{k}"[1:]: format_array(v) if isinstance(v, np.ndarray) else v - for k, v in self.__dict__.items() - if k in ("_input_size", "_output_sizes", "_color_order", - "_config", "_landmarks")} - s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) - return f"{self.__class__.__name__}({s_params})" - - def _create_targets(self, batch: npt.NDArray[np.uint8]) -> list[npt.NDArray[np.float32]]: - """ Compile target images, with masks, for the model output sizes. - - Parameters - ---------- - batch - This should be a 4-dimensional array of training images in the format (`batch size`, - `height`, `width`, `channels`). Targets should be requested after performing image - transformations but prior to performing warps. The 4th channel should be the mask. - Any channels above the 4th should be any additional area masks (e.g. eye/mouth) that - are required. - - Returns - ------- - list - List of (num_inputs, batch_size, height, width, channels) target images, at all model - output sizes, with masks compiled into channels 3+ for each output size as float32 - 0.0 - 1.0 range - """ - logger.trace("[%s] Compiling targets: batch shape: %s", # type:ignore[attr-defined] - self._name, batch.shape) - if self._resize_targets: - retval = [to_float32(batch if batch.shape[1] == size else - np.array([ - cv2.resize(image, - (size, size), - interpolation=cv2.INTER_AREA) - for image in batch - ])).reshape(self._num_inputs, - self._batch_size, - size, - size, - -1) - for size in self._output_sizes] - else: - retval = [to_float32(batch).reshape(self._num_inputs, - self._batch_size, - *batch.shape[1:]) - for _ in self._output_sizes] - - logger.trace("[%s] Processed targets: %s", # type:ignore[attr-defined] - self._name, [t.shape for t in retval]) - return retval - - def _get_landmarks_pairs(self, indices: npt.NDArray[np.int64] - ) -> npt.NDArray[np.float32] | None: - """Get a pair of matching source landmarks and closely selected destination landmarks - for Warp to Landmarks for each of the inputs - - Parameters - ---------- - indices - The (num_inputs, batch_size) face file image indices to obtain the landmarks pairs for - Returns - ------- - 2 sets of landmarks in shape (num_inputs * batch_size, 2, 68, 2). On the 3rd dimension, - position 0 are the source points. position 1 the randomly selected closest match points. - ``None`` if Warp to Landmarks is disabled - """ - if not self._config.warp or self._landmarks is None: - return None - assert indices.shape[0] == 2, "Only 2 inputs allowed for WTL" - return self._landmarks.get_close_landmarks(indices) - - def __call__(self, data: list[tuple[tuple[npt.NDArray[np.uint8], int], ...]] - ) -> tuple[torch.Tensor, list[torch.Tensor]]: - """Prepare the loaded samples for feeding the model, creating targets and applying - augmentation - - Parameters - ---------- - data - Batch of data tuples with the loaded stacked image and masks from each loader in the - first position and the image file index for each item in the batch in the 2nd - - Returns - ------- - feed - The for the (num_inputs, batch_size, H, W, C) inputs for the model - targets - The for the (num_inputs, batch_size, H, W, C) targets for the model - """ - shape = data[0][0][0].shape - batch = np.empty((self._num_inputs, self._batch_size, *shape), dtype=np.uint8) - indices = np.empty((self._num_inputs, self._batch_size), dtype=np.int64) - for idx in range(self._num_inputs): - batch[idx] = [d[0][idx] for d in data] - indices[idx] = [d[1][idx] for d in data] - - batch = batch.reshape(-1, *shape) - landmarks = self._get_landmarks_pairs(indices) - - if self._config.augment_color: - batch[..., :3] = self._aug.color_adjust(batch[..., :3]) - - self._aug.transform(batch, landmarks) - - if self._config.flip: - self._aug.random_flip(batch, landmarks) - if self._color_order == "rgb": - batch[..., :3] = batch[..., [2, 1, 0]] - - targets = self._create_targets(batch) - - feed = batch[..., :3] - if self._config.warp and landmarks is not None and self._landmarks is not None: - feed = self._aug.warp(feed, - to_landmarks=True, - batch_src_points=landmarks[:, 0], - batch_dst_points=landmarks[:, 1]) - elif self._config.warp: - feed = self._aug.warp(feed, to_landmarks=False) - - if self._resize_inputs: - feed = to_float32(np.array([cv2.resize(image, - (self._input_size, self._input_size), - interpolation=cv2.INTER_AREA) - for image in feed])) - else: - feed = to_float32(feed) - - feed = feed.reshape(self._num_inputs, self._batch_size, *feed.shape[1:]) - return torch.from_numpy(feed), [torch.from_numpy(x) for x in targets] - - -get_module_objects(__name__) +__all__ = get_module_objects(__name__) diff --git a/lib/training/data_loader.py b/lib/training/data/loader.py similarity index 70% rename from lib/training/data_loader.py rename to lib/training/data/loader.py index a7e9025324..5f933eaf27 100644 --- a/lib/training/data_loader.py +++ b/lib/training/data/loader.py @@ -2,7 +2,6 @@ """Handles the loading of data for training and previews for faceswap models""" from __future__ import annotations -import abc import logging import os import typing as T @@ -15,71 +14,18 @@ from plugins.train import train_config as mod_cfg from plugins.train.trainer import trainer_config as trn_cfg -from .data_set import Collate, get_label, LandmarkMatcher, TrainSet, PreviewSet, MultiDataset +from .data_set import get_label, TrainSet, PreviewSet, MultiDataset +from .collate import Collate, LandmarkMatcher if T.TYPE_CHECKING: from lib.align.constants import CenteringType from plugins.train.trainer.base import TrainConfig + from .collate import BatchMeta logger = logging.getLogger(__name__) -TargetT = T.TypeVar("TargetT") - -class _Loader(abc.ABC, T.Generic[TargetT]): - """Base class for Training and Preview loaders - - Parameters - ---------- - input_size - The input size to the model - color_order - The color order of the model - sampler - The sampler to use for the data loaders. Default: ``None`` (RandomSampler) - """ - def __init__(self, - input_size: int, - color_order: T.Literal["bgr", "rgb"], - sampler: None | type[tch_data.Sampler] = None) -> None: - self._input_size = input_size - self._color_order: T.Literal["bgr", "rgb"] = T.cast(T.Literal["bgr", "rgb"], - color_order.lower()) - self._sampler = tch_data.RandomSampler if sampler is None else sampler - self._loader = self.get_loader() - self._iterator = T.cast(T.Iterator[tuple[torch.Tensor, torch.Tensor | list[torch.Tensor]]], - iter(self._loader)) - - def __iter__(self) -> T.Self: - """This is an iterator""" - return self - - @abc.abstractmethod - def get_loader(self) -> DataLoader: - """Override to obtain the dataloaders for each input/output for the model - - Returns - ------- - The data loaders in side order (A, B, ...) - """ - - @abc.abstractmethod - def __next__(self) -> tuple[torch.Tensor, TargetT]: - """ Obtain the next batch of data for each side for feeding the model - - Returns - ------- - inputs - The inputs to the model for each side of the model. The array is returned in `(side, - batch_size, *dims)` where `side` 0 is "A" and `side` 1 is "B" etc. - targets - The targets for the model for each side of the model. For each target resolution output - required an array is inserted to the list in format `(side, batch_size, *dims) - where `side` 0 is "A" and `side` 1 is "B" etc. - """ - - -class TrainLoader(_Loader[list[torch.Tensor]]): +class TrainLoader(): # pylint:disable=too-many-instance-attributes """Generator for feeding faceswap models with multiple inputs and outputs. Gets the next items from each of the configured loaders and collates them for feeding into a model @@ -101,7 +47,8 @@ def __init__(self, output_sizes: tuple[int, ...], color_order: T.Literal["bgr", "rgb"], config: TrainConfig, - sampler: None | type[tch_data.Sampler] = None) -> None: + sampler: None | type[tch_data.RandomSampler | + tch_data.DistributedSampler] = None) -> None: logger.debug(parse_class_init(locals())) self._learn_mask = mod_cfg.Loss.learn_mask() self._output_sizes = output_sizes @@ -115,10 +62,21 @@ def __init__(self, T.cast("CenteringType", mod_cfg.centering()), mod_cfg.coverage() / 100., mod_cfg.vertical_offset() / 100.) - super().__init__(input_size, color_order, sampler) - self._iterator: T.Iterator[tuple[torch.Tensor, list[torch.Tensor]]] + + self._input_size = input_size + self._color_order: T.Literal["bgr", "rgb"] = T.cast(T.Literal["bgr", "rgb"], + color_order.lower()) + self._sampler = tch_data.RandomSampler if sampler is None else sampler + self._loader = self.get_loader() + self._iterator = T.cast(T.Iterator[tuple[list[torch.Tensor], + list[torch.Tensor], + "BatchMeta"]], + iter(self._loader)) self._epoch = 0 - self._sampler: type[tch_data.RandomSampler | tch_data.DistributedSampler] + + def __iter__(self) -> T.Self: + """This is an iterator""" + return self def __repr__(self) -> str: """Pretty print for logging""" @@ -161,25 +119,24 @@ def get_loader(self) -> DataLoader: logger.debug("[TrainLoader] Set loader: %s", retval) return retval - def _items_from_loader(self) -> tuple[torch.Tensor, list[torch.Tensor]]: - """Obtain the next outputs from the given loader index - - Parameters - ---------- - index - The index of the loader to retrieve data from where `index` 0 is "A" and `index` 1 is - "B" etc. + def __next__(self) -> tuple[list[torch.Tensor], list[torch.Tensor], BatchMeta]: + """Obtain the next outputs from the loader Returns ------- inputs - The inputs to a side of the model. `(batch_size, *dims)` + list of len (num_inputs) tensors of shape(batch_size, H, W, C) inputs for the model targets - The targets for a side of the model. For each target resolution output - required an array is inserted to the list in format `(batch_size, *dims). + List of len (num_outputs) of target images in shape (batch_size, num_inputs, height, + width, 3) at all model output sizes as float32 0.0 - 1.0 range + meta + The meta information for the batch """ try: - inputs, targets = T.cast(tuple[torch.Tensor, list[torch.Tensor]], next(self._iterator)) + inputs, targets, meta = T.cast(tuple[list[torch.Tensor], + list[torch.Tensor], + "BatchMeta"], + next(self._iterator)) except StopIteration: epoch = self._epoch logger.debug("[TrainLoader] epoch %s end", epoch) @@ -188,38 +145,19 @@ def _items_from_loader(self) -> tuple[torch.Tensor, list[torch.Tensor]]: self._loader.sampler.set_epoch(epoch + 1) T.cast(MultiDataset, self._loader.dataset).shuffle() self._iterator = iter(self._loader) - inputs, targets = next(self._iterator) + inputs, targets, meta = next(self._iterator) self._epoch += 1 if self._learn_mask: # Add the face mask as it's own target - targets += [targets[-1][..., 3][..., None]] + assert meta.mask_face is not None + targets += [meta.mask_face[-1].permute(0, 1, 3, 4, 2)] logger.trace( # type:ignore[attr-defined] - "[TrainLoader] input_shapes: %s, target_shapes: %s", - inputs.shape, [i.shape for i in targets]) - return inputs, targets - - def __next__(self) -> tuple[torch.Tensor, list[torch.Tensor]]: - """ Obtain the next batch of data for each side for feeding the model - - Returns - ------- - inputs - The inputs to the model for each side of the model. The array is returned in `(side, - batch_size, *dims)` where `side` 0 is "A" and `side` 1 is "B" etc. - targets - The targets for the model for each side of the model. For each target resolution output - required an array is inserted to the list in format `(side, batch_size, *dims) - where `side` 0 is "A" and `side` 1 is "B" etc. - """ - items = self._items_from_loader() - inputs = items[0] - targets = items[1] - logger.trace("[TrainLoader] inputs: %s, targets: %s", # type:ignore[attr-defined] - inputs.shape, [t.shape for t in targets]) - return inputs, targets + "[TrainLoader] input_shapes: %s, target_shapes: %s, meta: %s", + [i.shape for i in inputs], [t.shape for t in targets], meta) + return inputs, targets, meta -class PreviewLoader(_Loader[torch.Tensor]): +class PreviewLoader(): """Generator for feeding faceswap models input data for generating preview images. Gets the next items from each of the configured loaders and collates them for feeding into a model @@ -247,15 +185,24 @@ def __init__(self, color_order: T.Literal["bgr", "rgb"], input_folders: list[str], batch_size: int, - sampler: None | type[tch_data.Sampler] = None, + sampler: None | type[tch_data.RandomSampler | tch_data.SequentialSampler] = None, num_samples: int = 0) -> None: self._output_size = output_size self._input_folders = input_folders self._batch_size = batch_size self._num_samples = num_samples - super().__init__(input_size, color_order, sampler) - self._iterator: T.Iterator[tuple[torch.Tensor, torch.Tensor]] - self._sampler: type[tch_data.RandomSampler | tch_data.SequentialSampler] + + self._input_size = input_size + self._color_order: T.Literal["bgr", "rgb"] = T.cast(T.Literal["bgr", "rgb"], + color_order.lower()) + self._sampler = tch_data.RandomSampler if sampler is None else sampler + self._loader = self.get_loader() + self._iterator = T.cast(T.Iterator[tuple[torch.Tensor, torch.Tensor]], + iter(self._loader)) + + def __iter__(self) -> T.Self: + """This is an iterator""" + return self def __repr__(self) -> str: """Pretty print for logging""" diff --git a/lib/training/loss.py b/lib/training/loss.py new file mode 100644 index 0000000000..6c2cedea97 --- /dev/null +++ b/lib/training/loss.py @@ -0,0 +1,347 @@ +#! /usr/env/bin/python3 +"""Handles the collation, weighting masking and calculation of the selected Loss functions for +training Faceswap models""" +from __future__ import annotations + +from dataclasses import dataclass, field +import logging +import typing as T + +import torch +from torch import nn + +from lib.logger import parse_class_init +from lib.model.losses import get_loss_function +from lib.utils import get_module_objects + +if T.TYPE_CHECKING: + from .data import BatchMeta + +logger = logging.getLogger(__name__) + + +@dataclass +class BatchLoss: + """Dataclass for holding Loss values for a batch of data""" + unweighted: list[dict[str, torch.Tensor]] + """For each side output, the unweighted loss scalars for each function for each item in the + batch""" + weighted: list[dict[str, torch.Tensor]] + """For each side output, the weighted loss scalars for each function for each item in the + batch""" + mask: torch.Tensor | None = None + """The loss scalar for the mask for each item in the batch if learn_mask is selected otherwise + ``None``. Default: ``None``""" + _total: torch.Tensor | None = field(init=False, default=None) + + @property + def total(self) -> torch.Tensor: + """The total single weighted loss scalar for all items in the batch for backprop""" + if self._total is None: + total = T.cast(torch.Tensor, sum(sum(y.mean() for y in x.values()) + for x in self.weighted)) + if self.mask is not None: + total += self.mask.mean() + self._total = total + return self._total + + def to_cpu(self) -> T.Self: + """Detaches all contained loss values and moves them to CPU + + Returns + ------- + This object with all tensors detached and moved to CPU + """ + self._total = None if self._total is None else self._total.detach().cpu() + self.unweighted = [{k: v.detach().cpu() for k, v in x.items()} for x in self.unweighted] + self.weighted = [{k: v.detach().cpu() for k, v in x.items()} for x in self.weighted] + self.mask = None if self.mask is None else self.mask.detach().cpu() + return self + + +class LossCollator(nn.Module): + """Compiles the chosen loss functions and calculates the values in the training loop + + Parameters + ---------- + functions + List of lost function names from configuration file to collate for loss calculation + weights + List of weights, corresponding to the the list of functions, to apply to each loss function + use_mask + ``True`` if loss should be masked as `penalize mask loss` has been selected + eye_multiplier + The amount of extra weighting to apply to the eye area + mouth_multiplier + The amount of extra weighting to apply to the mouth area + smallest_output + The smallest output from the model. Required for initializing some loss functions + mask_loss + The loss function to use if learn_mask is enabled. Default: ``None`` (not enabled) + """ + def __init__(self, + functions: list[str], + weights: list[float], + use_mask: bool, + eye_multiplier: float, + mouth_multiplier: float, + smallest_output: int, + mask_loss: str | None = None) -> None: + logger.debug(parse_class_init(locals())) + super().__init__() + self._use_mask = use_mask + self._eye_multiplier = eye_multiplier + self._mouth_multiplier = mouth_multiplier + self._smallest_output = smallest_output + self._mask_loss = mask_loss + self._functions, self._weights = self._configure_functions(functions, weights) + self._spatial, self._non_spatial = self._get_function_types() + + self._mask_loss_function = ( + None if mask_loss is None + else self._functions[mask_loss] if mask_loss in self._functions + else get_loss_function(mask_loss) + ) + + def __repr__(self) -> str: + """Pretty print for logging""" + params = {"functions": list(self._functions), + "weights": list(self._weights.values())} + params |= {k[1:]: v for k, v in self.__dict__.items() + if k in ("_use_mask", "_eye_multiplier", "_mouth_multiplier", + "_smallest_output", "_mask_loss")} + s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) + return f"{self.__class__.__name__}({s_params})" + + @classmethod + def _configure_functions(cls, + names: list[str], + weights: list[float]) -> tuple[nn.ModuleDict, dict[str, float]]: + """Configure the selected loss functions and send to the correct device + + Parameters + ---------- + names + List of lost function names from configuration file to collate for loss calculation + weights + List of weights, corresponding to the the list of functions, to apply to each loss + function + + Returns + ------- + functions + ModuleDict of configured loss functions + weights + dict of loss names to weight to apply + + Raises + ------ + ValueError + If the number of function names and loss weights do not correspond + """ + if len(names) != len(weights): + raise ValueError(f"Number of loss functions ({len(names)}) and weights " + f"({len(weights)}) should match") + + functions = nn.ModuleDict() + weight_dict: dict[str, float] = {} + for name, weight in zip(names, weights): + if name is None or name == "none" or weight <= 0.0: + continue + functions[name] = get_loss_function(name) + weight_dict[name] = weight + + logger.debug("[Loss] Configured loss functions: %s", + {k: (functions[k].__class__.__name__, weight_dict[k]) for k in functions}) + return functions, weight_dict + + def _get_function_types(self) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Run a small tensor through each of the selected loss functions to determine which are + spatial or non-spatial loss functions + + Returns + ------- + spatial + Tuple of loss names that produce spatial output + non_spatial + Tuple of loss names that produce non-spatial output + """ + size = self._smallest_output + dummy_a = torch.rand((1, 3, size, size), dtype=torch.float32) + dummy_b = torch.rand((1, 3, size, size), dtype=torch.float32) + spatial: list[str] = [] + non_spatial: list[str] = [] + for name, func in self._functions.items(): + out = func(dummy_a, dummy_b) + dims = out.ndim + if dims not in (1, 4): + raise RuntimeError("Loss functions should return either spatial output per item " + f"(N, C, H, W) (4 dims) or scalar per item (N, ) (1 dim). " + f"Got {dims} dims for '{name}'") + dst = spatial if dims == 4 else non_spatial + dst.append(name) + + logger.debug("[Loss] spatial: %s, non-spatial: %s", spatial, non_spatial) + return tuple(spatial), tuple(non_spatial) + + def _get_spatial_loss(self, + y_true: torch.Tensor, + y_pred: torch.Tensor, + meta: BatchMeta, + index: int) -> dict[str, torch.Tensor]: + """Obtain the unweighted loss values for the spatial loss functions + + Parameters + ---------- + y_true + The ground truth batch of images + y_pred + The batch of model predictions + meta + The meta information for the batch + index + The output index for obtaining the correct meta data for the processing output + + Returns + ------- + The unweighted loss scalar for each loss function with masks and multipliers applied + """ + retval: dict[str, torch.Tensor] = {} + for name in self._spatial: + loss: torch.Tensor = self._functions[name](y_true, y_pred) + if self._use_mask and meta.mask_face is not None: + loss *= meta.mask_face[index] + if self._eye_multiplier > 1. and meta.mask_eye is not None: + loss += loss * meta.mask_eye[index] * self._eye_multiplier + if self._mouth_multiplier > 1. and meta.mask_mouth is not None: + loss += loss * meta.mask_mouth[index] * self._mouth_multiplier + retval[name] = loss.mean(dim=tuple(range(1, loss.ndim))) + logger.trace("[Loss] Spatial loss: %s", retval) # type:ignore[attr-defined] + return retval + + def _get_masked_inputs(self, + y_true: torch.Tensor, + y_pred: torch.Tensor, + meta: BatchMeta, + index: int + ) -> tuple[list[tuple[torch.Tensor, torch.Tensor]], list[float]]: + """For non spatial loss functions the inputs need to be masked for each supplied masks + + Parameters + ---------- + y_true + The ground truth batch of images + y_pred + The batch of model predictions + meta + The meta information for the batch + index + The output index for obtaining the correct meta data for the processing output + + Returns + ------- + inputs + The (y_true, y_pred) inputs to the loss function for each supplied mask + weights + The weight to be applied for each masked input + """ + weights = [1.0] + assert meta.mask_face is not None + face_mask = meta.mask_face[index] + inputs = [(y_true * face_mask, y_pred * face_mask)] + for m_type in ("eye", "mouth"): + masks: list[torch.Tensor] | None = getattr(meta, f"mask_{m_type}") + if masks is None: + continue + mask = masks[index] + inputs.append((y_true * mask, y_pred * mask)) + weights.append(self._eye_multiplier if m_type == "eye" else self._mouth_multiplier) + logger.trace("[Loss] masked inputs: %s, weights: %s", # type:ignore[attr-defined] + [[x.shape for x in i] for i in inputs], weights) + return inputs, weights + + def _get_non_spatial_loss(self, + y_true: torch.Tensor, + y_pred: torch.Tensor, + meta: BatchMeta, + index: int) -> dict[str, torch.Tensor]: + """Obtain the unweighted loss values for the non-spatial loss functions + + Parameters + ---------- + y_true + The ground truth batch of images + y_pred + The batch of model predictions + meta + The meta information for the batch + index + The output index for obtaining the correct meta data for the processing output + + Returns + ------- + The unweighted loss scalar for each loss function with masks and multipliers applied + """ + retval: dict[str, torch.Tensor] = {} + if not self._use_mask: + inputs = [(y_true, y_pred)] + weights = [1.0] + else: + inputs, weights = self._get_masked_inputs(y_true, y_pred, meta, index) + + for name in self._non_spatial: + losses = torch.stack([self._functions[name](inp_true, inp_pred) * weight + for weight, (inp_true, inp_pred) in zip(weights, inputs)]) + retval[name] = losses.sum(dim=0) + + logger.trace("[Loss] Non-spatial loss: %s", retval) # type:ignore[attr-defined] + return retval + + def forward(self, + y_true_all: list[torch.Tensor], + y_pred_all: list[torch.Tensor], + meta: BatchMeta) -> BatchLoss: + """Call the loss functions, reduce to batch dimension, apply masks and weighting and obtain + the weighted and unweighted per function values and the weighted total loss scalar + + Parameters + ---------- + y_true_all + The ground truth batch of images for all outputs for a side of the model + y_pred_all + The batch of model predictions for all outputs for a side of the model + meta + The meta information for the batch + + Returns + ------- + The loss scalars for the batch + """ + all_unweighted: list[dict[str, torch.Tensor]] = [] + all_weighted: list[dict[str, torch.Tensor]] = [] + mask_loss = None + for idx, (y_true, y_pred) in enumerate(zip(y_true_all, y_pred_all)): + + # TODO remove once channels first + y_true = y_true.permute(0, 3, 1, 2) + y_pred = y_pred.permute(0, 3, 1, 2) + + if y_true.shape[1] == 1: + assert self._mask_loss_function is not None + mask_loss = T.cast(torch.Tensor, self._mask_loss_function(y_true, y_pred)) + mask_loss = mask_loss.mean(dim=tuple(range(1, mask_loss.ndim))) + continue + + unweighted = self._get_spatial_loss(y_true, y_pred, meta, idx) + unweighted |= self._get_non_spatial_loss(y_true, y_pred, meta, idx) + all_unweighted.append(unweighted) + all_weighted.append({k: v * self._weights[k] for k, v in unweighted.items()}) + + retval = BatchLoss(unweighted=all_unweighted, + weighted=all_weighted, + mask=mask_loss) + logger.trace("[Loss] %s", retval) # type:ignore[attr-defined] + return retval + + +__all__ = get_module_objects(__name__) diff --git a/lib/training/lr_finder.py b/lib/training/lr_finder.py index f78522c466..c4f528c871 100644 --- a/lib/training/lr_finder.py +++ b/lib/training/lr_finder.py @@ -18,6 +18,7 @@ from plugins.train import train_config as cfg if T.TYPE_CHECKING: + import torch from keras import optimizers from . import train @@ -123,11 +124,12 @@ def _train(self) -> None: leave=False) for idx in p_bar: loss = self._trainer.train_one_batch() + total_loss = T.cast("torch.Tensor", sum(x.total for x in loss)).item() - if any(np.isnan(x) for x in loss): + if np.isnan(total_loss): logger.warning("NaN detected! Exiting early") break - self._on_batch_end(idx, loss[0]) + self._on_batch_end(idx, total_loss) self._update_description(p_bar) def _rebuild_optimizer(self, optimizer: optimizers.Optimizer) -> optimizers.Optimizer: diff --git a/lib/training/preview.py b/lib/training/preview.py index 34e05a8438..8f08097189 100644 --- a/lib/training/preview.py +++ b/lib/training/preview.py @@ -11,7 +11,7 @@ from lib.logger import format_array, parse_class_init from lib.image import hex_to_rgb from lib.utils import get_module_objects -from lib.training.data_set import get_label +from lib.training.data import get_label if T.TYPE_CHECKING: import numpy.typing as npt diff --git a/lib/training/preview_tk.py b/lib/training/preview_tk.py index c71610231c..25a7f5ba1a 100644 --- a/lib/training/preview_tk.py +++ b/lib/training/preview_tk.py @@ -1,9 +1,8 @@ #!/usr/bin/python -""" The pop up preview window for Faceswap. +"""The pop up preview window for Faceswap. If Tkinter is installed, then this will be used to manage the preview image, otherwise we -fallback to opencv's imshow -""" +fallback to opencv's imshow""" from __future__ import annotations import logging import os @@ -32,13 +31,13 @@ class _Taskbar(): - """ Taskbar at bottom of Preview window + """Taskbar at bottom of Preview window Parameters ---------- - parent: :class:`tkinter.Frame` + parent The parent frame that holds the canvas and taskbar - taskbar: :class:`tkinter.ttk.Frame` or ``None`` + taskbar None if preview is a pop-up window otherwise ttk.Frame if taskbar is managed by the GUI """ def __init__(self, parent: tk.Frame, taskbar: ttk.Frame | None) -> None: @@ -67,60 +66,57 @@ def __init__(self, parent: tk.Frame, taskbar: ttk.Frame | None) -> None: @property def min_scale(self) -> int: - """ int: The minimum allowed scale """ + """The minimum allowed scale""" return self._min_max_scales[0] @property def max_scale(self) -> int: - """ int: The maximum allowed scale """ + """The maximum allowed scale""" return self._min_max_scales[1] @property def save_var(self) -> tk.BooleanVar: - """:class:`tkinter.IntVar`: Variable which is set to ``True`` when the save button has - been. pressed """ + """Variable which is set to ``True`` when the save button has been. pressed""" retval = self._vars["save"] assert isinstance(retval, tk.BooleanVar) return retval @property def scale_var(self) -> tk.StringVar: - """:class:`tkinter.StringVar`: The variable holding the currently selected "##%" formatted - percentage scaling amount displayed in the Combobox. """ + """The variable holding the currently selected "##%" formatted percentage scaling amount + displayed in the Combobox.""" retval = self._vars["scale"] assert isinstance(retval, tk.StringVar) return retval @property def slider_var(self) -> tk.IntVar: - """:class:`tkinter.IntVar`: The variable holding the currently selected percentage scaling - amount in the slider. """ + """The variable holding the currently selected percentage scaling amount in the slider.""" retval = self._vars["slider"] assert isinstance(retval, tk.IntVar) return retval @property def interpolator_var(self) -> tk.IntVar: - """:class:`tkinter.IntVar`: The variable holding the CV2 Interpolator Enum. """ + """The variable holding the CV2 Interpolator Enum.""" retval = self._vars["interpolator"] assert isinstance(retval, tk.IntVar) return retval def _track_widget(self, widget: tk.Widget) -> None: - """ If running embedded in the GUI track the widgets so that they can be destroyed if - the preview is disabled """ + """If running embedded in the GUI track the widgets so that they can be destroyed if + the preview is disabled""" if self._is_standalone: return logger.debug("Tracking option bar widget for GUI: %s", widget) self._gui_mapped.append(widget) def _add_scale_combo(self) -> ttk.Combobox: - """ Add a scale combo for selecting zoom amount. + """Add a scale combo for selecting zoom amount. Returns ------- - :class:`tkinter.ttk.Combobox` - The Combobox widget + The Combobox widget """ logger.debug("Adding scale combo") self.scale_var.set("100%") @@ -136,20 +132,19 @@ def _add_scale_combo(self) -> ttk.Combobox: return scale def _clear_combo_focus(self, *args) -> None: # pylint:disable=unused-argument - """ Remove the highlighting and stealing of focus that the combobox annoyingly - implements. """ + """Remove the highlighting and stealing of focus that the combobox annoyingly + implements.""" logger.debug("Clearing scale combo focus") self._scale.selection_clear() self._scale.winfo_toplevel().focus_set() logger.debug("Cleared scale combo focus") def _add_scale_slider(self) -> tk.Scale: - """ Add a scale slider for zooming the image. + """Add a scale slider for zooming the image. Returns ------- - :class:`tkinter.Scale` - The scale widget + The scale widget """ logger.debug("Adding scale slider") self.slider_var.set(100) @@ -165,7 +160,7 @@ def _add_scale_slider(self) -> tk.Scale: return slider def _add_interpolator_radio(self) -> None: - """ Add a radio box to choose interpolator """ + """Add a radio box to choose interpolator""" frame = tk.Frame(self._frame) for text, mode in self._interpolators: logger.debug("Adding %s radio button", text) @@ -179,35 +174,35 @@ def _add_interpolator_radio(self) -> None: self._track_widget(frame) def _add_save_button(self) -> None: - """ Add a save button for saving out original preview """ + """Add a save button for saving out original preview""" logger.debug("Adding save button") button = tk.Button(self._frame, text="Save", cursor="hand2", command=lambda: self.save_var.set(True)) button.pack(side=tk.LEFT) - logger.debug("Added save burron: '%s'", button) + logger.debug("Added save button: '%s'", button) def _on_slider_update(self, value) -> None: - """ Callback for when the scale slider is adjusted. Adjusts the combo box display to the + """Callback for when the scale slider is adjusted. Adjusts the combo box display to the current slider value. Parameters ---------- - value: int + value The value that the slider has been set to """ self.scale_var.set(f"{value}%") def set_min_max_scale(self, min_scale: int, max_scale: int) -> None: - """ Set the minimum and maximum value that we allow an image to be scaled down to. This + """Set the minimum and maximum value that we allow an image to be scaled down to. This impacts the slider and combo box min/max values: Parameters ---------- - min_scale: int + min_scale The minimum percentage scale that is permitted - max_scale: int + max_scale The maximum percentage scale that is permitted """ logger.debug("Setting min/max scales: (min: %s, max: %s)", min_scale, max_scale) @@ -224,23 +219,29 @@ def set_min_max_scale(self, min_scale: int, max_scale: int) -> None: self._min_max_scales, choices) def cycle_interpolators(self, *args) -> None: # pylint:disable=unused-argument - """ Cycle interpolators on a keypress callback """ + """Cycle interpolators on a keypress callback""" current = next(i for i in self._interpolators if i[1] == self.interpolator_var.get()) next_idx = self._interpolators.index(current) + 1 next_idx = 0 if next_idx == len(self._interpolators) else next_idx self.interpolator_var.set(self._interpolators[next_idx][1]) def destroy_widgets(self) -> None: - """ Remove the taskbar widgets when the preview within the GUI has been disabled """ + """Remove the taskbar widgets when the preview within the GUI has been disabled""" if self._is_standalone: return for widget in reversed(self._gui_mapped): - if widget.winfo_ismapped(): - logger.debug("Removing widget: %s", widget) - widget.pack_forget() - widget.destroy() - del widget + try: + if not widget.winfo_exists(): + continue + if widget.winfo_ismapped(): + logger.debug("Removing widget: %s", widget) + widget.pack_forget() + widget.destroy() + del widget + except tk.TclError: + continue + self._gui_mapped.clear() for var in list(self._vars): logger.debug("Deleting tk variable: %s", var) @@ -248,17 +249,17 @@ def destroy_widgets(self) -> None: class _PreviewCanvas(tk.Canvas): # pylint:disable=too-many-ancestors - """ The canvas that holds the preview image + """The canvas that holds the preview image Parameters ---------- - parent: :class:`tkinter.Frame` + parent The parent frame that will hold the Canvas and taskbar - scale_var: :class:`tkinter.StringVar` + scale_var The variable that holds the value from the scale combo box - screen_dimensions: tuple + screen_dimensions The (`width`, `height`) of the displaying monitor - is_standalone: bool + is_standalone ``True`` if the preview is standalone, ``False`` if it is in the GUI """ def __init__(self, @@ -287,25 +288,25 @@ def __init__(self, @property def image_id(self) -> int: - """ int: The ID of the preview image item within the canvas """ + """The ID of the preview image item within the canvas""" return self._image_id @property def width(self) -> int: - """int: The pixel width of canvas""" + """The pixel width of canvas""" return self.winfo_width() @property def height(self) -> int: - """int: The pixel width of the canvas""" + """The pixel width of the canvas""" return self.winfo_height() def _configure_scrollbars(self, frame: tk.Frame) -> None: - """ Add X and Y scrollbars to the frame and set to scroll the canvas. + """Add X and Y scrollbars to the frame and set to scroll the canvas. Parameters ---------- - frame: :class:`tkinter.Frame` + frame The parent frame to the canvas """ logger.debug("Configuring scrollbars") @@ -319,11 +320,11 @@ def _configure_scrollbars(self, frame: tk.Frame) -> None: logger.debug("Configured scrollbars. x: '%s', y: '%s'", x_scrollbar, y_scrollbar) def _resize(self, event: tk.Event) -> None: # pylint:disable=unused-argument - """ Place the image in center of canvas on resize event and move to top left + """Place the image in center of canvas on resize event and move to top left Parameters ---------- - event: :class:`tkinter.Event` + event The canvas resize event. Unused. """ if self._var_scale.get() == "Fit": # Trigger an update to resize image @@ -346,13 +347,13 @@ def _resize(self, event: tk.Event) -> None: # pylint:disable=unused-argument self.yview_moveto(0.0) def _center_image(self, point_x: float, point_y: float) -> None: - """ Center the image on the canvas on a resize or image update. + """Center the image on the canvas on a resize or image update. Parameters ---------- - point_x: int + point_x The x point to center on - point_y: int + point_y The y point to center on """ canvas_location = (self.canvasx(point_x), self.canvasy(point_y)) @@ -363,13 +364,13 @@ def _center_image(self, point_x: float, point_y: float) -> None: def set_image(self, image: ImageTk.PhotoImage, center_image: bool = False) -> None: - """ Update the canvas with the given image and update area/scrollbars accordingly + """Update the canvas with the given image and update area/scrollbars accordingly Parameters ---------- - image: :class:`ImageTK.PhotoImage` + image The preview image to display in the canvas - bool, optional + center_image ``True`` if the image should be re-centered. Default ``True`` """ logger.debug("Setting canvas image. ID: %s, size: %s for canvas size: %s (recenter: %s)", @@ -389,13 +390,13 @@ def set_image(self, class _Image(): - """ Holds the source image and the resized display image for the canvas + """Holds the source image and the resized display image for the canvas Parameters ---------- - save_variable: :class:`tkinter.BooleanVar` + save_variable Variable that indicates a save preview has been requested in standalone mode - is_standalone: bool + is_standalone ``True`` if the preview is running in standalone mode. ``False`` if it is running in the GUI """ @@ -414,59 +415,58 @@ def __init__(self, save_variable: tk.BooleanVar, is_standalone: bool) -> None: @property def display_image(self) -> ImageTk.PhotoImage: - """ :class:`PIL.ImageTk.PhotoImage`: The current display image """ + """The current display image""" assert self._display is not None return self._display @property def source(self) -> np.ndarray: - """ :class:`PIL.Image.Image`: The current source preview image """ + """The current source preview image""" assert self._source is not None return self._source @property def scale(self) -> int: - """int: The current display scale as a percentage of original image size """ + """The current display scale as a percentage of original image size""" return int(self._scale * 100) def set_source_image(self, name: str, image: np.ndarray) -> None: - """ Set the source image to :attr:`source` + """Set the source image to :attr:`source` Parameters ---------- - name: str + name The name of the preview image to load - image: :class:`numpy.ndarray` + image The image to use in RGB format """ logger.debug("Setting source image. name: '%s', shape: %s", name, image.shape) self._source = image def set_display_image(self) -> None: - """ Obtain the scaled image and set to :attr:`display_image` """ + """Obtain the scaled image and set to :attr:`display_image`""" logger.debug("Setting display image. Scale: %s", self._scale) image = self.source[..., 2::-1] # TO RGB if self._scale not in (0.0, 1.0): # Scale will be 0,0 on initial load in GUI - interp = self._interpolation if self._scale > 1.0 else cv2.INTER_NEAREST + interpolator = self._interpolation if self._scale > 1.0 else cv2.INTER_NEAREST dims = (int(round(self.source.shape[1] * self._scale, 0)), int(round(self.source.shape[0] * self._scale, 0))) - image = cv2.resize(image, dims, interpolation=interp) + image = cv2.resize(image, dims, interpolation=interpolator) self._display = ImageTk.PhotoImage(Image.fromarray(image)) logger.debug("Set display image. Size: %s", (self._display.width(), self._display.height())) def set_scale(self, scale: float) -> bool: - """ Set the display scale to the given value. + """Set the display scale to the given value. Parameters ---------- - scale: float + scale The value to set scaling to Returns ------- - bool - ``True`` if the scale has been changed otherwise ``False`` + ``True`` if the scale has been changed otherwise ``False`` """ if self._scale == scale: return False @@ -475,17 +475,16 @@ def set_scale(self, scale: float) -> bool: return True def set_interpolation(self, interpolation: int) -> bool: - """ Set the interpolation enum to the given value. + """Set the interpolation enum to the given value. Parameters ---------- - interpolation: int + interpolation The value to set interpolation to Returns ------- - bool - ``True`` if the interpolation has been changed otherwise ``False`` + ``True`` if the interpolation has been changed otherwise ``False`` """ if self._interpolation == interpolation: return False @@ -494,11 +493,11 @@ def set_interpolation(self, interpolation: int) -> bool: return True def save_preview(self, *args) -> None: - """ Save out the full size preview to the faceswap folder on a save button press + """Save out the full size preview to the faceswap folder on a save button press Parameters ---------- - args: tuple + args Tuple containing either the key press event (Ctrl+s shortcut), the tk variable arguments (standalone save button press) or the folder location (GUI save button press) """ @@ -508,7 +507,7 @@ def save_preview(self, *args) -> None: if self._is_standalone: root_path = os.path.join(os.path.realpath(os.path.dirname(sys.argv[0]))) else: - root_path = args[0] + root_path = T.cast(str, args[0]) now = datetime.now().strftime("%Y-%m-%d_%H.%M.%S") filename = os.path.join(root_path, f"preview_{now}.png") @@ -521,17 +520,17 @@ def save_preview(self, *args) -> None: class _Bindings(): # pylint:disable=too-few-public-methods - """ Handle Mouse and Keyboard bindings for the canvas. + """Handle Mouse and Keyboard bindings for the canvas. Parameters ---------- - canvas: :class:`_PreviewCanvas` + canvas The canvas that holds the preview image - taskbar: :class:`_Taskbar` + taskbar The taskbar widget which holds the scaling variables - image: :class:`_Image` + image The object which holds the source and display version of the preview image - is_standalone: bool + is_standalone ``True`` if the preview is standalone, ``False`` if it is embedded in the GUI """ def __init__(self, @@ -551,11 +550,11 @@ def __init__(self, logger.debug("Initialized %s", self.__class__.__name__,) def _on_bound_zoom(self, event: tk.Event) -> None: - """ Action to perform on a valid zoom key press or mouse wheel action + """Action to perform on a valid zoom key press or mouse wheel action Parameters ---------- - event: :class:`tkinter.Event` + event The key press or mouse wheel event """ if event.keysym in ("KP_Add", "plus") or event.num == 4 or event.delta > 0: @@ -566,11 +565,11 @@ def _on_bound_zoom(self, event: tk.Event) -> None: self._taskbar.scale_var.set(f"{scale}%") def _on_mouse_click(self, event: tk.Event) -> None: - """ log initial click coordinates for mouse click + drag action + """log initial click coordinates for mouse click + drag action Parameters ---------- - event: :class:`tkinter.Event` + event The mouse event """ self._drag_data = [event.x / self._image.display_image.width(), @@ -579,11 +578,11 @@ def _on_mouse_click(self, event: tk.Event) -> None: event, self._drag_data) def _on_mouse_drag(self, event: tk.Event) -> None: - """ Drag image left, right, up or down + """Drag image left, right, up or down Parameters ---------- - event: :class:`tkinter.Event` + event The mouse event """ location_x = event.x / self._image.display_image.width() @@ -599,11 +598,11 @@ def _on_mouse_drag(self, event: tk.Event) -> None: self._drag_data = [location_x, location_y] def _on_key_move(self, event: tk.Event) -> None: - """ Action to perform on a valid move key press + """Action to perform on a valid move key press Parameters ---------- - event: :class:`tkinter.Event` + event The key press event """ move_axis = self._canvas.xview if event.keysym in ("Left", "Right") else self._canvas.yview @@ -614,7 +613,7 @@ def _on_key_move(self, event: tk.Event) -> None: move_axis(tk.MOVETO, min(1.0, max(0.0, move_axis()[0] + amount))) def _set_mouse_bindings(self) -> None: - """ Set the mouse bindings for interacting with the preview image + """Set the mouse bindings for interacting with the preview image Mousewheel: Zoom in and out Mouse click: Move image @@ -631,7 +630,7 @@ def _set_mouse_bindings(self) -> None: logger.debug("Bound mouse events") def _set_key_bindings(self, is_standalone: bool) -> None: - """ Set the keyboard bindings. + """Set the keyboard bindings. Up/Down/Left/Right: Moves image +/-: Zooms image @@ -640,7 +639,8 @@ def _set_key_bindings(self, is_standalone: bool) -> None: Parameters ---------- - ``True`` if the preview is standalone, ``False`` if it is embedded in the GUI + is_standalone + ``True`` if the preview is standalone, ``False`` if it is embedded in the GUI """ if not is_standalone: # Don't bind keys for GUI as it adds complication @@ -657,19 +657,19 @@ def _set_key_bindings(self, is_standalone: bool) -> None: class PreviewTk(PreviewBase): - """ Holds a preview window for displaying the pop out preview. + """Holds a preview window for displaying the pop out preview. Parameters ---------- - preview_buffer: :class:`PreviewBuffer` + preview_buffer The thread safe object holding the preview images - parent: tkinter widget, optional + parent If this viewer is being called from the GUI the parent widget should be passed in here. If this is a standalone pop-up window then pass ``None``. Default: ``None`` - taskbar: :class:`tkinter.ttk.Frame`, optional + taskbar If this viewer is being called from the GUI the parent's option frame should be passed in here. If this is a standalone pop-up window then pass ``None``. Default: ``None`` - triggers: dict, optional + triggers Dictionary of event triggers for pop-up preview. Not required when running inside the GUI. Default: `None` """ @@ -713,11 +713,11 @@ def __init__(self, @property def master_frame(self) -> tk.Frame: - """ :class:`tkinter.Frame`: The master frame that holds the preview window """ + """The master frame that holds the preview window""" return self._master_frame def pack(self, *args, **kwargs): - """ Redirect calls to pack the widget to pack the actual :attr:`_master_frame`. + """Redirect calls to pack the widget to pack the actual :attr:`_master_frame`. Takes standard :class:`tkinter.Frame` pack arguments """ @@ -725,19 +725,21 @@ def pack(self, *args, **kwargs): self._master_frame.pack(*args, **kwargs) def save(self, location: str) -> None: - """ Save action to be performed when save button pressed from the GUI. + """Save action to be performed when save button pressed from the GUI. - location: str + Parameters + ---------- + location Full path to the folder to save the preview image to """ self._image.save_preview(location) def remove_option_controls(self) -> None: - """ Remove the taskbar options controls when the preview is disabled in the GUI """ + """Remove the taskbar options controls when the preview is disabled in the GUI""" self._taskbar.destroy_widgets() def _output_helptext(self) -> None: - """ Output the keybindings to Console. """ + """Output the keybindings to Console.""" if not self._is_standalone: return logger.info("---------------------------------------------------") @@ -749,7 +751,7 @@ def _output_helptext(self) -> None: logger.info("---------------------------------------------------") def _get_geometry(self) -> tuple[int, int]: - """ Obtain the geometry of the current screen (standalone) or the dimensions of the widget + """Obtain the geometry of the current screen (standalone) or the dimensions of the widget holding the preview window (GUI). Just pulling screen width and height does not account for multiple monitors, so dummy in a @@ -757,8 +759,7 @@ def _get_geometry(self) -> tuple[int, int]: Returns ------- - Tuple - The (`width`, `height`) of the current monitor's display + The (`width`, `height`) of the current monitor's display """ if not self._is_standalone: root = self._root.winfo_toplevel() # Get dims of whole GUI @@ -778,7 +779,7 @@ def _get_geometry(self) -> tuple[int, int]: return retval def _set_min_max_scales(self) -> None: - """ Set the minimum and maximum area that we allow to scale image to. """ + """Set the minimum and maximum area that we allow to scale image to.""" logger.debug("Calculating minimum scale for screen dimensions %s", self._screen_dimensions) half_screen = tuple(x // 2 for x in self._screen_dimensions) min_scales = (half_screen[0] / self._image.source.shape[1], @@ -796,7 +797,7 @@ def _set_min_max_scales(self) -> None: self._taskbar.set_min_max_scale(min_scale, max_scale) def _initialize_window(self) -> None: - """ Initialize the window to fit into the current screen """ + """Initialize the window to fit into the current screen""" logger.debug("Initializing window") assert isinstance(self._root, tk.Tk) width = min(self._master_frame.winfo_reqwidth(), self._screen_dimensions[0]) @@ -809,11 +810,13 @@ def _initialize_window(self) -> None: logger.debug("Initialized window: (width: %s, height: %s)", width, height) def _update_image(self, center_image: bool = False) -> None: - """ Update the image displayed in the canvas and set the canvas size and scroll region + """Update the image displayed in the canvas and set the canvas size and scroll region accordingly - center_image: bool = ``True`` - ``True`` if the image in the canvas should be recentered. Defaul:``True`` + Parameters + ---------- + center_image + ``True`` if the image in the canvas should be re-centered. Default:``True`` """ logger.debug("Updating image (center_image: %s)", center_image) self._image.set_display_image() @@ -821,13 +824,12 @@ def _update_image(self, center_image: bool = False) -> None: logger.debug("Updated image") def _convert_fit_scale(self) -> str: - """ Convert "Fit" scale to the actual scaling amount + """Convert "Fit" scale to the actual scaling amount Returns ------- - str - The fit scaling in '##%' format - """ + The fit scaling in '##%' format + """ logger.debug("Converting 'Fit' scaling") width_scale = self._canvas.width / self._image.source.shape[1] height_scale = self._canvas.height / self._image.source.shape[0] @@ -838,11 +840,11 @@ def _convert_fit_scale(self) -> str: return retval def _set_scale(self, *args) -> None: # pylint:disable=unused-argument - """ Update the image on a scale request """ - txtscale = self._taskbar.scale_var.get() - logger.debug("Setting scale: '%s'", txtscale) - txtscale = self._convert_fit_scale() if txtscale == "Fit" else txtscale - scale = int(txtscale[:-1]) # Strip percentage and convert to int + """Update the image on a scale request""" + txt_scale = self._taskbar.scale_var.get() + logger.debug("Setting scale: '%s'", txt_scale) + txt_scale = self._convert_fit_scale() if txt_scale == "Fit" else txt_scale + scale = int(txt_scale[:-1]) # Strip percentage and convert to int logger.debug("Got scale: %s", scale) if self._image.set_scale(scale / 100): @@ -851,14 +853,14 @@ def _set_scale(self, *args) -> None: # pylint:disable=unused-argument self._update_image(center_image=True) def _set_interpolation(self, *args) -> None: # pylint:disable=unused-argument - """ Callback for when the interpolator is change""" - interp = self._taskbar.interpolator_var.get() - if not self._image.set_interpolation(interp) or self._image.scale <= 1.0: + """Callback for when the interpolator is change""" + interpolator = self._taskbar.interpolator_var.get() + if not self._image.set_interpolation(interpolator) or self._image.scale <= 1.0: return self._update_image(center_image=False) def _process_triggers(self) -> None: - """ Process the standard faceswap key press triggers: + """Process the standard faceswap key press triggers: m = toggle_mask r = refresh @@ -870,18 +872,18 @@ def _process_triggers(self) -> None: logger.debug("Processing triggers") root = self._canvas.winfo_toplevel() for key in self._keymaps: - bindkey = "Return" if key == "enter" else key - logger.debug("Adding trigger for key: '%s'", bindkey) + bind_key = "Return" if key == "enter" else key + logger.debug("Adding trigger for key: '%s'", bind_key) - root.bind(f"<{bindkey}>", self._on_keypress) + root.bind(f"<{bind_key}>", self._on_keypress) logger.debug("Processed triggers") def _on_keypress(self, event: tk.Event) -> None: - """ Update the triggers on a keypress event for picking up by main faceswap process. + """Update the triggers on a keypress event for picking up by main faceswap process. Parameters ---------- - event: :class:`tkinter.Event` + event The valid preview trigger keypress """ if self._triggers is None: # Don't need triggers for GUI @@ -897,7 +899,7 @@ def _on_keypress(self, event: tk.Event) -> None: logger.debug("Processed keypress '%s'. Set event for '%s'", key, self._keymaps[key]) def _display_preview(self) -> None: - """ Handle the displaying of the images currently in :attr:`_preview_buffer`""" + """Handle the displaying of the images currently in :attr:`_preview_buffer`""" if self._should_shutdown: self._root.destroy() @@ -927,7 +929,7 @@ def _display_preview(self) -> None: def main(): - """ Load image from first given argument and display + """Load image from first given argument and display python -m lib.training.preview_tk """ @@ -936,6 +938,7 @@ def main(): log_setup("DEBUG", "faceswap_preview.log", "Test", False) img = cv2.imread(sys.argv[-1], cv2.IMREAD_UNCHANGED) + assert img is not None buff = PreviewBuffer() # pylint:disable=used-before-assignment buff.add_image("test_image", img) PreviewTk(buff) diff --git a/lib/training/tensorboard.py b/lib/training/tensorboard.py index 07ed576a26..47765ca7e6 100644 --- a/lib/training/tensorboard.py +++ b/lib/training/tensorboard.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -""" Tensorboard call back for PyTorch logging. Hopefully temporary until a native Keras version -is implemented """ +"""Tensorboard call back for PyTorch logging. Hopefully temporary until a native Keras version +is implemented""" from __future__ import annotations import logging @@ -18,16 +18,19 @@ class RecordIterator: - """ A replacement for tensorflow's :func:`compat.v1.io.tf_record_iterator` + """A replacement for tensorflow's :func:`compat.v1.io.tf_record_iterator` Parameters ---------- - log_file : str + log_file The event log file to obtain records from - is_live : bool, optional + is_live ``True`` if the log file is for a live training session that will constantly provide data. Default: ``False`` """ + _max_record_size = 1024 ** 3 + """Maximum size for a TFRecord. Caps at 1GB to protect against nonsense length bytes""" + def __init__(self, log_file, is_live: bool = False) -> None: logger.debug(parse_class_init(locals())) self._file_path = log_file @@ -37,12 +40,12 @@ def __init__(self, log_file, is_live: bool = False) -> None: logger.debug("Initialized %s", self.__class__.__name__) def __iter__(self) -> RecordIterator: - """ Iterate over a Tensorboard event file""" + """Iterate over a Tensorboard event file""" return self def _on_file_read(self) -> None: - """ If the file is closed and we are reading live data, re-open the file and seek to the - correct position """ + """If the file is closed and we are reading live data, re-open the file and seek to the + correct position""" if not self._is_live or not self._log_file.closed: return @@ -52,7 +55,7 @@ def _on_file_read(self) -> None: self._log_file.seek(self._position, 0) def _on_file_end(self) -> None: - """ Close the event file. If live data, record the current position""" + """Close the event file. If live data, record the current position""" if self._is_live: self._position = self._log_file.tell() logger.trace("Setting live position to %s", # type:ignore[attr-defined] @@ -62,12 +65,11 @@ def _on_file_end(self) -> None: self._log_file.close() def __next__(self) -> bytes: - """ Get the next event log from a Tensorboard event file + """Get the next event log from a Tensorboard event file Returns ------- - bytes - A Tensorboard event log + A Tensorboard event log Raises ------ @@ -76,17 +78,30 @@ def __next__(self) -> bytes: """ self._on_file_read() + record_start = self._log_file.tell() b_header = self._log_file.read(8) - if not b_header: + if len(b_header) < 8: # Partial header. Rewind for next call + self._log_file.seek(record_start, 0) self._on_file_end() raise StopIteration read_len = int(struct.unpack('Q', b_header)[0]) - self._log_file.seek(4, 1) + if read_len > self._max_record_size: + logger.debug("Implausible record length %s in '%s' at offset %s; treating as partial " + "and stopping.", read_len, self._file_path, record_start) + self._log_file.seek(record_start, 0) + self._on_file_end() + raise StopIteration + + len_crc = self._log_file.read(4) data = self._log_file.read(read_len) + data_crc = self._log_file.read(4) + if len(len_crc) < 4 or len(data) < read_len or len(data_crc) < 4: # Partial read + self._log_file.seek(record_start, 0) + self._on_file_end() + raise StopIteration - self._log_file.seek(4, 1) logger.trace("Returning event data of len %s", read_len) # type:ignore[attr-defined] return data @@ -98,14 +113,14 @@ class TorchTensorBoard(keras.callbacks.Callback): Parameters ---------- - log_dir str + log_dir The path of the directory where to save the log files to be parsed by TensorBoard. e.g., `log_dir = os.path.join(working_dir, 'logs')`. This directory should not be reused by any other callbacks. - write_graph: bool (Not supported at this time) + write_graph Whether to visualize the graph in TensorBoard. Note that the log file can become quite - large when `write_graph` is set to `True`. - update_freq: Literal["batch", "epoch"] | int + large when `write_graph` is set to `True`. Note: Not supported at this time + update_freq When using `"epoch"`, writes the losses and metrics to TensorBoard after every epoch. If using an integer, let's say `1000`, all metrics and losses (including custom ones added by `Model.compile`) will be logged to TensorBoard every 1000 batches. `"batch"` @@ -116,7 +131,6 @@ class TorchTensorBoard(keras.callbacks.Callback): Scalars tutorial](https://www.tensorflow.org/tensorboard/scalars_and_keras#batch-level_logging) """ - def __init__(self, log_dir: str = "logs", write_graph: bool = True, @@ -139,7 +153,7 @@ def __init__(self, @property def _train_writer(self) -> SummaryWriter: - """:class:`torch.utils.tensorboard.SummaryWriter`: The summary writer """ + """The summary writer""" if "train" not in self._writers: self._writers["train"] = SummaryWriter(self._train_dir) return self._writers["train"] @@ -160,7 +174,7 @@ def set_model(self, model: keras.models.Model) -> None: Parameters ---------- - model: :class:`keras.models.Model` + model The model that is being trained """ self._model = model @@ -170,24 +184,26 @@ def set_model(self, model: keras.models.Model) -> None: self._should_write_train_graph = True def on_train_begin(self, logs=None) -> None: - """ Initialize the call back on train start + """Initialize the call back on train start Parameters ---------- - logs: None + logs Unused """ self._global_train_batch = 0 self._previous_epoch_iterations = 0 - def on_train_batch_end(self, batch: int, logs: dict[str, float] | None = None) -> None: - """ Update Tensorboard logs on batch end + def on_train_batch_end(self, + batch: int, + logs: dict[str, float | dict[str, float]] | None = None) -> None: + """Update Tensorboard logs on batch end Parameters ---------- - batch: int + batch The current iteration count - logs: dict[str, float] + logs The logs to write """ assert logs is not None @@ -196,21 +212,26 @@ def on_train_batch_end(self, batch: int, logs: dict[str, float] | None = None) - self._should_write_train_graph = False for key, value in logs.items(): - self._train_writer.add_scalar(f"batch_{key}", - value, - global_step=batch) + tag = f"batch_{key}" + if isinstance(value, float): + self._train_writer.add_scalar(tag, value, global_step=batch) + elif isinstance(value, dict): + for k, v in value.items(): + self._train_writer.add_scalar(f"{tag}/{k}", v, global_step=batch) + else: + raise ValueError(f"Unhandled Tensorboard data: {key}: {value}") def on_save(self) -> None: - """ Flush data to disk on save """ + """Flush data to disk on save""" logger.debug("Flushing Tensorboard writer") self._train_writer.flush() def on_train_end(self, logs=None) -> None: - """ Close the writer on train completion + """Close the writer on train completion Parameters ---------- - logs: None + logs Unused """ for writer in self._writers.values(): diff --git a/lib/training/train.py b/lib/training/train.py index 16310fbb5e..6b055e24c4 100644 --- a/lib/training/train.py +++ b/lib/training/train.py @@ -15,18 +15,22 @@ from torch.cuda import OutOfMemoryError from lib.logger import format_array, parse_class_init +from lib.torch_utils import get_device from lib.training import LearningRateFinder, LearningRateWarmup from lib.training.preview import Samples -from lib.training.data_loader import PreviewLoader, TrainLoader +from lib.training.data import get_label, PreviewLoader, TrainLoader from lib.training.tensorboard import TorchTensorBoard from lib.utils import get_module_objects, FaceswapError from plugins.train import train_config as mod_cfg from plugins.train.trainer import trainer_config as trn_cfg +from .loss import LossCollator + if T.TYPE_CHECKING: import numpy.typing as npt from collections.abc import Callable from plugins.train.trainer.base import TrainerBase + from .loss import BatchLoss logger = logging.getLogger(__name__) @@ -66,8 +70,10 @@ def __init__(self, self._timelapse_folders = [] if timelapse_folders is None else timelapse_folders self._timelapse_output = timelapse_output + self._device = get_device() self._model = plugin.model self._out_size = max(x[1] for x in self._model.output_shapes if x[-1] != 1) + self._configure_model(plugin) self._train_loader = self._get_train_loader() self._preview_loader = self._get_preview_loader() @@ -99,6 +105,33 @@ def exit_early(self) -> bool: """``True`` if the trainer should exit early, without performing any training steps""" return self._exit_early + def _configure_model(self, plugin: TrainerBase): + """Add the loss functions to the model and move to the correct device + + Parameters + ---------- + plugin + The plugin that is training the model + """ + loss = LossCollator( + functions=[mod_cfg.Loss.loss_function(), + mod_cfg.Loss.loss_function_2(), + mod_cfg.Loss.loss_function_3(), + mod_cfg.Loss.loss_function_4()], + weights=[1.0, + mod_cfg.Loss.loss_weight_2() / 100., + mod_cfg.Loss.loss_weight_3() / 100., + mod_cfg.Loss.loss_weight_4() / 100.], + use_mask=mod_cfg.Loss.penalized_mask_loss(), + eye_multiplier=mod_cfg.Loss.eye_multiplier(), + mouth_multiplier=mod_cfg.Loss.mouth_multiplier(), + smallest_output=min(x[1] for x in self._model.output_shapes + if x[-1] != 1), + mask_loss=(None if not mod_cfg.Loss.learn_mask() + else mod_cfg.Loss.mask_loss_function())) + plugin.register_loss(loss) + plugin.model.model.to(self._device) + def _get_train_loader(self) -> TrainLoader: """Get the loaders for training the model @@ -246,19 +279,19 @@ def toggle_mask(self) -> None: """Toggle the mask overlay on or off based on user input.""" self._samples.toggle_mask_display() - def train_one_batch(self) -> np.ndarray: + def train_one_batch(self) -> list[BatchLoss]: """Process a single batch through the model and obtain the loss Returns ------- - The total loss in the first position then A losses, by output order, then B losses, by - output order + The collated loss values detached and moved to CPU in order (A, B, ...) """ try: - inputs, targets = next(self._train_loader) - loss_t = self._plugin.train_batch(inputs, targets) - loss_cpu = loss_t.detach().cpu().numpy() - retval = np.array([sum(loss_cpu), *loss_cpu]) + inputs, targets, meta = next(self._train_loader) + loss = self._plugin.train_batch([i.to(self._device) for i in inputs], + [t.to(self._device) for t in targets], + meta.to(self._device)) + retval = [x.to_cpu() for x in loss] except OutOfMemoryError as err: msg = ("You do not have enough GPU memory available to train the selected model at " "the selected settings. You can try a number of things:" @@ -272,24 +305,33 @@ def train_one_batch(self) -> np.ndarray: raise FaceswapError(msg) from err return retval - def _log_tensorboard(self, loss: np.ndarray) -> None: + def _log_tensorboard(self, loss: list[BatchLoss]) -> None: """Log current loss to Tensorboard log files Parameters ---------- loss - The total loss in the first position then A losses, by output order, then B losses, by - output order + The loss scalars for the batch detached and moved to cpu in order (A, B, ...) """ if not self._tensorboard: return - logger.trace("[Trainer] Updating TensorBoard log") # type: ignore - logs = {log[0]: float(log[1]) - for log in zip(self._model.state.loss_names, loss)} - + logger.trace("[Trainer] Updating TensorBoard log: %s", loss) # type: ignore + logs: dict[str, float | dict[str, float]] = { + "total": T.cast(torch.Tensor, sum(x.total for x in loss)).item()} + for i, out in enumerate(loss): + lbl = get_label(i, len(loss)) + for idx, (w, u) in enumerate(zip(out.weighted, out.unweighted)): + key = lbl if len(out.unweighted) == 1 else f"{lbl}_{idx}" + weighted = {k: v.mean() for k, v in w.items()} + unweighted = {k: v.mean() for k, v in u.items()} + logs[f"face_{key}"] = T.cast(torch.Tensor, sum(weighted.values())).item() + logs[f"weighted_{key}"] = {k: v.item() for k, v in weighted.items()} + logs[f"unweighted_{key}"] = {k: v.item() for k, v in unweighted.items()} + if out.mask is not None: + logs[f"mask_{lbl}"] = out.mask.mean().item() self._tensorboard.on_train_batch_end(self._model.iterations, logs=logs) - def _collate_and_store_loss(self, loss: np.ndarray) -> np.ndarray: + def _collate_and_store_loss(self, loss: list[BatchLoss]) -> np.ndarray: """Collate the loss into totals for each side. The losses are summed into a total for each side. Loss totals are added to @@ -300,8 +342,7 @@ def _collate_and_store_loss(self, loss: np.ndarray) -> np.ndarray: Parameters ---------- loss - The total loss in the first position then A losses, by output order, then B losses, by - output order + The list of loss scalars in order (A, B, ...) Returns ------- @@ -313,13 +354,22 @@ def _collate_and_store_loss(self, loss: np.ndarray) -> np.ndarray: If a NaN is detected, a :class:`FaceswapError` will be raised """ # NaN protection - if mod_cfg.nan_protection() and not all(np.isfinite(val) for val in loss): - logger.critical("NaN Detected. Loss: %s", loss) + if mod_cfg.nan_protection() and not all(torch.isfinite(val.total).all() for val in loss): + loss_str = ", ".join(f"Loss {get_label(i, len(loss))}: {round(x.total.item(), 6)}" + for i, x in enumerate(loss)) + msg = f"NaN Detected. {loss_str}" + failed = ", ".join(f"{key}({get_label(i, len(loss))})" + for i, out in enumerate(loss) + for unweighted in out.unweighted + for key, sub_loss in unweighted.items() + if not torch.isfinite(sub_loss).all()) + if failed: + msg += f". The loss function(s) that NaN'd: {failed}" + logger.critical(msg) raise FaceswapError("A NaN was detected and you have NaN protection enabled. Training " "has been terminated.") - split = len(loss) // 2 - combined_loss = np.array([sum(loss[:split]), sum(loss[split:])]) + combined_loss = np.array([x.total.item() for x in loss], dtype=np.float32) self._model.add_history(combined_loss) logger.trace("[Trainer] original loss: %s, combined_loss: %s", # type:ignore[attr-defined] loss, combined_loss) @@ -480,8 +530,8 @@ def train_one_step(self, self._warmup() loss = self.train_one_batch() self._log_tensorboard(loss) - loss = self._collate_and_store_loss(loss[1:]) - self._print_loss(loss) + total_loss = self._collate_and_store_loss(loss) + self._print_loss(total_loss) if do_snapshot: self._model.io.snapshot() self._update_viewers(viewer, do_timelapse) diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index c961644c1d..c747505e9c 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -17,7 +17,7 @@ from .inference import Inference from .io import IO, get_all_sub_models, Weights -from .settings import Loss, Optimizer, Settings +from .settings import Optimizer, Settings from .state import State if T.TYPE_CHECKING: @@ -84,8 +84,6 @@ def __init__(self, self._settings = Settings(self._args, self._mixed_precision, self._is_predict) - self._loss = Loss(self.color_order) - logger.debug("Initialized ModelBase (%s)", self.__class__.__name__) @property @@ -309,11 +307,7 @@ def _compile_model(self) -> None: weights = Weights(self) weights.load(self._io.model_exists) weights.freeze() - - self._loss.configure(self.model) - losses = list(self._loss.functions.values()) - self.model.compile(optimizer=optimizer, loss=losses) - self._state.add_session_loss_names(self._loss.names) + self.model.compile(optimizer=optimizer) logger.debug("Compiled Model: %s", self.model) def add_history(self, loss: np.ndarray) -> None: diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 7cb1f5e10f..5887ecb65f 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -6,273 +6,31 @@ :class:`~plugins.train.model._base.ModelBase` Handles configuration of model plugins for: - - Loss configuration - Optimizer settings - General global model configuration settings """ from __future__ import annotations -from dataclasses import dataclass, field import logging import typing as T import keras from keras import config as k_config, dtype_policies, optimizers -import torch -from torch import nn -from lib.model import losses from lib.model.optimizers import AdaBelief from lib.model.autoclip import AutoClipper from lib.model.nn_blocks import reset_naming from lib.logger import parse_class_init -from lib.torch_utils import get_device from lib.utils import get_module_objects -from plugins.train.train_config import Loss as cfg_loss, Optimizer as cfg_opt +from plugins.train.train_config import Optimizer as cfg_opt if T.TYPE_CHECKING: from collections.abc import Callable from argparse import Namespace - from keras import KerasTensor from .state import State logger = logging.getLogger(__name__) -@dataclass -class LossClass: - """Typing class for holding loss functions. - - Parameters - ---------- - object - The class object that contains the function that takes in the true/predicted images and - returns the loss - kwargs - Any keyword arguments to supply to the loss function at initialization. - """ - function: type[nn.Module] = nn.MSELoss - kwargs: dict[str, T.Any] = field(default_factory=dict) - - -class Loss(): - """Holds loss names and functions for an Autoencoder. - - Parameters - ---------- - color_order - Color order of the model. One of `"BGR"` or `"RGB"` - """ - def __init__(self, color_order: T.Literal["bgr", "rgb"]) -> None: - logger.debug(parse_class_init(locals())) - self._mask_channels = self._get_mask_channels() - self._inputs: list[keras.layers.Layer] = [] - self._names: list[str] = [] - self._functions: dict[str, losses.LossWrapper | T.Callable[[torch.Tensor, torch.Tensor], - torch.Tensor]] = {} - - self._loss_dict = {"ffl": LossClass(function=losses.FocalFrequencyLoss), - "flip": LossClass(function=losses.LDRFLIPLoss, - kwargs={"color_order": color_order}), - "gmsd": LossClass(function=losses.GMSDLoss), - "l_inf_norm": LossClass(function=losses.LInfNorm), - "laploss": LossClass(function=losses.LaplacianPyramidLoss), - "logcosh": LossClass(function=losses.LogCosh), - "lpips_alex": LossClass(function=losses.LPIPSLoss, - kwargs={"trunk_network": "alex", - "crop": True, - "color_order": color_order}), - "lpips_squeeze": LossClass(function=losses.LPIPSLoss, - kwargs={"trunk_network": "squeeze", - "crop": True, - "color_order": color_order}), - "lpips_vgg16": LossClass(function=losses.LPIPSLoss, - kwargs={"trunk_network": "vgg16", - "crop": True, - "color_order": color_order}), - "ms_ssim": LossClass(function=losses.MSSIMLoss), - "mae": LossClass(function=nn.MSELoss, - kwargs={"reduction": "none"}), - "mse": LossClass(function=nn.L1Loss, - kwargs={"reduction": "none"}), - "pixel_gradient_diff": LossClass(function=losses.GradientLoss), - "ssim": LossClass(function=losses.DSSIMObjective), - "smooth_loss": LossClass(function=losses.GeneralizedLoss)} - - logger.debug("Initialized: %s", self.__class__.__name__) - - @property - def names(self) -> list[str]: - """The loss function names""" - return self._names - - @property - def functions(self) -> dict[str, losses.LossWrapper | T.Callable[[torch.Tensor, torch.Tensor], - torch.Tensor]]: - """The loss functions that apply to each model output.""" - return self._functions - - @property - def _mask_inputs(self) -> list | None: - """The list of input tensors to the model that contain the mask. Returns ``None`` if there - is no mask input to the model.""" - mask_inputs = [inp for inp in self._inputs if inp.name.startswith("mask")] - return None if not mask_inputs else mask_inputs - - @property - def _mask_shapes(self) -> list[tuple] | None: - """The list of shape tuples for the mask input tensors for the model. Returns ``None`` if - there is no mask input.""" - if self._mask_inputs is None: - return None - return [mask_input.shape for mask_input in self._mask_inputs] - - def configure(self, model: keras.models.Model) -> None: - """Configure the loss functions for the given inputs and outputs. - - Parameters - ---------- - model - The model that is to be trained - """ - self._inputs = model.inputs - self._set_loss_names(model.outputs) - self._set_loss_functions(model.output_names) - self._names.insert(0, "total") - - def _set_loss_names(self, outputs: list[KerasTensor]) -> None: - """Name the losses based on model output. - - This is used for correct naming in the state file, for display purposes only. - - Adds the loss names to :attr:`names` - - Parameters - ---------- - A list of output tensors from the model plugin - """ - # TODO Use output names if/when these are fixed upstream - split_outputs = [outputs[:len(outputs) // 2], outputs[len(outputs) // 2:]] - for side, side_output in zip(("a", "b"), split_outputs): - output_names = [output.name for output in side_output] - output_shapes = [output.shape[1:] for output in side_output] - output_types = ["mask" if shape[-1] == 1 else "face" for shape in output_shapes] - logger.debug("side: %s, output names: %s, output_shapes: %s, output_types: %s", - side, output_names, output_shapes, output_types) - for idx, name in enumerate(output_types): - suffix = "" if output_types.count(name) == 1 else f"_{idx}" - self._names.append(f"{name}_{side}{suffix}") - logger.debug(self._names) - - def _get_function(self, name: str) -> Callable[[torch.Tensor, torch.Tensor], torch.Tensor]: - """Obtain the requested Loss function - - Parameters - ---------- - name - The name of the loss function from the training configuration file - - Returns - ------- - The requested loss function - """ - func = self._loss_dict[name] - retval = func.function(**func.kwargs).to(get_device()) - logger.debug("Obtained loss function `%s` (%s)", name, retval) - return retval - - def _set_loss_functions(self, output_names: list[str]) -> None: - """Set the loss functions and their associated weights. - - Adds the loss functions to the :attr:`functions` dictionary. - - Parameters - ---------- - output_names - The output names from the model - """ - loss_functions = [cfg_loss.loss_function(), - cfg_loss.loss_function_2(), - cfg_loss.loss_function_3(), - cfg_loss.loss_function_4()] - loss_amount = [100, - cfg_loss.loss_weight_2(), - cfg_loss.loss_weight_3(), - cfg_loss.loss_weight_4()] - face_losses = [(name, weight) for name, weight in zip(loss_functions, loss_amount) - if name != "none" and weight > 0] - - for name, output_name in zip(self._names, output_names): - if name.startswith("mask"): - loss_func = self._get_function(cfg_loss.mask_loss_function()) - else: - loss_func = losses.LossWrapper() - for func, weight in face_losses: - self._add_face_loss_function(loss_func, func, weight / 100.) - - logger.debug("%s: (output_name: '%s', function: %s)", name, output_name, loss_func) - self._functions[name] = loss_func - logger.debug("functions: %s", self._functions) - - def _add_face_loss_function(self, - loss_wrapper: losses.LossWrapper, - loss_function: str, - weight: float) -> None: - """Add the given face loss function at the given weight and apply any mouth and eye - multipliers - - Parameters - ---------- - loss_wrapper - The wrapper loss function that holds the face losses - loss_function - The loss function to add to the loss wrapper - weight - The amount of weight to apply to the given loss function - """ - logger.debug("Adding loss function: %s, weight: %s", loss_function, weight) - loss_wrapper.add_loss(self._get_function(loss_function), - weight=weight, - mask_channel=self._mask_channels[0]) - - channel_idx = 1 - for section, multiplier in zip( - ("eye_multiplier", "mouth_multiplier"), - (float(cfg_loss.eye_multiplier()), float(cfg_loss.mouth_multiplier()))): - mask_channel = self._mask_channels[channel_idx] - multiplier *= 1. - if multiplier > 1.: - logger.debug("Adding section loss %s: %s", section, multiplier) - loss_wrapper.add_loss(self._get_function(loss_function), - weight=weight * multiplier, - mask_channel=mask_channel) - channel_idx += 1 - - def _get_mask_channels(self) -> list[int]: - """Obtain the channels from the face targets that the masks reside in from the training - data generator. - - Returns - ------- - A list of channel indices that contain the mask for the corresponding config item - """ - eye_multiplier = cfg_loss.eye_multiplier() - mouth_multiplier = cfg_loss.mouth_multiplier() - if not cfg_loss.penalized_mask_loss() and (eye_multiplier > 1 or mouth_multiplier > 1): - logger.warning("You have selected eye/mouth loss multipliers greater than 1x, but " - "Penalized Mask Loss is disabled. Disabling all multipliers.") - eye_multiplier = 1 - mouth_multiplier = 1 - uses_masks = (cfg_loss.penalized_mask_loss(), eye_multiplier > 1, mouth_multiplier > 1) - mask_channels = [-1 for _ in range(len(uses_masks))] - current_channel = 3 - for idx, mask_required in enumerate(uses_masks): - if mask_required: - mask_channels[idx] = current_channel - current_channel += 1 - logger.debug("uses_masks: %s, mask_channels: %s", uses_masks, mask_channels) - return mask_channels - - class Optimizer(): """Obtain the selected optimizer with the appropriate keyword arguments.""" def __init__(self) -> None: diff --git a/plugins/train/model/_base/state.py b/plugins/train/model/_base/state.py index 54f119ffe3..a9648d6ef3 100644 --- a/plugins/train/model/_base/state.py +++ b/plugins/train/model/_base/state.py @@ -53,7 +53,7 @@ def __init__(self, """float: The lowest average loss seen between save intervals. """ self._config: dict[str, ConfigValueType] = {} - self._updateable_options: list[str] = [] + self._updatable_options: list[str] = [] self._load() self._session_id = self._new_session_id() @@ -65,11 +65,6 @@ def filename(self) -> str: """ str: Full path to the state filename """ return self._filename - @property - def loss_names(self) -> list[str]: - """ list: The loss names for the current session """ - return self._sessions[self._session_id]["loss_names"] - @property def current_session(self) -> dict: """ dict: The state dictionary for the current :attr:`session_id`. """ @@ -135,11 +130,10 @@ def _create_new_session(self, no_logs: bool) -> None: logger.debug("Creating new session. id: %s", self._session_id) self._sessions[self._session_id] = {"timestamp": time.time(), "no_logs": no_logs, - "loss_names": [], "batchsize": 0, "iterations": 0, "config": {k: v for k, v in self._config.items() - if k in self._updateable_options}} + if k in self._updatable_options}} def update_session_config(self, key: str, value: T.Any) -> None: """ Update a configuration item of the currently loaded session. @@ -156,19 +150,6 @@ def update_session_config(self, key: str, value: T.Any) -> None: logger.debug("Updating configuration item '%s' from '%s' to '%s'", key, old_val, value) self.current_session["config"][key] = value - def add_session_loss_names(self, loss_names: list[str]) -> None: - """ Add the session loss names to the sessions dictionary. - - The loss names are used for Tensorboard logging - - Parameters - ---------- - loss_names: list - The list of loss names for this session. - """ - logger.debug("Adding session loss_names: %s", loss_names) - self._sessions[self._session_id]["loss_names"] = loss_names - def add_session_batchsize(self, batch_size: int) -> None: """ Add the session batch size to the sessions dictionary. @@ -382,7 +363,7 @@ def _update_config(self) -> None: old_val = "none" if old_val is None else old_val # We used to allow NoneType. No more if not opt.fixed: - self._updateable_options.append(key) + self._updatable_options.append(key) if not opt.fixed and val != old_val: self._config[key] = val @@ -399,7 +380,7 @@ def _update_config(self) -> None: if legacy_update: self.save() logger.info("Using configuration saved in state file") - logger.debug("Updateable items: %s", self._updateable_options) + logger.debug("Updatable items: %s", self._updatable_options) def _generate_config(self) -> None: """ Generate an initial state config based on the currently selected user config """ @@ -407,10 +388,10 @@ def _generate_config(self) -> None: for key, val in options.items(): self._config[key] = val.value if not val.fixed: - self._updateable_options.append(key) + self._updatable_options.append(key) logger.debug("Generated initial state config for '%s': %s", self._name, self._config) - logger.debug("Updateable items: %s", self._updateable_options) + logger.debug("Updatable items: %s", self._updatable_options) def _load(self) -> None: """ Load a state file and set the serialized values to the class instance. diff --git a/plugins/train/trainer/base.py b/plugins/train/trainer/base.py index 0a06279037..2888f9332b 100644 --- a/plugins/train/trainer/base.py +++ b/plugins/train/trainer/base.py @@ -15,6 +15,8 @@ import torch if T.TYPE_CHECKING: + from lib.training.data import BatchMeta + from lib.training.loss import LossCollator, BatchLoss from plugins.train.model._base import ModelBase logger = logging.getLogger(__name__) @@ -83,14 +85,29 @@ def __init__(self, model: ModelBase, config: TrainConfig) -> None: """Training configuration options""" self.sampler = self.get_sampler() """The data sampler that the data loader should use""" + self.loss_func: LossCollator + """The selected loss functions for the model""" def __repr__(self) -> str: """Pretty print for logging""" params = f"model={repr(self.model)}, config={repr(self.config)}" return f"{self.__class__.__name__}({params})" + def register_loss(self, loss: LossCollator) -> None: + """Registers the selected loss functions to the underlying model nn.module + + Parameters + ---------- + loss + The configured loss functions + """ + logger.debug("[%s] Registering loss: %s", self.__class__.__name__, loss) + self.model.model.add_module("loss_func", loss) + self.loss_func = loss + @abc.abstractmethod - def get_sampler(self) -> type[torch.utils.data.Sampler]: + def get_sampler(self) -> type[torch.utils.data.RandomSampler | + torch.utils.data.DistributedSampler]: """Override to set the sampler that the Torch DataLoader should use Returns @@ -99,21 +116,23 @@ def get_sampler(self) -> type[torch.utils.data.Sampler]: """ @abc.abstractmethod - def train_batch(self, inputs: torch.Tensor, targets: list[torch.Tensor]) -> torch.Tensor: - """Override to run a single forward and backwards pass through the model for a single - batch + def train_batch(self, + inputs: list[torch.Tensor], + targets: list[torch.Tensor], + meta: BatchMeta) -> list[BatchLoss]: + """Override to run a single forward and backwards pass through the model for a single batch Parameters ---------- inputs - The batch of input image tensors to the model in shape `(side, batch_size, - *dims)` with `side` 0 being input A and `side` 1 being input B + The batch of input image tensors to the model of length(num inputs) targets - The corresponding batch of target images for the model for each side's output(s). For - each model output an array should exist in the order of model outputs in the format `( - side, batch_size, *dims)` where `side` 0 is "A" and `side` 1 is "B" + List of len (num_outputs) of target images in shape (batch_size, num_inputs, height, + width, 3) at all model output sizes as float32 0.0 - 1.0 range + meta + The meta information for the batch Returns ------- - The loss for each side of this batch in layout (A1, ..., An, B1, ..., Bn) + The loss for each input to the model in order (A, B, ...) """ diff --git a/plugins/train/trainer/distributed.py b/plugins/train/trainer/distributed.py index b3f0aada3b..948b1f91d0 100644 --- a/plugins/train/trainer/distributed.py +++ b/plugins/train/trainer/distributed.py @@ -5,10 +5,10 @@ import typing as T import warnings -from keras import ops import torch - +from lib.training.data import BatchMeta +from lib.training.loss import BatchLoss from lib.utils import get_module_objects from .original import Trainer as OriginalTrainer @@ -36,52 +36,39 @@ def __init__(self, model: keras.Model): logger.debug("Wrapped keras model: %s (%s)", model.name, self) def forward(self, - input_a: torch.Tensor, - input_b: torch.Tensor, - targets_a: torch.Tensor, - targets_b: torch.Tensor, - *targets: torch.Tensor) -> torch.Tensor: + inputs: list[torch.Tensor], + targets: list[torch.Tensor], + meta_dict: dict[str, list[torch.Tensor]]) -> list[dict]: """Run the forward pass per GPU Parameters ---------- - input_a - The A batch of input images for 1 GPU - input_b - The B batch of input images for 1 GPU - targets_a - The A batch of target images for 1 GPU. If this is a multi-output model then this list - will be the target images per output for all items in the current batch, regardless of - GPU. If we have 1 output, this will be a Tensor for this GPUs current batch output - targets_b - The B batch of target images for 1 GPU. If this is a multi-output model then this list - will be the target images per output for all items in the current batch, regardless of - GPU. If we have 1 output, this will be a Tensor for this GPUs current batch output + inputs + The batch of input image tensors to the model of length(num inputs) targets - Used for multi-output models. Any additional outputs can be added here. They should be - added in A-B order - + List of len (num_outputs) of target images in shape (batch_size, num_inputs, height, + width, 3) at all model output sizes as float32 0.0 - 1.0 range + meta_dict + The meta information for the batch in dictionary form Returns ------- The loss outputs for each side of the model for 1 GPU """ - predictions = self._keras_model((input_a, input_b), training=True) - self._keras_model.zero_grad() - - if targets: # Go from [A1, B1, A2, B2, A3, B3] to [A1, A2, A3, B1, B2, B3] - all_targets = [targets_a, targets_b, *targets] - assert len(all_targets) % 2 == 0 - loss_targets = all_targets[0::2] + all_targets[1::2] - else: - loss_targets = [targets_a, targets_b] - - losses = torch.stack([loss_fn(y_true, y_pred) - for loss_fn, y_true, y_pred in zip(self._keras_model.loss, - loss_targets, - predictions)]) + meta = BatchMeta(**meta_dict) + predictions = self._keras_model(inputs, training=True) + num_sides = len(inputs) + num_outputs = len(predictions) // num_sides + losses = [ + self._keras_model.loss_func( + [t[:, i] for t in targets], + predictions[i * num_outputs:i * num_outputs + num_outputs], + meta=meta[i]) + for i in range(num_sides) + ] + logger.trace("Losses: %s", losses) # type:ignore[attr-defined] - return losses + return [{k: v for k, v in x.__dict__.items() if v is not None} for x in losses] class Trainer(OriginalTrainer): @@ -176,42 +163,55 @@ def _set_distributed(self) -> torch.nn.DataParallel: name, wrapped.device_ids) return wrapped + @classmethod + def _mean_loss(cls, value: torch.Tensor | list | dict) -> torch.Tensor | list | dict: + """Recursively collate the loss from multiple GPUs back to single scalars + + Parameters + ---------- + value + A loss value returned from the model as either a tensor, list or dict + + Returns + ------- + The mean value in the same format + + Raises + ------ + NotImplementedError + If the value is in an unexpected format + """ + if isinstance(value, torch.Tensor): + return value.mean() + if isinstance(value, list): + return [cls._mean_loss(v) for v in value] + if isinstance(value, dict): + return {k: cls._mean_loss(v) for k, v in value.items()} + raise NotImplementedError(f"Unsupported type in loss structure: {type(value)}") + def _forward(self, - inputs: torch.Tensor, - targets: list[torch.Tensor]) -> torch.Tensor: + inputs: list[torch.Tensor], + targets: list[torch.Tensor], + meta: BatchMeta) -> list[BatchLoss]: """Perform the forward pass on the model Parameters ---------- inputs - The batch of input image tensors to the model in shape `(side, batch_size, - *dims)` with `side` 0 being input A and `side` 1 being input B + The batch of input image tensors to the model of length(num inputs) targets - The corresponding batch of target images for the model for each side's output(s). For - each model output an array should exist in the order of model outputs in the format `( - side, batch_size, *dims)` with `side` 0 being input A and `side` 1 being input B + List of len (num_outputs) of target images in shape (batch_size, num_inputs, height, + width, 3) at all model output sizes as float32 0.0 - 1.0 range + meta + The meta information for the batch Returns ------- - The loss for each side of this batch in layout (A1, ..., An, B1, ..., Bn) + The loss for each input to the model in order (A, B, ...) """ - if self._is_multi_out is None: - self._is_multi_out = len(targets) > 1 - logger.debug("Setting multi-out to: %s", self._is_multi_out) - - if self._is_multi_out: - multi_targets = tuple(t[i] for t in targets[1:] for i in range(2)) - else: - multi_targets = () - - loss: torch.Tensor = self._distributed_model(inputs[0], - inputs[1], - targets[0][0], - targets[0][1], - *multi_targets) - scaled = T.cast(torch.Tensor, ops.sum(ops.reshape(loss, (self._gpu_count, 2, -1)), - axis=0) / self._gpu_count) - return scaled.flatten() + loss_dicts = self._distributed_model(inputs, targets, meta.__dict__) + loss = [BatchLoss(**T.cast(dict, self._mean_loss(loss_dict))) for loss_dict in loss_dicts] + return loss __all__ = get_module_objects(__name__) diff --git a/plugins/train/trainer/original.py b/plugins/train/trainer/original.py index 5f2acbe491..d919c1f221 100644 --- a/plugins/train/trainer/original.py +++ b/plugins/train/trainer/original.py @@ -6,12 +6,15 @@ import typing as T from keras import ops -from keras.src.tree import flatten import torch from lib.utils import get_module_objects from .base import TrainerBase +if T.TYPE_CHECKING: + from lib.training.data import BatchMeta + from lib.training.loss import BatchLoss + logger = logging.getLogger(__name__) @@ -29,32 +32,32 @@ def get_sampler(self) -> type[torch.utils.data.RandomSampler]: return torch.utils.data.RandomSampler def _forward(self, - inputs: torch.Tensor, - targets: list[torch.Tensor]) -> torch.Tensor: + inputs: list[torch.Tensor], + targets: list[torch.Tensor], + meta: BatchMeta) -> list[BatchLoss]: """Perform the forward pass on the model Parameters ---------- inputs - The batch of input image tensors to the model in shape `(side, batch_size, - *dims)` with `side` 0 being input A and `side` 1 being input B + The batch of input image tensors to the model of length(num inputs) targets - The corresponding batch of target images for the model for each side's output(s). For - each model output an array should exist in the order of model outputs in the format `( - side, batch_size, *dims)` with `side` 0 being input A and `side` 1 being input B + List of len (num_outputs) of target images in shape (batch_size, num_inputs, height, + width, 3) at all model output sizes as float32 0.0 - 1.0 range + meta + The meta information for the batch Returns ------- - The loss for each side of this batch in layout (A1, ..., An, B1, ..., Bn) + The loss for each input to the model in order (A, B, ...) """ - feed_targets = [[t[i] for t in targets] for i in range(2)] - predictions = self.model.model((inputs[0], inputs[1]), training=True) - self.model.model.zero_grad() - - losses = torch.stack([loss_fn(y_true, y_pred) - for loss_fn, y_true, y_pred in zip(self.model.model.loss, - flatten(feed_targets), - predictions)]) + predictions = self.model.model(inputs, training=True) + num_sides = len(inputs) + num_outputs = len(predictions) // num_sides + losses = [self.loss_func([t[:, i] for t in targets], + predictions[i * num_outputs:i * num_outputs + num_outputs], + meta[i]) + for i in range(num_sides)] logger.trace("Losses: %s", losses) # type:ignore[attr-defined] return losses @@ -78,27 +81,30 @@ def _backwards_and_apply(self, all_loss: torch.Tensor) -> None: self.model.model.optimizer.apply(gradients, trainable_weights) def train_batch(self, - inputs: torch.Tensor, - targets: list[torch.Tensor]) -> torch.Tensor: + inputs: list[torch.Tensor], + targets: list[torch.Tensor], + meta: BatchMeta) -> list[BatchLoss]: """Run a single forward and backwards pass through the model for a single batch Parameters ---------- inputs - The batch of input image tensors to the model in shape `(side, batch_size, - *dims)` with `side` 0 being input A and `side` 1 being input B + The batch of input image tensors to the model of length(num inputs) targets - The corresponding batch of target images for the model for each side's output(s). For - each model output an array should exist in the order of model outputs in the format `( - side, batch_size, *dims)` with `side` 0 being input A and `side` 1 being input B + List of len (num_outputs) of target images in shape (batch_size, num_inputs, height, + width, 3) at all model output sizes as float32 0.0 - 1.0 range + meta + The meta information for the batch Returns ------- - The loss for each side of this batch in layout (A1, ..., An, B1, ..., Bn) + The loss for each input to the model in order (A, B, ...) """ - loss_tensor = self._forward(inputs, targets) - self._backwards_and_apply(loss_tensor) - return loss_tensor + self.model.model.zero_grad() # TODO move this to optimizer + loss = self._forward(inputs, targets, meta) + total_loss = T.cast(torch.Tensor, sum(x.total for x in loss)) + self._backwards_and_apply(total_loss) + return loss __all__ = get_module_objects(__name__) diff --git a/scripts/train.py b/scripts/train.py index 8b7ef48bf1..2f96d3d7b6 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -18,7 +18,7 @@ from lib.logger import parse_class_init from lib.multithreading import MultiThread, FSThread from lib.training import Preview, PreviewBuffer, TriggerType -from lib.training.data_set import get_label +from lib.training.data import get_label from lib.training.train import Trainer from lib.utils import (get_folder, get_image_paths, get_module_objects, handle_deprecated_cli_opts, FaceswapError) @@ -403,14 +403,21 @@ def _check_keypress(self, keypress: KBHit) -> bool: ``True`` if an exit keypress has been detected otherwise ``False`` """ retval = False - if keypress.kbhit(): - console_key = keypress.getch() - if console_key in ("\n", "\r"): - logger.debug("[Train] Exit requested") + try: + if keypress.kbhit(): + console_key = keypress.getch() + if console_key in ("\n", "\r"): + logger.debug("[Train] Exit requested") + retval = True + if console_key in ("s", "S"): + logger.info("Save requested") + self._save_now = True + except ValueError as err: + if "I/O operation on closed file" in str(err): + logger.debug("[Train] Error encountered: %s", str(err)) retval = True - if console_key in ("s", "S"): - logger.info("Save requested") - self._save_now = True + else: + raise return retval def _process_gui_triggers(self) -> dict[T.Literal["mask", "refresh"], bool]: diff --git a/tests/lib/gui/stats/event_reader_test.py b/tests/lib/gui/stats/event_reader_test.py index 0e4094aba6..7caecbabf6 100644 --- a/tests/lib/gui/stats/event_reader_test.py +++ b/tests/lib/gui/stats/event_reader_test.py @@ -2,7 +2,6 @@ """ Pytest unit tests for :mod:`lib.gui.stats.event_reader` """ # pylint:disable=protected-access from __future__ import annotations -import json import os import typing as T @@ -633,7 +632,6 @@ def test_cache_events(self, monkeypatch.setattr("lib.utils._FS_BACKEND", "cpu") event_parse = event_parser_instance - event_parse._parse_outputs = T.cast(MagicMock, mocker.MagicMock()) # type:ignore event_parse._process_event = T.cast(MagicMock, mocker.MagicMock()) # type:ignore event_parse._cache.cache_data = T.cast(MagicMock, mocker.MagicMock()) # type:ignore @@ -642,10 +640,8 @@ def test_cache_events(self, "_iterator", iter([self._create_example_event(0, 1., time())])) event_parse.cache_events(1) - assert event_parse._parse_outputs.called assert not event_parse._process_event.called assert event_parse._cache.cache_data.called - event_parse._parse_outputs.reset_mock() event_parse._process_event.reset_mock() event_parse._cache.cache_data.reset_mock() @@ -654,10 +650,8 @@ def test_cache_events(self, "_iterator", iter([self._create_example_event(1, 1., time())])) event_parse.cache_events(1) - assert not event_parse._parse_outputs.called assert event_parse._process_event.called assert event_parse._cache.cache_data.called - event_parse._parse_outputs.reset_mock() event_parse._process_event.reset_mock() event_parse._cache.cache_data.reset_mock() @@ -665,67 +659,11 @@ def test_cache_events(self, monkeypatch.setattr(event_parse, "_iterator", iter([event_pb2.Event(step=1).SerializeToString()])) - assert not event_parse._parse_outputs.called assert not event_parse._process_event.called assert not event_parse._cache.cache_data.called - event_parse._parse_outputs.reset_mock() event_parse._process_event.reset_mock() event_parse._cache.cache_data.reset_mock() - def test__parse_outputs(self, - event_parser_instance: _EventParser, - mocker: pytest_mock.MockerFixture) -> None: - """ Test _parse_outputs works correctly - - Parameters - ---------- - event_parser_instance: :class:`lib.gui.analysis.event_reader._EventParser` - The class instance to test - mocker: :class:`pytest_mock.MockerFixture` - Mocker for event object - """ - event_parse = event_parser_instance - model = {"config": {"layers": [{"name": "decoder_a", - "config": {"output_layers": [["face_out_a", 0, 0]]}}, - {"name": "decoder_b", - "config": {"output_layers": [["face_out_b", 0, 0]]}}], - "output_layers": [["decoder_a", 1, 0], ["decoder_b", 1, 0]]}} - data = json.dumps(model).encode("utf-8") - - event = mocker.MagicMock() - event.summary.value.__getitem__ = lambda self, x: event - event.tensor.string_val.__getitem__ = lambda self, x: data - - assert not event_parse._loss_labels - event_parse._parse_outputs(event) - assert event_parse._loss_labels == ["face_out_a", "face_out_b"] - - def test__get_outputs(self, event_parser_instance: _EventParser) -> None: - """ Test _get_outputs works correctly - - Parameters - ---------- - event_parser_instance: :class:`lib.gui.analysis.event_reader._EventParser` - The class instance to test - """ - outputs = [["decoder_a", 1, 0], ["decoder_b", 1, 0]] - model_config = {"output_layers": outputs} - - expected = np.array([[out] for out in outputs]) - actual = event_parser_instance._get_outputs(model_config, is_sub_model=False) - assert isinstance(actual, np.ndarray) - assert actual.shape == (2, 1, 3) - np.testing.assert_equal(expected, actual) - - outputs = [["encoder", 1, 0]] - model_config = {"output_layers": outputs} - - expected = np.array([outputs]) - actual = event_parser_instance._get_outputs(model_config, is_sub_model=True) - assert isinstance(actual, np.ndarray) - assert actual.shape == (1, 1, 3) - np.testing.assert_equal(expected, actual) - def test__process_event(self, event_parser_instance: _EventParser) -> None: """ Test _process_event works correctly diff --git a/tests/lib/model/losses/feature_loss_test.py b/tests/lib/model/losses/feature_loss_test.py index 04c6881f70..c40c68637f 100644 --- a/tests/lib/model/losses/feature_loss_test.py +++ b/tests/lib/model/losses/feature_loss_test.py @@ -16,10 +16,10 @@ @pytest.mark.parametrize("net", _NETS, ids=_IDS) def test_loss_output(net): """ Basic dtype and value tests for loss functions. """ - y_a = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() - y_b = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() + y_a = torch.Tensor(np.random.random((2, 3, 32, 32))).cpu() + y_b = torch.Tensor(np.random.random((2, 3, 32, 32))).cpu() lpips = LPIPSLoss(net).cpu() objective_output = lpips(y_a, y_b) output = objective_output.detach().numpy() # type:ignore assert output.dtype == "float32" and not np.any(np.isnan(output)) - assert (output <= 0.1).all() # LPIPS loss is reduced 10x + assert output.mean() <= 0.1 # LPIPS loss is reduced 10x diff --git a/tests/lib/model/losses/loss_test.py b/tests/lib/model/losses/loss_test.py index 82de70fc8b..ef946f57bc 100644 --- a/tests/lib/model/losses/loss_test.py +++ b/tests/lib/model/losses/loss_test.py @@ -7,13 +7,10 @@ import pytest import numpy as np -from keras import device, losses as k_losses import torch from lib.model.losses.loss import (FocalFrequencyLoss, GeneralizedLoss, GradientLoss, - LaplacianPyramidLoss, LInfNorm, LossWrapper) -from lib.model.losses.feature_loss import LPIPSLoss -from lib.model.losses.perceptual_loss import DSSIMObjective, GMSDLoss, LDRFLIPLoss, MSSIMLoss + LaplacianPyramidLoss, LInfNorm) from lib.utils import get_backend @@ -29,41 +26,10 @@ @pytest.mark.parametrize(["loss_func", "max_target"], _PARAMS, ids=_IDS) def test_loss_output(loss_func, max_target): """ Basic dtype and value tests for loss functions. """ - y_a = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() - y_b = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() + y_a = torch.Tensor(np.random.random((2, 3, 32, 32))).cpu() + y_b = torch.Tensor(np.random.random((2, 3, 32, 32))).cpu() metric = loss_func().cpu() objective_output = metric(y_a, y_b) output = objective_output.detach().numpy() assert output.dtype == "float32" and not np.any(np.isnan(output)) - assert (output <= max_target).all() - - -_LWPARAMS = [(FocalFrequencyLoss, ()), - (GeneralizedLoss, ()), - (GradientLoss, ()), - (LaplacianPyramidLoss, ()), - (LInfNorm, ()), - (LPIPSLoss, ("squeeze", )), - (DSSIMObjective, ()), - (GMSDLoss, ()), - (LDRFLIPLoss, ()), - (MSSIMLoss, ()), - (k_losses.LogCosh, ()), - (k_losses.MeanAbsoluteError, ()), - (k_losses.MeanSquaredError, ())] -_LWIDS = [f"{x[0].__name__}[{get_backend().upper()}]" for x in _LWPARAMS] - - -@pytest.mark.parametrize(["loss_func", "func_args"], _LWPARAMS, ids=_LWIDS) -def test_loss_wrapper(loss_func, func_args): - """ Test penalized loss wrapper works as expected """ - with device("cpu"): - p_loss = LossWrapper() - p_loss.add_loss(loss_func(*func_args), 1.0, -1) - p_loss.add_loss(k_losses.MeanSquaredError(), 2.0, 3) - y_a = torch.Tensor(np.random.random((2, 32, 32, 4))).cpu() - y_b = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() - - output = p_loss(y_a, y_b) - output = output.detach().numpy() # type:ignore - assert output.dtype == "float32" and not np.any(np.isnan(output)) + assert output.mean() <= max_target diff --git a/tests/lib/model/losses/perceptual_loss_test.py b/tests/lib/model/losses/perceptual_loss_test.py index eed7b2c66b..e66d42f84e 100644 --- a/tests/lib/model/losses/perceptual_loss_test.py +++ b/tests/lib/model/losses/perceptual_loss_test.py @@ -5,21 +5,22 @@ import torch # pylint:disable=import-error,duplicate-code -from lib.model.losses.perceptual_loss import DSSIMObjective, GMSDLoss, LDRFLIPLoss, MSSIMLoss +from lib.model.losses.perceptual_loss import GMSDLoss, SSIMLoss, MSSIMLoss +from lib.model.losses.flip import LDRFLIPLoss from lib.utils import get_backend -_PARAMS = [DSSIMObjective, GMSDLoss, LDRFLIPLoss, MSSIMLoss] +_PARAMS = [SSIMLoss, GMSDLoss, LDRFLIPLoss, MSSIMLoss] _IDS = [f"{x.__name__}[{get_backend().upper()}]" for x in _PARAMS] @pytest.mark.parametrize("loss_func", _PARAMS, ids=_IDS) def test_loss_output(loss_func): """ Basic dtype and value tests for loss functions. """ - y_a = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() - y_b = torch.Tensor(np.random.random((2, 32, 32, 3))).cpu() + y_a = torch.Tensor(np.random.random((2, 3, 128, 128))).cpu() + y_b = torch.Tensor(np.random.random((2, 3, 128, 128))).cpu() metric = loss_func().cpu() objective_output = metric(y_a, y_b) output = objective_output.detach().numpy() # type:ignore assert output.dtype == "float32" and not np.any(np.isnan(output)) - assert (output <= 1.0).all() + assert output.mean() <= 1.0 diff --git a/tests/lib/training/data_augmentation_test.py b/tests/lib/training/data_augmentation_test.py index 1faff34bf9..a9005b5647 100644 --- a/tests/lib/training/data_augmentation_test.py +++ b/tests/lib/training/data_augmentation_test.py @@ -1,5 +1,5 @@ #!/usr/bin python3 -""" Pytest unit tests for :mod:`lib.training.data_augmentation` """ +""" Pytest unit tests for :mod:`lib.training.data.augmentation` """ import typing as T import cv2 @@ -8,7 +8,7 @@ import pytest_mock from lib.config import ConfigValueType -from lib.training.data_augmentation import ( +from lib.training.data.augmentation import ( ConstantsAugmentation, ConstantsColor, ConstantsTransform, ConstantsWarp, ImageAugmentation) from plugins.train.trainer import trainer_config as cfg @@ -18,7 +18,7 @@ # pylint:disable=protected-access,redefined-outer-name -MODULE_PREFIX = "lib.training.data_augmentation" +MODULE_PREFIX = "lib.training.data.augmentation" # CONSTANTS # diff --git a/tests/lib/training/lr_finder_test.py b/tests/lib/training/lr_finder_test.py index c2914707e6..3fa8641796 100644 --- a/tests/lib/training/lr_finder_test.py +++ b/tests/lib/training/lr_finder_test.py @@ -5,6 +5,7 @@ import pytest_mock import numpy as np +import torch from lib.training.lr_finder import LearningRateFinder from plugins.train import train_config as cfg @@ -15,6 +16,12 @@ # pylint:disable=protected-access,invalid-name,redefined-outer-name +class DummyLoss: # pylint:disable=too-few-public-methods + """Dummy loss return value""" + def __init__(self, value): + self.total = torch.Tensor([value]) + + @pytest.fixture def _trainer_mock(patch_config, mocker: pytest_mock.MockFixture): # noqa:[F811] """ Generate a mocked model and feeder object and patch user config items """ @@ -113,7 +120,7 @@ def test_LearningRateFinder_train(iters, # pylint:disable=too-many-locals """ Test lib.train.LearingRateFinder._train """ trainer, _, _ = _trainer_mock(iters, mode, strength) - mock_loss_return = np.random.rand(2).tolist() + mock_loss_return = [DummyLoss(np.random.random()) for _ in range(2)] trainer.train_one_batch = mocker.MagicMock(return_value=mock_loss_return) lrf = LearningRateFinder(trainer) @@ -126,14 +133,15 @@ def test_LearningRateFinder_train(iters, # pylint:disable=too-many-locals trainer.train_one_batch.assert_called() assert trainer.train_one_batch.call_count == iters - train_call_args = [mocker.call(x + 1, mock_loss_return[0]) for x in range(iters)] + train_call_args = [mocker.call(x + 1, sum(y.total for y in mock_loss_return)) + for x in range(iters)] assert lrf._on_batch_end.call_args_list == train_call_args lrf._update_description.assert_called() assert lrf._update_description.call_count == iters # NaN break - mock_loss_return = (np.nan, np.nan) + mock_loss_return = mock_loss_return = [DummyLoss(np.nan) for _ in range(2)] trainer.train_one_batch = mocker.MagicMock(return_value=mock_loss_return) lrf._train() diff --git a/tests/plugins/train/trainer/test_distributed.py b/tests/plugins/train/trainer/test_distributed.py index 10479b67ea..4524837fea 100644 --- a/tests/plugins/train/trainer/test_distributed.py +++ b/tests/plugins/train/trainer/test_distributed.py @@ -7,6 +7,7 @@ import pytest_mock import torch +from lib.training.data.collate import BatchMeta from plugins.train.trainer import distributed as mod_distributed from plugins.train.trainer import original as mod_original from plugins.train.trainer import base as mod_base @@ -15,6 +16,11 @@ _MODULE_PREFIX = "plugins.train.trainer.distributed" +class DummyLoss: # pylint:disable=too-few-public-methods + """Dummy loss return""" + total = 1.0 + + @pytest.mark.parametrize("batch_size", (4, 8, 16, 32, 64)) @pytest.mark.parametrize("outputs", (1, 2, 4)) def test_WrappedModel(batch_size, outputs, mocker): @@ -23,8 +29,8 @@ def test_WrappedModel(batch_size, outputs, mocker): instance = mod_distributed.WrappedModel(model) assert instance._keras_model is model - loss_return = [torch.from_numpy((np.random.random((1, )))) for _ in range(outputs * 2)] - model.loss = [mocker.MagicMock(return_value=ret) for ret in loss_return] + loss_return = DummyLoss() + model.loss_func = mocker.MagicMock(return_value=loss_return) test_dims = (batch_size, 16, 16, 3) @@ -37,7 +43,7 @@ def test_WrappedModel(batch_size, outputs, mocker): model.return_value = predictions # Call forwards - result = instance.forward(inp_a, inp_b, *targets) + instance.forward([inp_a, inp_b], targets, BatchMeta().__dict__) # Confirm model was called once forward with correct args model.assert_called_once() @@ -48,23 +54,8 @@ def test_WrappedModel(batch_size, outputs, mocker): for real, expected in zip(model_args[0], [inp_a, inp_b]): assert np.allclose(real.numpy(), expected.numpy()) - # Confirm ZeroGrad called - model.zero_grad.assert_called_once() - # Confirm loss functions correctly called - expected_targets = targets[0::2] + targets[1::2] - - for target, pred, loss in zip(expected_targets, predictions, model.loss): - loss.assert_called_once() - loss_args, loss_kwargs = loss.call_args - assert not loss_kwargs - assert len(loss_args) == 2 - for actual, expected in zip(loss_args, [target, pred]): - assert np.allclose(actual.numpy(), expected.numpy()) - - # Check that the result comes out as we put it in - for expected, actual in zip(loss_return, result.squeeze()): - assert np.isclose(expected.numpy(), actual.numpy()) + assert model.loss_func.call_count == 2 @pytest.fixture @@ -110,36 +101,21 @@ def test_Trainer_forward(gpu_count, batch_size, outputs, _trainer_mocked, mocker """ Test that original trainer _forward calls the correct model methods """ instance, _ = _trainer_mocked(gpus=gpu_count, batch_size=batch_size) - test_dims = (2, batch_size, 16, 16, 3) + test_dims = (batch_size, 2, 16, 16, 3) - inputs = torch.from_numpy(np.random.random(test_dims)).to("cpu") + inputs = list(torch.from_numpy(np.random.random(test_dims)).to("cpu")) targets = [torch.from_numpy(np.random.random(test_dims)).to("cpu") for _ in range(outputs)] - loss_return = torch.rand((gpu_count * 2 * outputs), device="cpu") + loss_return = [DummyLoss() for _ in range(gpu_count)] instance._distributed_model = mocker.MagicMock(return_value=loss_return) + instance._mean_loss = mocker.MagicMock(return_value={"unweighted": 1.0, "weighted": 1.0}) # Call the forward pass - result = instance._forward(inputs, targets).cpu().numpy() - - # Make sure multi-outs are enabled - if outputs > 1: - assert instance._is_multi_out is True - else: - assert instance._is_multi_out is False + instance._forward(inputs, targets, BatchMeta()) # Make sure that our wrapped distributed model was called in the correct order instance._distributed_model.assert_called_once() call_args, call_kwargs = instance._distributed_model.call_args assert not call_kwargs - assert len(call_args) == len(inputs) + (len(targets) * 2) - - expected_tgt = [t[i].cpu().numpy() for t in targets for i in range(2)] - - for expected, actual in zip([*inputs, *expected_tgt], call_args): - assert np.allclose(expected, actual) - - # Make sure loss gets grouped, summed and scaled correctly - expected = loss_return.cpu().numpy() - expected = expected.reshape((gpu_count, 2, -1)).sum(axis=0).flatten() / gpu_count - assert np.allclose(result, expected) + assert len(call_args) == 3 diff --git a/tests/plugins/train/trainer/test_original.py b/tests/plugins/train/trainer/test_original.py index 7e6f70420a..a386f6fb1e 100644 --- a/tests/plugins/train/trainer/test_original.py +++ b/tests/plugins/train/trainer/test_original.py @@ -7,10 +7,16 @@ import pytest_mock import torch +from lib.training.data.collate import BatchMeta from plugins.train.trainer import original as mod_original from plugins.train.trainer import base as mod_base +class DummyLoss: # pylint:disable=too-few-public-methods + """Dummy loss return""" + total = 1.0 + + @pytest.fixture def _trainer_mocked(mocker: pytest_mock.MockFixture): # noqa:[F811] """ Generate a mocked model and feeder object and patch user config items """ @@ -41,15 +47,17 @@ def test_Trainer(batch_size, _trainer_mocked): def test_Trainer_train_batch(_trainer_mocked, mocker): """ Test that original trainer calls the forward and backwards methods """ instance = _trainer_mocked() - loss_return = float(np.random.rand()) + loss_return = [DummyLoss()] instance._forward = mocker.MagicMock(return_value=loss_return) instance._backwards_and_apply = mocker.MagicMock() + instance.model.model.zero_grad = mocker.MagicMock() - ret_val = instance.train_batch("TEST_INPUT", "TEST_TARGET") + ret_val = instance.train_batch("TEST_INPUT", "TEST_TARGET", "TEST_META") assert ret_val == loss_return - instance._forward.assert_called_once_with("TEST_INPUT", "TEST_TARGET") - instance._backwards_and_apply.assert_called_once_with(loss_return) + instance._forward.assert_called_once_with("TEST_INPUT", "TEST_TARGET", "TEST_META") + instance._backwards_and_apply.assert_called_once_with(1.0) + instance.model.model.zero_grad.assert_called_once() @pytest.mark.parametrize("outputs", (1, 2, 4)) @@ -65,22 +73,18 @@ def test_Trainer_forward(batch_size, # pylint:disable=too-many-locals mock_predictions = [torch.from_numpy(np.random.random((batch_size, 16, 16, 3))) for _ in range(outputs * 2)] instance.model.model.return_value = mock_predictions - instance.model.model.zero_grad = mocker.MagicMock() - instance.model.model.loss = [mocker.MagicMock(return_value=ret) for ret in loss_returns] + instance.loss_func = mocker.MagicMock() - inputs = torch.from_numpy(np.random.random((2, batch_size, 16, 16, 3))) + inputs = list(torch.from_numpy(np.random.random((2, batch_size, 16, 16, 3)))) targets = [torch.from_numpy(np.random.random((2, batch_size, 16, 16, 3))) for _ in range(outputs)] # Call forwards - result = instance._forward(inputs, targets) + result = instance._forward(inputs, targets, BatchMeta()) # Output comes from loss functions assert (np.allclose(e.numpy(), a.numpy()) for e, a in zip(result, loss_returns)) - # Model was zero'd - instance.model.model.zero_grad.assert_called_once() - # model forward pass called with inputs split train_call = instance.model.model From 815b11f104cf2995c437a8452bcc432ea8f224a7 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 21 May 2026 08:44:57 +0100 Subject: [PATCH 972/981] Migrate optimizers to torch (#1547) --- docs/full/lib/model.rst | 20 +- docs/full/lib/training.rst | 5 + lib/align/aligned_mask.py | 2 +- lib/config/objects.py | 162 +++--- lib/model/autoclip.py | 55 +- lib/model/losses/feature_loss.py | 4 +- lib/model/optimizers/__init__.py | 5 + lib/model/optimizers/adabelief.py | 287 ++++++++++ .../keras_legacy.py} | 123 ++--- lib/model/optimizers/lion.py | 110 ++++ lib/training/__init__.py | 2 - lib/training/lr_finder.py | 180 +++---- lib/training/lr_warmup.py | 123 +++-- lib/training/optimizer.py | 497 ++++++++++++++++++ lib/training/train.py | 47 +- plugins/extract/detect/mtcnn.py | 8 +- plugins/train/model/_base/io.py | 476 ++++++++++++----- plugins/train/model/_base/model.py | 14 +- plugins/train/model/_base/settings.py | 151 ------ plugins/train/train_config.py | 45 +- plugins/train/trainer/base.py | 4 + plugins/train/trainer/original.py | 28 +- scripts/train.py | 1 + tests/lib/model/optimizers_test.py | 59 --- tests/lib/training/lr_finder_test.py | 278 ---------- tests/lib/training/lr_warmup_test.py | 181 ------- tests/plugins/train/trainer/test_original.py | 27 +- 27 files changed, 1645 insertions(+), 1249 deletions(-) create mode 100644 lib/model/optimizers/__init__.py create mode 100644 lib/model/optimizers/adabelief.py rename lib/model/{optimizers.py => optimizers/keras_legacy.py} (76%) create mode 100644 lib/model/optimizers/lion.py create mode 100644 lib/training/optimizer.py delete mode 100644 tests/lib/model/optimizers_test.py delete mode 100644 tests/lib/training/lr_finder_test.py delete mode 100644 tests/lib/training/lr_warmup_test.py diff --git a/docs/full/lib/model.rst b/docs/full/lib/model.rst index 84e8d49e33..37213553b6 100755 --- a/docs/full/lib/model.rst +++ b/docs/full/lib/model.rst @@ -33,6 +33,22 @@ networks package :include-all-objects: :noindex: +optimizers package +================== + +.. automodapi:: lib.model.optimizers.adabelief + :include-all-objects: + :noindex: + +| +.. automodapi:: lib.model.optimizers.lion + :include-all-objects: + :noindex: + +| +.. automodapi:: lib.model.optimizers.keras_legacy + :include-all-objects: + :noindex: model package ============= @@ -62,7 +78,3 @@ model package | .. automodapi:: lib.model.normalization :include-all-objects: - -| -.. automodapi:: lib.model.optimizers - :include-all-objects: diff --git a/docs/full/lib/training.rst b/docs/full/lib/training.rst index cbe7382d34..f6609db92c 100644 --- a/docs/full/lib/training.rst +++ b/docs/full/lib/training.rst @@ -20,6 +20,11 @@ The training Package handles libraries to assist with training a model :include-all-objects: :no-inheritance-diagram: +| +.. automodapi:: lib.training.optimizer + :include-all-objects: + :no-inheritance-diagram: + | .. automodapi:: lib.training.preview :include-all-objects: diff --git a/lib/align/aligned_mask.py b/lib/align/aligned_mask.py index 28a95976c8..33c38416d9 100644 --- a/lib/align/aligned_mask.py +++ b/lib/align/aligned_mask.py @@ -466,7 +466,7 @@ def __init__(self, blur_kernel: int = 0, blur_type: T.Literal["gaussian", "normalized"] | None = "gaussian", blur_passes: int = 1) -> None: - logger.debug(parse_class_init(locals())) + logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] self._area = area self._landmark_type = landmark_type self._landmarks = landmarks diff --git a/lib/config/objects.py b/lib/config/objects.py index d496c56904..c2800e52b0 100644 --- a/lib/config/objects.py +++ b/lib/config/objects.py @@ -1,5 +1,5 @@ #! /usr/env/bin/python3 -""" Dataclass objects for holding and validating Faceswap Config items """ +"""Dataclass objects for holding and validating Faceswap Config item""" from __future__ import annotations import gettext @@ -26,7 +26,7 @@ # TODO allow list items other than strings @dataclass class ConfigItem(Generic[T]): # pylint:disable=too-many-instance-attributes - """ A dataclass for storing config items loaded from config.ini files and dynamically assigning + """A dataclass for storing config items loaded from config.ini files and dynamically assigning and validating that the correct datatype is used. The value loaded from the .ini config file can be accessed with either: @@ -37,17 +37,17 @@ class ConfigItem(Generic[T]): # pylint:disable=too-many-instance-attributes Parameters ---------- - datatype : type + datatype A python type class. This limits the type of data that can be provided in the .ini file and ensures that the value is returned to faceswap is correct. Valid datatypes are: `int`, `float`, `str`, `bool` or `list`. Note that `list` items must all be strings. - default : Any + default The default value for this option. It must be of the same type as :attr:`datatype`. - group : str + group The group that this config item exists within in the config section - info : str + info A description of what this option does. - choices : list[str] | Literal["colorchooser"], optional + choices If this option's datatype is a `str` then valid selections can be defined here, empty list for any value. If the option's datatype is a `list`, then this option must be populated with the valid selections. This validates the option and also enables a combobox / radio @@ -55,61 +55,60 @@ class ConfigItem(Generic[T]): # pylint:disable=too-many-instance-attributes literal "colorchooser" to present a color choosing interface in the GUI. Ignored for all other datatypes Default: [] (empty list: no options) - gui_radio : bool, optional + gui_radio If :attr:`choices` are defined, this indicates that the GUI should use radio buttons rather than a combobox to display this option. Default: ``False`` - min_max : tuple[int | float, int | float] | None, optional + min_max For `int` and `float` :attr:`datatype` this is required otherwise it is ignored. Should be a tuple of min and max accepted values of the same datatype as the option value. This is used for controlling the GUI slider range. Values are not enforced. Default: ``None`` - rounding : int | None, optional + rounding For `int` and `float :attr:datatypes this is required to be > 0 otherwise it is ignored. Used for the GUI slider. For `float`, this is the number of decimal places to display. For `int` this is the step size. Default: `-1` (ignored) - fixed : bool, optional + fixed [train only]. Training configurations are fixed when the model is created, and then reloaded from the state file. Marking an item as fixed=``False`` indicates that this value can be changed for existing models, and will override the value saved in the state file with the updated value in config. Default: ``True`` """ datatype: type[T] - """ type : A python type class. The datatype of the config value. One of `int`, `float`, `str`, - `bool` or `list`. `list` will only contain `str` items """ + """A python type class. The datatype of the config value. One of `int`, `float`, `str`, `bool` + or `list`. `list` will only contain `str` items""" default: T - """ Any : The default value for this option. It is of the same type as :attr:`datatype` """ + """The default value for this option. It is of the same type as :attr:`datatype`""" group: str - """ str : The group that this config option belongs to """ + """The group that this config option belongs to""" info: str - """ str : A description of what this option does """ + """A description of what this option does""" choices: list[str] | Literal["colorchooser"] = field(default_factory=list) - """ list[str] | Literal["colorchooser"]: If this option's datatype is a `str` then valid - selections may be defined here, Empty list if any value is valid. If the datatype is a `list` - then valid choices will be populated here. If the default value is a hex color code, then the - literal "colorchooser" will display a color choosing interface in the GUI. """ + """If this option's datatype is a `str` then valid selections may be defined here, Empty list + if any value is valid. If the datatype is a `list` then valid choices will be populated here. + If the default value is a hex color code, then the literal "colorchooser" will display a color + choosing interface in the GUI.""" gui_radio: bool = False - """ bool : indicates that the GUI should use radio buttons rather than a combobox to display - this option if :attr:`choices` is populated """ + """indicates that the GUI should use radio buttons rather than a combobox to display this + option if :attr:`choices` is populated""" min_max: tuple[T, T] | None = None - """ tuple[int | float, int | float] | None : For `int` and `float` :attr:`datatype` this will - be populated otherwise it will be ``None``. Used for controlling the GUI slider range. Values - are not enforced. """ + """For `int` and `float` :attr:`datatype` this will be populated otherwise it will be ``None``. + Used for controlling the GUI slider range. Values are not enforced.""" rounding: int = -1 - """ int : For `int` and `float` :attr:`datatypes` this will be > 0 otherwise it will be `-1`. - Used for the GUI slider. For `float`, this is the number of decimal places to display. For - `int` this is the step size. """ + """For `int` and `float` :attr:`datatypes` this will be > 0 otherwise it will be `-1`. Used for + the GUI slider. For `float`, this is the number of decimal places to display. For `int` this is + the step size.""" fixed: bool = True - """ bool : Only used for train.model configurations. Options marked as fixed=``False`` - indicates that this value can be changed for existing models, otherwise the option set when the - model commenced training is fixed and cannot be changed. Default: ``True`` """ + """Only used for train.model configurations. Options marked as fixed=``False`` indicates that + this value can be changed for existing models, otherwise the option set when the model + commenced training is fixed and cannot be changed. Default: ``True``""" _value: T = field(init=False) - """ Any : The value of the config item of type :attr:`datatype`""" + """The value of the config item of type :attr:`datatype`""" _name: str = field(init=False) - """ str: The option name for this object. Set when the config is first loaded """ + """The option name for this object. Set when the config is first loaded""" @property def helptext(self) -> str: - """ str | Description of the config option with additional formating and helptext added - from the item parameters """ + """Description of the config option with additional formatting and helptext added from the + item parameters""" retval = f"{self.info}\n" if not self.fixed: retval += _("\nThis option can be updated for existing models.\n") @@ -122,20 +121,20 @@ def helptext(self) -> str: retval += _("\nChoose from: True, False") elif self.datatype == int: assert self.min_max is not None - cmin, cmax = self.min_max - retval += _("\nSelect an integer between {} and {}").format(cmin, cmax) + c_min, c_max = self.min_max + retval += _("\nSelect an integer between {} and {}").format(c_min, c_max) elif self.datatype == float: assert self.min_max is not None - cmin, cmax = self.min_max - retval += _("\nSelect a decimal number between {} and {}").format(cmin, cmax) + c_min, c_max = self.min_max + retval += _("\nSelect a decimal number between {} and {}").format(c_min, c_max) default = ", ".join(self.default) if isinstance(self.default, list) else self.default retval += _("\n[Default: {}]").format(default) return retval @property def value(self) -> T: - """ Any : The config value for this item loaded from the config .ini file. String values - will always be lowercase, regardless of what is loaded from Config """ + """The config value for this item loaded from the config .ini file. String values will + always be lowercase, regardless of what is loaded from Config""" retval = self._value if isinstance(self._value, str): retval = cast(T, self._value.lower()) @@ -145,35 +144,34 @@ def value(self) -> T: @property def ini_value(self) -> str: - """ str : The current value of the ConfigItem as a string for writing to a .ini file """ + """The current value of the ConfigItem as a string for writing to a .ini file""" if isinstance(self._value, list): return ", ".join(str(x) for x in self._value) return str(self._value) @property def name(self) -> str: - """str: The name associated with this option """ + """The name associated with this option""" return self._name def _validate_type(self, # pylint:disable=too-many-return-statements expected_type: Any, attr: Any, depth=1) -> bool: - """ Validate that provided types are correct when this Dataclass is initialized + """Validate that provided types are correct when this Dataclass is initialized Parameters ---------- - expected_type : Any + expected_type The expected data type for the given attribute - attr : Any + attr The attribute to test for correctness - depth : int, optional + depth The current recursion depth Returns ------- - bool - ``True`` if the given attribute is a valid datatype + ``True`` if the given attribute is a valid datatype Raises ------ @@ -218,7 +216,7 @@ def _validate_type(self, # pylint:disable=too-many-return-statements return False def _validate_required(self) -> None: - """ Validate that required parameters are populated + """Validate that required parameters are populated Raises ------ @@ -231,7 +229,7 @@ def _validate_required(self) -> None: raise ValueError("Option info must me provided") def _validate_choices(self) -> None: - """ Validate that choices have been used correctly + """Validate that choices have been used correctly Raises ------ @@ -266,7 +264,7 @@ def _validate_choices(self) -> None: raise ValueError("Config item of type list must have choices defined") def _validate_numeric(self) -> None: - """ Validate that float and int values have been set correctly + """Validate that float and int values have been set correctly Raises ------ @@ -283,7 +281,7 @@ def _validate_numeric(self) -> None: f") values. Got {self.min_max}") def __post_init__(self) -> None: - """ Validate and type check that the given parameters are valid and set the default value. + """Validate and type check that the given parameters are valid and set the default value. Raises ------ @@ -303,27 +301,25 @@ def __post_init__(self) -> None: self._validate_numeric() def get(self) -> T: - """ Obtain the currently stored configuration value + """Obtain the currently stored configuration value Returns ------- - Any - The config value for this item loaded from the config .ini file. String values will - always be lowecase, regardless of what is loaded from Config """ + The config value for this item loaded from the config .ini file. String values will always + be lowercase, regardless of what is loaded from Config""" return self.value def _parse_list(self, value: str | list[str]) -> list[str]: - """ Parse inbound list values. These can be space/comma-separated strings or a list. + """Parse inbound list values. These can be space/comma-separated strings or a list. Parameters ---------- - value : str | list[str] + value The inbound value to be converted to a list Returns ------- - list[str] - List of strings representing the inbound values. + List of strings representing the inbound values. """ if not value: return [] @@ -335,17 +331,15 @@ def _parse_list(self, value: str | list[str]) -> list[str]: return retval def _validate_selection(self, value: str | list[str]) -> str | list[str]: - """ Validate that the given value is valid within the stored choices + """Validate that the given value is valid within the stored choices Parameters ---------- - str | list[str] - The inbound config value to validate + The inbound config value to validate Returns ------- - bool - ``True`` if the selected value is a valid choice + ``True`` if the selected value is a valid choice """ assert isinstance(self.choices, list) choices = [x.lower() for x in self.choices] @@ -370,11 +364,11 @@ def _validate_selection(self, value: str | list[str]) -> str | list[str]: return valid def set(self, value: T) -> None: - """ Set the item's option value + """Set the item's option value Parameters ---------- - value : Any + value The value to set this item to. Must be of type :attr:`datatype` Raises @@ -409,11 +403,11 @@ def set(self, value: T) -> None: self._value = value def set_name(self, name: str) -> None: - """ Set the logging name for this object for display purposes + """Set the logging name for this object for display purposes Parameters ---------- - name : str + name The name to assign to this option """ logger.debug("Setting name to '%s'", name) @@ -421,40 +415,48 @@ def set_name(self, name: str) -> None: self._name = name def __call__(self) -> T: - """ Obtain the currently stored configuration value + """Obtain the currently stored configuration value Returns ------- - Any - The config value for this item loaded from the config .ini file. String values will - always be lowecase, regardless of what is loaded from Config """ + The config value for this item loaded from the config .ini file. String values will always + be lowercase, regardless of what is loaded from Config""" return self.value @dataclass class ConfigSection: - """ Dataclass for holding information about configuration sections and the contained + """Dataclass for holding information about configuration sections and the contained configuration items Parameters ---------- - helptext : str + helptext The helptext to be displayed for the configuration section - options : dict[str, :class:`ConfigItem`] + options Dictionary of configuration option name to the options for the section """ helptext: str options: dict[str, ConfigItem] +class ConfigReprMeta(type): + """A custom repr for printing currently selected config values""" + def __repr__(cls) -> str: + params = ", ".join(f"{k}={repr(v.value)}" + for k, v in cls.__dict__.items() + if isinstance(v, ConfigItem)) + return f"{cls.__name__}({params})" + + @dataclass -class GlobalSection: - """ A dataclass for holding and identifying global sub-sections for plugin groups. Any global +class GlobalSection(metaclass=ConfigReprMeta): + """A dataclass for holding and identifying global sub-sections for plugin groups. Any global subsections must inherit from this. Parameters ---------- - helptext : str + helptext The helptext to be displayed for the global configuration section """ helptext: str diff --git a/lib/model/autoclip.py b/lib/model/autoclip.py index 03d1a54af7..384418d41c 100644 --- a/lib/model/autoclip.py +++ b/lib/model/autoclip.py @@ -1,29 +1,28 @@ -""" Auto clipper for clipping gradients. """ +"""Auto clipper for clipping gradients.""" from __future__ import annotations import logging -import typing as T +import math +from collections import deque import numpy as np import torch +from torch import nn from lib.logger import parse_class_init from lib.utils import get_module_objects -if T.TYPE_CHECKING: - from keras import KerasTensor - logger = logging.getLogger(__name__) class AutoClipper(): - """ AutoClip: Adaptive Gradient Clipping for Source Separation Networks + """AutoClip: Adaptive Gradient Clipping for Source Separation Networks Parameters ---------- - clip_percentile: int + clip_percentile The percentile to clip the gradients at - history_size: int, optional + history_size The number of iterations of data to use to calculate the norm Default: ``10000`` References @@ -33,32 +32,32 @@ class AutoClipper(): """ def __init__(self, clip_percentile: int, history_size: int = 10000) -> None: logger.debug(parse_class_init(locals())) - self._clip_percentile = clip_percentile - self._history_size = history_size - self._grad_history: list[float] = [] + self._grad_history: deque[float] = deque(maxlen=history_size) - logger.debug("Initialized %s", self.__class__.__name__) - - def __call__(self, gradients: list[KerasTensor]) -> list[KerasTensor]: - """ Call the AutoClip function. + def __call__(self, parameters: list[nn.Parameter], *args) -> None: + """Call the AutoClip function. Parameters ---------- - gradients: list[:class:`keras.KerasTensor`] - The list of gradient tensors for the optimizer - - Returns - ---------- - list[:class:`keras.KerasTensor`] - The autoclipped gradients + parameters + The parameters to clip + args + Unused but for compatibility """ - self._grad_history.append(sum(g.data.norm(2).item() ** 2 - for g in gradients if g is not None) ** (1. / 2)) - self._grad_history = self._grad_history[-self._history_size:] - clip_value = np.percentile(self._grad_history, self._clip_percentile) - torch.nn.utils.clip_grad_norm_(gradients, T.cast(float, clip_value)) - return gradients + with torch.no_grad(): + norms = [p.grad.norm(2).item() for p in parameters if p.grad is not None] + + if not norms: + return + + global_norm = sum(n ** 2 for n in norms) ** 0.5 + if not math.isfinite(global_norm): + return + + self._grad_history.append(global_norm) + clip_value = float(np.percentile(self._grad_history, self._clip_percentile)) + nn.utils.clip_grad_norm_(parameters, clip_value) __all__ = get_module_objects(__name__) diff --git a/lib/model/losses/feature_loss.py b/lib/model/losses/feature_loss.py index a9c1e9092d..c127a47b97 100644 --- a/lib/model/losses/feature_loss.py +++ b/lib/model/losses/feature_loss.py @@ -133,9 +133,9 @@ def _normalize_output(cls, inputs: torch.Tensor, epsilon: float = 1e-10) -> torc Parameters ---------- - inputs: :class:`keras.KerasTensor` + inputs An output tensor from the trunk model - epsilon: float, optional + epsilon Epsilon to apply to the normalization operation. Default: `1e-10` """ norm_factor = torch.sqrt(torch.sum(torch.square(inputs), dim=1, keepdim=True)) diff --git a/lib/model/optimizers/__init__.py b/lib/model/optimizers/__init__.py new file mode 100644 index 0000000000..fd5ddd0642 --- /dev/null +++ b/lib/model/optimizers/__init__.py @@ -0,0 +1,5 @@ +#! /usr/env/bin/python3 +"""Custom Torch Optimizers""" +from .adabelief import AdaBelief +from .lion import Lion +from .keras_legacy import AdaBelief as AdaBeliefKeras diff --git a/lib/model/optimizers/adabelief.py b/lib/model/optimizers/adabelief.py new file mode 100644 index 0000000000..9d85a46a83 --- /dev/null +++ b/lib/model/optimizers/adabelief.py @@ -0,0 +1,287 @@ +#! /usr/env/bin/python3 +"""AdaBelief optimizer for Torch""" +# BSD 2-Clause License +# +# Copyright (c) 2021, Juntang Zhuang +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +import logging +import math +import typing as T + +import torch +from torch.optim.optimizer import Optimizer + +from lib.logger import parse_class_init +from lib.utils import get_module_objects + +logger = logging.getLogger(__name__) + + +class AdaBelief(Optimizer): + """Implements AdaBelief algorithm. Modified from Adam in PyTorch + + Parameters + ---------- + params + Iterable of parameters to optimize or dicts defining parameter groups + lr + Learning rate. Default: 1e-3 + betas + Coefficients used for computing running averages of gradient and its square. + Default: (0.9, 0.999) + eps + Term added to the denominator to improve numerical stability. Default: 1e-16 + weight_decay + Weight decay (L2 penalty). Default: 0 + amsgrad + Whether to use the AMSGrad variant of this algorithm from the paper `On the Convergence + of Adam and Beyond`. Default: ``False`` + weight_decouple + If set as True, then the optimizer uses decoupled weight decay as in AdamW. + Default: ``True`` + fixed_decay + This is used when weight_decouple is set as True. + - When fixed_decay == True, the weight decay is performed as W_{new} = W_{old} - W_{old} + * decay. + - When fixed_decay == False, the weight decay is performed as W_{new} = W_{old} - W_{old} + * decay * lr. Note that in this case, the weight decay ratio decreases with learning rate + (lr). + Default: ``False`` + rectify + If set as True, then perform the rectified update similar to RAdam. + Default: ``True`` + degenerated_to_sgd + If set as True, then perform SGD update when variance of gradient is high. + Default: ``True`` + + Reference + --------- + AdaBelief Optimizer, adapting step sizes by the belief in observed gradients, NeurIPS 2020 + https://github.com/juntang-zhuang/Adabelief-Optimizer + """ + def __init__(self, # pylint:disable=too-many-positional-arguments,too-many-arguments # noqa[C901] + params: T.Iterable, + lr: float = 1e-3, + betas: tuple[float, float] = (0.9, 0.999), + eps: float = 1e-16, + weight_decay: float = 0.0, + amsgrad: bool = False, + weight_decouple: bool = True, + fixed_decay: bool = False, + rectify: bool = True, + degenerated_to_sgd: bool = True) -> None: + logger.debug(parse_class_init(locals())) + if 0.0 > lr: + raise ValueError(f"Invalid learning rate: {lr}") + if 0.0 > eps: + raise ValueError(f"Invalid epsilon value: {eps}") + if not 0.0 <= betas[0] < 1.0: + raise ValueError(f"Invalid beta parameter at index 0: {betas[0]}") + if not 0.0 <= betas[1] < 1.0: + raise ValueError(f"Invalid beta parameter at index 1: {betas[1]}") + + self.degenerated_to_sgd = degenerated_to_sgd + if isinstance(params, (list, tuple)) and len(params) > 0 and isinstance(params[0], + dict): + for param in params: + if "betas" in param and (param["betas"][0] != betas[0] + or param["betas"][1] != betas[1]): + param["buffer"] = [[None, None, None] for _ in range(10)] + + defaults = {"lr": lr, + "betas": betas, + "eps": eps, + "weight_decay": weight_decay, + "amsgrad": amsgrad, + "buffer": [[None, None, None] for _ in range(10)]} + super().__init__(params, defaults) + + self.degenerated_to_sgd = degenerated_to_sgd + self.weight_decouple = weight_decouple + self.rectify = rectify + self.fixed_decay = fixed_decay + if self.weight_decouple: + logger.debug("[AdaBelief] Weight decoupling enabled in AdaBelief") + if self.fixed_decay: + logger.debug("[AdaBelief] Weight decay fixed") + if self.rectify: + logger.debug("[AdaBelief] Rectification enabled in AdaBelief") + if amsgrad: + logger.debug("[AdaBelief] AMSGrad enabled in AdaBelief") + + def __setstate__(self, state: dict[str, T.Any]) -> None: + """Set parameter state""" + super().__setstate__(state) + for group in self.param_groups: + group.setdefault("amsgrad", False) + + def reset(self) -> None: + """Reset parameters""" + for group in self.param_groups: + for p in group["params"]: + state = self.state[p] + amsgrad = group["amsgrad"] + + # State initialization + state["step"] = torch.zeros((), dtype=torch.float32) + # Exponential moving average of gradient values + state["exp_avg"] = torch.zeros_like(p.data, memory_format=torch.preserve_format) + + # Exponential moving average of squared gradient values + state["exp_avg_var"] = torch.zeros_like(p.data, + memory_format=torch.preserve_format) + + if amsgrad: + # Maintains max of all exp. moving avg. of sq. grad. values + state["max_exp_avg_var"] = torch.zeros_like( + p.data, memory_format=torch.preserve_format) + + def step(self, # type:ignore[override] # noqa[C901] + closure: T.Callable | None = None) -> torch.Tensor: + """Performs a single optimization step. + + Parameters + ---------- + closure + A closure that reevaluates the model and returns the loss. Default: ``None`` + """ + # pylint:disable=duplicate-code,too-many-statements,too-many-branches,too-many-locals + loss: torch.Tensor | None = None + if closure is not None: + loss = closure() + + for group in self.param_groups: + for p in group["params"]: + if p.grad is None: + continue + + # cast data type + half_precision = False + if p.data.dtype == torch.float16: + half_precision = True + p.data = p.data.float() + p.grad = p.grad.float() + + grad = p.grad.data + if grad.is_sparse: + raise RuntimeError( + "AdaBelief does not support sparse gradients, please consider SparseAdam " + "instead") + amsgrad = group["amsgrad"] + + state = self.state[p] + + beta1, beta2 = group["betas"] + + # State initialization + if len(state) == 0: + state["step"] = torch.zeros((), dtype=torch.float32) + # Exponential moving average of gradient values + state["exp_avg"] = torch.zeros_like(p.data, + memory_format=torch.preserve_format) + # Exponential moving average of squared gradient values + state["exp_avg_var"] = torch.zeros_like(p.data, + memory_format=torch.preserve_format) + if amsgrad: + # Maintains max of all exp. moving avg. of sq. grad. values + state["max_exp_avg_var"] = torch.zeros_like( + p.data, memory_format=torch.preserve_format) + + # perform weight decay, check if decoupled weight decay + if self.weight_decouple: + if not self.fixed_decay: + p.data.mul_(1.0 - group["lr"] * group["weight_decay"]) + else: + p.data.mul_(1.0 - group["weight_decay"]) + else: + if group["weight_decay"] != 0: + grad.add_(p.data, alpha=group["weight_decay"]) + + # get current state variable + exp_avg, exp_avg_var = state["exp_avg"], state["exp_avg_var"] + + state["step"] += 1 + bias_correction1 = 1 - beta1 ** state["step"] + bias_correction2 = 1 - beta2 ** state["step"] + + # Update first and second moment running average + exp_avg.mul_(beta1).add_(grad, alpha=1 - beta1) + grad_residual = grad - exp_avg + exp_avg_var.mul_(beta2).addcmul_(grad_residual, grad_residual, value=1 - beta2) + + if amsgrad: + max_exp_avg_var = state["max_exp_avg_var"] + # Maintains the maximum of all 2nd moment running avg. till now + torch.max(max_exp_avg_var, exp_avg_var.add_(group["eps"]), out=max_exp_avg_var) + + # Use the max. for normalizing running avg. of gradient + denom = (max_exp_avg_var.sqrt() / + math.sqrt(bias_correction2)).add_(group["eps"]) + else: + denom = (exp_avg_var.add_(group["eps"]).sqrt() / + math.sqrt(bias_correction2)).add_(group["eps"]) + + # update + if not self.rectify: + # Default update + step_size = group["lr"] / bias_correction1 + p.data.addcdiv_(exp_avg, denom, value=-step_size) + + else: # Rectified update, forked from RAdam + buffered = group["buffer"][int(state["step"] % 10)] + if state["step"] == buffered[0]: + n_sma, step_size = buffered[1], buffered[2] + else: + buffered[0] = state["step"] + beta2_t = beta2 ** state["step"] + n_sma_max = 2 / (1 - beta2) - 1 + n_sma = n_sma_max - 2 * state["step"] * beta2_t / (1 - beta2_t) + buffered[1] = n_sma + + # more conservative since it"s an approximated value + if n_sma >= 5: + step_size = math.sqrt( + (1 - beta2_t) * (n_sma - 4) / + (n_sma_max - 4) * (n_sma - 2) / + n_sma * n_sma_max / (n_sma_max - 2)) / (1 - beta1 ** state["step"]) + elif self.degenerated_to_sgd: + step_size = 1.0 / (1 - beta1 ** state["step"]) + else: + step_size = -1 + buffered[2] = step_size + + if n_sma >= 5: + denom = exp_avg_var.sqrt().add_(group["eps"]) + p.data.addcdiv_(exp_avg, denom, value=-step_size * group["lr"]) + elif step_size > 0: + p.data.add_(exp_avg, alpha=-step_size * group["lr"]) + + if half_precision: + p.data = p.data.half() + p.grad = p.grad.half() + + return T.cast(torch.Tensor, loss) + + +__all__ = get_module_objects(__name__) diff --git a/lib/model/optimizers.py b/lib/model/optimizers/keras_legacy.py similarity index 76% rename from lib/model/optimizers.py rename to lib/model/optimizers/keras_legacy.py index 835258ad28..530b36ad37 100644 --- a/lib/model/optimizers.py +++ b/lib/model/optimizers/keras_legacy.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Custom Optimizers for Torch/keras """ +"""Legacy keras Optimizers for weight migration""" from __future__ import annotations import inspect import logging @@ -12,13 +12,14 @@ from lib.utils import get_module_objects if T.TYPE_CHECKING: - from keras import KerasTensor, Variable + from torch import Tensor + from keras import Variable logger = logging.getLogger(__name__) class AdaBelief(Optimizer): # pylint:disable=too-many-instance-attributes,too-many-ancestors - """ Implementation of the AdaBelief Optimizer + """Implementation of the AdaBelief Optimizer Inherits from: keras.optimizers.Optimizer. @@ -32,30 +33,30 @@ class AdaBelief(Optimizer): # pylint:disable=too-many-instance-attributes,too-m Parameters ---------- - learning_rate: `Tensor`, float or :class: `keras.optimizers.schedules.LearningRateSchedule` + learning_rate The learning rate. - beta_1: float + beta_1 The exponential decay rate for the 1st moment estimates. - beta_2: float + beta_2 The exponential decay rate for the 2nd moment estimates. - epsilon: float + epsilon A small constant for numerical stability. - amsgrad: bool + amsgrad Whether to apply AMSGrad variant of this algorithm from the paper "On the Convergence of Adam and beyond". - rectify: bool + rectify Whether to enable rectification as in RectifiedAdam - sma_threshold. float + sma_threshold The threshold for simple mean average. - total_steps: int + total_steps Total number of training steps. Enable warmup by setting a positive value. - warmup_proportion: float + warmup_proportion The proportion of increasing steps. - min_lr: float + min_lr Minimum learning rate after warmup. - name: str, optional + name Name for the operations created when applying gradients. Default: ``"AdaBeliefOptimizer"``. - **kwargs: dict + **kwargs Standard Keras Optimizer keyword arguments. Allowed to be (`weight_decay`, `clipnorm`, `clipvalue`, `global_clipnorm`, `use_ema`, `ema_momentum`, `ema_overwrite_frequency`, `loss_scale_factor`, `gradient_accumulation_steps`) @@ -90,7 +91,7 @@ class AdaBelief(Optimizer): # pylint:disable=too-many-instance-attributes,too-m References ---------- - Juntang Zhuang et al. - AdaBelief Optimizer: Adapting stepsizes by the belief in observed + Juntang Zhuang et al. - AdaBelief Optimizer: Adapting step sizes by the belief in observed gradients - https://arxiv.org/abs/2010.07468. Original implementation - https://github.com/juntang-zhuang/Adabelief-Optimizer @@ -148,13 +149,9 @@ def __init__(self, # pylint:disable=too-many-arguments,too-many-positional-argu self.amsgrad = amsgrad self.rectify = rectify self.sma_threshold = sma_threshold - # TODO change the following 2 to "warm_up_steps" - # TODO Make learning rate warm up a global option - # Or these params can be calculated from a user "warm_up_steps" parameter self.total_steps = total_steps self.warmup_proportion = warmup_proportion self.min_learning_rate = min_learning_rate - logger.debug("Initialized %s", self.__class__.__name__) self._momentums: list[Variable] = [] self._velocities: list[Variable] = [] @@ -168,7 +165,7 @@ def build(self, variables: list[Variable]) -> None: Parameters ---------- - variables: list[:class:`keras.Variable`] + variables list of model variables to build AdaBelief variables on. """ if self.built: @@ -187,20 +184,19 @@ def build(self, variables: list[Variable]) -> None: logger.debug("Built AdaBelief. momentums: %s, velocities: %s, velocity_hats: %s", len(self._momentums), len(self._velocities), len(self._velocity_hats)) - def _maybe_warmup(self, learning_rate: KerasTensor, local_step: KerasTensor) -> KerasTensor: - """ Do learning rate warm up if requested + def _maybe_warmup(self, learning_rate: Tensor, local_step: Tensor) -> Tensor: + """Do learning rate warm up if requested Parameters ---------- - learning_rate: :class:`keras.KerasTensor` + learning_rate The learning rate - local_step: :class:`keras.KerasTensor` + local_step The current training step Returns ------- - :class:`keras.KerasTensor` - Either the original learning rate or adjusted learning rate if warmup is requested + Either the original learning rate or adjusted learning rate if warmup is requested """ if self.total_steps <= 0: return learning_rate @@ -210,74 +206,78 @@ def _maybe_warmup(self, learning_rate: KerasTensor, local_step: KerasTensor) -> min_lr = ops.cast(self.min_learning_rate, learning_rate.dtype) decay_steps = ops.maximum(total_steps - warmup_steps, 1) decay_rate = ops.divide(min_lr - learning_rate, decay_steps) - return ops.where(local_step <= warmup_steps, - ops.multiply(learning_rate, (ops.divide(local_step, warmup_steps))), - ops.multiply(learning_rate + decay_rate, - ops.minimum(local_step - warmup_steps, decay_steps))) + return T.cast("Tensor", + ops.where(local_step <= warmup_steps, + ops.multiply(learning_rate, + (ops.divide(local_step, warmup_steps))), + ops.multiply(learning_rate + decay_rate, + ops.minimum(local_step - warmup_steps, decay_steps)))) def _maybe_rectify(self, - momentum: KerasTensor, - velocity: KerasTensor, - local_step: KerasTensor, - beta_2_power: KerasTensor) -> KerasTensor: - """ Apply rectification, if requested + momentum: Tensor, + velocity: Tensor, + local_step: Tensor, + beta_2_power: Tensor) -> Tensor: + """Apply rectification, if requested Parameters ---------- - momentum: :class:`keras.KerasTensor` + momentum The momentum update - velocity: :class:`keras.KerasTensor` + velocity The velocity update - local_step: :class:`keras.KerasTensor` + local_step The current training step beta_2_power Adjusted exponential decay rate for the 2nd moment estimates. Returns ------- - :class:`keras.KerasTensor` - The standard or rectified update (if rectification enabled) + The standard or rectified update (if rectification enabled) """ if not self.rectify: - return ops.divide(momentum, ops.add(velocity, self.epsilon)) + return T.cast("Tensor", ops.divide(momentum, ops.add(velocity, self.epsilon))) sma_inf = 2 / (1 - self.beta_2) - 1 sma_t = sma_inf - 2 * local_step * beta_2_power / (1 - beta_2_power) rect = ops.sqrt((sma_t - 4) / (sma_inf - 4) * (sma_t - 2) / (sma_inf - 2) * sma_inf / sma_t) - return ops.where(sma_t >= self.sma_threshold, - ops.divide( - ops.multiply(rect, momentum), - (ops.add(velocity, self.epsilon))), - momentum) + return T.cast("Tensor", + ops.where(sma_t >= self.sma_threshold, + ops.divide(ops.multiply(rect, momentum), + (ops.add(velocity, self.epsilon))), + momentum)) def update_step(self, - gradient: KerasTensor, + gradient: Tensor, variable: Variable, - learning_rate: Variable) -> None: + learning_rate: Tensor) -> None: """Update step given gradient and the associated model variable for AdaBelief. Parameters ---------- - gradient :class:`keras.KerasTensor` + gradient The gradient to update - variable: :class:`keras.Variable` + variable The variable to update - learning_rate: :class:`keras.Variable` + learning_rate The learning rate """ - local_step = ops.cast(self.iterations + 1, variable.dtype) - learning_rate = self._maybe_warmup(ops.cast(learning_rate, variable.dtype), local_step) - gradient = ops.cast(gradient, variable.dtype) + local_step = T.cast("Tensor", ops.cast(self.iterations + 1, variable.dtype)) + learning_rate = self._maybe_warmup(T.cast("Tensor", + ops.cast(learning_rate, variable.dtype)), + local_step) + gradient = T.cast("Tensor", ops.cast(gradient, variable.dtype)) beta_1_power = ops.power(ops.cast(self.beta_1, variable.dtype), local_step) - beta_2_power = ops.power(ops.cast(self.beta_2, variable.dtype), local_step) + beta_2_power = T.cast("Tensor", + ops.power(ops.cast(self.beta_2, variable.dtype), local_step)) # m_t = b1 * m + (1 - b1) * g # => m_t = m + (g - m) * (1 - b1) - momentum = self._momentums[self._get_variable_index(variable)] + momentum = T.cast("Variable", self._momentums[self._get_variable_index(variable)]) self.assign_add(momentum, ops.multiply(ops.subtract(gradient, momentum), 1 - self.beta_1)) - momentum_corr = ops.divide(momentum, (1 - beta_1_power)) + momentum_corr = T.cast("Tensor", ops.divide(momentum, (1 - beta_1_power))) # v_t = b2 * v + (1 - b2) * (g - m_t)^2 + e # => v_t = v + ((g - m_t)^2 - v) * (1 - b2) + e @@ -291,16 +291,17 @@ def update_step(self, if self.amsgrad: velocity_hat = self._velocity_hats[self._get_variable_index(variable)] self.assign(velocity_hat, ops.maximum(velocity, velocity_hat)) - velocity_corr = ops.sqrt(ops.divide(velocity_hat, (1 - beta_2_power))) + velocity_corr = T.cast("Tensor", + ops.sqrt(ops.divide(velocity_hat, (1 - beta_2_power)))) else: - velocity_corr = ops.sqrt(ops.divide(velocity, (1 - beta_2_power))) + velocity_corr = T.cast("Tensor", ops.sqrt(ops.divide(velocity, (1 - beta_2_power)))) var_t = self._maybe_rectify(momentum_corr, velocity_corr, local_step, beta_2_power) self.assign_sub(variable, ops.multiply(learning_rate, var_t)) def get_config(self) -> dict[str, T.Any]: - """ Returns the config of the optimizer. + """Returns the config of the optimizer. Optimizer configuration for AdaBelief. diff --git a/lib/model/optimizers/lion.py b/lib/model/optimizers/lion.py new file mode 100644 index 0000000000..de90119178 --- /dev/null +++ b/lib/model/optimizers/lion.py @@ -0,0 +1,110 @@ +#! /usr/env/bin/python3 +"""PyTorch implementation of the Lion optimizer.""" +# Copyright 2023 Google Research. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +import logging +import typing as T + +import torch +from torch.optim.optimizer import Optimizer + +from lib.logger import parse_class_init +from lib.utils import get_module_objects + +logger = logging.getLogger(__name__) + + +class Lion(Optimizer): + """Lion optimizer from Google + + Parameters + ---------- + params + Iterable of parameters to optimize or dicts defining parameter groups + lr + Learning rate. Default: 1e-4 + betas + Coefficients used for computing running averages of gradient and its square. + Default: (0.9, 0.99) + weight_decay + Weight decay coefficient. Default: 0 + + Reference + --------- + https://github.com/google/automl/blob/master/lion/lion_pytorch.py + """ + def __init__(self, + params: T.Iterable, + lr: float = 1e-4, + betas: tuple[float, float] = (0.9, 0.99), + weight_decay: float = 0.0) -> None: + logger.debug(parse_class_init(locals())) + if 0.0 > lr: + raise ValueError(f"Invalid learning rate: {lr}") + if not 0.0 <= betas[0] < 1.0: + raise ValueError(f"Invalid beta parameter at index 0: {betas[0]}") + if not 0.0 <= betas[1] < 1.0: + raise ValueError(f"Invalid beta parameter at index 1: {betas[1]}") + defaults = {"lr": lr, "betas": betas, "weight_decay": weight_decay} + super().__init__(params, defaults) + + @torch.no_grad() + def step(self, closure: T.Callable | None = None) -> torch.Tensor: # type:ignore[override] + """Performs a single optimization step. + + Parameters + ---------- + closure + A closure that reevaluates the model and returns the loss. + + Returns + ------- + The loss + """ + loss = None + if closure is not None: + with torch.enable_grad(): + loss = closure() + + for group in self.param_groups: + for p in group["params"]: + if p.grad is None: + continue + + # Perform step weight decay + p.data.mul_(1 - group["lr"] * group["weight_decay"]) + + grad = p.grad + state = self.state[p] + # State initialization + if len(state) == 0: + # Exponential moving average of gradient values + state["exp_avg"] = torch.zeros_like(p) + + exp_avg = state["exp_avg"] + beta1, beta2 = group["betas"] + + # Weight update + update = exp_avg * beta1 + grad * (1 - beta1) + + p.add_(update.sign_(), alpha=-group["lr"]) + + # Decay the momentum running average coefficient + exp_avg.mul_(beta2).add_(grad, alpha=1 - beta2) + + return T.cast(torch.Tensor, loss) + + +__all__ = get_module_objects(__name__) diff --git a/lib/training/__init__.py b/lib/training/__init__.py index a6433c1e1d..e06533d81e 100644 --- a/lib/training/__init__.py +++ b/lib/training/__init__.py @@ -4,8 +4,6 @@ from __future__ import annotations import typing as T -from .lr_finder import LearningRateFinder -from .lr_warmup import LearningRateWarmup from .preview_cv import PreviewBuffer, TriggerType if T.TYPE_CHECKING: diff --git a/lib/training/lr_finder.py b/lib/training/lr_finder.py index c4f528c871..a99b6a251d 100644 --- a/lib/training/lr_finder.py +++ b/lib/training/lr_finder.py @@ -15,11 +15,10 @@ from lib.logger import parse_class_init from lib.utils import get_module_objects -from plugins.train import train_config as cfg if T.TYPE_CHECKING: - import torch - from keras import optimizers + from torch import Tensor + from torch.optim.lr_scheduler import ExponentialLR from . import train logger = logging.getLogger(__name__) @@ -39,40 +38,48 @@ class LearningRateFinder: # pylint:disable=too-many-instance-attributes ---------- trainer The training loop with the loaded training plugin + scheduler + The LRFinder scheduler + steps + The number of steps to run the finder for + strength + How aggressively to set the optimal learning rate + mode + The mode to run the Learning Rate Finder in stop_factor When to stop finding the optimal learning rate beta Amount to smooth loss by, for graphing purposes """ - def __init__(self, # pylint:disable=too-many-positional-arguments + def __init__(self, trainer: train.Trainer, + scheduler: ExponentialLR, + steps: int, + strength: T.Literal["default", "aggressive", "extreme"], + mode: T.Literal["set", "graph_and_set", "graph_and_exit"], stop_factor: int = 4, beta: float = 0.98) -> None: logger.debug(parse_class_init(locals())) - self._iterations = cfg.lr_finder_iterations() - self._save_graph = cfg.lr_finder_mode() in ("graph_and_set", "graph_and_exit") - self._strength = LRStrength[cfg.lr_finder_strength().upper()].value - - self._start_lr = 1e-10 - end_lr = 1e+1 - self._trainer = trainer - - self._model = trainer._plugin.model - self._optimizer = trainer._plugin.model.model.optimizer - + self._scheduler = scheduler + self._steps = steps + self._strength = LRStrength[strength.upper()].value + self._mode = mode self._stop_factor = stop_factor self._beta = beta - self._lr_multiplier: float = (end_lr / self._start_lr) ** (1.0 / self._iterations) - self._metrics: dict[T.Literal["learning_rates", "losses"], list[float]] = { - "learning_rates": [], - "losses": []} + self._model = trainer._plugin.model + self._losses: list[float] = [] + self._learning_rates: list[float] = [] self._loss: dict[T.Literal["avg", "best"], float] = {"avg": 0.0, "best": 1e9} + self._best_lr: None | float = None - logger.debug("Initialized %s", self.__class__.__name__) + @property + def best_lr(self) -> None | float: + """The discovered best learning rate or ``None`` if not found""" + return self._best_lr - def _on_batch_end(self, iteration: int, loss: float) -> None: + def _on_batch_end(self, iteration: int, loss: float) -> bool: """Learning rate actions to perform at the end of a batch Parameters @@ -81,26 +88,29 @@ def _on_batch_end(self, iteration: int, loss: float) -> None: The current iteration loss The loss value for the current batch + + Returns + ------- + ``True`` if training should cease. ``False`` to continue """ - learning_rate = float(self._optimizer.learning_rate.numpy()) - self._metrics["learning_rates"].append(learning_rate) + if np.isnan(loss): + logger.info("Loss has NaN'd. Exiting early") + return True + self._learning_rates.append(T.cast(float, self._scheduler.get_last_lr()[0])) self._loss["avg"] = (self._beta * self._loss["avg"]) + ((1 - self._beta) * loss) smoothed = self._loss["avg"] / (1 - (self._beta ** iteration)) - self._metrics["losses"].append(smoothed) + self._losses.append(smoothed) stop_loss = self._stop_factor * self._loss["best"] - if iteration > 1 and smoothed > stop_loss: - self._model.model.stop_training = True - return + logger.info("Loss has diverged. Exiting early") + return True if iteration == 1 or smoothed < self._loss["best"]: self._loss["best"] = smoothed - learning_rate *= self._lr_multiplier - - self._optimizer.learning_rate.assign(learning_rate) + return False def _update_description(self, progress_bar: tqdm) -> None: """Update the description of the progress bar for the current iteration @@ -110,106 +120,46 @@ def _update_description(self, progress_bar: tqdm) -> None: progress_bar The learning rate finder progress bar to update """ - current = self._metrics['learning_rates'][-1] - best_idx = self._metrics["losses"].index(self._loss["best"]) - best = self._metrics["learning_rates"][best_idx] / self._strength + current = self._learning_rates[-1] + best_idx = self._losses.index(self._loss["best"]) + best = self._learning_rates[best_idx] / self._strength progress_bar.set_description(f"Current: {current:.1e} Best: {best:.1e}") def _train(self) -> None: """Train the model for the given number of iterations to find the optimal learning rate and show progress""" logger.info("Finding optimal learning rate...") - p_bar = tqdm(range(1, self._iterations + 1), + p_bar = tqdm(range(1, self._steps + 1), desc="Current: N/A Best: N/A ", leave=False) for idx in p_bar: loss = self._trainer.train_one_batch() - total_loss = T.cast("torch.Tensor", sum(x.total for x in loss)).item() + total_loss = T.cast("Tensor", sum(x.total for x in loss)).item() - if np.isnan(total_loss): - logger.warning("NaN detected! Exiting early") + if self._on_batch_end(idx, total_loss): + logger.debug("[LearningRateFinder] Exiting early") break - self._on_batch_end(idx, total_loss) - self._update_description(p_bar) - def _rebuild_optimizer(self, optimizer: optimizers.Optimizer) -> optimizers.Optimizer: - """Pass through nested Optimizers (eg LossScaleOptimizer) and create new nested - optimizers based on their original config + self._update_description(p_bar) - Returns - ------- - A new optimizer of the same type as the given one, with the same config - """ - logger.debug("Processing optimizer: '%s'", optimizer.name) - config = optimizer.get_config() - if hasattr(optimizer, "inner_optimizer"): - config["inner_optimizer"] = self._rebuild_optimizer(optimizer.inner_optimizer) - retval = optimizer.__class__(**config) - logger.debug("Created optimizer '%s': (old: %s, new: %s)", - optimizer.name, optimizer, retval) - return retval - - def _reset_model(self, original_lr: float, new_lr: float) -> None: + def _reset_model(self, new_lr: float) -> None: """Reset the model's weights to initial values, reset the model's optimizer and set the learning rate Parameters ---------- - original_lr - The model's original learning rate new_lr The discovered optimal learning rate """ self._model.state.add_lr_finder(new_lr) self._model.state.save() - if cfg.lr_finder_mode() == "graph_and_exit": + if self._mode == "graph_and_exit": return - logger.debug("Resetting optimizer") - optimizer = self._rebuild_optimizer(self._optimizer) - del self._optimizer - del self._model.model.optimizer - logger.info("Loading initial weights") self._model.model.load_weights(self._model.io.filename) - self._model.model.compile(optimizer=optimizer, - loss=self._model.model.loss, - metrics=self._model.model.loss) - - logger.info("Updating Learning Rate from %s to %s", f"{original_lr:.1e}", f"{new_lr:.1e}") - self._model.model.optimizer.learning_rate.assign(new_lr) - self._optimizer = self._model.model.optimizer - - def find(self) -> bool: - """Find the optimal learning rate - - Returns - ------- - ``True`` if the learning rate was successfully discovered otherwise ``False`` - """ - if not self._model.io.model_exists: - self._model.io.save() - - original_lr = float(self._model.model.optimizer.learning_rate.numpy()) - self._model.model.optimizer.learning_rate.assign(self._start_lr) - - self._train() - print("\x1b[2K", end="\r") # Clear line - - best_idx = self._metrics["losses"].index(self._loss["best"]) - new_lr = self._metrics["learning_rates"][best_idx] / self._strength - if new_lr < 1e-9: - logger.error("The optimal learning rate could not be found. This is most likely " - "because you did not run the finder for enough iterations.") - shutil.rmtree(self._model.io.model_dir) - return False - - self._plot_loss() - self._reset_model(original_lr, new_lr) - return True - def _plot_loss(self, skip_begin: int = 10, skip_end: int = 1) -> None: """Plot a graph of loss vs learning rate and save to the training folder @@ -220,15 +170,15 @@ def _plot_loss(self, skip_begin: int = 10, skip_end: int = 1) -> None: skip_end Number of iterations to skip at the end. Default: `1` """ - if not self._save_graph: + if self._mode not in ("graph_and_set", "graph_and_exit"): return matplotlib.use("Agg") - lrs = self._metrics["learning_rates"][skip_begin:-skip_end] - losses = self._metrics["losses"][skip_begin:-skip_end] + lrs = self._learning_rates[skip_begin:-skip_end] + losses = self._losses[skip_begin:-skip_end] plt.plot(lrs, losses, label="Learning Rate") - best_idx = self._metrics["losses"].index(self._loss["best"]) - best_lr = self._metrics["learning_rates"][best_idx] + best_idx = self._losses.index(self._loss["best"]) + best_lr = self._learning_rates[best_idx] for val, color in zip(LRStrength, ("g", "y", "r")): l_r = best_lr / val.value idx = lrs.index(next(r for r in lrs if r >= l_r)) @@ -247,5 +197,25 @@ def _plot_loss(self, skip_begin: int = 10, skip_end: int = 1) -> None: logger.info("Saving Learning Rate Finder graph to: '%s'", output) plt.savefig(output) + def find(self) -> None: + """Find the optimal learning rate""" + if not self._model.io.model_exists: + self._model.io.save() + + self._train() + print("\x1b[2K", end="\r") # Clear line + + best_idx = self._losses.index(self._loss["best"]) + new_lr = self._learning_rates[best_idx] / self._strength + if new_lr < 1e-9: + logger.error("The optimal learning rate could not be found. This is most likely " + "because you did not run the finder for enough iterations.") + shutil.rmtree(self._model.io.model_dir) + return + + self._best_lr = new_lr + self._plot_loss() + self._reset_model(new_lr) + __all__ = get_module_objects(__name__) diff --git a/lib/training/lr_warmup.py b/lib/training/lr_warmup.py index 6bbb33ee7e..9245dc8b0a 100644 --- a/lib/training/lr_warmup.py +++ b/lib/training/lr_warmup.py @@ -1,104 +1,113 @@ #! /usr/env/bin/python3 -""" Handles Learning Rate Warmup when training a model """ +"""Handles Learning Rate Warmup when training a model""" from __future__ import annotations import logging import typing as T +from torch.optim.lr_scheduler import LRScheduler + +from lib.logger import parse_class_init from lib.utils import get_module_objects if T.TYPE_CHECKING: - from keras import models + from torch import Tensor + from torch.optim import Optimizer logger = logging.getLogger(__name__) -class LearningRateWarmup(): - """ Handles the updating of the model's learning rate during Learning Rate Warmup +class WarmupScheduler(LRScheduler): + """Handles the updating of the model's learning rate during Learning Rate Warmup Parameters ---------- - model : :class:`keras.models.Model` - The keras model that is to be trained - target_learning_rate : float - The final learning rate at the end of warmup - steps : int + optimizer + The torch optimizer in use + steps The number of iterations to warmup the learning rate for + last_epoch + The last step that was run (last_epoch is a misnomer inherited from PyTorch and actually + refers to steps in our use case). Default: -1 (not yet started) """ - def __init__(self, model: models.Model, target_learning_rate: float, steps: int) -> None: - self._model = model - self._target_lr = target_learning_rate - self._steps = steps - self._current_lr = 0.0 - self._current_step = 0 - self._reporting_points = [int(self._steps * i / 10) for i in range(11)] - logger.debug("Initialized %s", self) - - def __repr__(self) -> str: - """ Pretty string representation for logging """ - call_args = ", ".join(f"{k}={v}" for k, v in {"model": self._model, - "target_learning_rate": self._target_lr, - "steps": self._steps}.items()) - current_params = ", ".join(f"{k[1:]}: {v}" for k, v in self.__dict__.items() - if k not in ("_model", "_target_lr", "_steps")) - return f"{self.__class__.__name__}({call_args}) [{current_params}]" + def __init__(self, optimizer: Optimizer, steps: int, last_epoch: int = -1) -> None: + logger.debug(parse_class_init(locals())) + self.steps = steps + """The total number of steps to warmup the LR for""" + self._reporting_points = [int(self.steps * i / 10) for i in range(11)] + super().__init__(optimizer, last_epoch) @classmethod - def _format_notation(cls, value: float) -> str: - """ Format a float to scientific notation at 1 decimal place + def _fmt(cls, value: float) -> str: + """Format a float to scientific notation at 1 decimal place Parameters ---------- - value : float + value The value to format Returns ------- - str - The formatted float in scientific notation at 1 decimal place + The formatted float in scientific notation at 1 decimal place """ return f"{value:.1e}" - def _set_learning_rate(self) -> None: - """ Set the learning rate for the current step """ - self._current_lr = self._current_step / self._steps * self._target_lr - self._model.optimizer.learning_rate.assign(self._current_lr) - logger.debug("Learning rate set to %s for step %s/%s", - self._current_lr, self._current_step, self._steps) + def get_lr(self) -> list[float | Tensor]: + """Get the learning rate for the current step + + Returns + ------- + The next learning rate for each parameter group for the next step + """ + if self.last_epoch >= self.steps: + return self.base_lrs + + factor = self.last_epoch / self.steps + lrs = [base_lr * factor for base_lr in self.base_lrs] + logger.trace("Learning rate set to %s for step %s/%s", # type:ignore[attr-defined] + lrs, self.last_epoch, self.steps) + return lrs def _output_status(self) -> None: - """ Output the progress of Learning Rate Warmup at set intervals """ - if self._current_step == 1: + """Output the progress of Learning Rate Warmup at set intervals""" + step = self.last_epoch + if step < 1: + return + + current_lr = T.cast(float, self.get_last_lr()[0]) + target_lr = T.cast(float, self.base_lrs[0]) + + if step == 1: logger.info("[Learning Rate Warmup] Start: %s, Target: %s, Steps: %s", - self._format_notation(self._current_lr), - self._format_notation(self._target_lr), self._steps) + self._fmt(current_lr), self._fmt(target_lr), self.steps) return - if self._current_step == self._steps: + if step == self.steps: print() - logger.info("[Learning Rate Warmup] Final Learning Rate: %s", - self._format_notation(self._target_lr)) + logger.info("[Learning Rate Warmup] Final Learning Rate: %s", self._fmt(target_lr)) return - if self._current_step in self._reporting_points: + if step in self._reporting_points: print() progress = int(round(100 / (len(self._reporting_points) - 1) * - self._reporting_points.index(self._current_step), 0)) + self._reporting_points.index(step), 0)) logger.info("[Learning Rate Warmup] Step: %s/%s (%s), Current: %s, Target: %s", - self._current_step, - self._steps, + step, + self.steps, f"{progress}%", - self._format_notation(self._current_lr), - self._format_notation(self._target_lr)) + self._fmt(current_lr), + self._fmt(target_lr)) - def __call__(self) -> None: - """ If a learning rate update is required, update the model's learning rate, otherwise - do nothing """ - if self._steps == 0 or self._current_step >= self._steps: - return + def step(self, epoch=None) -> None: + """If a learning rate update is required, update the model's learning rate, otherwise + do nothing - self._current_step += 1 - self._set_learning_rate() + Parameters + ---------- + epoch + Deprecated argument from PyTorch that should always be ``None``. Default: ``None`` + """ + super().step(epoch) self._output_status() diff --git a/lib/training/optimizer.py b/lib/training/optimizer.py new file mode 100644 index 0000000000..3fc9d39d35 --- /dev/null +++ b/lib/training/optimizer.py @@ -0,0 +1,497 @@ +#!/usr/bin/env python3 +"""Wraps the selected Torch optimizer and handles optimizer related functions such as loss scaling, +clipping and gradient accumulation""" +from __future__ import annotations + +import logging +import typing as T + +import torch +from torch import nn +from torch.optim.lr_scheduler import ExponentialLR + +from lib.logger import parse_class_init +from lib.model.autoclip import AutoClipper +from lib.model import optimizers +from lib.utils import get_module_objects + +from .lr_finder import LearningRateFinder +from .lr_warmup import WarmupScheduler + +if T.TYPE_CHECKING: + from keras import Model as K_Model, Variable + from plugins.train.model._base import ModelBase as Model + from plugins.train.train_config import Optimizer as OptConfig + from .train import Trainer + + +logger = logging.getLogger(__name__) + +_OPTIMIZERS = {"adabelief": optimizers.AdaBelief, + "adam": torch.optim.Adam, + "adamax": torch.optim.Adamax, + "adamw": torch.optim.AdamW, + "lion": optimizers.Lion, + "nadam": torch.optim.NAdam, + "rms-prop": torch.optim.RMSprop} + + +def get_parameter_group_ids(trainable_variables: list[Variable] + ) -> dict[int, T.Literal["decay", "no_decay"]]: + """Obtain the index of each item in the keras model's trainable weights that belong to each + of the optimizer's parameter groups (ie split by weights that take decay and don't take decay) + + Parameters + ---------- + trainable_variables + list of trainable variables from keras model + + Returns + ------- + dictionary of keras model's trainable weight index to the name of the parameter group + """ + retval: dict[int, T.Literal["decay", "no_decay"]] = {} + for idx, var in enumerate(trainable_variables): + retval[idx] = "no_decay" if var.ndim <= 1 or var.name.endswith("bias") else "decay" + + logger.debug("parameter group ids: %s", retval) + return retval + + +class GradClip: + """Handles the clipping of gradients based on user supplied parameters + + Parameters + ---------- + method + The clipping method to use + value + The clipping value to use. For autoclip this is the percentile to clip at (a value of 1.0 + will clip at the 10th percentile a value of 2.5 will clip at the 25th percentile etc) + autoclip_history + The history length for auto clipping. Default: 10000 + """ + def __init__(self, + method: T.Literal["autoclip", "global_norm", "norm", "value"], + value: float, + autoclip_history: int = 10000) -> None: + logger.debug(parse_class_init(locals())) + self._value = value + self._clipper = self._get_clipper(method, autoclip_history) + + @classmethod + def _clip_norm(cls, parameters: list[nn.Parameter], max_norm: float) -> None: + """Clip each parameter independently by its own norm + + Parameters + ---------- + parameters + The parameters to clip + max_norm + The value to clip by + """ + with torch.no_grad(): + for param in parameters: + if param.grad is None: + continue + grad = param.grad + norm = grad.norm(2) + if norm > max_norm: + grad.mul_(max_norm / norm) + + def _get_clipper(self, + method: T.Literal["autoclip", "global_norm", "norm", "value"], + autoclip_history: int) -> T.Callable[[list[nn.Parameter], float], + None | torch.Tensor]: + """Obtain the correct function to clip the gradients based on the selected method + + Parameters + ---------- + method + The clipping method to use + autoclip_history + The history length for auto clipping + + Returns + ------- + The function used to clip the gradients + """ + methods: dict[str, T.Callable[[list[nn.Parameter], float], None | torch.Tensor]] = { + "autoclip": AutoClipper(int(self._value * 10), history_size=autoclip_history), + "global_norm": nn.utils.clip_grad_norm_, + "norm": self._clip_norm, + "value": nn.utils.clip_grad_value_} + if method not in methods: + raise ValueError(f"'{method}' is not a valid clipping method. Select " + f"from {list(methods)}") + retval = methods[method] + logger.debug("[GradClip] Got clipper '%s': %s", method, retval) + return retval + + def __call__(self, parameters: list[nn.Parameter]) -> None: + """Clip the given parameters by the chosen method + + Parameters + ---------- + parameters + The parameters to clip + """ + self._clipper(parameters, self._value) + + +class Optimizer: + """Object for managing the selected Torch optimizer + + Parameters + ---------- + model + The model that is to be trained + config + The optimizer user configuration options + mixed_precision + ``True`` to train using mixed precision. Default: ``False`` + warmup_steps + The number of steps to warmup the learning rate for. Default: 0 + """ + def __init__(self, + model: Model, + config: type[OptConfig], + mixed_precision: bool = False, + warmup_steps: int = 0) -> None: + logger.debug(parse_class_init(locals())) + self._mixed_precision = mixed_precision + self._accumulation_steps = config.gradient_accumulation() + self._scaler = None if not mixed_precision else torch.amp.grad_scaler.GradScaler() + self._clip = None if config.gradient_clipping() == "none" else GradClip( + T.cast(T.Literal["autoclip", "global_norm", "norm", "value"], + config.gradient_clipping()), + config.clipping_value(), + config.autoclip_history()) + + self._optimizer = self._get_optimizer(model.model, config) + self._warmup = None if warmup_steps < 1 else WarmupScheduler(self._optimizer, warmup_steps) + self._lr_scheduler: ExponentialLR | None = None + + self._load_state(model) + + self._accumulation_count = 0 + self._session_steps = 0 + + @classmethod + def _get_optimizer_kwargs(cls, config: type[OptConfig]) -> dict[str, T.Any]: + """Obtain the keyword arguments for the requested optimizer from the user configuration + + Parameters + ---------- + config + The optimizer user configuration options + + Returns + ------- + The optimizer keyword arguments + """ + retval: dict[str, T.Any] = {"weight_decay": config.weight_decay()} + name = config.optimizer() + + if name != "lion": + retval["eps"] = 10 ** config.epsilon_exponent() + + if name in ("adabelief", "adam", "adamw", "adamax", "lion", "nadam"): + retval["betas"] = (config.ada_beta_1(), config.ada_beta_2()) + + if name in ("adabelief", "adam", "adamw"): + retval["amsgrad"] = config.ada_amsgrad() + + logger.debug("[Optimizer] '%s' kwargs: %s", name, retval) + return retval + + def _get_optimizer(self, model: K_Model, config: type[OptConfig]) -> torch.optim.Optimizer: + """Obtain the configured optimizer the given configuration file options + + Parameters + ---------- + model + The keras model that is to be trained + config + The optimizer user configuration options + + Returns + ------- + The requested configured optimizer + """ + name = config.optimizer() + if name not in _OPTIMIZERS: + raise ValueError(f"'{name}' is not a valid optimizer. Select from {list(_OPTIMIZERS)}") + optimizer = _OPTIMIZERS[name] + + retval = optimizer(self._get_parameter_groups(model, config.weight_decay()), + lr=config.learning_rate(), + **self._get_optimizer_kwargs(config)) + logger.debug("[Optimizer] Got optimizer '%s': %s", name, retval) + return retval + + def _get_parameter_groups(self, model: K_Model, weight_decay: float + ) -> tuple[dict[T.Literal["params", "weight_decay"], + list[nn.Parameter] | float], + dict[T.Literal["params", "weight_decay"], + list[nn.Parameter] | float]]: + """Obtain the parameter groups from within the keras model + + Parameters + ---------- + model + The keras model that is to be trained + weight_decay + The amount of weight decay to apply + + Returns + ------- + The parameters that require weight decay in position 0 and no weight decay in position 1 + """ + index_map = get_parameter_group_ids(model.trainable_variables) + groups: dict[T.Literal["decay", "no_decay"], list[nn.Parameter]] = {"decay": [], + "no_decay": []} + # pylint:disable=protected-access + for idx, var in enumerate(model.trainable_variables): + if not hasattr(var, "_value") or not isinstance(var._value, nn.Parameter): + raise RuntimeError( + f"Cannot extract torch parameter from keras.Variable '{var.name}'. " + "Keras version may have changed internal structure.") + groups[index_map[idx]].append(var._value) + + retval: tuple[dict[T.Literal["params", "weight_decay"], list[nn.Parameter] | float], + dict[T.Literal["params", "weight_decay"], list[nn.Parameter] | float]] = ( + {"params": groups["decay"], "weight_decay": weight_decay}, + {"params": groups["no_decay"], "weight_decay": 0.0} + ) + + logger.debug("[Optimizer] decay params: %s, no_decay params: %s", + {k: len(v) if isinstance(v, list) else v for k, v in retval[0].items()}, + {k: len(v) if isinstance(v, list) else v for k, v in retval[1].items()}) + return retval + + def _from_legacy(self, + state: dict[str, T.Any]) -> dict[str, T.Any] | None: + """Populate the remaining param_group items for weights from legacy saved keras optimizer + and validate shapes + + Parameters + ---------- + state + The partial state_dict migrated from a keras optimizer + + Returns + ------- + The final state_dict grouped for torch or ``None`` if weights could not be mapped + """ + logger.debug("[Optimizer] Loading weights from legacy Keras optimizer") + imported_params = state["optimizer"]["state"] + p_groups = self._optimizer.param_groups + exists = [p for g in p_groups for p in g["params"]] + + if len(imported_params) != len(exists): + logger.warning("Imported optimizer weights count mismatch. Optimizer will be reset") + return None + + for idx, exist in enumerate(exists): + # exp_avg for ada based optimizers, square_avg for rms-prop + key = "exp_avg" if "exp_avg" in imported_params[idx] else "square_avg" + if imported_params[idx][key].shape != exist.shape: + logger.warning("Imported optimizer weights shape mismatch. " + "Optimizer will be reset") + return None + + imported_p_groups = state["optimizer"]["param_groups"] + if len(p_groups) != len(imported_p_groups): + logger.warning("Parameter group count mismatch (exists: %s, imported: %s). " + "Optimizer will be reset", len(p_groups), len(imported_p_groups)) + return None + + for idx, group in enumerate(p_groups): + p_group = state["optimizer"]["param_groups"][idx] + state["optimizer"]["param_groups"][idx] = {k: p_group.get(k, v) + for k, v in group.items()} + + return state + + def load_state_dict(self, state_dict: dict[str, T.Any]) -> None: + """Load the serialized data from a state dict into this object + + Parameters + ---------- + state_dict + The serialized data to load + """ + logger.debug("[Optimizer] Loading state_dict") + self._optimizer.load_state_dict(state_dict["optimizer"]) + if self._scaler is not None and state_dict.get("scaler") is not None: + logger.debug("[Optimizer] Loading scaler state_dict: %s", state_dict["scaler"]) + self._scaler.load_state_dict(state_dict["scaler"]) + + def _load_state(self, model: Model) -> None: + """Load weights if resuming and optimizer weights exist within the model file. + + Also handles migration of legacy Keras optimizer weights to torch optimizer + + Parameters + ---------- + model + The model that is to be trained + """ + if not model.io.model_exists: + logger.debug("[Optimizer] Model file does not exist. Not loading state") + return + + state = model.io.load_optimizer() + if state is None: + logger.debug("[Optimizer] No optimizer saved in model file") + return + + if state["version"] == 0.5: # Migrating from keras optimizer + state = self._from_legacy(state) + if state is None: + return + + self.load_state_dict(state_dict=state) + + def backward(self, loss: torch.Tensor) -> None: + """Perform the optimizer's backward pass + + Parameters + ---------- + loss + The loss scalar from the forward pass + """ + scaled = loss / self._accumulation_steps + if self._scaler: + self._scaler.scale(scaled).backward() + else: + scaled.backward() + + def step(self) -> None: + """Perform the optimizer step if valid and zero the gradients. + + Handles gradient accumulation, scaling for mixed precision and gradient clipping + """ + self._accumulation_count += 1 + if self._accumulation_count != self._accumulation_steps: + return + + if self._clip is not None and self._scaler is not None: + self._scaler.unscale_(self._optimizer) + if self._clip is not None: + self._clip([p for g in self._optimizer.param_groups for p in g["params"]]) + + if self._scaler is None: + self._optimizer.step() + else: + self._scaler.step(self._optimizer) + self._scaler.update() + + if self._lr_scheduler is not None: + self._lr_scheduler.step() + elif self._warmup is not None and self._session_steps < self._warmup.steps: + self._session_steps += 1 + self._warmup.step() + + self._optimizer.zero_grad(set_to_none=True) + self._accumulation_count = 0 + + def state_dict(self) -> dict[str, T.Any]: + """Serialized data as a dict for relevant options contained in this class + + Returns + ------- + The serialized data for this object for saving and loading + """ + return {"version": 1.0, + "optimizer": self._optimizer.state_dict(), + "scaler": None if self._scaler is None else self._scaler.state_dict()} + + def to(self, device: torch.Device) -> None: + """Place the optimizer onto the given device + + Parameters + ---------- + device + The device to place the optimizer on to + """ + logger.debug("[Optimizer] to: %s", device) + for state in self._optimizer.state.values(): + for k, v in state.items(): + if isinstance(v, torch.Tensor): + state[k] = v.to(device) + + def set_lr(self, lr: float) -> None: + """Manually assign the optimizer's learning rate with the given value + + Parameters + ---------- + lr + The learning rate to apply to the optimizer + """ + logger.debug("[Optimizer] Setting learning rate to: %s", lr) + for p in self._optimizer.param_groups: + p["lr"] = lr + if "initial_lr" in p: + p["initial_lr"] = lr + + def find_learning_rate(self, + trainer: Trainer, + steps: int, + start_lr: float, + end_lr: float, + strength: T.Literal["default", "aggressive", "extreme"], + mode: T.Literal["set", "graph_and_set", "graph_and_exit"]) -> bool: + """Use the Learning Rate Finder to discover the optimal learning rate + + Parameters + ---------- + trainer + The training loop with the loaded training plugin + steps + The number of iterations to run the learning rate finder for + start_lr + The learning rate to start scanning from + end_lr + The final learning rate to scan until + strength + How aggressively to set the optimal learning rate + mode + The mode to run the Learning Rate Finder in + + Returns + ------- + ``True`` if an optimal learning rate was discovered. + """ + original_lr = self._optimizer.param_groups[0].get("initial_lr", + self._optimizer.param_groups[0]["lr"]) + self.set_lr(start_lr) + opt_state = self._optimizer.state_dict() + scaler_state = None if self._scaler is None else self._scaler.state_dict() + + gamma: float = (end_lr / start_lr) ** (1.0 / steps) + self._lr_scheduler = ExponentialLR(self._optimizer, gamma=gamma) + + lrf = LearningRateFinder(trainer, self._lr_scheduler, steps, strength, mode) + lrf.find() + + del self._lr_scheduler + self._lr_scheduler = None + + if lrf.best_lr is None: + return False + + logger.debug("[Optimizer] Resetting optimizer for LearningRateFinder: %s", opt_state) + self._optimizer.load_state_dict(opt_state) + if self._scaler is not None and scaler_state is not None: + self._scaler.load_state_dict(scaler_state) + + logger.info("Updating Learning Rate from %s to %s", + f"{original_lr:.1e}", f"{lrf.best_lr:.1e}") + self.set_lr(lrf.best_lr) + + return True + + +__all__ = get_module_objects(__name__) diff --git a/lib/training/train.py b/lib/training/train.py index 6b055e24c4..52658fa6c2 100644 --- a/lib/training/train.py +++ b/lib/training/train.py @@ -1,5 +1,5 @@ #! /usr/env/bin/python3 -"""Run the training loop for a training plugin """ +"""Run the training loop for a training plugin""" from __future__ import annotations import logging @@ -16,7 +16,6 @@ from lib.logger import format_array, parse_class_init from lib.torch_utils import get_device -from lib.training import LearningRateFinder, LearningRateWarmup from lib.training.preview import Samples from lib.training.data import get_label, PreviewLoader, TrainLoader from lib.training.tensorboard import TorchTensorBoard @@ -25,6 +24,7 @@ from plugins.train.trainer import trainer_config as trn_cfg from .loss import LossCollator +from .optimizer import Optimizer if T.TYPE_CHECKING: import numpy.typing as npt @@ -53,6 +53,8 @@ class Trainer: # pylint:disable=too-many-instance-attributes The plugin that will be processing each batch preview ``True`` to generate previews + warmup_steps + The number of steps to warmup the learning rate for. Default: 0 timelapse_folders The input folders to create timelapse images from. Default: ``None`` (no timelapse) timelapse_output @@ -62,6 +64,7 @@ class Trainer: # pylint:disable=too-many-instance-attributes def __init__(self, plugin: TrainerBase, preview: bool, + warmup_steps: int = 0, timelapse_folders: list[str] | None = None, timelapse_output: str = "") -> None: logger.debug(parse_class_init(locals())) @@ -74,19 +77,23 @@ def __init__(self, self._model = plugin.model self._out_size = max(x[1] for x in self._model.output_shapes if x[-1] != 1) self._configure_model(plugin) + self._optimizer = Optimizer(self._model, + mod_cfg.Optimizer, + mixed_precision=mod_cfg.mixed_precision(), + warmup_steps=warmup_steps) + self._optimizer.to(self._device) self._train_loader = self._get_train_loader() - self._preview_loader = self._get_preview_loader() - self._timelapse_loader = self._get_timelapse_loader() self._exit_early = self._handle_lr_finder() if self._exit_early: logger.debug("[Trainer] Exiting from LR Finder") return - self._warmup = self._get_warmup() - self._model.state.add_session_batchsize(plugin.batch_size) + self._preview_loader = self._get_preview_loader() + self._timelapse_loader = self._get_timelapse_loader() + self._model.state.add_session_batchsize(plugin.batch_size) self._tensorboard = self._set_tensorboard() self._samples = Samples(self._model.coverage_ratio, mod_cfg.Loss.learn_mask() or mod_cfg.Loss.penalized_mask_loss(), @@ -227,28 +234,26 @@ def _handle_lr_finder(self) -> bool: learning_rate = self._model.state.lr_finder logger.info("Setting learning rate from Learning Rate Finder to %s", f"{learning_rate:.1e}") - self._model.model.optimizer.learning_rate.assign(learning_rate) + self._optimizer.set_lr(learning_rate) self._model.state.update_session_config("learning_rate", learning_rate) return False if self._model.state.iterations == 0 and self._model.state.session_id == 1: - lrf = LearningRateFinder(self) - success = lrf.find() + success = self._optimizer.find_learning_rate( + self, + mod_cfg.lr_finder_iterations(), + 1e-10, + 1e-1, + T.cast(T.Literal["default", "aggressive", "extreme"], + mod_cfg.lr_finder_strength()), + T.cast(T.Literal["set", "graph_and_set", "graph_and_exit"], + mod_cfg.lr_finder_mode()) + ) return mod_cfg.lr_finder_mode() == "graph_and_exit" or not success logger.debug("[Trainer] No learning rate finder rate. Not setting") return False - def _get_warmup(self) -> LearningRateWarmup: - """Obtain the learning rate warmup instance - - Returns - ------- - The Learning Rate Warmup object - """ - target_lr = float(self._model.model.optimizer.learning_rate.value.cpu().numpy()) - return LearningRateWarmup(self._model.model, target_lr, self._model.warmup_steps) - def _set_tensorboard(self) -> TorchTensorBoard | None: """Set up Tensorboard callback for logging loss. @@ -290,6 +295,7 @@ def train_one_batch(self) -> list[BatchLoss]: inputs, targets, meta = next(self._train_loader) loss = self._plugin.train_batch([i.to(self._device) for i in inputs], [t.to(self._device) for t in targets], + self._optimizer, meta.to(self._device)) retval = [x.to_cpu() for x in loss] except OutOfMemoryError as err: @@ -527,7 +533,6 @@ def train_one_step(self, do_snapshot = (self._plugin.config.snapshot_interval != 0 and self._model.iterations - 1 >= self._plugin.config.snapshot_interval and (self._model.iterations - 1) % self._plugin.config.snapshot_interval == 0) - self._warmup() loss = self.train_one_batch() self._log_tensorboard(loss) total_loss = self._collate_and_store_loss(loss) @@ -555,7 +560,7 @@ def save(self, is_exit: bool = False) -> None: is_exit ``True`` if save has been called on model exit. Default: ``False`` """ - self._model.io.save(is_exit=is_exit) + self._model.io.save(self._optimizer, is_exit=is_exit) assert self._tensorboard is not None self._tensorboard.on_save() if is_exit: diff --git a/plugins/extract/detect/mtcnn.py b/plugins/extract/detect/mtcnn.py index d4440b4ceb..4b226e40be 100644 --- a/plugins/extract/detect/mtcnn.py +++ b/plugins/extract/detect/mtcnn.py @@ -170,7 +170,7 @@ class PNet(nn.Module): Parameters ---------- weights_path - The path to the keras model file + The path to the torch model file """ def __init__(self, weights_path: str) -> None: super().__init__() @@ -217,7 +217,7 @@ class PNetRunner(): Parameters ---------- weights_path - The path to the keras model file + The path to the torch model file device The device to use for model inference input_size @@ -419,7 +419,7 @@ class RNetRunner(): Parameters ---------- weights_path - The path to the keras model file + The path to the torch model file device The device to run inference on input_size @@ -575,7 +575,7 @@ class ONetRunner(): Parameters ---------- weights_path - The path to the keras model file + The path to the torch model file device The device to run inference on input_size diff --git a/plugins/train/model/_base/io.py b/plugins/train/model/_base/io.py index a4635f2a7d..518d25036d 100644 --- a/plugins/train/model/_base/io.py +++ b/plugins/train/model/_base/io.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -IO handling for the model base plugin. +"""IO handling for the model base plugin. The objects in this module should not be called directly, but are called from :class:`~plugins.train.model._base.ModelBase` @@ -10,69 +9,77 @@ - The loading and freezing of weights for model plugins. """ from __future__ import annotations +import gc +import io +import json import logging import os import sys import typing as T -from keras import layers, models as kmodels +import zipfile + +from keras import layers, models as k_models, Variable +import numpy as np +import torch from lib.logger import parse_class_init from lib.model.backup_restore import Backup +from lib.training.optimizer import get_parameter_group_ids from lib.utils import get_module_objects, FaceswapError from .update import Legacy, PatchKerasConfig if T.TYPE_CHECKING: + from keras.optimizers import Optimizer as K_Optimizer, LossScaleOptimizer + from lib.training.optimizer import Optimizer from .model import ModelBase - from keras import Optimizer logger = logging.getLogger(__name__) def get_all_sub_models( - model: kmodels.Model, - models: list[kmodels.Model] | None = None) -> list[kmodels.Model]: - """ For a given model, return all sub-models that occur (recursively) as children. + model: k_models.Model, + models: list[k_models.Model] | None = None) -> list[k_models.Model]: + """For a given model, return all sub-models that occur (recursively) as children. Parameters ---------- - model: :class:`keras.models.Model` + model A Keras model to scan for sub models - models: `None` + models Do not provide this parameter. It is used for recursion Returns ------- - list - A list of all :class:`keras.models.Model` objects found within the given model. - The provided model will always be returned in the first position + A list of all :class:`keras.models.Model` objects found within the given model. The provided + model will always be returned in the first position """ if models is None: models = [model] else: models.append(model) for layer in model.layers: - if isinstance(layer, kmodels.Model): + if isinstance(layer, k_models.Model): get_all_sub_models(layer, models=models) return models class IO(): - """ Model saving and loading functions. + """Model saving and loading functions. Handles the loading and saving of the plugin model from disk as well as the model backup and snapshot functions. Parameters ---------- - plugin: :class:`Model` + plugin The parent plugin class that owns the IO functions. - model_dir: str + model_dir The full path to the model save location - is_predict: bool + is_predict ``True`` if the model is being loaded for inference. ``False`` if the model is being loaded for training. - save_optimizer: ["never", "always", "exit"] + save_optimizer When to save the optimizer weights. `"never"` never saves the optimizer weights. `"always"` always saves the optimizer weights. `"exit"` only saves the optimizer weights on an exit request. @@ -86,69 +93,65 @@ def __init__(self, self._plugin = plugin self._is_predict = is_predict self._model_dir = model_dir - self._save_optimizer = save_optimizer + self._do_save_optimizer = save_optimizer self._history: list[float] = [] - """list[float]: Loss history for current save iteration """ + """Loss history for current save iteration""" self._backup = Backup(self._model_dir, self._plugin.name) self._update_legacy() - logger.debug("Initialized %s", self.__class__.__name__) @property def model_dir(self) -> str: - """ str: The full path to the model folder """ + """The full path to the model folder""" return self._model_dir @property def filename(self) -> str: - """str: The filename for this model.""" + """The filename for this model.""" return os.path.join(self._model_dir, f"{self._plugin.name}.keras") @property def model_exists(self) -> bool: - """ bool: ``True`` if a model of the type being loaded exists within the model folder - location otherwise ``False``. - """ + """``True`` if a model of the type being loaded exists within the model folder location + otherwise ``False``.""" return os.path.isfile(self.filename) @property def history(self) -> list[float]: - """ list[float]: list of loss history for the current save iteration. """ + """list of loss history for the current save iteration.""" return self._history @property def multiple_models_in_folder(self) -> list[str] | None: - """ :list: or ``None`` If there are multiple model types in the requested folder, or model - types that don't correspond to the requested plugin type, then returns the list of plugin - names that exist in the folder, otherwise returns ``None`` """ + """If there are multiple model types in the requested folder, or model types that don't + correspond to the requested plugin type, then returns the list of plugin names that exist + in the folder, otherwise returns ``None``""" plugins = [fname.replace(".keras", "") for fname in os.listdir(self._model_dir) if fname.endswith(".keras")] test_names = plugins + [self._plugin.name] test = False if not test_names else os.path.commonprefix(test_names) == "" retval = None if not test else plugins - logger.debug("plugin name: %s, plugins: %s, test result: %s, retval: %s", + logger.debug("[IO] plugin name: %s, plugins: %s, test result: %s, retval: %s", self._plugin.name, plugins, test, retval) return retval def _update_legacy(self) -> None: - """ Look for faceswap 2.x .h5 files in the model folder. If exists, then update to Faceswap - 3 .keras file and backup the original model .h5 file - - Note: Currently disabled as keras hangs trying to load old faceswap models - """ + """Look for faceswap 2.x .h5 files in the model folder. If exists, then update to Faceswap + 3 .keras file and backup the original model .h5 file""" if self.model_exists: - logger.debug("Existing model file is current: '%s'", os.path.basename(self.filename)) + logger.debug("[IO] Existing model file is current: '%s'", + os.path.basename(self.filename)) return old_fname = f"{os.path.splitext(self.filename)[0]}.h5" if not os.path.isfile(old_fname): - logger.debug("No legacy model file to update") + logger.debug("[IO] No legacy model file to update") return Legacy(old_fname) - def load(self) -> kmodels.Model: - """ Loads the model from disk + def load(self) -> k_models.Model: + """Loads the model from disk If the predict function is to be called and the model cannot be found in the model folder then an error is logged and the process exits. @@ -158,16 +161,15 @@ def load(self) -> kmodels.Model: Returns ------- - :class:`keras.models.Model` - The saved model loaded from disk + The saved model loaded from disk """ - logger.debug("Loading model: %s", self.filename) + logger.debug("[IO] Loading model: %s", self.filename) if self._is_predict and not self.model_exists: logger.error("Model could not be found in folder '%s'. Exiting", self._model_dir) sys.exit(1) try: - model = kmodels.load_model(self.filename, compile=False) + model = k_models.load_model(self.filename, compile=False) except RuntimeError as err: if "unable to get link info" in str(err).lower(): msg = (f"Unable to load the model from '{self.filename}'. This may be a " @@ -199,69 +201,89 @@ def load(self) -> kmodels.Model: logger.info("Loaded model from disk: '%s'", self.filename) return model # pyright:ignore[reportReturnType] - def _remove_optimizer(self) -> Optimizer: - """ Keras 3 `.keras` format ignores the `save_optimizer` kwarg. To hack around this we - remove the optimizer from the model prior to saving and then re-attach it to the model + def load_optimizer(self) -> dict[str, T.Any] | None: + """Load the optimizer's state_dict from the .keras model file Returns ------- - :class:`keras.optimizers.Optimizer` | None - The optimizer for the model, if it should not be saved. ``None`` if it should be saved + The saved optimizer state_dict or ``None`` if it does not exist """ - retval = self._plugin.model.optimizer - del self._plugin.model.optimizer - logger.debug("Removed optimizer for saving: %s", retval) + logger.debug("[IO] Loading optimizer state_dict") + opt_file = "optimizer.pt" + keras_conf = "config.json" + with zipfile.ZipFile(self.filename, "r") as z_file: + f_list = z_file.namelist() + if opt_file in f_list: # Saved torch optimizer + retval = torch.load(io.BytesIO(z_file.read(opt_file))) + elif keras_conf in f_list: # convert legacy keras optimizer + conf = json.loads(z_file.read(keras_conf)) + retval = OptimizerMigrate(conf, self.filename).convert() + else: + retval = None + + if retval is None: + logger.debug("[IO] No optimizer in .keras file") + return None + + logger.debug("[IO] Loaded optimizer state_dict: %s", + {k: list(v) if isinstance(v, dict) else v for k, v in retval.items()}) return retval - def _save_model(self, is_exit: bool, force_save_optimizer: bool) -> None: - """ Save the model either with or without the optimizer weights + def _save_optimizer(self, optimizer: Optimizer) -> None: + """Inject the optimizer's state_dict into the .keras model file - Keras 3 ignores 'save_optimizer` so if it should not be saved, we remove it from - the model for saving, then re-attach it + Parameters + ---------- + optimizer + The current optimizer in use for the model that is to be injected + """ + logger.debug("[IO] Saving optimizer: %s", optimizer) + buf = io.BytesIO() + torch.save(optimizer.state_dict(), buf) + opt_bytes = buf.getvalue() + with zipfile.ZipFile(self.filename, "a") as z_file: + z_file.writestr("optimizer.pt", + opt_bytes, + compress_type=zipfile.ZIP_DEFLATED, + compresslevel=1) + + def _save_model(self, optimizer: Optimizer | None, is_exit: bool) -> None: + """Save the model either with or without the optimizer weights Parameters ---------- - is_exit: bool + optimizer + The current optimizer in use for the model if it should be saved + is_exit ``True`` if the save request has come from an exit process request otherwise ``False``. - force_save_optimizer: bool - ``True`` to force saving the optimizer weights with the model, otherwise ``False``. """ - include_optimizer = (force_save_optimizer or - self._save_optimizer == "always" or - (self._save_optimizer == "exit" and is_exit)) - - optimizer = None - if not include_optimizer: - optimizer = self._remove_optimizer() + include_optimizer = (self._do_save_optimizer == "always" or + (self._do_save_optimizer == "exit" and is_exit)) self._plugin.model.save(self.filename) + if include_optimizer and optimizer is not None: + self._save_optimizer(optimizer) self._plugin.state.save() - if not include_optimizer: - assert optimizer is not None - logger.debug("Re-attaching optimizer: %s", optimizer) - setattr(self._plugin.model, "optimizer", optimizer) - def _get_save_average(self) -> float: - """ Return the average loss since the last save iteration and reset historical loss + """Return the average loss since the last save iteration and reset historical loss Returns ------- - float - The average loss since the last save iteration + The average loss since the last save iteration """ - logger.debug("Getting save averages") + logger.debug("[IO] Getting save averages") if not self._history: - logger.debug("No loss in history") + logger.debug("[IO] No loss in history") retval = 0.0 else: retval = sum(self._history) / len(self._history) self._history = [] # Reset historical loss - logger.debug("Average loss since last save: %s", round(retval, 5)) + logger.debug("[IO] Average loss since last save: %s", round(retval, 5)) return retval def _should_backup(self, save_average: float) -> bool: - """ Check whether the loss average for this save iteration is the lowest that has been + """Check whether the loss average for this save iteration is the lowest that has been seen. This protects against model corruption by only backing up the model if the sum of all loss @@ -272,15 +294,15 @@ def _should_backup(self, save_average: float) -> bool: This is by no means a perfect system. If the model corrupts at an iteration close to a save iteration, then the averages may still be pushed lower than a previous save average, resulting in backing up a corrupted model. Changing loss weighting can also - arteficially impact this + artificially impact this Parameters ---------- - save_average: float + save_average The average loss since the last save iteration """ if not self._plugin.state.lowest_avg_loss: - logger.debug("Set initial save iteration loss average: %s", save_average) + logger.debug("[IO] Set initial save iteration loss average: %s", save_average) self._plugin.state.lowest_avg_loss = save_average return False @@ -289,56 +311,53 @@ def _should_backup(self, save_average: float) -> bool: if backup: # Update lowest loss values to the state file self._plugin.state.lowest_avg_loss = save_average - logger.debug("Updated lowest historical save iteration average from: %s to: %s", + logger.debug("[IO] Updated lowest historical save iteration average from: %s to: %s", old_average, save_average) - logger.debug("Should backup: %s", backup) + logger.debug("[IO] Should backup: %s", backup) return backup def _maybe_backup(self) -> tuple[float, bool]: - """ Backup the model if total average loss has dropped for the save iteration + """Backup the model if total average loss has dropped for the save iteration Returns ------- - float + average_loss The total loss average since the last save iteration - bool + backed_up ``True`` if the model was backed up """ save_average = self._get_save_average() should_backup = self._should_backup(save_average) if not save_average or not should_backup: - logger.debug("Not backing up model (save_average: %s, should_backup: %s)", + logger.debug("[IO] Not backing up model (save_average: %s, should_backup: %s)", save_average, should_backup) return save_average, False - logger.debug("Backing up model") + logger.debug("[IO] Backing up model") self._backup.backup_model(self.filename) self._backup.backup_model(self._plugin.state.filename) return save_average, True - def save(self, - is_exit: bool = False, - force_save_optimizer: bool = False) -> None: - """ Backup and save the model and state file. + def save(self, optimizer: Optimizer | None = None, is_exit: bool = False) -> None: + """Backup and save the model and state file. Parameters ---------- - is_exit: bool, optional + optimizer + The current optimizer in use for the model if it should be saved. Default: ``None`` + is_exit ``True`` if the save request has come from an exit process request otherwise ``False``. Default: ``False`` - force_save_optimizer: bool, optional - ``True`` to force saving the optimizer weights with the model, otherwise ``False``. - Default:``False`` """ - logger.debug("Backing up and saving models") + logger.debug("[IO] Backing up and saving models") print("\x1b[2K", end="\r") # Clear last line logger.info("Saving Model...") - self._save_model(is_exit, force_save_optimizer) + self._save_model(optimizer, is_exit) save_average, backed_up = self._maybe_backup() - msg = "[Saved optimizer state for Snapshot]" if force_save_optimizer else "[Saved model]" + msg = "[Saved model]" if save_average: msg += f" - Average total loss since last save: {save_average:.5f}" if backed_up: @@ -346,28 +365,28 @@ def save(self, logger.info(msg) def snapshot(self) -> None: - """ Perform a model snapshot. + """Perform a model snapshot. Notes ----- Snapshot function is called 1 iteration after the model was saved, so that it is built from the latest save, hence iteration being reduced by 1. """ - logger.debug("Performing snapshot. Iterations: %s", self._plugin.iterations) + logger.debug("[IO] Performing snapshot. Iterations: %s", self._plugin.iterations) self._backup.snapshot_models(self._plugin.iterations - 1) - logger.debug("Performed snapshot") + logger.debug("[IO] Performed snapshot") class Weights(): - """ Handling of freezing and loading model weights + """Handling of freezing and loading model weights Parameters ---------- - plugin: :class:`Model` + plugin The parent plugin class that owns the IO functions. """ def __init__(self, plugin: ModelBase) -> None: - logger.debug("Initializing %s: (plugin: %s)", self.__class__.__name__, plugin) + logger.debug(parse_class_init(locals())) self._model = plugin.model self._name = plugin.model_name self._do_freeze = plugin._args.freeze_weights @@ -375,24 +394,22 @@ def __init__(self, plugin: ModelBase) -> None: self._freeze_layers = plugin.freeze_layers self._load_layers = plugin.load_layers - logger.debug("Initialized %s", self.__class__.__name__) @classmethod def _check_weights_file(cls, weights_file: str) -> str | None: - """ Validate that we have a valid path to a .keras file. + """Validate that we have a valid path to a .keras file. Parameters ---------- - weights_file: str + weights_file The full path to a weights file Returns ------- - str - The full path to a weights file + The full path to a weights file """ if not weights_file: - logger.debug("No weights file selected.") + logger.debug("[Weights] No weights file selected.") return None msg = "" @@ -410,7 +427,7 @@ def _check_weights_file(cls, weights_file: str) -> str | None: return weights_file def freeze(self) -> None: - """ If freeze has been selected in the cli arguments, then freeze those models indicated + """If freeze has been selected in the cli arguments, then freeze those models indicated in the plugin's configuration. """ # Blanket unfreeze layers, as checking the value of :attr:`layer.trainable` appears to # return ``True`` even when the weights have been frozen @@ -418,7 +435,7 @@ def freeze(self) -> None: layer.trainable = True if not self._do_freeze: - logger.debug("Freeze weights deselected. Not freezing") + logger.debug("[Weights] Freeze weights deselected. Not freezing") return for layer in get_all_sub_models(self._model): @@ -431,15 +448,15 @@ def freeze(self) -> None: "model: %s", self._freeze_layers) def load(self, model_exists: bool) -> None: - """ Load weights for newly created models, or output warning for pre-existing models. + """Load weights for newly created models, or output warning for pre-existing models. Parameters ---------- - model_exists: bool + model_exists ``True`` if a model pre-exists and is being resumed, ``False`` if this is a new model """ if not self._weights_file: - logger.debug("No weights file provided. Not loading weights.") + logger.debug("[Weights] No weights file provided. Not loading weights.") return if model_exists and self._weights_file: logger.warning("Ignoring weights file '%s' as this model is resuming.", @@ -474,7 +491,7 @@ def load(self, model_exists: bool) -> None: del weights_models if loaded_ops == 0: - raise FaceswapError(f"No weights were succesfully loaded from your weights file: " + raise FaceswapError(f"No weights were successfully loaded from your weights file: " f"'{self._weights_file}'. Please check and try again.") if skipped_ops > 0: logger.warning("%s weight(s) were unable to be loaded for your model. This is most " @@ -482,13 +499,12 @@ def load(self, model_exists: bool) -> None: "different settings than you have set for your current model.", skipped_ops) - def _get_weights_model(self) -> list[kmodels.Model]: - """ Obtain a list of all sub-models contained within the weights model. + def _get_weights_model(self) -> list[k_models.Model]: + """Obtain a list of all sub-models contained within the weights model. Returns ------- - list - List of all models contained within the .keras file + List of all models contained within the .keras file Raises ------ @@ -496,7 +512,7 @@ def _get_weights_model(self) -> list[kmodels.Model]: In the event of a failure to load the weights, or the weights belonging to a different model """ - retval = get_all_sub_models(kmodels.load_model( # pyright:ignore[reportArgumentType] + retval = get_all_sub_models(k_models.load_model( # pyright:ignore[reportArgumentType] self._weights_file, compile=False)) if not retval: @@ -511,26 +527,25 @@ def _load_layer_weights(self, layer: layers.Layer, sub_weights: layers.Layer, model_name: str) -> T.Literal[-1, 0, 1]: - """ Load the weights for a single layer. + """Load the weights for a single layer. Parameters ---------- - layer: :class:`keras.layers.Layer` + layer The layer to set the weights for - sub_weights: list + sub_weights The list of layers in the weights model to load weights from - model_name: str + model_name The name of the current sub-model that is having it's weights loaded Returns ------- - int - `-1` if the layer has no weights to load. `0` if weights loading was unsuccessful. `1` - if weights loading was successful + `-1` if the layer has no weights to load. `0` if weights loading was unsuccessful. `1` if + weights loading was successful """ old_weights = layer.get_weights() if not old_weights: - logger.debug("Skipping layer without weights: %s", layer.name) + logger.debug("[Weights] Skipping layer without weights: %s", layer.name) return -1 layer_weights = next((lyr for lyr in sub_weights.layers @@ -550,4 +565,205 @@ def _load_layer_weights(self, return 1 +class OptimizerMigrate: + """Migrates weights from a keras optimizer to a torch optimizer's state dict""" + def __init__(self, config: dict[str, T.Any], model_path: str): + logger.debug(parse_class_init(locals())) + self._config = config + self._model_path = model_path + ada_map = (("_momentums", "_velocities"), ("exp_avg", "exp_avg_sq")) + self._mapping: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = { + "AdaBeliefOptimizer": (ada_map[0], ("exp_avg", "exp_avg_var")), + "adam": ada_map, + "adamax": (("_m", "_u"), ("exp_avg", "exp_inf")), + "adamw": ada_map, + "lion": (("_momentums", ), ("exp_avg", )), + "nadam": (ada_map[0] + ("_u_product", ), ada_map[1] + ("mu_product", )), + "rmsprop": (("_velocities", ), ("square_avg",)) + } + + def _get_optimizer_and_group_ids(self) -> tuple[K_Optimizer, + dict[int, + T.Literal["decay", "no_decay"]]] | None: + """Obtain the optimizer from the saved .keras model + + Returns + ------- + optimizer + The saved keras optimizer if it exists or ``None`` if it does not + group_ids + dictionary of keras model's trainable weight index to the name of the parameter group + """ + compile_conf = self._config.get("compile_config", {}).get("optimizer") + if not compile_conf: + logger.debug("[OptimizerMigrate] No saved keras optimizer in model file") + return None + tmp_model = T.cast(k_models.Model, k_models.load_model(self._model_path, compile=True)) + opt = T.cast("K_Optimizer", tmp_model.optimizer) + group_ids = get_parameter_group_ids(tmp_model.trainable_variables) + del tmp_model + gc.collect() + logger.debug("[OptimizerMigrate] keras optimizer from model file: %s", opt) + return opt, group_ids + + def _build_optimizer_state(self, + optimizer: K_Optimizer, + decay_indices: list[int], + no_decay_indices: list[int]) -> dict[int, dict[str, torch.Tensor]]: + """Build the "state" item for the optimizer state_dict + + Parameters + ---------- + optimizer + The loaded keras optimizer + decay_indices + The list of keras variable indices that belong to the decay parameter group + no_decay_indices + The list of keras variable indices that belong to the no_decay parameter group + + Returns + ------- + The populated, ordered, state item in torch format from the keras optimizer + """ + mapping = self._mapping[optimizer.name] + logger.debug("[OptimizerMigrate] mapping for '%s': %s -> %s", + optimizer.name, mapping[0], mapping[1]) + if not all(hasattr(optimizer, x) for x in mapping[0]): + raise RuntimeError( + f"Cannot extract {mapping[0]} from keras optimizer. Keras version may have " + "changed internal structure.") + + if optimizer.name == "lion": + step = {} + else: + step = {"step": torch.from_numpy( + T.cast(np.ndarray, optimizer.iterations.numpy()).astype(np.float32))} + ordered = decay_indices + no_decay_indices + + # pylint:disable=protected-access + vars_ = {mapping[1][idx]: getattr(optimizer, x)._value.data + for idx, x in enumerate(mapping[0]) + if isinstance(getattr(optimizer, x), Variable)} + weights = {x: getattr(optimizer, mapping[0][idx]) + for idx, x in enumerate(mapping[1]) + if x not in vars_} + + retval: dict[int, dict[str, torch.Tensor]] = {} + for dst_idx, src_idx in enumerate(ordered): + layer = {k: v[src_idx] for k, v in weights.items()} + if not all(hasattr(v, "_value") for v in layer.values()): + logger.debug("[OptimizerMigrate] Skipping variable without torch param: %s", + list(layer.values())[0].name.rsplit("_", maxsplit=1)[0]) + continue + c_step = {k: v.clone() for k, v in step.items()} + c_vars = {k: v.clone() for k, v in vars_.items()} + retval[dst_idx] = c_step | c_vars | {k: v._value.data for k, v in layer.items()} + + return retval + + @classmethod + def _get_parameter_groups(cls, + optimizer: K_Optimizer, + weight_indices: list[int], + bias_indices: list[int]) -> list[dict[str, T.Any]]: + """Obtain the fixed config optimizer value and param ids for each parameter group + + Parameters + ---------- + optimizer + The loaded keras optimizer + weight_indices + The list of keras variable indices that belong to the weight parameter group + bias_indices + The list of keras variable indices that belong to the bias parameter group + + Returns + ------- + The parameter group fixed config items and parameter ids + """ + fixed = {} + if hasattr(optimizer, "beta_1") and hasattr(optimizer, "beta_2"): + fixed["betas"] = (optimizer.beta_1, optimizer.beta_2) + if hasattr(optimizer, "amsgrad"): + fixed["amsgrad"] = optimizer.amsgrad + + g1_len = len(weight_indices) + params = [{"params": list(range(g1_len))}, + {"params": list(range(g1_len, g1_len + len(bias_indices)))}] + + retval = [fixed | params[0], fixed | params[1]] + logger.debug("[OptimizerMigrate] param_groups: %s", retval) + return retval + + @classmethod + def _get_scaler_state(cls, + optimizer: LossScaleOptimizer | None) -> dict[str, float | int] | None: + """Build the scaler state_dict from Keras' LossScaleOptimizer + + Parameters + ---------- + optimizer + The Keras LossScaleOptimizer or ``None`` if the optimizer is not scaled + + Returns + ------- + The state dict for Torch scaler or ``None`` if the optimizer is not scaled + """ + if optimizer is None: + logger.debug("[OptimizerMigrate] No scaler to migrate") + return None + + if (not hasattr(optimizer, "dynamic_growth_steps") + or not hasattr(optimizer, "dynamic_scale") + or not hasattr(optimizer, "step_counter")): + logger.warning("Unable to migrate Loss Scaler parameters. Scaler will be reset") + return None + + retval = {"scale": float(optimizer.dynamic_scale.numpy()), + "growth_factor": 2.0, + "backoff_factor": 0.5, + "growth_interval": optimizer.dynamic_growth_steps, + "_growth_tracker": int(optimizer.step_counter.numpy())} + logger.debug("[OptimizerMigrate] scaler: %s", retval) + return retval + + def convert(self) -> dict[str, T.Any] | None: + """Convert the keras optimizer from a keras model file into a torch optimizer state dict + + Returns + ------- + The optimizer state dict for loading into a torch optimizer or ``None`` if no saved + optimizer exists + """ + optimizer_group_ids = self._get_optimizer_and_group_ids() + if optimizer_group_ids is None: + return None + optimizer, index_map = optimizer_group_ids + + scaler_opt: LossScaleOptimizer | None = None + if hasattr(optimizer, "inner_optimizer"): + logger.debug("[OptimizerMigrate] Extracting inner optimizer %s from %s", + optimizer.inner_optimizer, optimizer) + scaler_opt = optimizer + optimizer = optimizer.inner_optimizer + + logger.info("Migrating optimizer weights to Torch") + + weight_indices = [k for k, v in index_map.items() if v == "decay"] + bias_indices = [k for k, v in index_map.items() if v == "no_decay"] + + opt_state = self._build_optimizer_state(optimizer, weight_indices, bias_indices) + if not opt_state: + logger.warning("Unable to migrate optimizer weights. Optimizer will be reset") + return None + + param_groups = self._get_parameter_groups(optimizer, weight_indices, bias_indices) + scaler_state = self._get_scaler_state(scaler_opt) + + retval = {"version": 0.5, + "optimizer": {"state": opt_state, "param_groups": param_groups}, + "scaler": scaler_state} + return retval + + __all__ = get_module_objects(__name__) diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index c747505e9c..9433116d3d 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -17,7 +17,7 @@ from .inference import Inference from .io import IO, get_all_sub_models, Weights -from .settings import Optimizer, Settings +from .settings import Settings from .state import State if T.TYPE_CHECKING: @@ -139,11 +139,6 @@ def iterations(self) -> int: """The total number of iterations that the model has trained.""" return self._state.iterations - @property - def warmup_steps(self) -> int: - """The number of steps to perform learning rate warmup""" - return self._args.warmup - @property def freeze_layers(self) -> list[str]: """Override to set plugin specific layers that can be frozen. Defaults to ["encoder"]""" @@ -294,20 +289,15 @@ def _output_summary(self) -> None: parent.summary(print_fn=print_fn) def _compile_model(self) -> None: - """Compile the model to include the Optimizer and Loss Function(s).""" + """Legacy from Keras code. Now just load and freeze weights""" logger.debug("Compiling Model") if self.state.model_needs_rebuild: self._model = self._settings.check_model_precision(self._model, self._state) - optimizer = Optimizer().optimizer - if self._settings.use_mixed_precision: - optimizer = self._settings.loss_scale_optimizer(optimizer) - weights = Weights(self) weights.load(self._io.model_exists) weights.freeze() - self.model.compile(optimizer=optimizer) logger.debug("Compiled Model: %s", self.model) def add_history(self, loss: np.ndarray) -> None: diff --git a/plugins/train/model/_base/settings.py b/plugins/train/model/_base/settings.py index 5887ecb65f..ec64fff067 100644 --- a/plugins/train/model/_base/settings.py +++ b/plugins/train/model/_base/settings.py @@ -16,12 +16,8 @@ import keras from keras import config as k_config, dtype_policies, optimizers -from lib.model.optimizers import AdaBelief -from lib.model.autoclip import AutoClipper from lib.model.nn_blocks import reset_naming -from lib.logger import parse_class_init from lib.utils import get_module_objects -from plugins.train.train_config import Optimizer as cfg_opt if T.TYPE_CHECKING: from collections.abc import Callable @@ -31,153 +27,6 @@ logger = logging.getLogger(__name__) -class Optimizer(): - """Obtain the selected optimizer with the appropriate keyword arguments.""" - def __init__(self) -> None: - logger.debug(parse_class_init(locals())) - betas = {"ada_beta_1": "beta_1", "ada_beta_2": "beta_2"} - amsgrad = {"ada_amsgrad": "amsgrad"} - self._valid: dict[str, tuple[T.Type[Optimizer], dict[str, T.Any]]] = { - "adabelief": (AdaBelief, betas | amsgrad), - "adam": (optimizers.Adam, betas | amsgrad), - "adamax": (optimizers.Adamax, betas), - "adamw": (optimizers.AdamW, betas | amsgrad), - "lion": (optimizers.Lion, betas), - "nadam": (optimizers.Nadam, betas), - "rms-prop": (optimizers.RMSprop, {})} - - self._optimizer = self._valid[cfg_opt.optimizer()][0] - self._kwargs: dict[str, T.Any] = {"learning_rate": cfg_opt.learning_rate()} - if cfg_opt.optimizer() != "lion": - self._kwargs["epsilon"] = 10 ** int(cfg_opt.epsilon_exponent()) - - self._configure() - logger.info("Using %s optimizer", self._optimizer.__name__) - logger.debug("Initialized: %s", self.__class__.__name__) - - @property - def optimizer(self) -> optimizers.Optimizer: - """The requested optimizer.""" - return T.cast(optimizers.Optimizer, self._optimizer(**self._kwargs)) - - def _configure_clipping(self, - method: T.Literal["autoclip", "norm", "value", "none"], - value: float, - history: int) -> None: - """Configure optimizer clipping related kwargs, if selected - - Parameters - ---------- - method - The clipping method to use. ``None`` for no clipping - value - The value to clip by norm/value by. For autoclip, this is the clip percentile - (a value of 1.0 is a clip percentile of 10%) - history - autoclip only: The number of iterations to keep for calculating the normalized value - """ - logger.debug("method: '%s', value: %s, history: %s", method, value, history) - if method == "none": - logger.debug("clipping disabled") - return - - logger.info("Enabling Clipping: %s", method.replace("_", " ").replace("_", " ").title()) - clip_types = {"global_norm": "global_clipnorm", "norm": "clipnorm", "value": "clipvalue"} - if method in clip_types: - self._kwargs[clip_types[method]] = value - logger.debug("Setting clipping kwargs for '%s': %s", - method, {k: v for k, v in self._kwargs.items() - if k == clip_types[method]}) - return - - assert method == "autoclip" - # Test for if keras optimizer changes its structure to no longer have _clip_gradients. - # Ensures any tests fails in this situation - assert hasattr(self._optimizer, - "_clip_gradients"), "keras.BaseOptimizer._clip_gradients no longer exists" - - # TODO Keras3 has removed the ""gradient_transformers" kwarg, and there now appears to be - # no standardized method to add custom gradient transformers. Currently, we monkey patch - # its _clip_gradients function, which feels hacky and potentially problematic - setattr(self._optimizer, "_clip_gradients", AutoClipper(int(value * 10), - history_size=history)) - - def _configure_ema(self, enable: bool, momentum: float, frequency: int) -> None: - """configure the optimizer kwargs for exponential moving average updates - - Parameters - ---------- - enable - ``False`` to disable - momentum - the momentum to use when computing the EMA of the model's weights: new_average = - momentum * old_average + (1 - momentum) * current_variable_value - frequency - the number of iterations, to overwrite the model variable by its moving average. - """ - self._kwargs["use_ema"] = enable - if not enable: - logger.debug("ema disabled.") - return - - logger.info("Enabling EMA") - self._kwargs["ema_momentum"] = momentum - self._kwargs["ema_overwrite_frequency"] = frequency - logger.debug("ema enabled (momentum: %s, frequency: %s)", momentum, frequency) - - def _configure_kwargs(self, weight_decay: float, gradient_accumulation_steps: int) -> None: - """Configure the remaining global optimizer kwargs - - Parameters - ---------- - weight_decay - The amount of weight decay to apply - gradient_accumulation_steps - The number of steps to accumulate gradients for before applying the average - """ - if weight_decay > 0.0: - logger.info("Enabling Weight Decay: %s", weight_decay) - self._kwargs["weight_decay"] = weight_decay - else: - logger.debug("weight decay disabled") - - if gradient_accumulation_steps > 1: - logger.info("Enabling Gradient Accumulation: %s", gradient_accumulation_steps) - self._kwargs["gradient_accumulation_steps"] = gradient_accumulation_steps - else: - logger.debug("gradient accumulation disabled") - - def _configure_specific(self) -> None: - """Configure keyword optimizer specific keyword arguments based on user settings.""" - opts = self._valid[cfg_opt.optimizer()][1] - if not opts: - logger.debug("No additional kwargs to set for '%s'", cfg_opt.optimizer()) - return - - for key, val in opts.items(): - opt_val = getattr(cfg_opt, key)() - logger.debug("Setting kwarg '%s' from '%s' to: %s", val, key, opt_val) - self._kwargs[val] = opt_val - - def _configure(self) -> None: - """Process the user configuration options into Keras Optimizer kwargs.""" - self._configure_clipping(T.cast(T.Literal["autoclip", "norm", "value", "none"], - cfg_opt.gradient_clipping()), - cfg_opt.clipping_value(), - cfg_opt.autoclip_history()) - - self._configure_ema(cfg_opt.use_ema(), - cfg_opt.ema_momentum(), - cfg_opt.ema_frequency()) - - self._configure_kwargs(cfg_opt.weight_decay(), - cfg_opt.gradient_accumulation()) - - self._configure_specific() - - logger.debug("Configured '%s' optimizer. kwargs: %s", cfg_opt.optimizer(), self._kwargs) - - class Settings(): """Core training settings. diff --git a/plugins/train/train_config.py b/plugins/train/train_config.py index d4835946f8..5ef196712a 100644 --- a/plugins/train/train_config.py +++ b/plugins/train/train_config.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Default configurations for models """ +"""Default configurations for models""" import gettext import logging @@ -23,10 +23,10 @@ class _Config(FaceswapConfig): - """ Config File for Models """ + """Config File for Models""" # pylint:disable=too-many-statements def set_defaults(self, helptext="") -> None: - """ Set the default values for config """ + """Set the default values for config""" super().set_defaults(helptext=_("Options that apply to all models") + _ADDITIONAL_INFO) self._defaults_from_plugin(os.path.dirname(__file__)) for section, opts in trainer_config.get_defaults().items(): @@ -304,7 +304,7 @@ def set_defaults(self, helptext="") -> None: @dataclass class Loss(GlobalSection): - """ global.loss configuration section + """global.loss configuration section Loss Documentation MAE https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine-learners-should-know-4fb140e9d4b0 MSE https://heartbeat.fritz.ai/5-regression-loss-functions-all-machine-learners-should-know-4fb140e9d4b0 @@ -557,7 +557,7 @@ class Loss(GlobalSection): @dataclass class Optimizer(GlobalSection): - """ global.optimizer configuration section """ + """global.optimizer configuration section""" helptext = (_("Optimizer configuration options\n" "The optimizer applies the output of the loss function to the model.\n") + _ADDITIONAL_INFO) @@ -724,37 +724,6 @@ class Optimizer(GlobalSection): min_max=(1, 100), rounding=1, fixed=False) - use_ema = ConfigItem( - datatype=bool, - default=False, - group=_("exponential moving average"), - info=_( - "Enable exponential moving average (EMA). EMA consists of computing an " - "exponential moving average of the weights of the model (as the weight values " - "change after each training batch), and periodically overwriting the weights " - "with their moving average"), - fixed=True) - ema_momentum = ConfigItem( - datatype=float, - default=0.99, - group=_("exponential moving average"), - info=_( - "Only used if use_ema is enabled. This is the momentum to use when computing " - "the EMA of the model's weights: new_average = ema_momentum * old_average + " - "(1 - ema_momentum) * current_variable_value."), - min_max=(0.0, 1.0), - rounding=4, - fixed=True) - ema_frequency = ConfigItem( - datatype=int, - default=100, - group=_("exponential moving average"), - info=_( - "Only used if use_ema is enabled. Set the number of iterations, to overwrite " - "the model variable by its moving average. "), - min_max=(10, 10000), - rounding=10, - fixed=True) ada_beta_1 = ConfigItem( datatype=float, default=0.9, @@ -793,11 +762,11 @@ class Optimizer(GlobalSection): def load_config(config_file: str | None = None) -> None: - """ Load the Train configuration .ini file + """Load the Train configuration .ini file Parameters ---------- - config_file : str | None, optional + config_file Path to a custom .ini configuration file to load. Default: ``None`` (use default configuration file) """ diff --git a/plugins/train/trainer/base.py b/plugins/train/trainer/base.py index 2888f9332b..6eb7861b86 100644 --- a/plugins/train/trainer/base.py +++ b/plugins/train/trainer/base.py @@ -17,6 +17,7 @@ if T.TYPE_CHECKING: from lib.training.data import BatchMeta from lib.training.loss import LossCollator, BatchLoss + from lib.training.optimizer import Optimizer from plugins.train.model._base import ModelBase logger = logging.getLogger(__name__) @@ -119,6 +120,7 @@ def get_sampler(self) -> type[torch.utils.data.RandomSampler | def train_batch(self, inputs: list[torch.Tensor], targets: list[torch.Tensor], + optimizer: Optimizer, meta: BatchMeta) -> list[BatchLoss]: """Override to run a single forward and backwards pass through the model for a single batch @@ -129,6 +131,8 @@ def train_batch(self, targets List of len (num_outputs) of target images in shape (batch_size, num_inputs, height, width, 3) at all model output sizes as float32 0.0 - 1.0 range + optimizer + The configured Optimizer to use meta The meta information for the batch diff --git a/plugins/train/trainer/original.py b/plugins/train/trainer/original.py index d919c1f221..518dbe466b 100644 --- a/plugins/train/trainer/original.py +++ b/plugins/train/trainer/original.py @@ -5,7 +5,6 @@ import logging import typing as T -from keras import ops import torch from lib.utils import get_module_objects @@ -14,6 +13,7 @@ if T.TYPE_CHECKING: from lib.training.data import BatchMeta from lib.training.loss import BatchLoss + from lib.training.optimizer import Optimizer logger = logging.getLogger(__name__) @@ -61,28 +61,24 @@ def _forward(self, logger.trace("Losses: %s", losses) # type:ignore[attr-defined] return losses - def _backwards_and_apply(self, all_loss: torch.Tensor) -> None: + def _backwards_and_apply(self, loss: list[BatchLoss], optimizer: Optimizer) -> None: """Perform the backwards pass on the model Parameters ---------- - all_loss + loss The loss for each output from the model + optimizer + The configured Optimizer to use """ - total_loss = T.cast(torch.Tensor, - self.model.model.optimizer.scale_loss(ops.sum(all_loss))) - total_loss.backward() - - trainable_weights = self.model.model.trainable_weights[:] - gradients = [v.value.grad for v in trainable_weights] - - # Update weights - with torch.no_grad(): - self.model.model.optimizer.apply(gradients, trainable_weights) + total_loss = T.cast(torch.Tensor, sum(x.total for x in loss)) + optimizer.backward(total_loss) + optimizer.step() def train_batch(self, inputs: list[torch.Tensor], targets: list[torch.Tensor], + optimizer: Optimizer, meta: BatchMeta) -> list[BatchLoss]: """Run a single forward and backwards pass through the model for a single batch @@ -93,6 +89,8 @@ def train_batch(self, targets List of len (num_outputs) of target images in shape (batch_size, num_inputs, height, width, 3) at all model output sizes as float32 0.0 - 1.0 range + optimizer + The configured Optimizer to use meta The meta information for the batch @@ -100,10 +98,8 @@ def train_batch(self, ------- The loss for each input to the model in order (A, B, ...) """ - self.model.model.zero_grad() # TODO move this to optimizer loss = self._forward(inputs, targets, meta) - total_loss = T.cast(torch.Tensor, sum(x.total for x in loss)) - self._backwards_and_apply(total_loss) + self._backwards_and_apply(loss, optimizer) return loss diff --git a/scripts/train.py b/scripts/train.py index 2f96d3d7b6..fbac5e3e8c 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -315,6 +315,7 @@ def _load_trainer(self, model: ModelBase) -> Trainer: snapshot_interval=self._args.snapshot_interval) retval = Trainer(PluginLoader.get_trainer(trainer)(model, config), self._args.preview or self._args.write_image or self._args.redirect_gui, + warmup_steps=self._args.warmup, timelapse_folders=[self._args.timelapse_input_a, self._args.timelapse_input_b], timelapse_output=self._args.timelapse_output) diff --git a/tests/lib/model/optimizers_test.py b/tests/lib/model/optimizers_test.py deleted file mode 100644 index 3f986ace50..0000000000 --- a/tests/lib/model/optimizers_test.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python3 -""" Tests for Faceswap Initializers. - -Adapted from Keras tests. -""" -import pytest - -import numpy as np - -from keras import device, layers as kl, optimizers as k_optimizers, Sequential - -from lib.model import optimizers -from lib.utils import get_backend - -from tests.utils import generate_test_data, to_categorical - - -def get_test_data(): - """ Obtain randomized test data for training """ - np.random.seed(1337) - (x_train, y_train), _ = generate_test_data(num_train=1000, - num_test=200, - input_shape=(10,), - classification=True, - num_classes=2) - y_train = to_categorical(y_train) - return x_train, y_train - - -def _test_optimizer(optimizer, target=0.75): - x_train, y_train = get_test_data() - - model = Sequential() - model.add(kl.Input((x_train.shape[1], ))) - model.add(kl.Dense(10)) - model.add(kl.Activation("relu")) - model.add(kl.Dense(y_train.shape[1])) - model.add(kl.Activation("softmax")) - model.compile(loss="categorical_crossentropy", - optimizer=optimizer, - metrics=["accuracy"]) - - history = model.fit(x_train, y_train, epochs=2, batch_size=16, verbose=0) # type:ignore - assert history.history["accuracy"][-1] >= target - config = k_optimizers.serialize(optimizer) - optim = k_optimizers.deserialize(config) - new_config = k_optimizers.serialize(optim) - config["class_name"] = config["class_name"].lower() # type:ignore - new_config["class_name"] = new_config["class_name"].lower() # type:ignore - assert config == new_config - - -# TODO remove the next line that supresses a weird pytest bug when it tears down the tempdir -@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") -@pytest.mark.parametrize("dummy", [None], ids=[get_backend().upper()]) -def test_adabelief(dummy): # pylint:disable=unused-argument - """ Test for custom Adam optimizer """ - with device("cpu"): - _test_optimizer(optimizers.AdaBelief(), target=0.20) diff --git a/tests/lib/training/lr_finder_test.py b/tests/lib/training/lr_finder_test.py deleted file mode 100644 index 3fa8641796..0000000000 --- a/tests/lib/training/lr_finder_test.py +++ /dev/null @@ -1,278 +0,0 @@ -#! /usr/env/bin/python3 -""" Unit tests for Learning Rate Finder. """ - -import pytest -import pytest_mock - -import numpy as np -import torch - -from lib.training.lr_finder import LearningRateFinder -from plugins.train import train_config as cfg - -# pylint:disable=unused-import -from tests.lib.config.helpers import patch_config # noqa:[F401] - -# pylint:disable=protected-access,invalid-name,redefined-outer-name - - -class DummyLoss: # pylint:disable=too-few-public-methods - """Dummy loss return value""" - def __init__(self, value): - self.total = torch.Tensor([value]) - - -@pytest.fixture -def _trainer_mock(patch_config, mocker: pytest_mock.MockFixture): # noqa:[F811] - """ Generate a mocked model and feeder object and patch user config items """ - def _apply_patch(iters=1000, mode="default", strength="default"): - patch_config(cfg, {"lr_finder_iterations": iters}) - patch_config(cfg, {"lr_finder_mode": mode}) - patch_config(cfg, {"lr_finder_strength": strength}) - trainer = mocker.MagicMock() - model = mocker.MagicMock() - model.name = "TestModel" - optimizer = mocker.MagicMock() - trainer._plugin.model = model - trainer._plugin.model.model.optimizer = optimizer - return trainer, model, optimizer - return _apply_patch - - -_STRENGTH_LOOKUP = {"default": 10, "aggressive": 5, "extreme": 2.5} - - -_LR_CONF = ((20, "graph_and_set", "default"), - (500, "set", "aggressive"), - (1000, "graph_and_exit", "extreme")) -_LR_CONF_PARAMS = ("iters", "mode", "strength") - -_LR_CMDS = ((4, 0.98), (8, 0.66), (2, 0.33) - ) -_LR_CMDS_PARAMS = ("stop_factor", "beta") -_LR_CMDS_IDS = [f"stop:{x[0]}|beta:{x[1]}" for x in _LR_CMDS] - - -@pytest.mark.parametrize(_LR_CONF_PARAMS, _LR_CONF) -@pytest.mark.parametrize(_LR_CMDS_PARAMS, _LR_CMDS, ids=_LR_CMDS_IDS) -def test_LearningRateFinder_init(iters, mode, strength, stop_factor, beta, _trainer_mock): - """ Test lib.train.LearingRateFinder.__init__ """ - trainer, model, optimizer = _trainer_mock(iters, mode, strength) - lrf = LearningRateFinder(trainer, stop_factor=stop_factor, beta=beta) - assert lrf._trainer is trainer - assert lrf._model is model - assert lrf._optimizer is optimizer - assert lrf._start_lr == 1e-10 - assert lrf._stop_factor == stop_factor - assert lrf._beta == beta - - -_BATCH_END = ((1, 0.01, 1e-5, 0.5), - (27, 0.01, 1e-5, 1e-6), - (42, 0.001, 1e-5, 0.002),) -_BATCH_END_PARAMS = ("iteration", "loss", "learning_rate", "best") -_BATCH_END_IDS = [f"iter:{x[0]}|loss:{x[1]}|lr:{x[2]}" for x in _BATCH_END] - - -@pytest.mark.parametrize(_LR_CMDS_PARAMS, _LR_CMDS, ids=_LR_CMDS_IDS) -@pytest.mark.parametrize(_BATCH_END_PARAMS, _BATCH_END, ids=_BATCH_END_IDS) -def test_LearningRateFinder_on_batch_end(iteration, - loss, - learning_rate, - best, - stop_factor, - beta, - _trainer_mock, - mocker): - """ Test lib.train.LearingRateFinder._on_batch_end """ - trainer, model, optimizer = _trainer_mock() - lrf = LearningRateFinder(trainer, stop_factor=stop_factor, beta=beta) - optimizer.learning_rate.assign = mocker.MagicMock() - optimizer.learning_rate.numpy = mocker.MagicMock(return_value=learning_rate) - - initial_avg = lrf._loss["avg"] - lrf._loss["best"] = best - lrf._on_batch_end(iteration, loss) - - assert lrf._metrics["learning_rates"][-1] == learning_rate - assert lrf._loss["avg"] == (lrf._beta * initial_avg) + ((1 - lrf._beta) * loss) - assert lrf._metrics["losses"][-1] == lrf._loss["avg"] / (1 - (lrf._beta ** iteration)) - - if iteration > 1 and lrf._metrics["losses"][-1] > lrf._stop_factor * lrf._loss["best"]: - assert model.model.stop_training is True - optimizer.learning_rate.assign.assert_not_called() - return - - if iteration == 1: - assert lrf._loss["best"] == lrf._metrics["losses"][-1] - - assert model.model.stop_training is not True - optimizer.learning_rate.assign.assert_called_with( - learning_rate * lrf._lr_multiplier) - - -@pytest.mark.parametrize(_LR_CONF_PARAMS, _LR_CONF) -def test_LearningRateFinder_train(iters, # pylint:disable=too-many-locals - mode, - strength, - _trainer_mock, - mocker): - """ Test lib.train.LearingRateFinder._train """ - trainer, _, _ = _trainer_mock(iters, mode, strength) - - mock_loss_return = [DummyLoss(np.random.random()) for _ in range(2)] - trainer.train_one_batch = mocker.MagicMock(return_value=mock_loss_return) - - lrf = LearningRateFinder(trainer) - - lrf._on_batch_end = mocker.MagicMock() - lrf._update_description = mocker.MagicMock() - - lrf._train() - - trainer.train_one_batch.assert_called() - assert trainer.train_one_batch.call_count == iters - - train_call_args = [mocker.call(x + 1, sum(y.total for y in mock_loss_return)) - for x in range(iters)] - assert lrf._on_batch_end.call_args_list == train_call_args - - lrf._update_description.assert_called() - assert lrf._update_description.call_count == iters - - # NaN break - mock_loss_return = mock_loss_return = [DummyLoss(np.nan) for _ in range(2)] - trainer.train_one_batch = mocker.MagicMock(return_value=mock_loss_return) - - lrf._train() - - assert trainer.train_one_batch.call_count == 1 # Called once - - assert lrf._update_description.call_count == iters # Not called - assert lrf._on_batch_end.call_count == iters # Not called - - -def test_LearningRateFinder_rebuild_optimizer(_trainer_mock): - """ Test lib.train.LearingRateFinder._rebuild_optimizer """ - trainer, _, _ = _trainer_mock() - lrf = LearningRateFinder(trainer) - - class Dummy: - """ Dummy Optimizer""" - name = "test" - - def get_config(self): - """Dummy get_config""" - return {} - - opt = Dummy() - new_opt = lrf._rebuild_optimizer(opt) - assert isinstance(new_opt, Dummy) and opt is not new_opt - - -@pytest.mark.parametrize(_LR_CONF_PARAMS, _LR_CONF) -@pytest.mark.parametrize("new_lr", (1e-4, 3.5e-5, 9.3e-6)) -def test_LearningRateFinder_reset_model(iters, mode, strength, new_lr, _trainer_mock, mocker): - """ Test lib.train.LearingRateFinder._reset_model """ - trainer, model, optimizer = _trainer_mock(iters, mode, strength) - model.state.add_lr_finder = mocker.MagicMock() - model.state.save = mocker.MagicMock() - model.model.load_weights = mocker.MagicMock() - - old_optimizer = optimizer - new_optimizer = mocker.MagicMock() - - def compile_side_effect(*args, **kwargs): # pylint:disable=unused-argument - """ Side effect for model.compile""" - model.model.optimizer = new_optimizer - - model.model.compile.side_effect = compile_side_effect - - lrf = LearningRateFinder(trainer) - lrf._rebuild_optimizer = mocker.MagicMock() - - lrf._reset_model(1e-5, new_lr) - - model.state.add_lr_finder.assert_called_with(new_lr) - model.state.save.assert_called_once() - - if mode == "graph_and_exit": - lrf._rebuild_optimizer.assert_not_called() - model.model.compile.assert_not_called() - model.model.load_weights.assert_not_called() - assert model.model.optimizer is old_optimizer - new_optimizer.learning_rate.assign.assert_not_called() - else: - lrf._rebuild_optimizer.assert_called_once_with(old_optimizer) - model.model.load_weights.assert_called_once() - model.model.compile.assert_called_once() - assert model.model.optimizer is new_optimizer - new_optimizer.learning_rate.assign.assert_called_once_with(new_lr) - - -_LR_FIND = ( - (True, [0.100, 0.050, 0.025], 0.025, [1e-5, 1e-4, 1e-3], "model_exist"), - (False, [0.100, 0.050, 0.025], 0.025, [1e-5, 1e-4, 1e-3], "no_model"), - (True, [0.100, 0.050, 0.025], 0.025, [1e-5, 1e-4, 1e-10], "low_lr"), - ) -_LR_PARAMS_FIND = ("exists", "losses", "best", "learning_rates") - - -@pytest.mark.parametrize(_LR_PARAMS_FIND, - [x[:-1] for x in _LR_FIND], - ids=[x[-1] for x in _LR_FIND]) -@pytest.mark.parametrize(_LR_CONF_PARAMS, _LR_CONF) -@pytest.mark.parametrize(_LR_CMDS_PARAMS, _LR_CMDS[0:1]) -def test_LearningRateFinder_find(iters, # pylint:disable=too-many-arguments,too-many-positional-arguments # noqa[E501] - mode, - strength, - stop_factor, - beta, - exists, - losses, - best, - learning_rates, - _trainer_mock, - mocker): - """ Test lib.train.LearingRateFinder.find """ - # pylint:disable=too-many-locals - trainer, model, optimizer = _trainer_mock(iters, mode, strength) - model.io.model_exists = exists - model.io.save = mocker.MagicMock() - original_lr = float(np.random.rand()) - optimizer.learning_rate.numpy = mocker.MagicMock(return_value=original_lr) - optimizer.learning_rate.assign = mocker.MagicMock() - mocker.patch("shutil.rmtree") - - lrf = LearningRateFinder(trainer, stop_factor=stop_factor, beta=beta) - - train_mock = mocker.MagicMock() - plot_mock = mocker.MagicMock() - reset_mock = mocker.MagicMock() - lrf._train = train_mock - lrf._plot_loss = plot_mock - lrf._reset_model = reset_mock - - lrf._metrics = {"losses": losses, "learning_rates": learning_rates} - lrf._loss = {"best": best} - - result = lrf.find() - - if exists: - model.io.save_assert_not_called() - else: - model.io.save.assert_called_once() - - optimizer.learning_rate.assign.assert_called_with(lrf._start_lr) - train_mock.assert_called_once() - - new_lr = learning_rates[losses.index(best)] / _STRENGTH_LOOKUP[strength] - if new_lr < 1e-9: - plot_mock.assert_not_called() - reset_mock.assert_not_called() - assert not result - return - - plot_mock.assert_called_once() - reset_mock.assert_called_once_with(original_lr, new_lr) - assert result diff --git a/tests/lib/training/lr_warmup_test.py b/tests/lib/training/lr_warmup_test.py deleted file mode 100644 index c1150c8dae..0000000000 --- a/tests/lib/training/lr_warmup_test.py +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin python3 -""" Pytest unit tests for :mod:`lib.training.lr_warmup` """ - -import pytest -import pytest_mock - -from keras.layers import Input, Dense -from keras.models import Model -from keras.optimizers import SGD - -from lib.training import LearningRateWarmup - - -# pylint:disable=protected-access,redefined-outer-name - - -@pytest.fixture -def model_fixture(): - """ Model fixture for testing LR Warmup """ - inp = Input((4, 4, 3)) - var_x = Dense(8)(inp) - model = Model(inputs=inp, outputs=var_x) - model.compile(optimizer=SGD(), loss="mse") - return model - - -_LR_STEPS = [(1e-5, 100), - (3.4e-6, 250), - (9e-4, 599), - (6e-5, 1000)] -_LR_STEPS_IDS = [f"lr:{x[0]}|steps:{x[1]}" for x in _LR_STEPS] - - -@pytest.mark.parametrize(("target_lr", "steps"), _LR_STEPS, ids=_LR_STEPS_IDS) -def test_init(model_fixture: Model, target_lr: float, steps: int) -> None: - """ Test class initializes correctly """ - instance = LearningRateWarmup(model_fixture, target_lr, steps) - - attrs = ["_model", "_target_lr", "_steps", "_current_lr", "_current_step", "_reporting_points"] - assert all(a in instance.__dict__ for a in attrs) - assert all(a in attrs for a in instance.__dict__) - assert instance._current_lr == 0.0 - assert instance._current_step == 0 - - assert isinstance(instance._model, Model) - assert instance._target_lr == target_lr - assert instance._steps == steps - - assert len(instance._reporting_points) == 11 - assert all(isinstance(x, int) for x in instance._reporting_points) - assert instance._reporting_points == [int(steps * i / 10) for i in range(11)] - - -_NOTATION = [(1e-5, "1.0e-05"), - (3.45489e-6, "3.5e-06"), - (0.0004, "4.0e-04"), - (0.1234, "1.2e-01")] - - -@pytest.mark.parametrize(("value", "expected"), _NOTATION, ids=[x[1] for x in _NOTATION]) -def test_format_notation(value: float, expected: str) -> None: - """ Test floats format to string correctly """ - result = LearningRateWarmup._format_notation(value) - assert result == expected - - -_LR_STEPS_CURRENT = [(1e-5, 100, 79), - (3.4e-6, 250, 250), - (9e-4, 599, 0), - (6e-5, 1000, 12)] -_LR_STEPS_CURRENT_IDS = [f"lr:{x[0]}|steps:{x[1]}|current_step:{x[2]}" for x in _LR_STEPS_CURRENT] - - -@pytest.mark.parametrize(("target_lr", "steps", "current_step"), - _LR_STEPS_CURRENT, - ids=_LR_STEPS_CURRENT_IDS) -def test_set_current_learning_rate(model_fixture: Model, - target_lr: float, - steps: int, - current_step: int) -> None: - """ Test that learning rate is set correctly """ - instance = LearningRateWarmup(model_fixture, target_lr, steps) - instance._current_step = current_step - instance._set_learning_rate() - - assert instance._current_lr == instance._current_step / instance._steps * instance._target_lr - assert instance._model.optimizer.learning_rate.value.cpu().numpy() == instance._current_lr - - -_STEPS_CURRENT = [(1000, 1, "start"), - (250, 250, "end"), - (500, 69, "unreported"), - (1000, 200, "reported")] -_STEPS_CURRENT_ID = [f"steps:{x[0]}|current_step:{x[1]}|action:{x[2]}" for x in _STEPS_CURRENT] - - -@pytest.mark.parametrize(("steps", "current_step", "action"), - _STEPS_CURRENT, - ids=_STEPS_CURRENT_ID) -def test_output_status(model_fixture: Model, - steps: int, - current_step: int, - action: str, - mocker: pytest_mock.MockerFixture) -> None: - """ Test that information is output correctly """ - mock_logger = mocker.patch("lib.training.lr_warmup.logger.info") - mock_print = mocker.patch("builtins.print") - instance = LearningRateWarmup(model_fixture, 5e-5, steps) - instance._current_step = current_step - instance._format_notation = mocker.MagicMock() # type:ignore[method-assign] - - instance._output_status() - - if action == "unreported": - assert current_step not in instance._reporting_points - mock_logger.assert_not_called() - instance._format_notation.assert_not_called() # type:ignore[attr-defined] - mock_print.assert_not_called() - return - - mock_logger.assert_called_once() - log_message: str = mock_logger.call_args.args[0] - assert log_message.startswith("[Learning Rate Warmup] ") - - instance._format_notation.assert_called() # type:ignore[attr-defined] - notation_args = [ - x.args for x in instance._format_notation.call_args_list] # type:ignore[attr-defined] - assert all(len(a) == 1 for a in notation_args) - assert all(isinstance(a[0], float) for a in notation_args) - - if action == "start": - mock_print.assert_not_called() - assert all(x in log_message for x in ("Start: ", "Target: ", "Steps: ")) - assert instance._format_notation.call_count == 2 # type:ignore[attr-defined] - return - - if action == "end": - mock_print.assert_called() - assert "Final Learning Rate: " in log_message - instance._format_notation.assert_called_once() # type:ignore[attr-defined] - return - - if action == "reported": - mock_print.assert_called() - assert current_step in instance._reporting_points - assert all(x in log_message for x in ("Step: ", "Current: ", "Target: ")) - assert instance._format_notation.call_count == 2 # type:ignore[attr-defined] - - -_STEPS_CURRENT_CALL = [(0, 500, "disabled"), - (1000, 500, "progress"), - (1000, 1000, "completed"), - (1000, 1111, "completed2")] -_STEPS_CURRENT_CALL_ID = [f"steps:{x[0]}|current_step:{x[1]}|action:{x[2]}" - for x in _STEPS_CURRENT_CALL] - - -@pytest.mark.parametrize(("steps", "current_step", "action"), - _STEPS_CURRENT_CALL, - ids=_STEPS_CURRENT_CALL_ID) -def test__call__(model_fixture: Model, - steps: int, - current_step: int, - action: str, - mocker: pytest_mock.MockerFixture) -> None: - """ Test calling the instance works correctly """ - instance = LearningRateWarmup(model_fixture, 5e-5, steps) - instance._current_step = current_step - instance._set_learning_rate = mocker.MagicMock() # type:ignore[method-assign] - instance._output_status = mocker.MagicMock() # type:ignore[method-assign] - - instance() - - if action in ("disabled", "completed", "completed2"): - assert instance._current_step == current_step - instance._set_learning_rate.assert_not_called() # type:ignore[attr-defined] - instance._output_status.assert_not_called() # type:ignore[attr-defined] - else: - assert instance._current_step == current_step + 1 - instance._set_learning_rate.assert_called_once() # type:ignore[attr-defined] - instance._output_status.assert_called_once() # type:ignore[attr-defined] diff --git a/tests/plugins/train/trainer/test_original.py b/tests/plugins/train/trainer/test_original.py index a386f6fb1e..41a30a1ec6 100644 --- a/tests/plugins/train/trainer/test_original.py +++ b/tests/plugins/train/trainer/test_original.py @@ -14,7 +14,7 @@ class DummyLoss: # pylint:disable=too-few-public-methods """Dummy loss return""" - total = 1.0 + total = np.random.rand() @pytest.fixture @@ -52,12 +52,11 @@ def test_Trainer_train_batch(_trainer_mocked, mocker): instance._backwards_and_apply = mocker.MagicMock() instance.model.model.zero_grad = mocker.MagicMock() - ret_val = instance.train_batch("TEST_INPUT", "TEST_TARGET", "TEST_META") + ret_val = instance.train_batch("TEST_INPUT", "TEST_TARGET", "TEST_OPTIMIZER", "TEST_META") assert ret_val == loss_return instance._forward.assert_called_once_with("TEST_INPUT", "TEST_TARGET", "TEST_META") - instance._backwards_and_apply.assert_called_once_with(1.0) - instance.model.model.zero_grad.assert_called_once() + instance._backwards_and_apply.assert_called_once_with(loss_return, "TEST_OPTIMIZER") @pytest.mark.parametrize("outputs", (1, 2, 4)) @@ -114,19 +113,9 @@ def test_Trainer_backwards_and_apply(_trainer_mocked, mocker): """ Test that original trainer _backwards_and_apply calls the correct model methods """ instance = _trainer_mocked() - mock_loss = mocker.MagicMock() - instance.model.model.optimizer.scale_loss = mocker.MagicMock(return_value=mock_loss) - instance.model.model.optimizer.app = mocker.MagicMock(return_value=mock_loss) + mock_optimizer = mocker.MagicMock() + all_loss = [DummyLoss] + instance._backwards_and_apply(all_loss, mock_optimizer) - all_loss = np.random.rand() - instance._backwards_and_apply(all_loss) - - scale_mock = instance.model.model.optimizer.scale_loss - scale_mock.assert_called_once() - assert not scale_mock.call_args[1] - assert len(scale_mock.call_args[0]) == 1 - assert np.isclose(all_loss, scale_mock.call_args[0][0].cpu().numpy()) - - mock_loss.backward.assert_called_once() - - instance.model.model.optimizer.apply.assert_called_once() + mock_optimizer.backward.assert_called_once_with(all_loss[0].total) + mock_optimizer.step.assert_called_once() From 7aa85b29aa537d22d7f7fd36574fb3616b28abdb Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Thu, 21 May 2026 12:02:26 +0100 Subject: [PATCH 973/981] extract bugfixes: vgg-obstructed model path + unet-dfl output shape --- plugins/extract/mask/unet_dfl.py | 2 +- plugins/extract/mask/vgg_obstructed.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/extract/mask/unet_dfl.py b/plugins/extract/mask/unet_dfl.py index 0dba511c34..90215738cd 100644 --- a/plugins/extract/mask/unet_dfl.py +++ b/plugins/extract/mask/unet_dfl.py @@ -68,7 +68,7 @@ def process(self, batch: np.ndarray) -> np.ndarray: ------- The predicted masks from the plugin """ - return self.from_torch(batch.transpose(0, 3, 1, 2)).transpose(0, 2, 3, 1) + return self.from_torch(batch.transpose(0, 3, 1, 2))[:, 0] class ConvBlock(nn.Module): diff --git a/plugins/extract/mask/vgg_obstructed.py b/plugins/extract/mask/vgg_obstructed.py index 166ba3b547..e85eea1c8e 100644 --- a/plugins/extract/mask/vgg_obstructed.py +++ b/plugins/extract/mask/vgg_obstructed.py @@ -39,7 +39,7 @@ def load_model(self) -> VGGObstructedModel: ------- The loaded VGGObstructed model """ - weights = GetModel("Nirkin_500_softmax_v2.pth", 8).model_path + weights = GetModel("Nirkin_500_softmax_v2.pth", 5).model_path assert isinstance(weights, str) return T.cast(VGGObstructedModel, self.load_torch_model(VGGObstructedModel(), weights, From 53b2fd9f1edf72a3508aa382d118eab74032081c Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 22 May 2026 12:09:05 +0100 Subject: [PATCH 974/981] bugfix: Set color order for relevant loss functions --- lib/training/loss.py | 15 +++++++++------ lib/training/train.py | 1 + 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/training/loss.py b/lib/training/loss.py index 6c2cedea97..aa284c638d 100644 --- a/lib/training/loss.py +++ b/lib/training/loss.py @@ -59,7 +59,7 @@ def to_cpu(self) -> T.Self: return self -class LossCollator(nn.Module): +class LossCollator(nn.Module): # pylint:disable=too-many-instance-attributes """Compiles the chosen loss functions and calculates the values in the training loop Parameters @@ -68,6 +68,8 @@ class LossCollator(nn.Module): List of lost function names from configuration file to collate for loss calculation weights List of weights, corresponding to the the list of functions, to apply to each loss function + color_order + The color order that the model is training in use_mask ``True`` if loss should be masked as `penalize mask loss` has been selected eye_multiplier @@ -82,13 +84,15 @@ class LossCollator(nn.Module): def __init__(self, functions: list[str], weights: list[float], + color_order: T.Literal["bgr", "rgb"], use_mask: bool, eye_multiplier: float, mouth_multiplier: float, smallest_output: int, mask_loss: str | None = None) -> None: - logger.debug(parse_class_init(locals())) + logger.info(parse_class_init(locals())) super().__init__() + self._color_order: T.Literal["bgr", "rgb"] = color_order self._use_mask = use_mask self._eye_multiplier = eye_multiplier self._mouth_multiplier = mouth_multiplier @@ -108,13 +112,12 @@ def __repr__(self) -> str: params = {"functions": list(self._functions), "weights": list(self._weights.values())} params |= {k[1:]: v for k, v in self.__dict__.items() - if k in ("_use_mask", "_eye_multiplier", "_mouth_multiplier", + if k in ("_color_order", "_use_mask", "_eye_multiplier", "_mouth_multiplier", "_smallest_output", "_mask_loss")} s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) return f"{self.__class__.__name__}({s_params})" - @classmethod - def _configure_functions(cls, + def _configure_functions(self, names: list[str], weights: list[float]) -> tuple[nn.ModuleDict, dict[str, float]]: """Configure the selected loss functions and send to the correct device @@ -148,7 +151,7 @@ def _configure_functions(cls, for name, weight in zip(names, weights): if name is None or name == "none" or weight <= 0.0: continue - functions[name] = get_loss_function(name) + functions[name] = get_loss_function(name, self._color_order) weight_dict[name] = weight logger.debug("[Loss] Configured loss functions: %s", diff --git a/lib/training/train.py b/lib/training/train.py index 52658fa6c2..1761df8506 100644 --- a/lib/training/train.py +++ b/lib/training/train.py @@ -129,6 +129,7 @@ def _configure_model(self, plugin: TrainerBase): mod_cfg.Loss.loss_weight_2() / 100., mod_cfg.Loss.loss_weight_3() / 100., mod_cfg.Loss.loss_weight_4() / 100.], + color_order=self._model.color_order, use_mask=mod_cfg.Loss.penalized_mask_loss(), eye_multiplier=mod_cfg.Loss.eye_multiplier(), mouth_multiplier=mod_cfg.Loss.mouth_multiplier(), From 6923cd207471f597056c466c00df085e9b8b952f Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 22 May 2026 16:54:11 +0100 Subject: [PATCH 975/981] bugfix: lpips - disable grad --- lib/model/losses/feature_loss.py | 4 ++++ lib/training/loss.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/model/losses/feature_loss.py b/lib/model/losses/feature_loss.py index c127a47b97..19a47ade6d 100644 --- a/lib/model/losses/feature_loss.py +++ b/lib/model/losses/feature_loss.py @@ -125,6 +125,8 @@ def _get_net(self) -> nn.Module: if self._eval_mode: net.eval() + for p in net.parameters(): + p.requires_grad = False return net @classmethod @@ -212,6 +214,8 @@ def _get_net(self) -> nn.ModuleList: if self._eval_mode: net.eval() + for p in net.parameters(): + p.requires_grad = False return net def forward(self, inputs: list[torch.Tensor]) -> list[torch.Tensor]: diff --git a/lib/training/loss.py b/lib/training/loss.py index aa284c638d..410cc55dc1 100644 --- a/lib/training/loss.py +++ b/lib/training/loss.py @@ -90,7 +90,7 @@ def __init__(self, mouth_multiplier: float, smallest_output: int, mask_loss: str | None = None) -> None: - logger.info(parse_class_init(locals())) + logger.debug(parse_class_init(locals())) super().__init__() self._color_order: T.Literal["bgr", "rgb"] = color_order self._use_mask = use_mask From fa79d4dcee912fe4a969d692130a59195400b338 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Fri, 29 May 2026 11:40:03 +0100 Subject: [PATCH 976/981] Initializers to Torch (#1549) * Update requirements * Remove docker * ICNR init to torch * conv-aware: Output as Tensor rather than Variable * Bugfix: Omit type object from automodsumm * bugfix: Tensorboard unit test. Switch Sequential for Functional * Suppress broken Keras test * revert failing test --- .dockerignore | 3 - .gitignore | 1 - Dockerfile.cpu | 19 -- Dockerfile.gpu | 18 -- INSTALL.md | 90 +----- lib/cli/launcher.py | 4 +- lib/config/ini.py | 116 ++++---- lib/config/objects.py | 4 +- lib/model/initializers.py | 161 ++++------- lib/model/layers.py | 3 +- lib/system/ml_libs.py | 299 +++++++++----------- lib/system/system.py | 103 +++---- requirements/_requirements_base.txt | 4 +- requirements/requirements_apple-silicon.txt | 2 +- requirements/requirements_cpu.txt | 2 +- requirements/requirements_nvidia_12.txt | 2 +- requirements/requirements_nvidia_13.txt | 2 +- requirements/requirements_rocm.txt | 2 +- requirements/requirements_rocm_71.txt | 3 + requirements/requirements_rocm_72.txt | 3 + setup.py | 261 ++++++----------- 21 files changed, 387 insertions(+), 715 deletions(-) delete mode 100644 .dockerignore delete mode 100755 Dockerfile.cpu delete mode 100755 Dockerfile.gpu create mode 100644 requirements/requirements_rocm_71.txt create mode 100644 requirements/requirements_rocm_72.txt diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 66cb3564d1..0000000000 --- a/.dockerignore +++ /dev/null @@ -1,3 +0,0 @@ -* -!requirements* -!_requirements* diff --git a/.gitignore b/.gitignore index ba4b84e9eb..3c540834e3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ !/requirements/*.py # Root files -!Dockerfile* !pyproject.toml !.gitignore !.travis.yml diff --git a/Dockerfile.cpu b/Dockerfile.cpu deleted file mode 100755 index 0c27ec9b69..0000000000 --- a/Dockerfile.cpu +++ /dev/null @@ -1,19 +0,0 @@ -FROM ubuntu:22.04 - -# To disable tzdata and others from asking for input -ENV DEBIAN_FRONTEND noninteractive -ENV FACESWAP_BACKEND cpu - -RUN apt-get update -qq -y -RUN apt-get upgrade -y -RUN apt-get install -y libgl1 libglib2.0-0 python3 python3-pip python3-tk git - -RUN ln -s $(which python3) /usr/local/bin/python - -RUN git clone --depth 1 --no-single-branch https://github.com/deepfakes/faceswap.git -WORKDIR "/faceswap" - -RUN python -m pip install --upgrade pip -RUN python -m pip --no-cache-dir install -r ./requirements/requirements_cpu.txt - -CMD ["/bin/bash"] diff --git a/Dockerfile.gpu b/Dockerfile.gpu deleted file mode 100755 index 5b9c0abd0a..0000000000 --- a/Dockerfile.gpu +++ /dev/null @@ -1,18 +0,0 @@ -FROM nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04 - -ENV DEBIAN_FRONTEND=noninteractive -ENV FACESWAP_BACKEND nvidia - -RUN apt-get update -qq -y -RUN apt-get upgrade -y -RUN apt-get install -y libgl1 libglib2.0-0 python3 python3-pip python3-tk git - -RUN ln -s $(which python3) /usr/local/bin/python - -RUN git clone --depth 1 --no-single-branch https://github.com/deepfakes/faceswap.git -WORKDIR "/faceswap" - -RUN python -m pip install --upgrade pip -RUN python -m pip --no-cache-dir install -r ./requirements/requirements_nvidia.txt - -CMD ["/bin/bash"] diff --git a/INSTALL.md b/INSTALL.md index 63ba045f91..83a60f104b 100755 --- a/INSTALL.md +++ b/INSTALL.md @@ -38,9 +38,6 @@ - [Getting the faceswap code](#getting-the-faceswap-code) - [Setup](#setup-2) - [About some of the options](#about-some-of-the-options) -- [Docker Install Guide](#docker-install-guide) - - [Docker CPU](#docker-cpu) - - [Docker Nvidia](#docker-nvidia) - [Run the project](#run-the-project) - [Notes](#notes) @@ -74,8 +71,6 @@ The type of computations that the process does are well suited for graphics card Intel based macOS systems should work, but you will need to follow the [Manual Install](#manual-install) instructions. - All operating systems must be 64-bit. -Alternatively, there is a docker image that is based on Debian. - # Important before you proceed **In its current iteration, the project relies heavily on the use of the command line, although a gui is available. if you are unfamiliar with command line tools, you may have difficulty setting up the environment and should perhaps not attempt any of the steps described in this guide.** This guide assumes you have intermediate knowledge of the command line. @@ -131,7 +126,7 @@ To enter the virtual environment: - If you have issues/errors follow the Manual install steps below. #### Manual install -Do not follow these steps if the Easy Install above completed succesfully. +Do not follow these steps if the Easy Install above completed successfully. If you are using an Nvidia card make sure you have the correct versions of Cuda/cuDNN installed for the required version of Torch - Install tkinter (required for the GUI) by typing: `conda install tk` - Install requirements: @@ -251,7 +246,7 @@ Alternatively you can install Python (3.14 64-bit) for your distribution (links If using Conda3 then setting up virtual environments is relatively straight forward. More information can be found at [Conda Docs](https://docs.conda.io/projects/conda/en/latest/user-guide/tasks/manage-environments.html) - If using a default Python distribution then [virtualenv](https://github.com/pypa/virtualenv) and [virtualenvwrapper](https://virtualenvwrapper.readthedocs.io) may help when you are not using docker. + If using a default Python distribution then [virtualenv](https://github.com/pypa/virtualenv) and [virtualenvwrapper](https://virtualenvwrapper.readthedocs.io) may help. ## Getting the faceswap code @@ -270,87 +265,8 @@ If setup fails for any reason you can still manually install the packages listed ### About some of the options - CUDA: For acceleration. Requires a good nVidia Graphics Card (which supports CUDA inside) - - Docker: Provide a ready-made image. Hide trivial details. Get you straight to the project. - - nVidia-Docker: Access to the nVidia GPU on host machine from inside container. - -# Docker Install Guide - -This Faceswap repo contains Docker build scripts for CPU and Nvidia backends. The scripts will set up a Docker container for you and install the latest version of the Faceswap software. - -You must first ensure that Docker is installed and running on your system. Follow the guide for downloading and installing Docker from their website: - - - https://www.docker.com/get-started + - ROCm: For AMD GPUs under Linux/WSL2 only. Make sure you install the correct version of faceswap for your installed ROCm version -Once Docker is installed and running, follow the relevant steps for your chosen backend -## Docker CPU -To run the CPU version of Faceswap follow these steps: - -1. Build the Docker image For faceswap: -``` -docker build \ --t faceswap-cpu \ -https://raw.githubusercontent.com/deepfakes/faceswap/master/Dockerfile.cpu -``` -2. Launch and enter the Faceswap container: - - a. For the **headless/command line** version of Faceswap run: - ``` - docker run --rm -it faceswap-cpu - ``` - You can then execute faceswap the standard way: - ``` - python faceswap.py --help - ``` - b. For the **GUI** version of Faceswap run: - ``` - xhost +local: && \ - docker run --rm -it \ - -v /tmp/.X11-unix:/tmp/.X11-unix \ - -e DISPLAY=${DISPLAY} \ - faceswap-cpu - ``` - You can then launch the GUI with - ``` - python faceswap.py gui - ``` - ## Docker Nvidia -To build the NVIDIA GPU version of Faceswap, follow these steps: - -1. Nvidia Docker builds need extra resources to provide the Docker container with access to your GPU. - - a. Follow the instructions to install and apply the `Nvidia Container Toolkit` for your distribution from: - - https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html - - b. If Docker is already running, restart it to pick up the changes made by the Nvidia Container Toolkit. - -2. Build the Docker image For faceswap -``` -docker build \ --t faceswap-gpu \ -https://raw.githubusercontent.com/deepfakes/faceswap/master/Dockerfile.gpu -``` -1. Launch and enter the Faceswap container: - - a. For the **headless/command line** version of Faceswap run: - ``` - docker run --runtime=nvidia --rm -it faceswap-gpu - ``` - You can then execute faceswap the standard way: - ``` - python faceswap.py --help - ``` - b. For the **GUI** version of Faceswap run: - ``` - xhost +local: && \ - docker run --runtime=nvidia --rm -it \ - -v /tmp/.X11-unix:/tmp/.X11-unix \ - -e DISPLAY=${DISPLAY} \ - faceswap-gpu - ``` - You can then launch the GUI with - ``` - python faceswap.py gui - ``` # Run the project Once all these requirements are installed, you can attempt to run the faceswap tools. Use the `-h` or `--help` options for a list of options. diff --git a/lib/cli/launcher.py b/lib/cli/launcher.py index 4f319cdf3b..cf0919ea73 100644 --- a/lib/cli/launcher.py +++ b/lib/cli/launcher.py @@ -11,6 +11,7 @@ from lib.gpu_stats import GPUStats from lib.logger import crash_log, log_setup +from lib.system.system import VALID_TORCH from lib.utils import (FaceswapError, get_backend, get_torch_version, get_module_objects, safe_shutdown, set_backend) @@ -77,8 +78,7 @@ def _test_for_torch_version(self) -> None: FaceswapError If PyTorch is not found, or is not between versions 2.3 and 2.11 """ - min_ver = (2, 3) - max_ver = (2, 11) + min_ver, max_ver = VALID_TORCH try: import torch # noqa:F401 pylint:disable=unused-import,import-outside-toplevel except ImportError as err: diff --git a/lib/config/ini.py b/lib/config/ini.py index e48c91862c..e7537a97b8 100644 --- a/lib/config/ini.py +++ b/lib/config/ini.py @@ -1,5 +1,5 @@ #! /usr/env/bin/python3 -""" Handles interfacing between Faceswap Configs and ConfigParser .ini files """ +"""Handles interfacing between Faceswap Configs and ConfigParser .ini files""" from __future__ import annotations import logging @@ -19,13 +19,13 @@ class ConfigFile(): - """ Handles the interfacing between saved faceswap .ini configs and internal Config objects + """Handles the interfacing between saved faceswap .ini configs and internal Config objects Parameters ---------- - plugin_group : str + plugin_group The plugin group that is requesting a config file - ini_path : str | None, optional + ini_path Optional path to a .ini config file. ``None`` for default location. Default: ``None`` """ def __init__(self, plugin_group: str, ini_path: str | None = None) -> None: @@ -38,21 +38,20 @@ def __init__(self, plugin_group: str, ini_path: str | None = None) -> None: @property def _exists(self) -> bool: - """ bool : ``True`` if the config.ini file exists """ + """``True`` if the config.ini file exists""" return os.path.isfile(self._file_path) def _get_config_path(self, ini_path: str | None) -> str: - """ Return the path to the config file from the calling folder or the provided file + """Return the path to the config file from the calling folder or the provided file Parameters ---------- - ini_path : str | None + ini_path Path to a config ini file. ``None`` for default location. Returns ------- - str - The full path to the configuration file + The full path to the configuration file """ if ini_path is not None: if not os.path.isfile(ini_path): @@ -66,12 +65,11 @@ def _get_config_path(self, ini_path: str | None) -> str: return retval def _get_new_configparser(self) -> ConfigParser: - """ Obtain a fresh ConfigParser object and set it to case-sensitive + """Obtain a fresh ConfigParser object and set it to case-sensitive Returns ------- - :class:`configparser.ConfigParser` - A new ConfigParser object set to case-sensitive + A new ConfigParser object set to case-sensitive """ retval = ConfigParser(allow_no_value=True) retval.optionxform = str # type:ignore[assignment,method-assign] @@ -79,34 +77,33 @@ def _get_new_configparser(self) -> ConfigParser: # I/O def load(self) -> None: - """ Load values from the saved config ini file into our Config object """ + """Load values from the saved config ini file into our Config object""" logger.verbose("[%s] Loading config: '%s'", # type:ignore[attr-defined] self._plugin_group, self._file_path) self._parser.read(self._file_path, encoding="utf-8") def save(self) -> None: - """ Save a config file """ + """Save a config file""" logger.debug("[%s] %s config: '%s'", self._plugin_group, "Updating" if self._exists else "Saving", self._file_path) # TODO in python >= 3.14 this will error when there are delimiters in the comments - with open(self._file_path, "w", encoding="utf-8", errors="replace") as f_cfgfile: - self._parser.write(f_cfgfile) + with open(self._file_path, "w", encoding="utf-8", errors="replace") as f_cfg_file: + self._parser.write(f_cfg_file) logger.info("[%s] Saved config: '%s'", self._plugin_group, self._file_path) # .ini vs Faceswap Config checking def _sections_synced(self, app_config: dict[str, ConfigSection]) -> bool: - """ Validate that all of the sections within the application config match with all of the + """Validate that all of the sections within the application config match with all of the sections in the ini file Parameters ---------- - app_config : dict[str, :class:`ConfigSection`] + app_config The latest configuration settings from the application. Section name is key Returns ------- - bool - ``True`` if application sections and saved ini sections match + ``True`` if application sections and saved ini sections match """ given_sections = set(app_config) loaded_sections = set(self._parser.sections()) @@ -117,7 +114,7 @@ def _sections_synced(self, app_config: dict[str, ConfigSection]) -> bool: return retval def _options_synced(self, app_config: dict[str, ConfigSection]) -> bool: - """ Validate that all of the option names within the application config match with all of + """Validate that all of the option names within the application config match with all of the option names in the ini file Note @@ -126,13 +123,12 @@ def _options_synced(self, app_config: dict[str, ConfigSection]) -> bool: Parameters ---------- - app_config : dict[str, :class:`ConfigSection`] + app_config The latest configuration settings from the application. Section name is key Returns ------- - bool - ``True`` if application option names match with saved ini option names + ``True`` if application option names match with saved ini option names """ for name, section in app_config.items(): given_opts = set(opt for opt in section.options) @@ -144,20 +140,19 @@ def _options_synced(self, app_config: dict[str, ConfigSection]) -> bool: return True def _values_synced(self, app_section: ConfigSection, section: str) -> bool: - """ Validate that all of the option values within the application config match with all of + """Validate that all of the option values within the application config match with all of the option values in the ini file Parameters ---------- - app_section : :class:`ConfigSection` + app_section The latest configuration settings from the application for the given section - section : str + section The section name to check the option values for Returns ------- - bool - ``True`` if application option values match with saved ini option values + ``True`` if application option values match with saved ini option values """ # Need to also pull in keys as False is omitted from the set with just values which can # cause edge-case false negatives @@ -170,18 +165,17 @@ def _values_synced(self, app_section: ConfigSection, section: str) -> bool: return retval def _is_synced_structure(self, app_config: dict[str, ConfigSection]) -> bool: - """ Validate that all the given sections and option names within the application config + """Validate that all the given sections and option names within the application config match with their corresponding items in the save .ini file Parameters ---------- - app_config: dict[str, :class:`ConfigSection`] + app_config The latest configuration settings from the application. Section name is key Returns ------- - bool - ``True`` if the app config and saved ini config structure match + ``True`` if the app config and saved ini config structure match """ if not self._sections_synced(app_config): return False @@ -193,20 +187,19 @@ def _is_synced_structure(self, app_config: dict[str, ConfigSection]) -> bool: # .ini file insertion def format_help(self, helptext: str, is_section: bool = False) -> str: - """ Format comments for insertion into a config ini file + """Format comments for insertion into a config ini file Parameters ---------- - helptext : str + helptext The help text to be formatted - is_section : bool, optional + is_section ``True`` if the help text pertains to a section. ``False`` if it pertains to an option. Default: ``True`` Returns ------- - str - The formatted help text + The formatted help text """ logger.debug("[%s] Formatting help: (helptext: '%s', is_section: '%s')", self._plugin_group, helptext, is_section) @@ -223,15 +216,15 @@ def format_help(self, helptext: str, is_section: bool = False) -> str: return helptext def _insert_section(self, section: str, helptext: str, config: ConfigParser) -> None: - """ Insert a section into the config + """Insert a section into the config Parameters ---------- - section : str + section The section title to insert - helptext : str + helptext The help text for the config section - config : :class:`configparser.ConfigParser` + config The config parser object to insert the section into. """ logger.debug("[%s:%s] Inserting section: (helptext: '%s', config: '%s')", @@ -246,19 +239,19 @@ def _insert_option(self, helptext: str, value: str, config: ConfigParser) -> None: - """ Insert an option into a config section + """Insert an option into a config section Parameters ---------- - section : str + section The section to insert the option into - name : str + name The name of the option to insert - helptext : str + helptext The help text for the option - value : str + value The value for the option - config : :class:`configparser.ConfigParser` + config The config parser object to insert the option into """ logger.debug( @@ -269,7 +262,7 @@ def _insert_option(self, config.set(section, name, value) def _sync_from_app(self, app_config: dict[str, ConfigSection]) -> None: - """ Update the saved config.ini file from the values stored in the application config + """Update the saved config.ini file from the values stored in the application config Existing options keep their saved values as per the .ini files. New options are added with their application defined default value. Options in the .ini file not in application @@ -281,7 +274,7 @@ def _sync_from_app(self, app_config: dict[str, ConfigSection]) -> None: Parameters ---------- - app_config: dict[str, :class:`ConfigSection`] + app_config The latest configuration settings from the application. Section name is key """ logger.debug("[%s] Syncing from app", self._plugin_group) @@ -306,21 +299,20 @@ def _sync_from_app(self, app_config: dict[str, ConfigSection]) -> None: # .ini extraction def _get_converted_value(self, section: str, option: str, datatype: type) -> ConfigValueType: - """ Return a config item from the .ini file in it's correct type. + """Return a config item from the .ini file in it's correct type. Parameters ---------- - section : str + section The configuration section to obtain the config option for - option : str + option The configuration option to obtain the converted value for - datatype : type + datatype The type to return the value as Returns ------- - bool | int | float | list[str] | str - The selected configuration option in the correct data format + The selected configuration option in the correct data format """ logger.debug("[%s:%s] Getting config item: (option: '%s', datatype: %s)", self._plugin_group, section, option, datatype) @@ -343,11 +335,11 @@ def _get_converted_value(self, section: str, option: str, datatype: type) -> Con return retval def _sync_to_app(self, app_config: dict[str, ConfigSection]) -> None: - """ Update the values in the application config to those loaded from the saved config.ini. + """Update the values in the application config to those loaded from the saved config.ini. Parameters ---------- - app_config: dict[str, :class:`ConfigSection`] + app_config The latest configuration settings from the application. Section name is key """ logger.debug("[%s] Syncing to app", self._plugin_group) @@ -369,13 +361,13 @@ def _sync_to_app(self, app_config: dict[str, ConfigSection]) -> None: # .ini insertion and extraction def on_load(self, app_config: dict[str, ConfigSection]) -> None: - """ Check whether there has been any change between the current application config and + """Check whether there has been any change between the current application config and the loaded ini config. If so, update the relevant object(s) appropriately. This check will also create new config.ini files if they do not pre-exist Parameters ---------- - app_config : dict[str, :class:`ConfigSection`] + app_config The latest configuration settings from the application. Section name is key """ if not self._exists: @@ -388,12 +380,12 @@ def on_load(self, app_config: dict[str, ConfigSection]) -> None: self._sync_to_app(app_config) def update_from_app(self, app_config: dict[str, ConfigSection]) -> None: - """ Update the config.ini file to those values that are currently in Faceswap's app + """Update the config.ini file to those values that are currently in Faceswap's app config Parameters ---------- - app_config : dict[str, :class:`ConfigSection`] + app_config The latest configuration settings from the application. Section name is key """ logger.debug("[%s] Updating saved config", self._plugin_group) diff --git a/lib/config/objects.py b/lib/config/objects.py index c2800e52b0..52c14abf9f 100644 --- a/lib/config/objects.py +++ b/lib/config/objects.py @@ -440,7 +440,7 @@ class ConfigSection: options: dict[str, ConfigItem] -class ConfigReprMeta(type): +class _ConfigReprMeta(type): # Must be private or breaks automodsumm """A custom repr for printing currently selected config values""" def __repr__(cls) -> str: params = ", ".join(f"{k}={repr(v.value)}" @@ -450,7 +450,7 @@ def __repr__(cls) -> str: @dataclass -class GlobalSection(metaclass=ConfigReprMeta): +class GlobalSection(metaclass=_ConfigReprMeta): """A dataclass for holding and identifying global sub-sections for plugin groups. Any global subsections must inherit from this. diff --git a/lib/model/initializers.py b/lib/model/initializers.py index 908dd6e892..6c45540c05 100644 --- a/lib/model/initializers.py +++ b/lib/model/initializers.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Custom Initializers for faceswap.py """ +"""Custom Initializers for faceswap.py""" from __future__ import annotations import logging @@ -7,8 +7,10 @@ import inspect import typing as T -from keras import backend as K, initializers, ops -from keras import saving, Variable +import torch + +from keras import backend as K, initializers +from keras import saving from keras.src.initializers.random_initializers import compute_fans import numpy as np @@ -16,27 +18,23 @@ from lib.logger import parse_class_init from lib.utils import get_module_objects -if T.TYPE_CHECKING: - from keras import KerasTensor - logger = logging.getLogger(__name__) class ICNR(initializers.Initializer): - """ ICNR initializer for checkerboard artifact free sub pixel convolution + """ICNR initializer for checkerboard artifact free sub pixel convolution Parameters ---------- - initializer: :class:`keras.initializers.Initializer` + initializer The initializer used for sub kernels (orthogonal, glorot uniform, etc.) - scale: int, optional + scale scaling factor of sub pixel convolution (up sampling from 8x8 to 16x16 is scale 2). Default: `2` Returns ------- - :class:`keras.KerasTensor` - The modified kernel weights + The modified kernel weights Example ------- @@ -46,95 +44,44 @@ class ICNR(initializers.Initializer): ---------- Andrew Aitken et al. Checkerboard artifact free sub-pixel convolution https://arxiv.org/pdf/1707.02937.pdf, https://distill.pub/2016/deconv-checkerboard/ + https://gist.github.com/A03ki/2305398458cb8e2155e8e81333f0a965 """ def __init__(self, initializer: dict[str, T.Any] | initializers.Initializer, scale: int = 2) -> None: logger.debug(parse_class_init(locals())) - self._scale = scale self._initializer = initializer - logger.debug("Initialized %s", self.__class__.__name__) - def __call__(self, shape: list[int] | tuple[int, ...], - dtype: str | None = "float32") -> KerasTensor: - """ Call function for the ICNR initializer. - - Parameters - ---------- - shape: list[int] | tuple[int, ...] - The required resized shape for the output tensor - dtype: str - The data type for the tensor - kwargs: dict[str, Any] - Standard keras initializer keyword arguments - - Returns - ------- - :class:`keras.KerasTensor` - The modified kernel weights - """ + dtype: str | None = "float32") -> torch.Tensor: shape = list(shape) - - if self._scale == 1: + if self._scale == 1: # TODO validate when moved to full torch if isinstance(self._initializer, dict): return next(i for i in self._initializer.values()) return self._initializer(shape) new_shape = shape[:3] + [shape[3] // (self._scale ** 2)] - size = [s * self._scale for s in new_shape[:2]] - if isinstance(self._initializer, dict): + if isinstance(self._initializer, dict): # TODO remove when full torch self._initializer = initializers.deserialize(self._initializer) - var_x = self._initializer(new_shape, dtype) - var_x = ops.transpose(var_x, [2, 0, 1, 3]) - var_x = ops.image.resize(var_x, - size, - interpolation="nearest", - data_format="channels_last") - var_x = self._space_to_depth(T.cast("KerasTensor", var_x)) - var_x = ops.transpose(var_x, [1, 2, 0, 3]) + x: torch.Tensor = self._initializer(new_shape, dtype) - logger.debug("ICNR Output shape: %s", var_x.shape) - return T.cast("KerasTensor", var_x) - - def _space_to_depth(self, input_tensor: KerasTensor) -> KerasTensor: - """ Space to depth Keras implementation. - - Parameters - ---------- - input_tensor: :class:`keras.KerasTensor` - The tensor to be manipulated - - Returns - ------- - :class:`keras.KerasTensor` - The manipulated input tensor - """ - batch, height, width, depth = input_tensor.shape - assert height is not None and width is not None - new_height, new_width = height // 2, width // 2 - inter_shape = (batch, new_height, self._scale, new_width, self._scale, depth) - - var_x = ops.reshape(input_tensor, inter_shape) - var_x = ops.transpose(var_x, (0, 1, 3, 2, 4, 5)) - retval = ops.reshape(var_x, (batch, new_height, new_width, -1)) - - logger.debug("Space to depth - Input shape: %s, Output shape: %s", - input_tensor.shape, retval.shape) - return T.cast("KerasTensor", retval) + # TODO repeat needs to be replaced with repeat_interleave when pixel-shuffler is ported: + # x = x.repeat_interleave(self._scale ** 2, dim = -1) + x = x.repeat(*([1] * (x.dim() - 1)), self._scale ** 2) + logger.debug("ICNR Output shape: %s", x.shape) + return x def get_config(self) -> dict[str, T.Any]: - """ Return the ICNR Initializer configuration. + """Return the ICNR Initializer configuration. Returns ------- - dict[str, Any] - The configuration for ICNR Initialization + The configuration for ICNR Initialization """ config = {"scale": self._scale, "initializer": self._initializer} base_config = super().get_config() @@ -142,8 +89,7 @@ def get_config(self) -> dict[str, T.Any]: class ConvolutionAware(initializers.Initializer): - """ - Initializer that generates orthogonal convolution filters in the Fourier space. If this + """Initializer that generates orthogonal convolution filters in the Fourier space. If this initializer is passed a shape that is not 3D or 4D, orthogonal initialization will be used. Adapted, fixed and optimized from: @@ -151,26 +97,26 @@ class ConvolutionAware(initializers.Initializer): Parameters ---------- - eps_std: float, optional + eps_std The Standard deviation for the random normal noise used to break symmetry in the inverse Fourier transform. Default: 0.05 - seed: int | None, optional + seed Used to seed the random generator. Default: ``None`` - initialized: bool, optional + initialized This should always be set to ``False``. To avoid Keras re-calculating the values every time the model is loaded, this parameter is internally set on first time initialization. Default:``False`` Returns ------- - :class:`keras.Variable` - The modified kernel weights + The modified kernel weights References ---------- Armen Aghajanyan, https://arxiv.org/abs/1702.06295 """ - + # TODO this needs to be done after porting models to torch as it depends on underlying model + # structure def __init__(self, eps_std: float = 0.05, seed: int | None = None, @@ -187,17 +133,16 @@ def __init__(self, @classmethod def _symmetrize(cls, inputs: np.ndarray) -> np.ndarray: - """ Make the given tensor symmetrical. + """Make the given tensor symmetrical. Parameters ---------- - inputs: :class:`numpy.ndarray` + inputs The input tensor to make symmetrical Returns ------- - :class:`numpy.ndarray` - The symmetrical output + The symmetrical output """ var_a = np.transpose(inputs, axes=(0, 1, 3, 2)) diag = var_a.diagonal(axis1=2, axis2=3) @@ -207,21 +152,20 @@ def _symmetrize(cls, inputs: np.ndarray) -> np.ndarray: return retval def _create_basis(self, filters_size: int, filters: int, size: int, dtype: str) -> np.ndarray: - """ Create the basis for convolutional aware initialization + """Create the basis for convolutional aware initialization Parameters ---------- - filters_size: int + filters_size The size of the filter - filters: int + filters The number of filters - dtype: str + dtype The data type Returns ------- - :class:`numpy.ndarray` - The output array + The output array """ if size == 1: return np.random.normal(0.0, self._eps_std, (filters_size, filters, size)) @@ -236,19 +180,18 @@ def _create_basis(self, filters_size: int, filters: int, size: int, dtype: str) @classmethod def _scale_filters(cls, filters: np.ndarray, variance: float) -> np.ndarray: - """ Scale the given filters. + """Scale the given filters. Parameters ---------- - filters: :class:`numpy.ndarray` + filters The filters to scale - variance: float + variance The amount of variance Returns ------- - :class:`numpy.ndarray` - The scaled filters + The scaled filters """ c_var = np.var(filters) var_p = np.sqrt(variance / c_var) @@ -259,23 +202,22 @@ def _scale_filters(cls, filters: np.ndarray, variance: float) -> np.ndarray: def __call__(self, # pylint: disable=too-many-locals shape: list[int] | tuple[int, ...], - dtype: str | None = None) -> Variable: - """ Call function for the ICNR initializer. + dtype: str | None = None) -> torch.Tensor: + """Call function for the ICNR initializer. Parameters ---------- - shape: list[int] | tuple[int, ...] + shape The required shape for the output tensor - dtype: str + dtype The data type for the tensor Returns ------- - :class:`keras.Variable` - The modified kernel weights + The modified kernel weights """ if self._initialized: # Avoid re-calculating initializer when loading a saved model - return T.cast("Variable", self._he_uniform(shape, dtype=dtype)) + return T.cast(torch.Tensor, self._he_uniform(shape, dtype=dtype)) dtype = K.floatx() if dtype is None else dtype logger.info("Calculating Convolution Aware Initializer for shape: %s", shape) rank = len(shape) @@ -317,7 +259,7 @@ def __call__(self, # pylint: disable=too-many-locals else: self._initialized = True - return Variable(self._orthogonal(shape), dtype=dtype) + return T.cast(torch.Tensor, self._orthogonal(shape)) kernel_fourier_shape = correct_fft(np.zeros(kernel_shape)).shape @@ -328,19 +270,18 @@ def __call__(self, # pylint: disable=too-many-locals basis = basis.reshape((filters_size, stack_size,) + kernel_fourier_shape) randoms = np.random.normal(0, self._eps_std, basis.shape[:-2] + kernel_shape) init = correct_ifft(basis, kernel_shape) + randoms - init = self._scale_filters(init, variance) + init = self._scale_filters(init, variance).astype(dtype) self._initialized = True - retval = Variable(init.transpose(transpose_dimensions), dtype=dtype, name="conv_aware") + retval = torch.from_numpy(init.transpose(transpose_dimensions)) logger.debug("ConvAware output: %s", retval) return retval def get_config(self) -> dict[str, T.Any]: - """ Return the Convolutional Aware Initializer configuration. + """Return the Convolutional Aware Initializer configuration. Returns ------- - dict[str, Any] - The configuration for Convolutional Aware Initialization + The configuration for Convolutional Aware Initialization """ config = {"eps_std": self._eps_std, "seed": self._seed, diff --git a/lib/model/layers.py b/lib/model/layers.py index 51bc644eb7..8a1d563087 100644 --- a/lib/model/layers.py +++ b/lib/model/layers.py @@ -295,6 +295,7 @@ class PixelShuffler(Layer): # pylint:disable=too-many-ancestors,abstract-method ---------- https://gist.github.com/t-ae/6e1016cc188104d123676ccef3264981 """ + # TODO. When this is ported to nn.PixelShuffle: ICNR init must be updated as commented in code def __init__(self, size: int | tuple[int, int] = (2, 2), data_format: str | None = None, @@ -464,7 +465,7 @@ def compute_output_shape(self, input_shape: tuple[int, ...] # pylint:disable=ar def call(self, inputs: KerasTensor, *args, **kwargs # pylint:disable=arguments-differ ) -> KerasTensor: - """ Call the QuickGELU layerr + """ Call the QuickGELU layer Parameters ---------- diff --git a/lib/system/ml_libs.py b/lib/system/ml_libs.py index d0c956db30..c469c4cae4 100644 --- a/lib/system/ml_libs.py +++ b/lib/system/ml_libs.py @@ -1,6 +1,5 @@ #! /usr/env/bin/python -""" -Queries information about system installed Machine Learning Libraries. +"""Queries information about system installed Machine Learning Libraries. NOTE: Only packages from Python's Standard Library should be imported in this module """ from __future__ import annotations @@ -31,20 +30,20 @@ _TORCH_ROCM_REQUIREMENTS = {">=2.2.1,<2.4.0": ((6, 0), (6, 0))} -"""dict[str, tuple[tuple[int, int], tuple[int, int]]]: Minumum and maximum ROCm versions """ +"""Minimum and maximum ROCm versions""" def _check_dynamic_linker(lib: str) -> list[str]: - """ Locate the folders that contain a given library in ldconfig and $LD_LIBRARY_PATH + """Locate the folders that contain a given library in ldconfig and $LD_LIBRARY_PATH Parameters ---------- - lib: str The library to locate + lib + The library to locate Returns ------- - list[str] - All real existing folders from ldconfig or $LD_LIBRARY_PATH that contain the given lib + All real existing folders from ldconfig or $LD_LIBRARY_PATH that contain the given lib """ paths: set[str] = set() ldconfig = which("ldconfig") @@ -65,19 +64,18 @@ def _check_dynamic_linker(lib: str) -> list[str]: def _files_from_folder(folder: str, prefix: str) -> list[str]: - """ Obtain all filenames from the given folder that start with the given prefix + """Obtain all filenames from the given folder that start with the given prefix Parameters ---------- - folder : str + folder The folder to search for files in - prefix : str + prefix The filename prefix to search for Returns ------- - list[str] - All filenames that exist in the given folder with the given prefic + All filenames that exist in the given folder with the given prefix """ if not os.path.exists(folder): return [] @@ -85,11 +83,11 @@ def _files_from_folder(folder: str, prefix: str) -> list[str]: class _Alternatives: - """ Holds output from the update-alternatives command for the given package + """Holds output from the update-alternatives command for the given package Parameters ---------- - package : str + package The package to query update-alternatives for information """ def __init__(self, package: str) -> None: @@ -101,7 +99,7 @@ def __init__(self, package: str) -> None: @property def alternatives(self) -> list[str]: - """ list[str] : Full path to alternatives listed for the given package """ + """Full path to alternatives listed for the given package""" if self._output is None: self._query() if not self._output: @@ -113,7 +111,7 @@ def alternatives(self) -> list[str]: @property def default(self) -> str: - """ str : Full path to the default package """ + """Full path to the default package""" if self._output is None: self._query() if not self._output: @@ -125,8 +123,8 @@ def default(self) -> str: return retval def _query(self) -> None: - """ Query update-alternatives for the given package and place stripped output into - :attr:`_output` """ + """Query update-alternatives for the given package and place stripped output into + :attr:`_output`""" if not self._bin: self._output = [] return @@ -138,17 +136,17 @@ def _query(self) -> None: class _Cuda(ABC): - """ Find the location of system installed Cuda and cuDNN on Windows and Linux. """ + """Find the location of system installed Cuda and cuDNN on Windows and Linux.""" def __init__(self) -> None: self.versions: list[tuple[int, int]] = [] - """ list[tuple[int, int]] : All detected globally installed Cuda versions """ + """All detected globally installed Cuda versions""" self.version: tuple[int, int] = (0, 0) - """ tuple[int, int] : Default installed Cuda version. (0, 0) if not detected """ + """Default installed Cuda version. (0, 0) if not detected""" self.cudnn_versions: dict[tuple[int, int], tuple[int, int, int]] = {} - """ dict[tuple[int, int], tuple[int, int, int]] : Detected cuDNN version for each installed - Cuda. key (0, 0) denotes globally installed cudnn """ + """Detected cuDNN version for each installed Cuda. key (0, 0) denotes globally installed + cudnn""" self._paths: list[str] = [] - """ list[str] : list of path to Cuda install folders relating to :attr:`versions` """ + """list of path to Cuda install folders relating to :attr:`versions`""" self._version_file = "version.json" self._lib = "libcudart.so" @@ -162,24 +160,23 @@ def __init__(self) -> None: self._get_cudnn_versions() def __repr__(self) -> str: - """ Pretty representation of this class """ + """Pretty representation of this class""" attrs = ", ".join(f"{k}={repr(v)}" for k, v in self.__dict__.items() if not k.startswith("_")) return f"{self.__class__.__name__}({attrs})" @classmethod def _tuple_from_string(cls, version: str) -> tuple[int, int] | None: - """ Convert a Cuda version string to a version tuple + """Convert a Cuda version string to a version tuple Parameters ---------- - version : str + version The Cuda version string to convert Returns ------- - tuple[int, int] | None - The converted Cuda version string. ``None`` if not a valid version string + The converted Cuda version string. ``None`` if not a valid version string """ if version.startswith("."): version = version[1:] @@ -193,47 +190,42 @@ def _tuple_from_string(cls, version: str) -> tuple[int, int] | None: @abstractmethod def get_versions(self) -> dict[tuple[int, int], str]: - """ Overide to Attempt to detect all installed Cuda versions on Linux or Windows systems + """Override to Attempt to detect all installed Cuda versions on Linux or Windows systems Returns ------- - dict[tuple[int, int], str] - The Cuda versions to the folder path on the system + The Cuda versions to the folder path on the system """ @abstractmethod def get_version(self) -> tuple[int, int] | None: - """ Override to attempt to locate the default Cuda version on Linux or Windows + """Override to attempt to locate the default Cuda version on Linux or Windows Returns ------- - tuple[int, int] | None - The Default global Cuda version or ``None`` if not found + The Default global Cuda version or ``None`` if not found """ @abstractmethod def get_cudnn_versions(self) -> dict[tuple[int, int], tuple[int, int, int]]: - """ Override to attempt to locate any installed cuDNN versions + """Override to attempt to locate any installed cuDNN versions Returns ------- - dict[tuple[int, int], tuple[int, int, int]] - Detected cuDNN version for each installed Cuda. key (0, 0) denotes globally installed - cudnn + Detected cuDNN version for each installed Cuda. key (0, 0) denotes globally installed cudnn """ def version_from_version_file(self, folder: str) -> tuple[int, int] | None: - """ Attempt to get an installed Cuda version from its version.json file + """Attempt to get an installed Cuda version from its version.json file Parameters ---------- - folder : str + folder Full path to the folder to check for a version file Returns ------- - tuple[int, int] | None - The detected Cuda version or ``None`` if not detected + The detected Cuda version or ``None`` if not detected """ vers_file = os.path.join(folder, self._version_file) if not os.path.exists(vers_file): @@ -245,12 +237,11 @@ def version_from_version_file(self, folder: str) -> tuple[int, int] | None: return retval def _version_from_nvcc(self) -> tuple[int, int] | None: - """ Obtain the version from NVCC output if it is on PATH + """Obtain the version from NVCC output if it is on PATH Returns ------- - tuple[int, int] | None - The detected default Cuda version. ``None`` if not version detected + The detected default Cuda version. ``None`` if not version detected """ retval = None nvcc = which("nvcc") @@ -266,7 +257,7 @@ def _version_from_nvcc(self) -> tuple[int, int] | None: return retval def _get_versions(self) -> None: - """ Attempt to detect all installed Cuda versions and populate to :attr:`versions` """ + """Attempt to detect all installed Cuda versions and populate to :attr:`versions`""" versions = self.get_versions() if versions: logger.debug("Cuda Versions: %s", versions) @@ -276,7 +267,7 @@ def _get_versions(self) -> None: logger.debug("Could not locate any Cuda versions") def _get_version(self) -> None: - """ Attempt to detect the default Cuda version and populate to :attr:`version` """ + """Attempt to detect the default Cuda version and populate to :attr:`version`""" version: tuple[int, int] | None = None if len(self.versions) == 1: version = self.versions[0] @@ -290,7 +281,7 @@ def _get_version(self) -> None: logger.debug("Cuda version: %s", self.version if version else "not detected") def _get_cudnn_versions(self) -> None: - """ Attempt to locate any installed cuDNN versions and add to :attr`cudnn_versions` """ + """Attempt to locate any installed cuDNN versions and add to :attr`cudnn_versions`""" versions = self.get_cudnn_versions() if versions: logger.debug("cudnn versions: %s", versions) @@ -299,17 +290,16 @@ def _get_cudnn_versions(self) -> None: logger.debug("No cudnn versions found") def cudnn_version_from_header(self, folder: str) -> tuple[int, int, int] | None: - """ Attempt to detect the cuDNN version from the version header file + """Attempt to detect the cuDNN version from the version header file Parameters ---------- - folder : str + folder The folder to check for the cuDNN header file Returns ------- - tuple[int, int, int] | None - The cuDNN version found from the given folder or ``None`` if not detected + The cuDNN version found from the given folder or ``None`` if not detected """ path = os.path.join(folder, self._cudnn_header) if not os.path.exists(path): @@ -331,25 +321,24 @@ def cudnn_version_from_header(self, folder: str) -> tuple[int, int, int] | None: class CudaLinux(_Cuda): - """ Find the location of system installed Cuda and cuDNN on Linux. """ + """Find the location of system installed Cuda and cuDNN on Linux.""" def __init__(self) -> None: self._folder_prefix = "cuda-" super().__init__() def _version_from_lib(self, folder: str) -> tuple[int, int] | None: - """ Attempt to locate the version from the existence of libcudart.so within a Cuda + """Attempt to locate the version from the existence of libcudart.so within a Cuda targets/x86_64-linux/lib folder Parameters ---------- - folder : str + folder Full file path to the Cuda folder Returns ------- - tuple[int, int] | None - The Cuda version identified by the existence of the libcudart.so file. ``None`` if - not detected + The Cuda version identified by the existence of the libcudart.so file. ``None`` if not + detected """ lib_folder = os.path.join(folder, "targets", "x86_64-linux", "lib") lib_versions = [f.replace(self._lib, "") @@ -366,15 +355,14 @@ def _version_from_lib(self, folder: str) -> tuple[int, int] | None: return retval def _versions_from_usr(self) -> dict[tuple[int, int], str]: - """ Attempt to detect all installed Cuda versions from the /usr/local folder + """Attempt to detect all installed Cuda versions from the /usr/local folder Scan /usr/local for cuda-x.x folders containing either a version.json file or include/lib/libcudart.so.x. Returns ------- - dict[tuple[int, int], str] - A dictionary of detected Cuda versions to their install paths + A dictionary of detected Cuda versions to their install paths """ retval: dict[tuple[int, int], str] = {} usr = os.path.join(os.sep, "usr", "local") @@ -389,13 +377,11 @@ def _versions_from_usr(self) -> dict[tuple[int, int], str]: return retval def _versions_from_alternatives(self) -> dict[tuple[int, int], str]: - """ Attempt to detect all installed Cuda versions from update-alternatives + """Attempt to detect all installed Cuda versions from update-alternatives Returns ------- - list[tuple[int, int, int]] - A dictionary of detected Cuda versions to their install paths found in - update-alternatives + A dictionary of detected Cuda versions to their install paths found in update-alternatives """ retval: dict[tuple[int, int], str] = {} alts = self._alternatives.alternatives @@ -407,28 +393,26 @@ def _versions_from_alternatives(self) -> dict[tuple[int, int], str]: return retval def _parent_from_targets(self, folder: str) -> str: - """ Obtain the Cuda parent folder from a path obtained from child targets folder + """Obtain the Cuda parent folder from a path obtained from child targets folder Parameters ---------- - folder : str + folder Full path to a folder that has a 'targets' folder in its path Returns ------- - str - The potential parent Cuda folder, or an empty string if not detected + The potential parent Cuda folder, or an empty string if not detected """ split = folder.split(os.sep) return os.sep.join(split[:split.index("targets")]) if "targets" in split else "" def _versions_from_dynamic_linker(self) -> dict[tuple[int, int], str]: - """ Attempt to detect all installed Cuda versions from ldconfig + """Attempt to detect all installed Cuda versions from ldconfig Returns ------- - dict[tuple[int, int], str] - The Cuda version to the folder path found from ldconfig + The Cuda version to the folder path found from ldconfig """ retval: dict[tuple[int, int], str] = {} folders = _check_dynamic_linker(self._lib) @@ -444,12 +428,11 @@ def _versions_from_dynamic_linker(self) -> dict[tuple[int, int], str]: return retval def get_versions(self) -> dict[tuple[int, int], str]: - """ Attempt to detect all installed Cuda versions on Linux systems + """Attempt to detect all installed Cuda versions on Linux systems Returns ------- - dict[tuple[int, int], str] - The Cuda version to the folder path on Linux + The Cuda version to the folder path on Linux """ versions = (self._versions_from_usr() | self._versions_from_alternatives() | @@ -457,12 +440,11 @@ def get_versions(self) -> dict[tuple[int, int], str]: return {k: versions[k] for k in sorted(versions)} def _version_from_alternatives(self) -> tuple[int, int] | None: - """ Attempt to get the default Cuda version from update-alternatives + """Attempt to get the default Cuda version from update-alternatives Returns ------- - tuple[int, int] | None - The detected default Cuda version. ``None`` if not version detected + The detected default Cuda version. ``None`` if not version detected """ default = self._alternatives.default if not default: @@ -472,12 +454,11 @@ def _version_from_alternatives(self) -> tuple[int, int] | None: return retval def _version_from_link(self) -> tuple[int, int] | None: - """ Attempt to get the default Cuda version from the /usr/local/cuda file + """Attempt to get the default Cuda version from the /usr/local/cuda file Returns ------- - tuple[int, int] | None - The detected default Cuda version. ``None`` if not version detected + The detected default Cuda version. ``None`` if not version detected """ path = os.path.join(os.sep, "usr", "local", "cuda") if not os.path.exists(path): @@ -488,12 +469,11 @@ def _version_from_link(self) -> tuple[int, int] | None: return retval def _version_from_dynamic_linker(self) -> tuple[int, int] | None: - """ Attempt to get the default version from ldconfig or $LD_LIBRARY_PATH + """Attempt to get the default version from ldconfig or $LD_LIBRARY_PATH Returns ------- - tuple[int, int, int] | None - The detected default ROCm version. ``None`` if not version detected + The detected default ROCm version. ``None`` if not version detected """ paths = _check_dynamic_linker(self._lib) if len(paths) != 1: # Multiple or None @@ -504,27 +484,24 @@ def _version_from_dynamic_linker(self) -> tuple[int, int] | None: return retval def get_version(self) -> tuple[int, int] | None: - """ Attempt to locate the default Cuda version on Linux + """Attempt to locate the default Cuda version on Linux Checks, in order: update-alternatives, /usr/local/cuda, ldconfig, nvcc Returns ------- - tuple[int, int] | None - The Default global Cuda version or ``None`` if not found + The Default global Cuda version or ``None`` if not found """ return (self._version_from_alternatives() or self._version_from_link() or self._version_from_dynamic_linker()) def get_cudnn_versions(self) -> dict[tuple[int, int], tuple[int, int, int]]: - """ Attempt to locate any installed cuDNN versions on Linux + """Attempt to locate any installed cuDNN versions on Linux Returns ------- - dict[tuple[int, int], tuple[int, int, int]] - Detected cuDNN version for each installed Cuda. key (0, 0) denotes globally installed - cudnn + Detected cuDNN version for each installed Cuda. key (0, 0) denotes globally installed cudnn """ retval: dict[tuple[int, int], tuple[int, int, int]] = {} gbl = ["/usr/include", "/usr/local/include"] @@ -543,21 +520,20 @@ def get_cudnn_versions(self) -> dict[tuple[int, int], tuple[int, int, int]]: class CudaWindows(_Cuda): - """ Find the location of system installed Cuda and cuDNN on Windows. """ + """Find the location of system installed Cuda and cuDNN on Windows.""" @classmethod - def _enum_subkeys(cls, key: HKEYType) -> T.Generator[str, None, None]: - """ Iterate through a Registry key's sub-keys + def _enum_sub_keys(cls, key: HKEYType) -> T.Generator[str, None, None]: + """Iterate through a Registry key's sub-keys Parameters ---------- - key : :class:`winreg.HKEYType` + key The Registry key to iterate Yields ------ - str - A sub-key name from the given registry key + A sub-key name from the given registry key """ assert winreg is not None i = 0 @@ -569,12 +545,11 @@ def _enum_subkeys(cls, key: HKEYType) -> T.Generator[str, None, None]: i += 1 def get_versions(self) -> dict[tuple[int, int], str]: - """ Attempt to detect all installed Cuda versions on Windows systems from the registry + """Attempt to detect all installed Cuda versions on Windows systems from the registry Returns ------- - dict[tuple[int, int], str] - The Cuda version to the folder path on Windows + The Cuda version to the folder path on Windows """ retval: dict[tuple[int, int], str] = {} assert winreg is not None @@ -585,7 +560,7 @@ def get_versions(self) -> dict[tuple[int, int], str]: try: with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, # type:ignore[attr-defined] reg_key) as key: - for version in self._enum_subkeys(key): + for version in self._enum_sub_keys(key): vers_tuple = self._tuple_from_string(version[1:]) if vers_tuple is not None: retval[vers_tuple] = paths.get(version, "") @@ -594,12 +569,11 @@ def get_versions(self) -> dict[tuple[int, int], str]: return {k: retval[k] for k in sorted(retval)} def get_version(self) -> tuple[int, int] | None: - """ Attempt to get the default Cuda version from the Environment Variable + """Attempt to get the default Cuda version from the Environment Variable Returns ------- - tuple[int, int] | None - The Default global Cuda version or ``None`` if not found + The Default global Cuda version or ``None`` if not found """ path = os.environ.get("CUDA_PATH") if not path or path not in self._paths: @@ -610,12 +584,11 @@ def get_version(self) -> tuple[int, int] | None: return retval def _get_cudnn_paths(self) -> list[str]: # noqa[C901] - """ Attempt to locate the locations of cuDNN installs for Windows + """Attempt to locate the locations of cuDNN installs for Windows Returns ------- - list[str] - Full path to existing cuDNN installs under Windows + Full path to existing cuDNN installs under Windows """ assert winreg is not None paths: set[str] = set() @@ -628,24 +601,24 @@ def _get_cudnn_paths(self) -> list[str]: # noqa[C901] key = winreg.OpenKey(lookup, reg_key) # type:ignore[attr-defined] except FileNotFoundError: continue - for name in self._enum_subkeys(key): + for name in self._enum_sub_keys(key): if cudnn_key not in name.lower(): - logger.debug("Skipping subkey '%s'", name) + logger.debug("Skipping sub_keys '%s'", name) continue try: - subkey = winreg.OpenKey(key, name) # type:ignore[attr-defined] - logger.debug("Skipping subkey not found '%s'", name) + sub_keys = winreg.OpenKey(key, name) # type:ignore[attr-defined] + logger.debug("Skipping sub_keys not found '%s'", name) except FileNotFoundError: continue logger.debug("Parsing cudnn key '%s'", cudnn_key) try: - path, _ = winreg.QueryValueEx(subkey, # type:ignore[attr-defined] + path, _ = winreg.QueryValueEx(sub_keys, # type:ignore[attr-defined] "InstallLocation") except (FileNotFoundError, OSError): - logger.debug("Skipping missing InstallLocation for sub-key '%s'", subkey) + logger.debug("Skipping missing InstallLocation for sub-key '%s'", sub_keys) continue if not os.path.isdir(path): - logger.debug("Skipping non-existant path '%s'", path) + logger.debug("Skipping non-existent path '%s'", path) continue paths.add(path) retval = list(paths) @@ -653,13 +626,11 @@ def _get_cudnn_paths(self) -> list[str]: # noqa[C901] return retval def get_cudnn_versions(self) -> dict[tuple[int, int], tuple[int, int, int]]: - """ Attempt to locate any installed cuDNN versions on Windows + """Attempt to locate any installed cuDNN versions on Windows Returns ------- - dict[tuple[int, int], tuple[int, int, int]] - Detected cuDNN version for each installed Cuda. key (0, 0) denotes globally installed - cudnn + Detected cuDNN version for each installed Cuda. key (0, 0) denotes globally installed cudnn """ retval: dict[tuple[int, int], tuple[int, int, int]] = {} gbl = self._get_cudnn_paths() @@ -682,8 +653,7 @@ def get_cuda_finder() -> type[_Cuda]: Returns ------- - type[_Cuda] - The OS specific finder for system-wide Cuda + The OS specific finder for system-wide Cuda """ if platform.system().lower() == "windows": return CudaWindows @@ -694,14 +664,14 @@ def get_cuda_finder() -> type[_Cuda]: class ROCm(): - """ Find the location of system installed ROCm on Linux """ + """Find the location of system installed ROCm on Linux""" def __init__(self) -> None: self.version_min = min(v[0] for v in _TORCH_ROCM_REQUIREMENTS.values()) self.version_max = max(v[1] for v in _TORCH_ROCM_REQUIREMENTS.values()) self.versions: list[tuple[int, int, int]] = [] - """ list[tuple[int, int, int]] : All detected ROCm installed versions """ + """All detected ROCm installed versions""" self.version: tuple[int, int, int] = (0, 0, 0) - """ tuple[int, int, int] : Default ROCm installed version. (0, 0, 0) if not detected """ + """Default ROCm installed version. (0, 0, 0) if not detected""" self._folder_prefix = "rocm-" self._version_files = ["version-rocm", "version"] @@ -713,39 +683,38 @@ def __init__(self) -> None: self._rocm_check() def __repr__(self) -> str: - """ Pretty representation of this class """ + """Pretty representation of this class""" attrs = ", ".join(f"{k}={repr(v)}" for k, v in self.__dict__.items() if not k.startswith("_")) return f"{self.__class__.__name__}({attrs})" @property def valid_versions(self) -> list[tuple[int, int, int]]: - """ list[tuple[int, int, int]] """ + """Valid ROCm versions""" return [v for v in self.versions if self.version_min <= v[:2] <= self.version_max] @property def valid_installed(self) -> bool: - """ bool : ``True`` if a valid version of ROCm is installed """ + """``True`` if a valid version of ROCm is installed""" return any(self.valid_versions) @property def is_valid(self): - """ bool : ``True`` if the default ROCm version is valid """ + """``True`` if the default ROCm version is valid""" return self.version_min <= self.version[:2] <= self.version_max @classmethod def _tuple_from_string(cls, version: str) -> tuple[int, int, int] | None: - """ Convert a ROCm version string to a version tuple + """Convert a ROCm version string to a version tuple Parameters ---------- - version : str + version The ROCm version string to convert Returns ------- - tuple[int, int, int] | None - The converted ROCm version string. ``None`` if not a valid version string + The converted ROCm version string. ``None`` if not a valid version string """ split = version.split(".") if len(split) != 3: @@ -755,17 +724,16 @@ def _tuple_from_string(cls, version: str) -> tuple[int, int, int] | None: return (int(split[0]), int(split[1]), int(split[2])) def _version_from_string(self, string: str) -> tuple[int, int, int] | None: - """ Obtain the ROCm version from the end of a string + """Obtain the ROCm version from the end of a string Parameters ---------- - string : str + string The string to test for a valid ROCm version Returns ------- - tuple[int, int, int] | None - The ROCm version from the end of the string or ``None`` if not detected + The ROCm version from the end of the string or ``None`` if not detected """ re_vers = self._re_version.search(string) if re_vers is None: @@ -773,18 +741,17 @@ def _version_from_string(self, string: str) -> tuple[int, int, int] | None: return self._tuple_from_string(re_vers.group(1)) def _version_from_info(self, folder: str) -> tuple[int, int, int] | None: - """ Attempt to locate the version from a version file within a ROCm .info folder + """Attempt to locate the version from a version file within a ROCm .info folder Parameters ---------- - file_path : str + file_path Full path to the ROCm .info folder Returns ------- - tuple[int, int, int] | None - The ROCm version extracted from a version file within the .info folder. ``None`` if - not detected + The ROCm version extracted from a version file within the .info folder. ``None`` if not + detected """ info_loc = [os.path.join(folder, ".info", v) for v in self._version_files] for info_file in info_loc: @@ -802,19 +769,18 @@ def _version_from_info(self, folder: str) -> tuple[int, int, int] | None: return None def _version_from_lib(self, folder: str) -> tuple[int, int, int] | None: - """ Attempt to locate the version from the existence of librocm-core.so within a ROCm + """Attempt to locate the version from the existence of librocm-core.so within a ROCm lib folder Parameters ---------- - folder : str + folder Full file path to the ROCm folder Returns ------- - tuple[int, int, int] | None - The ROCm version identified by the existence of the librocm-core.so file. ``None`` if - not detected + The ROCm version identified by the existence of the librocm-core.so file. ``None`` if not + detected """ lib_folder = os.path.join(folder, "lib") lib_files = _files_from_folder(lib_folder, self._lib) @@ -830,14 +796,13 @@ def _version_from_lib(self, folder: str) -> tuple[int, int, int] | None: return retval def _versions_from_opt(self) -> list[tuple[int, int, int]]: - """ Attempt to detect all installed ROCm versions from the /opt folder + """Attempt to detect all installed ROCm versions from the /opt folder Scan /opt for rocm.x.x.x folders containing either .info or lib/librocm-core.so.x Returns ------- - list[tuple[int, int, int]] - Any ROCm versions found in the /opt folder + Any ROCm versions found in the /opt folder """ retval: list[tuple[int, int, int]] = [] opt = os.path.join(os.sep, "opt") @@ -851,12 +816,11 @@ def _versions_from_opt(self) -> list[tuple[int, int, int]]: return retval def _versions_from_alternatives(self) -> list[tuple[int, int, int]]: - """ Attempt to detect all installed ROCm versions from update-alternatives + """Attempt to detect all installed ROCm versions from update-alternatives Returns ------- - list[tuple[int, int, int]] - Any ROCm versions found in update-alternatives + Any ROCm versions found in update-alternatives """ alts = self._alternatives.alternatives if not alts: @@ -867,12 +831,11 @@ def _versions_from_alternatives(self) -> list[tuple[int, int, int]]: return retval def _versions_from_dynamic_linker(self) -> list[tuple[int, int, int]]: - """ Attempt to detect all installed ROCm versions from ldconfig + """Attempt to detect all installed ROCm versions from ldconfig Returns ------- - dict[tuple[int, int], str] - The ROCm versions found from ldconfig + The ROCm versions found from ldconfig """ retval: list[tuple[int, int, int]] = [] folders = _check_dynamic_linker(self._lib) @@ -886,7 +849,7 @@ def _versions_from_dynamic_linker(self) -> list[tuple[int, int, int]]: return retval def _get_versions(self) -> None: - """ Attempt to detect all installed ROCm versions and populate to :attr:`rocm_versions` """ + """Attempt to detect all installed ROCm versions and populate to :attr:`rocm_versions`""" versions = list(sorted(set(self._versions_from_opt()) | set(self._versions_from_alternatives()) | set(self._versions_from_dynamic_linker()))) @@ -897,12 +860,11 @@ def _get_versions(self) -> None: logger.debug("Could not locate any ROCm versions") def _version_from_hipconfig(self) -> tuple[int, int, int] | None: - """ Attempt to get the default version from hipconfig + """Attempt to get the default version from hipconfig Returns ------- - tuple[int, int, int] | None - The detected default ROCm version. ``None`` if not version detected + The detected default ROCm version. ``None`` if not version detected """ retval: tuple[int, int, int] | None = None exe = which("hipconfig") @@ -925,12 +887,11 @@ def _version_from_hipconfig(self) -> tuple[int, int, int] | None: return retval def _version_from_alternatives(self) -> tuple[int, int, int] | None: - """ Attempt to get the default version from update-alternatives + """Attempt to get the default version from update-alternatives Returns ------- - tuple[int, int, int] | None - The detected default ROCm version. ``None`` if not version detected + The detected default ROCm version. ``None`` if not version detected """ default = self._alternatives.default if not default: @@ -940,12 +901,11 @@ def _version_from_alternatives(self) -> tuple[int, int, int] | None: return retval def _version_from_link(self) -> tuple[int, int, int] | None: - """ Attempt to get the default version from the /opt/rocm file + """Attempt to get the default version from the /opt/rocm file Returns ------- - tuple[int, int, int] | None - The detected default ROCm version. ``None`` if not version detected + The detected default ROCm version. ``None`` if not version detected """ path = os.path.join(os.sep, "opt", "rocm") if not os.path.exists(path): @@ -956,12 +916,11 @@ def _version_from_link(self) -> tuple[int, int, int] | None: return retval def _version_from_dynamic_linker(self) -> tuple[int, int, int] | None: - """ Attempt to get the default version from ldconfig or $LD_LIBRARY_PATH + """Attempt to get the default version from ldconfig or $LD_LIBRARY_PATH Returns ------- - tuple[int, int, int] | None - The detected default ROCm version. ``None`` if not version detected + The detected default ROCm version. ``None`` if not version detected """ paths = _check_dynamic_linker("librocm-core.so.") if len(paths) != 1: # Multiple or None @@ -972,7 +931,7 @@ def _version_from_dynamic_linker(self) -> tuple[int, int, int] | None: return retval def _get_version(self) -> None: - """ Attempt to detect the default ROCm version """ + """Attempt to detect the default ROCm version""" version = (self._version_from_hipconfig() or self._version_from_alternatives() or self._version_from_link() or @@ -984,7 +943,7 @@ def _get_version(self) -> None: logger.debug("Could not locate default ROCm version") def _rocm_check(self) -> None: - """ Attempt to locate the installed ROCm versions and the default ROCm version """ + """Attempt to locate the installed ROCm versions and the default ROCm version""" self._get_versions() self._get_version() logger.debug("ROCm Versions: %s, Version: %s", self.versions, self.version) diff --git a/lib/system/system.py b/lib/system/system.py index a69ee09a29..9aa8b463c1 100644 --- a/lib/system/system.py +++ b/lib/system/system.py @@ -1,6 +1,5 @@ #! /usr/env/bin/python3 -""" -Holds information about the running system. Used in setup.py and lib.sysinfo +"""Holds information about the running system. Used in setup.py and lib.sysinfo NOTE: Only packages from Python's Standard Library should be imported in this module """ from __future__ import annotations @@ -23,28 +22,24 @@ VALID_PYTHON = ((3, 11), (3, 13)) -""" tuple[tuple[int, int], tuple[int, int]] : The minimum and maximum versions of Python that can -run Faceswap """ -VALID_TORCH = ((2, 3), (2, 11)) -""" tuple[tuple[int, int], tuple[int, int]] : The minimum and maximum versions of Torch that can -run Faceswap """ -VALID_KERAS = ((3, 13), (3, 14)) -""" tuple[tuple[int, int], tuple[int, int]] : The minimum and maximum versions of Keras that can -run Faceswap """ +"""The minimum and maximum versions of Python that can run Faceswap""" +VALID_TORCH = ((2, 3), (2, 12)) +"""The minimum and maximum versions of Torch that can run Faceswap""" +VALID_KERAS = ((3, 14), (3, 14)) +"""The minimum and maximum versions of Keras that can run Faceswap""" def _lines_from_command(command: list[str]) -> list[str]: - """ Output stdout lines from an executed command. + """Output stdout lines from an executed command. Parameters ---------- - command : list[str] + command The command to run Returns ------- - list[str] - The output lines from the given command + The output lines from the given command """ logger.debug("Running command %s", command) try: @@ -60,65 +55,64 @@ def _lines_from_command(command: list[str]) -> list[str]: class System: # pylint:disable=too-many-instance-attributes - """ Holds information about the currently running system and environment """ + """Holds information about the currently running system and environment""" def __init__(self) -> None: self.platform = platform.platform() - """ str : Human readable platform identifier """ + """Human readable platform identifier""" self.system: T.Literal["darwin", "linux", "windows"] = T.cast( T.Literal["darwin", "linux", "windows"], platform.system().lower()) - """ str : The system (OS type) that this code is running on. Always lowercase """ + """The system (OS type) that this code is running on. Always lowercase""" self.machine = platform.machine() - """ str : The machine type (eg: "x86_64") """ + """The machine type (eg: "x86_64")""" self.release = platform.release() - """ str : The OS Release that this code is running on """ + """The OS Release that this code is running on""" self.processor = platform.processor() - """ str : The processor in use, if detected """ + """The processor in use, if detected""" self.cpu_count = os.cpu_count() - """ int : The number of CPU cores on the system """ + """The number of CPU cores on the system""" self.python_implementation = platform.python_implementation() - """ str : The python implementation in use""" + """The python implementation in use""" self.python_version = platform.python_version() - """ str : The .. version of Python that is running """ + """The .. version of Python that is running""" self.python_architecture = platform.architecture()[0] - """ str : The Python architecture that is running (eg: 64bit/32bit)""" + """The Python architecture that is running (eg: 64bit/32bit)""" self.encoding = locale.getpreferredencoding() - """ str : The system encoding """ + """The system encoding""" self.is_conda = ("conda" in sys.version.lower() or os.path.exists(os.path.join(sys.prefix, 'conda-meta'))) - """ bool : ``True`` if running under Conda otherwise ``False`` """ + """``True`` if running under Conda otherwise ``False``""" self.is_admin = self._get_permissions() - """ bool : ``True`` if we are running with Admin privileges """ + """``True`` if we are running with Admin privileges""" self.is_virtual_env = self._check_virtual_env() - """ bool : ``True`` if Python is being run inside a virtual environment """ + """``True`` if Python is being run inside a virtual environment""" @property def is_linux(self) -> bool: - """ bool : `True` if running on a Linux system otherwise ``False``. """ + """``True`` if running on a Linux system otherwise ``False``.""" return self.system == "linux" @property def is_macos(self) -> bool: - """ bool : `True` if running on a macOS system otherwise ``False``. """ + """``True`` if running on a macOS system otherwise ``False``.""" return self.system == "darwin" @property def is_windows(self) -> bool: - """ bool : `True` if running on a Windows system otherwise ``False``. """ + """``True`` if running on a Windows system otherwise ``False``.""" return self.system == "windows" def __repr__(self) -> str: - """ Pretty print the system information for logging """ + """Pretty print the system information for logging""" attrs = ", ".join(f"{k}={repr(v)}" for k, v in self.__dict__.items() if not k.startswith("_")) return f"{self.__class__.__name__}({attrs})" def _get_permissions(self) -> bool: - """ Check whether user is admin + """Check whether user is admin Returns ------- - bool - ``True`` if we are running with Admin privileges + ``True`` if we are running with Admin privileges """ if self.is_windows: retval = ctypes.windll.shell32.IsUserAnAdmin() != 0 # type:ignore[attr-defined] @@ -127,12 +121,11 @@ def _get_permissions(self) -> bool: return retval def _check_virtual_env(self) -> bool: - """ Check whether we are in a virtual environment + """Check whether we are in a virtual environment Returns ------- - bool - ``True`` if Python is being run inside a virtual environment + ``True`` if Python is being run inside a virtual environment """ if not self.is_conda: retval = (hasattr(sys, "real_prefix") or @@ -143,18 +136,17 @@ def _check_virtual_env(self) -> bool: return retval def validate_python(self, max_version: tuple[int, int] | None = None) -> bool: - """ Check that the running Python version is valid + """Check that the running Python version is valid Parameters ---------- - max_version: tuple[int, int] | None, Optional + max_version The max version to validate Python against. ``None`` for the project Maximum. Default: ``None`` (project maximum) Returns ------- - bool - ``True`` if the running Python version is valid, otherwise logs an error and exits + ``True`` if the running Python version is valid, otherwise logs an error and exits """ max_python = VALID_PYTHON[1] if max_version is None else max_version retval = (VALID_PYTHON[0] <= sys.version_info[:2] <= max_python @@ -186,8 +178,8 @@ def validate_python(self, max_version: tuple[int, int] | None = None) -> bool: return retval def validate(self) -> None: - """ Perform validation that the running system can be used for faceswap. Log an error and - exit if it cannot """ + """Perform validation that the running system can be used for faceswap. Log an error and + exit if it cannot""" if not any((self.is_linux, self.is_macos, self.is_windows)): logger.error("Your system %s is not supported!", self.system.title()) sys.exit(1) @@ -199,7 +191,7 @@ def validate(self) -> None: class Packages(): - """ Holds information about installed python and conda packages. + """Holds information about installed python and conda packages. Note: Packaging library is lazy loaded as it may not be available during setup.py """ @@ -211,20 +203,19 @@ def __init__(self) -> None: @property def installed_python(self) -> dict[str, str]: - """ dict[str, str] : Installed Python package names to Python package versions """ + """Installed Python package names to Python package versions""" return self._installed_python @property def installed_python_pretty(self) -> str: - """ str: A pretty printed representation of installed Python packages """ + """A pretty printed representation of installed Python packages""" pkgs = self._installed_python align = max(len(x) for x in pkgs) + 1 return "\n".join(f"{k.ljust(align)} {v}" for k, v in pkgs.items()) @property def installed_conda(self) -> dict[str, tuple[str, str, str]]: - """ dict[str, tuple[str, str]] : Installed Conda package names to the version and - channel """ + """Installed Conda package names to the version and channel""" if not self._installed_conda: return {} @@ -239,13 +230,13 @@ def installed_conda(self) -> dict[str, tuple[str, str, str]]: @property def installed_conda_pretty(self) -> str: - """ str: A pretty printed representation of installed conda packages """ + """A pretty printed representation of installed conda packages""" if not self._installed_conda: return "Could not get Conda package list" return "\n".join(self._installed_conda) def __repr__(self) -> str: - """ Pretty print the installed packages for logging """ + """Pretty print the installed packages for logging""" props = ", ".join( f"{k}={repr(getattr(self, k))}" for k, v in self.__class__.__dict__.items() @@ -253,12 +244,11 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}({props})" def _get_installed_python(self) -> dict[str, str]: - """ Parse the installed python modules + """Parse the installed python modules Returns ------- - dict[str, str] - Installed Python package names to Python package versions + Installed Python package names to Python package versions """ installed = _lines_from_command([sys.executable, "-m", "pip", "freeze", "--local"]) retval = {} @@ -271,13 +261,12 @@ def _get_installed_python(self) -> dict[str, str]: return retval def _get_installed_conda(self) -> None: - """ Collect the output from 'conda list' for the installed Conda packages and + """Collect the output from 'conda list' for the installed Conda packages and populate :attr:`_installed_conda` Returns ------- - list[str] - Each line of output from the 'conda list' command + Each line of output from the 'conda list' command """ if not self._conda_exe: logger.debug("Conda not found. Not collecting packages") diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index e8d372487d..940c0425e2 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -12,6 +12,6 @@ av>=17.0 ffmpeg-binaries>=1.1 ffmpy>=1.0.0 pywin32>=305 ; sys_platform == "win32" -torchvision>=0.18.0,<0.27.0 +torchvision>=0.18.0,<0.28.0 tensorboard>=2.20.0 -keras>=3.13.0,<3.14.0 +keras>=3.14.1,<3.15.0 diff --git a/requirements/requirements_apple-silicon.txt b/requirements/requirements_apple-silicon.txt index 48599420c0..127b9a498a 100644 --- a/requirements/requirements_apple-silicon.txt +++ b/requirements/requirements_apple-silicon.txt @@ -2,4 +2,4 @@ # These next 2 should have been installed, but some users complain of errors decorator cloudpickle -torch>=2.3.0,<2.10.0 +torch>=2.3.0,<2.13.0 diff --git a/requirements/requirements_cpu.txt b/requirements/requirements_cpu.txt index e3567a428b..be63be48b3 100644 --- a/requirements/requirements_cpu.txt +++ b/requirements/requirements_cpu.txt @@ -1,3 +1,3 @@ -r _requirements_base.txt --extra-index-url https://download.pytorch.org/whl/cpu -torch>=2.3.0,<2.10.0 +torch>=2.3.0,<2.13.0 diff --git a/requirements/requirements_nvidia_12.txt b/requirements/requirements_nvidia_12.txt index 153335e50a..dcc9a120e6 100644 --- a/requirements/requirements_nvidia_12.txt +++ b/requirements/requirements_nvidia_12.txt @@ -4,4 +4,4 @@ # Exclude badly numbered Python2 version of nvidia-ml-py nvidia-ml-py>=12.535,<300 --extra-index-url https://download.pytorch.org/whl/cu126 -torch>=2.7.0,<2.12.0 +torch>=2.7.0,<2.13.0 diff --git a/requirements/requirements_nvidia_13.txt b/requirements/requirements_nvidia_13.txt index 5f1891cb40..a192c07a6c 100644 --- a/requirements/requirements_nvidia_13.txt +++ b/requirements/requirements_nvidia_13.txt @@ -4,4 +4,4 @@ # Exclude badly numbered Python2 version of nvidia-ml-py nvidia-ml-py>=12.535,<300 --extra-index-url https://download.pytorch.org/whl/cu130 -torch>=2.9.0,<2.12.0 +torch>=2.9.0,<2.13.0 diff --git a/requirements/requirements_rocm.txt b/requirements/requirements_rocm.txt index 76f61581ea..65316d6b98 100644 --- a/requirements/requirements_rocm.txt +++ b/requirements/requirements_rocm.txt @@ -1,2 +1,2 @@ # Meta requirements file for latest ROCm version --r _requirements_rocm_64.txt +-r _requirements_rocm_72.txt diff --git a/requirements/requirements_rocm_71.txt b/requirements/requirements_rocm_71.txt new file mode 100644 index 0000000000..79937d2846 --- /dev/null +++ b/requirements/requirements_rocm_71.txt @@ -0,0 +1,3 @@ +-r _requirements_base.txt +--extra-index-url https://download.pytorch.org/whl/rocm7.1 +torch>=2.10.0,<2.11.0 diff --git a/requirements/requirements_rocm_72.txt b/requirements/requirements_rocm_72.txt new file mode 100644 index 0000000000..505e5b549e --- /dev/null +++ b/requirements/requirements_rocm_72.txt @@ -0,0 +1,3 @@ +-r _requirements_base.txt +--extra-index-url https://download.pytorch.org/whl/rocm7.2 +torch>=2.11.0,<2.13.0 diff --git a/setup.py b/setup.py index e1d332abba..513082431c 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" Install packages for faceswap.py """ +"""Install packages for faceswap.py""" # pylint:disable=too-many-lines from __future__ import annotations @@ -43,17 +43,17 @@ class _InstallState: # pylint:disable=too-few-public-methods - """ Marker to track if a step has failed installing """ + """Marker to track if a step has failed installing""" failed = False messages: list[str] = [] class Environment(): - """ The current install environment + """The current install environment Parameters ---------- - updater : bool, Optional + updater ``True`` if the script is being called by Faceswap's internal updater. ``False`` if full setup is running. Default: ``False`` """ @@ -68,7 +68,6 @@ def __init__(self, updater: bool = False) -> None: self.is_installer: bool = False # Flag setup is being run by installer to skip steps self.include_dev_tools: bool = False self.backend: T.Literal["nvidia", "apple_silicon", "cpu", "rocm"] | None = None - self.enable_docker: bool = False self.cuda_cudnn = ["", ""] self.requirement_version = "" self.rocm_version: tuple[int, ...] = (0, 0, 0) @@ -78,32 +77,32 @@ def __init__(self, updater: bool = False) -> None: @property def cuda_version(self) -> str: - """ str : The detected globally installed Cuda Version """ + """The detected globally installed Cuda Version""" return self.cuda_cudnn[0] @property def cudnn_version(self) -> str: - """ str : The detected globally installed cuDNN Version """ + """The detected globally installed cuDNN Version""" return self.cuda_cudnn[1] def set_backend(self, backend: T.Literal["nvidia", "apple_silicon", "cpu", "rocm"]) -> None: - """ Set the backend to install for + """Set the backend to install for Parameters ---------- - backend : Literal["nvidia", "apple_silicon", "cpu", "rocm"] + backend The backend to setup faceswap for """ logger.debug("Setting backend to '%s'", backend) self.backend = backend def set_requirements(self, requirements: str) -> None: - """ Validate that the requirements are compatible with the running Python version and + """Validate that the requirements are compatible with the running Python version and set the requirements file version to install use Parameters ---------- - backend : str + backend The requirements file version to use for install """ if requirements in PYTHON_VERSIONS: @@ -112,11 +111,11 @@ def set_requirements(self, requirements: str) -> None: self.requirement_version = requirements def _parse_backend_from_cli(self, arg: str) -> None: - """ Parse a command line argument and populate :attr:`backend` if valid + """Parse a command line argument and populate :attr:`backend` if valid Parameters ---------- - arg : str + arg The command line argument to parse """ arg = arg.lower() @@ -145,7 +144,7 @@ def _parse_backend_from_cli(self, arg: str) -> None: self.set_requirements(req_files[lookup.index(arg)]) def _process_arguments(self) -> None: - """ Process any cli arguments and dummy in cli arguments if calling from updater. """ + """Process any cli arguments and dummy in cli arguments if calling from updater.""" args = sys.argv[:] if self.updater: get_backend = T.cast("lib_utils", # type:ignore[attr-defined,valid-type] @@ -166,7 +165,7 @@ def _process_arguments(self) -> None: self._parse_backend_from_cli(arg[2:]) def _output_runtime_info(self) -> None: - """ Output run time info """ + """Output run time info""" logger.info("Setup in %s %s", self.system.system.title(), self.system.release) logger.info("Running as %s", "Root/Admin" if self.system.is_admin else "User") if self.system.is_conda: @@ -176,7 +175,7 @@ def _output_runtime_info(self) -> None: logger.info("Encoding: %s", self.system.encoding) def _check_pip(self) -> None: - """ Check installed pip version """ + """Check installed pip version""" for i in range(2): try: _pip = T.cast("pip", import_module("pip")) # type:ignore[valid-type] @@ -191,7 +190,7 @@ def _check_pip(self) -> None: logger.info("Pip version: %s", _pip.__version__) # type:ignore[attr-defined] def _configure_keras(self) -> None: - """ Set up the keras.json file to use Torch as the backend """ + """Set up the keras.json file to use Torch as the backend""" if "KERAS_HOME" in os.environ: keras_dir = os.environ["KERAS_HOME"] else: @@ -215,7 +214,7 @@ def _configure_keras(self) -> None: logger.info("Keras config written to: %s", conf_file) def set_config(self) -> None: - """ Set the backend in the faceswap config file """ + """Set the backend in the faceswap config file""" config = {"backend": self.backend} py_path = os.path.dirname(os.path.realpath(__file__)) config_file = os.path.join(py_path, "config", ".faceswap") @@ -226,12 +225,12 @@ def set_config(self) -> None: class RequiredPackages(): - """ Holds information about installed and required packages. + """Holds information about installed and required packages. Handles updating dependencies based on running platform/backend Parameters ---------- - environment : :class:`Environment` + environment Environment class holding information about the running system """ def __init__(self, environment: Environment) -> None: @@ -246,16 +245,16 @@ def __init__(self, environment: Environment) -> None: x.strip() for p in self._requirements.global_options[self._env.requirement_version] for x in p.split()] - """ list[str] : Any additional pip arguments that are required for installing from pip for - the given backend """ + """Any additional pip arguments that are required for installing from pip for the given + backend""" @property def packages_need_install(self) -> bool: - """bool : ``True`` if there are packages available that need to be installed """ + """``True`` if there are packages available that need to be installed""" return bool(self.conda or self.python) def _check_packaging(self) -> None: - """ Install packaging if it is not available """ + """Install packaging if it is not available""" if self._requirements.packaging_available: return cmd = [sys.executable, "-u", "-m", "pip", "install", "--no-cache-dir"] @@ -270,17 +269,16 @@ def _check_packaging(self) -> None: def _get_missing_python(self, requirements: list[Requirement] ) -> list[dict[T.Literal["name", "package"], str]]: - """ Check for missing Python dependencies + """Check for missing Python dependencies Parameters ---------- - requirements : list[:class:`packaging.requirements.Requirement]` + requirements The packages that are required to be installed Returns ------- - list[dict[Literal["name", "package"], str]] - List of missing Python packages to install + List of missing Python packages to install """ retval: list[dict[T.Literal["name", "package"], str]] = [] for req in requirements: @@ -302,12 +300,11 @@ def _get_missing_python(self, requirements: list[Requirement] return retval def _get_required_conda(self) -> list[dict[T.Literal["package", "channel"], str]]: - """ Add backend specific packages to Conda required packages + """Add backend specific packages to Conda required packages Returns ------- - list[tuple[Literal["package", "channel"], str]] - List of required Conda package names and the channel to install from + List of required Conda package names and the channel to install from """ retval: list[dict[T.Literal["package", "channel"], str]] = [] assert self._env.backend is not None @@ -328,12 +325,11 @@ def _get_required_conda(self) -> list[dict[T.Literal["package", "channel"], str] return retval def _get_missing_conda(self) -> dict[str, list[dict[T.Literal["name", "package"], str]]]: - """ Check for conda missing dependencies + """Check for conda missing dependencies Returns ------- - dict[str, list[dict[Literal["name", "package"], str]]] - The Conda packages to install grouped by channel + The Conda packages to install grouped by channel """ retval: dict[str, list[dict[T.Literal["name", "package"], str]]] = {} if not self._env.system.is_conda: @@ -375,11 +371,11 @@ def _get_missing_conda(self) -> dict[str, list[dict[T.Literal["name", "package"] class Checks(): # pylint:disable=too-few-public-methods - """ Pre-installation checks + """Pre-installation checks Parameters ---------- - environment : :class:`Environment` + environment Environment class holding information about the running system """ def __init__(self, environment: Environment) -> None: @@ -398,7 +394,7 @@ def __init__(self, environment: Environment) -> None: self._tips.pip() def _rocm_ask_enable(self) -> None: - """ Set backend to 'rocm' if OS is Linux and ROCm support required """ + """Set backend to 'rocm' if OS is Linux and ROCm support required""" if not self._env.system.is_linux: return logger.info("ROCm support:\r\nIf you are using an AMD GPU, then select 'yes'." @@ -423,22 +419,8 @@ def _rocm_ask_enable(self) -> None: logger.info("ROCm Version %s Selected", i) self._env.set_requirements(f"rocm_{i.replace('.', '')}") - def _docker_ask_enable(self) -> None: - """ Enable or disable Docker """ - i = input("Enable Docker? [y/N] ").strip() - if i not in ("", "Y", "y", "n", "N"): - logger.warning("Invalid selection '%s'", i) - self._docker_ask_enable() - return - if i in ("Y", "y"): - logger.info("Docker Enabled") - self._env.enable_docker = True - else: - logger.info("Docker Disabled") - self._env.enable_docker = False - def _cuda_ask_enable(self) -> None: - """ Enable or disable CUDA """ + """Enable or disable CUDA""" i = input("Enable CUDA? [Y/n] ").strip() if i not in ("", "Y", "y", "n", "N"): logger.warning("Invalid selection '%s'", i) @@ -459,39 +441,15 @@ def _cuda_ask_enable(self) -> None: logger.info("CUDA Version %s Selected", i) self._env.set_requirements(f"nvidia_{i}") - def _docker_confirm(self) -> None: - """ Warn if nvidia-docker on non-Linux system """ - logger.warning("Nvidia-Docker is only supported on Linux.\r\n" - "Only CPU is supported in Docker for your system") - self._docker_ask_enable() - if self._env.enable_docker: - logger.warning("CUDA Disabled") - self._env.set_backend("cpu") - - def _docker_tips(self) -> None: - """ Provide tips for Docker use """ - if self._env.backend != "nvidia": - self._tips.docker_no_cuda() - else: - self._tips.docker_cuda() - def _user_input(self) -> None: - """ Get user input for AMD/ROCm/Cuda/Docker """ + """Get user input for AMD/ROCm/Cuda""" if self._env.backend is None: self._rocm_ask_enable() if self._env.backend is None: - self._docker_ask_enable() self._cuda_ask_enable() - if not self._env.system.is_linux and (self._env.enable_docker - and self._env.backend == "nvidia"): - self._docker_confirm() - if self._env.enable_docker: - self._docker_tips() - self._env.set_config() - sys.exit(0) def _check_cuda(self) -> None: - """ Check for Cuda and cuDNN Locations. """ + """Check for Cuda and cuDNN Locations.""" if self._env.backend != "nvidia": logger.debug("Skipping Cuda checks as not enabled") return @@ -518,7 +476,7 @@ def _check_cuda(self) -> None: logger.debug("cuDNN version: %s", self._env.cudnn_version) def _check_rocm(self) -> None: - """ Check for ROCm version """ + """Check for ROCm version""" if self._env.backend != "rocm" or not self._env.system.is_linux: logger.debug("Skipping ROCm checks as not enabled") return @@ -551,11 +509,11 @@ def _check_rocm(self) -> None: class Status(): - """ Simple Status output for intercepting Conda/Pip installs and keeping the terminal clean + """Simple Status output for intercepting Conda/Pip installs and keeping the terminal clean Parameters ---------- - is_conda : bool + is_conda ``True`` if installing packages from Conda. ``False`` if installing from pip """ def __init__(self, is_conda: bool): @@ -571,15 +529,15 @@ def __init__(self, is_conda: bool): r"(?P^\S+)\s+\|\s+(?P\d+\.?\d*\s\w+).*\|\s+(?P\d+)%") def _clear_line(self) -> None: - """ Clear the last printed line from the console """ + """Clear the last printed line from the console""" print(" " * self._max_width, end="\r") def _print(self, line: str) -> None: - """ Clear the last line and print the new line to the console + """Clear the last line and print the new line to the console Parameters ---------- - line : str + line The line to print """ full_line = f"{self._prefix}{line}" @@ -592,17 +550,16 @@ def _print(self, line: str) -> None: print(output, end="\r") def _parse_size(self, size: str) -> float: - """ Parse the string representation of a package size and return as megabytes + """Parse the string representation of a package size and return as megabytes Parameters ---------- - size : str + size The string representation of a package size Returns ------- - float - The size in megabytes + The size in megabytes """ size, unit = size.strip().split(" ", maxsplit=1) if unit.lower() == "b": @@ -616,11 +573,11 @@ def _parse_size(self, size: str) -> float: return float(size) # Should never happen, but to prevent error def _print_conda(self, line: str) -> None: - """ Output progress for Conda installs + """Output progress for Conda installs Parameters ---------- - line : str + line The conda install line to parse """ progress = self._re_conda.match(line) @@ -639,11 +596,11 @@ def _print_conda(self, line: str) -> None: self._print(f"Downloading {count} packages ({total_size:.1f} MB) {prog:.1f}%") def _print_pip(self, line: str) -> None: - """ Output progress for Pip installs + """Output progress for Pip installs Parameters ---------- - line : str + line The pip install line to parse """ if (line.lower().startswith("installing collected packages:") and @@ -664,11 +621,11 @@ def _print_pip(self, line: str) -> None: self._print(f"{last_line} {done:.1f}%") def __call__(self, line: str) -> None: - """ Update the output status with the given line + """Update the output status with the given line Parameters ---------- - line : str + line A cleansed line from either Conda or Pip installers """ if self._is_conda: @@ -677,24 +634,24 @@ def __call__(self, line: str) -> None: self._print_pip(line.strip()) def close(self) -> None: - """ Reset all progress bars and re-enable the cursor """ + """Reset all progress bars and re-enable the cursor """ self._clear_line() class Installer(): - """ Uses the python Subprocess module to install packages. + """Uses the python Subprocess module to install packages. Parameters ---------- - environment : :class:`Environment` + environment Environment class holding information about the running system - packages : list[str] + packages The list of package names that are to be installed - command : list + command The command to run - is_conda : bool + is_conda ``True`` if conda install command is running. ``False`` if pip install command is running - is_gui : bool + is_gui ``True`` if the process is being called from the Faceswap GUI """ def __init__(self, # pylint:disable=too-many-positional-arguments @@ -717,12 +674,12 @@ def __init__(self, # pylint:disable=too-many-positional-arguments @classmethod def _output_information(cls, packages: list[str]): - """ INFO log the packages to be installed, splitting along multiple lines for long package + """INFO log the packages to be installed, splitting along multiple lines for long package lists (68 chars = 79 chars - (log-level spacing + indent)) Parameters ---------- - packages : list[str] + packages The list of package names that are to be installed """ output = "" @@ -742,33 +699,30 @@ def _clean_line(self, text: str) -> str: Parameters ---------- - text : str + text The text to clean Returns ------- - str - The cleansed text + The cleansed text """ clean = self._re_ansi_escape.sub("", text.rstrip()) return ''.join(c for c in clean if c in set(printable)) def _seen_line_log(self, text: str, is_error: bool = False) -> str: - """ Output gets spammed to the log file when conda is waiting/processing. Only log each + """Output gets spammed to the log file when conda is waiting/processing. Only log each unique line once. Parameters ---------- - text : str + text The text to log - is_error : bool, optional + is_error ``True`` if the line comes from an error. Default: ``False`` Returns ------- - str - The cleansed log line - + The cleansed log line """ clean = self._clean_line(text) if clean in self._seen_lines: @@ -779,12 +733,11 @@ def _seen_line_log(self, text: str, is_error: bool = False) -> str: return clean def __call__(self) -> int: - """ Install a package using the Subprocess module + """Install a package using the Subprocess module Returns ------- - int - The return code of the package install process + The return code of the package install process """ with Popen(self._command, bufsize=0, stdout=PIPE, stderr=PIPE) as proc: @@ -813,16 +766,16 @@ def __call__(self) -> int: class Install(): # pylint:disable=too-few-public-methods - """ Handles installation of Faceswap requirements + """Handles installation of Faceswap requirements Parameters ---------- - environment : :class:`Environment` + environment Environment class holding information about the running system - is_gui : bool, Optional + is_gui ``True`` if the caller is the Faceswap GUI. Used to prevent output of progress bars which get scrambled in the GUI - """ + """ def __init__(self, environment: Environment, is_gui: bool = False) -> None: self._env = environment self._is_gui = is_gui @@ -836,7 +789,7 @@ def __init__(self, environment: Environment, is_gui: bool = False) -> None: self._finalize() def _ask_continue(self) -> None: - """ Ask Continue with Install """ + """Ask Continue with Install""" if _InstallState.messages: for msg in _InstallState.messages: logger.warning(msg) @@ -853,13 +806,13 @@ def _ask_continue(self) -> None: def _from_pip(self, packages: list[dict[T.Literal["name", "package"], str]], extra_args: list[str] | None = None) -> None: - """ Install packages from pip + """Install packages from pip Parameters ---------- - packages : list[dict[T.Literal["name", "package"], str] + packages The formatted list of packages to be installed - extra_args : list[str] | None, optional + extra_args Any extra arguments to provide to pip. Default: ``None`` (no extra arguments) """ pip_exe = [sys.executable, @@ -882,19 +835,18 @@ def _from_pip(self, def _from_conda(self, packages: list[dict[T.Literal["name", "package"], str]], channel: str) -> None: - """ Install packages from conda + """Install packages from conda Parameters ---------- - packages : list[dict[T.Literal["name", "package"], str]] + packages The full formatted packages to be installed - channel : str + channel The Conda channel to install from. Returns ------- - bool - ``True`` if the package was successfully installed otherwise ``False`` + ``True`` if the package was successfully installed otherwise ``False`` """ conda = which("conda") assert conda is not None @@ -909,7 +861,7 @@ def _from_conda(self, _InstallState.failed = True def _install_packages(self) -> None: - """ Install the required packages """ + """Install the required packages""" if self._packages.conda: logger.info("Installing Conda packages...") for channel, packages in self._packages.conda.items(): @@ -920,7 +872,7 @@ def _install_packages(self) -> None: self._from_pip(packages, extra_args=self._packages.pip_arguments) def _finalize(self) -> None: - """ Output final information on completion """ + """Output final information on completion""" if self._env.updater: return if not _InstallState.failed: @@ -945,53 +897,10 @@ def _finalize(self) -> None: class Tips(): - """ Display installation Tips """ - @classmethod - def docker_no_cuda(cls) -> None: - """ Output Tips for Docker without Cuda """ - logger.info( - "1. Install Docker from: https://www.docker.com/get-started\n\n" - "2. Enter the Faceswap folder and build the Docker Image For Faceswap:\n" - " docker build -t faceswap-cpu -f Dockerfile.cpu .\n\n" - "3. Launch and enter the Faceswap container:\n" - " a. Headless:\n" - " docker run --rm -it -v ./:/srv faceswap-cpu\n\n" - " b. GUI:\n" - " xhost +local: && \\ \n" - " docker run --rm -it \\ \n" - " -v ./:/srv \\ \n" - " -v /tmp/.X11-unix:/tmp/.X11-unix \\ \n" - " -e DISPLAY=${DISPLAY} \\ \n" - " faceswap-cpu \n") - logger.info("That's all you need to do with docker. Have fun.") - - @classmethod - def docker_cuda(cls) -> None: - """ Output Tips for Docker with Cuda""" - logger.info( - "1. Install Docker from: https://www.docker.com/get-started\n\n" - "2. Install latest CUDA 11 and cuDNN 8 from: https://developer.nvidia.com/cuda-" - "downloads\n\n" - "3. Install the the Nvidia Container Toolkit from https://docs.nvidia.com/datacenter/" - "cloud-native/container-toolkit/latest/install-guide\n\n" - "4. Restart Docker Service\n\n" - "5. Enter the Faceswap folder and build the Docker Image For Faceswap:\n" - " docker build -t faceswap-gpu -f Dockerfile.gpu .\n\n" - "6. Launch and enter the Faceswap container:\n" - " a. Headless:\n" - " docker run --runtime=nvidia --rm -it -v ./:/srv faceswap-gpu\n\n" - " b. GUI:\n" - " xhost +local: && \\ \n" - " docker run --runtime=nvidia --rm -it \\ \n" - " -v ./:/srv \\ \n" - " -v /tmp/.X11-unix:/tmp/.X11-unix \\ \n" - " -e DISPLAY=${DISPLAY} \\ \n" - " faceswap-gpu \n") - logger.info("That's all you need to do with docker. Have fun.") - + """Display installation Tips""" @classmethod def macos(cls) -> None: - """ Output Tips for macOS""" + """Output Tips for macOS""" logger.info( "setup.py does not directly support macOS. The following tips should help:\n\n" "1. Install system dependencies:\n" @@ -1000,7 +909,7 @@ def macos(cls) -> None: @classmethod def pip(cls) -> None: - """ Pip Tips """ + """Pip Tips""" logger.info("1. Install PIP requirements\n" "You may want to execute `chcp 65001` in cmd line\n" "to fix Unicode issues on Windows when installing dependencies") From acdcfa8796c26d47e3610efeae102c9d87b2ad89 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:08:45 +0100 Subject: [PATCH 977/981] bugfix: Villain model. Correctly build lowmem variant --- plugins/train/model/villain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/train/model/villain.py b/plugins/train/model/villain.py index e9081d05f3..541bc529a2 100644 --- a/plugins/train/model/villain.py +++ b/plugins/train/model/villain.py @@ -20,7 +20,7 @@ class Model(OriginalModel): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.input_shape = (128, 128, 3) - self.encoder_dim = 512 if self.low_mem else 1024 + self.encoder_dim = 512 if cfg.lowmem() else 1024 self.kernel_initializer = initializers.RandomNormal(0, 0.02) def encoder(self): From 00a2d77f3d730589d44910b269d4b911e596a974 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:50:43 +0100 Subject: [PATCH 978/981] bugfix: train data loading - resize function for arbitrary channels --- lib/training/data/collate.py | 43 +++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/lib/training/data/collate.py b/lib/training/data/collate.py index 9ddff48823..ba57572112 100644 --- a/lib/training/data/collate.py +++ b/lib/training/data/collate.py @@ -327,6 +327,34 @@ def __repr__(self) -> str: s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items()) return f"{self.__class__.__name__}({s_params})" + def _batch_resize(self, batch: npt.NDArray[np.uint8], size: int) -> npt.NDArray[np.uint8]: + """ Resize a batch of images with arbitrary channel count + + Parameters + ---------- + batch + The batch to resize + size + The destination size + + Returns + ------- + The resized batch + """ + channels = batch.shape[-1] + dims = (size, size) + retval = np.empty((batch.shape[0], size, size, channels), dtype=batch.dtype) + if channels <= 4: + for idx, img in enumerate(batch): + cv2.resize(img, dims, dst=retval[idx], interpolation=cv2.INTER_AREA) + return retval + for idx, img in enumerate(batch): + for start in range(0, channels, 4): + retval[idx, ..., start:start + 4] = cv2.resize(img[..., start:start + 4], + dims, + interpolation=cv2.INTER_AREA) + return retval + def _create_targets(self, batch: npt.NDArray[np.uint8] ) -> tuple[list[torch.Tensor], BatchMeta]: """ Compile target images, with masks, for the model output sizes. @@ -352,16 +380,11 @@ def _create_targets(self, batch: npt.NDArray[np.uint8] self._name, batch.shape) if self._resize_targets: reshaped = [to_float32(batch if batch.shape[1] == size else - np.array([ - cv2.resize(image, - (size, size), - interpolation=cv2.INTER_AREA) - for image in batch - ])).reshape(self._num_inputs, - self._batch_size, - size, - size, - -1).swapaxes(0, 1) + self._batch_resize(batch, size)).reshape(self._num_inputs, + self._batch_size, + size, + size, + -1).swapaxes(0, 1) for size in self._output_sizes] else: reshaped = [to_float32(batch).reshape(self._num_inputs, From 0d4ea274ac2af82c63fc98cb973efceb6ec099de Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:36:19 +0100 Subject: [PATCH 979/981] pin numpy<2.5 due to bug with image metadata loading --- requirements/_requirements_base.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements/_requirements_base.txt b/requirements/_requirements_base.txt index 940c0425e2..bcaa6f77e2 100644 --- a/requirements/_requirements_base.txt +++ b/requirements/_requirements_base.txt @@ -2,7 +2,8 @@ packaging>=26.0 tqdm>=4.67 psutil>=7.2.0 numexpr>=2.14.0 -numpy>=2.4.0 +# TODO issue with numpy 2.5.x loading alignments into DataclassDict.from_dict +numpy>=2.4.0,<2.5.0 opencv-python>=4.13.0 pillow>=12.2.0 scikit-learn>=1.8.0 From 87ac39eb9ebf586b7e576918fe871bdda5ea3806 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:35:30 +0100 Subject: [PATCH 980/981] bugfix: TFLambdaOp to ScalarOp on legacy update --- lib/model/layers.py | 25 ++++++++++++++++++++++++- plugins/train/model/_base/model.py | 4 ++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/model/layers.py b/lib/model/layers.py index 8a1d563087..09cb63ce29 100644 --- a/lib/model/layers.py +++ b/lib/model/layers.py @@ -8,7 +8,7 @@ import sys import typing as T -from keras import InputSpec, Layer, ops, saving +from keras import dtype_policies, InputSpec, Layer, ops, saving from lib.logger import parse_class_init from lib.utils import get_module_objects @@ -743,6 +743,29 @@ def get_config(self): config["value"] = self._value return config + @classmethod + def from_config(cls, config: dict[str, T.Any]): + """ Default Keras does not like our use of 'operation' as a keyword argument, so override + and intercept """ + if "dtype" in config and isinstance(config["dtype"], dict): + config = config.copy() + policy = dtype_policies.deserialize(config["dtype"]) + if (not isinstance(policy, dtype_policies.DTypePolicyMap) + and policy.quantization_mode is None): + policy = policy.name + config["dtype"] = policy + + if not isinstance(config["operation"], str): + config["operation"] = config["operation"].__name__ + + try: + return cls(**config) + except Exception as e: + raise TypeError( # pylint:disable=raise-missing-from + f"Error when deserializing class '{cls.__name__}' using " + f"config={config}.\n\nException encountered: {e}" + ) + # Update layers into Keras custom objects for name_, obj in inspect.getmembers(sys.modules[__name__]): diff --git a/plugins/train/model/_base/model.py b/plugins/train/model/_base/model.py index 9433116d3d..fe47fad049 100644 --- a/plugins/train/model/_base/model.py +++ b/plugins/train/model/_base/model.py @@ -285,8 +285,8 @@ def _output_summary(self) -> None: if idx == 0: parent = model continue - model.summary(print_fn=print_fn) - parent.summary(print_fn=print_fn) + model.summary(print_fn=print_fn, line_length=120) + parent.summary(print_fn=print_fn, line_length=120) def _compile_model(self) -> None: """Legacy from Keras code. Now just load and freeze weights""" From f530cb7508ae670f6474f8a7d9c4df94705cf96b Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:39:25 +0100 Subject: [PATCH 981/981] bugfix: Phaze-A, correctly set mobilenetv3 kwargs --- plugins/train/model/phaze_a.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/train/model/phaze_a.py b/plugins/train/model/phaze_a.py index e10ad642f3..2e9e896ea1 100644 --- a/plugins/train/model/phaze_a.py +++ b/plugins/train/model/phaze_a.py @@ -699,9 +699,12 @@ def _model_kwargs(self) -> dict[str, dict[str, float | int | bool]]: "depth_multiplier": cfg.mobilenet_depth(), "dropout": cfg.mobilenet_dropout()}, "mobilenet_v2": {"alpha": cfg.mobilenet_width()}, - "mobilenet_v3": {"alpha": cfg.mobilenet_width(), - "minimalist": cfg.mobilenet_minimalistic(), - "include_preprocessing": False}} + "mobilenet_v3_small": {"alpha": cfg.mobilenet_width(), + "minimalistic": cfg.mobilenet_minimalistic(), + "include_preprocessing": False}, + "mobilenet_v3_large": {"alpha": cfg.mobilenet_width(), + "minimalistic": cfg.mobilenet_minimalistic(), + "include_preprocessing": False}} @property def _selected_model(self) -> tuple[_EncoderInfo, dict]: